首页 / 资讯中心 / 文章详情

微信读书划线本地归档:浏览器自动化与增量同步实践

微信读书划线本地归档:浏览器自动化与增量同步实践 ★ FEATURED ARTICLE
读书这件事我这几年的一个明显感受是纸质书留下来的只有划线和折角电子书留下来的只有一堆碎片化的想法。我在微信读书上积累了三百多本、上千条划线和想法这些内容躺在手机里检索靠翻整理靠截图换设备就断掉。所以我一直在找一条路把这些真正属于我自己的阅读数据拿回到本地变成可以检索、可以喂给自己笔记系统的一份结构化文件。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, headlessFalse, # 首次登录必须可见 args[--disable-blink-featuresAutomationControlled], ) 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_rounds30): 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% 时只写新目录不动旧目录等人工确认。我个人在实际操作中的体会是这类工具的价值不在抓这个动作本身而在你愿不愿意为它设计一套永不丢数据的保险机制。抓取逻辑可以慢慢改选择器失效了大不了重写但一次错误的覆盖可能就是几百小时的阅读痕迹归零。所以如果只让我保留一条设计原则我会选任何覆盖式写入之前先留一份旧的。
阅读完成 · 觉得有帮助?
咨询建站