Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions extension/src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,8 @@ async function handleCommand(cmd: Command): Promise<Result> {
return await handleCloseWindow(cmd, workspace);
case 'sessions':
return await handleSessions(cmd);
case 'set-file-input':
return await handleSetFileInput(cmd, workspace);
default:
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
}
Expand Down Expand Up @@ -579,6 +581,19 @@ async function handleCloseWindow(cmd: Command, workspace: string): Promise<Resul
return { id: cmd.id, ok: true, data: { closed: true } };
}

async function handleSetFileInput(cmd: Command, workspace: string): Promise<Result> {
if (!cmd.files || !Array.isArray(cmd.files) || cmd.files.length === 0) {
return { id: cmd.id, ok: false, error: 'Missing or empty files array' };
}
const tabId = await resolveTabId(cmd.tabId, workspace);
try {
await executor.setFileInputFiles(tabId, cmd.files, cmd.selector);
return { id: cmd.id, ok: true, data: { count: cmd.files.length } };
} catch (err) {
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
}
}

async function handleSessions(cmd: Command): Promise<Result> {
const now = Date.now();
const data = await Promise.all([...automationSessions.entries()].map(async ([workspace, session]) => ({
Expand Down
42 changes: 42 additions & 0 deletions extension/src/cdp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,48 @@ export async function screenshot(
}
}

/**
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
* This bypasses the need to send large base64 payloads through the message channel —
* Chrome reads the files directly from the local filesystem.
*
* @param tabId - Target tab ID
* @param files - Array of absolute local file paths
* @param selector - CSS selector to find the file input (optional, defaults to first file input)
*/
export async function setFileInputFiles(
tabId: number,
files: string[],
selector?: string,
): Promise<void> {
await ensureAttached(tabId);

// Enable DOM domain (required for DOM.querySelector and DOM.setFileInputFiles)
await chrome.debugger.sendCommand({ tabId }, 'DOM.enable');

// Get the document root
const doc = await chrome.debugger.sendCommand({ tabId }, 'DOM.getDocument') as {
root: { nodeId: number };
};

// Find the file input element
const query = selector || 'input[type="file"]';
const result = await chrome.debugger.sendCommand({ tabId }, 'DOM.querySelector', {
nodeId: doc.root.nodeId,
selector: query,
}) as { nodeId: number };

if (!result.nodeId) {
throw new Error(`No element found matching selector: ${query}`);
}

// Set files directly via CDP — Chrome reads from local filesystem
await chrome.debugger.sendCommand({ tabId }, 'DOM.setFileInputFiles', {
files,
nodeId: result.nodeId,
});
}

export async function detach(tabId: number): Promise<void> {
if (!attached.has(tabId)) return;
attached.delete(tabId);
Expand Down
6 changes: 5 additions & 1 deletion extension/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* Everything else is just JS code sent via 'exec'.
*/

export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input';

export interface Command {
/** Unique request ID */
Expand All @@ -32,6 +32,10 @@ export interface Command {
quality?: number;
/** Whether to capture full page (not just viewport) */
fullPage?: boolean;
/** Local file paths for set-file-input action */
files?: string[];
/** CSS selector for file input element (set-file-input action) */
selector?: string;
}

export interface Result {
Expand Down
6 changes: 5 additions & 1 deletion src/browser/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ function generateId(): string {

export interface DaemonCommand {
id: string;
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions' | 'set-file-input';
tabId?: number;
code?: string;
workspace?: string;
Expand All @@ -30,6 +30,10 @@ export interface DaemonCommand {
format?: 'png' | 'jpeg';
quality?: number;
fullPage?: boolean;
/** Local file paths for set-file-input action */
files?: string[];
/** CSS selector for file input element (set-file-input action) */
selector?: string;
}

export interface DaemonResult {
Expand Down
16 changes: 16 additions & 0 deletions src/browser/page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,22 @@ export class Page implements IPage {
return Array.isArray(result) ? result : [];
}

/**
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
* Chrome reads the files directly from the local filesystem, avoiding the
* payload size limits of base64-in-evaluate.
*/
async setFileInput(files: string[], selector?: string): Promise<void> {
const result = await sendCommand('set-file-input', {
files,
selector,
...this._cmdOpts(),
}) as { count?: number };
if (!result?.count) {
throw new Error('setFileInput returned no count — command may not be supported by the extension');
}
}

async waitForCapture(timeout: number = 10): Promise<void> {
const maxMs = timeout * 1000;
await sendCommand('exec', {
Expand Down
80 changes: 79 additions & 1 deletion src/clis/xiaohongshu/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { getRegistry } from '../../registry.js';
import type { IPage } from '../../types.js';
import './publish.js';

function createPageMock(evaluateResults: any[]): IPage {
function createPageMock(evaluateResults: any[], overrides: Partial<IPage> = {}): IPage {
const evaluate = vi.fn();
for (const result of evaluateResults) {
evaluate.mockResolvedValueOnce(result);
Expand Down Expand Up @@ -37,10 +37,88 @@ function createPageMock(evaluateResults: any[]): IPage {
getCookies: vi.fn().mockResolvedValue([]),
screenshot: vi.fn().mockResolvedValue(''),
waitForCapture: vi.fn().mockResolvedValue(undefined),
...overrides,
};
}

describe('xiaohongshu publish', () => {
it('prefers CDP setFileInput upload when the page supports it', async () => {
const cmd = getRegistry().get('xiaohongshu/publish');
expect(cmd?.func).toBeTypeOf('function');

const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
const imagePath = path.join(tempDir, 'demo.jpg');
fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));

const setFileInput = vi.fn().mockResolvedValue(undefined);
const page = createPageMock([
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
{ ok: true, target: '上传图文', text: '上传图文' },
{ state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
'input[type="file"][accept*="image"],input[type="file"][accept*=".jpg"],input[type="file"][accept*=".jpeg"],input[type="file"][accept*=".png"],input[type="file"][accept*=".gif"],input[type="file"][accept*=".webp"]',
false,
true,
{ ok: true, sel: 'input[maxlength="20"]' },
{ ok: true, sel: '[contenteditable="true"][class*="content"]' },
true,
'https://creator.xiaohongshu.com/publish/success',
'发布成功',
], {
setFileInput,
});

const result = await cmd!.func!(page, {
title: 'CDP上传优先',
content: '优先走 setFileInput 主路径',
images: imagePath,
topics: '',
draft: false,
});

expect(setFileInput).toHaveBeenCalledWith(
[imagePath],
expect.stringContaining('input[type="file"][accept*="image"]'),
);
const evaluateCalls = (page.evaluate as any).mock.calls.map((args: any[]) => String(args[0]));
expect(evaluateCalls.some((code: string) => code.includes('atob(img.base64)'))).toBe(false);
expect(result).toEqual([
{
status: '✅ 发布成功',
detail: '"CDP上传优先" · 1张图片 · 发布成功',
},
]);
});

it('fails fast when only a generic file input exists on the page', async () => {
const cmd = getRegistry().get('xiaohongshu/publish');
expect(cmd?.func).toBeTypeOf('function');

const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-'));
const imagePath = path.join(tempDir, 'demo.jpg');
fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9]));

const setFileInput = vi.fn().mockResolvedValue(undefined);
const page = createPageMock([
'https://creator.xiaohongshu.com/publish/publish?from=menu_left',
{ ok: true, target: '上传图文', text: '上传图文' },
{ state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false },
null,
], {
setFileInput,
});

await expect(cmd!.func!(page, {
title: '不要走泛化上传',
content: 'generic file input 应该直接报错',
images: imagePath,
topics: '',
draft: false,
})).rejects.toThrow('Image injection failed: No file input found on page');

expect(setFileInput).not.toHaveBeenCalled();
expect(page.screenshot).toHaveBeenCalledWith({ path: '/tmp/xhs_publish_upload_debug.png' });
});

it('selects the image-text tab and publishes successfully', async () => {
const cmd = getRegistry().get('xiaohongshu/publish');
expect(cmd?.func).toBeTypeOf('function');
Expand Down
Loading