资讯动态

A股招股书PDF解析:跨页表格抽取与检索索引构建

发布时间:2026/9/17 15:48:17 来源:尧图企业网站定制
简介这份资源是三花智控原浙江三花股份有限公司2005年首次公开发行A股时的招股说明书PDF面向关注家电零部件行业、拟研究上市公司历史沿革与资本运作路径的投资研究者、券商投行从业者及财经院校师生。文件为单一PDF体量约1.25MB完整收录了路演公告与招股说明书正文包括发行数量3000万股、网下累计投标询价配售600万股与网上市值配售2400万股的发行安排以及保荐机构东北证券、拟上市地深圳证券交易所等关键信息。正文覆盖发行人董事声明、滚存未分配利润分配方案、2004年销售收入同比增长70.84%的业绩说明并系统披露了行业制约风险、依赖截止阀单一产品风险、电子膨胀阀募投项目产能扩张十倍带来的投资风险以及铜棒涨价与出口退税率由17%降至13%导致毛利率下滑等特别风险提示。目前已有315人学习下载适合作为研究制冷空调控制元器件企业早期经营面貌与招股书文本结构的一手材料。1. 一份 2005 年的 A 股招股书为什么值得拿来练文档解析招股说明书这种东西纸面上是法律文件落到工程上是一份排版密集、表格跨页、还夹着扫描件的超长 PDF。这份三花股份 2005 年的招股说明书就是典型样本正文一百多页目录里能翻到第九章 财务会计信息这类章节正文里塞着截止阀 2004 年销售收入占主营业务收入的 69%毛利率由 34.33% 下降到 28.83%这样的数字还有一大片在 80 页之后才出现的财务表格。它有文本层但不干净有表格但不规范有目录但没有书签。拿它练手的人大致三类做金融文档解析要往数据库里灌结构化字段的做本地知识库检索要把长文档切块建索引的还有单纯想搞清楚 pdfplumber、PyMuPDF 这类库到底差在哪的。往下写的全是文档工程视角的东西不涉及任何投资判断重点是怎么把这一类 PDF 拆成能进数据库、能被打分的结构化数据以及每一步会在什么地方翻车。2. 文本层可用性判断与版面坐标解析拿到一份一百多页的招股书 PDF最忌讳的动作是直接pdftotext一把梭然后发现目录和正文糊在一起、表头和数据行错位。先花两分钟确认这份文件到底有没有可用的文本层。2005 年前后的招股书通常有两种来源一种是排版软件直接导出的矢量 PDF每个字符都带坐标另一种是纸质件扫描后合成的页面上只有一张大图。两者的处理路径完全不同前者走坐标解析加正则后者必须先做 OCROCR 之后再谈结构还原。2.1 用字符数与图片数给每页打标签判断成本极低遍历一遍页面输出每页的纯文本长度和图片数量分布一眼就能看出来。文本页的字符数会在几百到几千之间波动扫描页通常是零到几十同时带一张覆盖整页的图。import fitz # PyMuPDFpip install pymupdf doc fitz.open(sanhua_2005_ipo.pdf) for pno, page in enumerate(doc): text page.get_text(text) # 纯文本流保留换行 imgs page.get_images(fullTrue) # 每张图返回一个元组列表 flag 扫描页 if len(text) 50 and imgs else 文本页 print(f{pno:4} {flag} chars{len(text):6} imgs{len(imgs)} {text[:20]!r})get_text()的参数决定输出粒度text是扁平字符串blocks带块级坐标dict会给出 span 和 font 信息rawdict连每个字符的包围盒都有。做页级标签只需要text成本最低。get_images(fullTrue)比get_images()多返回 xref 和尺寸信息判断是不是整页扫描图时用得上——如果图片宽度接近page.rect.width基本可以确认这一页就是图。注意混排页面很常见页眉是文本、正文是扫描图或者反过来。所以阈值不要卡死len(text) 50只是一个初筛真正的判定要结合图片面积占比。2.2 字符级坐标还原阅读顺序page.get_text()出来的顺序是按 PDF 内容流写的遇到双栏排版或者页边有批注时阅读顺序会被打乱。招股书的风险因素财务会计信息这些章节经常出现左栏正文加右侧小字注释的版式直接抽文本会串行。稳妥做法是下沉到字符级按纵向坐标聚类成行再按横向坐标排序。import pdfplumber # pip install pdfplumber with pdfplumber.open(sanhua_2005_ipo.pdf) as pdf: page pdf.pages[26] # 页码从 0 开始 rows {} for c in page.chars: # 每个字符都是独立对象 key round(c[top], 1) # 用 top 聚类行距通常 10~14pt rows.setdefault(key, []).append(c) for key in sorted(rows): line .join(c[text] for c in sorted(rows[key], keylambda x: x[x0])) print(f{key:7.1f} | {line})pdfplumber的坐标系原点在左上角top向下递增这一点和PyMuPDF的y一致但和 PDF 原始坐标原点在左下角相反好在两个库都替你做了翻转。round(c[top], 1)里的阈值是经验值正文行距一般在 10 到 14 磅之间保留一位小数足够把同一行的字符聚到一起又不至于把相邻两行合并。如果文档里混了上下标可以改成基于bottom - top的高度过滤把字号明显偏小的字符单独处理。2.3 字体、字号与乱码的排查顺序字号分布是定位标题层级最便宜的信号字体名则用来判断有没有缺映射。子集嵌入的字体名会带ABCDEF这样的前缀属于正常现象不影响抽取。真正麻烦的是 CID 字体缺少ToUnicode映射抽出来是(cid:123)这类占位符。from collections import Counter import pdfplumber with pdfplumber.open(sanhua_2005_ipo.pdf) as pdf: page pdf.pages[26] print(字号分布:, Counter(round(c[size], 1) for c in page.chars).most_common(5)) print(字体:, Counter(c[fontname] for c in page.chars).most_common(5)) empty sum(1 for c in page.chars if not c[text]) print(空字符比例:, round(empty / max(len(page.chars), 1), 4))正文通常在 9 到 10.5 磅章节标题 12 磅以上目录页会出现大量点线引导符。空字符比例超过 5%说明这个库拿不到字符映射换PyMuPDF的rawdict再试一次两边都拿不到就只能走 OCR。工具依赖文本层表格能力更适合的场景pdfplumber是中上线框表格强逐字符坐标、版面重建、少量表格PyMuPDF是弱快速遍历、整页渲染、OCR 前置pypdf是无页数、元数据、拆分合并Camelot是强有明确框线的规则表格PaddleOCR / Tesseract否中扫描页兜底、混排页补漏3. 跨页表格与关键财务指标的抽取落地这份招股书里真正有价值的结构化数据集中在两处一处是特别提示里的散排数字比如 2004 年销售收入 36,412.90 万元、比 2003 年的 21,313.94 万元增长 70.84%另一处是 80 页之后的财务表格。两类数据得用两套方法处理混着来一定出错。3.1 extract_table 的策略选择与关键参数pdfplumber的表格检测有两种策略lines靠页面上的矢量线text靠文字对齐推断列边界。招股书的财务表基本都带完整框线优先用lines准确率高且不需要猜列数。参数里最影响结果的是吸附和拼接阈值。import pdfplumber table_settings { vertical_strategy: lines, # 有线框就用 lines无线改 text horizontal_strategy: lines, snap_tolerance: 3, # 相邻线吸附阈值扫描件可放大到 5 join_tolerance: 3, # 断线拼接阈值线框有缺口时调大 edge_min_length: 20, # 过滤页眉页脚处的短横线 intersection_tolerance: 5, # 交点容差决定单元格是否闭合 } with pdfplumber.open(sanhua_2005_ipo.pdf) as pdf: for pno in range(80, 92): # 财务会计信息所在页区间 for t in pdf.pages[pno].extract_tables(table_settingstable_settings): head .join((c or ) for c in t[0]) if any(k in head for k in (毛利, 项目, 年度)): print(pno, len(t), head)edge_min_length是最容易被忽略的参数。招股书页眉有一条贯穿整页的横线如果不过滤掉它会被当成表格上边界抽出来的第一行全是页眉里的公司名和页码。intersection_tolerance调小会让单元格判定变严格遇到印刷质量差的页会大量丢格调到 5 以上则容易把邻近的两个表格并成一个。3.2 跨页表格的拼接与表头归一财务表动辄跨两三页续页通常不重复表头。直接逐页抽得到的是一堆列名缺失的行。常见做法是维护一个最近一次出现的表头遇到没有表头的页就沿用上一次的同时记录页码方便回查。def stitch_tables(pages_tables): pages_tables: [(页码, 行列表)]行列表第一行可能是表头 out, header [], None for pno, rows in pages_tables: if not rows: continue first [(c or ).strip() for c in rows[0]] joined .join(first) if any(k in joined for k in (项目, 年度, 科目, 年末余额)): header, body first, rows[1:] else: body rows # 续页沿用上一次表头 if header is None: continue # 表头还没出现跳过孤立行 for r in body: width min(len(header), len(r)) # 合并单元格会造成列数变少 out.append({page: pno, **{header[i]: (r[i] or ).strip() for i in range(width)}}) return outlen(header)和len(r)不相等是常态合并单元格会让续页的行少一列。用min兜底只是让它别崩更严谨的做法是把列数不一致的行单独输出到一个suspect列表里人工确认后再决定是补空还是丢弃。表头关键词要按你实际抽的章节调整招股书不同章节的表头风格差别不小。3.3 正文散排数字的正则抓取毛利率由 2003 年度的 34.33% 下降到 2004 年的 28.83%这类句子里的数字走表格抽取是抽不到的只能用正则。难点在于 PDF 抽出来的文本会在数字中间插换行或者把全角括号、全角空格混进来所以正则之前必须先归一化。import re NUM r[-]?\d{1,3}(?:,\d{3})*(?:\.\d)? def normalize(s: str) - str: return (s.replace(\u3000, ) # 全角空格 .replace(\u00ad, ) # 软连字符PDF 换行处常见 .replace(\n, ) .replace( , ) .replace(, ().replace(, ))) def pick_metrics(text: str) - dict: t normalize(text) out {} m re.search(rf毛利率由(\d{{4}})年度的({NUM})%下降到(\d{{4}})年的({NUM})%, t) if m: out[gross_margin] {m.group(1): float(m.group(2)), m.group(3): float(m.group(4))} m re.search(r截止阀(\d{4})年销售收入占(?:公司)?主营业务收入的?(\d(?:\.\d)?)%, t) if m: out[valve_share] {year: int(m.group(1)), pct: float(m.group(2))} return outNUM里把千分位逗号写进去了因为 36,412.90 这种写法在财务报表里是硬要求不处理就会被切成两段。归一化阶段一次性清掉全角空格、软连字符和换行比在每个正则里各写一遍补丁省事得多而且后续所有抽取逻辑都能复用。指标原文位置抽取方式易错点销售收入与同比特别提示正则千分位逗号、单位万元毛利率变化特别提示、风险因素正则年份和百分比跨行未分配利润特别提示正则元与万元混用分产品收入占比业务和技术表格合并单元格导致列错位产能与募投金额募投项目章节表格加正则单位万套与万只混用4. 章节切分与本地检索索引构建抽出来的文本如果只是堆在一个txt里等于没处理。要让这份招股书能被检索、能被引用、能接到问答流程里需要两步按语义边界切块然后建一个能快速定位的索引。4.1 按章节目录而不是按字数切块固定 512 字符滑窗是最省事的切法但对招股书这种带明确章节结构的文档是浪费。目录里第三章 风险因素第四章 发行人基本情况第九章 财务会计信息这些标题在正文里是完整的行用一条正则就能切出章节级块再在块内按段落二次切分。import re HEAD re.compile(r^第[一二三四五六七八九十]章\s*(\S)) def split_chunks(pages_text): pages_text: [(页码, 整页文本)]返回章节级块 chunks, cur [], {chapter: 封面与声明, start: 0, text: } for pno, text in pages_text: for line in text.split(\n): s line.strip() if HEAD.match(s) and len(s) 30: # 排除目录页的引导行 if cur[text].strip(): chunks.append(cur) cur {chapter: s, start: pno, text: } cur[text] line \n if cur[text].strip(): chunks.append(cur) return chunkslen(s) 30这一条是必须的否则目录页里第三章 风险因素......26这种带页码的引导行也会被当成章节起点切出一堆空块。章节块内部如果超过两三千字再按空行切段落保留段首的层级标记检索时能还原出这是第几章第几节的内容。4.2 SQLite FTS5 建索引与中文分词的坑不想引入 Elasticsearch 这种重家伙SQLite 的 FTS5 足够撑住一份几百页文档。但默认的unicode61分词器对中文不友好它按空白和标点切词一整句中文会变成一个大 token搜电子膨胀阀 产能基本命不中。SQLite 3.34 以后可以用trigram分词器按三字符滑窗建索引中文子串匹配才正常。import sqlite3 con sqlite3.connect(sanhua.db) con.execute(CREATE VIRTUAL TABLE IF NOT EXISTS doc USING fts5(chapter, text, tokenizetrigram)) con.executemany(INSERT INTO doc(chapter, text) VALUES (?, ?), [(c[chapter], c[text][:50000]) for c in chunks]) con.commit() q 电子膨胀阀产能 sql SELECT chapter, snippet(doc, 1, [, ], ..., 16) FROM doc WHERE doc MATCH ? ORDER BY rank LIMIT 5 for row in con.execute(sql, (q,)): print(row)trigram的硬限制是查询串至少要三个字符搜毛利这种两字词会直接报错得退回到LIKE %毛利%或者改用unicode61配合预分词。snippet()的第五个参数是片段长度单位是 token 数中文场景下 16 到 32 比较合适太短看不全上下文太长日志刷屏。ORDER BY rank走的是 BM25比按插入顺序返回靠谱得多。4.3 块粒度与召回质量的权衡块切多大直接决定召回质量。章节级块上下文完整但一个块里可能同时讲毛利率和应收账款噪声大段落级块干净但容易丢掉上述项目这类指代。常见做法是做父子块父块是章节子块是段落检索命中子块后返回父块的章节标题和页码。切块策略典型大小优点代价按章切3000~8000 字上下文完整便于引用噪声大单块超模型上下文按节切800~2500 字结构清晰标题即元数据需要能识别到二级标题按段切150~500 字纯净召回精度高指代丢失需父子块补回固定滑窗512 token实现最简单会在表格中间切断如果要在本地做语义检索中文小模型是够用的常见做法是选一个参数量在千万级、CPU 上也能跑的模型把每个子块编码成向量存进 FAISS 或者 sqlite-vec关键词检索和向量检索各跑一遍再合并去重。招股书这种术语密度高的文档关键词检索的召回往往比向量更稳两路并用比较合理。5. 抽取结果的数值校验与增量比对技巧抽取脚本跑通不等于数据能用。财务数据之间往往存在恒等关系拿这些关系做交叉校验比人眼抽查一百页要快得多。5.1 用文档自带的等式做校验这份招股书里有一组现成的数字关系截至 2004 年 12 月 31 日累计未分配利润 56,830,371.22 元2004 年度每 10 股派发现金股利 1.80 元、共计分配 1,494 万元派发后新老股东共享的滚存未分配利润为 41,890,371.22 元。三个数之间应该严格成立减法关系抽错了立刻能发现。from decimal import Decimal def d(s: str) - Decimal: return Decimal(s.replace(,, )) def check_retained(total_undistributed, paid_dividend, shared_after): diff d(total_undistributed) - d(paid_dividend) - d(shared_after) return diff 0, diff ok, diff check_retained(56,830,371.22, 14,940,000.00, 41,890,371.22) print(一致: , ok, 差值:, diff) # 一致: True 差值: 0.00必须用Decimal而不是float。招股书里的金额精确到分float在这种量级上做减法会带出 1e-10 级别的尾差校验逻辑会被误报淹没。同时单位要统一正文里1,494 万元和14,940,000.00 元是同一个数抽取阶段就该转成同一种单位别留到校验阶段再猜。这类恒等式在每个章节里都能找到几组分产品收入之和等于主营业务收入各年度收入加上同比增幅能反推上一年募投项目已投入金额之和不超过项目总投资。把能找到的关系都写成断言任何一条不成立就打印页码和原文片段人工回看那几页即可。5.2 页眉页脚清洗与内容哈希比对页眉页脚是最顽固的噪声源。同一份招股书里每页都有三花股份 首次公开发行 A 股招股说明书加上页码这些内容会跟着正文一起进索引搜什么都命中。清洗规则可以用频次统计自动生成把出现在超过 80% 页面上的短行收集起来列成黑名单在切块前剔除比手写正则稳。增量比对则靠哈希。给每个页面的归一化文本算一次 SHA-256存成{页码: 哈希}的清单下次拿到更新版本或者重新扫描的同一份文件只要比对哈希就能定位到哪些页变了只重抽变化页省掉整本重跑的开销。用difflib.SequenceMatcher对变化页做字符级 diff还能看出是排版微调还是数字被改过。最后一个小技巧值得单独拎出来归一化函数一定要在整条链路的最前面调用一次把\u00ad、\u3000、换行和全角括号统一清干净后面所有正则、哈希、切块逻辑都基于同一份干净文本。把这类不可见字符在入口处处理完比在十几个地方各写一遍补丁要省事得多。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价