☰
wereader阅读笔记归档:分层采集、清洗去重与SQLite增量同步
2026/11/3 6:51:06 网站建设 项目流程

1. 先搞清楚 wereader 这类工具到底该做什么

做阅读笔记整理这件事,我从最开始就没打算把它做成一个“抓全站数据”的东西。wereader 这个名字听起来像个采集工具,但我给它定的实际定位是:把散在某个读书 App 里的个人阅读笔记、划线、想法,搬到本地来,做成一份自己能长期持有、能检索、能二次加工的个人资料库。它解决的问题特别具体——你在一个读书 App 里读了三年书,划了几千条线,写了几百条想法,但这些内容只能待在 App 里,搜索靠它的搜索框,导出只能一小段一小段复制,换设备、换平台、想做一次年度回顾,全是体力活。

所以这篇东西适合谁看?我把读者分成三类。第一类是重度读者,笔记攒了几千条,迫切想让这些内容变成可复用的资产;第二类是想学数据处理管线的人,采集、清洗、存储、展示这一条链路的每个环节其实都是通用技能,用一个自己天天用的场景来练手,比做十个玩具 Demo 强得多;第三类是做个人知识管理的人,想知道“书摘进笔记库”这条流水线怎么搭才不别扭。

有一条边界必须先说清楚,后面所有内容都在这个边界内展开。这个工具处理的是你自己账号下、你自己可见的内容,也就是你的划线、你的想法、你的书架进度这类个人数据。它不碰书籍正文,不碰付费章节,不做绕过任何访问限制的事,也不会把别人的数据拿去分发。这不是为了合规而合规,从工程角度看这也是最省事的选择——书籍正文的体量、格式、版权复杂度都远超个人笔记,而个人笔记的价值密度恰恰最高。我做这个工具的核心诉求是“归档”,不是“搬运”。

提示:任何涉及第三方平台数据的行为,都要先看平台自身的使用条款。个人本地归档、低频、非商业、不分发,是相对稳妥的姿势;一旦涉及批量抓取、二次发布、对外提供服务,性质就完全变了。这条线在心里要划死。

1.1 需求拆解:三类数据,三种处理难度

把需求摊开看,个人阅读数据其实分三层,难度递增。

最外层是书目元信息:书名、作者、封面、出版社、总字数、阅读进度。这一层结构最规整,变化最少,是整套数据的骨架。有了它,后面的笔记才能挂上去形成“一本书一条记录”的完整视图。

中间层是划线和高亮:某本书第几章、哪句话被划了、什么时候划的。这一层信息量大、重复率高(同一句话可能被反复划),需要去重;但字段结构还算清晰。

最内层是个人想法和评论:你在某条划线后面写的感想,短的可能就俩字“确实”,长的可能写了几百字。这一层价值最高,也最没规律——没有固定格式,时间戳有时缺失,和划线的关联关系需要靠位置或顺序去推。我当初的教训就是:先做了元信息和划线,觉得挺顺,结果一碰想法就发现原来那套表结构塞不进去,又回头重构了一遍。

1.2 为什么不直接手工导出:算一笔时间账

有人会问,几百条笔记,手工复制粘贴不行吗?可以,但要算账。假设你有 3000 条划线,每条从选中、复制、切窗口、粘贴、加分隔符平均 8 秒,总计 24000 秒,折合 6.7 小时,还得全程不走神、不粘错位置。而写一个处理脚本,第一次投入大概 2 到 3 小时,之后每次更新增量数据只需几十秒。更重要的是,手工操作没法保证一致性——漏一条、顺序错乱、全角半角混用,事后根本查不出来。数据这种东西,一次做对,后面都是复利。

我在实际使用中发现,真正的痛点在“增量”而不是“全量”。第一次全量导入虽然麻烦,但咬咬牙就过去了;麻烦的是每周新增的那十几条,如果每次都要手工搬,人一定会放弃。所以这套工具从第一天起就要按“增量同步”来设计,而不是“一次性导入”。

2. 整体架构与技术选型:三层拆开,各干各的

2.1 采集层、清洗层、展示层,三层不要混着写

我踩过的最典型的坑,就是把采集和清洗写在一个函数里。表面上省事,实际上后患无穷:采集一旦失败,清洗逻辑也跟着崩;清洗规则一改,就得重跑采集,白白多打一遍请求。后来拆成三层,问题就没了。

