☰
告别爬虫踩坑:中国采招网API接入与招标商机监控实战
2026/9/26 7:36:59 网站建设 项目流程

中国采招网API是我做招投标数据服务这两年一直离不开的一个接口。最开始做招标信息盘点,我一门心思想用爬虫解决,结果被反爬、字符编码、页面结构改版折腾得够呛。后来在一个同行那儿看到他们直接接的采招网官方接口,才意识到有些数据入口,与其自己修路不如直接上高速。这篇文章我就把从申请密钥、理解鉴权、完成第一次调用,到搭一个能自动盯商机的小工具的完整过程摊开来讲,顺带把我踩过的坑都标出来。不管是做商机监控、标讯聚合,还是给企业内部系统补数据源,这套思路基本都能复用。

1. 中国采招网API是什么,我为什么从爬虫转过来

1.1 官方接口解决的三个原始痛点

先说结论:能用官方接口解决的问题,尽量不要自己写爬虫。这句话我是在被页面改版坑了三次之后才真正认同的。

第一次做标讯采集的时候,我的方案很简单:定时抓取采招网的列表页,再用规则解析标题、地区、时间这些字段。那个版本上线后,头两周跑得还算平稳,但第三周就收到告警,解析出来的标题全是乱的。打开页面一看,原来是列表页结构调整了,字段从<td>放进了<div>里,正则和XPath全部失效。改完一轮,过了大概一个月,对方又加了动态加载,直接变成异步接口渲染,我那些静态请求又拿不到完整数据了,最后不得不上一套无头浏览器。那段时间的维护成本,说实话比写业务代码还高。

第二点是反爬问题。官方页面虽然不至于像电商平台那样严防死守,但高频请求后IP会被临时限制,验证码也会偶尔弹出来。我见过有人用几十个住宅代理去轮换,且不说成本,单是代理池本身的稳定性和合规性就已经够让人头疼了。做数据服务的人应该都知道,业务侧最怕的不是接口少,而是数据源中途断了,整个下游链路全停。

第三点才是关键:网页数据是非结构化的。列表页里每一条公告的标题、地区、行业、预算金额能不能拆清楚,完全取决于页面当时的排版。有时候预算金额不在列表里,需要点进详情页再取;有时候代理机构名称写得很随意,根本没法做标准化。而官方API直接返回结构化JSON,字段是定死的,拿过来就能用,清洗成本瞬间降了一个量级。这三件事叠在一起,让我很坚定地转了方向。

1.2 它的接口设计和我接触过的其他平台有什么不同

接触采招网API之前,我也接过一些别家的信息接口,对比下来最大感受是:这套接口更像是一套面向B端业务的开放平台,而不是临时拿数据库往外糊一层HTTP。

第一,接口整体走的是比较标准的RESTful风格,资源用名词表示,比如公告列表、公告详情、关键词订阅,操作方式就是GET或POST。第一次拿到文档的时候,我甚至没有花太多时间就猜出了大部分路径的命名规律,这对联调来说非常友好。第二,返回结构是统一封装的,外层有code、message、data,不管请求成功还是失败,走的都是同一套格式,写通用异常处理非常省事。

第三,它在鉴权上做得比较细,不只是给你一个API Key就完事,而是要求加上签名参数,时间戳、随机数这些都有。刚开始觉得麻烦,后来想想,这种设计能挡住不少恶意重放和参数篡改,对靠数据吃饭的业务来说反而是好事。还有一点我特别满意:字段设计上考虑到了实际业务场景,比如publish_time、deadline、budget这些字段都是现成的,不用我从标题或者正文里再去扣信息。这种"懂业务"的接口,比那种只给你一堆无意义字段名的接口省心太多。

2. 核心能力拆解:能拿到什么数据,字段长什么样

2.1 主要接口能力清单

