479 lines
20 KiB
JavaScript
479 lines
20 KiB
JavaScript
#!/usr/bin/env node
|
|
/** Node Playwright sidecar. The Bare daemon never loads Playwright itself. */
|
|
import { createRequire } from 'node:module';
|
|
import { extensionLaunch, installedExtensionDirs } from './extensions.js';
|
|
import { pageSliceOffsets } from './reading.js';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import readline from 'node:readline';
|
|
|
|
const require = createRequire(import.meta.url);
|
|
const { SEARCH_HOME, duckduckgoSearchUrl, isBlankStart } = require('./startpage.cjs');
|
|
|
|
const SEARCH_BUDGET_MS = 25_000;
|
|
const FETCH_BUDGET_MS = 30_000;
|
|
const CHALLENGE_MS = 20_000;
|
|
const CHALLENGE_RE = /just a moment|attention required|verify (?:that )?you are human|checking your browser|enable javascript|security check|captcha|cloudflare|unusual traffic|please wait/i;
|
|
|
|
const SEARCH_URLS = {
|
|
duckduckgo: (q) => duckduckgoSearchUrl(q),
|
|
ddg: (q) => duckduckgoSearchUrl(q),
|
|
ddg_lite: (q) => duckduckgoSearchUrl(q),
|
|
startpage: (q) => duckduckgoSearchUrl(q),
|
|
start: (q) => duckduckgoSearchUrl(q),
|
|
google: (q) => `https://www.google.com/search?q=${encodeURIComponent(q)}`,
|
|
bing: (q) => `https://www.bing.com/search?q=${encodeURIComponent(q)}`,
|
|
bing_rss: (q) => `https://www.bing.com/search?q=${encodeURIComponent(q)}`,
|
|
wikipedia: (q) => `https://en.wikipedia.org/w/index.php?search=${encodeURIComponent(q)}`,
|
|
hn: (q) => `https://hn.algolia.com/?q=${encodeURIComponent(q)}`,
|
|
github: (q) => `https://github.com/search?q=${encodeURIComponent(q)}&type=repositories`,
|
|
npm: (q) => `https://www.npmjs.com/search?q=${encodeURIComponent(q)}`,
|
|
mdn: (q) => `https://developer.mozilla.org/en-US/search?q=${encodeURIComponent(q)}`,
|
|
stackoverflow: (q) => `https://stackoverflow.com/search?q=${encodeURIComponent(q)}`,
|
|
arxiv: (q) => `https://arxiv.org/search/?query=${encodeURIComponent(q)}&searchtype=all`,
|
|
};
|
|
|
|
function stateRoot() {
|
|
return path.join(process.env.XDG_STATE_HOME || path.join(os.homedir(), '.local/state'), 'jarvis', 'browser');
|
|
}
|
|
|
|
function isBlockedHostname(hostname) {
|
|
if (!hostname) return true;
|
|
const h = String(hostname).toLowerCase().replace(/^\[|\]$/g, '');
|
|
if (h === 'localhost' || h.endsWith('.localhost') || h === '0.0.0.0' || h === '::' || h === '::1' || h === 'metadata.google.internal' || h.endsWith('.internal')) return true;
|
|
const v4 = h.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
if (v4) {
|
|
const a = Number(v4[1]);
|
|
const b = Number(v4[2]);
|
|
if (a === 0 || a === 10 || a === 127) return true;
|
|
if (a === 169 && b === 254) return true;
|
|
if (a === 172 && b >= 16 && b <= 31) return true;
|
|
if (a === 192 && b === 168) return true;
|
|
if (a === 100 && b >= 64 && b <= 127) return true;
|
|
}
|
|
if (h.includes(':') && (h === '::1' || h.startsWith('fe80:') || h.startsWith('fc') || h.startsWith('fd'))) return true;
|
|
return false;
|
|
}
|
|
|
|
function assertPublicHttpUrl(raw) {
|
|
let url;
|
|
try { url = new URL(String(raw)); } catch { throw new Error('invalid URL'); }
|
|
if (url.protocol !== 'https:' && url.protocol !== 'http:') throw new Error('only http(s) URLs are allowed');
|
|
if (isBlockedHostname(url.hostname)) throw new Error('private, loopback, and metadata hosts are blocked');
|
|
return url.href;
|
|
}
|
|
|
|
function reply(id, payload) {
|
|
process.stdout.write(`${JSON.stringify({ id, ...payload })}\n`);
|
|
}
|
|
|
|
function fail(id, error) {
|
|
reply(id, { ok: false, error: String(error && error.message || error) });
|
|
}
|
|
|
|
async function loadPlaywright() {
|
|
try {
|
|
return require('playwright');
|
|
} catch (error) {
|
|
throw new Error(`Playwright is not installed (${error.message}). From the Jarvis tree run: npm install && npm run browser:install`);
|
|
}
|
|
}
|
|
|
|
function challengeText(title, text) {
|
|
return CHALLENGE_RE.test(String(title || '')) || CHALLENGE_RE.test(String(text || '').slice(0, 1200));
|
|
}
|
|
|
|
async function waitOutChallenge(page, timeoutMs) {
|
|
const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
|
|
while (Date.now() < deadline) {
|
|
const title = await page.title().catch(() => '');
|
|
const text = await page.locator('body').innerText({ timeout: 1000 }).catch(() => '');
|
|
if (!challengeText(title, text)) return false;
|
|
await page.waitForTimeout(400);
|
|
}
|
|
const title = await page.title().catch(() => '');
|
|
const text = await page.locator('body').innerText({ timeout: 1000 }).catch(() => '');
|
|
return challengeText(title, text);
|
|
}
|
|
|
|
async function extractHits(page, limit) {
|
|
const max = Math.min(15, Math.max(1, Number(limit) || 8));
|
|
return page.evaluate((cap) => {
|
|
const out = [];
|
|
const seen = new Set();
|
|
const unwrap = (href) => {
|
|
try {
|
|
const u = new URL(href);
|
|
const host = u.hostname.replace(/^www\./, '');
|
|
const dest = u.searchParams.get('uddg') || u.searchParams.get('url');
|
|
if (dest && /^https?:/i.test(dest)) return dest;
|
|
if ((host === 'google.com' || host.endsWith('.google.com')) && u.pathname === '/url') {
|
|
const q = u.searchParams.get('q');
|
|
if (q && /^https?:/i.test(q)) return q;
|
|
}
|
|
if (['google.com', 'bing.com', 'googleusercontent.com'].includes(host)) return '';
|
|
return u.href;
|
|
} catch {
|
|
return '';
|
|
}
|
|
};
|
|
const push = (href, title, snippet) => {
|
|
const url = unwrap(href);
|
|
if (!url || !/^https?:/i.test(url) || seen.has(url)) return;
|
|
seen.add(url);
|
|
out.push({
|
|
url,
|
|
title: String(title || url).replace(/\s+/g, ' ').trim().slice(0, 200) || url,
|
|
snippet: String(snippet || '').replace(/\s+/g, ' ').trim().slice(0, 280),
|
|
});
|
|
};
|
|
const blocks = document.querySelectorAll('article[data-testid="result"], [data-testid="result"], li.b_algo, div.g, a.result__a');
|
|
for (const node of blocks) {
|
|
const a = node.tagName === 'A' ? node : node.querySelector('a[href]');
|
|
if (!a) continue;
|
|
const heading = node.querySelector('h3, h2') || a;
|
|
const snip = node.querySelector('[data-result="snippet"], .b_caption p, .result__snippet, .VwiC3b');
|
|
push(a.href, heading.innerText, snip ? snip.innerText : '');
|
|
if (out.length >= cap) return out;
|
|
}
|
|
if (!out.length) {
|
|
for (const a of document.querySelectorAll('a[href^="http"]')) {
|
|
const label = (a.innerText || '').replace(/\s+/g, ' ').trim();
|
|
if (label.length < 8) continue;
|
|
push(a.href, label, '');
|
|
if (out.length >= cap) break;
|
|
}
|
|
}
|
|
return out.slice(0, cap);
|
|
}, max);
|
|
}
|
|
|
|
const PAGE_TEXT_CAP = 64000;
|
|
const PAGE_SHOTS = 4;
|
|
|
|
async function walkPage(page) {
|
|
return page.evaluate(async () => {
|
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
for (const node of document.querySelectorAll('details')) node.open = true;
|
|
const view = Math.max(400, window.innerHeight || 800);
|
|
const step = Math.max(500, Math.floor(view * 0.85));
|
|
let lastHeight = 0;
|
|
let still = 0;
|
|
for (let i = 0; i < 36; i++) {
|
|
const height = Math.max(document.documentElement?.scrollHeight || 0, document.body?.scrollHeight || 0);
|
|
const y = Math.min(i * step, Math.max(0, height - view));
|
|
window.scrollTo(0, y);
|
|
await sleep(80);
|
|
if (height > lastHeight + 40) still = 0;
|
|
else still += 1;
|
|
lastHeight = Math.max(lastHeight, height);
|
|
if (y + view >= height - 16 && still >= 2) {
|
|
return { height: lastHeight, view, screens: Math.max(1, Math.ceil(lastHeight / view)), complete: true };
|
|
}
|
|
}
|
|
const height = Math.max(document.documentElement?.scrollHeight || 0, document.body?.scrollHeight || 0, lastHeight);
|
|
return { height, view, screens: Math.max(1, Math.ceil(height / view)), complete: false };
|
|
});
|
|
}
|
|
|
|
async function readPage(page) {
|
|
return page.evaluate((cap) => {
|
|
const clean = (value) => String(value || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
|
const skip = 'nav, footer, [role="navigation"], [role="contentinfo"], [aria-label*="cookie" i], [id*="cookie" i], [class*="cookie" i], [class*="consent" i]';
|
|
function visibleText(root) {
|
|
if (!root) return '';
|
|
const clone = root.cloneNode(true);
|
|
for (const node of clone.querySelectorAll('script, style, noscript, ' + skip)) node.remove();
|
|
return clean(clone.innerText || clone.textContent || '');
|
|
}
|
|
const full = visibleText(document.body);
|
|
const text = full.slice(0, cap);
|
|
const headings = [...document.querySelectorAll('h1, h2, h3, h4')]
|
|
.map((node) => clean(node.innerText))
|
|
.filter(Boolean)
|
|
.slice(0, 80);
|
|
const tables = [...document.querySelectorAll('table')].slice(0, 12).map((table) => {
|
|
return [...table.rows].slice(0, 80).map((row) => [...row.cells].map((cell) => clean(cell.innerText).replace(/\s+/g, ' ')).filter(Boolean).join(' | ')).filter(Boolean).join('\n');
|
|
}).filter(Boolean);
|
|
const seen = [];
|
|
for (const node of document.querySelectorAll('img, figure')) {
|
|
if (seen.length >= 60) break;
|
|
const img = node.tagName === 'IMG' ? node : node.querySelector('img');
|
|
const caption = node.tagName === 'FIGURE' ? clean(node.querySelector('figcaption')?.innerText) : '';
|
|
const alt = clean(img?.getAttribute('alt') || img?.getAttribute('title') || '');
|
|
const label = [alt, caption].filter(Boolean).join(' — ');
|
|
if (label.length > 2) seen.push(label.slice(0, 180));
|
|
}
|
|
return { text, headings, tables, seen, chars: full.length, truncated: full.length > cap };
|
|
}, PAGE_TEXT_CAP);
|
|
}
|
|
|
|
async function pageShot(page, index) {
|
|
const dir = '/tmp/jarvis-browser';
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
const file = path.join(dir, 'view-' + Date.now() + '-' + index + '.jpg');
|
|
await page.screenshot({ path: file, type: 'jpeg', quality: 55, fullPage: false });
|
|
return { path: file, mime: 'image/jpeg' };
|
|
}
|
|
|
|
async function pageShots(page, offsets) {
|
|
const shots = [];
|
|
const stops = offsets.length ? offsets : [0];
|
|
for (let i = 0; i < stops.length; i++) {
|
|
await page.evaluate((top) => window.scrollTo(0, top), stops[i]).catch(() => {});
|
|
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
try {
|
|
shots.push(await pageShot(page, i + 1));
|
|
} catch {}
|
|
}
|
|
await page.evaluate(() => window.scrollTo(0, 0)).catch(() => {});
|
|
return shots;
|
|
}
|
|
|
|
async function snapshot(page) {
|
|
const walked = await walkPage(page).catch(() => ({ height: 0, view: 800, screens: 1, complete: true }));
|
|
const reading = await readPage(page).catch(() => ({ text: '', headings: [], tables: [], seen: [] }));
|
|
const title = await page.title().catch(() => '');
|
|
const refs = await page.evaluate(() => {
|
|
const skip = 'nav, footer, [role="navigation"], [role="contentinfo"], [aria-label*="cookie" i], [id*="cookie" i], [class*="cookie" i]';
|
|
const nodes = [...document.querySelectorAll('a, button, input, textarea, select, [role="button"], [role="link"], [contenteditable="true"]')];
|
|
const content = nodes.filter((el) => !el.closest(skip));
|
|
const chrome = nodes.filter((el) => el.closest(skip));
|
|
const items = [];
|
|
let i = 1;
|
|
for (const el of content.concat(chrome)) {
|
|
if (items.length >= 48) break;
|
|
const name = (el.innerText || el.value || el.getAttribute('aria-label') || el.getAttribute('placeholder') || el.getAttribute('name') || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
if (!name && el.tagName !== 'INPUT' && el.tagName !== 'TEXTAREA' && el.tagName !== 'SELECT') continue;
|
|
el.setAttribute('data-jarvis-ref', String(i));
|
|
items.push({ ref: String(i), role: (el.getAttribute('role') || el.tagName.toLowerCase()), name });
|
|
i += 1;
|
|
}
|
|
return items;
|
|
});
|
|
const screens = walked.screens || 1;
|
|
const coverage = walked.complete
|
|
? `full page, ${screens} screen${screens === 1 ? '' : 's'}`
|
|
: `walked ${screens} screens; an infinite scroll may continue below`;
|
|
const result = {
|
|
url: page.url(),
|
|
title,
|
|
refs,
|
|
text: reading.text || '',
|
|
headings: reading.headings || [],
|
|
tables: reading.tables || [],
|
|
seen: reading.seen || [],
|
|
coverage: reading.truncated ? coverage + '; page text cut at the length cap' : coverage,
|
|
};
|
|
result.images = await pageShots(page, pageSliceOffsets(walked.height, walked.view, PAGE_SHOTS));
|
|
if (challengeText(title, result.text)) {
|
|
result.challenge = true;
|
|
result.next_action = 'Complete the prompt in the Jarvis browser window, then snapshot again.';
|
|
}
|
|
return result;
|
|
}
|
|
|
|
async function locate(page, input) {
|
|
const ref = input.ref != null ? String(input.ref) : '';
|
|
if (ref) {
|
|
const handle = page.locator(`[data-jarvis-ref="${ref}"]`).first();
|
|
if (await handle.count()) return handle;
|
|
}
|
|
const selector = input.selector != null ? String(input.selector) : '';
|
|
if (selector) return page.locator(selector).first();
|
|
const text = input.text != null && !input.submit ? String(input.text) : '';
|
|
if (text && input.action === 'click') return page.getByText(text, { exact: false }).first();
|
|
return null;
|
|
}
|
|
|
|
let context;
|
|
let page;
|
|
let browserApp;
|
|
let relaunching = false;
|
|
let shuttingDown = false;
|
|
let relaunchCount = 0;
|
|
|
|
async function launchBrowser() {
|
|
const root = stateRoot();
|
|
const profile = process.env.JARVIS_BROWSER_PROFILE || path.join(root, 'profile');
|
|
const browsers = process.env.PLAYWRIGHT_BROWSERS_PATH || path.join(root, 'ms-playwright');
|
|
process.env.PLAYWRIGHT_BROWSERS_PATH = browsers;
|
|
fs.mkdirSync(profile, { recursive: true });
|
|
fs.mkdirSync(browsers, { recursive: true });
|
|
const { chromium } = await loadPlaywright();
|
|
const canShow = Boolean(process.env.DISPLAY || process.env.WAYLAND_DISPLAY);
|
|
const headless = process.env.JARVIS_BROWSER_HEADLESS === '1' || !canShow;
|
|
const extensions = extensionLaunch(installedExtensionDirs());
|
|
const next = await chromium.launchPersistentContext(profile, {
|
|
headless,
|
|
viewport: { width: 1280, height: 800 },
|
|
args: ['--disable-blink-features=AutomationControlled', '--homepage=' + SEARCH_HOME, ...extensions.args],
|
|
...(extensions.ignoreDefaultArgs ? { ignoreDefaultArgs: extensions.ignoreDefaultArgs } : {}),
|
|
});
|
|
context = next;
|
|
browserApp = typeof next.browser === 'function' ? next.browser() : null;
|
|
const reopen = () => {
|
|
if (shuttingDown || relaunching || relaunchCount >= 3) return;
|
|
relaunching = true;
|
|
relaunchCount += 1;
|
|
context = null;
|
|
page = null;
|
|
setTimeout(() => {
|
|
launchBrowser().catch(() => {}).finally(() => { relaunching = false; });
|
|
}, 400);
|
|
};
|
|
next.on('close', reopen);
|
|
if (browserApp) browserApp.on('disconnected', reopen);
|
|
page = next.pages()[0] || await next.newPage();
|
|
if (isBlankStart(page.url())) {
|
|
await page.goto(SEARCH_HOME, { waitUntil: 'domcontentloaded', timeout: 20000 }).catch(() => {});
|
|
}
|
|
return { headless, extensions: extensions.extensions };
|
|
}
|
|
|
|
async function currentPage() {
|
|
if (context && page && !page.isClosed()) return page;
|
|
if (!context && !relaunching) await launchBrowser().catch(() => {});
|
|
if (page && !page.isClosed()) return page;
|
|
if (!context) throw new Error('Jarvis browser window is restarting');
|
|
page = context.pages().find((item) => !item.isClosed()) || await context.newPage();
|
|
return page;
|
|
}
|
|
|
|
async function gotoUrl(target, timeoutMs) {
|
|
const url = assertPublicHttpUrl(target);
|
|
const tab = await currentPage();
|
|
const response = await tab.goto(url, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
|
await tab.waitForLoadState('networkidle', { timeout: Math.min(8000, timeoutMs) }).catch(() => {});
|
|
const challenged = await waitOutChallenge(tab, Math.min(CHALLENGE_MS, timeoutMs));
|
|
return { tab, url: tab.url(), status: response ? response.status() : 0, challenged };
|
|
}
|
|
|
|
async function fetchPage(input, timeoutMs) {
|
|
const opened = await gotoUrl(input.url, timeoutMs);
|
|
const tab = opened.tab;
|
|
const url = opened.url;
|
|
const status = opened.status;
|
|
const challenged = opened.challenged;
|
|
const title = await tab.title().catch(() => '');
|
|
const text = await tab.locator('body').innerText().catch(() => '');
|
|
let html = '';
|
|
try { html = await tab.content(); } catch { html = ''; }
|
|
if (html.length > 2 * 1024 * 1024) html = html.slice(0, 2 * 1024 * 1024);
|
|
const result = {
|
|
url,
|
|
title,
|
|
status,
|
|
html,
|
|
text: String(text || '').slice(0, 30_000),
|
|
via: 'browser',
|
|
};
|
|
if (challenged) {
|
|
result.challenge = true;
|
|
result.next_action = 'Complete the prompt in the Jarvis browser window, then call the tool again.';
|
|
}
|
|
if (status >= 400 && !challenged) result.error = `HTTP ${status}`;
|
|
return result;
|
|
}
|
|
|
|
async function handle(message) {
|
|
const action = String(message.action || '').trim();
|
|
const timeoutMs = Number(message.timeoutMs) > 0 ? Number(message.timeoutMs) : (action === 'search' ? SEARCH_BUDGET_MS : FETCH_BUDGET_MS);
|
|
const tab = await currentPage();
|
|
if (action === 'search') {
|
|
const engine = String(message.engine || 'duckduckgo').trim() || 'duckduckgo';
|
|
const build = SEARCH_URLS[engine];
|
|
if (!build) throw new Error(`unknown engine ${engine}`);
|
|
const query = String(message.query || '').trim();
|
|
if (!query) throw new Error('query required');
|
|
const { tab: searchTab, url, challenged } = await gotoUrl(build(query), timeoutMs);
|
|
if (challenged) {
|
|
return {
|
|
error: 'Provider requires a browser security challenge',
|
|
code: 'bot_challenge',
|
|
challenge: true,
|
|
url,
|
|
next_action: 'Complete the prompt in the Jarvis browser window, then call the tool again.',
|
|
};
|
|
}
|
|
const hits = await extractHits(searchTab, message.limit);
|
|
return hits.map((hit) => ({ ...hit, source: engine }));
|
|
}
|
|
if (action === 'fetch' || action === 'navigate') {
|
|
const result = await fetchPage(message, timeoutMs);
|
|
if (action === 'navigate') {
|
|
const shot = await snapshot(await currentPage());
|
|
return { ...shot, status: result.status, challenge: result.challenge, next_action: result.next_action, error: result.error };
|
|
}
|
|
return result;
|
|
}
|
|
if (action === 'snapshot') return snapshot(tab);
|
|
if (action === 'click') {
|
|
await snapshot(tab);
|
|
const target = await locate(tab, message);
|
|
if (!target) throw new Error('click target not found');
|
|
await target.click({ timeout: Math.min(8000, timeoutMs) });
|
|
await tab.waitForLoadState('domcontentloaded', { timeout: 8000 }).catch(() => {});
|
|
return snapshot(tab);
|
|
}
|
|
if (action === 'type') {
|
|
const value = String(message.text || '');
|
|
await snapshot(tab);
|
|
const target = await locate(tab, { ...message, action: 'type' });
|
|
if (target) {
|
|
await target.click({ timeout: 4000 }).catch(() => {});
|
|
await target.fill(value, { timeout: Math.min(8000, timeoutMs) }).catch(async () => {
|
|
await tab.keyboard.type(value, { delay: 20 });
|
|
});
|
|
} else {
|
|
await tab.keyboard.type(value, { delay: 20 });
|
|
}
|
|
if (message.submit) await tab.keyboard.press('Enter');
|
|
return snapshot(tab);
|
|
}
|
|
if (action === 'press') {
|
|
const key = String(message.key || message.combo || '').trim();
|
|
if (!key) throw new Error('key required');
|
|
await tab.keyboard.press(key);
|
|
return snapshot(tab);
|
|
}
|
|
if (action === 'scroll') {
|
|
await tab.mouse.wheel(Number(message.dx) || 0, Number(message.dy) || 600);
|
|
return snapshot(tab);
|
|
}
|
|
if (action === 'wait') {
|
|
const ms = Math.min(20_000, Math.max(0, Number(message.ms) || 1000));
|
|
await tab.waitForTimeout(ms);
|
|
if (message.text) await tab.getByText(String(message.text), { exact: false }).first().waitFor({ timeout: timeoutMs }).catch(() => {});
|
|
return snapshot(tab);
|
|
}
|
|
throw new Error(`unsupported browser action ${action}`);
|
|
}
|
|
|
|
async function main() {
|
|
const opened = await launchBrowser();
|
|
process.stdout.write(`${JSON.stringify({ type: 'ready', headless: opened.headless, extensions: opened.extensions, homepage: SEARCH_HOME })}\n`);
|
|
const rl = readline.createInterface({ input: process.stdin });
|
|
rl.on('close', () => { shuttingDown = true; });
|
|
for await (const line of rl) {
|
|
if (!line.trim()) continue;
|
|
let message;
|
|
try { message = JSON.parse(line); } catch { continue; }
|
|
const id = message.id;
|
|
try {
|
|
const result = await handle(message);
|
|
relaunchCount = 0;
|
|
reply(id, { ok: true, result });
|
|
} catch (error) {
|
|
fail(id, error);
|
|
}
|
|
}
|
|
shuttingDown = true;
|
|
}
|
|
|
|
main().catch((error) => {
|
|
process.stdout.write(`${JSON.stringify({ type: 'error', reason: String(error && error.message || error) })}\n`);
|
|
process.exit(1);
|
|
});
|