| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945 |
- /**
- * ============================================================================
- * 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/<id>.html SSR 完整,含 collectCount
- * → diggCount=2400000 shareCount=1200000 commentCount=18900
- * playCount=14800000 collectCount=246728
- * ✓ https://www.tiktok.com/embed/v2/<id> 备用(无 collectCount)
- * ✗ https://www.tiktok.com/video/<id> 常返回 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=<id>&comment_id=<cid>&count=50&cursor=N
- //
- // 【实测坑位】回复接口比列表接口更不稳定:
- // · 空页随机出现(cursor=50 空、100 有数据)→ 空页容错放到 replyEmptyStreakLimit(5)
- // · 返回条数可能**超过**声明的 reply_total(声明 81 实得 278)→ 不能拿 declared 当终止界
- // · 终止规则:① 已达标 且 末页未满(n<count) → 收工;② 连续空页超限;③ cursor 超 hardCap
- function getRepliesViaMirror(awemeId, commentId, declaredTotal) {
- var PAGE = CONFIG.commentPageSize;
- var all = [];
- var seen = {};
- var cursor = 0;
- var pages = 0;
- var emptyStreak = 0;
- var hardCap = (declaredTotal > 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();
|