Files
ArchiveBox/archivebox/plugins/chrome/on_Crawl__20_chrome_launch.bg.js

501 lines
16 KiB
JavaScript

#!/usr/bin/env node
/**
* Launch a shared Chrome browser session for the entire crawl.
*
* This runs once per crawl and keeps Chrome alive for all snapshots to share.
* Each snapshot creates its own tab via on_Snapshot__20_chrome_tab.bg.js.
*
* Usage: on_Crawl__20_chrome_launch.bg.js --crawl-id=<uuid> --source-url=<url>
* Output: Creates chrome/ directory under crawl output dir with:
* - cdp_url.txt: WebSocket URL for CDP connection
* - pid.txt: Chrome process ID (for cleanup)
* - port.txt: Debug port number
* - extensions.json: Loaded extensions metadata
*
* Environment variables:
* CHROME_BINARY: Path to Chrome/Chromium binary
* CHROME_RESOLUTION: Page resolution (default: 1440,2000)
* CHROME_HEADLESS: Run in headless mode (default: true)
* CHROME_CHECK_SSL_VALIDITY: Whether to check SSL certificates (default: true)
* CHROME_EXTENSIONS_DIR: Directory containing Chrome extensions
*/
const fs = require('fs');
const path = require('path');
const { spawn } = require('child_process');
const http = require('http');
// Extractor metadata
const PLUGIN_NAME = 'chrome_launch';
const OUTPUT_DIR = 'chrome';
// Helpers for PID file creation
function writePidWithMtime(filePath, pid, startTimeSeconds) {
fs.writeFileSync(filePath, String(pid));
const startTimeMs = startTimeSeconds * 1000;
fs.utimesSync(filePath, new Date(startTimeMs), new Date(startTimeMs));
}
function writeCmdScript(filePath, binary, args) {
const escape = arg => (arg.includes(' ') || arg.includes('"') || arg.includes('$'))
? `"${arg.replace(/"/g, '\\"')}"` : arg;
fs.writeFileSync(filePath, `#!/bin/bash\n${binary} ${args.map(escape).join(' ')}\n`);
fs.chmodSync(filePath, 0o755);
}
// Global state for cleanup
let chromePid = null;
// Parse command line arguments
function parseArgs() {
const args = {};
process.argv.slice(2).forEach(arg => {
if (arg.startsWith('--')) {
const [key, ...valueParts] = arg.slice(2).split('=');
args[key.replace(/-/g, '_')] = valueParts.join('=') || true;
}
});
return args;
}
// Get environment variable with default
function getEnv(name, defaultValue = '') {
return (process.env[name] || defaultValue).trim();
}
function getEnvBool(name, defaultValue = false) {
const val = getEnv(name, '').toLowerCase();
if (['true', '1', 'yes', 'on'].includes(val)) return true;
if (['false', '0', 'no', 'off'].includes(val)) return false;
return defaultValue;
}
// Cleanup handler for SIGTERM - kill Chrome and all child processes
async function cleanup() {
if (!chromePid) {
process.exit(0);
return;
}
console.log(`[*] Killing Chrome process tree (PID ${chromePid})...`);
try {
// Try to kill the entire process group
process.kill(-chromePid, 'SIGTERM');
} catch (e) {
// Fall back to killing just the process
try {
process.kill(chromePid, 'SIGTERM');
} catch (e2) {
// Already dead
}
}
// Wait 2 seconds for graceful shutdown
await new Promise(resolve => setTimeout(resolve, 2000));
// Force kill with SIGKILL
try {
process.kill(-chromePid, 'SIGKILL');
} catch (e) {
try {
process.kill(chromePid, 'SIGKILL');
} catch (e2) {
// Already dead
}
}
console.log('[*] Chrome process tree killed');
// Delete PID files to prevent PID reuse issues
try {
fs.unlinkSync(path.join(OUTPUT_DIR, 'chrome.pid'));
} catch (e) {}
try {
fs.unlinkSync(path.join(OUTPUT_DIR, 'hook.pid'));
} catch (e) {}
process.exit(0);
}
// Register signal handlers
process.on('SIGTERM', cleanup);
process.on('SIGINT', cleanup);
// Find Chrome binary
function findChrome() {
const chromeBinary = getEnv('CHROME_BINARY');
if (chromeBinary && fs.existsSync(chromeBinary)) {
return chromeBinary;
}
const candidates = [
// Linux
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
// macOS
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
return null;
}
// Parse resolution string
function parseResolution(resolution) {
const [width, height] = resolution.split(',').map(x => parseInt(x.trim(), 10));
return { width: width || 1440, height: height || 2000 };
}
// Find a free port
function findFreePort() {
return new Promise((resolve, reject) => {
const server = require('net').createServer();
server.unref();
server.on('error', reject);
server.listen(0, () => {
const port = server.address().port;
server.close(() => resolve(port));
});
});
}
// Wait for Chrome's DevTools port to be ready
function waitForDebugPort(port, timeout = 30000) {
const startTime = Date.now();
return new Promise((resolve, reject) => {
const tryConnect = () => {
if (Date.now() - startTime > timeout) {
reject(new Error(`Timeout waiting for Chrome debug port ${port}`));
return;
}
const req = http.get(`http://127.0.0.1:${port}/json/version`, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const info = JSON.parse(data);
resolve(info);
} catch (e) {
setTimeout(tryConnect, 100);
}
});
});
req.on('error', () => {
setTimeout(tryConnect, 100);
});
req.setTimeout(1000, () => {
req.destroy();
setTimeout(tryConnect, 100);
});
};
tryConnect();
});
}
// Kill zombie Chrome processes from stale crawls
function killZombieChrome() {
const dataDir = getEnv('DATA_DIR', '.');
const crawlsDir = path.join(dataDir, 'crawls');
const now = Date.now();
const fiveMinutesAgo = now - 300000;
let killed = 0;
console.error('[*] Checking for zombie Chrome processes...');
if (!fs.existsSync(crawlsDir)) {
console.error('[+] No crawls directory found');
return;
}
try {
// Only scan data/crawls/*/chrome/*.pid - no recursion into archive dirs
const crawls = fs.readdirSync(crawlsDir, { withFileTypes: true });
for (const crawl of crawls) {
if (!crawl.isDirectory()) continue;
const crawlDir = path.join(crawlsDir, crawl.name);
const chromeDir = path.join(crawlDir, 'chrome');
if (!fs.existsSync(chromeDir)) continue;
// Check if crawl was modified recently (still active)
try {
const crawlStats = fs.statSync(crawlDir);
if (crawlStats.mtimeMs > fiveMinutesAgo) {
continue; // Crawl modified recently, likely still active
}
} catch (e) {
continue;
}
// Crawl is stale (> 5 minutes since modification), check for PIDs
try {
const pidFiles = fs.readdirSync(chromeDir).filter(f => f.endsWith('.pid'));
for (const pidFileName of pidFiles) {
const pidFile = path.join(chromeDir, pidFileName);
try {
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
if (isNaN(pid) || pid <= 0) continue;
// Check if process exists (simple check, Python will validate properly)
try {
process.kill(pid, 0);
} catch (e) {
// Process dead, remove stale PID file
try { fs.unlinkSync(pidFile); } catch (e) {}
continue;
}
// Process alive and crawl is stale - zombie!
console.error(`[!] Found zombie (PID ${pid}) from stale crawl ${crawl.name}`);
try {
// Kill process group
try {
process.kill(-pid, 'SIGKILL');
} catch (e) {
process.kill(pid, 'SIGKILL');
}
killed++;
console.error(`[+] Killed zombie (PID ${pid})`);
try { fs.unlinkSync(pidFile); } catch (e) {}
} catch (e) {
console.error(`[!] Failed to kill PID ${pid}: ${e.message}`);
}
} catch (e) {
// Skip invalid PID files
}
}
} catch (e) {
// Skip if can't read chrome dir
}
}
} catch (e) {
console.error(`[!] Error scanning crawls: ${e.message}`);
}
if (killed > 0) {
console.error(`[+] Killed ${killed} zombie process(es)`);
} else {
console.error('[+] No zombies found');
}
}
async function launchChrome(binary) {
// First, kill any zombie Chrome from crashed crawls
killZombieChrome();
const resolution = getEnv('CHROME_RESOLUTION') || getEnv('RESOLUTION', '1440,2000');
const checkSsl = getEnvBool('CHROME_CHECK_SSL_VALIDITY', getEnvBool('CHECK_SSL_VALIDITY', true));
const headless = getEnvBool('CHROME_HEADLESS', true);
const { width, height } = parseResolution(resolution);
// Create output directory
if (!fs.existsSync(OUTPUT_DIR)) {
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
}
// Find a free port for Chrome DevTools
const debugPort = await findFreePort();
console.error(`[*] Using debug port: ${debugPort}`);
// Load any installed extensions
const extensionUtils = require('./chrome_extension_utils.js');
const extensionsDir = getEnv('CHROME_EXTENSIONS_DIR') ||
path.join(getEnv('DATA_DIR', '.'), 'personas', getEnv('ACTIVE_PERSONA', 'Default'), 'chrome_extensions');
const installedExtensions = [];
if (fs.existsSync(extensionsDir)) {
const files = fs.readdirSync(extensionsDir);
for (const file of files) {
if (file.endsWith('.extension.json')) {
try {
const extPath = path.join(extensionsDir, file);
const extData = JSON.parse(fs.readFileSync(extPath, 'utf-8'));
if (extData.unpacked_path && fs.existsSync(extData.unpacked_path)) {
installedExtensions.push(extData);
console.error(`[*] Loading extension: ${extData.name || file}`);
}
} catch (e) {
// Skip invalid cache files
console.warn(`[!] Skipping invalid extension cache: ${file}`);
}
}
}
}
// Get extension launch arguments
const extensionArgs = extensionUtils.getExtensionLaunchArgs(installedExtensions);
if (extensionArgs.length > 0) {
console.error(`[+] Loaded ${installedExtensions.length} extension(s)`);
// Write extensions metadata for config hooks to use
fs.writeFileSync(
path.join(OUTPUT_DIR, 'extensions.json'),
JSON.stringify(installedExtensions, null, 2)
);
}
// Build Chrome arguments
const chromeArgs = [
`--remote-debugging-port=${debugPort}`,
'--remote-debugging-address=127.0.0.1',
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-sync',
'--no-first-run',
'--no-default-browser-check',
'--disable-default-apps',
'--disable-infobars',
'--disable-blink-features=AutomationControlled',
'--disable-component-update',
'--disable-domain-reliability',
'--disable-breakpad',
'--disable-background-networking',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-renderer-backgrounding',
'--disable-ipc-flooding-protection',
'--password-store=basic',
'--use-mock-keychain',
'--font-render-hinting=none',
'--force-color-profile=srgb',
`--window-size=${width},${height}`,
...extensionArgs, // Load extensions
...(headless ? ['--headless=new'] : []),
...(checkSsl ? [] : ['--ignore-certificate-errors']),
'about:blank', // Start with blank page
];
// Launch Chrome as a detached process group leader
// This allows us to kill Chrome and all its child processes as a group
const chromeProcess = spawn(binary, chromeArgs, {
detached: true,
stdio: ['ignore', 'ignore', 'ignore'],
});
chromeProcess.unref(); // Don't keep Node.js process running
chromePid = chromeProcess.pid;
const chromeStartTime = Date.now() / 1000; // Unix epoch seconds
console.error(`[*] Launched Chrome (PID: ${chromePid}), waiting for debug port...`);
// Write Chrome PID with mtime set to start time for validation
writePidWithMtime(path.join(OUTPUT_DIR, 'chrome.pid'), chromePid, chromeStartTime);
// Write command script for validation
writeCmdScript(path.join(OUTPUT_DIR, 'cmd.sh'), binary, chromeArgs);
fs.writeFileSync(path.join(OUTPUT_DIR, 'port.txt'), String(debugPort));
// Write hook's own PID with mtime for validation
const hookStartTime = Date.now() / 1000;
writePidWithMtime(path.join(OUTPUT_DIR, 'hook.pid'), process.pid, hookStartTime);
try {
// Wait for Chrome to be ready
const versionInfo = await waitForDebugPort(debugPort, 30000);
console.error(`[+] Chrome ready: ${versionInfo.Browser}`);
// Build WebSocket URL
const wsUrl = versionInfo.webSocketDebuggerUrl;
fs.writeFileSync(path.join(OUTPUT_DIR, 'cdp_url.txt'), wsUrl);
return { success: true, cdpUrl: wsUrl, pid: chromePid, port: debugPort };
} catch (e) {
// Kill Chrome if setup failed
try {
process.kill(chromePid, 'SIGTERM');
} catch (killErr) {
// Ignore
}
return { success: false, error: `${e.name}: ${e.message}` };
}
}
async function main() {
const args = parseArgs();
const crawlId = args.crawl_id;
const startTs = new Date();
let status = 'failed';
let output = null;
let error = '';
let version = '';
try {
const binary = findChrome();
if (!binary) {
console.error('ERROR: Chrome/Chromium binary not found');
console.error('DEPENDENCY_NEEDED=chrome');
console.error('BIN_PROVIDERS=puppeteer,env,playwright,apt,brew');
console.error('INSTALL_HINT=npx @puppeteer/browsers install chrome@stable');
process.exit(1);
}
// Get Chrome version
try {
const { execSync } = require('child_process');
version = execSync(`"${binary}" --version`, { encoding: 'utf8', timeout: 5000 }).trim().slice(0, 64);
} catch (e) {
version = '';
}
const result = await launchChrome(binary);
if (result.success) {
status = 'succeeded';
output = OUTPUT_DIR;
console.error(`[+] Chrome session started for crawl ${crawlId}`);
console.error(`[+] CDP URL: ${result.cdpUrl}`);
console.error(`[+] PID: ${result.pid}`);
} else {
status = 'failed';
error = result.error;
}
} catch (e) {
error = `${e.name}: ${e.message}`;
status = 'failed';
}
const endTs = new Date();
const duration = (endTs - startTs) / 1000;
if (error) {
console.error(`ERROR: ${error}`);
process.exit(1);
}
// Background hook - stay running to handle cleanup on SIGTERM
console.log('[*] Chrome launch hook staying alive to handle cleanup...');
// Keep process alive by setting an interval (won't actually do anything)
// This allows us to receive SIGTERM when crawl ends
setInterval(() => {}, 1000000);
}
main().catch(e => {
console.error(`Fatal error: ${e.message}`);
process.exit(1);
});