少女祈祷中...
一言即诺
踩坑笔记

政务网站政策列表抓取:探测→分类→选择器→噪音治理

阅读 -- 次

前阵子给公司的政策抓取系统批量新增政务来源——各省市税务局、财政厅这类门户的"最新文件/政策文件"栏目。听起来是个体力活:一个站半小时,三十个站一天搞定。实际做完,我前后跑了三轮批量探测,最后接入 13 个省局,剩下 23 个分别归入瑞数锁死、IPv6 不可达、SPA 异步、403 四类死路。这篇文章把这些坑按顺序复盘一遍,核心原则只有一句话:先探测真实结构再写选择器,探测结论必须落档复用。政府站的探测成本极高(很多站一探测就触发 WAF 风控),同一个弯路全队只允许踩一次。

坑一:瑞数动态安全,412 就是死刑判决

现象。 第一轮拿 curl 逐局拉首页,北京、辽宁、吉林、山东、四川、青岛、宁波等一批站点返回的不是 200,而是 412(重庆是 202),页面只有一个很小的 JS 挑战壳。按常规思路,先换姿势:

# 换 UA、加 Referer、退协议,全套试一遍
for opt in \
  "-A 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'" \
  "-e https://www.chinatax.gov.cn/" \
  "--http1.1 -sS"; do
  code=$(curl -s -o /tmp/probe.html -w '%{http_code}' $opt "http://beijing.chinatax.gov.cn/" --max-time 15)
  echo "$opt -> $code ($(wc -c < /tmp/probe.html) bytes)"
done
# 实测:https 超时 + http 412,全姿势无一幸免

排查。 返回体里有两个标志性特征串:$_ts 和 MMB1_,外加一个随机文件名的外链 JS(形如 /随机串.js)。这三个凑齐一个,基本可以确定是瑞数(Riverd)动态安全:它要求执行一段混淆 JS 才能拿到真正的 cookie,纯 HTTP 客户端永远过不了第一关。

解法。 不是解,是止损:判定特征串 + 412/202 后直接归死路,换 UA、加 Referer、换协议全部无效,不要消耗重试轮次。我把这条写进了批量探测脚本的初筛规则,特征命中即打标,后续轮次自动跳过。北京局后来改走市财政局的公开栏目绕了过去——瑞数锁的是这个域名,不是这个地区的信息公开。

复盘。 对抗类 WAF 的第一正确反应是分类,不是攻坚。静态抓取体系里遇到瑞数就该写"验过的弯路",而不是留个"待尝试"反复吊着整条流水线。

坑二:泛域名占位页骗过可达性检查,还有山西/陕西拼写陷阱

现象。 批量探测里有几个域名 curl 返回 200、size 也正常,兴冲冲去提栏目,结果页面内容是统一的占位文案。更隐蔽的是:好几条不同域名返回完全相同的 size 和相同的栏目编号——这是泛域名解析下 NXDOMAIN 兜底占位页的典型指纹。

排查与解法。 从那以后我在流程里加了一步强制 title 鉴伪:每条域名单独抓 <title> 验证归属,标题含目标机构名才算真站。同轮还交了一次学费:域名拼写上山西是 shanxi、陕西是 shaanxi(多一个 a),我把两个域名的探测结果并排对比时才发现陕西局的"探测失败"其实是打到了不存在的域上。正确姿势是先查国家税务总局官网的省份导航页,拿权威子域清单再逐域探测,不要凭记忆拼域名。

复盘。 "200 + 有内容"不等于"这是目标站"。假阳性比失败更贵——失败你会重试,假阳性会污染下游数据。

坑三:IPv6-only 站点,curl 000 不代表站挂了

现象。 黑龙江、福建、河南、湖北、甘肃、青海等一批省级站,浏览器里有人能打开,我的机器上 curl 一律 000。

排查。 分诊命令一条:

getent hosts heilongjiang.chinatax.gov.cn
# 返回一串 AAAA 记录、没有 A 记录 → 域名只有 IPv6

有 DNS 记录、只有 IPv6、而本机/服务器出口没有 v6 链路,000 是本地网络问题,不是站点故障。这类站我统一归"网络不可达",与站点改版分开记录,等哪天出口有 v6 了再按表捞回。

复盘。 000、412、403 三种"不通"的处方完全不同:000 查网络出口,412 查 WAF 指纹,403 可能是风控黑名单。混为一谈就会在错误的方向上重试。

