mirror of
https://github.com/garrytan/gstack.git
synced 2026-05-02 11:45:20 +02:00
feat: browse server inspector endpoints + inspect/style/cleanup/prettyscreenshot CLI
Server endpoints: POST /inspector/pick, GET /inspector, POST /inspector/apply, POST /inspector/reset, GET /inspector/history, GET /inspector/events (SSE). CLI commands: inspect (CDP cascade), style (live CSS mod), cleanup (page clutter removal), prettyscreenshot (clean screenshot pipeline). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@ export const READ_COMMANDS = new Set([
|
||||
'js', 'eval', 'css', 'attrs',
|
||||
'console', 'network', 'cookies', 'storage', 'perf',
|
||||
'dialog', 'is',
|
||||
'inspect',
|
||||
]);
|
||||
|
||||
export const WRITE_COMMANDS = new Set([
|
||||
@@ -22,6 +23,7 @@ export const WRITE_COMMANDS = new Set([
|
||||
'click', 'fill', 'select', 'hover', 'type', 'press', 'scroll', 'wait',
|
||||
'viewport', 'cookie', 'cookie-import', 'cookie-import-browser', 'header', 'useragent',
|
||||
'upload', 'dialog-accept', 'dialog-dismiss',
|
||||
'style', 'cleanup', 'prettyscreenshot',
|
||||
]);
|
||||
|
||||
export const META_COMMANDS = new Set([
|
||||
@@ -115,6 +117,11 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
|
||||
'state': { category: 'Server', description: 'Save/load browser state (cookies + URLs)', usage: 'state save|load <name>' },
|
||||
// Frame
|
||||
'frame': { category: 'Meta', description: 'Switch to iframe context (or main to return)', usage: 'frame <sel|@ref|--name n|--url pattern|main>' },
|
||||
// CSS Inspector
|
||||
'inspect': { category: 'Inspection', description: 'Deep CSS inspection via CDP — full rule cascade, box model, computed styles', usage: 'inspect [selector] [--all] [--history]' },
|
||||
'style': { category: 'Interaction', description: 'Modify CSS property on element (with undo support)', usage: 'style <sel> <prop> <value> | style --undo [N]' },
|
||||
'cleanup': { category: 'Interaction', description: 'Remove page clutter (ads, cookie banners, sticky elements, social widgets)', usage: 'cleanup [--ads] [--cookies] [--sticky] [--social] [--all]' },
|
||||
'prettyscreenshot': { category: 'Visual', description: 'Clean screenshot with optional cleanup, scroll positioning, and element hiding', usage: 'prettyscreenshot [--scroll-to sel|text] [--cleanup] [--hide sel...] [--width px] [path]' },
|
||||
};
|
||||
|
||||
// Load-time validation: descriptions must cover exactly the command sets
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Page, Frame } from 'playwright';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { TEMP_DIR, isPathWithin } from './platform';
|
||||
import { inspectElement, formatInspectorResult, getModificationHistory } from './cdp-inspector';
|
||||
|
||||
/** Detect await keyword, ignoring comments. Accepted risk: await in string literals triggers wrapping (harmless). */
|
||||
function hasAwait(code: string): boolean {
|
||||
@@ -352,6 +353,54 @@ export async function handleReadCommand(
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
case 'inspect': {
|
||||
// Parse flags
|
||||
let includeUA = false;
|
||||
let showHistory = false;
|
||||
let selector: string | undefined;
|
||||
|
||||
for (const arg of args) {
|
||||
if (arg === '--all') {
|
||||
includeUA = true;
|
||||
} else if (arg === '--history') {
|
||||
showHistory = true;
|
||||
} else if (!selector) {
|
||||
selector = arg;
|
||||
}
|
||||
}
|
||||
|
||||
// --history mode: return modification history
|
||||
if (showHistory) {
|
||||
const history = getModificationHistory();
|
||||
if (history.length === 0) return '(no style modifications)';
|
||||
return history.map((m, i) =>
|
||||
`[${i}] ${m.selector} { ${m.property}: ${m.oldValue} → ${m.newValue} } (${m.source}, ${m.method})`
|
||||
).join('\n');
|
||||
}
|
||||
|
||||
// If no selector given, check for stored inspector data
|
||||
if (!selector) {
|
||||
// Access stored inspector data from the server's in-memory state
|
||||
// The server stores this when the extension picks an element via POST /inspector/pick
|
||||
const stored = (bm as any)._inspectorData;
|
||||
const storedTs = (bm as any)._inspectorTimestamp;
|
||||
if (stored) {
|
||||
const stale = storedTs && (Date.now() - storedTs > 60000);
|
||||
let output = formatInspectorResult(stored, { includeUA });
|
||||
if (stale) output = '⚠ Data may be stale (>60s old)\n\n' + output;
|
||||
return output;
|
||||
}
|
||||
throw new Error('Usage: browse inspect [selector] [--all] [--history]\nOr pick an element in the Chrome sidebar first.');
|
||||
}
|
||||
|
||||
// Direct inspection by selector
|
||||
const result = await inspectElement(page, selector, { includeUA });
|
||||
// Store for later retrieval
|
||||
(bm as any)._inspectorData = result;
|
||||
(bm as any)._inspectorTimestamp = Date.now();
|
||||
return formatInspectorResult(result, { includeUA });
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown read command: ${command}`);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { COMMAND_DESCRIPTIONS } from './commands';
|
||||
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
|
||||
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
|
||||
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
|
||||
import { inspectElement, modifyStyle, resetModifications, getModificationHistory, detachSession, type InspectorResult } from './cdp-inspector';
|
||||
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
|
||||
// fail posix_spawn on all executables including /bin/bash)
|
||||
import * as fs from 'fs';
|
||||
@@ -544,6 +545,22 @@ const idleCheckInterval = setInterval(() => {
|
||||
import { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS } from './commands';
|
||||
export { READ_COMMANDS, WRITE_COMMANDS, META_COMMANDS };
|
||||
|
||||
// ─── Inspector State (in-memory) ──────────────────────────────
|
||||
let inspectorData: InspectorResult | null = null;
|
||||
let inspectorTimestamp: number = 0;
|
||||
|
||||
// Inspector SSE subscribers
|
||||
type InspectorSubscriber = (event: any) => void;
|
||||
const inspectorSubscribers = new Set<InspectorSubscriber>();
|
||||
|
||||
function emitInspectorEvent(event: any): void {
|
||||
for (const notify of inspectorSubscribers) {
|
||||
queueMicrotask(() => {
|
||||
try { notify(event); } catch {}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Server ────────────────────────────────────────────────────
|
||||
const browserManager = new BrowserManager();
|
||||
let isShuttingDown = false;
|
||||
@@ -728,6 +745,9 @@ async function shutdown() {
|
||||
isShuttingDown = true;
|
||||
|
||||
console.log('[browse] Shutting down...');
|
||||
// Clean up CDP inspector sessions
|
||||
try { detachSession(); } catch {}
|
||||
inspectorSubscribers.clear();
|
||||
// Stop watch mode if active
|
||||
if (browserManager.isWatching()) browserManager.stopWatch();
|
||||
killAgent();
|
||||
@@ -1127,6 +1147,149 @@ async function start() {
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Inspector endpoints ──────────────────────────────────────
|
||||
|
||||
// POST /inspector/pick — receive element pick from extension, run CDP inspection
|
||||
if (url.pathname === '/inspector/pick' && req.method === 'POST') {
|
||||
const body = await req.json();
|
||||
const { selector, activeTabUrl } = body;
|
||||
if (!selector) {
|
||||
return new Response(JSON.stringify({ error: 'Missing selector' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const page = browserManager.getPage();
|
||||
const result = await inspectElement(page, selector);
|
||||
inspectorData = result;
|
||||
inspectorTimestamp = Date.now();
|
||||
// Also store on browserManager for CLI access
|
||||
(browserManager as any)._inspectorData = result;
|
||||
(browserManager as any)._inspectorTimestamp = inspectorTimestamp;
|
||||
emitInspectorEvent({ type: 'pick', selector, timestamp: inspectorTimestamp });
|
||||
return new Response(JSON.stringify(result), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// GET /inspector — return latest inspector data
|
||||
if (url.pathname === '/inspector' && req.method === 'GET') {
|
||||
if (!inspectorData) {
|
||||
return new Response(JSON.stringify({ data: null }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
const stale = inspectorTimestamp > 0 && (Date.now() - inspectorTimestamp > 60000);
|
||||
return new Response(JSON.stringify({ data: inspectorData, timestamp: inspectorTimestamp, stale }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// POST /inspector/apply — apply a CSS modification
|
||||
if (url.pathname === '/inspector/apply' && req.method === 'POST') {
|
||||
const body = await req.json();
|
||||
const { selector, property, value } = body;
|
||||
if (!selector || !property || value === undefined) {
|
||||
return new Response(JSON.stringify({ error: 'Missing selector, property, or value' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
try {
|
||||
const page = browserManager.getPage();
|
||||
const mod = await modifyStyle(page, selector, property, value);
|
||||
emitInspectorEvent({ type: 'apply', modification: mod, timestamp: Date.now() });
|
||||
return new Response(JSON.stringify(mod), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// POST /inspector/reset — clear all modifications
|
||||
if (url.pathname === '/inspector/reset' && req.method === 'POST') {
|
||||
try {
|
||||
const page = browserManager.getPage();
|
||||
await resetModifications(page);
|
||||
emitInspectorEvent({ type: 'reset', timestamp: Date.now() });
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// GET /inspector/history — return modification list
|
||||
if (url.pathname === '/inspector/history' && req.method === 'GET') {
|
||||
return new Response(JSON.stringify({ history: getModificationHistory() }), {
|
||||
status: 200, headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// GET /inspector/events — SSE for inspector state changes
|
||||
if (url.pathname === '/inspector/events' && req.method === 'GET') {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
// Send current state immediately
|
||||
if (inspectorData) {
|
||||
controller.enqueue(encoder.encode(
|
||||
`event: state\ndata: ${JSON.stringify({ data: inspectorData, timestamp: inspectorTimestamp })}\n\n`
|
||||
));
|
||||
}
|
||||
|
||||
// Subscribe for live events
|
||||
const notify: InspectorSubscriber = (event) => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(
|
||||
`event: inspector\ndata: ${JSON.stringify(event)}\n\n`
|
||||
));
|
||||
} catch {
|
||||
inspectorSubscribers.delete(notify);
|
||||
}
|
||||
};
|
||||
inspectorSubscribers.add(notify);
|
||||
|
||||
// Heartbeat every 15s
|
||||
const heartbeat = setInterval(() => {
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`: heartbeat\n\n`));
|
||||
} catch {
|
||||
clearInterval(heartbeat);
|
||||
inspectorSubscribers.delete(notify);
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
// Cleanup on disconnect
|
||||
req.signal.addEventListener('abort', () => {
|
||||
clearInterval(heartbeat);
|
||||
inspectorSubscribers.delete(notify);
|
||||
try { controller.close(); } catch {}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Command endpoint ──────────────────────────────────────────
|
||||
|
||||
if (url.pathname === '/command' && req.method === 'POST') {
|
||||
resetIdleTimer(); // Only commands reset idle timer
|
||||
const body = await req.json();
|
||||
|
||||
@@ -11,6 +11,49 @@ import { validateNavigationUrl } from './url-validation';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { TEMP_DIR, isPathWithin } from './platform';
|
||||
import { modifyStyle, undoModification, resetModifications, getModificationHistory } from './cdp-inspector';
|
||||
|
||||
// Security: Path validation for screenshot output
|
||||
const SAFE_DIRECTORIES = [TEMP_DIR, process.cwd()];
|
||||
|
||||
function validateOutputPath(filePath: string): void {
|
||||
const resolved = path.resolve(filePath);
|
||||
const isSafe = SAFE_DIRECTORIES.some(dir => isPathWithin(resolved, dir));
|
||||
if (!isSafe) {
|
||||
throw new Error(`Path must be within: ${SAFE_DIRECTORIES.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Common selectors for page clutter removal */
|
||||
const CLEANUP_SELECTORS = {
|
||||
ads: [
|
||||
'ins.adsbygoogle', '[id^="google_ads"]', '[id^="div-gpt-ad"]',
|
||||
'iframe[src*="doubleclick"]', 'iframe[src*="googlesyndication"]',
|
||||
'[class*="ad-banner"]', '[class*="ad-wrapper"]', '[class*="ad-container"]',
|
||||
'[data-ad]', '[data-ad-slot]', '[class*="sponsored"]',
|
||||
'.ad', '.ads', '.advert', '.advertisement',
|
||||
],
|
||||
cookies: [
|
||||
'[class*="cookie-consent"]', '[class*="cookie-banner"]', '[class*="cookie-notice"]',
|
||||
'[id*="cookie-consent"]', '[id*="cookie-banner"]', '[id*="cookie-notice"]',
|
||||
'[class*="consent-banner"]', '[class*="consent-modal"]',
|
||||
'[class*="gdpr"]', '[id*="gdpr"]',
|
||||
'[class*="CookieConsent"]', '[id*="CookieConsent"]',
|
||||
'#onetrust-consent-sdk', '.onetrust-pc-dark-filter',
|
||||
'[class*="cc-banner"]', '[class*="cc-window"]',
|
||||
],
|
||||
sticky: [
|
||||
// Select fixed/sticky positioned elements (except navs and headers at top)
|
||||
// This is handled via JavaScript evaluation, not pure selectors
|
||||
],
|
||||
social: [
|
||||
'[class*="social-share"]', '[class*="share-buttons"]', '[class*="share-bar"]',
|
||||
'[class*="social-widget"]', '[class*="social-icons"]',
|
||||
'iframe[src*="facebook.com/plugins"]', 'iframe[src*="platform.twitter"]',
|
||||
'[class*="fb-like"]', '[class*="tweet-button"]',
|
||||
'[class*="addthis"]', '[class*="sharethis"]',
|
||||
],
|
||||
};
|
||||
|
||||
export async function handleWriteCommand(
|
||||
command: string,
|
||||
@@ -358,6 +401,253 @@ export async function handleWriteCommand(
|
||||
return `Cookie picker opened at ${pickerUrl}\nDetected browsers: ${browsers.map(b => b.name).join(', ')}\nSelect domains to import, then close the picker when done.`;
|
||||
}
|
||||
|
||||
case 'style': {
|
||||
// style --undo [N] → revert modification
|
||||
if (args[0] === '--undo') {
|
||||
const idx = args[1] ? parseInt(args[1], 10) : undefined;
|
||||
await undoModification(page, idx);
|
||||
return idx !== undefined ? `Reverted modification #${idx}` : 'Reverted last modification';
|
||||
}
|
||||
|
||||
// style <selector> <property> <value>
|
||||
const [selector, property, ...valueParts] = args;
|
||||
const value = valueParts.join(' ');
|
||||
if (!selector || !property || !value) {
|
||||
throw new Error('Usage: browse style <sel> <prop> <value> | style --undo [N]');
|
||||
}
|
||||
|
||||
// Validate CSS property name
|
||||
if (!/^[a-zA-Z-]+$/.test(property)) {
|
||||
throw new Error(`Invalid CSS property name: ${property}. Only letters and hyphens allowed.`);
|
||||
}
|
||||
|
||||
const mod = await modifyStyle(page, selector, property, value);
|
||||
return `Style modified: ${selector} { ${property}: ${mod.oldValue || '(none)'} → ${value} } (${mod.method})`;
|
||||
}
|
||||
|
||||
case 'cleanup': {
|
||||
// Parse flags
|
||||
let doAds = false, doCookies = false, doSticky = false, doSocial = false;
|
||||
let doAll = false;
|
||||
|
||||
if (args.length === 0) {
|
||||
throw new Error('Usage: browse cleanup [--ads] [--cookies] [--sticky] [--social] [--all]');
|
||||
}
|
||||
|
||||
for (const arg of args) {
|
||||
switch (arg) {
|
||||
case '--ads': doAds = true; break;
|
||||
case '--cookies': doCookies = true; break;
|
||||
case '--sticky': doSticky = true; break;
|
||||
case '--social': doSocial = true; break;
|
||||
case '--all': doAll = true; break;
|
||||
default:
|
||||
throw new Error(`Unknown cleanup flag: ${arg}. Use: --ads, --cookies, --sticky, --social, --all`);
|
||||
}
|
||||
}
|
||||
|
||||
if (doAll) {
|
||||
doAds = doCookies = doSticky = doSocial = true;
|
||||
}
|
||||
|
||||
const removed: string[] = [];
|
||||
|
||||
// Build selector list for categories to clean
|
||||
const selectors: string[] = [];
|
||||
if (doAds) selectors.push(...CLEANUP_SELECTORS.ads);
|
||||
if (doCookies) selectors.push(...CLEANUP_SELECTORS.cookies);
|
||||
if (doSocial) selectors.push(...CLEANUP_SELECTORS.social);
|
||||
|
||||
if (selectors.length > 0) {
|
||||
const count = await page.evaluate((sels: string[]) => {
|
||||
let removed = 0;
|
||||
for (const sel of sels) {
|
||||
try {
|
||||
const els = document.querySelectorAll(sel);
|
||||
els.forEach(el => {
|
||||
(el as HTMLElement).style.display = 'none';
|
||||
removed++;
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
return removed;
|
||||
}, selectors);
|
||||
if (count > 0) {
|
||||
if (doAds) removed.push('ads');
|
||||
if (doCookies) removed.push('cookie banners');
|
||||
if (doSocial) removed.push('social widgets');
|
||||
}
|
||||
}
|
||||
|
||||
// Sticky/fixed elements — handled separately with computed style check
|
||||
if (doSticky) {
|
||||
const stickyCount = await page.evaluate(() => {
|
||||
let removed = 0;
|
||||
const allElements = document.querySelectorAll('*');
|
||||
for (const el of allElements) {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.position === 'fixed' || style.position === 'sticky') {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
// Skip main nav/header elements
|
||||
if (tag === 'nav' || tag === 'header') continue;
|
||||
if (el.getAttribute('role') === 'navigation') continue;
|
||||
// Skip elements at the very top that look like navbars
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top <= 10 && rect.height < 100 && tag !== 'div') continue;
|
||||
(el as HTMLElement).style.display = 'none';
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
});
|
||||
if (stickyCount > 0) removed.push(`${stickyCount} sticky/fixed elements`);
|
||||
}
|
||||
|
||||
if (removed.length === 0) return 'No clutter elements found to remove.';
|
||||
return `Cleaned up: ${removed.join(', ')}`;
|
||||
}
|
||||
|
||||
case 'prettyscreenshot': {
|
||||
// Parse flags
|
||||
let scrollTo: string | undefined;
|
||||
let doCleanup = false;
|
||||
const hideSelectors: string[] = [];
|
||||
let viewportWidth: number | undefined;
|
||||
let outputPath: string | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i] === '--scroll-to' && i + 1 < args.length) {
|
||||
scrollTo = args[++i];
|
||||
} else if (args[i] === '--cleanup') {
|
||||
doCleanup = true;
|
||||
} else if (args[i] === '--hide' && i + 1 < args.length) {
|
||||
// Collect all following non-flag args as selectors to hide
|
||||
i++;
|
||||
while (i < args.length && !args[i].startsWith('--')) {
|
||||
hideSelectors.push(args[i]);
|
||||
i++;
|
||||
}
|
||||
i--; // Back up since the for loop will increment
|
||||
} else if (args[i] === '--width' && i + 1 < args.length) {
|
||||
viewportWidth = parseInt(args[++i], 10);
|
||||
if (isNaN(viewportWidth)) throw new Error('--width must be a number');
|
||||
} else if (!args[i].startsWith('--')) {
|
||||
outputPath = args[i];
|
||||
} else {
|
||||
throw new Error(`Unknown prettyscreenshot flag: ${args[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Default output path
|
||||
if (!outputPath) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
outputPath = `${TEMP_DIR}/browse-pretty-${timestamp}.png`;
|
||||
}
|
||||
validateOutputPath(outputPath);
|
||||
|
||||
const originalViewport = page.viewportSize();
|
||||
|
||||
// Set viewport width if specified
|
||||
if (viewportWidth && originalViewport) {
|
||||
await page.setViewportSize({ width: viewportWidth, height: originalViewport.height });
|
||||
}
|
||||
|
||||
// Run cleanup if requested
|
||||
if (doCleanup) {
|
||||
const allSelectors = [
|
||||
...CLEANUP_SELECTORS.ads,
|
||||
...CLEANUP_SELECTORS.cookies,
|
||||
...CLEANUP_SELECTORS.social,
|
||||
];
|
||||
await page.evaluate((sels: string[]) => {
|
||||
for (const sel of sels) {
|
||||
try {
|
||||
document.querySelectorAll(sel).forEach(el => {
|
||||
(el as HTMLElement).style.display = 'none';
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
// Also hide fixed/sticky (except nav)
|
||||
for (const el of document.querySelectorAll('*')) {
|
||||
const style = getComputedStyle(el);
|
||||
if (style.position === 'fixed' || style.position === 'sticky') {
|
||||
const tag = el.tagName.toLowerCase();
|
||||
if (tag === 'nav' || tag === 'header') continue;
|
||||
if (el.getAttribute('role') === 'navigation') continue;
|
||||
(el as HTMLElement).style.display = 'none';
|
||||
}
|
||||
}
|
||||
}, allSelectors);
|
||||
}
|
||||
|
||||
// Hide specific elements
|
||||
if (hideSelectors.length > 0) {
|
||||
await page.evaluate((sels: string[]) => {
|
||||
for (const sel of sels) {
|
||||
try {
|
||||
document.querySelectorAll(sel).forEach(el => {
|
||||
(el as HTMLElement).style.display = 'none';
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
}, hideSelectors);
|
||||
}
|
||||
|
||||
// Scroll to target
|
||||
if (scrollTo) {
|
||||
// Try as CSS selector first, then as text content
|
||||
const scrolled = await page.evaluate((target: string) => {
|
||||
// Try CSS selector
|
||||
let el = document.querySelector(target);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
return true;
|
||||
}
|
||||
// Try text match
|
||||
const walker = document.createTreeWalker(
|
||||
document.body,
|
||||
NodeFilter.SHOW_TEXT,
|
||||
null,
|
||||
);
|
||||
let node: Node | null;
|
||||
while ((node = walker.nextNode())) {
|
||||
if (node.textContent?.includes(target)) {
|
||||
const parent = node.parentElement;
|
||||
if (parent) {
|
||||
parent.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}, scrollTo);
|
||||
|
||||
if (!scrolled) {
|
||||
// Restore viewport before throwing
|
||||
if (viewportWidth && originalViewport) {
|
||||
await page.setViewportSize(originalViewport);
|
||||
}
|
||||
throw new Error(`Could not find element or text to scroll to: ${scrollTo}`);
|
||||
}
|
||||
// Brief wait for scroll to settle
|
||||
await page.waitForTimeout(300);
|
||||
}
|
||||
|
||||
// Take screenshot
|
||||
await page.screenshot({ path: outputPath, fullPage: !scrollTo });
|
||||
|
||||
// Restore viewport
|
||||
if (viewportWidth && originalViewport) {
|
||||
await page.setViewportSize(originalViewport);
|
||||
}
|
||||
|
||||
const parts = ['Screenshot saved'];
|
||||
if (doCleanup) parts.push('(cleaned)');
|
||||
if (scrollTo) parts.push(`(scrolled to: ${scrollTo})`);
|
||||
parts.push(`: ${outputPath}`);
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown write command: ${command}`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user