From 1c5f889bead42526df330b2c8ba960a34a726d16 Mon Sep 17 00:00:00 2001 From: Wyckoff Date: Sat, 4 Apr 2026 15:14:57 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E6=8E=A8=E7=89=B9=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E5=9B=9E=E5=A4=8D=E5=9B=BE=E7=89=87=E8=83=BD=E5=8A=9B?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=9C=AC=E5=9C=B0=E8=B7=AF=E5=BE=84=E5=92=8C?= =?UTF-8?q?=E7=BD=91=E7=BB=9C=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/clis/twitter/reply.test.ts | 177 ++++++++++++++++++++ src/clis/twitter/reply.ts | 298 ++++++++++++++++++++++++++++----- 2 files changed, 436 insertions(+), 39 deletions(-) create mode 100644 src/clis/twitter/reply.test.ts diff --git a/src/clis/twitter/reply.test.ts b/src/clis/twitter/reply.test.ts new file mode 100644 index 00000000..c91723c7 --- /dev/null +++ b/src/clis/twitter/reply.test.ts @@ -0,0 +1,177 @@ +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; + +import { describe, expect, it, vi } from 'vitest'; + +import { getRegistry } from '../../registry.js'; +import type { IPage } from '../../types.js'; +import { __test__ } from './reply.js'; + +function createPageMock(evaluateResults: any[], overrides: Partial = {}): IPage { + const evaluate = vi.fn(); + for (const result of evaluateResults) { + evaluate.mockResolvedValueOnce(result); + } + + return { + goto: vi.fn().mockResolvedValue(undefined), + evaluate, + snapshot: vi.fn().mockResolvedValue(undefined), + click: vi.fn().mockResolvedValue(undefined), + typeText: vi.fn().mockResolvedValue(undefined), + pressKey: vi.fn().mockResolvedValue(undefined), + scrollTo: vi.fn().mockResolvedValue(undefined), + getFormState: vi.fn().mockResolvedValue({ forms: [], orphanFields: [] }), + wait: vi.fn().mockResolvedValue(undefined), + tabs: vi.fn().mockResolvedValue([]), + selectTab: vi.fn().mockResolvedValue(undefined), + networkRequests: vi.fn().mockResolvedValue([]), + consoleMessages: vi.fn().mockResolvedValue([]), + scroll: vi.fn().mockResolvedValue(undefined), + autoScroll: vi.fn().mockResolvedValue(undefined), + installInterceptor: vi.fn().mockResolvedValue(undefined), + getInterceptedRequests: vi.fn().mockResolvedValue([]), + getCookies: vi.fn().mockResolvedValue([]), + screenshot: vi.fn().mockResolvedValue(''), + waitForCapture: vi.fn().mockResolvedValue(undefined), + ...overrides, + }; +} + +describe('twitter reply command', () => { + it('keeps the text-only reply flow working', async () => { + const cmd = getRegistry().get('twitter/reply'); + expect(cmd?.func).toBeTypeOf('function'); + + const page = createPageMock([ + { ok: true, message: 'Reply posted successfully.' }, + ]); + + const result = await cmd!.func!(page, { + url: 'https://x.com/_kop6/status/2040254679301718161?s=20', + text: 'text-only reply', + }); + + expect(page.goto).toHaveBeenCalledWith('https://x.com/_kop6/status/2040254679301718161?s=20'); + expect(page.wait).toHaveBeenCalledWith({ selector: '[data-testid="primaryColumn"]' }); + expect(result).toEqual([ + { + status: 'success', + message: 'Reply posted successfully.', + text: 'text-only reply', + }, + ]); + }); + + it('uploads a local image through the dedicated reply composer when --image is provided', async () => { + const cmd = getRegistry().get('twitter/reply'); + expect(cmd?.func).toBeTypeOf('function'); + + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-')); + const imagePath = path.join(tempDir, 'qr.png'); + fs.writeFileSync(imagePath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); + + const setFileInput = vi.fn().mockResolvedValue(undefined); + const page = createPageMock([ + { ok: true, previewCount: 1 }, + { ok: true, message: 'Reply posted successfully.' }, + ], { + setFileInput, + }); + + const result = await cmd!.func!(page, { + url: 'https://x.com/_kop6/status/2040254679301718161?s=20', + text: 'reply with image', + image: imagePath, + }); + + expect(page.goto).toHaveBeenCalledWith('https://x.com/compose/post?in_reply_to=2040254679301718161'); + expect(page.wait).toHaveBeenNthCalledWith(1, { selector: '[data-testid="tweetTextarea_0"]' }); + expect(page.wait).toHaveBeenNthCalledWith(2, { selector: 'input[type="file"][data-testid="fileInput"]', timeout: 20 }); + expect(setFileInput).toHaveBeenCalledWith([imagePath], 'input[type="file"][data-testid="fileInput"]'); + expect(result).toEqual([ + { + status: 'success', + message: 'Reply posted successfully.', + text: 'reply with image', + image: imagePath, + }, + ]); + }); + + it('downloads a remote image before uploading when --image-url is provided', async () => { + const cmd = getRegistry().get('twitter/reply'); + expect(cmd?.func).toBeTypeOf('function'); + + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: { + get: vi.fn().mockReturnValue('image/png'), + }, + arrayBuffer: vi.fn().mockResolvedValue(Uint8Array.from([0x89, 0x50, 0x4e, 0x47]).buffer), + }); + vi.stubGlobal('fetch', fetchMock); + + const setFileInput = vi.fn().mockResolvedValue(undefined); + const page = createPageMock([ + { ok: true, previewCount: 1 }, + { ok: true, message: 'Reply posted successfully.' }, + ], { + setFileInput, + }); + + const result = await cmd!.func!(page, { + url: 'https://x.com/_kop6/status/2040254679301718161?s=20', + text: 'reply with remote image', + 'image-url': 'https://example.com/qr', + }); + + expect(fetchMock).toHaveBeenCalledWith('https://example.com/qr'); + expect(setFileInput).toHaveBeenCalledTimes(1); + const uploadedPath = setFileInput.mock.calls[0][0][0]; + expect(uploadedPath).toMatch(/opencli-twitter-reply-.*\/image\.png$/); + expect(fs.existsSync(uploadedPath)).toBe(false); + expect(result).toEqual([ + { + status: 'success', + message: 'Reply posted successfully.', + text: 'reply with remote image', + 'image-url': 'https://example.com/qr', + }, + ]); + + vi.unstubAllGlobals(); + }); + + it('rejects invalid image paths early', async () => { + await expect(() => __test__.resolveImagePath('/tmp/does-not-exist.png')) + .toThrow('Image file not found'); + }); + + it('rejects using --image and --image-url together', async () => { + const cmd = getRegistry().get('twitter/reply'); + expect(cmd?.func).toBeTypeOf('function'); + + const page = createPageMock([]); + + await expect(cmd!.func!(page, { + url: 'https://x.com/_kop6/status/2040254679301718161?s=20', + text: 'nope', + image: '/tmp/a.png', + 'image-url': 'https://example.com/a.png', + })).rejects.toThrow('Use either --image or --image-url, not both.'); + }); + + it('extracts tweet ids from both user and i/status URLs', () => { + expect(__test__.extractTweetId('https://x.com/_kop6/status/2040254679301718161?s=20')).toBe('2040254679301718161'); + expect(__test__.extractTweetId('https://x.com/i/status/2040318731105313143')).toBe('2040318731105313143'); + expect(__test__.buildReplyComposerUrl('https://x.com/i/status/2040318731105313143')) + .toBe('https://x.com/compose/post?in_reply_to=2040318731105313143'); + }); + + it('prefers content-type when resolving remote image extensions', () => { + expect(__test__.resolveImageExtension('https://example.com/no-ext', 'image/webp')).toBe('.webp'); + expect(__test__.resolveImageExtension('https://example.com/a.jpeg?x=1', null)).toBe('.jpeg'); + }); +}); diff --git a/src/clis/twitter/reply.ts b/src/clis/twitter/reply.ts index 0fd1e595..810736e6 100644 --- a/src/clis/twitter/reply.ts +++ b/src/clis/twitter/reply.ts @@ -1,63 +1,283 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + import { CommandExecutionError } from '../../errors.js'; import { cli, Strategy } from '../../registry.js'; import type { IPage } from '../../types.js'; +const REPLY_FILE_INPUT_SELECTOR = 'input[type="file"][data-testid="fileInput"]'; +const SUPPORTED_IMAGE_EXTENSIONS = new Set([ + '.jpg', + '.jpeg', + '.png', + '.gif', + '.webp', +]); +const CONTENT_TYPE_TO_EXTENSION: Record = { + 'image/jpeg': '.jpg', + 'image/jpg': '.jpg', + 'image/png': '.png', + 'image/gif': '.gif', + 'image/webp': '.webp', +}; + +function resolveImagePath(imagePath: string): string { + const absPath = path.resolve(imagePath); + if (!fs.existsSync(absPath)) { + throw new Error(`Image file not found: ${absPath}`); + } + + const ext = path.extname(absPath).toLowerCase(); + if (!SUPPORTED_IMAGE_EXTENSIONS.has(ext)) { + throw new Error(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`); + } + + return absPath; +} + +function extractTweetId(url: string): string { + let pathname = ''; + try { + pathname = new URL(url).pathname; + } catch { + throw new Error(`Invalid tweet URL: ${url}`); + } + + const match = pathname.match(/\/status\/(\d+)/); + if (!match?.[1]) { + throw new Error(`Could not extract tweet ID from URL: ${url}`); + } + + return match[1]; +} + +function buildReplyComposerUrl(url: string): string { + return `https://x.com/compose/post?in_reply_to=${extractTweetId(url)}`; +} + +function resolveImageExtension(url: string, contentType: string | null): string { + const normalizedContentType = (contentType || '').split(';')[0].trim().toLowerCase(); + if (normalizedContentType && CONTENT_TYPE_TO_EXTENSION[normalizedContentType]) { + return CONTENT_TYPE_TO_EXTENSION[normalizedContentType]; + } + + try { + const pathname = new URL(url).pathname; + const ext = path.extname(pathname).toLowerCase(); + if (SUPPORTED_IMAGE_EXTENSIONS.has(ext)) return ext; + } catch { + // Fall through to the final error below. + } + + throw new Error( + `Unsupported remote image format "${normalizedContentType || 'unknown'}". ` + + 'Supported: jpg, jpeg, png, gif, webp' + ); +} + +async function downloadRemoteImage(imageUrl: string): Promise { + let parsed: URL; + try { + parsed = new URL(imageUrl); + } catch { + throw new Error(`Invalid image URL: ${imageUrl}`); + } + + if (!/^https?:$/.test(parsed.protocol)) { + throw new Error(`Unsupported image URL protocol: ${parsed.protocol}`); + } + + const response = await fetch(imageUrl); + if (!response.ok) { + throw new Error(`Image download failed: HTTP ${response.status}`); + } + + const ext = resolveImageExtension(imageUrl, response.headers.get('content-type')); + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-')); + const tmpPath = path.join(tmpDir, `image${ext}`); + const buffer = Buffer.from(await response.arrayBuffer()); + fs.writeFileSync(tmpPath, buffer); + return tmpPath; +} + +async function attachReplyImage(page: IPage, absImagePath: string): Promise { + if (page.setFileInput) { + try { + await page.setFileInput([absImagePath], REPLY_FILE_INPUT_SELECTOR); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!msg.includes('Unknown action') && !msg.includes('not supported')) { + throw new Error(`Image upload failed: ${msg}`); + } + } + } + + if (!page.setFileInput) { + const ext = path.extname(absImagePath).toLowerCase(); + const mimeType = ext === '.png' + ? 'image/png' + : ext === '.gif' + ? 'image/gif' + : ext === '.webp' + ? 'image/webp' + : 'image/jpeg'; + const base64 = fs.readFileSync(absImagePath).toString('base64'); + const upload = await page.evaluate(` + (() => { + const input = document.querySelector(${JSON.stringify(REPLY_FILE_INPUT_SELECTOR)}); + if (!input) return { ok: false, error: 'No file input found on page' }; + + const binary = atob(${JSON.stringify(base64)}); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + + const dt = new DataTransfer(); + const blob = new Blob([bytes], { type: ${JSON.stringify(mimeType)} }); + dt.items.add(new File([blob], ${JSON.stringify(path.basename(absImagePath))}, { type: ${JSON.stringify(mimeType)} })); + + Object.defineProperty(input, 'files', { value: dt.files, writable: false }); + input.dispatchEvent(new Event('change', { bubbles: true })); + input.dispatchEvent(new Event('input', { bubbles: true })); + return { ok: true }; + })() + `); + + if (!upload?.ok) { + throw new Error(`Image upload failed: ${upload?.error ?? 'unknown error'}`); + } + } + + await page.wait(2); + const uploadState = await page.evaluate(` + (() => { + const previewCount = document.querySelectorAll( + '[data-testid="attachments"] img, [data-testid="attachments"] video, [data-testid="tweetPhoto"]' + ).length; + const hasMedia = previewCount > 0 + || !!document.querySelector('[data-testid="attachments"]') + || !!Array.from(document.querySelectorAll('button,[role="button"]')).find((el) => + /remove media|remove image|remove/i.test((el.getAttribute('aria-label') || '') + ' ' + (el.textContent || '')) + ); + return { ok: hasMedia, previewCount }; + })() + `); + + if (!uploadState?.ok) { + throw new Error('Image upload failed: preview did not appear.'); + } +} + +async function submitReply(page: IPage, text: string): Promise<{ ok: boolean; message: string }> { + return page.evaluate(`(async () => { + try { + const visible = (el) => !!el && (el.offsetParent !== null || el.getClientRects().length > 0); + const boxes = Array.from(document.querySelectorAll('[data-testid="tweetTextarea_0"]')); + const box = boxes.find(visible) || boxes[0]; + if (!box) { + return { ok: false, message: 'Could not find the reply text area. Are you logged in?' }; + } + + box.focus(); + const textToInsert = ${JSON.stringify(text)}; + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', textToInsert); + box.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true + })); + + await new Promise(r => setTimeout(r, 1000)); + + const buttons = Array.from( + document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]') + ); + const btn = buttons.find((el) => visible(el) && !el.disabled) + || buttons.find(visible) + || buttons[0]; + if (!btn) { + return { ok: false, message: 'Reply button is disabled or not found.' }; + } + if (btn.disabled) { + return { ok: false, message: 'Reply button is disabled or not found.' }; + } + + btn.click(); + return { ok: true, message: 'Reply posted successfully.' }; + } catch (e) { + return { ok: false, message: e.toString() }; + } + })()`); +} + cli({ site: 'twitter', name: 'reply', - description: 'Reply to a specific tweet', + description: 'Reply to a specific tweet, optionally with a local or remote image', domain: 'x.com', strategy: Strategy.UI, // Uses the UI directly to input and click post browser: true, args: [ { name: 'url', type: 'string', required: true, positional: true, help: 'The URL of the tweet to reply to' }, { name: 'text', type: 'string', required: true, positional: true, help: 'The text content of your reply' }, + { name: 'image', help: 'Optional local image path to attach to the reply' }, + { name: 'image-url', help: 'Optional remote image URL to download and attach to the reply' }, ], columns: ['status', 'message', 'text'], func: async (page: IPage | null, kwargs: any) => { if (!page) throw new CommandExecutionError('Browser session required for twitter reply'); + if (kwargs.image && kwargs['image-url']) { + throw new Error('Use either --image or --image-url, not both.'); + } - // 1. Navigate to the tweet page - await page.goto(kwargs.url); - await page.wait({ selector: '[data-testid="primaryColumn"]' }); - - // 2. Automate typing the reply and clicking reply - const result = await page.evaluate(`(async () => { - try { - // Find the reply text area on the tweet page. - // The placeholder is usually "Post your reply" - const box = document.querySelector('[data-testid="tweetTextarea_0"]'); - if (box) { - box.focus(); - document.execCommand('insertText', false, ${JSON.stringify(kwargs.text)}); - } else { - return { ok: false, message: 'Could not find the reply text area. Are you logged in?' }; - } - - // Wait for React state to register the input and enable the button - await new Promise(r => setTimeout(r, 1000)); - - // Find the Reply button. It usually shares the same test id tweetButtonInline in this context - const btn = document.querySelector('[data-testid="tweetButtonInline"]'); - if (btn && !btn.disabled) { - btn.click(); - return { ok: true, message: 'Reply posted successfully.' }; - } else { - return { ok: false, message: 'Reply button is disabled or not found.' }; - } - } catch (e) { - return { ok: false, message: e.toString() }; - } - })()`); - - if (result.ok) { + let localImagePath: string | undefined; + let cleanupDir: string | undefined; + try { + if (kwargs.image) { + localImagePath = resolveImagePath(kwargs.image); + } else if (kwargs['image-url']) { + localImagePath = await downloadRemoteImage(kwargs['image-url']); + cleanupDir = path.dirname(localImagePath); + } + + // Dedicated composer is more reliable for image replies because the media + // toolbar and file input are consistently present there. + if (localImagePath) { + await page.goto(buildReplyComposerUrl(kwargs.url)); + await page.wait({ selector: '[data-testid="tweetTextarea_0"]' }); + await page.wait({ selector: REPLY_FILE_INPUT_SELECTOR, timeout: 20 }); + await attachReplyImage(page, localImagePath); + } else { + await page.goto(kwargs.url); + await page.wait({ selector: '[data-testid="primaryColumn"]' }); + } + + const result = await submitReply(page, kwargs.text); + + if (result.ok) { await page.wait(3); // Wait for network submission to complete - } + } - return [{ + return [{ status: result.ok ? 'success' : 'failed', message: result.message, - text: kwargs.text - }]; + text: kwargs.text, + ...(kwargs.image ? { image: kwargs.image } : {}), + ...(kwargs['image-url'] ? { 'image-url': kwargs['image-url'] } : {}), + }]; + } finally { + if (cleanupDir) { + fs.rmSync(cleanupDir, { recursive: true, force: true }); + } + } } }); + +export const __test__ = { + buildReplyComposerUrl, + downloadRemoteImage, + extractTweetId, + resolveImageExtension, + resolveImagePath, +}; From ac38ec29d9b2a23890bfe28213d2a1989e6e12c7 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 4 Apr 2026 15:48:37 +0800 Subject: [PATCH 2/9] fix(twitter/reply): fix image upload fallback, restore execCommand, add size limit - Fix attachReplyImage fallback: use uploaded flag instead of checking page.setFileInput existence, so base64 fallback actually runs when CDP setFileInput throws "Unknown action" - Restore execCommand('insertText') as primary text input method for Twitter's Draft.js editor, with paste event as fallback - Add 20MB size limit for remote image downloads to prevent OOM - Remove unsafe buttons[0] fallback that could click invisible buttons --- src/clis/twitter/reply.ts | 40 +++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/src/clis/twitter/reply.ts b/src/clis/twitter/reply.ts index 810736e6..32869f38 100644 --- a/src/clis/twitter/reply.ts +++ b/src/clis/twitter/reply.ts @@ -14,6 +14,7 @@ const SUPPORTED_IMAGE_EXTENSIONS = new Set([ '.gif', '.webp', ]); +const MAX_IMAGE_SIZE_BYTES = 20 * 1024 * 1024; // 20 MB (Twitter allows 5MB images, 15MB GIFs) const CONTENT_TYPE_TO_EXTENSION: Record = { 'image/jpeg': '.jpg', 'image/jpg': '.jpg', @@ -93,27 +94,39 @@ async function downloadRemoteImage(imageUrl: string): Promise { throw new Error(`Image download failed: HTTP ${response.status}`); } + const contentLength = Number(response.headers.get('content-length') || '0'); + if (contentLength > MAX_IMAGE_SIZE_BYTES) { + throw new Error(`Image too large: ${(contentLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`); + } + const ext = resolveImageExtension(imageUrl, response.headers.get('content-type')); const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-twitter-reply-')); const tmpPath = path.join(tmpDir, `image${ext}`); const buffer = Buffer.from(await response.arrayBuffer()); + if (buffer.byteLength > MAX_IMAGE_SIZE_BYTES) { + fs.rmSync(tmpDir, { recursive: true, force: true }); + throw new Error(`Image too large: ${(buffer.byteLength / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)`); + } fs.writeFileSync(tmpPath, buffer); return tmpPath; } async function attachReplyImage(page: IPage, absImagePath: string): Promise { + let uploaded = false; if (page.setFileInput) { try { await page.setFileInput([absImagePath], REPLY_FILE_INPUT_SELECTOR); + uploaded = true; } catch (err) { const msg = err instanceof Error ? err.message : String(err); if (!msg.includes('Unknown action') && !msg.includes('not supported')) { throw new Error(`Image upload failed: ${msg}`); } + // setFileInput not supported by extension — fall through to base64 fallback } } - if (!page.setFileInput) { + if (!uploaded) { const ext = path.extname(absImagePath).toLowerCase(); const mimeType = ext === '.png' ? 'image/png' @@ -180,28 +193,27 @@ async function submitReply(page: IPage, text: string): Promise<{ ok: boolean; me box.focus(); const textToInsert = ${JSON.stringify(text)}; - const dataTransfer = new DataTransfer(); - dataTransfer.setData('text/plain', textToInsert); - box.dispatchEvent(new ClipboardEvent('paste', { - clipboardData: dataTransfer, - bubbles: true, - cancelable: true - })); + // execCommand('insertText') is more reliable with Twitter's Draft.js editor + if (!document.execCommand('insertText', false, textToInsert)) { + // Fallback to paste event if execCommand fails + const dataTransfer = new DataTransfer(); + dataTransfer.setData('text/plain', textToInsert); + box.dispatchEvent(new ClipboardEvent('paste', { + clipboardData: dataTransfer, + bubbles: true, + cancelable: true + })); + } await new Promise(r => setTimeout(r, 1000)); const buttons = Array.from( document.querySelectorAll('[data-testid="tweetButton"], [data-testid="tweetButtonInline"]') ); - const btn = buttons.find((el) => visible(el) && !el.disabled) - || buttons.find(visible) - || buttons[0]; + const btn = buttons.find((el) => visible(el) && !el.disabled); if (!btn) { return { ok: false, message: 'Reply button is disabled or not found.' }; } - if (btn.disabled) { - return { ok: false, message: 'Reply button is disabled or not found.' }; - } btn.click(); return { ok: true, message: 'Reply posted successfully.' }; From 73b0ef8543f5246e5e1ad5e926959eac7ad354b2 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sat, 4 Apr 2026 16:00:06 +0800 Subject: [PATCH 3/9] fix(twitter/reply): add local image size check and base64 fallback warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local images were not validated for size — a 100MB file would fail only at upload time. Remote images already had MAX_IMAGE_SIZE_BYTES checks. Also add a console.warn when using the base64 fallback with large payloads, consistent with xiaohongshu/publish.ts behavior. --- src/clis/twitter/reply.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/clis/twitter/reply.ts b/src/clis/twitter/reply.ts index 32869f38..9298e5d6 100644 --- a/src/clis/twitter/reply.ts +++ b/src/clis/twitter/reply.ts @@ -34,6 +34,13 @@ function resolveImagePath(imagePath: string): string { throw new Error(`Unsupported image format "${ext}". Supported: jpg, jpeg, png, gif, webp`); } + const stat = fs.statSync(absPath); + if (stat.size > MAX_IMAGE_SIZE_BYTES) { + throw new Error( + `Image too large: ${(stat.size / 1024 / 1024).toFixed(1)} MB (max ${MAX_IMAGE_SIZE_BYTES / 1024 / 1024} MB)` + ); + } + return absPath; } @@ -136,6 +143,13 @@ async function attachReplyImage(page: IPage, absImagePath: string): Promise 500_000) { + console.warn( + `[warn] Image base64 payload is ${(base64.length / 1024 / 1024).toFixed(1)}MB. ` + + 'This may fail with the browser bridge. Update the extension to v1.6+ for CDP-based upload, ' + + 'or compress the image before attaching.' + ); + } const upload = await page.evaluate(` (() => { const input = document.querySelector(${JSON.stringify(REPLY_FILE_INPUT_SELECTOR)}); From 2165d04b03d7ba01f0b1860e5f13df571c30bec5 Mon Sep 17 00:00:00 2001 From: youngcan Date: Sun, 5 Apr 2026 13:04:09 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat:=20=E8=A7=A3=E5=86=B3=E5=AE=9E?= =?UTF-8?q?=E9=99=85=E7=97=9B=E7=82=B9=EF=BC=88=E9=87=8D=E5=9E=8B=20SPA=20?= =?UTF-8?q?=E8=B6=85=E6=97=B6=EF=BC=89=EF=BC=8C=E6=94=B9=E5=8A=A8=E9=9B=86?= =?UTF-8?q?=E4=B8=AD=E4=B8=94=E5=90=91=E5=90=8E=E5=85=BC=E5=AE=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/build-manifest.ts | 2 +- src/execution.ts | 31 +++++++++++++++++++++++-------- src/registry.ts | 19 ++++++++++++++++++- 3 files changed, 42 insertions(+), 10 deletions(-) diff --git a/src/build-manifest.ts b/src/build-manifest.ts index f5073520..411d075a 100644 --- a/src/build-manifest.ts +++ b/src/build-manifest.ts @@ -48,7 +48,7 @@ export interface ManifestEntry { /** Relative path from clis/ dir, e.g. 'bilibili/hot.yaml' or 'bilibili/search.js' */ modulePath?: string; /** Pre-navigation control — see CliCommand.navigateBefore */ - navigateBefore?: boolean | string; + navigateBefore?: boolean | string | { url: string; waitUntil?: 'load' | 'none'; settleMs?: number }; } import { type YamlCliDefinition, parseYamlArgs } from './yaml-schema.js'; diff --git a/src/execution.ts b/src/execution.ts index efd1f1f4..6c440364 100644 --- a/src/execution.ts +++ b/src/execution.ts @@ -10,7 +10,7 @@ * 6. Lifecycle hooks (onBeforeExecute / onAfterExecute) */ -import { type CliCommand, type InternalCliCommand, type Arg, type CommandArgs, Strategy, getRegistry, fullName } from './registry.js'; +import { type CliCommand, type InternalCliCommand, type PreNavOptions, type Arg, type CommandArgs, Strategy, getRegistry, fullName } from './registry.js'; import type { IPage } from './types.js'; import { pathToFileURL } from 'node:url'; import { executePipeline } from './pipeline/index.js'; @@ -108,12 +108,24 @@ async function runCommand( ); } -function resolvePreNav(cmd: CliCommand): string | null { +interface ResolvedPreNav { + url: string; + waitUntil?: 'load' | 'none'; + settleMs?: number; +} + +function resolvePreNav(cmd: CliCommand): ResolvedPreNav | null { if (cmd.navigateBefore === false) return null; - if (typeof cmd.navigateBefore === 'string') return cmd.navigateBefore; + + if (typeof cmd.navigateBefore === 'object' && cmd.navigateBefore !== null && 'url' in cmd.navigateBefore) { + const opts = cmd.navigateBefore as PreNavOptions; + return { url: opts.url, waitUntil: opts.waitUntil, settleMs: opts.settleMs }; + } + + if (typeof cmd.navigateBefore === 'string') return { url: cmd.navigateBefore }; if ((cmd.strategy === Strategy.COOKIE || cmd.strategy === Strategy.HEADER) && cmd.domain) { - return `https://${cmd.domain}`; + return { url: `https://${cmd.domain}` }; } return null; } @@ -191,17 +203,20 @@ export async function executeCommand( ensureRequiredEnv(cmd); const BrowserFactory = getBrowserFactory(cmd.site); result = await browserSession(BrowserFactory, async (page) => { - const preNavUrl = resolvePreNav(cmd); - if (preNavUrl) { + const preNav = resolvePreNav(cmd); + if (preNav) { // Navigate directly — the extension's handleNavigate already has a fast-path // that skips navigation if the tab is already at the target URL. // This avoids an extra exec round-trip (getCurrentUrl) on first command and // lets the extension create the automation window with the target URL directly // instead of about:blank. + const gotoOpts = (preNav.waitUntil || preNav.settleMs) + ? { waitUntil: preNav.waitUntil, settleMs: preNav.settleMs } + : undefined; try { - await page.goto(preNavUrl); + await page.goto(preNav.url, gotoOpts); } catch (err) { - if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNavUrl}: ${err instanceof Error ? err.message : err}`); + if (debug) log.debug(`[pre-nav] Failed to navigate to ${preNav.url}: ${err instanceof Error ? err.message : err}`); } } return runWithTimeout(runCommand(cmd, page, kwargs, debug), { diff --git a/src/registry.ts b/src/registry.ts index f9a9d4f2..eefb5855 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -31,6 +31,22 @@ export interface RequiredEnv { // eslint-disable-next-line @typescript-eslint/no-explicit-any -- kwargs from CLI parsing are inherently untyped export type CommandArgs = Record; +/** + * Options for navigateBefore when fine-grained control is needed. + * + * Useful for heavy SPAs where the default `waitUntil: 'load'` + 1s settle + * causes timeouts, or when a cookie-seeding navigation to a different + * subdomain is required before loading the actual target page. + */ +export interface PreNavOptions { + /** Primary URL to navigate to. */ + url: string; + /** Override waitUntil behavior ('load' or 'none'). Default: 'load'. */ + waitUntil?: 'load' | 'none'; + /** Milliseconds to wait for DOM stability after load. Default: 1000. */ + settleMs?: number; +} + export interface CliCommand { site: string; name: string; @@ -62,8 +78,9 @@ export interface CliCommand { * - `undefined` / `true`: navigate to `https://${domain}` (default) * - `false`: skip — adapter handles its own navigation (e.g. boss common.ts) * - `string`: navigate to this specific URL instead of the domain root + * - `object`: navigate with explicit options (waitUntil, settleMs) */ - navigateBefore?: boolean | string; + navigateBefore?: boolean | string | PreNavOptions; /** Override the default CLI output format when the user does not pass -f/--format. */ defaultFormat?: 'table' | 'plain' | 'json' | 'yaml' | 'yml' | 'md' | 'markdown' | 'csv'; } From 23de071922d2b5ab1b63b15602f5830443d73f53 Mon Sep 17 00:00:00 2001 From: youngcan Date: Sun, 5 Apr 2026 13:04:37 +0800 Subject: [PATCH 5/9] =?UTF-8?q?feat=EF=BC=9A=20ERROR=5FICONS=20=E9=87=8C?= =?UTF-8?q?=E5=B7=B2=E7=BB=8F=E6=B3=A8=E5=86=8C=E4=BA=86=20NETWORK?= =?UTF-8?q?=E3=80=81API=5FERROR=E3=80=81PAGE=5FCHANGED=20=E4=B8=89?= =?UTF-8?q?=E4=B8=AA=E5=9B=BE=E6=A0=87=EF=BC=8C=E4=BD=86=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E5=AF=B9=E5=BA=94=E7=9A=84=20Error=20=E5=AD=90=E7=B1=BB?= =?UTF-8?q?=E3=80=82=E9=80=82=E9=85=8D=E5=99=A8=E9=81=87=E5=88=B0=E8=BF=99?= =?UTF-8?q?=E4=B8=89=E7=B1=BB=E9=94=99=E8=AF=AF=E5=8F=AA=E8=83=BD=20throw?= =?UTF-8?q?=20new=20=20=20Error(...)=EF=BC=8CCLI=20=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E5=99=A8=E6=97=A0=E6=B3=95=E5=8C=B9=E9=85=8D=E5=88=B0=E6=AD=A3?= =?UTF-8?q?=E7=A1=AE=E5=9B=BE=E6=A0=87=EF=BC=8C=E5=85=A8=E9=83=A8=E6=98=BE?= =?UTF-8?q?=E7=A4=BA=E4=B8=BA=E9=80=9A=E7=94=A8=E7=9A=84=20=F0=9F=92=A5?= =?UTF-8?q?=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/errors.ts | 43 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 9ea9adec..3b22507c 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -10,11 +10,11 @@ * opencli follows Unix conventions (sysexits.h) for process exit codes: * * 0 Success - * 1 Generic / unexpected error + * 1 Generic / unexpected error (ApiError, SelectorError) * 2 Argument / usage error (ArgumentError) - * 66 No input / empty result (EmptyResultError) + * 66 No input / empty result (EmptyResultError, PageChangedError) * 69 Service unavailable (BrowserConnectError, AdapterLoadError) - * 75 Temporary failure, retry later (TimeoutError) EX_TEMPFAIL + * 75 Temporary failure, retry later (TimeoutError, NetworkError) EX_TEMPFAIL * 77 Permission denied / auth needed (AuthRequiredError) * 78 Configuration error (ConfigError) * 130 Interrupted by Ctrl-C (set by tui.ts SIGINT handler) @@ -137,6 +137,43 @@ export class SelectorError extends CliError { } } +export class NetworkError extends CliError { + readonly statusCode?: number; + constructor(message: string, statusCode?: number, hint?: string) { + super( + 'NETWORK', + message, + hint ?? 'Check your network connection, or try again later', + EXIT_CODES.TEMPFAIL, + ); + this.statusCode = statusCode; + } +} + +export class ApiError extends CliError { + readonly apiCode?: number | string; + constructor(label: string, apiCode?: number | string, apiMessage?: string, hint?: string) { + super( + 'API_ERROR', + `${label}: API error${apiCode !== undefined ? ` code=${apiCode}` : ''}${apiMessage ? ` — ${apiMessage}` : ''}`, + hint ?? 'The API returned an unexpected error. The endpoint may have changed.', + EXIT_CODES.GENERIC_ERROR, + ); + this.apiCode = apiCode; + } +} + +export class PageChangedError extends CliError { + constructor(command: string, hint?: string) { + super( + 'PAGE_CHANGED', + `${command}: page structure has changed`, + hint ?? 'The website may have been updated. Please report this issue so the adapter can be fixed.', + EXIT_CODES.EMPTY_RESULT, + ); + } +} + // ── Utilities ─────────────────────────────────────────────────────────────── /** Extract a human-readable message from an unknown caught value. */ From 300efbd02b8e7bd74d117758ca4fd500c37e6433 Mon Sep 17 00:00:00 2001 From: youngcan Date: Sun, 5 Apr 2026 13:06:36 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E6=8E=A5=E5=8F=A3=E6=89=BF?= =?UTF-8?q?=E8=AF=BA=E8=BF=94=E5=9B=9E=20any=EF=BC=88=E8=B0=83=E7=94=A8?= =?UTF-8?q?=E8=80=85=E5=8F=AF=E7=9B=B4=E6=8E=A5=E8=AE=BF=E9=97=AE=E5=B1=9E?= =?UTF-8?q?=E6=80=A7=EF=BC=89=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=8D=B4=E5=A3=B0?= =?UTF-8?q?=E6=98=8E=20unknown=EF=BC=88=E9=9C=80=E8=A6=81=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E6=96=AD=E8=A8=80=EF=BC=89=E3=80=82=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E5=90=8E=E6=B6=88=E9=99=A4=E4=BA=86=E8=BF=99=E4=B8=AA=E9=9A=90?= =?UTF-8?q?=E5=BC=8F=E5=88=86=E6=AD=A7=E3=80=82=20=E5=85=B1=E4=BA=AB=20eva?= =?UTF-8?q?luateFetch()=20=E5=B7=A5=E5=85=B7=E5=87=BD=E6=95=B0,=E7=9B=AE?= =?UTF-8?q?=E5=89=8D=E6=AF=8F=E4=B8=AA=E7=AB=99=E7=82=B9=E5=90=84=E8=87=AA?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=EF=BC=8C=E5=8A=9F=E8=83=BD=E9=87=8D=E5=8F=A0?= =?UTF-8?q?=E4=BD=86=E8=B4=A8=E9=87=8F=E5=8F=82=E5=B7=AE=E3=80=82=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E4=B8=BA=E5=85=B1=E4=BA=AB=E5=B7=A5=E5=85=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clis/_shared/common.ts | 95 ++++++++++++++++++++++++++++++++++++++++ src/browser/base-page.ts | 2 +- src/browser/cdp.ts | 2 +- src/browser/page.ts | 2 +- 4 files changed, 98 insertions(+), 3 deletions(-) diff --git a/clis/_shared/common.ts b/clis/_shared/common.ts index 5e56a6c1..72d44f6d 100644 --- a/clis/_shared/common.ts +++ b/clis/_shared/common.ts @@ -2,6 +2,8 @@ * Shared utilities for CLI adapters. */ +import type { IPage } from '../../src/types.js'; + /** * Clamp a numeric value to [min, max]. * Matches the signature of lodash.clamp and Rust's clamp. @@ -9,3 +11,96 @@ export function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(value, max)); } + +// ── Browser evaluate helpers ──────────────────────────────────────────────── + +export interface EvaluateFetchOptions { + /** HTTP method. Default: 'GET'. */ + method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; + /** Query parameters — appended to the URL. */ + params?: Record; + /** JSON body for POST/PUT requests. */ + body?: Record; + /** Extra headers to include in the request. */ + headers?: Record; + /** Credential mode. Default: 'include' (sends cookies). */ + credentials?: 'include' | 'omit' | 'same-origin'; +} + +/** + * Perform a `fetch()` call **inside the browser page context** and return the + * parsed JSON response. + * + * This is the most common pattern in cookie-tier adapters: the page has been + * navigated to the target domain (establishing cookie context + any SDK patches + * like H5Guard/mtgsig), and we want to call an API that requires those cookies. + * + * Returns the JSON body on success, or `{ __error, __status? }` on failure so + * callers can distinguish network/HTTP errors from API-level errors. + * + * @example + * ```ts + * // Simple GET + * const data = await evaluateFetch(page, 'https://api.example.com/hot'); + * + * // GET with query params + * const data = await evaluateFetch(page, '/api/orders', { + * params: { page: 1, limit: 20 }, + * }); + * + * // POST with JSON body + * const data = await evaluateFetch(page, '/api/search', { + * method: 'POST', + * body: { query: 'test', limit: 10 }, + * }); + * ``` + */ +export async function evaluateFetch( + page: IPage, + url: string, + options?: EvaluateFetchOptions, +): Promise { + const method = options?.method ?? 'GET'; + const params = options?.params; + const body = options?.body; + const headers = options?.headers; + const credentials = options?.credentials ?? 'include'; + + // Build the full URL with query parameters + let fullUrl = url; + if (params) { + const qs = Object.entries(params) + .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) + .join('&'); + fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs; + } + + // Build fetch init options as an inline string for evaluate + const initParts: string[] = [`credentials: '${credentials}'`]; + if (method !== 'GET') { + initParts.push(`method: '${method}'`); + } + const mergedHeaders: Record = {}; + if (body) mergedHeaders['Content-Type'] = 'application/json'; + if (headers) Object.assign(mergedHeaders, headers); + if (Object.keys(mergedHeaders).length > 0) { + initParts.push(`headers: ${JSON.stringify(mergedHeaders)}`); + } + if (body) { + initParts.push(`body: ${JSON.stringify(JSON.stringify(body))}`); + } + + const fetchCode = ` + (async () => { + try { + const res = await fetch(${JSON.stringify(fullUrl)}, { ${initParts.join(', ')} }); + if (!res.ok) return { __error: 'HTTP ' + res.status, __status: res.status }; + return await res.json(); + } catch (e) { + return { __error: String(e) }; + } + })() + `; + + return page.evaluate(fetchCode); +} diff --git a/src/browser/base-page.ts b/src/browser/base-page.ts index 4ed0d64f..ada5e05e 100644 --- a/src/browser/base-page.ts +++ b/src/browser/base-page.ts @@ -33,7 +33,7 @@ export abstract class BasePage implements IPage { // ── Transport-specific methods (must be implemented by subclasses) ── abstract goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise; - abstract evaluate(js: string): Promise; + abstract evaluate(js: string): Promise; abstract getCookies(opts?: { domain?: string; url?: string }): Promise; abstract screenshot(options?: ScreenshotOptions): Promise; abstract tabs(): Promise; diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index b3fb90ed..2cef8979 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -184,7 +184,7 @@ class CDPPage extends BasePage { } } - async evaluate(js: string): Promise { + async evaluate(js: string): Promise { const expression = wrapForEval(js); const result = await this.bridge.send('Runtime.evaluate', { expression, diff --git a/src/browser/page.ts b/src/browser/page.ts index e97f6955..99857279 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -98,7 +98,7 @@ export class Page extends BasePage { return this._tabId; } - async evaluate(js: string): Promise { + async evaluate(js: string): Promise { const code = wrapForEval(js); try { return await sendCommand('exec', { code, ...this._cmdOpts() }); From 0d1da98e461e92a8b829eadc3ad48659a834d4cb Mon Sep 17 00:00:00 2001 From: youngcan Date: Sun, 5 Apr 2026 13:22:24 +0800 Subject: [PATCH 7/9] ci: re-trigger E2E From 84da3d982979e220f403ca52397e83572d006fa4 Mon Sep 17 00:00:00 2001 From: youngcan Date: Mon, 6 Apr 2026 14:06:32 +0800 Subject: [PATCH 8/9] =?UTF-8?q?Revert=20"feat:=20=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=E6=89=BF=E8=AF=BA=E8=BF=94=E5=9B=9E=20any=EF=BC=88=E8=B0=83?= =?UTF-8?q?=E7=94=A8=E8=80=85=E5=8F=AF=E7=9B=B4=E6=8E=A5=E8=AE=BF=E9=97=AE?= =?UTF-8?q?=E5=B1=9E=E6=80=A7=EF=BC=89=EF=BC=8C=E5=AE=9E=E7=8E=B0=E5=8D=B4?= =?UTF-8?q?=E5=A3=B0=E6=98=8E=20unknown=EF=BC=88=E9=9C=80=E8=A6=81?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E6=96=AD=E8=A8=80=EF=BC=89=E3=80=82=E5=AF=B9?= =?UTF-8?q?=E9=BD=90=E5=90=8E=E6=B6=88=E9=99=A4=E4=BA=86=E8=BF=99=E4=B8=AA?= =?UTF-8?q?=E9=9A=90=E5=BC=8F=E5=88=86=E6=AD=A7=E3=80=82=20=E5=85=B1?= =?UTF-8?q?=E4=BA=AB=20evaluateFetch()=20=E5=B7=A5=E5=85=B7=E5=87=BD?= =?UTF-8?q?=E6=95=B0,=E7=9B=AE=E5=89=8D=E6=AF=8F=E4=B8=AA=E7=AB=99?= =?UTF-8?q?=E7=82=B9=E5=90=84=E8=87=AA=E5=AE=9E=E7=8E=B0=EF=BC=8C=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=E9=87=8D=E5=8F=A0=E4=BD=86=E8=B4=A8=E9=87=8F=E5=8F=82?= =?UTF-8?q?=E5=B7=AE=E3=80=82=E6=8F=90=E5=8F=96=E4=B8=BA=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E5=B7=A5=E5=85=B7"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 300efbd02b8e7bd74d117758ca4fd500c37e6433. --- clis/_shared/common.ts | 95 ---------------------------------------- src/browser/base-page.ts | 2 +- src/browser/cdp.ts | 2 +- src/browser/page.ts | 2 +- 4 files changed, 3 insertions(+), 98 deletions(-) diff --git a/clis/_shared/common.ts b/clis/_shared/common.ts index 72d44f6d..5e56a6c1 100644 --- a/clis/_shared/common.ts +++ b/clis/_shared/common.ts @@ -2,8 +2,6 @@ * Shared utilities for CLI adapters. */ -import type { IPage } from '../../src/types.js'; - /** * Clamp a numeric value to [min, max]. * Matches the signature of lodash.clamp and Rust's clamp. @@ -11,96 +9,3 @@ import type { IPage } from '../../src/types.js'; export function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(value, max)); } - -// ── Browser evaluate helpers ──────────────────────────────────────────────── - -export interface EvaluateFetchOptions { - /** HTTP method. Default: 'GET'. */ - method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; - /** Query parameters — appended to the URL. */ - params?: Record; - /** JSON body for POST/PUT requests. */ - body?: Record; - /** Extra headers to include in the request. */ - headers?: Record; - /** Credential mode. Default: 'include' (sends cookies). */ - credentials?: 'include' | 'omit' | 'same-origin'; -} - -/** - * Perform a `fetch()` call **inside the browser page context** and return the - * parsed JSON response. - * - * This is the most common pattern in cookie-tier adapters: the page has been - * navigated to the target domain (establishing cookie context + any SDK patches - * like H5Guard/mtgsig), and we want to call an API that requires those cookies. - * - * Returns the JSON body on success, or `{ __error, __status? }` on failure so - * callers can distinguish network/HTTP errors from API-level errors. - * - * @example - * ```ts - * // Simple GET - * const data = await evaluateFetch(page, 'https://api.example.com/hot'); - * - * // GET with query params - * const data = await evaluateFetch(page, '/api/orders', { - * params: { page: 1, limit: 20 }, - * }); - * - * // POST with JSON body - * const data = await evaluateFetch(page, '/api/search', { - * method: 'POST', - * body: { query: 'test', limit: 10 }, - * }); - * ``` - */ -export async function evaluateFetch( - page: IPage, - url: string, - options?: EvaluateFetchOptions, -): Promise { - const method = options?.method ?? 'GET'; - const params = options?.params; - const body = options?.body; - const headers = options?.headers; - const credentials = options?.credentials ?? 'include'; - - // Build the full URL with query parameters - let fullUrl = url; - if (params) { - const qs = Object.entries(params) - .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`) - .join('&'); - fullUrl += (fullUrl.includes('?') ? '&' : '?') + qs; - } - - // Build fetch init options as an inline string for evaluate - const initParts: string[] = [`credentials: '${credentials}'`]; - if (method !== 'GET') { - initParts.push(`method: '${method}'`); - } - const mergedHeaders: Record = {}; - if (body) mergedHeaders['Content-Type'] = 'application/json'; - if (headers) Object.assign(mergedHeaders, headers); - if (Object.keys(mergedHeaders).length > 0) { - initParts.push(`headers: ${JSON.stringify(mergedHeaders)}`); - } - if (body) { - initParts.push(`body: ${JSON.stringify(JSON.stringify(body))}`); - } - - const fetchCode = ` - (async () => { - try { - const res = await fetch(${JSON.stringify(fullUrl)}, { ${initParts.join(', ')} }); - if (!res.ok) return { __error: 'HTTP ' + res.status, __status: res.status }; - return await res.json(); - } catch (e) { - return { __error: String(e) }; - } - })() - `; - - return page.evaluate(fetchCode); -} diff --git a/src/browser/base-page.ts b/src/browser/base-page.ts index ada5e05e..4ed0d64f 100644 --- a/src/browser/base-page.ts +++ b/src/browser/base-page.ts @@ -33,7 +33,7 @@ export abstract class BasePage implements IPage { // ── Transport-specific methods (must be implemented by subclasses) ── abstract goto(url: string, options?: { waitUntil?: 'load' | 'none'; settleMs?: number }): Promise; - abstract evaluate(js: string): Promise; + abstract evaluate(js: string): Promise; abstract getCookies(opts?: { domain?: string; url?: string }): Promise; abstract screenshot(options?: ScreenshotOptions): Promise; abstract tabs(): Promise; diff --git a/src/browser/cdp.ts b/src/browser/cdp.ts index 2cef8979..b3fb90ed 100644 --- a/src/browser/cdp.ts +++ b/src/browser/cdp.ts @@ -184,7 +184,7 @@ class CDPPage extends BasePage { } } - async evaluate(js: string): Promise { + async evaluate(js: string): Promise { const expression = wrapForEval(js); const result = await this.bridge.send('Runtime.evaluate', { expression, diff --git a/src/browser/page.ts b/src/browser/page.ts index 99857279..e97f6955 100644 --- a/src/browser/page.ts +++ b/src/browser/page.ts @@ -98,7 +98,7 @@ export class Page extends BasePage { return this._tabId; } - async evaluate(js: string): Promise { + async evaluate(js: string): Promise { const code = wrapForEval(js); try { return await sendCommand('exec', { code, ...this._cmdOpts() }); From b40947448750e720440ca93fdf4a5caa02593702 Mon Sep 17 00:00:00 2001 From: youngcan Date: Tue, 7 Apr 2026 08:50:56 +0800 Subject: [PATCH 9/9] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0raw=20mode,?= =?UTF-8?q?=E8=A7=A3=E5=86=B3cli=E7=BD=91=E9=A1=B5=E6=97=B6agent=E4=BC=9A?= =?UTF-8?q?=E7=8C=9C=E5=8F=98=E9=87=8F=E5=90=8D=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/commanderAdapter.ts | 10 +++++++++- src/output.ts | 5 +++++ src/pipeline/executor.ts | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/commanderAdapter.ts b/src/commanderAdapter.ts index 117d0965..83d7499d 100644 --- a/src/commanderAdapter.ts +++ b/src/commanderAdapter.ts @@ -71,7 +71,8 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi } subCmd .option('-f, --format ', 'Output format: table, plain, json, yaml, md, csv', 'table') - .option('-v, --verbose', 'Debug output', false); + .option('-v, --verbose', 'Debug output', false) + .option('--raw', 'Output raw unformatted result as JSON (for adapter development)', false); subCmd.addHelpText('after', formatRegistryHelpText(cmd)); @@ -97,6 +98,7 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi cmd.validateArgs?.(kwargs); const verbose = optionsRecord.verbose === true; + const raw = optionsRecord.raw === true; let format = typeof optionsRecord.format === 'string' ? optionsRecord.format : 'table'; const formatExplicit = subCmd.getOptionValueSource('format') === 'cli'; if (verbose) process.env.OPENCLI_VERBOSE = '1'; @@ -111,6 +113,12 @@ export function registerCommandToProgram(siteCmd: Command, cmd: CliCommand): voi return; } + // --raw: output raw unformatted result as JSON, skip all formatting + if (raw) { + console.log(JSON.stringify(result, null, 2)); + return; + } + const resolved = getRegistry().get(fullName(cmd)) ?? cmd; if (!formatExplicit && format === 'table' && resolved.defaultFormat) { format = resolved.defaultFormat; diff --git a/src/output.ts b/src/output.ts index ec8880ee..8a705ff8 100644 --- a/src/output.ts +++ b/src/output.ts @@ -28,6 +28,11 @@ function resolveColumns(rows: Record[], opts: RenderOptions): s } export function render(data: unknown, opts: RenderOptions = {}): void { + // RAW mode: bypass all formatting, output raw JSON (for adapter development) + if (process.env.RAW === '1' || process.env.OPENCLI_RAW === '1') { + console.log(JSON.stringify(data, null, 2)); + return; + } let fmt = opts.fmt ?? 'table'; // Non-TTY auto-downgrade only when format was NOT explicitly passed by user. // Priority: explicit -f (any value) > OUTPUT env var > TTY auto-detect > table diff --git a/src/pipeline/executor.ts b/src/pipeline/executor.ts index 37d12ce7..0a13eb6d 100644 --- a/src/pipeline/executor.ts +++ b/src/pipeline/executor.ts @@ -98,6 +98,9 @@ function debugStepResult(op: string, data: unknown): void { log.stepResult('(no data)'); } else if (Array.isArray(data)) { log.stepResult(`${data.length} items`); + if (data.length > 0) { + log.verbose(` [Sample] ${JSON.stringify(data[0]).slice(0, 300)}`); + } } else if (typeof data === 'object') { const keys = Object.keys(data).slice(0, 5); log.stepResult(`dict (${keys.join(', ')}${Object.keys(data).length > 5 ? '...' : ''})`);