Setting the file. One moment.
Capture Screenshots · Competitor Analysis · browserbase/skills · Skills Docs
ContentsBack to the top of the page scripts/capture_screenshots.mjs
scripts/ capture_screenshots.mjs
JavaScript · 142 lines · 7 KB
import
{ readdirSync, readFileSync, mkdirSync, existsSync }
from
'fs'
;
16 import { join } from 'path' ;
17 import { spawnSync } from 'child_process' ;
18 import { parseFrontmatter } from './md_utils.mjs' ;
19
20 const args = process.argv. slice ( 2 );
21
22 if (args. includes ( '--help' ) || args. includes ( '-h' ) || args. length === 0 ) {
23 console. error ( `Usage: node capture_screenshots.mjs <research-dir> [options]
24
25 Reads all .md files in <research-dir>, extracts the "website" field from each
26 competitor's YAML frontmatter, and captures a 1280x800 viewport screenshot of the
27 homepage. Writes one PNG per competitor as {slug}-hero.png.
28
29 Output goes to <research-dir>/screenshots/.
30
31 Options:
32 --mode <remote|local> Which browse session to use (default: remote).
33 Passed as --remote / --local on each browse command.
34 --concurrency <n> How many competitors to capture in parallel (default: 1)
35 (screenshot takes ~3s; serial is usually fine)
36 --skip-existing Skip competitors that already have screenshots
37 --help, -h Show this help message` );
38 process. exit (args. includes ( '--help' ) || args. includes ( '-h' ) ? 0 : 1 );
39 }
40
41 const dir = args[ 0 ];
42 const modeIdx = args. indexOf ( '--mode' );
43 const browseMode = modeIdx !== - 1 ? args[modeIdx + 1 ] : 'remote' ;
44 const modeFlag = browseMode === 'local' ? '--local' : '--remote' ;
45 // Drive a dedicated named session so we never collide with whatever `browse` session
46 // the user already has open (the default session is bound to one mode — opening it
47 // --remote while a --local session is live errors out). Stopped at the end of the run.
48 const SESSION = 'competitor-analysis-shots' ;
49 const browseFlags = [modeFlag, '-s' , SESSION ];
50 const concurrencyIdx = args. indexOf ( '--concurrency' );
51 let concurrency = concurrencyIdx !== - 1 ? parseInt (args[concurrencyIdx + 1 ], 10 ) : 1 ;
52 // Floor at 1: `--concurrency 0` would spawn zero workers (no screenshots captured, yet the
53 // script exits "successfully"), and a non-numeric value (NaN) would throw on Array(NaN).
54 // Normalize before the >1 clamp below.
55 if ( ! Number. isFinite (concurrency) || concurrency < 1 ) concurrency = 1 ;
56 const skipExisting = args. includes ( '--skip-existing' );
57
58 // All captures share one named `browse` session; parallel `browse open/screenshot` calls would
59 // race on the same tab. Clamp concurrency to 1 and warn rather than silently corrupt output.
60 // (Each capture is fast — ~3-4s — so serial is acceptable.)
61 if (concurrency > 1 ) {
62 console. error ( `Note: clamping --concurrency ${ concurrency } to 1 — \` browse \` shares a single session across calls, so parallel screenshots would race on the same tab.` );
63 concurrency = 1 ;
64 }
65
66 const shotsDir = join (dir, 'screenshots' );
67 mkdirSync (shotsDir, { recursive: true });
68
69 function run ( cmd , args , { timeout = 30000 } = {}) {
70 return spawnSync (cmd, args, { encoding: 'utf-8' , timeout, maxBuffer: 4 * 1024 * 1024 });
71 }
72
73 async function captureOne ( slug , website ) {
74 const heroPath = join (shotsDir, `${ slug }-hero.png` );
75 const result = { slug, hero: null , errors: [] };
76
77 if (skipExisting && existsSync (heroPath)) {
78 return { ... result, hero: heroPath, skipped: true };
79 }
80
81 // Hero: viewport 1280x800, single-screen shot. The mode + session flags are passed on
82 // each command so every call resolves to the same dedicated browser session.
83 try {
84 const openRes = run ( 'browse' , [ 'open' , website, ... browseFlags], { timeout: 30000 });
85 // `browse open` exits 0 even when navigation fails — it just lands the tab on
86 // `chrome-error://chromewebdata/`. Detect failure from the resulting URL, not the exit
87 // code, so we never screenshot a Chrome error page (and, since the session is reused
88 // across competitors, never save one competitor's page under another's slug).
89 let landedUrl = '' ;
90 try { landedUrl = ( JSON . parse (openRes.stdout || '{}' ).url) || '' ; } catch { /* non-JSON stdout */ }
91 if (openRes.status !== 0 || ! landedUrl || / ^ chrome-error: \/\/ / . test (landedUrl) || landedUrl === 'about:blank' ) {
92 result.errors. push ( `open failed (landed: ${ landedUrl || 'unknown'}): ${ openRes . stderr || openRes . stdout || `exit ${ openRes . status }`}` . slice ( 0 , 200 ));
93 return result;
94 }
95 run ( 'browse' , [ 'viewport' , '1280' , '800' , ... browseFlags]);
96 run ( 'browse' , [ 'wait' , 'timeout' , '1500' , ... browseFlags]); // let the hero settle
97 const r = run ( 'browse' , [ 'screenshot' , '--path' , heroPath, '--animations' , 'disabled' , ... browseFlags]);
98 if (r.status === 0 && existsSync (heroPath)) result.hero = heroPath;
99 else result.errors. push ( `hero: ${ r . stderr || r . stdout }` );
100 } catch (err) { result.errors. push ( `hero exception: ${ err . message }` ); }
101
102 return result;
103 }
104
105 // Load competitor records
106 const files = readdirSync (dir). filter ( f => f. endsWith ( '.md' )). sort ();
107 const jobs = [];
108 for ( const f of files) {
109 const content = readFileSync ( join (dir, f), 'utf-8' );
110 const fm = parseFrontmatter (content);
111 if ( ! fm || ! fm.website) continue ;
112 const slug = f. replace ( '.md' , '' );
113 jobs. push ({ slug, website: fm.website });
114 }
115
116 console. error ( `Capturing hero screenshots for ${ jobs . length } competitors → ${ shotsDir }` );
117
118 const results = [];
119 const queue = [ ... jobs];
120 async function worker () {
121 while (queue. length > 0 ) {
122 const job = queue. shift ();
123 const started = Date. now ();
124 const r = await captureOne (job.slug, job.website);
125 results. push (r);
126 const elapsed = ((Date. now () - started) / 1000 ). toFixed ( 1 );
127 const mark = r.hero ? 'H' : '-' ;
128 console. error ( ` [${ mark }] ${ job . slug . padEnd ( 24 ) } ${ elapsed }s ${ r . skipped ? '(skipped)' : ''}` );
129 if (r.errors. length ) for ( const e of r.errors) console. error ( ` ! ${ e . slice ( 0 , 120 ) }` );
130 }
131 }
132 await Promise . all ( Array (Math. min (concurrency, jobs. length || 1 )). fill ( 0 ). map (worker));
133
134 // Tear down the dedicated session so we don't leak a running browser (or remote
135 // Browserbase session) after the run. `browse stop` takes only `-s <session>` — it does NOT
136 // accept --remote/--local (passing them errors out), and `stop -s <session>` reliably stops
137 // a remote Browserbase session (verified against browse v0.8.5). Best-effort — ignore failures.
138 run ( 'browse' , [ 'stop' , '-s' , SESSION ]);
139
140 const okHero = results. filter ( r => r.hero). length ;
141 console. error ( ` \n Done: ${ okHero }/${ jobs . length } hero` );
142 console. log ( JSON . stringify ({ total: jobs. length , hero: okHero, outputDir: shotsDir }));