import { ToolDef } from '../../llm/openai-compat.js'; import type { ToolContext, ToolResult } from './core.js'; import { resolveOutputPathWithin } from './core.js'; import { logger } from '../../logger.js'; import { recorder } from '../browser-recorder.js'; import type { RecordedAction, FrameChainEntry } from '../browser-recorder.js'; import * as dns from 'dns'; import * as path from 'path'; import { existsSync, mkdirSync, appendFileSync, statSync } from 'fs'; import { fileURLToPath, pathToFileURL } from 'url'; import type { Browser, BrowserContext, Download, Frame, Locator, Page } from 'playwright'; import { SessionManager, type BrowserSession, CAPTCHA_POOL_SESSION_ID } from '../browser-session.js'; import { loadConfig } from '../../config.js'; import { buildNovncPath } from '../../bridge/novnc-proxy.js'; import { checkSSRF, isPrivateIPv4, isPrivateIPv6, isHostAllowed } from './shared/ssrf.js'; import { htmlToText } from './shared/html.js'; import { detectAuthExpiry } from '../browser-session-expiry.js'; export { detectAuthExpiry as runAuthCheck } from '../browser-session-expiry.js'; /** * After navigation, check if the page indicates the auth session has expired * (login URL redirect or logged-in selector missing). Returns the reason * string if expired (and notifies ctx.onAuthExpired), or null otherwise. * * Skipped entirely when no browser session profile is bound to the job. */ async function checkAuthExpiry(page: Page, ctx: ToolContext): Promise { if (!ctx.browserSessionProfileId || !ctx.browserSessionProfile) return null; const profile = ctx.browserSessionProfile; const present = profile.loggedInSelector ? !!(await page.$(profile.loggedInSelector).catch(() => null)) : true; // statusCode is hard to get reliably after waitFor; we rely on URL pattern + // selector for the heuristic. 200 is a placeholder so detectAuthExpiry doesn't // 401-flag. const verdict = detectAuthExpiry({ profile, finalUrl: page.url(), statusCode: 200, loggedInSelectorPresent: present, }); if (verdict.expired) { ctx.onAuthExpired?.(ctx.browserSessionProfileId, verdict.reason); return verdict.reason; } return null; } /** * Check if a URL targets a private/internal address. * Returns an error message if blocked, or null if allowed. */ function isPathWithin(parent: string, child: string): boolean { const relative = path.relative(path.resolve(parent), path.resolve(child)); return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); } function resolveWorkspaceFileUrl(parsed: URL, workspacePath: string): { filePath: string; url: string } | { error: string } { let filePath: string; try { filePath = fileURLToPath(parsed); } catch (e) { return { error: `Invalid file URL: ${(e as Error).message}` }; } const workspaceRoot = path.resolve(workspacePath); // Backwards-compat: `file:///workspace/...` was previously documented as a // virtual workspace root. The docs no longer advertise it, but in-flight // jobs and LLMs trained on the old convention may still emit it. We // silently remap and log a deprecation warning so the bad pattern is // observable in logs. const virtualWorkspaceRoot = path.resolve('/workspace'); if (!isPathWithin(workspaceRoot, filePath) && isPathWithin(virtualWorkspaceRoot, filePath)) { logger.warn(`[BrowseWeb] deprecated /workspace virtual path used: ${filePath}. Use a workspace-relative path instead (e.g., "output/foo.html").`); filePath = path.resolve(workspaceRoot, path.relative(virtualWorkspaceRoot, filePath)); } if (!isPathWithin(workspacePath, filePath)) { return { error: `file:// URL is only allowed within workspace: ${workspacePath}` }; } const normalized = new URL(pathToFileURL(filePath).href); normalized.search = parsed.search; normalized.hash = parsed.hash; return { filePath, url: normalized.href }; } function validateFileUrlAccess(parsed: URL, workspacePath: string): string | null { const resolved = resolveWorkspaceFileUrl(parsed, workspacePath); return 'error' in resolved ? resolved.error : null; } // Detects strings that look like workspace-relative paths rather than URLs. // We accept inputs without a scheme so the LLM can pass `output/viewer.html` // directly (the recommended form). Strings starting with `//` (protocol- // relative URL) or containing `://` are left to the URL parser. // // To avoid swallowing genuinely malformed URLs like "example.com" or // "not-a-url", we require the input either to contain a `/` (path // separator) or to start with `./` / `../`. A bare token with no slash // is left to the URL parser, which will reject it as "Invalid URL". function looksLikeWorkspaceRelativePath(url: string): boolean { if (!url) return false; if (url.startsWith('//')) return false; // Has a URL scheme like "http:" / "https:" / "file:" / "about:" / "data:" if (/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(url)) return false; if (url.startsWith('./') || url.startsWith('../')) return true; return url.includes('/'); } export function normalizeFileUrlForWorkspace(url: string, workspacePath: string): { url: string } | { error: string } { // Workspace-relative path (recommended form): e.g., "output/viewer.html". // Resolved against the actual workspace root and converted to file:// URL. if (looksLikeWorkspaceRelativePath(url)) { if (path.isAbsolute(url)) { return { error: `BrowseWeb URL "${url}" は workspace 外の絶対パスです。workspace ルートからの相対パス (例: "output/foo.html") または完全な URL (https://...) を使ってください。` }; } const resolved = path.resolve(workspacePath, url); if (!isPathWithin(workspacePath, resolved)) { return { error: `Path "${url}" is outside workspace: ${workspacePath}` }; } return { url: pathToFileURL(resolved).href }; } let parsed: URL; try { parsed = new URL(url); } catch { return { error: `Invalid URL: "${url}"` }; } if (parsed.protocol !== 'file:') return { url }; const resolved = resolveWorkspaceFileUrl(parsed, workspacePath); if ('error' in resolved) return { error: resolved.error }; return { url: resolved.url }; } async function ssrfCheck(url: string, allowedHosts: string[], workspacePath: string): Promise { let parsed: URL; try { parsed = new URL(url); } catch { return `Invalid URL: "${url}"`; } if (parsed.protocol === 'file:') { return validateFileUrlAccess(parsed, workspacePath); } try { await checkSSRF(parsed.hostname, allowedHosts); } catch (e) { return (e as Error).message; } return null; } // --- Tool definitions --- interface BrowseWebAction { type: 'goto' | 'click' | 'fill' | 'screenshot' | 'getText' | 'wait' | 'dumpHtml'; selector?: string; ref?: string; value?: string; url?: string; ms?: number; /** dumpHtml: 包含する子孫の階層数。デフォルト 3 */ depth?: number; /** dumpHtml: 戻り値プレビュー長 (ファイルにはフル保存)。デフォルト 5000 */ maxChars?: number; } const BROWSEWEB_DEF: ToolDef = { type: 'function', function: { name: 'BrowseWeb', description: 'ヘッドレスブラウザでWebページを操作する。同一ジョブ内ではセッション(Cookie・ログイン状態等)が維持される。\n' + '基本モード: url を指定してページのテキストを取得。screenshot でスクリーンショットも保存可能。\n' + 'アクションモード: actions 配列で goto/click/fill/screenshot/getText/wait/dumpHtml を連続実行。\n' + '出力には操作可能要素が {e1 button "..."} 形式の ref 注釈付きで埋め込まれ、click/fill で ref を直接指定できる。
等の ARIA ベース要素・addEventListener で click handler が後付けされた要素・open shadow DOM・iframe (cross-origin 含む) の中身も検出される。iframe 内の ref は {f1.e3 ...} のように frame ID で prefix される。状態属性 (expanded/checked/selected/pressed/disabled/haspopup) は注釈末尾に列挙。\n' + 'ref で当たらない or 構造を直接見たいときは dumpHtml アクションで該当要素の outerHTML を取得できる(脱出口)。\n' + 'click が繰り返し空振り / ログイン or CAPTCHA / ドラッグ&ドロップや canvas など DOM では操作できない UI に当たったら、InteractiveBrowse でユーザーに noVNC 経由で手動操作してもらい、その後 BrowseWithSession で続きを引き継げる。\n' + 'ページから発生したファイルダウンロードは自動的に output/ に保存され、戻り値末尾に [download] saved output/ として通知される。\n' + '詳細な使い方・ワークフロー例は ReadToolDoc({ name: "BrowseWeb" }) で取得可能。', parameters: { type: 'object', properties: { url: { type: 'string', description: '取得する URL(アクションモード時は省略可)。ローカルファイルを開く場合は workspace ルートからの相対パスを指定 (例: "output/viewer.html")' }, waitFor: { type: 'string', description: '待機する CSS セレクタ(省略時は load イベント完了まで待機)', }, extractSelector: { type: 'string', description: '特定要素のテキストだけ抽出する CSS セレクタ(省略時はページ全体)', }, screenshot: { type: 'string', description: 'スクリーンショットを保存するファイル名(例: "page.png")。output/ に保存される', }, actions: { type: 'array', description: '実行するアクションの配列(指定時は基本モードのパラメータは無視される)', items: { type: 'object', properties: { type: { type: 'string', enum: ['goto', 'click', 'fill', 'screenshot', 'getText', 'wait', 'dumpHtml'], description: 'アクション種別', }, selector: { type: 'string', description: 'CSS セレクタ (click, fill, getText, dumpHtml) — ref があれば不要' }, ref: { type: 'string', description: '前回スナップショットで割り振られた要素 ref (e1, e2, ...) — click/fill/dumpHtml で selector の代わりに使える' }, value: { type: 'string', description: '入力値 (fill) またはファイル名 (screenshot)' }, url: { type: 'string', description: 'URL (goto)。ローカルファイルを開く場合は workspace ルートからの相対パス (例: "output/viewer.html")' }, ms: { type: 'number', description: '待機ミリ秒 (wait)' }, depth: { type: 'number', description: 'dumpHtml: 包含する子孫の階層数 (デフォルト 3)' }, maxChars: { type: 'number', description: 'dumpHtml: 戻り値プレビュー長 (デフォルト 5000)。フル HTML は logs/browse/ に保存' }, }, required: ['type'], }, }, timeout: { type: 'number', description: 'タイムアウト(ミリ秒、デフォルト: 60000)', }, recordTo: { type: 'string', description: '省略すると記録しない。指定すると、本ジョブで成功した各アクションを buffer に記録し、タスク終了時に data/users/{userId}/recordings/{recordTo}.json として保存する(Task 3.5 の Save as Script で使う)。', }, }, }, }, }; // --- Session manager (injected from server.ts) --- let _sessionManager: SessionManager | null = null; /** server.ts から SessionManager を注入する */ export function setSessionManager(sm: SessionManager | null): void { _sessionManager = sm; } function getSessionManager(): SessionManager | null { return _sessionManager; } export { getSessionManager }; // --- Browser lifecycle --- // // 2026-05 redesign: 1 つの Browser を共有していたものを、3 種類に分離した: // 1. CAPTCHA Pool (kind='pool'): admin が CAPTCHA を解く共有 noVNC session。 // WebSearch / WebFetch スクショなどタスク横断の処理が使う。 // 固定 sessionId `__captcha_pool__`。 // 2. Task Session (kind='task'): タスクごとに分離された noVNC session。 // BrowseWeb / InteractiveBrowse が ctx.taskId をキーに取得・再利用する。 // タスク visibility に基づき認可される。LRU 退避 + idle GC 対象。 // 3. Headless 共有 (skip mode): config の captchaSolve != 'novnc' の場合、 // または noVNC が立ち上げられない fallback 経路で使う single Browser。 let _headlessBrowser: Browser | null = null; let _headlessInitPromise: Promise | null = null; let _browserUnavailable: string | null = null; /** Headless 共有 Browser を取得する。skip モード / noVNC fallback 用 */ async function getHeadlessBrowser(): Promise { if (_browserUnavailable) throw new Error(_browserUnavailable); if (_headlessBrowser?.isConnected()) return _headlessBrowser; if (_headlessInitPromise) return _headlessInitPromise; _headlessInitPromise = (async () => { const { chromium } = await import('playwright'); const { buildLaunchOptions } = await import('../browser-launch.js'); _headlessBrowser = await chromium.launch(buildLaunchOptions(loadConfig().browser, true)); logger.debug('[browser] launched chromium headless'); return _headlessBrowser; })().catch((e) => { const msg = (e as Error).message ?? String(e); if (msg.includes("Executable doesn't exist") || msg.includes('browserType.launch')) { _browserUnavailable = `Playwright browser unavailable: ${msg}`; logger.warn(`[browser] ${_browserUnavailable}`); } throw e; }).finally(() => { _headlessInitPromise = null; }); return _headlessInitPromise; } /** * CAPTCHA Pool の Browser を取得する。 * - noVNC モード: SessionManager.createPoolSession() で立ち上げる * - skip モード or 立ち上げ失敗時: headless 共有 Browser に fallback * * Pool は WebSearch / WebFetch スクショなど "タスク横断で同じ Cookie / 認証 * を使いまわしたい" 処理が使う。admin が noVNC で CAPTCHA を手動解決すると * Cookie が Pool 配下のコンテキストに残り、後続 WebSearch がそのまま使える。 */ export async function getCaptchaPoolBrowser(): Promise { const config = loadConfig(); if (config.browser?.captchaSolve === 'novnc') { const sm = getSessionManager(); if (sm) { try { const pool = await sm.createPoolSession(); if (pool.browser?.isConnected()) return pool.browser; logger.warn('[browser] CAPTCHA pool browser disconnected, falling back to headless'); } catch (e) { logger.warn(`[browser] CAPTCHA pool creation failed: ${e}, falling back to headless`); } } else { logger.warn('[browser] noVNC deps missing (Xvfb/x11vnc/websockify), falling back to headless'); } } return getHeadlessBrowser(); } /** * 指定タスク用の Browser を取得する。 * - noVNC モード + ctx.taskId あり: SessionManager.getOrCreateTaskSession() で立ち上げる * - それ以外: headless 共有 Browser に fallback * * Task Session は BrowseWeb / InteractiveBrowse が使う。同じ taskId への * 連続呼び出しは同じ Browser を再利用するので Cookie / ログイン状態が維持される。 */ export async function getTaskSessionBrowser(ctx: ToolContext): Promise { const config = loadConfig(); if (config.browser?.captchaSolve === 'novnc' && ctx.taskId) { const sm = getSessionManager(); if (sm) { try { const session = await sm.getOrCreateTaskSession(ctx.taskId, ctx.userId); sm.touchSession(session.id); if (session.browser?.isConnected()) return session.browser; logger.warn(`[browser] task session ${ctx.taskId} browser disconnected, falling back to headless`); } catch (e) { logger.warn(`[browser] task session ${ctx.taskId} creation failed: ${e}, falling back to headless`); } } else { logger.warn('[browser] noVNC deps missing, falling back to headless for task session'); } } return getHeadlessBrowser(); } /** UI が CAPTCHA Pool の noVNC パスを取得するためのヘルパー (admin only) */ export function getCaptchaPoolInfo(): { sessionId: string; novncPath: string; display: string; captchaPending: boolean; } | null { const sm = getSessionManager(); if (!sm) return null; const pool = sm.getSession(CAPTCHA_POOL_SESSION_ID); if (!pool) return null; return { sessionId: pool.id, novncPath: buildNovncPath(pool.id), display: pool.display, captchaPending: pool.captchaPending === true, }; } /** UI が指定 taskId の Task Session の noVNC パスを取得するためのヘルパー */ export function getTaskSessionInfo(taskId: string): { sessionId: string; novncPath: string; display: string; } | null { const sm = getSessionManager(); if (!sm) return null; for (const s of sm.listSessions()) { if (s.kind === 'task' && s.taskId === taskId) { return { sessionId: s.id, novncPath: buildNovncPath(s.id), display: s.display }; } } return null; } export async function closeBrowser(): Promise { try { const webModule = await import('./web.js') as unknown as { clearPersistentContexts?: () => void }; webModule.clearPersistentContexts?.(); } catch { // web.js が未ロードの場合は無視 } // headless 用ジョブコンテキストを全て閉じる for (const [key, jobCtx] of _jobContexts) { await jobCtx.close().catch(() => {}); _jobContexts.delete(key); } // 全 noVNC session (pool + task) を destroy const sm = getSessionManager(); if (sm) { await sm.destroyAll().catch(() => {}); } if (_headlessBrowser) { try { await _headlessBrowser.close(); } catch { // ignore cleanup errors } _headlessBrowser = null; logger.debug('[browser] headless closed'); } } // --- Per-job persistent browser context --- /** * Headless モード時の per-workspace BrowserContext。 * noVNC モードのときは task session 自身が context を持つので、ここには * エントリが入らない (session.context を直接使う)。 */ const _jobContexts = new Map(); const _interceptedPages = new WeakSet(); const _hookedContexts = new WeakSet(); /** * Per-page ref → (frame, selector) mapping. Main-frame refs are `e1, e2, ...`; * child-frame refs are prefixed `f1.e1, f1.e2, ...` (`f1` is the first iframe * encountered in page.frames(), excluding main). * * Storing the Frame reference rather than a frame ID lets us dispatch click / * fill / dumpHtml on the correct execution context (Playwright's Frame has the * same surface as Page for these methods, and it transparently bridges * cross-origin iframes via CDP). */ type RefTarget = { frame: Frame; selector: string }; const _pageRefs = new WeakMap>(); // --- Download capture (Playwright `page.on('download')`) --- // // クリック等で発生したダウンロードを workspace の output/ に保存して、agent から // Read / ReadPdf / 等で続けて操作できるようにする。各 BrowseWeb / BrowseWithSession // の戻り値末尾に `[download] saved output/foo.csv (12345 bytes)` を追加する。 export interface BrowserDownloadEntry { filename: string; /** workspace 相対パス (例: "output/report.csv") */ savedRelPath: string; ok: boolean; bytes?: number; error?: string; timestamp: string; } const _downloadHookedPages = new WeakSet(); const _pageDownloads = new WeakMap(); const _pageDownloadPromises = new WeakMap>>(); /** path traversal や禁則文字を排除して安全な basename にする (export: テスト用) */ export function sanitizeDownloadFilename(name: string | undefined | null): string { const base = path.basename((name ?? '').toString() || 'download'); const cleaned = base.replace(/[\\/:*?"<>|\s]/g, '_').slice(0, 200); return cleaned || 'download'; } /** 衝突したら "foo-1.csv" "foo-2.csv" 形式で空きを探す (export: テスト用) */ export function pickUniqueOutputPath(workspacePath: string, filename: string): string { const dir = path.join(workspacePath, 'output'); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const parsed = path.parse(filename); let candidate = path.join(dir, filename); for (let n = 1; existsSync(candidate) && n < 1000; n++) { candidate = path.join(dir, `${parsed.name}-${n}${parsed.ext}`); } return candidate; } function logBrowserDownload(workspacePath: string, entry: BrowserDownloadEntry): void { try { const logsDir = path.join(workspacePath, 'logs'); if (!existsSync(logsDir)) mkdirSync(logsDir, { recursive: true }); appendFileSync( path.join(logsDir, 'downloads.jsonl'), JSON.stringify({ ...entry, source: 'BrowseWeb' }) + '\n', ); } catch (e) { logger.warn(`[BrowseWeb] failed to write download history: ${(e as Error).message}`); } } function setupDownloadHandler(page: Page, workspacePath: string): void { if (_downloadHookedPages.has(page)) return; _downloadHookedPages.add(page); if (!_pageDownloads.has(page)) _pageDownloads.set(page, []); if (!_pageDownloadPromises.has(page)) _pageDownloadPromises.set(page, new Set()); page.on('download', (download: Download) => { const promiseSet = _pageDownloadPromises.get(page)!; const entries = _pageDownloads.get(page)!; const filename = sanitizeDownloadFilename(download.suggestedFilename()); const ts = new Date().toISOString(); const handlerPromise = (async () => { try { const savePath = pickUniqueOutputPath(workspacePath, filename); await download.saveAs(savePath); const bytes = statSync(savePath).size; const savedRelPath = path.relative(workspacePath, savePath); const entry: BrowserDownloadEntry = { filename: path.basename(savePath), savedRelPath, ok: true, bytes, timestamp: ts, }; entries.push(entry); logBrowserDownload(workspacePath, entry); logger.debug(`[BrowseWeb] downloaded ${savedRelPath} (${bytes} bytes)`); } catch (e) { const entry: BrowserDownloadEntry = { filename, savedRelPath: '', ok: false, error: (e as Error).message, timestamp: ts, }; entries.push(entry); logBrowserDownload(workspacePath, entry); } })(); promiseSet.add(handlerPromise); handlerPromise.finally(() => promiseSet.delete(handlerPromise)); }); } /** in-flight な download を最大 timeoutMs だけ待ち、完了済みエントリを取り出して queue を空にする */ async function drainDownloads(page: Page, timeoutMs: number = 30_000): Promise { const promiseSet = _pageDownloadPromises.get(page); if (promiseSet && promiseSet.size > 0) { await Promise.race([ Promise.all(Array.from(promiseSet)), new Promise(r => setTimeout(r, timeoutMs)), ]); } const entries = _pageDownloads.get(page) ?? []; _pageDownloads.set(page, []); return entries; } function formatDownloadLines(entries: BrowserDownloadEntry[]): string { if (entries.length === 0) return ''; return entries.map(e => e.ok ? `[download] saved ${e.savedRelPath} (${e.bytes} bytes)` : `[download] FAILED ${e.filename}: ${e.error}`, ).join('\n'); } /** * Task の BrowserContext を取得する。 * - noVNC + ctx.taskId あり: SessionManager から取った session.context を返す * (admin / 該当タスク owner が noVNC で見ている画面と同じものを操作する) * - それ以外: headless 共有 Browser から workspacePath ごとに新規 context を * 作って _jobContexts に保存 */ async function getJobContext( ctx: ToolContext, ): Promise { const config = loadConfig(); if (config.browser?.captchaSolve === 'novnc' && ctx.taskId) { const sm = getSessionManager(); if (sm) { try { const session = await sm.getOrCreateTaskSessionWithState( ctx.taskId, ctx.userId, ctx.browserSessionState ?? null, ctx.browserSessionProfileId ?? null, ); sm.touchSession(session.id); if (session.context) return session.context; } catch (e) { logger.warn(`[browser] task session for taskId=${ctx.taskId} unavailable: ${(e as Error).message}, falling back to headless`); } } } let jobContext = _jobContexts.get(ctx.workspacePath); if (!jobContext || jobContext.browser() === null) { const browser = await getHeadlessBrowser(); jobContext = await browser.newContext( ctx.browserSessionState ? { storageState: ctx.browserSessionState as never } : {}, ); const { applyStealthInitScript, applyAgentSnapshotHooks } = await import('../browser-launch.js'); await applyStealthInitScript(jobContext); await applyAgentSnapshotHooks(jobContext); _jobContexts.set(ctx.workspacePath, jobContext); } return jobContext; } /** BrowserContext に SSRF インターセプト + ref マップ管理のフックを 1 回だけ装着する */ function ensureContextHooks( context: BrowserContext, allowedHosts: string[], workspacePath: string, ): void { if (_hookedContexts.has(context)) return; _hookedContexts.add(context); context.on('page', (newPage: Page) => { if (!_interceptedPages.has(newPage)) { _interceptedPages.add(newPage); setupRouteInterception(newPage, allowedHosts, workspacePath).catch(() => {}); } setupDownloadHandler(newPage, workspacePath); newPage.on('framenavigated', (frame) => { if (frame === newPage.mainFrame()) _pageRefs.delete(newPage); }); }); // 既存ページにも装着 (context が既に作られている場合) for (const existingPage of context.pages()) { setupDownloadHandler(existingPage, workspacePath); } } /** * BrowseWeb / InteractiveBrowse が使う Page を取得する。 * 同じ taskId / workspacePath 内ではセッション (Cookie / ログイン状態) が維持される。 */ async function getJobPage( ctx: ToolContext, allowedHosts: string[], timeout: number, ): Promise { const context = await getJobContext(ctx); ensureContextHooks(context, allowedHosts, ctx.workspacePath); const pages = context.pages(); if (pages.length > 0) { const page = pages[pages.length - 1]!; page.setDefaultTimeout(timeout); return page; } const page = await context.newPage(); page.setDefaultTimeout(timeout); await setupRouteInterception(page, allowedHosts, ctx.workspacePath); _interceptedPages.add(page); page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) _pageRefs.delete(page); }); return page; } /** * ページの DOM をスキャンし、表示テキスト + 操作可能要素のリファレンス注釈を返す。 * 注釈は {e1 button "ログイン"} のような形式で本文中に埋め込まれる。 * 各 ref は Playwright が解釈できるセレクタ(属性ベース優先、fallback で * XPath 風 nth-of-type)にマップされる。 * * 検出する操作可能要素("div クリック" 系を取り逃さないための拡張): * - 標準タグ: A, BUTTON, INPUT, SELECT, TEXTAREA, LABEL, SUMMARY, DETAILS, OPTION * - ARIA role: button, link, menuitem, menuitemcheckbox/radio, tab, option, * checkbox, radio, switch, combobox, listbox, slider, spinbutton, * textbox, searchbox, treeitem * - [onclick] / [tabindex >= 0] / [contenteditable=true] * * 状態属性 (aria-expanded / aria-checked / aria-selected / aria-pressed / * aria-disabled / aria-haspopup) は注釈末尾に列挙される。 * * Open shadow DOM も走査する。IFRAME と 内部は走査しない(前者は別 * frame、後者はノイズが多い。 自体は親側で interactive * 検出される)。 * * page.evaluate に渡す関数はブラウザ側で実行されるため、DOM API を使用する。 * Node サイドの tsconfig には DOM 型がないため、関数を文字列として渡す。 */ const SNAPSHOT_SCRIPT = `(() => { // IFRAME is intentionally NOT in HARD_SKIP — encountering an
" pattern common in jQuery / vanilla / Vue apps. if (el.hasAttribute && el.hasAttribute('data-ao-click')) return true; const tabindex = el.getAttribute && el.getAttribute('tabindex'); if (tabindex !== null && tabindex !== undefined && parseInt(tabindex, 10) >= 0) return true; if (isContenteditable(el)) return true; return false; } // ── Description ─────────────────────────────────────────── function elementName(el) { const aria = el.getAttribute && el.getAttribute('aria-label'); if (aria) return aria.trim().slice(0, 80); const labelledBy = el.getAttribute && el.getAttribute('aria-labelledby'); if (labelledBy) { const ids = labelledBy.split(/\\s+/); const parts = []; for (const lid of ids) { const target = document.getElementById(lid); if (target) parts.push((target.textContent || '').trim()); } const joined = parts.join(' ').trim(); if (joined) return joined.slice(0, 80); } const text = (el.textContent || '').replace(/\\s+/g, ' ').trim(); if (text) return text.slice(0, 80); const placeholder = el.getAttribute && el.getAttribute('placeholder'); if (placeholder) return placeholder.trim().slice(0, 80); const title = el.getAttribute && el.getAttribute('title'); if (title) return title.trim().slice(0, 80); const alt = el.getAttribute && el.getAttribute('alt'); if (alt) return alt.trim().slice(0, 80); return ''; } function elementRole(el) { const explicit = el.getAttribute && el.getAttribute('role'); if (explicit) return explicit; const tag = el.tagName.toLowerCase(); if (tag === 'a' && el.getAttribute && el.getAttribute('href')) return 'link'; if (tag === 'button') return 'button'; if (tag === 'input') { const t = (el.getAttribute('type') || 'text').toLowerCase(); if (t === 'checkbox' || t === 'radio') return t; if (t === 'submit' || t === 'button' || t === 'reset' || t === 'image') return 'button'; if (t === 'search') return 'searchbox'; return 'textbox'; } if (tag === 'select') return 'combobox'; if (tag === 'textarea') return 'textbox'; if (tag === 'summary') return 'button'; if (tag === 'details') return 'group'; if (tag === 'option') return 'option'; if (isContenteditable(el)) return 'textbox'; return tag; } function elementStates(el) { const states = []; const expanded = el.getAttribute && el.getAttribute('aria-expanded'); if (expanded === 'true') states.push('expanded'); else if (expanded === 'false') states.push('collapsed'); if (el.tagName === 'DETAILS' && el.open) states.push('expanded'); const pressed = el.getAttribute && el.getAttribute('aria-pressed'); if (pressed === 'true') states.push('pressed'); const selected = el.getAttribute && el.getAttribute('aria-selected'); if (selected === 'true') states.push('selected'); const checked = el.getAttribute && el.getAttribute('aria-checked'); if (checked === 'true') states.push('checked'); else if (checked === 'mixed') states.push('mixed'); if (el.tagName === 'INPUT' && (el.type === 'checkbox' || el.type === 'radio') && el.checked && !states.includes('checked')) { states.push('checked'); } if (el.disabled === true || (el.getAttribute && el.getAttribute('aria-disabled') === 'true')) states.push('disabled'); if (el.required === true) states.push('required'); const haspopup = el.getAttribute && el.getAttribute('aria-haspopup'); if (haspopup && haspopup !== 'false') states.push('haspopup'); return states; } function describeElement(el, ref) { const role = elementRole(el); const name = elementName(el).replace(/"/g, "'"); const states = elementStates(el); const tag = el.tagName.toLowerCase(); const parts = [ref, role]; if (name) parts.push('"' + name + '"'); if (tag === 'input' || tag === 'textarea') { const v = el.value || ''; if (v) parts.push('value="' + String(v).slice(0, 30).replace(/"/g, "'") + '"'); } if (tag === 'a') { const href = el.getAttribute('href'); if (href) parts.push('href="' + href.slice(0, 60).replace(/"/g, "'") + '"'); } if (states.length) parts.push(states.join(' ')); return '{' + parts.join(' ') + '}'; } // ── Walk ───────────────────────────────────────────────── function walk(node) { if (node.nodeType === 3) { const t = (node.textContent || '').replace(/\\s+/g, ' ').trim(); if (t) lines.push(t); return; } if (node.nodeType !== 1) return; const el = node; if (HARD_SKIP_TAGS.has(el.tagName)) return; if (isHidden(el)) return; if (!isVisible(el)) return; //