← 返回博客
技术进阶2026-09-27·Digital Footprint Health Team

给 200MB 的 X 归档建一份本地搜索索引

X 归档本地索引全文检索隐私工程

X 数据归档解压后,推文正文集中在一个叫 tweets.js 的文件里。它的开头是一段赋值语句,后面跟着一个巨大的 JSON 数组。文件本身不加密,但也没有任何索引结构,而你要找的东西通常只是一个细节,比如某个门牌号、某个邮箱、某次出差住的酒店名。

浏览器端解析归档的做法在另一篇里写过,那篇解决的是"怎么在不装环境的情况下读完它"。这一篇换一个方向:把归档变成一个能反复查询的本地索引,一次建好,之后每次搜索都是毫秒级。

为什么直接读会爆内存

先看一组实测数字,这决定了脚本该怎么写。

对象典型大小说明
归档 ZIP150-400 MB包含多个 js 与媒体目录
tweets.js120-300 MBUTF-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 数字足迹

免费本机扫描,你的归档永不离开电脑。

免费开始体检

相关阅读

发布于 2026-09-27,最后更新于 2026-09-27。