复杂网页表格采集:合并单元格、多级表头与分页导出
用 Python 展开 rowspan 与 colspan,组合多级列名,跟随分页并检查表头一致性,再把带单位与缺失值的统计数据导出 CSV。
网页上一眼能看懂的表格,保存成数组后可能已经错位:第二行没有地区单元格,因为它被上一行合并;表头分两层,而“销售额”上方还有年份;破折号表示缺失,不能自动当零。
本文提供两页完整表格材料,先展开合并单元格,再建立稳定列名,最后换算单位并导出 CSV。它适合需要理解解析过程的数据开发者,不把所有表格直接丢给一个函数后忽略结构差异。
安装与输入合同
使用 Python 3.12:
python3.12 -m venv .venv
source .venv/bin/activate
python -m pip install beautifulsoup4 httpx表格需要明确 thead、tbody 与下一页链接。本文支持正整数 rowspan/colspan;跨分组的特殊合并和 rowspan=0 需要另外实现,不能静默猜值。
统计单位是万元人民币,导出统一为人民币元,并保留 year、region 与 channel。两个渠道可能属于同一地区,不能只用地区作为唯一键。
先把两页材料放进同一个文件
创建 tables.py:
import csv
import re
import unicodedata
from decimal import Decimal
from urllib.parse import urljoin
from bs4 import BeautifulSoup
import httpx
HEAD = """<thead><tr><th rowspan="2">地区</th><th rowspan="2">渠道</th>
<th colspan="2">2026年</th></tr><tr><th>销售额(万元)</th><th>订单数</th></tr></thead>"""
PAGES = {
"https://example.16yun.cn/stats?page=1":
'<table id="stats">' + HEAD + """<tbody>
<tr><td rowspan="2">华东</td><td>在线</td><td>1,234.50</td><td>10</td></tr>
<tr><td>线下</td><td>—</td><td>3</td></tr></tbody></table>
<a class="next" href="?page=2">Next</a>""",
"https://example.16yun.cn/stats?page=2":
'<table id="stats">' + HEAD + """<tbody>
<tr><td>华南</td><td>在线</td><td>100.00</td><td>2</td></tr>
</tbody></table>""",
}
def expand(section):
rows = section.find_all("tr", recursive=False)
grid = {}
for r, row in enumerate(rows):
c = 0
for cell in row.find_all(["th", "td"], recursive=False):
while (r, c) in grid:
c += 1
height, width = int(cell.get("rowspan", 1)), int(cell.get("colspan", 1))
if height < 1 or width < 1 or r + height > len(rows):
raise ValueError("合并范围超出当前行组")
value = cell.get_text(" ", strip=True)
for dr in range(height):
for dc in range(width):
position = (r + dr, c + dc)
if position in grid:
raise ValueError("合并单元格重叠")
grid[position] = value
c += width
columns = max((c for _, c in grid), default=-1) + 1
if any((r, c) not in grid for r in range(len(rows)) for c in range(columns)):
raise ValueError("表格存在无法解释的缺格")
return [[grid[r, c] for c in range(columns)] for r in range(len(rows))]
def flattened_headers(section):
matrix = expand(section)
return [
"_".join(dict.fromkeys(row[c] for row in matrix if row[c]))
for c in range(len(matrix[0]))
]
def amount_yuan(value):
value = unicodedata.normalize("NFKC", value).strip()
if value in {"—", "-", ""}:
return None
if not re.fullmatch(r"(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d{1,2})?", value):
raise ValueError("金额格式不符合本例约定")
return str(Decimal(value.replace(",", "")) * Decimal(10000))PAGES 是本地教学材料映射,不是该域名真实提供的统计 API。两页表头相同,第一页有地区跨行,第二页无合并。实际下载可以在下面的取页位置换成 HTTPX,但必须先确认真实表格的范围和表头。
跟随分页、检查表头与导出
继续追加主流程:
url = "https://example.16yun.cn/stats?page=1"
visited, records, seen_keys = set(), [], set()
expected = None
while url:
if url in visited:
raise ValueError("分页出现循环")
visited.add(url)
if len(visited) > 10:
raise ValueError("超出本次页数上限")
if url in PAGES:
html = PAGES[url]
else:
with httpx.Client(timeout=20, follow_redirects=False) as client:
response = client.get(url)
response.raise_for_status()
html = response.text
soup = BeautifulSoup(html, "html.parser")
table = soup.select_one("table#stats")
if table is None or table.thead is None or table.tbody is None:
raise ValueError("没有识别到完整表格行组")
headers = flattened_headers(table.thead)
if expected is None:
expected = headers
if headers != expected:
raise ValueError("分页表头变化,不直接拼接")
for cells in expand(table.tbody):
if len(cells) != len(headers):
raise ValueError("数据列数与表头不一致")
item = dict(zip(headers, cells))
region, channel = item["地区"], item["渠道"]
order_text = item["2026年_订单数"]
if not order_text.isdigit():
raise ValueError("订单数不是非负整数")
key = (2026, region, channel)
if key in seen_keys:
raise ValueError("分页中出现重复业务行")
seen_keys.add(key)
records.append({
"year": 2026, "region": region, "channel": channel,
"sales_cny": amount_yuan(item["2026年_销售额(万元)"]),
"orders": int(order_text), "source_url": url,
})
next_link = soup.select_one("a.next")
url = urljoin(url, next_link["href"]) if next_link else None
with open("statistics.csv", "w", encoding="utf-8-sig", newline="") as stream:
writer = csv.DictWriter(stream, fieldnames=[
"year", "region", "channel", "sales_cny", "orders", "source_url"
])
writer.writeheader()
writer.writerows(records)
print({"pages": len(visited), "rows": len(records)})运行:
python tables.py预期处理两页、输出三条统计行。代码在完成整批解析后才导出;失败时不要把旧 CSV 当成这次成功结果。真实重复运行应采用不同批次目录。
展开合并与组合表头为什么要先做
rowspan 让后续行省略了单元格,但业务上该值仍然属于后续行。算法先把覆盖位置填入网格,再读取每行,因此第二条华东记录不会把“线下”错放进地区列。
colspan 同理影响列位置。两层表头展开后,“2026年”与“销售额(万元)”共同形成列名,避免把下一年的销售额无说明拼进同一列。
跨行的“地区”会在两层头里重复,组合时只保留一次。真实表格如果存在同名列,还需要包含完整层级或增加稳定列标识,不能简单去重后覆盖字典中的前一列。
Beautiful Soup 的节点与选择器处理见 官方文档。
金额、缺失与行身份
1,234.50 万元转换为 12,345,000.00 元。用 Decimal 从字符串处理,不先变浮点。破折号输出为空单元格,表示缺失,不是零收入;下游需要显式缺失标记时可增加状态列。
年份写进记录,同时价格单位已经统一。输入表头如果变成美元或元,应该修改并验证映射,不继续按一万乘。表头变化时停止拼接,就是为了让这种变化可见。
地区与渠道共同识别本例的统计行。分页里的重复行可能是重复页,也可能业务键还缺少产品类别。先核对再调整键,不为了凑行数直接删除相同文本。
常见问题与修复
第二行开始列错位。 检查是否按 HTML 单元格数量直接读取,没有先展开 rowspan。对照原表格,看每个跨行值覆盖几行。
多级表头只剩销售额。 保留年份和单位等上级语义,再组合列名。不同年的同名指标不应覆盖。
金额多一万倍。 核对源单位,确认没有在两个处理阶段重复换算。先保留原表头和来源,再修正转换。
空值变成 0。 检查是否把破折号、空字符串统一填零。只有业务明确为零的字段才能这么做。
下一页表头变化。 保存该页,检查年份、单位、列顺序或访问页面身份。不要用第一页列名强行套到另一张表。
只有前几页。 查看 next 解析、循环检测与页数上限。达到限制应报告范围不足,而不是当作正常末页。
复杂表格采集先恢复结构,再解释数值。只有列、单位和行身份都保持一致,CSV 才是可用于分析的数据。
需要企业代理方案?
我们可根据目标站点、并发规模与稳定性目标提供定制方案。