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

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,20 +1,92 @@
1
1
  const require_rolldown_runtime = require("./rolldown-runtime-C0BPl7ul.cjs");
2
+ const require_steps = require("./steps-BO8NYQHx.cjs");
2
3
  const require_tokens = require("./tokens-DHKAuPVW.cjs");
3
- let node_url = require("node:url");
4
- node_url = require_rolldown_runtime.__toESM(node_url, 1);
4
+ require("node:url");
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);
9
11
  let protobufjs = require("protobufjs");
10
12
  protobufjs = require_rolldown_runtime.__toESM(protobufjs, 1);
11
13
  let axios = require("axios");
12
14
  axios = require_rolldown_runtime.__toESM(axios, 1);
13
15
  let node_events = require("node:events");
14
- let node_crypto = require("node:crypto");
15
- node_crypto = require_rolldown_runtime.__toESM(node_crypto, 1);
16
16
  let _ikenxuan_xhshow_ts = require("@ikenxuan/xhshow-ts");
17
17
  //#region src/platforms/bilibili/api.ts
18
+ /**
19
+ * B站评论区类型代码(`comments` / `commentReplies` 的 `type` 参数)。
20
+ *
21
+ * 完整对照表(部分 oid 含义官方未明确,转写自 bilibili-API-collect):
22
+ *
23
+ * | 代码 | 评论区类型 | oid 的意义 |
24
+ * | --- | --- | --- |
25
+ * | 1 | 视频稿件 | 稿件 avid |
26
+ * | 2 | 话题 | 话题 id |
27
+ * | 4 | 活动 | 活动 id |
28
+ * | 5 | 小视频 | 小视频 id |
29
+ * | 6 | 小黑屋封禁信息 | 封禁公示 id |
30
+ * | 7 | 公告信息 | 公告 id |
31
+ * | 8 | 直播活动 | 直播间 id |
32
+ * | 9 | 活动稿件 | (?) |
33
+ * | 10 | 直播公告 | (?) |
34
+ * | 11 | 相簿(图片动态) | 相簿 id |
35
+ * | 12 | 专栏 | 专栏 cvid |
36
+ * | 13 | 票务 | (?) |
37
+ * | 14 | 音频 | 音频 auid |
38
+ * | 15 | 风纪委员会 | 众裁项目 id |
39
+ * | 16 | 点评 | (?) |
40
+ * | 17 | 动态(纯文字动态&分享) | 动态 id |
41
+ * | 18 | 播单 | (?) |
42
+ * | 19 | 音乐播单 | (?) |
43
+ * | 20 | 漫画 | (?) |
44
+ * | 21 | 漫画 | (?) |
45
+ * | 22 | 漫画 | 漫画 mcid |
46
+ * | 33 | 课程 | 课程 epid |
47
+ *
48
+ * @see https://github.com/SocialSisterYi/bilibili-API-collect/blob/master/docs/comment/readme.md#%E8%AF%84%E8%AE%BA%E5%8C%BA%E7%B1%BB%E5%9E%8B%E4%BB%A3%E7%A0%81
49
+ */
50
+ const commentTypeUnion = zod.default.union([
51
+ zod.default.literal(1),
52
+ zod.default.literal(2),
53
+ zod.default.literal(4),
54
+ zod.default.literal(5),
55
+ zod.default.literal(6),
56
+ zod.default.literal(7),
57
+ zod.default.literal(8),
58
+ zod.default.literal(9),
59
+ zod.default.literal(10),
60
+ zod.default.literal(11),
61
+ zod.default.literal(12),
62
+ zod.default.literal(13),
63
+ zod.default.literal(14),
64
+ zod.default.literal(15),
65
+ zod.default.literal(16),
66
+ zod.default.literal(17),
67
+ zod.default.literal(18),
68
+ zod.default.literal(19),
69
+ zod.default.literal(20),
70
+ zod.default.literal(21),
71
+ zod.default.literal(22),
72
+ zod.default.literal(33)
73
+ ], { error: "无效的评论区类型" });
74
+ /**
75
+ * `type` 参数的完整 schema:HTTP query 的字符串先 coerce 成数字,再 pipe 进合法代码
76
+ * 联合收窄 —— SDK 签名拿到的是 `CommentType` 而不是裸 `number`,非法代码报
77
+ * 「无效的评论区类型」。`zod.enum` 在 zod 4.6.5 不接受数字数组(values 恒空、恒报错),
78
+ * 所以用 literal 联合。
79
+ */
80
+ const commentTypeSchema = zod.default.coerce.number().pipe(commentTypeUnion);
81
+ /** `mode` 的合法值联合:0/1/2/3 之外(含 coerce 出的 NaN)统一报错 */
82
+ const commentModeUnion = zod.default.union([
83
+ zod.default.literal(0),
84
+ zod.default.literal(1),
85
+ zod.default.literal(2),
86
+ zod.default.literal(3)
87
+ ], { error: "排序方式只能是 0、1、2、3" });
88
+ /** `mode` 参数的完整 schema:coerce + 收窄到 `0 | 1 | 2 | 3` */
89
+ const commentModeSchema = zod.default.coerce.number().pipe(commentModeUnion);
18
90
  /** B站 API URL 构建类(所有方法只拼 URL,不发起请求) */
