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