我拿到的权限里,最常用的接口大概是这几个,大家实际接入时以官方文档为准,但能力类型基本差不多:

  • 公告列表接口:按发布时间倒序返回当前最新的招标公告,支持分页和关键词筛选。
  • 公告全文搜索接口:不只是匹配标题,可以对正文、附件名称做关键词检索,适合做比较细的商机筛选。
  • 公告详情接口:通过公告ID取某一条的完整内容,包括项目概况、投标人资格要求、联系方式等。
  • 关键词订阅接口:在平台上配置关键词后,可以把新匹配到的公告推送到你自己的回调地址,或者定期拉取。
  • 分类筛选项接口:获取行业分类、地区编码等基础数据,方便在业务系统里做下拉筛选。

我第一次看到能力清单的时候,第一反应是"这个接口能撑起一个小型标讯平台"。后来确实也验证了这一点,我自己用它搭了一个面向内部销售团队的商机监控系统,数据层面完全够用。

2.2 返回数据的字段结构解读

直接上一个我实际处理过的返回示例,字段名和结构做了脱敏,但格式是一样的:

{ "code": 0, "message": "ok", "data": { "list": [ { "id": 123456, "title": "某市智慧交通项目招标公告", "notice_type": "招标公告", "region": "浙江省", "industry": "交通运输", "publish_time": "2025-01-10 09:30:00", "deadline": "2025-02-10 17:00:00", "budget": 1250000.00, "agency": "某招标代理有限公司", "source_url": "https://example.com/detail/123456", "summary": "本项目主要建设内容包括交通信号系统升级、视频监控设备采购及相关配套服务。" } ], "total": 100, "page": 1, "page_size": 10 } }

这里有几个字段我要特别提醒。budget看起来是一个简单的金额,但很多项目不一定公示预算,实际返回可能是null,如果业务侧把"预算为空"当成"预算为0",那做统计报表的时候会非常尴尬。publish_time和deadline建议统一按北京时间处理,别用服务器的本地时区,我之前因为时区问题导致定时任务在夏令时切换的几天里推送时间全部错乱。source_url是官方落地页的地址,它不等于永久链接,有些详情页只对短期内有效的公告友好,时间一长可能跳转或者打不开,后面我会专门说这个问题。

2.3 几个容易忽略的隐藏功能

说两个容易被文档埋没、但实际很有用的点。

第一个是全文搜索接口的"召回率"和我想的不太一样。最初我以为关键词搜索只能在标题里匹配,后来发现它可以检索正文和附件名,甚至能搜出一些标题里完全没出现关键词的公告。这个特性在做商机挖掘的时候特别值钱。举个例子,很多市政项目标题是"道路提升改造",但正文里写到了"智能井盖",如果你只盯标题关键词,这类信息就漏掉了。所以我建议有条件的人优先测一下搜索接口的检索范围,把关键词配置做得宽一点。

第二个是增量同步的时间戳参数。部分接口支持传一个开始时间,只返回这个时间之后新发布或更新的公告。这比每次用page翻到底要高效得多。我一开始傻乎乎地全量翻页,每天产生上万次请求,还经常触发限流,后来改成"按最近更新时间增量拉取",请求量降到原来的十分之一,数据完整性反而更稳定。这个思路做数据同步的人应该都懂——增量永远比全量优雅。

3. 从零接入:申请密钥、鉴权细节和第一次真实调用

3.1 申请API Key之前需要想清楚的事

先泼一盆冷水:申请接口权限之前,最好先把自己想用它干什么想清楚。我见过太多人一上来就问"给我开个最高权限",结果拿到之后发现根本用不上,每个接口的调用量是单独计费的,预算蹭蹭就出去了。

正常流程是这样的:先在采招网注册账号,完善企业信息,然后找商务或技术支持申请开放平台权限。这个过程通常需要提供营业执照、业务场景说明,以及你预估的调用量。官方要通过资质审核,主要是防止接口被拿去批量倒卖数据。申请时你可以多要几个接口的测试权限,一般是免费的或者带少量免费配额,足够你跑通链路。

拿到API Key之后,第一件事绝对不是去写代码,而是把Key和Secret存好。我见过不少人直接把Key写在Git仓库里,或者贴在Jira工单上,这等于把钥匙挂在门口。建议用环境变量、专门的密钥管理服务,或者至少放到一个不参与版本控制的本地配置文件中。Key一旦泄露,别人可以拿你的配额去跑数据,轻则账单飙升,重则账号被限流封禁,处理起来非常麻烦。

