|
|
@@ -1,5 +1,5 @@
|
|
|
/*! tiktok-auto v1.0.0
|
|
|
- * built 2026-10-28T09:52:51.438Z
|
|
|
+ * built 2026-10-28T10:49:20.750Z
|
|
|
* modules: logger, bridge, config, wait, layout, ui, popups, tiktok, player, comment, interact, flow, parse, uidset, filters, profile, idsource, resolve, idflow, main
|
|
|
* 由 tools/build.js 自动生成 —— 请不要直接编辑本文件,改 src/ 后重新构建。
|
|
|
*/
|
|
|
@@ -557,7 +557,14 @@ TikTokAuto.config = (function () {
|
|
|
timeouts: {
|
|
|
appLaunchMs: 25000,
|
|
|
pageReadyMs: 15000,
|
|
|
- searchResultsMs: 20000,
|
|
|
+ /**
|
|
|
+ * 搜索结果首屏 / 切页签重刷的等待上限。
|
|
|
+ *
|
|
|
+ * 2026-10-28 实测:关键词 `cat` 从点搜索到网格出现约 **18 秒**
|
|
|
+ * (8 秒时截图还是空的,再等 10 秒才有卡)。原来 20 秒只剩 2 秒余量,
|
|
|
+ * 网络稍慢就会判成"没有可用卡片"。放到 30 秒。
|
|
|
+ */
|
|
|
+ searchResultsMs: 30000,
|
|
|
playerReadyMs: 20000,
|
|
|
playbackStartMs: 12000,
|
|
|
panelReadyMs: 8000,
|
|
|
@@ -1730,6 +1737,15 @@ TikTokAuto.tiktok = (function () {
|
|
|
var TOOL_ROW_ANCHORS = ['Stickers', 'Mention someone', 'Microphone', '表情', '提及', '语音'];
|
|
|
var RESULT_TABS = ['Top', 'Users', 'Videos', 'Sounds', 'Shop', 'LIVE', 'Places', 'Photos'];
|
|
|
|
|
|
+ /**
|
|
|
+ * 结果页卡片搜索区的上边界(占屏高比例)。
|
|
|
+ *
|
|
|
+ * 实测布局:搜索框 → 页签栏 y 152~232 → GridView 从 y=232 开始(屏高 1920)。
|
|
|
+ * 取 0.13(=250)刚好卡在网格起始线下方,把搜索框和页签排掉。
|
|
|
+ * 详见 videoCards() 的注释(用 0.3 会漏掉滚动后的上排卡片)。
|
|
|
+ */
|
|
|
+ var ROWS_TOP_RATIO = 0.13;
|
|
|
+
|
|
|
var layout = TikTokAuto.layout; // 常量引用,模块加载顺序由 build.js 保证
|
|
|
var logger = TikTokAuto.logger;
|
|
|
|
|
|
@@ -1862,7 +1878,11 @@ TikTokAuto.tiktok = (function () {
|
|
|
var depth = 0;
|
|
|
while (cursor && depth < 6) {
|
|
|
var cardHeight = bottomOf(cursor) - topOf(cursor);
|
|
|
- if (cardHeight >= height * 0.18 && cardHeight <= height * 0.8) {
|
|
|
+ /* 上限从 0.8 放宽到 1.05(2026-10-28):卡片滚动到贴着屏幕上下边缘时,
|
|
|
+ * 容器会有一部分在屏幕外,`bounds()` 报的高度于是**被裁切得又大又怪**,
|
|
|
+ * 实测超过 80% 后被这里挡掉 → 整排卡片变成"找不到容器" → 上层误判
|
|
|
+ * "卡片用尽"。点击点由 `layout.center` 用 visibleRect 夹取,仍然安全。 */
|
|
|
+ if (cardHeight >= height * 0.18 && cardHeight <= height * 1.05) {
|
|
|
return cursor;
|
|
|
}
|
|
|
cursor = layout.clickableAncestor(cursor);
|
|
|
@@ -1872,23 +1892,84 @@ TikTokAuto.tiktok = (function () {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 结果页的视频卡片列表。
|
|
|
+ * 这个节点是不是在"结果网格"里(GridView / RecyclerView 的子孙)。
|
|
|
*
|
|
|
- * 判据(都来自实测踩坑):
|
|
|
+ * 为什么需要:结果页上会浮出各种**不是视频卡但同样又宽又有文案**的东西 ——
|
|
|
+ * 授权弹窗的 `Customize Settings` / `Confirm My Choices`、商品卡的 `Shop now`、
|
|
|
+ * 「Users」分区的表头。它们能通过"宽 + 有文案"的判据,被当成卡片点开白等 20 秒。
|
|
|
+ * 而真正的结果卡片一定在滚动容器里,弹窗不出现在其中。
|
|
|
+ */
|
|
|
+ function insideResultGrid(node) {
|
|
|
+ var cursor = node;
|
|
|
+ for (var depth = 0; depth < 8; depth += 1) {
|
|
|
+ try {
|
|
|
+ cursor = cursor.parent();
|
|
|
+ } catch (ignored) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (!cursor) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ var name = '';
|
|
|
+ try {
|
|
|
+ name = String(cursor.className() || '');
|
|
|
+ } catch (ignoredToo) {
|
|
|
+ name = '';
|
|
|
+ }
|
|
|
+ if (name.indexOf('GridView') >= 0 || name.indexOf('RecyclerView') >= 0) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 当前页面是"照片轮播帖"吗(不是视频)?
|
|
|
+ *
|
|
|
+ * 真机实测(2026-10-28):照片帖的信息行是「`Photo` · `1w ago`」两个独立 TextView,
|
|
|
+ * 视频帖是「`Video` · …」。照片帖**没有播放页**,`onPlayerPage()` 也认不出来,
|
|
|
+ * 于是 openCard 会白等满 20 秒(playerReadyMs)再失败。
|
|
|
+ *
|
|
|
+ * 判据限定文案**完全等于** `Photo` 且位于屏幕下半部分(信息行),
|
|
|
+ * 避免误伤说明文字里出现 Photo 的普通视频。
|
|
|
+ */
|
|
|
+ function photoPost() {
|
|
|
+ var height = screenHeight();
|
|
|
+ var hits = findByLabel(className('android.widget.TextView'), function (node) {
|
|
|
+ return (layout.text(node) || '') === 'Photo' && topOf(node) > height * 0.5;
|
|
|
+ });
|
|
|
+ return hits.length > 0 ? hits[0] : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 结果网格里当前可见的卡片(按屏幕从上到下)。
|
|
|
+ *
|
|
|
+ * 判据(2026-10-28 用 AutoX 无障碍树真机核对过):
|
|
|
* - 卡片文案是一条"宽"控件(≥35%W),据此把右侧竖排的点赞/评论/分享按钮排掉;
|
|
|
- * - top > 30%H,据此排掉顶部搜索框;
|
|
|
- * - 底部被导航栏切掉半截的先不收,避免点到一半的位置。
|
|
|
+ * - **必须在 GridView/RecyclerView 里**(排掉浮层/弹窗按钮,如 `Customize Settings`);
|
|
|
+ * - **必须能找到一个"卡片尺寸"的可点容器**(`cardContainer` 找不到时会退回节点
|
|
|
+ * 本身,那种情况说明它只是一个孤立按钮,不是卡片);
|
|
|
+ * - 可见性看**容器**,不看文案。
|
|
|
+ *
|
|
|
+ * ★ 为什么不再要求"文案在屏幕内"(这次最关键的修正):
|
|
|
+ * 卡片的文案 Button 在**卡片底部**,而卡片很高(实测一张 8,1242 → 536,1920+)。
|
|
|
+ * 结果页上只要多一条通栏广告或一行筛选 chips,网格就被推下去,文案跟着落到
|
|
|
+ * **屏幕外**(实测 y=2099 > 1920)。旧的 `bottomOf(node) <= height - 12` 于是把这些
|
|
|
+ * **明明看得见、点得着**的卡片全部丢掉 —— 表现成"只处理了 1 个就说卡片用尽"。
|
|
|
+ *
|
|
|
+ * 正确做法:文案照读(无障碍树里它在,只是不在可视区),能不能点由**容器**决定;
|
|
|
+ * 点击点由 `layout.center` 用 `visibleRect` 夹取,永远落在屏幕内。
|
|
|
*/
|
|
|
function videoCards() {
|
|
|
var width = screenWidth();
|
|
|
var height = screenHeight();
|
|
|
+ var gridTop = height * ROWS_TOP_RATIO; /* 页签栏底部,网格从这里开始 */
|
|
|
+ var minVisible = height * 0.08; /* 露出来的太少就不点(点下去容易点到邻居) */
|
|
|
var cards = [];
|
|
|
var seen = {};
|
|
|
|
|
|
var hits = findByLabel(className('android.widget.Button'), function (node) {
|
|
|
- return widthOf(node) >= width * 0.35 &&
|
|
|
- topOf(node) > height * 0.3 &&
|
|
|
- bottomOf(node) <= height - 12;
|
|
|
+ return widthOf(node) >= width * 0.35 && insideResultGrid(node);
|
|
|
});
|
|
|
|
|
|
for (var i = 0; i < hits.length; i += 1) {
|
|
|
@@ -1896,16 +1977,35 @@ TikTokAuto.tiktok = (function () {
|
|
|
if (!caption) {
|
|
|
continue;
|
|
|
}
|
|
|
- var key = caption + '@' + topOf(hits[i]);
|
|
|
+ var container = cardContainer(hits[i]);
|
|
|
+ if (!container || container === hits[i]) {
|
|
|
+ /* 找不到卡片尺寸的可点容器 → 这是个孤立按钮(弹窗按钮、分区表头等) */
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ var boxTop = topOf(container);
|
|
|
+ var boxBottom = bottomOf(container);
|
|
|
+ if (boxBottom <= gridTop) {
|
|
|
+ /* 整个容器都在页签栏之上 —— 不是结果卡片 */
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (boxTop >= height) {
|
|
|
+ /* 完全在屏幕下方,看不见也点不着 */
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (Math.min(boxBottom, height) - Math.max(boxTop, gridTop) < minVisible) {
|
|
|
+ /* 只露出一条缝,点下去很容易落到相邻卡片上 */
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ var key = caption + '@' + boxTop;
|
|
|
if (seen[key]) {
|
|
|
continue;
|
|
|
}
|
|
|
seen[key] = true;
|
|
|
cards.push({
|
|
|
anchor: hits[i],
|
|
|
- container: cardContainer(hits[i]),
|
|
|
+ container: container,
|
|
|
caption: caption,
|
|
|
- top: topOf(hits[i])
|
|
|
+ top: boxTop
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -2792,6 +2892,7 @@ TikTokAuto.tiktok = (function () {
|
|
|
authorAvatar: authorAvatar,
|
|
|
followState: followState,
|
|
|
onPlayerPage: onPlayerPage,
|
|
|
+ photoPost: photoPost,
|
|
|
backButton: backButton,
|
|
|
commentCount: commentCount,
|
|
|
inlineInput: inlineInput,
|
|
|
@@ -3084,6 +3185,30 @@ TikTokAuto.player = (function () {
|
|
|
function waitPlayerReady(config, step) {
|
|
|
var logger = TikTokAuto.logger;
|
|
|
var tiktok = TikTokAuto.tiktok;
|
|
|
+
|
|
|
+ /* 先花最多 4 秒分辨"打开的是不是视频"。
|
|
|
+ *
|
|
|
+ * 搜索结果里混着照片轮播帖,它的信息行是 `Photo · 1w ago`,**永远不会有播放页**,
|
|
|
+ * 而 `onPlayerPage()` 也认不出来 —— 不提前判掉就要白等满 playerReadyMs(20 秒)。
|
|
|
+ * 真机实测:一轮 4 个目标里撞到 1 个照片帖,就白白浪费掉 20 秒。
|
|
|
+ *
|
|
|
+ * 认出是视频(或两种都不是)之后仍走原来的等待,不改变正常路径的行为。
|
|
|
+ */
|
|
|
+ var kind = TikTokAuto.wait.waitFor(step + ':kind', function () {
|
|
|
+ if (tiktok.onPlayerPage()) {
|
|
|
+ return 'player';
|
|
|
+ }
|
|
|
+ if (tiktok.photoPost()) {
|
|
|
+ return 'photo';
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }, 4000, config.probe.pollTickMs);
|
|
|
+
|
|
|
+ if (kind === 'photo') {
|
|
|
+ logger.info(step, '打开的是照片帖(没有播放页),跳过');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
var ready = TikTokAuto.wait.waitFor(step, function () {
|
|
|
return tiktok.onPlayerPage() ? true : null;
|
|
|
}, config.timeouts.playerReadyMs, config.probe.pollTickMs);
|
|
|
@@ -3650,13 +3775,19 @@ TikTokAuto.flow = (function () {
|
|
|
}, config.timeouts.searchResultsMs, config.probe.pollTickMs) === true;
|
|
|
}
|
|
|
|
|
|
- /** 等结果网格真的渲染出卡片(而不是页面框架出来了就算完) */
|
|
|
- function waitForCards(config) {
|
|
|
+ /**
|
|
|
+ * 等结果网格真的渲染出卡片(而不是页面框架出来了就算完)。
|
|
|
+ *
|
|
|
+ * @param timeoutMs 可选。不传用 `searchResultsMs`(20 秒,够首屏/切页签重刷)。
|
|
|
+ * 主循环里"滚动之前"的那次只值几秒 —— 到底时不该白等 20 秒。
|
|
|
+ * @returns 卡片数组(成功)或 null(超时)
|
|
|
+ */
|
|
|
+ function waitForCards(config, timeoutMs) {
|
|
|
var step = 'waitForCards';
|
|
|
return TikTokAuto.wait.waitFor(step, function () {
|
|
|
var cards = TikTokAuto.tiktok.videoCards();
|
|
|
return cards.length > 0 ? cards : null;
|
|
|
- }, config.timeouts.searchResultsMs, config.probe.pollTickMs);
|
|
|
+ }, timeoutMs || config.timeouts.searchResultsMs, config.probe.pollTickMs);
|
|
|
}
|
|
|
|
|
|
function selectResultTab(config) {
|
|
|
@@ -3665,16 +3796,36 @@ TikTokAuto.flow = (function () {
|
|
|
if (!name) {
|
|
|
return false;
|
|
|
}
|
|
|
- var tab = TikTokAuto.tiktok.resultTab(name);
|
|
|
+ var tiktok = TikTokAuto.tiktok;
|
|
|
+
|
|
|
+ /* ★ 已经是目标页签就**不要点**(2026-10-28 实测)。
|
|
|
+ *
|
|
|
+ * 「Top」是结果页的默认页签,而我们默认配置的 resultTab 也是 Top ——
|
|
|
+ * 等于每次运行都会点一次当前页签。点已选中的页签会让 TikTok 重刷整个网格:
|
|
|
+ * 卡片先消失、再重新渲染,中间那段时间 videoCards() 返回 0。
|
|
|
+ * 后面 `waitQuiet` 又判断不出"列表正在重刷"(签名和点之前一样),
|
|
|
+ * 于是直接进了主循环 → 读到 0 张卡 → 滚一下 → 还是 0 → 报「已无更多卡片」。
|
|
|
+ * 整个流程零报错地失败,看着像"这个关键词没有视频"。
|
|
|
+ */
|
|
|
+ if (tiktok.resultTabSelected(name)) {
|
|
|
+ logger().info(step, '页签已经是 ' + name + ',跳过点击(点了会重刷列表)');
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ var tab = tiktok.resultTab(name);
|
|
|
if (!tab) {
|
|
|
logger().warn(step, '找不到页签 ' + name + ',沿用当前页签');
|
|
|
return false;
|
|
|
}
|
|
|
logger().info(step, '切换到页签 ' + name);
|
|
|
TikTokAuto.ui.clickNode(tab, step);
|
|
|
- TikTokAuto.wait.waitQuiet(function () {
|
|
|
- return TikTokAuto.layout.signature();
|
|
|
- }, { maxMs: 4000, tickMs: 250, stableTimes: 3 });
|
|
|
+
|
|
|
+ /* 切页签一定会重刷网格 → 判据必须是"卡片真的重新出现了",
|
|
|
+ * 不能再用 waitQuiet(它会把"还在加载"当成"已经安静")。 */
|
|
|
+ if (!waitForCards(config)) {
|
|
|
+ logger().warn(step, '切到 ' + name + ' 后没等到卡片');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
return true;
|
|
|
}
|
|
|
|
|
|
@@ -3695,18 +3846,76 @@ TikTokAuto.flow = (function () {
|
|
|
|
|
|
// ------------------------------------------------------------ 选片与观看
|
|
|
|
|
|
- function chooseCard(config, cards, visited) {
|
|
|
+ /**
|
|
|
+ * 从当前屏的卡片里挑一张没试过的。
|
|
|
+ *
|
|
|
+ * @param visited 已处理过的 caption 列表(用于跨屏去重)
|
|
|
+ * @param tried 已尝试过的**容器矩形**列表
|
|
|
+ *
|
|
|
+ * ★ 为什么还要按矩形去重(2026-10-28 修):
|
|
|
+ * 同一张卡片的文案在不同时刻会读到**不同的截断**:一次是 `swagger #fashion …`,
|
|
|
+ * 另一次是 `… #fashion #outfitinspo #fyp`(前面被省略号截掉)。按 caption 去重认不出
|
|
|
+ * 是同一张,于是"点开→不是视频→跳过→又点到它"循环 11 次才被 guard 拦住。
|
|
|
+ * 容器矩形在没滚动时是稳定的,用它兜住这一层。
|
|
|
+ *
|
|
|
+ * 也去掉了"全都试过就退回全量再挑一遍"的兜底 —— 那个兜底正是死循环的入口。
|
|
|
+ * 全试过时返回 null,由调用方决定是滚动还是收工。
|
|
|
+ */
|
|
|
+ function chooseCard(config, cards, visited, tried) {
|
|
|
var fresh = [];
|
|
|
for (var i = 0; i < cards.length; i += 1) {
|
|
|
- if (visited.indexOf(cards[i].caption) === -1) {
|
|
|
- fresh.push(cards[i]);
|
|
|
+ if (visited.indexOf(cards[i].caption) !== -1) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (cardAlreadyTried(cards[i], tried)) {
|
|
|
+ continue;
|
|
|
}
|
|
|
+ fresh.push(cards[i]);
|
|
|
+ }
|
|
|
+ if (fresh.length === 0) {
|
|
|
+ return null;
|
|
|
}
|
|
|
- var pool = fresh.length > 0 ? fresh : cards;
|
|
|
if (config.target.pickStrategy === 'first') {
|
|
|
- return pool[0];
|
|
|
+ return fresh[0];
|
|
|
+ }
|
|
|
+ return fresh[Math.floor(Math.random() * fresh.length)];
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 容器矩形是否已在"试过"名单里(容差 40px,容忍布局微抖) */
|
|
|
+ function cardAlreadyTried(card, tried) {
|
|
|
+ if (!tried || tried.length === 0) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ var box = null;
|
|
|
+ try {
|
|
|
+ box = card.container.bounds();
|
|
|
+ } catch (ignored) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (!box) {
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ for (var i = 0; i < tried.length; i += 1) {
|
|
|
+ var one = tried[i];
|
|
|
+ if (Math.abs(one.left - box.left) <= 40 &&
|
|
|
+ Math.abs(one.top - box.top) <= 40 &&
|
|
|
+ Math.abs(one.right - box.right) <= 40) {
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** 把一张卡的容器矩形记进"试过"名单 */
|
|
|
+ function rememberCard(card, tried) {
|
|
|
+ try {
|
|
|
+ var box = card.container.bounds();
|
|
|
+ if (box) {
|
|
|
+ tried.push({ left: box.left, top: box.top, right: box.right });
|
|
|
+ }
|
|
|
+ } catch (ignored) {
|
|
|
+ /* 记不上就记不上,caption 那一层去重仍在 */
|
|
|
}
|
|
|
- return pool[Math.floor(Math.random() * pool.length)];
|
|
|
}
|
|
|
|
|
|
function openCard(config, card) {
|
|
|
@@ -3729,15 +3938,31 @@ TikTokAuto.flow = (function () {
|
|
|
var tiktokLayout = TikTokAuto.layout;
|
|
|
var deadline = Date.now() + config.timeouts.pageReadyMs;
|
|
|
var backs = 0;
|
|
|
+ var unknownStreak = 0;
|
|
|
|
|
|
- while (Date.now() < deadline && backs < 5) {
|
|
|
+ while (Date.now() < deadline && backs < 6) {
|
|
|
var state = tiktok.pageState();
|
|
|
if (state === 'results') {
|
|
|
return true;
|
|
|
}
|
|
|
if (state === 'unknown') {
|
|
|
- TikTokAuto.wait.tick(500);
|
|
|
- continue;
|
|
|
+ /* ★ 连续 unknown 也要按返回(2026-10-28 修)。
|
|
|
+ *
|
|
|
+ * 原来是"unknown 就只等 500ms、绝不按返回",于是卡在
|
|
|
+ * **照片轮播帖 / 商品页**这类 pageState 认不出的页面时,
|
|
|
+ * 15 秒预算会被 500ms 的等待耗完,一次返回都没按过,
|
|
|
+ * 最后报「未能回到结果页,跳过后续取片」——整个循环就此中断。
|
|
|
+ *
|
|
|
+ * 页面刚切换的头 1.5 秒确实是 unknown(还在渲染),所以先让两次;
|
|
|
+ * 之后就必须动。
|
|
|
+ */
|
|
|
+ unknownStreak += 1;
|
|
|
+ if (unknownStreak < 3) {
|
|
|
+ TikTokAuto.wait.tick(500);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ unknownStreak = 0;
|
|
|
}
|
|
|
backs += 1;
|
|
|
log.info(step, '当前在 ' + state + ',返回上一页');
|
|
|
@@ -3786,6 +4011,10 @@ TikTokAuto.flow = (function () {
|
|
|
likesRequested: config.likes && config.likes.enabled === true,
|
|
|
followsRequested: config.follows && config.follows.enabled === true,
|
|
|
details: [],
|
|
|
+ /* 不算失败但值得说的事(例:搜索结果里混着商品卡,点开不是视频就跳过)。
|
|
|
+ * 必须在这里初始化 —— 后面直接 push,不初始化就是运行期 TypeError,
|
|
|
+ * 而 AutoX 上这种异常不弹窗、不报错,只表现成"流程莫名停住"。 */
|
|
|
+ warnings: [],
|
|
|
errors: []
|
|
|
};
|
|
|
var total = config.target.maxVideos;
|
|
|
@@ -3814,6 +4043,7 @@ TikTokAuto.flow = (function () {
|
|
|
selectResultTab(config);
|
|
|
|
|
|
var visited = [];
|
|
|
+ var tried = [];
|
|
|
var usedComments = [];
|
|
|
var guard = 0;
|
|
|
|
|
|
@@ -3824,20 +4054,76 @@ TikTokAuto.flow = (function () {
|
|
|
summary.attemptedVideos, total);
|
|
|
|
|
|
var cards = TikTokAuto.tiktok.videoCards();
|
|
|
+ if (cards.length === 0) {
|
|
|
+ /* ★ 先"等"再"滚"(2026-10-28 修)。
|
|
|
+ *
|
|
|
+ * 读到 0 张卡有两种可能:① 真的没有更多了 ② 列表正在重排/重刷
|
|
|
+ * (切页签、刚翻过一页、图片加载中)。直接滚会把 ② 当成 ① 而收工。
|
|
|
+ * 这里只给 5 秒 —— 真的到底时不该白等满 20 秒。
|
|
|
+ */
|
|
|
+ cards = waitForCards(config, 5000) || [];
|
|
|
+ }
|
|
|
if (cards.length === 0) {
|
|
|
scrollResults(config);
|
|
|
- cards = TikTokAuto.tiktok.videoCards();
|
|
|
+ cards = waitForCards(config) || [];
|
|
|
if (cards.length === 0) {
|
|
|
- summary.errors.push('结果页已无更多卡片');
|
|
|
+ /* ★ 卡片用尽不是"错误"(2026-10-28 改)。
|
|
|
+ *
|
|
|
+ * 搜索结果少的时候(例:关键词 cat 只有 3~4 张视频卡,
|
|
|
+ * 中间还夹着 1 张商品卡)会正常用完。以前这里 push 一条 errors,
|
|
|
+ * 于是"处理了 3 个视频、目标 4 个"看起来像出了故障。
|
|
|
+ *
|
|
|
+ * 真正该报错的是**一个都没收到** —— 那说明选词/解析真有问题。
|
|
|
+ */
|
|
|
+ if (summary.attemptedVideos === 0) {
|
|
|
+ summary.errors.push('结果页没有可用的视频卡片');
|
|
|
+ } else {
|
|
|
+ summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
|
|
|
+ summary.attemptedVideos + ' 个(目标 ' + total +
|
|
|
+ ' 个),换个关键词或调小目标数量');
|
|
|
+ }
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ var card = chooseCard(config, cards, visited, tried);
|
|
|
+
|
|
|
+ /* 当前屏的卡片全试过了(含"试过但打不开"的)→ 滚一屏再看。
|
|
|
+ * 绝不能像旧版那样"全都试过就退回全量再挑" —— 那是死循环的入口:
|
|
|
+ * 同一张照片帖会被反复点开、反复跳过,直到 guard 用完。 */
|
|
|
+ if (!card) {
|
|
|
+ scrollResults(config);
|
|
|
+ cards = waitForCards(config) || [];
|
|
|
+ card = chooseCard(config, cards, visited, tried);
|
|
|
+ if (!card) {
|
|
|
+ if (summary.attemptedVideos === 0) {
|
|
|
+ summary.errors.push('结果页没有可用的视频卡片');
|
|
|
+ } else {
|
|
|
+ summary.warnings.push('结果页卡片已用尽:本轮处理 ' +
|
|
|
+ summary.attemptedVideos + ' 个(目标 ' + total +
|
|
|
+ ' 个),换个关键词或调小目标数量');
|
|
|
+ }
|
|
|
break;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- var card = chooseCard(config, cards, visited);
|
|
|
visited.push(card.caption);
|
|
|
+ rememberCard(card, tried);
|
|
|
|
|
|
if (!openCard(config, card)) {
|
|
|
- summary.errors.push('打开视频失败:' + String(card.caption).slice(0, 30));
|
|
|
+ /* ★ 打不开 ≠ 出错(2026-10-28 实测)。
|
|
|
+ *
|
|
|
+ * 搜索结果里混着两种"长得像视频卡"的东西:
|
|
|
+ * · 广告**视频** —— 点进去是正常播放页(有 Ad 标记 + Shop now),能正常看
|
|
|
+ * · 照片轮播帖 / 商品卡 —— 根本没有播放页
|
|
|
+ * (照片帖约 2 秒就能判出来,见 player.waitPlayerReady 的 :kind 预判)
|
|
|
+ *
|
|
|
+ * 以前这里 push 一条 errors,于是"4 个视频里有 1 张照片帖"会让整轮
|
|
|
+ * 记录看起来像出了故障。现在改成:记一条 warning、跳过、继续下一张。
|
|
|
+ */
|
|
|
+ summary.warnings.push('跳过非视频卡片:' + String(card.caption).slice(0, 30));
|
|
|
+ logger().info('flow', '点开后没有播放页,当作非视频卡片跳过:' +
|
|
|
+ String(card.caption).slice(0, 30));
|
|
|
returnToResults(config);
|
|
|
continue;
|
|
|
}
|
|
|
@@ -5542,6 +5828,11 @@ TikTokAuto.idsource = (function () {
|
|
|
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;
|