读书这件事,我这几年的一个明显感受是:纸质书留下来的只有划线和折角,电子书留下来的只有一堆碎片化的想法。我在微信读书上积累了三百多本、上千条划线和想法,这些内容躺在手机里,检索靠翻,整理靠截图,换设备就断掉。所以我一直在找一条路,把这些真正属于我自己的阅读数据拿回到本地,变成可以检索、可以喂给自己笔记系统的一份结构化文件。wereader就是这类工具里比较有代表性的一个,它做的事情说起来朴素——把你在微信读书里的书架、书籍信息、章节结构、划线和想法,按一定规则抓取下来,再整理成 Markdown 这类纯文本格式。
这篇文章不是给它做推介,而是把我自己完整跑通一遍的整个过程拆开讲:需求边界在哪里、技术选型为什么是浏览器自动化而不是别的路子、页面结构怎么读、限速和断点续传怎么设计、哪些坑会把你卡住一整天。适合两类人看:一类是有一定编程基础、想给自己做一套个人阅读数据归档的开发者;另一类是产品、运营、研究者,想理解"自动化采集 + 结构化整理"这套思路在小规模、个人自用场景下是怎么落地的。全文所有代码只是示意,不会给出可直接打的地址和选择器,也强烈建议你把频次压到最低、只碰自己账号下的数据。
1. 先把"抓"这件事想清楚:需求边界与合规红线
1.1 工具真正解决的是"我自己的数据搬不出来"
很多人一听爬虫,脑子里浮现的是海量抓取、并发拉满、数据建库。wereader这类工具跟那个画像是两回事。它的核心价值在于把自己账号里已经产生的、平台没有提供批量导出入口的数据,转成本地可读的文本。具体来说就是三类东西:书架清单(书名、作者、封面、进度)、书籍内部的章节结构、以及划线和想法这类用户生成内容。
为什么这个需求真实存在?因为平台提供的便利是"阅读"和"同步",而不是"迁移和再加工"。你想做主题阅读笔记、想把某一年的划线圈出来复盘、想用搜索查"我到底在哪本书里看过这个概念",平台内的搜索能力是有限的。数据一旦落到本地 Markdown,你就可以用任何工具去 grep、去双链、去做关键词统计,这是质的区别。
所以我在动手之前给自己定了三条线:只处理登录状态下的本人账号、只导出自己可见范围内的内容、不做高频批量请求。这三条不是给谁看的免责声明,而是实际决定工具形态的约束条件——它直接导致了我选择低频串行、本地缓存优先、手动触发为主的方案,而不是一套无人值守的采集系统。
1.2 哪些能做、哪些坚决不能碰
边界这件事必须写清楚,因为它决定了你的工具会不会变成一个自己都不敢跑的脚本。
| 类别 | 是否适合自己做 | 原因 |
|---|---|---|
| 书籍元信息(书名、作者、进度) | 适合 | 公开可见,且属于你自己书架 |
| 章节标题与目录结构 | 适合 | 帮你还原阅读脉络 |
| 个人划线、想法、批注 | 适合 | 用户生成内容,本人数据 |
| 阅读时长、历史记录 | 谨慎 | 涉及账号行为轨迹,只留本地 |
| 正文全文大规模导出 | 不建议 | 涉及版权,超出个人备份必要范围 |
| 付费/会员专属内容的完整转存 | 坚决不做 | 触碰版权与付费机制 |
我把这张表贴在项目 README 的第一屏,不是为了显得严谨,而是踩过一次之后学到的教训:最初我把"整章正文"也纳入了导出范围,结果文件体积暴涨、断点续传变得极难维护,而且导出物在一台旧电脑上被同步工具传了出去——那一刻我才意识到,多抓的那部分并不是我的数据,而是别人的内容。后来我把导出策略改成只保留元信息 + 划线原文 + 想法文本,配合少量上下文做定位,文件体积降了一个数量级,也彻底避开了版权边界。
注意:划线和想法里如果引用了大段原文,导出时建议做长度截断(比如每条划线超过 500 字就折行存储并标注来源位置),既便于回查,也避免本地文件变成原文的镜像。
1.3 技术选型:为什么是浏览器自动化而不是接口逆向
这里是最容易被新手问的一个问题:为什么不直接调用接口?
先说结论:对个人自用、低频、结构不稳定的场景,浏览器自动化的综合成本最低。原因有三层。
第一层是登录态的成本。接口方案的鉴权通常依赖若干请求头、签名参数和时间戳,这些值会随平台迭代频繁变化,你要持续跟进。而浏览器自动化直接复用真实浏览器里的登录状态,你在页面上是什么身份,脚本就是什么身份,天然同步。
第二层是结构稳定性。接口返回的 JSON 字段可以由平台随时增减、加密、改名;页面 DOM 虽然也会变,但页面是给人看的,视觉结构的变化通常比内部协议变化慢得多,而且变化时你能直观看到、快速定位。
第三层是调试体验。用浏览器自动化时,每一步都能截图、能暂停、能在控制台里手动验证选择器。接口方案出问题时,你面对的是一段无法解释的加密串,排查成本陡增。
当然代价也明显:执行慢、资源占用高、对页面渲染完成时间敏感。这就引出后面几节的重点——等待策略、限速、重试、断点续传,这些不是锦上添花,而是浏览器自动化方案能不能长期跑起来的关键。我自己的实测是:单次全量同步 200 本书的元信息加划线,串行跑下来大概二十到四十分钟,取决于网络和渲染。这个耗时对个人自用完全可接受,因为它是可断点续传的一次性任务,不是服务。
顺带说一句选型的隐性收益:浏览器自动化天然"慢",而慢在这个场景里是优点。它的请求节奏接近真人浏览,不需要你额外设计复杂的随机延迟逻辑,也不会给平台造成脉冲式压力。
2. 环境搭建与登录态管理
2.1 运行环境与依赖的最小组合
我试过好几个组合,最后稳定下来的是Python + Playwright。早些年用 Selenium,能跑,但等待机制和上下文管理写起来啰嗦,尤其在处理弹层、滚动加载这些交互时,隐性等待经常不够或者过头。Playwright 的自动等待和 context 隔离让代码干净不少,而且启动浏览器、持久化用户数据目录这些操作都是一等公民。
依赖我尽量压缩到最少:
# 示意,版本按当时实际安装为准 pip install playwright playwright install chromium目录结构上我分成四块,这个分法后来证明是对的:
project/ ├── profile/ # 持久化浏览器用户数据目录 ├── cache/ # 原始响应与页面快照缓存 ├── output/ # 整理后的 Markdown └── state/ # 断点续传状态、任务队列profile/单独拎出来是为了登录态复用;cache/存原始页面内容,方便选择器失效时离线复现问题;output/是最终产物;state/记录跑到哪了。这个分层在调试期救过我无数次——有次页面结构大改,我不用重新登录、重新跑,直接在缓存页面上改选择器验证,五分钟定位完。
提示:
profile/目录里保存的是完整的登录会话信息,务必不要提交到任何代码仓库,也不要放在会被自动同步到公共位置的目录下。
2.2 登录态怎么来、怎么存、怎么续
这是整个项目的第一道坎,也是新手卡住最多的地方。
我的做法是手动登录 + 持久化上下文。第一次运行时,脚本启动一个可见的浏览器窗口,把页面停在需要登录的位置,然后等待一段时间(或者等待你按回车),你在这段时间里手动完成登录。登录完成后,Playwright 的持久化上下文会把 Cookie、localStorage 等状态自动写进profile/目录。之后每次启动都指向同一个目录,就能带着登录态直接跑。
# 示意伪代码,重点是思路不是可直接执行的实现 from playwright.sync_api import sync_playwright with sync_playwright() as p: ctx = p.chromium.launch_persistent_context( user_data_dir="./profile", headless=False, # 首次登录必须可见 args=["--disable-blink-features=AutomationControlled"], ) page = ctx.pages[0] if ctx.pages else ctx.new_page() page.goto(START_URL) # 引导到你自己的书架入口 input("完成登录后按回车继续...") ctx.close()这里有几个实操细节值得单独说。
第一,要不要 headless。我建议调试期一律可见模式。不可见模式下很多渲染问题你看不到,只能靠日志猜。等整套流程稳定了,再切换成不可见跑定时任务。
第二,状态检测要显式。不要靠"跑到某一步失败了才判断登录失效"。我在每次任务开始前加一个轻量探测:打开个人页面,检查某个只有登录后才会出现、且长期稳定的元素是否在。这个小检查花两秒钟,省掉的是跑到一半全部失败的返工。
第三,过期是常态不是异常。登录态通常有有效期,也可能因为设备变更、异地访问等原因被清掉。所以整个流程必须假设"随时会掉",也就是所有任务都设计成可以中止、可以恢复。这一点在第 4 节会详细展开。
2.3 数据落盘的设计:为什么先存原始再加工
新手常见的写法是:抓到一个字段,立刻拼进目标文件。这种"边抓边写"的方式在数据量小的时候很爽,一旦中途失败,你就会得到一个半截的、格式可能还不一致的残文件,重跑还得从头来。
我后来改成两段式:第一阶段把抓到的原始结果按条目落成独立的中间文件(一个条目一个文件,或者按批次落 JSON Lines),第二阶段统一读取中间文件生成最终 Markdown。好处非常实在:
- 抓取阶段失败,已有中间结果不受影响,补抓缺的就行;
- 格式调整、模板改动只影响第二阶段,不用重抓;
- 中间文件是人类可读的,排查字段错位时一眼能看出来是抓错了还是渲染错了;
- 可以和缓存页面交叉验证,判断问题出在解析逻辑还是页面本身。
代价是磁盘占用翻倍,但个人阅读数据这点量完全无所谓。
心得:中间文件建议带时间戳前缀命名,比如
2024xxxx_booklist.jsonl。这样每次全量跑都能对比前后差异,能快速发现"这次少抓了几本书"这种隐蔽问题。
3. 页面结构解析与数据抽取
3.1 书架列表:分页、懒加载与"抓不全"
书架是最容易出问题的部分,因为它的加载方式最复杂。
第一层坑是懒加载。滚动到一定位置才触发新内容渲染,如果你只在页面顶部读一次 DOM,拿到的永远是第一批。我的处理是模拟滚动到底,然后等待网络空闲或等待某个哨兵元素出现,而不是用固定 sleep。固定 sleep 在小数据量下看着能用,数据一多就露馅——滚动到底后内容还没渲染完,脚本就开始解析了。
# 滚动加载示意 def scroll_until_stable(page, max_rounds=30): last_height = 0 for _ in range(max_rounds): page.mouse.wheel(0, 3000) page.wait_for_timeout(800) # 只是给渲染留缓冲 height = page.evaluate("document.body.scrollHeight") if height == last_height: break last_height = height注意这里的wait_for_timeout只负责给渲染留缓冲,真正的结束条件是高度不再变化。这个判断比任何固定次数都可靠。
第二层坑是虚拟列表。有些长列表为了性能只渲染可视区附近的节点,滚动过去之后前面的 DOM 就被回收了。这时候"滚到底再一次性读"的办法直接失效,必须改成边滚边收集,用唯一标识去重。我一开始没意识到这点,抓了 200 本书只落了 40 条,对着日志找了半天才想明白。
第三层坑是同名书与版本差异。同一个书名可能有不同作者、不同译本。所以去重键绝对不能只用书名,我用的是"书名 + 作者 + 一个能区分版本的辅助标识"组合。抓完之后还会做一次数量核对:页面上显示的书籍总数和实际抓到的条数是否一致,不一致就报警。
3.2 书籍详情:元信息与章节结构的还原
进入单本书之后,要抓两类东西:元信息(书名、作者、进度、标签)和章节结构(目录)。
元信息相对简单,难点在于字段的容错。有些书没有作者信息、有些没有简介、有些进度字段格式不统一(可能是百分比,可能是"已读完",可能是空)。我的做法是给每个字段配一个候选选择器列表,按顺序尝试,第一个非空结果胜出;全部失败就写null并且记一条日志,绝不写空字符串。这个区别很重要:null表示"我没抓到",空字符串表示"页面上就是空的",两者在后续清洗时处理方式完全不同。
章节结构的坑在于层级。有些书是扁平的一级目录,有些是"卷 - 章"两级,还有的带"篇 - 章 - 节"三级。如果按固定层级解析,遇到非常规结构就会错位。我用了缩进或者 DOM 嵌套深度来判断层级,把结果存成带level字段的扁平列表,渲染时再按 level 重建缩进。这个"扁平存储 + 渲染时重建"的模式,比直接套嵌套结构稳得多。
注意:章节列表在某些书上也是懒加载或分页的。判断依据是目录区有没有"展开更多"之类的入口,或者滚动容器的高度是否大于可视高度。抓到章节数为 1 的,八成是没展开。
3.3 划线与想法:最容易错位的一块
这是整个工具里价值最高、也最容易出 bug 的部分。
划线数据天然是分页/分段的,而且和章节强关联。你看到的可能是"第 3 章下有 12 条划线",但实际抓下来可能变成"全部划线混在一起",章节归属丢失了。一旦丢失,你导出的笔记就变成一堆没有上下文的孤句,价值折损一半以上。
我的处理思路是在滚动收集时同步记录归属上下文。具体做法是:每进入一个章节的划线区域,先把当前章节标识记下来,然后把在这个区域内收集到的所有划线都打上这个标识。听起来简单,但如果页面是"整本书所有划线在一个长列表里、按章节分段显示"的结构,就需要在滚动过程中持续检测"当前处于哪个章节标题之下"。
实际操作中我用了一个比较土但有效的办法:滚动时每一步都读取所有可见的章节标题节点的位置,取"位于当前视口顶部之上、且距离最近"的那个,作为当前归属。这样即使滚动步长不均匀,也能正确归类。性能上完全够用,因为标题节点数量有限。
划线本身的字段我保留:划线原文、所在章节、位置信息(页码或百分比,如果有)、以及想法/评论内容(如果有的话)。位置信息很有用,回查的时候能定位到具体段落,不用在整章里搜。
3.4 清洗与 Markdown 输出:让文件真的能读
原始数据结构化之后,最后一步是生成人能读的 Markdown。这一步看着简单,但决定了你会不会真的去翻这些文件。
我踩过的坑是过度设计。第一版我按"书 - 章 - 划线"三层嵌套,每条划线下面挂想法,想法下面挂标签,结果文件层级深到什么程度?在编辑器里折叠一次要按好几下,找东西反而更慢。
后来改成两套输出:
| 输出类型 | 结构 | 用途 |
|---|---|---|
| 全书归档 | 一本书一个文件,按章节分组,划线按原顺序平铺 | 回读、复盘、存档 |
| 划线总表 | 所有书的所有划线汇总成一个文件,每行带书名和章节 | 全局搜索、主题检索 |
全书归档里,我用二级标题表示章节,每条划线用引用块,想法直接跟在下面一段。这样在任何 Markdown 编辑器里都能有清晰的视觉层次,而且纯文本可读性极强。划线总表则是给grep和全文搜索用的,格式越扁平越好。
还有一个小细节:元信息头。每个文件开头放一个小的 YAML front matter,记录书名、作者、抓取时间、划线条数。抓取时间这一项特别有用——当你发现某本书的划线突然少了很多,能立刻判断是数据变了还是解析坏了。
4. 稳定性设计:限速、重试与断点续传
4.1 节奏控制:慢一点反而更快
前面说过浏览器自动化天然慢,但"天然慢"不等于"不用管节奏"。真正的风险点是你的代码比页面渲染快,导致在错误的时间读到错误的内容。
我的节奏控制分三层。
第一层是动作间延迟。页面跳转、点击展开、滚动之后,都留一个短延迟。这个值我从 300ms 试到 1500ms,最后定在 800ms 左右——不是因为有什么神秘的最优值,而是这个值下我在本地从没遇到过"渲染没跟上"的问题。
第二层是并发度。我直接设成 1,也就是完全串行。曾试过开 3 个标签页并行,速度确实快了,但出现了两个问题:一是同一账号短时间大量请求更容易触发风控,二是并行下日志交错,出问题时根本看不出是哪本书哪一步挂的。串行的可观测性优势,远大于并发带来的那点速度收益。
第三层是超时与熔断。每一步操作都设超时。如果连续 N 次操作超时,直接中止任务并保存状态,而不是死循环重试。我踩过一次:某本书的详情页一直返回异常,重试逻辑没设上限,脚本跑了三个小时全耗在这一本书上,早上起来看到日志人都麻了。
提示:给整个任务设一个总时长上限也很必要。个人自用场景下,超过两小时还没跑完,基本可以断定是卡在某个异常点上了,不如停下来看看。
4.2 异常分类:不同错误不同对待
把所有异常一视同仁地重试,是最常见的错误做法。我把异常分成四类,处理策略完全不同:
| 异常类型 | 典型表现 | 处理策略 |
|---|---|---|
| 网络抖动 | 超时、连接重置 | 指数退避重试 3 次 |
| 登录失效 | 被重定向到登录页 | 立即中止,提示重新登录 |
| 页面结构变化 | 选择器找不到元素 | 不重试,保存页面快照报错 |
| 单条数据异常 | 某本书缺字段 | 记录跳过,继续下一条 |
这个分类的关键在于区分"可恢复"和"不可恢复"。登录失效重试一百次也没用,页面结构变了重试只会浪费时间。而某本书缺个作者字段,根本不该影响整个任务。
实现上我用了一个简单的异常包装:每种异常带一个retryable标记和一个snapshot动作。遇到不可重试的,立刻把当前页面内容存进cache/,然后抛出。这样做的好处是排查时你手里有现场,而不是一段干巴巴的堆栈。
4.3 断点续传与增量同步
这是让工具从"能跑"变成"能用"的分水岭。
断点续传的思路很朴素:每完成一个最小单元,就往状态文件里写一条记录。最小单元我定成"一本书"。状态文件就是一个 JSON,记录每本书的状态(待处理 / 已完成 / 跳过 / 失败)、完成时间、数据指纹。
{ "某书标识": { "status": "done", "finished_at": "2024-xx-xxTxx:xx:xx", "lines": 42, "fingerprint": "划线数量+最后一条划线的短哈希" } }启动时先读状态文件,跳过已完成且指纹未变的书。失败的书保留在队列里,下次优先重试。
增量同步则是更进一步:如果一本书的指纹没变,就完全跳过不访问。这要求指纹能反映内容变化。我用的组合是"划线条数 + 最后一条划线的短哈希 + 进度值"。划线变了、进度变了,指纹就变,才会重新抓。这个机制让我日常同步的耗时从二十多分钟降到了一两分钟——大部分书根本没动过。
有个细节要注意:指纹不能包含抓取时间之类的易变字段,否则每次都不一样,增量判断就失效了。我第一次写的时候把时间戳也塞进去了,结果每次都全量重跑,还纳闷为什么增量没用。
5. 常见问题排查实录
5.1 登录相关:失效、验证、以及"看起来登录了其实没有"
症状一:跑了几分钟突然全部失败。大概率是登录态中途失效。我现在的做法是在每个大阶段开始前做一次轻量探测,而不是只在开头检查一次。
症状二:明明登录了,却拿不到数据。这种情况我遇到过两次。一次是因为持久化目录被另一个进程占用,浏览器实际启动了一个全新的空白上下文;另一次是因为页面渲染在"登录后重定向"的过程中被脚本提前读取了。前者靠检查启动日志能发现,后者靠"等页面稳定再读"解决。
症状三:频繁出现需要额外验证的提示。出现这个信号就说明你的请求节奏有问题了。我的处理不是想办法绕过,而是停下来、降频、把并发彻底关掉。这个场景下硬顶没有任何好处,一次触发会浪费大量时间去等冷却。
注意:任何时候都不要试图用自动化手段去处理额外的验证步骤。遇到这类提示,正确做法是手动完成、然后降低后续的请求频次。
5.2 数据不全与错位:怎么定位是抓漏还是解析错
这是我花时间最多的一类问题,后来总结出一套固定流程:
- 先看 count:页面上显示的划线总数 vs 实际抓到条数。对不上说明抓漏。
- 再看结构:随便挑 3 条,看章节归属对不对。错了说明上下文识别逻辑有问题。
- 最后看内容:比对一条划线的原文和想法,看有没有串行。
抓漏最常见的原因是滚动没到底或者滚动太快。错位最常见的原因是"章节归属"用了滚动结束时的状态而不是滚动过程中的状态——这个坑我印象极深,当初图省事,等滚到底再统一归类,结果所有划线的章节都变成了最后一章。
5.3 问题速查表
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
| 书架只抓到零星几本 | 虚拟列表回收 / 滚动未触发 | 改成边滚边收,用唯一键去重 |
| 章节只有一个 | 目录未展开 | 找展开入口,或检查容器高度 |
| 划线章节全指向最后一章 | 上下文在滚动结束后才判定 | 改为滚动过程中持续记录归属 |
| 想法内容串到了别的划线 | 解析时按索引对齐而非按 DOM 层级 | 改为在父节点内解析 |
| 某本书持续失败 | 单本数据异常 | 加入跳过名单,单独处理 |
| 每次运行都全量重跑 | 指纹含易变字段 | 剔除时间戳等字段 |
| 文件里出现大量空字段 | 把"没抓到"写成了空串 | 改为写 null,并记日志 |
| 中途崩溃后要重头开始 | 缺少状态文件 | 按最小单元落状态 |
6. 数据落地之后:让它真的被用起来
6.1 从"抓下来"到"能用起来"的二次整理
工具跑通只是起点。我最初导出的第一版 Markdown,自己在半年里翻了不到三次——因为它是按书分的,而我回忆时是按主题想的。这个错配让我意识到,归档结构应该跟着使用场景走,而不是跟着数据来源走。
后来我加了一层"主题聚合":在划线总表的基础上,用关键词把不同书的线条聚到一起。比如我在很多本书里都划过关于"注意力"的内容,聚合之后能一眼看到不同作者在同一个概念上的表述差异,这个视角是单本书内部永远看不到的。
具体做法不复杂:给每条划线保留书名、章节、原句三个字段,然后按关键词建立索引文件。不需要任何复杂工具,一个简单的脚本扫一遍所有划线,命中关键词就写进对应的主题文件里。关键词表我手动维护,二十来个词,足够覆盖我关心的领域。
6.2 格式选择的取舍:Markdown、JSON 与纯文本
我同时保留三种格式,各司其职:
- Markdown:给人读的。带层级、带引用、带元信息头,编辑器里直接能看。
- JSON Lines:给机器读的。一行一条,方便流式处理和脚本加工,加字段不改结构。
- 纯文本:给搜索用的。去掉所有标记符号,只留内容,
grep出来的结果最干净。
这个"一鱼三吃"的做法听起来冗余,但实际省下的时间是实打实的。写文章时用 Markdown,做统计时用 JSONL,找一句话时用纯文本。如果你时间有限只能做一种,我建议先做纯文本——它的下限最高,最不容易做成"看起来很酷但没人用"的东西。
6.3 定时同步与备份的最小可行方案
最后说说怎么把它变成日常。我的配置非常克制:
- 频率:一周一次,手动触发为主,不设复杂定时。
- 前置检查:登录态探测 + 磁盘空间检查。
- 执行策略:增量优先,只处理指纹变化的书。
- 产出校验:跑完后对比新旧划线条数,出现"总数减少"就报警而不是直接覆盖。
- 备份:输出目录做版本化,每次运行前先快照一份。
那个"总数减少就报警"的机制救过我一次。有次解析逻辑改错,导致每本书只抓到一条划线,总数从一千多掉到三百。如果直接覆盖,我半年的数据就没了。现在我的规则是:新结果少于旧结果的 90% 时,只写新目录,不动旧目录,等人工确认。
我个人在实际操作中的体会是,这类工具的价值不在"抓"这个动作本身,而在你愿不愿意为它设计一套"永不丢数据"的保险机制。抓取逻辑可以慢慢改,选择器失效了大不了重写,但一次错误的覆盖,可能就是几百小时的阅读痕迹归零。所以如果只让我保留一条设计原则,我会选:任何覆盖式写入之前,先留一份旧的。