diff --git a/bot/scripts/stream-test/browse-search.mjs b/bot/scripts/stream-test/browse-search.mjs index 7f0a205..6a37e10 100644 --- a/bot/scripts/stream-test/browse-search.mjs +++ b/bot/scripts/stream-test/browse-search.mjs @@ -1,11 +1,17 @@ -// True-mode browser action core. Drives the on-screen Chrome (CDP at CDP_PORT, -// default 9222) so the action is visible on the Go-Live broadcast, and prints a -// JSON result on stdout for the Python `browseAndSearch` tool to wrap. +// Browser action core. Prefers the on-screen Chrome (CDP at CDP_PORT, default +// 9222) so the action is visible on the Go-Live broadcast, and prints a JSON +// result on stdout for the Python `browseAndSearch` tool to wrap. // // node browse-search.mjs "" [search|youtube] // // - search : Google-search the query, return the top organic results. // - youtube : search YouTube and play the first result. +// +// When no broadcast Chrome is reachable on CDP (e.g. a plain text conversation +// with no active broadcast), `search` falls back to a headless Chromium we +// launch ourselves, so browser-based Google search still works at zero API +// cost. `youtube` only makes sense on the visible broadcast Chrome, so it never +// uses the headless fallback. import { chromium } from 'playwright'; const CDP = process.env.CDP_PORT || '9222'; @@ -18,11 +24,48 @@ const out = (o) => { process.stdout.write(JSON.stringify(o)); }; if (!query) { out({ ok: false, error: 'no query' }); process.exit(1); } -let b; +let browser; // playwright Browser: connected (CDP) or launched (headless) +let launched = false; // true when we launched our own headless browser +let page; + +// Acquire a page from the broadcast Chrome over CDP, or — for search mode only — +// from a headless Chromium we launch when no broadcast Chrome is reachable. +async function acquirePage() { + try { + browser = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`); + const ctx = browser.contexts()[0]; + page = ctx.pages()[0] || (await ctx.newPage()); + return; + } catch (e) { + // YouTube playback must be on the visible broadcast Chrome; no fallback. + if (mode === 'youtube') throw e; + } + // Prefer the system Chrome the host already has (channel: 'chrome') so the + // fallback needs no extra Playwright browser download; degrade to Playwright's + // bundled chromium if no system Chrome is installed. + launched = true; + let launchErr; + for (const opts of [{ headless: true, channel: 'chrome' }, { headless: true }]) { + try { + browser = await chromium.launch(opts); + break; + } catch (e) { + launchErr = e; + browser = undefined; + } + } + if (!browser) throw launchErr; + const ctx = await browser.newContext({ + locale: 'ko-KR', + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' + + '(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36', + }); + page = await ctx.newPage(); +} + try { - b = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`); - const ctx = b.contexts()[0]; - const page = ctx.pages()[0] || (await ctx.newPage()); + await acquirePage(); page.setDefaultTimeout(20000); await page.bringToFront().catch(() => {}); @@ -38,6 +81,15 @@ try { } else { await page.goto(`https://www.google.com/search?q=${encodeURIComponent(query)}&hl=ko`, { waitUntil: 'domcontentloaded' }); await page.waitForTimeout(1500); + // Google serves its bot-detection interstitial (/sorry/index) to sessions it + // suspects are automated. Detect it structurally (by URL, locale-independent) + // and fail fast so the Python caller fail-opens to the DDG cascade instead of + // treating an empty challenge page as "no results". + if (page.url().includes('/sorry/')) { + await browser.close(); + out({ ok: false, error: 'google-bot-challenge', headless: launched }); + process.exit(1); + } const results = await page.evaluate(() => { const seen = new Set(); const items = []; @@ -55,11 +107,11 @@ try { } return items; }); - out({ ok: true, mode, query, count: results.length, results }); + out({ ok: true, mode, query, count: results.length, results, headless: launched }); } - await b.close(); + await browser.close(); } catch (e) { - try { await b?.close(); } catch { /* ignore */ } + try { await browser?.close(); } catch { /* ignore */ } out({ ok: false, error: String(e?.message || e) }); process.exit(1); } diff --git a/src/jarvis/tools/builtin/web_search.spec.md b/src/jarvis/tools/builtin/web_search.spec.md index 08e560e..878a151 100644 --- a/src/jarvis/tools/builtin/web_search.spec.md +++ b/src/jarvis/tools/builtin/web_search.spec.md @@ -22,7 +22,17 @@ path (evals, text entry) and falls back to the master flag: - **on-screen Chrome**: `browser_search()` drives Chrome (Node CDP helper `bot/scripts/stream-test/browse-search.mjs`) to Google-search the query, so - the action is visible on the Go-Live broadcast. + the action is visible on the Go-Live broadcast. When no broadcast Chrome is + reachable on CDP (e.g. a plain text turn with no active broadcast), the helper + falls back to launching its own headless Chrome (system `channel: 'chrome'`, + else Playwright's bundled chromium) so browser-based Google search still works + with no API cost. The `youtube` action never uses the headless fallback (it + only makes sense on the visible broadcast Chrome). Caveat: a freshly-launched + headless session with no logged-in profile can be served Google's + bot-detection interstitial (`/sorry/index`); the helper detects this + structurally by URL and fails fast, so the caller fail-opens to the DDG / + Brave / Wikipedia cascade rather than treating the challenge page as "no + results". - **Gemini**: answers, with the sub-mode chosen by `cfg.gemini_auth` (env `GEMINI_AUTH`, default `oauth`): - `oauth` (default): `gemini_cli_search()` shells out to the Gemini CLI