采集层只干一件事:拿到原始数据,原样落盘存成 JSON,不做任何解析。文件名带时间戳,比如raw_20240115_0930.json。这一层的原则是“宁脏勿丢”,哪怕字段长得再奇怪也先存下来,因为原始数据不可再生,一旦丢了就得重新采集,成本远高于多存几百 KB。

清洗层是纯函数式的,输入原始 JSON,输出结构化记录,不发起任何网络请求。这一层可以无限次重跑,改规则、调正则、加字段,随便折腾都不影响采集结果。

展示层只读数据库,不关心数据从哪来。想换前端、想导到别的笔记软件、想生成统计图,都是在这一层动,采集和清洗完全不受影响。

这个分层看起来是常识,但我观察过不少同类的小工具,八成以上都是“一把梭”写法,最后都卡在“想改个字段要重跑全流程”上。

2.2 技术栈选型:够用就行,别上重型武器

选型上我坚持一个原则:这个工具的使用频率决定了它的技术上限。一个每周用一次、跑几十秒就结束的本地工具,上分布式框架、上消息队列、上容器编排,纯属自找麻烦。我用的是最朴素的一套:

环节选型理由
语言Python 3.10+文本处理生态最厚,正则、编码、日期处理都有现成的
网络请求requests + 自写重试比引入重型框架轻,重试逻辑自己写反而更可控
数据存储SQLite 单文件零部署,一个文件就是全部数据,备份直接拷贝
清洗re + 少量 pandas逐条解析用 re 就够,做批量统计时再上 pandas
展示静态 HTML + 少量 JS生成即用,不依赖任何服务端
配置dotenv + yaml凭证走环境变量,业务参数走 yaml,两者分开

为什么存储用 SQLite 而不是直接存 JSON?因为一旦你要做“按月份统计划线数量”“找出划线密度最高的十本书”这类查询,JSON 就得自己写过滤逻辑,而 SQL 一条语句搞定。SQLite 单文件的好处是备份极其简单——把文件拷走就是完整备份,没有“忘了导哪张表”的风险。

2.3 数据模型设计:四张表撑起全部场景

表结构是最容易返工的地方,我改了三次才定下来。最终是四张表:

  • books:一本书一条。主键用平台内的书籍标识,如果拿不到就用“书名 + 作者”算一个哈希当主键。
  • highlights:一条划线一条。外键指向books,同时保留章节信息、位置信息、创建时间。
  • thoughts:一条想法一条。外键指向所属划线(能关联的话),否则挂到书下,标记为“游离想法”。
  • sync_log:每次同步一条。记录开始时间、结束时间、新增条数、失败条数、失败原因摘要。

单独建sync_log这个事,我一开始觉得多余,后来发现它是最有用的一张表。当你发现“这周怎么一条新笔记都没同步进来”,打开这张表一看就知道是没新数据,还是请求失败了被静默吞掉。日志表就是工具的眼睛,省掉它等于蒙眼开车。

关联关系上有一个坑要提醒:划线 ID 不一定存在,有些数据源只给位置偏移量,不给唯一标识。这种情况下不能指望“按 ID 关联”,得用“书 ID + 章节 + 文本内容哈希”三者组合来定位。我当初就是假设有 ID,结果关联率只有 60%,剩下的想法全成了孤儿数据,只能手工补,非常痛苦。

2.4 为什么不做“实时同步”:节奏感比实时性重要

技术上完全可以做成定时任务,每小时跑一次。但我实测下来,改成“手动触发 + 一周一次”反而更好用。理由有三:一是阅读行为本身是低频的,你不可能一小时读完一本书;二是低频调用对所有相关方都更友好,无论是平台的服务压力还是你自己的账号状态;三是手动触发时你会看一眼结果,出问题当场就知道,而定时任务失败了往往一周后才发现。

工具设计上有一个反直觉的规律:自动化的程度越高,出问题时的排查成本越高。一个每周你亲手按下回车、亲眼看到“新增 12 条”的工具,比一个默默运行、某天你才发现数据断了三个月的工具可靠得多。

3. 采集环节的实操要点:把“稳”放在第一位

