ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

招股说明书 PDF 解析:版面体检、表格抽取与字段校验

2026/9/17 15:08:55 拓冰建站 浏览量
招股说明书 PDF 解析:版面体检、表格抽取与字段校验 简介本资源为浙江三花股份有限公司现三花智控2005年首次公开发行A股招股说明书PDF原件面向投行研究、证券分析、财务尽调及资本市场史研究人群可用于回溯家电制冷元器件龙头的早期资本运作路径与信息披露原貌。文件为单一pdf压缩包约1.25MB内容涵盖路演公告、发行方案网下累计投标询价配售600万股与网上市值配售2400万股、董事声明、特别提示与特别风险提示并详列滚存未分配利润分配、2004年销售收入同比增长70.84%的原因、募集资金投向电子膨胀阀等项目的已投入情况。招股书还完整披露相关行业制约风险、依赖单一市场风险、募集资金投资项目风险及毛利率由34.33%降至28.83%等要点并附财务会计信息章节可直接引用作案例材料。已有315人学习下载适合需要一手发行文件、研究历史估值与行业竞争格局的读者参考。1. 一份招股说明书 PDF为什么能把解析脚本逼到重写投研、风控或者数据团队拿到标题里这份《三花智控三花股份招股说明书》PDF最常见的起点是全选复制粘贴进 Excel标题和正文挤在同一格三列的财务表塌成一列金额和「单位万元」被拆到两页页码和页眉像噪音一样插在句子中间。招股说明书是 A 股文档里版面最复杂的一档双栏正文、跨页长表、合并单元格、目录页点线引导、附录里的扫描页全都有篇幅动辄几百页。想把它变成能检索、能对账、能喂给检索系统的结构化数据一条pdftotext命令远远不够。下面按「先体检版面、再抽正文、再攻表格、最后做字段校验」的顺序把一套可复现的解析流水线拆开讲适合需要批量处理招股书、年报、审计报告的工程师和数据岗。2. 三花股份招股说明书 PDF 的版面体检文本页、表格页、扫描页先分清拿到文件别急着写抽取逻辑先花十分钟做一次逐页体检。体检结论直接决定后面每一步走什么分支文本量大的页走坐标抽取表格密集的页走表格解析器文本量接近零的页只能走图像识别。三类页面混在同一份 400 页文档里是常态尤其是附录中的审计报告、验资报告、专利证书复印件扫描页比例可能到两位数百分比。2.1 三类页面的判据不是「有没有字」而是字符密度很多人用「能复制出多少字符」判断页面类型这个判据在招股说明书上会翻车有些扫描页做了隐藏文本层复制出来是一串乱码或重复字符有些正文页因为大量插图字符数也不高。更稳的做法是同时看三个量——可选中字符数、文本块数量、平均每块字符数。正常正文页一个文本块通常承载 80 个字符以上双栏页每栏各自成块扫描页要么没有文本块要么块内字符数极低且分布零散。第三个量是判断扫描页的关键单看字符总数容易被隐藏文本层骗过去。2.2 用 PyMuPDF 逐页统计文本量和图像量import fitz # 装包名是 pymupdf导入名是 fitz doc fitz.open(sanhua_prospectus.pdf) report [] for i, page in enumerate(doc, start1): text page.get_text(text) # 纯文本用来估算可选中字符量 blocks page.get_text(blocks) # 文本块带坐标用来判断版面 images page.get_images(fullTrue) # 页面资源里的图像对象 chars len(text.strip()) per_block chars / len(blocks) if blocks else 0 report.append({ page: i, chars: chars, blocks: len(blocks), per_block: round(per_block, 1), images: len(images), }) scan_pages [r[page] for r in report if r[chars] 50 or r[per_block] 20] print(疑似扫描页, scan_pages[:20], 共, len(scan_pages), 页)get_text(text)按阅读顺序返回纯文本速度快但双栏页的顺序不保证正确所以它只用来做数量统计不作为最终正文来源。get_text(blocks)返回的是(x0, y0, x1, y1, text, block_no, block_type)元组block_type为 1 表示图像块这个字段在判断「整页是不是一张图」时很有用。get_images(fullTrue)统计的是页面资源里的嵌入对象不是渲染后的图片区域——一页扫描件可能是一个大图也可能是被切成十几条横条的小图所以它只做辅助判据。判据汇总成一张表后面所有分支都按这张表走判据文本页表格页扫描页chars 500 300 50per_block 6020 ~ 60 20images0 ~ 20 ~ 3 1 且覆盖整页主要处理坐标抽文本表格解析器图像识别兜底典型位置业务与技术章节财务会计信息章节附录、证照复印件2.3 页眉页脚和目录页的定位靠「重复字符串」而不是靠坐标硬切页眉页脚的处理不能简单粗暴地按 y 坐标裁掉顶部 60pt 和底部 60pt因为招股说明书里不少页面正文起始位置就在 60pt 附近硬裁会吃掉首行。更稳的策略是统计跨页重复出现的短字符串同一句话在 100 页以上重复出现它几乎不可能是正文。from collections import Counter head_foot Counter() for page in doc: d page.get_text(dict) h page.rect.height for b in d[blocks]: for line in b.get(lines, []): for span in line[spans]: y0, y1 span[bbox][1], span[bbox][3] if y1 60 or y0 h - 60: # 只在页边带内取样 head_foot[span[text].strip()] 1 for text, cnt in head_foot.most_common(8): print(cnt, repr(text))get_text(dict)会把每个字符的字体、字号、坐标都带出来是后面还原章节树的同一个数据源。这里把范围限制在页边带内再统计是为了避免正文里高频出现的词比如「公司」「股份」污染结果。拿到高频串之后把它们加进黑名单抽正文时逐行过滤比坐标裁剪安全得多。目录页的识别更简单连续多页含大量「…」或前后数字编号、「第X节」密集出现基本可以判定为目录单独抽出来当章节树使用。3. 正文抽取把三花股份招股说明书的双栏排版读成一条直线正文抽取的难点从来不是「拿到字」而是「拿到正确顺序的字」。招股说明书的「业务与技术」章节大量使用双栏甚至三栏排版财务附注里还有整宽的表格穿插其中按默认顺序抽出来句子会在左右栏之间来回跳读起来像被剪刀剪碎再随机拼贴。3.1 双栏重排先在文本块坐标上做分栏判断def read_order(page, mid_ratio0.5): 按分栏还原一页的阅读顺序返回纯文本 width page.rect.width mid width * mid_ratio blocks [b for b in page.get_text(blocks) if b[6] 0 and b[4].strip()] full, left, right [], [], [] for b in blocks: if b[0] mid b[2]: full.append(b) # 跨中线的块通栏标题或整宽表格 elif b[2] mid: left.append(b) else: right.append(b) key lambda b: (round(b[1], 1), b[0]) full.sort(keykey); left.sort(keykey); right.sort(keykey) return \n.join(b[4] for b in left right full)这段逻辑是「左栏读完再读右栏通栏块最后处理」的近似。要让它真正可用得先判断这一页到底是不是双栏如果左右两侧的字符量严重失衡比如右侧不到总量的 20%说明这页其实是单栏加边注应该统一按 y 坐标排序否则会把边注全挤到正文后面。mid_ratio默认 0.5遇到左右栏宽不等比如 0.45 / 0.55 的版式时可以按实测的栏间距调整。通栏小标题夹在两栏中间的情况正确做法是按通栏块的 y 坐标把页面切成若干段每段内部再交替读左右栏这一步如果量不大人工核对关键章节的页码即可不必追求全自动。3.2 跨页段落拼接和断行修复PDF 里一个自然段被切成十几行是常态页与页之间还会被页眉页脚隔开。拼接规则的核心是中文行尾如果没有句末标点说明这句话没说完应该和下一行直接连起来如果行尾是句末标点则视为一个段落结束。import re CN_END 。”』 # 句末标点集合 def join_paragraphs(lines, min_len40): out, buf [], for ln in lines: ln ln.strip() if not ln: continue if re.match(r^第[一二三四五六七八九十百][章节], ln): if buf: out.append(buf); buf out.append(ln) # 章节标题单独成行 continue buf ln if buf.endswith(tuple(CN_END)) and len(buf) min_len: out.append(buf); buf if buf: out.append(buf) return outmin_len40这个阈值是为了防止把「一」「如下」这类短行当作完整段落收尾实际调参时看抽出来的段落长度分布如果出现大量 5 到 10 字的碎片段就把阈值调高。另一个常见坑是数字断行金额被切成「1,234,」和「567.89」两行拼接时中间不加空格否则会污染后续的数值解析。3.3 用字号和编号规则还原章节树招股说明书的章节层级在版面上是很规整的字号和编号方式能直接映射成层级字号(pt)是否加粗编号形式映射层级18 ~ 22是无编号一级章14 ~ 16是第X节 / 一、二级节12是1.1 / 一三级小节10.5否无正文实现上遍历get_text(dict)的 span把符合上表的行抽出来按出现顺序串成树。这里有个必须处理的干扰项表格里的表头文字字号往往也是 12pt 加粗如果不先排除表格区域用表格解析器给出的 bbox 做差集章节树里会混进一堆「项目 / 本期金额 / 上期金额」。做完之后把章节树和目录页抽出来的目录做一次比对两边条目数量对不上就说明有章节被漏掉或者被误判。4. 表格提取三花股份招股说明书里的财务表怎么抽干净表格是这份文档里价值最高的部分也是最容易出错的部分。招股说明书的表格大致分三类有完整竖线横线的规整财务表、只有上下框线的排版表、以及完全没有框线只靠空格对齐的表。三类表格用同一套参数去抽结果一定参差不齐。4.1 pdfplumber 表格策略与三个必调参数import pdfplumber TABLE_SETTINGS { vertical_strategy: lines, # 竖线策略有竖线就用线条 horizontal_strategy: lines, snap_tolerance: 4, # 线偏移 4pt 内视为同一条线 join_tolerance: 4, # 断线拼接的容差 edge_min_length: 20, # 短于 20pt 的线段当噪声丢掉 intersection_tolerance: 4, # 交点判定的容差 } with pdfplumber.open(sanhua_prospectus.pdf) as pdf: for pno in range(40, 60): # 先在小页码区间试参数别一上来全量跑 page pdf.pages[pno] for t in page.extract_tables(TABLE_SETTINGS): for row in t: print([ if c is None else c.replace(\n, ) for c in row])snap_tolerance和join_tolerance是这套参数里最需要动手调的两个前者管的是「两条几乎重合的线算不算一条」值太小会把同一根粗线识别成两条导致多出一列空列后者管的是「断开的线段要不要接起来」扫描件转出来的表格线经常断成几截值太小整张表会被拆成碎片。intersection_tolerance决定横竖线交叉到多近才算一个单元格角点噪声多的文档可以适当放大到 6。策略组合按表格形态选表格形态vertical_strategyhorizontal_strategy有完整网格线lineslines只有上下边框textlines完全无线靠间距对齐texttext4.2 跨页表格合并与表头去重一张资产负债表横跨两三页是常规操作而且每一页都会重复表头。合并的逻辑是「列数一致才拼重复表头才去」def merge_tables(tables): 跨页续表合并列数不一致直接跳过表头重复则去掉 if not tables: return [] merged tables[0] head0 [( if c is None else c.strip()) for c in merged[0]] for nxt in tables[1:]: if not nxt or len(nxt[0]) ! len(head0): continue head [( if c is None else c.strip()) for c in nxt[0]] if head head0: # 与首行完全一致判为重复表头 nxt nxt[1:] merged.extend(nxt) return merged列数校验是最省事也最有效的护栏招股说明书里相邻两张表列数不同的情况很常见不做校验就会把两张不相关的表拼成一张。表头比较用「完全一致」而不是相似度是因为财务表的表头用词高度固定一旦出现近似但不一致的比如「本期金额」和「上期金额」那就是新表而不是续表。合并单元格的处理要单独说extract_tables遇到合并单元格时通常只在左上角填值其余位置返回None。对这种行做向下填充还是向下补空取决于你要做什么分析——做结构化存储时向下填充更省事做原样导出时保留None更忠实。4.3 数字归一化千分位、括号负数和单位换算招股说明书里的负数有两种写法短横线负号和带括号括号形式在财务附注里更常见。单位也分「元」「万元」「千元」三种而且单位常常写在表格标题上方而不是单元格里。import re def to_number(s, unit元): 把招股说明书里的金额字符串转成 float失败返回 None if s is None: return None t str(s).strip().replace(,, ).replace(, ) t t.replace(, -).replace(—, -) # 全角负号与破折号 neg t.startswith(() and t.endswith()) # 括号表示负数 if neg: t t[1:-1] t re.sub(r[^\d.\-], , t) # 去掉残留的单位字符 if t in (, -, ., -.): return None v float(t) if unit 万元: v * 10000 elif unit 千元: v * 1000 return -v if neg else vunit参数需要从表格上方 200 字符范围内正则匹配「单位万元」这类文字后传入不要靠固定值假定。归一化之后建议保留原始字符串把数值和原文一起入库后面做校验时才有回查的依据。5. 字段抽取与校验把三花股份招股说明书变成可检索数据集正文和表格都抽出来之后真正的目标是抽出少量关键指标营业收入、毛利率、资产负债率、研发投入这类。抽取本身不难难的是让结果可信。5.1 正则加上下文窗口抽指标import re PATTERNS { 营业收入: r营业收入[^\d\-]{0,20}?([\d,]\.?\d*), 毛利率: r毛利率[^\d\-]{0,20}?(-?[\d.])\s*%, 资产负债率: r资产负债率[^\d\-]{0,20}?(-?[\d.])\s*%, } def extract(text, name): m re.search(PATTERNS[name], text) return m.group(1) if m else None{0,20}这个限定窗口是整个正则的核心如果写成.*?一个「营业收入」可能一路跳到几百字后另一个表的数字上。20 个字符大致覆盖「万元」「本期比上年同期增减」这类夹在中间的修饰语超过就说明匹配错位了。百分号必须写进正则否则「毛利率 32.5」和「毛利率 32.5%」会被混为一谈而招股说明书里「同比增减 32.5」这种不带百分号的数字到处都是。5.2 跨章节交叉校验同一个指标出现两次就该对账招股说明书的特性是同一个数字会在多个章节重复出现「概览」章节说一遍正文说一遍财务会计信息章节再说一遍。这三处的数字必须一致不一致要么是抽取错了要么是单位没统一。入库之后直接跑一句 SQL 就能把问题捞出来SELECT metric, COUNT(DISTINCT value) AS distinct_values FROM metric GROUP BY metric HAVING distinct_values 1 ORDER BY distinct_values DESC;distinct_values大于 1 的指标全部拉出来人工看一遍八成集中在单位换算和括号负数这两类问题上。另一个常见的不一致来源是「合并口径」和「母公司口径」两张表的数据被抽到同一个指标名下解决办法是在抽取时把章节路径一起记下来不同口径分别建指标名。5.3 落地成 JSONL 与 SQLite 检索表字段类型说明docTEXT文档标识便于多份招股书共存chapterTEXT章节路径用来区分口径metricTEXT指标名valueREAL归一化后的数值unitTEXT原始单位pageINTEGER页码用于回查rawTEXT原始字符串import sqlite3 conn sqlite3.connect(sanhua.db) conn.execute(CREATE TABLE IF NOT EXISTS metric( doc TEXT, chapter TEXT, metric TEXT, value REAL, unit TEXT, page INTEGER, raw TEXT, PRIMARY KEY(doc, chapter, metric, page))) conn.executemany(INSERT OR REPLACE INTO metric VALUES (?,?,?,?,?,?,?), rows) conn.commit()page字段一定要留出错时能一键跳回原文核对。要做全文检索的话正文按章节切成 500 到 800 字的块块内保留章节路径和页码索引到检索系统里切块时不要把表格切开表格作为独立块存入并带上表名。6. 进阶三花股份招股说明书的扫描页兜底与抽取质量验证前面所有流程的前提是页面有可选中的文本。附录部分不满足这个前提时就得走图像识别但走之前先确认这一步值不值得。6.1 哪些页必须走 OCR哪些页走了反而更差判断标准是第 2 章统计出来的per_block。真正需要走图像识别的页面通常是整页渲染成图、没有任何文本块的那些如果一页已经有 300 个字符的文本层只是某张图片里的表格没抽出来那走图像识别大概率得不偿失——识别结果和已有文本层打架还要额外做去重。一个实用的取舍是只对chars 50的页面走图像识别其余页面一律用文本层。识别时先用版面分析把整页切成文本区、表格区、图片区表格区单独走带结构还原的模型别用整页通用识别硬跑那样出来的表格是一堆按行打散的文本重建列关系的成本比手工录入还高。识别结果入库时给一个source字段标明来源是文本层还是识别层后续对账时能区分优先级。6.2 用抽样回读和指标对账验证抽取质量全量人工核对不现实抽样核对是性价比最高的办法具体分三步。第一步是结构抽样从章节树里随机抽 10 个叶子节点把抽取出来的正文和原 PDF 对应页并排看重点看段落有没有串栏、表格有没有混进正文。这一步能捞出八成以上的版面类错误。第二步是指标对账把抽出来的「营业收入」和「净利润」按年排成序列检查同比增减幅度是否和文档里写的「同比增长 X%」对得上。算一遍(本期 - 上期) / 上期和原文的增减率对比差超过 0.1 个百分点就说明有数字抽错了或者单位不统一。def check_growth(cur, prev, reported_pct, tol0.1): cur/prev 为归一化后的数值reported_pct 为原文写的增减百分比 if not prev: return None calc (cur - prev) / abs(prev) * 100 return abs(calc - reported_pct) tol, round(calc, 2)tol0.1是按原文保留一位小数的习惯设的如果原文只写整数百分比把容差放宽到 1 更合适。第三步是页码回查率统计入库记录里page字段能成功定位到原文的比例低于 95% 就说明有页面在抽取过程中被丢了通常是跨页表格合并时列数校验拦掉的那部分。这三步跑完一份招股说明书的数据集才算能放心交给下游用。本文还有配套的精品资源点击获取