Files
wehub-resource-sync 9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

3346 lines
151 KiB
JavaScript

/**
* ChatGPT web browser automation helpers.
* Cross-platform: works on Linux/macOS/Windows via OpenCLI's CDP browser automation.
*/
import { htmlToMarkdown } from '@jackwener/opencli/utils';
import { ArgumentError, AuthRequiredError, CommandExecutionError, TimeoutError } from '@jackwener/opencli/errors';
export const CHATGPT_DOMAIN = 'chatgpt.com';
export const CHATGPT_URL = 'https://chatgpt.com';
const CHATGPT_MODEL_TARGETS = {
fast: {
label: 'Fast',
labels: ['Fast', 'Speed', 'Instant', '极速', '即时'],
optionLabels: ['Fast', 'Speed', 'Instant', '极速', '即时'],
testIds: ['model-switcher-gpt-5-5'],
intelligenceOrder: 0,
aliases: ['speed', 'instant', '极速'],
},
balanced: {
label: 'Balanced',
labels: ['Balanced', 'Balance', 'Medium', '均衡'],
optionLabels: ['Balanced', 'Balance', 'Medium', '均衡'],
testIds: [],
intelligenceOrder: 1,
aliases: ['balance', 'medium', '均衡'],
},
advanced: {
label: 'Advanced',
labels: ['Advanced', 'High', 'Thinking', '高级', '思考'],
optionLabels: ['Advanced', 'High', 'Thinking', '高级', '思考'],
testIds: ['model-switcher-gpt-5-5-thinking'],
intelligenceOrder: 2,
aliases: ['high', 'thinking', '高级'],
modelConfig: { modelSlug: 'gpt-5-5-thinking', effort: 'extended' },
},
'very-high': {
label: 'Very High',
labels: ['Very High', 'Extra High', 'Ultra', 'XHigh', 'X-High', '超高'],
optionLabels: ['Very High', 'Extra High', 'Ultra', 'XHigh', 'X-High', '超高'],
testIds: [],
intelligenceOrder: 3,
aliases: ['ultra', 'xhigh', 'x-high', 'extra-high', '超高'],
},
pro: {
label: 'Pro',
labels: ['Pro', 'Professional', '进阶专业', '专业'],
optionLabels: ['专业', 'Pro', 'Professional', '进阶专业'],
testIds: ['model-switcher-gpt-5-5-pro'],
intelligenceOrder: 4,
aliases: ['professional', '专业'],
modelConfig: { modelSlug: 'gpt-5-5-pro', effort: 'standard' },
},
};
const CHATGPT_MODEL_ALIASES = Object.fromEntries(Object.entries(CHATGPT_MODEL_TARGETS).flatMap(([key, target]) => [
[key, key],
...(target.aliases || []).map((alias) => [String(alias).toLowerCase(), key]),
]));
export const CHATGPT_MODEL_CHOICES = Object.keys(CHATGPT_MODEL_ALIASES);
function debugChatGPTModel(message) {
if (process?.env?.OPENCLI_CHATGPT_MODEL_DEBUG) {
console.error(`[chatgpt/model] ${message}`);
}
}
const CHATGPT_TOOL_OPTIONS = {
'deep-research': { label: 'Deep Research', labels: ['深度研究', 'Deep Research'] },
'web-search': { label: 'Web Search', labels: ['网页搜索', '搜索', 'Web Search', 'Search'] },
};
export const CHATGPT_TOOL_CHOICES = Object.keys(CHATGPT_TOOL_OPTIONS);
// Selectors
const COMPOSER_SELECTORS = [
'[contenteditable="true"][role="textbox"]',
'#prompt-textarea[contenteditable="true"]',
'[aria-label="Chat with ChatGPT"]',
'[aria-label="与 ChatGPT 聊天"]',
'[placeholder="Ask anything"]',
'[placeholder="有问题,尽管问"]',
'#prompt-textarea',
'[data-testid="prompt-textarea"]',
];
const SEND_BUTTON_SELECTOR = 'button[data-testid="send-button"]:not([disabled])';
const SEND_BUTTON_FALLBACK_SELECTORS = [
'#composer-submit-button:not([disabled])',
];
const SEND_BUTTON_LABELS = [
'Send prompt',
'Send message',
'Send',
'发送',
'发送消息',
'发送提示',
];
const CLOSE_SIDEBAR_LABELS = [
'Close sidebar',
'关闭边栏',
];
function isSameChatGPTConversation(currentUrl, expectedUrl) {
if (!currentUrl || !expectedUrl) return false;
return currentUrl === expectedUrl
|| currentUrl.startsWith(`${expectedUrl}?`)
|| currentUrl.startsWith(`${expectedUrl}#`);
}
function buildComposerLocatorScript() {
const markerAttr = 'data-opencli-chatgpt-composer';
return `
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const markerAttr = ${JSON.stringify(markerAttr)};
const clearMarkers = (active) => {
document.querySelectorAll('[' + markerAttr + ']').forEach(node => {
if (node !== active) node.removeAttribute(markerAttr);
});
};
const findComposer = () => {
for (const selector of ${JSON.stringify(COMPOSER_SELECTORS)}) {
const candidates = Array.from(document.querySelectorAll(selector)).filter(c => c instanceof HTMLElement && isVisible(c));
const node = candidates.find(c => c.isContentEditable) || candidates[0];
if (node instanceof HTMLElement) {
clearMarkers(node);
node.setAttribute(markerAttr, '1');
return node;
}
}
return null;
};
findComposer.toString = () => 'findComposer';
`;
}
export function normalizeBooleanFlag(value, fallback = false) {
if (typeof value === 'boolean') return value;
if (value == null || value === '') return fallback;
const normalized = String(value).trim().toLowerCase();
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
}
export function requireNonEmptyPrompt(prompt, commandName) {
const text = String(prompt ?? '').trim();
if (!text) {
throw new ArgumentError(
`${commandName} prompt cannot be empty`,
`Example: opencli ${commandName} "hello"`,
);
}
return text;
}
export function requirePositiveInt(value, flagLabel, hint) {
if (!Number.isInteger(value) || value < 1) {
throw new ArgumentError(`${flagLabel} must be a positive integer`, hint);
}
return value;
}
export function requireNonNegativeInt(value, flagLabel, hint) {
if (!Number.isInteger(value) || value < 0) {
throw new ArgumentError(`${flagLabel} must be a non-negative integer`, hint);
}
return value;
}
// ─────────────────────────────────────────────────────────────────────────────
// page.evaluate envelope helpers.
//
// The browser bridge wraps every `page.evaluate(...)` return value in a
// `{ session, data }` envelope. Adapters that read `.length` or
// `Array.isArray(payload)` directly on the envelope silently see "no data" —
// this matches the failure mode fixed for xiaohongshu/rednote (#1561) and
// weibo (#1568).
//
// `unwrapEvaluateResult` is a defensive ternary: it unwraps when the payload
// looks like an envelope, otherwise passes the value through unchanged so
// older bridge versions and primitive return values still work.
// ─────────────────────────────────────────────────────────────────────────────
export function unwrapEvaluateResult(payload) {
if (payload && !Array.isArray(payload) && typeof payload === 'object' && 'session' in payload && 'data' in payload) {
return payload.data;
}
return payload;
}
export function requireArrayEvaluateResult(payload, label) {
if (!Array.isArray(payload)) {
if (payload && typeof payload === 'object' && 'error' in payload) {
throw new CommandExecutionError(`${label}: ${String(payload.error)}`);
}
throw new CommandExecutionError(`${label} returned malformed extraction payload`);
}
return payload;
}
export function requireObjectEvaluateResult(payload, label) {
if (!payload || Array.isArray(payload) || typeof payload !== 'object') {
throw new CommandExecutionError(`${label} returned malformed extraction payload`);
}
return payload;
}
export function requireBooleanEvaluateResult(payload, label) {
if (typeof payload !== 'boolean') {
throw new CommandExecutionError(`${label} returned malformed extraction payload`);
}
return payload;
}
function isTrustedChatGPTHost(hostname) {
return hostname === CHATGPT_DOMAIN || hostname.endsWith(`.${CHATGPT_DOMAIN}`);
}
function projectIdFromPathname(pathname) {
const match = String(pathname || '').match(/^\/g\/g-p-([a-f0-9]{8,})(?:[-/]|$)/i);
return match ? match[1].toLowerCase() : '';
}
function projectIdFromUrl(value) {
try {
const url = new URL(String(value || ''), CHATGPT_URL);
if (url.protocol !== 'https:' || !isTrustedChatGPTHost(url.hostname)) return '';
return projectIdFromPathname(url.pathname);
} catch {
return '';
}
}
export function parseChatGPTConversationId(value) {
const raw = String(value ?? '').trim();
if (/^https?:\/\//i.test(raw)) {
try {
const parsed = new URL(raw);
if (parsed.protocol !== 'https:' || (parsed.hostname !== CHATGPT_DOMAIN && !parsed.hostname.endsWith(`.${CHATGPT_DOMAIN}`))) {
throw new Error('off-domain');
}
const match = parsed.pathname.match(/^\/(?:g\/g-p-[^/]+\/)?c\/([A-Za-z0-9_-]{8,})$/);
if (match) return match[1];
} catch {
// Fall through to the shared typed ArgumentError below.
}
throw new ArgumentError(
'chatgpt detail requires a conversation id or chatgpt.com /c/<id> URL',
'Example: opencli chatgpt detail https://chatgpt.com/c/123e4567-e89b-12d3-a456-426614174000',
);
}
const pathMatch = raw.match(/^\/(?:g\/g-p-[^/]+\/)?c\/([A-Za-z0-9_-]{8,})(?:[?#].*)?$/);
if (pathMatch) return pathMatch[1];
if (/^[A-Za-z0-9_-]{8,}$/.test(raw)) return raw;
throw new ArgumentError(
'chatgpt detail requires a conversation id or chatgpt.com /c/<id> URL',
'Example: opencli chatgpt detail 123e4567-e89b-12d3-a456-426614174000',
);
}
export async function currentChatGPTUrl(page) {
const url = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
return typeof url === 'string' ? url : '';
}
export async function isOnChatGPT(page) {
const url = await currentChatGPTUrl(page);
if (!url) return false;
try {
const host = new URL(url).hostname;
return host === CHATGPT_DOMAIN || host.endsWith(`.${CHATGPT_DOMAIN}`);
} catch {
return false;
}
}
// Comma-joined CSS selector list passed to page.wait({ selector }) so the
// wait succeeds as soon as any composer flavour mounts (querySelectorAll
// matches all of them). Tracks the most stable subset of COMPOSER_SELECTORS;
// we only need to know "the composer is ready", not which variant rendered.
const COMPOSER_WAIT_SELECTOR = '#prompt-textarea, [data-testid="prompt-textarea"]';
const CONVERSATION_LINK_SELECTOR = 'a[href*="/c/"]';
const PROJECT_LINK_SELECTOR = 'a[href*="/g/g-p-"]';
// Selector used by detail.js to wait for at least one rendered message bubble
// after navigating to /c/<id>; mirrors the markup queried by getVisibleMessages.
export const CONVERSATION_MESSAGE_SELECTOR = '[data-message-author-role], article[data-testid*="conversation-turn"]';
export async function ensureOnChatGPT(page) {
if (await isOnChatGPT(page)) return false;
await page.goto(CHATGPT_URL, { settleMs: 2000 });
try {
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
} catch {
// Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error.
}
return true;
}
export async function startNewChat(page) {
await page.goto(`${CHATGPT_URL}/new`, { settleMs: 2000 });
try {
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
} catch {
// Composer didn't mount; downstream ensureChatGPTComposer surfaces a typed error.
}
}
export async function openChatGPTConversation(page, value) {
const id = parseChatGPTConversationId(value);
await page.goto(`${CHATGPT_URL}/c/${id}`, { settleMs: 2000 });
try {
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 8 });
} catch {
// Composer didn't mount; downstream ensureChatGPTLogin / ensureChatGPTComposer surfaces a typed error.
}
return id;
}
export async function getPageState(page) {
return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const composerSelectors = ${JSON.stringify(COMPOSER_SELECTORS)};
const hasComposer = composerSelectors.some((selector) =>
Array.from(document.querySelectorAll(selector)).some((node) => isVisible(node))
);
const text = (document.body?.innerText || '').replace(/\\s+/g, ' ').trim();
const loginLink = Array.from(document.querySelectorAll('a, button')).find((node) => {
const label = ((node.innerText || node.textContent || '') + ' ' + (node.getAttribute('aria-label') || '')).trim().toLowerCase();
return isVisible(node) && /^(log in|login|sign up|sign in)$/.test(label);
});
const userMenu = document.querySelector('[data-testid="profile-button"], [aria-label*="Profile"], [aria-label*="Account"], button[id*="headlessui-menu-button"]');
const hasLoginGate = !!loginLink || /log in to chatgpt|sign up to chatgpt|welcome to chatgpt/i.test(text);
return {
url: window.location.href,
title: document.title,
hasComposer,
isLoggedIn: hasComposer || !!userMenu || !hasLoginGate,
hasLoginGate,
};
})()`)), 'chatgpt page state');
}
export async function ensureChatGPTLogin(page, message = 'ChatGPT requires a logged-in browser session.') {
const state = await getPageState(page);
if (!state.isLoggedIn || state.hasLoginGate) {
throw new AuthRequiredError(CHATGPT_DOMAIN, message);
}
return state;
}
export async function ensureChatGPTComposer(page, message = 'ChatGPT composer is not available on the current page.') {
const state = await ensureChatGPTLogin(page, message);
if (!state.hasComposer) {
throw new CommandExecutionError(message);
}
return state;
}
function requireKnownChatGPTModel(model) {
const key = String(model ?? '').trim().toLowerCase();
const targetKey = CHATGPT_MODEL_ALIASES[key] || key;
const option = CHATGPT_MODEL_TARGETS[targetKey];
if (!option) {
throw new ArgumentError(
`Unknown ChatGPT model "${model}"`,
`Choose one of: ${CHATGPT_MODEL_CHOICES.join(', ')}`,
);
}
return { key: targetKey, alias: key !== targetKey ? key : null, ...option };
}
function requireKnownChatGPTTool(tool) {
const key = String(tool ?? '').trim().toLowerCase();
const option = CHATGPT_TOOL_OPTIONS[key];
if (!option) {
throw new ArgumentError(
`Unknown ChatGPT tool "${tool}"`,
`Choose one of: ${CHATGPT_TOOL_CHOICES.join(', ')}`,
);
}
return { key, ...option };
}
export async function getCurrentChatGPTModel(page) {
return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const escapeRegExp = (value) => String(value).replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&');
const textMatchesLabel = (text, label) => {
const normalizedText = normalize(text);
const normalizedLabel = normalize(label);
if (!normalizedText || !normalizedLabel) return false;
if (normalizedText === normalizedLabel) return true;
if (/^[\\x00-\\x7F]+$/.test(normalizedLabel)) {
return new RegExp('(^|\\\\b)' + escapeRegExp(normalizedLabel) + '(\\\\b|$)', 'i').test(normalizedText);
}
return normalizedText.replace(/\\s+/g, '').includes(normalizedLabel.replace(/\\s+/g, ''));
};
const labels = ${JSON.stringify(CHATGPT_MODEL_TARGETS)};
const findEntryForText = (text) => {
const matches = [];
for (const [key, value] of Object.entries(labels)) {
for (const item of value.labels || []) {
if (textMatchesLabel(text, item)) {
matches.push({ key, value, length: normalize(item).length });
}
}
}
matches.sort((a, b) => b.length - a.length);
return matches[0] || null;
};
const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node));
const testIdNode = form
? Array.from(form.querySelectorAll('[data-testid]')).find((node) => {
if (!(node instanceof HTMLElement) || !isVisible(node)) return false;
const testId = node.getAttribute('data-testid');
return Object.values(labels).some((entry) => (entry.testIds || []).includes(testId));
})
: null;
const testId = testIdNode?.getAttribute('data-testid') || '';
const testIdEntry = Object.entries(labels).find(([, value]) => (value.testIds || []).includes(testId));
if (testIdEntry) {
return {
model: testIdEntry[0],
label: testIdEntry[1].label,
};
}
const button = Array.from((form || document).querySelectorAll('button')).find((node) => {
if (!isVisible(node)) return false;
const text = normalize(node.textContent);
return Object.values(labels).some((entry) => entry.labels.some((label) => textMatchesLabel(text, label)));
});
const label = normalize(button?.textContent || '');
const entry = findEntryForText(label);
return {
model: entry?.key ?? null,
label: entry?.value?.label ?? null,
};
})()`)), 'chatgpt current model');
}
async function buildChatGPTBackendHeaders(page, { includeAuthorization = false } = {}) {
if (typeof page.getCookies !== 'function') {
return { ok: false, status: 0, reason: 'missing-cookie-api' };
}
const cookieLists = await Promise.all([
page.getCookies({ url: CHATGPT_URL }).catch(() => []),
page.getCookies({ url: `${CHATGPT_URL}/api/auth/session` }).catch(() => []),
page.getCookies({ domain: CHATGPT_DOMAIN }).catch(() => []),
page.getCookies({ domain: `.${CHATGPT_DOMAIN}` }).catch(() => []),
page.getCookies().catch(() => []),
]);
const cookiesByName = new Map();
for (const cookie of cookieLists.flat()) {
if (!cookie?.name || typeof cookie.value !== 'string') continue;
if (!cookiesByName.has(cookie.name) || cookie.domain === CHATGPT_DOMAIN || cookie.domain === `.${CHATGPT_DOMAIN}`) {
cookiesByName.set(cookie.name, cookie);
}
}
const cookieHeader = Array.from(cookiesByName.values())
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ');
if (!cookieHeader) return { ok: false, status: 0, reason: 'missing-cookies' };
const headers = {
accept: 'application/json',
cookie: cookieHeader,
origin: CHATGPT_URL,
referer: `${CHATGPT_URL}/`,
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
};
if (!includeAuthorization) return { ok: true, status: 200, headers };
const sessionResponse = await fetch(`${CHATGPT_URL}/api/auth/session`, {
headers,
signal: AbortSignal.timeout(10000),
});
if (!sessionResponse.ok) {
return { ok: false, status: sessionResponse.status, reason: 'session' };
}
let session = null;
try {
session = await sessionResponse.json();
} catch {
return { ok: false, status: sessionResponse.status, reason: 'session-json' };
}
const accessToken = session?.accessToken;
if (!accessToken) return { ok: false, status: 0, reason: 'missing-access-token' };
return {
ok: true,
status: 200,
headers: {
...headers,
authorization: `Bearer ${accessToken}`,
},
};
}
async function setChatGPTModelConfig(page, target) {
if (!target.modelConfig) return null;
const auth = await buildChatGPTBackendHeaders(page, { includeAuthorization: true });
if (!auth.ok) return auth;
const modelSlug = target.modelConfig.modelSlug;
const effort = target.modelConfig.effort;
const patchUrl = `${CHATGPT_URL}/backend-api/settings/user_last_used_model_config`
+ `?model_slug=${encodeURIComponent(modelSlug)}`
+ `&thinking_effort=${encodeURIComponent(effort)}`;
const response = await fetch(patchUrl, {
method: 'PATCH',
headers: auth.headers,
signal: AbortSignal.timeout(10000),
});
let body = null;
try { body = await response.json(); } catch {}
if (!response.ok || body?.success !== true) {
return { ok: false, status: response.status, reason: 'patch', body };
}
await page.evaluate(`(() => {
const value = encodeURIComponent(JSON.stringify({ model: ${JSON.stringify(modelSlug)}, effort: ${JSON.stringify(effort)} }));
for (const domain of ['; domain=.chatgpt.com', '; domain=chatgpt.com', '']) {
document.cookie = 'oai-last-model-config=; path=/' + domain + '; max-age=0; SameSite=Lax';
}
document.cookie = 'oai-last-model-config=' + value + '; path=/; domain=.chatgpt.com; max-age=31536000; SameSite=Lax';
document.cookie = 'oai-last-model-config=' + value + '; path=/; max-age=31536000; SameSite=Lax';
if (window.location.pathname === '/new') window.location.reload();
else window.location.assign('/new');
return true;
})()`).catch(() => true);
return { ok: true, status: response.status, modelSlug, effort };
}
export async function selectChatGPTModel(page, model) {
const target = requireKnownChatGPTModel(model);
debugChatGPTModel(`target=${target.key}`);
if (typeof page.nativeClick !== 'function') {
throw new CommandExecutionError('ChatGPT model selection requires native browser click support.');
}
await ensureOnChatGPT(page);
debugChatGPTModel('ensured chatgpt');
const currentUrl = await currentChatGPTUrl(page).catch(() => '');
debugChatGPTModel(`url=${currentUrl}`);
if (!currentUrl.startsWith(`${CHATGPT_URL}/new`)) {
await page.goto(`${CHATGPT_URL}/new`, { waitUntil: 'none' });
await page.wait(2);
}
await ensureChatGPTComposer(page, 'ChatGPT model selection requires a logged-in ChatGPT session with a visible composer.');
debugChatGPTModel('composer ok');
const before = await getCurrentChatGPTModel(page);
debugChatGPTModel(`before=${before.model || 'none'}`);
if (before.model === target.key) {
return { Status: 'Already selected', Model: target.label };
}
const apiResult = await setChatGPTModelConfig(page, target);
debugChatGPTModel(`api=${apiResult ? JSON.stringify({ ok: apiResult.ok, status: apiResult.status, reason: apiResult.reason }) : 'none'}`);
if (apiResult) {
if (!apiResult.ok) {
if (apiResult.status === 401 || apiResult.status === 403) {
throw new AuthRequiredError(CHATGPT_DOMAIN, `ChatGPT model preference API rejected the current session while selecting ${target.label}.`);
}
debugChatGPTModel(`falling back to picker after api failure: ${apiResult.reason || 'unknown'}`);
} else {
debugChatGPTModel('config cookie set and reload scheduled');
await page.wait(2);
await ensureChatGPTComposer(page, 'ChatGPT model selection requires a logged-in ChatGPT session with a visible composer.');
const afterApi = await getCurrentChatGPTModel(page);
debugChatGPTModel(`after-api=${afterApi.model || 'none'}`);
if (afterApi.model === target.key) {
return { Status: 'Success', Model: target.label };
}
debugChatGPTModel('api did not prove selection; falling back to visible picker');
}
}
await page.wait(2);
const menuButton = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const escapeRegExp = (value) => String(value).replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&');
const textMatchesLabel = (text, label) => {
const normalizedText = normalize(text);
const normalizedLabel = normalize(label);
if (!normalizedText || !normalizedLabel) return false;
if (normalizedText === normalizedLabel) return true;
if (/^[\\x00-\\x7F]+$/.test(normalizedLabel)) {
return new RegExp('(^|\\\\b)' + escapeRegExp(normalizedLabel) + '(\\\\b|$)', 'i').test(normalizedText);
}
return normalizedText.replace(/\\s+/g, '').includes(normalizedLabel.replace(/\\s+/g, ''));
};
const labels = ${JSON.stringify(Object.values(CHATGPT_MODEL_TARGETS).flatMap((entry) => entry.labels))};
const menuButtonSelectors = [
'button[data-testid="model-switcher-dropdown-button"]',
'button[aria-label*="model" i]',
'button[aria-label*="模型"]',
'button[aria-label*="智能"]',
];
let button = Array.from(document.querySelectorAll('form button')).find((node) =>
isVisible(node) && labels.some((label) => textMatchesLabel(node.textContent, label))
);
if (!button) {
button = menuButtonSelectors
.map((selector) => document.querySelector(selector))
.find((node) => node instanceof HTMLElement && isVisible(node));
}
if (!button) return { found: false };
button.scrollIntoView({ block: 'center', inline: 'center' });
const rect = button.getBoundingClientRect();
return {
found: true,
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
};
})()`)), 'chatgpt model menu button');
if (!menuButton.found) {
throw new CommandExecutionError('Could not find the ChatGPT model selector in the composer.');
}
await page.nativeClick(Number(menuButton.x), Number(menuButton.y));
await page.wait(0.5);
let optionCenter = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
optionCenter = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const escapeRegExp = (value) => String(value).replace(/[|\\\\{}()[\\]^$+*?.]/g, '\\\\$&');
const textMatchesLabel = (text, label) => {
const normalizedText = normalize(text);
const normalizedLabel = normalize(label);
if (!normalizedText || !normalizedLabel) return false;
if (normalizedText === normalizedLabel) return true;
if (/^[\\x00-\\x7F]+$/.test(normalizedLabel)) {
return new RegExp('(^|\\\\b)' + escapeRegExp(normalizedLabel) + '(\\\\b|$)', 'i').test(normalizedText);
}
return normalizedText.replace(/\\s+/g, '').includes(normalizedLabel.replace(/\\s+/g, ''));
};
const target = ${JSON.stringify(target)};
const clickableSelector = '[role="menuitemradio"], [role="menuitem"], [role="option"], button, [data-testid^="model-switcher"]';
const intelligenceContent = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
const intelligenceOptions = intelligenceContent
? Array.from(intelligenceContent.querySelectorAll('[role="menuitemradio"]')).filter(isVisible)
: [];
let option = null;
for (const testId of target.testIds || []) {
option = Array.from(document.querySelectorAll('[data-testid]')).find((candidate) =>
candidate instanceof HTMLElement && candidate.getAttribute('data-testid') === testId
) || null;
if (option instanceof HTMLElement && isVisible(option)) break;
option = null;
}
const clickables = intelligenceOptions.length ? intelligenceOptions : Array.from(document.querySelectorAll(clickableSelector));
for (const label of target.optionLabels || target.labels || []) {
option = clickables.find((candidate) =>
candidate instanceof HTMLElement
&& isVisible(candidate)
&& textMatchesLabel(candidate.textContent, label)
) || null;
if (option) break;
const labelRoot = intelligenceContent || document;
const labelNode = Array.from(labelRoot.querySelectorAll('span, div, p')).find((candidate) =>
candidate instanceof HTMLElement
&& isVisible(candidate)
&& textMatchesLabel(candidate.textContent, label)
);
option = labelNode?.closest(clickableSelector) || null;
if (option instanceof HTMLElement && isVisible(option)) break;
option = null;
}
if (!option && Number.isInteger(target.intelligenceOrder)) {
if (intelligenceOptions.length === 5) {
option = intelligenceOptions[target.intelligenceOrder] || null;
}
}
if (!(option instanceof HTMLElement) || !isVisible(option)) return { found: false };
option.scrollIntoView({ block: 'center', inline: 'center' });
const rect = option.getBoundingClientRect();
return {
found: true,
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
};
})()`)), 'chatgpt model option click');
if (optionCenter.found) break;
await page.wait(0.5);
}
if (!optionCenter?.found) {
throw new CommandExecutionError(`Could not click the ChatGPT ${target.label} model option.`);
}
await page.nativeClick(Number(optionCenter.x), Number(optionCenter.y));
await page.wait(0.5);
const after = await getCurrentChatGPTModel(page);
if (after.model !== target.key) {
await page.nativeClick(Number(menuButton.x), Number(menuButton.y));
await page.wait(0.5);
const checked = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const target = ${JSON.stringify(target)};
const intelligenceContent = document.querySelector('[data-testid="composer-intelligence-picker-content"]');
const options = intelligenceContent
? Array.from(intelligenceContent.querySelectorAll('[role="menuitemradio"]')).filter(isVisible)
: [];
const checkedIndex = options.findIndex((node) => node.getAttribute('aria-checked') === 'true');
return {
recognized: options.length === 5 && Number.isInteger(target.intelligenceOrder),
checkedIndex,
};
})()`)), 'chatgpt model checked intelligence option');
if (checked.recognized && checked.checkedIndex === target.intelligenceOrder) {
await page.nativeClick(Number(menuButton.x), Number(menuButton.y));
return { Status: 'Success', Model: target.label };
}
await page.nativeClick(Number(menuButton.x), Number(menuButton.y));
throw new CommandExecutionError(`ChatGPT model did not switch to ${target.label}.`);
}
return { Status: 'Success', Model: target.label };
}
export async function getCurrentChatGPTTool(page) {
return requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const compact = (value) => normalize(value).toLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, '');
const matchesLabel = (value, labels) => {
const normalized = normalize(value).toLowerCase();
const compacted = compact(value);
if (!normalized && !compacted) return false;
return labels.some((label) => {
const normalizedLabel = normalize(label).toLowerCase();
const compactedLabel = compact(label);
return normalized === normalizedLabel
|| normalized.includes(normalizedLabel)
|| (compactedLabel && compacted.includes(compactedLabel));
});
};
const labels = ${JSON.stringify(CHATGPT_TOOL_OPTIONS)};
const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node));
const root = form || document.body;
const nodes = Array.from(root.querySelectorAll('button, [role="button"], [role="menuitemradio"], [role="menuitem"], [role="option"], span, div[tabindex="0"], div'));
const node = nodes.find((candidate) => {
if (!isVisible(candidate)) return false;
const composerSelector = '[contenteditable="true"][role="textbox"], #prompt-textarea[contenteditable="true"], [data-testid="prompt-textarea"][contenteditable="true"]';
const toolPill = candidate.closest('[contenteditable="false"]');
if (!toolPill && (candidate.closest(composerSelector) || candidate.querySelector(composerSelector))) return false;
const haystacks = [
candidate.textContent,
candidate.getAttribute('aria-label'),
candidate.getAttribute('title'),
candidate.getAttribute('data-testid'),
];
return Object.values(labels).some((entry) => haystacks.some((value) => matchesLabel(value, entry.labels)));
});
const haystacks = node instanceof HTMLElement
? [
node.textContent,
node.getAttribute('aria-label'),
node.getAttribute('title'),
node.getAttribute('data-testid'),
]
: [];
const entry = Object.entries(labels).find(([, value]) => haystacks.some((candidate) => matchesLabel(candidate, value.labels)));
return {
tool: entry?.[0] ?? null,
label: entry?.[1]?.label ?? null,
};
})()`)), 'chatgpt current tool');
}
export async function selectChatGPTTool(page, tool) {
const target = requireKnownChatGPTTool(tool);
if (typeof page.nativeClick !== 'function') {
throw new CommandExecutionError('ChatGPT tool selection requires native browser click support.');
}
await ensureOnChatGPT(page);
await ensureChatGPTComposer(page, 'ChatGPT tool selection requires a logged-in ChatGPT session with a visible composer.');
const before = await getCurrentChatGPTTool(page);
if (before.tool === target.key) {
return { Status: 'Already selected', Tool: target.label };
}
const menuButton = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const button = document.querySelector('button[data-testid="composer-plus-btn"]');
if (!(button instanceof HTMLElement) || !isVisible(button)) return { found: false };
button.scrollIntoView({ block: 'center', inline: 'center' });
const rect = button.getBoundingClientRect();
return {
found: true,
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
};
})()`)), 'chatgpt tools menu button');
if (!menuButton.found) {
throw new CommandExecutionError('Could not find the ChatGPT tools menu button in the composer.');
}
await page.nativeClick(Number(menuButton.x), Number(menuButton.y));
await page.wait(0.5);
let optionCenter = null;
for (let attempt = 0; attempt < 10; attempt += 1) {
optionCenter = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const compact = (value) => normalize(value).toLowerCase().replace(/[^\\p{L}\\p{N}]+/gu, '');
const labels = ${JSON.stringify(target.labels)};
const optionSelector = '[role="menuitemradio"], [role="menuitem"], [role="option"], button, div[tabindex="0"]';
const matchesLabel = (value) => {
const normalized = normalize(value).toLowerCase();
const compacted = compact(value);
if (!normalized && !compacted) return false;
return labels.some((label) => {
const normalizedLabel = normalize(label).toLowerCase();
const compactedLabel = compact(label);
return normalized === normalizedLabel
|| normalized.includes(normalizedLabel)
|| (compactedLabel && compacted.includes(compactedLabel));
});
};
const rootSelector = '[role="menu"], [role="listbox"], [data-radix-popper-content-wrapper], [data-radix-menu-content], [data-testid*="menu"], [data-testid*="popover"]';
const visibleRoots = Array.from(document.querySelectorAll(rootSelector))
.filter((node) => node instanceof HTMLElement && isVisible(node) && !node.closest('nav, aside'));
const searchRoots = visibleRoots.length ? visibleRoots : [document];
const options = Array.from(new Set(searchRoots.flatMap((root) => {
const matchesRoot = root instanceof HTMLElement && root.matches(optionSelector) ? [root] : [];
return matchesRoot.concat(Array.from(root.querySelectorAll(optionSelector)));
})));
const option = options.find((node) => {
if (!(node instanceof HTMLElement) || !isVisible(node) || node.closest('nav, aside')) return false;
const haystacks = [
node.textContent,
node.getAttribute('aria-label'),
node.getAttribute('title'),
node.getAttribute('data-testid'),
];
return haystacks.some(matchesLabel);
});
if (!(option instanceof HTMLElement)) return { found: false };
const checked = option.getAttribute('aria-checked') === 'true' || option.getAttribute('aria-selected') === 'true';
option.scrollIntoView({ block: 'center', inline: 'center' });
const rect = option.getBoundingClientRect();
return {
found: true,
checked,
x: Math.round(rect.left + rect.width / 2),
y: Math.round(rect.top + rect.height / 2),
};
})()`)), 'chatgpt tool option click');
if (optionCenter.found) break;
await page.wait(0.5);
}
if (!optionCenter?.found) {
throw new CommandExecutionError(`Could not find the ChatGPT ${target.label} tool option.`);
}
if (!optionCenter.checked) {
await page.nativeClick(Number(optionCenter.x), Number(optionCenter.y));
}
await page.wait(0.5);
const after = await getCurrentChatGPTTool(page);
if (after.tool !== target.key) {
throw new CommandExecutionError(`ChatGPT tool did not switch to ${target.label}.`);
}
return { Status: optionCenter.checked ? 'Already selected' : 'Success', Tool: target.label };
}
export async function clearChatGPTDraft(page) {
await page.evaluate(`
(() => {
const removeLabels = [/^remove file/i, /^移除文件/];
for (let pass = 0; pass < 10; pass += 1) {
const button = Array.from(document.querySelectorAll('button')).find((node) => {
const label = node.getAttribute('aria-label') || '';
return removeLabels.some((pattern) => pattern.test(label));
});
if (!button) break;
button.click();
}
const selectors = ${JSON.stringify(COMPOSER_SELECTORS)};
for (const selector of selectors) {
for (const node of document.querySelectorAll(selector)) {
if (!(node instanceof HTMLElement)) continue;
if (node instanceof HTMLTextAreaElement || node instanceof HTMLInputElement) {
node.value = '';
} else if (node.isContentEditable) {
node.textContent = '';
node.innerHTML = '<p><br></p>';
} else {
node.textContent = '';
}
node.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
node.dispatchEvent(new Event('change', { bubbles: true }));
}
}
})()
`);
await page.wait(0.5);
}
export function parseChatGPTProjectId(value) {
const raw = String(value ?? '').trim();
if (/^https?:\/\//i.test(raw) || raw.startsWith('/')) {
const id = projectIdFromUrl(raw);
if (id) return id;
throw new ArgumentError(
'chatgpt project commands require a chatgpt.com project id or /g/g-p-<id> URL',
'Example: opencli chatgpt project-file-add report.pdf --id 12345678',
);
}
// Accept project slug pattern: g-p-{hex_id}-{slug} or just hex id
const slugMatch = raw.match(/^g-p-([a-f0-9]{8,})/i);
if (slugMatch) return slugMatch[1].toLowerCase();
if (/^[a-f0-9]{8,}$/i.test(raw)) return raw.toLowerCase();
throw new ArgumentError(
'chatgpt project commands require a project id or /g/g-p-<id> URL',
'Example: opencli chatgpt project-file-add report.pdf --id 12345678',
);
}
async function closeChatGPTSidebar(page) {
// Close sidebar if open (it can cover the chat composer)
await page.evaluate(`
(() => {
const labels = ${JSON.stringify(CLOSE_SIDEBAR_LABELS)};
const closeBtn = Array.from(document.querySelectorAll('button')).find(b => labels.includes(b.getAttribute('aria-label') || ''));
if (closeBtn) closeBtn.click();
})()
`);
}
/**
* Clear and fill the ChatGPT composer without submitting it.
* Returns true if the composer was ready and the text was inserted.
*/
async function fillChatGPTMessage(page, text) {
await closeChatGPTSidebar(page);
// The previous 0.5 s + 1.5 s pre-composer settles are dropped: the next
// page.evaluate roundtrip flushes the close-sidebar React update and
// findComposer() retries inside a single CDP call, so no fixed sleep is
// needed before reading the composer.
const typeResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
${buildComposerLocatorScript()}
const composer = findComposer();
if (!composer) return { ready: false };
composer.focus();
if (composer instanceof HTMLTextAreaElement || composer instanceof HTMLInputElement) {
composer.value = '';
} else if (composer.isContentEditable) {
const preserved = Array.from(composer.querySelectorAll('[contenteditable="false"]')).map((node) => node.cloneNode(true));
if (preserved.length) {
const p = document.createElement('p');
for (const node of preserved) {
p.appendChild(node);
p.appendChild(document.createTextNode(' '));
}
composer.replaceChildren(p);
} else {
composer.textContent = '';
composer.innerHTML = '<p><br></p>';
}
const range = document.createRange();
range.selectNodeContents(composer);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
} else {
composer.textContent = '';
}
composer.dispatchEvent(new InputEvent('input', { bubbles: true, inputType: 'deleteContentBackward', data: null }));
composer.dispatchEvent(new Event('change', { bubbles: true }));
composer.scrollIntoView({ block: 'center', inline: 'center' });
const rect = composer.getBoundingClientRect();
return {
ready: true,
contentEditable: !!composer.isContentEditable,
x: Math.round(rect.left + Math.max(8, Math.min(rect.width / 2, rect.width - 8))),
y: Math.round(rect.top + Math.max(8, Math.min(rect.height / 2, rect.height - 8))),
};
})()
`)), 'chatgpt composer readiness');
if (!typeResult.ready) return false;
// Use page.type() which is Playwright's native method
try {
if (page.nativeType) {
if (typeof page.nativeClick === 'function') {
await page.nativeClick(Number(typeResult.x), Number(typeResult.y));
await page.wait(0.2);
}
if (typeResult.contentEditable) {
await page.evaluate(`
(() => {
${buildComposerLocatorScript()}
const composer = findComposer();
if (!composer || !composer.isContentEditable) return;
composer.focus();
const range = document.createRange();
range.selectNodeContents(composer);
range.collapse(false);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
})()
`);
}
await page.nativeType(text);
} else {
throw new Error('nativeType unavailable');
}
} catch (e) {
// Fallback: use execCommand
await page.evaluate(`
(() => {
var composer = null;
var sels = ${JSON.stringify(COMPOSER_SELECTORS)};
for (var si = 0; si < sels.length; si++) { composer = document.querySelector(sels[si]); if (composer) break; }
if (!composer) return;
composer.focus();
document.execCommand('insertText', false, ${JSON.stringify(text)});
})()
`);
}
return true;
}
/**
* Submit the current ChatGPT composer contents.
* Returns true if the message was sent successfully.
*/
async function submitChatGPTMessage(page) {
let sent = null;
for (let attempt = 0; attempt < 20; attempt += 1) {
await page.wait(0.5);
sent = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const isUsable = (button) => button
&& isVisible(button)
&& !button.disabled
&& button.getAttribute('aria-disabled') !== 'true';
const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node));
const root = form || document.body;
const primary = root.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => root.querySelector(selector)).find(Boolean);
const btns = Array.from(root.querySelectorAll('button'));
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
const looksLikeSend = (button) => {
const label = button.getAttribute('aria-label') || '';
const text = (button.innerText || button.textContent || '').replace(/\\s+/g, ' ').trim();
return labels.includes(label) || labels.includes(text) || /send|发送/i.test(label) || /send|发送/i.test(text);
};
const sendBtn = isUsable(primary)
? primary
: btns.find(b => looksLikeSend(b) && isUsable(b));
return { sendBtnFound: !!sendBtn };
})()
`)), 'chatgpt send button readiness');
if (sent?.sendBtnFound) break;
}
if (!sent?.sendBtnFound) {
return false;
}
await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const isUsable = (button) => button
&& isVisible(button)
&& !button.disabled
&& button.getAttribute('aria-disabled') !== 'true';
const form = Array.from(document.querySelectorAll('form')).find((node) => node instanceof HTMLElement && isVisible(node));
const root = form || document.body;
const primary = root.querySelector(${JSON.stringify(SEND_BUTTON_SELECTOR)})
|| ${JSON.stringify(SEND_BUTTON_FALLBACK_SELECTORS)}.map(selector => root.querySelector(selector)).find(Boolean);
const labels = ${JSON.stringify(SEND_BUTTON_LABELS)};
const looksLikeSend = (button) => {
const label = button.getAttribute('aria-label') || '';
const text = (button.innerText || button.textContent || '').replace(/\\s+/g, ' ').trim();
return labels.includes(label) || labels.includes(text) || /send|发送/i.test(label) || /send|发送/i.test(text);
};
const sendBtn = isUsable(primary)
? primary
: Array.from(root.querySelectorAll('button')).find(b => looksLikeSend(b) && isUsable(b));
if (sendBtn) sendBtn.click();
})()
`);
return true;
}
/**
* Send a message to the ChatGPT composer and submit it.
* Returns true if the message was sent successfully.
*/
export async function sendChatGPTMessage(page, text) {
const filled = await fillChatGPTMessage(page, text);
if (!filled) return false;
return submitChatGPTMessage(page);
}
export async function getVisibleMessages(page, { textOnly = false } = {}) {
// textOnly skips the per-turn innerHTML read (used only for --markdown
// rendering) so poll loops that need only role + text don't allocate a
// second conversation-sized string on every tick.
const includeHtml = textOnly ? 'false' : 'true';
const result = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const includeHtml = ${includeHtml};
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\u00a0/g, ' ').replace(/[ \\t]+\\n/g, '\\n').replace(/\\n{3,}/g, '\\n\\n').trim();
const roleOf = (node) => {
const attr = node.getAttribute('data-message-author-role') || node.getAttribute('data-author') || '';
if (/assistant/i.test(attr)) return 'Assistant';
if (/user/i.test(attr)) return 'User';
const testid = node.getAttribute('data-testid') || '';
if (/assistant/i.test(testid)) return 'Assistant';
if (/user/i.test(testid)) return 'User';
const label = node.getAttribute('aria-label') || '';
if (/assistant|chatgpt/i.test(label)) return 'Assistant';
if (/you|user/i.test(label)) return 'User';
return '';
};
let nodes = Array.from(document.querySelectorAll('[data-message-author-role], article[data-testid*="conversation-turn"]'));
nodes = nodes.filter((node) => node instanceof HTMLElement && isVisible(node));
const rows = [];
const seen = new Set();
for (const node of nodes) {
let role = roleOf(node);
const roleNode = node.querySelector('[data-message-author-role], [data-author]');
if (!role && roleNode) role = roleOf(roleNode);
if (!role) continue;
const contentNode = node.querySelector('[data-message-author-role] .markdown')
|| node.querySelector('.markdown')
|| node.querySelector('[data-message-author-role]')
|| node;
const html = includeHtml && contentNode instanceof HTMLElement ? (contentNode.innerHTML || '') : '';
const rawText = contentNode instanceof HTMLElement
? (includeHtml ? (contentNode.innerText || contentNode.textContent || '') : (contentNode.textContent || ''))
: '';
const text = normalize(rawText);
if (!text) continue;
const key = role + '\\n' + text;
if (seen.has(key)) continue;
seen.add(key);
rows.push({ role, text, html });
}
return rows;
})()`)), 'chatgpt visible messages');
return result.map((item, index) => ({
Index: index + 1,
Role: item?.role === 'Assistant' ? 'Assistant' : 'User',
Text: String(item?.text || '').trim(),
Html: String(item?.html || ''),
})).filter((item) => item.Text);
}
function formatChatGPTDetailMessages(messages, { wantMarkdown, generating, stableSeconds }) {
return messages.map((message) => ({
Index: message.Index,
Role: message.Role,
Text: wantMarkdown && message.Role === 'Assistant' && message.Html
? (messageHtmlToMarkdown(message.Html) || message.Text)
: message.Text,
Generating: generating,
StableSeconds: stableSeconds,
}));
}
export async function getChatGPTDetailRows(page, { wantMarkdown = false, stableSeconds = 0 } = {}) {
const generating = await isGenerating(page);
const messages = await getVisibleMessages(page);
return {
messages,
rows: formatChatGPTDetailMessages(messages, { wantMarkdown, generating, stableSeconds }),
generating,
};
}
export async function waitForChatGPTDetailRows(page, { wantMarkdown = false, timeoutSeconds = 120, stableSeconds = 6 } = {}) {
const startTime = Date.now();
let lastKey = '';
let stableStartedAt = 0;
while (Date.now() - startTime < timeoutSeconds * 1000) {
const generating = await isGenerating(page);
const messages = await getVisibleMessages(page);
const key = JSON.stringify(messages.map((message) => [message.Role, message.Text]));
if (!generating && messages.length && messages[messages.length - 1]?.Role === 'Assistant') {
if (key === lastKey) {
if (!stableStartedAt) stableStartedAt = Date.now();
const elapsedSeconds = Math.floor((Date.now() - stableStartedAt) / 1000);
if (elapsedSeconds >= stableSeconds) {
return {
messages,
rows: formatChatGPTDetailMessages(messages, {
wantMarkdown,
generating: false,
stableSeconds: elapsedSeconds,
}),
generating: false,
};
}
} else {
lastKey = key;
stableStartedAt = Date.now();
}
} else {
lastKey = key;
stableStartedAt = 0;
}
await page.sleep(3);
}
throw new TimeoutError(
'chatgpt detail',
timeoutSeconds,
'Conversation did not finish or stabilize before timeout. Re-run with a higher --timeout if it is still generating.',
);
}
function normalizeDeepResearchText(value) {
return String(value || '')
.replace(/\u00a0/g, ' ')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim();
}
function looksLikeDeepResearchReport(text) {
const normalized = normalizeDeepResearchText(text);
if (normalized.length < 500) return false;
return /(^|\n)\s*#{1,3}\s+\S|Sources|References|参考|来源|结论|建议|Executive Summary|摘要/i.test(normalized);
}
function parseJsonMaybe(value) {
if (!value) return null;
if (typeof value === 'object') return value;
if (typeof value !== 'string') return null;
try {
return JSON.parse(value);
} catch {
return null;
}
}
function extractDeepResearchSourcesFromReportMessage(reportMessage) {
const metadata = reportMessage?.metadata && typeof reportMessage.metadata === 'object'
? reportMessage.metadata
: {};
const references = Array.isArray(metadata.content_references) ? metadata.content_references : [];
const safeUrls = Array.isArray(metadata.safe_urls) ? metadata.safe_urls : [];
const groups = Array.isArray(metadata.search_result_groups) ? metadata.search_result_groups : [];
const byUrl = new Map();
const addSource = (source = {}, label = 'source') => {
if (!source || typeof source !== 'object') {
throw new CommandExecutionError(`Malformed ChatGPT Deep Research ${label}: expected object source row.`);
}
const rawUrl = String(source.url || source.href || source.safe_url || '').trim();
const title = String(source.title || source.name || source.text || '').trim();
if (!rawUrl) {
if (title || source.matched_text || source.metadata) {
throw new CommandExecutionError(`Malformed ChatGPT Deep Research ${label}: missing source URL.`);
}
return;
}
if (!/^https?:\/\//i.test(rawUrl)) {
throw new CommandExecutionError(`Malformed ChatGPT Deep Research ${label}: invalid source URL.`);
}
if (!byUrl.has(rawUrl)) {
byUrl.set(rawUrl, { title, url: rawUrl });
} else if (title && !byUrl.get(rawUrl).title) {
byUrl.get(rawUrl).title = title;
}
};
for (const reference of references) {
const hasDirectSource = reference && typeof reference === 'object'
&& (reference.url || reference.href || reference.safe_url || reference.title || reference.name || reference.text || reference.matched_text);
if (hasDirectSource) addSource(reference, 'content reference');
if (reference?.matched_text) addSource({ title: reference.matched_text, url: reference.url }, 'matched content reference');
if (reference?.metadata) addSource(reference.metadata, 'content reference metadata');
}
for (const url of safeUrls) addSource(typeof url === 'string' ? { url } : url, 'safe URL');
for (const group of groups) {
for (const entry of [
...(Array.isArray(group?.entries) ? group.entries : []),
...(Array.isArray(group?.results) ? group.results : []),
...(Array.isArray(group?.items) ? group.items : []),
]) {
addSource(entry, 'search result');
}
}
return [...byUrl.values()].slice(0, 200);
}
function pickFirstObject(...values) {
for (const value of values) {
const parsed = parseJsonMaybe(value);
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
}
return {};
}
function stringOrEmpty(value) {
return value === undefined || value === null ? '' : String(value);
}
function compactDeepResearchPlanSteps(plan, stepStatusesByPlan) {
const steps = Array.isArray(plan?.steps) ? plan.steps : [];
return steps.slice(0, 50).map((step, index) => {
const id = stringOrEmpty(step?.id || step?.step_id || step?.plan_step_id || step?.key || index);
return {
id,
title: stringOrEmpty(step?.title || step?.name || step?.summary || step?.description),
status: stringOrEmpty(step?.status || step?.step_status || stepStatusesByPlan?.[id] || ''),
};
}).filter((step) => step.id || step.title || step.status);
}
function deepResearchProgressStatus(progress) {
const venusStatus = String(progress.venusStatus || '').toLowerCase();
if (/waiting_for_user|user_response/.test(venusStatus)) return 'waiting_for_user';
if (/needs_user|user_action|action_required|requires_action/.test(venusStatus)) return 'needs_user_action';
if (/running|in_progress|loading|generating|researching|queued|started|processing/.test(venusStatus)) return 'running';
const venusMessageType = String(progress.venusMessageType || '').toLowerCase();
if (/loading|running|generating|research|progress/.test(venusMessageType)) return 'running';
if (progress.asyncTaskConversationId
|| progress.widgetSessionId
|| progress.asyncStatus !== undefined
|| progress.venusStatus
|| progress.planId
|| progress.planTitle) {
return 'not_ready';
}
return '';
}
function buildDeepResearchProgressResult(state, responseMetadata, source) {
const widgetState = state && typeof state === 'object' ? state : {};
const response = responseMetadata && typeof responseMetadata === 'object' ? responseMetadata : {};
const plan = pickFirstObject(widgetState.plan, widgetState.current_plan, widgetState.research_plan);
const stepStatusesByPlan = pickFirstObject(
widgetState.step_statuses_by_plan,
widgetState.stepStatusesByPlan,
widgetState.step_statuses,
);
const progress = {
asyncTaskConversationId: stringOrEmpty(
response.async_task_conversation_id
|| response.asyncTaskConversationId
|| response['openai/asyncTaskConversationId'],
),
widgetSessionId: stringOrEmpty(
response['openai/widgetSessionId']
|| response.widget_session_id
|| response.widgetSessionId,
),
asyncStatus: response['openai/asyncStatus'] ?? response.async_status ?? response.asyncStatus,
venusMessageType: stringOrEmpty(response.venus_message_type || response.venusMessageType),
venusStatus: stringOrEmpty(widgetState.status || widgetState.venus_status || widgetState.venusStatus),
waitingForUserUntil: stringOrEmpty(
widgetState.waiting_for_user_response_on_plan_until
|| widgetState.waitingForUserResponseOnPlanUntil
|| widgetState.waiting_for_user_until,
),
planId: stringOrEmpty(plan.plan_id || plan.planId || plan.id),
planTitle: stringOrEmpty(plan.title || plan.name),
planSteps: compactDeepResearchPlanSteps(plan, stepStatusesByPlan),
stepStatusesByPlan,
};
const status = deepResearchProgressStatus(progress);
if (!status) return null;
if (/^completed$/i.test(progress.venusStatus)
&& !progress.asyncTaskConversationId
&& !progress.widgetSessionId
&& progress.asyncStatus === undefined
&& !progress.venusMessageType
&& !progress.planId
&& !progress.planTitle
&& !progress.planSteps.length
&& !Object.keys(progress.stepStatusesByPlan || {}).length) {
return null;
}
return {
status,
report: '',
html: '',
method: source.includes('widget-state') ? source.replace('widget-state', 'widget-progress') : `${source}-progress`,
sources: [],
progress,
asyncTaskConversationId: progress.asyncTaskConversationId,
widgetSessionId: progress.widgetSessionId,
asyncStatus: progress.asyncStatus,
venusMessageType: progress.venusMessageType,
venusStatus: progress.venusStatus,
waitingForUserUntil: progress.waitingForUserUntil,
planId: progress.planId,
planTitle: progress.planTitle,
};
}
function deepResearchCandidateScore(candidate) {
if (!candidate) return 0;
if (candidate.status === 'completed') return 1000000 + (candidate.reportLength || candidate.report?.length || 0);
if (candidate.status === 'waiting_for_user' || candidate.status === 'needs_user_action') return 500000;
if (candidate.status === 'running') return 400000;
if (candidate.status === 'not_ready') return 300000;
return 1;
}
function extractDeepResearchFromWidgetState(widgetState, source = 'conversation-widget-state', responseMetadata = null) {
const state = parseJsonMaybe(widgetState);
if ((!state || typeof state !== 'object') && !responseMetadata) return null;
const widgetStateObject = state && typeof state === 'object' ? state : {};
const reportMessage = widgetStateObject.report_message || widgetStateObject.reportMessage || null;
const parts = Array.isArray(reportMessage?.content?.parts) ? reportMessage.content.parts : [];
const report = normalizeDeepResearchText(parts.filter((part) => typeof part === 'string').join('\n\n'));
if (looksLikeDeepResearchReport(report)) {
return {
status: 'completed',
report,
html: '',
method: source,
sources: extractDeepResearchSourcesFromReportMessage(reportMessage),
widgetStatus: String(widgetStateObject.status || ''),
reportMessageId: String(reportMessage?.id || ''),
reportLength: report.length,
};
}
return buildDeepResearchProgressResult(widgetStateObject, pickFirstObject(responseMetadata), source);
}
function extractDeepResearchFromConversationPayload(payload, { expectedConversationId = '' } = {}) {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new CommandExecutionError('Malformed ChatGPT conversation payload for Deep Research extraction.');
}
const payloadConversationId = String(payload.conversation_id || payload.conversationId || payload.id || '').trim();
if (expectedConversationId && payloadConversationId && payloadConversationId !== expectedConversationId) {
throw new CommandExecutionError(
`ChatGPT conversation payload id mismatch: expected ${expectedConversationId}, got ${payloadConversationId}.`,
);
}
const mapping = payload?.mapping && typeof payload.mapping === 'object' ? payload.mapping : {};
if (!payload.mapping || typeof payload.mapping !== 'object' || Array.isArray(payload.mapping)) {
throw new CommandExecutionError('Malformed ChatGPT conversation payload for Deep Research extraction: missing mapping.');
}
const candidates = [];
for (const [messageId, node] of Object.entries(mapping)) {
const message = node?.message || {};
const metadata = message?.metadata || {};
const sdk = metadata?.chatgpt_sdk || {};
const responseMetadata = pickFirstObject(
sdk?.response_metadata,
sdk?.responseMetadata,
metadata?.response_metadata,
metadata?.responseMetadata,
);
let sawWidgetState = false;
for (const widgetState of [
sdk?.widget_state,
sdk?.widgetState,
metadata?.widget_state,
metadata?.widgetState,
]) {
if (widgetState === undefined || widgetState === null) continue;
sawWidgetState = true;
const extracted = extractDeepResearchFromWidgetState(widgetState, 'conversation-widget-state', responseMetadata);
if (extracted) {
candidates.push({
...extracted,
conversationMessageId: messageId,
});
}
}
if (!sawWidgetState && Object.keys(responseMetadata).length) {
const extracted = extractDeepResearchFromWidgetState(null, 'conversation-widget-state', responseMetadata);
if (extracted) {
candidates.push({
...extracted,
conversationMessageId: messageId,
});
}
}
}
candidates.sort((a, b) => deepResearchCandidateScore(b) - deepResearchCandidateScore(a));
return candidates[0] || null;
}
function conversationIdFromBackendConversationUrl(url) {
const match = String(url || '').match(/\/backend-api\/conversation\/([^/?#]+)/);
return match?.[1] ? decodeURIComponent(match[1]) : '';
}
function extractDeepResearchFromNetworkEntries(entries, { expectedConversationId = '' } = {}) {
const candidates = [];
for (const entry of Array.isArray(entries) ? entries : []) {
const url = String(entry?.url || '');
if (!/\/backend-api\/conversation\//.test(url)) continue;
const entryConversationId = conversationIdFromBackendConversationUrl(url);
if (expectedConversationId && entryConversationId !== expectedConversationId) continue;
const body = parseJsonMaybe(entry?.responsePreview) || parseJsonMaybe(entry?.body) || null;
if (!body) {
throw new CommandExecutionError(`Malformed ChatGPT conversation network payload for ${entryConversationId || 'unknown conversation'}.`);
}
const extracted = extractDeepResearchFromConversationPayload(body, { expectedConversationId });
if (extracted) {
candidates.push({
...extracted,
method: extracted.status === 'completed'
? 'network-conversation-widget-state'
: 'network-conversation-widget-progress',
networkUrl: url,
});
}
}
candidates.sort((a, b) => deepResearchCandidateScore(b) - deepResearchCandidateScore(a));
return candidates[0] || null;
}
function conversationIdFromUrl(url) {
const match = String(url || '').match(/\/c\/([a-zA-Z0-9_-]+)/);
return match?.[1] || '';
}
async function buildChatGPTConversationHeaders(page, { includeAuthorization = false } = {}) {
if (typeof page.getCookies !== 'function') {
return { ok: false, status: 0, reason: 'missing-cookie-api' };
}
const cookieLists = await Promise.all([
page.getCookies({ url: CHATGPT_URL }).catch(() => []),
page.getCookies({ url: `${CHATGPT_URL}/api/auth/session` }).catch(() => []),
page.getCookies({ domain: CHATGPT_DOMAIN }).catch(() => []),
page.getCookies({ domain: `.${CHATGPT_DOMAIN}` }).catch(() => []),
page.getCookies().catch(() => []),
]);
const cookiesByName = new Map();
for (const cookie of cookieLists.flat()) {
if (!cookie?.name || typeof cookie.value !== 'string') continue;
if (!cookiesByName.has(cookie.name) || cookie.domain === CHATGPT_DOMAIN || cookie.domain === `.${CHATGPT_DOMAIN}`) {
cookiesByName.set(cookie.name, cookie);
}
}
const cookieHeader = Array.from(cookiesByName.values())
.map((cookie) => `${cookie.name}=${cookie.value}`)
.join('; ');
if (!cookieHeader) return { ok: false, status: 0, reason: 'missing-cookies' };
const headers = {
accept: 'application/json',
cookie: cookieHeader,
origin: CHATGPT_URL,
referer: `${CHATGPT_URL}/`,
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-origin',
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36',
};
if (!includeAuthorization) return { ok: true, status: 200, headers };
const sessionResponse = await fetch(`${CHATGPT_URL}/api/auth/session`, {
headers,
signal: AbortSignal.timeout(10000),
});
if (!sessionResponse.ok) {
return { ok: false, status: sessionResponse.status, reason: 'session' };
}
const session = await sessionResponse.json();
const accessToken = session?.accessToken;
if (!accessToken) return { ok: false, status: 0, reason: 'missing-access-token' };
return {
ok: true,
status: 200,
headers: {
...headers,
authorization: `Bearer ${accessToken}`,
},
};
}
async function fetchChatGPTConversationPayload(page, conversationId) {
if (!conversationId) return null;
const errors = [];
try {
const cookieAuth = await buildChatGPTConversationHeaders(page, { includeAuthorization: false });
if (cookieAuth.ok) {
const response = await fetch(`${CHATGPT_URL}/backend-api/conversation/${conversationId}`, {
headers: {
...cookieAuth.headers,
referer: `${CHATGPT_URL}/c/${conversationId}`,
},
signal: AbortSignal.timeout(8000),
});
const text = await response.text();
if (response.ok) {
const payload = parseJsonMaybe(text);
if (payload) {
return {
payload,
status: response.status,
contentType: response.headers.get('content-type') || '',
transport: 'node-fetch-cookie',
};
}
errors.push('node fetch returned non-json');
} else if (response.status === 401 || response.status === 403) {
errors.push(`node cookie fetch status ${response.status}`);
const bearerAuth = await buildChatGPTConversationHeaders(page, { includeAuthorization: true });
if (bearerAuth.ok) {
const bearerResponse = await fetch(`${CHATGPT_URL}/backend-api/conversation/${conversationId}`, {
headers: {
...bearerAuth.headers,
referer: `${CHATGPT_URL}/c/${conversationId}`,
},
signal: AbortSignal.timeout(8000),
});
const bearerText = await bearerResponse.text();
if (bearerResponse.ok) {
const payload = parseJsonMaybe(bearerText);
if (payload) {
return {
payload,
status: bearerResponse.status,
contentType: bearerResponse.headers.get('content-type') || '',
transport: 'node-fetch-bearer',
};
}
errors.push('node bearer fetch returned non-json');
} else {
errors.push(`node bearer fetch status ${bearerResponse.status}`);
}
} else {
errors.push(`node bearer auth ${bearerAuth.reason || bearerAuth.status || 'failed'}`);
}
} else {
errors.push(`node fetch status ${response.status}`);
}
} else {
errors.push(`node cookie auth ${cookieAuth.reason || cookieAuth.status || 'failed'}`);
}
} catch (error) {
errors.push(`node fetch ${String(error?.message || error)}`);
}
const result = unwrapEvaluateResult(await withTimeout(page.evaluate(`(async () => {
const response = await fetch('/backend-api/conversation/${conversationId}', {
credentials: 'include',
headers: { accept: 'application/json' },
});
const text = await response.text();
return {
ok: response.ok,
status: response.status,
contentType: response.headers.get('content-type') || '',
text,
};
})()`), 8000, 'conversation fetch'));
if (!result?.ok) {
return { error: [...errors, `page fetch status ${result?.status || 0}`].join('; ') };
}
const payload = parseJsonMaybe(result.text);
if (!payload) return { error: [...errors, 'page fetch returned non-json'].join('; ') };
return { payload, status: result.status, contentType: result.contentType, transport: 'page-fetch' };
}
function collectAxText(tree) {
const nodes = Array.isArray(tree?.nodes) ? tree.nodes : [];
const pieces = [];
for (const node of nodes) {
const role = String(node?.role?.value || node?.role || '');
if (/StaticText|InlineTextBox|heading|paragraph|link|button|text/i.test(role)) {
const value = String(node?.name?.value || node?.name || '').trim();
if (value) pieces.push(value);
}
}
return normalizeDeepResearchText(pieces.join('\n'));
}
function withTimeout(promise, ms, label) {
return Promise.race([
promise,
new Promise((_, reject) => setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms)),
]);
}
export async function getChatGPTDeepResearchResult(page, { conversationId = '', useBridgeProbes = false } = {}) {
const iframeState = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const iframes = Array.from(document.querySelectorAll('iframe')).map((frame, index) => {
const rect = frame.getBoundingClientRect();
const title = frame.getAttribute('title') || '';
const src = frame.getAttribute('src') || frame.src || '';
const deepResearch = /deep-research|connector_openai_deep_research/i.test(title + ' ' + src);
let accessible = false;
let text = '';
let html = '';
let accessError = '';
try {
const doc = frame.contentDocument || frame.contentWindow?.document;
accessible = !!doc;
text = normalize(doc?.body?.innerText || doc?.body?.textContent || '');
html = String(doc?.body?.innerHTML || '');
} catch (error) {
accessError = String(error?.name || error);
}
return {
index,
title,
src,
visible: isVisible(frame),
width: Math.round(rect.width),
height: Math.round(rect.height),
deepResearch,
accessible,
accessError,
text,
html,
};
});
const matched = iframes.find((frame) => frame.deepResearch) || null;
return {
url: window.location.href,
title: document.title,
iframes,
deepResearchIframe: matched,
};
})()`)), 'chatgpt deep research iframe state');
const generating = await isGenerating(page).catch(() => false);
const iframe = iframeState.deepResearchIframe;
const currentConversationId = conversationIdFromUrl(iframeState.url);
if (conversationId) {
if (!currentConversationId) {
throw new CommandExecutionError(
`ChatGPT deep-research-result did not stay on requested conversation ${conversationId}.`,
);
}
if (currentConversationId !== conversationId) {
throw new CommandExecutionError(
`ChatGPT deep-research-result conversation mismatch: expected ${conversationId}, current page is ${currentConversationId}.`,
);
}
}
let progressCandidate = null;
const diagnostics = {
iframeCount: Array.isArray(iframeState.iframes) ? iframeState.iframes.length : 0,
iframe: iframe ? {
index: iframe.index,
title: iframe.title,
src: iframe.src,
visible: iframe.visible,
width: iframe.width,
height: iframe.height,
accessible: iframe.accessible,
accessError: iframe.accessError || '',
} : null,
methodsTried: ['main-document-iframe'],
methodsSkipped: useBridgeProbes ? [] : ['browser-frames', 'cdp-accessibility', 'network-capture'],
};
if (useBridgeProbes && typeof page.readNetworkCapture === 'function') {
diagnostics.methodsTried.push('network-conversation-widget-state');
try {
const entries = await withTimeout(page.readNetworkCapture(), 5000, 'network capture read');
const relevantEntries = (Array.isArray(entries) ? entries : [])
.filter((entry) => /\/backend-api\/conversation\/|deep|research|oaiusercontent|ecosystem|widget/i.test(String(entry?.url || '')));
diagnostics.networkEntries = relevantEntries
.slice(-20)
.map((entry) => ({
url: entry.url,
status: entry.responseStatus ?? entry.status ?? 0,
contentType: entry.responseContentType ?? '',
preview: String(entry.responsePreview || '').slice(0, 500),
bodySize: Number(entry.responseBodyFullSize || 0) || undefined,
bodyTruncated: entry.responseBodyTruncated === true || undefined,
}));
const extracted = extractDeepResearchFromNetworkEntries(relevantEntries, { expectedConversationId: conversationId });
if (extracted) {
diagnostics.networkConversation = {
foundReport: extracted.status === 'completed',
foundProgress: extracted.status !== 'completed',
status: extracted.status,
reportLength: extracted.reportLength || extracted.report.length,
sourceCount: Array.isArray(extracted.sources) ? extracted.sources.length : 0,
venusStatus: extracted.venusStatus || '',
asyncTaskConversationId: extracted.asyncTaskConversationId || '',
};
if (extracted.status === 'completed') {
return {
status: 'completed',
report: extracted.report,
html: '',
url: iframeState.url,
method: extracted.method,
sources: extracted.sources || [],
diagnostics,
};
}
progressCandidate = extracted;
}
} catch (error) {
if (error instanceof CommandExecutionError) throw error;
diagnostics.networkConversationError = String(error?.message || error);
}
}
const fetchConversationId = conversationId || currentConversationId;
if (fetchConversationId) {
diagnostics.methodsTried.push('conversation-widget-state');
try {
const conversation = await fetchChatGPTConversationPayload(page, fetchConversationId);
if (conversation?.error) {
diagnostics.conversationError = conversation.error;
} else {
const extracted = extractDeepResearchFromConversationPayload(conversation?.payload, {
expectedConversationId: fetchConversationId,
});
diagnostics.conversation = {
status: conversation?.status || 0,
contentType: conversation?.contentType || '',
transport: conversation?.transport || '',
foundReport: extracted?.status === 'completed',
foundProgress: !!extracted && extracted.status !== 'completed',
deepResearchStatus: extracted?.status || '',
reportLength: extracted?.reportLength || 0,
widgetStatus: extracted?.widgetStatus || '',
venusStatus: extracted?.venusStatus || '',
asyncTaskConversationId: extracted?.asyncTaskConversationId || '',
sourceCount: Array.isArray(extracted?.sources) ? extracted.sources.length : 0,
};
if (extracted?.status === 'completed') {
return {
status: 'completed',
report: extracted.report,
html: '',
url: iframeState.url,
method: extracted.method,
sources: extracted.sources || [],
diagnostics,
};
}
if (extracted) progressCandidate = extracted;
}
} catch (error) {
if (error instanceof CommandExecutionError) throw error;
diagnostics.conversationError = String(error?.message || error);
}
}
if (progressCandidate) {
return {
...progressCandidate,
url: iframeState.url,
diagnostics,
};
}
if (iframe?.text && looksLikeDeepResearchReport(iframe.text)) {
return {
status: 'completed',
report: normalizeDeepResearchText(iframe.text),
html: iframe.html || '',
url: iframeState.url,
method: 'same-origin-iframe-dom',
sources: [],
diagnostics,
};
}
if (!iframe) {
return {
status: generating ? 'running' : 'not_found',
report: '',
html: '',
url: iframeState.url,
method: 'main-document-dom',
sources: [],
diagnostics,
};
}
if (useBridgeProbes && typeof page.frames === 'function' && typeof page.evaluateInFrame === 'function') {
try {
const frames = await withTimeout(page.frames(), 3000, 'browser frames');
diagnostics.frames = Array.isArray(frames) ? frames : [];
for (let index = 0; index < diagnostics.frames.length; index += 1) {
const frameInfo = diagnostics.frames[index];
const frameText = unwrapEvaluateResult(await withTimeout(
page.evaluateInFrame('document.body?.innerText || document.body?.textContent || ""', index),
3000,
'frame eval',
));
const text = normalizeDeepResearchText(frameText);
if ((/deep-research|connector_openai_deep_research/i.test(String(frameInfo?.url || '')) || looksLikeDeepResearchReport(text))
&& looksLikeDeepResearchReport(text)) {
return {
status: 'completed',
report: text,
html: '',
url: iframeState.url,
method: 'browser-frame-dom',
sources: [],
diagnostics,
};
}
}
} catch (error) {
diagnostics.frameError = String(error?.message || error);
}
}
if (useBridgeProbes && typeof page.cdp === 'function') {
try {
const frameTree = await withTimeout(page.cdp('Page.getFrameTree', {}), 5000, 'Page.getFrameTree');
diagnostics.frameTree = frameTree;
const stack = [frameTree?.frameTree].filter(Boolean);
const frames = [];
while (stack.length) {
const node = stack.shift();
const frame = node?.frame;
const url = String(frame?.url || frame?.unreachableUrl || '');
if (frame?.id && /deep-research|connector_openai_deep_research|oaiusercontent/i.test(url)) {
frames.push({ frameId: frame.id, url });
}
for (const child of node?.childFrames || []) stack.push(child);
}
diagnostics.cdpFrames = frames;
for (const frame of frames) {
const tree = await withTimeout(page.cdp('Accessibility.getFullAXTree', {
frameId: frame.frameId,
sessionId: 'target',
targetUrl: frame.url,
}), 5000, 'Accessibility.getFullAXTree').catch(() => null);
const text = collectAxText(tree);
if (looksLikeDeepResearchReport(text)) {
return {
status: 'completed',
report: text,
html: '',
url: iframeState.url,
method: 'cdp-accessibility-frame',
sources: [],
diagnostics,
};
}
}
} catch (error) {
diagnostics.cdpError = String(error?.message || error);
}
}
if (useBridgeProbes && typeof page.readNetworkCapture === 'function' && !diagnostics.networkEntries) {
try {
const entries = await withTimeout(page.readNetworkCapture(), 3000, 'network capture read');
diagnostics.networkEntries = (Array.isArray(entries) ? entries : [])
.filter((entry) => /deep|research|oaiusercontent|ecosystem|widget/i.test(String(entry?.url || '')))
.slice(-20)
.map((entry) => ({
url: entry.url,
status: entry.responseStatus ?? entry.status ?? 0,
contentType: entry.responseContentType ?? '',
preview: String(entry.responsePreview || '').slice(0, 500),
}));
const candidate = diagnostics.networkEntries
.map((entry) => entry.preview)
.find((preview) => looksLikeDeepResearchReport(preview));
if (candidate) {
return {
status: 'completed',
report: normalizeDeepResearchText(candidate),
html: '',
url: iframeState.url,
method: 'network-capture',
sources: [],
diagnostics,
};
}
} catch (error) {
diagnostics.networkError = String(error?.message || error);
}
}
return {
status: generating ? 'running' : 'unavailable',
report: '',
html: '',
url: iframeState.url,
method: 'cross-origin-iframe-detected',
sources: [],
diagnostics,
};
}
export async function waitForChatGPTDeepResearchResult(page, { conversationId = '', timeoutSeconds = 120, stableSeconds = 6 } = {}) {
const startTime = Date.now();
let lastReport = '';
let stableStartedAt = 0;
while (Date.now() - startTime < timeoutSeconds * 1000) {
const result = await getChatGPTDeepResearchResult(page, { conversationId, useBridgeProbes: true });
if (result.status === 'completed' && result.report) {
if (/conversation-widget-state/.test(result.method || '')) {
return { ...result, stableSeconds: 0 };
}
if (result.report === lastReport) {
if (!stableStartedAt) stableStartedAt = Date.now();
const elapsedSeconds = Math.floor((Date.now() - stableStartedAt) / 1000);
if (elapsedSeconds >= stableSeconds) {
return { ...result, stableSeconds: elapsedSeconds };
}
} else {
lastReport = result.report;
stableStartedAt = Date.now();
}
} else if (result.status !== 'running' && result.status !== 'not_ready') {
return result;
}
await page.sleep(3);
}
throw new TimeoutError(
'chatgpt deep-research-result',
timeoutSeconds,
'Deep Research did not complete or become extractable before timeout.',
);
}
export function messageHtmlToMarkdown(html) {
try {
return htmlToMarkdown(html).trim();
} catch {
return String(html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
}
}
export async function getBubbleCount(page) {
const messages = await getVisibleMessages(page);
return messages.length;
}
function cleanPromptText(str) {
return String(str || '')
.replace(/\s+/g, ' ')
.trim();
}
function responsePairKey(user, assistant) {
return JSON.stringify([
cleanPromptText(user?.Text),
String(assistant?.Text || '').trim(),
]);
}
export function getChatGPTResponsePairKeys(messages, prompt) {
const promptKey = cleanPromptText(prompt);
if (!promptKey) return [];
const keys = [];
for (let index = 0; index < messages.length; index += 1) {
const user = messages[index];
if (user?.Role !== 'User' || cleanPromptText(user.Text) !== promptKey) continue;
const assistant = messages.slice(index + 1).find((message) => message?.Role === 'Assistant');
if (!assistant || !String(assistant.Text || '').trim()) continue;
keys.push(responsePairKey(user, assistant));
}
return keys;
}
export function getChatGPTResponsePairCounts(messages, prompt) {
const counts = new Map();
for (const key of getChatGPTResponsePairKeys(messages, prompt)) {
counts.set(key, (counts.get(key) || 0) + 1);
}
return counts;
}
function normalizeBaselinePairCounts(options) {
if (options.baselinePairCounts instanceof Map) return options.baselinePairCounts;
return new Map(Array.from(options.baselinePairKeys || []).map((key) => [key, 1]));
}
function findLatestNewAssistantResponse(messages, prompt, baselinePairCounts) {
const promptKey = cleanPromptText(prompt);
if (!promptKey) return '';
const currentPairCounts = getChatGPTResponsePairCounts(messages, prompt);
for (let index = messages.length - 1; index >= 0; index -= 1) {
const user = messages[index];
if (user?.Role !== 'User' || cleanPromptText(user.Text) !== promptKey) continue;
const assistantIndex = messages.findIndex((message, candidateIndex) => (
candidateIndex > index
&& message?.Role === 'Assistant'
&& String(message.Text || '').trim()
));
if (assistantIndex < 0) continue;
const assistant = messages[assistantIndex];
const key = responsePairKey(user, assistant);
if ((currentPairCounts.get(key) || 0) <= (baselinePairCounts.get(key) || 0)) continue;
return String(assistant.Text || '').trim();
}
return '';
}
export async function waitForChatGPTResponse(page, baselineCount, prompt, timeoutSeconds, options = {}) {
const startTime = Date.now();
let lastText = '';
let stableCount = 0;
const baselinePairCounts = normalizeBaselinePairCounts(options);
while (Date.now() - startTime < timeoutSeconds * 1000) {
await page.sleep(3);
if (options.conversationUrl) {
const currentUrl = await currentChatGPTUrl(page);
if (currentUrl && !isSameChatGPTConversation(currentUrl, options.conversationUrl)) {
throw new CommandExecutionError(
`ChatGPT navigated away from the target conversation (${options.conversationUrl}); current URL is ${currentUrl}`,
);
}
}
if (await isGenerating(page)) {
stableCount = 0;
continue;
}
const messages = await getVisibleMessages(page, { textOnly: true });
const candidate = findLatestNewAssistantResponse(messages, prompt, baselinePairCounts);
if (!candidate || candidate === String(prompt || '').trim()) continue;
if (candidate === lastText) {
stableCount += 1;
if (stableCount >= 2) return candidate;
} else {
lastText = candidate;
stableCount = 0;
}
}
throw new TimeoutError(
'chatgpt ask',
timeoutSeconds,
'No ChatGPT response appeared before timeout. Re-run with a higher --timeout if it is still generating.',
);
}
export async function getConversationList(page) {
// ensureOnChatGPT already waits for the composer selector after navigation,
// so the previous standalone 2 s settle is redundant.
await ensureOnChatGPT(page);
const openSidebar = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const button = Array.from(document.querySelectorAll('button'))
.find((node) => /open sidebar/i.test(node.getAttribute('aria-label') || ''));
if (button instanceof HTMLElement) {
button.click();
return true;
}
return false;
})()`)), 'chatgpt sidebar open state');
if (openSidebar) {
try {
await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 3 });
} catch {
// Sidebar slide-in didn't surface conversation links; extractConversationLinks below tolerates empty and falls back to home goto.
}
}
let items = await extractConversationLinks(page);
if (!items.length) {
await page.goto(CHATGPT_URL, { settleMs: 2000 });
try {
await page.wait({ selector: CONVERSATION_LINK_SELECTOR, timeout: 8 });
} catch {
// No conversation links visible after fallback goto; extractConversationLinks returns empty.
}
items = await extractConversationLinks(page);
}
return items;
}
async function extractConversationLinks(page) {
const items = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const links = Array.from(document.querySelectorAll('a[href*="/c/"]'))
.filter((link) => link instanceof HTMLAnchorElement && isVisible(link));
const seen = new Set();
const rows = [];
for (const link of links) {
const href = link.getAttribute('href') || '';
const match = href.match(/\\/c\\/([^/?#]+)/);
if (!match || seen.has(match[1])) continue;
seen.add(match[1]);
const title = (link.innerText || link.textContent || '').replace(/\\s+/g, ' ').trim() || '(untitled)';
rows.push({
Id: match[1],
Title: title,
Url: href.startsWith('http') ? href : ('${CHATGPT_URL}' + href),
});
}
return rows;
})()`)), 'chatgpt conversation link extraction');
return items.map((item, index) => ({
Index: index + 1,
Id: String(item?.Id || ''),
Title: String(item?.Title || '(untitled)').trim() || '(untitled)',
Url: String(item?.Url || ''),
})).filter((item) => item.Id);
}
function imageMimeFromPath(filePath) {
const lower = String(filePath || '').toLowerCase();
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.heic')) return 'image/heic';
if (lower.endsWith('.heif')) return 'image/heif';
return 'image/jpeg';
}
export async function prepareChatGPTImagePaths(imagePaths) {
const fs = await import('node:fs');
const path = await import('node:path');
const absPaths = imagePaths.map(filePath => path.default.resolve(filePath));
const allowedExts = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif', '.heic', '.heif']);
for (const absPath of absPaths) {
if (!fs.default.existsSync(absPath)) {
return { ok: false, reason: `Image not found: ${absPath}` };
}
const stat = fs.default.statSync(absPath);
if (!stat.isFile()) {
return { ok: false, reason: `Not a file: ${absPath}` };
}
if (stat.size > 25 * 1024 * 1024) {
return { ok: false, reason: `Image too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Max: 25 MB` };
}
const ext = path.default.extname(absPath).toLowerCase();
if (!allowedExts.has(ext)) {
return { ok: false, reason: `Unsupported image type: ${absPath}` };
}
}
return { ok: true, paths: absPaths };
}
async function waitForChatGPTUploadPreview(page, fileNames) {
const namesJson = JSON.stringify(fileNames);
for (let attempt = 0; attempt < 10; attempt += 1) {
await page.sleep(1);
const ready = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
const names = ${namesJson};
const text = document.body ? (document.body.innerText || '') : '';
const matchedNames = names.filter(name => text.includes(name)).length;
if (matchedNames >= names.length) return true;
const composer = document.querySelector('[aria-label="Chat with ChatGPT"], [placeholder="Ask anything"], #prompt-textarea');
let root = composer;
for (let i = 0; i < 6 && root && root.parentElement; i += 1) root = root.parentElement;
const scope = root || document.body;
if (!scope) return false;
const isVisibleMedia = (node) => {
if (!(node instanceof HTMLElement)) return false;
const style = window.getComputedStyle(node);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = node.getBoundingClientRect();
const width = node.naturalWidth || node.videoWidth || rect.width || 0;
const height = node.naturalHeight || node.videoHeight || rect.height || 0;
if (width > 32 && height > 32) return true;
const backgroundImage = style.backgroundImage || '';
return /url\\(/.test(backgroundImage) && rect.width > 32 && rect.height > 32;
};
const previewNodes = Array.from(scope.querySelectorAll('img[src], canvas, video, [style*="background-image"]')).filter(isVisibleMedia);
return previewNodes.length >= names.length;
})()
`)), 'chatgpt upload preview detection');
if (ready) return true;
}
return false;
}
export async function uploadChatGPTImages(page, imagePaths) {
const fs = await import('node:fs');
const path = await import('node:path');
const prepared = await prepareChatGPTImagePaths(imagePaths);
if (!prepared.ok) return prepared;
const absPaths = prepared.paths;
const fileNames = absPaths.map(filePath => path.default.basename(filePath));
let uploaded = false;
if (page.setFileInput) {
try {
await page.setFileInput(absPaths, 'input[type="file"]');
uploaded = true;
} catch (err) {
const msg = String(err?.message || err);
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed') && !msg.includes('No element found')) {
throw err;
}
}
}
if (!uploaded) {
const files = absPaths.map(absPath => ({
name: path.default.basename(absPath),
mime: imageMimeFromPath(absPath),
base64: fs.default.readFileSync(absPath).toString('base64'),
}));
const fallbackResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
const files = ${JSON.stringify(files)};
const input = document.querySelector('input[type="file"]');
if (!(input instanceof HTMLInputElement)) {
return { ok: false, reason: 'file input not found' };
}
const dt = new DataTransfer();
for (const item of files) {
const binary = atob(item.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
dt.items.add(new File([bytes], item.name, { type: item.mime }));
}
input.files = dt.files;
const propsKey = Object.keys(input).find(key => key.startsWith('__reactProps$'));
if (propsKey && input[propsKey] && typeof input[propsKey].onChange === 'function') {
const nativeEvent = new Event('change', { bubbles: true });
input[propsKey].onChange({
target: input,
currentTarget: input,
nativeEvent,
preventDefault() {},
stopPropagation() {},
isDefaultPrevented() { return false; },
isPropagationStopped() { return false; },
persist() {},
});
} else {
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
return { ok: true };
})()
`)), 'chatgpt image upload fallback');
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
}
const ready = await waitForChatGPTUploadPreview(page, fileNames);
if (!ready) return { ok: false, reason: 'image upload preview did not appear' };
return { ok: true, files: absPaths };
}
/**
* Check if ChatGPT is still generating a response.
*/
export async function isGenerating(page) {
// Deliberately avoids document.body.innerText: reading it forces a full-page
// layout/reflow and allocates a conversation-sized string on every poll,
// which during a 10-20 min streamed answer pins the renderer at high CPU.
// Cheap signals only — control aria-labels, the stop-button test id, and a
// textContent (no reflow) scan scoped to the composer + last turn.
return requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
if (document.querySelector('[data-testid="stop-button"]')) return true;
// No bare 'Thinking' here: the model picker renders 'Thinking' as
// an idle model label (see CHATGPT_MODEL_TARGETS.advanced), and an
// English streaming state always comes with the stop button above.
const controls = Array.from(document.querySelectorAll('button, [role="button"], [aria-label]'));
for (const control of controls) {
const label = control.getAttribute('aria-label') || '';
if (label.includes('Stop generating')
|| label.includes('停止生成')
|| label.includes('正在思考')) return true;
}
// The "正在思考 / Thinking" pill can render as plain text without an
// aria-label. Scope the text scan to small containers and use
// textContent (does not trigger layout) instead of body.innerText.
const scopes = [];
// Cover both message shapes from CONVERSATION_MESSAGE_SELECTOR:
// prefer the article turn (the wider container, so a pill outside
// the message div is still seen), fall back to bare
// [data-message-author-role] nodes when articles are absent.
// Bare 'Thinking' only counts inside the last turn — the composer
// area shows 'Thinking' as an idle model label.
const turns = document.querySelectorAll('article[data-testid*="conversation-turn"]');
const messages = turns.length ? turns : document.querySelectorAll('[data-message-author-role]');
if (messages.length) scopes.push([messages[messages.length - 1], /正在思考|停止生成|Thinking/]);
const composer = document.querySelector('#prompt-textarea, [aria-label="Chat with ChatGPT"]');
if (composer) {
let root = composer;
for (let i = 0; i < 4 && root.parentElement; i += 1) root = root.parentElement;
scopes.push([root, /正在思考|停止生成/]);
}
// Only a short leaf element OUTSIDE rendered message content
// counts as a status pill. Testing whole scope text would flag any
// answer that merely *mentions* "Thinking" (prose or a backticked
// code span in a conversation about this very code) as still
// generating, permanently blocking follow-up sends.
for (const [scope, pattern] of scopes) {
for (const el of [scope, ...scope.querySelectorAll('*')]) {
if (el.children.length) continue;
if (el.closest('.markdown, pre, code')) continue;
const text = (el.textContent || '').trim();
if (text && text.length <= 40 && pattern.test(text)) return true;
}
}
return false;
})()
`)), 'chatgpt generation state');
}
/**
* Get visible image URLs from the ChatGPT page (excluding profile/avatar images).
*/
export async function getChatGPTVisibleImageUrls(page) {
return requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 32 && rect.height > 32;
};
const urls = [];
const seen = new Set();
const normalizeUrl = (value) => {
const raw = String(value || '').trim();
if (!raw || raw === 'none') return '';
if (/^(?:https?:|blob:|data:)/i.test(raw)) return raw;
try {
return new URL(raw, window.location.href).href;
} catch {
return raw;
}
};
const addUrl = (value) => {
const src = normalizeUrl(value);
if (!src || seen.has(src)) return;
seen.add(src);
urls.push(src);
};
const isDecorative = (el, src = '') => {
const alt = (el.getAttribute('alt') || '').toLowerCase();
const cls = String(el.className || '').toLowerCase();
const testId = (el.getAttribute('data-testid') || '').toLowerCase();
const label = (el.getAttribute('aria-label') || '').toLowerCase();
const text = [alt, cls, testId, label, src.toLowerCase()].join(' ');
return /avatar|profile|logo|icon/.test(text);
};
const isUserUploadPreview = (img) => {
const alt = (img.getAttribute('alt') || '').toLowerCase();
const turn = img.closest('section[data-testid^="conversation-turn"]');
const heading = (turn?.querySelector('h4')?.innerText || '').toLowerCase();
if (/you said|你说/.test(heading)) return true;
if (/chatgpt|assistant|助手/.test(heading)) return false;
const openButtonLabel = (img.closest('button[aria-label^="Open image:"]')?.getAttribute('aria-label') || '').toLowerCase();
const previewText = [alt, openButtonLabel].join(' ');
return /\.(png|jpe?g|webp|gif|heic|heif)(?:\b|$)/i.test(previewText)
|| /ref-|reference|参考|upload|uploaded|attachment/.test(previewText);
};
const imgs = Array.from(document.querySelectorAll('img')).filter(img =>
img instanceof HTMLImageElement && isVisible(img)
);
for (const img of imgs) {
const src = img.currentSrc || img.src || '';
const width = img.naturalWidth || img.width || 0;
const height = img.naturalHeight || img.height || 0;
if (!src) continue;
if (isDecorative(img, src)) continue;
if (isUserUploadPreview(img)) continue;
if (width < 128 && height < 128) continue;
addUrl(src);
}
// ChatGPT occasionally renders generated images as CSS background
// thumbnails instead of plain <img> nodes. Treat visible, large
// background images as generated-image candidates too.
for (const el of Array.from(document.querySelectorAll('[style*="background-image"], [style*="background"]'))) {
if (!(el instanceof HTMLElement) || !isVisible(el) || isDecorative(el)) continue;
const rect = el.getBoundingClientRect();
if (rect.width < 128 && rect.height < 128) continue;
const backgroundImage = window.getComputedStyle(el).backgroundImage || '';
for (const match of backgroundImage.matchAll(/url\\((['"]?)(.*?)\\1\\)/g)) {
const src = match[2];
if (!src || isDecorative(el, src)) continue;
addUrl(src);
}
}
// Some ChatGPT image surfaces mount large transparent canvases as
// placeholders/overlays before the real backend image is ready. If
// those data URLs are accepted as generated assets, the adapter can
// save a blank transparent PNG while reporting success. Prefer real
// <img>/background URLs; only keep a canvas if it contains at least
// one non-transparent/non-white sampled pixel.
for (const canvas of Array.from(document.querySelectorAll('canvas'))) {
if (!(canvas instanceof HTMLCanvasElement) || !isVisible(canvas) || isDecorative(canvas)) continue;
const width = canvas.width || canvas.getBoundingClientRect().width || 0;
const height = canvas.height || canvas.getBoundingClientRect().height || 0;
if (width < 128 && height < 128) continue;
try {
const ctx = canvas.getContext('2d', { willReadFrequently: true });
if (!ctx) continue;
const sourceWidth = Math.max(1, Math.floor(canvas.width || width));
const sourceHeight = Math.max(1, Math.floor(canvas.height || height));
const xCount = Math.min(sourceWidth, 16);
const yCount = Math.min(sourceHeight, 16);
let hasContent = false;
for (let yi = 0; yi < yCount && !hasContent; yi += 1) {
const y = Math.min(sourceHeight - 1, Math.floor((yi + 0.5) * sourceHeight / yCount));
for (let xi = 0; xi < xCount && !hasContent; xi += 1) {
const x = Math.min(sourceWidth - 1, Math.floor((xi + 0.5) * sourceWidth / xCount));
const pixel = ctx.getImageData(x, y, 1, 1).data;
const r = pixel[0];
const g = pixel[1];
const b = pixel[2];
const a = pixel[3];
if (a > 0 && !(r > 248 && g > 248 && b > 248)) {
hasContent = true;
break;
}
}
}
if (hasContent) addUrl(canvas.toDataURL('image/png'));
} catch { }
}
return urls;
})()
`)), 'chatgpt visible image url extraction');
}
/**
* Wait for new images to appear after sending a prompt.
*/
export async function waitForChatGPTImages(page, beforeUrls, timeoutSeconds, convUrl) {
const beforeSet = new Set(beforeUrls);
const pollIntervalSeconds = 3;
const maxPolls = Math.max(1, Math.ceil(timeoutSeconds / pollIntervalSeconds));
let lastUrls = [];
let stableCount = 0;
for (let i = 0; i < maxPolls; i++) {
await page.sleep(i === 0 ? 3 : pollIntervalSeconds);
let currentUrl = '';
if (convUrl && convUrl.includes('/c/')) {
currentUrl = unwrapEvaluateResult(await page.evaluate('window.location.href').catch(() => ''));
if (currentUrl && !isSameChatGPTConversation(currentUrl, convUrl)) {
await page.goto(convUrl);
await page.sleep(3);
}
}
const generating = await isGenerating(page);
if (generating) continue;
if (convUrl && convUrl.includes('/c/') && i > 0 && i % 5 === 0) {
const onConversation = !currentUrl || isSameChatGPTConversation(currentUrl, convUrl);
if (onConversation) {
await page.goto(convUrl);
await page.sleep(3);
}
}
const urls = (await getChatGPTVisibleImageUrls(page)).filter(url => !beforeSet.has(url));
if (urls.length === 0) continue;
const key = urls.join('\n');
const prevKey = lastUrls.join('\n');
if (key === prevKey) {
stableCount += 1;
} else {
lastUrls = urls;
stableCount = 1;
}
if (stableCount >= 2 || i === maxPolls - 1) {
return lastUrls;
}
}
return lastUrls;
}
/**
* Get the list of ChatGPT Projects from the sidebar.
* Navigates to chatgpt.com if not already there, opens the sidebar,
* and extracts project links (matching /g/g-p-*).
*/
export async function getProjectList(page) {
await ensureOnChatGPT(page);
// Ensure sidebar is open
const openSidebar = requireBooleanEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const button = Array.from(document.querySelectorAll('button'))
.find((node) => /open sidebar/i.test(node.getAttribute('aria-label') || ''));
if (button instanceof HTMLElement) {
button.click();
return true;
}
return false;
})()`)), 'chatgpt sidebar open state');
if (openSidebar) {
await page.wait(0.5);
}
// Click "Show more" to reveal all projects
await page.evaluate(`(() => {
var btn = Array.from(document.querySelectorAll('button')).find(function(b) {
var text = (b.innerText || '').trim();
return text === 'Show more' || text === '显示更多' || text === '查看更多';
});
if (btn instanceof HTMLElement) {
btn.click();
}
})()`);
await page.wait(0.5);
let items = await extractProjectLinks(page);
if (!items.length) {
await page.goto(CHATGPT_URL, { settleMs: 2000 });
await page.wait(1);
// Try clicking Show more again on fresh page
await page.evaluate(`(() => {
var btn = Array.from(document.querySelectorAll('button')).find(function(b) {
var text = (b.innerText || '').trim();
return text === 'Show more' || text === '显示更多' || text === '查看更多';
});
if (btn instanceof HTMLElement) {
btn.click();
}
})()`);
await page.wait(0.5);
items = await extractProjectLinks(page);
}
return items;
}
async function extractProjectLinks(page) {
const items = requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`(() => {
const projectLinkSelector = ${JSON.stringify(PROJECT_LINK_SELECTOR)};
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const cleanText = (value) => String(value || '').replace(new RegExp('\\\\s+', 'g'), ' ').trim();
const trustedHost = (hostname) => hostname === '${CHATGPT_DOMAIN}' || hostname.endsWith('.${CHATGPT_DOMAIN}');
const projectIdFromPathname = (pathname) => {
const match = String(pathname || '').match(new RegExp('^/g/g-p-([a-f0-9]{8,})(?:[-/]|$)', 'i'));
return match ? match[1].toLowerCase() : '';
};
const parseProjectId = (value) => {
const raw = String(value || '').trim();
if (new RegExp('^https?://', 'i').test(raw) || raw.startsWith('/')) {
try {
const url = new URL(raw, '${CHATGPT_URL}');
if (url.protocol !== 'https:' || !trustedHost(url.hostname)) return '';
return projectIdFromPathname(url.pathname);
} catch {
return '';
}
}
const slugMatch = raw.match(new RegExp('^g-p-([a-f0-9]{8,})', 'i'));
if (slugMatch) return slugMatch[1].toLowerCase();
if (new RegExp('^[a-f0-9]{8,}$', 'i').test(raw)) return raw.toLowerCase();
return '';
};
const normalizeProjectUrl = (href, projectId) => {
try {
const url = new URL(href, '${CHATGPT_URL}');
if (url.protocol !== 'https:' || !trustedHost(url.hostname)) return '';
if (projectIdFromPathname(url.pathname) !== projectId) return '';
url.search = '';
url.hash = '';
return url.href.endsWith('/') ? url.href.slice(0, -1) : url.href;
} catch {
return '${CHATGPT_URL}' + '/g/g-p-' + projectId;
}
};
var seen = new Set();
var rows = [];
const addRow = (projectId, title, url) => {
if (!projectId || seen.has(projectId)) return;
seen.add(projectId);
rows.push({
Id: projectId,
Title: cleanText(title) || '(untitled project)',
Url: url || ('${CHATGPT_URL}' + '/g/g-p-' + projectId),
});
};
// Prefer explicit project anchors when the sidebar exposes them. This is
// stable across React internals and matches the URL shape documented by
// PROJECT_LINK_SELECTOR.
for (const link of Array.from(document.querySelectorAll(projectLinkSelector))) {
if (!isVisible(link)) continue;
const href = link.getAttribute('href') || link.href || '';
const projectId = parseProjectId(href);
if (!projectId) continue;
const container = link.closest('[data-sidebar-item="true"]') || link;
addRow(projectId, cleanText(container.innerText || container.textContent || link.textContent), normalizeProjectUrl(href, projectId));
}
// Fallback for ChatGPT sidebar builds that render project rows without
// anchors but keep gizmo data on React Fiber props.
const projectEls = Array.from(document.querySelectorAll('[data-sidebar-item="true"]'))
.filter(function(el) {
if (!isVisible(el)) return false;
var icon = el.querySelector('[data-testid="project-folder-icon"]');
if (!icon) return false;
var text = cleanText(el.innerText || el.textContent);
if (!text) return false;
if (el.getAttribute('data-testid') === 'accounts-profile-button') return false;
return true;
});
for (var i = 0; i < projectEls.length; i++) {
var el = projectEls[i];
var title = cleanText(el.innerText || el.textContent);
var projectId = '';
var shortUrl = '';
var fiberKey = Object.keys(el).find(function(k) { return k.startsWith('__reactFiber$'); });
if (fiberKey) {
var f = el[fiberKey];
for (var d = 0; f && d < 15; d++) {
var props = f.memoizedProps || f.pendingProps;
if (props && props.gizmo) {
var g = props.gizmo;
var gId = g.gizmo && g.gizmo.id ? g.gizmo.id : g.id;
var gIdMatch = String(gId || '').match(new RegExp('^g-p-([a-f0-9]{8,})(?:-|$)', 'i'));
if (gIdMatch) {
projectId = gIdMatch[1].toLowerCase();
shortUrl = String(g.short_url || g.gizmo && g.gizmo.short_url || '');
break;
}
}
f = f.return;
}
}
if (!projectId) continue;
var url = shortUrl ? '${CHATGPT_URL}' + '/g/' + shortUrl : '${CHATGPT_URL}' + '/g/g-p-' + projectId;
addRow(projectId, title, url);
}
return rows;
})()`)), 'chatgpt project link extraction');
return items.map(function(item, index) {
return {
Index: index + 1,
Id: String(item?.Id || ''),
Title: String(item?.Title || '(untitled project)').trim() || '(untitled project)',
Url: String(item?.Url || ''),
};
}).filter(function(item) { return item.Id; });
}
/**
* Navigate to a ChatGPT project page.
*/
const PROJECT_ADD_FILES_LABELS = [
'Add files',
'Add sources',
'添加文件',
'Project files',
'项目文件',
];
const PROJECT_ADD_FILES_DIALOG_SELECTORS = [
'[role="tabpanel"][data-state="active"] [data-project-home-sources-surface="true"] input[type="file"]:not([accept])',
'[data-project-home-sources-surface="true"] input[type="file"]:not([accept])',
'[role="dialog"] input[type="file"]',
'[data-testid*="project-files"] input[type="file"]',
'[data-testid*="project"] input[type="file"]',
];
/**
* Navigate to a ChatGPT project page.
*/
export async function navigateToProject(page, projectId) {
const id = parseChatGPTProjectId(projectId);
await page.goto(`${CHATGPT_URL}/g/g-p-${id}`, { settleMs: 2000 });
try {
await page.wait({ selector: COMPOSER_WAIT_SELECTOR, timeout: 10 });
} catch {
// Composer may not mount if project requires login; downstream ensureChatGPTLogin handles it.
}
const state = await getPageState(page);
if (projectIdFromUrl(state.url) === id) return id;
if (state.hasLoginGate || !state.isLoggedIn) {
throw new AuthRequiredError(CHATGPT_DOMAIN, 'ChatGPT project requires a logged-in ChatGPT session.');
}
throw new CommandExecutionError(
`ChatGPT did not open the requested project ${id}.`,
`Current URL: ${state.url || '(unknown)'}`,
);
}
/**
* Open the Project knowledge files dialog by clicking the "Add files" button
* in the project header area (NOT the chat composer's plus button).
* Returns true if the dialog appeared.
*/
export async function openProjectKnowledgeDialog(page) {
const rawOpenResult = unwrapEvaluateResult(await page.evaluate(`
(() => {
const labels = ${JSON.stringify(PROJECT_ADD_FILES_LABELS)};
const isVisible = (el) => {
if (!(el instanceof HTMLElement)) return false;
const style = window.getComputedStyle(el);
if (style.display === 'none' || style.visibility === 'hidden') return false;
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
// Current ChatGPT project pages expose project knowledge under a
// Sources tab. Prefer that surface when present; it contains the
// project-source file input and avoids the chat composer's plus menu.
const sourceInput = document.querySelector('[data-project-home-sources-surface="true"] input[type="file"]:not([accept])');
if (sourceInput instanceof HTMLInputElement) return { ok: true };
const sourcesTab = Array.from(document.querySelectorAll('[role="tab"], button')).find(el => {
const text = (el.innerText || el.textContent || '').trim();
const id = el.id || '';
return text === 'Sources' || text === '资料' || id.includes('-sources');
});
if (sourcesTab instanceof HTMLElement) {
if (sourcesTab.getAttribute('aria-selected') === 'true') return { ok: true };
const rect = sourcesTab.getBoundingClientRect();
const centerX = rect.left + rect.width / 2;
const centerY = rect.top + rect.height / 2;
const nativeClick = rect.width > 0 && rect.height > 0 && Number.isFinite(centerX) && Number.isFinite(centerY) ? {
x: centerX,
y: centerY,
} : null;
// Radix-powered tabs on the live ChatGPT project page do not
// respond reliably to HTMLElement.click(); they activate after
// the same pointer/mouse sequence a real browser click emits.
const eventInit = {
bubbles: true,
cancelable: true,
composed: true,
view: window,
clientX: nativeClick ? nativeClick.x : 0,
clientY: nativeClick ? nativeClick.y : 0,
button: 0,
buttons: 1,
};
for (const type of ['pointerover', 'pointerenter', 'mouseover', 'mouseenter', 'pointermove', 'mousemove', 'pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
const Ctor = type.startsWith('pointer') && typeof PointerEvent !== 'undefined' ? PointerEvent : MouseEvent;
sourcesTab.dispatchEvent(new Ctor(type, eventInit));
}
return { ok: true, nativeClick };
}
// Older project pages opened a dedicated project files dialog.
// Strategy 1: aria-label or data-testid
const byAttr = Array.from(document.querySelectorAll('button, a, [role="button"]')).find(el => {
if (!isVisible(el)) return false;
if (el.closest('[role="textbox"], #prompt-textarea, [data-testid="composer"], form[data-type="unified-composer"]')) return false;
const aria = (el.getAttribute('aria-label') || '').toLowerCase();
const testid = (el.getAttribute('data-testid') || '').toLowerCase();
const text = (el.innerText || el.textContent || '').trim();
if (aria.includes('add sources') || aria.includes('project files')) return true;
if (aria === 'add files') return true;
if (testid.includes('add-files') || testid.includes('project-files')) return true;
if (labels.some(l => text === l)) return true;
return false;
});
if (byAttr instanceof HTMLElement) { byAttr.click(); return { ok: true }; }
// Strategy 2: look for buttons that contain "Add files"/"Add sources"
// text, but exclude the composer plus button (which has a different role).
const allButtons = Array.from(document.querySelectorAll('button'));
for (const btn of allButtons) {
if (!isVisible(btn)) continue;
const text = (btn.innerText || btn.textContent || '').trim();
if (labels.some(l => text === l) && !btn.closest('[role="textbox"], #prompt-textarea, [data-testid="composer"]')) {
btn.click();
return { ok: true };
}
}
return { ok: false };
})()
`));
const openResult = typeof rawOpenResult === 'boolean'
? { ok: rawOpenResult }
: requireObjectEvaluateResult(rawOpenResult, 'chatgpt project knowledge dialog open');
if (openResult.ok) {
if (openResult.nativeClick && typeof page.nativeClick === 'function') {
try { await page.nativeClick(openResult.nativeClick.x, openResult.nativeClick.y); } catch {}
}
if (openResult.nativeClick && typeof page.click === 'function') {
try { await page.click('[role="tab"][id$="-sources"]'); } catch {}
}
// Wait for the dialog or Sources tab content to appear
await page.wait(1);
try {
await page.wait({ selector: '[role="dialog"], [data-project-home-sources-surface="true"] input[type="file"]', timeout: 5 });
} catch {
// Dialog/source input may use a different shape; upload selectors surface the precise failure.
}
return true;
}
return false;
}
/**
* Upload files to a ChatGPT Project's knowledge base.
* This navigates to the project page, opens the knowledge files dialog,
* and uploads files through the dialog's file input.
*/
export async function uploadChatGPTProjectFiles(page, projectId, filePaths) {
const id = parseChatGPTProjectId(projectId);
const fs = await import('node:fs');
const path = await import('node:path');
const prepared = await prepareChatGPTFilePaths(filePaths);
if (!prepared.ok) return { ...prepared, inputError: true };
const absPaths = prepared.paths;
// Navigate to project and open knowledge dialog
await navigateToProject(page, id);
await ensureChatGPTLogin(page, 'ChatGPT project file upload requires a logged-in ChatGPT session.');
const dialogOpened = await openProjectKnowledgeDialog(page);
if (!dialogOpened) {
return { ok: false, reason: 'could not find or click the project "Add files" button' };
}
// Try uploading via dialog file input (multiple selector patterns)
const fileNames = absPaths.map(fp => path.default.basename(fp));
let uploaded = false;
if (page.setFileInput) {
for (const selector of PROJECT_ADD_FILES_DIALOG_SELECTORS) {
try {
await page.setFileInput(absPaths, selector);
uploaded = true;
break;
} catch (err) {
const msg = String(err?.message || err);
if (!msg.includes('Unknown action') && !msg.includes('not supported') && !msg.includes('Not allowed') && !msg.includes('No element found')) {
throw err;
}
}
}
}
if (!uploaded) {
// Fallback: try all dialog file inputs via evaluate
const files = absPaths.map(absPath => ({
name: path.default.basename(absPath),
mime: mimeFromFilePath(absPath),
base64: fs.default.readFileSync(absPath).toString('base64'),
}));
const fallbackResult = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
const files = ${JSON.stringify(files)};
// Look for file input inside a dialog or the project area
const selectors = ${JSON.stringify(PROJECT_ADD_FILES_DIALOG_SELECTORS)};
let input = null;
for (const sel of selectors) {
input = document.querySelector(sel);
if (input instanceof HTMLInputElement) break;
}
// Last resort: stay scoped to project knowledge containers. Do
// not fall back to arbitrary page inputs, because the composer
// attachment input can also accept files but uploads them to
// the conversation instead of project knowledge.
if (!(input instanceof HTMLInputElement)) {
const allFileInputs = document.querySelectorAll('[data-project-home-sources-surface="true"] input[type="file"], [role="dialog"] input[type="file"], [data-testid*="project"] input[type="file"]');
for (const fi of allFileInputs) {
input = fi;
break;
}
}
if (!(input instanceof HTMLInputElement)) {
return { ok: false, reason: 'project file input not found' };
}
const dt = new DataTransfer();
for (const item of files) {
const binary = atob(item.base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
dt.items.add(new File([bytes], item.name, { type: item.mime }));
}
input.files = dt.files;
const propsKey = Object.keys(input).find(key => key.startsWith('__reactProps$'));
if (propsKey && input[propsKey] && typeof input[propsKey].onChange === 'function') {
const nativeEvent = new Event('change', { bubbles: true });
input[propsKey].onChange({
target: input,
currentTarget: input,
nativeEvent,
preventDefault() {},
stopPropagation() {},
isDefaultPrevented() { return false; },
isPropagationStopped() { return false; },
persist() {},
});
} else {
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
}
return { ok: true };
})()
`)), 'chatgpt project file upload fallback');
if (fallbackResult && !fallbackResult.ok) return fallbackResult;
}
const confirmation = await waitForChatGPTProjectUploadConfirmation(page, fileNames);
if (!confirmation.ok) return confirmation;
return { ok: true, files: absPaths };
}
async function waitForChatGPTProjectUploadConfirmation(page, fileNames) {
const expectedFileNames = fileNames.map(name => String(name || '').trim()).filter(Boolean);
if (!expectedFileNames.length) return { ok: true };
let lastReason = 'uploaded file did not appear in project knowledge';
for (let attempt = 0; attempt < 10; attempt += 1) {
const result = requireObjectEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(() => {
const expectedFileNames = ${JSON.stringify(expectedFileNames)};
const normalize = (value) => String(value || '').replace(/\\s+/g, ' ').trim();
const root = document.querySelector('[role="dialog"]')
|| document.querySelector('[data-project-home-sources-surface="true"]')
|| document.querySelector('[role="tabpanel"][data-state="active"]');
if (!root) {
return { ok: false, pending: true, reason: 'project knowledge surface was not visible after upload' };
}
const text = normalize(root?.innerText || root?.textContent || '');
const errorNode = Array.from((root || document).querySelectorAll('[role="alert"], [data-testid*="error"], [class*="error"]')).find((node) => {
const label = normalize(node.innerText || node.textContent || node.getAttribute('aria-label') || '');
return /failed|error|unable|could not|too large|unsupported/i.test(label);
});
if (errorNode) {
return { ok: false, reason: normalize(errorNode.innerText || errorNode.textContent || errorNode.getAttribute('aria-label') || 'project upload failed') };
}
const missing = expectedFileNames.filter((name) => !text.includes(name));
if (!missing.length) return { ok: true };
return { ok: false, pending: true, reason: 'uploaded file did not appear in project knowledge: ' + missing.join(', ') };
})()
`)), 'chatgpt project upload confirmation');
if (result.ok === true) return { ok: true };
lastReason = String(result.reason || lastReason);
if (!result.pending) return { ok: false, reason: lastReason };
await page.wait(0.5);
}
return { ok: false, reason: lastReason };
}
/**
* Validate local file paths for project file upload.
* Accepts all file types with a 512 MB per-file limit (matching ChatGPT's project limit).
*/
export async function prepareChatGPTFilePaths(filePaths) {
const fs = await import('node:fs');
const path = await import('node:path');
const absPaths = filePaths.map(filePath => path.default.resolve(filePath));
for (const absPath of absPaths) {
if (!fs.default.existsSync(absPath)) {
return { ok: false, reason: `File not found: ${absPath}` };
}
const stat = fs.default.statSync(absPath);
if (!stat.isFile()) {
return { ok: false, reason: `Not a file: ${absPath}` };
}
if (stat.size > 512 * 1024 * 1024) {
return { ok: false, reason: `File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB). Max: 512 MB` };
}
}
return { ok: true, paths: absPaths };
}
function mimeFromFilePath(filePath) {
const lower = String(filePath || '').toLowerCase();
if (lower.endsWith('.pdf')) return 'application/pdf';
if (lower.endsWith('.doc')) return 'application/msword';
if (lower.endsWith('.docx')) return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
if (lower.endsWith('.xls')) return 'application/vnd.ms-excel';
if (lower.endsWith('.xlsx')) return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
if (lower.endsWith('.ppt')) return 'application/vnd.ms-powerpoint';
if (lower.endsWith('.pptx')) return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
if (lower.endsWith('.csv')) return 'text/csv';
if (lower.endsWith('.txt')) return 'text/plain';
if (lower.endsWith('.json')) return 'application/json';
if (lower.endsWith('.xml')) return 'application/xml';
if (lower.endsWith('.html') || lower.endsWith('.htm')) return 'text/html';
if (lower.endsWith('.md')) return 'text/markdown';
if (lower.endsWith('.py')) return 'text/x-python';
if (lower.endsWith('.js')) return 'text/javascript';
if (lower.endsWith('.ts')) return 'application/typescript';
if (lower.endsWith('.jsx')) return 'text/jsx';
if (lower.endsWith('.tsx')) return 'text/tsx';
if (lower.endsWith('.png')) return 'image/png';
if (lower.endsWith('.jpg') || lower.endsWith('.jpeg')) return 'image/jpeg';
if (lower.endsWith('.gif')) return 'image/gif';
if (lower.endsWith('.webp')) return 'image/webp';
if (lower.endsWith('.svg')) return 'image/svg+xml';
return 'application/octet-stream';
}
export const __test__ = {
COMPOSER_SELECTORS,
SEND_BUTTON_SELECTOR,
SEND_BUTTON_FALLBACK_SELECTORS,
SEND_BUTTON_LABELS,
CLOSE_SIDEBAR_LABELS,
buildComposerLocatorScript,
isSameChatGPTConversation,
parseChatGPTConversationId,
parseChatGPTProjectId,
extractDeepResearchFromConversationPayload,
extractDeepResearchFromNetworkEntries,
extractDeepResearchFromWidgetState,
looksLikeDeepResearchReport,
imageMimeFromPath,
mimeFromFilePath,
PROJECT_LINK_SELECTOR,
};
/**
* Export images by URL: fetch from ChatGPT backend API and convert to base64 data URLs.
*/
export async function getChatGPTImageAssets(page, urls) {
const urlsJson = JSON.stringify(urls);
return requireArrayEvaluateResult(unwrapEvaluateResult(await page.evaluate(`
(async (targetUrls) => {
const blobToDataUrl = (blob) => new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(String(reader.result || ''));
reader.onerror = () => reject(new Error('Failed to read blob'));
reader.readAsDataURL(blob);
});
const inferMime = (value, fallbackUrl) => {
if (value) return value;
const lower = String(fallbackUrl || '').toLowerCase();
if (lower.includes('.png')) return 'image/png';
if (lower.includes('.webp')) return 'image/webp';
if (lower.includes('.gif')) return 'image/gif';
return 'image/jpeg';
};
const results = [];
for (const targetUrl of targetUrls) {
let dataUrl = '';
let mimeType = 'image/jpeg';
let width = 0;
let height = 0;
// Try to find the img element for size info
const img = Array.from(document.querySelectorAll('img')).find(el =>
(el.currentSrc || el.src || '') === targetUrl
);
if (img) {
width = img.naturalWidth || img.width || 0;
height = img.naturalHeight || img.height || 0;
} else {
const backgroundEl = Array.from(document.querySelectorAll('[style*="background-image"], [style*="background"]')).find(el => {
if (!(el instanceof HTMLElement)) return false;
const backgroundImage = window.getComputedStyle(el).backgroundImage || '';
return Array.from(backgroundImage.matchAll(/url\\((['"]?)(.*?)\\1\\)/g)).some(match => {
const raw = String(match[2] || '').trim();
if (!raw) return false;
if (raw === targetUrl) return true;
try {
return new URL(raw, window.location.href).href === targetUrl;
} catch {
return false;
}
});
});
if (backgroundEl) {
const rect = backgroundEl.getBoundingClientRect();
width = Math.round(rect.width || 0);
height = Math.round(rect.height || 0);
}
}
try {
if (String(targetUrl).startsWith('data:')) {
dataUrl = String(targetUrl);
mimeType = (String(targetUrl).match(/^data:([^;]+);/i) || [])[1] || 'image/png';
} else {
// Try to fetch via CORS from the page's origin
const res = await fetch(targetUrl, { credentials: 'include' });
if (res.ok) {
const blob = await res.blob();
mimeType = inferMime(blob.type, targetUrl);
dataUrl = await blobToDataUrl(blob);
}
}
} catch (e) {
// If fetch fails (CORS), try canvas approach via img element
}
// Fallback: draw img to canvas
if (!dataUrl && img && img instanceof HTMLImageElement) {
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width || 512;
canvas.height = img.naturalHeight || img.height || 512;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(img, 0, 0);
dataUrl = canvas.toDataURL('image/png');
mimeType = 'image/png';
}
} catch (e) { }
}
if (dataUrl) {
results.push({ url: String(targetUrl), dataUrl, mimeType, width, height });
}
}
return results;
})(${urlsJson})
`)), 'chatgpt image asset export');
}