☰
油猴脚本实战:零服务器抓取知乎内容,从原理到导出全流程
2026/10/17 23:13:53 网站建设 项目流程

提到“爬取知乎内容”,很多人的第一反应是上爬虫框架、搞代理池、维护登录态,整套东西下来没有两三天跑不通。但如果你只是想把自己某个问题下的回答、收藏夹里的内容、某个用户的高赞回答保存成本地资料,其实有个轻量得多的路子——油猴脚本。

油猴脚本跑在浏览器里,天然拥有你登录后的身份,不需要单独处理Cookie过期,也不需要绕登录验证。页面加载什么,脚本就能拿到什么,数据存在哪里、结构长什么样,直接打开开发工具看一眼就知道。这篇分享就围绕“用油猴脚本抓知乎内容”这个思路展开,把从原理、实现到踩坑的完整过程整理出来,适合有一定前端基础、想快速做内容备份的开发者参考。

1. 为什么是用油猴脚本抓知乎,而不是养一个爬虫服务

1.1 登录态与数据来源的天然优势

写爬虫最麻烦的不是解析页面,而是身份问题。知乎的绝大多数内容必须登录后才能稳定访问,尤其是搜索、收藏夹、关注页这类场景。传统方案需要维护登录Cookie、处理验证码、应对风控,一套流程做下来,精力全耗在对抗上,真正的数据提取反而成了小头。

油猴脚本是运行在浏览器扩展沙盒里的用户脚本,它复用你当前浏览器窗口的登录态。知乎觉得“你就是那个正常登录的用户”,请求从真实浏览器发出,带完整的浏览器指纹、Cookie、UA,脚本再去操作页面或调用页面内接口,几乎不增加额外风控压力。

我自己的体会是:做个人数据备份、内容整理这类低频任务,油猴脚本是性价比最高的形态。它不需要独立服务器,不需要定时任务,打开页面点一下脚本就能跑,抓完导出一个文件收工。数据量级在几百条到几千条的范围内,浏览器内存完全扛得住。

1.2 适用场景与不该碰的场景

油猴抓取天然适合三类场景:一是备份自己的创作内容,比如把某段时间发过的想法、文章按时间归档;二是收藏夹整理,把收藏的问题、回答导出成可检索的Markdown;三是跟踪性抓取,比如固定时间抓取某个热榜榜单做趋势记录。

但它不适合做大规模数据采集。一方面浏览器标签页的内存有限,持续滚动几万条内容,页面渲染节点太多,标签页会卡死;另一方面脚本频繁调用页面接口,短期内大量请求仍然会触发频控,严重时影响当前账号的正常使用。这个边界要想清楚:油猴是“个人备份工具”,不是“数据采集平台”。

2. 抓取前必须搞懂的底层机制:渲染方式、数据位置与分页规则

2.1 知乎内容页的装载方式:服务端渲染与异步加载的混合体

抓取前先做一次彻底的分析。我以前吃过亏,拿到页面就开始写选择器,结果滚动几下脚本就失灵,重新排查才发现内容根本不是一直存在的DOM节点。

打开一个知乎问题页面,按F12看网络请求,会看到两种明显不同的情况。第一屏的问题描述、部分回答是服务端渲染的结果,直接存在于初始HTML里;但继续往下滚动,后面的回答是通过XMLHttpRequest或Fetch请求分页接口拿到的JSON数据,再由前端JS渲染成DOM。换句话说,你看到的页面,一部分是“死”的,一部分是“活”的。

这种混合渲染机制决定了脚本不能只写一个页面加载完成的回调。必须监听滚动或者监听DOM变化,等待异步内容渲染完成后,再去提取新出现的节点,否则只能抓到第一屏的内容。我建议的做法是:区分“首屏抓取”和“动态抓取”两个阶段,首屏直接解析已渲染DOM,动态阶段通过MutationObserver监听新增回答节点,这样结构最清晰,也便于出错时定位到底哪一步没跑。

