Skip to content
SlashClone

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.

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