给 200MB 的 X 归档建一份本地搜索索引
X 数据归档解压后,推文正文集中在一个叫 tweets.js 的文件里。它的开头是一段赋值语句,后面跟着一个巨大的 JSON 数组。文件本身不加密,但也没有任何索引结构,而你要找的东西通常只是一个细节,比如某个门牌号、某个邮箱、某次出差住的酒店名。
浏览器端解析归档的做法在另一篇里写过,那篇解决的是"怎么在不装环境的情况下读完它"。这一篇换一个方向:把归档变成一个能反复查询的本地索引,一次建好,之后每次搜索都是毫秒级。
为什么直接读会爆内存
先看一组实测数字,这决定了脚本该怎么写。
| 对象 | 典型大小 | 说明 |
|---|---|---|
| 归档 ZIP | 150-400 MB | 包含多个 js 与媒体目录 |
| tweets.js | 120-300 MB | UTF-8,单行 JSON |
| 解析后的对象数组 | 原文件 3-5 倍 | V8 里每个对象的内存开销远大于文本 |
| 索引文件(去停用词) | 15-40 MB | 取决于推文数量和分词粒度 |
问题的根源是第三步。把两百多兆的 JSON 一次性 parse 进来,加上字符串去重后的驻留,很容易撞上 Node 默认的堆上限,表现是一句含糊的 "JavaScript heap out of memory"。加大堆上限能跑通,但没必要。索引只需要文本,不需要保留对象结构。
分流式读取
做法是先剥掉文件开头的赋值前缀,再把主体按顶层数组元素切开,逐条处理,处理完即释放。整个流程内存占用与推文数量无关,只与单条推文大小有关。
import fs from 'node:fs';
import readline from 'node:readline';
const src = process.argv[2];
const out = fs.createWriteStream('index.ndjson');
const rl = readline.createInterface({
input: fs.createReadStream(src, { encoding: 'utf8' }),
crlfDelay: Infinity,
});
let buf = '';
let count = 0;
rl.on('line', (line) => {
buf += line;
// 逐条切出顶层对象,不整体 parse
for (;;) {
const end = findObjectEnd(buf, 0);
if (end < 0) break;
const chunk = buf.slice(0, end + 1);
buf = buf.slice(end + 1);
try {
const obj = JSON.parse(chunk);
const t = obj.tweet || obj;
out.write(JSON.stringify({
id: t.id_str,
d: t.created_at,
s: (t.full_text || t.text || '').replace(/\s+/g, ' '),
}) + '\n');
count++;
} catch (e) { /* 单条失败不影响整体 */ }
}
});
rl.on('close', () => {
out.end();
console.log('indexed', count);
});
配套的 findObjectEnd 只需要做一件事:从给定位置开始扫描,维护一个花括号深度计数,遇到深度归零就返回下标。字符串内部的括号必须跳过,否则带引号的文本会让计数错位。
索引文件长什么样
输出是行为单位的 ndjson,每行一条推文,只保留三个字段:标识、时间、正文。行式存储的好处是可以直接用流式过滤器搜索,不需要把整个索引读进内存。
- 标识用于回查原始条目,也用于去重。
- 时间保留原始格式,需要排序时再转。
- 正文压平所有空白字符,保留标点,便于正则匹配。
如果要做真正的全文检索而不只是关键词匹配,可以把 ndjson 转成 SQLite 的 FTS5 表,一行命令导入即可,搜索响应比正则扫描快一个量级。归档里其他文件的结构差异在CSV 与 JSON 两种导出的区别里有对照。
搜索阶段
建好索引后,找东西的写法很简单:逐行读 ndjson,对正文跑你关心的模式。列出几个实际好用的模式。
- 手机号:匹配连续十一位数字,注意推文里常有空格和短横线分隔,匹配前先去掉这些字符。
- 邮箱:常规的本地部分加域名结构,注意 @ 前后可能有空格。
- 地址片段:匹配"街""路""号""栋"这类后缀词,再人工核对上下文。
- 时间窗口:先用日期过滤出区间,再在区间内跑上面的模式,避免全量扫描。
把这些模式固化成一个小脚本,就能在每次归档更新后重跑一遍,只输出新增命中。这种做法配合本地删除脚本的批处理逻辑,可以做到"扫出来的条目直接交给删除流程",中间不经过任何第三方服务。
索引文件本身也是敏感数据
索引去掉了对象结构,但保留了完整正文,敏感程度和原始归档没有区别。放对位置比建得快更重要:不要放进任何同步目录,不要留在临时文件夹。如果要长期保存,用本地密钥管理里描述的方式加密,密钥和索引分开存放。做完清理后,索引应该和归档一起重新生成或直接销毁。
关于 digital-footprint-health.shop
自己搭索引适合想完全掌控流程的人,不想写脚本的话有现成路径。digital-footprint-health.shop 把整个解析和一键扫描做成了产品:归档拖进首页的入口,本机完成解析与风险识别,输出 0-100 评分和清单,不上传任何内容。体检免费且只读,清理范围和费用在定价页,工程类文章收在博客索引。
常见问题
19 万条推文的索引要建多久?
在一台普通笔记本上,纯文本解析加写出的耗时通常在 20 到 40 秒之间,瓶颈是磁盘读,不在 CPU。真正的耗时差异来自分词:如果只做精确字符串匹配,建索引这一步可以跳过,直接每行读一遍即可,单次搜索在几秒内完成。
归档更新后要整个重建索引吗?
不必。新归档的推文标识是递增的,拿新索引里最大的一条标识和旧索引比对,只处理比它新的部分,再追加到 ndjson 末尾即可。这种增量方式也让"上次扫到哪里"变成一个可以记录的状态,搜索时可以只看增量片段确认有没有新命中。
删除推文后归档里的记录会消失吗?
不会。归档是导出那一刻的快照,之后你删除的内容仍然留在快照里。这也是本地索引存在的意义之一:它可以作为"删除前"的对照,用来看哪些内容其实早就删过但归档里还有。索引和线上状态之间的一致性说明在归档格式对照那篇里。
检查你自己的 X/Twitter 数字足迹
免费本机扫描,你的归档永不离开电脑。
免费开始体检相关阅读
X 接口限流为什么让批量删除变慢:配额机制与排队策略
批量删帖慢,慢的不是网络也不是工具,而是写操作配额的窗口计算方式。理解窗口长度、端点配额和 429 的含义之后,就能把删除从「一次性猛冲」改成「可控排队」,中断也不用从头再来。
自己写一个本机推文删除脚本:从归档解析到可续跑批处理
托管删除服务要交出账号权限,官方界面又处理不了几万条。自己写一个本机脚本是第三条路:归档解析、权限范围、批次限流、状态文件四块拼起来,删到一半掉线也能接着跑。附完整的模块划分与失败处理清单。
在浏览器里解析 200MB 的 X 归档:流式读取、内存与线程安排
不装 Node、不把归档传上服务器,只靠浏览器能不能解析一份 200MB 的 X 归档?可以,但要绕开三个硬限制:主线程阻塞、内存峰值和文件读取方式。本文讲清每个限制的成因和对应的工程做法。