Tam builder
@gtmengineer/tam-builder
Define your ICP and size the market from a companies CSV (a CRM export or any company list). Applies market filters for SAM, scores every company into A, B, and C tiers with reasons, values each layer with ACV by size band, and writes a tiered account list ready for territories or outbound.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// tam-builder: size the market from a companies CSV and an ICP definition.
- 3// TAM (every company in the file) -> SAM (companies inside your market filters)
- 4// -> ICP tiers A/B/C from a points score, with dollars from ACV by employee band.
- 5// Writes a summary and a tiered account list. Node 22, built-ins only.
- 6//
- 7// Usage:
- 8// node scripts/tam.mjs <companies.csv> <icp.json> [--out out] [--name tam]
- 10import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
- 11import { join } from "node:path";
- 13function fail(msg) {
- 14 console.error(`tam-builder: ${msg}`);
- 15 process.exit(1);
- 16}
- 18const args = process.argv.slice(2);
- 19const opts = { out: "out", name: "tam" };
- 20const pos = [];
- 21for (let i = 0; i < args.length; i++) {
- 22 if (args[i] === "--out") opts.out = args[++i];
- 23 else if (args[i] === "--name") opts.name = args[++i];
- 24 else if (args[i] === "--help" || args[i] === "-h") {
- 25 console.log("Usage: node scripts/tam.mjs <companies.csv> <icp.json> [--out out] [--name tam]");
- 26 process.exit(0);
- 27 } else pos.push(args[i]);
- 28}
- 29if (pos.length !== 2) fail("give a companies CSV and an ICP JSON. Try --help.");
- 30const read = (p) => {
- 31 try {
- 32 return readFileSync(p, "utf8");
- 33 } catch {
- 34 fail(`cannot read ${p}`);
- 35 }
- 36};
- 38function parseCsv(text) {
- 39 if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
- 40 const rows = [];
- 41 let row = [];
- 42 let field = "";
- 43 let q = false;
- 44 for (let i = 0; i < text.length; i++) {
- 45 const c = text[i];
- 46 if (q) {
- 47 if (c === '"') {
- 48 if (text[i + 1] === '"') {
- 49 field += '"';
- 50 i++;
- 51 } else q = false;
- 52 } else field += c;
- 53 } else if (c === '"') q = true;
- 54 else if (c === ",") {
- 55 row.push(field);
- 56 field = "";
- 57 } else if (c === "\n" || c === "\r") {
- 58 if (c === "\r" && text[i + 1] === "\n") i++;
- 59 row.push(field);
- 60 rows.push(row);
- 61 row = [];
- 62 field = "";
- 63 } else field += c;
- 64 }
- 65 if (field !== "" || row.length) {
- 66 row.push(field);
- 67 rows.push(row);
- 68 }
- 69 const data = rows.filter((r) => r.some((v) => v.trim() !== ""));
- 70 if (!data.length) return { header: [], records: [] };
- 71 const header = data.shift().map((h) => h.trim());
- 72 return { header, records: data.map((r) => Object.fromEntries(header.map((h, i) => [h, (r[i] ?? "").trim()]))) };
- 73}
- 74const norm = (s) => String(s).toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "");
- 75const csvCell = (s) => (/[",\n]/.test(String(s)) ? `"${String(s).replace(/"/g, '""')}"` : String(s));
- 77let icp;
- 78try {
- 79 icp = JSON.parse(read(pos[1]));
- 80} catch (e) {
- 81 fail(`${pos[1]} is not valid JSON: ${e.message}`);
- 82}
- 83const market = icp.market || {};
- 84const scoreRules = icp.score || [];
- 85const tiers = [...(icp.tiers || [{ name: "A", min_score: 70 }, { name: "B", min_score: 45 }, { name: "C", min_score: 20 }])].sort((a, b) => b.min_score - a.min_score);
- 86const bands = icp.bands || [{ name: "All", acv: 0 }];
- 87if (!bands.length || bands[bands.length - 1].max != null) fail("bands must end with an open band (no max), for example { \"name\": \"Enterprise\", \"acv\": 60000 }");
- 89const { header, records } = parseCsv(read(pos[0]));
- 90if (!records.length) fail("the companies file has no rows");
- 91const ALIASES = {
- 92 name: ["name", "company_name", "company", "account_name"],
- 93 domain: ["domain", "website", "company_domain", "company_domain_name"],
- 94 industry: ["industry", "sector", "primary_industry"],
- 95 employees: ["employees", "employee_count", "number_of_employees", "headcount", "company_size", "employee_range"],
- 96 country: ["country", "hq_country", "country_region", "billing_country", "headquarters_country"],
- 97};
- 98const byNorm = new Map(header.map((h) => [norm(h), h]));
- 99const cols = {};
- 100for (const [f, list] of Object.entries(ALIASES)) {
- 101 const hit = list.find((a) => byNorm.has(a));
- 102 if (hit) cols[f] = byNorm.get(hit);
- 103}
- 104if (!cols.employees) fail(`no employee count column found. Columns are: ${header.join(", ")}`);
- 106const COUNTRY = { "united states": "US", usa: "US", "u.s.": "US", "united states of america": "US", "united kingdom": "GB", uk: "GB", "great britain": "GB", england: "GB", germany: "DE", deutschland: "DE", canada: "CA", france: "FR", australia: "AU", netherlands: "NL", ireland: "IE", spain: "ES", sweden: "SE", switzerland: "CH", india: "IN", japan: "JP", singapore: "SG", brazil: "BR", mexico: "MX" };
- 107const country = (s) => {
- 108 const t = String(s || "").trim();
- 109 return COUNTRY[t.toLowerCase()] || t.toUpperCase();
- 110};
- 111function employees(s) {
- 112 const t = String(s || "").replace(/,/g, "");
- 113 const nums = t.match(/\d+(?:\.\d+)?/g);
- 114 if (!nums) return null;
- 115 let v = Number(nums[0]);
- 116 if (/k\b/i.test(t) && v < 1000) v *= 1000;
- 117 if (nums.length >= 2 && /\d\s*(-|to)\s*\d/.test(t)) return Math.round((Number(nums[0]) + Number(nums[1])) / 2);
- 118 return v;
- 119}
- 121function view(r) {
- 122 const v = {};
- 123 for (const [k, val] of Object.entries(r)) v[norm(k)] = val;
- 124 v.name = cols.name ? r[cols.name] : "";
- 125 v.domain = cols.domain ? r[cols.domain] : "";
- 126 v.industry = cols.industry ? r[cols.industry] : v.industry || "";
- 127 v.employees = employees(r[cols.employees]);
- 128 v.country = cols.country ? country(r[cols.country]) : "";
- 129 return v;
- 130}
- 131const lc = (x) => String(x ?? "").toLowerCase().trim();
- 132function test(rule, v) {
- 133 const raw = v[norm(rule.field)];
- 134 const s = lc(raw);
- 135 const vals = [].concat(rule.value ?? []).map(lc);
- 136 const n = typeof raw === "number" ? raw : Number(String(raw ?? "").replace(/[,%$]/g, ""));
- 137 switch (rule.op) {
- 138 case "in":
- 139 return vals.includes(s);
- 140 case "not_in":
- 141 return s !== "" && !vals.includes(s);
- 142 case "==":
- 143 return s === vals[0];
- 144 case "contains_any": {
- 145 const parts = s.split(/[;,|]/).map((x) => x.trim());
- 146 return vals.some((x) => parts.includes(x) || s.includes(x));
- 147 }
- 148 case "exists":
- 149 return s !== "";
- 150 case "between":
- 151 return Number.isFinite(n) && raw !== "" && n >= Number(rule.value[0]) && n <= Number(rule.value[1]);
- 152 case ">=":
- 153 return Number.isFinite(n) && raw !== "" && n >= Number(rule.value);
- 154 case "<=":
- 155 return Number.isFinite(n) && raw !== "" && n <= Number(rule.value);
- 156 default:
- 157 fail(`unknown op "${rule.op}" (use in, not_in, ==, contains_any, exists, between, >=, <=)`);
- 158 }
- 159}
- 160for (const r of scoreRules) {
- 161 if (!r.field || !r.op || typeof r.points !== "number") fail("every score rule needs field, op, and points");
- 162 test(r, {}); // validates op
- 163}
- 165const inSet = (list, x) => list.map(lc).includes(lc(x));
- 166const accounts = [];
- 167const missing = { employees: 0, country: 0, industry: 0 };
- 168for (const r of records) {
- 169 const v = view(r);
- 170 if (v.employees == null) missing.employees++;
- 171 if (cols.country && !v.country) missing.country++;
- 172 if (!v.industry) missing.industry++;
- 173 const outReasons = [];
- 174 if (market.countries && !inSet(market.countries, v.country)) outReasons.push(v.country ? `country ${v.country}` : "no country");
- 175 if (market.employees_min != null && !(v.employees >= market.employees_min)) outReasons.push(v.employees == null ? "no employee count" : `under ${market.employees_min} employees`);
- 176 if (market.employees_max != null && v.employees != null && v.employees > market.employees_max) outReasons.push(`over ${market.employees_max} employees`);
- 177 if (market.industries && !inSet(market.industries, v.industry)) outReasons.push(v.industry ? `industry ${v.industry}` : "no industry");
- 178 if (market.exclude_industries && inSet(market.exclude_industries, v.industry)) outReasons.push(`excluded industry ${v.industry}`);
- 179 const inMarket = outReasons.length === 0;
- 180 let score = 0;
- 181 const why = [];
- 182 for (const rule of scoreRules)
- 183 if (test(rule, v)) {
- 184 score += rule.points;
- 185 why.push(rule.label || `${rule.field} ${rule.op} ${JSON.stringify(rule.value)}`);
- 186 }
- 187 const tier = inMarket ? tiers.find((t) => score >= t.min_score)?.name || "Unscored" : "Out of market";
- 188 const band = v.employees == null ? null : bands.find((b) => b.max == null || v.employees <= b.max);
- 189 accounts.push({ name: v.name, domain: v.domain, industry: v.industry, country: v.country, employees: v.employees, in_market: inMarket, out_reasons: outReasons, score, tier, band: band ? band.name : "Unknown size", acv: band ? Number(band.acv || 0) : 0, why });
- 190}
- 192const sum = (list) => ({ companies: list.length, dollars: list.reduce((a, x) => a + x.acv, 0) });
- 193const sam = accounts.filter((a) => a.in_market);
- 194const tierNames = [...tiers.map((t) => t.name), "Unscored"];
- 195const byTier = Object.fromEntries(tierNames.map((t) => [t, sum(sam.filter((a) => a.tier === t))]));
- 196const bandNames = [...bands.map((b) => b.name), "Unknown size"];
- 197const matrix = Object.fromEntries(
- 198 bandNames
- 199 .map((b) => [b, Object.fromEntries(tierNames.map((t) => [t, sam.filter((a) => a.band === b && a.tier === t).length]))])
- 200 .filter(([, row]) => Object.values(row).some((x) => x > 0)),
- 201);
- 202const outCounts = {};
- 203for (const a of accounts) for (const r of a.out_reasons) {
- 204 const key = r.startsWith("country ") ? "country not in market" : r.startsWith("industry ") ? "industry not in market" : r.startsWith("excluded industry") ? "excluded industry" : r;
- 205 outCounts[key] = (outCounts[key] || 0) + 1;
- 206}
- 207const industries = {};
- 208for (const a of sam) industries[a.industry || "(blank)"] = (industries[a.industry || "(blank)"] || 0) + 1;
- 209const target = sam.filter((a) => (icp.target_tiers || tiers.slice(0, 2).map((t) => t.name)).includes(a.tier));
- 211const result = {
- 212 companies_in_file: accounts.length,
- 213 tam: sum(accounts),
- 214 sam: sum(sam),
- 215 target: { tiers: icp.target_tiers || tiers.slice(0, 2).map((t) => t.name), ...sum(target) },
- 216 by_tier: byTier,
- 217 band_by_tier: matrix,
- 218 out_of_market_reasons: outCounts,
- 219 top_industries_in_sam: Object.entries(industries).sort((a, b) => b[1] - a[1]).slice(0, 10).map(([industry, companies]) => ({ industry, companies })),
- 220 missing_data: missing,
- 221 columns_used: cols,
- 222};
- 224const money = (n) => "$" + Math.round(n).toLocaleString("en-US");
- 225const md = [`# TAM: ${icp.name || "market sizing"}`, ""];
- 226md.push("| Layer | Companies | Annual value |", "| --- | ---: | ---: |");
- 227md.push(`| TAM (every company in the file) | ${result.tam.companies} | ${money(result.tam.dollars)} |`);
- 228md.push(`| SAM (inside market filters) | ${result.sam.companies} | ${money(result.sam.dollars)} |`);
- 229md.push(`| Target (tiers ${result.target.tiers.join(", ")}) | ${result.target.companies} | ${money(result.target.dollars)} |`, "");
- 230md.push("## ICP tiers inside the SAM", "", "| Tier | Companies | Annual value |", "| --- | ---: | ---: |");
- 231for (const [t, v] of Object.entries(byTier)) md.push(`| ${t} | ${v.companies} | ${money(v.dollars)} |`);
- 232md.push("", "## Size band by tier (companies)", "", `| Band | ${tierNames.join(" | ")} |`, `| --- | ${tierNames.map(() => "---:").join(" | ")} |`);
- 233for (const [b, row] of Object.entries(matrix)) md.push(`| ${b} | ${tierNames.map((t) => row[t]).join(" | ")} |`);
- 234md.push("", "## Why companies are out of market", "");
- 235for (const [k, v] of Object.entries(outCounts).sort((a, b) => b[1] - a[1])) md.push(`- ${k}: ${v}`);
- 236md.push("", `Missing data: ${missing.employees} without employee count, ${missing.country} without country, ${missing.industry} without industry.`, "");
- 237md.push("Dollar figures are company count times ACV by size band: a planning number, not a forecast.", "");
- 239const cols2 = ["name", "domain", "industry", "country", "employees", "band", "tier", "score", "acv", "why", "out_reasons"];
- 240const csv = [cols2.join(",")]
- 241 .concat(
- 242 [...accounts]
- 243 .sort((a, b) => Number(b.in_market) - Number(a.in_market) || b.score - a.score || (b.employees || 0) - (a.employees || 0))
- 244 .map((a) => cols2.map((c) => csvCell(Array.isArray(a[c]) ? a[c].join("; ") : (a[c] ?? ""))).join(",")),
- 245 )
- 246 .join("\n");
- 248mkdirSync(opts.out, { recursive: true });
- 249writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify(result, null, 2) + "\n");
- 250writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
- 251writeFileSync(join(opts.out, `${opts.name}-accounts.csv`), csv + "\n");
- 252console.log(`TAM ${result.tam.companies} companies (${money(result.tam.dollars)}), SAM ${result.sam.companies} (${money(result.sam.dollars)}), target ${result.target.companies} (${money(result.target.dollars)}). Wrote ${join(opts.out, opts.name)}.{md,json} and ${opts.name}-accounts.csv`);
