@sdxc/spam 0.0.0-pre.1

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 (57) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +382 -0
  3. package/dist/akismet.d.ts +63 -0
  4. package/dist/akismet.js +127 -0
  5. package/dist/author-email.d.ts +16 -0
  6. package/dist/author-email.js +42 -0
  7. package/dist/bayes/classify.d.ts +39 -0
  8. package/dist/bayes/classify.js +72 -0
  9. package/dist/bayes/data-table.d.ts +58 -0
  10. package/dist/bayes/data-table.js +123 -0
  11. package/dist/bayes/memory.d.ts +18 -0
  12. package/dist/bayes/memory.js +26 -0
  13. package/dist/bayes/store.d.ts +41 -0
  14. package/dist/bayes/store.js +0 -0
  15. package/dist/bayes/tokens.d.ts +31 -0
  16. package/dist/bayes/tokens.js +42 -0
  17. package/dist/bayes.d.ts +43 -0
  18. package/dist/bayes.js +67 -0
  19. package/dist/check.d.ts +116 -0
  20. package/dist/check.js +20 -0
  21. package/dist/corpus.fixtures.d.ts +20 -0
  22. package/dist/corpus.fixtures.js +233 -0
  23. package/dist/filter.d.ts +70 -0
  24. package/dist/filter.js +122 -0
  25. package/dist/index.d.ts +13 -0
  26. package/dist/index.js +11 -0
  27. package/dist/lib/text.d.ts +16 -0
  28. package/dist/lib/text.js +43 -0
  29. package/dist/link-blocklist.d.ts +64 -0
  30. package/dist/link-blocklist.js +202 -0
  31. package/dist/memory.d.ts +71 -0
  32. package/dist/memory.js +73 -0
  33. package/dist/rules/author-name.d.ts +29 -0
  34. package/dist/rules/author-name.js +39 -0
  35. package/dist/rules/contact-bait.d.ts +28 -0
  36. package/dist/rules/contact-bait.js +43 -0
  37. package/dist/rules/index.d.ts +24 -0
  38. package/dist/rules/index.js +26 -0
  39. package/dist/rules/language.d.ts +29 -0
  40. package/dist/rules/language.js +84 -0
  41. package/dist/rules/link-syntax.d.ts +28 -0
  42. package/dist/rules/link-syntax.js +48 -0
  43. package/dist/rules/link-targets.d.ts +41 -0
  44. package/dist/rules/link-targets.js +87 -0
  45. package/dist/rules/links.d.ts +32 -0
  46. package/dist/rules/links.js +39 -0
  47. package/dist/rules/shouting.d.ts +31 -0
  48. package/dist/rules/shouting.js +60 -0
  49. package/dist/rules/timing.d.ts +29 -0
  50. package/dist/rules/timing.js +46 -0
  51. package/dist/rules/unicode.d.ts +31 -0
  52. package/dist/rules/unicode.js +66 -0
  53. package/dist/stop-forum-spam.d.ts +52 -0
  54. package/dist/stop-forum-spam.js +135 -0
  55. package/dist/workers-ai.d.ts +68 -0
  56. package/dist/workers-ai.js +164 -0
  57. package/package.json +32 -0
