mcp-rule34 0.1.0
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 +21 -0
- package/README.md +410 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1435 -0
- package/dist/index.js.map +1 -0
- package/dist/rule34/client.d.ts +221 -0
- package/dist/rule34/client.js +811 -0
- package/dist/rule34/client.js.map +1 -0
- package/package.json +84 -0
- package/server.json +56 -0
|
@@ -0,0 +1,811 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var Rule34Error = class extends Error {
|
|
3
|
+
constructor(code, text, details = {}) {
|
|
4
|
+
super(`[${code}] ${text}`);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.details = details;
|
|
7
|
+
this.name = "Rule34Error";
|
|
8
|
+
}
|
|
9
|
+
code;
|
|
10
|
+
details;
|
|
11
|
+
};
|
|
12
|
+
var ISSUES_URL = "https://github.com/smeet666/mcp-rule34/issues";
|
|
13
|
+
function invalidInput(message, hint) {
|
|
14
|
+
return new Rule34Error("invalid_input", message, hint ? { hint } : {});
|
|
15
|
+
}
|
|
16
|
+
function missingCredentials(url) {
|
|
17
|
+
return new Rule34Error(
|
|
18
|
+
"invalid_input",
|
|
19
|
+
"rule34.xxx answered that this request carries no valid API credentials.",
|
|
20
|
+
{
|
|
21
|
+
url,
|
|
22
|
+
hint: "Set RULE34_USER_ID and RULE34_API_KEY in the env block of this server's entry in your MCP client configuration. Both are shown under 'API Access Credentials' at https://rule34.xxx/index.php?page=account&s=options once you are signed in. The site issues one key per person, so use your own rather than sharing one."
|
|
23
|
+
}
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
function notFound(url) {
|
|
27
|
+
return new Rule34Error("not_found", "rule34.xxx has nothing at this address.", {
|
|
28
|
+
url,
|
|
29
|
+
status: 404,
|
|
30
|
+
hint: "Check the post id. Ids come from a search; they are not stable to guess by hand."
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
function rateLimited(url, retryAfterMs) {
|
|
34
|
+
return new Rule34Error(
|
|
35
|
+
"rate_limited",
|
|
36
|
+
"rule34.xxx is refusing requests from this client for now (HTTP 429). This does NOT mean the search found nothing.",
|
|
37
|
+
{
|
|
38
|
+
url,
|
|
39
|
+
status: 429,
|
|
40
|
+
retryAfterMs,
|
|
41
|
+
hint: "Wait before asking again. If it keeps happening, raise RULE34_MIN_INTERVAL_MS in your MCP client configuration to slow this server down."
|
|
42
|
+
}
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
function parseFailure(url, what) {
|
|
46
|
+
return new Rule34Error(
|
|
47
|
+
"parse_failure",
|
|
48
|
+
`The request was answered, but not with the document this route publishes (${what}).`,
|
|
49
|
+
{ url, hint: `Please report this, with the query you used, at ${ISSUES_URL}` }
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
function upstreamFailure(url, message) {
|
|
53
|
+
return new Rule34Error("network_error", `rule34.xxx reported a failure: ${message}`, {
|
|
54
|
+
url,
|
|
55
|
+
hint: "This is the site's own message. Waiting and asking again is usually the only remedy."
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/rule34/cache.ts
|
|
60
|
+
var TtlLruCache = class {
|
|
61
|
+
constructor(maxEntries, ttlMs) {
|
|
62
|
+
this.maxEntries = maxEntries;
|
|
63
|
+
this.ttlMs = ttlMs;
|
|
64
|
+
}
|
|
65
|
+
maxEntries;
|
|
66
|
+
ttlMs;
|
|
67
|
+
store = /* @__PURE__ */ new Map();
|
|
68
|
+
get(key) {
|
|
69
|
+
const entry = this.store.get(key);
|
|
70
|
+
if (!entry) {
|
|
71
|
+
return void 0;
|
|
72
|
+
}
|
|
73
|
+
if (Date.now() >= entry.expiresAt) {
|
|
74
|
+
this.store.delete(key);
|
|
75
|
+
return void 0;
|
|
76
|
+
}
|
|
77
|
+
this.store.delete(key);
|
|
78
|
+
this.store.set(key, entry);
|
|
79
|
+
return entry.value;
|
|
80
|
+
}
|
|
81
|
+
set(key, value) {
|
|
82
|
+
if (this.maxEntries <= 0 || this.ttlMs <= 0) {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.store.delete(key);
|
|
86
|
+
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
87
|
+
while (this.store.size > this.maxEntries) {
|
|
88
|
+
const oldest = this.store.keys().next();
|
|
89
|
+
if (oldest.done) {
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
this.store.delete(oldest.value);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** The keys currently held, in least recently used order. */
|
|
96
|
+
keys() {
|
|
97
|
+
return [...this.store.keys()];
|
|
98
|
+
}
|
|
99
|
+
get size() {
|
|
100
|
+
return this.store.size;
|
|
101
|
+
}
|
|
102
|
+
clear() {
|
|
103
|
+
this.store.clear();
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// src/rule34/rateLimiter.ts
|
|
108
|
+
var DEFAULT_MAX_INTERVAL_MS = 3e4;
|
|
109
|
+
var RateLimiter = class {
|
|
110
|
+
baseIntervalMs;
|
|
111
|
+
maxIntervalMs;
|
|
112
|
+
intervalMs;
|
|
113
|
+
/** Tail of the queue: each task chains onto the previous one. */
|
|
114
|
+
tail = Promise.resolve();
|
|
115
|
+
lastStart = 0;
|
|
116
|
+
constructor(options) {
|
|
117
|
+
this.baseIntervalMs = Math.max(0, options.minIntervalMs);
|
|
118
|
+
this.maxIntervalMs = Math.max(
|
|
119
|
+
this.baseIntervalMs,
|
|
120
|
+
options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS
|
|
121
|
+
);
|
|
122
|
+
this.intervalMs = this.baseIntervalMs;
|
|
123
|
+
}
|
|
124
|
+
get currentIntervalMs() {
|
|
125
|
+
return this.intervalMs;
|
|
126
|
+
}
|
|
127
|
+
/** Queue a task. Tasks run in call order, one at a time. */
|
|
128
|
+
schedule(task) {
|
|
129
|
+
const run = this.tail.then(async () => task());
|
|
130
|
+
this.tail = run.then(
|
|
131
|
+
() => void 0,
|
|
132
|
+
() => void 0
|
|
133
|
+
);
|
|
134
|
+
return run;
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Wait for this request's slot, then claim it.
|
|
138
|
+
*
|
|
139
|
+
* Called once per upstream request rather than once per task, because a task
|
|
140
|
+
* runs a whole retry chain: stamping only its start would let the next task
|
|
141
|
+
* follow the chain's last request with no gap at all.
|
|
142
|
+
*/
|
|
143
|
+
async beforeRequest() {
|
|
144
|
+
await this.waitForSlot();
|
|
145
|
+
this.lastStart = Date.now();
|
|
146
|
+
}
|
|
147
|
+
/** Called after the site answers that it is being asked too often. */
|
|
148
|
+
penalize() {
|
|
149
|
+
const next = this.intervalMs === 0 ? 500 : this.intervalMs * 2;
|
|
150
|
+
this.intervalMs = Math.min(this.maxIntervalMs, next);
|
|
151
|
+
}
|
|
152
|
+
/** Called after a success, so one push-back does not slow things down forever. */
|
|
153
|
+
relax() {
|
|
154
|
+
this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
|
|
155
|
+
}
|
|
156
|
+
async waitForSlot() {
|
|
157
|
+
if (this.intervalMs === 0 || this.lastStart === 0) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const elapsed = Date.now() - this.lastStart;
|
|
161
|
+
const remaining = Math.min(this.intervalMs, this.intervalMs - elapsed);
|
|
162
|
+
if (remaining > 0) {
|
|
163
|
+
await sleep(remaining);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
function sleep(ms) {
|
|
168
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// src/rule34/urls.ts
|
|
172
|
+
var API_URL = "https://api.rule34.xxx/index.php";
|
|
173
|
+
var SITE_URL = "https://rule34.xxx/index.php";
|
|
174
|
+
var MAX_LIMIT = 1e3;
|
|
175
|
+
var MEDIA_TOKENS = {
|
|
176
|
+
image: ["-video", "-animated"],
|
|
177
|
+
animated: ["animated", "-video"],
|
|
178
|
+
video: ["video"],
|
|
179
|
+
any: []
|
|
180
|
+
};
|
|
181
|
+
var SORT_TOKENS = {
|
|
182
|
+
score: "sort:score:desc",
|
|
183
|
+
id: "sort:id:desc",
|
|
184
|
+
updated: "sort:updated:desc",
|
|
185
|
+
random: "sort:random"
|
|
186
|
+
};
|
|
187
|
+
var MAX_TAGS = 10;
|
|
188
|
+
function normalizeTag(raw) {
|
|
189
|
+
const trimmed = raw.trim();
|
|
190
|
+
if (trimmed === "") {
|
|
191
|
+
throw invalidInput(
|
|
192
|
+
"A tag is required.",
|
|
193
|
+
"Pass the tag to search for, such as 'black hair' or 'asuka langley sohryu'."
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
if (trimmed.startsWith("-")) {
|
|
197
|
+
throw invalidInput(
|
|
198
|
+
`"${trimmed}" starts with '-', which means "without this tag" on rule34.xxx.`,
|
|
199
|
+
"This tool searches for a tag. Pass the tag itself, without the leading dash."
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
if (trimmed.includes(":")) {
|
|
203
|
+
throw invalidInput(
|
|
204
|
+
`"${trimmed}" carries a ':', which rule34.xxx reads as a filter rather than as a tag.`,
|
|
205
|
+
"Filters are separate arguments here: use 'rating', 'sort' and 'media_type'."
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return trimmed.replace(/\s+/g, "_").toLowerCase();
|
|
209
|
+
}
|
|
210
|
+
function normalizeList(raw, argument) {
|
|
211
|
+
const tags = (raw ?? []).map(normalizeTag);
|
|
212
|
+
if (tags.length > MAX_TAGS) {
|
|
213
|
+
throw invalidInput(
|
|
214
|
+
`'${argument}' holds ${tags.length} tags, and this tool searches at most ${MAX_TAGS} at a time.`,
|
|
215
|
+
"Search on the tags that narrow the most, then look through the results."
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
return tags;
|
|
219
|
+
}
|
|
220
|
+
function normalizeSearch(search) {
|
|
221
|
+
const required = normalizeList(search.tags, "tags");
|
|
222
|
+
if (required.length === 0) {
|
|
223
|
+
throw invalidInput(
|
|
224
|
+
"At least one tag is required.",
|
|
225
|
+
"Pass the tags to search for, such as ['asuka langley sohryu', 'black hair']."
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const alternatives = normalizeList(search.anyOf, "any_of");
|
|
229
|
+
const excluded = normalizeList(search.exclude, "exclude");
|
|
230
|
+
const contradiction = required.find((tag) => excluded.includes(tag));
|
|
231
|
+
if (contradiction) {
|
|
232
|
+
throw invalidInput(
|
|
233
|
+
`'${contradiction}' is both required and excluded, so no post can match.`,
|
|
234
|
+
"Remove it from one of the two lists."
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return { required, alternatives, excluded };
|
|
238
|
+
}
|
|
239
|
+
function buildSearchQuery(search) {
|
|
240
|
+
const { required, alternatives, excluded } = normalizeSearch(search);
|
|
241
|
+
const tokens = [...required];
|
|
242
|
+
if (alternatives.length === 1) {
|
|
243
|
+
tokens.push(...alternatives);
|
|
244
|
+
} else if (alternatives.length > 1) {
|
|
245
|
+
tokens.push(`( ${alternatives.join(" ~ ")} )`);
|
|
246
|
+
}
|
|
247
|
+
tokens.push(...excluded.map((tag) => `-${tag}`), ...MEDIA_TOKENS[search.mediaType ?? "any"]);
|
|
248
|
+
if (search.rating) {
|
|
249
|
+
tokens.push(`rating:${search.rating}`);
|
|
250
|
+
}
|
|
251
|
+
if (search.sort) {
|
|
252
|
+
tokens.push(SORT_TOKENS[search.sort]);
|
|
253
|
+
}
|
|
254
|
+
return tokens.join(" ");
|
|
255
|
+
}
|
|
256
|
+
function buildPostSearchUrl(search, credentials) {
|
|
257
|
+
if (!Number.isInteger(search.limit) || search.limit < 1 || search.limit > MAX_LIMIT) {
|
|
258
|
+
throw invalidInput(
|
|
259
|
+
`limit must be a whole number between 1 and ${MAX_LIMIT}, and ${search.limit} is not.`,
|
|
260
|
+
`rule34.xxx serves at most ${MAX_LIMIT} posts in one request.`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (!Number.isInteger(search.page) || search.page < 1) {
|
|
264
|
+
throw invalidInput(`page must be a whole number from 1 upwards, and ${search.page} is not.`);
|
|
265
|
+
}
|
|
266
|
+
const query = encodeURIComponent(buildSearchQuery(search));
|
|
267
|
+
const pid = search.page - 1;
|
|
268
|
+
return `${API_URL}?page=dapi&s=post&q=index&tags=${query}&limit=${search.limit}&pid=${pid}&api_key=${encodeURIComponent(credentials.apiKey)}&user_id=${encodeURIComponent(credentials.userId)}`;
|
|
269
|
+
}
|
|
270
|
+
function buildTagLookupUrl(tag, credentials) {
|
|
271
|
+
return `${API_URL}?page=dapi&s=tag&q=index&name=${encodeURIComponent(normalizeTag(tag))}&api_key=${encodeURIComponent(credentials.apiKey)}&user_id=${encodeURIComponent(credentials.userId)}`;
|
|
272
|
+
}
|
|
273
|
+
function readPostPageUrl(raw) {
|
|
274
|
+
let parsed;
|
|
275
|
+
try {
|
|
276
|
+
parsed = new URL(raw.trim());
|
|
277
|
+
} catch {
|
|
278
|
+
throw invalidInput(
|
|
279
|
+
`"${raw}" is not a URL.`,
|
|
280
|
+
"Pass a link to a post's page, such as https://rule34.xxx/index.php?page=post&s=view&id=2195419"
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const host = parsed.hostname.toLowerCase();
|
|
284
|
+
if (host !== "rule34.xxx" && !host.endsWith(".rule34.xxx")) {
|
|
285
|
+
throw invalidInput(
|
|
286
|
+
`"${raw}" is not a rule34.xxx address.`,
|
|
287
|
+
"This tool reads posts on rule34.xxx. A link elsewhere names no post it holds."
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (parsed.searchParams.get("page") !== "post" || parsed.searchParams.get("s") !== "view") {
|
|
291
|
+
throw invalidInput(
|
|
292
|
+
`"${raw}" is a rule34.xxx address, but not a post's page.`,
|
|
293
|
+
"A post's page looks like https://rule34.xxx/index.php?page=post&s=view&id=2195419"
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
const id = Number(parsed.searchParams.get("id"));
|
|
297
|
+
if (!Number.isInteger(id) || id < 1) {
|
|
298
|
+
throw invalidInput(`"${raw}" carries no post id.`);
|
|
299
|
+
}
|
|
300
|
+
return id;
|
|
301
|
+
}
|
|
302
|
+
function resolvePostRef(ref) {
|
|
303
|
+
const fromUrl = ref.url === void 0 ? null : readPostPageUrl(ref.url);
|
|
304
|
+
const fromId = ref.id;
|
|
305
|
+
if (fromId !== void 0) {
|
|
306
|
+
if (!Number.isInteger(fromId) || fromId < 1) {
|
|
307
|
+
throw invalidInput(`id must be a whole number from 1 upwards, and ${fromId} is not.`);
|
|
308
|
+
}
|
|
309
|
+
if (fromUrl !== null && fromUrl !== fromId) {
|
|
310
|
+
throw invalidInput(
|
|
311
|
+
`'id' names post ${fromId} and 'url' names post ${fromUrl}, so they cannot both be read.`,
|
|
312
|
+
"Pass one of the two, or make them agree."
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return fromId;
|
|
316
|
+
}
|
|
317
|
+
if (fromUrl !== null) {
|
|
318
|
+
return fromUrl;
|
|
319
|
+
}
|
|
320
|
+
throw invalidInput(
|
|
321
|
+
"Either 'id' or 'url' must be given.",
|
|
322
|
+
"Pass the numeric post id, or a link to the post's page on rule34.xxx."
|
|
323
|
+
);
|
|
324
|
+
}
|
|
325
|
+
function buildPostByIdXmlUrl(id, credentials) {
|
|
326
|
+
return `${API_URL}?page=dapi&s=post&q=index&id=${id}${credentialsQuery(credentials)}`;
|
|
327
|
+
}
|
|
328
|
+
function buildPostByIdJsonUrl(id, credentials) {
|
|
329
|
+
return `${API_URL}?page=dapi&s=post&q=index&id=${id}&json=1&fields=tag_info${credentialsQuery(credentials)}`;
|
|
330
|
+
}
|
|
331
|
+
function credentialsQuery(credentials) {
|
|
332
|
+
return `&api_key=${encodeURIComponent(credentials.apiKey)}&user_id=${encodeURIComponent(credentials.userId)}`;
|
|
333
|
+
}
|
|
334
|
+
function buildPostPageUrl(id) {
|
|
335
|
+
return `${SITE_URL}?page=post&s=view&id=${id}`;
|
|
336
|
+
}
|
|
337
|
+
function redactCredentials(url) {
|
|
338
|
+
return url.replace(/([?&]api_key=)[^&]*/gi, "$1REDACTED");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// src/rule34/http.ts
|
|
342
|
+
var BACKOFF_BASE_MS = 2e3;
|
|
343
|
+
var BACKOFF_FACTOR = 2;
|
|
344
|
+
var BACKOFF_MAX_MS = 3e4;
|
|
345
|
+
function backoffDelay(attempt, random = Math.random) {
|
|
346
|
+
const uncapped = BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt;
|
|
347
|
+
const capped = Math.min(BACKOFF_MAX_MS, uncapped);
|
|
348
|
+
return Math.round(capped * (0.5 + random() * 0.5));
|
|
349
|
+
}
|
|
350
|
+
async function fetchXml(url, deps) {
|
|
351
|
+
const { config, limiter, logger } = deps;
|
|
352
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
353
|
+
const safeUrl = redactCredentials(url);
|
|
354
|
+
return await limiter.schedule(async () => {
|
|
355
|
+
let lastError;
|
|
356
|
+
let askedWaitMs = null;
|
|
357
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
|
|
358
|
+
if (attempt > 0) {
|
|
359
|
+
const delay = Math.min(askedWaitMs ?? backoffDelay(attempt - 1), BACKOFF_MAX_MS);
|
|
360
|
+
askedWaitMs = null;
|
|
361
|
+
logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${safeUrl}`);
|
|
362
|
+
await sleep(delay);
|
|
363
|
+
}
|
|
364
|
+
let status;
|
|
365
|
+
let body;
|
|
366
|
+
let retryAfterMs = null;
|
|
367
|
+
try {
|
|
368
|
+
await limiter.beforeRequest();
|
|
369
|
+
const response = await doFetch(url, {
|
|
370
|
+
headers: {
|
|
371
|
+
"User-Agent": config.userAgent,
|
|
372
|
+
Accept: "application/xml,text/xml;q=0.9,*/*;q=0.8"
|
|
373
|
+
},
|
|
374
|
+
redirect: "follow",
|
|
375
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
376
|
+
});
|
|
377
|
+
status = response.status;
|
|
378
|
+
retryAfterMs = parseRetryAfter(response.headers.get("retry-after"));
|
|
379
|
+
body = await response.text();
|
|
380
|
+
} catch (error) {
|
|
381
|
+
lastError = asTransportError(error, safeUrl);
|
|
382
|
+
logger.debug(`${lastError.code} for ${safeUrl}: ${lastError.message}`);
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
if (status === 429) {
|
|
386
|
+
limiter.penalize();
|
|
387
|
+
logger.info(`rate limited on ${safeUrl}, interval now ${limiter.currentIntervalMs}ms`);
|
|
388
|
+
askedWaitMs = retryAfterMs;
|
|
389
|
+
lastError = rateLimited(safeUrl, retryAfterMs ?? backoffDelay(attempt));
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (status === 404) {
|
|
393
|
+
throw notFound(safeUrl);
|
|
394
|
+
}
|
|
395
|
+
if (status >= 500) {
|
|
396
|
+
lastError = new Rule34Error("network_error", `rule34.xxx returned HTTP ${status}.`, {
|
|
397
|
+
url: safeUrl,
|
|
398
|
+
status
|
|
399
|
+
});
|
|
400
|
+
continue;
|
|
401
|
+
}
|
|
402
|
+
if (status >= 400) {
|
|
403
|
+
throw new Rule34Error("network_error", `rule34.xxx refused the request (HTTP ${status}).`, {
|
|
404
|
+
url: safeUrl,
|
|
405
|
+
status
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
limiter.relax();
|
|
409
|
+
return body;
|
|
410
|
+
}
|
|
411
|
+
throw lastError ?? new Rule34Error("network_error", `Could not fetch ${safeUrl}.`, {
|
|
412
|
+
url: safeUrl
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
function parseRetryAfter(raw) {
|
|
417
|
+
if (!raw) {
|
|
418
|
+
return null;
|
|
419
|
+
}
|
|
420
|
+
const seconds = Number(raw.trim());
|
|
421
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
422
|
+
return Math.round(seconds * 1e3);
|
|
423
|
+
}
|
|
424
|
+
const when = Date.parse(raw);
|
|
425
|
+
if (Number.isNaN(when)) {
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
428
|
+
return Math.max(0, when - Date.now());
|
|
429
|
+
}
|
|
430
|
+
function asTransportError(error, safeUrl) {
|
|
431
|
+
if (error instanceof Rule34Error) {
|
|
432
|
+
return error;
|
|
433
|
+
}
|
|
434
|
+
const name = error instanceof Error ? error.name : "";
|
|
435
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
436
|
+
return new Rule34Error("timeout", "rule34.xxx did not answer in time.", {
|
|
437
|
+
url: safeUrl,
|
|
438
|
+
hint: "Raise RULE34_TIMEOUT_MS if this happens often on a slow connection."
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
442
|
+
return new Rule34Error("network_error", `Could not reach rule34.xxx: ${message}`, {
|
|
443
|
+
url: safeUrl
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// src/rule34/parsers/postDetail.ts
|
|
448
|
+
var TYPES = {
|
|
449
|
+
tag: "general",
|
|
450
|
+
artist: "artist",
|
|
451
|
+
copyright: "copyright",
|
|
452
|
+
character: "character",
|
|
453
|
+
metadata: "metadata"
|
|
454
|
+
};
|
|
455
|
+
function parsePostDetail(body, url, id) {
|
|
456
|
+
const safeUrl = redactCredentials(url);
|
|
457
|
+
if (body.trim() === "") {
|
|
458
|
+
throw notFound(safeUrl);
|
|
459
|
+
}
|
|
460
|
+
let parsed;
|
|
461
|
+
try {
|
|
462
|
+
parsed = JSON.parse(body);
|
|
463
|
+
} catch {
|
|
464
|
+
throw parseFailure(safeUrl, "the response is not JSON");
|
|
465
|
+
}
|
|
466
|
+
if (typeof parsed === "string") {
|
|
467
|
+
if (/authenticat/i.test(parsed)) {
|
|
468
|
+
throw missingCredentials(safeUrl);
|
|
469
|
+
}
|
|
470
|
+
throw upstreamFailure(safeUrl, parsed);
|
|
471
|
+
}
|
|
472
|
+
if (!Array.isArray(parsed)) {
|
|
473
|
+
throw parseFailure(safeUrl, "the response is not a list of posts");
|
|
474
|
+
}
|
|
475
|
+
const [raw] = parsed;
|
|
476
|
+
if (raw === void 0) {
|
|
477
|
+
throw notFound(safeUrl);
|
|
478
|
+
}
|
|
479
|
+
if (raw === null || typeof raw !== "object") {
|
|
480
|
+
throw parseFailure(safeUrl, "the first entry is not a post");
|
|
481
|
+
}
|
|
482
|
+
return readPost(raw, id);
|
|
483
|
+
}
|
|
484
|
+
function readPost(raw, askedFor) {
|
|
485
|
+
const id = readInteger(raw.id) ?? askedFor;
|
|
486
|
+
const tags = readString(raw.tags).split(/\s+/).filter((tag) => tag !== "");
|
|
487
|
+
return {
|
|
488
|
+
id,
|
|
489
|
+
postUrl: buildPostPageUrl(id),
|
|
490
|
+
fileUrl: readString(raw.file_url),
|
|
491
|
+
sampleUrl: readText(raw.sample_url),
|
|
492
|
+
previewUrl: readText(raw.preview_url),
|
|
493
|
+
width: readInteger(raw.width) ?? 0,
|
|
494
|
+
height: readInteger(raw.height) ?? 0,
|
|
495
|
+
md5: readString(raw.hash),
|
|
496
|
+
score: readInteger(raw.score) ?? 0,
|
|
497
|
+
rating: readString(raw.rating),
|
|
498
|
+
tags,
|
|
499
|
+
tagDetails: readTagDetails(raw.tag_info),
|
|
500
|
+
source: readText(raw.source),
|
|
501
|
+
// This route writes 0 where the XML writes an empty string, and 0 is a post
|
|
502
|
+
// id that belongs to somebody else.
|
|
503
|
+
parentId: nonZero(readInteger(raw.parent_id)),
|
|
504
|
+
owner: readText(raw.owner),
|
|
505
|
+
commentCount: readInteger(raw.comment_count),
|
|
506
|
+
status: readString(raw.status),
|
|
507
|
+
hasNotes: raw.has_notes === true,
|
|
508
|
+
changedAtUnix: readInteger(raw.change)
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
function readTagDetails(value) {
|
|
512
|
+
if (!Array.isArray(value)) {
|
|
513
|
+
return [];
|
|
514
|
+
}
|
|
515
|
+
return value.map((entry) => {
|
|
516
|
+
const info = entry ?? {};
|
|
517
|
+
const type = readString(info.type);
|
|
518
|
+
return {
|
|
519
|
+
name: readString(info.tag),
|
|
520
|
+
// A word this site has not documented is carried as it came: naming it
|
|
521
|
+
// something else would state a kind the site never wrote.
|
|
522
|
+
type: TYPES[type] ?? type,
|
|
523
|
+
postCount: readInteger(info.count) ?? 0
|
|
524
|
+
};
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
function nonZero(value) {
|
|
528
|
+
return value === null || value === 0 ? null : value;
|
|
529
|
+
}
|
|
530
|
+
function readString(value) {
|
|
531
|
+
return typeof value === "string" ? value : "";
|
|
532
|
+
}
|
|
533
|
+
function readText(value) {
|
|
534
|
+
const trimmed = readString(value).trim();
|
|
535
|
+
return trimmed === "" ? null : trimmed;
|
|
536
|
+
}
|
|
537
|
+
function readInteger(value) {
|
|
538
|
+
if (typeof value === "number") {
|
|
539
|
+
return Number.isFinite(value) ? value : null;
|
|
540
|
+
}
|
|
541
|
+
const trimmed = readString(value).trim();
|
|
542
|
+
if (trimmed === "") {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
const parsed = Number(trimmed);
|
|
546
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// src/rule34/parsers/document.ts
|
|
550
|
+
import { XMLParser } from "fast-xml-parser";
|
|
551
|
+
var parser = new XMLParser({
|
|
552
|
+
ignoreAttributes: false,
|
|
553
|
+
attributeNamePrefix: "@_",
|
|
554
|
+
// Attribute values are converted one field at a time by each parser, so that
|
|
555
|
+
// an empty `parent_id` stays empty instead of becoming a number.
|
|
556
|
+
parseAttributeValue: false,
|
|
557
|
+
trimValues: true
|
|
558
|
+
});
|
|
559
|
+
function readDocument(xml, safeUrl) {
|
|
560
|
+
if (xml.trim() === "") {
|
|
561
|
+
throw parseFailure(safeUrl, "the response was empty");
|
|
562
|
+
}
|
|
563
|
+
let document;
|
|
564
|
+
try {
|
|
565
|
+
document = parser.parse(xml);
|
|
566
|
+
} catch (error) {
|
|
567
|
+
throw parseFailure(safeUrl, error instanceof Error ? error.message : "unreadable XML");
|
|
568
|
+
}
|
|
569
|
+
if (document === null || typeof document !== "object") {
|
|
570
|
+
throw parseFailure(safeUrl, "the response is not an XML document");
|
|
571
|
+
}
|
|
572
|
+
const parsed = document;
|
|
573
|
+
const failure = parsed.error;
|
|
574
|
+
if (failure !== void 0) {
|
|
575
|
+
const message = typeof failure === "string" ? failure : String(failure);
|
|
576
|
+
if (/authenticat/i.test(message)) {
|
|
577
|
+
throw missingCredentials(safeUrl);
|
|
578
|
+
}
|
|
579
|
+
throw upstreamFailure(safeUrl, message);
|
|
580
|
+
}
|
|
581
|
+
return parsed;
|
|
582
|
+
}
|
|
583
|
+
function toArray(value) {
|
|
584
|
+
if (value === void 0 || value === null) {
|
|
585
|
+
return [];
|
|
586
|
+
}
|
|
587
|
+
return Array.isArray(value) ? value : [value];
|
|
588
|
+
}
|
|
589
|
+
function readText2(value) {
|
|
590
|
+
const trimmed = value?.trim() ?? "";
|
|
591
|
+
return trimmed === "" ? null : trimmed;
|
|
592
|
+
}
|
|
593
|
+
function readInteger2(value) {
|
|
594
|
+
const trimmed = value?.trim() ?? "";
|
|
595
|
+
if (trimmed === "") {
|
|
596
|
+
return null;
|
|
597
|
+
}
|
|
598
|
+
const parsed = Number(trimmed);
|
|
599
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// src/rule34/parsers/posts.ts
|
|
603
|
+
var RATINGS = {
|
|
604
|
+
e: "explicit",
|
|
605
|
+
q: "questionable",
|
|
606
|
+
s: "safe"
|
|
607
|
+
};
|
|
608
|
+
function parsePostList(xml, url) {
|
|
609
|
+
const safeUrl = redactCredentials(url);
|
|
610
|
+
const document = readDocument(xml, safeUrl);
|
|
611
|
+
const posts = document.posts;
|
|
612
|
+
if (posts === void 0 || posts === null || typeof posts !== "object") {
|
|
613
|
+
throw parseFailure(safeUrl, "no <posts> element");
|
|
614
|
+
}
|
|
615
|
+
const attributes = posts;
|
|
616
|
+
const total = readInteger2(attributes["@_count"]);
|
|
617
|
+
if (total === null) {
|
|
618
|
+
throw parseFailure(safeUrl, "the <posts> element carries no count");
|
|
619
|
+
}
|
|
620
|
+
return {
|
|
621
|
+
total,
|
|
622
|
+
offset: readInteger2(attributes["@_offset"]) ?? 0,
|
|
623
|
+
posts: toArray(attributes.post).map(readPost2)
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
function readPost2(attributes) {
|
|
627
|
+
const id = readInteger2(attributes["@_id"]) ?? 0;
|
|
628
|
+
const rating = attributes["@_rating"]?.trim() ?? "";
|
|
629
|
+
return {
|
|
630
|
+
id,
|
|
631
|
+
postUrl: buildPostPageUrl(id),
|
|
632
|
+
fileUrl: attributes["@_file_url"] ?? "",
|
|
633
|
+
sampleUrl: readText2(attributes["@_sample_url"]),
|
|
634
|
+
previewUrl: readText2(attributes["@_preview_url"]),
|
|
635
|
+
width: readInteger2(attributes["@_width"]) ?? 0,
|
|
636
|
+
height: readInteger2(attributes["@_height"]) ?? 0,
|
|
637
|
+
md5: attributes["@_md5"] ?? "",
|
|
638
|
+
score: readInteger2(attributes["@_score"]) ?? 0,
|
|
639
|
+
// An unknown letter is carried as it came: naming it would state a rating
|
|
640
|
+
// the site never wrote.
|
|
641
|
+
rating: RATINGS[rating] ?? rating,
|
|
642
|
+
tags: (attributes["@_tags"] ?? "").split(/\s+/).filter((tag) => tag !== ""),
|
|
643
|
+
source: readText2(attributes["@_source"]),
|
|
644
|
+
// The attribute is present and empty on a post with no parent, and 0 is a
|
|
645
|
+
// post id that belongs to somebody else.
|
|
646
|
+
parentId: readInteger2(attributes["@_parent_id"]),
|
|
647
|
+
creatorId: readInteger2(attributes["@_creator_id"]),
|
|
648
|
+
status: attributes["@_status"] ?? "",
|
|
649
|
+
hasChildren: attributes["@_has_children"] === "true",
|
|
650
|
+
hasComments: attributes["@_has_comments"] === "true",
|
|
651
|
+
hasNotes: attributes["@_has_notes"] === "true",
|
|
652
|
+
createdAt: readDate(attributes["@_created_at"]),
|
|
653
|
+
changedAtUnix: readInteger2(attributes["@_change"])
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
function readDate(value) {
|
|
657
|
+
const trimmed = value?.trim() ?? "";
|
|
658
|
+
if (trimmed === "") {
|
|
659
|
+
return null;
|
|
660
|
+
}
|
|
661
|
+
const parsed = Date.parse(trimmed);
|
|
662
|
+
return Number.isNaN(parsed) ? null : new Date(parsed).toISOString();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
// src/rule34/parsers/tags.ts
|
|
666
|
+
var TYPES2 = {
|
|
667
|
+
0: "general",
|
|
668
|
+
1: "artist",
|
|
669
|
+
3: "copyright",
|
|
670
|
+
4: "character",
|
|
671
|
+
5: "metadata"
|
|
672
|
+
};
|
|
673
|
+
function parseTagList(xml, url) {
|
|
674
|
+
const safeUrl = redactCredentials(url);
|
|
675
|
+
const document = readDocument(xml, safeUrl);
|
|
676
|
+
const tags = document.tags;
|
|
677
|
+
if (tags !== void 0 && tags !== null && typeof tags === "object") {
|
|
678
|
+
return toArray(tags.tag).map(readTag);
|
|
679
|
+
}
|
|
680
|
+
const lone = document.tag;
|
|
681
|
+
if (lone !== void 0 && lone !== null && typeof lone === "object") {
|
|
682
|
+
const attributes = lone;
|
|
683
|
+
return attributes["@_name"] === void 0 ? [] : [readTag(attributes)];
|
|
684
|
+
}
|
|
685
|
+
throw parseFailure(safeUrl, "no <tags> element");
|
|
686
|
+
}
|
|
687
|
+
function readTag(attributes) {
|
|
688
|
+
const typeCode = readInteger2(attributes["@_type"]);
|
|
689
|
+
return {
|
|
690
|
+
id: readInteger2(attributes["@_id"]) ?? 0,
|
|
691
|
+
name: attributes["@_name"] ?? "",
|
|
692
|
+
postCount: readInteger2(attributes["@_count"]) ?? 0,
|
|
693
|
+
// A number this site has not documented is left unnamed: calling it
|
|
694
|
+
// something would state a kind the site never wrote.
|
|
695
|
+
type: (typeCode === null ? void 0 : TYPES2[typeCode]) ?? "unknown",
|
|
696
|
+
typeCode,
|
|
697
|
+
ambiguous: attributes["@_ambiguous"] === "true"
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
// src/rule34/client.ts
|
|
702
|
+
var Rule34Client = class {
|
|
703
|
+
constructor(deps) {
|
|
704
|
+
this.deps = deps;
|
|
705
|
+
this.limiter = new RateLimiter({ minIntervalMs: deps.config.minIntervalMs });
|
|
706
|
+
this.cache = new TtlLruCache(deps.config.cacheMaxEntries, deps.config.cacheTtlMs);
|
|
707
|
+
}
|
|
708
|
+
deps;
|
|
709
|
+
limiter;
|
|
710
|
+
cache;
|
|
711
|
+
/** The addresses currently held, for a diagnostic to look at. */
|
|
712
|
+
cacheKeys() {
|
|
713
|
+
return this.cache.keys();
|
|
714
|
+
}
|
|
715
|
+
async searchPosts(search) {
|
|
716
|
+
const url = buildPostSearchUrl(search, this.credentials());
|
|
717
|
+
const { body, cached } = await this.read(url);
|
|
718
|
+
return { data: parsePostList(body, url), cached };
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* One post, read from both formats the API publishes.
|
|
722
|
+
*
|
|
723
|
+
* Neither format carries everything: the publication date and the uploader's
|
|
724
|
+
* numeric id come from the XML, and the uploader's name, the comment count
|
|
725
|
+
* and the tag types come from the JSON. Reading one of them would leave out
|
|
726
|
+
* what the other holds, so a fiche costs two reads of the site.
|
|
727
|
+
*/
|
|
728
|
+
async getPost(ref) {
|
|
729
|
+
const credentials = this.credentials();
|
|
730
|
+
const id = resolvePostRef(ref);
|
|
731
|
+
const jsonUrl = buildPostByIdJsonUrl(id, credentials);
|
|
732
|
+
const json = await this.read(jsonUrl);
|
|
733
|
+
const detail = parsePostDetail(json.body, jsonUrl, id);
|
|
734
|
+
const xmlUrl = buildPostByIdXmlUrl(id, credentials);
|
|
735
|
+
const xml = await this.read(xmlUrl);
|
|
736
|
+
const [fromXml] = parsePostList(xml.body, xmlUrl).posts;
|
|
737
|
+
return {
|
|
738
|
+
data: {
|
|
739
|
+
...detail,
|
|
740
|
+
createdAt: fromXml?.createdAt ?? null,
|
|
741
|
+
creatorId: fromXml?.creatorId ?? null,
|
|
742
|
+
hasComments: fromXml?.hasComments ?? (detail.commentCount ?? 0) > 0,
|
|
743
|
+
hasChildren: fromXml?.hasChildren ?? false
|
|
744
|
+
},
|
|
745
|
+
cached: json.cached && xml.cached
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
/** The tag under this exact name, or nothing when the site holds no such tag. */
|
|
749
|
+
async lookupTag(name) {
|
|
750
|
+
const url = buildTagLookupUrl(name, this.credentials());
|
|
751
|
+
const { body, cached } = await this.read(url);
|
|
752
|
+
const [tag] = parseTagList(body, url);
|
|
753
|
+
return { data: tag ?? null, cached };
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* Which of these names the site does not hold as a tag.
|
|
757
|
+
*
|
|
758
|
+
* A search of several tags that finds nothing says nothing about which tag
|
|
759
|
+
* emptied it, and a misspelled name is indistinguishable from a combination
|
|
760
|
+
* the site genuinely has nothing for. Each distinct name costs one request,
|
|
761
|
+
* which is why this is worth spending only once a search has come back empty.
|
|
762
|
+
*/
|
|
763
|
+
async findUnknownTags(names) {
|
|
764
|
+
const distinct = [...new Set(names.map(normalizeTag))];
|
|
765
|
+
const unknown = [];
|
|
766
|
+
for (const name of distinct) {
|
|
767
|
+
const { data } = await this.lookupTag(name);
|
|
768
|
+
if (data === null) {
|
|
769
|
+
unknown.push(name);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return unknown;
|
|
773
|
+
}
|
|
774
|
+
credentials() {
|
|
775
|
+
const credentials = this.deps.config.credentials;
|
|
776
|
+
if (!credentials) {
|
|
777
|
+
throw invalidInput(
|
|
778
|
+
"This server has no rule34.xxx API credentials, so it cannot ask the site anything.",
|
|
779
|
+
"Set RULE34_USER_ID and RULE34_API_KEY in the env block of this server's entry in your MCP client configuration. Both are shown under 'API Access Credentials' at https://rule34.xxx/index.php?page=account&s=options once you are signed in. The site issues one key per person, so use your own rather than sharing one."
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
return credentials;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Fetch one address, remembering the answer under it.
|
|
786
|
+
*
|
|
787
|
+
* The key is the address without the key in it: two searches differ by their
|
|
788
|
+
* query and never by their credentials, and holding the secret in a second
|
|
789
|
+
* place buys nothing.
|
|
790
|
+
*/
|
|
791
|
+
async read(url) {
|
|
792
|
+
const key = redactCredentials(url);
|
|
793
|
+
const remembered = this.cache.get(key);
|
|
794
|
+
if (remembered !== void 0) {
|
|
795
|
+
this.deps.logger.debug(`cache hit for ${key}`);
|
|
796
|
+
return { body: remembered, cached: true };
|
|
797
|
+
}
|
|
798
|
+
const body = await fetchXml(url, {
|
|
799
|
+
config: this.deps.config,
|
|
800
|
+
limiter: this.limiter,
|
|
801
|
+
logger: this.deps.logger,
|
|
802
|
+
fetchImpl: this.deps.fetchImpl
|
|
803
|
+
});
|
|
804
|
+
this.cache.set(key, body);
|
|
805
|
+
return { body, cached: false };
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
export {
|
|
809
|
+
Rule34Client
|
|
810
|
+
};
|
|
811
|
+
//# sourceMappingURL=client.js.map
|