// 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] [index] // // - search : Google-search the query, return the top organic results. // - youtube : search YouTube and play a result. `index` is the 1-based position // from the top of the result list (default 1 = 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'; 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 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 mode = (process.argv[3] || 'search').toLowerCase(); // 1-based position of the YouTube result to play, counted from the top of the // list. Defaults to 1 (first result). Anything <1 or non-numeric falls back to 1. const playIndex = Math.max(1, parseInt(process.argv[4], 10) || 1); const out = (o) => { process.stdout.write(JSON.stringify(o)); }; if (!query) { out({ ok: false, error: 'no query' }); process.exit(1); } 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 { await acquirePage(); page.setDefaultTimeout(20000); await page.bringToFront().catch(() => {}); if (mode === 'youtube') { // Type into YouTube's search box like a person, then play the requested // result (the Nth from the top of the list; default the first). 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 }); const results = page.locator('ytd-video-renderer a#video-title, a#video-title'); // Clamp to what's actually on the page so "play the 5th" still plays the // last available result rather than failing when fewer were returned. const available = await results.count(); const targetIdx = Math.min(playIndex, Math.max(available, 1)) - 1; const target = results.nth(targetIdx); const title = (await target.getAttribute('title').catch(() => '')) || (await target.innerText().catch(() => '')); await target.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, index: targetIdx + 1, title: (title || '').trim(), url: page.url() }); } else { // 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); // 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 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 closeAll(); } catch (e) { await closeAll(); out({ ok: false, error: String(e?.message || e) }); process.exit(1); }