/** * idflow.js — ID 提取的编排 * * 与 `flow.js`(视频链路)并列,共用同一套基础设施:应用启动、弹窗守护、 * 坐标点击、`wait.tick()` 的停止、`bridge.report()` 的状态上报。 * * ## 一轮跑完是什么样 * * 启动应用 → 进信息流 → 对每条取材: * 取材(列表型 / 文本型 / 逐个点开型) * → 每个候选:去重判定 → 需要时打开用户页 → 过滤判定 → 写盘 * → 深度展开(第二层,最多一层) * → 收尾:关文件、把 summary 上报给界面 * * ## 三个必须处理好的"与视频链路不同" * * ① **总量未知**。粉丝列表可能几十万人,没有 `total` 可报。 * 所以状态条上的进度是「已收 N 个」而不是"N/M" —— 界面只读 `phase`,零改动。 * * ② **"不限"是危险的**。界面里 `extractLimit = 0` 的语义是"不限制"。 * 但真跑起来没有上限就等于跑到没电。所以引擎**另加三道物理护栏**: * 最大时长、单列表最大翻屏数(在 idsource 里)、连续无新内容即止。 * 到护栏时**如实报告**"因达到护栏而结束",绝不装作抓完了。 * * ③ **停止时必须写文件**。用户在任意时刻点停,已经抓到的部分不能丢 —— * 所以 `writer.close()` 放在 finally 里,无论怎么结束都会刷盘。 * * 依赖:全部 ID 相关模块 + bridge / wait / ui / popups */ TikTokAuto.idflow = (function () { 'use strict'; /** * 最近一次运行的 summary。 * * 为什么挂模块级而不是只靠返回值:用户点停止时 StopSignal 会从 * `wait.tick()` 里抛出来,穿过整个流程到 main.js 的 catch —— * 那时 `runSession` 的返回值根本用不上。main 从这里取已经统计好的数字, * 否则界面会显示"提取 0 个"(实际抓到的都在文件里,只是没报上去)。 */ var lastSummary = null; function logger() { return TikTokAuto.logger; } /** 上报进度。只动 phase(界面状态条只读它),total 恒为 0 —— 这里没有总量。 */ function progress(phase, done) { TikTokAuto.bridge.report({ status: 'running', phase: phase + '(已收 ' + done + ' 个)', done: done, total: 0 }); } /** * 把界面配置里的 `id` 段收拢成引擎侧要用的形状。 * * 界面已经做过一遍归一化(Sources.normalize),但**引擎不能假设界面一定对**: * 手写的 engine-config.json、旧版本残留、被改坏的文件都会进来。 * 这里只做"缺了就补默认",不做语义校验 —— 语义校验在 filters 与 idsource 里。 */ function readSettings(config) { var id = config.id || {}; var filter = id.filter || {}; var output = id.output || {}; var guard = id.guard || {}; return { sources: (id.sources instanceof Array ? id.sources : []), filter: { private: filter.private === true, noAvatar: filter.noAvatar === true, langRegion: filter.langRegion || 'any', nicknameCheck: filter.nicknameCheck || 'any', follow: filter.follow || {}, fans: filter.fans || {}, likes: filter.likes || {}, videos: filter.videos || {} }, output: { dir: output.dir || '/sdcard/Download/tiktok_app', historyPath: output.historyPath || (output.dir || '/sdcard/Download/tiktok_app') + '/uid_extracted.txt', dedupeHistory: output.dedupeHistory !== false, dryRun: output.dryRun === true }, limit: Number(output.limit) || 0, maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000 }; } /** * 跑一轮 ID 提取。 * * @returns 结构化 summary(与视频链路同族,供 bridge/main 复用) */ function runSession(config) { var log = logger(); var profileApi = TikTokAuto.profile; var settings = readSettings(config); var began = Date.now(); var summary = { ok: false, task: 'id', extracted: 0, /* 本次写进产物文件的个数 */ skipped: 0, /* 因过滤被排除 */ duplicates: 0, /* 因去重被排除 */ openedProfiles: 0, /* 打开过多少次用户页(成本指标) */ sources: [], warnings: [], resultFile: '', errors: [] }; lastSummary = summary; var seen = TikTokAuto.uidset.create(); var expandQueue = []; var softReported = {}; var writer = null; /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */ function limitReached() { return settings.limit > 0 && summary.extracted >= settings.limit; } function timeUp() { return Date.now() - began >= settings.maxRuntimeMs; } function shouldStop() { if (limitReached()) { return true; } if (timeUp()) { if (!summary.warnings.length || summary.warnings[summary.warnings.length - 1] !== 'TIME_LIMIT') { log.warn('idflow', '已达单次运行时长上限(' + Math.round(settings.maxRuntimeMs / 60000) + ' 分钟),准备收尾'); summary.warnings.push('TIME_LIMIT'); } return true; } return false; } /** * 给一个候选拿到资料。三条路径按代价从低到高: * ① 调用方已经读过(评论者那只,`_profile`); * ② 列表行自带计数(搜索用户列表有"粉丝 · 获赞"); * ③ 打开用户页(最贵,约 3~5 秒)。 */ function obtainProfile(row) { if (row._profile) { return row._profile; } if (!TikTokAuto.filters.needsProfile(settings.filter)) { /* 不需要任何用户页才能判的条件 → 绝不为此付一次开页代价 */ if (row.fans !== undefined || row.likes !== undefined) { return { uid: row.uid, nickname: row.nickname, signature: '', follow: null, fans: row.fans === undefined ? null : row.fans, likes: row.likes === undefined ? null : row.likes, isPrivate: false, hasAvatar: null }; } return null; } /* 从列表行点进去最快(且实测返回后列表位置保持)。 * ★ 点开失败时**不再退回搜索**:在列表遍历中途跳去搜索, * 会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。 * 这里直接返回 null,由 judge 按"读不到资料"拦下并记日志。 */ if (row.node && row.x !== undefined && profileApi.where() === 'user-list') { if (profileApi.openRow(row)) { summary.openedProfiles += 1; var data = profileApi.readProfile(); profileApi.backToList(); return data; } log.warn('idflow', '点不开 @' + row.uid + ' 的主页,跳过(不回搜索,避免丢失列表位置)'); return null; } var profile = profileApi.openBySearch(row.uid, config); if (profile) { summary.openedProfiles += 1; } return profile; } /** 处理一个候选:去重 → 过滤 → 写盘 → 记下要不要展开 */ function handleRow(row) { var parse = TikTokAuto.parse; var uid = parse.username(row.uid); if (uid === '' || !parse.looksLikeUsername(uid)) { return; } if (seen.has(uid)) { summary.duplicates += 1; return; } if (shouldStop()) { return; } progress('正在读取 @' + uid, summary.extracted); var profile = obtainProfile(row); var verdict = TikTokAuto.filters.judge(profile, settings.filter); if (!verdict.ok) { summary.skipped += 1; log.info('idflow', '跳过 @' + uid + ':' + verdict.reason); seen.add(uid); return; } if (verdict.soft && !softReported[verdict.soft]) { softReported[verdict.soft] = true; summary.warnings.push(verdict.soft); log.warn('idflow', verdict.soft); } seen.add(uid); writer.write(uid); summary.extracted += 1; /* 收录时记下依据。这不只是"好看":用户事后想核对"这个人为啥被收进来"时, * 日志是唯一的答案;没有它就只能重新跑一遍。 */ log.info('idflow', '收录 @' + uid + (profile ? '(粉丝 ' + profile.fans + ' / 关注 ' + profile.follow + ' / 获赞 ' + profile.likes + ')' : '(未打开用户页,按列表现有资料通过)')); if (row.expandTo) { expandQueue.push({ uid: uid, which: row.expandTo }); } } try { log.step('idflow', '=== TikTok ID 提取开始 ==='); log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' + TikTokAuto.filters.describe(settings.filter)); TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 }); if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) { summary.errors.push('无法创建结果目录:' + settings.output.dir); return summary; } /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟, * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。 */ var runFile = settings.output.dir + '/' + TikTokAuto.parse.timestampName('uid', new Date()); writer = TikTokAuto.uidset.createWriter({ runPath: runFile, historyPath: settings.output.historyPath, dryRun: settings.output.dryRun, historyEnabled: settings.output.dedupeHistory }); summary.resultFile = settings.output.dryRun ? '' : runFile; if (settings.output.dedupeHistory) { TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath); } /* —— 启动与就位(复用视频链路的成熟实现) —— */ progress('启动应用', 0); if (!TikTokAuto.flow.launchApp(config)) { summary.errors.push('启动应用失败'); return summary; } progress('进入信息流', 0); if (!TikTokAuto.profile.ensureFeed(config)) { summary.errors.push('未能进入信息流首页'); return summary; } if (settings.sources.length === 0) { summary.errors.push('没有配置任何取材(界面里至少需要一条)'); return summary; } /* —— 第一层:逐条取材 —— */ for (var i = 0; i < settings.sources.length; i += 1) { if (shouldStop()) { break; } var source = settings.sources[i]; var before = summary.extracted; progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted); var ctx = { config: config, seen: seen, shouldStop: shouldStop, onSeenProfile: function (prof) { log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans + ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes); }, onRow: function (row) { /* 深度只在第一层记:第二层不再展开(上限两层) */ if (source.expand && !row.expandTo) { row.expandTo = source.expand; } handleRow(row); } }; var handled = 0; try { handled = TikTokAuto.idsource.run(source, ctx); } catch (error) { if (TikTokAuto.bridge.isStopSignal(error)) { throw error; } log.error('idflow', '取材失败(' + TikTokAuto.idsource.describe(source) + '):' + error); summary.errors.push('取材失败:' + TikTokAuto.idsource.describe(source)); } summary.sources.push({ label: TikTokAuto.idsource.describe(source), handled: handled, added: summary.extracted - before }); log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) + ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个'); } /* —— 第二层:深度展开 —— * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。 * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。 */ if (expandQueue.length > 0 && !shouldStop()) { log.step('idflow', '开始深度展开:' + expandQueue.length + ' 个账号'); for (var e = 0; e < expandQueue.length; e += 1) { if (shouldStop()) { break; } var target = expandQueue[e]; progress('展开 @' + target.uid + ' 的' + (target.which === 'following' ? '关注' : '粉丝'), summary.extracted); var opened = profileApi.openBySearch(target.uid, config); if (!opened) { log.warn('idflow', '展开时打不开 @' + target.uid); continue; } summary.openedProfiles += 1; if (!profileApi.openList(target.which, config)) { continue; } profileApi.switchListTab(target.which); var subCtx = { config: config, seen: seen, shouldStop: shouldStop, onSeenProfile: function () { }, onRow: handleRow /* 注意:这里**不再**设置 expandTo,所以不会递归 */ }; try { TikTokAuto.idsource.walkList(subCtx); } catch (error2) { if (TikTokAuto.bridge.isStopSignal(error2)) { throw error2; } log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2); } } } summary.ok = summary.extracted > 0; if (!summary.ok && summary.errors.length === 0) { summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)'); } } finally { /* 无论是正常结束、出错、还是用户叫停,都必须把已经抓到的刷进文件 —— * "点了停止但文件是空的"是最不能接受的一种失败。 */ if (writer) { var written = writer.close(); if (written !== summary.extracted) { log.warn('idflow', '写盘数量与统计不符:文件 ' + written + ' / 统计 ' + summary.extracted); } if (writer.hadFailure()) { summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)'); } } /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append), * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。 */ if (summary.extracted === 0) { summary.resultFile = ''; } if (limitReached()) { summary.warnings.push('已提取满 ' + settings.limit + ' 个,按设置停止'); } log.step('idflow', '=== 提取结束:新增 ' + summary.extracted + ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates + ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ==='); } return summary; } return { runSession: runSession, readSettings: readSettings, lastSummary: function () { return lastSummary; } }; }());