简介:《天鹅股份:首次公开发行股票招股说明书.PDF》是山东天鹅棉业机械股份有限公司首次公开发行A股的正式披露文件,面向投行、法律、财务、证券研究及关注棉业机械行业的投资者。文件完整呈现发行概况:拟发行不超过2334万股,每股面值1元,发行价8.93元,预计2016年4月15日发行,拟登陆上海证券交易所,发行后总股本9334万股。同时收录控股股东山东省供销合作社联合社36个月锁定承诺、其他股东12个月锁定承诺、董监高及核心技术人员减持限制、中德证券先行赔付承诺与发行人声明,便于读者梳理IPO审核要点、股份流通限制、公司治理和投资风险。资源包共1个PDF文件,大小约5.96MB,结构清晰,已有256人学习下载,适合作为招股书研读、投融资尽调与行业案例检索的参考资料。
1. 一份招股说明书PDF,为什么成了文档解析的硬骨头
拿到「天鹅股份:首次公开发行股票招股说明书.PDF」这类文件,多数人的第一反应是 Ctrl+F,第二反应是整本丢给大模型。两次都会失望:前者在三百多页、正文五号宋体、表格字号更小的文档里只能捞到零散词条;后者会因为上下文超长而丢掉中间章节,答案还带不出页码,无法回溯核对。招股说明书PDF的特殊性在于它同时是排版文档和数据仓库——「释义」「概览」「业务与技术」是连续散文,董监高薪酬、关联交易、审计报告附注是密集数字表,两者混排、跨页断裂、页眉页脚穿插。写这份材料的工程师通常在做三件事:把财务数据抽成结构化表、把全文做成可检索知识库、把问答结果锚回原文页码。后面几章按「先看清版面,再切章节,再抽数字,最后做检索」的顺序推演,每一步都给可复现的命令和参数。
2. 招股说明书PDF的文本层判定与坐标级版面还原
2.1 先分清这份PDF是文本版还是扫描版
这一步跳过,后面全是无用功。文本版能直接抽出字符和坐标,扫描版必须先走 OCR,两者的表格识别策略完全不同。用 PyMuPDF 十行代码就能判定,别急着上大模型。
import fitz # PyMuPDF,包名是 fitz doc = fitz.open("天鹅股份_招股说明书.pdf") print("页数:", doc.page_count, "是否加密:", doc.needs_pass) empty_pages, char_total = [], 0 for i, page in enumerate(doc): text = page.get_text("text").strip() # 按阅读顺序拼文本 char_total += len(text) # 一页抽不出 20 个字符,但页面里又存在图像对象,基本可判为扫描页 if len(text) < 20 and page.get_images(full=True): empty_pages.append(i) print("总字符数:", char_total, "疑似扫描页:", len(empty_pages), empty_pages[:10]) print("平均每页字符:", round(char_total / doc.page_count, 1))get_text()的三个常用模式要分清楚:"text"只回字符串,适合统计和全文检索;"blocks"回(x0, y0, x1, y1, text, block_no, block_type)七元组,适合做段落合并;"dict"回嵌套字典,能拿到每个 span 的字号、字体和 flags,是做标题识别的唯一选择。page.get_images(full=True)返回图像对象列表,full=True才会带上 xref 等信息。判定阈值按经验取:平均每页字符数低于 200 且疑似扫描页占比超过 30%,就直接转 OCR 流程,用 PaddleOCR 或 ocrmypdf 先做一层文本覆盖,不要指望后续脚本能凭空变出文字。
| 判定信号 | 文本版特征 | 扫描版特征 | 处理动作 |
|---|---|---|---|
| 平均每页字符数 | 800 以上 | 低于 200 | 低于阈值走 OCR |
page.get_images() | 少量或无 | 每页一个大图 | 走 OCR |
pdffonts输出 | 有嵌入字体列表 | 空 | 空则走 OCR |
| 复制粘贴效果 | 文字可选 | 只能选中整页 | 走 OCR |
这条命令在 Linux/macOS 下用 poppler-utils 更快验证一遍:pdffonts 招股说明书.pdf | head -20,输出为空基本就是扫描件;再用pdfinfo 招股说明书.pdf | grep -E "Pages|Encrypted"确认页数与是否加密。
2.2 用坐标块还原段落顺序,顺便干掉页眉页脚
招股说明书里「释义」表是左右两栏,「董监高情况」是姓名加职务两栏,直接get_text("text")出来的顺序经常串行。稳妥做法是按块取文本,用 y 坐标做主排序键、x 坐标做次排序键,并且把页面上下边缘的重复内容剔掉。
import fitz PAGE_TOP_CUT, PAGE_BOTTOM_CUT = 60, 60 # 页眉页脚带的高度,按实际版心调 def page_blocks(page, y_tol=5): h = page.rect.height blocks = page.get_text("blocks") kept = [] for b in blocks: x0, y0, x1, y1, text, _, btype = b if btype != 0: # 0 是文本块,1 是图像块 continue text = text.strip() if not text: continue if y0 < PAGE_TOP_CUT or y1 > h - PAGE_BOTTOM_CUT: continue # 丢掉页眉页脚 kept.append((x0, y0, text)) # 先按 y 归并到 y_tol 容差的一行,再按 x 从左到右排 kept.sort(key=lambda t: (round(t[1] / y_tol), t[0])) return [t[2] for t in kept] doc = fitz.open("天鹅股份_招股说明书.pdf") for line in page_blocks(doc[88])[:15]: print(repr(line[:60]))y_tol=5是关键参数:同一行的左右两栏 y 坐标通常差不到 2 个点,跨行的间距一般在 12 个点以上,取 5 能把「同一行」和「下一行」分开。PAGE_TOP_CUT设小了页眉会混进正文,设大了首行正文会被吃掉,招股说明书版心一般上下留白 50 到 70 点,先打印前 20 个块的y0观察一下再定。这个函数返回的是行列表,做章节切分前先跑一遍全文,把每行的页码、行号一起存下来,后面做引用回溯全靠它。
提示:不要用
page.get_text("text")的输出做章节切分,那个顺序在双栏页面上不可靠,切出来的「第X节」和正文会错位。
2.3 表格抽取:pdfplumber 的线框策略什么时候失效
有完整框线的表格用 pdfplumber 的 lines 策略最稳,无框线的靠 text 策略猜列边界。招股说明书的财务附注表格九成带框线,但「发行人股权结构图」这类半框表格会翻车。
import pdfplumber with pdfplumber.open("天鹅股份_招股说明书.pdf") as pdf: page = pdf.pages[104] tables = page.extract_tables({ "vertical_strategy": "lines", # 竖线由实际线条决定 "horizontal_strategy": "lines", # 横线同理 "snap_tolerance": 3, # 断开的线在 3 点内吸附成一条 "join_tolerance": 3, "edge_min_length": 5, # 短于 5 点的线忽略,去掉装饰线段 "intersection_tolerance": 3, }) for t in tables: for row in t[:6]: print([None if c is None else c.replace("\n", "") for c in row])四个容差参数的作用是消化矢量线条的微小错位。招股说明书里表格线经常被打断成多段,snap_tolerance和join_tolerance设在 3 到 5 之间能自动接上;设太大会把相邻两个表粘成一个。edge_min_length用来过滤表格外的横线装饰。如果输出列数明显不对,先把vertical_strategy换成"text"再跑一遍对比:text 策略按字符间距推断列边界,对无框线的「分地区收入构成」表更合适。两条策略都试过还是乱,就退回 2.2 的坐标块方案,用 x0 聚类自己分列。
2.4 提速与两个高频报错
三百页文档逐页抽表格,单进程要跑几分钟到十几分钟。按页区间切给多进程是性价比最高的优化,PyMuPDF 的对象不能跨进程传递,每个子进程自己fitz.open()一次。
from multiprocessing import Pool import fitz PATH = "天鹅股份_招股说明书.pdf" def work(rng): start, end = rng doc = fitz.open(PATH) # 每个进程独立打开,不要传 doc 对象 out = [] for i in range(start, end): out.append((i, doc[i].get_text("blocks"))) doc.close() return out if __name__ == "__main__": doc = fitz.open(PATH) n = doc.page_count step = 40 # 每 40 页一个任务,任务太碎反而慢 ranges = [(i, min(i + step, n)) for i in range(0, n, step)] with Pool(4) as p: results = p.map(work, ranges) print("已处理页数:", sum(len(r) for r in results))step取 40 左右比较均衡,太小进程调度开销占比高,太大最后一个任务拖尾。两个常见报错:一是抽出来是乱码或方框,说明 PDF 里的字体没有 ToUnicode 映射,先用pdftotext -enc UTF-8验证一遍,能出正确文本就换 poppler 系工具;二是ValueError: cannot save with zero pages,通常是打开了加密文档,先检查doc.needs_pass,需要口令就在fitz.open(path)后调用doc.authenticate(pwd)。
3. 用书签与标题锚点把招股说明书PDF切成章节树
3.1 优先读 PDF 内置书签,成本最低
招股说明书的排版流程相对规范,多数成品带 PDF 书签,直接读出来的层级比任何正则都准。
import fitz doc = fitz.open("天鹅股份_招股说明书.pdf") toc = doc.get_toc(simple=True) # [[level, title, page], ...] page 从 1 开始 print("书签条目数:", len(toc)) for lvl, title, page in toc[:12]: print(" " * (lvl - 1) + f"[{lvl}] {title} -> p{page}")get_toc(simple=True)返回三元组列表,level是层级,page是 1 基页码,正好可以直接拿来当章节的起止边界。判断可信度的标准很简单:条目数在 20 到 200 之间、顶级标题里能看到「释义」「概览」「业务与技术」「财务会计信息」这类词,就可以直接用。如果return [],说明这份文件没有书签,走 3.2 的字号加正则方案。
3.2 没有书签时,用字号和正则做标题锚点
招股说明书的节标题有稳定特征:字号明显大于正文、通常加粗、以「第X节」开头。用"dict"模式把 span 级的字号和 flags 抓出来过滤。
import re import fitz SEC = re.compile(r"^第[一二三四五六七八九十百]+节\s*\S+") def find_headings(doc, skip_pages=8, min_size=12.0): hits = [] for i in range(skip_pages, doc.page_count): # 跳过封面、目录本身 d = doc[i].get_text("dict") for blk in d["blocks"]: for line in blk.get("lines", []): for span in line["spans"]: txt = span["text"].strip() if not txt or span["size"] < min_size: continue if SEC.match(txt): hits.append({ "title": txt, "page": i + 1, "size": round(span["size"], 1), "bold": bool(span["flags"] & 2 ** 4), # bit4 表示加粗 "bbox": [round(v, 1) for v in span["bbox"]], }) return hits doc = fitz.open("天鹅股份_招股说明书.pdf") for h in find_headings(doc)[:15]: print(h)skip_pages=8是为了跳过目录页——目录里每一行都长得像节标题,不跳过会一次性匹配出几十个假锚点。min_size=12.0需要按实际文档调:先打印正文 span 的字号分布,取正文众数字号加 1.5 到 2 点作为阈值。span["flags"]是位掩码,bit4 为 1 表示加粗字体,招股说明书节标题一般同时满足大字号和加粗,两个条件与一下能显著降噪。真锚点排序后按页码单调递增校验一遍,出现倒退说明匹配到了表格里的大字单元格,回退阈值重跑。
3.3 章节树的存储结构与边界规则
切分结果建议存成一张带父子关系的表,后面无论是做检索过滤还是做章节级摘要都用得上。
import pandas as pd def build_tree(toc_like, doc): rows, stack = [], [] for idx, h in enumerate(toc_like): level = h.get("level", 1) while stack and stack[-1]["level"] >= level: stack[-1]["end_page"] = h["page"] stack.pop() node = { "section_id": f"S{idx:04d}", "parent_id": stack[-1]["section_id"] if stack else None, "level": level, "title": h["title"], "start_page": h["page"], "end_page": doc.page_count, # 先给默认值,出栈时回填 "char_count": 0, } rows.append(node) stack.append(node) # 收尾:栈里剩下的节点统一收到尾页 while stack: stack.pop()["end_page"] = doc.page_count return pd.DataFrame(rows) tree = build_tree(toc, doc) # toc 来自 get_toc 或 find_headings print(tree[["section_id", "level", "title", "start_page", "end_page"]].head(10)) print("总节点数:", len(tree), "顶级节点:", (tree.level == 1).sum())核心是那个栈:遇到同级或更高层级的标题,说明前一个节点结束了,回填end_page再弹栈。char_count在切正文时累加,用来筛掉「释义」这类只有半页的短节点,做检索时可以给短章节单独加权。三条硬规则必须补上:跨页续写的段落属于当前章节,边界按标题所在页的起始坐标切,不是整页切;目录页产生的重复锚点在skip_pages之外还要做一次标题去重;页眉里重复出现的章节名(有些排版会带)要在取块阶段就按 y 坐标过滤掉,否则每个 chunk 头上都会挂一句无关的标题。
注意:切分粒度不要到「节」就停。「财务会计信息」一节通常上百页,内部还有「合并资产负债表」「应收账款账龄分析」等小标题,至少做到二级,检索命中率会明显不同。
4. 招股说明书PDF表格抽取与财务数字的可校验落地
4.1 合并单元格与跨页表的还原
pdfplumber 遇到合并单元格会给None,直接转 DataFrame 会出现大面积空洞。招股说明书里的「项目/本期/上期」这种表头经常横向合并,需要先做一次表头补全再纵向填充。
import pdfplumber import pandas as pd def table_to_df(raw, header_rows=1): # 先去掉全空的行列 raw = [r for r in raw if any(c and c.strip() for c in r)] header = ["" if c is None else c.replace("\n", "").strip() for c in raw[0]] # 表头横向合并补全:空单元格继承左边非空值 filled, last = [], "" for c in header: last = c if c else last filled.append(last) body = raw[header_rows:] width = len(filled) body = [(r + [None] * width)[:width] for r in body] df = pd.DataFrame(body, columns=filled) # 纵向合并补全:首列空值继承上一行,常见于「其中:」这类分组 df.iloc[:, 0] = df.iloc[:, 0].ffill() return df with pdfplumber.open("天鹅股份_招股说明书.pdf") as pdf: tables = pdf.pages[120].extract_tables({"vertical_strategy": "lines", "horizontal_strategy": "lines"}) df = table_to_df(tables[0]) print(df.head(8).to_string())横向补全解决的是表头合并,纵向ffill解决的是分组行留空——招股说明书的费用明细里,「其中:」下的子项往往只有第一行写主科目名。跨页表要在extract_tables之外单独处理:判断上一页最后一行是否是数字行、下一页第一行是否没有表头,两个条件成立就拼接。拼接前记得剥掉重复表头,否则会出现「项目」重复成数据行。
4.2 数字归一化:万元、括号负数与特殊符号
财务表格的数字坑集中在三处:单位在不同表之间切换、负数用括号表示、破折号表示零或缺失。抽出来直接float()必炸。
import re NUM = re.compile(r"^-?[\d,]+(\.\d+)?$") def norm_num(s, unit="万元"): if s is None: return None t = str(s).strip().replace(",", "").replace(" ", "") t = t.replace("\u2212", "-").replace("-", "-") # 两种减号统一 if t in {"", "-", "—", "-", "不适用", "N/A"}: return 0.0 if t in {"-", "—", "-"} else None neg = t.startswith("(") and t.endswith(")") if neg: t = t[1:-1] if not NUM.match(t): return None v = float(t) if neg: v = -v if unit == "万元": v = v * 10000 # 统一落到「元」做校验 return v for s in ["1,234.56", "(2,300.00)", "—", "12,000"]: print(s, "->", norm_num(s))符号集里最容易漏的是 Unicode 减号\u2212和全角减号-,会计软件导出的 PDF 里两者都出现过,不统一就会把负数识别成解析失败。括号负数在审计报告附注里极为常见,必须先剥括号再转 float,顺序反了NUM匹配直接失败。破折号在这类文档里通常表示零,返回0.0;「不适用」是语义缺失,返回None,两者在后续校验里的处理方式不同,不要混。
4.3 用会计恒等式反向校验抽取质量
抽完不校验,等于抽了个寂寞。资产负债表天然带恒等式,是最省事的自检手段。
def check_balance_sheet(assets, liabilities, equity, tol=0.02): # 全部统一到「元」后比较,tol 是为两位小数四舍五入留的余量 total = liabilities + equity diff = abs(assets - total) return { "assets": assets, "liab_plus_equity": total, "diff": diff, "ok": diff <= max(tol, abs(assets) * 1e-6), } print(check_balance_sheet(assets=1_234_567_890.12, liabilities=456_789_012.34, equity=777_778_877.78))tol给的是绝对误差下限,abs(assets) * 1e-6是相对误差,两者取大值,因为大额数字的舍入误差会随量级放大。校验不通过时按顺序查三处:表头单位是不是「元」而不是「万元」(差一万倍);「其中:」子项是否被当成了合计行,导致重复计入;跨页拼接时是否漏了半页数据。这套校验也适用于利润表——营业收入减营业成本减税金及附加等不等于营业利润,逻辑一样,只是项数更多。
4.4 落到一张能查的表
抽完之后用一张宽表存起来,字段设计要能支持「按报告期、按科目、按章节回溯到页码」这三种查询。
CREATE TABLE financial_item ( id BIGSERIAL PRIMARY KEY, doc_id VARCHAR(64) NOT NULL, -- 文档标识 section_id VARCHAR(16) NOT NULL, -- 章节树节点,来自第 3 章 statement VARCHAR(32) NOT NULL, -- 合并资产负债表 / 母公司利润表 period VARCHAR(16) NOT NULL, -- 2023-12-31 或 2023 年度 item_name VARCHAR(128) NOT NULL, -- 科目名称 item_value NUMERIC(20, 2), -- 统一到「元」 unit_raw VARCHAR(8), -- 原始单位,便于排查 page_no INT NOT NULL, -- 页码,用于回溯 bbox JSONB, -- 单元格坐标 UNIQUE (doc_id, statement, period, item_name) ); CREATE INDEX idx_fin_period ON financial_item (doc_id, period); CREATE INDEX idx_fin_item ON financial_item (doc_id, item_name);unit_raw一定要留,出问题时能立刻区分是抽取错还是单位换算错。bbox存坐标是为了前端做原文高亮。(doc_id, statement, period, item_name)这个唯一键同时也是幂等写入的保证,重跑抽取不会产生重复行。
| 常见坑 | 现象 | 定位方法 |
|---|---|---|
| 单位混用 | 数值差一万倍 | 对比unit_raw与表头文字 |
| 括号负数 | 值与方向相反 | 检查norm_num前的原始字符串 |
| 「其中」子项重复计入 | 合计偏大 | 按item_name前缀分组核对 |
| 跨页拼接丢行 | 恒等式差一个量级 | 对比相邻两页末行与首行 |
| 千分位残留 | float()抛异常 | 统计None返回率 |
5. 招股说明书PDF检索问答的引用回溯技巧
5.1 分块跟着章节树走,别按固定字数硬切
固定 500 字切块在招股说明书上问题很大:一个财务表格会被切成三段,每段都缺表头,单独看毫无意义。合理的做法是以第 3 章的章节树为骨架,章内再按段落聚合到 600 到 1000 字;遇到表格,整表作为一个块,并且在块头拼上「章节标题 + 表名 + 单位」这三段上下文。每个块带上section_id、page_no、bbox三个字段,检索命中后直接跳原文位置。
def make_chunk(section, texts, tables, max_chars=900): chunks, buf = [], "" for seg in texts: if len(buf) + len(seg) > max_chars and buf: chunks.append({"text": buf, "type": "text"}) buf = "" buf += seg if buf: chunks.append({"text": buf, "type": "text"}) for t in tables: # 表格块把表名和单位写进正文,检索时才不会被切碎 head = f"{section['title']}|{t['caption']}|单位:{t['unit']}\n" chunks.append({"text": head + t["markdown"], "type": "table"}) for c in chunks: c.update({"section_id": section["section_id"], "page_no": section["start_page"]}) return chunksmax_chars=900是中文语料的经验值,太小会切断论证,太大稀释检索相关性。表格块把caption和unit拼在前面,是因为用户提问「报告期应收账款周转率是多少」,向量检索靠的是这些文字,纯 Markdown 表格本身语义很弱。
5.2 混合检索加 RRF 融合,表格类问题单独加权
招股说明书的问题分两类:概念类(「什么是发行人的实际控制人」)适合向量检索,数字类(「2023 年研发投入占营业收入比例」)关键词匹配更准。用 BM25 和向量各召回 20 条,再用 RRF 融合,公式是score = Σ 1 / (k + rank),k取 60 是常用默认值。
def rrf_fuse(bm25_ids, vec_ids, k=60, w_table=1.3): scores = {} for rank, doc_id in enumerate(bm25_ids, 1): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank) for rank, doc_id in enumerate(vec_ids, 1): scores[doc_id] = scores.get(doc_id, 0) + 1 / (k + rank) # 表格块略微加权,数字类问题的命中率会明显上来 for doc_id in scores: if doc_id.startswith("table::"): scores[doc_id] *= w_table return sorted(scores.items(), key=lambda x: -x[1])[:8]k=60让排名靠前的文档之间差距变缓,避免单路召回霸榜。w_table=1.3是表格块的加权系数,调它的依据是线上 badcase:如果用户问数字时经常返回一大堆正文段落,就把这个值往上加 0.1 试。
5.3 用块坐标做可回溯引用
答案里必须带得出页码和原文片段,否则业务方没法核对。块的page_no加上bbox就是完整的定位信息,前端拿到后可以在 PDF.js 上画高亮框。
def to_citation(chunk): return { "section": chunk.get("title", ""), "page": chunk["page_no"], "quote": chunk["text"][:120], "rect": chunk.get("bbox"), # [x0, y0, x1, y1],PDF 用户空间坐标 }注意 PDF.js 的坐标系原点在左上、y 轴向下,而 PyMuPDF 的bbox是 PDF 原生坐标系的左上原点,两者对得上,但渲染窗口有缩放和旋转时要按viewport.convertToViewportRectangle()换算一次,否则高亮框会偏。生成答案时提示模型逐条引用块编号,输出后用块编号反查page_no,任何没有原文支撑的句子直接丢弃——这一步比调分块参数更能提升可信度。
本文还有配套的精品资源,点击获取