复杂网页表格采集:合并单元格、多级表头与分页导出

用 Python 展开 rowspan 与 colspan,组合多级列名,跟随分页并检查表头一致性,再把带单位与缺失值的统计数据导出 CSV。

亿牛云技术团队2026年10月28日4 分钟阅读

网页上一眼能看懂的表格,保存成数组后可能已经错位:第二行没有地区单元格,因为它被上一行合并;表头分两层,而“销售额”上方还有年份;破折号表示缺失,不能自动当零。

本文提供两页完整表格材料,先展开合并单元格,再建立稳定列名,最后换算单位并导出 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 才是可用于分析的数据。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。