idflow.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  1. /**
  2. * idflow.js — ID 提取的编排
  3. *
  4. * 与 `flow.js`(视频链路)并列,共用同一套基础设施:应用启动、弹窗守护、
  5. * 坐标点击、`wait.tick()` 的停止、`bridge.report()` 的状态上报。
  6. *
  7. * ## 一轮跑完是什么样
  8. *
  9. * 启动应用 → 进信息流 → 对每条取材:
  10. * 取材(列表型 / 文本型 / 逐个点开型)
  11. * → 每个候选:去重判定 → 需要时打开用户页 → 过滤判定 → 写盘
  12. * → 深度展开(第二层,最多一层)
  13. * → 收尾:关文件、把 summary 上报给界面
  14. *
  15. * ## 三个必须处理好的"与视频链路不同"
  16. *
  17. * ① **总量未知**。粉丝列表可能几十万人,没有 `total` 可报。
  18. * 所以状态条上的进度是「已收 N 个」而不是"N/M" —— 界面只读 `phase`,零改动。
  19. *
  20. * ② **"不限"是危险的**。界面里 `extractLimit = 0` 的语义是"不限制"。
  21. * 但真跑起来没有上限就等于跑到没电。所以引擎**另加三道物理护栏**:
  22. * 最大时长、单列表最大翻屏数(在 idsource 里)、连续无新内容即止。
  23. * 到护栏时**如实报告**"因达到护栏而结束",绝不装作抓完了。
  24. *
  25. * ③ **停止时必须写文件**。用户在任意时刻点停,已经抓到的部分不能丢 ——
  26. * 所以 `writer.close()` 放在 finally 里,无论怎么结束都会刷盘。
  27. *
  28. * 依赖:全部 ID 相关模块 + bridge / wait / ui / popups
  29. */
  30. TikTokAuto.idflow = (function () {
  31. 'use strict';
  32. /**
  33. * 最近一次运行的 summary。
  34. *
  35. * 为什么挂模块级而不是只靠返回值:用户点停止时 StopSignal 会从
  36. * `wait.tick()` 里抛出来,穿过整个流程到 main.js 的 catch ——
  37. * 那时 `runSession` 的返回值根本用不上。main 从这里取已经统计好的数字,
  38. * 否则界面会显示"提取 0 个"(实际抓到的都在文件里,只是没报上去)。
  39. */
  40. var lastSummary = null;
  41. function logger() { return TikTokAuto.logger; }
  42. /** 上报进度。只动 phase(界面状态条只读它),total 恒为 0 —— 这里没有总量。 */
  43. function progress(phase, done) {
  44. TikTokAuto.bridge.report({
  45. status: 'running',
  46. phase: phase + '(已收 ' + done + ' 个)',
  47. done: done,
  48. total: 0
  49. });
  50. }
  51. /**
  52. * 把界面配置里的 `id` 段收拢成引擎侧要用的形状。
  53. *
  54. * 界面已经做过一遍归一化(Sources.normalize),但**引擎不能假设界面一定对**:
  55. * 手写的 engine-config.json、旧版本残留、被改坏的文件都会进来。
  56. * 这里只做"缺了就补默认",不做语义校验 —— 语义校验在 filters 与 idsource 里。
  57. */
  58. function readSettings(config) {
  59. var id = config.id || {};
  60. var filter = id.filter || {};
  61. var output = id.output || {};
  62. var guard = id.guard || {};
  63. return {
  64. sources: (id.sources instanceof Array ? id.sources : []),
  65. filter: {
  66. private: filter.private === true,
  67. noAvatar: filter.noAvatar === true,
  68. langRegion: filter.langRegion || 'any',
  69. nicknameCheck: filter.nicknameCheck || 'any',
  70. follow: filter.follow || {},
  71. fans: filter.fans || {},
  72. likes: filter.likes || {},
  73. videos: filter.videos || {}
  74. },
  75. output: {
  76. dir: output.dir || '/sdcard/Download/tiktok_app',
  77. historyPath: output.historyPath || (output.dir || '/sdcard/Download/tiktok_app') + '/uid_extracted.txt',
  78. dedupeHistory: output.dedupeHistory !== false,
  79. dryRun: output.dryRun === true
  80. },
  81. limit: Number(output.limit) || 0,
  82. maxRuntimeMs: Number(guard.maxRuntimeMs) || 30 * 60 * 1000
  83. };
  84. }
  85. /**
  86. * 跑一轮 ID 提取。
  87. *
  88. * @returns 结构化 summary(与视频链路同族,供 bridge/main 复用)
  89. */
  90. function runSession(config) {
  91. var log = logger();
  92. var profileApi = TikTokAuto.profile;
  93. var settings = readSettings(config);
  94. var began = Date.now();
  95. var summary = {
  96. ok: false,
  97. task: 'id',
  98. extracted: 0, /* 本次写进产物文件的个数 */
  99. skipped: 0, /* 因过滤被排除 */
  100. duplicates: 0, /* 因去重被排除 */
  101. openedProfiles: 0, /* 打开过多少次用户页(成本指标) */
  102. sources: [],
  103. warnings: [],
  104. resultFile: '',
  105. errors: []
  106. };
  107. lastSummary = summary;
  108. var seen = TikTokAuto.uidset.create();
  109. var expandQueue = [];
  110. var softReported = {};
  111. var writer = null;
  112. /* —— 停止条件:上限 / 时长 / 用户叫停,三处统一问它 —— */
  113. function limitReached() {
  114. return settings.limit > 0 && summary.extracted >= settings.limit;
  115. }
  116. function timeUp() {
  117. return Date.now() - began >= settings.maxRuntimeMs;
  118. }
  119. function shouldStop() {
  120. if (limitReached()) {
  121. return true;
  122. }
  123. if (timeUp()) {
  124. if (!summary.warnings.length || summary.warnings[summary.warnings.length - 1] !== 'TIME_LIMIT') {
  125. log.warn('idflow', '已达单次运行时长上限(' +
  126. Math.round(settings.maxRuntimeMs / 60000) + ' 分钟),准备收尾');
  127. summary.warnings.push('TIME_LIMIT');
  128. }
  129. return true;
  130. }
  131. return false;
  132. }
  133. /**
  134. * 给一个候选拿到资料。三条路径按代价从低到高:
  135. * ① 调用方已经读过(评论者那只,`_profile`);
  136. * ② 列表行自带计数(搜索用户列表有"粉丝 · 获赞");
  137. * ③ 打开用户页(最贵,约 3~5 秒)。
  138. */
  139. function obtainProfile(row) {
  140. if (row._profile) {
  141. return row._profile;
  142. }
  143. if (!TikTokAuto.filters.needsProfile(settings.filter)) {
  144. /* 不需要任何用户页才能判的条件 → 绝不为此付一次开页代价 */
  145. if (row.fans !== undefined || row.likes !== undefined) {
  146. return {
  147. uid: row.uid,
  148. nickname: row.nickname,
  149. signature: '',
  150. follow: null,
  151. fans: row.fans === undefined ? null : row.fans,
  152. likes: row.likes === undefined ? null : row.likes,
  153. isPrivate: false,
  154. hasAvatar: null
  155. };
  156. }
  157. return null;
  158. }
  159. /* 从列表行点进去最快(且实测返回后列表位置保持)。
  160. * ★ 点开失败时**不再退回搜索**:在列表遍历中途跳去搜索,
  161. * 会把"当前在哪个列表的第几行"整个丢掉,之后再也接不回原来的列表。
  162. * 这里直接返回 null,由 judge 按"读不到资料"拦下并记日志。 */
  163. if (row.node && row.x !== undefined && profileApi.where() === 'user-list') {
  164. if (profileApi.openRow(row)) {
  165. summary.openedProfiles += 1;
  166. var data = profileApi.readProfile();
  167. profileApi.backToList();
  168. return data;
  169. }
  170. log.warn('idflow', '点不开 @' + row.uid + ' 的主页,跳过(不回搜索,避免丢失列表位置)');
  171. return null;
  172. }
  173. var profile = profileApi.openBySearch(row.uid, config);
  174. if (profile) {
  175. summary.openedProfiles += 1;
  176. }
  177. return profile;
  178. }
  179. /** 处理一个候选:去重 → 过滤 → 写盘 → 记下要不要展开 */
  180. function handleRow(row) {
  181. var parse = TikTokAuto.parse;
  182. var uid = parse.username(row.uid);
  183. if (uid === '' || !parse.looksLikeUsername(uid)) {
  184. return;
  185. }
  186. if (seen.has(uid)) {
  187. summary.duplicates += 1;
  188. return;
  189. }
  190. if (shouldStop()) {
  191. return;
  192. }
  193. progress('正在读取 @' + uid, summary.extracted);
  194. var profile = obtainProfile(row);
  195. var verdict = TikTokAuto.filters.judge(profile, settings.filter);
  196. if (!verdict.ok) {
  197. summary.skipped += 1;
  198. log.info('idflow', '跳过 @' + uid + ':' + verdict.reason);
  199. seen.add(uid);
  200. return;
  201. }
  202. if (verdict.soft && !softReported[verdict.soft]) {
  203. softReported[verdict.soft] = true;
  204. summary.warnings.push(verdict.soft);
  205. log.warn('idflow', verdict.soft);
  206. }
  207. seen.add(uid);
  208. writer.write(uid);
  209. summary.extracted += 1;
  210. /* 收录时记下依据。这不只是"好看":用户事后想核对"这个人为啥被收进来"时,
  211. * 日志是唯一的答案;没有它就只能重新跑一遍。 */
  212. log.info('idflow', '收录 @' + uid + (profile
  213. ? '(粉丝 ' + profile.fans + ' / 关注 ' + profile.follow +
  214. ' / 获赞 ' + profile.likes + ')'
  215. : '(未打开用户页,按列表现有资料通过)'));
  216. if (row.expandTo) {
  217. expandQueue.push({ uid: uid, which: row.expandTo });
  218. }
  219. }
  220. try {
  221. log.step('idflow', '=== TikTok ID 提取开始 ===');
  222. log.info('idflow', '取材 ' + settings.sources.length + ' 条,过滤:' +
  223. TikTokAuto.filters.describe(settings.filter));
  224. TikTokAuto.bridge.report({ status: 'running', phase: '准备', done: 0, total: 0 });
  225. if (!TikTokAuto.uidset.ensureDir(settings.output.dir)) {
  226. summary.errors.push('无法创建结果目录:' + settings.output.dir);
  227. return summary;
  228. }
  229. /* 文件名只生成一次:分两次调 timestampName 有可能跨过整分钟,
  230. * 于是"写进去的文件"和"报给界面的文件名"不是同一个 —— 这种错几乎无法复现。 */
  231. var runFile = settings.output.dir + '/' +
  232. TikTokAuto.parse.timestampName('uid', new Date());
  233. writer = TikTokAuto.uidset.createWriter({
  234. runPath: runFile,
  235. historyPath: settings.output.historyPath,
  236. dryRun: settings.output.dryRun,
  237. historyEnabled: settings.output.dedupeHistory
  238. });
  239. summary.resultFile = settings.output.dryRun ? '' : runFile;
  240. if (settings.output.dedupeHistory) {
  241. TikTokAuto.uidset.loadHistory(seen, settings.output.historyPath);
  242. }
  243. /* —— 启动与就位(复用视频链路的成熟实现) —— */
  244. progress('启动应用', 0);
  245. if (!TikTokAuto.flow.launchApp(config)) {
  246. summary.errors.push('启动应用失败');
  247. return summary;
  248. }
  249. progress('进入信息流', 0);
  250. if (!TikTokAuto.profile.ensureFeed(config)) {
  251. summary.errors.push('未能进入信息流首页');
  252. return summary;
  253. }
  254. if (settings.sources.length === 0) {
  255. summary.errors.push('没有配置任何取材(界面里至少需要一条)');
  256. return summary;
  257. }
  258. /* —— 第一层:逐条取材 —— */
  259. for (var i = 0; i < settings.sources.length; i += 1) {
  260. if (shouldStop()) {
  261. break;
  262. }
  263. var source = settings.sources[i];
  264. var before = summary.extracted;
  265. progress('取材 ' + (i + 1) + '/' + settings.sources.length, summary.extracted);
  266. var ctx = {
  267. config: config,
  268. seen: seen,
  269. shouldStop: shouldStop,
  270. onSeenProfile: function (prof) {
  271. log.info('idflow', '取材账号 @' + prof.uid + ':粉丝 ' + prof.fans +
  272. ' / 关注 ' + prof.follow + ' / 获赞 ' + prof.likes);
  273. },
  274. onRow: function (row) {
  275. /* 深度只在第一层记:第二层不再展开(上限两层) */
  276. if (source.expand && !row.expandTo) {
  277. row.expandTo = source.expand;
  278. }
  279. handleRow(row);
  280. }
  281. };
  282. var handled = 0;
  283. try {
  284. handled = TikTokAuto.idsource.run(source, ctx);
  285. } catch (error) {
  286. if (TikTokAuto.bridge.isStopSignal(error)) {
  287. throw error;
  288. }
  289. log.error('idflow', '取材失败(' + TikTokAuto.idsource.describe(source) + '):' + error);
  290. summary.errors.push('取材失败:' + TikTokAuto.idsource.describe(source));
  291. }
  292. summary.sources.push({
  293. label: TikTokAuto.idsource.describe(source),
  294. handled: handled,
  295. added: summary.extracted - before
  296. });
  297. log.info('idflow', '取材完成:' + TikTokAuto.idsource.describe(source) +
  298. ',处理 ' + handled + ' 行,新增 ' + (summary.extracted - before) + ' 个');
  299. }
  300. /* —— 第二层:深度展开 ——
  301. * 与第一层分开跑,是为了避免嵌套列表上下文(见 idsource.js 文件头)。
  302. * 展开进来的人**同样要过一遍过滤** —— 否则"粉丝的粉丝"会绕过所有条件。 */
  303. if (expandQueue.length > 0 && !shouldStop()) {
  304. log.step('idflow', '开始深度展开:' + expandQueue.length + ' 个账号');
  305. for (var e = 0; e < expandQueue.length; e += 1) {
  306. if (shouldStop()) {
  307. break;
  308. }
  309. var target = expandQueue[e];
  310. progress('展开 @' + target.uid + ' 的' +
  311. (target.which === 'following' ? '关注' : '粉丝'), summary.extracted);
  312. var opened = profileApi.openBySearch(target.uid, config);
  313. if (!opened) {
  314. log.warn('idflow', '展开时打不开 @' + target.uid);
  315. continue;
  316. }
  317. summary.openedProfiles += 1;
  318. if (!profileApi.openList(target.which, config)) {
  319. continue;
  320. }
  321. profileApi.switchListTab(target.which);
  322. var subCtx = {
  323. config: config,
  324. seen: seen,
  325. shouldStop: shouldStop,
  326. onSeenProfile: function () { },
  327. onRow: handleRow /* 注意:这里**不再**设置 expandTo,所以不会递归 */
  328. };
  329. try {
  330. TikTokAuto.idsource.walkList(subCtx);
  331. } catch (error2) {
  332. if (TikTokAuto.bridge.isStopSignal(error2)) {
  333. throw error2;
  334. }
  335. log.warn('idflow', '展开 @' + target.uid + ' 失败:' + error2);
  336. }
  337. }
  338. }
  339. summary.ok = summary.extracted > 0;
  340. if (!summary.ok && summary.errors.length === 0) {
  341. summary.errors.push('没有提取到任何 ID(取材范围内都被过滤或去重掉了)');
  342. }
  343. } finally {
  344. /* 无论是正常结束、出错、还是用户叫停,都必须把已经抓到的刷进文件 ——
  345. * "点了停止但文件是空的"是最不能接受的一种失败。 */
  346. if (writer) {
  347. var written = writer.close();
  348. if (written !== summary.extracted) {
  349. log.warn('idflow', '写盘数量与统计不符:文件 ' + written + ' / 统计 ' + summary.extracted);
  350. }
  351. if (writer.hadFailure()) {
  352. summary.warnings.push('结果文件写入失败(部分结果可能没有落盘)');
  353. }
  354. }
  355. /* 一个都没收时**不报文件名**:那个文件根本没被创建(writer 只在有内容时 append),
  356. * 报一个不存在的路径,用户去文件管理器找会以为"抓到了但丢了"。 */
  357. if (summary.extracted === 0) {
  358. summary.resultFile = '';
  359. }
  360. if (limitReached()) {
  361. summary.warnings.push('已提取满 ' + settings.limit + ' 个,按设置停止');
  362. }
  363. log.step('idflow', '=== 提取结束:新增 ' + summary.extracted +
  364. ' 个,跳过 ' + summary.skipped + ' 个,重复 ' + summary.duplicates +
  365. ' 个,耗时 ' + Math.round((Date.now() - began) / 1000) + ' 秒 ===');
  366. }
  367. return summary;
  368. }
  369. return {
  370. runSession: runSession,
  371. readSettings: readSettings,
  372. lastSummary: function () {
  373. return lastSummary;
  374. }
  375. };
  376. }());