Files
javis_bot/bot/scripts/stream-test/browse-search.mjs
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

118 lines
5.1 KiB
JavaScript

// 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 "<query>" [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';
// 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.
const CDP_HOST = process.env.CDP_HOST || '127.0.0.1';
const query = process.argv[2] || '';
const mode = (process.argv[3] || 'search').toLowerCase();
const out = (o) => { process.stdout.write(JSON.stringify(o)); };
if (!query) { out({ ok: false, error: 'no query' }); process.exit(1); }
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 {
await acquirePage();
page.setDefaultTimeout(20000);
await page.bringToFront().catch(() => {});
if (mode === 'youtube') {
await page.goto(`https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`, { waitUntil: 'domcontentloaded' });
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 title = (await first.getAttribute('title').catch(() => '')) || (await first.innerText().catch(() => ''));
await first.click();
await page.waitForSelector('#movie_player', { timeout: 20000 });
await page.evaluate(() => { const v = document.querySelector('video'); if (v && v.paused) v.play(); });
out({ ok: true, mode, title: (title || '').trim(), url: page.url() });
} 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 = [];
for (const h of Array.from(document.querySelectorAll('a h3'))) {
const a = h.closest('a');
const url = a?.href || '';
if (!url || seen.has(url) || url.includes('google.com')) continue;
const block = h.closest('div[data-hveid], div.g') || a.parentElement;
let snippet = '';
const sn = block?.querySelector('div[data-sncf], div[style*="webkit-line-clamp"], .VwiC3b');
snippet = (sn?.innerText || '').trim();
seen.add(url);
items.push({ title: h.innerText.trim(), url, snippet });
if (items.length >= 6) break;
}
return items;
});
out({ ok: true, mode, query, count: results.length, results, headless: launched });
}
await browser.close();
} catch (e) {
try { await browser?.close(); } catch { /* ignore */ }
out({ ok: false, error: String(e?.message || e) });
process.exit(1);
}