Setting the file. One moment.
Compile Report · Competitor Analysis · browserbase/skills · Skills Docs
ContentsBack to the top of the page function truncate
— line 312
This file
Number 8.8
Position 8 of 16
Type JavaScript
Size 46 KB
Lines 929 scripts/ compile_report.mjs
JavaScript · 929 lines · 46 KB
12
import
{ parseFrontmatter, parseBody, parseSections }
from
'./md_utils.mjs'
;
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> [--user-company "<name>"] [--template <path>] [--open]
21
22 Reads all .md files from <research-dir>, generates:
23 - index.html — overview: competitor table with tagline, pricing, features, strategic diff
24 - competitors/<slug>.html — per-competitor deep dive pages
25 - matrix.html — side-by-side feature/pricing grid across competitors
26 - mentions.html — chronological feed of all external mentions with source-type filter
27 - results.csv — flat spreadsheet
28
29 Options:
30 --user-company <name> Name of the user's company (used in comparison sections)
31 --template <path> Path to report-template.html (default: auto-detect)
32 --open Open index.html in the default browser after generation
33 --help, -h Show this help message` );
34 process. exit (args. includes ( '--help' ) || args. includes ( '-h' ) ? 0 : 1 );
35 }
36
37 const dir = args[ 0 ];
38 const shouldOpen = args. includes ( '--open' );
39 const userCompanyIdx = args. indexOf ( '--user-company' );
40 const userCompany = userCompanyIdx !== - 1 ? args[userCompanyIdx + 1 ] : '' ;
41 const templateIdx = args. indexOf ( '--template' );
42 let templatePath = templateIdx !== - 1 ? args[templateIdx + 1 ] : null ;
43
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 let files;
59 try {
60 files = readdirSync (dir). filter ( f => f. endsWith ( '.md' )). sort ();
61 } catch (err) {
62 console. error ( `Error reading directory ${ dir }: ${ err . message }` );
63 process. exit ( 1 );
64 }
65
66 if (files. length === 0 ) {
67 console. error ( `No .md files found in ${ dir }` );
68 process. exit ( 1 );
69 }
70
71 // ---------- Parsing ----------
72
73 // parseFrontmatter, parseBody, parseSections imported from md_utils.mjs
74
75 // Normalize subagent-invented source types onto the canonical taxonomy so the mentions
76 // feed CSS has a pill class for every entry. Observed drift: HackerNews→HN, VendorBlog→Blog,
77 // CompetitorBlog→Blog, GitHubIssue→Blog, Twitter→X. Unknown types fall back to "Blog" to
78 // guarantee styled rendering (catch-all). Also handles free-text leaking into the bracket
79 // slot (e.g. "Browsaur Blog — ..." — sourceType becomes "Blog" if we can find that token).
80 function normalizeSourceType ( raw ) {
81 if ( ! raw) return 'Blog' ;
82 const t = raw. trim ();
83 const canonical = new Set ([
84 'Benchmark' , 'Comparison' , 'News' , 'Reddit' , 'HN' , 'LinkedIn' , 'YouTube' ,
85 'Review' , 'Podcast' , 'X' , 'DevTo' , 'Hashnode' , 'Substack' , 'Blog'
86 ]);
87 if (canonical. has (t)) return t;
88 // Alias table for common drifts
89 const aliases = {
90 'Hacker News' : 'HN' , 'HackerNews' : 'HN' , 'Show HN' : 'HN' , 'Ask HN' : 'HN' ,
91 'Twitter' : 'X' ,
92 'Vendor Blog' : 'Blog' , 'VendorBlog' : 'Blog' ,
93 'Competitor Blog' : 'Blog' , 'CompetitorBlog' : 'Blog' ,
94 'GitHub Issue' : 'Blog' , 'GitHubIssue' : 'Blog' , 'GitHub' : 'Blog' ,
95 'Documentation' : 'Blog' , 'Docs' : 'Blog' ,
96 'Medium' : 'Blog' , 'Substack Post' : 'Substack' ,
97 };
98 if (aliases[t]) return aliases[t];
99 // Keyword scan — if the raw contains a canonical token anywhere, use that.
100 for ( const c of canonical) {
101 if ( new RegExp ( ` \\ b${ c } \\ b` , 'i' ). test (t)) return c;
102 }
103 return 'Blog' ; // catch-all for fully unknown types (styled via .src-Blog)
104 }
105
106 // Parse Mentions section into structured entries.
107 // Format: `- **[SourceType]** Title | Snippet (source: URL, YYYY-MM-DD)`
108 function parseMentions ( sectionText ) {
109 if ( ! sectionText) return [];
110 const out = [];
111 for ( const raw of sectionText. split ( ' \n ' )) {
112 const line = raw. trim ();
113 if ( ! line. startsWith ( '- ' )) continue ;
114 const typeM = line. match ( / ^ - \s * \*\*\[ ( [ ^ \] ] + ) \]\*\* \s * ( . * ) $ / );
115 if ( ! typeM) continue ;
116 const sourceType = normalizeSourceType (typeM[ 1 ]. trim ());
117 let rest = typeM[ 2 ];
118
119 let url = '' ;
120 let date = '' ;
121 const sourceM = rest. match ( / \( source: \s * ( [ ^ )] + ) \) \s *$ / );
122 if (sourceM) {
123 const sourceBlock = sourceM[ 1 ];
124 const parts = sourceBlock. split ( ',' ). map ( s => s. trim ()). filter (Boolean);
125 url = parts[ 0 ] || '' ;
126 const dateCandidate = parts. slice ( 1 ). join ( ', ' );
127 if (dateCandidate && / \d {4} - \d {2} - \d {2} / . test (dateCandidate)) date = dateCandidate. match ( / \d {4} - \d {2} - \d {2} / )[ 0 ];
128 rest = rest. slice ( 0 , sourceM.index). trim ();
129 }
130
131 let title = rest;
132 let snippet = '' ;
133 const pipeIdx = rest. indexOf ( '|' );
134 if (pipeIdx !== - 1 ) {
135 title = rest. slice ( 0 , pipeIdx). trim ();
136 snippet = rest. slice (pipeIdx + 1 ). trim ();
137 }
138
139 out. push ({ sourceType, title, snippet, url, date });
140 }
141 return out;
142 }
143
144 // Parse Benchmarks section into structured entries.
145 // Format: `- Title | Source | URL | Key finding` or `- **Title** — Source (URL): finding`
146 function parseBenchmarks ( sectionText ) {
147 if ( ! sectionText) return [];
148 const out = [];
149 for ( const raw of sectionText. split ( ' \n ' )) {
150 const line = raw. trim ();
151 if ( ! line. startsWith ( '- ' )) continue ;
152 const rest = line. slice ( 2 ). trim ();
153 const parts = rest. split ( '|' ). map ( s => s. trim ()). filter (Boolean);
154 let title = '' , source = '' , url = '' , finding = '' ;
155 if (parts. length >= 4 ) {
156 [title, source, url, finding] = parts;
157 } else if (parts. length === 3 ) {
158 [title, url, finding] = parts;
159 } else {
160 title = rest;
161 const urlM = rest. match ( /https ? : \/\/ \S + / );
162 if (urlM) url = urlM[ 0 ];
163 }
164 out. push ({ title, source, url, finding });
165 }
166 return out;
167 }
168
169 function splitPipes ( s ) {
170 return (s || '' ). split ( '|' ). map ( x => x. trim ()). filter (Boolean);
171 }
172
173 function escapeHtml ( str ) {
174 return (str || '' ). replace ( /&/ g , '&' ). replace ( /</ g , '<' ). replace ( />/ g , '>' ). replace ( /"/ g , '"' );
175 }
176
177 function mdToHtml ( md ) {
178 const lines = md. split ( ' \n ' );
179 const out = [];
180 let inList = false ;
181 let paraLines = [];
182
183 function flushPara () {
184 if (paraLines. length > 0 ) {
185 let text = escapeHtml (paraLines. join ( ' ' ). trim ());
186 text = text. replace ( / \*\*\[ ( \w + ) \]\*\* / g , '<span class="confidence $1">[$1]</span>' );
187 text = text. replace ( / \*\* ( [ ^ *] + ) \*\* / g , '<strong>$1</strong>' );
188 if (text) out. push ( `<p>${ text }</p>` );
189 paraLines = [];
190 }
191 }
192 function closeList () { if (inList) { out. push ( '</ul>' ); inList = false ; } }
193
194 for ( const line of lines) {
195 const trimmed = line. trim ();
196 if ( ! trimmed) { flushPara (); closeList (); continue ; }
197 if (trimmed. startsWith ( '## ' )) { flushPara (); closeList (); out. push ( `<h2>${ escapeHtml ( trimmed . slice ( 3 )) }</h2>` ); continue ; }
198 if (trimmed. startsWith ( '### ' )) { flushPara (); closeList (); out. push ( `<h3>${ escapeHtml ( trimmed . slice ( 4 )) }</h3>` ); continue ; }
199 if (trimmed. startsWith ( '- ' )) {
200 flushPara ();
201 if ( ! inList) { out. push ( '<ul>' ); inList = true ; }
202 let text = escapeHtml (trimmed. slice ( 2 ));
203 text = text. replace ( / \*\*\[ ( \w + ) \]\*\* / g , '<span class="confidence $1">[$1]</span>' );
204 text = text. replace ( / \*\* ( [ ^ *] + ) \*\* / g , '<strong>$1</strong>' );
205 text = text. replace ( /(https ? : \/\/ \S + )/ g , ( _ , raw ) => {
206 let url = raw;
207 let trail = '' ;
208 while (url && / [) \] ,.;:!?] $ / . test (url)) {
209 trail = url. slice ( - 1 ) + trail;
210 url = url. slice ( 0 , - 1 );
211 }
212 if ( ! url) return raw;
213 return `<a href="${ url }" target="_blank">${ url }</a>${ trail }` ;
214 });
215 out. push ( `<li>${ text }</li>` );
216 continue ;
217 }
218 closeList ();
219 paraLines. push (trimmed);
220 }
221 flushPara (); closeList ();
222 return out. join ( ' \n ' );
223 }
224
225 // ---------- Load all competitor records ----------
226
227 const competitors = [];
228 for ( const file of files) {
229 const content = readFileSync ( join (dir, file), 'utf-8' );
230 const fields = parseFrontmatter (content);
231 if ( ! fields) continue ;
232 const body = parseBody (content);
233 const sections = parseSections (body);
234 const mentions = parseMentions (sections[ 'Mentions' ]);
235 const benchmarks = parseBenchmarks (sections[ 'Benchmarks' ]);
236 const slug = file. replace ( '.md' , '' );
237 competitors. push ({ ... fields, body, sections, mentions, benchmarks, slug, file });
238 }
239
240 // Deduplicate by normalized competitor name (keep first occurrence — richer data tends to come first alphabetically)
241 // The `\b` word boundary before the suffix group is load-bearing: without it the regex would
242 // strip "co" from inside names like "Cisco" or "Costco" (`\s*` matches zero chars), corrupting
243 // the dedup key and silently dropping legit competitors.
244 const seen = new Map ();
245 for ( const c of competitors) {
246 const name = (c.competitor_name || '' ). toLowerCase (). replace ( / \s *\b (inc | llc | ltd | corp | co) \b \s * \. ?$ / i , '' ). trim ();
247 if ( ! seen. has (name)) seen. set (name, c);
248 }
249 const deduped = [ ... seen. values ()]. sort (( a , b ) => (a.competitor_name || '' ). localeCompare (b.competitor_name || '' ));
250
251 // Load the curated matrix EARLY — the overview table needs userCompany.name to filter the
252 // user's own company out of the competitor list, and the strategic summary card needs the
253 // whole matrix. Keep this block above the first use site to avoid temporal dead zones.
254 let curatedMatrix = null ;
255 try {
256 const p = join (dir, 'matrix.json' );
257 if ( existsSync (p)) curatedMatrix = JSON . parse ( readFileSync (p, 'utf-8' ));
258 } catch (err) {
259 console. error ( `Warning: matrix.json present but unreadable — falling back to pipe split. ${ err . message }` );
260 }
261
262 // Filter the user's own company out before computing any "competitor" totals or rendering
263 // any view. matrix.json's userCompany.name wins; fall back to the --user-company CLI arg.
264 // Match case-insensitively against competitor_name AND slug. EVERY downstream loop that
265 // represents "the competitor set" (matrix.html columns, mentions feed, totals, strategic
266 // summary, per-competitor pages, CSV) must iterate `competitorRows`, not `deduped` —
267 // otherwise the user appears as a phantom column with all-false features.
268 const userCompanyName = (curatedMatrix && curatedMatrix.userCompany && curatedMatrix.userCompany.name) || userCompany || '' ;
269 // Normalize before comparing so legal/DBA drift ("Exa, Inc." in matrix.json vs slug `exa`)
270 // still excludes the user's own file. Strip a trailing corporate suffix then all non-alphanumerics.
271 // We deliberately do NOT fuzzy-match — exclusion still requires an exact normalized-equality, so a
272 // real competitor is only dropped if its normalized name/slug is identical to the user's.
273 const normKey = s => (s || '' ). toLowerCase (). replace ( / \s *\b (inc | llc | ltd | corp | co) \b \s * \. ?$ / i , '' ). replace ( / [ ^ a-z0-9] / g , '' );
274 const userKey = normKey (userCompanyName);
275 // Slug compare strips punctuation only (no suffix strip) so `rival-co` isn't reduced to `rival`.
276 const slugKey = s => (s || '' ). toLowerCase (). replace ( / [ ^ a-z0-9] / g , '' );
277 const userSlugKey = slugKey (userCompanyName);
278 const competitorRows = deduped. filter ( c => {
279 if ( ! userKey) return true ;
280 const nameKey = normKey (c.competitor_name);
281 const sKey = slugKey (c.slug);
282 return nameKey !== userKey && sKey !== userKey && sKey !== userSlugKey;
283 });
284
285 // ---------- Aggregates ----------
286
287 const totalMentions = competitorRows. reduce (( sum , c ) => sum + c.mentions. length , 0 );
288 const totalBenchmarks = competitorRows. reduce (( sum , c ) => sum + c.benchmarks. length , 0 );
289 const withPricing = competitorRows. filter ( c => c.pricing_tiers). length ;
290
291 const dirName = dir. split ( '/' ). pop ();
292 const title = dirName. replace ( /_/ g , ' ' ). replace ( /-/ g , ' ' ). replace ( / \b \w / g , c => c. toUpperCase ());
293 const genDate = new Date (). toLocaleDateString ( 'en-US' , { year: 'numeric' , month: 'long' , day: 'numeric' });
294 const metaLine = `${ competitorRows . length } competitors · ${ totalMentions } mentions · ${ totalBenchmarks } benchmarks · ${ genDate }` ;
295
296 // ---------- index.html (overview) ----------
297
298 function featurePills ( featuresStr , max = 4 ) {
299 // key_features is supposed to be pipe-separated but subagents drift into prose.
300 // If no pipes are present, split on commas as a fallback so we still show something
301 // and cap item length to avoid bleeding wall-of-text into the table.
302 let feats = splitPipes (featuresStr);
303 if (feats. length <= 1 && featuresStr) {
304 feats = featuresStr. split ( / [;,] / ). map ( s => s. trim ()). filter (Boolean);
305 }
306 return feats. slice ( 0 , max). map ( f => {
307 const short = f. length > 42 ? f. slice ( 0 , 40 ). replace ( / \s + \S *$ / , '' ) + '…' : f;
308 return `<span class="pill pill-feature">${ escapeHtml ( short ) }</span>` ;
309 }). join ( '' );
310 }
311
312 function truncate ( str , n ) {
313 if ( ! str) return '' ;
314 if (str. length <= n) return str;
315 return str. slice ( 0 , n - 1 ). replace ( / \s + \S *$ / , '' ) + '…' ;
316 }
317
318 const tableRows = competitorRows. map ( c => {
319 const hasDetail = c.body && c.body. length > 50 ;
320 const nameHtml = hasDetail
321 ? `<a href="competitors/${ c . slug }.html">${ escapeHtml ( c . competitor_name ) }</a>`
322 : escapeHtml (c.competitor_name);
323 const websiteHtml = c.website
324 ? `<span class="muted-line"><a href="${ escapeHtml ( c . website ) }" target="_blank" style="color:var(--muted);">${ escapeHtml ( c . website . replace ( / ^ https ? : \/\/ (www \. ) ? / , '' )) }</a></span>`
325 : '' ;
326 // Pricing: prefer pipe-split summary; if there are no pipes (prose drift), truncate hard.
327 let pricingShort = splitPipes (c.pricing_tiers). slice ( 0 , 3 ). join ( ' · ' );
328 if ( ! pricingShort) pricingShort = truncate (c.pricing_tiers || '' , 140 ) || '—' ;
329 return ` <tr>
330 <td><strong>${ nameHtml }</strong>${ websiteHtml }</td>
331 <td style="max-width:260px;">${ escapeHtml ( truncate ( c . tagline || c . positioning || c . product_description || '' , 140 )) }</td>
332 <td style="max-width:180px;">${ escapeHtml ( pricingShort ) }</td>
333 <td style="max-width:260px;">${ featurePills ( c . key_features ) }</td>
334 <td class="muted-line" style="max-width:260px;color:var(--muted);font-size:0.8125rem;">${ escapeHtml ( truncate ( c . strategic_diff || '' , 160 )) }</td>
335 </tr>` ;
336 }). join ( ' \n ' );
337
338 // curatedMatrix was loaded earlier (before the overview table renderer needed userCompany.name).
339 // Keeping this comment as a marker for the matrix-axis functions below.
340
341 // Strategic summary — "Where are you winning?" / "Where are you losing?"
342 // Requires matrix.json to carry a `userCompany` entry with feature flags. We then
343 // compare the user's flag per feature against how many competitors also have it.
344 // - Winning: user has the feature + at most 1 competitor has it (differentiated).
345 // - Losing: user LACKS the feature + 3 or more competitors have it (common gap).
346 // If userCompany is absent we render nothing — a skill run that skipped Step 5's
347 // matrix synthesis shouldn't get a broken/empty block here.
348 function buildStrategicSummary () {
349 if ( ! curatedMatrix || ! curatedMatrix.userCompany) return '' ;
350 const user = curatedMatrix.userCompany;
351 const userName = user.name || userCompany || 'You' ;
352 const userEsc = escapeHtml (userName);
353
354 function analyze ( kind ) {
355 const axis = curatedMatrix[kind] || [];
356 const compMap = curatedMatrix.competitors || {};
357 const userFlags = user[kind] || {};
358 const wins = [];
359 const losses = [];
360 for ( const entry of axis) {
361 const label = entry.name;
362 const userHas = !! userFlags[label];
363 const whoElseHas = [];
364 for ( const c of competitorRows) {
365 const compEntry = compMap[c.slug];
366 if (compEntry && compEntry[kind] && compEntry[kind][label]) whoElseHas. push (c.competitor_name);
367 }
368 const competitorCount = whoElseHas. length ;
369 if (userHas && competitorCount <= 1 ) {
370 wins. push ({ label, whoElseHas });
371 } else if ( ! userHas && competitorCount >= 3 ) {
372 losses. push ({ label, whoElseHas });
373 }
374 }
375 // Order wins by rarity (fewest competitors have it first → most differentiated).
376 wins. sort (( a , b ) => a.whoElseHas. length - b.whoElseHas. length );
377 // Order losses by how many competitors have it (more = bigger gap).
378 losses. sort (( a , b ) => b.whoElseHas. length - a.whoElseHas. length );
379 return { wins, losses };
380 }
381
382 const featureAnalysis = analyze ( 'features' );
383 const integrationAnalysis = analyze ( 'integrations' );
384 const allWins = [ ... featureAnalysis.wins, ... integrationAnalysis.wins];
385 const allLosses = [ ... featureAnalysis.losses, ... integrationAnalysis.losses];
386
387 function renderList ( items , emptyMessage ) {
388 if ( ! items. length ) return `<div class="empty">${ escapeHtml ( emptyMessage ) }</div>` ;
389 return `<ul>${ items . slice ( 0 , 10 ). map ( it => {
390 const n = it.whoElseHas. length ;
391 const who = n === 0 ? 'only you' : (n <= 3 ? it.whoElseHas. join ( ', ' ) : `${ n } competitors` );
392 return `<li><span class="label">${ escapeHtml ( it . label ) }</span><span class="who">${ escapeHtml ( who ) }</span></li>` ;
393 } ). join ( '' ) }</ul>` ;
394 }
395
396 // Prefer the analyst-written prose from matrix.json when present — reads as narrative,
397 // not a spreadsheet. Falls back to the bulleted list when no prose is provided so a
398 // skill run that skipped the prose step still surfaces the boolean comparison.
399 function renderBody ( prose , items , emptyMessage ) {
400 if (prose && prose. trim ()) return `<p class="prose">${ escapeHtml ( prose ) }</p>` ;
401 return renderList (items, emptyMessage);
402 }
403
404 // The badge counts the boolean-heuristic list. When analyst prose is shown instead of that
405 // list, the count can be 0 or unrelated to the prose — so only show the badge when the list
406 // is what's actually rendered.
407 const winBadge = (user.winningSummary && user.winningSummary. trim ()) ? '' : ` <span class="badge win">${ allWins . length }</span>` ;
408 const lossBadge = (user.losingSummary && user.losingSummary. trim ()) ? '' : ` <span class="badge loss">${ allLosses . length }</span>` ;
409
410 return `<div class="strategic">
411 <div class="card win">
412 <h3>Where ${ userEsc } is winning${ winBadge }</h3>
413 ${ user . winningSummary ? '' : `<div class="sub">Features and integrations ${ userEsc } has that 0–1 competitors match.</div>`}
414 ${ renderBody ( user . winningSummary , allWins , 'No clear differentiators found — user has no unique features in the current taxonomy.' ) }
415 </div>
416 <div class="card loss">
417 <h3>Where ${ userEsc } is losing${ lossBadge }</h3>
418 ${ user . losingSummary ? '' : `<div class="sub">Features and integrations ${ userEsc } lacks that 3+ competitors have.</div>`}
419 ${ renderBody ( user . losingSummary , allLosses , 'No major gaps found — user keeps up on table-stakes features.' ) }
420 </div>
421 </div>` ;
422 }
423
424 const strategicSummary = buildStrategicSummary ();
425
426 let indexHtml = template
427 . replace ( / \{\{ TITLE \}\} / g , escapeHtml ( `${ title }` ))
428 . replace ( / \{\{ META \}\} / g , escapeHtml (metaLine))
429 . replace ( / \{\{ TOTAL \}\} / g , String (competitorRows. length ))
430 . replace ( / \{\{ MENTION_COUNT \}\} / g , String (totalMentions))
431 . replace ( / \{\{ BENCHMARK_COUNT \}\} / g , String (totalBenchmarks))
432 . replace ( / \{\{ WITH_PRICING \}\} / g , String (withPricing))
433 . replace ( / \{\{ STRATEGIC_SUMMARY \}\} / g , strategicSummary)
434 . replace ( / \{\{ TABLE_ROWS \}\} / g , tableRows);
435
436 writeFileSync ( join (dir, 'index.html' ), indexHtml);
437
438 // ---------- competitors/{slug}.html ----------
439
440 try { mkdirSync ( join (dir, 'competitors' ), { recursive: true }); } catch {}
441
442 const perCompetitorCss = `
443 :root { --brand:#F03603; --blue:#4DA9E4; --black:#100D0D; --gray:#514F4F; --border:#edebeb; --bg:#F9F6F4; --card:#ffffff; --text:#100D0D; --muted:#514F4F; }
444 * { margin:0; padding:0; box-sizing:border-box; }
445 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; }
446 .container { max-width:880px; margin:0 auto; padding:2rem 1.5rem; }
447 a { color:var(--brand); text-decoration:none; }
448 a:hover { text-decoration:underline; }
449 .back { font-size:0.875rem; color:var(--muted); margin-bottom:1.5rem; display:inline-block; }
450 .back:hover { color:var(--brand); }
451 header { margin-bottom:2rem; }
452 header h1 { font-size:1.5rem; font-weight:600; margin-bottom:0.25rem; }
453 header .meta { color:var(--muted); font-size:0.875rem; }
454 .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; }
455 .fields dt { color:var(--muted); font-weight:500; }
456 .fields dd { color:var(--text); }
457 .research { background:var(--card); border:1px solid var(--border); border-radius:4px; padding:1.5rem; margin-bottom:1.25rem; }
458 .research h2 { font-size:1.125rem; font-weight:600; margin:1.5rem 0 0.5rem 0; color:var(--black); }
459 .research h2:first-child { margin-top:0; }
460 .research h3 { font-size:0.9375rem; font-weight:600; margin:1rem 0 0.375rem 0; color:var(--black); }
461 .research p { margin-bottom:0.75rem; }
462 .research ul { margin:0.5rem 0 1rem 1.25rem; }
463 .research li { margin-bottom:0.375rem; font-size:0.875rem; }
464 .research.battle { border-left:3px solid var(--brand); }
465 .research.battle h2 { color:var(--brand); }
466 .research.battle h3 { text-transform:uppercase; letter-spacing:0.04em; font-size:0.75rem; color:var(--muted); margin-top:1.25rem; }
467 .confidence { font-size:0.75rem; font-weight:600; padding:1px 6px; border-radius:2px; }
468 .confidence.high { background:rgba(144,201,77,0.12); color:#5a8a1a; }
469 .confidence.medium { background:rgba(244,186,65,0.12); color:#9a7520; }
470 .confidence.low { background:rgba(240,54,3,0.08); color:var(--brand); }
471 .mention-item { display:flex; gap:0.5rem; align-items:flex-start; padding:0.5rem 0; border-bottom:1px solid var(--border); font-size:0.875rem; }
472 .mention-item:last-child { border-bottom:none; }
473 .src-pill { font-size:0.6875rem; font-weight:600; padding:2px 8px; border-radius:999px; white-space:nowrap; border:1px solid; }
474 .src-Benchmark { background:rgba(77,169,228,0.12); color:#2172a3; border-color:rgba(77,169,228,0.4); }
475 .src-Comparison { background:rgba(240,54,3,0.10); color:var(--brand); border-color:rgba(240,54,3,0.4); }
476 .src-News { background:#f2f2f2; color:var(--black); border-color:#ddd; }
477 .src-Reddit { background:#fff2eb; color:#d84300; border-color:#ffd4b7; }
478 .src-HN { background:#fff4e5; color:#c95500; border-color:#ffcc99; }
479 .src-LinkedIn { background:#e7f1fa; color:#0a66c2; border-color:#b3d4ee; }
480 .src-YouTube { background:#ffebee; color:#c4302b; border-color:#f7b2ae; }
481 .src-Review { background:rgba(144,201,77,0.12); color:#5a8a1a; border-color:rgba(144,201,77,0.4); }
482 .src-Podcast { background:#efe7fa; color:#6236c2; border-color:#d1bde9; }
483 .src-X { background:#eef2f7; color:#111; border-color:#cfd9e5; }
484 .src-Twitter { background:#eef2f7; color:#111; border-color:#cfd9e5; }
485 .src-DevTo { background:#f3f3f6; color:#0a0a0a; border-color:#dcdce0; }
486 .src-Hashnode { background:#eef4ff; color:#2962ff; border-color:#c6d8ff; }
487 .src-Substack { background:#fff4e5; color:#ff6719; border-color:#ffd4b7; }
488 .src-Blog { background:#f6f3ee; color:#6a5d45; border-color:#e1dbcc; }
489 .shots { margin-bottom:1.5rem; }
490 .shot { background:var(--card); border:1px solid var(--border); border-radius:4px; overflow:hidden; }
491 .shot-label { font-size:0.6875rem; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); font-weight:600; padding:0.5rem 0.75rem; border-bottom:1px solid var(--border); background:#fafafa; }
492 .shot img { display:block; width:100%; height:auto; }
493 footer { margin-top:3rem; padding-top:1.5rem; border-top:1px solid var(--border); text-align:center; font-size:0.75rem; color:var(--muted); }
494 footer a { color:var(--brand); text-decoration:none; font-weight:500; }
495 ` ;
496
497 for ( const c of competitorRows) {
498 if ( ! c.body || c.body. length < 50 ) continue ;
499
500 const mentionsHtml = c.mentions. length
501 ? c.mentions. map ( m => {
502 const dateStr = m.date ? `<span class="muted-line" style="color:var(--muted);font-size:0.75rem;margin-left:auto;">${ escapeHtml ( m . date ) }</span>` : '' ;
503 const linkText = m.url ? `<a href="${ escapeHtml ( m . url ) }" target="_blank">${ escapeHtml ( m . title || m . url ) }</a>` : escapeHtml (m.title);
504 const snippet = m.snippet ? ` — <span style="color:var(--muted);">${ escapeHtml ( m . snippet ) }</span>` : '' ;
505 return `<div class="mention-item"><span class="src-pill src-${ escapeHtml ( m . sourceType ) }">${ escapeHtml ( m . sourceType ) }</span><div style="flex:1;">${ linkText }${ snippet }</div>${ dateStr }</div>` ;
506 }). join ( ' \n ' )
507 : '<p style="color:var(--muted);font-size:0.875rem;">No mentions collected.</p>' ;
508
509 const benchmarksHtml = c.benchmarks. length
510 ? `<ul>${ c . benchmarks . map ( b => {
511 const link = b.url ? `<a href="${ escapeHtml ( b . url ) }" target="_blank">${ escapeHtml ( b . title || b . url ) }</a>` : escapeHtml (b.title);
512 const src = b.source ? ` <span style="color:var(--muted);">(${ escapeHtml ( b . source ) })</span>` : '' ;
513 const finding = b.finding ? ` — ${ escapeHtml ( b . finding ) }` : '' ;
514 return `<li>${ link }${ src }${ finding }</li>` ;
515 } ). join ( '' ) }</ul>`
516 : '' ;
517
518 const productHtml = c.sections[ 'Product' ] ? `<h2>Product</h2>${ mdToHtml ( c . sections [ 'Product' ]) }` : '' ;
519 const pricingHtml = c.sections[ 'Pricing' ] ? `<h2>Pricing</h2>${ mdToHtml ( c . sections [ 'Pricing' ]) }` : '' ;
520 const featuresHtml = c.sections[ 'Features' ] ? `<h2>Features</h2>${ mdToHtml ( c . sections [ 'Features' ]) }` : '' ;
521 const positioningHtml = c.sections[ 'Positioning' ] ? `<h2>Positioning</h2>${ mdToHtml ( c . sections [ 'Positioning' ]) }` : '' ;
522 const comparisonKey = Object. keys (c.sections). find ( k => k. startsWith ( 'Comparison' ));
523 const comparisonHtml = comparisonKey ? `<h2>${ escapeHtml ( comparisonKey ) }</h2>${ mdToHtml ( c . sections [ comparisonKey ]) }` : '' ;
524 // Battle Card — synthesized by the Battle lane subagent (Step 5d) after fact-check completes.
525 // Contains Landmines / Objection Handlers / Talk Tracks — sales-enablement-grade output.
526 const battleCardKey = Object. keys (c.sections). find ( k => k === 'Battle Card' || k. startsWith ( 'Battle' ));
527 const battleCardHtml = battleCardKey ? `<h2>${ escapeHtml ( battleCardKey ) }</h2>${ mdToHtml ( c . sections [ battleCardKey ]) }` : '' ;
528 const findingsHtml = c.sections[ 'Research Findings' ] ? `<h2>Research Findings</h2>${ mdToHtml ( c . sections [ 'Research Findings' ]) }` : '' ;
529
530 // Screenshot — filename matches capture_screenshots.mjs output.
531 const heroShot = existsSync ( join (dir, 'screenshots' , `${ c . slug }-hero.png` ));
532 const screenshotsHtml = heroShot ? `
533 <div class="shots">
534 <div class="shot shot-hero"><div class="shot-label">Homepage</div><img src="../screenshots/${ escapeHtml ( c . slug ) }-hero.png" alt="${ escapeHtml ( c . competitor_name ) } homepage hero" loading="lazy"></div>
535 </div>` : '' ;
536
537 const companyHtml = `<!DOCTYPE html>
538 <html lang="en">
539 <head>
540 <meta charset="UTF-8">
541 <meta name="viewport" content="width=device-width, initial-scale=1.0">
542 <title>${ escapeHtml ( c . competitor_name ) } — Competitor Analysis</title>
543 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
544 <style>${ perCompetitorCss }</style>
545 </head>
546 <body>
547 <div class="container">
548 <a href="../index.html" class="back">← Back to overview</a>
549 <header>
550 <h1>${ escapeHtml ( c . competitor_name ) }</h1>
551 <div class="meta">
552 ${ c . website ? `<a href="${ escapeHtml ( c . website ) }" target="_blank">${ escapeHtml ( c . website ) }</a>` : ''}
553 ${ c . tagline ? ` · ${ escapeHtml ( c . tagline ) }` : ''}
554 </div>
555 </header>${ screenshotsHtml }
556 <dl class="fields">
557 ${ c . positioning ? `<dt>Positioning</dt><dd>${ escapeHtml ( c . positioning ) }</dd>` : ''}
558 ${ c . product_description ? `<dt>Product</dt><dd>${ escapeHtml ( c . product_description ) }</dd>` : ''}
559 ${ c . target_customer ? `<dt>Target Customer</dt><dd>${ escapeHtml ( c . target_customer ) }</dd>` : ''}
560 ${ c . pricing_model ? `<dt>Pricing Model</dt><dd>${ escapeHtml ( c . pricing_model ) }</dd>` : ''}
561 ${ c . pricing_tiers ? `<dt>Pricing Tiers</dt><dd>${ escapeHtml ( c . pricing_tiers ) }</dd>` : ''}
562 ${ c . key_features ? `<dt>Key Features</dt><dd>${ escapeHtml ( c . key_features ) }</dd>` : ''}
563 ${ c . integrations ? `<dt>Integrations</dt><dd>${ escapeHtml ( c . integrations ) }</dd>` : ''}
564 ${ c . headquarters ? `<dt>HQ</dt><dd>${ escapeHtml ( c . headquarters ) }</dd>` : ''}
565 ${ c . founded ? `<dt>Founded</dt><dd>${ escapeHtml ( c . founded ) }</dd>` : ''}
566 ${ c . employee_estimate ? `<dt>Employees</dt><dd>${ escapeHtml ( c . employee_estimate ) }</dd>` : ''}
567 ${ c . funding_info ? `<dt>Funding</dt><dd>${ escapeHtml ( c . funding_info ) }</dd>` : ''}
568 ${ c . strategic_diff ? `<dt>Strategic Diff</dt><dd>${ escapeHtml ( c . strategic_diff ) }</dd>` : ''}
569 </dl>
570 <div class="research">
571 ${ productHtml }
572 ${ pricingHtml }
573 ${ featuresHtml }
574 ${ positioningHtml }
575 ${ comparisonHtml }
576 </div>
577 ${ battleCardHtml ? `<div class="research battle">${ battleCardHtml }</div>` : ''}
578 <div class="research">
579 <h2>Mentions</h2>
580 ${ mentionsHtml }
581 </div>
582 ${ c . benchmarks . length ? `<div class="research"><h2>Benchmarks</h2>${ benchmarksHtml }</div>` : ''}
583 ${ findingsHtml ? `<div class="research">${ findingsHtml }</div>` : ''}
584 </div>
585 <footer>Generated by <a href="https://github.com/anthropics/skills">competitor-analysis</a> · Powered by <a href="https://browserbase.com">Browserbase</a></footer>
586 </body>
587 </html>` ;
588
589 writeFileSync ( join (dir, 'competitors' , `${ c . slug }.html` ), companyHtml);
590 }
591
592 // ---------- matrix.html (side-by-side) ----------
593
594 // curatedMatrix is loaded earlier (before the index.html section) because the
595 // strategic summary on the overview page reads userCompany from it.
596
597 function buildMatrixAxisFromCurated ( kind ) {
598 if ( ! curatedMatrix || ! curatedMatrix[kind]) return [];
599 const compMap = curatedMatrix.competitors || {};
600 return curatedMatrix[kind]. map ( entry => {
601 const label = entry.name;
602 let count = 0 ;
603 for ( const c of competitorRows) {
604 const compKey = compMap[c.slug];
605 if (compKey && compKey[kind] && compKey[kind][label]) count += 1 ;
606 }
607 return { label, count, description: entry.description || '' };
608 });
609 }
610
611 function buildMatrixAxisFromPipes ( field ) {
612 const counts = new Map ();
613 for ( const c of competitorRows) {
614 for ( const item of splitPipes (c[field])) {
615 const key = item. toLowerCase ();
616 if ( ! counts. has (key)) counts. set (key, { label: item, count: 0 });
617 counts. get (key).count += 1 ;
618 }
619 }
620 return [ ... counts. values ()]. sort (( a , b ) => b.count - a.count). slice ( 0 , 18 );
621 }
622
623 const featureAxis = curatedMatrix
624 ? buildMatrixAxisFromCurated ( 'features' )
625 : buildMatrixAxisFromPipes ( 'key_features' );
626 const integrationAxis = curatedMatrix
627 ? buildMatrixAxisFromCurated ( 'integrations' )
628 : buildMatrixAxisFromPipes ( 'integrations' );
629
630 function competitorHas ( c , field , label ) {
631 // Curated mode: look up in matrix.json (field is 'features' or 'integrations').
632 if (curatedMatrix) {
633 const compMap = curatedMatrix.competitors || {};
634 const compEntry = compMap[c.slug];
635 return !! (compEntry && compEntry[field] && compEntry[field][label]);
636 }
637 // Fallback: raw pipe-split match.
638 const rawField = field === 'features' ? 'key_features' : field;
639 return splitPipes (c[rawField]). some ( x => x. toLowerCase () === label. toLowerCase ());
640 }
641
642 function matrixSection ( heading , axis , field ) {
643 if ( ! axis. length ) return '' ;
644 // Horizontal competitor-name headers — simpler to read than rotated. Row label (feature name) is
645 // the sticky left column so users can scroll horizontally without losing context on wide tables.
646 const header = `<tr>
647 <th class="mx-feature-h">${ escapeHtml ( heading ) }</th>
648 ${ competitorRows . map ( c => `<th class="mx-comp-h"><a href="competitors/${ escapeHtml ( c . slug ) }.html">${ escapeHtml ( c . competitor_name ) }</a></th>` ). join ( '' ) }
649 </tr>` ;
650 const rows = axis. map ( a => {
651 const cells = competitorRows. map ( c => competitorHas (c, field, a.label)
652 ? `<td class="mx-cell mx-yes" title="${ escapeHtml ( c . competitor_name ) } has ${ escapeHtml ( a . label ) }">●</td>`
653 : `<td class="mx-cell mx-no">·</td>` ). join ( '' );
654 return `<tr>
655 <td class="mx-feature"><span class="mx-feature-label">${ escapeHtml ( a . label ) }</span><span class="mx-count">${ a . count }</span></td>
656 ${ cells }
657 </tr>` ;
658 }). join ( ' \n ' );
659 return `<section class="mx-section">
660 <h2 class="mx-heading">${ escapeHtml ( heading ) }</h2>
661 <div class="mx-scroll">
662 <table class="mx-table">${ header }${ rows }</table>
663 </div>
664 </section>` ;
665 }
666
667 const pricingRows = competitorRows. map ( c => `<tr><td style="font-weight:500;">${ escapeHtml ( c . competitor_name ) }</td><td style="color:var(--muted);font-size:0.8125rem;">${ escapeHtml ( c . pricing_model || '' ) }</td><td style="font-size:0.8125rem;">${ escapeHtml ( c . pricing_tiers || '—' ) }</td><td style="font-size:0.8125rem;">${ escapeHtml ( c . target_customer || '' ) }</td></tr>` ). join ( '' );
668
669 const matrixHtml = `<!DOCTYPE html>
670 <html lang="en">
671 <head>
672 <meta charset="UTF-8">
673 <meta name="viewport" content="width=device-width, initial-scale=1.0">
674 <title>Feature Matrix — ${ escapeHtml ( title ) }</title>
675 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
676 <style>
677 :root { --brand:#F03603; --black:#100D0D; --border:#edebeb; --bg:#F9F6F4; --card:#ffffff; --text:#100D0D; --muted:#514F4F; }
678 * { margin:0; padding:0; box-sizing:border-box; }
679 body { font-family:Inter,system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.5; font-size:15px; }
680 .container { max-width:1400px; margin:0 auto; padding:2rem 1.5rem; }
681 header { margin-bottom:1.5rem; }
682 header h1 { font-size:1.5rem; font-weight:600; margin-bottom:0.25rem; }
683 header .meta { color:var(--muted); font-size:0.875rem; }
684 nav.views { display:flex; gap:0.5rem; margin-bottom:2rem; }
685 nav.views a { background:var(--card); border:1px solid var(--border); border-radius:4px; padding:0.5rem 0.875rem; font-size:0.8125rem; color:var(--muted); text-decoration:none; font-weight:500; }
686 nav.views a:hover { border-color:var(--brand); color:var(--brand); }
687 nav.views a.active { background:var(--brand); color:#fff; border-color:var(--brand); }
688 table { border-collapse:collapse; background:var(--card); border:1px solid var(--border); border-radius:4px; overflow:hidden; margin-bottom:1.5rem; }
689 th, td { border:1px solid var(--border); padding:0.5rem 0.625rem; }
690 th { background:#fafafa; font-size:0.75rem; color:var(--muted); text-transform:uppercase; letter-spacing:0.04em; }
691
692 /* Feature matrix — sticky first column + tilted competitor headers */
693 .mx-section { margin:1.5rem 0; }
694 .mx-heading { font-size:1rem; font-weight:600; margin:0 0 0.5rem; color:var(--black); }
695 .mx-scroll { background:var(--card); border:1px solid var(--border); border-radius:4px; overflow-x:auto; }
696 .mx-table { border-collapse:collapse; width:auto; margin:0; background:var(--card); border:none; border-radius:0; }
697 .mx-table th, .mx-table td { border:1px solid var(--border); padding:0; }
698 .mx-table tr:hover td:not(.mx-feature) { background:#fdf7f5; }
699 .mx-table tr:hover .mx-feature { background:#fdfcfb; }
700 .mx-feature-h { position:sticky; left:0; z-index:3; background:#fafafa; text-align:left; min-width:240px; padding:0.75rem !important; border-bottom:1px solid var(--border); font-size:0.6875rem; text-transform:uppercase; letter-spacing:0.05em; color:var(--muted); font-weight:600; }
701 .mx-comp-h { padding:0.75rem 0.5rem !important; background:#fafafa; min-width:110px; max-width:140px; border-bottom:1px solid var(--border); text-align:center; font-size:0.8125rem; font-weight:600; text-transform:none; letter-spacing:0; color:var(--text); white-space:nowrap; }
702 .mx-comp-h a { color:var(--text); text-decoration:none; }
703 .mx-comp-h a:hover { color:var(--brand); }
704 .mx-feature { position:sticky; left:0; z-index:2; background:var(--card); min-width:240px; font-size:0.8125rem; padding:0.45rem 0.75rem !important; display:flex; align-items:center; justify-content:space-between; gap:0.5rem; }
705 .mx-feature-label { flex:1; }
706 .mx-count { color:var(--muted); font-size:0.7rem; font-weight:600; background:#f4f1ee; padding:0 6px; border-radius:999px; }
707 .mx-cell { text-align:center; font-weight:700; min-width:110px; max-width:140px; padding:0.5rem 0 !important; font-size:0.95rem; }
708 .mx-yes { color:#5a8a1a; background:rgba(144,201,77,0.06); }
709 .mx-no { color:#e0dcd7; }
710
711 footer { margin-top:3rem; padding-top:1.5rem; border-top:1px solid var(--border); text-align:center; font-size:0.75rem; color:var(--muted); }
712 footer a { color:var(--brand); text-decoration:none; }
713 </style>
714 </head>
715 <body>
716 <div class="container">
717 <header>
718 <h1>Feature & Pricing Matrix</h1>
719 <div class="meta">${ escapeHtml ( metaLine ) }</div>
720 </header>
721 <nav class="views">
722 <a href="index.html">Overview</a>
723 <a href="matrix.html" class="active">Matrix</a>
724 <a href="mentions.html">Mentions</a>
725 </nav>
726
727 <section>
728 <h2 style="font-size:1rem;font-weight:600;margin:0 0 0.5rem;">Pricing</h2>
729 <table style="width:100%;">
730 <thead><tr><th style="text-align:left;">Competitor</th><th style="text-align:left;">Model</th><th style="text-align:left;">Tiers</th><th style="text-align:left;">Target Customer</th></tr></thead>
731 <tbody>${ pricingRows }</tbody>
732 </table>
733 </section>
734
735 ${ matrixSection ( 'Features' , featureAxis , 'features' ) }
736 ${ matrixSection ( 'Integrations' , integrationAxis , 'integrations' ) }
737 </div>
738 <footer>Generated by <a href="https://github.com/anthropics/skills">competitor-analysis</a> · Powered by <a href="https://browserbase.com">Browserbase</a></footer>
739 </body>
740 </html>` ;
741
742 writeFileSync ( join (dir, 'matrix.html' ), matrixHtml);
743
744 // ---------- mentions.html (feed + filter) ----------
745
746 // Mentions feed: iterate `competitorRows` (user's own company already filtered out earlier)
747 // so the chronological feed doesn't mix the user's own mentions with competitors'.
748 const allMentions = [];
749 for ( const c of competitorRows) {
750 for ( const m of c.mentions) {
751 allMentions. push ({ ... m, competitor: c.competitor_name || c.slug, slug: c.slug });
752 }
753 }
754 // Sort by date desc (empty dates last)
755 allMentions. sort (( a , b ) => {
756 if (a.date && b.date) return b.date. localeCompare (a.date);
757 if (a.date) return - 1 ;
758 if (b.date) return 1 ;
759 return 0 ;
760 });
761
762 const sourceTypes = [ ...new Set (allMentions. map ( m => m.sourceType))]. sort ();
763 const sourceFilterButtons = [ 'All' , ... sourceTypes]. map ( t =>
764 `<button class="filter-btn${ t === 'All' ? ' active' : ''}" data-filter="${ escapeHtml ( t ) }">${ escapeHtml ( t ) }</button>`
765 ). join ( '' );
766
767 const mentionItems = allMentions. map ( m => {
768 const link = m.url ? `<a href="${ escapeHtml ( m . url ) }" target="_blank">${ escapeHtml ( m . title || m . url ) }</a>` : escapeHtml (m.title);
769 const snippet = m.snippet ? `<div class="snippet">${ escapeHtml ( m . snippet ) }</div>` : '' ;
770 const date = m.date ? `<span class="date">${ escapeHtml ( m . date ) }</span>` : '' ;
771 return `<div class="mention" data-type="${ escapeHtml ( m . sourceType ) }">
772 <span class="src-pill src-${ escapeHtml ( m . sourceType ) }">${ escapeHtml ( m . sourceType ) }</span>
773 <div class="body">
774 <div class="header-line">
775 <a href="competitors/${ escapeHtml ( m . slug ) }.html" class="competitor-chip">${ escapeHtml ( m . competitor ) }</a>
776 ${ date }
777 </div>
778 <div class="title">${ link }</div>
779 ${ snippet }
780 </div>
781 </div>` ;
782 }). join ( ' \n ' );
783
784 const mentionsHtml = `<!DOCTYPE html>
785 <html lang="en">
786 <head>
787 <meta charset="UTF-8">
788 <meta name="viewport" content="width=device-width, initial-scale=1.0">
789 <title>Mentions Feed — ${ escapeHtml ( title ) }</title>
790 <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
791 <style>
792 :root { --brand:#F03603; --blue:#4DA9E4; --black:#100D0D; --border:#edebeb; --bg:#F9F6F4; --card:#ffffff; --text:#100D0D; --muted:#514F4F; }
793 * { margin:0; padding:0; box-sizing:border-box; }
794 body { font-family:Inter,system-ui,sans-serif; background:var(--bg); color:var(--text); line-height:1.5; font-size:15px; }
795 .container { max-width:900px; margin:0 auto; padding:2rem 1.5rem; }
796 header { margin-bottom:1.5rem; }
797 header h1 { font-size:1.5rem; font-weight:600; margin-bottom:0.25rem; }
798 header .meta { color:var(--muted); font-size:0.875rem; }
799 nav.views { display:flex; gap:0.5rem; margin-bottom:1.5rem; }
800 nav.views a { background:var(--card); border:1px solid var(--border); border-radius:4px; padding:0.5rem 0.875rem; font-size:0.8125rem; color:var(--muted); text-decoration:none; font-weight:500; }
801 nav.views a:hover { border-color:var(--brand); color:var(--brand); }
802 nav.views a.active { background:var(--brand); color:#fff; border-color:var(--brand); }
803 .filters { display:flex; gap:0.375rem; margin-bottom:1rem; flex-wrap:wrap; }
804 .filter-btn { background:var(--card); border:1px solid var(--border); border-radius:999px; padding:0.25rem 0.75rem; font-size:0.75rem; color:var(--muted); cursor:pointer; font-weight:500; font-family:inherit; }
805 .filter-btn:hover { border-color:var(--brand); color:var(--brand); }
806 .filter-btn.active { background:var(--brand); color:#fff; border-color:var(--brand); }
807 .mention { display:flex; gap:0.75rem; align-items:flex-start; padding:0.875rem; background:var(--card); border:1px solid var(--border); border-radius:4px; margin-bottom:0.5rem; }
808 .mention.hidden { display:none; }
809 .mention .body { flex:1; min-width:0; }
810 .header-line { display:flex; gap:0.75rem; align-items:center; margin-bottom:0.25rem; font-size:0.8125rem; }
811 .competitor-chip { color:var(--muted); font-weight:500; text-decoration:none; }
812 .competitor-chip:hover { color:var(--brand); }
813 .date { color:var(--muted); font-size:0.75rem; margin-left:auto; }
814 .title { font-size:0.9375rem; margin-bottom:0.25rem; }
815 .title a { color:var(--text); text-decoration:none; font-weight:500; }
816 .title a:hover { color:var(--brand); text-decoration:underline; }
817 .snippet { color:var(--muted); font-size:0.8125rem; }
818 .src-pill { font-size:0.6875rem; font-weight:600; padding:3px 9px; border-radius:999px; white-space:nowrap; border:1px solid; flex-shrink:0; align-self:flex-start; }
819 .src-Benchmark { background:rgba(77,169,228,0.12); color:#2172a3; border-color:rgba(77,169,228,0.4); }
820 .src-Comparison { background:rgba(240,54,3,0.10); color:var(--brand); border-color:rgba(240,54,3,0.4); }
821 .src-News { background:#f2f2f2; color:var(--black); border-color:#ddd; }
822 .src-Reddit { background:#fff2eb; color:#d84300; border-color:#ffd4b7; }
823 .src-HN { background:#fff4e5; color:#c95500; border-color:#ffcc99; }
824 .src-LinkedIn { background:#e7f1fa; color:#0a66c2; border-color:#b3d4ee; }
825 .src-YouTube { background:#ffebee; color:#c4302b; border-color:#f7b2ae; }
826 .src-Review { background:rgba(144,201,77,0.12); color:#5a8a1a; border-color:rgba(144,201,77,0.4); }
827 .src-Podcast { background:#efe7fa; color:#6236c2; border-color:#d1bde9; }
828 .src-X { background:#eef2f7; color:#111; border-color:#cfd9e5; }
829 .src-Twitter { background:#eef2f7; color:#111; border-color:#cfd9e5; }
830 .src-DevTo { background:#f3f3f6; color:#0a0a0a; border-color:#dcdce0; }
831 .src-Hashnode { background:#eef4ff; color:#2962ff; border-color:#c6d8ff; }
832 .src-Substack { background:#fff4e5; color:#ff6719; border-color:#ffd4b7; }
833 .src-Blog { background:#f6f3ee; color:#6a5d45; border-color:#e1dbcc; }
834 .empty { text-align:center; color:var(--muted); padding:3rem 1rem; font-size:0.875rem; }
835 footer { margin-top:3rem; padding-top:1.5rem; border-top:1px solid var(--border); text-align:center; font-size:0.75rem; color:var(--muted); }
836 footer a { color:var(--brand); text-decoration:none; }
837 </style>
838 </head>
839 <body>
840 <div class="container">
841 <header>
842 <h1>Mentions Feed</h1>
843 <div class="meta">${ allMentions . length } mentions across ${ competitorRows . length } competitors · ${ escapeHtml ( genDate ) }</div>
844 </header>
845 <nav class="views">
846 <a href="index.html">Overview</a>
847 <a href="matrix.html">Matrix</a>
848 <a href="mentions.html" class="active">Mentions</a>
849 </nav>
850 <div class="filters">${ sourceFilterButtons }</div>
851 <div id="mentions-list">
852 ${ mentionItems || '<div class="empty">No mentions collected — try running in deep or deeper mode.</div>'}
853 </div>
854 </div>
855 <footer>Generated by <a href="https://github.com/anthropics/skills">competitor-analysis</a> · Powered by <a href="https://browserbase.com">Browserbase</a></footer>
856 <script>
857 (function () {
858 const buttons = document.querySelectorAll('.filter-btn');
859 const items = document.querySelectorAll('.mention');
860 buttons.forEach(btn => btn.addEventListener('click', () => {
861 buttons.forEach(b => b.classList.remove('active'));
862 btn.classList.add('active');
863 const f = btn.dataset.filter;
864 items.forEach(el => {
865 el.classList.toggle('hidden', f !== 'All' && el.dataset.type !== f);
866 });
867 }));
868 })();
869 </script>
870 </body>
871 </html>` ;
872
873 writeFileSync ( join (dir, 'mentions.html' ), mentionsHtml);
874
875 // ---------- CSV ----------
876
877 const priority = [
878 'competitor_name' , 'website' , 'tagline' , 'positioning' , 'product_description' ,
879 'target_customer' , 'pricing_model' , 'pricing_tiers' , 'key_features' , 'integrations' ,
880 'headquarters' , 'founded' , 'employee_estimate' , 'funding_info' , 'strategic_diff'
881 ];
882 const flatRows = competitorRows. map ( c => {
883 const row = {};
884 for ( const k of Object. keys (c)) {
885 if ([ 'body' , 'sections' , 'mentions' , 'benchmarks' , 'slug' , 'file' ]. includes (k)) continue ;
886 row[k] = c[k];
887 }
888 row.mention_count = String (c.mentions. length );
889 row.benchmark_count = String (c.benchmarks. length );
890 return row;
891 });
892 const allCols = [ ...new Set (flatRows. flatMap ( r => Object. keys (r)))];
893 const cols = [ ... priority. filter ( c => allCols. includes (c)), ... allCols. filter ( c => ! priority. includes (c)). sort ()];
894
895 function csvEscape ( v ) {
896 v = String (v || '' );
897 if (v. includes ( ',' ) || v. includes ( '"' ) || v. includes ( ' \n ' )) return '"' + v. replace ( /"/ g , '""' ) + '"' ;
898 return v;
899 }
900
901 const csvLines = [cols. join ( ',' )];
902 for ( const row of flatRows) csvLines. push (cols. map ( c => csvEscape (row[c] || '' )). join ( ',' ));
903 writeFileSync ( join (dir, 'results.csv' ), csvLines. join ( ' \n ' ) + ' \n ' );
904
905 // ---------- Summary ----------
906
907 console. error ( JSON . stringify ({
908 total: competitorRows. length ,
909 mentions: totalMentions,
910 benchmarks: totalBenchmarks,
911 with_pricing: withPricing,
912 user_company: userCompany,
913 files_generated: {
914 index: join (dir, 'index.html' ),
915 matrix: join (dir, 'matrix.html' ),
916 mentions: join (dir, 'mentions.html' ),
917 competitors: competitorRows. filter ( c => c.body && c.body. length > 50 ). length ,
918 csv: join (dir, 'results.csv' )
919 }
920 }, null , 2 ));
921
922 console. log ( join (dir, 'index.html' ));
923
924 if (shouldOpen) {
925 const { execFileSync } = await import ( 'child_process' );
926 // Use execFileSync (not execSync with string interpolation) so a `dir` containing
927 // shell metacharacters like `"`, `$`, or backticks can't break out into command exec.
928 try { execFileSync ( 'open' , [ join (dir, 'index.html' )]); } catch {}
929 }