中国中冶2020年报PDF表格提取与结构化落库实践

发布时间:2026/9/17 19:50:36
中国中冶2020年报PDF表格提取与结构化落库实践 简介《中国中冶2020年度报告》PDF面向关注建筑央企、冶金工程及基础设施行业的投资者、研究人员与财务分析人员可用于了解公司年度经营成果、财务表现、治理结构与风险因素。文件为1个PDF大小5.94MB完整收录释义、董事长致辞、公司简介和主要财务指标、董事会报告、经营情况讨论与分析、重要事项、股本变动及股东情况、公司治理与企业管治、公司债券相关情况、财务报告及备查文件目录等核心章节。报告披露2020年营业收入4001.15亿元、利润总额119.17亿元、归属上市公司股东净利润78.62亿元新签合同额突破万亿元并载明利润分配预案、审计意见与宏观经济、行业政策等风险提示。内容数据详实、结构规范适合作为行业咨询、投资研究与案例写作的一手参考资料。目前已有125人学习下载。1. 中国中冶 2020 年度报告 PDF 为什么不能直接复制粘贴投研或合规同事把一份《中国中冶2020年度报告.PDF》丢过来要求当天把主要会计数据、分行业营业收入、前十大股东这几张表变成能查、能对比的结构化数据。第一反应通常是 CtrlA 复制进 Excel结果一行数字被拆成三行负数的括号跑到隔壁单元格页眉“中国中冶 2020 年年度报告”插在数据中间跨页表格的下半截彻底没了表头。PDF 不是文本文件它是一串带坐标的绘制指令。人眼看到的是表格程序看到的只是散落的字符对象和线段对象。要把这份年报变成能跑 SQL 的数据源需要一条完整链路先判断文本层是否可用再定位章节锚点然后识别表格线、处理合并表头与跨页拼接最后做数字标准化和勾稽校验。做金融数据、投研中台、审计工具和企业知识库的工程师会反复用到这套方法任何一个被 PDF 报表折磨过的人也能直接抄。2. 拆解《中国中冶2020年度报告.PDF》的页面结构2.1 用 pdfplumber 探测年报 PDF 的文本层与字体对象拿到文件后第一件事不是抽表格而是看清楚它的“底子”。年报动辄三四百页全量扫描太慢先抽样前 20 页拿基本画像页数、页面尺寸、每页可提取字符数、出现的字体名。import pdfplumber PDF_PATH 中国中冶-2020年度报告.PDF with pdfplumber.open(PDF_PATH) as pdf: print(总页数:, len(pdf.pages)) # 只抽样前 20 页全量探测在几百页的年报上很慢 for i, page in enumerate(pdf.pages[:20]): text page.extract_text() or chars page.chars # 字符级对象fontname / size / bbox 都在这里 fonts sorted({c[fontname] for c in chars}) print(i, round(page.width), round(page.height), len(text), fonts[:4])page.chars是整份方案的基石。每个元素都带fontname、size、x0/y0/x1/y1字体名可以用来区分正文和章节标题——年报的章节标题通常换用另一套加粗字体或明显更大的字号。page.width/height用来判断这一页是常规竖版还是横排的大表格页横排页的裁剪参数要单独处理。如果len(text)在连续多页都是 0 或个位数说明文本层不可用后面所有基于字符坐标的策略都会失效。2.2 判断扫描件还是文本型 PDF三条可复现的判据不要凭“打开能看到字”就下结论屏幕渲染和文本层是两回事。用下面三条判据交叉验证比任何单一指标都可靠。判据探测方式经验阈值结论每页平均字符数len(page.extract_text() or )连续 10 页 100大概率扫描件图像覆盖率sum(i[width] * i[height] for i in page.images) / (page.width * page.height) 0.8整页是位图字体嵌入状态pdffonts输出的 emb 列全为 no或字体名带 Type3文本层不可靠命令行抽查pdftotext -layout输出成行成列文本层可用# 看字体是否嵌入、有没有 Type3 这种“伪文本” pdffonts 中国中冶-2020年度报告.PDF | head -20 # 抽前三页看排版还原度-layout 会尽量保留空格对齐 pdftotext -layout -f 1 -l 3 中国中冶-2020年度报告.PDF - | head -40-layout参数很关键不加它输出会把整页压成一段加上之后列的相对位置能保留肉眼就能判断表格是靠空格分列还是靠线条分隔这直接决定后面选stream还是lattice策略。注意同一份年报里可能混着两种页面。财务报告主体是文本型但部分附注或子公司清单可能是插进来的扫描图。判定要按页做不要给整份文件下一个结论。2.3 年报目录与章节定位书签、页码偏移与正则锚点真正的工程问题不是“能不能抽”而是“抽哪一页”。年报的章节结构稳定常见的有释义、公司简介与主要财务指标、管理层讨论与分析、公司治理、环境与社会责任、重要事项、财务报告。用书签直接定位是最省事的路径。import re import fitz # PyMuPDF doc fitz.open(中国中冶-2020年度报告.PDF) # 路径一PDF 自带书签直接拿到 [层级, 标题, 物理页号] for lvl, title, pno in doc.get_toc()[:15]: print(lvl, title, pno) # 路径二没有书签时用章节锚点正则逐页扫 ANCHOR re.compile(r第[一二三四五六七八九十]节\s*\S{2,20}) for pno in range(len(doc)): first_lines doc[pno].get_text().splitlines()[:6] for line in first_lines: m ANCHOR.search(line) if m: print(锚点命中:, pno, m.group(0))书签给的是物理页号读者看到的却是印刷页码。年报的封面、目录、释义通常不参与页码编号于是offset 物理页号 - 印刷页码会在前几十页波动到正文才稳定。正确做法是先用“第一节 释义”“第三节 管理层讨论与分析”这类锚点算出稳定后的 offset再把所有印刷页码统一换算。这一步做错的代价很大后续所有按页码抽取的逻辑都会整体偏移若干页抽出来的“主要会计数据”可能是上一年的或者干脆是目录。3. 中国中冶 2020 年度报告里的财务表格怎么抽才不串行3.1 extract_tables 的最小可跑代码与调参顺序定位到目标页之后先跑一次默认参数把结果打印出来看结构再决定往哪个方向调。import pdfplumber # 页号仅为示例实战中一律用 2.3 的锚点定位结果 TARGET_PAGE 68 with pdfplumber.open(中国中冶-2020年度报告.PDF) as pdf: page pdf.pages[TARGET_PAGE] # 先裁掉页眉页脚避免“中国中冶 2020 年年度报告”混进数据行 body page.crop((0, 0.06 * page.height, page.width, 0.94 * page.height)) tables body.extract_tables({ vertical_strategy: lines, # 竖线策略lines / lines_strict / text / explicit horizontal_strategy: lines, # 横线策略同上 snap_tolerance: 3, # 断开的线吸附到同一坐标的容差像素 join_tolerance: 3, # 同方向上相邻线段合并的容差 intersection_tolerance: 5, # 横竖线交叉点判定容差 }) for row in (tables[0] or [])[:6]: print(row)snap_tolerance是调参的第一顺位。年报排版里表格线经常被拆成多段短线段容差太小会识别出一堆碎片单元格太大又会把相邻列合并。调参顺序建议是先固定snap_tolerance调整到列数稳定再动intersection_tolerance解决表头错位最后用join_tolerance修补个别断开的长横线。vertical_strategy选explicit是兜底手段可以手工给一组竖线坐标。遇到跨页表格这种“没有完整线框”的情况比反复试容差更省时间。3.2 无框线表格lattice 与 stream 双跑对比年报里的分行业收入、前十大股东名单常常只有横线没有竖线或者全靠空白分隔。这时候单靠一个工具会翻车双跑对比是最务实的做法。# 有线框场景依赖 Ghostscript 和 OpenCV python -m camelot --format json --output ./out/ \ --pages 68-72 --lattice 中国中冶-2020年度报告.PDF # 无线框场景按空白列切分 python -m camelot --format json --output ./out/ \ --pages 68-72 --stream 中国中冶-2020年度报告.PDF工具适用表格环境依赖中文表头表现输出形态pdfplumber线框完整纯 Python好二维 list可直接进 DataFrameCamelot lattice有线框Ghostscript、OpenCV一般需要额外指定字体CSV / JSON / DataFrameCamelot stream靠空白分列同上一般列边界易漂移同上PyMuPDF find_tables线框较规整无额外依赖好表对象可读单元格坐标两种结果用行数、列数、数字单元格占比三个指标做一致性打分。两份结果高度一致的行可以直接入库差异大的行单独落一张待人工确认的表比强行合并要安全得多。提示Camelot 在中文场景下的坑主要在两处一是--stream对全角空格的宽度判断二是表头含中文时列名可能被截断。跑完一定要抽查前两行的文本不要只看行数对不对。3.3 表头合并单元格与跨页表格的拼接策略年报表头基本都是二维的第一行是“项目 / 2020 年 / 2019 年”第二行细分为“金额 / 占营业收入比例”。解析出来就是一堆 None需要做纵向填充。def fill_merged_header(rows): 年报表头常为两行合并向下填充第一行拿到的分组名 header_rows rows[:2] filled, last [], None for cell in header_rows[0]: if cell and cell.strip(): last cell.strip() filled.append(last) # 与第二行拼接成完整列名 return [ f{g or }{/ s.strip() if s and s.strip() else }.strip(/) for g, s in zip(filled, header_rows[1]) ]跨页表格的判定有两条可靠信号下一页首行是否为空或只有单位说明上一页最后一行的首个单元格是否为空说明数据仍在延续。把这两条组合起来做延续判断再用首列主键去重拼接比单纯按页号顺序堆叠准确得多。还有一个高频陷阱分行业收入表里“合计”行往往和明细行同一个样式如果直接sum()明细列结果会翻倍。做法是在解析阶段给每行打一个row_role标记命中“合计 / 小计 / 总计”的行单独标记为汇总行聚合时排除。4. 2020 年度报告 PDF 落库数字标准化、SQLite 存储与勾稽校验4.1 千分位、括号负数与单位换算的统一处理PDF 抽出来的数字是字符串形态五花八门1,234,567.89、(1,234)、—、-、全角空格夹在中间。这一步不做干净后面所有计算都是错的。import re NUM_RE re.compile(r^\(?-?[\d,](\.\d)?\)?$) def to_number(cell: str): 把年报里的各类数字写法统一成 float无法识别的返回 None if cell is None: return None s cell.strip().replace(\u3000, ).replace( , ) if s in {, -, —, --, /, 不适用}: return None neg s.startswith(() and s.endswith()) s s.strip(()).replace(,, ) if not NUM_RE.match(f({s}) if neg else s): return None try: v float(s) except ValueError: return None return -v if neg else v单位问题是另一条主线。年报的表头会写“单位千元”或“人民币千元”正文里又会出现“元”。解析器必须在读表头时捕获单位并统一折算到元后再入库同时把原始单位和原始字符串一起存下来方便回查。常见做法是用一条正则从表头行抽单位UNIT_RE re.compile(r(人民币)?(百万元|万元|千元|元)) def extract_unit(header_text: str): m UNIT_RE.search(header_text or ) return m.group(0) if m else Noneextract_unit返回 None 时不要默认按“元”处理应该打上unit_unknown标记。默认值一旦猜错金额会差三个数量级而勾稽校验又恰好可能“自洽”这种错误最难发现。4.2 用 SQLite 存年报段落与表格建 FTS5 全文检索单份年报没必要上重型数据库SQLite 加 FTS5 足够撑住段落检索和表格查询两类需求。CREATE TABLE IF NOT EXISTS report_table ( id INTEGER PRIMARY KEY AUTOINCREMENT, doc TEXT NOT NULL, section TEXT, page_no INTEGER, -- PDF 物理页号 print_page INTEGER, -- 印刷页码 table_index INTEGER, row_index INTEGER, col_index INTEGER, row_role TEXT, -- detail / subtotal / total raw_text TEXT, num_value REAL, unit TEXT, UNIQUE(doc, page_no, table_index, row_index, col_index) ); CREATE VIRTUAL TABLE IF NOT EXISTS report_fts USING fts5( doc, section, content, page_no UNINDEXED );-- 查某份年报里所有含“分行业”的段落按页排序 SELECT page_no, content FROM report_fts WHERE report_fts MATCH doc:中国中冶-2020年度报告 AND content:分行业 ORDER BY page_no; -- 查所有天然单位不明、需要人工确认的金额行 SELECT section, print_page, row_index, raw_text FROM report_table WHERE unit IS NULL AND num_value IS NOT NULL;UNIQUE约束的作用是让解析脚本可以幂等重跑同一页同一单元格重复写入会被忽略或触发更新不用每次先清库。row_role字段把汇总行和明细行分开做聚合时直接过滤比事后靠字符串匹配“合计”稳健。4.3 勾稽校验三大报表与脚注陷阱数据入库不等于数据正确。用报表内部的恒等关系做自动校验是把错误挡在上线前的最后一道闸门。校验项关系式允许误差常见失败原因资产负债表平衡资产总计 负债合计 所有者权益合计单位是元时为 0单位是千元时 ±1抽到了上年同期列利润表勾稽营业利润 营业外收入 − 营业外支出 利润总额同上括号负数被当正数归母与少数股东净利润 归属于母公司股东的净利润 少数股东损益同上表头行被当成数据行单位一致性数值 × 单位系数—“千元”与“元”混用def check_balance_sheet(rows, unit_scale1000): rows 是 dict 列表key 为标准化后的科目名 def get(name): for r in rows: if name in r[item]: return (r[num_value] or 0) * unit_scale return None assets get(资产总计) liab get(负债合计) equity get(所有者权益合计) if None in (assets, liab, equity): return MISSING diff assets - liab - equity return OK if abs(diff) unit_scale else fFAIL diff{diff}脚注是另一个必须处理的噪声源。年报的科目名后面经常挂上标数字比如“营业收入 1”对应页脚的解释。做法是在清洗阶段用正则剥掉行尾的独立数字上标同时把这些数字连同当页脚注文本存进单独的表避免误伤真实的金额末位数字。注意extract_text()默认会把页脚也抽进正文。用page.crop()按页面高度的百分比裁掉上下 6% 之后脚注和页码就基本不会污染数据行这一步比事后按关键词过滤更彻底。5. 让中国中冶年报 PDF 解析跨年份复用锚点配置与差异比对5.1 把锚点和单位规则抽成配置文件年报的章节名年际变化很小变的只是页码。所以定位逻辑必须建立在文本锚点上而不是硬编码页号。把这些规则外置成 YAML换一份年报只需要改配置。doc: 中国中冶-2020年度报告.PDF section_anchor: 第[一二三四五六七八九十]节\\s*\\S{2,20} sections: - key: major_financial anchor: 主要会计数据 must_table: true - key: mda anchor: 管理层讨论与分析 must_table: true - key: shareholders anchor: 前十大股东 must_table: true unit_pattern: (人民币)?(百万元|万元|千元|元) skip_row_pattern: ^(合计|小计|总计|减|其中)skip_row_pattern不是用来丢弃这些行而是用来打row_role标记。减开头的行在现金流量表里承担扣减语义绝不能当成普通明细行直接求和。5.2 用 difflib 对比相邻两年年报的文本差异跨年份复用时最怕的是披露口径悄悄变了同一张表多出一列、科目名改了叫法、单位从千元换成元。逐页盯人看不现实用difflib做行级差异比对几分钟就能把变化点捞出来。import difflib import fitz def page_lines(path, page_no): return fitz.open(path)[page_no].get_text().splitlines() a page_lines(中国中冶-2019年度报告.PDF, 40) b page_lines(中国中冶-2020年度报告.PDF, 40) # n0 减少上下文噪声只看真正变化的行 for line in difflib.unified_diff(a, b, lineterm, n0): if line.startswith((, -)) and not line.startswith((, ---)): print(line)输出里带的是新增行带-的是消失行。重点看三类表头新增的“2020 年”列、单位说明行的变化、科目名的措辞调整。前两类直接影响解析器的列映射第三类影响锚点正则的命中率。5.3 置信度打分与人工复核队列一个可长期跑的流水线必须能自己说出“这一页我抽得没把握”。用三个指标打分低于阈值的数据自动进人工队列而不是直接入库当正确数据用。指标计算方式低于阈值时的动作数字解析率to_number成功数 / 非空单元格数 0.95 检查是否有全角字符或脚注上标空值率空单元格 / 总单元格 0.3 检查crop区域是否切掉了表格勾稽通过率通过校验的表数 / 总表数 1.0 打印失败行并比对原始 PDF 截图-- 把低置信度的行集中到隔离表每周人工过一遍 CREATE TABLE IF NOT EXISTS quarantine AS SELECT * FROM report_table WHERE raw_text IS NULL AND num_value IS NULL;真正省时间的做法是给隔离表加一个reason字段把失败原因unit_unknown、parse_failed、check_failed写进去。人工复核时按reason分组批量处理同一类问题一次改完比逐行翻 PDF 快一个量级。本文还有配套的精品资源点击获取