mcp-recipes 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +239 -0
- package/README.md +174 -7
- package/dist/index.js +1171 -170
- package/dist/index.js.map +1 -1
- package/dist/recipe/scale.js +185 -38
- package/dist/recipe/scale.js.map +1 -1
- package/dist/sources/client.d.ts +180 -3
- package/dist/sources/client.js +657 -36
- package/dist/sources/client.js.map +1 -1
- package/package.json +3 -3
package/dist/sources/client.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// src/version.ts
|
|
2
|
-
var PKG_VERSION = "1.
|
|
2
|
+
var PKG_VERSION = "1.3.0";
|
|
3
3
|
var REPO_URL = "https://github.com/smeet666/mcp-recipes";
|
|
4
4
|
var ISSUES_URL = "https://github.com/smeet666/mcp-recipes/issues";
|
|
5
5
|
|
|
@@ -94,11 +94,17 @@ var RecipesError = class extends Error {
|
|
|
94
94
|
this.details = details;
|
|
95
95
|
}
|
|
96
96
|
};
|
|
97
|
+
var notFound = (message, details) => new RecipesError("not_found", message, details ?? {});
|
|
97
98
|
var invalidInput = (message, hint) => new RecipesError("invalid_input", message, hint ? { hint } : {});
|
|
99
|
+
var rateLimited = (message, details) => new RecipesError("rate_limited", message, {
|
|
100
|
+
hint: "Wait a moment and ask again. This says nothing about whether the recipe exists.",
|
|
101
|
+
...details
|
|
102
|
+
});
|
|
98
103
|
var parseFailure = (message, details) => new RecipesError("parse_failure", message, {
|
|
99
104
|
hint: `A site may have changed how it answers. Please report this at ${ISSUES_URL} with the arguments you used.`,
|
|
100
105
|
...details
|
|
101
106
|
});
|
|
107
|
+
var networkError = (message, details) => new RecipesError("network_error", message, details ?? {});
|
|
102
108
|
var timeout = (message, details) => new RecipesError("timeout", message, details ?? {});
|
|
103
109
|
function toRecipesError(error) {
|
|
104
110
|
if (error instanceof RecipesError) return error;
|
|
@@ -109,6 +115,30 @@ function toRecipesError(error) {
|
|
|
109
115
|
const details = raw && typeof raw.details === "object" && raw.details !== null ? raw.details : {};
|
|
110
116
|
return new RecipesError(known ?? "network_error", message, details);
|
|
111
117
|
}
|
|
118
|
+
function fromSource(error, sourceName) {
|
|
119
|
+
if (error instanceof RecipesError) return error;
|
|
120
|
+
const known = toRecipesError(error);
|
|
121
|
+
const reported = known.message.replace(/\s+/g, " ").trim();
|
|
122
|
+
const opening = known.code === "not_found" ? `${sourceName} holds nothing under this name.` : `${sourceName} could not be read.`;
|
|
123
|
+
const message = reported === "" ? opening : `${opening} It reports: "${reported}"`;
|
|
124
|
+
const details = {};
|
|
125
|
+
if (known.details.url !== void 0) details.url = known.details.url;
|
|
126
|
+
if (known.details.status !== void 0) details.status = known.details.status;
|
|
127
|
+
switch (known.code) {
|
|
128
|
+
case "not_found":
|
|
129
|
+
return notFound(message, details);
|
|
130
|
+
case "invalid_input":
|
|
131
|
+
return invalidInput(message);
|
|
132
|
+
case "rate_limited":
|
|
133
|
+
return rateLimited(message, details);
|
|
134
|
+
case "parse_failure":
|
|
135
|
+
return parseFailure(message, details);
|
|
136
|
+
case "timeout":
|
|
137
|
+
return timeout(message, details);
|
|
138
|
+
default:
|
|
139
|
+
return networkError(message, details);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
112
142
|
|
|
113
143
|
// src/sources/adapter.ts
|
|
114
144
|
function text(value) {
|
|
@@ -341,6 +371,9 @@ function cookbookDetail(payload) {
|
|
|
341
371
|
yieldUnit: text(recipe.yieldUnit) ?? span.unit,
|
|
342
372
|
ingredients: textList(recipe.ingredients),
|
|
343
373
|
steps: textList(recipe.steps),
|
|
374
|
+
// The page's own headings, which the reader reports. An answer holding no
|
|
375
|
+
// ingredient line uses them to say whether the page announced a list.
|
|
376
|
+
publishedSections: Array.isArray(recipe.sectionTitles) ? textList(recipe.sectionTitles) : null,
|
|
344
377
|
// The Cookbook publishes one time for a recipe rather than splitting it.
|
|
345
378
|
prepMinutes: null,
|
|
346
379
|
cookMinutes: null,
|
|
@@ -458,6 +491,10 @@ function marmitonDetail(payload) {
|
|
|
458
491
|
yieldUnit: span.unit ?? text(yields.unit),
|
|
459
492
|
ingredients: textList(recipe.ingredients),
|
|
460
493
|
steps: textList(recipe.steps),
|
|
494
|
+
// The reader hands over the parts of a page it recognised and not the
|
|
495
|
+
// headings the page carries, so there is nothing here to say how it is
|
|
496
|
+
// laid out.
|
|
497
|
+
publishedSections: null,
|
|
461
498
|
prepMinutes: count(recipe.prepMinutes),
|
|
462
499
|
cookMinutes: count(recipe.cookMinutes),
|
|
463
500
|
totalMinutes: count(recipe.totalMinutes),
|
|
@@ -510,6 +547,473 @@ function selectSources(sources, wanted) {
|
|
|
510
547
|
return chosen;
|
|
511
548
|
}
|
|
512
549
|
|
|
550
|
+
// src/sources/wordings.ts
|
|
551
|
+
var MAX_WORDINGS_PER_SOURCE = 3;
|
|
552
|
+
var FRENCH_FRAME = /* @__PURE__ */ new Set([
|
|
553
|
+
"a",
|
|
554
|
+
"ai",
|
|
555
|
+
"aimerais",
|
|
556
|
+
"au",
|
|
557
|
+
"aurais",
|
|
558
|
+
"auriez",
|
|
559
|
+
"aux",
|
|
560
|
+
"avec",
|
|
561
|
+
"avons",
|
|
562
|
+
"bcp",
|
|
563
|
+
"beaucoup",
|
|
564
|
+
"bien",
|
|
565
|
+
"bonjour",
|
|
566
|
+
"bonne",
|
|
567
|
+
"bonnes",
|
|
568
|
+
"cherche",
|
|
569
|
+
"chercher",
|
|
570
|
+
"cherchez",
|
|
571
|
+
"connais",
|
|
572
|
+
"connaissez",
|
|
573
|
+
"coucou",
|
|
574
|
+
"ce",
|
|
575
|
+
"ces",
|
|
576
|
+
"cette",
|
|
577
|
+
"comment",
|
|
578
|
+
"d",
|
|
579
|
+
"dans",
|
|
580
|
+
"de",
|
|
581
|
+
"des",
|
|
582
|
+
"du",
|
|
583
|
+
"en",
|
|
584
|
+
"est",
|
|
585
|
+
"et",
|
|
586
|
+
"facile",
|
|
587
|
+
"faire",
|
|
588
|
+
"fais",
|
|
589
|
+
"fait",
|
|
590
|
+
"idee",
|
|
591
|
+
"idees",
|
|
592
|
+
"id\xE9e",
|
|
593
|
+
"id\xE9es",
|
|
594
|
+
"j",
|
|
595
|
+
"je",
|
|
596
|
+
"l",
|
|
597
|
+
"la",
|
|
598
|
+
"le",
|
|
599
|
+
"les",
|
|
600
|
+
"ma",
|
|
601
|
+
"meilleur",
|
|
602
|
+
"meilleure",
|
|
603
|
+
"merci",
|
|
604
|
+
"mes",
|
|
605
|
+
"mon",
|
|
606
|
+
"ou",
|
|
607
|
+
"par",
|
|
608
|
+
"peux",
|
|
609
|
+
"plait",
|
|
610
|
+
"pla\xEEt",
|
|
611
|
+
"plus",
|
|
612
|
+
"pour",
|
|
613
|
+
"qu",
|
|
614
|
+
"que",
|
|
615
|
+
"quel",
|
|
616
|
+
"quelle",
|
|
617
|
+
"quelles",
|
|
618
|
+
"quels",
|
|
619
|
+
"qui",
|
|
620
|
+
"rapide",
|
|
621
|
+
"recette",
|
|
622
|
+
"recettes",
|
|
623
|
+
"recherche",
|
|
624
|
+
"s",
|
|
625
|
+
"salut",
|
|
626
|
+
"se",
|
|
627
|
+
"si",
|
|
628
|
+
"simple",
|
|
629
|
+
"sommes",
|
|
630
|
+
"souhaite",
|
|
631
|
+
"stp",
|
|
632
|
+
"suis",
|
|
633
|
+
"sur",
|
|
634
|
+
"svp",
|
|
635
|
+
"tres",
|
|
636
|
+
"tr\xE8s",
|
|
637
|
+
"un",
|
|
638
|
+
"une",
|
|
639
|
+
"veux",
|
|
640
|
+
"voudrais",
|
|
641
|
+
"vous",
|
|
642
|
+
"\xE0"
|
|
643
|
+
]);
|
|
644
|
+
var ENGLISH_FRAME = /* @__PURE__ */ new Set([
|
|
645
|
+
"a",
|
|
646
|
+
"about",
|
|
647
|
+
"am",
|
|
648
|
+
"an",
|
|
649
|
+
"and",
|
|
650
|
+
"any",
|
|
651
|
+
"anything",
|
|
652
|
+
"are",
|
|
653
|
+
"at",
|
|
654
|
+
"be",
|
|
655
|
+
"best",
|
|
656
|
+
"by",
|
|
657
|
+
"can",
|
|
658
|
+
"could",
|
|
659
|
+
"do",
|
|
660
|
+
"does",
|
|
661
|
+
"easy",
|
|
662
|
+
"find",
|
|
663
|
+
"for",
|
|
664
|
+
"from",
|
|
665
|
+
"get",
|
|
666
|
+
"good",
|
|
667
|
+
"hello",
|
|
668
|
+
"hi",
|
|
669
|
+
"how",
|
|
670
|
+
"i",
|
|
671
|
+
"in",
|
|
672
|
+
"into",
|
|
673
|
+
"is",
|
|
674
|
+
"it",
|
|
675
|
+
"like",
|
|
676
|
+
"looking",
|
|
677
|
+
"make",
|
|
678
|
+
"making",
|
|
679
|
+
"me",
|
|
680
|
+
"my",
|
|
681
|
+
"need",
|
|
682
|
+
"of",
|
|
683
|
+
"on",
|
|
684
|
+
"or",
|
|
685
|
+
"please",
|
|
686
|
+
"quick",
|
|
687
|
+
"recipe",
|
|
688
|
+
"recipes",
|
|
689
|
+
"should",
|
|
690
|
+
"simple",
|
|
691
|
+
"some",
|
|
692
|
+
"something",
|
|
693
|
+
"suggest",
|
|
694
|
+
"thank",
|
|
695
|
+
"thanks",
|
|
696
|
+
"that",
|
|
697
|
+
"the",
|
|
698
|
+
"less",
|
|
699
|
+
"over",
|
|
700
|
+
"than",
|
|
701
|
+
"to",
|
|
702
|
+
"under",
|
|
703
|
+
"want",
|
|
704
|
+
"what",
|
|
705
|
+
"which",
|
|
706
|
+
"will",
|
|
707
|
+
"with",
|
|
708
|
+
"would",
|
|
709
|
+
"you",
|
|
710
|
+
"your"
|
|
711
|
+
]);
|
|
712
|
+
var CONDITION_UNITS = /* @__PURE__ */ new Set([
|
|
713
|
+
"calorie",
|
|
714
|
+
"calories",
|
|
715
|
+
"couvert",
|
|
716
|
+
"couverts",
|
|
717
|
+
"heure",
|
|
718
|
+
"heures",
|
|
719
|
+
"kcal",
|
|
720
|
+
"min",
|
|
721
|
+
"mins",
|
|
722
|
+
"minute",
|
|
723
|
+
"minutes",
|
|
724
|
+
"part",
|
|
725
|
+
"parts",
|
|
726
|
+
"people",
|
|
727
|
+
"person",
|
|
728
|
+
"persons",
|
|
729
|
+
"personne",
|
|
730
|
+
"personnes",
|
|
731
|
+
"portion",
|
|
732
|
+
"portions",
|
|
733
|
+
"serving",
|
|
734
|
+
"servings"
|
|
735
|
+
]);
|
|
736
|
+
var NEGATORS = /* @__PURE__ */ new Set([
|
|
737
|
+
"aucun",
|
|
738
|
+
"aucune",
|
|
739
|
+
"avoid",
|
|
740
|
+
"avoiding",
|
|
741
|
+
"eviter",
|
|
742
|
+
"evite",
|
|
743
|
+
"evitez",
|
|
744
|
+
"exclude",
|
|
745
|
+
"excluding",
|
|
746
|
+
"exclure",
|
|
747
|
+
"except",
|
|
748
|
+
"ni",
|
|
749
|
+
"no",
|
|
750
|
+
"non",
|
|
751
|
+
"not",
|
|
752
|
+
"pas",
|
|
753
|
+
"sans",
|
|
754
|
+
"sauf",
|
|
755
|
+
"without",
|
|
756
|
+
"zero"
|
|
757
|
+
]);
|
|
758
|
+
var HEALTH_MARKERS = /* @__PURE__ */ new Set([
|
|
759
|
+
"allergene",
|
|
760
|
+
"allergenes",
|
|
761
|
+
"allergen",
|
|
762
|
+
"allergens",
|
|
763
|
+
"allergic",
|
|
764
|
+
"allergie",
|
|
765
|
+
"allergies",
|
|
766
|
+
"allergique",
|
|
767
|
+
"allergiques",
|
|
768
|
+
"allergy",
|
|
769
|
+
"celiac",
|
|
770
|
+
"coeliaque",
|
|
771
|
+
"intolerance",
|
|
772
|
+
"intolerances",
|
|
773
|
+
"intolerant",
|
|
774
|
+
"intolerante",
|
|
775
|
+
"intolerantes",
|
|
776
|
+
"intolerants"
|
|
777
|
+
]);
|
|
778
|
+
var DIETS = /* @__PURE__ */ new Set([
|
|
779
|
+
"casher",
|
|
780
|
+
"cetogene",
|
|
781
|
+
"halal",
|
|
782
|
+
"kascher",
|
|
783
|
+
"keto",
|
|
784
|
+
"ketogenic",
|
|
785
|
+
"kosher",
|
|
786
|
+
"paleo",
|
|
787
|
+
"pescatarian",
|
|
788
|
+
"pescetarien",
|
|
789
|
+
"pescetarienne",
|
|
790
|
+
"vegan",
|
|
791
|
+
"vegane",
|
|
792
|
+
"veganes",
|
|
793
|
+
"vegans",
|
|
794
|
+
"vegetalien",
|
|
795
|
+
"vegetalienne",
|
|
796
|
+
"vegetaliennes",
|
|
797
|
+
"vegetaliens",
|
|
798
|
+
"vegetarian",
|
|
799
|
+
"vegetarians",
|
|
800
|
+
"vegetarien",
|
|
801
|
+
"vegetarienne",
|
|
802
|
+
"vegetariennes",
|
|
803
|
+
"vegetariens",
|
|
804
|
+
"veggie"
|
|
805
|
+
]);
|
|
806
|
+
var CONDITION_ENDS = /* @__PURE__ */ new Set([
|
|
807
|
+
"et",
|
|
808
|
+
"and",
|
|
809
|
+
"ou",
|
|
810
|
+
"or",
|
|
811
|
+
"pour",
|
|
812
|
+
"for",
|
|
813
|
+
"avec",
|
|
814
|
+
"with",
|
|
815
|
+
"mais",
|
|
816
|
+
"but",
|
|
817
|
+
"dans",
|
|
818
|
+
"sur"
|
|
819
|
+
]);
|
|
820
|
+
var CONDITION_JOINERS = /* @__PURE__ */ new Set([
|
|
821
|
+
"a",
|
|
822
|
+
"au",
|
|
823
|
+
"aux",
|
|
824
|
+
"d",
|
|
825
|
+
"de",
|
|
826
|
+
"des",
|
|
827
|
+
"du",
|
|
828
|
+
"from",
|
|
829
|
+
"l",
|
|
830
|
+
"la",
|
|
831
|
+
"le",
|
|
832
|
+
"les",
|
|
833
|
+
"my",
|
|
834
|
+
"of",
|
|
835
|
+
"the",
|
|
836
|
+
"to"
|
|
837
|
+
]);
|
|
838
|
+
var FOOD_LINKERS = /* @__PURE__ */ new Set(["d", "de", "des", "du", "of"]);
|
|
839
|
+
var CONDITION_WORDS = 3;
|
|
840
|
+
var SERVING_PHRASES = [
|
|
841
|
+
/\bpour\s+(\d{1,3})\s*(?:personnes?|parts?|couverts?)\b/iu,
|
|
842
|
+
/\bfor\s+(\d{1,3})\s*(?:people|persons?|servings?|portions?)\b/iu,
|
|
843
|
+
/\bserves?\s+(\d{1,3})\b/iu,
|
|
844
|
+
/\b(\d{1,3})\s*(?:personnes?|people|servings?)\b/iu
|
|
845
|
+
];
|
|
846
|
+
var FREE_OF = /\b([\p{L}]{3,})[\s-]?free\b/iu;
|
|
847
|
+
function foldWord(word) {
|
|
848
|
+
return word.toLowerCase().normalize("NFD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/œ/gu, "oe");
|
|
849
|
+
}
|
|
850
|
+
function readQuestion(question) {
|
|
851
|
+
const words = tokenise(question);
|
|
852
|
+
const conditions = [];
|
|
853
|
+
const dish = [];
|
|
854
|
+
const state = (named, kind) => {
|
|
855
|
+
const trimmed = named === null ? null : named.trim();
|
|
856
|
+
if (trimmed === "") return;
|
|
857
|
+
if (conditions.some((held) => held.named === trimmed && held.kind === kind)) return;
|
|
858
|
+
conditions.push({ named: trimmed, kind });
|
|
859
|
+
};
|
|
860
|
+
for (let index = 0; index < words.length; index += 1) {
|
|
861
|
+
const word = words[index];
|
|
862
|
+
const key = foldWord(word);
|
|
863
|
+
const hyphen = word.indexOf("-");
|
|
864
|
+
if (hyphen > 0 && NEGATORS.has(foldWord(word.slice(0, hyphen)))) {
|
|
865
|
+
state(word.slice(hyphen + 1), "excluded");
|
|
866
|
+
continue;
|
|
867
|
+
}
|
|
868
|
+
const free = FREE_OF.exec(word);
|
|
869
|
+
if (free) {
|
|
870
|
+
state(free[1], "excluded");
|
|
871
|
+
continue;
|
|
872
|
+
}
|
|
873
|
+
if (key === "free") {
|
|
874
|
+
const scope = scopeOf(words, index, dish);
|
|
875
|
+
if (scope.named !== null) {
|
|
876
|
+
state(scope.named, "excluded");
|
|
877
|
+
index = scope.next - 1;
|
|
878
|
+
}
|
|
879
|
+
continue;
|
|
880
|
+
}
|
|
881
|
+
if (NEGATORS.has(key)) {
|
|
882
|
+
const forward = takeFood(words, index + 1);
|
|
883
|
+
state(forward.named === "" ? null : forward.named, "excluded");
|
|
884
|
+
index = forward.next - 1;
|
|
885
|
+
continue;
|
|
886
|
+
}
|
|
887
|
+
if (HEALTH_MARKERS.has(key)) {
|
|
888
|
+
const scope = scopeOf(words, index, dish);
|
|
889
|
+
state(scope.named, "allergy");
|
|
890
|
+
index = scope.next - 1;
|
|
891
|
+
continue;
|
|
892
|
+
}
|
|
893
|
+
if (DIETS.has(key)) {
|
|
894
|
+
state(word, "diet");
|
|
895
|
+
continue;
|
|
896
|
+
}
|
|
897
|
+
if (FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key)) continue;
|
|
898
|
+
if (FRENCH_FRAME.has(word) || ENGLISH_FRAME.has(word)) continue;
|
|
899
|
+
if (/^\d+$/.test(word)) continue;
|
|
900
|
+
if (word.length < 2) continue;
|
|
901
|
+
if (CONDITION_UNITS.has(word)) continue;
|
|
902
|
+
if (!dish.includes(word)) dish.push(word);
|
|
903
|
+
}
|
|
904
|
+
return { dish, conditions };
|
|
905
|
+
}
|
|
906
|
+
function scopeOf(words, marker, dish) {
|
|
907
|
+
if (CONDITION_JOINERS.has(foldWord(words[marker + 1] ?? ""))) {
|
|
908
|
+
const forward = takeFood(words, marker + 1);
|
|
909
|
+
return { named: forward.named === "" ? null : forward.named, next: forward.next };
|
|
910
|
+
}
|
|
911
|
+
const before = words[marker - 1];
|
|
912
|
+
if (before !== void 0) {
|
|
913
|
+
const held = dish.lastIndexOf(before);
|
|
914
|
+
if (held !== -1) {
|
|
915
|
+
dish.splice(held, 1);
|
|
916
|
+
return { named: before, next: marker + 1 };
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return { named: null, next: marker + 1 };
|
|
920
|
+
}
|
|
921
|
+
function takeFood(words, from) {
|
|
922
|
+
let index = from;
|
|
923
|
+
while (index < words.length && isFiller(words[index])) index += 1;
|
|
924
|
+
const named = [];
|
|
925
|
+
while (index < words.length && named.length < CONDITION_WORDS) {
|
|
926
|
+
if (!namesFood(words[index])) break;
|
|
927
|
+
named.push(words[index]);
|
|
928
|
+
index += 1;
|
|
929
|
+
const beyond = words[index + 1];
|
|
930
|
+
if (named.length + 2 <= CONDITION_WORDS && FOOD_LINKERS.has(foldWord(words[index] ?? "")) && beyond !== void 0 && namesFood(beyond)) {
|
|
931
|
+
named.push(words[index]);
|
|
932
|
+
index += 1;
|
|
933
|
+
continue;
|
|
934
|
+
}
|
|
935
|
+
break;
|
|
936
|
+
}
|
|
937
|
+
return { named: named.join(" "), next: index };
|
|
938
|
+
}
|
|
939
|
+
function isFiller(word) {
|
|
940
|
+
const key = foldWord(word);
|
|
941
|
+
return CONDITION_JOINERS.has(key) || FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key);
|
|
942
|
+
}
|
|
943
|
+
function namesFood(word) {
|
|
944
|
+
const key = foldWord(word);
|
|
945
|
+
if (key.length < 2) return false;
|
|
946
|
+
if (/^\d+$/.test(key)) return false;
|
|
947
|
+
if (key === "free") return false;
|
|
948
|
+
if (CONDITION_ENDS.has(key)) return false;
|
|
949
|
+
if (NEGATORS.has(key) || HEALTH_MARKERS.has(key)) return false;
|
|
950
|
+
if (CONDITION_UNITS.has(key)) return false;
|
|
951
|
+
return !(FRENCH_FRAME.has(key) || ENGLISH_FRAME.has(key));
|
|
952
|
+
}
|
|
953
|
+
function readConditions(question) {
|
|
954
|
+
const { conditions } = readQuestion(question);
|
|
955
|
+
let servings = null;
|
|
956
|
+
for (const phrase of SERVING_PHRASES) {
|
|
957
|
+
const found = phrase.exec(question);
|
|
958
|
+
if (found) {
|
|
959
|
+
const value = Number(found[1]);
|
|
960
|
+
if (Number.isFinite(value) && value > 0) {
|
|
961
|
+
servings = value;
|
|
962
|
+
break;
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
return { conditions, servings };
|
|
967
|
+
}
|
|
968
|
+
function deriveWordings(question) {
|
|
969
|
+
const asked = tidy(question);
|
|
970
|
+
const wordings = [];
|
|
971
|
+
const seen = /* @__PURE__ */ new Set();
|
|
972
|
+
const offer = (candidate, derivation) => {
|
|
973
|
+
const clean = tidy(candidate);
|
|
974
|
+
if (clean === "") return;
|
|
975
|
+
const key = clean.toLowerCase();
|
|
976
|
+
if (seen.has(key)) return;
|
|
977
|
+
seen.add(key);
|
|
978
|
+
wordings.push({ query: clean, derivation });
|
|
979
|
+
};
|
|
980
|
+
offer(asked, "the words as asked, condition words included");
|
|
981
|
+
const dish = dishWords(asked);
|
|
982
|
+
offer(
|
|
983
|
+
dish.join(" "),
|
|
984
|
+
"the words naming the dish, with the words framing the question and the conditions set aside, because a page is named for what it holds"
|
|
985
|
+
);
|
|
986
|
+
offer(
|
|
987
|
+
dish[0] ?? "",
|
|
988
|
+
"the leading dish word on its own, because an index that requires every word given returns nothing for a phrase and a great deal for the word naming the dish"
|
|
989
|
+
);
|
|
990
|
+
return wordings.slice(0, MAX_WORDINGS_PER_SOURCE);
|
|
991
|
+
}
|
|
992
|
+
function namesDish(title, question) {
|
|
993
|
+
const words = namingWords(question);
|
|
994
|
+
if (words.length === 0) return true;
|
|
995
|
+
return dishWordsMissing(title, question).length < words.length;
|
|
996
|
+
}
|
|
997
|
+
function dishWordsMissing(title, question) {
|
|
998
|
+
const haystack = ` ${fold(title)} `;
|
|
999
|
+
return namingWords(question).filter((word) => !haystack.includes(` ${fold(word)}`));
|
|
1000
|
+
}
|
|
1001
|
+
function namingWords(question) {
|
|
1002
|
+
return dishWords(question).filter((word) => word.length > 2);
|
|
1003
|
+
}
|
|
1004
|
+
function fold(value) {
|
|
1005
|
+
return value.normalize("NFD").replace(new RegExp("\\p{M}+", "gu"), "").replace(/œ/gu, "oe").toLowerCase().replace(/[^a-z0-9]+/g, " ");
|
|
1006
|
+
}
|
|
1007
|
+
function dishWords(question) {
|
|
1008
|
+
return readQuestion(question).dish;
|
|
1009
|
+
}
|
|
1010
|
+
function tokenise(value) {
|
|
1011
|
+
return value.toLowerCase().replace(/['’]/gu, " ").split(/[^\p{L}\p{N}-]+/u).map((word) => word.replace(/^-+|-+$/g, "")).filter((word) => word !== "");
|
|
1012
|
+
}
|
|
1013
|
+
function tidy(value) {
|
|
1014
|
+
return value.replace(/\s+/g, " ").trim();
|
|
1015
|
+
}
|
|
1016
|
+
|
|
513
1017
|
// src/sources/client.ts
|
|
514
1018
|
var MAX_LIMIT_PER_SOURCE = 25;
|
|
515
1019
|
function withGuarantees(config) {
|
|
@@ -542,9 +1046,7 @@ function withDeadline(work, ms, source) {
|
|
|
542
1046
|
const alarm = new Promise((_resolve, reject) => {
|
|
543
1047
|
timer = setTimeout(
|
|
544
1048
|
() => reject(
|
|
545
|
-
timeout(
|
|
546
|
-
`${source.name} did not answer within ${ms}ms and was left out of this answer.`
|
|
547
|
-
)
|
|
1049
|
+
timeout(`${source.name} did not answer within ${ms}ms and was left out of this answer.`)
|
|
548
1050
|
),
|
|
549
1051
|
ms
|
|
550
1052
|
);
|
|
@@ -563,7 +1065,20 @@ function reportOf(attempt, count2) {
|
|
|
563
1065
|
skipped: attempt.skipped,
|
|
564
1066
|
mixesReferencePages: attempt.source.mixesReferencePages,
|
|
565
1067
|
cached: attempt.cached,
|
|
566
|
-
error: attempt.error
|
|
1068
|
+
error: attempt.error,
|
|
1069
|
+
wordings: attempt.wordings,
|
|
1070
|
+
preferredByName: attempt.preferredByName
|
|
1071
|
+
};
|
|
1072
|
+
}
|
|
1073
|
+
function heldBack(wording, because) {
|
|
1074
|
+
return {
|
|
1075
|
+
query: wording.query,
|
|
1076
|
+
derivation: wording.derivation,
|
|
1077
|
+
ran: false,
|
|
1078
|
+
count: null,
|
|
1079
|
+
added: null,
|
|
1080
|
+
notRunBecause: because,
|
|
1081
|
+
error: null
|
|
567
1082
|
};
|
|
568
1083
|
}
|
|
569
1084
|
function interleave(groups) {
|
|
@@ -623,15 +1138,22 @@ var RecipesClient = class {
|
|
|
623
1138
|
* source rather than as long as all of them. Whichever of them fail, the rest
|
|
624
1139
|
* still come back, and the report says which was which.
|
|
625
1140
|
*/
|
|
626
|
-
async searchRecipes(query, limitPerSource, wanted) {
|
|
1141
|
+
async searchRecipes(query, limitPerSource, wanted, options = {}) {
|
|
627
1142
|
const trimmed = query.trim();
|
|
628
1143
|
if (trimmed === "") {
|
|
629
1144
|
throw invalidInput("A search needs something to look for.", "Name a dish or an ingredient.");
|
|
630
1145
|
}
|
|
1146
|
+
if (!/[\p{L}\p{N}]/u.test(trimmed)) {
|
|
1147
|
+
throw invalidInput(
|
|
1148
|
+
`"${trimmed}" carries no letter and no digit, so there is no word to search for.`,
|
|
1149
|
+
"Name a dish or an ingredient."
|
|
1150
|
+
);
|
|
1151
|
+
}
|
|
631
1152
|
const chosen = selectSources(this.sources, wanted);
|
|
632
1153
|
const limit = Math.max(1, Math.min(MAX_LIMIT_PER_SOURCE, Math.trunc(limitPerSource)));
|
|
1154
|
+
const fanOut = options.fanOut ?? true;
|
|
633
1155
|
const attempts = await Promise.all(
|
|
634
|
-
chosen.map((source) => this.searchOne(source, trimmed, limit))
|
|
1156
|
+
chosen.map((source) => this.searchOne(source, trimmed, limit, fanOut))
|
|
635
1157
|
);
|
|
636
1158
|
const groups = attempts.map((attempt) => attempt.rows.slice(0, limit));
|
|
637
1159
|
return {
|
|
@@ -639,40 +1161,135 @@ var RecipesClient = class {
|
|
|
639
1161
|
reports: attempts.map((attempt, index) => reportOf(attempt, groups[index].length))
|
|
640
1162
|
};
|
|
641
1163
|
}
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
1164
|
+
/**
|
|
1165
|
+
* Ask one source the question in several wordings, and union what comes back.
|
|
1166
|
+
*
|
|
1167
|
+
* The wordings go out one after another rather than together, because each
|
|
1168
|
+
* source is paced and a burst would spend the whole ladder's politeness at
|
|
1169
|
+
* once. They stop early: a wording is only sent when the ones before it left
|
|
1170
|
+
* the source short of what was asked for, so the ordinary case where the
|
|
1171
|
+
* question names a dish costs one request.
|
|
1172
|
+
*
|
|
1173
|
+
* Each wording is a different search rather than a further page of the same
|
|
1174
|
+
* one. One of these sites serves a single page of results and disallows
|
|
1175
|
+
* paging past it, so a second page is never asked for, here or anywhere else.
|
|
1176
|
+
*/
|
|
1177
|
+
async searchOne(source, question, limit, fanOut) {
|
|
1178
|
+
const derived = deriveWordings(question);
|
|
1179
|
+
const wordings = [];
|
|
1180
|
+
const rows = [];
|
|
1181
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1182
|
+
let cached = false;
|
|
1183
|
+
let skipped = 0;
|
|
1184
|
+
let reportedTotal = null;
|
|
1185
|
+
let reportedTotalMeans = null;
|
|
1186
|
+
let answered = false;
|
|
1187
|
+
let failure = null;
|
|
1188
|
+
let onTopic = 0;
|
|
1189
|
+
for (const [index, wording] of derived.entries()) {
|
|
1190
|
+
if (index > 0 && !fanOut) {
|
|
1191
|
+
wordings.push(
|
|
1192
|
+
heldBack(wording, "fan_out was turned off, so only the words as asked were sent")
|
|
648
1193
|
);
|
|
1194
|
+
continue;
|
|
649
1195
|
}
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
1196
|
+
if (index >= MAX_WORDINGS_PER_SOURCE) {
|
|
1197
|
+
wordings.push(
|
|
1198
|
+
heldBack(
|
|
1199
|
+
wording,
|
|
1200
|
+
`the ceiling of ${MAX_WORDINGS_PER_SOURCE} searches of one source was reached`
|
|
1201
|
+
)
|
|
1202
|
+
);
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (failure !== null) {
|
|
1206
|
+
wordings.push(
|
|
1207
|
+
heldBack(wording, `${source.name} did not answer the wording before this one`)
|
|
1208
|
+
);
|
|
1209
|
+
continue;
|
|
1210
|
+
}
|
|
1211
|
+
if (onTopic >= limit) {
|
|
1212
|
+
wordings.push(
|
|
1213
|
+
heldBack(
|
|
1214
|
+
wording,
|
|
1215
|
+
"the wordings already sent returned as many rows naming the dish as were asked for"
|
|
1216
|
+
)
|
|
1217
|
+
);
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
try {
|
|
1221
|
+
const read = await withDeadline(
|
|
1222
|
+
source.search(wording.query, limit),
|
|
1223
|
+
this.deadlineMs,
|
|
1224
|
+
source
|
|
1225
|
+
);
|
|
1226
|
+
answered = true;
|
|
1227
|
+
cached = cached || read.cached;
|
|
1228
|
+
skipped += read.skipped;
|
|
1229
|
+
if (reportedTotal === null && reportedTotalMeans === null) {
|
|
1230
|
+
reportedTotal = read.reportedTotal;
|
|
1231
|
+
reportedTotalMeans = read.reportedTotalMeans;
|
|
1232
|
+
}
|
|
1233
|
+
if (read.skipped > 0) {
|
|
1234
|
+
this.logger.warn(
|
|
1235
|
+
`${source.name} sent ${read.skipped} row(s) this server could not read; they were left out.`
|
|
1236
|
+
);
|
|
1237
|
+
}
|
|
1238
|
+
let added = 0;
|
|
1239
|
+
for (const row of read.rows) {
|
|
1240
|
+
if (seen.has(row.id)) continue;
|
|
1241
|
+
seen.add(row.id);
|
|
1242
|
+
rows.push(row);
|
|
1243
|
+
added += 1;
|
|
1244
|
+
if (namesDish(row.title, question)) onTopic += 1;
|
|
1245
|
+
}
|
|
1246
|
+
wordings.push({
|
|
1247
|
+
query: wording.query,
|
|
1248
|
+
derivation: wording.derivation,
|
|
1249
|
+
ran: true,
|
|
1250
|
+
count: read.rows.length,
|
|
1251
|
+
added,
|
|
1252
|
+
notRunBecause: null,
|
|
1253
|
+
error: null
|
|
1254
|
+
});
|
|
1255
|
+
} catch (error) {
|
|
1256
|
+
const known = fromSource(error, source.name);
|
|
1257
|
+
this.logger.warn(
|
|
1258
|
+
`${source.name} did not answer "${wording.query}": [${known.code}] ${known.message}`
|
|
1259
|
+
);
|
|
1260
|
+
const reason = {
|
|
670
1261
|
code: known.code,
|
|
671
1262
|
message: known.message,
|
|
672
1263
|
...known.details.hint ? { hint: known.details.hint } : {}
|
|
673
|
-
}
|
|
674
|
-
|
|
1264
|
+
};
|
|
1265
|
+
wordings.push({
|
|
1266
|
+
query: wording.query,
|
|
1267
|
+
derivation: wording.derivation,
|
|
1268
|
+
ran: true,
|
|
1269
|
+
count: null,
|
|
1270
|
+
added: null,
|
|
1271
|
+
notRunBecause: null,
|
|
1272
|
+
error: reason
|
|
1273
|
+
});
|
|
1274
|
+
failure = reason;
|
|
1275
|
+
}
|
|
675
1276
|
}
|
|
1277
|
+
const contributing = wordings.filter((attempt) => (attempt.added ?? 0) > 0).length;
|
|
1278
|
+
const ordered = contributing > 1 ? [
|
|
1279
|
+
...rows.filter((row) => namesDish(row.title, question)),
|
|
1280
|
+
...rows.filter((row) => !namesDish(row.title, question))
|
|
1281
|
+
] : rows;
|
|
1282
|
+
return {
|
|
1283
|
+
source,
|
|
1284
|
+
rows: ordered,
|
|
1285
|
+
preferredByName: contributing > 1,
|
|
1286
|
+
cached,
|
|
1287
|
+
reportedTotal,
|
|
1288
|
+
reportedTotalMeans,
|
|
1289
|
+
skipped,
|
|
1290
|
+
error: answered ? null : failure,
|
|
1291
|
+
wordings
|
|
1292
|
+
};
|
|
676
1293
|
}
|
|
677
1294
|
/**
|
|
678
1295
|
* Read one recipe, from whichever source the identifier names.
|
|
@@ -691,16 +1308,20 @@ var RecipesClient = class {
|
|
|
691
1308
|
);
|
|
692
1309
|
return { recipe: outcome.recipe, cached: outcome.cached, read };
|
|
693
1310
|
} catch (error) {
|
|
694
|
-
throw
|
|
1311
|
+
throw fromSource(error, read.source.name);
|
|
695
1312
|
}
|
|
696
1313
|
}
|
|
697
1314
|
};
|
|
698
1315
|
export {
|
|
699
1316
|
MAX_LIMIT_PER_SOURCE,
|
|
1317
|
+
MAX_WORDINGS_PER_SOURCE,
|
|
700
1318
|
RecipesClient,
|
|
701
1319
|
SOURCE_IDS,
|
|
1320
|
+
deriveWordings,
|
|
702
1321
|
interleave,
|
|
1322
|
+
namesDish,
|
|
703
1323
|
namespacedId,
|
|
1324
|
+
readConditions,
|
|
704
1325
|
resolveId
|
|
705
1326
|
};
|
|
706
1327
|
//# sourceMappingURL=client.js.map
|