3.1 数据从哪来:优先顺序要摆对

采集路径我按优先级排了三档,能用前面就不用后面。

第一档是官方给的导出能力。有些平台自带笔记导出或分享成图片、文本的功能,能用就直接用,这是最省事也最没争议的路径。虽然格式可能不完美,需要写点解析,但省去了所有网络层的麻烦。

第二档是本地剪贴板配合人工操作。听着原始,但极其可靠。你正常浏览,选中、复制,后台有个小脚本监听剪贴板变化,自动追加到本地文件并加上时间戳。实际用起来比想象中顺——你本来就要看笔记,顺手复制一下而已,而且因为是人工触发,节奏天然低频。

第三档才是程序化访问。这一档我只在“数据量已经大到人工不可能处理”的情况下才考虑,而且要严格约束:只读个人可见内容、只取必要字段、频率压到最低、失败就停不重试到底。

这三档的差别不在于技术难度,而在于心智负担。第一档和第二档你不用操心任何技术细节,第三档你要考虑会话、编码、限流、异常,每一样都可能出问题。我见过太多人一上来就冲第三档,结果在技术细节里泡了两周,笔记一条没整理出来。先用笨办法把数据搞到手,跑通全链路,再回头优化采集,这个顺序才对。

3.2 凭证管理:绝不能出现在代码里

凭证处理上有一条铁律:任何密钥、令牌、会话信息,都不允许出现在源码和版本库里。我见过不止一个项目把凭证硬编码在脚本第 12 行,然后推到了公开仓库,等到发现时已经过了好几天。

我的做法是:本地建一个.env文件,里面只放键值对;代码里用os.environ.get()读取;.gitignore第一行就是.env。同时在代码里加一个启动校验——如果环境变量缺失,直接抛错退出,而不是带着空值跑到一半才崩,那样排查起来更费劲。

import os import sys def load_config(): required = ["SESSION_TOKEN", "USER_AGENT"] missing = [k for k in required if not os.environ.get(k)] if missing: print(f"缺少必要环境变量: {', '.join(missing)},请检查 .env 文件") sys.exit(1) return {k: os.environ[k] for k in required}

这段代码没什么技术含量,但那个“缺什么就明确指出缺什么”的报错信息能省掉大量时间。错误提示的价值被严重低估了——一个说“缺少 SESSION_TOKEN”的报错,和一个说“KeyError: NoneType”的报错,排查时间差十倍。

3.3 请求节奏:把间隔算出来,别拍脑袋

请求间隔不能靠感觉,要给一个能自洽的算法。我的算法是这样的:假设目标是一周同步一次,每次需要请求 N 个页面,我希望整个过程在 3 分钟内跑完,同时保证任意两次请求之间至少间隔 T 秒。

先估 N。以个人数据量为例,一本书记录大概 2 到 4 个页面,300 本书大约 900 个页面。如果 T 取 2 秒,总耗时 900 × 2 = 1800 秒,也就是 30 分钟,太久了。这说明一个现实:逐页请求在大数据量下不可行,必须靠分页大小来压缩请求数。把每页条数从 20 提到 50,页面数降到 360,T 取 0.5 秒,总耗时约 3 分钟,符合预期。

参数就是这么一个“目标时长 ÷ 请求数 = 允许间隔”的倒推过程,而不是“我觉得 1 秒挺好”。倒推还有一个好处:数据量增长时,你能立刻意识到要去调分页大小,而不是无脑加长间隔。

重试策略我用的是指数退避加封顶:

import time import requests def fetch_with_retry(url, params, headers, max_retry=3, base=1.5): for attempt in range(max_retry): try: resp = requests.get(url, params=params, headers=headers, timeout=10) if resp.status_code == 200: return resp.json() # 非 200 不做无限重试,直接暴露 print(f"状态码 {resp.status_code},第 {attempt + 1} 次尝试") except requests.RequestException as e: print(f"请求异常: {e},第 {attempt + 1} 次尝试") sleep_time = base ** attempt time.sleep(min(sleep_time, 8)) return None

关键点在于max_retry=3和封顶 8 秒。不设上限的重试在遇到持续性失败时会把整个流程卡死,而带上限、失败就返回None让上层记录日志,反而是更负责的做法。

