@jackwener/opencli 1.7.4 → 1.7.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +76 -51
- package/README.zh-CN.md +78 -62
- package/cli-manifest.json +4558 -2979
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/video.js +61 -0
- package/clis/bilibili/video.test.js +81 -0
- package/clis/deepseek/ask.js +94 -0
- package/clis/deepseek/ask.test.js +73 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +291 -0
- package/clis/deepseek/utils.test.js +37 -0
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/jianyu/search.js +139 -3
- package/clis/jianyu/search.test.js +25 -0
- package/clis/jianyu/shared/procurement-detail.js +15 -0
- package/clis/jianyu/shared/procurement-detail.test.js +12 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- package/clis/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- package/clis/twitter/shared.js +7 -2
- package/clis/twitter/tweets.js +218 -0
- package/clis/twitter/tweets.test.js +125 -0
- package/clis/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/download.js +8 -4
- package/clis/xiaoyuzhou/download.test.js +23 -13
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- package/clis/youtube/channel.js +35 -0
- package/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +14 -4
- package/dist/src/browser/base-page.js +35 -25
- package/dist/src/browser/bridge.d.ts +1 -0
- package/dist/src/browser/bridge.js +1 -1
- package/dist/src/browser/cdp.d.ts +1 -0
- package/dist/src/browser/cdp.js +13 -4
- package/dist/src/browser/compound.d.ts +59 -0
- package/dist/src/browser/compound.js +112 -0
- package/dist/src/browser/compound.test.js +175 -0
- package/dist/src/browser/daemon-client.d.ts +6 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.d.ts +7 -0
- package/dist/src/browser/dom-snapshot.js +83 -5
- package/dist/src/browser/dom-snapshot.test.js +65 -0
- package/dist/src/browser/extract.d.ts +69 -0
- package/dist/src/browser/extract.js +132 -0
- package/dist/src/browser/extract.test.js +129 -0
- package/dist/src/browser/find.d.ts +76 -0
- package/dist/src/browser/find.js +179 -0
- package/dist/src/browser/find.test.js +120 -0
- package/dist/src/browser/html-tree.d.ts +75 -0
- package/dist/src/browser/html-tree.js +112 -0
- package/dist/src/browser/html-tree.test.d.ts +1 -0
- package/dist/src/browser/html-tree.test.js +181 -0
- package/dist/src/browser/network-cache.d.ts +48 -0
- package/dist/src/browser/network-cache.js +66 -0
- package/dist/src/browser/network-cache.test.d.ts +1 -0
- package/dist/src/browser/network-cache.test.js +58 -0
- package/dist/src/browser/network-key.d.ts +22 -0
- package/dist/src/browser/network-key.js +66 -0
- package/dist/src/browser/network-key.test.d.ts +1 -0
- package/dist/src/browser/network-key.test.js +49 -0
- package/dist/src/browser/page.d.ts +14 -4
- package/dist/src/browser/page.js +48 -7
- package/dist/src/browser/page.test.js +97 -0
- package/dist/src/browser/shape-filter.d.ts +52 -0
- package/dist/src/browser/shape-filter.js +101 -0
- package/dist/src/browser/shape-filter.test.d.ts +1 -0
- package/dist/src/browser/shape-filter.test.js +101 -0
- package/dist/src/browser/shape.d.ts +23 -0
- package/dist/src/browser/shape.js +95 -0
- package/dist/src/browser/shape.test.d.ts +1 -0
- package/dist/src/browser/shape.test.js +82 -0
- package/dist/src/browser/target-errors.d.ts +14 -1
- package/dist/src/browser/target-errors.js +13 -0
- package/dist/src/browser/target-errors.test.js +39 -6
- package/dist/src/browser/target-resolver.d.ts +57 -10
- package/dist/src/browser/target-resolver.js +195 -75
- package/dist/src/browser/target-resolver.test.js +80 -5
- package/dist/src/cli.js +849 -267
- package/dist/src/cli.test.js +961 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- package/dist/src/completion-shared.js +2 -5
- package/dist/src/daemon.js +8 -0
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.d.ts +1 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/execution.js +7 -2
- package/dist/src/execution.test.js +54 -0
- package/dist/src/main.js +16 -0
- package/dist/src/plugin.d.ts +1 -8
- package/dist/src/plugin.js +1 -27
- package/dist/src/plugin.test.js +1 -59
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +32 -8
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → browser/find.test.d.ts} +0 -0
|
@@ -54,6 +54,20 @@ function jsonResponse(res, status, data) {
|
|
|
54
54
|
function sleep(ms) {
|
|
55
55
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
56
56
|
}
|
|
57
|
+
function parseTimeoutValue(val, label, fallback) {
|
|
58
|
+
if (val === undefined) {
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
const parsed = typeof val === 'number' ? val : parseInt(String(val), 10);
|
|
62
|
+
if (Number.isNaN(parsed) || parsed <= 0) {
|
|
63
|
+
console.error(`[serve] Invalid ${label}="${val}", using default ${fallback}s`);
|
|
64
|
+
return fallback;
|
|
65
|
+
}
|
|
66
|
+
return parsed;
|
|
67
|
+
}
|
|
68
|
+
function parseEnvTimeout(envVar, fallback) {
|
|
69
|
+
return parseTimeoutValue(process.env[envVar], envVar, fallback);
|
|
70
|
+
}
|
|
57
71
|
// ─── DOM helpers ─────────────────────────────────────────────────────
|
|
58
72
|
/**
|
|
59
73
|
* Click the 'New Conversation' button to reset context.
|
|
@@ -267,41 +281,65 @@ async function waitForReply(page, beforeText, opts = {}) {
|
|
|
267
281
|
let lastText = beforeText;
|
|
268
282
|
let stableCount = 0;
|
|
269
283
|
const stableThreshold = 4; // 4 * 500ms = 2s of stability fallback
|
|
284
|
+
let reconnectCount = 0;
|
|
270
285
|
while (Date.now() < deadline) {
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
else {
|
|
279
|
-
if (hasStartedGenerating) {
|
|
280
|
-
// It actively generated and now it stopped -> DONE
|
|
281
|
-
// Provide a small buffer to let React render the final message fully
|
|
282
|
-
await sleep(500);
|
|
283
|
-
return;
|
|
286
|
+
try {
|
|
287
|
+
const generating = await isGenerating(page);
|
|
288
|
+
const currentText = await getConversationText(page);
|
|
289
|
+
const textChanged = currentText !== beforeText && currentText.length > 0;
|
|
290
|
+
if (generating) {
|
|
291
|
+
hasStartedGenerating = true;
|
|
292
|
+
stableCount = 0; // Reset stability while generating
|
|
284
293
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
294
|
+
else {
|
|
295
|
+
if (hasStartedGenerating) {
|
|
296
|
+
// It actively generated and now it stopped -> DONE
|
|
297
|
+
// Provide a small buffer to let React render the final message fully
|
|
298
|
+
await sleep(500);
|
|
299
|
+
return page;
|
|
300
|
+
}
|
|
301
|
+
// Fallback: If it never showed "Generating/Cancel", but text changed and is stable
|
|
302
|
+
if (textChanged) {
|
|
303
|
+
if (currentText === lastText) {
|
|
304
|
+
stableCount++;
|
|
305
|
+
if (stableCount >= stableThreshold) {
|
|
306
|
+
return page; // Text has been stable for 2 seconds -> DONE
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
stableCount = 0;
|
|
311
|
+
lastText = currentText;
|
|
291
312
|
}
|
|
292
313
|
}
|
|
293
|
-
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
const msg = err.message || String(err);
|
|
318
|
+
const isSessionLoss = /closed|lost|not open|websocket/i.test(msg);
|
|
319
|
+
if (opts.reconnect && isSessionLoss && reconnectCount < 2) {
|
|
320
|
+
reconnectCount++;
|
|
321
|
+
console.error(`[serve] CDP session loss detected (${msg}), attempting to reconnect (${reconnectCount}/2)...`);
|
|
322
|
+
try {
|
|
323
|
+
page = await opts.reconnect();
|
|
324
|
+
// Reset stability tracking after reconnect
|
|
294
325
|
stableCount = 0;
|
|
295
|
-
lastText =
|
|
326
|
+
lastText = beforeText;
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
329
|
+
catch (reconnectErr) {
|
|
330
|
+
console.error(`[serve] Reconnection failed: ${reconnectErr.message}`);
|
|
331
|
+
throw err; // Throw original error if reconnection itself fails
|
|
296
332
|
}
|
|
297
333
|
}
|
|
334
|
+
throw err;
|
|
298
335
|
}
|
|
299
336
|
await sleep(pollInterval);
|
|
300
337
|
}
|
|
301
|
-
throw new Error(
|
|
338
|
+
throw new Error(`Timeout waiting for Antigravity reply after ${timeout / 1000}s`);
|
|
302
339
|
}
|
|
303
340
|
// ─── Request Handlers ────────────────────────────────────────────────
|
|
304
|
-
async function handleMessages(body, page,
|
|
341
|
+
async function handleMessages(body, page, opts = {}) {
|
|
342
|
+
const { bridge, timeout, reconnect } = opts;
|
|
305
343
|
// Extract the last user message
|
|
306
344
|
const userMessages = body.messages.filter(m => m.role === 'user');
|
|
307
345
|
if (userMessages.length === 0) {
|
|
@@ -328,7 +366,7 @@ async function handleMessages(body, page, bridge) {
|
|
|
328
366
|
await sendMessage(page, userText, bridge);
|
|
329
367
|
// Poll for reply (change detection)
|
|
330
368
|
console.error('[serve] Waiting for reply...');
|
|
331
|
-
await waitForReply(page, beforeText);
|
|
369
|
+
page = await waitForReply(page, beforeText, { timeout, reconnect });
|
|
332
370
|
// Extract the actual reply text precisely from the DOM
|
|
333
371
|
const replyText = await getLastAssistantReply(page, userText);
|
|
334
372
|
console.error(`[serve] Got reply: "${replyText.slice(0, 80)}${replyText.length > 80 ? '...' : ''}"`);
|
|
@@ -349,6 +387,10 @@ async function handleMessages(body, page, bridge) {
|
|
|
349
387
|
// ─── Server ──────────────────────────────────────────────────────────
|
|
350
388
|
export async function startServe(opts = {}) {
|
|
351
389
|
const port = opts.port ?? 8082;
|
|
390
|
+
const envTimeoutSeconds = parseEnvTimeout('OPENCLI_ANTIGRAVITY_TIMEOUT', 120);
|
|
391
|
+
const effectiveTimeoutSeconds = parseTimeoutValue(opts.timeout, '--timeout', envTimeoutSeconds);
|
|
392
|
+
const effectiveTimeout = effectiveTimeoutSeconds * 1000;
|
|
393
|
+
console.error(`[serve] Starting Antigravity API proxy on port ${port} (timeout: ${effectiveTimeout / 1000}s)`);
|
|
352
394
|
// Lazy CDP connection — connect when first request comes in
|
|
353
395
|
let cdp = null;
|
|
354
396
|
let page = null;
|
|
@@ -462,7 +504,11 @@ export async function startServe(opts = {}) {
|
|
|
462
504
|
}
|
|
463
505
|
// Lazy connect on first request
|
|
464
506
|
const activePage = await ensureConnected();
|
|
465
|
-
const response = await handleMessages(body, activePage,
|
|
507
|
+
const response = await handleMessages(body, activePage, {
|
|
508
|
+
bridge: cdp,
|
|
509
|
+
timeout: effectiveTimeout,
|
|
510
|
+
reconnect: ensureConnected,
|
|
511
|
+
});
|
|
466
512
|
jsonResponse(res, 200, response);
|
|
467
513
|
}
|
|
468
514
|
finally {
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { clampInt, requireNonEmptyQuery } from '../_shared/common.js';
|
|
3
|
+
|
|
4
|
+
cli({
|
|
5
|
+
site: 'baidu-scholar',
|
|
6
|
+
name: 'search',
|
|
7
|
+
description: '百度学术搜索',
|
|
8
|
+
domain: 'xueshu.baidu.com',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: true,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'query', positional: true, required: true, help: '搜索关键词' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 10, help: '返回结果数量 (max 20)' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['rank', 'title', 'authors', 'journal', 'year', 'cited', 'url'],
|
|
16
|
+
navigateBefore: false,
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const limit = clampInt(kwargs.limit, 10, 1, 20);
|
|
19
|
+
const query = requireNonEmptyQuery(kwargs.query);
|
|
20
|
+
await page.goto(`https://xueshu.baidu.com/s?wd=${encodeURIComponent(query)}&pn=0&tn=SE_baiduxueshu_c1gjeupa`);
|
|
21
|
+
await page.wait(5);
|
|
22
|
+
const data = await page.evaluate(`
|
|
23
|
+
(async () => {
|
|
24
|
+
const normalize = v => (v || '').replace(/\\s+/g, ' ').trim();
|
|
25
|
+
for (let i = 0; i < 20; i++) {
|
|
26
|
+
if (document.querySelectorAll('.result').length > 0) break;
|
|
27
|
+
await new Promise(r => setTimeout(r, 500));
|
|
28
|
+
}
|
|
29
|
+
const results = [];
|
|
30
|
+
for (const el of document.querySelectorAll('.result')) {
|
|
31
|
+
const titleEl = el.querySelector('h3 a, .paper-title a, .t a');
|
|
32
|
+
const title = normalize(titleEl?.textContent);
|
|
33
|
+
if (!title) continue;
|
|
34
|
+
|
|
35
|
+
let url = titleEl?.getAttribute('href') || '';
|
|
36
|
+
if (url && !url.startsWith('http')) url = 'https://xueshu.baidu.com' + url;
|
|
37
|
+
|
|
38
|
+
const infoEl = el.querySelector('.paper-info');
|
|
39
|
+
const infoText = normalize(infoEl?.textContent);
|
|
40
|
+
const spans = infoEl ? Array.from(infoEl.querySelectorAll('span')) : [];
|
|
41
|
+
|
|
42
|
+
let journal = '';
|
|
43
|
+
let year = '';
|
|
44
|
+
let cited = '0';
|
|
45
|
+
const authorParts = [];
|
|
46
|
+
|
|
47
|
+
for (const span of spans) {
|
|
48
|
+
const text = normalize(span.textContent);
|
|
49
|
+
if (!text || text === ',' || text === ',') continue;
|
|
50
|
+
if (text.startsWith('《') || text.startsWith('〈')) {
|
|
51
|
+
journal = text.replace(/[《》〈〉]/g, '');
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (/^被引量[::]/.test(text)) {
|
|
55
|
+
cited = text.match(/(\\d+)/)?.[1] || '0';
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (/^-\\s*(\\d{4})/.test(text) || /^\\d{4}年?$/.test(text)) {
|
|
59
|
+
year = text.match(/(\\d{4})/)?.[1] || '';
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!journal && !/^被引/.test(text) && !text.startsWith('-')) {
|
|
63
|
+
authorParts.push(text);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (!year) year = infoText.match(/(19|20)\\d{2}/)?.[0] || '';
|
|
68
|
+
if (!cited || cited === '0') cited = infoText.match(/被引量[::]\\s*(\\d+)/)?.[1] || '0';
|
|
69
|
+
|
|
70
|
+
results.push({
|
|
71
|
+
rank: results.length + 1,
|
|
72
|
+
title,
|
|
73
|
+
authors: authorParts.join(', ').slice(0, 80),
|
|
74
|
+
journal,
|
|
75
|
+
year,
|
|
76
|
+
cited,
|
|
77
|
+
url,
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
if (results.length >= ${limit}) break;
|
|
81
|
+
}
|
|
82
|
+
return results;
|
|
83
|
+
})()
|
|
84
|
+
`);
|
|
85
|
+
return Array.isArray(data) ? data : [];
|
|
86
|
+
},
|
|
87
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './search.js';
|
|
4
|
+
|
|
5
|
+
describe('baidu-scholar search command', () => {
|
|
6
|
+
const command = getRegistry().get('baidu-scholar/search');
|
|
7
|
+
|
|
8
|
+
it('registers as a public browser command', () => {
|
|
9
|
+
expect(command).toBeDefined();
|
|
10
|
+
expect(command.site).toBe('baidu-scholar');
|
|
11
|
+
expect(command.strategy).toBe('public');
|
|
12
|
+
expect(command.browser).toBe(true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it('rejects empty queries before browser navigation', async () => {
|
|
16
|
+
const page = { goto: vi.fn() };
|
|
17
|
+
await expect(command.func(page, { query: ' ' })).rejects.toMatchObject({
|
|
18
|
+
name: 'ArgumentError',
|
|
19
|
+
code: 'ARGUMENT',
|
|
20
|
+
});
|
|
21
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import { apiGet, resolveBvid } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'bilibili',
|
|
7
|
+
name: 'video',
|
|
8
|
+
description: 'Get Bilibili video metadata (title, author, duration, stats, etc.)',
|
|
9
|
+
strategy: Strategy.COOKIE,
|
|
10
|
+
args: [
|
|
11
|
+
{ name: 'bvid', required: true, positional: true, help: 'BV ID, video URL, or b23.tv short link' },
|
|
12
|
+
],
|
|
13
|
+
columns: ['field', 'value'],
|
|
14
|
+
func: async (page, kwargs) => {
|
|
15
|
+
if (!page) {
|
|
16
|
+
throw new CommandExecutionError('Browser session required for bilibili video');
|
|
17
|
+
}
|
|
18
|
+
const bvid = await resolveBvid(kwargs.bvid);
|
|
19
|
+
|
|
20
|
+
// Navigate to video page first so subsequent api call shares a primed session.
|
|
21
|
+
await page.goto(`https://www.bilibili.com/video/${bvid}/`);
|
|
22
|
+
|
|
23
|
+
const payload = await apiGet(page, '/x/web-interface/view', {
|
|
24
|
+
params: { bvid },
|
|
25
|
+
});
|
|
26
|
+
if (payload.code !== 0) {
|
|
27
|
+
throw new CommandExecutionError(`Bilibili view API failed: ${payload.message} (${payload.code})`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const d = payload.data || {};
|
|
31
|
+
const stat = d.stat || {};
|
|
32
|
+
const owner = d.owner || {};
|
|
33
|
+
|
|
34
|
+
const pubDate = d.pubdate ? new Date(d.pubdate * 1000).toISOString().slice(0, 16).replace('T', ' ') : '';
|
|
35
|
+
const dur = d.duration || 0;
|
|
36
|
+
const mm = Math.floor(dur / 60);
|
|
37
|
+
const ss = dur % 60;
|
|
38
|
+
const desc = (d.desc || '').replace(/\s+/g, ' ').trim();
|
|
39
|
+
const descTrunc = desc.length > 200 ? desc.slice(0, 200) + '…' : desc;
|
|
40
|
+
|
|
41
|
+
return [
|
|
42
|
+
{ field: 'bvid', value: d.bvid ?? '' },
|
|
43
|
+
{ field: 'aid', value: String(d.aid ?? '') },
|
|
44
|
+
{ field: 'title', value: d.title ?? '' },
|
|
45
|
+
{ field: 'author', value: owner.name ? `${owner.name} (mid: ${owner.mid})` : '' },
|
|
46
|
+
{ field: 'category', value: d.tname_v2 || d.tname || '' },
|
|
47
|
+
{ field: 'publish_time', value: pubDate },
|
|
48
|
+
{ field: 'duration', value: dur ? `${mm}m${ss}s (${dur}s)` : '' },
|
|
49
|
+
{ field: 'view', value: String(stat.view ?? '') },
|
|
50
|
+
{ field: 'danmaku', value: String(stat.danmaku ?? '') },
|
|
51
|
+
{ field: 'reply', value: String(stat.reply ?? '') },
|
|
52
|
+
{ field: 'like', value: String(stat.like ?? '') },
|
|
53
|
+
{ field: 'coin', value: String(stat.coin ?? '') },
|
|
54
|
+
{ field: 'favorite', value: String(stat.favorite ?? '') },
|
|
55
|
+
{ field: 'share', value: String(stat.share ?? '') },
|
|
56
|
+
{ field: 'parts', value: String(d.videos ?? 1) },
|
|
57
|
+
{ field: 'thumbnail', value: d.pic ?? '' },
|
|
58
|
+
{ field: 'description', value: descTrunc },
|
|
59
|
+
];
|
|
60
|
+
},
|
|
61
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
|
|
4
|
+
const { mockApiGet } = vi.hoisted(() => ({
|
|
5
|
+
mockApiGet: vi.fn(),
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('./utils.js', async (importOriginal) => ({
|
|
9
|
+
...(await importOriginal()),
|
|
10
|
+
apiGet: mockApiGet,
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
14
|
+
import './video.js';
|
|
15
|
+
|
|
16
|
+
describe('bilibili video', () => {
|
|
17
|
+
const command = getRegistry().get('bilibili/video');
|
|
18
|
+
const page = {
|
|
19
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
20
|
+
evaluate: vi.fn(),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mockApiGet.mockReset();
|
|
25
|
+
page.goto.mockClear();
|
|
26
|
+
page.evaluate.mockReset();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('returns a field/value table of video metadata on success', async () => {
|
|
30
|
+
mockApiGet.mockResolvedValueOnce({
|
|
31
|
+
code: 0,
|
|
32
|
+
data: {
|
|
33
|
+
bvid: 'BV1xx411c7mD',
|
|
34
|
+
aid: 12345678,
|
|
35
|
+
title: '三层结构笔记法',
|
|
36
|
+
tname: '教程',
|
|
37
|
+
pubdate: 1775053078, // 2026-04-01 14:17:58 UTC
|
|
38
|
+
duration: 434,
|
|
39
|
+
videos: 1,
|
|
40
|
+
pic: 'https://i1.hdslb.com/some.jpg',
|
|
41
|
+
desc: 'Obsidian 教程',
|
|
42
|
+
owner: { mid: 507578555, name: 'IOI科技' },
|
|
43
|
+
stat: { view: 6128, danmaku: 0, reply: 21, like: 162, coin: 48, favorite: 564, share: 26 },
|
|
44
|
+
},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const rows = await command.func(page, { bvid: 'BV1xx411c7mD' });
|
|
48
|
+
|
|
49
|
+
// Every row has { field, value }
|
|
50
|
+
expect(Array.isArray(rows)).toBe(true);
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
expect(row).toHaveProperty('field');
|
|
53
|
+
expect(row).toHaveProperty('value');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const byField = Object.fromEntries(rows.map((r) => [r.field, r.value]));
|
|
57
|
+
expect(byField.bvid).toBe('BV1xx411c7mD');
|
|
58
|
+
expect(byField.title).toBe('三层结构笔记法');
|
|
59
|
+
expect(byField.author).toBe('IOI科技 (mid: 507578555)');
|
|
60
|
+
expect(byField.duration).toBe('7m14s (434s)');
|
|
61
|
+
expect(byField.view).toBe('6128');
|
|
62
|
+
expect(byField.like).toBe('162');
|
|
63
|
+
|
|
64
|
+
// Navigation primes the session
|
|
65
|
+
expect(page.goto).toHaveBeenCalledWith('https://www.bilibili.com/video/BV1xx411c7mD/');
|
|
66
|
+
// API called without signing
|
|
67
|
+
expect(mockApiGet).toHaveBeenCalledWith(page, '/x/web-interface/view', { params: { bvid: 'BV1xx411c7mD' } });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('throws CommandExecutionError when bilibili view API returns non-zero code', async () => {
|
|
71
|
+
mockApiGet.mockResolvedValueOnce({
|
|
72
|
+
code: -404,
|
|
73
|
+
message: '啥都木有',
|
|
74
|
+
data: null,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
await expect(command.func(page, { bvid: 'BV1xx411c7mD' })).rejects.toSatisfy(
|
|
78
|
+
(err) => err instanceof CommandExecutionError && /啥都木有|-404/.test(err.message),
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { CommandExecutionError } from '@jackwener/opencli/errors';
|
|
3
|
+
import {
|
|
4
|
+
DEEPSEEK_DOMAIN, DEEPSEEK_URL, ensureOnDeepSeek, selectModel, setFeature,
|
|
5
|
+
sendMessage, sendWithFile, getBubbleCount, waitForResponse, parseBoolFlag, withRetry,
|
|
6
|
+
} from './utils.js';
|
|
7
|
+
|
|
8
|
+
export const askCommand = cli({
|
|
9
|
+
site: 'deepseek',
|
|
10
|
+
name: 'ask',
|
|
11
|
+
description: 'Send a prompt to DeepSeek and get the response',
|
|
12
|
+
domain: DEEPSEEK_DOMAIN,
|
|
13
|
+
strategy: Strategy.COOKIE,
|
|
14
|
+
browser: true,
|
|
15
|
+
navigateBefore: false,
|
|
16
|
+
timeoutSeconds: 180,
|
|
17
|
+
args: [
|
|
18
|
+
{ name: 'prompt', positional: true, required: true, help: 'Prompt to send' },
|
|
19
|
+
{ name: 'timeout', type: 'int', default: 120, help: 'Max seconds to wait for response' },
|
|
20
|
+
{ name: 'new', type: 'boolean', default: false, help: 'Start a new chat before sending' },
|
|
21
|
+
{ name: 'model', default: 'instant', choices: ['instant', 'expert'], help: 'Model to use: instant or expert' },
|
|
22
|
+
{ name: 'think', type: 'boolean', default: false, help: 'Enable DeepThink mode' },
|
|
23
|
+
{ name: 'search', type: 'boolean', default: false, help: 'Enable web search' },
|
|
24
|
+
{ name: 'file', help: 'Attach a file (PDF, image, text) with the prompt' },
|
|
25
|
+
],
|
|
26
|
+
columns: ['response'],
|
|
27
|
+
|
|
28
|
+
func: async (page, kwargs) => {
|
|
29
|
+
const prompt = kwargs.prompt;
|
|
30
|
+
const timeoutMs = (kwargs.timeout || 120) * 1000;
|
|
31
|
+
const wantThink = parseBoolFlag(kwargs.think);
|
|
32
|
+
const wantSearch = parseBoolFlag(kwargs.search);
|
|
33
|
+
|
|
34
|
+
if (parseBoolFlag(kwargs.new)) {
|
|
35
|
+
await page.goto(DEEPSEEK_URL);
|
|
36
|
+
await page.wait(3);
|
|
37
|
+
} else {
|
|
38
|
+
await ensureOnDeepSeek(page);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
await page.wait(2);
|
|
42
|
+
|
|
43
|
+
const wantModel = kwargs.model || 'instant';
|
|
44
|
+
const modelResult = await withRetry(() => selectModel(page, wantModel));
|
|
45
|
+
if (!modelResult?.ok) {
|
|
46
|
+
throw new CommandExecutionError(`Could not switch to ${wantModel} model`);
|
|
47
|
+
}
|
|
48
|
+
if (modelResult.toggled) await page.wait(0.5);
|
|
49
|
+
|
|
50
|
+
const thinkResult = await withRetry(() => setFeature(page, 'DeepThink', wantThink));
|
|
51
|
+
if (!thinkResult?.ok) {
|
|
52
|
+
throw new CommandExecutionError('Could not toggle DeepThink');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const searchResult = await withRetry(() => setFeature(page, 'Search', wantSearch));
|
|
56
|
+
if (!searchResult?.ok) {
|
|
57
|
+
throw new CommandExecutionError('Could not toggle Search');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (thinkResult.toggled || searchResult.toggled) await page.wait(0.5);
|
|
61
|
+
|
|
62
|
+
if (kwargs.file) {
|
|
63
|
+
const baseline = await withRetry(() => getBubbleCount(page));
|
|
64
|
+
try {
|
|
65
|
+
const fileResult = await sendWithFile(page, kwargs.file, prompt);
|
|
66
|
+
if (fileResult && !fileResult.ok) {
|
|
67
|
+
throw new CommandExecutionError(fileResult.reason || 'Failed to attach file');
|
|
68
|
+
}
|
|
69
|
+
} catch (err) {
|
|
70
|
+
// SPA navigates after send; "Promise was collected" means send succeeded
|
|
71
|
+
if (!String(err?.message || err).includes('Promise was collected')) throw err;
|
|
72
|
+
}
|
|
73
|
+
await page.wait(3);
|
|
74
|
+
const response = await waitForResponse(page, baseline, prompt, timeoutMs);
|
|
75
|
+
if (!response) {
|
|
76
|
+
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
|
|
77
|
+
}
|
|
78
|
+
return [{ response }];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const baseline = await withRetry(() => getBubbleCount(page));
|
|
82
|
+
const sendResult = await withRetry(() => sendMessage(page, prompt));
|
|
83
|
+
if (!sendResult?.ok) {
|
|
84
|
+
throw new CommandExecutionError(sendResult?.reason || 'Failed to send message');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const response = await waitForResponse(page, baseline, prompt, timeoutMs);
|
|
88
|
+
if (!response) {
|
|
89
|
+
return [{ response: `[NO RESPONSE] No reply within ${kwargs.timeout}s.` }];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return [{ response }];
|
|
93
|
+
},
|
|
94
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
const {
|
|
4
|
+
mockEnsureOnDeepSeek,
|
|
5
|
+
mockSelectModel,
|
|
6
|
+
mockSetFeature,
|
|
7
|
+
mockSendMessage,
|
|
8
|
+
mockSendWithFile,
|
|
9
|
+
mockGetBubbleCount,
|
|
10
|
+
mockWaitForResponse,
|
|
11
|
+
mockParseBoolFlag,
|
|
12
|
+
mockWithRetry,
|
|
13
|
+
} = vi.hoisted(() => ({
|
|
14
|
+
mockEnsureOnDeepSeek: vi.fn(),
|
|
15
|
+
mockSelectModel: vi.fn(),
|
|
16
|
+
mockSetFeature: vi.fn(),
|
|
17
|
+
mockSendMessage: vi.fn(),
|
|
18
|
+
mockSendWithFile: vi.fn(),
|
|
19
|
+
mockGetBubbleCount: vi.fn(),
|
|
20
|
+
mockWaitForResponse: vi.fn(),
|
|
21
|
+
mockParseBoolFlag: vi.fn((v) => v === true || v === 'true'),
|
|
22
|
+
mockWithRetry: vi.fn(async (fn) => fn()),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
vi.mock('./utils.js', () => ({
|
|
26
|
+
DEEPSEEK_DOMAIN: 'chat.deepseek.com',
|
|
27
|
+
DEEPSEEK_URL: 'https://chat.deepseek.com/',
|
|
28
|
+
ensureOnDeepSeek: mockEnsureOnDeepSeek,
|
|
29
|
+
selectModel: mockSelectModel,
|
|
30
|
+
setFeature: mockSetFeature,
|
|
31
|
+
sendMessage: mockSendMessage,
|
|
32
|
+
sendWithFile: mockSendWithFile,
|
|
33
|
+
getBubbleCount: mockGetBubbleCount,
|
|
34
|
+
waitForResponse: mockWaitForResponse,
|
|
35
|
+
parseBoolFlag: mockParseBoolFlag,
|
|
36
|
+
withRetry: mockWithRetry,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
import { askCommand } from './ask.js';
|
|
40
|
+
|
|
41
|
+
describe('deepseek ask --file', () => {
|
|
42
|
+
const page = {
|
|
43
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
44
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
mockEnsureOnDeepSeek.mockResolvedValue(undefined);
|
|
50
|
+
mockSelectModel.mockResolvedValue({ ok: true, toggled: false });
|
|
51
|
+
mockSetFeature.mockResolvedValue({ ok: true, toggled: false });
|
|
52
|
+
mockSendWithFile.mockResolvedValue({ ok: true });
|
|
53
|
+
mockGetBubbleCount.mockResolvedValue(7);
|
|
54
|
+
mockWaitForResponse.mockResolvedValue('new reply');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('captures the existing baseline before sending a file prompt', async () => {
|
|
58
|
+
const rows = await askCommand.func(page, {
|
|
59
|
+
prompt: 'summarize this',
|
|
60
|
+
timeout: 120,
|
|
61
|
+
file: './report.pdf',
|
|
62
|
+
new: false,
|
|
63
|
+
model: 'instant',
|
|
64
|
+
think: false,
|
|
65
|
+
search: false,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(rows).toEqual([{ response: 'new reply' }]);
|
|
69
|
+
expect(mockGetBubbleCount).toHaveBeenCalledTimes(1);
|
|
70
|
+
expect(mockSendWithFile).toHaveBeenCalledWith(page, './report.pdf', 'summarize this');
|
|
71
|
+
expect(mockWaitForResponse).toHaveBeenCalledWith(page, 7, 'summarize this', 120000);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { DEEPSEEK_DOMAIN, getConversationList } from './utils.js';
|
|
3
|
+
|
|
4
|
+
export const historyCommand = cli({
|
|
5
|
+
site: 'deepseek',
|
|
6
|
+
name: 'history',
|
|
7
|
+
description: 'List conversation history from DeepSeek sidebar',
|
|
8
|
+
domain: DEEPSEEK_DOMAIN,
|
|
9
|
+
strategy: Strategy.COOKIE,
|
|
10
|
+
browser: true,
|
|
11
|
+
navigateBefore: false,
|
|
12
|
+
args: [
|
|
13
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max conversations to show' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['Index', 'Title', 'Url'],
|
|
16
|
+
|
|
17
|
+
func: async (page, kwargs) => {
|
|
18
|
+
const limit = Math.max(1, kwargs.limit || 20);
|
|
19
|
+
const conversations = await getConversationList(page);
|
|
20
|
+
if (conversations.length === 0) {
|
|
21
|
+
return [{ Index: 0, Title: 'No conversation history found.', Url: '' }];
|
|
22
|
+
}
|
|
23
|
+
return conversations.slice(0, limit);
|
|
24
|
+
},
|
|
25
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { DEEPSEEK_DOMAIN, DEEPSEEK_URL } from './utils.js';
|
|
3
|
+
|
|
4
|
+
export const newCommand = cli({
|
|
5
|
+
site: 'deepseek',
|
|
6
|
+
name: 'new',
|
|
7
|
+
description: 'Start a new conversation in DeepSeek',
|
|
8
|
+
domain: DEEPSEEK_DOMAIN,
|
|
9
|
+
strategy: Strategy.COOKIE,
|
|
10
|
+
browser: true,
|
|
11
|
+
navigateBefore: false,
|
|
12
|
+
args: [],
|
|
13
|
+
columns: ['Status'],
|
|
14
|
+
|
|
15
|
+
func: async (page) => {
|
|
16
|
+
await page.goto(DEEPSEEK_URL);
|
|
17
|
+
await page.wait(2);
|
|
18
|
+
return [{ Status: 'New chat started' }];
|
|
19
|
+
},
|
|
20
|
+
});
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { DEEPSEEK_DOMAIN, ensureOnDeepSeek, getVisibleMessages } from './utils.js';
|
|
3
|
+
|
|
4
|
+
export const readCommand = cli({
|
|
5
|
+
site: 'deepseek',
|
|
6
|
+
name: 'read',
|
|
7
|
+
description: 'Read the current DeepSeek conversation',
|
|
8
|
+
domain: DEEPSEEK_DOMAIN,
|
|
9
|
+
strategy: Strategy.COOKIE,
|
|
10
|
+
browser: true,
|
|
11
|
+
navigateBefore: false,
|
|
12
|
+
args: [],
|
|
13
|
+
columns: ['Role', 'Text'],
|
|
14
|
+
|
|
15
|
+
func: async (page) => {
|
|
16
|
+
await ensureOnDeepSeek(page);
|
|
17
|
+
await page.wait(5);
|
|
18
|
+
const messages = await getVisibleMessages(page);
|
|
19
|
+
if (messages.length > 0) return messages;
|
|
20
|
+
return [{ Role: 'system', Text: 'No visible messages found.' }];
|
|
21
|
+
},
|
|
22
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
2
|
+
import { DEEPSEEK_DOMAIN, ensureOnDeepSeek, getPageState } from './utils.js';
|
|
3
|
+
|
|
4
|
+
export const statusCommand = cli({
|
|
5
|
+
site: 'deepseek',
|
|
6
|
+
name: 'status',
|
|
7
|
+
description: 'Check DeepSeek page availability and login state',
|
|
8
|
+
domain: DEEPSEEK_DOMAIN,
|
|
9
|
+
strategy: Strategy.COOKIE,
|
|
10
|
+
browser: true,
|
|
11
|
+
navigateBefore: false,
|
|
12
|
+
args: [],
|
|
13
|
+
columns: ['Status', 'Login', 'Url'],
|
|
14
|
+
|
|
15
|
+
func: async (page) => {
|
|
16
|
+
await ensureOnDeepSeek(page);
|
|
17
|
+
const state = await getPageState(page);
|
|
18
|
+
return [{
|
|
19
|
+
Status: state.hasTextarea ? 'Connected' : 'Page not ready',
|
|
20
|
+
Login: state.isLoggedIn ? 'Yes' : 'No',
|
|
21
|
+
Url: state.url,
|
|
22
|
+
}];
|
|
23
|
+
},
|
|
24
|
+
});
|