19
91
  var BilibiliAPI$1 = class {
20
92
  /** 获取登录基本信息 */
@@ -175,6 +247,34 @@ const bilibiliApiUrls$1 = new BilibiliAPI$1();
175
247
  * @returns 携带 `T` 的令牌,运行时是个空对象
176
248
  */
177
249
  const type = () => ({});
250
+ /** 反爬参数的执行阶段次序:端点作者不用手排,{@link stepsToSigner} 按这个固定次序排好再跑 */
251
+ const PHASE_ORDER = {
252
+ prepare: 0,
253
+ token: 1,
254
+ sign: 2,
255
+ finalize: 3
256
+ };
257
+ /**
258
+ * 把一串 {@link SignStep} 压成一个签名器:按 {@link SignPhase} 稳定排序后依次 `apply`。
259
+ * 同 phase 内保留数组出现顺序(`index` 兜底),所以 `[aBogus(), msToken()]` 与
260
+ * `[msToken(), aBogus()]` 结果一致 —— token 阶段恒先于 sign,端点作者写错顺序也不翻车。
261
+ *
262
+ * 两条路径共用它、因此共用同一套排序语义:runtime 解析 {@link SignDecl} 里的 step 清单时用它,
263
+ * 平台签名器表(`sign/signers.ts` 那层薄壳)也用它把注册名对应的 step 清单压成一个 {@link SignFn}。
264
+ * @param steps - 反爬参数清单
265
+ * @returns 依次应用全部步骤的签名函数
266
+ */
267
+ const stepsToSigner = (steps) => {
268
+ const ordered = steps.map((step, index) => ({
269
+ step,
270
+ index
271
+ })).sort((a, b) => PHASE_ORDER[a.step.phase] - PHASE_ORDER[b.step.phase] || a.index - b.index).map((entry) => entry.step);
272
+ return async (spec, ctx) => {
273
+ let current = spec;
274
+ for (const step of ordered) current = await step.apply(current, ctx);
275
+ return current;
276
+ };
277
+ };
178
278
  /**
179
279
  * 声明一个端点。运行时是恒等函数(原样返回 `def`),全部价值在类型推导:
180
280
  * `TParams` 从 `params` 推、`TData` 从 `response` / `compute` 推。
@@ -182,6 +282,36 @@ const type = () => ({});
182
282
  * @param def - 端点声明
183
283
  */
184
284
  const defineEndpoint = (def) => def;
285
+ /**
286
+ * 标记一个端点参数为「内部参数」。
287
+ *
288
+ * 内部参数照常参与 zod 校验、`paginate.nextParams` 的写入与 URL 构造,但三个消费点
289
+ * 都看不见它:
290
+ * - **调用方签名**(fetcher / 静态 fetcher 的 options,见 `PublicParamsOf`)——对象
291
+ * 字面量里传入会被 excess property check 拒掉;
292
+ * - **openapi 的 query parameters**(`parametersOf` 经 `internalParamKeysOf` 过滤);
293
+ * - **文档参数表**(生成器读 openapi 产物,随上一条自动跟随)。
294
+ *
295
+ * 标记与字段声明焊在同一个表达式里(`pagination_str: internalParam(...)`),不存在
296
+ * 「写了字段忘了登记到某张清单」的漂移面 —— 这也是它不做成 EndpointDef 字段的理由:
297
+ * def 上已经够多键了,而且分离的清单必然漂移。运行时经 `.meta()` 挂进 zod 全局
298
+ * registry(文档生成器已在用 meta 传 examples,同一机制)。
299
+ */
300
+ const internalParam = (schema) => schema.meta({ amagiInternal: true });
301
+ /**
302
+ * 运行时判断一个 params 字段是否内部参数(openapi 生成器的过滤依据)。
303
+ *
304
+ * 读 `.meta()` 而不是别的运行时痕迹:meta 是 zod 官方的元数据通道,克隆与管道都保留。
305
+ */
306
+ const isInternalParam = (schema) => {
307
+ return (schema?.meta?.())?.amagiInternal === true;
308
+ };
309
+ /** 端点 params 里全部内部参数的键名(运行时)。非 object schema 返回空集 */
310
+ const internalParamKeysOf = (params) => {
311
+ const shape = params.shape;
312
+ if (shape === void 0) return /* @__PURE__ */ new Set();
313
+ return new Set(Object.keys(shape).filter((key) => isInternalParam(shape[key])));
314
+ };
185
315
  //#endregion
186
316
  //#region src/platforms/bilibili/endpoints/define.ts
187
317
  /**
@@ -415,7 +545,7 @@ const bangumiStream = defineBilibiliEndpoint({
415
545
  ep_id: p.ep_id.replace("ep", "")
416
546
  })
417
547
  }),
418
- sign: "qtparam",
548
+ sign: [require_steps.qtparam()],
419
549
  retryOn: ["RISK_CONTROL"],
420
550
  response: type()
421
551
  });
@@ -486,45 +616,17 @@ const commentReplies$1 = defineBilibiliEndpoint({
486
616
  },
487
617
  params: zod.default.object({
488
618
  oid: zod.default.string().min(1, { error: "OID不能为空" }).describe("目标对象 ID,视频稿件填 avid"),
489
- type: zod.default.coerce.number().int().min(1).refine((val) => COMMENT_TYPES$1.includes(val), { error: "无效的评论区类型" }).describe("评论区类型,视频稿件填 1"),
619
+ type: commentTypeSchema.describe("评论区类型代码:1 视频稿件(oid=avid)、11 相簿/图片动态、12 专栏(cvid)、17 动态等;完整对照表见 bilibili-API-collect「评论区类型代码」"),
490
620
  root: zod.default.string().min(1, { error: "根评论ID不能为空" }).describe("根评论 ID,即要展开的一级评论"),
491
621
  number: zod.default.coerce.number().int().positive().default(20).optional().describe("该根评论下的回复条数,不翻页,默认 20")
492
622
  }),
493
623
  build: (p) => ({
494
624
  method: "GET",
495
- url: bilibiliApiUrls$1.getCommentReplies({
496
- ...p,
497
- type: p.type
498
- })
625
+ url: bilibiliApiUrls$1.getCommentReplies(p)
499
626
  }),
500
627
  retryOn: ["RISK_CONTROL"],
501
628
  response: type()
502
629
  });
503
- /** 评论区类型枚举 */
504
- const COMMENT_TYPES$1 = [
505
- 1,
506
- 2,
507
- 4,
508
- 5,
509
- 6,
510
- 7,
511
- 8,
512
- 9,
513
- 10,
514
- 11,
515
- 12,
516
- 13,
517
- 14,
518
- 15,
519
- 16,
520
- 17,
521
- 18,
522
- 19,
523
- 20,
524
- 21,
525
- 22,
526
- 33
527
- ];
528
630
  //#endregion
529
631
  //#region src/platforms/bilibili/endpoints/comments.ts
530
632
  /**
@@ -549,23 +651,19 @@ const comments$2 = defineBilibiliEndpoint({
549
651
  },
550
652
  params: zod.default.object({
551
653
  oid: zod.default.string().min(1, { error: "OID不能为空" }).describe("目标对象 ID,视频稿件填 avid"),
552
- type: zod.default.coerce.number().int().min(1).refine((val) => COMMENT_TYPES.includes(val), { error: "无效的评论区类型" }).describe("评论区类型,视频稿件填 1"),
654
+ type: commentTypeSchema.describe("评论区类型代码:1 视频稿件(oid=avid)、2 话题、11 相簿/图片动态、12 专栏(cvid)、14 音频(auid)、17 动态、33 课程(epid)等;完整对照表见 bilibili-API-collect「评论区类型代码」"),
553
655
  number: zod.default.coerce.number().int().positive().default(20).optional().describe("目标条数,自动翻页合并后去重,默认 20"),
554
- mode: zod.default.coerce.number().int().min(0).max(3).optional().describe("排序方式,默认 3"),
555
- pagination_str: zod.default.string().optional().describe("翻页游标,由端点接管,不用传"),
656
+ mode: commentModeSchema.optional().describe("排序方式:0 和 3 仅热度,1 按热度+按时间,2 仅时间;默认 3"),
657
+ pagination_str: internalParam(zod.default.string().optional().describe("翻页游标,由端点接管,不用传")),
556
658
  plat: zod.default.coerce.number().int().optional().describe("平台类型,默认 1"),
557
659
  seek_rpid: zod.default.string().optional().describe("定位到某条评论,默认空"),
558
660
  web_location: zod.default.string().optional().describe("web 位置参数,默认 1315875")
559
661
  }),
560
662
  build: (p) => ({
561
663
  method: "GET",
562
- url: bilibiliApiUrls$1.getComments({
563
- ...p,
564
- type: p.type,
565
- mode: p.mode
566
- })
664
+ url: bilibiliApiUrls$1.getComments(p)
567
665
  }),
568
- sign: "wbi",
666
+ sign: [require_steps.wbi()],
569
667
  paginate: {
570
668
  maxPageSize: 100,
571
669
  items: (page) => page.data?.replies ?? [],
@@ -594,31 +692,6 @@ const comments$2 = defineBilibiliEndpoint({
594
692
  retryOn: ["RISK_CONTROL"],
595
693
  response: type()
596
694
  });
597
- /** 评论区类型枚举 */
598
- const COMMENT_TYPES = [
599
- 1,
600
- 2,
601
- 4,
602
- 5,
603
- 6,
604
- 7,
605
- 8,
606
- 9,
607
- 10,
608
- 11,
609
- 12,
610
- 13,
611
- 14,
612
- 15,
613
- 16,
614
- 17,
615
- 18,
616
- 19,
617
- 20,
618
- 21,
619
- 22,
620
- 33
621
- ];
622
695
  //#endregion
623
696
  //#region src/platforms/bilibili/endpoints/dynamicDetail.ts
624
697
  /**
@@ -820,112 +893,6 @@ const userCard = defineBilibiliEndpoint({
820
893
  response: type()
821
894
  });
822
895
  //#endregion
823
- //#region src/contracts/request.ts
824
- /**
825
- * 大小写不敏感的 header 容器。
826
- *
827
- * HTTP header 名本身大小写不敏感,用普通对象装就会出现「写了 `Cookie` 却读
828
- * `cookie` 读不到」这类静默失配。本类把「大小写」这个变量彻底消掉。
829
- *
830
- * 语义:
831
- * - 查找、判断、删除全部大小写不敏感。
832
- * - 写入是「后写覆盖」:值覆盖,**显示用的大小写也跟着最后一次写入**,
833
- * 所以 `set('User-Agent', a)` 之后 `set('user-agent', b)` 只会留下一个
834
- * `user-agent: b`,不可能出现两条同名 header。
835
- * - `undefined` / `null` 的值视为「不写这个 header」,方便直接摊入可选字段。
836
- */
837
- var AmagiHeaders = class AmagiHeaders {
838
- /** key 是小写化的 header 名,value 保留最后一次写入的原始大小写与值 */
839
- entries = /* @__PURE__ */ new Map();
840
- /**
841
- * @param init - 初始 header,可以是另一个 `AmagiHeaders` 或普通对象
842
- */
843
- constructor(init) {
844
- this.merge(init);
845
- }
846
- /**
847
- * 读取 header 值,大小写不敏感
848
- * @param name - header 名,任意大小写
849
- * @returns 值,不存在时返回 `undefined`
850
- */
851
- get(name) {
852
- return this.entries.get(name.toLowerCase())?.value;
853
- }
854
- /**
855
- * 判断 header 是否存在,大小写不敏感
856
- * @param name - header 名,任意大小写
857
- * @returns 存在则返回 `true`
858
- */
859
- has(name) {
860
- return this.entries.has(name.toLowerCase());
861
- }
862
- /**
863
- * 写入 header。同名(忽略大小写)时覆盖值与显示大小写
864
- * @param name - header 名
865
- * @param value - 值。`undefined` / `null` 表示不写入
866
- * @returns 自身,便于链式调用
867
- */
868
- set(name, value) {
869
- if (value === void 0 || value === null) return this;
870
- this.entries.set(name.toLowerCase(), {
871
- name,
872
- value: String(value)
873
- });
874
- return this;
875
- }
876
- /**
877
- * 删除 header,大小写不敏感
878
- * @param name - header 名
879
- * @returns 原本存在则返回 `true`
880
- */
881
- delete(name) {
882
- return this.entries.delete(name.toLowerCase());
883
- }
884
- /**
885
- * 合并另一组 header,后来者覆盖
886
- * @param input - 待合并的 header
887
- * @returns 自身,便于链式调用
888
- */
889
- merge(input) {
890
- if (!input) return this;
891
- const pairs = input instanceof AmagiHeaders ? input.toEntries() : Object.entries(input);
892
- for (const [name, value] of pairs) this.set(name, value);
893
- return this;
894
- }
895
- /** header 条数 */
896
- get size() {
897
- return this.entries.size;
898
- }
899
- /**
900
- * 全部 header 名,保留最后一次写入的大小写
901
- * @returns header 名数组
902
- */
903
- keys() {
904
- return [...this.entries.values()].map((e) => e.name);
905
- }
906
- /**
907
- * 全部 `[名, 值]` 对,名保留最后一次写入的大小写
908
- * @returns 键值对数组
909
- */
910
- toEntries() {
911
- return [...this.entries.values()].map((e) => [e.name, e.value]);
912
- }
913
- /**
914
- * 转成普通对象,用于交给 axios
915
- * @returns 普通 header 对象,键保留最后一次写入的大小写
916
- */
917
- toJSON() {
918
- return Object.fromEntries(this.toEntries());
919
- }
920
- /**
921
- * 深拷贝一份,避免下游改写上游的 header
922
- * @returns 新的 `AmagiHeaders` 实例
923
- */
924
- clone() {
925
- return new AmagiHeaders(this);
926
- }
927
- };
928
- //#endregion
929
896
  //#region src/platforms/bilibili/endpoints/userDynamicList.ts
930
897
  /**
931
898
  * 用户空间动态(wbi 签名 + Origin/Referer 注入)。
@@ -944,7 +911,7 @@ const userDynamicList = defineBilibiliEndpoint({
944
911
  },
945
912
  params: zod.default.object({ host_mid: zod.default.coerce.number().int().min(1, { error: "UP主UID必须大于等于1" }).describe("UP 主 UID") }),
946
913
  build: (p, ctx) => {
947
- const callerHeaders = new AmagiHeaders(ctx.requestConfig?.headers);
914
+ const callerHeaders = new require_steps.AmagiHeaders(ctx.requestConfig?.headers);
948
915
  const headers = { Origin: "https://space.bilibili.com" };
949
916
  if (!callerHeaders.has("referer")) headers.Referer = `https://space.bilibili.com/${p.host_mid}/dynamic`;
950
917
  return {
@@ -953,7 +920,7 @@ const userDynamicList = defineBilibiliEndpoint({
953
920
  headers
954
921
  };
955
922
  },
956
- sign: "wbi",
923
+ sign: [require_steps.wbi()],
957
924
  retryOn: ["RISK_CONTROL"],
958
925
  response: type()
959
926
  });
@@ -998,7 +965,7 @@ const userSpaceInfo = defineBilibiliEndpoint({
998
965
  method: "GET",
999
966
  url: bilibiliApiUrls$1.getUserSpaceInfo(p)
1000
967
  }),
1001
- sign: "wbi",
968
+ sign: [require_steps.wbi()],
1002
969
  retryOn: ["RISK_CONTROL"],
1003
970
  response: type()
1004
971
  });
@@ -1207,7 +1174,7 @@ const bilibiliRegistry = {
1207
1174
  method: "GET",
1208
1175
  url: bilibiliApiUrls$1.getVideoStream(p)
1209
1176
  }),
1210
- sign: "qtparam",
1177
+ sign: [require_steps.qtparam()],
1211
1178
  retryOn: ["RISK_CONTROL"],
1212
1179
  response: type()
1213
1180
  }),
@@ -1466,7 +1433,7 @@ var TypedEventEmitter = class extends node_events.EventEmitter {
1466
1433
  * @description 单例模式,所有模块共享同一个事件总线
1467
1434
  * @example
1468
1435
  * ```typescript
1469
- * import { amagiEvents } from '../model/events'
1436
+ * import { amagiEvents } from '@ikenxuan/amagi'
1470
1437
  *
1471
1438
  * // 监听 API 成功事件
1472
1439
  * amagiEvents.on('api:success', (data) => {
@@ -1988,7 +1955,7 @@ const bilibiliApiUrls = new BilibiliAPI();
1988
1955
  /**
1989
1956
  * 混合密钥编码表,用于对 imgKey 和 subKey 进行字符顺序打乱编码
1990
1957
  */
1991
- const mixinKeyEncTab$1 = [
1958
+ const mixinKeyEncTab = [
1992
1959
  46,
1993
1960
  47,
1994
1961
  18,
@@ -2059,7 +2026,7 @@ const mixinKeyEncTab$1 = [
2059
2026
  * @param orig - 原始字符串数组,通常是 img_key + sub_key 的字符数组
2060
2027
  * @returns 返回经过编码表打乱后的32位字符串
2061
2028
  */
2062
- const getMixinKey$1 = (orig) => mixinKeyEncTab$1.map((n) => orig[n]).join("").slice(0, 32);
2029
+ const getMixinKey = (orig) => mixinKeyEncTab.map((n) => orig[n]).join("").slice(0, 32);
2063
2030
  /**
2064
2031
  * 为请求参数进行 WBI 签名
2065
2032
  * @param params - 请求参数对象,键值对形式
@@ -2067,8 +2034,8 @@ const getMixinKey$1 = (orig) => mixinKeyEncTab$1.map((n) => orig[n]).join("").sl
2067
2034
  * @param sub_key - 子密钥
2068
2035
  * @returns 返回包含时间戳和签名的查询字符串
2069
2036
  */
2070
- const encWbi$1 = (params, img_key, sub_key) => {
2071
- const mixin_key = getMixinKey$1(img_key + sub_key);
2037
+ const encWbi = (params, img_key, sub_key) => {
2038
+ const mixin_key = getMixinKey(img_key + sub_key);
2072
2039
  const curr_time = Math.round(Date.now() / 1e3);
2073
2040
  const chr_filter = /[!'()*]/g;
2074
2041
  Object.assign(params, { wts: curr_time });
@@ -2103,7 +2070,7 @@ const wbi_sign = async (BASEURL, cookie) => {
2103
2070
  const url = new URL(BASEURL);
2104
2071
  const params = {};
2105
2072
  for (const [key, value] of url.searchParams.entries()) params[key] = value;
2106
- return encWbi$1(params, web_keys.img_key, web_keys.sub_key);
2073
+ return encWbi(params, web_keys.img_key, web_keys.sub_key);
2107
2074
  };
2108
2075
  //#endregion
2109
2076
  //#region src/platforms/legacy/bilibili/qtparam.ts
@@ -2186,10 +2153,10 @@ const generateSecChUa = (userAgent) => {
2186
2153
  * GET / POST),不属于基线。
2187
2154
  */
2188
2155
  const createBilibiliConfig = (cookie, requestConfig) => {
2189
- const userAgent = new 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";
2156
+ const userAgent = 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";
2190
2157
  const secChUa = generateSecChUa(userAgent);
2191
2158
  return {
2192
- headers: new AmagiHeaders().set("accept", "application/json, text/plain, */*").set("accept-language", "zh-CN,zh;q=0.9").set("accept-encoding", "gzip, deflate, br, zstd").set("cache-control", "no-cache").set("pragma", "no-cache").set("priority", "u=1, i").set("referer", "https://www.bilibili.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),
2159
+ headers: new require_steps.AmagiHeaders().set("accept", "application/json, text/plain, */*").set("accept-language", "zh-CN,zh;q=0.9").set("accept-encoding", "gzip, deflate, br, zstd").set("cache-control", "no-cache").set("pragma", "no-cache").set("priority", "u=1, i").set("referer", "https://www.bilibili.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),
2193
2160
  requestConfig: {
2194
2161
  timeout: 1e4,
2195
2162
  ...requestConfig
@@ -2407,254 +2374,20 @@ const bilibiliJudge = (raw, http) => {
2407
2374
  };
2408
2375
  };
2409
2376
  //#endregion
2410
- //#region src/platforms/bilibili/sign/qtparam.ts
2411
- /**
2412
- * B站视频流签名器(qtparam 前置)。
2413
- *
2414
- * cookie 取自 `ctx.cookie`(由 `resolveBoundRequest` 用 `AmagiHeaders`
2415
- * 大小写不敏感地解析),这里不自己翻 headers。
2416
- *
2417
- * 行为:
2418
- * - cookie 为空 → 只带 `&platform=html5`(未登录降级)。
2419
- * - 已登录 → 打 `/nav` 取 `vipStatus`(复用 wbi 签名器的 TTL 缓存):
2420
- * VIP 带 `&fnval=4048&fourk=1`,非 VIP 带 `&qn=64&fnval=16`,
2421
- * 再追加 wbi 签名(签名基于**未加 fnval 的原始 URL**)。
2422
- */
2423
- /** 高清档位:qn[3] = 64 = 720P */
2424
- const QN = [
2425
- 6,
2426
- 16,
2427
- 32,
2428
- 64,
2429
- 74,
2430
- 80,
2431
- 112,
2432
- 116,
2433
- 120,
2434
- 125,
2435
- 126,
2436
- 127
2437
- ];
2438
- /** VIP 的 fnval:16(DASH) | 64(HDR) | 128(4K) | 256(杜比) | 512(杜比视界) | 1024(8K) | 2048(AV1) = 4048 */
2439
- const VIP_FNVAL = 4048;
2440
- /**
2441
- * 创建 qtparam 签名器。
2442
- *
2443
- * 依赖同一个 {@link WbiSigner} 实例:登录态与 wbi keys 共用一次 `/nav` 的 TTL 缓存。
2444
- * @param wbi - wbi 签名器实例(与 `sign: 'wbi'` 的端点共用)
2445
- * @returns 签名器函数,挂到 `sign: 'qtparam'`
2446
- */
2447
- const createQtparamSigner = (wbi) => {
2448
- return async (spec, ctx) => {
2449
- if (ctx.cookie === "") return {
2450
- ...spec,
2451
- url: spec.url + "&platform=html5"
2452
- };
2453
- const isvip = (await wbi.getNav(ctx)).data?.vipStatus === 1;
2454
- const qtParams = isvip ? `&fnval=${VIP_FNVAL}&fourk=1` : `&qn=${QN[3]}&fnval=16`;
2455
- const signed = await wbi.sign(spec, ctx);
2456
- return {
2457
- ...spec,
2458
- url: signed.url + qtParams,
2459
- extra: {
2460
- ...spec.extra,
2461
- isvip
2462
- }
2463
- };
2464
- };
2465
- };
2466
- //#endregion
2467
- //#region src/platforms/bilibili/sign/wbi.ts
2468
- /**
2469
- * B站 wbi 签名器(实例级)。
2470
- *
2471
- * 两处实现要点:
2472
- * - **走 transport**:`getNav` 用 `ctx.send` 发 `/nav`(`reason: 'prepare'`
2473
- * 进 trace),与主请求走同一条路。
2474
- * - **TTL 缓存随 client 实例**:keys 缓存在实例里,TTL 内连续签名只打一次
2475
- * `/nav`。
2476
- *
2477
- * 签名算法本身(`mixinKeyEncTab` / `encWbi`)与旧版逐字一致。
2478
- */
2479
- /** wbi 密钥的 TTL(毫秒)。30 分钟内复用缓存 */
2480
- const WBI_TTL_MS = 18e5;
2481
- /** 混合密钥编码表 */
2482
- const mixinKeyEncTab = [
2483
- 46,
2484
- 47,
2485
- 18,
2486
- 2,
2487
- 53,
2488
- 8,
2489
- 23,
2490
- 32,
2491
- 15,
2492
- 50,
2493
- 10,
2494
- 31,
2495
- 58,
2496
- 3,
2497
- 45,
2498
- 35,
2499
- 27,
2500
- 43,
2501
- 5,
2502
- 49,
2503
- 33,
2504
- 9,
2505
- 42,
2506
- 19,
2507
- 29,
2508
- 28,
2509
- 14,
2510
- 39,
2511
- 12,
2512
- 38,
2513
- 41,
2514
- 13,
2515
- 37,
2516
- 48,
2517
- 7,
2518
- 16,
2519
- 24,
2520
- 55,
2521
- 40,
2522
- 61,
2523
- 26,
2524
- 17,
2525
- 0,
2526
- 1,
2527
- 60,
2528
- 51,
2529
- 30,
2530
- 4,
2531
- 22,
2532
- 25,
2533
- 54,
2534
- 21,
2535
- 56,
2536
- 59,
2537
- 6,
2538
- 63,
2539
- 57,
2540
- 62,
2541
- 11,
2542
- 36,
2543
- 20,
2544
- 34,
2545
- 44,
2546
- 52
2547
- ];
2548
- /** 从 URL 末尾取文件名部分(去扩展名),得到 img_key / sub_key */
2549
- const extractKey = (url) => url.slice(url.lastIndexOf("/") + 1, url.lastIndexOf("."));
2550
- /**
2551
- * 对 imgKey 和 subKey 进行字符顺序打乱编码。
2552
- * @param orig - img_key + sub_key 拼接
2553
- * @returns 32 位 mixin key
2554
- */
2555
- const getMixinKey = (orig) => mixinKeyEncTab.map((n) => orig[n]).join("").slice(0, 32);
2556
- /**
2557
- * 为请求参数计算 wbi 签名。
2558
- * @param params - 请求参数(不含 wts / w_rid)
2559
- * @param img_key - 图片密钥
2560
- * @param sub_key - 子密钥
2561
- * @returns `&wts=..&w_rid=..` 查询串
2562
- */
2563
- const encWbi = (params, img_key, sub_key) => {
2564
- const mixin_key = getMixinKey(img_key + sub_key);
2565
- const curr_time = Math.round(Date.now() / 1e3);
2566
- const chr_filter = /[!'()*]/g;
2567
- Object.assign(params, { wts: curr_time });
2568
- const query = Object.keys(params).sort().map((key) => {
2569
- const value = params[key].toString().replace(chr_filter, "");
2570
- return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
2571
- }).join("&");
2572
- return `&wts=${curr_time}&w_rid=${node_crypto.default.createHash("md5").update(query + mixin_key).digest("hex")}`;
2573
- };
2377
+ //#region src/platforms/bilibili/sign/signers.ts
2574
2378
  /**
2575
- * B站 wbi 签名器实例。
2379
+ * 创建 B站签名器表(薄壳,进程级共享)。
2576
2380
  *
2577
- * 每 client 实例持有一个(`PLATFORM_RUNTIME.bilibili.signers` 里创建),
2578
- * keys 缓存随实例 —— TTL 内 `sign` 不会重复打 `/nav`。
2381
+ * 不自建 `WbiSigner` —— `wbi()` / `qtparam()` 内部引用 `steps.ts` 的模块级
2382
+ * `sharedWbi`,所以 `createBilibiliSigners()` 调多次也共用同一个实例、同一份
2383
+ * `/nav` 缓存(与旧注释「每 client 一份」相反 —— 但实现上本来就只有一份,这里是
2384
+ * 让注释对上实现)。
2385
+ * @returns 注册名 → 签名器
2579
2386
  */
2580
- var WbiSigner = class {
2581
- ttlMs;
2582
- now;
2583
- nav;
2584
- /**
2585
- * @param ttlMs - keys 缓存有效期,默认 {@link WBI_TTL_MS}
2586
- * @param now - 时钟(可注入)
2587
- */
2588
- constructor(ttlMs = WBI_TTL_MS, now = Date.now) {
2589
- this.ttlMs = ttlMs;
2590
- this.now = now;
2591
- }
2592
- /**
2593
- * 取 `/nav` 响应体(带 TTL 缓存)。
2594
- *
2595
- * 走 `ctx.send`(reason `'prepare'`)。
2596
- * @param ctx - 执行上下文(提供 send 与 cookie)
2597
- * @returns `/nav` 响应体
2598
- */
2599
- async getNav(ctx) {
2600
- const cached = this.nav;
2601
- if (cached && this.now() - cached.fetchedAt < this.ttlMs) return cached.body;
2602
- const body = (await ctx.send({
2603
- method: "GET",
2604
- url: "https://api.bilibili.com/x/web-interface/nav",
2605
- headers: { cookie: ctx.cookie }
2606
- }, "prepare")).body;
2607
- this.nav = {
2608
- body,
2609
- fetchedAt: this.now()
2610
- };
2611
- return body;
2612
- }
2613
- /** 从 `/nav` 响应体提取 wbi keys */
2614
- keysOf(body) {
2615
- const img = body.data?.wbi_img?.img_url;
2616
- const sub = body.data?.wbi_img?.sub_url;
2617
- if (!img || !sub) throw new Error("wbi 密钥获取失败:/nav 响应缺少 wbi_img");
2618
- return {
2619
- img_key: extractKey(img),
2620
- sub_key: extractKey(sub)
2621
- };
2622
- }
2623
- /**
2624
- * 签名器:给请求 URL 追加 `&wts=..&w_rid=..`。
2625
- *
2626
- * `sign: 'wbi'` 的端点(comments / userDynamicList / userSpaceInfo 等)用它。
2627
- * 首次调用会触发 `/nav` 前置请求(reason `'prepare'`),TTL 内复用。
2628
- * @param spec - 请求描述
2629
- * @param ctx - 执行上下文
2630
- * @returns 带 wbi 签名的请求描述
2631
- */
2632
- sign = async (spec, ctx) => {
2633
- const nav = await this.getNav(ctx);
2634
- const { img_key, sub_key } = this.keysOf(nav);
2635
- const url = new URL(spec.url);
2636
- const params = {};
2637
- for (const [key, value] of url.searchParams.entries()) params[key] = value;
2638
- const query = encWbi(params, img_key, sub_key);
2639
- return {
2640
- ...spec,
2641
- url: spec.url + query
2642
- };
2643
- };
2644
- };
2645
- /** 创建一个 wbi 签名器实例(每 client 一个) */
2646
- const createWbiSigner = (ttlMs, now) => new WbiSigner(ttlMs, now);
2647
- //#endregion
2648
- //#region src/platforms/bilibili/sign/signers.ts
2649
- /** 创建 B站签名器表(每 client 一份) */
2650
- const createBilibiliSigners = () => {
2651
- const instance = createWbiSigner();
2652
- return {
2653
- wbi: instance.sign,
2654
- qtparam: createQtparamSigner(instance),
2655
- instance
2656
- };
2657
- };
2387
+ const createBilibiliSigners = () => ({
2388
+ wbi: stepsToSigner([require_steps.wbi()]),
2389
+ qtparam: stepsToSigner([require_steps.qtparam()])
2390
+ });
2658
2391
  //#endregion
2659
2392
  //#region src/platforms/douyin/config.ts
2660
2393
  /**
@@ -2668,10 +2401,10 @@ const createBilibiliSigners = () => {
2668
2401
  * `method` 归端点声明(抖音端点各自声明 GET / POST),不属于基线。
2669
2402
  */
2670
2403
  const createDouyinConfig = (cookie, requestConfig) => {
2671
- const userAgent = new 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";
2404
+ const userAgent = 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";
2672
2405
  const secChUa = generateSecChUa(userAgent);
2673
2406
  return {
2674
- headers: new 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("priority", "u=1, i").set("referer", "https://www.douyin.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-origin").set("user-agent", userAgent).set("cookie", cookie?.trim() ?? "").merge(requestConfig?.headers),
2407
+ 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("priority", "u=1, i").set("referer", "https://www.douyin.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-origin").set("user-agent", userAgent).set("cookie", cookie?.trim() ?? "").merge(requestConfig?.headers),
2675
2408
  requestConfig: {
2676
2409
  timeout: 1e4,
2677
2410
  ...requestConfig
@@ -2768,777 +2501,51 @@ const douyinJudge = (raw, http) => {
2768
2501
  return verdictFromHttpStatus(http.status) ?? { ok: true };
2769
2502
  };
2770
2503
  //#endregion
2771
- //#region src/contracts/cookie.ts
2772
- /**
2773
- * Cookie 契约。
2774
- *
2775
- * **全仓唯一的 cookie 解析与序列化实现。** 解析按 RFC 6265 的键值语义:
2776
- * 名字大小写敏感、值取第一个 `=` 之后的全部内容、不做 URL 解码。
2777
- *
2778
- * `contracts/` 是零依赖叶子层,本文件不 import 任何模块。
2779
- */
2780
- /**
2781
- * 解析 `name=value; name=value` 形式的 cookie 串。
2782
- *
2783
- * 规则:
2784
- * - 以第一个 `=` 分割,所以值里可以含 `=`(如 base64)。
2785
- * - 名与值两端去空白。
2786
- * - 名为空、或整段没有 `=` 的片段直接跳过。
2787
- * - 同名多次出现时**后者覆盖前者**。
2788
- * - 值不做 URL 解码 —— 签名依赖原始字节,解码会改变签名输入。
2789
- *
2790
- * cookie 名按 RFC 6265 是**大小写敏感**的,因此本函数不做任何大小写归一化。
2791
- * 这与 header 名大小写不敏感(见 `AmagiHeaders`)是两件不同的事。
2792
- * @param cookieString - cookie 串,`undefined` / `null` / 空串都安全
2793
- * @returns 名 → 值的普通对象,无有效项时是空对象
2794
- */
2795
- const parseCookie = (cookieString) => {
2796
- const result = {};
2797
- if (!cookieString) return result;
2798
- for (const pair of cookieString.split(";")) {
2799
- const index = pair.indexOf("=");
2800
- if (index <= 0) continue;
2801
- const name = pair.slice(0, index).trim();
2802
- if (!name) continue;
2803
- result[name] = pair.slice(index + 1).trim();
2804
- }
2805
- return result;
2806
- };
2807
- /**
2808
- * 取单条 cookie 的值。
2809
- *
2810
- * **按名精确匹配**,不是子串匹配 ——
2811
- * `getCookieValue('xa1=WRONG; a1=RIGHT', 'a1')` 返回 `'RIGHT'` 而不是 `'WRONG'`。
2812
- * @param cookieString - 完整 cookie 串
2813
- * @param name - cookie 名,大小写敏感
2814
- * @returns 值;不存在时返回 `undefined`
2815
- */
2816
- const getCookieValue = (cookieString, name) => {
2817
- if (!cookieString || !name) return void 0;
2818
- return parseCookie(cookieString)[name];
2819
- };
2820
- //#endregion
2821
- //#region src/platforms/douyin/webid.ts
2822
- /**
2823
- * 抖音 `webid` 的取得与缓存。
2824
- *
2825
- * `webid` 是服务端下发的访客 id,**客户端算不出来** —— 服务端按 `ttwid` 算好之后,
2826
- * 通过每个响应的 `cookie_ttwidinfo_webid` 头告知。一个 ttwid 对应一个稳定的 webid。
2827
- *
2828
- * 抖音会拿 query 里的 `webid` 与 cookie 的会话交叉校验,对不上就返回 HTTP 200 +
2829
- * 空 body(不是 403、也不是业务码),最典型的受害者是 `aweme/favorite`。实测:
2830
- *
2831
- * ```text
2832
- * 浏览器真实的 webid → 200,1,116,615 字节,19 条
2833
- * 代码里写死的那个 → 200, 0 字节,空
2834
- * 干脆不传 → 200,1,116,776 字节,19 条
2835
- * ```
2836
- *
2837
- * **不传安全,传错致命。** 所以不写死默认值,只在缓存命中时才补。
2838
- * 缓存按 ttwid 分键:换了 cookie,webid 就得跟着换。
2839
- *
2840
- * 顺带澄清一个容易误解的点:填别人的 `webid` **不会**拿到别人的数据 ——
2841
- * 「看谁」由 `sec_user_id` 决定,`webid` 只是设备一致性校验,身份来自 cookie 的
2842
- * `sessionid`。填错只会被拒绝。
2843
- *
2844
- * ## 实现要点
2845
- *
2846
- * 接入的是端点管线:
2847
- *
2848
- * - 键取自 `ctx.cookie`,解析走 `contracts/cookie.ts` 的 `getCookieValue`;
2849
- * - 回收由平台级 `observe` 钩子在每次 send 之后调用,读 `RawResponse.headers`;
2850
- * - 注入由 `sign/signers.ts` 里的第一步完成(`webid → a_bogus/x_bogus → secsdk`),
2851
- * 而不是让同步的 URL 构造去依赖运行期状态。
2852
- *
2853
- * ## 作用域:进程级
2854
- *
2855
- * **这是进程级的,不是每 client 一份** —— 端点定义本身就是模块级单例。
2856
- * 要按 client 隔离得把状态搬到 `ClientCtx` 上。
2857
- * 多 cookie 场景靠 ttwid 分键区分,所以「串用别人的 webid」不会发生。
2858
- *
2859
- * @module platforms/douyin/webid
2860
- */
2861
- /** ttwid → webid。webid 对同一 ttwid 是稳定的,进程内缓存即可 */
2862
- const cache = /* @__PURE__ */ new Map();
2863
- /** 抖音回传 webid 的响应头 */
2864
- const WEBID_HEADER = "cookie_ttwidinfo_webid";
2865
- /** webid 的形状:纯数字、至少 6 位。不合形状的值一律不收 —— 传错比不传更糟 */
2866
- const WEBID_SHAPE = /^\d{6,}$/;
2867
- /**
2868
- * 从 cookie 串里取 `ttwid`,用作缓存键
2869
- * @param cookie - 本次调用使用的 cookie
2870
- * @returns `ttwid` 的值,取不到时返回空串
2871
- */
2872
- const ttwidOf = (cookie) => cookie ? getCookieValue(cookie, "ttwid") ?? "" : "";
2873
- /**
2874
- * 看到响应头里的 webid 就记下来。没有这个头、值不合形状、或 cookie 里没有 ttwid
2875
- * (无从分键)时都是无操作。
2876
- * @param res - 本次请求的原始响应
2877
- * @param cookie - 本次请求使用的 cookie,用于取 ttwid 作为缓存键
2878
- */
2879
- const rememberDouyinWebid = (res, cookie) => {
2880
- const webid = res.headers.get(WEBID_HEADER);
2881
- if (!webid || !WEBID_SHAPE.test(webid)) return;
2882
- const key = ttwidOf(cookie);
2883
- if (key) cache.set(key, webid);
2884
- };
2885
- /**
2886
- * 取这份 cookie 对应的 webid
2887
- * @param cookie - 本次调用使用的 cookie
2888
- * @returns 缓存里的 webid,没有时返回空串
2889
- */
2890
- const douyinWebidFor = (cookie) => cache.get(ttwidOf(cookie)) ?? "";
2891
- /**
2892
- * 给 URL 补上 `webid`。
2893
- *
2894
- * 必须在算 a_bogus 之前调用:真实浏览器的顺序是「拼好 query(含 webid)→ a_bogus →
2895
- * secsdk 签名」,后两者都覆盖 query,顺序错了签名就不成立。
2896
- * URL 里已有 `webid`、或缓存里没有值时原样返回。
2897
- * @param url - 还没加签的 URL
2898
- * @param cookie - 本次请求使用的 cookie
2899
- * @returns 补好 `webid` 的 URL
2900
- */
2901
- const withDouyinWebid = (url, cookie) => {
2902
- const webid = douyinWebidFor(cookie);
2903
- if (!webid) return url;
2904
- let parsed;
2905
- try {
2906
- parsed = new URL(url);
2907
- } catch {
2908
- return url;
2909
- }
2910
- if (parsed.searchParams.has("webid")) return url;
2911
- parsed.searchParams.set("webid", webid);
2912
- return parsed.toString();
2913
- };
2914
- /**
2915
- * 平台级 `observe` 钩子:挂在 `PLATFORM_RUNTIME.douyin` 上,每次 send 之后被调一次。
2916
- *
2917
- * **自己吞异常** —— 契约是「只读、不抛、不改判定」。回收 webid 失败只该让下一次
2918
- * 请求少一个参数(不带是安全的),不该把一个本来成功的调用变成 `kind: 'internal'`。
2919
- * @param res - 本次请求的原始响应
2920
- * @param ctx - 当刻的执行上下文(只读 `cookie`)
2921
- */
2922
- const observeDouyinWebid = (res, ctx) => {
2923
- try {
2924
- rememberDouyinWebid(res, ctx.cookie);
2925
- } catch {}
2926
- };
2927
- //#endregion
2928
- //#region src/platforms/douyin/sign/x_bogus.ts
2929
- /**
2930
- * X-Bogus 生成工具(抖音 / TikTok Web 的老签名)—— TypeScript 移植
2931
- *
2932
- * 移植自 Douyin_TikTok_Download_API `src/dtk/signing/native/xbogus.py`,常量、槽位与
2933
- * **字节顺序**逐条对齐;那份 Python 又是 V4 `crawlers/douyin/web/xbogus.py` 的逐行移植,
2934
- * 而 V4 是站点自己那份混淆 JS 的直译。所以这里对齐的不是「某份实现的做法」,
2935
- * 而是站点校验时的做法。
2936
- *
2937
- * ## 载荷(19 字节)与槽位
2938
- *
2939
- * ```text
2940
- * [64, 1/256, 1, 12, query 摘要[14..15], empty 摘要[14..15], ua 摘要[14..15],
2941
- * 时间戳 4 字节, 536919696 4 字节, 校验位]
2942
- * ```
2943
- *
2944
- * 三条摘要链各自算出 16 字节 MD5,只取第 14、15 字节(`DIGEST_INDICES`);`1/256`
2945
- * 是站点留在载荷里的浮点常量,最终被截断成 `0`;最后一格是前 18 格的异或。
2946
- *
2947
- * ## 两个曾经的坑,都会产出「看着像模像样」的错签名
2948
- *
2949
- * 1. **字节顺序**:站点把载荷按奇偶位拆开,再交给一个**形参表本身就乱序**的编码函数,
2950
- * 而那个函数正好把两段拼回原序 —— 拆分是死代码。旧移植漏掉还原这一步,把
2951
- * 「偶数位在前、奇数位在后」直接送进 RC4,字节全体错位;再叠加把 `1/256` 写成 `1`,
2952
- * 输出与站点/V4 完全不同。见 {@link XBogus.interleave}。
2953
- * 2. **十六进制解码**:≤ 32 字符一律按站点的判据当摘要解十六进制,旧实现遇到落单的
2954
- * 半字节会悄悄补 0(Python 那版是悄悄丢掉末位),两个不同输入能撞出同一签名。
2955
- * 见 {@link XBogus.md5StrToArray}。
2956
- *
2957
- * ## 时钟
2958
- *
2959
- * V4 把 `time.time()` 烧在函数体里,签名没法在测试里钉死。这里把秒级时间戳做成
2960
- * 注入点(`options.timestamp`),默认值才是 `Date.now()`。
2961
- *
2962
- * @module platforms/douyin/sign/x_bogus
2963
- */
2964
- /** 输出字母表,与 a_bogus 的 `s2` 相同;两者可以各自演化,所以不共用一份 */
2965
- const CHARACTER = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=";
2966
- /** 作用在 User-Agent 上的 RC4 密钥(三个控制字节) */
2967
- const UA_KEY = Buffer.from([
2968
- 0,
2969
- 1,
2970
- 12
2971
- ]);
2972
- /** 作用在载荷上的 RC4 密钥 */
2973
- const PAYLOAD_KEY = Buffer.from([255]);
2974
- /** 空串的 MD5,算法会把它再哈希一轮,结果是个常量 */
2975
- const EMPTY_MD5 = "d41d8cd98f00b204e9800998ecf8427e";
2976
- /** 载荷开头四个槽位的字面量;`1/256` 那一格会被截断成 `0`,但不能顺手改写成 `0` */
2977
- const PAYLOAD_LEAD = [
2978
- 64,
2979
- 1 / 256,
2980
- 1,
2981
- 12
2982
- ];
2983
- /** 每条摘要链贡献的两个字节在 16 字节摘要里的下标 */
2984
- const DIGEST_INDICES = [14, 15];
2985
- /** 信封开头的两个明文字节(`2` 与 `255`),密文接在它们后面 */
2986
- const ENVELOPE_LEAD = [2, 255];
2987
- /** 不传 UA 时使用的默认值,与 V4 一字不差(改它等于改签名) */
2988
- const DEFAULT_USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36 Edg/122.0.0.0";
2989
- /** 小写十六进制字符,V4 与 xbogus.py 都只认这一档 */
2990
- const HEX_DIGITS = "0123456789abcdef";
2991
- var XBogus = class {
2992
- /** 十六进制字符 → 半字节;未映射的字符取出 `null` 或 `undefined`,两者都算非法 */
2993
- hexNibbles;
2994
- /** 内置默认 UA */
2995
- defaultUa;
2996
- /** V4 兼容字段:V4 会把结果同步写进 `params` / `xb`,本实现不写 */
2997
- params;
2998
- xb;
2999
- constructor() {
3000
- this.hexNibbles = new Array(128).fill(null);
3001
- for (let index = 0; index < 16; index++) this.hexNibbles[HEX_DIGITS.charCodeAt(index)] = index;
3002
- this.defaultUa = DEFAULT_USER_AGENT;
3003
- }
3004
- /**
3005
- * 十六进制串 → 字节数组;超过 32 字符则按原始文本取码点。
3006
- *
3007
- * **长度判据(≤ 32 走十六进制)是站点的**,不是这里加的:站点把任何不超过一份
3008
- * 摘要长度的字符串都当作摘要来解。真实请求的 path + query 永远长过 32 字符,
3009
- * 走的是文本分支,所以这条规则碰不到线上行为。
3010
- *
3011
- * **旧实现在这里会悄悄吞掉落单的半字节**,两个不同输入因此撞出同一份签名:
3012
- * 参考实现(Python)用 `range(0, len - 1, 2)`,末位字符直接不参与运算,
3013
- * `"abcdef"` 与 `"abcdefg"` 同签名;本仓库旧 TS 版把缺失的低半字节当成 0,
3014
- * `"abc"` 与 `"abc0"` 同签名。两者的共同点是「输入不合法,却拿到一份像模像样的
3015
- * 签名」—— 这比报错难查得多,所以这里选择**报错**:长度必须是偶数、字符必须是小写
3016
- * 十六进制,否则抛出,绝不替调用方补位或丢位。
3017
- *
3018
- * @param value - 十六进制串 / 原始文本,或现成的字节数组(复制一份返回)
3019
- * @returns 字节数组
3020
- */
3021
- md5StrToArray(value) {
3022
- if (Array.isArray(value)) return [...value];
3023
- if (value.length > 32) {
3024
- const codePoints = [];
3025
- for (const char of value) codePoints.push(char.charCodeAt(0));
3026
- return codePoints;
3027
- }
3028
- const bytes = [];
3029
- for (let index = 0; index < value.length; index += 2) {
3030
- const pair = value.slice(index, index + 2);
3031
- const high = this.hexNibbles[value.charCodeAt(index)];
3032
- const low = index + 1 < value.length ? this.hexNibbles[value.charCodeAt(index + 1)] : null;
3033
- if (high === null || high === void 0 || low === null || low === void 0) throw new Error(`X-Bogus 签名:${JSON.stringify(value)} 不是偶数长度的小写十六进制串(${JSON.stringify(pair)} 处)`);
3034
- bytes.push(high << 4 | low);
3035
- }
3036
- return bytes;
3037
- }
3038
- /** MD5 十六进制摘要;字符串先过 {@link XBogus.md5StrToArray},数组按原样取字节 */
3039
- md5(data) {
3040
- const bytes = typeof data === "string" ? this.md5StrToArray(data) : data;
3041
- return node_crypto.default.createHash("md5").update(Buffer.from(bytes)).digest("hex");
3042
- }
3043
- /** query 摘要链:两轮 MD5 */
3044
- md5Encrypt(urlPath) {
3045
- return this.md5StrToArray(this.md5(this.md5StrToArray(this.md5(urlPath))));
3046
- }
3047
- /**
3048
- * empty 摘要链:哈希空串,是个常量。
3049
- *
3050
- * 它的第 14、15 字节出现在每一份 X-Bogus 里 —— 解码时对不上这两个字节的,
3051
- * 就不是 X-Bogus。
3052
- */
3053
- emptyDigest() {
3054
- return this.md5StrToArray(this.md5(this.md5StrToArray(EMPTY_MD5)));
3055
- }
3056
- /** UA 摘要链:RC4 → base64 → MD5,再解回字节 */
3057
- userAgentDigest(userAgent) {
3058
- const encrypted = this.rc4Encrypt(UA_KEY, Buffer.from(userAgent, "latin1"));
3059
- return this.md5StrToArray(this.md5(encrypted.toString("base64")));
3060
- }
3061
- /** RC4(与 Python 版逐句对应) */
3062
- rc4Encrypt(key, data) {
3063
- const box = Array.from({ length: 256 }, (_, index) => index);
3064
- let j = 0;
3065
- for (let index = 0; index < 256; index++) {
3066
- j = (j + box[index] + key[index % key.length]) % 256;
3067
- [box[index], box[j]] = [box[j], box[index]];
3068
- }
3069
- const out = Buffer.alloc(data.length);
3070
- let i = 0;
3071
- j = 0;
3072
- for (let index = 0; index < data.length; index++) {
3073
- i = (i + 1) % 256;
3074
- j = (j + box[i]) % 256;
3075
- [box[i], box[j]] = [box[j], box[i]];
3076
- out[index] = data[index] ^ box[(box[i] + box[j]) % 256];
3077
- }
3078
- return out;
3079
- }
3080
- /**
3081
- * 偶数位在前、奇数位在后 —— 站点对载荷做的拆分。
3082
- *
3083
- * 拆分本身是死代码({@link XBogus.interleave} 会把它原样拼回去),真正的作用只有
3084
- * 一处:让 `1/256` 那一格被截断成整数。形状照抄站点,为的是别人对着 xbogus.py 读时
3085
- * 能一句一句对上,也让「漏掉还原」这个坑不至于再犯一次。
3086
- */
3087
- splitEvenOdd(values) {
3088
- const head = [];
3089
- const tail = [];
3090
- for (let index = 0; index < values.length; index++) if (index % 2 === 0) head.push(values[index]);
3091
- else tail.push(values[index]);
3092
- return [...head, ...tail];
3093
- }
3094
- /**
3095
- * 把 {@link XBogus.splitEvenOdd} 拆开的两段按下标交错回原序(顺便截断成整数)。
3096
- *
3097
- * **这一步不能省**:站点那个编码函数的形参表本身是乱序的 ——
3098
- * V4 写作 `encoding_conversion(a, b, c, e, d, t, f, r, n, o, i, _, x, u, s, l, v, h, p)`,
3099
- * 函数体按 `[a, i, b, _, c, x, e, u, d, s, t, l, f, v, r, h, n, p, o]` 拼接,
3100
- * 传进去的又是拆开的两段,算下来正好还原成原始顺序。旧移植把它当成顺序形参处理,
3101
- * 于是送进 RC4 的字节全体错位,签名从根上就是错的,还错得看不出来。
3102
- */
3103
- interleave(values) {
3104
- const head = values.slice(0, 10);
3105
- const tail = values.slice(10);
3106
- const merged = [];
3107
- for (let index = 0; index < head.length; index++) {
3108
- merged.push(Math.trunc(head[index]));
3109
- if (index < tail.length) merged.push(Math.trunc(tail[index]));
3110
- }
3111
- return merged;
3112
- }
3113
- /** 三个载荷字节 → 四个输出字符 */
3114
- encodeGroup(first, second, third) {
3115
- const merged = (first & 255) << 16 | (second & 255) << 8 | third & 255;
3116
- return CHARACTER[(merged & 16515072) >> 18] + CHARACTER[(merged & 258048) >> 12] + CHARACTER[(merged & 4032) >> 6] + CHARACTER[merged & 63];
3117
- }
3118
- /**
3119
- * 生成 X-Bogus 签名。
3120
- *
3121
- * @param url - 完整的 URL 地址(签的是它的 pathname + search)
3122
- * @param ua - 可选的 User-Agent,不提供则使用默认值
3123
- * @param options - `timestamp` 注入点(秒级),省略时取当前时间
3124
- * @returns 完整 URL(已带 `X-Bogus=`)、签名值与实际使用的 User-Agent
3125
- */
3126
- getXBogus(url, ua, options = {}) {
3127
- const parsedUrl = new node_url.default.URL(url);
3128
- const urlPath = parsedUrl.pathname + parsedUrl.search;
3129
- const userAgent = ua ?? this.defaultUa;
3130
- const userAgentDigest = this.userAgentDigest(userAgent);
3131
- const emptyDigest = this.emptyDigest();
3132
- const queryDigest = this.md5Encrypt(urlPath);
3133
- const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
3134
- const payload = [
3135
- ...PAYLOAD_LEAD,
3136
- queryDigest[DIGEST_INDICES[0]],
3137
- queryDigest[DIGEST_INDICES[1]],
3138
- emptyDigest[DIGEST_INDICES[0]],
3139
- emptyDigest[DIGEST_INDICES[1]],
3140
- userAgentDigest[DIGEST_INDICES[0]],
3141
- userAgentDigest[DIGEST_INDICES[1]],
3142
- timestamp >> 24 & 255,
3143
- timestamp >> 16 & 255,
3144
- timestamp >> 8 & 255,
3145
- timestamp & 255,
3146
- 32,
3147
- 0,
3148
- 190,
3149
- 144
3150
- ];
3151
- let checksum = Math.trunc(payload[0]);
3152
- for (let index = 1; index < payload.length; index++) checksum ^= Math.trunc(payload[index]);
3153
- payload.push(checksum);
3154
- const merged = this.interleave(this.splitEvenOdd(payload));
3155
- const encrypted = this.rc4Encrypt(PAYLOAD_KEY, Buffer.from(merged));
3156
- const garbled = String.fromCharCode(...ENVELOPE_LEAD) + encrypted.toString("latin1");
3157
- let xbogus = "";
3158
- for (let index = 0; index + 2 < garbled.length; index += 3) xbogus += this.encodeGroup(garbled.charCodeAt(index), garbled.charCodeAt(index + 1), garbled.charCodeAt(index + 2));
3159
- return {
3160
- fullUrl: url.includes("?") ? `${url}&X-Bogus=${xbogus}` : `${url}?X-Bogus=${xbogus}`,
3161
- xbogus,
3162
- userAgent
3163
- };
3164
- }
3165
- };
3166
- //#endregion
3167
- //#region src/platforms/douyin/sign/index.ts
3168
- const defaultUserAgent$1 = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
3169
- var douyinSign$1 = class {
3170
- /**
3171
- * 生成一个指定长度的随机字符串
3172
- * @param length 字符串长度,默认为116
3173
- * @returns
3174
- */
3175
- static Mstoken(length) {
3176
- const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
3177
- const randomBytes = node_crypto.default.randomBytes(length ?? 116);
3178
- return Array.from(randomBytes, (byte) => characters[byte % 62]).join("");
3179
- }
3180
- /**
3181
- * a_bogus 签名算法
3182
- * @param url 需要签名的地址
3183
- * @returns 对此地址签名后的URL查询参数
3184
- */
3185
- static AB(url, userAgent) {
3186
- return require_tokens.a_bogus_default(url, userAgent ?? defaultUserAgent$1);
3187
- }
3188
- /**
3189
- * X-Bogus 签名算法
3190
- * @param url 需要签名的地址
3191
- * @returns 对此地址签名后的URL查询参数
3192
- */
3193
- static XB(url, userAgent) {
3194
- return new XBogus().getXBogus(url, userAgent ?? defaultUserAgent$1).xbogus;
3195
- }
3196
- /**
3197
- * 生成一个唯一的验证字符串
3198
- *
3199
- * 形状与旧实现一致(`verify_<base36 毫秒>_<36 位>`,分隔符在 8/13/18/23、
3200
- * 版本位在 14、变体位在 19),但时钟来源从 `new Date().getTime()` 换成
3201
- * `Date.now()` —— 原来那个绕开了 `vi.spyOn(Date, 'now')`,导致这个函数
3202
- * **无法在测试里被冻结**,只能断言结构。现在可以钉死时钟与随机源了。
3203
- */
3204
- static VerifyFpManager() {
3205
- return require_tokens.genVerifyFp();
3206
- }
3207
- };
3208
- //#endregion
3209
- //#region src/platforms/douyin/sign/secsdkWebSign.ts
3210
- /**
3211
- * 抖音主站 `x-secsdk-web-signature` 纯算实现(TypeScript 移植)
3212
- *
3213
- * 移植自 cv-cat/DouYin_Spider `utils/secsdk_web_sign.py`,算法与常量按该实现逐条对齐。
3214
- *
3215
- * ## 算法
3216
- *
3217
- * ```text
3218
- * plain = {uifid}_{timestamp}_{WEBSIGN_CONST}_{canonical_query}
3219
- * signature = md5(plain) 的 32 位小写十六进制
3220
- * ```
3221
- *
3222
- * `WEBSIGN_CONST` 是 secsdk VMP 常量池里的写死盐,与账号 / 会话无关。
3223
- *
3224
- * ## canonical_query 规范化规则
3225
- *
3226
- * - 保持原始参数顺序,**不排序**;重复参数原样保留。
3227
- * - 每个 value 先解码(`+`→空格、`%XX`→字节)再用 `encodeURIComponent` 重编码。
3228
- * - key **只解码、不重编码**。
3229
- * - 无等号的裸参数补成 `k=`。
3230
- * - `&timestamp={ts}` 追加到 query 末尾后再参与哈希。
3231
- * - query 里没有 `uifid` 时,从 `UIFID` cookie 取值追加到末尾再签。
3232
- *
3233
- * ## 重要
3234
- *
3235
- * 签名算的是**规范化后的 query**,服务端也按收到的 query 校验,所以必须发送
3236
- * {@link signSecsdkWebUrl} 返回的 URL,不能拿原始 URL 直接发。
3237
- *
3238
- * 签名绑 query、**不绑 path**(本项目实测:同一份 query 换 path 仍然 200)。
3239
- *
3240
- * @module platform/douyin/secsdkWebSign
3241
- */
3242
- /** secsdk VM 常量池里的固定盐(与账号 / 会话无关) */
3243
- const WEBSIGN_CONST = "A96D855A08C0A9707F8BEF0D9A527E4E";
3244
- /** 签名参数名 */
3245
- const SECSDK_SIG_KEY = "x-secsdk-web-signature";
3246
- /** 时间戳参数名 */
3247
- const SECSDK_TS_KEY = "timestamp";
3248
- /** 走 secsdk 签名的 GET 接口路径(来自 SDK 的 webSign 策略配置) */
3249
- const PROTECTED_PATHS_GET = [
3250
- "/aweme/v1/web/aweme/detail/",
3251
- "/aweme/v1/web/aweme/post/",
3252
- "/aweme/v1/web/aweme/favorite/",
3253
- "/aweme/v1/web/aweme/listcollection/",
3254
- "/aweme/v1/web/mix/aweme/",
3255
- "/aweme/v1/web/tab/feed/",
3256
- "/aweme/v1/web/mix/list/",
3257
- "/aweme/v1/web/music/aweme/",
3258
- "/aweme/v1/web/music/list/",
3259
- "/aweme/v1/web/mix/detail/",
3260
- "/aweme/v1/web/mix/listcollection/",
3261
- "/aweme/v1/web/music/detail/",
3262
- "/aweme/v1/web/collects/list/",
3263
- "/aweme/v1/web/collects/video/list/"
3264
- ];
3265
- /** 走 secsdk 签名的 POST 接口路径 */
3266
- const PROTECTED_PATHS_POST = [
3267
- "/aweme/v1/web/aweme/detail/",
3268
- "/aweme/v1/web/aweme/post/",
3269
- "/aweme/v1/web/aweme/favorite/",
3270
- "/aweme/v1/web/aweme/listcollection/",
3271
- "/aweme/v1/web/mix/aweme/",
3272
- "/aweme/v1/web/tab/feed/"
3273
- ];
3274
- const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
3275
- /**
3276
- * 宽容的百分号解码,等价于 Python `urllib.parse.unquote(..., errors='replace')`:
3277
- * 合法的 `%XX` 解成字节,非法的 `%` 原样保留,最后按 UTF-8 解码(坏字节 → U+FFFD)。
3278
- *
3279
- * 不能直接用 `decodeURIComponent` —— 它遇到 `100%` 这种孤立百分号会抛异常,
3280
- * 而 SDK 的行为是把它当字面量留下(`100%` → 签成 `100%25`)。
3281
- *
3282
- * @param input - 待解码字符串
3283
- * @returns 解码后的字符串
3284
- */
3285
- const percentDecodeLoose = (input) => {
3286
- if (!input.includes("%")) return input;
3287
- const bytes = [];
3288
- let i = 0;
3289
- while (i < input.length) {
3290
- if (input[i] === "%" && HEX_PAIR.test(input.slice(i + 1, i + 3))) {
3291
- bytes.push(parseInt(input.slice(i + 1, i + 3), 16));
3292
- i += 3;
3293
- continue;
3294
- }
3295
- for (const byte of Buffer.from(input[i], "utf8")) bytes.push(byte);
3296
- i += 1;
3297
- }
3298
- return Buffer.from(bytes).toString("utf8");
3299
- };
2504
+ //#region src/platforms/douyin/sign/signers.ts
3300
2505
  /**
3301
- * 等价于 Python `unquote_plus`:先把 `+` 当空格,再做百分号解码。
2506
+ * 抖音签名器表:**只做「注册名 → `SignStep` 清单」的映射,不含算法**。
3302
2507
  *
3303
- * @param input - 待解码字符串
3304
- * @returns 解码后的字符串
3305
- */
3306
- const unquotePlusLoose = (input) => percentDecodeLoose(input.includes("+") ? input.replace(/\+/g, " ") : input);
3307
- /**
3308
- * 按 SDK 规则规范化 query 字符串:顺序不变,value 解码后用 `encodeURIComponent` 重编码,
3309
- * key 只解码不重编码,无等号的裸参数补成 `k=`。
2508
+ * 算法的唯一实现在 `./steps.ts`(含前置条件校验、webid 补参、secsdk 收尾的理由)。
2509
+ * 依赖方向是单向的 —— `signers.ts → steps.ts`,永不反向:两份实现并存时会改一边忘一边,
2510
+ * 而抖音的签名是**抽样校验**的(签错时大部分请求照样成功,只是被判高风险的概率上升),
2511
+ * 这类分叉极难发现。
3310
2512
  *
3311
- * @param query - 原始 query 字符串(不含 `?`)
3312
- * @returns 规范化后的 query 字符串
3313
- */
3314
- const canonicalQuery = (query) => {
3315
- const parts = [];
3316
- for (const pair of query.split("&")) {
3317
- if (!pair) continue;
3318
- const eq = pair.indexOf("=");
3319
- const rawKey = eq === -1 ? pair : pair.slice(0, eq);
3320
- const rawValue = eq === -1 ? "" : pair.slice(eq + 1);
3321
- parts.push(`${unquotePlusLoose(rawKey)}=${encodeURIComponent(unquotePlusLoose(rawValue))}`);
3322
- }
3323
- return parts.join("&");
3324
- };
3325
- /**
3326
- * 拆成 `[base, query]`,并剥掉已有的 `timestamp` / 签名字段,保证可重复调用。
2513
+ * ## 两条路,一份实现
3327
2514
  *
3328
- * @param url - 完整 URL
3329
- * @returns `[base, query]` 二元组
3330
- */
3331
- const splitUrl = (url) => {
3332
- const mark = url.indexOf("?");
3333
- if (mark === -1) return [url, ""];
3334
- return [url.slice(0, mark), url.slice(mark + 1).split("&").filter((pair) => {
3335
- if (!pair) return false;
3336
- const name = pair.split("=", 1)[0];
3337
- return name !== "timestamp" && name !== "x-secsdk-web-signature";
3338
- }).join("&")];
3339
- };
3340
- /**
3341
- * 从 cookie 字符串里取 `UIFID`。
2515
+ * 端点声明 `sign: douyinBogus(184)` 走清单;`client.douyin.request` 不给 `amagi.sign`
2516
+ * 时走字符串 `'a-bogus'`(见 `client/request/profile.ts`)查这张表。两者现在压的是同一份
2517
+ * step 清单,所以签出来必然一致。
3342
2518
  *
3343
- * 注意 cookie 里通常还有个 `UIFID_TEMP`,取值时必须锚定 `UIFID=` 前面是行首或分隔符,
3344
- * 否则会取错。
2519
+ * ## 为什么注册名对应的是三段清单
3345
2520
  *
3346
- * @param cookie - cookie 字符串
3347
- * @returns `UIFID` 的值,取不到时返回空串
3348
- */
3349
- const extractUifidFromCookie = (cookie) => {
3350
- if (!cookie) return "";
3351
- const hit = /(?:^|;\s*)UIFID=([^;]*)/.exec(cookie);
3352
- return hit ? hit[1].trim() : "";
3353
- };
3354
- /**
3355
- * 判断该 path 是否需要 secsdk 加签(依据 SDK 的 webSign 策略配置)。
3356
- *
3357
- * @param path - URL 的 pathname
3358
- * @param method - HTTP 方法,默认 `GET`
3359
- * @returns 是否需要加签
3360
- */
3361
- const isSecsdkProtected = (path, method = "GET") => {
3362
- return (String(method).toUpperCase() === "POST" ? PROTECTED_PATHS_POST : PROTECTED_PATHS_GET).includes(path);
3363
- };
3364
- /**
3365
- * 计算 `x-secsdk-web-signature`。
3366
- *
3367
- * @param url - 完整 API URL(scheme+host+path+query)。已带 `timestamp` /
3368
- * `x-secsdk-web-signature` 时会自动剥除,可重复调用。
3369
- * @param options - 时间戳与 uifid 兜底值
3370
- * @returns 时间戳、签名与**实际应当发送**的规范化 query
3371
- */
3372
- const signSecsdkWebQuery = (url, options = {}) => {
3373
- const ts = Math.trunc(options.ts ?? Date.now() / 1e3);
3374
- const [, rawQuery] = splitUrl(url);
3375
- let canon = canonicalQuery(rawQuery);
3376
- if (!canon.split("&").filter(Boolean).map((pair) => pair.split("=", 1)[0]).includes("uifid") && options.uifid) {
3377
- const appended = `uifid=${encodeURIComponent(options.uifid)}`;
3378
- canon = canon ? `${canon}&${appended}` : appended;
3379
- }
3380
- const signedQuery = canon ? `${canon}&${SECSDK_TS_KEY}=${ts}` : `${SECSDK_TS_KEY}=${ts}`;
3381
- let uifidValue = "";
3382
- for (const pair of signedQuery.split("&")) if (pair.startsWith("uifid=")) {
3383
- uifidValue = unquotePlusLoose(pair.slice(6));
3384
- break;
3385
- }
3386
- if (!uifidValue && options.uifid) uifidValue = options.uifid;
3387
- const plain = `${uifidValue}_${ts}_${WEBSIGN_CONST}_${signedQuery}`;
3388
- return {
3389
- ts,
3390
- signature: node_crypto.default.createHash("md5").update(plain, "utf8").digest("hex"),
3391
- signedQuery
3392
- };
3393
- };
3394
- /**
3395
- * 返回可直接发送的完整 URL(规范化 query + timestamp + 签名)。
2521
+ * 真实浏览器发这些请求的顺序是 **webid → msToken → a_bogus / x_bogus → secsdk**,每一步都改
2522
+ * query,颠倒任意一步签名就不成立。顺序不靠这里保证,由 `SignPhase` 的固定次序保证。
3396
2523
  *
3397
- * 服务端按收到的 query 校验签名,所以必须发这个返回值,不能拿原始 URL 直接发。
2524
+ * 其中 webid 与 secsdk 那两段都是「命中才动、否则原样返回」,对不相关的端点是无操作,
2525
+ * 所以可以无条件套用 —— 详见 `./steps.ts` 文件头。
3398
2526
  *
3399
- * @param url - 完整 API URL
3400
- * @param options - 时间戳与 uifid 兜底值
3401
- * @returns 带签名的完整 URL
3402
- */
3403
- const signSecsdkWebUrl = (url, options = {}) => {
3404
- const [base] = splitUrl(url);
3405
- const { signature, signedQuery } = signSecsdkWebQuery(url, options);
3406
- return `${base}?${signedQuery}&${SECSDK_SIG_KEY}=${signature}`;
3407
- };
3408
- /**
3409
- * 对受保护接口补上 secsdk 签名;path 不在策略表里时原样返回。
2527
+ * ## 为什么 secsdk 没有自己的注册名
3410
2528
  *
3411
- * 这是给取数层用的唯一入口 —— 无条件套在 URL 上即可,不需要调用方判断端点。
2529
+ * 它对策略表外的 path 是无操作,无条件复合进这两个名字是安全的;而 `sign` 是单槽位,
2530
+ * 另起一个名字只会逼出 `'a-bogus+secsdk'` 这种复合命名。要精细控制的调用方给 step
2531
+ * 清单,不是加注册名。
3412
2532
  *
3413
- * @param url - 完整 API URL(应当是**最后一步**,即 a_bogus 等参数都已拼好之后)
3414
- * @param options - cookie / uifid / method / ts
3415
- * @returns 需要加签的返回带签名的 URL,否则原样返回
2533
+ * @module platforms/douyin/sign/signers
3416
2534
  */
3417
- const applySecsdkWebSign = (url, options = {}) => {
3418
- let pathname;
3419
- try {
3420
- pathname = new URL(url).pathname;
3421
- } catch {
3422
- return url;
3423
- }
3424
- if (!isSecsdkProtected(pathname, options.method)) return url;
3425
- const uifid = options.uifid || extractUifidFromCookie(options.cookie);
3426
- return signSecsdkWebUrl(url, {
3427
- ts: options.ts,
3428
- uifid
3429
- });
3430
- };
3431
- //#endregion
3432
- //#region src/platforms/douyin/sign/signers.ts
3433
- /**
3434
- * 抖音签名器(SignFn 形式)。
3435
- *
3436
- * 签名器在入口先校验前置条件:
3437
- * - **AB 需绝对 URL**(以 `http(s)://` 开头)。
3438
- * - **XB 需真实接口形态的长路径**(pathname ≥ 3 段且带查询串)。
3439
- *
3440
- * 前置条件不满足时签名器**抛带明确 message 的错误** —— execute 的
3441
- * 单一 catch 把它归因为 `kind: 'internal'` / `INTERNAL_ERROR` 收进失败
3442
- * 信封,调用方不会拿到裸的 `TypeError`。这些条件由 `build` 保证满足
3443
- * (URL 构造器只产出合法绝对地址),签名器里的校验只是防线。
3444
- *
3445
- * ## 为什么这两个签名器前后各多一步
3446
- *
3447
- * 真实浏览器发这些请求的顺序是 **webid → a_bogus / x_bogus → secsdk**,三步都改
3448
- * query,颠倒任意一步签名就不成立。所以两个签名器各自是一条三段的管线,
3449
- * 前后那两段都是「命中才动、否则原样返回」,对不相关的端点是无操作。
3450
- *
3451
- * **前一步 webid**(见 {@link withDouyinWebid}):抖音会拿 query 里的 `webid` 与 cookie
3452
- * 会话交叉校验,对不上就静默回 0 字节。它是服务端下发的、客户端算不出来,所以只在
3453
- * 按 ttwid 缓存命中时才补 —— 冷启动第一次不带(不带是安全的)。签名器的入参是
3454
- * `(spec, ctx)`,能读 `ctx.cookie`。
3455
- *
3456
- * **后一步 secsdk**:`x-secsdk-web-signature` 是抖音主站的第三种签名,浏览器里由
3457
- * secsdk 的 JS 现算。它与 AB / XB 有三点不同(详见 {@link applySecsdkWebSign} 所在模块):
3458
- *
3459
- * 1. **它改写整条 URL**,不是返回一个参数值 —— 签名算的是规范化后的 query,
3460
- * 服务端也按收到的 query 校验;
3461
- * 2. **只对 SDK 策略表里的 path 生效**(14 个 GET / 6 个 POST),其余原样返回,
3462
- * 所以可以无条件套用;
3463
- * 3. **必须是最后一步**。
3464
- *
3465
- * 因为第 2 点是无条件安全的,它没有单独注册成第三个签名器名,而是复合进这两个 ——
3466
- * `sign` 是单槽位,另起一个名字只会逼出 `'a-bogus+secsdk'` 这种复合命名。
3467
- * 影响面:`musicInfo`(`music/detail`)、作品详情、用户作品、喜欢列表都在策略表内。
3468
- * `sign: false` 的两条(`emojiList` / `search`)与四条免鉴权端点不经过这里,
3469
- * 它们的 path 也都不在策略表里、也拿不到 webid。
3470
- */
3471
- /** AB 前置条件:绝对 URL(`http(s)://` 开头) */
3472
- const isAbsoluteUrl = (url) => /^https?:\/\//.test(url);
3473
- /** XB 前置条件:真实接口形态 —— pathname 至少 3 段且带查询串 */
3474
- const isApiLikePath = (url) => {
3475
- if (!isAbsoluteUrl(url)) return false;
3476
- const parsed = new URL(url);
3477
- return parsed.pathname.split("/").filter(Boolean).length >= 3 && parsed.search.length > 0;
3478
- };
3479
- /**
3480
- * 收尾一步:策略表内的 path 补上 `x-secsdk-web-signature`,表外原样返回。
3481
- *
3482
- * `uifid` 从本次请求的 cookie 里取(query 里已有就用 query 的)。
3483
- * @param spec - 已经加过 a_bogus / x_bogus 的请求描述
3484
- * @param cookie - 本次调用使用的 cookie
3485
- * @returns 需要加签时返回改写过 URL 的请求描述,否则原样返回
3486
- */
3487
- const withSecsdk = (spec, cookie) => {
3488
- const url = applySecsdkWebSign(spec.url, {
3489
- cookie,
3490
- method: spec.method
3491
- });
3492
- return url === spec.url ? spec : {
3493
- ...spec,
3494
- url
3495
- };
3496
- };
3497
- /**
3498
- * `a_bogus` 签名器(`sign: 'a-bogus'`)。
3499
- *
3500
- * 前置条件:URL 必须是绝对地址。不满足时抛错,由 execute 归因为
3501
- * `kind: 'internal'`。
3502
- * @param spec - 请求描述(`url` 参与签名)
3503
- * @param ctx - 执行上下文(`userAgent` 用于签名,`cookie` 用于取 secsdk 的 uifid)
3504
- * @returns 带 `a_bogus` 的请求描述;path 在 secsdk 策略表内时再补一层 `x-secsdk-web-signature`
3505
- */
3506
- const aBogusSigner = (spec, ctx) => {
3507
- if (!isAbsoluteUrl(spec.url)) throw new Error(`a_bogus 前置条件不满足:URL 必须是绝对地址(收到 "${spec.url}")`);
3508
- const url = new URL(withDouyinWebid(spec.url, ctx.cookie));
3509
- url.searchParams.set("a_bogus", douyinSign$1.AB(url.toString(), ctx.userAgent));
3510
- return withSecsdk({
3511
- ...spec,
3512
- url: url.toString()
3513
- }, ctx.cookie);
3514
- };
3515
- /**
3516
- * `x_bogus` 签名器(`sign: 'x-bogus'`)。
3517
- *
3518
- * 前置条件:真实接口形态的长路径(≥3 段且带查询串)。不满足时抛错,由
3519
- * execute 归因为 `kind: 'internal'`。
3520
- * @param spec - 请求描述(`url` 参与签名)
3521
- * @param ctx - 执行上下文(`userAgent` 用于签名)
3522
- * @returns 带 `X-Bogus` 的请求描述;path 在 secsdk 策略表内时再补一层 `x-secsdk-web-signature`
3523
- */
3524
- const xBogusSigner = (spec, ctx) => {
3525
- if (!isApiLikePath(spec.url)) throw new Error(`x_bogus 前置条件不满足:URL 需真实接口形态的长路径(≥3 段且带查询串,收到 "${spec.url}")`);
3526
- const url = new URL(withDouyinWebid(spec.url, ctx.cookie));
3527
- url.searchParams.set("X-Bogus", douyinSign$1.XB(url.toString(), ctx.userAgent));
3528
- return withSecsdk({
3529
- ...spec,
3530
- url: url.toString()
3531
- }, ctx.cookie);
3532
- };
3533
2535
  /**
3534
2536
  * 平台签名器表,交给 runtime 的 `signers` 查名。
3535
2537
  *
2538
+ * 两个名字都取 **184** 那档 msToken 长度:这张表只在 `client.<平台>.request` 那条路上被查到,
2539
+ * 而那条路打的是「本库还没收录的接口」—— 猜不出该用 184(作品详情类)还是 116(评论 / 音乐类),
2540
+ * 统一取前者。收录进端点的接口各自在声明里写准确的那一档,不经过这里。
2541
+ *
3536
2542
  * 用 `satisfies` 而非显式 `: Record<string, SignFn>` 返回:后者会把键联合抹成宽
3537
2543
  * `string`,而 {@link DouyinSignerName} 要靠 `keyof` 从这张表推导出精确的名字联合。
2544
+ * @returns 注册名 → 签名器
3538
2545
  */
3539
2546
  const createDouyinSigners = () => ({
3540
- "a-bogus": aBogusSigner,
3541
- "x-bogus": xBogusSigner
2547
+ "a-bogus": stepsToSigner(require_steps.douyinBogus(184)),
2548
+ "x-bogus": stepsToSigner(require_steps.douyinXBogus(184))
3542
2549
  });
3543
2550
  //#endregion
3544
2551
  //#region src/platforms/kuaishou/captcha.ts
@@ -3686,7 +2693,7 @@ const parseKuaishouCaptcha = (raw) => {
3686
2693
  const KUAISHOU_DEFAULT_TIMEOUT = 1e4;
3687
2694
  /** 从请求配置里取 UA:大小写不敏感,取不到用集中维护的默认值 */
3688
2695
  const userAgentOf$1 = (requestConfig) => {
3689
- return new 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";
2696
+ 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";
3690
2697
  };
3691
2698
  /**
3692
2699
  * 构造快手默认请求配置。
@@ -3700,7 +2707,7 @@ const createKuaishouConfig = (cookie, requestConfig) => {
3700
2707
  const userAgent = userAgentOf$1(requestConfig);
3701
2708
  const secChUa = generateSecChUa(userAgent);
3702
2709
  return {
3703
- headers: new 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),
2710
+ 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),
3704
2711
  requestConfig: {
3705
2712
  timeout: KUAISHOU_DEFAULT_TIMEOUT,
3706
2713
  ...requestConfig
@@ -3903,1140 +2910,20 @@ const kuaishouResultVerdict = (raw) => {
3903
2910
  };
3904
2911
  };
3905
2912
  //#endregion
3906
- //#region src/platforms/kuaishou/sign/hudr.ts
3907
- const KUAISHOU_HUDR_PREFIX = "HUDR_";
3908
- const KUAISHOU_HUDR_MASK_BYTE = 35;
3909
- const KUAISHOU_HUDR_BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
3910
- const KUAISHOU_HUDR_CHACHA_KEY = [
3911
- 4183807412,
3912
- 394484062,
3913
- 1106561997,
3914
- 2378328696,
3915
- 630790222,
3916
- 2546784104,
3917
- 2891127470,
3918
- 1922531795
3919
- ];
3920
- const KUAISHOU_HUDR_CHACHA_NONCE = [
3921
- 2215853858,
3922
- 1643070585,
3923
- 1849059804
3924
- ];
3925
- /** 将字符串转换为 Unicode code point 数组。 */
3926
- const toCodePointArray = (value) => {
3927
- return Array.from(value).map((char) => char.codePointAt(0) ?? 0);
3928
- };
3929
- /** 以小端序输出固定长度字节数组。 */
3930
- const toLittleEndianBytes = (value, size = 4) => {
3931
- if (size >= 4 && value >= Math.pow(2, 32)) return [
3932
- 255,
3933
- 255,
3934
- 255,
3935
- 255
3936
- ];
3937
- const result = [];
3938
- for (let index = 0; index <= size - 1; index++) result[index] = value >>> 8 * index & 255;
3939
- return result;
3940
- };
3941
- /** 对字节切片执行标准 Base64 编码。 */
3942
- const encodeBase64Slice = (bytes, start, end) => {
3943
- const result = [];
3944
- for (let index = start; index < end; index += 3) {
3945
- const value = (bytes[index] << 16 & 16711680) + (bytes[index + 1] << 8 & 65280) + (bytes[index + 2] & 255);
3946
- result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 18 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value >> 12 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value >> 6 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value & 63]);
3947
- }
3948
- return result.join("");
3949
- };
3950
- /** 快手 `HUDR_` 使用的 Base64URL 变体编码。 */
3951
- const encodeBase64Url = (bytes) => {
3952
- const result = [];
3953
- const remainder = bytes.length % 3;
3954
- const tripletLength = bytes.length - remainder;
3955
- for (let index = 0; index < tripletLength; index += 16383) {
3956
- const end = tripletLength < index + 16383 ? tripletLength : index + 16383;
3957
- result.push(encodeBase64Slice(bytes, index, end));
3958
- }
3959
- if (remainder === 1) {
3960
- const value = bytes[bytes.length - 1];
3961
- result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 2], KUAISHOU_HUDR_BASE64_ALPHABET[value << 4 & 63], "==");
3962
- } else if (remainder === 2) {
3963
- const value = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
3964
- result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 10], KUAISHOU_HUDR_BASE64_ALPHABET[value >> 4 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value << 2 & 63], "=");
3965
- }
3966
- return result.join("").replace(/[+/=]/g, (char) => {
3967
- if (char === "+") return "-";
3968
- if (char === "/") return "_";
3969
- return ".";
3970
- });
3971
- };
3972
- /**
3973
- * 快手 `HUDR_` 所用的 ChaCha20 变体实现。
3974
- *
3975
- * 该实现并非通用密码库封装,而是为对齐页面 `$encode`
3976
- * 数据流而最小化保留的局部算法。
3977
- */
3978
- var KuaishouChaChaCipher = class {
3979
- key;
3980
- nonce;
3981
- wordIndex = 0;
3982
- state = new Array(16).fill(0);
3983
- constructor(key, nonce) {
3984
- this.key = key;
3985
- this.nonce = nonce;
3986
- }
3987
- rotateLeft(value, shift) {
3988
- return (value << shift | value >>> 32 - shift) >>> 0;
3989
- }
3990
- quarterRound(target, a, b, c, d) {
3991
- target[a] = target[a] + target[b] >>> 0;
3992
- target[d] ^= target[a];
3993
- target[d] = this.rotateLeft(target[d], 16);
3994
- target[c] = target[c] + target[d] >>> 0;
3995
- target[b] ^= target[c];
3996
- target[b] = this.rotateLeft(target[b], 12);
3997
- target[a] = target[a] + target[b] >>> 0;
3998
- target[d] ^= target[a];
3999
- target[d] = this.rotateLeft(target[d], 8);
4000
- target[c] = target[c] + target[d] >>> 0;
4001
- target[b] ^= target[c];
4002
- target[b] = this.rotateLeft(target[b], 7);
4003
- }
4004
- refillBlock() {
4005
- const working = this.state.slice();
4006
- for (let round = 0; round < 20; round += 2) {
4007
- this.quarterRound(working, 0, 4, 8, 12);
4008
- this.quarterRound(working, 1, 5, 9, 13);
4009
- this.quarterRound(working, 2, 6, 10, 14);
4010
- this.quarterRound(working, 3, 7, 11, 15);
4011
- this.quarterRound(working, 0, 5, 10, 15);
4012
- this.quarterRound(working, 1, 6, 11, 12);
4013
- this.quarterRound(working, 2, 7, 8, 13);
4014
- this.quarterRound(working, 3, 4, 9, 14);
4015
- }
4016
- const mixedState = new Array(16).fill(0);
4017
- for (let index = 0; index < 16; index++) mixedState[index] = working[index] + this.state[index] >>> 0;
4018
- return mixedState;
4019
- }
4020
- encrypt(input) {
4021
- this.wordIndex = 0;
4022
- this.state[0] = 394484062;
4023
- this.state[1] = 2378328696;
4024
- this.state[2] = 630790222;
4025
- this.state[3] = 1922531795;
4026
- for (let index = 0; index < 8; index++) this.state[index + 4] = this.key[index] >>> 0;
4027
- this.state[12] = 1;
4028
- this.state[13] = this.nonce[0] >>> 0;
4029
- this.state[14] = this.nonce[1] >>> 0;
4030
- this.state[15] = this.nonce[2] >>> 0;
4031
- let mixedState = this.refillBlock();
4032
- const output = new Uint8Array(input.length);
4033
- for (let index = 0; index < input.length; index++) {
4034
- if (this.wordIndex === 64) {
4035
- this.state[12] = this.state[12] + 1 >>> 0;
4036
- mixedState = this.refillBlock();
4037
- this.wordIndex = 0;
4038
- }
4039
- const keystreamByte = mixedState[this.wordIndex >> 2] >>> ((this.wordIndex & 3) << 3) & 255;
4040
- this.wordIndex += 1;
4041
- output[index] = input[index] ^ keystreamByte;
4042
- }
4043
- return output;
4044
- }
4045
- };
4046
- /**
4047
- * 构造 `HUDR_` 头部中的 `infoCache` 字段。
4048
- *
4049
- * @param scriptCount - 页面脚本数量
4050
- * @returns `HUDR_` 载荷中的脚本信息缓存字节
4051
- */
4052
- const buildKuaishouHudrInfoCache = (scriptCount = 0) => {
4053
- return [68, 0].concat(toLittleEndianBytes(scriptCount, 4));
4054
- };
4055
- /**
4056
- * 构造快手 `HUDR_` 原始载荷。
4057
- *
4058
- * @param context - 生成 `HUDR_` 所需的上下文参数
4059
- * @returns 尚未异或和加密的原始字节载荷
4060
- */
4061
- const buildKuaishouHudrPayload = (context) => {
4062
- const stackTail = context.secs?.s ?? "";
4063
- const secsCount = context.secs?.c ?? 0;
4064
- const count = context.count || 0;
4065
- return [
4066
- 45,
4067
- 61,
4068
- 0,
4069
- 2
4070
- ].concat(buildKuaishouHudrInfoCache(context.scriptCount)).concat([112, 0], toLittleEndianBytes(count, 4)).concat([114, 1], toLittleEndianBytes(stackTail.length, 2), toCodePointArray(stackTail)).concat([115, 0], toLittleEndianBytes(secsCount, 4));
4071
- };
4072
- /**
4073
- * 对 `HUDR_` 原始载荷执行页面同款掩码异或。
4074
- *
4075
- * @param payload - 原始 `HUDR_` 字节载荷
4076
- * @returns 掩码处理后的字节数组
4077
- */
4078
- const maskKuaishouHudrPayload = (payload) => {
4079
- const masked = new Uint8Array(payload.length);
4080
- for (let index = 0; index < payload.length; index++) masked[index] = KUAISHOU_HUDR_MASK_BYTE ^ payload[index];
4081
- return masked;
4082
- };
4083
- /**
4084
- * 推导快手签名中的 `HUDR_` 段。
4085
- *
4086
- * @param context - 生成 `HUDR_` 所需的上下文参数
4087
- * @returns `HUDR_` 的完整结果及若干中间态,便于对拍与调试
4088
- */
4089
- const deriveKuaishouHudrBody = (context) => {
4090
- const payload = buildKuaishouHudrPayload(context);
4091
- const maskedPayload = maskKuaishouHudrPayload(payload);
4092
- const encrypted = new KuaishouChaChaCipher(KUAISHOU_HUDR_CHACHA_KEY, KUAISHOU_HUDR_CHACHA_NONCE).encrypt(maskedPayload);
4093
- const body = encodeBase64Url(encrypted);
4094
- return {
4095
- body,
4096
- full: `${KUAISHOU_HUDR_PREFIX}${body}`,
4097
- infoCache: buildKuaishouHudrInfoCache(context.scriptCount),
4098
- maskedPayload,
4099
- nextCount: context.count + 1
4100
- };
4101
- };
4102
- //#endregion
4103
- //#region src/platforms/kuaishou/sign/primitives.ts
4104
- const KUAISHOU_BLAKE2S_IV = [
4105
- 2837534710,
4106
- 2845986804,
4107
- 2436420605,
4108
- 706843635,
4109
- 719254516,
4110
- 2557931286,
4111
- 2596197199,
4112
- 2432949778
4113
- ];
4114
- const KUAISHOU_BLAKE2S_SIGMA = [
4115
- [
4116
- 0,
4117
- 1,
4118
- 2,
4119
- 3,
4120
- 4,
4121
- 5,
4122
- 6,
4123
- 7,
4124
- 8,
4125
- 9,
4126
- 10,
4127
- 11,
4128
- 12,
4129
- 13,
4130
- 14,
4131
- 15
4132
- ],
4133
- [
4134
- 14,
4135
- 10,
4136
- 4,
4137
- 8,
4138
- 9,
4139
- 15,
4140
- 13,
4141
- 6,
4142
- 1,
4143
- 12,
4144
- 0,
4145
- 2,
4146
- 11,
4147
- 7,
4148
- 5,
4149
- 3
4150
- ],
4151
- [
4152
- 11,
4153
- 8,
4154
- 12,
4155
- 0,
4156
- 5,
4157
- 2,
4158
- 15,
4159
- 13,
4160
- 10,
4161
- 14,
4162
- 3,
4163
- 6,
4164
- 7,
4165
- 1,
4166
- 9,
4167
- 4
4168
- ],
4169
- [
4170
- 7,
4171
- 9,
4172
- 3,
4173
- 1,
4174
- 13,
4175
- 12,
4176
- 11,
4177
- 14,
4178
- 2,
4179
- 6,
4180
- 5,
4181
- 10,
4182
- 4,
4183
- 0,
4184
- 15,
4185
- 8
4186
- ],
4187
- [
4188
- 9,
4189
- 0,
4190
- 5,
4191
- 7,
4192
- 2,
4193
- 4,
4194
- 10,
4195
- 15,
4196
- 14,
4197
- 1,
4198
- 11,
4199
- 12,
4200
- 6,
4201
- 8,
4202
- 3,
4203
- 13
4204
- ],
4205
- [
4206
- 2,
4207
- 12,
4208
- 6,
4209
- 10,
4210
- 0,
4211
- 11,
4212
- 8,
4213
- 3,
4214
- 4,
4215
- 13,
4216
- 7,
4217
- 5,
4218
- 15,
4219
- 14,
4220
- 1,
4221
- 9
4222
- ],
4223
- [
4224
- 12,
4225
- 5,
4226
- 1,
4227
- 15,
4228
- 14,
4229
- 13,
4230
- 4,
4231
- 10,
4232
- 0,
4233
- 7,
4234
- 6,
4235
- 3,
4236
- 9,
4237
- 2,
4238
- 8,
4239
- 11
4240
- ],
4241
- [
4242
- 13,
4243
- 11,
4244
- 7,
4245
- 14,
4246
- 12,
4247
- 1,
4248
- 3,
4249
- 9,
4250
- 5,
4251
- 0,
4252
- 15,
4253
- 4,
4254
- 8,
4255
- 6,
4256
- 2,
4257
- 10
4258
- ],
4259
- [
4260
- 6,
4261
- 15,
4262
- 14,
4263
- 9,
4264
- 11,
4265
- 3,
4266
- 0,
4267
- 8,
4268
- 12,
4269
- 2,
4270
- 13,
4271
- 7,
4272
- 1,
4273
- 4,
4274
- 10,
4275
- 5
4276
- ],
4277
- [
4278
- 10,
4279
- 2,
4280
- 8,
4281
- 4,
4282
- 7,
4283
- 6,
4284
- 1,
4285
- 5,
4286
- 15,
4287
- 11,
4288
- 9,
4289
- 14,
4290
- 3,
4291
- 12,
4292
- 13,
4293
- 0
4294
- ]
4295
- ];
4296
- const KUAISHOU_CTS_STATE_VECTOR = new Int8Array([
4297
- 98,
4298
- 0,
4299
- 0,
4300
- 128,
4301
- 49,
4302
- 117,
4303
- 185,
4304
- 253,
4305
- 224,
4306
- 172,
4307
- 104,
4308
- 36,
4309
- 223,
4310
- 155,
4311
- 87,
4312
- 19,
4313
- 32,
4314
- 0,
4315
- 0,
4316
- 64,
4317
- 2,
4318
- 0,
4319
- 0,
4320
- 16,
4321
- 255,
4322
- 255,
4323
- 255,
4324
- 127,
4325
- 255,
4326
- 255,
4327
- 255,
4328
- 63,
4329
- 0,
4330
- 0,
4331
- 0,
4332
- 240,
4333
- 0,
4334
- 0,
4335
- 0,
4336
- 192,
4337
- 0,
4338
- 0,
4339
- 0,
4340
- 128,
4341
- 255,
4342
- 255,
4343
- 255,
4344
- 15
4345
- ]);
4346
- const rotateRight = (value, shift) => {
4347
- return (value >>> shift | value << 32 - shift) >>> 0;
4348
- };
4349
- const toUtf8Int8Array = (value) => {
4350
- return new Int8Array(unescape(encodeURIComponent(value)).split("").map((char) => char.charCodeAt(0) || 0));
4351
- };
4352
- const toHex32 = (value) => {
4353
- return (value >>> 0).toString(16).padStart(8, "0");
4354
- };
4355
- const blake2sQuarterRound = (state, a, b, c, d, x, y) => {
4356
- state[a] = state[a] + state[b] + x >>> 0;
4357
- state[d] = rotateRight(state[d] ^ state[a], 16);
4358
- state[c] = state[c] + state[d] >>> 0;
4359
- state[b] = rotateRight(state[b] ^ state[c], 12);
4360
- state[a] = state[a] + state[b] + y >>> 0;
4361
- state[d] = rotateRight(state[d] ^ state[a], 8);
4362
- state[c] = state[c] + state[d] >>> 0;
4363
- state[b] = rotateRight(state[b] ^ state[c], 7);
4364
- };
4365
- const blake2sCompress = (hash, words, offset, counter, length, isLastBlock) => {
4366
- const work = new Array(16).fill(0);
4367
- const block = new Array(16).fill(0);
4368
- for (let index = 0; index < 8; index++) {
4369
- work[index] = hash[index] >>> 0;
4370
- work[index + 8] = KUAISHOU_BLAKE2S_IV[index] >>> 0;
4371
- }
4372
- work[12] ^= counter >>> 0;
4373
- if (isLastBlock) work[14] ^= 4294967295;
4374
- for (let index = 0; index < length; index++) block[index % 16] ^= words[offset + index] >>> 0;
4375
- for (const sigma of KUAISHOU_BLAKE2S_SIGMA) {
4376
- blake2sQuarterRound(work, 0, 4, 8, 12, block[sigma[0]], block[sigma[1]]);
4377
- blake2sQuarterRound(work, 1, 5, 9, 13, block[sigma[2]], block[sigma[3]]);
4378
- blake2sQuarterRound(work, 2, 6, 10, 14, block[sigma[4]], block[sigma[5]]);
4379
- blake2sQuarterRound(work, 3, 7, 11, 15, block[sigma[6]], block[sigma[7]]);
4380
- blake2sQuarterRound(work, 0, 5, 10, 15, block[sigma[8]], block[sigma[9]]);
4381
- blake2sQuarterRound(work, 1, 6, 11, 12, block[sigma[10]], block[sigma[11]]);
4382
- blake2sQuarterRound(work, 2, 7, 8, 13, block[sigma[12]], block[sigma[13]]);
4383
- blake2sQuarterRound(work, 3, 4, 9, 14, block[sigma[14]], block[sigma[15]]);
4384
- }
4385
- for (let index = 0; index < 8; index++) hash[index] = (hash[index] ^ work[index] ^ work[index + 8]) >>> 0;
4386
- return hash;
4387
- };
4388
- const deriveB2hasWords = (value) => {
4389
- const utf8 = toUtf8Int8Array(value);
4390
- const padding = utf8.length % 4 === 0 ? 0 : 4 - utf8.length % 4;
4391
- const padded = new Int8Array(utf8.length + padding);
4392
- for (let index = 0; index < utf8.length; index++) padded[index] = utf8[index];
4393
- const words = new Array(padded.length / 4);
4394
- for (let index = 0; index < padded.length; index += 4) words[index / 4] = new Int32Array(padded.slice(index, index + 4).buffer)[0];
4395
- return words;
4396
- };
4397
- const deriveB2hasHash = (words) => {
4398
- const hash = KUAISHOU_BLAKE2S_IV.slice();
4399
- hash[0] ^= 16842784;
4400
- let offset = 0;
4401
- let length = words.length;
4402
- let counter = 0;
4403
- while (length > 64) {
4404
- length -= 64;
4405
- counter += 64;
4406
- blake2sCompress(hash, words, offset, counter, 64, false);
4407
- offset += 64;
4408
- }
4409
- return blake2sCompress(hash, words, offset, counter + length, length, true);
4410
- };
4411
- /**
4412
- * 计算快手页面中的 `b2has` 字符串。
4413
- *
4414
- * 这是后续 `b2sa` / `cts` 链路的起点。
4415
- *
4416
- * @param value - 原始输入字符串
4417
- * @returns `b2has` 结果字符串
4418
- */
4419
- const deriveKuaishouB2has = (value) => {
4420
- return deriveB2hasHash(deriveB2hasWords(value)).map(toHex32).join("");
4421
- };
4422
- /**
4423
- * 将输入字符串转换为快手 `b2sa` 所需的字节数组。
4424
- *
4425
- * @param value - 原始输入字符串
4426
- * @returns `b2has` 结果转 UTF-8 后的 Int8Array
4427
- */
4428
- const deriveKuaishouB2sa = (value) => {
4429
- return toUtf8Int8Array(deriveKuaishouB2has(value));
4430
- };
4431
- const createKuaishouCtsState = () => {
4432
- return {
4433
- s: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(12, 16).buffer)[0],
4434
- u: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(8, 12).buffer)[0],
4435
- c: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(4, 8).buffer)[0],
4436
- l: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(0, 4).buffer)[0],
4437
- p: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(16, 20).buffer)[0],
4438
- f: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(20, 24).buffer)[0],
4439
- d: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(24, 28).buffer)[0],
4440
- y: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(28, 32).buffer)[0],
4441
- h: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(44, 48).buffer)[0],
4442
- E: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(40, 44).buffer)[0],
4443
- m: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(36, 40).buffer)[0],
4444
- b: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(32, 36).buffer)[0]
4445
- };
4446
- };
4447
- const seedKuaishouCtsState = (state, seed) => {
4448
- const chars = new Int8Array(seed.split("").map((char) => char.codePointAt(0) ?? 0)).slice(0, seed.length);
4449
- for (let index = 0; index < 4; index++) {
4450
- state.s = (state.s <<= 8) | chars[index + 4];
4451
- state.u = (state.u <<= 8) | chars[index + 4];
4452
- state.c = (state.c <<= 8) | chars[index + 4];
4453
- }
4454
- if (state.s === 0) state.s = 324508639;
4455
- if (state.u === 0) state.u = 610839776;
4456
- if (state.c === 0) state.c = 4256789809;
4457
- };
4458
- const deriveKuaishouCtsByte = (state, value) => {
4459
- let result = 0;
4460
- let rightBit = 1 & state.u;
4461
- let leftBit = 1 & state.c;
4462
- for (let index = 0; index < 8; index++) {
4463
- if (1 & state.s) {
4464
- state.s = state.s ^ state.l >> 1 & 4294967295 | state.E;
4465
- rightBit = 1 & state.u ? (state.u = state.u ^ state.p >> 1 & 4294967295 | state.m, 1) : (state.u = state.u >> 1 & 4294967295 & state.y, 0);
4466
- } else {
4467
- state.s = state.s >> 1 & 4294967295 & state.d;
4468
- leftBit = 1 & state.c ? (state.c = state.c ^ state.f >> 1 & 4294967295 | state.b, 1) : (state.c = state.c >> 1 & 4294967295 & state.h, 0);
4469
- }
4470
- const mixed = result << 1 & 4294967295 | rightBit ^ leftBit;
4471
- result = mixed > 127 ? mixed - 256 : mixed < -128 ? mixed + 256 : mixed;
4472
- }
4473
- return value ^ result + 3;
4474
- };
4475
- /**
4476
- * 执行快手页面中的 `cts` 字节流变换。
4477
- *
4478
- * 该变换会作用在 `b2sa` 的输出上,随后被 `$HE_` 使用。
4479
- *
4480
- * @param input - 输入字节数组
4481
- * @returns 变换后的字节数组
4482
- */
4483
- const deriveKuaishouCts = (input) => {
4484
- const state = createKuaishouCtsState();
4485
- seedKuaishouCtsState(state, "Vuz4fCHxn1CO");
4486
- const result = new Int8Array(input.length);
4487
- for (let index = 0; index < input.length; index++) result[index] = deriveKuaishouCtsByte(state, input[index]);
4488
- return result;
4489
- };
4490
- /**
4491
- * 将字节数组编码为小写 hex 字符串。
4492
- *
4493
- * @param bytes - 任意可按字节访问的数组
4494
- * @returns 小写 hex 字符串
4495
- */
4496
- const bytesToLowerHex = (bytes) => {
4497
- let result = "";
4498
- for (const byte of Array.from(bytes)) {
4499
- const value = byte & 255;
4500
- result += value === 0 ? "00" : `${value < 16 ? "0" : ""}${value.toString(16)}`;
4501
- }
4502
- return result;
4503
- };
4504
- /**
4505
- * 将 hex 字符串还原为带符号字节数组。
4506
- *
4507
- * @param value - 偶数字节长度的 hex 字符串
4508
- * @returns 带符号 byte 数组
4509
- */
4510
- const hexToSignedBytes = (value) => {
4511
- const result = [];
4512
- for (let index = 0; index < value.length; index += 2) {
4513
- const parsed = parseInt(value.slice(index, index + 2), 16);
4514
- const range = Math.pow(2, value.slice(index, index + 2).length / 2 * 8);
4515
- result.push(parsed > range / 2 - 1 ? parsed - range : parsed);
4516
- }
4517
- return result;
4518
- };
4519
- /**
4520
- * 对两个字节数组执行循环异或。
4521
- *
4522
- * @param left - 被处理的字节数组
4523
- * @param right - 异或掩码字节数组
4524
- * @returns 异或后的 Int8Array
4525
- */
4526
- const xorByteArrays = (left, right) => {
4527
- let index = 0;
4528
- const result = new Int8Array(left.length);
4529
- while (index < left.length) for (let rightIndex = 0; rightIndex < right.length && index < left.length; rightIndex++) {
4530
- result[index] = left[index] ^ 255 & right[rightIndex];
4531
- index += 1;
4532
- }
4533
- return result;
4534
- };
4535
- /**
4536
- * 将数值编码为固定长度的小端 hex。
4537
- *
4538
- * @param value - 原始数值
4539
- * @param size - 输出字节长度
4540
- * @returns 小端序 hex 字符串
4541
- */
4542
- const toLittleEndianHex = (value, size) => {
4543
- const normalized = BigInt.asUintN(size * 8, typeof value === "bigint" ? value : BigInt(Math.trunc(value)));
4544
- let result = "";
4545
- for (let index = 0; index < size; index++) {
4546
- const byte = Number(normalized >> BigInt(8 * index) & 255n);
4547
- result += byte.toString(16).padStart(2, "0");
4548
- }
4549
- return result;
4550
- };
4551
- /**
4552
- * 计算快手 `$HE_` 所使用的 8-bit LRC 校验值。
4553
- *
4554
- * @param sourceHex - 原始 hex 字符串
4555
- * @returns 单字节 LRC 的 hex 表示
4556
- */
4557
- const computeKuaishouLrcHex = (sourceHex) => {
4558
- return (-hexToSignedBytes(sourceHex).reduce((total, value) => total + (value & 255), 0) & 255).toString(16).padStart(2, "0");
4559
- };
4560
- /**
4561
- * 对 `$HE_` 的 preHex + checksum 执行最终变换。
4562
- *
4563
- * 页面实现会以最后一个字节为 key,对前面的字节逐个异或,
4564
- * 最后再整体转回小写 hex。
4565
- *
4566
- * @param prefixHex - 不含最终 LRC 的前缀 hex
4567
- * @param checksumHex - 最终 LRC hex
4568
- * @returns `$HE_` 最终输出的 hex 字符串
4569
- */
4570
- const transformKuaishouHeHex = (prefixHex, checksumHex) => {
4571
- const input = hexToSignedBytes(`${prefixHex}${checksumHex}`);
4572
- const output = new Int8Array(input.length);
4573
- const xorKey = input[input.length - 1];
4574
- for (let index = 0; index < input.length - 1; index++) output[index] = input[index] ^ xorKey;
4575
- output[input.length - 1] = xorKey;
4576
- return bytesToLowerHex(output);
4577
- };
4578
- //#endregion
4579
- //#region src/platforms/kuaishou/sign/he.ts
4580
- const KUAISHOU_HE_HEADER_HEX = "4B54";
4581
- const KUAISHOU_HE_VERSION_HEX = "cda9";
4582
- const KUAISHOU_HE_STARTUP_MARKER_HEX = "ab";
4583
- const KUAISHOU_HE_FIXED_BODY_HEX = "0100000001";
4584
- const KUAISHOU_HE_INPUT_XOR_MASK = [
4585
- 45,
4586
- 211,
4587
- 69,
4588
- 192
4589
- ];
4590
- const KUAISHOU_HE_COUNTER_XOR_MASK = 3131873467;
4591
- const KUAISHOU_HE_TIME_XOR_MASK = 3360347992n;
4592
- const KUAISHOU_HE_TAIL_HEX = "9b563eda7b563e";
4593
- const KUAISHOU_HE_RANDOM_MAX = 0xffffffffffff;
4594
- /**
4595
- * 计算 `$HE_` 中的 hash field。
4596
- *
4597
- * 它由 `signInput + HUDRBody` 经 `b2sa -> cts -> 截断 -> 异或掩码`
4598
- * 这条链路导出,是 `$HE_` 中与接口输入最直接相关的一段。
4599
- *
4600
- * @param signInput - 快手 `__NS_hxfalcon` 的 sign input
4601
- * @param hudrBody - `HUDR_` 去前缀后的主体
4602
- * @returns `$HE_` 载荷中的 4 字节 hash field hex
4603
- */
4604
- const deriveKuaishouHeHashFieldHex = (signInput, hudrBody) => {
4605
- const hashInput = `${signInput}HUDR_${hudrBody}`;
4606
- const digestHex = bytesToLowerHex(deriveKuaishouCts(deriveKuaishouB2sa(hashInput))).slice(0, 8);
4607
- return bytesToLowerHex(xorByteArrays(hexToSignedBytes(digestHex), KUAISHOU_HE_INPUT_XOR_MASK));
4608
- };
4609
- /**
4610
- * 推导快手签名中的 `$HE_` 段。
4611
- *
4612
- * @param context - 生成 `$HE_` 所需的上下文参数
4613
- * @returns `$HE_` 的最终 hex、中间 hash field 和 preHex
4614
- */
4615
- const deriveKuaishouHeHex = (context) => {
4616
- const random48 = Math.floor(context.randomValue * KUAISHOU_HE_RANDOM_MAX);
4617
- const hashFieldHex = deriveKuaishouHeHashFieldHex(context.signInput, context.hudrBody);
4618
- const timeXor = BigInt(context.timestamp) ^ KUAISHOU_HE_TIME_XOR_MASK;
4619
- const preHex = [
4620
- KUAISHOU_HE_HEADER_HEX,
4621
- KUAISHOU_HE_VERSION_HEX,
4622
- KUAISHOU_HE_STARTUP_MARKER_HEX,
4623
- toLittleEndianHex(context.startupRandom, 6),
4624
- toLittleEndianHex(random48, 6),
4625
- KUAISHOU_HE_FIXED_BODY_HEX,
4626
- toLittleEndianHex(context.count ^ KUAISHOU_HE_COUNTER_XOR_MASK, 4),
4627
- hashFieldHex,
4628
- toLittleEndianHex(timeXor, 6),
4629
- KUAISHOU_HE_TAIL_HEX,
4630
- computeKuaishouLrcHex(KUAISHOU_HE_TAIL_HEX)
4631
- ].join("");
4632
- return {
4633
- finalHex: transformKuaishouHeHex(preHex, computeKuaishouLrcHex(preHex)),
4634
- hashFieldHex,
4635
- preHex
4636
- };
4637
- };
4638
- /**
4639
- * 一次性推导快手完整纯算法签名。
4640
- *
4641
- * 该方法会先生成 `HUDR_`,再拼出 `$HE_`,最终返回完整
4642
- * `HUDR_...$HE_...` 形式的 `__NS_hxfalcon`。
4643
- *
4644
- * @param context - 快手纯算法签名上下文
4645
- * @returns 完整签名结果及关键中间态
4646
- */
4647
- const deriveKuaishouPureSignature = (context) => {
4648
- const hudr = deriveKuaishouHudrBody(context);
4649
- const he = deriveKuaishouHeHex({
4650
- count: context.count,
4651
- hudrBody: hudr.body,
4652
- randomValue: context.randomValue,
4653
- signInput: context.signInput,
4654
- startupRandom: context.startupRandom,
4655
- timestamp: context.timestamp
4656
- });
4657
- return {
4658
- ...hudr,
4659
- he,
4660
- signResult: `${hudr.full}$HE_${he.finalHex}`
4661
- };
4662
- };
4663
- //#endregion
4664
- //#region src/platforms/kuaishou/sign/helpers.ts
4665
- const SIGN_INPUT_SKIP_KEYWORD$1 = "__NS";
4666
- const KUAISHOU_ANONYMOUS_KWW_KEY$1 = "K8wm5PvY9nX7qJc2";
4667
- const KUAISHOU_ANONYMOUS_KWW_SUFFIX$1 = "###ssrd";
4668
- const KUAISHOU_ANONYMOUS_KWW_ALPHABET$1 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
4669
- /** 创建一份独立的匿名 kww 缓存 */
4670
- const createKuaishouAnonymousKwwCache = () => {
4671
- const cache = {
4672
- value: "",
4673
- get: () => {
4674
- if (!cache.value) cache.value = `${encryptKuaishouAnonymousKwwSeed$1(generateKuaishouAnonymousKwwSeed$1())}${KUAISHOU_ANONYMOUS_KWW_SUFFIX$1}`;
4675
- return cache.value;
4676
- }
4677
- };
4678
- return cache;
4679
- };
4680
- /** 模块级单例缓存;生产代码用 createKuaishouAnonymousKwwCache */
4681
- let kuaishouAnonymousKwwCache$1 = "";
4682
- const compareLexicographically$1 = (left, right) => {
4683
- if (left === right) return 0;
4684
- return left < right ? -1 : 1;
4685
- };
4686
- const toComparableEntry$1 = (key, value) => {
4687
- if (typeof value === "object" && value !== null && !Array.isArray(value)) return `${key}=[object Object]`;
4688
- return `${key}=${String(value)}`;
4689
- };
4690
- const normalizePathname$1 = (urlOrPath) => {
4691
- try {
4692
- return new URL(urlOrPath).pathname;
4693
- } catch {
4694
- return urlOrPath.split("?")[0] ?? urlOrPath;
4695
- }
4696
- };
4697
- const sortSearchParams$1 = (searchParams) => {
4698
- return [...searchParams.entries()].sort(([leftKey], [rightKey]) => compareLexicographically$1(leftKey, rightKey)).reduce((result, [key, value]) => {
4699
- result[key] = value;
4700
- return result;
4701
- }, {});
4702
- };
4703
- /**
4704
- * 解析快手签名所使用的规范路径。
4705
- *
4706
- * 快手部分 `live_api` 接口的公开 URL 与算法内部参与签名的路径并不一致,
4707
- * 因此这里优先使用调用方显式传入的 `signPath`,否则回退到 URL 自身的 pathname。
4708
- *
4709
- * @param urlOrPath - 原始 URL 或路径
4710
- * @param signPath - 调用方声明的规范签名路径
4711
- * @returns 参与签名的规范 pathname
4712
- */
4713
- const resolveKuaishouHxfalconSignPath$1 = (urlOrPath, signPath) => {
4714
- const pathname = normalizePathname$1(signPath ?? urlOrPath);
4715
- if (!pathname.startsWith("/")) throw new Error(`Invalid Kuaishou signing path: ${pathname}`);
4716
- return pathname;
4717
- };
4718
- /**
4719
- * 从请求 URL 构造快手签名载荷。
4720
- *
4721
- * `requestBody` **必须**由调用方透下来:`buildKuaishouHxfalconSignInput` 会把
4722
- * `JSON.stringify(requestBody)` 拼在 sign input 尾部。漏传时校验松的接口
4723
- * (`simple/info` / `comment/list`)仍会放行,而 `photo/info` 一类严格校验的接口
4724
- * 会一律 `result=50 签名验证失败`。
4725
- * 参见 @OduckO 的 kuaishou-parser(GPL-3.0-only)`sign/helpers.ts:78-81`
4726
- * 与其 `TODO.md:193-195`。
4727
- *
4728
- * @param url - 实际请求 URL
4729
- * @param signPath - 可选的规范签名路径
4730
- * @param requestBody - 参与签名的请求体(POST 端点必传,GET 端点保持缺省)
4731
- * @returns 供纯算法签名链路使用的结构化载荷
4732
- */
4733
- const buildKuaishouHxfalconPayload$1 = (url, signPath, requestBody = {}) => {
4734
- const parsedUrl = new URL(url);
4735
- const realPath = resolveKuaishouHxfalconSignPath$1(parsedUrl.pathname, signPath);
4736
- const query = sortSearchParams$1(parsedUrl.searchParams);
4737
- if (!query.caver) throw new Error(`Missing caver query parameter for Kuaishou signing: ${url}`);
4738
- return {
4739
- url: realPath,
4740
- query,
4741
- form: {},
4742
- requestBody
4743
- };
4744
- };
4745
- /**
4746
- * 构造快手 `__NS_hxfalcon` 的 sign input。
4747
- *
4748
- * 该字符串就是后续 `HUDR_` / `$HE_` 两段算法共同依赖的输入材料。
4749
- *
4750
- * @param payload - 已标准化的签名载荷
4751
- * @returns 按快手页面规则拼接后的 sign input
4752
- */
4753
- const buildKuaishouHxfalconSignInput$1 = (payload) => {
4754
- const combinedParams = {
4755
- ...payload.query,
4756
- ...payload.form
4757
- };
4758
- const serializedParams = Object.keys(combinedParams).filter((key) => !key.includes(SIGN_INPUT_SKIP_KEYWORD$1)).map((key) => toComparableEntry$1(key, combinedParams[key])).sort(compareLexicographically$1).join("");
4759
- const requestBody = Object.keys(payload.requestBody).length > 0 ? JSON.stringify(payload.requestBody) : "";
4760
- return `${normalizePathname$1(payload.url)}${serializedParams}${requestBody}`;
4761
- };
4762
- /**
4763
- * 从 Cookie 字符串中提取指定键值。
4764
- *
4765
- * @param cookie - 原始 Cookie 字符串
4766
- * @param key - 目标键名
4767
- * @returns 命中的 Cookie 值;未命中时返回空字符串
4768
- */
4769
- const extractCookieValue$1 = (cookie, key) => {
4770
- if (!cookie?.trim()) return "";
4771
- const pattern = new RegExp(`(?:^|;\\s*)${key}=([^;]*)`);
4772
- return cookie.match(pattern)?.[1] ?? "";
4773
- };
4774
- const generateKuaishouAnonymousKwwSeed$1 = () => {
4775
- let randomPart = "";
4776
- for (let index = 0; index < 8; index++) {
4777
- const randomIndex = Math.floor(Math.random() * 62);
4778
- randomPart += KUAISHOU_ANONYMOUS_KWW_ALPHABET$1[randomIndex];
4779
- }
4780
- return `${Date.now()}|${randomPart}`;
4781
- };
4782
- const encryptKuaishouAnonymousKwwSeed$1 = (seed) => {
4783
- const key = Buffer.from(KUAISHOU_ANONYMOUS_KWW_KEY$1, "utf8");
4784
- const cipher = node_crypto.default.createCipheriv("aes-128-cbc", key, key);
4785
- return Buffer.concat([cipher.update(seed, "utf8"), cipher.final()]).toString("base64");
4786
- };
4787
- /**
4788
- * 生成匿名访问快手 `live_api` 时所需的 `kww`。
4789
- *
4790
- * 页面未持有 `kwfv1` 时,会退回到一段本地 AES 生成值。
4791
- * 传入 `cache` 时按实例缓存(同一实例内复用同一份访客标识);
4792
- * 不传时用模块级单例缓存。
4793
- *
4794
- * @param cache - 可选的实例级匿名 kww 缓存
4795
- * @returns 可用于匿名请求的 `kww`
4796
- */
4797
- const deriveKuaishouAnonymousKww$1 = (cache) => {
4798
- if (cache) return cache.get();
4799
- if (!kuaishouAnonymousKwwCache$1) kuaishouAnonymousKwwCache$1 = `${encryptKuaishouAnonymousKwwSeed$1(generateKuaishouAnonymousKwwSeed$1())}${KUAISHOU_ANONYMOUS_KWW_SUFFIX$1}`;
4800
- return kuaishouAnonymousKwwCache$1;
4801
- };
4802
- /**
4803
- * 解析快手 `kww` 请求头。
4804
- *
4805
- * 当前页面存在两条路径:
4806
- * 1. 若已有 `kwfv1`,则 `kww` 直接复用该值
4807
- * 2. 若还没有访客 Cookie,则退回到页面本地生成的匿名 `kww`
4808
- *
4809
- * @param cookie - 原始 Cookie 字符串
4810
- * @param cache - 可选的实例级匿名 kww 缓存
4811
- * @returns 可用于请求头的 `kww` 值
4812
- */
4813
- const deriveKuaishouKww$1 = (cookie, cache) => {
4814
- const kwfv1 = extractCookieValue$1(cookie, "kwfv1");
4815
- if (kwfv1) return kwfv1;
4816
- return deriveKuaishouAnonymousKww$1(cache);
4817
- };
4818
- //#endregion
4819
- //#region src/platforms/kuaishou/sign/state.ts
4820
- const KUAISHOU_DEFAULT_CAT_VERSION$1 = "2";
4821
- const KUAISHOU_DEFAULT_COUNT$1 = 100;
4822
- const KUAISHOU_SECS_STACK_LIMIT$1 = 100;
4823
- const captureKuaishouEncodeStack$1 = () => {
4824
- try {
4825
- throw new Error();
4826
- } catch (error) {
4827
- return error instanceof Error ? error.stack ?? "" : "";
4828
- }
4829
- };
4830
- /**
4831
- * 计算快手 `SECS.s` 所需的调用栈尾部。
4832
- *
4833
- * 真实页面会取 `Error().stack` 的最后 100 个字符,
4834
- * 这里保持同样的裁剪规则。
4835
- *
4836
- * @param stack - 可选的完整调用栈;不传时会即时捕获
4837
- * @returns 用于 `SECS.s` 的栈尾字符串
4838
- */
4839
- const deriveKuaishouSecsStackTail$1 = (stack = captureKuaishouEncodeStack$1()) => {
4840
- return stack.length > KUAISHOU_SECS_STACK_LIMIT$1 ? stack.slice(-100) : stack;
4841
- };
4842
- /**
4843
- * 构造快手 `window.SECS` 的纯算法等价状态。
4844
- *
4845
- * @param count - 当前签名计数器
4846
- * @param stack - 可选的完整调用栈
4847
- * @returns 包含 `s` 与 `c` 的 `SECS` 状态对象
4848
- */
4849
- const deriveKuaishouSecsState$1 = (count, stack) => {
4850
- return {
4851
- s: deriveKuaishouSecsStackTail$1(stack),
4852
- c: count
4853
- };
4854
- };
4855
- /**
4856
- * 创建一份独立的快手签名运行时状态。
4857
- *
4858
- * 每次调用创建独立副本,`count` / `startupRandom` 随签名器实例走。
4859
- *
4860
- * @returns 新的签名运行时状态(`count` 从默认值 100 起步)
4861
- */
4862
- const createKuaishouPureRuntimeState = () => ({
4863
- catVersion: KUAISHOU_DEFAULT_CAT_VERSION$1,
4864
- count: KUAISHOU_DEFAULT_COUNT$1,
4865
- startupRandom: Date.now()
4866
- });
4867
- //#endregion
4868
- //#region src/platforms/kuaishou/sign/index.ts
4869
- /**
4870
- * 快手签名器(**实例级**)。
4871
- *
4872
- * 签名状态(`count` / `startupRandom` / 匿名 `kww` 缓存)随实例走,不挂在模块级
4873
- * 单例上 —— 同进程内两个 client 的签名互不干扰:
4874
- * - `count` 每次签名递增,但只影响当前实例。
4875
- * - 匿名 `kww` 按实例缓存(同一实例内复用,不同实例各自生成)。
4876
- * - `startupRandom` 按实例生成。
4877
- */
4878
- var KuaishouSigner = class {
4879
- runtimeState;
4880
- anonymousKwwCache;
4881
- constructor() {
4882
- this.runtimeState = createKuaishouPureRuntimeState();
4883
- this.anonymousKwwCache = createKuaishouAnonymousKwwCache();
4884
- }
4885
- /**
4886
- * 获取当前纯算法运行时使用的 `caver`。
4887
- * @returns 快手 `caver` 值
4888
- */
4889
- getCatVersion() {
4890
- return this.runtimeState.catVersion;
4891
- }
4892
- /**
4893
- * 生成快手请求头中的 `kww` 值(匿名分支按实例缓存)。
4894
- * @param cookie - 原始 Cookie 字符串
4895
- * @returns `kww` 请求头值
4896
- */
4897
- generateKww(cookie) {
4898
- return deriveKuaishouKww$1(cookie, this.anonymousKwwCache);
4899
- }
4900
- /**
4901
- * 根据结构化签名载荷生成 `__NS_hxfalcon`。
4902
- *
4903
- * `count` 随实例递增:同一实例内连续签名结果不同(防重放),
4904
- * 但两个实例的 `count` 互不干扰。
4905
- *
4906
- * @param payload - 已标准化的快手签名载荷
4907
- * @returns 包含最终签名串、sign input 与 `caver` 的结果
4908
- */
4909
- generateHxfalconFromPayload(payload) {
4910
- const signInput = buildKuaishouHxfalconSignInput$1(payload);
4911
- const count = this.runtimeState.count;
4912
- const secs = deriveKuaishouSecsState$1(count);
4913
- const scriptCount = globalThis.document?.scripts?.length ?? 0;
4914
- const catVersion = this.runtimeState.catVersion;
4915
- const signResult = deriveKuaishouPureSignature({
4916
- count,
4917
- randomValue: Math.random(),
4918
- scriptCount,
4919
- secs,
4920
- signInput,
4921
- startupRandom: this.runtimeState.startupRandom,
4922
- timestamp: Date.now()
4923
- }).signResult;
4924
- this.runtimeState.count += 1;
4925
- return {
4926
- signResult,
4927
- signInput,
4928
- catVersion
4929
- };
4930
- }
4931
- /**
4932
- * 为快手 `live_api` / H5 URL 签名。
4933
- * @param url - 实际请求 URL
4934
- * @param cookie - 原始 Cookie 字符串
4935
- * @param signPath - 可选的规范签名路径
4936
- * @param requestBody - 参与签名的请求体(POST 端点必传,见 helpers 的说明)
4937
- * @returns 带签名 URL、附加请求头和调试信息
4938
- */
4939
- signLiveApiUrl(url, cookie, signPath, requestBody = {}) {
4940
- const payload = buildKuaishouHxfalconPayload$1(url, signPath, requestBody);
4941
- const { signResult, signInput, catVersion } = this.generateHxfalconFromPayload(payload);
4942
- const signedUrl = new URL(url);
4943
- const headers = {};
4944
- const kww = deriveKuaishouKww$1(cookie, this.anonymousKwwCache);
4945
- signedUrl.searchParams.set("__NS_hxfalcon", signResult);
4946
- signedUrl.searchParams.set("caver", catVersion);
4947
- if (kww) headers.kww = kww;
4948
- return {
4949
- url: signedUrl.toString(),
4950
- headers,
4951
- signResult,
4952
- signInput,
4953
- catVersion
4954
- };
4955
- }
4956
- /**
4957
- * 为结构化的快手 `live_api` 请求描述对象签名。
4958
- *
4959
- * 入参与静态类 {@link kuaishouSign} 相同,但状态随实例。
4960
- *
4961
- * @param request - 快手 `live_api` 请求描述对象
4962
- * @param cookie - 原始 Cookie 字符串
4963
- * @param requestBody - 参与签名的请求体
4964
- * @returns 带签名 URL、附加请求头和调试信息
4965
- */
4966
- signLiveApiRequest(request, cookie, requestBody = {}) {
4967
- return this.signLiveApiUrl(request.url, cookie, request.signPath, requestBody);
4968
- }
4969
- };
4970
- /**
4971
- * 创建一个快手签名器实例。
4972
- *
4973
- * 每个 client 持有一个实例,签名状态(`count` / `startupRandom` /
4974
- * 匿名 `kww`)随实例走。
4975
- * @returns 新的签名器实例
4976
- */
4977
- const createKuaishouSigner = () => new KuaishouSigner();
4978
- //#endregion
4979
2913
  //#region src/platforms/kuaishou/sign/signers.ts
