Setting the file. One moment.
Compile Report · Event Prospecting · browserbase/skills · Skills Docs
ContentsBack to the top of the page function flushPara
— line 205
This file
Number 10.7
Position 7 of 14
Type JavaScript
Size 39 KB
Lines 912 scripts/ compile_report.mjs
JavaScript · 912 lines · 39 KB
15 // <research-dir>/companies/<slug>.html — individual company research pages
16 // <research-dir>/results.csv — scored spreadsheet
17 //
18 // Usage: node compile_report.mjs <research-dir> [--template <path>] [--open]
19
20 import { readdirSync, readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs' ;
21 import { join, dirname } from 'path' ;
22 import { fileURLToPath } from 'url' ;
23
24 const __filename = fileURLToPath ( import . meta .url);
25 const __dirname = dirname (__filename);
26
27 const args = process.argv. slice ( 2 );
28
29 if (args. includes ( '--help' ) || args. includes ( '-h' ) || args. length === 0 ) {
30 console. error ( `Usage: node compile_report.mjs <research-dir> [--template <path>] [--open]
31
32 Reads companies/*.md and people/*.md from <research-dir>, generates:
33 - index.html — people grouped by company (ranked by company ICP)
34 - people.html — filterable people list (chips: company, role, ICP band)
35 - companies.html — ICP-ranked company table with expandable attendees
36 - companies/<slug>.html — individual company research pages
37 - results.csv — scored spreadsheet
38
39 Options:
40 --template <path> Path to report-template.html (default: auto-detect)
41 --open Open index.html in browser after generation
42 --help, -h Show this help message` );
43 process. exit (args. includes ( '--help' ) || args. includes ( '-h' ) ? 0 : 1 );
44 }
45
46 const dir = args[ 0 ];
47 const shouldOpen = args. includes ( '--open' );
48 const templateIdx = args. indexOf ( '--template' );
49 let templatePath = templateIdx !== - 1 ? args[templateIdx + 1 ] : null ;
50
51 // Auto-detect template
52 if ( ! templatePath) {
53 const candidates = [
54 join (__dirname, '..' , 'references' , 'report-template.html' ),
55 join (__dirname, 'report-template.html' ),
56 ];
57 templatePath = candidates. find ( p => existsSync (p));
58 if ( ! templatePath) {
59 console. error ( 'Error: Could not find report-template.html. Use --template to specify path.' );
60 process. exit ( 1 );
61 }
62 }
63
64 const template = readFileSync (templatePath, 'utf-8' );
65
66 // ----- Frontmatter / body parsing (shared) ---------------------------------
67
68 function parseFrontmatter ( content ) {
69 // Tolerant frontmatter match: prefer closing ---, but if a subagent forgot it,
70 // fall back to stopping at the first markdown heading (e.g. ## Product) so the
71 // file still parses instead of vanishing from the report.
72 const fmMatch = content. match ( / ^ --- \n ( [\s\S] *? )(?: \n --- \s * \n | \n (?=## ))/ );
73 if ( ! fmMatch) return null ;
74 const fields = {};
75 const lines = fmMatch[ 1 ]. split ( ' \n ' );
76 let i = 0 ;
77 while (i < lines. length ) {
78 const line = lines[i];
79 // Multi-line YAML pipe scalar: key: |
80 // line one
81 // line two
82 const pipeMatch = line. match ( / ^ ( [a-zA-Z_][\w] * ) \s * : \s * \| \s *$ / );
83 if (pipeMatch) {
84 const key = pipeMatch[ 1 ];
85 const buf = [];
86 i ++ ;
87 while (i < lines. length && / ^ \s {2,} / . test (lines[i])) {
88 buf. push (lines[i]. replace ( / ^ \s {2} / , '' ));
89 i ++ ;
90 }
91 fields[key] = buf. join ( ' \n ' ). trim ();
92 continue ;
93 }
94 // Nested block (e.g. links: with indented children)
95 const nestedHeadMatch = line. match ( / ^ ( [a-zA-Z_][\w] * ) \s * : \s *$ / );
96 if (nestedHeadMatch && i + 1 < lines. length && / ^ \s {2,} \S / . test (lines[i + 1 ])) {
97 const key = nestedHeadMatch[ 1 ];
98 const child = {};
99 i ++ ;
100 while (i < lines. length && / ^ \s {2,} \S / . test (lines[i])) {
101 const c = lines[i]. trim ();
102 const idx = c. indexOf ( ':' );
103 if (idx > 0 ) {
104 const ck = c. slice ( 0 , idx). trim ();
105 const cv = c. slice (idx + 1 ). trim (). replace ( / ^ ["'] | ["'] $ / g , '' );
106 child[ck] = (cv === 'null' || cv === '' ) ? null : cv;
107 }
108 i ++ ;
109 }
110 fields[key] = child;
111 continue ;
112 }
113 const idx = line. indexOf ( ':' );
114 if (idx > 0 ) {
115 const key = line. slice ( 0 , idx). trim ();
116 const val = line. slice (idx + 1 ). trim (). replace ( / ^ ["'] | ["'] $ / g , '' );
117 if (key) fields[key] = val;
118 }
119 i ++ ;
120 }
121 return fields;
122 }
123
124 function parseBody ( content ) {
125 // Mirror parseFrontmatter's tolerance — body starts after closing --- if present,
126 // else at the first ## heading.
127 const closed = content. match ( / ^ --- \n[\s\S] *? \n --- \s * \n ( [\s\S] * )/ );
128 if (closed) return closed[ 1 ]. trim ();
129 const fallback = content. match ( / ^ --- \n[\s\S] *? \n (## [\s\S] * )/ );
130 return fallback ? fallback[ 1 ]. trim () : '' ;
131 }
132
133 // Pull a markdown section's content given its heading text. Used as a fallback when
134 // person-enrichment subagents wrote hook/dm_opener/etc. as ## sections instead of YAML.
135 function extractSection ( body , heading ) {
136 if ( ! body) return null ;
137 const escaped = heading. replace ( / [.*+?^${}()|[ \]\\ ] / g , ' \\ $&' );
138 const re = new RegExp ( `^## \\ s+${ escaped } \\ s* \\ n+([ \\ s \\ S]*?)(?= \\ n## \\ s|$)` , 'im' );
139 const m = body. match (re);
140 return m ? m[ 1 ]. trim () : null ;
141 }
142
143 function escapeHtml ( str ) {
144 return (str || '' ). toString (). replace ( /&/ g , '&' ). replace ( /</ g , '<' ). replace ( />/ g , '>' ). replace ( /"/ g , '"' );
145 }
146
147 function escapeAttr ( str ) {
148 return escapeHtml (str). replace ( / \n / g , ' ' );
149 }
150
151 // Escape a value going into a single-quoted JS string literal that itself sits
152 // inside an HTML attribute (e.g. onerror="...textContent:'X'..."). JS escapes
153 // have to come before HTML escapes — `escapeHtml` doesn't touch ' or \, so a
154 // name starting with `'` would otherwise close the JS string mid-attribute.
155 function escapeJsInAttr ( str ) {
156 const js = (str || '' ). toString (). replace ( / \\ / g , ' \\\\ ' ). replace ( /'/ g , " \\ '" );
157 return escapeAttr (js);
158 }
159
160 function scoreClass ( score ) {
161 const s = parseInt (score) || 0 ;
162 if (s >= 8 ) return 'high' ;
163 if (s >= 5 ) return 'medium' ;
164 return 'low' ;
165 }
166
167 // Same thresholds as scoreClass + the template's "Strong Fit (8-10) / Partial
168 // Fit (5-7) / Low Fit (1-4)" header so a score-5 company isn't simultaneously
169 // counted as "Partial" in the summary and "Low" on its person card.
170 function icpBand ( score ) {
171 const s = parseInt (score) || 0 ;
172 if (s >= 8 ) return 'high' ;
173 if (s >= 5 ) return 'mid' ;
174 return 'low' ;
175 }
176
177 function slugify ( s ) {
178 return (s || '' ). toLowerCase (). replace ( / [ ^ a-z0-9] + / g , '-' ). replace ( / ^ - +| - +$ / g , '' );
179 }
180
181 function roleBucket ( title ) {
182 const t = (title || '' ). toLowerCase ();
183 // VP/Director check must run before Founder/CXO — otherwise "Vice President"
184 // gets caught by the "president" alternative and miscategorized as a CXO.
185 // Two-letter tokens (pm, ui, ml, ai, ux) need \b anchors or they substring-
186 // match inside unrelated words ("development" → pm, "build" → ui, "html" →
187 // ml, "captain" → ai, "deluxe" → ux).
188 if ( /( \b vp \b| vice president | head of | director)/ . test (t)) return 'VP/Director' ;
189 if ( /(ceo | founder | co- ? founder | president | chief)/ . test (t)) return 'Founder/CXO' ;
190 if ( /(engineer | developer | programmer | architect |\b sre \b| devops)/ . test (t)) return 'Engineering' ;
191 if ( /(product |\b pm \b )/ . test (t)) return 'Product' ;
192 if ( /(design |\b ux \b|\b ui \b )/ . test (t)) return 'Design' ;
193 if ( /(market | growth | content)/ . test (t)) return 'Marketing' ;
194 if ( /(sales | account | revenue |\b gtm \b )/ . test (t)) return 'Sales/GTM' ;
195 if ( /(research | scientist |\b ml \b|\b ai \b )/ . test (t)) return 'Research/AI' ;
196 return 'Other' ;
197 }
198
199 function mdToHtml ( md ) {
200 const lines = md. split ( ' \n ' );
201 const out = [];
202 let inList = false ;
203 let paraLines = [];
204
205 function flushPara () {
206 if (paraLines. length > 0 ) {
207 let text = escapeHtml (paraLines. join ( ' ' ). trim ());
208 text = text. replace ( / \*\*\[ ( \w + ) \]\*\* / g , '<span class="confidence $1">[$1]</span>' );
209 text = text. replace ( / \*\* ( [ ^ *] + ) \*\* / g , '<strong>$1</strong>' );
210 if (text) out. push ( `<p>${ text }</p>` );
211 paraLines = [];
212 }
213 }
214
215 function closeList () {
216 if (inList) { out. push ( '</ul>' ); inList = false ; }
217 }
218
219 for ( const line of lines) {
220 const trimmed = line. trim ();
221
222 if ( ! trimmed) {
223 flushPara ();
224 closeList ();
225 continue ;
226 }
227
228 if (trimmed. startsWith ( '## ' )) {
229 flushPara (); closeList ();
230 out. push ( `<h2>${ escapeHtml ( trimmed . slice ( 3 )) }</h2>` );
231 continue ;
232 }
233 if (trimmed. startsWith ( '### ' )) {
234 flushPara (); closeList ();
235 out. push ( `<h3>${ escapeHtml ( trimmed . slice ( 4 )) }</h3>` );
236 continue ;
237 }
238
239 if (trimmed. startsWith ( '- ' )) {
240 flushPara ();
241 if ( ! inList) { out. push ( '<ul>' ); inList = true ; }
242 let text = escapeHtml (trimmed. slice ( 2 ));
243 text = text. replace ( / \*\*\[ ( \w + ) \]\*\* / g , '<span class="confidence $1">[$1]</span>' );
244 text = text. replace ( / \*\* ( [ ^ *] + ) \*\* / g , '<strong>$1</strong>' );
245 out. push ( `<li>${ text }</li>` );
246 continue ;
247 }
248
249 closeList ();
250 paraLines. push (trimmed);
251 }
252
253 flushPara ();
254 closeList ();
255 return out. join ( ' \n ' );
256 }
257
258 // ----- Read companies + people --------------------------------------------
259
260 function readMdDir ( p ) {
261 if ( ! existsSync (p)) return [];
262 let entries = [];
263 try { entries = readdirSync (p); }
264 catch (e) {
265 // Surface the failure — a permissions/disk issue here means the user gets
266 // a partial report (e.g. people but no companies) and would otherwise
267 // never know why their data was skipped.
268 console. error ( `[compile_report] readdir ${ p } failed: ${ e . message }` );
269 return [];
270 }
271 return entries. filter ( f => f. endsWith ( '.md' )). sort (). map ( f => {
272 const content = readFileSync ( join (p, f), 'utf-8' );
273 const fields = parseFrontmatter (content);
274 if ( ! fields) return null ;
275 const body = parseBody (content);
276 const slug = f. replace ( '.md' , '' );
277 return { ... fields, body, slug, file: f };
278 }). filter (Boolean);
279 }
280
281 const companiesDir = join (dir, 'companies' );
282 let companies = readMdDir (companiesDir);
283
284 // Legacy fallback: top-level *.md files = companies (company-research's format)
285 if (companies. length === 0 ) {
286 companies = readMdDir (dir);
287 }
288
289 const peopleDir = join (dir, 'people' );
290 const people = readMdDir (peopleDir);
291
292 if (companies. length === 0 && people. length === 0 ) {
293 console. error ( `No .md files found in ${ dir } (looked in companies/, people/, and top-level)` );
294 process. exit ( 1 );
295 }
296
297 // Sort companies by ICP score descending
298 companies. sort (( a , b ) => ( parseInt (b.icp_fit_score) || 0 ) - ( parseInt (a.icp_fit_score) || 0 ));
299
300 // Strip suffixes like ", Inc" / "LLC" / "Corp" so "Acme LLC" and "Acme" collapse.
301 function normalizeCompanyName ( s ) {
302 return (s || '' ). toLowerCase (). replace ( / [,\s] + (inc | llc | ltd | corp | co) \. ?$ / i , '' ). trim ();
303 }
304
305 // Deduplicate companies by normalized name
306 const seen = new Map ();
307 for ( const c of companies) {
308 const name = normalizeCompanyName (c.company_name);
309 if ( ! name) continue ;
310 if ( ! seen. has (name)) seen. set (name, c);
311 }
312 const deduped = [ ... seen. values ()];
313
314 // Build company lookups. Iterate ALL companies (not just dedup survivors) so
315 // that every variant slug + name + normalized-name maps to the surviving
316 // record — otherwise people whose `p.company` matches the discarded variant
317 // fall into "Unmatched" and lose their ICP score / grouping.
318 const companyBySlug = new Map ();
319 const companyByName = new Map ();
320 for ( const c of companies) {
321 const winner = seen. get ( normalizeCompanyName (c.company_name)) || c;
322 if (c.slug) companyBySlug. set (c.slug, winner);
323 if (c.company_name) {
324 companyByName. set (c.company_name. toLowerCase (). trim (), winner);
325 companyByName. set ( normalizeCompanyName (c.company_name), winner);
326 }
327 }
328
329 function resolveCompany ( person ) {
330 if (person.company_slug && companyBySlug. has (person.company_slug)) return companyBySlug. get (person.company_slug);
331 if (person.company) {
332 const k = person.company. toLowerCase (). trim ();
333 if (companyByName. has (k)) return companyByName. get (k);
334 const norm = normalizeCompanyName (person.company);
335 if (companyByName. has (norm)) return companyByName. get (norm);
336 const slugGuess = slugify (person.company);
337 if (companyBySlug. has (slugGuess)) return companyBySlug. get (slugGuess);
338 }
339 return null ;
340 }
341
342 // Augment each person with effective company + score for sorting
343 for ( const p of people) {
344 const comp = resolveCompany (p);
345 p._company = comp;
346 // Effective ICP: company score wins (per the plan), else person frontmatter, else -1 (last)
347 const cs = comp ? parseInt (comp.icp_fit_score) : NaN ;
348 const ps = parseInt (p.icp_fit_score);
349 p._effectiveScore = ! isNaN (cs) ? cs : ( ! isNaN (ps) ? ps : - 1 );
350 }
351
352 // Sort people by effective ICP descending; unscored last; stable on name
353 people. sort (( a , b ) => {
354 if (b._effectiveScore !== a._effectiveScore) return b._effectiveScore - a._effectiveScore;
355 return (a.name || '' ). localeCompare (b.name || '' );
356 });
357
358 // ----- Stats --------------------------------------------------------------
359
360 const scores = deduped. map ( c => parseInt (c.icp_fit_score) || 0 );
361 const high = scores. filter ( s => s >= 8 ). length ;
362 const medium = scores. filter ( s => s >= 5 && s < 8 ). length ;
363 const low = scores. filter ( s => s < 5 ). length ;
364 const total = deduped. length ;
365 const highPct = total > 0 ? Math. round ((high / total) * 100 ) : 0 ;
366 const mediumPct = total > 0 ? Math. round ((medium / total) * 100 ) : 0 ;
367 const lowPct = total > 0 ? 100 - highPct - mediumPct : 0 ;
368
369 const dirName = dir. split ( '/' ). filter (Boolean). pop () || 'event' ;
370 const title = dirName. replace ( /_/ g , ' ' ). replace ( /-/ g , ' ' ). replace ( / \b \w / g , c => c. toUpperCase ());
371
372 // ----- Person card render --------------------------------------------------
373
374 function initials ( name ) {
375 return (name || '?' ). split ( / \s + / ). filter (Boolean). slice ( 0 , 2 ). map ( w => w[ 0 ]. toUpperCase ()). join ( '' );
376 }
377
378 function renderPersonCard ( person , company ) {
379 const c = company || {};
380 // Tolerate two YAML shapes: { links: { linkedin, x, github, ... } } or flat top-level keys.
381 const links = (person.links && typeof person.links === 'object' ) ? person.links : {
382 linkedin: person.linkedin || null ,
383 x: person.x || person.twitter || null ,
384 github: person.github || null ,
385 blog: person.blog || null ,
386 podcast: person.podcast || null ,
387 };
388 const linkPills = [ 'linkedin' , 'x' , 'github' , 'blog' , 'podcast' ]
389 . filter ( k => links[k])
390 . map ( k => `<a class="link-pill link-${ k }" href="${ escapeHtml ( links [ k ]) }" target="_blank" rel="noopener">${ k . toUpperCase () }</a>` )
391 . join ( ' ' );
392
393 const score = c.icp_fit_score || person.icp_fit_score || '?' ;
394 const band = icpBand (score);
395 // Fall back to body sections if subagents wrote ## Hook / ## DM Opener / etc. instead of YAML fields.
396 const hook = person.hook || extractSection (person.body, 'Hook' ) || '—' ;
397 const roleReason = person.role_reason || extractSection (person.body, 'Why the person' ) || '—' ;
398 const dmOpener = person.dm_opener || extractSection (person.body, 'DM Opener' ) || '' ;
399 const photo = person.image
400 ? `<img class="photo" src="${ escapeHtml ( person . image ) }" alt="${ escapeHtml ( person . name || '' ) }" loading="lazy" referrerpolicy="no-referrer" onerror="this.replaceWith(Object.assign(document.createElement('div'),{className:'photo photo-placeholder',textContent:'${ escapeJsInAttr ( initials ( person . name )) }'}))">`
401 : `<div class="photo photo-placeholder">${ escapeHtml ( initials ( person . name )) }</div>` ;
402
403 return `<div class="person-card" data-slug="${ escapeHtml ( person . slug ) }" data-company="${ escapeHtml (( person . company || '' ). toLowerCase ()) }" data-role="${ escapeHtml ( roleBucket ( person . title )) }" data-icpband="${ band }" data-icp-score="${ escapeHtml ( String ( score )) }">
404 ${ photo }
405 <div class="card-body">
406 <div class="card-header">
407 <h3>${ escapeHtml ( person . name || person . slug ) }</h3>
408 <span class="icp-badge icp-${ band }">ICP ${ escapeHtml ( String ( score )) }</span>
409 </div>
410 <div class="card-meta">${ escapeHtml ( person . title || '' ) }${ person . title && person . company ? ' · ' : ''}${ escapeHtml ( person . company || '' ) }</div>
411 ${ linkPills ? `<div class="card-links">${ linkPills }</div>` : ''}
412 <ul class="card-why">
413 ${ roleReason && roleReason !== '—' ? `<li><strong>Why the person:</strong> ${ escapeHtml ( roleReason ) }</li>` : ''}
414 ${ hook && hook !== '—' ? `<li><strong>Hook:</strong> ${ escapeHtml ( hook ) }</li>` : ''}
415 </ul>
416 <div class="card-actions">
417 <button class="btn-copy" data-clipboard="${ escapeAttr ( dmOpener ) }">Copy DM opener</button>
418 </div>
419 </div>
420 </div>` ;
421 }
422
423 // ----- Shared CSS for the new event-prospecting UI -------------------------
424
425 const eventCss = `
426 .nav-bar { display:flex; gap:0.5rem; margin-bottom:1.25rem; font-size:0.875rem; }
427 .nav-bar a { padding:0.4rem 0.85rem; border:1px solid var(--border); border-radius:4px; background:var(--card); color:var(--muted); font-weight:500; text-decoration:none; }
428 .nav-bar a.active { background:var(--brand); color:#fff; border-color:var(--brand); }
429 .filter-bar { display:flex; gap:0.75rem; flex-wrap:wrap; margin-bottom:1.25rem; align-items:center; }
430 .filter-group { display:flex; gap:0.4rem; flex-wrap:wrap; align-items:center; padding:0.4rem 0.6rem; background:var(--card); border:1px solid var(--border); border-radius:4px; }
431 .filter-group .label { font-size:0.7rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.05em; font-weight:600; margin-right:0.25rem; }
432 .chip { display:inline-block; padding:0.2rem 0.6rem; border:1px solid var(--border); border-radius:999px; background:#fafafa; font-size:0.7rem; color:var(--muted); cursor:pointer; user-select:none; }
433 .chip.active { background:var(--brand); color:#fff; border-color:var(--brand); }
434 .chip:hover { border-color:var(--brand); }
435 .person-grid { display:flex; flex-direction:column; gap:0.75rem; }
436 .person-card { background:var(--card); border:1px solid var(--border); border-radius:6px; padding:1rem 1.1rem; display:flex; flex-direction:row; gap:1rem; align-items:stretch; }
437 .person-card.hidden { display:none; }
438 .person-card .photo { width:96px; height:96px; flex:0 0 96px; border-radius:6px; object-fit:cover; background:#f0eeec; }
439 .person-card .photo-placeholder { display:flex; align-items:center; justify-content:center; font-weight:700; font-size:1.5rem; color:var(--muted); letter-spacing:0.04em; }
440 .card-body { flex:1; min-width:0; display:flex; flex-direction:column; gap:0.45rem; }
441 .card-header { display:flex; justify-content:space-between; align-items:flex-start; gap:0.5rem; }
442 .card-header h3 { font-size:1rem; font-weight:600; color:var(--black); margin:0; }
443 .company-groups { display:flex; flex-direction:column; gap:1.5rem; }
444 .company-group { background:transparent; }
445 .company-header { display:flex; flex-direction:column; gap:0.25rem; padding:0.5rem 0.1rem 0.75rem; border-bottom:1px solid var(--border); margin-bottom:0.75rem; }
446 .company-header-row { display:flex; align-items:center; gap:0.6rem; }
447 .company-header h2 { font-size:1.05rem; font-weight:600; color:var(--black); margin:0; }
448 .company-header .company-meta { font-size:0.75rem; color:var(--muted); margin:0; }
449 .company-header .company-fit { font-size:0.8125rem; color:var(--text); margin:0.15rem 0 0; }
450 .company-header a { color:var(--brand); text-decoration:none; }
451 .company-header a:hover { text-decoration:underline; }
452 .company-people { display:flex; flex-direction:column; gap:0.6rem; }
453 @media (max-width: 640px) {
454 .person-card { flex-direction:column; }
455 .person-card .photo { width:80px; height:80px; flex-basis:80px; }
456 }
457 .icp-badge { font-size:0.7rem; font-weight:700; padding:2px 8px; border-radius:3px; white-space:nowrap; }
458 .icp-badge.icp-high { background:rgba(144,201,77,0.14); color:#5a8a1a; }
459 .icp-badge.icp-mid { background:rgba(244,186,65,0.14); color:#9a7520; }
460 .icp-badge.icp-low { background:rgba(240,54,3,0.10); color:var(--low); }
461 .card-meta { font-size:0.8125rem; color:var(--muted); }
462 .card-links { display:flex; flex-wrap:wrap; gap:0.3rem; }
463 .link-pill { font-size:0.7rem; font-weight:600; padding:2px 8px; border-radius:3px; text-decoration:none; border:1px solid var(--border); color:var(--text); background:#fafafa; letter-spacing:0.04em; }
464 .link-pill:hover { background:var(--brand); color:#fff; border-color:var(--brand); }
465 .card-why { list-style:none; margin:0; padding:0; display:flex; flex-direction:column; gap:0.3rem; font-size:0.8125rem; color:var(--text); }
466 .card-why li { line-height:1.45; }
467 .card-why strong { color:var(--black); font-weight:600; }
468 .card-actions { display:flex; gap:0.5rem; margin-top:auto; padding-top:0.5rem; }
469 .card-actions button { font:inherit; font-size:0.75rem; font-weight:600; padding:0.4rem 0.7rem; border-radius:4px; border:1px solid var(--border); background:var(--card); color:var(--text); cursor:pointer; }
470 .card-actions button:hover { background:var(--brand); color:#fff; border-color:var(--brand); }
471 .card-actions button.copied { background:var(--high); color:#fff; border-color:var(--high); }
472 details.attendees { margin-top:0.4rem; }
473 details.attendees summary { cursor:pointer; color:var(--brand); font-size:0.8125rem; font-weight:500; }
474 details.attendees ul { margin:0.4rem 0 0 1rem; padding:0; list-style:disc; }
475 details.attendees li { font-size:0.8125rem; color:var(--text); margin-bottom:0.2rem; }
476 ` ;
477
478 const clipboardScript = `
479 <script>
480 document.addEventListener('click', e => {
481 const btn = e.target.closest('button[data-clipboard]');
482 if (!btn) return;
483 const text = btn.getAttribute('data-clipboard') || '';
484 if (navigator.clipboard && navigator.clipboard.writeText) {
485 navigator.clipboard.writeText(text).catch(() => {});
486 } else {
487 const ta = document.createElement('textarea');
488 ta.value = text;
489 document.body.appendChild(ta);
490 ta.select();
491 try { document.execCommand('copy'); } catch {}
492 ta.remove();
493 }
494 const orig = btn.textContent;
495 btn.classList.add('copied');
496 btn.textContent = 'Copied';
497 setTimeout(() => { btn.textContent = orig; btn.classList.remove('copied'); }, 1200);
498 });
499
500 // Filter chips (people.html)
501 document.addEventListener('click', e => {
502 const chip = e.target.closest('.chip');
503 if (!chip) return;
504 const group = chip.closest('.filter-group');
505 if (!group) return;
506 group.querySelectorAll('.chip').forEach(c => c.classList.remove('active'));
507 chip.classList.add('active');
508 applyFilters();
509 });
510
511 function applyFilters() {
512 const grid = document.querySelector('.person-grid');
513 if (!grid) return;
514 const active = {};
515 document.querySelectorAll('.filter-group').forEach(g => {
516 const key = g.dataset.filter;
517 const chip = g.querySelector('.chip.active');
518 active[key] = chip ? chip.dataset.value : '';
519 });
520 grid.querySelectorAll('.person-card').forEach(card => {
521 let show = true;
522 for (const k in active) {
523 const v = active[k];
524 if (!v) continue;
525 if ((card.dataset[k] || '') !== v) { show = false; break; }
526 }
527 card.classList.toggle('hidden', !show);
528 });
529 }
530 </script>` ;
531
532 // ----- Person grid + filter chips -----------------------------------------
533
534 function renderPeopleGrid ( personList ) {
535 if (personList. length === 0 ) {
536 return '<p style="color:var(--muted);">No people found.</p>' ;
537 }
538 return `<div class="person-grid">
539 ${ personList . map ( p => renderPersonCard ( p , p . _company )). join ( ' \n ' ) }
540 </div>` ;
541 }
542
543 // Index page: people grouped by their company, ordered by company ICP score desc.
544 // Companies with zero enriched people are skipped here (they still appear on companies.html).
545 function renderGroupedByCompany ( personList ) {
546 if (personList. length === 0 ) {
547 return '<p style="color:var(--muted);">No people found.</p>' ;
548 }
549 const groups = new Map ();
550 const unmatched = [];
551 for ( const p of personList) {
552 const c = p._company;
553 if ( ! c) { unmatched. push (p); continue ; }
554 const key = c.slug || (c.company_name || '' ). toLowerCase ();
555 if ( ! groups. has (key)) groups. set (key, { company: c, people: [] });
556 groups. get (key).people. push (p);
557 }
558 const ordered = [ ... groups. values ()]. sort (( a , b ) =>
559 ( parseInt (b.company.icp_fit_score) || 0 ) - ( parseInt (a.company.icp_fit_score) || 0 )
560 );
561
562 const sections = ordered. map (({ company , people : members }) => {
563 const score = company.icp_fit_score || '?' ;
564 const band = icpBand (score);
565 const hasDetail = company.body && company.body. length > 50 ;
566 const nameHtml = hasDetail
567 ? `<a href="companies/${ escapeHtml ( company . slug ) }.html">${ escapeHtml ( company . company_name ) }</a>`
568 : escapeHtml (company.company_name);
569 const websiteHtml = company.website
570 ? ` · <a href="${ escapeHtml ( company . website ) }" target="_blank" rel="noopener">${ escapeHtml ( company . website . replace ( / ^ https ? : \/\/ (www \. ) ? / , '' )) }</a>`
571 : '' ;
572 const metaBits = [
573 `${ members . length } speaker${ members . length === 1 ? '' : 's'}` ,
574 company.industry ? escapeHtml (company.industry) : null ,
575 ]. filter (Boolean). join ( ' · ' );
576 return `<section class="company-group" data-icpband="${ band }">
577 <header class="company-header">
578 <div class="company-header-row">
579 <h2>${ nameHtml }</h2>
580 <span class="icp-badge icp-${ band }">ICP ${ escapeHtml ( String ( score )) }</span>
581 </div>
582 <p class="company-meta">${ metaBits }${ websiteHtml }</p>
583 ${ company . icp_fit_reasoning ? `<p class="company-fit">${ escapeHtml ( company . icp_fit_reasoning ) }</p>` : ''}
584 </header>
585 <div class="company-people">
586 ${ members . map ( p => renderPersonCard ( p , company )). join ( ' \n ' ) }
587 </div>
588 </section>` ;
589 });
590
591 if (unmatched. length ) {
592 sections. push ( `<section class="company-group" data-icpband="low">
593 <header class="company-header">
594 <div class="company-header-row"><h2>Unmatched</h2></div>
595 <p class="company-meta">${ unmatched . length } speaker${ unmatched . length === 1 ? '' : 's'} without a resolved company file</p>
596 </header>
597 <div class="company-people">
598 ${ unmatched . map ( p => renderPersonCard ( p , null )). join ( ' \n ' ) }
599 </div>
600 </section>` );
601 }
602
603 return `<div class="company-groups"> \n ${ sections . join ( ' \n ' ) } \n </div>` ;
604 }
605
606 function uniqValues ( list , fn ) {
607 return [ ...new Set (list. map (fn). filter (Boolean))]. sort ();
608 }
609
610 // people.html filter chips: ICP band, role bucket, company.
611 // Activating a chip applies a single-value filter against the matching
612 // data-* attribute on each .person-card. Click handlers are in clipboardScript.
613 function renderFilterBar ( personList ) {
614 const compNames = uniqValues (personList, p => p.company);
615 const roles = uniqValues (personList, p => roleBucket (p.title));
616 const bands = [ 'high' , 'mid' , 'low' ];
617
618 const chip = ( val , label ) => `<span class="chip${ val === '' ? ' active' : ''}" data-value="${ escapeHtml ( val ) }">${ escapeHtml ( label ) }</span>` ;
619
620 const bandLabels = { high: 'High (8-10)' , mid: 'Mid (5-7)' , low: 'Low (1-4)' };
621
622 return `<div class="filter-bar">
623 <div class="filter-group" data-filter="icpband">
624 <span class="label">ICP</span>
625 ${ chip ( '' , 'All' ) }
626 ${ bands . map ( b => chip ( b , bandLabels [ b ])). join ( ' ' ) }
627 </div>
628 <div class="filter-group" data-filter="role">
629 <span class="label">Role</span>
630 ${ chip ( '' , 'All' ) }
631 ${ roles . map ( r => chip ( r , r )). join ( ' ' ) }
632 </div>
633 <div class="filter-group" data-filter="company">
634 <span class="label">Company</span>
635 ${ chip ( '' , 'All' ) }
636 ${ compNames . map ( c => chip ( c . toLowerCase (), c )). join ( ' ' ) }
637 </div>
638 </div>` ;
639 }
640
641 // ----- Companies table with attendees expandable ---------------------------
642
643 function renderCompaniesTable () {
644 // Group people by company slug or name (lowered) so each row can show its attendees.
645 const byCompany = new Map ();
646 for ( const p of people) {
647 const key = p._company ? (p._company.slug || (p._company.company_name || '' ). toLowerCase ()) : null ;
648 if ( ! key) continue ;
649 if ( ! byCompany. has (key)) byCompany. set (key, []);
650 byCompany. get (key). push (p);
651 }
652
653 return deduped. map ( c => {
654 const sc = scoreClass (c.icp_fit_score);
655 const hasDetail = c.body && c.body. length > 50 ;
656 const nameHtml = hasDetail
657 ? `<a href="companies/${ c . slug }.html">${ escapeHtml ( c . company_name ) }</a>`
658 : escapeHtml (c.company_name);
659 const websiteHtml = c.website
660 ? `<br><a href="${ escapeHtml ( c . website ) }" target="_blank" style="font-size:0.75rem;color:var(--muted);">${ escapeHtml ( c . website . replace ( / ^ https ? : \/\/ (www \. ) ? / , '' )) }</a>`
661 : '' ;
662 const key = c.slug || (c.company_name || '' ). toLowerCase ();
663 const attendees = byCompany. get (key) || [];
664 const attendeeBlock = attendees. length ? `
665 <details class="attendees">
666 <summary>${ attendees . length } attendee${ attendees . length === 1 ? '' : 's'}</summary>
667 <ul>${ attendees . map ( a => `<li><strong>${ escapeHtml ( a . name || a . slug ) }</strong>${ a . title ? ' — ' + escapeHtml ( a . title ) : ''}${ ( a . links && a . links . linkedin ) ? ` · <a href="${ escapeHtml ( a . links . linkedin ) }" target="_blank" rel="noopener">LinkedIn</a>` : ''}</li>` ). join ( '' ) }</ul>
668 </details>` : '' ;
669 return ` <tr>
670 <td><span class="score ${ sc }">${ escapeHtml ( c . icp_fit_score || '—' ) }</span></td>
671 <td>${ nameHtml }${ websiteHtml }${ attendeeBlock }</td>
672 <td style="max-width:200px;">${ escapeHtml ( c . product_description || '' ) }</td>
673 <td>${ escapeHtml ( c . industry || '' ) }</td>
674 <td class="reasoning">${ escapeHtml ( c . icp_fit_reasoning || '' ) }</td>
675 </tr>` ;
676 }). join ( ' \n ' );
677 }
678
679 // ----- Compose final pages -------------------------------------------------
680
681 const escapedTitle = escapeHtml (title);
682 const metaLine = `${ people . length } speakers · ${ deduped . length } companies · ${ new Date (). toLocaleDateString ( 'en-US' , { year: 'numeric' , month: 'long' , day: 'numeric' }) }` ;
683
684 const navHtml = ( active ) => `<div class="nav-bar">
685 <a href="index.html" class="${ active === 'index' ? 'active' : ''}">People</a>
686 <a href="people.html" class="${ active === 'people' ? 'active' : ''}">People (filterable)</a>
687 <a href="companies.html" class="${ active === 'companies' ? 'active' : ''}">Companies</a>
688 </div>` ;
689
690 function injectCss ( html ) {
691 return html. replace ( '</style>' , `${ eventCss } \n </style>` );
692 }
693
694 function injectScript ( html ) {
695 return html. replace ( '</body>' , `${ clipboardScript } \n </body>` );
696 }
697
698 function renderShell ( activeNav , contentHtml , pageTitle ) {
699 let html = template
700 . replace ( / \{\{ TITLE \}\} / g , escapeHtml (pageTitle))
701 . replace ( / \{\{ COMPANY_NAME \}\} / g , escapedTitle)
702 . replace ( / \{\{ META \}\} / g , metaLine)
703 . replace ( / \{\{ TOTAL \}\} / g , String (total))
704 . replace ( / \{\{ HIGH_COUNT \}\} / g , String (high))
705 . replace ( / \{\{ MEDIUM_COUNT \}\} / g , String (medium))
706 . replace ( / \{\{ LOW_COUNT \}\} / g , String (low))
707 . replace ( / \{\{ HIGH_PCT \}\} / g , String (highPct))
708 . replace ( / \{\{ MEDIUM_PCT \}\} / g , String (mediumPct))
709 . replace ( / \{\{ LOW_PCT \}\} / g , String (lowPct))
710 . replace ( / \{\{ TABLE_ROWS \}\} / g , () => '' );
711
712 // Replace the entire <table>...</table> block with our content. Use a
713 // function replacer so any `$` characters inside contentHtml (e.g. price
714 // strings, regex examples in person hooks) aren't interpreted as `$&` /
715 // `$1` / `$$` replacement patterns.
716 html = html. replace ( /<table class="results-table"> [\s\S] *? < \/ table>/ , () => `<div class="page-content">${ navHtml ( activeNav ) } \n ${ contentHtml }</div>` );
717
718 html = injectCss (html);
719 html = injectScript (html);
720 return html;
721 }
722
723 const indexHtml = renderShell ( 'index' , renderGroupedByCompany (people), `Event Prospecting — ${ title }` );
724 writeFileSync ( join (dir, 'index.html' ), indexHtml);
725
726 const peopleHtml = renderShell (
727 'people' ,
728 `${ renderFilterBar ( people ) } \n ${ renderPeopleGrid ( people ) }` ,
729 `People — ${ title }`
730 );
731 writeFileSync ( join (dir, 'people.html' ), peopleHtml);
732
733 const companiesContent = `<table class="results-table">
734 <thead>
735 <tr>
736 <th>Score</th>
737 <th>Company</th>
738 <th>Product</th>
739 <th>Industry</th>
740 <th>Fit Reasoning</th>
741 </tr>
742 </thead>
743 <tbody>
744 ${ renderCompaniesTable () }
745 </tbody>
746 </table>` ;
747 const companiesHtml = renderShell ( 'companies' , companiesContent, `Companies — ${ title }` );
748 writeFileSync ( join (dir, 'companies.html' ), companiesHtml);
749
750 // ----- Per-company detail pages -------------------------------------------
751
752 try { mkdirSync ( join (dir, 'companies' ), { recursive: true }); } catch {}
753
754 for ( const c of deduped) {
755 if ( ! c.body || c.body. length < 50 ) continue ;
756 const sc = scoreClass (c.icp_fit_score);
757 const bodyHtml = mdToHtml (c.body);
758
759 const companyHtml = `<!DOCTYPE html>
760 <html lang="en">
761 <head>
762 <meta charset="UTF-8">
763 <meta name="viewport" content="width=device-width, initial-scale=1.0">
764 <title>${ escapeHtml ( c . company_name ) } — Research</title>
765 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
766 <style>
767 :root { --brand:#F03603; --high:#90C94D; --medium:#F4BA41; --low:#F03603; --black:#100D0D; --gray:#514F4F; --border:#edebeb; --bg:#F9F6F4; --card:#ffffff; --text:#100D0D; --muted:#514F4F; }
768 * { margin:0; padding:0; box-sizing:border-box; }
769 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; }
770 .container { max-width:800px; margin:0 auto; padding:2rem 1.5rem; }
771 a { color:var(--brand); text-decoration:none; }
772 a:hover { text-decoration:underline; }
773 .back { font-size:0.875rem; color:var(--muted); margin-bottom:1.5rem; display:inline-block; }
774 .back:hover { color:var(--brand); }
775 header { margin-bottom:2rem; }
776 header h1 { font-size:1.5rem; font-weight:600; margin-bottom:0.25rem; }
777 header .meta { color:var(--muted); font-size:0.875rem; }
778 .score-badge { display:inline-block; font-size:0.875rem; font-weight:700; padding:4px 14px; border-radius:4px; margin-right:0.75rem; }
779 .score-badge.high { background:rgba(144,201,77,0.12); color:#5a8a1a; border:1px solid rgba(144,201,77,0.3); }
780 .score-badge.medium { background:rgba(244,186,65,0.12); color:#9a7520; border:1px solid rgba(244,186,65,0.3); }
781 .score-badge.low { background:rgba(240,54,3,0.08); color:var(--low); border:1px solid rgba(240,54,3,0.2); }
782 .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; }
783 .fields dt { color:var(--muted); font-weight:500; }
784 .fields dd { color:var(--text); }
785 .research { background:var(--card); border:1px solid var(--border); border-radius:4px; padding:1.5rem; }
786 .research h2 { font-size:1.125rem; font-weight:600; margin:1.5rem 0 0.5rem 0; color:var(--black); }
787 .research h2:first-child { margin-top:0; }
788 .research p { margin-bottom:0.75rem; }
789 .research ul { margin:0.5rem 0 1rem 1.25rem; }
790 .research li { margin-bottom:0.375rem; font-size:0.875rem; }
791 .confidence { font-size:0.75rem; font-weight:600; padding:1px 6px; border-radius:2px; }
792 .confidence.high { background:rgba(144,201,77,0.12); color:#5a8a1a; }
793 .confidence.medium { background:rgba(244,186,65,0.12); color:#9a7520; }
794 .confidence.low { background:rgba(240,54,3,0.08); color:var(--low); }
795 footer { margin-top:3rem; padding-top:1.5rem; border-top:1px solid var(--border); text-align:center; font-size:0.75rem; color:var(--muted); }
796 </style>
797 </head>
798 <body>
799 <div class="container">
800 <a href="../index.html" class="back">← Back to overview</a>
801 <header>
802 <h1>${ escapeHtml ( c . company_name ) }</h1>
803 <div class="meta">
804 <span class="score-badge ${ sc }">ICP Score: ${ escapeHtml ( c . icp_fit_score || '—' ) }</span>
805 ${ c . website ? `<a href="${ escapeHtml ( c . website ) }" target="_blank">${ escapeHtml ( c . website ) }</a>` : ''}
806 </div>
807 </header>
808 <dl class="fields">
809 ${ c . product_description ? `<dt>Product</dt><dd>${ escapeHtml ( c . product_description ) }</dd>` : ''}
810 ${ c . industry ? `<dt>Industry</dt><dd>${ escapeHtml ( c . industry ) }</dd>` : ''}
811 ${ c . target_audience ? `<dt>Target Audience</dt><dd>${ escapeHtml ( c . target_audience ) }</dd>` : ''}
812 ${ c . key_features ? `<dt>Key Features</dt><dd>${ escapeHtml ( c . key_features ) }</dd>` : ''}
813 ${ c . employee_estimate ? `<dt>Employees</dt><dd>${ escapeHtml ( c . employee_estimate ) }</dd>` : ''}
814 ${ c . funding_info ? `<dt>Funding</dt><dd>${ escapeHtml ( c . funding_info ) }</dd>` : ''}
815 ${ c . headquarters ? `<dt>HQ</dt><dd>${ escapeHtml ( c . headquarters ) }</dd>` : ''}
816 ${ c . icp_fit_reasoning ? `<dt>Fit Reasoning</dt><dd>${ escapeHtml ( c . icp_fit_reasoning ) }</dd>` : ''}
817 </dl>
818 <div class="research">
819 ${ bodyHtml }
820 </div>
821 </div>
822 <footer>Generated by <a href="https://github.com/anthropics/skills">event-prospecting</a> · Powered by <a href="https://browserbase.com">Browserbase</a></footer>
823 </body>
824 </html>` ;
825
826 writeFileSync ( join (dir, 'companies' , `${ c . slug }.html` ), companyHtml);
827 }
828
829 // ----- CSV ----------------------------------------------------------------
830
831 // One row per speaker — this is the spreadsheet the AE imports into outbound
832 // tooling, so it has to answer "who do I reach out to", not "what companies
833 // attended". Company-level fields (ICP score, fit reasoning, website, industry)
834 // are joined in from the resolved company file.
835 const personCols = [
836 'name' , 'title' , 'company' , 'icp_fit_score' ,
837 'linkedin' , 'x' , 'github' , 'blog' , 'podcast' ,
838 'hook' , 'role_reason' , 'dm_opener' ,
839 'icp_fit_reasoning' , 'company_website' , 'company_industry' ,
840 'person_slug' , 'company_slug' , 'image' ,
841 ];
842
843 function personRow ( p ) {
844 const c = p._company || {};
845 const links = (p.links && typeof p.links === 'object' ) ? p.links : {
846 linkedin: p.linkedin || null ,
847 x: p.x || p.twitter || null ,
848 github: p.github || null ,
849 blog: p.blog || null ,
850 podcast: p.podcast || null ,
851 };
852 const score = c.icp_fit_score || p.icp_fit_score || '' ;
853 return {
854 name: p.name || '' ,
855 title: p.title || '' ,
856 company: p.company || c.company_name || '' ,
857 icp_fit_score: score,
858 linkedin: links.linkedin || '' ,
859 x: links.x || '' ,
860 github: links.github || '' ,
861 blog: links.blog || '' ,
862 podcast: links.podcast || '' ,
863 hook: p.hook || extractSection (p.body, 'Hook' ) || '' ,
864 role_reason: p.role_reason || extractSection (p.body, 'Why the person' ) || '' ,
865 dm_opener: p.dm_opener || extractSection (p.body, 'DM Opener' ) || '' ,
866 icp_fit_reasoning: c.icp_fit_reasoning || '' ,
867 company_website: c.website || '' ,
868 company_industry: c.industry || '' ,
869 person_slug: p.slug || '' ,
870 company_slug: c.slug || '' ,
871 image: p.image || '' ,
872 };
873 }
874
875 function csvEscape ( v ) {
876 if (v == null ) return '' ;
877 const s = typeof v === 'object' ? JSON . stringify (v) : String (v);
878 // Quote on any RFC 4180 record-/field-terminator: comma, quote, LF, or bare CR.
879 if (s. includes ( ',' ) || s. includes ( '"' ) || s. includes ( ' \n ' ) || s. includes ( ' \r ' )) return '"' + s. replace ( /"/ g , '""' ) + '"' ;
880 return s;
881 }
882
883 const csvLines = [personCols. join ( ',' )];
884 for ( const p of people) {
885 const row = personRow (p);
886 csvLines. push (personCols. map ( c => csvEscape (row[c])). join ( ',' ));
887 }
888 writeFileSync ( join (dir, 'results.csv' ), csvLines. join ( ' \n ' ) + ' \n ' );
889
890 // ----- Summary ------------------------------------------------------------
891
892 console. error ( JSON . stringify ({
893 total_companies: deduped. length ,
894 total_people: people. length ,
895 high_fit: high,
896 medium_fit: medium,
897 low_fit: low,
898 files_generated: {
899 index: join (dir, 'index.html' ),
900 people: join (dir, 'people.html' ),
901 companies: join (dir, 'companies.html' ),
902 company_pages: deduped. filter ( c => c.body && c.body. length > 50 ). length ,
903 csv: join (dir, 'results.csv' )
904 }
905 }, null , 2 ));
906
907 console. log ( join (dir, 'index.html' ));
908
909 if (shouldOpen) {
910 const { execSync } = await import ( 'child_process' );
911 try { execSync ( `open "${ join ( dir , 'index.html' ) }"` ); } catch {}
912 }