资讯详情 知乎公开回答轻量采集:Requests+BS4绕过登录与渲染陷阱
📅 2026/10/13 8:23:28
简介本资源是一份面向Python初学者与爬虫实践者的实战项目包聚焦知乎问答社区中文本与图片的自动化采集需求适用于数据采集、内容存档及小规模信息分析等场景。压缩包为4KB的ZIP格式共含3个文件2个核心Python脚本get_data.py负责HTTP请求与HTML解析save_data.py实现文本提取、图片下载及用户信息保存和1个requirements.txt依赖清单结构简洁、即装即用。已有1144人学习下载反映出其在入门级网络爬虫教学中的实用价值。读者可直接运行代码获取结构化回答文本、原始图片二进制文件及作者ID/主页链接并通过内置延时策略与计数控制规避基础反爬风险配套依赖声明也便于环境快速复现是理解requestsBeautifulSoup协同解析、文件I/O与基础反爬应对的典型轻量级范例。1. 知乎回答批量采集不是“封号倒计时”用 Requests BeautifulSoup 做轻量级文本图片抓取避开登录态与动态渲染陷阱很多开发者第一次想从知乎拿点真实问答数据做 NLP 练手、舆情分析或知识图谱构建时直接上 Selenium结果发现页面加载慢、截图黑屏、验证码弹窗、IP 被限流、爬完 3 条就 403 —— 以为是知乎反爬太狠其实只是没看清它的内容分发逻辑。真相是知乎 PC 端绝大多数公开回答的正文 HTML 是服务端直出的非纯 JS 渲染只要绕过登录态校验、模拟合理 UA 和 Referer、处理好 JSONP 接口跳转用纯 Requests 就能稳定拿到结构化文本和原始图片 URL。本项目不依赖浏览器自动化不触发风控策略不破解加密参数只做「已公开、可右键查看源码」范围内的合规采集。适合某高校课程设计中的数据预处理环节、某跨平台系统的内容冷启动阶段或 A 同学做毕业设计时需要 500 条高质量问答样本。它不是万能采集器但对「非登录用户可见」的回答实测单线程每分钟稳定抓取 812 条含图片下载且全程无弹窗、无等待、无验证码干扰。2. 抓取链路拆解为什么不用 SeleniumRequests 如何吃下知乎的 HTML 静态骨架知乎的回答页存在明确的「双层结构」外层是带导航栏/侧边栏的壳页面Shell内层是实际回答内容Content。关键在于——当你在浏览器中打开一个公开回答链接如https://www.zhihu.com/question/123456789/answer/987654321服务端返回的 HTML 源码里div classRichContent-inner及其子节点已经完整包含 Markdown 渲染前的原始文本段落、加粗标记、列表符号甚至img标签里的>headers { 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: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,image/apng,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://www.zhihu.com/, Sec-Ch-Ua: Not_A Brand;v8, Chromium;v120, Google Chrome;v120, Sec-Ch-Ua-Mobile: ?0, Sec-Ch-Ua-Platform: Windows, Upgrade-Insecure-Requests: 1, Sec-Fetch-Dest: document, Sec-Fetch-Mode: navigate, Sec-Fetch-Site: same-origin, Sec-Fetch-User: ?1 }提示Sec-Ch-*系列字段是 Chrome 101 新增的客户端提示头Client Hints知乎已将其纳入风控模型。缺失这些字段即使 UA 正确也会被降权为低信任度请求。Referer必须设为https://www.zhihu.com/不能是空或 google.com否则触发跳转拦截。2.2 跳转捕获与目标 URL 提取绕过「未登录重定向」的确定性方案知乎对未登录用户的典型响应是 302 跳转至https://www.zhihu.com/signin?nextxxx。但注意这个跳转仅发生在「首次访问该域名」或「Cookie 中无有效 z_c0 字段」时。而我们并不需要登录只需让服务端认为“这是一个已建立会话的普通用户”。解决方案是主动发起一次对首页的 GET 请求捕获其 Set-Cookie再将该 Cookie 注入后续回答页请求。实测表明仅携带d_c0和tgw_l7_route两个 Cookie 就足以绕过 95% 的跳转。import requests from urllib.parse import urljoin # 第一步预热会话获取基础 Cookie session requests.Session() session.get(https://www.zhihu.com/, headersheaders, timeout10) # 第二步用同一 session 请求回答页自动携带 Cookie answer_url https://www.zhihu.com/question/123456789/answer/987654321 resp session.get(answer_url, headersheaders, timeout15, allow_redirectsFalse) # 关键检查是否跳转。若 status_code 302则从 headers[Location] 提取真实目标 if resp.status_code 302: redirect_url urljoin(https://www.zhihu.com, resp.headers.get(Location, )) # 再次请求重定向后的 URL此时通常已是真实内容页 resp session.get(redirect_url, headersheaders, timeout15)逻辑说明allow_redirectsFalse是核心开关。它阻止 requests 自动跟随跳转让我们有机会读取Location响应头。urljoin用于处理相对路径如/signin?nextxxx确保拼接出绝对 URL。此法比正则匹配 HTML 中的跳转 JS 更稳定因为后者易受前端代码更新影响。2.3 图片 URL 提取与清洗>img srchttps://picx.zhimg.com/v2-xxx_placeholder.jpg >from bs4 import BeautifulSoup import re def extract_image_urls(html_content: str) - list: soup BeautifulSoup(html_content, html.parser) img_tags soup.find_all(img, attrs{data-actualsrc: True}) urls [] for tag in img_tags: raw_url tag.get(data-actualsrc, ).strip() if not raw_url or raw_url in [/, ]: continue # 保留 query 参数仅清理可能的空格和换行 clean_url re.sub(r\s, , raw_url) urls.append(clean_url) return urls # 示例调用 image_urls extract_image_urls(resp.text) print(f共提取 {len(image_urls)} 张图片 URL)参数说明attrs{data-actualsrc: True}表示只找含有该属性的img标签避免误抓src属性re.sub(r\s, , raw_url)替换所有空白符包括\n,\t因某些回答页 HTML 源码中>def extract_rich_content(html_content: str) - str: soup BeautifulSoup(html_content, html.parser) # 严格查找 classRichContent-inner 的 div content_div soup.find(div, class_RichContent-inner) if not content_div: raise ValueError(未找到 RichContent-inner 区域请确认页面结构是否变更) return str(content_div)注意此处返回str(content_div)而非.get_text()是为了保留内部所有标签供下一步精细化转换。3.2 语义标签映射表把 HTML 标签翻译成 Markdown 语义知乎富文本使用的 HTML 标签非常规范对应关系稳定。我们建立一张映射表覆盖 95% 以上内容HTML 标签典型场景Markdown 输出特殊处理p普通段落原文 \n\n过滤空段落、合并连续brh1~h6回答小标题# 标题~###### 标题自动降级为##知乎不用 h1precode代码块python\n代码内容\n提取classlang-python等语言标识blockquote引用他人回答 引用内容递归处理内部pul/ol无序/有序列表- 项1/1. 项1处理嵌套列表缩进 2 空格strong/em加粗/斜体**加粗**/*斜体*支持嵌套如**加粗*斜体*3.3 实战转换函数支持嵌套、保留换行、过滤广告水印以下函数完成从 HTML 片段到 Markdown 的端到端转换重点解决三个高频痛点① 列表嵌套时缩进错乱②br被忽略导致段落粘连③ 知乎插入的「编辑于 XXX」、「赞同 XX」等非内容信息需过滤。import re from typing import List, Tuple def html_to_markdown(html_fragment: str) - str: soup BeautifulSoup(html_fragment, html.parser) def process_node(node) - str: if node.name p: # 过滤空段落和广告段落 text node.get_text(stripTrue) if not text or 编辑于 in text or 赞同 in text or 添加评论 in text: return # 处理段内 br 为换行 brs node.find_all(br) for br in brs: br.replace_with(\n) return node.get_text() \n\n elif node.name in [h1, h2, h3, h4, h5, h6]: level min(int(node.name[1]), 2) # 统一用 ## 和 ###避免 h1 return f{# * level} {node.get_text(stripTrue)}\n\n elif node.name pre and node.find(code): code node.find(code) lang code.get(class, [])[0].replace(lang-, ) if code.get(class) else return f{lang}\n{code.get_text()}\n\n\n elif node.name blockquote: lines [] for child in node.children: if child.name p: lines.append( child.get_text(stripTrue)) elif child.name is None and child.strip(): lines.append( child.strip()) return \n.join(lines) \n\n elif node.name in [ul, ol]: items [] for li in node.find_all(li, recursiveFalse): item_text li.get_text(stripTrue) if item_text: # 递归处理 li 内部标签如加粗、链接 inner_html str(li) items.append(process_node(BeautifulSoup(inner_html, html.parser))) # 生成列表 Markdownul 用 -ol 用 1. prefix - if node.name ul else 1. return \n.join([prefix item.strip() for item in items]) \n\n elif node.name strong: return f**{node.get_text()}** elif node.name em: return f*{node.get_text()}* elif node.name a: href node.get(href, ) text node.get_text(stripTrue) return f[{text}]({href}) if href.startswith(http) else text # 其他标签如 span, div直接取文本 elif node.name and node.name not in [br, img]: return node.get_text() return # 主流程遍历 RichContent-inner 下所有直接子节点 result_lines [] for child in soup.children: if child.name: line process_node(child) if line.strip(): result_lines.append(line) # 合并为最终 Markdown去除多余空行 markdown .join(result_lines) markdown re.sub(r\n{3,}, \n\n, markdown) # 多个空行压成两个 return markdown.strip() # 示例调用 rich_html extract_rich_content(resp.text) md_content html_to_markdown(rich_html) print(md_content[:200] ...)逻辑说明process_node是递归核心对不同标签执行差异化处理recursiveFalse在find_all(li)中确保只取一级列表项避免嵌套列表重复解析re.sub(r\n{3,}, \n\n, markdown)是玄学收尾——知乎 HTML 中常出现多个br连续导致输出 Markdown 里空行爆炸此正则强制压缩。4. 避坑指南5 个血泪经验总结每一条都来自真实翻车现场做知乎采集最怕的不是写不出代码而是跑着跑着突然全量 403、图片批量 403、Markdown 乱码、或者爬了 200 条才发现前 100 条全是广告软文。以下是我在某图像处理 Demo 项目中踩过的 5 个具体坑按「现象 → 原因 → 解决」给出可立即复用的方案。4.1 现象请求频繁返回 403但 UA 和 Cookie 都正确原因知乎服务端会校验 TCP 连接的 TLS 指纹JA3 Hashrequests 默认 OpenSSL 库指纹与 Chrome 不一致被识别为工具流量。解决改用requests-toolbeltpyOpenSSL强制指定 TLS 版本与扩展顺序或更简单——换httpx库其默认指纹更接近浏览器。实测httpx在相同请求头下403 率从 35% 降至 2%。pip install httpximport httpx client httpx.Client(http2True, timeout15) resp client.get(answer_url, headersheaders, follow_redirectsFalse)4.2 现象图片下载失败返回 403 或 404原因>def download_image(url: str, save_path: str) - bool: try: resp httpx.get(url, timeout3) if resp.status_code 403 and ? in url: fallback_url url.split(?)[0] resp httpx.get(fallback_url, timeout3) if resp.status_code 200: with open(save_path, wb) as f: f.write(resp.content) return True except Exception as e: pass return False4.3 现象Markdown 中代码块语言标识丢失全变成原因知乎代码块code标签的class属性格式为lang-python但部分回答使用language-python或无 class导致正则匹配失败。解决增强语言检测逻辑优先读class其次读>def detect_lang(code_text: str) - str: # 优先从标签属性获取 if python in code_text[:50].lower(): return python if function in code_text[:50] or console.log in code_text: return javascript if SELECT in code_text[:50] or FROM in code_text: return sql return # 空字符串表示不标注语言4.4 现象抓取的回答里混入大量「知乎盐选专栏」推广内容原因知乎会在回答末尾插入div classCard--recommend类型的推荐卡片其 HTML 结构与正常回答混在一起RichContent-inner无法完全隔离。解决在extract_rich_content后用 BeautifulSoup 显式移除所有class含recommend、salt、column的节点def remove_promo_blocks(soup: BeautifulSoup) - BeautifulSoup: for cls in [Card--recommend, SaltSelect, ColumnCard]: for tag in soup.find_all(class_cls): tag.decompose() return soup4.5 现象中文标点被转义为 HTML 实体如#12288;显示为乱码原因知乎部分回答使用全角空格#12288;和中文引号#8220;BeautifulSoup 默认不解码。解决在get_text()前对 HTML 字符串做全局实体解码import html cleaned_html html.unescape(html_fragment) soup BeautifulSoup(cleaned_html, html.parser)5. 进阶技巧用异步协程提速 4 倍同时控制并发数与请求间隔单线程爬 100 条回答要 15 分钟而课程设计往往需要 1000 条样本。有人直接上threading结果 IP 被限速有人用asyncio却不懂节流瞬间触发风控。真正的平衡点在于用 httpx.AsyncClient 控制并发连接数用asyncio.sleep()强制请求间隔用信号量Semaphore限制总并发量。我给某高校课程设计写的生产级脚本就是这套组合。5.1 异步采集主框架并发数 5间隔 1.2 秒稳如老狗import asyncio import httpx from asyncio import Semaphore # 全局信号量最多同时 5 个请求 sem Semaphore(5) async def fetch_answer(client: httpx.AsyncClient, url: str, headers: dict) - dict: async with sem: # 获取信号量阻塞直到有空位 try: resp await client.get(url, headersheaders, timeout15, follow_redirectsFalse) await asyncio.sleep(1.2) # 强制间隔防抖动 if resp.status_code 302: redirect_url urljoin(https://www.zhihu.com, resp.headers.get(Location, )) resp await client.get(redirect_url, headersheaders, timeout15) if resp.status_code 200: content_html extract_rich_content(resp.text) md_text html_to_markdown(content_html) image_urls extract_image_urls(resp.text) return { url: url, markdown: md_text, images: image_urls, status: success } except Exception as e: pass return {url: url, status: failed} async def main(answer_urls: List[str]): headers { /* 同前 */ } limits httpx.Limits(max_connections10, max_keepalive_connections5) timeout httpx.Timeout(15.0, connect10.0) async with httpx.AsyncClient(limitslimits, timeouttimeout) as client: tasks [fetch_answer(client, url, headers) for url in answer_urls] results await asyncio.gather(*tasks, return_exceptionsTrue) # 过滤成功结果 success_results [r for r in results if isinstance(r, dict) and r.get(status) success] print(f成功采集 {len(success_results)} / {len(answer_urls)} 条回答) return success_results # 启动 if __name__ __main__: urls [ https://www.zhihu.com/question/123/answer/456, https://www.zhihu.com/question/789/answer/012, # ... 1000 条 ] results asyncio.run(main(urls))参数说明httpx.Limits(max_connections10)限制客户端总连接池大小避免耗尽系统文件描述符max_keepalive_connections5复用 HTTP/1.1 连接减少握手开销Semaphore(5)是灵魂——它确保无论你提交多少任务同一时刻只有 5 个在跑既提速又安全。5.2 图片异步下载用 aiofiles 避免阻塞主线程文本采集完成后图片下载是第二大耗时环节。同步下载 100 张图平均 200KB需 30 秒而异步可压到 8 秒内import aiofiles import asyncio async def download_image_async(url: str, save_path: str) - bool: try: async with httpx.AsyncClient() as client: resp await client.get(url, timeout5) if resp.status_code 200: async with aiofiles.open(save_path, wb) as f: await f.write(resp.content) return True except Exception as e: pass return False async def batch_download_images(image_tasks: List[Tuple[str, str]]) - int: # image_tasks: [(url1, path1), (url2, path2), ...] tasks [download_image_async(url, path) for url, path in image_tasks] results await asyncio.gather(*tasks) return sum(results) # 调用示例 all_image_tasks [] for i, result in enumerate(results): for j, img_url in enumerate(result[images]): ext img_url.split(.)[-1].split(?)[0] or jpg save_path fimages/answer_{i}_img_{j}.{ext} all_image_tasks.append((img_url, save_path)) success_count asyncio.run(batch_download_images(all_image_tasks)) print(f图片下载完成{success_count}/{len(all_image_tasks)})5.3 防风控终极 checklist每次上线前必跑的 4 个验证动作从那以后我每次部署新采集任务都强制走一遍这四步验证再没被封过 IPUA 一致性验证用curl -I -H User-Agent: xxx模拟请求检查响应头是否含X-Frame-Options: DENY正常而非X-Blocked-By-Zhihu: true被拦Cookie 生效验证抓取首页后打印session.cookies.get_dict()确认含d_c0和tgw_l7_route跳转链路验证对一个已知公开回答 URL手动执行session.get(..., allow_redirectsFalse)确认resp.status_code ! 302图片直连验证取一个style="width:16px;margin-left:4px;vertical-align:text-bottom;cursor:text;" />