注意:不要把失败当成“再试几次就好了”。连续失败通常意味着某个前提条件变了(会话过期、参数格式变了),继续重试只是在浪费时间和请求额度。正确做法是停下、看日志、人工确认。

3.4 断点续传:小工具也必须有的能力

一个 300 本书的同步任务,跑到第 200 本时网络断了,如果没有断点续传,你只能从头再来。加这个能力其实成本很低:在sync_log里记录“已完成的最后一个书籍 ID”,下次启动时先查一下,从这个 ID 之后开始。

def get_resume_point(conn): row = conn.execute( "SELECT last_book_id FROM sync_log WHERE status='partial' " "ORDER BY id DESC LIMIT 1" ).fetchone() return row[0] if row else None

我甚至建议把这个能力做到更细:每完成一本书就提交一次事务。SQLite 的写入很快,一本书的提交开销可以忽略,但换来的是“任何时刻中断,已完成的都不会丢”。这个习惯在我后来的好几个项目里都救过命。

4. 数据清洗与结构化:把散沙变成数据库

4.1 字段归一:全角半角、时间格式、空白字符

清洗层的第一件事永远是字段归一化,这一步没什么技术含量,但漏掉任何一项都会让后面的统计结果错得莫名其妙。

文本归一要处理的至少有四类问题:全角标点转半角(否则搜索“测试”搜不到“测试,”)、连续空白压缩成一个空格、首尾空白裁剪、不可见字符(零宽空格、BOM 头)清除。零宽字符是最恶心的,肉眼完全看不出来,但会导致两条本该相同的文本哈希不一致,去重直接失效。

时间归一要处理两种格式:带时区和不带时区。统一转成 UTC 时间戳存整数,展示时再转回本地时区。存整数而不是存字符串的理由是排序和范围查询都更快,而且不会有“2024-1-5”和“2024-01-05”这种同义不同形的麻烦。

编码归一:中文内容里混进 Latin-1 乱码是常事,尤其在从剪贴板采集的场景下。我的做法是清洗阶段统一encode('utf-8', errors='replace')兜底,然后统计一下替换字符的数量,如果某批次数量异常,就说明上游编码出了问题,需要单独查。

4.2 去重:内容指纹 + 位置信息双因子

去重是这一类工具的核心难点。同一句话可能被划了三次,可能在不同设备上产生重复记录,也可能因为重新阅读而位置略有偏移。只靠内容去重会误删(两本书里都有“第一章”这种短文本),只靠 ID 去重又会漏(没有 ID 的场景)。

我最终用的是双因子指纹:md5(书ID + 章节 + 归一化后的文本)。三个因素缺一不可——书 ID 区分不同书,章节区分同一本书里的不同位置,文本内容区分同一章节里的不同划线。三个合起来碰撞概率极低。

import hashlib def make_fingerprint(book_id, chapter, text): normalized = "".join(text.split()) # 去掉所有空白 raw = f"{book_id}|{chapter}|{normalized}" return hashlib.md5(raw.encode("utf-8")).hexdigest()

注意"".join(text.split())这一步,它把所有的空格、换行、制表符都干掉了。为什么要做到这么狠?因为划线的文本边界常常包含前后空格,如果不清理干净,同一个句子会因为一个多余空格产生两个不同指纹。我在实际使用中发现,这一行代码让去重准确率从大概 85% 提到了接近 100%。

4.3 存储落地:建表语句与索引取舍

表结构定下来之后,建表语句本身不复杂,但索引的选择值得说一说。索引不是越多越好,每个索引都会拖慢写入速度,而同步场景恰恰是写多读少。

CREATE TABLE IF NOT EXISTS books ( book_id TEXT PRIMARY KEY, title TEXT NOT NULL, author TEXT, publisher TEXT, total_words INTEGER, progress REAL, updated_at INTEGER ); CREATE TABLE IF NOT EXISTS highlights ( fp TEXT PRIMARY KEY, book_id TEXT NOT NULL, chapter TEXT, content TEXT NOT NULL, position INTEGER, created_at INTEGER, FOREIGN KEY (book_id) REFERENCES books(book_id) ); CREATE INDEX IF NOT EXISTS idx_highlights_book ON highlights(book_id); CREATE INDEX IF NOT EXISTS idx_highlights_time ON highlights(created_at); CREATE TABLE IF NOT EXISTS sync_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, started_at INTEGER, finished_at INTEGER, new_count INTEGER DEFAULT 0, fail_count INTEGER DEFAULT 0, status TEXT, note TEXT );

