目标:https://www.tiktok.com/t/ZTy8r7T2a/ → @marcgagilang/video/7552202593888603410
抓取项:点赞 / 评论数 / 分享 / 收藏 / 播放 + 全量评论内容
✅ 不使用无障碍权限。全程 HTTP 请求,无
auto/UiSelector/点击/滑动。
| 现象 | 结论 |
|---|---|
adb shell 里 curl https://www.tiktok.com |
❌ Recv failure: Connection reset by peer |
有 tun0 网卡(172.19.0.1/30) |
云机跑 NekoBox(包 moe.nb4a)VPN |
127.0.0.1:2080 在监听 |
NekoBox 的 mixed 代理(HTTP + SOCKS5) |
curl -x http://127.0.0.1:2080 https://www.tiktok.com |
✅ 200 |
原因:adb shell 的 curl 走 eth0(被过滤,TLS 握手被 reset),App 走 tun0。
对脚本的影响:Auto.js 走系统 VPN,一般直连即可;若连不上,在 CONFIG 里填
proxyHost: "127.0.0.1" + proxyPort: 2080。
| 页面 | 结果 |
|---|---|
https://m.tiktok.com/v/<id>.html |
✅ 首选。SSR 完整,含收藏数 |
https://www.tiktok.com/embed/v2/<id> |
✅ 备用(无收藏数) |
https://www.tiktok.com/video/<id> |
❌ 常返回 SPA 空壳,无 SSR 数据 |
实测值:点赞 2,400,000 / 评论 18,900 / 分享 1,200,000 / 收藏 246,728 / 播放 14,800,000
注意:
itemStruct之后第一个stats块是话题的{"videoCount":...}, 脚本会遍历所有"stats":{...}并取第一个含diggCount/playCount的(实测需跳过 5 个)。
| 接口 | 结果 |
|---|---|
www.tiktok.com/api/comment/list/ |
❌ HTTP 200 但 Content-Length: 0(缺 A-Bogus/X-Bogus) |
| 同上 + msToken 二次握手 | ❌ 仍为空(服务端下发了 x-ms-token 也没用) |
api16/api19/.../comment/list/ (app_name=musically) |
❌ 403(Argus 风控) |
同上 (app_name=tiktok_web) |
❌ 2146 Please upgrade your TikTok app |
2146与 cookie / UA /version_code(试过 180800 / 270000 / 618002 / 2024505030 等) / 设备参数全部无关,是死路。登录态 Cookie 对评论接口没有任何帮助。
可用方案:www.tikwm.com 镜像接口(已实测)
顶级评论 https://www.tikwm.com/api/comment/list?url=<视频URL>&count=50&cursor=N
子回复 https://www.tikwm.com/api/comment/reply?video_id=<ID>&comment_id=<CID>&count=50&cursor=N
→ {code:0, data:{comments:[...], total:18752, cursor:50, hasMore:true}}
| # | 坑 | 实测证据 | 后果 |
|---|---|---|---|
| 1 | hasMore 会偶发「假 false」 |
cursor=400 返回 hasMore:false(仅 41 条),但 cursor=450/500 依然 true 且有数据 |
拿它当终止条件 → 第 9 页就收工,只拿到 426 条 |
| 2 | 只按「并发」限流,不按「时间」限流 | 串行 0.2s 间隔连打 20 次 → 20/20 全成功;并发 3 → 2/3;并发 5 → 3/5 | 必须串行;间隔只是「礼貌」,不是硬阈值(见 1.5) |
| 3 | total 字段不可信 |
cursor≥15000 时 total 退化成 cursor 本身(15000/18000/18700…) |
不能拿 total 算进度或判结束 |
| 4 | count 上限 50 |
传 100 / 200 也只返回 49 条 | 必须老老实实翻 200+ 页 |
| 5 | 翻页深度上限 ≈ cursor 10369 |
超过后真的没有数据(不是假 false) | 拿不到声明的 1.8 万,实得 ≈ 1 万(52%) |
| 6 | 限速维度必须用「请求数/小时」 | 评论量差异达 20 倍:500 条=10 请求,9,931 条=199 请求 | 按「视频个数」设固定间隔 → 速率失控(见 1.5) |
真实可抓范围(实测):
| 项 | 值 |
|---|---|
| 顶级评论 | cursor 从 0 翻到 ≈10369 后真的没数据(约 212 页) |
| 单页 | 46~50 条(count=50) |
| 子回复 | 需单独接口;reply_total 声明值可信(声明 367 → 实测抓到 366) |
| 声明总量 | total=18752(官方显示 18900),但回复总量远超此数 |
⚠️
reply_total分布极不均匀:仅cursor=0这一页 49 条顶级评论,reply_total合计就 3447(单条最高 1446)。但全量统计后实际总量只有 5952 条 (1060 条评论带回复),缺口不大。
回复接口 comment/reply 比列表接口更不稳(实测):
| 现象 | 证据 |
|---|---|
| 空页随机出现 | cursor=50 空,cursor=100 却正常 |
| 返回条数会超过声明 | 声明 81 → 实得 278;声明 20 → 实得 23 |
| 也可能只拿到一部分 | 声明 21 → 实得 1(cursor 50/100 都空,tikwm 侧就没有) |
所以回复的终止规则是「已达标 + 末页未满」而不是拿 declared 硬卡:
① all.length >= declaredTotal 且 本页 rawN < count → 收工(末页)
② 连续 replyEmptyStreakLimit(=5) 页空 → 收工
③ cursor > declaredTotal + 2*count → 收工(hardCap)
(踩过:用 emptyStreak>=2 会让「声明 61 实得 4」这种被误判成结束。)
先给结论:tikwm 只卡并发,不卡频率。
用云机 curl 在同一出口 IP 上做了两组标定:
组 1 —— 串行下的频率:没有阈值
| 请求间隔 | 次数 | 成功 | code=-1 |
|---|---|---|---|
| 0.2s(网络极限,≈24 req/min) | 20 | 20 | 0 |
| 1.0s | 20 | 20 | 0 |
| 3.0s | 15 | 15 | 0 |
组 2 —— 并发:阈值 ≈ 2
| 并发数 | 成功 | code=-1 |
|---|---|---|
| 2 | 2/2 | 0 |
| 3 | 2/3 | 1 |
| 5 | 3/5 | 2 |
且触发后无需冷却——限流一结束,下一次请求立即恢复(恢复校验全通过)。
① 硬约束(有实测支撑):必须串行。 这是唯一会被服务端拒绝的行为。 本脚本天然串行,所以你不必把"间隔"当成某个必须达到的阈值。
② 建议节奏(主动收敛,非实测阈值)。 之所以要主动收敛,是因为:
2026-09-25 起不再需要人工分档:旧的「视频间隔 90s」被证明是有缺陷的—— 评论量差异达 20 倍,固定间隔会让轻视频空等、重视频歇不够。现改为自动两级限速。
| 级别 | 机制 | 作用 |
|---|---|---|
① 令牌桶 targetReqPerHour: 1200 |
每次请求前预占一个「令牌」,跨视频累计、不因换视频重置 | 小时级硬上界(1200 → 每请求至少 3s) |
| ② 视频间动态冷却 | videoCooldownMinMs(15s) + 上一视频请求数 × videoCooldownPerReqMs(250ms) |
重视频自动多歇,轻视频不空等 |
| ③ 批次长休(沿用) | 每 5 个视频休 10 min(±20%) | 打断长时间匀速 |
端到端实测(_probe/test_pacing.js 模拟「轻 10 请求 + 重 199 请求 + 轻 10 请求」):
219 请求 / 726s → 1086 请求/小时 (目标上界 1200)
成本对照(已含限速后的单视频耗时):
| 评论量 | 请求数 | 动态冷却 | 令牌桶耗时 | 单视频合计 |
|---|---|---|---|---|
| 500 | 11 | 18s | 33s | ≈ 51s |
| 2,000 | 41 | 25s | 123s | ≈ 2.5 min |
| 5,000 | 101 | 40s | 303s | ≈ 5.7 min |
| 9,931(本视频) | 200 | 65s | 600s | ≈ 11 min |
⚠️ 加了主动限速后,本视频从原来的 ≈6 min 变成 ≈11 min。 这是「把速率压进安全区」的代价,不是性能 bug。
仍然别按"抓几个"规划,按"这个视频要发多少次请求"规划。 建议单 IP 控制在 ≤ 1000~1500 请求/小时,默认配置(1200)已落在区间内。
| 机制 | 行为 |
|---|---|
| 串行 | 天然单线程,不存在并发风险 |
令牌桶 targetReqPerHour |
全局速率上界,跨视频累计(主要限速手段) |
页间最小间隔 requestDelay: 400 |
含 ±40% 抖动;通常小于令牌桶间隔,只在目标设得很高时主导 |
| 自适应退避 | 命中 code=-1 → 页间隔与令牌桶同步 ×2(分别 8s / 30s 封顶) |
| 快速恢复 | 连续 20 页干净通过 → 两者同步一步回到基准(不做缓慢衰减) |
| 视频间动态冷却 | 15s + 请求数 × 250ms,±50% 抖动 |
| 批次长休 | 每 5 个视频长休 10 min,±20% 抖动 |
断点续跑 skipExisting |
已有 _stats.json 的视频自动跳过,跳过的不触发冷却 |
| 失败也冷却 | 抓取失败的视频同样进入冷却(失败多因风控,连续快跑只会加剧) |
| 结果自检 | 结束打印「共发请求 N 次,命中限流 M 次」+「实际速率 ≈ X 请求/小时」 |
以上逻辑已用「加载生产文件本体 + 注入假 sleep 与可控时钟」的方式单测覆盖(40 项断言全绿)。
怎么看收尾那两行日志:
[pace] 期间共发请求 1987 次,命中限流 3 次
[pace] 实际速率 ≈ 1102 请求/小时(目标上界 1200)
命中限流 M:偶发 1~5 次属正常(假 hasMore / 空页重试引起);持续 > 0 或出现
CF 403 页面(返回 HTML 而非 JSON)→ 换节点,而不是继续降速。实际速率:长跑(几十个视频以上)会稳定落在目标以内;视频数很少时会偏高,
因为固定冷却占比低 —— 长跑自然收敛。评论全程走 tikwm,不携带任何登录态(sessionid 对评论接口无效,见 1.3)。
所以批量抓取的主要风险是「IP 被镜像站/代理节点标记」,
而不是「TikTok 账号被封」——脚本也不会拿 Cookie 去请求评论接口。
结论:不登录 TikTok 账号,本方案可完整跑通。 上一轮的成功抓取 (9,931 顶级评论 + 3,316 回复)本身就是无 Cookie 跑出来的,已逐行核对脚本:
| 链路 | 函数 | 请求头 | 用 Cookie? |
|---|---|---|---|
| 计数 | fetchStats() |
User-Agent + Accept |
否 |
| 评论 | getCommentsViaMirror() |
User-Agent + Accept |
否 |
getCommentsViaOfficial() |
曾含 Cookie |
接口已被风控拦死,函数已于 2026-09-25 删除 |
→ 结论落地:cookie / cookieFile / tryOfficial 三项配置已从脚本移除,
主脚本现在完全没有携带登录态的代码路径。
登录态唯一还能帮上忙的三个场景(都不是"防封"):
itemStruct 可能整个缺失;其中 1、3 用目标地区的出口 IP 同样能解决 → 短效 IP 确实可以替代登录态。
两条风控对象不同,混在一起看就会误判:
| 层 | 谁在判定 | 换你的 IP 是否有效 |
|---|---|---|
| m.tiktok → TikTok Cloudflare | 你的出口 IP | 有效 |
| tikwm 入口限流(并发 ≈ 2) | 你的出口 IP | 有效(换 IP = 变成新客户端,额度重置) |
| tikwm 服务端 → TikTok | tikwm 自己的 IP | 无效 |
三个反直觉点:
code=-1)——此时换节点无用,应等其恢复或换镜像。| 信号 | 判定 |
|---|---|
[pace] 命中限流 M 持续 > 0 |
偶发 1~2 次属正常抖动(假 hasMore / 空页重试),持续才是信号 |
连续出现 code=-1 |
换节点 |
| 返回 HTML 而非 JSON(CF 403 挑战页) | 换节点 |
| 所有请求成功率骤降 | 换节点 |
命中任一条 → 立刻换节点;无异常时按期轮换(20~30 min)作兜底即可。
换 IP 也治不了的天花板: tikwm 顶级评论 cursor 上限 ≈ 10369(见 1.5 与第六节), 这是镜像的数据完整性上限,与账号、IP 均无关。
合规提示:用 IP 轮换替代登录态,不等于免除平台 ToS 约束。控制量级、 避免对单一镜像站高频施压。
运行:
adb push tiktok_stats.js /sdcard/Download/tiktok_stats.js
# Auto.js Pro 导入 → 运行
输出目录 /sdcard/Download/tiktok_data/:
tiktok_<ID>_stats.json — 5 项计数 + 元信息tiktok_<ID>_comments.json — 评论(结构化)tiktok_<ID>_comments.txt — 评论(可读文本)| 配置项 | 说明 |
|---|---|
shareUrl |
目标短链 |
awemeId |
已知视频 ID 直填(当前 7552202593888603410),留空则解析短链 |
maxComments |
0=顶级评论全量;>0=只抓前 N 条(调试建议先设 200) |
commentPageSize |
50(tikwm 上限,填大也没用) |
useMirror |
★ 评论来源:tikwm 镜像(false 则只抓计数、不抓评论) |
proxyHost / proxyPort |
一般留空;Auto.js 连不上时填 127.0.0.1 / 2080 |
targetReqPerHour |
★ 小时级请求上界(默认 1200 → 每请求至少 3s)。0=不限速 |
requestDelay / maxRetries |
页间最小间隔(400ms)与重试次数;必须串行 |
jitterRatio |
页间间隔抖动比例(默认 0.4) |
throttleBackoffFactor |
命中限流时的退避倍数(2 倍,页间隔与令牌桶同步放大) |
throttleMaxDelay / throttleMaxInterval |
页间隔上限 8s / 令牌桶上限 30s |
throttleRecoverPages |
连续 N 页干净通过后同步回到基准(默认 20) |
emptyStreakLimit |
连续 N 页无数据才判定结束(默认 3,配合假 hasMore 容错) |
maxOffset |
0=翻到无数据为止;设值则到该 cursor 停(实测上限 ≈10400) |
batchUrls |
批量模式:填多个短链/ID 则按顺序批量抓(留空=只抓 shareUrl) |
videoCooldownMinMs |
★ 视频间冷却底(默认 15s) |
videoCooldownPerReqMs |
★ 每请求追加冷却(默认 250ms)→ 重视频自动多歇 |
videoCooldownJitter |
视频间冷却抖动(±50%) |
batchCooldownEvery / batchLongCooldownMs |
每 5 个视频长休 10 分钟 |
skipExisting |
批量模式下已有结果的视频跳过(断点续跑,仅批量生效) |
fetchReplies |
是否抓子回复(默认 false,见 1.4 的量级说明) |
replyTopN |
抓回复最多的前 N 条评论的回复(默认 30) |
replyMinTotal |
只抓 reply_total >= 该值的评论(默认 2) |
outputDir |
输出目录(自动尝试多个候选并校验可写) |
关于登录态:本脚本不提供
cookie/cookieFile配置项,也刻意不带登录态。 计数与评论两条链路都不读 Cookie(见 1.6),且「登录态 + 轮换 IP」会造成同账号 跨地区跳变,是比匿名请求更强的风控特征。原先的cookie/cookieFile/tryOfficial三项已于 2026-09-25 移除。
// 只改这一处即可批量:填链接或裸 ID 都行
batchUrls: [
"https://www.tiktok.com/t/ZTy8r7T2a/",
"7552202593888603410",
"https://www.tiktok.com/@someone/video/7512345678901234567"
],
targetReqPerHour: 1200, // ★ 小时级上界(每请求至少 3s)—— 主要限速手段
videoCooldownMinMs: 15000, // 视频间冷却底 15s
videoCooldownPerReqMs: 250, // + 每请求 250ms(重视频自动多歇)
batchCooldownEvery: 5, // 每 5 个
batchLongCooldownMs: 600000, // 长休 10 分钟
skipExisting: true // 中断后直接重跑,只补没抓的
大批量长跑建议
targetReqPerHour: 600(每请求 6s),其余不用动。[pace] 行。命中限流 偶发属正常;持续 > 0 或出现
CF 403 页面就换节点,别靠降速硬扛。files.ensureDir(dir) 只创建传入路径的父目录,不创建 dir 自身。
已改为 new java.io.File(dir).mkdirs() 递归创建 + createNewFile() 可写校验,
失败自动降级到 files.getSdcardPath() / files.cwd() 等备选目录。invalid return(0.004s 闪退):Rhino 禁止顶层 return。
所有逻辑已收进 main()。m.tiktok.com 的 SSR stats 块解析,稳定拿到。/data/system/graphicsstats/...:find /data -maxdepth 4
兜底时把 graphicsstats 的日期目录当成了数据目录。已改为先 pm dump 拿权威
dataDir,再用 /proc/1/root 前缀探测可读性,不再靠 find 猜路径。hasMore 会偶发假 false,
旧逻辑在 cursor=400 那页直接 break,累计正好 426。
已改为:hasMore 只作提示不作终止;空页重试 3 次;连续 3 页无数据才结束。
验证方式:录制 12 页真实响应(含出问题的那页)回放生产代码 →
426 → 559 条,且正确打印「⚠ 第9页 hasMore=false,但实际返回 41 条 → 判定为假 false,继续翻页」。reply_total 误以为是总量:tikwm 的 total 是「顶级评论 + 回复」的混合口径,
且回复量极大;已把子回复拆成独立可选阶段(fetchReplies),默认只抓 Top N。batchUrls 批量模式 + 视频间冷却 + 批次长休 +
skipExisting 断点续跑(故意做在同一份脚本里,避免复制出第二份代码后两边漂移)。buildCookie() / getCommentsViaOfficial() 两个死函数,
以及 cookie / cookieFile / tryOfficial 三项配置 ——
顺带每个视频少打 1 次注定失败的请求。code=-1 退避只放大页间隔(400ms→8000ms),
仍会被令牌桶的 3s/请求淹没,退避等于没生效。
已改为页间隔与令牌桶同步放大、同步回落,并新增断言「令牌桶确实被退避放大」。⚠️ 2026-09-25 提示:日常抓取不需要这一节。 已核实登录态对计数与评论零贡献(见 1.6),主脚本也不读 Cookie。 这里保留完整提取链路,仅用于:① 确实遇到地区/年龄限制内容需要验证; ② 调试其他依赖登录态的接口。不要把提取到的 Cookie 塞进批量抓取流程。
tiktok_extract_cookie.js(Auto.js 版)adb push tiktok_extract_cookie.js /sdcard/Download/
# Auto.js Pro 导入运行
云机上踩的两个坑(已在脚本内解决,勿回退)
坑 1 —— mount namespace 隔离
云机会把 /data/data 用 tmpfs 重新挂载成空目录,只 bind 进 Auto.js 自己和 GMS:
tmpfs on /data/data type tmpfs (mode=751) ← 空壳
/dev/loop2 on /data/data/org.autojs.autojspro
/dev/loop2 on /data/data/com.google.android.gms
所以 test -d /data/data/com.zhiliaoapp.musically 返回 NO、find /data 只能扫到
/data/system/graphicsstats/... —— 这是挂载视图问题,不是权限问题,加 root 也没用。
解决:用 /proc/1/root 前缀穿透到 init 的挂载视图。/proc/<pid>/root 是 magic
symlink,会跟随目标进程的 mount ns,不需要 CAP_SYS_ADMIN(nsenter 需要,故仅作兜底):
/proc/1/root/data/user/0/com.zhiliaoapp.musically/shared_prefs/ttnetCookieStore.xml
脚本按 [/proc/1/root 前缀] → [nsenter] → [直接路径] 依次探测并打印结果。
另外:files.* / java.io.* 在 Auto.js 进程 uid 下运行(非 root),读不到
/proc/1/root 下的文件 → 必须先用 root shell cp 到 /sdcard,再用 files.read()。
坑 2 —— Cookie 存储不是明文
| 存储 | 形态 | grep 结果 |
|---|---|---|
shared_prefs/ttnetCookieStore.xml |
值是 Java 序列化 hex(ACED0005…) |
❌ 永远 0 命中 |
app_webview/Default/Cookies |
SQLite,name 与 value 分列 | ❌ 匹配不到 name=value |
所以必须结构化解析:
<string name="http://tiktok.com/|d_ticket">ACED0005…</string>
字节流结构 [TC_STRING 名称][TC_STRING 值][TC_STRING 域名][TC_STRING 路径],
取第 2 个 TC_STRING(0x74) 即值。实测 170 条目 → 24 个 Cookie 名。android.database.sqlite.SQLiteDatabase 直接查。tiktok_extract_cookie_pc.js(备用通道)node --experimental-sqlite tiktok_extract_cookie_pc.js
同步用 adb pull 取两个存储,解析逻辑同上,并做双源交叉校验。
备注:这台云机的
su不支持-c,必须用su 0 sh -c '...'。 Git Bash 下需export MSYS_NO_PATHCONV=1,否则/sdcard/...会被转成 Windows 路径。
24 个 Cookie 名(2018 字符):sessionid sessionid_ss sid_tt uid_tt uid_tt_ss
sid_guard msToken odin_tt passport_csrf_token passport_csrf_token_default
multi_sids tt-target-idc store-idc cmpl_token d_ticket install_id ttreq
store-country-code store-country-sign tt_session_tlb_tag user_oec_info BUYER_TOKEN 等。
ttwid不在 App 的存储里(App 不持久化它),只能向www.tiktok.com现捞。 但当前主脚本不需要它 —— 不携带任何 Cookie 也能正常抓取(见 1.6)。
计数:m.tiktok.com/v/<id>.html → __UNIVERSAL_DATA_FOR_REHYDRATION__
→ 定位 itemStruct → 遍历 "stats":{...} 取含 diggCount 的块。
失败降级 embed/v2/<id> → 全局正则。
全程不携带 Cookie(未登录 SSR 同样完整)。
评论:
www.tikwm.com/api/comment/list(唯一来源,实测可用)
cursor=0 串行翻页,单页 50,hasMore 假 false 忽略,空页重试,
连续 3 页无数据结束 → 实得顶级评论 ≈10369 条www.tikwm.com/api/comment/reply(子回复,可选)
video_id + comment_id(不需要 url)www.tiktok.com/api/comment/list输出:
tiktok_<ID>_stats.json — 5 项计数 + topLevelComments / repliesFetchedtiktok_<ID>_comments.json — 结构化(回复挂在父评论的 replies 数组)tiktok_<ID>_comments.txt — 可读文本(回复以 └─ 缩进)