@reddoorla/maintenance 0.72.0 → 0.74.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/dist/airtable-6L3JD7FY.js +9 -0
  2. package/dist/{announce-HJCGEVJA.js → announce-V6EYPPUR.js} +4 -4
  3. package/dist/{blux-FKZYMKLF.js → blux-OL5HKP26.js} +950 -69
  4. package/dist/blux-OL5HKP26.js.map +1 -0
  5. package/dist/{bump-deps-GZEJAUKI.js → bump-deps-OQR3IXLN.js} +2 -2
  6. package/dist/chunk-2OP4JKIQ.js +19 -0
  7. package/dist/chunk-2OP4JKIQ.js.map +1 -0
  8. package/dist/{chunk-BJCA7O4A.js → chunk-3PU2QWOJ.js} +2 -2
  9. package/dist/{chunk-GI4M2AMO.js → chunk-4BHNP6Y2.js} +2 -2
  10. package/dist/{chunk-KRHPS3R4.js → chunk-6OYICQSJ.js} +47 -3
  11. package/dist/chunk-6OYICQSJ.js.map +1 -0
  12. package/dist/{chunk-BWKK2ZXS.js → chunk-GXVX4HUH.js} +32 -2
  13. package/dist/chunk-GXVX4HUH.js.map +1 -0
  14. package/dist/{chunk-KLB2OLPW.js → chunk-HBFMTM2E.js} +3 -3
  15. package/dist/{chunk-SQJ53Z7R.js → chunk-I55YWV6T.js} +3 -3
  16. package/dist/{chunk-7P7YETHG.js → chunk-IDBMYDVH.js} +2 -2
  17. package/dist/{chunk-JGLAWXUC.js → chunk-JR67FZVC.js} +7 -3
  18. package/dist/chunk-JR67FZVC.js.map +1 -0
  19. package/dist/{chunk-APTAITXZ.js → chunk-KKL3RYFQ.js} +2 -2
  20. package/dist/{chunk-NRISG4XD.js → chunk-NUIBQPB3.js} +42 -11
  21. package/dist/chunk-NUIBQPB3.js.map +1 -0
  22. package/dist/{chunk-BHE5CUWI.js → chunk-PERP7LSS.js} +269 -18
  23. package/dist/chunk-PERP7LSS.js.map +1 -0
  24. package/dist/chunk-RHPDQW6D.js +29 -0
  25. package/dist/chunk-RHPDQW6D.js.map +1 -0
  26. package/dist/{chunk-HR5SONJC.js → chunk-SK44IFZN.js} +2 -2
  27. package/dist/cli/bin.js +31 -19
  28. package/dist/cli/bin.js.map +1 -1
  29. package/dist/cli/commands/audit.js +6 -6
  30. package/dist/{convert-to-pnpm-SVFDLXZS.js → convert-to-pnpm-OVMASATZ.js} +2 -2
  31. package/dist/{digest-VX7Q4MNF.js → digest-LEMZS5ZK.js} +99 -7
  32. package/dist/digest-LEMZS5ZK.js.map +1 -0
  33. package/dist/{ensure-site-O7G5ZJS7.js → ensure-site-3SOAWTZF.js} +2 -2
  34. package/dist/forms/index.js +1 -1
  35. package/dist/{github-signals-QVW2LB7N.js → github-signals-TF3VSNIN.js} +4 -4
  36. package/dist/{health-endpoint-IYR5VBUT.js → health-endpoint-JZGYCMTV.js} +2 -2
  37. package/dist/index.d.ts +104 -19
  38. package/dist/index.js +122 -30
  39. package/dist/index.js.map +1 -1
  40. package/dist/{init-CU6PVZRG.js → init-7PTP4O2K.js} +4 -4
  41. package/dist/{launch-JWXCTJRG.js → launch-W33FWWOZ.js} +6 -6
  42. package/dist/{lighthouse-airtable-J5TLASJC.js → lighthouse-airtable-ZXNMDEPE.js} +3 -3
  43. package/dist/{onboard-6UQE5QIO.js → onboard-PX36YDZA.js} +2 -2
  44. package/dist/{orchestrate-ZDH24Z6Y.js → orchestrate-NYRYEKQM.js} +3 -3
  45. package/dist/{preflight-BNTMWTPG.js → preflight-JCQFKB6M.js} +4 -4
  46. package/dist/{renovate-dispatch-C3ALLACX.js → renovate-dispatch-6II2JVOX.js} +29 -27
  47. package/dist/renovate-dispatch-6II2JVOX.js.map +1 -0
  48. package/dist/{report-YLFSGNWR.js → report-POHQEV6F.js} +21 -5
  49. package/dist/report-POHQEV6F.js.map +1 -0
  50. package/dist/screenouts-7P5GOZMV.js +57 -0
  51. package/dist/screenouts-7P5GOZMV.js.map +1 -0
  52. package/dist/{self-updating-64FNRQB5.js → self-updating-2YSVNMPP.js} +2 -2
  53. package/dist/{selftest-7DPNGL6R.js → selftest-OO2WDSDM.js} +5 -5
  54. package/dist/{smoke-suite-TWILWDSF.js → smoke-suite-CSP23NZM.js} +2 -2
  55. package/dist/spam-classifier-VG4ICIWS.js +217 -0
  56. package/dist/spam-classifier-VG4ICIWS.js.map +1 -0
  57. package/dist/submissions-BNRGY3JQ.js +122 -0
  58. package/dist/submissions-BNRGY3JQ.js.map +1 -0
  59. package/dist/submissions-MDKWJYMM.js +281 -0
  60. package/dist/submissions-MDKWJYMM.js.map +1 -0
  61. package/dist/{svelte-codemods-RVDTTXFD.js → svelte-codemods-V54KOBWU.js} +2 -2
  62. package/dist/{sync-configs-BUUD6L5Q.js → sync-configs-UBCJRASZ.js} +2 -2
  63. package/dist/{upgrade-HHKWYJRN.js → upgrade-WR6ICPAG.js} +2 -2
  64. package/dist/{websites-EH2ULELZ.js → websites-U6642XSE.js} +10 -2
  65. package/dist/{write-audits-to-airtable-LZDYTNX5.js → write-audits-to-airtable-22T6D733.js} +4 -4
  66. package/package.json +1 -1
  67. package/dist/airtable-U2GTDTK5.js +0 -9
  68. package/dist/blux-FKZYMKLF.js.map +0 -1
  69. package/dist/chunk-BHE5CUWI.js.map +0 -1
  70. package/dist/chunk-BWKK2ZXS.js.map +0 -1
  71. package/dist/chunk-JGLAWXUC.js.map +0 -1
  72. package/dist/chunk-KRHPS3R4.js.map +0 -1
  73. package/dist/chunk-NRISG4XD.js.map +0 -1
  74. package/dist/chunk-QTHKOIFJ.js +0 -13
  75. package/dist/chunk-QTHKOIFJ.js.map +0 -1
  76. package/dist/digest-VX7Q4MNF.js.map +0 -1
  77. package/dist/renovate-dispatch-C3ALLACX.js.map +0 -1
  78. package/dist/report-YLFSGNWR.js.map +0 -1
  79. /package/dist/{airtable-U2GTDTK5.js.map → airtable-6L3JD7FY.js.map} +0 -0
  80. /package/dist/{announce-HJCGEVJA.js.map → announce-V6EYPPUR.js.map} +0 -0
  81. /package/dist/{bump-deps-GZEJAUKI.js.map → bump-deps-OQR3IXLN.js.map} +0 -0
  82. /package/dist/{chunk-BJCA7O4A.js.map → chunk-3PU2QWOJ.js.map} +0 -0
  83. /package/dist/{chunk-GI4M2AMO.js.map → chunk-4BHNP6Y2.js.map} +0 -0
  84. /package/dist/{chunk-KLB2OLPW.js.map → chunk-HBFMTM2E.js.map} +0 -0
  85. /package/dist/{chunk-SQJ53Z7R.js.map → chunk-I55YWV6T.js.map} +0 -0
  86. /package/dist/{chunk-7P7YETHG.js.map → chunk-IDBMYDVH.js.map} +0 -0
  87. /package/dist/{chunk-APTAITXZ.js.map → chunk-KKL3RYFQ.js.map} +0 -0
  88. /package/dist/{chunk-HR5SONJC.js.map → chunk-SK44IFZN.js.map} +0 -0
  89. /package/dist/{convert-to-pnpm-SVFDLXZS.js.map → convert-to-pnpm-OVMASATZ.js.map} +0 -0
  90. /package/dist/{ensure-site-O7G5ZJS7.js.map → ensure-site-3SOAWTZF.js.map} +0 -0
  91. /package/dist/{github-signals-QVW2LB7N.js.map → github-signals-TF3VSNIN.js.map} +0 -0
  92. /package/dist/{health-endpoint-IYR5VBUT.js.map → health-endpoint-JZGYCMTV.js.map} +0 -0
  93. /package/dist/{init-CU6PVZRG.js.map → init-7PTP4O2K.js.map} +0 -0
  94. /package/dist/{launch-JWXCTJRG.js.map → launch-W33FWWOZ.js.map} +0 -0
  95. /package/dist/{lighthouse-airtable-J5TLASJC.js.map → lighthouse-airtable-ZXNMDEPE.js.map} +0 -0
  96. /package/dist/{onboard-6UQE5QIO.js.map → onboard-PX36YDZA.js.map} +0 -0
  97. /package/dist/{orchestrate-ZDH24Z6Y.js.map → orchestrate-NYRYEKQM.js.map} +0 -0
  98. /package/dist/{preflight-BNTMWTPG.js.map → preflight-JCQFKB6M.js.map} +0 -0
  99. /package/dist/{self-updating-64FNRQB5.js.map → self-updating-2YSVNMPP.js.map} +0 -0
  100. /package/dist/{selftest-7DPNGL6R.js.map → selftest-OO2WDSDM.js.map} +0 -0
  101. /package/dist/{smoke-suite-TWILWDSF.js.map → smoke-suite-CSP23NZM.js.map} +0 -0
  102. /package/dist/{svelte-codemods-RVDTTXFD.js.map → svelte-codemods-V54KOBWU.js.map} +0 -0
  103. /package/dist/{sync-configs-BUUD6L5Q.js.map → sync-configs-UBCJRASZ.js.map} +0 -0
  104. /package/dist/{upgrade-HHKWYJRN.js.map → upgrade-WR6ICPAG.js.map} +0 -0
  105. /package/dist/{websites-EH2ULELZ.js.map → websites-U6642XSE.js.map} +0 -0
  106. /package/dist/{write-audits-to-airtable-LZDYTNX5.js.map → write-audits-to-airtable-22T6D733.js.map} +0 -0
