@steipete/oracle 1.0.8 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -0
- package/dist/.DS_Store +0 -0
- package/dist/bin/oracle-cli.js +9 -3
- package/dist/markdansi/types/index.js +4 -0
- package/dist/oracle/bin/oracle-cli.js +472 -0
- package/dist/oracle/src/browser/actions/assistantResponse.js +471 -0
- package/dist/oracle/src/browser/actions/attachments.js +82 -0
- package/dist/oracle/src/browser/actions/modelSelection.js +190 -0
- package/dist/oracle/src/browser/actions/navigation.js +75 -0
- package/dist/oracle/src/browser/actions/promptComposer.js +167 -0
- package/dist/oracle/src/browser/chromeLifecycle.js +104 -0
- package/dist/oracle/src/browser/config.js +33 -0
- package/dist/oracle/src/browser/constants.js +40 -0
- package/dist/oracle/src/browser/cookies.js +210 -0
- package/dist/oracle/src/browser/domDebug.js +36 -0
- package/dist/oracle/src/browser/index.js +331 -0
- package/dist/oracle/src/browser/pageActions.js +5 -0
- package/dist/oracle/src/browser/prompt.js +88 -0
- package/dist/oracle/src/browser/promptSummary.js +20 -0
- package/dist/oracle/src/browser/sessionRunner.js +80 -0
- package/dist/oracle/src/browser/types.js +1 -0
- package/dist/oracle/src/browser/utils.js +62 -0
- package/dist/oracle/src/browserMode.js +1 -0
- package/dist/oracle/src/cli/browserConfig.js +44 -0
- package/dist/oracle/src/cli/dryRun.js +59 -0
- package/dist/oracle/src/cli/engine.js +17 -0
- package/dist/oracle/src/cli/errorUtils.js +9 -0
- package/dist/oracle/src/cli/help.js +70 -0
- package/dist/oracle/src/cli/markdownRenderer.js +15 -0
- package/dist/oracle/src/cli/options.js +103 -0
- package/dist/oracle/src/cli/promptRequirement.js +14 -0
- package/dist/oracle/src/cli/rootAlias.js +30 -0
- package/dist/oracle/src/cli/sessionCommand.js +77 -0
- package/dist/oracle/src/cli/sessionDisplay.js +270 -0
- package/dist/oracle/src/cli/sessionRunner.js +94 -0
- package/dist/oracle/src/heartbeat.js +43 -0
- package/dist/oracle/src/oracle/client.js +48 -0
- package/dist/oracle/src/oracle/config.js +29 -0
- package/dist/oracle/src/oracle/errors.js +101 -0
- package/dist/oracle/src/oracle/files.js +220 -0
- package/dist/oracle/src/oracle/format.js +33 -0
- package/dist/oracle/src/oracle/fsAdapter.js +7 -0
- package/dist/oracle/src/oracle/oscProgress.js +60 -0
- package/dist/oracle/src/oracle/request.js +48 -0
- package/dist/oracle/src/oracle/run.js +444 -0
- package/dist/oracle/src/oracle/tokenStats.js +39 -0
- package/dist/oracle/src/oracle/types.js +1 -0
- package/dist/oracle/src/oracle.js +9 -0
- package/dist/oracle/src/sessionManager.js +205 -0
- package/dist/oracle/src/version.js +39 -0
- package/dist/src/cli/markdownRenderer.js +18 -0
- package/dist/src/cli/rootAlias.js +14 -0
- package/dist/src/cli/sessionCommand.js +60 -2
- package/dist/src/cli/sessionDisplay.js +129 -4
- package/dist/src/oracle/oscProgress.js +60 -0
- package/dist/src/oracle/run.js +63 -51
- package/dist/src/sessionManager.js +17 -0
- package/package.json +14 -22
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { MENU_CONTAINER_SELECTOR, MENU_ITEM_SELECTOR, MODEL_BUTTON_SELECTOR, } from '../constants.js';
|
|
2
|
+
import { logDomFailure } from '../domDebug.js';
|
|
3
|
+
export async function ensureModelSelection(Runtime, desiredModel, logger) {
|
|
4
|
+
const outcome = await Runtime.evaluate({
|
|
5
|
+
expression: buildModelSelectionExpression(desiredModel),
|
|
6
|
+
awaitPromise: true,
|
|
7
|
+
returnByValue: true,
|
|
8
|
+
});
|
|
9
|
+
const result = outcome.result?.value;
|
|
10
|
+
switch (result?.status) {
|
|
11
|
+
case 'already-selected':
|
|
12
|
+
case 'switched': {
|
|
13
|
+
const label = result.label ?? desiredModel;
|
|
14
|
+
logger(`Model picker: ${label}`);
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
case 'option-not-found': {
|
|
18
|
+
await logDomFailure(Runtime, logger, 'model-switcher-option');
|
|
19
|
+
throw new Error(`Unable to find model option matching "${desiredModel}" in the model switcher.`);
|
|
20
|
+
}
|
|
21
|
+
default: {
|
|
22
|
+
await logDomFailure(Runtime, logger, 'model-switcher-button');
|
|
23
|
+
throw new Error('Unable to locate the ChatGPT model selector button.');
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function buildModelSelectionExpression(targetModel) {
|
|
28
|
+
const matchers = buildModelMatchersLiteral(targetModel);
|
|
29
|
+
const labelLiteral = JSON.stringify(matchers.labelTokens);
|
|
30
|
+
const idLiteral = JSON.stringify(matchers.testIdTokens);
|
|
31
|
+
const menuContainerLiteral = JSON.stringify(MENU_CONTAINER_SELECTOR);
|
|
32
|
+
const menuItemLiteral = JSON.stringify(MENU_ITEM_SELECTOR);
|
|
33
|
+
return `(() => {
|
|
34
|
+
const BUTTON_SELECTOR = '${MODEL_BUTTON_SELECTOR}';
|
|
35
|
+
const LABEL_TOKENS = ${labelLiteral};
|
|
36
|
+
const TEST_IDS = ${idLiteral};
|
|
37
|
+
const CLICK_INTERVAL_MS = 50;
|
|
38
|
+
const MAX_WAIT_MS = 12000;
|
|
39
|
+
const normalizeText = (value) => {
|
|
40
|
+
if (!value) {
|
|
41
|
+
return '';
|
|
42
|
+
}
|
|
43
|
+
return value
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
46
|
+
.replace(/\\s+/g, ' ')
|
|
47
|
+
.trim();
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const button = document.querySelector(BUTTON_SELECTOR);
|
|
51
|
+
if (!button) {
|
|
52
|
+
return { status: 'button-missing' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
let lastPointerClick = 0;
|
|
56
|
+
const pointerClick = () => {
|
|
57
|
+
const down = new PointerEvent('pointerdown', { bubbles: true, pointerId: 1, pointerType: 'mouse' });
|
|
58
|
+
const up = new PointerEvent('pointerup', { bubbles: true, pointerId: 1, pointerType: 'mouse' });
|
|
59
|
+
const click = new MouseEvent('click', { bubbles: true });
|
|
60
|
+
button.dispatchEvent(down);
|
|
61
|
+
button.dispatchEvent(up);
|
|
62
|
+
button.dispatchEvent(click);
|
|
63
|
+
lastPointerClick = performance.now();
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getOptionLabel = (node) => node?.textContent?.trim() ?? '';
|
|
67
|
+
const optionIsSelected = (node) => {
|
|
68
|
+
if (!(node instanceof HTMLElement)) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const ariaChecked = node.getAttribute('aria-checked');
|
|
72
|
+
const ariaSelected = node.getAttribute('aria-selected');
|
|
73
|
+
const ariaCurrent = node.getAttribute('aria-current');
|
|
74
|
+
const dataSelected = node.getAttribute('data-selected');
|
|
75
|
+
const dataState = (node.getAttribute('data-state') ?? '').toLowerCase();
|
|
76
|
+
const selectedStates = ['checked', 'selected', 'on', 'true'];
|
|
77
|
+
if (ariaChecked === 'true' || ariaSelected === 'true' || ariaCurrent === 'true') {
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
if (dataSelected === 'true' || selectedStates.includes(dataState)) {
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
if (node.querySelector('[data-testid*="check"], [role="img"][data-icon="check"], svg[data-icon="check"]')) {
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
return false;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const findOption = () => {
|
|
90
|
+
const menus = Array.from(document.querySelectorAll(${menuContainerLiteral}));
|
|
91
|
+
for (const menu of menus) {
|
|
92
|
+
const buttons = Array.from(menu.querySelectorAll(${menuItemLiteral}));
|
|
93
|
+
for (const option of buttons) {
|
|
94
|
+
const testid = (option.getAttribute('data-testid') ?? '').toLowerCase();
|
|
95
|
+
const text = option.textContent ?? '';
|
|
96
|
+
const normalizedText = normalizeText(text);
|
|
97
|
+
const matchesTestId = testid && TEST_IDS.some((id) => testid.includes(id));
|
|
98
|
+
const matchesText = LABEL_TOKENS.some((token) => {
|
|
99
|
+
const normalizedToken = normalizeText(token);
|
|
100
|
+
if (!normalizedToken) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
return normalizedText.includes(normalizedToken);
|
|
104
|
+
});
|
|
105
|
+
if (matchesTestId || matchesText) {
|
|
106
|
+
return option;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
pointerClick();
|
|
114
|
+
return new Promise((resolve) => {
|
|
115
|
+
const start = performance.now();
|
|
116
|
+
const ensureMenuOpen = () => {
|
|
117
|
+
const menuOpen = document.querySelector('[role="menu"], [data-radix-collection-root]');
|
|
118
|
+
if (!menuOpen && performance.now() - lastPointerClick > 300) {
|
|
119
|
+
pointerClick();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const attempt = () => {
|
|
123
|
+
ensureMenuOpen();
|
|
124
|
+
const option = findOption();
|
|
125
|
+
if (option) {
|
|
126
|
+
if (optionIsSelected(option)) {
|
|
127
|
+
resolve({ status: 'already-selected', label: getOptionLabel(option) });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
option.click();
|
|
131
|
+
resolve({ status: 'switched', label: getOptionLabel(option) });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (performance.now() - start > MAX_WAIT_MS) {
|
|
135
|
+
resolve({ status: 'option-not-found' });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (performance.now() - lastPointerClick > 500) {
|
|
139
|
+
pointerClick();
|
|
140
|
+
}
|
|
141
|
+
setTimeout(attempt, CLICK_INTERVAL_MS);
|
|
142
|
+
};
|
|
143
|
+
attempt();
|
|
144
|
+
});
|
|
145
|
+
})()`;
|
|
146
|
+
}
|
|
147
|
+
function buildModelMatchersLiteral(targetModel) {
|
|
148
|
+
const base = targetModel.trim().toLowerCase();
|
|
149
|
+
const labelTokens = new Set();
|
|
150
|
+
const testIdTokens = new Set();
|
|
151
|
+
const push = (value, set) => {
|
|
152
|
+
const normalized = value?.trim();
|
|
153
|
+
if (normalized) {
|
|
154
|
+
set.add(normalized);
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
push(base, labelTokens);
|
|
158
|
+
push(base.replace(/\s+/g, ' '), labelTokens);
|
|
159
|
+
const collapsed = base.replace(/\s+/g, '');
|
|
160
|
+
push(collapsed, labelTokens);
|
|
161
|
+
const dotless = base.replace(/[.]/g, '');
|
|
162
|
+
push(dotless, labelTokens);
|
|
163
|
+
push(`chatgpt ${base}`, labelTokens);
|
|
164
|
+
push(`chatgpt ${dotless}`, labelTokens);
|
|
165
|
+
push(`gpt ${base}`, labelTokens);
|
|
166
|
+
push(`gpt ${dotless}`, labelTokens);
|
|
167
|
+
base
|
|
168
|
+
.split(/\s+/)
|
|
169
|
+
.map((token) => token.trim())
|
|
170
|
+
.filter(Boolean)
|
|
171
|
+
.forEach((token) => {
|
|
172
|
+
push(token, labelTokens);
|
|
173
|
+
});
|
|
174
|
+
const hyphenated = base.replace(/\s+/g, '-');
|
|
175
|
+
push(hyphenated, testIdTokens);
|
|
176
|
+
push(collapsed, testIdTokens);
|
|
177
|
+
push(dotless, testIdTokens);
|
|
178
|
+
push(`model-switcher-${hyphenated}`, testIdTokens);
|
|
179
|
+
push(`model-switcher-${collapsed}`, testIdTokens);
|
|
180
|
+
if (!labelTokens.size) {
|
|
181
|
+
labelTokens.add(base);
|
|
182
|
+
}
|
|
183
|
+
if (!testIdTokens.size) {
|
|
184
|
+
testIdTokens.add(base.replace(/\s+/g, '-'));
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
labelTokens: Array.from(labelTokens).filter(Boolean),
|
|
188
|
+
testIdTokens: Array.from(testIdTokens).filter(Boolean),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { CLOUDFLARE_SCRIPT_SELECTOR, CLOUDFLARE_TITLE, INPUT_SELECTORS, } from '../constants.js';
|
|
2
|
+
import { delay } from '../utils.js';
|
|
3
|
+
import { logDomFailure } from '../domDebug.js';
|
|
4
|
+
export async function navigateToChatGPT(Page, Runtime, url, logger) {
|
|
5
|
+
logger(`Navigating to ${url}`);
|
|
6
|
+
await Page.navigate({ url });
|
|
7
|
+
await waitForDocumentReady(Runtime, 45_000);
|
|
8
|
+
}
|
|
9
|
+
export async function ensureNotBlocked(Runtime, headless, logger) {
|
|
10
|
+
if (await isCloudflareInterstitial(Runtime)) {
|
|
11
|
+
const message = headless
|
|
12
|
+
? 'Cloudflare challenge detected in headless mode. Re-run with --headful so you can solve the challenge.'
|
|
13
|
+
: 'Cloudflare challenge detected. Complete the “Just a moment…” check in the open browser, then rerun.';
|
|
14
|
+
logger('Cloudflare anti-bot page detected');
|
|
15
|
+
throw new Error(message);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function ensurePromptReady(Runtime, timeoutMs, logger) {
|
|
19
|
+
const ready = await waitForPrompt(Runtime, timeoutMs);
|
|
20
|
+
if (!ready) {
|
|
21
|
+
await logDomFailure(Runtime, logger, 'prompt-textarea');
|
|
22
|
+
throw new Error('Prompt textarea did not appear before timeout');
|
|
23
|
+
}
|
|
24
|
+
logger('Prompt textarea ready');
|
|
25
|
+
}
|
|
26
|
+
async function waitForDocumentReady(Runtime, timeoutMs) {
|
|
27
|
+
const start = Date.now();
|
|
28
|
+
while (Date.now() - start < timeoutMs) {
|
|
29
|
+
const { result } = await Runtime.evaluate({
|
|
30
|
+
expression: `document.readyState`,
|
|
31
|
+
returnByValue: true,
|
|
32
|
+
});
|
|
33
|
+
if (result?.value === 'complete' || result?.value === 'interactive') {
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
await delay(100);
|
|
37
|
+
}
|
|
38
|
+
throw new Error('Page did not reach ready state in time');
|
|
39
|
+
}
|
|
40
|
+
async function waitForPrompt(Runtime, timeoutMs) {
|
|
41
|
+
const deadline = Date.now() + timeoutMs;
|
|
42
|
+
while (Date.now() < deadline) {
|
|
43
|
+
const { result } = await Runtime.evaluate({
|
|
44
|
+
expression: `(() => {
|
|
45
|
+
const selectors = ${JSON.stringify(INPUT_SELECTORS)};
|
|
46
|
+
for (const selector of selectors) {
|
|
47
|
+
const node = document.querySelector(selector);
|
|
48
|
+
if (node && !node.hasAttribute('disabled')) {
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
})()`,
|
|
54
|
+
returnByValue: true,
|
|
55
|
+
});
|
|
56
|
+
if (result?.value) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
await delay(200);
|
|
60
|
+
}
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
async function isCloudflareInterstitial(Runtime) {
|
|
64
|
+
const { result: titleResult } = await Runtime.evaluate({ expression: 'document.title', returnByValue: true });
|
|
65
|
+
const title = typeof titleResult.value === 'string' ? titleResult.value : '';
|
|
66
|
+
const challengeTitle = CLOUDFLARE_TITLE.toLowerCase();
|
|
67
|
+
if (title.toLowerCase().includes(challengeTitle)) {
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
const { result } = await Runtime.evaluate({
|
|
71
|
+
expression: `Boolean(document.querySelector('${CLOUDFLARE_SCRIPT_SELECTOR}'))`,
|
|
72
|
+
returnByValue: true,
|
|
73
|
+
});
|
|
74
|
+
return Boolean(result.value);
|
|
75
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { INPUT_SELECTORS, PROMPT_PRIMARY_SELECTOR, PROMPT_FALLBACK_SELECTOR, SEND_BUTTON_SELECTOR, CONVERSATION_TURN_SELECTOR, } from '../constants.js';
|
|
2
|
+
import { delay } from '../utils.js';
|
|
3
|
+
import { logDomFailure } from '../domDebug.js';
|
|
4
|
+
export async function submitPrompt(deps, prompt, logger) {
|
|
5
|
+
const { runtime, input } = deps;
|
|
6
|
+
const encodedPrompt = JSON.stringify(prompt);
|
|
7
|
+
const focusResult = await runtime.evaluate({
|
|
8
|
+
expression: `(() => {
|
|
9
|
+
const SELECTORS = ${JSON.stringify(INPUT_SELECTORS)};
|
|
10
|
+
const dispatchPointer = (target) => {
|
|
11
|
+
if (!(target instanceof HTMLElement)) {
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
|
15
|
+
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window }));
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
const focusNode = (node) => {
|
|
19
|
+
if (!node) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
dispatchPointer(node);
|
|
23
|
+
if (typeof node.focus === 'function') {
|
|
24
|
+
node.focus();
|
|
25
|
+
}
|
|
26
|
+
const doc = node.ownerDocument;
|
|
27
|
+
const selection = doc?.getSelection?.();
|
|
28
|
+
if (selection) {
|
|
29
|
+
const range = doc.createRange();
|
|
30
|
+
range.selectNodeContents(node);
|
|
31
|
+
range.collapse(false);
|
|
32
|
+
selection.removeAllRanges();
|
|
33
|
+
selection.addRange(range);
|
|
34
|
+
}
|
|
35
|
+
return true;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
for (const selector of SELECTORS) {
|
|
39
|
+
const node = document.querySelector(selector);
|
|
40
|
+
if (!node) continue;
|
|
41
|
+
if (focusNode(node)) {
|
|
42
|
+
return { focused: true };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return { focused: false };
|
|
46
|
+
})()`,
|
|
47
|
+
returnByValue: true,
|
|
48
|
+
awaitPromise: true,
|
|
49
|
+
});
|
|
50
|
+
if (!focusResult.result?.value?.focused) {
|
|
51
|
+
await logDomFailure(runtime, logger, 'focus-textarea');
|
|
52
|
+
throw new Error('Failed to focus prompt textarea');
|
|
53
|
+
}
|
|
54
|
+
await input.insertText({ text: prompt });
|
|
55
|
+
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
56
|
+
const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
|
|
57
|
+
const verification = await runtime.evaluate({
|
|
58
|
+
expression: `(() => {
|
|
59
|
+
const editor = document.querySelector(${primarySelectorLiteral});
|
|
60
|
+
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
61
|
+
return {
|
|
62
|
+
editorText: editor?.innerText ?? '',
|
|
63
|
+
fallbackValue: fallback?.value ?? '',
|
|
64
|
+
};
|
|
65
|
+
})()`,
|
|
66
|
+
returnByValue: true,
|
|
67
|
+
});
|
|
68
|
+
const editorText = verification.result?.value?.editorText?.trim?.() ?? '';
|
|
69
|
+
const fallbackValue = verification.result?.value?.fallbackValue?.trim?.() ?? '';
|
|
70
|
+
if (!editorText && !fallbackValue) {
|
|
71
|
+
await runtime.evaluate({
|
|
72
|
+
expression: `(() => {
|
|
73
|
+
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
74
|
+
if (fallback) {
|
|
75
|
+
fallback.value = ${encodedPrompt};
|
|
76
|
+
fallback.dispatchEvent(new InputEvent('input', { bubbles: true, data: ${encodedPrompt}, inputType: 'insertFromPaste' }));
|
|
77
|
+
fallback.dispatchEvent(new Event('change', { bubbles: true }));
|
|
78
|
+
}
|
|
79
|
+
const editor = document.querySelector(${primarySelectorLiteral});
|
|
80
|
+
if (editor) {
|
|
81
|
+
editor.textContent = ${encodedPrompt};
|
|
82
|
+
}
|
|
83
|
+
})()`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
const clicked = await attemptSendButton(runtime);
|
|
87
|
+
if (!clicked) {
|
|
88
|
+
await input.dispatchKeyEvent({
|
|
89
|
+
type: 'rawKeyDown',
|
|
90
|
+
key: 'Enter',
|
|
91
|
+
code: 'Enter',
|
|
92
|
+
windowsVirtualKeyCode: 13,
|
|
93
|
+
nativeVirtualKeyCode: 13,
|
|
94
|
+
});
|
|
95
|
+
await input.dispatchKeyEvent({
|
|
96
|
+
type: 'keyUp',
|
|
97
|
+
key: 'Enter',
|
|
98
|
+
code: 'Enter',
|
|
99
|
+
windowsVirtualKeyCode: 13,
|
|
100
|
+
nativeVirtualKeyCode: 13,
|
|
101
|
+
});
|
|
102
|
+
logger('Submitted prompt via Enter key');
|
|
103
|
+
}
|
|
104
|
+
else {
|
|
105
|
+
logger('Clicked send button');
|
|
106
|
+
}
|
|
107
|
+
await verifyPromptCommitted(runtime, prompt, 30_000, logger);
|
|
108
|
+
}
|
|
109
|
+
async function attemptSendButton(Runtime) {
|
|
110
|
+
const script = `(() => {
|
|
111
|
+
const button = document.querySelector('${SEND_BUTTON_SELECTOR}');
|
|
112
|
+
if (!button) {
|
|
113
|
+
return 'missing';
|
|
114
|
+
}
|
|
115
|
+
const ariaDisabled = button.getAttribute('aria-disabled');
|
|
116
|
+
const disabled = button.hasAttribute('disabled') || ariaDisabled === 'true';
|
|
117
|
+
if (disabled || window.getComputedStyle(button).display === 'none') {
|
|
118
|
+
return 'disabled';
|
|
119
|
+
}
|
|
120
|
+
button.click();
|
|
121
|
+
return 'clicked';
|
|
122
|
+
})()`;
|
|
123
|
+
const deadline = Date.now() + 2_000;
|
|
124
|
+
while (Date.now() < deadline) {
|
|
125
|
+
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
126
|
+
if (result.value === 'clicked') {
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
if (result.value === 'missing') {
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
await delay(100);
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
async function verifyPromptCommitted(Runtime, prompt, timeoutMs, logger) {
|
|
137
|
+
const deadline = Date.now() + timeoutMs;
|
|
138
|
+
const encodedPrompt = JSON.stringify(prompt.trim());
|
|
139
|
+
const primarySelectorLiteral = JSON.stringify(PROMPT_PRIMARY_SELECTOR);
|
|
140
|
+
const fallbackSelectorLiteral = JSON.stringify(PROMPT_FALLBACK_SELECTOR);
|
|
141
|
+
const script = `(() => {
|
|
142
|
+
const editor = document.querySelector(${primarySelectorLiteral});
|
|
143
|
+
const fallback = document.querySelector(${fallbackSelectorLiteral});
|
|
144
|
+
const normalize = (value) => value?.toLowerCase?.().replace(/\\s+/g, ' ').trim() ?? '';
|
|
145
|
+
const normalizedPrompt = normalize(${encodedPrompt});
|
|
146
|
+
const CONVERSATION_SELECTOR = ${JSON.stringify(CONVERSATION_TURN_SELECTOR)};
|
|
147
|
+
const articles = Array.from(document.querySelectorAll(CONVERSATION_SELECTOR));
|
|
148
|
+
const userMatched = articles.some((node) => normalize(node?.innerText).includes(normalizedPrompt));
|
|
149
|
+
return {
|
|
150
|
+
userMatched,
|
|
151
|
+
fallbackValue: fallback?.value ?? '',
|
|
152
|
+
editorValue: editor?.innerText ?? '',
|
|
153
|
+
};
|
|
154
|
+
})()`;
|
|
155
|
+
while (Date.now() < deadline) {
|
|
156
|
+
const { result } = await Runtime.evaluate({ expression: script, returnByValue: true });
|
|
157
|
+
const info = result.value;
|
|
158
|
+
if (info?.userMatched) {
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
await delay(100);
|
|
162
|
+
}
|
|
163
|
+
if (logger) {
|
|
164
|
+
await logDomFailure(Runtime, logger, 'prompt-commit');
|
|
165
|
+
}
|
|
166
|
+
throw new Error('Prompt did not appear in conversation before timeout (send may have failed)');
|
|
167
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { rm } from 'node:fs/promises';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
import CDP from 'chrome-remote-interface';
|
|
5
|
+
import { launch } from 'chrome-launcher';
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
export async function launchChrome(config, userDataDir, logger) {
|
|
8
|
+
const chromeFlags = buildChromeFlags(config.headless);
|
|
9
|
+
const launcher = await launch({
|
|
10
|
+
chromePath: config.chromePath ?? undefined,
|
|
11
|
+
chromeFlags,
|
|
12
|
+
userDataDir,
|
|
13
|
+
});
|
|
14
|
+
const pidLabel = typeof launcher.pid === 'number' ? ` (pid ${launcher.pid})` : '';
|
|
15
|
+
logger(`Launched Chrome${pidLabel} on port ${launcher.port}`);
|
|
16
|
+
return launcher;
|
|
17
|
+
}
|
|
18
|
+
export function registerTerminationHooks(chrome, userDataDir, keepBrowser, logger) {
|
|
19
|
+
const signals = ['SIGINT', 'SIGTERM', 'SIGQUIT'];
|
|
20
|
+
let handling;
|
|
21
|
+
const handleSignal = (signal) => {
|
|
22
|
+
if (handling) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
handling = true;
|
|
26
|
+
logger(`Received ${signal}; terminating Chrome process`);
|
|
27
|
+
void (async () => {
|
|
28
|
+
try {
|
|
29
|
+
await chrome.kill();
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
// ignore kill failures
|
|
33
|
+
}
|
|
34
|
+
if (!keepBrowser) {
|
|
35
|
+
await rm(userDataDir, { recursive: true, force: true }).catch(() => undefined);
|
|
36
|
+
}
|
|
37
|
+
})().finally(() => {
|
|
38
|
+
const exitCode = signal === 'SIGINT' ? 130 : 1;
|
|
39
|
+
process.exit(exitCode);
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
for (const signal of signals) {
|
|
43
|
+
process.on(signal, handleSignal);
|
|
44
|
+
}
|
|
45
|
+
return () => {
|
|
46
|
+
for (const signal of signals) {
|
|
47
|
+
process.removeListener(signal, handleSignal);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export async function hideChromeWindow(chrome, logger) {
|
|
52
|
+
if (process.platform !== 'darwin') {
|
|
53
|
+
logger('Window hiding is only supported on macOS');
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
if (!chrome.pid) {
|
|
57
|
+
logger('Unable to hide window: missing Chrome PID');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const script = `tell application "System Events"
|
|
61
|
+
try
|
|
62
|
+
set visible of (first process whose unix id is ${chrome.pid}) to false
|
|
63
|
+
end try
|
|
64
|
+
end tell`;
|
|
65
|
+
try {
|
|
66
|
+
await execFileAsync('osascript', ['-e', script]);
|
|
67
|
+
logger('Chrome window hidden (Cmd-H)');
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
71
|
+
logger(`Failed to hide Chrome window: ${message}`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
export async function connectToChrome(port, logger) {
|
|
75
|
+
const client = await CDP({ port });
|
|
76
|
+
logger('Connected to Chrome DevTools protocol');
|
|
77
|
+
return client;
|
|
78
|
+
}
|
|
79
|
+
function buildChromeFlags(headless) {
|
|
80
|
+
const flags = [
|
|
81
|
+
'--disable-background-networking',
|
|
82
|
+
'--disable-background-timer-throttling',
|
|
83
|
+
'--disable-breakpad',
|
|
84
|
+
'--disable-client-side-phishing-detection',
|
|
85
|
+
'--disable-default-apps',
|
|
86
|
+
'--disable-hang-monitor',
|
|
87
|
+
'--disable-popup-blocking',
|
|
88
|
+
'--disable-prompt-on-repost',
|
|
89
|
+
'--disable-sync',
|
|
90
|
+
'--disable-translate',
|
|
91
|
+
'--metrics-recording-only',
|
|
92
|
+
'--no-first-run',
|
|
93
|
+
'--safebrowsing-disable-auto-update',
|
|
94
|
+
'--disable-features=TranslateUI,AutomationControlled',
|
|
95
|
+
'--mute-audio',
|
|
96
|
+
'--window-size=1280,720',
|
|
97
|
+
'--password-store=basic',
|
|
98
|
+
'--use-mock-keychain',
|
|
99
|
+
];
|
|
100
|
+
if (headless) {
|
|
101
|
+
flags.push('--headless=new');
|
|
102
|
+
}
|
|
103
|
+
return flags;
|
|
104
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { CHATGPT_URL, DEFAULT_MODEL_TARGET } from './constants.js';
|
|
2
|
+
export const DEFAULT_BROWSER_CONFIG = {
|
|
3
|
+
chromeProfile: null,
|
|
4
|
+
chromePath: null,
|
|
5
|
+
url: CHATGPT_URL,
|
|
6
|
+
timeoutMs: 900_000,
|
|
7
|
+
inputTimeoutMs: 30_000,
|
|
8
|
+
cookieSync: true,
|
|
9
|
+
headless: false,
|
|
10
|
+
keepBrowser: false,
|
|
11
|
+
hideWindow: false,
|
|
12
|
+
desiredModel: DEFAULT_MODEL_TARGET,
|
|
13
|
+
debug: false,
|
|
14
|
+
allowCookieErrors: false,
|
|
15
|
+
};
|
|
16
|
+
export function resolveBrowserConfig(config) {
|
|
17
|
+
return {
|
|
18
|
+
...DEFAULT_BROWSER_CONFIG,
|
|
19
|
+
...(config ?? {}),
|
|
20
|
+
url: config?.url ?? DEFAULT_BROWSER_CONFIG.url,
|
|
21
|
+
timeoutMs: config?.timeoutMs ?? DEFAULT_BROWSER_CONFIG.timeoutMs,
|
|
22
|
+
inputTimeoutMs: config?.inputTimeoutMs ?? DEFAULT_BROWSER_CONFIG.inputTimeoutMs,
|
|
23
|
+
cookieSync: config?.cookieSync ?? DEFAULT_BROWSER_CONFIG.cookieSync,
|
|
24
|
+
headless: config?.headless ?? DEFAULT_BROWSER_CONFIG.headless,
|
|
25
|
+
keepBrowser: config?.keepBrowser ?? DEFAULT_BROWSER_CONFIG.keepBrowser,
|
|
26
|
+
hideWindow: config?.hideWindow ?? DEFAULT_BROWSER_CONFIG.hideWindow,
|
|
27
|
+
desiredModel: config?.desiredModel ?? DEFAULT_BROWSER_CONFIG.desiredModel,
|
|
28
|
+
chromeProfile: config?.chromeProfile ?? DEFAULT_BROWSER_CONFIG.chromeProfile,
|
|
29
|
+
chromePath: config?.chromePath ?? DEFAULT_BROWSER_CONFIG.chromePath,
|
|
30
|
+
debug: config?.debug ?? DEFAULT_BROWSER_CONFIG.debug,
|
|
31
|
+
allowCookieErrors: config?.allowCookieErrors ?? DEFAULT_BROWSER_CONFIG.allowCookieErrors,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export const CHATGPT_URL = 'https://chatgpt.com/';
|
|
2
|
+
export const DEFAULT_MODEL_TARGET = 'ChatGPT 5.1';
|
|
3
|
+
export const COOKIE_URLS = ['https://chatgpt.com', 'https://chat.openai.com'];
|
|
4
|
+
export const INPUT_SELECTORS = [
|
|
5
|
+
'textarea[data-id="prompt-textarea"]',
|
|
6
|
+
'textarea[placeholder*="Send a message"]',
|
|
7
|
+
'textarea[aria-label="Message ChatGPT"]',
|
|
8
|
+
'textarea:not([disabled])',
|
|
9
|
+
'textarea[name="prompt-textarea"]',
|
|
10
|
+
'#prompt-textarea',
|
|
11
|
+
'.ProseMirror',
|
|
12
|
+
'[contenteditable="true"][data-virtualkeyboard="true"]',
|
|
13
|
+
];
|
|
14
|
+
export const ANSWER_SELECTORS = [
|
|
15
|
+
'article[data-testid^="conversation-turn"][data-message-author-role="assistant"]',
|
|
16
|
+
'article[data-testid^="conversation-turn"] [data-message-author-role="assistant"]',
|
|
17
|
+
'article[data-testid^="conversation-turn"] .markdown',
|
|
18
|
+
'[data-message-author-role="assistant"] .markdown',
|
|
19
|
+
'[data-message-author-role="assistant"]',
|
|
20
|
+
];
|
|
21
|
+
export const CONVERSATION_TURN_SELECTOR = 'article[data-testid^="conversation-turn"]';
|
|
22
|
+
export const ASSISTANT_ROLE_SELECTOR = '[data-message-author-role="assistant"]';
|
|
23
|
+
export const CLOUDFLARE_SCRIPT_SELECTOR = 'script[src*="/challenge-platform/"]';
|
|
24
|
+
export const CLOUDFLARE_TITLE = 'just a moment';
|
|
25
|
+
export const PROMPT_PRIMARY_SELECTOR = '#prompt-textarea';
|
|
26
|
+
export const PROMPT_FALLBACK_SELECTOR = 'textarea[name="prompt-textarea"]';
|
|
27
|
+
export const FILE_INPUT_SELECTOR = 'form input[type="file"]:not([accept])';
|
|
28
|
+
export const GENERIC_FILE_INPUT_SELECTOR = 'input[type="file"]:not([accept])';
|
|
29
|
+
export const MENU_CONTAINER_SELECTOR = '[role="menu"], [data-radix-collection-root]';
|
|
30
|
+
export const MENU_ITEM_SELECTOR = 'button, [role="menuitem"], [role="menuitemradio"], [data-testid*="model-switcher-"]';
|
|
31
|
+
export const UPLOAD_STATUS_SELECTORS = [
|
|
32
|
+
'[data-testid*="upload"]',
|
|
33
|
+
'[data-testid*="attachment"]',
|
|
34
|
+
'[data-state="loading"]',
|
|
35
|
+
'[aria-live="polite"]',
|
|
36
|
+
];
|
|
37
|
+
export const STOP_BUTTON_SELECTOR = '[data-testid="stop-button"]';
|
|
38
|
+
export const SEND_BUTTON_SELECTOR = '[data-testid="send-button"]';
|
|
39
|
+
export const MODEL_BUTTON_SELECTOR = '[data-testid="model-switcher-dropdown-button"]';
|
|
40
|
+
export const COPY_BUTTON_SELECTOR = 'button[data-testid="copy-turn-action-button"]';
|