mcp-marmiton 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 +25 -0
- package/README.md +310 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1334 -0
- package/dist/index.js.map +1 -0
- package/dist/marmiton/client.d.ts +122 -0
- package/dist/marmiton/client.js +631 -0
- package/dist/marmiton/client.js.map +1 -0
- package/package.json +82 -0
- package/server.json +35 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
// src/version.ts
|
|
2
|
+
var PKG_VERSION = "0.1.0";
|
|
3
|
+
var REPO_URL = "https://github.com/smeet666/mcp-marmiton";
|
|
4
|
+
|
|
5
|
+
// src/config.ts
|
|
6
|
+
var DEFAULT_USER_AGENT = `mcp-marmiton v${PKG_VERSION} (${REPO_URL})`;
|
|
7
|
+
var DEFAULTS = {
|
|
8
|
+
minIntervalMs: 1e3,
|
|
9
|
+
timeoutMs: 15e3,
|
|
10
|
+
maxRetries: 3,
|
|
11
|
+
cacheTtlMs: 15 * 60 * 1e3,
|
|
12
|
+
cacheMaxEntries: 200,
|
|
13
|
+
logLevel: "error"
|
|
14
|
+
};
|
|
15
|
+
var MIN_ALLOWED_INTERVAL_MS = 500;
|
|
16
|
+
var LOG_LEVELS = ["silent", "error", "info", "debug"];
|
|
17
|
+
function readNumber(name, env, range) {
|
|
18
|
+
const raw = env[name];
|
|
19
|
+
if (raw === void 0 || raw.trim() === "") return range.fallback;
|
|
20
|
+
const parsed = Number(raw);
|
|
21
|
+
if (!Number.isFinite(parsed)) {
|
|
22
|
+
warn(`${name}="${raw}" is not a number, using ${range.fallback}`);
|
|
23
|
+
return range.fallback;
|
|
24
|
+
}
|
|
25
|
+
const rounded = Math.round(parsed);
|
|
26
|
+
if (rounded < range.min || rounded > range.max) {
|
|
27
|
+
warn(
|
|
28
|
+
`${name}=${raw} is outside the accepted range ${range.min}-${range.max} and was ignored; using ${range.fallback}`
|
|
29
|
+
);
|
|
30
|
+
return range.fallback;
|
|
31
|
+
}
|
|
32
|
+
return rounded;
|
|
33
|
+
}
|
|
34
|
+
function warn(message) {
|
|
35
|
+
process.stderr.write(`[mcp-marmiton] ${message}
|
|
36
|
+
`);
|
|
37
|
+
}
|
|
38
|
+
function readInterval(env) {
|
|
39
|
+
const raw = env.MARMITON_MIN_INTERVAL_MS;
|
|
40
|
+
if (raw === void 0 || raw.trim() === "") return DEFAULTS.minIntervalMs;
|
|
41
|
+
const parsed = Number(raw);
|
|
42
|
+
if (!Number.isFinite(parsed)) {
|
|
43
|
+
warn(`MARMITON_MIN_INTERVAL_MS="${raw}" is not a number, using ${DEFAULTS.minIntervalMs}ms`);
|
|
44
|
+
return DEFAULTS.minIntervalMs;
|
|
45
|
+
}
|
|
46
|
+
const rounded = Math.round(parsed);
|
|
47
|
+
if (rounded < MIN_ALLOWED_INTERVAL_MS) {
|
|
48
|
+
warn(
|
|
49
|
+
`MARMITON_MIN_INTERVAL_MS=${raw} is below the ${MIN_ALLOWED_INTERVAL_MS}ms floor and was ignored; using ${DEFAULTS.minIntervalMs}ms. Marmiton serves these pages for readers, so this client paces itself.`
|
|
50
|
+
);
|
|
51
|
+
return DEFAULTS.minIntervalMs;
|
|
52
|
+
}
|
|
53
|
+
return Math.min(6e4, rounded);
|
|
54
|
+
}
|
|
55
|
+
function loadConfig(env = process.env) {
|
|
56
|
+
const rawUserAgent = env.MARMITON_USER_AGENT?.trim();
|
|
57
|
+
const rawLogLevel = env.MARMITON_LOG_LEVEL?.trim().toLowerCase();
|
|
58
|
+
let logLevel = DEFAULTS.logLevel;
|
|
59
|
+
if (rawLogLevel) {
|
|
60
|
+
if (LOG_LEVELS.includes(rawLogLevel)) {
|
|
61
|
+
logLevel = rawLogLevel;
|
|
62
|
+
} else {
|
|
63
|
+
warn(`MARMITON_LOG_LEVEL="${rawLogLevel}" is unknown, using "${DEFAULTS.logLevel}"`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
userAgent: rawUserAgent || DEFAULT_USER_AGENT,
|
|
68
|
+
minIntervalMs: readInterval(env),
|
|
69
|
+
timeoutMs: readNumber("MARMITON_TIMEOUT_MS", env, {
|
|
70
|
+
min: 1e3,
|
|
71
|
+
max: 12e4,
|
|
72
|
+
fallback: DEFAULTS.timeoutMs
|
|
73
|
+
}),
|
|
74
|
+
maxRetries: readNumber("MARMITON_MAX_RETRIES", env, {
|
|
75
|
+
min: 0,
|
|
76
|
+
max: 10,
|
|
77
|
+
fallback: DEFAULTS.maxRetries
|
|
78
|
+
}),
|
|
79
|
+
cacheTtlMs: readNumber("MARMITON_CACHE_TTL_MS", env, {
|
|
80
|
+
min: 0,
|
|
81
|
+
max: 24 * 60 * 60 * 1e3,
|
|
82
|
+
fallback: DEFAULTS.cacheTtlMs
|
|
83
|
+
}),
|
|
84
|
+
cacheMaxEntries: readNumber("MARMITON_CACHE_MAX_ENTRIES", env, {
|
|
85
|
+
min: 0,
|
|
86
|
+
max: 1e4,
|
|
87
|
+
fallback: DEFAULTS.cacheMaxEntries
|
|
88
|
+
}),
|
|
89
|
+
logLevel
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
var LEVEL_RANK = { silent: 0, error: 1, info: 2, debug: 3 };
|
|
93
|
+
function createLogger(level) {
|
|
94
|
+
const emit = (at, message) => {
|
|
95
|
+
if (LEVEL_RANK[level] >= LEVEL_RANK[at]) {
|
|
96
|
+
process.stderr.write(`[mcp-marmiton] ${message}
|
|
97
|
+
`);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
return {
|
|
101
|
+
error: (message) => emit("error", message),
|
|
102
|
+
info: (message) => emit("info", message),
|
|
103
|
+
debug: (message) => emit("debug", message)
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// src/marmiton/cache.ts
|
|
108
|
+
var TtlLruCache = class {
|
|
109
|
+
constructor(maxEntries, ttlMs) {
|
|
110
|
+
this.maxEntries = maxEntries;
|
|
111
|
+
this.ttlMs = ttlMs;
|
|
112
|
+
}
|
|
113
|
+
maxEntries;
|
|
114
|
+
ttlMs;
|
|
115
|
+
store = /* @__PURE__ */ new Map();
|
|
116
|
+
get(key) {
|
|
117
|
+
const entry = this.store.get(key);
|
|
118
|
+
if (!entry) return void 0;
|
|
119
|
+
if (Date.now() >= entry.expiresAt) {
|
|
120
|
+
this.store.delete(key);
|
|
121
|
+
return void 0;
|
|
122
|
+
}
|
|
123
|
+
this.store.delete(key);
|
|
124
|
+
this.store.set(key, entry);
|
|
125
|
+
return entry.value;
|
|
126
|
+
}
|
|
127
|
+
set(key, value) {
|
|
128
|
+
if (this.maxEntries <= 0 || this.ttlMs <= 0) return;
|
|
129
|
+
this.store.delete(key);
|
|
130
|
+
this.store.set(key, { value, expiresAt: Date.now() + this.ttlMs });
|
|
131
|
+
while (this.store.size > this.maxEntries) {
|
|
132
|
+
const oldest = this.store.keys().next();
|
|
133
|
+
if (oldest.done) break;
|
|
134
|
+
this.store.delete(oldest.value);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
get size() {
|
|
138
|
+
return this.store.size;
|
|
139
|
+
}
|
|
140
|
+
clear() {
|
|
141
|
+
this.store.clear();
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// src/errors.ts
|
|
146
|
+
var MarmitonError = class extends Error {
|
|
147
|
+
constructor(code, message, details = {}) {
|
|
148
|
+
super(message);
|
|
149
|
+
this.code = code;
|
|
150
|
+
this.details = details;
|
|
151
|
+
this.name = "MarmitonError";
|
|
152
|
+
}
|
|
153
|
+
code;
|
|
154
|
+
details;
|
|
155
|
+
};
|
|
156
|
+
var ISSUES_URL = "https://github.com/smeet666/mcp-marmiton/issues";
|
|
157
|
+
function notFound(url, what) {
|
|
158
|
+
return new MarmitonError("not_found", `Marmiton has no recipe at ${what}.`, {
|
|
159
|
+
url,
|
|
160
|
+
status: 404,
|
|
161
|
+
hint: "Use search_recipes to find a recipe and its id, then call this tool with that id."
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
function invalidInput(message, hint) {
|
|
165
|
+
return new MarmitonError("invalid_input", message, hint ? { hint } : {});
|
|
166
|
+
}
|
|
167
|
+
function rateLimited(url, retryAfterMs) {
|
|
168
|
+
return new MarmitonError(
|
|
169
|
+
"rate_limited",
|
|
170
|
+
"Marmiton is rate limiting this client. This does NOT mean the recipe does not exist.",
|
|
171
|
+
{
|
|
172
|
+
url,
|
|
173
|
+
retryAfterMs,
|
|
174
|
+
hint: `Wait about ${Math.ceil(retryAfterMs / 1e3)} seconds, then call the same tool again with the same arguments. If it keeps happening, raise MARMITON_MIN_INTERVAL_MS.`
|
|
175
|
+
}
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
function parseFailure(url, what) {
|
|
179
|
+
return new MarmitonError(
|
|
180
|
+
"parse_failure",
|
|
181
|
+
`The page loaded but the expected structured recipe data was not found (${what}). Marmiton may have changed how it publishes it.`,
|
|
182
|
+
{ url, hint: `Please report this, with the recipe you asked for, at ${ISSUES_URL}` }
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
function upstreamError(url, status) {
|
|
186
|
+
return new MarmitonError("network_error", `Marmiton returned HTTP ${status}.`, {
|
|
187
|
+
url,
|
|
188
|
+
status,
|
|
189
|
+
...status >= 500 ? { hint: "This is a problem on Marmiton's side. Try again shortly." } : {}
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/marmiton/rateLimiter.ts
|
|
194
|
+
var DEFAULT_MAX_INTERVAL_MS = 1e4;
|
|
195
|
+
var RateLimiter = class {
|
|
196
|
+
baseIntervalMs;
|
|
197
|
+
maxIntervalMs;
|
|
198
|
+
intervalMs;
|
|
199
|
+
/** Tail of the queue: each task chains onto the previous one. */
|
|
200
|
+
tail = Promise.resolve();
|
|
201
|
+
lastStart = 0;
|
|
202
|
+
constructor(options) {
|
|
203
|
+
this.baseIntervalMs = Math.max(0, options.minIntervalMs);
|
|
204
|
+
this.maxIntervalMs = Math.max(
|
|
205
|
+
this.baseIntervalMs,
|
|
206
|
+
options.maxIntervalMs ?? DEFAULT_MAX_INTERVAL_MS
|
|
207
|
+
);
|
|
208
|
+
this.intervalMs = this.baseIntervalMs;
|
|
209
|
+
}
|
|
210
|
+
get currentIntervalMs() {
|
|
211
|
+
return this.intervalMs;
|
|
212
|
+
}
|
|
213
|
+
/** Queue a task. Tasks run in call order, one at a time. */
|
|
214
|
+
schedule(task) {
|
|
215
|
+
const run = this.tail.then(async () => {
|
|
216
|
+
await this.waitForSlot();
|
|
217
|
+
this.lastStart = Date.now();
|
|
218
|
+
return task();
|
|
219
|
+
});
|
|
220
|
+
this.tail = run.then(
|
|
221
|
+
() => void 0,
|
|
222
|
+
() => void 0
|
|
223
|
+
);
|
|
224
|
+
return run;
|
|
225
|
+
}
|
|
226
|
+
/** Called after the site signals it is under load. */
|
|
227
|
+
penalize() {
|
|
228
|
+
const next = this.intervalMs === 0 ? 250 : this.intervalMs * 2;
|
|
229
|
+
this.intervalMs = Math.min(this.maxIntervalMs, next);
|
|
230
|
+
}
|
|
231
|
+
/** Called after a success, so a single hiccup does not slow things down forever. */
|
|
232
|
+
relax() {
|
|
233
|
+
this.intervalMs = Math.max(this.baseIntervalMs, Math.floor(this.intervalMs * 0.75));
|
|
234
|
+
}
|
|
235
|
+
async waitForSlot() {
|
|
236
|
+
if (this.intervalMs === 0 || this.lastStart === 0) return;
|
|
237
|
+
const elapsed = Date.now() - this.lastStart;
|
|
238
|
+
const remaining = this.intervalMs - elapsed;
|
|
239
|
+
if (remaining > 0) await sleep(remaining);
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
function sleep(ms) {
|
|
243
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// src/marmiton/http.ts
|
|
247
|
+
var BACKOFF_BASE_MS = 2e3;
|
|
248
|
+
var BACKOFF_FACTOR = 2;
|
|
249
|
+
var BACKOFF_MAX_MS = 2e4;
|
|
250
|
+
var MIN_PLAUSIBLE_HTML = 2e3;
|
|
251
|
+
function backoffDelay(attempt, random = Math.random) {
|
|
252
|
+
const capped = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * BACKOFF_FACTOR ** attempt);
|
|
253
|
+
return Math.round(capped * (0.5 + random() * 0.5));
|
|
254
|
+
}
|
|
255
|
+
async function fetchHtml(url, deps) {
|
|
256
|
+
const { config, limiter, logger } = deps;
|
|
257
|
+
const doFetch = deps.fetchImpl ?? fetch;
|
|
258
|
+
return limiter.schedule(async () => {
|
|
259
|
+
let lastError;
|
|
260
|
+
for (let attempt = 0; attempt <= config.maxRetries; attempt += 1) {
|
|
261
|
+
if (attempt > 0) {
|
|
262
|
+
const delay = backoffDelay(attempt - 1);
|
|
263
|
+
logger.info(`retry ${attempt}/${config.maxRetries} in ${delay}ms for ${url}`);
|
|
264
|
+
await sleep(delay);
|
|
265
|
+
}
|
|
266
|
+
let status;
|
|
267
|
+
let body;
|
|
268
|
+
try {
|
|
269
|
+
const response = await doFetch(url, {
|
|
270
|
+
headers: {
|
|
271
|
+
"User-Agent": config.userAgent,
|
|
272
|
+
Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
273
|
+
"Accept-Language": "fr-FR,fr;q=0.9,en;q=0.8"
|
|
274
|
+
},
|
|
275
|
+
redirect: "follow",
|
|
276
|
+
signal: AbortSignal.timeout(config.timeoutMs)
|
|
277
|
+
});
|
|
278
|
+
status = response.status;
|
|
279
|
+
body = await response.text();
|
|
280
|
+
} catch (error) {
|
|
281
|
+
lastError = asTransportError(error, url);
|
|
282
|
+
logger.debug(`${lastError.code} for ${url}: ${lastError.message}`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (status === 429 || status === 503) {
|
|
286
|
+
limiter.penalize();
|
|
287
|
+
lastError = rateLimited(url, backoffDelay(attempt));
|
|
288
|
+
logger.info(`rate limited on ${url}, interval now ${limiter.currentIntervalMs}ms`);
|
|
289
|
+
continue;
|
|
290
|
+
}
|
|
291
|
+
if (status === 404) throw notFound(url, "that address");
|
|
292
|
+
if (status >= 500) {
|
|
293
|
+
lastError = upstreamError(url, status);
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (status >= 400) throw upstreamError(url, status);
|
|
297
|
+
const trimmed = body.trim();
|
|
298
|
+
if (trimmed.length < MIN_PLAUSIBLE_HTML && !/<\/html>/i.test(trimmed)) {
|
|
299
|
+
limiter.penalize();
|
|
300
|
+
lastError = rateLimited(url, backoffDelay(attempt));
|
|
301
|
+
logger.info(`implausibly short body on ${url}, treating as rate limiting`);
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
limiter.relax();
|
|
305
|
+
return body;
|
|
306
|
+
}
|
|
307
|
+
throw lastError ?? new MarmitonError("network_error", `Could not fetch ${url}.`, { url });
|
|
308
|
+
});
|
|
309
|
+
}
|
|
310
|
+
function asTransportError(error, url) {
|
|
311
|
+
if (error instanceof MarmitonError) return error;
|
|
312
|
+
const name = error instanceof Error ? error.name : "";
|
|
313
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
314
|
+
return new MarmitonError("timeout", "Marmiton did not answer in time.", {
|
|
315
|
+
url,
|
|
316
|
+
hint: "Raise MARMITON_TIMEOUT_MS if this happens often on a slow connection."
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
320
|
+
return new MarmitonError("network_error", `Could not reach Marmiton: ${message}`, { url });
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// src/recipe/duration.ts
|
|
324
|
+
var ISO_DURATION = /^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
|
325
|
+
function parseIsoDuration(value) {
|
|
326
|
+
if (typeof value !== "string") return null;
|
|
327
|
+
const match = ISO_DURATION.exec(value.trim().toUpperCase());
|
|
328
|
+
if (!match) return null;
|
|
329
|
+
const [, days, hours, minutes, seconds] = match;
|
|
330
|
+
const total = Number(days ?? 0) * 1440 + Number(hours ?? 0) * 60 + Number(minutes ?? 0) + Number(seconds ?? 0) / 60;
|
|
331
|
+
if (total === 0 && !minutes && !hours && !days && !seconds) return null;
|
|
332
|
+
return Math.round(total);
|
|
333
|
+
}
|
|
334
|
+
function parseYield(value) {
|
|
335
|
+
const text = typeof value === "number" && Number.isFinite(value) ? String(value) : typeof value === "string" ? value.trim() : Array.isArray(value) ? String(value[0] ?? "").trim() : "";
|
|
336
|
+
if (!text) return { count: null, unit: null, text: "" };
|
|
337
|
+
const match = /^(\d+(?:[.,]\d+)?)\s*(.*)$/.exec(text);
|
|
338
|
+
if (!match) return { count: null, unit: null, text };
|
|
339
|
+
const count = Number(match[1].replace(",", "."));
|
|
340
|
+
const unit = (match[2] ?? "").trim() || null;
|
|
341
|
+
return { count: Number.isFinite(count) ? count : null, unit, text };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// src/marmiton/jsonld.ts
|
|
345
|
+
var LD_JSON_BLOCK = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
|
|
346
|
+
function extractJsonLdNodes(html) {
|
|
347
|
+
const nodes = [];
|
|
348
|
+
LD_JSON_BLOCK.lastIndex = 0;
|
|
349
|
+
let match;
|
|
350
|
+
while ((match = LD_JSON_BLOCK.exec(html)) !== null) {
|
|
351
|
+
const raw = match[1]?.trim();
|
|
352
|
+
if (!raw) continue;
|
|
353
|
+
let parsed;
|
|
354
|
+
try {
|
|
355
|
+
parsed = JSON.parse(raw);
|
|
356
|
+
} catch {
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
for (const node of flatten(parsed)) nodes.push(node);
|
|
360
|
+
}
|
|
361
|
+
return nodes;
|
|
362
|
+
}
|
|
363
|
+
function flatten(value) {
|
|
364
|
+
if (Array.isArray(value)) return value.flatMap(flatten);
|
|
365
|
+
if (typeof value !== "object" || value === null) return [];
|
|
366
|
+
const node = value;
|
|
367
|
+
const graph = node["@graph"];
|
|
368
|
+
if (graph !== void 0) return flatten(graph);
|
|
369
|
+
return [node];
|
|
370
|
+
}
|
|
371
|
+
function hasType(node, type) {
|
|
372
|
+
if (typeof node !== "object" || node === null) return false;
|
|
373
|
+
const declared = node["@type"];
|
|
374
|
+
if (typeof declared === "string") return declared === type;
|
|
375
|
+
if (Array.isArray(declared)) return declared.includes(type);
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
function findNodeOfType(nodes, type) {
|
|
379
|
+
const found = nodes.find((node) => hasType(node, type));
|
|
380
|
+
return found ?? null;
|
|
381
|
+
}
|
|
382
|
+
function asString(value) {
|
|
383
|
+
if (typeof value === "string") return value.trim() || null;
|
|
384
|
+
if (Array.isArray(value)) {
|
|
385
|
+
for (const entry of value) {
|
|
386
|
+
const found = asString(entry);
|
|
387
|
+
if (found) return found;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
if (typeof value === "object" && value !== null) {
|
|
391
|
+
const url = value.url;
|
|
392
|
+
if (typeof url === "string") return url.trim() || null;
|
|
393
|
+
const name = value.name;
|
|
394
|
+
if (typeof name === "string") return name.trim() || null;
|
|
395
|
+
}
|
|
396
|
+
return null;
|
|
397
|
+
}
|
|
398
|
+
function asNumber(value) {
|
|
399
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
400
|
+
if (typeof value === "string") {
|
|
401
|
+
const parsed = Number(value.replace(",", "."));
|
|
402
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
403
|
+
}
|
|
404
|
+
return null;
|
|
405
|
+
}
|
|
406
|
+
function asStringList(value) {
|
|
407
|
+
if (typeof value === "string") return [value.trim()].filter(Boolean);
|
|
408
|
+
if (!Array.isArray(value)) return [];
|
|
409
|
+
const out = [];
|
|
410
|
+
for (const entry of value) {
|
|
411
|
+
if (typeof entry === "string") {
|
|
412
|
+
const text = entry.trim();
|
|
413
|
+
if (text) out.push(text);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
if (typeof entry === "object" && entry !== null) {
|
|
417
|
+
const record = entry;
|
|
418
|
+
const text = asString(record.text) ?? asString(record.name);
|
|
419
|
+
if (text) out.push(text);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return out;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// src/marmiton/urls.ts
|
|
426
|
+
var BASE_URL = "https://www.marmiton.org";
|
|
427
|
+
var RECIPE_PATH_RE = /\/recettes\/recette[_-][a-z0-9%\-.]*?_(\d+)\.aspx/i;
|
|
428
|
+
var ALLOWED_HOSTS = /* @__PURE__ */ new Set(["marmiton.org", "www.marmiton.org"]);
|
|
429
|
+
function buildSearchUrl(query) {
|
|
430
|
+
const url = new URL(`${BASE_URL}/recettes/recherche.aspx`);
|
|
431
|
+
url.searchParams.set("aqt", query);
|
|
432
|
+
return url.toString();
|
|
433
|
+
}
|
|
434
|
+
function buildRecipeUrl(id) {
|
|
435
|
+
return `${BASE_URL}/recettes/recette_r_${id}.aspx`;
|
|
436
|
+
}
|
|
437
|
+
function isMarmitonHost(rawUrl) {
|
|
438
|
+
let parsed;
|
|
439
|
+
try {
|
|
440
|
+
parsed = new URL(rawUrl);
|
|
441
|
+
} catch {
|
|
442
|
+
return false;
|
|
443
|
+
}
|
|
444
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") return false;
|
|
445
|
+
return ALLOWED_HOSTS.has(parsed.hostname.toLowerCase());
|
|
446
|
+
}
|
|
447
|
+
function extractRecipeId(hrefOrUrl) {
|
|
448
|
+
let path = hrefOrUrl;
|
|
449
|
+
if (/^https?:\/\//i.test(hrefOrUrl)) {
|
|
450
|
+
if (!isMarmitonHost(hrefOrUrl)) return null;
|
|
451
|
+
path = new URL(hrefOrUrl).pathname;
|
|
452
|
+
}
|
|
453
|
+
const match = RECIPE_PATH_RE.exec(path);
|
|
454
|
+
return match?.[1] ?? null;
|
|
455
|
+
}
|
|
456
|
+
function resolveRecipeRef(input) {
|
|
457
|
+
if (input.id) {
|
|
458
|
+
if (!/^\d+$/.test(input.id)) {
|
|
459
|
+
throw invalidInput(
|
|
460
|
+
`"${input.id}" is not a Marmiton recipe id.`,
|
|
461
|
+
"Ids are digits only, as returned by search_recipes."
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
return { id: input.id, url: buildRecipeUrl(input.id) };
|
|
465
|
+
}
|
|
466
|
+
if (input.url) {
|
|
467
|
+
if (!isMarmitonHost(input.url)) {
|
|
468
|
+
throw invalidInput(
|
|
469
|
+
"Only marmiton.org URLs are accepted.",
|
|
470
|
+
"Pass the url returned by search_recipes, or use the numeric id instead."
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
const id = extractRecipeId(input.url);
|
|
474
|
+
if (!id) {
|
|
475
|
+
throw invalidInput(
|
|
476
|
+
"That Marmiton URL is not a recipe page.",
|
|
477
|
+
"Recipe pages look like https://www.marmiton.org/recettes/recette_tarte-aux-pommes_18588.aspx"
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
return { id, url: input.url };
|
|
481
|
+
}
|
|
482
|
+
throw invalidInput("Either 'id' or 'url' must be provided.");
|
|
483
|
+
}
|
|
484
|
+
function toAbsoluteUrl(href) {
|
|
485
|
+
return new URL(href, BASE_URL).toString();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// src/marmiton/parseRecipe.ts
|
|
489
|
+
function parseRecipePage(html, options) {
|
|
490
|
+
const nodes = extractJsonLdNodes(html);
|
|
491
|
+
const node = findNodeOfType(nodes, "Recipe");
|
|
492
|
+
if (!node) {
|
|
493
|
+
throw parseFailure(options.url, "no schema.org Recipe node in the page");
|
|
494
|
+
}
|
|
495
|
+
const title = asString(node.name);
|
|
496
|
+
const ingredients = asStringList(node.recipeIngredient);
|
|
497
|
+
const steps = asStringList(node.recipeInstructions);
|
|
498
|
+
if (!title) throw parseFailure(options.url, "the Recipe node has no name");
|
|
499
|
+
if (ingredients.length === 0) {
|
|
500
|
+
throw parseFailure(options.url, "the Recipe node carries no ingredient list");
|
|
501
|
+
}
|
|
502
|
+
return {
|
|
503
|
+
id: options.id,
|
|
504
|
+
title,
|
|
505
|
+
url: asString(node.mainEntityOfPage) ?? options.url,
|
|
506
|
+
imageUrl: asString(node.image),
|
|
507
|
+
ingredients,
|
|
508
|
+
steps,
|
|
509
|
+
recipeYield: parseYield(node.recipeYield),
|
|
510
|
+
prepMinutes: parseIsoDuration(node.prepTime),
|
|
511
|
+
cookMinutes: parseIsoDuration(node.cookTime),
|
|
512
|
+
totalMinutes: parseIsoDuration(node.totalTime),
|
|
513
|
+
category: asString(node.recipeCategory),
|
|
514
|
+
author: asString(node.author),
|
|
515
|
+
rating: parseRating(node.aggregateRating),
|
|
516
|
+
nutrition: parseNutrition(node.nutrition)
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
function parseRating(value) {
|
|
520
|
+
if (typeof value !== "object" || value === null) return null;
|
|
521
|
+
const record = value;
|
|
522
|
+
const ratingValue = asNumber(record.ratingValue);
|
|
523
|
+
if (ratingValue === null) return null;
|
|
524
|
+
return {
|
|
525
|
+
value: ratingValue,
|
|
526
|
+
count: asNumber(record.ratingCount) ?? asNumber(record.reviewCount),
|
|
527
|
+
best: asNumber(record.bestRating)
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
function parseNutrition(value) {
|
|
531
|
+
if (typeof value !== "object" || value === null) return null;
|
|
532
|
+
const record = value;
|
|
533
|
+
const nutrition = {
|
|
534
|
+
calories: asString(record.calories),
|
|
535
|
+
protein: asString(record.proteinContent),
|
|
536
|
+
fat: asString(record.fatContent),
|
|
537
|
+
carbohydrate: asString(record.carbohydrateContent),
|
|
538
|
+
fiber: asString(record.fiberContent),
|
|
539
|
+
sodium: asString(record.sodiumContent),
|
|
540
|
+
servingSize: asString(record.servingSize)
|
|
541
|
+
};
|
|
542
|
+
const hasAny = Object.values(nutrition).some((entry) => entry !== null);
|
|
543
|
+
return hasAny ? nutrition : null;
|
|
544
|
+
}
|
|
545
|
+
function parseSearchPage(html, url) {
|
|
546
|
+
const nodes = extractJsonLdNodes(html);
|
|
547
|
+
const list = findNodeOfType(nodes, "ItemList");
|
|
548
|
+
if (!list) {
|
|
549
|
+
throw parseFailure(url, "no schema.org ItemList node on the search page");
|
|
550
|
+
}
|
|
551
|
+
const elements = list.itemListElement;
|
|
552
|
+
if (!Array.isArray(elements)) {
|
|
553
|
+
throw parseFailure(url, "the ItemList node carries no itemListElement array");
|
|
554
|
+
}
|
|
555
|
+
const results = [];
|
|
556
|
+
let skipped = 0;
|
|
557
|
+
for (const element of elements) {
|
|
558
|
+
if (typeof element !== "object" || element === null) {
|
|
559
|
+
skipped += 1;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
const record = element;
|
|
563
|
+
const href = asString(record.url);
|
|
564
|
+
const title = asString(record.name);
|
|
565
|
+
const id = href ? extractRecipeId(href) : null;
|
|
566
|
+
if (!href || !title || !id) {
|
|
567
|
+
skipped += 1;
|
|
568
|
+
continue;
|
|
569
|
+
}
|
|
570
|
+
results.push({ id, title, url: toAbsoluteUrl(href), imageUrl: asString(record.image) });
|
|
571
|
+
}
|
|
572
|
+
if (results.length === 0 && elements.length > 0) {
|
|
573
|
+
throw parseFailure(url, `${elements.length} list entries but none could be read`);
|
|
574
|
+
}
|
|
575
|
+
if (skipped > 0) {
|
|
576
|
+
process.stderr.write(`[mcp-marmiton] skipped ${skipped} unreadable search entries on ${url}
|
|
577
|
+
`);
|
|
578
|
+
}
|
|
579
|
+
return results;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// src/marmiton/client.ts
|
|
583
|
+
var MarmitonClient = class {
|
|
584
|
+
config;
|
|
585
|
+
logger;
|
|
586
|
+
limiter;
|
|
587
|
+
cache;
|
|
588
|
+
fetchImpl;
|
|
589
|
+
constructor(options = {}) {
|
|
590
|
+
this.config = options.config ?? loadConfig();
|
|
591
|
+
this.logger = options.logger ?? createLogger(this.config.logLevel);
|
|
592
|
+
this.limiter = new RateLimiter({ minIntervalMs: this.config.minIntervalMs });
|
|
593
|
+
this.cache = new TtlLruCache(this.config.cacheMaxEntries, this.config.cacheTtlMs);
|
|
594
|
+
this.fetchImpl = options.fetchImpl;
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Search recipes.
|
|
598
|
+
*
|
|
599
|
+
* One page only: Marmiton's robots.txt disallows the paginated form of this
|
|
600
|
+
* URL, so there is no page parameter to pass.
|
|
601
|
+
*/
|
|
602
|
+
async search(query) {
|
|
603
|
+
const url = buildSearchUrl(query);
|
|
604
|
+
const { html, cached } = await this.fetchPage(url);
|
|
605
|
+
return { data: parseSearchPage(html, url), cached };
|
|
606
|
+
}
|
|
607
|
+
async getRecipe(ref) {
|
|
608
|
+
const { id, url } = resolveRecipeRef(ref);
|
|
609
|
+
const { html, cached } = await this.fetchPage(url);
|
|
610
|
+
return { data: parseRecipePage(html, { id, url }), cached };
|
|
611
|
+
}
|
|
612
|
+
async fetchPage(url) {
|
|
613
|
+
const hit = this.cache.get(url);
|
|
614
|
+
if (hit !== void 0) {
|
|
615
|
+
this.logger.debug(`cache hit ${url}`);
|
|
616
|
+
return { html: hit, cached: true };
|
|
617
|
+
}
|
|
618
|
+
const html = await fetchHtml(url, {
|
|
619
|
+
config: this.config,
|
|
620
|
+
limiter: this.limiter,
|
|
621
|
+
logger: this.logger,
|
|
622
|
+
...this.fetchImpl ? { fetchImpl: this.fetchImpl } : {}
|
|
623
|
+
});
|
|
624
|
+
this.cache.set(url, html);
|
|
625
|
+
return { html, cached: false };
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
export {
|
|
629
|
+
MarmitonClient
|
|
630
|
+
};
|
|
631
|
+
//# sourceMappingURL=client.js.map
|