TikTok 移动 API 抓取技术指南:24 个端点与 56 亿视频数据集
Scraping TikTok's Mobile API
一份技术指南详解如何接入 TikTok Android 应用的私有 HTTP+JSON API,需同时满足设备凭证、请求签名、区域主机和 TLS 指纹四项条件,任一错误都只返回空 body 的 HTTP 200。
技术指南 · 24 个端点 · 实测
每天数十亿条个人资料和视频
TikTok 的 Android 应用与一个私有的 HTTP+JSON API 通信,它比 Web 端点更快,返回的内容也多得多。这是一份访问它的技术指南: 设备如何注册、请求如何签名、区域主机如何 分区,以及 TLS 握手如何被指纹识别。基于它构建的一个系统 在三周内收集了 32.3 亿个创作者资料、59.4 亿个视频和 28 亿条 评论。
免费数据集。 其中 56 亿个视频在 Hugging Face 上:标题、 话题标签、观看、点赞、评论、分享和收藏数、音乐、国家和 发布时间。 huggingface.co/datasets/datasocial/tiktok-5.6B-videos 或者实时查询全部数据,无需注册。原始 ClickHouse 连接字符串(只读,无 用户,无密码): https://sql.datasocial.ai:443
你能拉取的内容。 创作者资料、创作者发布的每个视频、 粉丝和关注列表,以及 TikTok 自有的相似创作者图谱。完整的视频 详情及完整统计块。评论和评论回复,每条都带有 评论者的账号。音乐、使用它们的视频,以及热门音乐 榜单。话题标签及其视频,最新或最热门。跨 视频、创作者和音乐的关键词搜索。热门货架和相机特效。 共 24 个端点,每个都有实测的成功率。
约 30 分钟阅读 测量于 2026 年 9 月重新运行 Go 1.24
这是什么
你能找到的几乎每个 TikTok 抓取器都驱动无头浏览器或访问
公开 Web 端点。两者都是错误的层级:慢、脆弱,而且缺少大多数
有趣的字段。Android 应用两者都不用。它与一个私有的
HTTP+JSON API 通信,就是你滚动时 com.zhiliaoapp.musically 访问的同一个,
而且那个 API 快速、稳定,返回的内容多得多。
进入它是困难的部分,而且难得很特别。四件完全无关的事情必须同时正确: TikTok 签发的设备凭证、有效的请求签名、正确的区域 主机,以及看起来像手机的 TLS 握手。
其中任何一个出错,你都会收到相同的响应:
一个干净的 HTTP 200,正文为空。没有错误消息。
没有状态码。你的 HTTP 客户端报告成功,你的日志保持绿色,你的
数据库什么也填不满。没有任何信号告诉你正卡在四者中的哪一个。
本文逐一讲解这四点,然后记录由此得到的 24 个端点。 它点出基本要素,展示真实的流水线,并给出实测 数字而非空口宣称。
这四点都没有反馈回路。错误的轮换常量、错误的字节序、 错误的主机和握手中错误的密码套件,都会产生同样 格式正确的请求和同样的空响应,因此没有错误可供二分排查, 也没有部分得分。
范围
以下所有内容都是匿名设备流量。这个系统中没有任何登录,没有账户,没有会话 cookie。这也意味着任何真正需要账户才能访问的内容(你自己的私信、私密视频、谁点赞了什么)都无法触及,并且始终无法触及。再怎么调整也无法达到那里。
一个请求的剖析
在说其他之前,先看看这些请求之一实际长什么样。这是一次真实调用,识别性值已缩短:
GET /aweme/v1/aweme/post/
?# ── what you are asking for ──────────────────────────────
source=0
&user_id=6744630345964389381
&count=20
&max_cursor=1751028792000
&sort_type=0
&# ── who is asking: 38 params, order matters ─────────────
ts=1788361402&ac=mobile&ac2=lte
&aid=<aid> # app id
&iid=7680617333853718293 # install id ← from register
&device_id=7680616891110524437 # device id ← from register
&cdid=4a1d... # client-generated uuid
&openudid=8f2c... # client-generated 16-hex
&device_brand=Samsung&device_type=<device model>&os_version=12&os_api=30
&resolution=1080*2280&dpi=440&host_abi=arm64-v8a
®ion=SG&carrier_region=SG&sys_region=SG&mcc_mnc=52506
&language=ja&app_language=ja&locale=ja-SG&timezone_name=Asia%2FSingapore
&version_name=<app version>&version_code=<version code>&manifest_version_code=<version code>
&_rticket=1788361402193&channel=googleplay&app_type=normal
Headers:
user-agent: <package>/<version code> (Linux; U; Android 12; ...)
x-tt-trace-id: 00-6a9f...-6a9f...-01
x-ss-req-ticket: 1788361402193
x-khronos: 1788361402 # timestamp
x-ladon: XKp9... # Speck-128/256
x-argus: cQqbRZm8k1x... # the hard one
x-gorgon: 0404b0d30000... # legacy digest
有三件事需要注意,因为每一件都会在后面带来麻烦:
- URL 的三分之二都是设备身份。三十八个常见参数描述了手机、运营商、地区和 app 构建版本。它们不是装饰。签名覆盖了它们。
-
device_id和iid由 TikTok 签发,不是你选择的。cdid和openudid由你生成并在注册时提交。把这两者搞混是第一道墙。 -
参数顺序是固定的。签名会把查询字符串按字面量进行哈希,所以
url.Values.Encode()会按字母顺序排序键,从而悄悄生成无效签名。在 Go 中,你必须手动构建查询。
先说明术语,因为它会反复出现:
| 字段 | 它是什么 | 来源 |
|---|---|---|
| aid | 应用 id。1233 是主应用(musically),1340 是 musically_go,其他每个构建版本都有自己的 id。不同的 aid 意味着不同的签名密钥和不同的端点集合。 | 常量 |
| device_id | 持久设备身份。19 位数字。 | TikTok,注册时 |
| iid | 安装 id。与 device_id 配对。 | TikTok,注册时 |
| cdid | 客户端设备 id。一个由你生成的 UUID。 | 你 |
| openudid | 由你生成的 16 个十六进制字符。 | 你 |
| license_id | 用于 X-Ladon 密钥调度。 | 每个应用固定 |
| version_code | 应用构建版本。决定哪些端点会响应。 | 由你选择 |
第一个空的 200
一个正确实现的签名器会产生能通过捕获流量验证的输出,参数逐字节匹配。响应仍然是这样:
$ curl -sD- -o /tmp/body "https://<the Singapore host>/aweme/v1/user/profile/other/?..."
HTTP/1.1 200 OK
content-type: application/json
content-length: 0
x-tt-logid: 2026090117...
server: TLB
$ wc -c /tmp/body
0 /tmp/body
status_code,因为没有正文可以放它。这就是 TikTok 的软封锁,也是理解这个 API 最重要的一件事。它不是 403。不是 429。不是挑战页面。它是一个成功的 HTTP 响应,但里面什么都没有。
这意味着下面这段代码——也就是所有人最先写出的代码——会悄悄失效:
res = requests.get(url, headers=signed)
if res.ok: # True. Always true.
store(res.json()) # {} stored, no exception
# six hours later: 400,000 rows in the database, all empty,
# nothing in the error log, dashboard green调试它很昂贵,因为四种互不相关的失败都会产生它:
响应中没有任何东西能告诉你到底是哪一种。你无法通过阅读错误来二分定位,因为根本没有错误。唯一的方法是把这四项都修好,并分别单独测量每一项。
设备 ID 从何而来
你不能凭空发明一个 device_id。TikTok 会签发它,从它日志主机上的 /service/2/device_register/ 签发,以换取一个看起来合理的手机。
请求体是一个 JSON 文档(app header、device header、custom block),使用 TTEncrypt(TikTok 自有的请求体加密,一种简单的字节级变换,带有固定的密钥调度)加密,并以
application/octet-stream;tt-data=a 的形式发送。它带着完整的签名集发出,所以你需要可用的签名才能获取设备,而签名又需要设备。你用客户端生成的字段和占位为零的已签发字段来引导启动。
请求体的结构,以及其中关键的部分:
{
"magic_tag": "ss_app_log",
"header": {
// app identity: must agree with the aid in the query string
"aid": <aid>, "package": "<package>",
"app_version": "<app version>", "version_code": <version code>,
"sdk_version": "...", "git_hash": "...", "sig_hash": "...",
// hardware: every field here has to be internally consistent
"device_model": "<device model>", "device_brand": "Samsung",
"device_manufacturer": "samsung", "cpu_abi": "arm64-v8a",
"os_version": "12", "os_api": 30,
"resolution": "2280*1080", "density_dpi": 440,
"rom": "...", "rom_version": "...",
// identity you generate and are about to trade in
"cdid": "<uuid4>", "openudid": "<16 hex>",
"clientudid": "<uuid4>", "google_aid": "<uuid4>",
// region: carrier must plausibly exist in this country
"region": "SG", "sim_region": "sg", "carrier": "Singtel",
"mcc_mnc": "52506", "tz_name": "Asia/Singapore", "tz_offset": 25200,
"custom": {
"screen_width_dp": 408, "screen_height_dp": 883,
"web_ua": "Dalvik/2.1.0 (Linux; U; Android 12; <device model> Build/...)",
"apk_last_update_time": 1788361409271
},
"apk_first_install_time": 1788360902118
},
"_gen_time": 1788361402240
}
其中每个字段都会与其他字段相互校验。一台三星 <device model> 有特定的屏幕分辨率、特定的 DPI、特定的 ABI,并且出厂时搭载特定范围的 Android 版本。它在某些国家由运营商销售,在另一些国家则没有。一台从未在该网络上销售的旗舰手机不是真手机,注册会被拒绝。
与其按程序化方式生成这些,我从约 250 个真实 Android 设备档案的目录中构建它们,并与 MCC/MNC 配对的运营商表(约 2,000 行,源自公开的号码规划数据)交叉匹配。选一款手机,选一个目标国家真实存在的运营商,填入一致的数值。
成功注册会返回你需要的两个 id:
{
"device_id_str": "7680616891110524437",
"install_id_str": "7680617333853718293",
"new_user": 1
}大多数实现到此为止。
激活调用
注册可用后,大多数端点都能正常响应。视频列表、搜索、话题标签、声音,都没问题。但 /aweme/v1/user/profile/other/,即完整的个人资料记录,每一次都返回空的 200,在我制作的每一台设备上,永远如此。
最明显的嫌疑是签名,但这是错的。线索在于,一批更早的设备——几个月前由不同代码生成——在同一个端点上、用同一个签名器和同样的参数,却能正常工作。唯一的差别在于设备的创建方式,而它归结为一个额外的 HTTP 调用:
GET /service/2/app_alert_check/?<common params>
&cronet_version=...&ttnet_version=...
&tt_info=<base64url(TTEncrypt(<60-field key=value blob>))>
→ {"message":"success"}就是这样。它不返回任何你需要的东西。它看起来像遥测,功能上它就是遥测。这是真实应用在启动时、请求任何数据之前所做的调用。
这就是该调用的用途。一台注册后立即开始查询 API 的设备,从字节跳动的角度看,是一个从未启动过的安装。仅注册并不能让你成为一个正在运行的应用。启动调用才能。
| 设备生成 | 个人资料端点 | |
|---|---|---|
| 仅注册 | 0 / 360 | 签名正确。空响应体,每一次,无限期。 |
| 注册 + 启动调用 | 100 / 100 | 相同代码,相同签名,多一个请求。 |
0 / 360 到 100 / 100
从零到百分之百,来自一个你丢弃其响应的调用。它没有任何文档记载。它在签名转储中不可见。它不会大声报错。而且由于症状是空的 200,它与签名器损坏无法区分。
这个 tt_info blob 是请求中有意思的部分:大约六十个
key=value 对(GAID、时区、安装 id、设备 id、运营商、屏幕、ABI、区域设置、一个请求 UUID)经过 TTEncrypt 加密和 base64url 编码。这是
应用在启动时报告其完整环境。我的猜测,而且仅仅是猜测,是
这里就是设备被标记为真实安装而非单纯
注册的地方;我没有尝试去证明它,因为经验结果
是明确无误的。
在使用设备之前先证明它
激活修复了 profile 端点,但它引入了一个二阶问题: 激活本身有时会静默失败,而一个激活失败的设备 在你使用它之前看起来与成功的设备完全一样。
所以生成并不止于激活。它以对已知 创作者的一次真实读取结束。如果返回真实内容,设备就加入池中。如果 没有,它就被丢弃。不重试,不隔离。丢弃。
func GenerateDevice(client *http.Client, country string) (map[string]any, error) {
tmpl, err := NewAndroidTemplate() // handset × carrier
...
if err := registerDevice(client, tmpl); err != nil {
return nil, fmt.Errorf("register: %w", err)
}
// Without this TikTok will not serve profile detail to a fresh device.
if err := appAlertCheck(client, tmpl); err != nil {
return nil, fmt.Errorf("activate: %w", err)
}
// Survivorship filter: only provably-capable devices enter the pool.
if !profileCapable(client, tmpl) {
return nil, errors.New("profile probe failed: device not capable")
}
return tmpl, nil
}三阶段流水线。大约 60-95% 的尝试能通过它,几乎完全取决于代理质量。
没有过滤器,你得到的池是可用设备和悄悄死掉的 设备的混合,而且因为死设备返回空的 200,与其他所有失败一样,池会无形地退化。你的 成功率会在几天内逐渐下降,而日志中没有任何东西能解释它。
有了过滤器,池在构造上就是统一可用的。实时健康状态 可以从运行中的服务器看到:
$ curl -s localhost:8080/v1/devices | jq
{
"live": 43,
"generated_total": 43,
"rejected_total": 2,
"evicted_total": 0,
"success_total": 177,
"failure_total": 74,
"generation_survival_rate": 0.9555
}X-Argus 内部
X-Argus 不是字符串的哈希。它的明文是一个
protobuf 消息,采用 proto3 线格式,varint 和长度分隔字段,然后经过两阶段加密
流水线。
该消息携带的字段包括:
type Argus struct {
Magic int32 // fixed marker
Version int32
Rand int64 // per-request random, 0x10000000..0xFFFFFFFF
MsAppID string // "1233" / "<aid>"
LicenseID string
DeviceID string
SdkVersion int32
SdkVersionStr string
AppVersion string
EnvCode []byte
CreateTime int64 // X-Khronos, again, inside the blob
BodyHash []byte // SM3 of the body (16 zero bytes on GET)
QueryHash []byte // SM3 of the literal query string
AlgorithmCount struct {
SignCount int32 // how many signatures this install has made
ReportCount int32
SettingCount int32
Timestamp int64
}
SecDeviceToken string
IsAppLicense int64
PskHash []byte
CallType int32
ChannelInfo struct { PhoneInfo, Channel string; ... }
}签名者实际填充的子集。确定字段编号是大部分逆向工程工作。
AlgorithmCount.SignCount 是一个计数器,记录这个安装
已经签名了多少请求。真实手机的计数器会在
安装的生命周期内稳步攀升。一个发出恒定值、或每次
请求都重置为零的爬虫,即使每个单独的
签名都能验证,也在产生统计上明显的模式。我为每个设备在合理范围内随机播种它,它
从未成为问题,但正是这种字段的存在,使得
朴素重放可以在总体上而非单个请求上被检测到。
流水线
protobuf 序列化后,按顺序经过以下步骤:
1. pb = proto3_serialize(Argus{...})
2. padded = pkcs7(pb, 16)
// key derivation: the signing key is a per-aid 32-byte constant
3. xmKey = SM3( derived from the app's signing key and rand )
4. enc1 = Simon-128/256-ECB( key = xmKey, padded )
5. enc1 = reverse_bytes(enc1)
6. enc1 = xor_mix(enc1, derived_from(rand)) // bit-level, order-sensitive
// framing: a version byte, entropy, and a 3-byte marker built from
// the first bytes of two separate SM3 digests
7. framed = hexFirstByte(aid) || rand_bytes || append_array || enc1
8. enc2 = AES-128-CBC( key, iv derived from the app's signing key,
framed )
9. X-Argus = base64( rand_lo || enc2 )两层加密,使用不同的原语和不同的密钥派生, 中间夹着一次字节反转和一次 XOR 混合。没有任何单独 步骤是困难的。难点完全在于没有反馈 。第 6 步做错,你会产生一个完全格式良好、大小正确、 base64 干净的头部,而 TikTok 会用空的 200 来回应。
这就是为什么实现为每个 原语都附带独立的测试向量。你分别针对已知 输入/输出对验证 Simon、Speck、SM3 和 TTEncrypt,这样当请求失败时,你已经知道加密是正确的 而 bug 在组合中。
Simon、Speck 和 SM3
原语的选择是刻意的,它透露了威胁模型的一些信息。
ARX 密码
Simon 和 Speck 是 NSA 于 2013 年发布的轻量级分组密码。两者都是 ARX 构造,完全由模 Addition(加法)、按位 Rotation(旋转)和 Xor(异或)构成。没有 S 盒。没有查找表。没有乘法。
Speck 的轮函数,完整来说,只有两行:
x = (ROR(x, α) + y) ⊕ k
y = ROL(y, β) ⊕ x
// for the 128-bit block size: α = 8, β = 3, 64-bit words
// 128/256 configuration: 256-bit key, 34 roundsSimon 是同样的思路,只是把加法换成了 AND,这让它在硬件上更便宜,在软件上略贵一些:
x' = y ⊕ (ROL(x,1) & ROL(x,8)) ⊕ ROL(x,2) ⊕ k
y' = x
// 128/256 configuration: 256-bit key, 128-bit block, 72 rounds,
// round constants from the Z4 sequence (a 62-bit LFSR period)为什么用这些而不用 AES?三个原因,而且它们都指向同一个方向:
- 它们编译出来几乎不占空间。几百字节的 ARM 代码,没有表,没有依赖数据的访存。当代码存在于一个混淆过的原生库中时,这一点很重要——这个库必须小,而且必须避免缓存时序侧信道,否则会很容易被定位。
- 它们不在你的标准库里。AES 在每种语言里都是一个函数调用。Simon 和 Speck 你得自己实现,而且参数空间很大(分组大小、密钥大小、轮数、旋转常数、密钥编排),所以猜错会生成看似合理的密文,而且不会报错。
- 它们很容易被微妙地弄错。Speck 的密钥编排复用了轮函数本身。把字序或字节序弄错,你就会得到 32 个看起来有效但全都错误的轮密钥。
注意 AES-128-CBC 确实在流程中,作为外层。有趣的设计选择在于,内层——真正保护 protobuf 的那一层——是你不能直接调用的那一层。
SM3
SM3 是中国国家密码哈希标准(GB/T 32905-2016)。256 位输出,512 位分组,Merkle-Damgård 构造,压缩函数在结构上类似 SHA-256,但有两个并行的消息扩展调度和一个不同的轮函数:
// two boolean functions, switching at round 16
FF(x,y,z) = x ⊕ y ⊕ z // j < 16
= (x&y) | (x&z) | (y&z) // j ≥ 16
GG(x,y,z) = x ⊕ y ⊕ z // j < 16
= (x&y) | (~x&z) // j ≥ 16
// IV
7380166F 4914B2B9 172442D7 DA8A0600 A96F30BC 163138AA E38DEE4D B0FB0E4E
SM3 出现在字节跳动的技术栈中,原因显而易见。对他们来说还有一点很有用:它不在任何西方标准库中。而且那两个消息扩展数组(W 和 W')可以轻易互换,所以流传的参考实现中有很大一部分是错误的,而且这些错误只在某些输入上才会显现。
TTEncrypt
主体密码,用于注册载荷和激活数据块。不是标准构造,只是一个带小表的固定密钥字节变换。它在密码学上并不严谨,也无意如此;它的存在是为了阻止随意的流量检查,而且是这四种中最容易重新实现的。
X-Ladon
比 Argus 简单得多,值得完整展示,因为它很好地说明了这些方案是如何分层的:在昂贵的门前面放一道便宜的门。
plaintext = "<khronos>-<license_id>-<aid>"
key = ascii_hex( MD5( rand_bytes(4) || aid ) ) // 32 bytes
cipher = Speck-128/256-ECB( key, pkcs7(plaintext) )
X-Ladon = base64( rand_bytes || cipher )四个随机字节、一个 MD5,以及对一个用短横线连接的字符串做一次 Speck 加密。随机字节被前置到输出中,这样服务器可以重新推导出密钥。这就是整个构造。
它会过滤掉完全没有访问过该应用的人,而且大规模验证的成本几乎为零。Argus 是之后运行的高成本检查。
版本门控
正确的签名是必要条件,而非充分条件。某些端点受客户端构建版本门控,而且这个门控在服务端。
最明显的例子是评论。同一设备、同一签名者、同一秒、一切都相同。只有 version_code 不同:
| 应用构建版本 | /aweme/v2/comment/list/ |
|---|---|
| 较旧构建版本 | 空的 200 |
| 较新构建版本 | 178 KB 的评论 |
版本升级还解锁了评论回复和粉丝列表。并不是较旧构建版本的签名被拒绝,因为它验证得好好的。而是该端点根本不会提供给那个客户端版本。
实际上这意味着应用版本是每个端点的属性,而不是全局设置。在我的目录中,每个端点记录它所需的构建版本,服务端在签名前会透明地替换那四个版本字段:
func WithAppVersion(dev *DevInfo, version, code string) *DevInfo {
c := *dev
c.App.AppVersion = version
c.App.AppVersionCode = code
c.App.ManifestVersionCode = code
c.App.UpdateVersionCode = code
return &c
}在所有地方都固定使用最新构建版本并不是答案,因为较新的构建版本会收紧其他检查。目录的存在就是为了让每个端点落在对它有效的构建版本上。
区域划分
第三道门,也是毫无线索可循的一道:没有错误、没有重定向、响应中没有任何提示。
TikTok 并不是运行一个 API。它运行着多个区域数据中心: 一个在新加坡,两个在美国,还有 其他。而且它们不会向同一批设备提供相同的端点。
在激活问题解决后,个人资料详情在全新生成的设备上仍然失败,而在较旧的设备池上却能正常工作。同样的代码,同样的签名者。差异最终出在主机上:
| 主机 | 全新设备,个人资料详情 | 响应 |
|---|---|---|
| 美国主机 | 50 / 50 | 9,517 字节 |
| 新加坡主机 | 1 / 50 | 空的 200 |
| 第二个美国主机 | 0 / 50 | 空的 200 |
同一秒、同一凭证、同一签名请求,三个主机,一个答案。而
music/detail 则相反:它在第二个美国主机上响应,而在几乎服务其他所有内容的新加坡主机上什么都不返回。
所以主机是端点定义的一部分。不是全局基础 URL,而是每条路由的属性,通过测量确定,因为没有任何文档可供查阅:
{
ID: "user.info", Route: "/v1/user/info",
Host: tiktok.HostUS, // the ONLY host that serves this to fresh devices
Path: "/aweme/v1/user/profile/other/",
...
},
{
ID: "music.info", Route: "/v1/music/info",
Host: tiktok.HostUS2, // and this one is the only host for THIS
Path: "/aweme/v1/music/detail/",
...
},
这里有一个有用的二阶效应。设备注册的区域也会影响区域范围端点上的内容:热门音效和热门分类货架。运行一个注册在 US 的设备池,
另一个注册在 BR,同样的调用就能得到真正不同的榜单,这就是无需任何按国家代码就能获得按国家数据的方法。
TLS 指纹
第四道门,也是应用开发者通常检查的每一层都看不见的一道。
在你的任何字节到达之前,你的 TLS 客户端会发送一个 ClientHello。其中的一切,以及关键的是其中一切的顺序,都是一个指纹。 JA3 是捕获它的标准方式,是对五个逗号连接字段的 MD5:
TLSVersion , Ciphers , Extensions , EllipticCurves , ECPointFormats
771,4865-4866-4867-49195-...,0-23-65281-10-11-...,29-23-24,0
↓ MD5
<32 hex characters>JA3 字符串及其哈希的形态(已缩短)。密码套件列表和扩展列表是有序的,而不同库对它们的排序方式不同。
该指纹能高精度地识别你的 TLS 库,通常还能识别其版本。OpenSSL、BoringSSL、NSS、Go 的 crypto/tls、Java 的 JSSE
在交换任何一个 HTTP 字节之前就都能被区分开。
Go 的 crypto/tls 有一个非常独特的指纹。而没有任何 Android 应用发出过它,因为 Android 应用通过 OkHttp 使用 BoringSSL。TikTok 的
美国主机检查。
隔离出它的实验
同一个请求从 Python 发出能成功,从 Go 发出却失败。签名逐字节相同, 参数逐字节相同,cookie 无关紧要(有和没有都能成功)。把 Python 刚刚使用的确切请求头 dump 出来,从 Go 重放它们,其他一切保持不变。相同 URL、相同签名、相同设备、相同秒数:
// identical request, three clients, back to back
python urllib3 / OpenSSL → 9,517 bytes
curl OpenSSL → 9,519 bytes
go crypto/tls → 0 bytes ← HTTP 200请求本身没有任何不同。握手不同。
修复方案是 uTLS,它 让你指定要发出的确切 ClientHello,而不是接受 Go 为你构建的那个:
cfg := &utls.Config{ServerName: host, NextProtos: []string{"http/1.1"}}
conn := utls.UClient(raw, cfg, utls.HelloAndroid_11_OkHttp)
if err := conn.HandshakeContext(ctx); err != nil {
return nil, fmt.Errorf("utls handshake: %w", err)
}一行 profile 选择。新设备上的 profile 详情从 0% 提升到 100%。
NextProtos 被有意固定到 http/1.1。
Android profile 宣称支持 h2,但其底层传输仅支持 HTTP/1.1。
协商 h2 后你会得到一个没人能通信的连接。
Go 代理陷阱
简短,且是 Go 特有的。
接上 uTLS,直接测试它,确认指纹已改变,然后把它放到轮换代理后面。失败立刻回来了。
原因是当设置了 Proxy 时,http.Transport 会忽略
DialTLSContext。它会拨号到代理,自己发出 CONNECT,然后在生成的隧道上运行自己的标准库
握手。你的自定义 dialer 被静默丢弃。没有错误、没有警告、没有日志行。
你必须手动完成隧道:
dialTLS := func(ctx context.Context, network, addr string) (net.Conn, error) {
// 1. plain TCP to the proxy
raw, err := d.DialContext(ctx, "tcp", proxyURL.Host)
...
// 2. CONNECT by hand: this is the part Transport would have done
req := &http.Request{Method: "CONNECT", URL: &url.URL{Opaque: addr}, Host: addr, ...}
req.Write(raw)
resp, _ := http.ReadResponse(bufio.NewReader(raw), req)
if resp.StatusCode != 200 { return nil, fmt.Errorf("CONNECT: %s", resp.Status) }
// 3. NOW run the uTLS handshake over the tunnel
u := utls.UClient(raw, cfg, utls.HelloAndroid_11_OkHttp)
return u, u.HandshakeContext(ctx)
}
tr := &http.Transport{
DialTLSContext: dialTLS,
DisableKeepAlives: true, // see the next section
// note: NO Proxy field. Setting it would bypass all of the above.
}
Proxy 字段被有意从 transport 中省略。设置它
正是静默丢弃 dialer 的原因。
检测空的 200
即使通过了全部四道关卡,你仍然需要针对每个响应知道 你是否真的拿到了数据。状态码不会告诉你。检查必须基于内容:
if resp.StatusCode != http.StatusOK {
return body, fmt.Errorf("upstream HTTP %d", resp.StatusCode)
}
if len(body) < minBodyBytes { // 64
// The soft block: 200 with (almost) nothing in it.
return body, fmt.Errorf("empty upstream body (%d bytes)", len(body))
}
var probe map[string]json.RawMessage
if err := json.Unmarshal(body, &probe); err != nil {
// HTML, usually a proxy error page rather than TikTok
return body, errors.New("upstream body is not a JSON object")
}
if raw, ok := probe["status_code"]; ok {
var n int
if json.Unmarshal(raw, &n) == nil && n != 0 {
return body, fmt.Errorf("upstream status_code %d", n)
}
}
四个条件,按顺序:HTTP 状态、长度下限、可解析的 JSON 对象、
干净的内部 status_code。任何一项失败都会用来自不同 IP 的不同设备重试。
除非重试毫无意义
一些非零的 status_code 值是 TikTok 在回应,
而不是拒绝。把这些重试四次会浪费四台设备和四个 IP:
status_code | 消息 | 被视为 |
|---|---|---|
| 2065 | 用户不存在。 | 404,不重试 |
| 3170 | user not exists | 404,不重试 |
| 3002060 | Profile 用户隐藏了关注列表 | 403,不重试 |
它向调用方呈现为真实答案,而不是网关失败:
$ curl -s localhost:8080/v1/user/following?user_id=6744630345964389381 | jq
{
"error": {
"code": "hidden_by_user",
"message": "This creator has hidden their following list. Most accounts do; there is no way around it.",
"upstream_status_code": 3002060,
"upstream_status_msg": "Profile user is hiding following list",
"retried": false,
"retry_would_not_help": true
}
}其他所有情况都保留完整的重试预算,当预算耗尽时你会得到 每次尝试的明细,而不是一个笼统的失败,这正是让它在生产环境中可调试的原因:
{
"error": {
"code": "upstream_failed",
"attempts": 4,
"attempt_failures": [
{"attempt": 1, "reason": "empty upstream body (0 bytes)"},
{"attempt": 2, "reason": "empty upstream body (0 bytes)"},
{"attempt": 3, "reason": "transport: ... EOF"},
{"attempt": 4, "reason": "upstream HTTP 429"}
]
}
}来自目录中最弱端点的真实输出。两个软封锁、一个断开的连接,以及一个诚实的速率限制。
Keep-alive 固定出口 IP
在通过全部四道关卡后,最高流量的端点在 1.74 亿次尝试中达到 88.2%。不错,但在那个量级下, 缺失的 12% 就是两千万条丢失的记录。
显而易见的做法是增加重试次数。但这毫无作用,因为速率限制和连接复用之间存在相互作用。
这里的速率限制是按出口 IP 计算的。轮换代理网关会为每个 TCP 连接分配一个出口 IP。HTTP keep-alive——每个客户端默认都会启用,而且通常正是你想要的——会在该连接的整个生命周期内把你固定在一个出口 IP 上。
所以重试是从刚刚被拒绝的那个地址发出的。下一次也是。再下一次也是:
// keep-alive on a rotating proxy
attempt 1 → exit 203.0.113.44 → empty 200
attempt 2 → exit 203.0.113.44 → empty 200 ← same IP
attempt 3 → exit 203.0.113.44 → empty 200 ← same IP
attempt 4 → exit 203.0.113.44 → empty 200 ← same IP
// fresh connection per attempt
attempt 1 → exit 203.0.113.44 → empty 200
attempt 2 → exit 198.51.100.7 → ok四次尝试,一个 IP,四次完全相同的失败。重试预算什么也没换来。它在结构上就无能为力。
为什么朴素的修复方案卡在 96%
在所有地方都设置 DisableKeepAlives: true 后,成功率提升到了 96.2%,然后就停住了。原因是,在完全并发的情况下,你现在要为每一次尝试都支付一次 TLS 握手成本,包括那约 88% 本来第一次就会成功的请求。瓶颈变成了代理网关,而不是 TikTok,它开始拒绝隧道:
{"attempt": 1, "reason": "transport: proxy CONNECT: 466 Too Many Requests"}失败只是转移了,并没有消失。生产环境的形态是两者的混合,归结为两个池和一个策略开关:
// First-attempt pool: keep-alive, so the common case costs no handshake.
r.proxyPool, _ = httpclient.New(httpclient.Config{ProxyURL: cfg.ProxyURL})
// Retry pool: DisableKeepAlives => fresh TCP => NEW exit IP.
r.proxyPoolFresh, _ = httpclient.New(httpclient.Config{
ProxyURL: cfg.ProxyURL, DisableKeepAlives: true,
})
// ...and in the request path:
pool := r.proxyPool
if attempt > 0 && r.proxyPoolFresh != nil {
pool = r.proxyPoolFresh // rotation exactly where it matters
}| 配置 | 成功率 | 瓶颈 |
|---|---|---|
| 处处启用 keep-alive | 88.2% | 重试复用被封锁的 IP |
| 处处禁用 keep-alive | 96.2% | 代理网关,握手风暴 |
| 仅在首次尝试时启用 keep-alive | 99.3% | 无 |
在四个分片上对数亿次调用进行了测量。下面描述的自托管服务器保留了更简单的始终全新连接的形式,因为单个实例远未达到出现第二个瓶颈的负载水平。
代理:唯一的运行成本
到这里为止的一切都是你只需解决一次的软件问题。代理是唯一的外部依赖,也是唯一的经常性成本,而且对代理的需求是结构性的,而非偶然的。
简要说明什么是代理
代理是一台代表你发出请求的机器。你连接到它,它连接到 TikTok,TikTok 看到的是代理的 IP 地址,而不是你的。一个轮换网关是指每个新连接都从一个大池子中的不同地址发出,这正是这里关键的特性。
为什么它是强制性的而非建议性的
有两个原因,第二个是人们低估的那个。
速率限制是按出口 IP 计算的。一个地址会获得一个预算,而且这个预算并不大。没有代理的话,你系统中的每个请求都共享同一个地址,几分钟内就会耗尽。
没有轮换,重试在结构上毫无用处。这就是上一节中的要点。当一个请求被软封锁时,重试必须从不同的地址发出,否则会以完全相同的方式失败。按连接轮换正是从 88% 跃升到 99.3% 背后的全部机制。静态代理只给你一个 IP,因此什么也帮不了你。
所以需求是一个轮换网关,在做出任何承诺之前,你可以用一行代码验证你的网关是否真的在轮换:
$ for i in 1 2 3; do curl -s --proxy "$PROXY_URL" https://api.ipify.org; echo; done
203.0.113.44
198.51.100.7 # different IP each time = rotating, good
192.0.2.19
如果同一个地址返回了三次,那么你的重试预算只是摆设,无论你把 MAX_ATTEMPTS 设置成什么,你的成功率都会停留在接近首次尝试成功率的水平。
它实际要花多少钱
首先要明白的是,你不应该按 GB 付费。在这个领域,按量计费方案是默认推荐,但它们并不适合这种工作负载,因为返回最有价值数据的端点恰恰是以 MB 计的。一页视频是 1.1 MB。一页推荐创作者是 2.9 MB。按量计费会把其中每一项都变成一条账单明细。
两种代理类型都有包月订阅,这才是你想要的。两个档位基本覆盖了所有人:
| 档位 | 你能得到什么 | 费用 | 适合场景 |
|---|---|---|---|
| 轮换数据中心 | 100 个并发线程,200 Mbit/s | 约 $150 / 月 | 数百万条记录。几乎所有人都应该从这里开始。 |
| 无限住宅 | 不计量的住宅代理池 | 约 $950 / 月 | 数十亿条。本页顶部那次六天的运行所用的方案。 |
对于丰富几十万创作者、每日追踪声音或绘制某个细分领域,$150 档位已经足够,而非妥协:轮换数据中心代理池可以注册设备、通过激活,并维持下方表格中的成功率。
住宅档位是你在把数据中心档位跑满之后才升级到的方案。
$150 档位能买到什么
在包月方案中,两个限制是线程数(可以有多少请求在途)和线路速度(每秒多少字节)。哪一个成为瓶颈完全取决于响应大小,而这里的端点相差两个数量级:一个个人资料是 9.5 KB,一页视频是 1.1 MB。
下面的数字是根据基准测试中测得的响应大小和延迟,在 100 线程和 200 Mbit/s 下算出的。它们是满饱和时的上限,所以请把它们当作上限而非承诺。
| 采集内容 | 每次响应 | 瓶颈限制 | 上限 |
|---|---|---|---|
| 创作者个人资料 (user.info) | 9.5 KB | 线程数 | ~140/s · ~12M/天 |
| 评论 (每页 20 条) | 174 KB | 线程数 | ~1,600/s · ~140M/天 |
| 粉丝 (每页 20 条) | 178 KB | 线程数 | ~1,600/s · ~140M/天 |
| 带完整元数据的视频 (每页 20 条) | 1.1 MB | 线路速度 | ~450/s · ~39M/天 |
| 创作者图谱遍历 (user.recommended) | 2.9 MB | 线路速度 | ~350/s · ~30M/天 |
在小端点上,你会先耗尽线程数,远早于带宽,所以解决办法是提高线程数。在
视频端点上,你会在约每秒 22 个请求时把线路跑满,再多的线程也毫无用处。这正是 MAX_CONCURRENT
用来控制的数字,而把它设得超过你的方案所能承载的值,会在尝试失败中产生
proxy CONNECT: 466 Too Many Requests,而不是更高的吞吐量。
一个订阅在哪里会不够用
一个 $150 订阅可以轻松采集数百万条记录,在小端点上可达数千万条。但对于数十亿条来说还不够。本页顶部那次六天的运行需要约每月 $950 的无限住宅档位,并分片到四个实例上,而在那种规模下,代理费用是整个运营的主导成本。
无论哪种方式,扩展都是横向的:再加一个订阅,再启动一个指向它的服务器实例。代码无需任何改动。
购买时应该关注什么
- 轮换,且只有一个网关端点。 在付费一个月之前,先用上面的循环验证轮换是否生效。
- 公布的线程上限。 如果没有说明,就假定它很低。这才是你实际需要据此规划的数字。
- 固定费率优于按量计费,除非你清楚自己的用量很小且会一直很小。按量计费方案惩罚的恰恰是那些返回最有价值数据的端点。
- 国家定向,如果你想要地区图表的话。设备地区和出口地区应当一致。
- 先试用或先买一个月。 注册成功率才是真正的考验,而且它在宣传同一产品的不同供应商之间差异很大。在做出承诺之前,先生成 30 个设备并读取
generation_survival_rate。
实测成功率
每个端点都附带真实的成功率而非宣称值:各 100 次调用,最多 4 次尝试,针对通过轮换代理网关新生成的池进行测试。种子是通过遍历 API 实时发现的,而非硬编码,这会改变数字。下面的说明解释了原因。
| 端点 | 成功率 | 平均尝试次数 | 平均响应 |
|---|---|---|---|
| user.info | 100% | 1.00 | 9 KB |
| user.recommended | 100% | 1.08 | 2.9 MB |
| music.posts | 100% | 1.06 | 1.7 MB |
| music.posts_fresh | 100% | 1.34 | 1.7 MB |
| music.trending | 100% | 1.00 | 85 KB |
| music.related | 100% | 1.00 | 139 KB |
| hashtag.info | 100% | 1.00 | 3.8 KB |
| hashtag.posts_fresh | 100% | 1.00 | 1.3 MB |
| search.general | 100% | 1.06 | 527 KB |
| search.music | 100% | 1.11 | 100 KB |
| search.users | 100% | 1.00 | 86 KB |
| trending.categories | 100% | 1.07 | 380 KB |
| trending.effects | 100% | 1.10 | 232 KB |
| video.comment_replies | 100% | 1.05 | 8 KB |
| video.info | 94% | 1.96 | 58 KB |
| user.following | 93% | 1.90 | 23 KB |
| user.followers | 92% | 2.12 | 178 KB |
| video.comments | 92% | 2.02 | 174 KB |
| search.videos | 92% | 1.82 | 639 KB |
| user.posts | 90% | 2.26 | 1.1 MB |
| music.info | 90% | 2.15 | 12 KB |
| hashtag.posts | 89% | 2.17 | 1.3 MB |
| hashtag.search | 78% | 2.16 | 11 KB |
| feed.recommended | 10% | 3.93 | 248 KB |
平均尝试次数是信息量更大的那一列。一个 100% 成功率、平均 1.00 次尝试的端点每次都能一次成功。一个 92% 成功率、平均 2.12 次尝试的端点,大约有一半的首次尝试被软封锁,并在重试时恢复,这意味着加大预算就能改善它;而一次就干净的端点则无法通过任何手段改善,因为没有什么可改善的。
feed.recommended 确实很弱,各次运行中只有 10-25%,而且它主要受制于诚实的 429 而非软封锁。一个没有观看历史的匿名设备请求个性化信息流,正是 TikTok 最想限制的流量形态。它发布时就被记录为弱项,并指明了两个 100% 的替代方案。
种子是如何选择的
种子是实时发现的,而非硬编码:先是创作者,然后是视频,然后是一条确实有回复的评论,然后是一个确实有视频的声音,然后是一个话题标签。这对准确性很重要。把一个粉丝基准指向一个隐藏其关注列表的创作者,你测到的就是 TikTok 正确地回答“这里什么都没有”,却把它记为失败。
24 个端点
以上所有内容都打包为一个自托管的 Go 服务。单个二进制文件,无数据库、无队列、无模拟器、无原生库。参考数据已编译在内。
$ cp .env.example .env # set PROXY_URL
$ docker compose up -d
$ docker compose logs -f
TikTok Open API 1.0.0 starting
config: port=8080 country=SG pool=15/30 attempts=4 concurrency=32 proxy=http://***@gw:9000 auth=true
pool: 0 device(s) live, filling to 30 ...
listening on http://0.0.0.0:8080 (GET /healthz, GET /v1/endpoints)
pool: initial fill complete, 43 device(s) live真实的启动输出。冷启动需要 15 到 60 秒;池会持久化到磁盘,因此之后的重新启动是即时的。
全部 24 条路由都是 GET,都接受查询参数,都原样返回 TikTok 的 JSON。
创作者
| 路由 | 参数 | 返回 |
|---|---|---|
| /v1/user/posts | user_id, count, max_cursor | 视频,每个都带有完整的作者对象 |
| /v1/user/info | user_id, sec_user_id | 完整资料,包括bio_email、链接、商业标志 |
| /v1/user/followers | user_id, sec_user_id, count, max_time | 粉丝列表 |
| /v1/user/following | user_id, sec_user_id, count, max_time | 关注列表,已发布 |
| /v1/user/recommended | user_id, sec_user_id, count | TikTok 自有的相似创作者图谱 |
视频
| 路由 | 参数 | 返回 |
|---|---|---|
| /v1/video/info | aweme_id | 媒体、统计、声音、标签、作者 |
| /v1/video/comments | aweme_id, count, cursor | 评论,附带评论者的用户对象 |
| /v1/video/comments/replies | aweme_id, comment_id, count, cursor | 评论树的第二层 |
声音
| 路由 | 参数 | 返回 |
|---|---|---|
| /v1/music/info | music_id | 声音详情,包括user_count |
| /v1/music/posts | music_id, count, cursor | 使用该声音的热门视频 |
| /v1/music/posts/fresh | music_id, count, cursor | 使用该声音的最新视频 |
| /v1/music/trending | count, cursor | 热门声音排行榜,按设备地区 |
| /v1/music/related | aweme_id, count, cursor | 为视频推荐的声音 |
话题标签与搜索
| 路由 | 参数 | 返回 |
|---|---|---|
| /v1/hashtag/search | keyword, count, cursor | 话题标签 id 及浏览量 |
| /v1/hashtag/info | hashtag_id | 话题标签详情 |
| /v1/hashtag/posts | hashtag_id, count, cursor | 该标签下的热门视频 |
| /v1/hashtag/posts/fresh | hashtag_id, count, cursor | 该标签下的最新视频 |
| /v1/search/videos | keyword, count, offset | 视频 |
| /v1/search/general | keyword, count, offset | 混合的创作者、视频和标签 |
| /v1/search/music | keyword, count, cursor | 声音 |
| /v1/search/users | keyword, count, cursor | 用户名或名称 → 数字user_id |
发现
| 路由 | 参数 | 返回 |
|---|---|---|
| /v1/trending/categories | count, cursor | 应用的热门货架 |
| /v1/trending/effects | count, cursor | 带有sticker_detail的热门特效视频 |
| /v1/feed | count, max_cursor | 匿名 For You 信息流(较弱,见上文) |
一个真实响应
裁剪至有趣的字段。原始对象有数百个键:
$ curl -s "localhost:8080/v1/user/posts?user_id=6744630345964389381&count=20" \
| jq '{has_more, max_cursor, first: (.aweme_list[0] | {aweme_id, desc, statistics, music, author})}'
{
"has_more": 1,
"max_cursor": 1751028792000,
"first": {
"aweme_id": "7678101694902832397",
"desc": "Who Remembers 2022? #fortnite #piececontrolkyle #dogwater",
"statistics": {
"play_count": 19438, "digg_count": 2461,
"comment_count": 39, "share_count": 176
},
"music": {
"id_str": "7245172246876227585",
"title": "Need 2 (Instrumental)"
},
"author": {
"uid": "6744630345964389381",
"unique_id": "freakynaughty",
"nickname": "freaky",
"follower_count": 1277258
}
}
}注意,作者对象嵌入在每个视频中。一次请求就能获得二十个 视频以及完整的创作者记录。在网页上,这需要二十一次请求。
请求元数据在响应头中返回,而不是污染响应体:
HTTP/1.1 200 OK
X-Endpoint-Id: user.posts
X-Attempts: 2 ← first try was soft-blocked
X-Elapsed-Ms: 1874
X-Upstream-Region: sg
X-Device-Region: SG网页不提供的字段
| 字段 | 位置 | 填充率 |
|---|---|---|
| statistics.collect_count | 任意视频 | 始终。收藏,通常是最早的动向信号 |
| music.user_count | 任意声音 | 始终。使用该声音制作的视频数 |
| author.ins_id | 视频作者对象 | 约 26% 的创作者 |
| author.youtube_channel_id | 视频作者对象 | ~19% |
| bio_email | 仅user.info | ~1% |
| commerce_user_level | user.info | 始终 |
| bio link | 无处 | 0%。移动 API 中完全没有 |
最后一行是在两个端点上对 579 位可能携带该字段的创作者测量得出的。 外链个人资料链接仅是网页表面的字段。
教程:声音趋势检测器
具体的实操示例,因为仅凭端点列表无法告诉你这些数据有什么用。 目标:在声音明显流行之前,找到当下正在起飞的声音。
信号是music.user_count,即有多少视频使用了某个声音制作。绝对数字告诉你一个声音很大。变化率
告诉你它正在移动,这才是你想要的。
第 1 步。对排行榜进行快照
curl -s "$API/v1/music/trending?count=50" \
| jq -r '.music_list[] | [.id_str, .user_count, .title] | @tsv' \
> "sounds-$(date +%s).tsv"用 cron 每小时运行一次。每一行是id, uses, title。
第 2 步。对比连续快照
import glob, csv, collections
snaps = sorted(glob.glob("sounds-*.tsv"))[-2:]
prev, curr = [{r[0]: (int(r[1]), r[2])
for r in csv.reader(open(f), delimiter="\t")} for f in snaps]
movers = []
for mid, (n, title) in curr.items():
was = prev.get(mid, (0, title))[0]
if was > 0:
movers.append((n / was - 1, n - was, title, mid))
for growth, delta, title, mid in sorted(movers, reverse=True)[:10]:
print(f"{growth:6.1%} +{delta:>8,} {title[:40]:<40} {mid}")第 3 步。确认它确实在加速
图表中的增长只是候选信号,而非确认。区分真正的加速与图表位置造成的假象,关键在于近期视频的时间分布。用该声音拉取最新视频,看看它们的上传时间聚集得有多紧密:
curl -s "$API/v1/music/posts/fresh?music_id=$MID&count=30" \
| jq '[.aweme_list[].create_time] | (max - min) / 3600'
2.42.4 小时窗口内有三十个视频,意味着三十个人今天下午都用了这个声音。再与热门排序对比,就能看出它是否已经站稳:
curl -s "$API/v1/music/posts?music_id=$MID&count=30" \
| jq '[.aweme_list[].statistics.play_count] | add'新鲜聚集度高、累计播放量低,才是值得关注的象限。用的人多,但累计触达还不多。这说明这个声音正在上升,而不是在走下坡路。
第 4 步:找出是谁在推动它
curl -s "$API/v1/music/posts/fresh?music_id=$MID&count=30" \
| jq -r '.aweme_list[].author | [.follower_count, .unique_id] | @tsv' \
| sort -rn | head因为作者对象是内嵌的,这不会产生额外请求。如果一个大账号排在首位,其他都是小账号,那你看到的就是一个由某位创作者带起来的声音,这与众多中型账号自然采用是不同(且通常更短命)的现象。
第 5 步:扩大范围
music.trending 是按设备所在地区限定的。用 POOL_COUNTRY=US 再跑一个实例,用 POOL_COUNTRY=BR 跑第三个,你就能从完全相同的代码得到三张独立的图表。声音常常先在一个市场爆发,几天后才在另一个市场爆发。
工作速率
上述整个循环每个候选声音每轮是 4 个请求。每小时 50 个候选就是每小时 200 个请求,这根本不算什么。真正昂贵的是向外遍历 user.recommended 来摸清一个细分领域,那种情况下每个响应约 3 MB,瓶颈就变成了带宽,而不是速率限制。
获取代码
这里描述的一切都是一个私有 Go 仓库。一次性付款,永久访问,完整源码。
- 完整的签名栈(Simon、Speck、SM3、TTEncrypt、Argus、Ladon),每个原语都有测试向量
- 设备注册、激活与验证流水线
- 带手动代理隧道的 uTLS 传输
- 全部 24 个端点,均已实测并记录
- 池管理:健康检查、剔除、轮换、持久化
- Docker 镜像与 compose 文件
- 带种子发现的实时集成测试套件
- Python、Node、curl 和
.http客户端 - 为每个端点生成的参考文档
- 可靠性、分页与错误处理工程指南
- 同一仓库的终身更新
- 搭建期间的直接沟通渠道
付款时请附上你的 GitHub 用户名。款项到账后,该账号就会被加入私有仓库。
其他付款方式,联系我
- PayPal
- 银行转账
- Ethereum
- Solana
- Tron
用其中一种方式付款,或者购买前想问点什么? 在 Telegram 上给我发消息 我会手动发送仓库邀请。
运行之前
唯一硬性要求是一个轮换代理网关。速率限制是按出口 IP 计算的,重试设计假定新连接会获得新地址,因此静态代理有还不如没有。它是唯一的外部依赖,而且在规模化场景下并非可选。
自托管后维护由你负责。仓库的结构设计使得当某些东西变动时,通常只是某张表里的一个结构体字面量,但那是你的结构体字面量。
常见问题
我需要 TikTok 账号吗?
不需要。任何地方都没有登录。每台设备都是 TikTok 发放了凭据的匿名应用安装。没有会被封禁的东西,没有需要轮换的凭据,没有 2FA。
为什么没有代理就无法运行?
随便看看的话运行良好。但无法规模化运行,因为速率限制是按出口 IP 计算的,整个重试设计都假定新连接会获得新 IP。轮换是必需的;静态代理有还不如没有。
一个轮换数据中心网关每月约 150 美元,固定费率,可覆盖数百万条记录。数十亿条则是另一个层级,大约 950 美元。代理部分会讲解两者,以及各自在何处达到上限。
来源:Hacker News · datasocial.ai