feat: headless Chrome fallback for browser web search outside broadcast
browse-search.mjs only connected to the on-screen broadcast Chrome over CDP, so browser-based Google search worked only during a live broadcast; plain text turns fell through to the DDG cascade. Add a headless fallback (system Chrome via channel:'chrome', else Playwright's bundled chromium) for `search` mode so general conversation can use Google at no API cost. `youtube` still requires the visible broadcast Chrome. Detect Google's /sorry bot-detection interstitial structurally by URL and fail fast so the caller fail-opens to DDG/Brave/Wikipedia instead of treating the challenge page as empty results. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,17 @@
|
|||||||
// True-mode browser action core. Drives the on-screen Chrome (CDP at CDP_PORT,
|
// Browser action core. Prefers the on-screen Chrome (CDP at CDP_PORT, default
|
||||||
// default 9222) so the action is visible on the Go-Live broadcast, and prints a
|
// 9222) so the action is visible on the Go-Live broadcast, and prints a JSON
|
||||||
// JSON result on stdout for the Python `browseAndSearch` tool to wrap.
|
// result on stdout for the Python `browseAndSearch` tool to wrap.
|
||||||
//
|
//
|
||||||
// node browse-search.mjs "<query>" [search|youtube]
|
// node browse-search.mjs "<query>" [search|youtube]
|
||||||
//
|
//
|
||||||
// - search : Google-search the query, return the top organic results.
|
// - search : Google-search the query, return the top organic results.
|
||||||
// - youtube : search YouTube and play the first result.
|
// - 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';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
const CDP = process.env.CDP_PORT || '9222';
|
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); }
|
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 {
|
try {
|
||||||
b = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`);
|
await acquirePage();
|
||||||
const ctx = b.contexts()[0];
|
|
||||||
const page = ctx.pages()[0] || (await ctx.newPage());
|
|
||||||
page.setDefaultTimeout(20000);
|
page.setDefaultTimeout(20000);
|
||||||
await page.bringToFront().catch(() => {});
|
await page.bringToFront().catch(() => {});
|
||||||
|
|
||||||
@@ -38,6 +81,15 @@ try {
|
|||||||
} else {
|
} else {
|
||||||
await page.goto(`https://www.google.com/search?q=${encodeURIComponent(query)}&hl=ko`, { waitUntil: 'domcontentloaded' });
|
await page.goto(`https://www.google.com/search?q=${encodeURIComponent(query)}&hl=ko`, { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForTimeout(1500);
|
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 results = await page.evaluate(() => {
|
||||||
const seen = new Set();
|
const seen = new Set();
|
||||||
const items = [];
|
const items = [];
|
||||||
@@ -55,11 +107,11 @@ try {
|
|||||||
}
|
}
|
||||||
return items;
|
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) {
|
} catch (e) {
|
||||||
try { await b?.close(); } catch { /* ignore */ }
|
try { await browser?.close(); } catch { /* ignore */ }
|
||||||
out({ ok: false, error: String(e?.message || e) });
|
out({ ok: false, error: String(e?.message || e) });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
- **on-screen Chrome**: `browser_search()` drives Chrome (Node CDP helper
|
||||||
`bot/scripts/stream-test/browse-search.mjs`) to Google-search the query, so
|
`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`
|
- **Gemini**: answers, with the sub-mode chosen by `cfg.gemini_auth`
|
||||||
(env `GEMINI_AUTH`, default `oauth`):
|
(env `GEMINI_AUTH`, default `oauth`):
|
||||||
- `oauth` (default): `gemini_cli_search()` shells out to the Gemini CLI
|
- `oauth` (default): `gemini_cli_search()` shells out to the Gemini CLI
|
||||||
|
|||||||
Reference in New Issue
Block a user