/** * checkpoint.js — ID 提取的断点("跑到哪了"的唯一真相) * * ## 粒度:取材条目级,不是屏/行级 * * TikTok 的列表内容会变、滚动位置无法复原。记"第几屏第几行"只会造成 * 漏掉别人或重复劳动;而「哪条取材已经走完」是稳定事实。 * 所以:**跳过已完成的取材;未完成的那条从头重走,靠已收集合跳人**。 * * ## 文件 * `/id_progress.json`,与产物同目录(用户清产物时看得见)。 * 全局只保留一份(全局单跑,多份会让"哪份有效"变成判断题)。 * * ## 几条硬约束(都踩过或差点踩) * * 1. **只能覆盖写,不能删文件** —— `files.remove` 命中 MCP 危险调用黑名单 * (见 `tools/build.js` 的 `DANGEROUS_RULES` 与 `uidset.js` 文件头)。 * 「清空断点」= 覆盖写成 `state:"none"`。 * 2. **read 必须容错** —— 写到一半被杀会留下半截 JSON。读不懂就当"没有断点", * 绝不抛异常:产物还在,丢的只是"能不能接着跑"。 * 3. **不存已收 uid 列表** —— 结果文件本身就是那份清单,重复存只会出现两份不一致。 * 4. **写盘不追每一条 uid** —— 粒度是取材条目级,每收 1 个就写只换来"少重走 19 个人"。 * 节奏是:取材边界 + 每 20 个(与产物刷盘同拍)+ 收尾。 * * 依赖:TikTokAuto.logger、TikTokAuto.fingerprint(仅 compare 用) */ TikTokAuto.checkpoint = (function () { 'use strict'; /** 文件名固定:不放用户配置里,避免"换个名字就找不到断点" */ var DEFAULT_NAME = 'id_progress.json'; var VERSION = 1; function logger() { return TikTokAuto.logger; } /** * 是不是数组。优先 `Array.isArray`;退回用 `.apply` 而不是 `Function.prototype.call` * —— 后者那个关键字加左括号会命中 MCP 危险调用黑名单(见 `tools/build.js`)。 */ function isArray(v) { if (typeof Array.isArray === 'function') { return Array.isArray(v); } return Object.prototype.toString.apply(v, []) === '[object Array]'; } function dirOf(path) { var s = String(path || ''); var i = s.lastIndexOf('/'); return i > 0 ? s.substring(0, i + 1) : ''; } /** 断点文件路径(由结果目录派生) */ function pathOf(output) { var dir = (output && output.dir) || '/sdcard/Download/tiktok_app'; return dir + '/' + DEFAULT_NAME; } /** * 新建一份断点。 * * @param runId 当前这次运行(界面靠它把记录与断点精确对上) * @param sig 指纹(`fingerprint.ofIdSection(config.id)`) * @param runFile 结果文件绝对路径(续跑时复用它,不新开文件) * @param sources `[{key, kind, label}]`,顺序与 config.id.sources 一致 */ function empty(runId, sig, runFile, sources) { var list = []; for (var i = 0; i < (sources || []).length; i += 1) { var s = sources[i] || {}; list.push({ key: s.key || ('i' + i + ':' + (s.kind || '')), kind: s.kind || '', label: s.label || '', status: 'pending', /* pending | running | done */ screens: 0, handled: 0, added: 0 }); } var now = Date.now(); return { v: VERSION, state: 'running', /* running | stopped | done | none */ runId: runId || '', beganAt: now, updatedAt: now, sig: sig || null, runFile: runFile || '', mapFile: '', limitBase: 0, counts: { extracted: 0, skipped: 0, duplicates: 0, openedProfiles: 0 }, sources: list, expand: { queue: [], done: [] } }; } /** 读断点。**任何解析问题都返回 null**(当作没有断点),绝不抛 */ function read(path) { try { if (!path || !files.exists(path)) { return null; } var text = String(files.read(path) || ''); if (text.replace(/\s/g, '') === '') { return null; } var cp = JSON.parse(text); if (!cp || cp.v !== VERSION) { return null; } /* 半截文件可能缺字段:补齐,别让调用方到处判空 */ if (!isArray(cp.sources)) { cp.sources = []; } if (!cp.expand || typeof cp.expand !== 'object') { cp.expand = { queue: [], done: [] }; } if (!isArray(cp.expand.queue)) { cp.expand.queue = []; } if (!isArray(cp.expand.done)) { cp.expand.done = []; } return cp; } catch (error) { logger().warn('checkpoint', '断点文件读不懂(当作没有断点):' + error); return null; } } /** 覆盖写整份断点。返回是否写成功(失败只记 warn,不影响主流程) */ function save(path, cp) { if (!cp) { return false; } try { var dir = dirOf(path); if (dir) { files.ensureDir(dir); } cp.updatedAt = Date.now(); files.write(path, JSON.stringify(cp)); return true; } catch (error) { logger().warn('checkpoint', '断点写盘失败(不影响已抓到的结果):' + error); return false; } } /** 「清空断点」= 覆盖成空壳。**不删文件**(`files.remove` 是危险调用) */ function clearStub() { return { v: VERSION, state: 'none', updatedAt: Date.now() }; } /** 指纹不符时给用户一句人话,说清是哪一段变了 */ function nameOf(key) { if (key === 'sources') { return '取材'; } if (key === 'filter') { return '过滤条件'; } if (key === 'output') { return '输出设置(目录 / 跨轮去重)'; } return key; } /** * 能不能续跑?—— 顺序很重要,每一步的失败都要给可操作的话。 * * 全部判定都在**启动 App 之前**做完:拒绝时零成本返回,不折腾用户手机。 * * @param prev 上一次读到的断点(或 null) * @param sig 当前配置指纹 * @param runFileExists 上次的结果文件还在不在 * @param opts { dryRun } * @returns { mode:'resume'|'fresh'|'reject', reason, message, changed } */ function planResume(prev, sig, runFileExists, opts) { var options = opts || {}; if (!prev || prev.v !== VERSION || prev.state === 'none') { return { mode: 'fresh', reason: 'no-checkpoint', message: '', changed: [] }; } if (prev.state === 'done') { return { mode: 'fresh', reason: 'last-complete', message: '', changed: [] }; } if (options.dryRun) { return { mode: 'reject', reason: 'dry-run', changed: [], message: '断点失效:演练模式不写产物,无法续跑' }; } var cmp = TikTokAuto.fingerprint.compare(prev.sig, sig); if (!cmp.same) { var names = []; for (var i = 0; i < cmp.changed.length; i += 1) { names.push(nameOf(cmp.changed[i])); } return { mode: 'reject', reason: 'config-changed', changed: cmp.changed, message: '断点失效:' + names.join('、') + '已改动,只能重新开始' }; } if (!runFileExists) { return { mode: 'reject', reason: 'run-file-missing', changed: [], message: '断点失效:上次的结果文件已不存在(' + (prev.runFile || '') + ')' }; } return { mode: 'resume', reason: 'ok', message: '', changed: [] }; } /* ------------------------------------------------------------ 取材条目 */ function findSource(cp, key) { if (!cp || !isArray(cp.sources)) { return null; } for (var i = 0; i < cp.sources.length; i += 1) { if (cp.sources[i].key === key) { return cp.sources[i]; } } return null; } function sourceDone(cp, key) { var one = findSource(cp, key); return !!(one && one.status === 'done'); } /** * 更新一条取材的状态。 * @param extra 可选 { screens, handled, added } */ function markSource(cp, key, status, extra) { var one = findSource(cp, key); if (!one) { return null; } one.status = status; if (extra) { if (extra.screens !== undefined) { one.screens = extra.screens; } if (extra.handled !== undefined) { one.handled = extra.handled; } if (extra.added !== undefined) { one.added = extra.added; } } return one; } /** 已完成 / 共几条(给界面写提示条用) */ function sourceProgress(cp) { var done = 0; var total = cp && isArray(cp.sources) ? cp.sources.length : 0; for (var i = 0; i < total; i += 1) { if (cp.sources[i].status === 'done') { done += 1; } } return { done: done, total: total }; } /** 上次停在哪儿(提示条上那句"停在「xxx」") */ function stoppedAt(cp) { if (!cp || !isArray(cp.sources)) { return ''; } for (var i = 0; i < cp.sources.length; i += 1) { if (cp.sources[i].status !== 'done') { return cp.sources[i].label || ''; } } return ''; } /** * 取一条取材的记录(找不到返回 null)。 * * 续跑时要用它上次翻了多少屏来**放大** `maxScreens` 预算: * 从头重放旧人也要翻屏,不放大就会"还没走到新人就撞上 60 屏硬顶"。 */ function sourceOf(cp, key) { return findSource(cp, key); } /** 上次这条取材翻了多少屏(没记录就 0) */ function screensOf(cp, key) { var one = findSource(cp, key); return one ? (one.screens || 0) : 0; } /* ------------------------------------------------------------ 深度展开 */ function expandKey(uid, which) { return String(uid) + '|' + String(which); } function enqueueExpand(cp, uid, which) { if (!cp || !uid) { return false; } var key = expandKey(uid, which); if (cp.expand.done.indexOf(key) >= 0) { return false; /* 上次已经展开过,别重复排 */ } for (var i = 0; i < cp.expand.queue.length; i += 1) { if (expandKey(cp.expand.queue[i].uid, cp.expand.queue[i].which) === key) { return false; } } cp.expand.queue.push({ uid: String(uid), which: String(which) }); return true; } function expandDone(cp, uid, which) { return !!cp && cp.expand && cp.expand.done.indexOf(expandKey(uid, which)) >= 0; } function markExpandDone(cp, uid, which) { if (!cp || !cp.expand) { return; } var key = expandKey(uid, which); if (cp.expand.done.indexOf(key) < 0) { cp.expand.done.push(key); } } /** * 这次还要展开谁。 * * @param queue 本轮内存里攒的待展开项(`[{uid, which}]`,由 handleRow 边收边入队) * @returns 过滤掉"上次已完成"之后的那部分 */ function pendingExpand(cp, queue) { var out = []; for (var i = 0; i < (queue || []).length; i += 1) { var one = queue[i]; if (!expandDone(cp, one.uid, one.which)) { out.push(one); } } return out; } /* ------------------------------------------------------------ 收尾判定 */ /** 所有取材都 done,且展开队列里没有未完成的 → 这一轮真的做完了 */ function allDone(cp) { if (!cp || !isArray(cp.sources) || cp.sources.length === 0) { return false; } for (var i = 0; i < cp.sources.length; i += 1) { if (cp.sources[i].status !== 'done') { return false; } } for (var j = 0; j < cp.expand.queue.length; j += 1) { if (!expandDone(cp, cp.expand.queue[j].uid, cp.expand.queue[j].which)) { return false; } } return true; } /** * 「提取满 N 个就停」—— 续跑时必须**按累计数**算。 * * 续跑的人均语义是"这一轮凑够 N 个",那就变成设了 100 个、跑了 60 个被停、 * 接着再收 100 个 = 一共 160 个。所以基数是"上次已落盘的条数", * 而那个数以**结果文件的行数**为准(文件是真相,不信 JSON 里的计数)。 * * @param cp 断点(读 `limitBase`) * @param limit 用户设的上限(0 = 不限) * @param currentExtracted 本轮新增 */ function limitReached(cp, limit, currentExtracted) { if (!(limit > 0)) { return false; } var base = (cp && cp.limitBase) || 0; return (base + (currentExtracted || 0)) >= limit; } return { DEFAULT_NAME: DEFAULT_NAME, VERSION: VERSION, pathOf: pathOf, empty: empty, read: read, save: save, clearStub: clearStub, planResume: planResume, nameOf: nameOf, sourceDone: sourceDone, markSource: markSource, sourceProgress: sourceProgress, stoppedAt: stoppedAt, sourceOf: sourceOf, screensOf: screensOf, enqueueExpand: enqueueExpand, expandDone: expandDone, markExpandDone: markExpandDone, pendingExpand: pendingExpand, allDone: allDone, limitReached: limitReached }; }());