2.2 DOM解析还是接口直取:两条路线的取舍逻辑

知乎页面的数据获取有两条技术路线:解析渲染后的DOM节点,或者直接调用页面内部的API接口。

DOM解析的优点是稳定直接,所见即所得。用户看到什么,脚本抓到的就是什么。知乎前端的渲染逻辑就算变化,只要最终DOM结构变化不大,脚本调整的成本就很低。缺点是受页面结构影响大,如果知乎某次改版调整了class名,选择器就失效一大片。

直接调接口的优点是数据结构干净,拿到的JSON里有作者、时间、点赞数、内容全文,不需要费劲剥离HTML标签。但接口不是随便调的,知乎的接口有签名参数,普通请求不带签名会被风控拦下来。油猴脚本的好处在于,页面自身已经发起了这些接口请求,脚本可以直接复用页面上下文中已有的请求参数,或者从网络面板中提取请求格式。

我最终采用的方式是混合路线:页面里有完整数据的场景用DOM解析,拿不到稳定DOM时再退回到接口直取。比如抓收藏夹内容时,收藏夹页面DOM结构经常变化,我就抓页面底部发起的列表接口,解析JSON比调整DOM选择器快得多。

2.3 分页规则的三种形态:页码、游标与时间线

知乎不同模块的分页逻辑各不相同,写脚本前必须先确认你目标页面的分页类型。

问题回答列表用的是深度分页加游标方式。接口返回的JSON里有一个“cursor”字段,下一页请求把它原样带过去即可。评论区和想法流也类似,都是基于游标。

收藏夹列表则是比较简单的页码分页,页面URL里直接有page参数,或者接口体里有offset参数。如果你只抓前几页,这个最简单,循环请求即可。

用户时间线是另一种情况,它更像是“按时间截断”的流式接口,请求参数里有before和after两个时间戳。初次请求拿最新20条,下一次把当前列表最后一条的时间戳作为before参数,继续往下翻。这个逻辑和cursor本质上很像,但字段名不同,容易搞混。

3. 脚本实战:从第一行代码到稳定跑完整个页面流

3.1 构建脚本骨架:注入时机与运行容器

油猴脚本的头部元信息决定了脚本的运行环境。抓取知乎内容需要操作DOM、发请求、下载文件,权限比普通脚本多一些。我常用的头部配置如下:

// ==UserScript== // @name 知乎内容抓取与导出 // @namespace local.zhihu.export // @version 0.2.0 // @description 抓取当前页面的回答/文章内容,支持翻页自动加载,导出Markdown与JSON // @match https://www.zhihu.com/* // @grant GM_xmlhttpRequest // @grant GM_download // @grant GM_setValue // @grant GM_getValue // @run-at document-idle // @noframes // ==/UserScript==

几个字段值得展开说一下。

@run-at document-idle表示页面加载完成后运行脚本,这个时机能保证首屏DOM已经存在,但又不会因为等待太久而丢失早期动态数据。@noframes限制脚本只在主页面运行,知乎页面上偶尔有内嵌iframe,不限制的话脚本会在每个iframe里都跑一遍,做弹窗逻辑时会异常。

权限方面,GM_download不是必需的,如果脚本只用URL.createObjectURL加a[download]触发下载,就不需要这个权限。但考虑到抓取内容量较大时可能超过浏览器单次下载限额,我会用GM_download做兜底,遇到大文件时更稳定。GM_setValue用于保存抓取进度,防止页面刷新后从头再来。

3.2 滚动加载监听:别用最笨的定时器轮询

监听知乎无限滚动时,新手最容易写出的代码是setInterval每500毫秒检查一次“有没有新节点”,这种做法的问题有两个:一是频繁执行查询,影响页面流畅度;二是容易在知乎自身加载动画还没完成时抓到半截DOM,提取出残缺数据。

正确的方式是用MutationObserver监听列表容器的子节点变化,同时配合IntersectionObserver做底部触发判断。简单说,MutationObserver负责回答“是不是新增了内容”,IntersectionObserver负责回答“是不是应该去加载下一页”。两者配合,几乎不消耗多余性能。