坑四:hanweb 的 recordset 双重转义——最漂亮的一次破局

现象。 浙江、江苏、安徽、江西、云南、大连这批省级站(hanweb CMS 家族),curl 拿回来的静态 HTML 里 <a> 标签只有导航链接,政策列表一条都没有,页面列表区靠前端 JS 渲染。看上去像 SPA,实际比 SPA 幸运得多。

排查。 我没有直接放弃,把整页原始 HTML dump 下来 grep 关键词("通知""文件"类词根),发现真实列表其实以双重 HTML 转义的形式藏在页面里:

&lt;recordset&gt;&lt;record&gt;&lt;![CDATA[&lt;li&gt;&lt;a title="关于×××的通知" href="/art/2026/7/28/art_2.html"&gt;...&lt;/a&gt;&lt;span&gt;2026-07-28&lt;/span&gt;&lt;/li&gt;]]&gt;&lt;/record&gt;&lt;/recordset&gt;

解法。 两步还原,代码在生产路径里验证通过:

import re, html
from bs4 import BeautifulSoup

raw = response.text
m = re.search(r'recordset&gt;(.*?)&lt;/recordset', raw, re.S)  # 兼容未转义的 <recordset> 变体
block = html.unescape(html.unescape(m.group(1)))               # 注意:两次 unescape

# 关键:先剥掉 record/CDATA 标记,再交给解析器
block = re.sub(r'<[!?]+\[?CDATA\[|]]>|</?record>', '', block)
for li in BeautifulSoup(block, "lxml").select("li"):
    a = li.find("a")
    title, href, date = a.get("title"), a.get("href"), li.find("span").get_text(strip=True)
    url = urljoin(final_url_after_redirects, href)   # 相对 href 必须以重定向后的最终 URL 为基准

两个最容易踩的细节:一是 CDATA 必须先剥。lxml 会把 CDATA 段当 bogus comment 整段吞掉,不剥标记直接丢给 bs4/lxml,解析结果是 0 个节点——我第一次就栽在这里,正则明明抓到了内容,select 出来却是空的。二是政务站 http→https→年份子目录的重定向链很常见,urljoin 基准要用重定向后的最终 URL,否则相对链接全部拼错。

复盘。 判断"JS 渲染"之前先看原始 HTML。很多所谓动态页只是把数据转义内嵌了,多一层 unescape 就能静态抓,比上浏览器方案便宜几个数量级。另外同类结论要横向复用:云南局首页带瑞数指纹,但栏目页可以静态还原——归类以栏目页为准,不要被首页指纹一棍子打死。

坑五:选择器写着简单,清洗全是细节

结构确认后写选择器,又是半天的坑:

  • 完整标题在 <a title=""> 属性里,列表正文常被截断成 xxx...,抓正文文本=抓残缺;
  • title 属性里还能再嵌 HTML 标签和实体(&lt;font&gt; 这种高亮残留)、【置顶】前缀、[国家税务总局XX税务局]机构名前缀。清洗流水线固定为:双重 unescape → 剥标签 → 剥行首前缀 → 空白归一。机构名前缀不剥,跨站去重和展示全脏;
  • 日期宽容匹配,别锚定。实测见过 2026-07-28、[2026-07-28]、2026/7/28、2026年7月28日 四种以上:
DATE_RE = re.compile(r'(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})')  # 非锚定 search,方括号自然被跳过
  • 表格型栏目(如湖南省财政厅,td4=日期、td3=文号)先数列序再写选择器,标题/文号/日期的列序错位是最常见的静默数据污染——不报错,只是每一行都张冠李戴。

各站结构样本:内蒙古是 li > a@title + span,上海是 ul.infolist li > a@title + span.time,广东是 div.fileList li > h4 a@title(日期带方括号),深圳是 div.listContent h4。没有跨站通用的选择器,每个站都要 dump 带日期的完整 <li>/<tr> 原始 DOM 样本逐一定。

坑六:地方站转载上级文件,去重命中 76%

现象。 接入深圳局后,当日抓取"入库 0 条",第一反应是选择器坏了。

排查。 拉出抓到的 15 条标题和全库比对——15/15 全部是此前从总局入库过的文件的原样转载。深圳局如此,多个市级"最新文件"栏目与总局标题重合实测可达 76%。

