@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,84 @@
|
|
|
1
|
+
/** The Unicode scripts each ISO 15924 code `Intl.Locale#maximize` reports is written in. */
|
|
2
|
+
const SCRIPTS_BY_CODE = {
|
|
3
|
+
Latn: ["Latin"],
|
|
4
|
+
Cyrl: ["Cyrillic"],
|
|
5
|
+
Grek: ["Greek"],
|
|
6
|
+
Arab: ["Arabic"],
|
|
7
|
+
Hebr: ["Hebrew"],
|
|
8
|
+
Hans: ["Han"],
|
|
9
|
+
Hant: ["Han"],
|
|
10
|
+
Jpan: ["Han", "Hiragana", "Katakana"],
|
|
11
|
+
Kore: ["Hangul", "Han"],
|
|
12
|
+
Thai: ["Thai"],
|
|
13
|
+
Deva: ["Devanagari"],
|
|
14
|
+
Beng: ["Bengali"],
|
|
15
|
+
Taml: ["Tamil"],
|
|
16
|
+
Armn: ["Armenian"],
|
|
17
|
+
Geor: ["Georgian"],
|
|
18
|
+
Ethi: ["Ethiopic"],
|
|
19
|
+
};
|
|
20
|
+
/** Every script this rule can attribute letters to, each with the pattern matching its letters. */
|
|
21
|
+
const SCRIPT_PATTERNS = [
|
|
22
|
+
...new Set(Object.values(SCRIPTS_BY_CODE).flat()),
|
|
23
|
+
].map((script) => [script, new RegExp(`\\p{Script=${script}}`, "u")]);
|
|
24
|
+
/**
|
|
25
|
+
* Scores content whose letters are mostly (over `ratio` of at least `minLetters`) in scripts none
|
|
26
|
+
* of {@link Submission.languages} is written in. A submission without `languages`, or with a tag
|
|
27
|
+
* whose script is unknown, draws nothing.
|
|
28
|
+
*
|
|
29
|
+
* @example language({ ratio: 0.8 })
|
|
30
|
+
*/
|
|
31
|
+
export function language(options = {}) {
|
|
32
|
+
let ratio = options.ratio ?? 0.5;
|
|
33
|
+
let minLetters = options.minLetters ?? 20;
|
|
34
|
+
let score = options.score ?? 5;
|
|
35
|
+
return {
|
|
36
|
+
name: "language",
|
|
37
|
+
stage: "local",
|
|
38
|
+
check(submission) {
|
|
39
|
+
let expected = expectedScripts(submission.languages ?? []);
|
|
40
|
+
if (expected === null)
|
|
41
|
+
return [];
|
|
42
|
+
let letters = 0;
|
|
43
|
+
let foreign = 0;
|
|
44
|
+
for (let character of submission.content) {
|
|
45
|
+
if (!/\p{L}/u.test(character))
|
|
46
|
+
continue;
|
|
47
|
+
letters++;
|
|
48
|
+
let script = SCRIPT_PATTERNS.find(([, pattern]) => pattern.test(character))?.[0];
|
|
49
|
+
if (script !== undefined && !expected.has(script))
|
|
50
|
+
foreign++;
|
|
51
|
+
}
|
|
52
|
+
if (letters < minLetters || foreign / letters <= ratio)
|
|
53
|
+
return [];
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
check: "language.script",
|
|
57
|
+
score,
|
|
58
|
+
detail: `${Math.round((foreign / letters) * 100)}% of letters in a script the site does not use`,
|
|
59
|
+
},
|
|
60
|
+
];
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
/** The scripts `tags` are written in, or `null` when there are none or one is unknown. */
|
|
65
|
+
function expectedScripts(tags) {
|
|
66
|
+
if (tags.length === 0)
|
|
67
|
+
return null;
|
|
68
|
+
let scripts = new Set();
|
|
69
|
+
for (let tag of tags) {
|
|
70
|
+
let code;
|
|
71
|
+
try {
|
|
72
|
+
code = new Intl.Locale(tag).maximize().script;
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
let known = code === undefined ? undefined : SCRIPTS_BY_CODE[code];
|
|
78
|
+
if (known === undefined)
|
|
79
|
+
return null;
|
|
80
|
+
for (let script of known)
|
|
81
|
+
scripts.add(script);
|
|
82
|
+
}
|
|
83
|
+
return scripts;
|
|
84
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link-syntax rule: markup a field does not render. BBCode belongs to forum software few
|
|
3
|
+
* sites run, and HTML or Markdown pasted into a plain-text field shows up as literal markup, so
|
|
4
|
+
* posting it there is what a bot aiming at every kind of form does.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { SpamCheck } from "../check.js";
|
|
10
|
+
/**
|
|
11
|
+
* Scores BBCode links in any format, HTML anchors outside `html`, and Markdown links in `text`.
|
|
12
|
+
* Each kind scores once however many links use it.
|
|
13
|
+
*
|
|
14
|
+
* @example linkSyntax({ markdownScore: 0 }) // for a text field whose readers write Markdown
|
|
15
|
+
*/
|
|
16
|
+
export declare function linkSyntax(options?: linkSyntax.Options): SpamCheck;
|
|
17
|
+
/** The options {@link linkSyntax} takes. */
|
|
18
|
+
export declare namespace linkSyntax {
|
|
19
|
+
/** Weights for the link-syntax rule. */
|
|
20
|
+
interface Options {
|
|
21
|
+
/** @default 8 */
|
|
22
|
+
bbcodeScore?: number;
|
|
23
|
+
/** @default 8 */
|
|
24
|
+
htmlScore?: number;
|
|
25
|
+
/** @default 3 */
|
|
26
|
+
markdownScore?: number;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** A BBCode link, `[url=…]` or `[url]…[/url]`. */
|
|
2
|
+
const BBCODE_LINK = /\[url(?:=[^\]]*)?\]/i;
|
|
3
|
+
/** An HTML anchor with an `href`. */
|
|
4
|
+
const HTML_LINK = /<a\s[^>]*href\s*=/i;
|
|
5
|
+
/** A Markdown inline link to an absolute URL. */
|
|
6
|
+
const MARKDOWN_LINK = /\[[^\]]+\]\(\s*https?:\/\//i;
|
|
7
|
+
/**
|
|
8
|
+
* Scores BBCode links in any format, HTML anchors outside `html`, and Markdown links in `text`.
|
|
9
|
+
* Each kind scores once however many links use it.
|
|
10
|
+
*
|
|
11
|
+
* @example linkSyntax({ markdownScore: 0 }) // for a text field whose readers write Markdown
|
|
12
|
+
*/
|
|
13
|
+
export function linkSyntax(options = {}) {
|
|
14
|
+
let bbcodeScore = options.bbcodeScore ?? 8;
|
|
15
|
+
let htmlScore = options.htmlScore ?? 8;
|
|
16
|
+
let markdownScore = options.markdownScore ?? 3;
|
|
17
|
+
return {
|
|
18
|
+
name: "link-syntax",
|
|
19
|
+
stage: "local",
|
|
20
|
+
check(submission) {
|
|
21
|
+
let format = submission.format ?? "text";
|
|
22
|
+
let content = submission.content;
|
|
23
|
+
let signals = [];
|
|
24
|
+
if (BBCODE_LINK.test(content)) {
|
|
25
|
+
signals.push({
|
|
26
|
+
check: "link-syntax.bbcode",
|
|
27
|
+
score: bbcodeScore,
|
|
28
|
+
detail: "BBCode link markup",
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
if (format !== "html" && HTML_LINK.test(content)) {
|
|
32
|
+
signals.push({
|
|
33
|
+
check: "link-syntax.html",
|
|
34
|
+
score: htmlScore,
|
|
35
|
+
detail: `HTML anchors in a ${format} field`,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
if (format === "text" && MARKDOWN_LINK.test(content)) {
|
|
39
|
+
signals.push({
|
|
40
|
+
check: "link-syntax.markdown",
|
|
41
|
+
score: markdownScore,
|
|
42
|
+
detail: "Markdown links in a text field",
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
return signals;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link-target rule: where links point. Shorteners hide the destination, raw IP addresses skip
|
|
3
|
+
* the domain a blocklist would catch, punycode hosts can imitate a brand, and a few top-level
|
|
4
|
+
* domains carry a disproportionate share of abuse.
|
|
5
|
+
*
|
|
6
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
7
|
+
* @copyright Sergio Xalambrí 2026
|
|
8
|
+
*/
|
|
9
|
+
import type { SpamCheck } from "../check.js";
|
|
10
|
+
/** Hosts of widely used URL shorteners. */
|
|
11
|
+
export declare const URL_SHORTENERS: readonly string[];
|
|
12
|
+
/** Top-level domains that abuse reports rank far above their share of registrations. */
|
|
13
|
+
export declare const ABUSED_TLDS: readonly string[];
|
|
14
|
+
/**
|
|
15
|
+
* Scores every link in the content and the author's URL by its host: a shortener, an IPv4 or
|
|
16
|
+
* IPv6 literal, a punycode label, or an abused top-level domain. The total is capped at
|
|
17
|
+
* `maxScore`, so a long list of shortened links cannot outweigh every other rule.
|
|
18
|
+
*
|
|
19
|
+
* @example linkTargets({ shorteners: [...URL_SHORTENERS, "go.example"] })
|
|
20
|
+
*/
|
|
21
|
+
export declare function linkTargets(options?: linkTargets.Options): SpamCheck;
|
|
22
|
+
/** The options {@link linkTargets} takes. */
|
|
23
|
+
export declare namespace linkTargets {
|
|
24
|
+
/** Lists and weights for the link-target rule. */
|
|
25
|
+
interface Options {
|
|
26
|
+
/** Replaces {@link URL_SHORTENERS}. */
|
|
27
|
+
shorteners?: readonly string[];
|
|
28
|
+
/** Replaces {@link ABUSED_TLDS}. */
|
|
29
|
+
abusedTlds?: readonly string[];
|
|
30
|
+
/** @default 3 */
|
|
31
|
+
shortenerScore?: number;
|
|
32
|
+
/** @default 5 */
|
|
33
|
+
ipScore?: number;
|
|
34
|
+
/** @default 2 */
|
|
35
|
+
punycodeScore?: number;
|
|
36
|
+
/** @default 1 */
|
|
37
|
+
tldScore?: number;
|
|
38
|
+
/** @default 12 */
|
|
39
|
+
maxScore?: number;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { extractLinks } from "../lib/text.js";
|
|
2
|
+
/** Hosts of widely used URL shorteners. */
|
|
3
|
+
export const URL_SHORTENERS = [
|
|
4
|
+
"bit.ly",
|
|
5
|
+
"bitly.com",
|
|
6
|
+
"buff.ly",
|
|
7
|
+
"cutt.ly",
|
|
8
|
+
"goo.gl",
|
|
9
|
+
"is.gd",
|
|
10
|
+
"ow.ly",
|
|
11
|
+
"rb.gy",
|
|
12
|
+
"rebrand.ly",
|
|
13
|
+
"s.id",
|
|
14
|
+
"shorturl.at",
|
|
15
|
+
"t.co",
|
|
16
|
+
"t.ly",
|
|
17
|
+
"tiny.cc",
|
|
18
|
+
"tinyurl.com",
|
|
19
|
+
"v.gd",
|
|
20
|
+
];
|
|
21
|
+
/** Top-level domains that abuse reports rank far above their share of registrations. */
|
|
22
|
+
export const ABUSED_TLDS = [
|
|
23
|
+
"bond",
|
|
24
|
+
"cfd",
|
|
25
|
+
"click",
|
|
26
|
+
"cyou",
|
|
27
|
+
"icu",
|
|
28
|
+
"lol",
|
|
29
|
+
"monster",
|
|
30
|
+
"quest",
|
|
31
|
+
"rest",
|
|
32
|
+
"sbs",
|
|
33
|
+
"top",
|
|
34
|
+
"xyz",
|
|
35
|
+
"zip",
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Scores every link in the content and the author's URL by its host: a shortener, an IPv4 or
|
|
39
|
+
* IPv6 literal, a punycode label, or an abused top-level domain. The total is capped at
|
|
40
|
+
* `maxScore`, so a long list of shortened links cannot outweigh every other rule.
|
|
41
|
+
*
|
|
42
|
+
* @example linkTargets({ shorteners: [...URL_SHORTENERS, "go.example"] })
|
|
43
|
+
*/
|
|
44
|
+
export function linkTargets(options = {}) {
|
|
45
|
+
let shorteners = new Set(options.shorteners ?? URL_SHORTENERS);
|
|
46
|
+
let abusedTlds = new Set(options.abusedTlds ?? ABUSED_TLDS);
|
|
47
|
+
let shortenerScore = options.shortenerScore ?? 3;
|
|
48
|
+
let ipScore = options.ipScore ?? 5;
|
|
49
|
+
let punycodeScore = options.punycodeScore ?? 2;
|
|
50
|
+
let tldScore = options.tldScore ?? 1;
|
|
51
|
+
let maxScore = options.maxScore ?? 12;
|
|
52
|
+
return {
|
|
53
|
+
name: "link-targets",
|
|
54
|
+
stage: "local",
|
|
55
|
+
check(submission) {
|
|
56
|
+
let authorUrl = submission.author?.url ?? "";
|
|
57
|
+
let urls = extractLinks(`${submission.content} ${authorUrl}`);
|
|
58
|
+
let signals = [];
|
|
59
|
+
let total = 0;
|
|
60
|
+
let add = (check, score, host) => {
|
|
61
|
+
let capped = Math.min(score, maxScore - total);
|
|
62
|
+
if (capped <= 0)
|
|
63
|
+
return;
|
|
64
|
+
total += capped;
|
|
65
|
+
signals.push({ check: `link-targets.${check}`, score: capped, detail: host });
|
|
66
|
+
};
|
|
67
|
+
for (let url of urls) {
|
|
68
|
+
let host = url.hostname.toLowerCase();
|
|
69
|
+
if (shorteners.has(host))
|
|
70
|
+
add("shortener", shortenerScore, host);
|
|
71
|
+
else if (isIpLiteral(host))
|
|
72
|
+
add("ip-address", ipScore, host);
|
|
73
|
+
else if (host.split(".").some((label) => label.startsWith("xn--"))) {
|
|
74
|
+
add("punycode", punycodeScore, host);
|
|
75
|
+
}
|
|
76
|
+
else if (abusedTlds.has(host.slice(host.lastIndexOf(".") + 1))) {
|
|
77
|
+
add("abused-tld", tldScore, host);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return signals;
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Whether a URL's `hostname` is an IP address rather than a name. */
|
|
85
|
+
function isIpLiteral(host) {
|
|
86
|
+
return host.startsWith("[") || /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host);
|
|
87
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The link-count rule: spam exists to place links, so each link past the first adds weight, and a
|
|
3
|
+
* text that is mostly links adds more. A person citing one source draws nothing.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { SpamCheck } from "../check.js";
|
|
9
|
+
/**
|
|
10
|
+
* Scores the distinct links in the content: `perExtraLink` for each one past `freeLinks`, capped
|
|
11
|
+
* at `maxCountScore`, and `densityScore` once when there is at least one link for every
|
|
12
|
+
* `wordsPerLink` words and more than one link.
|
|
13
|
+
*
|
|
14
|
+
* @example links({ freeLinks: 3 })
|
|
15
|
+
*/
|
|
16
|
+
export declare function links(options?: links.Options): SpamCheck;
|
|
17
|
+
/** The options {@link links} takes. */
|
|
18
|
+
export declare namespace links {
|
|
19
|
+
/** Limits and weights for the link-count rule. */
|
|
20
|
+
interface Options {
|
|
21
|
+
/** Links a submission may carry before any scores. @default 1 */
|
|
22
|
+
freeLinks?: number;
|
|
23
|
+
/** @default 2 */
|
|
24
|
+
perExtraLink?: number;
|
|
25
|
+
/** @default 10 */
|
|
26
|
+
maxCountScore?: number;
|
|
27
|
+
/** The fewest words per link that count as prose rather than a link list. @default 4 */
|
|
28
|
+
wordsPerLink?: number;
|
|
29
|
+
/** @default 3 */
|
|
30
|
+
densityScore?: number;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { extractLinks, extractWords } from "../lib/text.js";
|
|
2
|
+
/**
|
|
3
|
+
* Scores the distinct links in the content: `perExtraLink` for each one past `freeLinks`, capped
|
|
4
|
+
* at `maxCountScore`, and `densityScore` once when there is at least one link for every
|
|
5
|
+
* `wordsPerLink` words and more than one link.
|
|
6
|
+
*
|
|
7
|
+
* @example links({ freeLinks: 3 })
|
|
8
|
+
*/
|
|
9
|
+
export function links(options = {}) {
|
|
10
|
+
let freeLinks = options.freeLinks ?? 1;
|
|
11
|
+
let perExtraLink = options.perExtraLink ?? 2;
|
|
12
|
+
let maxCountScore = options.maxCountScore ?? 10;
|
|
13
|
+
let wordsPerLink = options.wordsPerLink ?? 4;
|
|
14
|
+
let densityScore = options.densityScore ?? 3;
|
|
15
|
+
return {
|
|
16
|
+
name: "links",
|
|
17
|
+
stage: "local",
|
|
18
|
+
check(submission) {
|
|
19
|
+
let count = extractLinks(submission.content).length;
|
|
20
|
+
let signals = [];
|
|
21
|
+
if (count > freeLinks) {
|
|
22
|
+
signals.push({
|
|
23
|
+
check: "links.count",
|
|
24
|
+
score: Math.min((count - freeLinks) * perExtraLink, maxCountScore),
|
|
25
|
+
detail: `${count} links`,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
let words = extractWords(submission.content).length;
|
|
29
|
+
if (count > 1 && words <= count * wordsPerLink) {
|
|
30
|
+
signals.push({
|
|
31
|
+
check: "links.density",
|
|
32
|
+
score: densityScore,
|
|
33
|
+
detail: `${count} links in ${words} words`,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
return signals;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shouting rule: advertising copy written to grab attention, with text in capitals, runs of
|
|
3
|
+
* one character, a phrase said over and over, and emoji outnumbering words. Each is mild alone.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { SpamCheck } from "../check.js";
|
|
9
|
+
/**
|
|
10
|
+
* Scores capitals (over `capsRatio` of at least 20 cased letters), a run of one character, a
|
|
11
|
+
* three-word phrase appearing three times, and at least 8 emoji outnumbering the words.
|
|
12
|
+
*
|
|
13
|
+
* @example shouting({ capsScore: 1 })
|
|
14
|
+
*/
|
|
15
|
+
export declare function shouting(options?: shouting.Options): SpamCheck;
|
|
16
|
+
/** The options {@link shouting} takes. */
|
|
17
|
+
export declare namespace shouting {
|
|
18
|
+
/** Limits and weights for the shouting rule. */
|
|
19
|
+
interface Options {
|
|
20
|
+
/** @default 0.7 */
|
|
21
|
+
capsRatio?: number;
|
|
22
|
+
/** @default 2 */
|
|
23
|
+
capsScore?: number;
|
|
24
|
+
/** @default 1 */
|
|
25
|
+
runScore?: number;
|
|
26
|
+
/** @default 2 */
|
|
27
|
+
repetitionScore?: number;
|
|
28
|
+
/** @default 2 */
|
|
29
|
+
emojiScore?: number;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { extractWords } from "../lib/text.js";
|
|
2
|
+
/** The same non-space character six or more times in a row. */
|
|
3
|
+
const CHARACTER_RUN = /(\S)\1{5,}/u;
|
|
4
|
+
/** One emoji, counting a joined sequence as each of its parts. */
|
|
5
|
+
const EMOJI = /\p{Extended_Pictographic}/gu;
|
|
6
|
+
/**
|
|
7
|
+
* Scores capitals (over `capsRatio` of at least 20 cased letters), a run of one character, a
|
|
8
|
+
* three-word phrase appearing three times, and at least 8 emoji outnumbering the words.
|
|
9
|
+
*
|
|
10
|
+
* @example shouting({ capsScore: 1 })
|
|
11
|
+
*/
|
|
12
|
+
export function shouting(options = {}) {
|
|
13
|
+
let capsRatio = options.capsRatio ?? 0.7;
|
|
14
|
+
let capsScore = options.capsScore ?? 2;
|
|
15
|
+
let runScore = options.runScore ?? 1;
|
|
16
|
+
let repetitionScore = options.repetitionScore ?? 2;
|
|
17
|
+
let emojiScore = options.emojiScore ?? 2;
|
|
18
|
+
return {
|
|
19
|
+
name: "shouting",
|
|
20
|
+
stage: "local",
|
|
21
|
+
check(submission) {
|
|
22
|
+
let content = submission.content;
|
|
23
|
+
let signals = [];
|
|
24
|
+
let upper = content.match(/\p{Lu}/gu)?.length ?? 0;
|
|
25
|
+
let lower = content.match(/\p{Ll}/gu)?.length ?? 0;
|
|
26
|
+
if (upper + lower >= 20 && upper / (upper + lower) > capsRatio) {
|
|
27
|
+
signals.push({ check: "shouting.caps", score: capsScore, detail: "written in capitals" });
|
|
28
|
+
}
|
|
29
|
+
let run = content.match(CHARACTER_RUN);
|
|
30
|
+
if (run) {
|
|
31
|
+
signals.push({ check: "shouting.run", score: runScore, detail: `a run of "${run[1]}"` });
|
|
32
|
+
}
|
|
33
|
+
let phrase = repeatedPhrase(extractWords(content.toLowerCase()));
|
|
34
|
+
if (phrase !== null) {
|
|
35
|
+
signals.push({
|
|
36
|
+
check: "shouting.repetition",
|
|
37
|
+
score: repetitionScore,
|
|
38
|
+
detail: `"${phrase}" repeated`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
let emoji = content.match(EMOJI)?.length ?? 0;
|
|
42
|
+
if (emoji >= 8 && emoji > extractWords(content).length) {
|
|
43
|
+
signals.push({ check: "shouting.emoji", score: emojiScore, detail: `${emoji} emoji` });
|
|
44
|
+
}
|
|
45
|
+
return signals;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
/** The first three-word phrase that appears three or more times, or `null`. */
|
|
50
|
+
function repeatedPhrase(words) {
|
|
51
|
+
let counts = new Map();
|
|
52
|
+
for (let index = 0; index + 3 <= words.length; index++) {
|
|
53
|
+
let phrase = words.slice(index, index + 3).join(" ");
|
|
54
|
+
let count = (counts.get(phrase) ?? 0) + 1;
|
|
55
|
+
if (count >= 3)
|
|
56
|
+
return phrase;
|
|
57
|
+
counts.set(phrase, count);
|
|
58
|
+
}
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The timing rule: people take seconds to read and type, and bots post the instant a form loads
|
|
3
|
+
* or replay one captured long ago. It needs a render time the visitor cannot forge.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { SpamCheck } from "../check.js";
|
|
9
|
+
/**
|
|
10
|
+
* Scores a submission sent too soon after its form rendered, a render time in the future or long
|
|
11
|
+
* past. A submission without `renderedAt` draws nothing.
|
|
12
|
+
*
|
|
13
|
+
* @example timing({ minSeconds: 5 })
|
|
14
|
+
*/
|
|
15
|
+
export declare function timing(options?: timing.Options): SpamCheck;
|
|
16
|
+
/** The options {@link timing} takes. */
|
|
17
|
+
export declare namespace timing {
|
|
18
|
+
/** Limits and weights for the timing rule. */
|
|
19
|
+
interface Options {
|
|
20
|
+
/** @default 3 */
|
|
21
|
+
minSeconds?: number;
|
|
22
|
+
/** @default 86400 */
|
|
23
|
+
maxAgeSeconds?: number;
|
|
24
|
+
/** Scored for a submission faster than `minSeconds` or rendered in the future. @default 6 */
|
|
25
|
+
fastScore?: number;
|
|
26
|
+
/** Scored for a submission older than `maxAgeSeconds`. @default 2 */
|
|
27
|
+
staleScore?: number;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scores a submission sent too soon after its form rendered, a render time in the future or long
|
|
3
|
+
* past. A submission without `renderedAt` draws nothing.
|
|
4
|
+
*
|
|
5
|
+
* @example timing({ minSeconds: 5 })
|
|
6
|
+
*/
|
|
7
|
+
export function timing(options = {}) {
|
|
8
|
+
let minSeconds = options.minSeconds ?? 3;
|
|
9
|
+
let maxAgeSeconds = options.maxAgeSeconds ?? 86_400;
|
|
10
|
+
let fastScore = options.fastScore ?? 6;
|
|
11
|
+
let staleScore = options.staleScore ?? 2;
|
|
12
|
+
return {
|
|
13
|
+
name: "timing",
|
|
14
|
+
stage: "local",
|
|
15
|
+
check(submission) {
|
|
16
|
+
if (submission.renderedAt === undefined)
|
|
17
|
+
return [];
|
|
18
|
+
let submittedAt = submission.submittedAt ?? new Date();
|
|
19
|
+
let seconds = (submittedAt.getTime() - submission.renderedAt.getTime()) / 1000;
|
|
20
|
+
if (seconds < 0) {
|
|
21
|
+
return [
|
|
22
|
+
{ check: "timing.future", score: fastScore, detail: "rendered after it was submitted" },
|
|
23
|
+
];
|
|
24
|
+
}
|
|
25
|
+
if (seconds < minSeconds) {
|
|
26
|
+
return [
|
|
27
|
+
{
|
|
28
|
+
check: "timing.fast",
|
|
29
|
+
score: fastScore,
|
|
30
|
+
detail: `submitted ${seconds.toFixed(1)}s after the form rendered`,
|
|
31
|
+
},
|
|
32
|
+
];
|
|
33
|
+
}
|
|
34
|
+
if (seconds > maxAgeSeconds) {
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
check: "timing.stale",
|
|
38
|
+
score: staleScore,
|
|
39
|
+
detail: `submitted ${Math.round(seconds / 3600)}h after the form rendered`,
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
return [];
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Unicode obfuscation rule: characters that look like ordinary text to a reader but not to a
|
|
3
|
+
* keyword filter. Spam uses them to slip a brand name or a banned word past naive matching.
|
|
4
|
+
*
|
|
5
|
+
* @author [Sergio Xalambrí](https://sergiodxa.com)
|
|
6
|
+
* @copyright Sergio Xalambrí 2026
|
|
7
|
+
*/
|
|
8
|
+
import type { SpamCheck } from "../check.js";
|
|
9
|
+
/**
|
|
10
|
+
* Scores homoglyph words, invisible separators, styled letters (at least `minStyledLetters` of
|
|
11
|
+
* them) and stacked combining marks. Each kind scores once.
|
|
12
|
+
*
|
|
13
|
+
* @example unicode({ mixedScriptScore: 3 })
|
|
14
|
+
*/
|
|
15
|
+
export declare function unicode(options?: unicode.Options): SpamCheck;
|
|
16
|
+
/** The options {@link unicode} takes. */
|
|
17
|
+
export declare namespace unicode {
|
|
18
|
+
/** Limits and weights for the Unicode obfuscation rule. */
|
|
19
|
+
interface Options {
|
|
20
|
+
/** @default 5 */
|
|
21
|
+
mixedScriptScore?: number;
|
|
22
|
+
/** @default 5 */
|
|
23
|
+
hiddenScore?: number;
|
|
24
|
+
/** @default 5 */
|
|
25
|
+
styledScore?: number;
|
|
26
|
+
/** @default 3 */
|
|
27
|
+
minStyledLetters?: number;
|
|
28
|
+
/** @default 3 */
|
|
29
|
+
markStackScore?: number;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A word mixing Latin letters with Cyrillic or Greek ones, the shape of a homoglyph substitution
|
|
3
|
+
* such as a Cyrillic `а` inside `Paypal`.
|
|
4
|
+
*/
|
|
5
|
+
const MIXED_SCRIPT_WORD = /(?=[\p{L}]*\p{Script=Latin})(?=[\p{L}]*[\p{Script=Cyrillic}\p{Script=Greek}])[\p{L}]+/u;
|
|
6
|
+
/**
|
|
7
|
+
* An invisible character between two letters or digits. Zero-width joiners and non-joiners count
|
|
8
|
+
* only between Latin letters, since emoji sequences and several scripts use them legitimately.
|
|
9
|
+
*/
|
|
10
|
+
const HIDDEN_SEPARATOR = /[\p{L}\p{N}][][\p{L}\p{N}]|\p{Script=Latin}[]\p{Script=Latin}/u;
|
|
11
|
+
/** Mathematical alphanumeric symbols and fullwidth Latin letters, which render as styled text. */
|
|
12
|
+
const STYLED_LETTER = /[\u{1D400}-\u{1D7FF}A-Za-z]/gu;
|
|
13
|
+
/** Five or more combining marks stacked on one character, as "Zalgo" text does. */
|
|
14
|
+
const MARK_STACK = /\p{M}{5,}/u;
|
|
15
|
+
/**
|
|
16
|
+
* Scores homoglyph words, invisible separators, styled letters (at least `minStyledLetters` of
|
|
17
|
+
* them) and stacked combining marks. Each kind scores once.
|
|
18
|
+
*
|
|
19
|
+
* @example unicode({ mixedScriptScore: 3 })
|
|
20
|
+
*/
|
|
21
|
+
export function unicode(options = {}) {
|
|
22
|
+
let mixedScriptScore = options.mixedScriptScore ?? 5;
|
|
23
|
+
let hiddenScore = options.hiddenScore ?? 5;
|
|
24
|
+
let styledScore = options.styledScore ?? 5;
|
|
25
|
+
let minStyledLetters = options.minStyledLetters ?? 3;
|
|
26
|
+
let markStackScore = options.markStackScore ?? 3;
|
|
27
|
+
return {
|
|
28
|
+
name: "unicode",
|
|
29
|
+
stage: "local",
|
|
30
|
+
check(submission) {
|
|
31
|
+
let content = submission.content;
|
|
32
|
+
let signals = [];
|
|
33
|
+
let mixed = content.match(MIXED_SCRIPT_WORD);
|
|
34
|
+
if (mixed) {
|
|
35
|
+
signals.push({
|
|
36
|
+
check: "unicode.mixed-script",
|
|
37
|
+
score: mixedScriptScore,
|
|
38
|
+
detail: `"${mixed[0]}" mixes Latin with Cyrillic or Greek letters`,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (HIDDEN_SEPARATOR.test(content)) {
|
|
42
|
+
signals.push({
|
|
43
|
+
check: "unicode.hidden",
|
|
44
|
+
score: hiddenScore,
|
|
45
|
+
detail: "invisible characters inside words",
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
let styled = content.match(STYLED_LETTER)?.length ?? 0;
|
|
49
|
+
if (styled >= minStyledLetters) {
|
|
50
|
+
signals.push({
|
|
51
|
+
check: "unicode.styled",
|
|
52
|
+
score: styledScore,
|
|
53
|
+
detail: `${styled} mathematical or fullwidth letters`,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (MARK_STACK.test(content)) {
|
|
57
|
+
signals.push({
|
|
58
|
+
check: "unicode.mark-stack",
|
|
59
|
+
score: markStackScore,
|
|
60
|
+
detail: "stacked combining marks",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
return signals;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|