Setting the file. One moment.
Compile Report · Company Research · browserbase/skills · Skills Docs
ContentsBack to the top of the page scripts/ compile_report.mjs
JavaScript · 356 lines · 14 KB
'url'
;
13
14 const __filename = fileURLToPath ( import . meta .url);
15 const __dirname = dirname (__filename);
16
17 const args = process.argv. slice ( 2 );
18
19 if (args. includes ( '--help' ) || args. includes ( '-h' ) || args. length === 0 ) {
20 console. error ( `Usage: node compile_report.mjs <research-dir> [--template <path>]
21
22 Reads all .md files from <research-dir>, generates:
23 - index.html — overview page with scored table
24 - companies/<slug>.html — individual company research pages
25 - results.csv — scored spreadsheet
26
27 Options:
28 --template <path> Path to report-template.html (default: auto-detect)
29 --open Open index.html in browser after generation
30 --help, -h Show this help message
31
32 Examples:
33 node compile_report.mjs ~/Desktop/acme_research_2026-04-09
34 node compile_report.mjs ~/Desktop/research --open` );
35 process. exit (args. includes ( '--help' ) || args. includes ( '-h' ) ? 0 : 1 );
36 }
37
38 const dir = args[ 0 ];
39 const shouldOpen = args. includes ( '--open' );
40 const templateIdx = args. indexOf ( '--template' );
41 let templatePath = templateIdx !== - 1 ? args[templateIdx + 1 ] : null ;
42
43 // Auto-detect template
44 if ( ! templatePath) {
45 const candidates = [
46 join (__dirname, '..' , 'references' , 'report-template.html' ),
47 join (__dirname, 'report-template.html' ),
48 ];
49 templatePath = candidates. find ( p => existsSync (p));
50 if ( ! templatePath) {
51 console. error ( 'Error: Could not find report-template.html. Use --template to specify path.' );
52 process. exit ( 1 );
53 }
54 }
55
56 const template = readFileSync (templatePath, 'utf-8' );
57
58 // Read and parse markdown files
59 let files;
60 try {
61 files = readdirSync (dir). filter ( f => f. endsWith ( '.md' )). sort ();
62 } catch (err) {
63 console. error ( `Error reading directory ${ dir }: ${ err . message }` );
64 process. exit ( 1 );
65 }
66
67 if (files. length === 0 ) {
68 console. error ( `No .md files found in ${ dir }` );
69 process. exit ( 1 );
70 }
71
72 function parseFrontmatter ( content ) {
73 const fmMatch = content. match ( / ^ --- \n ( [\s\S] *? ) \n ---/ );
74 if ( ! fmMatch) return null ;
75 const fields = {};
76 for ( const line of fmMatch[ 1 ]. split ( ' \n ' )) {
77 const idx = line. indexOf ( ':' );
78 if (idx > 0 ) {
79 const key = line. slice ( 0 , idx). trim ();
80 const val = line. slice (idx + 1 ). trim (). replace ( / ^ ["'] | ["'] $ / g , '' );
81 if (key && val) fields[key] = val;
82 }
83 }
84 return fields;
85 }
86
87 function parseBody ( content ) {
88 const bodyMatch = content. match ( / ^ --- \n[\s\S] *? \n --- \n ( [\s\S] * )/ );
89 return bodyMatch ? bodyMatch[ 1 ]. trim () : '' ;
90 }
91
92 function escapeHtml ( str ) {
93 return (str || '' ). replace ( /&/ g , '&' ). replace ( /</ g , '<' ). replace ( />/ g , '>' ). replace ( /"/ g , '"' );
94 }
95
96 function scoreClass ( score ) {
97 const s = parseInt (score) || 0 ;
98 if (s >= 8 ) return 'high' ;
99 if (s >= 5 ) return 'medium' ;
100 return 'low' ;
101 }
102
103 function mdToHtml ( md ) {
104 const lines = md. split ( ' \n ' );
105 const out = [];
106 let inList = false ;
107 let paraLines = [];
108
109 function flushPara () {
110 if (paraLines. length > 0 ) {
111 let text = escapeHtml (paraLines. join ( ' ' ). trim ());
112 text = text. replace ( / \*\*\[ ( \w + ) \]\*\* / g , '<span class="confidence $1">[$1]</span>' );
113 text = text. replace ( / \*\* ( [ ^ *] + ) \*\* / g , '<strong>$1</strong>' );
114 if (text) out. push ( `<p>${ text }</p>` );
115 paraLines = [];
116 }
117 }
118
119 function closeList () {
120 if (inList) { out. push ( '</ul>' ); inList = false ; }
121 }
122
123 for ( const line of lines) {
124 const trimmed = line. trim ();
125
126 if ( ! trimmed) {
127 flushPara ();
128 closeList ();
129 continue ;
130 }
131
132 // Headings
133 if (trimmed. startsWith ( '## ' )) {
134 flushPara (); closeList ();
135 out. push ( `<h2>${ escapeHtml ( trimmed . slice ( 3 )) }</h2>` );
136 continue ;
137 }
138 if (trimmed. startsWith ( '### ' )) {
139 flushPara (); closeList ();
140 out. push ( `<h3>${ escapeHtml ( trimmed . slice ( 4 )) }</h3>` );
141 continue ;
142 }
143
144 // List items
145 if (trimmed. startsWith ( '- ' )) {
146 flushPara ();
147 if ( ! inList) { out. push ( '<ul>' ); inList = true ; }
148 let text = escapeHtml (trimmed. slice ( 2 ));
149 text = text. replace ( / \*\*\[ ( \w + ) \]\*\* / g , '<span class="confidence $1">[$1]</span>' );
150 text = text. replace ( / \*\* ( [ ^ *] + ) \*\* / g , '<strong>$1</strong>' );
151 out. push ( `<li>${ text }</li>` );
152 continue ;
153 }
154
155 // Regular text — accumulate into paragraph
156 closeList ();
157 paraLines. push (trimmed);
158 }
159
160 flushPara ();
161 closeList ();
162 return out. join ( ' \n ' );
163 }
164
165 // Parse all companies
166 const companies = [];
167 for ( const file of files) {
168 const content = readFileSync ( join (dir, file), 'utf-8' );
169 const fields = parseFrontmatter (content);
170 if ( ! fields) continue ;
171 const body = parseBody (content);
172 const slug = file. replace ( '.md' , '' );
173 companies. push ({ ... fields, body, slug, file });
174 }
175
176 // Sort by ICP score descending
177 companies. sort (( a , b ) => ( parseInt (b.icp_fit_score) || 0 ) - ( parseInt (a.icp_fit_score) || 0 ));
178
179 // Deduplicate
180 const seen = new Map ();
181 for ( const c of companies) {
182 const name = (c.company_name || '' ). toLowerCase (). replace ( / [,\s] + (inc | llc | ltd | corp | co) \. ?$ / i , '' ). trim ();
183 if ( ! seen. has (name)) seen. set (name, c);
184 }
185 const deduped = [ ... seen. values ()];
186
187 // Stats
188 const scores = deduped. map ( c => parseInt (c.icp_fit_score) || 0 );
189 const high = scores. filter ( s => s >= 8 ). length ;
190 const medium = scores. filter ( s => s >= 5 && s < 8 ). length ;
191 const low = scores. filter ( s => s < 5 ). length ;
192 const total = deduped. length ;
193 const highPct = total > 0 ? Math. round ((high / total) * 100 ) : 0 ;
194 const mediumPct = total > 0 ? Math. round ((medium / total) * 100 ) : 0 ;
195 const lowPct = total > 0 ? 100 - highPct - mediumPct : 0 ;
196
197 // Derive title from directory name
198 const dirName = dir. split ( '/' ). pop ();
199 const title = dirName. replace ( /_/ g , ' ' ). replace ( /-/ g , ' ' ). replace ( / \b \w / g , c => c. toUpperCase ());
200
201 // Generate table rows
202 const tableRows = deduped. map ( c => {
203 const sc = scoreClass (c.icp_fit_score);
204 const hasDetail = c.body && c.body. length > 50 ;
205 const nameHtml = hasDetail
206 ? `<a href="companies/${ c . slug }.html">${ escapeHtml ( c . company_name ) }</a>`
207 : escapeHtml (c.company_name);
208 const websiteHtml = c.website
209 ? `<br><a href="${ escapeHtml ( c . website ) }" target="_blank" style="font-size:0.75rem;color:var(--muted);">${ escapeHtml ( c . website . replace ( / ^ https ? : \/\/ (www \. ) ? / , '' )) }</a>`
210 : '' ;
211 return ` <tr>
212 <td><span class="score ${ sc }">${ escapeHtml ( c . icp_fit_score || '—' ) }</span></td>
213 <td>${ nameHtml }${ websiteHtml }</td>
214 <td style="max-width:200px;">${ escapeHtml ( c . product_description || '' ) }</td>
215 <td>${ escapeHtml ( c . industry || '' ) }</td>
216 <td class="reasoning">${ escapeHtml ( c . icp_fit_reasoning || '' ) }</td>
217 </tr>` ;
218 }). join ( ' \n ' );
219
220 // Fill index template
221 const escapedTitle = escapeHtml (title);
222 let indexHtml = template
223 . replace ( / \{\{ TITLE \}\} / g , `Company Research — ${ escapedTitle }` )
224 . replace ( / \{\{ COMPANY_NAME \}\} / g , escapedTitle)
225 . replace ( / \{\{ META \}\} / g , `${ deduped . length } companies researched · ${ new Date (). toLocaleDateString ( 'en-US' , { year: 'numeric' , month: 'long' , day: 'numeric' }) }` )
226 . replace ( / \{\{ TOTAL \}\} / g , String (total))
227 . replace ( / \{\{ HIGH_COUNT \}\} / g , String (high))
228 . replace ( / \{\{ MEDIUM_COUNT \}\} / g , String (medium))
229 . replace ( / \{\{ LOW_COUNT \}\} / g , String (low))
230 . replace ( / \{\{ HIGH_PCT \}\} / g , String (highPct))
231 . replace ( / \{\{ MEDIUM_PCT \}\} / g , String (mediumPct))
232 . replace ( / \{\{ LOW_PCT \}\} / g , String (lowPct))
233 . replace ( / \{\{ TABLE_ROWS \}\} / g , () => tableRows);
234
235 writeFileSync ( join (dir, 'index.html' ), indexHtml);
236
237 // Generate individual company pages
238 const { mkdirSync } = await import ( 'fs' );
239 try { mkdirSync ( join (dir, 'companies' ), { recursive: true }); } catch {}
240
241 for ( const c of deduped) {
242 if ( ! c.body || c.body. length < 50 ) continue ;
243 const sc = scoreClass (c.icp_fit_score);
244 const bodyHtml = mdToHtml (c.body);
245
246 const companyHtml = `<!DOCTYPE html>
247 <html lang="en">
248 <head>
249 <meta charset="UTF-8">
250 <meta name="viewport" content="width=device-width, initial-scale=1.0">
251 <title>${ escapeHtml ( c . company_name ) } — Research</title>
252 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
253 <style>
254 :root { --brand:#F03603; --high:#90C94D; --medium:#F4BA41; --low:#F03603; --black:#100D0D; --gray:#514F4F; --border:#edebeb; --bg:#F9F6F4; --card:#ffffff; --text:#100D0D; --muted:#514F4F; }
255 * { margin:0; padding:0; box-sizing:border-box; }
256 body { font-family:Inter,-apple-system,BlinkMacSystemFont,'Segoe UI',system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.6; font-size:16px; }
257 .container { max-width:800px; margin:0 auto; padding:2rem 1.5rem; }
258 a { color:var(--brand); text-decoration:none; }
259 a:hover { text-decoration:underline; }
260 .back { font-size:0.875rem; color:var(--muted); margin-bottom:1.5rem; display:inline-block; }
261 .back:hover { color:var(--brand); }
262 header { margin-bottom:2rem; }
263 header h1 { font-size:1.5rem; font-weight:600; margin-bottom:0.25rem; }
264 header .meta { color:var(--muted); font-size:0.875rem; }
265 .score-badge { display:inline-block; font-size:0.875rem; font-weight:700; padding:4px 14px; border-radius:4px; margin-right:0.75rem; }
266 .score-badge.high { background:rgba(144,201,77,0.12); color:#5a8a1a; border:1px solid rgba(144,201,77,0.3); }
267 .score-badge.medium { background:rgba(244,186,65,0.12); color:#9a7520; border:1px solid rgba(244,186,65,0.3); }
268 .score-badge.low { background:rgba(240,54,3,0.08); color:var(--low); border:1px solid rgba(240,54,3,0.2); }
269 .fields { background:var(--card); border:1px solid var(--border); border-radius:4px; padding:1.25rem; margin-bottom:2rem; display:grid; grid-template-columns:auto 1fr; gap:0.375rem 1rem; font-size:0.875rem; }
270 .fields dt { color:var(--muted); font-weight:500; }
271 .fields dd { color:var(--text); }
272 .research { background:var(--card); border:1px solid var(--border); border-radius:4px; padding:1.5rem; }
273 .research h2 { font-size:1.125rem; font-weight:600; margin:1.5rem 0 0.5rem 0; color:var(--black); }
274 .research h2:first-child { margin-top:0; }
275 .research p { margin-bottom:0.75rem; }
276 .research ul { margin:0.5rem 0 1rem 1.25rem; }
277 .research li { margin-bottom:0.375rem; font-size:0.875rem; }
278 .confidence { font-size:0.75rem; font-weight:600; padding:1px 6px; border-radius:2px; }
279 .confidence.high { background:rgba(144,201,77,0.12); color:#5a8a1a; }
280 .confidence.medium { background:rgba(244,186,65,0.12); color:#9a7520; }
281 .confidence.low { background:rgba(240,54,3,0.08); color:var(--low); }
282 footer { margin-top:3rem; padding-top:1.5rem; border-top:1px solid var(--border); text-align:center; font-size:0.75rem; color:var(--muted); }
283 </style>
284 </head>
285 <body>
286 <div class="container">
287 <a href="../index.html" class="back">← Back to overview</a>
288 <header>
289 <h1>${ escapeHtml ( c . company_name ) }</h1>
290 <div class="meta">
291 <span class="score-badge ${ sc }">ICP Score: ${ escapeHtml ( c . icp_fit_score || '—' ) }</span>
292 ${ c . website ? `<a href="${ escapeHtml ( c . website ) }" target="_blank">${ escapeHtml ( c . website ) }</a>` : ''}
293 </div>
294 </header>
295 <dl class="fields">
296 ${ c . product_description ? `<dt>Product</dt><dd>${ escapeHtml ( c . product_description ) }</dd>` : ''}
297 ${ c . industry ? `<dt>Industry</dt><dd>${ escapeHtml ( c . industry ) }</dd>` : ''}
298 ${ c . target_audience ? `<dt>Target Audience</dt><dd>${ escapeHtml ( c . target_audience ) }</dd>` : ''}
299 ${ c . key_features ? `<dt>Key Features</dt><dd>${ escapeHtml ( c . key_features ) }</dd>` : ''}
300 ${ c . employee_estimate ? `<dt>Employees</dt><dd>${ escapeHtml ( c . employee_estimate ) }</dd>` : ''}
301 ${ c . funding_info ? `<dt>Funding</dt><dd>${ escapeHtml ( c . funding_info ) }</dd>` : ''}
302 ${ c . headquarters ? `<dt>HQ</dt><dd>${ escapeHtml ( c . headquarters ) }</dd>` : ''}
303 ${ c . icp_fit_reasoning ? `<dt>Fit Reasoning</dt><dd>${ escapeHtml ( c . icp_fit_reasoning ) }</dd>` : ''}
304 </dl>
305 <div class="research">
306 ${ bodyHtml }
307 </div>
308 </div>
309 <footer>Generated by <a href="https://github.com/anthropics/skills">company-research</a> · Powered by <a href="https://browserbase.com">Browserbase</a></footer>
310 </body>
311 </html>` ;
312
313 writeFileSync ( join (dir, 'companies' , `${ c . slug }.html` ), companyHtml);
314 }
315
316 // Generate CSV
317 const priority = [
318 'company_name' , 'website' , 'product_description' , 'icp_fit_score' ,
319 'icp_fit_reasoning' , 'industry' , 'target_audience' , 'key_features' ,
320 'employee_estimate' , 'funding_info' , 'headquarters'
321 ];
322 const allCols = [ ...new Set (deduped. flatMap ( r => Object. keys (r)). filter ( k => k !== 'body' && k !== 'slug' && k !== 'file' ))];
323 const cols = [ ... priority. filter ( c => allCols. includes (c)), ... allCols. filter ( c => ! priority. includes (c)). sort ()];
324
325 function csvEscape ( v ) {
326 if ( ! v) return '' ;
327 if (v. includes ( ',' ) || v. includes ( '"' ) || v. includes ( ' \n ' )) return '"' + v. replace ( /"/ g , '""' ) + '"' ;
328 return v;
329 }
330
331 const csvLines = [cols. join ( ',' )];
332 for ( const row of deduped) {
333 csvLines. push (cols. map ( c => csvEscape (row[c] || '' )). join ( ',' ));
334 }
335 writeFileSync ( join (dir, 'results.csv' ), csvLines. join ( ' \n ' ) + ' \n ' );
336
337 // Summary
338 console. error ( JSON . stringify ({
339 total: deduped. length ,
340 high_fit: high,
341 medium_fit: medium,
342 low_fit: low,
343 files_generated: {
344 index: join (dir, 'index.html' ),
345 company_pages: deduped. filter ( c => c.body && c.body. length > 50 ). length ,
346 csv: join (dir, 'results.csv' )
347 }
348 }, null , 2 ));
349
350 console. log ( join (dir, 'index.html' ));
351
352 // Open in browser if requested
353 if (shouldOpen) {
354 const { execSync } = await import ( 'child_process' );
355 try { execSync ( `open "${ join ( dir , 'index.html' ) }"` ); } catch {}
356 }