@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,13 +1,13 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
+
import { C as wbi, S as qtparam$1, _ as douyinSign$1, a as createXiaohongshuGuestCookie$1, b as getCookieValue, c as sharedKuaishouSigner, d as douyinBogus, f as douyinXBogus, g as applySecsdkWebSign, h as xBogus, i as getSearchId, l as deriveKuaishouPureSignature, m as secsdk, n as traceId, o as createXiaohongshuCryptoConfig, p as msToken, r as xs, s as hxfalcon, t as rap, u as aBogus$1, v as XBogus, x as AmagiHeaders, y as observeDouyinWebid } from "./steps-DQlwODdh.mjs";
|
|
2
3
|
import { A as a_bogus_default, M as buildBrowserInfo, d as genVerifyFp, m as ABogus, o as TTWID_REGISTER_URL, r as DOUYIN_TTWID } from "./tokens-BFqSOfhx.mjs";
|
|
3
|
-
import URL$1 from "node:url";
|
|
4
4
|
import express, { Router } from "express";
|
|
5
5
|
import zod from "zod";
|
|
6
|
+
import crypto, { createCipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
6
7
|
import protobuf from "protobufjs";
|
|
7
8
|
import axios, { AxiosError, AxiosHeaders, mergeConfig } from "axios";
|
|
8
9
|
import { EventEmitter } from "node:events";
|
|
9
|
-
import
|
|
10
|
-
import { CryptoConfig, FingerprintGenerator, Xhshow, xRapParam } from "@ikenxuan/xhshow-ts";
|
|
10
|
+
import { FingerprintGenerator, Xhshow } from "@ikenxuan/xhshow-ts";
|
|
11
11
|
//#region src/platforms/bilibili/api.ts
|
|
12
12
|
/** B站 API URL 构建类(所有方法只拼 URL,不发起请求) */
|
|
13
13
|
var BilibiliAPI$1 = class {
|
|
@@ -169,16 +169,54 @@ const bilibiliApiUrls$1 = new BilibiliAPI$1();
|
|
|
169
169
|
* @returns 携带 `T` 的令牌,运行时是个空对象
|
|
170
170
|
*/
|
|
171
171
|
const type = () => ({});
|
|
172
|
+
/** 反爬参数的执行阶段次序:端点作者不用手排,{@link stepsToSigner} 按这个固定次序排好再跑 */
|
|
173
|
+
const PHASE_ORDER = {
|
|
174
|
+
prepare: 0,
|
|
175
|
+
token: 1,
|
|
176
|
+
sign: 2,
|
|
177
|
+
finalize: 3
|
|
178
|
+
};
|
|
179
|
+
/**
|
|
180
|
+
* 把一串 {@link SignStep} 压成一个签名器:按 {@link SignPhase} 稳定排序后依次 `apply`。
|
|
181
|
+
* 同 phase 内保留数组出现顺序(`index` 兜底),所以 `[aBogus(), msToken()]` 与
|
|
182
|
+
* `[msToken(), aBogus()]` 结果一致 —— token 阶段恒先于 sign,端点作者写错顺序也不翻车。
|
|
183
|
+
*
|
|
184
|
+
* 两条路径共用它、因此共用同一套排序语义:runtime 解析 {@link SignDecl} 里的 step 清单时用它,
|
|
185
|
+
* 平台签名器表(`sign/signers.ts` 那层薄壳)也用它把注册名对应的 step 清单压成一个 {@link SignFn}。
|
|
186
|
+
* @param steps - 反爬参数清单
|
|
187
|
+
* @returns 依次应用全部步骤的签名函数
|
|
188
|
+
*/
|
|
189
|
+
const stepsToSigner = (steps) => {
|
|
190
|
+
const ordered = steps.map((step, index) => ({
|
|
191
|
+
step,
|
|
192
|
+
index
|
|
193
|
+
})).sort((a, b) => PHASE_ORDER[a.step.phase] - PHASE_ORDER[b.step.phase] || a.index - b.index).map((entry) => entry.step);
|
|
194
|
+
return async (spec, ctx) => {
|
|
195
|
+
let current = spec;
|
|
196
|
+
for (const step of ordered) current = await step.apply(current, ctx);
|
|
197
|
+
return current;
|
|
198
|
+
};
|
|
199
|
+
};
|
|
172
200
|
/**
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
* `TData` 从 `response` / `normalize` / `compute` 推出。
|
|
201
|
+
* 声明一个端点。运行时是恒等函数(原样返回 `def`),全部价值在类型推导:
|
|
202
|
+
* `TParams` 从 `params` 推、`TData` 从 `response` / `compute` 推。
|
|
203
|
+
* 平台一般用各自的 `defineXxxEndpoint`(把签名器名收窄),不直接用这个。
|
|
177
204
|
* @param def - 端点声明
|
|
178
|
-
* @returns 原样返回 `def`,但带上推导好的具体类型
|
|
179
205
|
*/
|
|
180
206
|
const defineEndpoint = (def) => def;
|
|
181
207
|
//#endregion
|
|
208
|
+
//#region src/platforms/bilibili/endpoints/define.ts
|
|
209
|
+
/**
|
|
210
|
+
* B站端点声明。
|
|
211
|
+
*
|
|
212
|
+
* 就是 {@link defineEndpoint} 绑定了 B站签名器名联合 {@link BilibiliSignerName}:
|
|
213
|
+
* `sign` 的字符串分支被收窄成 `'wbi' | 'qtparam'`,写错名字编译期即报错,不必等
|
|
214
|
+
* 运行时查表失败。运行时仍是恒等函数,其余行为与通用 `defineEndpoint` 完全一致。
|
|
215
|
+
* @param def - 端点声明(`sign` 的名字受 B站签名器表约束)
|
|
216
|
+
* @returns 原样返回 `def`,带上推导好的具体类型
|
|
217
|
+
*/
|
|
218
|
+
const defineBilibiliEndpoint = (def) => defineEndpoint(def);
|
|
219
|
+
//#endregion
|
|
182
220
|
//#region src/platforms/bilibili/endpoints/articleCards.ts
|
|
183
221
|
/**
|
|
184
222
|
* 专栏显示卡片信息(单请求)。
|
|
@@ -186,7 +224,7 @@ const defineEndpoint = (def) => def;
|
|
|
186
224
|
* 与旧版一致:`getArticleCards` GET,无签名。
|
|
187
225
|
* `ids` 可传字符串或字符串数组。
|
|
188
226
|
*/
|
|
189
|
-
const articleCards =
|
|
227
|
+
const articleCards = defineBilibiliEndpoint({
|
|
190
228
|
name: "bilibili.articleCards",
|
|
191
229
|
route: "/fetch_article_card",
|
|
192
230
|
doc: {
|
|
@@ -208,7 +246,7 @@ const articleCards = defineEndpoint({
|
|
|
208
246
|
*
|
|
209
247
|
* 与旧版一致:`getArticleContent` GET,无签名。
|
|
210
248
|
*/
|
|
211
|
-
const articleContent =
|
|
249
|
+
const articleContent = defineBilibiliEndpoint({
|
|
212
250
|
name: "bilibili.articleContent",
|
|
213
251
|
route: "/fetch_article_content",
|
|
214
252
|
doc: {
|
|
@@ -230,7 +268,7 @@ const articleContent = defineEndpoint({
|
|
|
230
268
|
*
|
|
231
269
|
* 与旧版一致:`getArticleInfo` GET,无签名。
|
|
232
270
|
*/
|
|
233
|
-
const articleInfo =
|
|
271
|
+
const articleInfo = defineBilibiliEndpoint({
|
|
234
272
|
name: "bilibili.articleInfo",
|
|
235
273
|
route: "/fetch_article_info",
|
|
236
274
|
doc: {
|
|
@@ -252,7 +290,7 @@ const articleInfo = defineEndpoint({
|
|
|
252
290
|
*
|
|
253
291
|
* 与旧版一致:`getArticleListInfo` GET,无签名。
|
|
254
292
|
*/
|
|
255
|
-
const articleListInfo =
|
|
293
|
+
const articleListInfo = defineBilibiliEndpoint({
|
|
256
294
|
name: "bilibili.articleListInfo",
|
|
257
295
|
route: "/fetch_column_info",
|
|
258
296
|
doc: {
|
|
@@ -328,7 +366,7 @@ const bv2av = (bvid) => {
|
|
|
328
366
|
*
|
|
329
367
|
* 返回形状:`{ bvid }`。
|
|
330
368
|
*/
|
|
331
|
-
const avToBv =
|
|
369
|
+
const avToBv = defineBilibiliEndpoint({
|
|
332
370
|
name: "bilibili.avToBv",
|
|
333
371
|
route: "/av_to_bv",
|
|
334
372
|
doc: {
|
|
@@ -347,7 +385,7 @@ const avToBv = defineEndpoint({
|
|
|
347
385
|
* 与旧版一致:`getBangumiInfo` GET,无签名。
|
|
348
386
|
* `ep_id` 与 `season_id` 至少传其一,两者都要求非空串(`ep_id` 优先)。
|
|
349
387
|
*/
|
|
350
|
-
const bangumiInfo =
|
|
388
|
+
const bangumiInfo = defineBilibiliEndpoint({
|
|
351
389
|
name: "bilibili.bangumiInfo",
|
|
352
390
|
route: "/fetch_bangumi_video_info",
|
|
353
391
|
doc: {
|
|
@@ -381,7 +419,7 @@ const bangumiInfo = defineEndpoint({
|
|
|
381
419
|
* 与旧版一致:`getBangumiStream` GET + qtparam 签名,
|
|
382
420
|
* `ep_id` 去掉 `ep` 前缀。
|
|
383
421
|
*/
|
|
384
|
-
const bangumiStream =
|
|
422
|
+
const bangumiStream = defineBilibiliEndpoint({
|
|
385
423
|
name: "bilibili.bangumiStream",
|
|
386
424
|
route: "/fetch_bangumi_video_playurl",
|
|
387
425
|
doc: {
|
|
@@ -399,7 +437,7 @@ const bangumiStream = defineEndpoint({
|
|
|
399
437
|
ep_id: p.ep_id.replace("ep", "")
|
|
400
438
|
})
|
|
401
439
|
}),
|
|
402
|
-
sign:
|
|
440
|
+
sign: [qtparam$1()],
|
|
403
441
|
retryOn: ["RISK_CONTROL"],
|
|
404
442
|
response: type()
|
|
405
443
|
});
|
|
@@ -412,7 +450,7 @@ const bangumiStream = defineEndpoint({
|
|
|
412
450
|
*
|
|
413
451
|
* 返回 `{ aid: number }`,不带 `av` 前缀。
|
|
414
452
|
*/
|
|
415
|
-
const bvToAv =
|
|
453
|
+
const bvToAv = defineBilibiliEndpoint({
|
|
416
454
|
name: "bilibili.bvToAv",
|
|
417
455
|
route: "/bv_to_av",
|
|
418
456
|
doc: {
|
|
@@ -431,7 +469,7 @@ const bvToAv = defineEndpoint({
|
|
|
431
469
|
* 与旧版一致:`getCaptchaFromVoucher` POST,
|
|
432
470
|
* body 为 `{ csrf?, v_voucher }`,无签名。
|
|
433
471
|
*/
|
|
434
|
-
const captchaFromVoucher =
|
|
472
|
+
const captchaFromVoucher = defineBilibiliEndpoint({
|
|
435
473
|
name: "bilibili.captchaFromVoucher",
|
|
436
474
|
route: "/apply_captcha",
|
|
437
475
|
doc: {
|
|
@@ -461,7 +499,7 @@ const captchaFromVoucher = defineEndpoint({
|
|
|
461
499
|
*
|
|
462
500
|
* 与旧版一致:`getCommentReplies` GET,无签名。
|
|
463
501
|
*/
|
|
464
|
-
const commentReplies$1 =
|
|
502
|
+
const commentReplies$1 = defineBilibiliEndpoint({
|
|
465
503
|
name: "bilibili.commentReplies",
|
|
466
504
|
route: "/fetch_comment_reply",
|
|
467
505
|
doc: {
|
|
@@ -524,7 +562,7 @@ const COMMENT_TYPES$1 = [
|
|
|
524
562
|
* 按 `rpid` 去重 + 截断到 `number`。
|
|
525
563
|
* - 未开放评论区由主接口返回非 0 的 `code`,由 judge 判失败。
|
|
526
564
|
*/
|
|
527
|
-
const comments$2 =
|
|
565
|
+
const comments$2 = defineBilibiliEndpoint({
|
|
528
566
|
name: "bilibili.comments",
|
|
529
567
|
route: "/fetch_work_comments",
|
|
530
568
|
doc: {
|
|
@@ -549,7 +587,7 @@ const comments$2 = defineEndpoint({
|
|
|
549
587
|
mode: p.mode
|
|
550
588
|
})
|
|
551
589
|
}),
|
|
552
|
-
sign:
|
|
590
|
+
sign: [wbi()],
|
|
553
591
|
paginate: {
|
|
554
592
|
maxPageSize: 100,
|
|
555
593
|
items: (page) => page.data?.replies ?? [],
|
|
@@ -563,20 +601,18 @@ const comments$2 = defineEndpoint({
|
|
|
563
601
|
...params,
|
|
564
602
|
pagination_str: next ?? params.pagination_str
|
|
565
603
|
};
|
|
604
|
+
},
|
|
605
|
+
merge: ({ lastPage, items }, params) => {
|
|
606
|
+
const sliced = Array.from(new Map(items.map((item) => [item.rpid, item])).values()).slice(0, params.number ?? 20);
|
|
607
|
+
return {
|
|
608
|
+
...lastPage,
|
|
609
|
+
data: {
|
|
610
|
+
...lastPage.data,
|
|
611
|
+
replies: sliced
|
|
612
|
+
}
|
|
613
|
+
};
|
|
566
614
|
}
|
|
567
615
|
},
|
|
568
|
-
normalize: (decoded, params) => {
|
|
569
|
-
const { lastPage, items } = decoded;
|
|
570
|
-
const page = lastPage;
|
|
571
|
-
const sliced = Array.from(new Map(items.map((item) => [item.rpid, item])).values()).slice(0, params.number ?? 20);
|
|
572
|
-
return {
|
|
573
|
-
...page ?? {},
|
|
574
|
-
data: {
|
|
575
|
-
...page?.data ?? {},
|
|
576
|
-
replies: sliced
|
|
577
|
-
}
|
|
578
|
-
};
|
|
579
|
-
},
|
|
580
616
|
retryOn: ["RISK_CONTROL"],
|
|
581
617
|
response: type()
|
|
582
618
|
});
|
|
@@ -612,7 +648,7 @@ const COMMENT_TYPES = [
|
|
|
612
648
|
*
|
|
613
649
|
* 与旧版一致:`getDynamicDetail` GET,无签名。
|
|
614
650
|
*/
|
|
615
|
-
const dynamicDetail =
|
|
651
|
+
const dynamicDetail = defineBilibiliEndpoint({
|
|
616
652
|
name: "bilibili.dynamicDetail",
|
|
617
653
|
route: "/fetch_dynamic_info",
|
|
618
654
|
doc: {
|
|
@@ -635,7 +671,7 @@ const dynamicDetail = defineEndpoint({
|
|
|
635
671
|
* 与旧版一致:`getEmojiList` GET,无签名。
|
|
636
672
|
* 它是网络请求(表情面板接口),不是本地计算。
|
|
637
673
|
*/
|
|
638
|
-
const emojiList$4 =
|
|
674
|
+
const emojiList$4 = defineBilibiliEndpoint({
|
|
639
675
|
name: "bilibili.emojiList",
|
|
640
676
|
route: "/fetch_emoji_list",
|
|
641
677
|
doc: {
|
|
@@ -657,7 +693,7 @@ const emojiList$4 = defineEndpoint({
|
|
|
657
693
|
*
|
|
658
694
|
* 与旧版一致:`getLiveRoomInfo` GET,无签名。
|
|
659
695
|
*/
|
|
660
|
-
const liveRoomInfo$2 =
|
|
696
|
+
const liveRoomInfo$2 = defineBilibiliEndpoint({
|
|
661
697
|
name: "bilibili.liveRoomInfo",
|
|
662
698
|
route: "/fetch_live_room_detail",
|
|
663
699
|
doc: {
|
|
@@ -679,7 +715,7 @@ const liveRoomInfo$2 = defineEndpoint({
|
|
|
679
715
|
*
|
|
680
716
|
* 与旧版一致:`getLiveRoomInit` GET,无签名。
|
|
681
717
|
*/
|
|
682
|
-
const liveRoomInit =
|
|
718
|
+
const liveRoomInit = defineBilibiliEndpoint({
|
|
683
719
|
name: "bilibili.liveRoomInit",
|
|
684
720
|
route: "/fetch_liveroom_def",
|
|
685
721
|
doc: {
|
|
@@ -701,7 +737,7 @@ const liveRoomInit = defineEndpoint({
|
|
|
701
737
|
*
|
|
702
738
|
* 与旧版一致:`getLoginQrcode` GET,无签名。
|
|
703
739
|
*/
|
|
704
|
-
const loginQrcode$1 =
|
|
740
|
+
const loginQrcode$1 = defineBilibiliEndpoint({
|
|
705
741
|
name: "bilibili.loginQrcode",
|
|
706
742
|
route: "/new_login_qrcode",
|
|
707
743
|
doc: {
|
|
@@ -723,7 +759,7 @@ const loginQrcode$1 = defineEndpoint({
|
|
|
723
759
|
*
|
|
724
760
|
* 与旧版一致:`getLoginStatus` GET,无签名。
|
|
725
761
|
*/
|
|
726
|
-
const loginStatus =
|
|
762
|
+
const loginStatus = defineBilibiliEndpoint({
|
|
727
763
|
name: "bilibili.loginStatus",
|
|
728
764
|
route: "/login_basic_info",
|
|
729
765
|
doc: {
|
|
@@ -746,7 +782,7 @@ const loginStatus = defineEndpoint({
|
|
|
746
782
|
* 与旧版一致:`getQrcodeStatus` GET,无签名。
|
|
747
783
|
* **返回形状**:只返回平台响应体(`code` / `data` / `message`),不透出响应 headers。
|
|
748
784
|
*/
|
|
749
|
-
const qrcodeStatus =
|
|
785
|
+
const qrcodeStatus = defineBilibiliEndpoint({
|
|
750
786
|
name: "bilibili.qrcodeStatus",
|
|
751
787
|
route: "/check_qrcode",
|
|
752
788
|
doc: {
|
|
@@ -768,7 +804,7 @@ const qrcodeStatus = defineEndpoint({
|
|
|
768
804
|
*
|
|
769
805
|
* 与旧版一致:`getUploaderTotalViews` GET,无签名。
|
|
770
806
|
*/
|
|
771
|
-
const uploaderTotalViews =
|
|
807
|
+
const uploaderTotalViews = defineBilibiliEndpoint({
|
|
772
808
|
name: "bilibili.uploaderTotalViews",
|
|
773
809
|
route: "/fetch_user_full_view",
|
|
774
810
|
doc: {
|
|
@@ -790,7 +826,7 @@ const uploaderTotalViews = defineEndpoint({
|
|
|
790
826
|
*
|
|
791
827
|
* 与旧版一致:`getUserCard` GET,无签名。
|
|
792
828
|
*/
|
|
793
|
-
const userCard =
|
|
829
|
+
const userCard = defineBilibiliEndpoint({
|
|
794
830
|
name: "bilibili.userCard",
|
|
795
831
|
route: "/fetch_user_profile",
|
|
796
832
|
doc: {
|
|
@@ -806,112 +842,6 @@ const userCard = defineEndpoint({
|
|
|
806
842
|
response: type()
|
|
807
843
|
});
|
|
808
844
|
//#endregion
|
|
809
|
-
//#region src/contracts/request.ts
|
|
810
|
-
/**
|
|
811
|
-
* 大小写不敏感的 header 容器。
|
|
812
|
-
*
|
|
813
|
-
* HTTP header 名本身大小写不敏感,用普通对象装就会出现「写了 `Cookie` 却读
|
|
814
|
-
* `cookie` 读不到」这类静默失配。本类把「大小写」这个变量彻底消掉。
|
|
815
|
-
*
|
|
816
|
-
* 语义:
|
|
817
|
-
* - 查找、判断、删除全部大小写不敏感。
|
|
818
|
-
* - 写入是「后写覆盖」:值覆盖,**显示用的大小写也跟着最后一次写入**,
|
|
819
|
-
* 所以 `set('User-Agent', a)` 之后 `set('user-agent', b)` 只会留下一个
|
|
820
|
-
* `user-agent: b`,不可能出现两条同名 header。
|
|
821
|
-
* - `undefined` / `null` 的值视为「不写这个 header」,方便直接摊入可选字段。
|
|
822
|
-
*/
|
|
823
|
-
var AmagiHeaders = class AmagiHeaders {
|
|
824
|
-
/** key 是小写化的 header 名,value 保留最后一次写入的原始大小写与值 */
|
|
825
|
-
entries = /* @__PURE__ */ new Map();
|
|
826
|
-
/**
|
|
827
|
-
* @param init - 初始 header,可以是另一个 `AmagiHeaders` 或普通对象
|
|
828
|
-
*/
|
|
829
|
-
constructor(init) {
|
|
830
|
-
this.merge(init);
|
|
831
|
-
}
|
|
832
|
-
/**
|
|
833
|
-
* 读取 header 值,大小写不敏感
|
|
834
|
-
* @param name - header 名,任意大小写
|
|
835
|
-
* @returns 值,不存在时返回 `undefined`
|
|
836
|
-
*/
|
|
837
|
-
get(name) {
|
|
838
|
-
return this.entries.get(name.toLowerCase())?.value;
|
|
839
|
-
}
|
|
840
|
-
/**
|
|
841
|
-
* 判断 header 是否存在,大小写不敏感
|
|
842
|
-
* @param name - header 名,任意大小写
|
|
843
|
-
* @returns 存在则返回 `true`
|
|
844
|
-
*/
|
|
845
|
-
has(name) {
|
|
846
|
-
return this.entries.has(name.toLowerCase());
|
|
847
|
-
}
|
|
848
|
-
/**
|
|
849
|
-
* 写入 header。同名(忽略大小写)时覆盖值与显示大小写
|
|
850
|
-
* @param name - header 名
|
|
851
|
-
* @param value - 值。`undefined` / `null` 表示不写入
|
|
852
|
-
* @returns 自身,便于链式调用
|
|
853
|
-
*/
|
|
854
|
-
set(name, value) {
|
|
855
|
-
if (value === void 0 || value === null) return this;
|
|
856
|
-
this.entries.set(name.toLowerCase(), {
|
|
857
|
-
name,
|
|
858
|
-
value: String(value)
|
|
859
|
-
});
|
|
860
|
-
return this;
|
|
861
|
-
}
|
|
862
|
-
/**
|
|
863
|
-
* 删除 header,大小写不敏感
|
|
864
|
-
* @param name - header 名
|
|
865
|
-
* @returns 原本存在则返回 `true`
|
|
866
|
-
*/
|
|
867
|
-
delete(name) {
|
|
868
|
-
return this.entries.delete(name.toLowerCase());
|
|
869
|
-
}
|
|
870
|
-
/**
|
|
871
|
-
* 合并另一组 header,后来者覆盖
|
|
872
|
-
* @param input - 待合并的 header
|
|
873
|
-
* @returns 自身,便于链式调用
|
|
874
|
-
*/
|
|
875
|
-
merge(input) {
|
|
876
|
-
if (!input) return this;
|
|
877
|
-
const pairs = input instanceof AmagiHeaders ? input.toEntries() : Object.entries(input);
|
|
878
|
-
for (const [name, value] of pairs) this.set(name, value);
|
|
879
|
-
return this;
|
|
880
|
-
}
|
|
881
|
-
/** header 条数 */
|
|
882
|
-
get size() {
|
|
883
|
-
return this.entries.size;
|
|
884
|
-
}
|
|
885
|
-
/**
|
|
886
|
-
* 全部 header 名,保留最后一次写入的大小写
|
|
887
|
-
* @returns header 名数组
|
|
888
|
-
*/
|
|
889
|
-
keys() {
|
|
890
|
-
return [...this.entries.values()].map((e) => e.name);
|
|
891
|
-
}
|
|
892
|
-
/**
|
|
893
|
-
* 全部 `[名, 值]` 对,名保留最后一次写入的大小写
|
|
894
|
-
* @returns 键值对数组
|
|
895
|
-
*/
|
|
896
|
-
toEntries() {
|
|
897
|
-
return [...this.entries.values()].map((e) => [e.name, e.value]);
|
|
898
|
-
}
|
|
899
|
-
/**
|
|
900
|
-
* 转成普通对象,用于交给 axios
|
|
901
|
-
* @returns 普通 header 对象,键保留最后一次写入的大小写
|
|
902
|
-
*/
|
|
903
|
-
toJSON() {
|
|
904
|
-
return Object.fromEntries(this.toEntries());
|
|
905
|
-
}
|
|
906
|
-
/**
|
|
907
|
-
* 深拷贝一份,避免下游改写上游的 header
|
|
908
|
-
* @returns 新的 `AmagiHeaders` 实例
|
|
909
|
-
*/
|
|
910
|
-
clone() {
|
|
911
|
-
return new AmagiHeaders(this);
|
|
912
|
-
}
|
|
913
|
-
};
|
|
914
|
-
//#endregion
|
|
915
845
|
//#region src/platforms/bilibili/endpoints/userDynamicList.ts
|
|
916
846
|
/**
|
|
917
847
|
* 用户空间动态(wbi 签名 + Origin/Referer 注入)。
|
|
@@ -921,7 +851,7 @@ var AmagiHeaders = class AmagiHeaders {
|
|
|
921
851
|
* `Referer: https://space.bilibili.com/{host_mid}/dynamic`
|
|
922
852
|
* (调用方显式传了 Referer 则不覆盖)。
|
|
923
853
|
*/
|
|
924
|
-
const userDynamicList =
|
|
854
|
+
const userDynamicList = defineBilibiliEndpoint({
|
|
925
855
|
name: "bilibili.userDynamicList",
|
|
926
856
|
route: "/fetch_user_dynamic",
|
|
927
857
|
doc: {
|
|
@@ -939,7 +869,7 @@ const userDynamicList = defineEndpoint({
|
|
|
939
869
|
headers
|
|
940
870
|
};
|
|
941
871
|
},
|
|
942
|
-
sign:
|
|
872
|
+
sign: [wbi()],
|
|
943
873
|
retryOn: ["RISK_CONTROL"],
|
|
944
874
|
response: type()
|
|
945
875
|
});
|
|
@@ -950,7 +880,7 @@ const userDynamicList = defineEndpoint({
|
|
|
950
880
|
*
|
|
951
881
|
* 与旧版一致:`getUserLiveStatus` GET,无签名。
|
|
952
882
|
*/
|
|
953
|
-
const userLiveStatus =
|
|
883
|
+
const userLiveStatus = defineBilibiliEndpoint({
|
|
954
884
|
name: "bilibili.userLiveStatus",
|
|
955
885
|
route: "/fetch_user_live_status",
|
|
956
886
|
doc: {
|
|
@@ -972,7 +902,7 @@ const userLiveStatus = defineEndpoint({
|
|
|
972
902
|
*
|
|
973
903
|
* 与旧版一致:`getUserSpaceInfo` GET + wbi 签名。
|
|
974
904
|
*/
|
|
975
|
-
const userSpaceInfo =
|
|
905
|
+
const userSpaceInfo = defineBilibiliEndpoint({
|
|
976
906
|
name: "bilibili.userSpaceInfo",
|
|
977
907
|
route: "/fetch_user_space_info",
|
|
978
908
|
doc: {
|
|
@@ -984,7 +914,7 @@ const userSpaceInfo = defineEndpoint({
|
|
|
984
914
|
method: "GET",
|
|
985
915
|
url: bilibiliApiUrls$1.getUserSpaceInfo(p)
|
|
986
916
|
}),
|
|
987
|
-
sign:
|
|
917
|
+
sign: [wbi()],
|
|
988
918
|
retryOn: ["RISK_CONTROL"],
|
|
989
919
|
response: type()
|
|
990
920
|
});
|
|
@@ -996,7 +926,7 @@ const userSpaceInfo = defineEndpoint({
|
|
|
996
926
|
* 与旧版一致:`validateCaptcha` POST,
|
|
997
927
|
* body 为 `{ challenge, token, validate, seccode, csrf? }`,无签名。
|
|
998
928
|
*/
|
|
999
|
-
const validateCaptcha =
|
|
929
|
+
const validateCaptcha = defineBilibiliEndpoint({
|
|
1000
930
|
name: "bilibili.validateCaptcha",
|
|
1001
931
|
route: "/validate_captcha",
|
|
1002
932
|
doc: {
|
|
@@ -1133,7 +1063,7 @@ function parseDmSegMobileReply(data) {
|
|
|
1133
1063
|
* judge 恒成功:二进制响应没有 `code` 字段,交给 decode 解析;
|
|
1134
1064
|
* 解析失败由 execute 归因为 `parse` / `DECODE_FAILED`。
|
|
1135
1065
|
*/
|
|
1136
|
-
const videoDanmaku =
|
|
1066
|
+
const videoDanmaku = defineBilibiliEndpoint({
|
|
1137
1067
|
name: "bilibili.videoDanmaku",
|
|
1138
1068
|
route: "/fetch_danmaku",
|
|
1139
1069
|
doc: {
|
|
@@ -1163,7 +1093,7 @@ const videoDanmaku = defineEndpoint({
|
|
|
1163
1093
|
* 共 27 条路由。
|
|
1164
1094
|
*/
|
|
1165
1095
|
const bilibiliRegistry = {
|
|
1166
|
-
videoInfo:
|
|
1096
|
+
videoInfo: defineBilibiliEndpoint({
|
|
1167
1097
|
name: "bilibili.videoInfo",
|
|
1168
1098
|
route: "/fetch_one_video",
|
|
1169
1099
|
doc: {
|
|
@@ -1178,7 +1108,7 @@ const bilibiliRegistry = {
|
|
|
1178
1108
|
retryOn: ["RISK_CONTROL"],
|
|
1179
1109
|
response: type()
|
|
1180
1110
|
}),
|
|
1181
|
-
videoStream:
|
|
1111
|
+
videoStream: defineBilibiliEndpoint({
|
|
1182
1112
|
name: "bilibili.videoStream",
|
|
1183
1113
|
route: "/fetch_video_playurl",
|
|
1184
1114
|
doc: {
|
|
@@ -1193,7 +1123,7 @@ const bilibiliRegistry = {
|
|
|
1193
1123
|
method: "GET",
|
|
1194
1124
|
url: bilibiliApiUrls$1.getVideoStream(p)
|
|
1195
1125
|
}),
|
|
1196
|
-
sign:
|
|
1126
|
+
sign: [qtparam$1()],
|
|
1197
1127
|
retryOn: ["RISK_CONTROL"],
|
|
1198
1128
|
response: type()
|
|
1199
1129
|
}),
|
|
@@ -1974,7 +1904,7 @@ const bilibiliApiUrls = new BilibiliAPI();
|
|
|
1974
1904
|
/**
|
|
1975
1905
|
* 混合密钥编码表,用于对 imgKey 和 subKey 进行字符顺序打乱编码
|
|
1976
1906
|
*/
|
|
1977
|
-
const mixinKeyEncTab
|
|
1907
|
+
const mixinKeyEncTab = [
|
|
1978
1908
|
46,
|
|
1979
1909
|
47,
|
|
1980
1910
|
18,
|
|
@@ -2045,7 +1975,7 @@ const mixinKeyEncTab$1 = [
|
|
|
2045
1975
|
* @param orig - 原始字符串数组,通常是 img_key + sub_key 的字符数组
|
|
2046
1976
|
* @returns 返回经过编码表打乱后的32位字符串
|
|
2047
1977
|
*/
|
|
2048
|
-
const getMixinKey
|
|
1978
|
+
const getMixinKey = (orig) => mixinKeyEncTab.map((n) => orig[n]).join("").slice(0, 32);
|
|
2049
1979
|
/**
|
|
2050
1980
|
* 为请求参数进行 WBI 签名
|
|
2051
1981
|
* @param params - 请求参数对象,键值对形式
|
|
@@ -2053,8 +1983,8 @@ const getMixinKey$1 = (orig) => mixinKeyEncTab$1.map((n) => orig[n]).join("").sl
|
|
|
2053
1983
|
* @param sub_key - 子密钥
|
|
2054
1984
|
* @returns 返回包含时间戳和签名的查询字符串
|
|
2055
1985
|
*/
|
|
2056
|
-
const encWbi
|
|
2057
|
-
const mixin_key = getMixinKey
|
|
1986
|
+
const encWbi = (params, img_key, sub_key) => {
|
|
1987
|
+
const mixin_key = getMixinKey(img_key + sub_key);
|
|
2058
1988
|
const curr_time = Math.round(Date.now() / 1e3);
|
|
2059
1989
|
const chr_filter = /[!'()*]/g;
|
|
2060
1990
|
Object.assign(params, { wts: curr_time });
|
|
@@ -2089,7 +2019,7 @@ const wbi_sign = async (BASEURL, cookie) => {
|
|
|
2089
2019
|
const url = new URL(BASEURL);
|
|
2090
2020
|
const params = {};
|
|
2091
2021
|
for (const [key, value] of url.searchParams.entries()) params[key] = value;
|
|
2092
|
-
return encWbi
|
|
2022
|
+
return encWbi(params, web_keys.img_key, web_keys.sub_key);
|
|
2093
2023
|
};
|
|
2094
2024
|
//#endregion
|
|
2095
2025
|
//#region src/platforms/legacy/bilibili/qtparam.ts
|
|
@@ -2393,254 +2323,20 @@ const bilibiliJudge = (raw, http) => {
|
|
|
2393
2323
|
};
|
|
2394
2324
|
};
|
|
2395
2325
|
//#endregion
|
|
2396
|
-
//#region src/platforms/bilibili/sign/
|
|
2397
|
-
/**
|
|
2398
|
-
* B站视频流签名器(qtparam 前置)。
|
|
2399
|
-
*
|
|
2400
|
-
* cookie 取自 `ctx.cookie`(由 `resolveBoundRequest` 用 `AmagiHeaders`
|
|
2401
|
-
* 大小写不敏感地解析),这里不自己翻 headers。
|
|
2402
|
-
*
|
|
2403
|
-
* 行为:
|
|
2404
|
-
* - cookie 为空 → 只带 `&platform=html5`(未登录降级)。
|
|
2405
|
-
* - 已登录 → 打 `/nav` 取 `vipStatus`(复用 wbi 签名器的 TTL 缓存):
|
|
2406
|
-
* VIP 带 `&fnval=4048&fourk=1`,非 VIP 带 `&qn=64&fnval=16`,
|
|
2407
|
-
* 再追加 wbi 签名(签名基于**未加 fnval 的原始 URL**)。
|
|
2408
|
-
*/
|
|
2409
|
-
/** 高清档位:qn[3] = 64 = 720P */
|
|
2410
|
-
const QN = [
|
|
2411
|
-
6,
|
|
2412
|
-
16,
|
|
2413
|
-
32,
|
|
2414
|
-
64,
|
|
2415
|
-
74,
|
|
2416
|
-
80,
|
|
2417
|
-
112,
|
|
2418
|
-
116,
|
|
2419
|
-
120,
|
|
2420
|
-
125,
|
|
2421
|
-
126,
|
|
2422
|
-
127
|
|
2423
|
-
];
|
|
2424
|
-
/** VIP 的 fnval:16(DASH) | 64(HDR) | 128(4K) | 256(杜比) | 512(杜比视界) | 1024(8K) | 2048(AV1) = 4048 */
|
|
2425
|
-
const VIP_FNVAL = 4048;
|
|
2426
|
-
/**
|
|
2427
|
-
* 创建 qtparam 签名器。
|
|
2428
|
-
*
|
|
2429
|
-
* 依赖同一个 {@link WbiSigner} 实例:登录态与 wbi keys 共用一次 `/nav` 的 TTL 缓存。
|
|
2430
|
-
* @param wbi - wbi 签名器实例(与 `sign: 'wbi'` 的端点共用)
|
|
2431
|
-
* @returns 签名器函数,挂到 `sign: 'qtparam'`
|
|
2432
|
-
*/
|
|
2433
|
-
const createQtparamSigner = (wbi) => {
|
|
2434
|
-
return async (spec, ctx) => {
|
|
2435
|
-
if (ctx.cookie === "") return {
|
|
2436
|
-
...spec,
|
|
2437
|
-
url: spec.url + "&platform=html5"
|
|
2438
|
-
};
|
|
2439
|
-
const isvip = (await wbi.getNav(ctx)).data?.vipStatus === 1;
|
|
2440
|
-
const qtParams = isvip ? `&fnval=${VIP_FNVAL}&fourk=1` : `&qn=${QN[3]}&fnval=16`;
|
|
2441
|
-
const signed = await wbi.sign(spec, ctx);
|
|
2442
|
-
return {
|
|
2443
|
-
...spec,
|
|
2444
|
-
url: signed.url + qtParams,
|
|
2445
|
-
extra: {
|
|
2446
|
-
...spec.extra,
|
|
2447
|
-
isvip
|
|
2448
|
-
}
|
|
2449
|
-
};
|
|
2450
|
-
};
|
|
2451
|
-
};
|
|
2452
|
-
//#endregion
|
|
2453
|
-
//#region src/platforms/bilibili/sign/wbi.ts
|
|
2454
|
-
/**
|
|
2455
|
-
* B站 wbi 签名器(实例级)。
|
|
2456
|
-
*
|
|
2457
|
-
* 两处实现要点:
|
|
2458
|
-
* - **走 transport**:`getNav` 用 `ctx.send` 发 `/nav`(`reason: 'prepare'`
|
|
2459
|
-
* 进 trace),与主请求走同一条路。
|
|
2460
|
-
* - **TTL 缓存随 client 实例**:keys 缓存在实例里,TTL 内连续签名只打一次
|
|
2461
|
-
* `/nav`。
|
|
2462
|
-
*
|
|
2463
|
-
* 签名算法本身(`mixinKeyEncTab` / `encWbi`)与旧版逐字一致。
|
|
2464
|
-
*/
|
|
2465
|
-
/** wbi 密钥的 TTL(毫秒)。30 分钟内复用缓存 */
|
|
2466
|
-
const WBI_TTL_MS = 18e5;
|
|
2467
|
-
/** 混合密钥编码表 */
|
|
2468
|
-
const mixinKeyEncTab = [
|
|
2469
|
-
46,
|
|
2470
|
-
47,
|
|
2471
|
-
18,
|
|
2472
|
-
2,
|
|
2473
|
-
53,
|
|
2474
|
-
8,
|
|
2475
|
-
23,
|
|
2476
|
-
32,
|
|
2477
|
-
15,
|
|
2478
|
-
50,
|
|
2479
|
-
10,
|
|
2480
|
-
31,
|
|
2481
|
-
58,
|
|
2482
|
-
3,
|
|
2483
|
-
45,
|
|
2484
|
-
35,
|
|
2485
|
-
27,
|
|
2486
|
-
43,
|
|
2487
|
-
5,
|
|
2488
|
-
49,
|
|
2489
|
-
33,
|
|
2490
|
-
9,
|
|
2491
|
-
42,
|
|
2492
|
-
19,
|
|
2493
|
-
29,
|
|
2494
|
-
28,
|
|
2495
|
-
14,
|
|
2496
|
-
39,
|
|
2497
|
-
12,
|
|
2498
|
-
38,
|
|
2499
|
-
41,
|
|
2500
|
-
13,
|
|
2501
|
-
37,
|
|
2502
|
-
48,
|
|
2503
|
-
7,
|
|
2504
|
-
16,
|
|
2505
|
-
24,
|
|
2506
|
-
55,
|
|
2507
|
-
40,
|
|
2508
|
-
61,
|
|
2509
|
-
26,
|
|
2510
|
-
17,
|
|
2511
|
-
0,
|
|
2512
|
-
1,
|
|
2513
|
-
60,
|
|
2514
|
-
51,
|
|
2515
|
-
30,
|
|
2516
|
-
4,
|
|
2517
|
-
22,
|
|
2518
|
-
25,
|
|
2519
|
-
54,
|
|
2520
|
-
21,
|
|
2521
|
-
56,
|
|
2522
|
-
59,
|
|
2523
|
-
6,
|
|
2524
|
-
63,
|
|
2525
|
-
57,
|
|
2526
|
-
62,
|
|
2527
|
-
11,
|
|
2528
|
-
36,
|
|
2529
|
-
20,
|
|
2530
|
-
34,
|
|
2531
|
-
44,
|
|
2532
|
-
52
|
|
2533
|
-
];
|
|
2534
|
-
/** 从 URL 末尾取文件名部分(去扩展名),得到 img_key / sub_key */
|
|
2535
|
-
const extractKey = (url) => url.slice(url.lastIndexOf("/") + 1, url.lastIndexOf("."));
|
|
2536
|
-
/**
|
|
2537
|
-
* 对 imgKey 和 subKey 进行字符顺序打乱编码。
|
|
2538
|
-
* @param orig - img_key + sub_key 拼接
|
|
2539
|
-
* @returns 32 位 mixin key
|
|
2540
|
-
*/
|
|
2541
|
-
const getMixinKey = (orig) => mixinKeyEncTab.map((n) => orig[n]).join("").slice(0, 32);
|
|
2542
|
-
/**
|
|
2543
|
-
* 为请求参数计算 wbi 签名。
|
|
2544
|
-
* @param params - 请求参数(不含 wts / w_rid)
|
|
2545
|
-
* @param img_key - 图片密钥
|
|
2546
|
-
* @param sub_key - 子密钥
|
|
2547
|
-
* @returns `&wts=..&w_rid=..` 查询串
|
|
2548
|
-
*/
|
|
2549
|
-
const encWbi = (params, img_key, sub_key) => {
|
|
2550
|
-
const mixin_key = getMixinKey(img_key + sub_key);
|
|
2551
|
-
const curr_time = Math.round(Date.now() / 1e3);
|
|
2552
|
-
const chr_filter = /[!'()*]/g;
|
|
2553
|
-
Object.assign(params, { wts: curr_time });
|
|
2554
|
-
const query = Object.keys(params).sort().map((key) => {
|
|
2555
|
-
const value = params[key].toString().replace(chr_filter, "");
|
|
2556
|
-
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
|
2557
|
-
}).join("&");
|
|
2558
|
-
return `&wts=${curr_time}&w_rid=${crypto.createHash("md5").update(query + mixin_key).digest("hex")}`;
|
|
2559
|
-
};
|
|
2326
|
+
//#region src/platforms/bilibili/sign/signers.ts
|
|
2560
2327
|
/**
|
|
2561
|
-
* B
|
|
2328
|
+
* 创建 B站签名器表(薄壳,进程级共享)。
|
|
2562
2329
|
*
|
|
2563
|
-
*
|
|
2564
|
-
*
|
|
2330
|
+
* 不自建 `WbiSigner` —— `wbi()` / `qtparam()` 内部引用 `steps.ts` 的模块级
|
|
2331
|
+
* `sharedWbi`,所以 `createBilibiliSigners()` 调多次也共用同一个实例、同一份
|
|
2332
|
+
* `/nav` 缓存(与旧注释「每 client 一份」相反 —— 但实现上本来就只有一份,这里是
|
|
2333
|
+
* 让注释对上实现)。
|
|
2334
|
+
* @returns 注册名 → 签名器
|
|
2565
2335
|
*/
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
2570
|
-
/**
|
|
2571
|
-
* @param ttlMs - keys 缓存有效期,默认 {@link WBI_TTL_MS}
|
|
2572
|
-
* @param now - 时钟(可注入)
|
|
2573
|
-
*/
|
|
2574
|
-
constructor(ttlMs = WBI_TTL_MS, now = Date.now) {
|
|
2575
|
-
this.ttlMs = ttlMs;
|
|
2576
|
-
this.now = now;
|
|
2577
|
-
}
|
|
2578
|
-
/**
|
|
2579
|
-
* 取 `/nav` 响应体(带 TTL 缓存)。
|
|
2580
|
-
*
|
|
2581
|
-
* 走 `ctx.send`(reason `'prepare'`)。
|
|
2582
|
-
* @param ctx - 执行上下文(提供 send 与 cookie)
|
|
2583
|
-
* @returns `/nav` 响应体
|
|
2584
|
-
*/
|
|
2585
|
-
async getNav(ctx) {
|
|
2586
|
-
const cached = this.nav;
|
|
2587
|
-
if (cached && this.now() - cached.fetchedAt < this.ttlMs) return cached.body;
|
|
2588
|
-
const body = (await ctx.send({
|
|
2589
|
-
method: "GET",
|
|
2590
|
-
url: "https://api.bilibili.com/x/web-interface/nav",
|
|
2591
|
-
headers: { cookie: ctx.cookie }
|
|
2592
|
-
}, "prepare")).body;
|
|
2593
|
-
this.nav = {
|
|
2594
|
-
body,
|
|
2595
|
-
fetchedAt: this.now()
|
|
2596
|
-
};
|
|
2597
|
-
return body;
|
|
2598
|
-
}
|
|
2599
|
-
/** 从 `/nav` 响应体提取 wbi keys */
|
|
2600
|
-
keysOf(body) {
|
|
2601
|
-
const img = body.data?.wbi_img?.img_url;
|
|
2602
|
-
const sub = body.data?.wbi_img?.sub_url;
|
|
2603
|
-
if (!img || !sub) throw new Error("wbi 密钥获取失败:/nav 响应缺少 wbi_img");
|
|
2604
|
-
return {
|
|
2605
|
-
img_key: extractKey(img),
|
|
2606
|
-
sub_key: extractKey(sub)
|
|
2607
|
-
};
|
|
2608
|
-
}
|
|
2609
|
-
/**
|
|
2610
|
-
* 签名器:给请求 URL 追加 `&wts=..&w_rid=..`。
|
|
2611
|
-
*
|
|
2612
|
-
* `sign: 'wbi'` 的端点(comments / userDynamicList / userSpaceInfo 等)用它。
|
|
2613
|
-
* 首次调用会触发 `/nav` 前置请求(reason `'prepare'`),TTL 内复用。
|
|
2614
|
-
* @param spec - 请求描述
|
|
2615
|
-
* @param ctx - 执行上下文
|
|
2616
|
-
* @returns 带 wbi 签名的请求描述
|
|
2617
|
-
*/
|
|
2618
|
-
sign = async (spec, ctx) => {
|
|
2619
|
-
const nav = await this.getNav(ctx);
|
|
2620
|
-
const { img_key, sub_key } = this.keysOf(nav);
|
|
2621
|
-
const url = new URL(spec.url);
|
|
2622
|
-
const params = {};
|
|
2623
|
-
for (const [key, value] of url.searchParams.entries()) params[key] = value;
|
|
2624
|
-
const query = encWbi(params, img_key, sub_key);
|
|
2625
|
-
return {
|
|
2626
|
-
...spec,
|
|
2627
|
-
url: spec.url + query
|
|
2628
|
-
};
|
|
2629
|
-
};
|
|
2630
|
-
};
|
|
2631
|
-
/** 创建一个 wbi 签名器实例(每 client 一个) */
|
|
2632
|
-
const createWbiSigner = (ttlMs, now) => new WbiSigner(ttlMs, now);
|
|
2633
|
-
//#endregion
|
|
2634
|
-
//#region src/platforms/bilibili/sign/signers.ts
|
|
2635
|
-
/** 创建 B站签名器表(每 client 一份) */
|
|
2636
|
-
const createBilibiliSigners = () => {
|
|
2637
|
-
const instance = createWbiSigner();
|
|
2638
|
-
return {
|
|
2639
|
-
wbi: instance.sign,
|
|
2640
|
-
qtparam: createQtparamSigner(instance),
|
|
2641
|
-
instance
|
|
2642
|
-
};
|
|
2643
|
-
};
|
|
2336
|
+
const createBilibiliSigners = () => ({
|
|
2337
|
+
wbi: stepsToSigner([wbi()]),
|
|
2338
|
+
qtparam: stepsToSigner([qtparam$1()])
|
|
2339
|
+
});
|
|
2644
2340
|
//#endregion
|
|
2645
2341
|
//#region src/platforms/douyin/config.ts
|
|
2646
2342
|
/**
|
|
@@ -2754,772 +2450,51 @@ const douyinJudge = (raw, http) => {
|
|
|
2754
2450
|
return verdictFromHttpStatus(http.status) ?? { ok: true };
|
|
2755
2451
|
};
|
|
2756
2452
|
//#endregion
|
|
2757
|
-
//#region src/
|
|
2758
|
-
/**
|
|
2759
|
-
* Cookie 契约。
|
|
2760
|
-
*
|
|
2761
|
-
* **全仓唯一的 cookie 解析与序列化实现。** 解析按 RFC 6265 的键值语义:
|
|
2762
|
-
* 名字大小写敏感、值取第一个 `=` 之后的全部内容、不做 URL 解码。
|
|
2763
|
-
*
|
|
2764
|
-
* `contracts/` 是零依赖叶子层,本文件不 import 任何模块。
|
|
2765
|
-
*/
|
|
2766
|
-
/**
|
|
2767
|
-
* 解析 `name=value; name=value` 形式的 cookie 串。
|
|
2768
|
-
*
|
|
2769
|
-
* 规则:
|
|
2770
|
-
* - 以第一个 `=` 分割,所以值里可以含 `=`(如 base64)。
|
|
2771
|
-
* - 名与值两端去空白。
|
|
2772
|
-
* - 名为空、或整段没有 `=` 的片段直接跳过。
|
|
2773
|
-
* - 同名多次出现时**后者覆盖前者**。
|
|
2774
|
-
* - 值不做 URL 解码 —— 签名依赖原始字节,解码会改变签名输入。
|
|
2775
|
-
*
|
|
2776
|
-
* cookie 名按 RFC 6265 是**大小写敏感**的,因此本函数不做任何大小写归一化。
|
|
2777
|
-
* 这与 header 名大小写不敏感(见 `AmagiHeaders`)是两件不同的事。
|
|
2778
|
-
* @param cookieString - cookie 串,`undefined` / `null` / 空串都安全
|
|
2779
|
-
* @returns 名 → 值的普通对象,无有效项时是空对象
|
|
2780
|
-
*/
|
|
2781
|
-
const parseCookie = (cookieString) => {
|
|
2782
|
-
const result = {};
|
|
2783
|
-
if (!cookieString) return result;
|
|
2784
|
-
for (const pair of cookieString.split(";")) {
|
|
2785
|
-
const index = pair.indexOf("=");
|
|
2786
|
-
if (index <= 0) continue;
|
|
2787
|
-
const name = pair.slice(0, index).trim();
|
|
2788
|
-
if (!name) continue;
|
|
2789
|
-
result[name] = pair.slice(index + 1).trim();
|
|
2790
|
-
}
|
|
2791
|
-
return result;
|
|
2792
|
-
};
|
|
2793
|
-
/**
|
|
2794
|
-
* 取单条 cookie 的值。
|
|
2795
|
-
*
|
|
2796
|
-
* **按名精确匹配**,不是子串匹配 ——
|
|
2797
|
-
* `getCookieValue('xa1=WRONG; a1=RIGHT', 'a1')` 返回 `'RIGHT'` 而不是 `'WRONG'`。
|
|
2798
|
-
* @param cookieString - 完整 cookie 串
|
|
2799
|
-
* @param name - cookie 名,大小写敏感
|
|
2800
|
-
* @returns 值;不存在时返回 `undefined`
|
|
2801
|
-
*/
|
|
2802
|
-
const getCookieValue = (cookieString, name) => {
|
|
2803
|
-
if (!cookieString || !name) return void 0;
|
|
2804
|
-
return parseCookie(cookieString)[name];
|
|
2805
|
-
};
|
|
2806
|
-
//#endregion
|
|
2807
|
-
//#region src/platforms/douyin/webid.ts
|
|
2808
|
-
/**
|
|
2809
|
-
* 抖音 `webid` 的取得与缓存。
|
|
2810
|
-
*
|
|
2811
|
-
* `webid` 是服务端下发的访客 id,**客户端算不出来** —— 服务端按 `ttwid` 算好之后,
|
|
2812
|
-
* 通过每个响应的 `cookie_ttwidinfo_webid` 头告知。一个 ttwid 对应一个稳定的 webid。
|
|
2813
|
-
*
|
|
2814
|
-
* 抖音会拿 query 里的 `webid` 与 cookie 的会话交叉校验,对不上就返回 HTTP 200 +
|
|
2815
|
-
* 空 body(不是 403、也不是业务码),最典型的受害者是 `aweme/favorite`。实测:
|
|
2816
|
-
*
|
|
2817
|
-
* ```text
|
|
2818
|
-
* 浏览器真实的 webid → 200,1,116,615 字节,19 条
|
|
2819
|
-
* 代码里写死的那个 → 200, 0 字节,空
|
|
2820
|
-
* 干脆不传 → 200,1,116,776 字节,19 条
|
|
2821
|
-
* ```
|
|
2822
|
-
*
|
|
2823
|
-
* **不传安全,传错致命。** 所以不写死默认值,只在缓存命中时才补。
|
|
2824
|
-
* 缓存按 ttwid 分键:换了 cookie,webid 就得跟着换。
|
|
2825
|
-
*
|
|
2826
|
-
* 顺带澄清一个容易误解的点:填别人的 `webid` **不会**拿到别人的数据 ——
|
|
2827
|
-
* 「看谁」由 `sec_user_id` 决定,`webid` 只是设备一致性校验,身份来自 cookie 的
|
|
2828
|
-
* `sessionid`。填错只会被拒绝。
|
|
2829
|
-
*
|
|
2830
|
-
* ## 实现要点
|
|
2831
|
-
*
|
|
2832
|
-
* 接入的是端点管线:
|
|
2833
|
-
*
|
|
2834
|
-
* - 键取自 `ctx.cookie`,解析走 `contracts/cookie.ts` 的 `getCookieValue`;
|
|
2835
|
-
* - 回收由平台级 `observe` 钩子在每次 send 之后调用,读 `RawResponse.headers`;
|
|
2836
|
-
* - 注入由 `sign/signers.ts` 里的第一步完成(`webid → a_bogus/x_bogus → secsdk`),
|
|
2837
|
-
* 而不是让同步的 URL 构造去依赖运行期状态。
|
|
2838
|
-
*
|
|
2839
|
-
* ## 作用域:进程级
|
|
2840
|
-
*
|
|
2841
|
-
* **这是进程级的,不是每 client 一份** —— 端点定义本身就是模块级单例。
|
|
2842
|
-
* 要按 client 隔离得把状态搬到 `ClientCtx` 上。
|
|
2843
|
-
* 多 cookie 场景靠 ttwid 分键区分,所以「串用别人的 webid」不会发生。
|
|
2844
|
-
*
|
|
2845
|
-
* @module platforms/douyin/webid
|
|
2846
|
-
*/
|
|
2847
|
-
/** ttwid → webid。webid 对同一 ttwid 是稳定的,进程内缓存即可 */
|
|
2848
|
-
const cache = /* @__PURE__ */ new Map();
|
|
2849
|
-
/** 抖音回传 webid 的响应头 */
|
|
2850
|
-
const WEBID_HEADER = "cookie_ttwidinfo_webid";
|
|
2851
|
-
/** webid 的形状:纯数字、至少 6 位。不合形状的值一律不收 —— 传错比不传更糟 */
|
|
2852
|
-
const WEBID_SHAPE = /^\d{6,}$/;
|
|
2853
|
-
/**
|
|
2854
|
-
* 从 cookie 串里取 `ttwid`,用作缓存键
|
|
2855
|
-
* @param cookie - 本次调用使用的 cookie
|
|
2856
|
-
* @returns `ttwid` 的值,取不到时返回空串
|
|
2857
|
-
*/
|
|
2858
|
-
const ttwidOf = (cookie) => cookie ? getCookieValue(cookie, "ttwid") ?? "" : "";
|
|
2859
|
-
/**
|
|
2860
|
-
* 看到响应头里的 webid 就记下来。没有这个头、值不合形状、或 cookie 里没有 ttwid
|
|
2861
|
-
* (无从分键)时都是无操作。
|
|
2862
|
-
* @param res - 本次请求的原始响应
|
|
2863
|
-
* @param cookie - 本次请求使用的 cookie,用于取 ttwid 作为缓存键
|
|
2864
|
-
*/
|
|
2865
|
-
const rememberDouyinWebid = (res, cookie) => {
|
|
2866
|
-
const webid = res.headers.get(WEBID_HEADER);
|
|
2867
|
-
if (!webid || !WEBID_SHAPE.test(webid)) return;
|
|
2868
|
-
const key = ttwidOf(cookie);
|
|
2869
|
-
if (key) cache.set(key, webid);
|
|
2870
|
-
};
|
|
2871
|
-
/**
|
|
2872
|
-
* 取这份 cookie 对应的 webid
|
|
2873
|
-
* @param cookie - 本次调用使用的 cookie
|
|
2874
|
-
* @returns 缓存里的 webid,没有时返回空串
|
|
2875
|
-
*/
|
|
2876
|
-
const douyinWebidFor = (cookie) => cache.get(ttwidOf(cookie)) ?? "";
|
|
2877
|
-
/**
|
|
2878
|
-
* 给 URL 补上 `webid`。
|
|
2879
|
-
*
|
|
2880
|
-
* 必须在算 a_bogus 之前调用:真实浏览器的顺序是「拼好 query(含 webid)→ a_bogus →
|
|
2881
|
-
* secsdk 签名」,后两者都覆盖 query,顺序错了签名就不成立。
|
|
2882
|
-
* URL 里已有 `webid`、或缓存里没有值时原样返回。
|
|
2883
|
-
* @param url - 还没加签的 URL
|
|
2884
|
-
* @param cookie - 本次请求使用的 cookie
|
|
2885
|
-
* @returns 补好 `webid` 的 URL
|
|
2886
|
-
*/
|
|
2887
|
-
const withDouyinWebid = (url, cookie) => {
|
|
2888
|
-
const webid = douyinWebidFor(cookie);
|
|
2889
|
-
if (!webid) return url;
|
|
2890
|
-
let parsed;
|
|
2891
|
-
try {
|
|
2892
|
-
parsed = new URL(url);
|
|
2893
|
-
} catch {
|
|
2894
|
-
return url;
|
|
2895
|
-
}
|
|
2896
|
-
if (parsed.searchParams.has("webid")) return url;
|
|
2897
|
-
parsed.searchParams.set("webid", webid);
|
|
2898
|
-
return parsed.toString();
|
|
2899
|
-
};
|
|
2900
|
-
/**
|
|
2901
|
-
* 平台级 `observe` 钩子:挂在 `PLATFORM_RUNTIME.douyin` 上,每次 send 之后被调一次。
|
|
2902
|
-
*
|
|
2903
|
-
* **自己吞异常** —— 契约是「只读、不抛、不改判定」。回收 webid 失败只该让下一次
|
|
2904
|
-
* 请求少一个参数(不带是安全的),不该把一个本来成功的调用变成 `kind: 'internal'`。
|
|
2905
|
-
* @param res - 本次请求的原始响应
|
|
2906
|
-
* @param ctx - 当刻的执行上下文(只读 `cookie`)
|
|
2907
|
-
*/
|
|
2908
|
-
const observeDouyinWebid = (res, ctx) => {
|
|
2909
|
-
try {
|
|
2910
|
-
rememberDouyinWebid(res, ctx.cookie);
|
|
2911
|
-
} catch {}
|
|
2912
|
-
};
|
|
2913
|
-
//#endregion
|
|
2914
|
-
//#region src/platforms/douyin/sign/x_bogus.ts
|
|
2915
|
-
/**
|
|
2916
|
-
* X-Bogus 生成工具(抖音 / TikTok Web 的老签名)—— TypeScript 移植
|
|
2917
|
-
*
|
|
2918
|
-
* 移植自 Douyin_TikTok_Download_API `src/dtk/signing/native/xbogus.py`,常量、槽位与
|
|
2919
|
-
* **字节顺序**逐条对齐;那份 Python 又是 V4 `crawlers/douyin/web/xbogus.py` 的逐行移植,
|
|
2920
|
-
* 而 V4 是站点自己那份混淆 JS 的直译。所以这里对齐的不是「某份实现的做法」,
|
|
2921
|
-
* 而是站点校验时的做法。
|
|
2922
|
-
*
|
|
2923
|
-
* ## 载荷(19 字节)与槽位
|
|
2924
|
-
*
|
|
2925
|
-
* ```text
|
|
2926
|
-
* [64, 1/256, 1, 12, query 摘要[14..15], empty 摘要[14..15], ua 摘要[14..15],
|
|
2927
|
-
* 时间戳 4 字节, 536919696 4 字节, 校验位]
|
|
2928
|
-
* ```
|
|
2929
|
-
*
|
|
2930
|
-
* 三条摘要链各自算出 16 字节 MD5,只取第 14、15 字节(`DIGEST_INDICES`);`1/256`
|
|
2931
|
-
* 是站点留在载荷里的浮点常量,最终被截断成 `0`;最后一格是前 18 格的异或。
|
|
2932
|
-
*
|
|
2933
|
-
* ## 两个曾经的坑,都会产出「看着像模像样」的错签名
|
|
2934
|
-
*
|
|
2935
|
-
* 1. **字节顺序**:站点把载荷按奇偶位拆开,再交给一个**形参表本身就乱序**的编码函数,
|
|
2936
|
-
* 而那个函数正好把两段拼回原序 —— 拆分是死代码。旧移植漏掉还原这一步,把
|
|
2937
|
-
* 「偶数位在前、奇数位在后」直接送进 RC4,字节全体错位;再叠加把 `1/256` 写成 `1`,
|
|
2938
|
-
* 输出与站点/V4 完全不同。见 {@link XBogus.interleave}。
|
|
2939
|
-
* 2. **十六进制解码**:≤ 32 字符一律按站点的判据当摘要解十六进制,旧实现遇到落单的
|
|
2940
|
-
* 半字节会悄悄补 0(Python 那版是悄悄丢掉末位),两个不同输入能撞出同一签名。
|
|
2941
|
-
* 见 {@link XBogus.md5StrToArray}。
|
|
2942
|
-
*
|
|
2943
|
-
* ## 时钟
|
|
2944
|
-
*
|
|
2945
|
-
* V4 把 `time.time()` 烧在函数体里,签名没法在测试里钉死。这里把秒级时间戳做成
|
|
2946
|
-
* 注入点(`options.timestamp`),默认值才是 `Date.now()`。
|
|
2947
|
-
*
|
|
2948
|
-
* @module platforms/douyin/sign/x_bogus
|
|
2949
|
-
*/
|
|
2950
|
-
/** 输出字母表,与 a_bogus 的 `s2` 相同;两者可以各自演化,所以不共用一份 */
|
|
2951
|
-
const CHARACTER = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=";
|
|
2952
|
-
/** 作用在 User-Agent 上的 RC4 密钥(三个控制字节) */
|
|
2953
|
-
const UA_KEY = Buffer.from([
|
|
2954
|
-
0,
|
|
2955
|
-
1,
|
|
2956
|
-
12
|
|
2957
|
-
]);
|
|
2958
|
-
/** 作用在载荷上的 RC4 密钥 */
|
|
2959
|
-
const PAYLOAD_KEY = Buffer.from([255]);
|
|
2960
|
-
/** 空串的 MD5,算法会把它再哈希一轮,结果是个常量 */
|
|
2961
|
-
const EMPTY_MD5 = "d41d8cd98f00b204e9800998ecf8427e";
|
|
2962
|
-
/** 载荷开头四个槽位的字面量;`1/256` 那一格会被截断成 `0`,但不能顺手改写成 `0` */
|
|
2963
|
-
const PAYLOAD_LEAD = [
|
|
2964
|
-
64,
|
|
2965
|
-
1 / 256,
|
|
2966
|
-
1,
|
|
2967
|
-
12
|
|
2968
|
-
];
|
|
2969
|
-
/** 每条摘要链贡献的两个字节在 16 字节摘要里的下标 */
|
|
2970
|
-
const DIGEST_INDICES = [14, 15];
|
|
2971
|
-
/** 信封开头的两个明文字节(`2` 与 `255`),密文接在它们后面 */
|
|
2972
|
-
const ENVELOPE_LEAD = [2, 255];
|
|
2973
|
-
/** 不传 UA 时使用的默认值,与 V4 一字不差(改它等于改签名) */
|
|
2974
|
-
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";
|
|
2975
|
-
/** 小写十六进制字符,V4 与 xbogus.py 都只认这一档 */
|
|
2976
|
-
const HEX_DIGITS = "0123456789abcdef";
|
|
2977
|
-
var XBogus = class {
|
|
2978
|
-
/** 十六进制字符 → 半字节;未映射的字符取出 `null` 或 `undefined`,两者都算非法 */
|
|
2979
|
-
hexNibbles;
|
|
2980
|
-
/** 内置默认 UA */
|
|
2981
|
-
defaultUa;
|
|
2982
|
-
/** V4 兼容字段:V4 会把结果同步写进 `params` / `xb`,本实现不写 */
|
|
2983
|
-
params;
|
|
2984
|
-
xb;
|
|
2985
|
-
constructor() {
|
|
2986
|
-
this.hexNibbles = new Array(128).fill(null);
|
|
2987
|
-
for (let index = 0; index < 16; index++) this.hexNibbles[HEX_DIGITS.charCodeAt(index)] = index;
|
|
2988
|
-
this.defaultUa = DEFAULT_USER_AGENT;
|
|
2989
|
-
}
|
|
2990
|
-
/**
|
|
2991
|
-
* 十六进制串 → 字节数组;超过 32 字符则按原始文本取码点。
|
|
2992
|
-
*
|
|
2993
|
-
* **长度判据(≤ 32 走十六进制)是站点的**,不是这里加的:站点把任何不超过一份
|
|
2994
|
-
* 摘要长度的字符串都当作摘要来解。真实请求的 path + query 永远长过 32 字符,
|
|
2995
|
-
* 走的是文本分支,所以这条规则碰不到线上行为。
|
|
2996
|
-
*
|
|
2997
|
-
* **旧实现在这里会悄悄吞掉落单的半字节**,两个不同输入因此撞出同一份签名:
|
|
2998
|
-
* 参考实现(Python)用 `range(0, len - 1, 2)`,末位字符直接不参与运算,
|
|
2999
|
-
* `"abcdef"` 与 `"abcdefg"` 同签名;本仓库旧 TS 版把缺失的低半字节当成 0,
|
|
3000
|
-
* `"abc"` 与 `"abc0"` 同签名。两者的共同点是「输入不合法,却拿到一份像模像样的
|
|
3001
|
-
* 签名」—— 这比报错难查得多,所以这里选择**报错**:长度必须是偶数、字符必须是小写
|
|
3002
|
-
* 十六进制,否则抛出,绝不替调用方补位或丢位。
|
|
3003
|
-
*
|
|
3004
|
-
* @param value - 十六进制串 / 原始文本,或现成的字节数组(复制一份返回)
|
|
3005
|
-
* @returns 字节数组
|
|
3006
|
-
*/
|
|
3007
|
-
md5StrToArray(value) {
|
|
3008
|
-
if (Array.isArray(value)) return [...value];
|
|
3009
|
-
if (value.length > 32) {
|
|
3010
|
-
const codePoints = [];
|
|
3011
|
-
for (const char of value) codePoints.push(char.charCodeAt(0));
|
|
3012
|
-
return codePoints;
|
|
3013
|
-
}
|
|
3014
|
-
const bytes = [];
|
|
3015
|
-
for (let index = 0; index < value.length; index += 2) {
|
|
3016
|
-
const pair = value.slice(index, index + 2);
|
|
3017
|
-
const high = this.hexNibbles[value.charCodeAt(index)];
|
|
3018
|
-
const low = index + 1 < value.length ? this.hexNibbles[value.charCodeAt(index + 1)] : null;
|
|
3019
|
-
if (high === null || high === void 0 || low === null || low === void 0) throw new Error(`X-Bogus 签名:${JSON.stringify(value)} 不是偶数长度的小写十六进制串(${JSON.stringify(pair)} 处)`);
|
|
3020
|
-
bytes.push(high << 4 | low);
|
|
3021
|
-
}
|
|
3022
|
-
return bytes;
|
|
3023
|
-
}
|
|
3024
|
-
/** MD5 十六进制摘要;字符串先过 {@link XBogus.md5StrToArray},数组按原样取字节 */
|
|
3025
|
-
md5(data) {
|
|
3026
|
-
const bytes = typeof data === "string" ? this.md5StrToArray(data) : data;
|
|
3027
|
-
return crypto.createHash("md5").update(Buffer.from(bytes)).digest("hex");
|
|
3028
|
-
}
|
|
3029
|
-
/** query 摘要链:两轮 MD5 */
|
|
3030
|
-
md5Encrypt(urlPath) {
|
|
3031
|
-
return this.md5StrToArray(this.md5(this.md5StrToArray(this.md5(urlPath))));
|
|
3032
|
-
}
|
|
3033
|
-
/**
|
|
3034
|
-
* empty 摘要链:哈希空串,是个常量。
|
|
3035
|
-
*
|
|
3036
|
-
* 它的第 14、15 字节出现在每一份 X-Bogus 里 —— 解码时对不上这两个字节的,
|
|
3037
|
-
* 就不是 X-Bogus。
|
|
3038
|
-
*/
|
|
3039
|
-
emptyDigest() {
|
|
3040
|
-
return this.md5StrToArray(this.md5(this.md5StrToArray(EMPTY_MD5)));
|
|
3041
|
-
}
|
|
3042
|
-
/** UA 摘要链:RC4 → base64 → MD5,再解回字节 */
|
|
3043
|
-
userAgentDigest(userAgent) {
|
|
3044
|
-
const encrypted = this.rc4Encrypt(UA_KEY, Buffer.from(userAgent, "latin1"));
|
|
3045
|
-
return this.md5StrToArray(this.md5(encrypted.toString("base64")));
|
|
3046
|
-
}
|
|
3047
|
-
/** RC4(与 Python 版逐句对应) */
|
|
3048
|
-
rc4Encrypt(key, data) {
|
|
3049
|
-
const box = Array.from({ length: 256 }, (_, index) => index);
|
|
3050
|
-
let j = 0;
|
|
3051
|
-
for (let index = 0; index < 256; index++) {
|
|
3052
|
-
j = (j + box[index] + key[index % key.length]) % 256;
|
|
3053
|
-
[box[index], box[j]] = [box[j], box[index]];
|
|
3054
|
-
}
|
|
3055
|
-
const out = Buffer.alloc(data.length);
|
|
3056
|
-
let i = 0;
|
|
3057
|
-
j = 0;
|
|
3058
|
-
for (let index = 0; index < data.length; index++) {
|
|
3059
|
-
i = (i + 1) % 256;
|
|
3060
|
-
j = (j + box[i]) % 256;
|
|
3061
|
-
[box[i], box[j]] = [box[j], box[i]];
|
|
3062
|
-
out[index] = data[index] ^ box[(box[i] + box[j]) % 256];
|
|
3063
|
-
}
|
|
3064
|
-
return out;
|
|
3065
|
-
}
|
|
3066
|
-
/**
|
|
3067
|
-
* 偶数位在前、奇数位在后 —— 站点对载荷做的拆分。
|
|
3068
|
-
*
|
|
3069
|
-
* 拆分本身是死代码({@link XBogus.interleave} 会把它原样拼回去),真正的作用只有
|
|
3070
|
-
* 一处:让 `1/256` 那一格被截断成整数。形状照抄站点,为的是别人对着 xbogus.py 读时
|
|
3071
|
-
* 能一句一句对上,也让「漏掉还原」这个坑不至于再犯一次。
|
|
3072
|
-
*/
|
|
3073
|
-
splitEvenOdd(values) {
|
|
3074
|
-
const head = [];
|
|
3075
|
-
const tail = [];
|
|
3076
|
-
for (let index = 0; index < values.length; index++) if (index % 2 === 0) head.push(values[index]);
|
|
3077
|
-
else tail.push(values[index]);
|
|
3078
|
-
return [...head, ...tail];
|
|
3079
|
-
}
|
|
3080
|
-
/**
|
|
3081
|
-
* 把 {@link XBogus.splitEvenOdd} 拆开的两段按下标交错回原序(顺便截断成整数)。
|
|
3082
|
-
*
|
|
3083
|
-
* **这一步不能省**:站点那个编码函数的形参表本身是乱序的 ——
|
|
3084
|
-
* V4 写作 `encoding_conversion(a, b, c, e, d, t, f, r, n, o, i, _, x, u, s, l, v, h, p)`,
|
|
3085
|
-
* 函数体按 `[a, i, b, _, c, x, e, u, d, s, t, l, f, v, r, h, n, p, o]` 拼接,
|
|
3086
|
-
* 传进去的又是拆开的两段,算下来正好还原成原始顺序。旧移植把它当成顺序形参处理,
|
|
3087
|
-
* 于是送进 RC4 的字节全体错位,签名从根上就是错的,还错得看不出来。
|
|
3088
|
-
*/
|
|
3089
|
-
interleave(values) {
|
|
3090
|
-
const head = values.slice(0, 10);
|
|
3091
|
-
const tail = values.slice(10);
|
|
3092
|
-
const merged = [];
|
|
3093
|
-
for (let index = 0; index < head.length; index++) {
|
|
3094
|
-
merged.push(Math.trunc(head[index]));
|
|
3095
|
-
if (index < tail.length) merged.push(Math.trunc(tail[index]));
|
|
3096
|
-
}
|
|
3097
|
-
return merged;
|
|
3098
|
-
}
|
|
3099
|
-
/** 三个载荷字节 → 四个输出字符 */
|
|
3100
|
-
encodeGroup(first, second, third) {
|
|
3101
|
-
const merged = (first & 255) << 16 | (second & 255) << 8 | third & 255;
|
|
3102
|
-
return CHARACTER[(merged & 16515072) >> 18] + CHARACTER[(merged & 258048) >> 12] + CHARACTER[(merged & 4032) >> 6] + CHARACTER[merged & 63];
|
|
3103
|
-
}
|
|
3104
|
-
/**
|
|
3105
|
-
* 生成 X-Bogus 签名。
|
|
3106
|
-
*
|
|
3107
|
-
* @param url - 完整的 URL 地址(签的是它的 pathname + search)
|
|
3108
|
-
* @param ua - 可选的 User-Agent,不提供则使用默认值
|
|
3109
|
-
* @param options - `timestamp` 注入点(秒级),省略时取当前时间
|
|
3110
|
-
* @returns 完整 URL(已带 `X-Bogus=`)、签名值与实际使用的 User-Agent
|
|
3111
|
-
*/
|
|
3112
|
-
getXBogus(url, ua, options = {}) {
|
|
3113
|
-
const parsedUrl = new URL$1.URL(url);
|
|
3114
|
-
const urlPath = parsedUrl.pathname + parsedUrl.search;
|
|
3115
|
-
const userAgent = ua ?? this.defaultUa;
|
|
3116
|
-
const userAgentDigest = this.userAgentDigest(userAgent);
|
|
3117
|
-
const emptyDigest = this.emptyDigest();
|
|
3118
|
-
const queryDigest = this.md5Encrypt(urlPath);
|
|
3119
|
-
const timestamp = options.timestamp ?? Math.floor(Date.now() / 1e3);
|
|
3120
|
-
const payload = [
|
|
3121
|
-
...PAYLOAD_LEAD,
|
|
3122
|
-
queryDigest[DIGEST_INDICES[0]],
|
|
3123
|
-
queryDigest[DIGEST_INDICES[1]],
|
|
3124
|
-
emptyDigest[DIGEST_INDICES[0]],
|
|
3125
|
-
emptyDigest[DIGEST_INDICES[1]],
|
|
3126
|
-
userAgentDigest[DIGEST_INDICES[0]],
|
|
3127
|
-
userAgentDigest[DIGEST_INDICES[1]],
|
|
3128
|
-
timestamp >> 24 & 255,
|
|
3129
|
-
timestamp >> 16 & 255,
|
|
3130
|
-
timestamp >> 8 & 255,
|
|
3131
|
-
timestamp & 255,
|
|
3132
|
-
32,
|
|
3133
|
-
0,
|
|
3134
|
-
190,
|
|
3135
|
-
144
|
|
3136
|
-
];
|
|
3137
|
-
let checksum = Math.trunc(payload[0]);
|
|
3138
|
-
for (let index = 1; index < payload.length; index++) checksum ^= Math.trunc(payload[index]);
|
|
3139
|
-
payload.push(checksum);
|
|
3140
|
-
const merged = this.interleave(this.splitEvenOdd(payload));
|
|
3141
|
-
const encrypted = this.rc4Encrypt(PAYLOAD_KEY, Buffer.from(merged));
|
|
3142
|
-
const garbled = String.fromCharCode(...ENVELOPE_LEAD) + encrypted.toString("latin1");
|
|
3143
|
-
let xbogus = "";
|
|
3144
|
-
for (let index = 0; index + 2 < garbled.length; index += 3) xbogus += this.encodeGroup(garbled.charCodeAt(index), garbled.charCodeAt(index + 1), garbled.charCodeAt(index + 2));
|
|
3145
|
-
return {
|
|
3146
|
-
fullUrl: url.includes("?") ? `${url}&X-Bogus=${xbogus}` : `${url}?X-Bogus=${xbogus}`,
|
|
3147
|
-
xbogus,
|
|
3148
|
-
userAgent
|
|
3149
|
-
};
|
|
3150
|
-
}
|
|
3151
|
-
};
|
|
3152
|
-
//#endregion
|
|
3153
|
-
//#region src/platforms/douyin/sign/index.ts
|
|
3154
|
-
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";
|
|
3155
|
-
var douyinSign$1 = class {
|
|
3156
|
-
/**
|
|
3157
|
-
* 生成一个指定长度的随机字符串
|
|
3158
|
-
* @param length 字符串长度,默认为116
|
|
3159
|
-
* @returns
|
|
3160
|
-
*/
|
|
3161
|
-
static Mstoken(length) {
|
|
3162
|
-
const characters = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
3163
|
-
const randomBytes = crypto.randomBytes(length ?? 116);
|
|
3164
|
-
return Array.from(randomBytes, (byte) => characters[byte % 62]).join("");
|
|
3165
|
-
}
|
|
3166
|
-
/**
|
|
3167
|
-
* a_bogus 签名算法
|
|
3168
|
-
* @param url 需要签名的地址
|
|
3169
|
-
* @returns 对此地址签名后的URL查询参数
|
|
3170
|
-
*/
|
|
3171
|
-
static AB(url, userAgent) {
|
|
3172
|
-
return a_bogus_default(url, userAgent ?? defaultUserAgent$1);
|
|
3173
|
-
}
|
|
3174
|
-
/**
|
|
3175
|
-
* X-Bogus 签名算法
|
|
3176
|
-
* @param url 需要签名的地址
|
|
3177
|
-
* @returns 对此地址签名后的URL查询参数
|
|
3178
|
-
*/
|
|
3179
|
-
static XB(url, userAgent) {
|
|
3180
|
-
return new XBogus().getXBogus(url, userAgent ?? defaultUserAgent$1).xbogus;
|
|
3181
|
-
}
|
|
3182
|
-
/**
|
|
3183
|
-
* 生成一个唯一的验证字符串
|
|
3184
|
-
*
|
|
3185
|
-
* 形状与旧实现一致(`verify_<base36 毫秒>_<36 位>`,分隔符在 8/13/18/23、
|
|
3186
|
-
* 版本位在 14、变体位在 19),但时钟来源从 `new Date().getTime()` 换成
|
|
3187
|
-
* `Date.now()` —— 原来那个绕开了 `vi.spyOn(Date, 'now')`,导致这个函数
|
|
3188
|
-
* **无法在测试里被冻结**,只能断言结构。现在可以钉死时钟与随机源了。
|
|
3189
|
-
*/
|
|
3190
|
-
static VerifyFpManager() {
|
|
3191
|
-
return genVerifyFp();
|
|
3192
|
-
}
|
|
3193
|
-
};
|
|
3194
|
-
//#endregion
|
|
3195
|
-
//#region src/platforms/douyin/sign/secsdkWebSign.ts
|
|
3196
|
-
/**
|
|
3197
|
-
* 抖音主站 `x-secsdk-web-signature` 纯算实现(TypeScript 移植)
|
|
3198
|
-
*
|
|
3199
|
-
* 移植自 cv-cat/DouYin_Spider `utils/secsdk_web_sign.py`,算法与常量按该实现逐条对齐。
|
|
3200
|
-
*
|
|
3201
|
-
* ## 算法
|
|
3202
|
-
*
|
|
3203
|
-
* ```text
|
|
3204
|
-
* plain = {uifid}_{timestamp}_{WEBSIGN_CONST}_{canonical_query}
|
|
3205
|
-
* signature = md5(plain) 的 32 位小写十六进制
|
|
3206
|
-
* ```
|
|
3207
|
-
*
|
|
3208
|
-
* `WEBSIGN_CONST` 是 secsdk VMP 常量池里的写死盐,与账号 / 会话无关。
|
|
3209
|
-
*
|
|
3210
|
-
* ## canonical_query 规范化规则
|
|
3211
|
-
*
|
|
3212
|
-
* - 保持原始参数顺序,**不排序**;重复参数原样保留。
|
|
3213
|
-
* - 每个 value 先解码(`+`→空格、`%XX`→字节)再用 `encodeURIComponent` 重编码。
|
|
3214
|
-
* - key **只解码、不重编码**。
|
|
3215
|
-
* - 无等号的裸参数补成 `k=`。
|
|
3216
|
-
* - `×tamp={ts}` 追加到 query 末尾后再参与哈希。
|
|
3217
|
-
* - query 里没有 `uifid` 时,从 `UIFID` cookie 取值追加到末尾再签。
|
|
3218
|
-
*
|
|
3219
|
-
* ## 重要
|
|
3220
|
-
*
|
|
3221
|
-
* 签名算的是**规范化后的 query**,服务端也按收到的 query 校验,所以必须发送
|
|
3222
|
-
* {@link signSecsdkWebUrl} 返回的 URL,不能拿原始 URL 直接发。
|
|
3223
|
-
*
|
|
3224
|
-
* 签名绑 query、**不绑 path**(本项目实测:同一份 query 换 path 仍然 200)。
|
|
3225
|
-
*
|
|
3226
|
-
* @module platform/douyin/secsdkWebSign
|
|
3227
|
-
*/
|
|
3228
|
-
/** secsdk VM 常量池里的固定盐(与账号 / 会话无关) */
|
|
3229
|
-
const WEBSIGN_CONST = "A96D855A08C0A9707F8BEF0D9A527E4E";
|
|
3230
|
-
/** 签名参数名 */
|
|
3231
|
-
const SECSDK_SIG_KEY = "x-secsdk-web-signature";
|
|
3232
|
-
/** 时间戳参数名 */
|
|
3233
|
-
const SECSDK_TS_KEY = "timestamp";
|
|
3234
|
-
/** 走 secsdk 签名的 GET 接口路径(来自 SDK 的 webSign 策略配置) */
|
|
3235
|
-
const PROTECTED_PATHS_GET = [
|
|
3236
|
-
"/aweme/v1/web/aweme/detail/",
|
|
3237
|
-
"/aweme/v1/web/aweme/post/",
|
|
3238
|
-
"/aweme/v1/web/aweme/favorite/",
|
|
3239
|
-
"/aweme/v1/web/aweme/listcollection/",
|
|
3240
|
-
"/aweme/v1/web/mix/aweme/",
|
|
3241
|
-
"/aweme/v1/web/tab/feed/",
|
|
3242
|
-
"/aweme/v1/web/mix/list/",
|
|
3243
|
-
"/aweme/v1/web/music/aweme/",
|
|
3244
|
-
"/aweme/v1/web/music/list/",
|
|
3245
|
-
"/aweme/v1/web/mix/detail/",
|
|
3246
|
-
"/aweme/v1/web/mix/listcollection/",
|
|
3247
|
-
"/aweme/v1/web/music/detail/",
|
|
3248
|
-
"/aweme/v1/web/collects/list/",
|
|
3249
|
-
"/aweme/v1/web/collects/video/list/"
|
|
3250
|
-
];
|
|
3251
|
-
/** 走 secsdk 签名的 POST 接口路径 */
|
|
3252
|
-
const PROTECTED_PATHS_POST = [
|
|
3253
|
-
"/aweme/v1/web/aweme/detail/",
|
|
3254
|
-
"/aweme/v1/web/aweme/post/",
|
|
3255
|
-
"/aweme/v1/web/aweme/favorite/",
|
|
3256
|
-
"/aweme/v1/web/aweme/listcollection/",
|
|
3257
|
-
"/aweme/v1/web/mix/aweme/",
|
|
3258
|
-
"/aweme/v1/web/tab/feed/"
|
|
3259
|
-
];
|
|
3260
|
-
const HEX_PAIR = /^[0-9A-Fa-f]{2}$/;
|
|
3261
|
-
/**
|
|
3262
|
-
* 宽容的百分号解码,等价于 Python `urllib.parse.unquote(..., errors='replace')`:
|
|
3263
|
-
* 合法的 `%XX` 解成字节,非法的 `%` 原样保留,最后按 UTF-8 解码(坏字节 → U+FFFD)。
|
|
3264
|
-
*
|
|
3265
|
-
* 不能直接用 `decodeURIComponent` —— 它遇到 `100%` 这种孤立百分号会抛异常,
|
|
3266
|
-
* 而 SDK 的行为是把它当字面量留下(`100%` → 签成 `100%25`)。
|
|
3267
|
-
*
|
|
3268
|
-
* @param input - 待解码字符串
|
|
3269
|
-
* @returns 解码后的字符串
|
|
3270
|
-
*/
|
|
3271
|
-
const percentDecodeLoose = (input) => {
|
|
3272
|
-
if (!input.includes("%")) return input;
|
|
3273
|
-
const bytes = [];
|
|
3274
|
-
let i = 0;
|
|
3275
|
-
while (i < input.length) {
|
|
3276
|
-
if (input[i] === "%" && HEX_PAIR.test(input.slice(i + 1, i + 3))) {
|
|
3277
|
-
bytes.push(parseInt(input.slice(i + 1, i + 3), 16));
|
|
3278
|
-
i += 3;
|
|
3279
|
-
continue;
|
|
3280
|
-
}
|
|
3281
|
-
for (const byte of Buffer.from(input[i], "utf8")) bytes.push(byte);
|
|
3282
|
-
i += 1;
|
|
3283
|
-
}
|
|
3284
|
-
return Buffer.from(bytes).toString("utf8");
|
|
3285
|
-
};
|
|
2453
|
+
//#region src/platforms/douyin/sign/signers.ts
|
|
3286
2454
|
/**
|
|
3287
|
-
*
|
|
2455
|
+
* 抖音签名器表:**只做「注册名 → `SignStep` 清单」的映射,不含算法**。
|
|
3288
2456
|
*
|
|
3289
|
-
*
|
|
3290
|
-
*
|
|
3291
|
-
|
|
3292
|
-
|
|
3293
|
-
/**
|
|
3294
|
-
* 按 SDK 规则规范化 query 字符串:顺序不变,value 解码后用 `encodeURIComponent` 重编码,
|
|
3295
|
-
* key 只解码不重编码,无等号的裸参数补成 `k=`。
|
|
2457
|
+
* 算法的唯一实现在 `./steps.ts`(含前置条件校验、webid 补参、secsdk 收尾的理由)。
|
|
2458
|
+
* 依赖方向是单向的 —— `signers.ts → steps.ts`,永不反向:两份实现并存时会改一边忘一边,
|
|
2459
|
+
* 而抖音的签名是**抽样校验**的(签错时大部分请求照样成功,只是被判高风险的概率上升),
|
|
2460
|
+
* 这类分叉极难发现。
|
|
3296
2461
|
*
|
|
3297
|
-
*
|
|
3298
|
-
* @returns 规范化后的 query 字符串
|
|
3299
|
-
*/
|
|
3300
|
-
const canonicalQuery = (query) => {
|
|
3301
|
-
const parts = [];
|
|
3302
|
-
for (const pair of query.split("&")) {
|
|
3303
|
-
if (!pair) continue;
|
|
3304
|
-
const eq = pair.indexOf("=");
|
|
3305
|
-
const rawKey = eq === -1 ? pair : pair.slice(0, eq);
|
|
3306
|
-
const rawValue = eq === -1 ? "" : pair.slice(eq + 1);
|
|
3307
|
-
parts.push(`${unquotePlusLoose(rawKey)}=${encodeURIComponent(unquotePlusLoose(rawValue))}`);
|
|
3308
|
-
}
|
|
3309
|
-
return parts.join("&");
|
|
3310
|
-
};
|
|
3311
|
-
/**
|
|
3312
|
-
* 拆成 `[base, query]`,并剥掉已有的 `timestamp` / 签名字段,保证可重复调用。
|
|
2462
|
+
* ## 两条路,一份实现
|
|
3313
2463
|
*
|
|
3314
|
-
*
|
|
3315
|
-
*
|
|
3316
|
-
|
|
3317
|
-
const splitUrl = (url) => {
|
|
3318
|
-
const mark = url.indexOf("?");
|
|
3319
|
-
if (mark === -1) return [url, ""];
|
|
3320
|
-
return [url.slice(0, mark), url.slice(mark + 1).split("&").filter((pair) => {
|
|
3321
|
-
if (!pair) return false;
|
|
3322
|
-
const name = pair.split("=", 1)[0];
|
|
3323
|
-
return name !== "timestamp" && name !== "x-secsdk-web-signature";
|
|
3324
|
-
}).join("&")];
|
|
3325
|
-
};
|
|
3326
|
-
/**
|
|
3327
|
-
* 从 cookie 字符串里取 `UIFID`。
|
|
2464
|
+
* 端点声明 `sign: douyinBogus(184)` 走清单;`client.douyin.request` 不给 `amagi.sign`
|
|
2465
|
+
* 时走字符串 `'a-bogus'`(见 `client/request/profile.ts`)查这张表。两者现在压的是同一份
|
|
2466
|
+
* step 清单,所以签出来必然一致。
|
|
3328
2467
|
*
|
|
3329
|
-
*
|
|
3330
|
-
* 否则会取错。
|
|
2468
|
+
* ## 为什么注册名对应的是三段清单
|
|
3331
2469
|
*
|
|
3332
|
-
*
|
|
3333
|
-
*
|
|
3334
|
-
*/
|
|
3335
|
-
const extractUifidFromCookie = (cookie) => {
|
|
3336
|
-
if (!cookie) return "";
|
|
3337
|
-
const hit = /(?:^|;\s*)UIFID=([^;]*)/.exec(cookie);
|
|
3338
|
-
return hit ? hit[1].trim() : "";
|
|
3339
|
-
};
|
|
3340
|
-
/**
|
|
3341
|
-
* 判断该 path 是否需要 secsdk 加签(依据 SDK 的 webSign 策略配置)。
|
|
2470
|
+
* 真实浏览器发这些请求的顺序是 **webid → msToken → a_bogus / x_bogus → secsdk**,每一步都改
|
|
2471
|
+
* query,颠倒任意一步签名就不成立。顺序不靠这里保证,由 `SignPhase` 的固定次序保证。
|
|
3342
2472
|
*
|
|
3343
|
-
*
|
|
3344
|
-
*
|
|
3345
|
-
* @returns 是否需要加签
|
|
3346
|
-
*/
|
|
3347
|
-
const isSecsdkProtected = (path, method = "GET") => {
|
|
3348
|
-
return (String(method).toUpperCase() === "POST" ? PROTECTED_PATHS_POST : PROTECTED_PATHS_GET).includes(path);
|
|
3349
|
-
};
|
|
3350
|
-
/**
|
|
3351
|
-
* 计算 `x-secsdk-web-signature`。
|
|
3352
|
-
*
|
|
3353
|
-
* @param url - 完整 API URL(scheme+host+path+query)。已带 `timestamp` /
|
|
3354
|
-
* `x-secsdk-web-signature` 时会自动剥除,可重复调用。
|
|
3355
|
-
* @param options - 时间戳与 uifid 兜底值
|
|
3356
|
-
* @returns 时间戳、签名与**实际应当发送**的规范化 query
|
|
3357
|
-
*/
|
|
3358
|
-
const signSecsdkWebQuery = (url, options = {}) => {
|
|
3359
|
-
const ts = Math.trunc(options.ts ?? Date.now() / 1e3);
|
|
3360
|
-
const [, rawQuery] = splitUrl(url);
|
|
3361
|
-
let canon = canonicalQuery(rawQuery);
|
|
3362
|
-
if (!canon.split("&").filter(Boolean).map((pair) => pair.split("=", 1)[0]).includes("uifid") && options.uifid) {
|
|
3363
|
-
const appended = `uifid=${encodeURIComponent(options.uifid)}`;
|
|
3364
|
-
canon = canon ? `${canon}&${appended}` : appended;
|
|
3365
|
-
}
|
|
3366
|
-
const signedQuery = canon ? `${canon}&${SECSDK_TS_KEY}=${ts}` : `${SECSDK_TS_KEY}=${ts}`;
|
|
3367
|
-
let uifidValue = "";
|
|
3368
|
-
for (const pair of signedQuery.split("&")) if (pair.startsWith("uifid=")) {
|
|
3369
|
-
uifidValue = unquotePlusLoose(pair.slice(6));
|
|
3370
|
-
break;
|
|
3371
|
-
}
|
|
3372
|
-
if (!uifidValue && options.uifid) uifidValue = options.uifid;
|
|
3373
|
-
const plain = `${uifidValue}_${ts}_${WEBSIGN_CONST}_${signedQuery}`;
|
|
3374
|
-
return {
|
|
3375
|
-
ts,
|
|
3376
|
-
signature: crypto.createHash("md5").update(plain, "utf8").digest("hex"),
|
|
3377
|
-
signedQuery
|
|
3378
|
-
};
|
|
3379
|
-
};
|
|
3380
|
-
/**
|
|
3381
|
-
* 返回可直接发送的完整 URL(规范化 query + timestamp + 签名)。
|
|
2473
|
+
* 其中 webid 与 secsdk 那两段都是「命中才动、否则原样返回」,对不相关的端点是无操作,
|
|
2474
|
+
* 所以可以无条件套用 —— 详见 `./steps.ts` 文件头。
|
|
3382
2475
|
*
|
|
3383
|
-
*
|
|
2476
|
+
* ## 为什么 secsdk 没有自己的注册名
|
|
3384
2477
|
*
|
|
3385
|
-
*
|
|
3386
|
-
*
|
|
3387
|
-
*
|
|
3388
|
-
*/
|
|
3389
|
-
const signSecsdkWebUrl = (url, options = {}) => {
|
|
3390
|
-
const [base] = splitUrl(url);
|
|
3391
|
-
const { signature, signedQuery } = signSecsdkWebQuery(url, options);
|
|
3392
|
-
return `${base}?${signedQuery}&${SECSDK_SIG_KEY}=${signature}`;
|
|
3393
|
-
};
|
|
3394
|
-
/**
|
|
3395
|
-
* 对受保护接口补上 secsdk 签名;path 不在策略表里时原样返回。
|
|
3396
|
-
*
|
|
3397
|
-
* 这是给取数层用的唯一入口 —— 无条件套在 URL 上即可,不需要调用方判断端点。
|
|
2478
|
+
* 它对策略表外的 path 是无操作,无条件复合进这两个名字是安全的;而 `sign` 是单槽位,
|
|
2479
|
+
* 另起一个名字只会逼出 `'a-bogus+secsdk'` 这种复合命名。要精细控制的调用方给 step
|
|
2480
|
+
* 清单,不是加注册名。
|
|
3398
2481
|
*
|
|
3399
|
-
* @
|
|
3400
|
-
* @param options - cookie / uifid / method / ts
|
|
3401
|
-
* @returns 需要加签的返回带签名的 URL,否则原样返回
|
|
2482
|
+
* @module platforms/douyin/sign/signers
|
|
3402
2483
|
*/
|
|
3403
|
-
const applySecsdkWebSign = (url, options = {}) => {
|
|
3404
|
-
let pathname;
|
|
3405
|
-
try {
|
|
3406
|
-
pathname = new URL(url).pathname;
|
|
3407
|
-
} catch {
|
|
3408
|
-
return url;
|
|
3409
|
-
}
|
|
3410
|
-
if (!isSecsdkProtected(pathname, options.method)) return url;
|
|
3411
|
-
const uifid = options.uifid || extractUifidFromCookie(options.cookie);
|
|
3412
|
-
return signSecsdkWebUrl(url, {
|
|
3413
|
-
ts: options.ts,
|
|
3414
|
-
uifid
|
|
3415
|
-
});
|
|
3416
|
-
};
|
|
3417
|
-
//#endregion
|
|
3418
|
-
//#region src/platforms/douyin/sign/signers.ts
|
|
3419
|
-
/**
|
|
3420
|
-
* 抖音签名器(SignFn 形式)。
|
|
3421
|
-
*
|
|
3422
|
-
* 签名器在入口先校验前置条件:
|
|
3423
|
-
* - **AB 需绝对 URL**(以 `http(s)://` 开头)。
|
|
3424
|
-
* - **XB 需真实接口形态的长路径**(pathname ≥ 3 段且带查询串)。
|
|
3425
|
-
*
|
|
3426
|
-
* 前置条件不满足时签名器**抛带明确 message 的错误** —— execute 的
|
|
3427
|
-
* 单一 catch 把它归因为 `kind: 'internal'` / `INTERNAL_ERROR` 收进失败
|
|
3428
|
-
* 信封,调用方不会拿到裸的 `TypeError`。这些条件由 `build` 保证满足
|
|
3429
|
-
* (URL 构造器只产出合法绝对地址),签名器里的校验只是防线。
|
|
3430
|
-
*
|
|
3431
|
-
* ## 为什么这两个签名器前后各多一步
|
|
3432
|
-
*
|
|
3433
|
-
* 真实浏览器发这些请求的顺序是 **webid → a_bogus / x_bogus → secsdk**,三步都改
|
|
3434
|
-
* query,颠倒任意一步签名就不成立。所以两个签名器各自是一条三段的管线,
|
|
3435
|
-
* 前后那两段都是「命中才动、否则原样返回」,对不相关的端点是无操作。
|
|
3436
|
-
*
|
|
3437
|
-
* **前一步 webid**(见 {@link withDouyinWebid}):抖音会拿 query 里的 `webid` 与 cookie
|
|
3438
|
-
* 会话交叉校验,对不上就静默回 0 字节。它是服务端下发的、客户端算不出来,所以只在
|
|
3439
|
-
* 按 ttwid 缓存命中时才补 —— 冷启动第一次不带(不带是安全的)。签名器的入参是
|
|
3440
|
-
* `(spec, ctx)`,能读 `ctx.cookie`。
|
|
3441
|
-
*
|
|
3442
|
-
* **后一步 secsdk**:`x-secsdk-web-signature` 是抖音主站的第三种签名,浏览器里由
|
|
3443
|
-
* secsdk 的 JS 现算。它与 AB / XB 有三点不同(详见 {@link applySecsdkWebSign} 所在模块):
|
|
3444
|
-
*
|
|
3445
|
-
* 1. **它改写整条 URL**,不是返回一个参数值 —— 签名算的是规范化后的 query,
|
|
3446
|
-
* 服务端也按收到的 query 校验;
|
|
3447
|
-
* 2. **只对 SDK 策略表里的 path 生效**(14 个 GET / 6 个 POST),其余原样返回,
|
|
3448
|
-
* 所以可以无条件套用;
|
|
3449
|
-
* 3. **必须是最后一步**。
|
|
3450
|
-
*
|
|
3451
|
-
* 因为第 2 点是无条件安全的,它没有单独注册成第三个签名器名,而是复合进这两个 ——
|
|
3452
|
-
* `sign` 是单槽位,另起一个名字只会逼出 `'a-bogus+secsdk'` 这种复合命名。
|
|
3453
|
-
* 影响面:`musicInfo`(`music/detail`)、作品详情、用户作品、喜欢列表都在策略表内。
|
|
3454
|
-
* `sign: false` 的两条(`emojiList` / `search`)与四条免鉴权端点不经过这里,
|
|
3455
|
-
* 它们的 path 也都不在策略表里、也拿不到 webid。
|
|
3456
|
-
*/
|
|
3457
|
-
/** AB 前置条件:绝对 URL(`http(s)://` 开头) */
|
|
3458
|
-
const isAbsoluteUrl = (url) => /^https?:\/\//.test(url);
|
|
3459
|
-
/** XB 前置条件:真实接口形态 —— pathname 至少 3 段且带查询串 */
|
|
3460
|
-
const isApiLikePath = (url) => {
|
|
3461
|
-
if (!isAbsoluteUrl(url)) return false;
|
|
3462
|
-
const parsed = new URL(url);
|
|
3463
|
-
return parsed.pathname.split("/").filter(Boolean).length >= 3 && parsed.search.length > 0;
|
|
3464
|
-
};
|
|
3465
2484
|
/**
|
|
3466
|
-
*
|
|
2485
|
+
* 平台签名器表,交给 runtime 的 `signers` 查名。
|
|
3467
2486
|
*
|
|
3468
|
-
*
|
|
3469
|
-
*
|
|
3470
|
-
*
|
|
3471
|
-
* @returns 需要加签时返回改写过 URL 的请求描述,否则原样返回
|
|
3472
|
-
*/
|
|
3473
|
-
const withSecsdk = (spec, cookie) => {
|
|
3474
|
-
const url = applySecsdkWebSign(spec.url, {
|
|
3475
|
-
cookie,
|
|
3476
|
-
method: spec.method
|
|
3477
|
-
});
|
|
3478
|
-
return url === spec.url ? spec : {
|
|
3479
|
-
...spec,
|
|
3480
|
-
url
|
|
3481
|
-
};
|
|
3482
|
-
};
|
|
3483
|
-
/**
|
|
3484
|
-
* `a_bogus` 签名器(`sign: 'a-bogus'`)。
|
|
3485
|
-
*
|
|
3486
|
-
* 前置条件:URL 必须是绝对地址。不满足时抛错,由 execute 归因为
|
|
3487
|
-
* `kind: 'internal'`。
|
|
3488
|
-
* @param spec - 请求描述(`url` 参与签名)
|
|
3489
|
-
* @param ctx - 执行上下文(`userAgent` 用于签名,`cookie` 用于取 secsdk 的 uifid)
|
|
3490
|
-
* @returns 带 `a_bogus` 的请求描述;path 在 secsdk 策略表内时再补一层 `x-secsdk-web-signature`
|
|
3491
|
-
*/
|
|
3492
|
-
const aBogusSigner = (spec, ctx) => {
|
|
3493
|
-
if (!isAbsoluteUrl(spec.url)) throw new Error(`a_bogus 前置条件不满足:URL 必须是绝对地址(收到 "${spec.url}")`);
|
|
3494
|
-
const url = new URL(withDouyinWebid(spec.url, ctx.cookie));
|
|
3495
|
-
url.searchParams.set("a_bogus", douyinSign$1.AB(url.toString(), ctx.userAgent));
|
|
3496
|
-
return withSecsdk({
|
|
3497
|
-
...spec,
|
|
3498
|
-
url: url.toString()
|
|
3499
|
-
}, ctx.cookie);
|
|
3500
|
-
};
|
|
3501
|
-
/**
|
|
3502
|
-
* `x_bogus` 签名器(`sign: 'x-bogus'`)。
|
|
2487
|
+
* 两个名字都取 **184** 那档 msToken 长度:这张表只在 `client.<平台>.request` 那条路上被查到,
|
|
2488
|
+
* 而那条路打的是「本库还没收录的接口」—— 猜不出该用 184(作品详情类)还是 116(评论 / 音乐类),
|
|
2489
|
+
* 统一取前者。收录进端点的接口各自在声明里写准确的那一档,不经过这里。
|
|
3503
2490
|
*
|
|
3504
|
-
*
|
|
3505
|
-
*
|
|
3506
|
-
* @
|
|
3507
|
-
* @param ctx - 执行上下文(`userAgent` 用于签名)
|
|
3508
|
-
* @returns 带 `X-Bogus` 的请求描述;path 在 secsdk 策略表内时再补一层 `x-secsdk-web-signature`
|
|
2491
|
+
* 用 `satisfies` 而非显式 `: Record<string, SignFn>` 返回:后者会把键联合抹成宽
|
|
2492
|
+
* `string`,而 {@link DouyinSignerName} 要靠 `keyof` 从这张表推导出精确的名字联合。
|
|
2493
|
+
* @returns 注册名 → 签名器
|
|
3509
2494
|
*/
|
|
3510
|
-
const xBogusSigner = (spec, ctx) => {
|
|
3511
|
-
if (!isApiLikePath(spec.url)) throw new Error(`x_bogus 前置条件不满足:URL 需真实接口形态的长路径(≥3 段且带查询串,收到 "${spec.url}")`);
|
|
3512
|
-
const url = new URL(withDouyinWebid(spec.url, ctx.cookie));
|
|
3513
|
-
url.searchParams.set("X-Bogus", douyinSign$1.XB(url.toString(), ctx.userAgent));
|
|
3514
|
-
return withSecsdk({
|
|
3515
|
-
...spec,
|
|
3516
|
-
url: url.toString()
|
|
3517
|
-
}, ctx.cookie);
|
|
3518
|
-
};
|
|
3519
|
-
/** 平台签名器表,交给 runtime 的 `signers` 查名 */
|
|
3520
2495
|
const createDouyinSigners = () => ({
|
|
3521
|
-
"a-bogus":
|
|
3522
|
-
"x-bogus":
|
|
2496
|
+
"a-bogus": stepsToSigner(douyinBogus(184)),
|
|
2497
|
+
"x-bogus": stepsToSigner(douyinXBogus(184))
|
|
3523
2498
|
});
|
|
3524
2499
|
//#endregion
|
|
3525
2500
|
//#region src/platforms/kuaishou/captcha.ts
|
|
@@ -3884,1140 +2859,20 @@ const kuaishouResultVerdict = (raw) => {
|
|
|
3884
2859
|
};
|
|
3885
2860
|
};
|
|
3886
2861
|
//#endregion
|
|
3887
|
-
//#region src/platforms/kuaishou/sign/hudr.ts
|
|
3888
|
-
const KUAISHOU_HUDR_PREFIX = "HUDR_";
|
|
3889
|
-
const KUAISHOU_HUDR_MASK_BYTE = 35;
|
|
3890
|
-
const KUAISHOU_HUDR_BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
|
3891
|
-
const KUAISHOU_HUDR_CHACHA_KEY = [
|
|
3892
|
-
4183807412,
|
|
3893
|
-
394484062,
|
|
3894
|
-
1106561997,
|
|
3895
|
-
2378328696,
|
|
3896
|
-
630790222,
|
|
3897
|
-
2546784104,
|
|
3898
|
-
2891127470,
|
|
3899
|
-
1922531795
|
|
3900
|
-
];
|
|
3901
|
-
const KUAISHOU_HUDR_CHACHA_NONCE = [
|
|
3902
|
-
2215853858,
|
|
3903
|
-
1643070585,
|
|
3904
|
-
1849059804
|
|
3905
|
-
];
|
|
3906
|
-
/** 将字符串转换为 Unicode code point 数组。 */
|
|
3907
|
-
const toCodePointArray = (value) => {
|
|
3908
|
-
return Array.from(value).map((char) => char.codePointAt(0) ?? 0);
|
|
3909
|
-
};
|
|
3910
|
-
/** 以小端序输出固定长度字节数组。 */
|
|
3911
|
-
const toLittleEndianBytes = (value, size = 4) => {
|
|
3912
|
-
if (size >= 4 && value >= Math.pow(2, 32)) return [
|
|
3913
|
-
255,
|
|
3914
|
-
255,
|
|
3915
|
-
255,
|
|
3916
|
-
255
|
|
3917
|
-
];
|
|
3918
|
-
const result = [];
|
|
3919
|
-
for (let index = 0; index <= size - 1; index++) result[index] = value >>> 8 * index & 255;
|
|
3920
|
-
return result;
|
|
3921
|
-
};
|
|
3922
|
-
/** 对字节切片执行标准 Base64 编码。 */
|
|
3923
|
-
const encodeBase64Slice = (bytes, start, end) => {
|
|
3924
|
-
const result = [];
|
|
3925
|
-
for (let index = start; index < end; index += 3) {
|
|
3926
|
-
const value = (bytes[index] << 16 & 16711680) + (bytes[index + 1] << 8 & 65280) + (bytes[index + 2] & 255);
|
|
3927
|
-
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]);
|
|
3928
|
-
}
|
|
3929
|
-
return result.join("");
|
|
3930
|
-
};
|
|
3931
|
-
/** 快手 `HUDR_` 使用的 Base64URL 变体编码。 */
|
|
3932
|
-
const encodeBase64Url = (bytes) => {
|
|
3933
|
-
const result = [];
|
|
3934
|
-
const remainder = bytes.length % 3;
|
|
3935
|
-
const tripletLength = bytes.length - remainder;
|
|
3936
|
-
for (let index = 0; index < tripletLength; index += 16383) {
|
|
3937
|
-
const end = tripletLength < index + 16383 ? tripletLength : index + 16383;
|
|
3938
|
-
result.push(encodeBase64Slice(bytes, index, end));
|
|
3939
|
-
}
|
|
3940
|
-
if (remainder === 1) {
|
|
3941
|
-
const value = bytes[bytes.length - 1];
|
|
3942
|
-
result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 2], KUAISHOU_HUDR_BASE64_ALPHABET[value << 4 & 63], "==");
|
|
3943
|
-
} else if (remainder === 2) {
|
|
3944
|
-
const value = (bytes[bytes.length - 2] << 8) + bytes[bytes.length - 1];
|
|
3945
|
-
result.push(KUAISHOU_HUDR_BASE64_ALPHABET[value >> 10], KUAISHOU_HUDR_BASE64_ALPHABET[value >> 4 & 63], KUAISHOU_HUDR_BASE64_ALPHABET[value << 2 & 63], "=");
|
|
3946
|
-
}
|
|
3947
|
-
return result.join("").replace(/[+/=]/g, (char) => {
|
|
3948
|
-
if (char === "+") return "-";
|
|
3949
|
-
if (char === "/") return "_";
|
|
3950
|
-
return ".";
|
|
3951
|
-
});
|
|
3952
|
-
};
|
|
3953
|
-
/**
|
|
3954
|
-
* 快手 `HUDR_` 所用的 ChaCha20 变体实现。
|
|
3955
|
-
*
|
|
3956
|
-
* 该实现并非通用密码库封装,而是为对齐页面 `$encode`
|
|
3957
|
-
* 数据流而最小化保留的局部算法。
|
|
3958
|
-
*/
|
|
3959
|
-
var KuaishouChaChaCipher = class {
|
|
3960
|
-
key;
|
|
3961
|
-
nonce;
|
|
3962
|
-
wordIndex = 0;
|
|
3963
|
-
state = new Array(16).fill(0);
|
|
3964
|
-
constructor(key, nonce) {
|
|
3965
|
-
this.key = key;
|
|
3966
|
-
this.nonce = nonce;
|
|
3967
|
-
}
|
|
3968
|
-
rotateLeft(value, shift) {
|
|
3969
|
-
return (value << shift | value >>> 32 - shift) >>> 0;
|
|
3970
|
-
}
|
|
3971
|
-
quarterRound(target, a, b, c, d) {
|
|
3972
|
-
target[a] = target[a] + target[b] >>> 0;
|
|
3973
|
-
target[d] ^= target[a];
|
|
3974
|
-
target[d] = this.rotateLeft(target[d], 16);
|
|
3975
|
-
target[c] = target[c] + target[d] >>> 0;
|
|
3976
|
-
target[b] ^= target[c];
|
|
3977
|
-
target[b] = this.rotateLeft(target[b], 12);
|
|
3978
|
-
target[a] = target[a] + target[b] >>> 0;
|
|
3979
|
-
target[d] ^= target[a];
|
|
3980
|
-
target[d] = this.rotateLeft(target[d], 8);
|
|
3981
|
-
target[c] = target[c] + target[d] >>> 0;
|
|
3982
|
-
target[b] ^= target[c];
|
|
3983
|
-
target[b] = this.rotateLeft(target[b], 7);
|
|
3984
|
-
}
|
|
3985
|
-
refillBlock() {
|
|
3986
|
-
const working = this.state.slice();
|
|
3987
|
-
for (let round = 0; round < 20; round += 2) {
|
|
3988
|
-
this.quarterRound(working, 0, 4, 8, 12);
|
|
3989
|
-
this.quarterRound(working, 1, 5, 9, 13);
|
|
3990
|
-
this.quarterRound(working, 2, 6, 10, 14);
|
|
3991
|
-
this.quarterRound(working, 3, 7, 11, 15);
|
|
3992
|
-
this.quarterRound(working, 0, 5, 10, 15);
|
|
3993
|
-
this.quarterRound(working, 1, 6, 11, 12);
|
|
3994
|
-
this.quarterRound(working, 2, 7, 8, 13);
|
|
3995
|
-
this.quarterRound(working, 3, 4, 9, 14);
|
|
3996
|
-
}
|
|
3997
|
-
const mixedState = new Array(16).fill(0);
|
|
3998
|
-
for (let index = 0; index < 16; index++) mixedState[index] = working[index] + this.state[index] >>> 0;
|
|
3999
|
-
return mixedState;
|
|
4000
|
-
}
|
|
4001
|
-
encrypt(input) {
|
|
4002
|
-
this.wordIndex = 0;
|
|
4003
|
-
this.state[0] = 394484062;
|
|
4004
|
-
this.state[1] = 2378328696;
|
|
4005
|
-
this.state[2] = 630790222;
|
|
4006
|
-
this.state[3] = 1922531795;
|
|
4007
|
-
for (let index = 0; index < 8; index++) this.state[index + 4] = this.key[index] >>> 0;
|
|
4008
|
-
this.state[12] = 1;
|
|
4009
|
-
this.state[13] = this.nonce[0] >>> 0;
|
|
4010
|
-
this.state[14] = this.nonce[1] >>> 0;
|
|
4011
|
-
this.state[15] = this.nonce[2] >>> 0;
|
|
4012
|
-
let mixedState = this.refillBlock();
|
|
4013
|
-
const output = new Uint8Array(input.length);
|
|
4014
|
-
for (let index = 0; index < input.length; index++) {
|
|
4015
|
-
if (this.wordIndex === 64) {
|
|
4016
|
-
this.state[12] = this.state[12] + 1 >>> 0;
|
|
4017
|
-
mixedState = this.refillBlock();
|
|
4018
|
-
this.wordIndex = 0;
|
|
4019
|
-
}
|
|
4020
|
-
const keystreamByte = mixedState[this.wordIndex >> 2] >>> ((this.wordIndex & 3) << 3) & 255;
|
|
4021
|
-
this.wordIndex += 1;
|
|
4022
|
-
output[index] = input[index] ^ keystreamByte;
|
|
4023
|
-
}
|
|
4024
|
-
return output;
|
|
4025
|
-
}
|
|
4026
|
-
};
|
|
4027
|
-
/**
|
|
4028
|
-
* 构造 `HUDR_` 头部中的 `infoCache` 字段。
|
|
4029
|
-
*
|
|
4030
|
-
* @param scriptCount - 页面脚本数量
|
|
4031
|
-
* @returns `HUDR_` 载荷中的脚本信息缓存字节
|
|
4032
|
-
*/
|
|
4033
|
-
const buildKuaishouHudrInfoCache = (scriptCount = 0) => {
|
|
4034
|
-
return [68, 0].concat(toLittleEndianBytes(scriptCount, 4));
|
|
4035
|
-
};
|
|
4036
|
-
/**
|
|
4037
|
-
* 构造快手 `HUDR_` 原始载荷。
|
|
4038
|
-
*
|
|
4039
|
-
* @param context - 生成 `HUDR_` 所需的上下文参数
|
|
4040
|
-
* @returns 尚未异或和加密的原始字节载荷
|
|
4041
|
-
*/
|
|
4042
|
-
const buildKuaishouHudrPayload = (context) => {
|
|
4043
|
-
const stackTail = context.secs?.s ?? "";
|
|
4044
|
-
const secsCount = context.secs?.c ?? 0;
|
|
4045
|
-
const count = context.count || 0;
|
|
4046
|
-
return [
|
|
4047
|
-
45,
|
|
4048
|
-
61,
|
|
4049
|
-
0,
|
|
4050
|
-
2
|
|
4051
|
-
].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));
|
|
4052
|
-
};
|
|
4053
|
-
/**
|
|
4054
|
-
* 对 `HUDR_` 原始载荷执行页面同款掩码异或。
|
|
4055
|
-
*
|
|
4056
|
-
* @param payload - 原始 `HUDR_` 字节载荷
|
|
4057
|
-
* @returns 掩码处理后的字节数组
|
|
4058
|
-
*/
|
|
4059
|
-
const maskKuaishouHudrPayload = (payload) => {
|
|
4060
|
-
const masked = new Uint8Array(payload.length);
|
|
4061
|
-
for (let index = 0; index < payload.length; index++) masked[index] = KUAISHOU_HUDR_MASK_BYTE ^ payload[index];
|
|
4062
|
-
return masked;
|
|
4063
|
-
};
|
|
4064
|
-
/**
|
|
4065
|
-
* 推导快手签名中的 `HUDR_` 段。
|
|
4066
|
-
*
|
|
4067
|
-
* @param context - 生成 `HUDR_` 所需的上下文参数
|
|
4068
|
-
* @returns `HUDR_` 的完整结果及若干中间态,便于对拍与调试
|
|
4069
|
-
*/
|
|
4070
|
-
const deriveKuaishouHudrBody = (context) => {
|
|
4071
|
-
const payload = buildKuaishouHudrPayload(context);
|
|
4072
|
-
const maskedPayload = maskKuaishouHudrPayload(payload);
|
|
4073
|
-
const encrypted = new KuaishouChaChaCipher(KUAISHOU_HUDR_CHACHA_KEY, KUAISHOU_HUDR_CHACHA_NONCE).encrypt(maskedPayload);
|
|
4074
|
-
const body = encodeBase64Url(encrypted);
|
|
4075
|
-
return {
|
|
4076
|
-
body,
|
|
4077
|
-
full: `${KUAISHOU_HUDR_PREFIX}${body}`,
|
|
4078
|
-
infoCache: buildKuaishouHudrInfoCache(context.scriptCount),
|
|
4079
|
-
maskedPayload,
|
|
4080
|
-
nextCount: context.count + 1
|
|
4081
|
-
};
|
|
4082
|
-
};
|
|
4083
|
-
//#endregion
|
|
4084
|
-
//#region src/platforms/kuaishou/sign/primitives.ts
|
|
4085
|
-
const KUAISHOU_BLAKE2S_IV = [
|
|
4086
|
-
2837534710,
|
|
4087
|
-
2845986804,
|
|
4088
|
-
2436420605,
|
|
4089
|
-
706843635,
|
|
4090
|
-
719254516,
|
|
4091
|
-
2557931286,
|
|
4092
|
-
2596197199,
|
|
4093
|
-
2432949778
|
|
4094
|
-
];
|
|
4095
|
-
const KUAISHOU_BLAKE2S_SIGMA = [
|
|
4096
|
-
[
|
|
4097
|
-
0,
|
|
4098
|
-
1,
|
|
4099
|
-
2,
|
|
4100
|
-
3,
|
|
4101
|
-
4,
|
|
4102
|
-
5,
|
|
4103
|
-
6,
|
|
4104
|
-
7,
|
|
4105
|
-
8,
|
|
4106
|
-
9,
|
|
4107
|
-
10,
|
|
4108
|
-
11,
|
|
4109
|
-
12,
|
|
4110
|
-
13,
|
|
4111
|
-
14,
|
|
4112
|
-
15
|
|
4113
|
-
],
|
|
4114
|
-
[
|
|
4115
|
-
14,
|
|
4116
|
-
10,
|
|
4117
|
-
4,
|
|
4118
|
-
8,
|
|
4119
|
-
9,
|
|
4120
|
-
15,
|
|
4121
|
-
13,
|
|
4122
|
-
6,
|
|
4123
|
-
1,
|
|
4124
|
-
12,
|
|
4125
|
-
0,
|
|
4126
|
-
2,
|
|
4127
|
-
11,
|
|
4128
|
-
7,
|
|
4129
|
-
5,
|
|
4130
|
-
3
|
|
4131
|
-
],
|
|
4132
|
-
[
|
|
4133
|
-
11,
|
|
4134
|
-
8,
|
|
4135
|
-
12,
|
|
4136
|
-
0,
|
|
4137
|
-
5,
|
|
4138
|
-
2,
|
|
4139
|
-
15,
|
|
4140
|
-
13,
|
|
4141
|
-
10,
|
|
4142
|
-
14,
|
|
4143
|
-
3,
|
|
4144
|
-
6,
|
|
4145
|
-
7,
|
|
4146
|
-
1,
|
|
4147
|
-
9,
|
|
4148
|
-
4
|
|
4149
|
-
],
|
|
4150
|
-
[
|
|
4151
|
-
7,
|
|
4152
|
-
9,
|
|
4153
|
-
3,
|
|
4154
|
-
1,
|
|
4155
|
-
13,
|
|
4156
|
-
12,
|
|
4157
|
-
11,
|
|
4158
|
-
14,
|
|
4159
|
-
2,
|
|
4160
|
-
6,
|
|
4161
|
-
5,
|
|
4162
|
-
10,
|
|
4163
|
-
4,
|
|
4164
|
-
0,
|
|
4165
|
-
15,
|
|
4166
|
-
8
|
|
4167
|
-
],
|
|
4168
|
-
[
|
|
4169
|
-
9,
|
|
4170
|
-
0,
|
|
4171
|
-
5,
|
|
4172
|
-
7,
|
|
4173
|
-
2,
|
|
4174
|
-
4,
|
|
4175
|
-
10,
|
|
4176
|
-
15,
|
|
4177
|
-
14,
|
|
4178
|
-
1,
|
|
4179
|
-
11,
|
|
4180
|
-
12,
|
|
4181
|
-
6,
|
|
4182
|
-
8,
|
|
4183
|
-
3,
|
|
4184
|
-
13
|
|
4185
|
-
],
|
|
4186
|
-
[
|
|
4187
|
-
2,
|
|
4188
|
-
12,
|
|
4189
|
-
6,
|
|
4190
|
-
10,
|
|
4191
|
-
0,
|
|
4192
|
-
11,
|
|
4193
|
-
8,
|
|
4194
|
-
3,
|
|
4195
|
-
4,
|
|
4196
|
-
13,
|
|
4197
|
-
7,
|
|
4198
|
-
5,
|
|
4199
|
-
15,
|
|
4200
|
-
14,
|
|
4201
|
-
1,
|
|
4202
|
-
9
|
|
4203
|
-
],
|
|
4204
|
-
[
|
|
4205
|
-
12,
|
|
4206
|
-
5,
|
|
4207
|
-
1,
|
|
4208
|
-
15,
|
|
4209
|
-
14,
|
|
4210
|
-
13,
|
|
4211
|
-
4,
|
|
4212
|
-
10,
|
|
4213
|
-
0,
|
|
4214
|
-
7,
|
|
4215
|
-
6,
|
|
4216
|
-
3,
|
|
4217
|
-
9,
|
|
4218
|
-
2,
|
|
4219
|
-
8,
|
|
4220
|
-
11
|
|
4221
|
-
],
|
|
4222
|
-
[
|
|
4223
|
-
13,
|
|
4224
|
-
11,
|
|
4225
|
-
7,
|
|
4226
|
-
14,
|
|
4227
|
-
12,
|
|
4228
|
-
1,
|
|
4229
|
-
3,
|
|
4230
|
-
9,
|
|
4231
|
-
5,
|
|
4232
|
-
0,
|
|
4233
|
-
15,
|
|
4234
|
-
4,
|
|
4235
|
-
8,
|
|
4236
|
-
6,
|
|
4237
|
-
2,
|
|
4238
|
-
10
|
|
4239
|
-
],
|
|
4240
|
-
[
|
|
4241
|
-
6,
|
|
4242
|
-
15,
|
|
4243
|
-
14,
|
|
4244
|
-
9,
|
|
4245
|
-
11,
|
|
4246
|
-
3,
|
|
4247
|
-
0,
|
|
4248
|
-
8,
|
|
4249
|
-
12,
|
|
4250
|
-
2,
|
|
4251
|
-
13,
|
|
4252
|
-
7,
|
|
4253
|
-
1,
|
|
4254
|
-
4,
|
|
4255
|
-
10,
|
|
4256
|
-
5
|
|
4257
|
-
],
|
|
4258
|
-
[
|
|
4259
|
-
10,
|
|
4260
|
-
2,
|
|
4261
|
-
8,
|
|
4262
|
-
4,
|
|
4263
|
-
7,
|
|
4264
|
-
6,
|
|
4265
|
-
1,
|
|
4266
|
-
5,
|
|
4267
|
-
15,
|
|
4268
|
-
11,
|
|
4269
|
-
9,
|
|
4270
|
-
14,
|
|
4271
|
-
3,
|
|
4272
|
-
12,
|
|
4273
|
-
13,
|
|
4274
|
-
0
|
|
4275
|
-
]
|
|
4276
|
-
];
|
|
4277
|
-
const KUAISHOU_CTS_STATE_VECTOR = new Int8Array([
|
|
4278
|
-
98,
|
|
4279
|
-
0,
|
|
4280
|
-
0,
|
|
4281
|
-
128,
|
|
4282
|
-
49,
|
|
4283
|
-
117,
|
|
4284
|
-
185,
|
|
4285
|
-
253,
|
|
4286
|
-
224,
|
|
4287
|
-
172,
|
|
4288
|
-
104,
|
|
4289
|
-
36,
|
|
4290
|
-
223,
|
|
4291
|
-
155,
|
|
4292
|
-
87,
|
|
4293
|
-
19,
|
|
4294
|
-
32,
|
|
4295
|
-
0,
|
|
4296
|
-
0,
|
|
4297
|
-
64,
|
|
4298
|
-
2,
|
|
4299
|
-
0,
|
|
4300
|
-
0,
|
|
4301
|
-
16,
|
|
4302
|
-
255,
|
|
4303
|
-
255,
|
|
4304
|
-
255,
|
|
4305
|
-
127,
|
|
4306
|
-
255,
|
|
4307
|
-
255,
|
|
4308
|
-
255,
|
|
4309
|
-
63,
|
|
4310
|
-
0,
|
|
4311
|
-
0,
|
|
4312
|
-
0,
|
|
4313
|
-
240,
|
|
4314
|
-
0,
|
|
4315
|
-
0,
|
|
4316
|
-
0,
|
|
4317
|
-
192,
|
|
4318
|
-
0,
|
|
4319
|
-
0,
|
|
4320
|
-
0,
|
|
4321
|
-
128,
|
|
4322
|
-
255,
|
|
4323
|
-
255,
|
|
4324
|
-
255,
|
|
4325
|
-
15
|
|
4326
|
-
]);
|
|
4327
|
-
const rotateRight = (value, shift) => {
|
|
4328
|
-
return (value >>> shift | value << 32 - shift) >>> 0;
|
|
4329
|
-
};
|
|
4330
|
-
const toUtf8Int8Array = (value) => {
|
|
4331
|
-
return new Int8Array(unescape(encodeURIComponent(value)).split("").map((char) => char.charCodeAt(0) || 0));
|
|
4332
|
-
};
|
|
4333
|
-
const toHex32 = (value) => {
|
|
4334
|
-
return (value >>> 0).toString(16).padStart(8, "0");
|
|
4335
|
-
};
|
|
4336
|
-
const blake2sQuarterRound = (state, a, b, c, d, x, y) => {
|
|
4337
|
-
state[a] = state[a] + state[b] + x >>> 0;
|
|
4338
|
-
state[d] = rotateRight(state[d] ^ state[a], 16);
|
|
4339
|
-
state[c] = state[c] + state[d] >>> 0;
|
|
4340
|
-
state[b] = rotateRight(state[b] ^ state[c], 12);
|
|
4341
|
-
state[a] = state[a] + state[b] + y >>> 0;
|
|
4342
|
-
state[d] = rotateRight(state[d] ^ state[a], 8);
|
|
4343
|
-
state[c] = state[c] + state[d] >>> 0;
|
|
4344
|
-
state[b] = rotateRight(state[b] ^ state[c], 7);
|
|
4345
|
-
};
|
|
4346
|
-
const blake2sCompress = (hash, words, offset, counter, length, isLastBlock) => {
|
|
4347
|
-
const work = new Array(16).fill(0);
|
|
4348
|
-
const block = new Array(16).fill(0);
|
|
4349
|
-
for (let index = 0; index < 8; index++) {
|
|
4350
|
-
work[index] = hash[index] >>> 0;
|
|
4351
|
-
work[index + 8] = KUAISHOU_BLAKE2S_IV[index] >>> 0;
|
|
4352
|
-
}
|
|
4353
|
-
work[12] ^= counter >>> 0;
|
|
4354
|
-
if (isLastBlock) work[14] ^= 4294967295;
|
|
4355
|
-
for (let index = 0; index < length; index++) block[index % 16] ^= words[offset + index] >>> 0;
|
|
4356
|
-
for (const sigma of KUAISHOU_BLAKE2S_SIGMA) {
|
|
4357
|
-
blake2sQuarterRound(work, 0, 4, 8, 12, block[sigma[0]], block[sigma[1]]);
|
|
4358
|
-
blake2sQuarterRound(work, 1, 5, 9, 13, block[sigma[2]], block[sigma[3]]);
|
|
4359
|
-
blake2sQuarterRound(work, 2, 6, 10, 14, block[sigma[4]], block[sigma[5]]);
|
|
4360
|
-
blake2sQuarterRound(work, 3, 7, 11, 15, block[sigma[6]], block[sigma[7]]);
|
|
4361
|
-
blake2sQuarterRound(work, 0, 5, 10, 15, block[sigma[8]], block[sigma[9]]);
|
|
4362
|
-
blake2sQuarterRound(work, 1, 6, 11, 12, block[sigma[10]], block[sigma[11]]);
|
|
4363
|
-
blake2sQuarterRound(work, 2, 7, 8, 13, block[sigma[12]], block[sigma[13]]);
|
|
4364
|
-
blake2sQuarterRound(work, 3, 4, 9, 14, block[sigma[14]], block[sigma[15]]);
|
|
4365
|
-
}
|
|
4366
|
-
for (let index = 0; index < 8; index++) hash[index] = (hash[index] ^ work[index] ^ work[index + 8]) >>> 0;
|
|
4367
|
-
return hash;
|
|
4368
|
-
};
|
|
4369
|
-
const deriveB2hasWords = (value) => {
|
|
4370
|
-
const utf8 = toUtf8Int8Array(value);
|
|
4371
|
-
const padding = utf8.length % 4 === 0 ? 0 : 4 - utf8.length % 4;
|
|
4372
|
-
const padded = new Int8Array(utf8.length + padding);
|
|
4373
|
-
for (let index = 0; index < utf8.length; index++) padded[index] = utf8[index];
|
|
4374
|
-
const words = new Array(padded.length / 4);
|
|
4375
|
-
for (let index = 0; index < padded.length; index += 4) words[index / 4] = new Int32Array(padded.slice(index, index + 4).buffer)[0];
|
|
4376
|
-
return words;
|
|
4377
|
-
};
|
|
4378
|
-
const deriveB2hasHash = (words) => {
|
|
4379
|
-
const hash = KUAISHOU_BLAKE2S_IV.slice();
|
|
4380
|
-
hash[0] ^= 16842784;
|
|
4381
|
-
let offset = 0;
|
|
4382
|
-
let length = words.length;
|
|
4383
|
-
let counter = 0;
|
|
4384
|
-
while (length > 64) {
|
|
4385
|
-
length -= 64;
|
|
4386
|
-
counter += 64;
|
|
4387
|
-
blake2sCompress(hash, words, offset, counter, 64, false);
|
|
4388
|
-
offset += 64;
|
|
4389
|
-
}
|
|
4390
|
-
return blake2sCompress(hash, words, offset, counter + length, length, true);
|
|
4391
|
-
};
|
|
4392
|
-
/**
|
|
4393
|
-
* 计算快手页面中的 `b2has` 字符串。
|
|
4394
|
-
*
|
|
4395
|
-
* 这是后续 `b2sa` / `cts` 链路的起点。
|
|
4396
|
-
*
|
|
4397
|
-
* @param value - 原始输入字符串
|
|
4398
|
-
* @returns `b2has` 结果字符串
|
|
4399
|
-
*/
|
|
4400
|
-
const deriveKuaishouB2has = (value) => {
|
|
4401
|
-
return deriveB2hasHash(deriveB2hasWords(value)).map(toHex32).join("");
|
|
4402
|
-
};
|
|
4403
|
-
/**
|
|
4404
|
-
* 将输入字符串转换为快手 `b2sa` 所需的字节数组。
|
|
4405
|
-
*
|
|
4406
|
-
* @param value - 原始输入字符串
|
|
4407
|
-
* @returns `b2has` 结果转 UTF-8 后的 Int8Array
|
|
4408
|
-
*/
|
|
4409
|
-
const deriveKuaishouB2sa = (value) => {
|
|
4410
|
-
return toUtf8Int8Array(deriveKuaishouB2has(value));
|
|
4411
|
-
};
|
|
4412
|
-
const createKuaishouCtsState = () => {
|
|
4413
|
-
return {
|
|
4414
|
-
s: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(12, 16).buffer)[0],
|
|
4415
|
-
u: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(8, 12).buffer)[0],
|
|
4416
|
-
c: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(4, 8).buffer)[0],
|
|
4417
|
-
l: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(0, 4).buffer)[0],
|
|
4418
|
-
p: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(16, 20).buffer)[0],
|
|
4419
|
-
f: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(20, 24).buffer)[0],
|
|
4420
|
-
d: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(24, 28).buffer)[0],
|
|
4421
|
-
y: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(28, 32).buffer)[0],
|
|
4422
|
-
h: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(44, 48).buffer)[0],
|
|
4423
|
-
E: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(40, 44).buffer)[0],
|
|
4424
|
-
m: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(36, 40).buffer)[0],
|
|
4425
|
-
b: new Int32Array(KUAISHOU_CTS_STATE_VECTOR.slice(32, 36).buffer)[0]
|
|
4426
|
-
};
|
|
4427
|
-
};
|
|
4428
|
-
const seedKuaishouCtsState = (state, seed) => {
|
|
4429
|
-
const chars = new Int8Array(seed.split("").map((char) => char.codePointAt(0) ?? 0)).slice(0, seed.length);
|
|
4430
|
-
for (let index = 0; index < 4; index++) {
|
|
4431
|
-
state.s = (state.s <<= 8) | chars[index + 4];
|
|
4432
|
-
state.u = (state.u <<= 8) | chars[index + 4];
|
|
4433
|
-
state.c = (state.c <<= 8) | chars[index + 4];
|
|
4434
|
-
}
|
|
4435
|
-
if (state.s === 0) state.s = 324508639;
|
|
4436
|
-
if (state.u === 0) state.u = 610839776;
|
|
4437
|
-
if (state.c === 0) state.c = 4256789809;
|
|
4438
|
-
};
|
|
4439
|
-
const deriveKuaishouCtsByte = (state, value) => {
|
|
4440
|
-
let result = 0;
|
|
4441
|
-
let rightBit = 1 & state.u;
|
|
4442
|
-
let leftBit = 1 & state.c;
|
|
4443
|
-
for (let index = 0; index < 8; index++) {
|
|
4444
|
-
if (1 & state.s) {
|
|
4445
|
-
state.s = state.s ^ state.l >> 1 & 4294967295 | state.E;
|
|
4446
|
-
rightBit = 1 & state.u ? (state.u = state.u ^ state.p >> 1 & 4294967295 | state.m, 1) : (state.u = state.u >> 1 & 4294967295 & state.y, 0);
|
|
4447
|
-
} else {
|
|
4448
|
-
state.s = state.s >> 1 & 4294967295 & state.d;
|
|
4449
|
-
leftBit = 1 & state.c ? (state.c = state.c ^ state.f >> 1 & 4294967295 | state.b, 1) : (state.c = state.c >> 1 & 4294967295 & state.h, 0);
|
|
4450
|
-
}
|
|
4451
|
-
const mixed = result << 1 & 4294967295 | rightBit ^ leftBit;
|
|
4452
|
-
result = mixed > 127 ? mixed - 256 : mixed < -128 ? mixed + 256 : mixed;
|
|
4453
|
-
}
|
|
4454
|
-
return value ^ result + 3;
|
|
4455
|
-
};
|
|
4456
|
-
/**
|
|
4457
|
-
* 执行快手页面中的 `cts` 字节流变换。
|
|
4458
|
-
*
|
|
4459
|
-
* 该变换会作用在 `b2sa` 的输出上,随后被 `$HE_` 使用。
|
|
4460
|
-
*
|
|
4461
|
-
* @param input - 输入字节数组
|
|
4462
|
-
* @returns 变换后的字节数组
|
|
4463
|
-
*/
|
|
4464
|
-
const deriveKuaishouCts = (input) => {
|
|
4465
|
-
const state = createKuaishouCtsState();
|
|
4466
|
-
seedKuaishouCtsState(state, "Vuz4fCHxn1CO");
|
|
4467
|
-
const result = new Int8Array(input.length);
|
|
4468
|
-
for (let index = 0; index < input.length; index++) result[index] = deriveKuaishouCtsByte(state, input[index]);
|
|
4469
|
-
return result;
|
|
4470
|
-
};
|
|
4471
|
-
/**
|
|
4472
|
-
* 将字节数组编码为小写 hex 字符串。
|
|
4473
|
-
*
|
|
4474
|
-
* @param bytes - 任意可按字节访问的数组
|
|
4475
|
-
* @returns 小写 hex 字符串
|
|
4476
|
-
*/
|
|
4477
|
-
const bytesToLowerHex = (bytes) => {
|
|
4478
|
-
let result = "";
|
|
4479
|
-
for (const byte of Array.from(bytes)) {
|
|
4480
|
-
const value = byte & 255;
|
|
4481
|
-
result += value === 0 ? "00" : `${value < 16 ? "0" : ""}${value.toString(16)}`;
|
|
4482
|
-
}
|
|
4483
|
-
return result;
|
|
4484
|
-
};
|
|
4485
|
-
/**
|
|
4486
|
-
* 将 hex 字符串还原为带符号字节数组。
|
|
4487
|
-
*
|
|
4488
|
-
* @param value - 偶数字节长度的 hex 字符串
|
|
4489
|
-
* @returns 带符号 byte 数组
|
|
4490
|
-
*/
|
|
4491
|
-
const hexToSignedBytes = (value) => {
|
|
4492
|
-
const result = [];
|
|
4493
|
-
for (let index = 0; index < value.length; index += 2) {
|
|
4494
|
-
const parsed = parseInt(value.slice(index, index + 2), 16);
|
|
4495
|
-
const range = Math.pow(2, value.slice(index, index + 2).length / 2 * 8);
|
|
4496
|
-
result.push(parsed > range / 2 - 1 ? parsed - range : parsed);
|
|
4497
|
-
}
|
|
4498
|
-
return result;
|
|
4499
|
-
};
|
|
4500
|
-
/**
|
|
4501
|
-
* 对两个字节数组执行循环异或。
|
|
4502
|
-
*
|
|
4503
|
-
* @param left - 被处理的字节数组
|
|
4504
|
-
* @param right - 异或掩码字节数组
|
|
4505
|
-
* @returns 异或后的 Int8Array
|
|
4506
|
-
*/
|
|
4507
|
-
const xorByteArrays = (left, right) => {
|
|
4508
|
-
let index = 0;
|
|
4509
|
-
const result = new Int8Array(left.length);
|
|
4510
|
-
while (index < left.length) for (let rightIndex = 0; rightIndex < right.length && index < left.length; rightIndex++) {
|
|
4511
|
-
result[index] = left[index] ^ 255 & right[rightIndex];
|
|
4512
|
-
index += 1;
|
|
4513
|
-
}
|
|
4514
|
-
return result;
|
|
4515
|
-
};
|
|
4516
|
-
/**
|
|
4517
|
-
* 将数值编码为固定长度的小端 hex。
|
|
4518
|
-
*
|
|
4519
|
-
* @param value - 原始数值
|
|
4520
|
-
* @param size - 输出字节长度
|
|
4521
|
-
* @returns 小端序 hex 字符串
|
|
4522
|
-
*/
|
|
4523
|
-
const toLittleEndianHex = (value, size) => {
|
|
4524
|
-
const normalized = BigInt.asUintN(size * 8, typeof value === "bigint" ? value : BigInt(Math.trunc(value)));
|
|
4525
|
-
let result = "";
|
|
4526
|
-
for (let index = 0; index < size; index++) {
|
|
4527
|
-
const byte = Number(normalized >> BigInt(8 * index) & 255n);
|
|
4528
|
-
result += byte.toString(16).padStart(2, "0");
|
|
4529
|
-
}
|
|
4530
|
-
return result;
|
|
4531
|
-
};
|
|
4532
|
-
/**
|
|
4533
|
-
* 计算快手 `$HE_` 所使用的 8-bit LRC 校验值。
|
|
4534
|
-
*
|
|
4535
|
-
* @param sourceHex - 原始 hex 字符串
|
|
4536
|
-
* @returns 单字节 LRC 的 hex 表示
|
|
4537
|
-
*/
|
|
4538
|
-
const computeKuaishouLrcHex = (sourceHex) => {
|
|
4539
|
-
return (-hexToSignedBytes(sourceHex).reduce((total, value) => total + (value & 255), 0) & 255).toString(16).padStart(2, "0");
|
|
4540
|
-
};
|
|
4541
|
-
/**
|
|
4542
|
-
* 对 `$HE_` 的 preHex + checksum 执行最终变换。
|
|
4543
|
-
*
|
|
4544
|
-
* 页面实现会以最后一个字节为 key,对前面的字节逐个异或,
|
|
4545
|
-
* 最后再整体转回小写 hex。
|
|
4546
|
-
*
|
|
4547
|
-
* @param prefixHex - 不含最终 LRC 的前缀 hex
|
|
4548
|
-
* @param checksumHex - 最终 LRC hex
|
|
4549
|
-
* @returns `$HE_` 最终输出的 hex 字符串
|
|
4550
|
-
*/
|
|
4551
|
-
const transformKuaishouHeHex = (prefixHex, checksumHex) => {
|
|
4552
|
-
const input = hexToSignedBytes(`${prefixHex}${checksumHex}`);
|
|
4553
|
-
const output = new Int8Array(input.length);
|
|
4554
|
-
const xorKey = input[input.length - 1];
|
|
4555
|
-
for (let index = 0; index < input.length - 1; index++) output[index] = input[index] ^ xorKey;
|
|
4556
|
-
output[input.length - 1] = xorKey;
|
|
4557
|
-
return bytesToLowerHex(output);
|
|
4558
|
-
};
|
|
4559
|
-
//#endregion
|
|
4560
|
-
//#region src/platforms/kuaishou/sign/he.ts
|
|
4561
|
-
const KUAISHOU_HE_HEADER_HEX = "4B54";
|
|
4562
|
-
const KUAISHOU_HE_VERSION_HEX = "cda9";
|
|
4563
|
-
const KUAISHOU_HE_STARTUP_MARKER_HEX = "ab";
|
|
4564
|
-
const KUAISHOU_HE_FIXED_BODY_HEX = "0100000001";
|
|
4565
|
-
const KUAISHOU_HE_INPUT_XOR_MASK = [
|
|
4566
|
-
45,
|
|
4567
|
-
211,
|
|
4568
|
-
69,
|
|
4569
|
-
192
|
|
4570
|
-
];
|
|
4571
|
-
const KUAISHOU_HE_COUNTER_XOR_MASK = 3131873467;
|
|
4572
|
-
const KUAISHOU_HE_TIME_XOR_MASK = 3360347992n;
|
|
4573
|
-
const KUAISHOU_HE_TAIL_HEX = "9b563eda7b563e";
|
|
4574
|
-
const KUAISHOU_HE_RANDOM_MAX = 0xffffffffffff;
|
|
4575
|
-
/**
|
|
4576
|
-
* 计算 `$HE_` 中的 hash field。
|
|
4577
|
-
*
|
|
4578
|
-
* 它由 `signInput + HUDRBody` 经 `b2sa -> cts -> 截断 -> 异或掩码`
|
|
4579
|
-
* 这条链路导出,是 `$HE_` 中与接口输入最直接相关的一段。
|
|
4580
|
-
*
|
|
4581
|
-
* @param signInput - 快手 `__NS_hxfalcon` 的 sign input
|
|
4582
|
-
* @param hudrBody - `HUDR_` 去前缀后的主体
|
|
4583
|
-
* @returns `$HE_` 载荷中的 4 字节 hash field hex
|
|
4584
|
-
*/
|
|
4585
|
-
const deriveKuaishouHeHashFieldHex = (signInput, hudrBody) => {
|
|
4586
|
-
const hashInput = `${signInput}HUDR_${hudrBody}`;
|
|
4587
|
-
const digestHex = bytesToLowerHex(deriveKuaishouCts(deriveKuaishouB2sa(hashInput))).slice(0, 8);
|
|
4588
|
-
return bytesToLowerHex(xorByteArrays(hexToSignedBytes(digestHex), KUAISHOU_HE_INPUT_XOR_MASK));
|
|
4589
|
-
};
|
|
4590
|
-
/**
|
|
4591
|
-
* 推导快手签名中的 `$HE_` 段。
|
|
4592
|
-
*
|
|
4593
|
-
* @param context - 生成 `$HE_` 所需的上下文参数
|
|
4594
|
-
* @returns `$HE_` 的最终 hex、中间 hash field 和 preHex
|
|
4595
|
-
*/
|
|
4596
|
-
const deriveKuaishouHeHex = (context) => {
|
|
4597
|
-
const random48 = Math.floor(context.randomValue * KUAISHOU_HE_RANDOM_MAX);
|
|
4598
|
-
const hashFieldHex = deriveKuaishouHeHashFieldHex(context.signInput, context.hudrBody);
|
|
4599
|
-
const timeXor = BigInt(context.timestamp) ^ KUAISHOU_HE_TIME_XOR_MASK;
|
|
4600
|
-
const preHex = [
|
|
4601
|
-
KUAISHOU_HE_HEADER_HEX,
|
|
4602
|
-
KUAISHOU_HE_VERSION_HEX,
|
|
4603
|
-
KUAISHOU_HE_STARTUP_MARKER_HEX,
|
|
4604
|
-
toLittleEndianHex(context.startupRandom, 6),
|
|
4605
|
-
toLittleEndianHex(random48, 6),
|
|
4606
|
-
KUAISHOU_HE_FIXED_BODY_HEX,
|
|
4607
|
-
toLittleEndianHex(context.count ^ KUAISHOU_HE_COUNTER_XOR_MASK, 4),
|
|
4608
|
-
hashFieldHex,
|
|
4609
|
-
toLittleEndianHex(timeXor, 6),
|
|
4610
|
-
KUAISHOU_HE_TAIL_HEX,
|
|
4611
|
-
computeKuaishouLrcHex(KUAISHOU_HE_TAIL_HEX)
|
|
4612
|
-
].join("");
|
|
4613
|
-
return {
|
|
4614
|
-
finalHex: transformKuaishouHeHex(preHex, computeKuaishouLrcHex(preHex)),
|
|
4615
|
-
hashFieldHex,
|
|
4616
|
-
preHex
|
|
4617
|
-
};
|
|
4618
|
-
};
|
|
4619
|
-
/**
|
|
4620
|
-
* 一次性推导快手完整纯算法签名。
|
|
4621
|
-
*
|
|
4622
|
-
* 该方法会先生成 `HUDR_`,再拼出 `$HE_`,最终返回完整
|
|
4623
|
-
* `HUDR_...$HE_...` 形式的 `__NS_hxfalcon`。
|
|
4624
|
-
*
|
|
4625
|
-
* @param context - 快手纯算法签名上下文
|
|
4626
|
-
* @returns 完整签名结果及关键中间态
|
|
4627
|
-
*/
|
|
4628
|
-
const deriveKuaishouPureSignature = (context) => {
|
|
4629
|
-
const hudr = deriveKuaishouHudrBody(context);
|
|
4630
|
-
const he = deriveKuaishouHeHex({
|
|
4631
|
-
count: context.count,
|
|
4632
|
-
hudrBody: hudr.body,
|
|
4633
|
-
randomValue: context.randomValue,
|
|
4634
|
-
signInput: context.signInput,
|
|
4635
|
-
startupRandom: context.startupRandom,
|
|
4636
|
-
timestamp: context.timestamp
|
|
4637
|
-
});
|
|
4638
|
-
return {
|
|
4639
|
-
...hudr,
|
|
4640
|
-
he,
|
|
4641
|
-
signResult: `${hudr.full}$HE_${he.finalHex}`
|
|
4642
|
-
};
|
|
4643
|
-
};
|
|
4644
|
-
//#endregion
|
|
4645
|
-
//#region src/platforms/kuaishou/sign/helpers.ts
|
|
4646
|
-
const SIGN_INPUT_SKIP_KEYWORD$1 = "__NS";
|
|
4647
|
-
const KUAISHOU_ANONYMOUS_KWW_KEY$1 = "K8wm5PvY9nX7qJc2";
|
|
4648
|
-
const KUAISHOU_ANONYMOUS_KWW_SUFFIX$1 = "###ssrd";
|
|
4649
|
-
const KUAISHOU_ANONYMOUS_KWW_ALPHABET$1 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
|
4650
|
-
/** 创建一份独立的匿名 kww 缓存 */
|
|
4651
|
-
const createKuaishouAnonymousKwwCache = () => {
|
|
4652
|
-
const cache = {
|
|
4653
|
-
value: "",
|
|
4654
|
-
get: () => {
|
|
4655
|
-
if (!cache.value) cache.value = `${encryptKuaishouAnonymousKwwSeed$1(generateKuaishouAnonymousKwwSeed$1())}${KUAISHOU_ANONYMOUS_KWW_SUFFIX$1}`;
|
|
4656
|
-
return cache.value;
|
|
4657
|
-
}
|
|
4658
|
-
};
|
|
4659
|
-
return cache;
|
|
4660
|
-
};
|
|
4661
|
-
/** 模块级单例缓存;生产代码用 createKuaishouAnonymousKwwCache */
|
|
4662
|
-
let kuaishouAnonymousKwwCache$1 = "";
|
|
4663
|
-
const compareLexicographically$1 = (left, right) => {
|
|
4664
|
-
if (left === right) return 0;
|
|
4665
|
-
return left < right ? -1 : 1;
|
|
4666
|
-
};
|
|
4667
|
-
const toComparableEntry$1 = (key, value) => {
|
|
4668
|
-
if (typeof value === "object" && value !== null && !Array.isArray(value)) return `${key}=[object Object]`;
|
|
4669
|
-
return `${key}=${String(value)}`;
|
|
4670
|
-
};
|
|
4671
|
-
const normalizePathname$1 = (urlOrPath) => {
|
|
4672
|
-
try {
|
|
4673
|
-
return new URL(urlOrPath).pathname;
|
|
4674
|
-
} catch {
|
|
4675
|
-
return urlOrPath.split("?")[0] ?? urlOrPath;
|
|
4676
|
-
}
|
|
4677
|
-
};
|
|
4678
|
-
const sortSearchParams$1 = (searchParams) => {
|
|
4679
|
-
return [...searchParams.entries()].sort(([leftKey], [rightKey]) => compareLexicographically$1(leftKey, rightKey)).reduce((result, [key, value]) => {
|
|
4680
|
-
result[key] = value;
|
|
4681
|
-
return result;
|
|
4682
|
-
}, {});
|
|
4683
|
-
};
|
|
4684
|
-
/**
|
|
4685
|
-
* 解析快手签名所使用的规范路径。
|
|
4686
|
-
*
|
|
4687
|
-
* 快手部分 `live_api` 接口的公开 URL 与算法内部参与签名的路径并不一致,
|
|
4688
|
-
* 因此这里优先使用调用方显式传入的 `signPath`,否则回退到 URL 自身的 pathname。
|
|
4689
|
-
*
|
|
4690
|
-
* @param urlOrPath - 原始 URL 或路径
|
|
4691
|
-
* @param signPath - 调用方声明的规范签名路径
|
|
4692
|
-
* @returns 参与签名的规范 pathname
|
|
4693
|
-
*/
|
|
4694
|
-
const resolveKuaishouHxfalconSignPath$1 = (urlOrPath, signPath) => {
|
|
4695
|
-
const pathname = normalizePathname$1(signPath ?? urlOrPath);
|
|
4696
|
-
if (!pathname.startsWith("/")) throw new Error(`Invalid Kuaishou signing path: ${pathname}`);
|
|
4697
|
-
return pathname;
|
|
4698
|
-
};
|
|
4699
|
-
/**
|
|
4700
|
-
* 从请求 URL 构造快手签名载荷。
|
|
4701
|
-
*
|
|
4702
|
-
* `requestBody` **必须**由调用方透下来:`buildKuaishouHxfalconSignInput` 会把
|
|
4703
|
-
* `JSON.stringify(requestBody)` 拼在 sign input 尾部。漏传时校验松的接口
|
|
4704
|
-
* (`simple/info` / `comment/list`)仍会放行,而 `photo/info` 一类严格校验的接口
|
|
4705
|
-
* 会一律 `result=50 签名验证失败`。
|
|
4706
|
-
* 参见 @OduckO 的 kuaishou-parser(GPL-3.0-only)`sign/helpers.ts:78-81`
|
|
4707
|
-
* 与其 `TODO.md:193-195`。
|
|
4708
|
-
*
|
|
4709
|
-
* @param url - 实际请求 URL
|
|
4710
|
-
* @param signPath - 可选的规范签名路径
|
|
4711
|
-
* @param requestBody - 参与签名的请求体(POST 端点必传,GET 端点保持缺省)
|
|
4712
|
-
* @returns 供纯算法签名链路使用的结构化载荷
|
|
4713
|
-
*/
|
|
4714
|
-
const buildKuaishouHxfalconPayload$1 = (url, signPath, requestBody = {}) => {
|
|
4715
|
-
const parsedUrl = new URL(url);
|
|
4716
|
-
const realPath = resolveKuaishouHxfalconSignPath$1(parsedUrl.pathname, signPath);
|
|
4717
|
-
const query = sortSearchParams$1(parsedUrl.searchParams);
|
|
4718
|
-
if (!query.caver) throw new Error(`Missing caver query parameter for Kuaishou signing: ${url}`);
|
|
4719
|
-
return {
|
|
4720
|
-
url: realPath,
|
|
4721
|
-
query,
|
|
4722
|
-
form: {},
|
|
4723
|
-
requestBody
|
|
4724
|
-
};
|
|
4725
|
-
};
|
|
4726
|
-
/**
|
|
4727
|
-
* 构造快手 `__NS_hxfalcon` 的 sign input。
|
|
4728
|
-
*
|
|
4729
|
-
* 该字符串就是后续 `HUDR_` / `$HE_` 两段算法共同依赖的输入材料。
|
|
4730
|
-
*
|
|
4731
|
-
* @param payload - 已标准化的签名载荷
|
|
4732
|
-
* @returns 按快手页面规则拼接后的 sign input
|
|
4733
|
-
*/
|
|
4734
|
-
const buildKuaishouHxfalconSignInput$1 = (payload) => {
|
|
4735
|
-
const combinedParams = {
|
|
4736
|
-
...payload.query,
|
|
4737
|
-
...payload.form
|
|
4738
|
-
};
|
|
4739
|
-
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("");
|
|
4740
|
-
const requestBody = Object.keys(payload.requestBody).length > 0 ? JSON.stringify(payload.requestBody) : "";
|
|
4741
|
-
return `${normalizePathname$1(payload.url)}${serializedParams}${requestBody}`;
|
|
4742
|
-
};
|
|
4743
|
-
/**
|
|
4744
|
-
* 从 Cookie 字符串中提取指定键值。
|
|
4745
|
-
*
|
|
4746
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4747
|
-
* @param key - 目标键名
|
|
4748
|
-
* @returns 命中的 Cookie 值;未命中时返回空字符串
|
|
4749
|
-
*/
|
|
4750
|
-
const extractCookieValue$1 = (cookie, key) => {
|
|
4751
|
-
if (!cookie?.trim()) return "";
|
|
4752
|
-
const pattern = new RegExp(`(?:^|;\\s*)${key}=([^;]*)`);
|
|
4753
|
-
return cookie.match(pattern)?.[1] ?? "";
|
|
4754
|
-
};
|
|
4755
|
-
const generateKuaishouAnonymousKwwSeed$1 = () => {
|
|
4756
|
-
let randomPart = "";
|
|
4757
|
-
for (let index = 0; index < 8; index++) {
|
|
4758
|
-
const randomIndex = Math.floor(Math.random() * 62);
|
|
4759
|
-
randomPart += KUAISHOU_ANONYMOUS_KWW_ALPHABET$1[randomIndex];
|
|
4760
|
-
}
|
|
4761
|
-
return `${Date.now()}|${randomPart}`;
|
|
4762
|
-
};
|
|
4763
|
-
const encryptKuaishouAnonymousKwwSeed$1 = (seed) => {
|
|
4764
|
-
const key = Buffer.from(KUAISHOU_ANONYMOUS_KWW_KEY$1, "utf8");
|
|
4765
|
-
const cipher = crypto.createCipheriv("aes-128-cbc", key, key);
|
|
4766
|
-
return Buffer.concat([cipher.update(seed, "utf8"), cipher.final()]).toString("base64");
|
|
4767
|
-
};
|
|
4768
|
-
/**
|
|
4769
|
-
* 生成匿名访问快手 `live_api` 时所需的 `kww`。
|
|
4770
|
-
*
|
|
4771
|
-
* 页面未持有 `kwfv1` 时,会退回到一段本地 AES 生成值。
|
|
4772
|
-
* 传入 `cache` 时按实例缓存(同一实例内复用同一份访客标识);
|
|
4773
|
-
* 不传时用模块级单例缓存。
|
|
4774
|
-
*
|
|
4775
|
-
* @param cache - 可选的实例级匿名 kww 缓存
|
|
4776
|
-
* @returns 可用于匿名请求的 `kww`
|
|
4777
|
-
*/
|
|
4778
|
-
const deriveKuaishouAnonymousKww$1 = (cache) => {
|
|
4779
|
-
if (cache) return cache.get();
|
|
4780
|
-
if (!kuaishouAnonymousKwwCache$1) kuaishouAnonymousKwwCache$1 = `${encryptKuaishouAnonymousKwwSeed$1(generateKuaishouAnonymousKwwSeed$1())}${KUAISHOU_ANONYMOUS_KWW_SUFFIX$1}`;
|
|
4781
|
-
return kuaishouAnonymousKwwCache$1;
|
|
4782
|
-
};
|
|
4783
|
-
/**
|
|
4784
|
-
* 解析快手 `kww` 请求头。
|
|
4785
|
-
*
|
|
4786
|
-
* 当前页面存在两条路径:
|
|
4787
|
-
* 1. 若已有 `kwfv1`,则 `kww` 直接复用该值
|
|
4788
|
-
* 2. 若还没有访客 Cookie,则退回到页面本地生成的匿名 `kww`
|
|
4789
|
-
*
|
|
4790
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4791
|
-
* @param cache - 可选的实例级匿名 kww 缓存
|
|
4792
|
-
* @returns 可用于请求头的 `kww` 值
|
|
4793
|
-
*/
|
|
4794
|
-
const deriveKuaishouKww$1 = (cookie, cache) => {
|
|
4795
|
-
const kwfv1 = extractCookieValue$1(cookie, "kwfv1");
|
|
4796
|
-
if (kwfv1) return kwfv1;
|
|
4797
|
-
return deriveKuaishouAnonymousKww$1(cache);
|
|
4798
|
-
};
|
|
4799
|
-
//#endregion
|
|
4800
|
-
//#region src/platforms/kuaishou/sign/state.ts
|
|
4801
|
-
const KUAISHOU_DEFAULT_CAT_VERSION$1 = "2";
|
|
4802
|
-
const KUAISHOU_DEFAULT_COUNT$1 = 100;
|
|
4803
|
-
const KUAISHOU_SECS_STACK_LIMIT$1 = 100;
|
|
4804
|
-
const captureKuaishouEncodeStack$1 = () => {
|
|
4805
|
-
try {
|
|
4806
|
-
throw new Error();
|
|
4807
|
-
} catch (error) {
|
|
4808
|
-
return error instanceof Error ? error.stack ?? "" : "";
|
|
4809
|
-
}
|
|
4810
|
-
};
|
|
4811
|
-
/**
|
|
4812
|
-
* 计算快手 `SECS.s` 所需的调用栈尾部。
|
|
4813
|
-
*
|
|
4814
|
-
* 真实页面会取 `Error().stack` 的最后 100 个字符,
|
|
4815
|
-
* 这里保持同样的裁剪规则。
|
|
4816
|
-
*
|
|
4817
|
-
* @param stack - 可选的完整调用栈;不传时会即时捕获
|
|
4818
|
-
* @returns 用于 `SECS.s` 的栈尾字符串
|
|
4819
|
-
*/
|
|
4820
|
-
const deriveKuaishouSecsStackTail$1 = (stack = captureKuaishouEncodeStack$1()) => {
|
|
4821
|
-
return stack.length > KUAISHOU_SECS_STACK_LIMIT$1 ? stack.slice(-100) : stack;
|
|
4822
|
-
};
|
|
4823
|
-
/**
|
|
4824
|
-
* 构造快手 `window.SECS` 的纯算法等价状态。
|
|
4825
|
-
*
|
|
4826
|
-
* @param count - 当前签名计数器
|
|
4827
|
-
* @param stack - 可选的完整调用栈
|
|
4828
|
-
* @returns 包含 `s` 与 `c` 的 `SECS` 状态对象
|
|
4829
|
-
*/
|
|
4830
|
-
const deriveKuaishouSecsState$1 = (count, stack) => {
|
|
4831
|
-
return {
|
|
4832
|
-
s: deriveKuaishouSecsStackTail$1(stack),
|
|
4833
|
-
c: count
|
|
4834
|
-
};
|
|
4835
|
-
};
|
|
4836
|
-
/**
|
|
4837
|
-
* 创建一份独立的快手签名运行时状态。
|
|
4838
|
-
*
|
|
4839
|
-
* 每次调用创建独立副本,`count` / `startupRandom` 随签名器实例走。
|
|
4840
|
-
*
|
|
4841
|
-
* @returns 新的签名运行时状态(`count` 从默认值 100 起步)
|
|
4842
|
-
*/
|
|
4843
|
-
const createKuaishouPureRuntimeState = () => ({
|
|
4844
|
-
catVersion: KUAISHOU_DEFAULT_CAT_VERSION$1,
|
|
4845
|
-
count: KUAISHOU_DEFAULT_COUNT$1,
|
|
4846
|
-
startupRandom: Date.now()
|
|
4847
|
-
});
|
|
4848
|
-
//#endregion
|
|
4849
|
-
//#region src/platforms/kuaishou/sign/index.ts
|
|
4850
|
-
/**
|
|
4851
|
-
* 快手签名器(**实例级**)。
|
|
4852
|
-
*
|
|
4853
|
-
* 签名状态(`count` / `startupRandom` / 匿名 `kww` 缓存)随实例走,不挂在模块级
|
|
4854
|
-
* 单例上 —— 同进程内两个 client 的签名互不干扰:
|
|
4855
|
-
* - `count` 每次签名递增,但只影响当前实例。
|
|
4856
|
-
* - 匿名 `kww` 按实例缓存(同一实例内复用,不同实例各自生成)。
|
|
4857
|
-
* - `startupRandom` 按实例生成。
|
|
4858
|
-
*/
|
|
4859
|
-
var KuaishouSigner = class {
|
|
4860
|
-
runtimeState;
|
|
4861
|
-
anonymousKwwCache;
|
|
4862
|
-
constructor() {
|
|
4863
|
-
this.runtimeState = createKuaishouPureRuntimeState();
|
|
4864
|
-
this.anonymousKwwCache = createKuaishouAnonymousKwwCache();
|
|
4865
|
-
}
|
|
4866
|
-
/**
|
|
4867
|
-
* 获取当前纯算法运行时使用的 `caver`。
|
|
4868
|
-
* @returns 快手 `caver` 值
|
|
4869
|
-
*/
|
|
4870
|
-
getCatVersion() {
|
|
4871
|
-
return this.runtimeState.catVersion;
|
|
4872
|
-
}
|
|
4873
|
-
/**
|
|
4874
|
-
* 生成快手请求头中的 `kww` 值(匿名分支按实例缓存)。
|
|
4875
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4876
|
-
* @returns `kww` 请求头值
|
|
4877
|
-
*/
|
|
4878
|
-
generateKww(cookie) {
|
|
4879
|
-
return deriveKuaishouKww$1(cookie, this.anonymousKwwCache);
|
|
4880
|
-
}
|
|
4881
|
-
/**
|
|
4882
|
-
* 根据结构化签名载荷生成 `__NS_hxfalcon`。
|
|
4883
|
-
*
|
|
4884
|
-
* `count` 随实例递增:同一实例内连续签名结果不同(防重放),
|
|
4885
|
-
* 但两个实例的 `count` 互不干扰。
|
|
4886
|
-
*
|
|
4887
|
-
* @param payload - 已标准化的快手签名载荷
|
|
4888
|
-
* @returns 包含最终签名串、sign input 与 `caver` 的结果
|
|
4889
|
-
*/
|
|
4890
|
-
generateHxfalconFromPayload(payload) {
|
|
4891
|
-
const signInput = buildKuaishouHxfalconSignInput$1(payload);
|
|
4892
|
-
const count = this.runtimeState.count;
|
|
4893
|
-
const secs = deriveKuaishouSecsState$1(count);
|
|
4894
|
-
const scriptCount = globalThis.document?.scripts?.length ?? 0;
|
|
4895
|
-
const catVersion = this.runtimeState.catVersion;
|
|
4896
|
-
const signResult = deriveKuaishouPureSignature({
|
|
4897
|
-
count,
|
|
4898
|
-
randomValue: Math.random(),
|
|
4899
|
-
scriptCount,
|
|
4900
|
-
secs,
|
|
4901
|
-
signInput,
|
|
4902
|
-
startupRandom: this.runtimeState.startupRandom,
|
|
4903
|
-
timestamp: Date.now()
|
|
4904
|
-
}).signResult;
|
|
4905
|
-
this.runtimeState.count += 1;
|
|
4906
|
-
return {
|
|
4907
|
-
signResult,
|
|
4908
|
-
signInput,
|
|
4909
|
-
catVersion
|
|
4910
|
-
};
|
|
4911
|
-
}
|
|
4912
|
-
/**
|
|
4913
|
-
* 为快手 `live_api` / H5 URL 签名。
|
|
4914
|
-
* @param url - 实际请求 URL
|
|
4915
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4916
|
-
* @param signPath - 可选的规范签名路径
|
|
4917
|
-
* @param requestBody - 参与签名的请求体(POST 端点必传,见 helpers 的说明)
|
|
4918
|
-
* @returns 带签名 URL、附加请求头和调试信息
|
|
4919
|
-
*/
|
|
4920
|
-
signLiveApiUrl(url, cookie, signPath, requestBody = {}) {
|
|
4921
|
-
const payload = buildKuaishouHxfalconPayload$1(url, signPath, requestBody);
|
|
4922
|
-
const { signResult, signInput, catVersion } = this.generateHxfalconFromPayload(payload);
|
|
4923
|
-
const signedUrl = new URL(url);
|
|
4924
|
-
const headers = {};
|
|
4925
|
-
const kww = deriveKuaishouKww$1(cookie, this.anonymousKwwCache);
|
|
4926
|
-
signedUrl.searchParams.set("__NS_hxfalcon", signResult);
|
|
4927
|
-
signedUrl.searchParams.set("caver", catVersion);
|
|
4928
|
-
if (kww) headers.kww = kww;
|
|
4929
|
-
return {
|
|
4930
|
-
url: signedUrl.toString(),
|
|
4931
|
-
headers,
|
|
4932
|
-
signResult,
|
|
4933
|
-
signInput,
|
|
4934
|
-
catVersion
|
|
4935
|
-
};
|
|
4936
|
-
}
|
|
4937
|
-
/**
|
|
4938
|
-
* 为结构化的快手 `live_api` 请求描述对象签名。
|
|
4939
|
-
*
|
|
4940
|
-
* 入参与静态类 {@link kuaishouSign} 相同,但状态随实例。
|
|
4941
|
-
*
|
|
4942
|
-
* @param request - 快手 `live_api` 请求描述对象
|
|
4943
|
-
* @param cookie - 原始 Cookie 字符串
|
|
4944
|
-
* @param requestBody - 参与签名的请求体
|
|
4945
|
-
* @returns 带签名 URL、附加请求头和调试信息
|
|
4946
|
-
*/
|
|
4947
|
-
signLiveApiRequest(request, cookie, requestBody = {}) {
|
|
4948
|
-
return this.signLiveApiUrl(request.url, cookie, request.signPath, requestBody);
|
|
4949
|
-
}
|
|
4950
|
-
};
|
|
4951
|
-
/**
|
|
4952
|
-
* 创建一个快手签名器实例。
|
|
4953
|
-
*
|
|
4954
|
-
* 每个 client 持有一个实例,签名状态(`count` / `startupRandom` /
|
|
4955
|
-
* 匿名 `kww`)随实例走。
|
|
4956
|
-
* @returns 新的签名器实例
|
|
4957
|
-
*/
|
|
4958
|
-
const createKuaishouSigner = () => new KuaishouSigner();
|
|
4959
|
-
//#endregion
|
|
4960
2862
|
//#region src/platforms/kuaishou/sign/signers.ts
|
|
4961
2863
|
/**
|
|
4962
|
-
* 快手签名器表。
|
|
4963
|
-
*
|
|
4964
|
-
* 一张表只有一个签名器:快手的 `live_api`(`/rest/k/*`)与 H5(`/rest/wd/*`)
|
|
4965
|
-
* 共用 `__NS_hxfalcon` 这一套算法,区别只在喂什么料。
|
|
4966
|
-
*/
|
|
4967
|
-
/** 签名时必须出现在 query 里的版本标记 —— 它参与 sign input,不能只在发送时补 */
|
|
4968
|
-
const CAVER_PARAM = "caver";
|
|
4969
|
-
/**
|
|
4970
|
-
* 取参与签名的请求体。
|
|
4971
|
-
*
|
|
4972
|
-
* 只认普通对象:签名把 `JSON.stringify(body)` 拼在 sign input 尾部,而 transport
|
|
4973
|
-
* 交给 axios 的也是同一个对象(同样 `JSON.stringify`),两边逐字一致才算签对。
|
|
4974
|
-
* 若 body 已被端点预先序列化成字符串或换成了别的形状,签名与实发就会错位 ——
|
|
4975
|
-
* 这正是「签名验证失败」最难查的形态,所以这里直接抛,不静默签一个错的。
|
|
4976
|
-
* @param spec - 请求描述
|
|
4977
|
-
* @returns 参与签名的请求体
|
|
4978
|
-
*/
|
|
4979
|
-
const signableBody = (spec) => {
|
|
4980
|
-
if (spec.body === void 0 || spec.body === null) return {};
|
|
4981
|
-
if (typeof spec.body !== "object" || Array.isArray(spec.body)) throw new Error(`快手签名只支持普通对象 body,收到 ${Array.isArray(spec.body) ? "array" : typeof spec.body}`);
|
|
4982
|
-
return spec.body;
|
|
4983
|
-
};
|
|
4984
|
-
/**
|
|
4985
|
-
* 造 `__NS_hxfalcon` 签名器。
|
|
4986
|
-
*
|
|
4987
|
-
* 签名的三份料全部从 spec 上取 —— 走正规 `sign` 声明这条路,`body` 天然可得,
|
|
4988
|
-
* 不需要在 request 层手动把 body 递给签名器:
|
|
4989
|
-
* - query:从 `spec.url` 解析,必须含 `caver`(缺就按签名器自己的 `catVersion`
|
|
4990
|
-
* 补上,补的值与最终发出去的一致)
|
|
4991
|
-
* - `spec.signPath`:规范签名路径(端点已在 spec 里填好)
|
|
4992
|
-
* - `spec.body`:POST 端点的请求体,`photo/info` 一类严格校验的接口非它不可
|
|
4993
|
-
* @param signer - 签名器实例(状态随实例:`count` / `startupRandom` / 匿名 kww)
|
|
4994
|
-
* @returns 签名函数
|
|
4995
|
-
*/
|
|
4996
|
-
const createHxfalconSigner = (signer) => (spec, ctx) => {
|
|
4997
|
-
const url = new URL(spec.url);
|
|
4998
|
-
if (!url.searchParams.get(CAVER_PARAM)) url.searchParams.set(CAVER_PARAM, signer.getCatVersion());
|
|
4999
|
-
const signed = signer.signLiveApiUrl(url.toString(), ctx.cookie, spec.signPath, signableBody(spec));
|
|
5000
|
-
const headers = new AmagiHeaders(spec.headers);
|
|
5001
|
-
for (const [key, value] of Object.entries(signed.headers)) headers.set(key, value);
|
|
5002
|
-
return {
|
|
5003
|
-
...spec,
|
|
5004
|
-
url: signed.url,
|
|
5005
|
-
headers: headers.toJSON()
|
|
5006
|
-
};
|
|
5007
|
-
};
|
|
5008
|
-
/**
|
|
5009
2864
|
* 创建快手签名器表。
|
|
5010
2865
|
*
|
|
5011
2866
|
* 表由 `PLATFORM_RUNTIME.kuaishou.signers` 持有,与另外三个平台同构 ——
|
|
5012
2867
|
* client fetcher / `createKuaishouRoutes` / 静态 fetcher 三个入口共用同一张表,
|
|
5013
2868
|
* 保证任何入口下的签名行为一致。
|
|
5014
2869
|
*
|
|
5015
|
-
* `signer`
|
|
5016
|
-
*
|
|
5017
|
-
* @param signer -
|
|
2870
|
+
* `signer` 可注入:缺省用 `steps.ts` 的模块级 {@link sharedKuaishouSigner},于是端点
|
|
2871
|
+
* 声明与 `client.kuaishou.request` 两条路共用一个 `count`;测试可注入独立实例做隔离。
|
|
2872
|
+
* @param signer - 签名器实例,缺省用共享实例
|
|
5018
2873
|
* @returns 签名器表
|
|
5019
2874
|
*/
|
|
5020
|
-
const createKuaishouSigners = (signer =
|
|
2875
|
+
const createKuaishouSigners = (signer = sharedKuaishouSigner) => ({ hxfalcon: stepsToSigner([hxfalcon(signer)]) });
|
|
5021
2876
|
//#endregion
|
|
5022
2877
|
//#region src/platforms/xiaohongshu/config.ts
|
|
5023
2878
|
/**
|
|
@@ -5083,392 +2938,50 @@ const xiaohongshuJudge = (raw, http) => {
|
|
|
5083
2938
|
return verdictFromHttpStatus(http.status) ?? { ok: true };
|
|
5084
2939
|
};
|
|
5085
2940
|
//#endregion
|
|
5086
|
-
//#region src/platforms/xiaohongshu/sign/
|
|
5087
|
-
/** 初始化签名配置。 */
|
|
5088
|
-
const createXiaohongshuCryptoConfig = () => new CryptoConfig().withOverrides({
|
|
5089
|
-
DATA_WEB_BUILD: "6.12.3",
|
|
5090
|
-
SIGNATURE_DATA_TEMPLATE: {
|
|
5091
|
-
x0: "4.4.3",
|
|
5092
|
-
x1: "xhs-pc-web",
|
|
5093
|
-
x2: "Windows",
|
|
5094
|
-
x3: "",
|
|
5095
|
-
x4: ""
|
|
5096
|
-
},
|
|
5097
|
-
SIGNATURE_XSCOMMON_TEMPLATE: {
|
|
5098
|
-
s0: 5,
|
|
5099
|
-
s1: "",
|
|
5100
|
-
x0: "1",
|
|
5101
|
-
x1: "4.3.5",
|
|
5102
|
-
x2: "Windows",
|
|
5103
|
-
x3: "xhs-pc-web",
|
|
5104
|
-
x4: "6.12.3",
|
|
5105
|
-
x5: "",
|
|
5106
|
-
x6: "",
|
|
5107
|
-
x7: "",
|
|
5108
|
-
x8: "",
|
|
5109
|
-
x9: -596800761,
|
|
5110
|
-
x10: 0,
|
|
5111
|
-
x11: "normal"
|
|
5112
|
-
}
|
|
5113
|
-
});
|
|
5114
|
-
//#endregion
|
|
5115
|
-
//#region src/platforms/xiaohongshu/sign/guestCookie.ts
|
|
5116
|
-
/** @see https://github.com/Cialle/RedCrack */
|
|
5117
|
-
/** 小红书 Web 端游客会话初始化使用的浏览器请求头。 */
|
|
5118
|
-
const GUEST_HEADERS$1 = {
|
|
5119
|
-
accept: "application/json, text/plain, */*",
|
|
5120
|
-
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
|
5121
|
-
"content-type": "application/json;charset=UTF-8",
|
|
5122
|
-
origin: "https://www.xiaohongshu.com",
|
|
5123
|
-
priority: "u=1, i",
|
|
5124
|
-
referer: "https://www.xiaohongshu.com/",
|
|
5125
|
-
"sec-ch-ua": "\"Microsoft Edge\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"",
|
|
5126
|
-
"sec-ch-ua-mobile": "?0",
|
|
5127
|
-
"sec-ch-ua-platform": "\"Windows\"",
|
|
5128
|
-
"sec-fetch-dest": "empty",
|
|
5129
|
-
"sec-fetch-mode": "cors",
|
|
5130
|
-
"sec-fetch-site": "same-site",
|
|
5131
|
-
"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"
|
|
5132
|
-
};
|
|
5133
|
-
/** 生成 a1 随机段时使用的 Web 端字符集。 */
|
|
5134
|
-
const COOKIE_RANDOM_CHARS$1 = "abcdefghijklmnopqrstuvwxyz1234567890";
|
|
5135
|
-
/** 将 Cookie Jar 序列化为可直接写入 HTTP Cookie 请求头的字符串。 */
|
|
5136
|
-
const toCookieString$1 = (cookies) => Object.entries(cookies).map(([key, value]) => `${key}=${value}`).join("; ");
|
|
5137
|
-
/** 将响应头中的 Set-Cookie 字段合并到当前游客会话的 Cookie Jar。 */
|
|
5138
|
-
const updateCookiesFromResponse$1 = (cookies, setCookie) => {
|
|
5139
|
-
const headers = Array.isArray(setCookie) ? setCookie : setCookie ? [setCookie] : [];
|
|
5140
|
-
for (const header of headers) {
|
|
5141
|
-
const [nameValue] = header.split(";", 1);
|
|
5142
|
-
const separatorIndex = nameValue.indexOf("=");
|
|
5143
|
-
if (separatorIndex > 0) cookies[nameValue.slice(0, separatorIndex).trim()] = nameValue.slice(separatorIndex + 1).trim();
|
|
5144
|
-
}
|
|
5145
|
-
};
|
|
5146
|
-
/** 按小红书 Web 端规则生成 a1,以及由 a1 派生的 webId。 */
|
|
5147
|
-
const generateA1AndWebId$1 = () => {
|
|
5148
|
-
const source = `${Date.now().toString(16)}${Array.from(randomBytes(30), (byte) => COOKIE_RANDOM_CHARS$1[byte % 36]).join("")}5000`;
|
|
5149
|
-
const a1 = `${source}${crc32$1(source)}`.slice(0, 52);
|
|
5150
|
-
return {
|
|
5151
|
-
a1,
|
|
5152
|
-
webId: createHash("md5").update(a1).digest("hex")
|
|
5153
|
-
};
|
|
5154
|
-
};
|
|
5155
|
-
/** 计算与浏览器端实现兼容的无符号 CRC32 校验值。 */
|
|
5156
|
-
const crc32$1 = (input) => {
|
|
5157
|
-
let value = 4294967295;
|
|
5158
|
-
for (const byte of Buffer.from(input)) {
|
|
5159
|
-
value ^= byte;
|
|
5160
|
-
for (let bit = 0; bit < 8; bit += 1) value = value & 1 ? value >>> 1 ^ 3988292384 : value >>> 1;
|
|
5161
|
-
}
|
|
5162
|
-
return (value ^ 4294967295) >>> 0;
|
|
5163
|
-
};
|
|
5164
|
-
/** 从 scripting 接口返回的 VMP 数据中解码 websectiga Cookie。 */
|
|
5165
|
-
const generateWebsectiga$1 = (payload) => {
|
|
5166
|
-
const bMatch = payload.match(/"b":"(.*?)",/);
|
|
5167
|
-
const dMatch = payload.match(/"d":(.*?)\}\)/);
|
|
5168
|
-
if (!bMatch || !dMatch) throw new Error("小红书 scripting 响应格式异常,无法生成 websectiga");
|
|
5169
|
-
const decoderData = JSON.parse(dMatch[1]);
|
|
5170
|
-
const encoded = Buffer.from(bMatch[1], "base64").toString("utf8");
|
|
5171
|
-
const logicList = [];
|
|
5172
|
-
for (let index = 0; index < encoded.length; index += 5) logicList.push(Array.from(encoded.slice(index, index + 5), (char) => char.charCodeAt(0) - 1));
|
|
5173
|
-
const start = decoderData[92];
|
|
5174
|
-
const end = decoderData[93];
|
|
5175
|
-
const target = logicList.slice(start, end + 1);
|
|
5176
|
-
const key = Array.from({ length: 64 }, (_, index) => {
|
|
5177
|
-
const item = target[675 + index * 2];
|
|
5178
|
-
if (!item) throw new Error("小红书 scripting 响应缺少 websectiga 解码数据");
|
|
5179
|
-
return decoderData[item[2]];
|
|
5180
|
-
});
|
|
5181
|
-
return Array.from({ length: 8 }, (_, group) => {
|
|
5182
|
-
const offset = 56 - group * 8;
|
|
5183
|
-
return String.fromCharCode(...key.slice(offset, offset + 8));
|
|
5184
|
-
}).join("");
|
|
5185
|
-
};
|
|
5186
|
-
/** 将浏览器指纹编码为 webprofile 接口要求的 DES-ECB profileData。 */
|
|
5187
|
-
const encryptProfileData$1 = (fingerprint, desKey) => {
|
|
5188
|
-
const encoded = Buffer.from(JSON.stringify(fingerprint)).toString("base64");
|
|
5189
|
-
const blockSize = 8;
|
|
5190
|
-
const padding = blockSize - encoded.length % blockSize;
|
|
5191
|
-
const plaintext = Buffer.concat([Buffer.from(encoded), Buffer.alloc(padding)]);
|
|
5192
|
-
const cipher = createCipheriv("des-ede3", Buffer.from(desKey.repeat(3)), null);
|
|
5193
|
-
cipher.setAutoPadding(false);
|
|
5194
|
-
return Buffer.concat([cipher.update(plaintext), cipher.final()]).toString("hex");
|
|
5195
|
-
};
|
|
5196
|
-
/** 将 scripting 的 JSON 或 JSONP 响应统一还原为结构化数据。 */
|
|
5197
|
-
const unwrapScriptingResponse$1 = (data) => {
|
|
5198
|
-
if (typeof data === "string") {
|
|
5199
|
-
const json = data.match(/^[^(]+\((.*)\)$/s)?.[1];
|
|
5200
|
-
return JSON.parse(json ?? data);
|
|
5201
|
-
}
|
|
5202
|
-
return data;
|
|
5203
|
-
};
|
|
5204
|
-
/**
|
|
5205
|
-
* 创建小红书 Web 端游客会话 Cookie。
|
|
5206
|
-
*
|
|
5207
|
-
* 该流程对应 Web 端首次访问时的 Cookie 初始化:生成 a1/webId,完成
|
|
5208
|
-
* scripting、webprofile 与 activate 三个会话请求,并返回最终 Cookie 字符串。
|
|
5209
|
-
*
|
|
5210
|
-
* 会话请求经注入的 `send` 走 transport —— 调用方配的
|
|
5211
|
-
* proxy / agent / 超时对它生效;请求头、响应处理与 Cookie 合并逻辑
|
|
5212
|
-
* 不随发送方式改变。
|
|
5213
|
-
* @param send - 注入的底层发送函数(来自 transport)
|
|
5214
|
-
* @param requestConfig - 调用方请求配置,透传
|
|
5215
|
-
* @returns 游客 Cookie 字符串
|
|
5216
|
-
*/
|
|
5217
|
-
const createXiaohongshuGuestCookie$1 = async (send, requestConfig) => {
|
|
5218
|
-
const cryptoConfig = createXiaohongshuCryptoConfig();
|
|
5219
|
-
const signer = new Xhshow(cryptoConfig);
|
|
5220
|
-
const cookies = {
|
|
5221
|
-
...generateA1AndWebId$1(),
|
|
5222
|
-
webBuild: cryptoConfig.DATA_WEB_BUILD,
|
|
5223
|
-
xsecappid: "xhs-pc-web",
|
|
5224
|
-
loadts: String(Date.now()),
|
|
5225
|
-
abRequestId: randomUUID()
|
|
5226
|
-
};
|
|
5227
|
-
const { headers: requestHeaders } = requestConfig ?? {};
|
|
5228
|
-
/** 发送会话初始化请求,并将响应中的 Set-Cookie 合并回当前 Cookie Jar。 */
|
|
5229
|
-
const request = async (url, data, signed = false) => {
|
|
5230
|
-
const signatureHeaders = signed ? signer.signHeadersPost(new URL(url).pathname, cookies, "xhs-pc-web", data) : {};
|
|
5231
|
-
const response = await send({
|
|
5232
|
-
method: "POST",
|
|
5233
|
-
url,
|
|
5234
|
-
body: data,
|
|
5235
|
-
headers: {
|
|
5236
|
-
...GUEST_HEADERS$1,
|
|
5237
|
-
...requestHeaders,
|
|
5238
|
-
...signatureHeaders,
|
|
5239
|
-
Cookie: toCookieString$1(cookies)
|
|
5240
|
-
}
|
|
5241
|
-
});
|
|
5242
|
-
const setCookie = response.setCookie;
|
|
5243
|
-
updateCookiesFromResponse$1(cookies, setCookie);
|
|
5244
|
-
if (response.status < 200 || response.status >= 300) throw new Error(`小红书游客会话初始化失败:${url} 返回 HTTP ${response.status}`);
|
|
5245
|
-
return response.body;
|
|
5246
|
-
};
|
|
5247
|
-
const scriptingData = unwrapScriptingResponse$1(await request("https://as.xiaohongshu.com/api/sec/v1/scripting", {
|
|
5248
|
-
callFrom: "web",
|
|
5249
|
-
callback: "seccallback"
|
|
5250
|
-
})).data;
|
|
5251
|
-
if (!scriptingData?.data || !scriptingData.secPoisonId) throw new Error("小红书 scripting 响应缺少游客会话数据");
|
|
5252
|
-
cookies.websectiga = generateWebsectiga$1(scriptingData.data);
|
|
5253
|
-
cookies.sec_poison_id = scriptingData.secPoisonId;
|
|
5254
|
-
const fingerprint = new FingerprintGenerator(cryptoConfig).generate(cookies, GUEST_HEADERS$1["user-agent"]);
|
|
5255
|
-
await request(cryptoConfig.GID_URL, {
|
|
5256
|
-
platform: cryptoConfig.DATA_PLATFORM,
|
|
5257
|
-
profileData: encryptProfileData$1(fingerprint, cryptoConfig.DES_KEY),
|
|
5258
|
-
sdkVersion: cryptoConfig.DATA_SDK_VERSION,
|
|
5259
|
-
svn: cryptoConfig.DATA_SVN
|
|
5260
|
-
}, true);
|
|
5261
|
-
await request("https://edith.xiaohongshu.com/api/sns/web/v1/login/activate", {}, true);
|
|
5262
|
-
if (!cookies.web_session) throw new Error("小红书游客会话初始化失败:未获取到 web_session");
|
|
5263
|
-
return toCookieString$1(cookies);
|
|
5264
|
-
};
|
|
5265
|
-
//#endregion
|
|
5266
|
-
//#region src/platforms/xiaohongshu/sign/index.ts
|
|
2941
|
+
//#region src/platforms/xiaohongshu/sign/signers.ts
|
|
5267
2942
|
/**
|
|
5268
|
-
*
|
|
2943
|
+
* 小红书签名器表:**只做「注册名 → `SignStep` 清单」的映射,不含算法**。
|
|
5269
2944
|
*
|
|
5270
|
-
*
|
|
5271
|
-
*
|
|
5272
|
-
*
|
|
2945
|
+
* 算法的唯一实现在 `./steps.ts`(四个基础签名器、helper,以及 XYS_/XYW_ 协议区别、
|
|
2946
|
+
* 签名路径为何取 signPath 等理由都在那边的文件头)。依赖方向单向 —— `signers.ts → steps.ts`,
|
|
2947
|
+
* 永不反向:两份实现并存时会改一边忘一边,而小红书签名错了往往整条请求被判 406,这类分叉极难发现。
|
|
5273
2948
|
*
|
|
5274
|
-
*
|
|
2949
|
+
* ## 两条路,一份实现
|
|
5275
2950
|
*
|
|
5276
|
-
*
|
|
5277
|
-
*
|
|
5278
|
-
* (`user_posted`、`user/otherinfo` 等)自 2026-03 起以 HTTP 406 拒绝传统 XYS_
|
|
5279
|
-
* 格式,改用基于 AES-128-CBC 的 XYW_ 签名。x-s 值以 `XYW_` 开头。
|
|
5280
|
-
* - **x-rap-param**(`generateXRapParam`):feed / 搜索 / 发布类接口的额外校验头。
|
|
5281
|
-
* - **x-xray-traceid**(`generateXrayTraceid`):追踪头,随现代浏览器每次请求下发。
|
|
5282
|
-
* - **search request_id**(`getSearchRequestId`):搜索接口所需的 `{random}-{ts_ms}`。
|
|
5283
|
-
*/
|
|
5284
|
-
/** 模块级 Xhshow 单例 */
|
|
5285
|
-
const client = new Xhshow(createXiaohongshuCryptoConfig());
|
|
5286
|
-
/**
|
|
5287
|
-
* 生成 GET 请求的 X-S 签名。
|
|
5288
|
-
* @param path - API 路径,如 `/api/sns/web/v1/feed`
|
|
5289
|
-
* @param a1Cookie - a1 cookie 值
|
|
5290
|
-
* @param clientType - 客户端类型,默认 `'xhs-pc-web'`
|
|
5291
|
-
* @param params - 查询参数对象
|
|
5292
|
-
* @returns X-S 签名
|
|
5293
|
-
*/
|
|
5294
|
-
const generateXSGet = (path, a1Cookie, clientType = "xhs-pc-web", params = {}) => client.signXsGet(path, a1Cookie, clientType, params);
|
|
5295
|
-
/**
|
|
5296
|
-
* 生成 POST 请求的 X-S 签名。
|
|
5297
|
-
* @param path - API 路径
|
|
5298
|
-
* @param a1Cookie - a1 cookie 值
|
|
5299
|
-
* @param clientType - 客户端类型,默认 `'xhs-pc-web'`
|
|
5300
|
-
* @param body - 请求体对象
|
|
5301
|
-
* @returns X-S 签名
|
|
5302
|
-
*/
|
|
5303
|
-
const generateXSPost = (path, a1Cookie, clientType = "xhs-pc-web", body = {}) => client.signXsPost(path, a1Cookie, clientType, body);
|
|
5304
|
-
/**
|
|
5305
|
-
* 生成 GET 请求的 XYW_ 格式 X-S 签名(AES-128-CBC)。
|
|
2951
|
+
* 端点声明 `sign: [xs('get', 'xyw'), traceId()]` 走清单;`client.xiaohongshu.request` 不给
|
|
2952
|
+
* `amagi.sign` 时走字符串 `'xhs-get'` 这类名字查这张表。两者现在压的是同一批 step,签出来必然一致。
|
|
5306
2953
|
*
|
|
5307
|
-
*
|
|
5308
|
-
* 需改用本函数。返回值以 `XYW_` 开头,走独立于 `signXsGet` 的加密路径。
|
|
5309
|
-
* @param path - API 路径,如 `/api/sns/web/v1/user_posted`
|
|
5310
|
-
* @param a1Cookie - a1 cookie 值
|
|
5311
|
-
* @param clientType - 客户端类型,默认 `'xhs-pc-web'`
|
|
5312
|
-
* @param params - 查询参数对象
|
|
5313
|
-
* @returns XYW_ 格式 X-S 签名
|
|
5314
|
-
*/
|
|
5315
|
-
const generateXSGetXyw = (path, a1Cookie, clientType = "xhs-pc-web", params = {}) => client.signXyw("GET", path, a1Cookie, clientType, params);
|
|
5316
|
-
/**
|
|
5317
|
-
* 生成 POST 请求的 XYW_ 格式 X-S 签名(AES-128-CBC)。
|
|
5318
|
-
* @param path - API 路径
|
|
5319
|
-
* @param a1Cookie - a1 cookie 值
|
|
5320
|
-
* @param clientType - 客户端类型,默认 `'xhs-pc-web'`
|
|
5321
|
-
* @param body - 请求体对象
|
|
5322
|
-
* @returns XYW_ 格式 X-S 签名
|
|
5323
|
-
*/
|
|
5324
|
-
const generateXSPostXyw = (path, a1Cookie, clientType = "xhs-pc-web", body = {}) => client.signXyw("POST", path, a1Cookie, clientType, body);
|
|
5325
|
-
/**
|
|
5326
|
-
* 生成 X-Rap-Param 请求头(feed / 搜索 / 发布类接口的额外校验)。
|
|
5327
|
-
* @param api - 请求 API,形如 `//edith.xiaohongshu.com/api/sns/web/v1/feed`(含 host、去协议)
|
|
5328
|
-
* @param body - 请求体对象(GET 传空对象)
|
|
5329
|
-
* @returns Base64 编码的 x-rap-param 字符串
|
|
5330
|
-
*/
|
|
5331
|
-
const generateXRapParam = (api, body = {}) => xRapParam(api, body);
|
|
5332
|
-
/**
|
|
5333
|
-
* 生成 X-S-Common 参数。
|
|
5334
|
-
* @param cookies - cookie 字符串
|
|
5335
|
-
* @returns Base64 编码的随机字符串
|
|
5336
|
-
*/
|
|
5337
|
-
const generateXSCommon = (cookies) => client.signXsCommon(cookies);
|
|
5338
|
-
/**
|
|
5339
|
-
* 生成 X-T 时间戳。
|
|
5340
|
-
* @returns 当前时间戳(毫秒)
|
|
5341
|
-
*/
|
|
5342
|
-
const generateXT = () => client.getXT();
|
|
5343
|
-
/**
|
|
5344
|
-
* 生成 X-B3-Traceid。
|
|
5345
|
-
* @returns 16 位随机字符串
|
|
5346
|
-
*/
|
|
5347
|
-
const generateXB3Traceid = () => client.getB3TraceId();
|
|
5348
|
-
/**
|
|
5349
|
-
* 生成 X-Xray-Traceid。
|
|
5350
|
-
*
|
|
5351
|
-
* 32 位字符(16 位时间戳+序列 + 16 位随机),随现代浏览器每次请求下发。
|
|
5352
|
-
* @returns X-Xray-Traceid 字符串
|
|
5353
|
-
*/
|
|
5354
|
-
const generateXrayTraceid = () => client.getXrayTraceId();
|
|
5355
|
-
/**
|
|
5356
|
-
* 生成搜索 ID。
|
|
5357
|
-
*
|
|
5358
|
-
* 交由上游 `client.getSearchId()`:base36 编码的 `(timestamp_ms << 64) + random`。
|
|
5359
|
-
* 旧的本地实现把 BigInt 与 string 相加,得到「十进制拼 base36 后缀」而非预期的
|
|
5360
|
-
* 位运算结果 —— 上游修正后此处直接复用,不再自行拼装。
|
|
5361
|
-
* @returns 搜索 ID 字符串
|
|
5362
|
-
*/
|
|
5363
|
-
const getSearchId = () => client.getSearchId();
|
|
5364
|
-
//#endregion
|
|
5365
|
-
//#region src/platforms/xiaohongshu/sign/signers.ts
|
|
5366
|
-
/**
|
|
5367
|
-
* 小红书签名器表。
|
|
2954
|
+
* ## 七个注册名
|
|
5368
2955
|
*
|
|
5369
2956
|
* XYS_(传统)路径:
|
|
5370
|
-
* - `'xhs-post'`:POST
|
|
5371
|
-
* - `'xhs-get'`:GET
|
|
2957
|
+
* - `'xhs-post'`:POST,x-s 用 `signXsPost` 对 body 签名
|
|
2958
|
+
* - `'xhs-get'`:GET,x-s 用 `signXsGet` 对 query 签名
|
|
5372
2959
|
* - `'xhs-get-trace'`:GET + `x-b3-traceid`(userNoteList 专用)
|
|
5373
2960
|
* - `'xhs-post-rap'`:POST + `x-rap-param`(feed / 搜索 / 发布类接口的额外校验)
|
|
5374
2961
|
*
|
|
5375
|
-
* XYW_(2026-03 之后)路径 —— 数据获取类接口以 HTTP 406 拒绝 XYS_
|
|
5376
|
-
*
|
|
5377
|
-
* - `'xhs-get-xyw'`:GET
|
|
5378
|
-
* - `'xhs-post-xyw'`:POST
|
|
2962
|
+
* XYW_(2026-03 之后)路径 —— 数据获取类接口以 HTTP 406 拒绝 XYS_,x-s 改用以 `XYW_`
|
|
2963
|
+
* 开头的 AES-128-CBC 签名:
|
|
2964
|
+
* - `'xhs-get-xyw'`:GET,x-s 用 `signXyw`
|
|
2965
|
+
* - `'xhs-post-xyw'`:POST,x-s 用 `signXyw`
|
|
5379
2966
|
* - `'xhs-get-xyw-trace'`:GET XYW + `x-b3-traceid`(userNoteList 专用)
|
|
5380
2967
|
*
|
|
5381
|
-
*
|
|
5382
|
-
* 小红书签名只用接口路径,与完整 URL 无关。x-rap-param 例外,它要 host + path。
|
|
5383
|
-
*
|
|
5384
|
-
* 签名输入:a1 取自 cookie,POST 签名含 body,GET 签名含 query。
|
|
2968
|
+
* @module platforms/xiaohongshu/sign/signers
|
|
5385
2969
|
*/
|
|
5386
|
-
/** 从 cookie 取 a1:用 `getCookieValue` 按名精确匹配 */
|
|
5387
|
-
const a1Of = (cookie) => getCookieValue(cookie, "a1") ?? "";
|
|
5388
|
-
/** 签名路径:优先 `spec.signPath`(build 填的 apiPath),缺省取 URL 的 pathname */
|
|
5389
|
-
const apiPathOf = (spec) => spec.signPath ?? new URL(spec.url).pathname;
|
|
5390
2970
|
/**
|
|
5391
|
-
*
|
|
5392
|
-
* 小红书 GET 的 x-s 必须覆盖 query(路径 + 参数),只签路径会被判非法请求返回 406。
|
|
5393
|
-
* 无参数端点(emojiList / userProfile)返回空对象,签名退化为只签路径,行为不变。
|
|
5394
|
-
*/
|
|
5395
|
-
const signParamsOf = (spec) => spec.extra?.signParams ?? {};
|
|
5396
|
-
/** x-rap-param 的 api 入参:去协议、保留 `//host/path`(不含 query) */
|
|
5397
|
-
const rapApiOf = (spec) => {
|
|
5398
|
-
const url = new URL(spec.url);
|
|
5399
|
-
return `//${url.host}${url.pathname}`;
|
|
5400
|
-
};
|
|
5401
|
-
/**
|
|
5402
|
-
* 注入 x-s / x-s-common / x-t / x-xray-traceid 四个签名头。
|
|
2971
|
+
* 平台签名器表,交给 runtime 的 `signers` 查名。
|
|
5403
2972
|
*
|
|
5404
|
-
* `
|
|
5405
|
-
*
|
|
5406
|
-
*
|
|
2973
|
+
* 用 `satisfies` 而非显式 `: Record<string, SignFn>` 返回:后者会把键联合抹成宽
|
|
2974
|
+
* `string`,而 {@link XiaohongshuSignerName} 要靠 `keyof` 从这张表推导精确名字联合。
|
|
2975
|
+
* @returns 注册名 → 签名器
|
|
5407
2976
|
*/
|
|
5408
|
-
const signHeaders = (spec, ctx, xs) => {
|
|
5409
|
-
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());
|
|
5410
|
-
return {
|
|
5411
|
-
...spec,
|
|
5412
|
-
headers: headers.toJSON()
|
|
5413
|
-
};
|
|
5414
|
-
};
|
|
5415
|
-
/** POST 签名器:x-s = signXsPost(apiPath, a1, 'xhs-pc-web', body) */
|
|
5416
|
-
const xhsPostSigner = (spec, ctx) => {
|
|
5417
|
-
const xs = generateXSPost(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", spec.body ?? {});
|
|
5418
|
-
return signHeaders(spec, ctx, xs);
|
|
5419
|
-
};
|
|
5420
|
-
/** GET 签名器:x-s = signXsGet(apiPath, a1, 'xhs-pc-web', signParams) */
|
|
5421
|
-
const xhsGetSigner = (spec, ctx) => {
|
|
5422
|
-
const xs = generateXSGet(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", signParamsOf(spec));
|
|
5423
|
-
return signHeaders(spec, ctx, xs);
|
|
5424
|
-
};
|
|
5425
|
-
/** GET + x-b3-traceid(userNoteList 专用) */
|
|
5426
|
-
const xhsGetTraceSigner = async (spec, ctx) => {
|
|
5427
|
-
const signed = await xhsGetSigner(spec, ctx);
|
|
5428
|
-
const headers = new AmagiHeaders(signed.headers).set("x-b3-traceid", generateXB3Traceid());
|
|
5429
|
-
return {
|
|
5430
|
-
...signed,
|
|
5431
|
-
headers: headers.toJSON()
|
|
5432
|
-
};
|
|
5433
|
-
};
|
|
5434
|
-
/** POST + x-rap-param(feed / 搜索 / 发布类接口的额外校验) */
|
|
5435
|
-
const xhsPostRapSigner = async (spec, ctx) => {
|
|
5436
|
-
const signed = await xhsPostSigner(spec, ctx);
|
|
5437
|
-
const rap = generateXRapParam(rapApiOf(spec), spec.body ?? {});
|
|
5438
|
-
const headers = new AmagiHeaders(signed.headers).set("x-rap-param", rap);
|
|
5439
|
-
return {
|
|
5440
|
-
...signed,
|
|
5441
|
-
headers: headers.toJSON()
|
|
5442
|
-
};
|
|
5443
|
-
};
|
|
5444
|
-
/** XYW GET 签名器:x-s = signXyw('GET', apiPath, a1, 'xhs-pc-web', signParams),绕过数据接口 406 */
|
|
5445
|
-
const xhsGetXywSigner = (spec, ctx) => {
|
|
5446
|
-
const xs = generateXSGetXyw(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", signParamsOf(spec));
|
|
5447
|
-
return signHeaders(spec, ctx, xs);
|
|
5448
|
-
};
|
|
5449
|
-
/** XYW POST 签名器:x-s = signXyw('POST', apiPath, a1, 'xhs-pc-web', body) */
|
|
5450
|
-
const xhsPostXywSigner = (spec, ctx) => {
|
|
5451
|
-
const xs = generateXSPostXyw(apiPathOf(spec), a1Of(ctx.cookie), "xhs-pc-web", spec.body ?? {});
|
|
5452
|
-
return signHeaders(spec, ctx, xs);
|
|
5453
|
-
};
|
|
5454
|
-
/** XYW GET + x-b3-traceid(userNoteList 专用,2026-03 之后) */
|
|
5455
|
-
const xhsGetXywTraceSigner = async (spec, ctx) => {
|
|
5456
|
-
const signed = await xhsGetXywSigner(spec, ctx);
|
|
5457
|
-
const headers = new AmagiHeaders(signed.headers).set("x-b3-traceid", generateXB3Traceid());
|
|
5458
|
-
return {
|
|
5459
|
-
...signed,
|
|
5460
|
-
headers: headers.toJSON()
|
|
5461
|
-
};
|
|
5462
|
-
};
|
|
5463
|
-
/** 平台签名器表,交给 runtime 的 `signers` 查名 */
|
|
5464
2977
|
const createXiaohongshuSigners = () => ({
|
|
5465
|
-
"xhs-post":
|
|
5466
|
-
"xhs-get":
|
|
5467
|
-
"xhs-get-trace":
|
|
5468
|
-
"xhs-post-rap":
|
|
5469
|
-
"xhs-get-xyw":
|
|
5470
|
-
"xhs-post-xyw":
|
|
5471
|
-
"xhs-get-xyw-trace":
|
|
2978
|
+
"xhs-post": stepsToSigner([xs("post", "xys")]),
|
|
2979
|
+
"xhs-get": stepsToSigner([xs("get", "xys")]),
|
|
2980
|
+
"xhs-get-trace": stepsToSigner([xs("get", "xys"), traceId()]),
|
|
2981
|
+
"xhs-post-rap": stepsToSigner([xs("post", "xys"), rap()]),
|
|
2982
|
+
"xhs-get-xyw": stepsToSigner([xs("get", "xyw")]),
|
|
2983
|
+
"xhs-post-xyw": stepsToSigner([xs("post", "xyw")]),
|
|
2984
|
+
"xhs-get-xyw-trace": stepsToSigner([xs("get", "xyw"), traceId()])
|
|
5472
2985
|
});
|
|
5473
2986
|
//#endregion
|
|
5474
2987
|
//#region src/runtime/events.ts
|
|
@@ -6377,7 +3890,10 @@ const runPaginated = async (def, params, runPage) => {
|
|
|
6377
3890
|
const limitKey = def.limitParam ?? "number";
|
|
6378
3891
|
const countKey = def.countParam ?? limitKey;
|
|
6379
3892
|
const target = resolveTarget(params[limitKey], def.maxPageSize);
|
|
6380
|
-
|
|
3893
|
+
if (target === 0) return {
|
|
3894
|
+
ok: true,
|
|
3895
|
+
value: { empty: true }
|
|
3896
|
+
};
|
|
6381
3897
|
const collected = [];
|
|
6382
3898
|
let current = params;
|
|
6383
3899
|
let lastPage;
|
|
@@ -6394,7 +3910,6 @@ const runPaginated = async (def, params, runPage) => {
|
|
|
6394
3910
|
error: outcome.error
|
|
6395
3911
|
};
|
|
6396
3912
|
const page = outcome.value;
|
|
6397
|
-
pages.push(page);
|
|
6398
3913
|
lastPage = page;
|
|
6399
3914
|
const list = def.items(page);
|
|
6400
3915
|
if (Array.isArray(list) && list.length > 0) collected.push(...list);
|
|
@@ -6406,9 +3921,9 @@ const runPaginated = async (def, params, runPage) => {
|
|
|
6406
3921
|
return {
|
|
6407
3922
|
ok: true,
|
|
6408
3923
|
value: {
|
|
3924
|
+
empty: false,
|
|
6409
3925
|
lastPage,
|
|
6410
|
-
|
|
6411
|
-
items: target === 0 ? [] : collected.slice(0, target)
|
|
3926
|
+
items: collected.slice(0, target)
|
|
6412
3927
|
}
|
|
6413
3928
|
};
|
|
6414
3929
|
};
|
|
@@ -6584,6 +4099,8 @@ const classifyThrown = (cause, stage) => {
|
|
|
6584
4099
|
const resolveSigner = (decl, signers) => {
|
|
6585
4100
|
if (decl === void 0 || decl === false) return void 0;
|
|
6586
4101
|
if (typeof decl === "function") return decl;
|
|
4102
|
+
if (Array.isArray(decl)) return stepsToSigner(decl);
|
|
4103
|
+
if (typeof decl === "object") return stepsToSigner([decl]);
|
|
6587
4104
|
const signer = signers?.[decl];
|
|
6588
4105
|
if (!signer) throw new Error(`未注册的签名器:'${decl}'`);
|
|
6589
4106
|
return signer;
|
|
@@ -6760,7 +4277,10 @@ const execute = async (def, input, options) => {
|
|
|
6760
4277
|
});
|
|
6761
4278
|
if (!paged.ok) return failWith(paged.error);
|
|
6762
4279
|
stage = "normalize";
|
|
6763
|
-
|
|
4280
|
+
const outcome = paged.value;
|
|
4281
|
+
if (outcome.empty) return succeed(void 0);
|
|
4282
|
+
const merge = def.paginate.merge;
|
|
4283
|
+
return succeed(merge ? merge(outcome, params) : outcome);
|
|
6764
4284
|
}
|
|
6765
4285
|
stage = "build";
|
|
6766
4286
|
const built = def.build(params, ctx);
|
|
@@ -6784,6 +4304,15 @@ const execute = async (def, input, options) => {
|
|
|
6784
4304
|
if (firstFailure) return failWith(firstFailure.error);
|
|
6785
4305
|
}
|
|
6786
4306
|
stage = "normalize";
|
|
4307
|
+
if (isMulti && def.aggregate) {
|
|
4308
|
+
const parts = outcomes.map((o) => o.ok ? o.value : void 0);
|
|
4309
|
+
const head = parts.find((p) => p !== void 0);
|
|
4310
|
+
const aggregate = def.aggregate;
|
|
4311
|
+
return succeed(aggregate({
|
|
4312
|
+
parts,
|
|
4313
|
+
head
|
|
4314
|
+
}, params));
|
|
4315
|
+
}
|
|
6787
4316
|
const decodedValue = isMulti ? outcomes.map((o) => o.ok ? o.value : void 0) : outcomes[0].value;
|
|
6788
4317
|
return succeed(def.normalize ? def.normalize(decodedValue, params) : decodedValue);
|
|
6789
4318
|
} catch (cause) {
|
|
@@ -7197,7 +4726,6 @@ var DouyinAPI$1 = class {
|
|
|
7197
4726
|
platform: "PC",
|
|
7198
4727
|
downlink: "10",
|
|
7199
4728
|
effective_type: "4g",
|
|
7200
|
-
msToken: douyinSign$1.Mstoken(184),
|
|
7201
4729
|
verifyFp: fp$1,
|
|
7202
4730
|
fp: fp$1
|
|
7203
4731
|
};
|
|
@@ -7301,7 +4829,6 @@ var DouyinAPI$1 = class {
|
|
|
7301
4829
|
device_id: "7326472315356857893",
|
|
7302
4830
|
aweme_ids: `[${data.aweme_id}]`,
|
|
7303
4831
|
request_source: "200",
|
|
7304
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7305
4832
|
verifyFp: fp$1,
|
|
7306
4833
|
fp: fp$1
|
|
7307
4834
|
};
|
|
@@ -7423,7 +4950,6 @@ var DouyinAPI$1 = class {
|
|
|
7423
4950
|
downlink: "10",
|
|
7424
4951
|
effective_type: "4g",
|
|
7425
4952
|
round_trip_time: "50",
|
|
7426
|
-
msToken: douyinSign$1.Mstoken(184),
|
|
7427
4953
|
verifyFp: fp$1,
|
|
7428
4954
|
fp: fp$1
|
|
7429
4955
|
};
|
|
@@ -7551,8 +5077,7 @@ var DouyinAPI$1 = class {
|
|
|
7551
5077
|
}
|
|
7552
5078
|
/** 获取互动表情数据 */
|
|
7553
5079
|
getDynamicEmojiList() {
|
|
7554
|
-
|
|
7555
|
-
const params = {
|
|
5080
|
+
return `https://www.douyin.com/aweme/v1/web/im/strategy/config?${buildQueryString$3({
|
|
7556
5081
|
device_platform: "webapp",
|
|
7557
5082
|
aid: "1128",
|
|
7558
5083
|
channel: "channel_pc_web",
|
|
@@ -7581,11 +5106,9 @@ var DouyinAPI$1 = class {
|
|
|
7581
5106
|
downlink: "1.5",
|
|
7582
5107
|
effective_type: "4g",
|
|
7583
5108
|
round_trip_time: "350",
|
|
7584
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7585
5109
|
verifyFp: fp$1,
|
|
7586
5110
|
fp: fp$1
|
|
7587
|
-
}
|
|
7588
|
-
return `${baseUrl}?${buildQueryString$3(params)}`;
|
|
5111
|
+
})}`;
|
|
7589
5112
|
}
|
|
7590
5113
|
/** 获取背景音乐数据 */
|
|
7591
5114
|
getMusicInfo(data) {
|
|
@@ -7617,7 +5140,6 @@ var DouyinAPI$1 = class {
|
|
|
7617
5140
|
downlink: "1.5",
|
|
7618
5141
|
effective_type: "4g",
|
|
7619
5142
|
round_trip_time: "350",
|
|
7620
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7621
5143
|
verifyFp: fp$1,
|
|
7622
5144
|
fp: fp$1
|
|
7623
5145
|
};
|
|
@@ -7646,7 +5168,6 @@ var DouyinAPI$1 = class {
|
|
|
7646
5168
|
is_need_double_stream: "false",
|
|
7647
5169
|
insert_task_id: "",
|
|
7648
5170
|
live_reason: "",
|
|
7649
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7650
5171
|
verifyFp: fp$1,
|
|
7651
5172
|
fp: fp$1
|
|
7652
5173
|
};
|
|
@@ -7687,7 +5208,6 @@ var DouyinAPI$1 = class {
|
|
|
7687
5208
|
engine_version: "140.0.0.0",
|
|
7688
5209
|
downlink: "1.55",
|
|
7689
5210
|
round_trip_time: "200",
|
|
7690
|
-
msToken: douyinSign$1.Mstoken(116),
|
|
7691
5211
|
verifyFp: fp$1,
|
|
7692
5212
|
fp: fp$1
|
|
7693
5213
|
};
|
|
@@ -7697,6 +5217,19 @@ var DouyinAPI$1 = class {
|
|
|
7697
5217
|
/** 默认的 DouyinAPI 实例(使用默认浏览器版本 125.0.0.0) */
|
|
7698
5218
|
const douyinApiUrls$1 = new DouyinAPI$1();
|
|
7699
5219
|
//#endregion
|
|
5220
|
+
//#region src/platforms/douyin/endpoints/define.ts
|
|
5221
|
+
/**
|
|
5222
|
+
* 抖音端点声明。
|
|
5223
|
+
*
|
|
5224
|
+
* 就是 {@link defineEndpoint} 绑定了抖音签名器名联合 {@link DouyinSignerName}:
|
|
5225
|
+
* `sign` 的字符串分支被收窄成 `'a-bogus' | 'x-bogus'`,写错名字(如 `'a-bogas'`)
|
|
5226
|
+
* 编译期即报错,不必等运行时查表失败。运行时仍是恒等函数,其余行为与通用
|
|
5227
|
+
* `defineEndpoint` 完全一致。
|
|
5228
|
+
* @param def - 端点声明(`sign` 的名字受抖音签名器表约束)
|
|
5229
|
+
* @returns 原样返回 `def`,带上推导好的具体类型
|
|
5230
|
+
*/
|
|
5231
|
+
const defineDouyinEndpoint = (def) => defineEndpoint(def);
|
|
5232
|
+
//#endregion
|
|
7700
5233
|
//#region src/platforms/douyin/endpoints/commentReplies.ts
|
|
7701
5234
|
/**
|
|
7702
5235
|
* 二级评论(声明式翻页,maxPageSize 3,签名用 `x_bogus`)。
|
|
@@ -7704,7 +5237,7 @@ const douyinApiUrls$1 = new DouyinAPI$1();
|
|
|
7704
5237
|
* 与旧版一致:`getCommentReplies` GET,签名器是 `x_bogus`,游标是 `cursor`
|
|
7705
5238
|
* (`has_more === 1` 继续),最终形状 `{ ...最后一页, comments, cursor }`。
|
|
7706
5239
|
*/
|
|
7707
|
-
const commentReplies =
|
|
5240
|
+
const commentReplies = defineDouyinEndpoint({
|
|
7708
5241
|
name: "douyin.commentReplies",
|
|
7709
5242
|
route: "/fetch_video_comment_replies",
|
|
7710
5243
|
doc: {
|
|
@@ -7721,7 +5254,7 @@ const commentReplies = defineEndpoint({
|
|
|
7721
5254
|
method: "GET",
|
|
7722
5255
|
url: douyinApiUrls$1.getCommentReplies(p)
|
|
7723
5256
|
}),
|
|
7724
|
-
sign:
|
|
5257
|
+
sign: [xBogus(), secsdk()],
|
|
7725
5258
|
retryOn: ["ANTIBOT_PAGE"],
|
|
7726
5259
|
retryFresh: true,
|
|
7727
5260
|
paginate: {
|
|
@@ -7731,16 +5264,12 @@ const commentReplies = defineEndpoint({
|
|
|
7731
5264
|
nextParams: (params, page) => ({
|
|
7732
5265
|
...params,
|
|
7733
5266
|
cursor: page.cursor
|
|
7734
|
-
})
|
|
7735
|
-
|
|
7736
|
-
|
|
7737
|
-
const { lastPage, items } = decoded;
|
|
7738
|
-
const page = lastPage;
|
|
7739
|
-
return {
|
|
7740
|
-
...page ?? {},
|
|
5267
|
+
}),
|
|
5268
|
+
merge: ({ lastPage, items }) => ({
|
|
5269
|
+
...lastPage,
|
|
7741
5270
|
comments: items,
|
|
7742
|
-
cursor:
|
|
7743
|
-
}
|
|
5271
|
+
cursor: lastPage.cursor ?? items.length
|
|
5272
|
+
})
|
|
7744
5273
|
},
|
|
7745
5274
|
response: type()
|
|
7746
5275
|
});
|
|
@@ -7753,7 +5282,7 @@ const commentReplies = defineEndpoint({
|
|
|
7753
5282
|
* `cursor`(`has_more === 1` 继续),最终形状 `{ ...最后一页, comments, cursor }`
|
|
7754
5283
|
* (`cursor: resp.cursor ?? list.length`)。
|
|
7755
5284
|
*/
|
|
7756
|
-
const comments$1 =
|
|
5285
|
+
const comments$1 = defineDouyinEndpoint({
|
|
7757
5286
|
name: "douyin.comments",
|
|
7758
5287
|
route: "/fetch_work_comments",
|
|
7759
5288
|
doc: {
|
|
@@ -7769,7 +5298,7 @@ const comments$1 = defineEndpoint({
|
|
|
7769
5298
|
method: "GET",
|
|
7770
5299
|
url: douyinApiUrls$1.getComments(p)
|
|
7771
5300
|
}),
|
|
7772
|
-
sign:
|
|
5301
|
+
sign: douyinBogus(184),
|
|
7773
5302
|
retryOn: ["ANTIBOT_PAGE"],
|
|
7774
5303
|
retryFresh: true,
|
|
7775
5304
|
paginate: {
|
|
@@ -7779,16 +5308,12 @@ const comments$1 = defineEndpoint({
|
|
|
7779
5308
|
nextParams: (params, page) => ({
|
|
7780
5309
|
...params,
|
|
7781
5310
|
cursor: page.cursor
|
|
7782
|
-
})
|
|
7783
|
-
|
|
7784
|
-
|
|
7785
|
-
const { lastPage, items } = decoded;
|
|
7786
|
-
const page = lastPage;
|
|
7787
|
-
return {
|
|
7788
|
-
...page ?? {},
|
|
5311
|
+
}),
|
|
5312
|
+
merge: ({ lastPage, items }) => ({
|
|
5313
|
+
...lastPage,
|
|
7789
5314
|
comments: items,
|
|
7790
|
-
cursor:
|
|
7791
|
-
}
|
|
5315
|
+
cursor: lastPage.cursor ?? items.length
|
|
5316
|
+
})
|
|
7792
5317
|
},
|
|
7793
5318
|
response: type()
|
|
7794
5319
|
});
|
|
@@ -7805,7 +5330,7 @@ const comments$1 = defineEndpoint({
|
|
|
7805
5330
|
* 整体(execute 的 tolerate:**全部分片都失败时仍返回失败信封**),
|
|
7806
5331
|
* `normalize` 负责合并与排序。
|
|
7807
5332
|
*/
|
|
7808
|
-
const danmakuList$1 =
|
|
5333
|
+
const danmakuList$1 = defineDouyinEndpoint({
|
|
7809
5334
|
name: "douyin.danmakuList",
|
|
7810
5335
|
route: "/fetch_work_danmaku",
|
|
7811
5336
|
doc: {
|
|
@@ -7853,37 +5378,18 @@ const danmakuList$1 = defineEndpoint({
|
|
|
7853
5378
|
tag: `segment-${index + 1}`
|
|
7854
5379
|
}));
|
|
7855
5380
|
},
|
|
7856
|
-
sign:
|
|
5381
|
+
sign: douyinBogus(116),
|
|
7857
5382
|
retryOn: ["ANTIBOT_PAGE"],
|
|
7858
5383
|
retryFresh: true,
|
|
7859
5384
|
partial: "tolerate",
|
|
7860
|
-
|
|
7861
|
-
const
|
|
7862
|
-
const startTime = params.start_time ?? 0;
|
|
7863
|
-
const endTime = params.end_time ?? params.duration;
|
|
7864
|
-
const merged = [];
|
|
7865
|
-
let finalExtra = null;
|
|
7866
|
-
let finalLogPb = null;
|
|
7867
|
-
let finalStatusCode = 0;
|
|
7868
|
-
parts.forEach((segmentData, index) => {
|
|
7869
|
-
if (segmentData && Array.isArray(segmentData.danmaku_list)) {
|
|
7870
|
-
merged.push(...segmentData.danmaku_list);
|
|
7871
|
-
if (index === 0) {
|
|
7872
|
-
finalExtra = segmentData.extra;
|
|
7873
|
-
finalLogPb = segmentData.log_pb;
|
|
7874
|
-
finalStatusCode = segmentData.status_code ?? 0;
|
|
7875
|
-
}
|
|
7876
|
-
}
|
|
7877
|
-
});
|
|
7878
|
-
merged.sort((a, b) => (a.offset_time ?? 0) - (b.offset_time ?? 0));
|
|
5385
|
+
aggregate: ({ parts, head }, params) => {
|
|
5386
|
+
const merged = parts.flatMap((seg) => seg?.danmaku_list ?? []).sort((a, b) => a.offset_time - b.offset_time);
|
|
7879
5387
|
return {
|
|
5388
|
+
...head,
|
|
7880
5389
|
danmaku_list: merged,
|
|
7881
|
-
start_time:
|
|
7882
|
-
end_time:
|
|
7883
|
-
total: merged.length
|
|
7884
|
-
status_code: finalStatusCode,
|
|
7885
|
-
extra: finalExtra,
|
|
7886
|
-
log_pb: finalLogPb
|
|
5390
|
+
start_time: params.start_time ?? 0,
|
|
5391
|
+
end_time: params.end_time ?? params.duration,
|
|
5392
|
+
total: merged.length
|
|
7887
5393
|
};
|
|
7888
5394
|
},
|
|
7889
5395
|
response: type()
|
|
@@ -7895,7 +5401,7 @@ const danmakuList$1 = defineEndpoint({
|
|
|
7895
5401
|
*
|
|
7896
5402
|
* 与旧版一致:`getDynamicEmojiList` GET + a_bogus 签名。
|
|
7897
5403
|
*/
|
|
7898
|
-
const dynamicEmojiList =
|
|
5404
|
+
const dynamicEmojiList = defineDouyinEndpoint({
|
|
7899
5405
|
name: "douyin.dynamicEmojiList",
|
|
7900
5406
|
route: "/fetch_emoji_pro_list",
|
|
7901
5407
|
doc: {
|
|
@@ -7907,7 +5413,7 @@ const dynamicEmojiList = defineEndpoint({
|
|
|
7907
5413
|
method: "GET",
|
|
7908
5414
|
url: douyinApiUrls$1.getDynamicEmojiList()
|
|
7909
5415
|
}),
|
|
7910
|
-
sign:
|
|
5416
|
+
sign: douyinBogus(116),
|
|
7911
5417
|
retryOn: ["ANTIBOT_PAGE"],
|
|
7912
5418
|
retryFresh: true,
|
|
7913
5419
|
response: type()
|
|
@@ -7919,7 +5425,7 @@ const dynamicEmojiList = defineEndpoint({
|
|
|
7919
5425
|
*
|
|
7920
5426
|
* 与旧版一致:`getEmojiList` GET,**不带签名参数**。
|
|
7921
5427
|
*/
|
|
7922
|
-
const emojiList$3 =
|
|
5428
|
+
const emojiList$3 = defineDouyinEndpoint({
|
|
7923
5429
|
name: "douyin.emojiList",
|
|
7924
5430
|
route: "/fetch_emoji_list",
|
|
7925
5431
|
doc: {
|
|
@@ -7931,7 +5437,7 @@ const emojiList$3 = defineEndpoint({
|
|
|
7931
5437
|
method: "GET",
|
|
7932
5438
|
url: douyinApiUrls$1.getEmojiList()
|
|
7933
5439
|
}),
|
|
7934
|
-
sign:
|
|
5440
|
+
sign: [msToken(184)],
|
|
7935
5441
|
response: type()
|
|
7936
5442
|
});
|
|
7937
5443
|
//#endregion
|
|
@@ -7953,7 +5459,7 @@ const emojiList$3 = defineEndpoint({
|
|
|
7953
5459
|
* 超时是「本次调用」的属性而不是端点的属性)。
|
|
7954
5460
|
|
|
7955
5461
|
*/
|
|
7956
|
-
const emojiResourceMeta =
|
|
5462
|
+
const emojiResourceMeta = defineDouyinEndpoint({
|
|
7957
5463
|
name: "douyin.emojiResourceMeta",
|
|
7958
5464
|
route: "/fetch_emoji_resource_meta",
|
|
7959
5465
|
doc: {
|
|
@@ -7988,7 +5494,7 @@ const emojiResourceMeta = defineEndpoint({
|
|
|
7988
5494
|
* 接口的 `has_more` 形状还没有样本可依。
|
|
7989
5495
|
|
|
7990
5496
|
*/
|
|
7991
|
-
const guestMusicAwemeList =
|
|
5497
|
+
const guestMusicAwemeList = defineDouyinEndpoint({
|
|
7992
5498
|
name: "douyin.guestMusicAwemeList",
|
|
7993
5499
|
route: "/fetch_guest_music_aweme_list",
|
|
7994
5500
|
doc: {
|
|
@@ -8019,7 +5525,7 @@ const guestMusicAwemeList = defineEndpoint({
|
|
|
8019
5525
|
* `extra.extract_item_id` 就是创建这条原声的那个作品。哪条优先由调用方按场景排。
|
|
8020
5526
|
|
|
8021
5527
|
*/
|
|
8022
|
-
const guestMusicInfo =
|
|
5528
|
+
const guestMusicInfo = defineDouyinEndpoint({
|
|
8023
5529
|
name: "douyin.guestMusicInfo",
|
|
8024
5530
|
route: "/fetch_guest_music_info",
|
|
8025
5531
|
doc: {
|
|
@@ -8049,7 +5555,7 @@ const guestMusicInfo = defineEndpoint({
|
|
|
8049
5555
|
* 「设备参数 × 会话」的交叉校验,这条接口本来不需要身份。
|
|
8050
5556
|
|
|
8051
5557
|
*/
|
|
8052
|
-
const guestUserInfo =
|
|
5558
|
+
const guestUserInfo = defineDouyinEndpoint({
|
|
8053
5559
|
name: "douyin.guestUserInfo",
|
|
8054
5560
|
route: "/fetch_guest_user_info",
|
|
8055
5561
|
doc: {
|
|
@@ -8072,7 +5578,7 @@ const guestUserInfo = defineEndpoint({
|
|
|
8072
5578
|
*
|
|
8073
5579
|
* 行为与旧版一致:`getWorkDetail` GET + a_bogus 签名。
|
|
8074
5580
|
*/
|
|
8075
|
-
const imageAlbumWork =
|
|
5581
|
+
const imageAlbumWork = defineDouyinEndpoint({
|
|
8076
5582
|
name: "douyin.imageAlbumWork",
|
|
8077
5583
|
route: "/fetch_image_album_work",
|
|
8078
5584
|
doc: {
|
|
@@ -8084,7 +5590,7 @@ const imageAlbumWork = defineEndpoint({
|
|
|
8084
5590
|
method: "GET",
|
|
8085
5591
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8086
5592
|
}),
|
|
8087
|
-
sign:
|
|
5593
|
+
sign: douyinBogus(184),
|
|
8088
5594
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8089
5595
|
retryFresh: true,
|
|
8090
5596
|
response: type()
|
|
@@ -8131,7 +5637,7 @@ const withDouyinReferer = (ctx, page) => {
|
|
|
8131
5637
|
* 与旧版一致:`getLiveRoomInfo` GET + a_bogus 签名,
|
|
8132
5638
|
* Referer 指向 `https://live.douyin.com/{web_rid}`。
|
|
8133
5639
|
*/
|
|
8134
|
-
const liveRoomInfo$1 =
|
|
5640
|
+
const liveRoomInfo$1 = defineDouyinEndpoint({
|
|
8135
5641
|
name: "douyin.liveRoomInfo",
|
|
8136
5642
|
route: "/fetch_user_live_videos",
|
|
8137
5643
|
doc: {
|
|
@@ -8150,7 +5656,7 @@ const liveRoomInfo$1 = defineEndpoint({
|
|
|
8150
5656
|
webRid: p.web_rid
|
|
8151
5657
|
})
|
|
8152
5658
|
}),
|
|
8153
|
-
sign:
|
|
5659
|
+
sign: douyinBogus(116),
|
|
8154
5660
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8155
5661
|
retryFresh: true,
|
|
8156
5662
|
response: type()
|
|
@@ -8166,7 +5672,7 @@ const liveRoomInfo$1 = defineEndpoint({
|
|
|
8166
5672
|
* 映射条目 `DyLoginQrcode` 是本端点的**原始响应**,与 `DyPassportQrcode`
|
|
8167
5673
|
* (登录状态机归一化后的形状,runtime/session 用)不是一回事。
|
|
8168
5674
|
*/
|
|
8169
|
-
const loginQrcode =
|
|
5675
|
+
const loginQrcode = defineDouyinEndpoint({
|
|
8170
5676
|
name: "douyin.loginQrcode",
|
|
8171
5677
|
route: "/fetch_login_qrcode",
|
|
8172
5678
|
doc: {
|
|
@@ -8178,7 +5684,7 @@ const loginQrcode = defineEndpoint({
|
|
|
8178
5684
|
method: "GET",
|
|
8179
5685
|
url: douyinApiUrls$1.getLoginQrcode(p)
|
|
8180
5686
|
}),
|
|
8181
|
-
sign:
|
|
5687
|
+
sign: [aBogus$1(), secsdk()],
|
|
8182
5688
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8183
5689
|
retryFresh: true,
|
|
8184
5690
|
response: type()
|
|
@@ -8190,7 +5696,7 @@ const loginQrcode = defineEndpoint({
|
|
|
8190
5696
|
*
|
|
8191
5697
|
* 与旧版一致:`getMusicInfo` GET + a_bogus 签名。
|
|
8192
5698
|
*/
|
|
8193
|
-
const musicInfo =
|
|
5699
|
+
const musicInfo = defineDouyinEndpoint({
|
|
8194
5700
|
name: "douyin.musicInfo",
|
|
8195
5701
|
route: "/fetch_music_work",
|
|
8196
5702
|
doc: {
|
|
@@ -8202,7 +5708,7 @@ const musicInfo = defineEndpoint({
|
|
|
8202
5708
|
method: "GET",
|
|
8203
5709
|
url: douyinApiUrls$1.getMusicInfo(p)
|
|
8204
5710
|
}),
|
|
8205
|
-
sign:
|
|
5711
|
+
sign: douyinBogus(116),
|
|
8206
5712
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8207
5713
|
retryFresh: true,
|
|
8208
5714
|
response: type()
|
|
@@ -8216,7 +5722,7 @@ const musicInfo = defineEndpoint({
|
|
|
8216
5722
|
* 其余 4 个各占新路径。
|
|
8217
5723
|
* 与旧版一致:`getWorkDetail` GET + a_bogus 签名,返回原始响应。
|
|
8218
5724
|
*/
|
|
8219
|
-
const parseWork =
|
|
5725
|
+
const parseWork = defineDouyinEndpoint({
|
|
8220
5726
|
name: "douyin.parseWork",
|
|
8221
5727
|
route: "/fetch_one_work",
|
|
8222
5728
|
doc: {
|
|
@@ -8228,7 +5734,7 @@ const parseWork = defineEndpoint({
|
|
|
8228
5734
|
method: "GET",
|
|
8229
5735
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8230
5736
|
}),
|
|
8231
|
-
sign:
|
|
5737
|
+
sign: douyinBogus(184),
|
|
8232
5738
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8233
5739
|
retryFresh: true,
|
|
8234
5740
|
response: type()
|
|
@@ -8360,7 +5866,7 @@ const SEARCH_TYPE_FIELD = "__search_type";
|
|
|
8360
5866
|
*
|
|
8361
5867
|
* 与旧版一致:**不签名**。
|
|
8362
5868
|
*/
|
|
8363
|
-
const search =
|
|
5869
|
+
const search = defineDouyinEndpoint({
|
|
8364
5870
|
name: "douyin.search",
|
|
8365
5871
|
route: "/fetch_search_info",
|
|
8366
5872
|
doc: {
|
|
@@ -8397,7 +5903,7 @@ const search = defineEndpoint({
|
|
|
8397
5903
|
})
|
|
8398
5904
|
};
|
|
8399
5905
|
},
|
|
8400
|
-
sign:
|
|
5906
|
+
sign: [msToken(184)],
|
|
8401
5907
|
decode: (raw) => {
|
|
8402
5908
|
if (typeof raw !== "string") return raw;
|
|
8403
5909
|
const chunks = parseDouyinMultiJson(raw);
|
|
@@ -8417,50 +5923,47 @@ const search = defineEndpoint({
|
|
|
8417
5923
|
judge: searchJudge,
|
|
8418
5924
|
paginate: {
|
|
8419
5925
|
maxPageSize: 15,
|
|
5926
|
+
page: type(),
|
|
8420
5927
|
items: (page) => {
|
|
8421
|
-
|
|
8422
|
-
|
|
8423
|
-
return p.data ?? [];
|
|
5928
|
+
if (Array.isArray(page.user_list)) return page.user_list;
|
|
5929
|
+
return page.data ?? [];
|
|
8424
5930
|
},
|
|
8425
5931
|
hasMore: (page) => page.has_more !== 0,
|
|
8426
5932
|
nextParams: (params, page) => {
|
|
8427
5933
|
const p = page;
|
|
8428
5934
|
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;
|
|
8429
5935
|
const currentOffset = params.offset ?? 0;
|
|
8430
|
-
const items = Array.isArray(page
|
|
5936
|
+
const items = Array.isArray(page.user_list) ? page.user_list : page.data ?? [];
|
|
8431
5937
|
const nextOffset = typeof p.cursor === "number" && p.cursor > currentOffset ? p.cursor : currentOffset + items.length;
|
|
8432
5938
|
return {
|
|
8433
5939
|
...params,
|
|
8434
5940
|
search_id: nextSearchId,
|
|
8435
5941
|
offset: nextOffset
|
|
8436
5942
|
};
|
|
5943
|
+
},
|
|
5944
|
+
merge: ({ lastPage, items }, params) => {
|
|
5945
|
+
/**
|
|
5946
|
+
* 判别值 = **响应形状** 与 **请求类型** 的组合,各管一半:
|
|
5947
|
+
*
|
|
5948
|
+
* - **user 只看结构**:成功路径上 user 必有 `user_list`、video / general 必有 `data`
|
|
5949
|
+
* (`searchJudge` 就是按这条判反爬的),所以 `user_list` 在不在是可靠信号。
|
|
5950
|
+
* - **general 与 video 之间没有可靠结构信号**,只能取 `params.type`。
|
|
5951
|
+
* **反爬包装的形态跟逻辑类型不是一回事,不能当判据** —— 分页最后一页是单个
|
|
5952
|
+
* 合法 JSON,会被解析成对象,按 wire body 形态判会把 general 误标成 video。
|
|
5953
|
+
* - 两者矛盾时(请求 user、回的却是 data 形状)按 `general` 记:「不是 user」比
|
|
5954
|
+
* 「请求说是 user」更硬。
|
|
5955
|
+
*/
|
|
5956
|
+
const requested = params.type ?? "general";
|
|
5957
|
+
const searchType = Array.isArray(lastPage.user_list) ? "user" : requested === "user" ? "general" : requested;
|
|
5958
|
+
const out = {
|
|
5959
|
+
...lastPage,
|
|
5960
|
+
[SEARCH_TYPE_FIELD]: searchType
|
|
5961
|
+
};
|
|
5962
|
+
if (Array.isArray(lastPage.user_list)) out.user_list = items;
|
|
5963
|
+
else out.data = items;
|
|
5964
|
+
return out;
|
|
8437
5965
|
}
|
|
8438
5966
|
},
|
|
8439
|
-
normalize: (decoded, params) => {
|
|
8440
|
-
const { lastPage, items } = decoded;
|
|
8441
|
-
const page = lastPage;
|
|
8442
|
-
/**
|
|
8443
|
-
* 判别值 = **响应形状** 与 **请求类型** 的组合,各管一半:
|
|
8444
|
-
*
|
|
8445
|
-
* - **user 只看结构**:成功路径上 user 必有 `user_list`、video / general 必有 `data`
|
|
8446
|
-
* (`searchJudge` 就是按这条判反爬的),所以 `user_list` 在不在是可靠信号。
|
|
8447
|
-
* - **general 与 video 之间没有可靠结构信号**,只能取 `params.type`。
|
|
8448
|
-
* **反爬包装的形态跟逻辑类型不是一回事,不能当判据** —— 分页最后一页是单个
|
|
8449
|
-
* 合法 JSON,会被解析成对象,按 wire body 形态判会把 general 误标成 video。
|
|
8450
|
-
* - 两者矛盾时(请求 user、回的却是 data 形状)按 `general` 记:「不是 user」比
|
|
8451
|
-
* 「请求说是 user」更硬 —— 记成 user 会让下游收窄到「有 `user_list` 的那一支」,
|
|
8452
|
-
* 而那恰恰是它没有的东西。
|
|
8453
|
-
*/
|
|
8454
|
-
const requested = params.type ?? "general";
|
|
8455
|
-
const searchType = Array.isArray(page?.user_list) ? "user" : requested === "user" ? "general" : requested;
|
|
8456
|
-
const out = {
|
|
8457
|
-
...page ?? {},
|
|
8458
|
-
[SEARCH_TYPE_FIELD]: searchType
|
|
8459
|
-
};
|
|
8460
|
-
if (Array.isArray(page?.user_list)) out.user_list = items;
|
|
8461
|
-
else out.data = items;
|
|
8462
|
-
return out;
|
|
8463
|
-
},
|
|
8464
5967
|
response: type()
|
|
8465
5968
|
});
|
|
8466
5969
|
//#endregion
|
|
@@ -8470,7 +5973,7 @@ const search = defineEndpoint({
|
|
|
8470
5973
|
*
|
|
8471
5974
|
* 行为与旧版一致:`getWorkDetail` GET + a_bogus 签名。
|
|
8472
5975
|
*/
|
|
8473
|
-
const slidesWork =
|
|
5976
|
+
const slidesWork = defineDouyinEndpoint({
|
|
8474
5977
|
name: "douyin.slidesWork",
|
|
8475
5978
|
route: "/fetch_slides_work",
|
|
8476
5979
|
doc: {
|
|
@@ -8482,7 +5985,7 @@ const slidesWork = defineEndpoint({
|
|
|
8482
5985
|
method: "GET",
|
|
8483
5986
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8484
5987
|
}),
|
|
8485
|
-
sign:
|
|
5988
|
+
sign: douyinBogus(184),
|
|
8486
5989
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8487
5990
|
retryFresh: true,
|
|
8488
5991
|
response: type()
|
|
@@ -8495,7 +5998,7 @@ const slidesWork = defineEndpoint({
|
|
|
8495
5998
|
* 与旧版一致:`getSuggestWords` GET + a_bogus 签名,
|
|
8496
5999
|
* Referer 指向 `https://www.douyin.com/search/{query}`。
|
|
8497
6000
|
*/
|
|
8498
|
-
const suggestWords =
|
|
6001
|
+
const suggestWords = defineDouyinEndpoint({
|
|
8499
6002
|
name: "douyin.suggestWords",
|
|
8500
6003
|
route: "/fetch_suggest_words",
|
|
8501
6004
|
doc: {
|
|
@@ -8511,7 +6014,7 @@ const suggestWords = defineEndpoint({
|
|
|
8511
6014
|
query: p.query
|
|
8512
6015
|
})
|
|
8513
6016
|
}),
|
|
8514
|
-
sign:
|
|
6017
|
+
sign: douyinBogus(184),
|
|
8515
6018
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8516
6019
|
retryFresh: true,
|
|
8517
6020
|
response: type()
|
|
@@ -8523,7 +6026,7 @@ const suggestWords = defineEndpoint({
|
|
|
8523
6026
|
*
|
|
8524
6027
|
* 行为与旧版一致:`getWorkDetail` GET + a_bogus 签名。
|
|
8525
6028
|
*/
|
|
8526
|
-
const textWork =
|
|
6029
|
+
const textWork = defineDouyinEndpoint({
|
|
8527
6030
|
name: "douyin.textWork",
|
|
8528
6031
|
route: "/fetch_text_work",
|
|
8529
6032
|
doc: {
|
|
@@ -8535,7 +6038,7 @@ const textWork = defineEndpoint({
|
|
|
8535
6038
|
method: "GET",
|
|
8536
6039
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8537
6040
|
}),
|
|
8538
|
-
sign:
|
|
6041
|
+
sign: douyinBogus(184),
|
|
8539
6042
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8540
6043
|
retryFresh: true,
|
|
8541
6044
|
response: type()
|
|
@@ -8548,7 +6051,7 @@ const textWork = defineEndpoint({
|
|
|
8548
6051
|
* 与旧版一致:`getUserFavoriteList` GET + a_bogus 签名,
|
|
8549
6052
|
* 游标是 `max_cursor`(`has_more === 1` 继续),Referer 指向用户主页。
|
|
8550
6053
|
*/
|
|
8551
|
-
const userFavoriteList =
|
|
6054
|
+
const userFavoriteList = defineDouyinEndpoint({
|
|
8552
6055
|
name: "douyin.userFavoriteList",
|
|
8553
6056
|
route: "/fetch_user_favorite_list",
|
|
8554
6057
|
doc: {
|
|
@@ -8568,7 +6071,7 @@ const userFavoriteList = defineEndpoint({
|
|
|
8568
6071
|
secUid: p.sec_uid
|
|
8569
6072
|
})
|
|
8570
6073
|
}),
|
|
8571
|
-
sign:
|
|
6074
|
+
sign: douyinBogus(184),
|
|
8572
6075
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8573
6076
|
retryFresh: true,
|
|
8574
6077
|
paginate: {
|
|
@@ -8578,14 +6081,11 @@ const userFavoriteList = defineEndpoint({
|
|
|
8578
6081
|
nextParams: (params, page) => ({
|
|
8579
6082
|
...params,
|
|
8580
6083
|
max_cursor: page.max_cursor?.toString() ?? "0"
|
|
8581
|
-
})
|
|
8582
|
-
|
|
8583
|
-
|
|
8584
|
-
const { lastPage, items } = decoded;
|
|
8585
|
-
return {
|
|
8586
|
-
...lastPage ?? {},
|
|
6084
|
+
}),
|
|
6085
|
+
merge: ({ lastPage, items }) => ({
|
|
6086
|
+
...lastPage,
|
|
8587
6087
|
aweme_list: items
|
|
8588
|
-
}
|
|
6088
|
+
})
|
|
8589
6089
|
},
|
|
8590
6090
|
response: type()
|
|
8591
6091
|
});
|
|
@@ -8597,7 +6097,7 @@ const userFavoriteList = defineEndpoint({
|
|
|
8597
6097
|
* 与旧版一致:`getUserProfile` GET + a_bogus 签名,
|
|
8598
6098
|
* Referer 指向 `https://www.douyin.com/user/{sec_uid}`(由 {@link withDouyinReferer} 注入)。
|
|
8599
6099
|
*/
|
|
8600
|
-
const userProfile$3 =
|
|
6100
|
+
const userProfile$3 = defineDouyinEndpoint({
|
|
8601
6101
|
name: "douyin.userProfile",
|
|
8602
6102
|
route: "/fetch_user_info",
|
|
8603
6103
|
doc: {
|
|
@@ -8613,7 +6113,7 @@ const userProfile$3 = defineEndpoint({
|
|
|
8613
6113
|
secUid: p.sec_uid
|
|
8614
6114
|
})
|
|
8615
6115
|
}),
|
|
8616
|
-
sign:
|
|
6116
|
+
sign: douyinBogus(184),
|
|
8617
6117
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8618
6118
|
retryFresh: true,
|
|
8619
6119
|
response: type()
|
|
@@ -8627,7 +6127,7 @@ const userProfile$3 = defineEndpoint({
|
|
|
8627
6127
|
* 签名,游标是 `max_cursor`。**`hasMore` 判的是 `has_more === true`(布尔)**,
|
|
8628
6128
|
* 与 userVideoList / userFavoriteList 的 `=== 1` 不同。
|
|
8629
6129
|
*/
|
|
8630
|
-
const userRecommendList =
|
|
6130
|
+
const userRecommendList = defineDouyinEndpoint({
|
|
8631
6131
|
name: "douyin.userRecommendList",
|
|
8632
6132
|
route: "/fetch_user_recommend_list",
|
|
8633
6133
|
doc: {
|
|
@@ -8647,7 +6147,7 @@ const userRecommendList = defineEndpoint({
|
|
|
8647
6147
|
secUid: p.sec_uid
|
|
8648
6148
|
})
|
|
8649
6149
|
}),
|
|
8650
|
-
sign:
|
|
6150
|
+
sign: douyinBogus(184),
|
|
8651
6151
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8652
6152
|
retryFresh: true,
|
|
8653
6153
|
paginate: {
|
|
@@ -8657,14 +6157,11 @@ const userRecommendList = defineEndpoint({
|
|
|
8657
6157
|
nextParams: (params, page) => ({
|
|
8658
6158
|
...params,
|
|
8659
6159
|
max_cursor: page.max_cursor?.toString() ?? "0"
|
|
8660
|
-
})
|
|
8661
|
-
|
|
8662
|
-
|
|
8663
|
-
const { lastPage, items } = decoded;
|
|
8664
|
-
return {
|
|
8665
|
-
...lastPage ?? {},
|
|
6160
|
+
}),
|
|
6161
|
+
merge: ({ lastPage, items }) => ({
|
|
6162
|
+
...lastPage,
|
|
8666
6163
|
aweme_list: items
|
|
8667
|
-
}
|
|
6164
|
+
})
|
|
8668
6165
|
},
|
|
8669
6166
|
response: type()
|
|
8670
6167
|
});
|
|
@@ -8677,7 +6174,7 @@ const userRecommendList = defineEndpoint({
|
|
|
8677
6174
|
* 游标是 `max_cursor`(字符串,`has_more === 1` 继续),Referer 指向用户主页。
|
|
8678
6175
|
* 最终形状 `{ ...最后一页, aweme_list }`。
|
|
8679
6176
|
*/
|
|
8680
|
-
const userVideoList =
|
|
6177
|
+
const userVideoList = defineDouyinEndpoint({
|
|
8681
6178
|
name: "douyin.userVideoList",
|
|
8682
6179
|
route: "/fetch_user_post_videos",
|
|
8683
6180
|
doc: {
|
|
@@ -8697,7 +6194,7 @@ const userVideoList = defineEndpoint({
|
|
|
8697
6194
|
secUid: p.sec_uid
|
|
8698
6195
|
})
|
|
8699
6196
|
}),
|
|
8700
|
-
sign:
|
|
6197
|
+
sign: douyinBogus(184),
|
|
8701
6198
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8702
6199
|
retryFresh: true,
|
|
8703
6200
|
paginate: {
|
|
@@ -8707,14 +6204,11 @@ const userVideoList = defineEndpoint({
|
|
|
8707
6204
|
nextParams: (params, page) => ({
|
|
8708
6205
|
...params,
|
|
8709
6206
|
max_cursor: page.max_cursor?.toString() ?? "0"
|
|
8710
|
-
})
|
|
8711
|
-
|
|
8712
|
-
|
|
8713
|
-
const { lastPage, items } = decoded;
|
|
8714
|
-
return {
|
|
8715
|
-
...lastPage ?? {},
|
|
6207
|
+
}),
|
|
6208
|
+
merge: ({ lastPage, items }) => ({
|
|
6209
|
+
...lastPage,
|
|
8716
6210
|
aweme_list: items
|
|
8717
|
-
}
|
|
6211
|
+
})
|
|
8718
6212
|
},
|
|
8719
6213
|
response: type()
|
|
8720
6214
|
});
|
|
@@ -8731,7 +6225,7 @@ const userVideoList = defineEndpoint({
|
|
|
8731
6225
|
*/
|
|
8732
6226
|
const douyinRegistry = {
|
|
8733
6227
|
parseWork,
|
|
8734
|
-
videoWork:
|
|
6228
|
+
videoWork: defineDouyinEndpoint({
|
|
8735
6229
|
name: "douyin.videoWork",
|
|
8736
6230
|
route: "/fetch_video_work",
|
|
8737
6231
|
doc: {
|
|
@@ -8743,7 +6237,7 @@ const douyinRegistry = {
|
|
|
8743
6237
|
method: "GET",
|
|
8744
6238
|
url: douyinApiUrls$1.getWorkDetail(p)
|
|
8745
6239
|
}),
|
|
8746
|
-
sign:
|
|
6240
|
+
sign: douyinBogus(184),
|
|
8747
6241
|
retryOn: ["ANTIBOT_PAGE"],
|
|
8748
6242
|
retryFresh: true,
|
|
8749
6243
|
response: type(),
|
|
@@ -10979,6 +8473,18 @@ const kuaishouDidState = createKuaishouDidState();
|
|
|
10979
8473
|
*/
|
|
10980
8474
|
const kuaishouDidPrepare = async (ctx) => ({ cookie: buildKuaishouDidCookie(kuaishouDidState.getDid(), ctx.cookie) });
|
|
10981
8475
|
//#endregion
|
|
8476
|
+
//#region src/platforms/kuaishou/endpoints/define.ts
|
|
8477
|
+
/**
|
|
8478
|
+
* 快手端点声明。
|
|
8479
|
+
*
|
|
8480
|
+
* 就是 {@link defineEndpoint} 绑定了快手签名器名联合 {@link KuaishouSignerName}:
|
|
8481
|
+
* `sign` 的字符串分支被收窄成 `'hxfalcon'`,写错名字编译期即报错,不必等运行时
|
|
8482
|
+
* 查表失败。运行时仍是恒等函数,其余行为与通用 `defineEndpoint` 完全一致。
|
|
8483
|
+
* @param def - 端点声明(`sign` 的名字受快手签名器表约束)
|
|
8484
|
+
* @returns 原样返回 `def`,带上推导好的具体类型
|
|
8485
|
+
*/
|
|
8486
|
+
const defineKuaishouEndpoint = (def) => defineEndpoint(def);
|
|
8487
|
+
//#endregion
|
|
10982
8488
|
//#region src/platforms/kuaishou/endpoints/comments.ts
|
|
10983
8489
|
/**
|
|
10984
8490
|
* 获取作品评论(H5 `photo/comment/list`,POST + 声明式翻页)。
|
|
@@ -10998,7 +8504,7 @@ const kuaishouDidPrepare = async (ctx) => ({ cookie: buildKuaishouDidCookie(kuai
|
|
|
10998
8504
|
* 翻页:调用方传 `number` 指定目标条数,`pcursor` 由 `paginate` 声明管理,
|
|
10999
8505
|
* 不暴露为自由参数。
|
|
11000
8506
|
*/
|
|
11001
|
-
const comments =
|
|
8507
|
+
const comments = defineKuaishouEndpoint({
|
|
11002
8508
|
name: "kuaishou.comments",
|
|
11003
8509
|
route: "/fetch_work_comments",
|
|
11004
8510
|
doc: {
|
|
@@ -11009,7 +8515,7 @@ const comments = defineEndpoint({
|
|
|
11009
8515
|
photoId: zod.string().min(1, { error: "photoId 不能为空" }).describe("作品 ID"),
|
|
11010
8516
|
number: zod.coerce.number().int().min(1).max(500).optional().describe("目标条数,默认一页")
|
|
11011
8517
|
}),
|
|
11012
|
-
sign:
|
|
8518
|
+
sign: [hxfalcon()],
|
|
11013
8519
|
prepare: kuaishouDidPrepare,
|
|
11014
8520
|
build: (p) => {
|
|
11015
8521
|
const req = kuaishouApiUrls$1.comments(p);
|
|
@@ -11031,14 +8537,11 @@ const comments = defineEndpoint({
|
|
|
11031
8537
|
nextParams: (params, page) => ({
|
|
11032
8538
|
...params,
|
|
11033
8539
|
pcursor: page.pcursor ?? ""
|
|
11034
|
-
})
|
|
11035
|
-
|
|
11036
|
-
|
|
11037
|
-
const { lastPage, items } = decoded;
|
|
11038
|
-
return {
|
|
11039
|
-
...lastPage ?? {},
|
|
8540
|
+
}),
|
|
8541
|
+
merge: ({ lastPage, items }) => ({
|
|
8542
|
+
...lastPage,
|
|
11040
8543
|
rootComments: items
|
|
11041
|
-
}
|
|
8544
|
+
})
|
|
11042
8545
|
},
|
|
11043
8546
|
response: type()
|
|
11044
8547
|
});
|
|
@@ -11114,7 +8617,7 @@ const resolveScanRange = (p) => {
|
|
|
11114
8617
|
* 同许可):https://github.com/OduckO —— `src/platform/kuaishou/danmaku.ts` 与
|
|
11115
8618
|
* `TODO.md:140-164`。
|
|
11116
8619
|
*/
|
|
11117
|
-
const danmakuList =
|
|
8620
|
+
const danmakuList = defineKuaishouEndpoint({
|
|
11118
8621
|
name: "kuaishou.danmakuList",
|
|
11119
8622
|
route: "/fetch_danmaku_list",
|
|
11120
8623
|
doc: {
|
|
@@ -11176,39 +8679,38 @@ const danmakuList = defineEndpoint({
|
|
|
11176
8679
|
retryOn: ["PLATFORM_UNAVAILABLE"],
|
|
11177
8680
|
partial: "tolerate",
|
|
11178
8681
|
/**
|
|
11179
|
-
* 合并多个窗口:去重 → 按时间升序 →
|
|
8682
|
+
* 合并多个窗口:去重 → 按时间升序 → 写回整体扫描范围。
|
|
8683
|
+
*
|
|
8684
|
+
* 这不是「归一化」,只是把分段拿到的条目放回原位(与 `comments` 的跨页累积同语义),
|
|
8685
|
+
* 所以返回类型仍描述真实 GraphQL 形状;只有 `positionFromInclude` / `positionToExclude`
|
|
8686
|
+
* 被改写成本次**整体**扫描范围(单窗口时就等于那一窗的值)。
|
|
11180
8687
|
*
|
|
11181
|
-
*
|
|
11182
|
-
*
|
|
11183
|
-
* `
|
|
11184
|
-
* 单窗口调用时它们就等于那一窗的值。
|
|
11185
|
-
* @param decoded - 每个窗口 decode 之后的值(失败窗口是 `undefined`)
|
|
8688
|
+
* 走 typed 的 aggregate(而非 normalize):`parts` 已收窄成 `Array<…Response | undefined>`、
|
|
8689
|
+
* `head` 恒有值,去重 / 排序 / `...head` 全程无断言。
|
|
8690
|
+
* @param value - 各窗口累积值(`parts` 含失败窗口的 undefined,`head` 恒有值)
|
|
11186
8691
|
* @param params - 校验后的参数
|
|
11187
8692
|
* @returns 合并后的响应
|
|
11188
8693
|
*/
|
|
11189
|
-
|
|
11190
|
-
const windows = decoded;
|
|
8694
|
+
aggregate: ({ parts, head }, params) => {
|
|
11191
8695
|
const { from, to } = resolveScanRange(params);
|
|
11192
8696
|
const merged = [];
|
|
11193
8697
|
const seen = /* @__PURE__ */ new Set();
|
|
11194
|
-
for (const win of
|
|
8698
|
+
for (const win of parts) {
|
|
11195
8699
|
const node = win?.data?.visionDanmaku;
|
|
11196
8700
|
if (!node) continue;
|
|
11197
|
-
for (const row of node.danmakus
|
|
11198
|
-
|
|
11199
|
-
|
|
11200
|
-
seen.add(key);
|
|
8701
|
+
for (const row of node.danmakus) {
|
|
8702
|
+
if (seen.has(row.id)) continue;
|
|
8703
|
+
seen.add(row.id);
|
|
11201
8704
|
merged.push(row);
|
|
11202
8705
|
}
|
|
11203
8706
|
}
|
|
11204
|
-
merged.sort((a, b) =>
|
|
11205
|
-
const firstBody = windows.find((win) => Boolean(win?.data?.visionDanmaku));
|
|
8707
|
+
merged.sort((a, b) => a.position - b.position);
|
|
11206
8708
|
return {
|
|
11207
|
-
...
|
|
8709
|
+
...head,
|
|
11208
8710
|
data: {
|
|
11209
|
-
...
|
|
8711
|
+
...head.data,
|
|
11210
8712
|
visionDanmaku: {
|
|
11211
|
-
...
|
|
8713
|
+
...head.data.visionDanmaku,
|
|
11212
8714
|
positionFromInclude: from,
|
|
11213
8715
|
positionToExclude: to,
|
|
11214
8716
|
danmakus: merged
|
|
@@ -11230,7 +8732,7 @@ const danmakuList = defineEndpoint({
|
|
|
11230
8732
|
* 也正因为它零凭证,它是「无 cookie 能不能取到数」的**基线探针** ——
|
|
11231
8733
|
* 这条通而 videoWork 不通,问题就在签名或 did,不在网络环境。
|
|
11232
8734
|
*/
|
|
11233
|
-
const emojiList$2 =
|
|
8735
|
+
const emojiList$2 = defineKuaishouEndpoint({
|
|
11234
8736
|
name: "kuaishou.emojiList",
|
|
11235
8737
|
route: "/fetch_emoji_list",
|
|
11236
8738
|
doc: {
|
|
@@ -11261,7 +8763,7 @@ const emojiList$2 = defineEndpoint({
|
|
|
11261
8763
|
* 返回结构直接包含 `liveStream` / `author` / `gameInfo` / `noticeList` 等。
|
|
11262
8764
|
* 响应判定用快手 judge(`result=1` 才进 data)。
|
|
11263
8765
|
*/
|
|
11264
|
-
const liveRoomInfo =
|
|
8766
|
+
const liveRoomInfo = defineKuaishouEndpoint({
|
|
11265
8767
|
name: "kuaishou.liveRoomInfo",
|
|
11266
8768
|
route: "/fetch_live_room_info",
|
|
11267
8769
|
doc: {
|
|
@@ -11269,7 +8771,7 @@ const liveRoomInfo = defineEndpoint({
|
|
|
11269
8771
|
description: "取直播间聚合信息(直播流、主播、公告等);只看直播间时比 `userProfile` 轻。"
|
|
11270
8772
|
},
|
|
11271
8773
|
params: zod.object({ principalId: zod.string().min(1, { error: "principalId 不能为空" }).describe("用户 ID") }),
|
|
11272
|
-
sign:
|
|
8774
|
+
sign: [hxfalcon()],
|
|
11273
8775
|
build: (p) => {
|
|
11274
8776
|
return {
|
|
11275
8777
|
method: "POST",
|
|
@@ -11511,7 +9013,7 @@ const mapLiveDetailToUserProfileLiveInfo = (detailData, author) => {
|
|
|
11511
9013
|
* 到空值 —— 这正是「部分失败」语义:build 返回 12 个请求并发发出,
|
|
11512
9014
|
* 失败分片在 normalize 里留空,**全部分片都失败时仍返回失败信封**。
|
|
11513
9015
|
*/
|
|
11514
|
-
const userProfile$2 =
|
|
9016
|
+
const userProfile$2 = defineKuaishouEndpoint({
|
|
11515
9017
|
name: "kuaishou.userProfile",
|
|
11516
9018
|
route: "/fetch_user_profile",
|
|
11517
9019
|
doc: {
|
|
@@ -11519,7 +9021,7 @@ const userProfile$2 = defineEndpoint({
|
|
|
11519
9021
|
description: "取用户主页聚合信息(资料、作品列表、直播间等)。部分数据取不到时留空,不整体失败。"
|
|
11520
9022
|
},
|
|
11521
9023
|
params: zod.object({ principalId: zod.string().min(1, { error: "principalId 不能为空" }).describe("用户 ID") }),
|
|
11522
|
-
sign:
|
|
9024
|
+
sign: [hxfalcon()],
|
|
11523
9025
|
build: (p) => {
|
|
11524
9026
|
const principalId = p.principalId;
|
|
11525
9027
|
const refererPath = `profile/${encodeURIComponent(principalId)}`;
|
|
@@ -11611,7 +9113,7 @@ const userProfile$2 = defineEndpoint({
|
|
|
11611
9113
|
* `profilePublic` 的查询参数(`userWorkList` 是 `profilePublic` 的
|
|
11612
9114
|
* 领域化封装)。
|
|
11613
9115
|
*/
|
|
11614
|
-
const userWorkList =
|
|
9116
|
+
const userWorkList = defineKuaishouEndpoint({
|
|
11615
9117
|
name: "kuaishou.userWorkList",
|
|
11616
9118
|
route: "/fetch_user_work_list",
|
|
11617
9119
|
doc: {
|
|
@@ -11622,7 +9124,7 @@ const userWorkList = defineEndpoint({
|
|
|
11622
9124
|
principalId: zod.string().min(1, { error: "principalId 不能为空" }).describe("用户 ID"),
|
|
11623
9125
|
number: zod.coerce.number().int().min(1).max(500).optional().describe("目标条数,默认 12")
|
|
11624
9126
|
}),
|
|
11625
|
-
sign:
|
|
9127
|
+
sign: [hxfalcon()],
|
|
11626
9128
|
build: (p) => {
|
|
11627
9129
|
return {
|
|
11628
9130
|
method: "POST",
|
|
@@ -11643,19 +9145,17 @@ const userWorkList = defineEndpoint({
|
|
|
11643
9145
|
nextParams: (params, page) => ({
|
|
11644
9146
|
...params,
|
|
11645
9147
|
pcursor: page.data?.pcursor ?? ""
|
|
11646
|
-
})
|
|
11647
|
-
|
|
11648
|
-
|
|
11649
|
-
|
|
11650
|
-
|
|
11651
|
-
|
|
11652
|
-
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
|
|
11656
|
-
|
|
11657
|
-
result: page?.data?.result ?? 1
|
|
11658
|
-
};
|
|
9148
|
+
}),
|
|
9149
|
+
merge: ({ lastPage, items }, params) => {
|
|
9150
|
+
const pcursor = lastPage.data?.pcursor;
|
|
9151
|
+
return {
|
|
9152
|
+
principalId: params.principalId,
|
|
9153
|
+
list: items,
|
|
9154
|
+
pcursor: typeof pcursor === "string" ? pcursor : "",
|
|
9155
|
+
hasMore: typeof pcursor === "string" && pcursor.length > 0,
|
|
9156
|
+
result: lastPage.data?.result ?? 1
|
|
9157
|
+
};
|
|
9158
|
+
}
|
|
11659
9159
|
},
|
|
11660
9160
|
response: type()
|
|
11661
9161
|
});
|
|
@@ -11676,7 +9176,7 @@ const userWorkList = defineEndpoint({
|
|
|
11676
9176
|
* 第 8 条 `/fetch_danmaku_list` 是弹幕。它**完全免鉴权**(不签名、不要 cookie)。
|
|
11677
9177
|
*/
|
|
11678
9178
|
const kuaishouRegistry = {
|
|
11679
|
-
videoWork:
|
|
9179
|
+
videoWork: defineKuaishouEndpoint({
|
|
11680
9180
|
name: "kuaishou.videoWork",
|
|
11681
9181
|
route: "/fetch_one_work",
|
|
11682
9182
|
doc: {
|
|
@@ -11696,7 +9196,7 @@ const kuaishouRegistry = {
|
|
|
11696
9196
|
},
|
|
11697
9197
|
response: type()
|
|
11698
9198
|
}),
|
|
11699
|
-
videoWorkFull:
|
|
9199
|
+
videoWorkFull: defineKuaishouEndpoint({
|
|
11700
9200
|
name: "kuaishou.videoWorkFull",
|
|
11701
9201
|
route: "/fetch_one_work_full",
|
|
11702
9202
|
doc: {
|
|
@@ -11704,7 +9204,7 @@ const kuaishouRegistry = {
|
|
|
11704
9204
|
description: "日常取作品信息请用 `videoWork`;这条是唯一可能返回图集预渲染地址与同类推荐的通道。"
|
|
11705
9205
|
},
|
|
11706
9206
|
params: zod.object({ photoId: zod.string().min(1, { error: "photoId 不能为空" }).describe("作品 ID") }),
|
|
11707
|
-
sign:
|
|
9207
|
+
sign: [hxfalcon()],
|
|
11708
9208
|
prepare: kuaishouDidPrepare,
|
|
11709
9209
|
build: (p) => {
|
|
11710
9210
|
const req = kuaishouApiUrls$1.videoWorkFull(p);
|
|
@@ -12200,6 +9700,18 @@ const xiaohongshuApiUrls$1 = {
|
|
|
12200
9700
|
searchNotes: searchNotes$1
|
|
12201
9701
|
};
|
|
12202
9702
|
//#endregion
|
|
9703
|
+
//#region src/platforms/xiaohongshu/endpoints/define.ts
|
|
9704
|
+
/**
|
|
9705
|
+
* 小红书端点声明。
|
|
9706
|
+
*
|
|
9707
|
+
* 就是 {@link defineEndpoint} 绑定了小红书签名器名联合 {@link XiaohongshuSignerName}:
|
|
9708
|
+
* `sign` 的字符串分支被收窄成小红书那几个 `xhs-*` 名字,写错名字编译期即报错,不必
|
|
9709
|
+
* 等运行时查表失败。运行时仍是恒等函数,其余行为与通用 `defineEndpoint` 完全一致。
|
|
9710
|
+
* @param def - 端点声明(`sign` 的名字受小红书签名器表约束)
|
|
9711
|
+
* @returns 原样返回 `def`,带上推导好的具体类型
|
|
9712
|
+
*/
|
|
9713
|
+
const defineXiaohongshuEndpoint = (def) => defineEndpoint(def);
|
|
9714
|
+
//#endregion
|
|
12203
9715
|
//#region src/platforms/xiaohongshu/endpoints/emojiList.ts
|
|
12204
9716
|
/**
|
|
12205
9717
|
* 表情列表(GET,无参数)。
|
|
@@ -12207,7 +9719,7 @@ const xiaohongshuApiUrls$1 = {
|
|
|
12207
9719
|
* 没有业务参数 —— 用空对象 schema(`zod.object({})`),
|
|
12208
9720
|
* fetcher 方法可以不传 options。
|
|
12209
9721
|
*/
|
|
12210
|
-
const emojiList =
|
|
9722
|
+
const emojiList = defineXiaohongshuEndpoint({
|
|
12211
9723
|
name: "xiaohongshu.emojiList",
|
|
12212
9724
|
route: "/fetch_emoji_list",
|
|
12213
9725
|
doc: {
|
|
@@ -12223,7 +9735,7 @@ const emojiList = defineEndpoint({
|
|
|
12223
9735
|
signPath: apiPath
|
|
12224
9736
|
};
|
|
12225
9737
|
},
|
|
12226
|
-
sign: "
|
|
9738
|
+
sign: [xs("get", "xys")],
|
|
12227
9739
|
response: type()
|
|
12228
9740
|
});
|
|
12229
9741
|
//#endregion
|
|
@@ -12238,7 +9750,7 @@ const emojiList = defineEndpoint({
|
|
|
12238
9750
|
* 进 trace)。已有 a1 直接跳过 —— guest cookie 流程有真实网络开销,
|
|
12239
9751
|
* 不能每次调用都重跑。
|
|
12240
9752
|
*/
|
|
12241
|
-
const homeFeed =
|
|
9753
|
+
const homeFeed = defineXiaohongshuEndpoint({
|
|
12242
9754
|
name: "xiaohongshu.homeFeed",
|
|
12243
9755
|
route: "/fetch_home_feed",
|
|
12244
9756
|
doc: {
|
|
@@ -12266,7 +9778,7 @@ const homeFeed = defineEndpoint({
|
|
|
12266
9778
|
signPath: apiPath
|
|
12267
9779
|
};
|
|
12268
9780
|
},
|
|
12269
|
-
sign: "
|
|
9781
|
+
sign: [xs("post", "xys"), rap()],
|
|
12270
9782
|
response: type()
|
|
12271
9783
|
});
|
|
12272
9784
|
//#endregion
|
|
@@ -12281,7 +9793,7 @@ const homeFeed = defineEndpoint({
|
|
|
12281
9793
|
* `data.has_more` 是 boolean,与抖音的 `has_more === 1` 不同 ——
|
|
12282
9794
|
* 差异收敛在 `paginate` 声明里,管线不感知。
|
|
12283
9795
|
*/
|
|
12284
|
-
const noteComments =
|
|
9796
|
+
const noteComments = defineXiaohongshuEndpoint({
|
|
12285
9797
|
name: "xiaohongshu.noteComments",
|
|
12286
9798
|
route: "/fetch_note_comments",
|
|
12287
9799
|
doc: {
|
|
@@ -12302,7 +9814,7 @@ const noteComments = defineEndpoint({
|
|
|
12302
9814
|
extra: { signParams }
|
|
12303
9815
|
};
|
|
12304
9816
|
},
|
|
12305
|
-
sign: "
|
|
9817
|
+
sign: [xs("get", "xys"), traceId()],
|
|
12306
9818
|
paginate: {
|
|
12307
9819
|
maxPageSize: 50,
|
|
12308
9820
|
items: (page) => page.data?.comments ?? [],
|
|
@@ -12310,18 +9822,14 @@ const noteComments = defineEndpoint({
|
|
|
12310
9822
|
nextParams: (params, page) => ({
|
|
12311
9823
|
...params,
|
|
12312
9824
|
cursor: page.data?.cursor ?? ""
|
|
12313
|
-
})
|
|
12314
|
-
|
|
12315
|
-
|
|
12316
|
-
const { lastPage, items } = decoded;
|
|
12317
|
-
const page = lastPage;
|
|
12318
|
-
return {
|
|
12319
|
-
...page ?? {},
|
|
9825
|
+
}),
|
|
9826
|
+
merge: ({ lastPage, items }) => ({
|
|
9827
|
+
...lastPage,
|
|
12320
9828
|
data: {
|
|
12321
|
-
...
|
|
9829
|
+
...lastPage.data,
|
|
12322
9830
|
comments: items
|
|
12323
9831
|
}
|
|
12324
|
-
}
|
|
9832
|
+
})
|
|
12325
9833
|
},
|
|
12326
9834
|
response: type()
|
|
12327
9835
|
});
|
|
@@ -12332,7 +9840,7 @@ const noteComments = defineEndpoint({
|
|
|
12332
9840
|
*
|
|
12333
9841
|
* `note_id` 与 `xsec_token` 都要求 `min(1)`:空字符串会发出一个必败的请求。
|
|
12334
9842
|
*/
|
|
12335
|
-
const noteDetail =
|
|
9843
|
+
const noteDetail = defineXiaohongshuEndpoint({
|
|
12336
9844
|
name: "xiaohongshu.noteDetail",
|
|
12337
9845
|
route: "/fetch_one_note",
|
|
12338
9846
|
doc: {
|
|
@@ -12352,7 +9860,7 @@ const noteDetail = defineEndpoint({
|
|
|
12352
9860
|
signPath: apiPath
|
|
12353
9861
|
};
|
|
12354
9862
|
},
|
|
12355
|
-
sign: "
|
|
9863
|
+
sign: [xs("post", "xys"), rap()],
|
|
12356
9864
|
response: type()
|
|
12357
9865
|
});
|
|
12358
9866
|
//#endregion
|
|
@@ -12363,7 +9871,7 @@ const noteDetail = defineEndpoint({
|
|
|
12363
9871
|
* `search_id` 是每次搜索的随机 id:在 `build` 里显式生成并传给
|
|
12364
9872
|
* `api.searchNotes(data, searchId)`(api 保持纯函数,随机源归 `sign/`)。
|
|
12365
9873
|
*/
|
|
12366
|
-
const searchNotes =
|
|
9874
|
+
const searchNotes = defineXiaohongshuEndpoint({
|
|
12367
9875
|
name: "xiaohongshu.searchNotes",
|
|
12368
9876
|
route: "/fetch_search_notes",
|
|
12369
9877
|
doc: {
|
|
@@ -12384,7 +9892,7 @@ const searchNotes = defineEndpoint({
|
|
|
12384
9892
|
signPath: apiPath
|
|
12385
9893
|
};
|
|
12386
9894
|
},
|
|
12387
|
-
sign: "
|
|
9895
|
+
sign: [xs("post", "xys"), rap()],
|
|
12388
9896
|
response: type()
|
|
12389
9897
|
});
|
|
12390
9898
|
//#endregion
|
|
@@ -12396,7 +9904,7 @@ const searchNotes = defineEndpoint({
|
|
|
12396
9904
|
* XYS_ 签名会被平台以 HTTP 406 拒绝,因此用 `'xhs-get-xyw-trace'`
|
|
12397
9905
|
* (XYW_ GET 签名 + traceid),而不是给所有 GET 都换。
|
|
12398
9906
|
*/
|
|
12399
|
-
const userNoteList =
|
|
9907
|
+
const userNoteList = defineXiaohongshuEndpoint({
|
|
12400
9908
|
name: "xiaohongshu.userNoteList",
|
|
12401
9909
|
route: "/fetch_user_notes",
|
|
12402
9910
|
doc: {
|
|
@@ -12418,7 +9926,7 @@ const userNoteList = defineEndpoint({
|
|
|
12418
9926
|
extra: { signParams }
|
|
12419
9927
|
};
|
|
12420
9928
|
},
|
|
12421
|
-
sign: "
|
|
9929
|
+
sign: [xs("get", "xyw"), traceId()],
|
|
12422
9930
|
response: type()
|
|
12423
9931
|
});
|
|
12424
9932
|
//#endregion
|
|
@@ -12435,7 +9943,7 @@ const xiaohongshuRegistry = {
|
|
|
12435
9943
|
homeFeed,
|
|
12436
9944
|
noteDetail,
|
|
12437
9945
|
noteComments,
|
|
12438
|
-
userProfile:
|
|
9946
|
+
userProfile: defineXiaohongshuEndpoint({
|
|
12439
9947
|
name: "xiaohongshu.userProfile",
|
|
12440
9948
|
route: "/fetch_user_profile",
|
|
12441
9949
|
doc: {
|
|
@@ -12452,7 +9960,7 @@ const xiaohongshuRegistry = {
|
|
|
12452
9960
|
responseType: "text"
|
|
12453
9961
|
};
|
|
12454
9962
|
},
|
|
12455
|
-
sign: "
|
|
9963
|
+
sign: [xs("get", "xys")],
|
|
12456
9964
|
decode: (raw) => {
|
|
12457
9965
|
if (typeof raw !== "string") return raw;
|
|
12458
9966
|
const match = raw.match(/<script>window\.__INITIAL_STATE__=(.+)<\/script>/m);
|
|
@@ -54014,7 +51522,7 @@ const buildOpenApiSpec = (options = {}) => {
|
|
|
54014
51522
|
openapi: "3.1.0",
|
|
54015
51523
|
info: {
|
|
54016
51524
|
title: "amagi HTTP API",
|
|
54017
|
-
version: options.version ?? "7.0.0-beta.
|
|
51525
|
+
version: options.version ?? "7.0.0-beta.6",
|
|
54018
51526
|
description: [
|
|
54019
51527
|
"由端点注册表派生的规范 —— 手写的那份已经漂移,这份不会(`gen-openapi --check` 进 CI)。",
|
|
54020
51528
|
"",
|
|
@@ -54070,38 +51578,12 @@ const mountOpenApiSpec = (app) => {
|
|
|
54070
51578
|
};
|
|
54071
51579
|
//#endregion
|
|
54072
51580
|
//#region src/client/request/profile.ts
|
|
54073
|
-
/**
|
|
54074
|
-
* 抖音:重抽 URL 里已有的 `msToken`。
|
|
54075
|
-
*
|
|
54076
|
-
* Argus 按整组 token 判定,只换 `a_bogus` 不够。`msToken` 是调用方的 URL
|
|
54077
|
-
* 构造器写进去的(这一层**不补**参数),所以能换的只有「URL 里本来就有」的
|
|
54078
|
-
* 那一种情况 —— 长度保持原样(作品详情 184、其余 116),因为长度本身就是
|
|
54079
|
-
* 参数的一部分。
|
|
54080
|
-
* @param spec - 上一次用的请求描述
|
|
54081
|
-
* @returns 换过 msToken 的请求描述;没有可换的就原样返回
|
|
54082
|
-
*/
|
|
54083
|
-
const refreshDouyinMsToken = (spec) => {
|
|
54084
|
-
let url;
|
|
54085
|
-
try {
|
|
54086
|
-
url = new URL(spec.url);
|
|
54087
|
-
} catch {
|
|
54088
|
-
return spec;
|
|
54089
|
-
}
|
|
54090
|
-
const current = url.searchParams.get("msToken");
|
|
54091
|
-
if (current === null || current === "") return spec;
|
|
54092
|
-
url.searchParams.set("msToken", douyinSign$1.Mstoken(current.length));
|
|
54093
|
-
return {
|
|
54094
|
-
...spec,
|
|
54095
|
-
url: url.toString()
|
|
54096
|
-
};
|
|
54097
|
-
};
|
|
54098
51581
|
/** 平台档案表。四个平台必须齐全 —— 少一项不会编译报错,会静默不签名 */
|
|
54099
51582
|
const PROFILES = {
|
|
54100
51583
|
douyin: {
|
|
54101
51584
|
defaultSign: "a-bogus",
|
|
54102
51585
|
retryOn: ["ANTIBOT_PAGE"],
|
|
54103
|
-
retryFresh: true
|
|
54104
|
-
refresh: refreshDouyinMsToken
|
|
51586
|
+
retryFresh: true
|
|
54105
51587
|
},
|
|
54106
51588
|
bilibili: {
|
|
54107
51589
|
defaultSign: false,
|
|
@@ -54235,10 +51717,15 @@ const buildRequestSpec = (config, url, amagi) => ({
|
|
|
54235
51717
|
/**
|
|
54236
51718
|
* 现场合成一个一次性的端点声明。
|
|
54237
51719
|
*
|
|
54238
|
-
* `build`
|
|
54239
|
-
*
|
|
54240
|
-
*
|
|
54241
|
-
*
|
|
51720
|
+
* `build` 恒返回同一个 `spec`,**这不等于「重试会原样重放」**:`execute` 在 `retryFresh`
|
|
51721
|
+
* 重试时会把 `build` 再调一次、**并紧接着重新签名**(见 `runtime/execute.ts` 的
|
|
51722
|
+
* `rebuildAt`),换参数是后者干的 —— 抖音的 `msToken` / `a_bogus` 都出自签名 step,
|
|
51723
|
+
* 每次签名现生成,所以重试拿到的是一整套新 token。
|
|
51724
|
+
*
|
|
51725
|
+
* 这里曾经有一个「返回当前值的同时把下一个算好」的双缓冲写法,配合平台档案的
|
|
51726
|
+
* `refresh` 钩子重抽 URL 里的 `msToken`。msToken 下沉到签名 step 之后那个钩子
|
|
51727
|
+
* 恒为无操作(URL 里本来就没有它可抽),连同这套机制一起删了 —— 裸请求的 `spec`
|
|
51728
|
+
* 是调用方给的、本身没有「下一次该长什么样」这种状态。
|
|
54242
51729
|
* @param platform - 平台
|
|
54243
51730
|
* @param spec - 本次调用的请求描述
|
|
54244
51731
|
* @param method - HTTP 方法(小红书按它推默认签名器)
|
|
@@ -54247,16 +51734,11 @@ const buildRequestSpec = (config, url, amagi) => ({
|
|
|
54247
51734
|
*/
|
|
54248
51735
|
const makeRequestDef = (platform, spec, method, amagi) => {
|
|
54249
51736
|
const profile = requestProfileOf(platform);
|
|
54250
|
-
let current = spec;
|
|
54251
51737
|
return defineEndpoint({
|
|
54252
51738
|
name: `${platform}.request`,
|
|
54253
51739
|
route: REQUEST_ROUTE,
|
|
54254
51740
|
params: zod.object({}),
|
|
54255
|
-
build: () =>
|
|
54256
|
-
const out = current;
|
|
54257
|
-
current = profile.refresh ? profile.refresh(spec) : spec;
|
|
54258
|
-
return out;
|
|
54259
|
-
},
|
|
51741
|
+
build: () => spec,
|
|
54260
51742
|
sign: amagi.sign ?? resolveDefaultSign(profile, method),
|
|
54261
51743
|
...profile.retryOn === void 0 ? {} : { retryOn: profile.retryOn },
|
|
54262
51744
|
...profile.retryFresh === void 0 ? {} : { retryFresh: profile.retryFresh }
|
|
@@ -54597,17 +52079,6 @@ const createStaticFetcher = (platform, registry) => {
|
|
|
54597
52079
|
* ```
|
|
54598
52080
|
*/
|
|
54599
52081
|
const bilibiliFetcher = createStaticFetcher("bilibili", bilibiliRegistry);
|
|
54600
|
-
/**
|
|
54601
|
-
* 创建绑定了 Cookie 和请求配置的B站 Fetcher
|
|
54602
|
-
* @param cookie - B站 Cookie
|
|
54603
|
-
* @param requestConfig - 请求配置 (可选)
|
|
54604
|
-
* @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
|
|
54605
|
-
* @example
|
|
54606
|
-
* ```typescript
|
|
54607
|
-
* const fetcher = createBoundBilibiliFetcher('your_cookie')
|
|
54608
|
-
* const result = await fetcher.fetchVideoInfo({ bvid: 'BV1xx411c7mD' })
|
|
54609
|
-
* ```
|
|
54610
|
-
*/
|
|
54611
52082
|
const createBoundBilibiliFetcher = (cookie, requestConfig) => createFetcherFromRegistry("bilibili", bilibiliRegistry, makeClientCtx("bilibili", cookie, requestConfig, "bound-bilibili"));
|
|
54612
52083
|
//#endregion
|
|
54613
52084
|
//#region src/model/fetchers/douyin/auth.ts
|
|
@@ -54855,17 +52326,6 @@ const createBoundDouyinFetcher = (cookie, requestConfig) => createFetcherFromReg
|
|
|
54855
52326
|
* ```
|
|
54856
52327
|
*/
|
|
54857
52328
|
const kuaishouFetcher = createStaticFetcher("kuaishou", kuaishouRegistry);
|
|
54858
|
-
/**
|
|
54859
|
-
* 创建绑定了 Cookie 和请求配置的快手 Fetcher
|
|
54860
|
-
* @param cookie - 快手 Cookie
|
|
54861
|
-
* @param requestConfig - 请求配置 (可选)
|
|
54862
|
-
* @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
|
|
54863
|
-
* @example
|
|
54864
|
-
* ```typescript
|
|
54865
|
-
* const fetcher = createBoundKuaishouFetcher('your_cookie')
|
|
54866
|
-
* const result = await fetcher.fetchVideoWork({ photoId: '3x123456789' })
|
|
54867
|
-
* ```
|
|
54868
|
-
*/
|
|
54869
52329
|
const createBoundKuaishouFetcher = (cookie, requestConfig) => createFetcherFromRegistry("kuaishou", kuaishouRegistry, makeClientCtx("kuaishou", cookie, requestConfig, "bound-kuaishou"));
|
|
54870
52330
|
//#endregion
|
|
54871
52331
|
//#region src/model/fetchers/xiaohongshu/index.ts
|
|
@@ -54888,17 +52348,6 @@ const createBoundKuaishouFetcher = (cookie, requestConfig) => createFetcherFromR
|
|
|
54888
52348
|
* ```
|
|
54889
52349
|
*/
|
|
54890
52350
|
const xiaohongshuFetcher = createStaticFetcher("xiaohongshu", xiaohongshuRegistry);
|
|
54891
|
-
/**
|
|
54892
|
-
* 创建绑定了 Cookie 和请求配置的小红书 Fetcher
|
|
54893
|
-
* @param cookie - 小红书 Cookie
|
|
54894
|
-
* @param requestConfig - 请求配置 (可选)
|
|
54895
|
-
* @returns 绑定了 Cookie 的 Fetcher 对象,调用时无需传递 cookie
|
|
54896
|
-
* @example
|
|
54897
|
-
* ```typescript
|
|
54898
|
-
* const fetcher = createBoundXiaohongshuFetcher('your_cookie')
|
|
54899
|
-
* const result = await fetcher.fetchNoteDetail({ note_id: 'n1', xsec_token: 'tk' })
|
|
54900
|
-
* ```
|
|
54901
|
-
*/
|
|
54902
52351
|
const createBoundXiaohongshuFetcher = (cookie, requestConfig) => createFetcherFromRegistry("xiaohongshu", xiaohongshuRegistry, makeClientCtx("xiaohongshu", cookie, requestConfig, "bound-xiaohongshu"));
|
|
54903
52352
|
//#endregion
|
|
54904
52353
|
//#region src/utils/errors.ts
|
|
@@ -56837,7 +54286,7 @@ let DynamicType = /* @__PURE__ */ function(DynamicType) {
|
|
|
56837
54286
|
* 构建后使用 __VERSION__,开发环境从 package.json 读取
|
|
56838
54287
|
*/
|
|
56839
54288
|
const getVersion = () => {
|
|
56840
|
-
return "7.0.0-beta.
|
|
54289
|
+
return "7.0.0-beta.6";
|
|
56841
54290
|
};
|
|
56842
54291
|
const VERSION = getVersion();
|
|
56843
54292
|
/**
|