const listSelector = '.List-item'; const observer = new MutationObserver(() => { collectNewItems(); }); observer.observe(document.querySelector('#root'), { childList: true, subtree: true });

注意监听范围不要太大。#root是知乎整个应用挂载节点,监听它虽然能覆盖所有动态组件,但每次知乎内部按钮状态变化都会触发回调,回调里要做一层过滤,判断当前Collection里是否真的增加了新的回答节点。数据量上去后,建议缩小监听范围到回答列表的外层容器,这样回调触发频率大幅下降,页面也更流畅。

3.3 回答解析与去噪:只留下有价值的内容块

从回答节点提取内容时,目标是干净正文,去掉推荐卡片、话题标签、无关链接。核心解析思路是定位内容容器,再移除非正文子节点。

以单条回答为例,一个典型的List-item节点内部包含:作者信息区、内容区、操作栏(赞同、评论、分享按钮)、推荐内容卡片等。内容区在class里常出现RichText,这是知乎的富文本渲染容器。提取时我直接取RichText的innerText,这个字段会保留段落结构,又不会带进去HTML标签,适合导出Markdown。

需要注意标题和正文的区分。回答内容里如果含有用户自己插入的图片,图片的alt文本有时会被拼进innerText,导出时就会出现一段莫名奇妙重复的文字,这部分要做一次清洗,把连续的空格和换行压缩掉。作者、点赞数、评论数这些元数据在AuthorInfo组件和操作栏中提取,按需保留,不需要太多。

function extractAnswer(item) { const authorNode = item.querySelector('.AuthorInfo-name'); const contentNode = item.querySelector('.RichText'); const likeNode = item.querySelector('.VoteButtonContainer span'); if (!contentNode) return null; return { author: authorNode ? authorNode.innerText.trim() : '匿名用户', content: contentNode.innerText.replace(/\n{3,}/g, '\n\n').trim(), likes: likeNode ? parseInt(likeNode.innerText, 10) : 0, url: window.location.href.split('#')[0] }; }

这段代码里的replace(/\n{3,}/g, '\n\n')是我在实际使用中加上的,知乎的富文本渲染会保留大量空行,直接导出到Markdown里段落间距大得离谱,这个正则能把多行空行压缩成单空行,阅读体验好很多。

3.4 翻页到底该如何驱动:两种触发机制的组合策略

处理知乎问题回答列表时,需要你先滚动到页面底部,触发加载,再等新回答渲染,再继续滚动,如此反复。单纯设置一个滚动事件监听,回调函数会高频触发,加载还没完成就再次触发,容易重复请求。

推荐用一个“状态机”来控制抓取流程。定义三个状态:IDLE(空闲)、LOADING(加载中)、FINISHED(结束)。滚动触底时,只有IDLE状态下才允许触发加载,切换到LOADING;等待新节点渲染后,再切回IDLE。这样可以天然避免重复触发。

let state = 'IDLE'; async function handleScroll() { if (state !== 'IDLE') return; const nearestBottom = document.documentElement.scrollHeight - window.scrollY - window.innerHeight; if (nearestBottom < 600) { state = 'LOADING'; const prevCount = itemCount; window.scrollTo(0, document.documentElement.scrollHeight); await waitForNewItems(prevCount, 8000); state = 'IDLE'; } }

waitForNewItems的实现方式:轮询当前已收集节点数,如果8秒内没有增加,判定为没有更多内容,置为FINISHED。加上这个超时机制后,脚本能正确识别“已经加载到末尾”的情况,避免无限等待。

4. 数据落盘:序列化方案、暂存队列与文件导出

4.1 三种导出格式的取舍:Markdown、JSON与CSV

抓取完成后,数据导出格式直接影响后续使用效率。我保留了三种导出方式,按场景选择。

