1240 lines · 45.2 KB · TypeScript
RawDownload
#!/usr/bin/env ts-node /** * Minimal Chrome DevTools helpers inspired by Mario Zechner's * "What if you don't need MCP?" article. * * Keeps everything in one TypeScript CLI so agents (or humans) can drive Chrome * directly via the DevTools protocol without pulling in a large MCP server. */import { Command } from 'commander';import { execFileSync, execSync, spawn } from 'node:child_process';import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, realpathSync, rmSync, statSync } from 'node:fs';import { writeFile } from 'node:fs/promises';import http from 'node:http';import os from 'node:os';import path from 'node:path';import readline from 'node:readline/promises';import { stdin as input, stdout as output } from 'node:process';import { fileURLToPath } from 'node:url';import { inspect } from 'node:util';import puppeteer, { type HTTPRequest } from 'puppeteer-core'; /** Utility type so TypeScript knows the async function constructor */type AsyncFunctionCtor = new (...args: string[]) => (...fnArgs: unknown[]) => Promise<unknown>; const DEFAULT_PORT = 9222;const DEFAULT_PROFILE_DIR = path.join(os.homedir(), '.cache', 'scraping');const DEFAULT_CHROME_BIN = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; function browserURL(port: number): string {  return `http://localhost:${port}`;} async function connectBrowser(port: number) {  return puppeteer.connect({ browserURL: browserURL(port), defaultViewport: null });} function resolveComparablePath(inputPath: string): string {  let existingPath = path.resolve(inputPath);  const missingSegments: string[] = [];  while (!existsSync(existingPath)) {    const parent = path.dirname(existingPath);    if (parent === existingPath) break;    missingSegments.unshift(path.basename(existingPath));    existingPath = parent;  }  return path.join(realpathSync(existingPath), ...missingSegments);} function pathsOverlap(first: string, second: string): boolean {  const relative = path.relative(first, second);  return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative));} export function copyChromeProfile(sourceDir: string, profileDir: string): void {  const source = realpathSync(sourceDir);  if (!statSync(source).isDirectory()) {    throw new Error('Chrome profile source must be a directory');  }   let destinationLinkExists = false;  try {    destinationLinkExists = lstatSync(profileDir).isSymbolicLink();  } catch {    // Missing destinations are created below.  }  if (destinationLinkExists && !existsSync(profileDir)) {    throw new Error('Chrome profile destination symlink target does not exist');  }   let destination = resolveComparablePath(profileDir);  if (pathsOverlap(source, destination) || pathsOverlap(destination, source)) {    throw new Error('Chrome profile source and destination must not overlap');  }   if (existsSync(profileDir) && statSync(profileDir).isDirectory()) {    for (const entry of readdirSync(destination)) {      rmSync(path.join(destination, entry), { recursive: true, force: true });    }  } else {    rmSync(profileDir, { recursive: true, force: true });    mkdirSync(profileDir, { recursive: true });    destination = resolveComparablePath(profileDir);  }  cpSync(source, destination, {    recursive: true,    force: true,    verbatimSymlinks: true,  });} async function getActivePage(port: number) {  const browser = await connectBrowser(port);  const pages = await browser.pages();  const page = pages.at(-1);  if (!page) {    await browser.disconnect();    throw new Error('No active tab found');  }  return { browser, page };} const program = new Command();program  .name('browser-tools')  .description('Lightweight Chrome DevTools helpers (no MCP required).')  .configureHelp({ sortSubcommands: true })  .showSuggestionAfterError(); program  .command('start')  .description('Launch Chrome with remote debugging enabled.')  .option('-p, --port <number>', 'Remote debugging port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)  .option('--profile', 'Copy your default Chrome profile before launch.', false)  .option('--profile-dir <path>', 'Directory for the temporary Chrome profile.', DEFAULT_PROFILE_DIR)  .option('--chrome-path <path>', 'Path to the Chrome binary.', DEFAULT_CHROME_BIN)  .option('--kill-existing', 'Stop any running Google Chrome before launch (default: false).', false)  .action(async (options) => {    const { port, profile, profileDir, chromePath, killExisting } = options as {      port: number;      profile: boolean;      profileDir: string;      chromePath: string;      killExisting: boolean;    };     if (killExisting) {      try {        execFileSync('killall', ['Google Chrome'], { stdio: 'ignore' });      } catch {        // ignore missing processes      }      await new Promise((resolve) => setTimeout(resolve, 1000));    }    mkdirSync(profileDir, { recursive: true });    if (profile) {      const source = path.join(os.homedir(), 'Library', 'Application Support', 'Google', 'Chrome');      copyChromeProfile(source, profileDir);    }     spawn(chromePath, [`--remote-debugging-port=${port}`, `--user-data-dir=${profileDir}`, '--no-first-run', '--disable-popup-blocking'], {      detached: true,      stdio: 'ignore',    }).unref();     let connected = false;    for (let attempt = 0; attempt < 30; attempt++) {      try {        const browser = await connectBrowser(port);        await browser.disconnect();        connected = true;        break;      } catch {        await new Promise((resolve) => setTimeout(resolve, 500));      }    }     if (!connected) {      console.error(`✗ Failed to start Chrome on port ${port}`);      process.exit(1);    }    console.log(`✓ Chrome listening on http://localhost:${port}${profile ? ' (profile copied)' : ''}`);  }); program  .command('nav <url>')  .description('Navigate the current tab or open a new tab.')  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)  .option('--new', 'Open in a new tab.', false)  .action(async (url: string, options) => {    const port = options.port as number;    const browser = await connectBrowser(port);    try {      if (options.new) {        const page = await browser.newPage();        await page.goto(url, { waitUntil: 'domcontentloaded' });        console.log('✓ Opened in new tab:', url);      } else {        const pages = await browser.pages();        const page = pages.at(-1);        if (!page) {          throw new Error('No active tab found');        }        await page.goto(url, { waitUntil: 'domcontentloaded' });        console.log('✓ Navigated current tab to:', url);      }    } finally {      await browser.disconnect();    }  }); program  .command('eval <code...>')  .description('Evaluate JavaScript in the active page context.')  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)  .option('--pretty-print', 'Format array/object results with indentation.', false)  .action(async (code: string[], options) => {    const snippet = code.join(' ');    const port = options.port as number;    const pretty = Boolean(options.prettyPrint);    const useColor = process.stdout.isTTY;     const printPretty = (value: unknown) => {      console.log(        inspect(value, {          depth: 6,          colors: useColor,          maxArrayLength: 50,          breakLength: 80,          compact: false,        }),      );    };     const { browser, page } = await getActivePage(port);    try {      const result = await page.evaluate((body) => {        const ASYNC_FN = Object.getPrototypeOf(async () => {}).constructor as AsyncFunctionCtor;        return new ASYNC_FN(`return (${body})`)();      }, snippet);       if (pretty) {        printPretty(result);      } else if (Array.isArray(result)) {        result.forEach((entry, index) => {          if (index > 0) {            console.log('');          }          Object.entries(entry).forEach(([key, value]) => {            console.log(`${key}: ${value}`);          });        });      } else if (typeof result === 'object' && result !== null) {        Object.entries(result).forEach(([key, value]) => {          console.log(`${key}: ${value}`);        });      } else {        console.log(result);      }    } finally {      await browser.disconnect();    }  }); program  .command('screenshot')  .description('Capture the current viewport and print the temp PNG path.')  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)  .action(async (options) => {    const port = options.port as number;    const { browser, page } = await getActivePage(port);    const client = await page.target().createCDPSession();    try {      const timestamp = new Date().toISOString().replace(/[:.]/g, '-');      const filePath = path.join(os.tmpdir(), `screenshot-${timestamp}.png`);      const layoutMetrics = await client.send('Page.getLayoutMetrics').catch(() => undefined);      const layoutViewport = layoutMetrics?.layoutViewport as        | { clientWidth: number; clientHeight: number; pageX?: number; pageY?: number }        | undefined;       let cssWidth = layoutViewport?.clientWidth;      let cssHeight = layoutViewport?.clientHeight;      const pageX = layoutViewport?.pageX ?? 0;      const pageY = layoutViewport?.pageY ?? 0;       if (!cssWidth || !cssHeight) {        const viewport = page.viewport();        cssWidth = viewport?.width;        cssHeight = viewport?.height;      }       if (!cssWidth || !cssHeight) {        const fallback = await page.evaluate(() => ({          width: window.innerWidth,          height: window.innerHeight,        }));        cssWidth = fallback.width;        cssHeight = fallback.height;      }       const maxDimension = 2000;      const scale =        cssWidth && cssHeight          ? Math.max(              0.01,              Math.min(1, maxDimension / Math.max(cssWidth, cssHeight)),            )          : 1;       if (!cssWidth || !cssHeight) {        await page.screenshot({ path: filePath });        console.log(filePath);        return;      }       const screenshot = await client.send('Page.captureScreenshot', {        format: 'png',        fromSurface: true,        captureBeyondViewport: false,        clip: {          x: pageX,          y: pageY,          width: cssWidth,          height: cssHeight,          scale,        },      });       await writeFile(filePath, Buffer.from(screenshot.data, 'base64'));      console.log(filePath);    } finally {      try {        await client.detach();      } catch {        // ignore      }      await browser.disconnect();    }  }); program  .command('pick <message...>')  .description('Interactive DOM picker that prints metadata for clicked elements.')  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)  .action(async (messageParts: string[], options) => {    const message = messageParts.join(' ');    const port = options.port as number;    const { browser, page } = await getActivePage(port);    try {      await page.evaluate(() => {        const scope = globalThis as typeof globalThis & {          pickOverlayInjected?: boolean;          pick?: (prompt: string) => Promise<unknown>;        };        if (scope.pickOverlayInjected) {          return;        }        scope.pickOverlayInjected = true;        scope.pick = async (prompt: string) =>          new Promise((resolve) => {            const selections: unknown[] = [];            const selectedElements = new Set<HTMLElement>();             const overlay = document.createElement('div');            overlay.style.cssText =              'position:fixed;top:0;left:0;width:100%;height:100%;z-index:2147483647;pointer-events:none';             const highlight = document.createElement('div');            highlight.style.cssText =              'position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);transition:all 0.05s ease';            overlay.appendChild(highlight);             const banner = document.createElement('div');            banner.style.cssText =              'position:fixed;bottom:20px;left:50%;transform:translateX(-50%);background:#1f2937;color:#fff;padding:12px 24px;border-radius:8px;font:14px system-ui;box-shadow:0 4px 12px rgba(0,0,0,0.3);pointer-events:auto;z-index:2147483647';             const updateBanner = () => {              banner.textContent = `${prompt} (${selections.length} selected, Cmd/Ctrl+click to add, Enter to finish, ESC to cancel)`;            };             const cleanup = () => {              document.removeEventListener('mousemove', onMove, true);              document.removeEventListener('click', onClick, true);              document.removeEventListener('keydown', onKey, true);              overlay.remove();              banner.remove();              selectedElements.forEach((el) => {                el.style.outline = '';              });            };             const serialize = (el: HTMLElement) => {              const parents: string[] = [];              let current = el.parentElement;              while (current && current !== document.body) {                const id = current.id ? `#${current.id}` : '';                const cls = current.className ? `.${current.className.trim().split(/\s+/).join('.')}` : '';                parents.push(`${current.tagName.toLowerCase()}${id}${cls}`);                current = current.parentElement;              }              return {                tag: el.tagName.toLowerCase(),                id: el.id || null,                class: el.className || null,                text: el.textContent?.trim()?.slice(0, 200) || null,                html: el.outerHTML.slice(0, 500),                parents: parents.join(' > '),              };            };             const onMove = (event: MouseEvent) => {              const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;              if (!node || overlay.contains(node) || banner.contains(node)) return;              const rect = node.getBoundingClientRect();              highlight.style.cssText = `position:absolute;border:2px solid #3b82f6;background:rgba(59,130,246,0.1);top:${rect.top}px;left:${rect.left}px;width:${rect.width}px;height:${rect.height}px`;            };            const onClick = (event: MouseEvent) => {              if (banner.contains(event.target as Node)) return;              event.preventDefault();              event.stopPropagation();              const node = document.elementFromPoint(event.clientX, event.clientY) as HTMLElement | null;              if (!node || overlay.contains(node) || banner.contains(node)) return;               if (event.metaKey || event.ctrlKey) {                if (!selectedElements.has(node)) {                  selectedElements.add(node);                  node.style.outline = '3px solid #10b981';                  selections.push(serialize(node));                  updateBanner();                }              } else {                cleanup();                const info = serialize(node);                resolve(selections.length > 0 ? selections : info);              }            };             const onKey = (event: KeyboardEvent) => {              if (event.key === 'Escape') {                cleanup();                resolve(null);              } else if (event.key === 'Enter' && selections.length > 0) {                cleanup();                resolve(selections);              }            };             document.addEventListener('mousemove', onMove, true);            document.addEventListener('click', onClick, true);            document.addEventListener('keydown', onKey, true);             document.body.append(overlay, banner);            updateBanner();          });      });       const result = await page.evaluate((msg) => {        const pickFn = (window as Window & { pick?: (message: string) => Promise<unknown> }).pick;        if (!pickFn) {          return null;        }        return pickFn(msg);      }, message);       if (Array.isArray(result)) {        result.forEach((entry, index) => {          if (index > 0) {            console.log('');          }          Object.entries(entry).forEach(([key, value]) => {            console.log(`${key}: ${value}`);          });        });      } else if (result && typeof result === 'object') {        Object.entries(result).forEach(([key, value]) => {          console.log(`${key}: ${value}`);        });      } else {        console.log(result);      }    } finally {      await browser.disconnect();    }  }); program  .command('console')  .description('Capture and display console logs from the active tab.')  .option('--port <number>', 'Debugger port (default: 9222)', (value) => Number.parseInt(value, 10), DEFAULT_PORT)  .option('--types <list>', 'Comma-separated log types to show (e.g., log,error,warn). Default: all types')  .option('--follow', 'Continuous monitoring mode (like tail -f)', false)  .option('--timeout <seconds>', 'Capture duration in seconds (default: 5 for one-shot, infinite for --follow)', (value) => Number.parseInt(value, 10))  .option('--color', 'Force color output')  .option('--no-color', 'Disable color output')  .option('--no-serialize', 'Disable object serialization (show raw text only)')  .action(async (options) => {    const port = options.port as number;    const follow = options.follow as boolean;    const timeout = options.timeout as number | undefined;    const typesFilter = options.types as string | undefined;    const serialize = options.serialize !== false;     // Track explicit color flags by looking at argv to avoid Commander defaults overriding TTY detection.    const argv = process.argv.slice(2);    const colorFlag = argv.includes('--color') ? true : argv.includes('--no-color') ? false : undefined;     // Determine if we should use colors: explicit flag or TTY auto-detection    const useColor = colorFlag ?? process.stdout.isTTY;     // Parse types filter    const normalizeType = (value: string) => {      const lower = value.toLowerCase();      if (lower === 'warning') return 'warn';      return lower;    };     const allowedTypes = typesFilter      ? new Set(typesFilter.split(',').map((t) => normalizeType(t.trim())))      : null; // null means show all types 

Lines 1–500 of 1240.