简介:这份资源面向具备Python基础、希望入门网络数据采集的开发者与数据分析学习者,围绕知乎问答社区这一典型场景,讲解如何抓取回答中的正文文本与配图。内容覆盖HTTP请求发送、HTML解析、文本与图片链接提取、二进制图片保存、用户信息抓取,以及time.sleep()延时防封与下载计数控制等实用策略,帮助读者建立从请求到落盘的完整爬虫思路。压缩包共3个文件,以2个py脚本和1个txt依赖清单为主,脚本分别承担数据获取与保存职责,txt用于声明requests、BeautifulSoup、lxml等依赖版本,整体约4KB,结构精简、便于快速阅读与二次修改。目前已有1144人学习下载,适合作为爬虫练手项目,理解请求、解析、存储与限速的配合方式,并注意遵守robots.txt协议与知识产权规范。
1. 从零拆解知乎回答爬取:文本与图片双通道落地的实战思路
很多人第一次接触爬虫,都是从“把某个页面上的内容搬下来”开始的。知乎回答页恰好是一个典型样本:正文是动态渲染的富文本,图片藏在懒加载属性里,分页靠滚动触发,直接拿 requests 怼上去大概率只能拿到一具空壳。这份资源围绕“Python 爬取知乎回答中的文本及图片”展开,核心解决三件事:把回答正文完整提取成可读文本、把配图按回答维度归档到本地、把翻页逻辑跑通不至于只抓第一屏。适合已经会写基础 requests 和 BeautifulSoup、但一遇到动态页面就卡住的开发者,也适合想拿真实站点练手数据清洗的在校同学。下面按“先跑通再优化”的顺序拆。
2. 请求层与解析层:把回答正文和图片地址同时拿到手
2.1 为什么直接 requests.get 拿不到完整回答
知乎回答页的正文并不是一次性塞在初始 HTML 里的。服务端返回的文档里,回答内容往往以 JSON 片段形式嵌在 script 标签中,或者由前端脚本异步填充。你如果只做一次requests.get(url).text,再用 BeautifulSoup 找div.RichContent,结果经常是空的。常见做法是两条路:一是直接请求知乎的 API 接口,返回结构化 JSON;二是用浏览器自动化工具渲染后再取 DOM。前者轻量但需要处理签名参数,后者重但稳定。我一般先用 API 路线,因为文本和图片地址在 JSON 里是分开字段,清洗起来更干净。
2.2 用 API 接口拿回答列表的请求构造
知乎的回答列表接口通常形如https://www.zhihu.com/api/v4/questions/{qid}/answers,带include、limit、offset参数。下面这段代码演示如何构造请求并翻页,注意 headers 里必须带User-Agent和Referer,否则容易被拒。
import requests import time import json # 问题 ID,从回答页 URL 里提取 QID = "12345678" # 接口地址,include 字段决定返回哪些数据 API = f"https://www.zhihu.com/api/v4/questions/{QID}/answers" HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": f"https://www.zhihu.com/question/{QID}", "Accept": "application/json, text/plain, */*", } def fetch_page(offset=0, limit=20): """拉取一页回答,返回 JSON 字典""" params = { "include": "data[*].content,excerpt,author.name,created_time,voteup_count", "limit": limit, "offset": offset, } resp = requests.get(API, headers=HEADERS, params=params, timeout=10) # 状态码非 200 直接抛错,方便定位 resp.raise_for_status() return resp.json() def iter_answers(max_page=5): """按页迭代回答,最多抓 max_page 页""" for page in range(max_page): data = fetch_page(offset=page * 20) for item in data.get("data", []): yield item # 翻页间隔,避免请求过密 time.sleep(1.5) if __name__ == "__main__": for ans in iter_answers(): print(ans.get("author", {}).get("name"), ans.get("voteup_count"))这段代码里,include参数是关键,它告诉接口把content(正文 HTML)、author.name(作者名)、voteup_count(赞同数)一起返回。offset按页递增,limit一般设 20。time.sleep(1.5)是给服务端留缓冲,实际跑的时候如果发现返回 403,先把间隔调到 3 秒以上再试。raise_for_status()能让你在状态码异常时立刻知道,而不是拿着空 JSON 往下走。
2.3 从 content 字段里分离文本和图片地址
接口返回的content是一段 HTML 字符串,里面既有<p>文本,也有<img>标签。图片地址通常写在>from bs4 import BeautifulSoup def parse_content(html): """输入回答的 content HTML,返回 (纯文本, 图片URL列表)""" soup = BeautifulSoup(html, "html.parser") # 提取所有图片,优先取>import os def download_images(img_urls, answer_id, save_root="zhihu_images"): """把一组图片下载到 save_root/answer_id/ 下""" folder = os.path.join(save_root, str(answer_id)) os.makedirs(folder, exist_ok=True) for idx, url in enumerate(img_urls): try: r = requests.get(url, headers=HEADERS, timeout=15) r.raise_for_status() # 从 URL 里取扩展名,取不到就默认 jpg ext = os.path.splitext(url.split("?")[0])[-1] or ".jpg" path = os.path.join(folder, f"{idx}{ext}") with open(path, "wb") as f: f.write(r.content) except Exception as e: print(f"下载失败 {url} -> {e}")
os.makedirs(..., exist_ok=True)保证重复跑不会报目录已存在。url.split("?")[0]去掉查询参数再取扩展名,因为知乎图片 URL 后面常带?source=xxx之类。每张图单独 try,一张失败不影响后续。下载间隔如果被限流,可以在循环里加time.sleep(0.5)。
3. 翻页、去重与存储:让抓取结果可复用
3.1 翻页边界与停止条件
API 返回的 JSON 里通常有paging字段,包含is_end和next。用is_end判断是否还有下一页,比固定页数更稳。下面改造iter_answers,让它自动停。
def iter_answers_auto(max_page=50): """自动翻页,遇到 is_end 或达到 max_page 停止""" offset = 0 for _ in range(max_page): data = fetch_page(offset=offset) paging = data.get("paging", {}) for item in data.get("data", []): yield item if paging.get("is_end"): break offset += 20 time.sleep(1.5)paging.is_end为 True 时说明没有更多数据。max_page是保险丝,防止接口异常导致死循环。每次 offset 加 20 要和limit保持一致,否则会漏数据或重复。
3.2 用 answer_id 做去重
翻页过程中如果接口不稳定,可能重复返回同一条回答。用集合记录已处理的id,重复的直接跳过。
seen = set() def iter_unique_answers(): for ans in iter_answers_auto(): aid = ans.get("id") if aid in seen: continue seen.add(aid) yield ansans["id"]是回答的唯一标识,比用作者名或时间戳去重更可靠。seen集合放在模块级,整个抓取周期内有效。
3.3 文本落盘:按回答存 txt 还是汇总成 csv
如果后续要做文本分析,汇总成 CSV 更方便;如果只是存档,按回答存 txt 更直观。下面给一个 CSV 方案,字段包括回答 ID、作者、赞同数、正文、图片数量。
import csv def save_to_csv(answers, path="answers.csv"): with open(path, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["answer_id", "author", "voteup", "text", "img_count"]) for ans in answers: text, imgs = parse_content(ans.get("content", "")) writer.writerow([ ans.get("id"), ans.get("author", {}).get("name"), ans.get("voteup_count"), text, len(imgs), ])encoding="utf-8-sig"让 Excel 打开不乱码。newline=""避免 Windows 下多空行。正文里的换行会被 csv 模块自动处理成引号包裹,不会破坏结构。
3.4 图片去重与命名冲突
同一张图可能在多个回答里出现,按回答分文件夹已经隔离了大部分冲突。如果同一回答内 URL 重复,可以在下载前用集合过滤。
def download_images_unique(img_urls, answer_id): seen_url = set() unique = [] for u in img_urls: if u not in seen_url: seen_url.add(u) unique.append(u) download_images(unique, answer_id)这样同一回答内不会出现两张一样的图。跨回答的重复如果也想去掉,可以把seen_url提到全局,但一般没必要,因为不同回答的图分开存更利于溯源。
4. 避坑与排查:请求被拒、图片 403、正文乱码怎么破
4.1 返回 403 或 401
现象:requests.get直接返回 403,或者 API 返回{"error": ...}。原因通常是 headers 里缺User-Agent或Referer,或者请求频率过高被临时限制。解决:补全 headers,把time.sleep间隔调到 3 秒以上,必要时换一个User-Agent字符串再试。如果仍然 403,检查是否漏了Accept头。
4.2 图片下载下来是空白或 1x1 像素
现象:图片文件只有几百字节,打开是空白。原因是你抓的是src里的占位图,不是>import sqlite3 def init_db(path="zhihu.db"): conn = sqlite3.connect(path) conn.execute(""" CREATE TABLE IF NOT EXISTS answers ( answer_id TEXT PRIMARY KEY, question_id TEXT, author TEXT, voteup INTEGER, text TEXT, img_count INTEGER, crawled_at TEXT ) """) conn.commit() return conn def save_to_db(conn, answers, qid): from datetime import datetime for ans in answers: text, imgs = parse_content(ans.get("content", "")) conn.execute( "INSERT OR REPLACE INTO answers VALUES (?,?,?,?,?,?,?)", ( ans.get("id"), qid, ans.get("author", {}).get("name"), ans.get("voteup_count"), text, len(imgs), datetime.now().isoformat(), ), ) conn.commit()
INSERT OR REPLACE保证重复跑不会报主键冲突,同时更新抓取时间。crawled_at用 ISO 格式,方便后续按时间筛选。建完库之后,你可以直接用 SQL 查“赞同数大于 100 且包含某个关键词的回答”,比翻 CSV 快得多。
图片那边,我习惯在下载完成后生成一份index.csv,记录每张图的回答 ID、本地路径、原始 URL 和文件大小。这样即使以后原图链接失效,你也能知道本地文件对应的是哪条回答。生成索引的代码很短:
import os def build_image_index(root="zhihu_images", out="image_index.csv"): with open(out, "w", newline="", encoding="utf-8-sig") as f: writer = csv.writer(f) writer.writerow(["answer_id", "local_path", "size_bytes"]) for answer_id in os.listdir(root): folder = os.path.join(root, answer_id) if not os.path.isdir(folder): continue for fname in os.listdir(folder): fpath = os.path.join(folder, fname) writer.writerow([answer_id, fpath, os.path.getsize(fpath)])跑完这一步,你手里就有了一份带文本库和图片索引的本地数据集。后续想做词频统计、图片分类或者只是离线翻阅,都不用再碰网络。最后提醒一句:抓取频率一定要克制,time.sleep不要省,遇到 403 先降速而不是换 IP。从那以后我每次写爬虫都先把间隔和重试逻辑写好再跑第一行请求,省得后面返工。希望帮到你。
本文还有配套的精品资源,点击获取