@jackwener/opencli 1.7.1 → 1.7.3

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 (122) hide show
  1. package/README.md +5 -2
  2. package/README.zh-CN.md +6 -3
  3. package/cli-manifest.json +1085 -73
  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/feed.js +202 -48
  9. package/clis/binance/asks.js +21 -0
  10. package/clis/binance/commands.test.js +70 -0
  11. package/clis/binance/depth.js +21 -0
  12. package/clis/binance/gainers.js +22 -0
  13. package/clis/binance/klines.js +21 -0
  14. package/clis/binance/losers.js +22 -0
  15. package/clis/binance/pairs.js +21 -0
  16. package/clis/binance/price.js +18 -0
  17. package/clis/binance/prices.js +19 -0
  18. package/clis/binance/ticker.js +21 -0
  19. package/clis/binance/top.js +21 -0
  20. package/clis/binance/trades.js +20 -0
  21. package/clis/boss/utils.js +2 -1
  22. package/clis/chatgpt/image.js +97 -0
  23. package/clis/chatgpt/utils.js +297 -0
  24. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  25. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  26. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  27. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  28. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  29. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  30. package/clis/discord-app/delete.js +114 -0
  31. package/clis/douban/utils.js +29 -2
  32. package/clis/douban/utils.test.js +121 -1
  33. package/clis/ke/chengjiao.js +77 -0
  34. package/clis/ke/ershoufang.js +100 -0
  35. package/clis/ke/utils.js +104 -0
  36. package/clis/ke/xiaoqu.js +77 -0
  37. package/clis/ke/zufang.js +94 -0
  38. package/clis/maimai/search-talents.js +172 -0
  39. package/clis/mubu/doc.js +40 -0
  40. package/clis/mubu/docs.js +43 -0
  41. package/clis/mubu/notes.js +244 -0
  42. package/clis/mubu/recent.js +27 -0
  43. package/clis/mubu/search.js +62 -0
  44. package/clis/mubu/utils.js +304 -0
  45. package/clis/reuters/search.js +1 -1
  46. package/clis/twitter/lists-parser.js +77 -0
  47. package/clis/twitter/lists.d.ts +5 -0
  48. package/clis/twitter/lists.js +62 -0
  49. package/clis/twitter/lists.test.js +50 -0
  50. package/clis/weibo/feed.js +18 -5
  51. package/clis/xiaohongshu/comments.js +18 -6
  52. package/clis/xiaohongshu/comments.test.js +36 -0
  53. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  54. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  55. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  56. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  57. package/clis/xiaohongshu/creator-notes.js +1 -0
  58. package/clis/xiaohongshu/creator-profile.js +1 -0
  59. package/clis/xiaohongshu/creator-stats.js +1 -0
  60. package/clis/xiaohongshu/download.js +12 -0
  61. package/clis/xiaohongshu/download.test.js +30 -0
  62. package/clis/xiaohongshu/navigation.test.js +34 -0
  63. package/clis/xiaohongshu/note.js +14 -5
  64. package/clis/xiaohongshu/note.test.js +28 -0
  65. package/clis/xiaohongshu/publish.js +1 -0
  66. package/clis/xiaohongshu/search.js +1 -0
  67. package/clis/xiaohongshu/user.js +1 -0
  68. package/clis/yahoo-finance/quote.js +1 -1
  69. package/clis/zsxq/topic.js +5 -3
  70. package/clis/zsxq/topic.test.js +4 -3
  71. package/clis/zsxq/utils.js +1 -1
  72. package/dist/src/browser/base-page.d.ts +9 -0
  73. package/dist/src/browser/base-page.js +19 -0
  74. package/dist/src/browser/cdp.js +10 -2
  75. package/dist/src/browser/daemon-client.d.ts +1 -0
  76. package/dist/src/cli.js +112 -2
  77. package/dist/src/daemon.js +5 -0
  78. package/dist/src/discovery.d.ts +5 -2
  79. package/dist/src/discovery.js +7 -35
  80. package/dist/src/doctor.d.ts +1 -0
  81. package/dist/src/doctor.js +51 -2
  82. package/dist/src/electron-apps.js +1 -1
  83. package/dist/src/engine.test.js +29 -1
  84. package/dist/src/errors.d.ts +1 -0
  85. package/dist/src/errors.js +13 -0
  86. package/dist/src/execution.js +36 -9
  87. package/dist/src/execution.test.js +23 -0
  88. package/dist/src/logger.d.ts +2 -2
  89. package/dist/src/logger.js +4 -9
  90. package/dist/src/main.js +6 -5
  91. package/dist/src/registry.js +3 -4
  92. package/dist/src/types.d.ts +2 -0
  93. package/dist/src/update-check.d.ts +14 -0
  94. package/dist/src/update-check.js +48 -3
  95. package/dist/src/update-check.test.js +31 -0
  96. package/package.json +3 -3
  97. package/scripts/fetch-adapters.js +92 -34
  98. package/dist/src/clis/binance/asks.js +0 -20
  99. package/dist/src/clis/binance/commands.test.d.ts +0 -3
  100. package/dist/src/clis/binance/commands.test.js +0 -58
  101. package/dist/src/clis/binance/depth.d.ts +0 -1
  102. package/dist/src/clis/binance/depth.js +0 -20
  103. package/dist/src/clis/binance/gainers.d.ts +0 -1
  104. package/dist/src/clis/binance/gainers.js +0 -21
  105. package/dist/src/clis/binance/klines.d.ts +0 -1
  106. package/dist/src/clis/binance/klines.js +0 -20
  107. package/dist/src/clis/binance/losers.d.ts +0 -1
  108. package/dist/src/clis/binance/losers.js +0 -21
  109. package/dist/src/clis/binance/pairs.d.ts +0 -1
  110. package/dist/src/clis/binance/pairs.js +0 -20
  111. package/dist/src/clis/binance/price.d.ts +0 -1
  112. package/dist/src/clis/binance/price.js +0 -17
  113. package/dist/src/clis/binance/prices.d.ts +0 -1
  114. package/dist/src/clis/binance/prices.js +0 -18
  115. package/dist/src/clis/binance/ticker.d.ts +0 -1
  116. package/dist/src/clis/binance/ticker.js +0 -20
  117. package/dist/src/clis/binance/top.d.ts +0 -1
  118. package/dist/src/clis/binance/top.js +0 -20
  119. package/dist/src/clis/binance/trades.d.ts +0 -1
  120. package/dist/src/clis/binance/trades.js +0 -19
  121. /package/clis/{chatgpt → chatgpt-app}/ax.js +0 -0
  122. /package/dist/src/{clis/binance/asks.d.ts → update-check.test.d.ts} +0 -0
