27 个官网,10709 条公告:一次招标数据采集复盘
散在 27 个医院官网、格式各不相同的招标采购信息,怎么收成一张能横向比较、能建模的表。这篇写完整流程,也写翻车的地方。
一、问题:27 个官网,没有一张统一的表
这个项目要解决的不是算法问题,是一张表的问题。实验任务要求采集河北省任务清单里序号 21—45 共 25 条记录对应医疗机构的「招标 / 采购 / 中标 / 公示」栏目。25 条记录去重后是 22 家医院官网,另备 5 家三甲医院补采,最后真正入库的公告来自 27 家官网,时间从 2014-04-25 一直到 2026-10-12。
麻烦在模板不统一:列表页有的用 ?page=2,有的用 /index_2.html;日期有的藏在标题后,有的只能从 URL 里认;正文被导航和页脚包着;附件有时直接是 PDF,有时是不带扩展名的下载接口。27 个站点逐个写死解析器不可维护——这是后面所有设计的出发点。
二、采集设计:两阶段、缓存、限速、附件
参考脚本是一个单文件、按站点写死选择器的爬虫,我把它重写成「配置驱动 + 两阶段 + 缓存」:
sites.py 只描述站点:列表 URL 怎么生成、条目链接正则、是否单页、最大页数、有没有 JSON 接口。
crawl_list.py 只翻列表页、去重,把标题 / 链接 / 日期落到 data/items/;连续两页没有新条目就停,避免死循环。
crawl_detail.py 逐条进详情页,抽正文和字段、下载附件,写 JSONL;已抓过的 URL 跳过,中断能接着跑。
两阶段的好处很直接:列表阶段挂了不影响已入库的详情;改抽取规则拿已落盘正文重跑,不用联网。正文落成 details/<站点>/<序号>_<日期>_<标题>.txt,文件名带日期和标题,核对时不用开数据库。附件按扩展名白名单过滤、单文件超过 80 MB 跳过,下载时带详情页的 Referer——有几个站点不带就直接 403。
三、字段抽取与清洗口径
正文抽取是踩得最深的一处。最初用「文字量最大的块」,结果好几个站点的整页导航比正文还长,抽出来全是栏目名。后来改成「按非链接文字量打分」:先去掉 script / style / nav / header / footer 这类噪声容器,再遍历候选块算「文字数 − 链接文字数」,class / id 像 article、content、detail 的给 1.6 倍加成,取最高分那块。
结构化字段全是规则抽的,没有现成接口:
- 招标状态:标题优先、正文前 600 字兜底,按流标 / 变更 / 中标 / 公示 / 招标的顺序匹配。
- 招标金额:先找「中标金额 / 成交金额」,没有就退到预算 / 最高限价;元、万元、亿元统一换算成元,并留原文与命中字段备查。
- 中标单位:按「中标单位:…」匹配,再用公司特征词过滤,遇到「见附件 / 无 / 另行通知」就放弃。
- 附件:扩展名白名单加「附件 / 下载 / 招标文件」字样,双重判断。
抽完还有一道人工复核:把金额异常高的挑出来看,多数是把「万元」当「元」,或者把预算当成交价。
公告状态是规则从标题和正文打上的标签,不是官方字段;后面只能当分类标签,不能当特征。
清洗的标准是「这条公告和招标投标有没有关系」。人事招聘、预决算公开、医院简介、政策文件、健康科普都算无关,最后一共剔除 1332 条。最终入库公告 10709 条,详情页正文 9579 个,网页附件 2830 个。正文文件比公告数少的部分没有硬凑,缺就按缺失留在表里。
四、分析:把字段变成能回答问题的表
分析口径尽量贴着 SQL 的 GROUP BY 想:按医院、年、月、标的类型、中标单位分组。清洗后的数据上做了五类建模:金额回归、标的类型分类、高价值采购识别、流标风险识别、聚类与时间序列。
- 金额回归:目标取 lg(金额),六模型五折交叉验证:随机森林 R²=0.425(对数尺度 RMSE 0.729),只用结构化特征的线性回归 0.189——「买的是什么」这段正文确实有增量信息。
- 标的类型分类:7 分类,只用正文、剔除标签列,准确率约 0.71,宏平均 F1≈0.62。
- 高价值采购识别:金额 > 10 万元二分类,同分布 AUC≈0.91;时间外推(2024 前训练、2025 后预测)掉到 0.66。
- 流标风险识别:随机五折 AUC 0.99,按医院分组验证降到 0.95,PR-AUC 从 0.89 掉到 0.62,模型排序还反转了。
- 聚类与时序:KMeans 用轮廓系数定簇数、LDA 取主题;月度公告量用 SARIMA 按 AIC 选阶外推 6 个月,残差 Ljung-Box p=0.025,仍有残余自相关。
三个防坑点比模型更值得记:
防标签泄漏:状态和类型是规则打上的标签,不能当特征,所以分类只用正文。防数字泄漏:金额从正文数字抽出来,建模文本统一抹掉数字,否则回归会退化成「把数字再抄一遍」。防验证失真:有分组结构时随机五折会高估泛化,所以随机 5 折与按医院分组 / 按时间外推两组结果并列汇报。
五、踩过的坑
- 编码:不少站点是 GBK / GB18030,猜错就整页乱码;按页面 charset、utf-8、gb18030、gbk 顺序试才稳定。
- 日期:列表不显示日期时要从 URL 认:
/2024/1/1/、20240101、2024-01-01都遇到过;正文里再兜底,仍没有就留空,不猜。 - 分页:有的站点第 1 页和首页不是同一个 URL,会重复抓;用 URL 去重加「连续两页无新增」兜底。
- 限速:同主机默认间隔 2 秒(CRAWL_DELAY 可调)加随机抖动,单域名并发默认 1;403 / 429 冷却 15 秒起、指数退避,最多重试 3 次——慢,但没把对方站点打疼。
- 缓存会「骗」你:命中缓存就不再请求,但忘了缓存里是旧页面就会以为规则没生效;所以「只读缓存重跑字段」单独做成脚本,改规则不联网。
六、我学到了什么
第一,口径写在代码前面:每个数字先定义清楚(什么算「入库」、什么算「无关」),再写实现,否则最后对不上数只能重跑。第二,采集是工程问题:两阶段、缓存、断点续跑、配置分层,比「用哪个库」重要得多。第三,验证要敢用难看的那种:随机五折 AUC 0.99 好看,按医院分 0.95、PR-AUC 0.62 难看,但它更接近换一家医院还能不能用的真相。
限制也留着:站点改版会让规则失效;附件里的表格只是落盘,还没做成结构化字段;27 家里 22 家来自任务清单、5 家来自备用名单,来源差异在报告里单独标了。下一步是把站点配置和列表解析抽成独立模块、补单元测试。
完整图表和口径在作品页:医院招标投标数据采集与分析。