untappd-mcp 2.3.2 → 2.5.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.
@@ -1,5 +1,6 @@
1
1
  import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
2
  import { mapCheckinRow } from './store.js';
3
+ import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
3
4
  const PAGE_LIMIT = 50; // Untappd's max page size for /user/checkins.
4
5
  // Backfill counts as complete only when the cache holds ~this fraction of the
5
6
  // API's reported total_checkins — allowing drift for deleted check-ins. Below
@@ -41,7 +42,10 @@ async function fetchTotalCheckins(client, encodedUser) {
41
42
  const t = info?.user?.stats?.total_checkins;
42
43
  return typeof t === 'number' ? t : null;
43
44
  }
44
- catch {
45
+ catch (e) {
46
+ // A 429 here means every later call would 429 too — let the caller stop.
47
+ if (e instanceof RateLimitError)
48
+ throw e;
45
49
  return null;
46
50
  }
47
51
  }
@@ -74,7 +78,42 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
74
78
  const encodedUser = encodeURIComponent(rawUsername);
75
79
  const now = () => new Date().toISOString();
76
80
  const priorState = await cache.getState(rawUsername);
77
- const total = await fetchTotalCheckins(client, encodedUser);
81
+ // The stop-before-429 guard, consulted before EVERY call. Once it (or a real
82
+ // 429) stops the run, nothing else is fetched; progress is persisted per page.
83
+ let rateStop;
84
+ const gate = async () => {
85
+ if (rateStop)
86
+ return false;
87
+ const v = await checkRateBudget(client, cache);
88
+ if (!v.ok)
89
+ rateStop = stopFromVerdict(v);
90
+ return v.ok;
91
+ };
92
+ /** Fetch a page, or return null (and record why) when rate limited. */
93
+ const guardedFetch = async (maxId) => {
94
+ if (!(await gate()))
95
+ return null;
96
+ try {
97
+ return await nextFetch(maxId);
98
+ }
99
+ catch (e) {
100
+ if (!(e instanceof RateLimitError))
101
+ throw e;
102
+ rateStop = stopFrom429(e);
103
+ return null;
104
+ }
105
+ };
106
+ let total = priorState?.total_checkins ?? null;
107
+ if (await gate()) {
108
+ try {
109
+ total = await fetchTotalCheckins(client, encodedUser);
110
+ }
111
+ catch (e) {
112
+ if (!(e instanceof RateLimitError))
113
+ throw e;
114
+ rateStop = stopFrom429(e);
115
+ }
116
+ }
78
117
  const cachedAtStart = await cache.cachedCount(rawUsername);
79
118
  // Self-heal a cache wrongly flagged complete (the historical bug): if state
80
119
  // claims done but coverage is well short of total, treat it like a forced reset.
@@ -176,7 +215,10 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
176
215
  let caughtUp = false;
177
216
  let phase1Pages = 0;