Markdown适合直接阅读和二次编辑。把回答内容按作者、点赞数、正文结构组织,导出后用Typora或VS Code打开就是一份干净的阅读文档。缺点是Markdown对图片支持有限,知乎图片链接是外链,需要联网才能显示,离线阅读时图片会裂掉。

JSON适合程序化处理。保留原始数据字段,导出后可以写个小脚本做统计、转格式、入库。CSV适合表格化整理,导入Excel做筛选排序方便,但CSV对长文本的兼容性不好,回答正文里如果包含换行符和逗号,需要做转义,否则列会错位。

我的建议是:日常阅读场景导出Markdown,后续要做处理就导出JSON,CSV只用于轻量清单,比如回答标题、作者、点赞数这种短字段,不要塞正文。

4.2 暂存机制:页面被刷新的最后防线

知乎这种单页应用,用户滚动加载大量内容后,标签页内存占用高,随时可能崩溃。一旦页面崩溃,脚本里的数组全部丢失,之前的抓取就白干了。

GM_setValue本来可以做持久化,但知乎内容动辄几百条,每抓一条就写入一次存储,频繁同步会产生明显卡顿。折中方案是“暂存队列”:每抓取20条数据,批量写入一次GM_setValue,同时维护内存里的完整数组用于最终导出。

const BATCH_SIZE = 20; let cache = GM_getValue('zhihu_cache', []); function flushCache() { if (cache.length >= BATCH_SIZE) { GM_setValue('zhihu_cache', JSON.stringify(tempStore)); tempStore = []; } }

这个设计投入实用后很关键,有一次我抓一个三百多回答的问题,滚动到一半标签页崩溃了,重启浏览器打开脚本,数据恢复到了前不久的暂存点,只是少抓了二十来条,重新滚动补抓就完了,没有从头再来。

4.3 文件下载:两种方式与大小限制

浏览器端的下载方式有URL.createObjectURL和GM_download两种。前者是纯前端方式,适合中小文件,生成一个临时对象URL后触发a[download]点击即可。缺点是如果数据序列化成超大JSON字符串,Blob存入内存会占用较多资源,文件过大时浏览器可能阻止下载。

后者是油猴脚本API,把数据用Blob打包后交给浏览器的下载管理器处理,适合更大的数据量,而且这个API不受页面JS对a[download]跨域限制的影响。知乎页面的JS环境本身很复杂,它对下载行为可能会有拦截,GM_download绕过页面环境,更稳。

使用GM_download时注意MIME类型。导出Markdown文本用text/markdown,导出JSON用application/json,如果MIME传错,部分浏览器会强制把文件改成.download后缀,还要手动重命名,体验不好。

5. 上线后必须处理的五个坑:一次真实的翻车记录

5.1 重复数据:无限滚动列表的顽疾

第一个遇到的坑出现在滚动抓取第二页时。翻页后新渲染的回答节点里,有一部分和之前已经抓过的相同。

原因是知乎的列表渲染不是严格按offset追加的。在用户快速滚动时,知乎前端可能会重复渲染一部分节点,或者在网络抖动时重新拉取了之前页面的数据。解决思路是给每条回答建立一个唯一指纹。如果回答URL存在,直接用URL去重;如果抓的是收藏夹列表项,没有唯一URL,就用“作者加内容前50个字符”做哈希。哈希去重还有个额外好处,如果一条回答被修改过,指纹变化,脚本会把它当新内容抓到,这在备份场景下反而是优点。

5.2 顺序错乱:异步请求返回时间不一致导致的内容颠倒

第二个坑比较隐蔽。抓到第50条之后,我发现一旦打开“按时间排序”的回答列表,内容顺序会出现跳变,中间会漏几条。

排查后发现原因是知乎回答列表的翻页不完全是顺序请求。前端预加载机制会在你滚动还没到末尾时就提前请求下一页,多个接口并发返回,渲染顺序和回答实际顺序不完全一致。我最初逐条保存,存储顺序等于渲染顺序,渲染顺序一旦错乱,导出的内容顺序也就乱了。

解决办法是提取节点时把节点自带的>

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询