@@ -0,0 +1,116 @@
1
+ /**
2
+ * The contract every spam check implements, local rule or remote provider alike: a check reads
3
+ * a submission and answers weighted signals, so the filter can add evidence from any source and
4
+ * explain a verdict by the signals behind it.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Result } from "@sdxc/result";
10
+ /**
11
+ * One piece of user-generated content and what is known about who sent it. Only `content` is
12
+ * required; every other field enables more checks, and a check whose input is absent adds nothing.
13
+ */
14
+ export interface Submission {
15
+ content: string;
16
+ /**
17
+ * How `content` is written. In `text`, Markdown and HTML link syntax count as a signal, since
18
+ * a plain-text field renders them literally and only a bot pastes them there.
19
+ *
20
+ * @default "text"
21
+ */
22
+ format?: "text" | "markdown" | "html";
23
+ author?: Submission.Author;
24
+ /** When the form was rendered, from a source the visitor cannot forge, such as a signed field. */
25
+ renderedAt?: Date;
26
+ /** @default the time of the check */
27
+ submittedAt?: Date;
28
+ /** The languages the site expects, as BCP 47 tags such as `en` or `pt-BR`. */
29
+ languages?: string[];
30
+ }
31
+ /** The types describing a {@link Submission}. */
32
+ export declare namespace Submission {
33
+ /** What the form or the session knows about the submission's author. */
34
+ interface Author {
35
+ name?: string;
36
+ email?: string;
37
+ /** The website the author entered, as a comment form's "website" field collects it. */
38
+ url?: string;
39
+ ip?: string;
40
+ userAgent?: string;
41
+ }
42
+ }
43
+ /** Whether a submission is spam, as a moderator decided it or a filter scored it. */
44
+ export type Label = "spam" | "ham";
45
+ /**
46
+ * One piece of evidence. Positive scores point to spam and negative ones to ham, so a provider
47
+ * vouching for a known author can pull a submission back below a threshold.
48
+ */
49
+ export interface Signal {
50
+ /** A stable identifier, the check's name and the finding, such as `links.count`. */
51
+ check: string;
52
+ score: number;
53
+ /** What was found, written for a moderator reading why a submission was held. */
54
+ detail?: string;
55
+ }
56
+ /**
57
+ * A source of signals. The filter runs `local` checks first, in order and without a timeout, so a
58
+ * local check reads only the app's own data; `remote` checks run in parallel under a timeout, and
59
+ * `escalation` checks only for a submission still in the unsure band.
60
+ */
61
+ export interface SpamCheck {
62
+ /** Identifies the check in a failure report; signals carry their own `check` identifier. */
63
+ readonly name: string;
64
+ readonly stage: SpamCheck.Stage;
65
+ /**
66
+ * Scores one submission, answering signals directly or, when it reads storage or a network, a
67
+ * `Result` whose failure means no evidence either way, never a verdict.
68
+ *
69
+ * @param submission - The submission to score
70
+ * @param options - The abort signal bounding the call and the score earlier stages reached
71
+ */
72
+ check(submission: Submission, options: SpamCheck.Options): Signal[] | Promise<Result<Signal[], SpamCheckError>>;
73
+ /**
74
+ * Learns from a moderator's decision, for checks that train on them or forward them to a
75
+ * provider. A check without it ignores reports.
76
+ *
77
+ * @param submission - The submission as it was checked
78
+ * @param label - What the moderator decided it is
79
+ */
80
+ report?(submission: Submission, label: Label): Promise<Result<void, SpamCheckError>>;
81
+ }
82
+ /** The types shared by every {@link SpamCheck}. */
83
+ export declare namespace SpamCheck {
84
+ /** When a check runs; see {@link SpamCheck}. */
85
+ type Stage = "local" | "remote" | "escalation";
86
+ /** What the filter passes to every call. */
87
+ interface Options {
88
+ /** Aborts when the check's timeout elapses; a remote check passes it to `fetch`. */
89
+ signal: AbortSignal;
90
+ /** The total of every signal from earlier stages. */
91
+ score: number;
92
+ /** The signals earlier stages produced, so an escalation check can weigh the evidence. */
93
+ signals: readonly Signal[];
94
+ }
95
+ /**
96
+ * Why a check produced no evidence. `unavailable` is the provider failing, `timeout` is the
97
+ * filter's deadline passing, `misconfigured` is a key or option the provider refused, and
98
+ * `invalid-response` is an answer in a shape the check cannot read.
99
+ */
100
+ type ErrorCode = "unavailable" | "timeout" | "misconfigured" | "invalid-response";
101
+ }
102
+ /**
103
+ * A check that produced no evidence. `code` is what to branch on and log; the message carries
104
+ * the provider's own detail.
105
+ *
106
+ * @example if (error.code === "misconfigured") log.error("spam.misconfigured", { message: error.message });
107
+ */
108
+ export declare class SpamCheckError extends Error {
109
+ name: string;
110
+ readonly code: SpamCheck.ErrorCode;
111
+ /**
112
+ * @param code - The reason, the value to branch on
113
+ * @param message - The provider's detail, for logs
114
+ */
115
+ constructor(code: SpamCheck.ErrorCode, message?: string);
116
+ }
package/dist/check.js ADDED
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A check that produced no evidence. `code` is what to branch on and log; the message carries
3
+ * the provider's own detail.
4
+ *
5
+ * @example if (error.code === "misconfigured") log.error("spam.misconfigured", { message: error.message });
6
+ */
7
+ export class SpamCheckError extends Error {
8
+ name = "SpamCheckError";
9
+ code;
10
+ /**
11
+ * @param code - The reason, the value to branch on
12
+ * @param message - The provider's detail, for logs
13
+ */
14
+ constructor(code, message) {
15
+ super(message === undefined
16
+ ? `Spam check failed: ${code}`
17
+ : `Spam check failed: ${code} (${message})`);
18
+ this.code = code;
19
+ }
20
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * A hand-written, labelled corpus of submissions: the acceptance criterion the default rules are
3
+ * tuned against. Every entry is invented, so no real submission or personal data ships here; the
4
+ * hard ham cases are the ones a careless rule would hold.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ import type { Submission } from "./check.js";
10
+ import type { SpamFilter } from "./filter.js";
11
+ /** One labelled submission, with the verdict the default rules must reach and why. */
12
+ export interface CorpusEntry {
13
+ name: string;
14
+ expected: SpamFilter.Verdict;
15
+ submission: Submission;
16
+ }
17
+ /** Real people writing, including every shape a careless rule would mistake for spam. */
18
+ export declare const HAM: CorpusEntry[];
19
+ /** Spam as it arrives, from the crude to the careful. */
20
+ export declare const SPAM: CorpusEntry[];
@@ -0,0 +1,233 @@
1
+ /** The moment every timed submission is rendered at. */
2
+ const RENDERED_AT = new Date("2026-09-28T12:00:00Z");
3
+ /** A submission sent `seconds` after {@link RENDERED_AT}. */
4
+ function after(seconds) {
5
+ return {
6
+ renderedAt: RENDERED_AT,
7
+ submittedAt: new Date(RENDERED_AT.getTime() + seconds * 1000),
8
+ };
9
+ }
10
+ /** Real people writing, including every shape a careless rule would mistake for spam. */
11
+ export const HAM = [
12
+ {
13
+ name: "a short thank-you",
14
+ expected: "ham",
15
+ submission: { content: "Thanks, this was exactly what I needed!", ...after(40) },
16
+ },
17
+ {
18
+ name: "a reply with one reference link",
19
+ expected: "ham",
20
+ submission: {
21
+ content: "I hit the same problem last week. The fix is described in the MDN article on AbortSignal: https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static",
22
+ ...after(120),
23
+ },
24
+ },
25
+ {
26
+ name: "a Markdown comment with two links in a Markdown field",
27
+ expected: "ham",
28
+ submission: {
29
+ format: "markdown",
30
+ content: "Nice write-up. For anyone comparing, [the spec](https://www.w3.org/TR/webmention/) and [this test suite](https://webmention.rocks/) helped me more than any tutorial.",
31
+ ...after(95),
32
+ },
33
+ },
34
+ {
35
+ name: "an excited comment with exclamation marks and an emoji",
36
+ expected: "ham",
37
+ submission: {
38
+ content: "This is AMAZING!!! Finally someone explains it properly 🎉",
39
+ ...after(30),
40
+ },
41
+ },
42
+ {
43
+ name: "a support request with a phone number",
44
+ expected: "ham",
45
+ submission: {
46
+ content: "Hi, my order hasn't arrived and the tracking page shows nothing. You can reach me at +1 415 555 0134 during business hours. Order number 48213.",
47
+ author: { name: "Dana Whitfield", email: "dana@example.com" },
48
+ ...after(180),
49
+ },
50
+ },
51
+ {
52
+ name: "a question with a code sample",
53
+ expected: "ham",
54
+ submission: {
55
+ format: "markdown",
56
+ content: "Does this still work if the handler is async?\n\n```ts\nlet response = await fetch(url, { signal: AbortSignal.timeout(5000) });\n```",
57
+ ...after(75),
58
+ },
59
+ },
60
+ {
61
+ name: "a Spanish comment on a site expecting Spanish",
62
+ expected: "ham",
63
+ submission: {
64
+ content: "Muy buen artículo, me ayudó a entender cómo funcionan los Durable Objects.",
65
+ languages: ["es", "en"],
66
+ ...after(60),
67
+ },
68
+ },
69
+ {
70
+ name: "a Russian comment on a site expecting Russian",
71
+ expected: "ham",
72
+ submission: {
73
+ content: "Спасибо за статью, очень понятно объяснено, как работает кэширование.",
74
+ languages: ["ru"],
75
+ ...after(50),
76
+ },
77
+ },
78
+ {
79
+ name: "a Japanese comment on a site expecting Japanese",
80
+ expected: "ham",
81
+ submission: {
82
+ content: "とても分かりやすい記事でした。ありがとうございます。",
83
+ languages: ["ja"],
84
+ ...after(45),
85
+ },
86
+ },
87
+ {
88
+ name: "a comment whose author entered a personal website",
89
+ expected: "ham",
90
+ submission: {
91
+ content: "I wrote a follow-up on this with benchmarks, happy to hear what you think.",
92
+ author: { name: "Priya N.", url: "https://priya.example.dev" },
93
+ ...after(90),
94
+ },
95
+ },
96
+ {
97
+ name: "a long, thoughtful comment",
98
+ expected: "ham",
99
+ submission: {
100
+ content: "I disagree with the second point. Caching at the edge is great until you need per-user data, and then every cache key has to include the session, which kills the hit rate. What worked for us was splitting the page into a cached shell and a small personalized fragment fetched after load. It is more moving parts, but the shell is served from cache 98% of the time.",
101
+ ...after(400),
102
+ },
103
+ },
104
+ {
105
+ name: "a comment mentioning an Ethereum concept without a wallet",
106
+ expected: "ham",
107
+ submission: {
108
+ content: "How does this compare to storing hashes on Ethereum? Genuinely curious about the trade-offs.",
109
+ ...after(70),
110
+ },
111
+ },
112
+ {
113
+ name: "a comment typed in all caps by a short word",
114
+ expected: "ham",
115
+ submission: { content: "OK, THANKS", ...after(20) },
116
+ },
117
+ {
118
+ name: "a submission with no timing or author data",
119
+ expected: "ham",
120
+ submission: { content: "Could you add an RSS feed for the tutorials section?" },
121
+ },
122
+ ];
123
+ /** Spam as it arrives, from the crude to the careful. */
124
+ export const SPAM = [
125
+ {
126
+ name: "a link dump",
127
+ expected: "spam",
128
+ submission: {
129
+ content: "cheap watches https://watches.example/a https://watches.example/b https://watches.example/c https://watches.example/d https://watches.example/e",
130
+ ...after(30),
131
+ },
132
+ },
133
+ {
134
+ name: "BBCode links in a plain-text field",
135
+ expected: "spam",
136
+ submission: {
137
+ content: "Nice site! [url=https://pills.example]buy pills online[/url] [url=https://pills.example/2]discount[/url]",
138
+ ...after(30),
139
+ },
140
+ },
141
+ {
142
+ name: "HTML links pasted into a plain-text field",
143
+ expected: "spam",
144
+ submission: {
145
+ content: 'Very informative. <a href="https://loans.example">fast loans</a> and <a href="https://loans.example/b">no credit check</a>',
146
+ ...after(25),
147
+ },
148
+ },
149
+ {
150
+ name: "a submission sent a second after the form rendered",
151
+ expected: "unsure",
152
+ submission: { content: "Great article, thanks for writing it.", ...after(1) },
153
+ },
154
+ {
155
+ name: "a crypto recovery scam with messaging handles",
156
+ expected: "spam",
157
+ submission: {
158
+ content: "Lost your crypto? Our team recovers stolen funds in 24 hours. Contact us on Telegram @recover_fast_team or WhatsApp +44 7700 900123. Send a small fee to 0x52908400098527886E0F7030069857D2E4169EE7 to start.",
159
+ ...after(20),
160
+ },
161
+ },
162
+ {
163
+ name: "shortened links behind a vague compliment",
164
+ expected: "spam",
165
+ submission: {
166
+ content: "Wow what a post, check this out https://bit.ly/3xYz12a and https://tinyurl.com/deal-now",
167
+ ...after(15),
168
+ },
169
+ },
170
+ {
171
+ name: "a link to a raw IP address",
172
+ expected: "unsure",
173
+ submission: {
174
+ content: "Download the full version here: http://203.0.113.45/setup.exe",
175
+ ...after(30),
176
+ },
177
+ },
178
+ {
179
+ name: "Cyrillic homoglyphs disguising a brand",
180
+ expected: "unsure",
181
+ submission: {
182
+ content: "Your Pаypаl account is on hold, verify now at the link in our profile.",
183
+ ...after(30),
184
+ },
185
+ },
186
+ {
187
+ name: "mathematical letters dodging keyword filters",
188
+ expected: "unsure",
189
+ submission: { content: "𝗙𝗥𝗘𝗘 𝗙𝗢𝗟𝗟𝗢𝗪𝗘𝗥𝗦 𝗡𝗢𝗪 click the link in bio", ...after(30) },
190
+ },
191
+ {
192
+ name: "zero-width characters splitting keywords",
193
+ expected: "unsure",
194
+ submission: { content: "Get c​h​e​a​p v​i​a​g​r​a today", ...after(30) },
195
+ },
196
+ {
197
+ name: "shouting and repetition",
198
+ expected: "unsure",
199
+ submission: {
200
+ content: "BEST PRICES GUARANTEED BEST PRICES GUARANTEED BEST PRICES GUARANTEED ORDER TODAY!!!!!!!! $$$$$$$$",
201
+ ...after(30),
202
+ },
203
+ },
204
+ {
205
+ name: "Russian SEO spam on an English-only site",
206
+ expected: "unsure",
207
+ submission: {
208
+ content: "Продвижение сайтов в топ поисковых систем недорого, пишите нам сегодня.",
209
+ languages: ["en"],
210
+ ...after(30),
211
+ },
212
+ },
213
+ {
214
+ name: "an author name stuffed with keywords and a URL",
215
+ expected: "spam",
216
+ submission: {
217
+ content: "Great content, keep it up. Visit us for the best deals https://casino.example",
218
+ author: {
219
+ name: "Best Online Casino Bonus 2026 casino.example",
220
+ url: "https://bit.ly/casino-bonus",
221
+ },
222
+ ...after(10),
223
+ },
224
+ },
225
+ {
226
+ name: "a fast link dump with emoji",
227
+ expected: "spam",
228
+ submission: {
229
+ content: "🔥🔥🔥💰💰💰🚀🚀🚀✅ earn money https://earn.example/1 https://earn.example/2 https://earn.example/3",
230
+ ...after(2),
231
+ },
232
+ },
233
+ ];
@@ -0,0 +1,70 @@
1
+ import type { Label, Signal, SpamCheck, Submission } from "./check.js";
2
+ import { SpamCheckError } from "./check.js";
3
+ /** Scores a submission and forwards moderators' decisions to the checks that learn from them. */
4
+ export interface SpamFilter {
5
+ /**
6
+ * Scores one submission. Local checks run first; when they already reach the spam threshold,
7
+ * no remote or escalation check runs. Escalation runs only in the unsure band.
8
+ */
9
+ check(submission: Submission): Promise<SpamFilter.Assessment>;
10
+ /**
11
+ * Sends a moderator's decision to every check that implements `report`, in parallel.
12
+ *
13
+ * @returns The checks whose report failed; empty when every one succeeded
14
+ */
15
+ report(submission: Submission, label: Label): Promise<SpamFilter.Failure[]>;
16
+ }
17
+ /** The types a {@link SpamFilter} reads and returns. */
18
+ export declare namespace SpamFilter {
19
+ /** How a filter is built. */
20
+ interface Options {
21
+ checks: readonly SpamCheck[];
22
+ /**
23
+ * The totals where a submission becomes `unsure` and `spam`. A total at or above `spam` is
24
+ * spam, at or above `unsure` is unsure, and anything lower is ham.
25
+ *
26
+ * @default { unsure: 5, spam: 10 }
27
+ */
28
+ thresholds?: Thresholds;
29
+ /**
30
+ * Milliseconds each remote or escalation check may take before it fails with `timeout`.
31
+ *
32
+ * @default 1500
33
+ */
34
+ timeout?: number;
35
+ }
36
+ /** See {@link Options.thresholds}. */
37
+ interface Thresholds {
38
+ unsure: number;
39
+ spam: number;
40
+ }
41
+ /** The verdict and every piece of evidence behind it. */
42
+ interface Assessment {
43
+ verdict: Verdict;
44
+ /** The sum of every signal's score. */
45
+ score: number;
46
+ signals: Signal[];
47
+ /** Checks that ran and failed, so the verdict lacks their evidence. */
48
+ failures: Failure[];
49
+ }
50
+ /** `unsure` is the band a moderator reviews. */
51
+ type Verdict = "ham" | "unsure" | "spam";
52
+ /** A check that produced no evidence, with the reason. */
53
+ interface Failure {
54
+ check: string;
55
+ error: SpamCheckError;
56
+ }
57
+ }
58
+ /** The thresholds a filter uses when none are given. */
59
+ export declare const DEFAULT_THRESHOLDS: SpamFilter.Thresholds;
60
+ /**
61
+ * Builds a filter over `checks`. Their order within a stage has no effect on the verdict.
62
+ *
63
+ * @example let filter = createSpamFilter({ checks: DEFAULT_RULES });
64
+ */
65
+ export declare function createSpamFilter(options: SpamFilter.Options): SpamFilter;
66
+ /**
67
+ * Maps a total onto a verdict. With `unsure` at or above `spam` the unsure band is empty, and
68
+ * every total is either ham or spam.
69
+ */
70
+ export declare function verdictFor(score: number, thresholds: SpamFilter.Thresholds): SpamFilter.Verdict;
package/dist/filter.js ADDED
@@ -0,0 +1,122 @@
1
+ import { failure, isFailure, success } from "@sdxc/result";
2
+ import { SpamCheckError } from "./check.js";
3
+ /** The thresholds a filter uses when none are given. */
4
+ export const DEFAULT_THRESHOLDS = { unsure: 5, spam: 10 };
5
+ /** Milliseconds a remote or escalation check gets by default. */
6
+ const DEFAULT_TIMEOUT_MS = 1500;
7
+ /**
8
+ * Builds a filter over `checks`. Their order within a stage has no effect on the verdict.
9
+ *
10
+ * @example let filter = createSpamFilter({ checks: DEFAULT_RULES });
11
+ */
12
+ export function createSpamFilter(options) {
13
+ let thresholds = options.thresholds ?? DEFAULT_THRESHOLDS;
14
+ let timeout = options.timeout ?? DEFAULT_TIMEOUT_MS;
15
+ let byStage = (stage) => options.checks.filter((c) => c.stage === stage);
16
+ return {
17
+ async check(submission) {
18
+ let signals = [];
19
+ let failures = [];
20
+ let total = () => signals.reduce((sum, signal) => sum + signal.score, 0);
21
+ for (let check of byStage("local")) {
22
+ let outcome = await runCheck(check, submission, [...signals], undefined);
23
+ if (isFailure(outcome))
24
+ failures.push({ check: check.name, error: outcome.error });
25
+ else
26
+ signals.push(...outcome.data);
27
+ }
28
+ let stages = [
29
+ { stage: "remote", runs: (score) => score < thresholds.spam },
30
+ {
31
+ stage: "escalation",
32
+ runs: (score) => score >= thresholds.unsure && score < thresholds.spam,
33
+ },
34
+ ];
35
+ for (let { stage, runs } of stages) {
36
+ let earlier = [...signals];
37
+ if (!runs(total()))
38
+ continue;
39
+ let outcomes = await Promise.all(byStage(stage).map(async (check) => ({
40
+ check,
41
+ outcome: await runCheck(check, submission, earlier, timeout),
42
+ })));
43
+ for (let { check, outcome } of outcomes) {
44
+ if (isFailure(outcome))
45
+ failures.push({ check: check.name, error: outcome.error });
46
+ else
47
+ signals.push(...outcome.data);
48
+ }
49
+ }
50
+ let score = total();
51
+ return { verdict: verdictFor(score, thresholds), score, signals, failures };
52
+ },
53
+ async report(submission, label) {
54
+ let outcomes = await Promise.all(options.checks.map(async (check) => {
55
+ if (check.report === undefined)
56
+ return null;
57
+ let report = check.report.bind(check);
58
+ return { check, outcome: await settle(() => report(submission, label)) };
59
+ }));
60
+ return outcomes.flatMap((entry) => entry !== null && isFailure(entry.outcome)
61
+ ? [{ check: entry.check.name, error: entry.outcome.error }]
62
+ : []);
63
+ },
64
+ };
65
+ }
66
+ /**
67
+ * Maps a total onto a verdict. With `unsure` at or above `spam` the unsure band is empty, and
68
+ * every total is either ham or spam.
69
+ */
70
+ export function verdictFor(score, thresholds) {
71
+ if (score >= thresholds.spam)
72
+ return "spam";
73
+ if (score >= thresholds.unsure)
74
+ return "unsure";
75
+ return "ham";
76
+ }
77
+ /**
78
+ * Runs one check under `timeoutMs`, racing it against the deadline so a check that ignores its
79
+ * signal still yields. A thrown error or rejected promise becomes `unavailable`.
80
+ */
81
+ async function runCheck(check, submission, earlier, timeoutMs) {
82
+ let controller = new AbortController();
83
+ let timer;
84
+ let deadline = new Promise((resolve) => {
85
+ if (timeoutMs === undefined)
86
+ return;
87
+ timer = setTimeout(() => {
88
+ controller.abort();
89
+ resolve(failure(new SpamCheckError("timeout", `exceeded ${timeoutMs}ms`)));
90
+ }, timeoutMs);
91
+ });
92
+ let run = settle(async () => {
93
+ let score = earlier.reduce((sum, signal) => sum + signal.score, 0);
94
+ let answer = await check.check(submission, {
95
+ signal: controller.signal,
96
+ score,
97
+ signals: earlier,
98
+ });
99
+ return Array.isArray(answer) ? success(answer) : answer;
100
+ });
101
+ try {
102
+ return await Promise.race([run, deadline]);
103
+ }
104
+ finally {
105
+ clearTimeout(timer);
106
+ }
107
+ }
108
+ /**
109
+ * Calls `work`, turning a throw or rejection into an `unavailable` failure so a third-party
110
+ * check's bug degrades to missing evidence.
111
+ *
112
+ * @template T - The value a successful call carries.
113
+ */
114
+ async function settle(work) {
115
+ try {
116
+ return await work();
117
+ }
118
+ catch (error) {
119
+ let message = error instanceof Error ? error.message : String(error);
120
+ return failure(new SpamCheckError("unavailable", message));
121
+ }
122
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Spam scoring for user-generated submissions: the check contract, the filter that adds up the
3
+ * signals of every check into a verdict, and the built-in local rules. Remote providers, the
4
+ * classifier and the test check live under their own export paths.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export type { Label, Signal, SpamCheck, Submission } from "./check.js";
10
+ export type { SpamFilter } from "./filter.js";
11
+ export { SpamCheckError } from "./check.js";
12
+ export { createSpamFilter, DEFAULT_THRESHOLDS, verdictFor } from "./filter.js";
13
+ export { ABUSED_TLDS, authorName, contactBait, DEFAULT_RULES, language, links, linkSyntax, linkTargets, shouting, timing, unicode, URL_SHORTENERS, } from "./rules/index.js";
package/dist/index.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Spam scoring for user-generated submissions: the check contract, the filter that adds up the
3
+ * signals of every check into a verdict, and the built-in local rules. Remote providers, the
4
+ * classifier and the test check live under their own export paths.
5
+ *
6
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
7
+ * @copyright Sergio Xalambrí 2026
8
+ */
9
+ export { SpamCheckError } from "./check.js";
10
+ export { createSpamFilter, DEFAULT_THRESHOLDS, verdictFor } from "./filter.js";
11
+ export { ABUSED_TLDS, authorName, contactBait, DEFAULT_RULES, language, links, linkSyntax, linkTargets, shouting, timing, unicode, URL_SHORTENERS, } from "./rules/index.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Text measurements the rules share: the links a submission carries and its words. Every rule
3
+ * reads the same extraction, so two rules never disagree about how many links a text holds.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * The distinct links in `text`, parsed. A `www.` link is read as `http://`; a match that fails to
10
+ * parse is dropped, so every entry has a host.
11
+ *
12
+ * @example extractLinks("see https://example.com/a.").map((url) => url.hostname); // ["example.com"]
13
+ */
14
+ export declare function extractLinks(text: string): URL[];
15
+ /** The words of `text` with its links removed, split on anything that is not a letter or digit. */
16
+ export declare function extractWords(text: string): string[];
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Text measurements the rules share: the links a submission carries and its words. Every rule
3
+ * reads the same extraction, so two rules never disagree about how many links a text holds.
4
+ *
5
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
6
+ * @copyright Sergio Xalambrí 2026
7
+ */
8
+ /**
9
+ * An absolute `http(s)` URL or a `www.` host, stopping at whitespace, quotes, angle brackets and
10
+ * the brackets that close Markdown and BBCode link syntax. A bracketed IPv6 host is read whole.
11
+ */
12
+ const URL_PATTERN = /\b(?:https?:\/\/(?:\[[0-9a-f:.]+\])?|www\.)[^\s"'<>()[\]]*/gi;
13
+ /** Punctuation a sentence puts after a URL, which is never part of it. */
14
+ const TRAILING_PUNCTUATION = /[.,;:!?]+$/;
15
+ /**
16
+ * The distinct links in `text`, parsed. A `www.` link is read as `http://`; a match that fails to
17
+ * parse is dropped, so every entry has a host.
18
+ *
19
+ * @example extractLinks("see https://example.com/a.").map((url) => url.hostname); // ["example.com"]
20
+ */
21
+ export function extractLinks(text) {
22
+ let seen = new Set();
23
+ let links = [];
24
+ for (let match of text.matchAll(URL_PATTERN)) {
25
+ let raw = match[0].replace(TRAILING_PUNCTUATION, "");
26
+ let href = /^www\./i.test(raw) ? `http://${raw}` : raw;
27
+ if (!URL.canParse(href))
28
+ continue;
29
+ let url = new URL(href);
30
+ if (seen.has(url.href))
31
+ continue;
32
+ seen.add(url.href);
33
+ links.push(url);
34
+ }
35
+ return links;
36
+ }
37
+ /** The words of `text` with its links removed, split on anything that is not a letter or digit. */
38
+ export function extractWords(text) {
39
+ return text
40
+ .replace(URL_PATTERN, " ")
41
+ .split(/[^\p{L}\p{N}'’]+/u)
42
+ .filter((word) => word.length > 0);
43
+ }