Files
2026-04-12 01:06:31 +07:00

375 lines
9.9 KiB
JavaScript

/**
* Shared browser utilities for Chrome DevTools scripts
* Supports persistent browser sessions via WebSocket endpoint file
*/
import puppeteer from 'puppeteer';
import debug from 'debug';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const log = debug('chrome-devtools:browser');
// Session file stores WebSocket endpoint for browser reuse across processes
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const SESSION_FILE = path.join(__dirname, '..', '.browser-session.json');
const AUTH_SESSION_FILE = path.join(__dirname, '..', '.auth-session.json');
let browserInstance = null;
let pageInstance = null;
/**
* Resolve headless mode based on explicit value or OS auto-detection.
* - Explicit 'true'/'false' or boolean always wins
* - CI environments (CI, GITHUB_ACTIONS, GITLAB_CI, JENKINS_URL) → headless
* - Linux → headless (servers/WSL typically have no display)
* - macOS/Windows → headed for better debugging
* @param {string|boolean|undefined} value - CLI arg value or boolean
* @returns {boolean} - true for headless, false for headed
*/
export function resolveHeadless(value) {
if (value === false || value === 'false') return false;
if (value === true || value === 'true') return true;
// Auto-detect: CI → headless
if (process.env.CI || process.env.GITHUB_ACTIONS || process.env.GITLAB_CI || process.env.JENKINS_URL) {
log('Auto-detected CI environment → headless');
return true;
}
// Linux → headless (includes WSL, remote servers)
if (process.platform === 'linux') {
log('Auto-detected Linux → headless');
return true;
}
// macOS/Windows → headed for debugging
log(`Auto-detected ${process.platform} → headed`);
return false;
}
/**
* Get default Chrome profile path based on OS
* @returns {string} - Path to Chrome's default user data directory
*/
function getDefaultChromeProfilePath() {
switch (process.platform) {
case 'darwin':
return `${process.env.HOME}/Library/Application Support/Google/Chrome`;
case 'win32':
return `${process.env.LOCALAPPDATA}/Google/Chrome/User Data`;
default: // Linux and others
return `${process.env.HOME}/.config/google-chrome`;
}
}
/**
* Read session info from file
*/
function readSession() {
try {
if (fs.existsSync(SESSION_FILE)) {
const data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'));
// Check if session is not too old (max 1 hour)
if (Date.now() - data.timestamp < 3600000) {
return data;
}
}
} catch (e) {
log('Failed to read session:', e.message);
}
return null;
}
/**
* Write session info to file
*/
function writeSession(wsEndpoint) {
try {
fs.writeFileSync(SESSION_FILE, JSON.stringify({
wsEndpoint,
timestamp: Date.now()
}));
} catch (e) {
log('Failed to write session:', e.message);
}
}
/**
* Clear session file
*/
function clearSession() {
try {
if (fs.existsSync(SESSION_FILE)) {
fs.unlinkSync(SESSION_FILE);
}
} catch (e) {
log('Failed to clear session:', e.message);
}
}
/**
* Save auth session (cookies, storage) for persistence
* @param {Object} authData - Auth data to save
*/
export function saveAuthSession(authData) {
try {
const existing = readAuthSession() || {};
const merged = { ...existing, ...authData, timestamp: Date.now() };
fs.writeFileSync(AUTH_SESSION_FILE, JSON.stringify(merged, null, 2));
log('Auth session saved');
} catch (e) {
log('Failed to save auth session:', e.message);
}
}
/**
* Read auth session from file
* @returns {Object|null} - Auth session data or null
*/
export function readAuthSession() {
try {
if (fs.existsSync(AUTH_SESSION_FILE)) {
const data = JSON.parse(fs.readFileSync(AUTH_SESSION_FILE, 'utf8'));
// Auth sessions valid for 24 hours
if (Date.now() - data.timestamp < 86400000) {
return data;
}
}
} catch (e) {
log('Failed to read auth session:', e.message);
}
return null;
}
/**
* Clear auth session file
*/
export function clearAuthSession() {
try {
if (fs.existsSync(AUTH_SESSION_FILE)) {
fs.unlinkSync(AUTH_SESSION_FILE);
log('Auth session cleared');
}
} catch (e) {
log('Failed to clear auth session:', e.message);
}
}
/**
* Apply saved auth session to page
* @param {Object} page - Puppeteer page instance
* @param {string} url - Target URL for domain context
*/
export async function applyAuthSession(page, url) {
const authData = readAuthSession();
if (!authData) {
log('No auth session found');
return false;
}
try {
// Apply cookies
if (authData.cookies && authData.cookies.length > 0) {
await page.setCookie(...authData.cookies);
log(`Applied ${authData.cookies.length} cookies`);
}
// Apply localStorage (requires navigation first)
if (authData.localStorage && Object.keys(authData.localStorage).length > 0) {
await page.evaluate((data) => {
Object.entries(data).forEach(([key, value]) => {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
});
}, authData.localStorage);
log('Applied localStorage data');
}
// Apply sessionStorage
if (authData.sessionStorage && Object.keys(authData.sessionStorage).length > 0) {
await page.evaluate((data) => {
Object.entries(data).forEach(([key, value]) => {
sessionStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
});
}, authData.sessionStorage);
log('Applied sessionStorage data');
}
// Apply extra headers
if (authData.headers) {
await page.setExtraHTTPHeaders(authData.headers);
log('Applied HTTP headers');
}
return true;
} catch (e) {
log('Failed to apply auth session:', e.message);
return false;
}
}
/**
* Launch or connect to browser
* If a session file exists with valid wsEndpoint, connects to existing browser
* Otherwise launches new browser and saves wsEndpoint for future connections
*/
export async function getBrowser(options = {}) {
// If we already have a connected browser in this process, reuse it
if (browserInstance && browserInstance.isConnected()) {
log('Reusing existing browser instance from process');
return browserInstance;
}
// Try to connect to existing browser from session file
const session = readSession();
if (session && session.wsEndpoint) {
try {
log('Attempting to connect to existing browser session');
browserInstance = await puppeteer.connect({
browserWSEndpoint: session.wsEndpoint
});
log('Connected to existing browser');
return browserInstance;
} catch (e) {
log('Failed to connect to existing browser:', e.message);
clearSession();
}
}
// Connect via provided wsEndpoint or browserUrl
if (options.wsEndpoint || options.browserUrl) {
log('Connecting to browser via provided endpoint');
browserInstance = await puppeteer.connect({
browserWSEndpoint: options.wsEndpoint,
browserURL: options.browserUrl
});
return browserInstance;
}
// Resolve Chrome profile path
let userDataDir = options.userDataDir || options.profile;
if (options.useDefaultProfile) {
userDataDir = getDefaultChromeProfilePath();
log(`Using default Chrome profile: ${userDataDir}`);
}
// Destructure known properties — only pass Puppeteer-valid options to launch()
const { headless, args: extraArgs, viewport, useDefaultProfile, profile, browserUrl, wsEndpoint: _ws, userDataDir: _udd, ...restOptions } = options;
// Launch new browser
const launchOptions = {
headless: resolveHeadless(headless),
args: [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
...(extraArgs || [])
],
defaultViewport: viewport || {
width: 1920,
height: 1080
},
...(userDataDir && { userDataDir }),
...restOptions
};
log('Launching new browser');
browserInstance = await puppeteer.launch(launchOptions);
// Save wsEndpoint for future connections
const wsEndpoint = browserInstance.wsEndpoint();
writeSession(wsEndpoint);
log('Browser launched, session saved');
return browserInstance;
}
/**
* Get current page or create new one
*/
export async function getPage(browser) {
if (pageInstance && !pageInstance.isClosed()) {
log('Reusing existing page');
return pageInstance;
}
const pages = await browser.pages();
if (pages.length > 0) {
pageInstance = pages[0];
} else {
pageInstance = await browser.newPage();
}
return pageInstance;
}
/**
* Close browser and clear session
*/
export async function closeBrowser() {
if (browserInstance) {
await browserInstance.close();
browserInstance = null;
pageInstance = null;
clearSession();
log('Browser closed, session cleared');
}
}
/**
* Disconnect from browser without closing it
* Use this to keep browser running for future script executions
*/
export async function disconnectBrowser() {
if (browserInstance) {
browserInstance.disconnect();
browserInstance = null;
pageInstance = null;
log('Disconnected from browser (browser still running)');
}
}
/**
* Parse command line arguments
*/
export function parseArgs(argv, options = {}) {
const args = {};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const key = arg.slice(2);
const nextArg = argv[i + 1];
if (nextArg && !nextArg.startsWith('--')) {
args[key] = nextArg;
i++;
} else {
args[key] = true;
}
}
}
return args;
}
/**
* Output JSON result
*/
export function outputJSON(data) {
console.log(JSON.stringify(data, null, 2));
}
/**
* Output error
*/
export function outputError(error) {
console.error(JSON.stringify({
success: false,
error: error.message,
stack: error.stack
}, null, 2));
process.exit(1);
}