@ikenxuan/amagi 6.5.0 → 6.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_rolldown_runtime = require("../rolldown-runtime-VH7oDXx4.cjs");
5
+ const require_rolldown_runtime = require("../rolldown-runtime-C0BPl7ul.cjs");
6
6
  let node_url = require("node:url");
7
7
  node_url = require_rolldown_runtime.__toESM(node_url, 1);
8
8
  let node_events = require("node:events");
@@ -2151,6 +2151,1695 @@ const bilibiliFetcher = {
2151
2151
  fetchEmojiList: fetchEmojiList$3
2152
2152
  };
2153
2153
  //#endregion
2154
+ //#region src/platform/douyin/passport/sm3.ts
2155
+ /**
2156
+ * SM3 摘要(GM/T 0004-2012),抖音 bdms 签名链使用的变体
2157
+ *
2158
+ * 与标准实现的唯一差异:字符串按 `charCodeAt` 逐字符取字节(非 UTF-8 编码),
2159
+ * 与浏览器里 bdms 的 `strToBytes` 行为一致。签名输入均为 ASCII,实际不会踩到多字节分支,
2160
+ * 但仍保留该分支以保证与浏览器实现逐位一致。
2161
+ */
2162
+ /** 循环左移 32 位 */
2163
+ const rotl = (x, n) => {
2164
+ const shift = n % 32;
2165
+ return (x << shift | x >>> 32 - shift) >>> 0;
2166
+ };
2167
+ /** 轮常量 Tj */
2168
+ const tj = (j) => j < 16 ? 2043430169 : 2055708042;
2169
+ /** 布尔函数 FFj */
2170
+ const ff = (j, x, y, z) => j < 16 ? (x ^ y ^ z) >>> 0 : (x & y | x & z | y & z) >>> 0;
2171
+ /** 布尔函数 GGj */
2172
+ const gg = (j, x, y, z) => j < 16 ? (x ^ y ^ z) >>> 0 : (x & y | ~x & z) >>> 0;
2173
+ /** 初始向量 IV */
2174
+ const IV = [
2175
+ 1937774191,
2176
+ 1226093241,
2177
+ 388252375,
2178
+ 3666478592,
2179
+ 2842636476,
2180
+ 372324522,
2181
+ 3817729613,
2182
+ 2969243214
2183
+ ];
2184
+ /** 字符串转字节数组:逐字符 charCodeAt,大于一字节时按大端拆分 */
2185
+ const strToBytes = (input) => {
2186
+ const bytes = [];
2187
+ for (let i = 0; i < input.length; i++) {
2188
+ let code = input.charCodeAt(i);
2189
+ const chunk = [];
2190
+ do {
2191
+ chunk.push(code & 255);
2192
+ code >>= 8;
2193
+ } while (code);
2194
+ bytes.push(...chunk.reverse());
2195
+ }
2196
+ return bytes;
2197
+ };
2198
+ /** 消息扩展:生成 W[0..67] 与 W'[0..63](后者放在 68 之后) */
2199
+ const expand = (block) => {
2200
+ const w = new Array(132);
2201
+ for (let i = 0; i < 16; i++) w[i] = (block[i * 4] << 24 | block[i * 4 + 1] << 16 | block[i * 4 + 2] << 8 | block[i * 4 + 3]) >>> 0;
2202
+ for (let j = 16; j < 68; j++) {
2203
+ let x = w[j - 16] ^ w[j - 9] ^ rotl(w[j - 3], 15);
2204
+ x = x ^ rotl(x, 15) ^ rotl(x, 23);
2205
+ w[j] = (x ^ rotl(w[j - 13], 7) ^ w[j - 6]) >>> 0;
2206
+ }
2207
+ for (let j = 0; j < 64; j++) w[j + 68] = (w[j] ^ w[j + 4]) >>> 0;
2208
+ return w;
2209
+ };
2210
+ /** SM3 压缩函数:就地更新寄存器 */
2211
+ const compress = (reg, block) => {
2212
+ const w = expand(block);
2213
+ const r = reg.slice();
2214
+ for (let j = 0; j < 64; j++) {
2215
+ let ss1 = rotl(r[0], 12) + r[4] + rotl(tj(j), j) & 4294967295;
2216
+ ss1 = rotl(ss1 >>> 0, 7);
2217
+ const ss2 = (ss1 ^ rotl(r[0], 12)) >>> 0;
2218
+ const tt1 = ff(j, r[0], r[1], r[2]) + r[3] + ss2 + w[j + 68] >>> 0;
2219
+ const tt2 = gg(j, r[4], r[5], r[6]) + r[7] + ss1 + w[j] >>> 0;
2220
+ r[3] = r[2];
2221
+ r[2] = rotl(r[1], 9);
2222
+ r[1] = r[0];
2223
+ r[0] = tt1;
2224
+ r[7] = r[6];
2225
+ r[6] = rotl(r[5], 19);
2226
+ r[5] = r[4];
2227
+ r[4] = (tt2 ^ rotl(tt2, 9) ^ rotl(tt2, 17)) >>> 0;
2228
+ }
2229
+ for (let i = 0; i < 8; i++) reg[i] = (reg[i] ^ r[i]) >>> 0;
2230
+ };
2231
+ /** 按 SM3 规则填充消息(0x80 + 0 + 64 位比特长度) */
2232
+ const pad = (bytes) => {
2233
+ const padded = bytes.slice();
2234
+ const bitLength = bytes.length * 8;
2235
+ padded.push(128);
2236
+ while (padded.length % 64 !== 56) padded.push(0);
2237
+ const high = Math.floor(bitLength / 4294967296);
2238
+ for (let i = 0; i < 4; i++) padded.push(high >>> (3 - i) * 8 & 255);
2239
+ for (let i = 0; i < 4; i++) padded.push(bitLength >>> (3 - i) * 8 & 255);
2240
+ return padded;
2241
+ };
2242
+ /**
2243
+ * 计算 SM3 摘要
2244
+ * @param message 待摘要的字符串或字节数组
2245
+ * @returns 32 字节摘要
2246
+ */
2247
+ const sm3 = (message) => {
2248
+ const bytes = typeof message === "string" ? strToBytes(message) : message;
2249
+ const reg = IV.slice();
2250
+ const padded = pad(bytes);
2251
+ for (let i = 0; i < padded.length; i += 64) compress(reg, padded.slice(i, i + 64));
2252
+ const digest = new Array(32);
2253
+ for (let i = 0; i < 8; i++) {
2254
+ digest[i * 4] = reg[i] >>> 24 & 255;
2255
+ digest[i * 4 + 1] = reg[i] >>> 16 & 255;
2256
+ digest[i * 4 + 2] = reg[i] >>> 8 & 255;
2257
+ digest[i * 4 + 3] = reg[i] & 255;
2258
+ }
2259
+ return digest;
2260
+ };
2261
+ /**
2262
+ * 连续两次 SM3(bdms 对 URL 与盐值的处理方式)
2263
+ * @param message 待摘要的字符串或字节数组
2264
+ * @returns 32 字节摘要
2265
+ */
2266
+ const sm3Twice = (message) => sm3(sm3(message));
2267
+ /**
2268
+ * 十六进制摘要,仅用于测试与排查
2269
+ * @param message 待摘要的字符串或字节数组
2270
+ */
2271
+ const sm3Hex = (message) => sm3(message).map((byte) => byte.toString(16).padStart(2, "0")).join("");
2272
+ //#endregion
2273
+ //#region src/platform/douyin/passport/aBogus.ts
2274
+ /**
2275
+ * a_bogus 签名(bdms 1.0.1.19 形态,passport 登录接口使用)
2276
+ *
2277
+ * 抖音同时在线着多个 a_bogus 版本:`@ikenxuan/amagi` 里带的是 web 数据接口用的旧版
2278
+ * (盐值 `cus`、pageId 6241),而 login.douyin.com 的 passport SDK 用的是本文件实现的
2279
+ * 新版(盐值 `dhzx`、pageId 7571、sdkVersion 1.0.1.19-fix.01)。两者互不通用,
2280
+ * 所以这里单独实现一份,不复用 amagi 的签名。
2281
+ *
2282
+ * 算法为社区公开的逆向结论(见 PR 说明中的参考链接),此处按 kkk 的代码风格重写:
2283
+ * - SM3 为抖音变体(连续两次摘要),见 ./sm3
2284
+ * - RC4 为变体:S 盒递减初始化 + `j = (j * S[i] + j + K[i]) % 256`
2285
+ * - Base64 使用自定义字符表,UA 段用 s3、最终结果用 s4
2286
+ * - `random()` 保留了原实现里 `Math.random`(函数对象而非调用)参与位运算的行为,
2287
+ * 该表达式恒为 NaN → 位运算结果恒为 0,这里直接写成常量而不是照抄错误代码
2288
+ */
2289
+ /** RC4 / S 盒长度 */
2290
+ const BOX_SIZE = 256;
2291
+ /** bdms 1.0.1.19 的盐值 */
2292
+ const SALT = "dhzx";
2293
+ /** bdms SDK 版本号,同时也是 passport 通用参数里的 p_bd */
2294
+ const BDMS_SDK_VERSION = "1.0.1.19-fix.01";
2295
+ /** 版本号基准时间戳(bdms 内部按 14 天为一档计数) */
2296
+ const VERSION_EPOCH = 17218368e5;
2297
+ /** 模块加载时刻,等价于浏览器里的「进入页面时间」 */
2298
+ const ENTER_PAGE_TS = Date.now();
2299
+ /** 自定义 Base64 字符表 */
2300
+ const BASE64_TABLES = {
2301
+ s0: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
2302
+ s1: "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
2303
+ s2: "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
2304
+ s3: "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe",
2305
+ s4: "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe"
2306
+ };
2307
+ /**
2308
+ * 浏览器环境快照。服务器上没有真实窗口,这里给出一组常见的桌面分辨率组合;
2309
+ * 该值只影响指纹内容本身,不需要与任何真实设备对应。
2310
+ */
2311
+ const BROWSER_ENV = {
2312
+ innerWidth: 2048,
2313
+ innerHeight: 960,
2314
+ outerWidth: 2554,
2315
+ outerHeight: 1386,
2316
+ availWidth: 2560,
2317
+ availHeight: 1392,
2318
+ sizeWidth: 2560,
2319
+ sizeHeight: 1440,
2320
+ platform: "Win32"
2321
+ };
2322
+ /** RC4 密钥:bdms 用 `[1 / 256, 1 % 256, 14 % 256]` 造出 "\x00\x01\x0e" */
2323
+ const rc4Key = String.fromCharCode(...[
2324
+ 1 / BOX_SIZE,
2325
+ 1 % BOX_SIZE,
2326
+ 14 % BOX_SIZE
2327
+ ]);
2328
+ /**
2329
+ * 变体 RC4
2330
+ * @param key 密钥
2331
+ * @param text 明文(按 charCode 处理)
2332
+ */
2333
+ const rc4 = (key, text) => {
2334
+ const s = new Uint8Array(BOX_SIZE);
2335
+ const k = new Uint8Array(BOX_SIZE);
2336
+ for (let i = 0; i < BOX_SIZE; i++) {
2337
+ s[i] = 255 - i;
2338
+ k[i] = key.charCodeAt(i % key.length);
2339
+ }
2340
+ let j = 0;
2341
+ for (let i = 0; i < BOX_SIZE; i++) {
2342
+ j = (j * s[i] + j + k[i]) % BOX_SIZE;
2343
+ [s[i], s[j]] = [s[j], s[i]];
2344
+ }
2345
+ let x = 0;
2346
+ let y = 0;
2347
+ let cipher = "";
2348
+ for (let n = 0; n < text.length; n++) {
2349
+ x = (x + 1) % BOX_SIZE;
2350
+ y = (y + s[x]) % BOX_SIZE;
2351
+ [s[x], s[y]] = [s[y], s[x]];
2352
+ cipher += String.fromCharCode(text.charCodeAt(n) ^ s[(s[x] + s[y]) % BOX_SIZE]);
2353
+ }
2354
+ return cipher;
2355
+ };
2356
+ /**
2357
+ * 自定义字符表 Base64
2358
+ * @param input 明文(按 charCode 取低 8 位)
2359
+ * @param table 字符表名(s0 ~ s4)
2360
+ */
2361
+ const base64 = (input, table) => {
2362
+ const alphabet = BASE64_TABLES[table];
2363
+ let output = "";
2364
+ let i = 0;
2365
+ while (i < input.length) {
2366
+ const c1 = input.charCodeAt(i++);
2367
+ const c2 = input.charCodeAt(i++);
2368
+ const c3 = input.charCodeAt(i++);
2369
+ const chunk = (c1 & 255) << 16 | (Number.isNaN(c2) ? 0 : (c2 & 255) << 8) | (Number.isNaN(c3) ? 0 : c3 & 255);
2370
+ output += alphabet.charAt(chunk >> 18 & 63);
2371
+ output += alphabet.charAt(chunk >> 12 & 63);
2372
+ output += Number.isNaN(c2) ? "=" : alphabet.charAt(chunk >> 6 & 63);
2373
+ output += Number.isNaN(c3) ? "=" : alphabet.charAt(chunk & 63);
2374
+ }
2375
+ return output;
2376
+ };
2377
+ /**
2378
+ * 生成 4 字节随机混淆段
2379
+ * @param seed 两字节的基准值
2380
+ * @param flag 0 = 完全随机;1 = 高位固定为 0;2 = 低位受限、高位固定为 178
2381
+ */
2382
+ const mix = (seed, flag) => {
2383
+ const r = Math.random() * 65535 | 0;
2384
+ let low = r & 255;
2385
+ let high = r >> 8 & 255;
2386
+ if (flag === 1) high = 0;
2387
+ if (flag === 2) {
2388
+ low = Math.random() * 240 >> 0;
2389
+ if (low > 109) low += low % 2 + 1;
2390
+ high = 178;
2391
+ }
2392
+ return [
2393
+ low & 170 | seed[0] & 85,
2394
+ low & 85 | seed[0] & 170,
2395
+ high & 170 | seed[1] & 85,
2396
+ high & 85 | seed[1] & 170
2397
+ ];
2398
+ };
2399
+ /** SDK 版本号拆成数字数组,非纯数字段(如 `19-fix`)取 0 */
2400
+ const versionSegments = (version) => version.split(".").map((segment) => ~~Number(segment));
2401
+ /** 每 3 字节扩成 4 字节,掺入随机位 */
2402
+ const spread = (bytes) => {
2403
+ const masks = [
2404
+ 145,
2405
+ 110,
2406
+ 66,
2407
+ 189,
2408
+ 44,
2409
+ 211
2410
+ ];
2411
+ const out = [];
2412
+ for (let i = 0; i < bytes.length; i += 3) {
2413
+ if (i + 2 >= bytes.length) {
2414
+ out.push(bytes[i]);
2415
+ if (bytes[i + 1] !== void 0) out.push(bytes[i + 1]);
2416
+ continue;
2417
+ }
2418
+ const noise = Math.random() * 1e3 & 255;
2419
+ out.push(noise & masks[0] | bytes[i] & masks[1], noise & masks[2] | bytes[i + 1] & masks[3], noise & masks[4] | bytes[i + 2] & masks[5], bytes[i] & masks[0] | bytes[i + 1] & masks[2] | bytes[i + 2] & masks[4]);
2420
+ }
2421
+ return out;
2422
+ };
2423
+ /**
2424
+ * 生成 a_bogus
2425
+ * @param query 除 a_bogus 之外的完整查询串(未加 `?`,保持实际发送顺序)
2426
+ * @param userAgent 与请求头一致的 UA
2427
+ * @returns a_bogus 参数值(未做 URL 编码)
2428
+ */
2429
+ const aBogus = (query, userAgent) => {
2430
+ const salted = query.endsWith(SALT) ? query : query + SALT;
2431
+ const queryDigest = sm3Twice(salted);
2432
+ const saltDigest = sm3Twice(SALT);
2433
+ const uaEncoded = base64(rc4(rc4Key, userAgent), "s3");
2434
+ const uaDigest = sm3(uaEncoded);
2435
+ const now = Date.now();
2436
+ const ink = now - 1;
2437
+ /** 指纹字节表,下标沿用 bdms 内部编号,便于与逆向资料对照 */
2438
+ const b = {};
2439
+ b[24] = 41;
2440
+ b[26] = (now - VERSION_EPOCH) / 1e3 / 60 / 60 / 24 / 14 >> 0;
2441
+ b[27] = 6;
2442
+ b[28] = now - ENTER_PAGE_TS + 3 & 255;
2443
+ b[29] = now & 255;
2444
+ b[30] = now >> 8 & 255;
2445
+ b[31] = now >> 16 & 255;
2446
+ b[32] = now >> 24 & 255;
2447
+ b[33] = now / 2 ** 32 & 255;
2448
+ b[34] = now / 2 ** 40 & 255;
2449
+ b[35] = 1;
2450
+ b[36] = 0;
2451
+ b[38] = 129;
2452
+ b[39] = 0;
2453
+ b[40] = 0;
2454
+ b[41] = 0;
2455
+ b[42] = 0;
2456
+ b[43] = 0;
2457
+ b[44] = 14;
2458
+ b[45] = 0;
2459
+ b[46] = 0;
2460
+ b[47] = 0;
2461
+ b[48] = queryDigest[9];
2462
+ b[49] = queryDigest[18];
2463
+ b[51] = queryDigest[3];
2464
+ b[52] = saltDigest[10];
2465
+ b[53] = saltDigest[19];
2466
+ b[55] = saltDigest[4];
2467
+ b[56] = uaDigest[11];
2468
+ b[57] = uaDigest[21];
2469
+ b[59] = uaDigest[5];
2470
+ b[60] = ink & 255;
2471
+ b[61] = ink >> 8 & 255;
2472
+ b[62] = ink >> 16 & 255;
2473
+ b[63] = ink >> 24 & 255;
2474
+ b[64] = ink / 2 ** 32 & 255;
2475
+ b[65] = ink / 2 ** 40 & 255;
2476
+ b[66] = 3;
2477
+ b[67] = 147;
2478
+ b[68] = 29;
2479
+ b[69] = 0;
2480
+ b[70] = 0;
2481
+ b[71] = 239;
2482
+ b[72] = 24;
2483
+ b[73] = 0;
2484
+ b[74] = 0;
2485
+ const envSnapshot = Object.values(BROWSER_ENV).join("|");
2486
+ const envBytes = Array.from(envSnapshot, (char) => char.charCodeAt(0));
2487
+ b[79] = envBytes.length & 255;
2488
+ b[80] = envBytes.length >> 8 & 255;
2489
+ const tail = `${now + 3 & 255},`;
2490
+ const tailBytes = Array.from(tail, (char) => char.charCodeAt(0));
2491
+ b[84] = tailBytes.length & 255;
2492
+ b[85] = tailBytes.length >> 8 & 255;
2493
+ const version = versionSegments(BDMS_SDK_VERSION);
2494
+ const noise = mix([version[0], version[1]], 0).concat(mix([version[0], version[1]], 2));
2495
+ const checksum = [
2496
+ 24,
2497
+ 26,
2498
+ 27,
2499
+ 28,
2500
+ 29,
2501
+ 30,
2502
+ 31,
2503
+ 32,
2504
+ 33,
2505
+ 34,
2506
+ 35,
2507
+ 36,
2508
+ 38,
2509
+ 39,
2510
+ 40,
2511
+ 41,
2512
+ 42,
2513
+ 43,
2514
+ 44,
2515
+ 45,
2516
+ 46,
2517
+ 47,
2518
+ 48,
2519
+ 49,
2520
+ 51,
2521
+ 52,
2522
+ 53,
2523
+ 55,
2524
+ 56,
2525
+ 57,
2526
+ 59,
2527
+ 60,
2528
+ 61,
2529
+ 62,
2530
+ 63,
2531
+ 64,
2532
+ 65,
2533
+ 66,
2534
+ 67,
2535
+ 68,
2536
+ 69,
2537
+ 70,
2538
+ 71,
2539
+ 72,
2540
+ 73,
2541
+ 74,
2542
+ 79,
2543
+ 80,
2544
+ 84,
2545
+ 85
2546
+ ].reduce((acc, key) => acc ^ b[key], noise.reduce((acc, value) => acc ^ value, 0));
2547
+ /** 打乱后的字节顺序,与 bdms 内部的 tlist 一致 */
2548
+ const shuffled = [
2549
+ 34,
2550
+ 44,
2551
+ 56,
2552
+ 61,
2553
+ 73,
2554
+ 29,
2555
+ 70,
2556
+ 45,
2557
+ 35,
2558
+ 49,
2559
+ 38,
2560
+ 66,
2561
+ 51,
2562
+ 68,
2563
+ 28,
2564
+ 48,
2565
+ 64,
2566
+ 47,
2567
+ 30,
2568
+ 71,
2569
+ 26,
2570
+ 55,
2571
+ 31,
2572
+ 69,
2573
+ 59,
2574
+ 40,
2575
+ 62,
2576
+ 63,
2577
+ 27,
2578
+ 72,
2579
+ 41,
2580
+ 74,
2581
+ 57,
2582
+ 52,
2583
+ 42,
2584
+ 39,
2585
+ 33,
2586
+ 67,
2587
+ 53,
2588
+ 43,
2589
+ 65,
2590
+ 46,
2591
+ 36,
2592
+ 24,
2593
+ 60,
2594
+ 32,
2595
+ 79,
2596
+ 80,
2597
+ 84,
2598
+ 85
2599
+ ].map((key) => b[key]);
2600
+ const payload = spread(shuffled.concat(envBytes, tailBytes, [checksum]));
2601
+ const prefix = String.fromCharCode(...mix([3, 82], 1));
2602
+ const encrypted = rc4(String.fromCharCode(211), String.fromCharCode(...noise.concat(payload)));
2603
+ return base64(prefix + encrypted, "s4");
2604
+ };
2605
+ //#endregion
2606
+ //#region src/platform/douyin/passport/cookieJar.ts
2607
+ /**
2608
+ * 登录流程用的轻量 CookieJar
2609
+ *
2610
+ * 登录过程会跨 `www.douyin.com` / `login.douyin.com` / `ttwid.bytedance.com` 三个域,
2611
+ * 且同一个 cookie 名会被多次下发(例如 `ttwid` 在换取可信指纹后会被替换、
2612
+ * `sessionid` 在二次验证通过后会被升级)。这里只做一件事:**按下发顺序覆盖同名 cookie**,
2613
+ * 保证最终拿到的永远是最后一次下发的值,同时正确处理服务端的删除指令。
2614
+ *
2615
+ * 不做域/路径隔离:整个登录流程都在抖音自己的域下,隔离反而会漏掉跨子域下发的凭证。
2616
+ *
2617
+ * 另外承载一小部分**本地会话状态**(见 `INTERNAL_PREFIX`):passport 的几个接口对外是
2618
+ * 无状态的,会话全靠 cookie 串在调用之间传递,而 bd-ticket-guard 需要在多次调用之间
2619
+ * 记住自己生成的密钥与服务端签发的票据。这些条目以 `__amagi_` 开头,
2620
+ * `toString()` 不会把它们放进 Cookie 请求头,只有 `serialize()` 才会带上。
2621
+ */
2622
+ /** 本地会话状态的 cookie 名前缀,这些条目永远不会发给服务端 */
2623
+ const INTERNAL_PREFIX = "__amagi_";
2624
+ /** 是否为本地会话状态条目 */
2625
+ const isInternal = (name) => name.startsWith(INTERNAL_PREFIX);
2626
+ /** 判断一条 Set-Cookie 是否表示「删除该 cookie」 */
2627
+ const isDeletion = (value, attributes) => {
2628
+ if (value === "") return true;
2629
+ for (const attribute of attributes) {
2630
+ const [rawName, ...rest] = attribute.split("=");
2631
+ const name = rawName.trim().toLowerCase();
2632
+ const rawValue = rest.join("=").trim();
2633
+ if (name === "max-age") {
2634
+ const maxAge = Number(rawValue);
2635
+ if (Number.isFinite(maxAge) && maxAge <= 0) return true;
2636
+ }
2637
+ if (name === "expires") {
2638
+ const expires = Date.parse(rawValue);
2639
+ if (Number.isFinite(expires) && expires <= Date.now()) return true;
2640
+ }
2641
+ }
2642
+ return false;
2643
+ };
2644
+ var CookieJar = class {
2645
+ /** Map 保留插入顺序,重复 set 只更新值、不改变位置 */
2646
+ cookies = /* @__PURE__ */ new Map();
2647
+ /**
2648
+ * @param initial 初始 cookie 串,形如 `a=1; b=2`
2649
+ */
2650
+ constructor(initial) {
2651
+ if (initial) this.merge(initial);
2652
+ }
2653
+ /** 当前持有的 cookie 数量 */
2654
+ get size() {
2655
+ return this.cookies.size;
2656
+ }
2657
+ /**
2658
+ * 写入一条 cookie
2659
+ * @param name cookie 名
2660
+ * @param value cookie 值
2661
+ */
2662
+ set(name, value) {
2663
+ this.cookies.set(name, value);
2664
+ return this;
2665
+ }
2666
+ /**
2667
+ * 读取一条 cookie
2668
+ * @param name cookie 名
2669
+ */
2670
+ get(name) {
2671
+ return this.cookies.get(name);
2672
+ }
2673
+ /**
2674
+ * 是否持有某条 cookie
2675
+ * @param name cookie 名
2676
+ */
2677
+ has(name) {
2678
+ return this.cookies.has(name);
2679
+ }
2680
+ /**
2681
+ * 合并一段 `name=value; name=value` 形式的 cookie 串
2682
+ * @param cookieString cookie 串,空值直接忽略
2683
+ */
2684
+ merge(cookieString) {
2685
+ if (!cookieString) return this;
2686
+ for (const pair of cookieString.split(";")) {
2687
+ const index = pair.indexOf("=");
2688
+ if (index <= 0) continue;
2689
+ const name = pair.slice(0, index).trim();
2690
+ const value = pair.slice(index + 1).trim();
2691
+ if (name && value) this.cookies.set(name, value);
2692
+ }
2693
+ return this;
2694
+ }
2695
+ /**
2696
+ * 应用响应的 Set-Cookie 头
2697
+ * @param setCookies 单条或多条 Set-Cookie 原始值
2698
+ */
2699
+ applySetCookie(setCookies) {
2700
+ if (!setCookies) return this;
2701
+ for (const line of Array.isArray(setCookies) ? setCookies : [setCookies]) {
2702
+ const [pair, ...attributes] = line.split(";");
2703
+ const index = pair.indexOf("=");
2704
+ if (index <= 0) continue;
2705
+ const name = pair.slice(0, index).trim();
2706
+ const value = pair.slice(index + 1).trim();
2707
+ if (!name) continue;
2708
+ if (isDeletion(value, attributes)) {
2709
+ this.cookies.delete(name);
2710
+ continue;
2711
+ }
2712
+ this.cookies.set(name, value);
2713
+ }
2714
+ return this;
2715
+ }
2716
+ /**
2717
+ * 是否已经拿到登录态凭证(`ttwid` 是匿名设备指纹,不算登录)
2718
+ */
2719
+ isLoggedIn() {
2720
+ return this.has("sessionid") || this.has("sessionid_ss") || this.has("sid_guard");
2721
+ }
2722
+ /** 序列化为可直接放进 Cookie 请求头的字符串,不含本地会话状态 */
2723
+ toString() {
2724
+ return [...this.cookies].filter(([name]) => !isInternal(name)).map(([name, value]) => `${name}=${value}`).join("; ");
2725
+ }
2726
+ /**
2727
+ * 序列化为在两次调用之间传递的会话串,包含本地会话状态
2728
+ *
2729
+ * 登录流程内部用这个;最终落库的登录凭证用 `toString()`,避免把本地密钥写进配置。
2730
+ */
2731
+ serialize() {
2732
+ return [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; ");
2733
+ }
2734
+ /** 导出为普通对象,便于断言与日志 */
2735
+ toJSON() {
2736
+ return Object.fromEntries(this.cookies);
2737
+ }
2738
+ };
2739
+ //#endregion
2740
+ //#region src/platform/douyin/passport/params.ts
2741
+ /**
2742
+ * passport 登录 SDK 的参数与签名构造
2743
+ *
2744
+ * login.douyin.com 的接口一共要过四道签名:
2745
+ * - `p_no`:SDK 版本相关参数排序后取 sha256
2746
+ * - `sign`:`排序后的前 10 个 query 参数 & 排序后的 body 参数 & app_key` 取 sha256
2747
+ * - `qs`:参与 sign 的参数名列表逐字节异或 5 后转十六进制
2748
+ * - `x-tt-passport-aid-sign` 请求头:以 appKey 为消息、当日 UTC 正午时间戳为密钥做 HMAC 派生
2749
+ *
2750
+ * 这些常量都是 SDK 自身的版本号与固定 app_key,属于协议的一部分,不含任何设备/账号信息。
2751
+ */
2752
+ /** passport 登录 SDK 的 app_key */
2753
+ const APP_KEY = "163e7ce78d58971a41f5b969996d85c2";
2754
+ /** 抖音 web 的 aid */
2755
+ const PASSPORT_AID = "6383";
2756
+ /** 登录接口域名 */
2757
+ const LOGIN_HOST = "login.douyin.com";
2758
+ /** 抖音主站域名 */
2759
+ const WEB_HOST = "www.douyin.com";
2760
+ /** 登录 SDK(normal 形态)版本号 */
2761
+ const JSSDK_VERSION = "3.1.3";
2762
+ /** 验证页 SDK(lite 形态)版本号 */
2763
+ const LITE_JSSDK_VERSION = "5.1.2";
2764
+ /** 验证页使用的 authn SDK 版本号 */
2765
+ const LITE_AUTHN_VERSION = "1.0.0.420-web";
2766
+ /** 各子 SDK 版本号,参与 p_no 计算 */
2767
+ const SDK_VERSIONS = {
2768
+ pVer: "1.1.3",
2769
+ pZt: "3.3.14",
2770
+ pUi: "2.1.9-alpha.6",
2771
+ pCa: "4.0.17",
2772
+ pCaReal: "1.0.0.874"
2773
+ };
2774
+ /** 与 aBogus 中 BROWSER_ENV 对应的窗口尺寸,用于生成 account_sdk_source_info */
2775
+ const ENV_VIEWPORT = {
2776
+ innerWidth: 2048,
2777
+ innerHeight: 960,
2778
+ outerWidth: 2554,
2779
+ outerHeight: 1386
2780
+ };
2781
+ const sha256Hex = (input) => node_crypto.default.createHash("sha256").update(input, "utf8").digest("hex");
2782
+ const hmacSha256 = (key, message) => node_crypto.default.createHmac("sha256", Buffer.from(key)).update(Buffer.from(message)).digest();
2783
+ const hexToBytes = (hex) => Uint8Array.from(hex.match(/.{2}/g)?.map((byte) => parseInt(byte, 16)) ?? []);
2784
+ /**
2785
+ * 逐字节异或 5 后转十六进制,SDK 用它编码参数名列表、验证码与密码
2786
+ * @param input 明文
2787
+ */
2788
+ const xor5Hex = (input) => Array.from(Buffer.from(input, "utf8"), (byte) => (byte ^ 5).toString(16).padStart(2, "0")).join("");
2789
+ /** 随机十六进制串,用于 biz_trace_id 一类的追踪 ID */
2790
+ const randomHex = (length) => node_crypto.default.randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
2791
+ /** 当日 UTC 12:00 的秒级时间戳,aid-sign 以此为密钥基准 */
2792
+ const utcNoonTimestamp = (now = /* @__PURE__ */ new Date()) => Math.floor(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0, 0) / 1e3);
2793
+ /**
2794
+ * 按 SDK 规则序列化参数:键名排序后拼成 `k=v&k=v`
2795
+ * @param params 参数对象
2796
+ * @param limit 大于等于 0 时只取排序后的前 limit 个键
2797
+ */
2798
+ const serializeSorted = (params, limit = -1) => {
2799
+ const keys = Object.keys(params).sort();
2800
+ if (limit >= 0) keys.splice(limit);
2801
+ return {
2802
+ text: keys.map((key) => `${key}=${typeof params[key] === "object" ? JSON.stringify(params[key]) : params[key]}`).join("&"),
2803
+ keys
2804
+ };
2805
+ };
2806
+ /**
2807
+ * 计算 sign 与 qs
2808
+ * @param params query 参数(仅排序后的前 10 个参与签名)
2809
+ * @param data body 参数,GET 请求传空对象
2810
+ */
2811
+ const makeSignAndQs = (params, data = {}) => {
2812
+ const { text: paramsText, keys } = serializeSorted(params, 10);
2813
+ const { text: dataText } = serializeSorted(data);
2814
+ return {
2815
+ sign: sha256Hex(`${paramsText}&${dataText}&app_key=${APP_KEY}`),
2816
+ qs: xor5Hex(keys.join(","))
2817
+ };
2818
+ };
2819
+ /**
2820
+ * 计算 p_no
2821
+ * @param pTs 毫秒时间戳,与 query 里的 p_ts 保持一致
2822
+ */
2823
+ const makePNo = (pTs) => {
2824
+ const parts = {
2825
+ passport_jssdk_version: JSSDK_VERSION,
2826
+ p_bd: BDMS_SDK_VERSION,
2827
+ p_ca: SDK_VERSIONS.pCa,
2828
+ p_ts: pTs,
2829
+ p_ver: SDK_VERSIONS.pVer,
2830
+ p_zt: SDK_VERSIONS.pZt
2831
+ };
2832
+ return sha256Hex(Object.keys(parts).sort().map((key) => `${key}=${parts[key]}`).join("&"));
2833
+ };
2834
+ /**
2835
+ * HKDF 风格的密钥派生,aid-sign 内部使用
2836
+ * @param keyHex 初始密钥(十六进制)
2837
+ * @param length 输出长度
2838
+ */
2839
+ const deriveKey = (keyHex, length) => {
2840
+ const output = [];
2841
+ let previous = "";
2842
+ let counter = 0;
2843
+ while (output.length < length) {
2844
+ counter++;
2845
+ const message = Uint8Array.from([...hexToBytes(previous), counter]);
2846
+ previous = hmacSha256(hexToBytes(keyHex), message).toString("hex");
2847
+ output.push(...hexToBytes(previous));
2848
+ }
2849
+ return Uint8Array.from(output.slice(0, length));
2850
+ };
2851
+ /**
2852
+ * 计算 `x-tt-passport-aid-sign` 请求头
2853
+ * @param urlPath 接口路径,如 `/passport/web/get_qrcode/`
2854
+ * @param timestamp 当日 UTC 正午时间戳(秒),默认取当前
2855
+ */
2856
+ const makeAidSign = (urlPath, timestamp = utcNoonTimestamp()) => {
2857
+ const encoder = new TextEncoder();
2858
+ const ts = String(timestamp);
2859
+ const seed = hmacSha256(encoder.encode(ts), encoder.encode(APP_KEY)).toString("hex");
2860
+ const key = deriveKey(seed, 32);
2861
+ return hmacSha256(key, encoder.encode(`aid=${PASSPORT_AID}&path=${urlPath}&ts=${ts}`)).toString("hex");
2862
+ };
2863
+ /**
2864
+ * 生成 account_sdk_source_info:SDK 采集的浏览器环境快照,异或 5 后转十六进制。
2865
+ *
2866
+ * 上游参考实现内联的是作者本机抓包值(含显卡型号、堆内存占用、带 query 的个人主页 URL),
2867
+ * 不适合进仓库,这里换成一份等价形态的通用快照。
2868
+ *
2869
+ * 实测服务端在 `get_qrcode` 阶段不校验该字段内容(删掉、置空、填垃圾值都同样返回
2870
+ * `error_code: 0`),保留它只是为了与 SDK 的真实请求形态一致。
2871
+ */
2872
+ const makeAccountSdkSourceInfo = () => xor5Hex(JSON.stringify({
2873
+ hardwareConcurrency: 8,
2874
+ webdriver: false,
2875
+ chromedriver: false,
2876
+ shelldriver: false,
2877
+ plugins: 5,
2878
+ innerHeight: ENV_VIEWPORT.innerHeight,
2879
+ innerWidth: ENV_VIEWPORT.innerWidth,
2880
+ outerHeight: ENV_VIEWPORT.outerHeight,
2881
+ outerWidth: ENV_VIEWPORT.outerWidth,
2882
+ webgl: {
2883
+ vendor: "Google Inc. (Intel)",
2884
+ renderer: "ANGLE (Intel, Intel(R) UHD Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)"
2885
+ },
2886
+ performance: {
2887
+ timeOrigin: Date.now(),
2888
+ navigationTiming: {
2889
+ entryType: "navigation",
2890
+ initiatorType: "navigation",
2891
+ name: `https://${WEB_HOST}/`,
2892
+ renderBlockingStatus: "non-blocking"
2893
+ }
2894
+ },
2895
+ browser: {
2896
+ bit_protocol: "false",
2897
+ bit_helper: false
2898
+ }
2899
+ }));
2900
+ /**
2901
+ * 构造 passport 登录 SDK 的通用 query 参数
2902
+ *
2903
+ * 参数顺序即实际发送顺序:SDK 各拦截器依次注入,`request_host` 在这里先编码一次,
2904
+ * 拼 URL 时会再编码一次,所以线上抓包看到的是双重编码。
2905
+ * @param extra 业务参数(GET 时并入 query)
2906
+ */
2907
+ const makeCommonParams = (extra = {}) => {
2908
+ const pTs = String(Date.now());
2909
+ return {
2910
+ passport_jssdk_version: JSSDK_VERSION,
2911
+ passport_jssdk_type: "normal",
2912
+ is_from_ttaccountsdk: "1",
2913
+ aid: PASSPORT_AID,
2914
+ language: "zh",
2915
+ account_app_language: "zh-CN",
2916
+ ts: String(utcNoonTimestamp()),
2917
+ ...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, String(value)])),
2918
+ is_from_iesaccountsaas: "1",
2919
+ p_ui: SDK_VERSIONS.pUi,
2920
+ p_ca: SDK_VERSIONS.pCa,
2921
+ p_ca_real: SDK_VERSIONS.pCaReal,
2922
+ account_sdk_source: "web",
2923
+ account_sdk_source_info: makeAccountSdkSourceInfo(),
2924
+ p_js_v: JSSDK_VERSION,
2925
+ p_js_t: "pro",
2926
+ p_zt: SDK_VERSIONS.pZt,
2927
+ p_ver: SDK_VERSIONS.pVer,
2928
+ p_ver_real: "0",
2929
+ request_host: encodeURIComponent(`https://${WEB_HOST}`),
2930
+ p_bd: BDMS_SDK_VERSION,
2931
+ p_ts: pTs,
2932
+ p_no: makePNo(pTs),
2933
+ biz_trace_id: randomHex(8),
2934
+ device_platform: "web_app"
2935
+ };
2936
+ };
2937
+ /**
2938
+ * 构造验证页 SDK(lite 形态)的固定 query 参数
2939
+ * @param bizTraceId 业务追踪 ID
2940
+ */
2941
+ const makeLiteParams = (bizTraceId) => ({
2942
+ passport_jssdk_version: LITE_JSSDK_VERSION,
2943
+ passport_jssdk_type: "lite",
2944
+ is_from_ttaccountsdk: "1",
2945
+ aid: PASSPORT_AID,
2946
+ language: "zh",
2947
+ account_app_language: "zh-CN",
2948
+ new_authn_sdk_version: LITE_AUTHN_VERSION,
2949
+ biz_trace_id: bizTraceId
2950
+ });
2951
+ /**
2952
+ * 按插入顺序序列化为查询串(不排序,`request_host` 因此产生二次编码)
2953
+ * @param params 参数对象
2954
+ */
2955
+ const serializeQuery = (params) => Object.entries(params).map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`).join("&");
2956
+ //#endregion
2957
+ //#region src/platform/douyin/passport/ticketGuard.ts
2958
+ /**
2959
+ * bd-ticket-guard 设备票据
2960
+ *
2961
+ * 抖音的设备真实性风控。常见的说法是必须从浏览器 localStorage 里导出一对密钥才能用,
2962
+ * 但那只适用于「已登录之后」的接口 —— **登录流程本身就是票据的签发入口**:
2963
+ *
2964
+ * 1. 本地生成一对 P-256 密钥
2965
+ * 2. 把公钥写进 `bd_ticket_guard_client_data` cookie,在取二维码**之前**交给服务端
2966
+ * 3. 服务端在后续响应的 `bd-ticket-guard-server-data` 头里签发 `{ticket, ts_sign, client_cert}`
2967
+ * 4. 之后每个请求用 `ECDH(自己的私钥, client_cert 里的服务端公钥)` 派生的密钥
2968
+ * 对 `ticket=…&path=…&timestamp=…` 做 HMAC,放进 `bd-ticket-guard-client-data` 头
2969
+ *
2970
+ * 全程不需要浏览器,票据是服务端真实签发给我们自己这把密钥的,没有任何伪造数据。
2971
+ * 密钥与票据以 `__amagi_` 前缀存在 CookieJar 里,只在调用之间传递,不会发给服务端。
2972
+ */
2973
+ /** 交给服务端的公钥所在的 cookie */
2974
+ const CLIENT_DATA_COOKIE = "bd_ticket_guard_client_data";
2975
+ /** 声明 web 域版本的 cookie */
2976
+ const CLIENT_WEB_DOMAIN_COOKIE = "bd_ticket_guard_client_web_domain";
2977
+ /** 服务端也可能把签发结果放在这条 cookie 里 */
2978
+ const SERVER_DATA_COOKIE = "bd_ticket_guard_server_data";
2979
+ /** 服务端签发结果所在的响应头 */
2980
+ const SERVER_DATA_HEADER = "bd-ticket-guard-server-data";
2981
+ /** 本地会话状态:PKCS#8 私钥(base64) */
2982
+ const KEY_ENTRY = `${INTERNAL_PREFIX}tg_key`;
2983
+ /** 本地会话状态:服务端签发的票据 */
2984
+ const TICKET_ENTRY = `${INTERNAL_PREFIX}tg_ticket`;
2985
+ /** 本地会话状态:票据的时间戳签名 */
2986
+ const TS_SIGN_ENTRY = `${INTERNAL_PREFIX}tg_ts_sign`;
2987
+ /** 本地会话状态:ECDH 派生出的 HMAC 密钥(hex) */
2988
+ const ECDH_ENTRY = `${INTERNAL_PREFIX}tg_ecdh`;
2989
+ /** SDK 声明的 ticket-guard 版本 */
2990
+ const GUARD_VERSION = "2";
2991
+ /** SDK 声明的迭代版本 */
2992
+ const ITERATION_VERSION = "1";
2993
+ /** P-256 公钥的 SubjectPublicKeyInfo 前缀,其后紧跟 65 字节未压缩点 */
2994
+ const P256_SPKI_PREFIX = Buffer.from("3059301306072a8648ce3d020106082a8648ce3d030107034200", "hex");
2995
+ /** 被签名内容的字段列表,需与实际拼接顺序一致 */
2996
+ const REQ_CONTENT = "ticket,path,timestamp";
2997
+ /** 紧凑 JSON,与浏览器的 `JSON.stringify` 一致 */
2998
+ const compactJson = (value) => JSON.stringify(value);
2999
+ /**
3000
+ * 从 PEM 证书或 `pub.<base64>` 里取出服务端公钥
3001
+ * @param clientCert 服务端下发的 client_cert
3002
+ */
3003
+ const readServerPublicKey = (clientCert) => {
3004
+ if (clientCert.startsWith("pub.")) {
3005
+ const point = Buffer.from(clientCert.slice(4), "base64");
3006
+ const body = point.length === 65 ? point.subarray(1) : point;
3007
+ const spki = Buffer.concat([
3008
+ P256_SPKI_PREFIX,
3009
+ Buffer.from([4]),
3010
+ body
3011
+ ]);
3012
+ return node_crypto.default.createPublicKey({
3013
+ key: spki,
3014
+ format: "der",
3015
+ type: "spki"
3016
+ });
3017
+ }
3018
+ return new node_crypto.default.X509Certificate(clientCert).publicKey;
3019
+ };
3020
+ /**
3021
+ * bd-ticket-guard 会话
3022
+ *
3023
+ * 状态全部读写自传入的 CookieJar,因此与 passport 的无状态调用形态天然兼容。
3024
+ */
3025
+ var TicketGuard = class {
3026
+ jar;
3027
+ /**
3028
+ * @param jar 当前会话 cookie
3029
+ */
3030
+ constructor(jar) {
3031
+ this.jar = jar;
3032
+ }
3033
+ /** 本会话的私钥,缺失时生成一把并写回 CookieJar */
3034
+ get privateKey() {
3035
+ const stored = this.jar.get(KEY_ENTRY);
3036
+ if (stored) return node_crypto.default.createPrivateKey({
3037
+ key: Buffer.from(stored, "base64"),
3038
+ format: "der",
3039
+ type: "pkcs8"
3040
+ });
3041
+ const { privateKey } = node_crypto.default.generateKeyPairSync("ec", { namedCurve: "prime256v1" });
3042
+ const der = privateKey.export({
3043
+ format: "der",
3044
+ type: "pkcs8"
3045
+ });
3046
+ this.jar.set(KEY_ENTRY, der.toString("base64"));
3047
+ return privateKey;
3048
+ }
3049
+ /** 未压缩格式的公钥(base64),即 `bd-ticket-guard-ree-public-key` */
3050
+ get reePublicKey() {
3051
+ const spki = node_crypto.default.createPublicKey(this.privateKey).export({
3052
+ format: "der",
3053
+ type: "spki"
3054
+ });
3055
+ return spki.subarray(spki.length - 65).toString("base64");
3056
+ }
3057
+ /** 已签发的票据,未签发时为 undefined */
3058
+ get state() {
3059
+ const ticket = this.jar.get(TICKET_ENTRY);
3060
+ const tsSign = this.jar.get(TS_SIGN_ENTRY);
3061
+ const ecdh = this.jar.get(ECDH_ENTRY);
3062
+ if (!ticket || !tsSign || !ecdh) return void 0;
3063
+ return {
3064
+ ticket,
3065
+ tsSign,
3066
+ ecdhKey: Buffer.from(ecdh, "hex")
3067
+ };
3068
+ }
3069
+ /**
3070
+ * 在首个 passport 请求之前把公钥交给服务端
3071
+ *
3072
+ * 缺了这一步扫码依然能成功,但服务端不会签发票据,后续请求也就无从携带。
3073
+ */
3074
+ publishPublicKey() {
3075
+ const payload = compactJson({
3076
+ "bd-ticket-guard-version": Number(GUARD_VERSION),
3077
+ "bd-ticket-guard-iteration-version": Number(ITERATION_VERSION),
3078
+ "bd-ticket-guard-ree-public-key": this.reePublicKey,
3079
+ "bd-ticket-guard-web-version": Number(GUARD_VERSION)
3080
+ });
3081
+ this.jar.set(CLIENT_DATA_COOKIE, encodeURIComponent(Buffer.from(payload, "utf8").toString("base64")));
3082
+ this.jar.set(CLIENT_WEB_DOMAIN_COOKIE, GUARD_VERSION);
3083
+ }
3084
+ /**
3085
+ * 消化响应里可能带回的票据签发结果
3086
+ * @param headers 响应头
3087
+ * @returns 是否收到了新票据
3088
+ */
3089
+ applyServerData(headers) {
3090
+ const fromHeader = headers[SERVER_DATA_HEADER];
3091
+ const raw = typeof fromHeader === "string" && fromHeader ? fromHeader : this.jar.get(SERVER_DATA_COOKIE);
3092
+ if (!raw) return false;
3093
+ let info;
3094
+ try {
3095
+ info = JSON.parse(Buffer.from(decodeURIComponent(raw), "base64").toString("utf8"));
3096
+ } catch {
3097
+ return false;
3098
+ }
3099
+ if (!info.ticket || !info.ts_sign || !info.client_cert) return false;
3100
+ let ecdhKey;
3101
+ try {
3102
+ ecdhKey = this.deriveEcdhKey(info.client_cert);
3103
+ } catch {
3104
+ return false;
3105
+ }
3106
+ this.jar.set(TICKET_ENTRY, info.ticket);
3107
+ this.jar.set(TS_SIGN_ENTRY, info.ts_sign);
3108
+ this.jar.set(ECDH_ENTRY, ecdhKey.toString("hex"));
3109
+ return true;
3110
+ }
3111
+ /**
3112
+ * 生成本次请求的 bd-ticket-guard 请求头
3113
+ *
3114
+ * 尚未拿到票据时只声明公钥,让服务端有机会签发;拿到之后带完整签名。
3115
+ * @param path 请求路径,不含 query
3116
+ * @param timestamp 秒级时间戳,默认取当前
3117
+ */
3118
+ headers(path, timestamp = Math.floor(Date.now() / 1e3)) {
3119
+ const base = {
3120
+ "bd-ticket-guard-version": GUARD_VERSION,
3121
+ "bd-ticket-guard-iteration-version": ITERATION_VERSION,
3122
+ "bd-ticket-guard-ree-public-key": this.reePublicKey
3123
+ };
3124
+ const state = this.state;
3125
+ if (!state) return base;
3126
+ const signed = `ticket=${state.ticket}&path=${path}&timestamp=${timestamp}`;
3127
+ const clientData = compactJson({
3128
+ ts_sign: state.tsSign,
3129
+ req_content: REQ_CONTENT,
3130
+ req_sign: node_crypto.default.createHmac("sha256", state.ecdhKey).update(signed, "utf8").digest("base64"),
3131
+ timestamp
3132
+ });
3133
+ return {
3134
+ ...base,
3135
+ "bd-ticket-guard-client-data": Buffer.from(clientData, "utf8").toString("base64"),
3136
+ "bd-ticket-guard-web-version": state.tsSign.startsWith("ts.1") ? "1" : GUARD_VERSION,
3137
+ "bd-ticket-guard-web-sign-type": "1"
3138
+ };
3139
+ }
3140
+ /**
3141
+ * ECDH + HKDF-SHA256 派生 HMAC 密钥
3142
+ * @param clientCert 服务端下发的证书或裸公钥
3143
+ */
3144
+ deriveEcdhKey(clientCert) {
3145
+ const shared = node_crypto.default.diffieHellman({
3146
+ privateKey: this.privateKey,
3147
+ publicKey: readServerPublicKey(clientCert)
3148
+ });
3149
+ return Buffer.from(node_crypto.default.hkdfSync("sha256", shared, Buffer.alloc(32), Buffer.alloc(0), 32));
3150
+ }
3151
+ };
3152
+ //#endregion
3153
+ //#region src/platform/douyin/passport/client.ts
3154
+ /**
3155
+ * passport 登录的 HTTP 客户端
3156
+ *
3157
+ * 走 amagi 自己的 `fetchResponse`(axios),因此代理、超时、重试与网络事件与其它接口一致。
3158
+ *
3159
+ * 客户端本身是无状态的:会话状态(`msToken`、`passport_csrf_token`)都以 cookie 形式
3160
+ * 随调用方传入的 cookie 串进出,`x-tt-passport-verify-portrait` 则由 `ttwid` 派生,
3161
+ * 因此同一份 cookie 在整个登录过程中会得到稳定的 portrait,调用方无需额外保存任何东西。
3162
+ */
3163
+ /** 与签名里的浏览器环境保持一致的 UA */
3164
+ const PASSPORT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36";
3165
+ /** 单次请求默认超时 */
3166
+ const DEFAULT_TIMEOUT = 15e3;
3167
+ /** SSO 跳转最多跟随的次数 */
3168
+ const MAX_REDIRECT_HOPS = 5;
3169
+ /** 浏览器客户端提示头,服务端会与 UA 交叉校验 */
3170
+ const CLIENT_HINTS = {
3171
+ "sec-ch-ua": "\"Not_A Brand\";v=\"99\", \"Chromium\";v=\"142\"",
3172
+ "sec-ch-ua-mobile": "?0",
3173
+ "sec-ch-ua-platform": "\"Windows\""
3174
+ };
3175
+ /** 安全解析 JSON,失败返回空对象 */
3176
+ const parseJson = (text) => {
3177
+ try {
3178
+ return JSON.parse(text);
3179
+ } catch {
3180
+ return {};
3181
+ }
3182
+ };
3183
+ /**
3184
+ * 由 ttwid 派生出稳定的 verify portrait
3185
+ *
3186
+ * 浏览器里这个值在一次登录页生命周期内固定不变。这里用 cookie 中已有的设备指纹推导,
3187
+ * 既保证同一会话内多次调用得到同一个值,又不需要调用方额外携带状态。
3188
+ * @param jar 当前会话 cookie
3189
+ */
3190
+ const deriveVerifyPortrait = (jar) => {
3191
+ const seed = jar.get("ttwid") ?? jar.get("__ac_nonce") ?? "douyin-passport";
3192
+ const hex = node_crypto.default.createHash("sha256").update(seed).digest("hex");
3193
+ return `${[
3194
+ hex.slice(0, 8),
3195
+ hex.slice(8, 12),
3196
+ `4${hex.slice(13, 16)}`,
3197
+ `8${hex.slice(17, 20)}`,
3198
+ hex.slice(20, 32)
3199
+ ].join("-")}.login`;
3200
+ };
3201
+ var DouyinPassportClient = class {
3202
+ requestConfig;
3203
+ /** 会话 cookie */
3204
+ cookies;
3205
+ /** bd-ticket-guard 设备票据,状态随 cookie 一起流转 */
3206
+ ticketGuard;
3207
+ /**
3208
+ * @param cookie 已有的会话 cookie 串
3209
+ * @param requestConfig amagi 的请求配置(代理、超时、额外请求头)
3210
+ */
3211
+ constructor(cookie, requestConfig) {
3212
+ this.requestConfig = requestConfig;
3213
+ this.cookies = new CookieJar(cookie);
3214
+ this.ticketGuard = new TicketGuard(this.cookies);
3215
+ }
3216
+ /** CSRF token:优先用服务端下发的,缺失时本地生成并同步写进 cookie(双提交校验) */
3217
+ get csrfToken() {
3218
+ const fromCookie = this.cookies.get("passport_csrf_token");
3219
+ if (fromCookie) return fromCookie;
3220
+ const generated = randomHex(32);
3221
+ this.cookies.set("passport_csrf_token", generated);
3222
+ this.cookies.set("passport_csrf_token_default", generated);
3223
+ return generated;
3224
+ }
3225
+ /**
3226
+ * 初始化登录环境指纹
3227
+ *
3228
+ * 依次请求抖音首页拿 `__ac_nonce`、再向 ttwid 服务注册拿 `ttwid`。两步都是匿名的,
3229
+ * 任意机器、任意系统都能跑;失败不抛错,只会让后续更容易命中风控。
3230
+ */
3231
+ async bootstrap() {
3232
+ this.ticketGuard.publishPublicKey();
3233
+ if (this.cookies.has("ttwid") && this.cookies.has("__ac_nonce")) return;
3234
+ await this.send({
3235
+ method: "GET",
3236
+ url: `https://${WEB_HOST}/`,
3237
+ headers: {
3238
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
3239
+ ...CLIENT_HINTS
3240
+ }
3241
+ });
3242
+ await this.send({
3243
+ method: "POST",
3244
+ url: "https://ttwid.bytedance.com/ttwid/register/",
3245
+ headers: {
3246
+ "Content-Type": "application/json",
3247
+ Origin: `https://${WEB_HOST}`,
3248
+ Referer: `https://${WEB_HOST}/`
3249
+ },
3250
+ data: JSON.stringify({
3251
+ aid: 6383,
3252
+ service: WEB_HOST
3253
+ })
3254
+ });
3255
+ emitLogDebug(`[douyin passport] 环境指纹就绪: ttwid=${this.cookies.has("ttwid")}, ac_nonce=${this.cookies.has("__ac_nonce")}`);
3256
+ }
3257
+ /**
3258
+ * 请求 login.douyin.com 的 passport 接口(四重签名 + a_bogus 形态)
3259
+ * @param path 接口路径,如 `/passport/web/get_qrcode/`
3260
+ * @param params 业务参数,并入 query
3261
+ */
3262
+ async request(path, params = {}) {
3263
+ const common = makeCommonParams(params);
3264
+ const { sign, qs } = makeSignAndQs(common, {});
3265
+ const query = {
3266
+ ...common,
3267
+ sign,
3268
+ qs
3269
+ };
3270
+ const msToken = this.cookies.get("msToken");
3271
+ if (msToken) query.msToken = msToken;
3272
+ const queryString = serializeQuery(query);
3273
+ const url = `https://${LOGIN_HOST}${path}?${queryString}&a_bogus=${encodeURIComponent(aBogus(queryString, PASSPORT_USER_AGENT))}`;
3274
+ return this.send({
3275
+ method: "GET",
3276
+ url,
3277
+ headers: {
3278
+ Accept: "application/json, text/javascript",
3279
+ "Content-Type": "application/x-www-form-urlencoded",
3280
+ Referer: `https://${WEB_HOST}/`,
3281
+ "x-tt-passport-aid-sign": makeAidSign(path),
3282
+ "x-tt-passport-csrf-token": this.csrfToken,
3283
+ "x-tt-passport-verify-portrait": deriveVerifyPortrait(this.cookies),
3284
+ "x-tt-passport-trace-id": String(common.biz_trace_id),
3285
+ ...this.ticketGuard.headers(path),
3286
+ ...CLIENT_HINTS
3287
+ }
3288
+ });
3289
+ }
3290
+ /**
3291
+ * 请求 www.douyin.com 的验证页接口(lite 形态:固定 query + 表单 body,无签名)
3292
+ * @param path 接口路径,如 `/passport/web/send_code/`
3293
+ * @param params 业务参数,进 body
3294
+ * @param bizTraceId 业务追踪 ID,同一次验证流程内保持一致
3295
+ */
3296
+ async liteRequest(path, params, bizTraceId) {
3297
+ return this.send({
3298
+ method: "POST",
3299
+ url: `https://${WEB_HOST}${path}?${serializeQuery(makeLiteParams(bizTraceId))}`,
3300
+ headers: {
3301
+ Accept: "application/json, text/javascript",
3302
+ "Content-Type": "application/x-www-form-urlencoded",
3303
+ Origin: `https://${WEB_HOST}`,
3304
+ Referer: `https://${WEB_HOST}/`,
3305
+ "x-tt-passport-aid-sign": makeAidSign(path),
3306
+ "x-tt-passport-csrf-token": this.csrfToken,
3307
+ "x-tt-passport-verify-portrait": deriveVerifyPortrait(this.cookies),
3308
+ "x-tt-passport-trace-id": bizTraceId,
3309
+ ...this.ticketGuard.headers(path),
3310
+ ...CLIENT_HINTS
3311
+ },
3312
+ data: serializeQuery(params)
3313
+ });
3314
+ }
3315
+ /**
3316
+ * 跟随扫码确认后下发的 SSO 跳转链,把最终的登录凭证收进 CookieJar
3317
+ * @param redirectUrl `check_qrconnect` 返回的 redirect_url
3318
+ * @returns 是否拿到登录态 cookie
3319
+ */
3320
+ async followSsoRedirect(redirectUrl) {
3321
+ let current = redirectUrl;
3322
+ for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop++) {
3323
+ const response = await this.send({
3324
+ method: "GET",
3325
+ url: current,
3326
+ maxRedirects: 0,
3327
+ headers: {
3328
+ Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
3329
+ Referer: `https://${LOGIN_HOST}/`,
3330
+ ...CLIENT_HINTS
3331
+ }
3332
+ });
3333
+ const location = response.location;
3334
+ if (!location || response.status < 300 || response.status >= 400) break;
3335
+ current = new URL(location, current).toString();
3336
+ }
3337
+ return this.cookies.isLoggedIn();
3338
+ }
3339
+ /** 实际发请求:合并 cookie、消化 Set-Cookie 与 msToken */
3340
+ async send(config) {
3341
+ const cookie = this.cookies.toString();
3342
+ const response = await fetchResponse({
3343
+ timeout: this.requestConfig?.timeout ?? DEFAULT_TIMEOUT,
3344
+ proxy: this.requestConfig?.proxy,
3345
+ ...config,
3346
+ responseType: "text",
3347
+ maxRedirects: config.maxRedirects ?? 5,
3348
+ headers: {
3349
+ "User-Agent": PASSPORT_USER_AGENT,
3350
+ ...config.headers,
3351
+ ...cookie ? { Cookie: cookie } : {}
3352
+ }
3353
+ });
3354
+ if (isNetworkErrorResult(response)) throw new Error(response.error.amagiError.errorDescription);
3355
+ const axiosResponse = response;
3356
+ this.cookies.applySetCookie(axiosResponse.headers["set-cookie"]);
3357
+ const refreshed = axiosResponse.headers["x-ms-token"];
3358
+ if (typeof refreshed === "string" && refreshed) this.cookies.set("msToken", refreshed);
3359
+ if (this.ticketGuard.applyServerData(axiosResponse.headers)) emitLogDebug("[douyin passport] 已获得 bd-ticket-guard 票据");
3360
+ const raw = typeof axiosResponse.data === "string" ? axiosResponse.data : JSON.stringify(axiosResponse.data);
3361
+ return {
3362
+ status: axiosResponse.status,
3363
+ raw,
3364
+ body: parseJson(raw),
3365
+ cookie: this.cookies.serialize(),
3366
+ location: axiosResponse.headers.location
3367
+ };
3368
+ }
3369
+ };
3370
+ //#endregion
3371
+ //#region src/platform/douyin/passport/parser.ts
3372
+ /** 触发账号二次验证的错误码 */
3373
+ const ERROR_SECOND_VERIFY = 2046;
3374
+ /** 验证码错误 */
3375
+ const ERROR_WRONG_CODE = 1202;
3376
+ /** 发码过于频繁 */
3377
+ const ERROR_RATE_LIMITED = 1206;
3378
+ /** 命中风控 / 设备环境异常 */
3379
+ const RISK_ERROR_CODES = /* @__PURE__ */ new Set([2156, 4031]);
3380
+ /**
3381
+ * 轮询过于频繁
3382
+ *
3383
+ * 描述文案是「访问太频繁」,但设备指纹不完整时服务端也用这个码兜底,
3384
+ * 属于可重试的瞬时状态,退避后继续轮询即可,不能当致命错误。
3385
+ */
3386
+ const ERROR_POLL_BUSY = 7;
3387
+ /** 命中限频后的退避倍率 */
3388
+ const BUSY_BACKOFF = 2;
3389
+ /** 轮询间隔下限与默认值,服务端偶尔会给 0 */
3390
+ const MIN_INTERVAL = 1e3;
3391
+ const DEFAULT_INTERVAL = 3e3;
3392
+ /** 验证会话票据字段,需原样透传 */
3393
+ const STD_KEYS = [
3394
+ "std_verify_flow_id",
3395
+ "std_verify_scene",
3396
+ "std_verify_template",
3397
+ "std_verify_token",
3398
+ "std_verify_type",
3399
+ "std_verify_way"
3400
+ ];
3401
+ const asString = (value) => typeof value === "string" ? value : "";
3402
+ const asNumber = (value) => {
3403
+ const parsed = typeof value === "number" ? value : Number(value);
3404
+ return Number.isFinite(parsed) ? parsed : void 0;
3405
+ };
3406
+ /** 从响应体的多个可能位置取错误码 */
3407
+ const readErrorCode = (payload) => asNumber(payload.data?.error_code) ?? asNumber(payload.error_code);
3408
+ /** 取一段人类可读的错误描述 */
3409
+ const readMessage = (payload) => asString(payload.data?.description) || asString(payload.description) || asString(payload.message) || "";
3410
+ /**
3411
+ * 解析 `get_qrcode` 响应
3412
+ * @param payload 服务端响应体
3413
+ * @returns 二维码信息,缺少 token 时返回 null
3414
+ */
3415
+ const parseQrcode = (payload) => {
3416
+ const data = payload.data ?? {};
3417
+ const token = asString(data.token);
3418
+ if (!token) return null;
3419
+ return {
3420
+ token,
3421
+ content: asString(data.qrcode_index_url) || token,
3422
+ expireTime: asNumber(data.expire_time) ?? 0
3423
+ };
3424
+ };
3425
+ /** 解析服务端下发的可选验证方式 */
3426
+ const parseVerifyWays = (raw) => {
3427
+ if (!Array.isArray(raw)) return [];
3428
+ return raw.map((item) => {
3429
+ const way = item;
3430
+ return {
3431
+ verifyWay: asString(way?.verify_way),
3432
+ mobile: asString(way?.mobile) || void 0
3433
+ };
3434
+ }).filter((way) => way.verifyWay !== "");
3435
+ };
3436
+ /**
3437
+ * 从轮询响应里提取二次验证上下文
3438
+ * @param data 轮询响应的 data 段
3439
+ */
3440
+ const parseVerifyContext = (data) => {
3441
+ const stdParams = {};
3442
+ for (const key of STD_KEYS) {
3443
+ const value = asString(data[key]);
3444
+ if (value) stdParams[key] = value;
3445
+ }
3446
+ return {
3447
+ encryptUid: asString(data.encrypt_uid),
3448
+ verifyTicket: asString(data.verify_ticket),
3449
+ stdParams,
3450
+ copywritingKey: asString(data.copywriting_key) || "qr_connect",
3451
+ diversionTag: asString(data.ies_safety_diversion_tag) || "mfa",
3452
+ newVerifyFlow: asString(data.new_verify_flow),
3453
+ verifyWays: parseVerifyWays(data.verify_ways)
3454
+ };
3455
+ };
3456
+ /**
3457
+ * 解析 `check_qrconnect` 响应为状态机可消费的结果
3458
+ * @param payload 服务端响应体
3459
+ */
3460
+ const parsePollResult = (payload) => {
3461
+ const data = payload.data ?? {};
3462
+ const rawInterval = asNumber(data.interval) ?? 0;
3463
+ const interval = rawInterval >= MIN_INTERVAL ? rawInterval : DEFAULT_INTERVAL;
3464
+ const status = asString(data.status);
3465
+ const errorCode = readErrorCode(payload);
3466
+ if (errorCode === ERROR_SECOND_VERIFY || asString(data.account_flow) === "verify") return {
3467
+ status: "verify",
3468
+ interval,
3469
+ verify: parseVerifyContext(data)
3470
+ };
3471
+ if (errorCode !== void 0 && RISK_ERROR_CODES.has(errorCode)) return {
3472
+ status: "risk",
3473
+ interval,
3474
+ message: readMessage(payload) || `error_code=${errorCode}`
3475
+ };
3476
+ if (errorCode === ERROR_POLL_BUSY) return {
3477
+ status: "busy",
3478
+ interval: interval * BUSY_BACKOFF,
3479
+ message: readMessage(payload) || "轮询过于频繁"
3480
+ };
3481
+ switch (status) {
3482
+ case "new":
3483
+ case "scanned":
3484
+ case "expired": return {
3485
+ status,
3486
+ interval
3487
+ };
3488
+ case "confirmed": return {
3489
+ status: "confirmed",
3490
+ interval,
3491
+ redirectUrl: asString(data.redirect_url) || (Array.isArray(data.redirect_urls) ? asString(data.redirect_urls[0]) : "")
3492
+ };
3493
+ default: return {
3494
+ status: "unknown",
3495
+ interval,
3496
+ message: readMessage(payload) || (status ? `status=${status}` : `error_code=${errorCode ?? "unknown"}`)
3497
+ };
3498
+ }
3499
+ };
3500
+ /**
3501
+ * 解析 `send_code` 响应
3502
+ * @param payload 服务端响应体
3503
+ */
3504
+ const parseSendCodeResult = (payload) => {
3505
+ const data = payload.data ?? {};
3506
+ const errorCode = readErrorCode(payload);
3507
+ const retryAfter = asNumber(data.retry_time) ?? 60;
3508
+ const mobile = asString(data.mobile);
3509
+ if (errorCode === 0 || errorCode === void 0 && payload.message === "success") return {
3510
+ ok: true,
3511
+ mobile,
3512
+ retryAfter,
3513
+ message: ""
3514
+ };
3515
+ return {
3516
+ ok: false,
3517
+ mobile,
3518
+ retryAfter,
3519
+ errorCode,
3520
+ message: readMessage(payload) || (errorCode === ERROR_RATE_LIMITED ? "短信发送过于频繁" : `发码失败 error_code=${errorCode ?? "unknown"}`)
3521
+ };
3522
+ };
3523
+ /**
3524
+ * 解析 `validate_code` 响应
3525
+ * @param payload 服务端响应体
3526
+ */
3527
+ const parseValidateCodeResult = (payload) => {
3528
+ const data = payload.data ?? {};
3529
+ const errorCode = readErrorCode(payload);
3530
+ if (errorCode === 0 || asString(data.ticket) || errorCode === void 0 && payload.message === "success") return {
3531
+ ok: true,
3532
+ wrongCode: false,
3533
+ message: ""
3534
+ };
3535
+ return {
3536
+ ok: false,
3537
+ wrongCode: errorCode === ERROR_WRONG_CODE,
3538
+ errorCode,
3539
+ message: readMessage(payload) || (errorCode === ERROR_WRONG_CODE ? "验证码错误" : `验证失败 error_code=${errorCode ?? "unknown"}`)
3540
+ };
3541
+ };
3542
+ //#endregion
3543
+ //#region src/platform/douyin/passport/index.ts
3544
+ var passport_exports = /* @__PURE__ */ require_rolldown_runtime.__exportAll({
3545
+ BDMS_SDK_VERSION: () => BDMS_SDK_VERSION,
3546
+ CookieJar: () => CookieJar,
3547
+ DouyinPassportClient: () => DouyinPassportClient,
3548
+ INTERNAL_PREFIX: () => INTERNAL_PREFIX,
3549
+ PASSPORT_USER_AGENT: () => PASSPORT_USER_AGENT,
3550
+ TicketGuard: () => TicketGuard,
3551
+ aBogus: () => aBogus,
3552
+ makeAidSign: () => makeAidSign,
3553
+ makeSignAndQs: () => makeSignAndQs,
3554
+ parsePollResult: () => parsePollResult,
3555
+ parseQrcode: () => parseQrcode,
3556
+ parseSendCodeResult: () => parseSendCodeResult,
3557
+ parseValidateCodeResult: () => parseValidateCodeResult,
3558
+ randomHex: () => randomHex,
3559
+ sm3: () => sm3,
3560
+ sm3Hex: () => sm3Hex,
3561
+ sm3Twice: () => sm3Twice,
3562
+ utcNoonTimestamp: () => utcNoonTimestamp,
3563
+ xor5Hex: () => xor5Hex
3564
+ });
3565
+ //#endregion
3566
+ //#region src/types/NetworksConfigType.ts
3567
+ /** 快手平台API错误码 */
3568
+ let kuaishouAPIErrorCode = /* @__PURE__ */ function(kuaishouAPIErrorCode) {
3569
+ /** Cookie无效或已过期 */
3570
+ kuaishouAPIErrorCode["COOKIE"] = "INVALID_COOKIE";
3571
+ /** 未知错误 */
3572
+ kuaishouAPIErrorCode["UNKNOWN"] = "UNKNOWN_ERROR";
3573
+ return kuaishouAPIErrorCode;
3574
+ }({});
3575
+ /** 小红书平台API错误码 */
3576
+ let xiaohongshuAPIErrorCode = /* @__PURE__ */ function(xiaohongshuAPIErrorCode) {
3577
+ /** Cookie无效或已过期 */
3578
+ xiaohongshuAPIErrorCode["COOKIE"] = "INVALID_COOKIE";
3579
+ /** 未知错误 */
3580
+ xiaohongshuAPIErrorCode["UNKNOWN"] = "UNKNOWN_ERROR";
3581
+ /** 非法请求 */
3582
+ xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["ILLEGAL_REQUEST"] = 500] = "ILLEGAL_REQUEST";
3583
+ /** 检测到帐号异常,请稍后重试 */
3584
+ xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["ACCOUNT_ABNORMAL"] = 300011] = "ACCOUNT_ABNORMAL";
3585
+ /** 网络连接异常,请检查网络设置后重试 */
3586
+ xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["NETWORK_ERROR"] = 300012] = "NETWORK_ERROR";
3587
+ /** 访问频次异常,请勿频繁操作 */
3588
+ xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["FREQUENCY_ERROR"] = 300013] = "FREQUENCY_ERROR";
3589
+ /** 浏览器异常,请尝试更换浏览器后重试 */
3590
+ xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["BROWSER_ERROR"] = 300015] = "BROWSER_ERROR";
3591
+ return xiaohongshuAPIErrorCode;
3592
+ }({});
3593
+ //#endregion
3594
+ //#region src/model/fetchers/douyin/auth.ts
3595
+ /**
3596
+ * 抖音登录认证相关 API(passport 扫码登录)
3597
+ *
3598
+ * 与其它 fetcher 的差别:这几个接口不走 `DouyinData` 的 URL 拼装 + a_bogus 流水线,
3599
+ * 因为 passport 体系有自己的三重 query 签名、独立的 a_bogus 形态与双重编码规则。
3600
+ * 对外形态保持一致:同样是 `(options, cookie?, requestConfig?) => Result<T>`,
3601
+ * 同样发 `apiSuccess` / `apiError` 事件,同样复用 amagi 的代理、超时与重试。
3602
+ *
3603
+ * 这几个方法都是无状态的:会话状态全部装在 cookie 串里,调用方拿到返回的 `cookie`
3604
+ * 后在下一次调用时传回来即可。轮询循环由调用方维护。
3605
+ *
3606
+ * @module fetchers/douyin/auth
3607
+ */
3608
+ /** 短信验证码的验证方式标识,服务端未给出可用方式时的兜底值 */
3609
+ const SMS_VERIFY_WAY = "mobile_sms_verify";
3610
+ /**
3611
+ * 可以用「收 6 位验证码」这套流程走完的验证方式
3612
+ *
3613
+ * 除官方常见的 `mobile_sms_verify`,账号被判定需要辅助验证时会给出
3614
+ * `assist_mobile_sms_verify`,两者都是下行短信收码,走同一对
3615
+ * `send_code` / `validate_code` 接口,区别只在 `std_verify_way` 的取值。
3616
+ * 上行短信(`*_up_sms_verify`)要求用户从手机发短信出去,是另一套接口,不在此列。
3617
+ */
3618
+ const SMS_CODE_WAY_PATTERN = /^(assist_)?mobile_sms_verify$/;
3619
+ /**
3620
+ * 判断某个验证方式能否用短信验证码流程完成
3621
+ * @param verifyWay 服务端下发的 verify_way
3622
+ */
3623
+ const isSmsCodeVerifyWay = (verifyWay) => SMS_CODE_WAY_PATTERN.test(verifyWay);
3624
+ /**
3625
+ * 选出本次要用的 std_verify_way
3626
+ *
3627
+ * 优先用调用方指定的;否则从服务端给出的可选方式里挑一个能收码的;都没有才回退到默认值。
3628
+ * 之前这里写死 `mobile_sms_verify`,遇到辅助验证的账号会因为 way 对不上而失败。
3629
+ * @param verify 轮询下发的验证上下文
3630
+ * @param requested 调用方显式指定的验证方式
3631
+ */
3632
+ const resolveVerifyWay = (verify, requested) => requested ?? verify.verifyWays.find((way) => isSmsCodeVerifyWay(way.verifyWay))?.verifyWay ?? verify.stdParams.std_verify_way ?? SMS_VERIFY_WAY;
3633
+ /** 短信验证码的 act_type */
3634
+ const SMS_ACT_TYPE = "3737";
3635
+ /** 验证页 SDK 版本,随表单一起提交 */
3636
+ const AUTHN_VERSION = "1.0.0.420-web";
3637
+ /** 抖音 web 的 aid */
3638
+ const AID = "6383";
3639
+ /** 扫码成功后的跳转地址 */
3640
+ const NEXT_URL = "https://www.douyin.com";
3641
+ /**
3642
+ * 发码与验码共用的表单字段
3643
+ *
3644
+ * 字段顺序与「空值也要占位」的行为对齐官方验证页 SDK 的抓包形态:
3645
+ * `verify_ticket` / `new_verify_flow` / `std_verify_flow_id` / `std_verify_token`
3646
+ * 即使为空也必须出现,缺字段会被判为伪造请求。
3647
+ * @param verify 轮询下发的验证上下文
3648
+ * @param verifyWay 本次使用的验证方式,原样进 `std_verify_way`
3649
+ * @param tail 追加在 std_verify_way 之后的字段(发码是 is6Digits,验码是 code)
3650
+ */
3651
+ const buildVerifyBody = (verify, verifyWay, tail) => ({
3652
+ mix_mode: "1",
3653
+ type: SMS_ACT_TYPE,
3654
+ encrypt_uid: verify.encryptUid,
3655
+ verify_ticket: verify.verifyTicket,
3656
+ copywriting_key: verify.copywritingKey,
3657
+ ies_safety_diversion_tag: verify.diversionTag,
3658
+ new_verify_flow: verify.newVerifyFlow,
3659
+ std_verify_flow_id: verify.stdParams.std_verify_flow_id ?? "",
3660
+ std_verify_scene: verify.stdParams.std_verify_scene ?? "account_login",
3661
+ std_verify_template: verify.stdParams.std_verify_template ?? "ato_web",
3662
+ std_verify_token: verify.stdParams.std_verify_token ?? "",
3663
+ std_verify_type: verify.stdParams.std_verify_type ?? "MFA",
3664
+ std_verify_way: verifyWay,
3665
+ ...tail,
3666
+ aid: AID,
3667
+ new_authn_sdk_version: AUTHN_VERSION
3668
+ });
3669
+ /**
3670
+ * 构造 passport 侧的业务错误响应
3671
+ * @param methodType 方法名,进 amagiError.requestType
3672
+ * @param message 错误描述
3673
+ */
3674
+ const passportError = (methodType, message) => createErrorResponse({
3675
+ code: "UNKNOWN_ERROR",
3676
+ data: null,
3677
+ amagiError: {
3678
+ errorDescription: message,
3679
+ requestType: methodType,
3680
+ requestUrl: `https://login.douyin.com/passport/`
3681
+ },
3682
+ amagiMessage: message
3683
+ }, message);
3684
+ /** 统一包一层事件上报与异常兜底 */
3685
+ const run = async (methodType, task) => {
3686
+ const startTime = Date.now();
3687
+ try {
3688
+ const result = await task();
3689
+ const duration = Date.now() - startTime;
3690
+ if (result.code === 200) emitApiSuccess({
3691
+ platform: "douyin",
3692
+ methodType,
3693
+ response: result,
3694
+ statusCode: 200,
3695
+ duration
3696
+ });
3697
+ else emitApiError({
3698
+ platform: "douyin",
3699
+ methodType,
3700
+ errorCode: result.code,
3701
+ errorMessage: result.message,
3702
+ duration
3703
+ });
3704
+ return result;
3705
+ } catch (error) {
3706
+ const duration = Date.now() - startTime;
3707
+ const errorMessage = error instanceof Error ? error.message : "未知错误";
3708
+ emitApiError({
3709
+ platform: "douyin",
3710
+ methodType,
3711
+ errorMessage,
3712
+ duration
3713
+ });
3714
+ throw new Error(`抖音登录请求失败: ${errorMessage}`);
3715
+ }
3716
+ };
3717
+ /**
3718
+ * 申请抖音扫码登录二维码
3719
+ *
3720
+ * 首次调用会自动完成环境指纹初始化(`__ac_nonce` + `ttwid`),无需额外准备。
3721
+ * @param options - 请求选项 (可选)
3722
+ * @param cookie - 已有的会话 Cookie (可选,续用同一会话时传入)
3723
+ * @param requestConfig - 请求配置 (可选)
3724
+ * @returns 二维码令牌、内容与会话 cookie
3725
+ * @example
3726
+ * ```typescript
3727
+ * const qrcode = await requestPassportQrcode()
3728
+ * console.log(qrcode.data.content) // 拿去生成二维码图片
3729
+ * ```
3730
+ */
3731
+ async function requestPassportQrcode(options, cookie, requestConfig) {
3732
+ return run("passportQrcode", async () => {
3733
+ const client = new DouyinPassportClient(cookie, requestConfig);
3734
+ await client.bootstrap();
3735
+ const response = await client.request("/passport/web/get_qrcode/", {
3736
+ next: NEXT_URL,
3737
+ need_short_url: "true",
3738
+ need_logo: "false",
3739
+ is_new_login: "1"
3740
+ });
3741
+ const qrcode = parseQrcode(response.body);
3742
+ if (!qrcode) return passportError("passportQrcode", response.body.message || `获取二维码失败: ${response.raw.slice(0, 200)}`);
3743
+ return createSuccessResponse({
3744
+ token: qrcode.token,
3745
+ content: qrcode.content,
3746
+ expire_time: qrcode.expireTime,
3747
+ expires_in: Math.max(0, qrcode.expireTime - Math.floor(Date.now() / 1e3)),
3748
+ cookie: response.cookie
3749
+ }, "获取成功", 200);
3750
+ });
3751
+ }
3752
+ /**
3753
+ * 查询抖音扫码登录二维码的状态
3754
+ *
3755
+ * 状态为 `confirmed` 时会自动跟随 SSO 跳转领取登录凭证,返回的 `cookie` 即完整登录态。
3756
+ * @param options - 二维码状态参数
3757
+ * @param options.token - `requestPassportQrcode` 返回的令牌
3758
+ * @param cookie - 会话 Cookie,必须是申请二维码时返回的那一份
3759
+ * @param requestConfig - 请求配置 (可选)
3760
+ * @returns 扫码状态与最新会话 cookie
3761
+ * @example
3762
+ * ```typescript
3763
+ * const status = await checkPassportQrcode({ token }, cookie)
3764
+ * // new 未扫码 / scanned 已扫待确认 / verify 需二次验证 / confirmed 登录成功 / expired 已过期
3765
+ * console.log(status.data.status)
3766
+ * ```
3767
+ */
3768
+ async function checkPassportQrcode(options, cookie, requestConfig) {
3769
+ return run("passportQrcodeStatus", async () => {
3770
+ if (!options?.token) return passportError("passportQrcodeStatus", "缺少 token 参数");
3771
+ const client = new DouyinPassportClient(cookie, requestConfig);
3772
+ const response = await client.request("/passport/web/check_qrconnect/", {
3773
+ next: NEXT_URL,
3774
+ need_logo: "false",
3775
+ is_frontier: "true",
3776
+ token: options.token,
3777
+ is_new_login: "1",
3778
+ need_short_url: "true"
3779
+ });
3780
+ const result = parsePollResult(response.body);
3781
+ if (result.status === "verify") emitLogDebug(`[douyin passport] 触发二次验证,服务端原始响应: ${response.raw.slice(0, 1e3)}`);
3782
+ if (result.status === "confirmed" && result.redirectUrl) await client.followSsoRedirect(result.redirectUrl);
3783
+ const sessionCookie = result.status === "confirmed" ? client.cookies.toString() : client.cookies.serialize();
3784
+ return createSuccessResponse({
3785
+ ...result,
3786
+ cookie: sessionCookie,
3787
+ logged_in: client.cookies.isLoggedIn()
3788
+ }, "获取成功", 200);
3789
+ });
3790
+ }
3791
+ /**
3792
+ * 向账号绑定手机发送二次验证短信验证码
3793
+ *
3794
+ * 用于轮询返回 `status: 'verify'`(即 `error_code=2046` / `account_flow=verify`)的场景。
3795
+ * @param options - 发码参数
3796
+ * @param options.verify - 轮询返回的验证上下文
3797
+ * @param options.biz_trace_id - 追踪 ID (可选,不传自动生成)
3798
+ * @param cookie - 会话 Cookie
3799
+ * @param requestConfig - 请求配置 (可选)
3800
+ * @returns 脱敏手机号、重发等待秒数与追踪 ID
3801
+ */
3802
+ async function sendPassportVerifyCode(options, cookie, requestConfig) {
3803
+ return run("passportSendCode", async () => {
3804
+ if (!options?.verify?.encryptUid) return passportError("passportSendCode", "缺少 encrypt_uid,请从轮询响应中取得验证上下文");
3805
+ const bizTraceId = options.biz_trace_id ?? randomHex(8);
3806
+ const verifyWay = resolveVerifyWay(options.verify, options.verify_way);
3807
+ emitLogDebug(`[douyin passport] 发码使用的验证方式: ${verifyWay}`);
3808
+ const response = await new DouyinPassportClient(cookie, requestConfig).liteRequest("/passport/web/send_code/", buildVerifyBody(options.verify, verifyWay, { is6Digits: "1" }), bizTraceId);
3809
+ const result = parseSendCodeResult(response.body);
3810
+ if (!result.ok) emitLogDebug(`[douyin passport] 发码失败原文: ${response.raw.slice(0, 500)}`);
3811
+ return createSuccessResponse({
3812
+ ...result,
3813
+ cookie: response.cookie,
3814
+ biz_trace_id: bizTraceId,
3815
+ verify_way: verifyWay
3816
+ }, "获取成功", 200);
3817
+ });
3818
+ }
3819
+ /**
3820
+ * 提交二次验证的短信验证码
3821
+ * @param options - 验码参数
3822
+ * @param options.verify - 轮询返回的验证上下文
3823
+ * @param options.code - 用户收到的 6 位验证码明文
3824
+ * @param options.biz_trace_id - 必须与发码时用的是同一个
3825
+ * @param cookie - 会话 Cookie
3826
+ * @param requestConfig - 请求配置 (可选)
3827
+ * @returns 验证结果;`wrongCode` 为 true 表示验证码填错,可以让用户重试
3828
+ */
3829
+ async function validatePassportVerifyCode(options, cookie, requestConfig) {
3830
+ return run("passportValidateCode", async () => {
3831
+ if (!options?.verify?.encryptUid) return passportError("passportValidateCode", "缺少 encrypt_uid,请从轮询响应中取得验证上下文");
3832
+ if (!options.code) return passportError("passportValidateCode", "缺少 code,请填入收到的短信验证码");
3833
+ const response = await new DouyinPassportClient(cookie, requestConfig).liteRequest("/passport/web/validate_code/", buildVerifyBody(options.verify, resolveVerifyWay(options.verify, options.verify_way), { code: xor5Hex(options.code) }), options.biz_trace_id ?? randomHex(8));
3834
+ const result = parseValidateCodeResult(response.body);
3835
+ if (!result.ok) emitLogDebug(`[douyin passport] 验码失败原文: ${response.raw.slice(0, 500)}`);
3836
+ return createSuccessResponse({
3837
+ ...result,
3838
+ cookie: response.cookie
3839
+ }, "获取成功", 200);
3840
+ });
3841
+ }
3842
+ //#endregion
2154
3843
  //#region src/platform/defaultConfigs.ts
