@jackwener/opencli 1.7.2 → 1.7.4

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 (144) hide show
  1. package/README.md +18 -15
  2. package/README.zh-CN.md +31 -15
  3. package/cli-manifest.json +1265 -101
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/favorite.js +18 -13
  9. package/clis/bilibili/feed.js +202 -48
  10. package/clis/binance/depth.js +3 -4
  11. package/clis/boss/utils.js +2 -2
  12. package/clis/chatgpt/image.js +97 -0
  13. package/clis/chatgpt/utils.js +297 -0
  14. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
  16. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  18. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  19. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  20. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  21. package/clis/discord-app/delete.js +114 -0
  22. package/clis/douban/search.js +1 -0
  23. package/clis/douban/search.test.js +11 -0
  24. package/clis/douban/subject.js +20 -93
  25. package/clis/douban/subject.test.js +11 -0
  26. package/clis/douban/utils.js +279 -10
  27. package/clis/douban/utils.test.js +296 -1
  28. package/clis/doubao/utils.js +319 -130
  29. package/clis/doubao/utils.test.js +241 -2
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/grok/image.test.ts +107 -0
  33. package/clis/grok/image.ts +356 -0
  34. package/clis/ke/chengjiao.js +77 -0
  35. package/clis/ke/ershoufang.js +100 -0
  36. package/clis/ke/utils.js +104 -0
  37. package/clis/ke/xiaoqu.js +77 -0
  38. package/clis/ke/zufang.js +94 -0
  39. package/clis/maimai/search-talents.js +172 -0
  40. package/clis/mubu/doc.js +40 -0
  41. package/clis/mubu/docs.js +43 -0
  42. package/clis/mubu/notes.js +244 -0
  43. package/clis/mubu/recent.js +27 -0
  44. package/clis/mubu/search.js +62 -0
  45. package/clis/mubu/utils.js +304 -0
  46. package/clis/reuters/search.js +1 -1
  47. package/clis/tdx/hot-rank.js +47 -0
  48. package/clis/tdx/hot-rank.test.js +59 -0
  49. package/clis/ths/hot-rank.js +49 -0
  50. package/clis/ths/hot-rank.test.js +64 -0
  51. package/clis/twitter/bookmarks.js +2 -1
  52. package/clis/uiverse/_shared.js +368 -0
  53. package/clis/uiverse/_shared.test.js +55 -0
  54. package/clis/uiverse/code.js +47 -0
  55. package/clis/uiverse/preview.js +71 -0
  56. package/clis/xiaohongshu/comments.js +20 -8
  57. package/clis/xiaohongshu/comments.test.js +69 -12
  58. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  59. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  60. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  61. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  62. package/clis/xiaohongshu/creator-notes.js +1 -0
  63. package/clis/xiaohongshu/creator-profile.js +1 -0
  64. package/clis/xiaohongshu/creator-stats.js +1 -0
  65. package/clis/xiaohongshu/download.js +18 -7
  66. package/clis/xiaohongshu/download.test.js +42 -0
  67. package/clis/xiaohongshu/navigation.test.js +34 -0
  68. package/clis/xiaohongshu/note-helpers.js +46 -12
  69. package/clis/xiaohongshu/note.js +17 -10
  70. package/clis/xiaohongshu/note.test.js +66 -11
  71. package/clis/xiaohongshu/publish.js +1 -0
  72. package/clis/xiaohongshu/search.js +1 -0
  73. package/clis/xiaohongshu/user.js +1 -0
  74. package/clis/xiaoyuzhou/auth.js +303 -0
  75. package/clis/xiaoyuzhou/auth.test.js +124 -0
  76. package/clis/xiaoyuzhou/download.js +49 -0
  77. package/clis/xiaoyuzhou/download.test.js +125 -0
  78. package/clis/xiaoyuzhou/transcript.js +76 -0
  79. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  80. package/clis/yahoo-finance/quote.js +1 -1
  81. package/clis/youtube/feed.js +120 -0
  82. package/clis/youtube/history.js +118 -0
  83. package/clis/youtube/like.js +62 -0
  84. package/clis/youtube/playlist.js +97 -0
  85. package/clis/youtube/subscribe.js +71 -0
  86. package/clis/youtube/subscriptions.js +57 -0
  87. package/clis/youtube/unlike.js +62 -0
  88. package/clis/youtube/unsubscribe.js +71 -0
  89. package/clis/youtube/utils.js +122 -0
  90. package/clis/youtube/utils.test.js +32 -1
  91. package/clis/youtube/watch-later.js +76 -0
  92. package/dist/src/browser/base-page.d.ts +9 -0
  93. package/dist/src/browser/base-page.js +44 -5
  94. package/dist/src/browser/bridge.d.ts +2 -0
  95. package/dist/src/browser/bridge.js +51 -14
  96. package/dist/src/browser/cdp.js +11 -2
  97. package/dist/src/browser/daemon-client.d.ts +2 -0
  98. package/dist/src/browser/dom-snapshot.js +13 -1
  99. package/dist/src/browser/page.d.ts +4 -1
  100. package/dist/src/browser/page.js +48 -8
  101. package/dist/src/browser/page.test.js +61 -1
  102. package/dist/src/browser/target-errors.d.ts +23 -0
  103. package/dist/src/browser/target-errors.js +29 -0
  104. package/dist/src/browser/target-errors.test.d.ts +1 -0
  105. package/dist/src/browser/target-errors.test.js +61 -0
  106. package/dist/src/browser/target-resolver.d.ts +57 -0
  107. package/dist/src/browser/target-resolver.js +298 -0
  108. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  109. package/dist/src/browser/target-resolver.test.js +43 -0
  110. package/dist/src/browser.test.js +38 -1
  111. package/dist/src/cli.js +45 -35
  112. package/dist/src/commands/daemon.d.ts +4 -2
  113. package/dist/src/commands/daemon.js +22 -2
  114. package/dist/src/commands/daemon.test.js +65 -2
  115. package/dist/src/daemon.js +7 -0
  116. package/dist/src/doctor.d.ts +2 -0
  117. package/dist/src/doctor.js +82 -10
  118. package/dist/src/doctor.test.js +28 -12
  119. package/dist/src/electron-apps.js +1 -1
  120. package/dist/src/errors.d.ts +1 -0
  121. package/dist/src/errors.js +13 -0
  122. package/dist/src/execution.js +36 -9
  123. package/dist/src/execution.test.js +23 -0
  124. package/dist/src/external-clis.yaml +2 -2
  125. package/dist/src/logger.d.ts +2 -2
  126. package/dist/src/logger.js +3 -8
  127. package/dist/src/output.js +1 -5
  128. package/dist/src/output.test.js +0 -21
  129. package/dist/src/pipeline/steps/transform.js +1 -1
  130. package/dist/src/pipeline/template.d.ts +1 -0
  131. package/dist/src/pipeline/template.js +11 -3
  132. package/dist/src/pipeline/template.test.js +3 -0
  133. package/dist/src/pipeline/transform.test.js +14 -0
  134. package/dist/src/plugin.d.ts +7 -1
  135. package/dist/src/plugin.js +23 -1
  136. package/dist/src/plugin.test.js +15 -1
  137. package/dist/src/registry.js +3 -4
  138. package/dist/src/types.d.ts +3 -1
  139. package/dist/src/update-check.d.ts +14 -0
  140. package/dist/src/update-check.js +48 -3
  141. package/dist/src/update-check.test.d.ts +1 -0
  142. package/dist/src/update-check.test.js +31 -0
  143. package/package.json +1 -1
  144. package/scripts/fetch-adapters.js +35 -8