@@ -0,0 +1,97 @@
1
+ import * as os from 'node:os';
2
+ import * as path from 'node:path';
3
+ import { cli, Strategy } from '@jackwener/opencli/registry';
4
+ import { saveBase64ToFile } from '@jackwener/opencli/utils';
5
+ import { getChatGPTVisibleImageUrls, sendChatGPTMessage, waitForChatGPTImages, getChatGPTImageAssets } from './utils.js';
6
+
7
+ const CHATGPT_DOMAIN = 'chatgpt.com';
8
+
9
+ function extFromMime(mime) {
10
+ if (mime.includes('png')) return '.png';
11
+ if (mime.includes('webp')) return '.webp';
12
+ if (mime.includes('gif')) return '.gif';
13
+ return '.jpg';
14
+ }
15
+
16
+ function normalizeBooleanFlag(value) {
17
+ if (typeof value === 'boolean') return value;
18
+ const normalized = String(value ?? '').trim().toLowerCase();
19
+ return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
20
+ }
21
+
22
+ function displayPath(filePath) {
23
+ const home = os.homedir();
24
+ return filePath.startsWith(home) ? `~${filePath.slice(home.length)}` : filePath;
25
+ }
26
+
27
+ async function currentChatGPTLink(page) {
28
+ const url = await page.evaluate('window.location.href').catch(() => '');
29
+ return typeof url === 'string' && url ? url : 'https://chatgpt.com';
30
+ }
31
+
32
+ export const imageCommand = cli({
33
+ site: 'chatgpt',
34
+ name: 'image',
35
+ description: 'Generate images with ChatGPT web and save them locally',
36
+ domain: CHATGPT_DOMAIN,
37
+ strategy: Strategy.COOKIE,
38
+ browser: true,
39
+ navigateBefore: false,
40
+ defaultFormat: 'plain',
41
+ timeoutSeconds: 240,
42
+ args: [
43
+ { name: 'prompt', positional: true, required: true, help: 'Image prompt to send to ChatGPT' },
44
+ { name: 'op', default: path.join(os.homedir(), 'Pictures', 'chatgpt'), help: 'Output directory' },
45
+ { name: 'sd', type: 'boolean', default: false, help: 'Skip download shorthand; only show ChatGPT link' },
46
+ ],
47
+ columns: ['status', 'file', 'link'],
48
+ func: async (page, kwargs) => {
49
+ const prompt = kwargs.prompt;
50
+ const outputDir = kwargs.op || path.join(os.homedir(), 'Pictures', 'chatgpt');
51
+ const skipDownloadRaw = kwargs.sd;
52
+ const skipDownload = skipDownloadRaw === '' || skipDownloadRaw === true || normalizeBooleanFlag(skipDownloadRaw);
53
+ const timeout = 120;
54
+
55
+ // Navigate to chatgpt.com/new with full reload to clear React sidebar state
56
+ await page.goto(`https://${CHATGPT_DOMAIN}/new`, { settleMs: 2000 });
57
+
58
+ const beforeUrls = await getChatGPTVisibleImageUrls(page);
59
+
60
+ // Send the image generation prompt - must be explicit
61
+ const sent = await sendChatGPTMessage(page, `Generate an image of: ${prompt}`);
62
+ if (!sent) {
63
+ return [{ status: '⚠️ send-failed', file: '📁 -', link: `🔗 ${await currentChatGPTLink(page)}` }];
64
+ }
65
+
66
+ // Wait for response and images
67
+ const urls = await waitForChatGPTImages(page, beforeUrls, timeout);
68
+ const link = await currentChatGPTLink(page);
69
+
70
+ if (!urls.length) {
71
+ return [{ status: '⚠️ no-images', file: '📁 -', link: `🔗 ${link}` }];
72
+ }
73
+
74
+ if (skipDownload) {
75
+ return [{ status: '🎨 generated', file: '📁 -', link: `🔗 ${link}` }];
76
+ }
77
+
78
+ // Export and save images
79
+ const assets = await getChatGPTImageAssets(page, urls);
80
+ if (!assets.length) {
81
+ return [{ status: '⚠️ export-failed', file: '📁 -', link: `🔗 ${link}` }];
82
+ }
83
+
84
+ const stamp = Date.now();
85
+ const results = [];
86
+ for (let index = 0; index < assets.length; index += 1) {
87
+ const asset = assets[index];
88
+ const base64 = asset.dataUrl.replace(/^data:[^;]+;base64,/, '');
89
+ const suffix = assets.length > 1 ? `_${index + 1}` : '';
90
+ const ext = extFromMime(asset.mimeType);
91
+ const filePath = path.join(outputDir, `chatgpt_${stamp}${suffix}${ext}`);
92
+ await saveBase64ToFile(base64, filePath);
93
+ results.push({ status: '✅ saved', file: `📁 ${displayPath(filePath)}`, link: `🔗 ${link}` });
94
+ }
95
+ return results;
96
+ },
97
+ });
@@ -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',
@@ -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
+ };
@@ -293,17 +293,42 @@ export async function loadDoubanMovieHot(page, limit) {
293
293
  `);
294
294
  return Array.isArray(data) ? data : [];
295
295
  }
296
+ export function inferDoubanSearchResultType(searchType, item = {}) {
297
+ const fallbackType = String(searchType || '').trim() || 'movie';
298
+ if (fallbackType !== 'movie') {
299
+ return fallbackType;
300
+ }
301
+ const moreUrl = String(item.moreUrl || item.more_url || '').trim();
302
+ const isTv = moreUrl.match(/is_tv:\s*['"]?([01])['"]?/)?.[1] || '';
303
+ if (isTv === '1') {
304
+ return 'tvshow';
305
+ }
306
+ const labels = Array.isArray(item.labels)
307
+ ? item.labels
308
+ .map((label) => typeof label === 'string' ? label.trim() : String(label?.text || '').trim())
309
+ .filter(Boolean)
310
+ : [];
311
+ return labels.includes('剧集') ? 'tvshow' : fallbackType;
312
+ }
296
313
  export async function searchDouban(page, type, keyword, limit) {
297
314
  const safeLimit = clampLimit(limit);
298
315
  await page.goto(`https://search.douban.com/${encodeURIComponent(type)}/subject_search?search_text=${encodeURIComponent(keyword)}`);
299
316
  await page.wait(2);
300
317
  await ensureDoubanReady(page);
318
+ const inferDoubanSearchResultTypeSource = inferDoubanSearchResultType.toString();
301
319
  const data = await page.evaluate(`
302
320
  (async () => {
303
321
  const type = ${JSON.stringify(type)};
322
+ const inferDoubanSearchResultType = ${inferDoubanSearchResultTypeSource};
304
323
  const normalize = (value) => (value || '').replace(/\\s+/g, ' ').trim();
305
324
  const seen = new Set();
306
325
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
326
+ const rawItems = Array.isArray(window.__DATA__?.items) ? window.__DATA__.items : [];
327
+ const rawItemsById = new Map(
328
+ rawItems
329
+ .map((item) => [String(item?.id || '').trim(), item])
330
+ .filter(([id]) => id),
331
+ );
307
332
 
308
333
  for (let i = 0; i < 20; i += 1) {
309
334
  if (document.querySelector('.item-root .title-text, .item-root .title a')) break;
@@ -321,14 +346,16 @@ export async function searchDouban(page, type, keyword, limit) {
321
346
  if (!url.startsWith('http')) url = 'https://search.douban.com' + url;
322
347
  if (!url.includes('/subject/') || seen.has(url)) continue;
323
348
  seen.add(url);
349
+ const id = url.match(/subject\\/(\\d+)/)?.[1] || '';
350
+ const rawItem = rawItemsById.get(id) || {};
324
351
  const ratingText = normalize(el.querySelector('.rating_nums')?.textContent);
325
352
  const abstract = normalize(
326
353
  el.querySelector('.meta.abstract, .meta, .abstract, p')?.textContent,
327
354
  );
328
355
  results.push({
329
356
  rank: results.length + 1,
330
- id: url.match(/subject\\/(\\d+)/)?.[1] || '',
331
- type,
357
+ id,
358
+ type: inferDoubanSearchResultType(type, rawItem),
332
359
  title,
333
360
  rating: ratingText.includes('.') ? parseFloat(ratingText) : 0,
334
361
  abstract: abstract.slice(0, 100) + (abstract.length > 100 ? '...' : ''),