@ikenxuan/amagi 7.0.0-beta.7 → 7.0.0-beta.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-BENpfNA1.d.ts +137 -0
- package/dist/bv2av-DUm4x7as.mjs +52 -0
- package/dist/bv2av-Dw0qrO9g.cjs +63 -0
- package/dist/default/index.cjs +4 -3
- package/dist/default/index.d.ts +3 -2
- package/dist/default/index.mjs +2 -1
- package/dist/{api-BWAjDuTS.d.ts → endpoint-QPR2Pp-S.d.ts} +2 -138
- package/dist/exports/compat.cjs +4 -3
- package/dist/exports/compat.d.ts +4 -2
- package/dist/exports/compat.mjs +2 -1
- package/dist/exports/sign-steps.cjs +1 -1
- package/dist/exports/sign-steps.d.ts +2 -1
- package/dist/exports/sign-steps.mjs +1 -1
- package/dist/exports/signing.cjs +128 -427
- package/dist/exports/signing.d.ts +286 -1
- package/dist/exports/signing.mjs +50 -371
- package/dist/he-CuCIF0C7.cjs +3898 -0
- package/dist/he-q7589Kp7.mjs +3419 -0
- package/dist/{index-NkyAecv9.d.ts → index-BSNuPU57.d.ts} +4 -32
- package/dist/secsdkWebSign-DoqHj52s.d.ts +122 -0
- package/dist/{src-CNU3-zoM.cjs → src-BMmA6XJW.cjs} +392 -92
- package/dist/{src-BM1fZuVp.mjs → src-ZHFzJ9Lf.mjs} +376 -64
- package/dist/{steps-BO8NYQHx.cjs → steps-CpeOWLPf.cjs} +9 -1491
- package/dist/{steps-DQlwODdh.mjs → steps-bZrsb4W6.mjs} +2 -1459
- package/package.json +2 -2
- package/dist/tokens-BFqSOfhx.mjs +0 -1001
- package/dist/tokens-DHKAuPVW.cjs +0 -1294
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { t as __exportAll } from "./rolldown-runtime-D7D4PA-g.mjs";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { n as bv2av, t as av2bv } from "./bv2av-DUm4x7as.mjs";
|
|
3
|
+
import { _ as AmagiHeaders, a as createXiaohongshuGuestCookie$1, c as sharedKuaishouSigner, d as douyinXBogus, f as msToken, g as observeDouyinWebid, h as douyinSign$1, i as getSearchId, l as aBogus$1, m as xBogus, n as traceId, o as createXiaohongshuCryptoConfig, p as secsdk, r as xs, s as hxfalcon, t as rap, u as douyinBogus, v as qtparam$1, y as wbi } from "./steps-bZrsb4W6.mjs";
|
|
4
|
+
import { A as genVerifyFp, Ct as getCookieValue, N as ABogus, Y as a_bogus_default, Z as buildBrowserInfo, _t as applySecsdkWebSign, i as deriveKuaishouPureSignature, o as XBogus, w as TTWID_REGISTER_URL, x as DOUYIN_TTWID, yt as extractUifidFromCookie } from "./he-q7589Kp7.mjs";
|
|
4
5
|
import express, { Router } from "express";
|
|
5
6
|
import zod from "zod";
|
|
6
|
-
import crypto, { createCipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
7
7
|
import protobuf from "protobufjs";
|
|
8
8
|
import axios, { AxiosError, AxiosHeaders, mergeConfig } from "axios";
|
|
9
9
|
import { EventEmitter } from "node:events";
|
|
10
|
+
import crypto, { createCipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
11
|
+
import vm from "node:vm";
|
|
10
12
|
import { FingerprintGenerator, Xhshow } from "@ikenxuan/xhshow-ts";
|
|
11
13
|
//#region src/platforms/bilibili/api.ts
|
|
12
14
|
/**
|
|
@@ -408,57 +410,6 @@ const articleListInfo = defineBilibiliEndpoint({
|
|
|
408
410
|
response: type()
|
|
409
411
|
});
|
|
410
412
|
//#endregion
|
|
411
|
-
//#region src/platforms/bilibili/sign/bv2av.ts
|
|
412
|
-
const XOR_CODE = 23442827791579n;
|
|
413
|
-
const MASK_CODE = 2251799813685247n;
|
|
414
|
-
const MAX_AID = 1n << 51n;
|
|
415
|
-
const BASE = 58n;
|
|
416
|
-
const data = "FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf";
|
|
417
|
-
/**
|
|
418
|
-
* av号转bv号
|
|
419
|
-
* @param aid av号
|
|
420
|
-
* @returns
|
|
421
|
-
*/
|
|
422
|
-
const av2bv = (aid) => {
|
|
423
|
-
const bytes = [
|
|
424
|
-
"B",
|
|
425
|
-
"V",
|
|
426
|
-
"1",
|
|
427
|
-
"0",
|
|
428
|
-
"0",
|
|
429
|
-
"0",
|
|
430
|
-
"0",
|
|
431
|
-
"0",
|
|
432
|
-
"0",
|
|
433
|
-
"0",
|
|
434
|
-
"0",
|
|
435
|
-
"0"
|
|
436
|
-
];
|
|
437
|
-
let bvIndex = bytes.length - 1;
|
|
438
|
-
let tmp = (MAX_AID | BigInt(aid)) ^ XOR_CODE;
|
|
439
|
-
while (tmp > 0) {
|
|
440
|
-
bytes[bvIndex] = data[Number(tmp % BigInt(BASE))];
|
|
441
|
-
tmp = tmp / BASE;
|
|
442
|
-
bvIndex -= 1;
|
|
443
|
-
}
|
|
444
|
-
[bytes[3], bytes[9]] = [bytes[9], bytes[3]];
|
|
445
|
-
[bytes[4], bytes[7]] = [bytes[7], bytes[4]];
|
|
446
|
-
return bytes.join("");
|
|
447
|
-
};
|
|
448
|
-
/**
|
|
449
|
-
* bv号转av号
|
|
450
|
-
* @param bvid bv号
|
|
451
|
-
* @returns
|
|
452
|
-
*/
|
|
453
|
-
const bv2av = (bvid) => {
|
|
454
|
-
const bvidArr = Array.from(bvid);
|
|
455
|
-
[bvidArr[3], bvidArr[9]] = [bvidArr[9], bvidArr[3]];
|
|
456
|
-
[bvidArr[4], bvidArr[7]] = [bvidArr[7], bvidArr[4]];
|
|
457
|
-
bvidArr.splice(0, 3);
|
|
458
|
-
const tmp = bvidArr.reduce((pre, bvidChar) => pre * BASE + BigInt(data.indexOf(bvidChar)), 0n);
|
|
459
|
-
return Number(tmp & MASK_CODE ^ XOR_CODE);
|
|
460
|
-
};
|
|
461
|
-
//#endregion
|
|
462
413
|
//#region src/platforms/bilibili/endpoints/avToBv.ts
|
|
463
414
|
/**
|
|
464
415
|
* AV 号转 BV 号(纯本地计算,不发请求)。
|
|
@@ -2208,6 +2159,7 @@ const DEFAULT_ERROR_MESSAGES = {
|
|
|
2208
2159
|
RATE_LIMITED: "请求过于频繁,请稍后再试",
|
|
2209
2160
|
RISK_CONTROL: "触发平台风控",
|
|
2210
2161
|
CAPTCHA_REQUIRED: "需要完成验证码",
|
|
2162
|
+
SIGNATURE_REFUSED: "平台拒绝了本次请求的签名(amagi 侧的签名问题,换 cookie 无用)",
|
|
2211
2163
|
NOT_FOUND: "资源不存在",
|
|
2212
2164
|
DELETED: "资源已删除或已下架",
|
|
2213
2165
|
PRIVATE: "资源为私密状态",
|
|
@@ -2449,6 +2401,61 @@ const DOUYIN_ANDROID_UA = "Mozilla/5.0 (Linux; Android 13; SM-S908E) AppleWebKit
|
|
|
2449
2401
|
*/
|
|
2450
2402
|
const isDouyinArgusBody = (raw) => typeof raw === "string" && /ArgusSecurityPlugin|Blocked by/i.test(raw);
|
|
2451
2403
|
/**
|
|
2404
|
+
* Argus 拦截文本里点名「签名不对」的片段。
|
|
2405
|
+
*
|
|
2406
|
+
* 命中这些的 403 是 **amagi 自己的问题,不是这个账号的问题** —— 平台在说
|
|
2407
|
+
* 「你这个签名我不认」,而不是「你这个号被风控了」。两者处置方式完全相反:
|
|
2408
|
+
* 前者换 cookie 一万次也没用(得修签名),后者换个号就好了。
|
|
2409
|
+
*
|
|
2410
|
+
* 片段清单来自线上实测,全部是 `Blocked by ArgusSecurityPlugin <reason>` 形态。
|
|
2411
|
+
* 403 若一律判风控,「签名器少算了一个参数」就会表现成账号被风控,排查方向
|
|
2412
|
+
* 整个跑偏。
|
|
2413
|
+
*
|
|
2414
|
+
* `uifid not found` 这一条在本仓库有具体成因:`/aweme/v1/web/music/detail/` 这类
|
|
2415
|
+
* 受 secsdk 保护的端点要求访客 id,而扫码登录拿回的 cookie 里**没有** `UIFID`
|
|
2416
|
+
* (它由 secsdk 在真实浏览器里铸造,passport 链路不经过那一步)。见
|
|
2417
|
+
* `sign/secsdkWebSign.ts` 的 `applySecsdkWebSign`。
|
|
2418
|
+
*/
|
|
2419
|
+
const SIGNATURE_REFUSAL_MARKERS = [
|
|
2420
|
+
"uifid not found",
|
|
2421
|
+
"signature not found",
|
|
2422
|
+
"sign invalid",
|
|
2423
|
+
"sign expired"
|
|
2424
|
+
];
|
|
2425
|
+
/**
|
|
2426
|
+
* 从 Argus 拦截文本里认出「签名被拒」。
|
|
2427
|
+
*
|
|
2428
|
+
* 只在**已经确定是 Argus 文本**之后调用(`isDouyinArgusBody` 为真),所以这里
|
|
2429
|
+
* 直接按子串找,不必担心把正常响应里恰好出现的字眼当成拦截。
|
|
2430
|
+
* @param raw - decode 之后的响应体
|
|
2431
|
+
* @returns 命中的片段;不是签名类拒绝时返回 `undefined`
|
|
2432
|
+
*/
|
|
2433
|
+
const douyinSignatureRefusal = (raw) => {
|
|
2434
|
+
if (typeof raw !== "string") return void 0;
|
|
2435
|
+
const text = raw.toLowerCase();
|
|
2436
|
+
return SIGNATURE_REFUSAL_MARKERS.find((marker) => text.includes(marker));
|
|
2437
|
+
};
|
|
2438
|
+
/**
|
|
2439
|
+
* 抖音风控页(verify_center)的判据字段。
|
|
2440
|
+
*
|
|
2441
|
+
* 实物形状(`status_code: 10000` + 这个字段,字段本身是**字符串形式的 JSON**):
|
|
2442
|
+
*
|
|
2443
|
+
* ```jsonc
|
|
2444
|
+
* { "status_code": 10000, "status_msg": "",
|
|
2445
|
+
* "verify_center_decision_conf": "{\"verify_center_decision\":\"verify_hit\",\"decision_conf\":{\"subtype\":\"slide\"}}" }
|
|
2446
|
+
* ```
|
|
2447
|
+
*
|
|
2448
|
+
* **按字段名判,不扫全文**。扫 `captcha` 这类子串会把「简介里恰好提到验证码」
|
|
2449
|
+
* 的正常作品判成风控,而每一次误判都会冤枉一个健康账号;字段名不会出现在用户
|
|
2450
|
+
* 文案里。
|
|
2451
|
+
*
|
|
2452
|
+
* 这一条只负责**定性**到「需要人机验证」。它不产出验证页地址 —— 目前掌握的
|
|
2453
|
+
* 这份响应里确实没有可跳转的 URL,`verify_center_decision_conf` 里只有
|
|
2454
|
+
* `verify_hit` 与 `subtype: slide` 这类决策信息。所以 amagi 不装
|
|
2455
|
+
* `ChallengeExtractor`(`error.challenge` 仍为空),不编造一个地址出来。
|
|
2456
|
+
*/
|
|
2457
|
+
const VERIFY_CENTER_KEY = "verify_center_decision_conf";
|
|
2458
|
+
/**
|
|
2452
2459
|
* 抖音平台默认响应判定。
|
|
2453
2460
|
*
|
|
2454
2461
|
* 判定规则:
|
|
@@ -2474,10 +2481,22 @@ const douyinJudge = (raw, http) => {
|
|
|
2474
2481
|
code: "EMPTY_RESPONSE",
|
|
2475
2482
|
retryable: false
|
|
2476
2483
|
};
|
|
2484
|
+
if (douyinSignatureRefusal(raw)) return {
|
|
2485
|
+
ok: false,
|
|
2486
|
+
kind: "risk",
|
|
2487
|
+
code: "SIGNATURE_REFUSED",
|
|
2488
|
+
retryable: false
|
|
2489
|
+
};
|
|
2477
2490
|
const nonJson = verdictFromNonJsonBody(raw);
|
|
2478
2491
|
if (nonJson) return nonJson;
|
|
2479
2492
|
if (typeof raw !== "object" || raw === null) return verdictFromHttpStatus(http.status) ?? { ok: true };
|
|
2480
2493
|
const body = raw;
|
|
2494
|
+
if (body[VERIFY_CENTER_KEY] !== void 0 && body[VERIFY_CENTER_KEY] !== null && body[VERIFY_CENTER_KEY] !== "") return {
|
|
2495
|
+
ok: false,
|
|
2496
|
+
kind: "risk",
|
|
2497
|
+
code: "CAPTCHA_REQUIRED",
|
|
2498
|
+
retryable: false
|
|
2499
|
+
};
|
|
2481
2500
|
const filterDetail = body.filter_detail;
|
|
2482
2501
|
if (filterDetail && typeof filterDetail.filter_reason === "string" && filterDetail.filter_reason.length > 0) return {
|
|
2483
2502
|
ok: false,
|
|
@@ -2495,6 +2514,282 @@ const douyinJudge = (raw, http) => {
|
|
|
2495
2514
|
return verdictFromHttpStatus(http.status) ?? { ok: true };
|
|
2496
2515
|
};
|
|
2497
2516
|
//#endregion
|
|
2517
|
+
//#region src/platforms/douyin/mint.ts
|
|
2518
|
+
/**
|
|
2519
|
+
* 抖音访客 id(`UIFID_TEMP`)的铸造与 cookie 审计。
|
|
2520
|
+
*
|
|
2521
|
+
* 受 secsdk 保护的端点(`/aweme/v1/web/music/detail/` 等)要求 cookie 里带访客 id,
|
|
2522
|
+
* 而它不由登录颁发——passport 扫码链路只发身份 cookie。缺了它,受保护端点稳定回
|
|
2523
|
+
* `403 Uifid Not Found`(见 `judge.ts` 的 `SIGNATURE_REFUSED` 归因)。
|
|
2524
|
+
*
|
|
2525
|
+
* 铸造是纯 HTTP 的,三步:
|
|
2526
|
+
*
|
|
2527
|
+
* 1. `GET www.douyin.com` → 服务端发 `__ac_nonce`,响应体是一份挑战页(内联
|
|
2528
|
+
* byted_acrawler 本体 + 引导脚本 `init({aid: 99999999, dfp: 0})` 与
|
|
2529
|
+
* `sign("", nonce)`);
|
|
2530
|
+
* 2. 在 Node `vm` 里执行本体,离线算出 `__ac_signature`;
|
|
2531
|
+
* 3. 带 `__ac_nonce + __ac_signature` 再 GET 一次 → 服务端一次性 Set-Cookie:
|
|
2532
|
+
* `ttwid` + `web_sign_token` + `UIFID_TEMP`。
|
|
2533
|
+
*
|
|
2534
|
+
* acrawler 本体不进仓库——每次铸造从挑战页现取现执行,抖音更新算法时自动跟随。
|
|
2535
|
+
* 同一客户端上下文(IP + UA)重复铸造返回同一个 `UIFID_TEMP`(有效期约 500 天),
|
|
2536
|
+
* 进程内缓存一次即可。
|
|
2537
|
+
*
|
|
2538
|
+
* 接线:`PLATFORM_RUNTIME.douyin.ensureCookie` 在每次请求 prepare 之后审计
|
|
2539
|
+
* (prepare 可能整串换 cookie,审计要对最终生效的那份负责);`session/qrcode.ts`
|
|
2540
|
+
* 在登录完成时审计,让扫码登录直接产出带访客 id 的凭证。
|
|
2541
|
+
*
|
|
2542
|
+
* 铸造尽力而为:网络失败、挑战页形状变化、签名被拒——一律返回原 cookie,归因
|
|
2543
|
+
* 交给 `SIGNATURE_REFUSED`。不抛错:自愈失败不该把调用变成 `internal`。
|
|
2544
|
+
*
|
|
2545
|
+
* @module platform/douyin/mint
|
|
2546
|
+
*/
|
|
2547
|
+
/** 无调用方 UA 时铸造请求的兜底 UA。与 amagi 默认基线同代 */
|
|
2548
|
+
const DEFAULT_MINT_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
|
2549
|
+
/** 挑战页引导脚本里的 aid。实测固定为 99999999,从引导块里解析,解析不到用它兜底 */
|
|
2550
|
+
const AID_FALLBACK = 99999999;
|
|
2551
|
+
/** acrawler 本体的最小长度。挑战页里最大的内联块(实测 ~71KB),低于此视为形状变化 */
|
|
2552
|
+
const ACGRAWLER_MIN_LENGTH = 1e4;
|
|
2553
|
+
/**
|
|
2554
|
+
* 从挑战页 HTML 里解析 acrawler 本体与引导 aid。
|
|
2555
|
+
*
|
|
2556
|
+
* 规则刻意保守:取**最大的无 `src` 内联 script 块**当本体(实测 ~71KB,远大于页面
|
|
2557
|
+
* 上任何其它内联块),引导块按「提到 `byted_acrawler` 且含 `sign(`」认——两者都不
|
|
2558
|
+
* 合格就返回 `undefined`,宁可放弃铸造也不把来路不明的脚本执行一遍。
|
|
2559
|
+
* @param html - 挑战页 HTML(首次请求的响应体)
|
|
2560
|
+
* @returns 解析结果;形状不对时 `undefined`
|
|
2561
|
+
*/
|
|
2562
|
+
const extractChallengeParts = (html) => {
|
|
2563
|
+
const blocks = [...html.matchAll(/<script>([\s\S]*?)<\/script>/g)].map((m) => m[1]);
|
|
2564
|
+
if (blocks.length === 0) return void 0;
|
|
2565
|
+
const acrawlerSource = blocks.reduce((a, b) => b.length > a.length ? b : a);
|
|
2566
|
+
if (acrawlerSource.length < ACGRAWLER_MIN_LENGTH) return void 0;
|
|
2567
|
+
const bootstrap = blocks.find((b) => b !== acrawlerSource && b.includes("byted_acrawler") && b.includes("sign("));
|
|
2568
|
+
if (!bootstrap) return void 0;
|
|
2569
|
+
const aidMatch = bootstrap.match(/aid:(\d+)/);
|
|
2570
|
+
return {
|
|
2571
|
+
acrawlerSource,
|
|
2572
|
+
aid: aidMatch ? Number(aidMatch[1]) : AID_FALLBACK
|
|
2573
|
+
};
|
|
2574
|
+
};
|
|
2575
|
+
/**
|
|
2576
|
+
* 执行 acrawler 本体,返回对任意 nonce 的离线签名函数。
|
|
2577
|
+
*
|
|
2578
|
+
* shim 只需要 SDK 引用到的环境面:`document.cookie`(nonce 从这里读回,给空串让
|
|
2579
|
+
* 签名只依赖入参)、location / navigator / screen / history 与基础内置对象。环境
|
|
2580
|
+
* 数据不参与服务端校验(实测假数据可通过),所以 shim 不追求逼真。
|
|
2581
|
+
* @param source - acrawler 本体源码
|
|
2582
|
+
* @param aid - 引导 aid
|
|
2583
|
+
* @param userAgent - 参与签名的 UA(与后续 API 请求保持一致)
|
|
2584
|
+
* @returns `sign('', nonce)` 的等价函数
|
|
2585
|
+
*/
|
|
2586
|
+
const createAcrawlerSigner = (source, aid, userAgent) => {
|
|
2587
|
+
const doc = {};
|
|
2588
|
+
Object.defineProperty(doc, "cookie", {
|
|
2589
|
+
get: () => "",
|
|
2590
|
+
set: () => {}
|
|
2591
|
+
});
|
|
2592
|
+
const windowShim = {
|
|
2593
|
+
document: doc,
|
|
2594
|
+
location: {
|
|
2595
|
+
href: "https://www.douyin.com/",
|
|
2596
|
+
protocol: "https:",
|
|
2597
|
+
host: "www.douyin.com",
|
|
2598
|
+
hostname: "www.douyin.com",
|
|
2599
|
+
pathname: "/",
|
|
2600
|
+
search: "",
|
|
2601
|
+
hash: "",
|
|
2602
|
+
origin: "https://www.douyin.com",
|
|
2603
|
+
reload: () => {}
|
|
2604
|
+
},
|
|
2605
|
+
navigator: {
|
|
2606
|
+
userAgent,
|
|
2607
|
+
language: "zh-CN",
|
|
2608
|
+
platform: "Win32"
|
|
2609
|
+
},
|
|
2610
|
+
screen: {
|
|
2611
|
+
width: 2560,
|
|
2612
|
+
height: 1440
|
|
2613
|
+
},
|
|
2614
|
+
history: { length: 2 },
|
|
2615
|
+
setTimeout: () => 0,
|
|
2616
|
+
setInterval: () => 0,
|
|
2617
|
+
clearTimeout: () => {},
|
|
2618
|
+
clearInterval: () => {},
|
|
2619
|
+
console: {
|
|
2620
|
+
log: () => {},
|
|
2621
|
+
warn: () => {},
|
|
2622
|
+
error: () => {}
|
|
2623
|
+
},
|
|
2624
|
+
Object,
|
|
2625
|
+
Array,
|
|
2626
|
+
Symbol,
|
|
2627
|
+
Error,
|
|
2628
|
+
TypeError,
|
|
2629
|
+
JSON,
|
|
2630
|
+
Math,
|
|
2631
|
+
Date,
|
|
2632
|
+
Promise,
|
|
2633
|
+
Reflect,
|
|
2634
|
+
Map,
|
|
2635
|
+
Set,
|
|
2636
|
+
Number,
|
|
2637
|
+
String,
|
|
2638
|
+
Boolean,
|
|
2639
|
+
RegExp,
|
|
2640
|
+
Function,
|
|
2641
|
+
parseInt,
|
|
2642
|
+
parseFloat,
|
|
2643
|
+
isNaN,
|
|
2644
|
+
encodeURIComponent,
|
|
2645
|
+
decodeURIComponent,
|
|
2646
|
+
TextEncoder,
|
|
2647
|
+
URL,
|
|
2648
|
+
URLSearchParams,
|
|
2649
|
+
performance
|
|
2650
|
+
};
|
|
2651
|
+
windowShim.window = windowShim;
|
|
2652
|
+
windowShim.self = windowShim;
|
|
2653
|
+
windowShim.top = windowShim;
|
|
2654
|
+
windowShim.parent = windowShim;
|
|
2655
|
+
windowShim.globalThis = windowShim;
|
|
2656
|
+
const context = vm.createContext(windowShim);
|
|
2657
|
+
vm.runInContext(source, context, { timeout: 2e4 });
|
|
2658
|
+
const ac = windowShim.byted_acrawler;
|
|
2659
|
+
if (!ac?.sign) throw new Error("acrawler 本体未注册 byted_acrawler.sign");
|
|
2660
|
+
ac.init({
|
|
2661
|
+
aid,
|
|
2662
|
+
dfp: 0
|
|
2663
|
+
});
|
|
2664
|
+
return (nonce) => ac.sign("", nonce);
|
|
2665
|
+
};
|
|
2666
|
+
/**
|
|
2667
|
+
* 测试环境守卫:vitest 进程内默认出口**不做真实出网请求**(审计在测试里保持透明,
|
|
2668
|
+
* 断言 Cookie 透传的用例不该被一次真网铸造改写)。通过 {@link MintDeps.getHome}
|
|
2669
|
+
* 注入出口的用例不受影响——守卫只管默认出口。
|
|
2670
|
+
*/
|
|
2671
|
+
const testEnvGuard = async () => {
|
|
2672
|
+
throw new Error("douyin visitor mint is disabled under vitest (inject MintDeps.getHome to test it)");
|
|
2673
|
+
};
|
|
2674
|
+
/**
|
|
2675
|
+
* 运营开关:`AMAGI_DISABLE_DOUYIN_MINT=1` 关闭自动铸造。
|
|
2676
|
+
*
|
|
2677
|
+
* 铸造是 amagi **自主发起**的出网请求(用户没有显式调用任何端点),出口受限、
|
|
2678
|
+
* 安全审计、或「cookie 由我自己全权管理」的环境需要一个明确的选择权。关闭后
|
|
2679
|
+
* cookie 保持原样,受保护端点按 `SIGNATURE_REFUSED` 的既有归因失败。注入
|
|
2680
|
+
* {@link MintDeps.getHome} 的用例不受影响——开关只管默认出口。
|
|
2681
|
+
*/
|
|
2682
|
+
const mintDisabledByEnv = () => process.env.AMAGI_DISABLE_DOUYIN_MINT === "1";
|
|
2683
|
+
const defaultGetHome = async (cookie, userAgent) => {
|
|
2684
|
+
if (process.env.VITEST) return testEnvGuard();
|
|
2685
|
+
if (mintDisabledByEnv()) return testEnvGuard();
|
|
2686
|
+
const response = await axios.get("https://www.douyin.com/", {
|
|
2687
|
+
headers: {
|
|
2688
|
+
"User-Agent": userAgent,
|
|
2689
|
+
Accept: "text/html",
|
|
2690
|
+
...cookie ? { Cookie: cookie } : {}
|
|
2691
|
+
},
|
|
2692
|
+
timeout: 15e3,
|
|
2693
|
+
maxRedirects: 5,
|
|
2694
|
+
responseType: "text"
|
|
2695
|
+
});
|
|
2696
|
+
const setCookies = response.headers["set-cookie"] ?? [];
|
|
2697
|
+
return {
|
|
2698
|
+
setCookies: Array.isArray(setCookies) ? setCookies : [setCookies],
|
|
2699
|
+
body: String(response.data ?? "")
|
|
2700
|
+
};
|
|
2701
|
+
};
|
|
2702
|
+
/** 从 Set-Cookie 行数组里取某个 cookie 的值 */
|
|
2703
|
+
const setCookieValue = (cookies, name) => {
|
|
2704
|
+
const line = cookies.find((c) => c.startsWith(name + "="));
|
|
2705
|
+
return line ? line.split(";")[0].split("=").slice(1).join("=") : void 0;
|
|
2706
|
+
};
|
|
2707
|
+
/**
|
|
2708
|
+
* 铸造一次访客 id(两次 GET + 离线签名)。不做缓存、不去重——那是
|
|
2709
|
+
* {@link ensureDouyinVisitorCookie} 的职责。
|
|
2710
|
+
* @param userAgent - 铸造请求的 UA(与后续 API 请求保持一致)
|
|
2711
|
+
* @param deps - 可注入的 HTTP 出口(测试用)
|
|
2712
|
+
* @returns 铸造结果;挑战页形状变化 / 签名被拒 / 网络失败时 `undefined`
|
|
2713
|
+
*/
|
|
2714
|
+
const mintDouyinVisitorCookie = async (userAgent, deps) => {
|
|
2715
|
+
const ua = userAgent || DEFAULT_MINT_UA;
|
|
2716
|
+
const getHome = deps?.getHome ?? defaultGetHome;
|
|
2717
|
+
try {
|
|
2718
|
+
const first = await getHome(void 0, ua);
|
|
2719
|
+
const nonce = setCookieValue(first.setCookies, "__ac_nonce");
|
|
2720
|
+
if (!nonce) return void 0;
|
|
2721
|
+
const parts = extractChallengeParts(first.body);
|
|
2722
|
+
if (!parts) return void 0;
|
|
2723
|
+
const second = await getHome(`__ac_nonce=${nonce}; __ac_signature=${createAcrawlerSigner(parts.acrawlerSource, parts.aid, ua)(nonce)}`, ua);
|
|
2724
|
+
const ttwid = setCookieValue(second.setCookies, "ttwid");
|
|
2725
|
+
const uifidTemp = setCookieValue(second.setCookies, "UIFID_TEMP");
|
|
2726
|
+
if (!ttwid || !uifidTemp) return void 0;
|
|
2727
|
+
return {
|
|
2728
|
+
ttwid,
|
|
2729
|
+
uifidTemp
|
|
2730
|
+
};
|
|
2731
|
+
} catch {
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
};
|
|
2735
|
+
/**
|
|
2736
|
+
* 把铸造结果合并进现有 cookie。**只补缺失的名字**:`ttwid` 已有则不动(那是调用方
|
|
2737
|
+
* 的设备标识,铸造顺带产的那份与它会话无关),访客 id 已有任何拼写则原样返回。
|
|
2738
|
+
* @param cookie - 现有 cookie 串,可为空
|
|
2739
|
+
* @param minted - 铸造结果
|
|
2740
|
+
* @returns 合并后的 cookie 串;无可合并项时原样返回
|
|
2741
|
+
*/
|
|
2742
|
+
const mergeVisitorCookie = (cookie, minted) => {
|
|
2743
|
+
const base = cookie?.trim() ? cookie.trim().replace(/;\s*$/, "") : "";
|
|
2744
|
+
const names = new Set(base.split(";").map((p) => p.split("=")[0].trim()).filter(Boolean));
|
|
2745
|
+
if (names.has("UIFID_TEMP") || names.has("UIFID") || names.has("uifid") || names.has("uifid_temp")) return base;
|
|
2746
|
+
const parts = [];
|
|
2747
|
+
if (!names.has("ttwid") && minted.ttwid) parts.push(`ttwid=${minted.ttwid}`);
|
|
2748
|
+
if (minted.uifidTemp) parts.push(`UIFID_TEMP=${minted.uifidTemp}`);
|
|
2749
|
+
if (parts.length === 0) return base;
|
|
2750
|
+
return base ? `${base}; ${parts.join("; ")}` : parts.join("; ");
|
|
2751
|
+
};
|
|
2752
|
+
/** 审计缓存:原始 cookie(+UA)→ 合并后 cookie。进程级,`UIFID_TEMP` 有效期约 500 天 */
|
|
2753
|
+
const cache = /* @__PURE__ */ new Map();
|
|
2754
|
+
/** in-flight 去重:并发调用共享同一次铸造(合并是按各自 cookie 进行的,结果正确) */
|
|
2755
|
+
let inflight;
|
|
2756
|
+
const CACHE_MAX_ENTRIES = 64;
|
|
2757
|
+
/**
|
|
2758
|
+
* cookie 审计:缺访客 id 时铸造并合并,其余原样返回。进程内缓存,同一 cookie 只铸一次。
|
|
2759
|
+
*
|
|
2760
|
+
* **永不为调用增加失败**:任何异常都吞掉并返回原 cookie,请求按原路径走——自愈失败
|
|
2761
|
+
* 的归因交给受保护端点自己的 `SIGNATURE_REFUSED`。
|
|
2762
|
+
* @param cookie - 本次调用的 cookie,可为空(全新部署连 cookie 都没配的场景)
|
|
2763
|
+
* @param userAgent - 本次调用的 UA(铸造请求用它,保证与后续 API 请求同源)
|
|
2764
|
+
* @param deps - 可注入的 HTTP 出口(测试用)
|
|
2765
|
+
* @returns 升级后的 cookie;无需升级或铸造失败时原样返回
|
|
2766
|
+
*/
|
|
2767
|
+
const ensureDouyinVisitorCookie = async (cookie, userAgent, deps) => {
|
|
2768
|
+
if (cookie && extractUifidFromCookie(cookie)) return cookie;
|
|
2769
|
+
const cacheKey = `${cookie ?? ""} ${userAgent ?? ""}`;
|
|
2770
|
+
const hit = cache.get(cacheKey);
|
|
2771
|
+
if (hit !== void 0) return hit;
|
|
2772
|
+
if (inflight !== void 0) return inflight;
|
|
2773
|
+
inflight = (async () => {
|
|
2774
|
+
try {
|
|
2775
|
+
const minted = await mintDouyinVisitorCookie(userAgent, deps);
|
|
2776
|
+
if (!minted) return cookie;
|
|
2777
|
+
const merged = mergeVisitorCookie(cookie, minted);
|
|
2778
|
+
cache.set(cacheKey, merged);
|
|
2779
|
+
if (cache.size > CACHE_MAX_ENTRIES) {
|
|
2780
|
+
const oldest = cache.keys().next().value;
|
|
2781
|
+
if (oldest !== void 0) cache.delete(oldest);
|
|
2782
|
+
}
|
|
2783
|
+
return merged;
|
|
2784
|
+
} catch {
|
|
2785
|
+
return cookie;
|
|
2786
|
+
} finally {
|
|
2787
|
+
inflight = void 0;
|
|
2788
|
+
}
|
|
2789
|
+
})();
|
|
2790
|
+
return inflight;
|
|
2791
|
+
};
|
|
2792
|
+
//#endregion
|
|
2498
2793
|
//#region src/platforms/douyin/sign/signers.ts
|
|
2499
2794
|
/**
|
|
2500
2795
|
* 抖音签名器表:**只做「注册名 → `SignStep` 清单」的映射,不含算法**。
|
|
@@ -2686,7 +2981,7 @@ const parseKuaishouCaptcha = (raw) => {
|
|
|
2686
2981
|
/** 默认超时,与抖音 / B站 / 小红书一致 */
|
|
2687
2982
|
const KUAISHOU_DEFAULT_TIMEOUT = 1e4;
|
|
2688
2983
|
/** 从请求配置里取 UA:大小写不敏感,取不到用集中维护的默认值 */
|
|
2689
|
-
const userAgentOf$
|
|
2984
|
+
const userAgentOf$2 = (requestConfig) => {
|
|
2690
2985
|
return new AmagiHeaders(requestConfig?.headers).get("user-agent") ?? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36";
|
|
2691
2986
|
};
|
|
2692
2987
|
/**
|
|
@@ -2698,7 +2993,7 @@ const userAgentOf$1 = (requestConfig) => {
|
|
|
2698
2993
|
* @returns 默认 header 基线 + 请求配置
|
|
2699
2994
|
*/
|
|
2700
2995
|
const createKuaishouConfig = (cookie, requestConfig) => {
|
|
2701
|
-
const userAgent = userAgentOf$
|
|
2996
|
+
const userAgent = userAgentOf$2(requestConfig);
|
|
2702
2997
|
const secChUa = generateSecChUa(userAgent);
|
|
2703
2998
|
return {
|
|
2704
2999
|
headers: new AmagiHeaders().set("accept", "application/json, text/plain, */*").set("accept-encoding", "gzip, deflate, br, zstd").set("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6").set("content-type", "application/json").set("priority", "u=0, i").set("origin", "https://www.kuaishou.com").set("referer", "https://www.kuaishou.com/new-reco").set("sec-ch-ua", secChUa).set("sec-ch-ua-mobile", "?0").set("sec-ch-ua-platform", "\"Windows\"").set("sec-fetch-dest", "empty").set("sec-fetch-mode", "cors").set("sec-fetch-site", "same-site").set("user-agent", userAgent).merge(requestConfig?.headers),
|
|
@@ -2936,7 +3231,7 @@ const createKuaishouSigners = (signer = sharedKuaishouSigner) => ({ hxfalcon: st
|
|
|
2936
3231
|
/** 默认超时,与抖音 / B站 / 快手一致 */
|
|
2937
3232
|
const XIAOHONGSHU_DEFAULT_TIMEOUT = 1e4;
|
|
2938
3233
|
/** 从请求配置里取 UA:大小写不敏感,取不到用集中维护的默认值 */
|
|
2939
|
-
const userAgentOf = (requestConfig) => {
|
|
3234
|
+
const userAgentOf$1 = (requestConfig) => {
|
|
2940
3235
|
return new AmagiHeaders(requestConfig?.headers).get("user-agent") ?? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36";
|
|
2941
3236
|
};
|
|
2942
3237
|
/**
|
|
@@ -2946,7 +3241,7 @@ const userAgentOf = (requestConfig) => {
|
|
|
2946
3241
|
* @returns 默认 header 基线 + 请求配置
|
|
2947
3242
|
*/
|
|
2948
3243
|
const createXiaohongshuConfig = (cookie, requestConfig) => {
|
|
2949
|
-
const userAgent = userAgentOf(requestConfig);
|
|
3244
|
+
const userAgent = userAgentOf$1(requestConfig);
|
|
2950
3245
|
const secChUa = generateSecChUa(userAgent);
|
|
2951
3246
|
return {
|
|
2952
3247
|
headers: new AmagiHeaders().set("accept", "application/json, text/plain, */*").set("accept-language", "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6").set("cache-control", "no-cache").set("content-type", "application/json;charset=UTF-8").set("pragma", "no-cache").set("priority", "u=1, i").set("referer", "https://www.xiaohongshu.com/").set("sec-ch-ua", secChUa).set("sec-ch-ua-mobile", "?0").set("sec-ch-ua-platform", "\"Windows\"").set("sec-fetch-dest", "empty").set("sec-fetch-mode", "cors").set("sec-fetch-site", "same-site").set("user-agent", userAgent).set("cookie", cookie?.trim() ?? "").merge(requestConfig?.headers),
|
|
@@ -3651,7 +3946,7 @@ var HttpClient = class {
|
|
|
3651
3946
|
//#endregion
|
|
3652
3947
|
//#region src/client/runtime.ts
|
|
3653
3948
|
/**
|
|
3654
|
-
* 平台运行期依赖表:签名器表 + 默认 judge + 可选的风控挑战提取器 /
|
|
3949
|
+
* 平台运行期依赖表:签名器表 + 默认 judge + 可选的风控挑战提取器 / 响应旁观者 / cookie 审计。
|
|
3655
3950
|
*
|
|
3656
3951
|
* 四个平台都必须 `signers` / `judge` 齐全 —— 少一项不会报编译错误,请求会
|
|
3657
3952
|
* 不签名、或业务失败被判成成功。
|
|
@@ -3663,6 +3958,11 @@ var HttpClient = class {
|
|
|
3663
3958
|
* `observe` 是**可选的第四项**:只有「服务端把状态写在响应头里」的平台才装
|
|
3664
3959
|
* (目前只有抖音,用它回收 `webid`)。每次 send 之后调用一次,只读、不影响判定。
|
|
3665
3960
|
*
|
|
3961
|
+
* `ensureCookie` 是**可选的第五项**:只有「cookie 里有平台自己才认得的必填身份」的
|
|
3962
|
+
* 平台才装(目前只有抖音,缺访客 id 时自动铸造并合并)。每次调用在 prepare 之后、
|
|
3963
|
+
* build/sign 之前执行一次,返回升级后的 cookie;失败/无需升级原样返回——审计
|
|
3964
|
+
* **永不为调用增加失败**。
|
|
3965
|
+
*
|
|
3666
3966
|
* createClient 的 fetcher、四个 `createXxxRoutes` 与静态 fetcher 共用这张表,
|
|
3667
3967
|
* 保证同一平台在任何入口下的签名 / 判定 / 风控行为一致。
|
|
3668
3968
|
*/
|
|
@@ -3679,7 +3979,8 @@ const PLATFORM_RUNTIME = {
|
|
|
3679
3979
|
douyin: {
|
|
3680
3980
|
signers: createDouyinSigners(),
|
|
3681
3981
|
judge: douyinJudge,
|
|
3682
|
-
observe: observeDouyinWebid
|
|
3982
|
+
observe: observeDouyinWebid,
|
|
3983
|
+
ensureCookie: ensureDouyinVisitorCookie
|
|
3683
3984
|
},
|
|
3684
3985
|
bilibili: {
|
|
3685
3986
|
signers: (() => {
|
|
@@ -3744,6 +4045,7 @@ const makeClientCtx = (platform, cookie, requestConfig = {}, clientId = "shared"
|
|
|
3744
4045
|
judge: runtime.judge,
|
|
3745
4046
|
...runtime.challenge === void 0 ? {} : { challenge: runtime.challenge },
|
|
3746
4047
|
...runtime.observe === void 0 ? {} : { observe: runtime.observe },
|
|
4048
|
+
...runtime.ensureCookie === void 0 ? {} : { ensureCookie: runtime.ensureCookie },
|
|
3747
4049
|
...bus === void 0 ? {} : { bus },
|
|
3748
4050
|
...debug === void 0 ? {} : { debug },
|
|
3749
4051
|
send: (spec, reason, perCall) => http.send(spec, reason, perCall),
|
|
@@ -4278,6 +4580,10 @@ const execute = async (def, input, options) => {
|
|
|
4278
4580
|
...baseCtx,
|
|
4279
4581
|
send: boundSend
|
|
4280
4582
|
};
|
|
4583
|
+
if (options.ensureCookie) {
|
|
4584
|
+
const upgraded = await options.ensureCookie(ctx.cookie, ctx.userAgent);
|
|
4585
|
+
if (upgraded !== void 0 && upgraded !== ctx.cookie) ctx.cookie = upgraded;
|
|
4586
|
+
}
|
|
4281
4587
|
stage = "build";
|
|
4282
4588
|
if (!def.build) throw new Error(`端点 ${def.name} 既没有 build 也没有 compute`);
|
|
4283
4589
|
stage = "sign";
|
|
@@ -4601,6 +4907,7 @@ const callEndpoint = (def, ctx, options, requestConfig) => {
|
|
|
4601
4907
|
judge: ctx.judge,
|
|
4602
4908
|
challenge: ctx.challenge,
|
|
4603
4909
|
observe: ctx.observe,
|
|
4910
|
+
ensureCookie: ctx.ensureCookie,
|
|
4604
4911
|
bus: ctx.bus,
|
|
4605
4912
|
trace: tracer,
|
|
4606
4913
|
debug: ctx.debug,
|
|
@@ -7449,6 +7756,11 @@ const buildVerifyBody = (verify, verifyWay, tail) => ({
|
|
|
7449
7756
|
});
|
|
7450
7757
|
//#endregion
|
|
7451
7758
|
//#region src/platforms/douyin/session/qrcode.ts
|
|
7759
|
+
/** 从请求配置的 headers 里尽量取出 UA(大小写不敏感;取不到返回 `undefined`,铸造用兜底 UA) */
|
|
7760
|
+
const userAgentOf = (headers) => {
|
|
7761
|
+
if (!headers || typeof headers !== "object") return void 0;
|
|
7762
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === "user-agent" && typeof value === "string") return value;
|
|
7763
|
+
};
|
|
7452
7764
|
/**
|
|
7453
7765
|
* 抖音扫码登录策略。
|
|
7454
7766
|
*
|
|
@@ -7574,7 +7886,7 @@ const douyinQrcodeStrategy = {
|
|
|
7574
7886
|
};
|
|
7575
7887
|
case "confirmed": {
|
|
7576
7888
|
if (result.redirectUrl) await client.followSsoRedirect(result.redirectUrl);
|
|
7577
|
-
const cookie = client.cookies.toString();
|
|
7889
|
+
const cookie = await ensureDouyinVisitorCookie(client.cookies.toString(), userAgentOf(ctx.requestConfig?.headers)) ?? client.cookies.toString();
|
|
7578
7890
|
return {
|
|
7579
7891
|
ok: true,
|
|
7580
7892
|
state: {
|
|
@@ -51623,7 +51935,7 @@ const buildOpenApiSpec = (options = {}) => {
|
|
|
51623
51935
|
openapi: "3.1.0",
|
|
51624
51936
|
info: {
|
|
51625
51937
|
title: "amagi HTTP API",
|
|
51626
|
-
version: options.version ?? "7.0.0-beta.
|
|
51938
|
+
version: options.version ?? "7.0.0-beta.8",
|
|
51627
51939
|
description: [
|
|
51628
51940
|
"由端点注册表派生的规范 —— 手写的那份已经漂移,这份不会(`gen-openapi --check` 进 CI)。",
|
|
51629
51941
|
"",
|
|
@@ -54393,7 +54705,7 @@ let DynamicType = /* @__PURE__ */ function(DynamicType) {
|
|
|
54393
54705
|
* 构建后使用 __VERSION__,开发环境从 package.json 读取
|
|
54394
54706
|
*/
|
|
54395
54707
|
const getVersion = () => {
|
|
54396
|
-
return "7.0.0-beta.
|
|
54708
|
+
return "7.0.0-beta.8";
|
|
54397
54709
|
};
|
|
54398
54710
|
const VERSION = getVersion();
|
|
54399
54711
|
/**
|
|
@@ -54444,4 +54756,4 @@ const amagi = Client;
|
|
|
54444
54756
|
* GPL-3.0 Licensed
|
|
54445
54757
|
*/
|
|
54446
54758
|
//#endregion
|
|
54447
|
-
export { unwrap as $, douyinFetcher as A, xiaohongshuSign as B, ValidationError as C, parseDmSegMobileReply as Ct, createBoundKuaishouFetcher as D, xiaohongshuFetcher as E, bilibiliFetcher as F, createDouyinRoutes as G, createKuaishouRoutes as H, createBoundBilibiliFetcher as I, bilibiliUtils as J, passport_exports as K, createClient as L, requestPassportQrcode as M, sendPassportVerifyCode as N, kuaishouFetcher as O, validatePassportVerifyCode as P, isSuccess as Q, xiaohongshuUtils as R, ApiError as S, emitNetworkRetry as St, createBoundXiaohongshuFetcher as T,
|
|
54759
|
+
export { unwrap as $, douyinFetcher as A, xiaohongshuSign as B, ValidationError as C, parseDmSegMobileReply as Ct, createBoundKuaishouFetcher as D, xiaohongshuFetcher as E, bilibiliFetcher as F, createDouyinRoutes as G, createKuaishouRoutes as H, createBoundBilibiliFetcher as I, bilibiliUtils as J, passport_exports as K, createClient as L, requestPassportQrcode as M, sendPassportVerifyCode as N, kuaishouFetcher as O, validatePassportVerifyCode as P, isSuccess as Q, xiaohongshuUtils as R, ApiError as S, emitNetworkRetry as St, createBoundXiaohongshuFetcher as T, kuaishouSign as U, kuaishouUtils as V, douyinUtils as W, AmagiThrownError as X, createBilibiliRoutes as Y, isFailure as Z, validateBilibiliParams as _, emitLogError as _t, AdditionalType as a, wbi_sign as at, validateXiaohongshuParams as b, emitLogWarn as bt, createAmagiClient as c, fetchResponse as ct, assertValidBilibiliParams as d, emitApiError as dt, AMAGI_BUS_EVENT_NAMES as et, assertValidDouyinParams as f, emitApiSuccess as ft, createSuccessResponse as g, emitLogDebug as gt, createErrorResponse as h, emitLog as ht, DynamicType as i, qtparam as it, checkPassportQrcode as j, createBoundDouyinFetcher as k, kuaishouApiUrls as l, isNetworkErrorResult as lt, assertValidXiaohongshuParams as m, emitHttpResponse as mt, CreateApp as n, KUAISHOU_PC_CAPTCHA_RESULT as nt, MajorType as o, bilibiliApiUrls as ot, assertValidKuaishouParams as p, emitHttpRequest as pt, douyinSign as q, amagi as r, parseKuaishouCaptcha as rt, CommentType as s, fetchData as st, Client as t, KUAISHOU_H5_CAPTCHA_RESULT as tt, douyinApiUrls as u, amagiEvents as ut, validateDouyinParams as v, emitLogInfo as vt, handleError as w, xiaohongshuApiUrls as x, emitNetworkError as xt, validateKuaishouParams as y, emitLogMark as yt, createXiaohongshuRoutes as z };
|