Add Brave CDP automation, replace Oracle browser mode
Connects to user's running Brave via Chrome DevTools Protocol to automate ChatGPT interaction. Uses puppeteer-core to open a tab, send the prompt, wait for response, and extract the result. No cookies, no separate profiles, no copy/paste. Just connects to the browser where the user is already logged in. One-time setup: relaunch Brave with --remote-debugging-port=9222 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
196
lib/chatgpt-send.mjs
Normal file
196
lib/chatgpt-send.mjs
Normal file
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env node
|
||||
// chatgpt-send.mjs — Send a prompt to ChatGPT via the user's running Brave browser
|
||||
// Connects via Chrome DevTools Protocol to an already-authenticated session.
|
||||
//
|
||||
// Usage: node chatgpt-send.mjs <prompt-file> <output-file> [--timeout <seconds>]
|
||||
//
|
||||
// Requires: Brave running with --remote-debugging-port=9222
|
||||
|
||||
import puppeteer from 'puppeteer-core';
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
|
||||
const CDP_URL = `http://127.0.0.1:${process.env.CHATGPT_CDP_PORT || '9222'}`;
|
||||
const DEFAULT_TIMEOUT_SEC = 600; // 10 minutes for long responses
|
||||
|
||||
function parseArgs() {
|
||||
const args = process.argv.slice(2);
|
||||
let promptFile = null;
|
||||
let outputFile = null;
|
||||
let timeoutSec = DEFAULT_TIMEOUT_SEC;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--timeout' && args[i + 1]) {
|
||||
timeoutSec = parseInt(args[i + 1], 10);
|
||||
i++;
|
||||
} else if (!promptFile) {
|
||||
promptFile = args[i];
|
||||
} else if (!outputFile) {
|
||||
outputFile = args[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (!promptFile || !outputFile) {
|
||||
console.error('Usage: node chatgpt-send.mjs <prompt-file> <output-file> [--timeout <seconds>]');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
return { promptFile, outputFile, timeoutSec };
|
||||
}
|
||||
|
||||
async function waitForSelector(page, selector, timeout) {
|
||||
return page.waitForSelector(selector, { timeout });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { promptFile, outputFile, timeoutSec } = parseArgs();
|
||||
const prompt = readFileSync(promptFile, 'utf-8').trim();
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
|
||||
// Connect to running Brave
|
||||
let browser;
|
||||
try {
|
||||
browser = await puppeteer.connect({ browserURL: CDP_URL });
|
||||
} catch (err) {
|
||||
console.error(`Cannot connect to Brave at ${CDP_URL}`);
|
||||
console.error('Make sure Brave is running with: --remote-debugging-port=9222');
|
||||
console.error('');
|
||||
console.error('Relaunch Brave:');
|
||||
console.error(' 1. Quit Brave (Cmd+Q)');
|
||||
console.error(' 2. Run: open -a "Brave Browser" --args --remote-debugging-port=9222');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Open new tab for ChatGPT
|
||||
const page = await browser.newPage();
|
||||
|
||||
try {
|
||||
console.error('Navigating to ChatGPT...');
|
||||
await page.goto('https://chatgpt.com/', { waitUntil: 'networkidle2', timeout: 30000 });
|
||||
|
||||
// Verify we're logged in by checking for the composer
|
||||
const composerSelector = '#prompt-textarea, [id="prompt-textarea"], div[contenteditable="true"][data-placeholder]';
|
||||
try {
|
||||
await waitForSelector(page, composerSelector, 15000);
|
||||
} catch {
|
||||
// Check if login button is present
|
||||
const loginBtn = await page.$('button[data-testid="login-button"], a[href*="auth"]');
|
||||
if (loginBtn) {
|
||||
console.error('ERROR: Not logged into ChatGPT. Log in via Brave first.');
|
||||
process.exit(1);
|
||||
}
|
||||
console.error('ERROR: Could not find ChatGPT composer. The UI may have changed.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error('Logged in. Sending prompt...');
|
||||
|
||||
// Find and focus the composer
|
||||
const composer = await page.$(composerSelector);
|
||||
await composer.click();
|
||||
|
||||
// Type the prompt — use clipboard for large prompts
|
||||
await page.evaluate(async (text) => {
|
||||
const composer = document.querySelector('#prompt-textarea, [id="prompt-textarea"], div[contenteditable="true"][data-placeholder]');
|
||||
if (composer) {
|
||||
// Use execCommand for contenteditable divs
|
||||
composer.focus();
|
||||
// Clear existing content
|
||||
document.execCommand('selectAll', false, null);
|
||||
// Insert via clipboard API for reliability with large text
|
||||
const clipItem = new ClipboardItem({
|
||||
'text/plain': new Blob([text], { type: 'text/plain' })
|
||||
});
|
||||
await navigator.clipboard.write([clipItem]);
|
||||
document.execCommand('paste');
|
||||
}
|
||||
}, prompt);
|
||||
|
||||
// Small delay to ensure content is rendered
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
|
||||
// Verify content was entered
|
||||
const composerText = await page.evaluate(() => {
|
||||
const el = document.querySelector('#prompt-textarea, [id="prompt-textarea"], div[contenteditable="true"][data-placeholder]');
|
||||
return el ? el.textContent.length : 0;
|
||||
});
|
||||
|
||||
if (composerText < 10) {
|
||||
// Fallback: type directly (slower but more reliable)
|
||||
console.error('Clipboard paste failed, typing directly...');
|
||||
await composer.click({ clickCount: 3 }); // select all
|
||||
await page.keyboard.type(prompt, { delay: 1 });
|
||||
}
|
||||
|
||||
// Find and click the send button
|
||||
const sendSelector = 'button[data-testid="send-button"], button[aria-label="Send prompt"], button[aria-label*="Send"]';
|
||||
const sendBtn = await page.$(sendSelector);
|
||||
if (sendBtn) {
|
||||
await sendBtn.click();
|
||||
} else {
|
||||
// Fallback: press Enter
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
console.error('Prompt sent. Waiting for response...');
|
||||
|
||||
// Wait for the response to complete
|
||||
// Strategy: watch for the stop button to appear then disappear
|
||||
const stopSelector = 'button[data-testid="stop-button"], button[aria-label="Stop generating"], button[aria-label*="Stop"]';
|
||||
|
||||
// Wait for generation to start (stop button appears)
|
||||
try {
|
||||
await waitForSelector(page, stopSelector, 30000);
|
||||
console.error('Generating...');
|
||||
} catch {
|
||||
// Stop button might not appear for very fast responses
|
||||
console.error('Response may have completed quickly.');
|
||||
}
|
||||
|
||||
// Wait for generation to finish (stop button disappears)
|
||||
await page.waitForFunction(
|
||||
(sel) => !document.querySelector(sel),
|
||||
{ timeout: timeoutMs, polling: 1000 },
|
||||
stopSelector
|
||||
);
|
||||
|
||||
// Small delay for final rendering
|
||||
await new Promise(r => setTimeout(r, 2000));
|
||||
|
||||
console.error('Response complete. Extracting...');
|
||||
|
||||
// Extract the last assistant message
|
||||
const response = await page.evaluate(() => {
|
||||
// ChatGPT renders assistant messages in article elements or divs with specific data attributes
|
||||
const messages = document.querySelectorAll(
|
||||
'[data-message-author-role="assistant"], article[data-testid*="conversation-turn"]'
|
||||
);
|
||||
if (messages.length === 0) return null;
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
|
||||
// Try to get markdown content via the copy button's data
|
||||
// Fallback to innerText
|
||||
const markdownEl = lastMessage.querySelector('.markdown, .prose');
|
||||
if (markdownEl) return markdownEl.innerText;
|
||||
return lastMessage.innerText;
|
||||
});
|
||||
|
||||
if (!response) {
|
||||
console.error('ERROR: Could not extract response from page.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
writeFileSync(outputFile, response, 'utf-8');
|
||||
console.error(`Response saved to: ${outputFile} (${response.length} chars)`);
|
||||
|
||||
} finally {
|
||||
// Close the tab we opened, don't close the browser
|
||||
await page.close();
|
||||
browser.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error(`Fatal: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user