@ikenxuan/amagi 7.0.0-beta.7 → 7.0.0-beta.8

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.
@@ -1,18 +1,20 @@
1
1
  const require_rolldown_runtime = require("./rolldown-runtime-C0BPl7ul.cjs");
2
- const require_steps = require("./steps-BO8NYQHx.cjs");
3
- const require_tokens = require("./tokens-DHKAuPVW.cjs");
4
- require("node:url");
2
+ const require_bv2av = require("./bv2av-Dw0qrO9g.cjs");
3
+ const require_steps = require("./steps-CpeOWLPf.cjs");
4
+ const require_he = require("./he-CuCIF0C7.cjs");
5
5
  let express = require("express");
6
6
  express = require_rolldown_runtime.__toESM(express, 1);
7
7
  let zod = require("zod");
8
8
  zod = require_rolldown_runtime.__toESM(zod, 1);
9
- let node_crypto = require("node:crypto");
10
- node_crypto = require_rolldown_runtime.__toESM(node_crypto, 1);
11
9
  let protobufjs = require("protobufjs");
12
10
  protobufjs = require_rolldown_runtime.__toESM(protobufjs, 1);
13
11
  let axios = require("axios");
14
12
  axios = require_rolldown_runtime.__toESM(axios, 1);
15
13
  let node_events = require("node:events");
14
+ let node_crypto = require("node:crypto");
15
+ node_crypto = require_rolldown_runtime.__toESM(node_crypto, 1);
16
+ let node_vm = require("node:vm");
17
+ node_vm = require_rolldown_runtime.__toESM(node_vm, 1);
16
18
  let _ikenxuan_xhshow_ts = require("@ikenxuan/xhshow-ts");
17
19
  //#region src/platforms/bilibili/api.ts
18
20
  /**
@@ -414,57 +416,6 @@ const articleListInfo = defineBilibiliEndpoint({
414
416
  response: type()
415
417
  });
416
418
  //#endregion
417
- //#region src/platforms/bilibili/sign/bv2av.ts
418
- const XOR_CODE = 23442827791579n;
419
- const MASK_CODE = 2251799813685247n;
420
- const MAX_AID = 1n << 51n;
421
- const BASE = 58n;
422
- const data = "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf";
423
- /**
424
- * av号转bv号
425
- * @param aid av号
426
- * @returns
427
- */
428
- const av2bv = (aid) => {
429
- const bytes = [
430
- "B",
431
- "V",
432
- "1",
433
- "0",
434
- "0",
435
- "0",
436
- "0",
437
- "0",
438
- "0",
439
- "0",
440
- "0",
441
- "0"
442
- ];
443
- let bvIndex = bytes.length - 1;
444
- let tmp = (MAX_AID | BigInt(aid)) ^ XOR_CODE;
445
- while (tmp > 0) {
446
- bytes[bvIndex] = data[Number(tmp % BigInt(BASE))];
447
- tmp = tmp / BASE;
448
- bvIndex -= 1;
449
- }
450
- [bytes[3], bytes[9]] = [bytes[9], bytes[3]];
451
- [bytes[4], bytes[7]] = [bytes[7], bytes[4]];
452
- return bytes.join("");
453
- };
454
- /**
455
- * bv号转av号
456
- * @param bvid bv号
457
- * @returns
458
- */
459
- const bv2av = (bvid) => {
460
- const bvidArr = Array.from(bvid);
461
- [bvidArr[3], bvidArr[9]] = [bvidArr[9], bvidArr[3]];
462
- [bvidArr[4], bvidArr[7]] = [bvidArr[7], bvidArr[4]];
463
- bvidArr.splice(0, 3);
464
- const tmp = bvidArr.reduce((pre, bvidChar) => pre * BASE + BigInt(data.indexOf(bvidChar)), 0n);
465
- return Number(tmp & MASK_CODE ^ XOR_CODE);
466
- };
467
- //#endregion
468
419
  //#region src/platforms/bilibili/endpoints/avToBv.ts
469
420
  /**
470
421
  * AV 号转 BV 号(纯本地计算,不发请求)。
@@ -482,7 +433,7 @@ const avToBv = defineBilibiliEndpoint({
482
433
  description: "把稿件 AV 号换算成 BV 号,本地计算不发请求,返回 `{ bvid }`。"
483
434
  },
484
435
  params: zod.default.object({ avid: zod.default.coerce.number().int({ error: "AVID必须是整数" }).positive({ error: "AVID必须是正数" }).describe("稿件 AV 号,纯数字不带 `av` 前缀") }),
485
- compute: (p) => ({ bvid: av2bv(p.avid) }),
436
+ compute: (p) => ({ bvid: require_bv2av.av2bv(p.avid) }),
486
437
  response: type()
487
438
  });
488
439
  //#endregion
@@ -566,7 +517,7 @@ const bvToAv = defineBilibiliEndpoint({
566
517
  description: "把稿件 BV 号换算成 AV 号,本地计算不发请求,返回 `{ aid }`。"
567
518
  },
568
519
  params: zod.default.object({ bvid: zod.default.string().regex(/^BV[1-9A-HJ-NP-Za-km-z]{10}$/, { error: "BV号格式不正确" }).describe("稿件 BV 号,如 `BV1xx411c7mD`") }),
569
- compute: (p) => ({ aid: bv2av(p.bvid) }),
520
+ compute: (p) => ({ aid: require_bv2av.bv2av(p.bvid) }),
570
521
  response: type()
571
522
  });
572
523
  //#endregion
@@ -2214,6 +2165,7 @@ const DEFAULT_ERROR_MESSAGES = {
2214
2165
  RATE_LIMITED: "请求过于频繁,请稍后再试",
2215
2166
  RISK_CONTROL: "触发平台风控",
2216
2167
  CAPTCHA_REQUIRED: "需要完成验证码",
2168
+ SIGNATURE_REFUSED: "平台拒绝了本次请求的签名(amagi 侧的签名问题,换 cookie 无用)",
2217
2169
  NOT_FOUND: "资源不存在",
2218
2170
  DELETED: "资源已删除或已下架",
2219
2171
  PRIVATE: "资源为私密状态",
@@ -2455,6 +2407,61 @@ const DOUYIN_ANDROID_UA = "Mozilla/5.0 (Linux; Android 13; SM-S908E) AppleWebKit
2455
2407
  */
