Setting the file. One moment.
Extract Vs Names · Competitor Analysis · browserbase/skills · Skills Docs
ContentsBack to the top of the page (opens in a new tab)
scripts/ extract_vs_names.mjs
JavaScript · 140 lines · 5 KB
'path'
;
14
15 const args = process.argv. slice ( 2 );
16
17 if (args. includes ( '--help' ) || args. includes ( '-h' ) || args. length === 0 ) {
18 console. error ( `Usage: node extract_vs_names.mjs <directory> [--prefix <prefix>] [--seed "<csv>"]
19
20 Reads all <prefix>_discovery_batch_*.json files, parses "X vs Y" patterns from result
21 titles, and outputs a ranked list of candidate competitor names as newline-delimited JSON.
22
23 Options:
24 --prefix <prefix> Batch file prefix (default: "competitor")
25 --seed "<csv>" Comma-separated list of seed names to exclude from output
26 (you already know these; want the OTHER side of the comparison)
27 --help, -h Show this help message` );
28 process. exit (args. includes ( '--help' ) || args. includes ( '-h' ) ? 0 : 1 );
29 }
30
31 const dir = args[ 0 ];
32 const prefixIdx = args. indexOf ( '--prefix' );
33 const prefix = prefixIdx !== - 1 && args[prefixIdx + 1 ] ? args[prefixIdx + 1 ] : 'competitor' ;
34 const seedIdx = args. indexOf ( '--seed' );
35 const seeds = seedIdx !== - 1 && args[seedIdx + 1 ]
36 ? args[seedIdx + 1 ]. split ( ',' ). map ( s => s. trim (). toLowerCase ()). filter (Boolean)
37 : [];
38 const seedSet = new Set (seeds);
39
40 // Escape regex metacharacters in the user-supplied prefix so a value like
41 // "comp.+" matches the literal filename, not as a regex pattern.
42 const escapedPrefix = prefix. replace ( / [.*+?^${}()|[ \]\\ ] / g , ' \\ $&' );
43 const pattern = new RegExp ( `^${ escapedPrefix }_discovery_batch_.* \\ .json$` );
44
45 let files;
46 try {
47 files = readdirSync (dir). filter ( f => pattern. test (f)). sort ();
48 } catch (err) {
49 console. error ( `Error reading directory ${ dir }: ${ err . message }` );
50 process. exit ( 1 );
51 }
52
53 if (files. length === 0 ) {
54 console. error ( `No ${ prefix }_discovery_batch_*.json files found in ${ dir }` );
55 process. exit ( 1 );
56 }
57
58 const allResults = [];
59 for ( const f of files) {
60 try {
61 const d = JSON . parse ( readFileSync ( join (dir, f), 'utf-8' ));
62 const rs = Array. isArray (d) ? d : d.results || [];
63 allResults. push ( ... rs);
64 } catch {}
65 }
66
67 // Build a lookup of hostname -> candidate root domain from all result URLs.
68 // Used later to try to resolve "serper" -> "serper.dev".
69 // Exclude any host whose root-base equals a seed name — otherwise a short extracted token
70 // like "exa" can match the user's own domain (exa.ai).
71 const hostMap = new Map ();
72 for ( const r of allResults) {
73 if ( ! r.url) continue ;
74 try {
75 const h = new URL (r.url).hostname. replace ( / ^ www \. / , '' );
76 const root = h. split ( '.' ). slice ( - 2 ). join ( '.' );
77 const rootBase = root. split ( '.' )[ 0 ];
78 if (seedSet. has (rootBase)) continue ;
79 if ( ! hostMap. has (root)) hostMap. set (root, h);
80 } catch {}
81 }
82
83 // Extract names from "X vs Y" patterns.
84 const counts = new Map ();
85 for ( const r of allResults) {
86 const title = (r.title || '' ). toLowerCase ();
87 const ms = [ ... title. matchAll ( / \b ( [a-z][\w. \- ] {2,} ) \s + (?:vs \. ?| versus) \s + ( [a-z][\w. \- ] {2,} )/ g )];
88 for ( const m of ms) {
89 for ( const raw of [m[ 1 ], m[ 2 ]]) {
90 const name = raw. replace ( / [ ^ a-z0-9. \- ] / g , '' ). trim ();
91 if ( ! name || name. length < 3 ) continue ;
92 if (seedSet. has (name)) continue ;
93 // Reject obvious non-product tokens
94 if ([ 'the' , 'and' , 'for' , 'with' , 'best' , 'top' , 'better' , 'using' , 'choosing' ]. includes (name)) continue ;
95 if ( ! counts. has (name)) counts. set (name, { name, hits: 0 , example: r.title });
96 counts. get (name).hits += 1 ;
97 }
98 }
99 }
100
101 // Try to resolve each name to a domain.
102 // Strategy:
103 // 1. Exact match on rootBase wins outright.
104 // 2. Otherwise allow rootBase.startsWith(needle) ONLY when the suffix is a known
105 // branding token (e.g. "serp" → "serpapi.com"). Bidirectional startsWith
106 // was too loose: "serp" matched serpstack.com, "exa" matched example.com.
107 // 3. Among multiple suffix matches, prefer the shortest suffix (most specific —
108 // "serp" should match "serpapi" before "serpapilabs"). Deterministic.
109 const BRAND_SUFFIXES = [ 'api' , 'search' , 'app' , 'ai' , 'io' , 'hq' , 'co' , 'dev' , 'tech' , 'cloud' , 'agent' , 'agents' , 'labs' , 'lab' ];
110
111 function resolveDomain ( name ) {
112 const needle = name. replace ( / \. / g , '' );
113 let exact = null ;
114 let bestSuffix = null ; // { host, suffixLen }
115 for ( const [ root , host ] of hostMap. entries ()) {
116 const rootBase = root. split ( '.' )[ 0 ];
117 if (rootBase === needle) { exact = host; break ; }
118 if (rootBase. length > needle. length && rootBase. startsWith (needle)) {
119 const suffix = rootBase. slice (needle. length ). replace ( / ^ [\-_] / , '' );
120 if ( BRAND_SUFFIXES . includes (suffix)) {
121 if ( ! bestSuffix || suffix. length < bestSuffix.suffixLen) {
122 bestSuffix = { host, suffixLen: suffix. length };
123 }
124 }
125 }
126 }
127 if (exact) return exact;
128 if (bestSuffix) return bestSuffix.host;
129 return null ;
130 }
131
132 const ranked = [ ... counts. values ()]
133 . map ( c => ({ ... c, domain: resolveDomain (c.name) }))
134 . sort (( a , b ) => b.hits - a.hits);
135
136 for ( const c of ranked) {
137 console. log ( JSON . stringify (c));
138 }
139
140 console. error ( `Extracted ${ ranked . length } candidate names from ${ files . length } batch files` );