4980
2914
  /**
4981
- * 快手签名器表。
4982
- *
4983
- * 一张表只有一个签名器:快手的 `live_api`(`/rest/k/*`)与 H5(`/rest/wd/*`)
4984
- * 共用 `__NS_hxfalcon` 这一套算法,区别只在喂什么料。
4985
- */
4986
- /** 签名时必须出现在 query 里的版本标记 —— 它参与 sign input,不能只在发送时补 */
4987
- const CAVER_PARAM = "caver";
4988
- /**
4989
- * 取参与签名的请求体。
4990
- *
4991
- * 只认普通对象:签名把 `JSON.stringify(body)` 拼在 sign input 尾部,而 transport
4992
- * 交给 axios 的也是同一个对象(同样 `JSON.stringify`),两边逐字一致才算签对。
4993
- * 若 body 已被端点预先序列化成字符串或换成了别的形状,签名与实发就会错位 ——
4994
- * 这正是「签名验证失败」最难查的形态,所以这里直接抛,不静默签一个错的。
4995
- * @param spec - 请求描述
4996
- * @returns 参与签名的请求体
4997
- */
4998
- const signableBody = (spec) => {
4999
- if (spec.body === void 0 || spec.body === null) return {};
5000
- if (typeof spec.body !== "object" || Array.isArray(spec.body)) throw new Error(`快手签名只支持普通对象 body,收到 ${Array.isArray(spec.body) ? "array" : typeof spec.body}`);
5001
- return spec.body;
5002
- };
5003
- /**
5004
- * 造 `__NS_hxfalcon` 签名器。
5005
- *
5006
- * 签名的三份料全部从 spec 上取 —— 走正规 `sign` 声明这条路,`body` 天然可得,
5007
- * 不需要在 request 层手动把 body 递给签名器:
5008
- * - query:从 `spec.url` 解析,必须含 `caver`(缺就按签名器自己的 `catVersion`
5009
- * 补上,补的值与最终发出去的一致)
5010
- * - `spec.signPath`:规范签名路径(端点已在 spec 里填好)
5011
- * - `spec.body`:POST 端点的请求体,`photo/info` 一类严格校验的接口非它不可
5012
- * @param signer - 签名器实例(状态随实例:`count` / `startupRandom` / 匿名 kww)
5013
- * @returns 签名函数
5014
- */
5015
- const createHxfalconSigner = (signer) => (spec, ctx) => {
5016
- const url = new URL(spec.url);
5017
- if (!url.searchParams.get(CAVER_PARAM)) url.searchParams.set(CAVER_PARAM, signer.getCatVersion());
5018
- const signed = signer.signLiveApiUrl(url.toString(), ctx.cookie, spec.signPath, signableBody(spec));
5019
- const headers = new AmagiHeaders(spec.headers);
5020
- for (const [key, value] of Object.entries(signed.headers)) headers.set(key, value);
5021
- return {
5022
- ...spec,
5023
- url: signed.url,
5024
- headers: headers.toJSON()
5025
- };
5026
- };
5027
- /**
5028
2915
  * 创建快手签名器表。
5029
2916
  *
5030
2917
  * 表由 `PLATFORM_RUNTIME.kuaishou.signers` 持有,与另外三个平台同构 ——
5031
2918
  * client fetcher / `createKuaishouRoutes` / 静态 fetcher 三个入口共用同一张表,
5032
2919
  * 保证任何入口下的签名行为一致。
5033
2920
  *
5034
- * `signer` 可注入:签名器实例带状态(`count` / `startupRandom` / 匿名 kww);
5035
- * 表里只放 `SignFn`,混进一个实例会让它不再满足 `Record<string, SignFn>`。
5036
- * @param signer - 签名器实例,缺省新建一个
2921
+ * `signer` 可注入:缺省用 `steps.ts` 的模块级 {@link sharedKuaishouSigner},于是端点
2922
+ * 声明与 `client.kuaishou.request` 两条路共用一个 `count`;测试可注入独立实例做隔离。
2923
+ * @param signer - 签名器实例,缺省用共享实例
5037
2924
  * @returns 签名器表
5038
2925
  */
