untappd-mcp 2.3.2 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +112 -4
- package/dist/bundle.js +1186 -133
- package/dist/cache/db.js +100 -10
- package/dist/cache/store.js +224 -15
- package/dist/cache/sync-beers.js +27 -5
- package/dist/client.js +24 -0
- package/dist/index.js +8 -2
- package/dist/menu/match.js +178 -0
- package/dist/menu/pick.js +514 -0
- package/dist/menu/venue-menu.js +108 -0
- package/dist/quota.js +14 -0
- package/dist/tools/beer.js +4 -1
- package/dist/tools/cache.js +54 -32
- package/dist/tools/menu.js +104 -0
- package/dist/tools/venue.js +14 -92
- package/dist/version.js +1 -1
- package/mint.yaml +12 -0
- package/package.json +5 -1
- package/server.json +9 -2
|
@@ -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
|
+
}
|