某乎爬虫进阶:爬取问题+回答+用户信息,构建知识图谱数据源
引言
在信息过载的时代,如何从海量的UGC(用户生成内容)中高效提取结构化知识,是每一位数据从业者都在探索的问题。某乎作为中文互联网最大的知识分享社区之一,汇聚了数以亿计的问题、回答和用户互动数据,是构建知识图谱的天然数据宝库。
然而,爬取某乎数据绝非易事。其前端大量采用React服务端渲染(SSR)加客户端水合的混合模式,初始HTML仅含骨架结构,真实数据依赖后续AJAX接口动态加载。与此同时,某乎的反爬体系经过多年迭代,已形成了一套覆盖请求频率检测、请求头验证、行为模式分析等多维度的智能防护系统。很多开发者可能有过这样的经历:昨天还能正常运行的爬虫脚本,今天一运行就弹出验证码、返回乱码,甚至IP直接被封。
本文将从零开始,系统介绍如何爬取某乎的问题、回答和用户信息,并以此为基础构建知识图谱数据源。文章将涵盖数据建模、接口分析、反爬突破、隧道代理集成以及知识图谱的初步构建思路。
一、为什么要爬取某乎构建知识图谱?
1.1 某乎数据的知识价值
某乎的数据具有典型的知识图谱特征:
- 实体丰富:问题、回答、用户、话题标签构成了多类型的实体节点
- 关系多样:用户-提问、用户-回答、问题-话题、回答-评论等多种关系
- 属性完整:每个实体都带有丰富的属性信息——问题的关注数、浏览数、回答数;用户的职业、教育背景、关注数;回答的点赞数、评论数、创建时间
这些数据的结构化程度高、语义密度大,非常适合用于知识图谱的构建。
1.2 知识图谱的数据需求
构建一个完整的知识图谱,至少需要三类核心数据:
| 数据类型 | 某乎对应内容 | 图谱中的角色 |
| 实体数据 | 问题、用户、话题 | 图谱节点 |
| 关系数据 | 提问关系、回答关系、关注关系 | 图谱边 |
| 属性数据 | 标题、内容、点赞数、创建时间 | 节点/边的属性 |
某乎恰好能够同时满足这三类数据的获取需求,是知识图谱数据源的理想选择。
二、数据建模:爬什么、存什么
在动手写代码之前,先搞清楚要爬取哪些数据、如何存储。
2.1 实体模型设计
根据某乎的数据结构,建议建立三张核心数据表:
问题表(questions)
| 字段 | 类型 | 说明 |
| question_id | VARCHAR(32) | 问题ID(主键) |
| title | TEXT | 问题标题 |
| description | TEXT | 问题描述 |
| follower_count | INT | 关注数 |
| view_count | INT | 浏览数 |
| answer_count | INT | 回答数 |
| topic_tags | JSON | 话题标签列表 |
| created_at | DATETIME | 创建时间 |
回答表(answers)
| 字段 | 类型 | 说明 |
| answer_id | VARCHAR(32) | 回答ID(主键) |
| question_id | VARCHAR(32) | 所属问题ID(外键) |
| author_id | VARCHAR(32) | 作者ID(外键) |
| content | LONGTEXT | 回答内容 |
| voteup_count | INT | 点赞数 |
| comment_count | INT | 评论数 |
| created_at | DATETIME | 创建时间 |
用户表(users)
| 字段 | 类型 | 说明 |
| user_id | VARCHAR(32) | 用户ID(主键) |
| name | VARCHAR(100) | 昵称 |
| headline | VARCHAR(500) | 一句话简介 |
| follower_count | INT | 关注者数 |
| following_count | INT | 关注数 |
| answer_count | INT | 回答数 |
这三张表通过外键关联,构成了一个完整的关系网络——用户通过回答连接到问题,问题通过话题标签相互关联。
2.2 数据采集的优先级策略
考虑到某乎的反爬限制,建议采用分层采集策略:
- 第一层:采集问题列表(元信息)
- 第二层:根据问题ID采集回答列表
- 第三层:根据回答中的作者ID采集用户信息
这种“先宽后深”的策略可以在有限的请求配额内最大化数据覆盖面。
三、技术选型与接口分析
3.1 为什么不用页面解析?
直接使用requests + BeautifulSoup解析某乎的HTML页面会面临两个致命问题:
- 动态加载:某乎页面采用SSR+客户端水合模式,初始HTML只有骨架,真实数据通过AJAX异步加载
- 结构多变:某乎的DOM结构频繁变更,类名和嵌套层次经常调整,基于XPath/CSS的选择器极易失效
因此,直接调用API接口才是正确且高效的做法。
3.2 核心API接口
某乎的内部API主要遵循v4版本规范:
获取问题下的回答列表:
GET https://www.zhihu.com/api/v4/questions/{question_id}/answers
参数:offset=0&limit=20&sort_by=default
支持按时间、热度双维度排序。
获取用户信息:
GET https://www.zhihu.com/api/v4/members/{user_id}
返回用户名、头像、简介、关注数等详细信息。
获取话题下的问题列表:
GET https://www.zhihu.com/api/v4/topics/{topic_id}/feeds/timeline_questions
特别提醒:根据开源社区的反馈,某乎在2025年3月前后加密了部分API接口,原有的爬取方案可能已失效。这意味着在实际开发中,需要持续关注接口变化并及时调整策略。
3.3 Cookie认证
某乎的API接口通常需要登录态才能访问。核心Cookie字段包括:
- z_c0:主要的授权令牌
- _xsrf:CSRF防护参数
获取方式:在浏览器中登录某乎,打开开发者工具 → Network → 找到任意请求 → 复制Cookie中的这两个值。
强烈建议使用小号,防止主号被封禁。
四、代码实现:从API到结构化数据
4.1 基础爬虫框架
import requests
import json
import time
import random
import pandas as pd
from typing import List, Dict, Optional
class ZhihuScraper:
def __init__(self, cookie: str, use_proxy: bool = False):
"""
初始化爬虫
:param cookie: 登录后的Cookie字符串(含z_c0和_xsrf)
:param use_proxy: 是否启用隧道代理
"""
self.session = requests.Session()
self.session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Cookie": cookie,
"Referer": "https://www.zhihu.com",
"X-Requested-With": "XMLHttpRequest"
})
self.use_proxy = use_proxy
# 隧道代理配置(以站大爷为例)
if use_proxy:
self.proxies = {
"http": "http://隧道代理地址:端口",
"https": "https://隧道代理地址:端口"
}
else:
self.proxies = None
def _request(self, url: str, params: Dict = None, retry: int = 3) -> Optional[Dict]:
"""发送请求,支持重试和隧道代理"""
for i in range(retry):
try:
response = self.session.get(
url,
params=params,
proxies=self.proxies,
timeout=15
)
if response.status_code == 200:
return response.json()
elif response.status_code == 403:
print(f"第{i+1}次请求被拒绝(403),等待后重试...")
time.sleep(5 * (i + 1))
else:
print(f"请求失败,状态码: {response.status_code}")
time.sleep(2)
except Exception as e:
print(f"第{i+1}次请求异常: {e}")
time.sleep(3 * (i + 1))
return None
def get_answers_by_question(
self,
question_id: str,
limit: int = 100,
sort_by: str = "default"
) -> List[Dict]:
"""
获取某个问题下的回答列表
:param question_id: 问题ID
:param limit: 最大获取数量
:param sort_by: 排序方式 (default/created)
"""
url = f"https://www.zhihu.com/api/v4/questions/{question_id}/answers"
answers = []
offset = 0
page_size = 20
while len(answers) < limit:
params = {
"offset": offset,
"limit": page_size,
"sort_by": sort_by
}
data = self._request(url, params)
if not data or "data" not in data:
break
batch = data.get("data", [])
if not batch:
break
for item in batch:
author = item.get("author", {})
answers.append({
"answer_id": item.get("id"),
"question_id": question_id,
"author_id": author.get("id"),
"author_name": author.get("name"),
"content": item.get("content", ""),
"voteup_count": item.get("voteup_count", 0),
"comment_count": item.get("comment_count", 0),
"created_at": item.get("created_at", 0)
})
# 检查是否还有下一页
paging = data.get("paging", {})
if not paging.get("is_end", True):
offset += page_size
# 随机延迟,避免触发反爬
time.sleep(random.uniform(1, 3))
else:
break
return answers[:limit]
def get_user_info(self, user_id: str) -> Optional[Dict]:
"""获取用户详细信息"""
url = f"https://www.zhihu.com/api/v4/members/{user_id}"
data = self._request(url)
if data:
return {
"user_id": data.get("id"),
"name": data.get("name"),
"headline": data.get("headline", ""),
"follower_count": data.get("follower_count", 0),
"following_count": data.get("following_count", 0),
"answer_count": data.get("answer_count", 0),
"voteup_count": data.get("voteup_count", 0)
}
return None
4.2 完整采集流程
# 使用示例
if __name__ == "__main__":
# 1. 配置Cookie(从浏览器复制)
COOKIE = "z_c0=你的令牌; _xsrf=你的验证参数"
# 2. 初始化爬虫(启用隧道代理)
scraper = ZhihuScraper(cookie=COOKIE, use_proxy=True)
# 3. 采集某个问题下的回答
question_id = "19550255" # 替换为目标问题ID
answers = scraper.get_answers_by_question(question_id, limit=200)
print(f"共采集 {len(answers)} 条回答")
# 4. 提取所有作者ID,采集用户信息
author_ids = set([a["author_id"] for a in answers if a["author_id"]])
users = []
for uid in author_ids:
user_info = scraper.get_user_info(uid)
if user_info:
users.append(user_info)
time.sleep(random.uniform(1, 2))
print(f"共采集 {len(users)} 位用户信息")
# 5. 保存数据
pd.DataFrame(answers).to_csv("answers.csv", index=False, encoding="utf-8-sig")
pd.DataFrame(users).to_csv("users.csv", index=False, encoding="utf-8-sig")
4.3 扩展:爬取话题下的问题列表
如果需要从某个话题出发采集数据,可以使用话题接口:
def get_questions_by_topic(self, topic_id: str, limit: int = 1000):
"""
获取某个话题下的问题列表
注意:某乎限制了每个话题板块最多展示1000条数据
"""
url = f"https://www.zhihu.com/api/v4/topics/{topic_id}/feeds/timeline_questions"
# 实现逻辑与get_answers_by_question类似
# ...
重要提示:某乎从服务器端限制了每个话题板块最多展示1000条数据。对于时间跨度大、热度高的话题,这一限制意味着无法获取全部历史数据。建议通过爬取多个相似话题来最大化数据覆盖度。
五、反爬策略与隧道代理
5.1 某乎的反爬体系
某乎的反爬机制是多层次的:
| 反爬手段 | 具体表现 | 触发条件 |
| 请求频率检测 | 短时间内大量相同模式的请求被识别为爬虫行为 | 请求间隔<1秒 |
| 请求头验证 | 缺少必要请求头或使用非常规请求头会被拦截 | User-Agent异常 |
| 行为模式分析 | 固定时间间隔发送完全相同的请求会被识别 | 请求模式过于规律 |
| 验证码拦截 | 弹出滑块验证码或人机校验 | 频率过高或IP异常 |
| 内容加密 | 检测到爬虫时返回加密乱码 | 服务器端动态检测 |
某乎对高频请求会触发验证码或封IP,建议每次请求间隔3-5秒。有开发者反馈,即使用自己的账号Cookie自建RSSHub,用不了几天也会失效。由此可见其反爬之严格。
5.2 UA池与请求头伪装
单一User-Agent特征会显著增加爬虫暴露风险。建议构建多样化的UA池:
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/120.0.0.0",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0",
# 覆盖Windows、macOS、iOS、Android等平台的主流浏览器版本
]
每次请求时随机选择一个UA,模拟不同终端设备的访问特征。
5.3 站大爷隧道代理:突破IP限制的利器
即使完美配置了请求头和请求频率,当采集规模达到一定量级时,IP封禁依然不可避免。某乎对单个IP的请求频率有着严格的限制——同一个IP在短时间内发出大量请求,必然触发保护机制。
传统的解决方案是自行维护代理IP池,但这种方法存在两个核心痛点:
- IP质量参差不齐:免费代理资源已被大量滥用,可用性低
- 手动维护麻烦:需要持续检测IP有效性,被封后手动更换
隧道代理正是为解决这些问题而生。它的工作原理是:你不需要手动维护IP池,只需配置好隧道代理的接入信息。每次发送请求时,隧道代理会自动把流量引导至不同的出口IP。
站大爷隧道代理在爬虫场景中有几个突出的优势:
- 自动切换无感知:每次请求自动更换出口IP,无需手动干预
- 覆盖范围广:覆盖全国99%地域
- 高可用性:在独立第三方评测中,连续7天运行连接成功率达99.3%
- 主备双隧道:持续更新IP池,稳定性有保障
- 灵活配置:支持精细化IP轮换周期自定义
在代码中集成站大爷隧道代理
import requests
# 站大爷隧道代理配置
PROXY_CONFIG = {
"http": "http://隧道代理地址:端口",
"https": "https://隧道代理地址:端口"
}
def fetch_with_tunnel(url, headers, cookies, retry=3):
"""使用隧道代理发送请求,自动处理IP切换"""
for i in range(retry):
try:
response = requests.get(
url,
headers=headers,
cookies=cookies,
proxies=PROXY_CONFIG,
timeout=15
)
# 如果遇到403,隧道代理会自动切换出口IP
if response.status_code == 403:
print(f"第{i+1}次请求被拒绝,隧道代理自动切换IP重试...")
time.sleep(2)
continue
return response
except Exception as e:
print(f"第{i+1}次请求异常: {e}")
time.sleep(3)
continue
return None
实际应用中,隧道代理可以将IP封禁率从80%以上降至5%以下,大幅提升采集的稳定性。
六、从爬取数据到知识图谱
6.1 数据清洗与预处理
爬取到的原始数据需要进行清洗:
- 内容清洗:去除HTML标签、特殊字符、表情符号
- 时间归一化:将时间戳统一转换为标准格式
- 去重处理:使用
answer_id、question_id作为唯一键,避免重复插入
6.2 实体关系抽取
从采集的数据中可以抽取以下关系:
| 关系类型 | 起点 | 终点 | 说明 |
| 提问 | 用户 | 问题 | 用户提出了某个问题 |
| 回答 | 用户 | 问题 | 用户回答了某个问题 |
| 关注 | 用户 | 话题 | 用户关注了某个话题 |
| 属于 | 问题 | 话题 | 问题被打上了某个话题标签 |
6.3 使用Neo4j存储知识图谱
Neo4j是构建知识图谱的理想图数据库:
// 创建用户节点
CREATE (u:User {id: '123', name: '张三', follower_count: 1000})
// 创建问题节点
CREATE (q:Question {id: '456', title: '如何学习Python?', view_count: 10000})
// 创建回答关系
MATCH (u:User {id: '123'}), (q:Question {id: '456'})
CREATE (u)-[:ANSWERED {content: '...', voteup_count: 100}]->(q)
6.4 知识图谱的应用场景
基于某乎数据构建的知识图谱可以应用于:
- 知识推荐:根据用户关注的话题推荐相关问题
- 专家发现:通过回答质量和数量识别领域专家
- 热点追踪:分析话题下问题的增长趋势
- 用户画像:通过回答主题分布构建用户兴趣画像
七、常见问题与避坑指南
7.1 Cookie频繁过期怎么办?
某乎的Cookie有效期通常为7天左右。建议:
- 编写定时任务,每周自动重新登录并更新Cookie
- 使用多个账号的Cookie池轮换使用
7.2 API接口失效怎么办?
某乎会不定期更新API接口。应对策略:
- 定期检查接口变化,及时更新代码
- 建立多级备选选择器或解析方案
- 参考开源社区的最新适配方案
7.3 遇到验证码怎么处理?
- 半自动方案:检测到验证码时暂停脚本,人工完成验证后继续
- 降低频率:出现验证码时自动降低采集速率
- 切换账号:使用备用账号继续采集
7.4 数据采集不全怎么办?
- 某乎话题页最多展示1000条数据
- 解决方案:爬取多个相似话题,合并数据
- 按时间分段采集,突破单次请求的限制
7.5 乱码问题
某乎在检测到爬虫时可能会返回加密乱码。解决方案:
- 使用隧道代理切换IP
- 使用登录态Cookie访问
- 模拟真实浏览器的完整请求头
八、总结
本文从某乎的数据价值出发,系统介绍了爬取问题、回答、用户信息以构建知识图谱数据源的完整方案。核心要点可以概括为:
| 挑战 | 解决方案 |
| 动态加载内容 | 直接调用API接口,而非解析HTML |
| 登录态校验 | 获取z_c0和_xsrf Cookie,持久化使用 |
| DOM结构多变 | 使用API接口,避免依赖页面选择器 |
| 请求频率限制 | 随机延迟、UA池轮换 |
| IP封禁 | 站大爷隧道代理,自动切换IP |
| 数据建模 | 问题、回答、用户三表设计,外键关联 |
| 知识图谱存储 | Neo4j图数据库 |
技术选型速览:推荐“API接口调用 + Cookie认证 + UA池轮换 + 站大爷隧道代理”的组合方案,兼顾效率与稳定性。
某乎的知识数据是一座尚未被充分开采的金矿。通过合理的爬虫策略和数据建模,我们可以将这座金矿转化为结构化的知识图谱,为推荐系统、专家发现、热点追踪等应用提供坚实的数据基础。
最后需要提醒的是:数据采集行为应当遵循行业规范,控制请求频率以避免对目标服务器造成过载。请遵守目标网站的robots.txt协议及相关法律法规,仅将爬虫技术用于学习和研究目的,勿对目标网站造成过大压力。希望本文能帮助你在知识图谱构建的道路上迈出坚实的一步。