mcp-recipes 2.0.0 → 3.0.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.
@@ -2,7 +2,7 @@
2
2
  import process from "process";
3
3
 
4
4
  // src/version.ts
5
- var PKG_VERSION = "2.0.0";
5
+ var PKG_VERSION = "3.0.0";
6
6
  var REPO_URL = "https://github.com/smeet666/mcp-recipes";
7
7
  var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
8
8
 
@@ -214,7 +214,10 @@ function example(sources) {
214
214
  }
215
215
 
216
216
  // src/sources/registry.ts
217
+ import { GoodFoodClient } from "mcp-bbc-goodfood/client";
217
218
  import { MarmitonClient } from "mcp-marmiton/client";
219
+ import { PtitchefClient } from "mcp-ptitchef/client";
220
+ import { SupertoinetteClient } from "mcp-supertoinette/client";
218
221
  import { CookbookClient } from "mcp-wikibooks-cookbook/client";
219
222
 
220
223
  // src/sources/adapter.ts
@@ -416,12 +419,176 @@ function cookbookDetail(payload) {
416
419
  nutrition: publishedFigures(recipe.nutrition),
417
420
  equipment: textList(recipe.equipment),
418
421
  tips: textList(recipe.tips),
422
+ // The Cookbook publishes no resting time, says nothing about how a page
423
+ // lays its method out, and keeps nothing behind a subscription.
424
+ restMinutes: null,
425
+ stepsAsOneBlock: null,
426
+ withheld: null,
419
427
  // A licence is only stated when both halves of it arrived: a name with no
420
428
  // address, or an address with no name, is not terms anyone can follow.
421
429
  license: licenseTitle && licenseUrl ? { title: licenseTitle, url: licenseUrl } : null
422
430
  };
423
431
  }
424
432
 
