我一直觉得,刚入门的爬虫学习者最容易走偏的地方,不是写不出代码,而是不知道代码该往哪里发。很多人学了好几个requests的API,拿到一个真实网站还是懵:这个数据到底从哪里来?页面源码里搜不到,直接GET又全是乱码。这篇案例十三,就是专门治这个问题的。我选了知乎热榜作为切入点,把抓包→分析→写代码→存数据整个链路完整走一遍。标题里提到的截图和动图演示,在静态博文里没法原样呈现,我会把动图里的每一步操作拆成文字步骤,你照着做,看到的效果是一样的。
为什么是热榜而不是别的页面?三个原因:第一,热榜数据是页面加载后用JavaScript异步请求回来的,这种场景天然适合练习看Network面板;第二,接口返回的是标准JSON,新手不用去啃嵌套几十层的HTML标签;第三,热榜数据结构清晰,标题、热度值、回答数、关注数全都有业务含义,学完直接可以往数据分析和表格方向延伸。这篇代码部分我给了完整可运行的版本,连带Cookie处理、数据清洗、CSV和JSON双格式存储都写进去了,只要你Python环境装好requests库就能跑通。
1. 为什么选知乎热榜当入门案例,以及动手前必须想清楚的边界
1.1 热榜接口的三个特点:正好踩中入门练习的甜蜜点
我在给新手选案例的时候,心里有一套自己的标准。太简单的没有学习价值,比如直接抓一个静态HTML页面,requests.get然后正则匹配就搞定了,学完你对整个爬虫链路还是没概念。太难的又不适合入门,比如需要逆向JavaScript签名参数的接口,光一个加密逻辑就能劝退大半人。热榜属于中间那档——恰到好处。
第一个特点是异步JSON接口。打开知乎热榜页面,浏览器先拿到的是HTML骨架,真正的内容是JavaScript随后发起的XHR请求带回来的。这个特性意味着Network面板里一定存在一条独立的、可定位的数据接口,而不是像静态页面那样数据散落在HTML源码里。练习抓包的人,最喜欢的就是这种结构。
第二个特点是数据规整。接口返回的JSON是标准的数组套对象,字段名都是可读的英文单词,比如title表示标题,answer_count表示回答数,follower_count表示关注数。处理JSON比解析HTML舒服太多了,你不需要关心class名字、标签嵌套这些烦人的东西,直接按键取值就行。
第三个特点是反爬措施适中。热榜接口需要你伪装User-Agent,有时候要带Cookie,但不涉及加密签名参数。我用一个对比表格来说明为什么这个难度合适:
| 接口类型 | 是否需要签名参数 | 是否需要登录 | 数据结构 | 适合新手程度 |
|---|---|---|---|---|
| 热榜接口 | 否 | 弱登录,建议带Cookie | 标准JSON | 非常适合 |
| 搜索接口 | 是,存在加密签名逻辑 | 需要 | JSON | 不适合 |
| 详情页HTML | 否 | 不需要 | HTML标签嵌套 | 一般 |
搜索接口为什么难?因为它的请求参数里有加密签名,每个请求都要用JavaScript动态计算,新手还没学会爬虫就先要学会逆向,这是另一门功力了。热榜接口没有这层考验,正好拿来练手。
1.2 学习采集的合规边界:这几条红线要记住
每次写爬虫教程都必须说清楚的,是边界问题。我教的是技术,不是教你拿技术去做伤害别人的事。知乎热榜本身是公开数据,任何人都能看到,采集它不比用眼睛看它有更大的破坏力。但既然写出来,这几条底线我必须划清楚:
- 仅限个人学习使用。采集到的数据自己分析、自己练手,不要整理成数据集公开发布,更不要打包拿去卖钱。数据的二次传播是敏感地带,别碰。
- 控制请求频率。这个案例单次运行只发一个请求,拿50条数据,对知乎服务器来说微不足道。但如果你写了个for循环每小时跑一万次,那性质就变了。采集不是抢资源,是礼貌地获取公开信息。
- 尊重平台服务协议。平台产品规则里通常明确写着禁止未经许可的自动化采集,个人学习场景下问题不大,但是所有抓到的数据只能停在个人电脑里。
- 随时做好被拒绝的准备。网站有权通过技术手段阻止爬虫访问,遇到被封IP、被要求登录验证,停下就好,不要想着突破。
这四句话不是套话,是这几年我见过太多人踩坑后的血泪教训。有人把爬来的数据分析结果发到社交平台上,账号被平台方追究;有人写了个采集脚本放在GitHub公开仓库里,直接收到律师函。技术本身是干净的,用在什么地方、怎么用,是你自己的选择。
2. 抓包定位热榜接口:完整思路与操作演示
2.1 准备阶段:用无痕窗口加F12打开一个干净的现场
抓包这件事,看似技术复杂,其实就是三个步骤:打开浏览器开发者工具、刷新页面、在请求列表里找到目标接口。但很多人第一步就做错了——用自己日常使用的浏览器窗口直接操作。
我强烈建议开一个无痕窗口再去抓包。原因很简单:你自己浏览器里装了各种插件,广告拦截、翻译助手、密码管理器,这些插件会在页面加载时发出大量额外请求,把Network面板搅成一锅粥。而且浏览器缓存会干扰判断,你刷新页面时,部分资源可能直接走缓存根本没发起网络请求,看起来就不完整。
无痕窗口初始状态下没有缓存、没有插件干扰,Network面板清清白白,每个请求都是页面真实发起的,排查起来舒服得多。
具体操作流程是这样:
- 打开桌面浏览器,按
Ctrl+Shift+N(Mac上是Command+Shift+N)进入无痕模式。 - 在地址栏输入
www.zhihu.com/hot,回车进入知乎热榜页面。 - 按
F12打开开发者工具,它会自动贴在浏览器右侧或底部。 - 切到Network面板,在过滤条件里勾选Fetch/XHR。这一步很关键,它会把图片、CSS、字体这些无关资源全部过滤掉,只留下普通的异步请求。
- 按
F5刷新页面。
做完这些,Network面板就会开始记录页面发出的所有异步请求。动图演示里我按的就是这个顺序,如果你抓到的界面和我描述的不一样,大概率是漏了哪一步。
2.2 Network面板里如何快速认出数据接口
刷新完之后,你会在面板里看到一串请求。新手到这里最容易懵:这么多请求,哪个才是热榜数据的真身?我教你三个识别方法,按顺序使用,很快就能锁定目标。
先看名字。数据接口的URL通常包含业务关键词。在热榜这个场景下,你优先找名字里带hot、topstory、feed、recommendations这类词的请求。特别是hot-lists/total这个关键词组合,几乎就是明牌告诉你“这是热榜接口”。
再看类型。每个请求在面板里都有Type列,标着xhr、fetch、document、script、stylesheet这些类型。你要找的是xhr或fetch。看到document可以直接跳过,那是页面本身的HTML;看到script、stylesheet也跳过,那是JS和CSS文件。
最后看响应内容。点中一个请求,右侧会弹出详情面板,切到Preview或Response标签。Preview里如果能展开成树状的JSON结构,一级一级能点开,那多半就是数据接口。如果Preview里是一坨压缩过的代码,或者是一堆看不懂的乱码,那这个请求跟数据没关系。
在动图演示里,操作过程是这样的:鼠标从请求列表从上往下扫,一连点了七八个,Preview里要么是HTML代码,要么是字体文件,直到鼠标点到一个叫hot-lists/total的请求时,右侧的Preview直接展开成三级树形结构,最外层是data数组,数组里整整齐齐排列着50条热榜数据。到这一步,你不需要再怀疑,接口已经找到了。
2.3 从请求到响应的完整链路:一条接口的解剖
找到接口之后,不要急着写代码,先把这条请求的细节看明白。点击请求详情面板,里面有几个信息是你写爬虫代码时必须要用到的:
Request URL(请求地址)是https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total?limit=50&desktop=true。这个URL拆开来看就清楚了:hot-lists/total是接口路径,问号后面是参数。limit=50表示返回50条数据,desktop=true表示桌面端请求。这两个参数你也可以改,比如把limit改成20,返回的数据条数会相应变化。
Request Method(请求方法)是GET,没有请求体,所有信息都放在URL和请求头里。记住这个,后面写代码时用requests.get而不是requests.post。
User-Agent需要特别关注。知乎服务器会根据User-Agent判断请求来自真实浏览器还是脚本。如果你在抓包时发现自己的浏览器UA字段是一个长长的、包含Mozilla/5.0、Windows NT、AppleWebKit、Chrome这些关键词的字符串,说明你的访问环境正常。写爬虫代码时,要把这个UA原样复制到代码里。
Referer字段是https://www.zhihu.com/hot,表示请求是从热榜页面发起的。有些服务器会用Referer做合法性校验,非法来源的请求直接拒绝。代码里把Referer也带上,能减少被拦截的概率。
还有一个很实用的验证方法:在热榜页面上,找到第一名的问题标题,然后在接口的Response内容里Ctrl+F搜索这个标题的一个关键词。如果搜到了,就证明这个接口返回的就是你页面上看到的那些数据。这个方法适用于所有网站的抓包验证,学会它比背一百条接口地址都管用。
3. 热榜接口返回数据的结构拆解:JSON字段逐个看
3.1 先看骨架:paging、data、fresh_text
拿到接口返回的JSON后,新手的第一反应往往是直接开写正则去抠数据。我劝你停一下,先把JSON的结构打开,一层一层看清楚。理解了数据结构,解析代码自然就写出来了;不理解结构,代码写一半一定卡壳。
热榜接口的JSON最外层有几个字段:paging、data、fresh_text。先讲后面两个,fresh_text字段的值是一个字符串,类似“刚刚更新”,页面上的更新时间就是它;paging字段是分页信息,包含is_end、next、previous三个子字段,表示是否还有下一页以及跳转链接。
data字段是关键核心,它是一个数组,里面每一项就是一条热榜内容。数组长度和你传入的limit参数一致,15、20、50都可以,默认50条。每一条内容的结构大致是:type(内容类型)、detail_text(热度文字描述)、target(实际的内容对象)、index(排名序号)、children(子内容,通常是补充信息)。
3.2 target对象里有什么:标题、摘要、回答数、关注数
每一条热榜数据里,真正承载内容的是target对象。这个对象是一个典型的“内容聚合体”,大部分字段都是可读的英语单词,我挑最常用的列了一个表格:
| 字段名 | 含义 | 示例值 |
|---|---|---|
id | 问题ID | 602233456 |
title | 问题标题 | 你经历过最尴尬的事是什么 |
excerpt | 问题摘要 | 就是突然不知道怎么接话那种... |
answer_count | 回答数 | 3287 |
follower_count | 关注数 | 89245 |
comment_count | 评论数 | 126 |
created | 创建时间戳 | 1612500000 |
有时候你点开某一条数据,发现target里没有title字段,别慌。热榜里有一种内容类型叫“想法”(类似短动态),它的标题不在最外层,而是嵌套在target里的question对象中。所以写解析逻辑的时候,最好兼容两种结构:先取target.title,取不到再去target.question.title里找。我在后面的完整代码里就是这么处理的。
3.3 detail_text热度值的处理思路
每个data条目里都有一个detail_text字段,它的值是类似1850 万热度这样的字符串。这个字段是给人看的,不是给程序用的,直接存下来后面做数据分析会很痛苦。你想算热度变化趋势,总不能对“1850 万热度”和“896 万热度”两个字符串做减法吧?
所以在解析阶段就应该把文字热度值转换成数值。思路很简单:判断字符串里有没有“万”字,有的话,把“万”字前后拆开,前面的数字乘以10000;没有“万”字,说明热度数值还比较小,直接转换成整数就行。举个例子:
1850 万热度→ 去掉“ 万热度” → 1850 → 乘以10000 → 1850000096 万热度→ 去掉“ 万热度” → 96 → 乘以10000 → 960000
有了这个数值,你就可以画热度趋势折线图,做排序对比,甚至用pandas做统计分析。这是整个案例里最能体现“数据清洗”思想的一个点,也是新手最容易忽略的细节。很多人抓完数据存进CSV就不管了,等到真想分析的时候才发现字段格式不对,又要回头重新写清洗逻辑,多走一遍冤枉路。
4. 完整爬虫代码实现:每行注释都给你写清楚
4.1 整体设计:请求、解析、存储三板斧
写爬虫代码,我最怕的就是那种把几十行逻辑全堆在全局作用域里的写法。变量满天飞,函数一个没有,跑完一次就废,想改个URL还得在代码里到处搜索。正规一点的写法是拆成三个模块:请求模块负责获取数据,解析模块负责把JSON变成规整的列表,存储模块负责写文件。这样每块逻辑都是独立的,出了问题单独排查就行。
基于这个设计思路,我用了一个类来组织代码,把请求、解析、保存都封装成类方法。类的好处是状态统一管理,实例化一次之后,headers、cookie、session这些配置都跟着实例走,主函数里调用起来非常清爽。
4.2 完整代码:热榜采集器
import requests import json import csv from datetime import datetime class ZhihuHotListSpider: """知乎热榜数据采集器""" def __init__(self, cookie=None): # 接口地址:limit控制返回条数,desktop表示桌面端 self.base_url = "https://www.zhihu.com/api/v3/feed/topstory/hot-lists/total" # 请求头伪装成真实浏览器,这是反爬的第一道门槛 self.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": "application/json, text/plain, */*", "Referer": "https://www.zhihu.com/hot", "Origin": "https://www.zhihu.com", } # 如果有登录后的Cookie就加进去,可以提高请求成功率 if cookie: self.headers["Cookie"] = cookie def fetch(self, limit=50): """发送请求,获取热榜原始JSON数据""" params = { "limit": limit, "desktop": "true" } # 使用Session保持请求会话,连续请求时对服务器更友好 session = requests.Session() session.headers.update(self.headers) resp = session.get(self.base_url, params=params, timeout=10) resp.raise_for_status() # 状态码不是200时直接抛异常 return resp.json() def parse(self, json_data): """解析JSON,提取需要的关键字段""" result = [] for item in json_data.get("data", []): target = item.get("target", {}) # 兼容两种内容结构: # 一种是target里直接有title,一种是title在question子对象里 title = target.get("title", "") if not title: question = target.get("question", {}) title = question.get("title", "") excerpt = target.get("excerpt", "") answer_count = target.get("answer_count", 0) follower_count = target.get("follower_count", 0) # 热度值清洗:"1850 万热度" -> 18500000 hot_text = item.get("detail_text", "") hot_num = 0 if "万" in hot_text: try: num_str = hot_text.replace(" 万热度", "").replace("万热度", "") hot_num = int(float(num_str) * 10000) except ValueError: hot_num = 0 result.append({ "rank": item.get("index", 0), "title": title, "excerpt": excerpt, "answer_count": answer_count, "follower_count": follower_count, "hot_text": hot_text, "hot_num": hot_num, "url": f"https://www.zhihu.com/question/{target.get('id', '')}", "crawled_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S") }) return result def save_as_json(self, data, path): """保存为JSON文件,ensure_ascii=False保留中文""" with open(path, "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) def save_as_csv(self, data, path): """保存为CSV文件,utf-8-sig编码防止Excel打开中文乱码""" fieldnames = [ "rank", "title", "hot_text", "hot_num", "answer_count", "follower_count", "url", "crawled_at" ] with open(path, "w", encoding="utf-8-sig", newline="") as f: writer = csv.DictWriter(f, fieldnames=fieldnames) writer.writeheader() writer.writerows(data) if __name__ == "__main__": # 实例化爬虫,建议传入你登录后浏览器里的Cookie,成功率更高 spider = ZhihuHotListSpider( cookie="你的Cookie" ) # 1. 请求热榜数据 raw_data = spider.fetch(limit=50) # 2. 解析为规整的字典列表 items = spider.parse(raw_data) # 3. 分别保存为JSON和CSV两种格式 spider.save_as_json(items, "zhihu_hot_list.json") spider.save_as_csv(items, "zhihu_hot_list.csv") # 4. 在控制台打印前5条,验证结果 for item in items[:5]: print(item["rank"], item["title"], item["hot_text"])注意:代码里的
cookie="你的Cookie"需要替换成你自己浏览器里的真实Cookie。获取方法是在已登录的知乎页面按F12打开开发者工具,切到Network面板,刷新页面,选中任意一个请求,在Request Headers里找到Cookie:后面的内容,整段复制粘贴过来。Cookie是你登录凭证的一部分,不要把这个文件推送到公开的Git仓库里。
4.3 运行与验证:一次跑通整个采集流程
第一次运行前,确保你的环境里已经安装了requests库。没装的打开命令行执行pip install requests,装完再运行脚本。如果用的是Anaconda,requests通常已经内置了,不用额外装。
运行之后,你会看到控制台打印出类似这样的内容:
1 你经历过最尴尬的事是什么 1850 万热度 2 有哪些你以为是常识但其实是误解的冷知识 1723 万热度 3 如何评价2025年春节期间上映的电影 1468 万热度同时当前目录下会多出两个文件:zhihu_hot_list.json和zhihu_hot_list.csv。用记事本打开JSON文件,能看到格式化过的完整数据;用Excel打开CSV文件,表格里每一列就是解析好的字段,中文不会乱码,热度值已经是纯数字。
到了这一步,一个完整的爬虫项目就算做完了。整个流程走下来,你实际上掌握了四个核心技能:用开发者工具定位异步接口、解析JSON数据、处理非规范文本、把结果落盘成文件。这四个技能是90%以上爬虫项目的通用骨架,学会了这一套,换任何网站都只是更换URL和解析字段的事。
5. 实战中必踩的坑:UA、Cookie、频率控制
5.1 第一个坑:不给User-Agent,直接403
新手写爬虫第一个请求经常这样写:requests.get(url),就这么一行,没有headers,然后运行,返回一个403 Forbidden。为什么?because服务器看到你的请求头里的User-Agent是python-requests/2.31.0,一眼识破这是脚本,不是浏览器,直接拒绝服务了。
解决方式是给requests库穿上浏览器的马甲。把浏览器F12里看到的User-Agent完整字符串复制到代码的headers字典里,请求就变得和正常浏览器访问没有区别了。这一招是所有网站通用的,不管你是爬百度还是爬豆瓣,先伪装UA再谈其他。
5.2 第二个坑:不带Cookie,时好时坏
热榜接口有个烦人的特点:有时候不登录也能访问,返回正常数据;有时候又会把你重定向到验证页面,返回的是HTML而不是JSON。这个“时好时坏”跟你的IP段、请求频率、以及平台当前的风控策略都有关系。
最稳妥的做法是在请求头里带上Cookie。这里分享一个效率技巧:不要手动复制Cookie——在Network面板里,右键点击刚才找到的热榜接口请求,选择Copy→Copy as cURL (bash),然后把整段curl命令粘贴到命令行工具里执行,正常情况下会直接返回JSON数据。这说明你当前浏览器的Cookie是有效的。这时候再把curl命令转成Python代码,推荐用 requests库结合curlconverter这类在线工具,一键转换,比自己手写强多了。
5.3 第三个坑:频率控制不到位,账号被限制
很多爬虫初学者拿到一个能用的接口,就控制不住地想要“多爬一点”。今天爬了一次,下午又爬一次,明天写个循环爬一百次。一段时间后你会发现,接口突然返回登录验证页,甚至提示账号异常。这就是被风控了。
我个人的建议是,学习场景下每小时最多发几次请求,哪怕做定时采集,间隔也不要低于5分钟一次。热榜数据的更新频率本身就慢,通常几分钟才变化一次,你爬得再频繁,拿到的数据也几乎一样。没必要用高频请求去冒账号风险。
重要提示:Cookie关联着你的知乎账号。假如采集频率过高导致平台发起了账号安全验证,你不仅失去了接口访问权,自己的日常账号使用也会受到影响。为了练一个爬虫把自己主账号搭进去,这笔账怎么算都不划算。
5.4 第四个坑:返回的是HTML不是JSON
还有一种情况容易让人迷惑:请求的URL明明是对的,UA也带了,Cookie也带了,但代码里执行resp.json()时报错,说JSON解析失败。别急着改代码,先把resp.text打印出来看一眼。
大概率你会看到一段完整的HTML,里面写着“请完成安全验证”或“访问太过频繁”。这说明你的请求被风控拦截,重定向到了验证页面。接口本身并没有问题,问题是你的当前环境触发了反爬。处理的方式无非是降低请求频率、换一个网络环境、或者等待一段时间再试。
这四种情况我在不同网站上都碰到过,处理思路是通用的:先判断返回内容是不是预期格式,不是就要怀疑被拦截,而不是怀疑自己的代码逻辑。
6. 从热榜采集到数据落地:顺手把存储和扩展也做了
6.1 CSV和JSON两种格式的取舍
代码里我同时保存了JSON和CSV两种格式,这不是多此一举,而是两个格式的用途完全不同。
JSON格式保留了数据的层级关系和完整度,方便程序后续读取和处理。如果你想做更复杂的分析,用Python的pandas库读取JSON数据非常顺手。CSV格式则方便人眼查看和Excel操作,双击文件就能用表格软件打开,排序、筛选、做图表都很直观。两个都存一份,后续无论是给人看还是给程序用,都有的选。
需要注意CSV文件的编码细节。我用的是utf-8-sig而不是普通的utf-8。这个带-sig后缀的编码会在文件开头写入一个BOM标记,Excel打开的时候就能正确识别中文字符编码,不会出现一打开全是乱码的情况。很多新手在这里吃过亏,存的时候明明用的utf-8,用Excel打开却乱码,就是因为少了这一个小小的后缀。
6.2 定时采集:让脚本自己跑起来
热榜数据是动态变化的,只跑一次拿到的只是一个时间点的快照。如果你想观察数据的变化趋势,比如搞清楚一个热点话题在24小时内的热度走势,那就需要定时采集。
我常用的方式是用系统自带的任务计划程序,或者写一个最简单的死循环加sleep:
import time while True: spider = ZhihuHotListSpider(cookie="你的Cookie") raw_data = spider.fetch(limit=50) items = spider.parse(raw_data) timestamp = time.strftime("%Y%m%d_%H%M%S") spider.save_as_csv(items, f"hotlist_{timestamp}.csv") print(f"{timestamp} 采集完成,共{len(items)}条") time.sleep(1800) # 每30分钟采集一次这样每30分钟自动采集一次,文件名带上时间戳,积累一个月,你就拥有一份完整的热度变化数据集,接下来可以做的事情就多了:算热度排名波动、分析话题上升速度、对比不同时段的活跃度。这些都算是数据采集的上层应用,本质上是你这篇博文学到的基础技能在延伸。
6.3 关于这个案例的几句体己话
带了几期入门爬虫项目之后,我越来越发现一个规律:新手能不能学好爬虫,关键不在他掌握了多少库的API,而在于他遇到问题时的第一反应是什么。是打开浏览器看网络请求,还是瞎猜参数乱试?是去查接口返回的数据结构,还是满屏print碰运气?
这个案例把抓包思路放在最前面,就是想让你养成一个职业习惯:任何数据采集需求,第一步永远是搞清楚数据从哪里来,而不是急着打开编辑器写代码。你用开发者工具把接口定位清楚,把返回的数据结构看上两遍,写代码的时间可能只要十分钟。
我在实际带项目的过程中,见过太多人卡在同一个地方:接口找到了,数据结构也看懂了,却不知道怎么把数据变成代码里的变量。其实这个问题没有门槛,就是多用、多错、多改。就像学游泳,你看再多的教程,都不如下水呛两口水来得实在。现在热榜的采集脚本已经完整地摆在上面了,建议你把它跑起来,然后在它基础上改一改,比如增加翻页、改成采集问题的全部回答、或者接一个简单的数据可视化页面。改着改着,你就算真正入了门。