// 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'; // 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); }