← 全部文章

招标监控:爬虫从未抓取到的公告

监控低于欧盟门槛的招标意味着需要抓取大量长尾小型门户网站。代价高昂的失败并非请求被拒,而是显示正常运行却没有存入任何数据。

216,000 欧元。低于这个门槛,欧洲市议会采购服务时无需将招标公告发布在欧盟中央注册平台 TED 上。

这意味着小型竞标者实际能够拿下的合同分散在其他地方:发布在具体负责采购的议会、地区、公用事业机构或部门的门户网站上。

挑战

根据 欧盟委员会的统计,欧盟超过 250,000 家公共机构每年在工程、物资和服务上的支出约为 2.6 万亿欧元。欧盟指令仅管辖高于门槛的合同。自 2026 年 1 月 1 日起,中央政府物资和服务采购门槛为 140,000 欧元,地方和区域机构为 216,000 欧元,工程类为 5,404,000 欧元。低于这些门槛的合同适用国家规则,在 TED 上发布属于自愿行为。

这就是为什么招标监控从根本上说是一项抓取任务。如果你正在为竞标咨询公司或建筑企业构建监控系统,高于门槛的部分反而是简单的。剩下的则是极其庞大的长尾门户网站,这些网站在设计时根本没有考虑过机器读取。

在北美销售公共部门竞标数据的 BidPrime 于 2026 年 5 月将美国类似的情况描述为 "数千种工作流":对自动化访问限流的门户网站、需要账户或 session 的采购系统、与公告分开存储的附件、毫无预警发生变更的 URL。这些问题并不只存在于美国。

但在招标监控中,真正致命的故障不是拒绝访问的门户网站。那种错误会明确记录在 logs 里。最致命的是状态显示成功但内容空空如也的运行记录。

议会门户网站可能会在 session 过期时返回一个带有 HTTP 200 的友好提示页面。维护通知可能会直接渲染在原本应该展示结果表格的位置。你昨天提交的搜索可能会再次返回第一页,因为第二页仅在请求它的特定 cookie 下有效。你的 parser 没有找到任何行,并记录"今日无新标讯"。与错过的价格不同,错过的标讯无法挽回:等到有人发现遗漏时,投标截止日期早已过去,这份公告对竞标者而言已毫无价值。

应对方案:让每次请求证明其获取的内容

先从不需要抓取的内容开始。TED 的 API 提供对已发布公告的匿名访问权限,无需密钥,因此可以从该处直接拉取达到门槛标准的数据。在英国,根据 GOV.UK 的说明,自 2025 年 2 月 24 日起,Find a Tender 已成为所有订约当局发布采购信息的中央平台。在为某个门户编写解析器之前,请先确认中央注册库是否已经覆盖了其镜像的内容。

对于其他所有内容,以下三条规则起到了关键作用。

1. 定义真实的响应结果,包括空结果。 结果页面包含其列标题(按门户自身的用词,例如 "Closing date")。真实的无数据日期会显示门户自身的空状态文本。其他任何情况都属于异常缺失,而非数据为零。将这两个字符串都加入 request 的 validate.data.accept 列表中,这样会话过期的页面就会使 request 判定为失败,而不是被误认为没有数据。我们的引擎已经能够拒绝将识别到的验证页面视为成功,但没有任何引擎能掌握每个市政机构对超时页面的具体措辞(这部分必须由你提供)。匹配规则是严格区分大小写的纯子字符串匹配,因此请直接从页面复制文本,而不要手动重新输入。我们在验证规则开始用于判定成功时介绍过相关机制,并在当拦截看起来像正常数据点时中从时间序列的角度探讨了同一问题。

2. 校验门户显示的计数。 大多数结果列表都会显示匹配的公告数量(例如 "1-20 of 143")。解析该数字并与你存储的行数进行对比。如果不一致,无论状态码是什么,该次运行都未完成。

3. 找到路由一次,随后进行重放。 分页且绑定会话的门户通常要求后续页面来自相同的地址和相同的浏览器。借助 FourA 等平台,Auto 会为第一页找到可行的路由并返回其会话:包括生效尝试中的 proxy ID、user agent 以及 cookies。此后的每个页面都通过 Single 在该出口上请求,这就是生产路径。Auto 仅用作路径探测。

import math, re, requests

API = "https://api.foura.ai/api"
H = {"X-API-Key": "pk_live_..."}
LIST = "https://tenders.example-council.gov/open?page={}"
PER_PAGE = 20

# A real answer is the results table or the portal's own empty-state line.
# An expired session or a maintenance notice served at 200 is neither.
CHECK = {"data": {"accept": ["Closing date", "No open tenders"]}}

def fetch(endpoint, body):
    r = requests.post(f"{API}/{endpoint}", headers=H, json=body).json()
    if r.get("error"):
        raise RuntimeError(r["error"])  # a gap to refill, not a quiet day
    return r

# Auto finds a route that works and hands back its session...
first = fetch("auto", {"url": LIST.format(1), "validate": CHECK})
proxy, ua = first["session"]["proxy"], first["session"]["userAgent"]
pages = [first["data"]]

# ...and Single replays it on the same exit for every other page.
m = re.search(r"\d+-\d+ of (\d+)", pages[0])
total = int(m.group(1)) if m else 0
for n in range(2, math.ceil(total / PER_PAGE) + 1):
    pages.append(fetch("single", {
        "method": "GET", "url": LIST.format(n), "proxy": proxy,
        "headers": [["User-Agent", ua]], "validate": CHECK,
    })["data"])

如果门户网站在服务端保存搜索状态,请在同一个 headers 列表中发送其 session cookie;Single 会返回每个重定向跃点的所有 Set-Cookie header。招标文档通常以 PDF 形式存放在下载链接后,Single 的 重定向控制与原始 buffer 模式能直接将其以字节形式返回,避免文本损坏。还有一个习惯:按照各个门户独立的调度计划进行轮询。这些系统是小型市政机构运行的小型服务器,一次性突发 20 次请求对它们造成的压力,远大于分散在整个下午的 20 次请求。

结果

  • 捕获的招标项目增加 25%。 爬取服务公司 GroupBWT 为某位监控 50 多个采购门户的客户对比人工基准时报告了这一数据,“并非因为招标项目变多了,而是因为他们不再遗漏项目。”
  • 阈值以上部分无需爬虫。 TED 的公告来自无需密钥的 API,因此解析器的每小时工作量都可以集中在没有 API 的门户上。
  • 在公告开放期间及时暴露缺口。 未通过接受规则的页面会在发生当天抛出错误,任务会进行重试,而不是直接记录空数据。
  • 每个门户只进行一次路由发现,而非每页一次。 Auto 的搜索只执行一次;后续每个页面都是在找到的出口上发起 Single 调用。

仅第一项为实测结果;其余三项阐述了该架构带来的改变。

核心要点

在招标监控中,最昂贵的数据行莫过于“今日无新增”,因为这是唯一无法在事后修正的记录。价格明天还会更新。但如果在爬虫读取过期会话页面时招标已经截止,数据就彻底丢失了。

因此,确保每次零结果都经过严谨验证。一个无法展示表格、门户自身空状态提示或匹配数量的列表页,并没有提供任何有效信息;若将其视作正常响应,竞标方最终就只能在中标结果公布时才得知合同的存在。