feat: reuse a signed-in Chrome profile for browser web search
Add CHROME_USER_DATA_DIR so the browser search fallback can open Chrome against a dedicated, Google-signed-in profile instead of a fresh anonymous session. A returning signed-in profile is what actually avoids Google's /sorry bot-detection page, so this is the reliable way to get browser Google search in plain text turns. Fallback order is now CDP (broadcast Chrome) -> persistent profile (when configured) -> ephemeral headless, all still fail-open to the DDG/Brave/Wikipedia cascade. Document the profile in .env.example and web_search.spec.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -98,6 +98,15 @@ CHROME_START_URL=about:blank
|
|||||||
# on-screen browser for real-time info (search / play / read screen).
|
# on-screen browser for real-time info (search / play / read screen).
|
||||||
# false = no screen share; voice only, real-time info via the Gemini API.
|
# false = no screen share; voice only, real-time info via the Gemini API.
|
||||||
STREAM_BROWSER=true
|
STREAM_BROWSER=true
|
||||||
|
# Optional: profile dir for browser-based Google search in plain text turns
|
||||||
|
# (no active broadcast). When set, the search helper opens Chrome against this
|
||||||
|
# profile instead of a fresh anonymous one. Sign that profile into Google once
|
||||||
|
# (run a real Chrome with --user-data-dir=<this path> and log in) so Google
|
||||||
|
# treats later searches as a returning user and does not serve the bot-detection
|
||||||
|
# page. Leave blank to use an ephemeral headless session (works only where
|
||||||
|
# Google does not challenge it). Use a DEDICATED dir, not your everyday Chrome
|
||||||
|
# profile, to avoid the "profile in use" lock while Chrome is open.
|
||||||
|
CHROME_USER_DATA_DIR=
|
||||||
# Gemini auth for real-time info when STREAM_BROWSER=false.
|
# Gemini auth for real-time info when STREAM_BROWSER=false.
|
||||||
# oauth = use the Gemini CLI with a Google-account login (no API key).
|
# oauth = use the Gemini CLI with a Google-account login (no API key).
|
||||||
# Install once: npm i -g @google/gemini-cli ; then run `gemini` and
|
# Install once: npm i -g @google/gemini-cli ; then run `gemini` and
|
||||||
|
|||||||
@@ -7,63 +7,86 @@
|
|||||||
// - 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
|
// Backend selection for `search`:
|
||||||
// with no active broadcast), `search` falls back to a headless Chromium we
|
// 1. The broadcast Chrome over CDP (visible on the Go-Live stream).
|
||||||
// launch ourselves, so browser-based Google search still works at zero API
|
// 2. Else, if CHROME_USER_DATA_DIR is set, a persistent Chrome using that
|
||||||
// cost. `youtube` only makes sense on the visible broadcast Chrome, so it never
|
// profile dir. Logging that dedicated profile into Google once lets Google
|
||||||
// uses the headless fallback.
|
// treat later searches as a returning signed-in user, which avoids the
|
||||||
|
// bot-detection interstitial that blocks a fresh anonymous session.
|
||||||
|
// 3. Else a fresh ephemeral headless Chrome (works only where Google does not
|
||||||
|
// challenge the session, e.g. a non-flagged residential IP).
|
||||||
|
// `youtube` only makes sense on the visible broadcast Chrome, so it never uses
|
||||||
|
// the headless/persistent fallback.
|
||||||
import { chromium } from 'playwright';
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
const CDP = process.env.CDP_PORT || '9222';
|
const CDP = process.env.CDP_PORT || '9222';
|
||||||
// Use 127.0.0.1, not "localhost": in containers localhost can resolve to IPv6
|
// Use 127.0.0.1, not "localhost": in containers localhost can resolve to IPv6
|
||||||
// (::1) first while Chrome's CDP listens on IPv4, giving ECONNREFUSED ::1.
|
// (::1) first while Chrome's CDP listens on IPv4, giving ECONNREFUSED ::1.
|
||||||
const CDP_HOST = process.env.CDP_HOST || '127.0.0.1';
|
const CDP_HOST = process.env.CDP_HOST || '127.0.0.1';
|
||||||
|
const USER_DATA_DIR = process.env.CHROME_USER_DATA_DIR || '';
|
||||||
|
const UA =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ' +
|
||||||
|
'(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
|
||||||
const query = process.argv[2] || '';
|
const query = process.argv[2] || '';
|
||||||
const mode = (process.argv[3] || 'search').toLowerCase();
|
const mode = (process.argv[3] || 'search').toLowerCase();
|
||||||
const out = (o) => { process.stdout.write(JSON.stringify(o)); };
|
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 browser; // playwright Browser: connected (CDP) or launched (headless)
|
let connected; // CDP Browser (the broadcast Chrome — never kill it)
|
||||||
let launched = false; // true when we launched our own headless browser
|
let launchedBrowser; // ephemeral headless Browser we launched
|
||||||
|
let persistent; // persistent BrowserContext we launched
|
||||||
|
let launched = false;
|
||||||
let page;
|
let page;
|
||||||
|
|
||||||
// Acquire a page from the broadcast Chrome over CDP, or — for search mode only —
|
// Try system Chrome (channel:'chrome') first so no extra Playwright browser
|
||||||
// from a headless Chromium we launch when no broadcast Chrome is reachable.
|
// download is needed; fall back to Playwright's bundled chromium.
|
||||||
|
async function tryLaunch(launchFn) {
|
||||||
|
let err;
|
||||||
|
for (const opts of [{ headless: true, channel: 'chrome' }, { headless: true }]) {
|
||||||
|
try {
|
||||||
|
return await launchFn(opts);
|
||||||
|
} catch (e) {
|
||||||
|
err = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
async function acquirePage() {
|
async function acquirePage() {
|
||||||
|
// 1. Broadcast Chrome over CDP.
|
||||||
try {
|
try {
|
||||||
browser = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`);
|
connected = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`);
|
||||||
const ctx = browser.contexts()[0];
|
const ctx = connected.contexts()[0];
|
||||||
page = ctx.pages()[0] || (await ctx.newPage());
|
page = ctx.pages()[0] || (await ctx.newPage());
|
||||||
return;
|
return;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// YouTube playback must be on the visible broadcast Chrome; no fallback.
|
if (mode === 'youtube') throw e; // youtube needs the visible broadcast Chrome
|
||||||
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
|
// 2. Persistent profile (signed-in) when configured.
|
||||||
// bundled chromium if no system Chrome is installed.
|
if (USER_DATA_DIR) {
|
||||||
|
persistent = await tryLaunch((opts) =>
|
||||||
|
chromium.launchPersistentContext(USER_DATA_DIR, { ...opts, locale: 'ko-KR', userAgent: UA }),
|
||||||
|
);
|
||||||
|
launched = true;
|
||||||
|
page = persistent.pages()[0] || (await persistent.newPage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ephemeral headless.
|
||||||
|
launchedBrowser = await tryLaunch((opts) => chromium.launch(opts));
|
||||||
launched = true;
|
launched = true;
|
||||||
let launchErr;
|
const ctx = await launchedBrowser.newContext({ locale: 'ko-KR', userAgent: UA });
|
||||||
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();
|
page = await ctx.newPage();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function closeAll() {
|
||||||
|
try { await persistent?.close(); } catch { /* ignore */ }
|
||||||
|
try { await launchedBrowser?.close(); } catch { /* ignore */ }
|
||||||
|
try { await connected?.close(); } catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await acquirePage();
|
await acquirePage();
|
||||||
page.setDefaultTimeout(20000);
|
page.setDefaultTimeout(20000);
|
||||||
@@ -86,7 +109,7 @@ try {
|
|||||||
// and fail fast so the Python caller fail-opens to the DDG cascade instead of
|
// and fail fast so the Python caller fail-opens to the DDG cascade instead of
|
||||||
// treating an empty challenge page as "no results".
|
// treating an empty challenge page as "no results".
|
||||||
if (page.url().includes('/sorry/')) {
|
if (page.url().includes('/sorry/')) {
|
||||||
await browser.close();
|
await closeAll();
|
||||||
out({ ok: false, error: 'google-bot-challenge', headless: launched });
|
out({ ok: false, error: 'google-bot-challenge', headless: launched });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@@ -109,9 +132,9 @@ try {
|
|||||||
});
|
});
|
||||||
out({ ok: true, mode, query, count: results.length, results, headless: launched });
|
out({ ok: true, mode, query, count: results.length, results, headless: launched });
|
||||||
}
|
}
|
||||||
await browser.close();
|
await closeAll();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
try { await browser?.close(); } catch { /* ignore */ }
|
await closeAll();
|
||||||
out({ ok: false, error: String(e?.message || e) });
|
out({ ok: false, error: String(e?.message || e) });
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,15 +24,24 @@ path (evals, text entry) and falls back to the master flag:
|
|||||||
`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. When no broadcast Chrome is
|
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
|
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'`,
|
falls back, for `search` only, to launching its own Chrome so browser-based
|
||||||
else Playwright's bundled chromium) so browser-based Google search still works
|
Google search still works with no API cost. Fallback order:
|
||||||
with no API cost. The `youtube` action never uses the headless fallback (it
|
- **CDP** (the broadcast Chrome) — preferred, visible on the stream.
|
||||||
only makes sense on the visible broadcast Chrome). Caveat: a freshly-launched
|
- **Persistent profile** when `CHROME_USER_DATA_DIR` is set — Chrome opened
|
||||||
headless session with no logged-in profile can be served Google's
|
against that profile dir (system `channel: 'chrome'`, else bundled chromium).
|
||||||
bot-detection interstitial (`/sorry/index`); the helper detects this
|
Logging that dedicated profile into Google once lets Google treat later
|
||||||
structurally by URL and fails fast, so the caller fail-opens to the DDG /
|
searches as a returning signed-in user, which is what avoids the
|
||||||
Brave / Wikipedia cascade rather than treating the challenge page as "no
|
bot-detection interstitial. This is the reliable way to get browser Google
|
||||||
results".
|
search in plain text turns.
|
||||||
|
- **Ephemeral headless** otherwise — a fresh anonymous session; works only
|
||||||
|
where Google does not challenge it (e.g. a non-flagged residential IP).
|
||||||
|
|
||||||
|
The `youtube` action never uses the fallback (it only makes sense on the
|
||||||
|
visible broadcast Chrome). Caveat: an anonymous (not-signed-in) session 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