2456
2408
  const isDouyinArgusBody = (raw) => typeof raw === "string" && /ArgusSecurityPlugin|Blocked by/i.test(raw);
2457
2409
  /**
2410
+ * Argus 拦截文本里点名「签名不对」的片段。
2411
+ *
2412
+ * 命中这些的 403 是 **amagi 自己的问题,不是这个账号的问题** —— 平台在说
2413
+ * 「你这个签名我不认」,而不是「你这个号被风控了」。两者处置方式完全相反:
2414
+ * 前者换 cookie 一万次也没用(得修签名),后者换个号就好了。
2415
+ *
2416
+ * 片段清单来自线上实测,全部是 `Blocked by ArgusSecurityPlugin <reason>` 形态。
2417
+ * 403 若一律判风控,「签名器少算了一个参数」就会表现成账号被风控,排查方向
2418
+ * 整个跑偏。
2419
+ *
2420
+ * `uifid not found` 这一条在本仓库有具体成因:`/aweme/v1/web/music/detail/` 这类
2421
+ * 受 secsdk 保护的端点要求访客 id,而扫码登录拿回的 cookie 里**没有** `UIFID`
2422
+ * (它由 secsdk 在真实浏览器里铸造,passport 链路不经过那一步)。见
2423
+ * `sign/secsdkWebSign.ts` 的 `applySecsdkWebSign`。
2424
+ */
2425
+ const SIGNATURE_REFUSAL_MARKERS = [
2426
+ "uifid not found",
2427
+ "signature not found",
2428
+ "sign invalid",
2429
+ "sign expired"
2430
+ ];
2431
+ /**
2432
+ * 从 Argus 拦截文本里认出「签名被拒」。
2433
+ *
2434
+ * 只在**已经确定是 Argus 文本**之后调用(`isDouyinArgusBody` 为真),所以这里
2435
+ * 直接按子串找,不必担心把正常响应里恰好出现的字眼当成拦截。
2436
+ * @param raw - decode 之后的响应体
2437
+ * @returns 命中的片段;不是签名类拒绝时返回 `undefined`
2438
+ */
2439
+ const douyinSignatureRefusal = (raw) => {
2440
+ if (typeof raw !== "string") return void 0;
2441
+ const text = raw.toLowerCase();
2442
+ return SIGNATURE_REFUSAL_MARKERS.find((marker) => text.includes(marker));
2443
+ };
2444
+ /**
2445
+ * 抖音风控页(verify_center)的判据字段。
2446
+ *
2447
+ * 实物形状(`status_code: 10000` + 这个字段,字段本身是**字符串形式的 JSON**):
2448
+ *
2449
+ * ```jsonc
2450
+ * { "status_code": 10000, "status_msg": "",
2451
+ * "verify_center_decision_conf": "{\"verify_center_decision\":\"verify_hit\",\"decision_conf\":{\"subtype\":\"slide\"}}" }
2452
+ * ```
2453
+ *
2454
+ * **按字段名判,不扫全文**。扫 `captcha` 这类子串会把「简介里恰好提到验证码」
2455
+ * 的正常作品判成风控,而每一次误判都会冤枉一个健康账号;字段名不会出现在用户
2456
+ * 文案里。
2457
+ *
2458
+ * 这一条只负责**定性**到「需要人机验证」。它不产出验证页地址 —— 目前掌握的
2459
+ * 这份响应里确实没有可跳转的 URL,`verify_center_decision_conf` 里只有
2460
+ * `verify_hit` 与 `subtype: slide` 这类决策信息。所以 amagi 不装
2461
+ * `ChallengeExtractor`(`error.challenge` 仍为空),不编造一个地址出来。
2462
+ */
2463
+ const VERIFY_CENTER_KEY = "verify_center_decision_conf";
2464
+ /**
2458
2465
  * 抖音平台默认响应判定。
2459
2466
  *
2460
2467
  * 判定规则:
@@ -2480,10 +2487,22 @@ const douyinJudge = (raw, http) => {
2480
2487
  code: "EMPTY_RESPONSE",
2481
2488
  retryable: false
2482
2489
  };
2490
+ if (douyinSignatureRefusal(raw)) return {
2491
+ ok: false,
2492
+ kind: "risk",
2493
+ code: "SIGNATURE_REFUSED",
2494
+ retryable: false
2495
+ };
2483
2496
  const nonJson = verdictFromNonJsonBody(raw);
2484
2497
  if (nonJson) return nonJson;
2485
2498
  if (typeof raw !== "object" || raw === null) return verdictFromHttpStatus(http.status) ?? { ok: true };
2486
2499
  const body = raw;
2500
+ if (body[VERIFY_CENTER_KEY] !== void 0 && body[VERIFY_CENTER_KEY] !== null && body[VERIFY_CENTER_KEY] !== "") return {
2501
+ ok: false,
2502
+ kind: "risk",
2503
+ code: "CAPTCHA_REQUIRED",
2504
+ retryable: false
2505
+ };
2487
2506
  const filterDetail = body.filter_detail;
2488
2507
  if (filterDetail && typeof filterDetail.filter_reason === "string" && filterDetail.filter_reason.length > 0) return {
2489
2508
  ok: false,
@@ -2501,6 +2520,282 @@ const douyinJudge = (raw, http) => {
2501
2520
  return verdictFromHttpStatus(http.status) ?? { ok: true };
2502
2521
  };
2503
2522
  //#endregion
2523
+ //#region src/platforms/douyin/mint.ts
2524
+ /**
2525
+ * 抖音访客 id(`UIFID_TEMP`)的铸造与 cookie 审计。
2526
+ *
2527
+ * 受 secsdk 保护的端点(`/aweme/v1/web/music/detail/` 等)要求 cookie 里带访客 id,
2528
+ * 而它不由登录颁发——passport 扫码链路只发身份 cookie。缺了它,受保护端点稳定回
2529
+ * `403 Uifid Not Found`(见 `judge.ts` 的 `SIGNATURE_REFUSED` 归因)。
2530
+ *
2531
+ * 铸造是纯 HTTP 的,三步:
2532
+ *
2533
+ * 1. `GET www.douyin.com` → 服务端发 `__ac_nonce`,响应体是一份挑战页(内联
2534
+ * byted_acrawler 本体 + 引导脚本 `init({aid: 99999999, dfp: 0})` 与
2535
+ * `sign("", nonce)`);
2536
+ * 2. 在 Node `vm` 里执行本体,离线算出 `__ac_signature`;
2537
+ * 3. 带 `__ac_nonce + __ac_signature` 再 GET 一次 → 服务端一次性 Set-Cookie:
2538
+ * `ttwid` + `web_sign_token` + `UIFID_TEMP`。
2539
+ *
2540
+ * acrawler 本体不进仓库——每次铸造从挑战页现取现执行,抖音更新算法时自动跟随。
2541
+ * 同一客户端上下文(IP + UA)重复铸造返回同一个 `UIFID_TEMP`(有效期约 500 天),
2542
+ * 进程内缓存一次即可。
2543
+ *
2544
+ * 接线:`PLATFORM_RUNTIME.douyin.ensureCookie` 在每次请求 prepare 之后审计
2545
+ * (prepare 可能整串换 cookie,审计要对最终生效的那份负责);`session/qrcode.ts`
2546
+ * 在登录完成时审计,让扫码登录直接产出带访客 id 的凭证。
2547
+ *
2548
+ * 铸造尽力而为:网络失败、挑战页形状变化、签名被拒——一律返回原 cookie,归因
2549
+ * 交给 `SIGNATURE_REFUSED`。不抛错:自愈失败不该把调用变成 `internal`。
2550
+ *
2551
+ * @module platform/douyin/mint
2552
+ */
2553
+ /** 无调用方 UA 时铸造请求的兜底 UA。与 amagi 默认基线同代 */
2554
+ const DEFAULT_MINT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
2555
+ /** 挑战页引导脚本里的 aid。实测固定为 99999999,从引导块里解析,解析不到用它兜底 */
2556
+ const AID_FALLBACK = 99999999;
2557
+ /** acrawler 本体的最小长度。挑战页里最大的内联块(实测 ~71KB),低于此视为形状变化 */
2558
+ const ACGRAWLER_MIN_LENGTH = 1e4;
2559
+ /**
2560
+ * 从挑战页 HTML 里解析 acrawler 本体与引导 aid。
2561
+ *
2562
+ * 规则刻意保守:取**最大的无 `src` 内联 script 块**当本体(实测 ~71KB,远大于页面
2563
+ * 上任何其它内联块),引导块按「提到 `byted_acrawler` 且含 `sign(`」认——两者都不
2564
+ * 合格就返回 `undefined`,宁可放弃铸造也不把来路不明的脚本执行一遍。
2565
+ * @param html - 挑战页 HTML(首次请求的响应体)
2566
+ * @returns 解析结果;形状不对时 `undefined`
2567
+ */
2568
+ const extractChallengeParts = (html) => {
2569
+ const blocks = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
2570
+ if (blocks.length === 0) return void 0;
2571
+ const acrawlerSource = blocks.reduce((a, b) => b.length > a.length ? b : a);
2572
+ if (acrawlerSource.length < ACGRAWLER_MIN_LENGTH) return void 0;
2573
+ const bootstrap = blocks.find((b) => b !== acrawlerSource && b.includes("byted_acrawler") && b.includes("sign("));
2574
+ if (!bootstrap) return void 0;
2575
+ const aidMatch = bootstrap.match(/aid:(\d+)/);
2576
+ return {
2577
+ acrawlerSource,
2578
+ aid: aidMatch ? Number(aidMatch[1]) : AID_FALLBACK
2579
+ };
2580
+ };
2581
+ /**
2582
+ * 执行 acrawler 本体,返回对任意 nonce 的离线签名函数。
2583
+ *
2584
+ * shim 只需要 SDK 引用到的环境面:`document.cookie`(nonce 从这里读回,给空串让
2585
+ * 签名只依赖入参)、location / navigator / screen / history 与基础内置对象。环境
2586
+ * 数据不参与服务端校验(实测假数据可通过),所以 shim 不追求逼真。
2587
+ * @param source - acrawler 本体源码
2588
+ * @param aid - 引导 aid
2589
+ * @param userAgent - 参与签名的 UA(与后续 API 请求保持一致)
2590
+ * @returns `sign('', nonce)` 的等价函数
2591
+ */
2592
+ const createAcrawlerSigner = (source, aid, userAgent) => {
2593
+ const doc = {};
2594
+ Object.defineProperty(doc, "cookie", {
2595
+ get: () => "",
2596
+ set: () => {}
2597
+ });
2598
+ const windowShim = {
2599
+ document: doc,
2600
+ location: {
2601
+ href: "https://www.douyin.com/",
2602
+ protocol: "https:",
2603
+ host: "www.douyin.com",
2604
+ hostname: "www.douyin.com",
2605
+ pathname: "/",
2606
+ search: "",
2607
+ hash: "",
2608
+ origin: "https://www.douyin.com",
2609
+ reload: () => {}
2610
+ },
2611
+ navigator: {
2612
+ userAgent,
2613
+ language: "zh-CN",
2614
+ platform: "Win32"
2615
+ },
2616
+ screen: {
2617
+ width: 2560,
2618
+ height: 1440
2619
+ },
2620
+ history: { length: 2 },
2621
+ setTimeout: () => 0,
2622
+ setInterval: () => 0,
2623
+ clearTimeout: () => {},
2624
+ clearInterval: () => {},
2625
+ console: {
2626
+ log: () => {},
2627
+ warn: () => {},
2628
+ error: () => {}
2629
+ },
2630
+ Object,
2631
+ Array,
2632
+ Symbol,
2633
+ Error,
2634
+ TypeError,
2635
+ JSON,
2636
+ Math,
2637
+ Date,
2638
+ Promise,
2639
+ Reflect,
2640
+ Map,
2641
+ Set,
2642
+ Number,
2643
+ String,
2644
+ Boolean,
2645
+ RegExp,
2646
+ Function,
2647
+ parseInt,
2648
+ parseFloat,
2649
+ isNaN,
2650
+ encodeURIComponent,
2651
+ decodeURIComponent,
2652
+ TextEncoder,
2653
+ URL,
2654
+ URLSearchParams,
2655
+ performance
2656
+ };
2657
+ windowShim.window = windowShim;
2658
+ windowShim.self = windowShim;
2659
+ windowShim.top = windowShim;
2660
+ windowShim.parent = windowShim;
2661
+ windowShim.globalThis = windowShim;
2662
+ const context = node_vm.default.createContext(windowShim);
2663
+ node_vm.default.runInContext(source, context, { timeout: 2e4 });
2664
+ const ac = windowShim.byted_acrawler;
2665
+ if (!ac?.sign) throw new Error("acrawler 本体未注册 byted_acrawler.sign");
2666
+ ac.init({
2667
+ aid,
2668
+ dfp: 0
2669
+ });
2670
+ return (nonce) => ac.sign("", nonce);
2671
+ };
2672
+ /**
2673
+ * 测试环境守卫:vitest 进程内默认出口**不做真实出网请求**(审计在测试里保持透明,
2674
+ * 断言 Cookie 透传的用例不该被一次真网铸造改写)。通过 {@link MintDeps.getHome}
2675
+ * 注入出口的用例不受影响——守卫只管默认出口。
2676
+ */
2677
+ const testEnvGuard = async () => {
2678
+ throw new Error("douyin visitor mint is disabled under vitest (inject MintDeps.getHome to test it)");
2679
+ };
2680
+ /**
2681
+ * 运营开关:`AMAGI_DISABLE_DOUYIN_MINT=1` 关闭自动铸造。
2682
+ *
2683
+ * 铸造是 amagi **自主发起**的出网请求(用户没有显式调用任何端点),出口受限、
2684
+ * 安全审计、或「cookie 由我自己全权管理」的环境需要一个明确的选择权。关闭后
2685
+ * cookie 保持原样,受保护端点按 `SIGNATURE_REFUSED` 的既有归因失败。注入
2686
+ * {@link MintDeps.getHome} 的用例不受影响——开关只管默认出口。
2687
+ */
2688
+ const mintDisabledByEnv = () => process.env.AMAGI_DISABLE_DOUYIN_MINT === "1";
2689
+ const defaultGetHome = async (cookie, userAgent) => {
2690
+ if (process.env.VITEST) return testEnvGuard();
2691
+ if (mintDisabledByEnv()) return testEnvGuard();
2692
+ const response = await axios.default.get("https://www.douyin.com/", {
2693
+ headers: {
2694
+ "User-Agent": userAgent,
2695
+ Accept: "text/html",
2696
+ ...cookie ? { Cookie: cookie } : {}
2697
+ },
2698
+ timeout: 15e3,
2699
+ maxRedirects: 5,
2700
+ responseType: "text"
2701
+ });
2702
+ const setCookies = response.headers["set-cookie"] ?? [];
2703
+ return {
2704
+ setCookies: Array.isArray(setCookies) ? setCookies : [setCookies],
2705
+ body: String(response.data ?? "")
2706
+ };
2707
+ };
2708
+ /** 从 Set-Cookie 行数组里取某个 cookie 的值 */
2709
+ const setCookieValue = (cookies, name) => {
2710
+ const line = cookies.find((c) => c.startsWith(name + "="));
2711
+ return line ? line.split(";")[0].split("=").slice(1).join("=") : void 0;
2712
+ };
2713
+ /**
2714
+ * 铸造一次访客 id(两次 GET + 离线签名)。不做缓存、不去重——那是
2715
+ * {@link ensureDouyinVisitorCookie} 的职责。
2716
+ * @param userAgent - 铸造请求的 UA(与后续 API 请求保持一致)
2717
+ * @param deps - 可注入的 HTTP 出口(测试用)
2718
+ * @returns 铸造结果;挑战页形状变化 / 签名被拒 / 网络失败时 `undefined`
2719
+ */
2720
+ const mintDouyinVisitorCookie = async (userAgent, deps) => {
2721
+ const ua = userAgent || DEFAULT_MINT_UA;
2722
+ const getHome = deps?.getHome ?? defaultGetHome;
2723
+ try {
2724
+ const first = await getHome(void 0, ua);
2725
+ const nonce = setCookieValue(first.setCookies, "__ac_nonce");
2726
+ if (!nonce) return void 0;
2727
+ const parts = extractChallengeParts(first.body);
2728
+ if (!parts) return void 0;
2729
+ const second = await getHome(`__ac_nonce=${nonce}; __ac_signature=${createAcrawlerSigner(parts.acrawlerSource, parts.aid, ua)(nonce)}`, ua);
2730
+ const ttwid = setCookieValue(second.setCookies, "ttwid");
2731
+ const uifidTemp = setCookieValue(second.setCookies, "UIFID_TEMP");
2732
+ if (!ttwid || !uifidTemp) return void 0;
2733
+ return {
2734
+ ttwid,
2735
+ uifidTemp
2736
+ };
2737
+ } catch {
2738
+ return;
2739
+ }
2740
+ };
2741
+ /**
2742
+ * 把铸造结果合并进现有 cookie。**只补缺失的名字**:`ttwid` 已有则不动(那是调用方
2743
+ * 的设备标识,铸造顺带产的那份与它会话无关),访客 id 已有任何拼写则原样返回。
2744
+ * @param cookie - 现有 cookie 串,可为空
2745
+ * @param minted - 铸造结果
2746
+ * @returns 合并后的 cookie 串;无可合并项时原样返回
2747
+ */
2748
+ const mergeVisitorCookie = (cookie, minted) => {
2749
+ const base = cookie?.trim() ? cookie.trim().replace(/;\s*$/, "") : "";
2750
+ const names = new Set(base.split(";").map((p) => p.split("=")[0].trim()).filter(Boolean));
2751
+ if (names.has("UIFID_TEMP") || names.has("UIFID") || names.has("uifid") || names.has("uifid_temp")) return base;
2752
+ const parts = [];
2753
+ if (!names.has("ttwid") && minted.ttwid) parts.push(`ttwid=${minted.ttwid}`);
2754
+ if (minted.uifidTemp) parts.push(`UIFID_TEMP=${minted.uifidTemp}`);
2755
+ if (parts.length === 0) return base;
2756
+ return base ? `${base}; ${parts.join("; ")}` : parts.join("; ");
2757
+ };
2758
+ /** 审计缓存:原始 cookie(+UA)→ 合并后 cookie。进程级,`UIFID_TEMP` 有效期约 500 天 */
2759
+ const cache = /* @__PURE__ */ new Map();
2760
+ /** in-flight 去重:并发调用共享同一次铸造(合并是按各自 cookie 进行的,结果正确) */
2761
+ let inflight;
2762
+ const CACHE_MAX_ENTRIES = 64;
2763
+ /**
2764
+ * cookie 审计:缺访客 id 时铸造并合并,其余原样返回。进程内缓存,同一 cookie 只铸一次。
2765
+ *
2766
+ * **永不为调用增加失败**:任何异常都吞掉并返回原 cookie,请求按原路径走——自愈失败
2767
+ * 的归因交给受保护端点自己的 `SIGNATURE_REFUSED`。
2768
+ * @param cookie - 本次调用的 cookie,可为空(全新部署连 cookie 都没配的场景)
2769
+ * @param userAgent - 本次调用的 UA(铸造请求用它,保证与后续 API 请求同源)
2770
+ * @param deps - 可注入的 HTTP 出口(测试用)
2771
+ * @returns 升级后的 cookie;无需升级或铸造失败时原样返回
2772
+ */
2773
+ const ensureDouyinVisitorCookie = async (cookie, userAgent, deps) => {
2774
+ if (cookie && require_he.extractUifidFromCookie(cookie)) return cookie;
2775
+ const cacheKey = `${cookie ?? ""} ${userAgent ?? ""}`;
2776
+ const hit = cache.get(cacheKey);
2777
+ if (hit !== void 0) return hit;
2778
+ if (inflight !== void 0) return inflight;
2779
+ inflight = (async () => {
2780
+ try {
2781
+ const minted = await mintDouyinVisitorCookie(userAgent, deps);
2782
+ if (!minted) return cookie;
2783
+ const merged = mergeVisitorCookie(cookie, minted);
2784
+ cache.set(cacheKey, merged);
2785
+ if (cache.size > CACHE_MAX_ENTRIES) {
2786
+ const oldest = cache.keys().next().value;
2787
+ if (oldest !== void 0) cache.delete(oldest);
2788
+ }
2789
+ return merged;
2790
+ } catch {
2791
+ return cookie;
2792
+ } finally {
2793
+ inflight = void 0;
2794
+ }
2795
+ })();
2796
+ return inflight;
2797
+ };
2798
+ //#endregion
2504
2799
  //#region src/platforms/douyin/sign/signers.ts