433
+ // src/sources/goodfood.ts
434
+ var GOODFOOD_PROFILE = {
435
+ id: "goodfood",
436
+ name: "BBC Good Food",
437
+ homeUrl: "https://www.bbcgoodfood.com",
438
+ language: "en",
439
+ attribution: "Source: BBC Good Food",
440
+ // The search serves recipe pages; a question aimed at the site's reviews and
441
+ // buying guides is answered with recipes all the same.
442
+ mixesReferencePages: false
443
+ };
444
+ var SITE_URL2 = /^https?:\/\/(?:www\.)?bbcgoodfood\.com\//i;
445
+ var REFERENCE = /^recipes\/[^/]+$/;
446
+ var DOT_SEGMENT = /(?:^|\/)\.\.?(?:\/|$)/;
447
+ var LEADING_SLASH = /^\/+/;
448
+ function asReference(raw) {
449
+ const path = raw.replace(LEADING_SLASH, "");
450
+ return REFERENCE.test(path) && !DOT_SEGMENT.test(`/${path}`) ? path : null;
451
+ }
452
+ function meansFor(report) {
453
+ if (count(report.total_available) === null) {
454
+ return null;
455
+ }
456
+ 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";
457
+ }
458
+ function nutritionPanel(recipe) {
459
+ const facts = Array.isArray(recipe.nutrition) ? recipe.nutrition : [];
460
+ const kept = {};
461
+ for (const fact of facts) {
462
+ const label = text(fact?.label);
463
+ const value = count(fact?.value);
464
+ if (label === null || value === null) {
465
+ continue;
466
+ }
467
+ const unit = text(fact?.unit);
468
+ kept[label] = unit === null ? String(value) : `${value} ${unit}`;
469
+ }
470
+ const per = text(recipe.nutrition_per);
471
+ if (Object.keys(kept).length === 0) {
472
+ return null;
473
+ }
474
+ if (per !== null) {
475
+ kept.per = per;
476
+ }
477
+ return kept;
478
+ }
479
+ function goodfoodAdapter(reader) {
480
+ return {
481
+ ...GOODFOOD_PROFILE,
482
+ claims(raw) {
483
+ if (SITE_URL2.test(raw)) {
484
+ const path2 = asReference(new URL(raw).pathname);
485
+ return path2 === null ? null : { reference: path2, why: "the address is a BBC Good Food recipe page", guess: false };
486
+ }
487
+ const path = asReference(raw);
488
+ return path === null ? null : {
489
+ reference: path,
490
+ why: "the path is the shape BBC Good Food mints for a recipe",
491
+ guess: false
492
+ };
493
+ },
494
+ async search(query, limit) {
495
+ const outcome = await reader.searchRecipes(query, { limit });
496
+ const report = outcome.data ?? {};
497
+ const list = rowsOf(report.results, GOODFOOD_PROFILE);
498
+ const rows = [];
499
+ let skipped = outcome.skipped?.length ?? 0;
500
+ for (const summary of list) {
501
+ const id = text(summary?.id);
502
+ const title = text(summary?.title);
503
+ const url = text(summary?.url);
504
+ if (!(id && title && url)) {
505
+ skipped += 1;
506
+ continue;
507
+ }
508
+ rows.push({
509
+ id: namespacedId(GOODFOOD_PROFILE.id, id),
510
+ source: GOODFOOD_PROFILE.id,
511
+ sourceName: GOODFOOD_PROFILE.name,
512
+ title,
513
+ url,
514
+ imageUrl: text(summary?.image_url),
515
+ // A row carries a title and a picture and no prose.
516
+ excerpt: null
517
+ });
518
+ }
519
+ return {
520
+ rows,
521
+ skipped,
522
+ reportedTotal: count(report.total_available),
523
+ reportedTotalMeans: meansFor(report),
524
+ cached: outcome.cached
525
+ };
526
+ },
527
+ async getRecipe(reference) {
528
+ const outcome = await reader.getRecipe(reference);
529
+ return { recipe: goodfoodDetail(outcome.data), cached: outcome.cached };
530
+ }
531
+ };
532
+ }
533
+ function goodfoodDetail(payload) {
534
+ const recipe = payload ?? {};
535
+ const published = text(recipe.yield_text);
536
+ const span = readYieldSpan(published);
537
+ const value = count(recipe.rating);
538
+ const groups = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
539
+ const lines = [];
540
+ for (const group of groups) {
541
+ const heading = text(group?.heading);
542
+ if (heading !== null) {
543
+ lines.push(heading);
544
+ }
545
+ for (const entry of group?.ingredients ?? []) {
546
+ const line = text(entry?.text);
547
+ if (line !== null) {
548
+ lines.push(line);
549
+ }
550
+ }
551
+ }
552
+ return {
553
+ ...detailBase(GOODFOOD_PROFILE),
554
+ id: namespacedId(GOODFOOD_PROFILE.id, required(recipe.id, "id", GOODFOOD_PROFILE)),
555
+ title: required(recipe.title, "title", GOODFOOD_PROFILE),
556
+ url: required(recipe.url, "url", GOODFOOD_PROFILE),
557
+ // A row carries the picture; the recipe page's own is not read.
558
+ imageUrl: null,
559
+ yieldCount: count(recipe.yield_count) ?? span.count,
560
+ yieldMax: span.max,
561
+ yieldText: published,
562
+ yieldUnit: span.unit,
563
+ ingredients: lines,
564
+ steps: Array.isArray(recipe.steps) ? recipe.steps.filter((step) => typeof step === "string") : [],
565
+ publishedSections: null,
566
+ prepMinutes: count(recipe.prep_minutes),
567
+ cookMinutes: count(recipe.cook_minutes),
568
+ totalMinutes: count(recipe.total_minutes),
569
+ // The site publishes no resting time of its own.
570
+ restMinutes: null,
571
+ stepsAsOneBlock: null,
572
+ // The page carries these and the site sells access to them. What is said
573
+ // here is what the site did, so an answer never reports a wall as a page
574
+ // this server could not read.
575
+ withheld: recipe.premium === true ? {
576
+ parts: ["ingredients", "method"],
577
+ 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"
578
+ } : null,
579
+ // The site files a recipe under collections rather than under one category
580
+ // on the page itself.
581
+ category: null,
582
+ author: text(recipe.author),
583
+ rating: value === null ? null : { value, count: count(recipe.rating_count), max: 5 },
584
+ nutrition: nutritionPanel(recipe),
585
+ equipment: [],
586
+ tips: [],
587
+ // The site states no licence on a recipe page, and silence is not a grant.
588
+ license: null
589
+ };
590
+ }
591
+
425
592
  // src/sources/marmiton.ts