@@ -0,0 +1,217 @@
1
+ // src/forms/spam-classifier.ts
2
+ var SPAM_THRESHOLD = 60;
3
+ var SPAM_KEYWORDS = [
4
+ "viagra",
5
+ "cialis",
6
+ "online casino",
7
+ "casino bonus",
8
+ "porn",
9
+ "payday loans online",
10
+ "buy crypto",
11
+ "crypto wallet",
12
+ "bitcoin investment",
13
+ "cheap seo",
14
+ "forex signals",
15
+ "escort girls",
16
+ "replica watches",
17
+ "weight loss pills",
18
+ // Cold-outreach / SEO-pitch vertical (2026-07-15, expanded from the live miss
19
+ // corpus the same day). Multi-word and solicitation-shaped: a real lead describing
20
+ // their OWN site/needs occasionally grazes one of these, but never two.
21
+ "guest post",
22
+ "guest article",
23
+ "article for your website",
24
+ "link building",
25
+ "backlinks",
26
+ "page one",
27
+ // "You're not on page one. Your competitors are." (covers "page one of google" too)
28
+ "first page",
29
+ // covers "first page of google"
30
+ "1st page",
31
+ // "The Wiki links show up on the 1st page of Google 97% of the time"
32
+ "google ranking",
33
+ "rank higher",
34
+ "drive traffic",
35
+ "increase your traffic",
36
+ "top of search results",
37
+ "people already searching",
38
+ "seo problem",
39
+ "seo process",
40
+ // "no long SEO process, no delay"
41
+ "leads and sales",
42
+ "position your brand",
43
+ "above competitors",
44
+ "businesses like yours",
45
+ "no obligation",
46
+ "would you be interested",
47
+ "would you be open",
48
+ "tried emailing you",
49
+ // the "I tried emailing you, but it seems it didn't go through" opener
50
+ // Virtual-assistant flood (MAVIS et al. — rotating sender names/domains with
51
+ // reworded bodies, so the exact-duplicate velocity signal can't see it; these are
52
+ // the template's invariants).
53
+ "virtual assistant",
54
+ "virtual intelligent system",
55
+ // "MAVIS (My Advanced Virtual Intelligent System)" — in every copy
56
+ "mavis",
57
+ "overtake and handle",
58
+ "custom built ai",
59
+ // matches "custom-built AI" via hyphen folding
60
+ // Wikipedia-page-creation vertical.
61
+ "wikipedia page",
62
+ "wiki links",
63
+ // Product-blast ad copy (the dog-harness family).
64
+ "get yours today",
65
+ "free shipping"
66
+ ];
67
+ var BUYER_KEYWORDS = ["within 24 hours", "free consultation"];
68
+ var DISPOSABLE_EMAIL_DOMAINS = [
69
+ "mailinator.com",
70
+ "guerrillamail.com",
71
+ "10minutemail.com",
72
+ "tempmail.com",
73
+ "trashmail.com",
74
+ "yopmail.com",
75
+ "sharklasers.com",
76
+ "getnada.com",
77
+ "throwawaymail.com",
78
+ "maildrop.cc"
79
+ ];
80
+ var URL_RE = /https?:\/\/[^\s,;]+|www\.[^\s,;]+/gi;
81
+ var LINK_MARKUP_RE = /<a\s[^>]*href|\[url[=\]]/i;
82
+ var ONLY_URL_RE = /^(https?:\/\/\S+|www\.\S+)$/i;
83
+ function countUrls(text) {
84
+ return (text.match(URL_RE) ?? []).length;
85
+ }
86
+ function countKeywordHits(text, keywords) {
87
+ const lower = text.toLowerCase().replace(/[-–—]/g, " ");
88
+ return keywords.filter((kw) => lower.includes(kw)).length;
89
+ }
90
+ var LOREM_STEMS = [
91
+ /\blorem\b/i,
92
+ /\bipsum\b/i,
93
+ /\bvelit\b/i,
94
+ /\bullam/i,
95
+ /\bharum\b/i,
96
+ /\breprehen/i,
97
+ /\bvolupt/i,
98
+ /\bnostrud\b/i,
99
+ /\badipisc/i,
100
+ /\beiusmod\b/i,
101
+ /\bincididunt\b/i,
102
+ /\bconsectetur\b/i,
103
+ /\baliqua\b/i
104
+ ];
105
+ function countLoremStems(text) {
106
+ return LOREM_STEMS.filter((re) => re.test(text)).length;
107
+ }
108
+ function nonLatinRatio(text) {
109
+ const letters = text.match(new RegExp("\\p{L}", "gu")) ?? [];
110
+ if (letters.length === 0) return 0;
111
+ const nonLatin = letters.filter((ch) => !new RegExp("\\p{Script=Latin}", "u").test(ch)).length;
112
+ return nonLatin / letters.length;
113
+ }
114
+ function emailDomain(email) {
115
+ const at = email.lastIndexOf("@");
116
+ return at === -1 ? "" : email.slice(at + 1).trim().toLowerCase();
117
+ }
118
+ function isAllCaps(text) {
119
+ if (text.length <= 20) return false;
120
+ const letters = text.match(/[a-zA-Z]/g) ?? [];
121
+ if (letters.length === 0) return false;
122
+ const upper = letters.filter((c) => c >= "A" && c <= "Z").length;
123
+ return upper / letters.length > 0.7;
124
+ }
125
+ function hasGibberishToken(text, minLen) {
126
+ for (const run of text.match(/[A-Za-z]+/g) ?? []) {
127
+ if (run.length < minLen) continue;
128
+ if (/[^aeiouyAEIOUY]{7,}/.test(run)) return true;
129
+ let flips = 0;
130
+ for (let i = 1; i < run.length; i++) {
131
+ const prev = run[i - 1];
132
+ const cur = run[i];
133
+ if (prev >= "a" && prev <= "z" && cur >= "A" && cur <= "Z") flips++;
134
+ }
135
+ if (flips >= 3) return true;
136
+ }
137
+ return false;
138
+ }
139
+ var BARE_DOMAIN_RE = /(?<![@\w.])[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.(?:com|net|org|io|co|biz|info|online|site|shop|store|xyz|agency|digital|marketing|dev|app)\b/i;
140
+ function classifySpam(input) {
141
+ const name = input.name ?? "";
142
+ const email = input.email ?? "";
143
+ const message = input.message ?? "";
144
+ const reasons = [];
145
+ let score = 0;
146
+ const extraText = Object.values(input.extraFields ?? {}).filter((v) => typeof v === "string").join(" ");
147
+ const body = extraText ? `${message} ${extraText}` : message;
148
+ if (input.turnstile === "fail") {
149
+ score += 50;
150
+ reasons.push("turnstile-fail");
151
+ }
152
+ const urls = countUrls(body);
153
+ if (urls > 0) {
154
+ score += Math.min(urls, 2) * 25;
155
+ reasons.push(`links:${urls}`);
156
+ } else if (BARE_DOMAIN_RE.test(body)) {
157
+ score += 20;
158
+ reasons.push("bare-domain");
159
+ }
160
+ if (LINK_MARKUP_RE.test(body)) {
161
+ score += 40;
162
+ reasons.push("link-markup");
163
+ }
164
+ const seller = countKeywordHits(body, SPAM_KEYWORDS);
165
+ const buyer = countKeywordHits(body, BUYER_KEYWORDS);
166
+ if (seller > 0) {
167
+ const hits = seller + buyer;
168
+ score += Math.min(hits, 3) * 30;
169
+ reasons.push(`keywords:${hits}`);
170
+ } else if (buyer > 0) {
171
+ score += Math.min(buyer, 2) * 10;
172
+ reasons.push(`keywords-buyer:${buyer}`);
173
+ }
174
+ if (countLoremStems(body) >= 2) {
175
+ score += 60;
176
+ reasons.push("lorem-ipsum");
177
+ }
178
+ if (nonLatinRatio(body) > 0.3) {
179
+ score += 25;
180
+ reasons.push("non-latin");
181
+ }
182
+ if (hasGibberishToken(body, 10)) {
183
+ score += 35;
184
+ reasons.push("gibberish-body");
185
+ }
186
+ if (!name.trim().includes(" ") && hasGibberishToken(name, 12)) {
187
+ score += 35;
188
+ reasons.push("gibberish-name");
189
+ }
190
+ if (DISPOSABLE_EMAIL_DOMAINS.includes(emailDomain(email))) {
191
+ score += 45;
192
+ reasons.push("disposable-email");
193
+ }
194
+ if (countUrls(name) > 0) {
195
+ score += 45;
196
+ reasons.push("url-in-name");
197
+ }
198
+ const trimmedMsg = body.trim();
199
+ const degenerate = trimmedMsg.length > 0 && trimmedMsg === name.trim() || ONLY_URL_RE.test(trimmedMsg);
200
+ if (degenerate) {
201
+ score += 40;
202
+ reasons.push("degenerate");
203
+ }
204
+ if (isAllCaps(body)) {
205
+ score += 15;
206
+ reasons.push("all-caps");
207
+ }
208
+ return { score, reasons };
209
+ }
210
+ export {
211
+ BUYER_KEYWORDS,
212
+ DISPOSABLE_EMAIL_DOMAINS,
213
+ SPAM_KEYWORDS,
214
+ SPAM_THRESHOLD,
215
+ classifySpam
216
+ };
217
+ //# sourceMappingURL=spam-classifier-VG4ICIWS.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/forms/spam-classifier.ts"],"sourcesContent":["import type { FormType } from \"./types.js\";\nimport type { TurnstileOutcome } from \"./turnstile.js\";\n\n/**\n * A submission at or above this score is classified auto-spam by ingest.\n *\n * Lowered 100 → 60 (2026-07-15): live data showed the classifier auto-bucketed\n * essentially nothing while ~1-in-4 delivered messages were spam. The dominant\n * bypass — human-plausible Latin-script cold outreach (SEO / virtual-assistant\n * pitches) with 0-1 links — sums only 25-55 from content signals. 60 lets the\n * high-precision multi-word keyword phrases + the gibberish/bare-domain signals\n * below actually bite. No single signal buckets alone (each needs corroboration),\n * with ONE deliberate exception: lorem-ipsum filler (+60) is machine-generated\n * content with zero genuine use. `spam_auto` is recoverable, so a false positive\n * is a nuisance the operator can undo, not a lost lead.\n */\nexport const SPAM_THRESHOLD = 60;\n\nexport type SpamVerdict = { score: number; reasons: string[] };\n\n/**\n * Maintained SELLER-VOICE spam-keyword list (case-insensitive substring match on\n * hyphen-normalized text — see countKeywordHits). Tunable from the `spam_score` /\n * `spam_reason` data the pipeline records. Keep entries specific enough to avoid\n * false positives: where a term is also legitimate business vocabulary (casino\n * resorts, weight-loss studios, transport escorts, payday lenders), list only the\n * clearly-promotional phrasing, never the bare term.\n *\n * SEO/marketing-topic policy (operator decision 2026-07-15): the fleet's sites are\n * niche and specific — they rank top for their own names — and clients who want\n * SEO/marketing help ask the agency DIRECTLY, so SEO-topic content arriving through\n * a public contact form is near-always solicitation. SEO-topic phrases therefore\n * live HERE at full weight (an SEO-help ask through the form is deliberately\n * filtered; `spam_auto` is recoverable and the operator prefers hand-delivering the\n * rare real one over receiving the flood). Each entry is +30, so TWO seller phrases\n * bucket; no single phrase can (max single non-lorem signal is turnstile-fail's 50).\n *\n * NOTE: entries must not be substrings of each other (\"page one\" + \"page one of\n * google\" would double-count one occurrence) and must be written hyphen-free (the\n * matcher folds `-–—` to spaces, so \"link-building\" in a message matches\n * \"link building\" here).\n */\nexport const SPAM_KEYWORDS: readonly string[] = [\n \"viagra\",\n \"cialis\",\n \"online casino\",\n \"casino bonus\",\n \"porn\",\n \"payday loans online\",\n \"buy crypto\",\n \"crypto wallet\",\n \"bitcoin investment\",\n \"cheap seo\",\n \"forex signals\",\n \"escort girls\",\n \"replica watches\",\n \"weight loss pills\",\n // Cold-outreach / SEO-pitch vertical (2026-07-15, expanded from the live miss\n // corpus the same day). Multi-word and solicitation-shaped: a real lead describing\n // their OWN site/needs occasionally grazes one of these, but never two.\n \"guest post\",\n \"guest article\",\n \"article for your website\",\n \"link building\",\n \"backlinks\",\n \"page one\", // \"You're not on page one. Your competitors are.\" (covers \"page one of google\" too)\n \"first page\", // covers \"first page of google\"\n \"1st page\", // \"The Wiki links show up on the 1st page of Google 97% of the time\"\n \"google ranking\",\n \"rank higher\",\n \"drive traffic\",\n \"increase your traffic\",\n \"top of search results\",\n \"people already searching\",\n \"seo problem\",\n \"seo process\", // \"no long SEO process, no delay\"\n \"leads and sales\",\n \"position your brand\",\n \"above competitors\",\n \"businesses like yours\",\n \"no obligation\",\n \"would you be interested\",\n \"would you be open\",\n \"tried emailing you\", // the \"I tried emailing you, but it seems it didn't go through\" opener\n // Virtual-assistant flood (MAVIS et al. — rotating sender names/domains with\n // reworded bodies, so the exact-duplicate velocity signal can't see it; these are\n // the template's invariants).\n \"virtual assistant\",\n \"virtual intelligent system\", // \"MAVIS (My Advanced Virtual Intelligent System)\" — in every copy\n \"mavis\",\n \"overtake and handle\",\n \"custom built ai\", // matches \"custom-built AI\" via hyphen folding\n // Wikipedia-page-creation vertical.\n \"wikipedia page\",\n \"wiki links\",\n // Product-blast ad copy (the dog-harness family).\n \"get yours today\",\n \"free shipping\",\n];\n\n/**\n * BUYER-COMPATIBLE outreach phrases: common in cold pitches but also plausible in a\n * genuine prospect's own words (\"do you offer a free consultation?\", \"we need it\n * within 24 hours\"). Alone they score a weak +10 (capped at 2 hits / +20) so they\n * can never carry a message toward the threshold — but when at least one\n * seller-voice phrase is present the message is demonstrably a pitch, and these\n * PROMOTE to full keyword weight as corroboration.\n */\nexport const BUYER_KEYWORDS: readonly string[] = [\"within 24 hours\", \"free consultation\"];\n\n/** Maintained disposable / throwaway email domains. */\nexport const DISPOSABLE_EMAIL_DOMAINS: readonly string[] = [\n \"mailinator.com\",\n \"guerrillamail.com\",\n \"10minutemail.com\",\n \"tempmail.com\",\n \"trashmail.com\",\n \"yopmail.com\",\n \"sharklasers.com\",\n \"getnada.com\",\n \"throwawaymail.com\",\n \"maildrop.cc\",\n];\n\n// A URL candidate ends at whitespace, `,` or `;` so comma/semicolon-glued\n// URLs (\"a.com,b.com\") count individually instead of matching as one.\nconst URL_RE = /https?:\\/\\/[^\\s,;]+|www\\.[^\\s,;]+/gi;\nconst LINK_MARKUP_RE = /<a\\s[^>]*href|\\[url[=\\]]/i;\nconst ONLY_URL_RE = /^(https?:\\/\\/\\S+|www\\.\\S+)$/i;\n\n/** Count of bare http(s)/www URLs in a string. */\nfunction countUrls(text: string): number {\n return (text.match(URL_RE) ?? []).length;\n}\n\n/** How many of `keywords` appear in `text` (each counted once). Hyphens/dashes fold\n * to spaces first so \"link-building\" / \"custom-built AI\" match their space-separated\n * list entries — the hyphenated spelling was a live keyword dodge. */\nfunction countKeywordHits(text: string, keywords: readonly string[]): number {\n const lower = text.toLowerCase().replace(/[-–—]/g, \" \");\n return keywords.filter((kw) => lower.includes(kw)).length;\n}\n\n/** Distinct lorem-ipsum vocabulary stems. Form-tester bots submit truncated filler\n * (\"Velit ullam reprehen\", \"Dolore harum volupta\") — too short for the velocity\n * signal and invisible to gibberish (they're real Latin words). Two DISTINCT stems\n * are required so a lone romance-language cognate (voluptuous, tempora) can never\n * fire; no human writes two lorem-ipsum words in a genuine inquiry. */\nconst LOREM_STEMS: readonly RegExp[] = [\n /\\blorem\\b/i,\n /\\bipsum\\b/i,\n /\\bvelit\\b/i,\n /\\bullam/i,\n /\\bharum\\b/i,\n /\\breprehen/i,\n /\\bvolupt/i,\n /\\bnostrud\\b/i,\n /\\badipisc/i,\n /\\beiusmod\\b/i,\n /\\bincididunt\\b/i,\n /\\bconsectetur\\b/i,\n /\\baliqua\\b/i,\n];\n\nfunction countLoremStems(text: string): number {\n return LOREM_STEMS.filter((re) => re.test(text)).length;\n}\n\n/** Fraction of letters that are outside the Latin script (0..1). */\nfunction nonLatinRatio(text: string): number {\n const letters = text.match(/\\p{L}/gu) ?? [];\n if (letters.length === 0) return 0;\n const nonLatin = letters.filter((ch) => !/\\p{Script=Latin}/u.test(ch)).length;\n return nonLatin / letters.length;\n}\n\n/** Domain part of an email, lowercased; \"\" when unparseable. */\nfunction emailDomain(email: string): string {\n const at = email.lastIndexOf(\"@\");\n return at === -1\n ? \"\"\n : email\n .slice(at + 1)\n .trim()\n .toLowerCase();\n}\n\n/** len > 20 and > 70% of its letters uppercase. */\nfunction isAllCaps(text: string): boolean {\n if (text.length <= 20) return false;\n const letters = text.match(/[a-zA-Z]/g) ?? [];\n if (letters.length === 0) return false;\n const upper = letters.filter((c) => c >= \"A\" && c <= \"Z\").length;\n return upper / letters.length > 0.7;\n}\n\n/**\n * True when `text` has a token of >= `minLen` ASCII letters that looks like random\n * keyboard mash, via EITHER of two discriminators (measured 2026-07-15 against\n * /usr/share/dict/words + the live mash corpus — each alone misses live samples the\n * other catches, and together they flag ZERO dictionary words or common brand names):\n *\n * 1. A run of >= 7 consecutive non-vowels, with `y` counted as a VOWEL\n * (`OsDMQohNGefhfCqqQCwr` has a 10-run, `zddDVjhArCJ` a 7-run). The original\n * >=5-with-y-as-consonant rule fired on ordinary English — every psych* word\n * >= 10 letters (p-s-y-c-h is itself a 5-run), \"worthwhile\", \"nightclubs\",\n * 3,138 dictionary words in all — i.e. gibberish(+35) + one pasted link(+25)\n * silently bucketed whole genuine-lead verticals (a psychology practice!).\n * 2. >= 3 interior lower→upper case flips (`IjIiJuhkojCYrNDFTXe`, `XiwkUDgrboTgMSVX`\n * — live samples whose longest y-as-vowel run is only 6). Real CamelCase tokens\n * (JavaScript, SquareSpace, MailChimp) have at most 1-2 humps.\n *\n * LATIN a-z ONLY: a native-script name (王小明, Владимир) has no a-z letters here and\n * is never flagged — that is the non-latin signal's job, deliberately de-weighted so\n * a real foreign name isn't spam.\n */\nfunction hasGibberishToken(text: string, minLen: number): boolean {\n for (const run of text.match(/[A-Za-z]+/g) ?? []) {\n if (run.length < minLen) continue;\n if (/[^aeiouyAEIOUY]{7,}/.test(run)) return true;\n let flips = 0;\n for (let i = 1; i < run.length; i++) {\n const prev = run[i - 1]!;\n const cur = run[i]!;\n if (prev >= \"a\" && prev <= \"z\" && cur >= \"A\" && cur <= \"Z\") flips++;\n }\n if (flips >= 3) return true;\n }\n return false;\n}\n\n// A domain-like token with a known TLD but NO scheme/`www` (those are already caught by\n// URL_RE). The leading `(?<![@\\w.])` excludes an email's domain (`a@foo.com`), a\n// sub-label continuation, and file-ish `name.ext` runs. Curated TLD set keeps it from\n// firing on `node.js` / `index.html`. Used only when no real URL was found.\nconst BARE_DOMAIN_RE =\n /(?<![@\\w.])[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.(?:com|net|org|io|co|biz|info|online|site|shop|store|xyz|agency|digital|marketing|dev|app)\\b/i;\n\n/**\n * Pure content spam scorer. Folds message/name/email content signals plus the\n * Turnstile verdict into a numeric score with human-readable reason strings.\n * Never throws; `formType` is accepted for future per-type tuning.\n */\nexport function classifySpam(input: {\n name: string;\n email: string;\n message?: string;\n formType: FormType;\n extraFields: Record<string, unknown>;\n turnstile: TurnstileOutcome;\n}): SpamVerdict {\n const name = input.name ?? \"\";\n const email = input.email ?? \"\";\n const message = input.message ?? \"\";\n const reasons: string[] = [];\n let score = 0;\n\n // Site-specific free-text fields (e.g. extra.comments) carry the same spam\n // signals as message — fold every STRING extraFields value into the scanned\n // body so a site with a custom \"comments\"/\"details\" field isn't a blind spot.\n // Non-string values (numbers, booleans, nested objects) are ignored.\n const extraText = Object.values(input.extraFields ?? {})\n .filter((v): v is string => typeof v === \"string\")\n .join(\" \");\n const body = extraText ? `${message} ${extraText}` : message;\n\n // 50: post-#400 a \"fail\" is a FORGED token (invalid-input-response), near-certainly a\n // bot — but kept below the 60 threshold so a forged token alone still needs one\n // corroborating content signal before auto-bucketing (fail-open caution). A benign\n // human never reaches here: expired/duplicate tokens are \"unverifiable\", not \"fail\".\n if (input.turnstile === \"fail\") {\n score += 50;\n reasons.push(\"turnstile-fail\");\n }\n\n const urls = countUrls(body);\n if (urls > 0) {\n // Capped at 2 (max +50) so a genuine lead pasting two links (site + portfolio) stays\n // under 60 on URLs alone; a third adds nothing. 25/link keeps one link well shy of a\n // solo bucket.\n score += Math.min(urls, 2) * 25;\n reasons.push(`links:${urls}`);\n } else if (BARE_DOMAIN_RE.test(body)) {\n // No real http/www URL, but a bare \"brand.com\" is pasted — the exact dodge spammers\n // use to slip past URL_RE. +20, needs corroboration to bucket.\n score += 20;\n reasons.push(\"bare-domain\");\n }\n\n if (LINK_MARKUP_RE.test(body)) {\n score += 40;\n reasons.push(\"link-markup\");\n }\n\n // Two-tier keywords. Seller-voice phrases are unambiguous pitch language: +30\n // each (capped at 3 hits / +90) — TWO seller phrases bucket outright, per the\n // operator's 2026-07-15 steer (see the SPAM_KEYWORDS docstring): genuine leads on\n // this fleet write zero solicitor phrases, and SEO/marketing-help asks through\n // the public form are deliberately filtered. A seller hit also PROMOTES any\n // buyer-compatible phrases in the same message to full weight — a pitch that says\n // \"would you be interested\" AND \"within 24 hours\" is corroborating itself.\n // WITHOUT a seller-voice phrase, buyer-compatible hits alone score a weak +10\n // capped at +20, never enough to carry a message toward the threshold.\n const seller = countKeywordHits(body, SPAM_KEYWORDS);\n const buyer = countKeywordHits(body, BUYER_KEYWORDS);\n if (seller > 0) {\n const hits = seller + buyer;\n score += Math.min(hits, 3) * 30;\n reasons.push(`keywords:${hits}`);\n } else if (buyer > 0) {\n score += Math.min(buyer, 2) * 10;\n reasons.push(`keywords-buyer:${buyer}`);\n }\n\n // Lorem-ipsum form-tester bots: >=2 distinct filler stems is machine-generated\n // content with zero genuine use, so this is the one signal allowed to bucket\n // alone (+60) — the bodies are too short for the velocity signal and are real\n // Latin words, invisible to the gibberish detector.\n if (countLoremStems(body) >= 2) {\n score += 60;\n reasons.push(\"lorem-ipsum\");\n }\n\n // Body only — a native-script NAME (王小明, Владимир) is not a spam signal.\n // 25 (not 50): non-Latin alone must need corroboration from other signals\n // before it can reach SPAM_THRESHOLD.\n if (nonLatinRatio(body) > 0.3) {\n score += 25;\n reasons.push(\"non-latin\");\n }\n\n // Random keyboard-mash tokens (form-filler bots): body is the strong tell (+35); the\n // NAME corroborates only under a stricter rule (single token, >=12 chars). A real\n // consonant-heavy surname (Krzysztofowicz — max 3-run with y as vowel) no longer trips\n // at all under the 7-run rule. A bot with both name and body mashed sums 70 and is caught.\n if (hasGibberishToken(body, 10)) {\n score += 35;\n reasons.push(\"gibberish-body\");\n }\n if (!name.trim().includes(\" \") && hasGibberishToken(name, 12)) {\n score += 35;\n reasons.push(\"gibberish-name\");\n }\n\n if (DISPOSABLE_EMAIL_DOMAINS.includes(emailDomain(email))) {\n score += 45;\n reasons.push(\"disposable-email\");\n }\n\n if (countUrls(name) > 0) {\n score += 45;\n reasons.push(\"url-in-name\");\n }\n\n const trimmedMsg = body.trim();\n const degenerate =\n (trimmedMsg.length > 0 && trimmedMsg === name.trim()) || ONLY_URL_RE.test(trimmedMsg);\n if (degenerate) {\n score += 40;\n reasons.push(\"degenerate\");\n }\n\n if (isAllCaps(body)) {\n score += 15;\n reasons.push(\"all-caps\");\n }\n\n return { score, reasons };\n}\n"],"mappings":";AAgBO,IAAM,iBAAiB;AA0BvB,IAAM,gBAAmC;AAAA,EAC9C;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA,EAIA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA;AAAA;AAAA,EAIA;AAAA,EACA;AAAA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA;AAAA;AAAA,EAEA;AAAA,EACA;AAAA;AAAA,EAEA;AAAA,EACA;AACF;AAUO,IAAM,iBAAoC,CAAC,mBAAmB,mBAAmB;AAGjF,IAAM,2BAA8C;AAAA,EACzD;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAIA,IAAM,SAAS;AACf,IAAM,iBAAiB;AACvB,IAAM,cAAc;AAGpB,SAAS,UAAU,MAAsB;AACvC,UAAQ,KAAK,MAAM,MAAM,KAAK,CAAC,GAAG;AACpC;AAKA,SAAS,iBAAiB,MAAc,UAAqC;AAC3E,QAAM,QAAQ,KAAK,YAAY,EAAE,QAAQ,UAAU,GAAG;AACtD,SAAO,SAAS,OAAO,CAAC,OAAO,MAAM,SAAS,EAAE,CAAC,EAAE;AACrD;AAOA,IAAM,cAAiC;AAAA,EACrC;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AACF;AAEA,SAAS,gBAAgB,MAAsB;AAC7C,SAAO,YAAY,OAAO,CAAC,OAAO,GAAG,KAAK,IAAI,CAAC,EAAE;AACnD;AAGA,SAAS,cAAc,MAAsB;AAC3C,QAAM,UAAU,KAAK,MAAM,WAAC,UAAM,IAAE,MAAK,CAAC;AAC1C,MAAI,QAAQ,WAAW,EAAG,QAAO;AACjC,QAAM,WAAW,QAAQ,OAAO,CAAC,OAAO,CAAC,WAAC,qBAAiB,GAAC,EAAC,KAAK,EAAE,CAAC,EAAE;AACvE,SAAO,WAAW,QAAQ;AAC5B;AAGA,SAAS,YAAY,OAAuB;AAC1C,QAAM,KAAK,MAAM,YAAY,GAAG;AAChC,SAAO,OAAO,KACV,KACA,MACG,MAAM,KAAK,CAAC,EACZ,KAAK,EACL,YAAY;AACrB;AAGA,SAAS,UAAU,MAAuB;AACxC,MAAI,KAAK,UAAU,GAAI,QAAO;AAC9B,QAAM,UAAU,KAAK,MAAM,WAAW,KAAK,CAAC;AAC5C,MAAI,QAAQ,WAAW,EAAG,QAAO;AACjC,QAAM,QAAQ,QAAQ,OAAO,CAAC,MAAM,KAAK,OAAO,KAAK,GAAG,EAAE;AAC1D,SAAO,QAAQ,QAAQ,SAAS;AAClC;AAsBA,SAAS,kBAAkB,MAAc,QAAyB;AAChE,aAAW,OAAO,KAAK,MAAM,YAAY,KAAK,CAAC,GAAG;AAChD,QAAI,IAAI,SAAS,OAAQ;AACzB,QAAI,sBAAsB,KAAK,GAAG,EAAG,QAAO;AAC5C,QAAI,QAAQ;AACZ,aAAS,IAAI,GAAG,IAAI,IAAI,QAAQ,KAAK;AACnC,YAAM,OAAO,IAAI,IAAI,CAAC;AACtB,YAAM,MAAM,IAAI,CAAC;AACjB,UAAI,QAAQ,OAAO,QAAQ,OAAO,OAAO,OAAO,OAAO,IAAK;AAAA,IAC9D;AACA,QAAI,SAAS,EAAG,QAAO;AAAA,EACzB;AACA,SAAO;AACT;AAMA,IAAM,iBACJ;AAOK,SAAS,aAAa,OAOb;AACd,QAAM,OAAO,MAAM,QAAQ;AAC3B,QAAM,QAAQ,MAAM,SAAS;AAC7B,QAAM,UAAU,MAAM,WAAW;AACjC,QAAM,UAAoB,CAAC;AAC3B,MAAI,QAAQ;AAMZ,QAAM,YAAY,OAAO,OAAO,MAAM,eAAe,CAAC,CAAC,EACpD,OAAO,CAAC,MAAmB,OAAO,MAAM,QAAQ,EAChD,KAAK,GAAG;AACX,QAAM,OAAO,YAAY,GAAG,OAAO,IAAI,SAAS,KAAK;AAMrD,MAAI,MAAM,cAAc,QAAQ;AAC9B,aAAS;AACT,YAAQ,KAAK,gBAAgB;AAAA,EAC/B;AAEA,QAAM,OAAO,UAAU,IAAI;AAC3B,MAAI,OAAO,GAAG;AAIZ,aAAS,KAAK,IAAI,MAAM,CAAC,IAAI;AAC7B,YAAQ,KAAK,SAAS,IAAI,EAAE;AAAA,EAC9B,WAAW,eAAe,KAAK,IAAI,GAAG;AAGpC,aAAS;AACT,YAAQ,KAAK,aAAa;AAAA,EAC5B;AAEA,MAAI,eAAe,KAAK,IAAI,GAAG;AAC7B,aAAS;AACT,YAAQ,KAAK,aAAa;AAAA,EAC5B;AAWA,QAAM,SAAS,iBAAiB,MAAM,aAAa;AACnD,QAAM,QAAQ,iBAAiB,MAAM,cAAc;AACnD,MAAI,SAAS,GAAG;AACd,UAAM,OAAO,SAAS;AACtB,aAAS,KAAK,IAAI,MAAM,CAAC,IAAI;AAC7B,YAAQ,KAAK,YAAY,IAAI,EAAE;AAAA,EACjC,WAAW,QAAQ,GAAG;AACpB,aAAS,KAAK,IAAI,OAAO,CAAC,IAAI;AAC9B,YAAQ,KAAK,kBAAkB,KAAK,EAAE;AAAA,EACxC;AAMA,MAAI,gBAAgB,IAAI,KAAK,GAAG;AAC9B,aAAS;AACT,YAAQ,KAAK,aAAa;AAAA,EAC5B;AAKA,MAAI,cAAc,IAAI,IAAI,KAAK;AAC7B,aAAS;AACT,YAAQ,KAAK,WAAW;AAAA,EAC1B;AAMA,MAAI,kBAAkB,MAAM,EAAE,GAAG;AAC/B,aAAS;AACT,YAAQ,KAAK,gBAAgB;AAAA,EAC/B;AACA,MAAI,CAAC,KAAK,KAAK,EAAE,SAAS,GAAG,KAAK,kBAAkB,MAAM,EAAE,GAAG;AAC7D,aAAS;AACT,YAAQ,KAAK,gBAAgB;AAAA,EAC/B;AAEA,MAAI,yBAAyB,SAAS,YAAY,KAAK,CAAC,GAAG;AACzD,aAAS;AACT,YAAQ,KAAK,kBAAkB;AAAA,EACjC;AAEA,MAAI,UAAU,IAAI,IAAI,GAAG;AACvB,aAAS;AACT,YAAQ,KAAK,aAAa;AAAA,EAC5B;AAEA,QAAM,aAAa,KAAK,KAAK;AAC7B,QAAM,aACH,WAAW,SAAS,KAAK,eAAe,KAAK,KAAK,KAAM,YAAY,KAAK,UAAU;AACtF,MAAI,YAAY;AACd,aAAS;AACT,YAAQ,KAAK,YAAY;AAAA,EAC3B;AAEA,MAAI,UAAU,IAAI,GAAG;AACnB,aAAS;AACT,YAAQ,KAAK,UAAU;AAAA,EACzB;AAEA,SAAO,EAAE,OAAO,QAAQ;AAC1B;","names":[]}
@@ -0,0 +1,122 @@
1
+ // src/cli/commands/submissions.ts
2
+ var RESCORE_REASON_MARKER = "retro-rescore";
3
+ function parseExtraFields(raw) {
4
+ if (!raw || raw.trim() === "") return {};
5
+ try {
6
+ const parsed = JSON.parse(raw);
7
+ return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
8
+ } catch {
9
+ return {};
10
+ }
11
+ }
12
+ async function rescoreNewSubmissions(db, opts) {
13
+ const { listSubmissionsFiltered, rescoreSubmissionSpam } = await import("./submissions-MDKWJYMM.js");
14
+ const { classifySpam, SPAM_THRESHOLD } = await import("./spam-classifier-VG4ICIWS.js");
15
+ const rows = [];
16
+ const PAGE = 500;
17
+ for (let offset = 0; ; offset += PAGE) {
18
+ const page = await listSubmissionsFiltered(db, { status: "new" }, { limit: PAGE, offset });
19
+ rows.push(...page);
20
+ if (page.length < PAGE) break;
21
+ }
22
+ const flagged = [];
23
+ for (const row of rows) {
24
+ const verdict = classifySpam({
25
+ name: row.name,
26
+ email: row.email,
27
+ // message is optional on the classifier input; exactOptionalPropertyTypes
28
+ // forbids passing `undefined`, so spread only when present (form-ingest idiom).
29
+ ...row.message !== null ? { message: row.message } : {},
30
+ formType: row.formType,
31
+ extraFields: parseExtraFields(row.extraFields),
32
+ turnstile: "unverifiable"
33
+ });
34
+ if (verdict.score < SPAM_THRESHOLD) continue;
35
+ const newReason = [...verdict.reasons, RESCORE_REASON_MARKER].join(",");
36
+ const written = opts.apply ? await rescoreSubmissionSpam(db, row.id, verdict.score, newReason) : false;
37
+ flagged.push({ row, newScore: verdict.score, newReason, written });
38
+ }
39
+ return { scanned: rows.length, flagged };
40
+ }
41
+ async function loadSiteNames() {
42
+ try {
43
+ const { openBase, readAirtableConfig } = await import("./client-EPVO3FEH.js");
44
+ const { listWebsites } = await import("./websites-U6642XSE.js");
45
+ const sites = await listWebsites(openBase(readAirtableConfig()));
46
+ return new Map(sites.map((s) => [s.id, s.name]));
47
+ } catch (err) {
48
+ console.error(`[submissions] site names unavailable \u2014 printing raw site ids: ${String(err)}`);
49
+ return /* @__PURE__ */ new Map();
50
+ }
51
+ }
52
+ function pad(v, width) {
53
+ return v.length >= width ? v : v + " ".repeat(width - v.length);
54
+ }
55
+ function formatTable(flagged, names) {
56
+ const rows = flagged.map((f) => ({
57
+ site: names.get(f.row.siteId) ?? f.row.siteId,
58
+ email: f.row.email || "(no email)",
59
+ score: `${f.row.spamScore ?? "\u2014"} \u2192 ${f.newScore}`,
60
+ reasons: f.newReason
61
+ }));
62
+ const siteW = Math.max(4, ...rows.map((r) => r.site.length));
63
+ const emailW = Math.max(5, ...rows.map((r) => r.email.length));
64
+ const scoreW = Math.max(5, ...rows.map((r) => r.score.length));
65
+ const lines = [
66
+ `${pad("site", siteW)} ${pad("email", emailW)} ${pad("score", scoreW)} reasons`,
67
+ ...rows.map(
68
+ (r) => `${pad(r.site, siteW)} ${pad(r.email, emailW)} ${pad(r.score, scoreW)} ${r.reasons}`
69
+ )
70
+ ];
71
+ return lines.join("\n");
72
+ }
73
+ async function runSubmissionsCommand(action, opts) {
74
+ if (action !== "rescore") {
75
+ return { output: `unknown submissions action '${action}'. Use: rescore.`, code: 1 };
76
+ }
77
+ if (opts.apply && opts.dryRun) {
78
+ return { output: "--apply and --dry-run conflict. Pick one.", code: 2 };
79
+ }
80
+ const apply = opts.apply === true;
81
+ const { openDb, readDbConfig } = await import("./client-NVVQK3HH.js");
82
+ const { SPAM_THRESHOLD } = await import("./spam-classifier-VG4ICIWS.js");
83
+ const cfg = opts.url ? { url: opts.url } : readDbConfig();
84
+ const db = await openDb(cfg);
85
+ const { scanned, flagged } = await rescoreNewSubmissions(db, { apply });
86
+ if (flagged.length === 0) {
87
+ return {
88
+ output: `Scanned ${scanned} status='new' submission${scanned === 1 ? "" : "s"} \u2014 none score >= ${SPAM_THRESHOLD} under the current classifier. Nothing to re-bucket.`,
89
+ code: 0
90
+ };
91
+ }
92
+ const table = formatTable(flagged, await loadSiteNames());
93
+ const head = `Scanned ${scanned} status='new' submissions; ${flagged.length} score >= ${SPAM_THRESHOLD} under the current classifier:`;
94
+ if (!apply) {
95
+ return {
96
+ output: `${head}
97
+
98
+ ${table}
99
+
100
+ DRY RUN \u2014 nothing was written. Re-run with --apply to re-bucket these ${flagged.length} rows to spam_auto.`,
101
+ code: 0
102
+ };
103
+ }
104
+ const written = flagged.filter((f) => f.written).length;
105
+ const skipped = flagged.length - written;
106
+ const skippedNote = skipped > 0 ? `
107
+ ${skipped} row${skipped === 1 ? "" : "s"} skipped \u2014 no longer status='new' at write time (operator/ingest got there first).` : "";
108
+ return {
109
+ output: `${head}
110
+
111
+ ${table}
112
+
113
+ Applied: ${written} row${written === 1 ? "" : "s"} \u2192 spam_auto (new score + reasons written, '${RESCORE_REASON_MARKER}' marker appended).${skippedNote}`,
114
+ code: 0
115
+ };
116
+ }
117
+ export {
118
+ RESCORE_REASON_MARKER,
119
+ rescoreNewSubmissions,
120
+ runSubmissionsCommand
121
+ };
122
+ //# sourceMappingURL=submissions-BNRGY3JQ.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/cli/commands/submissions.ts"],"sourcesContent":["import type { Db } from \"../../db/client.js\";\nimport type { SubmissionRow } from \"../../reports/submission-row.js\";\n\nexport type SubmissionsCommandOptions = {\n /** Write the re-buckets. Without it the command is a DRY RUN (deliberate default —\n * this rewrites operator-visible triage state, so the table prints first). */\n apply?: boolean;\n /** Explicit dry run (the default). Conflicts with --apply. */\n dryRun?: boolean;\n /** Override the libSQL url (tests use \":memory:\"); otherwise read from env. */\n url?: string;\n cwd?: string;\n verbose?: boolean;\n};\n\n/** Provenance marker appended to the NEW classifier reasons on every re-bucketed row,\n * so a retro'd row is always distinguishable from one the classifier caught at ingest. */\nexport const RESCORE_REASON_MARKER = \"retro-rescore\";\n\nexport type RescoreFlagged = {\n row: SubmissionRow;\n newScore: number;\n /** What --apply writes to spam_reason: the new verdict's reasons + RESCORE_REASON_MARKER. */\n newReason: string;\n /** True when --apply actually flipped the row (false in dry runs, or when the row\n * stopped being 'new' between the scan and the write). */\n written: boolean;\n};\n\n/** extraFields is stored as a raw JSON string; the classifier wants the object. A\n * null/garbage/non-object value degrades to {} — same blind-spot posture as ingest\n * (classification never throws on stored data). */\nfunction parseExtraFields(raw: string | null): Record<string, unknown> {\n if (!raw || raw.trim() === \"\") return {};\n try {\n const parsed: unknown = JSON.parse(raw);\n return parsed !== null && typeof parsed === \"object\" && !Array.isArray(parsed)\n ? (parsed as Record<string, unknown>)\n : {};\n } catch {\n return {};\n }\n}\n\n/**\n * Core of `submissions rescore`, separated from the command wrapper so tests can run it\n * against a seeded :memory: db. Scans EVERY status='new' row (the pre-2026-07-15-tuning\n * backlog these rows predate), re-runs the CURRENT classifySpam over each — turnstile\n * \"unverifiable\", exactly what ingest passes when no token reaches verification — and\n * flags rows at/above SPAM_THRESHOLD. With `apply` it re-buckets each flagged row to\n * spam_auto, REPLACING spam_score/spam_reason with the new verdict (+ marker). The\n * classifier is the single source of truth: no scoring logic is duplicated here.\n */\nexport async function rescoreNewSubmissions(\n db: Db,\n opts: { apply: boolean },\n): Promise<{ scanned: number; flagged: RescoreFlagged[] }> {\n const { listSubmissionsFiltered, rescoreSubmissionSpam } =\n await import(\"../../db/submissions.js\");\n const { classifySpam, SPAM_THRESHOLD } = await import(\"../../forms/spam-classifier.js\");\n\n // Collect the WHOLE backlog before any write: --apply mutates rows out of the\n // status='new' filter, so paging offset-wise while writing would skip rows.\n const rows: SubmissionRow[] = [];\n const PAGE = 500;\n for (let offset = 0; ; offset += PAGE) {\n const page = await listSubmissionsFiltered(db, { status: \"new\" }, { limit: PAGE, offset });\n rows.push(...page);\n if (page.length < PAGE) break;\n }\n\n const flagged: RescoreFlagged[] = [];\n for (const row of rows) {\n const verdict = classifySpam({\n name: row.name,\n email: row.email,\n // message is optional on the classifier input; exactOptionalPropertyTypes\n // forbids passing `undefined`, so spread only when present (form-ingest idiom).\n ...(row.message !== null ? { message: row.message } : {}),\n formType: row.formType,\n extraFields: parseExtraFields(row.extraFields),\n turnstile: \"unverifiable\",\n });\n if (verdict.score < SPAM_THRESHOLD) continue;\n const newReason = [...verdict.reasons, RESCORE_REASON_MARKER].join(\",\");\n const written = opts.apply\n ? await rescoreSubmissionSpam(db, row.id, verdict.score, newReason)\n : false;\n flagged.push({ row, newScore: verdict.score, newReason, written });\n }\n return { scanned: rows.length, flagged };\n}\n\n/** site_id → display name for the table. Soft-fails to an empty map (raw rec ids still\n * print) so the re-score itself never depends on Airtable being reachable. */\nasync function loadSiteNames(): Promise<Map<string, string>> {\n try {\n const { openBase, readAirtableConfig } = await import(\"../../reports/airtable/client.js\");\n const { listWebsites } = await import(\"../../reports/airtable/websites.js\");\n const sites = await listWebsites(openBase(readAirtableConfig()));\n return new Map(sites.map((s) => [s.id, s.name] as const));\n } catch (err) {\n console.error(`[submissions] site names unavailable — printing raw site ids: ${String(err)}`);\n return new Map();\n }\n}\n\nfunction pad(v: string, width: number): string {\n return v.length >= width ? v : v + \" \".repeat(width - v.length);\n}\n\nfunction formatTable(flagged: RescoreFlagged[], names: Map<string, string>): string {\n const rows = flagged.map((f) => ({\n site: names.get(f.row.siteId) ?? f.row.siteId,\n email: f.row.email || \"(no email)\",\n score: `${f.row.spamScore ?? \"—\"} → ${f.newScore}`,\n reasons: f.newReason,\n }));\n const siteW = Math.max(4, ...rows.map((r) => r.site.length));\n const emailW = Math.max(5, ...rows.map((r) => r.email.length));\n const scoreW = Math.max(5, ...rows.map((r) => r.score.length));\n const lines = [\n `${pad(\"site\", siteW)} ${pad(\"email\", emailW)} ${pad(\"score\", scoreW)} reasons`,\n ...rows.map(\n (r) =>\n `${pad(r.site, siteW)} ${pad(r.email, emailW)} ${pad(r.score, scoreW)} ${r.reasons}`,\n ),\n ];\n return lines.join(\"\\n\");\n}\n\n/**\n * `submissions <action>` — operate on stored form submissions. The only action today is\n * `rescore`: re-run the CURRENT spam classifier over the status='new' backlog. Dry-run\n * by default; `--apply` writes. Only status='new' rows are ever touched.\n */\nexport async function runSubmissionsCommand(\n action: string,\n opts: SubmissionsCommandOptions,\n): Promise<{ output: string; code: number }> {\n if (action !== \"rescore\") {\n return { output: `unknown submissions action '${action}'. Use: rescore.`, code: 1 };\n }\n if (opts.apply && opts.dryRun) {\n return { output: \"--apply and --dry-run conflict. Pick one.\", code: 2 };\n }\n const apply = opts.apply === true;\n\n const { openDb, readDbConfig } = await import(\"../../db/client.js\");\n const { SPAM_THRESHOLD } = await import(\"../../forms/spam-classifier.js\");\n const cfg = opts.url ? { url: opts.url } : readDbConfig();\n const db = await openDb(cfg);\n\n const { scanned, flagged } = await rescoreNewSubmissions(db, { apply });\n if (flagged.length === 0) {\n return {\n output: `Scanned ${scanned} status='new' submission${scanned === 1 ? \"\" : \"s\"} — none score >= ${SPAM_THRESHOLD} under the current classifier. Nothing to re-bucket.`,\n code: 0,\n };\n }\n\n // Airtable is only consulted when there is a table to print.\n const table = formatTable(flagged, await loadSiteNames());\n const head = `Scanned ${scanned} status='new' submissions; ${flagged.length} score >= ${SPAM_THRESHOLD} under the current classifier:`;\n if (!apply) {\n return {\n output: `${head}\\n\\n${table}\\n\\nDRY RUN — nothing was written. Re-run with --apply to re-bucket these ${flagged.length} rows to spam_auto.`,\n code: 0,\n };\n }\n const written = flagged.filter((f) => f.written).length;\n const skipped = flagged.length - written;\n const skippedNote =\n skipped > 0\n ? `\\n${skipped} row${skipped === 1 ? \"\" : \"s\"} skipped — no longer status='new' at write time (operator/ingest got there first).`\n : \"\";\n return {\n output: `${head}\\n\\n${table}\\n\\nApplied: ${written} row${written === 1 ? \"\" : \"s\"} → spam_auto (new score + reasons written, '${RESCORE_REASON_MARKER}' marker appended).${skippedNote}`,\n code: 0,\n };\n}\n"],"mappings":"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","names":[]}