3.2 签名和鉴权:不是简单的Token

很多刚接触这套接口的人会在鉴权这里卡一下。它并不是把API Key放到Header里就完事,而是要求对请求参数做签名,常见的流程是这样的:

  1. 准备公共参数,包括appid(就是你的API Key)、timestamp(当前Unix时间戳)、业务参数(比如page、page_size)。
  2. 把参数按字典序排序,转成key=value的字符串并用&拼接。
  3. 用API Secret对拼接字符串做HMAC-SHA256摘要,得到sign。
  4. 请求的时候把sign一起传上去,服务端用同样的方式计算签名,对比一致才会放行。

为什么要这么设计?简单说就是为了防重放。如果只传一个API Key,别人截获请求之后就可以无限重放你的请求;加了时间戳之后,服务端可以判断这个请求是不是在某个时间窗口内发出的,过期作废。再加签名,参数稍有改动签名就对不上。做数据接口的,这类鉴权已经是常规操作了。

我自己的签名函数是这么写的,大家可以参考:

import hashlib import hmac import time import requests API_KEY = "your_api_key" API_SECRET = "your_api_secret" BASE_URL = "https://api.example.com" # 以官方文档为准 def generate_sign(params: dict, secret: str) -> str: # 过滤空值并按 key 排序,保证签名结果稳定 items = sorted((k, v) for k, v in params.items() if v not in (None, "")) query_string = "&".join(f"{k}={v}" for k, v in items) sign = hmac.new(secret.encode(), query_string.encode(), hashlib.sha256).hexdigest() return sign def fetch_notices(page=1, page_size=10): params = { "appid": API_KEY, "timestamp": str(int(time.time())), "page": page, "page_size": page_size, "keyword": "", } params["sign"] = generate_sign(params, API_SECRET) resp = requests.get(f"{BASE_URL}/openapi/notice/list", params=params, timeout=10) return resp.json()

需要注意几个细节:签名时要不要把sign自身也放进去,不同平台规则不一样,官方文档里会写清楚;timestamp的时间单位是秒,别拿毫秒去算;另外有些接口要求用POST+JSON,签名参数放Body里,规则相同但拼字符串的方式可能略有差别。拿到文档后,第一件事就是用"最小请求"跑通签名,再逐步加业务参数。

3.3 用Python完成第一次可用调用

签名函数写好之后,第一次真实调用基本就是拼参数的事。我习惯先不写任何业务逻辑,直接用curl或者一个非常简单的Python脚本,把返回结果打印出来,确认能拿到数据再做下一步。

data = fetch_notices(page=1, page_size=5) if data["code"] == 0: for item in data["data"]["list"]: print(item["id"], item["title"], item["publish_time"]) else: print("request failed:", data["message"])

第一次看到正常返回的时候,说实话还是挺兴奋的,毕竟从"各种正则匹配网页文本"切换到"一个JSON直接给全字段",这个体验差异是巨大的。

在这里也提醒一下:如果第一次调用就遇到错误,不要急着怀疑是接口坏了,先按顺序排查三件事——参数是不是齐全,时间戳对不对,签名计算是否一致。大部分401、403都是这三类问题引起的。我刚开始有一次怎么签都不对,最后发现是因为我用了中文关键词,拼接签名的时候没有做URL编码,导致服务端算出来的签名和我的不一样。这类问题多看几眼官方示例就能避开。

4. 实战:搭一个招标商机监控与推送小工具

4.1 最小可用方案的选择

接入接口之后,最容易上手的第一个实战项目,就是做一个招标商机监控工具。需求很简单:每天定时去查一遍最新的招标公告,凡是标题或正文里命中了我们关心的关键词(比如"智慧城市""视频监控""系统集成"),就把这条公告推到工作群,让销售或商务人员第一时间看到。

方案选择上,我不推荐一上来就上Spring Boot、消息队列、Docker这些重家伙。对中小团队来说,一个Python脚本 + SQLite数据库 + crontab,再配合飞书、钉钉或者企业微信的机器人Webhook,完全够用。这样做的好处是:代码量小,部署简单,出了问题直接看日志就行,不需要额外维护一堆基础设施。

