import fs from 'fs' const all = JSON.parse(fs.readFileSync(process.argv[2], 'utf8')) const norm = s => s.toLowerCase().replace(/[^a-z0-9 ]/g, ' ').replace(/\s+/g, ' ').trim() // Product management only. The exclusion list is deliberately short: because // INCLUDE already pins the profession ("product manager" / "product management"), // a word like "developer" or "engineer" in one of these titles names the product // domain, not the job — "Staff Product Manager, Developer Experience" is a PM. // An earlier, wider EXCLUDE dropped 14 real PMs for exactly that reason. // "Product Marketing Manager" never matches INCLUDE anyway; it is listed for safety. const INCLUDE = /\bproduct manager\b|\bproduct management\b/i const EXCLUDE = /\bproduct marketing\b|\bintern\b|\binternship\b/i const seen = new Set(), jobs = [] for (const j of all.filter(x => INCLUDE.test(x.title) && !EXCLUDE.test(x.title))) { const k = j.co + '|' + norm(j.title) + '|' + j.content.length if (!seen.has(k)) { seen.add(k); jobs.push(j) } } const unesc = s => s.replace(/</g,'<').replace(/>/g,'>').replace(/&/g,'&') .replace(/"/g,'"').replace(/'/g,"'").replace(/ /g,' ') const texts = jobs.map(j => unesc(j.content).replace(/<[^>]+>/g,' ').replace(/\s+/g,' ')) // Left out on purpose, and said so on the page: // Linear — 0.7% loose, 0.2% once "linear regression/algebra/model" is excluded. // Too rare to report and dirty at that size. // Aha! — one posting. The trailing "!" also makes a word-boundary match // unreliable. // platform — 85%, but it is ordinary English in a tech posting rather than a // term a candidate can act on. Reporting it would pad the table. // "discovery" is matched only in its qualified forms: bare "discovery" hits 17.6% // against 4.0% for product/customer/user/continuous discovery, and the remainder // is generic English. const T = [ ['Craft & practice','roadmap',/\broadmap/i], ['Craft & practice','product strategy',/\bproduct strategy\b/i], ['Craft & practice','prioritization',/\bprioriti[sz]/i], ['Craft & practice','user research',/\buser research\b|\bcustomer research\b/i], ['Craft & practice','customer interviews',/\bcustomer interviews?\b|\buser interviews?\b/i], ['Craft & practice','product discovery',/\b(product|customer|user|continuous)\s+discovery\b|\bdiscovery (process|work|phase)\b/i], ['Craft & practice','requirements / PRD',/\bPRDs?\b|\bproduct requirements?\b/i], ['Craft & practice','user stories',/\buser stor(y|ies)\b/i], ['Craft & practice','go-to-market',/\bgo[- ]to[- ]market\b|\bGTM\b/i], ['Craft & practice','stakeholder management',/\bstakeholder/i], ['Craft & practice','cross-functional',/\bcross[- ]functional\b/i], ['Metrics & analysis','KPIs',/\bKPIs?\b/i], ['Metrics & analysis','OKRs',/\bOKRs?\b/i], ['Metrics & analysis','A/B testing',/\bA\/B test/i], ['Metrics & analysis','experimentation',/\bexperiment/i], ['Metrics & analysis','retention',/\bretention\b/i], ['Metrics & analysis','churn',/\bchurn\b/i], ['Metrics & analysis','conversion',/\bconversion\b/i], ['Metrics & analysis','engagement',/\bengagement\b/i], ['Metrics & analysis','north star metric',/\bnorth star\b/i], ['Metrics & analysis','funnel',/\bfunnel\b/i], ['Metrics & analysis','cohort analysis',/\bcohort/i], ['Metrics & analysis','NPS',/\bNPS\b/], ['Method','Agile',/\bAgile\b/i], ['Method','Scrum',/\bScrum\b/i], ['Method','sprint',/\bsprints?\b/i], ['Method','Kanban',/\bKanban\b/i], ['Method','lean',/\blean (startup|product)\b/i], ['Tools','Jira',/\bJira\b/i], ['Tools','Figma',/\bFigma\b/i], ['Tools','SQL',/\bSQL\b/], ['Tools','Amplitude',/\bAmplitude\b/], ['Tools','Mixpanel',/\bMixpanel\b/i], ['Tools','Looker',/\bLooker\b/i], ['Tools','Tableau',/\bTableau\b/i], ['Tools','Confluence',/\bConfluence\b/i], ['Tools','Productboard',/\bProductboard\b/i], ['Domain & context','B2B',/\bB2B\b/i], ['Domain & context','B2C',/\bB2C\b/i], ['Domain & context','SaaS',/\bSaaS\b/i], ['Domain & context','API',/\bAPIs?\b/], ['Domain & context','AI / machine learning',/\bmachine learning\b|\bartificial intelligence\b|\bAI\/ML\b/i], ['Domain & context','enterprise',/\benterprise\b/i], ['Domain & context','MBA',/\bMBA\b/], ] const n = texts.length const moe = p => +(1.96 * Math.sqrt((p/100)*(1-p/100)/n) * 100).toFixed(1) const rows = T.map(([cat, term, re]) => { const c = texts.filter(t => re.test(t)).length const pct = +(100*c/n).toFixed(1) return { cat, term, n: c, pct, moe: moe(pct) } }).sort((a,b) => b.n - a.n) const out = { role: 'Product Manager', n, companies: [...new Set(jobs.map(j=>j.co))].sort(), collected: new Date(Date.now() - new Date().getTimezoneOffset() * 60000).toISOString().slice(0,10), medianWords: texts.map(t=>t.split(' ').length).sort((a,b)=>a-b)[Math.floor(n/2)], rows } fs.writeFileSync(process.argv[3], JSON.stringify(out, null, 1)) console.log(`n=${n} from ${out.companies.length} companies, median ${out.medianWords} words`) rows.forEach((r,i)=>console.log(` ${String(i+1).padStart(2)}. ${r.term.padEnd(24)} ${String(r.pct).padStart(5)}% ±${r.moe} (${r.n})`))