| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646 |
- /**
- * idsource.js — 五种「从哪儿找人」,以及深度展开
- *
- * ## 为什么是五种而不是四张脚本卡
- * 旧的四个脚本卡是「起点 × 深度」的 4 个**不完整组合**。把它们拆开后发现,
- * 真正不同的只有一件事:**候选 ID 从哪儿来**。于是收敛成五种起点
- * (指定账号 / 搜索结果 / 某个视频的评论区 / 文本文件 / 当前屏幕),
- * 深度(再取这些人的粉丝)是所有起点共用的一个选项。
- *
- * ## 三种遍历模式
- * 五种起点在"怎么走"上只有三种形态,实现都归到这三条路径上:
- * · **列表型**(指定账号 / 搜索用户 / 当前屏幕)—— 逐屏读行,可翻页;
- * · **文本型**(文本文件)—— 一次性读出每一行;
- * · **逐个点开型**(搜索视频作者 / 评论区)—— 屏幕上的条目没有 uid,
- * 只能点进去看(视频页底部有 `@uid`,评论者要靠头像进用户页)。
- *
- * ## 深度展开为什么分两阶段
- * "取这批人的粉丝"如果边遍历边展开,会嵌套两层列表上下文 ——
- * 打开 B 列表时 A 的滚动位置就丢了。所以做成:
- * ① 先把基础层收完,边收边记下"这些人要展开";
- * ② 再对这批人逐个打开其粉丝/关注列表。
- * 两层都线性,没有嵌套,也就没有"回到上一层时不知道走到哪"的问题。
- * **上限固定两层**:100 粉丝 × 每人 100 粉丝 = 1 万次开页,真机上不现实。
- *
- * 依赖:TikTokAuto.tiktok / profile / filters / parse / logger / wait
- */
- TikTokAuto.idsource = (function () {
- 'use strict';
- /** 翻一个列表最多翻多少屏。防止"永远有新内容"(推荐流)把一轮拖成无限长。 */
- var MAX_SCREENS = 60;
- /**
- * 连续这么多次"翻屏后内容不变"就认为到头了(有的列表末尾会重复推荐位)。
- *
- * ★ 注意判据是**翻不动**,不是"没有新 uid"(2026-10-28 修,见 walkStep 的注释)。
- */
- var IDLE_SCREENS_TO_STOP = 2;
- function logger() { return TikTokAuto.logger; }
- /**
- * 一屏处理完之后决定下一步。纯函数,便于在 PC 上回归(`tools/pc-selftest-id.js`)。
- *
- * ★ 这里修的是一个**真 bug**(2026-10-28)。
- *
- * 旧实现有两个地方同时给 `idle` 加一:
- * ① 本屏 `fresh` 为空(全是已收过的人)→ idle + 1
- * ② 翻屏后签名不变 → idle + 1
- * 而 `idflow` 会先把 `uid_extracted.txt` 预载进 `seen`。于是**第二轮跑同一个账号**时,
- * 前两屏全是历史 → idle=2 → **在列表顶部就判"到底"**,一条新人都拿不到。
- * 断点续跑要把上次的产物也预载进 `seen`,这个误判会从"偶发"变成"必然"。
- *
- * 正解:**只看"翻得动翻不动"**。一屏全是旧人说明"旧人多",不代表列表到头;
- * 只有"滚动之后签名没变"才是真的到头。
- *
- * @param state { screens, stuck }
- * @param view { sigBefore, sigAfter, rowsAfter, maxScreens }
- * @returns { action: 'continue'|'break', state, reason }
- */
- function walkStep(state, view) {
- var stuck = state.stuck;
- var screens = state.screens;
- if (view.rowsAfter === 0) {
- /* 翻完这一屏读不到行了:可能还在加载,也可能真的到头。
- * 不在这里下结论 —— 交给下一轮循环开头的"空行即结束"处理
- * (这样"加载慢"和"列表结束"两个判断只在一处)。 */
- return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
- }
- if (view.sigAfter === view.sigBefore) {
- stuck += 1;
- if (stuck >= IDLE_SCREENS_TO_STOP) {
- return {
- action: 'break',
- reason: '翻屏后内容不变,认为列表到底',
- state: { screens: screens, stuck: stuck }
- };
- }
- } else {
- /* 内容变了 → 列表还在往下走。**不管这一屏里有没有新人**都归零。 */
- stuck = 0;
- }
- if (screens >= view.maxScreens) {
- return {
- action: 'break',
- reason: '达到单列表翻屏上限 ' + view.maxScreens + ' 屏',
- state: { screens: screens, stuck: stuck }
- };
- }
- return { action: 'continue', reason: '', state: { screens: screens, stuck: stuck } };
- }
- /** 一行 uid 的临时载体(列表行带昵称与计数,用户页带更多资料,统一成这个形状) */
- function rowOf(uid, nickname, extra) {
- var one = { uid: uid, nickname: nickname || '' };
- if (extra) {
- for (var key in extra) {
- if (extra.hasOwnProperty(key)) {
- one[key] = extra[key];
- }
- }
- }
- return one;
- }
- /* ============================================================ 列表型 */
- /**
- * 在"一个可滚动的用户列表"上逐行推进。
- *
- * 关键设计:**用 uid 集合决定"哪一行还要处理",不用行下标**。
- * 因为每次点进用户页再退回来,列表的节点树都重建过一遍(引用失效),
- * 只有 uid 是稳的。
- *
- * 但**停止判据不能用 uid 集合** —— 那会把"全是旧人"误判成"列表到底"(见 walkStep)。
- *
- * @param ctx { seen, onRow(row), shouldStop(), maxScreens?, stat?, config? }
- * maxScreens:本次最多翻多少屏(续跑时按上次已翻的屏数放大,见 idflow)
- * stat:传入则回填 { screens, handled },供断点记录
- * @returns 处理了多少行(本屏新收的)
- */
- function walkList(ctx) {
- var log = logger();
- var handled = 0;
- var maxScreens = ctx.maxScreens || MAX_SCREENS;
- var state = { screens: 0, stuck: 0 };
- while (state.screens < maxScreens) {
- if (ctx.shouldStop()) {
- log.info('idsource', '达到停止条件,结束当前列表');
- break;
- }
- state.screens += 1;
- var rows = TikTokAuto.profile.listRows();
- if (rows.length === 0 && state.screens === 1) {
- /* 首屏还在加载。**等一次**再判定,否则会把"慢"当成"空":
- * 从用户页点开粉丝列表、或从结果页切到用户页签后,
- * 容器先出来、行后出来(实测差 1~2 秒),这时 listRows() 返回空数组 ——
- * 直接 break 就等于"一条都没抓到"。 */
- TikTokAuto.wait.waitFor('listRows', function () {
- return TikTokAuto.profile.listRows().length > 0 ? true : null;
- }, ctx.config ? ctx.config.timeouts.pageReadyMs : 8000, 300);
- rows = TikTokAuto.profile.listRows();
- }
- if (rows.length === 0) {
- log.info('idsource', '当前屏没有读到用户行,结束列表');
- break;
- }
- /* 一行一行处理:只处理没见过的人(见过的直接跳过,但**不因此判定列表到底**)。
- * 需要资料时会点进用户页再退回(实测位置保持)。 */
- var stopped = false;
- for (var i = 0; i < rows.length; i += 1) {
- if (ctx.shouldStop()) {
- stopped = true;
- break;
- }
- if (ctx.seen.has(rows[i].uid)) {
- continue;
- }
- ctx.onRow(rows[i]);
- ctx.seen.add(rows[i].uid); /* 处理过就算见过,避免同一行被反复点开 */
- handled += 1;
- }
- if (stopped) {
- break;
- }
- /* 翻一屏,用"内容有没有变"判断是否到头 */
- var before = signatureOfRows(rows);
- TikTokAuto.profile.scrollList();
- var afterRows = TikTokAuto.profile.listRows();
- var step = walkStep(state, {
- sigBefore: before,
- sigAfter: signatureOfRows(afterRows),
- rowsAfter: afterRows.length,
- maxScreens: maxScreens
- });
- state = step.state;
- if (step.action === 'break') {
- log.info('idsource', step.reason);
- break;
- }
- }
- if (ctx.stat) {
- ctx.stat.screens = state.screens;
- ctx.stat.handled = handled;
- }
- return handled;
- }
- /** 一屏 uid 的签名,用来判断"翻完到底有没有变" */
- function signatureOfRows(rows) {
- var parts = [];
- for (var i = 0; i < rows.length; i += 1) {
- parts.push(rows[i].uid);
- }
- return parts.join('|');
- }
- /* ============================================================ 文本型 */
- /**
- * 从本地文本文件读 uid。
- *
- * 两条容忍(都是为了让用户不用改自己的文件):
- * · 行首的 `@` 会被剥掉(有人习惯直接粘贴 "@name");
- * · `#` 开头的行当注释跳过。
- */
- function readTextSource(source) {
- var list = TikTokAuto.uidset.readUidFile(source.path);
- logger().info('idsource', '文本取材:' + source.path + ' 读到 ' + list.length + ' 行');
- var rows = [];
- for (var i = 0; i < list.length; i += 1) {
- rows.push(rowOf(list[i], ''));
- }
- return rows;
- }
- /* ============================================================ 逐个点开型 */
- /**
- * 搜索结果里的视频 → 逐个点开,读播放页底部的作者 uid。
- *
- * 实测:搜索结果的视频卡片上只有**作者昵称**,没有 uid;
- * 播放页底部有一个 `Button` 文案是 `@uid` —— 这是唯一能拿到 uid 的地方。
- * 所以每个视频要"点开 → 读 → 退回",约 6 秒一个。
- */
- function walkSearchVideos(source, ctx) {
- var log = logger();
- var handled = 0;
- var idle = 0;
- for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
- if (ctx.shouldStop()) {
- break;
- }
- var cards = TikTokAuto.tiktok.videoCards();
- if (cards.length === 0) {
- /* 0 张卡可能是"到底了",也可能是"列表正在重排/图片在加载"。
- * 先给 5 秒,等不到再判定到底 —— 直接 break 会把慢当成空。 */
- cards = TikTokAuto.flow.waitForCards(ctx.config, 5000) || [];
- }
- if (cards.length === 0) {
- log.info('idsource', '结果页没有卡片了');
- break;
- }
- var opened = 0;
- for (var i = 0; i < cards.length; i += 1) {
- if (ctx.shouldStop()) {
- return handled;
- }
- var card = cards[i];
- var marker = 'card:' + String(card.caption).slice(0, 40);
- if (ctx.seen.has(marker)) {
- continue;
- }
- ctx.seen.add(marker);
- if (!TikTokAuto.flow.openCard(ctx.config, card)) {
- log.warn('idsource', '打开视频失败:' + marker);
- TikTokAuto.flow.returnToResults(ctx.config);
- continue;
- }
- var uid = TikTokAuto.tiktok.playerAuthorUid();
- if (uid) {
- ctx.onRow(rowOf(uid, String(card.caption).slice(0, 30)));
- handled += 1;
- opened += 1;
- } else {
- log.warn('idsource', '播放页读不到作者 uid');
- }
- TikTokAuto.flow.returnToResults(ctx.config);
- }
- if (opened === 0) {
- idle += 1;
- if (idle >= IDLE_SCREENS_TO_STOP) {
- log.info('idsource', '连续几屏都读不到作者,结束');
- break;
- }
- } else {
- idle = 0;
- }
- TikTokAuto.flow.scrollResults(ctx.config);
- }
- return handled;
- }
- /**
- * 评论区 → 逐个点评论者的头像进用户页读 uid。
- *
- * 实测这条最贵:评论区只显示昵称,没有 uid(`Button title` 是昵称,
- * 旁边那个 `@uid` 是固定节点、不是评论者的)。所以每个评论者都要
- * "点头像 → 读 → 退回",约 8 秒一个。这也是为什么方案里
- * 把"评论者"和"评论者的粉丝"分开成两条取材,而不是默认都开。
- */
- function walkCommenters(source, ctx) {
- var log = logger();
- var handled = 0;
- for (var screen = 0; screen < MAX_SCREENS; screen += 1) {
- if (ctx.shouldStop()) {
- break;
- }
- var rows = TikTokAuto.tiktok.commentRowers();
- if (rows.length === 0) {
- log.info('idsource', '评论区没有更多可见条目');
- break;
- }
- var opened = 0;
- var before = signatureOfNodes(rows);
- for (var i = 0; i < rows.length; i += 1) {
- if (ctx.shouldStop()) {
- return handled;
- }
- /* 头像节点每次都会重建,用"位置 + 序号"当标记 —— 换一屏位置就会变 */
- var marker = 'cmt:' + rows[i].top + ':' + (screen * 100 + i);
- if (ctx.seen.has(marker)) {
- continue;
- }
- ctx.seen.add(marker);
- TikTokAuto.ui.tapPoint(rows[i].x, rows[i].y);
- /* ★ 用 waitProfile(等 @username + 三组数字),而不是 settle + where()。
- *
- * 早先这里是"等页面安静 3 秒,再看 where() 是不是 profile":
- * · settle 会提前返回(骨架先出、内容后加载,签名看起来没变);
- * · 而 where() 要求 `@username` 已经渲染出来。
- * 两头一凑,就成了"人明明已经进主页了,却被判成没进去"——
- * 实测日志里连续两条"点评论者头像没有进入用户主页",然后一条都没取到。
- */
- if (!TikTokAuto.profile.waitProfilePublic()) {
- log.warn('idsource', '点评论者头像没有进入用户主页(等了 10 秒)');
- continue;
- }
- var profile = TikTokAuto.profile.readProfile();
- if (profile.uid) {
- /* 资料已经在手上了,带上 `_profile` 让编排层不必再开一次用户页 */
- ctx.onRow(rowOf(profile.uid, profile.nickname, { _profile: profile }));
- handled += 1;
- opened += 1;
- }
- TikTokAuto.profile.backToCommentPanel();
- }
- if (opened === 0) {
- break;
- }
- /* 评论区滚动 */
- var layout = TikTokAuto.layout;
- var screenSize = layout.screen();
- TikTokAuto.ui.dragScreen(
- Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.8),
- Math.floor(screenSize.width * 0.5), Math.floor(screenSize.height * 0.4),
- 420
- );
- TikTokAuto.profile.settle(2000);
- if (signatureOfNodes(TikTokAuto.tiktok.commentRowers()) === before) {
- log.info('idsource', '评论区滚动后内容不变,结束');
- break;
- }
- }
- return handled;
- }
- function signatureOfNodes(rows) {
- var parts = [];
- for (var i = 0; i < rows.length; i += 1) {
- parts.push(rows[i].top);
- }
- return parts.join(',');
- }
- /* ============================================================ 入口 */
- /**
- * 把一个取材源跑完,逐行交给 `onRow`。
- *
- * @param source 取材项(与界面 sources.js 的形状一致)
- * @param ctx { config, seen, onRow(row), shouldStop() }
- * @returns 处理的行数
- */
- function run(source, ctx) {
- var log = logger();
- log.step('idsource', '取材开始:' + describe(source));
- if (source.kind === 'text') {
- /* 文本源也走 `onRow`,**不在这一层自己判去重** ——
- * 去重与过滤是编排层(idflow)的职责,在这里再做一遍会让
- * `duplicates` 统计漏掉(实测:跨轮去重跳了 3 个,summary 里却是 0)。 */
- var rows = readTextSource(source);
- var handled = 0;
- for (var i = 0; i < rows.length; i += 1) {
- if (ctx.shouldStop()) {
- break;
- }
- ctx.onRow(rows[i]);
- handled += 1;
- }
- return handled;
- }
- if (source.kind === 'screen') {
- /* 当前屏幕:不做任何导航,直接把眼前这一屏当列表读。
- *
- * 但要先确认眼前确实是"一个用户列表" —— 否则 walkList 会一屏一屏地读,
- * 最后报"处理 0 行",用户完全不知道是页面不对。 */
- var state = TikTokAuto.profile.where();
- if (!TikTokAuto.profile.isListPage(state)) {
- log.warn('idsource', '当前屏幕取材:需要先把页面停在「用户列表」(粉丝/关注/搜索用户)上,' +
- '引擎不会替你跳转。现在停在「' + state + '」');
- return 0;
- }
- return walkList(ctx);
- }
- if (source.kind === 'anchor') {
- return runAnchor(source, ctx);
- }
- if (source.kind === 'search') {
- if (source.searchType === 'videos') {
- if (!prepareSearch(source.keyword, ctx.config)) {
- return 0;
- }
- TikTokAuto.flow.selectResultTab({
- target: { resultTab: 'Top' },
- timeouts: ctx.config.timeouts,
- probe: ctx.config.probe
- });
- return walkSearchVideos(source, ctx);
- }
- if (!prepareSearch(source.keyword, ctx.config)) {
- return 0;
- }
- return walkList(ctx);
- }
- if (source.kind === 'video') {
- if (!prepareVideo(source, ctx.config)) {
- return 0;
- }
- return walkCommenters(source, ctx);
- }
- log.warn('idsource', '未知的取材起点:' + source.kind);
- return 0;
- }
- /**
- * 指定账号:打开它的主页 → 按 `lists` 逐个开列表 → 每处走一遍 walkList。
- *
- * `anchors` 为空表示"不限定账号,从当前屏幕取" —— 这是界面允许的状态,
- * 对应 v6 时代「主播 ID 留空」的语义。
- */
- function runAnchor(source, ctx) {
- var log = logger();
- var parse = TikTokAuto.parse;
- var anchors = String(source.anchors || '').split('#');
- var handled = 0;
- var accounts = [];
- for (var i = 0; i < anchors.length; i += 1) {
- var name = parse.username(anchors[i]);
- if (name !== '') {
- accounts.push(name);
- }
- }
- if (accounts.length === 0) {
- log.info('idsource', '没有指定账号 → 直接把当前屏幕当列表');
- return walkList(ctx);
- }
- for (var a = 0; a < accounts.length; a += 1) {
- if (ctx.shouldStop()) {
- break;
- }
- var profile = TikTokAuto.profile.openBySearch(accounts[a], ctx.config);
- if (!profile) {
- log.warn('idsource', '打不开账号 @' + accounts[a] + ',跳过');
- continue;
- }
- ctx.onSeenProfile(profile);
- var lists = source.lists && source.lists.length > 0 ? source.lists : ['fans'];
- for (var l = 0; l < lists.length; l += 1) {
- if (ctx.shouldStop()) {
- return handled;
- }
- var which = lists[l] === 'home' ? 'fans' : lists[l];
- if (!TikTokAuto.profile.openList(which, ctx.config)) {
- log.warn('idsource', '打不开 ' + lists[l] + ' 列表');
- continue;
- }
- TikTokAuto.profile.switchListTab(which);
- handled += walkList(ctx);
- TikTokAuto.profile.backToProfile();
- }
- }
- return handled;
- }
- /** 搜索关键词并停在结果页(供列表型与逐个点开型共用) */
- function prepareSearch(keyword, config) {
- var log = logger();
- if (!TikTokAuto.profile.ensureFeed(config)) {
- return false;
- }
- var tiktok = TikTokAuto.tiktok;
- var entry = tiktok.searchEntry();
- if (!entry) {
- log.error('idsource', '首页找不到搜索入口');
- return false;
- }
- TikTokAuto.ui.clickNode(entry, 'idsource:search');
- if (TikTokAuto.wait.waitFor('idsource:search', function () {
- var state = tiktok.idPageState();
- return (state === 'search' || state === 'users-results') ? true : null;
- }, config.timeouts.pageReadyMs, config.probe.pollTickMs) !== true) {
- return false;
- }
- var input = tiktok.searchInput();
- if (!input) {
- return false;
- }
- TikTokAuto.ui.clickNode(input, 'idsource:input');
- TikTokAuto.wait.tick(250);
- if (!TikTokAuto.ui.typeInto(tiktok.searchInput() || input, keyword, 'idsource:type').ok) {
- return false;
- }
- var submit = tiktok.searchSubmit();
- if (!submit) {
- return false;
- }
- TikTokAuto.ui.clickNode(submit, 'idsource:submit');
- if (TikTokAuto.wait.waitFor('idsource:results', function () {
- return tiktok.resultTabStripPresent() ? true : null;
- }, config.timeouts.searchResultsMs, config.probe.pollTickMs) !== true) {
- return false;
- }
- /* 用户搜索要切到 Users 页签;视频搜索留在 Top */
- var usersTab = tiktok.resultTab('Users');
- if (usersTab && !tiktok.resultTabSelected('Users')) {
- TikTokAuto.ui.clickNode(usersTab, 'idsource:usersTab');
- /* 等结果行真的出现(页签条先渲染、行后加载,只等"安静"会误判为空) */
- TikTokAuto.wait.waitFor('idsource:userRows', function () {
- return tiktok.usersResultRows().length > 0 ? true : null;
- }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
- }
- return true;
- }
- /**
- * 打开一个视频(播放页),供评论区取材用。
- * `videoSource = "screen"` 表示用户自己已经把视频停在屏幕上,不动它。
- */
- function prepareVideo(source, config) {
- var log = logger();
- if (source.videoSource !== 'keyword') {
- var state = TikTokAuto.tiktok.pageState();
- if (state === 'player' || state === 'comment-panel') {
- log.info('idsource', '已在播放页,直接用当前屏幕的视频');
- return true;
- }
- /* 这里的提示必须是**可操作**的:用户能做的只有三件事 —— 自己先把页面停好、
- * 改用关键词模式、或者换一个取材源。不能只说"不在播放页"。 */
- log.warn('idsource', '「当前屏幕」需要用你手上已经打开的那个视频,引擎不会替你跳转。\n' +
- ' 请先在 TikTok 里打开视频(或直接打开它的评论区),再点「开始运行」。\n' +
- ' 当前页面是「' + state + '」。');
- return false;
- }
- if (!prepareSearch(source.videoKeyword, config)) {
- return false;
- }
- /* Top 页签是视频;取第一张卡点开 */
- var cards = TikTokAuto.tiktok.videoCards();
- if (cards.length === 0 && !TikTokAuto.flow.waitForCards(config)) {
- return false;
- }
- cards = TikTokAuto.tiktok.videoCards();
- if (cards.length === 0) {
- return false;
- }
- if (!TikTokAuto.flow.openCard(config, cards[0])) {
- return false;
- }
- /* 打开评论面板 */
- var button = TikTokAuto.tiktok.commentButton();
- if (!button) {
- log.warn('idsource', '播放页找不到评论按钮');
- return false;
- }
- TikTokAuto.ui.clickNode(button, 'idsource:comments');
- return TikTokAuto.wait.waitFor('idsource:panel', function () {
- return TikTokAuto.tiktok.commentPanelOpen() ? true : null;
- }, config.timeouts.panelReadyMs, config.probe.pollTickMs) === true;
- }
- /** 一句人话摘要(日志与状态条用) */
- function describe(source) {
- var parse = TikTokAuto.parse;
- if (source.kind === 'anchor') {
- var list = source.anchors ? source.anchors.split('#')[0] : '当前屏幕';
- return '账号 ' + list + ' 的 ' + (source.lists || []).join('+');
- }
- if (source.kind === 'search') {
- return (source.searchType === 'videos' ? '视频作者' : '用户') + '「' + source.keyword + '」';
- }
- if (source.kind === 'video') {
- return source.videoSource === 'keyword'
- ? '评论区「' + source.videoKeyword + '」'
- : '当前屏幕的评论区';
- }
- if (source.kind === 'text') {
- return '文本 ' + String(source.path).split('/').pop();
- }
- return '当前屏幕';
- }
- return {
- MAX_SCREENS: MAX_SCREENS,
- IDLE_SCREENS_TO_STOP: IDLE_SCREENS_TO_STOP,
- run: run,
- walkList: walkList,
- /* 纯函数,供 PC 回归(`tools/pc-selftest-id.js`)——
- * 停止判据是"翻不动了"而不是"没有新人",这条必须能被离线断言。 */
- walkStep: walkStep,
- readTextSource: readTextSource,
- describe: describe
- };
- }());
|