跳到正文
Hacker News· Gander5739·· 10 天前AI 评分22

TikTok 移动 API 抓取技术指南:24 个端点与 56 亿视频数据集

Scraping TikTok's Mobile API

AI 导读

一份技术指南详解如何接入 TikTok Android 应用的私有 HTTP+JSON API,需同时满足设备凭证、请求签名、区域主机和 TLS 指纹四项条件,任一错误都只返回空 body 的 HTTP 200。

正文 · AI 翻译

技术指南 · 24 个端点 · 实测

每天数十亿条个人资料和视频

TikTok 的 Android 应用与一个私有的 HTTP+JSON API 通信,它比 Web 端点更快,返回的内容也多得多。这是一份访问它的技术指南: 设备如何注册、请求如何签名、区域主机如何 分区,以及 TLS 握手如何被指纹识别。基于它构建的一个系统 在三周内收集了 32.3 亿个创作者资料、59.4 亿个视频和 28 亿条 评论。

获取完整代码 测试实时 API

免费数据集。 其中 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
    &region=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

调试它很昂贵,因为四种互不相关的失败都会产生它:

  1. 你的设备从未被激活(§ 激活)
  2. 你的签名错误(§ X-Argus)
  3. 你在和错误的区域主机通信(§ 区域)
  4. 你的 TLS 握手看起来像服务器,而不是手机(§ JA3)

响应中没有任何东西能告诉你到底是哪一种。你无法通过阅读错误来二分定位,因为根本没有错误。唯一的方法是把这四项都修好,并分别单独测量每一项。

设备 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 rounds

Simon 是同样的思路,只是把加法换成了 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 / 509,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,不重试
3170user not exists404,不重试
3002060Profile 用户隐藏了关注列表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-alive88.2%重试复用被封锁的 IP
处处禁用 keep-alive96.2%代理网关,握手风暴
仅在首次尝试时启用 keep-alive99.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.info100%1.009 KB
user.recommended100%1.082.9 MB
music.posts100%1.061.7 MB
music.posts_fresh100%1.341.7 MB
music.trending100%1.0085 KB
music.related100%1.00139 KB
hashtag.info100%1.003.8 KB
hashtag.posts_fresh100%1.001.3 MB
search.general100%1.06527 KB
search.music100%1.11100 KB
search.users100%1.0086 KB
trending.categories100%1.07380 KB
trending.effects100%1.10232 KB
video.comment_replies100%1.058 KB
video.info94%1.9658 KB
user.following93%1.9023 KB
user.followers92%2.12178 KB
video.comments92%2.02174 KB
search.videos92%1.82639 KB
user.posts90%2.261.1 MB
music.info90%2.1512 KB
hashtag.posts89%2.171.3 MB
hashtag.search78%2.1611 KB
feed.recommended10%3.93248 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/postsuser_id, count, max_cursor视频,每个都带有完整的作者对象
/v1/user/infouser_id, sec_user_id完整资料,包括bio_email、链接、商业标志
/v1/user/followersuser_id, sec_user_id, count, max_time粉丝列表
/v1/user/followinguser_id, sec_user_id, count, max_time关注列表,已发布
/v1/user/recommendeduser_id, sec_user_id, countTikTok 自有的相似创作者图谱

视频

路由参数返回
/v1/video/infoaweme_id媒体、统计、声音、标签、作者
/v1/video/commentsaweme_id, count, cursor评论,附带评论者的用户对象
/v1/video/comments/repliesaweme_id, comment_id, count, cursor评论树的第二层

声音

路由参数返回
/v1/music/infomusic_id声音详情,包括user_count
/v1/music/postsmusic_id, count, cursor使用该声音的热门视频
/v1/music/posts/freshmusic_id, count, cursor使用该声音的最新视频
/v1/music/trendingcount, cursor热门声音排行榜,按设备地区
/v1/music/relatedaweme_id, count, cursor为视频推荐的声音

话题标签与搜索

路由参数返回
/v1/hashtag/searchkeyword, count, cursor话题标签 id 及浏览量
/v1/hashtag/infohashtag_id话题标签详情
/v1/hashtag/postshashtag_id, count, cursor该标签下的热门视频
/v1/hashtag/posts/freshhashtag_id, count, cursor该标签下的最新视频
/v1/search/videoskeyword, count, offset视频
/v1/search/generalkeyword, count, offset混合的创作者、视频和标签
/v1/search/musickeyword, count, cursor声音
/v1/search/userskeyword, count, cursor用户名或名称 → 数字user_id

发现

路由参数返回
/v1/trending/categoriescount, cursor应用的热门货架
/v1/trending/effectscount, cursor带有sticker_detail的热门特效视频
/v1/feedcount, 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_leveluser.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.4

2.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