3 Commits

Author SHA1 Message Date
javis-bot
c56ce1eb30 feat: human-like typing for browser Google and YouTube search
Some checks failed
Release / semantic-release (push) Successful in 26s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m16s
Release / build-linux (push) Failing after 7m11s
Release / build-windows (push) Has been cancelled
Release / build-macos (arm64, macos-latest) (push) Has been cancelled
Release / build-macos (x64, macos-15-intel) (push) Has been cancelled
Release / release-main (push) Has been cancelled
Release / release-develop (push) Has been cancelled
Make the browser search helper search the way a person does: load the
site home page, type the query into the search box one key at a time, and
press Enter — for both Google `search` and `youtube` — instead of jumping
straight to a results URL. Supports the goal of a human-like assistant.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-22 21:09:15 +09:00
javis-bot
597207dd33 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>
2026-06-22 20:57:25 +09:00
javis-bot
98a1825d01 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>
2026-06-22 20:52:54 +09:00
3 changed files with 134 additions and 13 deletions

View File

@@ -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

View File

@@ -1,33 +1,112 @@
// 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.
//
// Backend selection for `search`:
// 1. The broadcast Chrome over CDP (visible on the Go-Live stream).
// 2. Else, if CHROME_USER_DATA_DIR is set, a persistent Chrome using that
// profile dir. Logging that dedicated profile into Google once lets Google
// 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 b; let connected; // CDP Browser (the broadcast Chrome — never kill it)
let launchedBrowser; // ephemeral headless Browser we launched
let persistent; // persistent BrowserContext we launched
let launched = false;
let page;
// Try system Chrome (channel:'chrome') first so no extra Playwright browser
// 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() {
// 1. Broadcast Chrome over CDP.
try {
connected = await chromium.connectOverCDP(`http://${CDP_HOST}:${CDP}`);
const ctx = connected.contexts()[0];
page = ctx.pages()[0] || (await ctx.newPage());
return;
} catch (e) {
if (mode === 'youtube') throw e; // youtube needs the visible broadcast Chrome
}
// 2. Persistent profile (signed-in) when configured.
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;
const ctx = await launchedBrowser.newContext({ locale: 'ko-KR', userAgent: UA });
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 */ }
}
// Human-like search: land on the site's home page, type the query into its
// search box one key at a time, and press Enter — the way a person would,
// rather than jumping straight to a results URL.
async function typeSearch(homeUrl, boxSelector, query) {
await page.goto(homeUrl, { waitUntil: 'domcontentloaded' });
const box = page.locator(boxSelector).first();
await box.waitFor({ timeout: 15000 });
await box.click();
await box.pressSequentially(query, { delay: 45 });
await box.press('Enter');
}
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(() => {});
if (mode === 'youtube') { if (mode === 'youtube') {
await page.goto(`https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`, { waitUntil: 'domcontentloaded' }); // Type into YouTube's search box like a person, then play the first result.
await typeSearch('https://www.youtube.com/?hl=ko', 'input#search, input[name="search_query"]', query);
await page.waitForSelector('ytd-video-renderer a#video-title, a#video-title', { timeout: 20000 }); await page.waitForSelector('ytd-video-renderer a#video-title, a#video-title', { timeout: 20000 });
const first = page.locator('ytd-video-renderer a#video-title, a#video-title').first(); const first = page.locator('ytd-video-renderer a#video-title, a#video-title').first();
const title = (await first.getAttribute('title').catch(() => '')) || (await first.innerText().catch(() => '')); const title = (await first.getAttribute('title').catch(() => '')) || (await first.innerText().catch(() => ''));
@@ -36,8 +115,19 @@ try {
await page.evaluate(() => { const v = document.querySelector('video'); if (v && v.paused) v.play(); }); await page.evaluate(() => { const v = document.querySelector('video'); if (v && v.paused) v.play(); });
out({ ok: true, mode, title: (title || '').trim(), url: page.url() }); out({ ok: true, mode, title: (title || '').trim(), url: page.url() });
} else { } else {
await page.goto(`https://www.google.com/search?q=${encodeURIComponent(query)}&hl=ko`, { waitUntil: 'domcontentloaded' }); // Type into Google's search box like a person, then read the results.
await typeSearch('https://www.google.com/?hl=ko', 'textarea[name="q"], input[name="q"]', query);
await page.waitForLoadState('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 closeAll();
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 +145,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 closeAll();
} catch (e) { } catch (e) {
try { await b?.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);
} }

View File

@@ -22,7 +22,29 @@ 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. The helper searches the
human way — it loads the site home page, types the query into the search box
one key at a time, and presses Enter (both Google `search` and `youtube`),
rather than jumping to a results URL. When no broadcast Chrome is
reachable on CDP (e.g. a plain text turn with no active broadcast), the helper
falls back, for `search` only, to launching its own Chrome so browser-based
Google search still works with no API cost. Fallback order:
- **CDP** (the broadcast Chrome) — preferred, visible on the stream.
- **Persistent profile** when `CHROME_USER_DATA_DIR` is set — Chrome opened
against that profile dir (system `channel: 'chrome'`, else bundled chromium).
Logging that dedicated profile into Google once lets Google treat later
searches as a returning signed-in user, which is what avoids the
bot-detection interstitial. This is the reliable way to get browser Google
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