@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
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link-blocklist check: looks each linked domain up in DNS URI blocklists (Spamhaus DBL,
|
|
3
|
+
* SURBL, URIBL) over DNS-over-HTTPS, so a domain reported by the wider mail and web community
|
|
4
|
+
* scores here even when the submission's text looks innocent.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { DoH } from "@sdxc/doh";
|
|
10
|
+
import type { SpamCheck } from "./check.js";
|
|
11
|
+
/**
|
|
12
|
+
* Scores the content's links and the author's URL by DNS blocklist listings. Each distinct
|
|
13
|
+
* domain, up to `maxHosts`, is looked up in every zone in parallel; IP literals are skipped.
|
|
14
|
+
* When any lookup finds a listing the signals are returned; otherwise a refused or failed
|
|
15
|
+
* lookup fails the check, so an incomplete answer never passes for a clean one.
|
|
16
|
+
*
|
|
17
|
+
* @example linkBlocklist({ dqsKey: env.SPAMHAUS_DQS_KEY })
|
|
18
|
+
*/
|
|
19
|
+
export declare function linkBlocklist(options?: linkBlocklist.Options): SpamCheck;
|
|
20
|
+
/** The options {@link linkBlocklist} takes. */
|
|
21
|
+
export declare namespace linkBlocklist {
|
|
22
|
+
/** A supported blocklist: Spamhaus DBL, SURBL `multi` or URIBL `multi`. */
|
|
23
|
+
type Zone = "spamhaus" | "surbl" | "uribl";
|
|
24
|
+
/** Zones, credentials and weights for the link-blocklist check. */
|
|
25
|
+
interface Options {
|
|
26
|
+
/**
|
|
27
|
+
* The zones to query. Spamhaus refuses queries relayed by public resolvers, so it needs
|
|
28
|
+
* `dqsKey` and is included by default only when one is given.
|
|
29
|
+
*
|
|
30
|
+
* @default ["surbl", "uribl"], plus "spamhaus" first when `dqsKey` is set
|
|
31
|
+
*/
|
|
32
|
+
zones?: readonly Zone[];
|
|
33
|
+
/** A Spamhaus Data Query Service key, which the DBL query name embeds. */
|
|
34
|
+
dqsKey?: string;
|
|
35
|
+
/**
|
|
36
|
+
* The DoH resolver every query goes through; point it at a resolver the zones accept when
|
|
37
|
+
* the default one is refused.
|
|
38
|
+
*
|
|
39
|
+
* @default Cloudflare's 1.1.1.1
|
|
40
|
+
*/
|
|
41
|
+
resolver?: DoH.Resolver;
|
|
42
|
+
/**
|
|
43
|
+
* Distinct domains looked up per submission, bounding the queries a link-stuffed post costs.
|
|
44
|
+
*
|
|
45
|
+
* @default 10
|
|
46
|
+
*/
|
|
47
|
+
maxHosts?: number;
|
|
48
|
+
/**
|
|
49
|
+
* The score of each zone listing a domain.
|
|
50
|
+
*
|
|
51
|
+
* @default 6
|
|
52
|
+
*/
|
|
53
|
+
listedScore?: number;
|
|
54
|
+
/**
|
|
55
|
+
* The score of a low-confidence listing: URIBL grey, or a Spamhaus listing of an abused
|
|
56
|
+
* legitimate domain.
|
|
57
|
+
*
|
|
58
|
+
* @default 2
|
|
59
|
+
*/
|
|
60
|
+
weakScore?: number;
|
|
61
|
+
/** @default 12 */
|
|
62
|
+
maxScore?: number;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { NameNotFoundError, resolve } from "@sdxc/doh";
|
|
2
|
+
import { failure, isFailure, success } from "@sdxc/result";
|
|
3
|
+
import { SpamCheckError } from "./check.js";
|
|
4
|
+
import { extractLinks } from "./lib/text.js";
|
|
5
|
+
/**
|
|
6
|
+
* Second-level labels under a country-code TLD that registries sell names beneath, such as
|
|
7
|
+
* `co.uk` or `com.br`, so `shop.example.co.uk` reduces to `example.co.uk`.
|
|
8
|
+
*/
|
|
9
|
+
const SECOND_LEVEL_LABELS = new Set([
|
|
10
|
+
"ac",
|
|
11
|
+
"co",
|
|
12
|
+
"com",
|
|
13
|
+
"edu",
|
|
14
|
+
"gob",
|
|
15
|
+
"gov",
|
|
16
|
+
"go",
|
|
17
|
+
"ltd",
|
|
18
|
+
"me",
|
|
19
|
+
"ne",
|
|
20
|
+
"net",
|
|
21
|
+
"nom",
|
|
22
|
+
"or",
|
|
23
|
+
"org",
|
|
24
|
+
"plc",
|
|
25
|
+
]);
|
|
26
|
+
/**
|
|
27
|
+
* Spamhaus DBL categories by the last octet of `127.0.1.x`; the same code plus 100 (`127.0.1.102`
|
|
28
|
+
* and on) is that category on a legitimate domain that was abused, a weaker reason to hold a post.
|
|
29
|
+
*/
|
|
30
|
+
const SPAMHAUS_CODES = {
|
|
31
|
+
2: "spam",
|
|
32
|
+
3: "spammed redirector",
|
|
33
|
+
4: "phishing",
|
|
34
|
+
5: "malware",
|
|
35
|
+
6: "botnet C&C",
|
|
36
|
+
};
|
|
37
|
+
/** SURBL `multi` list bits in the last octet. */
|
|
38
|
+
const SURBL_BITS = [
|
|
39
|
+
[8, "phishing"],
|
|
40
|
+
[16, "malware"],
|
|
41
|
+
[64, "abuse"],
|
|
42
|
+
[128, "cracked site"],
|
|
43
|
+
];
|
|
44
|
+
/** URIBL `multi` list bits in the last octet; black and red are listings, grey is weak. */
|
|
45
|
+
const URIBL_BITS = [
|
|
46
|
+
[2, "black"],
|
|
47
|
+
[4, "grey"],
|
|
48
|
+
[8, "red"],
|
|
49
|
+
];
|
|
50
|
+
/**
|
|
51
|
+
* The supported zones. Every one answers NXDOMAIN for a clean name and a 127.0.0.0/8 address
|
|
52
|
+
* for a listed one, and each reserves some of those addresses to say the query itself was
|
|
53
|
+
* refused, which reads as a misconfiguration rather than a listing.
|
|
54
|
+
*/
|
|
55
|
+
const ZONES = {
|
|
56
|
+
spamhaus: {
|
|
57
|
+
title: "Spamhaus DBL",
|
|
58
|
+
queryName: (host, dqsKey) => `${host}.${dqsKey}.dbl.dq.spamhaus.net`,
|
|
59
|
+
read([, second, third, fourth]) {
|
|
60
|
+
if (second === 255 && third === 255)
|
|
61
|
+
return { kind: "error" };
|
|
62
|
+
if (third !== 1 || fourth === 255)
|
|
63
|
+
return { kind: "error" };
|
|
64
|
+
let label = SPAMHAUS_CODES[(fourth ?? 0) % 100] ?? "listed";
|
|
65
|
+
if ((fourth ?? 0) >= 100)
|
|
66
|
+
return { kind: "listed", strength: "weak", label: `abused ${label}` };
|
|
67
|
+
return { kind: "listed", strength: "listed", label };
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
surbl: {
|
|
71
|
+
title: "SURBL",
|
|
72
|
+
queryName: (host) => `${host}.multi.surbl.org`,
|
|
73
|
+
read([, second, third, fourth]) {
|
|
74
|
+
if (second !== 0 || third !== 0 || fourth === 1)
|
|
75
|
+
return { kind: "error" };
|
|
76
|
+
let labels = bitLabels(fourth ?? 0, SURBL_BITS);
|
|
77
|
+
return { kind: "listed", strength: "listed", label: labels || "listed" };
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
uribl: {
|
|
81
|
+
title: "URIBL",
|
|
82
|
+
queryName: (host) => `${host}.multi.uribl.com`,
|
|
83
|
+
read([, second, third, fourth]) {
|
|
84
|
+
if (second !== 0 || third !== 0 || fourth === 1)
|
|
85
|
+
return { kind: "error" };
|
|
86
|
+
let bits = fourth ?? 0;
|
|
87
|
+
let strength = (bits & 0b1010) !== 0 ? "listed" : "weak";
|
|
88
|
+
return { kind: "listed", strength, label: bitLabels(bits, URIBL_BITS) || "listed" };
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
/**
|
|
93
|
+
* Scores the content's links and the author's URL by DNS blocklist listings. Each distinct
|
|
94
|
+
* domain, up to `maxHosts`, is looked up in every zone in parallel; IP literals are skipped.
|
|
95
|
+
* When any lookup finds a listing the signals are returned; otherwise a refused or failed
|
|
96
|
+
* lookup fails the check, so an incomplete answer never passes for a clean one.
|
|
97
|
+
*
|
|
98
|
+
* @example linkBlocklist({ dqsKey: env.SPAMHAUS_DQS_KEY })
|
|
99
|
+
*/
|
|
100
|
+
export function linkBlocklist(options = {}) {
|
|
101
|
+
let zones = options.zones ?? (options.dqsKey ? ["spamhaus", "surbl", "uribl"] : ["surbl", "uribl"]);
|
|
102
|
+
let maxHosts = options.maxHosts ?? 10;
|
|
103
|
+
let listedScore = options.listedScore ?? 6;
|
|
104
|
+
let weakScore = options.weakScore ?? 2;
|
|
105
|
+
let maxScore = options.maxScore ?? 12;
|
|
106
|
+
return {
|
|
107
|
+
name: "link-blocklist",
|
|
108
|
+
stage: "remote",
|
|
109
|
+
async check(submission, { signal }) {
|
|
110
|
+
if (zones.includes("spamhaus") && !options.dqsKey) {
|
|
111
|
+
return failure(new SpamCheckError("misconfigured", "Spamhaus DBL needs a dqsKey"));
|
|
112
|
+
}
|
|
113
|
+
let links = extractLinks(`${submission.content} ${submission.author?.url ?? ""}`);
|
|
114
|
+
let hosts = [...new Set(links.flatMap((url) => registrableHost(url.hostname) ?? []))];
|
|
115
|
+
hosts = hosts.slice(0, maxHosts);
|
|
116
|
+
if (hosts.length === 0 || zones.length === 0)
|
|
117
|
+
return success([]);
|
|
118
|
+
let lookups = await Promise.all(hosts.flatMap((host) => zones.map(async (zone) => ({
|
|
119
|
+
host,
|
|
120
|
+
zone,
|
|
121
|
+
outcome: await lookUp(ZONES[zone], zone, host, options, signal),
|
|
122
|
+
}))));
|
|
123
|
+
let signals = [];
|
|
124
|
+
let total = 0;
|
|
125
|
+
let firstError = null;
|
|
126
|
+
for (let { host, zone, outcome } of lookups) {
|
|
127
|
+
if (isFailure(outcome)) {
|
|
128
|
+
firstError ??= outcome.error;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (outcome.data === null)
|
|
132
|
+
continue;
|
|
133
|
+
let base = outcome.data.strength === "listed" ? listedScore : weakScore;
|
|
134
|
+
let score = Math.min(base, maxScore - total);
|
|
135
|
+
if (score <= 0)
|
|
136
|
+
continue;
|
|
137
|
+
total += score;
|
|
138
|
+
signals.push({
|
|
139
|
+
check: `link-blocklist.${zone}`,
|
|
140
|
+
score,
|
|
141
|
+
detail: `${host} is listed by ${ZONES[zone].title} (${outcome.data.label})`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
if (signals.length === 0 && firstError !== null)
|
|
145
|
+
return failure(firstError);
|
|
146
|
+
return success(signals);
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Queries one zone for one host. NXDOMAIN and an answer with no address mean not listed
|
|
152
|
+
* (`null`); a reserved error code is `misconfigured`, an address outside 127.0.0.0/8 is
|
|
153
|
+
* `invalid-response`, and a resolver failure is `unavailable`.
|
|
154
|
+
*/
|
|
155
|
+
async function lookUp(spec, zone, host, options, signal) {
|
|
156
|
+
let answer = await resolve(spec.queryName(host, options.dqsKey), "A", {
|
|
157
|
+
resolver: options.resolver,
|
|
158
|
+
signal,
|
|
159
|
+
});
|
|
160
|
+
if (isFailure(answer)) {
|
|
161
|
+
if (answer.error instanceof NameNotFoundError)
|
|
162
|
+
return success(null);
|
|
163
|
+
return failure(new SpamCheckError("unavailable", `${zone}: ${answer.error.message}`));
|
|
164
|
+
}
|
|
165
|
+
let strongest = null;
|
|
166
|
+
for (let record of answer.data.records) {
|
|
167
|
+
let octets = record.address.split(".").map(Number);
|
|
168
|
+
if (octets[0] !== 127) {
|
|
169
|
+
return failure(new SpamCheckError("invalid-response", `${zone} answered ${record.address}`));
|
|
170
|
+
}
|
|
171
|
+
let reading = spec.read(octets);
|
|
172
|
+
if (reading.kind === "error") {
|
|
173
|
+
return failure(new SpamCheckError("misconfigured", `${zone} refused the query (${record.address})`));
|
|
174
|
+
}
|
|
175
|
+
if (strongest === null || reading.strength === "listed")
|
|
176
|
+
strongest = reading;
|
|
177
|
+
}
|
|
178
|
+
return success(strongest);
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The domain a blocklist indexes for `hostname`: its last two labels, or three under a
|
|
182
|
+
* registry second level such as `co.uk`. IP literals and single-label names give `null`.
|
|
183
|
+
*/
|
|
184
|
+
function registrableHost(hostname) {
|
|
185
|
+
let host = hostname.toLowerCase().replace(/\.$/, "");
|
|
186
|
+
if (host.startsWith("[") || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host))
|
|
187
|
+
return null;
|
|
188
|
+
let labels = host.split(".");
|
|
189
|
+
if (labels.length < 2)
|
|
190
|
+
return null;
|
|
191
|
+
let tld = labels[labels.length - 1] ?? "";
|
|
192
|
+
let second = labels[labels.length - 2] ?? "";
|
|
193
|
+
let keep = labels.length > 2 && tld.length === 2 && SECOND_LEVEL_LABELS.has(second) ? 3 : 2;
|
|
194
|
+
return labels.slice(-keep).join(".");
|
|
195
|
+
}
|
|
196
|
+
/** The names of the bits set in `value`, comma-separated. */
|
|
197
|
+
function bitLabels(value, bits) {
|
|
198
|
+
return bits
|
|
199
|
+
.filter(([bit]) => (value & bit) !== 0)
|
|
200
|
+
.map(([, label]) => label)
|
|
201
|
+
.join(", ");
|
|
202
|
+
}
|
package/dist/memory.d.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scriptable check for testing code that uses a spam filter: a test queues the signals or
|
|
3
|
+
* failure the next call answers and asserts on the submissions and reports it received, with no
|
|
4
|
+
* provider involved.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { Result } from "@sdxc/result";
|
|
10
|
+
import type { Label, Signal, SpamCheck, Submission } from "./check.js";
|
|
11
|
+
import { SpamCheckError } from "./check.js";
|
|
12
|
+
/**
|
|
13
|
+
* Answers each call with the next queued outcome, or with its default signals once the queue is
|
|
14
|
+
* empty. Every call and report is recorded in order.
|
|
15
|
+
*
|
|
16
|
+
* @example let check = new MemoryCheck().failNext("unavailable");
|
|
17
|
+
*/
|
|
18
|
+
export declare class MemoryCheck implements SpamCheck {
|
|
19
|
+
#private;
|
|
20
|
+
readonly name: string;
|
|
21
|
+
readonly stage: SpamCheck.Stage;
|
|
22
|
+
/** Every submission checked, oldest first. */
|
|
23
|
+
readonly calls: Submission[];
|
|
24
|
+
/** Every report received, oldest first. */
|
|
25
|
+
readonly reports: MemoryCheck.Report[];
|
|
26
|
+
/** @param options - The check's name, stage and default signals */
|
|
27
|
+
constructor(options?: MemoryCheck.Options);
|
|
28
|
+
/** The most recent submission checked, or `undefined` before the first call. */
|
|
29
|
+
get last(): Submission | undefined;
|
|
30
|
+
/**
|
|
31
|
+
* Queues the signals the next call answers.
|
|
32
|
+
*
|
|
33
|
+
* @returns This check, so calls chain
|
|
34
|
+
*/
|
|
35
|
+
signalNext(signals: Signal[]): this;
|
|
36
|
+
/**
|
|
37
|
+
* Queues a failure for the next call.
|
|
38
|
+
*
|
|
39
|
+
* @returns This check, so calls chain
|
|
40
|
+
*/
|
|
41
|
+
failNext(code: SpamCheck.ErrorCode, message?: string): this;
|
|
42
|
+
/**
|
|
43
|
+
* Makes every later report fail with `code` until {@link reset}.
|
|
44
|
+
*
|
|
45
|
+
* @returns This check, so calls chain
|
|
46
|
+
*/
|
|
47
|
+
failReports(code: SpamCheck.ErrorCode): this;
|
|
48
|
+
/** Forgets calls, reports, queued outcomes and report failures, keeping the default signals. */
|
|
49
|
+
reset(): void;
|
|
50
|
+
/** Records the call and answers the next queued outcome, else the default signals. */
|
|
51
|
+
check(submission: Submission): Promise<Result<Signal[], SpamCheckError>>;
|
|
52
|
+
/** Records the report, failing when {@link failReports} asked it to. */
|
|
53
|
+
report(submission: Submission, label: Label): Promise<Result<void, SpamCheckError>>;
|
|
54
|
+
}
|
|
55
|
+
/** The types a {@link MemoryCheck} takes and records. */
|
|
56
|
+
export declare namespace MemoryCheck {
|
|
57
|
+
/** How a scripted check is built. */
|
|
58
|
+
interface Options {
|
|
59
|
+
/** @default "memory" */
|
|
60
|
+
name?: string;
|
|
61
|
+
/** @default "remote" */
|
|
62
|
+
stage?: SpamCheck.Stage;
|
|
63
|
+
/** What a call answers when nothing is queued. @default [] */
|
|
64
|
+
signals?: Signal[];
|
|
65
|
+
}
|
|
66
|
+
/** One recorded moderator decision. */
|
|
67
|
+
interface Report {
|
|
68
|
+
submission: Submission;
|
|
69
|
+
label: Label;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/dist/memory.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { failure, success } from "@sdxc/result";
|
|
2
|
+
import { SpamCheckError } from "./check.js";
|
|
3
|
+
/**
|
|
4
|
+
* Answers each call with the next queued outcome, or with its default signals once the queue is
|
|
5
|
+
* empty. Every call and report is recorded in order.
|
|
6
|
+
*
|
|
7
|
+
* @example let check = new MemoryCheck().failNext("unavailable");
|
|
8
|
+
*/
|
|
9
|
+
export class MemoryCheck {
|
|
10
|
+
name;
|
|
11
|
+
stage;
|
|
12
|
+
/** Every submission checked, oldest first. */
|
|
13
|
+
calls = [];
|
|
14
|
+
/** Every report received, oldest first. */
|
|
15
|
+
reports = [];
|
|
16
|
+
#signals;
|
|
17
|
+
#queue = [];
|
|
18
|
+
#reportFailure = null;
|
|
19
|
+
/** @param options - The check's name, stage and default signals */
|
|
20
|
+
constructor(options = {}) {
|
|
21
|
+
this.name = options.name ?? "memory";
|
|
22
|
+
this.stage = options.stage ?? "remote";
|
|
23
|
+
this.#signals = options.signals ?? [];
|
|
24
|
+
}
|
|
25
|
+
/** The most recent submission checked, or `undefined` before the first call. */
|
|
26
|
+
get last() {
|
|
27
|
+
return this.calls.at(-1);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Queues the signals the next call answers.
|
|
31
|
+
*
|
|
32
|
+
* @returns This check, so calls chain
|
|
33
|
+
*/
|
|
34
|
+
signalNext(signals) {
|
|
35
|
+
this.#queue.push(success(signals));
|
|
36
|
+
return this;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Queues a failure for the next call.
|
|
40
|
+
*
|
|
41
|
+
* @returns This check, so calls chain
|
|
42
|
+
*/
|
|
43
|
+
failNext(code, message) {
|
|
44
|
+
this.#queue.push(failure(new SpamCheckError(code, message)));
|
|
45
|
+
return this;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Makes every later report fail with `code` until {@link reset}.
|
|
49
|
+
*
|
|
50
|
+
* @returns This check, so calls chain
|
|
51
|
+
*/
|
|
52
|
+
failReports(code) {
|
|
53
|
+
this.#reportFailure = new SpamCheckError(code);
|
|
54
|
+
return this;
|
|
55
|
+
}
|
|
56
|
+
/** Forgets calls, reports, queued outcomes and report failures, keeping the default signals. */
|
|
57
|
+
reset() {
|
|
58
|
+
this.calls.length = 0;
|
|
59
|
+
this.reports.length = 0;
|
|
60
|
+
this.#queue = [];
|
|
61
|
+
this.#reportFailure = null;
|
|
62
|
+
}
|
|
63
|
+
/** Records the call and answers the next queued outcome, else the default signals. */
|
|
64
|
+
async check(submission) {
|
|
65
|
+
this.calls.push(submission);
|
|
66
|
+
return this.#queue.shift() ?? success(this.#signals);
|
|
67
|
+
}
|
|
68
|
+
/** Records the report, failing when {@link failReports} asked it to. */
|
|
69
|
+
async report(submission, label) {
|
|
70
|
+
this.reports.push({ submission, label });
|
|
71
|
+
return this.#reportFailure === null ? success(undefined) : failure(this.#reportFailure);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The author-name rule: a person's name is a few words, while spam uses the name field as one
|
|
3
|
+
* more place to put a domain or a string of search keywords.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { SpamCheck } from "../check.js";
|
|
9
|
+
/**
|
|
10
|
+
* Scores an author name containing a URL or domain, and a name longer than `maxWords` words or
|
|
11
|
+
* `maxLength` characters.
|
|
12
|
+
*
|
|
13
|
+
* @example authorName({ maxWords: 8 })
|
|
14
|
+
*/
|
|
15
|
+
export declare function authorName(options?: authorName.Options): SpamCheck;
|
|
16
|
+
/** The options {@link authorName} takes. */
|
|
17
|
+
export declare namespace authorName {
|
|
18
|
+
/** Limits and weights for the author-name rule. */
|
|
19
|
+
interface Options {
|
|
20
|
+
/** @default 5 */
|
|
21
|
+
domainScore?: number;
|
|
22
|
+
/** @default 2 */
|
|
23
|
+
lengthScore?: number;
|
|
24
|
+
/** @default 5 */
|
|
25
|
+
maxWords?: number;
|
|
26
|
+
/** @default 50 */
|
|
27
|
+
maxLength?: number;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** A URL or a bare domain such as `casino.example`. */
|
|
2
|
+
const DOMAIN = /https?:\/\/|\b[a-z0-9-]+\.(?:[a-z]{2,24})\b/i;
|
|
3
|
+
/**
|
|
4
|
+
* Scores an author name containing a URL or domain, and a name longer than `maxWords` words or
|
|
5
|
+
* `maxLength` characters.
|
|
6
|
+
*
|
|
7
|
+
* @example authorName({ maxWords: 8 })
|
|
8
|
+
*/
|
|
9
|
+
export function authorName(options = {}) {
|
|
10
|
+
let domainScore = options.domainScore ?? 5;
|
|
11
|
+
let lengthScore = options.lengthScore ?? 2;
|
|
12
|
+
let maxWords = options.maxWords ?? 5;
|
|
13
|
+
let maxLength = options.maxLength ?? 50;
|
|
14
|
+
return {
|
|
15
|
+
name: "author-name",
|
|
16
|
+
stage: "local",
|
|
17
|
+
check(submission) {
|
|
18
|
+
let name = submission.author?.name?.trim();
|
|
19
|
+
if (!name)
|
|
20
|
+
return [];
|
|
21
|
+
let signals = [];
|
|
22
|
+
if (DOMAIN.test(name)) {
|
|
23
|
+
signals.push({
|
|
24
|
+
check: "author-name.domain",
|
|
25
|
+
score: domainScore,
|
|
26
|
+
detail: "a domain in the name",
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
if (name.split(/\s+/).length > maxWords || name.length > maxLength) {
|
|
30
|
+
signals.push({
|
|
31
|
+
check: "author-name.length",
|
|
32
|
+
score: lengthScore,
|
|
33
|
+
detail: "a name longer than a person's",
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return signals;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contact-bait rule: scams move the conversation off the site, so they post a messaging
|
|
3
|
+
* handle, a phone number or a wallet address to pay into. A phone number alone scores low, since
|
|
4
|
+
* support requests carry them too.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { SpamCheck } from "../check.js";
|
|
10
|
+
/**
|
|
11
|
+
* Scores a messaging-app handle, an international phone number and a cryptocurrency wallet, each
|
|
12
|
+
* once.
|
|
13
|
+
*
|
|
14
|
+
* @example contactBait({ phoneScore: 0 }) // for a form that asks for a phone number
|
|
15
|
+
*/
|
|
16
|
+
export declare function contactBait(options?: contactBait.Options): SpamCheck;
|
|
17
|
+
/** The options {@link contactBait} takes. */
|
|
18
|
+
export declare namespace contactBait {
|
|
19
|
+
/** Weights for the contact-bait rule. */
|
|
20
|
+
interface Options {
|
|
21
|
+
/** @default 4 */
|
|
22
|
+
messagingScore?: number;
|
|
23
|
+
/** @default 2 */
|
|
24
|
+
phoneScore?: number;
|
|
25
|
+
/** @default 4 */
|
|
26
|
+
walletScore?: number;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** A Telegram or WhatsApp link, or either app named next to a handle or number. */
|
|
2
|
+
const MESSAGING = /\b(?:t\.me|wa\.me|telegram\.me)\/\w|\b(?:telegram|whatsapp|signal|wechat|viber)\b[^.\n]{0,20}?(?:@\w{3,}|\+?\d[\d\s-]{6,})/i;
|
|
3
|
+
/** An international phone number: a `+`, a country code and at least eight more digits. */
|
|
4
|
+
const PHONE = /\+\d{1,3}[\s.-]?(?:\(?\d{1,4}\)?[\s.-]?){2,5}\d{2,4}\b/;
|
|
5
|
+
/** Ethereum-style, Bitcoin bech32 and Tron addresses. */
|
|
6
|
+
const WALLET = /\b0x[a-fA-F0-9]{40}\b|\bbc1[a-z0-9]{25,59}\b|\bT[1-9A-HJ-NP-Za-km-z]{33}\b/;
|
|
7
|
+
/**
|
|
8
|
+
* Scores a messaging-app handle, an international phone number and a cryptocurrency wallet, each
|
|
9
|
+
* once.
|
|
10
|
+
*
|
|
11
|
+
* @example contactBait({ phoneScore: 0 }) // for a form that asks for a phone number
|
|
12
|
+
*/
|
|
13
|
+
export function contactBait(options = {}) {
|
|
14
|
+
let messagingScore = options.messagingScore ?? 4;
|
|
15
|
+
let phoneScore = options.phoneScore ?? 2;
|
|
16
|
+
let walletScore = options.walletScore ?? 4;
|
|
17
|
+
return {
|
|
18
|
+
name: "contact-bait",
|
|
19
|
+
stage: "local",
|
|
20
|
+
check(submission) {
|
|
21
|
+
let content = submission.content;
|
|
22
|
+
let signals = [];
|
|
23
|
+
if (MESSAGING.test(content)) {
|
|
24
|
+
signals.push({
|
|
25
|
+
check: "contact-bait.messaging",
|
|
26
|
+
score: messagingScore,
|
|
27
|
+
detail: "a messaging-app handle",
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
if (PHONE.test(content)) {
|
|
31
|
+
signals.push({ check: "contact-bait.phone", score: phoneScore, detail: "a phone number" });
|
|
32
|
+
}
|
|
33
|
+
if (WALLET.test(content)) {
|
|
34
|
+
signals.push({
|
|
35
|
+
check: "contact-bait.wallet",
|
|
36
|
+
score: walletScore,
|
|
37
|
+
detail: "a cryptocurrency wallet address",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
return signals;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The default rule set: every local rule at its default weights, tuned together against the
|
|
3
|
+
* labelled corpus so a submission needs several kinds of evidence to reach the spam threshold.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { SpamCheck } from "../check.js";
|
|
9
|
+
import { authorName } from "./author-name.js";
|
|
10
|
+
import { contactBait } from "./contact-bait.js";
|
|
11
|
+
import { language } from "./language.js";
|
|
12
|
+
import { linkSyntax } from "./link-syntax.js";
|
|
13
|
+
import { linkTargets } from "./link-targets.js";
|
|
14
|
+
import { links } from "./links.js";
|
|
15
|
+
import { shouting } from "./shouting.js";
|
|
16
|
+
import { timing } from "./timing.js";
|
|
17
|
+
import { unicode } from "./unicode.js";
|
|
18
|
+
/**
|
|
19
|
+
* Every built-in local rule with its default options. To reweight one, build the list from the
|
|
20
|
+
* rule functions instead: `[links({ freeLinks: 3 }), unicode(), …]`.
|
|
21
|
+
*/
|
|
22
|
+
export declare const DEFAULT_RULES: readonly SpamCheck[];
|
|
23
|
+
export { authorName, contactBait, language, links, linkSyntax, linkTargets, shouting, timing, unicode, };
|
|
24
|
+
export { ABUSED_TLDS, URL_SHORTENERS } from "./link-targets.js";
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { authorName } from "./author-name.js";
|
|
2
|
+
import { contactBait } from "./contact-bait.js";
|
|
3
|
+
import { language } from "./language.js";
|
|
4
|
+
import { linkSyntax } from "./link-syntax.js";
|
|
5
|
+
import { linkTargets } from "./link-targets.js";
|
|
6
|
+
import { links } from "./links.js";
|
|
7
|
+
import { shouting } from "./shouting.js";
|
|
8
|
+
import { timing } from "./timing.js";
|
|
9
|
+
import { unicode } from "./unicode.js";
|
|
10
|
+
/**
|
|
11
|
+
* Every built-in local rule with its default options. To reweight one, build the list from the
|
|
12
|
+
* rule functions instead: `[links({ freeLinks: 3 }), unicode(), …]`.
|
|
13
|
+
*/
|
|
14
|
+
export const DEFAULT_RULES = [
|
|
15
|
+
timing(),
|
|
16
|
+
links(),
|
|
17
|
+
linkSyntax(),
|
|
18
|
+
linkTargets(),
|
|
19
|
+
unicode(),
|
|
20
|
+
contactBait(),
|
|
21
|
+
shouting(),
|
|
22
|
+
language(),
|
|
23
|
+
authorName(),
|
|
24
|
+
];
|
|
25
|
+
export { authorName, contactBait, language, links, linkSyntax, linkTargets, shouting, timing, unicode, };
|
|
26
|
+
export { ABUSED_TLDS, URL_SHORTENERS } from "./link-targets.js";
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language rule: a site written in one language rarely receives genuine comments in a
|
|
3
|
+
* writing system none of its languages use, and SEO spam is often posted everywhere unchanged.
|
|
4
|
+
* It compares scripts rather than detecting languages, which needs no model or word list.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { SpamCheck } from "../check.js";
|
|
10
|
+
/**
|
|
11
|
+
* Scores content whose letters are mostly (over `ratio` of at least `minLetters`) in scripts none
|
|
12
|
+
* of {@link Submission.languages} is written in. A submission without `languages`, or with a tag
|
|
13
|
+
* whose script is unknown, draws nothing.
|
|
14
|
+
*
|
|
15
|
+
* @example language({ ratio: 0.8 })
|
|
16
|
+
*/
|
|
17
|
+
export declare function language(options?: language.Options): SpamCheck;
|
|
18
|
+
/** The options {@link language} takes. */
|
|
19
|
+
export declare namespace language {
|
|
20
|
+
/** Limits and weight for the language rule. */
|
|
21
|
+
interface Options {
|
|
22
|
+
/** @default 0.5 */
|
|
23
|
+
ratio?: number;
|
|
24
|
+
/** @default 20 */
|
|
25
|
+
minLetters?: number;
|
|
26
|
+
/** @default 5 */
|
|
27
|
+
score?: number;
|
|
28
|
+
}
|
|
29
|
+
}
|