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,783 @@
|
|
|
1
|
+
import { extname } from 'node:path';
|
|
2
|
+
import { brotliDecompressSync, inflateSync } from 'node:zlib';
|
|
3
|
+
import { GUEST } from '../../../core/auth-store.js';
|
|
4
|
+
import { SpecificAIError } from '../../../core/errors.js';
|
|
5
|
+
import { downloadMedia, readMedia } from '../../../core/files.js';
|
|
6
|
+
import { parseJson } from '../../../core/http.js';
|
|
7
|
+
import { cookieCredential, finishLogin, freshCredential, loginContext, poll, readPassword, showQrcode, smsLogin } from '../../../core/login.js';
|
|
8
|
+
import * as n from '../../../core/normalize.js';
|
|
9
|
+
import * as pb from '../../../core/pb.js';
|
|
10
|
+
import { jsonLoads } from '../../../core/py.js';
|
|
11
|
+
import * as rand from '../../../core/rand.js';
|
|
12
|
+
import { openSocket, reconnecting } from '../../../core/stream.js';
|
|
13
|
+
import { paged, scope } from '../../../core/toolkit.js';
|
|
14
|
+
import * as api from './api.js';
|
|
15
|
+
import { Bili, bili, check } from './client.js';
|
|
16
|
+
import { CANVAS_1 } from './gaia.js';
|
|
17
|
+
import * as geetest from './geetest.js';
|
|
18
|
+
import * as norm from './normalize.js';
|
|
19
|
+
import { COOKIE_DOMAIN, PROFILE } from './profile.js';
|
|
20
|
+
import { DYNAMIC_REPLY_HINT, resolveDynamic, resolveFolder, resolveItem, resolveReplyTarget, resolveRoom, resolveUser } from './resolve.js';
|
|
21
|
+
import { encryptPassword } from './sign.js';
|
|
22
|
+
import { uploadVideo } from './upos.js';
|
|
23
|
+
const page = (ctx) => Number(ctx.cursor ?? 1) || 1;
|
|
24
|
+
// ================================================================ auth
|
|
25
|
+
/** 登录用的临时上下文:凭证是新建的,身份按游客走一遍匿名设备初始化。 */
|
|
26
|
+
function guestLoginContext(ctx, method) {
|
|
27
|
+
return { ...ctx, account: GUEST, credential: freshCredential(ctx, method) };
|
|
28
|
+
}
|
|
29
|
+
async function completeLogin(ctx, b, refreshToken) {
|
|
30
|
+
const nav = await api.nav(b);
|
|
31
|
+
if (!nav.data?.isLogin)
|
|
32
|
+
throw new SpecificAIError('AUTH_REQUIRED', '登录没有成功:cookie 无效或已过期');
|
|
33
|
+
const credential = b.ctx.credential;
|
|
34
|
+
if (refreshToken)
|
|
35
|
+
scope(credential).tokens.refresh_token = refreshToken;
|
|
36
|
+
credential.extra.refresh_checked_at = rand.now();
|
|
37
|
+
return finishLogin(ctx, credential, norm.ref(nav.data.mid, nav.data.uname));
|
|
38
|
+
}
|
|
39
|
+
export async function authLogin(ctx) {
|
|
40
|
+
const method = ctx.options.method;
|
|
41
|
+
if (method === 'cookie') {
|
|
42
|
+
const credential = cookieCredential(ctx, COOKIE_DOMAIN);
|
|
43
|
+
const b = await bili(loginContext(ctx, credential));
|
|
44
|
+
return completeLogin(ctx, b);
|
|
45
|
+
}
|
|
46
|
+
if (method === 'sms') {
|
|
47
|
+
// 非 TTY 的第二步(--code)沿用第一步保存的设备 cookie,不重新初始化
|
|
48
|
+
const b = ctx.options.code ? new Bili(guestLoginContext(ctx, 'sms')) : await bili(guestLoginContext(ctx, 'sms'));
|
|
49
|
+
return smsLogin(ctx, {
|
|
50
|
+
send: async (phone) => {
|
|
51
|
+
const gt = await geetest.solve(b);
|
|
52
|
+
const r = await api.smsSend(b, phone, gt);
|
|
53
|
+
check(ctx, r);
|
|
54
|
+
return { phone, captcha_key: r.data.captcha_key, cookies: [...b.jar.cookies] };
|
|
55
|
+
},
|
|
56
|
+
verify: async (state, code) => {
|
|
57
|
+
b.jar.cookies.splice(0, b.jar.cookies.length, ...state.cookies);
|
|
58
|
+
const r = await api.smsLogin(b, state.phone, code, state.captcha_key);
|
|
59
|
+
check(ctx, r);
|
|
60
|
+
return completeLogin(ctx, b, r.data?.refresh_token);
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const b = await bili(guestLoginContext(ctx, method));
|
|
65
|
+
if (method === 'qrcode') {
|
|
66
|
+
const gen = await api.qrcodeGenerate(b);
|
|
67
|
+
check(ctx, gen);
|
|
68
|
+
await showQrcode(ctx, gen.data.url, '请用 B 站 App 扫码并确认');
|
|
69
|
+
let last = null;
|
|
70
|
+
const token = await poll(async () => {
|
|
71
|
+
const r = await api.qrcodePoll(b, gen.data.qrcode_key, CANVAS_1);
|
|
72
|
+
const code = r.data?.code;
|
|
73
|
+
if (code === 0)
|
|
74
|
+
return r.data.refresh_token ?? '';
|
|
75
|
+
if (code === 86038)
|
|
76
|
+
throw new SpecificAIError('AUTH_REQUIRED', '二维码已失效', { hint: '重新执行 specific-ai bilibili auth login' });
|
|
77
|
+
if (code === 86090 && last !== code)
|
|
78
|
+
ctx.log.info('已扫码,请在手机上确认');
|
|
79
|
+
last = code ?? null;
|
|
80
|
+
return undefined;
|
|
81
|
+
});
|
|
82
|
+
return completeLogin(ctx, b, token);
|
|
83
|
+
}
|
|
84
|
+
if (method === 'password') {
|
|
85
|
+
const username = ctx.options.username;
|
|
86
|
+
if (!username)
|
|
87
|
+
throw new SpecificAIError('USAGE', '账密登录需要 --username', { hint: 'specific-ai bilibili auth login --method password --username <手机号或邮箱> --password-stdin' });
|
|
88
|
+
const password = await readPassword(ctx);
|
|
89
|
+
const gt = await geetest.solve(b);
|
|
90
|
+
const key = await api.loginKey(b);
|
|
91
|
+
check(ctx, key);
|
|
92
|
+
const r = await api.passwordLogin(b, username, encryptPassword(key.data.hash, password, key.data.key), gt);
|
|
93
|
+
check(ctx, r);
|
|
94
|
+
return completeLogin(ctx, b, r.data?.refresh_token);
|
|
95
|
+
}
|
|
96
|
+
throw new SpecificAIError('USAGE', `不支持的登录方式:${method}`);
|
|
97
|
+
}
|
|
98
|
+
export async function authStatus(ctx) {
|
|
99
|
+
const b = await bili(ctx);
|
|
100
|
+
const nav = await api.nav(b);
|
|
101
|
+
const d = nav.data ?? {};
|
|
102
|
+
const sess = b.jar.cookies.find((c) => c.name === 'SESSDATA');
|
|
103
|
+
return {
|
|
104
|
+
logged_in: Boolean(d.isLogin),
|
|
105
|
+
user: d.isLogin ? norm.ref(d.mid, d.uname) : null,
|
|
106
|
+
method: d.isLogin ? ctx.credential.method : null,
|
|
107
|
+
expires_at: d.isLogin && sess?.expires ? n.time(sess.expires) : null,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** 服务端登出:`auth logout` 删除本地凭证前调用。 */
|
|
111
|
+
export async function serverLogout(ctx) {
|
|
112
|
+
const b = new Bili(ctx);
|
|
113
|
+
if (b.isLogin && b.csrf)
|
|
114
|
+
await api.logout(b);
|
|
115
|
+
}
|
|
116
|
+
// ================================================================ user
|
|
117
|
+
export async function userGet(ctx) {
|
|
118
|
+
const b = await bili(ctx);
|
|
119
|
+
if (ctx.args.user === 'me') {
|
|
120
|
+
const nav = await api.nav(b);
|
|
121
|
+
check(ctx, nav);
|
|
122
|
+
return norm.navUser(nav.data);
|
|
123
|
+
}
|
|
124
|
+
return norm.spaceUser(await api.userInfo(b, await resolveUser(b, ctx.args.user)));
|
|
125
|
+
}
|
|
126
|
+
export async function userSearch(ctx) {
|
|
127
|
+
const b = await bili(ctx);
|
|
128
|
+
const p = page(ctx);
|
|
129
|
+
const d = await api.searchType(b, ctx.args.keyword, 'totalrank', p, 'bili_user');
|
|
130
|
+
const list = (d.result ?? []).map(norm.searchUser);
|
|
131
|
+
return paged(list, p + 1, p < Number(d.numPages ?? 0));
|
|
132
|
+
}
|
|
133
|
+
const USER_ORDER = { latest: 'pubdate', views: 'click', collects: 'stow' };
|
|
134
|
+
export async function userItems(ctx) {
|
|
135
|
+
const b = await bili(ctx);
|
|
136
|
+
const mid = await resolveUser(b, ctx.args.user);
|
|
137
|
+
const p = page(ctx);
|
|
138
|
+
const order = USER_ORDER[ctx.options.sort ?? 'latest'] ?? 'pubdate';
|
|
139
|
+
const d = await api.userVideos(b, mid, p, 42, order, ctx.options.keyword ?? '');
|
|
140
|
+
const list = (d.list?.vlist ?? []).map(norm.spaceVideo);
|
|
141
|
+
const total = Number(d.page?.count ?? 0);
|
|
142
|
+
return paged(list, p + 1, list.length > 0 && p * Number(d.page?.ps ?? 42) < total);
|
|
143
|
+
}
|
|
144
|
+
// ================================================================ item
|
|
145
|
+
export async function itemGet(ctx) {
|
|
146
|
+
const b = await bili(ctx);
|
|
147
|
+
const { bvid } = await resolveItem(b, ctx.args.item);
|
|
148
|
+
return norm.video(await api.videoInfo(b, bvid));
|
|
149
|
+
}
|
|
150
|
+
/** --sort → 上游 search_type 的 order。视频:totalrank / click / pubdate / stow;专栏没有「收藏多」(它的取值是 totalrank / pubdate / click / attention / scores)。 */
|
|
151
|
+
const SEARCH_ORDER = {
|
|
152
|
+
video: { general: 'totalrank', views: 'click', latest: 'pubdate', collects: 'stow' },
|
|
153
|
+
article: { general: 'totalrank', views: 'click', latest: 'pubdate' },
|
|
154
|
+
};
|
|
155
|
+
/** --type → 上游 search_type:video 视频、article 专栏。 */
|
|
156
|
+
export async function itemSearch(ctx) {
|
|
157
|
+
const type = ctx.options.type === 'article' ? 'article' : 'video';
|
|
158
|
+
const article = type === 'article';
|
|
159
|
+
const sort = ctx.options.sort ?? 'general';
|
|
160
|
+
const order = SEARCH_ORDER[type][sort];
|
|
161
|
+
if (!order)
|
|
162
|
+
throw new SpecificAIError('UNSUPPORTED', `bilibili 的专栏搜索不支持 --sort ${sort}`, { hint: '专栏搜索的 --sort 可以是 general、views、latest' });
|
|
163
|
+
const b = await bili(ctx);
|
|
164
|
+
const p = page(ctx);
|
|
165
|
+
const d = await api.searchType(b, ctx.args.keyword, order, p, type);
|
|
166
|
+
const result = d.result ?? [];
|
|
167
|
+
// 视频搜索会混进课程、广告卡片,只留真正的稿件(上游 search_by_num)
|
|
168
|
+
const list = article ? result.filter((v) => v.id).map(norm.searchArticle) : result.filter((v) => v.type === 'video' && v.bvid).map(norm.searchVideo);
|
|
169
|
+
return paged(list, p + 1, p < Number(d.numPages ?? 0));
|
|
170
|
+
}
|
|
171
|
+
/** 相关推荐:稿件完整信息里的 Related(上游 get_video_detail)。 */
|
|
172
|
+
export async function itemRelated(ctx) {
|
|
173
|
+
const b = await bili(ctx);
|
|
174
|
+
const { bvid } = await resolveItem(b, ctx.args.item);
|
|
175
|
+
const d = await api.videoDetail(b, bvid);
|
|
176
|
+
return paged((d?.Related ?? []).filter((v) => v.bvid).map(norm.video), null, false);
|
|
177
|
+
}
|
|
178
|
+
export async function itemList(ctx) {
|
|
179
|
+
const b = await bili(ctx);
|
|
180
|
+
const p = page(ctx);
|
|
181
|
+
const d = await api.myArchives(b, p);
|
|
182
|
+
const list = (d.arc_audits ?? []).map(norm.archive);
|
|
183
|
+
return paged(list, p + 1, p * Number(d.page?.ps ?? 20) < Number(d.page?.count ?? 0));
|
|
184
|
+
}
|
|
185
|
+
/** 播放地址:DASH 取最高画质的视频轨和音频轨;老稿件是 durl 整段。 */
|
|
186
|
+
async function playMedia(b, bvid, cid) {
|
|
187
|
+
const d = await api.playUrl(b, bvid, cid);
|
|
188
|
+
if (d.dash) {
|
|
189
|
+
const duration = n.seconds(d.dash.duration);
|
|
190
|
+
const video = [...(d.dash.video ?? [])].sort((a, c) => c.id - a.id || c.bandwidth - a.bandwidth)[0];
|
|
191
|
+
const audio = [...(d.dash.audio ?? []), ...(d.dash.flac?.audio ? [d.dash.flac.audio] : [])].sort((a, c) => c.bandwidth - a.bandwidth)[0];
|
|
192
|
+
const out = [];
|
|
193
|
+
if (video)
|
|
194
|
+
out.push(n.media({ id: String(video.id), type: 'video', url: video.baseUrl ?? video.base_url, width: video.width, height: video.height, duration }, video));
|
|
195
|
+
if (audio)
|
|
196
|
+
out.push(n.media({ id: String(audio.id), type: 'audio', url: audio.baseUrl ?? audio.base_url, duration }, audio));
|
|
197
|
+
return out;
|
|
198
|
+
}
|
|
199
|
+
return (d.durl ?? []).map((x) => n.media({ id: String(x.order), type: 'video', url: x.url, duration: n.seconds(x.length / 1000) }, x));
|
|
200
|
+
}
|
|
201
|
+
export async function itemMedia(ctx) {
|
|
202
|
+
const b = await bili(ctx);
|
|
203
|
+
const { bvid } = await resolveItem(b, ctx.args.item);
|
|
204
|
+
const info = await api.videoInfo(b, bvid);
|
|
205
|
+
return playMedia(b, bvid, info.cid);
|
|
206
|
+
}
|
|
207
|
+
export async function itemDownload(ctx) {
|
|
208
|
+
const b = await bili(ctx);
|
|
209
|
+
const { bvid } = await resolveItem(b, ctx.args.item);
|
|
210
|
+
const info = await api.videoInfo(b, bvid);
|
|
211
|
+
const media = await playMedia(b, bvid, info.cid);
|
|
212
|
+
return downloadMedia(ctx, b.http, bvid, media, {
|
|
213
|
+
headers: [
|
|
214
|
+
['user-agent', PROFILE.ua],
|
|
215
|
+
['referer', 'https://www.bilibili.com/'],
|
|
216
|
+
],
|
|
217
|
+
ext: (m) => (m.type === 'audio' ? 'm4a' : 'mp4'),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async function likeItem(ctx, on) {
|
|
221
|
+
const b = await bili(ctx);
|
|
222
|
+
b.requireLogin();
|
|
223
|
+
const { bvid } = await resolveItem(b, ctx.args.item);
|
|
224
|
+
await api.like(b, bvid, on);
|
|
225
|
+
return { id: bvid };
|
|
226
|
+
}
|
|
227
|
+
export const itemLike = (ctx) => likeItem(ctx, true);
|
|
228
|
+
export const itemUnlike = (ctx) => likeItem(ctx, false);
|
|
229
|
+
/** --folder 指定收藏夹(上游 favour 的 add_media_ids);不给时收进第一个收藏夹。 */
|
|
230
|
+
export async function itemCollect(ctx) {
|
|
231
|
+
const b = await bili(ctx);
|
|
232
|
+
b.requireLogin();
|
|
233
|
+
const { bvid, aid } = await resolveItem(b, ctx.args.item);
|
|
234
|
+
await api.favour(b, aid, ctx.options.folder ?? '');
|
|
235
|
+
return { id: bvid };
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* --folder 指定从哪些收藏夹移出(上游 favour 的 del_media_ids)。不给时从所有收着它的收藏夹移出:
|
|
239
|
+
* 上游没有查「哪些收藏夹收着它」的方法,用收藏夹列表多带 rid 时返回的 fav_state 判断(非上游,见 api.favFoldersOf);
|
|
240
|
+
* 服务端没返回 fav_state 时退回到从全部收藏夹移出。一个收藏夹都没收着它时不发请求,照样返回成功。
|
|
241
|
+
*/
|
|
242
|
+
export async function itemUncollect(ctx) {
|
|
243
|
+
const b = await bili(ctx);
|
|
244
|
+
b.requireLogin();
|
|
245
|
+
const { bvid, aid } = await resolveItem(b, ctx.args.item);
|
|
246
|
+
let del = ctx.options.folder;
|
|
247
|
+
if (!del) {
|
|
248
|
+
const folders = (await api.favFoldersOf(b, aid))?.list ?? [];
|
|
249
|
+
const known = folders.some((f) => f.fav_state != null);
|
|
250
|
+
if (!known)
|
|
251
|
+
ctx.log.debug('收藏夹列表没有返回 fav_state,从全部收藏夹移出');
|
|
252
|
+
del = folders
|
|
253
|
+
.filter((f) => !known || f.fav_state === 1)
|
|
254
|
+
.map((f) => f.id)
|
|
255
|
+
.join(',');
|
|
256
|
+
if (!del)
|
|
257
|
+
ctx.log.info(`${bvid} 不在任何收藏夹里`);
|
|
258
|
+
}
|
|
259
|
+
if (del)
|
|
260
|
+
await api.favour(b, aid, '', del);
|
|
261
|
+
return { id: bvid };
|
|
262
|
+
}
|
|
263
|
+
export async function itemPublish(ctx) {
|
|
264
|
+
const b = await bili(ctx);
|
|
265
|
+
b.requireLogin();
|
|
266
|
+
const o = ctx.options;
|
|
267
|
+
if (!o.video)
|
|
268
|
+
throw new SpecificAIError('USAGE', 'B 站投稿需要 --video', { hint: 'specific-ai bilibili item publish --video <文件> --title <标题> --tag <标签> --category <分区 id>' });
|
|
269
|
+
if (!o.title)
|
|
270
|
+
throw new SpecificAIError('USAGE', 'B 站投稿需要 --title');
|
|
271
|
+
if (!o.tag?.length)
|
|
272
|
+
throw new SpecificAIError('USAGE', 'B 站投稿至少需要一个 --tag');
|
|
273
|
+
if (!o.category)
|
|
274
|
+
throw new SpecificAIError('USAGE', 'B 站投稿需要 --category(分区 id)', { hint: 'specific-ai bilibili item categories' });
|
|
275
|
+
const video = await uploadVideo(b, await readMedia(b.http, o.video));
|
|
276
|
+
const cover = o.cover ? await uploadCover(b, o.cover) : '';
|
|
277
|
+
const d = await api.submitArchive(b, {
|
|
278
|
+
videos: [video],
|
|
279
|
+
title: o.title,
|
|
280
|
+
tid: Number(o.category),
|
|
281
|
+
tag: o.tag.join(','),
|
|
282
|
+
cover,
|
|
283
|
+
desc: o.text ?? '',
|
|
284
|
+
copyright: o.source ? 2 : 1,
|
|
285
|
+
source: o.source ?? '',
|
|
286
|
+
private: o.visibility === 'private',
|
|
287
|
+
dynamic: o.dynamic ?? '',
|
|
288
|
+
noReprint: o.allowReprint ? 0 : 1,
|
|
289
|
+
});
|
|
290
|
+
return n.item({ id: d.bvid, kind: 'video', url: norm.videoUrl(d.bvid), title: o.title, text: o.text ?? null, cover: cover || null, status: 'reviewing' }, d);
|
|
291
|
+
}
|
|
292
|
+
/** 上传投稿封面,返回图片地址(上游 post_video 里的 upload_cover)。data URL 的类型取扩展名,缺省 jpeg。 */
|
|
293
|
+
export async function uploadCover(b, input) {
|
|
294
|
+
const img = await readMedia(b.http, input);
|
|
295
|
+
const suffix = extname(img.filename).slice(1).toLowerCase() || 'jpeg';
|
|
296
|
+
return String((await api.uploadCover(b, img.data, suffix))?.url ?? '');
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* 撤稿。平台要求先过极验点选,网页端真人也要过;不带验证结果固定返回 340022(上游 delete_archive)。
|
|
300
|
+
* specific-ai 还不能自动过这一步,报 RISK_CONTROL 时在 hint 里说明。
|
|
301
|
+
*/
|
|
302
|
+
export async function itemDelete(ctx) {
|
|
303
|
+
const b = await bili(ctx);
|
|
304
|
+
b.requireLogin();
|
|
305
|
+
const { bvid, aid } = await resolveItem(b, ctx.args.item);
|
|
306
|
+
try {
|
|
307
|
+
await api.deleteArchive(b, aid);
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
if (err instanceof SpecificAIError && err.code === 'RISK_CONTROL' && err.detail?.kind === 'captcha') {
|
|
311
|
+
throw new SpecificAIError('RISK_CONTROL', `撤稿需要人机验证:${err.message}`, {
|
|
312
|
+
hint: 'B 站撤稿要先过极验点选(网页端同样如此),specific-ai 还不能自动通过。请到创作中心手动删除:https://member.bilibili.com/platform/upload-manager/article',
|
|
313
|
+
detail: err.detail,
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
throw err;
|
|
317
|
+
}
|
|
318
|
+
return { id: bvid };
|
|
319
|
+
}
|
|
320
|
+
export async function itemCategories(ctx) {
|
|
321
|
+
const b = await bili(ctx);
|
|
322
|
+
const d = await api.archivePre(b);
|
|
323
|
+
return (d.typelist ?? []).flatMap((p) => [
|
|
324
|
+
n.category({ id: n.id(p.id), name: p.name }, p),
|
|
325
|
+
...(p.children ?? []).map((c) => n.category({ id: n.id(c.id), name: c.name, parent_id: n.id(p.id) }, c)),
|
|
326
|
+
]);
|
|
327
|
+
}
|
|
328
|
+
// ---------------------------------------------------------------- 扩展:投币、三连、字幕
|
|
329
|
+
export async function itemCoin(ctx) {
|
|
330
|
+
const b = await bili(ctx);
|
|
331
|
+
b.requireLogin();
|
|
332
|
+
const { bvid } = await resolveItem(b, ctx.args.item);
|
|
333
|
+
await api.addCoin(b, bvid, Number(ctx.options.count ?? 1), Boolean(ctx.options.like));
|
|
334
|
+
return { id: bvid };
|
|
335
|
+
}
|
|
336
|
+
export async function itemTriple(ctx) {
|
|
337
|
+
const b = await bili(ctx);
|
|
338
|
+
b.requireLogin();
|
|
339
|
+
const { bvid } = await resolveItem(b, ctx.args.item);
|
|
340
|
+
await api.triple(b, bvid);
|
|
341
|
+
return { id: bvid };
|
|
342
|
+
}
|
|
343
|
+
export async function itemSubtitles(ctx) {
|
|
344
|
+
const b = await bili(ctx);
|
|
345
|
+
const { bvid, aid } = await resolveItem(b, ctx.args.item);
|
|
346
|
+
const info = await api.videoInfo(b, bvid);
|
|
347
|
+
const player = await api.playerInfo(b, aid, info.cid);
|
|
348
|
+
const out = [];
|
|
349
|
+
for (const s of player.subtitle?.subtitles ?? []) {
|
|
350
|
+
const url = n.url(s.subtitle_url);
|
|
351
|
+
let lines = [];
|
|
352
|
+
if (url) {
|
|
353
|
+
const res = await b.http.request({ url, cookies: false, headers: [['user-agent', PROFILE.ua], ['referer', 'https://www.bilibili.com/']] });
|
|
354
|
+
const body = await parseJson(res);
|
|
355
|
+
lines = (body?.body ?? []).map((l) => ({ from: Number(l.from), to: Number(l.to), text: String(l.content ?? '') }));
|
|
356
|
+
}
|
|
357
|
+
out.push({ lang: n.str(s.lan), name: n.str(s.lan_doc), url, lines });
|
|
358
|
+
}
|
|
359
|
+
return out;
|
|
360
|
+
}
|
|
361
|
+
// ================================================================ comment
|
|
362
|
+
/** 评论排序 → 上游 get_replies 的 mode:3 热门、2 时间。 */
|
|
363
|
+
const REPLY_MODE = { popular: 3, latest: 2 };
|
|
364
|
+
/** 动态评论区的业务错误带上 DYNAMIC_REPLY_HINT:图文动态不在 type 17 下,常见的是 12002 或者取到空列表。 */
|
|
365
|
+
async function onReplies(t, run) {
|
|
366
|
+
try {
|
|
367
|
+
return await run();
|
|
368
|
+
}
|
|
369
|
+
catch (err) {
|
|
370
|
+
if (t.type === 17 && err instanceof SpecificAIError && err.code === 'UPSTREAM') {
|
|
371
|
+
throw new SpecificAIError('UPSTREAM', err.message, { hint: err.hint ?? DYNAMIC_REPLY_HINT, detail: err.detail });
|
|
372
|
+
}
|
|
373
|
+
throw err;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/** 评论区按参数形态识别:稿件 1、专栏 12、动态 17(见 resolveReplyTarget)。 */
|
|
377
|
+
export async function commentList(ctx) {
|
|
378
|
+
const b = await bili(ctx);
|
|
379
|
+
const t = await resolveReplyTarget(b, ctx.args.item);
|
|
380
|
+
// 游标是 `<上一页的 cursor.pagination_reply.next_offset>~<页序号>`,首页为空(见 api.replies)。
|
|
381
|
+
// 热门评论的 next_offset 常常连着几页不变(进度记在服务端的 session 里,同一个 offset 再请求就是下一页),
|
|
382
|
+
// 带上页序号,免得 core 把它当成游标没变而停止翻页
|
|
383
|
+
const [offset = '', seq = '0'] = (ctx.cursor ?? '').split('~');
|
|
384
|
+
const first = offset === '';
|
|
385
|
+
const d = await onReplies(t, () => api.replies(b, t.oid, t.type, offset, REPLY_MODE[ctx.options.sort ?? 'popular'] ?? 3));
|
|
386
|
+
const list = [...(first ? (d.top_replies ?? []) : []), ...(d.replies ?? [])].map((r) => norm.reply(r, t.itemId));
|
|
387
|
+
if (t.type === 17 && first && !list.length)
|
|
388
|
+
ctx.log.info(`动态 ${t.oid} 的评论区是空的。${DYNAMIC_REPLY_HINT}`);
|
|
389
|
+
const next = d.cursor?.pagination_reply?.next_offset || undefined;
|
|
390
|
+
return paged(list, next && `${next}~${Number(seq) + 1}`, !d.cursor?.is_end && list.length > 0 && next != null);
|
|
391
|
+
}
|
|
392
|
+
/** 回复楼中楼:--root 给根评论、--reply-to 给被回复的那条;只给 --reply-to 时它就是根评论(上游 add_reply 的 root / parent)。 */
|
|
393
|
+
export async function commentAdd(ctx) {
|
|
394
|
+
const b = await bili(ctx);
|
|
395
|
+
b.requireLogin();
|
|
396
|
+
const t = await resolveReplyTarget(b, ctx.args.item);
|
|
397
|
+
const to = ctx.options.replyTo;
|
|
398
|
+
const root = ctx.options.root ?? to ?? '';
|
|
399
|
+
const d = await onReplies(t, () => api.addReply(b, t.oid, ctx.args.text, t.type, root, to ?? ''));
|
|
400
|
+
return d.reply ? norm.reply(d.reply, t.itemId) : n.comment({ id: n.id(d.rpid_str ?? d.rpid), item_id: t.itemId, text: ctx.args.text, parent_id: to ?? null }, d);
|
|
401
|
+
}
|
|
402
|
+
export async function commentDelete(ctx) {
|
|
403
|
+
const b = await bili(ctx);
|
|
404
|
+
b.requireLogin();
|
|
405
|
+
const t = await resolveReplyTarget(b, ctx.args.item);
|
|
406
|
+
await onReplies(t, () => api.deleteReply(b, t.oid, ctx.args.comment, t.type));
|
|
407
|
+
return { id: ctx.args.comment };
|
|
408
|
+
}
|
|
409
|
+
// ================================================================ 弹幕(扩展)
|
|
410
|
+
/** 解 DmSegMobileReply:elems=1,每条 id=1 progress=2 ctime=8 content=7 idStr=12。 */
|
|
411
|
+
export function decodeDanmaku(buf, bvid) {
|
|
412
|
+
return pb.messages(pb.decode(buf), 1).map((e) => ({
|
|
413
|
+
id: pb.str(e, 12) ?? pb.int64(e, 1) ?? '',
|
|
414
|
+
item_id: bvid,
|
|
415
|
+
offset: (pb.int(e, 2) ?? 0) / 1000,
|
|
416
|
+
text: pb.str(e, 7) ?? '',
|
|
417
|
+
created_at: n.time(pb.int(e, 8)),
|
|
418
|
+
}));
|
|
419
|
+
}
|
|
420
|
+
export async function danmakuList(ctx) {
|
|
421
|
+
const b = await bili(ctx);
|
|
422
|
+
const { bvid, aid } = await resolveItem(b, ctx.args.item);
|
|
423
|
+
const info = await api.videoInfo(b, bvid);
|
|
424
|
+
const segments = Math.max(1, Math.ceil(Number(info.pages?.[0]?.duration ?? info.duration ?? 360) / 360));
|
|
425
|
+
const out = [];
|
|
426
|
+
for (let i = 1; i <= segments; i++)
|
|
427
|
+
out.push(...decodeDanmaku(await api.danmakuSeg(b, aid, info.cid, i), bvid));
|
|
428
|
+
return out.sort((a, c) => a.offset - c.offset);
|
|
429
|
+
}
|
|
430
|
+
/** 弹幕位置 → 上游的 mode:1 滚动、5 顶部、4 底部。 */
|
|
431
|
+
const DANMAKU_MODE = { scroll: 1, top: 5, bottom: 4 };
|
|
432
|
+
/** --color(#RRGGBB 或十进制)、--font-size、--position → 上游 send_danmaku 的 color / fontsize / mode。 */
|
|
433
|
+
export function danmakuStyle(o) {
|
|
434
|
+
const style = {};
|
|
435
|
+
const color = o.color;
|
|
436
|
+
if (color)
|
|
437
|
+
style.color = color.startsWith('#') ? parseInt(color.slice(1), 16) : Number(color);
|
|
438
|
+
if (o.fontSize != null)
|
|
439
|
+
style.fontsize = Number(o.fontSize);
|
|
440
|
+
if (o.position)
|
|
441
|
+
style.mode = DANMAKU_MODE[o.position];
|
|
442
|
+
return style;
|
|
443
|
+
}
|
|
444
|
+
export async function danmakuSend(ctx) {
|
|
445
|
+
const b = await bili(ctx);
|
|
446
|
+
b.requireLogin();
|
|
447
|
+
const { bvid, aid } = await resolveItem(b, ctx.args.item);
|
|
448
|
+
const info = await api.videoInfo(b, bvid);
|
|
449
|
+
const d = await api.sendVideoDanmaku(b, aid, info.cid, ctx.args.text, Math.round(Number(ctx.options.offset ?? 0) * 1000), danmakuStyle(ctx.options));
|
|
450
|
+
return { id: n.id(d?.dmid_str ?? d?.dmid) };
|
|
451
|
+
}
|
|
452
|
+
// ================================================================ feed
|
|
453
|
+
/**
|
|
454
|
+
* 推荐流的翻页游标:`<页号>:<上一批的 last_showlist>`。
|
|
455
|
+
* last_showlist 是上一批稿件的 `av_<aid>`(已关注的 UP 主为 `av_n_<aid>`),逗号分隔,服务端据此去重。
|
|
456
|
+
*/
|
|
457
|
+
export function parseFeedCursor(cursor) {
|
|
458
|
+
const m = /^(\d+)(?::(.*))?$/s.exec(cursor ?? '');
|
|
459
|
+
return { page: Number(m?.[1] ?? 1) || 1, showlist: m?.[2] ?? '' };
|
|
460
|
+
}
|
|
461
|
+
export function showlist(items) {
|
|
462
|
+
return items
|
|
463
|
+
.filter((v) => v.goto === 'av' && v.id)
|
|
464
|
+
.map((v) => (v.is_followed ? `av_n_${v.id}` : `av_${v.id}`))
|
|
465
|
+
.join(',');
|
|
466
|
+
}
|
|
467
|
+
export async function feedList(ctx) {
|
|
468
|
+
const kind = ctx.options.kind ?? 'recommend';
|
|
469
|
+
if (kind === 'following')
|
|
470
|
+
throw new SpecificAIError('NOT_IMPLEMENTED', 'bilibili 的 feed list --kind following 尚未实现', { detail: { upstream: 'none' } });
|
|
471
|
+
const b = await bili(ctx);
|
|
472
|
+
if (kind === 'hot') {
|
|
473
|
+
const p = page(ctx);
|
|
474
|
+
const d = await api.popular(b, p);
|
|
475
|
+
return paged((d.list ?? []).map(norm.video), p + 1, !d.no_more);
|
|
476
|
+
}
|
|
477
|
+
const c = parseFeedCursor(ctx.cursor);
|
|
478
|
+
const d = await api.rcmdFeed(b, c.page, 12, c.showlist);
|
|
479
|
+
const items = d.item ?? [];
|
|
480
|
+
const list = items.filter((v) => v.goto === 'av' && v.bvid).map(norm.feedVideo);
|
|
481
|
+
// 推荐流没有尽头;这一批一条都没有时停下,免得 --all 一直空翻
|
|
482
|
+
return paged(list, `${c.page + 1}:${showlist(items)}`, items.length > 0);
|
|
483
|
+
}
|
|
484
|
+
// ================================================================ folder
|
|
485
|
+
export async function folderList(ctx) {
|
|
486
|
+
const b = await bili(ctx);
|
|
487
|
+
const mid = await resolveUser(b, ctx.args.user ?? 'me');
|
|
488
|
+
const d = await api.favFolders(b, mid);
|
|
489
|
+
const list = (d?.list ?? []).map((f) => n.folder({ id: n.id(f.id), name: String(f.title ?? ''), count: n.count(f.media_count), url: `https://space.bilibili.com/${mid}/favlist?fid=${f.id}` }, f));
|
|
490
|
+
return paged(list, null, false);
|
|
491
|
+
}
|
|
492
|
+
/** 收藏夹内容(非上游,见 api.favResources)。只留稿件,失效的和别的类型(音频、合集)跳过。 */
|
|
493
|
+
export async function folderItems(ctx) {
|
|
494
|
+
const b = await bili(ctx);
|
|
495
|
+
const folder = await resolveFolder(b, ctx.args.folder);
|
|
496
|
+
const p = page(ctx);
|
|
497
|
+
const d = await api.favResources(b, folder, p);
|
|
498
|
+
return paged((d?.medias ?? []).filter((m) => m.bvid).map(norm.favVideo), p + 1, Boolean(d?.has_more));
|
|
499
|
+
}
|
|
500
|
+
// ================================================================ media
|
|
501
|
+
export async function mediaUpload(ctx) {
|
|
502
|
+
const b = await bili(ctx);
|
|
503
|
+
b.requireLogin();
|
|
504
|
+
const file = await readMedia(b.http, ctx.args.file);
|
|
505
|
+
if (file.contentType.startsWith('video/')) {
|
|
506
|
+
// 投稿用的是 filename;upos 的对象 key 不是可访问的地址,url 留空(core 的 Media.url 不可为 null,同 x)
|
|
507
|
+
const v = await uploadVideo(b, file);
|
|
508
|
+
return n.media({ id: v.filename, type: 'video', url: null }, v);
|
|
509
|
+
}
|
|
510
|
+
const d = await api.uploadDynamicImage(b, file.data, file.filename, file.contentType);
|
|
511
|
+
return n.media({ type: 'image', url: d.image_url, width: n.count(d.image_width), height: n.count(d.image_height) }, d);
|
|
512
|
+
}
|
|
513
|
+
// ================================================================ 动态、专栏(扩展)
|
|
514
|
+
async function uploadPics(b, inputs) {
|
|
515
|
+
const pics = [];
|
|
516
|
+
for (const input of inputs) {
|
|
517
|
+
const file = await readMedia(b.http, input);
|
|
518
|
+
const d = await api.uploadDynamicImage(b, file.data, file.filename, file.contentType);
|
|
519
|
+
pics.push({ img_src: d.image_url, img_width: d.image_width, img_height: d.image_height, img_size: d.img_size ?? 0 });
|
|
520
|
+
}
|
|
521
|
+
return pics;
|
|
522
|
+
}
|
|
523
|
+
export async function dynamicPublish(ctx) {
|
|
524
|
+
const b = await bili(ctx);
|
|
525
|
+
b.requireLogin();
|
|
526
|
+
const pics = await uploadPics(b, ctx.options.image ?? []);
|
|
527
|
+
const d = await api.createDynamic(b, String(ctx.options.text), pics);
|
|
528
|
+
const id = n.id(d.dyn_id_str ?? d.dyn_id);
|
|
529
|
+
return { id, url: `https://t.bilibili.com/${id}` };
|
|
530
|
+
}
|
|
531
|
+
export async function dynamicDelete(ctx) {
|
|
532
|
+
const b = await bili(ctx);
|
|
533
|
+
b.requireLogin();
|
|
534
|
+
const id = await resolveDynamic(b, ctx.args.id);
|
|
535
|
+
await api.removeDynamic(b, id);
|
|
536
|
+
return { id };
|
|
537
|
+
}
|
|
538
|
+
/** 先存草稿再提交(上游 save_article_draft + submit_article);--draft 时只存草稿。上游没有专栏封面,banner_url 固定为空。 */
|
|
539
|
+
export async function articlePublish(ctx) {
|
|
540
|
+
const b = await bili(ctx);
|
|
541
|
+
b.requireLogin();
|
|
542
|
+
const o = ctx.options;
|
|
543
|
+
const article = {
|
|
544
|
+
title: o.title,
|
|
545
|
+
content: o.text,
|
|
546
|
+
category: Number(o.category ?? 0),
|
|
547
|
+
tags: (o.tag ?? []).join(','),
|
|
548
|
+
summary: o.summary ?? '',
|
|
549
|
+
};
|
|
550
|
+
const draft = await api.saveArticleDraft(b, article);
|
|
551
|
+
const aid = n.id(draft.aid);
|
|
552
|
+
if (o.draft)
|
|
553
|
+
return { id: aid, url: null };
|
|
554
|
+
try {
|
|
555
|
+
await api.submitArticle(b, aid, article);
|
|
556
|
+
}
|
|
557
|
+
catch (err) {
|
|
558
|
+
if (err instanceof SpecificAIError) {
|
|
559
|
+
throw new SpecificAIError(err.code, `专栏提交失败,草稿 ${aid} 已保存:${err.message}`, {
|
|
560
|
+
hint: err.hint ?? `查看草稿:specific-ai bilibili draft get ${aid};删除:specific-ai bilibili draft delete ${aid}`,
|
|
561
|
+
detail: err.detail,
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
throw err;
|
|
565
|
+
}
|
|
566
|
+
return { id: aid, url: norm.articleUrl(aid) };
|
|
567
|
+
}
|
|
568
|
+
export async function draftGet(ctx) {
|
|
569
|
+
const b = await bili(ctx);
|
|
570
|
+
b.requireLogin();
|
|
571
|
+
const id = ctx.args.id;
|
|
572
|
+
return norm.articleDraft((await api.articleDraft(b, id)) ?? {}, id, b.mid);
|
|
573
|
+
}
|
|
574
|
+
export async function draftDelete(ctx) {
|
|
575
|
+
const b = await bili(ctx);
|
|
576
|
+
b.requireLogin();
|
|
577
|
+
await api.deleteArticleDraft(b, ctx.args.id);
|
|
578
|
+
return { id: ctx.args.id };
|
|
579
|
+
}
|
|
580
|
+
async function room(b, input) {
|
|
581
|
+
const init = await api.roomInit(b, await resolveRoom(b, input));
|
|
582
|
+
return { roomId: String(init.room_id), uid: String(init.uid), status: init.live_status };
|
|
583
|
+
}
|
|
584
|
+
export async function liveGet(ctx) {
|
|
585
|
+
const b = await bili(ctx);
|
|
586
|
+
const r = await room(b, ctx.args.room);
|
|
587
|
+
const d = await api.roomInfo(b, r.roomId);
|
|
588
|
+
const info = d.room_info ?? {};
|
|
589
|
+
const host = d.anchor_info?.base_info ?? {};
|
|
590
|
+
return n.live({
|
|
591
|
+
id: r.roomId,
|
|
592
|
+
url: norm.liveUrl(r.roomId),
|
|
593
|
+
title: n.str(info.title),
|
|
594
|
+
status: (info.live_status ?? r.status) === 1 ? 'live' : 'offline',
|
|
595
|
+
host: norm.ref(r.uid, host.uname),
|
|
596
|
+
cover: n.url(info.cover),
|
|
597
|
+
stats: { viewers: n.count(info.online) },
|
|
598
|
+
}, d);
|
|
599
|
+
}
|
|
600
|
+
export async function liveSearch(ctx) {
|
|
601
|
+
const b = await bili(ctx);
|
|
602
|
+
const p = page(ctx);
|
|
603
|
+
const d = await api.searchType(b, ctx.args.keyword, 'totalrank', p, 'live_room');
|
|
604
|
+
const list = (Array.isArray(d.result) ? d.result : (d.result?.live_room ?? [])).map(norm.searchLive);
|
|
605
|
+
return paged(list, p + 1, p < Number(d.numPages ?? 0));
|
|
606
|
+
}
|
|
607
|
+
export async function liveCategories(ctx) {
|
|
608
|
+
const b = await bili(ctx);
|
|
609
|
+
const d = await api.areaList(b);
|
|
610
|
+
return (d.data ?? []).flatMap((p) => [
|
|
611
|
+
n.category({ id: n.id(p.id), name: p.name }, p),
|
|
612
|
+
...(p.list ?? []).map((c) => n.category({ id: n.id(c.id), name: c.name, parent_id: n.id(c.parent_id ?? p.id) }, c)),
|
|
613
|
+
]);
|
|
614
|
+
}
|
|
615
|
+
export async function liveHistory(ctx) {
|
|
616
|
+
const b = await bili(ctx);
|
|
617
|
+
const r = await room(b, ctx.args.room);
|
|
618
|
+
const d = await api.danmakuHistory(b, r.roomId);
|
|
619
|
+
return [...(d.admin ?? []), ...(d.room ?? [])].map(norm.historyEvent);
|
|
620
|
+
}
|
|
621
|
+
/** 直播间的礼物面板(上游 get_gift_list 要分区,先取 get_room_info)。 */
|
|
622
|
+
async function roomGifts(b, r) {
|
|
623
|
+
const info = (await api.roomInfo(b, r.roomId)).room_info ?? {};
|
|
624
|
+
return api.giftList(b, r.roomId, info.parent_area_id ?? 0, info.area_id ?? 0, r.uid);
|
|
625
|
+
}
|
|
626
|
+
export async function liveGifts(ctx) {
|
|
627
|
+
const b = await bili(ctx);
|
|
628
|
+
const r = await room(b, ctx.args.room);
|
|
629
|
+
const d = await roomGifts(b, r);
|
|
630
|
+
const inRoom = new Set((d.gift_data?.room_gift_list?.gold_list ?? []).map((g) => String(g.gift_id)));
|
|
631
|
+
const all = d.gift_config?.base_config?.list ?? [];
|
|
632
|
+
return all
|
|
633
|
+
.filter((g) => !inRoom.size || inRoom.has(String(g.id)))
|
|
634
|
+
.map((g) => n.gift({
|
|
635
|
+
id: n.id(g.id),
|
|
636
|
+
name: String(g.name ?? ''),
|
|
637
|
+
price: g.coin_type === 'gold' ? n.price(Number(g.price) / 1000) : { amount: Number(g.price ?? 0), currency: 'BILI_SILVER' },
|
|
638
|
+
}, g));
|
|
639
|
+
}
|
|
640
|
+
export async function liveMedia(ctx) {
|
|
641
|
+
const b = await bili(ctx);
|
|
642
|
+
const r = await room(b, ctx.args.room);
|
|
643
|
+
const d = await api.roomPlayInfo(b, r.roomId);
|
|
644
|
+
const out = [];
|
|
645
|
+
for (const s of d.playurl_info?.playurl?.stream ?? []) {
|
|
646
|
+
for (const f of s.format ?? []) {
|
|
647
|
+
for (const c of f.codec ?? []) {
|
|
648
|
+
for (const u of c.url_info ?? [])
|
|
649
|
+
out.push(n.media({ id: `${s.protocol_name}-${f.format_name}-${c.codec_name}-${c.current_qn}`, type: 'video', url: `${u.host}${c.base_url}${u.extra}` }, c));
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return out;
|
|
654
|
+
}
|
|
655
|
+
export async function liveSend(ctx) {
|
|
656
|
+
const b = await bili(ctx);
|
|
657
|
+
b.requireLogin();
|
|
658
|
+
const r = await room(b, ctx.args.room);
|
|
659
|
+
const giftId = ctx.options.gift;
|
|
660
|
+
if (!giftId) {
|
|
661
|
+
const style = danmakuStyle(ctx.options);
|
|
662
|
+
const replyUser = ctx.options.replyUser;
|
|
663
|
+
if (replyUser) {
|
|
664
|
+
style.replyMid = await resolveUser(b, replyUser);
|
|
665
|
+
style.replyUname = await userName(b, style.replyMid);
|
|
666
|
+
}
|
|
667
|
+
await api.sendLiveDanmaku(b, r.roomId, ctx.args.text, style);
|
|
668
|
+
return { id: r.roomId };
|
|
669
|
+
}
|
|
670
|
+
const count = Number(ctx.options.count ?? 1);
|
|
671
|
+
const bag = ((await api.bagList(b, r.roomId))?.list ?? []).find((g) => String(g.gift_id) === giftId && g.gift_num >= count);
|
|
672
|
+
if (bag) {
|
|
673
|
+
await api.sendGift(b, r.roomId, r.uid, giftId, count, bag.bag_id, 'silver', 0);
|
|
674
|
+
return { id: r.roomId };
|
|
675
|
+
}
|
|
676
|
+
const list = (await roomGifts(b, r)).gift_config?.base_config?.list ?? [];
|
|
677
|
+
const gift = list.find((g) => String(g.id) === giftId);
|
|
678
|
+
if (!gift)
|
|
679
|
+
throw new SpecificAIError('USAGE', `礼物 ${giftId} 不在这个直播间的礼物列表里`, { hint: `specific-ai bilibili live gifts ${ctx.args.room}` });
|
|
680
|
+
await api.sendGift(b, r.roomId, r.uid, giftId, count, 0, gift.coin_type, gift.price);
|
|
681
|
+
return { id: r.roomId };
|
|
682
|
+
}
|
|
683
|
+
/** 直播弹幕回复对象的昵称(上游 send_danmaku 的 reply_uname):取不到时留空,只带 reply_mid。 */
|
|
684
|
+
async function userName(b, mid) {
|
|
685
|
+
try {
|
|
686
|
+
return String((await api.userInfo(b, mid))?.name ?? '');
|
|
687
|
+
}
|
|
688
|
+
catch (err) {
|
|
689
|
+
b.ctx.log.debug(`取用户 ${mid} 的昵称失败:${err.message}`);
|
|
690
|
+
return '';
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
export async function liveStart(ctx) {
|
|
694
|
+
const b = await bili(ctx);
|
|
695
|
+
b.requireLogin();
|
|
696
|
+
const area = ctx.options.category;
|
|
697
|
+
if (!area)
|
|
698
|
+
throw new SpecificAIError('USAGE', '开播需要 --category(直播分区 id)', { hint: 'specific-ai bilibili live categories' });
|
|
699
|
+
const roomId = await resolveRoom(b, 'me');
|
|
700
|
+
const d = await api.startLive(b, roomId, area);
|
|
701
|
+
return { id: roomId, push: { url: d.rtmp?.addr ?? null, key: d.rtmp?.code ?? null } };
|
|
702
|
+
}
|
|
703
|
+
export async function liveStop(ctx) {
|
|
704
|
+
const b = await bili(ctx);
|
|
705
|
+
b.requireLogin();
|
|
706
|
+
const roomId = await resolveRoom(b, 'me');
|
|
707
|
+
await api.stopLive(b, roomId);
|
|
708
|
+
return { id: roomId };
|
|
709
|
+
}
|
|
710
|
+
// ---------------------------------------------------------------- 弹幕长连(上游 live/server.py)
|
|
711
|
+
const OP_HEARTBEAT = 2;
|
|
712
|
+
const OP_HEARTBEAT_REPLY = 3;
|
|
713
|
+
const OP_MESSAGE = 5;
|
|
714
|
+
const OP_AUTH = 7;
|
|
715
|
+
export function pack(body, op, ver = 1) {
|
|
716
|
+
const head = Buffer.alloc(16);
|
|
717
|
+
head.writeUInt32BE(16 + body.length, 0);
|
|
718
|
+
head.writeUInt16BE(16, 4);
|
|
719
|
+
head.writeUInt16BE(ver, 6);
|
|
720
|
+
head.writeUInt32BE(op, 8);
|
|
721
|
+
head.writeUInt32BE(1, 12);
|
|
722
|
+
return Buffer.concat([head, body]);
|
|
723
|
+
}
|
|
724
|
+
export function unpack(data, out = []) {
|
|
725
|
+
let offset = 0;
|
|
726
|
+
while (offset + 16 <= data.length) {
|
|
727
|
+
const len = data.readUInt32BE(offset);
|
|
728
|
+
const headLen = data.readUInt16BE(offset + 4);
|
|
729
|
+
const ver = data.readUInt16BE(offset + 6);
|
|
730
|
+
const op = data.readUInt32BE(offset + 8);
|
|
731
|
+
const body = data.subarray(offset + headLen, offset + len);
|
|
732
|
+
if (ver === 2)
|
|
733
|
+
unpack(inflateSync(body), out);
|
|
734
|
+
else if (ver === 3)
|
|
735
|
+
unpack(brotliDecompressSync(body), out);
|
|
736
|
+
else if (ver === 1)
|
|
737
|
+
out.push([op, body.length >= 4 ? body.readUInt32BE(0) : 0]);
|
|
738
|
+
else
|
|
739
|
+
out.push([op, body.length ? jsonLoads(body.toString('utf8')) : {}]);
|
|
740
|
+
offset += len;
|
|
741
|
+
}
|
|
742
|
+
return out;
|
|
743
|
+
}
|
|
744
|
+
export function liveListen(ctx) {
|
|
745
|
+
return (async function* () {
|
|
746
|
+
const b = await bili(ctx);
|
|
747
|
+
const r = await room(b, ctx.args.room);
|
|
748
|
+
const cookie = b.jar.header('https://live.bilibili.com/');
|
|
749
|
+
yield* reconnecting(ctx, async function* () {
|
|
750
|
+
const info = await api.danmuInfo(b, r.roomId);
|
|
751
|
+
const host = info.host_list?.[0];
|
|
752
|
+
if (!host)
|
|
753
|
+
throw new Error('getDanmuInfo 没有返回接入点');
|
|
754
|
+
const socket = await openSocket(`wss://${host.host}:${host.wss_port}/sub`, {
|
|
755
|
+
headers: { 'User-Agent': PROFILE.ua, Origin: 'https://live.bilibili.com', Cookie: cookie },
|
|
756
|
+
...(ctx.config.proxy ? { proxy: ctx.config.proxy } : {}),
|
|
757
|
+
signal: ctx.signal,
|
|
758
|
+
});
|
|
759
|
+
const auth = JSON.stringify({ uid: Number(b.mid || 0), roomid: Number(r.roomId), protover: 3, buvid: b.jar.get('buvid3') ?? '', platform: 'web', type: 2, key: info.token });
|
|
760
|
+
await socket.send(pack(Buffer.from(auth), OP_AUTH, 0));
|
|
761
|
+
const heartbeat = () => socket.send(pack(Buffer.from('[object Object]'), OP_HEARTBEAT)).catch(() => { });
|
|
762
|
+
void heartbeat();
|
|
763
|
+
const beat = setInterval(heartbeat, 30_000).unref();
|
|
764
|
+
try {
|
|
765
|
+
for await (const raw of socket.messages) {
|
|
766
|
+
if (typeof raw === 'string')
|
|
767
|
+
continue;
|
|
768
|
+
for (const [op, payload] of unpack(raw)) {
|
|
769
|
+
ctx.log.debug(`ws op=${op} ${op === OP_MESSAGE ? String(payload.cmd) : JSON.stringify(payload)}`);
|
|
770
|
+
if (op === OP_HEARTBEAT_REPLY)
|
|
771
|
+
yield norm.popularityEvent(Number(payload));
|
|
772
|
+
else if (op === OP_MESSAGE)
|
|
773
|
+
yield norm.liveEvent(payload);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
finally {
|
|
778
|
+
clearInterval(beat);
|
|
779
|
+
socket.close();
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
})();
|
|
783
|
+
}
|