@@ -0,0 +1,297 @@
1
+ /**
2
+ * ChatGPT web browser automation helpers for image generation.
3
+ * Cross-platform: works on Linux/macOS/Windows via OpenCLI's CDP browser automation.
4
+ */
5
+
6
+ export const CHATGPT_DOMAIN = 'chatgpt.com';
7
+ export const CHATGPT_URL = 'https://chatgpt.com';
8
+
9
+ // Selectors
10
+ const COMPOSER_SELECTOR = '[aria-label="Chat with ChatGPT"]';
11
+ const SEND_BTN_SELECTOR = 'button[aria-label="Send prompt"]';
12
+
13
+ function buildComposerLocatorScript() {
14
+ const selectorsJson = JSON.stringify([COMPOSER_SELECTOR]);
15
+ const markerAttr = 'data-opencli-chatgpt-composer';
16
+ return `
17
+ const isVisible = (el) => {
18
+ if (!(el instanceof HTMLElement)) return false;
19
+ const style = window.getComputedStyle(el);
20
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
21
+ const rect = el.getBoundingClientRect();
22
+ return rect.width > 0 && rect.height > 0;
23
+ };
24
+
25
+ const markerAttr = ${JSON.stringify(markerAttr)};
26
+ const clearMarkers = (active) => {
27
+ document.querySelectorAll('[' + markerAttr + ']').forEach(node => {
28
+ if (node !== active) node.removeAttribute(markerAttr);
29
+ });
30
+ };
31
+
32
+ const findComposer = () => {
33
+ const marked = document.querySelector('[' + markerAttr + '="1"]');
34
+ if (marked instanceof HTMLElement && isVisible(marked)) return marked;
35
+
36
+ for (const selector of ${JSON.stringify([COMPOSER_SELECTOR])}) {
37
+ const node = Array.from(document.querySelectorAll(selector)).find(c => c instanceof HTMLElement && isVisible(c));
38
+ if (node instanceof HTMLElement) {
39
+ node.setAttribute(markerAttr, '1');
40
+ return node;
41
+ }
42
+ }
43
+ return null;
44
+ };
45
+
46
+ findComposer.toString = () => 'findComposer';
47
+ return { findComposer, markerAttr };
48
+ `;
49
+ }
50
+
51
+ /**
52
+ * Send a message to the ChatGPT composer and submit it.
53
+ * Returns true if the message was sent successfully.
54
+ */
55
+ export async function sendChatGPTMessage(page, text) {
56
+ // Close sidebar if open (it can cover the chat composer)
57
+ await page.evaluate(`
58
+ (() => {
59
+ const closeBtn = Array.from(document.querySelectorAll('button')).find(b => b.getAttribute('aria-label') === 'Close sidebar');
60
+ if (closeBtn) closeBtn.click();
61
+ })()
62
+ `);
63
+ await page.wait(0.5);
64
+
65
+ // Wait for composer to be ready and use Playwright's type()
66
+ await page.wait(1.5);
67
+
68
+ const typeResult = await page.evaluate(`
69
+ (() => {
70
+ ${buildComposerLocatorScript()}
71
+ const composer = findComposer();
72
+ if (!composer) return false;
73
+ composer.focus();
74
+ composer.textContent = '';
75
+ return true;
76
+ })()
77
+ `);
78
+
79
+ if (!typeResult) return false;
80
+
81
+ // Use page.type() which is Playwright's native method
82
+ try {
83
+ if (page.nativeType) {
84
+ await page.nativeType(text);
85
+ } else {
86
+ throw new Error('nativeType unavailable');
87
+ }
88
+ } catch (e) {
89
+ // Fallback: use execCommand
90
+ await page.evaluate(`
91
+ (() => {
92
+ const composer = document.querySelector('[aria-label="Chat with ChatGPT"]');
93
+ if (!composer) return;
94
+ composer.focus();
95
+ document.execCommand('insertText', false, ${JSON.stringify(text)});
96
+ })()
97
+ `);
98
+ }
99
+
100
+ // Wait for send button to appear (it only shows when there's text)
101
+ await page.wait(1.5);
102
+
103
+ // Click send button
104
+ const sent = await page.evaluate(`
105
+ (() => {
106
+ const btns = Array.from(document.querySelectorAll('button'));
107
+ const sendBtn = btns.find(b => b.getAttribute('aria-label') === 'Send prompt');
108
+ return { sendBtnFound: !!sendBtn };
109
+ })()
110
+ `);
111
+
112
+ if (!sent || !sent.sendBtnFound) {
113
+ return false;
114
+ }
115
+
116
+ await page.evaluate(`
117
+ (() => {
118
+ const sendBtn = Array.from(document.querySelectorAll('button')).find(b => b.getAttribute('aria-label') === 'Send prompt');
119
+ if (sendBtn) sendBtn.click();
120
+ })()
121
+ `);
122
+ return true;
123
+ }
124
+
125
+ /**
126
+ * Check if ChatGPT is still generating a response.
127
+ */
128
+ export async function isGenerating(page) {
129
+ return await page.evaluate(`
130
+ (() => {
131
+ return Array.from(document.querySelectorAll('button')).some(b => {
132
+ const label = b.getAttribute('aria-label') || '';
133
+ return label === 'Stop generating' || label.includes('Thinking');
134
+ });
135
+ })()
136
+ `);
137
+ }
138
+
139
+ /**
140
+ * Get visible image URLs from the ChatGPT page (excluding profile/avatar images).
141
+ */
142
+ export async function getChatGPTVisibleImageUrls(page) {
143
+ return await page.evaluate(`
144
+ (() => {
145
+ const isVisible = (el) => {
146
+ if (!(el instanceof HTMLElement)) return false;
147
+ const style = window.getComputedStyle(el);
148
+ if (style.display === 'none' || style.visibility === 'hidden') return false;
149
+ const rect = el.getBoundingClientRect();
150
+ return rect.width > 32 && rect.height > 32;
151
+ };
152
+
153
+ const imgs = Array.from(document.querySelectorAll('img')).filter(img =>
154
+ img instanceof HTMLImageElement && isVisible(img)
155
+ );
156
+
157
+ const urls = [];
158
+ const seen = new Set();
159
+
160
+ for (const img of imgs) {
161
+ const src = img.currentSrc || img.src || '';
162
+ const alt = (img.getAttribute('alt') || '').toLowerCase();
163
+ const cls = (img.className || '').toLowerCase();
164
+ const width = img.naturalWidth || img.width || 0;
165
+ const height = img.naturalHeight || img.height || 0;
166
+
167
+ if (!src) continue;
168
+ if (alt.includes('avatar') || alt.includes('profile') || alt.includes('logo') || alt.includes('icon')) continue;
169
+ if (cls.includes('avatar') || cls.includes('profile') || cls.includes('icon')) continue;
170
+ if (width < 128 && height < 128) continue;
171
+ if (seen.has(src)) continue;
172
+
173
+ seen.add(src);
174
+ urls.push(src);
175
+ }
176
+ return urls;
177
+ })()
178
+ `);
179
+ }
180
+
181
+ /**
182
+ * Wait for new images to appear after sending a prompt.
183
+ */
184
+ export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds) {
185
+ const beforeSet = new Set(beforeUrls);
186
+ const pollIntervalSeconds = 3;
187
+ const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
188
+ let lastUrls = [];
189
+ let stableCount = 0;
190
+
191
+ for (let i = 0; i < maxPolls; i++) {
192
+ await page.wait(i === 0 ? 3 : pollIntervalSeconds);
193
+
194
+ // Check if still generating
195
+ const generating = await isGenerating(page);
196
+ if (generating) continue;
197
+
198
+ const urls = (await getChatGPTVisibleImageUrls(page)).filter(url => !beforeSet.has(url));
199
+ if (urls.length === 0) continue;
200
+
201
+ const key = urls.join('\n');
202
+ const prevKey = lastUrls.join('\n');
203
+ if (key === prevKey) {
204
+ stableCount += 1;
205
+ } else {
206
+ lastUrls = urls;
207
+ stableCount = 1;
208
+ }
209
+
210
+ if (stableCount >= 2 || i === maxPolls - 1) {
211
+ return lastUrls;
212
+ }
213
+ }
214
+ return lastUrls;
215
+ }
216
+
217
+ /**
218
+ * Export images by URL: fetch from ChatGPT backend API and convert to base64 data URLs.
219
+ */
220
+ export async function getChatGPTImageAssets(page, urls) {
221
+ const urlsJson = JSON.stringify(urls);
222
+ return await page.evaluate(`
223
+ (async (targetUrls) => {
224
+ const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
225
+ const reader = new FileReader();
226
+ reader.onloadend = () => resolve(String(reader.result || ''));
227
+ reader.onerror = () => reject(new Error('Failed to read blob'));
228
+ reader.readAsDataURL(blob);
229
+ });
230
+
231
+ const inferMime = (value, fallbackUrl) => {
232
+ if (value) return value;
233
+ const lower = String(fallbackUrl || '').toLowerCase();
234
+ if (lower.includes('.png')) return 'image/png';
235
+ if (lower.includes('.webp')) return 'image/webp';
236
+ if (lower.includes('.gif')) return 'image/gif';
237
+ return 'image/jpeg';
238
+ };
239
+
240
+ const results = [];
241
+
242
+ for (const targetUrl of targetUrls) {
243
+ let dataUrl = '';
244
+ let mimeType = 'image/jpeg';
245
+ let width = 0;
246
+ let height = 0;
247
+
248
+ // Try to find the img element for size info
249
+ const img = Array.from(document.querySelectorAll('img')).find(el =>
250
+ (el.currentSrc || el.src || '') === targetUrl
251
+ );
252
+ if (img) {
253
+ width = img.naturalWidth || img.width || 0;
254
+ height = img.naturalHeight || img.height || 0;
255
+ }
256
+
257
+ try {
258
+ if (String(targetUrl).startsWith('data:')) {
259
+ dataUrl = String(targetUrl);
260
+ mimeType = (String(targetUrl).match(/^data:([^;]+);/i) || [])[1] || 'image/png';
261
+ } else {
262
+ // Try to fetch via CORS from the page's origin
263
+ const res = await fetch(targetUrl, { credentials: 'include' });
264
+ if (res.ok) {
265
+ const blob = await res.blob();
266
+ mimeType = inferMime(blob.type, targetUrl);
267
+ dataUrl = await blobToDataUrl(blob);
268
+ }
269
+ }
270
+ } catch (e) {
271
+ // If fetch fails (CORS), try canvas approach via img element
272
+ }
273
+
274
+ // Fallback: draw img to canvas
275
+ if (!dataUrl && img && img instanceof HTMLImageElement) {
276
+ try {
277
+ const canvas = document.createElement('canvas');
278
+ canvas.width = img.naturalWidth || img.width || 512;
279
+ canvas.height = img.naturalHeight || img.height || 512;
280
+ const ctx = canvas.getContext('2d');
281
+ if (ctx) {
282
+ ctx.drawImage(img, 0, 0);
283
+ dataUrl = canvas.toDataURL('image/png');
284
+ mimeType = 'image/png';
285
+ }
286
+ } catch (e) { }
287
+ }
288
+
289
+ if (dataUrl) {
290
+ results.push({ url: String(targetUrl), dataUrl, mimeType, width, height });
291
+ }
292
+ }
293
+
294
+ return results;
295
+ })(${urlsJson})
296
+ `, urls);
297
+ }
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { ConfigError } from '@jackwener/opencli/errors';
4
4
  import { activateChatGPT, getVisibleChatMessages, selectModel, MODEL_CHOICES, isGenerating } from './ax.js';
