/** * uidset.js — 去重集合与结果文件 * * ## 三种"已经见过" * 1. **本轮已收**:这次运行里已经写进产物文件的; * 2. **历史已收**:之前各轮跑过的(`uid_extracted.txt`,只追加不删除); * 3. **输入里已有**:文本取材那份文件本身就列着的("裂变"时,"输入里的人"不该再算新收获)。 * 三者合成一个集合,`add` 之后就不再重复。区别只在**装载来源**,判定逻辑是同一个。 * * ## 为什么落盘是"追加"而不是"最后一次性写" * 用户点了停止、或脚本被系统杀掉时,已经抓到的部分不能全丢。 * 所以每收 N 个就 append 一次 —— 代价是几条多余的系统调用,收益是"停在哪都留着"。 * * ## 为什么不用 files 的删除接口 * 引擎产物要过 MCP 的危险调用黑名单(`build.js` 的 `DANGEROUS_RULES` 里有一条按 * 词边界匹配 `remove` 加左括号),命中后每次下发都要求人工确认。 * 所以这里**只追加不删除** —— 需要"重来"时由用户自己在文件管理器里删。 * * 依赖:TikTokAuto.logger(调用时取) */ TikTokAuto.uidset = (function () { 'use strict'; /** 每收这么多个就 append 一次。20 是"丢掉的最多几个"与"写盘频率"的折中。 */ var FLUSH_EVERY = 20; /** * 去重集合。 * * 内部用普通对象而不是 Set:Rhino 对 ES6 Set 的支持在各版本不一致, * 而对象字面量的键查找在所有版本上都是稳的。uid 只会是字母数字下划线点,不会撞 `__proto__`。 */ function create() { var seen = {}; var count = 0; return { /** 见过没有 */ has: function (uid) { return seen[uid] === true; }, /** 记一笔;返回 true 表示这是新加的(之前没见过) */ add: function (uid) { if (seen[uid] === true) { return false; } seen[uid] = true; count += 1; return true; }, /** 已记多少(含历史装载的) */ size: function () { return count; } }; } /** * 读一批文本行,返回去重后的 uid 数组。 * 跳过空行、`#` 注释行、以及带 @ 的行(有些用户会直接粘贴 "@name",我们容忍这种输入)。 */ function readUidFile(path) { var out = []; try { if (!path || !files.exists(path)) { return out; } var lines = String(files.read(path)).split(/\r?\n/); for (var i = 0; i < lines.length; i += 1) { var uid = TikTokAuto.parse.username(lines[i]); if (uid !== '' && uid.charAt(0) !== '#') { out.push(uid); } } } catch (error) { TikTokAuto.logger.warn('uidset', '读取 ' + path + ' 失败:' + error); } return out; } /** * 从历史文件装载去重集合。 * 文件不存在 = 第一次跑,不是错误。 */ function loadHistory(set, path) { var list = readUidFile(path); for (var i = 0; i < list.length; i += 1) { set.add(list[i]); } TikTokAuto.logger.info('uidset', '历史去重装载 ' + list.length + ' 条(' + path + ')'); return list.length; } /** * 结果写入器。 * * 两个文件同时写: * · `本次`:这一轮新收的(新建,给人看/给下游吃); * · `累计`:所有轮次的(追加,跨轮去重的基准)。 * * @param options { runPath, historyPath, dryRun, historyEnabled } */ function createWriter(options) { var pending = []; var collected = []; var written = 0; var flushFailed = false; function flush() { if (pending.length === 0) { return; } var block = pending.join('\n') + '\n'; pending = []; if (options.dryRun) { return; /* 演练模式:不碰任何文件 */ } try { files.append(options.runPath, block); if (options.historyEnabled) { files.append(options.historyPath, block); } } catch (error) { /* 写盘失败要留痕但不能中断 —— 用户更需要"继续抓"而不是"因为一个文件写不进去全停"。 * 只报一次,避免几百条一模一样的警告把日志刷满。 */ if (!flushFailed) { flushFailed = true; TikTokAuto.logger.error('uidset', '写结果文件失败:' + error); } } } return { /** 收一个 uid。调用方保证已经过去重。 */ write: function (uid) { pending.push(uid); /* 同时留一份在内存里:收尾阶段要拿它去换数字 uid(见 resolve.js)。 * 从文件读一遍也行,但那样得处理"文件还没 flush 完"的时序问题, * 而这里的量级(几十到几千)远没到需要省内存的地步。 */ collected.push(uid); written += 1; if (pending.length >= FLUSH_EVERY) { flush(); } }, /** 本轮收到的全部 uid(按收取顺序) */ list: function () { return collected.slice(0); }, /** 已写多少个 */ count: function () { return written; }, /** 收尾:把剩下的刷出去。**收到停止信号时也必须调它**,否则"停了但文件是空的"。 */ close: function () { flush(); return written; }, /** 是否发生过写盘失败(给 summary 用) */ hadFailure: function () { return flushFailed; } }; } /** 确保目录存在(结果文件与累计文件同目录) */ function ensureDir(dir) { try { files.ensureDir(dir + '/'); return true; } catch (error) { TikTokAuto.logger.error('uidset', '创建目录失败 ' + dir + ':' + error); return false; } } return { FLUSH_EVERY: FLUSH_EVERY, create: create, readUidFile: readUidFile, loadHistory: loadHistory, createWriter: createWriter, ensureDir: ensureDir }; }());