2155
3844
  /**
2156
3845
  * 根据User-Agent生成对应的Sec-Ch-Ua值
@@ -2285,34 +3974,6 @@ const getXiaohongshuDefaultConfig = (cookie) => {
2285
3974
  } };
2286
3975
  };
2287
3976
  //#endregion
2288
- //#region src/types/NetworksConfigType.ts
2289
- /** 快手平台API错误码 */
2290
- let kuaishouAPIErrorCode = /* @__PURE__ */ function(kuaishouAPIErrorCode) {
2291
- /** Cookie无效或已过期 */
2292
- kuaishouAPIErrorCode["COOKIE"] = "INVALID_COOKIE";
2293
- /** 未知错误 */
2294
- kuaishouAPIErrorCode["UNKNOWN"] = "UNKNOWN_ERROR";
2295
- return kuaishouAPIErrorCode;
2296
- }({});
2297
- /** 小红书平台API错误码 */
2298
- let xiaohongshuAPIErrorCode = /* @__PURE__ */ function(xiaohongshuAPIErrorCode) {
2299
- /** Cookie无效或已过期 */
2300
- xiaohongshuAPIErrorCode["COOKIE"] = "INVALID_COOKIE";
2301
- /** 未知错误 */
2302
- xiaohongshuAPIErrorCode["UNKNOWN"] = "UNKNOWN_ERROR";
2303
- /** 非法请求 */
2304
- xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["ILLEGAL_REQUEST"] = 500] = "ILLEGAL_REQUEST";
2305
- /** 检测到帐号异常,请稍后重试 */
2306
- xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["ACCOUNT_ABNORMAL"] = 300011] = "ACCOUNT_ABNORMAL";
2307
- /** 网络连接异常,请检查网络设置后重试 */
2308
- xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["NETWORK_ERROR"] = 300012] = "NETWORK_ERROR";
2309
- /** 访问频次异常,请勿频繁操作 */
2310
- xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["FREQUENCY_ERROR"] = 300013] = "FREQUENCY_ERROR";
2311
- /** 浏览器异常,请尝试更换浏览器后重试 */
2312
- xiaohongshuAPIErrorCode[xiaohongshuAPIErrorCode["BROWSER_ERROR"] = 300015] = "BROWSER_ERROR";
2313
- return xiaohongshuAPIErrorCode;
2314
- }({});
2315
- //#endregion
2316
3977
  //#region src/platform/douyin/sign/a_bogus.ts
