| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408 |
- /**
- * 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;
- }
- };
- }());
|