我建了两个索引:book_id和created_at。前者对应“看某本书的所有划线”这个最高频查询,后者对应“按时间线回顾”和“月度统计”。chapter字段没有单独建索引,因为它几乎总是和book_id一起用,属于联合查询的第二列,单独建索引收益不大。

写入用INSERT OR IGNORE,配合主键指纹天然去重,比先查再插少一次往返:

conn.executemany( "INSERT OR IGNORE INTO highlights (fp, book_id, chapter, content, position, created_at) " "VALUES (?, ?, ?, ?, ?, ?)", rows ) conn.commit()

OR IGNORE在这里的语义正好是我要的:已经存在就跳过,不报错也不覆盖。如果需要更新已有记录(比如划线后来被编辑过),就换成ON CONFLICT(fp) DO UPDATE SET ...,但这会引入“到底以哪次为准”的问题,我选择简单点:不覆盖,冲突就跳过。

4.4 数据质量校验:写个小脚本给自己兜底

清洗完之后一定要做质量校验,否则脏数据会一路流到展示层,最后你看着一张漂亮的统计图,其实底下少了一半数据。

我固定跑这五项检查:总条数与原始条数的比值(正常应该在 0.7 到 1.0 之间,太低说明解析漏了)、时间戳为空的比例(应该低于 5%)、文本长度为 0 的记录数(应该为 0)、没有归属书籍的孤记录数(应该为 0)、最近一次同步新增条数的趋势。这五项里任何一项异常,都说明上游有变化,需要立刻去看。

这个校验脚本只有几十行,但它是整个工具里我跑得最频繁的部分。因为它跑得快、看得懂、给结论,比打开数据库客户端自己瞎翻高效太多。

5. 展示与二次利用:让数据真正被用起来

5.1 静态页面生成:够用、够快、够简单

展示层我选的是生成静态 HTML,而不是起一个 Web 服务。理由很实在:静态文件可以用任何浏览器打开、可以扔进任何云盘同步、十年后还能打开,而一个依赖某个框架版本的服务,两年后可能就跑不起来了。数据工具的生命周期很长,技术栈的生命周期很短,这个错配要在一开始就避开。

页面结构我做了三个:首页是书目列表,显示每本书的标题、作者、划线数、想法数、最近更新时间,按最近更新时间倒序;点进一本书是详情页,划线按章节分组,想法跟在对应的划线下面;第三个是统计页,放月度分布和书目维度的对比。

生成逻辑就是模板渲染,我用的是最原始的字符串模板,没有引入任何前端框架:

def render_book_page(book, highlights): items = [] for h in highlights: items.append( f'<div class="hl"><p class="text">{escape(h["content"])}</p>' f'<p class="meta">{h["chapter"]} · {fmt_time(h["created_at"])}</p></div>' ) return TEMPLATE.replace("{{TITLE}}", escape(book["title"])) \ .replace("{{ITEMS}}", "\n".join(items))

escape这一步千万别省。你划线的文本里可能有<、>、&,不转义直接拼进 HTML,轻则页面错乱,重则脚本注入。本地工具也要有这个意识,好习惯是通用的。

5.2 统计维度:四个指标就足够看出阅读习惯

统计这块容易陷入“指标越多越好”的陷阱,做了一堆图表,其实没人看。我最后只留了四个指标,每个都能改变我对自己的认知。

第一个是月度划线数曲线。这个最能反映阅读强度的变化,我发现自己的峰值出现在每年三月和九月,低谷在七月,和实际工作节奏完全对得上。

第二个是划线密度(每万字划线数)。这个指标比“划了多少条”更有信息量。有的书十万字我划了两百条,有的书三十万字我只划了五条,密度差十倍,前者是我真正在跟作者对话的书,后者是快速翻过的书。

第三个是想法与划线的比值。纯划线是“这句话好”,加了想法才是“这句话为什么好”。这个比值低于 0.1 的时候,说明我在吞书而不是读书。

