This commit is contained in:
2026-04-12 01:06:31 +07:00
commit 10d660cbcb
1066 changed files with 228596 additions and 0 deletions

View File

@@ -0,0 +1,374 @@
/**
* 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);
}

View File

@@ -0,0 +1,178 @@
/**
* Shared selector parsing and validation library
* Supports CSS and XPath selectors with security validation
*/
/**
* Parse and validate selector
* @param {string} selector - CSS or XPath selector
* @returns {{type: 'css'|'xpath', selector: string}}
* @throws {Error} If XPath contains injection patterns
*/
export function parseSelector(selector) {
if (!selector || typeof selector !== 'string') {
throw new Error('Selector must be a non-empty string');
}
// Detect XPath selectors
if (selector.startsWith('/') || selector.startsWith('(//')) {
// XPath injection prevention
validateXPath(selector);
return { type: 'xpath', selector };
}
// CSS selector
return { type: 'css', selector };
}
/**
* Validate XPath selector for security
* @param {string} xpath - XPath expression to validate
* @throws {Error} If XPath contains dangerous patterns
*/
function validateXPath(xpath) {
const dangerous = [
'javascript:',
'<script',
'onerror=',
'onload=',
'onclick=',
'onmouseover=',
'eval(',
'Function(',
'constructor(',
];
const lower = xpath.toLowerCase();
for (const pattern of dangerous) {
if (lower.includes(pattern.toLowerCase())) {
throw new Error(`Potential XPath injection detected: ${pattern}`);
}
}
// Additional validation: check for extremely long selectors (potential DoS)
if (xpath.length > 1000) {
throw new Error('XPath selector too long (max 1000 characters)');
}
}
/**
* Wait for element based on selector type
* @param {Object} page - Puppeteer page instance
* @param {{type: string, selector: string}} parsed - Parsed selector
* @param {Object} options - Wait options (visible, timeout)
* @returns {Promise<void>}
*/
export async function waitForElement(page, parsed, options = {}) {
const defaultOptions = {
visible: true,
timeout: 5000,
...options
};
if (parsed.type === 'xpath') {
// Use locator API for XPath (Puppeteer v24+)
const locator = page.locator(`::-p-xpath(${parsed.selector})`);
// setVisibility and setTimeout are the locator options
await locator
.setVisibility(defaultOptions.visible ? 'visible' : null)
.setTimeout(defaultOptions.timeout)
.wait();
} else {
await page.waitForSelector(parsed.selector, defaultOptions);
}
}
/**
* Click element based on selector type
* @param {Object} page - Puppeteer page instance
* @param {{type: string, selector: string}} parsed - Parsed selector
* @returns {Promise<void>}
*/
export async function clickElement(page, parsed) {
if (parsed.type === 'xpath') {
// Use locator API for XPath (Puppeteer v24+)
const locator = page.locator(`::-p-xpath(${parsed.selector})`);
await locator.click();
} else {
await page.click(parsed.selector);
}
}
/**
* Type into element based on selector type
* @param {Object} page - Puppeteer page instance
* @param {{type: string, selector: string}} parsed - Parsed selector
* @param {string} value - Text to type
* @param {Object} options - Type options (delay, clear)
* @returns {Promise<void>}
*/
export async function typeIntoElement(page, parsed, value, options = {}) {
if (parsed.type === 'xpath') {
// Use locator API for XPath (Puppeteer v24+)
const locator = page.locator(`::-p-xpath(${parsed.selector})`);
// Clear if requested
if (options.clear) {
await locator.fill('');
}
await locator.fill(value);
} else {
// CSS selector
if (options.clear) {
await page.$eval(parsed.selector, el => el.value = '');
}
await page.type(parsed.selector, value, { delay: options.delay || 0 });
}
}
/**
* Get element handle based on selector type
* @param {Object} page - Puppeteer page instance
* @param {{type: string, selector: string}} parsed - Parsed selector
* @returns {Promise<ElementHandle|null>}
*/
export async function getElement(page, parsed) {
if (parsed.type === 'xpath') {
// For XPath, use page.evaluate with XPath evaluation
// This returns the first matching element
const element = await page.evaluateHandle((xpath) => {
const result = document.evaluate(
xpath,
document,
null,
XPathResult.FIRST_ORDERED_NODE_TYPE,
null
);
return result.singleNodeValue;
}, parsed.selector);
// Convert JSHandle to ElementHandle
const elementHandle = element.asElement();
return elementHandle;
} else {
return await page.$(parsed.selector);
}
}
/**
* Get enhanced error message for selector failures
* @param {Error} error - Original error
* @param {string} selector - Selector that failed
* @returns {Error} Enhanced error with troubleshooting tips
*/
export function enhanceError(error, selector) {
if (error.message.includes('waiting for selector') ||
error.message.includes('waiting for XPath') ||
error.message.includes('No node found')) {
error.message += '\n\nTroubleshooting:\n' +
'1. Use snapshot.js to find correct selector: node snapshot.js --url <url>\n' +
'2. Try XPath selector: //button[text()="Click"] or //button[contains(text(),"Click")]\n' +
'3. Check element is visible on page (not display:none or hidden)\n' +
'4. Increase --timeout value: --timeout 10000\n' +
'5. Change wait strategy: --wait-until load or --wait-until domcontentloaded';
}
return error;
}