untappd-mcp 2.3.1 → 2.4.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.
@@ -0,0 +1,178 @@
1
+ // Menu-line normalisation and candidate scoring for untappd_menu_pick.
2
+ //
3
+ // A tap list read off a photo is noisy ("HAP & HARRY'S TENNESSEE LAGER $6"),
4
+ // and Untappd's beer search ANDs every term — one stray word returns nothing
5
+ // (live: "i believe in nashville blonde" → 0 hits, "i believe in nashville" →
6
+ // the right beer). So a line is normalised to search-friendly tokens, searched,
7
+ // and every candidate is scored against those tokens. Pure functions only; the
8
+ // API calls live in pick.ts.
9
+ // Words that make a line generic ("whatever's on this week").
10
+ const GENERIC_WORDS = new Set([
11
+ 'seasonal', 'seasonals', 'rotating', 'rotation', 'rotational', 'rotator', 'guest', 'featured', 'selection',
12
+ ]);
13
+ // Words that carry no identity on a generic line ("ROTATING DRAFT BEER").
14
+ const GENERIC_FILLER = new Set([
15
+ 'draft', 'draught', 'beer', 'beers', 'tap', 'taps', 'handle', 'cider', 'ciders', 'seltzer', 'seltzers',
16
+ 'select', 'of', 'the', 'our', 'current', 'ask', 'your', 'server', 'bartender', 'on', 'brew', 'brews', 'release',
17
+ ]);
18
+ // Serving words a menu adds that are never part of a beer's name.
19
+ const SERVING_WORDS = new Set(['draft', 'draught', 'pint', 'pints', 'pour', 'can', 'cans', 'bottle', 'bottles', 'glass']);
20
+ /**
21
+ * Bare style words. Untappd keeps the style out of most beer NAMES ("Fennario",
22
+ * style "IPA - American"), so a line's style word is dropped from the fallback
23
+ * search query and, when scoring, is explained by the candidate's style.
24
+ * Deliberately excludes differentiators like "double"/"imperial" — "Double
25
+ * Homestyle" is a different beer from "Homestyle".
26
+ */
27
+ const STYLE_WORDS = new Set([
28
+ 'ipa', 'ipas', 'lager', 'pilsner', 'pils', 'blonde', 'blond', 'ale', 'stout', 'porter', 'sour', 'saison', 'gose',
29
+ 'hefeweizen', 'kolsch', 'cider', 'seltzer', 'pale', 'amber', 'wheat', 'witbier', 'helles', 'marzen', 'dunkel',
30
+ ]);
31
+ // Brewery-name words that don't identify the brewery ("Brewing Company").
32
+ const BREWERY_NOISE = new Set([
33
+ 'brewing', 'brewery', 'breweries', 'brewers', 'brewhouse', 'brew', 'company', 'co', 'beer', 'beers', 'ales',
34
+ 'the', 'and', 'works', 'project', 'craft', 'inc', 'llc',
35
+ ]);
36
+ /** Lowercase, strip accents/apostrophes, turn every other non-alphanumeric into a space. */
37
+ export function tokenize(s) {
38
+ return s
39
+ .normalize('NFKD')
40
+ .replace(/[̀-ͯ]/g, '')
41
+ .toLowerCase()
42
+ .replace(/['’‘`´]/g, '') // harry's / harry’s → harrys, so either spelling matches
43
+ .replace(/[^a-z0-9]+/g, ' ')
44
+ .split(' ')
45
+ .filter(Boolean);
46
+ }
47
+ // Prices, pour sizes and ABV a menu prints next to the name.
48
+ const NOISE_PATTERNS = [
49
+ /\$\s*\d+(?:[.,]\d{1,2})?/g, // $6, $ 7.50
50
+ /\b\d+(?:[.,]\d+)?\s*%(?:\s*abv)?/gi, // 6.5%, 7% ABV
51
+ /\babv\b\s*\d+(?:[.,]\d+)?/gi, // ABV 6.5
52
+ /\b\d+(?:[.,]\d+)?\s*(?:oz|ml|cl|l)\b/gi, // 16oz, 12 oz, 330ml
53
+ /\b\d+[.,]\d{2}\b/g, // 7.00
54
+ /\b\d{1,2}\s*\/\s*\d{1,2}\s*$/g, // trailing "6/9" price columns (a bare trailing number may be part of a name)
55
+ ];
56
+ /** Normalise one raw menu line and classify it. */
57
+ export function normalizeLine(raw) {
58
+ let s = raw;
59
+ for (const re of NOISE_PATTERNS)
60
+ s = s.replace(re, ' ');
61
+ const tokens = [];
62
+ for (const t of tokenize(s)) {
63
+ // Collapse a duplicated word ("IPA IPA", "Lager Lager") from OCR or a
64
+ // style column printed after a name that already ends in the style.
65
+ if (tokens[tokens.length - 1] === t)
66
+ continue;
67
+ tokens.push(t);
68
+ }
69
+ // Generic only when nothing but filler follows the last generic word:
70
+ // "BLACK ABBEY SEASONAL" and "ROTATING DRAFT BEER" are, but "Widmer Rotator
71
+ // IPA" is a real beer whose name happens to contain one.
72
+ let lastGeneric = -1;
73
+ tokens.forEach((t, i) => {
74
+ if (GENERIC_WORDS.has(t))
75
+ lastGeneric = i;
76
+ });
77
+ if (lastGeneric !== -1 && tokens.slice(lastGeneric + 1).every((t) => GENERIC_FILLER.has(t))) {
78
+ const rest = tokens.filter((t) => !GENERIC_WORDS.has(t) && !GENERIC_FILLER.has(t));
79
+ return rest.length === 0
80
+ ? { raw, kind: 'generic', tokens }
81
+ : { raw, kind: 'brewery_generic', tokens, breweryHint: rest.join(' ') };
82
+ }
83
+ const named = tokens.filter((t) => !SERVING_WORDS.has(t));
84
+ return { raw, kind: named.length ? 'beer' : 'generic', tokens: named };
85
+ }
86
+ /** Search queries to try for a line, most specific first (deduped). */
87
+ export function searchQueries(line) {
88
+ const full = line.tokens.join(' ');
89
+ const noStyle = line.tokens.filter((t) => !STYLE_WORDS.has(t)).join(' ');
90
+ return [...new Set([full, noStyle].filter(Boolean))];
91
+ }
92
+ const dict = (v) => (v && typeof v === 'object' && !Array.isArray(v) ? v : {});
93
+ const numOr = (v, d) => (typeof v === 'number' && Number.isFinite(v) ? v : d);
94
+ const strOr = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
95
+ /** Map a raw `/search/beer` item (`{checkin_count, beer, brewery}`) to a {@link Candidate}. */
96
+ export function candidateFromSearch(item) {
97
+ const i = dict(item);
98
+ const beer = dict(i.beer);
99
+ const brewery = dict(i.brewery);
100
+ const bid = numOr(beer.bid, null);
101
+ if (bid === null)
102
+ return null;
103
+ return {
104
+ bid,
105
+ name: strOr(beer.beer_name) ?? '',
106
+ brewery: strOr(brewery.brewery_name) ?? '',
107
+ brewery_id: numOr(brewery.brewery_id, null),
108
+ brewery_city: strOr(dict(brewery.location).brewery_city),
109
+ style: strOr(beer.beer_style),
110
+ abv: numOr(beer.beer_abv, null),
111
+ checkin_count: numOr(i.checkin_count, 0) ?? 0,
112
+ rating: numOr(beer.rating_score, null) || null,
113
+ };
114
+ }
115
+ /**
116
+ * Score a candidate against a line's tokens. Higher is better. The terms:
117
+ * - beer-name tokens found in the line (×4) and brewery tokens found (×3);
118
+ * - each beer-name token NOT in the line costs 1.5 — the "Double Homestyle"
119
+ * / "Triple Homestyle" guard when the menu said "Homestyle";
120
+ * - each line token the candidate can't explain (by brewery, name or style)
121
+ * costs 1;
122
+ * - popularity, log10(check-ins) × 0.5 — the canonical 35k-check-in entry
123
+ * beats a 3-check-in duplicate with the same name;
124
+ * - +1 when the brewery's city is the venue's city (the tie-breaker).
125
+ */
126
+ export function scoreCandidate(lineTokens, c, venueCity) {
127
+ const line = new Set(lineTokens);
128
+ const breweryAll = tokenize(c.brewery);
129
+ const breweryId = breweryAll.filter((t) => !BREWERY_NOISE.has(t));
130
+ const brewery = breweryId.length ? breweryId : breweryAll;
131
+ const name = tokenize(c.name);
132
+ const style = tokenize(c.style ?? '');
133
+ const frac = (ts) => (ts.length ? ts.filter((t) => line.has(t)).length / ts.length : 0);
134
+ const brewery_match = frac(brewery);
135
+ const name_match = frac(name);
136
+ const extraName = name.filter((t) => !line.has(t)).length;
137
+ const explained = new Set([...breweryAll, ...name, ...style, ...BREWERY_NOISE]);
138
+ const unexplained = lineTokens.filter((t) => !explained.has(t)).length;
139
+ const sameCity = venueCity && c.brewery_city && tokenize(venueCity).join(' ') === tokenize(c.brewery_city).join(' ') ? 1 : 0;
140
+ const score = 4 * name_match +
141
+ 3 * brewery_match -
142
+ 1.5 * extraName -
143
+ unexplained +
144
+ 0.5 * Math.log10(c.checkin_count + 1) +
145
+ sameCity;
146
+ return { ...c, score: Math.round(score * 1000) / 1000, name_match, brewery_match };
147
+ }
148
+ /** Rank candidates for a line, best first. Duplicate bids keep their best score. */
149
+ export function rankCandidates(lineTokens, cands, venueCity) {
150
+ const best = new Map();
151
+ for (const c of cands) {
152
+ const s = scoreCandidate(lineTokens, c, venueCity);
153
+ const prev = best.get(c.bid);
154
+ if (!prev || s.score > prev.score)
155
+ best.set(c.bid, s);
156
+ }
157
+ return [...best.values()].sort((a, b) => b.score - a.score || b.checkin_count - a.checkin_count);
158
+ }
159
+ /**
160
+ * Is the top candidate a confident match? Most of the beer's name must be on
161
+ * the line, and the total must clear a floor that a name-only hit from an
162
+ * unrelated brewery doesn't reach.
163
+ */
164
+ export function isConfident(top) {
165
+ return !!top && top.name_match >= 0.5 && top.score >= 4.5;
166
+ }
167
+ // Name/style hints that a brewery's beer is a seasonal or limited release.
168
+ const SEASONAL_RE = /season|oktoberfest|okto|marzen|festbier|\bfest\b|pumpkin|winter|summer|spring|autumn|\bfall\b|harvest|holiday|christmas|xmas|noel|snow|maibock|wet hop|fresh hop|shandy|radler|limited|rotat|anniversary/i;
169
+ /** Does this beer look like a seasonal / limited release? */
170
+ export function looksSeasonal(name, style) {
171
+ return SEASONAL_RE.test(tokenize(name).join(' ')) || SEASONAL_RE.test(tokenize(style ?? '').join(' '));
172
+ }
173
+ /** Score a `/search/brewery` result's name against a brewery hint (fraction of hint tokens matched). */
174
+ export function breweryNameMatch(hint, breweryName) {
175
+ const hintTokens = tokenize(hint).filter((t) => !BREWERY_NOISE.has(t));
176
+ const name = new Set(tokenize(breweryName));
177
+ return hintTokens.length ? hintTokens.filter((t) => name.has(t)).length / hintTokens.length : 0;
178
+ }