426
593
  var ABSOLUTE_URL = /^https?:\/\//i;
427
594
  var MARMITON_PROFILE = {
@@ -433,8 +600,8 @@ var MARMITON_PROFILE = {
433
600
  // Every page the search returns is a recipe.
434
601
  mixesReferencePages: false
435
602
  };
436
- var SITE_URL2 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
437
- var REFERENCE = /^\d+$/;
603
+ var SITE_URL3 = /^https?:\/\/(?:www\.)?marmiton\.org\//i;
604
+ var REFERENCE2 = /^\d+$/;
438
605
  async function readSearch(reader, query) {
439
606
  try {
440
607
  return await reader.search(query);
@@ -450,10 +617,10 @@ function marmitonAdapter(reader) {
450
617
  return {
451
618
  ...MARMITON_PROFILE,
452
619
  claims(raw) {
453
- if (SITE_URL2.test(raw)) {
620
+ if (SITE_URL3.test(raw)) {
454
621
  return { reference: raw, why: "the address is a Marmiton page", guess: false };
455
622
  }
456
- if (REFERENCE.test(raw)) {
623
+ if (REFERENCE2.test(raw)) {
457
624
  return {
458
625
  reference: raw,
459
626
  why: "a bare number is the shape Marmiton mints",
@@ -537,14 +704,277 @@ function marmitonDetail(payload) {
537
704
  nutrition: publishedFigures(recipe.nutrition),
538
705
  equipment: [],
539
706
  tips: [],
707
+ // Marmiton publishes no resting time, no word on how it lays its method
708
+ // out, and keeps nothing back.
709
+ restMinutes: null,
710
+ stepsAsOneBlock: null,
711
+ withheld: null,
540
712
  // Marmiton states no licence on a recipe page, and silence is not a grant.
541
713
  license: null
542
714
  };
543
715
  }
544
716
 
717
+ // src/sources/ptitchef.ts
718
+ var PTITCHEF_PROFILE = {
719
+ id: "ptitchef",
720
+ name: "Ptitchef",
721
+ homeUrl: "https://www.ptitchef.com",
722
+ language: "fr",
723
+ attribution: "Source: Ptitchef",
724
+ // Every row a listing serves names a recipe; a topic the site answers with a
725
+ // guide of its own groups recipes under headings rather than mixing in pages
726
+ // about ingredients.
727
+ mixesReferencePages: false
728
+ };
729
+ var SITE_URL4 = /^https?:\/\/(?:www\.)?ptitchef\.com\//i;
730
+ var REFERENCE3 = /^recettes\/[^/]+\/[^/]+-fid-\d+$/;
731
+ var DOT_SEGMENT2 = /(?:^|\/)\.\.?(?:\/|$)/;
732
+ var LEADING_SLASH2 = /^\/+/;
733
+ function asReference2(raw) {
734
+ const path = raw.replace(LEADING_SLASH2, "");
735
+ return REFERENCE3.test(path) && !DOT_SEGMENT2.test(`/${path}`) ? path : null;
736
+ }
737
+ function meansFor2(listing) {
738
+ if (count(listing.total_available) === null) {
739
+ return null;
740
+ }
741
+ switch (listing.kind) {
742
+ case "topic":
743
+ case "category":
744
+ case "standing":
745
+ return "recipes Ptitchef says the category page it answered this wording with holds; it counts that page rather than what the words matched";
746
+ case "free_text":
747
+ case "fridge":
748
+ return "rows Ptitchef served on the single page it answered this wording with; the site offers no more for it";
749
+ case "recipe":
750
+ return "the one recipe Ptitchef answered this wording with, by opening its page";
751
+ default:
752
+ return null;
753
+ }
754
+ }
755
+ function ptitchefAdapter(reader) {
756
+ return {
757
+ ...PTITCHEF_PROFILE,
758
+ claims(raw) {
759
+ if (SITE_URL4.test(raw)) {
760
+ const path2 = asReference2(new URL(raw).pathname);
761
+ return path2 === null ? null : { reference: path2, why: "the address is a Ptitchef recipe page", guess: false };
762
+ }
763
+ const path = asReference2(raw);
764
+ return path === null ? null : {
765
+ reference: path,
766
+ why: "the path is the shape Ptitchef mints for a recipe",
767
+ guess: false
768
+ };
769
+ },
770
+ async search(query) {
771
+ const outcome = await reader.searchRecipes(query);
772
+ const listing = outcome.data ?? {};
773
+ const list = rowsOf(listing.results, PTITCHEF_PROFILE);
774
+ const rows = [];
775
+ let skipped = outcome.skipped?.length ?? 0;
776
+ for (const summary of list) {
777
+ const id = text(summary?.id);
778
+ const title = text(summary?.title);
779
+ const url = text(summary?.url);
780
+ if (!(id && title && url)) {
781
+ skipped += 1;
782
+ continue;
783
+ }
784
+ rows.push({
785
+ id: namespacedId(PTITCHEF_PROFILE.id, id),
786
+ source: PTITCHEF_PROFILE.id,
787
+ sourceName: PTITCHEF_PROFILE.name,
788
+ title,
789
+ url,
790
+ imageUrl: text(summary?.image_url),
791
+ excerpt: text(summary?.ingredients_preview)
792
+ });
793
+ }
794
+ return {
795
+ rows,
796
+ skipped,
797
+ reportedTotal: count(listing.total_available),
798
+ reportedTotalMeans: meansFor2(listing),
799
+ cached: outcome.cached
800
+ };
801
+ },
802
+ async getRecipe(reference) {
803
+ const outcome = await reader.getRecipe(reference);
804
+ return { recipe: ptitchefDetail(outcome.data), cached: outcome.cached };
805
+ }
806
+ };
807
+ }
808
+ function ptitchefDetail(payload) {
809
+ const recipe = payload ?? {};
810
+ const published = text(recipe.yield_text);
811
+ const span = readYieldSpan(published);
812
+ const value = count(recipe.rating);
813
+ const steps = Array.isArray(recipe.steps) ? recipe.steps : [];
814
+ const lines = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
815
+ return {
816
+ ...detailBase(PTITCHEF_PROFILE),
817
+ id: namespacedId(PTITCHEF_PROFILE.id, required(recipe.id, "id", PTITCHEF_PROFILE)),
818
+ title: required(recipe.title, "title", PTITCHEF_PROFILE),
819
+ url: required(recipe.url, "url", PTITCHEF_PROFILE),
820
+ imageUrl: text(recipe.image_url),
821
+ yieldCount: count(recipe.yield_count) ?? span.count,
822
+ yieldMax: span.max,
823
+ yieldText: published,
824
+ yieldUnit: text(recipe.yield_unit) ?? span.unit,
825
+ ingredients: lines.filter((line) => typeof line === "string"),
826
+ // A step carries a picture of itself, which is not part of the method.
827
+ steps: steps.map((step) => text(step?.text)).filter((step) => step !== null),
828
+ publishedSections: null,
829
+ prepMinutes: count(recipe.prep_minutes),
830
+ cookMinutes: count(recipe.cook_minutes),
831
+ totalMinutes: count(recipe.total_minutes),
832
+ // The site publishes no resting time of its own.
833
+ restMinutes: null,
834
+ stepsAsOneBlock: typeof recipe.steps_are_one_block === "boolean" ? recipe.steps_are_one_block : null,
835
+ withheld: null,
836
+ category: text(recipe.category),
837
+ author: text(recipe.author),
838
+ // The site prints two counts beside a rating, one of readers who rated and
839
+ // one of readers who wrote. Only the first is what a rating rests on, and
840
+ // adding them would count twice anyone who did both.
841
+ rating: value === null ? null : { value, count: count(recipe.rating_count), max: 5 },
842
+ nutrition: publishedFigures(recipe.nutrition),
843
+ equipment: [],
844
+ tips: [],
845
+ // The site states no licence on a recipe page, and silence is not a grant.
846
+ license: null
847
+ };
848
+ }
849
+
850
+ // src/sources/supertoinette.ts
851
+ var SUPERTOINETTE_PROFILE = {
852
+ id: "supertoinette",
853
+ name: "Supertoinette",
854
+ homeUrl: "https://www.supertoinette.com",
855
+ language: "fr",
856
+ attribution: "Source: Supertoinette",
857
+ // The reader sets aside any row that opens onto something other than a
858
+ // recipe, so what arrives here is recipes.
859
+ mixesReferencePages: false
860
+ };
861
+ var SITE_RECIPE_URL = /^https?:\/\/(?:www\.)?supertoinette\.com\/recette\/([1-9][0-9]{0,9})\//i;
862
+ var REFERENCE4 = /^[1-9][0-9]{0,9}$/;
863
+ function supertoinetteAdapter(reader) {
864
+ return {
865
+ ...SUPERTOINETTE_PROFILE,
866
+ claims(raw) {
867
+ const address = SITE_RECIPE_URL.exec(raw);
868
+ if (address?.[1]) {
869
+ return {
870
+ reference: address[1],
871
+ why: "the address is a Supertoinette recipe page",
872
+ guess: false
873
+ };
874
+ }
875
+ if (REFERENCE4.test(raw)) {
876
+ return {
877
+ reference: raw,
878
+ why: "a bare number is the shape Supertoinette mints",
879
+ guess: false
880
+ };
881
+ }
882
+ return null;
883
+ },
884
+ async search(query) {
885
+ const outcome = await reader.searchRecipes({ query, page: 1, category: null });
886
+ const listing = outcome.data?.listing ?? {};
887
+ const list = rowsOf(listing.results, SUPERTOINETTE_PROFILE);
888
+ const rows = [];
889
+ let skipped = outcome.skipped?.length ?? 0;
890
+ for (const summary of list) {
891
+ const id = text(summary?.id);
892
+ const title = text(summary?.title);
893
+ const url = text(summary?.url);
894
+ if (!(id && title && url)) {
895
+ skipped += 1;
896
+ continue;
897
+ }
898
+ rows.push({
899
+ id: namespacedId(SUPERTOINETTE_PROFILE.id, id),
900
+ source: SUPERTOINETTE_PROFILE.id,
901
+ sourceName: SUPERTOINETTE_PROFILE.name,
902
+ title,
903
+ url,
904
+ imageUrl: text(summary?.image_url),
905
+ excerpt: text(summary?.description)
906
+ });
907
+ }
908
+ return {
909
+ rows,
910
+ skipped,
911
+ // The site prints no total anywhere on a search page. The rows of one
912
+ // page are not put here in its place: the site pages, so they would
913
+ // understate what it holds rather than count it.
914
+ reportedTotal: null,
915
+ reportedTotalMeans: null,
916
+ cached: outcome.cached
917
+ };
918
+ },
919
+ async getRecipe(reference) {
920
+ const outcome = await reader.getRecipe(reference);
921
+ return { recipe: supertoinetteDetail(outcome.data), cached: outcome.cached };
922
+ }
923
+ };
924
+ }
925
+ function supertoinetteDetail(payload) {
926
+ const recipe = payload ?? {};
927
+ const rating = recipe.rating;
928
+ const value = count(rating?.value);
929
+ const published = text(recipe.yield_text);
930
+ const span = readYieldSpan(published);
931
+ const lines = Array.isArray(recipe.ingredients) ? recipe.ingredients : [];
932
+ return {
933
+ ...detailBase(SUPERTOINETTE_PROFILE),
934
+ id: namespacedId(SUPERTOINETTE_PROFILE.id, required(recipe.id, "id", SUPERTOINETTE_PROFILE)),
935
+ title: required(recipe.title, "title", SUPERTOINETTE_PROFILE),
936
+ url: required(recipe.url, "url", SUPERTOINETTE_PROFILE),
937
+ imageUrl: text(recipe.images?.[0]),
938
+ yieldCount: span.count,
939
+ yieldMax: span.max,
940
+ yieldText: published,
941
+ yieldUnit: span.unit,
942
+ // The whole line as printed, headings included: a heading dropped would
943
+ // merge two groups of a recipe that may each call for the same thing.
944
+ // The ingredient page the site links a line to is not carried, since the
945
+ // site links the wrong page often enough that pairing recipes by it would
946
+ // pair the wrong recipes.
947
+ ingredients: lines.map((line) => text(line?.raw)).filter((line) => line !== null),
948
+ steps: textList(recipe.steps),
949
+ publishedSections: null,
950
+ prepMinutes: count(recipe.prep_minutes),
951
+ cookMinutes: count(recipe.cook_minutes),
952
+ totalMinutes: count(recipe.total_minutes),
953
+ // The site prints this on its own, and its own total already holds it.
954
+ // Adding it to anything here would count it twice.
955
+ restMinutes: count(recipe.rest_minutes),
956
+ // The reader hands over the steps it read and says nothing about whether
957
+ // the page numbered them.
958
+ stepsAsOneBlock: null,
959
+ withheld: null,
960
+ category: text(recipe.category),
961
+ author: text(recipe.author),
962
+ // The scale is published, so a value is never read against a guess.
963
+ rating: value === null ? null : { value, count: count(rating?.count), max: count(rating?.scale) },
964
+ // This site publishes no nutrition panel at all, so there is nothing here
965
+ // to repeat. An empty panel would read as a page that printed one and left
966
+ // every figure out.
967
+ nutrition: null,
968
+ equipment: [],
969
+ tips: [],
970
+ // The site states no licence on a recipe page, and silence is not a grant.
971
+ license: null
972
+ };
973
+ }
974
+
545
975
  // src/sources/registry.ts
546
- var SOURCE_IDS = [MARMITON_PROFILE.id, COOKBOOK_PROFILE.id];
547
- function buildSources(config, readers = {}) {
976
+ var PAGE_READ_BOUNDS = { maxBodyBytes: 8e6, budgetMs: 6e4 };
977
+ function buildSources(config, readers, logger) {
548
978
  const shared = {
549
979
  userAgent: config.userAgent,
550
980
  minIntervalMs: config.minIntervalMs,
@@ -556,9 +986,18 @@ function buildSources(config, readers = {}) {
556
986
  };
557
987
  const marmitonOptions = { config: shared };
558
988
  const cookbookOptions = { config: shared };
989
+ const ptitchefOptions = {
990
+ config: { ...shared, ...PAGE_READ_BOUNDS },
991
+ logger
992
+ };
993
+ const goodfoodOptions = { config: shared, logger };
994
+ const supertoinetteOptions = { config: shared, logger };
559
995
  return [
560
996
  marmitonAdapter(readers.marmiton ?? new MarmitonClient(marmitonOptions)),
561
- cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions))
997
+ cookbookAdapter(readers.cookbook ?? new CookbookClient(cookbookOptions)),
998
+ ptitchefAdapter(readers.ptitchef ?? new PtitchefClient(ptitchefOptions)),
999
+ goodfoodAdapter(readers.goodfood ?? new GoodFoodClient(goodfoodOptions)),
1000
+ supertoinetteAdapter(readers.supertoinette ?? new SupertoinetteClient(supertoinetteOptions))
562
1001
  ];
563
1002
  }
564
1003
  function selectSources(sources, wanted) {
@@ -1175,7 +1614,7 @@ var RecipesClient = class {
1175
1614
  constructor(options = {}) {
1176
1615
  this.config = withGuarantees({ ...loadConfig(), ...options.config });
1177
1616
  this.logger = options.logger ?? createLogger(this.config.logLevel);
1178
- this.sources = options.sources ?? buildSources(this.config, options.readers ?? {});
1617
+ this.sources = options.sources ?? buildSources(this.config, options.readers ?? {}, this.logger);
1179
1618
  }
1180
1619
  /** What every source sees this client call itself. */
1181
1620
  get userAgent() {