2505
2800
  /**
2506
2801
  * 抖音签名器表:**只做「注册名 → `SignStep` 清单」的映射,不含算法**。
@@ -2692,7 +2987,7 @@ const parseKuaishouCaptcha = (raw) => {
2692
2987
  /** 默认超时,与抖音 / B站 / 小红书一致 */
2693
2988
  const KUAISHOU_DEFAULT_TIMEOUT = 1e4;
2694
2989
  /** 从请求配置里取 UA:大小写不敏感,取不到用集中维护的默认值 */
2695
- const userAgentOf$1 = (requestConfig) => {
2990
+ const userAgentOf$2 = (requestConfig) => {
2696
2991
  return new require_steps.AmagiHeaders(requestConfig?.headers).get("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";
2697
2992
  };
2698
2993
  /**
@@ -2704,7 +2999,7 @@ const userAgentOf$1 = (requestConfig) => {
2704
2999
  * @returns 默认 header 基线 + 请求配置
2705
3000
  */
2706
3001
  const createKuaishouConfig = (cookie, requestConfig) => {
2707
- const userAgent = userAgentOf$1(requestConfig);
3002
+ const userAgent = userAgentOf$2(requestConfig);
2708
3003
  const secChUa = generateSecChUa(userAgent);
2709
3004
  return {
2710
3005
  headers: new require_steps.AmagiHeaders().set("accept", "application/json, text/plain, */*").set("accept-encoding", "gzip, deflate, br, zstd").set("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6").set("content-type", "application/json").set("priority", "u=0, i").set("origin", "https://www.kuaishou.com").set("referer", "https://www.kuaishou.com/new-reco").set("sec-ch-ua", secChUa).set("sec-ch-ua-mobile", "?0").set("sec-ch-ua-platform", "\"Windows\"").set("sec-fetch-dest", "empty").set("sec-fetch-mode", "cors").set("sec-fetch-site", "same-site").set("user-agent", userAgent).merge(requestConfig?.headers),
@@ -2942,7 +3237,7 @@ const createKuaishouSigners = (signer = require_steps.sharedKuaishouSigner) => (
2942
3237
  /** 默认超时,与抖音 / B站 / 快手一致 */
2943
3238
  const XIAOHONGSHU_DEFAULT_TIMEOUT = 1e4;
2944
3239
  /** 从请求配置里取 UA:大小写不敏感,取不到用集中维护的默认值 */
2945
- const userAgentOf = (requestConfig) => {
3240
+ const userAgentOf$1 = (requestConfig) => {
2946
3241
  return new require_steps.AmagiHeaders(requestConfig?.headers).get("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";
2947
3242
  };
2948
3243
  /**
@@ -2952,7 +3247,7 @@ const userAgentOf = (requestConfig) => {
2952
3247
  * @returns 默认 header 基线 + 请求配置
2953
3248
  */
2954
3249
  const createXiaohongshuConfig = (cookie, requestConfig) => {
2955
- const userAgent = userAgentOf(requestConfig);
3250
+ const userAgent = userAgentOf$1(requestConfig);
2956
3251
  const secChUa = generateSecChUa(userAgent);
2957
3252
  return {
2958
3253
  headers: new require_steps.AmagiHeaders().set("accept", "application/json, text/plain, */*").set("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6").set("cache-control", "no-cache").set("content-type", "application/json;charset=UTF-8").set("pragma", "no-cache").set("priority", "u=1, i").set("referer", "https://www.xiaohongshu.com/").set("sec-ch-ua", secChUa).set("sec-ch-ua-mobile", "?0").set("sec-ch-ua-platform", "\"Windows\"").set("sec-fetch-dest", "empty").set("sec-fetch-mode", "cors").set("sec-fetch-site", "same-site").set("user-agent", userAgent).set("cookie", cookie?.trim() ?? "").merge(requestConfig?.headers),
@@ -3657,7 +3952,7 @@ var HttpClient = class {
3657
3952
  //#endregion
3658
3953
  //#region src/client/runtime.ts
3659
3954
  /**
3660
- * 平台运行期依赖表:签名器表 + 默认 judge + 可选的风控挑战提取器 / 响应旁观者。
3955
+ * 平台运行期依赖表:签名器表 + 默认 judge + 可选的风控挑战提取器 / 响应旁观者 / cookie 审计。
3661
3956
  *
3662
3957
  * 四个平台都必须 `signers` / `judge` 齐全 —— 少一项不会报编译错误,请求会
3663
3958
  * 不签名、或业务失败被判成成功。
@@ -3669,6 +3964,11 @@ var HttpClient = class {
3669
3964
  * `observe` 是**可选的第四项**:只有「服务端把状态写在响应头里」的平台才装
3670
3965
  * (目前只有抖音,用它回收 `webid`)。每次 send 之后调用一次,只读、不影响判定。
3671
3966
  *
3967
+ * `ensureCookie` 是**可选的第五项**:只有「cookie 里有平台自己才认得的必填身份」的
3968
+ * 平台才装(目前只有抖音,缺访客 id 时自动铸造并合并)。每次调用在 prepare 之后、
3969
+ * build/sign 之前执行一次,返回升级后的 cookie;失败/无需升级原样返回——审计
3970
+ * **永不为调用增加失败**。
3971
+ *
3672
3972
  * createClient 的 fetcher、四个 `createXxxRoutes` 与静态 fetcher 共用这张表,
3673
3973
  * 保证同一平台在任何入口下的签名 / 判定 / 风控行为一致。
3674
3974
  */
@@ -3685,7 +3985,8 @@ const PLATFORM_RUNTIME = {
3685
3985
  douyin: {
3686
3986
  signers: createDouyinSigners(),
3687
3987
  judge: douyinJudge,
3688
- observe: require_steps.observeDouyinWebid
3988
+ observe: require_steps.observeDouyinWebid,
3989
+ ensureCookie: ensureDouyinVisitorCookie
3689
3990
  },
3690
3991
  bilibili: {
3691
3992
  signers: (() => {
@@ -3750,6 +4051,7 @@ const makeClientCtx = (platform, cookie, requestConfig = {}, clientId = "shared"
3750
4051
  judge: runtime.judge,
3751
4052
  ...runtime.challenge === void 0 ? {} : { challenge: runtime.challenge },
3752
4053
  ...runtime.observe === void 0 ? {} : { observe: runtime.observe },
4054
+ ...runtime.ensureCookie === void 0 ? {} : { ensureCookie: runtime.ensureCookie },
3753
4055
  ...bus === void 0 ? {} : { bus },
3754
4056
  ...debug === void 0 ? {} : { debug },
3755
4057
  send: (spec, reason, perCall) => http.send(spec, reason, perCall),
@@ -4284,6 +4586,10 @@ const execute = async (def, input, options) => {
4284
4586
  ...baseCtx,
4285
4587
  send: boundSend
4286
4588
  };
4589
+ if (options.ensureCookie) {
4590
+ const upgraded = await options.ensureCookie(ctx.cookie, ctx.userAgent);
4591
+ if (upgraded !== void 0 && upgraded !== ctx.cookie) ctx.cookie = upgraded;
4592
+ }
4287
4593
  stage = "build";
4288
4594
  if (!def.build) throw new Error(`端点 ${def.name} 既没有 build 也没有 compute`);
4289
4595
  stage = "sign";
@@ -4607,6 +4913,7 @@ const callEndpoint = (def, ctx, options, requestConfig) => {
4607
4913
  judge: ctx.judge,
4608
4914
  challenge: ctx.challenge,
4609
4915
  observe: ctx.observe,
4916
+ ensureCookie: ctx.ensureCookie,
4610
4917
  bus: ctx.bus,
4611
4918
  trace: tracer,
4612
4919
  debug: ctx.debug,
@@ -4738,8 +5045,8 @@ const createBilibiliRoutes = (cookie, requestConfig) => {
4738
5045
  const bilibiliUtils = {
4739
5046
  sign: {
4740
5047
  wbi_sign,
4741
- av2bv,
4742
- bv2av
5048
+ av2bv: require_bv2av.av2bv,
5049
+ bv2av: require_bv2av.bv2av
4743
5050
  },
4744
5051
  danmaku: { parseDmSegMobileReply }
4745
5052
  };
@@ -6424,7 +6731,7 @@ const PASSPORT_PAGE_ID = 7571;
6424
6731
  * 不需要与任何真实设备对应。它会被逐字写进签名,所以**必须是个自洽的几何**——
6425
6732
  * 随请求变动的几何本身就是特征。
6426
6733
  */
6427
- const PASSPORT_BROWSER_INFO = require_tokens.buildBrowserInfo({
6734
+ const PASSPORT_BROWSER_INFO = require_he.buildBrowserInfo({
6428
6735
  innerWidth: 2048,
6429
6736
  innerHeight: 960,
6430
6737
  outerWidth: 2554,
@@ -6441,7 +6748,7 @@ const PASSPORT_BROWSER_INFO = require_tokens.buildBrowserInfo({
6441
6748
  * @param userAgent 与请求头一致的 UA
6442
6749
  * @returns a_bogus 参数值(未做 URL 编码)
6443
6750
  */
6444
- const aBogus = (query, userAgent) => new require_tokens.ABogus(userAgent, {
6751
+ const aBogus = (query, userAgent) => new require_he.ABogus(userAgent, {
6445
6752
  browserInfo: PASSPORT_BROWSER_INFO,
6446
6753
  pageId: PASSPORT_PAGE_ID
6447
6754
  }).getValue(query);
@@ -7083,13 +7390,13 @@ var DouyinPassportClient = class {
7083
7390
  });
7084
7391
  await this.send({
7085
7392
  method: "POST",
7086
- url: require_tokens.TTWID_REGISTER_URL,
7393
+ url: require_he.TTWID_REGISTER_URL,
7087
7394
  headers: {
7088
7395
  "Content-Type": "application/json",
7089
7396
  Origin: `https://${WEB_HOST}`,
7090
7397
  Referer: `https://${WEB_HOST}/`
7091
7398
  },
7092
- data: require_tokens.DOUYIN_TTWID.data
7399
+ data: require_he.DOUYIN_TTWID.data
7093
7400
  });
7094
7401
  emitLogDebug(`[douyin passport] 环境指纹就绪: ttwid=${this.cookies.has("ttwid")}, ac_nonce=${this.cookies.has("__ac_nonce")}`);
7095
7402
  }
@@ -7455,6 +7762,11 @@ const buildVerifyBody = (verify, verifyWay, tail) => ({
7455
7762
  });
7456
7763
  //#endregion
7457
7764
  //#region src/platforms/douyin/session/qrcode.ts
7765
+ /** 从请求配置的 headers 里尽量取出 UA(大小写不敏感;取不到返回 `undefined`,铸造用兜底 UA) */
7766
+ const userAgentOf = (headers) => {
7767
+ if (!headers || typeof headers !== "object") return void 0;
7768
+ for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === "user-agent" && typeof value === "string") return value;
7769
+ };
7458
7770
  /**
7459
7771
  * 抖音扫码登录策略。
7460
7772
  *
@@ -7580,7 +7892,7 @@ const douyinQrcodeStrategy = {
7580
7892
  };
7581
7893
  case "confirmed": {
7582
7894
  if (result.redirectUrl) await client.followSsoRedirect(result.redirectUrl);
7583
- const cookie = client.cookies.toString();
7895
+ const cookie = await ensureDouyinVisitorCookie(client.cookies.toString(), userAgentOf(ctx.requestConfig?.headers)) ?? client.cookies.toString();
7584
7896
  return {
7585
7897
  ok: true,
7586
7898
  state: {
@@ -7773,7 +8085,7 @@ var douyinSign = class {
7773
8085
  * @returns 对此地址签名后的URL查询参数
7774
8086
  */
7775
8087
  static AB(url, userAgent) {
7776
- return require_tokens.a_bogus_default(url, userAgent ?? defaultUserAgent);
8088
+ return require_he.a_bogus_default(url, userAgent ?? defaultUserAgent);
7777
8089
  }
7778
8090
  /**
7779
8091
  * X-Bogus 签名算法
@@ -7781,7 +8093,7 @@ var douyinSign = class {
7781
8093
  * @returns 对此地址签名后的URL查询参数
7782
8094
  */
7783
8095
  static XB(url, userAgent) {
7784
- return new require_steps.XBogus().getXBogus(url, userAgent ?? defaultUserAgent).xbogus;
8096
+ return new require_he.XBogus().getXBogus(url, userAgent ?? defaultUserAgent).xbogus;
7785
8097
  }
7786
8098
  /**
7787
8099
  * `x-secsdk-web-signature` 签名算法
@@ -7795,11 +8107,11 @@ var douyinSign = class {
7795
8107
  * @returns 需要加签时返回带签名的完整 URL,否则原样返回
7796
8108
  */
7797
8109
  static SecSdk(url, options = {}) {
7798
- return require_steps.applySecsdkWebSign(url, options);
8110
+ return require_he.applySecsdkWebSign(url, options);
7799
8111
  }
7800
8112
  /** 生成一个唯一的验证字符串 */
7801
8113
  static VerifyFpManager() {
7802
- return require_tokens.genVerifyFp();
8114
+ return require_he.genVerifyFp();
7803
8115
  }
7804
8116
  };
7805
8117
  //#endregion
@@ -9555,7 +9867,7 @@ var kuaishouSign = class {
9555
9867
  const secs = deriveKuaishouSecsState(count);
9556
9868
  const scriptCount = globalThis.document?.scripts?.length ?? 0;
9557
9869
  const catVersion = runtimeState.catVersion;
9558
- const signResult = require_steps.deriveKuaishouPureSignature({
9870
+ const signResult = require_he.deriveKuaishouPureSignature({
9559
9871
  count,
9560
9872
  randomValue: Math.random(),
9561
9873
  scriptCount,
@@ -9871,7 +10183,7 @@ const homeFeed = defineXiaohongshuEndpoint({
9871
10183
  search_key: zod.default.string().optional().describe("搜索关键词,默认空字符串")
9872
10184
  }),
9873
10185
  prepare: async (ctx) => {
9874
- if (require_steps.getCookieValue(ctx.cookie, "a1")) return {};
10186
+ if (require_he.getCookieValue(ctx.cookie, "a1")) return {};
9875
10187
  return { cookie: await require_steps.createXiaohongshuGuestCookie(ctx.send, ctx.requestConfig) };
9876
10188
  },
9877
10189
  build: (p) => {
@@ -10301,7 +10613,7 @@ var xiaohongshuSign = class {
10301
10613
  * @returns a1 cookie值
10302
10614
  */
10303
10615
  static extractA1FromCookie(cookieString) {
10304
- return require_steps.getCookieValue(cookieString, "a1") ?? "";
10616
+ return require_he.getCookieValue(cookieString, "a1") ?? "";
10305
10617
  }
10306
10618
  /**
10307
10619
  * 生成搜索ID
@@ -51629,7 +51941,7 @@ const buildOpenApiSpec = (options = {}) => {
51629
51941
  openapi: "3.1.0",
51630
51942
  info: {
51631
51943
  title: "amagi HTTP API",
51632
- version: options.version ?? "7.0.0-beta.7",
51944
+ version: options.version ?? "7.0.0-beta.8",
51633
51945
  description: [
51634
51946
  "由端点注册表派生的规范 —— 手写的那份已经漂移,这份不会(`gen-openapi --check` 进 CI)。",
51635
51947
  "",
@@ -54399,7 +54711,7 @@ let DynamicType = /* @__PURE__ */ function(DynamicType) {
54399
54711
  * 构建后使用 __VERSION__,开发环境从 package.json 读取
54400
54712
  */
54401
54713
  const getVersion = () => {
54402
- return "7.0.0-beta.7";
54714
+ return "7.0.0-beta.8";
54403
54715
  };
54404
54716
  const VERSION = getVersion();
54405
54717
  /**
@@ -54558,12 +54870,6 @@ Object.defineProperty(exports, "assertValidXiaohongshuParams", {
54558
54870
  return assertValidXiaohongshuParams;
54559
54871
  }
54560
54872
  });
54561
- Object.defineProperty(exports, "av2bv", {
54562
- enumerable: true,
54563
- get: function() {
54564
- return av2bv;
54565
- }
54566
- });
54567
54873
  Object.defineProperty(exports, "bilibiliApiUrls", {
54568
54874
  enumerable: true,
54569
54875
  get: function() {
@@ -54582,12 +54888,6 @@ Object.defineProperty(exports, "bilibiliUtils", {
54582
54888
  return bilibiliUtils;
54583
54889
  }
54584
54890
  });
54585
- Object.defineProperty(exports, "bv2av", {
54586
- enumerable: true,
54587
- get: function() {
54588
- return bv2av;
54589
- }
54590
- });
54591
54891
  Object.defineProperty(exports, "checkPassportQrcode", {
54592
54892
  enumerable: true,
54593
54893
  get: function() {