@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.
- package/dist/api-BWAjDuTS.d.ts +802 -0
- package/dist/default/index.cjs +1 -1
- package/dist/default/index.d.ts +2 -1
- package/dist/default/index.mjs +1 -1
- package/dist/exports/compat.cjs +1 -1
- package/dist/exports/compat.d.ts +13 -7397
- package/dist/exports/compat.mjs +1 -1
- package/dist/exports/sign-steps.cjs +69 -0
- package/dist/exports/sign-steps.d.ts +202 -0
- package/dist/exports/sign-steps.mjs +65 -0
- package/dist/{index-TDTBTnNP.d.ts → index-NkyAecv9.d.ts} +34937 -38466
- package/dist/{src-blqLvYiW.mjs → src-BM1fZuVp.mjs} +345 -2809
- package/dist/{src-DixrwqqA.cjs → src-CNU3-zoM.cjs} +373 -2837
- package/dist/steps-BO8NYQHx.cjs +2872 -0
- package/dist/steps-DQlwODdh.mjs +2732 -0
- package/package.json +10 -5
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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 '
|
|
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
|
|
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
|
|
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
|
|
2071
|
-
const mixin_key = getMixinKey
|
|
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
|
|
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/
|
|
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
|
|
2379
|
+
* 创建 B站签名器表(薄壳,进程级共享)。
|
|
2576
2380
|
*
|
|
2577
|
-
*
|
|
2578
|
-
*
|
|
2381
|
+
* 不自建 `WbiSigner` —— `wbi()` / `qtparam()` 内部引用 `steps.ts` 的模块级
|
|
2382
|
+
* `sharedWbi`,所以 `createBilibiliSigners()` 调多次也共用同一个实例、同一份
|
|
2383
|
+
* `/nav` 缓存(与旧注释「每 client 一份」相反 —— 但实现上本来就只有一份,这里是
|
|
2384
|
+
* 让注释对上实现)。
|
|
2385
|
+
* @returns 注册名 → 签名器
|
|
2579
2386
|
*/
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
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/
|
|
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
|
-
* - `×tamp={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
|
-
*
|
|
2506
|
+
* 抖音签名器表:**只做「注册名 → `SignStep` 清单」的映射,不含算法**。
|
|
3302
2507
|
*
|
|
3303
|
-
*
|
|
3304
|
-
*
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
/**
|
|
3308
|
-
* 按 SDK 规则规范化 query 字符串:顺序不变,value 解码后用 `encodeURIComponent` 重编码,
|
|
3309
|
-
* key 只解码不重编码,无等号的裸参数补成 `k=`。
|
|
2508
|
+
* 算法的唯一实现在 `./steps.ts`(含前置条件校验、webid 补参、secsdk 收尾的理由)。
|
|
2509
|
+
* 依赖方向是单向的 —— `signers.ts → steps.ts`,永不反向:两份实现并存时会改一边忘一边,
|
|
2510
|
+
* 而抖音的签名是**抽样校验**的(签错时大部分请求照样成功,只是被判高风险的概率上升),
|
|
2511
|
+
* 这类分叉极难发现。
|
|
3310
2512
|
*
|
|
3311
|
-
*
|
|
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
|
-
*
|
|
3329
|
-
*
|
|
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
|
-
*
|
|
3344
|
-
* 否则会取错。
|
|
2519
|
+
* ## 为什么注册名对应的是三段清单
|
|
3345
2520
|
*
|
|
3346
|
-
*
|
|
3347
|
-
*
|
|
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
|
-
*
|
|
2524
|
+
* 其中 webid 与 secsdk 那两段都是「命中才动、否则原样返回」,对不相关的端点是无操作,
|
|
2525
|
+
* 所以可以无条件套用 —— 详见 `./steps.ts` 文件头。
|
|
3398
2526
|
*
|
|
3399
|
-
*
|
|
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
|
-
*
|
|
2529
|
+
* 它对策略表外的 path 是无操作,无条件复合进这两个名字是安全的;而 `sign` 是单槽位,
|
|
2530
|
+
* 另起一个名字只会逼出 `'a-bogus+secsdk'` 这种复合命名。要精细控制的调用方给 step
|
|
2531
|
+
* 清单,不是加注册名。
|
|
3412
2532
|
*
|
|
3413
|
-
* @
|
|
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":
|
|
3541
|
-
"x-bogus":
|
|
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`
|
|
5035
|
-
*
|
|
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 =
|
|
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/
|
|
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
|
-
*
|
|
5294
|
-
*
|
|
5295
|
-
*
|
|
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
|
-
*
|
|
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
|
-
*
|
|
5371
|
-
*
|
|
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
|
-
*
|
|
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
|
|
5390
|
-
* - `'xhs-get'`:GET
|
|
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_
|
|
5395
|
-
*
|
|
5396
|
-
* - `'xhs-get-xyw'`:GET
|
|
5397
|
-
* - `'xhs-post-xyw'`:POST
|
|
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
|
-
*
|
|
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":
|
|
5490
|
-
"xhs-get":
|
|
5491
|
-
"xhs-get-trace":
|
|
5492
|
-
"xhs-post-rap":
|
|
5493
|
-
"xhs-get-xyw":
|
|
5494
|
-
"xhs-post-xyw":
|
|
5495
|
-
"xhs-get-xyw-trace":
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
11090
|
-
*
|
|
11091
|
-
*
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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: "
|
|
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
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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.
|
|
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`
|
|
54265
|
-
*
|
|
54266
|
-
*
|
|
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.
|
|
54402
|
+
return "7.0.0-beta.7";
|
|
56867
54403
|
};
|
|
56868
54404
|
const VERSION = getVersion();
|
|
56869
54405
|
/**
|