给爬虫增加数据质量检查:拦住空结果、错字段与异常记录

建立页面级与记录级质量检查,把商品数据分成合格、隔离与重复三类,并解释空结果、字段失效和部分完成时如何处理。

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

采集任务返回 200、导出文件也存在,仍可能没有可用数据:页面实际是登录提示,价格字段取到了运费,或者某个分类一夜之间从几百条降到零条。质量检查要回答的是“这批数据能否进入下游”,而不是“程序有没有报异常”。

本文面向已有采集程序、准备把商品数据接入分析或业务系统的团队。我们在下载解析之后、正式入库之前增加一道检查,产出合格记录、隔离记录和批次报告。示例同时处理空列表、访问异常、缺字段、错误类型和重复商品。

给采集器和下游约定输入

页面级证据和记录级字段要一起交付。采集器至少提供来源 URL、HTTP 状态、页面识别结果和提取记录。页面识别结果应由目标站的实际标记确定:正常商品列表、明确的空列表、登录页、访问挑战或未知页面。

不要把“找不到商品选择器”直接标成正常空列表。正常空列表应有明确的空结果提示,或者站点 API 返回可解释的空集合与结束信息。未知页面进入隔离,保留材料供排查。

下面用 page_kind 表达采集器已经判断的页面类型。这是接口合同,不是质量检查函数自动识别所有网站的能力。接入现有爬虫时,需要把识别逻辑放在解析阶段,页面不明时填写 unknown,不能默认填写 products。

一份可以解释每条去向的示例

使用 Python 3.12 或更新版本,无第三方依赖。创建 quality_gate.py,放入以下完整代码。正文提供的页面包是教学输入,没有访问外部站点。

import json
from urllib.parse import urlsplit
 
base = {"sku": "sku-1", "name": "红茶 500g", "price_minor": 1290,
        "currency": "CNY", "source_url": "https://example.16yun.cn/p/1"}
pages = [
    {"url": "https://example.16yun.cn/list/1", "status": 200,
     "page_kind": "products", "records": [base, {**base, "sku": "sku-2", "name": ""}]},
    {"url": "https://example.16yun.cn/list/2", "status": 200,
     "page_kind": "products", "records": [
         base, {**base, "sku": "sku-3", "price_minor": -1},
         {**base, "sku": "sku-4", "price_minor": "12.90"}]},
    {"url": "https://example.16yun.cn/list/3", "status": 200,
     "page_kind": "challenge", "records": []},
    {"url": "https://example.16yun.cn/list/4", "status": 200,
     "page_kind": "products", "records": []},
]
 
def reasons_for(row):
    reasons = []
    for field in ("sku", "name"):
        if not isinstance(row.get(field), str) or not row[field].strip():
            reasons.append(f"missing_or_invalid_{field}")
    amount = row.get("price_minor")
    if type(amount) is not int or amount < 0:
        reasons.append("invalid_price_minor")
    if row.get("currency") not in {"CNY", "USD", "GBP"}:
        reasons.append("unsupported_currency")
    source = row.get("source_url")
    if not isinstance(source, str):
        reasons.append("missing_source")
    elif urlsplit(source).scheme not in {"http", "https"} or not urlsplit(source).hostname:
        reasons.append("invalid_source_url")
    return reasons
 
accepted, rejected, duplicates, page_errors = [], [], [], []
seen = {}
for page in pages:
    if page["status"] != 200 or page["page_kind"] not in {"products", "empty"}:
        page_errors.append({"url": page["url"], "reason": "unexpected_page"})
        continue
    if page["page_kind"] == "empty" and page["records"]:
        page_errors.append({"url": page["url"], "reason": "inconsistent_empty_page"})
        continue
    if page["page_kind"] == "products" and not page["records"]:
        page_errors.append({"url": page["url"], "reason": "unexplained_empty_result"})
        continue
    for row in page["records"]:
        reasons = reasons_for(row)
        if reasons:
            rejected.append({"page": page["url"], "record": row, "reasons": reasons})
            continue
        key = (row["sku"], row["currency"])
        if key in seen:
            duplicates.append({
                "key": list(key), "record": row,
                "conflict": row != seen[key],
            })
            continue
        seen[key] = row
        accepted.append(row)
 
report = {
    "accepted": len(accepted), "rejected": len(rejected),
    "duplicates": len(duplicates), "page_errors": page_errors,
    "publishable": not rejected and not page_errors
        and not any(item["conflict"] for item in duplicates),
}
for filename, data in [
    ("accepted.json", accepted), ("rejected.json", rejected),
    ("duplicates.json", duplicates), ("quality-report.json", report),
]:
    with open(filename, "w", encoding="utf-8") as stream:
        json.dump(data, stream, ensure_ascii=False, indent=2)
