This commit is contained in:
k
2026-09-21 19:14:55 +02:00
parent 2a34d16b52
commit 5903b77d95
16 changed files with 958 additions and 125 deletions
+357
View File
@@ -0,0 +1,357 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { Browser, Page } from "puppeteer-core";
import { driverLog } from "./log.ts";
export const CHATGPT_URL = "https://chatgpt.com/";
export const CHATGPT_SELECTORS = {
composer: ["#mobile-composer-prompt", "#prompt-textarea", "div[contenteditable='true']"],
sendButton: "button[data-composer-submit]",
copyButton: "button[data-copy-message]",
assistantMarkdown: "div[data-assistant-markdown]",
streamBlock: "[data-assistant-stream-block]",
} as const;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
export interface ChatGPTBrowserOptions {
/** Firefox binary (puppeteer-core requires an explicit executable). */
executablePath: string;
headless: boolean;
/** Grace period for a manual login before giving up on the composer. */
loginTimeoutMs: number;
responseTimeoutMs: number;
pollMs?: number;
}
export interface ChatGPTSendCallbacks {
signal?: AbortSignal;
onToken?: (delta: string) => void;
onLoginHint?: () => void;
/** Start a fresh conversation (goto CHATGPT_URL) before sending. */
fresh?: boolean;
}
/** Manual login grace period expired (no composer on the page). */
export class BrowserLoginError extends Error {
constructor(timeoutMs: number) {
super(
`ChatGPT composer not available after ${Math.round(timeoutMs / 1000)}s — login required (run with PUPPETEER_HEADLESS=false and log in inside the window)`,
);
this.name = "BrowserLoginError";
}
}
/**
* Throwaway Firefox session driving chatgpt.com via puppeteer-core (WebDriver
* BiDi). Each launch creates a fresh mkdtemp profile that is removed on
* close(): zero persistence between runs. One engine = one browser + one tab.
*/
export class ChatGPTBrowserEngine {
private browser: Browser | null = null;
private page: Page | null = null;
private profileDir: string | null = null;
private opening: Promise<void> | null = null;
private closed = false;
private chain: Promise<unknown> = Promise.resolve();
private currentAbort: AbortController | null = null;
constructor(private readonly opts: ChatGPTBrowserOptions) {}
get isOpen(): boolean {
return this.browser !== null;
}
async open(): Promise<void> {
if (this.browser) return;
if (this.closed) throw new Error("engine already closed");
this.opening ??= this.launch().finally(() => {
this.opening = null;
});
await this.opening;
}
/** One-shot state report (used by the POC probe mode). */
async probe(): Promise<{ title: string; url: string; composer: string | null }> {
await this.open();
const page = this.requirePage();
await sleep(3000);
return { title: await page.title(), url: page.url(), composer: await this.findComposer() };
}
/**
* Type a prompt into the composer, submit, stream the answer through
* onToken and resolve with the final text. Completion = a new
* "Copy response" button appearing. Serialized: one send at a time.
*/
send(prompt: string, cb: ChatGPTSendCallbacks = {}): Promise<string> {
const task = this.chain
.catch(() => undefined)
.then(() => this.doSend(prompt, cb));
this.chain = task.catch(() => undefined);
return task;
}
abortCurrent(): void {
this.currentAbort?.abort();
}
async close(): Promise<void> {
this.closed = true;
this.abortCurrent();
const browser = this.browser;
this.browser = null;
this.page = null;
if (browser) {
try {
await browser.close();
} catch {
// already gone
}
}
const dir = this.profileDir;
this.profileDir = null;
if (dir) {
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
driverLog("chatgpt", `temporary profile removed (${dir})`);
}
}
private async launch(): Promise<void> {
const puppeteer = await import("puppeteer-core");
this.profileDir = await mkdtemp(join(tmpdir(), "ka-chatgpt-"));
driverLog("chatgpt", `launching firefox headless=${this.opts.headless} profile=${this.profileDir}`);
const browser = await puppeteer.launch({
browser: "firefox",
executablePath: this.opts.executablePath,
headless: this.opts.headless,
userDataDir: this.profileDir,
defaultViewport: null,
protocolTimeout: 300_000,
});
browser.once("disconnected", () => {
if (this.browser === browser) {
this.browser = null;
this.page = null;
driverLog("chatgpt", "browser disconnected unexpectedly");
}
});
this.browser = browser;
this.page = await browser.newPage();
await this.gotoChatGPT(this.page);
await this.dismissCookieBanner(this.page, 10_000);
}
/** chatgpt.com intermittently stalls on domcontentloaded — retry a few times. */
private async gotoChatGPT(page: Page): Promise<void> {
let lastErr: unknown = null;
for (let attempt = 1; attempt <= 3; attempt++) {
try {
await page.goto(CHATGPT_URL, { waitUntil: "domcontentloaded", timeout: 60_000 });
return;
} catch (e) {
lastErr = e;
driverLog("chatgpt", `navigation failed (attempt ${attempt}/3): ${(e as Error).message}`);
await sleep(3000);
}
}
throw lastErr instanceof Error ? lastErr : new Error("navigation to chatgpt.com failed");
}
/**
* Fresh profile = cookie consent banner on first load. It swallows input
* until "Accept all" (button[type=submit], text) is clicked.
*/
private async dismissCookieBanner(page: Page, waitMs: number): Promise<void> {
const deadline = Date.now() + waitMs;
for (;;) {
const clicked = await page
.evaluate(() => {
const btn = Array.from(document.querySelectorAll("button")).find((b) => {
const t = (b.textContent ?? "").trim().toLowerCase();
return (t === "accept all" || t === "accept") && (b as HTMLElement).getClientRects().length > 0;
});
if (!btn) return false;
(btn as HTMLElement).click();
return true;
})
.catch(() => false);
if (clicked) {
driverLog("chatgpt", "cookie banner dismissed (Accept all)");
await sleep(800);
return;
}
if (Date.now() >= deadline) return;
await sleep(1000);
}
}
private async doSend(prompt: string, cb: ChatGPTSendCallbacks): Promise<string> {
if (this.closed) throw new Error("engine already closed");
const pollMs = this.opts.pollMs ?? 250;
const ac = new AbortController();
this.currentAbort = ac;
const forward = () => ac.abort();
cb.signal?.addEventListener("abort", forward);
const t0 = Date.now();
try {
await this.open();
const page = this.requirePage();
if (cb.fresh) {
await this.gotoChatGPT(page);
await this.dismissCookieBanner(page, 1_500);
}
const composer = await this.waitForComposer(cb.onLoginHint);
const copyBefore = await this.countCopyButtons();
await this.typeIntoComposer(page, composer, prompt);
await this.waitSendable(page);
await page.click(CHATGPT_SELECTORS.sendButton);
driverLog("chatgpt", `sent ${prompt.length} chars via ${composer}, waiting for response`);
let last = "";
const deadline = Date.now() + this.opts.responseTimeoutMs;
for (;;) {
if (ac.signal.aborted) {
await this.stopGenerating();
throw new DOMException("ChatGPT browser request aborted", "AbortError");
}
const text = await this.readAssistantText();
if (text.startsWith(last) && text.length > last.length) {
const delta = text.slice(last.length);
last = text;
cb.onToken?.(delta);
}
if ((await this.countCopyButtons()) > copyBefore) {
const finalText = await this.readAssistantText();
if (finalText.length > last.length) cb.onToken?.(finalText.slice(last.length));
const out = finalText.length ? finalText : last;
driverLog("chatgpt", `response complete: ${out.length} chars in ${fmtS(Date.now() - t0)}`);
return out;
}
if (Date.now() > deadline) {
await this.stopGenerating();
throw new Error(`ChatGPT response timed out after ${Math.round(this.opts.responseTimeoutMs / 1000)}s`);
}
await sleep(pollMs);
}
} finally {
cb.signal?.removeEventListener("abort", forward);
if (this.currentAbort === ac) this.currentAbort = null;
}
}
private async waitForComposer(hint?: () => void): Promise<string> {
const page = this.requirePage();
const deadline = Date.now() + this.opts.loginTimeoutMs;
let hinted = false;
for (;;) {
const sel = await this.findComposer();
if (sel) return sel;
if (!hinted) {
hinted = true;
hint?.();
}
if (Date.now() > deadline) throw new BrowserLoginError(this.opts.loginTimeoutMs);
await sleep(1000);
}
}
private async findComposer(): Promise<string | null> {
const page = this.page;
if (!page) return null;
for (const sel of CHATGPT_SELECTORS.composer) {
try {
if (await page.$(sel)) return sel;
} catch {
// navigation in flight — retry on next poll
}
}
return null;
}
private async typeIntoComposer(page: Page, sel: string, text: string): Promise<void> {
const input = await page.$(sel);
if (!input) throw new Error(`composer ${sel} not found`);
await input.focus();
const lines = text.split("\n");
for (let i = 0; i < lines.length; i++) {
if (lines[i]) await page.keyboard.type(lines[i], { delay: 4 });
if (i < lines.length - 1) {
await page.keyboard.down("Shift");
await page.keyboard.press("Enter");
await page.keyboard.up("Shift");
await sleep(30);
}
}
}
private async waitSendable(page: Page): Promise<void> {
const deadline = Date.now() + 10_000;
for (;;) {
const ok = await page
.$eval(CHATGPT_SELECTORS.sendButton, (el) => {
const b = el as HTMLButtonElement;
return !b.disabled && b.getAttribute("aria-disabled") !== "true";
})
.catch(() => false);
if (ok) return;
if (Date.now() > deadline) throw new Error("ChatGPT send button never became clickable");
await sleep(250);
}
}
private async stopGenerating(): Promise<void> {
const page = this.page;
if (!page) return;
try {
await page.click(CHATGPT_SELECTORS.sendButton);
} catch {
// button already back in send state — nothing to stop
}
}
private async countCopyButtons(): Promise<number> {
const page = this.page;
if (!page) return 0;
try {
return await page.$$eval(CHATGPT_SELECTORS.copyButton, (els) =>
els.filter((el) => (el as HTMLElement).getClientRects().length > 0).length,
);
} catch {
return 0;
}
}
private async readAssistantText(): Promise<string> {
const page = this.page;
if (!page) return "";
try {
return await page.$$eval(
CHATGPT_SELECTORS.assistantMarkdown,
(els, blockSel: string) => {
const last = els[els.length - 1];
if (!last) return "";
const blocks = Array.from(last.querySelectorAll(blockSel));
if (blocks.length === 0) return (last as HTMLElement).innerText;
return blocks
.map((b) => (b as HTMLElement).innerText.trim())
.filter(Boolean)
.join("\n\n");
},
CHATGPT_SELECTORS.streamBlock,
);
} catch {
return "";
}
}
private requirePage(): Page {
if (!this.page) throw new Error("browser session is not open");
return this.page;
}
}
function fmtS(ms: number): string {
return `${(ms / 1000).toFixed(1)}s`;
}
+149 -13
View File
@@ -1,27 +1,163 @@
import { config } from "../config.ts";
import { driverLog, fmtBytes, fmtMs } from "./log.ts";
import { ChatGPTBrowserEngine } from "./browser.ts";
import type { ChatMessage, ChatOptions, DriverModel, LLMDriver } from "./types.ts";
import { DriverDisabledError } from "./types.ts";
const MODELS = ["chatgpt"];
function resolveExecutable(): string {
const conf = config.puppeteerExecutable;
if (conf.includes("/")) return conf;
const found = Bun.which(conf);
if (!found) throw new Error(`browser executable "${conf}" not found in PATH — set PUPPETEER_EXECUTABLE`);
return found;
}
/**
* Puppeteer/headless-browser driver stub (scrapes chatgpt.com).
* Intentionally NOT implemented: fragile, ToS-risky, needs a real
* account session. Kept as a stub so the Driver interface reserves
* its slot at global priority #3.
* Flatten a KisAssistant conversation into one prompt for the ChatGPT web
* composer: system text as a preamble, earlier turns as a transcript, and
* the latest user message sent as-is.
*/
export function flattenMessages(messages: ChatMessage[]): string {
const system = messages
.filter((m) => m.role === "system")
.map((m) => m.content)
.join("\n")
.trim();
const rest = messages.filter((m) => m.role !== "system");
const last = rest[rest.length - 1];
const earlier = rest.slice(0, -1);
const parts: string[] = [];
if (system) parts.push(`[Instructions]\n${system}\n`);
if (earlier.length) {
parts.push("[Conversation so far]");
for (const m of earlier) parts.push(`${m.role === "user" ? "User" : "Assistant"}: ${m.content}`);
parts.push("[End of conversation so far]\n");
}
if (last) parts.push(last.content);
return parts.join("\n");
}
/** Buffers tokens produced by a push-style callback into a pull-style iterator. */
class AsyncTokenQueue implements AsyncIterable<string> {
private buf: string[] = [];
private error: Error | null = null;
private ended = false;
private waiters: Array<(r: IteratorResult<string> | null) => void> = [];
push(token: string): void {
const w = this.waiters.shift();
if (w) w({ value: token, done: false });
else this.buf.push(token);
}
end(err?: Error): void {
if (err) this.error = err;
this.ended = true;
for (const w of this.waiters.splice(0)) w(null);
}
async next(): Promise<IteratorResult<string>> {
for (;;) {
if (this.buf.length) return { value: this.buf.shift() as string, done: false };
if (this.ended) {
if (this.error) throw this.error;
return { value: undefined, done: true };
}
const wake = await new Promise<IteratorResult<string> | null>((resolve) => this.waiters.push(resolve));
if (wake) return wake;
}
}
[Symbol.asyncIterator](): AsyncIterator<string> {
return this;
}
}
/**
* ChatGPT via the web UI: throwaway Firefox profile driven by
* puppeteer-core (WebDriver BiDi). ToS-risky and fragile by nature —
* disabled unless PUPPETEER_ENABLED=true. Login is manual for now: the
* first request opens a window and waits for the composer to appear.
*/
export class PuppeteerOpenAIDriver implements LLMDriver {
readonly name = "puppeteer-openai";
readonly enabled = false;
readonly enabled = config.puppeteerEnabled;
private engine: ChatGPTBrowserEngine | null = null;
constructor() {
if (this.enabled) driverLog(this.name, "enabled (chatgpt.com via Firefox, throwaway profile, manual login)");
}
private ensureEnabled(): void {
if (!this.enabled) throw new DriverDisabledError(this.name);
}
private getEngine(): ChatGPTBrowserEngine {
this.ensureEnabled();
if (!this.engine) {
this.engine = new ChatGPTBrowserEngine({
executablePath: resolveExecutable(),
headless: config.puppeteerHeadless,
loginTimeoutMs: config.puppeteerLoginTimeoutS * 1000,
responseTimeoutMs: config.puppeteerResponseTimeoutS * 1000,
});
}
return this.engine;
}
async listModels(): Promise<DriverModel[]> {
return [];
this.ensureEnabled();
return MODELS.map((m) => ({ id: `${this.name}:${m}`, name: m, driver: this.name, label: "ChatGPT (web)" }));
}
async chat(_messages: ChatMessage[], _opts: ChatOptions): Promise<string> {
throw new Error(
'driver "puppeteer-openai" is a stub and is not implemented (headless scraping of chatgpt.com is fragile and against ToS; use ollama or official APIs instead)',
);
async chat(messages: ChatMessage[], opts: ChatOptions): Promise<string> {
this.ensureEnabled();
const t0 = Date.now();
const text = await this.getEngine().send(flattenMessages(messages), {
signal: opts.signal,
fresh: true,
onLoginHint: () => driverLog(this.name, "login required — complete it inside the browser window"),
});
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(text.length)} in ${fmtMs(Date.now() - t0)}`);
return text;
}
async *chatStream(): AsyncGenerator<string, void, void> {
throw new Error('driver "puppeteer-openai" is a stub and is not implemented');
void 0;
async *chatStream(messages: ChatMessage[], opts: ChatOptions): AsyncGenerator<string, void, void> {
this.ensureEnabled();
const t0 = Date.now();
driverLog(this.name, `chat model=${opts.model} messages=${messages.length} stream=open`);
const queue = new AsyncTokenQueue();
let chars = 0;
let jobDone = false;
const job = this.getEngine()
.send(flattenMessages(messages), {
signal: opts.signal,
fresh: true,
onLoginHint: () => driverLog(this.name, "login required — complete it inside the browser window"),
onToken: (tok) => {
chars += tok.length;
opts.onToken?.(tok);
queue.push(tok);
},
})
.then(
() => {
jobDone = true;
queue.end();
},
(e) => {
jobDone = true;
queue.end(e as Error);
},
);
try {
yield* queue;
} finally {
if (!jobDone) this.getEngine().abortCurrent();
void job;
}
driverLog(this.name, `chat model=${opts.model} done: ${fmtBytes(chars)} in ${fmtMs(Date.now() - t0)}`);
}
}