解法。 新接入站入库前必须先做全库归一化标题比对(剥机构前缀、去空白、去标点后再 hash),并在日志里显式记录去重命中数。跑通后我把监控语义也改了:入库 0 条 + 去重命中 N 条是预期行为,不是故障;真正要报警的是"抓取 0 条"和"抓取 N 条且去重 0 条但入库 0 条"这类断档信号。没有这一步,知识库会被转载文件重复膨胀,检索质量直接被拖垮。

坑七:栏目噪音黑名单永远补不完

政务部门站是"大杂烩栏目"重灾区:一个"最新文件"里混着采购、招标、中标、停车、会议、人员招聘、检查询问、考试、继续教育。这些通用噪音我做成 title_exclude 黑名单一次性排掉了。真正麻烦的是"文件发布"类栏目里的宣传体裁:热点问答 / 一图看懂 / 图解 / 小课堂 / 小知识 / 码上监督 / 漫解 / 答记者问,以及——以问号结尾的标题,例如"个人所得税专项附加扣除如何申报?"。问句标题是个开放集,任何有限词表都有漏网之鱼。

结论性打法:排除词一轮定不完,新站首轮回灌后必做一次全库噪音 grep 盘点,按实际漏网形态补词,而不是幻想一版黑名单用到永远。同理,接一个站前先算目标主题占比:北京市政府政策文件页能静态抓 999 条,但行业专项类占比只有 5%,接进来宏观噪音反而污染匹配——吞吐量大不等于值得接。省级政府公报类也是性价比陷阱:期次列表藏在 JS dataItem.push 里、没有固定"最新一期"入口、列表项无日期,要两步式接入,而且内容多半已经被部门站收录过。

坑八:批量循环的临时文件卫生——自己坑自己

现象。 有一轮批量探测,某站明明 curl 已经失败了,脚本却报告"抓到 12 条",数据还是上一个站的。

排查。 循环里所有站复用同一个固定临时文件名:前一站的 dump 还在盘上,本站 curl 失败没写新文件,读的时候读到的就是旧站残留——假结论。这种 bug 不报错,只会把探测结论整体污染。

解法。 三条硬规则:每个目标独立临时文件名;写前 rm -f;读前先判文件存在。另外把"文件缺失 / 空文件 / 疑似短页(挑战壳、占位页)"和正常页分开显式打印,任何一种异常都不允许沉默地流进统计。

差点省下的弯路:search5"统一接口"证伪

探测中途我发现省局首页 meta 里都有形如 bm29\d{6,8} 的 siteCode,而总局公开了一个 search5 JSON 搜索接口。顺着这个思路,"借总局域名统一拿全部省局数据、绕开所有省局 WAF 和 IPv6 封锁"似乎是完美捷径。实测:

https://www.chinatax.gov.cn/search5/search/s?siteCode=bm29120006&column=最新文件
→ HTTP 200,searchResultAll 为空(换 column 值、去掉 Referer 都一样)

只有总局自己的 bm29000002 + column=政策法规 有数据。统一接口捷径不存在,被封锁的局只剩浏览器渲染或接受不可达两条路。另外 fgk.chinatax.gov.cn(总局法规库)的静态列表页也是同族假象:页面能打开,数据是 layui 异步加载的,真正数据源在上面的总局 search5 JSON 接口——这个接口对总局自己反而是通的。

收尾:探测矩阵落档

最后我把 36 个省级局的逐局实测结论落成一张矩阵表:域名子域、状态(✅已接入 / ⚠️可抓暂不接 / 🔒瑞数 / 📵网络不可达 / 🕸SPA / ⛔403)、"最新文件"栏目候选路径、结构家族、siteCode,连同 search5 证伪记录一起存档。之后任何人(包括半年后的我)接新省局前先查表,命中已验结论不许重测,站点改版才更新表格。

回头看,这套活里没有一行"高深"代码,值钱的全是判断:412 见指纹即止损、200 要验 title、000 先查出口、JS 渲染前先看转义、抓 0 条先查去重日志、批量循环管好临时文件、统一捷径先证伪再接手。政务抓取真正的门槛不是技术栈,是每个结论都有出处、每条死路都有案底。

评论

总浏览 -- 次 · 总访客 -- 人 · 今日浏览 -- 次 · 今日访客 -- 人