tiktok_stats.js 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945
  1. /**
  2. * ============================================================================
  3. * TikTok 视频数据抓取 —— 纯接口(API)版,不使用无障碍权限
  4. * 目标链接: https://www.tiktok.com/t/ZTy8r7T2a/
  5. * 目标视频: @marcgagilang/video/7552202593888603410
  6. * 抓取内容: 点赞(digg) / 评论数(comment) / 分享(share) / 收藏(collect) / 播放(play)
  7. * + 全量评论内容
  8. * ★ 全程不携带登录态:计数走公开 SSR、评论走第三方镜像,两者都不读 Cookie。
  9. * 登录既无收益,且「登录态 + 轮换 IP」会造成同账号跨地区跳变,是更强的风控特征。
  10. *
  11. * 【实测结论 —— 2026-09-23 在云机(106.119.179.124:23098)上逐条验证】
  12. * ── 网络环境 ──────────────────────────────────────────────
  13. * · 云机跑 NekoBox(moe.nb4a) VPN,网卡 tun0,本地代理 127.0.0.1:2080
  14. * · shell 里 curl 默认走 eth0 → TLS 被 Reset(连不上 tiktok)
  15. * · Auto.js 走系统 VPN → 直连即可,一般无需配代理
  16. *
  17. * ── 计数指标(公开 SSR,无需签名、无需登录,已实测)──────────
  18. * ✓ https://m.tiktok.com/v/<id>.html SSR 完整,含 collectCount
  19. * → diggCount=2400000 shareCount=1200000 commentCount=18900
  20. * playCount=14800000 collectCount=246728
  21. * ✓ https://www.tiktok.com/embed/v2/<id> 备用(无 collectCount)
  22. * ✗ https://www.tiktok.com/video/<id> 常返回 SPA 空壳,无 SSR 数据
  23. *
  24. * ── 评论接口(官方全部被拦 → 调用代码已于 2026-09-25 移除)──
  25. * ✗ www.tiktok.com/api/comment/list/ → HTTP 200 但 Content-Length: 0
  26. * (缺 A-Bogus/X-Bogus 签名;补 msToken 二次握手仍为空)
  27. * ✗ m.tiktok.com/api/comment/list/ → 403 {"code":40006,"no schema found"}
  28. * ✗ api16/api19/.../comment/list/ (musically) → 403(Argus 风控)
  29. * ✗ 同上 app_name=tiktok_web → 2146 "Please upgrade your app"
  30. * (与 cookie/UA/version_code 无关,死路 —— 挂登录态也解决不了)
  31. * ✓ 镜像接口 www.tikwm.com
  32. * · /api/comment/list 顶级评论,total=18752,单页上限 50,实测可翻到 cursor≈10369
  33. * · /api/comment/reply 子回复,需 video_id + comment_id(url 参数不需要)
  34. *
  35. * 【tikwm 的 6 个坑 —— 2026-09-24/25 实测】
  36. * 1. hasMore 会偶发「假 false」:cursor=400 返回 false,450/500 却依然有数据。
  37. * ★ 用它当终止条件会在第 9 页提前收工(旧版只拿到 426 条,而实际能拿 1 万+)
  38. * 2. ★ 限流只按「并发」不按「时间」:
  39. * 并发 1(串行) 0.2s 间隔连打 20 次 → 20/20 全成功
  40. * 并发 2 → 2/2 | 并发 3 → 2/3 | 并发 5 → 3/5(code=-1)
  41. * 触发后无需等待,下一次即刻恢复。→ 结论:串行是硬约束,间隔只是「礼貌」
  42. * 3. total 字段不可信:cursor≥15000 时会退化成 cursor 本身
  43. * 4. count 上限 50:传 100/200 也只返回 49 条
  44. * 5. 顶级评论翻页深度上限 cursor≈10400(约 1 万条),拿不到声明的 1.8 万
  45. * 6. ★ 正确的限速维度是「请求数/小时」而非「视频个数」:评论量差异达 20 倍
  46. * (500 条=10 请求 / 9931 条=199 请求)→ 故用两级限速:
  47. * ① 令牌桶 targetReqPerHour(跨视频累计,小时级硬上界)
  48. * ② 视频间动态冷却 videoCooldownMinMs + 请求数 × videoCooldownPerReqMs
  49. * 另:换 IP 治的是「被标记后的恢复」,治不了 tikwm 上游被限(那时所有视频一起失败)
  50. *
  51. * 【运行方式】
  52. * adb push tiktok_stats.js /sdcard/Download/tiktok_stats.js
  53. * Auto.js Pro 导入运行(仅需悬浮窗权限看日志,无需无障碍、无需 root)
  54. * ============================================================================
  55. */
  56. try { console.show(); } catch (e) {}
  57. console.log("==== TikTok 纯接口抓取开始 ====");
  58. // ============================== CONFIG ======================================
  59. var CONFIG = {
  60. shareUrl: "https://www.tiktok.com/t/ZTy8r7T2a/",
  61. awemeId: "7552202593888603410", // 已知可直填;留空则自动解析短链
  62. // ★ 本脚本不携带任何登录态 —— 已验证 Cookie 对「计数」与「评论」两条链路均零贡献
  63. // (计数走 m.tiktok SSR、评论走 tikwm 镜像,二者都不读 Cookie)。
  64. // 并且是刻意不带:携带登录态 + 轮换 IP 会造成「同一账号跨地区跳变」,
  65. // 那是比匿名请求更危险的风控特征。详见 README 第 1.6 节。
  66. maxComments: 0, // 0=顶级评论抓全部;>0=只抓前 N 条
  67. commentPageSize: 50, // 镜像接口单页上限就是 50(实测 count=100/200 也只给 49)
  68. outputDir: "/sdcard/Download/tiktok_data",
  69. timeout: 20000, // 单请求超时(ms)
  70. maxRetries: 3, // 单页失败重试次数
  71. useMirror: true, // ★ 评论来源:tikwm 镜像(=false 则只抓计数、不抓评论)
  72. proxyHost: "", // 一般留空;若 Auto.js 连不上再填 127.0.0.1
  73. proxyPort: 0, // 配合 proxyHost,如 2080
  74. // ================= 风控节流(2026-09-24/25 实测标定)=================
  75. // 实测结论:tikwm **不按时间限流,只按并发限流**
  76. // · 串行 0.2s 间隔连打 20 次 → 20/20 全成功(无 code=-1)
  77. // · 并发 2 → 2/2 成功;并发 3 → 2/3;并发 5 → 3/5(code=-1)
  78. // · 触发限流后无需等待,下一次请求立即恢复正常
  79. // 因此:① 永远不要并发(本脚本天然串行);② 下面是「主动收敛」,
  80. // 目的是保护共享的免费镜像服务与你的代理出口 IP,而不是绕过硬阈值。
  81. //
  82. // ★ 大批量的正确限速维度是「请求数/小时」,不是「视频个数」——
  83. // 评论量差异可达 20 倍(500 条=10 请求,9931 条=199 请求)。
  84. // 故采用两级限速:
  85. // 第一级 targetReqPerHour:令牌桶,跨视频累计、不因换视频重置 → 小时级硬上界
  86. // 第二级 视频间动态冷却:底 + 上一视频请求数 × 系数 → 重视频自动多歇
  87. targetReqPerHour: 1200, // ★ 小时级请求上界(0=不限速)。1200 → 每请求最少间隔 3s
  88. requestDelay: 400, // 页间最小间隔(ms),含 ±40% 抖动。通常被令牌桶覆盖,
  89. // 只在 targetReqPerHour 调得很高时才起主导作用
  90. jitterRatio: 0.4, // 间隔抖动比例:实际 = requestDelay*(1±40%),避免固定节奏
  91. throttleBackoffFactor: 2, // 命中限流(code=-1)时间隔放大倍数(页间隔与令牌桶同步放大)
  92. throttleMaxDelay: 8000, // 页间隔上限(ms)
  93. throttleMaxInterval: 30000, // 令牌桶间隔上限(ms),防止退避把速率压到停滞
  94. throttleRecoverPages: 20, // 连续成功 N 页后,页间隔与令牌桶一起回落到基准
  95. // ================= 批量模式(可选)=================
  96. // 留空 = 只抓上面的 shareUrl;填多个链接/ID 则按顺序批量抓,并在其间自动休息。
  97. // 例: batchUrls: ["https://www.tiktok.com/t/ZTy8r7T2a/", "7552202593888603410"]
  98. batchUrls: [],
  99. // ---- 视频间动态冷却(按上一视频实际消耗的请求数伸缩,取代固定 90s)----
  100. videoCooldownMinMs: 15000, // 冷却底(ms):轻视频(约 10 请求)≈ 15s
  101. videoCooldownPerReqMs: 250, // 每请求追加(ms):199 请求的重视频 → 15s + 50s ≈ 65s
  102. videoCooldownJitter: 0.5, // 视频间冷却抖动 ±50%
  103. batchCooldownEvery: 5, // 每抓完 N 个视频 → 进入长休息
  104. batchLongCooldownMs: 600000, // 长休息时长(ms),默认 10 分钟
  105. skipExisting: true, // 批量模式下,已有 _stats.json 的视频自动跳过(支持断点续跑)
  106. // ---- 分页容错(★ 实测 tikwm 的 hasMore 会偶发假 false,必须无视它)----
  107. maxOffset: 0, // 0=一直翻到无数据;实测 tikwm 顶级评论上限约 10400
  108. emptyStreakLimit: 3, // 连续 N 页无数据才判定结束
  109. // ---- 回复抓取(默认关闭)----
  110. // 实测:回复总量远超官方显示的评论数(单页 49 条顶级评论的 reply_total 合计就 3447),
  111. // 全部抓取需数十万次请求。默认只抓「回复最多的前 N 条评论」的回复。
  112. fetchReplies: false, // true = 抓回复
  113. replyTopN: 30, // 只抓 reply_total 排名前 N 的评论的回复
  114. replyMinTotal: 2, // 且 reply_total >= 该值才抓
  115. replyMaxPerComment: 0, // 单条评论最多抓多少回复,0=不限
  116. replyEmptyStreakLimit: 5, // ★ 回复接口空页随机出现,容错要放到 5
  117. // 注:回复分页与顶级评论共用同一套节流器(同一 IP / 同一服务,无需分开设置)
  118. verbose: true // 每 10 页打印一次进度
  119. };
  120. // ============================== UA ==========================================
  121. 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";
  122. 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";
  123. // 计数页面候选(按可靠性排序)
  124. var STATS_PAGES = [
  125. { name: "m.tiktok", url: function (id) { return "https://m.tiktok.com/v/" + id + ".html"; }, ua: MOB_UA },
  126. { name: "embed", url: function (id) { return "https://www.tiktok.com/embed/v2/" + id; }, ua: WEB_UA },
  127. { name: "www", url: function (id) { return "https://www.tiktok.com/video/" + id; }, ua: WEB_UA }
  128. ];
  129. // ============================== 通用工具 ====================================
  130. function toNum(v) {
  131. if (typeof v === "number") return v;
  132. if (v === null || v === undefined) return 0;
  133. var s = String(v).trim().toUpperCase();
  134. var mult = 1;
  135. if (s.indexOf("K") !== -1) { mult = 1000; s = s.replace(/K/g, ""); }
  136. else if (s.indexOf("M") !== -1) { mult = 1000000; s = s.replace(/M/g, ""); }
  137. else if (s.indexOf("万") !== -1) { mult = 10000; s = s.replace(/万/g, ""); }
  138. var n = parseFloat(s.replace(/,/g, ""));
  139. return isNaN(n) ? 0 : Math.round(n * mult);
  140. }
  141. function buildQuery(params) {
  142. var parts = [];
  143. for (var k in params) {
  144. if (params.hasOwnProperty && params.hasOwnProperty(k)) {
  145. parts.push(k + "=" + encodeURIComponent(String(params[k])));
  146. }
  147. }
  148. return parts.join("&");
  149. }
  150. function sleepMs(ms) {
  151. try { if (typeof sleep === "function") { sleep(ms); return; } } catch (e) {}
  152. try { java.lang.Thread.sleep(ms); } catch (e2) {}
  153. }
  154. // ============================== 节流器 ======================================
  155. // 两级限速:
  156. // ① 令牌桶(targetReqPerHour):跨视频累计、不因换视频重置 → 小时级硬上界。
  157. // 这是「按请求数驱动」的核心:轻视频天然跑得快;重视频会把下次允许时刻推远,
  158. // 后续请求自动被推迟,无需为每个视频手工设置不同冷却。
  159. // ② 页间最小间隔(requestDelay):防突发的礼貌间隔,通常被①覆盖。
  160. // 另:命中 code=-1 时两者同步退避,连续干净通过后同步回基准(实测限流是瞬时的)。
  161. var CLOCK = { now: function () { return new Date().getTime(); } };
  162. var PACING = {
  163. base: 400, cur: 400, // 页间间隔:基准 / 当前
  164. baseInterval: 3000, interval: 3000, // 令牌桶:基准 / 当前(ms per 请求)
  165. nextAllowed: 0, // 下一个请求最早可发出的时刻
  166. okStreak: 0, rateLimited: 0,
  167. requests: 0, // 全程请求总数
  168. videoReqs: 0 // 当前视频已发请求数(供动态冷却使用)
  169. };
  170. function pacingInit() {
  171. PACING.base = CONFIG.requestDelay;
  172. PACING.cur = CONFIG.requestDelay;
  173. PACING.baseInterval = CONFIG.targetReqPerHour > 0
  174. ? Math.round(3600000 / CONFIG.targetReqPerHour) : 0;
  175. PACING.interval = PACING.baseInterval;
  176. PACING.nextAllowed = 0;
  177. PACING.okStreak = 0;
  178. PACING.rateLimited = 0;
  179. PACING.requests = 0;
  180. PACING.videoReqs = 0;
  181. }
  182. // 页间间隔(含 ±jitterRatio 抖动)
  183. function pacingDelay() {
  184. var r = Math.random() * 2 - 1; // -1 ~ +1
  185. var v = Math.round(PACING.cur * (1 + r * CONFIG.jitterRatio));
  186. return Math.max(50, v);
  187. }
  188. // 令牌桶欠账:距下一个请求最早可发时刻还有多久(ms)
  189. function pacingTokenGap() {
  190. if (PACING.interval <= 0) return 0;
  191. return Math.max(0, PACING.nextAllowed - CLOCK.now());
  192. }
  193. function pacingWait() {
  194. var ms = Math.max(pacingTokenGap(), pacingDelay());
  195. if (ms > 0) sleepMs(ms);
  196. // 预定下次允许时刻;用 max(now, nextAllowed) 防止欠账被无限累积
  197. if (PACING.interval > 0) {
  198. PACING.nextAllowed = Math.max(CLOCK.now(), PACING.nextAllowed) + PACING.interval;
  199. }
  200. PACING.requests++;
  201. PACING.videoReqs++;
  202. }
  203. // 每个视频开始前归零(动态冷却需要统计本视频实际请求数)
  204. function pacingResetVideo() { PACING.videoReqs = 0; }
  205. function pacingNoteRateLimit() {
  206. PACING.rateLimited++;
  207. PACING.okStreak = 0;
  208. PACING.cur = Math.min(Math.round(PACING.cur * CONFIG.throttleBackoffFactor),
  209. CONFIG.throttleMaxDelay);
  210. if (PACING.interval > 0) {
  211. PACING.interval = Math.min(Math.round(PACING.interval * CONFIG.throttleBackoffFactor),
  212. CONFIG.throttleMaxInterval);
  213. }
  214. console.log("[pace] ⚠ 命中限流(code=-1),页间隔→" + PACING.cur + "ms,令牌桶→" +
  215. (PACING.interval / 1000).toFixed(1) + "s/请求(累计限流 " + PACING.rateLimited + " 次)");
  216. }
  217. function pacingNoteSuccess() {
  218. PACING.okStreak++;
  219. // 实测限流是瞬时的(触发后下一次请求即恢复),所以不必「缓慢衰减」——
  220. // 连续干净通过 throttleRecoverPages 页后直接回到基准,避免整段视频被永久拖慢。
  221. if (PACING.okStreak >= CONFIG.throttleRecoverPages) {
  222. var changed = false;
  223. if (PACING.cur > PACING.base) { PACING.cur = PACING.base; changed = true; }
  224. if (PACING.baseInterval > 0 && PACING.interval > PACING.baseInterval) {
  225. PACING.interval = PACING.baseInterval; changed = true;
  226. }
  227. if (changed) {
  228. PACING.okStreak = 0;
  229. console.log("[pace] 连续 " + CONFIG.throttleRecoverPages + " 页无异常,恢复基准(页间隔 " +
  230. PACING.cur + "ms,令牌桶 " + (PACING.interval / 1000).toFixed(1) + "s/请求)");
  231. }
  232. }
  233. }
  234. // 批量模式:视频间「动态冷却」
  235. // 冷却 = max(令牌桶欠账, videoCooldownMinMs + 上一视频请求数 × videoCooldownPerReqMs)
  236. // 轻视频(约 10 请求)≈ 17s;重视频(199 请求)≈ 65s —— 随实际开销自动伸缩,
  237. // 取代旧版「固定 90s」:抓轻视频不再空等,抓重视频也不会歇得太少。
  238. function cooldownBetweenVideos(idx, total, videoReqs) {
  239. var isLong = CONFIG.batchCooldownEvery > 0 && (idx % CONFIG.batchCooldownEvery === 0);
  240. var reqs = (videoReqs === undefined || videoReqs === null) ? 0 : videoReqs;
  241. var dynamic = CONFIG.videoCooldownMinMs + reqs * CONFIG.videoCooldownPerReqMs;
  242. var baseMs = isLong ? CONFIG.batchLongCooldownMs : Math.max(pacingTokenGap(), dynamic);
  243. if (baseMs <= 0) return;
  244. var jr = isLong ? 0.2 : (CONFIG.videoCooldownJitter || 0);
  245. var ms = Math.round(baseMs * (1 + (Math.random() * 2 - 1) * jr));
  246. if (isLong) {
  247. console.log("[batch] ★ 长休息 " + Math.round(ms / 1000) + "s(已完成 " + idx + "/" + total + ")");
  248. } else {
  249. console.log("[batch] 视频间冷却 " + Math.round(ms / 1000) + "s(上一视频 " + reqs +
  250. " 请求 → 底 " + Math.round(CONFIG.videoCooldownMinMs / 1000) + "s + 追加 " +
  251. Math.round(reqs * CONFIG.videoCooldownPerReqMs / 1000) + "s)");
  252. }
  253. sleepMs(ms);
  254. }
  255. // 打开连接(可选代理)
  256. function openConn(url) {
  257. var u = new java.net.URL(url);
  258. if (CONFIG.proxyHost && CONFIG.proxyPort > 0) {
  259. var proxy = new java.net.Proxy(java.net.Proxy.Type.HTTP,
  260. new java.net.InetSocketAddress(CONFIG.proxyHost, CONFIG.proxyPort));
  261. return u.openConnection(proxy);
  262. }
  263. return u.openConnection();
  264. }
  265. function readBody(conn) {
  266. try {
  267. var code = conn.getResponseCode();
  268. var stream = code >= 400 ? conn.getErrorStream() : conn.getInputStream();
  269. if (!stream) { try { conn.disconnect(); } catch (d) {} return ""; }
  270. var reader = new java.io.BufferedReader(new java.io.InputStreamReader(stream, "UTF-8"));
  271. var sb = new java.lang.StringBuilder();
  272. var line;
  273. while ((line = reader.readLine()) !== null) { sb.append(line).append("\n"); }
  274. try { reader.close(); } catch (rc) {}
  275. try { conn.disconnect(); } catch (dc) {}
  276. return sb.toString();
  277. } catch (e) {
  278. try { conn.disconnect(); } catch (d2) {}
  279. return "";
  280. }
  281. }
  282. function httpGetRaw(url, headers, tag) {
  283. for (var attempt = 1; attempt <= CONFIG.maxRetries; attempt++) {
  284. try {
  285. var conn = openConn(url);
  286. conn.setConnectTimeout(CONFIG.timeout);
  287. conn.setReadTimeout(CONFIG.timeout);
  288. conn.setRequestProperty("User-Agent", (headers && headers["User-Agent"]) || WEB_UA);
  289. conn.setRequestProperty("Accept-Language", "en-US,en;q=0.9");
  290. var extra = headers || {};
  291. for (var k in extra) {
  292. if (extra.hasOwnProperty(k) && k !== "User-Agent") {
  293. conn.setRequestProperty(k, extra[k]);
  294. }
  295. }
  296. var code = conn.getResponseCode();
  297. var body = readBody(conn);
  298. if (code === 200 && body && body.replace(/\s/g, "") !== "") return body;
  299. if (attempt === CONFIG.maxRetries) {
  300. console.log("[" + (tag || "http") + "] HTTP " + code + " 空响应(第" + attempt + "次)");
  301. }
  302. } catch (e) {
  303. if (attempt === CONFIG.maxRetries) {
  304. console.log("[" + (tag || "http") + "] 请求异常: " + e);
  305. }
  306. }
  307. sleepMs(600 * attempt);
  308. }
  309. return null;
  310. }
  311. function httpGetJson(url, headers, tag) {
  312. var body = httpGetRaw(url, headers, tag);
  313. if (body === null) return null;
  314. try { return JSON.parse(body); } catch (e) {
  315. console.log("[" + (tag || "http") + "] JSON 解析失败,前120字符: " + body.substring(0, 120));
  316. return null;
  317. }
  318. }
  319. // ============================== 短链解析 ====================================
  320. function resolveTikTokUrl(url) {
  321. try {
  322. var current = url;
  323. for (var i = 0; i < 8; i++) {
  324. var conn = openConn(current);
  325. conn.setInstanceFollowRedirects(false);
  326. conn.setConnectTimeout(CONFIG.timeout);
  327. conn.setReadTimeout(CONFIG.timeout);
  328. conn.setRequestProperty("User-Agent", WEB_UA);
  329. conn.setRequestMethod("GET");
  330. conn.connect();
  331. var loc = conn.getHeaderField("Location");
  332. conn.disconnect();
  333. if (loc) {
  334. current = new java.net.URL(new java.net.URL(current), loc).toString();
  335. if (current.indexOf("/video/") !== -1) break;
  336. } else { break; }
  337. }
  338. return current;
  339. } catch (e) {
  340. console.log("[resolve] 异常: " + e);
  341. return url;
  342. }
  343. }
  344. function extractVideoId(u) {
  345. var s = String(u || "");
  346. var m = s.match(/\/video\/(\d+)/);
  347. if (m) return m[1];
  348. m = s.match(/(\d{15,25})/);
  349. return m ? m[1] : null;
  350. }
  351. // ============================== 计数解析 ====================================
  352. // 在 s 中从 openIdx('{') 起做括号配对,返回闭合 '}' 的位置
  353. function matchBrace(s, openIdx) {
  354. var depth = 0;
  355. for (var i = openIdx; i < s.length && i < openIdx + 20000; i++) {
  356. var ch = s.charAt(i);
  357. if (ch === "{") depth++;
  358. else if (ch === "}") { depth--; if (depth === 0) return i; }
  359. }
  360. return -1;
  361. }
  362. // 优先从 itemStruct 内部定位视频级 stats;失败则退化为全局首匹配
  363. function parseStatsFromHtml(html, sourceName, pageUrl) {
  364. if (!html) return null;
  365. function fromStatsObj(obj) {
  366. if (!obj) return null;
  367. var d = toNum(obj.diggCount != null ? obj.diggCount : obj.digg_count);
  368. var c = toNum(obj.commentCount != null ? obj.commentCount : obj.comment_count);
  369. var s = toNum(obj.shareCount != null ? obj.shareCount : obj.share_count);
  370. var p = toNum(obj.playCount != null ? obj.playCount : obj.play_count);
  371. var col = toNum(obj.collectCount != null ? obj.collectCount : obj.collect_count);
  372. // 视频级 stats 必含 diggCount / playCount;话题 stats 只有 videoCount,据此区分
  373. if (!d && !p) return null;
  374. return { likes: d, comments: c, shares: s, collects: col, plays: p };
  375. }
  376. // 1) 从 itemStruct 起,遍历所有 "stats":{...},取第一个含 diggCount/playCount 的
  377. // (注意:itemStruct 之后的第一个 stats 往往是话题的 {"videoCount":N},必须跳过)
  378. var startIdx = html.indexOf('"itemStruct"');
  379. if (startIdx === -1) startIdx = 0;
  380. var searchFrom = startIdx;
  381. var guard = 0;
  382. while (guard++ < 40) {
  383. var ki = html.indexOf('"stats":{', searchFrom);
  384. if (ki === -1) break;
  385. var open = ki + '"stats":'.length;
  386. var close = matchBrace(html, open);
  387. if (close === -1) break;
  388. try {
  389. var r = fromStatsObj(JSON.parse(html.substring(open, close + 1)));
  390. if (r) {
  391. r.source = sourceName + ":statsBlock";
  392. r.url = pageUrl;
  393. return r;
  394. }
  395. } catch (e) {}
  396. searchFrom = close + 1;
  397. }
  398. // 2) 全局首匹配(embed 页:计数在 item 顶层,无 stats 包裹)
  399. function firstNum(field) {
  400. var re = new RegExp('"' + field + '":\\s*"?(\\d+)"?');
  401. var m = html.match(re);
  402. return m ? parseInt(m[1], 10) : 0;
  403. }
  404. var g = {
  405. likes: firstNum("diggCount"),
  406. comments: firstNum("commentCount"),
  407. shares: firstNum("shareCount"),
  408. plays: firstNum("playCount"),
  409. collects: firstNum("collectCount")
  410. };
  411. if (!g.likes && !g.comments && !g.shares) return null;
  412. g.source = sourceName + ":globalRegex";
  413. g.url = pageUrl;
  414. return g;
  415. }
  416. function fetchStats(awemeId) {
  417. for (var i = 0; i < STATS_PAGES.length; i++) {
  418. var p = STATS_PAGES[i];
  419. var url = p.url(awemeId);
  420. console.log("[stats] 尝试 " + p.name + " -> " + url);
  421. var html = httpGetRaw(url, {
  422. "User-Agent": p.ua,
  423. "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
  424. }, "stats:" + p.name);
  425. if (!html) continue;
  426. var st = parseStatsFromHtml(html, p.name, url);
  427. if (st) {
  428. console.log("[stats] ✓ " + p.name + " 解析成功 (" + st.source + ")");
  429. return st;
  430. }
  431. console.log("[stats] " + p.name + " 无 SSR 数据,换下一个");
  432. }
  433. return null;
  434. }
  435. // ============================== 输出目录(修复 ENOENT)======================
  436. // files.ensureDir(path) 只创建 path 的「父目录」,不创建 path 本身 → 必须 mkdirs()
  437. function ensureOutputDir() {
  438. var candidates = [
  439. CONFIG.outputDir,
  440. "/sdcard/Download/tiktok_data",
  441. "/storage/emulated/0/Download/tiktok_data",
  442. "/sdcard/tiktok_data",
  443. "/storage/emulated/0/tiktok_data"
  444. ];
  445. try { var sd = files.getSdcardPath(); if (sd) { candidates.push(sd + "/Download/tiktok_data"); candidates.push(sd + "/tiktok_data"); } } catch (e) {}
  446. try { var cwd = files.cwd(); if (cwd) candidates.push(cwd + "/tiktok_data"); } catch (e2) {}
  447. for (var i = 0; i < candidates.length; i++) {
  448. var dir = candidates[i];
  449. if (!dir) continue;
  450. try {
  451. var f = new java.io.File(dir);
  452. if (!f.exists()) f.mkdirs();
  453. if (f.exists() && f.isDirectory()) {
  454. var test = new java.io.File(dir + "/.writetest");
  455. var ok = test.createNewFile();
  456. if (ok) test.delete();
  457. if (ok) { console.log("[dir] 输出目录: " + dir + " (可写)"); return dir; }
  458. }
  459. } catch (e3) { console.log("[dir] 不可用 " + dir + " : " + e3); }
  460. }
  461. console.log("[dir] ✗ 无可用输出目录,请检查存储权限或改 CONFIG.outputDir");
  462. return null;
  463. }
  464. // ============================== 评论:镜像接口(实测可用)===================
  465. // 响应结构: {code:0, data:{comments:[...], total:N, cursor:N, hasMore:bool}}
  466. //
  467. // 【实测坑位 —— 2026-09-23/24 云机逐条验证】
  468. // 1. 单页上限 50:count=100/200 也只返回 49 条 → 必须翻页
  469. // 2. hasMore 会偶发「假 false」:cursor=400 返回 false,但 450/500 依然有数据
  470. // → 绝不能拿 hasMore 当终止条件(旧版就因此在第 9 页提前收工,只拿到 426 条)
  471. // 3. 翻到 cursor≈10369 后真的没有数据了(tikwm 深度上限)
  472. // 4. 并发会被限流:同时发 6 个请求,5 个返回 code=-1 → 必须串行
  473. // 5. total 字段不可信:cursor≥15000 时 total 会退化成 cursor 的值
  474. // 6. 子回复在另一个接口 /api/comment/reply?video_id=&comment_id=
  475. function mirrorFetch(path, params, tag) {
  476. var url = "https://www.tikwm.com/api/" + path + "?" + buildQuery(params);
  477. var obj = httpGetJson(url, {
  478. "User-Agent": WEB_UA,
  479. "Accept": "application/json, text/plain, */*"
  480. }, tag);
  481. if (!obj) return null;
  482. if (obj.code !== 0 || !obj.data) {
  483. if (obj.code === -1) pacingNoteRateLimit();
  484. else if (obj.code !== undefined) console.log("[" + (tag || "mirror") + "] code=" + obj.code + " msg=" + obj.msg);
  485. return null;
  486. }
  487. pacingNoteSuccess();
  488. return obj.data;
  489. }
  490. // 抓一个 cursor 的评论页(带空页重试)
  491. function mirrorFetchPage(videoUrl, cursor, tag) {
  492. for (var attempt = 1; attempt <= CONFIG.maxRetries; attempt++) {
  493. var d = mirrorFetch("comment/list", {
  494. url: videoUrl, count: CONFIG.commentPageSize, cursor: cursor
  495. }, tag + "a" + attempt);
  496. if (d && (d.comments || []).length > 0) return d;
  497. if (attempt < CONFIG.maxRetries) sleepMs(900 * attempt);
  498. }
  499. return null;
  500. }
  501. function normComment(c, fallbackId) {
  502. var u = c.user || {};
  503. return {
  504. cid: c.id || c.cid || fallbackId,
  505. text: c.text || "",
  506. user: u.unique_id || u.nickname || "匿名",
  507. nickname: u.nickname || "",
  508. userId: u.id || "",
  509. secUid: u.sec_uid || "",
  510. region: u.region || "",
  511. likes: toNum(c.digg_count),
  512. createTime: c.create_time || 0,
  513. replyCount: toNum(c.reply_total),
  514. replies: []
  515. };
  516. }
  517. function getCommentsViaMirror(awemeId, limit) {
  518. var all = [];
  519. var seen = {};
  520. var cursor = 0;
  521. var pages = 0;
  522. var emptyStreak = 0;
  523. var noNewStreak = 0;
  524. var fakeHasMore = 0;
  525. var declaredTotal = 0;
  526. var videoUrl = "https://www.tiktok.com/video/" + awemeId;
  527. console.log("[mirror] 使用 www.tikwm.com 评论接口(单页 " + CONFIG.commentPageSize +
  528. ",串行翻页;hasMore 假 false 会被忽略)");
  529. while (true) {
  530. pages++;
  531. var data = mirrorFetchPage(videoUrl, cursor, "mirror#p" + pages);
  532. if (!data) {
  533. emptyStreak++;
  534. console.log("[mirror] 第" + pages + "页 cursor=" + cursor + " 空(已重试" +
  535. CONFIG.maxRetries + "次),空页计数 " + emptyStreak + "/" + CONFIG.emptyStreakLimit);
  536. if (emptyStreak >= CONFIG.emptyStreakLimit) {
  537. console.log("[mirror] 连续 " + emptyStreak + " 页无数据,分页结束");
  538. break;
  539. }
  540. cursor += CONFIG.commentPageSize;
  541. if (CONFIG.maxOffset > 0 && cursor >= CONFIG.maxOffset) break;
  542. pacingWait();
  543. continue;
  544. }
  545. emptyStreak = 0;
  546. var list = data.comments || [];
  547. if (toNum(data.total) > declaredTotal) declaredTotal = toNum(data.total);
  548. var added = 0;
  549. for (var i = 0; i < list.length; i++) {
  550. var c = list[i];
  551. if (!c) continue;
  552. var obj = normComment(c, "m_" + pages + "_" + i);
  553. if (seen[obj.cid]) continue;
  554. seen[obj.cid] = true;
  555. all.push(obj);
  556. added++;
  557. if (limit > 0 && all.length >= limit) break;
  558. }
  559. if (added === 0) noNewStreak++; else noNewStreak = 0;
  560. // hasMore 为 false 但本页确实有数据 → 假 false,只提示不终止
  561. if (data.hasMore === false && added > 0) {
  562. fakeHasMore++;
  563. if (CONFIG.verbose) {
  564. console.log("[mirror] ⚠ 第" + pages + "页 hasMore=false,但实际返回 " + added +
  565. " 条 → 判定为接口假 false,继续翻页");
  566. }
  567. }
  568. if (pages === 1 || pages % 10 === 0 || added === 0) {
  569. console.log("[mirror] 第" + pages + "页 新增" + added + " 累计" + all.length +
  570. "/" + declaredTotal + " cursor=" + cursor +
  571. (data.hasMore === false ? " (hasMore=false)" : ""));
  572. }
  573. if (limit > 0 && all.length >= limit) { console.log("[mirror] 达到 maxComments=" + limit + ",停止"); break; }
  574. if (noNewStreak >= CONFIG.emptyStreakLimit) { console.log("[mirror] 连续 " + noNewStreak + " 页无新增,分页结束"); break; }
  575. var nc = toNum(data.cursor);
  576. cursor = (nc > cursor) ? nc : (cursor + CONFIG.commentPageSize);
  577. if (CONFIG.maxOffset > 0 && cursor >= CONFIG.maxOffset) { console.log("[mirror] 到达 maxOffset=" + CONFIG.maxOffset + ",停止"); break; }
  578. if (pages > 5000) { console.log("[mirror] 达到页数硬上限 5000,停止"); break; }
  579. pacingWait();
  580. }
  581. console.log("[mirror] 顶级评论合计 " + all.length + " 条(接口声明 total=" + declaredTotal +
  582. (fakeHasMore > 0 ? ",途中忽略 " + fakeHasMore + " 次假 hasMore=false" : "") + ")");
  583. return all;
  584. }
  585. // --------- 子回复(另一个接口)----------------------------------------------
  586. // /api/comment/reply?video_id=<id>&comment_id=<cid>&count=50&cursor=N
  587. //
  588. // 【实测坑位】回复接口比列表接口更不稳定:
  589. // · 空页随机出现(cursor=50 空、100 有数据)→ 空页容错放到 replyEmptyStreakLimit(5)
  590. // · 返回条数可能**超过**声明的 reply_total(声明 81 实得 278)→ 不能拿 declared 当终止界
  591. // · 终止规则:① 已达标 且 末页未满(n<count) → 收工;② 连续空页超限;③ cursor 超 hardCap
  592. function getRepliesViaMirror(awemeId, commentId, declaredTotal) {
  593. var PAGE = CONFIG.commentPageSize;
  594. var all = [];
  595. var seen = {};
  596. var cursor = 0;
  597. var pages = 0;
  598. var emptyStreak = 0;
  599. var hardCap = (declaredTotal > 0 ? declaredTotal : 1000) + PAGE * 2;
  600. while (true) {
  601. pages++;
  602. var data = null;
  603. for (var attempt = 1; attempt <= CONFIG.maxRetries; attempt++) {
  604. data = mirrorFetch("comment/reply", {
  605. video_id: awemeId, comment_id: commentId, count: PAGE, cursor: cursor
  606. }, "reply#" + commentId + "p" + pages + "a" + attempt);
  607. if (data && (data.comments || []).length > 0) break;
  608. data = null;
  609. if (attempt < CONFIG.maxRetries) sleepMs(900 * attempt);
  610. }
  611. if (!data) {
  612. emptyStreak++;
  613. if (emptyStreak >= CONFIG.replyEmptyStreakLimit) {
  614. if (CONFIG.verbose) {
  615. console.log("[reply] cid=" + commentId + " 连续 " + emptyStreak +
  616. " 页空(cursor=" + cursor + "),收工 实得" + all.length + "/声明" + declaredTotal);
  617. }
  618. break;
  619. }
  620. } else {
  621. emptyStreak = 0;
  622. var list = data.comments || [];
  623. var rawN = list.length;
  624. for (var i = 0; i < rawN; i++) {
  625. var obj = normComment(list[i], "r_" + commentId + "_" + cursor + "_" + i);
  626. if (seen[obj.cid]) continue;
  627. seen[obj.cid] = true;
  628. all.push(obj);
  629. if (CONFIG.replyMaxPerComment > 0 && all.length >= CONFIG.replyMaxPerComment) break;
  630. }
  631. if (CONFIG.replyMaxPerComment > 0 && all.length >= CONFIG.replyMaxPerComment) break;
  632. if (all.length >= declaredTotal && rawN < PAGE) break; // 达标且末页未满
  633. }
  634. cursor += PAGE;
  635. if (cursor > hardCap) {
  636. if (CONFIG.verbose) {
  637. console.log("[reply] cid=" + commentId + " 到 hardCap(" + hardCap +
  638. "),收工 实得" + all.length + "/声明" + declaredTotal);
  639. }
  640. break;
  641. }
  642. if (pages > 800) break;
  643. pacingWait();
  644. }
  645. return all;
  646. }
  647. // 按热度抓 Top N 条评论的回复
  648. function attachReplies(awemeId, comments) {
  649. var cands = [];
  650. for (var i = 0; i < comments.length; i++) {
  651. if (toNum(comments[i].replyCount) >= CONFIG.replyMinTotal) cands.push(comments[i]);
  652. }
  653. cands.sort(function (a, b) { return toNum(b.replyCount) - toNum(a.replyCount); });
  654. var topN = CONFIG.replyTopN > 0 ? Math.min(CONFIG.replyTopN, cands.length) : cands.length;
  655. console.log("[reply] 待抓回复的评论 " + cands.length + " 条,本轮取回复最多的前 " + topN + " 条");
  656. var done = 0, totalGot = 0;
  657. for (var k = 0; k < topN; k++) {
  658. var c = cands[k];
  659. var rs = getRepliesViaMirror(awemeId, c.cid, toNum(c.replyCount));
  660. c.replies = rs;
  661. totalGot += rs.length;
  662. done++;
  663. console.log("[reply] " + done + "/" + topN + " cid=" + c.cid +
  664. " 声明" + c.replyCount + " 抓到" + rs.length +
  665. " (累计回复 " + totalGot + " 条)");
  666. pacingWait();
  667. }
  668. console.log("[reply] ✓ 完成,共抓取回复 " + totalGot + " 条");
  669. return totalGot;
  670. }
  671. // ============================== 评论:官方接口(已移除)=====================
  672. // 官方评论接口在 2026-09-23 全部实测被拦,故不再保留调用代码:
  673. // · www.tiktok.com/api/comment/list/ → HTTP 200 但 Content-Length: 0(缺 A-Bogus 签名)
  674. // · m.tiktok.com/api/comment/list/ → 403 {"code":40006,"no schema found"}
  675. // · api16 / api19 / .../comment/list/ → 403(Argus 风控)
  676. // · 同上 app_name=tiktok_web → 2146 "Please upgrade your app"
  677. // 且实测与 cookie / UA / version_code 无关 —— 挂登录态也解决不了(见 README 1.6)。
  678. // 2026-09-25:删除 buildCookie() 与 getCommentsViaOfficial()。它们既是死代码,
  679. // 又会让每个视频多打一次注定失败的请求,还会诱导使用者去维护登录态。
  680. // 如将来拿到 A-Bogus 签名能力,再按上述参数结构重建此链路。
  681. // ============================== 保存 ========================================
  682. function saveResult(dir, awemeId, stats, comments, sourceName) {
  683. if (!dir) { console.log("[save] ✗ 无可用目录,结果只在日志"); return; }
  684. try {
  685. var base = dir + "/tiktok_" + (awemeId || "unknown");
  686. var replyTotal = 0;
  687. for (var i = 0; i < comments.length; i++) replyTotal += (comments[i].replies || []).length;
  688. var summary = {
  689. awemeId: awemeId,
  690. shareUrl: CONFIG.shareUrl,
  691. fetchedAt: new Date().toISOString(),
  692. commentSource: sourceName,
  693. stats: stats,
  694. topLevelComments: comments.length,
  695. repliesFetched: replyTotal,
  696. commentFetched: comments.length,
  697. commentFetchedAll: comments.length + replyTotal
  698. };
  699. files.write(base + "_stats.json", JSON.stringify(summary, null, 2));
  700. console.log("[save] " + base + "_stats.json");
  701. if (comments && comments.length > 0) {
  702. files.write(base + "_comments.json", JSON.stringify(comments, null, 2));
  703. var sb = new java.lang.StringBuilder();
  704. sb.append("视频ID: ").append(awemeId || "").append("\n");
  705. sb.append("来源: ").append(sourceName).append("\n");
  706. sb.append("点赞: ").append(stats ? stats.likes : 0)
  707. .append(" | 评论(接口声明): ").append(stats ? stats.comments : 0)
  708. .append(" | 分享: ").append(stats ? stats.shares : 0)
  709. .append(" | 收藏: ").append(stats ? stats.collects : 0)
  710. .append(" | 播放: ").append(stats ? stats.plays : 0).append("\n");
  711. sb.append("顶级评论: ").append(comments.length).append(" 条")
  712. .append(" | 子回复: ").append(replyTotal).append(" 条")
  713. .append(" | 合计: ").append(comments.length + replyTotal).append(" 条\n\n");
  714. for (var j = 0; j < comments.length; j++) {
  715. var c = comments[j];
  716. sb.append((j + 1)).append(". [").append(c.user)
  717. .append(" | 赞").append(c.likes)
  718. .append(" | 回复").append(c.replyCount)
  719. .append(" | 地区").append(c.region)
  720. .append("] ").append(c.text).append("\n");
  721. var rp = c.replies || [];
  722. for (var r = 0; r < rp.length; r++) {
  723. sb.append(" └─ [").append(rp[r].user)
  724. .append(" | 赞").append(rp[r].likes)
  725. .append("] ").append(rp[r].text).append("\n");
  726. }
  727. }
  728. files.write(base + "_comments.txt", sb.toString());
  729. console.log("[save] " + base + "_comments.json / .txt");
  730. }
  731. } catch (e) {
  732. console.log("[save] 失败: " + e);
  733. }
  734. }
  735. // ============================== 单个视频 ====================================
  736. // 返回: true=成功 / "skip"=已有结果跳过 / false=失败
  737. function fetchOneVideo(awemeId, outDir, allowSkip) {
  738. if (allowSkip && CONFIG.skipExisting && outDir) {
  739. try {
  740. var probe = new java.io.File(outDir + "/tiktok_" + awemeId + "_stats.json");
  741. if (probe.exists() && probe.length() > 0) {
  742. console.log("[skip] " + awemeId + " 已有结果文件,跳过(skipExisting=true)");
  743. return "skip";
  744. }
  745. } catch (e) {}
  746. }
  747. pacingResetVideo(); // 动态冷却依赖本视频实际消耗的请求数
  748. // 2) 计数
  749. console.log("[1/2] 抓取计数 ...");
  750. var stats = fetchStats(awemeId);
  751. if (!stats) {
  752. console.log("✗ 计数获取失败:三个候选页都无 SSR 数据。");
  753. console.log(" 排查: 1) 网络是否通畅 2) 若 Auto.js 直连不通,配置 CONFIG.proxyHost=127.0.0.1 / proxyPort=2080");
  754. return false;
  755. }
  756. console.log("[1/2] ✓ 点赞=" + stats.likes + " 评论数=" + stats.comments +
  757. " 分享=" + stats.shares + " 收藏=" + stats.collects +
  758. " 播放=" + stats.plays);
  759. // 3) 评论
  760. console.log("[2/2] 抓取评论 (目标 " + stats.comments + " 条) ...");
  761. var comments = [];
  762. var sourceName = "";
  763. if (CONFIG.useMirror) {
  764. comments = getCommentsViaMirror(awemeId, CONFIG.maxComments);
  765. if (comments.length > 0) sourceName = "tikwm镜像";
  766. } else {
  767. console.log("[2/2] useMirror=false,按配置跳过评论抓取(只取计数)");
  768. }
  769. console.log("[2/2] ✓ 实际抓取 " + comments.length + " 条" +
  770. (sourceName ? " [" + sourceName + "]" : " [无来源]") +
  771. ",接口声明 " + stats.comments + " 条");
  772. if (comments.length > 0 && comments.length < stats.comments) {
  773. console.log(" 注: 差 " + (stats.comments - comments.length) +
  774. " 条。tikwm 顶级评论翻页上限实测约 10400;官方接口需 A-Bogus 签名才能拿全。");
  775. }
  776. // 4) 子回复(可选)
  777. if (CONFIG.fetchReplies && comments.length > 0) {
  778. console.log("[3/3] 抓取子回复 ...");
  779. attachReplies(awemeId, comments);
  780. } else if (comments.length > 0) {
  781. var withR = 0, sumR = 0;
  782. for (var q = 0; q < comments.length; q++) {
  783. var rn = toNum(comments[q].replyCount);
  784. if (rn > 0) { withR++; sumR += rn; }
  785. }
  786. console.log("[reply] 未抓取子回复(CONFIG.fetchReplies=false)。");
  787. console.log("[reply] 其中 " + withR + " 条评论带有声明回复合计 " + sumR +
  788. " 条;如需抓取请把 CONFIG.fetchReplies 设为 true" +
  789. "(默认只抓回复最多的前 " + CONFIG.replyTopN + " 条评论)。");
  790. }
  791. saveResult(outDir, awemeId, stats, comments, sourceName || "无");
  792. return true;
  793. }
  794. // ============================== 主流程 ======================================
  795. // 单个链接 / 原始 ID → awemeId
  796. function resolveAwemeId(item) {
  797. var s = String(item || "").trim();
  798. if (!s) return null;
  799. if (/^\d{15,25}$/.test(s)) return s; // 直接填的纯数字 ID
  800. var resolved = resolveTikTokUrl(s);
  801. if (resolved !== s) console.log("[resolve] " + s + " -> " + resolved);
  802. return extractVideoId(resolved);
  803. }
  804. function main() {
  805. pacingInit();
  806. var outDir = ensureOutputDir();
  807. var isBatch = CONFIG.batchUrls && CONFIG.batchUrls.length > 0;
  808. var list = isBatch ? CONFIG.batchUrls : [CONFIG.shareUrl];
  809. var total = list.length;
  810. var ok = 0, skipped = 0, failed = 0;
  811. var t0 = new Date().getTime();
  812. console.log("[mode] " + (isBatch ? ("批量模式,共 " + total + " 个视频") : "单个视频") +
  813. "(串行;令牌桶 " + (PACING.baseInterval > 0
  814. ? (PACING.baseInterval / 1000).toFixed(1) + "s/请求 ≈ 上限 " +
  815. CONFIG.targetReqPerHour + " 请求/小时"
  816. : "不限速") + ")");
  817. for (var i = 0; i < total; i++) {
  818. if (isBatch) console.log("[batch] ======== 第 " + (i + 1) + "/" + total + " 个 ========");
  819. var awemeId;
  820. if (!isBatch && CONFIG.awemeId) {
  821. awemeId = CONFIG.awemeId; // 单视频且已知 ID,直接用
  822. } else {
  823. awemeId = resolveAwemeId(list[i]);
  824. }
  825. console.log("[id] awemeId = " + awemeId);
  826. var didWork = false;
  827. if (!awemeId) {
  828. console.log("[batch] ✗ 无法解析视频ID: " + list[i]);
  829. failed++;
  830. } else {
  831. var r = fetchOneVideo(awemeId, outDir, isBatch);
  832. if (r === "skip") skipped++;
  833. else if (r === true) { ok++; didWork = true; }
  834. // 失败也计入 didWork:失败前通常已发过请求(计数走三级降级),
  835. // 且失败多因风控/网络,连续快跑只会加剧 —— 冷却更稳妥
  836. else { failed++; didWork = true; }
  837. }
  838. // 视频间动态冷却:仅当本次真的发过请求才休息(跳过的不必空等)
  839. if (isBatch && i < total - 1 && didWork) {
  840. cooldownBetweenVideos(i + 1, total, PACING.videoReqs);
  841. }
  842. }
  843. var elapsedSec = Math.max(1, Math.round((new Date().getTime() - t0) / 1000));
  844. console.log("==== 完成 ====");
  845. if (isBatch) {
  846. console.log("[batch] 汇总: 成功 " + ok + " / 跳过 " + skipped + " / 失败 " + failed +
  847. ",总耗时 " + elapsedSec + "s");
  848. }
  849. console.log("[pace] 期间共发请求 " + PACING.requests + " 次,命中限流 " + PACING.rateLimited + " 次");
  850. if (PACING.requests > 0) {
  851. var rate = Math.round(PACING.requests * 3600 / elapsedSec);
  852. console.log("[pace] 实际速率 ≈ " + rate + " 请求/小时" +
  853. (CONFIG.targetReqPerHour > 0 ? "(目标上界 " + CONFIG.targetReqPerHour + ")" : ""));
  854. if (CONFIG.targetReqPerHour > 0 && rate > CONFIG.targetReqPerHour * 1.15) {
  855. console.log("[pace] 注: 实际速率高于目标 —— 通常是单视频请求数少、固定冷却占比低所致," +
  856. "长跑时会自然收敛到目标以内");
  857. }
  858. }
  859. if (PACING.rateLimited > 0) {
  860. console.log("[pace] 提示: 命中限流 " + PACING.rateLimited +
  861. " 次。偶发属正常(假 hasMore / 空页重试);若持续出现或出现 CF 403 页面" +
  862. "(返回 HTML 而非 JSON),应更换代理节点而非继续降速");
  863. }
  864. if (failed > 0) console.log("[batch] 提示: skipExisting=true 已开启,直接重跑本脚本即可只补失败的部分");
  865. }
  866. main();