karin-plugin-kkk 2.42.1 → 2.42.3
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/CHANGELOG.md +38 -0
- package/lib/build-metadata.json +5 -5
- package/lib/core_chunk/amagi.d.mts +589 -1
- package/lib/core_chunk/amagi.js +2 -2
- package/lib/core_chunk/main.js +2343 -644
- package/lib/core_chunk/vendor.js +1609 -743
- package/lib/root.js +1 -1
- package/lib/style.css +4 -0
- package/lib/web/assets/CronEditor-OPCDvfgS.js +1 -0
- package/lib/web/assets/{DesktopLayout-CRQdcmvy.js → DesktopLayout-BoYn3eKY.js} +1 -1
- package/lib/web/assets/{MobileLayout-ClOVwJEp.js → MobileLayout-U_BkCpN-.js} +1 -1
- package/lib/web/assets/{ThemeSwitch-DxWaxUmy.js → ThemeSwitch-BMYnI5zS.js} +1 -1
- package/lib/web/assets/index-Dj-StEiS.css +2 -0
- package/lib/web/assets/index-DlKtDaUN.js +47 -0
- package/lib/web/assets/{separator-B6VKhTtv.js → separator-ugQv-WRE.js} +1 -1
- package/lib/web/index.html +2 -2
- package/package.json +3 -4
- package/lib/web/assets/CronEditor-CoI7gVfa.js +0 -1
- package/lib/web/assets/index-CScw9ngf.js +0 -47
- package/lib/web/assets/index-D39hMurO.css +0 -2
package/lib/core_chunk/main.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
globalThis.__KTR_BUNDLED__ = true;
|
|
2
|
-
import { i as __toESM } from "./rolldown-runtime.js";
|
|
2
|
+
import { i as __toESM, n as __exportAll } from "./rolldown-runtime.js";
|
|
3
3
|
import { $ as RiTrophyFill, $n as Camera, $t as Share2, A as FaMusic, An as Gamepad2, At as format, B as RiGroupLine, Bn as Copy, Bt as Upload, C as react, Cn as LayoutTemplate, Cr as zod_default, Ct as a$1, D as MdLocationOn, Dn as Hash, Dt as parse, E as MdLightbulbOutline, En as Heart, Et as zhCN, F as AiFillPushpin, Fn as ExternalLink, Ft as Video, G as RiLiveLine, Gn as CircleQuestionMark, Gt as Star, H as RiHeart3Fill, Hn as Clock, Ht as Trash2, I as AiFillStar, In as Download, It as Users, J as RiShareForwardFill, Jn as CircleCheck, Jt as Sparkles, K as RiMessage3Fill, Kn as CircleFadingArrowUp, Kt as Square, L as AiOutlineVideoCamera, Ln as Crown, Lt as UsersRound, M as FaUserGroup, Mn as FilePlay, Mt as Zap, N as BiImage, Nn as Eye, Nt as X, O as MdSchedule, On as GitBranch, Ot as fromUnixTime, P as AiFillHeart, Pn as EyeOff, Pt as WandSparkles, Q as RiTiktokFill, Qn as ChartColumn, Qt as ShieldCheck, R as Markdown, Rn as Cpu, Rt as User, S as sqlite, Sn as Link, Sr as Xhshow, St as e, T as MdInfoOutline, Tn as Image$1, Tt as c, U as RiHeart3Line, Un as Clock3, Ut as Terminal, V as RiHashtag, Vn as Code, Vt as TriangleAlert, W as RiHeartLine, Wn as Clapperboard, Wt as Sun, X as RiStarLine, Xn as CircleAlert, Xt as ShoppingBag, Y as RiStarFill, Yn as CircleCheckBig, Yt as Smartphone, Z as RiThumbUpFill, Zn as Check, Zt as Shield, _ as webassembly, _n as MessageCircle, _r as defineTemplate, _t as o, an as RefreshCw, ar as BellRing, at as SiSamsung, b as tsdown, bn as MapPin, br as CryptoConfig, bt as a, c as require_heic_decode, cn as QrCode, cr as Button, ct as SiHuawei, d as VictoryLine, dn as Play, dr as require_jsx_runtime, dt as SiGithub, en as Settings2, er as Calendar, et as RiUserFollowLine, f as VictoryChart, fn as Pencil, fr as createTemplateRenderer, ft as SiBilibili, g as rehypeHighlight, gn as Monitor, gr as HtmlWrapper, gt as r, h as VictoryTheme, hn as Moon, hr as require_react, ht as o$3, in as RotateCcw, ir as BookOpenText, it as SiVivo, j as FaTiktok, jn as FileText, jt as differenceInSeconds, k as FaCommentDots, kn as Gift, kt as formatDistanceToNow, l as VictoryScatter, ln as Puzzle, lr as twMerge, lt as SiHonor, m as VictoryLabel, mn as Music, mr as require_server_node, mt as SiAnthropic, n as require_lib, nn as ScanLine, nr as Bot, nt as RiVideoLine, on as Radio, or as ArrowDownToLine, ot as SiOppo, p as VictoryAxis, pn as Palette, pr as resolveTemplateStyle, pt as SiApple, q as RiPieChart2Fill, qn as CircleEllipsis, qt as SquarePen, rn as Save, rr as Bookmark, rt as SiXiaomi, s as require_jpeg_js, sn as Quote, sr as Chip, st as SiOneplus, t as createProxyMiddleware, tn as Search, tr as Box, tt as RiVerifiedBadgeFill, u as VictoryPie, un as Plus, ur as clsx, ut as SiGooglephotos, v as vite, vn as Menu, vr as Chalk, vt as o$1, w as MdFitScreen, wn as Info, wt as o$2, x as tailwind_css, xn as LoaderCircle, xr as FingerprintGenerator, xt as n, y as typescript, yn as Maximize, yr as require_protobufjs, yt as m, z as RiArrowRightFill, zn as CornerDownLeft, zt as UserPlus } from "./vendor.js";
|
|
4
4
|
import "node:module";
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import path, { resolve } from "node:path";
|
|
7
7
|
import URL$2, { fileURLToPath } from "node:url";
|
|
8
|
-
import karin, { BOT_CONNECT, app, authMiddleware, checkPkgUpdate, checkPort, common, config, createBadRequestResponse, createNotFoundResponse, createServerErrorResponse, createSuccessResponse, db, defineConfig, ffmpeg, ffprobe, hooks, isDocker, karin as karin$1, karinPathHtml,
|
|
8
|
+
import karin, { BOT_CONNECT, app, authMiddleware, checkPkgUpdate, checkPort, common, config, createBadRequestResponse, createNotFoundResponse, createServerErrorResponse, createSuccessResponse, db, defineConfig, ffmpeg, ffprobe, hooks, isDocker, karin as karin$1, karinPathHtml, logger, logs, mkdirSync, parseChangelog, range, render, restart, segment, updatePkg, watch } from "node-karin";
|
|
9
9
|
import { EventEmitter } from "node:events";
|
|
10
10
|
import crypto, { createCipheriv, createHash, randomBytes, randomUUID } from "node:crypto";
|
|
11
11
|
import axios, { AxiosError } from "node-karin/axios";
|
|
12
12
|
import express from "node-karin/express";
|
|
13
|
-
import os
|
|
14
|
-
import { karinPathBase, karinPathTemp
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import { karinPathBase, karinPathTemp } from "node-karin/root";
|
|
15
15
|
import sqlite3 from "node-karin/sqlite3";
|
|
16
16
|
import YAML from "node-karin/yaml";
|
|
17
17
|
import util from "node:util";
|
|
@@ -21,8 +21,6 @@ import { pipeline } from "node:stream/promises";
|
|
|
21
21
|
import { inflateSync } from "node:zlib";
|
|
22
22
|
import { embedWatermarkToPngBytes } from "@ikenxuan/watermark";
|
|
23
23
|
import _ from "node-karin/lodash";
|
|
24
|
-
import { snapka } from "@karinjs/plugin-puppeteer";
|
|
25
|
-
import { newInjectedPage } from "fingerprint-injector";
|
|
26
24
|
//#region src/index.ts
|
|
27
25
|
globalThis.__kkkLoadStart ??= process.hrtime.bigint();
|
|
28
26
|
import("./setup.js");
|
|
@@ -2176,140 +2174,1410 @@ var bilibiliFetcher$1 = {
|
|
|
2176
2174
|
fetchEmojiList: fetchEmojiList$3
|
|
2177
2175
|
};
|
|
2178
2176
|
//#endregion
|
|
2179
|
-
//#region ../amagi/packages/core/src/platform/
|
|
2177
|
+
//#region ../amagi/packages/core/src/platform/douyin/passport/sm3.ts
|
|
2180
2178
|
/**
|
|
2181
|
-
*
|
|
2182
|
-
*
|
|
2183
|
-
*
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2179
|
+
* SM3 摘要(GM/T 0004-2012),抖音 bdms 签名链使用的变体
|
|
2180
|
+
*
|
|
2181
|
+
* 与标准实现的唯一差异:字符串按 `charCodeAt` 逐字符取字节(非 UTF-8 编码),
|
|
2182
|
+
* 与浏览器里 bdms 的 `strToBytes` 行为一致。签名输入均为 ASCII,实际不会踩到多字节分支,
|
|
2183
|
+
* 但仍保留该分支以保证与浏览器实现逐位一致。
|
|
2184
|
+
*/
|
|
2185
|
+
/** 循环左移 32 位 */
|
|
2186
|
+
var rotl = (x, n) => {
|
|
2187
|
+
const shift = n % 32;
|
|
2188
|
+
return (x << shift | x >>> 32 - shift) >>> 0;
|
|
2189
|
+
};
|
|
2190
|
+
/** 轮常量 Tj */
|
|
2191
|
+
var tj = (j) => j < 16 ? 2043430169 : 2055708042;
|
|
2192
|
+
/** 布尔函数 FFj */
|
|
2193
|
+
var ff = (j, x, y, z) => j < 16 ? (x ^ y ^ z) >>> 0 : (x & y | x & z | y & z) >>> 0;
|
|
2194
|
+
/** 布尔函数 GGj */
|
|
2195
|
+
var gg = (j, x, y, z) => j < 16 ? (x ^ y ^ z) >>> 0 : (x & y | ~x & z) >>> 0;
|
|
2196
|
+
/** 初始向量 IV */
|
|
2197
|
+
var IV = [
|
|
2198
|
+
1937774191,
|
|
2199
|
+
1226093241,
|
|
2200
|
+
388252375,
|
|
2201
|
+
3666478592,
|
|
2202
|
+
2842636476,
|
|
2203
|
+
372324522,
|
|
2204
|
+
3817729613,
|
|
2205
|
+
2969243214
|
|
2206
|
+
];
|
|
2207
|
+
/** 字符串转字节数组:逐字符 charCodeAt,大于一字节时按大端拆分 */
|
|
2208
|
+
var strToBytes = (input) => {
|
|
2209
|
+
const bytes = [];
|
|
2210
|
+
for (let i = 0; i < input.length; i++) {
|
|
2211
|
+
let code = input.charCodeAt(i);
|
|
2212
|
+
const chunk = [];
|
|
2213
|
+
do {
|
|
2214
|
+
chunk.push(code & 255);
|
|
2215
|
+
code >>= 8;
|
|
2216
|
+
} while (code);
|
|
2217
|
+
bytes.push(...chunk.reverse());
|
|
2218
|
+
}
|
|
2219
|
+
return bytes;
|
|
2220
|
+
};
|
|
2221
|
+
/** 消息扩展:生成 W[0..67] 与 W'[0..63](后者放在 68 之后) */
|
|
2222
|
+
var expand = (block) => {
|
|
2223
|
+
const w = new Array(132);
|
|
2224
|
+
for (let i = 0; i < 16; i++) w[i] = (block[i * 4] << 24 | block[i * 4 + 1] << 16 | block[i * 4 + 2] << 8 | block[i * 4 + 3]) >>> 0;
|
|
2225
|
+
for (let j = 16; j < 68; j++) {
|
|
2226
|
+
let x = w[j - 16] ^ w[j - 9] ^ rotl(w[j - 3], 15);
|
|
2227
|
+
x = x ^ rotl(x, 15) ^ rotl(x, 23);
|
|
2228
|
+
w[j] = (x ^ rotl(w[j - 13], 7) ^ w[j - 6]) >>> 0;
|
|
2229
|
+
}
|
|
2230
|
+
for (let j = 0; j < 64; j++) w[j + 68] = (w[j] ^ w[j + 4]) >>> 0;
|
|
2231
|
+
return w;
|
|
2232
|
+
};
|
|
2233
|
+
/** SM3 压缩函数:就地更新寄存器 */
|
|
2234
|
+
var compress = (reg, block) => {
|
|
2235
|
+
const w = expand(block);
|
|
2236
|
+
const r = reg.slice();
|
|
2237
|
+
for (let j = 0; j < 64; j++) {
|
|
2238
|
+
let ss1 = rotl(r[0], 12) + r[4] + rotl(tj(j), j) & 4294967295;
|
|
2239
|
+
ss1 = rotl(ss1 >>> 0, 7);
|
|
2240
|
+
const ss2 = (ss1 ^ rotl(r[0], 12)) >>> 0;
|
|
2241
|
+
const tt1 = ff(j, r[0], r[1], r[2]) + r[3] + ss2 + w[j + 68] >>> 0;
|
|
2242
|
+
const tt2 = gg(j, r[4], r[5], r[6]) + r[7] + ss1 + w[j] >>> 0;
|
|
2243
|
+
r[3] = r[2];
|
|
2244
|
+
r[2] = rotl(r[1], 9);
|
|
2245
|
+
r[1] = r[0];
|
|
2246
|
+
r[0] = tt1;
|
|
2247
|
+
r[7] = r[6];
|
|
2248
|
+
r[6] = rotl(r[5], 19);
|
|
2249
|
+
r[5] = r[4];
|
|
2250
|
+
r[4] = (tt2 ^ rotl(tt2, 9) ^ rotl(tt2, 17)) >>> 0;
|
|
2251
|
+
}
|
|
2252
|
+
for (let i = 0; i < 8; i++) reg[i] = (reg[i] ^ r[i]) >>> 0;
|
|
2253
|
+
};
|
|
2254
|
+
/** 按 SM3 规则填充消息(0x80 + 0 + 64 位比特长度) */
|
|
2255
|
+
var pad = (bytes) => {
|
|
2256
|
+
const padded = bytes.slice();
|
|
2257
|
+
const bitLength = bytes.length * 8;
|
|
2258
|
+
padded.push(128);
|
|
2259
|
+
while (padded.length % 64 !== 56) padded.push(0);
|
|
2260
|
+
const high = Math.floor(bitLength / 4294967296);
|
|
2261
|
+
for (let i = 0; i < 4; i++) padded.push(high >>> (3 - i) * 8 & 255);
|
|
2262
|
+
for (let i = 0; i < 4; i++) padded.push(bitLength >>> (3 - i) * 8 & 255);
|
|
2263
|
+
return padded;
|
|
2264
|
+
};
|
|
2265
|
+
/**
|
|
2266
|
+
* 计算 SM3 摘要
|
|
2267
|
+
* @param message 待摘要的字符串或字节数组
|
|
2268
|
+
* @returns 32 字节摘要
|
|
2269
|
+
*/
|
|
2270
|
+
var sm3 = (message) => {
|
|
2271
|
+
const bytes = typeof message === "string" ? strToBytes(message) : message;
|
|
2272
|
+
const reg = IV.slice();
|
|
2273
|
+
const padded = pad(bytes);
|
|
2274
|
+
for (let i = 0; i < padded.length; i += 64) compress(reg, padded.slice(i, i + 64));
|
|
2275
|
+
const digest = new Array(32);
|
|
2276
|
+
for (let i = 0; i < 8; i++) {
|
|
2277
|
+
digest[i * 4] = reg[i] >>> 24 & 255;
|
|
2278
|
+
digest[i * 4 + 1] = reg[i] >>> 16 & 255;
|
|
2279
|
+
digest[i * 4 + 2] = reg[i] >>> 8 & 255;
|
|
2280
|
+
digest[i * 4 + 3] = reg[i] & 255;
|
|
2281
|
+
}
|
|
2282
|
+
return digest;
|
|
2189
2283
|
};
|
|
2190
2284
|
/**
|
|
2191
|
-
*
|
|
2192
|
-
* @param
|
|
2193
|
-
* @
|
|
2194
|
-
* @returns 合并后的请求配置
|
|
2285
|
+
* 连续两次 SM3(bdms 对 URL 与盐值的处理方式)
|
|
2286
|
+
* @param message 待摘要的字符串或字节数组
|
|
2287
|
+
* @returns 32 字节摘要
|
|
2195
2288
|
*/
|
|
2196
|
-
var
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2289
|
+
var sm3Twice = (message) => sm3(sm3(message));
|
|
2290
|
+
/**
|
|
2291
|
+
* 十六进制摘要,仅用于测试与排查
|
|
2292
|
+
* @param message 待摘要的字符串或字节数组
|
|
2293
|
+
*/
|
|
2294
|
+
var sm3Hex = (message) => sm3(message).map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2295
|
+
//#endregion
|
|
2296
|
+
//#region ../amagi/packages/core/src/platform/douyin/passport/aBogus.ts
|
|
2297
|
+
/**
|
|
2298
|
+
* a_bogus 签名(bdms 1.0.1.19 形态,passport 登录接口使用)
|
|
2299
|
+
*
|
|
2300
|
+
* 抖音同时在线着多个 a_bogus 版本:`@ikenxuan/amagi` 里带的是 web 数据接口用的旧版
|
|
2301
|
+
* (盐值 `cus`、pageId 6241),而 login.douyin.com 的 passport SDK 用的是本文件实现的
|
|
2302
|
+
* 新版(盐值 `dhzx`、pageId 7571、sdkVersion 1.0.1.19-fix.01)。两者互不通用,
|
|
2303
|
+
* 所以这里单独实现一份,不复用 amagi 的签名。
|
|
2304
|
+
*
|
|
2305
|
+
* 算法为社区公开的逆向结论(见 PR 说明中的参考链接),此处按 kkk 的代码风格重写:
|
|
2306
|
+
* - SM3 为抖音变体(连续两次摘要),见 ./sm3
|
|
2307
|
+
* - RC4 为变体:S 盒递减初始化 + `j = (j * S[i] + j + K[i]) % 256`
|
|
2308
|
+
* - Base64 使用自定义字符表,UA 段用 s3、最终结果用 s4
|
|
2309
|
+
* - `random()` 保留了原实现里 `Math.random`(函数对象而非调用)参与位运算的行为,
|
|
2310
|
+
* 该表达式恒为 NaN → 位运算结果恒为 0,这里直接写成常量而不是照抄错误代码
|
|
2311
|
+
*/
|
|
2312
|
+
/** RC4 / S 盒长度 */
|
|
2313
|
+
var BOX_SIZE = 256;
|
|
2314
|
+
/** bdms 1.0.1.19 的盐值 */
|
|
2315
|
+
var SALT = "dhzx";
|
|
2316
|
+
/** bdms SDK 版本号,同时也是 passport 通用参数里的 p_bd */
|
|
2317
|
+
var BDMS_SDK_VERSION = "1.0.1.19-fix.01";
|
|
2318
|
+
/** 版本号基准时间戳(bdms 内部按 14 天为一档计数) */
|
|
2319
|
+
var VERSION_EPOCH = 17218368e5;
|
|
2320
|
+
/** 模块加载时刻,等价于浏览器里的「进入页面时间」 */
|
|
2321
|
+
var ENTER_PAGE_TS = Date.now();
|
|
2322
|
+
/** 自定义 Base64 字符表 */
|
|
2323
|
+
var BASE64_TABLES = {
|
|
2324
|
+
s0: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
|
|
2325
|
+
s1: "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
|
|
2326
|
+
s2: "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
|
|
2327
|
+
s3: "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe",
|
|
2328
|
+
s4: "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe"
|
|
2329
|
+
};
|
|
2330
|
+
/**
|
|
2331
|
+
* 浏览器环境快照。服务器上没有真实窗口,这里给出一组常见的桌面分辨率组合;
|
|
2332
|
+
* 该值只影响指纹内容本身,不需要与任何真实设备对应。
|
|
2333
|
+
*/
|
|
2334
|
+
var BROWSER_ENV = {
|
|
2335
|
+
innerWidth: 2048,
|
|
2336
|
+
innerHeight: 960,
|
|
2337
|
+
outerWidth: 2554,
|
|
2338
|
+
outerHeight: 1386,
|
|
2339
|
+
availWidth: 2560,
|
|
2340
|
+
availHeight: 1392,
|
|
2341
|
+
sizeWidth: 2560,
|
|
2342
|
+
sizeHeight: 1440,
|
|
2343
|
+
platform: "Win32"
|
|
2344
|
+
};
|
|
2345
|
+
/** RC4 密钥:bdms 用 `[1 / 256, 1 % 256, 14 % 256]` 造出 "\x00\x01\x0e" */
|
|
2346
|
+
var rc4Key = String.fromCharCode(...[
|
|
2347
|
+
1 / BOX_SIZE,
|
|
2348
|
+
1 % BOX_SIZE,
|
|
2349
|
+
14 % BOX_SIZE
|
|
2350
|
+
]);
|
|
2351
|
+
/**
|
|
2352
|
+
* 变体 RC4
|
|
2353
|
+
* @param key 密钥
|
|
2354
|
+
* @param text 明文(按 charCode 处理)
|
|
2355
|
+
*/
|
|
2356
|
+
var rc4 = (key, text) => {
|
|
2357
|
+
const s = new Uint8Array(BOX_SIZE);
|
|
2358
|
+
const k = new Uint8Array(BOX_SIZE);
|
|
2359
|
+
for (let i = 0; i < BOX_SIZE; i++) {
|
|
2360
|
+
s[i] = BOX_SIZE - 1 - i;
|
|
2361
|
+
k[i] = key.charCodeAt(i % key.length);
|
|
2362
|
+
}
|
|
2363
|
+
let j = 0;
|
|
2364
|
+
for (let i = 0; i < BOX_SIZE; i++) {
|
|
2365
|
+
j = (j * s[i] + j + k[i]) % BOX_SIZE;
|
|
2366
|
+
[s[i], s[j]] = [s[j], s[i]];
|
|
2367
|
+
}
|
|
2368
|
+
let x = 0;
|
|
2369
|
+
let y = 0;
|
|
2370
|
+
let cipher = "";
|
|
2371
|
+
for (let n = 0; n < text.length; n++) {
|
|
2372
|
+
x = (x + 1) % BOX_SIZE;
|
|
2373
|
+
y = (y + s[x]) % BOX_SIZE;
|
|
2374
|
+
[s[x], s[y]] = [s[y], s[x]];
|
|
2375
|
+
cipher += String.fromCharCode(text.charCodeAt(n) ^ s[(s[x] + s[y]) % BOX_SIZE]);
|
|
2376
|
+
}
|
|
2377
|
+
return cipher;
|
|
2378
|
+
};
|
|
2379
|
+
/**
|
|
2380
|
+
* 自定义字符表 Base64
|
|
2381
|
+
* @param input 明文(按 charCode 取低 8 位)
|
|
2382
|
+
* @param table 字符表名(s0 ~ s4)
|
|
2383
|
+
*/
|
|
2384
|
+
var base64 = (input, table) => {
|
|
2385
|
+
const alphabet = BASE64_TABLES[table];
|
|
2386
|
+
let output = "";
|
|
2387
|
+
let i = 0;
|
|
2388
|
+
while (i < input.length) {
|
|
2389
|
+
const c1 = input.charCodeAt(i++);
|
|
2390
|
+
const c2 = input.charCodeAt(i++);
|
|
2391
|
+
const c3 = input.charCodeAt(i++);
|
|
2392
|
+
const chunk = (c1 & 255) << 16 | (Number.isNaN(c2) ? 0 : (c2 & 255) << 8) | (Number.isNaN(c3) ? 0 : c3 & 255);
|
|
2393
|
+
output += alphabet.charAt(chunk >> 18 & 63);
|
|
2394
|
+
output += alphabet.charAt(chunk >> 12 & 63);
|
|
2395
|
+
output += Number.isNaN(c2) ? "=" : alphabet.charAt(chunk >> 6 & 63);
|
|
2396
|
+
output += Number.isNaN(c3) ? "=" : alphabet.charAt(chunk & 63);
|
|
2397
|
+
}
|
|
2398
|
+
return output;
|
|
2399
|
+
};
|
|
2400
|
+
/**
|
|
2401
|
+
* 生成 4 字节随机混淆段
|
|
2402
|
+
* @param seed 两字节的基准值
|
|
2403
|
+
* @param flag 0 = 完全随机;1 = 高位固定为 0;2 = 低位受限、高位固定为 178
|
|
2404
|
+
*/
|
|
2405
|
+
var mix = (seed, flag) => {
|
|
2406
|
+
const r = Math.random() * 65535 | 0;
|
|
2407
|
+
let low = r & 255;
|
|
2408
|
+
let high = r >> 8 & 255;
|
|
2409
|
+
if (flag === 1) high = 0;
|
|
2410
|
+
if (flag === 2) {
|
|
2411
|
+
low = Math.random() * 240 >> 0;
|
|
2412
|
+
if (low > 109) low += low % 2 + 1;
|
|
2413
|
+
high = 178;
|
|
2414
|
+
}
|
|
2415
|
+
return [
|
|
2416
|
+
low & 170 | seed[0] & 85,
|
|
2417
|
+
low & 85 | seed[0] & 170,
|
|
2418
|
+
high & 170 | seed[1] & 85,
|
|
2419
|
+
high & 85 | seed[1] & 170
|
|
2420
|
+
];
|
|
2421
|
+
};
|
|
2422
|
+
/** SDK 版本号拆成数字数组,非纯数字段(如 `19-fix`)取 0 */
|
|
2423
|
+
var versionSegments = (version) => version.split(".").map((segment) => ~~Number(segment));
|
|
2424
|
+
/** 每 3 字节扩成 4 字节,掺入随机位 */
|
|
2425
|
+
var spread = (bytes) => {
|
|
2426
|
+
const masks = [
|
|
2427
|
+
145,
|
|
2428
|
+
110,
|
|
2429
|
+
66,
|
|
2430
|
+
189,
|
|
2431
|
+
44,
|
|
2432
|
+
211
|
|
2433
|
+
];
|
|
2434
|
+
const out = [];
|
|
2435
|
+
for (let i = 0; i < bytes.length; i += 3) {
|
|
2436
|
+
if (i + 2 >= bytes.length) {
|
|
2437
|
+
out.push(bytes[i]);
|
|
2438
|
+
if (bytes[i + 1] !== void 0) out.push(bytes[i + 1]);
|
|
2439
|
+
continue;
|
|
2440
|
+
}
|
|
2441
|
+
const noise = Math.random() * 1e3 & 255;
|
|
2442
|
+
out.push(noise & masks[0] | bytes[i] & masks[1], noise & masks[2] | bytes[i + 1] & masks[3], noise & masks[4] | bytes[i + 2] & masks[5], bytes[i] & masks[0] | bytes[i + 1] & masks[2] | bytes[i + 2] & masks[4]);
|
|
2443
|
+
}
|
|
2444
|
+
return out;
|
|
2445
|
+
};
|
|
2446
|
+
/**
|
|
2447
|
+
* 生成 a_bogus
|
|
2448
|
+
* @param query 除 a_bogus 之外的完整查询串(未加 `?`,保持实际发送顺序)
|
|
2449
|
+
* @param userAgent 与请求头一致的 UA
|
|
2450
|
+
* @returns a_bogus 参数值(未做 URL 编码)
|
|
2451
|
+
*/
|
|
2452
|
+
var aBogus = (query, userAgent) => {
|
|
2453
|
+
const queryDigest = sm3Twice(query.endsWith(SALT) ? query : query + SALT);
|
|
2454
|
+
const saltDigest = sm3Twice(SALT);
|
|
2455
|
+
const uaDigest = sm3(base64(rc4(rc4Key, userAgent), "s3"));
|
|
2456
|
+
const now = Date.now();
|
|
2457
|
+
const ink = now - 1;
|
|
2458
|
+
/** 指纹字节表,下标沿用 bdms 内部编号,便于与逆向资料对照 */
|
|
2459
|
+
const b = {};
|
|
2460
|
+
b[24] = 41;
|
|
2461
|
+
b[26] = (now - VERSION_EPOCH) / 1e3 / 60 / 60 / 24 / 14 >> 0;
|
|
2462
|
+
b[27] = 6;
|
|
2463
|
+
b[28] = now - ENTER_PAGE_TS + 3 & 255;
|
|
2464
|
+
b[29] = now & 255;
|
|
2465
|
+
b[30] = now >> 8 & 255;
|
|
2466
|
+
b[31] = now >> 16 & 255;
|
|
2467
|
+
b[32] = now >> 24 & 255;
|
|
2468
|
+
b[33] = now / 2 ** 32 & 255;
|
|
2469
|
+
b[34] = now / 2 ** 40 & 255;
|
|
2470
|
+
b[35] = 1;
|
|
2471
|
+
b[36] = 0;
|
|
2472
|
+
b[38] = 129;
|
|
2473
|
+
b[39] = 0;
|
|
2474
|
+
b[40] = 0;
|
|
2475
|
+
b[41] = 0;
|
|
2476
|
+
b[42] = 0;
|
|
2477
|
+
b[43] = 0;
|
|
2478
|
+
b[44] = 14;
|
|
2479
|
+
b[45] = 0;
|
|
2480
|
+
b[46] = 0;
|
|
2481
|
+
b[47] = 0;
|
|
2482
|
+
b[48] = queryDigest[9];
|
|
2483
|
+
b[49] = queryDigest[18];
|
|
2484
|
+
b[51] = queryDigest[3];
|
|
2485
|
+
b[52] = saltDigest[10];
|
|
2486
|
+
b[53] = saltDigest[19];
|
|
2487
|
+
b[55] = saltDigest[4];
|
|
2488
|
+
b[56] = uaDigest[11];
|
|
2489
|
+
b[57] = uaDigest[21];
|
|
2490
|
+
b[59] = uaDigest[5];
|
|
2491
|
+
b[60] = ink & 255;
|
|
2492
|
+
b[61] = ink >> 8 & 255;
|
|
2493
|
+
b[62] = ink >> 16 & 255;
|
|
2494
|
+
b[63] = ink >> 24 & 255;
|
|
2495
|
+
b[64] = ink / 2 ** 32 & 255;
|
|
2496
|
+
b[65] = ink / 2 ** 40 & 255;
|
|
2497
|
+
b[66] = 3;
|
|
2498
|
+
b[67] = 147;
|
|
2499
|
+
b[68] = 29;
|
|
2500
|
+
b[69] = 0;
|
|
2501
|
+
b[70] = 0;
|
|
2502
|
+
b[71] = 239;
|
|
2503
|
+
b[72] = 24;
|
|
2504
|
+
b[73] = 0;
|
|
2505
|
+
b[74] = 0;
|
|
2506
|
+
const envSnapshot = Object.values(BROWSER_ENV).join("|");
|
|
2507
|
+
const envBytes = Array.from(envSnapshot, (char) => char.charCodeAt(0));
|
|
2508
|
+
b[79] = envBytes.length & 255;
|
|
2509
|
+
b[80] = envBytes.length >> 8 & 255;
|
|
2510
|
+
const tail = `${now + 3 & 255},`;
|
|
2511
|
+
const tailBytes = Array.from(tail, (char) => char.charCodeAt(0));
|
|
2512
|
+
b[84] = tailBytes.length & 255;
|
|
2513
|
+
b[85] = tailBytes.length >> 8 & 255;
|
|
2514
|
+
const version = versionSegments(BDMS_SDK_VERSION);
|
|
2515
|
+
const noise = mix([version[0], version[1]], 0).concat(mix([version[0], version[1]], 2));
|
|
2516
|
+
const checksum = [
|
|
2517
|
+
24,
|
|
2518
|
+
26,
|
|
2519
|
+
27,
|
|
2520
|
+
28,
|
|
2521
|
+
29,
|
|
2522
|
+
30,
|
|
2523
|
+
31,
|
|
2524
|
+
32,
|
|
2525
|
+
33,
|
|
2526
|
+
34,
|
|
2527
|
+
35,
|
|
2528
|
+
36,
|
|
2529
|
+
38,
|
|
2530
|
+
39,
|
|
2531
|
+
40,
|
|
2532
|
+
41,
|
|
2533
|
+
42,
|
|
2534
|
+
43,
|
|
2535
|
+
44,
|
|
2536
|
+
45,
|
|
2537
|
+
46,
|
|
2538
|
+
47,
|
|
2539
|
+
48,
|
|
2540
|
+
49,
|
|
2541
|
+
51,
|
|
2542
|
+
52,
|
|
2543
|
+
53,
|
|
2544
|
+
55,
|
|
2545
|
+
56,
|
|
2546
|
+
57,
|
|
2547
|
+
59,
|
|
2548
|
+
60,
|
|
2549
|
+
61,
|
|
2550
|
+
62,
|
|
2551
|
+
63,
|
|
2552
|
+
64,
|
|
2553
|
+
65,
|
|
2554
|
+
66,
|
|
2555
|
+
67,
|
|
2556
|
+
68,
|
|
2557
|
+
69,
|
|
2558
|
+
70,
|
|
2559
|
+
71,
|
|
2560
|
+
72,
|
|
2561
|
+
73,
|
|
2562
|
+
74,
|
|
2563
|
+
79,
|
|
2564
|
+
80,
|
|
2565
|
+
84,
|
|
2566
|
+
85
|
|
2567
|
+
].reduce((acc, key) => acc ^ b[key], noise.reduce((acc, value) => acc ^ value, 0));
|
|
2568
|
+
const payload = spread([
|
|
2569
|
+
34,
|
|
2570
|
+
44,
|
|
2571
|
+
56,
|
|
2572
|
+
61,
|
|
2573
|
+
73,
|
|
2574
|
+
29,
|
|
2575
|
+
70,
|
|
2576
|
+
45,
|
|
2577
|
+
35,
|
|
2578
|
+
49,
|
|
2579
|
+
38,
|
|
2580
|
+
66,
|
|
2581
|
+
51,
|
|
2582
|
+
68,
|
|
2583
|
+
28,
|
|
2584
|
+
48,
|
|
2585
|
+
64,
|
|
2586
|
+
47,
|
|
2587
|
+
30,
|
|
2588
|
+
71,
|
|
2589
|
+
26,
|
|
2590
|
+
55,
|
|
2591
|
+
31,
|
|
2592
|
+
69,
|
|
2593
|
+
59,
|
|
2594
|
+
40,
|
|
2595
|
+
62,
|
|
2596
|
+
63,
|
|
2597
|
+
27,
|
|
2598
|
+
72,
|
|
2599
|
+
41,
|
|
2600
|
+
74,
|
|
2601
|
+
57,
|
|
2602
|
+
52,
|
|
2603
|
+
42,
|
|
2604
|
+
39,
|
|
2605
|
+
33,
|
|
2606
|
+
67,
|
|
2607
|
+
53,
|
|
2608
|
+
43,
|
|
2609
|
+
65,
|
|
2610
|
+
46,
|
|
2611
|
+
36,
|
|
2612
|
+
24,
|
|
2613
|
+
60,
|
|
2614
|
+
32,
|
|
2615
|
+
79,
|
|
2616
|
+
80,
|
|
2617
|
+
84,
|
|
2618
|
+
85
|
|
2619
|
+
].map((key) => b[key]).concat(envBytes, tailBytes, [checksum]));
|
|
2620
|
+
return base64(String.fromCharCode(...mix([3, 82], 1)) + rc4(String.fromCharCode(211), String.fromCharCode(...noise.concat(payload))), "s4");
|
|
2621
|
+
};
|
|
2622
|
+
//#endregion
|
|
2623
|
+
//#region ../amagi/packages/core/src/platform/douyin/passport/cookieJar.ts
|
|
2624
|
+
/**
|
|
2625
|
+
* 登录流程用的轻量 CookieJar
|
|
2626
|
+
*
|
|
2627
|
+
* 登录过程会跨 `www.douyin.com` / `login.douyin.com` / `ttwid.bytedance.com` 三个域,
|
|
2628
|
+
* 且同一个 cookie 名会被多次下发(例如 `ttwid` 在换取可信指纹后会被替换、
|
|
2629
|
+
* `sessionid` 在二次验证通过后会被升级)。这里只做一件事:**按下发顺序覆盖同名 cookie**,
|
|
2630
|
+
* 保证最终拿到的永远是最后一次下发的值,同时正确处理服务端的删除指令。
|
|
2631
|
+
*
|
|
2632
|
+
* 不做域/路径隔离:整个登录流程都在抖音自己的域下,隔离反而会漏掉跨子域下发的凭证。
|
|
2633
|
+
*
|
|
2634
|
+
* 另外承载一小部分**本地会话状态**(见 `INTERNAL_PREFIX`):passport 的几个接口对外是
|
|
2635
|
+
* 无状态的,会话全靠 cookie 串在调用之间传递,而 bd-ticket-guard 需要在多次调用之间
|
|
2636
|
+
* 记住自己生成的密钥与服务端签发的票据。这些条目以 `__amagi_` 开头,
|
|
2637
|
+
* `toString()` 不会把它们放进 Cookie 请求头,只有 `serialize()` 才会带上。
|
|
2638
|
+
*/
|
|
2639
|
+
/** 本地会话状态的 cookie 名前缀,这些条目永远不会发给服务端 */
|
|
2640
|
+
var INTERNAL_PREFIX = "__amagi_";
|
|
2641
|
+
/** 是否为本地会话状态条目 */
|
|
2642
|
+
var isInternal = (name) => name.startsWith(INTERNAL_PREFIX);
|
|
2643
|
+
/** 判断一条 Set-Cookie 是否表示「删除该 cookie」 */
|
|
2644
|
+
var isDeletion = (value, attributes) => {
|
|
2645
|
+
if (value === "") return true;
|
|
2646
|
+
for (const attribute of attributes) {
|
|
2647
|
+
const [rawName, ...rest] = attribute.split("=");
|
|
2648
|
+
const name = rawName.trim().toLowerCase();
|
|
2649
|
+
const rawValue = rest.join("=").trim();
|
|
2650
|
+
if (name === "max-age") {
|
|
2651
|
+
const maxAge = Number(rawValue);
|
|
2652
|
+
if (Number.isFinite(maxAge) && maxAge <= 0) return true;
|
|
2653
|
+
}
|
|
2654
|
+
if (name === "expires") {
|
|
2655
|
+
const expires = Date.parse(rawValue);
|
|
2656
|
+
if (Number.isFinite(expires) && expires <= Date.now()) return true;
|
|
2657
|
+
}
|
|
2658
|
+
}
|
|
2659
|
+
return false;
|
|
2660
|
+
};
|
|
2661
|
+
var CookieJar = class {
|
|
2662
|
+
/** Map 保留插入顺序,重复 set 只更新值、不改变位置 */
|
|
2663
|
+
cookies = /* @__PURE__ */ new Map();
|
|
2664
|
+
/**
|
|
2665
|
+
* @param initial 初始 cookie 串,形如 `a=1; b=2`
|
|
2666
|
+
*/
|
|
2667
|
+
constructor(initial) {
|
|
2668
|
+
if (initial) this.merge(initial);
|
|
2669
|
+
}
|
|
2670
|
+
/** 当前持有的 cookie 数量 */
|
|
2671
|
+
get size() {
|
|
2672
|
+
return this.cookies.size;
|
|
2673
|
+
}
|
|
2674
|
+
/**
|
|
2675
|
+
* 写入一条 cookie
|
|
2676
|
+
* @param name cookie 名
|
|
2677
|
+
* @param value cookie 值
|
|
2678
|
+
*/
|
|
2679
|
+
set(name, value) {
|
|
2680
|
+
this.cookies.set(name, value);
|
|
2681
|
+
return this;
|
|
2682
|
+
}
|
|
2683
|
+
/**
|
|
2684
|
+
* 读取一条 cookie
|
|
2685
|
+
* @param name cookie 名
|
|
2686
|
+
*/
|
|
2687
|
+
get(name) {
|
|
2688
|
+
return this.cookies.get(name);
|
|
2689
|
+
}
|
|
2690
|
+
/**
|
|
2691
|
+
* 是否持有某条 cookie
|
|
2692
|
+
* @param name cookie 名
|
|
2693
|
+
*/
|
|
2694
|
+
has(name) {
|
|
2695
|
+
return this.cookies.has(name);
|
|
2696
|
+
}
|
|
2697
|
+
/**
|
|
2698
|
+
* 合并一段 `name=value; name=value` 形式的 cookie 串
|
|
2699
|
+
* @param cookieString cookie 串,空值直接忽略
|
|
2700
|
+
*/
|
|
2701
|
+
merge(cookieString) {
|
|
2702
|
+
if (!cookieString) return this;
|
|
2703
|
+
for (const pair of cookieString.split(";")) {
|
|
2704
|
+
const index = pair.indexOf("=");
|
|
2705
|
+
if (index <= 0) continue;
|
|
2706
|
+
const name = pair.slice(0, index).trim();
|
|
2707
|
+
const value = pair.slice(index + 1).trim();
|
|
2708
|
+
if (name && value) this.cookies.set(name, value);
|
|
2709
|
+
}
|
|
2710
|
+
return this;
|
|
2711
|
+
}
|
|
2712
|
+
/**
|
|
2713
|
+
* 应用响应的 Set-Cookie 头
|
|
2714
|
+
* @param setCookies 单条或多条 Set-Cookie 原始值
|
|
2715
|
+
*/
|
|
2716
|
+
applySetCookie(setCookies) {
|
|
2717
|
+
if (!setCookies) return this;
|
|
2718
|
+
for (const line of Array.isArray(setCookies) ? setCookies : [setCookies]) {
|
|
2719
|
+
const [pair, ...attributes] = line.split(";");
|
|
2720
|
+
const index = pair.indexOf("=");
|
|
2721
|
+
if (index <= 0) continue;
|
|
2722
|
+
const name = pair.slice(0, index).trim();
|
|
2723
|
+
const value = pair.slice(index + 1).trim();
|
|
2724
|
+
if (!name) continue;
|
|
2725
|
+
if (isDeletion(value, attributes)) {
|
|
2726
|
+
this.cookies.delete(name);
|
|
2727
|
+
continue;
|
|
2728
|
+
}
|
|
2729
|
+
this.cookies.set(name, value);
|
|
2730
|
+
}
|
|
2731
|
+
return this;
|
|
2732
|
+
}
|
|
2733
|
+
/**
|
|
2734
|
+
* 是否已经拿到登录态凭证(`ttwid` 是匿名设备指纹,不算登录)
|
|
2735
|
+
*/
|
|
2736
|
+
isLoggedIn() {
|
|
2737
|
+
return this.has("sessionid") || this.has("sessionid_ss") || this.has("sid_guard");
|
|
2738
|
+
}
|
|
2739
|
+
/** 序列化为可直接放进 Cookie 请求头的字符串,不含本地会话状态 */
|
|
2740
|
+
toString() {
|
|
2741
|
+
return [...this.cookies].filter(([name]) => !isInternal(name)).map(([name, value]) => `${name}=${value}`).join("; ");
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
2744
|
+
* 序列化为在两次调用之间传递的会话串,包含本地会话状态
|
|
2745
|
+
*
|
|
2746
|
+
* 登录流程内部用这个;最终落库的登录凭证用 `toString()`,避免把本地密钥写进配置。
|
|
2747
|
+
*/
|
|
2748
|
+
serialize() {
|
|
2749
|
+
return [...this.cookies].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
2750
|
+
}
|
|
2751
|
+
/** 导出为普通对象,便于断言与日志 */
|
|
2752
|
+
toJSON() {
|
|
2753
|
+
return Object.fromEntries(this.cookies);
|
|
2754
|
+
}
|
|
2755
|
+
};
|
|
2756
|
+
//#endregion
|
|
2757
|
+
//#region ../amagi/packages/core/src/platform/douyin/passport/params.ts
|
|
2758
|
+
/**
|
|
2759
|
+
* passport 登录 SDK 的参数与签名构造
|
|
2760
|
+
*
|
|
2761
|
+
* login.douyin.com 的接口一共要过四道签名:
|
|
2762
|
+
* - `p_no`:SDK 版本相关参数排序后取 sha256
|
|
2763
|
+
* - `sign`:`排序后的前 10 个 query 参数 & 排序后的 body 参数 & app_key` 取 sha256
|
|
2764
|
+
* - `qs`:参与 sign 的参数名列表逐字节异或 5 后转十六进制
|
|
2765
|
+
* - `x-tt-passport-aid-sign` 请求头:以 appKey 为消息、当日 UTC 正午时间戳为密钥做 HMAC 派生
|
|
2766
|
+
*
|
|
2767
|
+
* 这些常量都是 SDK 自身的版本号与固定 app_key,属于协议的一部分,不含任何设备/账号信息。
|
|
2768
|
+
*/
|
|
2769
|
+
/** passport 登录 SDK 的 app_key */
|
|
2770
|
+
var APP_KEY = "163e7ce78d58971a41f5b969996d85c2";
|
|
2771
|
+
/** 抖音 web 的 aid */
|
|
2772
|
+
var PASSPORT_AID = "6383";
|
|
2773
|
+
/** 登录接口域名 */
|
|
2774
|
+
var LOGIN_HOST = "login.douyin.com";
|
|
2775
|
+
/** 抖音主站域名 */
|
|
2776
|
+
var WEB_HOST = "www.douyin.com";
|
|
2777
|
+
/** 登录 SDK(normal 形态)版本号 */
|
|
2778
|
+
var JSSDK_VERSION = "3.1.3";
|
|
2779
|
+
/** 验证页 SDK(lite 形态)版本号 */
|
|
2780
|
+
var LITE_JSSDK_VERSION = "5.1.2";
|
|
2781
|
+
/** 验证页使用的 authn SDK 版本号 */
|
|
2782
|
+
var LITE_AUTHN_VERSION = "1.0.0.420-web";
|
|
2783
|
+
/** 各子 SDK 版本号,参与 p_no 计算 */
|
|
2784
|
+
var SDK_VERSIONS = {
|
|
2785
|
+
pVer: "1.1.3",
|
|
2786
|
+
pZt: "3.3.14",
|
|
2787
|
+
pUi: "2.1.9-alpha.6",
|
|
2788
|
+
pCa: "4.0.17",
|
|
2789
|
+
pCaReal: "1.0.0.874"
|
|
2790
|
+
};
|
|
2791
|
+
/** 与 aBogus 中 BROWSER_ENV 对应的窗口尺寸,用于生成 account_sdk_source_info */
|
|
2792
|
+
var ENV_VIEWPORT = {
|
|
2793
|
+
innerWidth: 2048,
|
|
2794
|
+
innerHeight: 960,
|
|
2795
|
+
outerWidth: 2554,
|
|
2796
|
+
outerHeight: 1386
|
|
2797
|
+
};
|
|
2798
|
+
var sha256Hex = (input) => crypto.createHash("sha256").update(input, "utf8").digest("hex");
|
|
2799
|
+
var hmacSha256 = (key, message) => crypto.createHmac("sha256", Buffer.from(key)).update(Buffer.from(message)).digest();
|
|
2800
|
+
var hexToBytes = (hex) => Uint8Array.from(hex.match(/.{2}/g)?.map((byte) => parseInt(byte, 16)) ?? []);
|
|
2801
|
+
/**
|
|
2802
|
+
* 逐字节异或 5 后转十六进制,SDK 用它编码参数名列表、验证码与密码
|
|
2803
|
+
* @param input 明文
|
|
2804
|
+
*/
|
|
2805
|
+
var xor5Hex = (input) => Array.from(Buffer.from(input, "utf8"), (byte) => (byte ^ 5).toString(16).padStart(2, "0")).join("");
|
|
2806
|
+
/** 随机十六进制串,用于 biz_trace_id 一类的追踪 ID */
|
|
2807
|
+
var randomHex = (length) => crypto.randomBytes(Math.ceil(length / 2)).toString("hex").slice(0, length);
|
|
2808
|
+
/** 当日 UTC 12:00 的秒级时间戳,aid-sign 以此为密钥基准 */
|
|
2809
|
+
var utcNoonTimestamp = (now = /* @__PURE__ */ new Date()) => Math.floor(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate(), 12, 0, 0, 0) / 1e3);
|
|
2810
|
+
/**
|
|
2811
|
+
* 按 SDK 规则序列化参数:键名排序后拼成 `k=v&k=v`
|
|
2812
|
+
* @param params 参数对象
|
|
2813
|
+
* @param limit 大于等于 0 时只取排序后的前 limit 个键
|
|
2814
|
+
*/
|
|
2815
|
+
var serializeSorted = (params, limit = -1) => {
|
|
2816
|
+
const keys = Object.keys(params).sort();
|
|
2817
|
+
if (limit >= 0) keys.splice(limit);
|
|
2818
|
+
return {
|
|
2819
|
+
text: keys.map((key) => `${key}=${typeof params[key] === "object" ? JSON.stringify(params[key]) : params[key]}`).join("&"),
|
|
2820
|
+
keys
|
|
2213
2821
|
};
|
|
2822
|
+
};
|
|
2823
|
+
/**
|
|
2824
|
+
* 计算 sign 与 qs
|
|
2825
|
+
* @param params query 参数(仅排序后的前 10 个参与签名)
|
|
2826
|
+
* @param data body 参数,GET 请求传空对象
|
|
2827
|
+
*/
|
|
2828
|
+
var makeSignAndQs = (params, data = {}) => {
|
|
2829
|
+
const { text: paramsText, keys } = serializeSorted(params, 10);
|
|
2830
|
+
const { text: dataText } = serializeSorted(data);
|
|
2214
2831
|
return {
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
...requestConfig,
|
|
2218
|
-
headers: {
|
|
2219
|
-
...defHeaders,
|
|
2220
|
-
...requestConfig?.headers ?? {}
|
|
2221
|
-
}
|
|
2832
|
+
sign: sha256Hex(`${paramsText}&${dataText}&app_key=${APP_KEY}`),
|
|
2833
|
+
qs: xor5Hex(keys.join(","))
|
|
2222
2834
|
};
|
|
2223
2835
|
};
|
|
2224
2836
|
/**
|
|
2225
|
-
*
|
|
2226
|
-
* @param
|
|
2227
|
-
* @param requestConfig - 外部请求配置(优先级最高)
|
|
2228
|
-
* @returns 合并后的请求配置
|
|
2837
|
+
* 计算 p_no
|
|
2838
|
+
* @param pTs 毫秒时间戳,与 query 里的 p_ts 保持一致
|
|
2229
2839
|
*/
|
|
2230
|
-
var
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
Pragma: "no-cache",
|
|
2239
|
-
Referer: "https://www.bilibili.com/",
|
|
2240
|
-
Priority: "u=1, i",
|
|
2241
|
-
"Sec-Ch-Ua": generateSecChUa(finalUserAgent),
|
|
2242
|
-
"Sec-Ch-Ua-Mobile": "?0",
|
|
2243
|
-
"Sec-Ch-Ua-Platform": "\"Windows\"",
|
|
2244
|
-
"Sec-Fetch-Dest": "empty",
|
|
2245
|
-
"Sec-Fetch-Mode": "cors",
|
|
2246
|
-
"Sec-Fetch-Site": "same-site",
|
|
2247
|
-
"User-Agent": finalUserAgent,
|
|
2248
|
-
Cookie: cookie?.trim() ?? ""
|
|
2840
|
+
var makePNo = (pTs) => {
|
|
2841
|
+
const parts = {
|
|
2842
|
+
passport_jssdk_version: JSSDK_VERSION,
|
|
2843
|
+
p_bd: BDMS_SDK_VERSION,
|
|
2844
|
+
p_ca: SDK_VERSIONS.pCa,
|
|
2845
|
+
p_ts: pTs,
|
|
2846
|
+
p_ver: SDK_VERSIONS.pVer,
|
|
2847
|
+
p_zt: SDK_VERSIONS.pZt
|
|
2249
2848
|
};
|
|
2849
|
+
return sha256Hex(Object.keys(parts).sort().map((key) => `${key}=${parts[key]}`).join("&"));
|
|
2850
|
+
};
|
|
2851
|
+
/**
|
|
2852
|
+
* HKDF 风格的密钥派生,aid-sign 内部使用
|
|
2853
|
+
* @param keyHex 初始密钥(十六进制)
|
|
2854
|
+
* @param length 输出长度
|
|
2855
|
+
*/
|
|
2856
|
+
var deriveKey = (keyHex, length) => {
|
|
2857
|
+
const output = [];
|
|
2858
|
+
let previous = "";
|
|
2859
|
+
let counter = 0;
|
|
2860
|
+
while (output.length < length) {
|
|
2861
|
+
counter++;
|
|
2862
|
+
const message = Uint8Array.from([...hexToBytes(previous), counter]);
|
|
2863
|
+
previous = hmacSha256(hexToBytes(keyHex), message).toString("hex");
|
|
2864
|
+
output.push(...hexToBytes(previous));
|
|
2865
|
+
}
|
|
2866
|
+
return Uint8Array.from(output.slice(0, length));
|
|
2867
|
+
};
|
|
2868
|
+
/**
|
|
2869
|
+
* 计算 `x-tt-passport-aid-sign` 请求头
|
|
2870
|
+
* @param urlPath 接口路径,如 `/passport/web/get_qrcode/`
|
|
2871
|
+
* @param timestamp 当日 UTC 正午时间戳(秒),默认取当前
|
|
2872
|
+
*/
|
|
2873
|
+
var makeAidSign = (urlPath, timestamp = utcNoonTimestamp()) => {
|
|
2874
|
+
const encoder = new TextEncoder();
|
|
2875
|
+
const ts = String(timestamp);
|
|
2876
|
+
return hmacSha256(deriveKey(hmacSha256(encoder.encode(ts), encoder.encode(APP_KEY)).toString("hex"), 32), encoder.encode(`aid=${PASSPORT_AID}&path=${urlPath}&ts=${ts}`)).toString("hex");
|
|
2877
|
+
};
|
|
2878
|
+
/**
|
|
2879
|
+
* 生成 account_sdk_source_info:SDK 采集的浏览器环境快照,异或 5 后转十六进制。
|
|
2880
|
+
*
|
|
2881
|
+
* 上游参考实现内联的是作者本机抓包值(含显卡型号、堆内存占用、带 query 的个人主页 URL),
|
|
2882
|
+
* 不适合进仓库,这里换成一份等价形态的通用快照。
|
|
2883
|
+
*
|
|
2884
|
+
* 实测服务端在 `get_qrcode` 阶段不校验该字段内容(删掉、置空、填垃圾值都同样返回
|
|
2885
|
+
* `error_code: 0`),保留它只是为了与 SDK 的真实请求形态一致。
|
|
2886
|
+
*/
|
|
2887
|
+
var makeAccountSdkSourceInfo = () => xor5Hex(JSON.stringify({
|
|
2888
|
+
hardwareConcurrency: 8,
|
|
2889
|
+
webdriver: false,
|
|
2890
|
+
chromedriver: false,
|
|
2891
|
+
shelldriver: false,
|
|
2892
|
+
plugins: 5,
|
|
2893
|
+
innerHeight: ENV_VIEWPORT.innerHeight,
|
|
2894
|
+
innerWidth: ENV_VIEWPORT.innerWidth,
|
|
2895
|
+
outerHeight: ENV_VIEWPORT.outerHeight,
|
|
2896
|
+
outerWidth: ENV_VIEWPORT.outerWidth,
|
|
2897
|
+
webgl: {
|
|
2898
|
+
vendor: "Google Inc. (Intel)",
|
|
2899
|
+
renderer: "ANGLE (Intel, Intel(R) UHD Graphics Direct3D11 vs_5_0 ps_5_0, D3D11)"
|
|
2900
|
+
},
|
|
2901
|
+
performance: {
|
|
2902
|
+
timeOrigin: Date.now(),
|
|
2903
|
+
navigationTiming: {
|
|
2904
|
+
entryType: "navigation",
|
|
2905
|
+
initiatorType: "navigation",
|
|
2906
|
+
name: `https://${WEB_HOST}/`,
|
|
2907
|
+
renderBlockingStatus: "non-blocking"
|
|
2908
|
+
}
|
|
2909
|
+
},
|
|
2910
|
+
browser: {
|
|
2911
|
+
bit_protocol: "false",
|
|
2912
|
+
bit_helper: false
|
|
2913
|
+
}
|
|
2914
|
+
}));
|
|
2915
|
+
/**
|
|
2916
|
+
* 构造 passport 登录 SDK 的通用 query 参数
|
|
2917
|
+
*
|
|
2918
|
+
* 参数顺序即实际发送顺序:SDK 各拦截器依次注入,`request_host` 在这里先编码一次,
|
|
2919
|
+
* 拼 URL 时会再编码一次,所以线上抓包看到的是双重编码。
|
|
2920
|
+
* @param extra 业务参数(GET 时并入 query)
|
|
2921
|
+
*/
|
|
2922
|
+
var makeCommonParams = (extra = {}) => {
|
|
2923
|
+
const pTs = String(Date.now());
|
|
2250
2924
|
return {
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2925
|
+
passport_jssdk_version: JSSDK_VERSION,
|
|
2926
|
+
passport_jssdk_type: "normal",
|
|
2927
|
+
is_from_ttaccountsdk: "1",
|
|
2928
|
+
aid: PASSPORT_AID,
|
|
2929
|
+
language: "zh",
|
|
2930
|
+
account_app_language: "zh-CN",
|
|
2931
|
+
ts: String(utcNoonTimestamp()),
|
|
2932
|
+
...Object.fromEntries(Object.entries(extra).map(([key, value]) => [key, String(value)])),
|
|
2933
|
+
is_from_iesaccountsaas: "1",
|
|
2934
|
+
p_ui: SDK_VERSIONS.pUi,
|
|
2935
|
+
p_ca: SDK_VERSIONS.pCa,
|
|
2936
|
+
p_ca_real: SDK_VERSIONS.pCaReal,
|
|
2937
|
+
account_sdk_source: "web",
|
|
2938
|
+
account_sdk_source_info: makeAccountSdkSourceInfo(),
|
|
2939
|
+
p_js_v: JSSDK_VERSION,
|
|
2940
|
+
p_js_t: "pro",
|
|
2941
|
+
p_zt: SDK_VERSIONS.pZt,
|
|
2942
|
+
p_ver: SDK_VERSIONS.pVer,
|
|
2943
|
+
p_ver_real: "0",
|
|
2944
|
+
request_host: encodeURIComponent(`https://${WEB_HOST}`),
|
|
2945
|
+
p_bd: BDMS_SDK_VERSION,
|
|
2946
|
+
p_ts: pTs,
|
|
2947
|
+
p_no: makePNo(pTs),
|
|
2948
|
+
biz_trace_id: randomHex(8),
|
|
2949
|
+
device_platform: "web_app"
|
|
2950
|
+
};
|
|
2951
|
+
};
|
|
2952
|
+
/**
|
|
2953
|
+
* 构造验证页 SDK(lite 形态)的固定 query 参数
|
|
2954
|
+
* @param bizTraceId 业务追踪 ID
|
|
2955
|
+
*/
|
|
2956
|
+
var makeLiteParams = (bizTraceId) => ({
|
|
2957
|
+
passport_jssdk_version: LITE_JSSDK_VERSION,
|
|
2958
|
+
passport_jssdk_type: "lite",
|
|
2959
|
+
is_from_ttaccountsdk: "1",
|
|
2960
|
+
aid: PASSPORT_AID,
|
|
2961
|
+
language: "zh",
|
|
2962
|
+
account_app_language: "zh-CN",
|
|
2963
|
+
new_authn_sdk_version: LITE_AUTHN_VERSION,
|
|
2964
|
+
biz_trace_id: bizTraceId
|
|
2965
|
+
});
|
|
2966
|
+
/**
|
|
2967
|
+
* 按插入顺序序列化为查询串(不排序,`request_host` 因此产生二次编码)
|
|
2968
|
+
* @param params 参数对象
|
|
2969
|
+
*/
|
|
2970
|
+
var serializeQuery = (params) => Object.entries(params).map(([key, value]) => `${key}=${encodeURIComponent(String(value))}`).join("&");
|
|
2971
|
+
//#endregion
|
|
2972
|
+
//#region ../amagi/packages/core/src/platform/douyin/passport/ticketGuard.ts
|
|
2973
|
+
/**
|
|
2974
|
+
* bd-ticket-guard 设备票据
|
|
2975
|
+
*
|
|
2976
|
+
* 抖音的设备真实性风控。常见的说法是必须从浏览器 localStorage 里导出一对密钥才能用,
|
|
2977
|
+
* 但那只适用于「已登录之后」的接口 —— **登录流程本身就是票据的签发入口**:
|
|
2978
|
+
*
|
|
2979
|
+
* 1. 本地生成一对 P-256 密钥
|
|
2980
|
+
* 2. 把公钥写进 `bd_ticket_guard_client_data` cookie,在取二维码**之前**交给服务端
|
|
2981
|
+
* 3. 服务端在后续响应的 `bd-ticket-guard-server-data` 头里签发 `{ticket, ts_sign, client_cert}`
|
|
2982
|
+
* 4. 之后每个请求用 `ECDH(自己的私钥, client_cert 里的服务端公钥)` 派生的密钥
|
|
2983
|
+
* 对 `ticket=…&path=…×tamp=…` 做 HMAC,放进 `bd-ticket-guard-client-data` 头
|
|
2984
|
+
*
|
|
2985
|
+
* 全程不需要浏览器,票据是服务端真实签发给我们自己这把密钥的,没有任何伪造数据。
|
|
2986
|
+
* 密钥与票据以 `__amagi_` 前缀存在 CookieJar 里,只在调用之间传递,不会发给服务端。
|
|
2987
|
+
*/
|
|
2988
|
+
/** 交给服务端的公钥所在的 cookie */
|
|
2989
|
+
var CLIENT_DATA_COOKIE = "bd_ticket_guard_client_data";
|
|
2990
|
+
/** 声明 web 域版本的 cookie */
|
|
2991
|
+
var CLIENT_WEB_DOMAIN_COOKIE = "bd_ticket_guard_client_web_domain";
|
|
2992
|
+
/** 服务端也可能把签发结果放在这条 cookie 里 */
|
|
2993
|
+
var SERVER_DATA_COOKIE = "bd_ticket_guard_server_data";
|
|
2994
|
+
/** 服务端签发结果所在的响应头 */
|
|
2995
|
+
var SERVER_DATA_HEADER = "bd-ticket-guard-server-data";
|
|
2996
|
+
/** 本地会话状态:PKCS#8 私钥(base64) */
|
|
2997
|
+
var KEY_ENTRY = `${INTERNAL_PREFIX}tg_key`;
|
|
2998
|
+
/** 本地会话状态:服务端签发的票据 */
|
|
2999
|
+
var TICKET_ENTRY = `${INTERNAL_PREFIX}tg_ticket`;
|
|
3000
|
+
/** 本地会话状态:票据的时间戳签名 */
|
|
3001
|
+
var TS_SIGN_ENTRY = `${INTERNAL_PREFIX}tg_ts_sign`;
|
|
3002
|
+
/** 本地会话状态:ECDH 派生出的 HMAC 密钥(hex) */
|
|
3003
|
+
var ECDH_ENTRY = `${INTERNAL_PREFIX}tg_ecdh`;
|
|
3004
|
+
/** SDK 声明的 ticket-guard 版本 */
|
|
3005
|
+
var GUARD_VERSION = "2";
|
|
3006
|
+
/** SDK 声明的迭代版本 */
|
|
3007
|
+
var ITERATION_VERSION = "1";
|
|
3008
|
+
/** P-256 公钥的 SubjectPublicKeyInfo 前缀,其后紧跟 65 字节未压缩点 */
|
|
3009
|
+
var P256_SPKI_PREFIX = Buffer.from("3059301306072a8648ce3d020106082a8648ce3d030107034200", "hex");
|
|
3010
|
+
/** 被签名内容的字段列表,需与实际拼接顺序一致 */
|
|
3011
|
+
var REQ_CONTENT = "ticket,path,timestamp";
|
|
3012
|
+
/** 紧凑 JSON,与浏览器的 `JSON.stringify` 一致 */
|
|
3013
|
+
var compactJson = (value) => JSON.stringify(value);
|
|
3014
|
+
/**
|
|
3015
|
+
* 从 PEM 证书或 `pub.<base64>` 里取出服务端公钥
|
|
3016
|
+
* @param clientCert 服务端下发的 client_cert
|
|
3017
|
+
*/
|
|
3018
|
+
var readServerPublicKey = (clientCert) => {
|
|
3019
|
+
if (clientCert.startsWith("pub.")) {
|
|
3020
|
+
const point = Buffer.from(clientCert.slice(4), "base64");
|
|
3021
|
+
const body = point.length === 65 ? point.subarray(1) : point;
|
|
3022
|
+
const spki = Buffer.concat([
|
|
3023
|
+
P256_SPKI_PREFIX,
|
|
3024
|
+
Buffer.from([4]),
|
|
3025
|
+
body
|
|
3026
|
+
]);
|
|
3027
|
+
return crypto.createPublicKey({
|
|
3028
|
+
key: spki,
|
|
3029
|
+
format: "der",
|
|
3030
|
+
type: "spki"
|
|
3031
|
+
});
|
|
3032
|
+
}
|
|
3033
|
+
return new crypto.X509Certificate(clientCert).publicKey;
|
|
3034
|
+
};
|
|
3035
|
+
/**
|
|
3036
|
+
* bd-ticket-guard 会话
|
|
3037
|
+
*
|
|
3038
|
+
* 状态全部读写自传入的 CookieJar,因此与 passport 的无状态调用形态天然兼容。
|
|
3039
|
+
*/
|
|
3040
|
+
var TicketGuard = class {
|
|
3041
|
+
jar;
|
|
3042
|
+
/**
|
|
3043
|
+
* @param jar 当前会话 cookie
|
|
3044
|
+
*/
|
|
3045
|
+
constructor(jar) {
|
|
3046
|
+
this.jar = jar;
|
|
3047
|
+
}
|
|
3048
|
+
/** 本会话的私钥,缺失时生成一把并写回 CookieJar */
|
|
3049
|
+
get privateKey() {
|
|
3050
|
+
const stored = this.jar.get(KEY_ENTRY);
|
|
3051
|
+
if (stored) return crypto.createPrivateKey({
|
|
3052
|
+
key: Buffer.from(stored, "base64"),
|
|
3053
|
+
format: "der",
|
|
3054
|
+
type: "pkcs8"
|
|
3055
|
+
});
|
|
3056
|
+
const { privateKey } = crypto.generateKeyPairSync("ec", { namedCurve: "prime256v1" });
|
|
3057
|
+
const der = privateKey.export({
|
|
3058
|
+
format: "der",
|
|
3059
|
+
type: "pkcs8"
|
|
3060
|
+
});
|
|
3061
|
+
this.jar.set(KEY_ENTRY, der.toString("base64"));
|
|
3062
|
+
return privateKey;
|
|
3063
|
+
}
|
|
3064
|
+
/** 未压缩格式的公钥(base64),即 `bd-ticket-guard-ree-public-key` */
|
|
3065
|
+
get reePublicKey() {
|
|
3066
|
+
const spki = crypto.createPublicKey(this.privateKey).export({
|
|
3067
|
+
format: "der",
|
|
3068
|
+
type: "spki"
|
|
3069
|
+
});
|
|
3070
|
+
return spki.subarray(spki.length - 65).toString("base64");
|
|
3071
|
+
}
|
|
3072
|
+
/** 已签发的票据,未签发时为 undefined */
|
|
3073
|
+
get state() {
|
|
3074
|
+
const ticket = this.jar.get(TICKET_ENTRY);
|
|
3075
|
+
const tsSign = this.jar.get(TS_SIGN_ENTRY);
|
|
3076
|
+
const ecdh = this.jar.get(ECDH_ENTRY);
|
|
3077
|
+
if (!ticket || !tsSign || !ecdh) return void 0;
|
|
3078
|
+
return {
|
|
3079
|
+
ticket,
|
|
3080
|
+
tsSign,
|
|
3081
|
+
ecdhKey: Buffer.from(ecdh, "hex")
|
|
3082
|
+
};
|
|
3083
|
+
}
|
|
3084
|
+
/**
|
|
3085
|
+
* 在首个 passport 请求之前把公钥交给服务端
|
|
3086
|
+
*
|
|
3087
|
+
* 缺了这一步扫码依然能成功,但服务端不会签发票据,后续请求也就无从携带。
|
|
3088
|
+
*/
|
|
3089
|
+
publishPublicKey() {
|
|
3090
|
+
const payload = compactJson({
|
|
3091
|
+
"bd-ticket-guard-version": Number(GUARD_VERSION),
|
|
3092
|
+
"bd-ticket-guard-iteration-version": Number(ITERATION_VERSION),
|
|
3093
|
+
"bd-ticket-guard-ree-public-key": this.reePublicKey,
|
|
3094
|
+
"bd-ticket-guard-web-version": Number(GUARD_VERSION)
|
|
3095
|
+
});
|
|
3096
|
+
this.jar.set(CLIENT_DATA_COOKIE, encodeURIComponent(Buffer.from(payload, "utf8").toString("base64")));
|
|
3097
|
+
this.jar.set(CLIENT_WEB_DOMAIN_COOKIE, GUARD_VERSION);
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* 消化响应里可能带回的票据签发结果
|
|
3101
|
+
* @param headers 响应头
|
|
3102
|
+
* @returns 是否收到了新票据
|
|
3103
|
+
*/
|
|
3104
|
+
applyServerData(headers) {
|
|
3105
|
+
const fromHeader = headers[SERVER_DATA_HEADER];
|
|
3106
|
+
const raw = typeof fromHeader === "string" && fromHeader ? fromHeader : this.jar.get(SERVER_DATA_COOKIE);
|
|
3107
|
+
if (!raw) return false;
|
|
3108
|
+
let info;
|
|
3109
|
+
try {
|
|
3110
|
+
info = JSON.parse(Buffer.from(decodeURIComponent(raw), "base64").toString("utf8"));
|
|
3111
|
+
} catch {
|
|
3112
|
+
return false;
|
|
2257
3113
|
}
|
|
3114
|
+
if (!info.ticket || !info.ts_sign || !info.client_cert) return false;
|
|
3115
|
+
let ecdhKey;
|
|
3116
|
+
try {
|
|
3117
|
+
ecdhKey = this.deriveEcdhKey(info.client_cert);
|
|
3118
|
+
} catch {
|
|
3119
|
+
return false;
|
|
3120
|
+
}
|
|
3121
|
+
this.jar.set(TICKET_ENTRY, info.ticket);
|
|
3122
|
+
this.jar.set(TS_SIGN_ENTRY, info.ts_sign);
|
|
3123
|
+
this.jar.set(ECDH_ENTRY, ecdhKey.toString("hex"));
|
|
3124
|
+
return true;
|
|
3125
|
+
}
|
|
3126
|
+
/**
|
|
3127
|
+
* 生成本次请求的 bd-ticket-guard 请求头
|
|
3128
|
+
*
|
|
3129
|
+
* 尚未拿到票据时只声明公钥,让服务端有机会签发;拿到之后带完整签名。
|
|
3130
|
+
* @param path 请求路径,不含 query
|
|
3131
|
+
* @param timestamp 秒级时间戳,默认取当前
|
|
3132
|
+
*/
|
|
3133
|
+
headers(path, timestamp = Math.floor(Date.now() / 1e3)) {
|
|
3134
|
+
const base = {
|
|
3135
|
+
"bd-ticket-guard-version": GUARD_VERSION,
|
|
3136
|
+
"bd-ticket-guard-iteration-version": ITERATION_VERSION,
|
|
3137
|
+
"bd-ticket-guard-ree-public-key": this.reePublicKey
|
|
3138
|
+
};
|
|
3139
|
+
const state = this.state;
|
|
3140
|
+
if (!state) return base;
|
|
3141
|
+
const signed = `ticket=${state.ticket}&path=${path}×tamp=${timestamp}`;
|
|
3142
|
+
const clientData = compactJson({
|
|
3143
|
+
ts_sign: state.tsSign,
|
|
3144
|
+
req_content: REQ_CONTENT,
|
|
3145
|
+
req_sign: crypto.createHmac("sha256", state.ecdhKey).update(signed, "utf8").digest("base64"),
|
|
3146
|
+
timestamp
|
|
3147
|
+
});
|
|
3148
|
+
return {
|
|
3149
|
+
...base,
|
|
3150
|
+
"bd-ticket-guard-client-data": Buffer.from(clientData, "utf8").toString("base64"),
|
|
3151
|
+
"bd-ticket-guard-web-version": state.tsSign.startsWith("ts.1") ? "1" : GUARD_VERSION,
|
|
3152
|
+
"bd-ticket-guard-web-sign-type": "1"
|
|
3153
|
+
};
|
|
3154
|
+
}
|
|
3155
|
+
/**
|
|
3156
|
+
* ECDH + HKDF-SHA256 派生 HMAC 密钥
|
|
3157
|
+
* @param clientCert 服务端下发的证书或裸公钥
|
|
3158
|
+
*/
|
|
3159
|
+
deriveEcdhKey(clientCert) {
|
|
3160
|
+
const shared = crypto.diffieHellman({
|
|
3161
|
+
privateKey: this.privateKey,
|
|
3162
|
+
publicKey: readServerPublicKey(clientCert)
|
|
3163
|
+
});
|
|
3164
|
+
return Buffer.from(crypto.hkdfSync("sha256", shared, Buffer.alloc(32), Buffer.alloc(0), 32));
|
|
3165
|
+
}
|
|
3166
|
+
};
|
|
3167
|
+
//#endregion
|
|
3168
|
+
//#region ../amagi/packages/core/src/platform/douyin/passport/client.ts
|
|
3169
|
+
/**
|
|
3170
|
+
* passport 登录的 HTTP 客户端
|
|
3171
|
+
*
|
|
3172
|
+
* 走 amagi 自己的 `fetchResponse`(axios),因此代理、超时、重试与网络事件与其它接口一致。
|
|
3173
|
+
*
|
|
3174
|
+
* 客户端本身是无状态的:会话状态(`msToken`、`passport_csrf_token`)都以 cookie 形式
|
|
3175
|
+
* 随调用方传入的 cookie 串进出,`x-tt-passport-verify-portrait` 则由 `ttwid` 派生,
|
|
3176
|
+
* 因此同一份 cookie 在整个登录过程中会得到稳定的 portrait,调用方无需额外保存任何东西。
|
|
3177
|
+
*/
|
|
3178
|
+
/** 与签名里的浏览器环境保持一致的 UA */
|
|
3179
|
+
var PASSPORT_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";
|
|
3180
|
+
/** 单次请求默认超时 */
|
|
3181
|
+
var DEFAULT_TIMEOUT = 15e3;
|
|
3182
|
+
/** SSO 跳转最多跟随的次数 */
|
|
3183
|
+
var MAX_REDIRECT_HOPS = 5;
|
|
3184
|
+
/** 浏览器客户端提示头,服务端会与 UA 交叉校验 */
|
|
3185
|
+
var CLIENT_HINTS = {
|
|
3186
|
+
"sec-ch-ua": "\"Not_A Brand\";v=\"99\", \"Chromium\";v=\"142\"",
|
|
3187
|
+
"sec-ch-ua-mobile": "?0",
|
|
3188
|
+
"sec-ch-ua-platform": "\"Windows\""
|
|
3189
|
+
};
|
|
3190
|
+
/** 安全解析 JSON,失败返回空对象 */
|
|
3191
|
+
var parseJson = (text) => {
|
|
3192
|
+
try {
|
|
3193
|
+
return JSON.parse(text);
|
|
3194
|
+
} catch {
|
|
3195
|
+
return {};
|
|
3196
|
+
}
|
|
3197
|
+
};
|
|
3198
|
+
/**
|
|
3199
|
+
* 由 ttwid 派生出稳定的 verify portrait
|
|
3200
|
+
*
|
|
3201
|
+
* 浏览器里这个值在一次登录页生命周期内固定不变。这里用 cookie 中已有的设备指纹推导,
|
|
3202
|
+
* 既保证同一会话内多次调用得到同一个值,又不需要调用方额外携带状态。
|
|
3203
|
+
* @param jar 当前会话 cookie
|
|
3204
|
+
*/
|
|
3205
|
+
var deriveVerifyPortrait = (jar) => {
|
|
3206
|
+
const seed = jar.get("ttwid") ?? jar.get("__ac_nonce") ?? "douyin-passport";
|
|
3207
|
+
const hex = crypto.createHash("sha256").update(seed).digest("hex");
|
|
3208
|
+
return `${[
|
|
3209
|
+
hex.slice(0, 8),
|
|
3210
|
+
hex.slice(8, 12),
|
|
3211
|
+
`4${hex.slice(13, 16)}`,
|
|
3212
|
+
`8${hex.slice(17, 20)}`,
|
|
3213
|
+
hex.slice(20, 32)
|
|
3214
|
+
].join("-")}.login`;
|
|
3215
|
+
};
|
|
3216
|
+
var DouyinPassportClient = class {
|
|
3217
|
+
requestConfig;
|
|
3218
|
+
/** 会话 cookie */
|
|
3219
|
+
cookies;
|
|
3220
|
+
/** bd-ticket-guard 设备票据,状态随 cookie 一起流转 */
|
|
3221
|
+
ticketGuard;
|
|
3222
|
+
/**
|
|
3223
|
+
* @param cookie 已有的会话 cookie 串
|
|
3224
|
+
* @param requestConfig amagi 的请求配置(代理、超时、额外请求头)
|
|
3225
|
+
*/
|
|
3226
|
+
constructor(cookie, requestConfig) {
|
|
3227
|
+
this.requestConfig = requestConfig;
|
|
3228
|
+
this.cookies = new CookieJar(cookie);
|
|
3229
|
+
this.ticketGuard = new TicketGuard(this.cookies);
|
|
3230
|
+
}
|
|
3231
|
+
/** CSRF token:优先用服务端下发的,缺失时本地生成并同步写进 cookie(双提交校验) */
|
|
3232
|
+
get csrfToken() {
|
|
3233
|
+
const fromCookie = this.cookies.get("passport_csrf_token");
|
|
3234
|
+
if (fromCookie) return fromCookie;
|
|
3235
|
+
const generated = randomHex(32);
|
|
3236
|
+
this.cookies.set("passport_csrf_token", generated);
|
|
3237
|
+
this.cookies.set("passport_csrf_token_default", generated);
|
|
3238
|
+
return generated;
|
|
3239
|
+
}
|
|
3240
|
+
/**
|
|
3241
|
+
* 初始化登录环境指纹
|
|
3242
|
+
*
|
|
3243
|
+
* 依次请求抖音首页拿 `__ac_nonce`、再向 ttwid 服务注册拿 `ttwid`。两步都是匿名的,
|
|
3244
|
+
* 任意机器、任意系统都能跑;失败不抛错,只会让后续更容易命中风控。
|
|
3245
|
+
*/
|
|
3246
|
+
async bootstrap() {
|
|
3247
|
+
this.ticketGuard.publishPublicKey();
|
|
3248
|
+
if (this.cookies.has("ttwid") && this.cookies.has("__ac_nonce")) return;
|
|
3249
|
+
await this.send({
|
|
3250
|
+
method: "GET",
|
|
3251
|
+
url: `https://${WEB_HOST}/`,
|
|
3252
|
+
headers: {
|
|
3253
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
3254
|
+
...CLIENT_HINTS
|
|
3255
|
+
}
|
|
3256
|
+
});
|
|
3257
|
+
await this.send({
|
|
3258
|
+
method: "POST",
|
|
3259
|
+
url: "https://ttwid.bytedance.com/ttwid/register/",
|
|
3260
|
+
headers: {
|
|
3261
|
+
"Content-Type": "application/json",
|
|
3262
|
+
Origin: `https://${WEB_HOST}`,
|
|
3263
|
+
Referer: `https://${WEB_HOST}/`
|
|
3264
|
+
},
|
|
3265
|
+
data: JSON.stringify({
|
|
3266
|
+
aid: 6383,
|
|
3267
|
+
service: WEB_HOST
|
|
3268
|
+
})
|
|
3269
|
+
});
|
|
3270
|
+
emitLogDebug(`[douyin passport] 环境指纹就绪: ttwid=${this.cookies.has("ttwid")}, ac_nonce=${this.cookies.has("__ac_nonce")}`);
|
|
3271
|
+
}
|
|
3272
|
+
/**
|
|
3273
|
+
* 请求 login.douyin.com 的 passport 接口(四重签名 + a_bogus 形态)
|
|
3274
|
+
* @param path 接口路径,如 `/passport/web/get_qrcode/`
|
|
3275
|
+
* @param params 业务参数,并入 query
|
|
3276
|
+
*/
|
|
3277
|
+
async request(path, params = {}) {
|
|
3278
|
+
const common = makeCommonParams(params);
|
|
3279
|
+
const { sign, qs } = makeSignAndQs(common, {});
|
|
3280
|
+
const query = {
|
|
3281
|
+
...common,
|
|
3282
|
+
sign,
|
|
3283
|
+
qs
|
|
3284
|
+
};
|
|
3285
|
+
const msToken = this.cookies.get("msToken");
|
|
3286
|
+
if (msToken) query.msToken = msToken;
|
|
3287
|
+
const queryString = serializeQuery(query);
|
|
3288
|
+
const url = `https://${LOGIN_HOST}${path}?${queryString}&a_bogus=${encodeURIComponent(aBogus(queryString, PASSPORT_USER_AGENT))}`;
|
|
3289
|
+
return this.send({
|
|
3290
|
+
method: "GET",
|
|
3291
|
+
url,
|
|
3292
|
+
headers: {
|
|
3293
|
+
Accept: "application/json, text/javascript",
|
|
3294
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3295
|
+
Referer: `https://${WEB_HOST}/`,
|
|
3296
|
+
"x-tt-passport-aid-sign": makeAidSign(path),
|
|
3297
|
+
"x-tt-passport-csrf-token": this.csrfToken,
|
|
3298
|
+
"x-tt-passport-verify-portrait": deriveVerifyPortrait(this.cookies),
|
|
3299
|
+
"x-tt-passport-trace-id": String(common.biz_trace_id),
|
|
3300
|
+
...this.ticketGuard.headers(path),
|
|
3301
|
+
...CLIENT_HINTS
|
|
3302
|
+
}
|
|
3303
|
+
});
|
|
3304
|
+
}
|
|
3305
|
+
/**
|
|
3306
|
+
* 请求 www.douyin.com 的验证页接口(lite 形态:固定 query + 表单 body,无签名)
|
|
3307
|
+
* @param path 接口路径,如 `/passport/web/send_code/`
|
|
3308
|
+
* @param params 业务参数,进 body
|
|
3309
|
+
* @param bizTraceId 业务追踪 ID,同一次验证流程内保持一致
|
|
3310
|
+
*/
|
|
3311
|
+
async liteRequest(path, params, bizTraceId) {
|
|
3312
|
+
return this.send({
|
|
3313
|
+
method: "POST",
|
|
3314
|
+
url: `https://${WEB_HOST}${path}?${serializeQuery(makeLiteParams(bizTraceId))}`,
|
|
3315
|
+
headers: {
|
|
3316
|
+
Accept: "application/json, text/javascript",
|
|
3317
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
3318
|
+
Origin: `https://${WEB_HOST}`,
|
|
3319
|
+
Referer: `https://${WEB_HOST}/`,
|
|
3320
|
+
"x-tt-passport-aid-sign": makeAidSign(path),
|
|
3321
|
+
"x-tt-passport-csrf-token": this.csrfToken,
|
|
3322
|
+
"x-tt-passport-verify-portrait": deriveVerifyPortrait(this.cookies),
|
|
3323
|
+
"x-tt-passport-trace-id": bizTraceId,
|
|
3324
|
+
...this.ticketGuard.headers(path),
|
|
3325
|
+
...CLIENT_HINTS
|
|
3326
|
+
},
|
|
3327
|
+
data: serializeQuery(params)
|
|
3328
|
+
});
|
|
3329
|
+
}
|
|
3330
|
+
/**
|
|
3331
|
+
* 跟随扫码确认后下发的 SSO 跳转链,把最终的登录凭证收进 CookieJar
|
|
3332
|
+
* @param redirectUrl `check_qrconnect` 返回的 redirect_url
|
|
3333
|
+
* @returns 是否拿到登录态 cookie
|
|
3334
|
+
*/
|
|
3335
|
+
async followSsoRedirect(redirectUrl) {
|
|
3336
|
+
let current = redirectUrl;
|
|
3337
|
+
for (let hop = 0; hop < MAX_REDIRECT_HOPS; hop++) {
|
|
3338
|
+
const response = await this.send({
|
|
3339
|
+
method: "GET",
|
|
3340
|
+
url: current,
|
|
3341
|
+
maxRedirects: 0,
|
|
3342
|
+
headers: {
|
|
3343
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
3344
|
+
Referer: `https://${LOGIN_HOST}/`,
|
|
3345
|
+
...CLIENT_HINTS
|
|
3346
|
+
}
|
|
3347
|
+
});
|
|
3348
|
+
const location = response.location;
|
|
3349
|
+
if (!location || response.status < 300 || response.status >= 400) break;
|
|
3350
|
+
current = new URL(location, current).toString();
|
|
3351
|
+
}
|
|
3352
|
+
return this.cookies.isLoggedIn();
|
|
3353
|
+
}
|
|
3354
|
+
/** 实际发请求:合并 cookie、消化 Set-Cookie 与 msToken */
|
|
3355
|
+
async send(config) {
|
|
3356
|
+
const cookie = this.cookies.toString();
|
|
3357
|
+
const response = await fetchResponse({
|
|
3358
|
+
timeout: this.requestConfig?.timeout ?? DEFAULT_TIMEOUT,
|
|
3359
|
+
proxy: this.requestConfig?.proxy,
|
|
3360
|
+
...config,
|
|
3361
|
+
responseType: "text",
|
|
3362
|
+
maxRedirects: config.maxRedirects ?? 5,
|
|
3363
|
+
headers: {
|
|
3364
|
+
"User-Agent": PASSPORT_USER_AGENT,
|
|
3365
|
+
...config.headers,
|
|
3366
|
+
...cookie ? { Cookie: cookie } : {}
|
|
3367
|
+
}
|
|
3368
|
+
});
|
|
3369
|
+
if (isNetworkErrorResult(response)) throw new Error(response.error.amagiError.errorDescription);
|
|
3370
|
+
const axiosResponse = response;
|
|
3371
|
+
this.cookies.applySetCookie(axiosResponse.headers["set-cookie"]);
|
|
3372
|
+
const refreshed = axiosResponse.headers["x-ms-token"];
|
|
3373
|
+
if (typeof refreshed === "string" && refreshed) this.cookies.set("msToken", refreshed);
|
|
3374
|
+
if (this.ticketGuard.applyServerData(axiosResponse.headers)) emitLogDebug("[douyin passport] 已获得 bd-ticket-guard 票据");
|
|
3375
|
+
const raw = typeof axiosResponse.data === "string" ? axiosResponse.data : JSON.stringify(axiosResponse.data);
|
|
3376
|
+
return {
|
|
3377
|
+
status: axiosResponse.status,
|
|
3378
|
+
raw,
|
|
3379
|
+
body: parseJson(raw),
|
|
3380
|
+
cookie: this.cookies.serialize(),
|
|
3381
|
+
location: axiosResponse.headers.location
|
|
3382
|
+
};
|
|
3383
|
+
}
|
|
3384
|
+
};
|
|
3385
|
+
//#endregion
|
|
3386
|
+
//#region ../amagi/packages/core/src/platform/douyin/passport/parser.ts
|
|
3387
|
+
/** 触发账号二次验证的错误码 */
|
|
3388
|
+
var ERROR_SECOND_VERIFY = 2046;
|
|
3389
|
+
/** 验证码错误 */
|
|
3390
|
+
var ERROR_WRONG_CODE = 1202;
|
|
3391
|
+
/** 发码过于频繁 */
|
|
3392
|
+
var ERROR_RATE_LIMITED = 1206;
|
|
3393
|
+
/** 命中风控 / 设备环境异常 */
|
|
3394
|
+
var RISK_ERROR_CODES = /* @__PURE__ */ new Set([2156, 4031]);
|
|
3395
|
+
/**
|
|
3396
|
+
* 轮询过于频繁
|
|
3397
|
+
*
|
|
3398
|
+
* 描述文案是「访问太频繁」,但设备指纹不完整时服务端也用这个码兜底,
|
|
3399
|
+
* 属于可重试的瞬时状态,退避后继续轮询即可,不能当致命错误。
|
|
3400
|
+
*/
|
|
3401
|
+
var ERROR_POLL_BUSY = 7;
|
|
3402
|
+
/** 命中限频后的退避倍率 */
|
|
3403
|
+
var BUSY_BACKOFF = 2;
|
|
3404
|
+
/** 轮询间隔下限与默认值,服务端偶尔会给 0 */
|
|
3405
|
+
var MIN_INTERVAL = 1e3;
|
|
3406
|
+
var DEFAULT_INTERVAL = 3e3;
|
|
3407
|
+
/** 验证会话票据字段,需原样透传 */
|
|
3408
|
+
var STD_KEYS = [
|
|
3409
|
+
"std_verify_flow_id",
|
|
3410
|
+
"std_verify_scene",
|
|
3411
|
+
"std_verify_template",
|
|
3412
|
+
"std_verify_token",
|
|
3413
|
+
"std_verify_type",
|
|
3414
|
+
"std_verify_way"
|
|
3415
|
+
];
|
|
3416
|
+
var asString = (value) => typeof value === "string" ? value : "";
|
|
3417
|
+
var asNumber = (value) => {
|
|
3418
|
+
const parsed = typeof value === "number" ? value : Number(value);
|
|
3419
|
+
return Number.isFinite(parsed) ? parsed : void 0;
|
|
3420
|
+
};
|
|
3421
|
+
/** 从响应体的多个可能位置取错误码 */
|
|
3422
|
+
var readErrorCode = (payload) => asNumber(payload.data?.error_code) ?? asNumber(payload.error_code);
|
|
3423
|
+
/** 取一段人类可读的错误描述 */
|
|
3424
|
+
var readMessage = (payload) => asString(payload.data?.description) || asString(payload.description) || asString(payload.message) || "";
|
|
3425
|
+
/**
|
|
3426
|
+
* 解析 `get_qrcode` 响应
|
|
3427
|
+
* @param payload 服务端响应体
|
|
3428
|
+
* @returns 二维码信息,缺少 token 时返回 null
|
|
3429
|
+
*/
|
|
3430
|
+
var parseQrcode = (payload) => {
|
|
3431
|
+
const data = payload.data ?? {};
|
|
3432
|
+
const token = asString(data.token);
|
|
3433
|
+
if (!token) return null;
|
|
3434
|
+
return {
|
|
3435
|
+
token,
|
|
3436
|
+
content: asString(data.qrcode_index_url) || token,
|
|
3437
|
+
expireTime: asNumber(data.expire_time) ?? 0
|
|
2258
3438
|
};
|
|
2259
3439
|
};
|
|
3440
|
+
/** 解析服务端下发的可选验证方式 */
|
|
3441
|
+
var parseVerifyWays = (raw) => {
|
|
3442
|
+
if (!Array.isArray(raw)) return [];
|
|
3443
|
+
return raw.map((item) => {
|
|
3444
|
+
const way = item;
|
|
3445
|
+
return {
|
|
3446
|
+
verifyWay: asString(way?.verify_way),
|
|
3447
|
+
mobile: asString(way?.mobile) || void 0
|
|
3448
|
+
};
|
|
3449
|
+
}).filter((way) => way.verifyWay !== "");
|
|
3450
|
+
};
|
|
2260
3451
|
/**
|
|
2261
|
-
*
|
|
2262
|
-
* @param
|
|
2263
|
-
* @param requestConfig - 外部请求配置(优先级最高)
|
|
2264
|
-
* @returns 合并后的请求配置
|
|
3452
|
+
* 从轮询响应里提取二次验证上下文
|
|
3453
|
+
* @param data 轮询响应的 data 段
|
|
2265
3454
|
*/
|
|
2266
|
-
var
|
|
2267
|
-
const
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
3455
|
+
var parseVerifyContext = (data) => {
|
|
3456
|
+
const stdParams = {};
|
|
3457
|
+
for (const key of STD_KEYS) {
|
|
3458
|
+
const value = asString(data[key]);
|
|
3459
|
+
if (value) stdParams[key] = value;
|
|
3460
|
+
}
|
|
3461
|
+
return {
|
|
3462
|
+
encryptUid: asString(data.encrypt_uid),
|
|
3463
|
+
verifyTicket: asString(data.verify_ticket),
|
|
3464
|
+
stdParams,
|
|
3465
|
+
copywritingKey: asString(data.copywriting_key) || "qr_connect",
|
|
3466
|
+
diversionTag: asString(data.ies_safety_diversion_tag) || "mfa",
|
|
3467
|
+
newVerifyFlow: asString(data.new_verify_flow),
|
|
3468
|
+
verifyWays: parseVerifyWays(data.verify_ways)
|
|
3469
|
+
};
|
|
3470
|
+
};
|
|
3471
|
+
/**
|
|
3472
|
+
* 解析 `check_qrconnect` 响应为状态机可消费的结果
|
|
3473
|
+
* @param payload 服务端响应体
|
|
3474
|
+
*/
|
|
3475
|
+
var parsePollResult = (payload) => {
|
|
3476
|
+
const data = payload.data ?? {};
|
|
3477
|
+
const rawInterval = asNumber(data.interval) ?? 0;
|
|
3478
|
+
const interval = rawInterval >= MIN_INTERVAL ? rawInterval : DEFAULT_INTERVAL;
|
|
3479
|
+
const status = asString(data.status);
|
|
3480
|
+
const errorCode = readErrorCode(payload);
|
|
3481
|
+
if (errorCode === ERROR_SECOND_VERIFY || asString(data.account_flow) === "verify") return {
|
|
3482
|
+
status: "verify",
|
|
3483
|
+
interval,
|
|
3484
|
+
verify: parseVerifyContext(data)
|
|
3485
|
+
};
|
|
3486
|
+
if (errorCode !== void 0 && RISK_ERROR_CODES.has(errorCode)) return {
|
|
3487
|
+
status: "risk",
|
|
3488
|
+
interval,
|
|
3489
|
+
message: readMessage(payload) || `error_code=${errorCode}`
|
|
3490
|
+
};
|
|
3491
|
+
if (errorCode === ERROR_POLL_BUSY) return {
|
|
3492
|
+
status: "busy",
|
|
3493
|
+
interval: interval * BUSY_BACKOFF,
|
|
3494
|
+
message: readMessage(payload) || "轮询过于频繁"
|
|
3495
|
+
};
|
|
3496
|
+
switch (status) {
|
|
3497
|
+
case "new":
|
|
3498
|
+
case "scanned":
|
|
3499
|
+
case "expired": return {
|
|
3500
|
+
status,
|
|
3501
|
+
interval
|
|
3502
|
+
};
|
|
3503
|
+
case "confirmed": return {
|
|
3504
|
+
status: "confirmed",
|
|
3505
|
+
interval,
|
|
3506
|
+
redirectUrl: asString(data.redirect_url) || (Array.isArray(data.redirect_urls) ? asString(data.redirect_urls[0]) : "")
|
|
3507
|
+
};
|
|
3508
|
+
default: return {
|
|
3509
|
+
status: "unknown",
|
|
3510
|
+
interval,
|
|
3511
|
+
message: readMessage(payload) || (status ? `status=${status}` : `error_code=${errorCode ?? "unknown"}`)
|
|
3512
|
+
};
|
|
3513
|
+
}
|
|
3514
|
+
};
|
|
3515
|
+
/**
|
|
3516
|
+
* 解析 `send_code` 响应
|
|
3517
|
+
* @param payload 服务端响应体
|
|
3518
|
+
*/
|
|
3519
|
+
var parseSendCodeResult = (payload) => {
|
|
3520
|
+
const data = payload.data ?? {};
|
|
3521
|
+
const errorCode = readErrorCode(payload);
|
|
3522
|
+
const retryAfter = asNumber(data.retry_time) ?? 60;
|
|
3523
|
+
const mobile = asString(data.mobile);
|
|
3524
|
+
if (errorCode === 0 || errorCode === void 0 && payload.message === "success") return {
|
|
3525
|
+
ok: true,
|
|
3526
|
+
mobile,
|
|
3527
|
+
retryAfter,
|
|
3528
|
+
message: ""
|
|
2277
3529
|
};
|
|
2278
3530
|
return {
|
|
2279
|
-
|
|
2280
|
-
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
...requestConfig?.headers ?? {}
|
|
2285
|
-
}
|
|
3531
|
+
ok: false,
|
|
3532
|
+
mobile,
|
|
3533
|
+
retryAfter,
|
|
3534
|
+
errorCode,
|
|
3535
|
+
message: readMessage(payload) || (errorCode === ERROR_RATE_LIMITED ? "短信发送过于频繁" : `发码失败 error_code=${errorCode ?? "unknown"}`)
|
|
2286
3536
|
};
|
|
2287
3537
|
};
|
|
2288
3538
|
/**
|
|
2289
|
-
*
|
|
2290
|
-
* @param
|
|
2291
|
-
* @returns 小红书请求配置
|
|
3539
|
+
* 解析 `validate_code` 响应
|
|
3540
|
+
* @param payload 服务端响应体
|
|
2292
3541
|
*/
|
|
2293
|
-
var
|
|
2294
|
-
|
|
2295
|
-
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
"
|
|
2306
|
-
|
|
2307
|
-
"sec-fetch-site": "same-site",
|
|
2308
|
-
"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",
|
|
2309
|
-
cookie: cookie ?? ""
|
|
2310
|
-
} };
|
|
3542
|
+
var parseValidateCodeResult = (payload) => {
|
|
3543
|
+
const data = payload.data ?? {};
|
|
3544
|
+
const errorCode = readErrorCode(payload);
|
|
3545
|
+
if (errorCode === 0 || asString(data.ticket) || errorCode === void 0 && payload.message === "success") return {
|
|
3546
|
+
ok: true,
|
|
3547
|
+
wrongCode: false,
|
|
3548
|
+
message: ""
|
|
3549
|
+
};
|
|
3550
|
+
return {
|
|
3551
|
+
ok: false,
|
|
3552
|
+
wrongCode: errorCode === ERROR_WRONG_CODE,
|
|
3553
|
+
errorCode,
|
|
3554
|
+
message: readMessage(payload) || (errorCode === ERROR_WRONG_CODE ? "验证码错误" : `验证失败 error_code=${errorCode ?? "unknown"}`)
|
|
3555
|
+
};
|
|
2311
3556
|
};
|
|
2312
3557
|
//#endregion
|
|
3558
|
+
//#region ../amagi/packages/core/src/platform/douyin/passport/index.ts
|
|
3559
|
+
var passport_exports = /* @__PURE__ */ __exportAll({
|
|
3560
|
+
BDMS_SDK_VERSION: () => BDMS_SDK_VERSION,
|
|
3561
|
+
CookieJar: () => CookieJar,
|
|
3562
|
+
DouyinPassportClient: () => DouyinPassportClient,
|
|
3563
|
+
INTERNAL_PREFIX: () => INTERNAL_PREFIX,
|
|
3564
|
+
PASSPORT_USER_AGENT: () => PASSPORT_USER_AGENT,
|
|
3565
|
+
TicketGuard: () => TicketGuard,
|
|
3566
|
+
aBogus: () => aBogus,
|
|
3567
|
+
makeAidSign: () => makeAidSign,
|
|
3568
|
+
makeSignAndQs: () => makeSignAndQs,
|
|
3569
|
+
parsePollResult: () => parsePollResult,
|
|
3570
|
+
parseQrcode: () => parseQrcode,
|
|
3571
|
+
parseSendCodeResult: () => parseSendCodeResult,
|
|
3572
|
+
parseValidateCodeResult: () => parseValidateCodeResult,
|
|
3573
|
+
randomHex: () => randomHex,
|
|
3574
|
+
sm3: () => sm3,
|
|
3575
|
+
sm3Hex: () => sm3Hex,
|
|
3576
|
+
sm3Twice: () => sm3Twice,
|
|
3577
|
+
utcNoonTimestamp: () => utcNoonTimestamp,
|
|
3578
|
+
xor5Hex: () => xor5Hex
|
|
3579
|
+
});
|
|
3580
|
+
//#endregion
|
|
2313
3581
|
//#region ../amagi/packages/core/src/types/NetworksConfigType.ts
|
|
2314
3582
|
/** 未知错误 */
|
|
2315
3583
|
var amagiAPIErrorCode = /* @__PURE__ */ function(amagiAPIErrorCode) {
|
|
@@ -2460,6 +3728,389 @@ var xiaohongshuAPIErrorCode = /* @__PURE__ */ function(xiaohongshuAPIErrorCode)
|
|
|
2460
3728
|
return xiaohongshuAPIErrorCode;
|
|
2461
3729
|
}({});
|
|
2462
3730
|
//#endregion
|
|
3731
|
+
//#region ../amagi/packages/core/src/model/fetchers/douyin/auth.ts
|
|
3732
|
+
/**
|
|
3733
|
+
* 抖音登录认证相关 API(passport 扫码登录)
|
|
3734
|
+
*
|
|
3735
|
+
* 与其它 fetcher 的差别:这几个接口不走 `DouyinData` 的 URL 拼装 + a_bogus 流水线,
|
|
3736
|
+
* 因为 passport 体系有自己的三重 query 签名、独立的 a_bogus 形态与双重编码规则。
|
|
3737
|
+
* 对外形态保持一致:同样是 `(options, cookie?, requestConfig?) => Result<T>`,
|
|
3738
|
+
* 同样发 `apiSuccess` / `apiError` 事件,同样复用 amagi 的代理、超时与重试。
|
|
3739
|
+
*
|
|
3740
|
+
* 这几个方法都是无状态的:会话状态全部装在 cookie 串里,调用方拿到返回的 `cookie`
|
|
3741
|
+
* 后在下一次调用时传回来即可。轮询循环由调用方维护。
|
|
3742
|
+
*
|
|
3743
|
+
* @module fetchers/douyin/auth
|
|
3744
|
+
*/
|
|
3745
|
+
/** 短信验证码的验证方式标识,服务端未给出可用方式时的兜底值 */
|
|
3746
|
+
var SMS_VERIFY_WAY = "mobile_sms_verify";
|
|
3747
|
+
/**
|
|
3748
|
+
* 可以用「收 6 位验证码」这套流程走完的验证方式
|
|
3749
|
+
*
|
|
3750
|
+
* 除官方常见的 `mobile_sms_verify`,账号被判定需要辅助验证时会给出
|
|
3751
|
+
* `assist_mobile_sms_verify`,两者都是下行短信收码,走同一对
|
|
3752
|
+
* `send_code` / `validate_code` 接口,区别只在 `std_verify_way` 的取值。
|
|
3753
|
+
* 上行短信(`*_up_sms_verify`)要求用户从手机发短信出去,是另一套接口,不在此列。
|
|
3754
|
+
*/
|
|
3755
|
+
var SMS_CODE_WAY_PATTERN = /^(assist_)?mobile_sms_verify$/;
|
|
3756
|
+
/**
|
|
3757
|
+
* 判断某个验证方式能否用短信验证码流程完成
|
|
3758
|
+
* @param verifyWay 服务端下发的 verify_way
|
|
3759
|
+
*/
|
|
3760
|
+
var isSmsCodeVerifyWay = (verifyWay) => SMS_CODE_WAY_PATTERN.test(verifyWay);
|
|
3761
|
+
/**
|
|
3762
|
+
* 选出本次要用的 std_verify_way
|
|
3763
|
+
*
|
|
3764
|
+
* 优先用调用方指定的;否则从服务端给出的可选方式里挑一个能收码的;都没有才回退到默认值。
|
|
3765
|
+
* 之前这里写死 `mobile_sms_verify`,遇到辅助验证的账号会因为 way 对不上而失败。
|
|
3766
|
+
* @param verify 轮询下发的验证上下文
|
|
3767
|
+
* @param requested 调用方显式指定的验证方式
|
|
3768
|
+
*/
|
|
3769
|
+
var resolveVerifyWay = (verify, requested) => requested ?? verify.verifyWays.find((way) => isSmsCodeVerifyWay(way.verifyWay))?.verifyWay ?? verify.stdParams.std_verify_way ?? SMS_VERIFY_WAY;
|
|
3770
|
+
/** 短信验证码的 act_type */
|
|
3771
|
+
var SMS_ACT_TYPE = "3737";
|
|
3772
|
+
/** 验证页 SDK 版本,随表单一起提交 */
|
|
3773
|
+
var AUTHN_VERSION = "1.0.0.420-web";
|
|
3774
|
+
/** 抖音 web 的 aid */
|
|
3775
|
+
var AID = "6383";
|
|
3776
|
+
/** 扫码成功后的跳转地址 */
|
|
3777
|
+
var NEXT_URL = "https://www.douyin.com";
|
|
3778
|
+
/**
|
|
3779
|
+
* 发码与验码共用的表单字段
|
|
3780
|
+
*
|
|
3781
|
+
* 字段顺序与「空值也要占位」的行为对齐官方验证页 SDK 的抓包形态:
|
|
3782
|
+
* `verify_ticket` / `new_verify_flow` / `std_verify_flow_id` / `std_verify_token`
|
|
3783
|
+
* 即使为空也必须出现,缺字段会被判为伪造请求。
|
|
3784
|
+
* @param verify 轮询下发的验证上下文
|
|
3785
|
+
* @param verifyWay 本次使用的验证方式,原样进 `std_verify_way`
|
|
3786
|
+
* @param tail 追加在 std_verify_way 之后的字段(发码是 is6Digits,验码是 code)
|
|
3787
|
+
*/
|
|
3788
|
+
var buildVerifyBody = (verify, verifyWay, tail) => ({
|
|
3789
|
+
mix_mode: "1",
|
|
3790
|
+
type: SMS_ACT_TYPE,
|
|
3791
|
+
encrypt_uid: verify.encryptUid,
|
|
3792
|
+
verify_ticket: verify.verifyTicket,
|
|
3793
|
+
copywriting_key: verify.copywritingKey,
|
|
3794
|
+
ies_safety_diversion_tag: verify.diversionTag,
|
|
3795
|
+
new_verify_flow: verify.newVerifyFlow,
|
|
3796
|
+
std_verify_flow_id: verify.stdParams.std_verify_flow_id ?? "",
|
|
3797
|
+
std_verify_scene: verify.stdParams.std_verify_scene ?? "account_login",
|
|
3798
|
+
std_verify_template: verify.stdParams.std_verify_template ?? "ato_web",
|
|
3799
|
+
std_verify_token: verify.stdParams.std_verify_token ?? "",
|
|
3800
|
+
std_verify_type: verify.stdParams.std_verify_type ?? "MFA",
|
|
3801
|
+
std_verify_way: verifyWay,
|
|
3802
|
+
...tail,
|
|
3803
|
+
aid: AID,
|
|
3804
|
+
new_authn_sdk_version: AUTHN_VERSION
|
|
3805
|
+
});
|
|
3806
|
+
/**
|
|
3807
|
+
* 构造 passport 侧的业务错误响应
|
|
3808
|
+
* @param methodType 方法名,进 amagiError.requestType
|
|
3809
|
+
* @param message 错误描述
|
|
3810
|
+
*/
|
|
3811
|
+
var passportError = (methodType, message) => createErrorResponse({
|
|
3812
|
+
code: amagiAPIErrorCode.UNKNOWN,
|
|
3813
|
+
data: null,
|
|
3814
|
+
amagiError: {
|
|
3815
|
+
errorDescription: message,
|
|
3816
|
+
requestType: methodType,
|
|
3817
|
+
requestUrl: `https://login.douyin.com/passport/`
|
|
3818
|
+
},
|
|
3819
|
+
amagiMessage: message
|
|
3820
|
+
}, message);
|
|
3821
|
+
/** 统一包一层事件上报与异常兜底 */
|
|
3822
|
+
var run = async (methodType, task) => {
|
|
3823
|
+
const startTime = Date.now();
|
|
3824
|
+
try {
|
|
3825
|
+
const result = await task();
|
|
3826
|
+
const duration = Date.now() - startTime;
|
|
3827
|
+
if (result.code === 200) emitApiSuccess({
|
|
3828
|
+
platform: "douyin",
|
|
3829
|
+
methodType,
|
|
3830
|
+
response: result,
|
|
3831
|
+
statusCode: 200,
|
|
3832
|
+
duration
|
|
3833
|
+
});
|
|
3834
|
+
else emitApiError({
|
|
3835
|
+
platform: "douyin",
|
|
3836
|
+
methodType,
|
|
3837
|
+
errorCode: result.code,
|
|
3838
|
+
errorMessage: result.message,
|
|
3839
|
+
duration
|
|
3840
|
+
});
|
|
3841
|
+
return result;
|
|
3842
|
+
} catch (error) {
|
|
3843
|
+
const duration = Date.now() - startTime;
|
|
3844
|
+
const errorMessage = error instanceof Error ? error.message : "未知错误";
|
|
3845
|
+
emitApiError({
|
|
3846
|
+
platform: "douyin",
|
|
3847
|
+
methodType,
|
|
3848
|
+
errorMessage,
|
|
3849
|
+
duration
|
|
3850
|
+
});
|
|
3851
|
+
throw new Error(`抖音登录请求失败: ${errorMessage}`);
|
|
3852
|
+
}
|
|
3853
|
+
};
|
|
3854
|
+
/**
|
|
3855
|
+
* 申请抖音扫码登录二维码
|
|
3856
|
+
*
|
|
3857
|
+
* 首次调用会自动完成环境指纹初始化(`__ac_nonce` + `ttwid`),无需额外准备。
|
|
3858
|
+
* @param options - 请求选项 (可选)
|
|
3859
|
+
* @param cookie - 已有的会话 Cookie (可选,续用同一会话时传入)
|
|
3860
|
+
* @param requestConfig - 请求配置 (可选)
|
|
3861
|
+
* @returns 二维码令牌、内容与会话 cookie
|
|
3862
|
+
* @example
|
|
3863
|
+
* ```typescript
|
|
3864
|
+
* const qrcode = await requestPassportQrcode()
|
|
3865
|
+
* console.log(qrcode.data.content) // 拿去生成二维码图片
|
|
3866
|
+
* ```
|
|
3867
|
+
*/
|
|
3868
|
+
async function requestPassportQrcode(options, cookie, requestConfig) {
|
|
3869
|
+
return run("passportQrcode", async () => {
|
|
3870
|
+
const client = new DouyinPassportClient(cookie, requestConfig);
|
|
3871
|
+
await client.bootstrap();
|
|
3872
|
+
const response = await client.request("/passport/web/get_qrcode/", {
|
|
3873
|
+
next: NEXT_URL,
|
|
3874
|
+
need_short_url: "true",
|
|
3875
|
+
need_logo: "false",
|
|
3876
|
+
is_new_login: "1"
|
|
3877
|
+
});
|
|
3878
|
+
const qrcode = parseQrcode(response.body);
|
|
3879
|
+
if (!qrcode) return passportError("passportQrcode", response.body.message || `获取二维码失败: ${response.raw.slice(0, 200)}`);
|
|
3880
|
+
return createSuccessResponse$1({
|
|
3881
|
+
token: qrcode.token,
|
|
3882
|
+
content: qrcode.content,
|
|
3883
|
+
expire_time: qrcode.expireTime,
|
|
3884
|
+
expires_in: Math.max(0, qrcode.expireTime - Math.floor(Date.now() / 1e3)),
|
|
3885
|
+
cookie: response.cookie
|
|
3886
|
+
}, "获取成功", 200);
|
|
3887
|
+
});
|
|
3888
|
+
}
|
|
3889
|
+
/**
|
|
3890
|
+
* 查询抖音扫码登录二维码的状态
|
|
3891
|
+
*
|
|
3892
|
+
* 状态为 `confirmed` 时会自动跟随 SSO 跳转领取登录凭证,返回的 `cookie` 即完整登录态。
|
|
3893
|
+
* @param options - 二维码状态参数
|
|
3894
|
+
* @param options.token - `requestPassportQrcode` 返回的令牌
|
|
3895
|
+
* @param cookie - 会话 Cookie,必须是申请二维码时返回的那一份
|
|
3896
|
+
* @param requestConfig - 请求配置 (可选)
|
|
3897
|
+
* @returns 扫码状态与最新会话 cookie
|
|
3898
|
+
* @example
|
|
3899
|
+
* ```typescript
|
|
3900
|
+
* const status = await checkPassportQrcode({ token }, cookie)
|
|
3901
|
+
* // new 未扫码 / scanned 已扫待确认 / verify 需二次验证 / confirmed 登录成功 / expired 已过期
|
|
3902
|
+
* console.log(status.data.status)
|
|
3903
|
+
* ```
|
|
3904
|
+
*/
|
|
3905
|
+
async function checkPassportQrcode(options, cookie, requestConfig) {
|
|
3906
|
+
return run("passportQrcodeStatus", async () => {
|
|
3907
|
+
if (!options?.token) return passportError("passportQrcodeStatus", "缺少 token 参数");
|
|
3908
|
+
const client = new DouyinPassportClient(cookie, requestConfig);
|
|
3909
|
+
const response = await client.request("/passport/web/check_qrconnect/", {
|
|
3910
|
+
next: NEXT_URL,
|
|
3911
|
+
need_logo: "false",
|
|
3912
|
+
is_frontier: "true",
|
|
3913
|
+
token: options.token,
|
|
3914
|
+
is_new_login: "1",
|
|
3915
|
+
need_short_url: "true"
|
|
3916
|
+
});
|
|
3917
|
+
const result = parsePollResult(response.body);
|
|
3918
|
+
if (result.status === "verify") emitLogDebug(`[douyin passport] 触发二次验证,服务端原始响应: ${response.raw.slice(0, 1e3)}`);
|
|
3919
|
+
if (result.status === "confirmed" && result.redirectUrl) await client.followSsoRedirect(result.redirectUrl);
|
|
3920
|
+
const sessionCookie = result.status === "confirmed" ? client.cookies.toString() : client.cookies.serialize();
|
|
3921
|
+
return createSuccessResponse$1({
|
|
3922
|
+
...result,
|
|
3923
|
+
cookie: sessionCookie,
|
|
3924
|
+
logged_in: client.cookies.isLoggedIn()
|
|
3925
|
+
}, "获取成功", 200);
|
|
3926
|
+
});
|
|
3927
|
+
}
|
|
3928
|
+
/**
|
|
3929
|
+
* 向账号绑定手机发送二次验证短信验证码
|
|
3930
|
+
*
|
|
3931
|
+
* 用于轮询返回 `status: 'verify'`(即 `error_code=2046` / `account_flow=verify`)的场景。
|
|
3932
|
+
* @param options - 发码参数
|
|
3933
|
+
* @param options.verify - 轮询返回的验证上下文
|
|
3934
|
+
* @param options.biz_trace_id - 追踪 ID (可选,不传自动生成)
|
|
3935
|
+
* @param cookie - 会话 Cookie
|
|
3936
|
+
* @param requestConfig - 请求配置 (可选)
|
|
3937
|
+
* @returns 脱敏手机号、重发等待秒数与追踪 ID
|
|
3938
|
+
*/
|
|
3939
|
+
async function sendPassportVerifyCode(options, cookie, requestConfig) {
|
|
3940
|
+
return run("passportSendCode", async () => {
|
|
3941
|
+
if (!options?.verify?.encryptUid) return passportError("passportSendCode", "缺少 encrypt_uid,请从轮询响应中取得验证上下文");
|
|
3942
|
+
const bizTraceId = options.biz_trace_id ?? randomHex(8);
|
|
3943
|
+
const verifyWay = resolveVerifyWay(options.verify, options.verify_way);
|
|
3944
|
+
emitLogDebug(`[douyin passport] 发码使用的验证方式: ${verifyWay}`);
|
|
3945
|
+
const response = await new DouyinPassportClient(cookie, requestConfig).liteRequest("/passport/web/send_code/", buildVerifyBody(options.verify, verifyWay, { is6Digits: "1" }), bizTraceId);
|
|
3946
|
+
const result = parseSendCodeResult(response.body);
|
|
3947
|
+
if (!result.ok) emitLogDebug(`[douyin passport] 发码失败原文: ${response.raw.slice(0, 500)}`);
|
|
3948
|
+
return createSuccessResponse$1({
|
|
3949
|
+
...result,
|
|
3950
|
+
cookie: response.cookie,
|
|
3951
|
+
biz_trace_id: bizTraceId,
|
|
3952
|
+
verify_way: verifyWay
|
|
3953
|
+
}, "获取成功", 200);
|
|
3954
|
+
});
|
|
3955
|
+
}
|
|
3956
|
+
/**
|
|
3957
|
+
* 提交二次验证的短信验证码
|
|
3958
|
+
* @param options - 验码参数
|
|
3959
|
+
* @param options.verify - 轮询返回的验证上下文
|
|
3960
|
+
* @param options.code - 用户收到的 6 位验证码明文
|
|
3961
|
+
* @param options.biz_trace_id - 必须与发码时用的是同一个
|
|
3962
|
+
* @param cookie - 会话 Cookie
|
|
3963
|
+
* @param requestConfig - 请求配置 (可选)
|
|
3964
|
+
* @returns 验证结果;`wrongCode` 为 true 表示验证码填错,可以让用户重试
|
|
3965
|
+
*/
|
|
3966
|
+
async function validatePassportVerifyCode(options, cookie, requestConfig) {
|
|
3967
|
+
return run("passportValidateCode", async () => {
|
|
3968
|
+
if (!options?.verify?.encryptUid) return passportError("passportValidateCode", "缺少 encrypt_uid,请从轮询响应中取得验证上下文");
|
|
3969
|
+
if (!options.code) return passportError("passportValidateCode", "缺少 code,请填入收到的短信验证码");
|
|
3970
|
+
const response = await new DouyinPassportClient(cookie, requestConfig).liteRequest("/passport/web/validate_code/", buildVerifyBody(options.verify, resolveVerifyWay(options.verify, options.verify_way), { code: xor5Hex(options.code) }), options.biz_trace_id ?? randomHex(8));
|
|
3971
|
+
const result = parseValidateCodeResult(response.body);
|
|
3972
|
+
if (!result.ok) emitLogDebug(`[douyin passport] 验码失败原文: ${response.raw.slice(0, 500)}`);
|
|
3973
|
+
return createSuccessResponse$1({
|
|
3974
|
+
...result,
|
|
3975
|
+
cookie: response.cookie
|
|
3976
|
+
}, "获取成功", 200);
|
|
3977
|
+
});
|
|
3978
|
+
}
|
|
3979
|
+
//#endregion
|
|
3980
|
+
//#region ../amagi/packages/core/src/platform/defaultConfigs.ts
|
|
3981
|
+
/**
|
|
3982
|
+
* 根据User-Agent生成对应的Sec-Ch-Ua值
|
|
3983
|
+
* @param userAgent - 用户代理字符串
|
|
3984
|
+
* @returns 对应的Sec-Ch-Ua值
|
|
3985
|
+
*/
|
|
3986
|
+
var generateSecChUa = (userAgent) => {
|
|
3987
|
+
const chromeMatch = userAgent.match(/Chrome\/(\d+)/);
|
|
3988
|
+
const chromeVersion = chromeMatch ? chromeMatch[1] : "125";
|
|
3989
|
+
return `"Not_A Brand";v="99", "Chromium";v="${chromeVersion}", "Google Chrome";v="${chromeVersion}"`;
|
|
3990
|
+
};
|
|
3991
|
+
/**
|
|
3992
|
+
* 抖音平台默认请求配置
|
|
3993
|
+
* @param cookie - 用户Cookie
|
|
3994
|
+
* @param requestConfig - 外部请求配置(优先级最高)
|
|
3995
|
+
* @returns 合并后的请求配置
|
|
3996
|
+
*/
|
|
3997
|
+
var getDouyinDefaultConfig = (cookie, requestConfig) => {
|
|
3998
|
+
let finalUserAgent = requestConfig?.headers?.["User-Agent"] ?? "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36";
|
|
3999
|
+
finalUserAgent = finalUserAgent.replace(/\s+Edg\/[\d.]+/g, "");
|
|
4000
|
+
const defHeaders = {
|
|
4001
|
+
Accept: "application/json, text/plain, */*",
|
|
4002
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
4003
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
|
4004
|
+
Cookie: cookie?.trim() ?? "",
|
|
4005
|
+
Priority: "u=1, i",
|
|
4006
|
+
Referer: "https://www.douyin.com/",
|
|
4007
|
+
"Sec-Ch-Ua": generateSecChUa(finalUserAgent),
|
|
4008
|
+
"Sec-Ch-Ua-Mobile": "?0",
|
|
4009
|
+
"Sec-Ch-Ua-Platform": "\"Windows\"",
|
|
4010
|
+
"Sec-Fetch-Dest": "empty",
|
|
4011
|
+
"Sec-Fetch-Mode": "cors",
|
|
4012
|
+
"Sec-Fetch-Site": "same-origin",
|
|
4013
|
+
"User-Agent": finalUserAgent
|
|
4014
|
+
};
|
|
4015
|
+
return {
|
|
4016
|
+
method: "GET",
|
|
4017
|
+
timeout: 1e4,
|
|
4018
|
+
...requestConfig,
|
|
4019
|
+
headers: {
|
|
4020
|
+
...defHeaders,
|
|
4021
|
+
...requestConfig?.headers ?? {}
|
|
4022
|
+
}
|
|
4023
|
+
};
|
|
4024
|
+
};
|
|
4025
|
+
/**
|
|
4026
|
+
* B站平台默认请求配置
|
|
4027
|
+
* @param cookie - 用户Cookie
|
|
4028
|
+
* @param requestConfig - 外部请求配置(优先级最高)
|
|
4029
|
+
* @returns 合并后的请求配置
|
|
4030
|
+
*/
|
|
4031
|
+
var getBilibiliDefaultConfig = (cookie, requestConfig) => {
|
|
4032
|
+
let finalUserAgent = requestConfig?.headers?.["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";
|
|
4033
|
+
finalUserAgent = finalUserAgent.replace(/\s+Edg\/[\d.]+/g, "");
|
|
4034
|
+
const defHeaders = {
|
|
4035
|
+
Accept: "application/json, text/plain, */*",
|
|
4036
|
+
"Accept-Language": "zh-CN,zh;q=0.9",
|
|
4037
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
4038
|
+
"Cache-Control": "no-cache",
|
|
4039
|
+
Pragma: "no-cache",
|
|
4040
|
+
Referer: "https://www.bilibili.com/",
|
|
4041
|
+
Priority: "u=1, i",
|
|
4042
|
+
"Sec-Ch-Ua": generateSecChUa(finalUserAgent),
|
|
4043
|
+
"Sec-Ch-Ua-Mobile": "?0",
|
|
4044
|
+
"Sec-Ch-Ua-Platform": "\"Windows\"",
|
|
4045
|
+
"Sec-Fetch-Dest": "empty",
|
|
4046
|
+
"Sec-Fetch-Mode": "cors",
|
|
4047
|
+
"Sec-Fetch-Site": "same-site",
|
|
4048
|
+
"User-Agent": finalUserAgent,
|
|
4049
|
+
Cookie: cookie?.trim() ?? ""
|
|
4050
|
+
};
|
|
4051
|
+
return {
|
|
4052
|
+
method: "GET",
|
|
4053
|
+
timeout: 1e4,
|
|
4054
|
+
...requestConfig,
|
|
4055
|
+
headers: {
|
|
4056
|
+
...defHeaders,
|
|
4057
|
+
...requestConfig?.headers ?? {}
|
|
4058
|
+
}
|
|
4059
|
+
};
|
|
4060
|
+
};
|
|
4061
|
+
/**
|
|
4062
|
+
* 快手平台默认请求配置
|
|
4063
|
+
* @param cookie - 用户Cookie
|
|
4064
|
+
* @param requestConfig - 外部请求配置(优先级最高)
|
|
4065
|
+
* @returns 合并后的请求配置
|
|
4066
|
+
*/
|
|
4067
|
+
var getKuaishouDefaultConfig = (cookie, requestConfig) => {
|
|
4068
|
+
const defHeaders = {
|
|
4069
|
+
Referer: "https://www.kuaishou.com/new-reco",
|
|
4070
|
+
Origin: "https://www.kuaishou.com",
|
|
4071
|
+
Accept: "application/json, text/plain, */*",
|
|
4072
|
+
"Accept-Encoding": "gzip, deflate, br, zstd",
|
|
4073
|
+
"Content-Type": "application/json",
|
|
4074
|
+
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
|
4075
|
+
Priority: "u=0, i",
|
|
4076
|
+
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36 Edg/130.0.0.0",
|
|
4077
|
+
Cookie: cookie?.trim() ?? ""
|
|
4078
|
+
};
|
|
4079
|
+
return {
|
|
4080
|
+
method: "POST",
|
|
4081
|
+
timeout: 1e4,
|
|
4082
|
+
...requestConfig,
|
|
4083
|
+
headers: {
|
|
4084
|
+
...defHeaders,
|
|
4085
|
+
...requestConfig?.headers ?? {}
|
|
4086
|
+
}
|
|
4087
|
+
};
|
|
4088
|
+
};
|
|
4089
|
+
/**
|
|
4090
|
+
* 获取小红书默认配置
|
|
4091
|
+
* @param cookie - 用户Cookie
|
|
4092
|
+
* @returns 小红书请求配置
|
|
4093
|
+
*/
|
|
4094
|
+
var getXiaohongshuDefaultConfig = (cookie) => {
|
|
4095
|
+
return { headers: {
|
|
4096
|
+
accept: "application/json, text/plain, */*",
|
|
4097
|
+
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8,en-GB;q=0.7,en-US;q=0.6",
|
|
4098
|
+
"cache-control": "no-cache",
|
|
4099
|
+
"content-type": "application/json;charset=UTF-8",
|
|
4100
|
+
pragma: "no-cache",
|
|
4101
|
+
priority: "u=1, i",
|
|
4102
|
+
referer: "https://www.xiaohongshu.com/",
|
|
4103
|
+
"sec-ch-ua": "\"Microsoft Edge\";v=\"141\", \"Not?A_Brand\";v=\"8\", \"Chromium\";v=\"141\"",
|
|
4104
|
+
"sec-ch-ua-mobile": "?0",
|
|
4105
|
+
"sec-ch-ua-platform": "\"Windows\"",
|
|
4106
|
+
"sec-fetch-dest": "empty",
|
|
4107
|
+
"sec-fetch-mode": "cors",
|
|
4108
|
+
"sec-fetch-site": "same-site",
|
|
4109
|
+
"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",
|
|
4110
|
+
cookie: cookie ?? ""
|
|
4111
|
+
} };
|
|
4112
|
+
};
|
|
4113
|
+
//#endregion
|
|
2463
4114
|
//#region ../amagi/packages/core/src/platform/douyin/sign/a_bogus.ts
|
|
2464
4115
|
var SM3 = class {
|
|
2465
4116
|
reg;
|
|
@@ -4745,6 +6396,10 @@ function createBoundDouyinFetcher(cookie, requestConfig) {
|
|
|
4745
6396
|
* ```
|
|
4746
6397
|
*/
|
|
4747
6398
|
var douyinFetcher$1 = {
|
|
6399
|
+
requestPassportQrcode,
|
|
6400
|
+
checkPassportQrcode,
|
|
6401
|
+
sendPassportVerifyCode,
|
|
6402
|
+
validatePassportVerifyCode,
|
|
4748
6403
|
fetchVideoWork: fetchVideoWork$1,
|
|
4749
6404
|
fetchImageAlbumWork,
|
|
4750
6405
|
fetchSlidesWork,
|
|
@@ -8777,6 +10432,7 @@ var createDouyinRoutes = (cookie, requestConfig = getDouyinDefaultConfig(cookie)
|
|
|
8777
10432
|
/** 抖音相关功能模块 (工具集) */
|
|
8778
10433
|
var douyinUtils = {
|
|
8779
10434
|
sign: douyinSign,
|
|
10435
|
+
passport: passport_exports,
|
|
8780
10436
|
douyinApiUrls
|
|
8781
10437
|
};
|
|
8782
10438
|
//#endregion
|
|
@@ -9549,7 +11205,7 @@ function getApiRoute(platform, methodType) {
|
|
|
9549
11205
|
* 构建后使用 __VERSION__,开发环境从 package.json 读取
|
|
9550
11206
|
*/
|
|
9551
11207
|
var getVersion = () => {
|
|
9552
|
-
return "6.
|
|
11208
|
+
return "6.6.0";
|
|
9553
11209
|
};
|
|
9554
11210
|
var VERSION = getVersion();
|
|
9555
11211
|
/**
|
|
@@ -10341,11 +11997,11 @@ var Common = new class Tools {
|
|
|
10341
11997
|
constructor() {
|
|
10342
11998
|
this.tempDri = {
|
|
10343
11999
|
/** 插件缓存目录 */
|
|
10344
|
-
default: `${karinPathTemp
|
|
12000
|
+
default: `${karinPathTemp}/${Root.pluginName}/`.replace(/\\/g, "/"),
|
|
10345
12001
|
/** 视频缓存文件 */
|
|
10346
|
-
video: `${karinPathTemp
|
|
12002
|
+
video: `${karinPathTemp}/${Root.pluginName}/kkkdownload/video/`.replace(/\\/g, "/"),
|
|
10347
12003
|
/** 图片缓存文件 */
|
|
10348
|
-
images: `${karinPathTemp
|
|
12004
|
+
images: `${karinPathTemp}/${Root.pluginName}/kkkdownload/images/`.replace(/\\/g, "/")
|
|
10349
12005
|
};
|
|
10350
12006
|
this.videoPreviewState = /* @__PURE__ */ new Map();
|
|
10351
12007
|
setInterval(() => {
|
|
@@ -25229,9 +26885,8 @@ var DouyinPosterHeader = ({ data, ctx }) => {
|
|
|
25229
26885
|
})]
|
|
25230
26886
|
});
|
|
25231
26887
|
};
|
|
25232
|
-
var DouyinVideoCover = (
|
|
25233
|
-
const
|
|
25234
|
-
const durationText = formatDuration$1(duration);
|
|
26888
|
+
var DouyinVideoCover = (props) => {
|
|
26889
|
+
const durationText = formatDuration$1(props.data.duration);
|
|
25235
26890
|
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", {
|
|
25236
26891
|
className: "relative -mx-20 mt-12 overflow-visible",
|
|
25237
26892
|
children: [
|
|
@@ -25242,7 +26897,7 @@ var DouyinVideoCover = ({ data }) => {
|
|
|
25242
26897
|
WebkitMaskImage: ambientCoverMask
|
|
25243
26898
|
},
|
|
25244
26899
|
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", {
|
|
25245
|
-
src: image_url,
|
|
26900
|
+
src: props.data.image_url,
|
|
25246
26901
|
alt: "",
|
|
25247
26902
|
className: "h-full w-full scale-[1.16] object-cover opacity-48 blur-[72px] saturate-[1.2]",
|
|
25248
26903
|
referrerPolicy: "no-referrer",
|
|
@@ -25250,7 +26905,7 @@ var DouyinVideoCover = ({ data }) => {
|
|
|
25250
26905
|
})
|
|
25251
26906
|
}),
|
|
25252
26907
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", {
|
|
25253
|
-
src: image_url,
|
|
26908
|
+
src: props.data.image_url,
|
|
25254
26909
|
alt: "视频封面",
|
|
25255
26910
|
className: "relative z-10 block h-auto w-full drop-shadow-xl",
|
|
25256
26911
|
style: {
|
|
@@ -25260,12 +26915,30 @@ var DouyinVideoCover = ({ data }) => {
|
|
|
25260
26915
|
referrerPolicy: "no-referrer",
|
|
25261
26916
|
crossOrigin: "anonymous"
|
|
25262
26917
|
}),
|
|
25263
|
-
/* @__PURE__ */ (0, import_jsx_runtime.
|
|
25264
|
-
className: "absolute
|
|
25265
|
-
children: durationText && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
25266
|
-
className: "text-
|
|
26918
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
26919
|
+
className: "absolute inset-x-24 top-10 z-30 flex items-start justify-between text-foreground/85",
|
|
26920
|
+
children: [durationText && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
26921
|
+
className: "text-4xl font-black leading-none tabular-nums",
|
|
25267
26922
|
children: ["时长: ", durationText]
|
|
25268
|
-
})
|
|
26923
|
+
}), props.data.resolution && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
26924
|
+
className: "ml-auto flex flex-col items-end gap-1.5",
|
|
26925
|
+
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
26926
|
+
className: "text-3xl font-black leading-none tracking-[0.08em] select-text",
|
|
26927
|
+
children: [props.data.resolution.name, props.data.is_HDR && (isDark(props.ctx) ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GlowText, {
|
|
26928
|
+
glowStrength: 1,
|
|
26929
|
+
blurRadius: 10,
|
|
26930
|
+
children: ` · HDR`
|
|
26931
|
+
}) : ` · HDR`)]
|
|
26932
|
+
}), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
|
|
26933
|
+
className: "text-2xl font-bold tracking-[0.08em] text-foreground/60 tabular-nums select-text",
|
|
26934
|
+
children: [
|
|
26935
|
+
props.data.resolution.width,
|
|
26936
|
+
" × ",
|
|
26937
|
+
props.data.resolution.height,
|
|
26938
|
+
" px"
|
|
26939
|
+
]
|
|
26940
|
+
})]
|
|
26941
|
+
})]
|
|
25269
26942
|
}),
|
|
25270
26943
|
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(a, {
|
|
25271
26944
|
size: 104,
|
|
@@ -25273,15 +26946,15 @@ var DouyinVideoCover = ({ data }) => {
|
|
|
25273
26946
|
"aria-label": "播放",
|
|
25274
26947
|
className: "absolute bottom-12 right-24 z-30 text-white/50"
|
|
25275
26948
|
}),
|
|
25276
|
-
music && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
26949
|
+
props.data.music && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
|
|
25277
26950
|
className: "absolute bottom-12 left-24 z-30 flex max-w-212.5 items-center gap-5 text-foreground/85",
|
|
25278
|
-
children: [music.cover ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
26951
|
+
children: [props.data.music.cover ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
25279
26952
|
className: "relative h-20 w-20 shrink-0",
|
|
25280
26953
|
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(GlowImage, {
|
|
25281
26954
|
glowStrength: 1,
|
|
25282
26955
|
blurRadius: 20,
|
|
25283
26956
|
children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("img", {
|
|
25284
|
-
src: music.cover,
|
|
26957
|
+
src: props.data.music.cover,
|
|
25285
26958
|
alt: "BGM封面",
|
|
25286
26959
|
className: "relative z-10 h-full w-full rounded-2xl object-cover",
|
|
25287
26960
|
referrerPolicy: "no-referrer",
|
|
@@ -25296,10 +26969,10 @@ var DouyinVideoCover = ({ data }) => {
|
|
|
25296
26969
|
className: "min-w-0",
|
|
25297
26970
|
children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
25298
26971
|
className: "truncate text-[36px] font-black leading-tight select-text",
|
|
25299
|
-
children: music.title
|
|
26972
|
+
children: props.data.music.title
|
|
25300
26973
|
}), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
|
|
25301
26974
|
className: "truncate text-[27px] font-semibold text-foreground/60 select-text",
|
|
25302
|
-
children: music.author
|
|
26975
|
+
children: props.data.music.author
|
|
25303
26976
|
})]
|
|
25304
26977
|
})]
|
|
25305
26978
|
})
|
|
@@ -37211,6 +38884,196 @@ async function burnDouyinDanmaku(videoPath, danmakuList, outputPath, options = {
|
|
|
37211
38884
|
return result.status;
|
|
37212
38885
|
}
|
|
37213
38886
|
//#endregion
|
|
38887
|
+
//#region src/platform/douyin/videoQuality.ts
|
|
38888
|
+
/** 档位优先级,从高到低 */
|
|
38889
|
+
var QUALITY_PRIORITY = [
|
|
38890
|
+
"4k",
|
|
38891
|
+
"2k",
|
|
38892
|
+
"1080p",
|
|
38893
|
+
"720p",
|
|
38894
|
+
"540p"
|
|
38895
|
+
];
|
|
38896
|
+
/**
|
|
38897
|
+
* `video_extra.definition` → 内部档位名。
|
|
38898
|
+
* 抖音上游把 2K 记作 `1440p`,而配置项 `videoQuality` 用的是 `2k`,
|
|
38899
|
+
* 这张表是两套词表唯一的翻译点,配置域不需要跟着上游改。
|
|
38900
|
+
*/
|
|
38901
|
+
var DEFINITION_TO_LEVEL = {
|
|
38902
|
+
"4k": "4k",
|
|
38903
|
+
"2k": "2k",
|
|
38904
|
+
"1440p": "2k",
|
|
38905
|
+
"1080p": "1080p",
|
|
38906
|
+
"720p": "720p",
|
|
38907
|
+
"540p": "540p"
|
|
38908
|
+
};
|
|
38909
|
+
/** 档位 → 展示标签,沿用抖音 Web 端播放器的说法 */
|
|
38910
|
+
var QUALITY_LABEL = {
|
|
38911
|
+
"4k": "超清4K",
|
|
38912
|
+
"2k": "超清2K",
|
|
38913
|
+
"1080p": "高清1080P",
|
|
38914
|
+
"720p": "高清720P",
|
|
38915
|
+
"540p": "标清540P"
|
|
38916
|
+
};
|
|
38917
|
+
/**
|
|
38918
|
+
* 安全解析 `video_extra`
|
|
38919
|
+
* @param extra - 原始 JSON 字符串
|
|
38920
|
+
* @returns 解析结果,为空或非法 JSON 时返回 null
|
|
38921
|
+
*/
|
|
38922
|
+
var parseVideoExtra = (extra) => {
|
|
38923
|
+
if (!extra) return null;
|
|
38924
|
+
try {
|
|
38925
|
+
return JSON.parse(extra);
|
|
38926
|
+
} catch {
|
|
38927
|
+
return null;
|
|
38928
|
+
}
|
|
38929
|
+
};
|
|
38930
|
+
/**
|
|
38931
|
+
* 从 `gear_name` 猜测档位。
|
|
38932
|
+
* 只作为 `video_extra.definition` 缺失时的兜底:抖音随时可能新增 gear_name,
|
|
38933
|
+
* 认不出来就返回 undefined 交给调用方处理,不再默认落到 540p 污染分档。
|
|
38934
|
+
* @param gearName - 视频源的 gear_name
|
|
38935
|
+
* @returns 档位名,认不出时返回 undefined
|
|
38936
|
+
*/
|
|
38937
|
+
var guessLevelFromGearName = (gearName) => {
|
|
38938
|
+
if (gearName.includes("lowest_4") || gearName.includes("2160")) return "4k";
|
|
38939
|
+
if (gearName.includes("1440") || gearName.includes("2k")) return "2k";
|
|
38940
|
+
if (gearName.includes("1080")) return "1080p";
|
|
38941
|
+
if (gearName.includes("720")) return "720p";
|
|
38942
|
+
if (gearName.includes("540")) return "540p";
|
|
38943
|
+
};
|
|
38944
|
+
/**
|
|
38945
|
+
* 判定单个视频源的清晰度档位。
|
|
38946
|
+
* `video_extra.definition` 是唯一可信来源:抖音视频比例不固定(存在 21:9、1:1 等),
|
|
38947
|
+
* 一个 3840x1608 的视频不满足 3840x2160 但官方仍定义为 4K,所以不能拿 play_addr 的宽高硬匹配。
|
|
38948
|
+
* @param video - 视频源对象
|
|
38949
|
+
* @returns 档位名,无法判定时返回 undefined
|
|
38950
|
+
*/
|
|
38951
|
+
var getDouyinQualityLevel = (video) => {
|
|
38952
|
+
const definition = parseVideoExtra(video.video_extra)?.definition;
|
|
38953
|
+
return (definition ? DEFINITION_TO_LEVEL[definition] : void 0) ?? guessLevelFromGearName(video.gear_name ?? "");
|
|
38954
|
+
};
|
|
38955
|
+
/**
|
|
38956
|
+
* 同档位内的取源顺序:先 H.264,再按体积从大到小。
|
|
38957
|
+
* 1080p / 720p 的 H.265(ByteVC1)在部分协议端软解失败,同档位下 H.264 更稳;
|
|
38958
|
+
* 4K / 2K 上游通常只提供 H.265,此时全组同权重,自然退化为按体积排序。
|
|
38959
|
+
* @param a - 视频源 A
|
|
38960
|
+
* @param b - 视频源 B
|
|
38961
|
+
* @returns 排序比较值
|
|
38962
|
+
*/
|
|
38963
|
+
var compareVideoPreference = (a, b) => {
|
|
38964
|
+
if (a.is_bytevc1 !== b.is_bytevc1) return a.is_bytevc1 - b.is_bytevc1;
|
|
38965
|
+
return b.play_addr.data_size - a.play_addr.data_size;
|
|
38966
|
+
};
|
|
38967
|
+
/**
|
|
38968
|
+
* 按档位给 mp4 视频源分组,组内已按 {@link compareVideoPreference} 排好序
|
|
38969
|
+
* @param videos - 视频源数组
|
|
38970
|
+
* @returns 档位 → 该档位下的视频源
|
|
38971
|
+
*/
|
|
38972
|
+
var groupByQualityLevel = (videos) => {
|
|
38973
|
+
const grouped = /* @__PURE__ */ new Map();
|
|
38974
|
+
for (const video of videos) {
|
|
38975
|
+
const level = getDouyinQualityLevel(video);
|
|
38976
|
+
if (!level) {
|
|
38977
|
+
logger.debug(`无法判定清晰度档位,跳过该视频源:gear_name=${video.gear_name}`);
|
|
38978
|
+
continue;
|
|
38979
|
+
}
|
|
38980
|
+
const bucket = grouped.get(level);
|
|
38981
|
+
if (bucket) bucket.push(video);
|
|
38982
|
+
else grouped.set(level, [video]);
|
|
38983
|
+
}
|
|
38984
|
+
grouped.forEach((bucket) => bucket.sort(compareVideoPreference));
|
|
38985
|
+
return grouped;
|
|
38986
|
+
};
|
|
38987
|
+
/**
|
|
38988
|
+
* 构建抖音视频播放地址(二维码分享链接与下载共用)。
|
|
38989
|
+
* 上游 play 接口现在除 video_id 外还要求携带 file_id(从 play_addr.url_list[2] 的查询参数中提取),
|
|
38990
|
+
* 旧的 ratio/line 参数不再需要;file_id 提取失败时回退为仅带 video_id。
|
|
38991
|
+
* @param playAddr - 视频源的 play_addr 对象(bit_rate 项或 images 项的 play_addr_h264)
|
|
38992
|
+
* @returns 完整的播放地址
|
|
38993
|
+
*/
|
|
38994
|
+
var buildDouyinPlayUrl = (playAddr) => {
|
|
38995
|
+
const fileId = (() => {
|
|
38996
|
+
try {
|
|
38997
|
+
const wrappedUrl = playAddr.url_list?.[2];
|
|
38998
|
+
return wrappedUrl ? new URL(wrappedUrl).searchParams.get("file_id") : null;
|
|
38999
|
+
} catch {
|
|
39000
|
+
return null;
|
|
39001
|
+
}
|
|
39002
|
+
})();
|
|
39003
|
+
return fileId ? `https://c/aweme/v1/play/?video_id=${playAddr.uri}&file_id=${fileId}` : `https://aweme.snssdk.com/aweme/v1/play/?video_id=${playAddr.uri}`;
|
|
39004
|
+
};
|
|
39005
|
+
/**
|
|
39006
|
+
* 把选中的视频源格式化成展示用的清晰度字符串。
|
|
39007
|
+
* 传入的必须是 {@link douyinProcessVideos} 选中的那一路源 —— 卡片上展示的清晰度要和实际下载的一致,
|
|
39008
|
+
* 否则会出现「卡片写 4K、实际下载 720p」的错位。
|
|
39009
|
+
* @param video - 选中的视频源
|
|
39010
|
+
* @returns 形如 `超清4K`;档位认不出时返回空字符串
|
|
39011
|
+
*/
|
|
39012
|
+
var formatDouyinQualityLabel = (video) => {
|
|
39013
|
+
if (!video) return "";
|
|
39014
|
+
const level = getDouyinQualityLevel(video);
|
|
39015
|
+
if (!level) return "";
|
|
39016
|
+
return QUALITY_LABEL[level];
|
|
39017
|
+
};
|
|
39018
|
+
/**
|
|
39019
|
+
* 按画质偏好从抖音视频源列表里挑出唯一一路可下载的源
|
|
39020
|
+
* @param videos - `aweme_detail.video.bit_rate` 数组
|
|
39021
|
+
* @param videoQuality - 画质偏好,`adapt` 为按体积上限自动选择,其余为固定档位
|
|
39022
|
+
* @param maxAutoVideoSize - 自动模式下可接受的最大体积(MB),缺省回落到 `Config.app.filelimit`
|
|
39023
|
+
* @returns 长度为 1 的数组,元素为选中的视频源
|
|
39024
|
+
*/
|
|
39025
|
+
var douyinProcessVideos = (videos, videoQuality, maxAutoVideoSize) => {
|
|
39026
|
+
const mp4Videos = videos.filter((video) => video.format === "mp4");
|
|
39027
|
+
if (mp4Videos.length === 0) {
|
|
39028
|
+
logger.warn("没有找到可用的 mp4 格式视频");
|
|
39029
|
+
return videos.slice(0, 1);
|
|
39030
|
+
}
|
|
39031
|
+
logger.debug(`过滤后剩余 ${mp4Videos.length} 个 mp4 格式视频`);
|
|
39032
|
+
const videosByQuality = groupByQualityLevel(mp4Videos);
|
|
39033
|
+
if (videoQuality === "adapt") {
|
|
39034
|
+
const sizeLimitBytes = (maxAutoVideoSize || Config.app.filelimit) * 1024 * 1024;
|
|
39035
|
+
for (const quality of QUALITY_PRIORITY) {
|
|
39036
|
+
const qualityVideos = videosByQuality.get(quality);
|
|
39037
|
+
if (qualityVideos && qualityVideos.length > 0) {
|
|
39038
|
+
const suitableVideo = qualityVideos.find((video) => video.play_addr.data_size <= sizeLimitBytes);
|
|
39039
|
+
if (suitableVideo) {
|
|
39040
|
+
logger.debug(`自动选择画质: ${quality}, 文件大小: ${(suitableVideo.play_addr.data_size / (1024 * 1024)).toFixed(2)}MB`);
|
|
39041
|
+
return [suitableVideo];
|
|
39042
|
+
}
|
|
39043
|
+
}
|
|
39044
|
+
}
|
|
39045
|
+
let smallestVideo = mp4Videos[0];
|
|
39046
|
+
mp4Videos.forEach((video) => {
|
|
39047
|
+
if (video.play_addr.data_size < smallestVideo.play_addr.data_size) smallestVideo = video;
|
|
39048
|
+
});
|
|
39049
|
+
logger.debug(`未找到符合大小限制的视频,选择最小视频: ${(smallestVideo.play_addr.data_size / (1024 * 1024)).toFixed(2)}MB`);
|
|
39050
|
+
return [smallestVideo];
|
|
39051
|
+
}
|
|
39052
|
+
const targetQuality = videoQuality;
|
|
39053
|
+
const targetVideos = videosByQuality.get(targetQuality);
|
|
39054
|
+
if (targetVideos && targetVideos.length > 0) {
|
|
39055
|
+
logger.debug(`选择固定画质: ${targetQuality}, 文件大小: ${(targetVideos[0].play_addr.data_size / (1024 * 1024)).toFixed(2)}MB`);
|
|
39056
|
+
return [targetVideos[0]];
|
|
39057
|
+
}
|
|
39058
|
+
const targetIndex = QUALITY_PRIORITY.indexOf(targetQuality);
|
|
39059
|
+
for (let i = targetIndex + 1; i < QUALITY_PRIORITY.length; i++) {
|
|
39060
|
+
const fallbackVideos = videosByQuality.get(QUALITY_PRIORITY[i]);
|
|
39061
|
+
if (fallbackVideos && fallbackVideos.length > 0) {
|
|
39062
|
+
logger.debug(`目标画质 ${targetQuality} 不可用,降级到: ${QUALITY_PRIORITY[i]}`);
|
|
39063
|
+
return [fallbackVideos[0]];
|
|
39064
|
+
}
|
|
39065
|
+
}
|
|
39066
|
+
for (let i = targetIndex - 1; i >= 0; i--) {
|
|
39067
|
+
const fallbackVideos = videosByQuality.get(QUALITY_PRIORITY[i]);
|
|
39068
|
+
if (fallbackVideos && fallbackVideos.length > 0) {
|
|
39069
|
+
logger.debug(`目标画质 ${targetQuality} 不可用,升级到: ${QUALITY_PRIORITY[i]}`);
|
|
39070
|
+
return [fallbackVideos[0]];
|
|
39071
|
+
}
|
|
39072
|
+
}
|
|
39073
|
+
logger.warn("未找到任何匹配的画质,返回默认视频");
|
|
39074
|
+
return [mp4Videos[0]];
|
|
39075
|
+
};
|
|
39076
|
+
//#endregion
|
|
37214
39077
|
//#region src/platform/douyin/workType.ts
|
|
37215
39078
|
/**
|
|
37216
39079
|
* 抖音作品类型工具模块
|
|
@@ -37337,7 +39200,7 @@ function getDouyinLiveImageSendPolicy(clipType, livePhotoMode = "video_and_livep
|
|
|
37337
39200
|
* @returns 如果描述为空则返回默认提示,否则返回原文
|
|
37338
39201
|
*/
|
|
37339
39202
|
function desc(Desc) {
|
|
37340
|
-
return Desc
|
|
39203
|
+
return !Desc ? "该作品没有描述" : Desc;
|
|
37341
39204
|
}
|
|
37342
39205
|
/**
|
|
37343
39206
|
* 构建合作信息数据
|
|
@@ -37356,8 +39219,8 @@ function buildCooperationInfo(Detail_Data) {
|
|
|
37356
39219
|
const co_creators = rawCreators.map((c) => {
|
|
37357
39220
|
return {
|
|
37358
39221
|
avatar_url: c.avatar_thumb?.url_list?.[0] ?? (c.avatar_thumb?.uri ? `https://p3.douyinpic.com/${c.avatar_thumb.uri}` : void 0),
|
|
37359
|
-
nickname: c.nickname,
|
|
37360
|
-
role_title: c.role_title
|
|
39222
|
+
nickname: c.nickname ?? "",
|
|
39223
|
+
role_title: c.role_title ?? ""
|
|
37361
39224
|
};
|
|
37362
39225
|
});
|
|
37363
39226
|
if (Detail_Data.author && !rawCreators.some((c) => Detail_Data.author?.uid && c.uid && c.uid === Detail_Data.author.uid || Detail_Data.author?.sec_uid && c.sec_uid && c.sec_uid === Detail_Data.author.sec_uid || Detail_Data.author?.nickname && c.nickname && c.nickname === Detail_Data.author.nickname)) co_creators.unshift({
|
|
@@ -37646,7 +39509,7 @@ function getDefaultPushLabel(workTypeInfo) {
|
|
|
37646
39509
|
* @returns 渲染后的图片元素数组
|
|
37647
39510
|
*/
|
|
37648
39511
|
async function renderWorkImage(options) {
|
|
37649
|
-
const { e, Detail_Data, create_time, shareLink, skipWatermark = false } = options;
|
|
39512
|
+
const { e, Detail_Data, create_time, shareLink, skipWatermark = false, videoSource } = options;
|
|
37650
39513
|
const workTypeInfo = getWorkTypeInfo(Detail_Data);
|
|
37651
39514
|
const dynamicTypeLabel = options.dynamicTypeLabel ?? getDefaultPushLabel(workTypeInfo);
|
|
37652
39515
|
const coverUrl = getWorkCoverUrl(workTypeInfo, Detail_Data);
|
|
@@ -37661,10 +39524,12 @@ async function renderWorkImage(options) {
|
|
|
37661
39524
|
const renderOpts = skipWatermark ? { skipWatermark: true } : void 0;
|
|
37662
39525
|
switch (workTypeInfo.mainType) {
|
|
37663
39526
|
case DouyinWorkMainType.ARTICLE: {
|
|
37664
|
-
const
|
|
37665
|
-
|
|
39527
|
+
const articleInfo = Detail_Data.article_info;
|
|
39528
|
+
if (!articleInfo) return [];
|
|
39529
|
+
const content = JSON.parse(articleInfo.article_content);
|
|
39530
|
+
const fe_data = JSON.parse(articleInfo.fe_data);
|
|
37666
39531
|
return await Render(e, "douyin/article-work", {
|
|
37667
|
-
title:
|
|
39532
|
+
title: articleInfo.article_title,
|
|
37668
39533
|
markdown: content.markdown,
|
|
37669
39534
|
images: fe_data.image_list || [],
|
|
37670
39535
|
read_time: fe_data.read_time || 0,
|
|
@@ -37690,6 +39555,12 @@ async function renderWorkImage(options) {
|
|
|
37690
39555
|
suggest_word: extractSuggestWord(Detail_Data),
|
|
37691
39556
|
music: buildMusicInfo(Detail_Data.music),
|
|
37692
39557
|
duration: Detail_Data.duration,
|
|
39558
|
+
resolution: videoSource ? {
|
|
39559
|
+
name: formatDouyinQualityLabel(videoSource),
|
|
39560
|
+
width: videoSource.play_addr.width,
|
|
39561
|
+
height: videoSource.play_addr.height
|
|
39562
|
+
} : void 0,
|
|
39563
|
+
is_HDR: videoSource ? String(videoSource.HDR_type) === "1" : false,
|
|
37693
39564
|
dianzan: Count(Detail_Data.statistics.digg_count),
|
|
37694
39565
|
pinglun: Count(Detail_Data.statistics.comment_count),
|
|
37695
39566
|
share: Count(Detail_Data.statistics.share_count),
|
|
@@ -37813,8 +39684,8 @@ async function renderLiveImage(options) {
|
|
|
37813
39684
|
const streamExtra = liveItem.stream_url?.extra;
|
|
37814
39685
|
const resolution = streamExtra ? `${streamExtra.width}x${streamExtra.height}` : liveItem.stream_url?.default_resolution;
|
|
37815
39686
|
return await Render(e, "douyin/live", {
|
|
37816
|
-
image_url: liveItem.cover?.url_list[0],
|
|
37817
|
-
text: liveItem.title,
|
|
39687
|
+
image_url: liveItem.cover ? liveItem.cover?.url_list[0] : "",
|
|
39688
|
+
text: liveItem.title ?? "",
|
|
37818
39689
|
partition_title: Detail_Data.live_data.data.data.partition_road_map?.partition?.title || "未知分区",
|
|
37819
39690
|
room_id: room_data.owner.web_rid,
|
|
37820
39691
|
online_viewers: Count(Number(liveItem.room_view_stats?.display_value)),
|
|
@@ -37825,8 +39696,8 @@ async function renderLiveImage(options) {
|
|
|
37825
39696
|
share_url: "https://live.douyin.com/" + room_data.owner.web_rid,
|
|
37826
39697
|
dynamicTYPE: dynamicTypeLabel,
|
|
37827
39698
|
like_count: Count(Number(liveItem.like_count || 0)),
|
|
37828
|
-
user_count_str: liveItem.user_count_str
|
|
37829
|
-
resolution,
|
|
39699
|
+
user_count_str: liveItem.user_count_str ?? "",
|
|
39700
|
+
resolution: resolution ?? "",
|
|
37830
39701
|
signature: user.signature || "",
|
|
37831
39702
|
city: user.city || "",
|
|
37832
39703
|
aweme_count: Count(user.aweme_count || 0),
|
|
@@ -37836,6 +39707,20 @@ async function renderLiveImage(options) {
|
|
|
37836
39707
|
}, skipWatermark ? { skipWatermark: true } : void 0);
|
|
37837
39708
|
}
|
|
37838
39709
|
//#endregion
|
|
39710
|
+
//#region src/platform/douyin/types.ts
|
|
39711
|
+
/**
|
|
39712
|
+
* 把 amagi 返回的 aweme 对象装配成渲染/推送使用的作品详情。
|
|
39713
|
+
* amagi 的生成类型在 text_extra(实为对象数组)、images(图文作品实为数组)等字段上
|
|
39714
|
+
* 与真实数据不符,统一在这一处边界断言,下游全部享受精确类型。
|
|
39715
|
+
* 返回类型会带上 extra 的实际形状,例如传入 user_info 后下游可把它当作必填字段使用。
|
|
39716
|
+
* @param aweme - amagi 返回的作品详情(aweme_detail 或作品列表项)
|
|
39717
|
+
* @param extra - 流程附加字段(作者/订阅者主页信息)
|
|
39718
|
+
*/
|
|
39719
|
+
var buildDouyinWorkDetail = (aweme, extra = {}) => ({
|
|
39720
|
+
...aweme,
|
|
39721
|
+
...extra
|
|
39722
|
+
});
|
|
39723
|
+
//#endregion
|
|
37839
39724
|
//#region src/platform/douyin/douyin.ts
|
|
37840
39725
|
var mp4size = "";
|
|
37841
39726
|
var img;
|
|
@@ -37923,7 +39808,7 @@ var DouYin = class DouYin extends Base {
|
|
|
37923
39808
|
continue;
|
|
37924
39809
|
}
|
|
37925
39810
|
/** live 图 */
|
|
37926
|
-
const liveimg = await downloadFile(
|
|
39811
|
+
const liveimg = await downloadFile(buildDouyinPlayUrl(imageItem.video.play_addr_h264), {
|
|
37927
39812
|
title: `Douyin_tmp_V_${Date.now()}.mp4`,
|
|
37928
39813
|
headers: this.headers
|
|
37929
39814
|
});
|
|
@@ -38079,7 +39964,7 @@ var DouYin = class DouYin extends Base {
|
|
|
38079
39964
|
continue;
|
|
38080
39965
|
}
|
|
38081
39966
|
/** 动图/短片 */
|
|
38082
|
-
const livePhoto = await downloadFile(
|
|
39967
|
+
const livePhoto = await downloadFile(buildDouyinPlayUrl(item.video.play_addr_h264), {
|
|
38083
39968
|
title: `Douyin_tmp_V_${Date.now()}.mp4`,
|
|
38084
39969
|
headers: this.headers
|
|
38085
39970
|
});
|
|
@@ -38208,7 +40093,13 @@ var DouYin = class DouYin extends Base {
|
|
|
38208
40093
|
}
|
|
38209
40094
|
/** 视频 */
|
|
38210
40095
|
let FPS;
|
|
40096
|
+
/**
|
|
40097
|
+
* 图文/文章作品的 video 字段不含 bit_rate,不能无条件初始化,
|
|
40098
|
+
* 否则会在这里直接抛 TypeError;仅在视频分支内赋值,其余场景保持 null。
|
|
40099
|
+
*/
|
|
38211
40100
|
let video = null;
|
|
40101
|
+
/** 按画质偏好选中、即将下载发送的那一路视频源 */
|
|
40102
|
+
let selectedVideo = null;
|
|
38212
40103
|
if (isVideo) {
|
|
38213
40104
|
video = VideoData.data.aweme_detail.video;
|
|
38214
40105
|
FPS = video.bit_rate[0]?.FPS ?? "获取失败";
|
|
@@ -38217,10 +40108,11 @@ var DouYin = class DouYin extends Base {
|
|
|
38217
40108
|
视频ID:${logger.green(VideoData.data.aweme_detail.aweme_id)}\n
|
|
38218
40109
|
分享链接:${logger.green(VideoData.data.aweme_detail.share_url)}
|
|
38219
40110
|
`);
|
|
38220
|
-
|
|
38221
|
-
|
|
40111
|
+
selectedVideo = douyinProcessVideos(video.bit_rate, Config.douyin.videoQuality, Config.douyin.maxAutoVideoSize)[0] ?? null;
|
|
40112
|
+
if (!selectedVideo) throw new Error(`未找到可用的视频源,aweme_id=${VideoData.data.aweme_detail.aweme_id}`);
|
|
40113
|
+
g_video_url = selectedVideo.play_addr.url_list[0] ?? selectedVideo.play_addr.url_list[1] ?? selectedVideo.play_addr.url_list[2];
|
|
38222
40114
|
g_title = VideoData.data.aweme_detail.preview_title.substring(0, 80).replace(/[\\/:*?"<>|\r\n]/g, " ");
|
|
38223
|
-
mp4size = (
|
|
40115
|
+
mp4size = (selectedVideo.play_addr.data_size / (1024 * 1024)).toFixed(2);
|
|
38224
40116
|
}
|
|
38225
40117
|
if (Config.douyin.sendContent.includes("info")) if (Config.douyin.videoInfoMode === "text") {
|
|
38226
40118
|
const replyContent = [];
|
|
@@ -38247,13 +40139,11 @@ var DouYin = class DouYin extends Base {
|
|
|
38247
40139
|
sec_uid: aweme.author.sec_uid,
|
|
38248
40140
|
typeMode: "strict"
|
|
38249
40141
|
});
|
|
38250
|
-
const shareLink = isVideo ?
|
|
40142
|
+
const shareLink = isVideo && selectedVideo ? buildDouyinPlayUrl(selectedVideo.play_addr) : `https://www.douyin.com/${isArticle ? "article" : "note"}/${aweme.aweme_id}`;
|
|
38251
40143
|
const workInfoImg = await renderWorkImage({
|
|
38252
40144
|
e: this.e,
|
|
38253
|
-
Detail_Data: {
|
|
38254
|
-
|
|
38255
|
-
user_info: userProfile
|
|
38256
|
-
},
|
|
40145
|
+
Detail_Data: buildDouyinWorkDetail(aweme, { user_info: userProfile }),
|
|
40146
|
+
videoSource: selectedVideo,
|
|
38257
40147
|
create_time: aweme.create_time,
|
|
38258
40148
|
shareLink,
|
|
38259
40149
|
dynamicTypeLabel: isArticle ? "文章作品" : isVideo ? "视频作品" : this.is_slides ? "合辑作品" : "图文作品"
|
|
@@ -38275,13 +40165,13 @@ var DouYin = class DouYin extends Base {
|
|
|
38275
40165
|
Type: isArticle ? "文章" : isVideo ? "视频" : this.is_slides ? "合辑" : "图集",
|
|
38276
40166
|
CommentsData: douyinCommentsRes.CommentsData,
|
|
38277
40167
|
CommentLength: douyinCommentsRes.CommentsData.length ?? 0,
|
|
38278
|
-
share_url: isVideo ?
|
|
40168
|
+
share_url: isVideo && selectedVideo ? buildDouyinPlayUrl(selectedVideo.play_addr) : aweme.share_url,
|
|
38279
40169
|
VideoSize: mp4size,
|
|
38280
40170
|
VideoFPS: FPS,
|
|
38281
40171
|
ImageLength: imagenum,
|
|
38282
40172
|
Region: aweme.region,
|
|
38283
40173
|
suggestWrod: suggest,
|
|
38284
|
-
Resolution:
|
|
40174
|
+
Resolution: selectedVideo ? `${selectedVideo.play_addr.width} x ${selectedVideo.play_addr.height}` : null,
|
|
38285
40175
|
maxDepth: 6,
|
|
38286
40176
|
Author: aweme.author.nickname,
|
|
38287
40177
|
AuthorAvatar: aweme.author.avatar_thumb.url_list[0],
|
|
@@ -38560,86 +40450,6 @@ var DouYin = class DouYin extends Base {
|
|
|
38560
40450
|
}
|
|
38561
40451
|
}
|
|
38562
40452
|
};
|
|
38563
|
-
var douyinProcessVideos = (videos, videoQuality, maxAutoVideoSize) => {
|
|
38564
|
-
const mp4Videos = videos.filter((video) => video.format !== "dash");
|
|
38565
|
-
if (mp4Videos.length === 0) {
|
|
38566
|
-
logger.warn("没有找到可用的 mp4 格式视频");
|
|
38567
|
-
return videos.slice(0, 1);
|
|
38568
|
-
}
|
|
38569
|
-
logger.debug(`过滤后剩余 ${mp4Videos.length} 个 mp4 格式视频`);
|
|
38570
|
-
const getQualityLevel = (gearName) => {
|
|
38571
|
-
if (gearName.includes("lowest_4") || gearName.includes("2160")) return "4k";
|
|
38572
|
-
if (gearName.includes("1440") || gearName.includes("2k")) return "2k";
|
|
38573
|
-
if (gearName.includes("1080")) return "1080p";
|
|
38574
|
-
if (gearName.includes("720")) return "720p";
|
|
38575
|
-
if (gearName.includes("540")) return "540p";
|
|
38576
|
-
return "540p";
|
|
38577
|
-
};
|
|
38578
|
-
const videosByQuality = /* @__PURE__ */ new Map();
|
|
38579
|
-
mp4Videos.forEach((video) => {
|
|
38580
|
-
const quality = getQualityLevel(video.gear_name);
|
|
38581
|
-
if (!videosByQuality.has(quality)) videosByQuality.set(quality, []);
|
|
38582
|
-
videosByQuality.get(quality).push(video);
|
|
38583
|
-
});
|
|
38584
|
-
videosByQuality.forEach((videos) => {
|
|
38585
|
-
videos.sort((a, b) => b.play_addr.data_size - a.play_addr.data_size);
|
|
38586
|
-
});
|
|
38587
|
-
if (videoQuality === "adapt") {
|
|
38588
|
-
const sizeLimitBytes = (maxAutoVideoSize || Config.app.filelimit) * 1024 * 1024;
|
|
38589
|
-
for (const quality of [
|
|
38590
|
-
"4k",
|
|
38591
|
-
"2k",
|
|
38592
|
-
"1080p",
|
|
38593
|
-
"720p",
|
|
38594
|
-
"540p"
|
|
38595
|
-
]) {
|
|
38596
|
-
const qualityVideos = videosByQuality.get(quality);
|
|
38597
|
-
if (qualityVideos && qualityVideos.length > 0) {
|
|
38598
|
-
const suitableVideo = qualityVideos.find((video) => video.play_addr.data_size <= sizeLimitBytes);
|
|
38599
|
-
if (suitableVideo) {
|
|
38600
|
-
logger.debug(`自动选择画质: ${quality}, 文件大小: ${(suitableVideo.play_addr.data_size / (1024 * 1024)).toFixed(2)}MB`);
|
|
38601
|
-
return [suitableVideo];
|
|
38602
|
-
}
|
|
38603
|
-
}
|
|
38604
|
-
}
|
|
38605
|
-
let smallestVideo = mp4Videos[0];
|
|
38606
|
-
mp4Videos.forEach((video) => {
|
|
38607
|
-
if (video.play_addr.data_size < smallestVideo.play_addr.data_size) smallestVideo = video;
|
|
38608
|
-
});
|
|
38609
|
-
logger.debug(`未找到符合大小限制的视频,选择最小视频: ${(smallestVideo.play_addr.data_size / (1024 * 1024)).toFixed(2)}MB`);
|
|
38610
|
-
return [smallestVideo];
|
|
38611
|
-
}
|
|
38612
|
-
const targetQuality = videoQuality;
|
|
38613
|
-
const targetVideos = videosByQuality.get(targetQuality);
|
|
38614
|
-
if (targetVideos && targetVideos.length > 0) {
|
|
38615
|
-
logger.debug(`选择固定画质: ${targetQuality}, 文件大小: ${(targetVideos[0].play_addr.data_size / (1024 * 1024)).toFixed(2)}MB`);
|
|
38616
|
-
return [targetVideos[0]];
|
|
38617
|
-
}
|
|
38618
|
-
const qualityPriority = [
|
|
38619
|
-
"4k",
|
|
38620
|
-
"2k",
|
|
38621
|
-
"1080p",
|
|
38622
|
-
"720p",
|
|
38623
|
-
"540p"
|
|
38624
|
-
];
|
|
38625
|
-
const targetIndex = qualityPriority.indexOf(targetQuality);
|
|
38626
|
-
for (let i = targetIndex + 1; i < qualityPriority.length; i++) {
|
|
38627
|
-
const fallbackVideos = videosByQuality.get(qualityPriority[i]);
|
|
38628
|
-
if (fallbackVideos && fallbackVideos.length > 0) {
|
|
38629
|
-
logger.debug(`目标画质 ${targetQuality} 不可用,降级到: ${qualityPriority[i]}`);
|
|
38630
|
-
return [fallbackVideos[0]];
|
|
38631
|
-
}
|
|
38632
|
-
}
|
|
38633
|
-
for (let i = targetIndex - 1; i >= 0; i--) {
|
|
38634
|
-
const fallbackVideos = videosByQuality.get(qualityPriority[i]);
|
|
38635
|
-
if (fallbackVideos && fallbackVideos.length > 0) {
|
|
38636
|
-
logger.debug(`目标画质 ${targetQuality} 不可用,升级到: ${qualityPriority[i]}`);
|
|
38637
|
-
return [fallbackVideos[0]];
|
|
38638
|
-
}
|
|
38639
|
-
}
|
|
38640
|
-
logger.warn("未找到任何匹配的画质,返回默认视频");
|
|
38641
|
-
return [mp4Videos[0]];
|
|
38642
|
-
};
|
|
38643
40453
|
/**
|
|
38644
40454
|
* 传递整数,返回x小时后的时间
|
|
38645
40455
|
* @param {number} delay
|
|
@@ -38816,11 +40626,10 @@ async function processFavoriteList(contentList, sec_uid, userinfo, item, targets
|
|
|
38816
40626
|
create_time: aweme.create_time,
|
|
38817
40627
|
targets: validTargets,
|
|
38818
40628
|
pushType,
|
|
38819
|
-
Detail_Data: {
|
|
38820
|
-
...aweme,
|
|
40629
|
+
Detail_Data: buildDouyinWorkDetail(aweme, {
|
|
38821
40630
|
user_info: userinfo,
|
|
38822
40631
|
author_user_info: authorUserInfo
|
|
38823
|
-
},
|
|
40632
|
+
}),
|
|
38824
40633
|
avatar_img: "https://p3-pc.douyinpic.com/aweme/1080x1080/" + userinfo.data.user.avatar_larger.uri,
|
|
38825
40634
|
living: false
|
|
38826
40635
|
});
|
|
@@ -38837,7 +40646,6 @@ async function processFavoriteList(contentList, sec_uid, userinfo, item, targets
|
|
|
38837
40646
|
* @returns 返回需要推送的直播项(如果有)
|
|
38838
40647
|
*/
|
|
38839
40648
|
async function processLiveStream(sec_uid, userinfo, item, targets, amagi) {
|
|
38840
|
-
const pushType = "live";
|
|
38841
40649
|
const liveStatus = await douyinDBInstance.getLiveStatus(sec_uid);
|
|
38842
40650
|
if (userinfo.data.user.live_status === 1) {
|
|
38843
40651
|
const UserInfoData = await amagi.douyin.fetcher.fetchUserProfile({
|
|
@@ -38865,7 +40673,7 @@ async function processLiveStream(sec_uid, userinfo, item, targets, amagi) {
|
|
|
38865
40673
|
sec_uid,
|
|
38866
40674
|
create_time: Date.now(),
|
|
38867
40675
|
targets,
|
|
38868
|
-
pushType,
|
|
40676
|
+
pushType: "live",
|
|
38869
40677
|
Detail_Data: {
|
|
38870
40678
|
user_info: userinfo,
|
|
38871
40679
|
room_data: JSON.parse(userinfo.data.user.room_data),
|
|
@@ -38924,10 +40732,7 @@ async function processPostList(contentList, sec_uid, userinfo, item, targets) {
|
|
|
38924
40732
|
create_time: aweme.create_time,
|
|
38925
40733
|
targets: validTargets,
|
|
38926
40734
|
pushType,
|
|
38927
|
-
Detail_Data: {
|
|
38928
|
-
...aweme,
|
|
38929
|
-
user_info: userinfo
|
|
38930
|
-
},
|
|
40735
|
+
Detail_Data: buildDouyinWorkDetail(aweme, { user_info: userinfo }),
|
|
38931
40736
|
avatar_img: "https://p3-pc.douyinpic.com/aweme/1080x1080/" + userinfo.data.user.avatar_larger.uri,
|
|
38932
40737
|
living: false
|
|
38933
40738
|
});
|
|
@@ -38980,11 +40785,10 @@ async function processRecommendList(contentList, sec_uid, userinfo, item, target
|
|
|
38980
40785
|
create_time: aweme.create_time,
|
|
38981
40786
|
targets: validTargets,
|
|
38982
40787
|
pushType,
|
|
38983
|
-
Detail_Data: {
|
|
38984
|
-
...aweme,
|
|
40788
|
+
Detail_Data: buildDouyinWorkDetail(aweme, {
|
|
38985
40789
|
user_info: userinfo,
|
|
38986
40790
|
author_user_info: authorUserInfo
|
|
38987
|
-
},
|
|
40791
|
+
}),
|
|
38988
40792
|
avatar_img: "https://p3-pc.douyinpic.com/aweme/1080x1080/" + userinfo.data.user.avatar_larger.uri,
|
|
38989
40793
|
living: false
|
|
38990
40794
|
});
|
|
@@ -39129,7 +40933,7 @@ var DouYinpush = class extends Base {
|
|
|
39129
40933
|
for (const awemeId in data) {
|
|
39130
40934
|
const pushItem = data[awemeId];
|
|
39131
40935
|
const actualAwemeId = awemeId.replace(/^(post|favorite|recommend|live)_/, "");
|
|
39132
|
-
const shareUrl = pushItem.
|
|
40936
|
+
const shareUrl = pushItem.pushType === "live" ? `https://live.douyin.com/${pushItem.Detail_Data.room_data?.owner.web_rid}` : pushItem.Detail_Data.share_url ?? `https://www.douyin.com/video/${actualAwemeId}`;
|
|
39133
40937
|
let pushTypeLabel;
|
|
39134
40938
|
switch (pushItem.pushType) {
|
|
39135
40939
|
case "post":
|
|
@@ -39159,11 +40963,13 @@ var DouYinpush = class extends Base {
|
|
|
39159
40963
|
if (skip) logger.warn(`作品 https://www.douyin.com/video/${actualAwemeId} 已被处理,跳过`);
|
|
39160
40964
|
let img = [];
|
|
39161
40965
|
let iddata = { type: "one_work" };
|
|
40966
|
+
/** 按画质配置选中、即将下载发送的那一路视频源 */
|
|
40967
|
+
let selectedVideo = null;
|
|
39162
40968
|
this.injectBotToEventForRender(pushItem.targets);
|
|
39163
|
-
if (!skip) iddata = await getDouyinID(this.e,
|
|
40969
|
+
if (!skip) iddata = await getDouyinID(this.e, shareUrl, false);
|
|
39164
40970
|
if (!skip) {
|
|
39165
40971
|
const realUrl = pushItem.pushType !== "live" && workTypeInfo.isVideo && Config.douyin.push.shareType === "web" && await new Network({
|
|
39166
|
-
url: Detail_Data.share_url,
|
|
40972
|
+
url: pushItem.Detail_Data.share_url,
|
|
39167
40973
|
headers: {
|
|
39168
40974
|
"User-Agent": "Apifox/1.0.0 (https://apifox.com)",
|
|
39169
40975
|
Accept: "*/*",
|
|
@@ -39171,17 +40977,21 @@ var DouYinpush = class extends Base {
|
|
|
39171
40977
|
Connection: "keep-alive"
|
|
39172
40978
|
}
|
|
39173
40979
|
}).getLocation();
|
|
40980
|
+
if (pushItem.pushType !== "live" && workTypeInfo.isVideo && pushItem.Detail_Data.video?.bit_rate?.length) selectedVideo = douyinProcessVideos(pushItem.Detail_Data.video.bit_rate, Config.douyin.videoQuality)[0];
|
|
39174
40981
|
let workShareLink;
|
|
39175
40982
|
if (pushItem.pushType !== "live") if (workTypeInfo.isArticle) workShareLink = `https://www.douyin.com/article/${actualAwemeId}`;
|
|
39176
40983
|
else if (workTypeInfo.isImage) workShareLink = `https://www.douyin.com/note/${actualAwemeId}`;
|
|
39177
40984
|
else if (Config.douyin.push.shareType === "web") workShareLink = realUrl || `https://www.douyin.com/video/${actualAwemeId}`;
|
|
39178
|
-
else
|
|
40985
|
+
else {
|
|
40986
|
+
const playAddr = selectedVideo?.play_addr ?? pushItem.Detail_Data.video?.play_addr;
|
|
40987
|
+
workShareLink = playAddr ? buildDouyinPlayUrl(playAddr) : `https://www.douyin.com/video/${actualAwemeId}`;
|
|
40988
|
+
}
|
|
39179
40989
|
switch (pushItem.pushType) {
|
|
39180
40990
|
case "live":
|
|
39181
|
-
if (!
|
|
40991
|
+
if (!pushItem.Detail_Data.room_data || !pushItem.Detail_Data.live_data) break;
|
|
39182
40992
|
img = await renderLiveImage({
|
|
39183
40993
|
e: this.e,
|
|
39184
|
-
Detail_Data,
|
|
40994
|
+
Detail_Data: pushItem.Detail_Data,
|
|
39185
40995
|
skipWatermark: true,
|
|
39186
40996
|
dynamicTypeLabel: "直播动态推送"
|
|
39187
40997
|
});
|
|
@@ -39189,7 +40999,7 @@ var DouYinpush = class extends Base {
|
|
|
39189
40999
|
case "favorite":
|
|
39190
41000
|
img = await renderFavoriteImage({
|
|
39191
41001
|
e: this.e,
|
|
39192
|
-
Detail_Data,
|
|
41002
|
+
Detail_Data: pushItem.Detail_Data,
|
|
39193
41003
|
create_time: pushItem.create_time,
|
|
39194
41004
|
shareLink: workShareLink,
|
|
39195
41005
|
remark: pushItem.remark,
|
|
@@ -39199,7 +41009,7 @@ var DouYinpush = class extends Base {
|
|
|
39199
41009
|
case "recommend":
|
|
39200
41010
|
img = await renderRecommendImage({
|
|
39201
41011
|
e: this.e,
|
|
39202
|
-
Detail_Data,
|
|
41012
|
+
Detail_Data: pushItem.Detail_Data,
|
|
39203
41013
|
create_time: pushItem.create_time,
|
|
39204
41014
|
shareLink: workShareLink,
|
|
39205
41015
|
remark: pushItem.remark,
|
|
@@ -39209,9 +41019,10 @@ var DouYinpush = class extends Base {
|
|
|
39209
41019
|
default:
|
|
39210
41020
|
img = await renderWorkImage({
|
|
39211
41021
|
e: this.e,
|
|
39212
|
-
Detail_Data,
|
|
41022
|
+
Detail_Data: pushItem.Detail_Data,
|
|
39213
41023
|
create_time: pushItem.create_time,
|
|
39214
41024
|
shareLink: workShareLink,
|
|
41025
|
+
videoSource: selectedVideo,
|
|
39215
41026
|
skipWatermark: true
|
|
39216
41027
|
});
|
|
39217
41028
|
break;
|
|
@@ -39227,10 +41038,10 @@ var DouYinpush = class extends Base {
|
|
|
39227
41038
|
eventWithBot.bot = bot;
|
|
39228
41039
|
eventWithBot.selfId = botId;
|
|
39229
41040
|
const watermarkedImg = img ? applyWatermarkToImages(img, this.e) : [];
|
|
39230
|
-
const parseButton = bot?.adapter?.name === "QQ Official Bot" && pushItem.pushType !== "live" && Detail_Data.share_url ? [segment.button([{
|
|
41041
|
+
const parseButton = bot?.adapter?.name === "QQ Official Bot" && pushItem.pushType !== "live" && pushItem.Detail_Data.share_url ? [segment.button([{
|
|
39231
41042
|
text: "解析",
|
|
39232
41043
|
callback: true,
|
|
39233
|
-
data: `#解析${Detail_Data.share_url}`
|
|
41044
|
+
data: `#解析${pushItem.Detail_Data.share_url}`
|
|
39234
41045
|
}, {
|
|
39235
41046
|
text: "帮助",
|
|
39236
41047
|
callback: true,
|
|
@@ -39238,20 +41049,21 @@ var DouYinpush = class extends Base {
|
|
|
39238
41049
|
}])] : [];
|
|
39239
41050
|
status = await karin.sendMsg(botId, Contact, [...watermarkedImg, ...parseButton]);
|
|
39240
41051
|
if (pushItem.pushType === "live" && "room_data" in pushItem.Detail_Data && status.message_id) await douyinDBInstance.updateLiveStatus(pushItem.sec_uid, true);
|
|
39241
|
-
if (Config.douyin.push.parsedynamic && status.message_id) {
|
|
41052
|
+
if (pushItem.pushType !== "live" && Config.douyin.push.parsedynamic && status.message_id) {
|
|
41053
|
+
const Detail_Data = pushItem.Detail_Data;
|
|
39242
41054
|
logger.debug(`开始解析作品,类型为:${getWorkTypeDisplayName(workTypeInfo)}`);
|
|
39243
|
-
if (workTypeInfo.isVideo) {
|
|
41055
|
+
if (workTypeInfo.isVideo && Detail_Data.video) {
|
|
39244
41056
|
/** 默认视频下载地址 */
|
|
39245
|
-
let downloadUrl =
|
|
41057
|
+
let downloadUrl = buildDouyinPlayUrl(Detail_Data.video.play_addr);
|
|
39246
41058
|
logger.debug(`开始排除不符合条件的视频分辨率;\n
|
|
39247
41059
|
共拥有${logger.yellow(Detail_Data.video.bit_rate.length)}个视频源\n
|
|
39248
41060
|
视频ID:${logger.green(Detail_Data.aweme_id)}\n
|
|
39249
41061
|
分享链接:${logger.green(Detail_Data.share_url)}
|
|
39250
41062
|
`);
|
|
39251
|
-
const videoObj = douyinProcessVideos(Detail_Data.video.bit_rate, Config.douyin.videoQuality);
|
|
41063
|
+
const videoObj = selectedVideo ?? douyinProcessVideos(Detail_Data.video.bit_rate, Config.douyin.videoQuality)[0];
|
|
39252
41064
|
logger.debug("获取精确下载地址");
|
|
39253
41065
|
downloadUrl = await new Network({
|
|
39254
|
-
url: videoObj
|
|
41066
|
+
url: videoObj.play_addr.url_list[0],
|
|
39255
41067
|
headers: douyinBaseHeaders
|
|
39256
41068
|
}).getLongLink();
|
|
39257
41069
|
await downloadVideo(this.e, {
|
|
@@ -39295,7 +41107,12 @@ var DouYinpush = class extends Base {
|
|
|
39295
41107
|
continue;
|
|
39296
41108
|
}
|
|
39297
41109
|
/** 动图/短片 */
|
|
39298
|
-
const
|
|
41110
|
+
const livePlayAddr = item.video?.play_addr_h264;
|
|
41111
|
+
if (!livePlayAddr?.uri) {
|
|
41112
|
+
logger.warn(`合辑第 ${index + 1} 个动态媒体缺少视频源,跳过`);
|
|
41113
|
+
continue;
|
|
41114
|
+
}
|
|
41115
|
+
const liveimg = await downloadFile(buildDouyinPlayUrl(livePlayAddr), {
|
|
39299
41116
|
title: `Douyin_tmp_V_${Date.now()}.mp4`,
|
|
39300
41117
|
headers: douyinBaseHeaders
|
|
39301
41118
|
});
|
|
@@ -39414,12 +41231,19 @@ var DouYinpush = class extends Base {
|
|
|
39414
41231
|
}
|
|
39415
41232
|
for (const [index, item] of Detail_Data.images.entries()) {
|
|
39416
41233
|
if (item.clip_type === 2 || item.clip_type === void 0) {
|
|
39417
|
-
const
|
|
41234
|
+
const image_url = item.url_list[2] ?? item.url_list[1];
|
|
41235
|
+
if (!image_url) continue;
|
|
41236
|
+
const imageUrl = await processImageUrl(image_url, Detail_Data.desc, index);
|
|
39418
41237
|
processedImages.push(segment.image(imageUrl));
|
|
39419
41238
|
continue;
|
|
39420
41239
|
}
|
|
39421
41240
|
/** live 图 */
|
|
39422
|
-
const
|
|
41241
|
+
const livePlayAddr = item.video?.play_addr_h264;
|
|
41242
|
+
if (!livePlayAddr?.uri) {
|
|
41243
|
+
logger.warn(`图集第 ${index + 1} 个动态媒体缺少视频源,跳过`);
|
|
41244
|
+
continue;
|
|
41245
|
+
}
|
|
41246
|
+
const liveimg = await downloadFile(buildDouyinPlayUrl(livePlayAddr), {
|
|
39423
41247
|
title: `Douyin_tmp_V_${Date.now()}.mp4`,
|
|
39424
41248
|
headers: douyinBaseHeaders
|
|
39425
41249
|
});
|
|
@@ -39523,11 +41347,12 @@ var DouYinpush = class extends Base {
|
|
|
39523
41347
|
let image_url;
|
|
39524
41348
|
for (const [index, item] of Detail_Data.images.entries()) {
|
|
39525
41349
|
image_url = item.url_list[2] ?? item.url_list[1];
|
|
41350
|
+
if (!image_url) continue;
|
|
39526
41351
|
const imageUrl = await processImageUrl(image_url, Detail_Data.desc, index);
|
|
39527
41352
|
imageres.push(segment.image(imageUrl));
|
|
39528
41353
|
}
|
|
39529
41354
|
const bot = karin.getBot(botId);
|
|
39530
|
-
if (imageres.length === 1) {
|
|
41355
|
+
if (imageres.length === 1 && image_url) {
|
|
39531
41356
|
const imageUrl = await processImageUrl(image_url, Detail_Data.desc);
|
|
39532
41357
|
await bot.sendMsg(Contact, [segment.image(imageUrl)]);
|
|
39533
41358
|
} else {
|
|
@@ -39834,7 +41659,7 @@ var DouYinpush = class extends Base {
|
|
|
39834
41659
|
* @returns 是否应该跳过推送
|
|
39835
41660
|
*/
|
|
39836
41661
|
var skipDynamic = async (PushItem) => {
|
|
39837
|
-
if ("liveStatus" in PushItem.Detail_Data) return false;
|
|
41662
|
+
if (PushItem.pushType === "live" || "liveStatus" in PushItem.Detail_Data) return false;
|
|
39838
41663
|
const tags = [];
|
|
39839
41664
|
if (PushItem.Detail_Data.text_extra) {
|
|
39840
41665
|
for (const item of PushItem.Detail_Data.text_extra) if (item.hashtag_name) tags.push(item.hashtag_name);
|
|
@@ -40626,376 +42451,251 @@ var XiaohongshuEmoji = (data) => {
|
|
|
40626
42451
|
};
|
|
40627
42452
|
//#endregion
|
|
40628
42453
|
//#region src/platform/douyin/login.ts
|
|
42454
|
+
/** 等待用户扫码的时限上限,与消息可撤回窗口(2 分钟)对齐;二维码本身更早失效时以它为准 */
|
|
42455
|
+
var SCAN_TIMEOUT = 12e4;
|
|
42456
|
+
/** 扫码后等待手机确认或完成二次验证的时限 */
|
|
42457
|
+
var CONFIRM_TIMEOUT = 18e4;
|
|
42458
|
+
/** 等待用户回填短信验证码的时限(秒) */
|
|
42459
|
+
var CODE_INPUT_TIMEOUT = 90;
|
|
42460
|
+
/** 短信验证码允许的重试次数 */
|
|
42461
|
+
var CODE_MAX_ATTEMPTS = 3;
|
|
42462
|
+
/** 6 位数字验证码 */
|
|
42463
|
+
var CODE_PATTERN = /^\d{6}$/;
|
|
42464
|
+
/** 登录凭证里需要确认下发的关键 cookie */
|
|
42465
|
+
var REQUIRED_COOKIES = [
|
|
42466
|
+
"sessionid",
|
|
42467
|
+
"sessionid_ss",
|
|
42468
|
+
"sid_guard",
|
|
42469
|
+
"uid_tt",
|
|
42470
|
+
"uid_tt_ss",
|
|
42471
|
+
"ttwid"
|
|
42472
|
+
];
|
|
42473
|
+
var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
42474
|
+
/** 复用 amagi 配置里的代理与超时,让登录请求与其它抖音请求走同一条出口 */
|
|
42475
|
+
var requestConfig = () => {
|
|
42476
|
+
const amagi = Config.amagi;
|
|
42477
|
+
return {
|
|
42478
|
+
timeout: amagi.timeout,
|
|
42479
|
+
proxy: amagi.proxy?.switch ? amagi.proxy : false
|
|
42480
|
+
};
|
|
42481
|
+
};
|
|
40629
42482
|
/**
|
|
40630
|
-
*
|
|
40631
|
-
* @param
|
|
40632
|
-
* @param screenshotPath 截图保存路径
|
|
42483
|
+
* 登录过程中发出的消息,全部登记在这里,结束时统一撤回,避免二维码留在群里
|
|
42484
|
+
* @param e 消息事件
|
|
40633
42485
|
*/
|
|
40634
|
-
var
|
|
40635
|
-
|
|
40636
|
-
|
|
40637
|
-
|
|
40638
|
-
|
|
40639
|
-
|
|
40640
|
-
|
|
40641
|
-
|
|
40642
|
-
|
|
40643
|
-
|
|
40644
|
-
|
|
40645
|
-
|
|
40646
|
-
|
|
40647
|
-
|
|
40648
|
-
|
|
40649
|
-
|
|
40650
|
-
"--window-size=800,600",
|
|
40651
|
-
"--disable-gpu",
|
|
40652
|
-
"--no-sandbox",
|
|
40653
|
-
"--disable-setuid-sandbox",
|
|
40654
|
-
"--no-zygote",
|
|
40655
|
-
"--disable-extensions",
|
|
40656
|
-
"--disable-dev-shm-usage",
|
|
40657
|
-
"--disable-background-networking",
|
|
40658
|
-
"--disable-sync",
|
|
40659
|
-
"--disable-crash-reporter",
|
|
40660
|
-
"--disable-translate",
|
|
40661
|
-
"--disable-notifications",
|
|
40662
|
-
"--disable-device-discovery-notifications",
|
|
40663
|
-
"--disable-accelerated-2d-canvas",
|
|
40664
|
-
"--autoplay-policy=user-gesture-required",
|
|
40665
|
-
"--disable-web-security",
|
|
40666
|
-
"--disable-features=IsolateOrigins,site-per-process",
|
|
40667
|
-
"--disable-site-isolation-trials",
|
|
40668
|
-
"--disable-features=VizDisplayCompositor",
|
|
40669
|
-
"--js-flags=--max-old-space-size=128",
|
|
40670
|
-
"--disable-software-rasterizer",
|
|
40671
|
-
"--disable-webgl",
|
|
40672
|
-
"--disable-webgl2",
|
|
40673
|
-
"--disable-3d-apis",
|
|
40674
|
-
"--disable-accelerated-video-decode",
|
|
40675
|
-
"--disable-background-timer-throttling",
|
|
40676
|
-
"--disable-backgrounding-occluded-windows",
|
|
40677
|
-
"--disable-breakpad",
|
|
40678
|
-
"--disable-component-extensions-with-background-pages",
|
|
40679
|
-
"--disable-features=TranslateUI,BlinkGenPropertyTrees",
|
|
40680
|
-
"--disable-ipc-flooding-protection",
|
|
40681
|
-
"--disable-renderer-backgrounding"
|
|
40682
|
-
],
|
|
40683
|
-
ignoreDefaultArgs: ["--enable-automation"]
|
|
40684
|
-
});
|
|
40685
|
-
/** 根据当前系统平台选择操作系统类型 */
|
|
40686
|
-
const getOperatingSystem = () => {
|
|
40687
|
-
const os = platform();
|
|
40688
|
-
if (os === "win32") return "windows";
|
|
40689
|
-
if (os === "darwin") return "macos";
|
|
40690
|
-
return "linux";
|
|
40691
|
-
};
|
|
40692
|
-
const page = await newInjectedPage(puppeteer.browser, { fingerprintOptions: {
|
|
40693
|
-
devices: ["desktop"],
|
|
40694
|
-
operatingSystems: [getOperatingSystem()]
|
|
40695
|
-
} });
|
|
40696
|
-
await page.setRequestInterception(true);
|
|
40697
|
-
page.on("request", async (request) => {
|
|
40698
|
-
const resourceType = request.resourceType();
|
|
40699
|
-
const url = request.url();
|
|
40700
|
-
if (url.includes("passport") || url.includes("login") || url.includes("qrconnect") || url.includes("qrcode")) logger.debug(`[请求] ${resourceType}: ${url}`);
|
|
40701
|
-
if (resourceType === "media" || resourceType === "font" || resourceType === "stylesheet" || /\.(mp4|webm|m3u8|flv|avi|mov|wmv|mkv)(\?|$)/i.test(url) || url.includes("/aweme/") || url.includes("/video/") || url.includes("v.douyin.com") || resourceType === "image" && !url.includes("qrcode") && !url.includes("data:image") && (url.includes(".jpg") || url.includes(".jpeg") || url.includes(".webp"))) {
|
|
40702
|
-
if (url.includes("passport") || url.includes("login") || url.includes("qrconnect")) logger.warn(`[拦截] 登录相关请求被拦截: ${url}`);
|
|
40703
|
-
request.abort();
|
|
40704
|
-
} else request.continue();
|
|
40705
|
-
});
|
|
40706
|
-
await page.evaluateOnNewDocument(() => {
|
|
40707
|
-
HTMLMediaElement.prototype.play = function() {
|
|
40708
|
-
return Promise.reject(/* @__PURE__ */ new Error("Video playback blocked"));
|
|
40709
|
-
};
|
|
40710
|
-
if (window.MediaSource) window.MediaSource = void 0;
|
|
40711
|
-
window.IntersectionObserver = class {
|
|
40712
|
-
observe() {}
|
|
40713
|
-
unobserve() {}
|
|
40714
|
-
disconnect() {}
|
|
40715
|
-
};
|
|
40716
|
-
});
|
|
40717
|
-
await page.goto("https://www.douyin.com", {
|
|
40718
|
-
timeout: 12e4,
|
|
40719
|
-
waitUntil: "domcontentloaded"
|
|
40720
|
-
});
|
|
40721
|
-
let qrCodeData;
|
|
40722
|
-
try {
|
|
40723
|
-
logger.mark("开始等待二维码加载...");
|
|
40724
|
-
qrCodeData = await Promise.race([waitQrcode(page), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("QR_CODE_TIMEOUT")), 6e4))]);
|
|
40725
|
-
logger.mark("二维码获取成功");
|
|
40726
|
-
} catch (error) {
|
|
40727
|
-
if (error.message === "QR_CODE_TIMEOUT") {
|
|
40728
|
-
logger.warn("获取二维码超时");
|
|
40729
|
-
await safeScreenshot(page, path.join(karinPathTemp, Root.pluginName, "DouyinLoginQrcodeError.png"));
|
|
40730
|
-
await e.reply("获取二维码超时,请稍后重试", { reply: true });
|
|
40731
|
-
} else {
|
|
40732
|
-
logger.error("获取二维码失败:", error);
|
|
40733
|
-
await e.reply("获取二维码失败,请查看控制台日志", { reply: true });
|
|
40734
|
-
}
|
|
40735
|
-
await puppeteer.browser.close();
|
|
40736
|
-
return true;
|
|
40737
|
-
}
|
|
40738
|
-
let gcInterval;
|
|
40739
|
-
try {
|
|
40740
|
-
const loginQRcode = await Render(e, "douyin/qrcodeImg", qrCodeData.url ? { share_url: qrCodeData.url } : { share_url: qrCodeData.originalImage });
|
|
40741
|
-
const base64Data = loginQRcode[0]?.file;
|
|
40742
|
-
if (!base64Data) throw new Error("生成二维码图片失败");
|
|
40743
|
-
const cleanBase64 = base64Data.replace(/^base64:\/\//, "");
|
|
40744
|
-
const buffer = Buffer.from(cleanBase64, "base64");
|
|
40745
|
-
fs.writeFileSync(`${Common.tempDri.default}DouyinLoginQrcode.png`, buffer);
|
|
40746
|
-
const message2 = await e.reply(loginQRcode, { reply: true });
|
|
40747
|
-
logger.debug("二维码图片消息ID:", message2.messageId);
|
|
40748
|
-
msg_id.push(message2.messageId);
|
|
40749
|
-
gcInterval = setInterval(async () => {
|
|
40750
|
-
try {
|
|
40751
|
-
await page.evaluate(() => {
|
|
40752
|
-
if (window.gc) window.gc();
|
|
40753
|
-
});
|
|
40754
|
-
} catch {}
|
|
40755
|
-
}, 1e4);
|
|
40756
|
-
logger.mark("开始等待用户扫码登录...");
|
|
40757
|
-
const loginResult = await Promise.race([new Promise((resolve) => {
|
|
40758
|
-
const timer = setTimeout(async () => {
|
|
40759
|
-
logger.warn("扫码登录超时(2分钟),撤回二维码消息");
|
|
40760
|
-
await Promise.all(msg_id.map(async (id) => {
|
|
40761
|
-
await e.bot.recallMsg(e.contact, id);
|
|
40762
|
-
}));
|
|
40763
|
-
resolve(false);
|
|
40764
|
-
}, 120 * 1e3);
|
|
40765
|
-
let secondVerifyHandled = false;
|
|
40766
|
-
let scannedHandled = false;
|
|
40767
|
-
let responseCount = 0;
|
|
40768
|
-
page.on("response", async (response) => {
|
|
40769
|
-
responseCount++;
|
|
40770
|
-
const url = response.url();
|
|
40771
|
-
if (responseCount % 10 === 0) logger.debug(`[心跳] 已收到 ${responseCount} 个响应`);
|
|
40772
|
-
if (url.includes("passport") || url.includes("login") || url.includes("qrconnect") || url.includes("qrcode")) logger.debug(`[响应] 登录相关请求: ${url}, status: ${response.status()}`);
|
|
40773
|
-
});
|
|
40774
|
-
logger.mark("响应监听器已注册");
|
|
40775
|
-
page.on("response", async (response) => {
|
|
42486
|
+
var createMessageTracker = (e) => {
|
|
42487
|
+
const messageIds = [];
|
|
42488
|
+
return {
|
|
42489
|
+
/**
|
|
42490
|
+
* 发送并登记一条消息
|
|
42491
|
+
* @param message 消息内容
|
|
42492
|
+
*/
|
|
42493
|
+
async send(message) {
|
|
42494
|
+
const sent = await e.reply(message, { reply: true });
|
|
42495
|
+
if (sent.messageId) messageIds.push(sent.messageId);
|
|
42496
|
+
return sent;
|
|
42497
|
+
},
|
|
42498
|
+
/** 撤回目前登记的全部消息 */
|
|
42499
|
+
async recallAll() {
|
|
42500
|
+
const pending = messageIds.splice(0, messageIds.length);
|
|
42501
|
+
await Promise.all(pending.map(async (id) => {
|
|
40776
42502
|
try {
|
|
40777
|
-
|
|
40778
|
-
logger.debug(`收到登录轮询响应: ${response.url()}`);
|
|
40779
|
-
const hasSidGuard = (response.headers()["set-cookie"] || "").includes("sid_guard");
|
|
40780
|
-
logger.debug(`响应头包含 sid_guard: ${hasSidGuard}`);
|
|
40781
|
-
if (hasSidGuard) {
|
|
40782
|
-
clearTimeout(timer);
|
|
40783
|
-
logger.mark("检测到 sid_guard,登录成功");
|
|
40784
|
-
logger.debug("开始获取 cookies...");
|
|
40785
|
-
const cookies = await puppeteer.browser.cookies();
|
|
40786
|
-
logger.debug(`获取到 ${cookies.length} 个 cookies`);
|
|
40787
|
-
const cookieString = cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
|
|
40788
|
-
const hasSessionidSs = cookies.some((cookie) => cookie.name === "sessionid_ss");
|
|
40789
|
-
const hasTtwid = cookies.some((cookie) => cookie.name === "ttwid");
|
|
40790
|
-
logger.mark(`Cookie 参数检测: sessionid_ss=${hasSessionidSs}, ttwid=${hasTtwid}`);
|
|
40791
|
-
if (!hasSessionidSs || !hasTtwid) {
|
|
40792
|
-
logger.warn("警告:缺少关键 cookie 参数!");
|
|
40793
|
-
if (!hasSessionidSs) logger.warn(" - 缺少 sessionid_ss");
|
|
40794
|
-
if (!hasTtwid) logger.warn(" - 缺少 ttwid");
|
|
40795
|
-
}
|
|
40796
|
-
logger.debug("开始保存 cookies...");
|
|
40797
|
-
await Config.Modify("amagi", "cookies.douyin", cookieString);
|
|
40798
|
-
const reloaded = reloadAmagiConfig();
|
|
40799
|
-
logger.debug(`cookies 保存完成,Amagi Client ${reloaded ? "已重载" : "配置未变化"}`);
|
|
40800
|
-
await e.reply("登录成功!用户登录凭证已保存至配置", { reply: true });
|
|
40801
|
-
await Promise.all(msg_id.map(async (id) => {
|
|
40802
|
-
await e.bot.recallMsg(e.contact, id);
|
|
40803
|
-
}));
|
|
40804
|
-
logger.mark("关闭浏览器...");
|
|
40805
|
-
await puppeteer.browser.close();
|
|
40806
|
-
logger.mark("浏览器已关闭");
|
|
40807
|
-
resolve(true);
|
|
40808
|
-
return;
|
|
40809
|
-
}
|
|
40810
|
-
const responseBody = await response.text();
|
|
40811
|
-
const jsonResponse = JSON.parse(responseBody);
|
|
40812
|
-
logger.debug(`二维码状态:${jsonResponse.data?.status}, error_code: ${jsonResponse.data?.error_code}`);
|
|
40813
|
-
if (jsonResponse.data?.status === "scanned" && !scannedHandled) {
|
|
40814
|
-
scannedHandled = true;
|
|
40815
|
-
logger.mark("检测到二维码已被扫描");
|
|
40816
|
-
await Promise.all(msg_id.map(async (id) => {
|
|
40817
|
-
await e.bot.recallMsg(e.contact, id);
|
|
40818
|
-
}));
|
|
40819
|
-
msg_id.length = 0;
|
|
40820
|
-
const authMsg = await e.reply("此二维码已被扫描,请在手机上授权以登录", { reply: true });
|
|
40821
|
-
msg_id.push(authMsg.messageId);
|
|
40822
|
-
}
|
|
40823
|
-
if (jsonResponse.data?.error_code === 2046 && !secondVerifyHandled) {
|
|
40824
|
-
secondVerifyHandled = true;
|
|
40825
|
-
logger.mark("检测到需要二次验证");
|
|
40826
|
-
clearTimeout(timer);
|
|
40827
|
-
(async () => {
|
|
40828
|
-
try {
|
|
40829
|
-
await page.waitForSelector("#uc-second-verify", { timeout: 5e3 });
|
|
40830
|
-
if (!await page.evaluate(() => {
|
|
40831
|
-
const element = document.evaluate("//text()[contains(., '接收短信验证码')]/ancestor::*[1]", document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
|
40832
|
-
if (element) {
|
|
40833
|
-
element.click();
|
|
40834
|
-
return true;
|
|
40835
|
-
}
|
|
40836
|
-
return false;
|
|
40837
|
-
})) logger.warn("未找到\"接收短信验证码\"按钮");
|
|
40838
|
-
await new Promise((resolve) => setTimeout(resolve, 2e3));
|
|
40839
|
-
const inputSelector = "#uc-second-verify input";
|
|
40840
|
-
await page.waitForSelector(inputSelector, { timeout: 1e4 });
|
|
40841
|
-
const tipMsg = await e.reply("此次验证需要进行 2FA\n6 位数的验证码已发送至扫码设备绑定的手机号\n请在 60 秒内发送此验证码以通过 2FA", { reply: true });
|
|
40842
|
-
msg_id.push(tipMsg.messageId);
|
|
40843
|
-
let verifyAttempts = 0;
|
|
40844
|
-
const maxAttempts = 2;
|
|
40845
|
-
let verifySuccess = false;
|
|
40846
|
-
while (verifyAttempts < maxAttempts && !verifySuccess) {
|
|
40847
|
-
verifyAttempts++;
|
|
40848
|
-
logger.debug(`验证码输入尝试 ${verifyAttempts}/${maxAttempts}`);
|
|
40849
|
-
const ctx = await Promise.race([karin$1.ctx(e, { reply: true }), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error("2FA_TIMEOUT")), 6e4))]).catch(async (error) => {
|
|
40850
|
-
if (error.message === "2FA_TIMEOUT") {
|
|
40851
|
-
logger.warn("2FA验证码输入超时");
|
|
40852
|
-
clearTimeout(timer);
|
|
40853
|
-
if (gcInterval) clearInterval(gcInterval);
|
|
40854
|
-
await puppeteer.browser.close();
|
|
40855
|
-
await Promise.all(msg_id.map(async (id) => {
|
|
40856
|
-
await e.bot.recallMsg(e.contact, id);
|
|
40857
|
-
}));
|
|
40858
|
-
await e.reply("验证码验证码输入超时,登录失败", { reply: true });
|
|
40859
|
-
resolve(true);
|
|
40860
|
-
}
|
|
40861
|
-
throw error;
|
|
40862
|
-
});
|
|
40863
|
-
if (!ctx) return;
|
|
40864
|
-
await page.evaluate((selector) => {
|
|
40865
|
-
const input = document.querySelector(selector);
|
|
40866
|
-
if (input) input.value = "";
|
|
40867
|
-
}, inputSelector);
|
|
40868
|
-
await page.type(inputSelector, ctx.msg);
|
|
40869
|
-
const validatePromise = new Promise((resolveValidate) => {
|
|
40870
|
-
const validateHandler = async (resp) => {
|
|
40871
|
-
if (resp.url().includes("validate_code")) try {
|
|
40872
|
-
const validateBody = await resp.text();
|
|
40873
|
-
const validateJson = JSON.parse(validateBody);
|
|
40874
|
-
logger.debug("验证码验证响应:", validateJson);
|
|
40875
|
-
if (validateJson.data?.error_code === 1202) {
|
|
40876
|
-
logger.warn("验证码错误");
|
|
40877
|
-
page.off("response", validateHandler);
|
|
40878
|
-
resolveValidate(false);
|
|
40879
|
-
} else if (validateJson.message === "success" || !validateJson.data?.error_code) {
|
|
40880
|
-
logger.mark("验证码验证成功");
|
|
40881
|
-
page.off("response", validateHandler);
|
|
40882
|
-
resolveValidate(true);
|
|
40883
|
-
}
|
|
40884
|
-
} catch (err) {
|
|
40885
|
-
logger.debug("解析验证响应失败:", err);
|
|
40886
|
-
}
|
|
40887
|
-
};
|
|
40888
|
-
page.on("response", validateHandler);
|
|
40889
|
-
setTimeout(() => {
|
|
40890
|
-
page.off("response", validateHandler);
|
|
40891
|
-
resolveValidate(false);
|
|
40892
|
-
}, 5e3);
|
|
40893
|
-
});
|
|
40894
|
-
await page.evaluate(() => {
|
|
40895
|
-
const verifyBtn = Array.from(document.querySelectorAll("*")).find((el) => el.textContent?.trim() === "验证");
|
|
40896
|
-
if (verifyBtn) verifyBtn.click();
|
|
40897
|
-
});
|
|
40898
|
-
logger.mark("已提交验证码,等待验证结果...");
|
|
40899
|
-
verifySuccess = await validatePromise;
|
|
40900
|
-
if (!verifySuccess && verifyAttempts < maxAttempts) {
|
|
40901
|
-
const retryMsg = await e.reply("验证码错误,请重新发送正确的 6 位数验证码(剩余机会:1次)", { reply: true });
|
|
40902
|
-
msg_id.push(retryMsg.messageId);
|
|
40903
|
-
} else if (!verifySuccess && verifyAttempts >= maxAttempts) {
|
|
40904
|
-
logger.warn("验证码错误次数过多,登录失败");
|
|
40905
|
-
clearTimeout(timer);
|
|
40906
|
-
if (gcInterval) clearInterval(gcInterval);
|
|
40907
|
-
await puppeteer.browser.close();
|
|
40908
|
-
await Promise.all(msg_id.map(async (id) => {
|
|
40909
|
-
await e.bot.recallMsg(e.contact, id);
|
|
40910
|
-
}));
|
|
40911
|
-
await e.reply("验证码错误,登录失败", { reply: true });
|
|
40912
|
-
resolve(true);
|
|
40913
|
-
return;
|
|
40914
|
-
}
|
|
40915
|
-
}
|
|
40916
|
-
if (verifySuccess) logger.mark("2FA验证通过,等待最终登录确认...");
|
|
40917
|
-
} catch (err) {
|
|
40918
|
-
logger.error("二次验证处理失败:", err);
|
|
40919
|
-
clearTimeout(timer);
|
|
40920
|
-
if (gcInterval) clearInterval(gcInterval);
|
|
40921
|
-
await puppeteer.browser.close();
|
|
40922
|
-
await Promise.all(msg_id.map(async (id) => {
|
|
40923
|
-
await e.bot.recallMsg(e.contact, id);
|
|
40924
|
-
}));
|
|
40925
|
-
await e.reply("二次验证处理失败,登录失败", { reply: true });
|
|
40926
|
-
resolve(true);
|
|
40927
|
-
}
|
|
40928
|
-
})();
|
|
40929
|
-
}
|
|
40930
|
-
}
|
|
42503
|
+
await e.bot.recallMsg(e.contact, id);
|
|
40931
42504
|
} catch (error) {
|
|
40932
|
-
logger.
|
|
42505
|
+
logger.debug("[抖音登录] 撤回消息失败:", error);
|
|
40933
42506
|
}
|
|
40934
|
-
});
|
|
40935
|
-
}
|
|
40936
|
-
|
|
40937
|
-
|
|
40938
|
-
|
|
40939
|
-
|
|
40940
|
-
|
|
42507
|
+
}));
|
|
42508
|
+
}
|
|
42509
|
+
};
|
|
42510
|
+
};
|
|
42511
|
+
/**
|
|
42512
|
+
* 处理账号二次验证:发短信验证码 → 等用户回填 → 提交
|
|
42513
|
+
* @param e 消息事件
|
|
42514
|
+
* @param session 登录会话,验证过程中会刷新其中的 cookie
|
|
42515
|
+
* @param verify 轮询下发的验证上下文
|
|
42516
|
+
* @param tracker 消息登记器
|
|
42517
|
+
* @returns 验证是否通过
|
|
42518
|
+
*/
|
|
42519
|
+
var handleSecondVerify = async (e, session, verify, tracker) => {
|
|
42520
|
+
if (!verify.encryptUid) {
|
|
42521
|
+
await tracker.send("账号触发了二次验证,但服务端未下发验证上下文,请稍后重试");
|
|
42522
|
+
return false;
|
|
42523
|
+
}
|
|
42524
|
+
const smsWay = verify.verifyWays.find((way) => isSmsCodeVerifyWay(way.verifyWay));
|
|
42525
|
+
if (verify.verifyWays.length > 0 && !smsWay) {
|
|
42526
|
+
const ways = verify.verifyWays.map((way) => way.verifyWay).join("、");
|
|
42527
|
+
logger.warn(`[抖音登录] 服务端给出的验证方式均不支持: ${ways}`);
|
|
42528
|
+
await tracker.send(`账号触发了二次验证,但当前仅支持短信验证码,服务端给出的方式为:${ways}`);
|
|
42529
|
+
return false;
|
|
42530
|
+
}
|
|
42531
|
+
const sent = await sendPassportVerifyCode({
|
|
42532
|
+
verify,
|
|
42533
|
+
verify_way: smsWay?.verifyWay,
|
|
42534
|
+
typeMode: "strict"
|
|
42535
|
+
}, session.cookie, requestConfig());
|
|
42536
|
+
if (!sent.success) {
|
|
42537
|
+
await tracker.send(`短信验证码发送失败:${sent.message}`);
|
|
42538
|
+
return false;
|
|
42539
|
+
}
|
|
42540
|
+
session.cookie = sent.data.cookie;
|
|
42541
|
+
if (!sent.data.ok) {
|
|
42542
|
+
await tracker.send(`短信验证码发送失败:${sent.data.message}`);
|
|
42543
|
+
return false;
|
|
42544
|
+
}
|
|
42545
|
+
const bizTraceId = sent.data.biz_trace_id;
|
|
42546
|
+
const verifyWay = sent.data.verify_way;
|
|
42547
|
+
logger.mark(`[抖音登录] 二次验证方式: ${verifyWay}`);
|
|
42548
|
+
const target = sent.data.mobile || smsWay?.mobile || "扫码设备绑定的手机号";
|
|
42549
|
+
await tracker.send(`此次登录需要二次验证\n6 位数验证码已发送至 ${target}\n请在 ${CODE_INPUT_TIMEOUT} 秒内直接回复该验证码`);
|
|
42550
|
+
for (let attempt = 1; attempt <= CODE_MAX_ATTEMPTS; attempt++) {
|
|
42551
|
+
const context = await karin$1.ctx(e, {
|
|
42552
|
+
time: CODE_INPUT_TIMEOUT,
|
|
42553
|
+
reply: false,
|
|
42554
|
+
throwOnTimeout: false
|
|
42555
|
+
});
|
|
42556
|
+
if (!context) {
|
|
42557
|
+
await tracker.send("等待验证码超时,登录已取消");
|
|
42558
|
+
return false;
|
|
42559
|
+
}
|
|
42560
|
+
const code = context.msg.trim();
|
|
42561
|
+
if (!CODE_PATTERN.test(code)) {
|
|
42562
|
+
if (attempt === CODE_MAX_ATTEMPTS) {
|
|
42563
|
+
await tracker.send("输入格式不正确,登录已取消");
|
|
42564
|
+
return false;
|
|
42565
|
+
}
|
|
42566
|
+
await tracker.send(`请只发送 6 位数字验证码(剩余 ${CODE_MAX_ATTEMPTS - attempt} 次机会)`);
|
|
42567
|
+
continue;
|
|
42568
|
+
}
|
|
42569
|
+
const checked = await validatePassportVerifyCode({
|
|
42570
|
+
verify,
|
|
42571
|
+
code,
|
|
42572
|
+
biz_trace_id: bizTraceId,
|
|
42573
|
+
verify_way: verifyWay,
|
|
42574
|
+
typeMode: "strict"
|
|
42575
|
+
}, session.cookie, requestConfig());
|
|
42576
|
+
if (!checked.success) {
|
|
42577
|
+
await tracker.send(`验证失败:${checked.message}`);
|
|
42578
|
+
return false;
|
|
42579
|
+
}
|
|
42580
|
+
session.cookie = checked.data.cookie;
|
|
42581
|
+
if (checked.data.ok) {
|
|
42582
|
+
logger.mark("[抖音登录] 二次验证通过");
|
|
42583
|
+
await tracker.send("验证通过,正在完成登录…");
|
|
40941
42584
|
return true;
|
|
40942
42585
|
}
|
|
40943
|
-
|
|
40944
|
-
|
|
40945
|
-
|
|
40946
|
-
|
|
40947
|
-
await
|
|
42586
|
+
if (!checked.data.wrongCode || attempt === CODE_MAX_ATTEMPTS) {
|
|
42587
|
+
await tracker.send(`验证失败:${checked.data.message}`);
|
|
42588
|
+
return false;
|
|
42589
|
+
}
|
|
42590
|
+
await tracker.send(`验证码错误,请重新发送(剩余 ${CODE_MAX_ATTEMPTS - attempt} 次机会)`);
|
|
40948
42591
|
}
|
|
40949
|
-
return
|
|
42592
|
+
return false;
|
|
40950
42593
|
};
|
|
40951
42594
|
/**
|
|
40952
|
-
*
|
|
40953
|
-
* @param
|
|
40954
|
-
* @returns 包含解码URL和原始图片的对象
|
|
42595
|
+
* 保存登录凭证并重载 Amagi 客户端
|
|
42596
|
+
* @param cookie 完整登录 cookie
|
|
40955
42597
|
*/
|
|
40956
|
-
var
|
|
40957
|
-
const
|
|
40958
|
-
|
|
40959
|
-
|
|
40960
|
-
|
|
40961
|
-
|
|
40962
|
-
|
|
40963
|
-
|
|
40964
|
-
|
|
40965
|
-
|
|
40966
|
-
|
|
40967
|
-
|
|
40968
|
-
|
|
40969
|
-
|
|
40970
|
-
|
|
40971
|
-
|
|
40972
|
-
|
|
40973
|
-
const originalImage = qrCodeImages[0].src;
|
|
42598
|
+
var persistCookie = async (cookie) => {
|
|
42599
|
+
const present = new Set(cookie.split(";").map((pair) => pair.split("=")[0].trim()));
|
|
42600
|
+
const missing = REQUIRED_COOKIES.filter((name) => !present.has(name));
|
|
42601
|
+
if (missing.length > 0) logger.warn(`[抖音登录] 以下 cookie 未在本次登录中下发:${missing.join(", ")}`);
|
|
42602
|
+
await Config.Modify("amagi", "cookies.douyin", cookie);
|
|
42603
|
+
const reloaded = reloadAmagiConfig();
|
|
42604
|
+
logger.mark(`[抖音登录] 登录凭证已保存,Amagi Client ${reloaded ? "已重载" : "配置未变化"}`);
|
|
42605
|
+
};
|
|
42606
|
+
/**
|
|
42607
|
+
* 抖音扫码登录
|
|
42608
|
+
*
|
|
42609
|
+
* 协议层在 `@ikenxuan/amagi` 的 passport 接口里,这里只负责与用户的交互和状态流转,
|
|
42610
|
+
* 全程不启动浏览器。
|
|
42611
|
+
* @param e 消息事件
|
|
42612
|
+
*/
|
|
42613
|
+
var douyinLogin = async (e) => {
|
|
42614
|
+
const tracker = createMessageTracker(e);
|
|
40974
42615
|
try {
|
|
40975
|
-
|
|
40976
|
-
if (
|
|
40977
|
-
|
|
40978
|
-
|
|
40979
|
-
} else {
|
|
40980
|
-
const response = await fetch(originalImage);
|
|
40981
|
-
imageBuffer = Buffer.from(await response.arrayBuffer());
|
|
42616
|
+
const qrcode = await requestPassportQrcode({ typeMode: "strict" }, void 0, requestConfig());
|
|
42617
|
+
if (!qrcode.success) {
|
|
42618
|
+
await e.reply(`获取二维码失败:${qrcode.message}`, { reply: true });
|
|
42619
|
+
return true;
|
|
40982
42620
|
}
|
|
40983
|
-
const
|
|
40984
|
-
|
|
40985
|
-
|
|
40986
|
-
|
|
40987
|
-
|
|
40988
|
-
|
|
40989
|
-
|
|
42621
|
+
const session = {
|
|
42622
|
+
cookie: qrcode.data.cookie,
|
|
42623
|
+
token: qrcode.data.token
|
|
42624
|
+
};
|
|
42625
|
+
const validFor = qrcode.data.expires_in;
|
|
42626
|
+
logger.mark(`[抖音登录] 二维码已获取,有效期 ${validFor} 秒`);
|
|
42627
|
+
const rendered = await Render(e, "douyin/qrcodeImg", { share_url: qrcode.data.content });
|
|
42628
|
+
const base64Data = rendered[0]?.file;
|
|
42629
|
+
if (!base64Data) throw new Error("生成二维码图片失败");
|
|
42630
|
+
fs.writeFileSync(`${Common.tempDri.default}DouyinLoginQrcode.png`, Buffer.from(base64Data.replace(/^base64:\/\//, ""), "base64"));
|
|
42631
|
+
await tracker.send(rendered);
|
|
42632
|
+
let deadline = Date.now() + (validFor > 0 ? Math.min(validFor * 1e3, SCAN_TIMEOUT) : SCAN_TIMEOUT);
|
|
42633
|
+
let scanned = false;
|
|
42634
|
+
while (Date.now() < deadline) {
|
|
42635
|
+
const polled = await checkPassportQrcode({
|
|
42636
|
+
token: session.token,
|
|
42637
|
+
typeMode: "strict"
|
|
42638
|
+
}, session.cookie, requestConfig());
|
|
42639
|
+
if (!polled.success) {
|
|
42640
|
+
await tracker.recallAll();
|
|
42641
|
+
await e.reply(`轮询二维码状态失败:${polled.message}`, { reply: true });
|
|
42642
|
+
return true;
|
|
42643
|
+
}
|
|
42644
|
+
const result = polled.data;
|
|
42645
|
+
session.cookie = result.cookie;
|
|
42646
|
+
switch (result.status) {
|
|
42647
|
+
case "new": break;
|
|
42648
|
+
case "scanned":
|
|
42649
|
+
if (!scanned) {
|
|
42650
|
+
scanned = true;
|
|
42651
|
+
deadline = Date.now() + CONFIRM_TIMEOUT;
|
|
42652
|
+
await tracker.recallAll();
|
|
42653
|
+
await tracker.send("二维码已扫描,请在手机上确认登录");
|
|
42654
|
+
}
|
|
42655
|
+
break;
|
|
42656
|
+
case "verify":
|
|
42657
|
+
if (!await handleSecondVerify(e, session, result.verify, tracker)) {
|
|
42658
|
+
await tracker.recallAll();
|
|
42659
|
+
return true;
|
|
42660
|
+
}
|
|
42661
|
+
deadline = Date.now() + CONFIRM_TIMEOUT;
|
|
42662
|
+
break;
|
|
42663
|
+
case "confirmed":
|
|
42664
|
+
if (!result.logged_in) {
|
|
42665
|
+
await tracker.recallAll();
|
|
42666
|
+
await e.reply("已确认登录,但服务端未下发登录凭证,请稍后重试", { reply: true });
|
|
42667
|
+
return true;
|
|
42668
|
+
}
|
|
42669
|
+
await persistCookie(result.cookie);
|
|
42670
|
+
await tracker.recallAll();
|
|
42671
|
+
await e.reply("登录成功!用户登录凭证已保存至配置", { reply: true });
|
|
42672
|
+
return true;
|
|
42673
|
+
case "expired":
|
|
42674
|
+
await tracker.recallAll();
|
|
42675
|
+
await e.reply("二维码已失效,请重新发起登录", { reply: true });
|
|
42676
|
+
return true;
|
|
42677
|
+
case "busy":
|
|
42678
|
+
logger.debug(`[抖音登录] 轮询被限频,${result.interval} ms 后重试: ${result.message}`);
|
|
42679
|
+
break;
|
|
42680
|
+
case "risk":
|
|
42681
|
+
logger.warn(`[抖音登录] 命中风控: ${result.message}`);
|
|
42682
|
+
await tracker.recallAll();
|
|
42683
|
+
await e.reply(`登录请求被抖音风控拦截:${result.message}\n请稍后再试`, { reply: true });
|
|
42684
|
+
return true;
|
|
42685
|
+
case "unknown":
|
|
42686
|
+
logger.warn(`[抖音登录] 未知的轮询状态: ${result.message}`);
|
|
42687
|
+
break;
|
|
42688
|
+
}
|
|
42689
|
+
await sleep(result.interval);
|
|
40990
42690
|
}
|
|
42691
|
+
await tracker.recallAll();
|
|
42692
|
+
await e.reply(scanned ? "等待手机确认超时,登录已取消" : "登录超时!二维码已失效!", { reply: true });
|
|
40991
42693
|
} catch (error) {
|
|
40992
|
-
logger.
|
|
42694
|
+
logger.error("[抖音登录] 登录流程出错:", error);
|
|
42695
|
+
await tracker.recallAll();
|
|
42696
|
+
await e.reply("登录过程出错,请查看控制台日志", { reply: true });
|
|
40993
42697
|
}
|
|
40994
|
-
|
|
40995
|
-
return {
|
|
40996
|
-
url: null,
|
|
40997
|
-
originalImage
|
|
40998
|
-
};
|
|
42698
|
+
return true;
|
|
40999
42699
|
};
|
|
41000
42700
|
//#endregion
|
|
41001
42701
|
//#region src/apps/admin.ts
|
|
@@ -41746,11 +43446,12 @@ var globalStatistics = karin.command(/^#?kkk全局解析统计$/, handleGlobalSt
|
|
|
41746
43446
|
//#endregion
|
|
41747
43447
|
//#region src/apps/testPush.ts
|
|
41748
43448
|
/** 构建与生产推送一致的作品二维码链接。 */
|
|
41749
|
-
function buildWorkShareLink(aweme) {
|
|
43449
|
+
function buildWorkShareLink(aweme, selectedVideo) {
|
|
41750
43450
|
const workTypeInfo = getWorkTypeInfo(aweme);
|
|
41751
43451
|
if (workTypeInfo.isArticle) return `https://www.douyin.com/article/${aweme.aweme_id}`;
|
|
41752
43452
|
if (workTypeInfo.isImage) return `https://www.douyin.com/note/${aweme.aweme_id}`;
|
|
41753
|
-
|
|
43453
|
+
const playAddr = selectedVideo?.play_addr ?? aweme.video?.play_addr;
|
|
43454
|
+
return playAddr ? buildDouyinPlayUrl(playAddr) : `https://www.douyin.com/video/${aweme.aweme_id}`;
|
|
41754
43455
|
}
|
|
41755
43456
|
/**
|
|
41756
43457
|
* 测试抖音推送命令处理器
|
|
@@ -41795,20 +43496,18 @@ var handleTestPush = wrapWithErrorHandler(async (e) => {
|
|
|
41795
43496
|
return true;
|
|
41796
43497
|
}
|
|
41797
43498
|
const aweme = workData.data.aweme_detail;
|
|
41798
|
-
const
|
|
43499
|
+
const Detail_Data = buildDouyinWorkDetail(aweme, { user_info: await douyinFetcher.fetchUserProfile({
|
|
41799
43500
|
sec_uid: aweme.author.sec_uid,
|
|
41800
43501
|
typeMode: "strict"
|
|
41801
|
-
});
|
|
41802
|
-
const
|
|
41803
|
-
|
|
41804
|
-
user_info: userinfo
|
|
41805
|
-
};
|
|
41806
|
-
const shareLink = buildWorkShareLink(Detail_Data);
|
|
43502
|
+
}) });
|
|
43503
|
+
const selectedVideo = aweme.video?.bit_rate?.length ? douyinProcessVideos(aweme.video.bit_rate, Config.douyin.videoQuality)[0] : null;
|
|
43504
|
+
const shareLink = buildWorkShareLink(Detail_Data, selectedVideo);
|
|
41807
43505
|
images = await renderWorkImage({
|
|
41808
43506
|
e,
|
|
41809
43507
|
Detail_Data,
|
|
41810
43508
|
create_time: aweme.create_time,
|
|
41811
|
-
shareLink
|
|
43509
|
+
shareLink,
|
|
43510
|
+
videoSource: selectedVideo
|
|
41812
43511
|
});
|
|
41813
43512
|
if (images.length === 0) {
|
|
41814
43513
|
e.reply("未能识别该作品类型,无法渲染推送图片");
|
|
@@ -41844,12 +43543,11 @@ var handleTestPush = wrapWithErrorHandler(async (e) => {
|
|
|
41844
43543
|
typeMode: "strict"
|
|
41845
43544
|
});
|
|
41846
43545
|
} catch {}
|
|
41847
|
-
const Detail_Data = {
|
|
41848
|
-
...aweme,
|
|
43546
|
+
const Detail_Data = buildDouyinWorkDetail(aweme, {
|
|
41849
43547
|
user_info: userinfo,
|
|
41850
43548
|
author_user_info: authorUserInfo
|
|
41851
|
-
};
|
|
41852
|
-
const shareLink = buildWorkShareLink(Detail_Data);
|
|
43549
|
+
});
|
|
43550
|
+
const shareLink = buildWorkShareLink(Detail_Data, aweme.video?.bit_rate?.length ? douyinProcessVideos(aweme.video.bit_rate, Config.douyin.videoQuality)[0] : null);
|
|
41853
43551
|
images = await renderFavoriteImage({
|
|
41854
43552
|
e,
|
|
41855
43553
|
Detail_Data,
|
|
@@ -41891,12 +43589,11 @@ var handleTestPush = wrapWithErrorHandler(async (e) => {
|
|
|
41891
43589
|
typeMode: "strict"
|
|
41892
43590
|
});
|
|
41893
43591
|
} catch {}
|
|
41894
|
-
const Detail_Data = {
|
|
41895
|
-
...aweme,
|
|
43592
|
+
const Detail_Data = buildDouyinWorkDetail(aweme, {
|
|
41896
43593
|
user_info: userinfo,
|
|
41897
43594
|
author_user_info: authorUserInfo
|
|
41898
|
-
};
|
|
41899
|
-
const shareLink = buildWorkShareLink(Detail_Data);
|
|
43595
|
+
});
|
|
43596
|
+
const shareLink = buildWorkShareLink(Detail_Data, aweme.video?.bit_rate?.length ? douyinProcessVideos(aweme.video.bit_rate, Config.douyin.videoQuality)[0] : null);
|
|
41900
43597
|
images = await renderRecommendImage({
|
|
41901
43598
|
e,
|
|
41902
43599
|
Detail_Data,
|
|
@@ -41969,8 +43666,9 @@ var reg = {
|
|
|
41969
43666
|
kuaishou: /(快手.*快手|v\.kuaishou\.com|kuaishou\.com)/,
|
|
41970
43667
|
xiaohongshu: /(xiaohongshu\.com|xhslink\.(?:com|cn))/
|
|
41971
43668
|
};
|
|
43669
|
+
var passthroughCommandReg = /^#kkk推送全局忽略/;
|
|
41972
43670
|
var handleDouyin = wrapWithErrorHandler(async (e, next) => {
|
|
41973
|
-
if (e.msg.startsWith("#测试")) return next();
|
|
43671
|
+
if (e.msg.startsWith("#测试") || passthroughCommandReg.test(e.msg)) return next();
|
|
41974
43672
|
const forceBurnDanmaku = /^#?弹幕解析/.test(e.msg);
|
|
41975
43673
|
const urlMatch = e.msg.match(/(https?:\/\/[^\s]*\.(douyin|iesdouyin)\.com[^\s]*)/gi);
|
|
41976
43674
|
if (!urlMatch) {
|
|
@@ -41988,6 +43686,7 @@ var handleDouyin = wrapWithErrorHandler(async (e, next) => {
|
|
|
41988
43686
|
}
|
|
41989
43687
|
}, { businessName: "抖音视频解析" });
|
|
41990
43688
|
var handleBilibili = wrapWithErrorHandler(async (e, next) => {
|
|
43689
|
+
if (passthroughCommandReg.test(e.msg)) return next();
|
|
41991
43690
|
e.msg = e.msg.replace(/\\/g, "");
|
|
41992
43691
|
const forceBurnDanmaku = /^#?弹幕解析/.test(e.msg);
|
|
41993
43692
|
const urlRegex = /(https?:\/\/(?:(?:www\.|m\.|t\.)?bilibili\.com|b23\.tv|bili2233\.cn)\/[a-zA-Z0-9_\-.~:/?#[\]@!$&'()*+,;=]+)/;
|
|
@@ -43075,4 +44774,4 @@ mkdirSync(`${karinPathBase}/${Root.pluginName}/data`);
|
|
|
43075
44774
|
mkdirSync(Common.tempDri.images);
|
|
43076
44775
|
mkdirSync(Common.tempDri.video);
|
|
43077
44776
|
//#endregion
|
|
43078
|
-
export { createVoteNode as $,
|
|
44777
|
+
export { createVoteNode as $, KuaishouLiveRoomInfoParamsSchema as $n, emitLogMark as $r, MajorType as $t, testWrapWithErrorHandler as A, createBoundDouyinFetcher as An, BilibiliCommentReplyParamsSchema as Ar, DouyinMethodMapping as At, createImageNode as B, createBoundBilibiliFetcher as Bn, BilibiliValidateCaptchaParamsSchema as Br, DouyinFetcherMethods as Bt, changelog as C, createBoundXiaohongshuFetcher as Cn, BilibiliArticleParamsSchema as Cr, softFetch as Ct, dylogin as D, kuaishouSign as Dn, BilibiliBv2AvParamsSchema as Dr, BilibiliApiRoutes as Dt, biLogin as E, kuaishouFetcher$1 as En, BilibiliBangumiStreamParamsSchema as Er, amagi as Et, createCodeBlockNode as F, requestPassportQrcode as Fn, BilibiliLoginParamsSchema as Fr, getApiRoute as Ft, createLotteryNode as G, validateKuaishouParams as Gn, emitApiError as Gr, KuaishouMethodToFetcher as Gt, createLinkCardNode as H, createSuccessResponse$1 as Hn, BilibiliVideoDownloadParamsSchema as Hr, DouyinMethodToFetcher as Ht, createEmojiNode as I, sendPassportVerifyCode as In, BilibiliMethodRoutes as Ir, getEnglishMethodName as It, createRichTextDocument as J, XiaohongshuValidationSchemas as Jn, emitHttpResponse as Jr, XiaohongshuInternalMethods as Jt, createMentionNode as K, validateXiaohongshuParams as Kn, emitApiSuccess as Kr, MethodMaps as Kt, createHashtagNode as L, validatePassportVerifyCode as Ln, BilibiliQrcodeParamsSchema as Lr, BilibiliFetcherMethods as Lt, renderRichTextToReact as M, douyinSign as Mn, BilibiliDynamicParamsSchema as Mr, KuaishouMethodMapping as Mt, createAtNode as N, checkPassportQrcode as Nn, BilibiliEmojiParamsSchema as Nr, XiaohongshuApiRoutes as Nt, removeOldFiles as O, kuaishouApiUrls as On, BilibiliColumnInfoParamsSchema as Or, BilibiliMethodMapping as Ot, createBlockquoteNode as P, isSmsCodeVerifyWay as Pn, BilibiliLiveParamsSchema as Pr, XiaohongshuMethodMapping as Pt, createViewPictureNode as Q, KuaishouEmojiParamsSchema as Qn, emitLogInfo as Qr, AdditionalType as Qt, createHeadingNode as R, passport_exports as Rn, BilibiliQrcodeStatusParamsSchema as Rr, BilibiliInternalMethods as Rt, setdyPush as S, isNetworkErrorResult as Sn, BilibiliArticleInfoParamsSchema as Sr, reloadAmagiConfig as St, version as T, createBoundKuaishouFetcher as Tn, BilibiliBangumiInfoParamsSchema as Tr, CreateApp as Tt, createListItemNode as U, validateBilibiliParams as Un, BilibiliVideoParamsSchema as Ur, KuaishouFetcherMethods as Ut, createLineBreakNode as V, createErrorResponse as Vn, BilibiliValidationSchemas as Vr, DouyinInternalMethods as Vt, createListNode as W, validateDouyinParams as Wn, amagiEvents as Wr, KuaishouInternalMethods as Wt, createTextNode as X, xiaohongshuSign as Xn, emitLogDebug as Xr, toFetcherMethod as Xt, createSearchKeywordNode as Y, xiaohongshuApiUrls as Yn, emitLog as Yr, XiaohongshuMethodToFetcher as Yt, createTopicNode as Z, KuaishouCommentParamsSchema as Zn, emitLogError as Zr, DynamicType as Zt, douyinPush as _, bv2av as _n, DouyinUserParamsSchema as _r, SOFT_ERROR_CODES as _t, bilibiliAPP as a, createKuaishouRoutes as an, DouyinCommentParamsSchema as ar, bilibiliDBInstance as at, globalIgnore as b, fetchResponse as bn, BilibiliApplyCaptchaParamsSchema as br, kuaishouFetcher as bt, prefix as c, bilibiliUtils as cn, DouyinEmojiListParamsSchema as cr, getBilibiliDB as ct, globalStatistics as d, ValidationError as dn, DouyinLiveRoomParamsSchema as dr, initAllDatabases as dt, emitLogWarn as ei, CommentType as en, KuaishouMethodRoutes as er, createWebLinkNode as et, groupStatistics as f, handleError as fn, DouyinMethodRoutes as fr, StatisticsDBBase as ft, changeBotID as g, av2bv as gn, DouyinUserListParamsSchema as gr, AmagiError as gt, bilibiliPushList as h, parseDmSegMobileReply as hn, DouyinSearchParamsSchema as hr, AmagiBase as ht, update as i, Root as ii, kuaishouUtils as in, KuaishouVideoParamsSchema as ir, webConfig as it, templates as j, douyinApiUrls as jn, BilibiliDanmakuParamsSchema as jr, KuaishouApiRoutes as jt, task as k, douyinFetcher$1 as kn, BilibiliCommentParamsSchema as kr, DouyinApiRoutes as kt, xiaohongshuAPP as l, createBilibiliRoutes as ln, DouyinEmojiProParamsSchema as lr, getDouyinDB as lt, bilibiliPush as m, wbi_sign as mn, DouyinQrcodeParamsSchema as mr, BilibiliDBBase as mt, kkkUpdateCommand as n, emitNetworkRetry as ni, xiaohongshuUtils as nn, KuaishouUserWorkListParamsSchema as nr, normalizeRichTextNodes as nt, douyinAPP as o, douyinUtils as on, DouyinCommentReplyParamsSchema as or, cleanOldDynamicCache as ot, qrLogin as p, bilibiliErrorCodeMap as pn, DouyinMusicParamsSchema as pr, DouyinDBBase as pt, createParagraphNode as q, XiaohongshuMethodRoutes as qn, emitHttpRequest as qr, XiaohongshuFetcherMethods as qt, kkkUpdateTest as r, bilibiliApiUrls as ri, createXiaohongshuRoutes as rn, KuaishouValidationSchemas as rr, renderVideoPreviewPage as rt, kuaishouAPP as s, createDouyinRoutes as sn, DouyinDanmakuParamsSchema as sr, douyinDBInstance as st, kkkUpdate as t, emitNetworkError as ti, createAmagiClient as tn, KuaishouUserProfileParamsSchema as tr, extractRichTextPlainText as tt, testPush as u, ApiError as un, DouyinHotWordsParamsSchema as ur, getStatisticsDB as ut, douyinPushList as v, qtparam as vn, DouyinValidationSchemas as vr, bilibiliFetcher as vt, help as w, xiaohongshuFetcher$1 as wn, BilibiliAv2BvParamsSchema as wr, xiaohongshuFetcher as wt, setbiliPush as x, getHeadersAndData as xn, BilibiliArticleCardParamsSchema as xr, registerAmagiReloadListener as xt, forcePush as y, fetchData as yn, DouyinWorkParamsSchema as yr, douyinFetcher as yt, createHorizontalRuleNode as z, bilibiliFetcher$1 as zn, BilibiliUserParamsSchema as zr, BilibiliMethodToFetcher as zt };
|