/** * ============================================================================ * TikTok 视频数据抓取 —— 纯接口(API)版,不使用无障碍权限 * 目标链接: https://www.tiktok.com/t/ZTy8r7T2a/ * 目标视频: @marcgagilang/video/7552202593888603410 * 抓取内容: 点赞(digg) / 评论数(comment) / 分享(share) / 收藏(collect) / 播放(play) * + 全量评论内容 * ★ 全程不携带登录态:计数走公开 SSR、评论走第三方镜像,两者都不读 Cookie。 * 登录既无收益,且「登录态 + 轮换 IP」会造成同账号跨地区跳变,是更强的风控特征。 * * 【实测结论 —— 2026-09-23 在云机(106.119.179.124:23098)上逐条验证】 * ── 网络环境 ────────────────────────────────────────────── * · 云机跑 NekoBox(moe.nb4a) VPN,网卡 tun0,本地代理 127.0.0.1:2080 * · shell 里 curl 默认走 eth0 → TLS 被 Reset(连不上 tiktok) * · Auto.js 走系统 VPN → 直连即可,一般无需配代理 * * ── 计数指标(公开 SSR,无需签名、无需登录,已实测)────────── * ✓ https://m.tiktok.com/v/.html SSR 完整,含 collectCount * → diggCount=2400000 shareCount=1200000 commentCount=18900 * playCount=14800000 collectCount=246728 * ✓ https://www.tiktok.com/embed/v2/ 备用(无 collectCount) * ✗ https://www.tiktok.com/video/ 常返回 SPA 空壳,无 SSR 数据 * * ── 评论接口(官方全部被拦 → 调用代码已于 2026-09-25 移除)── * ✗ www.tiktok.com/api/comment/list/ → HTTP 200 但 Content-Length: 0 * (缺 A-Bogus/X-Bogus 签名;补 msToken 二次握手仍为空) * ✗ m.tiktok.com/api/comment/list/ → 403 {"code":40006,"no schema found"} * ✗ api16/api19/.../comment/list/ (musically) → 403(Argus 风控) * ✗ 同上 app_name=tiktok_web → 2146 "Please upgrade your app" * (与 cookie/UA/version_code 无关,死路 —— 挂登录态也解决不了) * ✓ 镜像接口 www.tikwm.com * · /api/comment/list 顶级评论,total=18752,单页上限 50,实测可翻到 cursor≈10369 * · /api/comment/reply 子回复,需 video_id + comment_id(url 参数不需要) * * 【tikwm 的 6 个坑 —— 2026-09-24/25 实测】 * 1. hasMore 会偶发「假 false」:cursor=400 返回 false,450/500 却依然有数据。 * ★ 用它当终止条件会在第 9 页提前收工(旧版只拿到 426 条,而实际能拿 1 万+) * 2. ★ 限流只按「并发」不按「时间」: * 并发 1(串行) 0.2s 间隔连打 20 次 → 20/20 全成功 * 并发 2 → 2/2 | 并发 3 → 2/3 | 并发 5 → 3/5(code=-1) * 触发后无需等待,下一次即刻恢复。→ 结论:串行是硬约束,间隔只是「礼貌」 * 3. total 字段不可信:cursor≥15000 时会退化成 cursor 本身 * 4. count 上限 50:传 100/200 也只返回 49 条 * 5. 顶级评论翻页深度上限 cursor≈10400(约 1 万条),拿不到声明的 1.8 万 * 6. ★ 正确的限速维度是「请求数/小时」而非「视频个数」:评论量差异达 20 倍 * (500 条=10 请求 / 9931 条=199 请求)→ 故用两级限速: * ① 令牌桶 targetReqPerHour(跨视频累计,小时级硬上界) * ② 视频间动态冷却 videoCooldownMinMs + 请求数 × videoCooldownPerReqMs * 另:换 IP 治的是「被标记后的恢复」,治不了 tikwm 上游被限(那时所有视频一起失败) * * 【运行方式】 * adb push tiktok_stats.js /sdcard/Download/tiktok_stats.js * Auto.js Pro 导入运行(仅需悬浮窗权限看日志,无需无障碍、无需 root) * ============================================================================ */ try { console.show(); } catch (e) {} console.log("==== TikTok 纯接口抓取开始 ===="); // ============================== CONFIG ====================================== var CONFIG = { shareUrl: "https://www.tiktok.com/t/ZTy8r7T2a/", awemeId: "7552202593888603410", // 已知可直填;留空则自动解析短链 // ★ 本脚本不携带任何登录态 —— 已验证 Cookie 对「计数」与「评论」两条链路均零贡献 // (计数走 m.tiktok SSR、评论走 tikwm 镜像,二者都不读 Cookie)。 // 并且是刻意不带:携带登录态 + 轮换 IP 会造成「同一账号跨地区跳变」, // 那是比匿名请求更危险的风控特征。详见 README 第 1.6 节。 maxComments: 0, // 0=顶级评论抓全部;>0=只抓前 N 条 commentPageSize: 50, // 镜像接口单页上限就是 50(实测 count=100/200 也只给 49) outputDir: "/sdcard/Download/tiktok_data", timeout: 20000, // 单请求超时(ms) maxRetries: 3, // 单页失败重试次数 useMirror: true, // ★ 评论来源:tikwm 镜像(=false 则只抓计数、不抓评论) proxyHost: "", // 一般留空;若 Auto.js 连不上再填 127.0.0.1 proxyPort: 0, // 配合 proxyHost,如 2080 // ================= 风控节流(2026-09-24/25 实测标定)================= // 实测结论:tikwm **不按时间限流,只按并发限流** // · 串行 0.2s 间隔连打 20 次 → 20/20 全成功(无 code=-1) // · 并发 2 → 2/2 成功;并发 3 → 2/3;并发 5 → 3/5(code=-1) // · 触发限流后无需等待,下一次请求立即恢复正常 // 因此:① 永远不要并发(本脚本天然串行);② 下面是「主动收敛」, // 目的是保护共享的免费镜像服务与你的代理出口 IP,而不是绕过硬阈值。 // // ★ 大批量的正确限速维度是「请求数/小时」,不是「视频个数」—— // 评论量差异可达 20 倍(500 条=10 请求,9931 条=199 请求)。 // 故采用两级限速: // 第一级 targetReqPerHour:令牌桶,跨视频累计、不因换视频重置 → 小时级硬上界 // 第二级 视频间动态冷却:底 + 上一视频请求数 × 系数 → 重视频自动多歇 targetReqPerHour: 1200, // ★ 小时级请求上界(0=不限速)。1200 → 每请求最少间隔 3s requestDelay: 400, // 页间最小间隔(ms),含 ±40% 抖动。通常被令牌桶覆盖, // 只在 targetReqPerHour 调得很高时才起主导作用 jitterRatio: 0.4, // 间隔抖动比例:实际 = requestDelay*(1±40%),避免固定节奏 throttleBackoffFactor: 2, // 命中限流(code=-1)时间隔放大倍数(页间隔与令牌桶同步放大) throttleMaxDelay: 8000, // 页间隔上限(ms) throttleMaxInterval: 30000, // 令牌桶间隔上限(ms),防止退避把速率压到停滞 throttleRecoverPages: 20, // 连续成功 N 页后,页间隔与令牌桶一起回落到基准 // ================= 批量模式(可选)================= // 留空 = 只抓上面的 shareUrl;填多个链接/ID 则按顺序批量抓,并在其间自动休息。 // 例: batchUrls: ["https://www.tiktok.com/t/ZTy8r7T2a/", "7552202593888603410"] batchUrls: [], // ---- 视频间动态冷却(按上一视频实际消耗的请求数伸缩,取代固定 90s)---- videoCooldownMinMs: 15000, // 冷却底(ms):轻视频(约 10 请求)≈ 15s videoCooldownPerReqMs: 250, // 每请求追加(ms):199 请求的重视频 → 15s + 50s ≈ 65s videoCooldownJitter: 0.5, // 视频间冷却抖动 ±50% batchCooldownEvery: 5, // 每抓完 N 个视频 → 进入长休息 batchLongCooldownMs: 600000, // 长休息时长(ms),默认 10 分钟 skipExisting: true, // 批量模式下,已有 _stats.json 的视频自动跳过(支持断点续跑) // ---- 分页容错(★ 实测 tikwm 的 hasMore 会偶发假 false,必须无视它)---- maxOffset: 0, // 0=一直翻到无数据;实测 tikwm 顶级评论上限约 10400 emptyStreakLimit: 3, // 连续 N 页无数据才判定结束 // ---- 回复抓取(默认关闭)---- // 实测:回复总量远超官方显示的评论数(单页 49 条顶级评论的 reply_total 合计就 3447), // 全部抓取需数十万次请求。默认只抓「回复最多的前 N 条评论」的回复。 fetchReplies: false, // true = 抓回复 replyTopN: 30, // 只抓 reply_total 排名前 N 的评论的回复 replyMinTotal: 2, // 且 reply_total >= 该值才抓 replyMaxPerComment: 0, // 单条评论最多抓多少回复,0=不限 replyEmptyStreakLimit: 5, // ★ 回复接口空页随机出现,容错要放到 5 // 注:回复分页与顶级评论共用同一套节流器(同一 IP / 同一服务,无需分开设置) verbose: true // 每 10 页打印一次进度 }; // ============================== UA ========================================== var WEB_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"; var MOB_UA = "Mozilla/5.0 (Linux; Android 12; Pixel 5a Build/SQ3A.220605.009.A1; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/140.0.7339.207 Mobile Safari/537.36"; // 计数页面候选(按可靠性排序) var STATS_PAGES = [ { name: "m.tiktok", url: function (id) { return "https://m.tiktok.com/v/" + id + ".html"; }, ua: MOB_UA }, { name: "embed", url: function (id) { return "https://www.tiktok.com/embed/v2/" + id; }, ua: WEB_UA }, { name: "www", url: function (id) { return "https://www.tiktok.com/video/" + id; }, ua: WEB_UA } ]; // ============================== 通用工具 ==================================== function toNum(v) { if (typeof v === "number") return v; if (v === null || v === undefined) return 0; var s = String(v).trim().toUpperCase(); var mult = 1; if (s.indexOf("K") !== -1) { mult = 1000; s = s.replace(/K/g, ""); } else if (s.indexOf("M") !== -1) { mult = 1000000; s = s.replace(/M/g, ""); } else if (s.indexOf("万") !== -1) { mult = 10000; s = s.replace(/万/g, ""); } var n = parseFloat(s.replace(/,/g, "")); return isNaN(n) ? 0 : Math.round(n * mult); } function buildQuery(params) { var parts = []; for (var k in params) { if (params.hasOwnProperty && params.hasOwnProperty(k)) { parts.push(k + "=" + encodeURIComponent(String(params[k]))); } } return parts.join("&"); } function sleepMs(ms) { try { if (typeof sleep === "function") { sleep(ms); return; } } catch (e) {} try { java.lang.Thread.sleep(ms); } catch (e2) {} } // ============================== 节流器 ====================================== // 两级限速: // ① 令牌桶(targetReqPerHour):跨视频累计、不因换视频重置 → 小时级硬上界。 // 这是「按请求数驱动」的核心:轻视频天然跑得快;重视频会把下次允许时刻推远, // 后续请求自动被推迟,无需为每个视频手工设置不同冷却。 // ② 页间最小间隔(requestDelay):防突发的礼貌间隔,通常被①覆盖。 // 另:命中 code=-1 时两者同步退避,连续干净通过后同步回基准(实测限流是瞬时的)。 var CLOCK = { now: function () { return new Date().getTime(); } }; var PACING = { base: 400, cur: 400, // 页间间隔:基准 / 当前 baseInterval: 3000, interval: 3000, // 令牌桶:基准 / 当前(ms per 请求) nextAllowed: 0, // 下一个请求最早可发出的时刻 okStreak: 0, rateLimited: 0, requests: 0, // 全程请求总数 videoReqs: 0 // 当前视频已发请求数(供动态冷却使用) }; function pacingInit() { PACING.base = CONFIG.requestDelay; PACING.cur = CONFIG.requestDelay; PACING.baseInterval = CONFIG.targetReqPerHour > 0 ? Math.round(3600000 / CONFIG.targetReqPerHour) : 0; PACING.interval = PACING.baseInterval; PACING.nextAllowed = 0; PACING.okStreak = 0; PACING.rateLimited = 0; PACING.requests = 0; PACING.videoReqs = 0; } // 页间间隔(含 ±jitterRatio 抖动) function pacingDelay() { var r = Math.random() * 2 - 1; // -1 ~ +1 var v = Math.round(PACING.cur * (1 + r * CONFIG.jitterRatio)); return Math.max(50, v); } // 令牌桶欠账:距下一个请求最早可发时刻还有多久(ms) function pacingTokenGap() { if (PACING.interval <= 0) return 0; return Math.max(0, PACING.nextAllowed - CLOCK.now()); } function pacingWait() { var ms = Math.max(pacingTokenGap(), pacingDelay()); if (ms > 0) sleepMs(ms); // 预定下次允许时刻;用 max(now, nextAllowed) 防止欠账被无限累积 if (PACING.interval > 0) { PACING.nextAllowed = Math.max(CLOCK.now(), PACING.nextAllowed) + PACING.interval; } PACING.requests++; PACING.videoReqs++; } // 每个视频开始前归零(动态冷却需要统计本视频实际请求数) function pacingResetVideo() { PACING.videoReqs = 0; } function pacingNoteRateLimit() { PACING.rateLimited++; PACING.okStreak = 0; PACING.cur = Math.min(Math.round(PACING.cur * CONFIG.throttleBackoffFactor), CONFIG.throttleMaxDelay); if (PACING.interval > 0) { PACING.interval = Math.min(Math.round(PACING.interval * CONFIG.throttleBackoffFactor), CONFIG.throttleMaxInterval); } console.log("[pace] ⚠ 命中限流(code=-1),页间隔→" + PACING.cur + "ms,令牌桶→" + (PACING.interval / 1000).toFixed(1) + "s/请求(累计限流 " + PACING.rateLimited + " 次)"); } function pacingNoteSuccess() { PACING.okStreak++; // 实测限流是瞬时的(触发后下一次请求即恢复),所以不必「缓慢衰减」—— // 连续干净通过 throttleRecoverPages 页后直接回到基准,避免整段视频被永久拖慢。 if (PACING.okStreak >= CONFIG.throttleRecoverPages) { var changed = false; if (PACING.cur > PACING.base) { PACING.cur = PACING.base; changed = true; } if (PACING.baseInterval > 0 && PACING.interval > PACING.baseInterval) { PACING.interval = PACING.baseInterval; changed = true; } if (changed) { PACING.okStreak = 0; console.log("[pace] 连续 " + CONFIG.throttleRecoverPages + " 页无异常,恢复基准(页间隔 " + PACING.cur + "ms,令牌桶 " + (PACING.interval / 1000).toFixed(1) + "s/请求)"); } } } // 批量模式:视频间「动态冷却」 // 冷却 = max(令牌桶欠账, videoCooldownMinMs + 上一视频请求数 × videoCooldownPerReqMs) // 轻视频(约 10 请求)≈ 17s;重视频(199 请求)≈ 65s —— 随实际开销自动伸缩, // 取代旧版「固定 90s」:抓轻视频不再空等,抓重视频也不会歇得太少。 function cooldownBetweenVideos(idx, total, videoReqs) { var isLong = CONFIG.batchCooldownEvery > 0 && (idx % CONFIG.batchCooldownEvery === 0); var reqs = (videoReqs === undefined || videoReqs === null) ? 0 : videoReqs; var dynamic = CONFIG.videoCooldownMinMs + reqs * CONFIG.videoCooldownPerReqMs; var baseMs = isLong ? CONFIG.batchLongCooldownMs : Math.max(pacingTokenGap(), dynamic); if (baseMs <= 0) return; var jr = isLong ? 0.2 : (CONFIG.videoCooldownJitter || 0); var ms = Math.round(baseMs * (1 + (Math.random() * 2 - 1) * jr)); if (isLong) { console.log("[batch] ★ 长休息 " + Math.round(ms / 1000) + "s(已完成 " + idx + "/" + total + ")"); } else { console.log("[batch] 视频间冷却 " + Math.round(ms / 1000) + "s(上一视频 " + reqs + " 请求 → 底 " + Math.round(CONFIG.videoCooldownMinMs / 1000) + "s + 追加 " + Math.round(reqs * CONFIG.videoCooldownPerReqMs / 1000) + "s)"); } sleepMs(ms); } // 打开连接(可选代理) function openConn(url) { var u = new java.net.URL(url); if (CONFIG.proxyHost && CONFIG.proxyPort > 0) { var proxy = new java.net.Proxy(java.net.Proxy.Type.HTTP, new java.net.InetSocketAddress(CONFIG.proxyHost, CONFIG.proxyPort)); return u.openConnection(proxy); } return u.openConnection(); } function readBody(conn) { try { var code = conn.getResponseCode(); var stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream(); if (!stream) { try { conn.disconnect(); } catch (d) {} return ""; } var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, "UTF-8")); var sb = new java.lang.StringBuilder(); var line; while ((line = reader.readLine()) !== null) { sb.append(line).append("\n"); } try { reader.close(); } catch (rc) {} try { conn.disconnect(); } catch (dc) {} return sb.toString(); } catch (e) { try { conn.disconnect(); } catch (d2) {} return ""; } } function httpGetRaw(url, headers, tag) { for (var attempt = 1; attempt <= CONFIG.maxRetries; attempt++) { try { var conn = openConn(url); conn.setConnectTimeout(CONFIG.timeout); conn.setReadTimeout(CONFIG.timeout); conn.setRequestProperty("User-Agent", (headers && headers["User-Agent"]) || WEB_UA); conn.setRequestProperty("Accept-Language", "en-US,en;q=0.9"); var extra = headers || {}; for (var k in extra) { if (extra.hasOwnProperty(k) && k !== "User-Agent") { conn.setRequestProperty(k, extra[k]); } } var code = conn.getResponseCode(); var body = readBody(conn); if (code === 200 && body && body.replace(/\s/g, "") !== "") return body; if (attempt === CONFIG.maxRetries) { console.log("[" + (tag || "http") + "] HTTP " + code + " 空响应(第" + attempt + "次)"); } } catch (e) { if (attempt === CONFIG.maxRetries) { console.log("[" + (tag || "http") + "] 请求异常: " + e); } } sleepMs(600 * attempt); } return null; } function httpGetJson(url, headers, tag) { var body = httpGetRaw(url, headers, tag); if (body === null) return null; try { return JSON.parse(body); } catch (e) { console.log("[" + (tag || "http") + "] JSON 解析失败,前120字符: " + body.substring(0, 120)); return null; } } // ============================== 短链解析 ==================================== function resolveTikTokUrl(url) { try { var current = url; for (var i = 0; i < 8; i++) { var conn = openConn(current); conn.setInstanceFollowRedirects(false); conn.setConnectTimeout(CONFIG.timeout); conn.setReadTimeout(CONFIG.timeout); conn.setRequestProperty("User-Agent", WEB_UA); conn.setRequestMethod("GET"); conn.connect(); var loc = conn.getHeaderField("Location"); conn.disconnect(); if (loc) { current = new java.net.URL(new java.net.URL(current), loc).toString(); if (current.indexOf("/video/") !== -1) break; } else { break; } } return current; } catch (e) { console.log("[resolve] 异常: " + e); return url; } } function extractVideoId(u) { var s = String(u || ""); var m = s.match(/\/video\/(\d+)/); if (m) return m[1]; m = s.match(/(\d{15,25})/); return m ? m[1] : null; } // ============================== 计数解析 ==================================== // 在 s 中从 openIdx('{') 起做括号配对,返回闭合 '}' 的位置 function matchBrace(s, openIdx) { var depth = 0; for (var i = openIdx; i < s.length && i < openIdx + 20000; i++) { var ch = s.charAt(i); if (ch === "{") depth++; else if (ch === "}") { depth--; if (depth === 0) return i; } } return -1; } // 优先从 itemStruct 内部定位视频级 stats;失败则退化为全局首匹配 function parseStatsFromHtml(html, sourceName, pageUrl) { if (!html) return null; function fromStatsObj(obj) { if (!obj) return null; var d = toNum(obj.diggCount != null ? obj.diggCount : obj.digg_count); var c = toNum(obj.commentCount != null ? obj.commentCount : obj.comment_count); var s = toNum(obj.shareCount != null ? obj.shareCount : obj.share_count); var p = toNum(obj.playCount != null ? obj.playCount : obj.play_count); var col = toNum(obj.collectCount != null ? obj.collectCount : obj.collect_count); // 视频级 stats 必含 diggCount / playCount;话题 stats 只有 videoCount,据此区分 if (!d && !p) return null; return { likes: d, comments: c, shares: s, collects: col, plays: p }; } // 1) 从 itemStruct 起,遍历所有 "stats":{...},取第一个含 diggCount/playCount 的 // (注意:itemStruct 之后的第一个 stats 往往是话题的 {"videoCount":N},必须跳过) var startIdx = html.indexOf('"itemStruct"'); if (startIdx === -1) startIdx = 0; var searchFrom = startIdx; var guard = 0; while (guard++ < 40) { var ki = html.indexOf('"stats":{', searchFrom); if (ki === -1) break; var open = ki + '"stats":'.length; var close = matchBrace(html, open); if (close === -1) break; try { var r = fromStatsObj(JSON.parse(html.substring(open, close + 1))); if (r) { r.source = sourceName + ":statsBlock"; r.url = pageUrl; return r; } } catch (e) {} searchFrom = close + 1; } // 2) 全局首匹配(embed 页:计数在 item 顶层,无 stats 包裹) function firstNum(field) { var re = new RegExp('"' + field + '":\\s*"?(\\d+)"?'); var m = html.match(re); return m ? parseInt(m[1], 10) : 0; } var g = { likes: firstNum("diggCount"), comments: firstNum("commentCount"), shares: firstNum("shareCount"), plays: firstNum("playCount"), collects: firstNum("collectCount") }; if (!g.likes && !g.comments && !g.shares) return null; g.source = sourceName + ":globalRegex"; g.url = pageUrl; return g; } function fetchStats(awemeId) { for (var i = 0; i < STATS_PAGES.length; i++) { var p = STATS_PAGES[i]; var url = p.url(awemeId); console.log("[stats] 尝试 " + p.name + " -> " + url); var html = httpGetRaw(url, { "User-Agent": p.ua, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8" }, "stats:" + p.name); if (!html) continue; var st = parseStatsFromHtml(html, p.name, url); if (st) { console.log("[stats] ✓ " + p.name + " 解析成功 (" + st.source + ")"); return st; } console.log("[stats] " + p.name + " 无 SSR 数据,换下一个"); } return null; } // ============================== 输出目录(修复 ENOENT)====================== // files.ensureDir(path) 只创建 path 的「父目录」,不创建 path 本身 → 必须 mkdirs() function ensureOutputDir() { var candidates = [ CONFIG.outputDir, "/sdcard/Download/tiktok_data", "/storage/emulated/0/Download/tiktok_data", "/sdcard/tiktok_data", "/storage/emulated/0/tiktok_data" ]; try { var sd = files.getSdcardPath(); if (sd) { candidates.push(sd + "/Download/tiktok_data"); candidates.push(sd + "/tiktok_data"); } } catch (e) {} try { var cwd = files.cwd(); if (cwd) candidates.push(cwd + "/tiktok_data"); } catch (e2) {} for (var i = 0; i < candidates.length; i++) { var dir = candidates[i]; if (!dir) continue; try { var f = new java.io.File(dir); if (!f.exists()) f.mkdirs(); if (f.exists() && f.isDirectory()) { var test = new java.io.File(dir + "/.writetest"); var ok = test.createNewFile(); if (ok) test.delete(); if (ok) { console.log("[dir] 输出目录: " + dir + " (可写)"); return dir; } } } catch (e3) { console.log("[dir] 不可用 " + dir + " : " + e3); } } console.log("[dir] ✗ 无可用输出目录,请检查存储权限或改 CONFIG.outputDir"); return null; } // ============================== 评论:镜像接口(实测可用)=================== // 响应结构: {code:0, data:{comments:[...], total:N, cursor:N, hasMore:bool}} // // 【实测坑位 —— 2026-09-23/24 云机逐条验证】 // 1. 单页上限 50:count=100/200 也只返回 49 条 → 必须翻页 // 2. hasMore 会偶发「假 false」:cursor=400 返回 false,但 450/500 依然有数据 // → 绝不能拿 hasMore 当终止条件(旧版就因此在第 9 页提前收工,只拿到 426 条) // 3. 翻到 cursor≈10369 后真的没有数据了(tikwm 深度上限) // 4. 并发会被限流:同时发 6 个请求,5 个返回 code=-1 → 必须串行 // 5. total 字段不可信:cursor≥15000 时 total 会退化成 cursor 的值 // 6. 子回复在另一个接口 /api/comment/reply?video_id=&comment_id= function mirrorFetch(path, params, tag) { var url = "https://www.tikwm.com/api/" + path + "?" + buildQuery(params); var obj = httpGetJson(url, { "User-Agent": WEB_UA, "Accept": "application/json, text/plain, */*" }, tag); if (!obj) return null; if (obj.code !== 0 || !obj.data) { if (obj.code === -1) pacingNoteRateLimit(); else if (obj.code !== undefined) console.log("[" + (tag || "mirror") + "] code=" + obj.code + " msg=" + obj.msg); return null; } pacingNoteSuccess(); return obj.data; } // 抓一个 cursor 的评论页(带空页重试) function mirrorFetchPage(videoUrl, cursor, tag) { for (var attempt = 1; attempt <= CONFIG.maxRetries; attempt++) { var d = mirrorFetch("comment/list", { url: videoUrl, count: CONFIG.commentPageSize, cursor: cursor }, tag + "a" + attempt); if (d && (d.comments || []).length > 0) return d; if (attempt < CONFIG.maxRetries) sleepMs(900 * attempt); } return null; } function normComment(c, fallbackId) { var u = c.user || {}; return { cid: c.id || c.cid || fallbackId, text: c.text || "", user: u.unique_id || u.nickname || "匿名", nickname: u.nickname || "", userId: u.id || "", secUid: u.sec_uid || "", region: u.region || "", likes: toNum(c.digg_count), createTime: c.create_time || 0, replyCount: toNum(c.reply_total), replies: [] }; } function getCommentsViaMirror(awemeId, limit) { var all = []; var seen = {}; var cursor = 0; var pages = 0; var emptyStreak = 0; var noNewStreak = 0; var fakeHasMore = 0; var declaredTotal = 0; var videoUrl = "https://www.tiktok.com/video/" + awemeId; console.log("[mirror] 使用 www.tikwm.com 评论接口(单页 " + CONFIG.commentPageSize + ",串行翻页;hasMore 假 false 会被忽略)"); while (true) { pages++; var data = mirrorFetchPage(videoUrl, cursor, "mirror#p" + pages); if (!data) { emptyStreak++; console.log("[mirror] 第" + pages + "页 cursor=" + cursor + " 空(已重试" + CONFIG.maxRetries + "次),空页计数 " + emptyStreak + "/" + CONFIG.emptyStreakLimit); if (emptyStreak >= CONFIG.emptyStreakLimit) { console.log("[mirror] 连续 " + emptyStreak + " 页无数据,分页结束"); break; } cursor += CONFIG.commentPageSize; if (CONFIG.maxOffset > 0 && cursor >= CONFIG.maxOffset) break; pacingWait(); continue; } emptyStreak = 0; var list = data.comments || []; if (toNum(data.total) > declaredTotal) declaredTotal = toNum(data.total); var added = 0; for (var i = 0; i < list.length; i++) { var c = list[i]; if (!c) continue; var obj = normComment(c, "m_" + pages + "_" + i); if (seen[obj.cid]) continue; seen[obj.cid] = true; all.push(obj); added++; if (limit > 0 && all.length >= limit) break; } if (added === 0) noNewStreak++; else noNewStreak = 0; // hasMore 为 false 但本页确实有数据 → 假 false,只提示不终止 if (data.hasMore === false && added > 0) { fakeHasMore++; if (CONFIG.verbose) { console.log("[mirror] ⚠ 第" + pages + "页 hasMore=false,但实际返回 " + added + " 条 → 判定为接口假 false,继续翻页"); } } if (pages === 1 || pages % 10 === 0 || added === 0) { console.log("[mirror] 第" + pages + "页 新增" + added + " 累计" + all.length + "/" + declaredTotal + " cursor=" + cursor + (data.hasMore === false ? " (hasMore=false)" : "")); } if (limit > 0 && all.length >= limit) { console.log("[mirror] 达到 maxComments=" + limit + ",停止"); break; } if (noNewStreak >= CONFIG.emptyStreakLimit) { console.log("[mirror] 连续 " + noNewStreak + " 页无新增,分页结束"); break; } var nc = toNum(data.cursor); cursor = (nc > cursor) ? nc : (cursor + CONFIG.commentPageSize); if (CONFIG.maxOffset > 0 && cursor >= CONFIG.maxOffset) { console.log("[mirror] 到达 maxOffset=" + CONFIG.maxOffset + ",停止"); break; } if (pages > 5000) { console.log("[mirror] 达到页数硬上限 5000,停止"); break; } pacingWait(); } console.log("[mirror] 顶级评论合计 " + all.length + " 条(接口声明 total=" + declaredTotal + (fakeHasMore > 0 ? ",途中忽略 " + fakeHasMore + " 次假 hasMore=false" : "") + ")"); return all; } // --------- 子回复(另一个接口)---------------------------------------------- // /api/comment/reply?video_id=&comment_id=&count=50&cursor=N // // 【实测坑位】回复接口比列表接口更不稳定: // · 空页随机出现(cursor=50 空、100 有数据)→ 空页容错放到 replyEmptyStreakLimit(5) // · 返回条数可能**超过**声明的 reply_total(声明 81 实得 278)→ 不能拿 declared 当终止界 // · 终止规则:① 已达标 且 末页未满(n 0 ? declaredTotal : 1000) + PAGE * 2; while (true) { pages++; var data = null; for (var attempt = 1; attempt <= CONFIG.maxRetries; attempt++) { data = mirrorFetch("comment/reply", { video_id: awemeId, comment_id: commentId, count: PAGE, cursor: cursor }, "reply#" + commentId + "p" + pages + "a" + attempt); if (data && (data.comments || []).length > 0) break; data = null; if (attempt < CONFIG.maxRetries) sleepMs(900 * attempt); } if (!data) { emptyStreak++; if (emptyStreak >= CONFIG.replyEmptyStreakLimit) { if (CONFIG.verbose) { console.log("[reply] cid=" + commentId + " 连续 " + emptyStreak + " 页空(cursor=" + cursor + "),收工 实得" + all.length + "/声明" + declaredTotal); } break; } } else { emptyStreak = 0; var list = data.comments || []; var rawN = list.length; for (var i = 0; i < rawN; i++) { var obj = normComment(list[i], "r_" + commentId + "_" + cursor + "_" + i); if (seen[obj.cid]) continue; seen[obj.cid] = true; all.push(obj); if (CONFIG.replyMaxPerComment > 0 && all.length >= CONFIG.replyMaxPerComment) break; } if (CONFIG.replyMaxPerComment > 0 && all.length >= CONFIG.replyMaxPerComment) break; if (all.length >= declaredTotal && rawN < PAGE) break; // 达标且末页未满 } cursor += PAGE; if (cursor > hardCap) { if (CONFIG.verbose) { console.log("[reply] cid=" + commentId + " 到 hardCap(" + hardCap + "),收工 实得" + all.length + "/声明" + declaredTotal); } break; } if (pages > 800) break; pacingWait(); } return all; } // 按热度抓 Top N 条评论的回复 function attachReplies(awemeId, comments) { var cands = []; for (var i = 0; i < comments.length; i++) { if (toNum(comments[i].replyCount) >= CONFIG.replyMinTotal) cands.push(comments[i]); } cands.sort(function (a, b) { return toNum(b.replyCount) - toNum(a.replyCount); }); var topN = CONFIG.replyTopN > 0 ? Math.min(CONFIG.replyTopN, cands.length) : cands.length; console.log("[reply] 待抓回复的评论 " + cands.length + " 条,本轮取回复最多的前 " + topN + " 条"); var done = 0, totalGot = 0; for (var k = 0; k < topN; k++) { var c = cands[k]; var rs = getRepliesViaMirror(awemeId, c.cid, toNum(c.replyCount)); c.replies = rs; totalGot += rs.length; done++; console.log("[reply] " + done + "/" + topN + " cid=" + c.cid + " 声明" + c.replyCount + " 抓到" + rs.length + " (累计回复 " + totalGot + " 条)"); pacingWait(); } console.log("[reply] ✓ 完成,共抓取回复 " + totalGot + " 条"); return totalGot; } // ============================== 评论:官方接口(已移除)===================== // 官方评论接口在 2026-09-23 全部实测被拦,故不再保留调用代码: // · www.tiktok.com/api/comment/list/ → HTTP 200 但 Content-Length: 0(缺 A-Bogus 签名) // · m.tiktok.com/api/comment/list/ → 403 {"code":40006,"no schema found"} // · api16 / api19 / .../comment/list/ → 403(Argus 风控) // · 同上 app_name=tiktok_web → 2146 "Please upgrade your app" // 且实测与 cookie / UA / version_code 无关 —— 挂登录态也解决不了(见 README 1.6)。 // 2026-09-25:删除 buildCookie() 与 getCommentsViaOfficial()。它们既是死代码, // 又会让每个视频多打一次注定失败的请求,还会诱导使用者去维护登录态。 // 如将来拿到 A-Bogus 签名能力,再按上述参数结构重建此链路。 // ============================== 保存 ======================================== function saveResult(dir, awemeId, stats, comments, sourceName) { if (!dir) { console.log("[save] ✗ 无可用目录,结果只在日志"); return; } try { var base = dir + "/tiktok_" + (awemeId || "unknown"); var replyTotal = 0; for (var i = 0; i < comments.length; i++) replyTotal += (comments[i].replies || []).length; var summary = { awemeId: awemeId, shareUrl: CONFIG.shareUrl, fetchedAt: new Date().toISOString(), commentSource: sourceName, stats: stats, topLevelComments: comments.length, repliesFetched: replyTotal, commentFetched: comments.length, commentFetchedAll: comments.length + replyTotal }; files.write(base + "_stats.json", JSON.stringify(summary, null, 2)); console.log("[save] " + base + "_stats.json"); if (comments && comments.length > 0) { files.write(base + "_comments.json", JSON.stringify(comments, null, 2)); var sb = new java.lang.StringBuilder(); sb.append("视频ID: ").append(awemeId || "").append("\n"); sb.append("来源: ").append(sourceName).append("\n"); sb.append("点赞: ").append(stats ? stats.likes : 0) .append(" | 评论(接口声明): ").append(stats ? stats.comments : 0) .append(" | 分享: ").append(stats ? stats.shares : 0) .append(" | 收藏: ").append(stats ? stats.collects : 0) .append(" | 播放: ").append(stats ? stats.plays : 0).append("\n"); sb.append("顶级评论: ").append(comments.length).append(" 条") .append(" | 子回复: ").append(replyTotal).append(" 条") .append(" | 合计: ").append(comments.length + replyTotal).append(" 条\n\n"); for (var j = 0; j < comments.length; j++) { var c = comments[j]; sb.append((j + 1)).append(". [").append(c.user) .append(" | 赞").append(c.likes) .append(" | 回复").append(c.replyCount) .append(" | 地区").append(c.region) .append("] ").append(c.text).append("\n"); var rp = c.replies || []; for (var r = 0; r < rp.length; r++) { sb.append(" └─ [").append(rp[r].user) .append(" | 赞").append(rp[r].likes) .append("] ").append(rp[r].text).append("\n"); } } files.write(base + "_comments.txt", sb.toString()); console.log("[save] " + base + "_comments.json / .txt"); } } catch (e) { console.log("[save] 失败: " + e); } } // ============================== 单个视频 ==================================== // 返回: true=成功 / "skip"=已有结果跳过 / false=失败 function fetchOneVideo(awemeId, outDir, allowSkip) { if (allowSkip && CONFIG.skipExisting && outDir) { try { var probe = new java.io.File(outDir + "/tiktok_" + awemeId + "_stats.json"); if (probe.exists() && probe.length() > 0) { console.log("[skip] " + awemeId + " 已有结果文件,跳过(skipExisting=true)"); return "skip"; } } catch (e) {} } pacingResetVideo(); // 动态冷却依赖本视频实际消耗的请求数 // 2) 计数 console.log("[1/2] 抓取计数 ..."); var stats = fetchStats(awemeId); if (!stats) { console.log("✗ 计数获取失败:三个候选页都无 SSR 数据。"); console.log(" 排查: 1) 网络是否通畅 2) 若 Auto.js 直连不通,配置 CONFIG.proxyHost=127.0.0.1 / proxyPort=2080"); return false; } console.log("[1/2] ✓ 点赞=" + stats.likes + " 评论数=" + stats.comments + " 分享=" + stats.shares + " 收藏=" + stats.collects + " 播放=" + stats.plays); // 3) 评论 console.log("[2/2] 抓取评论 (目标 " + stats.comments + " 条) ..."); var comments = []; var sourceName = ""; if (CONFIG.useMirror) { comments = getCommentsViaMirror(awemeId, CONFIG.maxComments); if (comments.length > 0) sourceName = "tikwm镜像"; } else { console.log("[2/2] useMirror=false,按配置跳过评论抓取(只取计数)"); } console.log("[2/2] ✓ 实际抓取 " + comments.length + " 条" + (sourceName ? " [" + sourceName + "]" : " [无来源]") + ",接口声明 " + stats.comments + " 条"); if (comments.length > 0 && comments.length < stats.comments) { console.log(" 注: 差 " + (stats.comments - comments.length) + " 条。tikwm 顶级评论翻页上限实测约 10400;官方接口需 A-Bogus 签名才能拿全。"); } // 4) 子回复(可选) if (CONFIG.fetchReplies && comments.length > 0) { console.log("[3/3] 抓取子回复 ..."); attachReplies(awemeId, comments); } else if (comments.length > 0) { var withR = 0, sumR = 0; for (var q = 0; q < comments.length; q++) { var rn = toNum(comments[q].replyCount); if (rn > 0) { withR++; sumR += rn; } } console.log("[reply] 未抓取子回复(CONFIG.fetchReplies=false)。"); console.log("[reply] 其中 " + withR + " 条评论带有声明回复合计 " + sumR + " 条;如需抓取请把 CONFIG.fetchReplies 设为 true" + "(默认只抓回复最多的前 " + CONFIG.replyTopN + " 条评论)。"); } saveResult(outDir, awemeId, stats, comments, sourceName || "无"); return true; } // ============================== 主流程 ====================================== // 单个链接 / 原始 ID → awemeId function resolveAwemeId(item) { var s = String(item || "").trim(); if (!s) return null; if (/^\d{15,25}$/.test(s)) return s; // 直接填的纯数字 ID var resolved = resolveTikTokUrl(s); if (resolved !== s) console.log("[resolve] " + s + " -> " + resolved); return extractVideoId(resolved); } function main() { pacingInit(); var outDir = ensureOutputDir(); var isBatch = CONFIG.batchUrls && CONFIG.batchUrls.length > 0; var list = isBatch ? CONFIG.batchUrls : [CONFIG.shareUrl]; var total = list.length; var ok = 0, skipped = 0, failed = 0; var t0 = new Date().getTime(); console.log("[mode] " + (isBatch ? ("批量模式,共 " + total + " 个视频") : "单个视频") + "(串行;令牌桶 " + (PACING.baseInterval > 0 ? (PACING.baseInterval / 1000).toFixed(1) + "s/请求 ≈ 上限 " + CONFIG.targetReqPerHour + " 请求/小时" : "不限速") + ")"); for (var i = 0; i < total; i++) { if (isBatch) console.log("[batch] ======== 第 " + (i + 1) + "/" + total + " 个 ========"); var awemeId; if (!isBatch && CONFIG.awemeId) { awemeId = CONFIG.awemeId; // 单视频且已知 ID,直接用 } else { awemeId = resolveAwemeId(list[i]); } console.log("[id] awemeId = " + awemeId); var didWork = false; if (!awemeId) { console.log("[batch] ✗ 无法解析视频ID: " + list[i]); failed++; } else { var r = fetchOneVideo(awemeId, outDir, isBatch); if (r === "skip") skipped++; else if (r === true) { ok++; didWork = true; } // 失败也计入 didWork:失败前通常已发过请求(计数走三级降级), // 且失败多因风控/网络,连续快跑只会加剧 —— 冷却更稳妥 else { failed++; didWork = true; } } // 视频间动态冷却:仅当本次真的发过请求才休息(跳过的不必空等) if (isBatch && i < total - 1 && didWork) { cooldownBetweenVideos(i + 1, total, PACING.videoReqs); } } var elapsedSec = Math.max(1, Math.round((new Date().getTime() - t0) / 1000)); console.log("==== 完成 ===="); if (isBatch) { console.log("[batch] 汇总: 成功 " + ok + " / 跳过 " + skipped + " / 失败 " + failed + ",总耗时 " + elapsedSec + "s"); } console.log("[pace] 期间共发请求 " + PACING.requests + " 次,命中限流 " + PACING.rateLimited + " 次"); if (PACING.requests > 0) { var rate = Math.round(PACING.requests * 3600 / elapsedSec); console.log("[pace] 实际速率 ≈ " + rate + " 请求/小时" + (CONFIG.targetReqPerHour > 0 ? "(目标上界 " + CONFIG.targetReqPerHour + ")" : "")); if (CONFIG.targetReqPerHour > 0 && rate > CONFIG.targetReqPerHour * 1.15) { console.log("[pace] 注: 实际速率高于目标 —— 通常是单视频请求数少、固定冷却占比低所致," + "长跑时会自然收敛到目标以内"); } } if (PACING.rateLimited > 0) { console.log("[pace] 提示: 命中限流 " + PACING.rateLimited + " 次。偶发属正常(假 hasMore / 空页重试);若持续出现或出现 CF 403 页面" + "(返回 HTML 而非 JSON),应更换代理节点而非继续降速"); } if (failed > 0) console.log("[batch] 提示: skipExisting=true 已开启,直接重跑本脚本即可只补失败的部分"); } main();