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/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 = "2.0.0";
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 SITE_URL2 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
446
- var REFERENCE = /^\d+$/;
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 (SITE_URL2.test(raw)) {
571
+ if (SITE_URL3.test(raw)) {
463
572
  return { reference: raw, why: "the address is a Marmiton page", guess: false };
464
573
  }
465
- if (REFERENCE.test(raw)) {
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 SOURCE_IDS = [MARMITON_PROFILE.id, COOKBOOK_PROFILE.id];
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 buildRecipeView(recipe, options) {
4070
- const { factor, notes: yieldNotes } = resolveFactor(recipe, options.servings);
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(args.dish, 3);
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("From search_recipes, such as 'marmiton:44078' or 'cookbook:Cookbook:Carbonara'."),
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
- "Every source answered and none holds anything under this wording. Try the dish's name in another language, or name a main ingredient instead."
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);