第四个是复读标记。同一本书被划了两次以上、且两次时间间隔超过半年的,标记出来。这类书往往是真的影响过你的书,值得单独列一个“常读常新”清单。

这四个指标算起来都是几条 SQL 的事:

SELECT strftime('%Y-%m', created_at, 'unixepoch') AS ym, COUNT(*) AS cnt FROM highlights GROUP BY ym ORDER BY ym;

5.3 导出成 Markdown:接进自己的笔记库

数据躺在自己数据库里只是第一步,真正让它产生价值的是接进日常使用的笔记系统。我做的导出格式是这样的:

## 《书名》— 作者 > 划线内容第一句 > 划线内容第二句 - 2024-01-15 章节名 我的想法写在这里 --- ## 《下一本书》

一本书一个 Markdown 文件,文件名用“书名 - 作者.md”,方便按字母排序。之所以用>引用块而不是加粗,是因为大多数 Markdown 编辑器里引用块的样式更柔和,长列表看下来不刺眼。

导出的时候有个细节值得注意:文件已存在时的处理策略。我一开始是直接覆盖,结果有一次手工在导出的文件里补了几条注解,重新导出全没了。后来改成“生成到export/目录,文件名带时间戳”,旧文件保留,需要对比时用 diff。多占点磁盘换来不丢东西,这个交易很划算。

5.4 一个可以直接抄的配置结构

配置文件我分成两个:.env放凭证,config.yaml放业务参数。后者长得像这样:

sync: page_size: 50 interval_seconds: 0.5 max_retry: 3 timeout: 10 resume: true clean: fingerprint_fields: ["book_id", "chapter", "content"] strip_whitespace: true normalize_punct: true timezone: "Asia/Shanghai" export: output_dir: "./export" filename_pattern: "{title} - {author}.md" keep_history: true group_by: "book"

把参数外置的好处是,调整节奏、改导出格式、切换分组方式,全都不用碰代码。我从“参数写死在代码里”改到“参数外置”之后,改配置的频率远高于改代码,这个改动省下的时间比预想的多得多。

6. 常见问题与排查实录

6.1 高频问题速查表

现象最可能原因排查动作处理方式
同步成功但条数为 0增量游标没更新,或分页参数失效查sync_log里last_book_id是否变化手动重置游标,全量跑一次对比总数
中文全部变乱码响应编码声明与实际不符打印resp.encoding和resp.apparent_encoding强制指定 utf-8 解码
去重后条数少了一大半指纹字段里含不稳定数据(如时间戳)抽样两条本该不同的记录算指纹把时间戳移出指纹计算
想法挂不到对应划线上依赖了不存在的划线 ID统计关联成功率改用“书 ID + 章节 + 文本哈希”定位
跑一半卡住不动重试没有上限,或 timeout 太大看进程状态和日志最后一行加 max_retry 和 timeout
页面显示正常但搜索不到全角半角未归一分别搜全角和半角版本清洗阶段统一转半角
时间显示差 8 小时存了本地时间当 UTC对比数据库值和页面值统一存 UTC 时间戳,展示时转换

6.2 三个踩过的坑,每个都疼

第一个坑:拿“看起来像 ID”的字段当主键。有个字段值是一串数字,我理所当然地当成唯一标识用了。跑了两个月才发现,这个数字在部分场景下会复用,导致新记录覆盖了老记录,丢了大概两百条划线。教训是:任何字段用来当主键之前,先跑一个COUNT(*)和COUNT(DISTINCT field)对比一下,两个数不相等就说明它不唯一。

SELECT COUNT(*) AS total, COUNT(DISTINCT some_id) AS uniq FROM highlights;

这两行 SQL 只花三秒钟,能省掉两个月的返工。

第二个坑:增量同步的游标用了“时间”而不是“ID”。用时间做游标看起来合理——只取上次同步之后的新记录。但问题是,如果一条记录创建时间早、入库时间晚(这种情况在各类系统里都常见),它永远进不来。改成用单调递增的 ID 做游标之后,这个问题彻底消失。排序字段和游标字段最好是同一个,且必须单调递增。

第三个坑:以为 JSON 里字段顺序是固定的。早期我写解析逻辑时按位置取字段,跑得好好的。后来上游调整了字段顺序,我的解析全错位,直接把作者名填进了书名里。这个错误极其隐蔽,因为程序不报错,只是数据悄悄错了。从那以后我一律按字段名取,任何按位置取的地方都改成显式键名:

