@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.
- package/README.md +5 -2
- package/README.zh-CN.md +6 -3
- package/cli-manifest.json +1085 -73
- package/clis/barchart/flow.js +1 -1
- package/clis/barchart/greeks.js +2 -2
- package/clis/barchart/options.js +2 -2
- package/clis/barchart/quote.js +1 -1
- package/clis/bilibili/feed.js +202 -48
- package/clis/binance/asks.js +21 -0
- package/clis/binance/commands.test.js +70 -0
- package/clis/binance/depth.js +21 -0
- package/clis/binance/gainers.js +22 -0
- package/clis/binance/klines.js +21 -0
- package/clis/binance/losers.js +22 -0
- package/clis/binance/pairs.js +21 -0
- package/clis/binance/price.js +18 -0
- package/clis/binance/prices.js +19 -0
- package/clis/binance/ticker.js +21 -0
- package/clis/binance/top.js +21 -0
- package/clis/binance/trades.js +20 -0
- package/clis/boss/utils.js +2 -1
- package/clis/chatgpt/image.js +97 -0
- package/clis/chatgpt/utils.js +297 -0
- package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
- package/clis/discord-app/delete.js +114 -0
- package/clis/douban/utils.js +29 -2
- package/clis/douban/utils.test.js +121 -1
- package/clis/ke/chengjiao.js +77 -0
- package/clis/ke/ershoufang.js +100 -0
- package/clis/ke/utils.js +104 -0
- package/clis/ke/xiaoqu.js +77 -0
- package/clis/ke/zufang.js +94 -0
- package/clis/maimai/search-talents.js +172 -0
- package/clis/mubu/doc.js +40 -0
- package/clis/mubu/docs.js +43 -0
- package/clis/mubu/notes.js +244 -0
- package/clis/mubu/recent.js +27 -0
- package/clis/mubu/search.js +62 -0
- package/clis/mubu/utils.js +304 -0
- package/clis/reuters/search.js +1 -1
- package/clis/twitter/lists-parser.js +77 -0
- package/clis/twitter/lists.d.ts +5 -0
- package/clis/twitter/lists.js +62 -0
- package/clis/twitter/lists.test.js +50 -0
- package/clis/weibo/feed.js +18 -5
- package/clis/xiaohongshu/comments.js +18 -6
- package/clis/xiaohongshu/comments.test.js +36 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -0
- package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
- package/clis/xiaohongshu/creator-notes-summary.js +4 -0
- package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
- package/clis/xiaohongshu/creator-notes.js +1 -0
- package/clis/xiaohongshu/creator-profile.js +1 -0
- package/clis/xiaohongshu/creator-stats.js +1 -0
- package/clis/xiaohongshu/download.js +12 -0
- package/clis/xiaohongshu/download.test.js +30 -0
- package/clis/xiaohongshu/navigation.test.js +34 -0
- package/clis/xiaohongshu/note.js +14 -5
- package/clis/xiaohongshu/note.test.js +28 -0
- package/clis/xiaohongshu/publish.js +1 -0
- package/clis/xiaohongshu/search.js +1 -0
- package/clis/xiaohongshu/user.js +1 -0
- package/clis/yahoo-finance/quote.js +1 -1
- package/clis/zsxq/topic.js +5 -3
- package/clis/zsxq/topic.test.js +4 -3
- package/clis/zsxq/utils.js +1 -1
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +19 -0
- package/dist/src/browser/cdp.js +10 -2
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/cli.js +112 -2
- package/dist/src/daemon.js +5 -0
- package/dist/src/discovery.d.ts +5 -2
- package/dist/src/discovery.js +7 -35
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +51 -2
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/engine.test.js +29 -1
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +13 -0
- package/dist/src/execution.js +36 -9
- package/dist/src/execution.test.js +23 -0
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +4 -9
- package/dist/src/main.js +6 -5
- package/dist/src/registry.js +3 -4
- package/dist/src/types.d.ts +2 -0
- package/dist/src/update-check.d.ts +14 -0
- package/dist/src/update-check.js +48 -3
- package/dist/src/update-check.test.js +31 -0
- package/package.json +3 -3
- package/scripts/fetch-adapters.js +92 -34
- package/dist/src/clis/binance/asks.js +0 -20
- package/dist/src/clis/binance/commands.test.d.ts +0 -3
- package/dist/src/clis/binance/commands.test.js +0 -58
- package/dist/src/clis/binance/depth.d.ts +0 -1
- package/dist/src/clis/binance/depth.js +0 -20
- package/dist/src/clis/binance/gainers.d.ts +0 -1
- package/dist/src/clis/binance/gainers.js +0 -21
- package/dist/src/clis/binance/klines.d.ts +0 -1
- package/dist/src/clis/binance/klines.js +0 -20
- package/dist/src/clis/binance/losers.d.ts +0 -1
- package/dist/src/clis/binance/losers.js +0 -21
- package/dist/src/clis/binance/pairs.d.ts +0 -1
- package/dist/src/clis/binance/pairs.js +0 -20
- package/dist/src/clis/binance/price.d.ts +0 -1
- package/dist/src/clis/binance/price.js +0 -17
- package/dist/src/clis/binance/prices.d.ts +0 -1
- package/dist/src/clis/binance/prices.js +0 -18
- package/dist/src/clis/binance/ticker.d.ts +0 -1
- package/dist/src/clis/binance/ticker.js +0 -20
- package/dist/src/clis/binance/top.d.ts +0 -1
- package/dist/src/clis/binance/top.js +0 -20
- package/dist/src/clis/binance/trades.d.ts +0 -1
- package/dist/src/clis/binance/trades.js +0 -19
- /package/clis/{chatgpt → chatgpt-app}/ax.js +0 -0
- /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
|
+
};
|
package/clis/douban/utils.js
CHANGED
|
@@ -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
|
|
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 ? '...' : ''),
|