Python requests爬取非遗名录:分页、会话与429限流应对全指南

Python requests爬取非遗名录:分页、会话与429限流应对全指南 简介面向需要快速上手 Python 网络爬虫的开发者这份资源以“国家级非物质文化遗产代表性项目名录”为目标页面完整演示 requestsBeautifulSoup 的抓取流程从构造 GET 请求、解析 HTML 到批量提取并保存数据适合作为爬虫入门与打包发布的双重练习素材。压缩包共 92 个文件、约 16.66MB除主脚本“非遗爬取.py”外还包含通过 PyInstaller 打包生成的 dist 可执行程序、spec 配置文件、dll/pyd 运行库及构建中间文件既能看到源码逻辑也能在不安装 Python 的环境中直接运行。已有 1222 人浏览学习。通过阅读源码和目录结构你可以掌握 requests 请求发送、BeautifulSoup 元素定位、多页遍历思路并了解 PyInstaller 打包细节同时项目自带pycache缓存和 build 目录便于对照理解 Python 程序从源码到 exe 的完整过程是一份兼顾文化遗产数据采集与工程化落地的实用示例。1. 非遗名录的数据获取先把 requests 用利落国家级非物质文化遗产代表性项目名录是文化领域里少有的“公开、结构化程度高、但官网不提供打包下载”的数据集。做非遗研究、文化数据可视化、展览策划第一步几乎都是同一个动作把网页上的名录条目变成自己手里的结构化数据。这类爬取任务有个共性目标站点技术栈不新数据要么直接渲染在 HTML 里要么由一个简单的 POST 接口返回 JSON用 requests 加一个解析库就够不需要上 Scrapy 或 Playwright。下面顺着“国家级非物质文化遗产代表性项目名录”这个具体目标把从拆页面、定位数据、写请求、扛限流到落库的完整链路走一遍。重点放在 requests 的会话管理、分页参数和 429 应对上这几处恰恰是爬政务类网站最容易翻车的地方。2. 拆解名录页先确认数据在 HTML 还是在接口里2.1 浏览器开发者工具里的三个判断信号拿到一个名录查询页先不写任何代码打开浏览器开发者工具F12看 Network 面板和 Elements 面板。判断目标数据属于服务端渲染还是前端异步加载有三个信号可以快速区分信号观察方式结论页面源代码里直接搜项目名称CtrlU 查看源码搜索“苏绣”“京剧”等词搜得到就是 SSR直接 requests GET 再解析Network 面板出现 XHR/fetch 请求刷新页面看返回 JSON 的接口数据由接口返回需要模拟 POST/GET 参数点击分页时 URL 是否变化切到第 2 页看地址栏和 Network 记录URL 变则适合直接拼参数不变则多半是 POST 接口非遗名录这类站点常见做法是后端渲染列表页加前端查询表单分页参数藏在 URL 或表单字段里。我一般会先翻到最后一页把地址栏参数差异记下来比盲目猜测参数名快得多。2.2 用 requests 探活并打印响应头确认页面可以直接 GET 后第一件事不是写完整爬虫而是用最小请求验证可达性。以下代码先不解析页面只确认状态码、编码和最终 URLimport requests 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.ihchina.cn/, } url https://www.ihchina.cn/project # 以实际页面地址栏为准 resp requests.get(url, headersheaders, timeout10) print(resp.status_code) print(resp.encoding) print(resp.url) print(resp.text[:500])这段代码只做三件事检查状态码是否为 200、确认站点返回的编码、观察是否发生跳转。很多政务站点对带参数的地址做归一化跳转直接爬跳转后的 URL 才能拿到完整列表。timeout10是必填项避免某个请求卡死拖垮整个任务。拿到响应后重点看resp.encoding。如果站点实际是 GBK 或 GB2312而 requests 的编码判断有偏差后续解析会乱码。稳妥做法是显式指定resp.encoding utf-8 # 或让 requests 从内容推断 resp.encoding resp.apparent_encoding需要说明apparent_encoding是通过内容字节统计推断的编码不一定等于站点声明的编码。遇到混合编码页面时直接指定实际使用的编码更可靠。3. 用 requests 拉取完整名录会话、分页与解析3.1 用 Session 维持会话并提交查询条件名录站点通常是输入关键词后搜索列表数据可能来自表单 POST也可能来自带查询串的 GET。无论哪种都建议用requests.Session()而不是每次裸调requests.get。Session 会自动保存 Cookie并复用底层 TCP 连接翻几十页时省下的握手时间很明显。import requests from bs4 import BeautifulSoup session requests.Session() session.headers.update({ 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9, }) search_page session.get(url, timeout10) soup BeautifulSoup(search_page.text, html.parser) # 收集表单里的隐藏字段政务站点常用 __VIEWSTATE 或 token 防伪 form soup.find(form) inputs {} if form: for inp in form.find_all(input): name inp.get(name) value inp.get(value, ) if name: inputs[name] value # 填入搜索关键词字段名以实际表单为准 inputs[keyword] 传统技艺 # 按表单 method 提交 if form and form.get(method, get).lower() post: resp session.post(search_page.url, datainputs, timeout15) else: resp session.get(search_page.url, paramsinputs, timeout15)这段代码先动态收集表单里的隐藏字段再把搜索词放进去。很多 ASP.NET 或 PHP 站点的表单里有一堆隐藏字段漏掉任何一个提交后都可能被拒绝。动态收集比手写死参数更抗站点改版。3.2 翻页参数与列表解析分页是这次爬取的核心搜索“传统技艺”大概率返回上千条结果每页显示几十条翻页逻辑写错数据就缺一大截。先观察第二页 URL 的变化规律常见三种情况分页方式特征处理方式URL 路径分页/list/2.html循环拼路径查询串分页?page2size20更新paramsPOST 分页表单里带pageNo更新data字典用一个函数统一处理分页请求先读第一页拿到总页数def fetch_page(session, page_no, base_params): params dict(base_params) params[page] page_no params[pageSize] 20 resp session.get(search_url, paramsparams, timeout15) resp.raise_for_status() resp.encoding utf-8 return resp.text # 第一页 html fetch_page(session, 1, base_params) soup BeautifulSoup(html, html.parser) # 从分页栏提取总页数选择器按实际页面调整 page_info soup.select_one(.pagination .total-pages) total_pages int(page_info.get_text(stripTrue)) if page_info else 1fetch_page把页码抽成参数后续翻页全部复用一个函数。注意 GET 用paramsPOST 用data混用会导致参数丢失这是写翻页时最常犯的错。列表解析要看页面实际结构。名录页通常是一个ul或table每个条目包含项目名称、批次、类别、申报地区等字段。以表格为例rows soup.select(table tr)[1:] # 跳过表头 for row in rows: cells [td.get_text(stripTrue) for td in row.find_all(td)] if len(cells) 4: record { name: cells[0], batch: cells[1], category: cells[2], region: cells[3], }get_text(stripTrue)会自动去除单元格内的空白字符和换行避免出现“苏绣\n\n江苏”这类脏数据。解析不建议用正则匹配整段 HTML用 BeautifulSoup 的选择器更稳页面结构变化时只需改选择器。3.3 从列表进入详情页补充字段列表页能拿到的字段有限通常只有名称、批次、类别和申报地区。如果需要项目简介、历史渊源、保护单位等信息得进一步进入详情页。详情页 URL 一般藏在列表项的a标签里from urllib.parse import urljoin detail_links [] for a in soup.select(td a[href]): href a.get(href) if href and href ! #: detail_links.append(urljoin(base_url, href))urljoin把相对路径拼成绝对 URL这是很多爬虫新手容易漏的步骤。详情页抓取复用同一个 Session但要注意控制请求速度详情页请求频率过高最容易触发 429。4. 429 Too Many Requests 的应对重试、退避与限速4.1 先搞清 429 是怎么来的搜索热词里频繁出现exceeded retry limit, last status: 429 too many requests这个报错在 requests 爬虫里几乎是必经之路。429 状态码的含义是服务端做了限流单位时间内请求数超过阈值。很多文化类站点的限流策略不复杂基于 IP 的滑动窗口计数或基于 Session Cookie 的请求频率限制。触发限流后服务器返回 429部分站点会在响应头附带Retry-After字段告诉客户端要等多少秒。如果忽略这个字段继续重试轻则等待时间越来越长重则 IP 被封一段固定时间。4.2 补全请求头并主动限速第一层防御是补全请求头只带默认 User-Agent 的请求太容易被识别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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Accept-Encoding: gzip, deflate, br, Connection: keep-alive, Upgrade-Insecure-Requests: 1, }Accept-Encoding声明可以接收 gzip 压缩requests 会自动解压减少带宽消耗。不要手动解压resp.contentrequests 在读取resp.text时已经处理过了。第二层防御是限速。详情页抓取时在两次请求之间加随机间隔import time import random for link in detail_links: try: r session.get(link, timeout15) r.raise_for_status() parse_detail(r.text, link) except requests.exceptions.HTTPError as e: if e.response.status_code 429: handle_429(e.response, session) else: log.error(f请求失败: {link}, {e}) time.sleep(random.uniform(1, 3))随机间隔是故意的固定间隔在服务端限流模型里更容易被识别为脚本行为。1 到 3 秒对几百个详情页来说总耗时仍然可控。4.3 指数退避重试器遇到 429 后高频重试只会加剧封禁。标准做法是优先读取Retry-After头没有该字段时按指数退避递增等待import time def fetch_with_retry(session, url, max_retries3, **kwargs): for attempt in range(max_retries): resp session.get(url, **kwargs) if resp.status_code 200: return resp if resp.status_code 429: retry_after resp.headers.get(Retry-After) wait int(retry_after) if retry_after and retry_after.isdigit() else 2 ** attempt print(f429等待 {wait} 秒后重试第 {attempt 1} 次) time.sleep(wait) continue resp.raise_for_status() raise RuntimeError(f重试 {max_retries} 次仍失败: {url})Retry-After存在时优先使用服务端给出的值最精确不存在时用2 ** attempt生成 1、2、4 秒的递增等待。三次重试后仍返回 429说明已接近封禁阈值此时应该停更长时间并检查请求频率而不是继续死磕。requests 内置的 urllib3Retry对象也能配置重试但它的短板是很难读取响应头里的动态Retry-After所以我更倾向手写重试函数控制粒度更细。4.4 给每个请求加日志记录爬上千条数据时没有任何请求是“一定成功”的。用标准库logging记录每次请求的状态码、耗时和 URL中断后能快速定位断点import logging logging.basicConfig( levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, handlers[ logging.FileHandler(crawl.log, encodingutf-8), logging.StreamHandler(), ], ) logging.info(开始请求 %s, url) resp fetch_with_retry(session, url, timeout15) logging.info( 完成 %s 耗时 %.2fs 状态 %s, url, resp.elapsed.total_seconds(), resp.status_code, )resp.elapsed是 requests 提供的耗时对象。如果日志里某个 URL 的耗时突然从 0.3 秒涨到 8 秒说明服务端开始限速这时应该主动降速而不是等 429 出现。要注意别把响应体打到日志里。站点返回的 500 页面可能包含服务器版本信息日志文件外传有安全风险记状态码和 URL 就够。5. 把名录落成结构化数据并做完整性校验5.1 用 SQLite 增量存储名录数据名录数据总量在几千条级别字段相对固定用 SQLite 比 MySQL 更合适不需要额外起服务Python 标准库直接支持import sqlite3 conn sqlite3.connect(ich_projects.db) conn.execute( CREATE TABLE IF NOT EXISTS projects ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, batch TEXT, category TEXT, region TEXT, detail_url TEXT UNIQUE, description TEXT, crawled_at TEXT DEFAULT (datetime(now, localtime)) ) )detail_url加 UNIQUE 约束是增量爬取的关键。再次运行时用INSERT OR IGNORE已有 URL 自动跳过conn.execute( INSERT OR IGNORE INTO projects (name, batch, category, region, detail_url) VALUES (?, ?, ?, ?, ?), (record[name], record[batch], record[category], record[region], record[url]), ) conn.commit()5.2 完整性校验的五个维度抓完数据直接拿去可视化之前先做一轮校验校验维度方法期望结果总数核对SELECT COUNT(*) FROM projects与页面公示总数一致空值检查SELECT COUNT(*) FROM projects WHERE region IS NULL趋近于 0批次分布SELECT batch, COUNT(*) FROM projects GROUP BY batch每批数量合理URL 唯一性SELECT COUNT(DISTINCT detail_url) FROM projects与总数相等抽样人工核对随机抽 10 条浏览器打开详情页比对字段内容一致抽样核对最重要。机器只保证格式正确不保证内容正确解析逻辑写错时所有数据会错得一致只有人工抽样能发现。5.3 导出 CSV 并做最终核对SQLite 适合程序化查询但合作方通常只要 CSV。导出时注意编码import csv with open(ich_projects.csv, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([name, batch, category, region, detail_url, description]) for row in conn.execute( SELECT name, batch, category, region, detail_url, description FROM projects ): writer.writerow(row)utf-8-sig会在文件头部写入 BOM这是 Excel 打开 CSV 不乱码的关键。最后用一句SELECT COUNT(*) FROM projects;和 CSV 行数做比对两边一致名录的数据就真正握在自己手里了。本文还有配套的精品资源点击获取