Skip to content
SlashClone
Made by@revopslab

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.

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