# 不要这样 title = item[0] # 要这样 title = item.get("title", "").strip()

.get()带默认值这个写法还有额外好处:字段缺失时不会抛异常,而是给空字符串,后面的质量校验会把空值统计出来提醒我。

6.3 几条不在文档里的实操心得

心得一:先存原始数据,再谈解析。我现在的每个采集脚本第一行的逻辑都是落盘原始 JSON,解析永远是第二步。这个习惯来自一次惨痛经历——解析规则写错了,但原始数据没留,只能重新跑一遍采集,而那次采集花了四十分钟。四十分钟不算长,但如果数据源已经变了呢?原始数据是唯一不可再生的东西,优先级必须最高。

心得二:日志里要记“为什么没有数据”,而不只是“拿到多少数据”。早期的日志只记条数,0 条和失败都显示 0,分不清。后来改成明确记三种状态:成功拿到 N 条、成功但确实没有新数据、失败并附原因。这三种状态对应完全不同的处理动作,混在一起就没法自动化判断了。

心得三:小批量试跑比全量试跑重要得多。每次改完完善逻辑,我都是先拿 5 本书跑一遍,检查输出、看字段、抽查三条记录的内容是否对得上。这个“5 本试跑”的习惯,让我避免了很多次“跑完全量才发现字段全错”的惨剧。全量跑的代价不只是时间,还有一旦出错就要重新来的心理成本。

心得四:定期做一次“恢复演练”。数据工具最怕的不是跑不起来,而是你以为备份了其实没有。我每个月会做一次演练:拿备份文件在一个空目录里恢复,然后随机抽三条记录核对内容。这个动作只要五分钟,但能保证真出事的时候备份是能用的。我见过太多“备份文件存在但打不开”的情况。

注意:不要把工具做成“只有自己能跑起来”的状态。配置文件外置、凭证走环境变量、依赖版本锁在requirements.txt里、启动前自检环境,这几件事加起来能让工具在换电脑、隔半年之后再跑的时候依然顺畅。工具的可用性不只取决于功能,更取决于它能不能在时间冲刷后还活得下来。

7. 关于这套东西的扩展方向

这套结构搭好之后,扩展成本会很低,因为分层清楚。我自己试过几个方向,都挺顺手。

一个是标签体系。在highlights表上加个tags字段,存逗号分隔的标签,然后写个简单的规则引擎,按关键词自动打标。比如命中“习惯”“复利”“决策”这类词的打上“认知”标签,命中“代码”“架构”“重构”的打上“工程”标签。自动打标准确率不高,但能起个初筛作用,人工修正比从零开始标快得多。

另一个是跨书检索。数据进了 SQLite,全文搜索其实很容易——SQLite 自带的 FTS5 扩展就能做,建个虚拟表把content挂上去,然后按关键词查,能一秒内返回所有相关划线并带上书名。这个功能我用得最多,因为它把“我记得在哪本书里看过一句话”这种模糊记忆变成了精确查询。

还有一个是时间线回顾。查“三年前的今天我在读什么”,这个查询本身就是一条 SQL,配上当年那天的划线内容,做出来的回顾比任何年度总结都有意思。我用它发现自己有几本书每年都会翻一遍,而有些书读完就再没碰过——这个对比挺能说明问题的。

最后一个方向是多端同步。因为数据是单文件 SQLite,扔进任意同步盘就是一个最朴素的同步方案。要注意的是并发写入冲突,所以我加了一个规则:同一时间只允许一个设备执行写入,其他设备只读。这个约束靠一个锁文件实现,简单但够用。

说到底,wereader 这类工具的价值不在于技术有多复杂,而在于它把“读过的书”这件事从平台里解放出来,变成一份你能带着走、能继续加工、能十年后还打开看的个人资产。技术部分大概只占三成,剩下七成是想清楚要什么、边界在哪、什么该自动化什么该手工。我个人在实际操作中的体会是,凡是涉及数据归档的工具,宁可做得笨一点、慢一点、多留一份原始数据,也别追求一次到位的漂亮方案——因为数据一旦丢了,再漂亮的技术也补不回来。

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

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

立即咨询