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/sources/client.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import process from "process";
|
|
3
3
|
|
|
4
4
|
// src/version.ts
|
|
5
|
-
var PKG_VERSION = "
|
|
5
|
+
var PKG_VERSION = "3.0.1";
|
|
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
|
|
437
|
-
var
|
|
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 (
|
|
620
|
+
if (SITE_URL3.test(raw)) {
|
|
454
621
|
return { reference: raw, why: "the address is a Marmiton page", guess: false };
|
|
455
622
|
}
|
|
456
|
-
if (
|
|
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
|
|
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() {
|