178
217
  for (; phase1Pages < phase1Budget;) {
179
- const page = await nextFetch(maxId);
218
+ const fetched = await guardedFetch(maxId);
219
+ if (fetched === null)
220
+ break; // rate limited: catch-up resumes next run
221
+ const page = fetched;
180
222
  pages++;
181
223
  phase1Pages++;
182
224
  if (page.items.length === 0) {
@@ -231,7 +273,10 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
231
273
  let maxId = oldestMaxId ?? undefined;
232
274
  let phase2Pages = 0;
233
275
  for (; phase2Pages < phase2Budget;) {
234
- const page = await nextFetch(maxId);
276
+ const fetched = await guardedFetch(maxId);
277
+ if (fetched === null)
278
+ break; // rate limited: backfill resumes from oldest_max_id next run
279
+ const page = fetched;
235
280
  pages++;
236
281
  phase2Pages++;
237
282
  if (page.items.length === 0) {
@@ -287,14 +332,16 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
287
332
  // Re-running user/checkins only helps while there is more of the OWN history to
288
333
  // page. A truncated (non-self) history can't advance that way — the caller
289
334
  // should switch to untappd_sync_user_beers for has-had coverage.
290
- const anotherRunNeeded = catchupInProgress || (!backfillComplete && !checkinsTruncated);
291
- const note = checkinsTruncated
292
- ? 'Untappd only returns the most recent check-ins for this account (history_truncated), so the full check-in history cannot be paged. Use untappd_sync_user_beers to cache the complete distinct-beers list for has-had coverage.'
293
- : catchupInProgress
294
- ? 'More new check-ins remain than fit in this run — run untappd_sync_checkins again to finish catching up.'
295
- : backfillComplete
296
- ? 'Full check-in history is cached. Re-run occasionally to pick up new check-ins.'
297
- : `Backfill incomplete — run untappd_sync_checkins again to fetch the next ${maxPages} pages of older check-ins.`;
335
+ const anotherRunNeeded = catchupInProgress || (!backfillComplete && !checkinsTruncated) || rateStop !== undefined;
336
+ const note = rateStop
337
+ ? `Check-in sync paused (${cached}${total !== null ? `/${total}` : ''} cached). ${resumeAdvice(rateStop, 'untappd_sync_checkins')}`
338
+ : checkinsTruncated
339
+ ? 'Untappd only returns the most recent check-ins for this account (history_truncated), so the full check-in history cannot be paged. Use untappd_sync_user_beers to cache the complete distinct-beers list for has-had coverage.'
340
+ : catchupInProgress
341
+ ? 'More new check-ins remain than fit in this run — run untappd_sync_checkins again to finish catching up.'
342
+ : backfillComplete
343
+ ? 'Full check-in history is cached. Re-run occasionally to pick up new check-ins.'
344
+ : `Backfill incomplete — run untappd_sync_checkins again to fetch the next ${maxPages} pages of older check-ins.`;
298
345
  return {
299
346
  username: rawUsername,
300
347
  rows_added: added,
@@ -308,6 +355,8 @@ export async function syncCheckins(client, cache, rawUsername, options = {}) {
308
355
  history_truncated: checkinsTruncated,
309
356
  forced: doReset,
310
357
  another_run_needed: anotherRunNeeded,
358
+ stopped_for_rate_limit: rateStop !== undefined,
359
+ ...(rateStop ? { rate_limit: rateStop } : {}),
311
360
  last_synced_at: now(),
312
361
  note,
313
362
  };
@@ -0,0 +1,101 @@
1
+ import { RateLimitError, messageOf } from '@chrischall/mcp-utils';
2
+ import { mapCheckinRow } from './store.js';
3
+ import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
4
+ export const VENUE_DRINKS_DEFAULT_CALLS = 10;
5
+ export const VENUE_DRINKS_MAX_CALLS_CAP = 50;
6
+ const TOOL = 'untappd_sync_venue_drinks';
7
+ /** A check-in that no longer exists or isn't visible answers 404 (not a transient failure). */
8
+ function isGone(e) {
9
+ return /\b404\b|not found|does not exist/i.test(messageOf(e));
10
+ }
11
+ /**
12
+ * Opt-in "what did they drink there": for the user's cached user_venues rows,
13
+ * fetch the first and most-recent visit check-ins via checkin/view (works for
14
+ * any public check-in) and store the beer + date in `venue_drinks`. A PARTIAL
15
+ * picture by design — two check-ins per venue at most. Budgeted by `maxCalls`,
16
+ * with the stop-before-429 guard before every call; progress is stored per
17
+ * call, so a re-run continues with what is still pending.
18
+ */
19
+ export async function syncVenueDrinks(client, cache, username, opts) {
20
+ const budget = Math.max(1, Math.min(VENUE_DRINKS_MAX_CALLS_CAP, Math.floor(opts.maxCalls)));
21
+ const filter = { venueIds: opts.venueIds, which: opts.which ?? 'both' };
22
+ const candidates = await cache.venueDrinkCandidates(username, filter);
23
+ let calls = 0;
24
+ let stored = 0;
25
+ let unavailable = 0;
26
+ let errors = 0;
27
+ let firstError;
28
+ let rateStop;
29
+ for (const c of candidates) {
30
+ if (calls >= budget)
31
+ break;
32
+ const verdict = await checkRateBudget(client, cache);
33
+ if (!verdict.ok) {
34
+ rateStop = stopFromVerdict(verdict);
35
+ break;
36
+ }
37
+ calls++;
38
+ let data;
39
+ try {
40
+ data = await client.get(`/checkin/view/${c.checkin_id}`);
41
+ }
42
+ catch (e) {
43
+ if (e instanceof RateLimitError) {
44
+ rateStop = stopFrom429(e);
45
+ break;
46
+ }
47
+ if (isGone(e)) {
48
+ unavailable++;
49
+ await cache.upsertVenueDrinks(username, [{ venue_id: c.venue_id, checkin_id: c.checkin_id, bid: null, beer_name: null, brewery_id: null, brewery_name: null, created_at: null }], new Date().toISOString());
50
+ }
51
+ else {
52
+ errors++;
53
+ firstError ??= messageOf(e);
54
+ }
55
+ continue;
56
+ }
57
+ // Only the beer, brewery and date are kept — never the comment (other people's free text).
58
+ const r = mapCheckinRow(username, data?.checkin);
59
+ const row = {
60
+ venue_id: c.venue_id,
61
+ checkin_id: c.checkin_id,
62
+ bid: r?.bid ?? null,
63
+ beer_name: r?.beer_name ?? null,
64
+ brewery_id: r?.brewery_id ?? null,
65
+ brewery_name: r?.brewery_name ?? null,
66
+ created_at: r?.created_at ?? null,
67
+ };
68
+ if (row.bid === null && row.beer_name === null) {
69
+ // An unexpected shape — don't store a tombstone for it; count and retry later.
70
+ errors++;
71
+ firstError ??= `checkin/view ${c.checkin_id} had no beer`;
72
+ continue;
73
+ }
74
+ await cache.upsertVenueDrinks(username, [row], new Date().toISOString());
75
+ stored++;
76
+ }
77
+ const pending = (await cache.venueDrinkCandidates(username, filter)).length;
78
+ const another = pending > 0;
79
+ let note;
80
+ if (rateStop)
81
+ note = `Paused with ${pending} check-in(s) pending. ${resumeAdvice(rateStop, TOOL)}`;
82
+ else if (another)
83
+ note = `${pending} check-in(s) still pending — run ${TOOL} again to continue (max_calls per run).`;
84
+ else
85
+ note = 'Every selected venue\'s first/recent visit drink is cached; untappd_cache_has_visited lists them under matches[].drinks.';
86
+ if (errors > 0)
87
+ note += ` ${errors} fetch(es) failed and stay pending.`;
88
+ return {
89
+ username,
90
+ api_calls: calls,
91
+ stored,
92
+ unavailable,
93
+ errors,
94
+ pending,
95
+ another_run_needed: another,
96
+ stopped_for_rate_limit: rateStop !== undefined,
97
+ ...(rateStop ? { rate_limit: rateStop } : {}),
98
+ ...(firstError ? { first_error: firstError } : {}),
99
+ note,
100
+ };
101
+ }
package/dist/client.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { dirname, join } from 'path';
2
2
  import { fileURLToPath } from 'url';
3
3
  import { loadDotenvSafely, readEnvVar, buildQueryString, formatApiError, createHelpfulError, McpToolError, RateLimitError, UnreachableError, } from '@chrischall/mcp-utils';
4
+ import { RateLimitTracker, computeRateLimitStatus, configuredHourlyLimit, observeResponse, } from './ratelimit.js';
4
5
  // Load .env for local (Node) dev; silently skip if dotenv is unavailable (e.g.
5
6
  // the mcpb bundle). `loadDotenvSafely` swallows a missing dotenv module and
6
7
  // never lets .env override a host-provided value. Guard on `import.meta.url`:
@@ -151,6 +152,9 @@ export class UntappdClient {
151
152
  userAgent = readEnvVar('UNTAPPD_USER_AGENT') ?? DEFAULTS.userAgent;
152
153
  token;
153
154
  loginInFlight = null;
155
+ /** Every response this process received within the rolling hour (see src/ratelimit.ts). */
156
+ rateLimit = new RateLimitTracker();
157
+ apiCallListeners = [];
154
158
  tokenIsSupplied;
155
159
  /**
156
160
  * Defer the config error so the server can still start (and respond to the
@@ -206,6 +210,39 @@ export class UntappdClient {
206
210
  get loginName() {
207
211
  return this._loginName;
208
212
  }
213
+ /**
214
+ * Estimated calls left this hour, or null before this process has made any
215
+ * call in the window. Rests on a LOCAL count of calls (the v4 API sends
216
+ * `X-Ratelimit-Remaining: 0` on every success, so the header is only believed
217
+ * once it has reported a live non-zero value, or on a 429) — see
218
+ * src/ratelimit.ts. Consumers must still treat null as "unknown".
219
+ */
220
+ get quotaRemaining() {
221
+ const obs = this.rateLimit.observations();
222
+ if (obs.length === 0)
223
+ return null;
224
+ return computeRateLimitStatus(obs, Date.now(), { configuredLimit: configuredHourlyLimit() }).estimated_remaining;
225
+ }
226
+ /**
227
+ * Subscribe to every API response (status + rate-limit headers). The stdio
228
+ * server uses it to persist the call log into the shared cache file, so other
229
+ * processes (and the next one) know what this hour has already spent.
230
+ */
231
+ onApiCall(listener) {
232
+ this.apiCallListeners.push(listener);
233
+ }
234
+ noteQuota(res) {
235
+ const o = observeResponse(res.status, res.headers);
236
+ this.rateLimit.record(o);
237
+ for (const l of this.apiCallListeners) {
238
+ try {
239
+ l(o);
240
+ }
241
+ catch {
242
+ /* a listener failure must never fail the API call */
243
+ }
244
+ }
245
+ }
209
246
  /** App creds for a write; reached only once a token is in hand. */
210
247
  requireAppCreds() {
211
248
  if (!this.clientId || !this.clientSecret) {
@@ -315,6 +352,7 @@ export class UntappdClient {
315
352
  body = f.toString();
316
353
  }
317
354
  const res = await this.send(method, `${BASE_URL}${path}${qs}`, { headers, body });
355
+ this.noteQuota(res);
318
356
  // A 401 means the token went stale. Re-minting one needs a login, and a
319
357
  // deployment that SUPPLIED the token may have no username/password to log
320
358
  // in with — that fell through to `missingCredsError()` and told the
package/dist/compact.js CHANGED
@@ -75,36 +75,6 @@ export function compactUserBeer(item) {
75
75
  last_had: i.recent_created_at,
76
76
  };
77
77
  }
78
- /**
79
- * Slim one `/brewery/beer_list/` item.
80
- *
81
- * This endpoint needs its OWN projector rather than reusing
82
- * {@link compactBeerResult}, and the reason is easy to miss: the item shape IS
83
- * `{beer:{…}, brewery:{…}}`-wrapped, so `beerCore` fits perfectly — but the two
84
- * fields the search projector adds on top of it are named differently here.
85
- * `/search/beer` says `checkin_count` / `have_had`; `/brewery/beer_list/` says
86
- * `total_count` / `has_had`. Reusing the search projector would therefore have
87
- * emitted a page of records carrying two silent `undefined`s — a beer with no
88
- * check-ins that you have never had — and `projectItems`' drift guard cannot
89
- * catch that, because the CONTAINER is exactly where it expects it to be.
90
- * Established against a live capture of brewery 1142, not by inference.
91
- *
92
- * Rating rides along because the tool's own description promises "per-beer
93
- * rating and check-in counts"; a compact rung that dropped them would
94
- * contradict the thing the caller was told they were getting.
95
- */
96
- export function compactBreweryBeer(item) {
97
- const i = asDict(item) ?? {};
98
- const beer = asDict(i.beer) ?? {};
99
- return {
100
- ...beerCore(i),
101
- rating: beer.rating_score,
102
- rating_count: beer.rating_count,
103
- checkin_count: i.total_count,
104
- have_had: i.has_had,
105
- your_count: i.total_user_count,
106
- };
107
- }
108
78
  /**
109
79
  * Project `<container>.items` in a response with `mapFn`, preserving the rest of
110
80
  * the container (pagination etc.). Drift-safe: returns the raw response
@@ -125,20 +95,55 @@ export const compactBeerSearch = (resp) => projectItems(resp, 'beers', compactBe
125
95
  export const compactWishlist = (resp) => projectItems(resp, 'beers', compactWishlistBeer);
126
96
  export const compactUserBeers = (resp) => projectItems(resp, 'beers', compactUserBeer);
127
97
  /**
128
- * Project a brewery beer list, and drop `sorting_options` with it.
98
+ * The ONE compact shape of `untappd_brewery_beers`, whether its rows came from
99
+ * Untappd just now or from the local cache — so a caller never sees different
100
+ * keys depending on whether the cache happened to open. Both paths first map
101
+ * the page through `mapBreweryBeerItem` (store.ts), which is where the
102
+ * endpoint's own naming is read: `/brewery/beer_list/` says `total_count` (not
103
+ * `checkin_count`) and `has_had` (not `have_had`), established against a live
104
+ * capture of brewery 1142.
129
105
  *
130
- * `sorting_options` is 16 `{sort_key, sort_name}` pairs of UI chrome repeated on
131
- * every page. The tool's `sort` parameter is already a closed enum on its input
132
- * schema, so the list tells a caller nothing they were not handed with the
133
- * schema — it is pure duplication of a contract they already hold.
106
+ * It evolves the old live-only projection as a superset: every item keeps
107
+ * bid/name/style/abv/ibu/brewery/rating/rating_count/checkin_count and gains
108
+ * in_production (+ favorite when the cache marks one). Two old item fields are
109
+ * gone on purpose: `have_had` / `your_count` were the item's `has_had` /
110
+ * `total_user_count`, which describe the AUTHENTICATED account, never the user
111
+ * being asked about. The old top-level `total_count` and `beers.count` stay;
112
+ * Untappd's `sorting_options` (UI chrome duplicating the input enum) stays
113
+ * dropped; `pagination` is ours, so paging reads the same off either path.
134
114
  */
135
- export function compactBreweryBeers(resp) {
136
- const projected = projectItems(resp, 'beers', compactBreweryBeer);
137
- // projectItems returns the SAME object on drift; don't reshape that.
138
- if (projected === resp)
139
- return resp;
140
- const { sorting_options: _dropped, ...rest } = projected;
141
- return rest;
115
+ export function compactBreweryBeerList(r, page, favorites) {
116
+ const count = r.beers.length;
117
+ const more = r.total_count !== null ? page.offset + count < r.total_count : count > 0 && count >= page.limit;
118
+ return {
119
+ brewery_id: r.brewery_id,
120
+ brewery_name: r.brewery_name,
121
+ total_count: r.total_count,
122
+ source: r.source,
123
+ fetched_at: r.fetched_at,
124
+ expires_at: r.expires_at,
125
+ ...(favorites ? { favorite: favorites.brewery } : {}),
126
+ pagination: { offset: page.offset, limit: page.limit, next_offset: more ? page.offset + count : null },
127
+ beers: {
128
+ count,
129
+ items: r.beers.map((b) => {
130
+ const fav = favorites?.beer(b.bid);
131
+ return {
132
+ bid: b.bid,
133
+ name: b.beer_name,
134
+ style: b.style,
135
+ abv: b.abv,
136
+ ibu: b.ibu,
137
+ brewery: r.brewery_name,
138
+ rating: b.rating_score,
139
+ rating_count: b.rating_count,
140
+ checkin_count: b.checkin_count,
141
+ in_production: b.in_production,
142
+ ...(fav !== undefined ? { favorite: fav } : {}),
143
+ };
144
+ }),
145
+ },
146
+ };
142
147
  }
143
148
  /**
144
149
  * The rungs this server honours (`@chrischall/mcp-utils`' `view` vocabulary,
package/dist/index.js CHANGED
@@ -14,24 +14,44 @@ import { registerWishlistTools } from './tools/wishlist.js';
14
14
  import { registerCheckinTools } from './tools/checkin.js';
15
15
  import { registerUtilityTools } from './tools/utilities.js';
16
16
  import { registerCacheTools } from './tools/cache.js';
17
+ import { registerFavoriteTools } from './tools/favorites.js';
18
+ import { registerTripTools } from './tools/trip.js';
19
+ import { registerMenuTools } from './tools/menu.js';
17
20
  import { logRegisteredTools } from './tools/diagnostics.js';
18
- import { CheckinCache, defaultCachePath } from './cache/db.js';
21
+ import { CheckinCache, defaultCachePath, describeCachePath } from './cache/db.js';
19
22
  // Build the env-based client once and inject it into each registrar. The
20
23
  // constructor defers its config error, so the server still boots (and answers
21
24
  // the host's install-time tools/list probe) when credentials are absent.
22
25
  const client = new UntappdClient();
23
26
  // The stdio server backs the cache with a local `node:sqlite` file, opened lazily
24
27
  // on first cache-tool use so the server still boots when no path is configured.
28
+ // The path is resolved (and logged) once at startup, so it can't drift with the
29
+ // working directory a host happens to launch from.
30
+ const cachePath = defaultCachePath();
31
+ console.error(describeCachePath(cachePath));
25
32
  let nodeCache;
26
- const nodeCacheProvider = () => (nodeCache ??= CheckinCache.open(defaultCachePath()));
33
+ const nodeCacheProvider = () => {
34
+ if (!nodeCache) {
35
+ nodeCache = CheckinCache.open(cachePath);
36
+ // Hand the shared call log what this process already spent before the cache opened.
37
+ void nodeCache.recordApiCalls?.(client.rateLimit.observations()).catch(() => { });
38
+ }
39
+ return nodeCache;
40
+ };
41
+ // Persist every API response's rate-limit observation into the shared cache file
42
+ // (once it is open), so a second process — or the next one — counts this hour's
43
+ // calls too (src/ratelimit.ts). Recording never fails an API call.
44
+ client.onApiCall((o) => {
45
+ void nodeCache?.recordApiCalls?.([o]).catch(() => { });
46
+ });
27
47
  await runMcp({
28
48
  name: 'untappd-mcp',
29
49
  version: VERSION,
30
50
  banner: '[untappd-mcp] This project was developed and is maintained by AI (Claude Opus 4.8). Use at your own discretion.',
31
51
  tools: [
32
52
  (s) => registerBeerTools(s, client, nodeCacheProvider),
33
- (s) => registerBreweryTools(s, client),
34
- (s) => registerVenueTools(s, client),
53
+ (s) => registerBreweryTools(s, client, nodeCacheProvider),
54
+ (s) => registerVenueTools(s, client, nodeCacheProvider),
35
55
  (s) => registerUserTools(s, client),
36
56
  (s) => registerFeedTools(s, client),
37
57
  (s) => registerResolveTools(s, client),
@@ -43,6 +63,9 @@ await runMcp({
43
63
  // The cache is backed by a local `node:sqlite` file here; the provider is
44
64
  // injectable, so another deployment can back the same tools differently.
45
65
  (s) => registerCacheTools(s, client, nodeCacheProvider),
66
+ (s) => registerFavoriteTools(s, client, nodeCacheProvider),
67
+ (s) => registerTripTools(s, client, nodeCacheProvider),
68
+ (s) => registerMenuTools(s, client, nodeCacheProvider),
46
69
  // Keep last: logs the full registered toolset (count + names) at startup.
47
70
  (s) => logRegisteredTools(s, 'stdio'),
48
71
  ],
@@ -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
+ }