5
5
  export const askCommand = cli({
6
- site: 'chatgpt',
6
+ site: 'chatgpt-app',
7
7
  name: 'ask',
8
8
  description: 'Send a prompt and wait for the AI response (send + wait + read)',
9
9
  domain: 'localhost',
@@ -121,11 +121,14 @@ let args = CommandLine.arguments
121
121
  let target = args.count > 1 ? args[1] : ""
122
122
  let needsLegacy = args.count > 2 && args[2] == "legacy"
123
123
 
124
- // Step 1: Click the "Options" button to open the popover
125
- guard let optionsBtn = findByDesc(win, "Options") else {
124
+ // Step 1: Click the "Options" button to open the popover (support both English and Chinese UI)
125
+ var optionsBtn: AXUIElement? = nil
126
+ if let btn = findByDesc(win, "Options") { optionsBtn = btn }
127
+ else if let btn = findByDesc(win, "选项") { optionsBtn = btn }
128
+ guard let options = optionsBtn else {
126
129
  fputs("Could not find Options button\\n", stderr); exit(1)
127
130
  }
128
- press(optionsBtn)
131
+ press(options)
129
132
  Thread.sleep(forTimeInterval: 0.8)
130
133
 
131
134
  // Step 2: Find the popover that appeared, search ONLY within it
@@ -2,7 +2,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { ConfigError } from '@jackwener/opencli/errors';
3
3
  import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
4
4
  export const modelCommand = cli({
5
- site: 'chatgpt',
5
+ site: 'chatgpt-app',
6
6
  name: 'model',
7
7
  description: 'Switch ChatGPT Desktop model/mode (auto, instant, thinking, 5.2-instant, 5.2-thinking)',
8
8
  domain: 'localhost',
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
4
4
  export const newCommand = cli({
5
- site: 'chatgpt',
5
+ site: 'chatgpt-app',
6
6
  name: 'new',
7
7
  description: 'Open a new chat in ChatGPT Desktop App',
8
8
  domain: 'localhost',
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { CommandExecutionError, ConfigError, getErrorMessage } from '@jackwener/opencli/errors';
4
4
  import { getVisibleChatMessages } from './ax.js';
5
5
  export const readCommand = cli({
6
- site: 'chatgpt',
6
+ site: 'chatgpt-app',
7
7
  name: 'read',
8
8
  description: 'Read the last visible message from the focused ChatGPT Desktop window',
9
9
  domain: 'localhost',
@@ -3,7 +3,7 @@ import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { getErrorMessage } from '@jackwener/opencli/errors';
4
4
  import { activateChatGPT, selectModel, MODEL_CHOICES } from './ax.js';
5
5
  export const sendCommand = cli({
6
- site: 'chatgpt',
6
+ site: 'chatgpt-app',
7
7
  name: 'send',
8
8
  description: 'Send a message to the active ChatGPT Desktop App window',
9
9
  domain: 'localhost',
@@ -2,7 +2,7 @@ import { execSync } from 'node:child_process';
2
2
  import { cli, Strategy } from '@jackwener/opencli/registry';
3
3
  import { CommandExecutionError, ConfigError } from '@jackwener/opencli/errors';
4
4
  export const statusCommand = cli({
5
- site: 'chatgpt',
5
+ site: 'chatgpt-app',
6
6
  name: 'status',
7
7
  description: 'Check if ChatGPT Desktop App is running natively on macOS',
8
8
  domain: 'localhost',
@@ -0,0 +1,114 @@
1
+ import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { CommandExecutionError } from '@jackwener/opencli/errors';
3
+
4
+ function buildDeleteScript(messageId) {
5
+ return `(async () => {
6
+ try {
7
+ const messageId = ${JSON.stringify(messageId)};
8
+
9
+ // Find the message element by its ID attribute (format: chat-messages-{channelId}-{messageId})
10
+ const msgEl = document.querySelector('[id$="-' + messageId + '"]');
11
+ if (!msgEl) {
12
+ return { ok: false, message: 'Could not find a message with ID ' + messageId + ' in the current channel.' };
13
+ }
14
+
15
+ // Find the closest list item wrapper that Discord uses for messages
16
+ const listItem = msgEl.closest('[id^="chat-messages-"]') || msgEl;
17
+
18
+ // Hover over the message to reveal the action toolbar
19
+ listItem.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true }));
20
+ listItem.dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
21
+ await new Promise(r => setTimeout(r, 500));
22
+
23
+ // Look for the "More" button in the message toolbar
24
+ // Discord shows a toolbar with buttons when hovering over a message
25
+ const toolbar = listItem.querySelector('[class*="toolbar"]') ||
26
+ document.querySelector('[id^="message-actions-"]');
27
+ if (!toolbar) {
28
+ return { ok: false, message: 'Could not find the message action toolbar. Try scrolling so the message is fully visible.' };
29
+ }
30
+
31
+ const buttons = Array.from(toolbar.querySelectorAll('button, [role="button"], div[class*="button"]'));
32
+ const moreBtn = buttons.find(btn => {
33
+ const label = (btn.getAttribute('aria-label') || '').toLowerCase();
34
+ return label === 'more' || label.includes('more');
35
+ });
36
+ if (!moreBtn) {
37
+ return { ok: false, message: 'Could not find the "More" button on the message toolbar.' };
38
+ }
39
+
40
+ moreBtn.click();
41
+ await new Promise(r => setTimeout(r, 500));
42
+
43
+ // Find "Delete Message" in the context menu
44
+ const menuItems = Array.from(document.querySelectorAll('[role="menuitem"], [id*="message-actions"]'));
45
+ const deleteItem = menuItems.find(item => {
46
+ const text = (item.textContent || '').trim().toLowerCase();
47
+ return text.includes('delete message') || text === 'delete';
48
+ });
49
+
50
+ if (!deleteItem) {
51
+ // Close the menu by pressing Escape
52
+ document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
53
+ return { ok: false, message: 'No "Delete Message" option found. You may not have permission to delete this message.' };
54
+ }
55
+
56
+ deleteItem.click();
57
+ await new Promise(r => setTimeout(r, 500));
58
+
59
+ // Confirm deletion in the modal dialog
60
+ const confirmBtn = document.querySelector('[type="submit"], button[class*="colorRed"], button[class*="danger"]');
61
+ if (!confirmBtn) {
62
+ return { ok: false, message: 'Delete confirmation dialog did not appear.' };
63
+ }
64
+
65
+ confirmBtn.click();
66
+ return { ok: true, message: 'Message ' + messageId + ' deleted successfully.' };
67
+ } catch (e) {
68
+ return { ok: false, message: e.toString() };
69
+ }
70
+ })()`;
71
+ }
72
+
73
+ cli({
74
+ site: 'discord-app',
75
+ name: 'delete',
76
+ description: 'Delete a message by its ID in the active Discord channel',
77
+ domain: 'localhost',
78
+ strategy: Strategy.UI,
79
+ browser: true,
80
+ args: [
81
+ {
82
+ name: 'message_id',
83
+ type: 'string',
84
+ required: true,
85
+ positional: true,
86
+ help: 'The ID of the message to delete (visible via Developer Mode or the read command)',
87
+ },
88
+ ],
89
+ columns: ['status', 'message'],
90
+ func: async (page, kwargs) => {
91
+ if (!page)
92
+ throw new CommandExecutionError('Browser session required for discord-app delete');
93
+ const messageId = kwargs.message_id;
94
+ if (!/^\d+$/.test(messageId)) {
95
+ throw new CommandExecutionError(
96
+ `Invalid message ID: "${messageId}". A Discord message ID is a numeric snowflake (e.g. 1234567890123456789).`
97
+ );
98
+ }
99
+ // Wait a moment for the chat to be fully loaded
100
+ await page.wait(0.5);
101
+ const result = await page.evaluate(buildDeleteScript(messageId));
102
+ if (result.ok) {
103
+ await page.wait(1);
104
+ }
105
+ return [{
106
+ status: result.ok ? 'success' : 'failed',
107
+ message: result.message,
108
+ }];
109
+ },
110
+ });
111
+
112
+ export const __test__ = {
113
+ buildDeleteScript,
114
+ };
@@ -6,6 +6,7 @@ cli({
6
6
  description: '搜索豆瓣电影、图书或音乐',
7
7
  domain: 'search.douban.com',
8
8
  strategy: Strategy.COOKIE,
9
+ navigateBefore: false,
9
10
  args: [
10
11
  { name: 'type', default: 'movie', choices: ['movie', 'book', 'music'], help: '搜索类型(movie=电影, book=图书, music=音乐)' },
11
12
  { name: 'keyword', required: true, positional: true, help: '搜索关键词' },
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './search.js';
4
+
5
+ describe('douban search command', () => {
6
+ it('skips default pre-navigation because the adapter handles navigation itself', () => {
7
+ const command = getRegistry().get('douban/search');
8
+ expect(command).toBeDefined();
9
+ expect(command?.navigateBefore).toBe(false);
10
+ });
11
+ });
@@ -1,18 +1,35 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
+ import { loadDoubanSubjectDetail } from './utils.js';
3
+
2
4
  cli({
3
5
  site: 'douban',
4
6
  name: 'subject',
5
- description: '获取电影详情',
7
+ description: '获取豆瓣条目详情',
6
8
  domain: 'movie.douban.com',
7
9
  strategy: Strategy.COOKIE,
8
10
  browser: true,
11
+ navigateBefore: false,
9
12
  args: [
10
- { name: 'id', required: true, positional: true, help: '电影 ID' },
13
+ { name: 'id', required: true, positional: true, help: '豆瓣条目 ID' },
14
+ { name: 'type', default: 'movie', choices: ['movie', 'book'], help: '条目类型(movie=电影, book=图书)' },
11
15
  ],
12
16
  columns: [
13
17
  'id',
18
+ 'type',
14
19
  'title',
20
+ 'subtitle',
15
21
  'originalTitle',
22
+ 'authors',
23
+ 'translators',
24
+ 'publisher',
25
+ 'publishDate',
26
+ 'publishYear',
27
+ 'pageCount',
28
+ 'binding',
29
+ 'price',
30
+ 'series',
31
+ 'isbn10',
32
+ 'isbn13',
16
33
  'year',
17
34
  'rating',
18
35
  'ratingCount',
@@ -24,95 +41,5 @@ cli({
24
41
  'summary',
25
42
  'url',
26
43
  ],
27
- pipeline: [
28
- { navigate: 'https://movie.douban.com/subject/${{ args.id }}' },
29
- { evaluate: `(async () => {
30
- const id = '\${{ args.id }}';
31
-
32
- // Wait for page to load
33
- await new Promise(r => setTimeout(r, 2000));
34
-
35
- // Extract title - v:itemreviewed contains "中文名 OriginalName"
36
- const titleEl = document.querySelector('span[property="v:itemreviewed"]');
37
- const fullTitle = titleEl?.textContent?.trim() || '';
38
-
39
- // Split title and originalTitle
40
- // Douban format: "中文名 OriginalName" - split by first space that separates CJK from non-CJK
41
- let title = fullTitle;
42
- let originalTitle = '';
43
- const titleMatch = fullTitle.match(/^([\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef]+(?:\\s*[\\u4e00-\\u9fff\\u3000-\\u303f\\uff00-\\uffef·::!?]+)*)\\s+(.+)$/);
44
- if (titleMatch) {
45
- title = titleMatch[1].trim();
46
- originalTitle = titleMatch[2].trim();
47
- }
48
-
49
- // Extract year
50
- const yearEl = document.querySelector('.year');
51
- const year = yearEl?.textContent?.trim().replace(/[()()]/g, '') || '';
52
-
53
- // Extract rating
54
- const ratingEl = document.querySelector('strong[property="v:average"]');
55
- const rating = parseFloat(ratingEl?.textContent || '0');
56
-
57
- // Extract rating count
58
- const ratingCountEl = document.querySelector('span[property="v:votes"]');
59
- const ratingCount = parseInt(ratingCountEl?.textContent || '0', 10);
60
-
61
- // Extract genres
62
- const genreEls = document.querySelectorAll('span[property="v:genre"]');
63
- const genres = Array.from(genreEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');
64
-
65
- // Extract directors
66
- const directorEls = document.querySelectorAll('a[rel="v:directedBy"]');
67
- const directors = Array.from(directorEls).map(el => el.textContent?.trim()).filter(Boolean).join(',');
68
-
69
- // Extract casts
70
- const castEls = document.querySelectorAll('a[rel="v:starring"]');
71
- const casts = Array.from(castEls).slice(0, 5).map(el => el.textContent?.trim()).filter(Boolean);
72
-
73
- // Extract info section for country and duration
74
- const infoEl = document.querySelector('#info');
75
- const infoText = infoEl?.textContent || '';
76
-
77
- // Extract country/region from #info as list
78
- let country = [];
79
- const countryMatch = infoText.match(/制片国家\\/地区:\\s*([^\\n]+)/);
80
- if (countryMatch) {
81
- country = countryMatch[1].trim().split(/\\s*\\/\\s*/).filter(Boolean);
82
- }
83
-
84
- // Extract duration from #info as pure number in min
85
- const durationEl = document.querySelector('span[property="v:runtime"]');
86
- let durationRaw = durationEl?.textContent?.trim() || '';
87
- if (!durationRaw) {
88
- const durationMatch = infoText.match(/片长:\\s*([^\\n]+)/);
89
- if (durationMatch) {
90
- durationRaw = durationMatch[1].trim();
91
- }
92
- }
93
- const durationNumMatch = durationRaw.match(/(\\d+)/);
94
- const duration = durationNumMatch ? parseInt(durationNumMatch[1], 10) : null;
95
-
96
- // Extract summary
97
- const summaryEl = document.querySelector('span[property="v:summary"]');
98
- const summary = summaryEl?.textContent?.trim() || '';
99
-
100
- return [{
101
- id,
102
- title,
103
- originalTitle,
104
- year,
105
- rating,
106
- ratingCount,
107
- genres,
108
- directors,
109
- casts,
110
- country,
111
- duration,
112
- summary: summary.substring(0, 200),
113
- url: \`https://movie.douban.com/subject/\${id}\`
114
- }];
115
- })()
116
- ` },
117
- ],
44
+ func: async (page, args) => [await loadDoubanSubjectDetail(page, args.id, args.type)],
118
45
  });
@@ -0,0 +1,11 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './subject.js';
4
+
5
+ describe('douban subject command', () => {
6
+ it('skips default pre-navigation because the adapter handles subject navigation itself', () => {
7
+ const command = getRegistry().get('douban/subject');
8
+ expect(command).toBeDefined();
9
+ expect(command?.navigateBefore).toBe(false);
10
+ });
11
+ });