idsource.js 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646
  1. /**
  2. * idsource.js — 五种「从哪儿找人」,以及深度展开
  3. *
  4. * ## 为什么是五种而不是四张脚本卡
  5. * 旧的四个脚本卡是「起点 × 深度」的 4 个**不完整组合**。把它们拆开后发现,
  6. * 真正不同的只有一件事:**候选 ID 从哪儿来**。于是收敛成五种起点
  7. * (指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕),
  8. * 深度(再取这些人的粉丝)是所有起点共用的一个选项。
  9. *
  10. * ## 三种遍历模式
  11. * 五种起点在"怎么走"上只有三种形态,实现都归到这三条路径上:
  12. * · **列表型**(指定账号 / 搜索用户 / 当前屏幕)—— 逐屏读行,可翻页;
  13. * · **文本型**(文本文件)—— 一次性读出每一行;
  14. * · **逐个点开型**(搜索视频作者 / 评论区)—— 屏幕上的条目没有 uid,
  15. * 只能点进去看(视频页底部有 `@uid`,评论者要靠头像进用户页)。
  16. *
  17. * ## 深度展开为什么分两阶段
  18. * "取这批人的粉丝"如果边遍历边展开,会嵌套两层列表上下文 ——
  19. * 打开 B 列表时 A 的滚动位置就丢了。所以做成:
  20. * ① 先把基础层收完,边收边记下"这些人要展开";
  21. * ② 再对这批人逐个打开其粉丝/关注列表。
  22. * 两层都线性,没有嵌套,也就没有"回到上一层时不知道走到哪"的问题。
  23. * **上限固定两层**:100 粉丝 × 每人 100 粉丝 = 1 万次开页,真机上不现实。
  24. *
  25. * 依赖:TikTokAuto.tiktok / profile / filters / parse / logger / wait
  26. */
  27. TikTokAuto.idsource = (function () {
  28. 'use strict';
  29. /** 翻一个列表最多翻多少屏。防止"永远有新内容"(推荐流)把一轮拖成无限长。 */
  30. var MAX_SCREENS = 60;
  31. /**
  32. * 连续这么多次"翻屏后内容不变"就认为到头了(有的列表末尾会重复推荐位)。
  33. *
  34. * ★ 注意判据是**翻不动**,不是"没有新 uid"(2026-10-28 修,见 walkStep 的注释)。
  35. */
  36. var IDLE_SCREENS_TO_STOP = 2;
  37. function logger() { return TikTokAuto.logger; }
  38. /**
  39. * 一屏处理完之后决定下一步。纯函数,便于在 PC 上回归(`tools/pc-selftest-id.js`)。
  40. *
  41. * ★ 这里修的是一个**真 bug**(2026-10-28)。
  42. *
  43. * 旧实现有两个地方同时给 `idle` 加一:
  44. * ① 本屏 `fresh` 为空(全是已收过的人)→ idle + 1
  45. * ② 翻屏后签名不变 → idle + 1
  46. * 而 `idflow` 会先把 `uid_extracted.txt` 预载进 `seen`。于是**第二轮跑同一个账号**时,
  47. * 前两屏全是历史 → idle=2 → **在列表顶部就判"到底"**,一条新人都拿不到。
  48. * 断点续跑要把上次的产物也预载进 `seen`,这个误判会从"偶发"变成"必然"。
  49. *
  50. * 正解:**只看"翻得动翻不动"**。一屏全是旧人说明"旧人多",不代表列表到头;
  51. * 只有"滚动之后签名没变"才是真的到头。
  52. *
  53. * @param state { screens, stuck }
  54. * @param view { sigBefore, sigAfter, rowsAfter, maxScreens }
  55. * @returns { action: 'continue'|'break', state, reason }
  56. */
  57. function walkStep(state, view) {
  58. var stuck = state.stuck;
  59. var screens = state.screens;
  60. if (view.rowsAfter === 0) {
  61. /* 翻完这一屏读不到行了:可能还在加载,也可能真的到头。
  62. * 不在这里下结论 —— 交给下一轮循环开头的"空行即结束"处理
  63. * (这样"加载慢"和"列表结束"两个判断只在一处)。 */
  64. return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
  65. }
  66. if (view.sigAfter === view.sigBefore) {
  67. stuck += 1;
  68. if (stuck >= IDLE_SCREENS_TO_STOP) {
  69. return {
  70. action: 'break',
  71. reason: '翻屏后内容不变,认为列表到底',
  72. state: { screens: screens, stuck: stuck }
  73. };
  74. }
  75. } else {
  76. /* 内容变了 → 列表还在往下走。**不管这一屏里有没有新人**都归零。 */
  77. stuck = 0;
  78. }
  79. if (screens >= view.maxScreens) {
  80. return {
  81. action: 'break',
  82. reason: '达到单列表翻屏上限 ' + view.maxScreens + ' 屏',
  83. state: { screens: screens, stuck: stuck }
  84. };
  85. }
  86. return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
  87. }
  88. /** 一行 uid 的临时载体(列表行带昵称与计数,用户页带更多资料,统一成这个形状) */
  89. function rowOf(uid, nickname, extra) {
  90. var one = { uid: uid, nickname: nickname || '' };
  91. if (extra) {
  92. for (var key in extra) {
  93. if (extra.hasOwnProperty(key)) {
  94. one[key] = extra[key];
  95. }
  96. }
  97. }
  98. return one;
  99. }
  100. /* ============================================================ 列表型 */
  101. /**
  102. * 在"一个可滚动的用户列表"上逐行推进。
  103. *
  104. * 关键设计:**用 uid 集合决定"哪一行还要处理",不用行下标**。
  105. * 因为每次点进用户页再退回来,列表的节点树都重建过一遍(引用失效),
  106. * 只有 uid 是稳的。
  107. *
  108. * 但**停止判据不能用 uid 集合** —— 那会把"全是旧人"误判成"列表到底"(见 walkStep)。
  109. *
  110. * @param ctx { seen, onRow(row), shouldStop(), maxScreens?, stat?, config? }
  111. * maxScreens:本次最多翻多少屏(续跑时按上次已翻的屏数放大,见 idflow)
  112. * stat:传入则回填 { screens, handled },供断点记录
  113. * @returns 处理了多少行(本屏新收的)
  114. */
  115. function walkList(ctx) {
  116. var log = logger();
  117. var handled = 0;
  118. var maxScreens = ctx.maxScreens || MAX_SCREENS;
  119. var state = { screens: 0, stuck: 0 };
  120. while (state.screens < maxScreens) {
  121. if (ctx.shouldStop()) {
  122. log.info('idsource', '达到停止条件,结束当前列表');
  123. break;
  124. }
  125. state.screens += 1;
  126. var rows = TikTokAuto.profile.listRows();
  127. if (rows.length === 0 && state.screens === 1) {
  128. /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
  129. * 从用户页点开粉丝列表、或从结果页切到用户页签后,
  130. * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 ——
  131. * 直接 break 就等于"一条都没抓到"。 */
  132. TikTokAuto.wait.waitFor('listRows', function () {
  133. return TikTokAuto.profile.listRows().length > 0 ? true : null;
  134. }, ctx.config ? ctx.config.timeouts.pageReadyMs : 8000, 300);
  135. rows = TikTokAuto.profile.listRows();
  136. }
  137. if (rows.length === 0) {
  138. log.info('idsource', '当前屏没有读到用户行,结束列表');
  139. break;
  140. }
  141. /* 一行一行处理:只处理没见过的人(见过的直接跳过,但**不因此判定列表到底**)。
  142. * 需要资料时会点进用户页再退回(实测位置保持)。 */
  143. var stopped = false;
  144. for (var i = 0; i < rows.length; i += 1) {
  145. if (ctx.shouldStop()) {
  146. stopped = true;
  147. break;
  148. }
  149. if (ctx.seen.has(rows[i].uid)) {
  150. continue;
  151. }
  152. ctx.onRow(rows[i]);
  153. ctx.seen.add(rows[i].uid); /* 处理过就算见过,避免同一行被反复点开 */
  154. handled += 1;
  155. }
  156. if (stopped) {
  157. break;
  158. }
  159. /* 翻一屏,用"内容有没有变"判断是否到头 */
  160. var before = signatureOfRows(rows);
  161. TikTokAuto.profile.scrollList();
  162. var afterRows = TikTokAuto.profile.listRows();
  163. var step = walkStep(state, {
  164. sigBefore: before,
  165. sigAfter: signatureOfRows(afterRows),
  166. rowsAfter: afterRows.length,
  167. maxScreens: maxScreens
  168. });
  169. state = step.state;
  170. if (step.action === 'break') {
  171. log.info('idsource', step.reason);
  172. break;
  173. }
  174. }
  175. if (ctx.stat) {
  176. ctx.stat.screens = state.screens;
  177. ctx.stat.handled = handled;
  178. }
  179. return handled;
  180. }
  181. /** 一屏 uid 的签名,用来判断"翻完到底有没有变" */
  182. function signatureOfRows(rows) {
  183. var parts = [];
  184. for (var i = 0; i < rows.length; i += 1) {
  185. parts.push(rows[i].uid);
  186. }
  187. return parts.join('|');
  188. }
  189. /* ============================================================ 文本型 */
  190. /**
  191. * 从本地文本文件读 uid。
  192. *
  193. * 两条容忍(都是为了让用户不用改自己的文件):
  194. * · 行首的 `@` 会被剥掉(有人习惯直接粘贴 "@name");
  195. * · `#` 开头的行当注释跳过。
  196. */
  197. function readTextSource(source) {
  198. var list = TikTokAuto.uidset.readUidFile(source.path);
  199. logger().info('idsource', '文本取材:' + source.path + ' 读到 ' + list.length + ' 行');
  200. var rows = [];
  201. for (var i = 0; i < list.length; i += 1) {
  202. rows.push(rowOf(list[i], ''));
  203. }
  204. return rows;
  205. }
  206. /* ============================================================ 逐个点开型 */
  207. /**
  208. * 搜索结果里的视频 → 逐个点开,读播放页底部的作者 uid。
  209. *
  210. * 实测:搜索结果的视频卡片上只有**作者昵称**,没有 uid;
  211. * 播放页底部有一个 `Button` 文案是 `@uid` —— 这是唯一能拿到 uid 的地方。
  212. * 所以每个视频要"点开 → 读 → 退回",约 6 秒一个。
  213. */
  214. function walkSearchVideos(source, ctx) {
  215. var log = logger();
  216. var handled = 0;
  217. var idle = 0;
  218. for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
  219. if (ctx.shouldStop()) {
  220. break;
  221. }
  222. var cards = TikTokAuto.tiktok.videoCards();
  223. if (cards.length === 0) {
  224. /* 0 张卡可能是"到底了",也可能是"列表正在重排/图片在加载"。
  225. * 先给 5 秒,等不到再判定到底 —— 直接 break 会把慢当成空。 */
  226. cards = TikTokAuto.flow.waitForCards(ctx.config, 5000) || [];
  227. }
  228. if (cards.length === 0) {
  229. log.info('idsource', '结果页没有卡片了');
  230. break;
  231. }
  232. var opened = 0;
  233. for (var i = 0; i < cards.length; i += 1) {
  234. if (ctx.shouldStop()) {
  235. return handled;
  236. }
  237. var card = cards[i];
  238. var marker = 'card:' + String(card.caption).slice(0, 40);
  239. if (ctx.seen.has(marker)) {
  240. continue;
  241. }
  242. ctx.seen.add(marker);
  243. if (!TikTokAuto.flow.openCard(ctx.config, card)) {
  244. log.warn('idsource', '打开视频失败:' + marker);
  245. TikTokAuto.flow.returnToResults(ctx.config);
  246. continue;
  247. }
  248. var uid = TikTokAuto.tiktok.playerAuthorUid();
  249. if (uid) {
  250. ctx.onRow(rowOf(uid, String(card.caption).slice(0, 30)));
  251. handled += 1;
  252. opened += 1;
  253. } else {
  254. log.warn('idsource', '播放页读不到作者 uid');
  255. }
  256. TikTokAuto.flow.returnToResults(ctx.config);
  257. }
  258. if (opened === 0) {
  259. idle += 1;
  260. if (idle >= IDLE_SCREENS_TO_STOP) {
  261. log.info('idsource', '连续几屏都读不到作者,结束');
  262. break;
  263. }
  264. } else {
  265. idle = 0;
  266. }
  267. TikTokAuto.flow.scrollResults(ctx.config);
  268. }
  269. return handled;
  270. }
  271. /**
  272. * 评论区 → 逐个点评论者的头像进用户页读 uid。
  273. *
  274. * 实测这条最贵:评论区只显示昵称,没有 uid(`Button title` 是昵称,
  275. * 旁边那个 `@uid` 是固定节点、不是评论者的)。所以每个评论者都要
  276. * "点头像 → 读 → 退回",约 8 秒一个。这也是为什么方案里
  277. * 把"评论者"和"评论者的粉丝"分开成两条取材,而不是默认都开。
  278. */
  279. function walkCommenters(source, ctx) {
  280. var log = logger();
  281. var handled = 0;
  282. for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
  283. if (ctx.shouldStop()) {
  284. break;
  285. }
  286. var rows = TikTokAuto.tiktok.commentRowers();
  287. if (rows.length === 0) {
  288. log.info('idsource', '评论区没有更多可见条目');
  289. break;
  290. }
  291. var opened = 0;
  292. var before = signatureOfNodes(rows);
  293. for (var i = 0; i < rows.length; i += 1) {
  294. if (ctx.shouldStop()) {
  295. return handled;
  296. }
  297. /* 头像节点每次都会重建,用"位置 + 序号"当标记 —— 换一屏位置就会变 */
  298. var marker = 'cmt:' + rows[i].top + ':' + (screen * 100 + i);
  299. if (ctx.seen.has(marker)) {
  300. continue;
  301. }
  302. ctx.seen.add(marker);
  303. TikTokAuto.ui.tapPoint(rows[i].x, rows[i].y);
  304. /* ★ 用 waitProfile(等 @username + 三组数字),而不是 settle + where()。
  305. *
  306. * 早先这里是"等页面安静 3 秒,再看 where() 是不是 profile":
  307. * · settle 会提前返回(骨架先出、内容后加载,签名看起来没变);
  308. * · 而 where() 要求 `@username` 已经渲染出来。
  309. * 两头一凑,就成了"人明明已经进主页了,却被判成没进去"——
  310. * 实测日志里连续两条"点评论者头像没有进入用户主页",然后一条都没取到。
  311. */
  312. if (!TikTokAuto.profile.waitProfilePublic()) {
  313. log.warn('idsource', '点评论者头像没有进入用户主页(等了 10 秒)');
  314. continue;
  315. }
  316. var profile = TikTokAuto.profile.readProfile();
  317. if (profile.uid) {
  318. /* 资料已经在手上了,带上 `_profile` 让编排层不必再开一次用户页 */
  319. ctx.onRow(rowOf(profile.uid, profile.nickname, { _profile: profile }));
  320. handled += 1;
  321. opened += 1;
  322. }
  323. TikTokAuto.profile.backToCommentPanel();
  324. }
  325. if (opened === 0) {
  326. break;
  327. }
  328. /* 评论区滚动 */
  329. var layout = TikTokAuto.layout;
  330. var screenSize = layout.screen();
  331. TikTokAuto.ui.dragScreen(
  332. Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.8),
  333. Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.4),
  334. 420
  335. );
  336. TikTokAuto.profile.settle(2000);
  337. if (signatureOfNodes(TikTokAuto.tiktok.commentRowers()) === before) {
  338. log.info('idsource', '评论区滚动后内容不变,结束');
  339. break;
  340. }
  341. }
  342. return handled;
  343. }
  344. function signatureOfNodes(rows) {
  345. var parts = [];
  346. for (var i = 0; i < rows.length; i += 1) {
  347. parts.push(rows[i].top);
  348. }
  349. return parts.join(',');
  350. }
  351. /* ============================================================ 入口 */
  352. /**
  353. * 把一个取材源跑完,逐行交给 `onRow`。
  354. *
  355. * @param source 取材项(与界面 sources.js 的形状一致)
  356. * @param ctx { config, seen, onRow(row), shouldStop() }
  357. * @returns 处理的行数
  358. */
  359. function run(source, ctx) {
  360. var log = logger();
  361. log.step('idsource', '取材开始:' + describe(source));
  362. if (source.kind === 'text') {
  363. /* 文本源也走 `onRow`,**不在这一层自己判去重** ——
  364. * 去重与过滤是编排层(idflow)的职责,在这里再做一遍会让
  365. * `duplicates` 统计漏掉(实测:跨轮去重跳了 3 个,summary 里却是 0)。 */
  366. var rows = readTextSource(source);
  367. var handled = 0;
  368. for (var i = 0; i < rows.length; i += 1) {
  369. if (ctx.shouldStop()) {
  370. break;
  371. }
  372. ctx.onRow(rows[i]);
  373. handled += 1;
  374. }
  375. return handled;
  376. }
  377. if (source.kind === 'screen') {
  378. /* 当前屏幕:不做任何导航,直接把眼前这一屏当列表读。
  379. *
  380. * 但要先确认眼前确实是"一个用户列表" —— 否则 walkList 会一屏一屏地读,
  381. * 最后报"处理 0 行",用户完全不知道是页面不对。 */
  382. var state = TikTokAuto.profile.where();
  383. if (!TikTokAuto.profile.isListPage(state)) {
  384. log.warn('idsource', '当前屏幕取材:需要先把页面停在「用户列表」(粉丝/关注/搜索用户)上,' +
  385. '引擎不会替你跳转。现在停在「' + state + '」');
  386. return 0;
  387. }
  388. return walkList(ctx);
  389. }
  390. if (source.kind === 'anchor') {
  391. return runAnchor(source, ctx);
  392. }
  393. if (source.kind === 'search') {
  394. if (source.searchType === 'videos') {
  395. if (!prepareSearch(source.keyword, ctx.config)) {
  396. return 0;
  397. }
  398. TikTokAuto.flow.selectResultTab({
  399. target: { resultTab: 'Top' },
  400. timeouts: ctx.config.timeouts,
  401. probe: ctx.config.probe
  402. });
  403. return walkSearchVideos(source, ctx);
  404. }
  405. if (!prepareSearch(source.keyword, ctx.config)) {
  406. return 0;
  407. }
  408. return walkList(ctx);
  409. }
  410. if (source.kind === 'video') {
  411. if (!prepareVideo(source, ctx.config)) {
  412. return 0;
  413. }
  414. return walkCommenters(source, ctx);
  415. }
  416. log.warn('idsource', '未知的取材起点:' + source.kind);
  417. return 0;
  418. }
  419. /**
  420. * 指定账号:打开它的主页 → 按 `lists` 逐个开列表 → 每处走一遍 walkList。
  421. *
  422. * `anchors` 为空表示"不限定账号,从当前屏幕取" —— 这是界面允许的状态,
  423. * 对应 v6 时代「主播 ID 留空」的语义。
  424. */
  425. function runAnchor(source, ctx) {
  426. var log = logger();
  427. var parse = TikTokAuto.parse;
  428. var anchors = String(source.anchors || '').split('#');
  429. var handled = 0;
  430. var accounts = [];
  431. for (var i = 0; i < anchors.length; i += 1) {
  432. var name = parse.username(anchors[i]);
  433. if (name !== '') {
  434. accounts.push(name);
  435. }
  436. }
  437. if (accounts.length === 0) {
  438. log.info('idsource', '没有指定账号 → 直接把当前屏幕当列表');
  439. return walkList(ctx);
  440. }
  441. for (var a = 0; a < accounts.length; a += 1) {
  442. if (ctx.shouldStop()) {
  443. break;
  444. }
  445. var profile = TikTokAuto.profile.openBySearch(accounts[a], ctx.config);
  446. if (!profile) {
  447. log.warn('idsource', '打不开账号 @' + accounts[a] + ',跳过');
  448. continue;
  449. }
  450. ctx.onSeenProfile(profile);
  451. var lists = source.lists && source.lists.length > 0 ? source.lists : ['fans'];
  452. for (var l = 0; l < lists.length; l += 1) {
  453. if (ctx.shouldStop()) {
  454. return handled;
  455. }
  456. var which = lists[l] === 'home' ? 'fans' : lists[l];
  457. if (!TikTokAuto.profile.openList(which, ctx.config)) {
  458. log.warn('idsource', '打不开 ' + lists[l] + ' 列表');
  459. continue;
  460. }
  461. TikTokAuto.profile.switchListTab(which);
  462. handled += walkList(ctx);
  463. TikTokAuto.profile.backToProfile();
  464. }
  465. }
  466. return handled;
  467. }
  468. /** 搜索关键词并停在结果页(供列表型与逐个点开型共用) */
  469. function prepareSearch(keyword, config) {
  470. var log = logger();
  471. if (!TikTokAuto.profile.ensureFeed(config)) {
  472. return false;
  473. }
  474. var tiktok = TikTokAuto.tiktok;
  475. var entry = tiktok.searchEntry();
  476. if (!entry) {
  477. log.error('idsource', '首页找不到搜索入口');
  478. return false;
  479. }
  480. TikTokAuto.ui.clickNode(entry, 'idsource:search');
  481. if (TikTokAuto.wait.waitFor('idsource:search', function () {
  482. var state = tiktok.idPageState();
  483. return (state === 'search' || state === 'users-results') ? true : null;
  484. }, config.timeouts.pageReadyMs, config.probe.pollTickMs) !== true) {
  485. return false;
  486. }
  487. var input = tiktok.searchInput();
  488. if (!input) {
  489. return false;
  490. }
  491. TikTokAuto.ui.clickNode(input, 'idsource:input');
  492. TikTokAuto.wait.tick(250);
  493. if (!TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, 'idsource:type').ok) {
  494. return false;
  495. }
  496. var submit = tiktok.searchSubmit();
  497. if (!submit) {
  498. return false;
  499. }
  500. TikTokAuto.ui.clickNode(submit, 'idsource:submit');
  501. if (TikTokAuto.wait.waitFor('idsource:results', function () {
  502. return tiktok.resultTabStripPresent() ? true : null;
  503. }, config.timeouts.searchResultsMs, config.probe.pollTickMs) !== true) {
  504. return false;
  505. }
  506. /* 用户搜索要切到 Users 页签;视频搜索留在 Top */
  507. var usersTab = tiktok.resultTab('Users');
  508. if (usersTab && !tiktok.resultTabSelected('Users')) {
  509. TikTokAuto.ui.clickNode(usersTab, 'idsource:usersTab');
  510. /* 等结果行真的出现(页签条先渲染、行后加载,只等"安静"会误判为空) */
  511. TikTokAuto.wait.waitFor('idsource:userRows', function () {
  512. return tiktok.usersResultRows().length > 0 ? true : null;
  513. }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
  514. }
  515. return true;
  516. }
  517. /**
  518. * 打开一个视频(播放页),供评论区取材用。
  519. * `videoSource = "screen"` 表示用户自己已经把视频停在屏幕上,不动它。
  520. */
  521. function prepareVideo(source, config) {
  522. var log = logger();
  523. if (source.videoSource !== 'keyword') {
  524. var state = TikTokAuto.tiktok.pageState();
  525. if (state === 'player' || state === 'comment-panel') {
  526. log.info('idsource', '已在播放页,直接用当前屏幕的视频');
  527. return true;
  528. }
  529. /* 这里的提示必须是**可操作**的:用户能做的只有三件事 —— 自己先把页面停好、
  530. * 改用关键词模式、或者换一个取材源。不能只说"不在播放页"。 */
  531. log.warn('idsource', '「当前屏幕」需要用你手上已经打开的那个视频,引擎不会替你跳转。\n' +
  532. ' 请先在 TikTok 里打开视频(或直接打开它的评论区),再点「开始运行」。\n' +
  533. ' 当前页面是「' + state + '」。');
  534. return false;
  535. }
  536. if (!prepareSearch(source.videoKeyword, config)) {
  537. return false;
  538. }
  539. /* Top 页签是视频;取第一张卡点开 */
  540. var cards = TikTokAuto.tiktok.videoCards();
  541. if (cards.length === 0 && !TikTokAuto.flow.waitForCards(config)) {
  542. return false;
  543. }
  544. cards = TikTokAuto.tiktok.videoCards();
  545. if (cards.length === 0) {
  546. return false;
  547. }
  548. if (!TikTokAuto.flow.openCard(config, cards[0])) {
  549. return false;
  550. }
  551. /* 打开评论面板 */
  552. var button = TikTokAuto.tiktok.commentButton();
  553. if (!button) {
  554. log.warn('idsource', '播放页找不到评论按钮');
  555. return false;
  556. }
  557. TikTokAuto.ui.clickNode(button, 'idsource:comments');
  558. return TikTokAuto.wait.waitFor('idsource:panel', function () {
  559. return TikTokAuto.tiktok.commentPanelOpen() ? true : null;
  560. }, config.timeouts.panelReadyMs, config.probe.pollTickMs) === true;
  561. }
  562. /** 一句人话摘要(日志与状态条用) */
  563. function describe(source) {
  564. var parse = TikTokAuto.parse;
  565. if (source.kind === 'anchor') {
  566. var list = source.anchors ? source.anchors.split('#')[0] : '当前屏幕';
  567. return '账号 ' + list + ' 的 ' + (source.lists || []).join('+');
  568. }
  569. if (source.kind === 'search') {
  570. return (source.searchType === 'videos' ? '视频作者' : '用户') + '「' + source.keyword + '」';
  571. }
  572. if (source.kind === 'video') {
  573. return source.videoSource === 'keyword'
  574. ? '评论区「' + source.videoKeyword + '」'
  575. : '当前屏幕的评论区';
  576. }
  577. if (source.kind === 'text') {
  578. return '文本 ' + String(source.path).split('/').pop();
  579. }
  580. return '当前屏幕';
  581. }
  582. return {
  583. MAX_SCREENS: MAX_SCREENS,
  584. IDLE_SCREENS_TO_STOP: IDLE_SCREENS_TO_STOP,
  585. run: run,
  586. walkList: walkList,
  587. /* 纯函数,供 PC 回归(`tools/pc-selftest-id.js`)——
  588. * 停止判据是"翻不动了"而不是"没有新人",这条必须能被离线断言。 */
  589. walkStep: walkStep,
  590. readTextSource: readTextSource,
  591. describe: describe
  592. };
  593. }());