@ikenxuan/amagi 7.0.0-beta.4 → 7.0.0-beta.6
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/default/index.cjs +1 -1
- package/dist/default/index.d.ts +2 -1
- package/dist/default/index.mjs +1 -1
- package/dist/endpoint-CxOrmO8L.d.ts +778 -0
- package/dist/exports/compat.cjs +1 -1
- package/dist/exports/compat.d.ts +25 -514
- 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/exports/signing.d.ts +58 -58
- package/dist/{index-CySQMTrB.d.ts → index-Dh29lDYw.d.ts} +22730 -18057
- package/dist/{src-ocSC1FCs.mjs → src-BHqC-g47.mjs} +397 -2948
- package/dist/{src-D1zODJ56.cjs → src-e3JbCbbg.cjs} +425 -2976
- package/dist/steps-BO8NYQHx.cjs +2872 -0
- package/dist/steps-DQlwODdh.mjs +2732 -0
- package/package.json +11 -13
- package/dist/exports/chalk.cjs +0 -9
- package/dist/exports/chalk.d.ts +0 -1
- package/dist/exports/chalk.mjs +0 -2
|
@@ -1,18 +1,18 @@
|
|
|
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
18
|
/** B站 API URL 构建类(所有方法只拼 URL,不发起请求) */
|
|
@@ -175,16 +175,54 @@ const bilibiliApiUrls$1 = new BilibiliAPI$1();
|
|
|
175
175
|
* @returns 携带 `T` 的令牌,运行时是个空对象
|
|
176
176
|
*/
|
|
177
177
|
const type = () => ({});
|
|
178
|
+
/** 反爬参数的执行阶段次序:端点作者不用手排,{@link stepsToSigner} 按这个固定次序排好再跑 */
|
|
179
|
+
const PHASE_ORDER = {
|
|
180
|
+
prepare: 0,
|
|
181
|
+
token: 1,
|
|
182
|
+
sign: 2,
|
|
183
|
+
finalize: 3
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* 把一串 {@link SignStep} 压成一个签名器:按 {@link SignPhase} 稳定排序后依次 `apply`。
|
|
187
|
+
* 同 phase 内保留数组出现顺序(`index` 兜底),所以 `[aBogus(), msToken()]` 与
|
|
188
|
+
* `[msToken(), aBogus()]` 结果一致 —— token 阶段恒先于 sign,端点作者写错顺序也不翻车。
|
|
189
|
+
*
|
|
190
|
+
* 两条路径共用它、因此共用同一套排序语义:runtime 解析 {@link SignDecl} 里的 step 清单时用它,
|
|
191
|
+
* 平台签名器表(`sign/signers.ts` 那层薄壳)也用它把注册名对应的 step 清单压成一个 {@link SignFn}。
|
|
192
|
+
* @param steps - 反爬参数清单
|
|
193
|
+
* @returns 依次应用全部步骤的签名函数
|
|
194
|
+
*/
|
|
195
|
+
const stepsToSigner = (steps) => {
|
|
196
|
+
const ordered = steps.map((step, index) => ({
|
|
197
|
+
step,
|
|
198
|
+
index
|
|
199
|
+
})).sort((a, b) => PHASE_ORDER[a.step.phase] - PHASE_ORDER[b.step.phase] || a.index - b.index).map((entry) => entry.step);
|
|
200
|
+
return async (spec, ctx) => {
|
|
201
|
+
let current = spec;
|
|
202
|
+
for (const step of ordered) current = await step.apply(current, ctx);
|
|
203
|
+
return current;
|
|
204
|
+
};
|
|
205
|
+
};
|
|
178
206
|
/**
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* `TData` 从 `response` / `normalize` / `compute` 推出。
|
|
207
|
+
* 声明一个端点。运行时是恒等函数(原样返回 `def`),全部价值在类型推导:
|
|
208
|
+
* `TParams` 从 `params` 推、`TData` 从 `response` / `compute` 推。
|
|
209
|
+
* 平台一般用各自的 `defineXxxEndpoint`(把签名器名收窄),不直接用这个。
|
|
183
210
|
* @param def - 端点声明
|
|
184
|
-
* @returns 原样返回 `def`,但带上推导好的具体类型
|
|
185
211
|
*/
|
|
186
212
|
const defineEndpoint = (def) => def;
|
|
187
213
|
//#endregion
|
|
214
|
+
//#region src/platforms/bilibili/endpoints/define.ts
|
|
215
|
+
/**
|
|
216
|
+
* B站端点声明。
|
|
217
|
+
*
|
|
218
|
+
* 就是 {@link defineEndpoint} 绑定了 B站签名器名联合 {@link BilibiliSignerName}:
|
|
219
|
+
* `sign` 的字符串分支被收窄成 `'wbi' | 'qtparam'`,写错名字编译期即报错,不必等
|
|
220
|
+
* 运行时查表失败。运行时仍是恒等函数,其余行为与通用 `defineEndpoint` 完全一致。
|
|
221
|
+
* @param def - 端点声明(`sign` 的名字受 B站签名器表约束)
|
|
222
|
+
* @returns 原样返回 `def`,带上推导好的具体类型
|
|
223
|
+
*/
|
|
224
|
+
const defineBilibiliEndpoint = (def) => defineEndpoint(def);
|
|
225
|
+
//#endregion
|
|
188
226
|
//#region src/platforms/bilibili/endpoints/articleCards.ts
|
|
189
227
|
/**
|
|
190
228
|
* 专栏显示卡片信息(单请求)。
|
|
@@ -192,7 +230,7 @@ const defineEndpoint = (def) => def;
|
|
|
192
230
|
* 与旧版一致:`getArticleCards` GET,无签名。
|
|
193
231
|
* `ids` 可传字符串或字符串数组。
|
|
194
232
|
*/
|
|
195
|
-
const articleCards =
|
|
233
|
+
const articleCards = defineBilibiliEndpoint({
|
|
196
234
|
name: "bilibili.articleCards",
|
|
197
235
|
route: "/fetch_article_card",
|
|
198
236
|
doc: {
|
|
@@ -214,7 +252,7 @@ const articleCards = defineEndpoint({
|
|
|
214
252
|
*
|
|
215
253
|
* 与旧版一致:`getArticleContent` GET,无签名。
|
|
216
254
|
*/
|
|
217
|
-
const articleContent =
|
|
255
|
+
const articleContent = defineBilibiliEndpoint({
|
|
218
256
|
name: "bilibili.articleContent",
|
|
219
257
|
route: "/fetch_article_content",
|
|
220
258
|
doc: {
|
|
@@ -236,7 +274,7 @@ const articleContent = defineEndpoint({
|
|
|
236
274
|
*
|
|
237
275
|
* 与旧版一致:`getArticleInfo` GET,无签名。
|
|
238
276
|
*/
|
|
239
|
-
const articleInfo =
|
|
277
|
+
const articleInfo = defineBilibiliEndpoint({
|
|
240
278
|
name: "bilibili.articleInfo",
|
|
241
279
|
route: "/fetch_article_info",
|
|
242
280
|
doc: {
|
|
@@ -258,7 +296,7 @@ const articleInfo = defineEndpoint({
|
|
|
258
296
|
*
|
|
259
297
|
* 与旧版一致:`getArticleListInfo` GET,无签名。
|
|
260
298
|
*/
|
|
261
|
-
const articleListInfo =
|
|
299
|
+
const articleListInfo = defineBilibiliEndpoint({
|
|
262
300
|
name: "bilibili.articleListInfo",
|
|
263
301
|
route: "/fetch_column_info",
|
|
264
302
|
doc: {
|
|
@@ -334,7 +372,7 @@ const bv2av = (bvid) => {
|
|
|
334
372
|
*
|
|
335
373
|
* 返回形状:`{ bvid }`。
|
|
336
374
|
*/
|
|
337
|
-
const avToBv =
|
|
375
|
+
const avToBv = defineBilibiliEndpoint({
|
|
338
376
|
name: "bilibili.avToBv",
|
|
339
377
|
route: "/av_to_bv",
|
|
340
378
|
doc: {
|
|
@@ -353,7 +391,7 @@ const avToBv = defineEndpoint({
|
|
|
353
391
|
* 与旧版一致:`getBangumiInfo` GET,无签名。
|
|
354
392
|
* `ep_id` 与 `season_id` 至少传其一,两者都要求非空串(`ep_id` 优先)。
|
|
355
393
|
*/
|
|
356
|
-
const bangumiInfo =
|
|
394
|
+
const bangumiInfo = defineBilibiliEndpoint({
|
|
357
395
|
name: "bilibili.bangumiInfo",
|
|
358
396
|
route: "/fetch_bangumi_video_info",
|
|
359
397
|
doc: {
|
|
@@ -387,7 +425,7 @@ const bangumiInfo = defineEndpoint({
|
|
|
387
425
|
* 与旧版一致:`getBangumiStream` GET + qtparam 签名,
|
|
388
426
|
* `ep_id` 去掉 `ep` 前缀。
|
|
389
427
|
*/
|
|
390
|
-
const bangumiStream =
|
|
428
|
+
const bangumiStream = defineBilibiliEndpoint({
|
|
391
429
|
name: "bilibili.bangumiStream",
|
|
392
430
|
route: "/fetch_bangumi_video_playurl",
|
|
393
431
|
doc: {
|
|
@@ -405,7 +443,7 @@ const bangumiStream = defineEndpoint({
|
|
|
405
443
|
ep_id: p.ep_id.replace("ep", "")
|
|
406
444
|
})
|
|
407
445
|
}),
|
|
408
|
-
sign:
|
|
446
|
+
sign: [require_steps.qtparam()],
|
|
409
447
|
retryOn: ["RISK_CONTROL"],
|
|
410
448
|
response: type()
|
|
411
449
|
});
|
|
@@ -418,7 +456,7 @@ const bangumiStream = defineEndpoint({
|
|
|
418
456
|
*
|
|
419
457
|
* 返回 `{ aid: number }`,不带 `av` 前缀。
|
|
420
458
|
*/
|
|
421
|
-
const bvToAv =
|
|
459
|
+
const bvToAv = defineBilibiliEndpoint({
|
|
422
460
|
name: "bilibili.bvToAv",
|
|
423
461
|
route: "/bv_to_av",
|
|
424
462
|
doc: {
|
|
@@ -437,7 +475,7 @@ const bvToAv = defineEndpoint({
|
|
|
437
475
|
* 与旧版一致:`getCaptchaFromVoucher` POST,
|
|
438
476
|
* body 为 `{ csrf?, v_voucher }`,无签名。
|
|
439
477
|
*/
|
|
440
|
-
const captchaFromVoucher =
|
|
478
|
+
const captchaFromVoucher = defineBilibiliEndpoint({
|
|
441
479
|
name: "bilibili.captchaFromVoucher",
|
|
442
480
|
route: "/apply_captcha",
|
|
443
481
|
doc: {
|
|
@@ -467,7 +505,7 @@ const captchaFromVoucher = defineEndpoint({
|
|
|
467
505
|
*
|
|
468
506
|
* 与旧版一致:`getCommentReplies` GET,无签名。
|
|
469
507
|
*/
|
|
470
|
-
const commentReplies$1 =
|
|
508
|
+
const commentReplies$1 = defineBilibiliEndpoint({
|
|
471
509
|
name: "bilibili.commentReplies",
|
|
472
510
|
route: "/fetch_comment_reply",
|
|
473
511
|
doc: {
|
|
@@ -530,7 +568,7 @@ const COMMENT_TYPES$1 = [
|
|
|
530
568
|
* 按 `rpid` 去重 + 截断到 `number`。
|
|
531
569
|
* - 未开放评论区由主接口返回非 0 的 `code`,由 judge 判失败。
|
|
532
570
|
*/
|
|
533
|
-
const comments$2 =
|
|
571
|
+
const comments$2 = defineBilibiliEndpoint({
|
|
534
572
|
name: "bilibili.comments",
|
|
535
573
|
route: "/fetch_work_comments",
|
|
536
574
|
doc: {
|
|
@@ -555,7 +593,7 @@ const comments$2 = defineEndpoint({
|
|
|
555
593
|
mode: p.mode
|
|
556
594
|
})
|
|
557
595
|
}),
|
|
558
|
-
sign:
|
|
596
|
+
sign: [require_steps.wbi()],
|
|
559
597
|
paginate: {
|
|
560
598
|
maxPageSize: 100,
|
|
561
599
|
items: (page) => page.data?.replies ?? [],
|
|
@@ -569,20 +607,18 @@ const comments$2 = defineEndpoint({
|
|
|
569
607
|
...params,
|
|
570
608
|
pagination_str: next ?? params.pagination_str
|
|
571
609
|
};
|
|
610
|
+
},
|
|
611
|
+
merge: ({ lastPage, items }, params) => {
|
|
612
|
+
const sliced = Array.from(new Map(items.map((item) => [item.rpid, item])).values()).slice(0, params.number ?? 20);
|
|
613
|
+
return {
|
|
614
|
+
...lastPage,
|
|
615
|
+
data: {
|
|
616
|
+
...lastPage.data,
|
|
617
|
+
replies: sliced
|
|
618
|
+
}
|
|
619
|
+
};
|
|
572
620
|
}
|
|
573
621
|
},
|
|
574
|
-
normalize: (decoded, params) => {
|
|
575
|
-
const { lastPage, items } = decoded;
|
|
576
|
-
const page = lastPage;
|
|
577
|
-
const sliced = Array.from(new Map(items.map((item) => [item.rpid, item])).values()).slice(0, params.number ?? 20);
|
|
578
|
-
return {
|
|
579
|
-
...page ?? {},
|
|
580
|
-
data: {
|
|
581
|
-
...page?.data ?? {},
|
|
582
|
-
replies: sliced
|
|
583
|
-
}
|
|
584
|
-
};
|
|
585
|
-
},
|
|
586
622
|
retryOn: ["RISK_CONTROL"],
|
|
587
623
|
response: type()
|
|
588
624
|
});
|
|
@@ -618,7 +654,7 @@ const COMMENT_TYPES = [
|
|
|
618
654
|
*
|
|
619
655
|
* 与旧版一致:`getDynamicDetail` GET,无签名。
|
|
620
656
|
*/
|
|
621
|
-
const dynamicDetail =
|
|
657
|
+
const dynamicDetail = defineBilibiliEndpoint({
|
|
622
658
|
name: "bilibili.dynamicDetail",
|
|
623
659
|
route: "/fetch_dynamic_info",
|
|
624
660
|
doc: {
|
|
@@ -641,7 +677,7 @@ const dynamicDetail = defineEndpoint({
|
|
|
641
677
|
* 与旧版一致:`getEmojiList` GET,无签名。
|
|
642
678
|
* 它是网络请求(表情面板接口),不是本地计算。
|
|
643
679
|
*/
|
|
644
|
-
const emojiList$4 =
|
|
680
|
+
const emojiList$4 = defineBilibiliEndpoint({
|
|
645
681
|
name: "bilibili.emojiList",
|
|
646
682
|
route: "/fetch_emoji_list",
|
|
647
683
|
doc: {
|
|
@@ -663,7 +699,7 @@ const emojiList$4 = defineEndpoint({
|
|
|
663
699
|
*
|
|
664
700
|
* 与旧版一致:`getLiveRoomInfo` GET,无签名。
|
|
665
701
|
*/
|
|
666
|
-
const liveRoomInfo$2 =
|
|
702
|
+
const liveRoomInfo$2 = defineBilibiliEndpoint({
|
|
667
703
|
name: "bilibili.liveRoomInfo",
|
|
668
704
|
route: "/fetch_live_room_detail",
|
|
669
705
|
doc: {
|
|
@@ -685,7 +721,7 @@ const liveRoomInfo$2 = defineEndpoint({
|
|
|
685
721
|
*
|
|
686
722
|
* 与旧版一致:`getLiveRoomInit` GET,无签名。
|
|
687
723
|
*/
|
|
688
|
-
const liveRoomInit =
|
|
724
|
+
const liveRoomInit = defineBilibiliEndpoint({
|
|
689
725
|
name: "bilibili.liveRoomInit",
|
|
690
726
|
route: "/fetch_liveroom_def",
|
|
691
727
|
doc: {
|
|
@@ -707,7 +743,7 @@ const liveRoomInit = defineEndpoint({
|
|
|
707
743
|
*
|
|
708
744
|
* 与旧版一致:`getLoginQrcode` GET,无签名。
|
|
709
745
|
*/
|
|
710
|
-
const loginQrcode$1 =
|
|
746
|
+
const loginQrcode$1 = defineBilibiliEndpoint({
|
|
711
747
|
name: "bilibili.loginQrcode",
|
|
712
748
|
route: "/new_login_qrcode",
|
|
713
749
|
doc: {
|
|
@@ -729,7 +765,7 @@ const loginQrcode$1 = defineEndpoint({
|
|
|
729
765
|
*
|
|
730
766
|
* 与旧版一致:`getLoginStatus` GET,无签名。
|
|
731
767
|
*/
|
|
732
|
-
const loginStatus =
|
|
768
|
+
const loginStatus = defineBilibiliEndpoint({
|
|
733
769
|
name: "bilibili.loginStatus",
|
|
734
770
|
route: "/login_basic_info",
|
|
735
771
|
doc: {
|
|
@@ -752,7 +788,7 @@ const loginStatus = defineEndpoint({
|
|
|
752
788
|
* 与旧版一致:`getQrcodeStatus` GET,无签名。
|
|
753
789
|
* **返回形状**:只返回平台响应体(`code` / `data` / `message`),不透出响应 headers。
|
|
754
790
|
*/
|
|
755
|
-
const qrcodeStatus =
|
|
791
|
+
const qrcodeStatus = defineBilibiliEndpoint({
|
|
756
792
|
name: "bilibili.qrcodeStatus",
|
|
757
793
|
route: "/check_qrcode",
|
|
758
794
|
doc: {
|
|
@@ -774,7 +810,7 @@ const qrcodeStatus = defineEndpoint({
|
|
|
774
810
|
*
|
|
775
811
|
* 与旧版一致:`getUploaderTotalViews` GET,无签名。
|
|
776
812
|
*/
|
|
777
|
-
const uploaderTotalViews =
|
|
813
|
+
const uploaderTotalViews = defineBilibiliEndpoint({
|
|
778
814
|
name: "bilibili.uploaderTotalViews",
|
|
779
815
|
route: "/fetch_user_full_view",
|
|
780
816
|
doc: {
|
|
@@ -796,7 +832,7 @@ const uploaderTotalViews = defineEndpoint({
|
|
|
796
832
|
*
|
|
797
833
|
* 与旧版一致:`getUserCard` GET,无签名。
|
|
798
834
|
*/
|
|
799
|
-
const userCard =
|
|
835
|
+
const userCard = defineBilibiliEndpoint({
|
|
800
836
|
name: "bilibili.userCard",
|
|
801
837
|
route: "/fetch_user_profile",
|
|
802
838
|
doc: {
|
|
@@ -812,112 +848,6 @@ const userCard = defineEndpoint({
|
|
|
812
848
|
response: type()
|
|
813
849
|
});
|
|
814
850
|
//#endregion
|
|
815
|
-
//#region src/contracts/request.ts
|
|
816
|
-
/**
|
|
817
|
-
* 大小写不敏感的 header 容器。
|
|
818
|
-
*
|
|
819
|
-
* HTTP header 名本身大小写不敏感,用普通对象装就会出现「写了 `Cookie` 却读
|
|
820
|
-
* `cookie` 读不到」这类静默失配。本类把「大小写」这个变量彻底消掉。
|
|
821
|
-
*
|
|
822
|
-
* 语义:
|
|
823
|
-
* - 查找、判断、删除全部大小写不敏感。
|
|
824
|
-
* - 写入是「后写覆盖」:值覆盖,**显示用的大小写也跟着最后一次写入**,
|
|
825
|
-
* 所以 `set('User-Agent', a)` 之后 `set('user-agent', b)` 只会留下一个
|
|
826
|
-
* `user-agent: b`,不可能出现两条同名 header。
|
|
827
|
-
* - `undefined` / `null` 的值视为「不写这个 header」,方便直接摊入可选字段。
|
|
828
|
-
*/
|
|
829
|
-
var AmagiHeaders = class AmagiHeaders {
|
|
830
|
-
/** key 是小写化的 header 名,value 保留最后一次写入的原始大小写与值 */
|
|
831
|
-
entries = /* @__PURE__ */ new Map();
|
|
832
|
-
/**
|
|
833
|
-
* @param init - 初始 header,可以是另一个 `AmagiHeaders` 或普通对象
|
|
834
|
-
*/
|
|
835
|
-
constructor(init) {
|
|
836
|
-
this.merge(init);
|
|
837
|
-
}
|
|
838
|
-
/**
|
|
839
|
-
* 读取 header 值,大小写不敏感
|
|
840
|
-
* @param name - header 名,任意大小写
|
|
841
|
-
* @returns 值,不存在时返回 `undefined`
|
|
842
|
-
*/
|
|
843
|
-
get(name) {
|
|
844
|
-
return this.entries.get(name.toLowerCase())?.value;
|
|
845
|
-
}
|
|
846
|
-
/**
|
|
847
|
-
* 判断 header 是否存在,大小写不敏感
|
|
848
|
-
* @param name - header 名,任意大小写
|
|
849
|
-
* @returns 存在则返回 `true`
|
|
850
|
-
*/
|
|
851
|
-
has(name) {
|
|
852
|
-
return this.entries.has(name.toLowerCase());
|
|
853
|
-
}
|
|
854
|
-
/**
|
|
855
|
-
* 写入 header。同名(忽略大小写)时覆盖值与显示大小写
|
|
856
|
-
* @param name - header 名
|
|
857
|
-
* @param value - 值。`undefined` / `null` 表示不写入
|
|
858
|
-
* @returns 自身,便于链式调用
|
|
859
|
-
*/
|
|
860
|
-
set(name, value) {
|
|
861
|
-
if (value === void 0 || value === null) return this;
|
|
862
|
-
this.entries.set(name.toLowerCase(), {
|
|
863
|
-
name,
|
|
864
|
-
value: String(value)
|
|
865
|
-
});
|
|
866
|
-
return this;
|
|
867
|
-
}
|
|
868
|
-
/**
|
|
869
|
-
* 删除 header,大小写不敏感
|
|
870
|
-
* @param name - header 名
|
|
871
|
-
* @returns 原本存在则返回 `true`
|
|
872
|
-
*/
|
|
873
|
-
delete(name) {
|
|
874
|
-
return this.entries.delete(name.toLowerCase());
|
|
875
|
-
}
|
|
876
|
-
/**
|
|
877
|
-
* 合并另一组 header,后来者覆盖
|
|
878
|
-
* @param input - 待合并的 header
|
|
879
|
-
* @returns 自身,便于链式调用
|
|
880
|
-
*/
|
|
881
|
-
merge(input) {
|
|
882
|
-
if (!input) return this;
|
|
883
|
-
const pairs = input instanceof AmagiHeaders ? input.toEntries() : Object.entries(input);
|
|
884
|
-
for (const [name, value] of pairs) this.set(name, value);
|
|
885
|
-
return this;
|
|
886
|
-
}
|
|
887
|
-
/** header 条数 */
|
|
888
|
-
get size() {
|
|
889
|
-
return this.entries.size;
|
|
890
|
-
}
|
|
891
|
-
/**
|
|
892
|
-
* 全部 header 名,保留最后一次写入的大小写
|
|
893
|
-
* @returns header 名数组
|
|
894
|
-
*/
|
|
895
|
-
keys() {
|
|
896
|
-
return [...this.entries.values()].map((e) => e.name);
|
|
897
|
-
}
|
|
898
|
-
/**
|
|
899
|
-
* 全部 `[名, 值]` 对,名保留最后一次写入的大小写
|
|
900
|
-
* @returns 键值对数组
|
|
901
|
-
*/
|
|
902
|
-
toEntries() {
|
|
903
|
-
return [...this.entries.values()].map((e) => [e.name, e.value]);
|
|
904
|
-
}
|
|
905
|
-
/**
|
|
906
|
-
* 转成普通对象,用于交给 axios
|
|
907
|
-
* @returns 普通 header 对象,键保留最后一次写入的大小写
|
|
908
|
-
*/
|
|
909
|
-
toJSON() {
|
|
910
|
-
return Object.fromEntries(this.toEntries());
|
|
911
|
-
}
|
|
912
|
-
/**
|
|
913
|
-
* 深拷贝一份,避免下游改写上游的 header
|
|
914
|
-
* @returns 新的 `AmagiHeaders` 实例
|
|
915
|
-
*/
|
|
916
|
-
clone() {
|
|
917
|
-
return new AmagiHeaders(this);
|
|
918
|
-
}
|
|
919
|
-
};
|
|
920
|
-
//#endregion
|
|
921
851
|
//#region src/platforms/bilibili/endpoints/userDynamicList.ts
|
|
922
852
|
/**
|
|
923
853
|
* 用户空间动态(wbi 签名 + Origin/Referer 注入)。
|
|
@@ -927,7 +857,7 @@ var AmagiHeaders = class AmagiHeaders {
|
|
|
927
857
|
* `Referer: https://space.bilibili.com/{host_mid}/dynamic`
|
|
928
858
|
* (调用方显式传了 Referer 则不覆盖)。
|
|
929
859
|
*/
|
|
930
|
-
const userDynamicList =
|
|
860
|
+
const userDynamicList = defineBilibiliEndpoint({
|
|
931
861
|
name: "bilibili.userDynamicList",
|
|
932
862
|
route: "/fetch_user_dynamic",
|
|
933
863
|
doc: {
|
|
@@ -936,7 +866,7 @@ const userDynamicList = defineEndpoint({
|
|
|
936
866
|
},
|
|
937
867
|
params: zod.default.object({ host_mid: zod.default.coerce.number().int().min(1, { error: "UP主UID必须大于等于1" }).describe("UP 主 UID") }),
|
|
938
868
|
build: (p, ctx) => {
|
|
939
|
-
const callerHeaders = new AmagiHeaders(ctx.requestConfig?.headers);
|
|
869
|
+
const callerHeaders = new require_steps.AmagiHeaders(ctx.requestConfig?.headers);
|
|
940
870
|
const headers = { Origin: "https://space.bilibili.com" };
|
|
941
871
|
if (!callerHeaders.has("referer")) headers.Referer = `https://space.bilibili.com/${p.host_mid}/dynamic`;
|
|
942
872
|
return {
|
|
@@ -945,7 +875,7 @@ const userDynamicList = defineEndpoint({
|
|
|
945
875
|
headers
|
|
946
876
|
};
|
|
947
877
|
},
|
|
948
|
-
sign:
|
|
878
|
+
sign: [require_steps.wbi()],
|
|
949
879
|
retryOn: ["RISK_CONTROL"],
|
|
950
880
|
response: type()
|
|
951
881
|
});
|
|
@@ -956,7 +886,7 @@ const userDynamicList = defineEndpoint({
|
|
|
956
886
|
*
|
|
957
887
|
* 与旧版一致:`getUserLiveStatus` GET,无签名。
|
|
958
888
|
*/
|
|
959
|
-
const userLiveStatus =
|
|
889
|
+
const userLiveStatus = defineBilibiliEndpoint({
|
|
960
890
|
name: "bilibili.userLiveStatus",
|
|
961
891
|
route: "/fetch_user_live_status",
|
|
962
892
|
doc: {
|
|
@@ -978,7 +908,7 @@ const userLiveStatus = defineEndpoint({
|
|
|
978
908
|
*
|
|
979
909
|
* 与旧版一致:`getUserSpaceInfo` GET + wbi 签名。
|
|
980
910
|
*/
|
|
981
|
-
const userSpaceInfo =
|
|
911
|
+
const userSpaceInfo = defineBilibiliEndpoint({
|
|
982
912
|
name: "bilibili.userSpaceInfo",
|
|
983
913
|
route: "/fetch_user_space_info",
|
|
984
914
|
doc: {
|
|
@@ -990,7 +920,7 @@ const userSpaceInfo = defineEndpoint({
|
|
|
990
920
|
method: "GET",
|
|
991
921
|
url: bilibiliApiUrls$1.getUserSpaceInfo(p)
|
|
992
922
|
}),
|
|
993
|
-
sign:
|
|
923
|
+
sign: [require_steps.wbi()],
|
|
994
924
|
retryOn: ["RISK_CONTROL"],
|
|
995
925
|
response: type()
|
|
996
926
|
});
|
|
@@ -1002,7 +932,7 @@ const userSpaceInfo = defineEndpoint({
|
|
|
1002
932
|
* 与旧版一致:`validateCaptcha` POST,
|
|
1003
933
|
* body 为 `{ challenge, token, validate, seccode, csrf? }`,无签名。
|
|
1004
934
|
*/
|
|
1005
|
-
const validateCaptcha =
|
|
935
|
+
const validateCaptcha = defineBilibiliEndpoint({
|
|
1006
936
|
name: "bilibili.validateCaptcha",
|
|
1007
937
|
route: "/validate_captcha",
|
|
1008
938
|
doc: {
|
|
@@ -1139,7 +1069,7 @@ function parseDmSegMobileReply(data) {
|
|
|
1139
1069
|
* judge 恒成功:二进制响应没有 `code` 字段,交给 decode 解析;
|
|
1140
1070
|
* 解析失败由 execute 归因为 `parse` / `DECODE_FAILED`。
|
|
1141
1071
|
*/
|
|
1142
|
-
const videoDanmaku =
|
|
1072
|
+
const videoDanmaku = defineBilibiliEndpoint({
|
|
1143
1073
|
name: "bilibili.videoDanmaku",
|
|
1144
1074
|
route: "/fetch_danmaku",
|
|
1145
1075
|
doc: {
|
|
@@ -1169,7 +1099,7 @@ const videoDanmaku = defineEndpoint({
|
|
|
1169
1099
|
* 共 27 条路由。
|
|
1170
1100
|
*/
|
|
1171
1101
|
const bilibiliRegistry = {
|
|
1172
|
-
videoInfo:
|
|
1102
|
+
videoInfo: defineBilibiliEndpoint({
|
|
1173
1103
|
name: "bilibili.videoInfo",
|
|
1174
1104
|
route: "/fetch_one_video",
|
|
1175
1105
|
doc: {
|
|
@@ -1184,7 +1114,7 @@ const bilibiliRegistry = {
|
|
|
1184
1114
|
retryOn: ["RISK_CONTROL"],
|
|
1185
1115
|
response: type()
|
|
1186
1116
|
}),
|
|
1187
|
-
videoStream:
|
|
1117
|
+
videoStream: defineBilibiliEndpoint({
|
|
1188
1118
|
name: "bilibili.videoStream",
|
|
1189
1119
|
route: "/fetch_video_playurl",
|
|
1190
1120
|
doc: {
|
|
@@ -1199,7 +1129,7 @@ const bilibiliRegistry = {
|
|
|
1199
1129
|
method: "GET",
|
|
1200
1130
|
url: bilibiliApiUrls$1.getVideoStream(p)
|
|
1201
1131
|
}),
|
|
1202
|
-
sign:
|
|
1132
|
+
sign: [require_steps.qtparam()],
|
|
1203
1133
|
retryOn: ["RISK_CONTROL"],
|
|
1204
1134
|
response: type()
|
|
1205
1135
|
}),
|
|
@@ -1980,7 +1910,7 @@ const bilibiliApiUrls = new BilibiliAPI();
|
|
|
1980
1910
|
/**
|
|
1981
1911
|
* 混合密钥编码表,用于对 imgKey 和 subKey 进行字符顺序打乱编码
|
|
1982
1912
|
*/
|
|
1983
|
-
const mixinKeyEncTab
|
|
1913
|
+
const mixinKeyEncTab = [
|
|
1984
1914
|
46,
|
|
1985
1915
|
47,
|
|
1986
1916
|
18,
|
|
@@ -2051,7 +1981,7 @@ const mixinKeyEncTab$1 = [
|
|
|
2051
1981
|
* @param orig - 原始字符串数组,通常是 img_key + sub_key 的字符数组
|
|
2052
1982
|
* @returns 返回经过编码表打乱后的32位字符串
|
|
2053
1983
|
*/
|
|
2054
|
-
const getMixinKey
|
|
1984
|
+
const getMixinKey = (orig) => mixinKeyEncTab.map((n) => orig[n]).join("").slice(0, 32);
|
|
2055
1985
|
/**
|
|
2056
1986
|
* 为请求参数进行 WBI 签名
|
|
2057
1987
|
* @param params - 请求参数对象,键值对形式
|
|
@@ -2059,8 +1989,8 @@ const getMixinKey$1 = (orig) => mixinKeyEncTab$1.map((n) => orig[n]).join("").sl
|
|
|
2059
1989
|
* @param sub_key - 子密钥
|
|
2060
1990
|
* @returns 返回包含时间戳和签名的查询字符串
|
|
2061
1991
|
*/
|
|
2062
|
-
const encWbi
|
|
2063
|
-
const mixin_key = getMixinKey
|
|
1992
|
+
const encWbi = (params, img_key, sub_key) => {
|
|
1993
|
+
const mixin_key = getMixinKey(img_key + sub_key);
|
|
2064
1994
|
const curr_time = Math.round(Date.now() / 1e3);
|
|
2065
1995
|
const chr_filter = /[!'()*]/g;
|
|
2066
1996
|
Object.assign(params, { wts: curr_time });
|
|
@@ -2095,7 +2025,7 @@ const wbi_sign = async (BASEURL, cookie) => {
|
|
|
2095
2025
|
const url = new URL(BASEURL);
|
|
2096
2026
|
const params = {};
|
|
2097
2027
|
for (const [key, value] of url.searchParams.entries()) params[key] = value;
|
|
2098
|
-
return encWbi
|
|
2028
|
+
return encWbi(params, web_keys.img_key, web_keys.sub_key);
|
|
2099
2029
|
};
|
|
2100
2030
|
//#endregion
|
|
2101
2031
|
//#region src/platforms/legacy/bilibili/qtparam.ts
|
|
@@ -2178,10 +2108,10 @@ const generateSecChUa = (userAgent) => {
|
|
|
2178
2108
|
* GET / POST),不属于基线。
|
|
2179
2109
|
*/
|
|
2180
2110
|
const createBilibiliConfig = (cookie, requestConfig) => {
|
|
2181
|
-
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";
|
|
2111
|
+
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";
|
|
2182
2112
|
const secChUa = generateSecChUa(userAgent);
|
|
2183
2113
|
return {
|
|
2184
|
-
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),
|
|
2114
|
+
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),
|
|
2185
2115
|
requestConfig: {
|
|
2186
2116
|
timeout: 1e4,
|
|
2187
2117
|
...requestConfig
|
|
@@ -2399,254 +2329,20 @@ const bilibiliJudge = (raw, http) => {
|
|
|
2399
2329
|
};
|
|
2400
2330
|
};
|
|
2401
2331
|
//#endregion
|
|
2402
|
-
//#region src/platforms/bilibili/sign/
|
|
2403
|
-
/**
|
|
2404
|
-
* B站视频流签名器(qtparam 前置)。
|
|
2405
|
-
*
|
|
2406
|
-
* cookie 取自 `ctx.cookie`(由 `resolveBoundRequest` 用 `AmagiHeaders`
|
|
2407
|
-
* 大小写不敏感地解析),这里不自己翻 headers。
|
|
2408
|
-
*
|
|
2409
|
-
* 行为:
|
|
2410
|
-
* - cookie 为空 → 只带 `&platform=html5`(未登录降级)。
|
|
2411
|
-
* - 已登录 → 打 `/nav` 取 `vipStatus`(复用 wbi 签名器的 TTL 缓存):
|
|
2412
|
-
* VIP 带 `&fnval=4048&fourk=1`,非 VIP 带 `&qn=64&fnval=16`,
|
|
2413
|
-
* 再追加 wbi 签名(签名基于**未加 fnval 的原始 URL**)。
|
|
2414
|
-
*/
|
|
2415
|
-
/** 高清档位:qn[3] = 64 = 720P */
|
|
2416
|
-
const QN = [
|
|
2417
|
-
6,
|
|
2418
|
-
16,
|
|
2419
|
-
32,
|
|
2420
|
-
64,
|
|
2421
|
-
74,
|
|
2422
|
-
80,
|
|
2423
|
-
112,
|
|
2424
|
-
116,
|
|
2425
|
-
120,
|
|
2426
|
-
125,
|
|
2427
|
-
126,
|
|
2428
|
-
127
|
|
2429
|
-
];
|
|
2430
|
-
/** VIP 的 fnval:16(DASH) | 64(HDR) | 128(4K) | 256(杜比) | 512(杜比视界) | 1024(8K) | 2048(AV1) = 4048 */
|
|
2431
|
-
const VIP_FNVAL = 4048;
|
|
2432
|
-
/**
|
|
2433
|
-
* 创建 qtparam 签名器。
|
|
2434
|
-
*
|
|
2435
|
-
* 依赖同一个 {@link WbiSigner} 实例:登录态与 wbi keys 共用一次 `/nav` 的 TTL 缓存。
|
|
2436
|
-
* @param wbi - wbi 签名器实例(与 `sign: 'wbi'` 的端点共用)
|
|
2437
|
-
* @returns 签名器函数,挂到 `sign: 'qtparam'`
|
|
2438
|
-
*/
|
|
2439
|
-
const createQtparamSigner = (wbi) => {
|
|
2440
|
-
return async (spec, ctx) => {
|
|
2441
|
-
if (ctx.cookie === "") return {
|
|
2442
|
-
...spec,
|
|
2443
|
-
url: spec.url + "&platform=html5"
|
|
2444
|
-
};
|
|
2445
|
-
const isvip = (await wbi.getNav(ctx)).data?.vipStatus === 1;
|
|
2446
|
-
const qtParams = isvip ? `&fnval=${VIP_FNVAL}&fourk=1` : `&qn=${QN[3]}&fnval=16`;
|
|
2447
|
-
const signed = await wbi.sign(spec, ctx);
|
|
2448
|
-
return {
|
|
2449
|
-
...spec,
|
|
2450
|
-
url: signed.url + qtParams,
|
|
2451
|
-
extra: {
|
|
2452
|
-
...spec.extra,
|
|
2453
|
-
isvip
|
|
2454
|
-
}
|
|
2455
|
-
};
|
|
2456
|
-
};
|
|
2457
|
-
};
|
|
2458
|
-
//#endregion
|
|
2459
|
-
//#region src/platforms/bilibili/sign/wbi.ts
|
|
2460
|
-
/**
|
|
2461
|
-
* B站 wbi 签名器(实例级)。
|
|
2462
|
-
*
|
|
2463
|
-
* 两处实现要点:
|
|
2464
|
-
* - **走 transport**:`getNav` 用 `ctx.send` 发 `/nav`(`reason: 'prepare'`
|
|
2465
|
-
* 进 trace),与主请求走同一条路。
|
|
2466
|
-
* - **TTL 缓存随 client 实例**:keys 缓存在实例里,TTL 内连续签名只打一次
|
|
2467
|
-
* `/nav`。
|
|
2468
|
-
*
|
|
2469
|
-
* 签名算法本身(`mixinKeyEncTab` / `encWbi`)与旧版逐字一致。
|
|
2470
|
-
*/
|
|
2471
|
-
/** wbi 密钥的 TTL(毫秒)。30 分钟内复用缓存 */
|
|
2472
|
-
const WBI_TTL_MS = 18e5;
|
|
2473
|
-
/** 混合密钥编码表 */
|
|
2474
|
-
const mixinKeyEncTab = [
|
|
2475
|
-
46,
|
|
2476
|
-
47,
|
|
2477
|
-
18,
|
|
2478
|
-
2,
|
|
2479
|
-
53,
|
|
2480
|
-
8,
|
|
2481
|
-
23,
|
|
2482
|
-
32,
|
|
2483
|
-
15,
|
|
2484
|
-
50,
|
|
2485
|
-
10,
|
|
2486
|
-
31,
|
|
2487
|
-
58,
|
|
2488
|
-
3,
|
|
2489
|
-
45,
|
|
2490
|
-
35,
|
|
2491
|
-
27,
|
|
2492
|
-
43,
|
|
2493
|
-
5,
|
|
2494
|
-
49,
|
|
2495
|
-
33,
|
|
2496
|
-
9,
|
|
2497
|
-
42,
|
|
2498
|
-
19,
|
|
2499
|
-
29,
|
|
2500
|
-
28,
|
|
2501
|
-
14,
|
|
2502
|
-
39,
|
|
2503
|
-
12,
|
|
2504
|
-
38,
|
|
2505
|
-
41,
|
|
2506
|
-
13,
|
|
2507
|
-
37,
|
|
2508
|
-
48,
|
|
2509
|
-
7,
|
|
2510
|
-
16,
|
|
2511
|
-
24,
|
|
2512
|
-
55,
|
|
2513
|
-
40,
|
|
2514
|
-
61,
|
|
2515
|
-
26,
|
|
2516
|
-
17,
|
|
2517
|
-
0,
|
|
2518
|
-
1,
|
|
2519
|
-
60,
|
|
2520
|
-
51,
|
|
2521
|
-
30,
|
|
2522
|
-
4,
|
|
2523
|
-
22,
|
|
2524
|
-
25,
|
|
2525
|
-
54,
|
|
2526
|
-
21,
|
|
2527
|
-
56,
|
|
2528
|
-
59,
|
|
2529
|
-
6,
|
|
2530
|
-
63,
|
|
2531
|
-
57,
|
|
2532
|
-
62,
|
|
2533
|
-
11,
|
|
2534
|
-
36,
|
|
2535
|
-
20,
|
|
2536
|
-
34,
|
|
2537
|
-
44,
|
|
2538
|
-
52
|
|
2539
|
-
];
|
|
2540
|
-
/** 从 URL 末尾取文件名部分(去扩展名),得到 img_key / sub_key */
|
|
2541
|
-
const extractKey = (url) => url.slice(url.lastIndexOf("/") + 1, url.lastIndexOf("."));
|
|
2542
|
-
/**
|
|
2543
|
-
* 对 imgKey 和 subKey 进行字符顺序打乱编码。
|
|
2544
|
-
* @param orig - img_key + sub_key 拼接
|
|
2545
|
-
* @returns 32 位 mixin key
|
|
2546
|
-
*/
|
|
2547
|
-
const getMixinKey = (orig) => mixinKeyEncTab.map((n) => orig[n]).join("").slice(0, 32);
|
|
2548
|
-
/**
|
|
2549
|
-
* 为请求参数计算 wbi 签名。
|
|
2550
|
-
* @param params - 请求参数(不含 wts / w_rid)
|
|
2551
|
-
* @param img_key - 图片密钥
|
|
2552
|
-
* @param sub_key - 子密钥
|
|
2553
|
-
* @returns `&wts=..&w_rid=..` 查询串
|
|
2554
|
-
*/
|
|
2555
|
-
const encWbi = (params, img_key, sub_key) => {
|
|
2556
|
-
const mixin_key = getMixinKey(img_key + sub_key);
|
|
2557
|
-
const curr_time = Math.round(Date.now() / 1e3);
|
|
2558
|
-
const chr_filter = /[!'()*]/g;
|
|
2559
|
-
Object.assign(params, { wts: curr_time });
|
|
2560
|
-
const query = Object.keys(params).sort().map((key) => {
|
|
2561
|
-
const value = params[key].toString().replace(chr_filter, "");
|
|
2562
|
-
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
|
2563
|
-
}).join("&");
|
|
2564
|
-
return `&wts=${curr_time}&w_rid=${node_crypto.default.createHash("md5").update(query + mixin_key).digest("hex")}`;
|
|
2565
|
-
};
|
|
2332
|
+
//#region src/platforms/bilibili/sign/signers.ts
|
|
2566
2333
|
/**
|
|
2567
|
-
* B
|
|
2334
|
+
* 创建 B站签名器表(薄壳,进程级共享)。
|
|
2568
2335
|
*
|
|
2569
|
-
*
|
|
2570
|
-
*
|
|
2336
|
+
* 不自建 `WbiSigner` —— `wbi()` / `qtparam()` 内部引用 `steps.ts` 的模块级
|
|
2337
|
+
* `sharedWbi`,所以 `createBilibiliSigners()` 调多次也共用同一个实例、同一份
|
|
2338
|
+
* `/nav` 缓存(与旧注释「每 client 一份」相反 —— 但实现上本来就只有一份,这里是
|
|
2339
|
+
* 让注释对上实现)。
|
|
2340
|
+
* @returns 注册名 → 签名器
|
|
2571
2341
|
*/
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
/**
|
|
2577
|
-
* @param ttlMs - keys 缓存有效期,默认 {@link WBI_TTL_MS}
|
|
2578
|
-
* @param now - 时钟(可注入)
|
|
2579
|
-
*/
|
|
2580
|
-
constructor(ttlMs = WBI_TTL_MS, now = Date.now) {
|
|
2581
|
-
this.ttlMs = ttlMs;
|
|
2582
|
-
this.now = now;
|
|
2583
|
-
}
|
|
2584
|
-
/**
|
|
2585
|
-
* 取 `/nav` 响应体(带 TTL 缓存)。
|
|
2586
|
-
*
|
|
2587
|
-
* 走 `ctx.send`(reason `'prepare'`)。
|
|
2588
|
-
* @param ctx - 执行上下文(提供 send 与 cookie)
|
|
2589
|
-
* @returns `/nav` 响应体
|
|
2590
|
-
*/
|
|
2591
|
-
async getNav(ctx) {
|
|
2592
|
-
const cached = this.nav;
|
|
2593
|
-
if (cached && this.now() - cached.fetchedAt < this.ttlMs) return cached.body;
|
|
2594
|
-
const body = (await ctx.send({
|
|
2595
|
-
method: "GET",
|
|
2596
|
-
url: "https://api.bilibili.com/x/web-interface/nav",
|
|
2597
|
-
headers: { cookie: ctx.cookie }
|
|
2598
|
-
}, "prepare")).body;
|
|
2599
|
-
this.nav = {
|
|
2600
|
-
body,
|
|
2601
|
-
fetchedAt: this.now()
|
|
2602
|
-
};
|
|
2603
|
-
return body;
|
|
2604
|
-
}
|
|
2605
|
-
/** 从 `/nav` 响应体提取 wbi keys */
|
|
2606
|
-
keysOf(body) {
|
|
2607
|
-
const img = body.data?.wbi_img?.img_url;
|
|
2608
|
-
const sub = body.data?.wbi_img?.sub_url;
|
|
2609
|
-
if (!img || !sub) throw new Error("wbi 密钥获取失败:/nav 响应缺少 wbi_img");
|
|
2610
|
-
return {
|
|
2611
|
-
img_key: extractKey(img),
|
|
2612
|
-
sub_key: extractKey(sub)
|
|
2613
|
-
};
|
|
2614
|
-
}
|
|
2615
|
-
/**
|
|
2616
|
-
* 签名器:给请求 URL 追加 `&wts=..&w_rid=..`。
|
|
2617
|
-
*
|
|
2618
|
-
* `sign: 'wbi'` 的端点(comments / userDynamicList / userSpaceInfo 等)用它。
|
|
2619
|
-
* 首次调用会触发 `/nav` 前置请求(reason `'prepare'`),TTL 内复用。
|
|
2620
|
-
* @param spec - 请求描述
|
|
2621
|
-
* @param ctx - 执行上下文
|
|
2622
|
-
* @returns 带 wbi 签名的请求描述
|
|
2623
|
-
*/
|
|
2624
|
-
sign = async (spec, ctx) => {
|
|
2625
|
-
const nav = await this.getNav(ctx);
|
|
2626
|
-
const { img_key, sub_key } = this.keysOf(nav);
|
|
2627
|
-
const url = new URL(spec.url);
|
|
2628
|
-
const params = {};
|
|
2629
|
-
for (const [key, value] of url.searchParams.entries()) params[key] = value;
|
|
2630
|
-
const query = encWbi(params, img_key, sub_key);
|
|
2631
|
-
return {
|
|
2632
|
-
...spec,
|
|
2633
|
-
url: spec.url + query
|
|
2634
|
-
};
|
|
2635
|
-
};
|
|
2636
|
-
};
|
|
2637
|
-
/** 创建一个 wbi 签名器实例(每 client 一个) */
|
|
2638
|
-
const createWbiSigner = (ttlMs, now) => new WbiSigner(ttlMs, now);
|
|
2639
|
-
//#endregion
|
|
2640
|
-
//#region src/platforms/bilibili/sign/signers.ts
|
|
2641
|
-
/** 创建 B站签名器表(每 client 一份) */
|
|
2642
|
-
const createBilibiliSigners = () => {
|
|
2643
|
-
const instance = createWbiSigner();
|
|
2644
|
-
return {
|
|
2645
|
-
wbi: instance.sign,
|
|
2646
|
-
qtparam: createQtparamSigner(instance),
|
|
2647
|
-
instance
|
|
2648
|
-
};
|
|
2649
|
-
};
|
|
2342
|
+
const createBilibiliSigners = () => ({
|
|
2343
|
+
wbi: stepsToSigner([require_steps.wbi()]),
|
|
2344
|
+
qtparam: stepsToSigner([require_steps.qtparam()])
|
|
2345
|
+
});
|
|
2650
2346
|
//#endregion
|
|
2651
2347
|
//#region src/platforms/douyin/config.ts
|
|
2652
2348
|
/**
|
|
@@ -2660,10 +2356,10 @@ const createBilibiliSigners = () => {
|
|
|
2660
2356
|
* `method` 归端点声明(抖音端点各自声明 GET / POST),不属于基线。
|
|
2661
2357
|
*/
|
|
2662
2358
|
const createDouyinConfig = (cookie, requestConfig) => {
|
|
2663
|
-
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";
|
|
2359
|
+
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";
|
|
2664
2360
|
const secChUa = generateSecChUa(userAgent);
|
|
2665
2361
|
return {
|
|
2666
|
-
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),
|
|
2362
|
+
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),
|
|
2667
2363
|
requestConfig: {
|
|
2668
2364
|
timeout: 1e4,
|
|
2669
2365
|
...requestConfig
|
|
@@ -2760,772 +2456,51 @@ const douyinJudge = (raw, http) => {
|
|
|
2760
2456
|
return verdictFromHttpStatus(http.status) ?? { ok: true };
|
|
2761
2457
|
};
|
|
2762
2458
|
//#endregion
|
|
2763
|
-
//#region src/
|
|
2764
|
-
/**
|
|
2765
|
-
* Cookie 契约。
|
|
2766
|
-
*
|
|
2767
|
-
* **全仓唯一的 cookie 解析与序列化实现。** 解析按 RFC 6265 的键值语义:
|
|
2768
|
-
* 名字大小写敏感、值取第一个 `=` 之后的全部内容、不做 URL 解码。
|
|
2769
|
-
*
|
|
2770
|
-
* `contracts/` 是零依赖叶子层,本文件不 import 任何模块。
|
|
2771
|
-
*/
|
|
2772
|
-
/**
|
|
2773
|
-
* 解析 `name=value; name=value` 形式的 cookie 串。
|
|
2774
|
-
*
|
|
2775
|
-
* 规则:
|
|
2776
|
-
* - 以第一个 `=` 分割,所以值里可以含 `=`(如 base64)。
|
|
2777
|
-
* - 名与值两端去空白。
|
|
2778
|
-
* - 名为空、或整段没有 `=` 的片段直接跳过。
|
|
2779
|
-
* - 同名多次出现时**后者覆盖前者**。
|
|
2780
|
-
* - 值不做 URL 解码 —— 签名依赖原始字节,解码会改变签名输入。
|
|
2781
|
-
*
|
|
2782
|
-
* cookie 名按 RFC 6265 是**大小写敏感**的,因此本函数不做任何大小写归一化。
|
|
2783
|
-
* 这与 header 名大小写不敏感(见 `AmagiHeaders`)是两件不同的事。
|
|
2784
|
-
* @param cookieString - cookie 串,`undefined` / `null` / 空串都安全
|
|
2785
|
-
* @returns 名 → 值的普通对象,无有效项时是空对象
|
|
2786
|
-
*/
|
|
2787
|
-
const parseCookie = (cookieString) => {
|
|
2788
|
-
const result = {};
|
|
2789
|
-
if (!cookieString) return result;
|
|
2790
|
-
for (const pair of cookieString.split(";")) {
|
|
2791
|
-
const index = pair.indexOf("=");
|
|
2792
|
-
if (index <= 0) continue;
|
|
2793
|
-
const name = pair.slice(0, index).trim();
|
|
2794
|
-
if (!name) continue;
|
|
2795
|
-
result[name] = pair.slice(index + 1).trim();
|
|
2796
|
-
}
|
|
2797
|
-
return result;
|
|
2798
|
-
};
|
|
2799
|
-
/**
|
|
2800
|
-
* 取单条 cookie 的值。
|
|
2801
|
-
*
|
|
2802
|
-
* **按名精确匹配**,不是子串匹配 ——
|
|
2803
|
-
* `getCookieValue('xa1=WRONG; a1=RIGHT', 'a1')` 返回 `'RIGHT'` 而不是 `'WRONG'`。
|
|
2804
|
-
* @param cookieString - 完整 cookie 串
|
|
2805
|
-
* @param name - cookie 名,大小写敏感
|
|
2806
|
-
* @returns 值;不存在时返回 `undefined`
|
|
2807
|
-
*/
|
|
2808
|
-
const getCookieValue = (cookieString, name) => {
|
|
2809
|
-
if (!cookieString || !name) return void 0;
|
|
2810
|
-
return parseCookie(cookieString)[name];
|
|
2811
|
-
};
|
|
2812
|
-
//#endregion
|
|
2813
|
-
//#region src/platforms/douyin/webid.ts
|
|
2814
|
-
/**
|
|
2815
|
-
* 抖音 `webid` 的取得与缓存。
|
|
2816
|
-
*
|
|
2817
|
-
* `webid` 是服务端下发的访客 id,**客户端算不出来** —— 服务端按 `ttwid` 算好之后,
|
|
2818
|
-
* 通过每个响应的 `cookie_ttwidinfo_webid` 头告知。一个 ttwid 对应一个稳定的 webid。
|
|
2819
|
-
*
|
|
2820
|
-
* 抖音会拿 query 里的 `webid` 与 cookie 的会话交叉校验,对不上就返回 HTTP 200 +
|
|
2821
|
-
* 空 body(不是 403、也不是业务码),最典型的受害者是 `aweme/favorite`。实测:
|
|
2822
|
-
*
|
|
2823
|
-
* ```text
|
|
2824
|
-
* 浏览器真实的 webid → 200,1,116,615 字节,19 条
|
|
2825
|
-
* 代码里写死的那个 → 200, 0 字节,空
|
|
2826
|
-
* 干脆不传 → 200,1,116,776 字节,19 条
|
|
2827
|
-
* ```
|
|
2828
|
-
*
|
|
2829
|
-
* **不传安全,传错致命。** 所以不写死默认值,只在缓存命中时才补。
|
|
2830
|
-
* 缓存按 ttwid 分键:换了 cookie,webid 就得跟着换。
|
|
2831
|
-
*
|
|
2832
|
-
* 顺带澄清一个容易误解的点:填别人的 `webid` **不会**拿到别人的数据 ——
|
|
2833
|
-
* 「看谁」由 `sec_user_id` 决定,`webid` 只是设备一致性校验,身份来自 cookie 的
|
|
2834
|
-
* `sessionid`。填错只会被拒绝。
|
|
2835
|
-
*
|
|
2836
|
-
* ## 实现要点
|
|
2837
|
-
*
|
|
2838
|
-
* 接入的是端点管线:
|
|
2839
|
-
*
|
|
2840
|
-
* - 键取自 `ctx.cookie`,解析走 `contracts/cookie.ts` 的 `getCookieValue`;
|
|
2841
|
-
* - 回收由平台级 `observe` 钩子在每次 send 之后调用,读 `RawResponse.headers`;
|
|
2842
|
-
* - 注入由 `sign/signers.ts` 里的第一步完成(`webid → a_bogus/x_bogus → secsdk`),
|
|
2843
|
-
* 而不是让同步的 URL 构造去依赖运行期状态。
|
|
2844
|
-
*
|
|
2845
|
-
* ## 作用域:进程级
|
|
2846
|
-
*
|
|
2847
|
-
* **这是进程级的,不是每 client 一份** —— 端点定义本身就是模块级单例。
|
|
2848
|
-
* 要按 client 隔离得把状态搬到 `ClientCtx` 上。
|
|
2849
|
-
* 多 cookie 场景靠 ttwid 分键区分,所以「串用别人的 webid」不会发生。
|
|
2850
|
-
*
|
|
2851
|
-
* @module platforms/douyin/webid
|
|
2852
|
-
*/
|
|
2853
|
-
/** ttwid → webid。webid 对同一 ttwid 是稳定的,进程内缓存即可 */
|
|
2854
|
-
const cache = /* @__PURE__ */ new Map();
|
|
2855
|
-
/** 抖音回传 webid 的响应头 */
|
|
2856
|
-
const WEBID_HEADER = "cookie_ttwidinfo_webid";
|
|
2857
|
-
/** webid 的形状:纯数字、至少 6 位。不合形状的值一律不收 —— 传错比不传更糟 */
|
|
2858
|
-
const WEBID_SHAPE = /^\d{6,}$/;
|
|
2859
|
-
/**
|
|
2860
|
-
* 从 cookie 串里取 `ttwid`,用作缓存键
|
|
2861
|
-
* @param cookie - 本次调用使用的 cookie
|
|
2862
|
-
* @returns `ttwid` 的值,取不到时返回空串
|
|
2863
|
-
*/
|
|
2864
|
-
const ttwidOf = (cookie) => cookie ? getCookieValue(cookie, "ttwid") ?? "" : "";
|
|
2865
|
-
/**
|
|
2866
|
-
* 看到响应头里的 webid 就记下来。没有这个头、值不合形状、或 cookie 里没有 ttwid
|
|
2867
|
-
* (无从分键)时都是无操作。
|
|
2868
|
-
* @param res - 本次请求的原始响应
|
|
2869
|
-
* @param cookie - 本次请求使用的 cookie,用于取 ttwid 作为缓存键
|
|
2870
|
-
*/
|
|
2871
|
-
const rememberDouyinWebid = (res, cookie) => {
|
|
2872
|
-
const webid = res.headers.get(WEBID_HEADER);
|
|
2873
|
-
if (!webid || !WEBID_SHAPE.test(webid)) return;
|
|
2874
|
-
const key = ttwidOf(cookie);
|
|
2875
|
-
if (key) cache.set(key, webid);
|
|
2876
|
-
};
|
|
2877
|
-
/**
|
|
2878
|
-
* 取这份 cookie 对应的 webid
|
|
2879
|
-
* @param cookie - 本次调用使用的 cookie
|
|
2880
|
-
* @returns 缓存里的 webid,没有时返回空串
|
|
2881
|
-
*/
|
|
2882
|
-
const douyinWebidFor = (cookie) => cache.get(ttwidOf(cookie)) ?? "";
|
|
2883
|
-
/**
|
|
2884
|
-
* 给 URL 补上 `webid`。
|
|
2885
|
-
*
|
|
2886
|
-
* 必须在算 a_bogus 之前调用:真实浏览器的顺序是「拼好 query(含 webid)→ a_bogus →
|
|
2887
|
-
* secsdk 签名」,后两者都覆盖 query,顺序错了签名就不成立。
|
|
2888
|
-
* URL 里已有 `webid`、或缓存里没有值时原样返回。
|
|
2889
|
-
* @param url - 还没加签的 URL
|
|
2890
|
-
* @param cookie - 本次请求使用的 cookie
|
|
2891
|
-
* @returns 补好 `webid` 的 URL
|
|
2892
|
-
*/
|
|
2893
|
-
const withDouyinWebid = (url, cookie) => {
|
|
2894
|
-
const webid = douyinWebidFor(cookie);
|
|
2895
|
-
if (!webid) return url;
|
|
2896
|
-
let parsed;
|
|
2897
|
-
try {
|
|
2898
|
-
parsed = new URL(url);
|
|
2899
|
-
} catch {
|
|
2900
|
-
return url;
|
|
2901
|
-
}
|
|
2902
|
-
if (parsed.searchParams.has("webid")) return url;
|
|
2903
|
-
parsed.searchParams.set("webid", webid);
|
|
2904
|
-
return parsed.toString();
|
|
2905
|
-
};
|
|
2906
|
-
/**
|
|
2907
|
-
* 平台级 `observe` 钩子:挂在 `PLATFORM_RUNTIME.douyin` 上,每次 send 之后被调一次。
|
|
2908
|
-
*
|
|
2909
|
-
* **自己吞异常** —— 契约是「只读、不抛、不改判定」。回收 webid 失败只该让下一次
|
|
2910
|
-
* 请求少一个参数(不带是安全的),不该把一个本来成功的调用变成 `kind: 'internal'`。
|
|
2911
|
-
* @param res - 本次请求的原始响应
|
|
2912
|
-
* @param ctx - 当刻的执行上下文(只读 `cookie`)
|
|
2913
|
-
*/
|
|
2914
|
-
const observeDouyinWebid = (res, ctx) => {
|
|
2915
|
-
try {
|
|
2916
|
-
rememberDouyinWebid(res, ctx.cookie);
|
|
2917
|
-
} catch {}
|
|
2918
|
-
};
|
|
2919
|
-
//#endregion
|
|
2920
|
-
//#region src/platforms/douyin/sign/x_bogus.ts
|
|
2921
|
-
/**
|
|
2922
|
-
* X-Bogus 生成工具(抖音 / TikTok Web 的老签名)—— TypeScript 移植
|
|
2923
|
-
*
|
|
2924
|
-
* 移植自 Douyin_TikTok_Download_API `src/dtk/signing/native/xbogus.py`,常量、槽位与
|
|
2925
|
-
* **字节顺序**逐条对齐;那份 Python 又是 V4 `crawlers/douyin/web/xbogus.py` 的逐行移植,
|
|
2926
|
-
* 而 V4 是站点自己那份混淆 JS 的直译。所以这里对齐的不是「某份实现的做法」,
|
|
2927
|
-
* 而是站点校验时的做法。
|
|
2928
|
-
*
|
|
2929
|
-
* ## 载荷(19 字节)与槽位
|
|
2930
|
-
*
|
|
2931
|
-
* ```text
|
|
2932
|
-
* [64, 1/256, 1, 12, query 摘要[14..15], empty 摘要[14..15], ua 摘要[14..15],
|
|
2933
|
-
* 时间戳 4 字节, 536919696 4 字节, 校验位]
|
|
2934
|
-
* ```
|
|
2935
|
-
*
|
|
2936
|
-
* 三条摘要链各自算出 16 字节 MD5,只取第 14、15 字节(`DIGEST_INDICES`);`1/256`
|
|
2937
|
-
* 是站点留在载荷里的浮点常量,最终被截断成 `0`;最后一格是前 18 格的异或。
|
|
2938
|
-
*
|
|
2939
|
-
* ## 两个曾经的坑,都会产出「看着像模像样」的错签名
|
|
2940
|
-
*
|
|
2941
|
-
* 1. **字节顺序**:站点把载荷按奇偶位拆开,再交给一个**形参表本身就乱序**的编码函数,
|
|
2942
|
-
* 而那个函数正好把两段拼回原序 —— 拆分是死代码。旧移植漏掉还原这一步,把
|
|
2943
|
-
* 「偶数位在前、奇数位在后」直接送进 RC4,字节全体错位;再叠加把 `1/256` 写成 `1`,
|
|
2944
|
-
* 输出与站点/V4 完全不同。见 {@link XBogus.interleave}。
|
|
2945
|
-
* 2. **十六进制解码**:≤ 32 字符一律按站点的判据当摘要解十六进制,旧实现遇到落单的
|
|
2946
|
-
* 半字节会悄悄补 0(Python 那版是悄悄丢掉末位),两个不同输入能撞出同一签名。
|
|
2947
|
-
* 见 {@link XBogus.md5StrToArray}。
|
|
2948
|
-
*
|
|
2949
|
-
* ## 时钟
|
|
2950
|
-
*
|
|
2951
|
-
* V4 把 `time.time()` 烧在函数体里,签名没法在测试里钉死。这里把秒级时间戳做成
|
|
2952
|
-
* 注入点(`options.timestamp`),默认值才是 `Date.now()`。
|
|
2953
|
-
*
|
|
2954
|
-
* @module platforms/douyin/sign/x_bogus
|
|
2955
|
-
*/
|
|
2956
|
-
/** 输出字母表,与 a_bogus 的 `s2` 相同;两者可以各自演化,所以不共用一份 */
|
|
2957
|
-
const CHARACTER = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=";
|
|
2958
|
-
/** 作用在 User-Agent 上的 RC4 密钥(三个控制字节) */
|
|
2959
|
-
const UA_KEY = Buffer.from([
|
|
2960
|
-
0,
|
|
2961
|
-
1,
|
|
2962
|
-
12
|
|
2963
|
-
]);
|
|
2964
|
-
/** 作用在载荷上的 RC4 密钥 */
|
|
2965
|
-
const PAYLOAD_KEY = Buffer.from([255]);
|
|
2966
|
-
/** 空串的 MD5,算法会把它再哈希一轮,结果是个常量 */
|
|
2967
|
-
const EMPTY_MD5 = "d41d8cd98f00b204e9800998ecf8427e";
|
|
2968
|
-
/** 载荷开头四个槽位的字面量;`1/256` 那一格会被截断成 `0`,但不能顺手改写成 `0` */
|
|
2969
|
-
const PAYLOAD_LEAD = [
|
|
2970
|
-
64,
|
|
2971
|
-
1 / 256,
|
|
2972
|
-
1,
|
|
2973
|
-
12
|
|
2974
|
-
];
|
|
2975
|
-
/** 每条摘要链贡献的两个字节在 16 字节摘要里的下标 */
|
|
2976
|
-
const DIGEST_INDICES = [14, 15];
|
|
2977
|
-
/** 信封开头的两个明文字节(`2` 与 `255`),密文接在它们后面 */
|
|
2978
|
-
const ENVELOPE_LEAD = [2, 255];
|
|
2979
|
-
/** 不传 UA 时使用的默认值,与 V4 一字不差(改它等于改签名) */
|
|
2980
|
-
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";
|
|
2981
|
-
/** 小写十六进制字符,V4 与 xbogus.py 都只认这一档 */
|
|
2982
|
-
const HEX_DIGITS = "0123456789abcdef";
|
|
2983
|
-
var XBogus = class {
|
|
2984
|
-
/** 十六进制字符 → 半字节;未映射的字符取出 `null` 或 `undefined`,两者都算非法 */
|
|
2985
|
-
hexNibbles;
|
|
2986
|
-
/** 内置默认 UA */
|
|
2987
|
-
defaultUa;
|
|
2988
|
-
/** V4 兼容字段:V4 会把结果同步写进 `params` / `xb`,本实现不写 */
|
|
2989
|
-
params;
|
|
2990
|
-
xb;
|
|
2991
|
-
constructor() {
|
|
2992
|
-
this.hexNibbles = new Array(128).fill(null);
|
|
2993
|
-
for (let index = 0; index < 16; index++) this.hexNibbles[HEX_DIGITS.charCodeAt(index)] = index;
|
|
2994
|
-
this.defaultUa = DEFAULT_USER_AGENT;
|
|
2995
|
-
}
|
|
2996
|
-
/**
|
|
2997
|
-
* 十六进制串 → 字节数组;超过 32 字符则按原始文本取码点。
|
|
2998
|
-
*
|
|
2999
|
-
* **长度判据(≤ 32 走十六进制)是站点的**,不是这里加的:站点把任何不超过一份
|
|
3000
|
-
* 摘要长度的字符串都当作摘要来解。真实请求的 path + query 永远长过 32 字符,
|
|
3001
|
-
* 走的是文本分支,所以这条规则碰不到线上行为。
|
|
3002
|
-
*
|
|
3003
|
-
* **旧实现在这里会悄悄吞掉落单的半字节**,两个不同输入因此撞出同一份签名:
|
|
3004
|
-
* 参考实现(Python)用 `range(0, len - 1, 2)`,末位字符直接不参与运算,
|
|
3005
|
-
* `"abcdef"` 与 `"abcdefg"` 同签名;本仓库旧 TS 版把缺失的低半字节当成 0,
|
|
3006
|
-
* `"abc"` 与 `"abc0"` 同签名。两者的共同点是「输入不合法,却拿到一份像模像样的
|
|
3007
|
-
* 签名」—— 这比报错难查得多,所以这里选择**报错**:长度必须是偶数、字符必须是小写
|
|
3008
|
-
* 十六进制,否则抛出,绝不替调用方补位或丢位。
|
|
3009
|
-
*
|
|
3010
|
-
* @param value - 十六进制串 / 原始文本,或现成的字节数组(复制一份返回)
|
|
3011
|
-
* @returns 字节数组
|
|
3012
|
-
*/
|
|
3013
|
-
md5StrToArray(value) {
|
|
3014
|
-
if (Array.isArray(value)) return [...value];
|
|
3015
|
-
if (value.length > 32) {
|
|
3016
|
-
const codePoints = [];
|
|
3017
|
-
for (const char of value) codePoints.push(char.charCodeAt(0));
|
|
3018
|
-
return codePoints;
|
|
3019
|
-
}
|
|
3020
|
-
const bytes = [];
|
|
3021
|
-
for (let index = 0; index < value.length; index += 2) {
|
|
3022
|
-
const pair = value.slice(index, index + 2);
|
|
3023
|
-
const high = this.hexNibbles[value.charCodeAt(index)];
|
|
3024
|
-
const low = index + 1 < value.length ? this.hexNibbles[value.charCodeAt(index + 1)] : null;
|
|
3025
|
-
if (high === null || high === void 0 || low === null || low === void 0) throw new Error(`X-Bogus 签名:${JSON.stringify(value)} 不是偶数长度的小写十六进制串(${JSON.stringify(pair)} 处)`);
|
|
3026
|
-
bytes.push(high << 4 | low);
|
|
3027
|
-
}
|
|
3028
|
-
return bytes;
|
|
3029
|
-
}
|
|
3030
|
-
/** MD5 十六进制摘要;字符串先过 {@link XBogus.md5StrToArray},数组按原样取字节 */
|
|
3031
|
-
md5(data) {
|
|
3032
|
-
const bytes = typeof data === "string" ? this.md5StrToArray(data) : data;
|
|
3033
|
-
return node_crypto.default.createHash("md5").update(Buffer.from(bytes)).digest("hex");
|
|
3034
|
-
}
|
|
3035
|
-
/** query 摘要链:两轮 MD5 */
|
|
3036
|
-
md5Encrypt(urlPath) {
|
|
3037
|
-
return this.md5StrToArray(this.md5(this.md5StrToArray(this.md5(urlPath))));
|
|
3038
|
-
}
|
|
3039
|
-
/**
|
|
3040
|
-
* empty 摘要链:哈希空串,是个常量。
|
|
3041
|
-
*
|
|
3042
|
-
* 它的第 14、15 字节出现在每一份 X-Bogus 里 —— 解码时对不上这两个字节的,
|
|
3043
|
-
* 就不是 X-Bogus。
|
|
3044
|
-
*/
|
|
3045
|
-
emptyDigest() {
|
|
3046
|
-
return this.md5StrToArray(this.md5(this.md5StrToArray(EMPTY_MD5)));
|
|
3047
|
-
}
|
|
3048
|
-
/** UA 摘要链:RC4 → base64 → MD5,再解回字节 */
|
|
3049
|
-
userAgentDigest(userAgent) {
|
|
3050
|
-
const encrypted = this.rc4Encrypt(UA_KEY, Buffer.from(userAgent, "latin1"));
|
|
3051
|
-
return this.md5StrToArray(this.md5(encrypted.toString("base64")));
|
|
3052
|
-
}
|
|
3053
|
-
/** RC4(与 Python 版逐句对应) */
|
|
3054
|
-
rc4Encrypt(key, data) {
|
|
3055
|
-
const box = Array.from({ length: 256 }, (_, index) => index);
|
|
3056
|
-
let j = 0;
|
|
3057
|
-
for (let index = 0; index < 256; index++) {
|
|
3058
|
-
j = (j + box[index] + key[index % key.length]) % 256;
|
|
3059
|
-
[box[index], box[j]] = [box[j], box[index]];
|
|
3060
|
-
}
|
|
3061
|
-
const out = Buffer.alloc(data.length);
|
|
3062
|
-
let i = 0;
|
|
3063
|
-
j = 0;
|
|
3064
|
-
for (let index = 0; index < data.length; index++) {
|
|
3065
|
-
i = (i + 1) % 256;
|
|
3066
|
-
j = (j + box[i]) % 256;
|
|
3067
|
-
[box[i], box[j]] = [box[j], box[i]];
|
|
3068
|
-
out[index] = data[index] ^ box[(box[i] + box[j]) % 256];
|
|
3069
|
-
}
|
|
3070
|
-
return out;
|
|
3071
|
-
}
|
|
3072
|
-
/**
|
|
3073
|
-
* 偶数位在前、奇数位在后 —— 站点对载荷做的拆分。
|
|
3074
|
-
*
|
|
3075
|
-
* 拆分本身是死代码({@link XBogus.interleave} 会把它原样拼回去),真正的作用只有
|
|
3076
|
-
* 一处:让 `1/256` 那一格被截断成整数。形状照抄站点,为的是别人对着 xbogus.py 读时
|
|
3077
|
-
* 能一句一句对上,也让「漏掉还原」这个坑不至于再犯一次。
|
|
3078
|
-
*/
|
|
3079
|
-
splitEvenOdd(values) {
|
|
3080
|
-
const head = [];
|
|
3081
|
-
const tail = [];
|
|
3082
|
-
for (let index = 0; index < values.length; index++) if (index % 2 === 0) head.push(values[index]);
|
|
3083
|
-
else tail.push(values[index]);
|
|
3084
|
-
return [...head, ...tail];
|
|
3085
|
-
}
|
|
3086
|
-
/**
|
|
3087
|
-
* 把 {@link XBogus.splitEvenOdd} 拆开的两段按下标交错回原序(顺便截断成整数)。
|
|
3088
|
-
*
|
|
3089
|
-
* **这一步不能省**:站点那个编码函数的形参表本身是乱序的 ——
|
|
3090
|
-
* V4 写作 `encoding_conversion(a, b, c, e, d, t, f, r, n, o, i, _, x, u, s, l, v, h, p)`,
|
|
3091
|
-
* 函数体按 `[a, i, b, _, c, x, e, u, d, s, t, l, f, v, r, h, n, p, o]` 拼接,
|
|
3092
|
-
* 传进去的又是拆开的两段,算下来正好还原成原始顺序。旧移植把它当成顺序形参处理,
|
|
3093
|
-
* 于是送进 RC4 的字节全体错位,签名从根上就是错的,还错得看不出来。
|
|
3094
|
-
*/
|
|
3095
|
-
interleave(values) {
|
|
3096
|
-
const head = values.slice(0, 10);
|
|
3097
|
-
const tail = values.slice(10);
|
|
3098
|
-
const merged = [];
|
|
3099
|
-
for (let index = 0; index < head.length; index++) {
|
|
3100
|
-
merged.push(Math.trunc(head[index]));
|
|
3101
|
-
if (index < tail.length) merged.push(Math.trunc(tail[index]));
|
|
3102
|
-
}
|
|
3103
|
-
return merged;
|
|
3104
|
-
}
|
|
3105
|
-
/** 三个载荷字节 → 四个输出字符 */
|
|
3106
|
-
encodeGroup(first, second, third) {
|
|
3107
|
-
const merged = (first & 255) << 16 | (second & 255) << 8 | third & 255;
|
|
3108
|
-
return CHARACTER[(merged & 16515072) >> 18] + CHARACTER[(merged & 258048) >> 12] + CHARACTER[(merged & 4032) >> 6] + CHARACTER[merged & 63];
|
|
3109
|
-
}
|
|
3110
|
-
/**
|
|
3111
|
-
* 生成 X-Bogus 签名。
|
|
3112
|
-
*
|
|
3113
|
-
* @param url - 完整的 URL 地址(签的是它的 pathname + search)
|
|
3114
|
-
* @param ua - 可选的 User-Agent,不提供则使用默认值
|
|
3115
|
-
* @param options - `timestamp` 注入点(秒级),省略时取当前时间
|
|
3116
|
-
* @returns 完整 URL(已带 `X-Bogus=`)、签名值与实际使用的 User-Agent
|
|
3117
|
-
*/
|
|
3118
|
-
getXBogus(url, ua, options = {}) {
|
|
3119
|
-
const parsedUrl = new node_url.default.URL(url);
|
|
3120
|
-
const urlPath = parsedUrl.pathname + parsedUrl.search;
|
|
3121
|
-
const userAgent = ua ?? this.defaultUa;
|
|
3122
|
-
const userAgentDigest = this.userAgentDigest(userAgent);
|
|
3123
|
-
const emptyDigest = this.emptyDigest();
|
|
3124
|
-
const queryDigest = this.md5Encrypt(urlPath);
|
|
3125
|
-
const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
3126
|
-
const payload = [
|
|
3127
|
-
...PAYLOAD_LEAD,
|
|
3128
|
-
queryDigest[DIGEST_INDICES[0]],
|
|
3129
|
-
queryDigest[DIGEST_INDICES[1]],
|
|
3130
|
-
emptyDigest[DIGEST_INDICES[0]],
|
|
3131
|
-
emptyDigest[DIGEST_INDICES[1]],
|
|
3132
|
-
userAgentDigest[DIGEST_INDICES[0]],
|
|
3133
|
-
userAgentDigest[DIGEST_INDICES[1]],
|
|
3134
|
-
timestamp >> 24 & 255,
|
|
3135
|
-
timestamp >> 16 & 255,
|
|
3136
|
-
timestamp >> 8 & 255,
|
|
3137
|
-
timestamp & 255,
|
|
3138
|
-
32,
|
|
3139
|
-
0,
|
|
3140
|
-
190,
|
|
3141
|
-
144
|
|
3142
|
-
];
|
|
3143
|
-
let checksum = Math.trunc(payload[0]);
|
|
3144
|
-
for (let index = 1; index < payload.length; index++) checksum ^= Math.trunc(payload[index]);
|
|
3145
|
-
payload.push(checksum);
|
|
3146
|
-
const merged = this.interleave(this.splitEvenOdd(payload));
|
|
3147
|
-
const encrypted = this.rc4Encrypt(PAYLOAD_KEY, Buffer.from(merged));
|
|
3148
|
-
const garbled = String.fromCharCode(...ENVELOPE_LEAD) + encrypted.toString("latin1");
|
|
3149
|
-
let xbogus = "";
|
|
3150
|
-
for (let index = 0; index + 2 < garbled.length; index += 3) xbogus += this.encodeGroup(garbled.charCodeAt(index), garbled.charCodeAt(index + 1), garbled.charCodeAt(index + 2));
|
|
3151
|
-
return {
|
|
3152
|
-
fullUrl: url.includes("?") ? `${url}&X-Bogus=${xbogus}` : `${url}?X-Bogus=${xbogus}`,
|
|
3153
|
-
xbogus,
|
|
3154
|
-
userAgent
|
|
3155
|
-
};
|
|
3156
|
-
}
|
|
3157
|
-
};
|
|
3158
|
-
//#endregion
|
|
3159
|
-
//#region src/platforms/douyin/sign/index.ts
|
|
3160
|
-
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";
|
|
3161
|
-
var douyinSign$1 = class {
|
|
3162
|
-
/**
|
|
3163
|
-
* 生成一个指定长度的随机字符串
|
|
3164
|
-
* @param length 字符串长度,默认为116
|
|
3165
|
-
* @returns
|
|
3166
|
-
*/
|
|
3167
|
-
static Mstoken(length) {
|
|
3168
|
-
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
3169
|
-
const randomBytes = node_crypto.default.randomBytes(length ?? 116);
|
|
3170
|
-
return Array.from(randomBytes, (byte) => characters[byte % 62]).join("");
|
|
3171
|
-
}
|
|
3172
|
-
/**
|
|
3173
|
-
* a_bogus 签名算法
|
|
3174
|
-
* @param url 需要签名的地址
|
|
3175
|
-
* @returns 对此地址签名后的URL查询参数
|
|
3176
|
-
*/
|
|
3177
|
-
static AB(url, userAgent) {
|
|
3178
|
-
return require_tokens.a_bogus_default(url, userAgent ?? defaultUserAgent$1);
|
|
3179
|
-
}
|
|
3180
|
-
/**
|
|
3181
|
-
* X-Bogus 签名算法
|
|
3182
|
-
* @param url 需要签名的地址
|
|
3183
|
-
* @returns 对此地址签名后的URL查询参数
|
|
3184
|
-
*/
|
|
3185
|
-
static XB(url, userAgent) {
|
|
3186
|
-
return new XBogus().getXBogus(url, userAgent ?? defaultUserAgent$1).xbogus;
|
|
3187
|
-
}
|
|
3188
|
-
/**
|
|
3189
|
-
* 生成一个唯一的验证字符串
|
|
3190
|
-
*
|
|
3191
|
-
* 形状与旧实现一致(`verify_<base36 毫秒>_<36 位>`,分隔符在 8/13/18/23、
|
|
3192
|
-
* 版本位在 14、变体位在 19),但时钟来源从 `new Date().getTime()` 换成
|
|
3193
|
-
* `Date.now()` —— 原来那个绕开了 `vi.spyOn(Date, 'now')`,导致这个函数
|
|
3194
|
-
* **无法在测试里被冻结**,只能断言结构。现在可以钉死时钟与随机源了。
|
|
3195
|
-
*/
|
|
3196
|
-
static VerifyFpManager() {
|
|
3197
|
-
return require_tokens.genVerifyFp();
|
|
3198
|
-
}
|
|
3199
|
-
};
|
|
3200
|
-
//#endregion
|
|
3201
|
-
//#region src/platforms/douyin/sign/secsdkWebSign.ts
|
|
3202
|
-
/**
|
|
3203
|
-
* 抖音主站 `x-secsdk-web-signature` 纯算实现(TypeScript 移植)
|
|
3204
|
-
*
|
|
3205
|
-
* 移植自 cv-cat/DouYin_Spider `utils/secsdk_web_sign.py`,算法与常量按该实现逐条对齐。
|
|
3206
|
-
*
|
|
3207
|
-
* ## 算法
|
|
3208
|
-
*
|
|
3209
|
-
* ```text
|
|
3210
|
-
* plain = {uifid}_{timestamp}_{WEBSIGN_CONST}_{canonical_query}
|
|
3211
|
-
* signature = md5(plain) 的 32 位小写十六进制
|
|
3212
|
-
* ```
|
|
3213
|
-
*
|
|
3214
|
-
* `WEBSIGN_CONST` 是 secsdk VMP 常量池里的写死盐,与账号 / 会话无关。
|
|
3215
|
-
*
|
|
3216
|
-
* ## canonical_query 规范化规则
|
|
3217
|
-
*
|
|
3218
|
-
* - 保持原始参数顺序,**不排序**;重复参数原样保留。
|
|
3219
|
-
* - 每个 value 先解码(`+`→空格、`%XX`→字节)再用 `encodeURIComponent` 重编码。
|
|
3220
|
-
* - key **只解码、不重编码**。
|
|
3221
|
-
* - 无等号的裸参数补成 `k=`。
|
|
3222
|
-
* - `×tamp={ts}` 追加到 query 末尾后再参与哈希。
|
|
3223
|
-
* - query 里没有 `uifid` 时,从 `UIFID` cookie 取值追加到末尾再签。
|
|
3224
|
-
*
|
|
3225
|
-
* ## 重要
|
|
3226
|
-
*
|
|
3227
|
-
* 签名算的是**规范化后的 query**,服务端也按收到的 query 校验,所以必须发送
|
|
3228
|
-
* {@link signSecsdkWebUrl} 返回的 URL,不能拿原始 URL 直接发。
|
|
3229
|
-
*
|
|
3230
|
-
* 签名绑 query、**不绑 path**(本项目实测:同一份 query 换 path 仍然 200)。
|
|
3231
|
-
*
|
|
3232
|
-
* @module platform/douyin/secsdkWebSign
|
|
3233
|
-
*/
|
|
3234
|
-
/** secsdk VM 常量池里的固定盐(与账号 / 会话无关) */
|
|
3235
|
-
const WEBSIGN_CONST = "A96D855A08C0A9707F8BEF0D9A527E4E";
|
|
3236
|
-
/** 签名参数名 */
|
|
3237
|
-
const SECSDK_SIG_KEY = "x-secsdk-web-signature";
|
|
3238
|
-
/** 时间戳参数名 */
|
|
3239
|
-
const SECSDK_TS_KEY = "timestamp";
|
|
3240
|
-
/** 走 secsdk 签名的 GET 接口路径(来自 SDK 的 webSign 策略配置) */
|
|
3241
|
-
const PROTECTED_PATHS_GET = [
|
|
3242
|
-
"/aweme/v1/web/aweme/detail/",
|
|
3243
|
-
"/aweme/v1/web/aweme/post/",
|
|
3244
|
-
"/aweme/v1/web/aweme/favorite/",
|
|
3245
|
-
"/aweme/v1/web/aweme/listcollection/",
|
|
3246
|
-
"/aweme/v1/web/mix/aweme/",
|
|
3247
|
-
"/aweme/v1/web/tab/feed/",
|
|
3248
|
-
"/aweme/v1/web/mix/list/",
|
|
3249
|
-
"/aweme/v1/web/music/aweme/",
|
|
3250
|
-
"/aweme/v1/web/music/list/",
|
|
3251
|
-
"/aweme/v1/web/mix/detail/",
|
|
3252
|
-
"/aweme/v1/web/mix/listcollection/",
|
|
3253
|
-
"/aweme/v1/web/music/detail/",
|
|
3254
|
-
"/aweme/v1/web/collects/list/",
|
|
3255
|
-
"/aweme/v1/web/collects/video/list/"
|
|
3256
|
-
];
|
|
3257
|
-
/** 走 secsdk 签名的 POST 接口路径 */
|
|
3258
|
-
const PROTECTED_PATHS_POST = [
|
|
3259
|
-
"/aweme/v1/web/aweme/detail/",
|
|
3260
|
-
"/aweme/v1/web/aweme/post/",
|
|
3261
|
-
"/aweme/v1/web/aweme/favorite/",
|
|
3262
|
-
"/aweme/v1/web/aweme/listcollection/",
|
|
3263
|
-
"/aweme/v1/web/mix/aweme/",
|
|
3264
|
-
"/aweme/v1/web/tab/feed/"
|
|
3265
|
-
];
|
|
3266
|
-
const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
|
|
3267
|
-
/**
|
|
3268
|
-
* 宽容的百分号解码,等价于 Python `urllib.parse.unquote(..., errors='replace')`:
|
|
3269
|
-
* 合法的 `%XX` 解成字节,非法的 `%` 原样保留,最后按 UTF-8 解码(坏字节 → U+FFFD)。
|
|
3270
|
-
*
|
|
3271
|
-
* 不能直接用 `decodeURIComponent` —— 它遇到 `100%` 这种孤立百分号会抛异常,
|
|
3272
|
-
* 而 SDK 的行为是把它当字面量留下(`100%` → 签成 `100%25`)。
|
|
3273
|
-
*
|
|
3274
|
-
* @param input - 待解码字符串
|
|
3275
|
-
* @returns 解码后的字符串
|
|
3276
|
-
*/
|
|
3277
|
-
const percentDecodeLoose = (input) => {
|
|
3278
|
-
if (!input.includes("%")) return input;
|
|
3279
|
-
const bytes = [];
|
|
3280
|
-
let i = 0;
|
|
3281
|
-
while (i < input.length) {
|
|
3282
|
-
if (input[i] === "%" && HEX_PAIR.test(input.slice(i + 1, i + 3))) {
|
|
3283
|
-
bytes.push(parseInt(input.slice(i + 1, i + 3), 16));
|
|
3284
|
-
i += 3;
|
|
3285
|
-
continue;
|
|
3286
|
-
}
|
|
3287
|
-
for (const byte of Buffer.from(input[i], "utf8")) bytes.push(byte);
|
|
3288
|
-
i += 1;
|
|
3289
|
-
}
|
|
3290
|
-
return Buffer.from(bytes).toString("utf8");
|
|
3291
|
-
};
|
|
2459
|
+
//#region src/platforms/douyin/sign/signers.ts
|
|
3292
2460
|
/**
|
|
3293
|
-
*
|
|
2461
|
+
* 抖音签名器表:**只做「注册名 → `SignStep` 清单」的映射,不含算法**。
|
|
3294
2462
|
*
|
|
3295
|
-
*
|
|
3296
|
-
*
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
/**
|
|
3300
|
-
* 按 SDK 规则规范化 query 字符串:顺序不变,value 解码后用 `encodeURIComponent` 重编码,
|
|
3301
|
-
* key 只解码不重编码,无等号的裸参数补成 `k=`。
|
|
2463
|
+
* 算法的唯一实现在 `./steps.ts`(含前置条件校验、webid 补参、secsdk 收尾的理由)。
|
|
2464
|
+
* 依赖方向是单向的 —— `signers.ts → steps.ts`,永不反向:两份实现并存时会改一边忘一边,
|
|
2465
|
+
* 而抖音的签名是**抽样校验**的(签错时大部分请求照样成功,只是被判高风险的概率上升),
|
|
2466
|
+
* 这类分叉极难发现。
|
|
3302
2467
|
*
|
|
3303
|
-
*
|
|
3304
|
-
* @returns 规范化后的 query 字符串
|
|
3305
|
-
*/
|
|
3306
|
-
const canonicalQuery = (query) => {
|
|
3307
|
-
const parts = [];
|
|
3308
|
-
for (const pair of query.split("&")) {
|
|
3309
|
-
if (!pair) continue;
|
|
3310
|
-
const eq = pair.indexOf("=");
|
|
3311
|
-
const rawKey = eq === -1 ? pair : pair.slice(0, eq);
|
|
3312
|
-
const rawValue = eq === -1 ? "" : pair.slice(eq + 1);
|
|
3313
|
-
parts.push(`${unquotePlusLoose(rawKey)}=${encodeURIComponent(unquotePlusLoose(rawValue))}`);
|
|
3314
|
-
}
|
|
3315
|
-
return parts.join("&");
|
|
3316
|
-
};
|
|
3317
|
-
/**
|
|
3318
|
-
* 拆成 `[base, query]`,并剥掉已有的 `timestamp` / 签名字段,保证可重复调用。
|
|
2468
|
+
* ## 两条路,一份实现
|
|
3319
2469
|
*
|
|
3320
|
-
*
|
|
3321
|
-
*
|
|
3322
|
-
|
|
3323
|
-
const splitUrl = (url) => {
|
|
3324
|
-
const mark = url.indexOf("?");
|
|
3325
|
-
if (mark === -1) return [url, ""];
|
|
3326
|
-
return [url.slice(0, mark), url.slice(mark + 1).split("&").filter((pair) => {
|
|
3327
|
-
if (!pair) return false;
|
|
3328
|
-
const name = pair.split("=", 1)[0];
|
|
3329
|
-
return name !== "timestamp" && name !== "x-secsdk-web-signature";
|
|
3330
|
-
}).join("&")];
|
|
3331
|
-
};
|
|
3332
|
-
/**
|
|
3333
|
-
* 从 cookie 字符串里取 `UIFID`。
|
|
2470
|
+
* 端点声明 `sign: douyinBogus(184)` 走清单;`client.douyin.request` 不给 `amagi.sign`
|
|
2471
|
+
* 时走字符串 `'a-bogus'`(见 `client/request/profile.ts`)查这张表。两者现在压的是同一份
|
|
2472
|
+
* step 清单,所以签出来必然一致。
|
|
3334
2473
|
*
|
|
3335
|
-
*
|
|
3336
|
-
* 否则会取错。
|
|
2474
|
+
* ## 为什么注册名对应的是三段清单
|
|
3337
2475
|
*
|
|
3338
|
-
*
|
|
3339
|
-
*
|
|
3340
|
-
*/
|
|
3341
|
-
const extractUifidFromCookie = (cookie) => {
|
|
3342
|
-
if (!cookie) return "";
|
|
3343
|
-
const hit = /(?:^|;\s*)UIFID=([^;]*)/.exec(cookie);
|
|
3344
|
-
return hit ? hit[1].trim() : "";
|
|
3345
|
-
};
|
|
3346
|
-
/**
|
|
3347
|
-
* 判断该 path 是否需要 secsdk 加签(依据 SDK 的 webSign 策略配置)。
|
|
2476
|
+
* 真实浏览器发这些请求的顺序是 **webid → msToken → a_bogus / x_bogus → secsdk**,每一步都改
|
|
2477
|
+
* query,颠倒任意一步签名就不成立。顺序不靠这里保证,由 `SignPhase` 的固定次序保证。
|
|
3348
2478
|
*
|
|
3349
|
-
*
|
|
3350
|
-
*
|
|
3351
|
-
* @returns 是否需要加签
|
|
3352
|
-
*/
|
|
3353
|
-
const isSecsdkProtected = (path, method = "GET") => {
|
|
3354
|
-
return (String(method).toUpperCase() === "POST" ? PROTECTED_PATHS_POST : PROTECTED_PATHS_GET).includes(path);
|
|
3355
|
-
};
|
|
3356
|
-
/**
|
|
3357
|
-
* 计算 `x-secsdk-web-signature`。
|
|
3358
|
-
*
|
|
3359
|
-
* @param url - 完整 API URL(scheme+host+path+query)。已带 `timestamp` /
|
|
3360
|
-
* `x-secsdk-web-signature` 时会自动剥除,可重复调用。
|
|
3361
|
-
* @param options - 时间戳与 uifid 兜底值
|
|
3362
|
-
* @returns 时间戳、签名与**实际应当发送**的规范化 query
|
|
3363
|
-
*/
|
|
3364
|
-
const signSecsdkWebQuery = (url, options = {}) => {
|
|
3365
|
-
const ts = Math.trunc(options.ts ?? Date.now() / 1e3);
|
|
3366
|
-
const [, rawQuery] = splitUrl(url);
|
|
3367
|
-
let canon = canonicalQuery(rawQuery);
|
|
3368
|
-
if (!canon.split("&").filter(Boolean).map((pair) => pair.split("=", 1)[0]).includes("uifid") && options.uifid) {
|
|
3369
|
-
const appended = `uifid=${encodeURIComponent(options.uifid)}`;
|
|
3370
|
-
canon = canon ? `${canon}&${appended}` : appended;
|
|
3371
|
-
}
|
|
3372
|
-
const signedQuery = canon ? `${canon}&${SECSDK_TS_KEY}=${ts}` : `${SECSDK_TS_KEY}=${ts}`;
|
|
3373
|
-
let uifidValue = "";
|
|
3374
|
-
for (const pair of signedQuery.split("&")) if (pair.startsWith("uifid=")) {
|
|
3375
|
-
uifidValue = unquotePlusLoose(pair.slice(6));
|
|
3376
|
-
break;
|
|
3377
|
-
}
|
|
3378
|
-
if (!uifidValue && options.uifid) uifidValue = options.uifid;
|
|
3379
|
-
const plain = `${uifidValue}_${ts}_${WEBSIGN_CONST}_${signedQuery}`;
|
|
3380
|
-
return {
|
|
3381
|
-
ts,
|
|
3382
|
-
signature: node_crypto.default.createHash("md5").update(plain, "utf8").digest("hex"),
|
|
3383
|
-
signedQuery
|
|
3384
|
-
};
|
|
3385
|
-
};
|
|
3386
|
-
/**
|
|
3387
|
-
* 返回可直接发送的完整 URL(规范化 query + timestamp + 签名)。
|
|
2479
|
+
* 其中 webid 与 secsdk 那两段都是「命中才动、否则原样返回」,对不相关的端点是无操作,
|
|
2480
|
+
* 所以可以无条件套用 —— 详见 `./steps.ts` 文件头。
|
|
3388
2481
|
*
|
|
3389
|
-
*
|
|
2482
|
+
* ## 为什么 secsdk 没有自己的注册名
|
|
3390
2483
|
*
|
|
3391
|
-
*
|
|
3392
|
-
*
|
|
3393
|
-
*
|
|
3394
|
-
*/
|
|
3395
|
-
const signSecsdkWebUrl = (url, options = {}) => {
|
|
3396
|
-
const [base] = splitUrl(url);
|
|
3397
|
-
const { signature, signedQuery } = signSecsdkWebQuery(url, options);
|
|
3398
|
-
return `${base}?${signedQuery}&${SECSDK_SIG_KEY}=${signature}`;
|
|
3399
|
-
};
|
|
3400
|
-
/**
|
|
3401
|
-
* 对受保护接口补上 secsdk 签名;path 不在策略表里时原样返回。
|
|
3402
|
-
*
|
|
3403
|
-
* 这是给取数层用的唯一入口 —— 无条件套在 URL 上即可,不需要调用方判断端点。
|
|
2484
|
+
* 它对策略表外的 path 是无操作,无条件复合进这两个名字是安全的;而 `sign` 是单槽位,
|
|
2485
|
+
* 另起一个名字只会逼出 `'a-bogus+secsdk'` 这种复合命名。要精细控制的调用方给 step
|
|
2486
|
+
* 清单,不是加注册名。
|
|
3404
2487
|
*
|
|
3405
|
-
* @
|
|
3406
|
-
* @param options - cookie / uifid / method / ts
|
|
3407
|
-
* @returns 需要加签的返回带签名的 URL,否则原样返回
|
|
2488
|
+
* @module platforms/douyin/sign/signers
|
|
3408
2489
|
*/
|
|
3409
|
-
const applySecsdkWebSign = (url, options = {}) => {
|
|
3410
|
-
let pathname;
|
|
3411
|
-
try {
|
|
3412
|
-
pathname = new URL(url).pathname;
|
|
3413
|
-
} catch {
|
|
3414
|
-
return url;
|
|
3415
|
-
}
|
|
3416
|
-
if (!isSecsdkProtected(pathname, options.method)) return url;
|
|
3417
|
-
const uifid = options.uifid || extractUifidFromCookie(options.cookie);
|
|
3418
|
-
return signSecsdkWebUrl(url, {
|
|
3419
|
-
ts: options.ts,
|
|
3420
|
-
uifid
|
|
3421
|
-
});
|
|
3422
|
-
};
|
|
3423
|
-
//#endregion
|
|
3424
|
-
//#region src/platforms/douyin/sign/signers.ts
|
|
3425
|
-
/**
|
|
3426
|
-
* 抖音签名器(SignFn 形式)。
|
|
3427
|
-
*
|
|
3428
|
-
* 签名器在入口先校验前置条件:
|
|
3429
|
-
* - **AB 需绝对 URL**(以 `http(s)://` 开头)。
|
|
3430
|
-
* - **XB 需真实接口形态的长路径**(pathname ≥ 3 段且带查询串)。
|
|
3431
|
-
*
|
|
3432
|
-
* 前置条件不满足时签名器**抛带明确 message 的错误** —— execute 的
|
|
3433
|
-
* 单一 catch 把它归因为 `kind: 'internal'` / `INTERNAL_ERROR` 收进失败
|
|
3434
|
-
* 信封,调用方不会拿到裸的 `TypeError`。这些条件由 `build` 保证满足
|
|
3435
|
-
* (URL 构造器只产出合法绝对地址),签名器里的校验只是防线。
|
|
3436
|
-
*
|
|
3437
|
-
* ## 为什么这两个签名器前后各多一步
|
|
3438
|
-
*
|
|
3439
|
-
* 真实浏览器发这些请求的顺序是 **webid → a_bogus / x_bogus → secsdk**,三步都改
|
|
3440
|
-
* query,颠倒任意一步签名就不成立。所以两个签名器各自是一条三段的管线,
|
|
3441
|
-
* 前后那两段都是「命中才动、否则原样返回」,对不相关的端点是无操作。
|
|
3442
|
-
*
|
|
3443
|
-
* **前一步 webid**(见 {@link withDouyinWebid}):抖音会拿 query 里的 `webid` 与 cookie
|
|
3444
|
-
* 会话交叉校验,对不上就静默回 0 字节。它是服务端下发的、客户端算不出来,所以只在
|
|
3445
|
-
* 按 ttwid 缓存命中时才补 —— 冷启动第一次不带(不带是安全的)。签名器的入参是
|
|
3446
|
-
* `(spec, ctx)`,能读 `ctx.cookie`。
|
|
3447
|
-
*
|
|
3448
|
-
* **后一步 secsdk**:`x-secsdk-web-signature` 是抖音主站的第三种签名,浏览器里由
|
|
3449
|
-
* secsdk 的 JS 现算。它与 AB / XB 有三点不同(详见 {@link applySecsdkWebSign} 所在模块):
|
|
3450
|
-
*
|
|
3451
|
-
* 1. **它改写整条 URL**,不是返回一个参数值 —— 签名算的是规范化后的 query,
|
|
3452
|
-
* 服务端也按收到的 query 校验;
|
|
3453
|
-
* 2. **只对 SDK 策略表里的 path 生效**(14 个 GET / 6 个 POST),其余原样返回,
|
|
3454
|
-
* 所以可以无条件套用;
|
|
3455
|
-
* 3. **必须是最后一步**。
|
|
3456
|
-
*
|
|
3457
|
-
* 因为第 2 点是无条件安全的,它没有单独注册成第三个签名器名,而是复合进这两个 ——
|
|
3458
|
-
* `sign` 是单槽位,另起一个名字只会逼出 `'a-bogus+secsdk'` 这种复合命名。
|
|
3459
|
-
* 影响面:`musicInfo`(`music/detail`)、作品详情、用户作品、喜欢列表都在策略表内。
|
|
3460
|
-
* `sign: false` 的两条(`emojiList` / `search`)与四条免鉴权端点不经过这里,
|
|
3461
|
-
* 它们的 path 也都不在策略表里、也拿不到 webid。
|
|
3462
|
-
*/
|
|
3463
|
-
/** AB 前置条件:绝对 URL(`http(s)://` 开头) */
|
|
3464
|
-
const isAbsoluteUrl = (url) => /^https?:\/\//.test(url);
|
|
3465
|
-
/** XB 前置条件:真实接口形态 —— pathname 至少 3 段且带查询串 */
|
|
3466
|
-
const isApiLikePath = (url) => {
|
|
3467
|
-
if (!isAbsoluteUrl(url)) return false;
|
|
3468
|
-
const parsed = new URL(url);
|
|
3469
|
-
return parsed.pathname.split("/").filter(Boolean).length >= 3 && parsed.search.length > 0;
|
|
3470
|
-
};
|
|
3471
2490
|
/**
|
|
3472
|
-
*
|
|
2491
|
+
* 平台签名器表,交给 runtime 的 `signers` 查名。
|
|
3473
2492
|
*
|
|
3474
|
-
*
|
|
3475
|
-
*
|
|
3476
|
-
*
|
|
3477
|
-
* @returns 需要加签时返回改写过 URL 的请求描述,否则原样返回
|
|
3478
|
-
*/
|
|
3479
|
-
const withSecsdk = (spec, cookie) => {
|
|
3480
|
-
const url = applySecsdkWebSign(spec.url, {
|
|
3481
|
-
cookie,
|
|
3482
|
-
method: spec.method
|
|
3483
|
-
});
|
|
3484
|
-
return url === spec.url ? spec : {
|
|
3485
|
-
...spec,
|
|
3486
|
-
url
|
|
3487
|
-
};
|
|
3488
|
-
};
|
|
3489
|
-
/**
|
|
3490
|
-
* `a_bogus` 签名器(`sign: 'a-bogus'`)。
|
|
3491
|
-
*
|
|
3492
|
-
* 前置条件:URL 必须是绝对地址。不满足时抛错,由 execute 归因为
|
|
3493
|
-
* `kind: 'internal'`。
|
|
3494
|
-
* @param spec - 请求描述(`url` 参与签名)
|
|
3495
|
-
* @param ctx - 执行上下文(`userAgent` 用于签名,`cookie` 用于取 secsdk 的 uifid)
|
|
3496
|
-
* @returns 带 `a_bogus` 的请求描述;path 在 secsdk 策略表内时再补一层 `x-secsdk-web-signature`
|
|
3497
|
-
*/
|
|
3498
|
-
const aBogusSigner = (spec, ctx) => {
|
|
3499
|
-
if (!isAbsoluteUrl(spec.url)) throw new Error(`a_bogus 前置条件不满足:URL 必须是绝对地址(收到 "${spec.url}")`);
|
|
3500
|
-
const url = new URL(withDouyinWebid(spec.url, ctx.cookie));
|
|
3501
|
-
url.searchParams.set("a_bogus", douyinSign$1.AB(url.toString(), ctx.userAgent));
|
|
3502
|
-
return withSecsdk({
|
|
3503
|
-
...spec,
|
|
3504
|
-
url: url.toString()
|
|
3505
|
-
}, ctx.cookie);
|
|
3506
|
-
};
|
|
3507
|
-
/**
|
|
3508
|
-
* `x_bogus` 签名器(`sign: 'x-bogus'`)。
|
|
2493
|
+
* 两个名字都取 **184** 那档 msToken 长度:这张表只在 `client.<平台>.request` 那条路上被查到,
|
|
2494
|
+
* 而那条路打的是「本库还没收录的接口」—— 猜不出该用 184(作品详情类)还是 116(评论 / 音乐类),
|
|
2495
|
+
* 统一取前者。收录进端点的接口各自在声明里写准确的那一档,不经过这里。
|
|
3509
2496
|
*
|
|
3510
|
-
*
|
|
3511
|
-
*
|
|
3512
|
-
* @
|
|
3513
|
-
* @param ctx - 执行上下文(`userAgent` 用于签名)
|
|
3514
|
-
* @returns 带 `X-Bogus` 的请求描述;path 在 secsdk 策略表内时再补一层 `x-secsdk-web-signature`
|
|
2497
|
+
* 用 `satisfies` 而非显式 `: Record<string, SignFn>` 返回:后者会把键联合抹成宽
|
|
2498
|
+
* `string`,而 {@link DouyinSignerName} 要靠 `keyof` 从这张表推导出精确的名字联合。
|
|
2499
|
+
* @returns 注册名 → 签名器
|
|
3515
2500
|
*/
|
|
3516
|
-
const xBogusSigner = (spec, ctx) => {
|
|
3517
|
-
if (!isApiLikePath(spec.url)) throw new Error(`x_bogus 前置条件不满足:URL 需真实接口形态的长路径(≥3 段且带查询串,收到 "${spec.url}")`);
|
|
3518
|
-
const url = new URL(withDouyinWebid(spec.url, ctx.cookie));
|
|
3519
|
-
url.searchParams.set("X-Bogus", douyinSign$1.XB(url.toString(), ctx.userAgent));
|
|
3520
|
-
return withSecsdk({
|
|
3521
|
-
...spec,
|
|
3522
|
-
url: url.toString()
|
|
3523
|
-
}, ctx.cookie);
|
|
3524
|
-
};
|
|
3525
|
-
/** 平台签名器表,交给 runtime 的 `signers` 查名 */
|
|
3526
2501
|
const createDouyinSigners = () => ({
|
|
3527
|
-
"a-bogus":
|
|
3528
|
-
"x-bogus":
|
|
2502
|
+
"a-bogus": stepsToSigner(require_steps.douyinBogus(184)),
|
|
2503
|
+
"x-bogus": stepsToSigner(require_steps.douyinXBogus(184))
|
|
3529
2504
|
});
|
|
3530
2505
|
//#endregion
|
|
3531
2506
|
//#region src/platforms/kuaishou/captcha.ts
|
|
@@ -3673,7 +2648,7 @@ const parseKuaishouCaptcha = (raw) => {
|
|
|
3673
2648
|
const KUAISHOU_DEFAULT_TIMEOUT = 1e4;
|
|
3674
2649
|
/** 从请求配置里取 UA:大小写不敏感,取不到用集中维护的默认值 */
|
|
3675
2650
|
const userAgentOf$1 = (requestConfig) => {
|
|
3676
|
-
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";
|
|
2651
|
+
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";
|
|
3677
2652
|
};
|
|
3678
2653
|
/**
|
|
3679
2654
|
* 构造快手默认请求配置。
|
|
@@ -3687,7 +2662,7 @@ const createKuaishouConfig = (cookie, requestConfig) => {
|
|
|
3687
2662
|
const userAgent = userAgentOf$1(requestConfig);
|
|
3688
2663
|
const secChUa = generateSecChUa(userAgent);
|
|
3689
2664
|
return {
|
|
3690
|
-
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),
|
|
2665
|
+
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),
|
|
3691
2666
|
requestConfig: {
|
|
3692
2667
|
timeout: KUAISHOU_DEFAULT_TIMEOUT,
|
|
3693
2668
|
...requestConfig
|
|
@@ -3890,1140 +2865,20 @@ const kuaishouResultVerdict = (raw) => {
|
|
|
3890
2865
|
};
|
|
3891
2866
|
};
|
|
3892
2867
|
//#endregion
|
|
3893
|
-
//#region src/platforms/kuaishou/sign/hudr.ts
|
|
3894
|
-
const KUAISHOU_HUDR_PREFIX = "HUDR_";
|
|
3895
|
-
const KUAISHOU_HUDR_MASK_BYTE = 35;
|
|
3896
|
-
const KUAISHOU_HUDR_BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
3897
|
-
const KUAISHOU_HUDR_CHACHA_KEY = [
|
|
3898
|
-
4183807412,
|
|
3899
|
-
394484062,
|
|
3900
|
-
1106561997,
|
|
3901
|
-
2378328696,
|
|
3902
|
-
630790222,
|
|
3903
|
-
2546784104,
|
|
3904
|
-
2891127470,
|
|
3905
|
-
1922531795
|
|
3906
|
-
];
|
|
3907
|
-
const KUAISHOU_HUDR_CHACHA_NONCE = [
|
|
3908
|
-
2215853858,
|
|
3909
|
-
1643070585,
|
|
3910
|
-
1849059804
|
|
3911
|
-
];
|
|
3912
|
-
/** 将字符串转换为 Unicode code point 数组。 */
|
|
3913
|
-
const toCodePointArray = (value) => {
|
|
3914
|
-
return Array.from(value).map((char) => char.codePointAt(0) ?? 0);
|
|
3915
|
-
};
|
|
3916
|
-
/** 以小端序输出固定长度字节数组。 */
|
|
3917
|
-
const toLittleEndianBytes = (value, size = 4) => {
|
|
3918
|
-
if (size >= 4 && value >= Math.pow(2, 32)) return [
|
|
3919
|
-
255,
|
|
3920
|
-
255,
|
|
3921
|
-
255,
|
|
3922
|
-
255
|
|
3923
|
-
];
|
|
3924
|
-
const result = [];
|
|
3925
|
-
for (let index = 0; index <= size - 1; index++) result[index] = value >>> 8 * index & 255;
|
|
3926
|
-
return result;
|
|
3927
|
-
};
|
|
3928
|
-
/** 对字节切片执行标准 Base64 编码。 */
|
|
3929
|
-
const encodeBase64Slice = (bytes, start, end) => {
|
|
3930
|
-
const result = [];
|
|
3931
|
-
for (let index = start; index < end; index += 3) {
|
|
3932
|
-
const value = (bytes[index] << 16 & 16711680) + (bytes[index + 1] << 8 & 65280) + (bytes[index + 2] & 255);
|
|
3933
|
-
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]);
|
|
3934
|
-
}
|
|
3935
|
-
return result.join("");
|
|
3936
|
-
};
|
|
3937
|
-
/** 快手 `HUDR_` 使用的 Base64URL 变体编码。 */
|
|
3938
|
-
const encodeBase64Url = (bytes) => {
|
|
3939
|
-
const result = [];
|
|
3940
|
-
const remainder = bytes.length % 3;
|
|
3941
|
-
const tripletLength = bytes.length - remainder;
|
|
3942
|
-
for (let index = 0; index < tripletLength; index += 16383) {
|
|
3943
|
-
const end = tripletLength < index + 16383 ? tripletLength : index + 16383;
|
|
3944
|
-
result.push(encodeBase64Slice(bytes, index, end));
|
|
3945
|
-
}
|
|
3946
|
-
if (remainder === 1) {
|
|
3947
|
-
const value = bytes[bytes.length - 1];
|
|
3948
|
-
result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 2], KUAISHOU_HUDR_BASE64_ALPHABET[value << 4 & 63], "==");
|
|
3949
|
-
} else if (remainder === 2) {
|
|
3950
|
-
const value = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
|
|
3951
|
-
result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 10], KUAISHOU_HUDR_BASE64_ALPHABET[value >> 4 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value << 2 & 63], "=");
|
|
3952
|
-
}
|
|
3953
|
-
return result.join("").replace(/[+/=]/g, (char) => {
|
|
3954
|
-
if (char === "+") return "-";
|
|
3955
|
-
if (char === "/") return "_";
|
|
3956
|
-
return ".";
|
|
3957
|
-
});
|
|
3958
|
-
};
|
|
3959
|
-
/**
|
|
3960
|
-
* 快手 `HUDR_` 所用的 ChaCha20 变体实现。
|
|
3961
|
-
*
|
|
3962
|
-
* 该实现并非通用密码库封装,而是为对齐页面 `$encode`
|
|
3963
|
-
* 数据流而最小化保留的局部算法。
|
|
3964
|
-
*/
|
|
3965
|
-
var KuaishouChaChaCipher = class {
|
|
3966
|
-
key;
|
|
3967
|
-
nonce;
|
|
3968
|
-
wordIndex = 0;
|
|
3969
|
-
state = new Array(16).fill(0);
|
|
3970
|
-
constructor(key, nonce) {
|
|
3971
|
-
this.key = key;
|
|
3972
|
-
this.nonce = nonce;
|
|
3973
|
-
}
|
|
3974
|
-
rotateLeft(value, shift) {
|
|
3975
|
-
return (value << shift | value >>> 32 - shift) >>> 0;
|
|
3976
|
-
}
|
|
3977
|
-
quarterRound(target, a, b, c, d) {
|
|
3978
|
-
target[a] = target[a] + target[b] >>> 0;
|
|
3979
|
-
target[d] ^= target[a];
|
|
3980
|
-
target[d] = this.rotateLeft(target[d], 16);
|
|
3981
|
-
target[c] = target[c] + target[d] >>> 0;
|
|
3982
|
-
target[b] ^= target[c];
|
|
3983
|
-
target[b] = this.rotateLeft(target[b], 12);
|
|
3984
|
-
target[a] = target[a] + target[b] >>> 0;
|
|
3985
|
-
target[d] ^= target[a];
|
|
3986
|
-
target[d] = this.rotateLeft(target[d], 8);
|
|
3987
|
-
target[c] = target[c] + target[d] >>> 0;
|
|
3988
|
-
target[b] ^= target[c];
|
|
3989
|
-
target[b] = this.rotateLeft(target[b], 7);
|
|
3990
|
-
}
|
|
3991
|
-
refillBlock() {
|
|
3992
|
-
const working = this.state.slice();
|
|
3993
|
-
for (let round = 0; round < 20; round += 2) {
|
|
3994
|
-
this.quarterRound(working, 0, 4, 8, 12);
|
|
3995
|
-
this.quarterRound(working, 1, 5, 9, 13);
|
|
3996
|
-
this.quarterRound(working, 2, 6, 10, 14);
|
|
3997
|
-
this.quarterRound(working, 3, 7, 11, 15);
|
|
3998
|
-
this.quarterRound(working, 0, 5, 10, 15);
|
|
3999
|
-
this.quarterRound(working, 1, 6, 11, 12);
|
|
4000
|
-
this.quarterRound(working, 2, 7, 8, 13);
|
|
4001
|
-
this.quarterRound(working, 3, 4, 9, 14);
|
|
4002
|
-
}
|
|
4003
|
-
const mixedState = new Array(16).fill(0);
|
|
4004
|
-
for (let index = 0; index < 16; index++) mixedState[index] = working[index] + this.state[index] >>> 0;
|
|
4005
|
-
return mixedState;
|
|
4006
|
-
}
|
|
4007
|
-
encrypt(input) {
|
|
4008
|
-
this.wordIndex = 0;
|
|
4009
|
-
this.state[0] = 394484062;
|
|
4010
|
-
this.state[1] = 2378328696;
|
|
4011
|
-
this.state[2] = 630790222;
|
|
4012
|
-
this.state[3] = 1922531795;
|
|
4013
|
-
for (let index = 0; index < 8; index++) this.state[index + 4] = this.key[index] >>> 0;
|
|
4014
|
-
this.state[12] = 1;
|
|
4015
|
-
this.state[13] = this.nonce[0] >>> 0;
|
|
4016
|
-
this.state[14] = this.nonce[1] >>> 0;
|
|
4017
|
-
this.state[15] = this.nonce[2] >>> 0;
|
|
4018
|
-
let mixedState = this.refillBlock();
|
|
4019
|
-
const output = new Uint8Array(input.length);
|
|
4020
|
-
for (let index = 0; index < input.length; index++) {
|
|
4021
|
-
if (this.wordIndex === 64) {
|
|
4022
|
-
this.state[12] = this.state[12] + 1 >>> 0;
|
|
4023
|
-
mixedState = this.refillBlock();
|
|
4024
|
-
this.wordIndex = 0;
|
|
4025
|
-
}
|
|
4026
|
-
const keystreamByte = mixedState[this.wordIndex >> 2] >>> ((this.wordIndex & 3) << 3) & 255;
|
|
4027
|
-
this.wordIndex += 1;
|
|
4028
|
-
output[index] = input[index] ^ keystreamByte;
|
|
4029
|
-
}
|
|
4030
|
-
return output;
|
|
4031
|
-
}
|
|
4032
|
-
};
|
|
4033
|
-
/**
|
|
4034
|
-
* 构造 `HUDR_` 头部中的 `infoCache` 字段。
|
|
4035
|
-
*
|
|
4036
|
-
* @param scriptCount - 页面脚本数量
|
|
4037
|
-
* @returns `HUDR_` 载荷中的脚本信息缓存字节
|
|
4038
|
-
*/
|
|
4039
|
-
const buildKuaishouHudrInfoCache = (scriptCount = 0) => {
|
|
4040
|
-
return [68, 0].concat(toLittleEndianBytes(scriptCount, 4));
|
|
4041
|
-
};
|
|
4042
|
-
/**
|
|
4043
|
-
* 构造快手 `HUDR_` 原始载荷。
|
|
4044
|
-
*
|
|
4045
|
-
* @param context - 生成 `HUDR_` 所需的上下文参数
|
|
4046
|
-
* @returns 尚未异或和加密的原始字节载荷
|
|
4047
|
-
*/
|
|
4048
|
-
const buildKuaishouHudrPayload = (context) => {
|
|
4049
|
-
const stackTail = context.secs?.s ?? "";
|
|
4050
|
-
const secsCount = context.secs?.c ?? 0;
|
|
4051
|
-
const count = context.count || 0;
|
|
4052
|
-
return [
|
|
4053
|
-
45,
|
|
4054
|
-
61,
|
|
4055
|
-
0,
|
|
4056
|
-
2
|
|
4057
|
-
].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));
|
|
4058
|
-
};
|
|
4059
|
-
/**
|
|
4060
|
-
* 对 `HUDR_` 原始载荷执行页面同款掩码异或。
|
|
4061
|
-
*
|
|
4062
|
-
* @param payload - 原始 `HUDR_` 字节载荷
|
|
4063
|
-
* @returns 掩码处理后的字节数组
|
|
4064
|
-
*/
|
|
4065
|
-
const maskKuaishouHudrPayload = (payload) => {
|
|
4066
|
-
const masked = new Uint8Array(payload.length);
|
|
4067
|
-
for (let index = 0; index < payload.length; index++) masked[index] = KUAISHOU_HUDR_MASK_BYTE ^ payload[index];
|
|
4068
|
-
return masked;
|
|
4069
|
-
};
|
|
4070
|
-
/**
|
|
4071
|
-
* 推导快手签名中的 `HUDR_` 段。
|
|
4072
|
-
*
|
|
4073
|
-
* @param context - 生成 `HUDR_` 所需的上下文参数
|
|
4074
|
-
* @returns `HUDR_` 的完整结果及若干中间态,便于对拍与调试
|
|
4075
|
-
*/
|
|
4076
|
-
const deriveKuaishouHudrBody = (context) => {
|
|
4077
|
-
const payload = buildKuaishouHudrPayload(context);
|
|
4078
|
-
const maskedPayload = maskKuaishouHudrPayload(payload);
|
|
4079
|
-
const encrypted = new KuaishouChaChaCipher(KUAISHOU_HUDR_CHACHA_KEY, KUAISHOU_HUDR_CHACHA_NONCE).encrypt(maskedPayload);
|
|
4080
|
-
const body = encodeBase64Url(encrypted);
|
|
4081
|
-
return {
|
|
4082
|
-
body,
|
|
4083
|
-
full: `${KUAISHOU_HUDR_PREFIX}${body}`,
|
|
4084
|
-
infoCache: buildKuaishouHudrInfoCache(context.scriptCount),
|
|
4085
|
-
maskedPayload,
|
|
4086
|
-
nextCount: context.count + 1
|
|
4087
|
-
};
|
|
4088
|
-
};
|
|
4089
|
-
//#endregion
|
|
4090
|
-
//#region src/platforms/kuaishou/sign/primitives.ts
|
|
4091
|
-
const KUAISHOU_BLAKE2S_IV = [
|
|
4092
|
-
2837534710,
|
|
4093
|
-
2845986804,
|
|
4094
|
-
2436420605,
|
|
4095
|
-
706843635,
|
|
4096
|
-
719254516,
|
|
4097
|
-
2557931286,
|
|
4098
|
-
2596197199,
|
|
4099
|
-
2432949778
|
|
4100
|
-
];
|
|
4101
|
-
const KUAISHOU_BLAKE2S_SIGMA = [
|
|
4102
|
-
[
|
|
4103
|
-
0,
|
|
4104
|
-
1,
|
|
4105
|
-
2,
|
|
4106
|
-
3,
|
|
4107
|
-
4,
|
|
4108
|
-
5,
|
|
4109
|
-
6,
|
|
4110
|
-
7,
|
|
4111
|
-
8,
|
|
4112
|
-
9,
|
|
4113
|
-
10,
|
|
4114
|
-
11,
|
|
4115
|
-
12,
|
|
4116
|
-
13,
|
|
4117
|
-
14,
|
|
4118
|
-
15
|
|
4119
|
-
],
|
|
4120
|
-
[
|
|
4121
|
-
14,
|
|
4122
|
-
10,
|
|
4123
|
-
4,
|
|
4124
|
-
8,
|
|
4125
|
-
9,
|
|
4126
|
-
15,
|
|
4127
|
-
13,
|
|
4128
|
-
6,
|
|
4129
|
-
1,
|
|
4130
|
-
12,
|
|
4131
|
-
0,
|
|
4132
|
-
2,
|
|
4133
|
-
11,
|
|
4134
|
-
7,
|
|
4135
|
-
5,
|
|
4136
|
-
3
|
|
4137
|
-
],
|
|
4138
|
-
[
|
|
4139
|
-
11,
|
|
4140
|
-
8,
|
|
4141
|
-
12,
|
|
4142
|
-
0,
|
|
4143
|
-
5,
|
|
4144
|
-
2,
|
|
4145
|
-
15,
|
|
4146
|
-
13,
|
|
4147
|
-
10,
|
|
4148
|
-
14,
|
|
4149
|
-
3,
|
|
4150
|
-
6,
|
|
4151
|
-
7,
|
|
4152
|
-
1,
|
|
4153
|
-
9,
|
|
4154
|
-
4
|
|
4155
|
-
],
|
|
4156
|
-
[
|
|
4157
|
-
7,
|
|
4158
|
-
9,
|
|
4159
|
-
3,
|
|
4160
|
-
1,
|
|
4161
|
-
13,
|
|
4162
|
-
12,
|
|
4163
|
-
11,
|
|
4164
|
-
14,
|
|
4165
|
-
2,
|
|
4166
|
-
6,
|
|
4167
|
-
5,
|
|
4168
|
-
10,
|
|
4169
|
-
4,
|
|
4170
|
-
0,
|
|
4171
|
-
15,
|
|
4172
|
-
8
|
|
4173
|
-
],
|
|
4174
|
-
[
|
|
4175
|
-
9,
|
|
4176
|
-
0,
|
|
4177
|
-
5,
|
|
4178
|
-
7,
|
|
4179
|
-
2,
|
|
4180
|
-
4,
|
|
4181
|
-
10,
|
|
4182
|
-
15,
|
|
4183
|
-
14,
|
|
4184
|
-
1,
|
|
4185
|
-
11,
|
|
4186
|
-
12,
|
|
4187
|
-
6,
|
|
4188
|
-
8,
|
|
4189
|
-
3,
|
|
4190
|
-
13
|
|
4191
|
-
],
|
|
4192
|
-
[
|
|
4193
|
-
2,
|
|
4194
|
-
12,
|
|
4195
|
-
6,
|
|
4196
|
-
10,
|
|
4197
|
-
0,
|
|
4198
|
-
11,
|
|
4199
|
-
8,
|
|
4200
|
-
3,
|
|
4201
|
-
4,
|
|
4202
|
-
13,
|
|
4203
|
-
7,
|
|
4204
|
-
5,
|
|
4205
|
-
15,
|
|
4206
|
-
14,
|
|
4207
|
-
1,
|
|
4208
|
-
9
|
|
4209
|
-
],
|
|
4210
|
-
[
|
|
4211
|
-
12,
|
|
4212
|
-
5,
|
|
4213
|
-
1,
|
|
4214
|
-
15,
|
|
4215
|
-
14,
|
|
4216
|
-
13,
|
|
4217
|
-
4,
|
|
4218
|
-
10,
|
|
4219
|
-
0,
|
|
4220
|
-
7,
|
|
4221
|
-
6,
|
|
4222
|
-
3,
|
|
4223
|
-
9,
|
|
4224
|
-
2,
|
|
4225
|
-
8,
|
|
4226
|
-
11
|
|
4227
|
-
],
|
|
4228
|
-
[
|
|
4229
|
-
13,
|
|
4230
|
-
11,
|
|
4231
|
-
7,
|
|
4232
|
-
14,
|
|
4233
|
-
12,
|
|
4234
|
-
1,
|
|
4235
|
-
3,
|
|
4236
|
-
9,
|
|
4237
|
-
5,
|
|
4238
|
-
0,
|
|
4239
|
-
15,
|
|
4240
|
-
4,
|
|
4241
|
-
8,
|
|
4242
|
-
6,
|
|
4243
|
-
2,
|
|
4244
|
-
10
|
|
4245
|
-
],
|
|
4246
|
-
[
|
|
4247
|
-
6,
|
|
4248
|
-
15,
|
|
4249
|
-
14,
|
|
4250
|
-
9,
|
|
4251
|
-
11,
|
|
4252
|
-
3,
|
|
4253
|
-
0,
|
|
4254
|
-
8,
|
|
4255
|
-
12,
|
|
4256
|
-
2,
|
|
4257
|
-
13,
|
|
4258
|
-
7,
|
|
4259
|
-
1,
|
|
4260
|
-
4,
|
|
4261
|
-
10,
|
|
4262
|
-
5
|
|
4263
|
-
],
|
|
4264
|
-
[
|
|
4265
|
-
10,
|
|
4266
|
-
2,
|
|
4267
|
-
8,
|
|
4268
|
-
4,
|
|
4269
|
-
7,
|
|
4270
|
-
6,
|
|
4271
|
-
1,
|
|
4272
|
-
5,
|
|
4273
|
-
15,
|
|
4274
|
-
11,
|
|
4275
|
-
9,
|
|
4276
|
-
14,
|
|
4277
|
-
3,
|
|
4278
|
-
12,
|
|
4279
|
-
13,
|
|
4280
|
-
0
|
|
4281
|
-
]
|
|
4282
|
-
];
|
|
4283
|
-
const KUAISHOU_CTS_STATE_VECTOR = new Int8Array([
|
|
4284
|
-
98,
|
|
4285
|
-
0,
|
|
4286
|
-
0,
|
|
4287
|
-
128,
|
|
4288
|
-
49,
|
|
4289
|
-
117,
|
|
4290
|
-
185,
|
|
4291
|
-
253,
|
|
4292
|
-
224,
|
|
4293
|
-
172,
|
|
4294
|
-
104,
|
|
4295
|
-
36,
|
|
4296
|
-
223,
|
|
4297
|
-
155,
|
|
4298
|
-
87,
|
|
4299
|
-
19,
|
|
4300
|
-
32,
|
|
4301
|
-
0,
|
|
4302
|
-
0,
|
|
4303
|
-
64,
|
|
4304
|
-
2,
|
|
4305
|
-
0,
|
|
4306
|
-
0,
|
|
4307
|
-
16,
|
|
4308
|
-
255,
|
|
4309
|
-
255,
|
|
4310
|
-
255,
|
|
4311
|
-
127,
|
|
4312
|
-
255,
|
|
4313
|
-
255,
|
|
4314
|
-
255,
|
|
4315
|
-
63,
|
|
4316
|
-
0,
|
|
4317
|
-
0,
|
|
4318
|
-
0,
|
|
4319
|
-
240,
|
|
4320
|
-
0,
|
|
4321
|
-
0,
|
|
4322
|
-
0,
|
|
4323
|
-
192,
|
|
4324
|
-
0,
|
|
4325
|
-
0,
|
|
4326
|
-
0,
|
|
4327
|
-
128,
|
|
4328
|
-
255,
|
|
4329
|
-
255,
|
|
4330
|
-
255,
|
|
4331
|
-
15
|
|
4332
|
-
]);
|
|
4333
|
-
const rotateRight = (value, shift) => {
|
|
4334
|
-
return (value >>> shift | value << 32 - shift) >>> 0;
|
|
4335
|
-
};
|
|
4336
|
-
const toUtf8Int8Array = (value) => {
|
|
4337
|
-
return new Int8Array(unescape(encodeURIComponent(value)).split("").map((char) => char.charCodeAt(0) || 0));
|
|
4338
|
-
};
|
|
4339
|
-
const toHex32 = (value) => {
|
|
4340
|
-
return (value >>> 0).toString(16).padStart(8, "0");
|
|
4341
|
-
};
|
|
4342
|
-
const blake2sQuarterRound = (state, a, b, c, d, x, y) => {
|
|
4343
|
-
state[a] = state[a] + state[b] + x >>> 0;
|
|
4344
|
-
state[d] = rotateRight(state[d] ^ state[a], 16);
|
|
4345
|
-
state[c] = state[c] + state[d] >>> 0;
|
|
4346
|
-
state[b] = rotateRight(state[b] ^ state[c], 12);
|
|
4347
|
-
state[a] = state[a] + state[b] + y >>> 0;
|
|
4348
|
-
state[d] = rotateRight(state[d] ^ state[a], 8);
|
|
4349
|
-
state[c] = state[c] + state[d] >>> 0;
|
|
4350
|
-
state[b] = rotateRight(state[b] ^ state[c], 7);
|
|
4351
|
-
};
|
|
4352
|
-
const blake2sCompress = (hash, words, offset, counter, length, isLastBlock) => {
|
|
4353
|
-
const work = new Array(16).fill(0);
|
|
4354
|
-
const block = new Array(16).fill(0);
|
|
4355
|
-
for (let index = 0; index < 8; index++) {
|
|
4356
|
-
work[index] = hash[index] >>> 0;
|
|
4357
|
-
work[index + 8] = KUAISHOU_BLAKE2S_IV[index] >>> 0;
|
|
4358
|
-
}
|
|
4359
|
-
work[12] ^= counter >>> 0;
|
|
4360
|
-
if (isLastBlock) work[14] ^= 4294967295;
|
|
4361
|
-
for (let index = 0; index < length; index++) block[index % 16] ^= words[offset + index] >>> 0;
|
|
4362
|
-
for (const sigma of KUAISHOU_BLAKE2S_SIGMA) {
|
|
4363
|
-
blake2sQuarterRound(work, 0, 4, 8, 12, block[sigma[0]], block[sigma[1]]);
|
|
4364
|
-
blake2sQuarterRound(work, 1, 5, 9, 13, block[sigma[2]], block[sigma[3]]);
|
|
4365
|
-
blake2sQuarterRound(work, 2, 6, 10, 14, block[sigma[4]], block[sigma[5]]);
|
|
4366
|
-
blake2sQuarterRound(work, 3, 7, 11, 15, block[sigma[6]], block[sigma[7]]);
|
|
4367
|
-
blake2sQuarterRound(work, 0, 5, 10, 15, block[sigma[8]], block[sigma[9]]);
|
|
4368
|
-
blake2sQuarterRound(work, 1, 6, 11, 12, block[sigma[10]], block[sigma[11]]);
|
|
4369
|
-
blake2sQuarterRound(work, 2, 7, 8, 13, block[sigma[12]], block[sigma[13]]);
|
|
4370
|
-
blake2sQuarterRound(work, 3, 4, 9, 14, block[sigma[14]], block[sigma[15]]);
|
|
4371
|
-
}
|
|
4372
|
-
for (let index = 0; index < 8; index++) hash[index] = (hash[index] ^ work[index] ^ work[index + 8]) >>> 0;
|
|
4373
|
-
return hash;
|
|
4374
|
-
};
|
|
4375
|
-
const deriveB2hasWords = (value) => {
|
|
4376
|
-
const utf8 = toUtf8Int8Array(value);
|
|
4377
|
-
const padding = utf8.length % 4 === 0 ? 0 : 4 - utf8.length % 4;
|
|
4378
|
-
const padded = new Int8Array(utf8.length + padding);
|
|
4379
|
-
for (let index = 0; index < utf8.length; index++) padded[index] = utf8[index];
|
|
4380
|
-
const words = new Array(padded.length / 4);
|
|
4381
|
-
for (let index = 0; index < padded.length; index += 4) words[index / 4] = new Int32Array(padded.slice(index, index + 4).buffer)[0];
|
|
4382
|
-
return words;
|
|
4383
|
-
};
|
|
4384
|
-
const deriveB2hasHash = (words) => {
|
|
4385
|
-
const hash = KUAISHOU_BLAKE2S_IV.slice();
|
|
4386
|
-
hash[0] ^= 16842784;
|
|
4387
|
-
let offset = 0;
|
|
4388
|
-
let length = words.length;
|
|
4389
|
-
let counter = 0;
|
|
4390
|
-
while (length > 64) {
|
|
4391
|
-
length -= 64;
|
|
4392
|
-
counter += 64;
|
|
4393
|
-
blake2sCompress(hash, words, offset, counter, 64, false);
|
|
4394
|
-
offset += 64;
|
|
4395
|
-
}
|
|
4396
|
-
return blake2sCompress(hash, words, offset, counter + length, length, true);
|
|
4397
|
-
};
|
|
4398
|
-
/**
|
|
4399
|
-
* 计算快手页面中的 `b2has` 字符串。
|
|
4400
|
-
*
|
|
4401
|
-
* 这是后续 `b2sa` / `cts` 链路的起点。
|
|
4402
|
-
*
|
|
4403
|
-
* @param value - 原始输入字符串
|
|
4404
|
-
* @returns `b2has` 结果字符串
|
|
4405
|
-
*/
|
|
4406
|
-
const deriveKuaishouB2has = (value) => {
|
|
4407
|
-
return deriveB2hasHash(deriveB2hasWords(value)).map(toHex32).join("");
|
|
4408
|
-
};
|
|
4409
|
-
/**
|
|
4410
|
-
* 将输入字符串转换为快手 `b2sa` 所需的字节数组。
|
|
4411
|
-
*
|
|
4412
|
-
* @param value - 原始输入字符串
|
|
4413
|
-
* @returns `b2has` 结果转 UTF-8 后的 Int8Array
|
|
4414
|
-
*/
|
|
4415
|
-
const deriveKuaishouB2sa = (value) => {
|
|
4416
|
-
return toUtf8Int8Array(deriveKuaishouB2has(value));
|
|
4417
|
-
};
|
|
4418
|
-
const createKuaishouCtsState = () => {
|
|
4419
|
-
return {
|
|
4420
|
-
s: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(12, 16).buffer)[0],
|
|
4421
|
-
u: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(8, 12).buffer)[0],
|
|
4422
|
-
c: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(4, 8).buffer)[0],
|
|
4423
|
-
l: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(0, 4).buffer)[0],
|
|
4424
|
-
p: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(16, 20).buffer)[0],
|
|
4425
|
-
f: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(20, 24).buffer)[0],
|
|
4426
|
-
d: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(24, 28).buffer)[0],
|
|
4427
|
-
y: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(28, 32).buffer)[0],
|
|
4428
|
-
h: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(44, 48).buffer)[0],
|
|
4429
|
-
E: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(40, 44).buffer)[0],
|
|
4430
|
-
m: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(36, 40).buffer)[0],
|
|
4431
|
-
b: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(32, 36).buffer)[0]
|
|
4432
|
-
};
|
|
4433
|
-
};
|
|
4434
|
-
const seedKuaishouCtsState = (state, seed) => {
|
|
4435
|
-
const chars = new Int8Array(seed.split("").map((char) => char.codePointAt(0) ?? 0)).slice(0, seed.length);
|
|
4436
|
-
for (let index = 0; index < 4; index++) {
|
|
4437
|
-
state.s = (state.s <<= 8) | chars[index + 4];
|
|
4438
|
-
state.u = (state.u <<= 8) | chars[index + 4];
|
|
4439
|
-
state.c = (state.c <<= 8) | chars[index + 4];
|
|
4440
|
-
}
|
|
4441
|
-
if (state.s === 0) state.s = 324508639;
|
|
4442
|
-
if (state.u === 0) state.u = 610839776;
|
|
4443
|
-
if (state.c === 0) state.c = 4256789809;
|
|
4444
|
-
};
|
|
4445
|
-
const deriveKuaishouCtsByte = (state, value) => {
|
|
4446
|
-
let result = 0;
|
|
4447
|
-
let rightBit = 1 & state.u;
|
|
4448
|
-
let leftBit = 1 & state.c;
|
|
4449
|
-
for (let index = 0; index < 8; index++) {
|
|
4450
|
-
if (1 & state.s) {
|
|
4451
|
-
state.s = state.s ^ state.l >> 1 & 4294967295 | state.E;
|
|
4452
|
-
rightBit = 1 & state.u ? (state.u = state.u ^ state.p >> 1 & 4294967295 | state.m, 1) : (state.u = state.u >> 1 & 4294967295 & state.y, 0);
|
|
4453
|
-
} else {
|
|
4454
|
-
state.s = state.s >> 1 & 4294967295 & state.d;
|
|
4455
|
-
leftBit = 1 & state.c ? (state.c = state.c ^ state.f >> 1 & 4294967295 | state.b, 1) : (state.c = state.c >> 1 & 4294967295 & state.h, 0);
|
|
4456
|
-
}
|
|
4457
|
-
const mixed = result << 1 & 4294967295 | rightBit ^ leftBit;
|
|
4458
|
-
result = mixed > 127 ? mixed - 256 : mixed < -128 ? mixed + 256 : mixed;
|
|
4459
|
-
}
|
|
4460
|
-
return value ^ result + 3;
|
|
4461
|
-
};
|
|
4462
|
-
/**
|
|
4463
|
-
* 执行快手页面中的 `cts` 字节流变换。
|
|
4464
|
-
*
|
|
4465
|
-
* 该变换会作用在 `b2sa` 的输出上,随后被 `$HE_` 使用。
|
|
4466
|
-
*
|
|
4467
|
-
* @param input - 输入字节数组
|
|
4468
|
-
* @returns 变换后的字节数组
|
|
4469
|
-
*/
|
|
4470
|
-
const deriveKuaishouCts = (input) => {
|
|
4471
|
-
const state = createKuaishouCtsState();
|
|
4472
|
-
seedKuaishouCtsState(state, "Vuz4fCHxn1CO");
|
|
4473
|
-
const result = new Int8Array(input.length);
|
|
4474
|
-
for (let index = 0; index < input.length; index++) result[index] = deriveKuaishouCtsByte(state, input[index]);
|
|
4475
|
-
return result;
|
|
4476
|
-
};
|
|
4477
|
-
/**
|
|
4478
|
-
* 将字节数组编码为小写 hex 字符串。
|
|
4479
|
-
*
|
|
4480
|
-
* @param bytes - 任意可按字节访问的数组
|
|
4481
|
-
* @returns 小写 hex 字符串
|
|
4482
|
-
*/
|
|
4483
|
-
const bytesToLowerHex = (bytes) => {
|
|
4484
|
-
let result = "";
|
|
4485
|
-
for (const byte of Array.from(bytes)) {
|
|
4486
|
-
const value = byte & 255;
|
|
4487
|
-
result += value === 0 ? "00" : `${value < 16 ? "0" : ""}${value.toString(16)}`;
|
|
4488
|
-
}
|
|
4489
|
-
return result;
|
|
4490
|
-
};
|
|
4491
|
-
/**
|
|
4492
|
-
* 将 hex 字符串还原为带符号字节数组。
|
|
4493
|
-
*
|
|
4494
|
-
* @param value - 偶数字节长度的 hex 字符串
|
|
4495
|
-
* @returns 带符号 byte 数组
|
|
4496
|
-
*/
|
|
4497
|
-
const hexToSignedBytes = (value) => {
|
|
4498
|
-
const result = [];
|
|
4499
|
-
for (let index = 0; index < value.length; index += 2) {
|
|
4500
|
-
const parsed = parseInt(value.slice(index, index + 2), 16);
|
|
4501
|
-
const range = Math.pow(2, value.slice(index, index + 2).length / 2 * 8);
|
|
4502
|
-
result.push(parsed > range / 2 - 1 ? parsed - range : parsed);
|
|
4503
|
-
}
|
|
4504
|
-
return result;
|
|
4505
|
-
};
|
|
4506
|
-
/**
|
|
4507
|
-
* 对两个字节数组执行循环异或。
|
|
4508
|
-
*
|
|
4509
|
-
* @param left - 被处理的字节数组
|
|
4510
|
-
* @param right - 异或掩码字节数组
|
|
4511
|
-
* @returns 异或后的 Int8Array
|
|
4512
|
-
*/
|
|
4513
|
-
const xorByteArrays = (left, right) => {
|
|
4514
|
-
let index = 0;
|
|
4515
|
-
const result = new Int8Array(left.length);
|
|
4516
|
-
while (index < left.length) for (let rightIndex = 0; rightIndex < right.length && index < left.length; rightIndex++) {
|
|
4517
|
-
result[index] = left[index] ^ 255 & right[rightIndex];
|
|
4518
|
-
index += 1;
|
|
4519
|
-
}
|
|
4520
|
-
return result;
|
|
4521
|
-
};
|
|
4522
|
-
/**
|
|
4523
|
-
* 将数值编码为固定长度的小端 hex。
|
|
4524
|
-
*
|
|
4525
|
-
* @param value - 原始数值
|
|
4526
|
-
* @param size - 输出字节长度
|
|
4527
|
-
* @returns 小端序 hex 字符串
|
|
4528
|
-
*/
|
|
4529
|
-
const toLittleEndianHex = (value, size) => {
|
|
4530
|
-
const normalized = BigInt.asUintN(size * 8, typeof value === "bigint" ? value : BigInt(Math.trunc(value)));
|
|
4531
|
-
let result = "";
|
|
4532
|
-
for (let index = 0; index < size; index++) {
|
|
4533
|
-
const byte = Number(normalized >> BigInt(8 * index) & 255n);
|
|
4534
|
-
result += byte.toString(16).padStart(2, "0");
|
|
4535
|
-
}
|
|
4536
|
-
return result;
|
|
4537
|
-
};
|
|
4538
|
-
/**
|
|
4539
|
-
* 计算快手 `$HE_` 所使用的 8-bit LRC 校验值。
|
|
4540
|
-
*
|
|
4541
|
-
* @param sourceHex - 原始 hex 字符串
|
|
4542
|
-
* @returns 单字节 LRC 的 hex 表示
|
|
4543
|
-
*/
|
|
4544
|
-
const computeKuaishouLrcHex = (sourceHex) => {
|
|
4545
|
-
return (-hexToSignedBytes(sourceHex).reduce((total, value) => total + (value & 255), 0) & 255).toString(16).padStart(2, "0");
|
|
4546
|
-
};
|
|
4547
|
-
/**
|
|
4548
|
-
* 对 `$HE_` 的 preHex + checksum 执行最终变换。
|
|
4549
|
-
*
|
|
4550
|
-
* 页面实现会以最后一个字节为 key,对前面的字节逐个异或,
|
|
4551
|
-
* 最后再整体转回小写 hex。
|
|
4552
|
-
*
|
|
4553
|
-
* @param prefixHex - 不含最终 LRC 的前缀 hex
|
|
4554
|
-
* @param checksumHex - 最终 LRC hex
|
|
4555
|
-
* @returns `$HE_` 最终输出的 hex 字符串
|
|
4556
|
-
*/
|
|
4557
|
-
const transformKuaishouHeHex = (prefixHex, checksumHex) => {
|
|
4558
|
-
const input = hexToSignedBytes(`${prefixHex}${checksumHex}`);
|
|
4559
|
-
const output = new Int8Array(input.length);
|
|
4560
|
-
const xorKey = input[input.length - 1];
|
|
4561
|
-
for (let index = 0; index < input.length - 1; index++) output[index] = input[index] ^ xorKey;
|
|
4562
|
-
output[input.length - 1] = xorKey;
|
|
4563
|
-
return bytesToLowerHex(output);
|
|
4564
|
-
};
|
|
4565
|
-
//#endregion
|
|
4566
|
-
//#region src/platforms/kuaishou/sign/he.ts
|
|
4567
|
-
const KUAISHOU_HE_HEADER_HEX = "4B54";
|
|
4568
|
-
const KUAISHOU_HE_VERSION_HEX = "cda9";
|
|
4569
|
-
const KUAISHOU_HE_STARTUP_MARKER_HEX = "ab";
|
|
4570
|
-
const KUAISHOU_HE_FIXED_BODY_HEX = "0100000001";
|
|
4571
|
-
const KUAISHOU_HE_INPUT_XOR_MASK = [
|
|
4572
|
-
45,
|
|
4573
|
-
211,
|
|
4574
|
-
69,
|
|
4575
|
-
192
|
|
4576
|
-
];
|
|
4577
|
-
const KUAISHOU_HE_COUNTER_XOR_MASK = 3131873467;
|
|
4578
|
-
const KUAISHOU_HE_TIME_XOR_MASK = 3360347992n;
|
|
4579
|
-
const KUAISHOU_HE_TAIL_HEX = "9b563eda7b563e";
|
|
4580
|
-
const KUAISHOU_HE_RANDOM_MAX = 0xffffffffffff;
|
|
4581
|
-
/**
|
|
4582
|
-
* 计算 `$HE_` 中的 hash field。
|
|
4583
|
-
*
|
|
4584
|
-
* 它由 `signInput + HUDRBody` 经 `b2sa -> cts -> 截断 -> 异或掩码`
|
|
4585
|
-
* 这条链路导出,是 `$HE_` 中与接口输入最直接相关的一段。
|
|
4586
|
-
*
|
|
4587
|
-
* @param signInput - 快手 `__NS_hxfalcon` 的 sign input
|
|
4588
|
-
* @param hudrBody - `HUDR_` 去前缀后的主体
|
|
4589
|
-
* @returns `$HE_` 载荷中的 4 字节 hash field hex
|
|
4590
|
-
*/
|
|
4591
|
-
const deriveKuaishouHeHashFieldHex = (signInput, hudrBody) => {
|
|
4592
|
-
const hashInput = `${signInput}HUDR_${hudrBody}`;
|
|
4593
|
-
const digestHex = bytesToLowerHex(deriveKuaishouCts(deriveKuaishouB2sa(hashInput))).slice(0, 8);
|
|
4594
|
-
return bytesToLowerHex(xorByteArrays(hexToSignedBytes(digestHex), KUAISHOU_HE_INPUT_XOR_MASK));
|
|
4595
|
-
};
|
|
4596
|
-
/**
|
|
4597
|
-
* 推导快手签名中的 `$HE_` 段。
|
|
4598
|
-
*
|
|
4599
|
-
* @param context - 生成 `$HE_` 所需的上下文参数
|
|
4600
|
-
* @returns `$HE_` 的最终 hex、中间 hash field 和 preHex
|
|
4601
|
-
*/
|
|
4602
|
-
const deriveKuaishouHeHex = (context) => {
|
|
4603
|
-
const random48 = Math.floor(context.randomValue * KUAISHOU_HE_RANDOM_MAX);
|
|
4604
|
-
const hashFieldHex = deriveKuaishouHeHashFieldHex(context.signInput, context.hudrBody);
|
|
4605
|
-
const timeXor = BigInt(context.timestamp) ^ KUAISHOU_HE_TIME_XOR_MASK;
|
|
4606
|
-
const preHex = [
|
|
4607
|
-
KUAISHOU_HE_HEADER_HEX,
|
|
4608
|
-
KUAISHOU_HE_VERSION_HEX,
|
|
4609
|
-
KUAISHOU_HE_STARTUP_MARKER_HEX,
|
|
4610
|
-
toLittleEndianHex(context.startupRandom, 6),
|
|
4611
|
-
toLittleEndianHex(random48, 6),
|
|
4612
|
-
KUAISHOU_HE_FIXED_BODY_HEX,
|
|
4613
|
-
toLittleEndianHex(context.count ^ KUAISHOU_HE_COUNTER_XOR_MASK, 4),
|
|
4614
|
-
hashFieldHex,
|
|
4615
|
-
toLittleEndianHex(timeXor, 6),
|
|
4616
|
-
KUAISHOU_HE_TAIL_HEX,
|
|
4617
|
-
computeKuaishouLrcHex(KUAISHOU_HE_TAIL_HEX)
|
|
4618
|
-
].join("");
|
|
4619
|
-
return {
|
|
4620
|
-
finalHex: transformKuaishouHeHex(preHex, computeKuaishouLrcHex(preHex)),
|
|
4621
|
-
hashFieldHex,
|
|
4622
|
-
preHex
|
|
4623
|
-
};
|
|
4624
|
-
};
|
|
4625
|
-
/**
|
|
4626
|
-
* 一次性推导快手完整纯算法签名。
|
|
4627
|
-
*
|
|
4628
|
-
* 该方法会先生成 `HUDR_`,再拼出 `$HE_`,最终返回完整
|
|
4629
|
-
* `HUDR_...$HE_...` 形式的 `__NS_hxfalcon`。
|
|
4630
|
-
*
|
|
4631
|
-
* @param context - 快手纯算法签名上下文
|
|
4632
|
-
* @returns 完整签名结果及关键中间态
|
|
4633
|
-
*/
|
|
4634
|
-
const deriveKuaishouPureSignature = (context) => {
|
|
4635
|
-
const hudr = deriveKuaishouHudrBody(context);
|
|
4636
|
-
const he = deriveKuaishouHeHex({
|
|
4637
|
-
count: context.count,
|
|
4638
|
-
hudrBody: hudr.body,
|
|
4639
|
-
randomValue: context.randomValue,
|
|
4640
|
-
signInput: context.signInput,
|
|
4641
|
-
startupRandom: context.startupRandom,
|
|
4642
|
-
timestamp: context.timestamp
|
|
4643
|
-
});
|
|
4644
|
-
return {
|
|
4645
|
-
...hudr,
|
|
4646
|
-
he,
|
|
4647
|
-
signResult: `${hudr.full}$HE_${he.finalHex}`
|
|
4648
|
-
};
|
|
4649
|
-
};
|
|
4650
|
-
//#endregion
|
|
4651
|
-
//#region src/platforms/kuaishou/sign/helpers.ts
|
|
4652
|
-
const SIGN_INPUT_SKIP_KEYWORD$1 = "__NS";
|
|
4653
|
-
const KUAISHOU_ANONYMOUS_KWW_KEY$1 = "K8wm5PvY9nX7qJc2";
|
|
4654
|
-
const KUAISHOU_ANONYMOUS_KWW_SUFFIX$1 = "###ssrd";
|
|
4655
|
-
const KUAISHOU_ANONYMOUS_KWW_ALPHABET$1 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
4656
|
-
/** 创建一份独立的匿名 kww 缓存 */
|
|
4657
|
-
const createKuaishouAnonymousKwwCache = () => {
|
|
4658
|
-
const cache = {
|
|
4659
|
-
value: "",
|
|
4660
|
-
get: () => {
|
|
4661
|
-
if (!cache.value) cache.value = `${encryptKuaishouAnonymousKwwSeed$1(generateKuaishouAnonymousKwwSeed$1())}${KUAISHOU_ANONYMOUS_KWW_SUFFIX$1}`;
|
|
4662
|
-
return cache.value;
|
|
4663
|
-
}
|
|
4664
|
-
};
|
|
4665
|
-
return cache;
|
|
4666
|
-
};
|
|
4667
|
-
/** 模块级单例缓存;生产代码用 createKuaishouAnonymousKwwCache */
|
|
4668
|
-
let kuaishouAnonymousKwwCache$1 = "";
|
|
4669
|
-
const compareLexicographically$1 = (left, right) => {
|
|
4670
|
-
if (left === right) return 0;
|
|
4671
|
-
return left < right ? -1 : 1;
|
|
4672
|
-
};
|
|
4673
|
-
const toComparableEntry$1 = (key, value) => {
|
|
4674
|
-
if (typeof value === "object" && value !== null && !Array.isArray(value)) return `${key}=[object Object]`;
|
|
4675
|
-
return `${key}=${String(value)}`;
|
|
4676
|
-
};
|
|
4677
|
-
const normalizePathname$1 = (urlOrPath) => {
|
|
4678
|
-
try {
|
|
4679
|
-
return new URL(urlOrPath).pathname;
|
|
4680
|
-
} catch {
|
|
4681
|
-
return urlOrPath.split("?")[0] ?? urlOrPath;
|
|
4682
|
-
}
|
|
4683
|
-
};
|
|
4684
|
-
const sortSearchParams$1 = (searchParams) => {
|
|
4685
|
-
return [...searchParams.entries()].sort(([leftKey], [rightKey]) => compareLexicographically$1(leftKey, rightKey)).reduce((result, [key, value]) => {
|
|
4686
|
-
result[key] = value;
|
|
4687
|
-
return result;
|
|
4688
|
-
}, {});
|
|
4689
|
-
};
|
|
4690
|
-
/**
|
|
4691
|
-
* 解析快手签名所使用的规范路径。
|
|
4692
|
-
*
|
|
4693
|
-
* 快手部分 `live_api` 接口的公开 URL 与算法内部参与签名的路径并不一致,
|
|
4694
|
-
* 因此这里优先使用调用方显式传入的 `signPath`,否则回退到 URL 自身的 pathname。
|
|
4695
|
-
*
|
|
4696
|
-
* @param urlOrPath - 原始 URL 或路径
|
|
4697
|
-
* @param signPath - 调用方声明的规范签名路径
|
|
4698
|
-
* @returns 参与签名的规范 pathname
|
|
4699
|
-
*/
|
|
4700
|
-
const resolveKuaishouHxfalconSignPath$1 = (urlOrPath, signPath) => {
|
|
4701
|
-
const pathname = normalizePathname$1(signPath ?? urlOrPath);
|
|
4702
|
-
if (!pathname.startsWith("/")) throw new Error(`Invalid Kuaishou signing path: ${pathname}`);
|
|
4703
|
-
return pathname;
|
|
4704
|
-
};
|
|
4705
|
-
/**
|
|
4706
|
-
* 从请求 URL 构造快手签名载荷。
|
|
4707
|
-
*
|
|
4708
|
-
* `requestBody` **必须**由调用方透下来:`buildKuaishouHxfalconSignInput` 会把
|
|
4709
|
-
* `JSON.stringify(requestBody)` 拼在 sign input 尾部。漏传时校验松的接口
|
|
4710
|
-
* (`simple/info` / `comment/list`)仍会放行,而 `photo/info` 一类严格校验的接口
|
|
4711
|
-
* 会一律 `result=50 签名验证失败`。
|
|
4712
|
-
* 参见 @OduckO 的 kuaishou-parser(GPL-3.0-only)`sign/helpers.ts:78-81`
|
|
4713
|
-
* 与其 `TODO.md:193-195`。
|
|
4714
|
-
*
|
|
4715
|
-
* @param url - 实际请求 URL
|
|
4716
|
-
* @param signPath - 可选的规范签名路径
|
|
4717
|
-
* @param requestBody - 参与签名的请求体(POST 端点必传,GET 端点保持缺省)
|
|
4718
|
-
* @returns 供纯算法签名链路使用的结构化载荷
|
|
4719
|
-
*/
|
|
4720
|
-
const buildKuaishouHxfalconPayload$1 = (url, signPath, requestBody = {}) => {
|
|
4721
|
-
const parsedUrl = new URL(url);
|
|
4722
|
-
const realPath = resolveKuaishouHxfalconSignPath$1(parsedUrl.pathname, signPath);
|
|
4723
|
-
const query = sortSearchParams$1(parsedUrl.searchParams);
|
|
4724
|
-
if (!query.caver) throw new Error(`Missing caver query parameter for Kuaishou signing: ${url}`);
|
|
4725
|
-
return {
|
|
4726
|
-
url: realPath,
|
|
4727
|
-
query,
|
|
4728
|
-
form: {},
|
|
4729
|
-
requestBody
|
|
4730
|
-
};
|
|
4731
|
-
};
|
|
4732
|
-
/**
|
|
4733
|
-
* 构造快手 `__NS_hxfalcon` 的 sign input。
|
|
4734
|
-
*
|
|
4735
|
-
* 该字符串就是后续 `HUDR_` / `$HE_` 两段算法共同依赖的输入材料。
|
|
4736
|
-
*
|
|
4737
|
-
* @param payload - 已标准化的签名载荷
|
|
4738
|
-
* @returns 按快手页面规则拼接后的 sign input
|
|
4739
|
-
*/
|
|
4740
|
-
const buildKuaishouHxfalconSignInput$1 = (payload) => {
|
|
4741
|
-
const combinedParams = {
|
|
4742
|
-
...payload.query,
|
|
4743
|
-
...payload.form
|
|
4744
|
-
};
|
|
4745
|
-
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("");
|
|
4746
|
-
const requestBody = Object.keys(payload.requestBody).length > 0 ? JSON.stringify(payload.requestBody) : "";
|
|
4747
|
-
return `${normalizePathname$1(payload.url)}${serializedParams}${requestBody}`;
|
|
4748
|
-
};
|
|
4749
|
-
/**
|
|
4750
|
-
* 从 Cookie 字符串中提取指定键值。
|
|
4751
|
-
*
|
|
4752
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4753
|
-
* @param key - 目标键名
|
|
4754
|
-
* @returns 命中的 Cookie 值;未命中时返回空字符串
|
|
4755
|
-
*/
|
|
4756
|
-
const extractCookieValue$1 = (cookie, key) => {
|
|
4757
|
-
if (!cookie?.trim()) return "";
|
|
4758
|
-
const pattern = new RegExp(`(?:^|;\\s*)${key}=([^;]*)`);
|
|
4759
|
-
return cookie.match(pattern)?.[1] ?? "";
|
|
4760
|
-
};
|
|
4761
|
-
const generateKuaishouAnonymousKwwSeed$1 = () => {
|
|
4762
|
-
let randomPart = "";
|
|
4763
|
-
for (let index = 0; index < 8; index++) {
|
|
4764
|
-
const randomIndex = Math.floor(Math.random() * 62);
|
|
4765
|
-
randomPart += KUAISHOU_ANONYMOUS_KWW_ALPHABET$1[randomIndex];
|
|
4766
|
-
}
|
|
4767
|
-
return `${Date.now()}|${randomPart}`;
|
|
4768
|
-
};
|
|
4769
|
-
const encryptKuaishouAnonymousKwwSeed$1 = (seed) => {
|
|
4770
|
-
const key = Buffer.from(KUAISHOU_ANONYMOUS_KWW_KEY$1, "utf8");
|
|
4771
|
-
const cipher = node_crypto.default.createCipheriv("aes-128-cbc", key, key);
|
|
4772
|
-
return Buffer.concat([cipher.update(seed, "utf8"), cipher.final()]).toString("base64");
|
|
4773
|
-
};
|
|
4774
|
-
/**
|
|
4775
|
-
* 生成匿名访问快手 `live_api` 时所需的 `kww`。
|
|
4776
|
-
*
|
|
4777
|
-
* 页面未持有 `kwfv1` 时,会退回到一段本地 AES 生成值。
|
|
4778
|
-
* 传入 `cache` 时按实例缓存(同一实例内复用同一份访客标识);
|
|
4779
|
-
* 不传时用模块级单例缓存。
|
|
4780
|
-
*
|
|
4781
|
-
* @param cache - 可选的实例级匿名 kww 缓存
|
|
4782
|
-
* @returns 可用于匿名请求的 `kww`
|
|
4783
|
-
*/
|
|
4784
|
-
const deriveKuaishouAnonymousKww$1 = (cache) => {
|
|
4785
|
-
if (cache) return cache.get();
|
|
4786
|
-
if (!kuaishouAnonymousKwwCache$1) kuaishouAnonymousKwwCache$1 = `${encryptKuaishouAnonymousKwwSeed$1(generateKuaishouAnonymousKwwSeed$1())}${KUAISHOU_ANONYMOUS_KWW_SUFFIX$1}`;
|
|
4787
|
-
return kuaishouAnonymousKwwCache$1;
|
|
4788
|
-
};
|
|
4789
|
-
/**
|
|
4790
|
-
* 解析快手 `kww` 请求头。
|
|
4791
|
-
*
|
|
4792
|
-
* 当前页面存在两条路径:
|
|
4793
|
-
* 1. 若已有 `kwfv1`,则 `kww` 直接复用该值
|
|
4794
|
-
* 2. 若还没有访客 Cookie,则退回到页面本地生成的匿名 `kww`
|
|
4795
|
-
*
|
|
4796
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4797
|
-
* @param cache - 可选的实例级匿名 kww 缓存
|
|
4798
|
-
* @returns 可用于请求头的 `kww` 值
|
|
4799
|
-
*/
|
|
4800
|
-
const deriveKuaishouKww$1 = (cookie, cache) => {
|
|
4801
|
-
const kwfv1 = extractCookieValue$1(cookie, "kwfv1");
|
|
4802
|
-
if (kwfv1) return kwfv1;
|
|
4803
|
-
return deriveKuaishouAnonymousKww$1(cache);
|
|
4804
|
-
};
|
|
4805
|
-
//#endregion
|
|
4806
|
-
//#region src/platforms/kuaishou/sign/state.ts
|
|
4807
|
-
const KUAISHOU_DEFAULT_CAT_VERSION$1 = "2";
|
|
4808
|
-
const KUAISHOU_DEFAULT_COUNT$1 = 100;
|
|
4809
|
-
const KUAISHOU_SECS_STACK_LIMIT$1 = 100;
|
|
4810
|
-
const captureKuaishouEncodeStack$1 = () => {
|
|
4811
|
-
try {
|
|
4812
|
-
throw new Error();
|
|
4813
|
-
} catch (error) {
|
|
4814
|
-
return error instanceof Error ? error.stack ?? "" : "";
|
|
4815
|
-
}
|
|
4816
|
-
};
|
|
4817
|
-
/**
|
|
4818
|
-
* 计算快手 `SECS.s` 所需的调用栈尾部。
|
|
4819
|
-
*
|
|
4820
|
-
* 真实页面会取 `Error().stack` 的最后 100 个字符,
|
|
4821
|
-
* 这里保持同样的裁剪规则。
|
|
4822
|
-
*
|
|
4823
|
-
* @param stack - 可选的完整调用栈;不传时会即时捕获
|
|
4824
|
-
* @returns 用于 `SECS.s` 的栈尾字符串
|
|
4825
|
-
*/
|
|
4826
|
-
const deriveKuaishouSecsStackTail$1 = (stack = captureKuaishouEncodeStack$1()) => {
|
|
4827
|
-
return stack.length > KUAISHOU_SECS_STACK_LIMIT$1 ? stack.slice(-100) : stack;
|
|
4828
|
-
};
|
|
4829
|
-
/**
|
|
4830
|
-
* 构造快手 `window.SECS` 的纯算法等价状态。
|
|
4831
|
-
*
|
|
4832
|
-
* @param count - 当前签名计数器
|
|
4833
|
-
* @param stack - 可选的完整调用栈
|
|
4834
|
-
* @returns 包含 `s` 与 `c` 的 `SECS` 状态对象
|
|
4835
|
-
*/
|
|
4836
|
-
const deriveKuaishouSecsState$1 = (count, stack) => {
|
|
4837
|
-
return {
|
|
4838
|
-
s: deriveKuaishouSecsStackTail$1(stack),
|
|
4839
|
-
c: count
|
|
4840
|
-
};
|
|
4841
|
-
};
|
|
4842
|
-
/**
|
|
4843
|
-
* 创建一份独立的快手签名运行时状态。
|
|
4844
|
-
*
|
|
4845
|
-
* 每次调用创建独立副本,`count` / `startupRandom` 随签名器实例走。
|
|
4846
|
-
*
|
|
4847
|
-
* @returns 新的签名运行时状态(`count` 从默认值 100 起步)
|
|
4848
|
-
*/
|
|
4849
|
-
const createKuaishouPureRuntimeState = () => ({
|
|
4850
|
-
catVersion: KUAISHOU_DEFAULT_CAT_VERSION$1,
|
|
4851
|
-
count: KUAISHOU_DEFAULT_COUNT$1,
|
|
4852
|
-
startupRandom: Date.now()
|
|
4853
|
-
});
|
|
4854
|
-
//#endregion
|
|
4855
|
-
//#region src/platforms/kuaishou/sign/index.ts
|
|
4856
|
-
/**
|
|
4857
|
-
* 快手签名器(**实例级**)。
|
|
4858
|
-
*
|
|
4859
|
-
* 签名状态(`count` / `startupRandom` / 匿名 `kww` 缓存)随实例走,不挂在模块级
|
|
4860
|
-
* 单例上 —— 同进程内两个 client 的签名互不干扰:
|
|
4861
|
-
* - `count` 每次签名递增,但只影响当前实例。
|
|
4862
|
-
* - 匿名 `kww` 按实例缓存(同一实例内复用,不同实例各自生成)。
|
|
4863
|
-
* - `startupRandom` 按实例生成。
|
|
4864
|
-
*/
|
|
4865
|
-
var KuaishouSigner = class {
|
|
4866
|
-
runtimeState;
|
|
4867
|
-
anonymousKwwCache;
|
|
4868
|
-
constructor() {
|
|
4869
|
-
this.runtimeState = createKuaishouPureRuntimeState();
|
|
4870
|
-
this.anonymousKwwCache = createKuaishouAnonymousKwwCache();
|
|
4871
|
-
}
|
|
4872
|
-
/**
|
|
4873
|
-
* 获取当前纯算法运行时使用的 `caver`。
|
|
4874
|
-
* @returns 快手 `caver` 值
|
|
4875
|
-
*/
|
|
4876
|
-
getCatVersion() {
|
|
4877
|
-
return this.runtimeState.catVersion;
|
|
4878
|
-
}
|
|
4879
|
-
/**
|
|
4880
|
-
* 生成快手请求头中的 `kww` 值(匿名分支按实例缓存)。
|
|
4881
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4882
|
-
* @returns `kww` 请求头值
|
|
4883
|
-
*/
|
|
4884
|
-
generateKww(cookie) {
|
|
4885
|
-
return deriveKuaishouKww$1(cookie, this.anonymousKwwCache);
|
|
4886
|
-
}
|
|
4887
|
-
/**
|
|
4888
|
-
* 根据结构化签名载荷生成 `__NS_hxfalcon`。
|
|
4889
|
-
*
|
|
4890
|
-
* `count` 随实例递增:同一实例内连续签名结果不同(防重放),
|
|
4891
|
-
* 但两个实例的 `count` 互不干扰。
|
|
4892
|
-
*
|
|
4893
|
-
* @param payload - 已标准化的快手签名载荷
|
|
4894
|
-
* @returns 包含最终签名串、sign input 与 `caver` 的结果
|
|
4895
|
-
*/
|
|
4896
|
-
generateHxfalconFromPayload(payload) {
|
|
4897
|
-
const signInput = buildKuaishouHxfalconSignInput$1(payload);
|
|
4898
|
-
const count = this.runtimeState.count;
|
|
4899
|
-
const secs = deriveKuaishouSecsState$1(count);
|
|
4900
|
-
const scriptCount = globalThis.document?.scripts?.length ?? 0;
|
|
4901
|
-
const catVersion = this.runtimeState.catVersion;
|
|
4902
|
-
const signResult = deriveKuaishouPureSignature({
|
|
4903
|
-
count,
|
|
4904
|
-
randomValue: Math.random(),
|
|
4905
|
-
scriptCount,
|
|
4906
|
-
secs,
|
|
4907
|
-
signInput,
|
|
4908
|
-
startupRandom: this.runtimeState.startupRandom,
|
|
4909
|
-
timestamp: Date.now()
|
|
4910
|
-
}).signResult;
|
|
4911
|
-
this.runtimeState.count += 1;
|
|
4912
|
-
return {
|
|
4913
|
-
signResult,
|
|
4914
|
-
signInput,
|
|
4915
|
-
catVersion
|
|
4916
|
-
};
|
|
4917
|
-
}
|
|
4918
|
-
/**
|
|
4919
|
-
* 为快手 `live_api` / H5 URL 签名。
|
|
4920
|
-
* @param url - 实际请求 URL
|
|
4921
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4922
|
-
* @param signPath - 可选的规范签名路径
|
|
4923
|
-
* @param requestBody - 参与签名的请求体(POST 端点必传,见 helpers 的说明)
|
|
4924
|
-
* @returns 带签名 URL、附加请求头和调试信息
|
|
4925
|
-
*/
|
|
4926
|
-
signLiveApiUrl(url, cookie, signPath, requestBody = {}) {
|
|
4927
|
-
const payload = buildKuaishouHxfalconPayload$1(url, signPath, requestBody);
|
|
4928
|
-
const { signResult, signInput, catVersion } = this.generateHxfalconFromPayload(payload);
|
|
4929
|
-
const signedUrl = new URL(url);
|
|
4930
|
-
const headers = {};
|
|
4931
|
-
const kww = deriveKuaishouKww$1(cookie, this.anonymousKwwCache);
|
|
4932
|
-
signedUrl.searchParams.set("__NS_hxfalcon", signResult);
|
|
4933
|
-
signedUrl.searchParams.set("caver", catVersion);
|
|
4934
|
-
if (kww) headers.kww = kww;
|
|
4935
|
-
return {
|
|
4936
|
-
url: signedUrl.toString(),
|
|
4937
|
-
headers,
|
|
4938
|
-
signResult,
|
|
4939
|
-
signInput,
|
|
4940
|
-
catVersion
|
|
4941
|
-
};
|
|
4942
|
-
}
|
|
4943
|
-
/**
|
|
4944
|
-
* 为结构化的快手 `live_api` 请求描述对象签名。
|
|
4945
|
-
*
|
|
4946
|
-
* 入参与静态类 {@link kuaishouSign} 相同,但状态随实例。
|
|
4947
|
-
*
|
|
4948
|
-
* @param request - 快手 `live_api` 请求描述对象
|
|
4949
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4950
|
-
* @param requestBody - 参与签名的请求体
|
|
4951
|
-
* @returns 带签名 URL、附加请求头和调试信息
|
|
4952
|
-
*/
|
|
4953
|
-
signLiveApiRequest(request, cookie, requestBody = {}) {
|
|
4954
|
-
return this.signLiveApiUrl(request.url, cookie, request.signPath, requestBody);
|
|
4955
|
-
}
|
|
4956
|
-
};
|
|
4957
|
-
/**
|
|
4958
|
-
* 创建一个快手签名器实例。
|
|
4959
|
-
*
|
|
4960
|
-
* 每个 client 持有一个实例,签名状态(`count` / `startupRandom` /
|
|
4961
|
-
* 匿名 `kww`)随实例走。
|
|
4962
|
-
* @returns 新的签名器实例
|
|
4963
|
-
*/
|
|
4964
|
-
const createKuaishouSigner = () => new KuaishouSigner();
|
|
4965
|
-
//#endregion
|
|
4966
2868
|
//#region src/platforms/kuaishou/sign/signers.ts
|
|
4967
2869
|
/**
|
|
4968
|
-
* 快手签名器表。
|
|
4969
|
-
*
|
|
4970
|
-
* 一张表只有一个签名器:快手的 `live_api`(`/rest/k/*`)与 H5(`/rest/wd/*`)
|
|
4971
|
-
* 共用 `__NS_hxfalcon` 这一套算法,区别只在喂什么料。
|
|
4972
|
-
*/
|
|
4973
|
-
/** 签名时必须出现在 query 里的版本标记 —— 它参与 sign input,不能只在发送时补 */
|
|
4974
|
-
const CAVER_PARAM = "caver";
|
|
4975
|
-
/**
|
|
4976
|
-
* 取参与签名的请求体。
|
|
4977
|
-
*
|
|
4978
|
-
* 只认普通对象:签名把 `JSON.stringify(body)` 拼在 sign input 尾部,而 transport
|
|
4979
|
-
* 交给 axios 的也是同一个对象(同样 `JSON.stringify`),两边逐字一致才算签对。
|
|
4980
|
-
* 若 body 已被端点预先序列化成字符串或换成了别的形状,签名与实发就会错位 ——
|
|
4981
|
-
* 这正是「签名验证失败」最难查的形态,所以这里直接抛,不静默签一个错的。
|
|
4982
|
-
* @param spec - 请求描述
|
|
4983
|
-
* @returns 参与签名的请求体
|
|
4984
|
-
*/
|
|
4985
|
-
const signableBody = (spec) => {
|
|
4986
|
-
if (spec.body === void 0 || spec.body === null) return {};
|
|
4987
|
-
if (typeof spec.body !== "object" || Array.isArray(spec.body)) throw new Error(`快手签名只支持普通对象 body,收到 ${Array.isArray(spec.body) ? "array" : typeof spec.body}`);
|
|
4988
|
-
return spec.body;
|
|
4989
|
-
};
|
|
4990
|
-
/**
|
|
4991
|
-
* 造 `__NS_hxfalcon` 签名器。
|
|
4992
|
-
*
|
|
4993
|
-
* 签名的三份料全部从 spec 上取 —— 走正规 `sign` 声明这条路,`body` 天然可得,
|
|
4994
|
-
* 不需要在 request 层手动把 body 递给签名器:
|
|
4995
|
-
* - query:从 `spec.url` 解析,必须含 `caver`(缺就按签名器自己的 `catVersion`
|
|
4996
|
-
* 补上,补的值与最终发出去的一致)
|
|
4997
|
-
* - `spec.signPath`:规范签名路径(端点已在 spec 里填好)
|
|
4998
|
-
* - `spec.body`:POST 端点的请求体,`photo/info` 一类严格校验的接口非它不可
|
|
4999
|
-
* @param signer - 签名器实例(状态随实例:`count` / `startupRandom` / 匿名 kww)
|
|
5000
|
-
* @returns 签名函数
|
|
5001
|
-
*/
|
|
5002
|
-
const createHxfalconSigner = (signer) => (spec, ctx) => {
|
|
5003
|
-
const url = new URL(spec.url);
|
|
5004
|
-
if (!url.searchParams.get(CAVER_PARAM)) url.searchParams.set(CAVER_PARAM, signer.getCatVersion());
|
|
5005
|
-
const signed = signer.signLiveApiUrl(url.toString(), ctx.cookie, spec.signPath, signableBody(spec));
|
|
5006
|
-
const headers = new AmagiHeaders(spec.headers);
|
|
5007
|
-
for (const [key, value] of Object.entries(signed.headers)) headers.set(key, value);
|
|
5008
|
-
return {
|
|
5009
|
-
...spec,
|
|
5010
|
-
url: signed.url,
|
|
5011
|
-
headers: headers.toJSON()
|
|
5012
|
-
};
|
|
5013
|
-
};
|
|
5014
|
-
/**
|
|
5015
2870
|
* 创建快手签名器表。
|
|
5016
2871
|
*
|
|
5017
2872
|
* 表由 `PLATFORM_RUNTIME.kuaishou.signers` 持有,与另外三个平台同构 ——
|
|
5018
2873
|
* client fetcher / `createKuaishouRoutes` / 静态 fetcher 三个入口共用同一张表,
|
|
5019
2874
|
* 保证任何入口下的签名行为一致。
|
|
5020
2875
|
*
|
|
5021
|
-
* `signer`
|
|
5022
|
-
*
|
|
5023
|
-
* @param signer -
|
|
2876
|
+
* `signer` 可注入:缺省用 `steps.ts` 的模块级 {@link sharedKuaishouSigner},于是端点
|
|
2877
|
+
* 声明与 `client.kuaishou.request` 两条路共用一个 `count`;测试可注入独立实例做隔离。
|
|
2878
|
+
* @param signer - 签名器实例,缺省用共享实例
|
|
5024
2879
|
* @returns 签名器表
|
|
5025
2880
|
*/
|
|
5026
|
-
const createKuaishouSigners = (signer =
|
|
2881
|
+
const createKuaishouSigners = (signer = require_steps.sharedKuaishouSigner) => ({ hxfalcon: stepsToSigner([require_steps.hxfalcon(signer)]) });
|
|
5027
2882
|
//#endregion
|
|
5028
2883
|
//#region src/platforms/xiaohongshu/config.ts
|
|
5029
2884
|
/**
|
|
@@ -5043,7 +2898,7 @@ const createKuaishouSigners = (signer = createKuaishouSigner()) => ({ hxfalcon:
|
|
|
5043
2898
|
const XIAOHONGSHU_DEFAULT_TIMEOUT = 1e4;
|
|
5044
2899
|
/** 从请求配置里取 UA:大小写不敏感,取不到用集中维护的默认值 */
|
|
5045
2900
|
const userAgentOf = (requestConfig) => {
|
|
5046
|
-
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";
|
|
2901
|
+
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";
|
|
5047
2902
|
};
|
|
5048
2903
|
/**
|
|
5049
2904
|
* 构造小红书默认请求配置。
|
|
@@ -5055,7 +2910,7 @@ const createXiaohongshuConfig = (cookie, requestConfig) => {
|
|
|
5055
2910
|
const userAgent = userAgentOf(requestConfig);
|
|
5056
2911
|
const secChUa = generateSecChUa(userAgent);
|
|
5057
2912
|
return {
|
|
5058
|
-
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),
|
|
2913
|
+
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),
|
|
5059
2914
|
requestConfig: {
|
|
5060
2915
|
timeout: XIAOHONGSHU_DEFAULT_TIMEOUT,
|
|
5061
2916
|
...requestConfig
|
|
@@ -5089,392 +2944,50 @@ const xiaohongshuJudge = (raw, http) => {
|
|
|
5089
2944
|
return verdictFromHttpStatus(http.status) ?? { ok: true };
|
|
5090
2945
|
};
|
|
5091
2946
|
//#endregion
|
|
5092
|
-
//#region src/platforms/xiaohongshu/sign/
|
|
5093
|
-
/** 初始化签名配置。 */
|
|
5094
|
-
const createXiaohongshuCryptoConfig = () => new _ikenxuan_xhshow_ts.CryptoConfig().withOverrides({
|
|
5095
|
-
DATA_WEB_BUILD: "6.12.3",
|
|
5096
|
-
SIGNATURE_DATA_TEMPLATE: {
|
|
5097
|
-
x0: "4.4.3",
|
|
5098
|
-
x1: "xhs-pc-web",
|
|
5099
|
-
x2: "Windows",
|
|
5100
|
-
x3: "",
|
|
5101
|
-
x4: ""
|
|
5102
|
-
},
|
|
5103
|
-
SIGNATURE_XSCOMMON_TEMPLATE: {
|
|
5104
|
-
s0: 5,
|
|
5105
|
-
s1: "",
|
|
5106
|
-
x0: "1",
|
|
5107
|
-
x1: "4.3.5",
|
|
5108
|
-
x2: "Windows",
|
|
5109
|
-
x3: "xhs-pc-web",
|
|
5110
|
-
x4: "6.12.3",
|
|
5111
|
-
x5: "",
|
|
5112
|
-
x6: "",
|
|
5113
|
-
x7: "",
|
|
5114
|
-
x8: "",
|
|
5115
|
-
x9: -596800761,
|
|
5116
|
-
x10: 0,
|
|
5117
|
-
x11: "normal"
|
|
5118
|
-
}
|
|
5119
|
-
});
|
|
5120
|
-
//#endregion
|
|
5121
|
-
//#region src/platforms/xiaohongshu/sign/guestCookie.ts
|
|
5122
|
-
/** @see https://github.com/Cialle/RedCrack */
|
|
5123
|
-
/** 小红书 Web 端游客会话初始化使用的浏览器请求头。 */
|
|
5124
|
-
const GUEST_HEADERS$1 = {
|
|
5125
|
-
accept: "application/json, text/plain, */*",
|
|
5126
|
-
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
|
5127
|
-
"content-type": "application/json;charset=UTF-8",
|
|
5128
|
-
origin: "https://www.xiaohongshu.com",
|
|
5129
|
-
priority: "u=1, i",
|
|
5130
|
-
referer: "https://www.xiaohongshu.com/",
|
|
5131
|
-
"sec-ch-ua": "\"Microsoft Edge\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"",
|
|
5132
|
-
"sec-ch-ua-mobile": "?0",
|
|
5133
|
-
"sec-ch-ua-platform": "\"Windows\"",
|
|
5134
|
-
"sec-fetch-dest": "empty",
|
|
5135
|
-
"sec-fetch-mode": "cors",
|
|
5136
|
-
"sec-fetch-site": "same-site",
|
|
5137
|
-
"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"
|
|
5138
|
-
};
|
|
5139
|
-
/** 生成 a1 随机段时使用的 Web 端字符集。 */
|
|
5140
|
-
const COOKIE_RANDOM_CHARS$1 = "abcdefghijklmnopqrstuvwxyz1234567890";
|
|
5141
|
-
/** 将 Cookie Jar 序列化为可直接写入 HTTP Cookie 请求头的字符串。 */
|
|
5142
|
-
const toCookieString$1 = (cookies) => Object.entries(cookies).map(([key, value]) => `${key}=${value}`).join("; ");
|
|
5143
|
-
/** 将响应头中的 Set-Cookie 字段合并到当前游客会话的 Cookie Jar。 */
|
|
5144
|
-
const updateCookiesFromResponse$1 = (cookies, setCookie) => {
|
|
5145
|
-
const headers = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
|
5146
|
-
for (const header of headers) {
|
|
5147
|
-
const [nameValue] = header.split(";", 1);
|
|
5148
|
-
const separatorIndex = nameValue.indexOf("=");
|
|
5149
|
-
if (separatorIndex > 0) cookies[nameValue.slice(0, separatorIndex).trim()] = nameValue.slice(separatorIndex + 1).trim();
|
|
5150
|
-
}
|
|
5151
|
-
};
|
|
5152
|
-
/** 按小红书 Web 端规则生成 a1,以及由 a1 派生的 webId。 */
|
|
5153
|
-
const generateA1AndWebId$1 = () => {
|
|
5154
|
-
const source = `${Date.now().toString(16)}${Array.from((0, node_crypto.randomBytes)(30), (byte) => COOKIE_RANDOM_CHARS$1[byte % 36]).join("")}5000`;
|
|
5155
|
-
const a1 = `${source}${crc32$1(source)}`.slice(0, 52);
|
|
5156
|
-
return {
|
|
5157
|
-
a1,
|
|
5158
|
-
webId: (0, node_crypto.createHash)("md5").update(a1).digest("hex")
|
|
5159
|
-
};
|
|
5160
|
-
};
|
|
5161
|
-
/** 计算与浏览器端实现兼容的无符号 CRC32 校验值。 */
|
|
5162
|
-
const crc32$1 = (input) => {
|
|
5163
|
-
let value = 4294967295;
|
|
5164
|
-
for (const byte of Buffer.from(input)) {
|
|
5165
|
-
value ^= byte;
|
|
5166
|
-
for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? value >>> 1 ^ 3988292384 : value >>> 1;
|
|
5167
|
-
}
|
|
5168
|
-
return (value ^ 4294967295) >>> 0;
|
|
5169
|
-
};
|
|
5170
|
-
/** 从 scripting 接口返回的 VMP 数据中解码 websectiga Cookie。 */
|
|
5171
|
-
const generateWebsectiga$1 = (payload) => {
|
|
5172
|
-
const bMatch = payload.match(/"b":"(.*?)",/);
|
|
5173
|
-
const dMatch = payload.match(/"d":(.*?)\}\)/);
|
|
5174
|
-
if (!bMatch || !dMatch) throw new Error("小红书 scripting 响应格式异常,无法生成 websectiga");
|
|
5175
|
-
const decoderData = JSON.parse(dMatch[1]);
|
|
5176
|
-
const encoded = Buffer.from(bMatch[1], "base64").toString("utf8");
|
|
5177
|
-
const logicList = [];
|
|
5178
|
-
for (let index = 0; index < encoded.length; index += 5) logicList.push(Array.from(encoded.slice(index, index + 5), (char) => char.charCodeAt(0) - 1));
|
|
5179
|
-
const start = decoderData[92];
|
|
5180
|
-
const end = decoderData[93];
|
|
5181
|
-
const target = logicList.slice(start, end + 1);
|
|
5182
|
-
const key = Array.from({ length: 64 }, (_, index) => {
|
|
5183
|
-
const item = target[675 + index * 2];
|
|
5184
|
-
if (!item) throw new Error("小红书 scripting 响应缺少 websectiga 解码数据");
|
|
5185
|
-
return decoderData[item[2]];
|
|
5186
|
-
});
|
|
5187
|
-
return Array.from({ length: 8 }, (_, group) => {
|
|
5188
|
-
const offset = 56 - group * 8;
|
|
5189
|
-
return String.fromCharCode(...key.slice(offset, offset + 8));
|
|
5190
|
-
}).join("");
|
|
5191
|
-
};
|
|
5192
|
-
/** 将浏览器指纹编码为 webprofile 接口要求的 DES-ECB profileData。 */
|
|
5193
|
-
const encryptProfileData$1 = (fingerprint, desKey) => {
|
|
5194
|
-
const encoded = Buffer.from(JSON.stringify(fingerprint)).toString("base64");
|
|
5195
|
-
const blockSize = 8;
|
|
5196
|
-
const padding = blockSize - encoded.length % blockSize;
|
|
5197
|
-
const plaintext = Buffer.concat([Buffer.from(encoded), Buffer.alloc(padding)]);
|
|
5198
|
-
const cipher = (0, node_crypto.createCipheriv)("des-ede3", Buffer.from(desKey.repeat(3)), null);
|
|
5199
|
-
cipher.setAutoPadding(false);
|
|
5200
|
-
return Buffer.concat([cipher.update(plaintext), cipher.final()]).toString("hex");
|
|
5201
|
-
};
|
|
5202
|
-
/** 将 scripting 的 JSON 或 JSONP 响应统一还原为结构化数据。 */
|
|
5203
|
-
const unwrapScriptingResponse$1 = (data) => {
|
|
5204
|
-
if (typeof data === "string") {
|
|
5205
|
-
const json = data.match(/^[^(]+\((.*)\)$/s)?.[1];
|
|
5206
|
-
return JSON.parse(json ?? data);
|
|
5207
|
-
}
|
|
5208
|
-
return data;
|
|
5209
|
-
};
|
|
5210
|
-
/**
|
|
5211
|
-
* 创建小红书 Web 端游客会话 Cookie。
|
|
5212
|
-
*
|
|
5213
|
-
* 该流程对应 Web 端首次访问时的 Cookie 初始化:生成 a1/webId,完成
|
|
5214
|
-
* scripting、webprofile 与 activate 三个会话请求,并返回最终 Cookie 字符串。
|
|
5215
|
-
*
|
|
5216
|
-
* 会话请求经注入的 `send` 走 transport —— 调用方配的
|
|
5217
|
-
* proxy / agent / 超时对它生效;请求头、响应处理与 Cookie 合并逻辑
|
|
5218
|
-
* 不随发送方式改变。
|
|
5219
|
-
* @param send - 注入的底层发送函数(来自 transport)
|
|
5220
|
-
* @param requestConfig - 调用方请求配置,透传
|
|
5221
|
-
* @returns 游客 Cookie 字符串
|
|
5222
|
-
*/
|
|
5223
|
-
const createXiaohongshuGuestCookie$1 = async (send, requestConfig) => {
|
|
5224
|
-
const cryptoConfig = createXiaohongshuCryptoConfig();
|
|
5225
|
-
const signer = new _ikenxuan_xhshow_ts.Xhshow(cryptoConfig);
|
|
5226
|
-
const cookies = {
|
|
5227
|
-
...generateA1AndWebId$1(),
|
|
5228
|
-
webBuild: cryptoConfig.DATA_WEB_BUILD,
|
|
5229
|
-
xsecappid: "xhs-pc-web",
|
|
5230
|
-
loadts: String(Date.now()),
|
|
5231
|
-
abRequestId: (0, node_crypto.randomUUID)()
|
|
5232
|
-
};
|
|
5233
|
-
const { headers: requestHeaders } = requestConfig ?? {};
|
|
5234
|
-
/** 发送会话初始化请求,并将响应中的 Set-Cookie 合并回当前 Cookie Jar。 */
|
|
5235
|
-
const request = async (url, data, signed = false) => {
|
|
5236
|
-
const signatureHeaders = signed ? signer.signHeadersPost(new URL(url).pathname, cookies, "xhs-pc-web", data) : {};
|
|
5237
|
-
const response = await send({
|
|
5238
|
-
method: "POST",
|
|
5239
|
-
url,
|
|
5240
|
-
body: data,
|
|
5241
|
-
headers: {
|
|
5242
|
-
...GUEST_HEADERS$1,
|
|
5243
|
-
...requestHeaders,
|
|
5244
|
-
...signatureHeaders,
|
|
5245
|
-
Cookie: toCookieString$1(cookies)
|
|
5246
|
-
}
|
|
5247
|
-
});
|
|
5248
|
-
const setCookie = response.setCookie;
|
|
5249
|
-
updateCookiesFromResponse$1(cookies, setCookie);
|
|
5250
|
-
if (response.status < 200 || response.status >= 300) throw new Error(`小红书游客会话初始化失败:${url} 返回 HTTP ${response.status}`);
|
|
5251
|
-
return response.body;
|
|
5252
|
-
};
|
|
5253
|
-
const scriptingData = unwrapScriptingResponse$1(await request("https://as.xiaohongshu.com/api/sec/v1/scripting", {
|
|
5254
|
-
callFrom: "web",
|
|
5255
|
-
callback: "seccallback"
|
|
5256
|
-
})).data;
|
|
5257
|
-
if (!scriptingData?.data || !scriptingData.secPoisonId) throw new Error("小红书 scripting 响应缺少游客会话数据");
|
|
5258
|
-
cookies.websectiga = generateWebsectiga$1(scriptingData.data);
|
|
5259
|
-
cookies.sec_poison_id = scriptingData.secPoisonId;
|
|
5260
|
-
const fingerprint = new _ikenxuan_xhshow_ts.FingerprintGenerator(cryptoConfig).generate(cookies, GUEST_HEADERS$1["user-agent"]);
|
|
5261
|
-
await request(cryptoConfig.GID_URL, {
|
|
5262
|
-
platform: cryptoConfig.DATA_PLATFORM,
|
|
5263
|
-
profileData: encryptProfileData$1(fingerprint, cryptoConfig.DES_KEY),
|
|
5264
|
-
sdkVersion: cryptoConfig.DATA_SDK_VERSION,
|
|
5265
|
-
svn: cryptoConfig.DATA_SVN
|
|
5266
|
-
}, true);
|
|
5267
|
-
await request("https://edith.xiaohongshu.com/api/sns/web/v1/login/activate", {}, true);
|
|
5268
|
-
if (!cookies.web_session) throw new Error("小红书游客会话初始化失败:未获取到 web_session");
|
|
5269
|
-
return toCookieString$1(cookies);
|
|
5270
|
-
};
|
|
5271
|
-
//#endregion
|
|
5272
|
-
//#region src/platforms/xiaohongshu/sign/index.ts
|
|
2947
|
+
//#region src/platforms/xiaohongshu/sign/signers.ts
|
|
5273
2948
|
/**
|
|
5274
|
-
*
|
|
2949
|
+
* 小红书签名器表:**只做「注册名 → `SignStep` 清单」的映射,不含算法**。
|
|
5275
2950
|
*
|
|
5276
|
-
*
|
|
5277
|
-
*
|
|
5278
|
-
*
|
|
2951
|
+
* 算法的唯一实现在 `./steps.ts`(四个基础签名器、helper,以及 XYS_/XYW_ 协议区别、
|
|
2952
|
+
* 签名路径为何取 signPath 等理由都在那边的文件头)。依赖方向单向 —— `signers.ts → steps.ts`,
|
|
2953
|
+
* 永不反向:两份实现并存时会改一边忘一边,而小红书签名错了往往整条请求被判 406,这类分叉极难发现。
|
|
5279
2954
|
*
|
|
5280
|
-
*
|
|
2955
|
+
* ## 两条路,一份实现
|
|
5281
2956
|
*
|
|
5282
|
-
*
|
|
5283
|
-
*
|
|
5284
|
-
* (`user_posted`、`user/otherinfo` 等)自 2026-03 起以 HTTP 406 拒绝传统 XYS_
|
|
5285
|
-
* 格式,改用基于 AES-128-CBC 的 XYW_ 签名。x-s 值以 `XYW_` 开头。
|
|
5286
|
-
* - **x-rap-param**(`generateXRapParam`):feed / 搜索 / 发布类接口的额外校验头。
|
|
5287
|
-
* - **x-xray-traceid**(`generateXrayTraceid`):追踪头,随现代浏览器每次请求下发。
|
|
5288
|
-
* - **search request_id**(`getSearchRequestId`):搜索接口所需的 `{random}-{ts_ms}`。
|
|
5289
|
-
*/
|
|
5290
|
-
/** 模块级 Xhshow 单例 */
|
|
5291
|
-
const client = new _ikenxuan_xhshow_ts.Xhshow(createXiaohongshuCryptoConfig());
|
|
5292
|
-
/**
|
|
5293
|
-
* 生成 GET 请求的 X-S 签名。
|
|
5294
|
-
* @param path - API 路径,如 `/api/sns/web/v1/feed`
|
|
5295
|
-
* @param a1Cookie - a1 cookie 值
|
|
5296
|
-
* @param clientType - 客户端类型,默认 `'xhs-pc-web'`
|
|
5297
|
-
* @param params - 查询参数对象
|
|
5298
|
-
* @returns X-S 签名
|
|
5299
|
-
*/
|
|
5300
|
-
const generateXSGet = (path, a1Cookie, clientType = "xhs-pc-web", params = {}) => client.signXsGet(path, a1Cookie, clientType, params);
|
|
5301
|
-
/**
|
|
5302
|
-
* 生成 POST 请求的 X-S 签名。
|
|
5303
|
-
* @param path - API 路径
|
|
5304
|
-
* @param a1Cookie - a1 cookie 值
|
|
5305
|
-
* @param clientType - 客户端类型,默认 `'xhs-pc-web'`
|
|
5306
|
-
* @param body - 请求体对象
|
|
5307
|
-
* @returns X-S 签名
|
|
5308
|
-
*/
|
|
5309
|
-
const generateXSPost = (path, a1Cookie, clientType = "xhs-pc-web", body = {}) => client.signXsPost(path, a1Cookie, clientType, body);
|
|
5310
|
-
/**
|
|
5311
|
-
* 生成 GET 请求的 XYW_ 格式 X-S 签名(AES-128-CBC)。
|
|
2957
|
+
* 端点声明 `sign: [xs('get', 'xyw'), traceId()]` 走清单;`client.xiaohongshu.request` 不给
|
|
2958
|
+
* `amagi.sign` 时走字符串 `'xhs-get'` 这类名字查这张表。两者现在压的是同一批 step,签出来必然一致。
|
|
5312
2959
|
*
|
|
5313
|
-
*
|
|
5314
|
-
* 需改用本函数。返回值以 `XYW_` 开头,走独立于 `signXsGet` 的加密路径。
|
|
5315
|
-
* @param path - API 路径,如 `/api/sns/web/v1/user_posted`
|
|
5316
|
-
* @param a1Cookie - a1 cookie 值
|
|
5317
|
-
* @param clientType - 客户端类型,默认 `'xhs-pc-web'`
|
|
5318
|
-
* @param params - 查询参数对象
|
|
5319
|
-
* @returns XYW_ 格式 X-S 签名
|
|
5320
|
-
*/
|
|
5321
|
-
const generateXSGetXyw = (path, a1Cookie, clientType = "xhs-pc-web", params = {}) => client.signXyw("GET", path, a1Cookie, clientType, params);
|
|
5322
|
-
/**
|
|
5323
|
-
* 生成 POST 请求的 XYW_ 格式 X-S 签名(AES-128-CBC)。
|
|
5324
|
-
* @param path - API 路径
|
|
5325
|
-
* @param a1Cookie - a1 cookie 值
|
|
5326
|
-
* @param clientType - 客户端类型,默认 `'xhs-pc-web'`
|
|
5327
|
-
* @param body - 请求体对象
|
|
5328
|
-
* @returns XYW_ 格式 X-S 签名
|
|
5329
|
-
*/
|
|
5330
|
-
const generateXSPostXyw = (path, a1Cookie, clientType = "xhs-pc-web", body = {}) => client.signXyw("POST", path, a1Cookie, clientType, body);
|
|
5331
|
-
/**
|
|
5332
|
-
* 生成 X-Rap-Param 请求头(feed / 搜索 / 发布类接口的额外校验)。
|
|
5333
|
-
* @param api - 请求 API,形如 `//edith.xiaohongshu.com/api/sns/web/v1/feed`(含 host、去协议)
|
|
5334
|
-
* @param body - 请求体对象(GET 传空对象)
|
|
5335
|
-
* @returns Base64 编码的 x-rap-param 字符串
|
|
5336
|
-
*/
|
|
5337
|
-
const generateXRapParam = (api, body = {}) => (0, _ikenxuan_xhshow_ts.xRapParam)(api, body);
|
|
5338
|
-
/**
|
|
5339
|
-
* 生成 X-S-Common 参数。
|
|
5340
|
-
* @param cookies - cookie 字符串
|
|
5341
|
-
* @returns Base64 编码的随机字符串
|
|
5342
|
-
*/
|
|
5343
|
-
const generateXSCommon = (cookies) => client.signXsCommon(cookies);
|
|
5344
|
-
/**
|
|
5345
|
-
* 生成 X-T 时间戳。
|
|
5346
|
-
* @returns 当前时间戳(毫秒)
|
|
5347
|
-
*/
|
|
5348
|
-
const generateXT = () => client.getXT();
|
|
5349
|
-
/**
|
|
5350
|
-
* 生成 X-B3-Traceid。
|
|
5351
|
-
* @returns 16 位随机字符串
|
|
5352
|
-
*/
|
|
5353
|
-
const generateXB3Traceid = () => client.getB3TraceId();
|
|
5354
|
-
/**
|
|
5355
|
-
* 生成 X-Xray-Traceid。
|
|
5356
|
-
*
|
|
5357
|
-
* 32 位字符(16 位时间戳+序列 + 16 位随机),随现代浏览器每次请求下发。
|
|
5358
|
-
* @returns X-Xray-Traceid 字符串
|
|
5359
|
-
*/
|
|
5360
|
-
const generateXrayTraceid = () => client.getXrayTraceId();
|
|
5361
|
-
/**
|
|
5362
|
-
* 生成搜索 ID。
|
|
5363
|
-
*
|
|
5364
|
-
* 交由上游 `client.getSearchId()`:base36 编码的 `(timestamp_ms << 64) + random`。
|
|
5365
|
-
* 旧的本地实现把 BigInt 与 string 相加,得到「十进制拼 base36 后缀」而非预期的
|
|
5366
|
-
* 位运算结果 —— 上游修正后此处直接复用,不再自行拼装。
|
|
5367
|
-
* @returns 搜索 ID 字符串
|
|
5368
|
-
*/
|
|
5369
|
-
const getSearchId = () => client.getSearchId();
|
|
5370
|
-
//#endregion
|
|
5371
|
-
//#region src/platforms/xiaohongshu/sign/signers.ts
|
|
5372
|
-
/**
|
|
5373
|
-
* 小红书签名器表。
|
|
2960
|
+
* ## 七个注册名
|
|
5374
2961
|
*
|
|
5375
2962
|
* XYS_(传统)路径:
|
|
5376
|
-
* - `'xhs-post'`:POST
|
|
5377
|
-
* - `'xhs-get'`:GET
|
|
2963
|
+
* - `'xhs-post'`:POST,x-s 用 `signXsPost` 对 body 签名
|
|
2964
|
+
* - `'xhs-get'`:GET,x-s 用 `signXsGet` 对 query 签名
|
|
5378
2965
|
* - `'xhs-get-trace'`:GET + `x-b3-traceid`(userNoteList 专用)
|
|
5379
2966
|
* - `'xhs-post-rap'`:POST + `x-rap-param`(feed / 搜索 / 发布类接口的额外校验)
|
|
5380
2967
|
*
|
|
5381
|
-
* XYW_(2026-03 之后)路径 —— 数据获取类接口以 HTTP 406 拒绝 XYS_
|
|
5382
|
-
*
|
|
5383
|
-
* - `'xhs-get-xyw'`:GET
|
|
5384
|
-
* - `'xhs-post-xyw'`:POST
|
|
2968
|
+
* XYW_(2026-03 之后)路径 —— 数据获取类接口以 HTTP 406 拒绝 XYS_,x-s 改用以 `XYW_`
|
|
2969
|
+
* 开头的 AES-128-CBC 签名:
|
|
2970
|
+
* - `'xhs-get-xyw'`:GET,x-s 用 `signXyw`
|
|
2971
|
+
* - `'xhs-post-xyw'`:POST,x-s 用 `signXyw`
|
|
5385
2972
|
* - `'xhs-get-xyw-trace'`:GET XYW + `x-b3-traceid`(userNoteList 专用)
|
|
5386
2973
|
*
|
|
5387
|
-
*
|
|
5388
|
-
* 小红书签名只用接口路径,与完整 URL 无关。x-rap-param 例外,它要 host + path。
|
|
5389
|
-
*
|
|
5390
|
-
* 签名输入:a1 取自 cookie,POST 签名含 body,GET 签名含 query。
|
|
2974
|
+
* @module platforms/xiaohongshu/sign/signers
|
|
5391
2975
|
*/
|
|
5392
|
-
/** 从 cookie 取 a1:用 `getCookieValue` 按名精确匹配 */
|
|
5393
|
-
const a1Of = (cookie) => getCookieValue(cookie, "a1") ?? "";
|
|
5394
|
-
/** 签名路径:优先 `spec.signPath`(build 填的 apiPath),缺省取 URL 的 pathname */
|
|
5395
|
-
const apiPathOf = (spec) => spec.signPath ?? new URL(spec.url).pathname;
|
|
5396
2976
|
/**
|
|
5397
|
-
*
|
|
5398
|
-
* 小红书 GET 的 x-s 必须覆盖 query(路径 + 参数),只签路径会被判非法请求返回 406。
|
|
5399
|
-
* 无参数端点(emojiList / userProfile)返回空对象,签名退化为只签路径,行为不变。
|
|
5400
|
-
*/
|
|
5401
|
-
const signParamsOf = (spec) => spec.extra?.signParams ?? {};
|
|
5402
|
-
/** x-rap-param 的 api 入参:去协议、保留 `//host/path`(不含 query) */
|
|
5403
|
-
const rapApiOf = (spec) => {
|
|
5404
|
-
const url = new URL(spec.url);
|
|
5405
|
-
return `//${url.host}${url.pathname}`;
|
|
5406
|
-
};
|
|
5407
|
-
/**
|
|
5408
|
-
* 注入 x-s / x-s-common / x-t / x-xray-traceid 四个签名头。
|
|
2977
|
+
* 平台签名器表,交给 runtime 的 `signers` 查名。
|
|
5409
2978
|
*
|
|
5410
|
-
* `
|
|
5411
|
-
*
|
|
5412
|
-
*
|
|
2979
|
+
* 用 `satisfies` 而非显式 `: Record<string, SignFn>` 返回:后者会把键联合抹成宽
|
|
2980
|
+
* `string`,而 {@link XiaohongshuSignerName} 要靠 `keyof` 从这张表推导精确名字联合。
|
|
2981
|
+
* @returns 注册名 → 签名器
|
|
5413
2982
|
*/
|
|
5414
|
-
const signHeaders = (spec, ctx, xs) => {
|
|
5415
|
-
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());
|
|
5416
|
-
return {
|
|
5417
|
-
...spec,
|
|
5418
|
-
headers: headers.toJSON()
|
|
5419
|
-
};
|
|
5420
|
-
};
|
|
5421
|
-
/** POST 签名器:x-s = signXsPost(apiPath, a1, 'xhs-pc-web', body) */
|
|
5422
|
-
const xhsPostSigner = (spec, ctx) => {
|
|
5423
|
-
const xs = generateXSPost(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", spec.body ?? {});
|
|
5424
|
-
return signHeaders(spec, ctx, xs);
|
|
5425
|
-
};
|
|
5426
|
-
/** GET 签名器:x-s = signXsGet(apiPath, a1, 'xhs-pc-web', signParams) */
|
|
5427
|
-
const xhsGetSigner = (spec, ctx) => {
|
|
5428
|
-
const xs = generateXSGet(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", signParamsOf(spec));
|
|
5429
|
-
return signHeaders(spec, ctx, xs);
|
|
5430
|
-
};
|
|
5431
|
-
/** GET + x-b3-traceid(userNoteList 专用) */
|
|
5432
|
-
const xhsGetTraceSigner = async (spec, ctx) => {
|
|
5433
|
-
const signed = await xhsGetSigner(spec, ctx);
|
|
5434
|
-
const headers = new AmagiHeaders(signed.headers).set("x-b3-traceid", generateXB3Traceid());
|
|
5435
|
-
return {
|
|
5436
|
-
...signed,
|
|
5437
|
-
headers: headers.toJSON()
|
|
5438
|
-
};
|
|
5439
|
-
};
|
|
5440
|
-
/** POST + x-rap-param(feed / 搜索 / 发布类接口的额外校验) */
|
|
5441
|
-
const xhsPostRapSigner = async (spec, ctx) => {
|
|
5442
|
-
const signed = await xhsPostSigner(spec, ctx);
|
|
5443
|
-
const rap = generateXRapParam(rapApiOf(spec), spec.body ?? {});
|
|
5444
|
-
const headers = new AmagiHeaders(signed.headers).set("x-rap-param", rap);
|
|
5445
|
-
return {
|
|
5446
|
-
...signed,
|
|
5447
|
-
headers: headers.toJSON()
|
|
5448
|
-
};
|
|
5449
|
-
};
|
|
5450
|
-
/** XYW GET 签名器:x-s = signXyw('GET', apiPath, a1, 'xhs-pc-web', signParams),绕过数据接口 406 */
|
|
5451
|
-
const xhsGetXywSigner = (spec, ctx) => {
|
|
5452
|
-
const xs = generateXSGetXyw(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", signParamsOf(spec));
|
|
5453
|
-
return signHeaders(spec, ctx, xs);
|
|
5454
|
-
};
|
|
5455
|
-
/** XYW POST 签名器:x-s = signXyw('POST', apiPath, a1, 'xhs-pc-web', body) */
|
|
5456
|
-
const xhsPostXywSigner = (spec, ctx) => {
|
|
5457
|
-
const xs = generateXSPostXyw(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", spec.body ?? {});
|
|
5458
|
-
return signHeaders(spec, ctx, xs);
|
|
5459
|
-
};
|
|
5460
|
-
/** XYW GET + x-b3-traceid(userNoteList 专用,2026-03 之后) */
|
|
5461
|
-
const xhsGetXywTraceSigner = async (spec, ctx) => {
|
|
5462
|
-
const signed = await xhsGetXywSigner(spec, ctx);
|
|
5463
|
-
const headers = new AmagiHeaders(signed.headers).set("x-b3-traceid", generateXB3Traceid());
|
|
5464
|
-
return {
|
|
5465
|
-
...signed,
|
|
5466
|
-
headers: headers.toJSON()
|
|
5467
|
-
};
|
|
5468
|
-
};
|
|
5469
|
-
/** 平台签名器表,交给 runtime 的 `signers` 查名 */
|
|
5470
2983
|
const createXiaohongshuSigners = () => ({
|
|
5471
|
-
"xhs-post":
|
|
5472
|
-
"xhs-get":
|
|
5473
|
-
"xhs-get-trace":
|
|
5474
|
-
"xhs-post-rap":
|
|
5475
|
-
"xhs-get-xyw":
|
|
5476
|
-
"xhs-post-xyw":
|
|
5477
|
-
"xhs-get-xyw-trace":
|
|
2984
|
+
"xhs-post": stepsToSigner([require_steps.xs("post", "xys")]),
|
|
2985
|
+
"xhs-get": stepsToSigner([require_steps.xs("get", "xys")]),
|
|
2986
|
+
"xhs-get-trace": stepsToSigner([require_steps.xs("get", "xys"), require_steps.traceId()]),
|
|
2987
|
+
"xhs-post-rap": stepsToSigner([require_steps.xs("post", "xys"), require_steps.rap()]),
|
|
2988
|
+
"xhs-get-xyw": stepsToSigner([require_steps.xs("get", "xyw")]),
|
|
2989
|
+
"xhs-post-xyw": stepsToSigner([require_steps.xs("post", "xyw")]),
|
|
2990
|
+
"xhs-get-xyw-trace": stepsToSigner([require_steps.xs("get", "xyw"), require_steps.traceId()])
|
|
5478
2991
|
});
|
|
5479
2992
|
//#endregion
|
|
5480
2993
|
//#region src/runtime/events.ts
|
|
@@ -5871,10 +3384,10 @@ const stripEdgeToken = (headers) => {
|
|
|
5871
3384
|
* @returns 归一化后的容器
|
|
5872
3385
|
*/
|
|
5873
3386
|
const toAmagiHeaders = (headers) => {
|
|
5874
|
-
if (!headers) return new AmagiHeaders();
|
|
3387
|
+
if (!headers) return new require_steps.AmagiHeaders();
|
|
5875
3388
|
const source = headers;
|
|
5876
3389
|
const plain = typeof source.toJSON === "function" ? source.toJSON() : headers;
|
|
5877
|
-
const out = new AmagiHeaders();
|
|
3390
|
+
const out = new require_steps.AmagiHeaders();
|
|
5878
3391
|
for (const [name, value] of Object.entries(plain)) out.set(name, Array.isArray(value) ? value.join("; ") : value);
|
|
5879
3392
|
return out;
|
|
5880
3393
|
};
|
|
@@ -6034,7 +3547,7 @@ var HttpClient = class {
|
|
|
6034
3547
|
* @returns axios 配置
|
|
6035
3548
|
*/
|
|
6036
3549
|
buildAxiosConfig(spec, perCall) {
|
|
6037
|
-
const headers = new AmagiHeaders(this.options.headers).merge(this.options.requestConfig?.headers).merge(perCall?.headers).merge(spec.headers);
|
|
3550
|
+
const headers = new require_steps.AmagiHeaders(this.options.headers).merge(this.options.requestConfig?.headers).merge(perCall?.headers).merge(spec.headers);
|
|
6038
3551
|
for (const name of spec.dropHeaders ?? []) headers.delete(name);
|
|
6039
3552
|
const { headers: _baseHeaders, ...baseRest } = this.options.requestConfig ?? {};
|
|
6040
3553
|
const { headers: _callHeaders, ...callRest } = perCall ?? {};
|
|
@@ -6127,7 +3640,7 @@ const PLATFORM_RUNTIME = {
|
|
|
6127
3640
|
douyin: {
|
|
6128
3641
|
signers: createDouyinSigners(),
|
|
6129
3642
|
judge: douyinJudge,
|
|
6130
|
-
observe: observeDouyinWebid
|
|
3643
|
+
observe: require_steps.observeDouyinWebid
|
|
6131
3644
|
},
|
|
6132
3645
|
bilibili: {
|
|
6133
3646
|
signers: (() => {
|
|
@@ -6383,7 +3896,10 @@ const runPaginated = async (def, params, runPage) => {
|
|
|
6383
3896
|
const limitKey = def.limitParam ?? "number";
|
|
6384
3897
|
const countKey = def.countParam ?? limitKey;
|
|
6385
3898
|
const target = resolveTarget(params[limitKey], def.maxPageSize);
|
|
6386
|
-
|
|
3899
|
+
if (target === 0) return {
|
|
3900
|
+
ok: true,
|
|
3901
|
+
value: { empty: true }
|
|
3902
|
+
};
|
|
6387
3903
|
const collected = [];
|
|
6388
3904
|
let current = params;
|
|
6389
3905
|
let lastPage;
|
|
@@ -6400,7 +3916,6 @@ const runPaginated = async (def, params, runPage) => {
|
|
|
6400
3916
|
error: outcome.error
|
|
6401
3917
|
};
|
|
6402
3918
|
const page = outcome.value;
|
|
6403
|
-
pages.push(page);
|
|
6404
3919
|
lastPage = page;
|
|
6405
3920
|
const list = def.items(page);
|
|
6406
3921
|
if (Array.isArray(list) && list.length > 0) collected.push(...list);
|
|
@@ -6412,9 +3927,9 @@ const runPaginated = async (def, params, runPage) => {
|
|
|
6412
3927
|
return {
|
|
6413
3928
|
ok: true,
|
|
6414
3929
|
value: {
|
|
3930
|
+
empty: false,
|
|
6415
3931
|
lastPage,
|
|
6416
|
-
|
|
6417
|
-
items: target === 0 ? [] : collected.slice(0, target)
|
|
3932
|
+
items: collected.slice(0, target)
|
|
6418
3933
|
}
|
|
6419
3934
|
};
|
|
6420
3935
|
};
|
|
@@ -6429,7 +3944,7 @@ const runPaginated = async (def, params, runPage) => {
|
|
|
6429
3944
|
*/
|
|
6430
3945
|
const attachCookie = (spec, cookie) => {
|
|
6431
3946
|
if (!cookie) return spec;
|
|
6432
|
-
const headers = new AmagiHeaders(spec.headers);
|
|
3947
|
+
const headers = new require_steps.AmagiHeaders(spec.headers);
|
|
6433
3948
|
if (headers.has("cookie")) return spec;
|
|
6434
3949
|
headers.set("Cookie", cookie);
|
|
6435
3950
|
return {
|
|
@@ -6590,6 +4105,8 @@ const classifyThrown = (cause, stage) => {
|
|
|
6590
4105
|
const resolveSigner = (decl, signers) => {
|
|
6591
4106
|
if (decl === void 0 || decl === false) return void 0;
|
|
6592
4107
|
if (typeof decl === "function") return decl;
|
|
4108
|
+
if (Array.isArray(decl)) return stepsToSigner(decl);
|
|
4109
|
+
if (typeof decl === "object") return stepsToSigner([decl]);
|
|
6593
4110
|
const signer = signers?.[decl];
|
|
6594
4111
|
if (!signer) throw new Error(`未注册的签名器:'${decl}'`);
|
|
6595
4112
|
return signer;
|
|
@@ -6766,7 +4283,10 @@ const execute = async (def, input, options) => {
|
|
|
6766
4283
|
});
|
|
6767
4284
|
if (!paged.ok) return failWith(paged.error);
|
|
6768
4285
|
stage = "normalize";
|
|
6769
|
-
|
|
4286
|
+
const outcome = paged.value;
|
|
4287
|
+
if (outcome.empty) return succeed(void 0);
|
|
4288
|
+
const merge = def.paginate.merge;
|
|
4289
|
+
return succeed(merge ? merge(outcome, params) : outcome);
|
|
6770
4290
|
}
|
|
6771
4291
|
stage = "build";
|
|
6772
4292
|
const built = def.build(params, ctx);
|
|
@@ -6790,6 +4310,15 @@ const execute = async (def, input, options) => {
|
|
|
6790
4310
|
if (firstFailure) return failWith(firstFailure.error);
|
|
6791
4311
|
}
|
|
6792
4312
|
stage = "normalize";
|
|
4313
|
+
if (isMulti && def.aggregate) {
|
|
4314
|
+
const parts = outcomes.map((o) => o.ok ? o.value : void 0);
|
|
4315
|
+
const head = parts.find((p) => p !== void 0);
|
|
4316
|
+
const aggregate = def.aggregate;
|
|
4317
|
+
return succeed(aggregate({
|
|
4318
|
+
parts,
|
|
4319
|
+
head
|
|
4320
|
+
}, params));
|
|
4321
|
+
}
|
|
6793
4322
|
const decodedValue = isMulti ? outcomes.map((o) => o.ok ? o.value : void 0) : outcomes[0].value;
|
|
6794
4323
|
return succeed(def.normalize ? def.normalize(decodedValue, params) : decodedValue);
|
|
6795
4324
|
} catch (cause) {
|
|
@@ -6926,7 +4455,7 @@ const methodNameOf = (platform, endpoint) => METHOD_NAMES[fullNameOf(platform, e
|
|
|
6926
4455
|
* @returns 本次调用的有效 cookie 与合并后的请求配置
|
|
6927
4456
|
*/
|
|
6928
4457
|
const resolveBoundRequest = (boundCookie, base, override) => {
|
|
6929
|
-
const headers = new AmagiHeaders(base?.headers).merge(override?.headers);
|
|
4458
|
+
const headers = new require_steps.AmagiHeaders(base?.headers).merge(override?.headers);
|
|
6930
4459
|
return {
|
|
6931
4460
|
cookie: headers.get("cookie") ?? boundCookie,
|
|
6932
4461
|
requestConfig: {
|
|
@@ -6964,7 +4493,7 @@ const methodNameFor = (platform, endpoint) => methodNameOf(platform, endpoint) ?
|
|
|
6964
4493
|
*/
|
|
6965
4494
|
const callEndpoint = (def, ctx, options, requestConfig) => {
|
|
6966
4495
|
const merged = resolveBoundRequest(ctx.cookie, ctx.requestConfig, requestConfig);
|
|
6967
|
-
const mergedUA = merged.requestConfig ? new AmagiHeaders(merged.requestConfig.headers).get("user-agent") ?? ctx.userAgent : ctx.userAgent;
|
|
4496
|
+
const mergedUA = merged.requestConfig ? new require_steps.AmagiHeaders(merged.requestConfig.headers).get("user-agent") ?? ctx.userAgent : ctx.userAgent;
|
|
6968
4497
|
const now = ctx.now ?? Date.now;
|
|
6969
4498
|
const startedAt = now();
|
|
6970
4499
|
const requestId = (ctx.requestId ?? defaultRequestId)();
|
|
@@ -7157,7 +4686,7 @@ const extractBrowserVersion$1 = (userAgent) => {
|
|
|
7157
4686
|
const buildQueryString$3 = (params) => {
|
|
7158
4687
|
return Object.entries(params).filter(([_, value]) => value !== void 0 && value !== null).map(([key, value]) => `${key}=${encodeURIComponent(value)}`).join("&");
|
|
7159
4688
|
};
|
|
7160
|
-
const fp$1 = douyinSign
|
|
4689
|
+
const fp$1 = require_steps.douyinSign.VerifyFpManager();
|
|
7161
4690
|
/** iesdouyin v2 游客接口前缀:不需要 cookie、不需要签名 */
|
|
7162
4691
|
const GUEST_BASE$1 = "https://www.iesdouyin.com/web/api/v2";
|
|
7163
4692
|
/** 表情资源包元信息,走 App 接口,同样免鉴权(需要 Android UA,由端点声明覆盖) */
|
|
@@ -7203,7 +4732,6 @@ var DouyinAPI$1 = class {
|
|
|
7203
4732
|
platform: "PC",
|
|
7204
4733
|
downlink: "10",
|
|
7205
4734
|
effective_type: "4g",
|
|
7206
|
-
msToken: douyinSign$1.Mstoken(184),
|
|
7207
4735
|
verifyFp: fp$1,
|
|
7208
4736
|
fp: fp$1
|
|
7209
4737
|
};
|
|
@@ -7307,7 +4835,6 @@ var DouyinAPI$1 = class {
|
|
|
7307
4835
|
device_id: "7326472315356857893",
|
|
7308
4836
|
aweme_ids: `[${data.aweme_id}]`,
|
|
7309
4837
|
request_source: "200",
|
|
7310
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7311
4838
|
verifyFp: fp$1,
|
|
7312
4839
|
fp: fp$1
|
|
7313
4840
|
};
|
|
@@ -7429,7 +4956,6 @@ var DouyinAPI$1 = class {
|
|
|
7429
4956
|
downlink: "10",
|
|
7430
4957
|
effective_type: "4g",
|
|
7431
4958
|
round_trip_time: "50",
|
|
7432
|
-
msToken: douyinSign$1.Mstoken(184),
|
|
7433
4959
|
verifyFp: fp$1,
|
|
7434
4960
|
fp: fp$1
|
|
7435
4961
|
};
|
|
@@ -7557,8 +5083,7 @@ var DouyinAPI$1 = class {
|
|
|
7557
5083
|
}
|
|
7558
5084
|
/** 获取互动表情数据 */
|
|
7559
5085
|
getDynamicEmojiList() {
|
|
7560
|
-
|
|
7561
|
-
const params = {
|
|
5086
|
+
return `https://www.douyin.com/aweme/v1/web/im/strategy/config?${buildQueryString$3({
|
|
7562
5087
|
device_platform: "webapp",
|
|
7563
5088
|
aid: "1128",
|
|
7564
5089
|
channel: "channel_pc_web",
|
|
@@ -7587,11 +5112,9 @@ var DouyinAPI$1 = class {
|
|
|
7587
5112
|
downlink: "1.5",
|
|
7588
5113
|
effective_type: "4g",
|
|
7589
5114
|
round_trip_time: "350",
|
|
7590
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7591
5115
|
verifyFp: fp$1,
|
|
7592
5116
|
fp: fp$1
|
|
7593
|
-
}
|
|
7594
|
-
return `${baseUrl}?${buildQueryString$3(params)}`;
|
|
5117
|
+
})}`;
|
|
7595
5118
|
}
|
|
7596
5119
|
/** 获取背景音乐数据 */
|
|
7597
5120
|
getMusicInfo(data) {
|
|
@@ -7623,7 +5146,6 @@ var DouyinAPI$1 = class {
|
|
|
7623
5146
|
downlink: "1.5",
|
|
7624
5147
|
effective_type: "4g",
|
|
7625
5148
|
round_trip_time: "350",
|
|
7626
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7627
5149
|
verifyFp: fp$1,
|
|
7628
5150
|
fp: fp$1
|
|
7629
5151
|
};
|
|
@@ -7652,7 +5174,6 @@ var DouyinAPI$1 = class {
|
|
|
7652
5174
|
is_need_double_stream: "false",
|
|
7653
5175
|
insert_task_id: "",
|
|
7654
5176
|
live_reason: "",
|
|
7655
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7656
5177
|
verifyFp: fp$1,
|
|
7657
5178
|
fp: fp$1
|
|
7658
5179
|
};
|
|
@@ -7693,7 +5214,6 @@ var DouyinAPI$1 = class {
|
|
|
7693
5214
|
engine_version: "140.0.0.0",
|
|
7694
5215
|
downlink: "1.55",
|
|
7695
5216
|
round_trip_time: "200",
|
|
7696
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7697
5217
|
verifyFp: fp$1,
|
|
7698
5218
|
fp: fp$1
|
|
7699
5219
|
};
|
|
@@ -7703,6 +5223,19 @@ var DouyinAPI$1 = class {
|
|
|
7703
5223
|
/** 默认的 DouyinAPI 实例(使用默认浏览器版本 125.0.0.0) */
|
|
7704
5224
|
const douyinApiUrls$1 = new DouyinAPI$1();
|
|
7705
5225
|
//#endregion
|
|
5226
|
+
//#region src/platforms/douyin/endpoints/define.ts
|
|
5227
|
+
/**
|
|
5228
|
+
* 抖音端点声明。
|
|
5229
|
+
*
|
|
5230
|
+
* 就是 {@link defineEndpoint} 绑定了抖音签名器名联合 {@link DouyinSignerName}:
|
|
5231
|
+
* `sign` 的字符串分支被收窄成 `'a-bogus' | 'x-bogus'`,写错名字(如 `'a-bogas'`)
|
|
5232
|
+
* 编译期即报错,不必等运行时查表失败。运行时仍是恒等函数,其余行为与通用
|
|
5233
|
+
* `defineEndpoint` 完全一致。
|
|
5234
|
+
* @param def - 端点声明(`sign` 的名字受抖音签名器表约束)
|
|
5235
|
+
* @returns 原样返回 `def`,带上推导好的具体类型
|
|
5236
|
+
*/
|
|
5237
|
+
const defineDouyinEndpoint = (def) => defineEndpoint(def);
|
|
5238
|
+
//#endregion
|
|
7706
5239
|
//#region src/platforms/douyin/endpoints/commentReplies.ts
|
|
7707
5240
|
/**
|
|
7708
5241
|
* 二级评论(声明式翻页,maxPageSize 3,签名用 `x_bogus`)。
|
|
@@ -7710,7 +5243,7 @@ const douyinApiUrls$1 = new DouyinAPI$1();
|
|
|
7710
5243
|
* 与旧版一致:`getCommentReplies` GET,签名器是 `x_bogus`,游标是 `cursor`
|
|
7711
5244
|
* (`has_more === 1` 继续),最终形状 `{ ...最后一页, comments, cursor }`。
|
|
7712
5245
|
*/
|
|
7713
|
-
const commentReplies =
|
|
5246
|
+
const commentReplies = defineDouyinEndpoint({
|
|
7714
5247
|
name: "douyin.commentReplies",
|
|
7715
5248
|
route: "/fetch_video_comment_replies",
|
|
7716
5249
|
doc: {
|
|
@@ -7727,7 +5260,7 @@ const commentReplies = defineEndpoint({
|
|
|
7727
5260
|
method: "GET",
|
|
7728
5261
|
url: douyinApiUrls$1.getCommentReplies(p)
|
|
7729
5262
|
}),
|
|
7730
|
-
sign:
|
|
5263
|
+
sign: [require_steps.xBogus(), require_steps.secsdk()],
|
|
7731
5264
|
retryOn: ["ANTIBOT_PAGE"],
|
|
7732
5265
|
retryFresh: true,
|
|
7733
5266
|
paginate: {
|
|
@@ -7737,16 +5270,12 @@ const commentReplies = defineEndpoint({
|
|
|
7737
5270
|
nextParams: (params, page) => ({
|
|
7738
5271
|
...params,
|
|
7739
5272
|
cursor: page.cursor
|
|
7740
|
-
})
|
|
7741
|
-
|
|
7742
|
-
|
|
7743
|
-
const { lastPage, items } = decoded;
|
|
7744
|
-
const page = lastPage;
|
|
7745
|
-
return {
|
|
7746
|
-
...page ?? {},
|
|
5273
|
+
}),
|
|
5274
|
+
merge: ({ lastPage, items }) => ({
|
|
5275
|
+
...lastPage,
|
|
7747
5276
|
comments: items,
|
|
7748
|
-
cursor:
|
|
7749
|
-
}
|
|
5277
|
+
cursor: lastPage.cursor ?? items.length
|
|
5278
|
+
})
|
|
7750
5279
|
},
|
|
7751
5280
|
response: type()
|
|
7752
5281
|
});
|
|
@@ -7759,7 +5288,7 @@ const commentReplies = defineEndpoint({
|
|
|
7759
5288
|
* `cursor`(`has_more === 1` 继续),最终形状 `{ ...最后一页, comments, cursor }`
|
|
7760
5289
|
* (`cursor: resp.cursor ?? list.length`)。
|
|
7761
5290
|
*/
|
|
7762
|
-
const comments$1 =
|
|
5291
|
+
const comments$1 = defineDouyinEndpoint({
|
|
7763
5292
|
name: "douyin.comments",
|
|
7764
5293
|
route: "/fetch_work_comments",
|
|
7765
5294
|
doc: {
|
|
@@ -7775,7 +5304,7 @@ const comments$1 = defineEndpoint({
|
|
|
7775
5304
|
method: "GET",
|
|
7776
5305
|
url: douyinApiUrls$1.getComments(p)
|
|
7777
5306
|
}),
|
|
7778
|
-
sign:
|
|
5307
|
+
sign: require_steps.douyinBogus(184),
|
|
7779
5308
|
retryOn: ["ANTIBOT_PAGE"],
|
|
7780
5309
|
retryFresh: true,
|
|
7781
5310
|
paginate: {
|
|
@@ -7785,16 +5314,12 @@ const comments$1 = defineEndpoint({
|
|
|
7785
5314
|
nextParams: (params, page) => ({
|
|
7786
5315
|
...params,
|
|
7787
5316
|
cursor: page.cursor
|
|
7788
|
-
})
|
|
7789
|
-
|
|
7790
|
-
|
|
7791
|
-
const { lastPage, items } = decoded;
|
|
7792
|
-
const page = lastPage;
|
|
7793
|
-
return {
|
|
7794
|
-
...page ?? {},
|
|
5317
|
+
}),
|
|
5318
|
+
merge: ({ lastPage, items }) => ({
|
|
5319
|
+
...lastPage,
|
|
7795
5320
|
comments: items,
|
|
7796
|
-
cursor:
|
|
7797
|
-
}
|
|
5321
|
+
cursor: lastPage.cursor ?? items.length
|
|
5322
|
+
})
|
|
7798
5323
|
},
|
|
7799
5324
|
response: type()
|
|
7800
5325
|
});
|
|
@@ -7811,7 +5336,7 @@ const comments$1 = defineEndpoint({
|
|
|
7811
5336
|
* 整体(execute 的 tolerate:**全部分片都失败时仍返回失败信封**),
|
|
7812
5337
|
* `normalize` 负责合并与排序。
|
|
7813
5338
|
*/
|
|
7814
|
-
const danmakuList$1 =
|
|
5339
|
+
const danmakuList$1 = defineDouyinEndpoint({
|
|
7815
5340
|
name: "douyin.danmakuList",
|
|
7816
5341
|
route: "/fetch_work_danmaku",
|
|
7817
5342
|
doc: {
|
|
@@ -7859,37 +5384,18 @@ const danmakuList$1 = defineEndpoint({
|
|
|
7859
5384
|
tag: `segment-${index + 1}`
|
|
7860
5385
|
}));
|
|
7861
5386
|
},
|
|
7862
|
-
sign:
|
|
5387
|
+
sign: require_steps.douyinBogus(116),
|
|
7863
5388
|
retryOn: ["ANTIBOT_PAGE"],
|
|
7864
5389
|
retryFresh: true,
|
|
7865
5390
|
partial: "tolerate",
|
|
7866
|
-
|
|
7867
|
-
const
|
|
7868
|
-
const startTime = params.start_time ?? 0;
|
|
7869
|
-
const endTime = params.end_time ?? params.duration;
|
|
7870
|
-
const merged = [];
|
|
7871
|
-
let finalExtra = null;
|
|
7872
|
-
let finalLogPb = null;
|
|
7873
|
-
let finalStatusCode = 0;
|
|
7874
|
-
parts.forEach((segmentData, index) => {
|
|
7875
|
-
if (segmentData && Array.isArray(segmentData.danmaku_list)) {
|
|
7876
|
-
merged.push(...segmentData.danmaku_list);
|
|
7877
|
-
if (index === 0) {
|
|
7878
|
-
finalExtra = segmentData.extra;
|
|
7879
|
-
finalLogPb = segmentData.log_pb;
|
|
7880
|
-
finalStatusCode = segmentData.status_code ?? 0;
|
|
7881
|
-
}
|
|
7882
|
-
}
|
|
7883
|
-
});
|
|
7884
|
-
merged.sort((a, b) => (a.offset_time ?? 0) - (b.offset_time ?? 0));
|
|
5391
|
+
aggregate: ({ parts, head }, params) => {
|
|
5392
|
+
const merged = parts.flatMap((seg) => seg?.danmaku_list ?? []).sort((a, b) => a.offset_time - b.offset_time);
|
|
7885
5393
|
return {
|
|
5394
|
+
...head,
|
|
7886
5395
|
danmaku_list: merged,
|
|
7887
|
-
start_time:
|
|
7888
|
-
end_time:
|
|
7889
|
-
total: merged.length
|
|
7890
|
-
status_code: finalStatusCode,
|
|
7891
|
-
extra: finalExtra,
|
|
7892
|
-
log_pb: finalLogPb
|
|
5396
|
+
start_time: params.start_time ?? 0,
|
|
5397
|
+
end_time: params.end_time ?? params.duration,
|
|
5398
|
+
total: merged.length
|
|
7893
5399
|
};
|
|
7894
5400
|
},
|
|
7895
5401
|
response: type()
|
|
@@ -7901,7 +5407,7 @@ const danmakuList$1 = defineEndpoint({
|
|
|
7901
5407
|
*
|
|
7902
5408
|
* 与旧版一致:`getDynamicEmojiList` GET + a_bogus 签名。
|
|
7903
5409
|
*/
|
|
7904
|
-
const dynamicEmojiList =
|
|
5410
|
+
const dynamicEmojiList = defineDouyinEndpoint({
|
|
7905
5411
|
name: "douyin.dynamicEmojiList",
|
|
7906
5412
|
route: "/fetch_emoji_pro_list",
|
|
7907
5413
|
doc: {
|
|
@@ -7913,7 +5419,7 @@ const dynamicEmojiList = defineEndpoint({
|
|
|
7913
5419
|
method: "GET",
|
|
7914
5420
|
url: douyinApiUrls$1.getDynamicEmojiList()
|
|
7915
5421
|
}),
|
|
7916
|
-
sign:
|
|
5422
|
+
sign: require_steps.douyinBogus(116),
|
|
7917
5423
|
retryOn: ["ANTIBOT_PAGE"],
|
|
7918
5424
|
retryFresh: true,
|
|
7919
5425
|
response: type()
|
|
@@ -7925,7 +5431,7 @@ const dynamicEmojiList = defineEndpoint({
|
|
|
7925
5431
|
*
|
|
7926
5432
|
* 与旧版一致:`getEmojiList` GET,**不带签名参数**。
|
|
7927
5433
|
*/
|
|
7928
|
-
const emojiList$3 =
|
|
5434
|
+
const emojiList$3 = defineDouyinEndpoint({
|
|
7929
5435
|
name: "douyin.emojiList",
|
|
7930
5436
|
route: "/fetch_emoji_list",
|
|
7931
5437
|
doc: {
|
|
@@ -7937,7 +5443,7 @@ const emojiList$3 = defineEndpoint({
|
|
|
7937
5443
|
method: "GET",
|
|
7938
5444
|
url: douyinApiUrls$1.getEmojiList()
|
|
7939
5445
|
}),
|
|
7940
|
-
sign:
|
|
5446
|
+
sign: [require_steps.msToken(184)],
|
|
7941
5447
|
response: type()
|
|
7942
5448
|
});
|
|
7943
5449
|
//#endregion
|
|
@@ -7959,7 +5465,7 @@ const emojiList$3 = defineEndpoint({
|
|
|
7959
5465
|
* 超时是「本次调用」的属性而不是端点的属性)。
|
|
7960
5466
|
|
|
7961
5467
|
*/
|
|
7962
|
-
const emojiResourceMeta =
|
|
5468
|
+
const emojiResourceMeta = defineDouyinEndpoint({
|
|
7963
5469
|
name: "douyin.emojiResourceMeta",
|
|
7964
5470
|
route: "/fetch_emoji_resource_meta",
|
|
7965
5471
|
doc: {
|
|
@@ -7994,7 +5500,7 @@ const emojiResourceMeta = defineEndpoint({
|
|
|
7994
5500
|
* 接口的 `has_more` 形状还没有样本可依。
|
|
7995
5501
|
|
|
7996
5502
|
*/
|
|
7997
|
-
const guestMusicAwemeList =
|
|
5503
|
+
const guestMusicAwemeList = defineDouyinEndpoint({
|
|
7998
5504
|
name: "douyin.guestMusicAwemeList",
|
|
7999
5505
|
route: "/fetch_guest_music_aweme_list",
|
|
8000
5506
|
doc: {
|
|
@@ -8025,7 +5531,7 @@ const guestMusicAwemeList = defineEndpoint({
|
|
|
8025
5531
|
* `extra.extract_item_id` 就是创建这条原声的那个作品。哪条优先由调用方按场景排。
|
|
8026
5532
|
|
|
8027
5533
|
*/
|
|
8028
|
-
const guestMusicInfo =
|
|
5534
|
+
const guestMusicInfo = defineDouyinEndpoint({
|
|
8029
5535
|
name: "douyin.guestMusicInfo",
|
|
8030
5536
|
route: "/fetch_guest_music_info",
|
|
8031
5537
|
doc: {
|
|
@@ -8055,7 +5561,7 @@ const guestMusicInfo = defineEndpoint({
|
|
|
8055
5561
|
* 「设备参数 × 会话」的交叉校验,这条接口本来不需要身份。
|
|
8056
5562
|
|
|
8057
5563
|
*/
|
|
8058
|
-
const guestUserInfo =
|
|
5564
|
+
const guestUserInfo = defineDouyinEndpoint({
|
|
8059
5565
|
name: "douyin.guestUserInfo",
|
|
8060
5566
|
route: "/fetch_guest_user_info",
|
|
8061
5567
|
doc: {
|
|
@@ -8078,7 +5584,7 @@ const guestUserInfo = defineEndpoint({
|
|
|
8078
5584
|
*
|
|
8079
5585
|
* 行为与旧版一致:`getWorkDetail` GET + a_bogus 签名。
|
|
8080
5586
|
*/
|
|
8081
|
-
const imageAlbumWork =
|
|
5587
|
+
const imageAlbumWork = defineDouyinEndpoint({
|
|
8082
5588
|
name: "douyin.imageAlbumWork",
|
|
8083
5589
|
route: "/fetch_image_album_work",
|
|
8084
5590
|
doc: {
|
|
@@ -8090,7 +5596,7 @@ const imageAlbumWork = defineEndpoint({
|
|
|
8090
5596
|
method: "GET",
|
|
8091
5597
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8092
5598
|
}),
|
|
8093
|
-
sign:
|
|
5599
|
+
sign: require_steps.douyinBogus(184),
|
|
8094
5600
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8095
5601
|
retryFresh: true,
|
|
8096
5602
|
response: type()
|
|
@@ -8126,7 +5632,7 @@ const douyinRefererUrl = (page) => {
|
|
|
8126
5632
|
* @returns 需要写进 spec 的 header(可能为空对象)
|
|
8127
5633
|
*/
|
|
8128
5634
|
const withDouyinReferer = (ctx, page) => {
|
|
8129
|
-
if (new AmagiHeaders(ctx.requestConfig?.headers).has("referer")) return {};
|
|
5635
|
+
if (new require_steps.AmagiHeaders(ctx.requestConfig?.headers).has("referer")) return {};
|
|
8130
5636
|
return { referer: douyinRefererUrl(page) };
|
|
8131
5637
|
};
|
|
8132
5638
|
//#endregion
|
|
@@ -8137,7 +5643,7 @@ const withDouyinReferer = (ctx, page) => {
|
|
|
8137
5643
|
* 与旧版一致:`getLiveRoomInfo` GET + a_bogus 签名,
|
|
8138
5644
|
* Referer 指向 `https://live.douyin.com/{web_rid}`。
|
|
8139
5645
|
*/
|
|
8140
|
-
const liveRoomInfo$1 =
|
|
5646
|
+
const liveRoomInfo$1 = defineDouyinEndpoint({
|
|
8141
5647
|
name: "douyin.liveRoomInfo",
|
|
8142
5648
|
route: "/fetch_user_live_videos",
|
|
8143
5649
|
doc: {
|
|
@@ -8156,7 +5662,7 @@ const liveRoomInfo$1 = defineEndpoint({
|
|
|
8156
5662
|
webRid: p.web_rid
|
|
8157
5663
|
})
|
|
8158
5664
|
}),
|
|
8159
|
-
sign:
|
|
5665
|
+
sign: require_steps.douyinBogus(116),
|
|
8160
5666
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8161
5667
|
retryFresh: true,
|
|
8162
5668
|
response: type()
|
|
@@ -8172,7 +5678,7 @@ const liveRoomInfo$1 = defineEndpoint({
|
|
|
8172
5678
|
* 映射条目 `DyLoginQrcode` 是本端点的**原始响应**,与 `DyPassportQrcode`
|
|
8173
5679
|
* (登录状态机归一化后的形状,runtime/session 用)不是一回事。
|
|
8174
5680
|
*/
|
|
8175
|
-
const loginQrcode =
|
|
5681
|
+
const loginQrcode = defineDouyinEndpoint({
|
|
8176
5682
|
name: "douyin.loginQrcode",
|
|
8177
5683
|
route: "/fetch_login_qrcode",
|
|
8178
5684
|
doc: {
|
|
@@ -8184,7 +5690,7 @@ const loginQrcode = defineEndpoint({
|
|
|
8184
5690
|
method: "GET",
|
|
8185
5691
|
url: douyinApiUrls$1.getLoginQrcode(p)
|
|
8186
5692
|
}),
|
|
8187
|
-
sign:
|
|
5693
|
+
sign: [require_steps.aBogus(), require_steps.secsdk()],
|
|
8188
5694
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8189
5695
|
retryFresh: true,
|
|
8190
5696
|
response: type()
|
|
@@ -8196,7 +5702,7 @@ const loginQrcode = defineEndpoint({
|
|
|
8196
5702
|
*
|
|
8197
5703
|
* 与旧版一致:`getMusicInfo` GET + a_bogus 签名。
|
|
8198
5704
|
*/
|
|
8199
|
-
const musicInfo =
|
|
5705
|
+
const musicInfo = defineDouyinEndpoint({
|
|
8200
5706
|
name: "douyin.musicInfo",
|
|
8201
5707
|
route: "/fetch_music_work",
|
|
8202
5708
|
doc: {
|
|
@@ -8208,7 +5714,7 @@ const musicInfo = defineEndpoint({
|
|
|
8208
5714
|
method: "GET",
|
|
8209
5715
|
url: douyinApiUrls$1.getMusicInfo(p)
|
|
8210
5716
|
}),
|
|
8211
|
-
sign:
|
|
5717
|
+
sign: require_steps.douyinBogus(116),
|
|
8212
5718
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8213
5719
|
retryFresh: true,
|
|
8214
5720
|
response: type()
|
|
@@ -8222,7 +5728,7 @@ const musicInfo = defineEndpoint({
|
|
|
8222
5728
|
* 其余 4 个各占新路径。
|
|
8223
5729
|
* 与旧版一致:`getWorkDetail` GET + a_bogus 签名,返回原始响应。
|
|
8224
5730
|
*/
|
|
8225
|
-
const parseWork =
|
|
5731
|
+
const parseWork = defineDouyinEndpoint({
|
|
8226
5732
|
name: "douyin.parseWork",
|
|
8227
5733
|
route: "/fetch_one_work",
|
|
8228
5734
|
doc: {
|
|
@@ -8234,7 +5740,7 @@ const parseWork = defineEndpoint({
|
|
|
8234
5740
|
method: "GET",
|
|
8235
5741
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8236
5742
|
}),
|
|
8237
|
-
sign:
|
|
5743
|
+
sign: require_steps.douyinBogus(184),
|
|
8238
5744
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8239
5745
|
retryFresh: true,
|
|
8240
5746
|
response: type()
|
|
@@ -8366,7 +5872,7 @@ const SEARCH_TYPE_FIELD = "__search_type";
|
|
|
8366
5872
|
*
|
|
8367
5873
|
* 与旧版一致:**不签名**。
|
|
8368
5874
|
*/
|
|
8369
|
-
const search =
|
|
5875
|
+
const search = defineDouyinEndpoint({
|
|
8370
5876
|
name: "douyin.search",
|
|
8371
5877
|
route: "/fetch_search_info",
|
|
8372
5878
|
doc: {
|
|
@@ -8403,7 +5909,7 @@ const search = defineEndpoint({
|
|
|
8403
5909
|
})
|
|
8404
5910
|
};
|
|
8405
5911
|
},
|
|
8406
|
-
sign:
|
|
5912
|
+
sign: [require_steps.msToken(184)],
|
|
8407
5913
|
decode: (raw) => {
|
|
8408
5914
|
if (typeof raw !== "string") return raw;
|
|
8409
5915
|
const chunks = parseDouyinMultiJson(raw);
|
|
@@ -8423,50 +5929,47 @@ const search = defineEndpoint({
|
|
|
8423
5929
|
judge: searchJudge,
|
|
8424
5930
|
paginate: {
|
|
8425
5931
|
maxPageSize: 15,
|
|
5932
|
+
page: type(),
|
|
8426
5933
|
items: (page) => {
|
|
8427
|
-
|
|
8428
|
-
|
|
8429
|
-
return p.data ?? [];
|
|
5934
|
+
if (Array.isArray(page.user_list)) return page.user_list;
|
|
5935
|
+
return page.data ?? [];
|
|
8430
5936
|
},
|
|
8431
5937
|
hasMore: (page) => page.has_more !== 0,
|
|
8432
5938
|
nextParams: (params, page) => {
|
|
8433
5939
|
const p = page;
|
|
8434
5940
|
const nextSearchId = (typeof p.rid === "string" && p.rid.length > 0 ? p.rid : void 0) ?? (typeof p.log_pb?.impr_id === "string" ? p.log_pb.impr_id : void 0) ?? params.search_id;
|
|
8435
5941
|
const currentOffset = params.offset ?? 0;
|
|
8436
|
-
const items = Array.isArray(page
|
|
5942
|
+
const items = Array.isArray(page.user_list) ? page.user_list : page.data ?? [];
|
|
8437
5943
|
const nextOffset = typeof p.cursor === "number" && p.cursor > currentOffset ? p.cursor : currentOffset + items.length;
|
|
8438
5944
|
return {
|
|
8439
5945
|
...params,
|
|
8440
5946
|
search_id: nextSearchId,
|
|
8441
5947
|
offset: nextOffset
|
|
8442
5948
|
};
|
|
5949
|
+
},
|
|
5950
|
+
merge: ({ lastPage, items }, params) => {
|
|
5951
|
+
/**
|
|
5952
|
+
* 判别值 = **响应形状** 与 **请求类型** 的组合,各管一半:
|
|
5953
|
+
*
|
|
5954
|
+
* - **user 只看结构**:成功路径上 user 必有 `user_list`、video / general 必有 `data`
|
|
5955
|
+
* (`searchJudge` 就是按这条判反爬的),所以 `user_list` 在不在是可靠信号。
|
|
5956
|
+
* - **general 与 video 之间没有可靠结构信号**,只能取 `params.type`。
|
|
5957
|
+
* **反爬包装的形态跟逻辑类型不是一回事,不能当判据** —— 分页最后一页是单个
|
|
5958
|
+
* 合法 JSON,会被解析成对象,按 wire body 形态判会把 general 误标成 video。
|
|
5959
|
+
* - 两者矛盾时(请求 user、回的却是 data 形状)按 `general` 记:「不是 user」比
|
|
5960
|
+
* 「请求说是 user」更硬。
|
|
5961
|
+
*/
|
|
5962
|
+
const requested = params.type ?? "general";
|
|
5963
|
+
const searchType = Array.isArray(lastPage.user_list) ? "user" : requested === "user" ? "general" : requested;
|
|
5964
|
+
const out = {
|
|
5965
|
+
...lastPage,
|
|
5966
|
+
[SEARCH_TYPE_FIELD]: searchType
|
|
5967
|
+
};
|
|
5968
|
+
if (Array.isArray(lastPage.user_list)) out.user_list = items;
|
|
5969
|
+
else out.data = items;
|
|
5970
|
+
return out;
|
|
8443
5971
|
}
|
|
8444
5972
|
},
|
|
8445
|
-
normalize: (decoded, params) => {
|
|
8446
|
-
const { lastPage, items } = decoded;
|
|
8447
|
-
const page = lastPage;
|
|
8448
|
-
/**
|
|
8449
|
-
* 判别值 = **响应形状** 与 **请求类型** 的组合,各管一半:
|
|
8450
|
-
*
|
|
8451
|
-
* - **user 只看结构**:成功路径上 user 必有 `user_list`、video / general 必有 `data`
|
|
8452
|
-
* (`searchJudge` 就是按这条判反爬的),所以 `user_list` 在不在是可靠信号。
|
|
8453
|
-
* - **general 与 video 之间没有可靠结构信号**,只能取 `params.type`。
|
|
8454
|
-
* **反爬包装的形态跟逻辑类型不是一回事,不能当判据** —— 分页最后一页是单个
|
|
8455
|
-
* 合法 JSON,会被解析成对象,按 wire body 形态判会把 general 误标成 video。
|
|
8456
|
-
* - 两者矛盾时(请求 user、回的却是 data 形状)按 `general` 记:「不是 user」比
|
|
8457
|
-
* 「请求说是 user」更硬 —— 记成 user 会让下游收窄到「有 `user_list` 的那一支」,
|
|
8458
|
-
* 而那恰恰是它没有的东西。
|
|
8459
|
-
*/
|
|
8460
|
-
const requested = params.type ?? "general";
|
|
8461
|
-
const searchType = Array.isArray(page?.user_list) ? "user" : requested === "user" ? "general" : requested;
|
|
8462
|
-
const out = {
|
|
8463
|
-
...page ?? {},
|
|
8464
|
-
[SEARCH_TYPE_FIELD]: searchType
|
|
8465
|
-
};
|
|
8466
|
-
if (Array.isArray(page?.user_list)) out.user_list = items;
|
|
8467
|
-
else out.data = items;
|
|
8468
|
-
return out;
|
|
8469
|
-
},
|
|
8470
5973
|
response: type()
|
|
8471
5974
|
});
|
|
8472
5975
|
//#endregion
|
|
@@ -8476,7 +5979,7 @@ const search = defineEndpoint({
|
|
|
8476
5979
|
*
|
|
8477
5980
|
* 行为与旧版一致:`getWorkDetail` GET + a_bogus 签名。
|
|
8478
5981
|
*/
|
|
8479
|
-
const slidesWork =
|
|
5982
|
+
const slidesWork = defineDouyinEndpoint({
|
|
8480
5983
|
name: "douyin.slidesWork",
|
|
8481
5984
|
route: "/fetch_slides_work",
|
|
8482
5985
|
doc: {
|
|
@@ -8488,7 +5991,7 @@ const slidesWork = defineEndpoint({
|
|
|
8488
5991
|
method: "GET",
|
|
8489
5992
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8490
5993
|
}),
|
|
8491
|
-
sign:
|
|
5994
|
+
sign: require_steps.douyinBogus(184),
|
|
8492
5995
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8493
5996
|
retryFresh: true,
|
|
8494
5997
|
response: type()
|
|
@@ -8501,7 +6004,7 @@ const slidesWork = defineEndpoint({
|
|
|
8501
6004
|
* 与旧版一致:`getSuggestWords` GET + a_bogus 签名,
|
|
8502
6005
|
* Referer 指向 `https://www.douyin.com/search/{query}`。
|
|
8503
6006
|
*/
|
|
8504
|
-
const suggestWords =
|
|
6007
|
+
const suggestWords = defineDouyinEndpoint({
|
|
8505
6008
|
name: "douyin.suggestWords",
|
|
8506
6009
|
route: "/fetch_suggest_words",
|
|
8507
6010
|
doc: {
|
|
@@ -8517,7 +6020,7 @@ const suggestWords = defineEndpoint({
|
|
|
8517
6020
|
query: p.query
|
|
8518
6021
|
})
|
|
8519
6022
|
}),
|
|
8520
|
-
sign:
|
|
6023
|
+
sign: require_steps.douyinBogus(184),
|
|
8521
6024
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8522
6025
|
retryFresh: true,
|
|
8523
6026
|
response: type()
|
|
@@ -8529,7 +6032,7 @@ const suggestWords = defineEndpoint({
|
|
|
8529
6032
|
*
|
|
8530
6033
|
* 行为与旧版一致:`getWorkDetail` GET + a_bogus 签名。
|
|
8531
6034
|
*/
|
|
8532
|
-
const textWork =
|
|
6035
|
+
const textWork = defineDouyinEndpoint({
|
|
8533
6036
|
name: "douyin.textWork",
|
|
8534
6037
|
route: "/fetch_text_work",
|
|
8535
6038
|
doc: {
|
|
@@ -8541,7 +6044,7 @@ const textWork = defineEndpoint({
|
|
|
8541
6044
|
method: "GET",
|
|
8542
6045
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8543
6046
|
}),
|
|
8544
|
-
sign:
|
|
6047
|
+
sign: require_steps.douyinBogus(184),
|
|
8545
6048
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8546
6049
|
retryFresh: true,
|
|
8547
6050
|
response: type()
|
|
@@ -8554,7 +6057,7 @@ const textWork = defineEndpoint({
|
|
|
8554
6057
|
* 与旧版一致:`getUserFavoriteList` GET + a_bogus 签名,
|
|
8555
6058
|
* 游标是 `max_cursor`(`has_more === 1` 继续),Referer 指向用户主页。
|
|
8556
6059
|
*/
|
|
8557
|
-
const userFavoriteList =
|
|
6060
|
+
const userFavoriteList = defineDouyinEndpoint({
|
|
8558
6061
|
name: "douyin.userFavoriteList",
|
|
8559
6062
|
route: "/fetch_user_favorite_list",
|
|
8560
6063
|
doc: {
|
|
@@ -8574,7 +6077,7 @@ const userFavoriteList = defineEndpoint({
|
|
|
8574
6077
|
secUid: p.sec_uid
|
|
8575
6078
|
})
|
|
8576
6079
|
}),
|
|
8577
|
-
sign:
|
|
6080
|
+
sign: require_steps.douyinBogus(184),
|
|
8578
6081
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8579
6082
|
retryFresh: true,
|
|
8580
6083
|
paginate: {
|
|
@@ -8584,14 +6087,11 @@ const userFavoriteList = defineEndpoint({
|
|
|
8584
6087
|
nextParams: (params, page) => ({
|
|
8585
6088
|
...params,
|
|
8586
6089
|
max_cursor: page.max_cursor?.toString() ?? "0"
|
|
8587
|
-
})
|
|
8588
|
-
|
|
8589
|
-
|
|
8590
|
-
const { lastPage, items } = decoded;
|
|
8591
|
-
return {
|
|
8592
|
-
...lastPage ?? {},
|
|
6090
|
+
}),
|
|
6091
|
+
merge: ({ lastPage, items }) => ({
|
|
6092
|
+
...lastPage,
|
|
8593
6093
|
aweme_list: items
|
|
8594
|
-
}
|
|
6094
|
+
})
|
|
8595
6095
|
},
|
|
8596
6096
|
response: type()
|
|
8597
6097
|
});
|
|
@@ -8603,7 +6103,7 @@ const userFavoriteList = defineEndpoint({
|
|
|
8603
6103
|
* 与旧版一致:`getUserProfile` GET + a_bogus 签名,
|
|
8604
6104
|
* Referer 指向 `https://www.douyin.com/user/{sec_uid}`(由 {@link withDouyinReferer} 注入)。
|
|
8605
6105
|
*/
|
|
8606
|
-
const userProfile$3 =
|
|
6106
|
+
const userProfile$3 = defineDouyinEndpoint({
|
|
8607
6107
|
name: "douyin.userProfile",
|
|
8608
6108
|
route: "/fetch_user_info",
|
|
8609
6109
|
doc: {
|
|
@@ -8619,7 +6119,7 @@ const userProfile$3 = defineEndpoint({
|
|
|
8619
6119
|
secUid: p.sec_uid
|
|
8620
6120
|
})
|
|
8621
6121
|
}),
|
|
8622
|
-
sign:
|
|
6122
|
+
sign: require_steps.douyinBogus(184),
|
|
8623
6123
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8624
6124
|
retryFresh: true,
|
|
8625
6125
|
response: type()
|
|
@@ -8633,7 +6133,7 @@ const userProfile$3 = defineEndpoint({
|
|
|
8633
6133
|
* 签名,游标是 `max_cursor`。**`hasMore` 判的是 `has_more === true`(布尔)**,
|
|
8634
6134
|
* 与 userVideoList / userFavoriteList 的 `=== 1` 不同。
|
|
8635
6135
|
*/
|
|
8636
|
-
const userRecommendList =
|
|
6136
|
+
const userRecommendList = defineDouyinEndpoint({
|
|
8637
6137
|
name: "douyin.userRecommendList",
|
|
8638
6138
|
route: "/fetch_user_recommend_list",
|
|
8639
6139
|
doc: {
|
|
@@ -8653,7 +6153,7 @@ const userRecommendList = defineEndpoint({
|
|
|
8653
6153
|
secUid: p.sec_uid
|
|
8654
6154
|
})
|
|
8655
6155
|
}),
|
|
8656
|
-
sign:
|
|
6156
|
+
sign: require_steps.douyinBogus(184),
|
|
8657
6157
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8658
6158
|
retryFresh: true,
|
|
8659
6159
|
paginate: {
|
|
@@ -8663,14 +6163,11 @@ const userRecommendList = defineEndpoint({
|
|
|
8663
6163
|
nextParams: (params, page) => ({
|
|
8664
6164
|
...params,
|
|
8665
6165
|
max_cursor: page.max_cursor?.toString() ?? "0"
|
|
8666
|
-
})
|
|
8667
|
-
|
|
8668
|
-
|
|
8669
|
-
const { lastPage, items } = decoded;
|
|
8670
|
-
return {
|
|
8671
|
-
...lastPage ?? {},
|
|
6166
|
+
}),
|
|
6167
|
+
merge: ({ lastPage, items }) => ({
|
|
6168
|
+
...lastPage,
|
|
8672
6169
|
aweme_list: items
|
|
8673
|
-
}
|
|
6170
|
+
})
|
|
8674
6171
|
},
|
|
8675
6172
|
response: type()
|
|
8676
6173
|
});
|
|
@@ -8683,7 +6180,7 @@ const userRecommendList = defineEndpoint({
|
|
|
8683
6180
|
* 游标是 `max_cursor`(字符串,`has_more === 1` 继续),Referer 指向用户主页。
|
|
8684
6181
|
* 最终形状 `{ ...最后一页, aweme_list }`。
|
|
8685
6182
|
*/
|
|
8686
|
-
const userVideoList =
|
|
6183
|
+
const userVideoList = defineDouyinEndpoint({
|
|
8687
6184
|
name: "douyin.userVideoList",
|
|
8688
6185
|
route: "/fetch_user_post_videos",
|
|
8689
6186
|
doc: {
|
|
@@ -8703,7 +6200,7 @@ const userVideoList = defineEndpoint({
|
|
|
8703
6200
|
secUid: p.sec_uid
|
|
8704
6201
|
})
|
|
8705
6202
|
}),
|
|
8706
|
-
sign:
|
|
6203
|
+
sign: require_steps.douyinBogus(184),
|
|
8707
6204
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8708
6205
|
retryFresh: true,
|
|
8709
6206
|
paginate: {
|
|
@@ -8713,14 +6210,11 @@ const userVideoList = defineEndpoint({
|
|
|
8713
6210
|
nextParams: (params, page) => ({
|
|
8714
6211
|
...params,
|
|
8715
6212
|
max_cursor: page.max_cursor?.toString() ?? "0"
|
|
8716
|
-
})
|
|
8717
|
-
|
|
8718
|
-
|
|
8719
|
-
const { lastPage, items } = decoded;
|
|
8720
|
-
return {
|
|
8721
|
-
...lastPage ?? {},
|
|
6213
|
+
}),
|
|
6214
|
+
merge: ({ lastPage, items }) => ({
|
|
6215
|
+
...lastPage,
|
|
8722
6216
|
aweme_list: items
|
|
8723
|
-
}
|
|
6217
|
+
})
|
|
8724
6218
|
},
|
|
8725
6219
|
response: type()
|
|
8726
6220
|
});
|
|
@@ -8737,7 +6231,7 @@ const userVideoList = defineEndpoint({
|
|
|
8737
6231
|
*/
|
|
8738
6232
|
const douyinRegistry = {
|
|
8739
6233
|
parseWork,
|
|
8740
|
-
videoWork:
|
|
6234
|
+
videoWork: defineDouyinEndpoint({
|
|
8741
6235
|
name: "douyin.videoWork",
|
|
8742
6236
|
route: "/fetch_video_work",
|
|
8743
6237
|
doc: {
|
|
@@ -8749,7 +6243,7 @@ const douyinRegistry = {
|
|
|
8749
6243
|
method: "GET",
|
|
8750
6244
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8751
6245
|
}),
|
|
8752
|
-
sign:
|
|
6246
|
+
sign: require_steps.douyinBogus(184),
|
|
8753
6247
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8754
6248
|
retryFresh: true,
|
|
8755
6249
|
response: type(),
|
|
@@ -10189,7 +7683,7 @@ var douyinSign = class {
|
|
|
10189
7683
|
* @returns 对此地址签名后的URL查询参数
|
|
10190
7684
|
*/
|
|
10191
7685
|
static XB(url, userAgent) {
|
|
10192
|
-
return new XBogus().getXBogus(url, userAgent ?? defaultUserAgent).xbogus;
|
|
7686
|
+
return new require_steps.XBogus().getXBogus(url, userAgent ?? defaultUserAgent).xbogus;
|
|
10193
7687
|
}
|
|
10194
7688
|
/**
|
|
10195
7689
|
* `x-secsdk-web-signature` 签名算法
|
|
@@ -10203,7 +7697,7 @@ var douyinSign = class {
|
|
|
10203
7697
|
* @returns 需要加签时返回带签名的完整 URL,否则原样返回
|
|
10204
7698
|
*/
|
|
10205
7699
|
static SecSdk(url, options = {}) {
|
|
10206
|
-
return applySecsdkWebSign(url, options);
|
|
7700
|
+
return require_steps.applySecsdkWebSign(url, options);
|
|
10207
7701
|
}
|
|
10208
7702
|
/** 生成一个唯一的验证字符串 */
|
|
10209
7703
|
static VerifyFpManager() {
|
|
@@ -10985,6 +8479,18 @@ const kuaishouDidState = createKuaishouDidState();
|
|
|
10985
8479
|
*/
|
|
10986
8480
|
const kuaishouDidPrepare = async (ctx) => ({ cookie: buildKuaishouDidCookie(kuaishouDidState.getDid(), ctx.cookie) });
|
|
10987
8481
|
//#endregion
|
|
8482
|
+
//#region src/platforms/kuaishou/endpoints/define.ts
|
|
8483
|
+
/**
|
|
8484
|
+
* 快手端点声明。
|
|
8485
|
+
*
|
|
8486
|
+
* 就是 {@link defineEndpoint} 绑定了快手签名器名联合 {@link KuaishouSignerName}:
|
|
8487
|
+
* `sign` 的字符串分支被收窄成 `'hxfalcon'`,写错名字编译期即报错,不必等运行时
|
|
8488
|
+
* 查表失败。运行时仍是恒等函数,其余行为与通用 `defineEndpoint` 完全一致。
|
|
8489
|
+
* @param def - 端点声明(`sign` 的名字受快手签名器表约束)
|
|
8490
|
+
* @returns 原样返回 `def`,带上推导好的具体类型
|
|
8491
|
+
*/
|
|
8492
|
+
const defineKuaishouEndpoint = (def) => defineEndpoint(def);
|
|
8493
|
+
//#endregion
|
|
10988
8494
|
//#region src/platforms/kuaishou/endpoints/comments.ts
|
|
10989
8495
|
/**
|
|
10990
8496
|
* 获取作品评论(H5 `photo/comment/list`,POST + 声明式翻页)。
|
|
@@ -11004,7 +8510,7 @@ const kuaishouDidPrepare = async (ctx) => ({ cookie: buildKuaishouDidCookie(kuai
|
|
|
11004
8510
|
* 翻页:调用方传 `number` 指定目标条数,`pcursor` 由 `paginate` 声明管理,
|
|
11005
8511
|
* 不暴露为自由参数。
|
|
11006
8512
|
*/
|
|
11007
|
-
const comments =
|
|
8513
|
+
const comments = defineKuaishouEndpoint({
|
|
11008
8514
|
name: "kuaishou.comments",
|
|
11009
8515
|
route: "/fetch_work_comments",
|
|
11010
8516
|
doc: {
|
|
@@ -11015,7 +8521,7 @@ const comments = defineEndpoint({
|
|
|
11015
8521
|
photoId: zod.default.string().min(1, { error: "photoId 不能为空" }).describe("作品 ID"),
|
|
11016
8522
|
number: zod.default.coerce.number().int().min(1).max(500).optional().describe("目标条数,默认一页")
|
|
11017
8523
|
}),
|
|
11018
|
-
sign:
|
|
8524
|
+
sign: [require_steps.hxfalcon()],
|
|
11019
8525
|
prepare: kuaishouDidPrepare,
|
|
11020
8526
|
build: (p) => {
|
|
11021
8527
|
const req = kuaishouApiUrls$1.comments(p);
|
|
@@ -11037,14 +8543,11 @@ const comments = defineEndpoint({
|
|
|
11037
8543
|
nextParams: (params, page) => ({
|
|
11038
8544
|
...params,
|
|
11039
8545
|
pcursor: page.pcursor ?? ""
|
|
11040
|
-
})
|
|
11041
|
-
|
|
11042
|
-
|
|
11043
|
-
const { lastPage, items } = decoded;
|
|
11044
|
-
return {
|
|
11045
|
-
...lastPage ?? {},
|
|
8546
|
+
}),
|
|
8547
|
+
merge: ({ lastPage, items }) => ({
|
|
8548
|
+
...lastPage,
|
|
11046
8549
|
rootComments: items
|
|
11047
|
-
}
|
|
8550
|
+
})
|
|
11048
8551
|
},
|
|
11049
8552
|
response: type()
|
|
11050
8553
|
});
|
|
@@ -11120,7 +8623,7 @@ const resolveScanRange = (p) => {
|
|
|
11120
8623
|
* 同许可):https://github.com/OduckO —— `src/platform/kuaishou/danmaku.ts` 与
|
|
11121
8624
|
* `TODO.md:140-164`。
|
|
11122
8625
|
*/
|
|
11123
|
-
const danmakuList =
|
|
8626
|
+
const danmakuList = defineKuaishouEndpoint({
|
|
11124
8627
|
name: "kuaishou.danmakuList",
|
|
11125
8628
|
route: "/fetch_danmaku_list",
|
|
11126
8629
|
doc: {
|
|
@@ -11182,39 +8685,38 @@ const danmakuList = defineEndpoint({
|
|
|
11182
8685
|
retryOn: ["PLATFORM_UNAVAILABLE"],
|
|
11183
8686
|
partial: "tolerate",
|
|
11184
8687
|
/**
|
|
11185
|
-
* 合并多个窗口:去重 → 按时间升序 →
|
|
8688
|
+
* 合并多个窗口:去重 → 按时间升序 → 写回整体扫描范围。
|
|
8689
|
+
*
|
|
8690
|
+
* 这不是「归一化」,只是把分段拿到的条目放回原位(与 `comments` 的跨页累积同语义),
|
|
8691
|
+
* 所以返回类型仍描述真实 GraphQL 形状;只有 `positionFromInclude` / `positionToExclude`
|
|
8692
|
+
* 被改写成本次**整体**扫描范围(单窗口时就等于那一窗的值)。
|
|
11186
8693
|
*
|
|
11187
|
-
*
|
|
11188
|
-
*
|
|
11189
|
-
* `
|
|
11190
|
-
* 单窗口调用时它们就等于那一窗的值。
|
|
11191
|
-
* @param decoded - 每个窗口 decode 之后的值(失败窗口是 `undefined`)
|
|
8694
|
+
* 走 typed 的 aggregate(而非 normalize):`parts` 已收窄成 `Array<…Response | undefined>`、
|
|
8695
|
+
* `head` 恒有值,去重 / 排序 / `...head` 全程无断言。
|
|
8696
|
+
* @param value - 各窗口累积值(`parts` 含失败窗口的 undefined,`head` 恒有值)
|
|
11192
8697
|
* @param params - 校验后的参数
|
|
11193
8698
|
* @returns 合并后的响应
|
|
11194
8699
|
*/
|
|
11195
|
-
|
|
11196
|
-
const windows = decoded;
|
|
8700
|
+
aggregate: ({ parts, head }, params) => {
|
|
11197
8701
|
const { from, to } = resolveScanRange(params);
|
|
11198
8702
|
const merged = [];
|
|
11199
8703
|
const seen = /* @__PURE__ */ new Set();
|
|
11200
|
-
for (const win of
|
|
8704
|
+
for (const win of parts) {
|
|
11201
8705
|
const node = win?.data?.visionDanmaku;
|
|
11202
8706
|
if (!node) continue;
|
|
11203
|
-
for (const row of node.danmakus
|
|
11204
|
-
|
|
11205
|
-
|
|
11206
|
-
seen.add(key);
|
|
8707
|
+
for (const row of node.danmakus) {
|
|
8708
|
+
if (seen.has(row.id)) continue;
|
|
8709
|
+
seen.add(row.id);
|
|
11207
8710
|
merged.push(row);
|
|
11208
8711
|
}
|
|
11209
8712
|
}
|
|
11210
|
-
merged.sort((a, b) =>
|
|
11211
|
-
const firstBody = windows.find((win) => Boolean(win?.data?.visionDanmaku));
|
|
8713
|
+
merged.sort((a, b) => a.position - b.position);
|
|
11212
8714
|
return {
|
|
11213
|
-
...
|
|
8715
|
+
...head,
|
|
11214
8716
|
data: {
|
|
11215
|
-
...
|
|
8717
|
+
...head.data,
|
|
11216
8718
|
visionDanmaku: {
|
|
11217
|
-
...
|
|
8719
|
+
...head.data.visionDanmaku,
|
|
11218
8720
|
positionFromInclude: from,
|
|
11219
8721
|
positionToExclude: to,
|
|
11220
8722
|
danmakus: merged
|
|
@@ -11236,7 +8738,7 @@ const danmakuList = defineEndpoint({
|
|
|
11236
8738
|
* 也正因为它零凭证,它是「无 cookie 能不能取到数」的**基线探针** ——
|
|
11237
8739
|
* 这条通而 videoWork 不通,问题就在签名或 did,不在网络环境。
|
|
11238
8740
|
*/
|
|
11239
|
-
const emojiList$2 =
|
|
8741
|
+
const emojiList$2 = defineKuaishouEndpoint({
|
|
11240
8742
|
name: "kuaishou.emojiList",
|
|
11241
8743
|
route: "/fetch_emoji_list",
|
|
11242
8744
|
doc: {
|
|
@@ -11267,7 +8769,7 @@ const emojiList$2 = defineEndpoint({
|
|
|
11267
8769
|
* 返回结构直接包含 `liveStream` / `author` / `gameInfo` / `noticeList` 等。
|
|
11268
8770
|
* 响应判定用快手 judge(`result=1` 才进 data)。
|
|
11269
8771
|
*/
|
|
11270
|
-
const liveRoomInfo =
|
|
8772
|
+
const liveRoomInfo = defineKuaishouEndpoint({
|
|
11271
8773
|
name: "kuaishou.liveRoomInfo",
|
|
11272
8774
|
route: "/fetch_live_room_info",
|
|
11273
8775
|
doc: {
|
|
@@ -11275,7 +8777,7 @@ const liveRoomInfo = defineEndpoint({
|
|
|
11275
8777
|
description: "取直播间聚合信息(直播流、主播、公告等);只看直播间时比 `userProfile` 轻。"
|
|
11276
8778
|
},
|
|
11277
8779
|
params: zod.default.object({ principalId: zod.default.string().min(1, { error: "principalId 不能为空" }).describe("用户 ID") }),
|
|
11278
|
-
sign:
|
|
8780
|
+
sign: [require_steps.hxfalcon()],
|
|
11279
8781
|
build: (p) => {
|
|
11280
8782
|
return {
|
|
11281
8783
|
method: "POST",
|
|
@@ -11517,7 +9019,7 @@ const mapLiveDetailToUserProfileLiveInfo = (detailData, author) => {
|
|
|
11517
9019
|
* 到空值 —— 这正是「部分失败」语义:build 返回 12 个请求并发发出,
|
|
11518
9020
|
* 失败分片在 normalize 里留空,**全部分片都失败时仍返回失败信封**。
|
|
11519
9021
|
*/
|
|
11520
|
-
const userProfile$2 =
|
|
9022
|
+
const userProfile$2 = defineKuaishouEndpoint({
|
|
11521
9023
|
name: "kuaishou.userProfile",
|
|
11522
9024
|
route: "/fetch_user_profile",
|
|
11523
9025
|
doc: {
|
|
@@ -11525,7 +9027,7 @@ const userProfile$2 = defineEndpoint({
|
|
|
11525
9027
|
description: "取用户主页聚合信息(资料、作品列表、直播间等)。部分数据取不到时留空,不整体失败。"
|
|
11526
9028
|
},
|
|
11527
9029
|
params: zod.default.object({ principalId: zod.default.string().min(1, { error: "principalId 不能为空" }).describe("用户 ID") }),
|
|
11528
|
-
sign:
|
|
9030
|
+
sign: [require_steps.hxfalcon()],
|
|
11529
9031
|
build: (p) => {
|
|
11530
9032
|
const principalId = p.principalId;
|
|
11531
9033
|
const refererPath = `profile/${encodeURIComponent(principalId)}`;
|
|
@@ -11617,7 +9119,7 @@ const userProfile$2 = defineEndpoint({
|
|
|
11617
9119
|
* `profilePublic` 的查询参数(`userWorkList` 是 `profilePublic` 的
|
|
11618
9120
|
* 领域化封装)。
|
|
11619
9121
|
*/
|
|
11620
|
-
const userWorkList =
|
|
9122
|
+
const userWorkList = defineKuaishouEndpoint({
|
|
11621
9123
|
name: "kuaishou.userWorkList",
|
|
11622
9124
|
route: "/fetch_user_work_list",
|
|
11623
9125
|
doc: {
|
|
@@ -11628,7 +9130,7 @@ const userWorkList = defineEndpoint({
|
|
|
11628
9130
|
principalId: zod.default.string().min(1, { error: "principalId 不能为空" }).describe("用户 ID"),
|
|
11629
9131
|
number: zod.default.coerce.number().int().min(1).max(500).optional().describe("目标条数,默认 12")
|
|
11630
9132
|
}),
|
|
11631
|
-
sign:
|
|
9133
|
+
sign: [require_steps.hxfalcon()],
|
|
11632
9134
|
build: (p) => {
|
|
11633
9135
|
return {
|
|
11634
9136
|
method: "POST",
|
|
@@ -11649,19 +9151,17 @@ const userWorkList = defineEndpoint({
|
|
|
11649
9151
|
nextParams: (params, page) => ({
|
|
11650
9152
|
...params,
|
|
11651
9153
|
pcursor: page.data?.pcursor ?? ""
|
|
11652
|
-
})
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
|
|
11656
|
-
|
|
11657
|
-
|
|
11658
|
-
|
|
11659
|
-
|
|
11660
|
-
|
|
11661
|
-
|
|
11662
|
-
|
|
11663
|
-
result: page?.data?.result ?? 1
|
|
11664
|
-
};
|
|
9154
|
+
}),
|
|
9155
|
+
merge: ({ lastPage, items }, params) => {
|
|
9156
|
+
const pcursor = lastPage.data?.pcursor;
|
|
9157
|
+
return {
|
|
9158
|
+
principalId: params.principalId,
|
|
9159
|
+
list: items,
|
|
9160
|
+
pcursor: typeof pcursor === "string" ? pcursor : "",
|
|
9161
|
+
hasMore: typeof pcursor === "string" && pcursor.length > 0,
|
|
9162
|
+
result: lastPage.data?.result ?? 1
|
|
9163
|
+
};
|
|
9164
|
+
}
|
|
11665
9165
|
},
|
|
11666
9166
|
response: type()
|
|
11667
9167
|
});
|
|
@@ -11682,7 +9182,7 @@ const userWorkList = defineEndpoint({
|
|
|
11682
9182
|
* 第 8 条 `/fetch_danmaku_list` 是弹幕。它**完全免鉴权**(不签名、不要 cookie)。
|
|
11683
9183
|
*/
|
|
11684
9184
|
const kuaishouRegistry = {
|
|
11685
|
-
videoWork:
|
|
9185
|
+
videoWork: defineKuaishouEndpoint({
|
|
11686
9186
|
name: "kuaishou.videoWork",
|
|
11687
9187
|
route: "/fetch_one_work",
|
|
11688
9188
|
doc: {
|
|
@@ -11702,7 +9202,7 @@ const kuaishouRegistry = {
|
|
|
11702
9202
|
},
|
|
11703
9203
|
response: type()
|
|
11704
9204
|
}),
|
|
11705
|
-
videoWorkFull:
|
|
9205
|
+
videoWorkFull: defineKuaishouEndpoint({
|
|
11706
9206
|
name: "kuaishou.videoWorkFull",
|
|
11707
9207
|
route: "/fetch_one_work_full",
|
|
11708
9208
|
doc: {
|
|
@@ -11710,7 +9210,7 @@ const kuaishouRegistry = {
|
|
|
11710
9210
|
description: "日常取作品信息请用 `videoWork`;这条是唯一可能返回图集预渲染地址与同类推荐的通道。"
|
|
11711
9211
|
},
|
|
11712
9212
|
params: zod.default.object({ photoId: zod.default.string().min(1, { error: "photoId 不能为空" }).describe("作品 ID") }),
|
|
11713
|
-
sign:
|
|
9213
|
+
sign: [require_steps.hxfalcon()],
|
|
11714
9214
|
prepare: kuaishouDidPrepare,
|
|
11715
9215
|
build: (p) => {
|
|
11716
9216
|
const req = kuaishouApiUrls$1.videoWorkFull(p);
|
|
@@ -11956,7 +9456,7 @@ var kuaishouSign = class {
|
|
|
11956
9456
|
const secs = deriveKuaishouSecsState(count);
|
|
11957
9457
|
const scriptCount = globalThis.document?.scripts?.length ?? 0;
|
|
11958
9458
|
const catVersion = runtimeState.catVersion;
|
|
11959
|
-
const signResult = deriveKuaishouPureSignature({
|
|
9459
|
+
const signResult = require_steps.deriveKuaishouPureSignature({
|
|
11960
9460
|
count,
|
|
11961
9461
|
randomValue: Math.random(),
|
|
11962
9462
|
scriptCount,
|
|
@@ -12206,6 +9706,18 @@ const xiaohongshuApiUrls$1 = {
|
|
|
12206
9706
|
searchNotes: searchNotes$1
|
|
12207
9707
|
};
|
|
12208
9708
|
//#endregion
|
|
9709
|
+
//#region src/platforms/xiaohongshu/endpoints/define.ts
|
|
9710
|
+
/**
|
|
9711
|
+
* 小红书端点声明。
|
|
9712
|
+
*
|
|
9713
|
+
* 就是 {@link defineEndpoint} 绑定了小红书签名器名联合 {@link XiaohongshuSignerName}:
|
|
9714
|
+
* `sign` 的字符串分支被收窄成小红书那几个 `xhs-*` 名字,写错名字编译期即报错,不必
|
|
9715
|
+
* 等运行时查表失败。运行时仍是恒等函数,其余行为与通用 `defineEndpoint` 完全一致。
|
|
9716
|
+
* @param def - 端点声明(`sign` 的名字受小红书签名器表约束)
|
|
9717
|
+
* @returns 原样返回 `def`,带上推导好的具体类型
|
|
9718
|
+
*/
|
|
9719
|
+
const defineXiaohongshuEndpoint = (def) => defineEndpoint(def);
|
|
9720
|
+
//#endregion
|
|
12209
9721
|
//#region src/platforms/xiaohongshu/endpoints/emojiList.ts
|
|
12210
9722
|
/**
|
|
12211
9723
|
* 表情列表(GET,无参数)。
|
|
@@ -12213,7 +9725,7 @@ const xiaohongshuApiUrls$1 = {
|
|
|
12213
9725
|
* 没有业务参数 —— 用空对象 schema(`zod.object({})`),
|
|
12214
9726
|
* fetcher 方法可以不传 options。
|
|
12215
9727
|
*/
|
|
12216
|
-
const emojiList =
|
|
9728
|
+
const emojiList = defineXiaohongshuEndpoint({
|
|
12217
9729
|
name: "xiaohongshu.emojiList",
|
|
12218
9730
|
route: "/fetch_emoji_list",
|
|
12219
9731
|
doc: {
|
|
@@ -12229,7 +9741,7 @@ const emojiList = defineEndpoint({
|
|
|
12229
9741
|
signPath: apiPath
|
|
12230
9742
|
};
|
|
12231
9743
|
},
|
|
12232
|
-
sign: "
|
|
9744
|
+
sign: [require_steps.xs("get", "xys")],
|
|
12233
9745
|
response: type()
|
|
12234
9746
|
});
|
|
12235
9747
|
//#endregion
|
|
@@ -12244,7 +9756,7 @@ const emojiList = defineEndpoint({
|
|
|
12244
9756
|
* 进 trace)。已有 a1 直接跳过 —— guest cookie 流程有真实网络开销,
|
|
12245
9757
|
* 不能每次调用都重跑。
|
|
12246
9758
|
*/
|
|
12247
|
-
const homeFeed =
|
|
9759
|
+
const homeFeed = defineXiaohongshuEndpoint({
|
|
12248
9760
|
name: "xiaohongshu.homeFeed",
|
|
12249
9761
|
route: "/fetch_home_feed",
|
|
12250
9762
|
doc: {
|
|
@@ -12260,8 +9772,8 @@ const homeFeed = defineEndpoint({
|
|
|
12260
9772
|
search_key: zod.default.string().optional().describe("搜索关键词,默认空字符串")
|
|
12261
9773
|
}),
|
|
12262
9774
|
prepare: async (ctx) => {
|
|
12263
|
-
if (getCookieValue(ctx.cookie, "a1")) return {};
|
|
12264
|
-
return { cookie: await createXiaohongshuGuestCookie
|
|
9775
|
+
if (require_steps.getCookieValue(ctx.cookie, "a1")) return {};
|
|
9776
|
+
return { cookie: await require_steps.createXiaohongshuGuestCookie(ctx.send, ctx.requestConfig) };
|
|
12265
9777
|
},
|
|
12266
9778
|
build: (p) => {
|
|
12267
9779
|
const { Url, Body, apiPath } = homeFeed$1(p);
|
|
@@ -12272,7 +9784,7 @@ const homeFeed = defineEndpoint({
|
|
|
12272
9784
|
signPath: apiPath
|
|
12273
9785
|
};
|
|
12274
9786
|
},
|
|
12275
|
-
sign: "
|
|
9787
|
+
sign: [require_steps.xs("post", "xys"), require_steps.rap()],
|
|
12276
9788
|
response: type()
|
|
12277
9789
|
});
|
|
12278
9790
|
//#endregion
|
|
@@ -12287,7 +9799,7 @@ const homeFeed = defineEndpoint({
|
|
|
12287
9799
|
* `data.has_more` 是 boolean,与抖音的 `has_more === 1` 不同 ——
|
|
12288
9800
|
* 差异收敛在 `paginate` 声明里,管线不感知。
|
|
12289
9801
|
*/
|
|
12290
|
-
const noteComments =
|
|
9802
|
+
const noteComments = defineXiaohongshuEndpoint({
|
|
12291
9803
|
name: "xiaohongshu.noteComments",
|
|
12292
9804
|
route: "/fetch_note_comments",
|
|
12293
9805
|
doc: {
|
|
@@ -12308,7 +9820,7 @@ const noteComments = defineEndpoint({
|
|
|
12308
9820
|
extra: { signParams }
|
|
12309
9821
|
};
|
|
12310
9822
|
},
|
|
12311
|
-
sign: "
|
|
9823
|
+
sign: [require_steps.xs("get", "xys"), require_steps.traceId()],
|
|
12312
9824
|
paginate: {
|
|
12313
9825
|
maxPageSize: 50,
|
|
12314
9826
|
items: (page) => page.data?.comments ?? [],
|
|
@@ -12316,18 +9828,14 @@ const noteComments = defineEndpoint({
|
|
|
12316
9828
|
nextParams: (params, page) => ({
|
|
12317
9829
|
...params,
|
|
12318
9830
|
cursor: page.data?.cursor ?? ""
|
|
12319
|
-
})
|
|
12320
|
-
|
|
12321
|
-
|
|
12322
|
-
const { lastPage, items } = decoded;
|
|
12323
|
-
const page = lastPage;
|
|
12324
|
-
return {
|
|
12325
|
-
...page ?? {},
|
|
9831
|
+
}),
|
|
9832
|
+
merge: ({ lastPage, items }) => ({
|
|
9833
|
+
...lastPage,
|
|
12326
9834
|
data: {
|
|
12327
|
-
...
|
|
9835
|
+
...lastPage.data,
|
|
12328
9836
|
comments: items
|
|
12329
9837
|
}
|
|
12330
|
-
}
|
|
9838
|
+
})
|
|
12331
9839
|
},
|
|
12332
9840
|
response: type()
|
|
12333
9841
|
});
|
|
@@ -12338,7 +9846,7 @@ const noteComments = defineEndpoint({
|
|
|
12338
9846
|
*
|
|
12339
9847
|
* `note_id` 与 `xsec_token` 都要求 `min(1)`:空字符串会发出一个必败的请求。
|
|
12340
9848
|
*/
|
|
12341
|
-
const noteDetail =
|
|
9849
|
+
const noteDetail = defineXiaohongshuEndpoint({
|
|
12342
9850
|
name: "xiaohongshu.noteDetail",
|
|
12343
9851
|
route: "/fetch_one_note",
|
|
12344
9852
|
doc: {
|
|
@@ -12358,7 +9866,7 @@ const noteDetail = defineEndpoint({
|
|
|
12358
9866
|
signPath: apiPath
|
|
12359
9867
|
};
|
|
12360
9868
|
},
|
|
12361
|
-
sign: "
|
|
9869
|
+
sign: [require_steps.xs("post", "xys"), require_steps.rap()],
|
|
12362
9870
|
response: type()
|
|
12363
9871
|
});
|
|
12364
9872
|
//#endregion
|
|
@@ -12369,7 +9877,7 @@ const noteDetail = defineEndpoint({
|
|
|
12369
9877
|
* `search_id` 是每次搜索的随机 id:在 `build` 里显式生成并传给
|
|
12370
9878
|
* `api.searchNotes(data, searchId)`(api 保持纯函数,随机源归 `sign/`)。
|
|
12371
9879
|
*/
|
|
12372
|
-
const searchNotes =
|
|
9880
|
+
const searchNotes = defineXiaohongshuEndpoint({
|
|
12373
9881
|
name: "xiaohongshu.searchNotes",
|
|
12374
9882
|
route: "/fetch_search_notes",
|
|
12375
9883
|
doc: {
|
|
@@ -12382,7 +9890,7 @@ const searchNotes = defineEndpoint({
|
|
|
12382
9890
|
page_size: zod.default.coerce.number().int().min(1).max(100).optional().describe("每页条数,默认 20")
|
|
12383
9891
|
}),
|
|
12384
9892
|
build: (p) => {
|
|
12385
|
-
const { Url, Body, apiPath } = searchNotes$1(p, getSearchId());
|
|
9893
|
+
const { Url, Body, apiPath } = searchNotes$1(p, require_steps.getSearchId());
|
|
12386
9894
|
return {
|
|
12387
9895
|
method: "POST",
|
|
12388
9896
|
url: Url,
|
|
@@ -12390,7 +9898,7 @@ const searchNotes = defineEndpoint({
|
|
|
12390
9898
|
signPath: apiPath
|
|
12391
9899
|
};
|
|
12392
9900
|
},
|
|
12393
|
-
sign: "
|
|
9901
|
+
sign: [require_steps.xs("post", "xys"), require_steps.rap()],
|
|
12394
9902
|
response: type()
|
|
12395
9903
|
});
|
|
12396
9904
|
//#endregion
|
|
@@ -12402,7 +9910,7 @@ const searchNotes = defineEndpoint({
|
|
|
12402
9910
|
* XYS_ 签名会被平台以 HTTP 406 拒绝,因此用 `'xhs-get-xyw-trace'`
|
|
12403
9911
|
* (XYW_ GET 签名 + traceid),而不是给所有 GET 都换。
|
|
12404
9912
|
*/
|
|
12405
|
-
const userNoteList =
|
|
9913
|
+
const userNoteList = defineXiaohongshuEndpoint({
|
|
12406
9914
|
name: "xiaohongshu.userNoteList",
|
|
12407
9915
|
route: "/fetch_user_notes",
|
|
12408
9916
|
doc: {
|
|
@@ -12424,7 +9932,7 @@ const userNoteList = defineEndpoint({
|
|
|
12424
9932
|
extra: { signParams }
|
|
12425
9933
|
};
|
|
12426
9934
|
},
|
|
12427
|
-
sign: "
|
|
9935
|
+
sign: [require_steps.xs("get", "xyw"), require_steps.traceId()],
|
|
12428
9936
|
response: type()
|
|
12429
9937
|
});
|
|
12430
9938
|
//#endregion
|
|
@@ -12441,7 +9949,7 @@ const xiaohongshuRegistry = {
|
|
|
12441
9949
|
homeFeed,
|
|
12442
9950
|
noteDetail,
|
|
12443
9951
|
noteComments,
|
|
12444
|
-
userProfile:
|
|
9952
|
+
userProfile: defineXiaohongshuEndpoint({
|
|
12445
9953
|
name: "xiaohongshu.userProfile",
|
|
12446
9954
|
route: "/fetch_user_profile",
|
|
12447
9955
|
doc: {
|
|
@@ -12458,7 +9966,7 @@ const xiaohongshuRegistry = {
|
|
|
12458
9966
|
responseType: "text"
|
|
12459
9967
|
};
|
|
12460
9968
|
},
|
|
12461
|
-
sign: "
|
|
9969
|
+
sign: [require_steps.xs("get", "xys")],
|
|
12462
9970
|
decode: (raw) => {
|
|
12463
9971
|
if (typeof raw !== "string") return raw;
|
|
12464
9972
|
const match = raw.match(/<script>window\.__INITIAL_STATE__=(.+)<\/script>/m);
|
|
@@ -12576,7 +10084,7 @@ const unwrapScriptingResponse = (data) => {
|
|
|
12576
10084
|
* scripting、webprofile 与 activate 三个会话请求,并返回最终 Cookie 字符串。
|
|
12577
10085
|
*/
|
|
12578
10086
|
const createXiaohongshuGuestCookie = async (requestConfig) => {
|
|
12579
|
-
const cryptoConfig = createXiaohongshuCryptoConfig();
|
|
10087
|
+
const cryptoConfig = require_steps.createXiaohongshuCryptoConfig();
|
|
12580
10088
|
const signer = new _ikenxuan_xhshow_ts.Xhshow(cryptoConfig);
|
|
12581
10089
|
const cookies = {
|
|
12582
10090
|
...generateA1AndWebId(),
|
|
@@ -12630,7 +10138,7 @@ const createXiaohongshuGuestCookie = async (requestConfig) => {
|
|
|
12630
10138
|
* 小红书签名算法类
|
|
12631
10139
|
*/
|
|
12632
10140
|
var xiaohongshuSign = class {
|
|
12633
|
-
static client = new _ikenxuan_xhshow_ts.Xhshow(createXiaohongshuCryptoConfig());
|
|
10141
|
+
static client = new _ikenxuan_xhshow_ts.Xhshow(require_steps.createXiaohongshuCryptoConfig());
|
|
12634
10142
|
/**
|
|
12635
10143
|
* 创建包含 web_session 的小红书 Web 端游客 Cookie。
|
|
12636
10144
|
*
|
|
@@ -12693,7 +10201,7 @@ var xiaohongshuSign = class {
|
|
|
12693
10201
|
* @returns a1 cookie值
|
|
12694
10202
|
*/
|
|
12695
10203
|
static extractA1FromCookie(cookieString) {
|
|
12696
|
-
return getCookieValue(cookieString, "a1") ?? "";
|
|
10204
|
+
return require_steps.getCookieValue(cookieString, "a1") ?? "";
|
|
12697
10205
|
}
|
|
12698
10206
|
/**
|
|
12699
10207
|
* 生成搜索ID
|
|
@@ -54020,7 +51528,7 @@ const buildOpenApiSpec = (options = {}) => {
|
|
|
54020
51528
|
openapi: "3.1.0",
|
|
54021
51529
|
info: {
|
|
54022
51530
|
title: "amagi HTTP API",
|
|
54023
|
-
version: options.version ?? "7.0.0-beta.
|
|
51531
|
+
version: options.version ?? "7.0.0-beta.6",
|
|
54024
51532
|
description: [
|
|
54025
51533
|
"由端点注册表派生的规范 —— 手写的那份已经漂移,这份不会(`gen-openapi --check` 进 CI)。",
|
|
54026
51534
|
"",
|
|
@@ -54076,38 +51584,12 @@ const mountOpenApiSpec = (app) => {
|
|
|
54076
51584
|
};
|
|
54077
51585
|
//#endregion
|
|
54078
51586
|
//#region src/client/request/profile.ts
|
|
54079
|
-
/**
|
|
54080
|
-
* 抖音:重抽 URL 里已有的 `msToken`。
|
|
54081
|
-
*
|
|
54082
|
-
* Argus 按整组 token 判定,只换 `a_bogus` 不够。`msToken` 是调用方的 URL
|
|
54083
|
-
* 构造器写进去的(这一层**不补**参数),所以能换的只有「URL 里本来就有」的
|
|
54084
|
-
* 那一种情况 —— 长度保持原样(作品详情 184、其余 116),因为长度本身就是
|
|
54085
|
-
* 参数的一部分。
|
|
54086
|
-
* @param spec - 上一次用的请求描述
|
|
54087
|
-
* @returns 换过 msToken 的请求描述;没有可换的就原样返回
|
|
54088
|
-
*/
|
|
54089
|
-
const refreshDouyinMsToken = (spec) => {
|
|
54090
|
-
let url;
|
|
54091
|
-
try {
|
|
54092
|
-
url = new URL(spec.url);
|
|
54093
|
-
} catch {
|
|
54094
|
-
return spec;
|
|
54095
|
-
}
|
|
54096
|
-
const current = url.searchParams.get("msToken");
|
|
54097
|
-
if (current === null || current === "") return spec;
|
|
54098
|
-
url.searchParams.set("msToken", douyinSign$1.Mstoken(current.length));
|
|
54099
|
-
return {
|
|
54100
|
-
...spec,
|
|
54101
|
-
url: url.toString()
|
|
54102
|
-
};
|
|
54103
|
-
};
|
|
54104
51587
|
/** 平台档案表。四个平台必须齐全 —— 少一项不会编译报错,会静默不签名 */
|
|
54105
51588
|
const PROFILES = {
|
|
54106
51589
|
douyin: {
|
|
54107
51590
|
defaultSign: "a-bogus",
|
|
54108
51591
|
retryOn: ["ANTIBOT_PAGE"],
|
|
54109
|
-
retryFresh: true
|
|
54110
|
-
refresh: refreshDouyinMsToken
|
|
51592
|
+
retryFresh: true
|
|
54111
51593
|
},
|
|
54112
51594
|
bilibili: {
|
|
54113
51595
|
defaultSign: false,
|
|
@@ -54241,10 +51723,15 @@ const buildRequestSpec = (config, url, amagi) => ({
|
|
|
54241
51723
|
/**
|
|
54242
51724
|
* 现场合成一个一次性的端点声明。
|
|
54243
51725
|
*
|
|
54244
|
-
* `build`
|
|
54245
|
-
*
|
|
54246
|
-
*
|
|
54247
|
-
*
|
|
51726
|
+
* `build` 恒返回同一个 `spec`,**这不等于「重试会原样重放」**:`execute` 在 `retryFresh`
|
|
51727
|
+
* 重试时会把 `build` 再调一次、**并紧接着重新签名**(见 `runtime/execute.ts` 的
|
|
51728
|
+
* `rebuildAt`),换参数是后者干的 —— 抖音的 `msToken` / `a_bogus` 都出自签名 step,
|
|
51729
|
+
* 每次签名现生成,所以重试拿到的是一整套新 token。
|
|
51730
|
+
*
|
|
51731
|
+
* 这里曾经有一个「返回当前值的同时把下一个算好」的双缓冲写法,配合平台档案的
|
|
51732
|
+
* `refresh` 钩子重抽 URL 里的 `msToken`。msToken 下沉到签名 step 之后那个钩子
|
|
51733
|
+
* 恒为无操作(URL 里本来就没有它可抽),连同这套机制一起删了 —— 裸请求的 `spec`
|
|
51734
|
+
* 是调用方给的、本身没有「下一次该长什么样」这种状态。
|
|
54248
51735
|
* @param platform - 平台
|
|
54249
51736
|
* @param spec - 本次调用的请求描述
|
|
54250
51737
|
* @param method - HTTP 方法(小红书按它推默认签名器)
|
|
@@ -54253,16 +51740,11 @@ const buildRequestSpec = (config, url, amagi) => ({
|
|
|
54253
51740
|
*/
|
|
54254
51741
|
const makeRequestDef = (platform, spec, method, amagi) => {
|
|
54255
51742
|
const profile = requestProfileOf(platform);
|
|
54256
|
-
let current = spec;
|
|
54257
51743
|
return defineEndpoint({
|
|
54258
51744
|
name: `${platform}.request`,
|
|
54259
51745
|
route: REQUEST_ROUTE,
|
|
54260
51746
|
params: zod.default.object({}),
|
|
54261
|
-
build: () =>
|
|
54262
|
-
const out = current;
|
|
54263
|
-
current = profile.refresh ? profile.refresh(spec) : spec;
|
|
54264
|
-
return out;
|
|
54265
|
-
},
|
|
51747
|
+
build: () => spec,
|
|
54266
51748
|
sign: amagi.sign ?? resolveDefaultSign(profile, method),
|
|
54267
51749
|
...profile.retryOn === void 0 ? {} : { retryOn: profile.retryOn },
|
|
54268
51750
|
...profile.retryFresh === void 0 ? {} : { retryFresh: profile.retryFresh }
|
|
@@ -54603,17 +52085,6 @@ const createStaticFetcher = (platform, registry) => {
|
|
|
54603
52085
|
* ```
|
|
54604
52086
|
*/
|
|
54605
52087
|
const bilibiliFetcher = createStaticFetcher("bilibili", bilibiliRegistry);
|
|
54606
|
-
/**
|
|
54607
|
-
* 创建绑定了 Cookie 和请求配置的B站 Fetcher
|
|
54608
|
-
* @param cookie - B站 Cookie
|
|
54609
|
-
* @param requestConfig - 请求配置 (可选)
|
|
54610
|
-
* @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
|
|
54611
|
-
* @example
|
|
54612
|
-
* ```typescript
|
|
54613
|
-
* const fetcher = createBoundBilibiliFetcher('your_cookie')
|
|
54614
|
-
* const result = await fetcher.fetchVideoInfo({ bvid: 'BV1xx411c7mD' })
|
|
54615
|
-
* ```
|
|
54616
|
-
*/
|
|
54617
52088
|
const createBoundBilibiliFetcher = (cookie, requestConfig) => createFetcherFromRegistry("bilibili", bilibiliRegistry, makeClientCtx("bilibili", cookie, requestConfig, "bound-bilibili"));
|
|
54618
52089
|
//#endregion
|
|
54619
52090
|
//#region src/model/fetchers/douyin/auth.ts
|
|
@@ -54861,17 +52332,6 @@ const createBoundDouyinFetcher = (cookie, requestConfig) => createFetcherFromReg
|
|
|
54861
52332
|
* ```
|
|
54862
52333
|
*/
|
|
54863
52334
|
const kuaishouFetcher = createStaticFetcher("kuaishou", kuaishouRegistry);
|
|
54864
|
-
/**
|
|
54865
|
-
* 创建绑定了 Cookie 和请求配置的快手 Fetcher
|
|
54866
|
-
* @param cookie - 快手 Cookie
|
|
54867
|
-
* @param requestConfig - 请求配置 (可选)
|
|
54868
|
-
* @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
|
|
54869
|
-
* @example
|
|
54870
|
-
* ```typescript
|
|
54871
|
-
* const fetcher = createBoundKuaishouFetcher('your_cookie')
|
|
54872
|
-
* const result = await fetcher.fetchVideoWork({ photoId: '3x123456789' })
|
|
54873
|
-
* ```
|
|
54874
|
-
*/
|
|
54875
52335
|
const createBoundKuaishouFetcher = (cookie, requestConfig) => createFetcherFromRegistry("kuaishou", kuaishouRegistry, makeClientCtx("kuaishou", cookie, requestConfig, "bound-kuaishou"));
|
|
54876
52336
|
//#endregion
|
|
54877
52337
|
//#region src/model/fetchers/xiaohongshu/index.ts
|
|
@@ -54894,17 +52354,6 @@ const createBoundKuaishouFetcher = (cookie, requestConfig) => createFetcherFromR
|
|
|
54894
52354
|
* ```
|
|
54895
52355
|
*/
|
|
54896
52356
|
const xiaohongshuFetcher = createStaticFetcher("xiaohongshu", xiaohongshuRegistry);
|
|
54897
|
-
/**
|
|
54898
|
-
* 创建绑定了 Cookie 和请求配置的小红书 Fetcher
|
|
54899
|
-
* @param cookie - 小红书 Cookie
|
|
54900
|
-
* @param requestConfig - 请求配置 (可选)
|
|
54901
|
-
* @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
|
|
54902
|
-
* @example
|
|
54903
|
-
* ```typescript
|
|
54904
|
-
* const fetcher = createBoundXiaohongshuFetcher('your_cookie')
|
|
54905
|
-
* const result = await fetcher.fetchNoteDetail({ note_id: 'n1', xsec_token: 'tk' })
|
|
54906
|
-
* ```
|
|
54907
|
-
*/
|
|
54908
52357
|
const createBoundXiaohongshuFetcher = (cookie, requestConfig) => createFetcherFromRegistry("xiaohongshu", xiaohongshuRegistry, makeClientCtx("xiaohongshu", cookie, requestConfig, "bound-xiaohongshu"));
|
|
54909
52358
|
//#endregion
|
|
54910
52359
|
//#region src/utils/errors.ts
|
|
@@ -56843,7 +54292,7 @@ let DynamicType = /* @__PURE__ */ function(DynamicType) {
|
|
|
56843
54292
|
* 构建后使用 __VERSION__,开发环境从 package.json 读取
|
|
56844
54293
|
*/
|
|
56845
54294
|
const getVersion = () => {
|
|
56846
|
-
return "7.0.0-beta.
|
|
54295
|
+
return "7.0.0-beta.6";
|
|
56847
54296
|
};
|
|
56848
54297
|
const VERSION = getVersion();
|
|
56849
54298
|
/**
|