@recursiv/sdk 0.7.70 → 0.7.71
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/README.md +77 -9
- package/dist/client.d.ts +5 -0
- package/dist/client.js +7 -1
- package/dist/index.d.ts +13 -3
- package/dist/index.js +11 -1
- package/dist/resources/agents.d.ts +48 -0
- package/dist/resources/agents.js +22 -0
- package/dist/resources/curator.d.ts +9 -3
- package/dist/resources/discovery-fallover.d.ts +8 -0
- package/dist/resources/discovery-fallover.js +26 -0
- package/dist/resources/discovery-first-screen.d.ts +30 -0
- package/dist/resources/discovery-first-screen.js +149 -0
- package/dist/resources/discovery-merge.d.ts +144 -0
- package/dist/resources/discovery-merge.js +479 -0
- package/dist/resources/discovery-progressive.d.ts +117 -0
- package/dist/resources/discovery-progressive.js +414 -0
- package/dist/resources/discovery-safety.d.ts +32 -0
- package/dist/resources/discovery-safety.js +521 -0
- package/dist/resources/discovery.d.ts +59 -16
- package/dist/resources/discovery.js +147 -47
- package/dist/resources/feeds.d.ts +3 -1
- package/dist/resources/judgments.d.ts +252 -0
- package/dist/resources/judgments.js +79 -0
- package/dist/resources/protocols.d.ts +196 -15
- package/dist/resources/protocols.js +16 -2
- package/dist/resources/search.d.ts +9 -1
- package/dist/resources/settings.d.ts +8 -1
- package/dist/resources/settings.js +9 -0
- package/dist/resources/signals.d.ts +58 -0
- package/dist/resources/signals.js +32 -0
- package/dist/types.d.ts +30 -0
- package/llm.md +22 -6
- package/package.json +1 -1
|
@@ -0,0 +1,521 @@
|
|
|
1
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// What a Discover client never shows first, and masks unless the member shows
|
|
3
|
+
// sensitive content: a post flagged or tagged NSFW, or one that links a porn
|
|
4
|
+
// site. Open-network posts carry no NSFW flag, so for them the link is the
|
|
5
|
+
// only signal. The server's promotion floor (PromotionSafetyFloor's
|
|
6
|
+
// adultSiteLink, displayedText.ts and markerFold.ts) withholds such posts
|
|
7
|
+
// from Discover; this is the same rule on the client, for one that slipped
|
|
8
|
+
// through and for the lead of a first screen (composeFirstScreen).
|
|
9
|
+
//
|
|
10
|
+
// Ported from the Minds app (lib/contentVisibility, 2026-10-04) as its native
|
|
11
|
+
// build reads a text: the SDK has no DOM, so a legacy HTML body is read with
|
|
12
|
+
// its tags stripped and its references decoded the app's own way, never
|
|
13
|
+
// through a browser's parser.
|
|
14
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
15
|
+
const SENSITIVE_TAGS = new Set(['nsfw', 'nude', 'nudity', 'explicit', 'porn', 'pornography', 'xxx']);
|
|
16
|
+
function tagName(tag) {
|
|
17
|
+
const value = tag && typeof tag === 'object' ? tag.name ?? tag.slug : tag;
|
|
18
|
+
return String(value ?? '').replace(/^#/, '').trim().toLowerCase();
|
|
19
|
+
}
|
|
20
|
+
/** A tag (a `Tag`, `{ slug }` or a bare name, with or without `#`) that marks a post sensitive: exact aliases only. */
|
|
21
|
+
export function isSensitiveTag(tag) {
|
|
22
|
+
return SENSITIVE_TAGS.has(tagName(tag));
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* A porn site by its address: a link whose host is named for one of the big
|
|
26
|
+
* tube, cam and subscription sites ("xhamster.com", "de.xhamster.com",
|
|
27
|
+
* "xvideos.co.uk") or sits on an adult-only top-level domain ("rule34.xxx").
|
|
28
|
+
*
|
|
29
|
+
* The address decides, not the words. Only a link counts: a scheme or "//"
|
|
30
|
+
* or "www." in front, or a bare host with a path ("onlyfans.com/someone"). A
|
|
31
|
+
* domain named in a sentence is the news ("Aylo, which owns Pornhub.com,
|
|
32
|
+
* settles with the FTC"), a missing space a typo ("motherless.Now"), a bare
|
|
33
|
+
* ".xxx" a sign-off. And only the host counts: what comes before the last "@"
|
|
34
|
+
* is a user name, and a path or a longer host that starts with a site's name
|
|
35
|
+
* ("https://pornhub.com.example.org/a") is somewhere else.
|
|
36
|
+
*/
|
|
37
|
+
const ADULT_SITE_NAME = /^(?:xhamster\d*|pornhub|xvideos\d*|xnxx|redtube|youporn|tube8|spankbang|eporner|youjizz|motherless|tnaflix|drtuber|porntrex|hqporner|beeg|brazzers|bangbros|realitykings|chaturbate|stripchat|bongacams|livejasmin|camsoda|myfreecams|onlyfans|fansly|manyvids|clips4sale|nhentai|hanime|rule34video)$/;
|
|
38
|
+
const ADULT_TLDS = new Set(['xxx', 'porn', 'sex', 'adult']);
|
|
39
|
+
/** The labels a country's registry puts between a site's own name and the country ("xvideos.co.uk"). */
|
|
40
|
+
const REGISTRY_LABELS = new Set(['co', 'com', 'net', 'org']);
|
|
41
|
+
/**
|
|
42
|
+
* A link as written, to the end of its address; never from inside a word, an
|
|
43
|
+
* address or an email. A scheme is read to 63 characters: unbounded, a run of
|
|
44
|
+
* scheme characters with no ":" ("a+a+a+…//") was scanned to its end from
|
|
45
|
+
* every "+".
|
|
46
|
+
*/
|
|
47
|
+
export const DISCOVERY_LINK_TOKEN = /(?:^|[^a-z0-9.@/_-])((?:[a-z][a-z0-9+.-]{0,63}:)?\/\/[^\s"'<>()[\]]+|www\.[^\s"'<>()[\]]+|(?:[a-z0-9-]+\.)+[a-z][a-z0-9-]*\/[^\s"'<>()[\]]*)/g;
|
|
48
|
+
/** DISCOVERY_LINK_TOKEN, once: the first link in a run of a link's characters, which the token reads to the run's end. */
|
|
49
|
+
const RUN_LINK = /(?:^|[^a-z0-9.@/_-])((?:[a-z][a-z0-9+.-]{0,63}:)?\/\/[^\s"'<>()[\]]+|www\.[^\s"'<>()[\]]+|(?:[a-z0-9-]+\.)+[a-z][a-z0-9-]*\/[^\s"'<>()[\]]*)/;
|
|
50
|
+
const SPACE = /\s/;
|
|
51
|
+
/** A character a link never holds: whitespace, a quote, an angle bracket, a bracket or a parenthesis. */
|
|
52
|
+
function endsRun(char) {
|
|
53
|
+
const code = char.charCodeAt(0);
|
|
54
|
+
if (code <= 32)
|
|
55
|
+
return code === 32 || (code >= 9 && code <= 13);
|
|
56
|
+
if (code < 127)
|
|
57
|
+
return code === 34 || code === 39 || code === 40 || code === 41 || code === 60 || code === 62 || code === 91 || code === 93;
|
|
58
|
+
return SPACE.test(char);
|
|
59
|
+
}
|
|
60
|
+
/** A host's own character in a bare link's labels. */
|
|
61
|
+
const isLabelChar = (code) => (code >= 97 && code <= 122) || (code >= 48 && code <= 57) || code === 45;
|
|
62
|
+
/**
|
|
63
|
+
* Each link DISCOVERY_LINK_TOKEN finds in a text, found without trying it at
|
|
64
|
+
* every character. A link runs to the end of its run of link characters, so
|
|
65
|
+
* a run holds one at most, and every one holds "//", "www." or a bare host's
|
|
66
|
+
* "label.label/": only a run holding one of those is read, from its start.
|
|
67
|
+
* The same tokens in the same order as text.matchAll(DISCOVERY_LINK_TOKEN).
|
|
68
|
+
*/
|
|
69
|
+
export function discoveryLinkTokens(text) {
|
|
70
|
+
const tokens = [];
|
|
71
|
+
let runEnd = -1;
|
|
72
|
+
const read = (at) => {
|
|
73
|
+
if (at < runEnd)
|
|
74
|
+
return;
|
|
75
|
+
let start = at;
|
|
76
|
+
while (start > 0 && !endsRun(text[start - 1]))
|
|
77
|
+
start--;
|
|
78
|
+
let end = at;
|
|
79
|
+
while (end < text.length && !endsRun(text[end]))
|
|
80
|
+
end++;
|
|
81
|
+
runEnd = end;
|
|
82
|
+
const found = RUN_LINK.exec(text.slice(start, end));
|
|
83
|
+
if (found)
|
|
84
|
+
tokens.push(found[1]);
|
|
85
|
+
};
|
|
86
|
+
let slash = text.indexOf('/');
|
|
87
|
+
let www = text.indexOf('www.');
|
|
88
|
+
while (slash !== -1 || www !== -1) {
|
|
89
|
+
if (www !== -1 && (slash === -1 || www < slash)) {
|
|
90
|
+
read(www);
|
|
91
|
+
www = text.indexOf('www.', www + 4);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
const at = slash;
|
|
95
|
+
slash = text.indexOf('/', at + 1);
|
|
96
|
+
if (text[at + 1] === '/')
|
|
97
|
+
read(at);
|
|
98
|
+
else {
|
|
99
|
+
let label = at - 1;
|
|
100
|
+
while (label >= 0 && isLabelChar(text.charCodeAt(label)))
|
|
101
|
+
label--;
|
|
102
|
+
if (label < at - 1 && label >= 0 && text[label] === '.')
|
|
103
|
+
read(at);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return tokens;
|
|
107
|
+
}
|
|
108
|
+
/** Cherokee capitals that print as a Latin letter (CONFUSABLES). */
|
|
109
|
+
const CHEROKEE = {
|
|
110
|
+
'\u13a0': 'D', '\u13a1': 'R', '\u13a2': 'T', '\u13a5': 'i', '\u13aa': 'A', '\u13ab': 'J', '\u13ac': 'E', '\u13b3': 'W', '\u13b7': 'M',
|
|
111
|
+
'\u13bb': 'H', '\u13c0': 'G', '\u13c2': 'h', '\u13c3': 'Z', '\u13cf': 'b', '\u13d2': 'R', '\u13d4': 'W', '\u13d5': 'S', '\u13d9': 'V',
|
|
112
|
+
'\u13da': 'S', '\u13de': 'L', '\u13df': 'C', '\u13e2': 'P', '\u13e6': 'K', '\u13f4': 'B',
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
115
|
+
* Letters that print as a Latin one and that compatibility decomposition
|
|
116
|
+
* leaves alone, read as that Latin letter: "https://pornhub.com" written with
|
|
117
|
+
* a Cyrillic "o" (U+043E) shows the reader Pornhub's address. The same map as
|
|
118
|
+
* the server's markerFold.ts CONFUSABLES, key for key. A capital reads as a
|
|
119
|
+
* capital; a lowercase letter that looks like no Latin one stays as it is.
|
|
120
|
+
*/
|
|
121
|
+
const CONFUSABLES = {
|
|
122
|
+
// Cyrillic.
|
|
123
|
+
'\u0430': 'a', '\u0435': 'e', '\u043e': 'o', '\u0440': 'p', '\u0441': 'c', '\u0443': 'y', '\u0445': 'x', '\u0456': 'i', '\u0455': 's', '\u0458': 'j',
|
|
124
|
+
'\u04bb': 'h', '\u051b': 'q', '\u051d': 'w', '\u04af': 'y', '\u04cf': 'l', '\u0501': 'd', '\u0461': 'w',
|
|
125
|
+
'\u0410': 'A', '\u0415': 'E', '\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0423': 'Y', '\u0425': 'X', '\u0406': 'I', '\u0405': 'S', '\u0408': 'J',
|
|
126
|
+
'\u041d': 'H', '\u041a': 'K', '\u041c': 'M', '\u0422': 'T', '\u0412': 'B', '\u04ae': 'Y', '\u04c0': 'I', '\u051a': 'Q', '\u051c': 'W',
|
|
127
|
+
// Greek.
|
|
128
|
+
'\u03b1': 'a', '\u03b5': 'e', '\u03bf': 'o', '\u03c1': 'p', '\u03b9': 'i', '\u03ba': 'k', '\u03bd': 'v', '\u03c4': 't', '\u03c5': 'u', '\u03c7': 'x',
|
|
129
|
+
'\u03f3': 'j',
|
|
130
|
+
'\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u0396': 'Z', '\u0397': 'H', '\u0399': 'I', '\u039a': 'K', '\u039c': 'M', '\u039d': 'N', '\u039f': 'O',
|
|
131
|
+
'\u03a1': 'P', '\u03a4': 'T', '\u03a5': 'Y', '\u03a7': 'X',
|
|
132
|
+
// Armenian.
|
|
133
|
+
'\u0561': 'w', '\u0566': 'q', '\u0570': 'h', '\u0578': 'n', '\u057d': 'u', '\u0581': 'g', '\u0585': 'o', '\u053c': 'L', '\u054d': 'U', '\u0555': 'O',
|
|
134
|
+
// Latin letters past ASCII: dotless i and j, alpha, script g, iota, kra.
|
|
135
|
+
'\u0131': 'i', '\u0237': 'j', '\u0251': 'a', '\u0261': 'g', '\u0269': 'i', '\u0138': 'k',
|
|
136
|
+
// Latin small capitals: read lowercase.
|
|
137
|
+
'\u1d00': 'a', '\u0299': 'b', '\u1d04': 'c', '\u1d05': 'd', '\u1d07': 'e', '\ua730': 'f', '\u0262': 'g', '\u029c': 'h', '\u026a': 'i', '\u1d0a': 'j',
|
|
138
|
+
'\u1d0b': 'k', '\u029f': 'l', '\u1d0d': 'm', '\u0274': 'n', '\u1d0f': 'o', '\u1d18': 'p', '\ua7af': 'q', '\u0280': 'r', '\ua731': 's', '\u1d1b': 't',
|
|
139
|
+
'\u1d1c': 'u', '\u1d20': 'v', '\u1d21': 'w', '\u028f': 'y', '\u1d22': 'z',
|
|
140
|
+
// Cherokee: each capital, and its small letter as the lowercase letter.
|
|
141
|
+
...CHEROKEE,
|
|
142
|
+
...Object.fromEntries(Object.entries(CHEROKEE).map(([letter, latin]) => [letter.toLowerCase(), latin.toLowerCase()])),
|
|
143
|
+
};
|
|
144
|
+
/** Every character past ASCII, as a plain range: each CONFUSABLES key is in it, and every other character is kept as it is. */
|
|
145
|
+
const PAST_ASCII = /[\u0080-\uffff]/g;
|
|
146
|
+
const HAS_PAST_ASCII = /[\u0080-\uffff]/;
|
|
147
|
+
/**
|
|
148
|
+
* What prints as nothing, or only decorates the letter before it, which IDNA
|
|
149
|
+
* drops from a host: nonspacing and enclosing marks, format characters, the
|
|
150
|
+
* Hangul fillers and the reserved default-ignorables. The same class as the
|
|
151
|
+
* server's markerFold.ts.
|
|
152
|
+
*/
|
|
153
|
+
const PRINTS_AS_NOTHING = /[\p{Mn}\p{Me}\p{Cf}\u115f\u1160\u2065\u3164\uffa0\ufff0-\ufff8\u{E0000}-\u{E0FFF}]/gu;
|
|
154
|
+
/**
|
|
155
|
+
* The text as a browser reads an address in it, through the same fold as the
|
|
156
|
+
* server's promotion floor: compatibility-decomposed, what prints as nothing
|
|
157
|
+
* gone, look-alike letters read as Latin, then lowercased.
|
|
158
|
+
*/
|
|
159
|
+
function readAsAddress(text) {
|
|
160
|
+
if (!HAS_PAST_ASCII.test(text))
|
|
161
|
+
return text.toLowerCase();
|
|
162
|
+
return text.normalize('NFKD').replace(PRINTS_AS_NOTHING, '').replace(PAST_ASCII, char => CONFUSABLES[char] ?? char).toLowerCase();
|
|
163
|
+
}
|
|
164
|
+
/** An ideographic, fullwidth or halfwidth full stop between two of a host's characters is the dot IDNA maps it to. */
|
|
165
|
+
const HOST_DOT = /(?<=[a-z0-9])[\u3002\uff0e\uff61](?=[a-z0-9])/g;
|
|
166
|
+
/** A percent escape, which a browser decodes in a host ("porn%68ub.com"). */
|
|
167
|
+
const ESCAPE = /%([0-9a-f]{2})/gi;
|
|
168
|
+
/** What carries a label on past its ASCII: the host is then an IDN, not the site ("https://www.sexólogo.es"). */
|
|
169
|
+
const LABEL_GOES_ON = /^[\p{L}\p{M}\p{N}%]/u;
|
|
170
|
+
/** A host's own characters, from the start; a dot only where no second dot follows it (an ellipsis ends the host). */
|
|
171
|
+
const HOST = /^(?:[a-z0-9_-]|\.(?!\.))*/;
|
|
172
|
+
/**
|
|
173
|
+
* A link's host: after the scheme and any user name, escapes of a host's own
|
|
174
|
+
* characters decoded, up to the first character a host cannot hold, with the
|
|
175
|
+
* dots, hyphens and underscores a host never ends on cut. A label that goes
|
|
176
|
+
* on in another script is no host of ours: "". The server's linkHost is the same rule.
|
|
177
|
+
*/
|
|
178
|
+
function linkHost(link) {
|
|
179
|
+
const authority = link.replace(/^(?:[a-z][a-z0-9+.-]*:)?\/\//, '').split(/[/?#\\]/, 1)[0] ?? '';
|
|
180
|
+
const named = authority.slice(authority.lastIndexOf('@') + 1).replace(ESCAPE, (encoded, hex) => {
|
|
181
|
+
const char = String.fromCharCode(Number.parseInt(hex, 16)).toLowerCase();
|
|
182
|
+
return /^[a-z0-9._-]$/.test(char) ? char : encoded;
|
|
183
|
+
});
|
|
184
|
+
const host = HOST.exec(named)?.[0] ?? '';
|
|
185
|
+
if (LABEL_GOES_ON.test(named.slice(host.length)))
|
|
186
|
+
return '';
|
|
187
|
+
return host.replace(/[._-]+$/, '');
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* A porn site's host: its own name is one, or it sits on an adult-only
|
|
191
|
+
* top-level domain, except that TLD's registry's own "www.<tld>" (ICANN
|
|
192
|
+
* reserves it; no porn site has that address).
|
|
193
|
+
*/
|
|
194
|
+
function adultHost(host) {
|
|
195
|
+
const labels = host.split('.');
|
|
196
|
+
if (labels.length < 2 || labels.some(label => !/^[a-z0-9_-]+$/.test(label)))
|
|
197
|
+
return false;
|
|
198
|
+
const tld = labels[labels.length - 1];
|
|
199
|
+
if (ADULT_TLDS.has(tld))
|
|
200
|
+
return labels.length > 2 || labels[0] !== 'www';
|
|
201
|
+
const name = labels.length > 2 && tld.length === 2 && REGISTRY_LABELS.has(labels[labels.length - 2]) ? labels[labels.length - 3] : labels[labels.length - 2];
|
|
202
|
+
return ADULT_SITE_NAME.test(name);
|
|
203
|
+
}
|
|
204
|
+
/** Passes of decoding: ActivityPub text is decoded twice (the server, then the app). The server's DISPLAY_DECODE_PASSES is the same bound. */
|
|
205
|
+
const DISPLAY_DECODE_PASSES = 4;
|
|
206
|
+
const NAMED_REFERENCES = { amp: '&', apos: "'", gt: '>', lt: '<', nbsp: ' ', quot: '"' };
|
|
207
|
+
/** One layer of character references, as the Minds app decodes a text before showing it: numbers and six names. */
|
|
208
|
+
function decodeReferencesOnce(text) {
|
|
209
|
+
if (!text)
|
|
210
|
+
return '';
|
|
211
|
+
return text.replace(/&(#x[\da-f]+|#\d+|amp|apos|gt|lt|nbsp|quot);/gi, (match, entity) => {
|
|
212
|
+
if (entity[0] !== '#')
|
|
213
|
+
return NAMED_REFERENCES[entity.toLowerCase()] ?? match;
|
|
214
|
+
const hex = entity[1]?.toLowerCase() === 'x';
|
|
215
|
+
const codePoint = Number.parseInt(entity.slice(hex ? 2 : 1), hex ? 16 : 10);
|
|
216
|
+
if (!Number.isFinite(codePoint) || codePoint < 0 || codePoint > 0x10ffff)
|
|
217
|
+
return match;
|
|
218
|
+
try {
|
|
219
|
+
const decoded = String.fromCodePoint(codePoint);
|
|
220
|
+
return decoded === '\u00a0' ? ' ' : decoded;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return match;
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
/** The text after each pass of decoding, to its fixed point and within DISPLAY_DECODE_PASSES: every reading a surface shows. */
|
|
228
|
+
function decodedReadings(text) {
|
|
229
|
+
const readings = [];
|
|
230
|
+
let decoded = text;
|
|
231
|
+
for (let pass = 0; pass < DISPLAY_DECODE_PASSES && decoded.includes('&'); pass++) {
|
|
232
|
+
const next = decodeReferencesOnce(decoded);
|
|
233
|
+
if (next === decoded)
|
|
234
|
+
break;
|
|
235
|
+
readings.push(next);
|
|
236
|
+
decoded = next;
|
|
237
|
+
}
|
|
238
|
+
return readings;
|
|
239
|
+
}
|
|
240
|
+
/** A legacy blog body's markup: the first tag start decides, with any ">" after it. */
|
|
241
|
+
const LEGACY_HTML_TAG_START = /<\/?(p|div|a|img|br|h[1-6]|ul|ol|li|blockquote|strong|em|b|i|u|s|span|figure|figcaption|pre|code|hr)\b/i;
|
|
242
|
+
function looksLikeLegacyHtml(text) {
|
|
243
|
+
if (!text)
|
|
244
|
+
return false;
|
|
245
|
+
const start = LEGACY_HTML_TAG_START.exec(text);
|
|
246
|
+
return !!start && text.indexOf('>', start.index) !== -1;
|
|
247
|
+
}
|
|
248
|
+
/** HTML with its tags stripped, a line break at each block's end, and its character references left as written. */
|
|
249
|
+
function stripHtmlTags(html) {
|
|
250
|
+
if (!html)
|
|
251
|
+
return '';
|
|
252
|
+
// Every tag the passes below remove ends at a ">", so they read the text up to its last ">" only.
|
|
253
|
+
const closed = html.lastIndexOf('>') + 1;
|
|
254
|
+
const open = html.slice(closed);
|
|
255
|
+
let out = html.slice(0, closed).replace(/<(br|\/p|\/div|\/li|\/h[1-6]|\/blockquote)[^>]*>/gi, '\n').replace(/<li\b[^>]*>/gi, '\u2022 ');
|
|
256
|
+
// To a fixed point: one pass lets "<scr<script>ipt>" form a tag again from the residue.
|
|
257
|
+
let prev;
|
|
258
|
+
do {
|
|
259
|
+
prev = out;
|
|
260
|
+
out = out.replace(/<[^>]*>/g, '');
|
|
261
|
+
} while (out !== prev);
|
|
262
|
+
return (out + open).replace(/<[a-z/][^<]*$/gi, '');
|
|
263
|
+
}
|
|
264
|
+
/** Where markup and markdown put a link's target, which a browser opens through its URL parser rather than as the text reads. */
|
|
265
|
+
const ATTRIBUTE_TARGET = /\b(?:href|src)[\t\n\f\r ]*=[\t\n\f\r ]*(?:"([^"]*)|'([^']*)|([^\t\n\f\r "'<>`=]+))/g;
|
|
266
|
+
/** An autolink to a site: openedAddress reads no other scheme. */
|
|
267
|
+
const AUTOLINK_START = /<(?:https?|wss?|ftp|file):/;
|
|
268
|
+
const MARKDOWN_TARGET = /\]\([\t\n\f\r ]*(?:<([^>)]*)|([^)]*))|^ {0,3}\[[^\]\n]+\]:[\t ]*<?([^\s>]+)|<([a-z][a-z0-9+.-]*:[^\s<>]*)>/gm;
|
|
269
|
+
/** Each link target in a text, as written. `text`: read as an address. */
|
|
270
|
+
function linkTargets(text) {
|
|
271
|
+
const targets = [];
|
|
272
|
+
if (text.includes('href') || text.includes('src'))
|
|
273
|
+
for (const match of text.matchAll(ATTRIBUTE_TARGET))
|
|
274
|
+
targets.push(match[1] ?? match[2] ?? match[3] ?? '');
|
|
275
|
+
if (text.includes('](') || text.includes(']:') || (text.includes('<') && AUTOLINK_START.test(text))) {
|
|
276
|
+
for (const match of text.matchAll(MARKDOWN_TARGET))
|
|
277
|
+
targets.push(match[1] ?? match[2] ?? match[3] ?? match[4] ?? '');
|
|
278
|
+
}
|
|
279
|
+
return targets;
|
|
280
|
+
}
|
|
281
|
+
/** The URL parser's special schemes (WHATWG URL): after one, any run of slashes, "/" or "\", leads to the host. */
|
|
282
|
+
const SPECIAL_SCHEME = /^(?:https?|wss?|ftp|file):$/;
|
|
283
|
+
/**
|
|
284
|
+
* The address a browser opens for a link's target, read as its URL parser
|
|
285
|
+
* reads one: the controls and spaces around it stripped, every tab and
|
|
286
|
+
* newline in it removed, and after a special scheme any run of slashes of
|
|
287
|
+
* either kind skipped. Null when it opens no other site.
|
|
288
|
+
*/
|
|
289
|
+
function openedAddress(target) {
|
|
290
|
+
let start = 0;
|
|
291
|
+
let end = target.length;
|
|
292
|
+
while (start < end && target.charCodeAt(start) <= 0x20)
|
|
293
|
+
start++;
|
|
294
|
+
while (end > start && target.charCodeAt(end - 1) <= 0x20)
|
|
295
|
+
end--;
|
|
296
|
+
const url = target.slice(start, end).replace(/[\t\n\r]/g, '');
|
|
297
|
+
const scheme = /^[a-z][a-z0-9+.-]*:/.exec(url)?.[0];
|
|
298
|
+
if (scheme ? !SPECIAL_SCHEME.test(scheme) : !/^[/\\]{2}/.test(url))
|
|
299
|
+
return null;
|
|
300
|
+
return `${scheme ?? 'https:'}//${url.slice(scheme?.length ?? 0).replace(/^[/\\]+/, '')}`;
|
|
301
|
+
}
|
|
302
|
+
/** An escape or a letter past ASCII in an address, which the URL parser decodes and maps before it reads the host. */
|
|
303
|
+
const PARSER_READS_MORE = /[%\u0080-\uffff]/;
|
|
304
|
+
/** The hosts an opened address names: as linkHost reads it and, where it may read more, as the runtime's URL parser does. */
|
|
305
|
+
function openedHosts(address) {
|
|
306
|
+
const hosts = [linkHost(address)];
|
|
307
|
+
if (!PARSER_READS_MORE.test(address))
|
|
308
|
+
return hosts;
|
|
309
|
+
try {
|
|
310
|
+
const host = new URL(address).hostname.replace(/\.+$/, '');
|
|
311
|
+
if (!/(?:^|\.)xn--/.test(host))
|
|
312
|
+
hosts.push(host);
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
// Not an address the parser reads: linkHost's reading stands.
|
|
316
|
+
}
|
|
317
|
+
return hosts;
|
|
318
|
+
}
|
|
319
|
+
/** A target the URL parser reads as the text reads it: the link token has read it already, to the same host. */
|
|
320
|
+
const PLAIN_TARGET = /^[a-z][a-z0-9+.-]*:\/\/[^\s\\%\u0080-\uffff]*$/;
|
|
321
|
+
/** A link's authority with a user name in it, which may hold what ends a link as the text reads it ("(", "'", "["). */
|
|
322
|
+
const USERINFO_LINK = /[/\\]{2}[^\s/\\?#@]*@[^\s/\\?#]*/g;
|
|
323
|
+
/** A link or a link's target opens a porn site, read as a browser opens it. */
|
|
324
|
+
function opensAdultSite(target) {
|
|
325
|
+
const address = openedAddress(target);
|
|
326
|
+
return address !== null && openedHosts(address).some(adultHost);
|
|
327
|
+
}
|
|
328
|
+
/** The reading (already read as an address) links a porn site: a link in its text, or a link target in its markup or markdown. */
|
|
329
|
+
function readingLinksAdultSite(address) {
|
|
330
|
+
const text = address.replace(HOST_DOT, '.');
|
|
331
|
+
for (const link of discoveryLinkTokens(text)) {
|
|
332
|
+
if (adultHost(linkHost(link)) || (PARSER_READS_MORE.test(link) && opensAdultSite(link)))
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
for (const target of linkTargets(text))
|
|
336
|
+
if (!PLAIN_TARGET.test(target) && opensAdultSite(target))
|
|
337
|
+
return true;
|
|
338
|
+
if (text.includes('@'))
|
|
339
|
+
for (const match of text.matchAll(USERINFO_LINK))
|
|
340
|
+
if (opensAdultSite(match[0]))
|
|
341
|
+
return true;
|
|
342
|
+
return false;
|
|
343
|
+
}
|
|
344
|
+
/** How much of a text is rendered and decoded: four times the 5,000 characters a post may hold. The stored text is still read whole. */
|
|
345
|
+
const DISPLAY_SCAN_CHARS = 20_000;
|
|
346
|
+
/** Where decoding can change a text: "&" before "#" or a letter. */
|
|
347
|
+
const REFERENCE_START = /&[#a-zA-Z]/;
|
|
348
|
+
/**
|
|
349
|
+
* The start of each reference a reading can change a link at. The others
|
|
350
|
+
* decode, in every reading, to a character with no part in a link (an
|
|
351
|
+
* "&" before no second reference, dashes, quotes, an ellipsis, a no-break
|
|
352
|
+
* space, currency), so a host ends at it as it ends at the stored "&".
|
|
353
|
+
*/
|
|
354
|
+
const CHANGING_REFERENCE = /(?<!=[\t\n\f\r ]*)&(?!amp;(?!#|(?:[aA][mM][pP]|[aA][pP][oO][sS]|[gG][tT]|[lL][tT]|[nN][bB][sS][pP]|[qQ][uU][oO][tT]);)|(?:quot|apos|#39|#34|mdash|ndash|hellip|rsquo|lsquo|rdquo|ldquo|nbsp|copy|reg|pound|euro|laquo|raquo|bull|middot|deg|#8211|#8212|#8216|#8217|#8220|#8221|#8230|#160|#169|#174);)[#a-zA-Z]|(?<==[\t\n\f\r ]*)&[#a-zA-Z]/g;
|
|
355
|
+
/** A run of tags with no whitespace either side, which stripping a legacy body joins into one word unless it is a break. */
|
|
356
|
+
const GLUED_TAG_RUN = /(?<=[^\s>])(?:<[^<>]*>)+(?=[^\s<])/g;
|
|
357
|
+
/** A tag stripping turns into a line break or a bullet: the words either side stay apart. */
|
|
358
|
+
const SPLITTING_TAG = /<(?:br|\/p|\/div|\/li|\/h[1-6]|\/blockquote|li\b)/i;
|
|
359
|
+
/** What a link needs somewhere in its word: a slash, a backslash, a scheme's colon, or a dot between two of a host's characters. */
|
|
360
|
+
const HOST_SHAPED = /[/\\]|[a-z0-9]:[a-z0-9]|[a-z0-9_-][.\u3002][a-z0-9]/i;
|
|
361
|
+
/** A tag with no attributes: its slash is no link's, and it holds no href or src. */
|
|
362
|
+
const BARE_TAG = /<\/?[a-z][a-z0-9]*\s*\/?>/gi;
|
|
363
|
+
const WHITESPACE = /\s/;
|
|
364
|
+
function isSpace(char) {
|
|
365
|
+
const code = char.charCodeAt(0);
|
|
366
|
+
return code === 32 || (code >= 9 && code <= 13) || (code > 127 && WHITESPACE.test(char));
|
|
367
|
+
}
|
|
368
|
+
/** Words kept on each side of a change, so a markdown label or an attribute's name comes with its target. */
|
|
369
|
+
const CONTEXT_WORDS = 2;
|
|
370
|
+
/** Between two windows: what ends a line, a quoted value, a destination or an autolink, so no link runs from one window into the next. */
|
|
371
|
+
const WINDOW_BREAK = '\n)"\'>\n';
|
|
372
|
+
function wordStart(text, at, back) {
|
|
373
|
+
let i = at;
|
|
374
|
+
while (i > 0 && !isSpace(text[i - 1]))
|
|
375
|
+
i--;
|
|
376
|
+
for (let word = 0; word < back && i > 0; word++) {
|
|
377
|
+
while (i > 0 && isSpace(text[i - 1]))
|
|
378
|
+
i--;
|
|
379
|
+
while (i > 0 && !isSpace(text[i - 1]))
|
|
380
|
+
i--;
|
|
381
|
+
}
|
|
382
|
+
return i;
|
|
383
|
+
}
|
|
384
|
+
function wordEnd(text, at, ahead) {
|
|
385
|
+
let i = at;
|
|
386
|
+
while (i < text.length && !isSpace(text[i]))
|
|
387
|
+
i++;
|
|
388
|
+
for (let word = 0; word < ahead && i < text.length; word++) {
|
|
389
|
+
while (i < text.length && isSpace(text[i]))
|
|
390
|
+
i++;
|
|
391
|
+
while (i < text.length && !isSpace(text[i]))
|
|
392
|
+
i++;
|
|
393
|
+
}
|
|
394
|
+
return i;
|
|
395
|
+
}
|
|
396
|
+
/** The words mayHoldHost has read last, and its answer for each. */
|
|
397
|
+
const HOST_WORDS = new Map();
|
|
398
|
+
const HOST_WORDS_KEPT = 2048;
|
|
399
|
+
/** A word, as written or in some reading of it, could hold a host. Words repeat ("&", "can't"), so each answer is kept. */
|
|
400
|
+
function mayHoldHost(word, legacy) {
|
|
401
|
+
const own = legacy && word.includes('<') ? word.replace(BARE_TAG, ' ') : word;
|
|
402
|
+
if (HOST_SHAPED.test(own))
|
|
403
|
+
return true;
|
|
404
|
+
const key = legacy ? `\u0000legacy ${own}` : own;
|
|
405
|
+
const known = HOST_WORDS.get(key);
|
|
406
|
+
if (known !== undefined)
|
|
407
|
+
return known;
|
|
408
|
+
const holds = decodedReadings(own).some(reading => HOST_SHAPED.test(HAS_PAST_ASCII.test(reading) ? readAsAddress(reading) : reading));
|
|
409
|
+
if (HOST_WORDS.size >= HOST_WORDS_KEPT)
|
|
410
|
+
HOST_WORDS.delete(HOST_WORDS.keys().next().value);
|
|
411
|
+
HOST_WORDS.set(key, holds);
|
|
412
|
+
return holds;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* The parts of a text where a reading can differ from the stored text, each
|
|
416
|
+
* with CONTEXT_WORDS words either side, joined by WINDOW_BREAK: every word
|
|
417
|
+
* with a character reference that reads as part of a host once decoded, and,
|
|
418
|
+
* in a legacy HTML body, every run of tags that stripping joins into one word.
|
|
419
|
+
* Empty when no reading can differ.
|
|
420
|
+
*/
|
|
421
|
+
function changedWindows(head, legacy) {
|
|
422
|
+
const spans = [];
|
|
423
|
+
let readTo = -1;
|
|
424
|
+
CHANGING_REFERENCE.lastIndex = 0;
|
|
425
|
+
for (let match = CHANGING_REFERENCE.exec(head); match; match = CHANGING_REFERENCE.exec(head)) {
|
|
426
|
+
const at = match.index;
|
|
427
|
+
if (at < readTo)
|
|
428
|
+
continue;
|
|
429
|
+
const start = wordStart(head, at, 0);
|
|
430
|
+
const end = wordEnd(head, at, 0);
|
|
431
|
+
readTo = end;
|
|
432
|
+
if (!mayHoldHost(head.slice(start, end), legacy))
|
|
433
|
+
continue;
|
|
434
|
+
spans.push([wordStart(head, at, CONTEXT_WORDS), wordEnd(head, at, CONTEXT_WORDS)]);
|
|
435
|
+
}
|
|
436
|
+
if (legacy) {
|
|
437
|
+
let spannedTo = -1;
|
|
438
|
+
GLUED_TAG_RUN.lastIndex = 0;
|
|
439
|
+
for (let match = GLUED_TAG_RUN.exec(head); match; match = GLUED_TAG_RUN.exec(head)) {
|
|
440
|
+
const from = match.index;
|
|
441
|
+
const to = from + match[0].length;
|
|
442
|
+
if (from < spannedTo || SPLITTING_TAG.test(match[0]))
|
|
443
|
+
continue;
|
|
444
|
+
spannedTo = wordEnd(head, to, CONTEXT_WORDS);
|
|
445
|
+
spans.push([wordStart(head, from - 1, CONTEXT_WORDS), spannedTo]);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
if (!spans.length)
|
|
449
|
+
return '';
|
|
450
|
+
spans.sort((a, b) => a[0] - b[0]);
|
|
451
|
+
const windows = [];
|
|
452
|
+
let [from, to] = spans[0];
|
|
453
|
+
for (const [start, end] of spans.slice(1)) {
|
|
454
|
+
if (start <= to)
|
|
455
|
+
to = Math.max(to, end);
|
|
456
|
+
else {
|
|
457
|
+
windows.push(head.slice(from, to));
|
|
458
|
+
[from, to] = [start, end];
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
windows.push(head.slice(from, to));
|
|
462
|
+
return windows.join(WINDOW_BREAK);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* The readings a member can see besides the stored text, read on the parts of
|
|
466
|
+
* its first DISPLAY_SCAN_CHARS where they differ from it: each pass of
|
|
467
|
+
* decoding, and for a legacy HTML body its tags stripped and each pass of
|
|
468
|
+
* decoding that, as the Minds app's native build shows it.
|
|
469
|
+
*/
|
|
470
|
+
function derivedReadings(text) {
|
|
471
|
+
const head = text.length > DISPLAY_SCAN_CHARS ? text.slice(0, DISPLAY_SCAN_CHARS) : text;
|
|
472
|
+
const legacy = text.includes('<') && looksLikeLegacyHtml(text);
|
|
473
|
+
if (!legacy && !REFERENCE_START.test(head))
|
|
474
|
+
return [];
|
|
475
|
+
const windows = changedWindows(head, legacy);
|
|
476
|
+
if (!windows)
|
|
477
|
+
return [];
|
|
478
|
+
const readings = decodedReadings(windows);
|
|
479
|
+
if (legacy) {
|
|
480
|
+
const stripped = stripHtmlTags(windows);
|
|
481
|
+
readings.push(stripped, ...decodedReadings(stripped));
|
|
482
|
+
}
|
|
483
|
+
return readings;
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* The text links a porn site, as stored or as a member sees it: a link in it
|
|
487
|
+
* (read through look-alike letters, hidden characters, IDNA dots and escapes),
|
|
488
|
+
* a link target in its markup or markdown as a browser opens it, or either
|
|
489
|
+
* once its character references are decoded or a legacy body's tags stripped.
|
|
490
|
+
*/
|
|
491
|
+
export function linksAdultSite(text) {
|
|
492
|
+
if (typeof text !== 'string' || !text)
|
|
493
|
+
return false;
|
|
494
|
+
return readingLinksAdultSite(readAsAddress(text)) || derivedReadings(text).some(reading => readingLinksAdultSite(readAsAddress(reading)));
|
|
495
|
+
}
|
|
496
|
+
/** Each post read last, with the body and title read and whether either links a porn site: a post whose text is the same is not read again. */
|
|
497
|
+
const POST_LINKS = new WeakMap();
|
|
498
|
+
function postLinksAdultSite(post) {
|
|
499
|
+
const kept = POST_LINKS.get(post);
|
|
500
|
+
if (kept && kept.content === post.content && kept.title === post.title)
|
|
501
|
+
return kept.links;
|
|
502
|
+
const links = linksAdultSite(post.content) || linksAdultSite(post.title);
|
|
503
|
+
POST_LINKS.set(post, { content: post.content, title: post.title, links });
|
|
504
|
+
return links;
|
|
505
|
+
}
|
|
506
|
+
/**
|
|
507
|
+
* A Minds post to mask unless the member shows sensitive content: flagged NSFW
|
|
508
|
+
* (`is_nsfw` or `isNsfw`), tagged so, or linking a porn site in its content or
|
|
509
|
+
* title. Live on 2026-09-27 a visitor's Discover All showed "I think she likes
|
|
510
|
+
* it. https://xhamster.com/videos/…" with the video's explicit thumbnail as its
|
|
511
|
+
* picture: its author never flagged it.
|
|
512
|
+
*/
|
|
513
|
+
export function isSensitivePost(post) {
|
|
514
|
+
if (!post || typeof post !== 'object')
|
|
515
|
+
return false;
|
|
516
|
+
const value = post;
|
|
517
|
+
return value.is_nsfw === true
|
|
518
|
+
|| value.isNsfw === true
|
|
519
|
+
|| (Array.isArray(value.tags) && value.tags.some(isSensitiveTag))
|
|
520
|
+
|| postLinksAdultSite(value);
|
|
521
|
+
}
|