我遇到过很多新手,总想把工具做得非常复杂,又是搞微服务又是搞容器编排,结果光环境搭建就折腾了三天,核心业务还没碰。做这种内部工具,最重要的是快速响应业务需求,能跑、能推、能提醒就是胜利。

4.2 增量抓取与推送的核心实现

整个脚本的核心逻辑其实只有两步:先去接口拉最新数据,再和本地已处理过的公告ID做对比,发现新公告就推送。

去重这一环节很关键。如果每次都是全量拉取,不做本地去重,那同一个公告会被推送好几遍,群里全是重复消息,业务方很快就不看了。我的做法是本地维护一个seen表,只存公告ID,每次抓取时先拿到当前库里最大的ID,然后只处理大于这个ID的数据。

import sqlite3 import requests import time conn = sqlite3.connect("notices.db") conn.execute("CREATE TABLE IF NOT EXISTS seen(id INTEGER PRIMARY KEY)") conn.commit() def get_last_seen_id(): row = conn.execute("SELECT MAX(id) FROM seen").fetchone() return row[0] if row and row[0] else 0 def save_seen_ids(ids): conn.executemany("INSERT OR IGNORE INTO seen(id) VALUES (?)", [(i,) for i in ids]) conn.commit() def fetch_new_notices(keyword="智慧城市", max_pages=5): last_id = get_last_seen_id() new_items = [] for page in range(1, max_pages + 1): data = fetch_notices(page=page, keyword=keyword) for item in data["data"]["list"]: if item["id"] <= last_id: # 列表按发布时间倒序,遇到旧数据就可以停掉当前页循环 continue new_items.append(item) return new_items def push_to_webhook(items): webhook_url = "https://your-webhook-url" for item in items: msg = { "msgtype": "text", "text": { "content": f"新商机:{item['title']}\n地区:{item['region']}\n截止:{item['deadline']}\n链接:{item['source_url']}" } } requests.post(webhook_url, json=msg, timeout=5)

这段代码写的比较直白,胜在好理解好改。实际在项目里我会再加一个基础请求重试,比如遇到网络抖动或5xx错误时,用指数退避的方式重试两三次,而不是直接抛异常。推送消息的格式也要稍微克制一点,别把整篇正文塞进去,因为群消息太长了根本没人看。标题、地区、截止时间、链接这四样信息足够业务方判断要不要点开详情。

4.3 定时任务部署与日志排查

本地脚本写好后,部署很简单。我在服务器上放了一个目录,叫bidding-monitor,里面有三个东西:脚本文件、SQLite数据库文件、logs目录。然后用crontab加了一条定时任务:

*/10 * * * * cd /home/user/bidding-monitor && /usr/bin/python3 monitor.py >> logs/monitor.log 2>&1

每10分钟跑一次,这样从公告发布到推送进群,延迟一般不会超过10分钟。对于商机监控这个场景,这个频率是合理的。再高的频率比如每1分钟一次,容易触发限流,而且对业务的实际帮助不会提升太多,反而增加接口消耗。

日志是我排查问题的主要依据。每跑一次,脚本至少要打出以下几个信息:请求开始时间、返回码、本批次新增数量、推送是否成功、执行耗时。有了这些,后续不管是被群里的人问"为什么没推",还是接口突然报错,都能很快定位。我还养成了一个习惯:每天凌晨看一次昨天的错误统计,如果某种错误码在短期内突然增多,往往是接口参数变了、权限过期了,或者官方调整了限流策略。这种事早发现一天,就能少挨一天业务方的骂。

5. 高频报错与排查技巧实录

5.1 常见错误码速查表

接口用久了,常见的错误码基本就那么几种。我整理了一张速查表,方便大家对照排查:

