Crm field audit
@revopslab/crm-field-audit
Audit the fields in any CRM export (HubSpot, Salesforce, or other CSV). Reports fill rates, placeholder junk like n/a and unknown, top values, and flags empty, sparse, constant, default-heavy, and likely duplicate fields, plus fill rates by owner or team. Ends with a cleanup plan: what to hide, merge, make required, or fix.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// crm-field-audit: profile every column of a CRM export. Fill rates, placeholder
- 3// values, distinct counts, top values, inferred types, and flags for empty,
- 4// sparse, constant, default-heavy, and likely duplicate fields. Optional fill
- 5// rates by group (for example by owner). Node 22, built-ins only.
- 6//
- 7// Usage:
- 8// node scripts/field-audit.mjs <export.csv> [--sparse 10] [--group-by "Owner"]
- 9// [--key-fields "Industry,Amount"] [--out out] [--name field-audit]
- 11import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
- 12import { join } from "node:path";
- 14function fail(msg) {
- 15 console.error(`crm-field-audit: ${msg}`);
- 16 process.exit(1);
- 17}
- 19const args = process.argv.slice(2);
- 20const opts = { out: "out", name: "field-audit", sparse: 10, groupBy: null, keys: [] };
- 21const pos = [];
- 22for (let i = 0; i < args.length; i++) {
- 23 const a = args[i];
- 24 if (a === "--out") opts.out = args[++i];
- 25 else if (a === "--name") opts.name = args[++i];
- 26 else if (a === "--sparse") opts.sparse = Number(args[++i]);
- 27 else if (a === "--group-by") opts.groupBy = args[++i];
- 28 else if (a === "--key-fields") opts.keys = String(args[++i]).split(",").map((s) => s.trim()).filter(Boolean);
- 29 else if (a === "--help" || a === "-h") {
- 30 console.log('Usage: node scripts/field-audit.mjs <export.csv> [--sparse 10] [--group-by "Owner"] [--key-fields "A,B"] [--out out] [--name field-audit]');
- 31 process.exit(0);
- 32 } else pos.push(a);
- 33}
- 34if (pos.length !== 1) fail("give one CSV export. Try --help.");
- 35if (!Number.isFinite(opts.sparse) || opts.sparse < 0 || opts.sparse > 100) fail("--sparse must be a percent between 0 and 100");
- 37function parseCsv(text) {
- 38 if (text.charCodeAt(0) === 0xfeff) text = text.slice(1);
- 39 const rows = [];
- 40 let row = [];
- 41 let field = "";
- 42 let q = false;
- 43 for (let i = 0; i < text.length; i++) {
- 44 const c = text[i];
- 45 if (q) {
- 46 if (c === '"') {
- 47 if (text[i + 1] === '"') {
- 48 field += '"';
- 49 i++;
- 50 } else q = false;
- 51 } else field += c;
- 52 } else if (c === '"') q = true;
- 53 else if (c === ",") {
- 54 row.push(field);
- 55 field = "";
- 56 } else if (c === "\n" || c === "\r") {
- 57 if (c === "\r" && text[i + 1] === "\n") i++;
- 58 row.push(field);
- 59 rows.push(row);
- 60 row = [];
- 61 field = "";
- 62 } else field += c;
- 63 }
- 64 if (field !== "" || row.length) {
- 65 row.push(field);
- 66 rows.push(row);
- 67 }
- 68 const data = rows.filter((r) => r.some((v) => v.trim() !== ""));
- 69 if (!data.length) return { header: [], records: [] };
- 70 const header = data.shift().map((h) => h.trim());
- 71 return { header, records: data.map((r) => header.map((_, i) => (r[i] ?? "").trim())) };
- 72}
- 74let text;
- 75try {
- 76 text = readFileSync(pos[0], "utf8");
- 77} catch {
- 78 fail(`cannot read ${pos[0]}`);
- 79}
- 80const { header, records } = parseCsv(text);
- 81if (!header.length) fail("the file is empty");
- 82const n = records.length;
- 83if (n === 0) fail("the file has a header but no rows");
- 84for (const k of [...opts.keys, ...(opts.groupBy ? [opts.groupBy] : [])])
- 85 if (!header.includes(k)) fail(`column "${k}" not found. Columns are: ${header.join(", ")}`);
- 87const PLACEHOLDERS = new Set(["n/a", "none", "null", "nil", "-", "--", "tbd", "unknown", "test", "x", ".", "?", "not set", "not provided", "other - please specify", "select one", "--none--"]);
- 88const isPlaceholder = (v) => PLACEHOLDERS.has(v.toLowerCase());
- 90function inferType(values) {
- 91 if (!values.length) return "empty";
- 92 const share = (re) => values.filter((v) => re.test(v)).length / values.length;
- 93 if (share(/^(true|false|yes|no|y|n|0|1)$/i) >= 0.95 && new Set(values.map((v) => v.toLowerCase())).size <= 4) return "boolean";
- 94 if (share(/^-?[$€£]?\s?-?[\d,]+(\.\d+)?%?$/) >= 0.95) return "number";
- 95 if (share(/^\d{4}-\d{2}-\d{2}|^\d{1,2}\/\d{1,2}\/\d{2,4}/) >= 0.95) return "date";
- 96 if (share(/^[^@\s]+@[^@\s]+\.[^@\s]+$/) >= 0.95) return "email";
- 97 if (share(/^(https?:\/\/|www\.)/i) >= 0.95) return "url";
- 98 const distinct = new Set(values).size;
- 99 if (distinct <= 25 && values.length >= distinct * 2) return "picklist";
- 100 return "text";
- 101}
- 103const fields = header.map((name, col) => {
- 104 const raw = records.map((r) => r[col]);
- 105 const placeholders = raw.filter((v) => v !== "" && isPlaceholder(v)).length;
- 106 const values = raw.filter((v) => v !== "" && !isPlaceholder(v));
- 107 const counts = new Map();
- 108 for (const v of values) counts.set(v, (counts.get(v) || 0) + 1);
- 109 const type = inferType(values);
- 110 const top = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, 3);
- 111 const fill = (values.length / n) * 100;
- 112 const flags = [];
- 113 const idName = /(^|[^a-z])id$|^id\b|record.?id|uuid|\bkey$/i.test(name);
- 114 const identifier = values.length === n && counts.size === n && n > 1 && (idName || ["text", "email", "url"].includes(type));
- 115 if (values.length === 0) flags.push("empty");
- 116 else if (fill < opts.sparse) flags.push("sparse");
- 117 if (!identifier && values.length > 1 && counts.size === 1) flags.push("constant");
- 118 if (counts.size > 1 && top[0] && top[0][1] / values.length >= 0.9 && values.length >= 10) flags.push("default_heavy");
- 119 if (placeholders > 0 && placeholders / n >= 0.05) flags.push("placeholders");
- 120 return {
- 121 field: name,
- 122 type: identifier ? "identifier" : type,
- 123 filled: values.length,
- 124 fill_rate: Math.round(fill * 10) / 10,
- 125 placeholders,
- 126 distinct: counts.size,
- 127 top_values: top.map(([v, c]) => ({ value: v.length > 60 ? v.slice(0, 57) + "..." : v, count: c, share: Math.round((c / values.length) * 1000) / 10 })),
- 128 flags,
- 129 key_field: opts.keys.includes(name),
- 130 _col: col,
- 131 };
- 132});
- 134// likely duplicates: similar names, or the same values on rows where both are filled
- 135const base = (s) =>
- 136 s
- 137 .toLowerCase()
- 138 .replace(/__c$|_c$/, "")
- 139 .replace(/\b(custom|new|old|copy|legacy|v\d)\b/g, "")
- 140 .replace(/\d+$/, "")
- 141 .replace(/[^a-z]+/g, "");
- 142const duplicates = [];
- 143for (let i = 0; i < fields.length; i++)
- 144 for (let j = i + 1; j < fields.length; j++) {
- 145 const a = fields[i];
- 146 const b = fields[j];
- 147 if (a.type === "identifier" || b.type === "identifier") continue;
- 148 const reasons = [];
- 149 if (base(a.field) && base(a.field) === base(b.field)) reasons.push("similar names");
- 150 let both = 0;
- 151 let same = 0;
- 152 for (const r of records) {
- 153 const x = r[a._col];
- 154 const y = r[b._col];
- 155 if (x && y && !isPlaceholder(x) && !isPlaceholder(y)) {
- 156 both++;
- 157 if (x.toLowerCase() === y.toLowerCase()) same++;
- 158 }
- 159 }
- 160 if (both >= 5 && same / both >= 0.95 && a.distinct > 1) reasons.push(`same value on ${same} of ${both} rows where both are filled`);
- 161 if (reasons.length) duplicates.push({ fields: [a.field, b.field], fill_rates: [a.fill_rate, b.fill_rate], reasons });
- 162 }
- 163for (const d of duplicates) for (const f of fields) if (d.fields.includes(f.field) && !f.flags.includes("possible_duplicate")) f.flags.push("possible_duplicate");
- 165// fill rate by group
- 166let byGroup = null;
- 167if (opts.groupBy) {
- 168 const gCol = header.indexOf(opts.groupBy);
- 169 const target = opts.keys.length ? fields.filter((f) => f.key_field) : fields.filter((f) => f.field !== opts.groupBy && f.type !== "identifier");
- 170 const groups = new Map();
- 171 for (const r of records) {
- 172 const g = r[gCol] || "(blank)";
- 173 if (!groups.has(g)) groups.set(g, []);
- 174 groups.get(g).push(r);
- 175 }
- 176 byGroup = Object.fromEntries(
- 177 [...groups.entries()].sort().map(([g, rows]) => {
- 178 const rates = Object.fromEntries(
- 179 target.map((f) => [f.field, Math.round((rows.filter((r) => r[f._col] && !isPlaceholder(r[f._col])).length / rows.length) * 1000) / 10]),
- 180 );
- 181 const avg = Object.values(rates).reduce((a, b) => a + b, 0) / (target.length || 1);
- 182 return [g, { rows: rows.length, average_fill_rate: Math.round(avg * 10) / 10, fill_rates: rates }];
- 183 }),
- 184 );
- 185}
- 187for (const f of fields) delete f._col;
- 188const count = (flag) => fields.filter((f) => f.flags.includes(flag)).length;
- 189const result = {
- 190 file_rows: n,
- 191 fields: fields.length,
- 192 sparse_threshold_pct: opts.sparse,
- 193 summary: {
- 194 empty: count("empty"),
- 195 sparse: count("sparse"),
- 196 constant: count("constant"),
- 197 default_heavy: count("default_heavy"),
- 198 placeholders: count("placeholders"),
- 199 possible_duplicates: duplicates.length,
- 200 healthy: fields.filter((f) => f.flags.length === 0).length,
- 201 },
- 202 key_fields: fields.filter((f) => f.key_field).map((f) => ({ field: f.field, fill_rate: f.fill_rate })),
- 203 duplicates,
- 204 by_group: byGroup,
- 205 field_profiles: fields,
- 206};
- 208const md = [`# CRM field audit`, ""];
- 209md.push(`${n} records, ${fields.length} fields. Empty ${result.summary.empty}, sparse (under ${opts.sparse}%) ${result.summary.sparse}, constant ${result.summary.constant}, default-heavy ${result.summary.default_heavy}, placeholder values ${result.summary.placeholders}, possible duplicate pairs ${duplicates.length}.`, "");
- 210if (result.key_fields.length) {
- 211 md.push("## Key fields", "", "| Field | Fill rate |", "| --- | ---: |");
- 212 for (const k of result.key_fields) md.push(`| ${k.field} | ${k.fill_rate}% |`);
- 213 md.push("");
- 214}
- 215md.push("## All fields (lowest fill first)", "", "| Field | Type | Fill | Distinct | Top value | Flags |", "| --- | --- | ---: | ---: | --- | --- |");
- 216for (const f of [...fields].sort((a, b) => a.fill_rate - b.fill_rate || a.field.localeCompare(b.field)))
- 217 md.push(`| ${f.field} | ${f.type} | ${f.fill_rate}% | ${f.distinct} | ${f.top_values[0] ? `${f.top_values[0].value} (${f.top_values[0].share}%)` : ""} | ${f.flags.join(", ")} |`);
- 218md.push("");
- 219if (duplicates.length) {
- 220 md.push("## Possible duplicates", "");
- 221 for (const d of duplicates) md.push(`- ${d.fields.join(" and ")} (${d.fill_rates.join("% and ")}%): ${d.reasons.join("; ")}`);
- 222 md.push("");
- 223}
- 224if (byGroup) {
- 225 md.push(`## Fill rate by ${opts.groupBy}`, "", `| ${opts.groupBy} | Records | Average fill |`, "| --- | ---: | ---: |");
- 226 for (const [g, v] of Object.entries(byGroup)) md.push(`| ${g} | ${v.rows} | ${v.average_fill_rate}% |`);
- 227 md.push("");
- 228}
- 230mkdirSync(opts.out, { recursive: true });
- 231writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify(result, null, 2) + "\n");
- 232writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
- 233console.log(`${fields.length} fields audited over ${n} records: ${result.summary.healthy} healthy. Wrote ${join(opts.out, opts.name)}.{md,json}`);
