mcp-recipes 2.0.0 → 3.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/PRIVACY.md +168 -0
- package/README.md +491 -708
- package/dist/index.js +594 -88
- package/dist/index.js.map +1 -1
- package/dist/sources/client.d.ts +344 -0
- package/dist/sources/client.js +448 -9
- package/dist/sources/client.js.map +1 -1
- package/package.json +14 -4
- package/server.json +5 -5
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
|
11
11
|
import process from "process";
|
|
12
12
|
|
|
13
13
|
// src/version.ts
|
|
14
|
-
var PKG_VERSION = "
|
|
14
|
+
var PKG_VERSION = "3.0.1";
|
|
15
15
|
var REPO_URL = "https://github.com/smeet666/mcp-recipes";
|
|
16
16
|
var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
|
|
17
17
|
|
|
@@ -91,6 +91,13 @@ function loadConfig(env = process.env) {
|
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
+
// src/sources/registry.ts
|
|
95
|
+
import { GoodFoodClient } from "mcp-bbc-goodfood/client";
|
|
96
|
+
import { MarmitonClient } from "mcp-marmiton/client";
|
|
97
|
+
import { PtitchefClient } from "mcp-ptitchef/client";
|
|
98
|
+
import { SupertoinetteClient } from "mcp-supertoinette/client";
|
|
99
|
+
import { CookbookClient } from "mcp-wikibooks-cookbook/client";
|
|
100
|
+
|
|
94
101
|
// src/errors.ts
|
|
95
102
|
var ERROR_CODES = [
|
|
96
103
|
"not_found",
|
|
@@ -164,68 +171,6 @@ function fromSource(error, sourceName) {
|
|
|
164
171
|
}
|
|
165
172
|
}
|
|
166
173
|
|
|
167
|
-
// src/sources/ids.ts
|
|
168
|
-
var SEPARATOR = ":";
|
|
169
|
-
function resolveId(rawId, sources) {
|
|
170
|
-
const trimmed = rawId.trim();
|
|
171
|
-
if (trimmed === "") {
|
|
172
|
-
throw invalidInput(
|
|
173
|
-
"A recipe identifier is required.",
|
|
174
|
-
`Use one of the ids search_recipes returned, such as ${example(sources)}.`
|
|
175
|
-
);
|
|
176
|
-
}
|
|
177
|
-
for (const source of sources) {
|
|
178
|
-
const prefix = `${source.id}${SEPARATOR}`;
|
|
179
|
-
if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
180
|
-
continue;
|
|
181
|
-
}
|
|
182
|
-
const reference = trimmed.slice(prefix.length).trim();
|
|
183
|
-
if (reference === "") {
|
|
184
|
-
throw invalidInput(
|
|
185
|
-
`"${trimmed}" names a source and no recipe.`,
|
|
186
|
-
"Put the source's own identifier after the colon."
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
return { source, reference, inferred: null };
|
|
190
|
-
}
|
|
191
|
-
const claimed = sources.map((source) => ({ source, claim: source.claims(trimmed) })).filter(
|
|
192
|
-
(entry) => Boolean(entry.claim)
|
|
193
|
-
);
|
|
194
|
-
if (claimed.length > 1) {
|
|
195
|
-
throw invalidInput(
|
|
196
|
-
`"${trimmed}" could be an identifier on ${names(claimed.map((entry) => entry.source))}, so it names no one recipe.`,
|
|
197
|
-
`Spell it with its source, as in ${claimed.map((entry) => `${entry.source.id}:${trimmed}`).join(" or ")}.`
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
const only = claimed[0];
|
|
201
|
-
if (!only) {
|
|
202
|
-
throw invalidInput(
|
|
203
|
-
`"${trimmed}" is not an identifier any of the sources this server reads would mint.`,
|
|
204
|
-
`Call search_recipes with "${trimmed}" as the query and pass an id from a row, which reads like ${example(sources)}.`
|
|
205
|
-
);
|
|
206
|
-
}
|
|
207
|
-
return {
|
|
208
|
-
source: only.source,
|
|
209
|
-
reference: only.claim.reference,
|
|
210
|
-
inferred: only.claim.guess ? `${only.claim.why}, which is a guess: check the recipe that comes back is the one you meant` : only.claim.why
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
function names(sources) {
|
|
214
|
-
const all = sources.map((source) => source.name);
|
|
215
|
-
if (all.length <= 1) {
|
|
216
|
-
return all.join("");
|
|
217
|
-
}
|
|
218
|
-
return `${all.slice(0, -1).join(", ")} and ${all.at(-1)}`;
|
|
219
|
-
}
|
|
220
|
-
function example(sources) {
|
|
221
|
-
const first = sources[0];
|
|
222
|
-
return first ? `${first.id}${SEPARATOR}<the source's own id>` : "<source>:<id>";
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
// src/sources/registry.ts
|
|
226
|
-
import { MarmitonClient } from "mcp-marmiton/client";
|
|
227
|
-
import { CookbookClient } from "mcp-wikibooks-cookbook/client";
|
|
228
|
-
|
|
229
174
|
// src/sources/adapter.ts
|
|
230
175
|
var NUMBER_AND_REST = /^\s*(\d+(?:[.,]\d+)?)\s*(.*)$/;
|
|
231
176
|
var RANGE_AND_REST = /^\s*(\d+(?:[.,]\d+)?)\s*(?:à|a|to|-|–|—|ou|or)\s*(\d+(?:[.,]\d+)?)\s*(.*)$/i;
|
|
@@ -425,12 +370,176 @@ function cookbookDetail(payload) {
|
|
|
425
370
|
nutrition: publishedFigures(recipe.nutrition),
|
|
426
371
|
equipment: textList(recipe.equipment),
|
|
427
372
|
tips: textList(recipe.tips),
|
|
373
|
+
// The Cookbook publishes no resting time, says nothing about how a page
|
|
374
|
+
// lays its method out, and keeps nothing behind a subscription.
|
|
375
|
+
restMinutes: null,
|
|
376
|
+
stepsAsOneBlock: null,
|
|
377
|
+
withheld: null,
|
|
428
378
|
// A licence is only stated when both halves of it arrived: a name with no
|
|
429
379
|
// address, or an address with no name, is not terms anyone can follow.
|
|
430
380
|
license: licenseTitle && licenseUrl ? { title: licenseTitle, url: licenseUrl } : null
|
|
431
381
|
};
|
|
432
382
|
}
|
|
433
383
|
|
|
384
|
+
// src/sources/goodfood.ts
|
|
385
|
+
var GOODFOOD_PROFILE = {
|
|
386
|
+
id: "goodfood",
|
|
387
|
+
name: "BBC Good Food",
|
|
388
|
+
homeUrl: "https://www.bbcgoodfood.com",
|
|
389
|
+
language: "en",
|
|
390
|
+
attribution: "Source: BBC Good Food",
|
|
391
|
+
// The search serves recipe pages; a question aimed at the site's reviews and
|
|
392
|
+
// buying guides is answered with recipes all the same.
|
|
393
|
+
mixesReferencePages: false
|
|
394
|
+
};
|
|
395
|
+
var SITE_URL2 = /^https?:\/\/(?:www\.)?bbcgoodfood\.com\//i;
|
|
396
|
+
var REFERENCE = /^recipes\/[^/]+$/;
|
|
397
|
+
var DOT_SEGMENT = /(?:^|\/)\.\.?(?:\/|$)/;
|
|
398
|
+
var LEADING_SLASH = /^\/+/;
|
|
399
|
+
function asReference(raw) {
|
|
400
|
+
const path = raw.replace(LEADING_SLASH, "");
|
|
401
|
+
return REFERENCE.test(path) && !DOT_SEGMENT.test(`/${path}`) ? path : null;
|
|
402
|
+
}
|
|
403
|
+
function meansFor(report) {
|
|
404
|
+
if (count(report.total_available) === null) {
|
|
405
|
+
return null;
|
|
406
|
+
}
|
|
407
|
+
return report.total_is_ceiling === true ? "recipes BBC Good Food says its catalogue matches this wording, cut at the largest number of rows one search will serve, so it states a floor rather than a count" : "recipes BBC Good Food says its whole catalogue matches this wording; it counts the catalogue rather than the rows served";
|
|
408
|
+
}
|
|
409
|
+
function nutritionPanel(recipe) {
|
|
410
|
+
const facts = Array.isArray(recipe.nutrition) ? recipe.nutrition : [];
|
|
411
|
+
const kept = {};
|
|
412
|
+
for (const fact of facts) {
|
|
413
|
+
const label = text(fact?.label);
|
|
414
|
+
const value = count(fact?.value);
|
|
415
|
+
if (label === null || value === null) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
const unit = text(fact?.unit);
|
|
419
|
+
kept[label] = unit === null ? String(value) : `${value} ${unit}`;
|
|
420
|
+
}
|
|
421
|
+
const per = text(recipe.nutrition_per);
|
|
422
|
+
if (Object.keys(kept).length === 0) {
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
if (per !== null) {
|
|
426
|
+
kept.per = per;
|
|
427
|
+
}
|
|
428
|
+
return kept;
|
|
429
|
+
}
|
|
430
|
+
function goodfoodAdapter(reader) {
|
|
431
|
+
return {
|
|
432
|
+
...GOODFOOD_PROFILE,
|
|
433
|
+
claims(raw) {
|
|
434
|
+
if (SITE_URL2.test(raw)) {
|
|
435
|
+
const path2 = asReference(new URL(raw).pathname);
|
|
436
|
+
return path2 === null ? null : { reference: path2, why: "the address is a BBC Good Food recipe page", guess: false };
|
|
437
|
+
}
|
|
438
|
+
const path = asReference(raw);
|
|
439
|
+
return path === null ? null : {
|
|
440
|
+
reference: path,
|
|
441
|
+
why: "the path is the shape BBC Good Food mints for a recipe",
|
|
442
|
+
guess: false
|
|
443
|
+
};
|
|
444
|
+
},
|
|
445
|
+
async search(query, limit) {
|
|
446
|
+
const outcome = await reader.searchRecipes(query, { limit });
|
|
447
|
+
const report = outcome.data ?? {};
|
|
448
|
+
const list = rowsOf(report.results, GOODFOOD_PROFILE);
|
|
449
|
+
const rows = [];
|
|
450
|
+
let skipped = outcome.skipped?.length ?? 0;
|
|
451
|
+
for (const summary of list) {
|
|
452
|
+
const id = text(summary?.id);
|
|
453
|
+
const title = text(summary?.title);
|
|
454
|
+
const url = text(summary?.url);
|
|
455
|
+
if (!(id && title && url)) {
|
|
456
|
+
skipped += 1;
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
rows.push({
|
|
460
|
+
id: namespacedId(GOODFOOD_PROFILE.id, id),
|
|
461
|
+
source: GOODFOOD_PROFILE.id,
|
|
462
|
+
sourceName: GOODFOOD_PROFILE.name,
|
|
463
|
+
title,
|
|
464
|
+
url,
|
|
465
|
+
imageUrl: text(summary?.image_url),
|
|
466
|
+
// A row carries a title and a picture and no prose.
|
|
467
|
+
excerpt: null
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
return {
|
|
471
|
+
rows,
|
|
472
|
+
skipped,
|
|
473
|
+
reportedTotal: count(report.total_available),
|
|
474
|
+
reportedTotalMeans: meansFor(report),
|
|
475
|
+
cached: outcome.cached
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
async getRecipe(reference) {
|
|
479
|
+
const outcome = await reader.getRecipe(reference);
|
|
480
|
+
return { recipe: goodfoodDetail(outcome.data), cached: outcome.cached };
|
|
481
|
+
}
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
function goodfoodDetail(payload) {
|
|
485
|
+
const recipe = payload ?? {};
|
|
486
|
+
const published = text(recipe.yield_text);
|
|
487
|
+
const span = readYieldSpan(published);
|
|
488
|
+
const value = count(recipe.rating);
|
|
489
|
+
const groups = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
|
|
490
|
+
const lines = [];
|
|
491
|
+
for (const group of groups) {
|
|
492
|
+
const heading = text(group?.heading);
|
|
493
|
+
if (heading !== null) {
|
|
494
|
+
lines.push(heading);
|
|
495
|
+
}
|
|
496
|
+
for (const entry of group?.ingredients ?? []) {
|
|
497
|
+
const line = text(entry?.text);
|
|
498
|
+
if (line !== null) {
|
|
499
|
+
lines.push(line);
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return {
|
|
504
|
+
...detailBase(GOODFOOD_PROFILE),
|
|
505
|
+
id: namespacedId(GOODFOOD_PROFILE.id, required(recipe.id, "id", GOODFOOD_PROFILE)),
|
|
506
|
+
title: required(recipe.title, "title", GOODFOOD_PROFILE),
|
|
507
|
+
url: required(recipe.url, "url", GOODFOOD_PROFILE),
|
|
508
|
+
// A row carries the picture; the recipe page's own is not read.
|
|
509
|
+
imageUrl: null,
|
|
510
|
+
yieldCount: count(recipe.yield_count) ?? span.count,
|
|
511
|
+
yieldMax: span.max,
|
|
512
|
+
yieldText: published,
|
|
513
|
+
yieldUnit: span.unit,
|
|
514
|
+
ingredients: lines,
|
|
515
|
+
steps: Array.isArray(recipe.steps) ? recipe.steps.filter((step) => typeof step === "string") : [],
|
|
516
|
+
publishedSections: null,
|
|
517
|
+
prepMinutes: count(recipe.prep_minutes),
|
|
518
|
+
cookMinutes: count(recipe.cook_minutes),
|
|
519
|
+
totalMinutes: count(recipe.total_minutes),
|
|
520
|
+
// The site publishes no resting time of its own.
|
|
521
|
+
restMinutes: null,
|
|
522
|
+
stepsAsOneBlock: null,
|
|
523
|
+
// The page carries these and the site sells access to them. What is said
|
|
524
|
+
// here is what the site did, so an answer never reports a wall as a page
|
|
525
|
+
// this server could not read.
|
|
526
|
+
withheld: recipe.premium === true ? {
|
|
527
|
+
parts: ["ingredients", "method"],
|
|
528
|
+
why: "BBC Good Food keeps this recipe's ingredients and method for its subscribers, and publishes its title, its times, its rating and its nutrition"
|
|
529
|
+
} : null,
|
|
530
|
+
// The site files a recipe under collections rather than under one category
|
|
531
|
+
// on the page itself.
|
|
532
|
+
category: null,
|
|
533
|
+
author: text(recipe.author),
|
|
534
|
+
rating: value === null ? null : { value, count: count(recipe.rating_count), max: 5 },
|
|
535
|
+
nutrition: nutritionPanel(recipe),
|
|
536
|
+
equipment: [],
|
|
537
|
+
tips: [],
|
|
538
|
+
// The site states no licence on a recipe page, and silence is not a grant.
|
|
539
|
+
license: null
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
434
543
|
// src/sources/marmiton.ts
|
|
435
544
|
var ABSOLUTE_URL = /^https?:\/\//i;
|
|
436
545
|
var MARMITON_PROFILE = {
|
|
@@ -442,8 +551,8 @@ var MARMITON_PROFILE = {
|
|
|
442
551
|
// Every page the search returns is a recipe.
|
|
443
552
|
mixesReferencePages: false
|
|
444
553
|
};
|
|
445
|
-
var
|
|
446
|
-
var
|
|
554
|
+
var SITE_URL3 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
|
|
555
|
+
var REFERENCE2 = /^\d+$/;
|
|
447
556
|
async function readSearch(reader, query) {
|
|
448
557
|
try {
|
|
449
558
|
return await reader.search(query);
|
|
@@ -459,10 +568,10 @@ function marmitonAdapter(reader) {
|
|
|
459
568
|
return {
|
|
460
569
|
...MARMITON_PROFILE,
|
|
461
570
|
claims(raw) {
|
|
462
|
-
if (
|
|
571
|
+
if (SITE_URL3.test(raw)) {
|
|
463
572
|
return { reference: raw, why: "the address is a Marmiton page", guess: false };
|
|
464
573
|
}
|
|
465
|
-
if (
|
|
574
|
+
if (REFERENCE2.test(raw)) {
|
|
466
575
|
return {
|
|
467
576
|
reference: raw,
|
|
468
577
|
why: "a bare number is the shape Marmiton mints",
|
|
@@ -546,14 +655,277 @@ function marmitonDetail(payload) {
|
|
|
546
655
|
nutrition: publishedFigures(recipe.nutrition),
|
|
547
656
|
equipment: [],
|
|
548
657
|
tips: [],
|
|
658
|
+
// Marmiton publishes no resting time, no word on how it lays its method
|
|
659
|
+
// out, and keeps nothing back.
|
|
660
|
+
restMinutes: null,
|
|
661
|
+
stepsAsOneBlock: null,
|
|
662
|
+
withheld: null,
|
|
549
663
|
// Marmiton states no licence on a recipe page, and silence is not a grant.
|
|
550
664
|
license: null
|
|
551
665
|
};
|
|
552
666
|
}
|
|
553
667
|
|
|
668
|
+
// src/sources/ptitchef.ts
|
|
669
|
+
var PTITCHEF_PROFILE = {
|
|
670
|
+
id: "ptitchef",
|
|
671
|
+
name: "Ptitchef",
|
|
672
|
+
homeUrl: "https://www.ptitchef.com",
|
|
673
|
+
language: "fr",
|
|
674
|
+
attribution: "Source: Ptitchef",
|
|
675
|
+
// Every row a listing serves names a recipe; a topic the site answers with a
|
|
676
|
+
// guide of its own groups recipes under headings rather than mixing in pages
|
|
677
|
+
// about ingredients.
|
|
678
|
+
mixesReferencePages: false
|
|
679
|
+
};
|
|
680
|
+
var SITE_URL4 = /^https?:\/\/(?:www\.)?ptitchef\.com\//i;
|
|
681
|
+
var REFERENCE3 = /^recettes\/[^/]+\/[^/]+-fid-\d+$/;
|
|
682
|
+
var DOT_SEGMENT2 = /(?:^|\/)\.\.?(?:\/|$)/;
|
|
683
|
+
var LEADING_SLASH2 = /^\/+/;
|
|
684
|
+
function asReference2(raw) {
|
|
685
|
+
const path = raw.replace(LEADING_SLASH2, "");
|
|
686
|
+
return REFERENCE3.test(path) && !DOT_SEGMENT2.test(`/${path}`) ? path : null;
|
|
687
|
+
}
|
|
688
|
+
function meansFor2(listing) {
|
|
689
|
+
if (count(listing.total_available) === null) {
|
|
690
|
+
return null;
|
|
691
|
+
}
|
|
692
|
+
switch (listing.kind) {
|
|
693
|
+
case "topic":
|
|
694
|
+
case "category":
|
|
695
|
+
case "standing":
|
|
696
|
+
return "recipes Ptitchef says the category page it answered this wording with holds; it counts that page rather than what the words matched";
|
|
697
|
+
case "free_text":
|
|
698
|
+
case "fridge":
|
|
699
|
+
return "rows Ptitchef served on the single page it answered this wording with; the site offers no more for it";
|
|
700
|
+
case "recipe":
|
|
701
|
+
return "the one recipe Ptitchef answered this wording with, by opening its page";
|
|
702
|
+
default:
|
|
703
|
+
return null;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function ptitchefAdapter(reader) {
|
|
707
|
+
return {
|
|
708
|
+
...PTITCHEF_PROFILE,
|
|
709
|
+
claims(raw) {
|
|
710
|
+
if (SITE_URL4.test(raw)) {
|
|
711
|
+
const path2 = asReference2(new URL(raw).pathname);
|
|
712
|
+
return path2 === null ? null : { reference: path2, why: "the address is a Ptitchef recipe page", guess: false };
|
|
713
|
+
}
|
|
714
|
+
const path = asReference2(raw);
|
|
715
|
+
return path === null ? null : {
|
|
716
|
+
reference: path,
|
|
717
|
+
why: "the path is the shape Ptitchef mints for a recipe",
|
|
718
|
+
guess: false
|
|
719
|
+
};
|
|
720
|
+
},
|
|
721
|
+
async search(query) {
|
|
722
|
+
const outcome = await reader.searchRecipes(query);
|
|
723
|
+
const listing = outcome.data ?? {};
|
|
724
|
+
const list = rowsOf(listing.results, PTITCHEF_PROFILE);
|
|
725
|
+
const rows = [];
|
|
726
|
+
let skipped = outcome.skipped?.length ?? 0;
|
|
727
|
+
for (const summary of list) {
|
|
728
|
+
const id = text(summary?.id);
|
|
729
|
+
const title = text(summary?.title);
|
|
730
|
+
const url = text(summary?.url);
|
|
731
|
+
if (!(id && title && url)) {
|
|
732
|
+
skipped += 1;
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
735
|
+
rows.push({
|
|
736
|
+
id: namespacedId(PTITCHEF_PROFILE.id, id),
|
|
737
|
+
source: PTITCHEF_PROFILE.id,
|
|
738
|
+
sourceName: PTITCHEF_PROFILE.name,
|
|
739
|
+
title,
|
|
740
|
+
url,
|
|
741
|
+
imageUrl: text(summary?.image_url),
|
|
742
|
+
excerpt: text(summary?.ingredients_preview)
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
return {
|
|
746
|
+
rows,
|
|
747
|
+
skipped,
|
|
748
|
+
reportedTotal: count(listing.total_available),
|
|
749
|
+
reportedTotalMeans: meansFor2(listing),
|
|
750
|
+
cached: outcome.cached
|
|
751
|
+
};
|
|
752
|
+
},
|
|
753
|
+
async getRecipe(reference) {
|
|
754
|
+
const outcome = await reader.getRecipe(reference);
|
|
755
|
+
return { recipe: ptitchefDetail(outcome.data), cached: outcome.cached };
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
function ptitchefDetail(payload) {
|
|
760
|
+
const recipe = payload ?? {};
|
|
761
|
+
const published = text(recipe.yield_text);
|
|
762
|
+
const span = readYieldSpan(published);
|
|
763
|
+
const value = count(recipe.rating);
|
|
764
|
+
const steps = Array.isArray(recipe.steps) ? recipe.steps : [];
|
|
765
|
+
const lines = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
|
|
766
|
+
return {
|
|
767
|
+
...detailBase(PTITCHEF_PROFILE),
|
|
768
|
+
id: namespacedId(PTITCHEF_PROFILE.id, required(recipe.id, "id", PTITCHEF_PROFILE)),
|
|
769
|
+
title: required(recipe.title, "title", PTITCHEF_PROFILE),
|
|
770
|
+
url: required(recipe.url, "url", PTITCHEF_PROFILE),
|
|
771
|
+
imageUrl: text(recipe.image_url),
|
|
772
|
+
yieldCount: count(recipe.yield_count) ?? span.count,
|
|
773
|
+
yieldMax: span.max,
|
|
774
|
+
yieldText: published,
|
|
775
|
+
yieldUnit: text(recipe.yield_unit) ?? span.unit,
|
|
776
|
+
ingredients: lines.filter((line) => typeof line === "string"),
|
|
777
|
+
// A step carries a picture of itself, which is not part of the method.
|
|
778
|
+
steps: steps.map((step) => text(step?.text)).filter((step) => step !== null),
|
|
779
|
+
publishedSections: null,
|
|
780
|
+
prepMinutes: count(recipe.prep_minutes),
|
|
781
|
+
cookMinutes: count(recipe.cook_minutes),
|
|
782
|
+
totalMinutes: count(recipe.total_minutes),
|
|
783
|
+
// The site publishes no resting time of its own.
|
|
784
|
+
restMinutes: null,
|
|
785
|
+
stepsAsOneBlock: typeof recipe.steps_are_one_block === "boolean" ? recipe.steps_are_one_block : null,
|
|
786
|
+
withheld: null,
|
|
787
|
+
category: text(recipe.category),
|
|
788
|
+
author: text(recipe.author),
|
|
789
|
+
// The site prints two counts beside a rating, one of readers who rated and
|
|
790
|
+
// one of readers who wrote. Only the first is what a rating rests on, and
|
|
791
|
+
// adding them would count twice anyone who did both.
|
|
792
|
+
rating: value === null ? null : { value, count: count(recipe.rating_count), max: 5 },
|
|
793
|
+
nutrition: publishedFigures(recipe.nutrition),
|
|
794
|
+
equipment: [],
|
|
795
|
+
tips: [],
|
|
796
|
+
// The site states no licence on a recipe page, and silence is not a grant.
|
|
797
|
+
license: null
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// src/sources/supertoinette.ts
|
|
802
|
+
var SUPERTOINETTE_PROFILE = {
|
|
803
|
+
id: "supertoinette",
|
|
804
|
+
name: "Supertoinette",
|
|
805
|
+
homeUrl: "https://www.supertoinette.com",
|
|
806
|
+
language: "fr",
|
|
807
|
+
attribution: "Source: Supertoinette",
|
|
808
|
+
// The reader sets aside any row that opens onto something other than a
|
|
809
|
+
// recipe, so what arrives here is recipes.
|
|
810
|
+
mixesReferencePages: false
|
|
811
|
+
};
|
|
812
|
+
var SITE_RECIPE_URL = /^https?:\/\/(?:www\.)?supertoinette\.com\/recette\/([1-9][0-9]{0,9})\//i;
|
|
813
|
+
var REFERENCE4 = /^[1-9][0-9]{0,9}$/;
|
|
814
|
+
function supertoinetteAdapter(reader) {
|
|
815
|
+
return {
|
|
816
|
+
...SUPERTOINETTE_PROFILE,
|
|
817
|
+
claims(raw) {
|
|
818
|
+
const address = SITE_RECIPE_URL.exec(raw);
|
|
819
|
+
if (address?.[1]) {
|
|
820
|
+
return {
|
|
821
|
+
reference: address[1],
|
|
822
|
+
why: "the address is a Supertoinette recipe page",
|
|
823
|
+
guess: false
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
if (REFERENCE4.test(raw)) {
|
|
827
|
+
return {
|
|
828
|
+
reference: raw,
|
|
829
|
+
why: "a bare number is the shape Supertoinette mints",
|
|
830
|
+
guess: false
|
|
831
|
+
};
|
|
832
|
+
}
|
|
833
|
+
return null;
|
|
834
|
+
},
|
|
835
|
+
async search(query) {
|
|
836
|
+
const outcome = await reader.searchRecipes({ query, page: 1, category: null });
|
|
837
|
+
const listing = outcome.data?.listing ?? {};
|
|
838
|
+
const list = rowsOf(listing.results, SUPERTOINETTE_PROFILE);
|
|
839
|
+
const rows = [];
|
|
840
|
+
let skipped = outcome.skipped?.length ?? 0;
|
|
841
|
+
for (const summary of list) {
|
|
842
|
+
const id = text(summary?.id);
|
|
843
|
+
const title = text(summary?.title);
|
|
844
|
+
const url = text(summary?.url);
|
|
845
|
+
if (!(id && title && url)) {
|
|
846
|
+
skipped += 1;
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
rows.push({
|
|
850
|
+
id: namespacedId(SUPERTOINETTE_PROFILE.id, id),
|
|
851
|
+
source: SUPERTOINETTE_PROFILE.id,
|
|
852
|
+
sourceName: SUPERTOINETTE_PROFILE.name,
|
|
853
|
+
title,
|
|
854
|
+
url,
|
|
855
|
+
imageUrl: text(summary?.image_url),
|
|
856
|
+
excerpt: text(summary?.description)
|
|
857
|
+
});
|
|
858
|
+
}
|
|
859
|
+
return {
|
|
860
|
+
rows,
|
|
861
|
+
skipped,
|
|
862
|
+
// The site prints no total anywhere on a search page. The rows of one
|
|
863
|
+
// page are not put here in its place: the site pages, so they would
|
|
864
|
+
// understate what it holds rather than count it.
|
|
865
|
+
reportedTotal: null,
|
|
866
|
+
reportedTotalMeans: null,
|
|
867
|
+
cached: outcome.cached
|
|
868
|
+
};
|
|
869
|
+
},
|
|
870
|
+
async getRecipe(reference) {
|
|
871
|
+
const outcome = await reader.getRecipe(reference);
|
|
872
|
+
return { recipe: supertoinetteDetail(outcome.data), cached: outcome.cached };
|
|
873
|
+
}
|
|
874
|
+
};
|
|
875
|
+
}
|
|
876
|
+
function supertoinetteDetail(payload) {
|
|
877
|
+
const recipe = payload ?? {};
|
|
878
|
+
const rating = recipe.rating;
|
|
879
|
+
const value = count(rating?.value);
|
|
880
|
+
const published = text(recipe.yield_text);
|
|
881
|
+
const span = readYieldSpan(published);
|
|
882
|
+
const lines = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
|
|
883
|
+
return {
|
|
884
|
+
...detailBase(SUPERTOINETTE_PROFILE),
|
|
885
|
+
id: namespacedId(SUPERTOINETTE_PROFILE.id, required(recipe.id, "id", SUPERTOINETTE_PROFILE)),
|
|
886
|
+
title: required(recipe.title, "title", SUPERTOINETTE_PROFILE),
|
|
887
|
+
url: required(recipe.url, "url", SUPERTOINETTE_PROFILE),
|
|
888
|
+
imageUrl: text(recipe.images?.[0]),
|
|
889
|
+
yieldCount: span.count,
|
|
890
|
+
yieldMax: span.max,
|
|
891
|
+
yieldText: published,
|
|
892
|
+
yieldUnit: span.unit,
|
|
893
|
+
// The whole line as printed, headings included: a heading dropped would
|
|
894
|
+
// merge two groups of a recipe that may each call for the same thing.
|
|
895
|
+
// The ingredient page the site links a line to is not carried, since the
|
|
896
|
+
// site links the wrong page often enough that pairing recipes by it would
|
|
897
|
+
// pair the wrong recipes.
|
|
898
|
+
ingredients: lines.map((line) => text(line?.raw)).filter((line) => line !== null),
|
|
899
|
+
steps: textList(recipe.steps),
|
|
900
|
+
publishedSections: null,
|
|
901
|
+
prepMinutes: count(recipe.prep_minutes),
|
|
902
|
+
cookMinutes: count(recipe.cook_minutes),
|
|
903
|
+
totalMinutes: count(recipe.total_minutes),
|
|
904
|
+
// The site prints this on its own, and its own total already holds it.
|
|
905
|
+
// Adding it to anything here would count it twice.
|
|
906
|
+
restMinutes: count(recipe.rest_minutes),
|
|
907
|
+
// The reader hands over the steps it read and says nothing about whether
|
|
908
|
+
// the page numbered them.
|
|
909
|
+
stepsAsOneBlock: null,
|
|
910
|
+
withheld: null,
|
|
911
|
+
category: text(recipe.category),
|
|
912
|
+
author: text(recipe.author),
|
|
913
|
+
// The scale is published, so a value is never read against a guess.
|
|
914
|
+
rating: value === null ? null : { value, count: count(rating?.count), max: count(rating?.scale) },
|
|
915
|
+
// This site publishes no nutrition panel at all, so there is nothing here
|
|
916
|
+
// to repeat. An empty panel would read as a page that printed one and left
|
|
917
|
+
// every figure out.
|
|
918
|
+
nutrition: null,
|
|
919
|
+
equipment: [],
|
|
920
|
+
tips: [],
|
|
921
|
+
// The site states no licence on a recipe page, and silence is not a grant.
|
|
922
|
+
license: null
|
|
923
|
+
};
|
|
924
|
+
}
|
|
925
|
+
|
|
554
926
|
// src/sources/registry.ts
|
|
555
|
-
var
|
|
556
|
-
function buildSources(config, readers
|
|
927
|
+
var PAGE_READ_BOUNDS = { maxBodyBytes: 8e6, budgetMs: 6e4 };
|
|
928
|
+
function buildSources(config, readers, logger) {
|
|
557
929
|
const shared = {
|
|
558
930
|
userAgent: config.userAgent,
|
|
559
931
|
minIntervalMs: config.minIntervalMs,
|
|
@@ -565,9 +937,18 @@ function buildSources(config, readers = {}) {
|
|
|
565
937
|
};
|
|
566
938
|
const marmitonOptions = { config: shared };
|
|
567
939
|
const cookbookOptions = { config: shared };
|
|
940
|
+
const ptitchefOptions = {
|
|
941
|
+
config: { ...shared, ...PAGE_READ_BOUNDS },
|
|
942
|
+
logger
|
|
943
|
+
};
|
|
944
|
+
const goodfoodOptions = { config: shared, logger };
|
|
945
|
+
const supertoinetteOptions = { config: shared, logger };
|
|
568
946
|
return [
|
|
569
947
|
marmitonAdapter(readers.marmiton ?? new MarmitonClient(marmitonOptions)),
|
|
570
|
-
cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions))
|
|
948
|
+
cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions)),
|
|
949
|
+
ptitchefAdapter(readers.ptitchef ?? new PtitchefClient(ptitchefOptions)),
|
|
950
|
+
goodfoodAdapter(readers.goodfood ?? new GoodFoodClient(goodfoodOptions)),
|
|
951
|
+
supertoinetteAdapter(readers.supertoinette ?? new SupertoinetteClient(supertoinetteOptions))
|
|
571
952
|
];
|
|
572
953
|
}
|
|
573
954
|
function selectSources(sources, wanted) {
|
|
@@ -590,6 +971,71 @@ function selectSources(sources, wanted) {
|
|
|
590
971
|
}
|
|
591
972
|
return chosen;
|
|
592
973
|
}
|
|
974
|
+
var PROFILES = [
|
|
975
|
+
MARMITON_PROFILE,
|
|
976
|
+
COOKBOOK_PROFILE,
|
|
977
|
+
PTITCHEF_PROFILE,
|
|
978
|
+
GOODFOOD_PROFILE,
|
|
979
|
+
SUPERTOINETTE_PROFILE
|
|
980
|
+
];
|
|
981
|
+
|
|
982
|
+
// src/sources/ids.ts
|
|
983
|
+
var SEPARATOR = ":";
|
|
984
|
+
function resolveId(rawId, sources) {
|
|
985
|
+
const trimmed = rawId.trim();
|
|
986
|
+
if (trimmed === "") {
|
|
987
|
+
throw invalidInput(
|
|
988
|
+
"A recipe identifier is required.",
|
|
989
|
+
`Use one of the ids search_recipes returned, such as ${example(sources)}.`
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
for (const source of sources) {
|
|
993
|
+
const prefix = `${source.id}${SEPARATOR}`;
|
|
994
|
+
if (!trimmed.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
const reference = trimmed.slice(prefix.length).trim();
|
|
998
|
+
if (reference === "") {
|
|
999
|
+
throw invalidInput(
|
|
1000
|
+
`"${trimmed}" names a source and no recipe.`,
|
|
1001
|
+
"Put the source's own identifier after the colon."
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
return { source, reference, inferred: null };
|
|
1005
|
+
}
|
|
1006
|
+
const claimed = sources.map((source) => ({ source, claim: source.claims(trimmed) })).filter(
|
|
1007
|
+
(entry) => Boolean(entry.claim)
|
|
1008
|
+
);
|
|
1009
|
+
if (claimed.length > 1) {
|
|
1010
|
+
throw invalidInput(
|
|
1011
|
+
`"${trimmed}" could be an identifier on ${names(claimed.map((entry) => entry.source))}, so it names no one recipe.`,
|
|
1012
|
+
`Spell it with its source, as in ${claimed.map((entry) => `${entry.source.id}:${trimmed}`).join(" or ")}.`
|
|
1013
|
+
);
|
|
1014
|
+
}
|
|
1015
|
+
const only = claimed[0];
|
|
1016
|
+
if (!only) {
|
|
1017
|
+
throw invalidInput(
|
|
1018
|
+
`"${trimmed}" is not an identifier any of the sources this server reads would mint.`,
|
|
1019
|
+
`Call search_recipes with "${trimmed}" as the query and pass an id from a row, which reads like ${example(sources)}.`
|
|
1020
|
+
);
|
|
1021
|
+
}
|
|
1022
|
+
return {
|
|
1023
|
+
source: only.source,
|
|
1024
|
+
reference: only.claim.reference,
|
|
1025
|
+
inferred: only.claim.guess ? `${only.claim.why}, which is a guess: check the recipe that comes back is the one you meant` : only.claim.why
|
|
1026
|
+
};
|
|
1027
|
+
}
|
|
1028
|
+
function names(sources) {
|
|
1029
|
+
const all = sources.map((source) => source.name);
|
|
1030
|
+
if (all.length <= 1) {
|
|
1031
|
+
return all.join("");
|
|
1032
|
+
}
|
|
1033
|
+
return `${all.slice(0, -1).join(", ")} and ${all.at(-1)}`;
|
|
1034
|
+
}
|
|
1035
|
+
function example(sources) {
|
|
1036
|
+
const first = sources[0];
|
|
1037
|
+
return first ? `${first.id}${SEPARATOR}<the source's own id>` : "<source>:<id>";
|
|
1038
|
+
}
|
|
593
1039
|
|
|
594
1040
|
// src/sources/wordings.ts
|
|
595
1041
|
var DIGITS_ONLY = /^\d+$/;
|
|
@@ -1205,7 +1651,7 @@ var RecipesClient = class {
|
|
|
1205
1651
|
constructor(options = {}) {
|
|
1206
1652
|
this.config = withGuarantees({ ...loadConfig(), ...options.config });
|
|
1207
1653
|
this.logger = options.logger ?? createLogger(this.config.logLevel);
|
|
1208
|
-
this.sources = options.sources ?? buildSources(this.config, options.readers ?? {});
|
|
1654
|
+
this.sources = options.sources ?? buildSources(this.config, options.readers ?? {}, this.logger);
|
|
1209
1655
|
}
|
|
1210
1656
|
/** What every source sees this client call itself. */
|
|
1211
1657
|
get userAgent() {
|
|
@@ -3973,6 +4419,18 @@ var recipeSchema = z2.object({
|
|
|
3973
4419
|
prep_minutes: z2.number().int().nullable(),
|
|
3974
4420
|
cook_minutes: z2.number().int().nullable(),
|
|
3975
4421
|
total_minutes: z2.number().int().nullable(),
|
|
4422
|
+
rest_minutes: z2.number().int().nullable().describe(
|
|
4423
|
+
"Time the recipe stands, from a source that publishes it apart. It is in no other time here, and belongs to no other source's cooking time. Null where the source publishes none, which is not a dish that needs no rest."
|
|
4424
|
+
),
|
|
4425
|
+
steps_as_one_block: z2.boolean().nullable().describe(
|
|
4426
|
+
"Whether the source publishes the method as one block of prose rather than as steps of its own. Null where the source says neither, so a single entry is not read as step one of several."
|
|
4427
|
+
),
|
|
4428
|
+
withheld: z2.object({
|
|
4429
|
+
parts: z2.array(z2.string()).describe("Which parts, named as 'ingredients' or 'method'."),
|
|
4430
|
+
why: z2.string().describe("What the source says about keeping them back.")
|
|
4431
|
+
}).nullable().describe(
|
|
4432
|
+
"A part the source published nothing of because it keeps it for its subscribers. Null from a source that withholds nothing, which is a different statement from an empty list."
|
|
4433
|
+
),
|
|
3976
4434
|
category: z2.string().nullable(),
|
|
3977
4435
|
author: z2.string().nullable().describe("Null on a source written by everyone who edited the page."),
|
|
3978
4436
|
rating: ratingSchema.nullable().describe("Null on a source that carries no reader rating."),
|
|
@@ -4003,6 +4461,17 @@ function resolveFactor(recipe, servings) {
|
|
|
4003
4461
|
if (servings === null) {
|
|
4004
4462
|
return { factor: null, notes: [] };
|
|
4005
4463
|
}
|
|
4464
|
+
const held = withheldPart(recipe, "ingredients");
|
|
4465
|
+
if (held !== null) {
|
|
4466
|
+
return {
|
|
4467
|
+
factor: null,
|
|
4468
|
+
notes: [
|
|
4469
|
+
mustKeep(
|
|
4470
|
+
`${held}, so there is nothing here to put to ${servings}. The page states what it serves; read the lines at ${quoteForeign(recipe.url)}.`
|
|
4471
|
+
)
|
|
4472
|
+
]
|
|
4473
|
+
};
|
|
4474
|
+
}
|
|
4006
4475
|
if (recipe.yieldCount === null || recipe.yieldCount <= 0) {
|
|
4007
4476
|
return {
|
|
4008
4477
|
factor: null,
|
|
@@ -4049,7 +4518,19 @@ var METHOD_PART = {
|
|
|
4049
4518
|
whole: "method",
|
|
4050
4519
|
caution: "An empty method here is never evidence that the dish is made without one."
|
|
4051
4520
|
};
|
|
4521
|
+
function withheldPart(recipe, part) {
|
|
4522
|
+
if (recipe.withheld === null || !recipe.withheld.parts.includes(part)) {
|
|
4523
|
+
return null;
|
|
4524
|
+
}
|
|
4525
|
+
return recipe.withheld.why;
|
|
4526
|
+
}
|
|
4052
4527
|
function emptyPartNote(recipe, words) {
|
|
4528
|
+
const held = withheldPart(recipe, words.part);
|
|
4529
|
+
if (held !== null) {
|
|
4530
|
+
return mustKeep(
|
|
4531
|
+
`${held}, so no ${words.entry} is in this answer. The page publishes one and this server did not fail to read it; a reader who has the subscription reads the ${words.whole} at ${quoteForeign(recipe.url)}. ${words.caution}`
|
|
4532
|
+
);
|
|
4533
|
+
}
|
|
4053
4534
|
const heading = headingFor(words.part, recipe.publishedSections);
|
|
4054
4535
|
const counterpart = whatTheOtherPartHolds(words.part, recipe);
|
|
4055
4536
|
const shown = [
|
|
@@ -4066,17 +4547,28 @@ function emptyPartNote(recipe, words) {
|
|
|
4066
4547
|
`No ${words.entry} was read from this page${looked}. A page publishing no ${words.whole} and one written in a layout this server cannot follow look the same from here, so read the url before settling which this is. ${words.caution}`
|
|
4067
4548
|
);
|
|
4068
4549
|
}
|
|
4069
|
-
function
|
|
4070
|
-
const
|
|
4071
|
-
const wants = (section) => options.sections.includes(section);
|
|
4072
|
-
const ingredients = factor === null || factor === 1 ? passthroughIngredients(recipe.ingredients, recipe.language) : scaleIngredients(recipe.ingredients, { factor, language: recipe.language });
|
|
4073
|
-
const notes = [...yieldNotes];
|
|
4550
|
+
function partNotes(recipe, wants) {
|
|
4551
|
+
const notes = [];
|
|
4074
4552
|
if (wants("ingredients") && recipe.ingredients.length === 0) {
|
|
4075
4553
|
notes.push(emptyPartNote(recipe, INGREDIENTS_PART));
|
|
4076
4554
|
}
|
|
4077
4555
|
if (wants("steps") && recipe.steps.length === 0) {
|
|
4078
4556
|
notes.push(emptyPartNote(recipe, METHOD_PART));
|
|
4079
4557
|
}
|
|
4558
|
+
if (wants("steps") && recipe.stepsAsOneBlock === true && recipe.steps.length > 0) {
|
|
4559
|
+
notes.push(
|
|
4560
|
+
mustKeep(
|
|
4561
|
+
`${recipe.sourceName} publishes this method as one block of prose rather than as steps of its own, so what is here is that block and not the first step of several.`
|
|
4562
|
+
)
|
|
4563
|
+
);
|
|
4564
|
+
}
|
|
4565
|
+
return notes;
|
|
4566
|
+
}
|
|
4567
|
+
function buildRecipeView(recipe, options) {
|
|
4568
|
+
const { factor, notes: yieldNotes } = resolveFactor(recipe, options.servings);
|
|
4569
|
+
const wants = (section) => options.sections.includes(section);
|
|
4570
|
+
const ingredients = factor === null || factor === 1 ? passthroughIngredients(recipe.ingredients, recipe.language) : scaleIngredients(recipe.ingredients, { factor, language: recipe.language });
|
|
4571
|
+
const notes = [...yieldNotes, ...partNotes(recipe, wants)];
|
|
4080
4572
|
const rounded = ingredients.filter((entry) => entry.scaling === "rounded");
|
|
4081
4573
|
const unscaled = ingredients.filter((entry) => entry.scaling === "unscaled");
|
|
4082
4574
|
if (rounded.length > 0) {
|
|
@@ -4130,6 +4622,9 @@ function buildRecipeView(recipe, options) {
|
|
|
4130
4622
|
prep_minutes: wants("times") ? recipe.prepMinutes : null,
|
|
4131
4623
|
cook_minutes: wants("times") ? recipe.cookMinutes : null,
|
|
4132
4624
|
total_minutes: wants("times") ? recipe.totalMinutes : null,
|
|
4625
|
+
rest_minutes: wants("times") ? recipe.restMinutes : null,
|
|
4626
|
+
steps_as_one_block: recipe.stepsAsOneBlock,
|
|
4627
|
+
withheld: recipe.withheld,
|
|
4133
4628
|
category: recipe.category,
|
|
4134
4629
|
author: recipe.author,
|
|
4135
4630
|
rating: recipe.rating,
|
|
@@ -4260,7 +4755,10 @@ var compareRecipesInput = strictInput({
|
|
|
4260
4755
|
servings: z4.number().int().min(1).max(500).optional().describe("Rescale every version to this many, which is what makes the lists comparable."),
|
|
4261
4756
|
sections: z4.array(z4.enum(SECTIONS)).default(["ingredients"]).describe("Which parts of each version to return. Several full recipes is a lot of text."),
|
|
4262
4757
|
max_steps: z4.number().int().min(1).max(100).default(10).describe("Steps to return per version."),
|
|
4263
|
-
max_step_chars: z4.number().int().min(80).max(4e3).default(600).describe("Characters kept per step. Raise it only if a step was cut mid-sentence.")
|
|
4758
|
+
max_step_chars: z4.number().int().min(80).max(4e3).default(600).describe("Characters kept per step. Raise it only if a step was cut mid-sentence."),
|
|
4759
|
+
sources: z4.array(z4.string()).optional().describe(
|
|
4760
|
+
"Sources to compare, by id. Left out, every source is read, which is the point of this tool. Name two or three to put particular traditions side by side. The ids are the ones 'per_source' reports."
|
|
4761
|
+
)
|
|
4264
4762
|
});
|
|
4265
4763
|
var compareRecipesOutput = z4.object({
|
|
4266
4764
|
dish: z4.string(),
|
|
@@ -4277,6 +4775,9 @@ function listNames(names2) {
|
|
|
4277
4775
|
}
|
|
4278
4776
|
return `${names2.slice(0, -1).join(", ")} and ${names2.at(-1)}`;
|
|
4279
4777
|
}
|
|
4778
|
+
function agrees(names2, singular, plural) {
|
|
4779
|
+
return names2.length === 1 ? singular : plural;
|
|
4780
|
+
}
|
|
4280
4781
|
function weighTitlesAgainstTheDish(payloads, dish, notes) {
|
|
4281
4782
|
const weighed = payloads.map((payload) => ({
|
|
4282
4783
|
payload,
|
|
@@ -4340,20 +4841,20 @@ function describeDifferences(recipes, payloads, sections) {
|
|
|
4340
4841
|
}
|
|
4341
4842
|
if (timed.length > 0 && untimed.length > 0) {
|
|
4342
4843
|
differences.push(
|
|
4343
|
-
`${listNames(untimed.map(nameOf))} states no time for this recipe, so there is nothing there to compare against.`
|
|
4844
|
+
`${listNames(untimed.map(nameOf))} ${agrees(untimed.map(nameOf), "states", "state")} no time for this recipe, so there is nothing there to compare against.`
|
|
4344
4845
|
);
|
|
4345
4846
|
}
|
|
4346
4847
|
}
|
|
4347
4848
|
const rated = recipes.filter((recipe) => recipe.rating !== null);
|
|
4348
4849
|
if (rated.length > 0 && rated.length < recipes.length) {
|
|
4349
4850
|
differences.push(
|
|
4350
|
-
`Only ${listNames(rated.map(nameOf))} carries a reader rating, so the versions cannot be compared on one.`
|
|
4851
|
+
`Only ${listNames(rated.map(nameOf))} ${agrees(rated.map(nameOf), "carries", "carry")} a reader rating, so the versions cannot be compared on one.`
|
|
4351
4852
|
);
|
|
4352
4853
|
}
|
|
4353
4854
|
const credited = recipes.filter((recipe) => recipe.author !== null);
|
|
4354
4855
|
if (credited.length > 0 && credited.length < recipes.length) {
|
|
4355
4856
|
differences.push(
|
|
4356
|
-
`Only ${listNames(credited.map(nameOf))} credits an author; the rest are written by whoever edited the page.`
|
|
4857
|
+
`Only ${listNames(credited.map(nameOf))} ${agrees(credited.map(nameOf), "credits", "credit")} an author; the rest are written by whoever edited the page.`
|
|
4357
4858
|
);
|
|
4358
4859
|
}
|
|
4359
4860
|
for (const recipe of recipes) {
|
|
@@ -4368,14 +4869,18 @@ function describeDifferences(recipes, payloads, sections) {
|
|
|
4368
4869
|
const silent = recipes.filter((recipe) => recipe.license === null);
|
|
4369
4870
|
if (silent.length > 0 && silent.length < recipes.length) {
|
|
4370
4871
|
differences.push(
|
|
4371
|
-
`${listNames(silent.map(nameOf))} states no terms of use. Silence is not permission.`
|
|
4872
|
+
`${listNames(silent.map(nameOf))} ${agrees(silent.map(nameOf), "states", "state")} no terms of use. Silence is not permission.`
|
|
4372
4873
|
);
|
|
4373
4874
|
}
|
|
4374
4875
|
return differences;
|
|
4375
4876
|
}
|
|
4376
4877
|
async function runCompareRecipes(client, args) {
|
|
4377
4878
|
try {
|
|
4378
|
-
const merged = await client.searchRecipes(
|
|
4879
|
+
const merged = await client.searchRecipes(
|
|
4880
|
+
args.dish,
|
|
4881
|
+
3,
|
|
4882
|
+
args.sources
|
|
4883
|
+
);
|
|
4379
4884
|
const best = /* @__PURE__ */ new Map();
|
|
4380
4885
|
for (const row of merged.rows) {
|
|
4381
4886
|
if (!best.has(row.source)) {
|
|
@@ -4547,7 +5052,9 @@ var getRecipeDescription = [
|
|
|
4547
5052
|
"A field a source does not publish is null, never zero. Credit the source and link the url when you repeat any of it."
|
|
4548
5053
|
].join(" ");
|
|
4549
5054
|
var getRecipeInput = strictInput({
|
|
4550
|
-
id: z5.string().min(1).max(500).describe(
|
|
5055
|
+
id: z5.string().min(1).max(500).describe(
|
|
5056
|
+
"From search_recipes, such as 'marmiton:44078', 'goodfood:recipes/carbonara' or 'cookbook:Cookbook:Carbonara'. Two sources address a recipe by a bare number, so spell an id with its source."
|
|
5057
|
+
),
|
|
4551
5058
|
servings: z5.number().int().min(1).max(500).optional().describe("Rescale to this many. Left out, the quantities come back as published."),
|
|
4552
5059
|
sections: z5.array(z5.enum(SECTIONS)).default(["ingredients", "steps"]).describe("Which parts to return. A full recipe is a lot of text, so this defaults to two."),
|
|
4553
5060
|
max_steps: z5.number().int().min(1).max(100).default(20).describe("Steps to return. The answer says how many more there are."),
|
|
@@ -4857,7 +5364,9 @@ async function runSearchRecipes(client, args) {
|
|
|
4857
5364
|
}
|
|
4858
5365
|
if (results.length === 0 && answered.length === merged.reports.length) {
|
|
4859
5366
|
notes.push(
|
|
4860
|
-
|
|
5367
|
+
mustKeep(
|
|
5368
|
+
"Every source answered and none holds anything under this wording. Try the dish's name in another language, or name a main ingredient instead."
|
|
5369
|
+
)
|
|
4861
5370
|
);
|
|
4862
5371
|
}
|
|
4863
5372
|
const derived = [
|
|
@@ -4914,10 +5423,7 @@ function buildInstructions(sources) {
|
|
|
4914
5423
|
"Every result carries a url. Credit the source you took a recipe from and link it; some publish under a licence that requires it, and one that states no terms at all has not granted permission."
|
|
4915
5424
|
].join(" ");
|
|
4916
5425
|
}
|
|
4917
|
-
var INSTRUCTIONS = buildInstructions([
|
|
4918
|
-
{ name: "Marmiton", language: "fr" },
|
|
4919
|
-
{ name: "Wikibooks Cookbook", language: "en" }
|
|
4920
|
-
]);
|
|
5426
|
+
var INSTRUCTIONS = buildInstructions([...PROFILES]);
|
|
4921
5427
|
function createServer(options = {}) {
|
|
4922
5428
|
const config = options.config ?? loadConfig();
|
|
4923
5429
|
const logger = options.logger ?? createLogger(config.logLevel);
|