@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.
Files changed (181) hide show
  1. package/README.md +76 -51
  2. package/README.zh-CN.md +78 -62
  3. package/cli-manifest.json +4558 -2979
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/video.js +61 -0
  8. package/clis/bilibili/video.test.js +81 -0
  9. package/clis/deepseek/ask.js +94 -0
  10. package/clis/deepseek/ask.test.js +73 -0
  11. package/clis/deepseek/history.js +25 -0
  12. package/clis/deepseek/new.js +20 -0
  13. package/clis/deepseek/read.js +22 -0
  14. package/clis/deepseek/status.js +24 -0
  15. package/clis/deepseek/utils.js +291 -0
  16. package/clis/deepseek/utils.test.js +37 -0
  17. package/clis/eastmoney/_secid.js +78 -0
  18. package/clis/eastmoney/announcement.js +52 -0
  19. package/clis/eastmoney/convertible.js +73 -0
  20. package/clis/eastmoney/etf.js +65 -0
  21. package/clis/eastmoney/holders.js +78 -0
  22. package/clis/eastmoney/index-board.js +96 -0
  23. package/clis/eastmoney/kline.js +87 -0
  24. package/clis/eastmoney/kuaixun.js +54 -0
  25. package/clis/eastmoney/longhu.js +67 -0
  26. package/clis/eastmoney/money-flow.js +78 -0
  27. package/clis/eastmoney/northbound.js +57 -0
  28. package/clis/eastmoney/quote.js +107 -0
  29. package/clis/eastmoney/rank.js +94 -0
  30. package/clis/eastmoney/sectors.js +76 -0
  31. package/clis/google-scholar/search.js +58 -0
  32. package/clis/google-scholar/search.test.js +23 -0
  33. package/clis/gov-law/commands.test.js +39 -0
  34. package/clis/gov-law/recent.js +22 -0
  35. package/clis/gov-law/search.js +41 -0
  36. package/clis/gov-law/shared.js +51 -0
  37. package/clis/gov-policy/commands.test.js +27 -0
  38. package/clis/gov-policy/recent.js +47 -0
  39. package/clis/gov-policy/search.js +48 -0
  40. package/clis/jianyu/search.js +139 -3
  41. package/clis/jianyu/search.test.js +25 -0
  42. package/clis/jianyu/shared/procurement-detail.js +15 -0
  43. package/clis/jianyu/shared/procurement-detail.test.js +12 -0
  44. package/clis/nowcoder/companies.js +23 -0
  45. package/clis/nowcoder/creators.js +27 -0
  46. package/clis/nowcoder/detail.js +61 -0
  47. package/clis/nowcoder/experience.js +36 -0
  48. package/clis/nowcoder/hot.js +24 -0
  49. package/clis/nowcoder/jobs.js +21 -0
  50. package/clis/nowcoder/notifications.js +29 -0
  51. package/clis/nowcoder/papers.js +40 -0
  52. package/clis/nowcoder/practice.js +37 -0
  53. package/clis/nowcoder/recommend.js +30 -0
  54. package/clis/nowcoder/referral.js +39 -0
  55. package/clis/nowcoder/salary.js +40 -0
  56. package/clis/nowcoder/search.js +49 -0
  57. package/clis/nowcoder/suggest.js +33 -0
  58. package/clis/nowcoder/topics.js +27 -0
  59. package/clis/nowcoder/trending.js +25 -0
  60. package/clis/twitter/list-add.js +337 -0
  61. package/clis/twitter/list-add.test.js +15 -0
  62. package/clis/twitter/list-remove.js +297 -0
  63. package/clis/twitter/list-remove.test.js +14 -0
  64. package/clis/twitter/list-tweets.js +185 -0
  65. package/clis/twitter/list-tweets.test.js +108 -0
  66. package/clis/twitter/lists.js +134 -47
  67. package/clis/twitter/lists.test.js +105 -38
  68. package/clis/twitter/shared.js +7 -2
  69. package/clis/twitter/tweets.js +218 -0
  70. package/clis/twitter/tweets.test.js +125 -0
  71. package/clis/wanfang/search.js +66 -0
  72. package/clis/wanfang/search.test.js +23 -0
  73. package/clis/web/read.js +1 -1
  74. package/clis/weixin/download.js +3 -2
  75. package/clis/xiaohongshu/publish.js +149 -28
  76. package/clis/xiaohongshu/publish.test.js +319 -6
  77. package/clis/xiaoyuzhou/download.js +8 -4
  78. package/clis/xiaoyuzhou/download.test.js +23 -13
  79. package/clis/xiaoyuzhou/episode.js +9 -4
  80. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  81. package/clis/xiaoyuzhou/podcast.js +9 -4
  82. package/clis/xiaoyuzhou/utils.js +0 -40
  83. package/clis/xiaoyuzhou/utils.test.js +15 -75
  84. package/clis/youtube/channel.js +35 -0
  85. package/clis/zsxq/dynamics.js +1 -1
  86. package/clis/zsxq/utils.js +6 -3
  87. package/clis/zsxq/utils.test.js +31 -0
  88. package/dist/src/browser/base-page.d.ts +14 -4
  89. package/dist/src/browser/base-page.js +35 -25
  90. package/dist/src/browser/bridge.d.ts +1 -0
  91. package/dist/src/browser/bridge.js +1 -1
  92. package/dist/src/browser/cdp.d.ts +1 -0
  93. package/dist/src/browser/cdp.js +13 -4
  94. package/dist/src/browser/compound.d.ts +59 -0
  95. package/dist/src/browser/compound.js +112 -0
  96. package/dist/src/browser/compound.test.js +175 -0
  97. package/dist/src/browser/daemon-client.d.ts +6 -4
  98. package/dist/src/browser/daemon-client.js +6 -1
  99. package/dist/src/browser/daemon-client.test.js +40 -1
  100. package/dist/src/browser/dom-snapshot.d.ts +7 -0
  101. package/dist/src/browser/dom-snapshot.js +83 -5
  102. package/dist/src/browser/dom-snapshot.test.js +65 -0
  103. package/dist/src/browser/extract.d.ts +69 -0
  104. package/dist/src/browser/extract.js +132 -0
  105. package/dist/src/browser/extract.test.js +129 -0
  106. package/dist/src/browser/find.d.ts +76 -0
  107. package/dist/src/browser/find.js +179 -0
  108. package/dist/src/browser/find.test.js +120 -0
  109. package/dist/src/browser/html-tree.d.ts +75 -0
  110. package/dist/src/browser/html-tree.js +112 -0
  111. package/dist/src/browser/html-tree.test.d.ts +1 -0
  112. package/dist/src/browser/html-tree.test.js +181 -0
  113. package/dist/src/browser/network-cache.d.ts +48 -0
  114. package/dist/src/browser/network-cache.js +66 -0
  115. package/dist/src/browser/network-cache.test.d.ts +1 -0
  116. package/dist/src/browser/network-cache.test.js +58 -0
  117. package/dist/src/browser/network-key.d.ts +22 -0
  118. package/dist/src/browser/network-key.js +66 -0
  119. package/dist/src/browser/network-key.test.d.ts +1 -0
  120. package/dist/src/browser/network-key.test.js +49 -0
  121. package/dist/src/browser/page.d.ts +14 -4
  122. package/dist/src/browser/page.js +48 -7
  123. package/dist/src/browser/page.test.js +97 -0
  124. package/dist/src/browser/shape-filter.d.ts +52 -0
  125. package/dist/src/browser/shape-filter.js +101 -0
  126. package/dist/src/browser/shape-filter.test.d.ts +1 -0
  127. package/dist/src/browser/shape-filter.test.js +101 -0
  128. package/dist/src/browser/shape.d.ts +23 -0
  129. package/dist/src/browser/shape.js +95 -0
  130. package/dist/src/browser/shape.test.d.ts +1 -0
  131. package/dist/src/browser/shape.test.js +82 -0
  132. package/dist/src/browser/target-errors.d.ts +14 -1
  133. package/dist/src/browser/target-errors.js +13 -0
  134. package/dist/src/browser/target-errors.test.js +39 -6
  135. package/dist/src/browser/target-resolver.d.ts +57 -10
  136. package/dist/src/browser/target-resolver.js +195 -75
  137. package/dist/src/browser/target-resolver.test.js +80 -5
  138. package/dist/src/cli.js +849 -267
  139. package/dist/src/cli.test.js +961 -90
  140. package/dist/src/commanderAdapter.d.ts +0 -1
  141. package/dist/src/commanderAdapter.js +2 -16
  142. package/dist/src/commanderAdapter.test.js +1 -1
  143. package/dist/src/completion-shared.js +2 -5
  144. package/dist/src/daemon.js +8 -0
  145. package/dist/src/download/article-download.d.ts +1 -0
  146. package/dist/src/download/article-download.js +3 -0
  147. package/dist/src/download/article-download.test.d.ts +1 -0
  148. package/dist/src/download/article-download.test.js +39 -0
  149. package/dist/src/execution.js +7 -2
  150. package/dist/src/execution.test.js +54 -0
  151. package/dist/src/main.js +16 -0
  152. package/dist/src/plugin.d.ts +1 -8
  153. package/dist/src/plugin.js +1 -27
  154. package/dist/src/plugin.test.js +1 -59
  155. package/dist/src/registry.d.ts +1 -0
  156. package/dist/src/registry.js +3 -2
  157. package/dist/src/registry.test.js +22 -0
  158. package/dist/src/types.d.ts +32 -8
  159. package/package.json +1 -1
  160. package/clis/twitter/lists-parser.js +0 -77
  161. package/clis/twitter/lists.d.ts +0 -5
  162. package/dist/src/cascade.d.ts +0 -46
  163. package/dist/src/cascade.js +0 -135
  164. package/dist/src/explore.d.ts +0 -99
  165. package/dist/src/explore.js +0 -402
  166. package/dist/src/generate-verified.d.ts +0 -105
  167. package/dist/src/generate-verified.js +0 -696
  168. package/dist/src/generate-verified.test.js +0 -925
  169. package/dist/src/generate.d.ts +0 -46
  170. package/dist/src/generate.js +0 -117
  171. package/dist/src/record.d.ts +0 -96
  172. package/dist/src/record.js +0 -657
  173. package/dist/src/record.test.js +0 -293
  174. package/dist/src/skill-generate.d.ts +0 -30
  175. package/dist/src/skill-generate.js +0 -75
  176. package/dist/src/skill-generate.test.js +0 -173
  177. package/dist/src/synthesize.d.ts +0 -97
  178. package/dist/src/synthesize.js +0 -208
  179. /package/dist/src/{generate-verified.test.d.ts → browser/compound.test.d.ts} +0 -0
  180. /package/dist/src/{record.test.d.ts → browser/extract.test.d.ts} +0 -0
  181. /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
- const generating = await isGenerating(page);
272
- const currentText = await getConversationText(page);
273
- const textChanged = currentText !== beforeText && currentText.length > 0;
274
- if (generating) {
275
- hasStartedGenerating = true;
276
- stableCount = 0; // Reset stability while generating
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
- // Fallback: If it never showed "Generating/Cancel", but text changed and is stable
286
- if (textChanged) {
287
- if (currentText === lastText) {
288
- stableCount++;
289
- if (stableCount >= stableThreshold) {
290
- return; // Text has been stable for 2 seconds -> DONE
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
- else {
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 = currentText;
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('Timeout waiting for Antigravity reply');
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, bridge) {
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, cdp ?? undefined);
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
+ });