specific-ai-cli 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.en.md +177 -0
- package/README.ja.md +177 -0
- package/README.ko.md +177 -0
- package/README.md +174 -2
- package/README.zh-TW.md +177 -0
- package/assets/banner.txt +3 -0
- package/dist/cli/argv.js +98 -0
- package/dist/cli/commands/auth.js +11 -0
- package/dist/cli/commands/config.js +23 -0
- package/dist/cli/commands/doctor.js +134 -0
- package/dist/cli/commands/platforms.js +24 -0
- package/dist/cli/commands/version.js +4 -0
- package/dist/cli/confirm.js +22 -0
- package/dist/cli/dispatch.js +440 -0
- package/dist/cli/help.js +223 -0
- package/dist/cli/hints.js +60 -0
- package/dist/cli/main.js +18 -0
- package/dist/cli/output.js +66 -0
- package/dist/core/auth-handlers.js +41 -0
- package/dist/core/auth-store.js +119 -0
- package/dist/core/capabilities.js +163 -0
- package/dist/core/config.js +121 -0
- package/dist/core/cookies.js +164 -0
- package/dist/core/errors.js +37 -0
- package/dist/core/files.js +89 -0
- package/dist/core/fsutil.js +52 -0
- package/dist/core/hash.js +4 -0
- package/dist/core/http.js +331 -0
- package/dist/core/image.js +280 -0
- package/dist/core/log.js +40 -0
- package/dist/core/login.js +191 -0
- package/dist/core/mp4.js +130 -0
- package/dist/core/normalize.js +207 -0
- package/dist/core/options.js +110 -0
- package/dist/core/paths.js +38 -0
- package/dist/core/pb.js +59 -0
- package/dist/core/py.js +181 -0
- package/dist/core/rand.js +131 -0
- package/dist/core/registry.js +129 -0
- package/dist/core/schemas.js +41 -0
- package/dist/core/stream.js +104 -0
- package/dist/core/toolkit.js +35 -0
- package/dist/core/vm.js +73 -0
- package/dist/core/vocab.js +157 -0
- package/dist/platforms/_shared/impaas.js +410 -0
- package/dist/platforms/bilibili/index.js +216 -0
- package/dist/platforms/bilibili/web/api.js +806 -0
- package/dist/platforms/bilibili/web/client.js +311 -0
- package/dist/platforms/bilibili/web/commands.js +783 -0
- package/dist/platforms/bilibili/web/ddddocr.js +342 -0
- package/dist/platforms/bilibili/web/gaia.js +130 -0
- package/dist/platforms/bilibili/web/geetest-manual.js +154 -0
- package/dist/platforms/bilibili/web/geetest-vision.js +232 -0
- package/dist/platforms/bilibili/web/geetest.js +450 -0
- package/dist/platforms/bilibili/web/normalize.js +234 -0
- package/dist/platforms/bilibili/web/profile.js +135 -0
- package/dist/platforms/bilibili/web/resolve.js +112 -0
- package/dist/platforms/bilibili/web/sign.js +223 -0
- package/dist/platforms/bilibili/web/upos.js +102 -0
- package/dist/platforms/douyin/index.js +142 -0
- package/dist/platforms/douyin/web/api.js +863 -0
- package/dist/platforms/douyin/web/client.js +413 -0
- package/dist/platforms/douyin/web/commands.js +712 -0
- package/dist/platforms/douyin/web/creator.js +601 -0
- package/dist/platforms/douyin/web/crypto.js +247 -0
- package/dist/platforms/douyin/web/dtrait.js +190 -0
- package/dist/platforms/douyin/web/im.js +502 -0
- package/dist/platforms/douyin/web/image.js +5 -0
- package/dist/platforms/douyin/web/live.js +225 -0
- package/dist/platforms/douyin/web/mssdk.js +148 -0
- package/dist/platforms/douyin/web/normalize.js +326 -0
- package/dist/platforms/douyin/web/passport.js +1247 -0
- package/dist/platforms/douyin/web/profile.js +221 -0
- package/dist/platforms/douyin/web/proto.js +26 -0
- package/dist/platforms/douyin/web/resolve.js +88 -0
- package/dist/platforms/douyin/web/sign.js +436 -0
- package/dist/platforms/douyin/web/tos.js +78 -0
- package/dist/platforms/index.js +17 -0
- package/dist/platforms/jd/index.js +67 -0
- package/dist/platforms/jd/web/aks.js +115 -0
- package/dist/platforms/jd/web/api.js +549 -0
- package/dist/platforms/jd/web/chat.js +132 -0
- package/dist/platforms/jd/web/client.js +470 -0
- package/dist/platforms/jd/web/commands.js +557 -0
- package/dist/platforms/jd/web/jcap/helper.js +90 -0
- package/dist/platforms/jd/web/jcap/host.js +107 -0
- package/dist/platforms/jd/web/jcap/lsd.js +300 -0
- package/dist/platforms/jd/web/jcap/solver.js +995 -0
- package/dist/platforms/jd/web/jcap.js +150 -0
- package/dist/platforms/jd/web/js.js +420 -0
- package/dist/platforms/jd/web/login.js +775 -0
- package/dist/platforms/jd/web/normalize.js +297 -0
- package/dist/platforms/jd/web/profile.js +256 -0
- package/dist/platforms/jd/web/util.js +175 -0
- package/dist/platforms/jd/web/webm.js +133 -0
- package/dist/platforms/kuaishou/index.js +98 -0
- package/dist/platforms/kuaishou/web/api.js +694 -0
- package/dist/platforms/kuaishou/web/captcha-crypto.js +134 -0
- package/dist/platforms/kuaishou/web/captcha-fp.js +180 -0
- package/dist/platforms/kuaishou/web/captcha.js +273 -0
- package/dist/platforms/kuaishou/web/client.js +487 -0
- package/dist/platforms/kuaishou/web/commands.js +863 -0
- package/dist/platforms/kuaishou/web/gap.js +144 -0
- package/dist/platforms/kuaishou/web/gdfp.js +376 -0
- package/dist/platforms/kuaishou/web/gql.js +11 -0
- package/dist/platforms/kuaishou/web/live.js +125 -0
- package/dist/platforms/kuaishou/web/normalize.js +241 -0
- package/dist/platforms/kuaishou/web/oracle.js +329 -0
- package/dist/platforms/kuaishou/web/profile.js +259 -0
- package/dist/platforms/kuaishou/web/resolve.js +84 -0
- package/dist/platforms/kuaishou/web/session.js +592 -0
- package/dist/platforms/kuaishou/web/sign.js +568 -0
- package/dist/platforms/taobao/index.js +56 -0
- package/dist/platforms/taobao/web/api.js +96 -0
- package/dist/platforms/taobao/web/client.js +102 -0
- package/dist/platforms/taobao/web/commands.js +172 -0
- package/dist/platforms/taobao/web/im.js +68 -0
- package/dist/platforms/taobao/web/normalize.js +103 -0
- package/dist/platforms/taobao/web/profile.js +85 -0
- package/dist/platforms/taobao/web/resolve.js +51 -0
- package/dist/platforms/taobao/web/sign.js +35 -0
- package/dist/platforms/tiktok/index.js +120 -0
- package/dist/platforms/tiktok/web/api.js +1234 -0
- package/dist/platforms/tiktok/web/client.js +362 -0
- package/dist/platforms/tiktok/web/commands.js +821 -0
- package/dist/platforms/tiktok/web/im.js +133 -0
- package/dist/platforms/tiktok/web/jsrun.js +126 -0
- package/dist/platforms/tiktok/web/normalize.js +286 -0
- package/dist/platforms/tiktok/web/profile.js +169 -0
- package/dist/platforms/tiktok/web/resolve.js +156 -0
- package/dist/platforms/tiktok/web/sign.js +585 -0
- package/dist/platforms/tiktok/web/upload.js +805 -0
- package/dist/platforms/tiktok/web/wire.js +490 -0
- package/dist/platforms/weibo/index.js +91 -0
- package/dist/platforms/weibo/web/api.js +206 -0
- package/dist/platforms/weibo/web/client.js +155 -0
- package/dist/platforms/weibo/web/commands.js +202 -0
- package/dist/platforms/weibo/web/normalize.js +137 -0
- package/dist/platforms/weibo/web/profile.js +246 -0
- package/dist/platforms/weibo/web/resolve.js +60 -0
- package/dist/platforms/weibo/web/sign.js +85 -0
- package/dist/platforms/x/index.js +107 -0
- package/dist/platforms/x/web/api.js +443 -0
- package/dist/platforms/x/web/article.js +212 -0
- package/dist/platforms/x/web/client.js +202 -0
- package/dist/platforms/x/web/commands.js +363 -0
- package/dist/platforms/x/web/graphql.js +108 -0
- package/dist/platforms/x/web/normalize.js +235 -0
- package/dist/platforms/x/web/profile.js +99 -0
- package/dist/platforms/x/web/resolve.js +44 -0
- package/dist/platforms/x/web/transaction.js +171 -0
- package/dist/platforms/xhs/index.js +101 -0
- package/dist/platforms/xhs/web/api.js +278 -0
- package/dist/platforms/xhs/web/client.js +540 -0
- package/dist/platforms/xhs/web/commands.js +1046 -0
- package/dist/platforms/xhs/web/creator-api.js +609 -0
- package/dist/platforms/xhs/web/creator.js +203 -0
- package/dist/platforms/xhs/web/js.js +194 -0
- package/dist/platforms/xhs/web/login.js +230 -0
- package/dist/platforms/xhs/web/normalize.js +166 -0
- package/dist/platforms/xhs/web/profile.js +127 -0
- package/dist/platforms/xhs/web/push.js +361 -0
- package/dist/platforms/xhs/web/resolve.js +69 -0
- package/dist/platforms/xhs/web/state.js +368 -0
- package/dist/platforms/xianyu/index.js +59 -0
- package/dist/platforms/xianyu/web/api.js +312 -0
- package/dist/platforms/xianyu/web/client.js +134 -0
- package/dist/platforms/xianyu/web/commands.js +305 -0
- package/dist/platforms/xianyu/web/im.js +73 -0
- package/dist/platforms/xianyu/web/normalize.js +123 -0
- package/dist/platforms/xianyu/web/profile.js +146 -0
- package/dist/platforms/xianyu/web/resolve.js +48 -0
- package/dist/platforms/xianyu/web/sign.js +79 -0
- package/package.json +77 -4
- package/static/douyin/Live.proto +194 -0
- package/static/douyin/Request.proto +89 -0
- package/static/douyin/Response.proto +46 -0
- package/static/douyin/acrawler_runtime/ac_vm.js +2 -0
- package/static/douyin/acrawler_runtime/browser_window_shape.json +1 -0
- package/static/douyin/acrawler_runtime/canvas_actual_exact.json +1 -0
- package/static/douyin/acrawler_runtime/run_ac_node.js +1005 -0
- package/static/douyin/acrawler_runtime/runner_window_keys_baseline.txt +240 -0
- package/static/douyin/challenge_profile.json +801 -0
- package/static/douyin/challenge_template_runner.js +217 -0
- package/static/douyin/dtrait_profile.json +66 -0
- package/static/douyin/mstoken_common_profile.json +1 -0
- package/static/douyin/mstoken_profile.json +214 -0
- package/static/jd/h5st5_env.js +750 -0
- package/static/jd/h5st5_lib.js +5 -0
- package/static/jd/h5st5_server.js +49 -0
- package/static/jd/jcap/env/env_core.js +512 -0
- package/static/jd/jcap/env/run.js +1307 -0
- package/static/jd/jcap/run/jcap_ujb96b.js +39 -0
- package/static/jd/package.json +4 -0
- package/static/jd/pc_tk_lib.js +1 -0
- package/static/jd/pc_tk_server.js +63 -0
- package/static/jd/summer_cryptico_runner.js +50 -0
- package/static/jd/webm/env/run.js +453 -0
- package/static/jd/webm/run/jdwebm-riskhandle.js +49 -0
- package/static/kuaishou/bundles/weapon/kwf-0.0.2.2cee19b4b7dec496.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-0-0.0.1-obfuscated.4b7f3de1ab0ddcc2.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-1-0.0.1-obfuscated.346ebdb1c78df316.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-10-0.0.1-obfuscated.a33db41897b983af.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-11-0.0.1-obfuscated.5e0a90af726d8a7e.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-12-0.0.1-obfuscated.20ea29345706ec8c.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-13-0.0.1-obfuscated.6b74e9640ff18648.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-14-0.0.1-obfuscated.551e4429d156bf66.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-15-0.0.1-obfuscated.8625b087aaba4a60.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-16-0.0.1-obfuscated.d5ffa4020f3be99a.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-17-0.0.1-obfuscated.6d13e19b22f50a80.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-18-0.0.1-obfuscated.6d31405044166289.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-19-0.0.1-obfuscated.a9a8d925942f19e4.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-2-0.0.1-obfuscated.b849a136ae4a6b3e.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-3-0.0.1-obfuscated.f9e77ebf6186e2ac.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-4-0.0.1-obfuscated.2c3c155aa52ceb0e.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-5-0.0.1-obfuscated.4e553d17691b3c5a.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-6-0.0.1-obfuscated.f73ddd3ff13e1217.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-7-0.0.1-obfuscated.1628b8729beaab7e.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-8-0.0.1-obfuscated.6da6baef7aa81fff.js +1 -0
- package/static/kuaishou/bundles/weapon/kws-9-0.0.1-obfuscated.8645d55e5323b743.js +1 -0
- package/static/kuaishou/fixtures/current_follow_script_813.network-response +2 -0
- package/static/kuaishou/fixtures/live_ws_maps.json +61 -0
- package/static/kuaishou/fixtures/live_ws_proto.json +1342 -0
- package/static/kuaishou/js/cp-kwf.js +1 -0
- package/static/kuaishou/tools/kwf_oracle.js +799 -0
- package/static/kuaishou/tools/kws_oracle.js +885 -0
- package/static/kuaishou/tools/like_token_oracle.js +70 -0
- package/static/taobao/taobao_js_20260407.js +570 -0
- package/static/tiktok/Tiktok_Request.json +597 -0
- package/static/tiktok/package.json +3 -0
- package/static/tiktok/shop_bsid/core.js +1 -0
- package/static/tiktok/shop_bsid/env_core.js +512 -0
- package/static/tiktok/shop_bsid/loader.js +1 -0
- package/static/tiktok/shop_bsid/run.js +753 -0
- package/static/tiktok/shop_bsid/sign.js +100 -0
- package/static/tiktok/shop_bsid/webmssdk.js +1 -0
- package/static/tiktok/shop_bsid/webmssdk_ex.js +1 -0
- package/static/tiktok/signing/env/env_core.js +512 -0
- package/static/tiktok/signing/env/sign.js +455 -0
- package/static/tiktok/signing/sdk_config.json +230 -0
- package/static/tiktok/signing/source/webmssdk.js +1 -0
- package/static/tiktok/signing/source/webmssdk_ex.js +1 -0
- package/static/tiktok/signing/source/webmssdk_legacy.js +1 -0
- package/static/x/graphql.json +7777 -0
- package/static/x/transaction_l1.json +12 -0
- package/static/xhs/package.json +3 -0
- package/static/xhs/xhs_core/js/b1.js +210 -0
- package/static/xhs/xhs_core/js/mns.js +340 -0
- package/static/xhs/xhs_core/js/mns_0101_keystream.json +5 -0
- package/static/xhs/xhs_core/js/mns_keystreams.json +1 -0
- package/static/xhs/xhs_core/js/sign.js +225 -0
- package/static/xhs/xhs_core/js/websectiga_cli.js +220 -0
- package/static/xhs/xhs_core/js/websectiga_env.js +512 -0
- package/static/xhs/xhs_core/js/xs_common.js +79 -0
- package/static/xhs/xhs_creator/js/profile.js +115 -0
- package/static/xhs/xhs_creator/js/rap_fingerprint_creator.json +6 -0
- package/static/xhs/xhs_creator/js/reference_profile.json +282 -0
- package/static/xhs/xhs_creator/js/xhs_creator_sign.js +29 -0
- package/static/xhs/xhs_creator/js/xhs_creator_signature.js +23 -0
- package/static/xhs/xhs_pc/js/aes_decrypt.js +22 -0
- package/static/xhs/xhs_pc/js/aes_encrypt.js +30 -0
- package/static/xhs/xhs_pc/js/b1.js +9 -0
- package/static/xhs/xhs_pc/js/deflate.js +320 -0
- package/static/xhs/xhs_pc/js/mns.js +4 -0
- package/static/xhs/xhs_pc/js/profile.js +141 -0
- package/static/xhs/xhs_pc/js/rap.js +173 -0
- package/static/xhs/xhs_pc/js/rap_cli.js +30 -0
- package/static/xhs/xhs_pc/js/rap_crypto.js +73 -0
- package/static/xhs/xhs_pc/js/rap_fingerprint_template.json +8 -0
- package/static/xhs/xhs_pc/js/reference_profile.json +262 -0
- package/static/xhs/xhs_pc/js/sign.js +6 -0
- package/static/xhs/xhs_pc/js/web_ssk.js +82 -0
- package/static/xhs/xhs_pc/js/xs_common.js +4 -0
- package/static/xianyu/et_f.js +2 -0
- package/static/xianyu/gen_tfstk.js +410 -0
- package/static/xianyu/goofish_js_version_2.js +568 -0
|
@@ -0,0 +1,863 @@
|
|
|
1
|
+
import { basename, extname } from 'node:path';
|
|
2
|
+
import { GUEST } from '../../../core/auth-store.js';
|
|
3
|
+
import { SpecificAIError } from '../../../core/errors.js';
|
|
4
|
+
import { downloadMedia, readMedia } from '../../../core/files.js';
|
|
5
|
+
import { cookieCredential, finishLogin, freshCredential, loginContext, poll, showQrcode, smsLogin } from '../../../core/login.js';
|
|
6
|
+
import * as n from '../../../core/normalize.js';
|
|
7
|
+
import * as rand from '../../../core/rand.js';
|
|
8
|
+
import { openSocket, reconnecting } from '../../../core/stream.js';
|
|
9
|
+
import { paged } from '../../../core/toolkit.js';
|
|
10
|
+
import * as api from './api.js';
|
|
11
|
+
import { bootstrapDeviceFingerprint, credentialCookies, Ks } from './client.js';
|
|
12
|
+
import { decodeFrame, enterRoomFrame, feedEvents, heartbeatFrame, userExitFrame } from './live.js';
|
|
13
|
+
import * as norm from './normalize.js';
|
|
14
|
+
import { CP, LIVE, UA, WWW } from './profile.js';
|
|
15
|
+
import { resolveItem, resolveRoom, resolveUser, selfEid } from './resolve.js';
|
|
16
|
+
import { mergeIssued } from './session.js';
|
|
17
|
+
/** 建立会话(相当于上游 KuaishouAuth.initialize),命令结束前把状态写回凭证。 */
|
|
18
|
+
async function ks(ctx, site = 'www') {
|
|
19
|
+
const k = new Ks(ctx);
|
|
20
|
+
await k.init();
|
|
21
|
+
if (site === 'live')
|
|
22
|
+
k.s.useSite(LIVE);
|
|
23
|
+
else if (site === 'cp')
|
|
24
|
+
k.s.useSite(CP);
|
|
25
|
+
return k;
|
|
26
|
+
}
|
|
27
|
+
/** 跑完命令后保存凭证:设备 cookie、轮换过的票据、kwfcv1 计数器。 */
|
|
28
|
+
async function run(ctx, site, fn) {
|
|
29
|
+
const k = await ks(ctx, site);
|
|
30
|
+
try {
|
|
31
|
+
return await fn(k);
|
|
32
|
+
}
|
|
33
|
+
finally {
|
|
34
|
+
k.save();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const NO_MORE = new Set(['', 'no_more', '-1', 'None', 'null', 'undefined']);
|
|
38
|
+
const more = (cursor) => cursor != null && !NO_MORE.has(String(cursor));
|
|
39
|
+
/** 搜索的翻页游标:pcursor 与首屏返回的 searchSessionId 一起。 */
|
|
40
|
+
function searchCursor(ctx) {
|
|
41
|
+
if (!ctx.cursor)
|
|
42
|
+
return ['', ''];
|
|
43
|
+
try {
|
|
44
|
+
const [p, s] = JSON.parse(ctx.cursor);
|
|
45
|
+
return [String(p ?? ''), String(s ?? '')];
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
throw new SpecificAIError('USAGE', '无效的 --cursor');
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// ================================================================ auth
|
|
52
|
+
/** 登录后校验:userInfoQuery 给出 eid 与昵称。 */
|
|
53
|
+
async function completeLogin(ctx, k, credential) {
|
|
54
|
+
k.s.useSite(WWW);
|
|
55
|
+
const info = await api.userInfo(k);
|
|
56
|
+
const eid = info?.eid ?? info?.id;
|
|
57
|
+
if (!eid)
|
|
58
|
+
throw new SpecificAIError('AUTH_REQUIRED', '登录没有成功:cookie 无效或已过期');
|
|
59
|
+
k.save(credential);
|
|
60
|
+
return finishLogin(ctx, credential, norm.ref(eid, info.name));
|
|
61
|
+
}
|
|
62
|
+
/** 直播站子站点:passToken 换票 + userLogin,失败只提示(直播命令会再试一次)。 */
|
|
63
|
+
async function liveSubsite(k) {
|
|
64
|
+
if (!k.s.cookies.get('passToken'))
|
|
65
|
+
return;
|
|
66
|
+
try {
|
|
67
|
+
k.s.useSite(LIVE);
|
|
68
|
+
if (!(await api.refreshLiveSession(k)))
|
|
69
|
+
k.log.warn('直播站换票没有成功,直播相关命令会在使用时重试');
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
k.log.warn(`直播站换票失败:${err.message}`);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* login_browser_session / _finish_mobile_browser_session 的公共后半段:
|
|
77
|
+
* CP STS + CP 文档 + 设备指纹上报 → 回到 www 再导航一次文档。
|
|
78
|
+
*/
|
|
79
|
+
async function finishBrowserSession(k, issued) {
|
|
80
|
+
const cp = await api.stsLogin(k, api.SID_CP, CP);
|
|
81
|
+
if (!cp.some(([key]) => key === `${api.SID_CP}_ph`)) {
|
|
82
|
+
throw new SpecificAIError('AUTH_REQUIRED', '创作者中心 STS 换票没有下发 kuaishou.web.cp.api_ph', { detail: { stage: 'cp/sts', cookies: cp.map(([key]) => key) } });
|
|
83
|
+
}
|
|
84
|
+
mergeIssued(issued, cp);
|
|
85
|
+
mergeIssued(issued, await api.bootstrapDocument(k, CP, '/article/publish/video?origin=www.kuaishou.com'));
|
|
86
|
+
const fp = await bootstrapDeviceFingerprint(k);
|
|
87
|
+
k.s.useSite(WWW);
|
|
88
|
+
const doc = await api.bootstrapDocument(k, WWW, '/new-reco');
|
|
89
|
+
mergeIssued(issued, [
|
|
90
|
+
['wid', fp.wid],
|
|
91
|
+
['didv', fp.didv],
|
|
92
|
+
]);
|
|
93
|
+
mergeIssued(issued, doc);
|
|
94
|
+
return issued;
|
|
95
|
+
}
|
|
96
|
+
/** 登录用的新会话(上游 prepare_auth("") + _ensure_browser_defaults)。 */
|
|
97
|
+
function loginKs(ctx, credential) {
|
|
98
|
+
const k = new Ks({ ...ctx, account: GUEST, credential });
|
|
99
|
+
k.s.prepare([]);
|
|
100
|
+
k.s.liveBootstrapEnabled = true;
|
|
101
|
+
k.s.ensureBrowserDefaults();
|
|
102
|
+
return k;
|
|
103
|
+
}
|
|
104
|
+
/** 扫码登录的总时限(等扫码与等确认共用)。 */
|
|
105
|
+
const QR_TIMEOUT_MS = 600_000;
|
|
106
|
+
async function qrcodeLogin(ctx) {
|
|
107
|
+
const credential = freshCredential(ctx, 'qrcode');
|
|
108
|
+
const k = loginKs(ctx, credential);
|
|
109
|
+
const sid = api.SID_WWW;
|
|
110
|
+
const channel = api.channelFor(sid);
|
|
111
|
+
const deadline = rand.now() + QR_TIMEOUT_MS;
|
|
112
|
+
const state = { token: '', signature: '' };
|
|
113
|
+
const issue = async () => {
|
|
114
|
+
const start = await api.qrStart(k, sid, channel);
|
|
115
|
+
if (start?.result !== 1)
|
|
116
|
+
return false;
|
|
117
|
+
state.token = start.qrLoginToken;
|
|
118
|
+
state.signature = start.qrLoginSignature;
|
|
119
|
+
await showQrcode(ctx, String(start.qrUrl ?? ''), '请用快手 App 扫码并确认');
|
|
120
|
+
return true;
|
|
121
|
+
};
|
|
122
|
+
if (!(await issue()))
|
|
123
|
+
throw new SpecificAIError('UPSTREAM', '申请二维码失败', { detail: { stage: 'qr/start' } });
|
|
124
|
+
// 上游 login_by_qr 的 poll:等扫码时二维码过期(707)就换一张,扫过之后不能再换;711 / 712 是在手机上取消
|
|
125
|
+
const wait = (step) => poll(async () => {
|
|
126
|
+
let data;
|
|
127
|
+
try {
|
|
128
|
+
data = step === 'scan' ? await api.qrScanResult(k, state.token, state.signature, channel) : await api.qrAcceptResult(k, state.token, state.signature, sid, channel);
|
|
129
|
+
}
|
|
130
|
+
catch (err) {
|
|
131
|
+
// 长轮询读超时是正常的(上游 ReadTimeout 接着轮);其他网络错误交给 poll 容忍几次
|
|
132
|
+
if (err instanceof SpecificAIError && err.code === 'NETWORK' && err.detail?.kind === 'timeout')
|
|
133
|
+
return undefined;
|
|
134
|
+
throw err;
|
|
135
|
+
}
|
|
136
|
+
const result = data?.result;
|
|
137
|
+
if (result === 1)
|
|
138
|
+
return data;
|
|
139
|
+
if (result === 707) {
|
|
140
|
+
if (step === 'accept' || !(await issue()))
|
|
141
|
+
throw new SpecificAIError('AUTH_REQUIRED', '二维码已过期', { hint: 'specific-ai kuaishou auth login' });
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
if (result === 711 || result === 712)
|
|
145
|
+
throw new SpecificAIError('AUTH_REQUIRED', '已在手机上取消登录', { detail: { result } });
|
|
146
|
+
return undefined;
|
|
147
|
+
}, { interval: 1000, timeout: deadline - rand.now(), what: '扫码登录' });
|
|
148
|
+
await wait('scan');
|
|
149
|
+
ctx.log.info('已扫码,请在手机上确认');
|
|
150
|
+
const accepted = await wait('accept');
|
|
151
|
+
const qrToken = accepted.qrToken ?? accepted.qr_token;
|
|
152
|
+
if (!qrToken)
|
|
153
|
+
throw new SpecificAIError('UPSTREAM', '确认登录后没有拿到 qrToken', { detail: { stage: 'acceptResult' } });
|
|
154
|
+
const [data, issued] = await api.qrCallback(k, qrToken, sid, channel);
|
|
155
|
+
if (!issued.length || data?.result !== 1)
|
|
156
|
+
throw new SpecificAIError('AUTH_REQUIRED', `扫码登录失败(result=${data?.result})`, { detail: { stage: 'callback' } });
|
|
157
|
+
k.s.update(issued);
|
|
158
|
+
mergeIssued(issued, await api.stsLogin(k, sid));
|
|
159
|
+
mergeIssued(issued, await api.bootstrapDocument(k, WWW, '/new-reco'));
|
|
160
|
+
await finishBrowserSession(k, issued);
|
|
161
|
+
k.s.update(issued);
|
|
162
|
+
k.s.ensureBrowserDefaults();
|
|
163
|
+
await liveSubsite(k);
|
|
164
|
+
return completeLogin(ctx, k, credential);
|
|
165
|
+
}
|
|
166
|
+
async function smsFlow(ctx) {
|
|
167
|
+
const credential = freshCredential(ctx, 'sms');
|
|
168
|
+
let k = null;
|
|
169
|
+
return smsLogin(ctx, {
|
|
170
|
+
send: async (phone) => {
|
|
171
|
+
k = loginKs(ctx, credential);
|
|
172
|
+
await k.s.current();
|
|
173
|
+
const r = await api.requestMobileCode(k, phone);
|
|
174
|
+
if (r?.result !== 1) {
|
|
175
|
+
throw new SpecificAIError('UPSTREAM', `短信验证码申请失败:${r?.error_msg ?? r?.result}`, { detail: { result: r?.result } });
|
|
176
|
+
}
|
|
177
|
+
return { phone, cookies: [...k.s.cookies].filter(([name]) => name !== 'kwscode' && name !== 'kwssectoken'), kwfcv1: k.s.kwfcv1 };
|
|
178
|
+
},
|
|
179
|
+
verify: async (state, code) => {
|
|
180
|
+
if (!k) {
|
|
181
|
+
k = new Ks({ ...ctx, account: GUEST, credential });
|
|
182
|
+
k.s.prepare(state.cookies);
|
|
183
|
+
k.s.restoreCounter(state.kwfcv1);
|
|
184
|
+
k.s.liveBootstrapEnabled = true;
|
|
185
|
+
k.s.ensureBrowserDefaults();
|
|
186
|
+
}
|
|
187
|
+
const [data, issued] = await api.mobileCodeLogin(k, state.phone, code);
|
|
188
|
+
if (data?.result !== 1 || !data?.passToken) {
|
|
189
|
+
throw new SpecificAIError('AUTH_REQUIRED', `验证码登录失败:${data?.error_msg ?? data?.result}`, { detail: { stage: 'mobileCode', result: data?.result } });
|
|
190
|
+
}
|
|
191
|
+
k.s.update(issued);
|
|
192
|
+
k.s.update(issued);
|
|
193
|
+
mergeIssued(issued, await api.stsLogin(k, api.SID_WWW, WWW));
|
|
194
|
+
mergeIssued(issued, await api.bootstrapDocument(k, WWW, '/new-reco'));
|
|
195
|
+
await finishBrowserSession(k, issued);
|
|
196
|
+
k.s.update(issued);
|
|
197
|
+
k.s.ensureBrowserDefaults();
|
|
198
|
+
await liveSubsite(k);
|
|
199
|
+
return completeLogin(ctx, k, credential);
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
async function cookieLogin(ctx) {
|
|
204
|
+
const credential = cookieCredential(ctx, '.kuaishou.com');
|
|
205
|
+
const k = new Ks(loginContext(ctx, credential));
|
|
206
|
+
await k.init(credentialCookies(credential));
|
|
207
|
+
// 用户 CK 里有 passToken 但缺创作者中心票据时,补一次 CP STS(specific-ai 补充,失败只提示)
|
|
208
|
+
if (k.s.cookies.get('passToken') && !k.s.cookies.get('kuaishou.web.cp.api_ph')) {
|
|
209
|
+
try {
|
|
210
|
+
await api.stsLogin(k, api.SID_CP, CP);
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
ctx.log.warn(`创作者中心换票失败:${err.message}`);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!k.s.cookies.get('kuaishou.live.web_st'))
|
|
217
|
+
await liveSubsite(k);
|
|
218
|
+
return completeLogin(ctx, k, credential);
|
|
219
|
+
}
|
|
220
|
+
export async function authLogin(ctx) {
|
|
221
|
+
const method = ctx.options.method;
|
|
222
|
+
if (method === 'cookie')
|
|
223
|
+
return cookieLogin(ctx);
|
|
224
|
+
if (method === 'sms')
|
|
225
|
+
return smsFlow(ctx);
|
|
226
|
+
if (method === 'qrcode')
|
|
227
|
+
return qrcodeLogin(ctx);
|
|
228
|
+
throw new SpecificAIError('USAGE', `不支持的登录方式:${method}`);
|
|
229
|
+
}
|
|
230
|
+
export async function authStatus(ctx) {
|
|
231
|
+
return run(ctx, 'www', async (k) => {
|
|
232
|
+
const info = await api.userInfo(k);
|
|
233
|
+
const eid = info?.eid ?? info?.id;
|
|
234
|
+
return {
|
|
235
|
+
logged_in: Boolean(eid),
|
|
236
|
+
user: eid ? norm.ref(eid, info.name) : null,
|
|
237
|
+
method: eid ? ctx.credential.method : null,
|
|
238
|
+
expires_at: null,
|
|
239
|
+
};
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 服务端登出(`auth logout` 删除本地凭证前由 core 调用):直播页的 passport logout + userLogout。
|
|
244
|
+
* 直播站票据不全时先用 passToken 补一次(specific-ai 补充,与 live listen 相同),否则上游拒绝按半清理的状态登出。
|
|
245
|
+
*/
|
|
246
|
+
export async function serverLogout(ctx) {
|
|
247
|
+
await run(ctx, 'live', async (k) => {
|
|
248
|
+
const has = (name) => Boolean(k.s.cookies.get(name));
|
|
249
|
+
const live = ['kuaishou.live.web_st', 'kuaishou.live.web_ph'].filter(has).length;
|
|
250
|
+
if (has('userId') && live < 2 && has('passToken'))
|
|
251
|
+
await api.refreshLiveSession(k, api.roomReferer(api.LIVE_LOGOUT_EID));
|
|
252
|
+
await api.liveLogout(k);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
// ================================================================ user
|
|
256
|
+
export async function userGet(ctx) {
|
|
257
|
+
return run(ctx, 'www', async (k) => {
|
|
258
|
+
if (ctx.args.user === 'me') {
|
|
259
|
+
const p = k.check(await api.profile(k), 'profile/get');
|
|
260
|
+
return norm.selfUser(p);
|
|
261
|
+
}
|
|
262
|
+
const eid = await resolveUser(k, ctx.args.user);
|
|
263
|
+
// result 与其他接口一样映射(109 需要登录、2 风控);result=1 却没有 userProfile 的是用户不存在或不可见
|
|
264
|
+
const r = k.check(await api.profileReduced(k, eid), '用户资料');
|
|
265
|
+
if (!r.userProfile)
|
|
266
|
+
throw new SpecificAIError('UPSTREAM', `用户不存在或不可见(result=${r?.result})`, { detail: { result: r?.result, user: eid } });
|
|
267
|
+
return norm.reducedUser(r);
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
export async function userSearch(ctx) {
|
|
271
|
+
return run(ctx, 'www', async (k) => {
|
|
272
|
+
const [pcursor, session] = searchCursor(ctx);
|
|
273
|
+
const r = k.check(await api.searchUser(k, ctx.args.keyword, pcursor, session), '搜索');
|
|
274
|
+
const list = (r.users ?? r.list ?? []).map(norm.listUser);
|
|
275
|
+
const next = r.pcursor;
|
|
276
|
+
return paged(list, JSON.stringify([next, r.searchSessionId ?? session]), list.length > 0 && more(next));
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
export async function userItems(ctx) {
|
|
280
|
+
return run(ctx, 'www', async (k) => {
|
|
281
|
+
const eid = await resolveUser(k, ctx.args.user);
|
|
282
|
+
const r = k.check(await api.profileFeed(k, eid, ctx.cursor ?? ''), 'profile/feed');
|
|
283
|
+
const list = (r.feeds ?? []).map((f) => norm.feed(f));
|
|
284
|
+
return paged(list, r.pcursor, list.length > 0 && more(r.pcursor));
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
/** 快手只能看自己的点赞、关注、粉丝。 */
|
|
288
|
+
function onlyMe(ctx, what) {
|
|
289
|
+
const u = ctx.args.user ?? 'me';
|
|
290
|
+
if (u !== 'me')
|
|
291
|
+
throw new SpecificAIError('UNSUPPORTED', `快手只能查看自己的${what}`, { hint: `specific-ai kuaishou ${ctx.resource} ${ctx.action}` });
|
|
292
|
+
}
|
|
293
|
+
export async function userLikes(ctx) {
|
|
294
|
+
onlyMe(ctx, '点赞');
|
|
295
|
+
return run(ctx, 'www', async (k) => {
|
|
296
|
+
const r = k.check(await api.likedList(k, ctx.cursor ?? ''), 'feed/liked');
|
|
297
|
+
const list = (r.feeds ?? []).map((f) => norm.feed(f));
|
|
298
|
+
return paged(list, r.pcursor, list.length > 0 && more(r.pcursor));
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
export async function userCollects(ctx) {
|
|
302
|
+
return run(ctx, 'www', async (k) => {
|
|
303
|
+
const eid = await resolveUser(k, ctx.args.user ?? 'me');
|
|
304
|
+
const r = k.check(await api.collectList(k, eid, ctx.cursor ?? ''), 'collect/list');
|
|
305
|
+
const list = (r.feeds ?? []).map((f) => norm.feed(f));
|
|
306
|
+
return paged(list, r.pcursor, list.length > 0 && more(r.pcursor));
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
async function relation(ctx, ftype) {
|
|
310
|
+
onlyMe(ctx, ftype === 1 ? '关注' : '粉丝');
|
|
311
|
+
return run(ctx, 'www', async (k) => {
|
|
312
|
+
await selfEid(k);
|
|
313
|
+
const r = k.check(await api.relation(k, ftype, ctx.cursor ?? ''), 'relation/fol');
|
|
314
|
+
const list = (r.authors ?? r.fols ?? r.users ?? []).map(norm.listUser);
|
|
315
|
+
return paged(list, r.pcursor, list.length > 0 && more(r.pcursor));
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
export const userFollowers = (ctx) => relation(ctx, 2);
|
|
319
|
+
export const userFollowing = (ctx) => relation(ctx, 1);
|
|
320
|
+
// ================================================================ item
|
|
321
|
+
async function detailOf(k, input) {
|
|
322
|
+
return api.videoDetail(k, await resolveItem(k, input));
|
|
323
|
+
}
|
|
324
|
+
export async function itemGet(ctx) {
|
|
325
|
+
return run(ctx, 'www', async (k) => norm.detail(await detailOf(k, ctx.args.item)));
|
|
326
|
+
}
|
|
327
|
+
export async function itemSearch(ctx) {
|
|
328
|
+
return run(ctx, 'www', async (k) => {
|
|
329
|
+
const [pcursor, session] = searchCursor(ctx);
|
|
330
|
+
const r = k.check(await api.searchFeed(k, ctx.args.keyword, pcursor, session), '搜索');
|
|
331
|
+
const list = (r.feeds ?? r.list ?? []).map((f) => norm.feed(f));
|
|
332
|
+
return paged(list, JSON.stringify([r.pcursor, r.searchSessionId ?? session]), list.length > 0 && more(r.pcursor));
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
export async function itemRelated(ctx) {
|
|
336
|
+
return run(ctx, 'www', async (k) => {
|
|
337
|
+
const r = await api.shortVideoReco(k, await resolveItem(k, ctx.args.item));
|
|
338
|
+
return paged((r.feeds ?? []).map((f) => norm.feed(f)), null, false);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
/** 作品管理页的时间范围:服务端拒绝超过一年的范围(「时间范围不能大于1年」),一次查一年。 */
|
|
342
|
+
const WORKS_RANGE_MS = 365 * 86_400_000;
|
|
343
|
+
const WORKS_PAGE = 20;
|
|
344
|
+
/** 往前翻的下限:快手 2011 年上线,更早的窗口不用再查。 */
|
|
345
|
+
const WORKS_FLOOR_MS = Date.parse('2011-01-01T00:00:00+08:00');
|
|
346
|
+
/** photo/list 的查询(上游 video_photo_list,字段顺序照浏览器):时间窗口 [end - 1 年, end],从 cursor 往前翻。 */
|
|
347
|
+
function worksQuery(queryType, cursor, end) {
|
|
348
|
+
return { queryType, cursor, startTime: end - WORKS_RANGE_MS, endTime: end, limit: WORKS_PAGE, timeRangeType: 5, keyword: '' };
|
|
349
|
+
}
|
|
350
|
+
/** item list 的游标 `<cursor>:<窗口终点>`;只有一个数字的(旧版本输出的)按窗口终点为现在处理。 */
|
|
351
|
+
function worksCursor(cursor, now) {
|
|
352
|
+
if (!cursor)
|
|
353
|
+
return [now, now];
|
|
354
|
+
const [at, end] = cursor.split(':').map(Number);
|
|
355
|
+
if (!Number.isSafeInteger(at) || (end !== undefined && !Number.isSafeInteger(end)))
|
|
356
|
+
throw new SpecificAIError('USAGE', '无效的 --cursor');
|
|
357
|
+
return [at, end ?? now];
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* 作品管理页的作品列表(创作者中心),带审核状态。服务端一次只查一年:窗口里按 nextCursor 往前翻,
|
|
361
|
+
* 一个窗口翻完(一页不满 20 条)就把窗口前移一年接着翻(窗口编进游标),直到某个窗口一条作品都没有,或者早于快手上线。
|
|
362
|
+
*/
|
|
363
|
+
export async function itemList(ctx) {
|
|
364
|
+
return run(ctx, 'cp', async (k) => {
|
|
365
|
+
const now = rand.now();
|
|
366
|
+
const [cursor, end] = worksCursor(ctx.cursor, now);
|
|
367
|
+
const r = await api.videoPhotoList(k, worksQuery('0', cursor, end));
|
|
368
|
+
try {
|
|
369
|
+
k.check(r, '作品管理');
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
// 前移过的窗口没有实测过:服务端不认时当作没有更早的作品,前面已经取到的不因此作废
|
|
373
|
+
if (end > now - WORKS_RANGE_MS || !(err instanceof SpecificAIError) || err.code !== 'UPSTREAM')
|
|
374
|
+
throw err;
|
|
375
|
+
k.log.warn(`更早的作品没有取到,停止往前翻:${err.message}`);
|
|
376
|
+
return paged([], null, false);
|
|
377
|
+
}
|
|
378
|
+
const rows = r.data?.list ?? [];
|
|
379
|
+
const list = rows.map(norm.work);
|
|
380
|
+
// nextCursor 是本页最后一条的时间减 1 毫秒;一页满 20 条就在这个窗口里接着翻
|
|
381
|
+
const next = r.data?.nextCursor;
|
|
382
|
+
if (rows.length >= WORKS_PAGE && more(next))
|
|
383
|
+
return paged(list, `${next}:${end}`, true);
|
|
384
|
+
const start = end - WORKS_RANGE_MS;
|
|
385
|
+
if ((!rows.length && cursor === end) || start <= WORKS_FLOOR_MS)
|
|
386
|
+
return paged(list, null, false);
|
|
387
|
+
return paged(list, `${start - 1}:${start - 1}`, true);
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
export async function itemMedia(ctx) {
|
|
391
|
+
return run(ctx, 'www', async (k) => norm.photoMedia((await detailOf(k, ctx.args.item)).photo));
|
|
392
|
+
}
|
|
393
|
+
export async function itemDownload(ctx) {
|
|
394
|
+
return run(ctx, 'www', async (k) => {
|
|
395
|
+
const d = await detailOf(k, ctx.args.item);
|
|
396
|
+
return downloadMedia(ctx, k.http, String(d.photo?.id), norm.photoMedia(d.photo), {
|
|
397
|
+
headers: [
|
|
398
|
+
['user-agent', UA],
|
|
399
|
+
['referer', 'https://www.kuaishou.com/'],
|
|
400
|
+
],
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
// ---------------------------------------------------------------- 发布与上传(publish_api.py)
|
|
405
|
+
const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
406
|
+
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
407
|
+
/** 图文上传的 wire 合同是 image/png:非 PNG 先转成 PNG(上游用 Pillow,这里用 @napi-rs/canvas)。 */
|
|
408
|
+
async function toPng(file) {
|
|
409
|
+
if (Buffer.from(file.data.subarray(0, 8)).equals(PNG_SIGNATURE))
|
|
410
|
+
return file;
|
|
411
|
+
const { createCanvas, loadImage } = await import('@napi-rs/canvas');
|
|
412
|
+
let image;
|
|
413
|
+
try {
|
|
414
|
+
image = await loadImage(Buffer.from(file.data));
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
throw new SpecificAIError('USAGE', `无法读取图片:${file.filename}`);
|
|
418
|
+
}
|
|
419
|
+
const canvas = createCanvas(image.width, image.height);
|
|
420
|
+
canvas.getContext('2d').drawImage(image, 0, 0);
|
|
421
|
+
const stem = basename(file.filename, extname(file.filename)) || 'image';
|
|
422
|
+
return { data: new Uint8Array(await canvas.encode('png')), filename: `${stem}.png`, contentType: 'image/png' };
|
|
423
|
+
}
|
|
424
|
+
/** ksuploader:resume → 逐片 fragment → complete。服务端已有的分片跳过。 */
|
|
425
|
+
async function uploadBytes(k, token, endpoints, data, afterResume) {
|
|
426
|
+
if (!endpoints.length)
|
|
427
|
+
throw new SpecificAIError('UPSTREAM', 'upload/pre 没有下发上传地址');
|
|
428
|
+
const base = api.uploadBase(endpoints[0]);
|
|
429
|
+
const info = await api.uploadResume(k, base, token);
|
|
430
|
+
const d = info?.data ?? info ?? {};
|
|
431
|
+
const done = new Set((d.fragment_list ?? d.fragmentList ?? []).map((f) => Number(f?.id)));
|
|
432
|
+
const hint = Number(d.fragment_index ?? d.fragmentIndex ?? -1);
|
|
433
|
+
if (!done.size && hint >= 0)
|
|
434
|
+
for (let i = 0; i <= hint; i++)
|
|
435
|
+
done.add(i);
|
|
436
|
+
if (afterResume)
|
|
437
|
+
await afterResume();
|
|
438
|
+
const count = Math.max(1, Math.ceil(data.length / api.CHUNK_SIZE));
|
|
439
|
+
if (!d.existed) {
|
|
440
|
+
for (let i = 0; i < count; i++) {
|
|
441
|
+
if (done.has(i))
|
|
442
|
+
continue;
|
|
443
|
+
const start = i * api.CHUNK_SIZE;
|
|
444
|
+
const chunk = data.subarray(start, Math.min(start + api.CHUNK_SIZE, data.length));
|
|
445
|
+
k.log.info(`上传 ${i + 1}/${count}`);
|
|
446
|
+
await api.uploadFragment(k, base, token, i, chunk, start, data.length);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
await api.uploadComplete(k, base, token, count);
|
|
450
|
+
}
|
|
451
|
+
/** upload_atlas_images:权限预检 → 图文编辑器预请求 → 逐图 pre / 上传 / single finish → 单图草稿快照。 */
|
|
452
|
+
async function uploadAtlas(k, inputs) {
|
|
453
|
+
if (!inputs.length || inputs.length > 31)
|
|
454
|
+
throw new SpecificAIError('USAGE', '图文需要 1~31 张图片');
|
|
455
|
+
const images = [];
|
|
456
|
+
for (const input of inputs) {
|
|
457
|
+
if (input.data.length >= MAX_IMAGE_BYTES)
|
|
458
|
+
throw new SpecificAIError('USAGE', `${input.filename} 超过 15MB`);
|
|
459
|
+
images.push(await toPng(input));
|
|
460
|
+
}
|
|
461
|
+
if (k.s.cookies.get('kwpsecproductname') !== 'onvideo-cp')
|
|
462
|
+
k.s.useSite(CP);
|
|
463
|
+
await api.requirePublishAuthority(k);
|
|
464
|
+
for (const [label, r] of [
|
|
465
|
+
['realize/entrance', await api.atlasRealizeEntrance(k)],
|
|
466
|
+
['activity/list', await api.activityList(k, 'initial', 'atlas')],
|
|
467
|
+
['collection/canAddAtlas', await api.collectionCanAddAtlas(k)],
|
|
468
|
+
]) {
|
|
469
|
+
if (r?.result !== 1)
|
|
470
|
+
throw new SpecificAIError('UPSTREAM', `图文发布预检 ${label} 没有通过`, { detail: { result: r?.result, message: r?.message } });
|
|
471
|
+
}
|
|
472
|
+
let fileId = null;
|
|
473
|
+
let atlasId = null;
|
|
474
|
+
let resumed = false;
|
|
475
|
+
const blobKeys = [];
|
|
476
|
+
const urls = [];
|
|
477
|
+
for (const [i, image] of images.entries()) {
|
|
478
|
+
const pre = await api.atlasUploadPre(k, image.contentType, atlasId, fileId);
|
|
479
|
+
if (pre?.result !== 1)
|
|
480
|
+
throw new SpecificAIError('UPSTREAM', `第 ${i + 1} 张图片 upload/pre 失败:${pre?.message ?? pre?.result}`, { detail: { result: pre?.result } });
|
|
481
|
+
const data = pre.data ?? {};
|
|
482
|
+
fileId = data.fileId ?? fileId;
|
|
483
|
+
atlasId = data.atlasId ?? atlasId;
|
|
484
|
+
const one = data.uploadInfo?.[0] ?? {};
|
|
485
|
+
if (!one.token || !one.blobKey)
|
|
486
|
+
throw new SpecificAIError('UPSTREAM', `第 ${i + 1} 张图片 upload/pre 没有下发上传票据`);
|
|
487
|
+
await uploadBytes(k, one.token, one.endPoints ?? one.endpoints ?? [], image.data, async () => {
|
|
488
|
+
if (resumed)
|
|
489
|
+
return;
|
|
490
|
+
resumed = true;
|
|
491
|
+
for (const r of [await api.activityList(k, 'post_resume', 'atlas'), await api.activityTab(k, 'atlas'), await api.activityFilter(k, 'atlas')]) {
|
|
492
|
+
if (r?.result !== 1)
|
|
493
|
+
throw new SpecificAIError('UPSTREAM', '图文编辑器活动接口没有通过', { detail: { result: r?.result } });
|
|
494
|
+
}
|
|
495
|
+
});
|
|
496
|
+
const done = await api.atlasUploadSingleFinish(k, fileId, atlasId, one.blobKey);
|
|
497
|
+
if (done?.result !== 1)
|
|
498
|
+
throw new SpecificAIError('UPSTREAM', `第 ${i + 1} 张图片 single/finish 失败`, { detail: { result: done?.result } });
|
|
499
|
+
blobKeys.push(one.blobKey);
|
|
500
|
+
urls.push(n.url(done.data?.url?.[0]?.url));
|
|
501
|
+
}
|
|
502
|
+
if (images.length === 1) {
|
|
503
|
+
const snap = await api.atlasSnapshotSave(k, fileId, atlasId);
|
|
504
|
+
if (snap?.result !== 1)
|
|
505
|
+
throw new SpecificAIError('UPSTREAM', '图文草稿快照保存失败', { detail: { result: snap?.result } });
|
|
506
|
+
}
|
|
507
|
+
return { fileId, atlasId, blobKeys, urls };
|
|
508
|
+
}
|
|
509
|
+
/** upload_video_file:权限预检 → 视频上传上下文 → upload/pre → 分片上传 → upload/finish。 */
|
|
510
|
+
async function uploadVideo(k, file) {
|
|
511
|
+
if (!file.data.length)
|
|
512
|
+
throw new SpecificAIError('USAGE', '视频文件为空');
|
|
513
|
+
await api.requirePublishAuthority(k);
|
|
514
|
+
await k.s.useCpUpload();
|
|
515
|
+
const pre = await api.videoUploadPre(k);
|
|
516
|
+
if (pre?.result !== 1)
|
|
517
|
+
throw new SpecificAIError('UPSTREAM', `upload/pre 失败:${pre?.message ?? pre?.result}`, { detail: { result: pre?.result } });
|
|
518
|
+
const data = pre.data ?? {};
|
|
519
|
+
await uploadBytes(k, data.token, data.endPoints ?? data.endpoints ?? [], file.data);
|
|
520
|
+
const finish = await api.videoUploadFinish(k, data.token, file.filename, file.contentType.startsWith('video/') ? file.contentType : 'video/mp4', file.data.length);
|
|
521
|
+
if (finish?.result !== 1)
|
|
522
|
+
throw new SpecificAIError('UPSTREAM', `upload/finish 失败:${finish?.message ?? finish?.result}`, { detail: { result: finish?.result } });
|
|
523
|
+
if (!(Number(finish.data?.videoDuration) > 0))
|
|
524
|
+
throw new SpecificAIError('UPSTREAM', '获取视频时长失败,请换一个视频');
|
|
525
|
+
return finish.data;
|
|
526
|
+
}
|
|
527
|
+
function caption(o) {
|
|
528
|
+
const tags = [...(o.tag ?? []), ...(o.topic ?? [])].map((t) => `#${t.replace(/^#/, '')}`);
|
|
529
|
+
return [[o.title, o.text].filter(Boolean).join('\n'), tags.join(' ')].filter(Boolean).join(' ');
|
|
530
|
+
}
|
|
531
|
+
export async function itemPublish(ctx) {
|
|
532
|
+
const o = ctx.options;
|
|
533
|
+
const images = o.image ?? [];
|
|
534
|
+
if (!images.length === !o.video)
|
|
535
|
+
throw new SpecificAIError('USAGE', '快手发布需要 --image(1~31 张)或 --video,二选一', { hint: 'specific-ai kuaishou item publish --image a.jpg --text "正文"' });
|
|
536
|
+
const photoStatus = api.PHOTO_STATUS[(o.visibility ?? 'public')];
|
|
537
|
+
const publishTime = o.schedule ? Date.parse(o.schedule) : 0;
|
|
538
|
+
return run(ctx, 'cp', async (k) => {
|
|
539
|
+
const text = caption(o);
|
|
540
|
+
if (images.length) {
|
|
541
|
+
const files = [];
|
|
542
|
+
for (const input of images)
|
|
543
|
+
files.push(await readMedia(k.http, input));
|
|
544
|
+
const up = await uploadAtlas(k, files);
|
|
545
|
+
const checked = await api.atlasUploadFinish(k, up.fileId, up.atlasId, up.blobKeys);
|
|
546
|
+
if (checked?.result !== 1)
|
|
547
|
+
throw new SpecificAIError('UPSTREAM', `图文审核没有通过:${checked?.message ?? checked?.result}`, { detail: { result: checked?.result } });
|
|
548
|
+
const r = k.check(await api.publishAtlas(k, { caption: text, fileId: up.fileId, atlasId: up.atlasId, photoStatus, publishTime }), '图文发布');
|
|
549
|
+
return n.item({
|
|
550
|
+
id: n.id(up.atlasId ?? up.fileId),
|
|
551
|
+
kind: 'image',
|
|
552
|
+
text: text || null,
|
|
553
|
+
media: up.urls.filter((u) => Boolean(u)).map((url, i) => n.media({ id: up.blobKeys[i] ?? null, type: 'image', url })),
|
|
554
|
+
status: o.visibility === 'private' ? 'private' : 'reviewing',
|
|
555
|
+
}, r);
|
|
556
|
+
}
|
|
557
|
+
const file = await readMedia(k.http, o.video);
|
|
558
|
+
const info = await uploadVideo(k, file);
|
|
559
|
+
const r = k.check(await api.publishVideo(k, {
|
|
560
|
+
fileId: Number(info.fileId),
|
|
561
|
+
coverKey: String(info.coverKey ?? ''),
|
|
562
|
+
mediaId: String(info.mediaId ?? ''),
|
|
563
|
+
videoDuration: Number(info.videoDuration),
|
|
564
|
+
caption: text,
|
|
565
|
+
photoStatus,
|
|
566
|
+
publishTime,
|
|
567
|
+
}), '视频发布');
|
|
568
|
+
const fallback = n.item({ id: n.id(info.fileId), kind: 'video', text: text || null, status: o.visibility === 'private' ? 'private' : 'reviewing' }, r);
|
|
569
|
+
const published = await afterVideoPublish(k, String(info.coverKey ?? '')).catch((err) => {
|
|
570
|
+
k.log.warn(`发布已提交,但取发布状态失败:${err.message}`);
|
|
571
|
+
return null;
|
|
572
|
+
});
|
|
573
|
+
if (!published)
|
|
574
|
+
return fallback;
|
|
575
|
+
// 就地改字段:展开会丢掉不可枚举的原始对象(--raw)
|
|
576
|
+
published.text ??= fallback.text;
|
|
577
|
+
if (o.visibility === 'private')
|
|
578
|
+
published.status = 'private';
|
|
579
|
+
return published;
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
/**
|
|
583
|
+
* 视频提交后浏览器跳到作品管理页(status=2&from=publish):photo/list 拿到这次发布的 publishId,
|
|
584
|
+
* 再 publish/refresh 取一次发布状态(上游 video_photo_list(post_publish) + video_publish_refresh)。
|
|
585
|
+
* 这一行对不上本次上传(不是唯一一行、已有 workId、封面不是这次的)时不轮询,只用列表里的数据。
|
|
586
|
+
*/
|
|
587
|
+
export async function afterVideoPublish(k, coverKey) {
|
|
588
|
+
const now = rand.now();
|
|
589
|
+
const list = await api.videoPhotoList(k, worksQuery('2', now, now), { postPublish: true });
|
|
590
|
+
if (list?.result !== 1)
|
|
591
|
+
return null;
|
|
592
|
+
const rows = list.data?.list ?? [];
|
|
593
|
+
const id = api.publishRefreshId(list, coverKey);
|
|
594
|
+
if (id == null)
|
|
595
|
+
return rows.length === 1 && rows[0]?.unPublishCoverKey === coverKey ? norm.work(rows[0]) : null;
|
|
596
|
+
const refresh = await api.videoPublishRefresh(k, [id]);
|
|
597
|
+
return norm.publishedWork(rows[0], refresh?.result === 1 ? refresh : null);
|
|
598
|
+
}
|
|
599
|
+
export async function mediaUpload(ctx) {
|
|
600
|
+
return run(ctx, 'cp', async (k) => {
|
|
601
|
+
const file = await readMedia(k.http, ctx.args.file);
|
|
602
|
+
if (file.contentType.startsWith('video/')) {
|
|
603
|
+
const info = await uploadVideo(k, file);
|
|
604
|
+
return n.media({ id: n.id(info.fileId), type: 'video', url: `${CP}/rest/cp/works/v2/video/pc/upload/cover/view?coverKey=${encodeURIComponent(String(info.coverKey ?? ''))}` }, info);
|
|
605
|
+
}
|
|
606
|
+
const up = await uploadAtlas(k, [file]);
|
|
607
|
+
return n.media({ id: up.blobKeys[0] ?? null, type: 'image', url: up.urls[0] ?? '' }, up);
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
// ================================================================ comment
|
|
611
|
+
export async function commentList(ctx) {
|
|
612
|
+
if (ctx.options.product)
|
|
613
|
+
throw new SpecificAIError('NOT_IMPLEMENTED', 'kuaishou 的 comment list --product 尚未实现', { detail: { upstream: 'none' } });
|
|
614
|
+
return run(ctx, 'www', async (k) => {
|
|
615
|
+
const photoId = await resolveItem(k, ctx.args.item);
|
|
616
|
+
const r = await api.commentListGql(k, photoId, ctx.cursor ?? '');
|
|
617
|
+
const list = (r.rootCommentsV2 ?? r.rootComments ?? []).map((c) => norm.comment(c, photoId));
|
|
618
|
+
const next = r.pcursorV2 ?? r.pcursor;
|
|
619
|
+
return paged(list, next, list.length > 0 && more(next));
|
|
620
|
+
});
|
|
621
|
+
}
|
|
622
|
+
export async function commentReplies(ctx) {
|
|
623
|
+
return run(ctx, 'www', async (k) => {
|
|
624
|
+
const photoId = await resolveItem(k, ctx.args.item);
|
|
625
|
+
const root = ctx.args.comment;
|
|
626
|
+
const r = await api.subCommentList(k, photoId, root, ctx.cursor ?? '');
|
|
627
|
+
const list = (r.subCommentsV2 ?? r.subComments ?? []).map((c) => norm.comment(c, photoId, root));
|
|
628
|
+
const next = r.pcursorV2 ?? r.pcursor;
|
|
629
|
+
return paged(list, next, list.length > 0 && more(next));
|
|
630
|
+
});
|
|
631
|
+
}
|
|
632
|
+
// ================================================================ feed
|
|
633
|
+
/**
|
|
634
|
+
* recommend 是 new-reco 的推荐流(get_feed_hot,上游 docstring:“获取推荐流(精彩推荐)”);following 是关注页。
|
|
635
|
+
* 推荐流每页都返回 pcursor="1"、翻页固定发 `{}`,原样输出的话游标不变,core 会停止翻页:输出的游标带上已取的页数
|
|
636
|
+
* `<pcursor>:<页数>`,请求时只用 pcursor。某页没有作品、或者 pcursor 表示没有更多时停止(与上游 get_some_feed_hot 相同)。
|
|
637
|
+
*/
|
|
638
|
+
export async function feedList(ctx) {
|
|
639
|
+
const kind = ctx.options.kind ?? 'recommend';
|
|
640
|
+
return run(ctx, 'www', async (k) => {
|
|
641
|
+
if (kind === 'following') {
|
|
642
|
+
const r = await api.likeData(k);
|
|
643
|
+
if (r?.result != null && r.result !== 1)
|
|
644
|
+
k.check(r, '关注流');
|
|
645
|
+
return paged((r.feeds ?? []).map((f) => norm.feed(f)), null, false);
|
|
646
|
+
}
|
|
647
|
+
const [pcursor, pages] = feedCursor(ctx.cursor);
|
|
648
|
+
const r = k.check(await api.feedHot(k, pcursor), 'feed/hot');
|
|
649
|
+
const list = (r.feeds ?? []).map((f) => norm.feed(f));
|
|
650
|
+
return paged(list, `${r.pcursor}:${pages + 1}`, list.length > 0 && more(r.pcursor));
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
/** 推荐流的游标 `<pcursor>:<已取的页数>`;没有页数的(旧版本输出的)按已取一页处理。 */
|
|
654
|
+
function feedCursor(cursor) {
|
|
655
|
+
if (!cursor)
|
|
656
|
+
return ['', 0];
|
|
657
|
+
const i = cursor.lastIndexOf(':');
|
|
658
|
+
const pages = i < 0 ? 1 : Number(cursor.slice(i + 1));
|
|
659
|
+
if (!Number.isSafeInteger(pages) || pages < 1)
|
|
660
|
+
throw new SpecificAIError('USAGE', '无效的 --cursor');
|
|
661
|
+
return [i < 0 ? cursor : cursor.slice(0, i), pages];
|
|
662
|
+
}
|
|
663
|
+
// ================================================================ live
|
|
664
|
+
const ROOM_CACHE_TTL = 6 * 3600_000;
|
|
665
|
+
const ROOM_CACHE_MAX = 200;
|
|
666
|
+
/** 首页列表里的直播间快照(上游 find_live_room 把它交给 LiveDanmakuClient,避免首页轮换后找不到)。 */
|
|
667
|
+
function rememberRooms(ctx, items) {
|
|
668
|
+
const cache = { ...(ctx.credential.extra.live_rooms ?? {}) };
|
|
669
|
+
for (const item of items) {
|
|
670
|
+
const eid = String(item?.author?.id ?? '');
|
|
671
|
+
if (!item?.id || !eid)
|
|
672
|
+
continue;
|
|
673
|
+
cache[eid] = { id: item.id, caption: item.caption, poster: item.poster, living: item.living, watchingCount: item.watchingCount, author: { id: eid, name: item.author?.name, living: item.author?.living }, at: rand.now() };
|
|
674
|
+
}
|
|
675
|
+
const entries = Object.entries(cache).sort((a, b) => Number(b[1].at) - Number(a[1].at)).slice(0, ROOM_CACHE_MAX);
|
|
676
|
+
ctx.credential.extra.live_rooms = Object.fromEntries(entries);
|
|
677
|
+
}
|
|
678
|
+
function homeRooms(home) {
|
|
679
|
+
const out = [];
|
|
680
|
+
for (const section of home?.list ?? [])
|
|
681
|
+
for (const group of section?.gameLiveInfo ?? [])
|
|
682
|
+
for (const item of group?.liveInfo ?? [])
|
|
683
|
+
if (item?.id && item?.author?.id)
|
|
684
|
+
out.push(item);
|
|
685
|
+
return out;
|
|
686
|
+
}
|
|
687
|
+
/** get_room_state:在直播首页 home/list 里找这个主播的直播间(拿 liveStreamId),找不到时用最近见过的快照。 */
|
|
688
|
+
async function roomState(k, eid) {
|
|
689
|
+
const rooms = homeRooms(k.checkLive(await api.homeList(k)));
|
|
690
|
+
rememberRooms(k.ctx, rooms);
|
|
691
|
+
const found = rooms.find((item) => String(item.author.id) === eid);
|
|
692
|
+
if (found)
|
|
693
|
+
return found;
|
|
694
|
+
const cached = k.ctx.credential.extra.live_rooms?.[eid];
|
|
695
|
+
if (cached && rand.now() - Number(cached.at) < ROOM_CACHE_TTL)
|
|
696
|
+
return cached;
|
|
697
|
+
throw new SpecificAIError('UPSTREAM', `直播首页列表里没有这个直播间:${eid}`, { hint: 'specific-ai kuaishou live list 查看当前可访问的直播间', detail: { room: eid } });
|
|
698
|
+
}
|
|
699
|
+
export async function liveGet(ctx) {
|
|
700
|
+
return run(ctx, 'live', async (k) => norm.liveRoom(await roomState(k, await resolveRoom(k, ctx.args.room))));
|
|
701
|
+
}
|
|
702
|
+
export async function liveList(ctx) {
|
|
703
|
+
return run(ctx, 'live', async (k) => {
|
|
704
|
+
const items = homeRooms(k.checkLive(await api.homeList(k)));
|
|
705
|
+
rememberRooms(ctx, items);
|
|
706
|
+
const seen = new Set();
|
|
707
|
+
const rooms = [];
|
|
708
|
+
for (const item of items) {
|
|
709
|
+
const eid = String(item.author.id);
|
|
710
|
+
if (seen.has(eid))
|
|
711
|
+
continue;
|
|
712
|
+
seen.add(eid);
|
|
713
|
+
rooms.push(norm.liveRoom(item));
|
|
714
|
+
}
|
|
715
|
+
rooms.sort((a, b) => Number(b.status === 'live') - Number(a.status === 'live'));
|
|
716
|
+
return paged(rooms, null, false);
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
export async function liveCategories(ctx) {
|
|
720
|
+
return run(ctx, 'live', async (k) => {
|
|
721
|
+
const d = k.checkLive(await api.categoryClassify(k));
|
|
722
|
+
const out = [];
|
|
723
|
+
const parents = new Set();
|
|
724
|
+
for (const c of d.list ?? []) {
|
|
725
|
+
const parent = n.idOrNull(c.categoryAbbr);
|
|
726
|
+
if (parent && !parents.has(parent)) {
|
|
727
|
+
parents.add(parent);
|
|
728
|
+
out.push(n.category({ id: parent, name: String(c.categoryName ?? parent) }));
|
|
729
|
+
}
|
|
730
|
+
out.push(n.category({ id: n.id(c.id), name: String(c.name ?? ''), parent_id: parent }, c));
|
|
731
|
+
}
|
|
732
|
+
return out;
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
export async function liveGifts(ctx) {
|
|
736
|
+
return run(ctx, 'live', async (k) => {
|
|
737
|
+
const eid = await resolveRoom(k, ctx.args.room);
|
|
738
|
+
const room = await roomState(k, eid);
|
|
739
|
+
// 进房间时的首屏列表,再点开“更多礼物”(sortType=0)取全量;全量取不到时退回首屏
|
|
740
|
+
const first = k.checkLive(await api.giftList(k, String(room.id), eid)).gifts ?? [];
|
|
741
|
+
let all = [];
|
|
742
|
+
try {
|
|
743
|
+
all = k.checkLive(await api.giftList(k, String(room.id), eid, 0)).gifts ?? [];
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
k.log.warn(`“更多礼物”没有取到,只列出首屏礼物:${err.message}`);
|
|
747
|
+
}
|
|
748
|
+
return (all.length ? all : first).map(norm.gift);
|
|
749
|
+
});
|
|
750
|
+
}
|
|
751
|
+
export async function liveReplays(ctx) {
|
|
752
|
+
return run(ctx, 'www', async (k) => {
|
|
753
|
+
const eid = await resolveUser(k, ctx.args.user);
|
|
754
|
+
const r = k.check(await api.playbackList(k, eid, ctx.cursor ?? ''), '直播回放');
|
|
755
|
+
const rows = r.list ?? r.playbackList ?? r.feeds ?? r.data?.list ?? [];
|
|
756
|
+
const list = rows.map((v) => norm.playback(v, eid));
|
|
757
|
+
const next = r.pcursor ?? r.data?.pcursor;
|
|
758
|
+
return paged(list, next, list.length > 0 && more(next));
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
/** 直播弹幕长连(上游 ks_apis/live_ws.py 的 LiveDanmakuClient)。 */
|
|
762
|
+
export function liveListen(ctx) {
|
|
763
|
+
return (async function* () {
|
|
764
|
+
const k = await ks(ctx, 'live');
|
|
765
|
+
try {
|
|
766
|
+
const eid = await resolveRoom(k, ctx.args.room);
|
|
767
|
+
const referer = api.roomReferer(eid);
|
|
768
|
+
if (!k.s.cookies.get('kuaishou.live.web_st') || !k.s.cookies.get('kuaishou.live.web_ph')) {
|
|
769
|
+
if (!k.s.cookies.get('passToken') || !(await api.refreshLiveSession(k, referer))) {
|
|
770
|
+
throw new SpecificAIError('AUTH_REQUIRED', '快手直播弹幕需要登录(缺少直播站票据)', { hint: 'specific-ai kuaishou auth login' });
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
const room = await roomState(k, eid);
|
|
774
|
+
const liveStreamId = String(room.id);
|
|
775
|
+
if (!(room.living || room.author?.living))
|
|
776
|
+
ctx.log.warn(`主播当前可能不在播:${eid}`);
|
|
777
|
+
let info = (await api.websocketInfo(k, liveStreamId, eid))?.data ?? {};
|
|
778
|
+
if (!info.token) {
|
|
779
|
+
ctx.log.info('websocketinfo 没有给 token,重建直播会话后重试');
|
|
780
|
+
if (await api.refreshLiveSession(k))
|
|
781
|
+
info = (await api.websocketInfo(k, liveStreamId, eid))?.data ?? {};
|
|
782
|
+
}
|
|
783
|
+
if (info.result === 400002 || (info.result === 2 && !info.token)) {
|
|
784
|
+
throw new SpecificAIError('RISK_CONTROL', '快手直播风控拦截:需要先在浏览器里过一次滑块验证', { detail: { kind: info.result === 400002 ? 'captcha' : 'blocked', result: info.result } });
|
|
785
|
+
}
|
|
786
|
+
const token = String(info.token ?? '');
|
|
787
|
+
const urls = info.websocketUrls ?? [];
|
|
788
|
+
if (!token || !urls.length)
|
|
789
|
+
throw new SpecificAIError('UPSTREAM', 'websocketinfo 没有给 token 或接入地址', { detail: { result: info.result } });
|
|
790
|
+
// 礼物名:首屏礼物列表 + 房间的完整礼物字典(allgifts,含不在面板上的礼物)
|
|
791
|
+
const gifts = new Map();
|
|
792
|
+
for (const [label, load] of [
|
|
793
|
+
['礼物列表', () => api.giftList(k, liveStreamId, eid)],
|
|
794
|
+
['礼物字典', () => api.emojiAllGifts(k, eid)],
|
|
795
|
+
]) {
|
|
796
|
+
try {
|
|
797
|
+
for (const [id, name] of norm.giftNames(k.checkLive(await load())))
|
|
798
|
+
gifts.set(id, name);
|
|
799
|
+
}
|
|
800
|
+
catch (err) {
|
|
801
|
+
ctx.log.debug(`${label}获取失败:${err.message}`);
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
k.save();
|
|
805
|
+
yield* reconnecting(ctx, async function* (attempt) {
|
|
806
|
+
const url = urls[attempt % urls.length];
|
|
807
|
+
const socket = await openSocket(url, { headers: { Origin: LIVE, 'User-Agent': UA }, signal: ctx.signal });
|
|
808
|
+
let beat = null;
|
|
809
|
+
try {
|
|
810
|
+
await socket.send(enterRoomFrame(token, liveStreamId, attempt));
|
|
811
|
+
for await (const raw of socket.messages) {
|
|
812
|
+
if (typeof raw === 'string')
|
|
813
|
+
continue;
|
|
814
|
+
const frame = decodeFrame(raw);
|
|
815
|
+
ctx.log.debug(`ws ${frame.type}`);
|
|
816
|
+
if (frame.type === 'SC_ENTER_ROOM_ACK' && !beat) {
|
|
817
|
+
const interval = Math.max(1000, Number(frame.payload?.heartbeatIntervalMs ?? 20000));
|
|
818
|
+
beat = setInterval(() => void socket.send(heartbeatFrame(rand.now())).catch(() => { }), interval).unref();
|
|
819
|
+
}
|
|
820
|
+
else if (frame.type === 'SC_FEED_PUSH') {
|
|
821
|
+
yield* feedEvents(frame.payload, (id) => gifts.get(id) ?? null);
|
|
822
|
+
}
|
|
823
|
+
else if (frame.type === 'SC_ERROR') {
|
|
824
|
+
ctx.log.warn(`直播服务端报错:${JSON.stringify(frame.payload)}`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
finally {
|
|
829
|
+
if (beat)
|
|
830
|
+
clearInterval(beat);
|
|
831
|
+
await socket.send(userExitFrame(rand.now())).catch(() => { });
|
|
832
|
+
socket.close();
|
|
833
|
+
}
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
finally {
|
|
837
|
+
k.save();
|
|
838
|
+
}
|
|
839
|
+
})();
|
|
840
|
+
}
|
|
841
|
+
// ================================================================ notice
|
|
842
|
+
/** 创作者中心未读通知数。 */
|
|
843
|
+
export async function noticeCount(ctx) {
|
|
844
|
+
return run(ctx, 'cp', async (k) => {
|
|
845
|
+
const r = k.check(await api.notificationUnreadCount(k), '通知');
|
|
846
|
+
const d = r.data ?? {};
|
|
847
|
+
const num = (...keys) => {
|
|
848
|
+
for (const key of keys)
|
|
849
|
+
if (typeof d[key] === 'number')
|
|
850
|
+
return d[key];
|
|
851
|
+
return null;
|
|
852
|
+
};
|
|
853
|
+
const total = num('total', 'totalCount', 'unReadCount', 'unreadCount', 'count');
|
|
854
|
+
return n.noticeCount({
|
|
855
|
+
total: total ?? undefined,
|
|
856
|
+
comment: num('comment', 'commentCount'),
|
|
857
|
+
mention: num('at', 'atCount', 'mention', 'mentionCount'),
|
|
858
|
+
like: num('like', 'likeCount'),
|
|
859
|
+
follow: num('follow', 'followCount', 'fans', 'fansCount'),
|
|
860
|
+
system: num('system', 'systemCount', 'notice', 'noticeCount'),
|
|
861
|
+
}, r);
|
|
862
|
+
});
|
|
863
|
+
}
|