5039
- const createKuaishouSigners = (signer = createKuaishouSigner()) => ({ hxfalcon: createHxfalconSigner(signer) });
2926
+ const createKuaishouSigners = (signer = require_steps.sharedKuaishouSigner) => ({ hxfalcon: stepsToSigner([require_steps.hxfalcon(signer)]) });
5040
2927
  //#endregion
5041
2928
  //#region src/platforms/xiaohongshu/config.ts
5042
2929
  /**
@@ -5056,7 +2943,7 @@ const createKuaishouSigners = (signer = createKuaishouSigner()) => ({ hxfalcon:
5056
2943
  const XIAOHONGSHU_DEFAULT_TIMEOUT = 1e4;
5057
2944
  /** 从请求配置里取 UA:大小写不敏感,取不到用集中维护的默认值 */
5058
2945
  const userAgentOf = (requestConfig) => {
5059
- return new 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";
2946
+ 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";
5060
2947
  };
5061
2948
  /**
5062
2949
  * 构造小红书默认请求配置。
@@ -5068,7 +2955,7 @@ const createXiaohongshuConfig = (cookie, requestConfig) => {
5068
2955
  const userAgent = userAgentOf(requestConfig);
5069
2956
  const secChUa = generateSecChUa(userAgent);
5070
2957
  return {
5071
- headers: new 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),
2958
+ 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),
5072
2959
  requestConfig: {
5073
2960
  timeout: XIAOHONGSHU_DEFAULT_TIMEOUT,
5074
2961
  ...requestConfig
@@ -5102,397 +2989,50 @@ const xiaohongshuJudge = (raw, http) => {
5102
2989
  return verdictFromHttpStatus(http.status) ?? { ok: true };
5103
2990
  };
5104
2991
  //#endregion
5105
- //#region src/platforms/xiaohongshu/sign/config.ts
5106
- /** 初始化签名配置。 */
5107
- const createXiaohongshuCryptoConfig = () => new _ikenxuan_xhshow_ts.CryptoConfig().withOverrides({
5108
- DATA_WEB_BUILD: "6.12.3",
5109
- SIGNATURE_DATA_TEMPLATE: {
5110
- x0: "4.4.3",
5111
- x1: "xhs-pc-web",
5112
- x2: "Windows",
5113
- x3: "",
5114
- x4: ""
5115
- },
5116
- SIGNATURE_XSCOMMON_TEMPLATE: {
5117
- s0: 5,
5118
- s1: "",
5119
- x0: "1",
5120
- x1: "4.3.5",
5121
- x2: "Windows",
5122
- x3: "xhs-pc-web",
5123
- x4: "6.12.3",
5124
- x5: "",
5125
- x6: "",
5126
- x7: "",
5127
- x8: "",
5128
- x9: -596800761,
5129
- x10: 0,
5130
- x11: "normal"
5131
- }
5132
- });
5133
- //#endregion
5134
- //#region src/platforms/xiaohongshu/sign/guestCookie.ts
5135
- /** @see https://github.com/Cialle/RedCrack */
5136
- /** 小红书 Web 端游客会话初始化使用的浏览器请求头。 */
5137
- const GUEST_HEADERS$1 = {
5138
- accept: "application/json, text/plain, */*",
5139
- "accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
5140
- "content-type": "application/json;charset=UTF-8",
5141
- origin: "https://www.xiaohongshu.com",
5142
- priority: "u=1, i",
5143
- referer: "https://www.xiaohongshu.com/",
5144
- "sec-ch-ua": "\"Microsoft Edge\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"",
5145
- "sec-ch-ua-mobile": "?0",
5146
- "sec-ch-ua-platform": "\"Windows\"",
5147
- "sec-fetch-dest": "empty",
5148
- "sec-fetch-mode": "cors",
5149
- "sec-fetch-site": "same-site",
5150
- "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36 Edg/141.0.0.0"
5151
- };
5152
- /** 生成 a1 随机段时使用的 Web 端字符集。 */
5153
- const COOKIE_RANDOM_CHARS$1 = "abcdefghijklmnopqrstuvwxyz1234567890";
5154
- /** 将 Cookie Jar 序列化为可直接写入 HTTP Cookie 请求头的字符串。 */
5155
- const toCookieString$1 = (cookies) => Object.entries(cookies).map(([key, value]) => `${key}=${value}`).join("; ");
5156
- /** 将响应头中的 Set-Cookie 字段合并到当前游客会话的 Cookie Jar。 */
5157
- const updateCookiesFromResponse$1 = (cookies, setCookie) => {
5158
- const headers = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
5159
- for (const header of headers) {
5160
- const [nameValue] = header.split(";", 1);
5161
- const separatorIndex = nameValue.indexOf("=");
5162
- if (separatorIndex > 0) cookies[nameValue.slice(0, separatorIndex).trim()] = nameValue.slice(separatorIndex + 1).trim();
5163
- }
5164
- };
5165
- /** 按小红书 Web 端规则生成 a1,以及由 a1 派生的 webId。 */
5166
- const generateA1AndWebId$1 = () => {
5167
- const source = `${Date.now().toString(16)}${Array.from((0, node_crypto.randomBytes)(30), (byte) => COOKIE_RANDOM_CHARS$1[byte % 36]).join("")}5000`;
5168
- const a1 = `${source}${crc32$1(source)}`.slice(0, 52);
5169
- return {
5170
- a1,
5171
- webId: (0, node_crypto.createHash)("md5").update(a1).digest("hex")
5172
- };
5173
- };
5174
- /** 计算与浏览器端实现兼容的无符号 CRC32 校验值。 */
5175
- const crc32$1 = (input) => {
5176
- let value = 4294967295;
5177
- for (const byte of Buffer.from(input)) {
5178
- value ^= byte;
5179
- for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? value >>> 1 ^ 3988292384 : value >>> 1;
5180
- }
5181
- return (value ^ 4294967295) >>> 0;
5182
- };
5183
- /** 从 scripting 接口返回的 VMP 数据中解码 websectiga Cookie。 */
5184
- const generateWebsectiga$1 = (payload) => {
5185
- const bMatch = payload.match(/"b":"(.*?)",/);
5186
- const dMatch = payload.match(/"d":(.*?)\}\)/);
5187
- if (!bMatch || !dMatch) throw new Error("小红书 scripting 响应格式异常,无法生成 websectiga");
5188
- const decoderData = JSON.parse(dMatch[1]);
5189
- const encoded = Buffer.from(bMatch[1], "base64").toString("utf8");
5190
- const logicList = [];
5191
- for (let index = 0; index < encoded.length; index += 5) logicList.push(Array.from(encoded.slice(index, index + 5), (char) => char.charCodeAt(0) - 1));
5192
- const start = decoderData[92];
5193
- const end = decoderData[93];
5194
- const target = logicList.slice(start, end + 1);
5195
- const key = Array.from({ length: 64 }, (_, index) => {
5196
- const item = target[675 + index * 2];
5197
- if (!item) throw new Error("小红书 scripting 响应缺少 websectiga 解码数据");
5198
- return decoderData[item[2]];
5199
- });
5200
- return Array.from({ length: 8 }, (_, group) => {
5201
- const offset = 56 - group * 8;
5202
- return String.fromCharCode(...key.slice(offset, offset + 8));
5203
- }).join("");
5204
- };
5205
- /** 将浏览器指纹编码为 webprofile 接口要求的 DES-ECB profileData。 */
5206
- const encryptProfileData$1 = (fingerprint, desKey) => {
5207
- const encoded = Buffer.from(JSON.stringify(fingerprint)).toString("base64");
5208
- const blockSize = 8;
5209
- const padding = blockSize - encoded.length % blockSize;
5210
- const plaintext = Buffer.concat([Buffer.from(encoded), Buffer.alloc(padding)]);
5211
- const cipher = (0, node_crypto.createCipheriv)("des-ede3", Buffer.from(desKey.repeat(3)), null);
5212
- cipher.setAutoPadding(false);
5213
- return Buffer.concat([cipher.update(plaintext), cipher.final()]).toString("hex");
5214
- };
5215
- /** 将 scripting 的 JSON 或 JSONP 响应统一还原为结构化数据。 */
5216
- const unwrapScriptingResponse$1 = (data) => {
5217
- if (typeof data === "string") {
5218
- const json = data.match(/^[^(]+\((.*)\)$/s)?.[1];
5219
- return JSON.parse(json ?? data);
5220
- }
5221
- return data;
5222
- };
5223
- /**
5224
- * 创建小红书 Web 端游客会话 Cookie。
5225
- *
5226
- * 该流程对应 Web 端首次访问时的 Cookie 初始化:生成 a1/webId,完成
5227
- * scripting、webprofile 与 activate 三个会话请求,并返回最终 Cookie 字符串。
5228
- *
5229
- * 会话请求经注入的 `send` 走 transport —— 调用方配的
5230
- * proxy / agent / 超时对它生效;请求头、响应处理与 Cookie 合并逻辑
5231
- * 不随发送方式改变。
5232
- * @param send - 注入的底层发送函数(来自 transport)
5233
- * @param requestConfig - 调用方请求配置,透传
5234
- * @returns 游客 Cookie 字符串
5235
- */
5236
- const createXiaohongshuGuestCookie$1 = async (send, requestConfig) => {
5237
- const cryptoConfig = createXiaohongshuCryptoConfig();
5238
- const signer = new _ikenxuan_xhshow_ts.Xhshow(cryptoConfig);
5239
- const cookies = {
5240
- ...generateA1AndWebId$1(),
5241
- webBuild: cryptoConfig.DATA_WEB_BUILD,
5242
- xsecappid: "xhs-pc-web",
5243
- loadts: String(Date.now()),
5244
- abRequestId: (0, node_crypto.randomUUID)()
5245
- };
5246
- const { headers: requestHeaders } = requestConfig ?? {};
5247
- /** 发送会话初始化请求,并将响应中的 Set-Cookie 合并回当前 Cookie Jar。 */
5248
- const request = async (url, data, signed = false) => {
5249
- const signatureHeaders = signed ? signer.signHeadersPost(new URL(url).pathname, cookies, "xhs-pc-web", data) : {};
5250
- const response = await send({
5251
- method: "POST",
5252
- url,
5253
- body: data,
5254
- headers: {
5255
- ...GUEST_HEADERS$1,
5256
- ...requestHeaders,
5257
- ...signatureHeaders,
5258
- Cookie: toCookieString$1(cookies)
5259
- }
5260
- });
5261
- const setCookie = response.setCookie;
5262
- updateCookiesFromResponse$1(cookies, setCookie);
5263
- if (response.status < 200 || response.status >= 300) throw new Error(`小红书游客会话初始化失败:${url} 返回 HTTP ${response.status}`);
5264
- return response.body;
5265
- };
5266
- const scriptingData = unwrapScriptingResponse$1(await request("https://as.xiaohongshu.com/api/sec/v1/scripting", {
5267
- callFrom: "web",
5268
- callback: "seccallback"
5269
- })).data;
5270
- if (!scriptingData?.data || !scriptingData.secPoisonId) throw new Error("小红书 scripting 响应缺少游客会话数据");
5271
- cookies.websectiga = generateWebsectiga$1(scriptingData.data);
5272
- cookies.sec_poison_id = scriptingData.secPoisonId;
5273
- const fingerprint = new _ikenxuan_xhshow_ts.FingerprintGenerator(cryptoConfig).generate(cookies, GUEST_HEADERS$1["user-agent"]);
5274
- await request(cryptoConfig.GID_URL, {
5275
- platform: cryptoConfig.DATA_PLATFORM,
5276
- profileData: encryptProfileData$1(fingerprint, cryptoConfig.DES_KEY),
5277
- sdkVersion: cryptoConfig.DATA_SDK_VERSION,
5278
- svn: cryptoConfig.DATA_SVN
5279
- }, true);
5280
- await request("https://edith.xiaohongshu.com/api/sns/web/v1/login/activate", {}, true);
5281
- if (!cookies.web_session) throw new Error("小红书游客会话初始化失败:未获取到 web_session");
5282
- return toCookieString$1(cookies);
5283
- };
5284
- //#endregion
5285
- //#region src/platforms/xiaohongshu/sign/index.ts
2992
+ //#region src/platforms/xiaohongshu/sign/signers.ts
5286
2993
  /**
5287
- * 小红书签名算法。
5288
- *
5289
- * 包装 `@ikenxuan/xhshow-ts` 的 `Xhshow` 实例,提供 GET / POST / X-S-Common /
5290
- * X-T / X-B3-Traceid / X-Xray-Traceid / X-Rap-Param / 搜索 ID / 搜索 request_id
5291
- * 的生成函数。a1 从 cookie 取值的逻辑用 `contracts/cookie.ts` 的 `getCookieValue`。
2994
+ * 小红书签名器表:**只做「注册名 → `SignStep` 清单」的映射,不含算法**。
5292
2995
  *
5293
- * `Xhshow` 实例是模块级单例,行为与性能无额外开销。
5294
- *
5295
- * 上游 2026-02 之后的新协议由这里落地:
5296
- * - **XYW_ 格式**(`generateXSGetXyw` / `generateXSPostXyw`):数据获取类接口
5297
- * (`user_posted`、`user/otherinfo` 等)自 2026-03 起以 HTTP 406 拒绝传统 XYS_
5298
- * 格式,改用基于 AES-128-CBC 的 XYW_ 签名。x-s 值以 `XYW_` 开头。
5299
- * - **x-rap-param**(`generateXRapParam`):feed / 搜索 / 发布类接口的额外校验头。
5300
- * - **x-xray-traceid**(`generateXrayTraceid`):追踪头,随现代浏览器每次请求下发。
5301
- * - **search request_id**(`getSearchRequestId`):搜索接口所需的 `{random}-{ts_ms}`。
5302
- */
5303
- /** 模块级 Xhshow 单例 */
5304
- const client = new _ikenxuan_xhshow_ts.Xhshow(createXiaohongshuCryptoConfig());
5305
- /**
5306
- * 生成 GET 请求的 X-S 签名。
5307
- * @param path - API 路径,如 `/api/sns/web/v1/feed`
5308
- * @param a1Cookie - a1 cookie 值
5309
- * @param clientType - 客户端类型,默认 `'xhs-pc-web'`
5310
- * @param params - 查询参数对象
5311
- * @returns X-S 签名
5312
- */
5313
- const generateXSGet = (path, a1Cookie, clientType = "xhs-pc-web", params = {}) => client.signXsGet(path, a1Cookie, clientType, params);
5314
- /**
5315
- * 生成 POST 请求的 X-S 签名。
5316
- * @param path - API 路径
5317
- * @param a1Cookie - a1 cookie 值
5318
- * @param clientType - 客户端类型,默认 `'xhs-pc-web'`
5319
- * @param body - 请求体对象
5320
- * @returns X-S 签名
5321
- */
5322
- const generateXSPost = (path, a1Cookie, clientType = "xhs-pc-web", body = {}) => client.signXsPost(path, a1Cookie, clientType, body);
5323
- /**
5324
- * 生成 GET 请求的 XYW_ 格式 X-S 签名(AES-128-CBC)。
2996
+ * 算法的唯一实现在 `./steps.ts`(四个基础签名器、helper,以及 XYS_/XYW_ 协议区别、
2997
+ * 签名路径为何取 signPath 等理由都在那边的文件头)。依赖方向单向 —— `signers.ts → steps.ts`,
2998
+ * 永不反向:两份实现并存时会改一边忘一边,而小红书签名错了往往整条请求被判 406,这类分叉极难发现。
5325
2999
  *
5326
- * 数据获取类接口(`user_posted` 等)自 2026-03 起以 HTTP 406 拒绝 XYS_ 格式,
5327
- * 需改用本函数。返回值以 `XYW_` 开头,走独立于 `signXsGet` 的加密路径。
5328
- * @param path - API 路径,如 `/api/sns/web/v1/user_posted`
5329
- * @param a1Cookie - a1 cookie 值
5330
- * @param clientType - 客户端类型,默认 `'xhs-pc-web'`
5331
- * @param params - 查询参数对象
5332
- * @returns XYW_ 格式 X-S 签名
5333
- */
5334
- const generateXSGetXyw = (path, a1Cookie, clientType = "xhs-pc-web", params = {}) => client.signXyw("GET", path, a1Cookie, clientType, params);
5335
- /**
5336
- * 生成 POST 请求的 XYW_ 格式 X-S 签名(AES-128-CBC)。
5337
- * @param path - API 路径
5338
- * @param a1Cookie - a1 cookie 值
5339
- * @param clientType - 客户端类型,默认 `'xhs-pc-web'`
5340
- * @param body - 请求体对象
5341
- * @returns XYW_ 格式 X-S 签名
5342
- */
5343
- const generateXSPostXyw = (path, a1Cookie, clientType = "xhs-pc-web", body = {}) => client.signXyw("POST", path, a1Cookie, clientType, body);
5344
- /**
5345
- * 生成 X-Rap-Param 请求头(feed / 搜索 / 发布类接口的额外校验)。
5346
- * @param api - 请求 API,形如 `//edith.xiaohongshu.com/api/sns/web/v1/feed`(含 host、去协议)
5347
- * @param body - 请求体对象(GET 传空对象)
5348
- * @returns Base64 编码的 x-rap-param 字符串
5349
- */
5350
- const generateXRapParam = (api, body = {}) => (0, _ikenxuan_xhshow_ts.xRapParam)(api, body);
5351
- /**
5352
- * 生成 X-S-Common 参数。
5353
- * @param cookies - cookie 字符串
5354
- * @returns Base64 编码的随机字符串
5355
- */
5356
- const generateXSCommon = (cookies) => client.signXsCommon(cookies);
5357
- /**
5358
- * 生成 X-T 时间戳。
5359
- * @returns 当前时间戳(毫秒)
5360
- */
5361
- const generateXT = () => client.getXT();
5362
- /**
5363
- * 生成 X-B3-Traceid。
5364
- * @returns 16 位随机字符串
5365
- */
5366
- const generateXB3Traceid = () => client.getB3TraceId();
5367
- /**
5368
- * 生成 X-Xray-Traceid。
3000
+ * ## 两条路,一份实现
5369
3001
  *
5370
- * 32 位字符(16 位时间戳+序列 + 16 位随机),随现代浏览器每次请求下发。
5371
- * @returns X-Xray-Traceid 字符串
5372
- */
5373
- const generateXrayTraceid = () => client.getXrayTraceId();
5374
- /**
5375
- * 生成搜索 ID。
3002
+ * 端点声明 `sign: [xs('get', 'xyw'), traceId()]` 走清单;`client.xiaohongshu.request` 不给
3003
+ * `amagi.sign` 时走字符串 `'xhs-get'` 这类名字查这张表。两者现在压的是同一批 step,签出来必然一致。
5376
3004
  *
5377
- * 交由上游 `client.getSearchId()`:base36 编码的 `(timestamp_ms << 64) + random`。
5378
- * 旧的本地实现把 BigInt 与 string 相加,得到「十进制拼 base36 后缀」而非预期的
5379
- * 位运算结果 —— 上游修正后此处直接复用,不再自行拼装。
5380
- * @returns 搜索 ID 字符串
5381
- */
5382
- const getSearchId = () => client.getSearchId();
5383
- //#endregion
5384
- //#region src/platforms/xiaohongshu/sign/signers.ts
5385
- /**
5386
- * 小红书签名器表。
3005
+ * ## 七个注册名
5387
3006
  *
5388
3007
  * XYS_(传统)路径:
5389
- * - `'xhs-post'`:POST 请求,`x-s` 用 `signXsPost` 对 body 签名
5390
- * - `'xhs-get'`:GET 请求,`x-s` 用 `signXsGet` 对 query 签名
3008
+ * - `'xhs-post'`:POST,x-s 用 `signXsPost` 对 body 签名
3009
+ * - `'xhs-get'`:GET,x-s 用 `signXsGet` 对 query 签名
5391
3010
  * - `'xhs-get-trace'`:GET + `x-b3-traceid`(userNoteList 专用)
5392
3011
  * - `'xhs-post-rap'`:POST + `x-rap-param`(feed / 搜索 / 发布类接口的额外校验)
5393
3012
  *
5394
- * XYW_(2026-03 之后)路径 —— 数据获取类接口以 HTTP 406 拒绝 XYS_,`x-s` 改用
5395
- * 以 `XYW_` 开头的 AES-128-CBC 签名:
5396
- * - `'xhs-get-xyw'`:GET,`x-s` 用 `signXyw`
5397
- * - `'xhs-post-xyw'`:POST,`x-s` 用 `signXyw`
3013
+ * XYW_(2026-03 之后)路径 —— 数据获取类接口以 HTTP 406 拒绝 XYS_,x-s 改用以 `XYW_`
3014
+ * 开头的 AES-128-CBC 签名:
3015
+ * - `'xhs-get-xyw'`:GET,x-s 用 `signXyw`
3016
+ * - `'xhs-post-xyw'`:POST,x-s 用 `signXyw`
5398
3017
  * - `'xhs-get-xyw-trace'`:GET XYW + `x-b3-traceid`(userNoteList 专用)
5399
3018
  *
5400
- * 签名路径取自 `spec.signPath`(build 里填 `apiPath`),不是 `spec.url`:
5401
- * 小红书签名只用接口路径,与完整 URL 无关。x-rap-param 例外,它要 host + path。
5402
- *
5403
- * 签名输入:a1 取自 cookie,POST 签名含 body,GET 签名含 query。
5404
- */
5405
- /** 从 cookie 取 a1:用 `getCookieValue` 按名精确匹配 */
5406
- const a1Of = (cookie) => getCookieValue(cookie, "a1") ?? "";
5407
- /** 签名路径:优先 `spec.signPath`(build 填的 apiPath),缺省取 URL 的 pathname */
5408
- const apiPathOf = (spec) => spec.signPath ?? new URL(spec.url).pathname;
5409
- /**
5410
- * 取签名用的 query 参数:GET 端点在 `build` 里把原始 params 放进 `extra.signParams`。
5411
- * 小红书 GET 的 x-s 必须覆盖 query(路径 + 参数),只签路径会被判非法请求返回 406。
5412
- * 无参数端点(emojiList / userProfile)返回空对象,签名退化为只签路径,行为不变。
3019
+ * @module platforms/xiaohongshu/sign/signers
5413
3020
  */
5414
- const signParamsOf = (spec) => spec.extra?.signParams ?? {};
5415
- /** x-rap-param 的 api 入参:去协议、保留 `//host/path`(不含 query) */
5416
- const rapApiOf = (spec) => {
5417
- const url = new URL(spec.url);
5418
- return `//${url.host}${url.pathname}`;
5419
- };
5420
- /**
5421
- * 注入 x-s / x-s-common / x-t / x-xray-traceid 四个签名头。
5422
- *
5423
- * `x-xray-traceid` 是真实浏览器每次请求都带的追踪头(见 noteComments 的抓包);
5424
- * 早期只发前三个,`comment/page` 这类接口会因此被判为非法请求,返回 HTTP 406。
5425
- * 所有平台签名器共用本 helper,因此补上后对全部端点生效。
5426
- */
5427
- const signHeaders = (spec, ctx, xs) => {
5428
- const headers = new AmagiHeaders(spec.headers).set("x-s", xs).set("x-s-common", generateXSCommon(ctx.cookie)).set("x-t", String(generateXT())).set("x-xray-traceid", generateXrayTraceid());
5429
- return {
5430
- ...spec,
5431
- headers: headers.toJSON()
5432
- };
5433
- };
5434
- /** POST 签名器:x-s = signXsPost(apiPath, a1, 'xhs-pc-web', body) */
5435
- const xhsPostSigner = (spec, ctx) => {
5436
- const xs = generateXSPost(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", spec.body ?? {});
5437
- return signHeaders(spec, ctx, xs);
5438
- };
5439
- /** GET 签名器:x-s = signXsGet(apiPath, a1, 'xhs-pc-web', signParams) */
5440
- const xhsGetSigner = (spec, ctx) => {
5441
- const xs = generateXSGet(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", signParamsOf(spec));
5442
- return signHeaders(spec, ctx, xs);
5443
- };
5444
- /** GET + x-b3-traceid(userNoteList 专用) */
5445
- const xhsGetTraceSigner = async (spec, ctx) => {
5446
- const signed = await xhsGetSigner(spec, ctx);
5447
- const headers = new AmagiHeaders(signed.headers).set("x-b3-traceid", generateXB3Traceid());
5448
- return {
5449
- ...signed,
5450
- headers: headers.toJSON()
5451
- };
5452
- };
5453
- /** POST + x-rap-param(feed / 搜索 / 发布类接口的额外校验) */
5454
- const xhsPostRapSigner = async (spec, ctx) => {
5455
- const signed = await xhsPostSigner(spec, ctx);
5456
- const rap = generateXRapParam(rapApiOf(spec), spec.body ?? {});
5457
- const headers = new AmagiHeaders(signed.headers).set("x-rap-param", rap);
5458
- return {
5459
- ...signed,
5460
- headers: headers.toJSON()
5461
- };
5462
- };
5463
- /** XYW GET 签名器:x-s = signXyw('GET', apiPath, a1, 'xhs-pc-web', signParams),绕过数据接口 406 */
5464
- const xhsGetXywSigner = (spec, ctx) => {
5465
- const xs = generateXSGetXyw(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", signParamsOf(spec));
5466
- return signHeaders(spec, ctx, xs);
5467
- };
5468
- /** XYW POST 签名器:x-s = signXyw('POST', apiPath, a1, 'xhs-pc-web', body) */
5469
- const xhsPostXywSigner = (spec, ctx) => {
5470
- const xs = generateXSPostXyw(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", spec.body ?? {});
5471
- return signHeaders(spec, ctx, xs);
5472
- };
5473
- /** XYW GET + x-b3-traceid(userNoteList 专用,2026-03 之后) */
5474
- const xhsGetXywTraceSigner = async (spec, ctx) => {
5475
- const signed = await xhsGetXywSigner(spec, ctx);
5476
- const headers = new AmagiHeaders(signed.headers).set("x-b3-traceid", generateXB3Traceid());
5477
- return {
5478
- ...signed,
5479
- headers: headers.toJSON()
5480
- };
5481
- };
5482
3021
  /**
5483
3022
  * 平台签名器表,交给 runtime 的 `signers` 查名。
5484
3023
  *
5485
3024
  * 用 `satisfies` 而非显式 `: Record<string, SignFn>` 返回:后者会把键联合抹成宽
5486
3025
  * `string`,而 {@link XiaohongshuSignerName} 要靠 `keyof` 从这张表推导精确名字联合。
3026
+ * @returns 注册名 → 签名器
5487
3027
  */
5488
3028
  const createXiaohongshuSigners = () => ({
5489
- "xhs-post": xhsPostSigner,
5490
- "xhs-get": xhsGetSigner,
5491
- "xhs-get-trace": xhsGetTraceSigner,
5492
- "xhs-post-rap": xhsPostRapSigner,
5493
- "xhs-get-xyw": xhsGetXywSigner,
5494
- "xhs-post-xyw": xhsPostXywSigner,
5495
- "xhs-get-xyw-trace": xhsGetXywTraceSigner
3029
+ "xhs-post": stepsToSigner([require_steps.xs("post", "xys")]),
3030
+ "xhs-get": stepsToSigner([require_steps.xs("get", "xys")]),
3031
+ "xhs-get-trace": stepsToSigner([require_steps.xs("get", "xys"), require_steps.traceId()]),
3032
+ "xhs-post-rap": stepsToSigner([require_steps.xs("post", "xys"), require_steps.rap()]),
3033
+ "xhs-get-xyw": stepsToSigner([require_steps.xs("get", "xyw")]),
3034
+ "xhs-post-xyw": stepsToSigner([require_steps.xs("post", "xyw")]),
3035
+ "xhs-get-xyw-trace": stepsToSigner([require_steps.xs("get", "xyw"), require_steps.traceId()])
5496
3036
  });
5497
3037
  //#endregion
5498
3038
  //#region src/runtime/events.ts
@@ -5889,10 +3429,10 @@ const stripEdgeToken = (headers) => {
5889
3429
  * @returns 归一化后的容器
5890
3430
  */
5891
3431
  const toAmagiHeaders = (headers) => {
5892
- if (!headers) return new AmagiHeaders();
3432
+ if (!headers) return new require_steps.AmagiHeaders();
5893
3433
  const source = headers;
5894
3434
  const plain = typeof source.toJSON === "function" ? source.toJSON() : headers;
5895
- const out = new AmagiHeaders();
3435
+ const out = new require_steps.AmagiHeaders();
5896
3436
  for (const [name, value] of Object.entries(plain)) out.set(name, Array.isArray(value) ? value.join("; ") : value);
5897
3437
  return out;
5898
3438
  };
@@ -6052,7 +3592,7 @@ var HttpClient = class {
6052
3592
  * @returns axios 配置
6053
3593
  */
6054
3594
  buildAxiosConfig(spec, perCall) {
6055
- const headers = new AmagiHeaders(this.options.headers).merge(this.options.requestConfig?.headers).merge(perCall?.headers).merge(spec.headers);
3595
+ const headers = new require_steps.AmagiHeaders(this.options.headers).merge(this.options.requestConfig?.headers).merge(perCall?.headers).merge(spec.headers);
6056
3596
  for (const name of spec.dropHeaders ?? []) headers.delete(name);
6057
3597
  const { headers: _baseHeaders, ...baseRest } = this.options.requestConfig ?? {};
6058
3598
  const { headers: _callHeaders, ...callRest } = perCall ?? {};
@@ -6145,7 +3685,7 @@ const PLATFORM_RUNTIME = {
6145
3685
  douyin: {
6146
3686
  signers: createDouyinSigners(),
6147
3687
  judge: douyinJudge,
6148
- observe: observeDouyinWebid
3688
+ observe: require_steps.observeDouyinWebid
6149
3689
  },
6150
3690
  bilibili: {
6151
3691
  signers: (() => {
@@ -6291,7 +3831,15 @@ const SUCCESS_MESSAGE = "获取成功";
6291
3831
  * `T | undefined`)。数组回调里没有 `if` 可用,`filter` 又只认类型谓词
6292
3832
  * —— 这就是必须有守卫的场景:
6293
3833
  *
6294
- * ```ts
3834
+ * ```ts twoslash
3835
+ * import { isSuccess, type AmagiResult } from '@ikenxuan/amagi'
3836
+ *
3837
+ * declare const ids: string[]
3838
+ * declare const fetchOne: (id: string) => Promise<AmagiResult<Work>>
3839
+ * interface Work {
3840
+ * id: string
3841
+ * }
3842
+ *
6295
3843
  * const list: AmagiResult<Work>[] = await Promise.all(ids.map(fetchOne))
6296
3844
  * const works = list.filter(isSuccess).map((r) => r.data) // Work[]
6297
3845
  * ```
@@ -6449,7 +3997,7 @@ const runPaginated = async (def, params, runPage) => {
6449
3997
  */
6450
3998
  const attachCookie = (spec, cookie) => {
6451
3999
  if (!cookie) return spec;
6452
- const headers = new AmagiHeaders(spec.headers);
4000
+ const headers = new require_steps.AmagiHeaders(spec.headers);
6453
4001
  if (headers.has("cookie")) return spec;
6454
4002
  headers.set("Cookie", cookie);
6455
4003
  return {
@@ -6610,11 +4158,44 @@ const classifyThrown = (cause, stage) => {
6610
4158
  const resolveSigner = (decl, signers) => {
6611
4159
  if (decl === void 0 || decl === false) return void 0;
6612
4160
  if (typeof decl === "function") return decl;
4161
+ if (Array.isArray(decl)) return stepsToSigner(decl);
4162
+ if (typeof decl === "object") return stepsToSigner([decl]);
6613
4163
  const signer = signers?.[decl];
6614
4164
  if (!signer) throw new Error(`未注册的签名器:'${decl}'`);
6615
4165
  return signer;
6616
4166
  };
6617
4167
  /**
4168
+ * 限额并发地跑异步任务,结果按 `items` 下标顺序收齐(单任务结局语义同
4169
+ * `Promise.allSettled`)。
4170
+ *
4171
+ * 派发顺序仍按 `items` 顺序(FIFO),只是同时在途的不超过 `limit`。单个任务的
4172
+ * 抛错经 `then` 的第二参落进对应下标、不惊动其它任务 —— 不写 `try/catch` 是因为
4173
+ * execute 的「唯一一处 catch」是源码级契约(execute.test.ts 有断言),这里与
4174
+ * `Promise.allSettled` 同构即可。
4175
+ */
4176
+ const settledWithConcurrency = async (items, limit, task) => {
4177
+ const queue = items.map((item, index) => ({
4178
+ item,
4179
+ index
4180
+ })).reverse();
4181
+ const settled = new Array(items.length);
4182
+ const worker = async () => {
4183
+ while (true) {
4184
+ const job = queue.pop();
4185
+ if (job === void 0) return;
4186
+ settled[job.index] = await task(job.item, job.index).then((value) => ({
4187
+ status: "fulfilled",
4188
+ value
4189
+ }), (reason) => ({
4190
+ status: "rejected",
4191
+ reason
4192
+ }));
4193
+ }
4194
+ };
4195
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
4196
+ return settled;
4197
+ };
4198
+ /**
6618
4199
  * 执行一条端点声明,产出信封。
6619
4200
  *
6620
4201
  * **永不 reject。** 唯一的例外是调用方自己传入的回调抛出(如会话的
@@ -6800,15 +4381,18 @@ const execute = async (def, input, options) => {
6800
4381
  const isMulti = signed.length > 1 || Array.isArray(built);
6801
4382
  const reason = isMulti ? "segment" : "initial";
6802
4383
  const tolerate = def.partial === "tolerate";
4384
+ const settled = await settledWithConcurrency(signed, 10, (spec, index) => runSpec(spec, reason, refreshFor(params, index)));
6803
4385
  let outcomes;
6804
4386
  if (tolerate) {
6805
- outcomes = (await Promise.allSettled(signed.map((spec, index) => runSpec(spec, reason, refreshFor(params, index))))).map((r) => r.status === "fulfilled" ? r.value : {
4387
+ outcomes = settled.map((r) => r.status === "fulfilled" ? r.value : {
6806
4388
  ok: false,
6807
4389
  error: classifyThrown(r.reason, "send")
6808
4390
  });
6809
4391
  if (outcomes.every((o) => !o.ok)) return failWith(outcomes[0].error);
6810
4392
  } else {
6811
- outcomes = await Promise.all(signed.map((spec, index) => runSpec(spec, reason, refreshFor(params, index))));
4393
+ const rejected = settled.find((r) => r.status === "rejected");
4394
+ if (rejected) throw rejected.reason;
4395
+ outcomes = settled.map((r) => r.value);
6812
4396
  const firstFailure = outcomes.find((o) => !o.ok);
6813
4397
  if (firstFailure) return failWith(firstFailure.error);
6814
4398
  }
@@ -6958,7 +4542,7 @@ const methodNameOf = (platform, endpoint) => METHOD_NAMES[fullNameOf(platform, e
6958
4542
  * @returns 本次调用的有效 cookie 与合并后的请求配置
6959
4543
  */
6960
4544
  const resolveBoundRequest = (boundCookie, base, override) => {
6961
- const headers = new AmagiHeaders(base?.headers).merge(override?.headers);
4545
+ const headers = new require_steps.AmagiHeaders(base?.headers).merge(override?.headers);
6962
4546
  return {
6963
4547
  cookie: headers.get("cookie") ?? boundCookie,
6964
4548
  requestConfig: {
@@ -6996,7 +4580,7 @@ const methodNameFor = (platform, endpoint) => methodNameOf(platform, endpoint) ?
6996
4580
  */
6997
4581
  const callEndpoint = (def, ctx, options, requestConfig) => {
6998
4582
  const merged = resolveBoundRequest(ctx.cookie, ctx.requestConfig, requestConfig);
6999
- const mergedUA = merged.requestConfig ? new AmagiHeaders(merged.requestConfig.headers).get("user-agent") ?? ctx.userAgent : ctx.userAgent;
4583
+ const mergedUA = merged.requestConfig ? new require_steps.AmagiHeaders(merged.requestConfig.headers).get("user-agent") ?? ctx.userAgent : ctx.userAgent;
7000
4584
  const now = ctx.now ?? Date.now;
7001
4585
  const startedAt = now();
7002
4586
  const requestId = (ctx.requestId ?? defaultRequestId)();
@@ -7189,7 +4773,7 @@ const extractBrowserVersion$1 = (userAgent) => {
7189
4773
  const buildQueryString$3 = (params) => {
7190
4774
  return Object.entries(params).filter(([_, value]) => value !== void 0 && value !== null).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
7191
4775
  };
7192
- const fp$1 = douyinSign$1.VerifyFpManager();
4776
+ const fp$1 = require_steps.douyinSign.VerifyFpManager();
7193
4777
  /** iesdouyin v2 游客接口前缀:不需要 cookie、不需要签名 */
7194
4778
  const GUEST_BASE$1 = "https://www.iesdouyin.com/web/api/v2";
7195
4779
  /** 表情资源包元信息,走 App 接口,同样免鉴权(需要 Android UA,由端点声明覆盖) */
@@ -7235,7 +4819,6 @@ var DouyinAPI$1 = class {
7235
4819
  platform: "PC",
7236
4820
  downlink: "10",
7237
4821
  effective_type: "4g",
7238
- msToken: douyinSign$1.Mstoken(184),
7239
4822
  verifyFp: fp$1,
7240
4823
  fp: fp$1
7241
4824
  };
@@ -7339,7 +4922,6 @@ var DouyinAPI$1 = class {
7339
4922
  device_id: "7326472315356857893",
7340
4923
  aweme_ids: `[${data.aweme_id}]`,
7341
4924
  request_source: "200",
7342
- msToken: douyinSign$1.Mstoken(116),
7343
4925
  verifyFp: fp$1,
7344
4926
  fp: fp$1
7345
4927
  };
@@ -7461,7 +5043,6 @@ var DouyinAPI$1 = class {
7461
5043
  downlink: "10",
7462
5044
  effective_type: "4g",
7463
5045
  round_trip_time: "50",
7464
- msToken: douyinSign$1.Mstoken(184),
7465
5046
  verifyFp: fp$1,
7466
5047
  fp: fp$1
7467
5048
  };
@@ -7589,8 +5170,7 @@ var DouyinAPI$1 = class {
7589
5170
  }
7590
5171
  /** 获取互动表情数据 */
7591
5172
  getDynamicEmojiList() {
7592
- const baseUrl = "https://www.douyin.com/aweme/v1/web/im/strategy/config";
7593
- const params = {
5173
+ return `https://www.douyin.com/aweme/v1/web/im/strategy/config?${buildQueryString$3({
7594
5174
  device_platform: "webapp",
7595
5175
  aid: "1128",
7596
5176
  channel: "channel_pc_web",
@@ -7619,11 +5199,9 @@ var DouyinAPI$1 = class {
7619
5199
  downlink: "1.5",
7620
5200
  effective_type: "4g",
7621
5201
  round_trip_time: "350",
7622
- msToken: douyinSign$1.Mstoken(116),
7623
5202
  verifyFp: fp$1,
7624
5203
  fp: fp$1
7625
- };
7626
- return `${baseUrl}?${buildQueryString$3(params)}`;
5204
+ })}`;
7627
5205
  }
7628
5206
  /** 获取背景音乐数据 */
7629
5207
  getMusicInfo(data) {
@@ -7655,7 +5233,6 @@ var DouyinAPI$1 = class {
7655
5233
  downlink: "1.5",
7656
5234
  effective_type: "4g",
7657
5235
  round_trip_time: "350",
7658
- msToken: douyinSign$1.Mstoken(116),
7659
5236
  verifyFp: fp$1,
7660
5237
  fp: fp$1
7661
5238
  };
@@ -7684,7 +5261,6 @@ var DouyinAPI$1 = class {
7684
5261
  is_need_double_stream: "false",
7685
5262
  insert_task_id: "",
7686
5263
  live_reason: "",
7687
- msToken: douyinSign$1.Mstoken(116),
7688
5264
  verifyFp: fp$1,
7689
5265
  fp: fp$1
7690
5266
  };
@@ -7725,7 +5301,6 @@ var DouyinAPI$1 = class {
7725
5301
  engine_version: "140.0.0.0",
7726
5302
  downlink: "1.55",
7727
5303
  round_trip_time: "200",
7728
- msToken: douyinSign$1.Mstoken(116),
7729
5304
  verifyFp: fp$1,
7730
5305
  fp: fp$1
7731
5306
  };
@@ -7766,13 +5341,13 @@ const commentReplies = defineDouyinEndpoint({
7766
5341
  aweme_id: zod.default.string().min(1, { error: "作品ID不能为空" }).describe("作品 ID"),
7767
5342
  comment_id: zod.default.string().min(1, { error: "评论ID不能为空" }).describe("要取回复的评论 ID"),
7768
5343
  number: zod.default.coerce.number().int().min(1).optional().describe("目标条数,默认 3"),
7769
- cursor: zod.default.coerce.number().int().min(0).optional().describe("翻页游标,一般不用传")
5344
+ cursor: internalParam(zod.default.coerce.number().int().min(0).optional().describe("翻页游标,一般不用传"))
7770
5345
  }),
7771
5346
  build: (p) => ({
7772
5347
  method: "GET",
7773
5348
  url: douyinApiUrls$1.getCommentReplies(p)
7774
5349
  }),
7775
- sign: "x-bogus",
5350
+ sign: [require_steps.xBogus(), require_steps.secsdk()],
7776
5351
  retryOn: ["ANTIBOT_PAGE"],
7777
5352
  retryFresh: true,
7778
5353
  paginate: {
@@ -7810,13 +5385,13 @@ const comments$1 = defineDouyinEndpoint({
7810
5385
  params: zod.default.object({
7811
5386
  aweme_id: zod.default.string().min(1, { error: "作品ID不能为空" }).describe("作品 ID"),
7812
5387
  number: zod.default.coerce.number().int().min(1).optional().describe("目标条数,默认 50"),
7813
- cursor: zod.default.coerce.number().int().min(0).optional().describe("翻页游标,一般不用传")
5388
+ cursor: internalParam(zod.default.coerce.number().int().min(0).optional().describe("翻页游标,一般不用传"))
7814
5389
  }),
7815
5390
  build: (p) => ({
7816
5391
  method: "GET",
7817
5392
  url: douyinApiUrls$1.getComments(p)
7818
5393
  }),
7819
- sign: "a-bogus",
5394
+ sign: require_steps.douyinBogus(184),
7820
5395
  retryOn: ["ANTIBOT_PAGE"],
7821
5396
  retryFresh: true,
7822
5397
  paginate: {
@@ -7838,9 +5413,21 @@ const comments$1 = defineDouyinEndpoint({
7838
5413
  //#endregion
7839
5414
  //#region src/platforms/douyin/endpoints/danmakuList.ts
7840
5415
  /**
5416
+ * duration 的硬上限(毫秒,7 天)。
5417
+ *
5418
+ * 纯保险丝:分段并发已由执行器的限额池兜住,真实的超长作品(实测有 27 小时级、
5419
+ * 弹幕正常的直播回放)不受影响。挡的是天文数字的 `duration`(如 `9e15`,能通过
5420
+ * `.int()` 校验)—— `build` 的切段是同步 while 循环,那种值会在任何请求发出之前
5421
+ * 把事件循环卡死、把分段数组撑爆。真实作品离 7 天很远,这个值给足余量。
5422
+ *
5423
+ * 导出是因为校验层的平行 schema(validation/douyin.ts,v6 门面入口)要用同一个数。
5424
+ */
5425
+ const DANMAKU_MAX_DURATION_MS = 6048e5;
5426
+ /**
7841
5427
  * 弹幕列表(分段并发 + 合并排序 + `partial: 'tolerate'`)。
7842
5428
  *
7843
- * 总时长 ≤ 32000ms 单段直取;超过则按 32000ms 切成多段并发请求,
5429
+ * 总时长 ≤ 32000ms 单段直取;超过则按 32000ms 切成多段,经执行器的限额并发池
5430
+ * 发出(`SEGMENT_CONCURRENCY`,完成一个补一个),
7844
5431
  * **单段失败容忍**(失败段返回 null,其余段照常合并),最后按 `offset_time`
7845
5432
  * 升序合并,元信息(`extra` / `log_pb` / `status_code`)取第一段。
7846
5433
  *
@@ -7859,7 +5446,7 @@ const danmakuList$1 = defineDouyinEndpoint({
7859
5446
  aweme_id: zod.default.string().min(1, { error: "作品ID不能为空" }).describe("作品 ID"),
7860
5447
  start_time: zod.default.coerce.number().int().min(0).optional().describe("区间起点(毫秒),默认 0"),
7861
5448
  end_time: zod.default.coerce.number().int().min(0).optional().describe("区间终点(毫秒),默认取总时长"),
7862
- duration: zod.default.coerce.number().int().min(0).describe("作品总时长(毫秒),也是默认终点")
5449
+ duration: zod.default.coerce.number().int().min(0).max(DANMAKU_MAX_DURATION_MS, { error: "作品总时长不能超过 7 天" }).describe("作品总时长(毫秒),也是默认终点")
7863
5450
  }).refine((data) => data.end_time === void 0 || data.end_time <= data.duration, {
7864
5451
  error: "获取弹幕区间的结束时间不能超过视频总时长",
7865
5452
  path: ["end_time"]
@@ -7896,7 +5483,7 @@ const danmakuList$1 = defineDouyinEndpoint({
7896
5483
  tag: `segment-${index + 1}`
7897
5484
  }));
7898
5485
  },
7899
- sign: "a-bogus",
5486
+ sign: require_steps.douyinBogus(116),
7900
5487
  retryOn: ["ANTIBOT_PAGE"],
7901
5488
  retryFresh: true,
7902
5489
  partial: "tolerate",
@@ -7931,7 +5518,7 @@ const dynamicEmojiList = defineDouyinEndpoint({
7931
5518
  method: "GET",
7932
5519
  url: douyinApiUrls$1.getDynamicEmojiList()
7933
5520
  }),
7934
- sign: "a-bogus",
5521
+ sign: require_steps.douyinBogus(116),
7935
5522
  retryOn: ["ANTIBOT_PAGE"],
7936
5523
  retryFresh: true,
7937
5524
  response: type()
@@ -7955,7 +5542,7 @@ const emojiList$3 = defineDouyinEndpoint({
7955
5542
  method: "GET",
7956
5543
  url: douyinApiUrls$1.getEmojiList()
7957
5544
  }),
7958
- sign: false,
5545
+ sign: [require_steps.msToken(184)],
7959
5546
  response: type()
7960
5547
  });
7961
5548
  //#endregion
@@ -8108,7 +5695,7 @@ const imageAlbumWork = defineDouyinEndpoint({
8108
5695
  method: "GET",
8109
5696
  url: douyinApiUrls$1.getWorkDetail(p)
8110
5697
  }),
8111
- sign: "a-bogus",
5698
+ sign: require_steps.douyinBogus(184),
8112
5699
  retryOn: ["ANTIBOT_PAGE"],
8113
5700
  retryFresh: true,
8114
5701
  response: type()
@@ -8144,7 +5731,7 @@ const douyinRefererUrl = (page) => {
8144
5731
  * @returns 需要写进 spec 的 header(可能为空对象)
8145
5732
  */
8146
5733
  const withDouyinReferer = (ctx, page) => {
8147
- if (new AmagiHeaders(ctx.requestConfig?.headers).has("referer")) return {};
5734
+ if (new require_steps.AmagiHeaders(ctx.requestConfig?.headers).has("referer")) return {};
8148
5735
  return { referer: douyinRefererUrl(page) };
8149
5736
  };
8150
5737
  //#endregion
@@ -8164,7 +5751,7 @@ const liveRoomInfo$1 = defineDouyinEndpoint({
8164
5751
  },
8165
5752
  params: zod.default.object({
8166
5753
  web_rid: zod.default.string().min(1, { error: "直播间ID不能为空" }).describe("直播间短号(链接里那段)"),
8167
- room_id: zod.default.string().optional().describe("内部透传,一般不用传")
5754
+ room_id: internalParam(zod.default.string().optional().describe("内部透传,一般不用传"))
8168
5755
  }),
8169
5756
  build: (p, ctx) => ({
8170
5757
  method: "GET",
@@ -8174,7 +5761,7 @@ const liveRoomInfo$1 = defineDouyinEndpoint({
8174
5761
  webRid: p.web_rid
8175
5762
  })
8176
5763
  }),
8177
- sign: "a-bogus",
5764
+ sign: require_steps.douyinBogus(116),
8178
5765
  retryOn: ["ANTIBOT_PAGE"],
8179
5766
  retryFresh: true,
8180
5767
  response: type()
@@ -8202,7 +5789,7 @@ const loginQrcode = defineDouyinEndpoint({
8202
5789
  method: "GET",
8203
5790
  url: douyinApiUrls$1.getLoginQrcode(p)
8204
5791
  }),
8205
- sign: "a-bogus",
5792
+ sign: [require_steps.aBogus(), require_steps.secsdk()],
8206
5793
  retryOn: ["ANTIBOT_PAGE"],
8207
5794
  retryFresh: true,
8208
5795
  response: type()
@@ -8226,7 +5813,7 @@ const musicInfo = defineDouyinEndpoint({
8226
5813
  method: "GET",
8227
5814
  url: douyinApiUrls$1.getMusicInfo(p)
8228
5815
  }),
8229
- sign: "a-bogus",
5816
+ sign: require_steps.douyinBogus(116),
8230
5817
  retryOn: ["ANTIBOT_PAGE"],
8231
5818
  retryFresh: true,
8232
5819
  response: type()
@@ -8252,7 +5839,7 @@ const parseWork = defineDouyinEndpoint({
8252
5839
  method: "GET",
8253
5840
  url: douyinApiUrls$1.getWorkDetail(p)
8254
5841
  }),
8255
- sign: "a-bogus",
5842
+ sign: require_steps.douyinBogus(184),
8256
5843
  retryOn: ["ANTIBOT_PAGE"],
8257
5844
  retryFresh: true,
8258
5845
  response: type()
@@ -8399,15 +5986,14 @@ const search = defineDouyinEndpoint({
8399
5986
  "video"
8400
5987
  ]).default("general").optional().describe("搜索类型,默认综合搜索"),
8401
5988
  number: zod.default.coerce.number().int().min(1).optional().describe("目标条数,默认 15"),
8402
- search_id: zod.default.string().optional().describe("翻页游标,一般不用传"),
8403
- offset: zod.default.coerce.number().int().min(0).optional().describe("翻页偏移,一般不用传")
5989
+ search_id: internalParam(zod.default.string().optional().describe("翻页游标,一般不用传")),
5990
+ offset: internalParam(zod.default.coerce.number().int().min(0).optional().describe("翻页偏移,一般不用传"))
8404
5991
  }),
8405
5992
  build: (p, ctx) => {
8406
5993
  const searchType = p.type ?? "general";
8407
5994
  return {
8408
5995
  method: "GET",
8409
5996
  url: douyinApiUrls$1.search({
8410
- keyword: p.query,
8411
5997
  query: p.query,
8412
5998
  type: searchType,
8413
5999
  number: p.number,
@@ -8421,7 +6007,7 @@ const search = defineDouyinEndpoint({
8421
6007
  })
8422
6008
  };
8423
6009
  },
8424
- sign: false,
6010
+ sign: [require_steps.msToken(184)],
8425
6011
  decode: (raw) => {
8426
6012
  if (typeof raw !== "string") return raw;
8427
6013
  const chunks = parseDouyinMultiJson(raw);
@@ -8503,7 +6089,7 @@ const slidesWork = defineDouyinEndpoint({
8503
6089
  method: "GET",
8504
6090
  url: douyinApiUrls$1.getWorkDetail(p)
8505
6091
  }),
8506
- sign: "a-bogus",
6092
+ sign: require_steps.douyinBogus(184),
8507
6093
  retryOn: ["ANTIBOT_PAGE"],
8508
6094
  retryFresh: true,
8509
6095
  response: type()
@@ -8532,7 +6118,7 @@ const suggestWords = defineDouyinEndpoint({
8532
6118
  query: p.query
8533
6119
  })
8534
6120
  }),
8535
- sign: "a-bogus",
6121
+ sign: require_steps.douyinBogus(184),
8536
6122
  retryOn: ["ANTIBOT_PAGE"],
8537
6123
  retryFresh: true,
8538
6124
  response: type()
@@ -8556,7 +6142,7 @@ const textWork = defineDouyinEndpoint({
8556
6142
  method: "GET",
8557
6143
  url: douyinApiUrls$1.getWorkDetail(p)
8558
6144
  }),
8559
- sign: "a-bogus",
6145
+ sign: require_steps.douyinBogus(184),
8560
6146
  retryOn: ["ANTIBOT_PAGE"],
8561
6147
  retryFresh: true,
8562
6148
  response: type()
@@ -8579,7 +6165,7 @@ const userFavoriteList = defineDouyinEndpoint({
8579
6165
  params: zod.default.object({
8580
6166
  sec_uid: zod.default.string().min(1, { error: "用户ID不能为空" }).describe("用户 sec_uid,主页链接里那段"),
8581
6167
  number: zod.default.coerce.number().int().min(1).optional().describe("目标条数,默认 18"),
8582
- max_cursor: zod.default.string().optional().describe("翻页游标,一般不用传")
6168
+ max_cursor: internalParam(zod.default.string().optional().describe("翻页游标,一般不用传"))
8583
6169
  }),
8584
6170
  build: (p, ctx) => ({
8585
6171
  method: "GET",
@@ -8589,7 +6175,7 @@ const userFavoriteList = defineDouyinEndpoint({
8589
6175
  secUid: p.sec_uid
8590
6176
  })
8591
6177
  }),
8592
- sign: "a-bogus",
6178
+ sign: require_steps.douyinBogus(184),
8593
6179
  retryOn: ["ANTIBOT_PAGE"],
8594
6180
  retryFresh: true,
8595
6181
  paginate: {
@@ -8631,7 +6217,7 @@ const userProfile$3 = defineDouyinEndpoint({
8631
6217
  secUid: p.sec_uid
8632
6218
  })
8633
6219
  }),
8634
- sign: "a-bogus",
6220
+ sign: require_steps.douyinBogus(184),
8635
6221
  retryOn: ["ANTIBOT_PAGE"],
8636
6222
  retryFresh: true,
8637
6223
  response: type()
@@ -8655,7 +6241,7 @@ const userRecommendList = defineDouyinEndpoint({
8655
6241
  params: zod.default.object({
8656
6242
  sec_uid: zod.default.string().min(1, { error: "用户ID不能为空" }).describe("用户 sec_uid,主页链接里那段"),
8657
6243
  number: zod.default.coerce.number().int().min(1).optional().describe("目标条数,默认 18"),
8658
- max_cursor: zod.default.string().optional().describe("翻页游标,一般不用传")
6244
+ max_cursor: internalParam(zod.default.string().optional().describe("翻页游标,一般不用传"))
8659
6245
  }),
8660
6246
  build: (p, ctx) => ({
8661
6247
  method: "GET",
@@ -8665,7 +6251,7 @@ const userRecommendList = defineDouyinEndpoint({
8665
6251
  secUid: p.sec_uid
8666
6252
  })
8667
6253
  }),
8668
- sign: "a-bogus",
6254
+ sign: require_steps.douyinBogus(184),
8669
6255
  retryOn: ["ANTIBOT_PAGE"],
8670
6256
  retryFresh: true,
8671
6257
  paginate: {
@@ -8702,7 +6288,7 @@ const userVideoList = defineDouyinEndpoint({
8702
6288
  params: zod.default.object({
8703
6289
  sec_uid: zod.default.string().min(1, { error: "用户ID不能为空" }).describe("用户 sec_uid,主页链接里那段"),
8704
6290
  number: zod.default.coerce.number().int().min(1).optional().describe("目标条数,默认 18"),
8705
- max_cursor: zod.default.string().optional().describe("翻页游标,一般不用传")
6291
+ max_cursor: internalParam(zod.default.string().optional().describe("翻页游标,一般不用传"))
8706
6292
  }),
8707
6293
  build: (p, ctx) => ({
8708
6294
  method: "GET",
@@ -8712,7 +6298,7 @@ const userVideoList = defineDouyinEndpoint({
8712
6298
  secUid: p.sec_uid
8713
6299
  })
8714
6300
  }),
8715
- sign: "a-bogus",
6301
+ sign: require_steps.douyinBogus(184),
8716
6302
  retryOn: ["ANTIBOT_PAGE"],
8717
6303
  retryFresh: true,
8718
6304
  paginate: {
@@ -8755,7 +6341,7 @@ const douyinRegistry = {
8755
6341
  method: "GET",
8756
6342
  url: douyinApiUrls$1.getWorkDetail(p)
8757
6343
  }),
8758
- sign: "a-bogus",
6344
+ sign: require_steps.douyinBogus(184),
8759
6345
  retryOn: ["ANTIBOT_PAGE"],
8760
6346
  retryFresh: true,
8761
6347
  response: type(),
@@ -10195,7 +7781,7 @@ var douyinSign = class {
10195
7781
  * @returns 对此地址签名后的URL查询参数
10196
7782
  */
10197
7783
  static XB(url, userAgent) {
10198
- return new XBogus().getXBogus(url, userAgent ?? defaultUserAgent).xbogus;
7784
+ return new require_steps.XBogus().getXBogus(url, userAgent ?? defaultUserAgent).xbogus;
10199
7785
  }
10200
7786
  /**
10201
7787
  * `x-secsdk-web-signature` 签名算法
@@ -10209,7 +7795,7 @@ var douyinSign = class {
10209
7795
  * @returns 需要加签时返回带签名的完整 URL,否则原样返回
10210
7796
  */
10211
7797
  static SecSdk(url, options = {}) {
10212
- return applySecsdkWebSign(url, options);
7798
+ return require_steps.applySecsdkWebSign(url, options);
10213
7799
  }
10214
7800
  /** 生成一个唯一的验证字符串 */
10215
7801
  static VerifyFpManager() {
@@ -11033,7 +8619,7 @@ const comments = defineKuaishouEndpoint({
11033
8619
  photoId: zod.default.string().min(1, { error: "photoId 不能为空" }).describe("作品 ID"),
11034
8620
  number: zod.default.coerce.number().int().min(1).max(500).optional().describe("目标条数,默认一页")
11035
8621
  }),
11036
- sign: "hxfalcon",
8622
+ sign: [require_steps.hxfalcon()],
11037
8623
  prepare: kuaishouDidPrepare,
11038
8624
  build: (p) => {
11039
8625
  const req = kuaishouApiUrls$1.comments(p);
@@ -11086,9 +8672,10 @@ const DANMAKU_SCAN_STEP_MS = 6e4;
11086
8672
  /**
11087
8673
  * 扫描范围上限(毫秒,1 小时)。
11088
8674
  *
11089
- * 多窗口是 `Promise.all` 并发发出的,范围直接决定并发请求数
11090
- * (`ceil(范围 / 60000)`)。不设上限时一个手输的 `duration` 就能让一次调用打出上千个
11091
- * 请求,所以在参数层挡住:1 小时 → 最多 60 个窗口,与 `userProfile` 的 12 个同量级。
8675
+ * 多窗口经执行器的限额并发池发出(见 execute 的 `SEGMENT_CONCURRENCY`),范围决定
8676
+ * 的是**总**请求数(`ceil(范围 / 60000)`)。不设上限时一个手输的 `duration` 就能让
8677
+ * 一次调用打出上千个请求,所以在参数层挡住:1 小时 → 最多 60 个窗口,与
8678
+ * `userProfile` 的 12 个同量级。
11092
8679
  */
11093
8680
  const DANMAKU_MAX_RANGE_MS = 36e5;
11094
8681
  /**
@@ -11289,7 +8876,7 @@ const liveRoomInfo = defineKuaishouEndpoint({
11289
8876
  description: "取直播间聚合信息(直播流、主播、公告等);只看直播间时比 `userProfile` 轻。"
11290
8877
  },
11291
8878
  params: zod.default.object({ principalId: zod.default.string().min(1, { error: "principalId 不能为空" }).describe("用户 ID") }),
11292
- sign: "hxfalcon",
8879
+ sign: [require_steps.hxfalcon()],
11293
8880
  build: (p) => {
11294
8881
  return {
11295
8882
  method: "POST",
@@ -11539,7 +9126,7 @@ const userProfile$2 = defineKuaishouEndpoint({
11539
9126
  description: "取用户主页聚合信息(资料、作品列表、直播间等)。部分数据取不到时留空,不整体失败。"
11540
9127
  },
11541
9128
  params: zod.default.object({ principalId: zod.default.string().min(1, { error: "principalId 不能为空" }).describe("用户 ID") }),
11542
- sign: "hxfalcon",
9129
+ sign: [require_steps.hxfalcon()],
11543
9130
  build: (p) => {
11544
9131
  const principalId = p.principalId;
11545
9132
  const refererPath = `profile/${encodeURIComponent(principalId)}`;
@@ -11642,7 +9229,7 @@ const userWorkList = defineKuaishouEndpoint({
11642
9229
  principalId: zod.default.string().min(1, { error: "principalId 不能为空" }).describe("用户 ID"),
11643
9230
  number: zod.default.coerce.number().int().min(1).max(500).optional().describe("目标条数,默认 12")
11644
9231
  }),
11645
- sign: "hxfalcon",
9232
+ sign: [require_steps.hxfalcon()],
11646
9233
  build: (p) => {
11647
9234
  return {
11648
9235
  method: "POST",
@@ -11722,7 +9309,7 @@ const kuaishouRegistry = {
11722
9309
  description: "日常取作品信息请用 `videoWork`;这条是唯一可能返回图集预渲染地址与同类推荐的通道。"
11723
9310
  },
11724
9311
  params: zod.default.object({ photoId: zod.default.string().min(1, { error: "photoId 不能为空" }).describe("作品 ID") }),
11725
- sign: "hxfalcon",
9312
+ sign: [require_steps.hxfalcon()],
11726
9313
  prepare: kuaishouDidPrepare,
11727
9314
  build: (p) => {
11728
9315
  const req = kuaishouApiUrls$1.videoWorkFull(p);
@@ -11968,7 +9555,7 @@ var kuaishouSign = class {
11968
9555
  const secs = deriveKuaishouSecsState(count);
11969
9556
  const scriptCount = globalThis.document?.scripts?.length ?? 0;
11970
9557
  const catVersion = runtimeState.catVersion;
11971
- const signResult = deriveKuaishouPureSignature({
9558
+ const signResult = require_steps.deriveKuaishouPureSignature({
11972
9559
  count,
11973
9560
  randomValue: Math.random(),
11974
9561
  scriptCount,
@@ -12253,7 +9840,7 @@ const emojiList = defineXiaohongshuEndpoint({
12253
9840
  signPath: apiPath
12254
9841
  };
12255
9842
  },
12256
- sign: "xhs-get",
9843
+ sign: [require_steps.xs("get", "xys")],
12257
9844
  response: type()
12258
9845
  });
12259
9846
  //#endregion
@@ -12284,8 +9871,8 @@ const homeFeed = defineXiaohongshuEndpoint({
12284
9871
  search_key: zod.default.string().optional().describe("搜索关键词,默认空字符串")
12285
9872
  }),
12286
9873
  prepare: async (ctx) => {
12287
- if (getCookieValue(ctx.cookie, "a1")) return {};
12288
- return { cookie: await createXiaohongshuGuestCookie$1(ctx.send, ctx.requestConfig) };
9874
+ if (require_steps.getCookieValue(ctx.cookie, "a1")) return {};
9875
+ return { cookie: await require_steps.createXiaohongshuGuestCookie(ctx.send, ctx.requestConfig) };
12289
9876
  },
12290
9877
  build: (p) => {
12291
9878
  const { Url, Body, apiPath } = homeFeed$1(p);
@@ -12296,7 +9883,7 @@ const homeFeed = defineXiaohongshuEndpoint({
12296
9883
  signPath: apiPath
12297
9884
  };
12298
9885
  },
12299
- sign: "xhs-post-rap",
9886
+ sign: [require_steps.xs("post", "xys"), require_steps.rap()],
12300
9887
  response: type()
12301
9888
  });
12302
9889
  //#endregion
@@ -12319,6 +9906,7 @@ const noteComments = defineXiaohongshuEndpoint({
12319
9906
  description: "取笔记评论列表。`number` 指定目标条数,翻页由端点自动完成。"
12320
9907
  },
12321
9908
  params: zod.default.object({
9909
+ cursor: internalParam(zod.default.string().optional().describe("分页游标,由端点接管,不用传")),
12322
9910
  note_id: zod.default.string().min(1, { error: "note_id 不能为空" }).describe("笔记 ID;从笔记分享链接里取"),
12323
9911
  xsec_token: zod.default.string().min(1, { error: "xsec_token 不能为空" }).describe("反爬令牌,随笔记分享链接下发"),
12324
9912
  number: zod.default.coerce.number().int().min(1).max(500).optional().describe("目标条数,默认一页")
@@ -12332,7 +9920,7 @@ const noteComments = defineXiaohongshuEndpoint({
12332
9920
  extra: { signParams }
12333
9921
  };
12334
9922
  },
12335
- sign: "xhs-get-trace",
9923
+ sign: [require_steps.xs("get", "xys"), require_steps.traceId()],
12336
9924
  paginate: {
12337
9925
  maxPageSize: 50,
12338
9926
  items: (page) => page.data?.comments ?? [],
@@ -12378,7 +9966,7 @@ const noteDetail = defineXiaohongshuEndpoint({
12378
9966
  signPath: apiPath
12379
9967
  };
12380
9968
  },
12381
- sign: "xhs-post-rap",
9969
+ sign: [require_steps.xs("post", "xys"), require_steps.rap()],
12382
9970
  response: type()
12383
9971
  });
12384
9972
  //#endregion
@@ -12402,7 +9990,7 @@ const searchNotes = defineXiaohongshuEndpoint({
12402
9990
  page_size: zod.default.coerce.number().int().min(1).max(100).optional().describe("每页条数,默认 20")
12403
9991
  }),
12404
9992
  build: (p) => {
12405
- const { Url, Body, apiPath } = searchNotes$1(p, getSearchId());
9993
+ const { Url, Body, apiPath } = searchNotes$1(p, require_steps.getSearchId());
12406
9994
  return {
12407
9995
  method: "POST",
12408
9996
  url: Url,
@@ -12410,7 +9998,7 @@ const searchNotes = defineXiaohongshuEndpoint({
12410
9998
  signPath: apiPath
12411
9999
  };
12412
10000
  },
12413
- sign: "xhs-post-rap",
10001
+ sign: [require_steps.xs("post", "xys"), require_steps.rap()],
12414
10002
  response: type()
12415
10003
  });
12416
10004
  //#endregion
@@ -12444,7 +10032,7 @@ const userNoteList = defineXiaohongshuEndpoint({
12444
10032
  extra: { signParams }
12445
10033
  };
12446
10034
  },
12447
- sign: "xhs-get-xyw-trace",
10035
+ sign: [require_steps.xs("get", "xyw"), require_steps.traceId()],
12448
10036
  response: type()
12449
10037
  });
12450
10038
  //#endregion
@@ -12478,7 +10066,7 @@ const xiaohongshuRegistry = {
12478
10066
  responseType: "text"
12479
10067
  };
12480
10068
  },
12481
- sign: "xhs-get",
10069
+ sign: [require_steps.xs("get", "xys")],
12482
10070
  decode: (raw) => {
12483
10071
  if (typeof raw !== "string") return raw;
12484
10072
  const match = raw.match(/<script>window\.__INITIAL_STATE__=(.+)<\/script>/m);
@@ -12596,7 +10184,7 @@ const unwrapScriptingResponse = (data) => {
12596
10184
  * scripting、webprofile 与 activate 三个会话请求,并返回最终 Cookie 字符串。
12597
10185
  */
12598
10186
  const createXiaohongshuGuestCookie = async (requestConfig) => {
12599
- const cryptoConfig = createXiaohongshuCryptoConfig();
10187
+ const cryptoConfig = require_steps.createXiaohongshuCryptoConfig();
12600
10188
  const signer = new _ikenxuan_xhshow_ts.Xhshow(cryptoConfig);
12601
10189
  const cookies = {
12602
10190
  ...generateA1AndWebId(),
@@ -12650,7 +10238,7 @@ const createXiaohongshuGuestCookie = async (requestConfig) => {
12650
10238
  * 小红书签名算法类
12651
10239
  */
12652
10240
  var xiaohongshuSign = class {
12653
- static client = new _ikenxuan_xhshow_ts.Xhshow(createXiaohongshuCryptoConfig());
10241
+ static client = new _ikenxuan_xhshow_ts.Xhshow(require_steps.createXiaohongshuCryptoConfig());
12654
10242
  /**
12655
10243
  * 创建包含 web_session 的小红书 Web 端游客 Cookie。
12656
10244
  *
@@ -12713,7 +10301,7 @@ var xiaohongshuSign = class {
12713
10301
  * @returns a1 cookie值
12714
10302
  */
12715
10303
  static extractA1FromCookie(cookieString) {
12716
- return getCookieValue(cookieString, "a1") ?? "";
10304
+ return require_steps.getCookieValue(cookieString, "a1") ?? "";
12717
10305
  }
12718
10306
  /**
12719
10307
  * 生成搜索ID
@@ -53731,7 +51319,8 @@ const parametersOf = (def) => {
53731
51319
  unrepresentable: "any"
53732
51320
  });
53733
51321
  const required = new Set(json.required ?? []);
53734
- return Object.entries(json.properties ?? {}).map(([name, schema]) => {
51322
+ const internalKeys = internalParamKeysOf(def.params);
51323
+ return Object.entries(json.properties ?? {}).filter(([name]) => !internalKeys.has(name)).map(([name, schema]) => {
53735
51324
  const { description, ...rest } = schema;
53736
51325
  return {
53737
51326
  name,
@@ -54040,7 +51629,7 @@ const buildOpenApiSpec = (options = {}) => {
54040
51629
  openapi: "3.1.0",
54041
51630
  info: {
54042
51631
  title: "amagi HTTP API",
54043
- version: options.version ?? "7.0.0-beta.5",
51632
+ version: options.version ?? "7.0.0-beta.7",
54044
51633
  description: [
54045
51634
  "由端点注册表派生的规范 —— 手写的那份已经漂移,这份不会(`gen-openapi --check` 进 CI)。",
54046
51635
  "",
@@ -54096,38 +51685,12 @@ const mountOpenApiSpec = (app) => {
54096
51685
  };
54097
51686
  //#endregion
54098
51687
  //#region src/client/request/profile.ts
54099
- /**
54100
- * 抖音:重抽 URL 里已有的 `msToken`。
54101
- *
54102
- * Argus 按整组 token 判定,只换 `a_bogus` 不够。`msToken` 是调用方的 URL
54103
- * 构造器写进去的(这一层**不补**参数),所以能换的只有「URL 里本来就有」的
54104
- * 那一种情况 —— 长度保持原样(作品详情 184、其余 116),因为长度本身就是
54105
- * 参数的一部分。
54106
- * @param spec - 上一次用的请求描述
54107
- * @returns 换过 msToken 的请求描述;没有可换的就原样返回
54108
- */
54109
- const refreshDouyinMsToken = (spec) => {
54110
- let url;
54111
- try {
54112
- url = new URL(spec.url);
54113
- } catch {
54114
- return spec;
54115
- }
54116
- const current = url.searchParams.get("msToken");
54117
- if (current === null || current === "") return spec;
54118
- url.searchParams.set("msToken", douyinSign$1.Mstoken(current.length));
54119
- return {
54120
- ...spec,
54121
- url: url.toString()
54122
- };
54123
- };
54124
51688
  /** 平台档案表。四个平台必须齐全 —— 少一项不会编译报错,会静默不签名 */
54125
51689
  const PROFILES = {
54126
51690
  douyin: {
54127
51691
  defaultSign: "a-bogus",
54128
51692
  retryOn: ["ANTIBOT_PAGE"],
54129
- retryFresh: true,
54130
- refresh: refreshDouyinMsToken
51693
+ retryFresh: true
54131
51694
  },
54132
51695
  bilibili: {
54133
51696
  defaultSign: false,
@@ -54261,10 +51824,15 @@ const buildRequestSpec = (config, url, amagi) => ({
54261
51824
  /**
54262
51825
  * 现场合成一个一次性的端点声明。
54263
51826
  *
54264
- * `build` 的写法值得说明:`execute` 在 `retryFresh` 重试时会把 `build` **再调一次**
54265
- * (见 `runtime/execute.ts` 的 `rebuildAt`),而我们要的是「第一次给原 spec、
54266
- * 之后每次给刷新过的 spec」。所以这里返回当前值的同时把下一个算好 ——
54267
- * 不用计数器,也就没有「第几次调用」这种隐藏状态。
51827
+ * `build` 恒返回同一个 `spec`,**这不等于「重试会原样重放」**:`execute` 在 `retryFresh`
51828
+ * 重试时会把 `build` 再调一次、**并紧接着重新签名**(见 `runtime/execute.ts` 的
51829
+ * `rebuildAt`),换参数是后者干的 —— 抖音的 `msToken` / `a_bogus` 都出自签名 step,
51830
+ * 每次签名现生成,所以重试拿到的是一整套新 token。
51831
+ *
51832
+ * 这里曾经有一个「返回当前值的同时把下一个算好」的双缓冲写法,配合平台档案的
51833
+ * `refresh` 钩子重抽 URL 里的 `msToken`。msToken 下沉到签名 step 之后那个钩子
51834
+ * 恒为无操作(URL 里本来就没有它可抽),连同这套机制一起删了 —— 裸请求的 `spec`
51835
+ * 是调用方给的、本身没有「下一次该长什么样」这种状态。
54268
51836
  * @param platform - 平台
54269
51837
  * @param spec - 本次调用的请求描述
54270
51838
  * @param method - HTTP 方法(小红书按它推默认签名器)
@@ -54273,16 +51841,11 @@ const buildRequestSpec = (config, url, amagi) => ({
54273
51841
  */
54274
51842
  const makeRequestDef = (platform, spec, method, amagi) => {
54275
51843
  const profile = requestProfileOf(platform);
54276
- let current = spec;
54277
51844
  return defineEndpoint({
54278
51845
  name: `${platform}.request`,
54279
51846
  route: REQUEST_ROUTE,
54280
51847
  params: zod.default.object({}),
54281
- build: () => {
54282
- const out = current;
54283
- current = profile.refresh ? profile.refresh(spec) : spec;
54284
- return out;
54285
- },
51848
+ build: () => spec,
54286
51849
  sign: amagi.sign ?? resolveDefaultSign(profile, method),
54287
51850
  ...profile.retryOn === void 0 ? {} : { retryOn: profile.retryOn },
54288
51851
  ...profile.retryFresh === void 0 ? {} : { retryFresh: profile.retryFresh }
@@ -54619,21 +52182,11 @@ const createStaticFetcher = (platform, registry) => {
54619
52182
  * ```typescript
54620
52183
  * import { bilibiliFetcher } from '@ikenxuan/amagi'
54621
52184
  *
52185
+ * const cookie = 'SESSDATA=xxx; bili_jct=yyy'
54622
52186
  * const result = await bilibiliFetcher.fetchVideoInfo({ bvid: 'BV1xx411c7mD' }, cookie)
54623
52187
  * ```
54624
52188
  */
54625
52189
  const bilibiliFetcher = createStaticFetcher("bilibili", bilibiliRegistry);
54626
- /**
54627
- * 创建绑定了 Cookie 和请求配置的B站 Fetcher
54628
- * @param cookie - B站 Cookie
54629
- * @param requestConfig - 请求配置 (可选)
54630
- * @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
54631
- * @example
54632
- * ```typescript
54633
- * const fetcher = createBoundBilibiliFetcher('your_cookie')
54634
- * const result = await fetcher.fetchVideoInfo({ bvid: 'BV1xx411c7mD' })
54635
- * ```
54636
- */
54637
52190
  const createBoundBilibiliFetcher = (cookie, requestConfig) => createFetcherFromRegistry("bilibili", bilibiliRegistry, makeClientCtx("bilibili", cookie, requestConfig, "bound-bilibili"));
54638
52191
  //#endregion
54639
52192
  //#region src/model/fetchers/douyin/auth.ts
@@ -54839,6 +52392,7 @@ async function validatePassportVerifyCode(options, cookie, requestConfig) {
54839
52392
  * ```typescript
54840
52393
  * import { douyinFetcher } from '@ikenxuan/amagi'
54841
52394
  *
52395
+ * const cookie = 'ttwid=xxx'
54842
52396
  * const result = await douyinFetcher.fetchVideoWork({ aweme_id: '7123456789' }, cookie)
54843
52397
  * ```
54844
52398
  */
@@ -54856,6 +52410,8 @@ const douyinFetcher = {
54856
52410
  * @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
54857
52411
  * @example
54858
52412
  * ```typescript
52413
+ * import { createBoundDouyinFetcher } from '@ikenxuan/amagi'
52414
+ *
54859
52415
  * const fetcher = createBoundDouyinFetcher('your_cookie')
54860
52416
  * const result = await fetcher.fetchVideoWork({ aweme_id: '7123456789' })
54861
52417
  * ```
@@ -54877,21 +52433,11 @@ const createBoundDouyinFetcher = (cookie, requestConfig) => createFetcherFromReg
54877
52433
  * ```typescript
54878
52434
  * import { kuaishouFetcher } from '@ikenxuan/amagi'
54879
52435
  *
52436
+ * const cookie = 'did=xxx'
54880
52437
  * const result = await kuaishouFetcher.fetchVideoWork({ photoId: '3x123456789' }, cookie)
54881
52438
  * ```
54882
52439
  */
54883
52440
  const kuaishouFetcher = createStaticFetcher("kuaishou", kuaishouRegistry);
54884
- /**
54885
- * 创建绑定了 Cookie 和请求配置的快手 Fetcher
54886
- * @param cookie - 快手 Cookie
54887
- * @param requestConfig - 请求配置 (可选)
54888
- * @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
54889
- * @example
54890
- * ```typescript
54891
- * const fetcher = createBoundKuaishouFetcher('your_cookie')
54892
- * const result = await fetcher.fetchVideoWork({ photoId: '3x123456789' })
54893
- * ```
54894
- */
54895
52441
  const createBoundKuaishouFetcher = (cookie, requestConfig) => createFetcherFromRegistry("kuaishou", kuaishouRegistry, makeClientCtx("kuaishou", cookie, requestConfig, "bound-kuaishou"));
54896
52442
  //#endregion
54897
52443
  //#region src/model/fetchers/xiaohongshu/index.ts
@@ -54910,21 +52456,11 @@ const createBoundKuaishouFetcher = (cookie, requestConfig) => createFetcherFromR
54910
52456
  * ```typescript
54911
52457
  * import { xiaohongshuFetcher } from '@ikenxuan/amagi'
54912
52458
  *
52459
+ * const cookie = 'a1=xxx; web_session=xxx'
54913
52460
  * const result = await xiaohongshuFetcher.fetchNoteDetail({ note_id: 'n1', xsec_token: 'tk' }, cookie)
54914
52461
  * ```
54915
52462
  */
54916
52463
  const xiaohongshuFetcher = createStaticFetcher("xiaohongshu", xiaohongshuRegistry);
54917
- /**
54918
- * 创建绑定了 Cookie 和请求配置的小红书 Fetcher
54919
- * @param cookie - 小红书 Cookie
54920
- * @param requestConfig - 请求配置 (可选)
54921
- * @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
54922
- * @example
54923
- * ```typescript
54924
- * const fetcher = createBoundXiaohongshuFetcher('your_cookie')
54925
- * const result = await fetcher.fetchNoteDetail({ note_id: 'n1', xsec_token: 'tk' })
54926
- * ```
54927
- */
54928
52464
  const createBoundXiaohongshuFetcher = (cookie, requestConfig) => createFetcherFromRegistry("xiaohongshu", xiaohongshuRegistry, makeClientCtx("xiaohongshu", cookie, requestConfig, "bound-xiaohongshu"));
54929
52465
  //#endregion
54930
52466
  //#region src/utils/errors.ts
@@ -55315,7 +52851,7 @@ const DouyinValidationSchemas = {
55315
52851
  aweme_id: zod.default.string({ error: "视频ID必须是字符串" }).min(1, { error: "视频ID不能为空" }),
55316
52852
  start_time: zod.default.coerce.number({ error: "开始时间必须是数字" }).int({ error: "开始时间必须是整数" }).min(0, { error: "开始时间不能小于0" }).optional(),
55317
52853
  end_time: zod.default.coerce.number({ error: "结束时间必须是数字" }).int({ error: "结束时间必须是整数" }).min(0, { error: "结束时间不能小于0" }).optional(),
55318
- duration: zod.default.coerce.number({ error: "视频时长必须是数字" }).int({ error: "视频时长必须是整数" }).min(0, { error: "视频时长不能小于0" })
52854
+ duration: zod.default.coerce.number({ error: "视频时长必须是数字" }).int({ error: "视频时长必须是整数" }).min(0, { error: "视频时长不能小于0" }).max(DANMAKU_MAX_DURATION_MS, { error: "作品总时长不能超过 7 天" })
55319
52855
  }).refine((data) => {
55320
52856
  if (data.end_time !== void 0) return data.end_time <= data.duration;
55321
52857
  return true;
@@ -56863,7 +54399,7 @@ let DynamicType = /* @__PURE__ */ function(DynamicType) {
56863
54399
  * 构建后使用 __VERSION__,开发环境从 package.json 读取
56864
54400
  */
56865
54401
  const getVersion = () => {
56866
- return "7.0.0-beta.5";
54402
+ return "7.0.0-beta.7";
56867
54403
  };
56868
54404
  const VERSION = getVersion();
56869
54405
  /**