错误码常见原因排查与处理
400参数格式不对、必填项缺失、日期格式错误检查请求参数是否和文档一致,注意字段类型
401签名错误、API Key不对、时间戳超时重新检查签名算法,确认服务器时间准确
403账号没有该接口权限,或套餐不含该能力联系商务开通对应接口权限
429超过调用频率限制降低并发,加缓存,做指数退避
500服务端异常先等待几秒后重试,多次出现则反馈官方
503服务暂时不可用或正在升级不盲目重试,观察一段时间再恢复

这里我想特别强调一下401的排查思路。很多人一看到401就疯狂怀疑自己的API Key写错了,但其实签名错误才是最常出问题的地方。我的排障顺序是:先用官方SDK或文档里的示例请求跑一遍,如果能通,说明Key没问题;然后把示例请求逐步替换成自己的参数,看到底是哪一步把签名破坏了;最后再检查请求到服务端的过程中,有没有因为URL编码导致参数和签名前的原始字符串不一致。

5.2 限流问题:429不全是坏消息

限流是很多人第一次跑这个接口时最容易遇到的坎,尤其是上来就开多线程并发拉全量数据的。我看到429的第一反应,从骂骂咧咧变成了冷静分析,因为429至少说明三件事:接口的鉴权是正常通过的,你的请求量真的很大,服务端在保护自己的资源。

应对限流,我自己的经验是三步走。

第一步,先看自己的请求必要性。是不是所有数据都需要实时拉?比如历史公告归档,完全可以在凌晨低峰期跑一次,不用在白天高峰期反复翻。第二步,给请求加缓存。同一个分页参数、同一个关键词,短期内多次调用的结果是一样的,直接在本地缓存里返回就行,没必要反复打接口。第三步,设计退避重试策略。遇到429,不要立即重试,建议先等几秒,再按指数递增:第一次等1秒,第二次等2秒,第三次等4秒,最多不要超过30秒。同时把错误记录下来,如果连续多次429,宁愿让任务暂停,也别硬刚。

我后来用增量拉取代替全量翻页之后,整体请求量降了一个数量级,基本再也没触发过429。所以说,好的数据同步设计不只是省流量,更是避免被打回来的最好方式。

5.3 避坑清单:这些坑我替你踩过了

最后列一份我自己踩过、也看别人踩过的坑,每条都是真金白银换来的。

API Key管理不规范。有人把Key直接写在代码注释里,或者提交到Git仓库,导致泄露后被刷了一整晚的接口。建议所有密钥都从环境变量读取,代码库只留一个.env.example。

时间戳和时区混用。服务器如果用了UTC,和官方的北京时间差了8个小时,增量同步和截止时间判断会出大问题。强烈建议在代码入口统一使用北京时间。

分页不是无限翻的。很多开放平台对page_size有上限,有的最大50,有的100,翻页太深之后效率也很差。正确做法是用时间增量去同步,而不是傻乎乎地翻几千页。

返回字段的source_url不要当永久链接使用。我最早把这条链接直接存进数据库,给业务系统做外链跳转,结果一个月后很多链接点击去已经跳到首页。后来我改成把公告详情里的关键字段也存下来,自己渲染一个内部详情页,链接失效的问题才彻底解决。

不要多任务并行跑同一个账号。有人为了快,开了十个进程同时拉数据,结果就是全部429,关掉九个进程之后才恢复。单账号的并发数是有限制的,想要更高并发得走官方流程申请,而不是自己偷偷开线程。

接口文档和实际返回不一致时,以实际返回为准。有一次文档里明明写了某字段是budget_money,但真实返回是budget,我按文档字段写代码,结果全是None,查了半天才反应过来。遇到这种情况,先打印一段原始返回,用眼睛确认字段名,再写解析逻辑。

按照我自己的习惯,现在会把采招网的接口日志单独拉到一个目录,每天凌晨看一眼错误分布,哪类错误突然变多往往意味着接口参数或套餐权限调整了。另外,接口返回里的source_url别直接存到数据库当永久链接,我之前吃过教训,有些详情页只对"当天新发布的公告"有效,过了几天再打开就跳走了,所以业务上要留个自己的落地页快照。如果你正准备接这套API,我建议第一周先只做一个只读统计脚本,把返回数据的字段真实性摸清楚,再上线正式业务。这比什么都重要。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询