接到同事需求的时候,他手里拿着一张 Excel,里面躺着三百多家供应商的名字,每家都要核查有没有行政处罚记录。手动打开信用中国一家家搜,再复制粘贴,以每家三分钟算,一个下午就没了。而且这种活儿不是一次性的,下个季度还要复查一遍。我就想,干脆写个爬虫把“信用中国”里的行政处罚公示数据批量拉下来,结构化落库,以后每次只要把名单丢进去跑一遍就行。
信用中国(creditchina.gov.cn)是官方信用信息公示平台,里面的行政处罚公示数据属于公开信息,字段相对规范,适合做批量采集和二次加工。但和普通电商商品页不一样,这类政府公示网站经历过多次改版,网上能找到的旧教程很多已经失效,主要原因在于前端接口加了动态签名参数、部分页面走异步渲染,直接用 requests 请求静态 HTML 的方式很容易碰壁。这篇文章我会从需求场景说起,完整走一遍抓包定位、参数逆向、列表爬取、详情补全、增量更新和合规使用的过程,把我实际踩过的坑也一并列出来,给准备做同类数据采集的读者一个可以直接参考的模板。
1. 处罚数据的需求场景,以及为什么偏偏要爬这个站
很多人一听到“爬行政处罚”就默认是搞黑产的,实际不是。我接手这个需求后梳理了一下,真正的应用场景集中在企业风险控制、招投标辅助判断和深度舆情分析这几个方向。这类数据的特点是:权威、公开、更新有规律,而且带有明确的行政相对人、处罚机关、决定日期、处罚结果等结构化字段,非常适合做成自动化监控。
1.1 三个最常见的真实业务场景
供应商准入复核是目前最普遍的需求。一家大型企业选供应商,候选名单几十上百家,如果靠人工去信用中国逐条搜索,效率太低。更现实的是,很多企业的采购流程要求在短时间内完成多家供应商合规性筛查,这个时候自动化采集的价值就体现出来了:把供应商名单整理成 Excel,脚本逐家查询,命中处罚就把记录和链接输出到结果表,没有命中的标记为“干净”。
存量客户风险预警是另一个典型场景。融资租赁、商业保理、银行信贷这类机构,客户不是查一次就结束,而是需要持续跟踪。行政处罚记录往往是企业信用恶化的早期信号,比如一家企业突然因为环保问题被罚,后续可能影响经营和还款能力。我们的做法是每天跑一次增量采集,新增的处罚记录通过机器人推送到企业微信群,由风控人员判断要不要调整授信策略。
招投标辅助判断对工程类、咨询类企业很有用。很多招标文件要求投标人“近三年内无重大违法记录”,这里的“重大违法记录”通常指较大数额罚款、责令停产停业、吊销许可证等。把投标方名单批量跑一遍,按处罚类型和金额筛选出疑似不满足条件的单位,能在评标前就发现问题,避免中标后被人质疑。
1.2 “公开数据”不等于“好爬的数据”
理想状态下,政府公示数据应该有一个标准的开放接口,像数据库一样按字段查询。但实际操作中你会发现,这些网站的前端展示逻辑往往比商业网站更“复古而复杂”。信用中国经过多次改版,页面结构、接口地址、签名参数都换过好几轮,网上早年那种“直接 GET 某个 URL 拿 JSON”的教程基本失效了。
我总结这类网站的共同难点有三个。第一,动态签名 token。为了防止爬虫批量调用,前端 JS 会动态生成一个 token 参数,每次请求都不一样,直接构造请求的话缺了这个参数就会被拦截或者返回空数据。第二,异步渲染。列表页初始 HTML 里可能根本没有数据,数据是页面加载后通过 XHR 请求拿到的,必须找到真正的数据接口而不是去解析页面源码。第三,接口字段不稳定。同一个字段在不同时期、不同接口里可能叫法不同,给后续的数据清洗带来不少工作量。
这几个难点合在一起,注定了爬取信用中国的行政处罚数据不是“复制粘贴几行 requests 代码”就能搞定的,需要一套完整的分析流程。这也是我写这篇文章的原因——把你需要掌握的方法论和避坑经验都讲清楚。
2. 别急着写代码:先把网页到接口的数据链路摸清楚
很多新手拿到一个网站就急着写解析规则,结果解析半天发现页面里没有数据。正确的做法是先打开浏览器开发者工具,把请求链路看清楚,搞清楚数据到底是从哪个接口、以什么格式返回的。这一步花二十分钟,后面省两天。
2.1 四步锁定真正的数据接口
第一步,打开信用中国的行政处罚公示页面,按 F12 进入开发者工具,切到 Network(网络)面板,勾选 XHR/Fetch 过滤,只显示异步请求。保持开发者工具开启状态,在搜索框输入一个测试关键词,比如“某某建设有限公司”,然后点击查询。
第二步,观察 Network 面板中新产生的请求。正常情况下列表页面会发出一个或几个 XHR 请求,其中那个返回内容里包含“处罚机关”“决定文书号”等字段的请求,就是我们要找的数据接口。判断方法很简单,点击请求后在 Preview 或 Response 页签里查看响应内容,如果能看到列表数据,就锁定它。
第三步,查看这个请求的细节。General 部分能看到请求 URL 和请求方式(一般是 POST),Request Headers 部分能看到请求头,Request Payload 或 Form Data 部分能看到请求参数。这里要重点记录:请求头里有没有自定义参数、请求体里有哪些字段、哪些字段的值看起来像动态生成的。
第四步,也是最关键的一步,验证这个接口能否直接访问。用 Postman 或者写几行 Python 代码,把抓到的 URL、Headers、Payload 原样复现一遍,看能不能返回同样的数据。如果返回正常,恭喜,你已经拿到了核心数据通道;如果返回异常或者提示参数错误,说明请求里有动态签名参数,需要进入下一节的处理流程。
我在做的过程中发现,信用中国这类公示站点的列表接口通常返回 JSON 格式数据,里面会包含 total(总记录数)、records(当前页记录)等字段。如果返回的是 HTML 片段,也不要慌,用 parsel 或 BeautifulSoup 解析这个 HTML 片段即可,原理是一样的。
2.2 动态签名参数是怎么生成的
这是整个爬虫里最耗时间的环节。所谓的动态签名参数,本质上就是前端 JS 用固定算法把请求参数、时间戳、随机数拼在一起算出一个 token,请求时带上这个 token 表示“我是正常的浏览器”。要还原这个逻辑,核心手段是搜索前端源代码。
在开发者工具的 Sources(源代码)面板里,打开页面相关的 JS 文件,用 Ctrl+Shift+F 全局搜索你看到的 token 参数名。比如请求体里有个字段叫 h5token,就在 JS 里搜 h5token,看它的值是怎么赋值的。现在的前端代码虽然压缩过,但变量名和字符串常量一般不会被完全替换,搜索字段名往往能直接定位到生成逻辑。
我见过的签名参数生成方式中,最常见的是“拼接字符串后做 MD5”。比如把时间戳、关键词、页码、固定盐值拼成一个字符串,然后md5()加密生成 token。这里需要注意三点:拼接顺序、分隔符类型、盐值藏在哪个变量里。只要这三点还原对了,Python 里用hashlib.md5()就能复现。
如果前端代码做了重度混淆,直接阅读 JS 成本太高,一个更快的替代方案是使用 DrissionPage 或 Playwright 这类自动化浏览器工具,让浏览器自己去执行 JS 生成参数,Python 程序只需要负责操作页面、翻页、提取数据。代价是效率比纯接口模式慢一些,但稳定性很高,而且不依赖对签名算法的理解。我的建议是:先用浏览器自动化把流程跑通,确认数据结构和业务口径没问题,再决定要不要花时间去逆向签名参数。如果采集量不大、频率不高,浏览器自动化模式完全够用。
2.3 动手前先确认三件合规的事
爬虫不是能不能写的问题,而是怎么用的问题。行政处罚公示数据是官方主动公开的信息,采集这类数据本身没有原罪,但有三条线我在实际项目中始终坚持。
第一,采集范围只限定于公开可访问的数据。信用中国的行政处罚公示页不需要登录,直接打开就能看,这意味着它是面向全社会的公开信息。如果网站把某些数据放在登录后才能访问的区域,那就要谨慎了,绕过一个登录机制去抓取非公开数据是明确的风险行为,不做。
第二,严格遵守访问频率。不管网站有没有明确限制,我都会把请求间隔控制在 1 到 3 秒之间,单 IP 的并发数不超过 2。这个节奏虽然慢,但对目标网站的服务端压力小,也能有效降低触发验证码和 IP 封禁的概率。数据采集本质上是个长期工程,追求一时的速度往往得不偿失。
第三,数据使用不得违背个保和隐私原则。企业工商类和行政处罚类信息属于公开公示信息,但数据落到自己手里后,不能随意对外传播,更不能进行恶意拼接、人格攻击式披露。企业内部用于风控决策是可以的,但要在数据交付时注明数据来源和采集时间,避免被认定为“来源不明数据”。
3. 核心实现:把列表接口和详情页的数据稳定落库
明确了接口和合规边界后,就可以写代码了。我的实现结构分四层:会话层(负责维持请求头、token)、数据获取层(列表和详情)、解析清洗层、存储层。每层职责单一,后面任何一层改版,只需要改对应模块。
3.1 依赖选型和请求会话维护
Python 环境下,我用httpx替代requests,原因是它支持 HTTP/2、连接池管理和请求重试的配置更灵活。解析用parsel,合并了 CSS、XPath 和正则提取,比单纯用 BeautifulSoup 效率更高。存储选 SQLite,单机跑、字段不复杂,SQLite 完全够用,后面要上生产环境再切 MySQL 也不难。
请求会话的关键是保持统一的 headers、维持 cookies、自动处理 token。代码骨架如下:
import time import random import httpx from parsel import Selector HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36", "Referer": "https://www.creditchina.gov.cn/xypd/xzcf/index.html", "Accept": "application/json, text/plain, */*", } class CreditChinaClient: def __init__(self): self.client = httpx.Client(headers=HEADERS, timeout=15, follow_redirects=True) def get_token(self, keyword: str, page: int, page_size: int) -> str: # 这里根据实际逆向结果实现,核心是复现前端JS的签名算法 import hashlib ts = str(int(time.time())) raw = f"{keyword}|{page}|{page_size}|{ts}|your_salt_here" return hashlib.md5(raw.encode()).hexdigest()注意代码里盐值部分我用了占位符“your_salt_here”,实际项目里一定要替换成自己抓包逆向出来的真实盐值。不同时期的信用中国前端版本,签名算法和字段名都可能不同,抄代码没有意义,掌握逆向思路才是关键。
3.2 列表接口的请求构造与分页
列表接口的核心参数一般包括关键词、页码、每页条数、查询状态、搜索范围等。我从接口里观察到的典型结构如下(字段名以你实际抓包为准):
def fetch_list(self, keyword: str, page: int = 1, page_size: int = 10): token = self.get_token(keyword, page, page_size) payload = { "keyword": keyword, "page": page, "pageSize": page_size, "searchState": 2, "entityType": 2, "template": "xzcf", "token": token, } headers = {"h5token": token} resp = self.client.post(LIST_API_URL, json=payload, headers=headers) resp.raise_for_status() data = resp.json() # 假设结构为 data.records 和 data.total return data.get("data", {}).get("records", []), data.get("data", {}).get("total", 0)这段代码只是示意。实际字段名、token 位置、是放在 header 还是 body,都要以你自己的抓包结果为准。但结构框架是通用的:动态部分集中在一个方法里生成,后续站点改版时,改那个方法就行,不用动整个爬虫。
分页策略上有一个非常容易踩的坑:不要一味地为了减少请求数把 pageSize 调得很大。我实测过,pageSize 超过 50 以后,响应时间明显变长,偶尔还会被服务端判定为异常请求。我最终的配置是 pageSize 固定 10,配合 1.5 到 2.5 秒随机间隔翻页。这样单页数据量小,解析快,也不容易触发风控。
3.3 详情数据补全与字段清洗
列表接口返回的数据通常是摘要性质,包含企业名称、处罚机关、决定文书号、处罚日期、处罚结果这几项。但完整的处罚事由、罚款金额、没收违法所得等字段往往在详情页里。详情页有两种拿法:一种是根据列表里返回的详情 ID 调详情接口,另一种是直接请求详情页链接,从 HTML 里提取数据。
我用的是第二种,因为详情页的 HTML 结构相对稳定,而且直接拿链接,方便后续溯源。解析过程如下:
def parse_detail(html: str) -> dict: sel = Selector(text=html) fields = { "case_no": sel.xpath("//td[contains(text(),'决定文书号')]/following-sibling::td[1]/text()").get(), "punish_reason": sel.xpath("//td[contains(text(),'处罚事由')]/following-sibling::td[1]/text()").get(), "punish_basis": sel.xpath("//td[contains(text(),'处罚依据')]/following-sibling::td[1]/text()").get(), "punish_result": sel.xpath("//td[contains(text(),'处罚结果')]/following-sibling::td[1]/text()").get(), "punish_date": sel.xpath("//td[contains(text(),'处罚决定日期')]/following-sibling::td[1]/text()").get(), "punish_authority": sel.xpath("//td[contains(text(),'处罚机关')]/following-sibling::td[1]/text()").get(), } for k, v in fields.items(): if v: fields[k] = v.strip() return fields注意这种后代选择器在解析表格型详情页时很实用,但前提是页面里文本节点的格式是“字段名:值”或“字段名”紧跟一个单元格。如果字段名和值之间还有其他节点,就需要根据实际情况调整 XPath。另外,处罚日期经常出现“2023年05月18日”这种中文格式,入库前要统一转成 ISO 格式2023-05-18,方便后续排序和查询。
3.4 任务调度:重试、限速与去重落库
爬虫长时间跑,网络抖动、接口偶发 500、解析时某个字段缺失,都是常态。没有重试机制和去重逻辑,任务跑一半就断了,后面往往要人工重建,非常浪费时间。我在这个项目里做了三件事:
一是请求重试。统计失败次数,连续失败超过 5 次就停止任务并发送告警,而不是无限重试。重试使用指数退避策略,第一次等 2 秒,第二次 4 秒,第三次 8 秒,最多重试 3 次。
二是全局限速。整个爬虫使用单线程加随机延时,每完成一次请求后time.sleep(random.uniform(1.2, 3.0))。不要同时开多线程跑,信用中国这类站点对并发非常敏感,多线程带来的收益远低于封 IP 带来的损失。
三是以业务主键去重。处罚记录的唯一性由“企业名称”和“决定文书号”共同决定。我把这两列建成联合唯一索引,插入时使用INSERT OR IGNORE,新数据自动跳过已存在的记录,天然实现增量采集。
CREATE TABLE IF NOT EXISTS punish_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, company_name TEXT NOT NULL, credit_code TEXT, case_no TEXT, punish_authority TEXT, punish_result TEXT, punish_reason TEXT, punish_amount REAL, punish_date TEXT, detail_url TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')), UNIQUE(company_name, case_no) );落库前的最后一道工序是清洗处罚金额。行政处罚结果文本里经常混着中文大写和阿拉伯数字,比如“罚款人民币伍拾万元整”或“罚款人民币 500000 元”。我用正则统一提取数字部分,再转成数值型字段punish_amount,便于后续做金额排序和统计分析。
4. 容易被卡住的高频坑:验证码、翻页截断与数据漂移
写完代码不代表就能稳定跑通。我在这个项目里前后迭代了三周,卡住我的不是接口逻辑,而是几个隐藏很深的坑。这些坑如果没人提醒,你可能要自己踩一遍才能发现。
4.1 触发验证码的真实阈值与应对节奏
信用中国在短时间高频请求下会弹出滑块验证,我第一次跑就是吃了这个亏。当时为了图快,把间隔调到 0.3 秒,结果大概跑到第 80 个请求,页面开始返回验证码提示,整个 IP 被临时限制,后续请求全部失败。
后来我统计了一下,单个 IP 在大约每分钟 20 次请求以内是相对安全的,超过这个阈值,触发概率显著上升。所以我把节奏固定在每次请求后随机等待 2 到 3 秒,这样每分钟大概 20 到 30 次请求,跑几个小时没有问题。
如果不幸触发验证码,不要试图硬破。最稳妥的做法是停止当前任务,等待 10 到 15 分钟让限制解除,然后降低请求频率重新跑。如果项目对时效性要求高,可以准备一个合规的代理池,把请求分散到多个 IP 上,但代理质量直接影响数据完整性,免费代理的响应延迟过大会拖垮整个采集效率,不推荐。
4.2 接口翻页的隐性上限:看起来能翻到200页,实际只有前2000条
这是最坑的一个问题,单靠看接口返回的 total 字段根本发现不了。我最初的方案是一个关键词直接翻页,总共 2350 条记录,按 pageSize=10 算应该有 235 页,但脚本翻到第 200 页时,返回的数据突然变成了空数组,而且 total 字段还显示 2350。排查了很久才发现,接口对单个查询条件的结果集有隐性上限,超过前 2000 条之后就不再返回数据,但统计总数还是按全量计算的。
解决思路是拆分查询维度,让每个查询的结果集小于上限。具体做法有几种:按处罚日期区间分段查询,把“2020年至2024年”切成“2020年1月-6月”“2020年7月-12月”这样的小区间;或者按处罚机关所在地进行分类查询,用关键词加上地区维度缩小结果集。分段之后每个段落的记录数降下来了,翻页就不会撞到天花板。
这个坑在爬大型企业集团时尤其明显。一个知名房地产公司的处罚记录可能上千条,按企业名精确查仍然超上限,就必须用日期分段。我最终的做法就是把关键词和日期范围做成笛卡尔积,逐段采集,再统一去重合并。
4.3 数据漂移:别用接口返回的ID做唯一键
我发现列表接口里每条记录都有一个类似 ID 的字段,一开始想直接用这个字段做去重和增量更新的依据。跑了一轮增量后发现,同一家企业的同一条行政处罚,在两次接口返回中 ID 居然变了。原因可能是后端每次查询生成的临时序列不同,或者接口对不同查询维度生成了不同的聚合 Key。
这直接导致两轮采集之间产生了大量重复数据。解决方法是抛弃接口 ID,改用业务主键“企业名称 + 决定文书号”做唯一约束。决定文书号是处罚决定书的编号,同一家企业被处罚一次只会有一个文书号,在业务上是稳定的。哪怕处罚内容后续被更正,文书号一般不会变。
顺带说一个字段清洗的经验:处罚金额有时候不是数字,而是“壹拾万元整”这种中文大写。我写了一个简单的转换函数,把中文大写数字转成阿拉伯数字,再把“万元”换算成元。这种细节很容易被忽略,但做风险评分时,金额字段是核心指标,转换不干净会直接导致统计口径错误。
5. 数据到手之后:评分字段提取、增量更新与落地边界
采集只是第一步,爬下来的数据怎么变成业务能用的资产,才是真正拉开差距的地方。我把自己的处理方式分成三层:字段标准化、增量监控、按规矩使用。
5.1 把“处罚内容”字符串变成可统计的字段
原始处罚结果是一段文本,比如“罚款人民币10万元”,没法直接参与计算。我通过规则和关键词匹配做了一层轻量级的字段提取,不需要上 NLP,准确率已经足够:
PUNISH_TYPE_KEYWORDS = { "警告": "warning", "罚款": "fine", "没收违法所得": "confiscation", "责令停产停业": "stop_production", "吊销许可证": "revoke_license", "限制从业": "restrict_practice", } def extract_punish_types(text: str) -> list: types = [] for kw, code in PUNISH_TYPE_KEYWORDS.items(): if kw in text: types.append(code) return types def extract_amount(text: str) -> float | None: # 匹配“罚款XXX元/万元”模式 match = re.search(r"罚款[人民币]?\s*([\d.]+)\s*(元|万元)?", text) if not match: return None value = float(match.group(1)) if match.group(2) == "万元": value *= 10000 return value这两段代码的思路是把非结构化的处罚结果转成结构化的标签和数值。用它跑一遍全量数据,可以很直观地统计出某个区域的处罚类型分布、平均罚款金额、重点监管领域等,直接服务于风控决策。
5.2 增量更新和告警推送的工程做法
行政处罚数据不是一次性拉完就结束的,企业每天都在新增记录,爬虫需要按增量节奏持续运行。我的做法是每天凌晨跑一次全量关键词列表,每条记录插入前先查库,存在就不处理,不存在就插入并触发告警。
告警推送我接的是企业微信群机器人。核心逻辑是:新记录入库后,把企业名称、处罚机关、处罚日期、处罚结果拼成一段文本,通过 webhook POST 到群里。这样风控同事早上到公司,打开群消息就能看到昨天有哪些存量客户或者供应商新增了处罚记录,不用主动去查。
如果后续数据量增大、需要做更复杂的关联分析,我建议把 SQLite 里的数据同步到 ClickHouse 或 Elasticsearch。但在数据量低于百万级之前,SQLite 配合索引完全够用,没必要为了技术炫耀而引入重组件。
5.3 使用边界:能爬、能存,但别乱用
最后必须认真说一句:行政处罚公示数据虽然是公开数据,但采集和使用的边界不能含糊。
我把合规红线列成三条:第一,采集频率和方式不对目标网站造成实质性负担,不试图绕过登录、验证码或访问控制机制去获取非公开数据;第二,采集到的数据用于企业内部风控和合规审查,不向第三方批量转卖原始数据;第三,如果结果表里涉及自然人的处罚信息,严格控制访问权限,避免在公开场合展示,防止个人隐私被不当扩散。
我曾经看到过一些机构把行政处罚数据做成公开榜单,还附带法人的个人信息,这种做法非常危险。数据能够被采集到,不代表可以被任意使用,这一点从项目立项开始就要想清楚。
回到这个爬虫本身,我在实际维护中的感受是:这类政府公示网站改版频率不低,签名参数、接口字段、页面模板都可能调整,爬虫维护的重心不是“跑得快”,而是“改得动”。我的代码把签名生成、请求构造、解析规则、存储逻辑拆成了独立模块,站方一改版,我只需要定位到变化的那一层,改动量通常控制在一两个函数以内,整个任务就能继续运转。
另外一个心得是,处罚数据采集的稳定性,远没有想象中的“一次性全量拉取”方案来得靠谱。早期我追求一天内把几年的历史数据全搞定,结果频繁触发限制、频繁断点续跑,效率反而更低。后来改成每天按日期段小批量补数据,每天任务总量可控,跑完自动校验当天抓取数量与接口返回总数是否一致,不一致就重试漏掉的段落,整个系统变得非常省心。如果你的需求也是持续监控企业处罚动态,建议直接采用这种“细水长流”的策略,而不是一次压榨到位。