2317
3978
  var SM3 = class {
2318
3979
  reg;
@@ -4636,6 +6297,10 @@ function createBoundDouyinFetcher(cookie, requestConfig) {
4636
6297
  * ```
4637
6298
  */
4638
6299
  const douyinFetcher = {
6300
+ requestPassportQrcode,
6301
+ checkPassportQrcode,
6302
+ sendPassportVerifyCode,
6303
+ validatePassportVerifyCode,
4639
6304
  fetchVideoWork: fetchVideoWork$1,
4640
6305
  fetchImageAlbumWork,
4641
6306
  fetchSlidesWork,
@@ -8684,6 +10349,7 @@ const createDouyinRoutes = (cookie, requestConfig = getDouyinDefaultConfig(cooki
8684
10349
  /** 抖音相关功能模块 (工具集) */
8685
10350
  const douyinUtils = {
8686
10351
  sign: douyinSign,
10352
+ passport: passport_exports,
8687
10353
  douyinApiUrls
8688
10354
  };
8689
10355
  //#endregion
@@ -9456,7 +11122,7 @@ function getApiRoute(platform, methodType) {
9456
11122
  * 构建后使用 __VERSION__,开发环境从 package.json 读取
9457
11123
  */
9458
11124
  const getVersion = () => {
9459
- return "6.5.0";
11125
+ return "6.6.0";
9460
11126
  };
9461
11127
  const VERSION = getVersion();
9462
11128
  /**
@@ -9586,6 +11252,7 @@ exports.bilibiliErrorCodeMap = bilibiliErrorCodeMap;
9586
11252
  exports.bilibiliFetcher = bilibiliFetcher;
9587
11253
  exports.bilibiliUtils = bilibiliUtils;
9588
11254
  exports.bv2av = bv2av;
11255
+ exports.checkPassportQrcode = checkPassportQrcode;
9589
11256
  exports.createAmagiClient = createAmagiClient;
9590
11257
  exports.createBilibiliRoutes = createBilibiliRoutes;
9591
11258
  exports.createBoundBilibiliFetcher = createBoundBilibiliFetcher;
@@ -9600,6 +11267,12 @@ exports.createXiaohongshuRoutes = createXiaohongshuRoutes;
9600
11267
  exports.default = Client;
9601
11268
  exports.douyinApiUrls = douyinApiUrls;
9602
11269
  exports.douyinFetcher = douyinFetcher;
11270
+ Object.defineProperty(exports, "douyinPassport", {
11271
+ enumerable: true,
11272
+ get: function() {
11273
+ return passport_exports;
11274
+ }
11275
+ });
9603
11276
  exports.douyinSign = douyinSign;
9604
11277
  exports.douyinUtils = douyinUtils;
9605
11278
  exports.emitApiError = emitApiError;
@@ -9621,6 +11294,7 @@ exports.getEnglishMethodName = getEnglishMethodName;
9621
11294
  exports.getHeadersAndData = getHeadersAndData;
9622
11295
  exports.handleError = handleError;
9623
11296
  exports.isNetworkErrorResult = isNetworkErrorResult;
11297
+ exports.isSmsCodeVerifyWay = isSmsCodeVerifyWay;
9624
11298
  exports.kuaishouApiUrls = kuaishouApiUrls;
9625
11299
  exports.kuaishouFetcher = kuaishouFetcher;
9626
11300
  exports.kuaishouSign = kuaishouSign;
@@ -9631,10 +11305,13 @@ exports.registerBilibiliRoutes = createBilibiliRoutes;
9631
11305
  exports.registerDouyinRoutes = createDouyinRoutes;
9632
11306
  exports.registerKuaishouRoutes = createKuaishouRoutes;
9633
11307
  exports.registerXiaohongshuRoutes = createXiaohongshuRoutes;
11308
+ exports.requestPassportQrcode = requestPassportQrcode;
11309
+ exports.sendPassportVerifyCode = sendPassportVerifyCode;
9634
11310
  exports.toFetcherMethod = toFetcherMethod;
9635
11311
  exports.validateBilibiliParams = validateBilibiliParams;
9636
11312
  exports.validateDouyinParams = validateDouyinParams;
9637
11313
  exports.validateKuaishouParams = validateKuaishouParams;
11314
+ exports.validatePassportVerifyCode = validatePassportVerifyCode;
9638
11315
  exports.validateXiaohongshuParams = validateXiaohongshuParams;
9639
11316
  exports.wbi_sign = wbi_sign;
9640
11317
  exports.xiaohongshuApiUrls = xiaohongshuApiUrls;