@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.
- package/LICENSE.md +21 -0
- package/README.md +382 -0
- package/dist/akismet.d.ts +63 -0
- package/dist/akismet.js +127 -0
- package/dist/author-email.d.ts +16 -0
- package/dist/author-email.js +42 -0
- package/dist/bayes/classify.d.ts +39 -0
- package/dist/bayes/classify.js +72 -0
- package/dist/bayes/data-table.d.ts +58 -0
- package/dist/bayes/data-table.js +123 -0
- package/dist/bayes/memory.d.ts +18 -0
- package/dist/bayes/memory.js +26 -0
- package/dist/bayes/store.d.ts +41 -0
- package/dist/bayes/store.js +0 -0
- package/dist/bayes/tokens.d.ts +31 -0
- package/dist/bayes/tokens.js +42 -0
- package/dist/bayes.d.ts +43 -0
- package/dist/bayes.js +67 -0
- package/dist/check.d.ts +116 -0
- package/dist/check.js +20 -0
- package/dist/corpus.fixtures.d.ts +20 -0
- package/dist/corpus.fixtures.js +233 -0
- package/dist/filter.d.ts +70 -0
- package/dist/filter.js +122 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +11 -0
- package/dist/lib/text.d.ts +16 -0
- package/dist/lib/text.js +43 -0
- package/dist/link-blocklist.d.ts +64 -0
- package/dist/link-blocklist.js +202 -0
- package/dist/memory.d.ts +71 -0
- package/dist/memory.js +73 -0
- package/dist/rules/author-name.d.ts +29 -0
- package/dist/rules/author-name.js +39 -0
- package/dist/rules/contact-bait.d.ts +28 -0
- package/dist/rules/contact-bait.js +43 -0
- package/dist/rules/index.d.ts +24 -0
- package/dist/rules/index.js +26 -0
- package/dist/rules/language.d.ts +29 -0
- package/dist/rules/language.js +84 -0
- package/dist/rules/link-syntax.d.ts +28 -0
- package/dist/rules/link-syntax.js +48 -0
- package/dist/rules/link-targets.d.ts +41 -0
- package/dist/rules/link-targets.js +87 -0
- package/dist/rules/links.d.ts +32 -0
- package/dist/rules/links.js +39 -0
- package/dist/rules/shouting.d.ts +31 -0
- package/dist/rules/shouting.js +60 -0
- package/dist/rules/timing.d.ts +29 -0
- package/dist/rules/timing.js +46 -0
- package/dist/rules/unicode.d.ts +31 -0
- package/dist/rules/unicode.js +66 -0
- package/dist/stop-forum-spam.d.ts +52 -0
- package/dist/stop-forum-spam.js +135 -0
- package/dist/workers-ai.d.ts +68 -0
- package/dist/workers-ai.js +164 -0
- package/package.json +32 -0
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sergio Xalambrí
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
# @sdxc/spam
|
|
2
|
+
|
|
3
|
+
Spam scoring for user-generated submissions: local rules, reputation providers, a trainable
|
|
4
|
+
classifier and an LLM second opinion add weighted signals, and the total becomes a `ham`,
|
|
5
|
+
`unsure` or `spam` verdict with every reason attached.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm add @sdxc/spam
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Remote checks and moderator reports return [`@sdxc/result`](https://www.npmjs.com/package/@sdxc/result)
|
|
14
|
+
values, and the classifier's database store runs on
|
|
15
|
+
[`remix/data-table`](https://www.npmjs.com/package/remix). Both install alongside this package.
|
|
16
|
+
|
|
17
|
+
## Usage
|
|
18
|
+
|
|
19
|
+
### Score a submission
|
|
20
|
+
|
|
21
|
+
```ts
|
|
22
|
+
import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
|
|
23
|
+
|
|
24
|
+
let filter = createSpamFilter({ checks: DEFAULT_RULES });
|
|
25
|
+
|
|
26
|
+
let assessment = await filter.check({
|
|
27
|
+
content: "cheap watches https://a.example https://b.example",
|
|
28
|
+
});
|
|
29
|
+
assessment.verdict; // "ham" | "unsure" | "spam"
|
|
30
|
+
assessment.score; // 5
|
|
31
|
+
assessment.signals; // [{ check: "links.count", score: 2, detail: "2 links" }, { check: "links.density", score: 3, … }]
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
### Give the rules more to work with
|
|
35
|
+
|
|
36
|
+
Only `content` is required. Every other field lets more rules fire.
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
let assessment = await filter.check({
|
|
40
|
+
content: form.get("comment"),
|
|
41
|
+
format: "markdown",
|
|
42
|
+
author: {
|
|
43
|
+
name: form.get("name"),
|
|
44
|
+
email: form.get("email"),
|
|
45
|
+
ip: request.headers.get("CF-Connecting-IP"),
|
|
46
|
+
},
|
|
47
|
+
renderedAt: verifiedRenderTime, // signed at render, as @sdxc/honeypot does; never a plain hidden input
|
|
48
|
+
languages: ["en", "es"],
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Add reputation and a second opinion
|
|
53
|
+
|
|
54
|
+
```ts
|
|
55
|
+
import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
|
|
56
|
+
import { akismet } from "@sdxc/spam/akismet";
|
|
57
|
+
import { authorEmail } from "@sdxc/spam/author-email";
|
|
58
|
+
|
|
59
|
+
let filter = createSpamFilter({
|
|
60
|
+
checks: [
|
|
61
|
+
...DEFAULT_RULES,
|
|
62
|
+
authorEmail(),
|
|
63
|
+
akismet({ apiKey: AKISMET_KEY, blog: "https://example.com" }),
|
|
64
|
+
],
|
|
65
|
+
thresholds: { unsure: 5, spam: 10 },
|
|
66
|
+
timeout: 1500,
|
|
67
|
+
});
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Local rules run first. When they already reach the spam threshold, no request leaves the Worker.
|
|
71
|
+
|
|
72
|
+
### Learn from moderators
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
let failures = await filter.report(submission, "ham"); // a moderator approved a held comment
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Every check that learns receives the decision; `failures` lists the ones that could not record it.
|
|
79
|
+
|
|
80
|
+
## API
|
|
81
|
+
|
|
82
|
+
### `@sdxc/spam`
|
|
83
|
+
|
|
84
|
+
#### `createSpamFilter({ checks, thresholds?, timeout? })`
|
|
85
|
+
|
|
86
|
+
Returns a `SpamFilter` with two methods:
|
|
87
|
+
|
|
88
|
+
- `check(submission)` resolves a `SpamFilter.Assessment`: `{ verdict, score, signals, failures }`.
|
|
89
|
+
Checks run in three stages: `local` in order; `remote` in parallel, only while the score is below
|
|
90
|
+
the spam threshold; `escalation` in parallel, only while the score is inside the unsure band.
|
|
91
|
+
A check that fails, throws or overruns `timeout` (default 1500 ms, remote and escalation only)
|
|
92
|
+
adds nothing and is listed in `failures`, so an outage never decides a verdict.
|
|
93
|
+
- `report(submission, label)` sends `"spam"` or `"ham"` to every check with a `report` method, in
|
|
94
|
+
parallel, and resolves the ones that failed.
|
|
95
|
+
|
|
96
|
+
`thresholds` defaults to `{ unsure: 5, spam: 10 }` (`DEFAULT_THRESHOLDS`). A score at or above
|
|
97
|
+
`spam` is spam, at or above `unsure` is unsure, and anything lower is ham.
|
|
98
|
+
|
|
99
|
+
#### `verdictFor(score, thresholds)`
|
|
100
|
+
|
|
101
|
+
The mapping `check` uses, for re-scoring stored signals under new thresholds.
|
|
102
|
+
|
|
103
|
+
#### `SpamCheck`
|
|
104
|
+
|
|
105
|
+
The contract every check implements: `name`, `stage` (`"local" | "remote" | "escalation"`),
|
|
106
|
+
`check(submission, { signal, score })` and an optional `report(submission, label)`. A local check
|
|
107
|
+
returns `Signal[]`; any other returns `Promise<Result<Signal[], SpamCheckError>>`. `signal` aborts
|
|
108
|
+
at the timeout, and `score` is the total earlier stages reached.
|
|
109
|
+
|
|
110
|
+
#### `Signal`
|
|
111
|
+
|
|
112
|
+
`{ check, score, detail? }`. `check` is a stable identifier such as `links.count`. A positive score
|
|
113
|
+
points to spam and a negative one to ham, so a provider can vouch for an author.
|
|
114
|
+
|
|
115
|
+
#### `Submission`
|
|
116
|
+
|
|
117
|
+
`{ content, format?, author?, renderedAt?, submittedAt?, languages? }`. `format` is `"text"` (the
|
|
118
|
+
default), `"markdown"` or `"html"`; `author` holds `name`, `email`, `url`, `ip` and `userAgent`;
|
|
119
|
+
`submittedAt` defaults to the time of the check; `languages` are BCP 47 tags.
|
|
120
|
+
|
|
121
|
+
#### `SpamCheckError`
|
|
122
|
+
|
|
123
|
+
A check's failure. `code` is `unavailable`, `timeout`, `misconfigured` or `invalid-response`.
|
|
124
|
+
|
|
125
|
+
#### Rules
|
|
126
|
+
|
|
127
|
+
Each rule is a function returning a local `SpamCheck`; every weight and limit is an option with a
|
|
128
|
+
documented default. `DEFAULT_RULES` holds all of them at their defaults, tuned together so a
|
|
129
|
+
submission needs more than one kind of evidence to reach the spam threshold.
|
|
130
|
+
|
|
131
|
+
| Rule | Signals | Fires on |
|
|
132
|
+
| --------------- | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
133
|
+
| `timing()` | `timing.fast`, `timing.future`, `timing.stale` | Sent under 3 s after render, rendered in the future, or rendered over a day ago |
|
|
134
|
+
| `links()` | `links.count`, `links.density` | Each link past the first; fewer than 4 words per link |
|
|
135
|
+
| `linkSyntax()` | `link-syntax.bbcode`, `link-syntax.html`, `link-syntax.markdown` | BBCode anywhere; HTML anchors outside `html`; Markdown links in `text` |
|
|
136
|
+
| `linkTargets()` | `link-targets.shortener`, `.ip-address`, `.punycode`, `.abused-tld` | Links (and `author.url`) to shorteners, IP literals, punycode hosts, abused TLDs |
|
|
137
|
+
| `unicode()` | `unicode.mixed-script`, `.hidden`, `.styled`, `.mark-stack` | Latin mixed with Cyrillic or Greek in one word, invisible separators, styled letters |
|
|
138
|
+
| `contactBait()` | `contact-bait.messaging`, `.phone`, `.wallet` | Telegram or WhatsApp handles, international phone numbers, crypto wallet addresses |
|
|
139
|
+
| `shouting()` | `shouting.caps`, `.run`, `.repetition`, `.emoji` | Capitals, one character six times, a phrase three times, emoji outnumbering words |
|
|
140
|
+
| `language()` | `language.script` | Most letters in a script none of `languages` is written in |
|
|
141
|
+
| `authorName()` | `author-name.domain`, `author-name.length` | A domain in the name, or a name longer than a person's |
|
|
142
|
+
|
|
143
|
+
`URL_SHORTENERS` and `ABUSED_TLDS` are the lists `linkTargets()` uses by default.
|
|
144
|
+
|
|
145
|
+
### `@sdxc/spam/author-email`
|
|
146
|
+
|
|
147
|
+
`authorEmail({ disposableScore? })` scores an `author.email` on a disposable-mail domain
|
|
148
|
+
(`author-email.disposable`, default 3), subdomains included. It ships separately because the
|
|
149
|
+
domain list it bundles is large, so it is not in `DEFAULT_RULES`. An address that does not parse
|
|
150
|
+
draws nothing.
|
|
151
|
+
|
|
152
|
+
### `@sdxc/spam/stop-forum-spam`
|
|
153
|
+
|
|
154
|
+
`stopForumSpam(options?)` looks the author's IP, email and name up in
|
|
155
|
+
[StopForumSpam](https://www.stopforumspam.com/usage), a free community database of addresses
|
|
156
|
+
reported for forum and blog spam. It sends only the fields the submission has, and nothing when it
|
|
157
|
+
has none. Each reported field scores its weight times the reported confidence (`ipScore` 5,
|
|
158
|
+
`emailScore` 6, `usernameScore` 2), reports last seen over `maxAgeDays` (90) ago are ignored, and
|
|
159
|
+
the total is capped at `maxScore` (10). Signals are `stop-forum-spam.ip`, `.email` and
|
|
160
|
+
`.username`. `endpoint` pins a regional mirror. The service has a daily query limit per IP and
|
|
161
|
+
receives the author's IP, email and name.
|
|
162
|
+
|
|
163
|
+
### `@sdxc/spam/link-blocklist`
|
|
164
|
+
|
|
165
|
+
`linkBlocklist(options?)` looks every linked domain and the author's website up in DNS URI
|
|
166
|
+
blocklists over DNS-over-HTTPS, so it runs where no DNS socket exists. Zones are `spamhaus`,
|
|
167
|
+
`surbl` and `uribl`; the default is `["surbl", "uribl"]`, and a `dqsKey` adds Spamhaus. A listing
|
|
168
|
+
scores `listedScore` (6); a low-confidence one (URIBL grey, a Spamhaus "abused legitimate" domain)
|
|
169
|
+
scores `weakScore` (2). At most `maxHosts` (10) domains are looked up, IP literals are skipped, and
|
|
170
|
+
the total is capped at `maxScore` (12). Signals are `link-blocklist.spamhaus`, `.surbl` and
|
|
171
|
+
`.uribl`. `resolver` routes queries through another DoH endpoint.
|
|
172
|
+
|
|
173
|
+
A blocklist's "query refused" or rate-limit answer fails as `misconfigured`, never as a listing.
|
|
174
|
+
Spamhaus refuses public resolvers, so it needs a
|
|
175
|
+
[Data Query Service](https://www.spamhaus.com/product/data-query-service/) key, which travels in the
|
|
176
|
+
query name. SURBL and URIBL are free for low-volume, non-commercial use and may refuse queries
|
|
177
|
+
relayed through large public resolvers; when they do, the check reports `misconfigured`, and a
|
|
178
|
+
private resolver or a commercial feed is the fix.
|
|
179
|
+
|
|
180
|
+
### `@sdxc/spam/akismet`
|
|
181
|
+
|
|
182
|
+
`akismet({ apiKey, blog })` sends each submission to Akismet's `comment-check` and turns the answer
|
|
183
|
+
into a signal: `akismet.spam` (default 8, the unsure band), `akismet.discard` when Akismet marks it
|
|
184
|
+
blatant (default 12, spam on its own), and `akismet.ham` when Akismet vouches for it (default -2).
|
|
185
|
+
Akismet requires the author's IP, so a submission without `author.ip` sends nothing and draws
|
|
186
|
+
nothing. `commentType` (default `"comment"`), `blogLang` and `isTest` pass through; the weights are
|
|
187
|
+
`spamScore`, `discardScore` and `hamScore`. An invalid key fails as `misconfigured`, an HTTP or
|
|
188
|
+
network error as `unavailable`. `report` forwards a moderator's decision to `submit-spam` or
|
|
189
|
+
`submit-ham`, bounded by `reportTimeout` (default 10 s).
|
|
190
|
+
|
|
191
|
+
Akismet is free for personal, non-commercial sites and paid otherwise. Each call sends the author's
|
|
192
|
+
IP, user agent, name, email, website and the full content.
|
|
193
|
+
|
|
194
|
+
### `@sdxc/spam/bayes`
|
|
195
|
+
|
|
196
|
+
`bayes({ store })` is a naive Bayes classifier that learns from moderators: every
|
|
197
|
+
`filter.report(submission, label)` trains it. It tokenizes content words, a `host:` token per
|
|
198
|
+
linked host and the author's website, and an `email-domain:` token, then combines the most telling
|
|
199
|
+
tokens (Robinson's smoothed probabilities, Fisher's method) into a spam probability. At or above
|
|
200
|
+
`spamCut` (0.9) it adds `bayes.spam` (`spamScore`, 6); at or below `hamCut` (0.2) it adds
|
|
201
|
+
`bayes.ham` (`hamScore`, -4). Until the store holds `minDocuments` (20) reports of each label it
|
|
202
|
+
adds nothing, so a new install relies on the rules alone. The signal's detail names the tokens
|
|
203
|
+
that decided it. `tokenize(submission, options?)` is exported for inspecting what it learns.
|
|
204
|
+
|
|
205
|
+
Counts live behind `TokenStore` (`read(tokens)` and `increment(tokens, label)`, both `Result`s):
|
|
206
|
+
|
|
207
|
+
- `MemoryTokenStore` keeps counts in the instance, for tests.
|
|
208
|
+
- `new DataTableTokenStore(db)` stores them in a `remix/data-table` database, D1 and SQLite
|
|
209
|
+
included. Create its table from `SPAM_TOKENS_SCHEMA_SQL` in a migration; `spamTokens` is the
|
|
210
|
+
table definition. Every write is one upsert statement, so concurrent reports never lose a count.
|
|
211
|
+
|
|
212
|
+
In a multi-tenant app, build one store per tenant database, so one tenant's training never changes
|
|
213
|
+
another's verdicts. The check runs in the local stage, since it reads the app's own database.
|
|
214
|
+
|
|
215
|
+
### `@sdxc/spam/workers-ai`
|
|
216
|
+
|
|
217
|
+
`workersAi({ ai, model?, site?, spamScore?, hamScore?, maxContentLength?, maxReasonLength? })` is
|
|
218
|
+
an escalation check: the filter calls it only for submissions the other checks left unsure, so the
|
|
219
|
+
model runs on the few cases rules cannot settle. Pass the Workers AI binding (`env.AI`) as `ai`,
|
|
220
|
+
and describe the site in `site` (`"comments on a cooking blog"`) so the model judges relevance for
|
|
221
|
+
the right audience.
|
|
222
|
+
|
|
223
|
+
The check sends the content (cut to `maxContentLength`, default 4000 characters), the author's name
|
|
224
|
+
and URL, and up to 20 of the signals earlier checks found, as escaped JSON inside delimiters, under
|
|
225
|
+
a system prompt that treats everything inside as data to classify. The model answers `spam`, `ham`
|
|
226
|
+
or `unsure` with a confidence and a reason: spam adds `workers-ai.spam` at `spamScore × confidence`
|
|
227
|
+
(default 5, enough to carry an unsure 5 to 10), ham adds `workers-ai.ham` at
|
|
228
|
+
`-(hamScore × confidence)` (default 5), and unsure adds nothing. The reason, cut to
|
|
229
|
+
`maxReasonLength` (200), is the signal's detail. The default model is
|
|
230
|
+
`@cf/meta/llama-3.1-8b-instruct-fp8` (`DEFAULT_WORKERS_AI_MODEL`); `model` takes any Workers AI
|
|
231
|
+
text model with JSON mode. `ai` is typed as any object with a matching `run()`, so a test passes a
|
|
232
|
+
plain object.
|
|
233
|
+
|
|
234
|
+
### `@sdxc/spam/memory`
|
|
235
|
+
|
|
236
|
+
`new MemoryCheck({ name?, stage?, signals? })` is a scripted check for tests. A call answers the
|
|
237
|
+
next queued outcome, else `signals`.
|
|
238
|
+
|
|
239
|
+
- `signalNext(signals)` / `failNext(code, message?)` queue the next answer.
|
|
240
|
+
- `failReports(code)` makes every later `report` fail.
|
|
241
|
+
- `calls`, `last` and `reports` record what the check received.
|
|
242
|
+
- `reset()` forgets calls, reports and queued outcomes.
|
|
243
|
+
|
|
244
|
+
`name` defaults to `"memory"` and `stage` to `"remote"`.
|
|
245
|
+
|
|
246
|
+
## Pattern: Refuse bots before scoring
|
|
247
|
+
|
|
248
|
+
A honeypot refuses what no person produces, before the body is read into a submission, so no
|
|
249
|
+
rule, lookup or model call runs for it. [`@sdxc/honeypot`](https://www.npmjs.com/package/@sdxc/honeypot)
|
|
250
|
+
does that, and what it lets through carries a signed render time for the `timing` rule.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
import { Honeypot } from "@sdxc/honeypot";
|
|
254
|
+
import { honeypot } from "@sdxc/honeypot/middleware";
|
|
255
|
+
import { unwrap } from "@sdxc/result";
|
|
256
|
+
import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
|
|
257
|
+
|
|
258
|
+
let trap = new Honeypot({ secret: HONEYPOT_SECRET });
|
|
259
|
+
let filter = createSpamFilter({ checks: DEFAULT_RULES });
|
|
260
|
+
|
|
261
|
+
router.post("/comments", {
|
|
262
|
+
middleware: [honeypot(trap)],
|
|
263
|
+
async handler(ctx) {
|
|
264
|
+
let form = await ctx.request.formData();
|
|
265
|
+
let assessment = await filter.check({
|
|
266
|
+
content: String(form.get("content") ?? ""),
|
|
267
|
+
renderedAt: unwrap(ctx.honeypot).renderedAt,
|
|
268
|
+
});
|
|
269
|
+
return saveComment(form, assessment);
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
## Pattern: Hold, discard or publish
|
|
275
|
+
|
|
276
|
+
The verdict is a recommendation; the app decides what each one does. Answering spam like a success
|
|
277
|
+
teaches a bot nothing.
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import type { Submission } from "@sdxc/spam";
|
|
281
|
+
|
|
282
|
+
import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
|
|
283
|
+
|
|
284
|
+
let filter = createSpamFilter({ checks: DEFAULT_RULES });
|
|
285
|
+
|
|
286
|
+
async function submitComment(submission: Submission) {
|
|
287
|
+
let assessment = await filter.check(submission);
|
|
288
|
+
if (assessment.verdict === "spam") return { status: "received" };
|
|
289
|
+
let comment = await saveComment(submission, {
|
|
290
|
+
published: assessment.verdict === "ham",
|
|
291
|
+
reasons: assessment.signals,
|
|
292
|
+
});
|
|
293
|
+
return { status: comment.published ? "published" : "received" };
|
|
294
|
+
}
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
## Pattern: Writing a rule for your site
|
|
298
|
+
|
|
299
|
+
A rule is an object with a name, a stage and a `check` function. Signals from it add to every
|
|
300
|
+
other rule's.
|
|
301
|
+
|
|
302
|
+
```ts
|
|
303
|
+
import type { SpamCheck } from "@sdxc/spam";
|
|
304
|
+
|
|
305
|
+
import { createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
|
|
306
|
+
|
|
307
|
+
let competitorLinks: SpamCheck = {
|
|
308
|
+
name: "competitor-links",
|
|
309
|
+
stage: "local",
|
|
310
|
+
check: (submission) =>
|
|
311
|
+
/rival\.example/i.test(submission.content)
|
|
312
|
+
? [{ check: "competitor-links", score: 4, detail: "links to rival.example" }]
|
|
313
|
+
: [],
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
let filter = createSpamFilter({ checks: [...DEFAULT_RULES, competitorLinks] });
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
## Pattern: Reweighting a rule
|
|
320
|
+
|
|
321
|
+
`DEFAULT_RULES` is a list of rule calls at their defaults. Build your own list to change one.
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
import { contactBait, createSpamFilter, DEFAULT_RULES } from "@sdxc/spam";
|
|
325
|
+
|
|
326
|
+
let filter = createSpamFilter({
|
|
327
|
+
checks: [
|
|
328
|
+
...DEFAULT_RULES.filter((rule) => rule.name !== "contact-bait"),
|
|
329
|
+
contactBait({ phoneScore: 0 }), // this form asks for a phone number
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
## Pattern: Testing an action that filters
|
|
335
|
+
|
|
336
|
+
`MemoryCheck` fixes the evidence, so a test exercises each verdict without a provider.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { createSpamFilter } from "@sdxc/spam";
|
|
340
|
+
import { MemoryCheck } from "@sdxc/spam/memory";
|
|
341
|
+
import { expect, test } from "vitest";
|
|
342
|
+
|
|
343
|
+
test("holds an unsure comment for moderation", async () => {
|
|
344
|
+
let check = new MemoryCheck({ signals: [{ check: "test", score: 6 }] });
|
|
345
|
+
let filter = createSpamFilter({ checks: [check] });
|
|
346
|
+
|
|
347
|
+
let assessment = await filter.check({ content: "hello" });
|
|
348
|
+
|
|
349
|
+
expect(assessment.verdict).toBe("unsure");
|
|
350
|
+
expect(check.last).toEqual({ content: "hello" });
|
|
351
|
+
});
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
## Versioning
|
|
355
|
+
|
|
356
|
+
Releases are dated rather than semantic. A version is the UTC date it was published,
|
|
357
|
+
written `YYYY.M.D`, so `2026.9.4` is the release from 4 September 2026. At most one
|
|
358
|
+
release goes out per day.
|
|
359
|
+
|
|
360
|
+
Those numbers say when, not what: a later date means a later release and carries no
|
|
361
|
+
compatibility promise. Any release may change or remove an export.
|
|
362
|
+
|
|
363
|
+
Depend on one exact date, and move it when you are ready to take the change:
|
|
364
|
+
|
|
365
|
+
```json
|
|
366
|
+
{
|
|
367
|
+
"dependencies": {
|
|
368
|
+
"@sdxc/spam": "2026.9.4"
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
A caret or tilde range reads the date as major, minor and patch, so it accepts every
|
|
374
|
+
later release in the same year. An exact version keeps the upgrade yours to schedule.
|
|
375
|
+
|
|
376
|
+
## License
|
|
377
|
+
|
|
378
|
+
MIT
|
|
379
|
+
|
|
380
|
+
## Author
|
|
381
|
+
|
|
382
|
+
[Sergio Xalambrí](https://sergiodxa.com)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { SpamCheck } from "./check.js";
|
|
2
|
+
/**
|
|
3
|
+
* Checks submissions against Akismet's cross-site reputation network. Akismet requires the
|
|
4
|
+
* author's IP, so a submission without `author.ip` answers no signals and makes no request.
|
|
5
|
+
* `true` is spam, `true` with `X-akismet-pro-tip: discard` is blatant spam that clears the
|
|
6
|
+
* default spam threshold alone, and `false` is a small ham signal, since Akismet vouches for it.
|
|
7
|
+
*
|
|
8
|
+
* @example let check = akismet({ apiKey: env.AKISMET_API_KEY, blog: "https://example.com" });
|
|
9
|
+
*/
|
|
10
|
+
export declare function akismet(options: akismet.Options): SpamCheck;
|
|
11
|
+
/** The options {@link akismet} takes. */
|
|
12
|
+
export declare namespace akismet {
|
|
13
|
+
/** The Akismet account, the site it is registered for, and the weight of each answer. */
|
|
14
|
+
interface Options {
|
|
15
|
+
/** The Akismet API key; it never reaches the browser. */
|
|
16
|
+
apiKey: string;
|
|
17
|
+
/** The front page of the site the key is registered for, such as `https://example.com`. */
|
|
18
|
+
blog: string;
|
|
19
|
+
/**
|
|
20
|
+
* What kind of content is checked, in Akismet's vocabulary: `comment`, `reply`,
|
|
21
|
+
* `forum-post`, `blog-post`, `contact-form`, `signup` or `message`.
|
|
22
|
+
*
|
|
23
|
+
* @default "comment"
|
|
24
|
+
*/
|
|
25
|
+
commentType?: string;
|
|
26
|
+
/** The languages the site is written in, comma-separated ISO 639-1 codes such as `en, fr`. */
|
|
27
|
+
blogLang?: string;
|
|
28
|
+
/**
|
|
29
|
+
* Sends `is_test=1`, so Akismet answers without learning from the call; for development
|
|
30
|
+
* and automated tests.
|
|
31
|
+
*
|
|
32
|
+
* @default false
|
|
33
|
+
*/
|
|
34
|
+
isTest?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* The score when Akismet answers spam. It lands in the default unsure band, so a moderator
|
|
37
|
+
* sees it unless other signals push it over.
|
|
38
|
+
*
|
|
39
|
+
* @default 8
|
|
40
|
+
*/
|
|
41
|
+
spamScore?: number;
|
|
42
|
+
/**
|
|
43
|
+
* The score when Akismet marks spam as blatant with `X-akismet-pro-tip: discard`; it clears
|
|
44
|
+
* the default spam threshold on its own.
|
|
45
|
+
*
|
|
46
|
+
* @default 12
|
|
47
|
+
*/
|
|
48
|
+
discardScore?: number;
|
|
49
|
+
/**
|
|
50
|
+
* The score when Akismet answers ham. It is negative, so Akismet's vouching offsets a weak
|
|
51
|
+
* local signal without cancelling a strong one.
|
|
52
|
+
*
|
|
53
|
+
* @default -2
|
|
54
|
+
*/
|
|
55
|
+
hamScore?: number;
|
|
56
|
+
/**
|
|
57
|
+
* Milliseconds a `submit-spam` or `submit-ham` call may take before it fails `unavailable`.
|
|
58
|
+
*
|
|
59
|
+
* @default 10000
|
|
60
|
+
*/
|
|
61
|
+
reportTimeout?: number;
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/akismet.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
2
|
+
import * as s from "remix/data-schema";
|
|
3
|
+
import { SpamCheckError } from "./check.js";
|
|
4
|
+
/** The REST API root every Akismet call goes to. */
|
|
5
|
+
const AKISMET_API_URL = "https://rest.akismet.com/1.1";
|
|
6
|
+
/** The only `comment-check` bodies that carry a verdict; `invalid` is handled before them. */
|
|
7
|
+
const COMMENT_CHECK_ANSWER = s.enum_(["true", "false"]);
|
|
8
|
+
/**
|
|
9
|
+
* Checks submissions against Akismet's cross-site reputation network. Akismet requires the
|
|
10
|
+
* author's IP, so a submission without `author.ip` answers no signals and makes no request.
|
|
11
|
+
* `true` is spam, `true` with `X-akismet-pro-tip: discard` is blatant spam that clears the
|
|
12
|
+
* default spam threshold alone, and `false` is a small ham signal, since Akismet vouches for it.
|
|
13
|
+
*
|
|
14
|
+
* @example let check = akismet({ apiKey: env.AKISMET_API_KEY, blog: "https://example.com" });
|
|
15
|
+
*/
|
|
16
|
+
export function akismet(options) {
|
|
17
|
+
let spamScore = options.spamScore ?? 8;
|
|
18
|
+
let discardScore = options.discardScore ?? 12;
|
|
19
|
+
let hamScore = options.hamScore ?? -2;
|
|
20
|
+
let reportTimeout = options.reportTimeout ?? 10_000;
|
|
21
|
+
return {
|
|
22
|
+
name: "akismet",
|
|
23
|
+
stage: "remote",
|
|
24
|
+
/**
|
|
25
|
+
* Asks `comment-check` for a verdict under the filter's abort signal. A bad key is
|
|
26
|
+
* `misconfigured`, a network failure or non-OK status `unavailable`, and any body other
|
|
27
|
+
* than `true`, `false` or `invalid` is `invalid-response`.
|
|
28
|
+
*/
|
|
29
|
+
async check(submission, { signal }) {
|
|
30
|
+
let body = formFields(options, submission);
|
|
31
|
+
if (body === null)
|
|
32
|
+
return success([]);
|
|
33
|
+
let answer = await post("comment-check", body, signal);
|
|
34
|
+
if (isFailure(answer))
|
|
35
|
+
return answer;
|
|
36
|
+
let { text, headers } = answer.data;
|
|
37
|
+
let parsed = s.parseSafe(COMMENT_CHECK_ANSWER, text);
|
|
38
|
+
if (!parsed.success) {
|
|
39
|
+
return failure(new SpamCheckError("invalid-response", `comment-check answered "${text}"`));
|
|
40
|
+
}
|
|
41
|
+
if (parsed.value === "false") {
|
|
42
|
+
return success([
|
|
43
|
+
{ check: "akismet.ham", score: hamScore, detail: "Akismet considers it legitimate" },
|
|
44
|
+
]);
|
|
45
|
+
}
|
|
46
|
+
if (headers.get("x-akismet-pro-tip")?.toLowerCase() === "discard") {
|
|
47
|
+
return success([
|
|
48
|
+
{ check: "akismet.discard", score: discardScore, detail: "Akismet flagged blatant spam" },
|
|
49
|
+
]);
|
|
50
|
+
}
|
|
51
|
+
return success([{ check: "akismet.spam", score: spamScore, detail: "Akismet flagged spam" }]);
|
|
52
|
+
},
|
|
53
|
+
/**
|
|
54
|
+
* Sends a moderator's decision as `submit-spam` or `submit-ham` with the fields
|
|
55
|
+
* `comment-check` received, so Akismet can correct itself. A submission without
|
|
56
|
+
* `author.ip` was never checked by Akismet, so its report succeeds without a request.
|
|
57
|
+
*/
|
|
58
|
+
async report(submission, label) {
|
|
59
|
+
let body = formFields(options, submission);
|
|
60
|
+
if (body === null)
|
|
61
|
+
return success(undefined);
|
|
62
|
+
let endpoint = label === "spam" ? "submit-spam" : "submit-ham";
|
|
63
|
+
let answer = await post(endpoint, body, AbortSignal.timeout(reportTimeout));
|
|
64
|
+
if (isFailure(answer))
|
|
65
|
+
return answer;
|
|
66
|
+
return success(undefined);
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The form fields Akismet reads, from the options and the submission. Absent fields stay out of
|
|
72
|
+
* the form, so Akismet sees them as unknown rather than empty.
|
|
73
|
+
*
|
|
74
|
+
* @returns The form, or `null` when the submission has no author IP, which Akismet requires
|
|
75
|
+
*/
|
|
76
|
+
function formFields(options, submission) {
|
|
77
|
+
let author = submission.author;
|
|
78
|
+
if (author?.ip === undefined || author.ip === "")
|
|
79
|
+
return null;
|
|
80
|
+
let body = new URLSearchParams({
|
|
81
|
+
api_key: options.apiKey,
|
|
82
|
+
blog: options.blog,
|
|
83
|
+
user_ip: author.ip,
|
|
84
|
+
comment_type: options.commentType ?? "comment",
|
|
85
|
+
comment_content: submission.content,
|
|
86
|
+
});
|
|
87
|
+
let optional = [
|
|
88
|
+
["user_agent", author.userAgent],
|
|
89
|
+
["comment_author", author.name],
|
|
90
|
+
["comment_author_email", author.email],
|
|
91
|
+
["comment_author_url", author.url],
|
|
92
|
+
["blog_lang", options.blogLang],
|
|
93
|
+
["comment_date_gmt", submission.submittedAt?.toISOString()],
|
|
94
|
+
];
|
|
95
|
+
for (let [name, value] of optional) {
|
|
96
|
+
if (value !== undefined && value !== "")
|
|
97
|
+
body.set(name, value);
|
|
98
|
+
}
|
|
99
|
+
if (options.isTest)
|
|
100
|
+
body.set("is_test", "1");
|
|
101
|
+
return body;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Posts `body` to one Akismet endpoint. Akismet reports a rejected key with a 200 whose body is
|
|
105
|
+
* `invalid` or with an `X-akismet-debug-help` header, and both become `misconfigured` carrying
|
|
106
|
+
* Akismet's explanation; a network failure or non-OK status becomes `unavailable`.
|
|
107
|
+
*/
|
|
108
|
+
async function post(endpoint, body, signal) {
|
|
109
|
+
let response;
|
|
110
|
+
let text;
|
|
111
|
+
try {
|
|
112
|
+
response = await fetch(`${AKISMET_API_URL}/${endpoint}`, { method: "POST", body, signal });
|
|
113
|
+
text = (await response.text()).trim();
|
|
114
|
+
}
|
|
115
|
+
catch (error) {
|
|
116
|
+
let message = error instanceof Error ? error.message : String(error);
|
|
117
|
+
return failure(new SpamCheckError("unavailable", message));
|
|
118
|
+
}
|
|
119
|
+
if (!response.ok) {
|
|
120
|
+
return failure(new SpamCheckError("unavailable", `${endpoint} answered HTTP ${response.status}`));
|
|
121
|
+
}
|
|
122
|
+
let debugHelp = response.headers.get("x-akismet-debug-help");
|
|
123
|
+
if (text === "invalid" || debugHelp !== null) {
|
|
124
|
+
return failure(new SpamCheckError("misconfigured", debugHelp ?? `${endpoint} refused the API key`));
|
|
125
|
+
}
|
|
126
|
+
return success({ text, headers: response.headers });
|
|
127
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { SpamCheck } from "./check.js";
|
|
2
|
+
/**
|
|
3
|
+
* Scores an author email on a disposable domain. An address that fails to parse draws nothing,
|
|
4
|
+
* since validating the field is the form's job.
|
|
5
|
+
*
|
|
6
|
+
* @example createSpamFilter({ checks: [...DEFAULT_RULES, authorEmail()] })
|
|
7
|
+
*/
|
|
8
|
+
export declare function authorEmail(options?: authorEmail.Options): SpamCheck;
|
|
9
|
+
/** The options {@link authorEmail} takes. */
|
|
10
|
+
export declare namespace authorEmail {
|
|
11
|
+
/** Weights for the author-email rule. */
|
|
12
|
+
interface Options {
|
|
13
|
+
/** @default 3 */
|
|
14
|
+
disposableScore?: number;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The author-email rule: an address on a disposable-mail domain. It lives under its own export
|
|
3
|
+
* path because the domain list it reads is large, so a consumer who never checks email never
|
|
4
|
+
* bundles it.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import { parseEmailAddress } from "@sdxc/email-address";
|
|
10
|
+
import { findDisposableDomain } from "@sdxc/email-address/disposable";
|
|
11
|
+
import { isSuccess } from "@sdxc/result";
|
|
12
|
+
/**
|
|
13
|
+
* Scores an author email on a disposable domain. An address that fails to parse draws nothing,
|
|
14
|
+
* since validating the field is the form's job.
|
|
15
|
+
*
|
|
16
|
+
* @example createSpamFilter({ checks: [...DEFAULT_RULES, authorEmail()] })
|
|
17
|
+
*/
|
|
18
|
+
export function authorEmail(options = {}) {
|
|
19
|
+
let disposableScore = options.disposableScore ?? 3;
|
|
20
|
+
return {
|
|
21
|
+
name: "author-email",
|
|
22
|
+
stage: "local",
|
|
23
|
+
check(submission) {
|
|
24
|
+
let email = submission.author?.email;
|
|
25
|
+
if (!email)
|
|
26
|
+
return [];
|
|
27
|
+
let parsed = parseEmailAddress(email);
|
|
28
|
+
if (!isSuccess(parsed))
|
|
29
|
+
return [];
|
|
30
|
+
let listed = findDisposableDomain(parsed.data.domain);
|
|
31
|
+
if (listed === null)
|
|
32
|
+
return [];
|
|
33
|
+
return [
|
|
34
|
+
{
|
|
35
|
+
check: "author-email.disposable",
|
|
36
|
+
score: disposableScore,
|
|
37
|
+
detail: `${listed} is a disposable email domain`,
|
|
38
|
+
},
|
|
39
|
+
];
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|