Some checks failed
Release / semantic-release (push) Successful in 22s
tests / Unit tests (Linux, Python 3.11) (push) Failing after 5m16s
Release / build-linux (push) Failing after 7m10s
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
agents/llm.md promises "play the Nth video from the top", but browseAndPlay only ever clicked the first result. Add an optional 1-based index argument (default 1, backward-compatible) threaded to the Node helper, which now clicks the Nth a#video-title and clamps to the number of results returned so asking beyond the list plays the last available video instead of failing. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
166 lines
7.3 KiB
JavaScript
166 lines
7.3 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] [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);
|
|
}
|