print(json.dumps(report, ensure_ascii=False, indent=2))

执行:

python quality_gate.py

本例应保留一条合格商品、隔离三条坏记录,并识别一条重复记录。第三、四个页面分别因为挑战页面和无法解释的空结果进入页面错误清单。因此 publishable 为 false,并不因为有一条好数据就允许整批覆盖旧数据。

三种检查各自负责什么

字段类型检查阻止明显的结构变化,例如本来是便士整数的价格突然变成字符串 "12.90"。代码使用 type(amount) is int,避免把 Python 中属于整数子类的布尔值当作合法金额。

业务检查确认值的含义:这里允许零价格,但拒绝负价格;支持的币种是当前任务范围,而不是所有金融系统的完整列表。如果你的业务不接受免费商品,增加零值规则前先明确业务约定。

批次检查发现单条记录无法表达的问题:某页未成功处理、列表无法解释地变空、同一业务键出现冲突。示例采用保守发布策略:存在这些问题就不把整批作为完整快照发布。面向实时部分数据的系统可以接收合格分区,但必须向下游标明部分完成,不能冒充完整目录。

重复记录为什么不直接覆盖

同一个 SKU 与币种可能重复出现在两个列表中,完全相同的重复可以去重。如果两条价格不同,应先确定任务是否还缺少地区、规格或观测时间维度。

本例保存重复记录并标明 conflict,不采用“最后一条覆盖”。在没有时间排序约定时,最后完成的网络请求不一定包含最新价格。要选择最新报价,必须根据明确的观测时间与来源优先级排序。

业务键也需要随任务调整。多地区采集至少加入市场维度,多规格采集通常使用 SKU 而不是商品父 ID。不要把示例中的两字段键搬到所有任务里。

接到现有爬虫的哪个位置

下载和解析完成后先形成页面包,交给质量检查,再将合格记录送入暂存区。页面失败、字段隔离与最终发布是不同状态,不应该由一个 success 布尔值承担。

接入时保留原始响应或脱敏页面快照的路径,并把路径放进页面错误和坏记录中。本例输入直接在正文里,故没有文件路径;真实采集只保留错误字符串,通常不足以重现页面改版导致的错位。

如果出现 challenge,先判断访问条件;如果是 unexplained_empty_result,检查列表选择器与数据是否延迟加载;如果是 invalid_price_minor,检查价格解析和单位。分别修复后只补采受影响页面,再对组合后的整批结果重新检查。

检查报告还不能发现什么

一个错误字段如果恰好类型正确、范围合理,仍会通过规则。例如运费 1290 被当作商品价,单靠非负整数校验发现不了。应选择若干人工核对的来源记录,比较字段语义,或者增加页面字段标记与来源位置等证据。

同样,报告中的 publishable 只依据本次输入,无法知道采集器是否压根没发现第二页。因此目录完整性还需要预期页面集合、分页结束证据或来源数量等上游信息。质量检查不能替代发现范围管理。

对历史基线做异常检测时,先记录分母:合格率是按发现记录、处理记录还是全部预期记录计算。昨日 1000 条、今日 800 条可能来自真实下架,也可能是一次漏页,不能只设一个百分比阈值就自动删除历史数据。

常见问题与处理

坏记录越来越多,但程序没有报错。 检查发布流程是否实际读取 publishable,是否有人绕过暂存区直接写正式表。规则存在却不参与发布,不能保护数据。

空列表被误判为失败。 为真实空结果增加明确的 empty 识别依据;代码中这类页面可带空 records 通过。不要让所有零条页面都自动转成 empty。

同一商品出现冲突。 比较规格、地区、币种和观测时间。补齐缺失维度后再定义键;不要按先来或后来任意选择价格。

修复字段后仍然被隔离。 确认补采结果进入的是新的检查批次,且输入没有混入旧的失败文件。检查报告应绑定批次 ID,避免把上一次的缺陷计数当成本次结果。

部分成功是否全部重跑。 按错误来源保留补采清单。小任务可以重跑;大任务应补齐缺失部分后统一评估完整性,不能只检查刚补的记录就宣布整批正常。

这道质量检查的价值,是让每条数据和每个页面都有明确去向。它应帮助你决定修复哪里、哪些结果能交付,以及哪些历史数据暂时不能覆盖。

需要企业代理方案?

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