ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

基于requests+BeautifulSoup4的知乎爬虫实战:从限流处理到分布式演进

基于requests+BeautifulSoup4的知乎爬虫实战:从限流处理到分布式演进 简介基于Python3、requests与BeautifulSoup4开发的知乎内容爬虫源码包面向正在学习Python爬虫或需要采集知乎公开数据的开发者尤其适合处于入门到进阶阶段的爬虫爱好者。该爬虫可获取三类信息一是问题ID、标题、提出时间及所属子话题二是问题详情与所有回答涵盖回答人、回答内容、回答时间与赞数三是点赞者ID及其赞同数、感谢数、提问数、回答数等公开数据基本覆盖了知乎问题页面的核心维度。资源包共7个文件以3个Python脚本为主体分别负责核心爬取、工具封装与模块初始化另含json配置文件便于修改请求参数、README说明文档辅助理解运行方式以及LICENSE许可和gitignore文件整体仅6KB结构紧凑适合直接阅读和二次开发。已有290人学习浏览适合用来理解BeautifulSoup在静态页面解析中的使用方式、爬虫项目的目录组织方法以及配置文件的常见处理思路。读者通过这套源码可以快速掌握请求构造、结果解析和输出存储的基本流程并在此基础上根据自身目标扩展为更完整的知乎数据采集工具是一份小巧而实用的学习素材。1. 从“基于 python3requestsBeautifulSoup4 的知乎内容爬虫源码包”拆起基于 python3requestsBeautifulSoup4 的知乎内容爬虫源码包是爬虫入门阶段最常见的一个压缩包。它不需要 Selenium不需要 Scrapy单机单进程就能把知乎热榜、问题页面的静态 HTML 抓回本地。拿到源码先别急着解压跑这个技术组合有两个天然边界requests 拿到的 HTML 可能是空壳BeautifulSoup4 解析不了 JS 动态渲染的模块知乎对未登录请求有严格的频率限制一旦日志里出现exceeded retry limit, last status: 429 too many requests说明你被限流了而不是代码写错。这篇文章就把这类源码包的骨架拆开请求层怎么做、BeautifulSoup4 怎么解析、重试与落盘怎么组织、最后怎么往分布式方向演进。适合两类读者刚学 python3 基础、想拿真实站点练手的人以及下载了源码但跑不起来、想补全工程细节的维护者。2. requests 请求层先解决 429 too many requests再谈爬取requests 爬虫最常踩的坑不是解析失败而是请求发出去拿不到内容。知乎网页版对未登录、非浏览器来源的请求非常敏感接口会直接返回 403 或 429。第一部分必须先处理两个问题怎么让服务器觉得你是一个正常浏览器以及当限流发生时怎么退避。这两件事做完后面对接 BeautifulSoup4 才有意义。2.1 复刻一个浏览器会话headers、Session 与超时用 requests 写爬虫时别直接用requests.get(url)那等于把身份信息写在脸上。常见做法是创建一个requests.Session()然后在 Session 级别统一设置 headers这样同一个会话内的连接复用、Cookie 管理都会更贴近真实浏览器行为。下面是源码包中requester.py最常见的初始形态import requests session requests.Session() session.headers.update({ User-Agent: ( Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 ), Accept-Language: zh-CN,zh;q0.9, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/avif,*/*;q0.8, Referer: https://www.zhihu.com/, }) session.trust_env False逻辑说明User-Agent必须带浏览器完整标识只写python-requests一定会被识别Referer填知乎首页是因为部分路由会校验来源页trust_env False是为了避免 requests 自动读取本机环境变量里的额外配置防止请求路径被意外改动。参数说明Accept-Language强烈建议保留知乎内容以中文为主不声明语言可能拿到空列表timeout不要写死参数对应的数值上限吗——是的一定要写。下面发起每个请求时都带上超时try: resp session.get(https://www.zhihu.com/hot, timeout10) resp.raise_for_status() except requests.exceptions.RequestException as exc: print(f请求失败: {exc})timeout10表示连接与读取总超时被限制在 10 秒避免某个慢请求把整个爬虫拖死。raise_for_status()会在状态码为 4xx/5xx 时抛出异常防止你拿到错误页面后还继续喂给 BeautifulSoup4。2.2 Retry 参数怎么给才不会被 429 拖死日志里出现exceeded retry limit, last status: 429 too many requests时很多人第一反应是加大重试次数结果越重试越被封。429 的含义是“已经限制你了”正确做法是让 requests 在遇到 429 时自动等待指数级时长后再尝试并且设置一个上限。用urllib3.util.retry.Retry可以做到from requests.adapters import HTTPAdapter from urllib3.util.retry import Retry retry Retry( total5, connect3, read3, status5, status_forcelist(429, 500, 502, 503), allowed_methodsfrozenset([GET, HEAD]), backoff_factor2.0, ) adapter HTTPAdapter(max_retriesretry, pool_connections10, pool_maxsize10) session.mount(https://, adapter) session.mount(http://, adapter)逻辑说明Retry不只是对数据包做更安全的重试backoff_factor2.0会让每次重试等待时间成倍增加第一次等 2 秒第二次等 4 秒第三次等 8 秒直到total5次用完。status_forcelist里显式包含 429让 urllib3 把 429 当作可重试错误allowed_methods限制为 GET 和 HEAD因为 POST 请求不能随便重试会造成重复提交这在知乎场景下主要是登录接口才用到爬公开页不需要。参数表格下面列出来方便直接抄参数作用建议值total所有可重试情况的总上限3 到 5不要超过 8connect连接失败重试次数3read读取失败重试次数3status收到指定状态码后的重试次数5backoff_factor重试等待的递增基数1.5 到 3.0status_forcelist触发重试的 HTTP 状态码429、500、502、503当所有重试耗尽时requests 会抛出RetryError。源码包里最好把这种情况单独处理而不是只打印一行日志后退出from requests.exceptions import RetryError try: resp session.get(url, timeout10) except RetryError as exc: print(f[{url}] 已超过重试上限原因: {exc}) # 等待一分钟后再尝试或者把 URL 写回待处理队列RetryError和普通RequestException不同它明确告诉你“不是网络断了一次而是连续重试都被拒”。看到这个错误请停止爬取检查频控策略而不是继续新开线程。2.3 限速节奏interval 与 backoff 的取舍Retry 是已经撞墙后的补救限速才是预防。最简单的限速就是在每次请求前time.sleep()但这个间隔不能拍脑袋。知乎的普通匿名访问低频抓取建议间隔 3 秒以上如果抓取热榜这类单页可以放宽到 2 秒如果访问的是问题详情页最好提高到 5 秒以上。封装一个带间隔的请求函数放在源码包里import time def fetch_with_pacing(session, url, interval3.0): 每次请求前至少等待 interval 秒超时 10 秒。 time.sleep(interval) resp session.get(url, timeout10) resp.raise_for_status() return respinterval是请求之间的基础间隔它能保证你的 QPS 被压到很低水平。更完整的做法是引入一个最小间隔与随机抖动的组合import random import time def fetch_with_jitter(session, url, base_interval3.0): jitter random.uniform(0, 1.2) time.sleep(base_interval jitter) resp session.get(url, timeout10) resp.raise_for_status() return resp随机抖动让请求时间点不再规律能显著降低“一眼就是机器”的概率。不要小看这些细节很多“为什么别人能抓我不能抓”的差异就在于User-Agent、超时和抖动这三个参数上。这一层做扎实后再进入 BeautifulSoup4 解析阶段至少你能保证每次拿到的都是完整 HTML。3. BeautifulSoup4 解析层:把知乎 HTML 变成可查询的数据结构requests 负责把页面拿回来BeautifulSoup4 负责把页面里的内容抠出来。这一层要解决两件事怎么选元素怎么在页面改版后不马上崩。知乎页面现在有很多内容是接口渲染的但热榜页和部分问题页仍然是服务端渲染因此用 BeautifulSoup4 解析依然可行只是目标要选对首页、热榜、专栏文章列表这类静态内容适合解析动态时间线不适合。3.1 用 select 而不是 find从热榜页上提取问题链接知乎热榜页面的结构里问题标题通常是a href/question/xxxx形式。用 BeautifulSoup4 解析时有人习惯find_all(a)然后遍历再判断 class 或 id这样写一旦页面调整 class 名字就全废。更稳定的做法是直接用 CSS 选择器锁定链接前缀from bs4 import BeautifulSoup def parse_hot_page(html: str): soup BeautifulSoup(html, html.parser) items [] for a in soup.select(a[href^/question/]): href a.get(href) text a.get_text(stripTrue) if href and text and len(text) 5: items.append({ url: https://www.zhihu.com href, title: text, }) return items逻辑说明soup.select(a[href^/question/])选取所有 href 属性以/question/开头的链接避免匹配到/api/v4/questions这类接口地址。get_text(stripTrue)会把标签内的换行和空格压缩掉得到干净的标题文本。len(text) 5这个过滤条件很关键它能排除“更多”“去回答”这类短链接文字。参数说明html.parser是 Python 标准库自带的解析器不用额外安装依赖解析速度略慢于lxml但在知乎热榜这种规模下完全够用。如果源码包里基于性能考虑换成了lxml记得在requirements.txt中标注否则部署到干净环境会直接报No module named lxml。3.2 js-initialData 中藏着的结构化数据热榜链接是比较浅的解析遇到问题详情页时情况就复杂了。知乎问题页会在 HTML 里注入一个script idjs-initialData标签里面是 JSON 格式的初始状态包含问题标题、描述、回答数、关注数等字段。BeautifulSoup4 在这里的职责不是自己去遍历 DOM而是把这个 script 标签里的内容取出来交给json模块处理import json from bs4 import BeautifulSoup def parse_question_page(html: str): soup BeautifulSoup(html, html.parser) script soup.find(script, idjs-initialData) if not script or not script.string: return {} try: initial_data json.loads(script.string) except json.JSONDecodeError: return {} question ( initial_data .get(initialState, {}) .get(question, {}) .get(question, {}) ) return { title: question.get(title, ), detail: question.get(detail, ), answer_count: question.get(answerCount, 0), follower_count: question.get(followCount, 0), }script.string在 BeautifulSoup4 里是取脚本内容但 JSON 里可能包含 HTML 转义字符直接json.loads有时会报错。稳妥做法是用script.string.strip()如果仍有问题就先html.unescape()再解析。上面代码里加了json.JSONDecodeError的容错源码包在正式环境中一定要这样写因为知乎的 initialData 偶尔会出现字段类型变化一旦结构对不上就返回空字典而不是让整个爬虫崩溃。initialData的嵌套层级是initialState.question.question这是过去几年相对稳定的结构。但它不是公开 API 文档随时可能调整所以解析时每一层都要用.get()加上默认值。这也是为什么文章开头强调“先想清楚要爬什么”因为 BeautifulSoup4 只能帮你找到入口真正的结构化数据处理还是要靠 JSON 路径。3.3 解析器选择与常见报错BeautifulSoup4 的find_all和select是两套风格。find_all(div, class_Card)在标签修改后就失效;select(div.Card)也一样但select对选择器表达式的复用性更好写出来的代码会更接近前端工程师的表达习惯。我一般会在源码包里统一封装一个query_all函数def query_all(soup, selector): return soup.select(selector)这样后续调整选择器时只改字符串即可。下面是html.parser与lxml的对比源码包若考虑性能直接替换解析器优点缺点适用场景html.parser标准库、免安装解析速度偏慢热榜、问题页等中小文档lxml速度快、容错性强需要额外编译依赖高吞吐、大批量抓取html5lib符合 WHATWG 规范速度最慢处理严重不规范 HTML常见的AttributeError: NoneType object has no attribute find_all多半是soup.find(...)没有找到元素。这通常不是代码问题而是请求到了登录跳转页或风控页。遇到这种情况先把resp.status_code和resp.url打印出来检查是否被重定向到sso.zhihu.com。如果被重定向代表 Session 中没有有效 Cookie需要回到第 2 章检查请求头的完整性。4. 源码包工作流限速、去重、增量抓取把 requests 和 BeautifulSoup4 写在一起只是脚本不是源码包。真正的 .zip 源码包含的是工程结构请求层、解析层、存储层要分离抓取过程要有去重与增量的概念。这一章负责把前面几段代码拼成一个可以被调用的工作流。4.1 把爬虫代码拆成 requester / parser / storage 三块不要把所有逻辑塞进一个main.py后期没法维护。常见做法是建三个文件每个文件只做一件事模块职责核心函数输入输出requester.py请求与重试fetch_with_pacingURLrequests.Responseparser.py页面解析parse_hot_page/parse_question_pageHTML 字符串list[dict]storage.py数据落地save_jsonl/save_html数据列表、HTML本地文件这样拆分后调试时只跑parser.py不触网只跑requester.py不解析。源码包里如果没有这种拆分说明它还是教学脚本你应该自己补上。下面是一个最简单的调用入口from requester import session, fetch_with_pacing from parser import parse_hot_page from storage import save_jsonl def main(): html fetch_with_pacing(session, https://www.zhihu.com/hot, interval3.0).text items parse_hot_page(html) save_jsonl(items, hot_data.jsonl) print(f保存 {len(items)} 条数据)main()里不处理具体逻辑只做编排。这里fetch_with_pacing的返回值是完整 Response取.text时可能会丢失编码信息如果需要保留编码直接resp.encoding resp.apparent_encoding后再取文本。知乎页面本身是 UTF-8一般不需要额外处理但保存到文件时必须显式声明。4.2 用 visited 与时间戳做增量更新爬虫源码包最常见的问题是不去重。同一个问题链接被反复抓取既浪费请求又容易触发 429。在单机层面最经济的方式是用一个集合visited记录已经抓过的 URL再用一个存储文件做持久化import json from pathlib import Path visited_path Path(visited.json) def load_visited(): if visited_path.exists(): return set(json.loads(visited_path.read_text(encodingutf-8))) return set() def mark_visited(url): visited.add(url) visited_path.write_text( json.dumps(list(visited), ensure_asciiFalse), encodingutf-8, )逻辑说明visited存入 JSON 文件后爬虫中断也能从上次位置开始。mark_visited每次调用都写整个集合数据量大了以后性能差但源码包前期完全够用。改进做法是先把visited存在append的文本里或者直接用 SQLite 表去重这里不展开因为核心思想是用“已经见过”的 URL 来过滤新任务。增量更新是去重的延伸。热榜上的问题会变同一个 question id 的答案数会增加。源码包里可以在storage.py里给每条数据写入crawl_time字段保存时按question id update_time作为去重键而不是只按 URLdef build_record(question_data, crawled_at): return { **question_data, crawled_at: crawled_at, }这样第二次抓同一个问题时你可以对比answer_count是否变化只有变化时才更新本地文件。这个逻辑并不复杂但能让你的爬虫从“一次性玩具”变成“可持续运行的数据管道”。4.3 把响应保存成原始 HTML方便再解析解析时最容易出现的问题是选择器写错页面结构变了但你手里只剩解析后的数据无法回溯。源码包里尽量把符合过滤条件的页面原始 HTML 保存下来作为调试底稿def save_html(raw, filepath): Path(filepath).write_text(raw, encodingutf-8)在main()中调整流程html fetch_with_pacing(session, https://www.zhihu.com/hot, interval3.0).text save_html(html, fixture/hot.html) items parse_hot_page(html)这里的fixture/hot.html是个关键细节。它不仅是调试资源更能在后续改动解析代码时充当回归测试的数据源。源码包如果带着几个 fixture 文件维护成本会低很多。把 HTML 存下来并不会占太多磁盘空间但能帮你搞清楚是 requests 没拿到内容还是 BeautifulSoup4 没解析对省掉一半排错时间。5. 进阶把单机知乎爬虫放到分布式队列并用回归验证兜底单机跑通后热词里 “分布式爬虫” 就成了下一个目标。不要把分布式想得太复杂只做一件事从“一个进程抓所有 URL”变成“一个队列管所有 URL多个 worker 消费”。requests BeautifulSoup4 的解析部分可以原封不动保留只把fetch_with_pacing放进 worker 中。5.1 用 Redis 队列简单切分任务import redis import json r redis.Redis(hostlocalhost, port6379, db0) queue_key zhihu:question_urls def worker(task_limit10): for _ in range(task_limit): raw r.lpop(queue_key) if raw is None: break url raw.decode(utf-8) html fetch_with_pacing(session, url).text parsed parse_question_page(html) if parsed: save_jsonl([parsed], f{parsed.get(title)}.jsonl)这里lpop从队列左侧取任务多个 worker 同时运行也不会重复拿同一个 URL因为 Redis 的 pop 操作是原子的。去重仍然要做不过可以移到 Redis 的set里每次往队列塞 URL 前先sismember判断。分布式扩展的收益在于 IP 频率限制被分散到了多个执行节点所以限速参数可以按单个 worker 计。5.2 用响应快照验证源码包不被改坏做的最后一件事是测试。把第一节保存下来的fixture/hot.html做进 pytest 测试后续每次更新源码包先跑测试再上线from pathlib import Path from parser import parse_hot_page def test_parse_hot_page(): html Path(fixture/hot.html).read_text(encodingutf-8) items parse_hot_page(html) assert len(items) 10 assert all(i[url].startswith(https://www.zhihu.com/question/) for i in items)这个测试不请求网络只在本地验证解析逻辑。当知乎页面改版导致选择器失效时这个测试会第一时间报错而不是等爬虫跑了一天之后才发现全是空数据。使用 requests 与 BeautifulSoup4 的源码包能不能长期安全运行主要就看有没有这一层测试防护。本文还有配套的精品资源点击获取
返回列表