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,514 @@
|
|
|
1
|
+
// The untappd_menu_pick / untappd_venue_pick pipeline: freshen the user's
|
|
2
|
+
// distinct-beers cache, resolve each menu line to a bid, check every bid
|
|
3
|
+
// against the cache, and rank what the user hasn't had.
|
|
4
|
+
//
|
|
5
|
+
// Everything runs under ONE per-call API budget (Untappd allows ~100
|
|
6
|
+
// calls/hour per token): the sync gets what resolution doesn't need, and
|
|
7
|
+
// anything the budget can't cover comes back flagged for another run rather
|
|
8
|
+
// than looping.
|
|
9
|
+
import { RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
10
|
+
import { beerMetaFrom } from '../cache/store.js';
|
|
11
|
+
import { syncUserBeers } from '../cache/sync-beers.js';
|
|
12
|
+
import { breweryNameMatch, candidateFromSearch, isConfident, looksSeasonal, normalizeLine, rankCandidates, searchQueries, } from './match.js';
|
|
13
|
+
import { walkVenueMenu } from './venue-menu.js';
|
|
14
|
+
// Re-fetch cached beer metadata at most this often (matches untappd_top_not_had).
|
|
15
|
+
const BEER_META_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
16
|
+
// A brewery's "possible" list goes stale as it releases beers; a no-match line
|
|
17
|
+
// may start matching once someone adds the beer. Resolved bids don't change.
|
|
18
|
+
const POSSIBLE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
19
|
+
const UNRESOLVED_TTL_MS = 24 * 60 * 60 * 1000;
|
|
20
|
+
// Bump when matching logic changes, so old resolutions are re-scored.
|
|
21
|
+
const RESOLUTION_KEY_VERSION = 'r1';
|
|
22
|
+
const SEARCH_LIMIT = 15;
|
|
23
|
+
const POSSIBLE_MAX = 8;
|
|
24
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
25
|
+
// 429 handling: retry this many times, waiting at most this long. A longer
|
|
26
|
+
// Retry-After is the hourly limit itself — waiting it out inside a tool call
|
|
27
|
+
// is pointless, so the item is reported for another run instead.
|
|
28
|
+
const MAX_RETRIES = 2;
|
|
29
|
+
const MAX_BACKOFF_MS = 8000;
|
|
30
|
+
/** Per-call API budget. Every request the pipeline sends takes one unit. */
|
|
31
|
+
export class ApiBudget {
|
|
32
|
+
limit;
|
|
33
|
+
used = 0;
|
|
34
|
+
constructor(limit) {
|
|
35
|
+
this.limit = limit;
|
|
36
|
+
}
|
|
37
|
+
get remaining() {
|
|
38
|
+
return Math.max(0, this.limit - this.used);
|
|
39
|
+
}
|
|
40
|
+
take() {
|
|
41
|
+
if (this.used >= this.limit)
|
|
42
|
+
return false;
|
|
43
|
+
this.used++;
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
class BudgetExhausted extends Error {
|
|
48
|
+
constructor() {
|
|
49
|
+
super('API budget exhausted');
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
53
|
+
/** Call `fn` under the budget, retrying a 429 with bounded backoff. */
|
|
54
|
+
async function guarded(budget, sleep, fn) {
|
|
55
|
+
for (let attempt = 0;; attempt++) {
|
|
56
|
+
if (!budget.take())
|
|
57
|
+
throw new BudgetExhausted();
|
|
58
|
+
try {
|
|
59
|
+
return await fn();
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
if (!(e instanceof RateLimitError) || attempt >= MAX_RETRIES)
|
|
63
|
+
throw e;
|
|
64
|
+
const wait = e.retryAfterSeconds !== undefined ? e.retryAfterSeconds * 1000 : 1000 * 2 ** attempt;
|
|
65
|
+
if (wait > MAX_BACKOFF_MS)
|
|
66
|
+
throw e;
|
|
67
|
+
await sleep(wait);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Run `fn` over `items` with at most `n` in flight; results keep input order. */
|
|
72
|
+
async function mapPool(items, n, fn) {
|
|
73
|
+
const out = new Array(items.length);
|
|
74
|
+
let next = 0;
|
|
75
|
+
const worker = async () => {
|
|
76
|
+
while (next < items.length) {
|
|
77
|
+
const i = next++;
|
|
78
|
+
out[i] = await fn(items[i]);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker));
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Bring the user's distinct-beers cache up to date within `maxCalls`: an
|
|
86
|
+
* incremental top-of-list refresh when the cache is complete (it stops at the
|
|
87
|
+
* first unchanged cached beer), otherwise a backfill that resumes from the
|
|
88
|
+
* persisted offset and pages until done or out of budget. `coverageComplete`
|
|
89
|
+
* is the state AFTER the sync.
|
|
90
|
+
*/
|
|
91
|
+
export async function ensureFresh(client, cache, username, maxCalls) {
|
|
92
|
+
const prior = await cache.getState(username);
|
|
93
|
+
const mode = prior?.beers_complete ? 'incremental' : 'backfill';
|
|
94
|
+
let calls = 0;
|
|
95
|
+
const counting = { get: (p, q) => (calls++, client.get(p, q)) };
|
|
96
|
+
const extra = {};
|
|
97
|
+
if (maxCalls < 1) {
|
|
98
|
+
extra.skipped = 'no API budget left for a sync this run';
|
|
99
|
+
}
|
|
100
|
+
else {
|
|
101
|
+
try {
|
|
102
|
+
const s = await syncUserBeers(counting, cache, username, maxCalls);
|
|
103
|
+
if (s.incremental)
|
|
104
|
+
extra.stopped_at_bid = s.stopped_at_bid;
|
|
105
|
+
}
|
|
106
|
+
catch (e) {
|
|
107
|
+
if (e instanceof RateLimitError)
|
|
108
|
+
extra.rate_limited = true;
|
|
109
|
+
else
|
|
110
|
+
extra.error = messageOf(e);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const state = await cache.getState(username);
|
|
114
|
+
return {
|
|
115
|
+
report: { mode, api_calls: calls, ...extra },
|
|
116
|
+
coverageComplete: (state?.beers_complete ?? false) || (state?.backfill_complete ?? false),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
function resolutionKey(line, venueCity) {
|
|
120
|
+
const city = venueCity ? venueCity.trim().toLowerCase() : '';
|
|
121
|
+
const text = line.kind === 'brewery_generic' ? `brewery:${line.breweryHint}` : line.tokens.join(' ');
|
|
122
|
+
return `${RESOLUTION_KEY_VERSION}|${text}|${city}`;
|
|
123
|
+
}
|
|
124
|
+
function fromCache(row, item, nowMs) {
|
|
125
|
+
const age = nowMs - Date.parse(row.resolved_at);
|
|
126
|
+
try {
|
|
127
|
+
const data = row.data ? JSON.parse(row.data) : null;
|
|
128
|
+
if (row.status === 'resolved' && row.bid !== null && data) {
|
|
129
|
+
return { kind: 'resolved', beer: { ...data, item }, cached: true };
|
|
130
|
+
}
|
|
131
|
+
if (row.status === 'possible' && data && age < POSSIBLE_TTL_MS) {
|
|
132
|
+
return { kind: 'possible', possible: { ...data, item }, cached: true };
|
|
133
|
+
}
|
|
134
|
+
if (row.status === 'unresolved' && data && age < UNRESOLVED_TTL_MS) {
|
|
135
|
+
return { kind: 'unresolved', unresolved: { item, reason: data.reason }, cacheable: false, cached: true };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
/* corrupt row — re-resolve */
|
|
140
|
+
}
|
|
141
|
+
return null;
|
|
142
|
+
}
|
|
143
|
+
function toResolution(key, item, o, nowIso) {
|
|
144
|
+
if (o.cached)
|
|
145
|
+
return null;
|
|
146
|
+
if (o.kind === 'resolved') {
|
|
147
|
+
const { item: _i, ...beer } = o.beer;
|
|
148
|
+
return { key, item, status: 'resolved', bid: o.beer.bid, data: JSON.stringify(beer), resolved_at: nowIso };
|
|
149
|
+
}
|
|
150
|
+
if (o.kind === 'possible') {
|
|
151
|
+
const { item: _i, ...p } = o.possible;
|
|
152
|
+
return { key, item, status: 'possible', bid: null, data: JSON.stringify(p), resolved_at: nowIso };
|
|
153
|
+
}
|
|
154
|
+
if (!o.cacheable)
|
|
155
|
+
return null;
|
|
156
|
+
return { key, item, status: 'unresolved', bid: null, data: JSON.stringify({ reason: o.unresolved.reason }), resolved_at: nowIso };
|
|
157
|
+
}
|
|
158
|
+
/** Turn an API failure during resolution into an unresolved line (never cached). */
|
|
159
|
+
function failure(item, e) {
|
|
160
|
+
const reason = e instanceof BudgetExhausted
|
|
161
|
+
? 'api budget exhausted — run again to resolve'
|
|
162
|
+
: e instanceof RateLimitError
|
|
163
|
+
? 'rate limited by Untappd — run again later'
|
|
164
|
+
: `lookup failed: ${messageOf(e)}`;
|
|
165
|
+
return { kind: 'unresolved', unresolved: { item, reason }, cacheable: false, cached: false };
|
|
166
|
+
}
|
|
167
|
+
async function resolveBeerLine(line, ctx) {
|
|
168
|
+
const cands = [];
|
|
169
|
+
let ranked = rankCandidates(line.tokens, cands, ctx.venueCity);
|
|
170
|
+
// Most specific query first; the style-less fallback only when the first
|
|
171
|
+
// found nothing confident (Untappd ANDs every term, so "… nashville blonde"
|
|
172
|
+
// can return zero hits while "… nashville" finds the beer).
|
|
173
|
+
for (const q of searchQueries(line)) {
|
|
174
|
+
const data = await guarded(ctx.budget, ctx.sleep, () => ctx.client.get('/search/beer', { q, limit: SEARCH_LIMIT }));
|
|
175
|
+
for (const it of data?.beers?.items ?? []) {
|
|
176
|
+
const c = candidateFromSearch(it);
|
|
177
|
+
if (c)
|
|
178
|
+
cands.push(c);
|
|
179
|
+
}
|
|
180
|
+
ranked = rankCandidates(line.tokens, cands, ctx.venueCity);
|
|
181
|
+
if (isConfident(ranked[0]))
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
const top = ranked[0];
|
|
185
|
+
if (isConfident(top)) {
|
|
186
|
+
return {
|
|
187
|
+
kind: 'resolved',
|
|
188
|
+
beer: { item: line.raw, bid: top.bid, name: top.name, brewery: top.brewery, brewery_id: top.brewery_id, style: top.style, abv: top.abv },
|
|
189
|
+
cached: false,
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (ranked.length) {
|
|
193
|
+
return {
|
|
194
|
+
kind: 'possible',
|
|
195
|
+
possible: {
|
|
196
|
+
item: line.raw,
|
|
197
|
+
reason: 'no confident match — pick one',
|
|
198
|
+
candidates: ranked.slice(0, 3).map((c) => ({
|
|
199
|
+
bid: c.bid, name: `${c.brewery} ${c.name}`.trim(), style: c.style, abv: c.abv, global_rating: c.rating,
|
|
200
|
+
})),
|
|
201
|
+
needs_confirmation: true,
|
|
202
|
+
},
|
|
203
|
+
cached: false,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return { kind: 'unresolved', unresolved: { item: line.raw, reason: 'no Untappd match' }, cacheable: true, cached: false };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* "<Brewery> SEASONAL": find the brewery, then offer its in-production beers
|
|
210
|
+
* that look seasonal/limited (falling back to its newest releases) as
|
|
211
|
+
* `possible` candidates — the menu doesn't say which one is pouring.
|
|
212
|
+
* Brewery lists carry global ratings, so they also seed beer metadata.
|
|
213
|
+
*/
|
|
214
|
+
async function resolveBreweryLine(line, ctx) {
|
|
215
|
+
const hint = line.breweryHint ?? '';
|
|
216
|
+
const found = await guarded(ctx.budget, ctx.sleep, () => ctx.client.get('/search/brewery', { q: hint, limit: 10 }));
|
|
217
|
+
const breweries = (found?.brewery?.items ?? [])
|
|
218
|
+
.map((i) => i.brewery ?? {})
|
|
219
|
+
.map((b) => {
|
|
220
|
+
const city = b.location?.brewery_city;
|
|
221
|
+
const sameCity = ctx.venueCity && typeof city === 'string' && city.toLowerCase() === ctx.venueCity.toLowerCase();
|
|
222
|
+
return { b, score: breweryNameMatch(hint, String(b.brewery_name ?? '')) + (sameCity ? 0.25 : 0) };
|
|
223
|
+
})
|
|
224
|
+
.filter((x) => typeof x.b.brewery_id === 'number' && x.score >= 0.5)
|
|
225
|
+
.sort((a, b) => b.score - a.score);
|
|
226
|
+
const brewery = breweries[0]?.b;
|
|
227
|
+
if (!brewery) {
|
|
228
|
+
return { kind: 'unresolved', unresolved: { item: line.raw, reason: `brewery "${hint}" not found` }, cacheable: true, cached: false };
|
|
229
|
+
}
|
|
230
|
+
const list = await guarded(ctx.budget, ctx.sleep, () => ctx.client.get(`/brewery/beer_list/${brewery.brewery_id}`, { limit: 50 }));
|
|
231
|
+
const items = (list?.beers?.items ?? []).filter((i) => typeof i.beer?.bid === 'number');
|
|
232
|
+
const meta = items.map((i) => beerMetaFrom(i.beer, i.brewery ?? brewery, ctx.nowIso)).filter((m) => m !== null);
|
|
233
|
+
if (meta.length)
|
|
234
|
+
await ctx.cache.upsertBeerMeta(meta);
|
|
235
|
+
const live = items.filter((i) => i.beer?.is_in_production !== 0);
|
|
236
|
+
const seasonal = live.filter((i) => looksSeasonal(String(i.beer?.beer_name ?? ''), i.beer?.beer_style ?? null));
|
|
237
|
+
// No seasonal-looking names: the newest releases are the next-best guess at
|
|
238
|
+
// "what's rotating"; the list itself is popularity-ordered.
|
|
239
|
+
const newest = live
|
|
240
|
+
.slice()
|
|
241
|
+
.sort((a, b) => Date.parse(String(b.beer?.created_at ?? '')) - Date.parse(String(a.beer?.created_at ?? '')) || 0);
|
|
242
|
+
const chosen = (seasonal.length ? seasonal : newest).slice(0, POSSIBLE_MAX);
|
|
243
|
+
if (!chosen.length) {
|
|
244
|
+
return { kind: 'unresolved', unresolved: { item: line.raw, reason: 'brewery has no beers listed' }, cacheable: true, cached: false };
|
|
245
|
+
}
|
|
246
|
+
return {
|
|
247
|
+
kind: 'possible',
|
|
248
|
+
possible: {
|
|
249
|
+
item: line.raw,
|
|
250
|
+
brewery: String(brewery.brewery_name ?? hint),
|
|
251
|
+
reason: seasonal.length ? 'seasonal-looking beers from this brewery' : "brewery's newest releases",
|
|
252
|
+
candidates: chosen.map((i) => ({
|
|
253
|
+
bid: i.beer.bid,
|
|
254
|
+
name: String(i.beer.beer_name ?? ''),
|
|
255
|
+
style: i.beer.beer_style ?? null,
|
|
256
|
+
abv: typeof i.beer.beer_abv === 'number' ? i.beer.beer_abv : null,
|
|
257
|
+
global_rating: typeof i.beer.rating_score === 'number' && i.beer.rating_score > 0 ? i.beer.rating_score : null,
|
|
258
|
+
})),
|
|
259
|
+
needs_confirmation: true,
|
|
260
|
+
},
|
|
261
|
+
cached: false,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
const ratingOf = (m) => m?.weighted_rating_score ?? m?.rating_score ?? null;
|
|
265
|
+
/**
|
|
266
|
+
* Split resolved beers into had / not-had from the cache (no API), fill in
|
|
267
|
+
* global ratings for the not-had ones (beer_meta, else beer/info within the
|
|
268
|
+
* budget), and rank. `possible` candidates get a `had` flag but are never
|
|
269
|
+
* picks — the caller has to confirm which one is actually pouring.
|
|
270
|
+
*/
|
|
271
|
+
async function checkAndRank(ctx, username, resolved, possible, styleFilter, topN) {
|
|
272
|
+
const { cache } = ctx;
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const had = [];
|
|
275
|
+
const notHadBeers = [];
|
|
276
|
+
const candidateBids = possible.flatMap((p) => p.candidates.map((c) => c.bid));
|
|
277
|
+
const verdicts = await cache.hasHadMany(username, [...resolved.map((r) => r.bid), ...candidateBids]);
|
|
278
|
+
for (const r of resolved) {
|
|
279
|
+
if (seen.has(r.bid))
|
|
280
|
+
continue;
|
|
281
|
+
seen.add(r.bid);
|
|
282
|
+
const h = verdicts.get(r.bid);
|
|
283
|
+
if (h.had) {
|
|
284
|
+
had.push({ item: r.item, bid: r.bid, name: r.name, brewery: r.brewery, count: h.count, last_date: h.last_date, user_rating: h.best_rating });
|
|
285
|
+
}
|
|
286
|
+
else
|
|
287
|
+
notHadBeers.push(r);
|
|
288
|
+
}
|
|
289
|
+
for (const p of possible) {
|
|
290
|
+
for (const c of p.candidates)
|
|
291
|
+
c.had = verdicts.get(c.bid).had;
|
|
292
|
+
}
|
|
293
|
+
// Ratings: a fresh cached rating is used as-is; otherwise beer/info, within
|
|
294
|
+
// the budget. A null cached rating counts as missing — search results carry
|
|
295
|
+
// no rating, so a null there means "never fetched", not "unrated".
|
|
296
|
+
const nowMs = Date.parse(ctx.nowIso);
|
|
297
|
+
const metaByBid = new Map((await cache.getBeerMeta(notHadBeers.map((b) => b.bid))).map((m) => [m.bid, m]));
|
|
298
|
+
const fresh = (m) => !!m && ratingOf(m) !== null && !!m.fetched_at && nowMs - Date.parse(m.fetched_at) < BEER_META_TTL_MS;
|
|
299
|
+
let need = notHadBeers.filter((b) => !fresh(metaByBid.get(b.bid)) && b.rating == null);
|
|
300
|
+
let deferred = 0;
|
|
301
|
+
const newMeta = [];
|
|
302
|
+
// Two or more beers from one brewery: its beer list (up to 50, most popular
|
|
303
|
+
// first, ratings included) is one call instead of one beer/info each. Beers
|
|
304
|
+
// the list doesn't reach fall through to beer/info below.
|
|
305
|
+
const byBrewery = new Map();
|
|
306
|
+
for (const b of need) {
|
|
307
|
+
if (typeof b.brewery_id === 'number')
|
|
308
|
+
byBrewery.set(b.brewery_id, [...(byBrewery.get(b.brewery_id) ?? []), b]);
|
|
309
|
+
}
|
|
310
|
+
const groups = [...byBrewery.entries()].filter(([, bs]) => bs.length >= 2);
|
|
311
|
+
const lists = await mapPool(groups, ctx.concurrency, async ([breweryId]) => {
|
|
312
|
+
try {
|
|
313
|
+
const data = await guarded(ctx.budget, ctx.sleep, () => ctx.client.get(`/brewery/beer_list/${breweryId}`, { limit: 50 }));
|
|
314
|
+
return (data?.beers?.items ?? [])
|
|
315
|
+
.map((i) => beerMetaFrom(i.beer, i.brewery, ctx.nowIso))
|
|
316
|
+
.filter((m) => m !== null && ratingOf(m) !== null);
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return []; // the per-beer fallback below still gets a try
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
for (const m of lists.flat()) {
|
|
323
|
+
newMeta.push(m);
|
|
324
|
+
metaByBid.set(m.bid, m);
|
|
325
|
+
}
|
|
326
|
+
need = need.filter((b) => !fresh(metaByBid.get(b.bid)));
|
|
327
|
+
const fetched = await mapPool(need, ctx.concurrency, async (b) => {
|
|
328
|
+
try {
|
|
329
|
+
const data = await guarded(ctx.budget, ctx.sleep, () => ctx.client.get(`/beer/info/${b.bid}`, { compact: 'true' }));
|
|
330
|
+
return beerMetaFrom(data?.beer, undefined, ctx.nowIso);
|
|
331
|
+
}
|
|
332
|
+
catch {
|
|
333
|
+
deferred++;
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
for (const m of fetched) {
|
|
338
|
+
if (!m)
|
|
339
|
+
continue;
|
|
340
|
+
newMeta.push(m);
|
|
341
|
+
metaByBid.set(m.bid, m);
|
|
342
|
+
}
|
|
343
|
+
// Seeds the whole brewery list too, so later menus from it cost nothing.
|
|
344
|
+
if (newMeta.length)
|
|
345
|
+
await cache.upsertBeerMeta(newMeta);
|
|
346
|
+
const not_had = notHadBeers.map((b) => {
|
|
347
|
+
const m = metaByBid.get(b.bid);
|
|
348
|
+
const rating = b.rating ?? ratingOf(m);
|
|
349
|
+
return {
|
|
350
|
+
item: b.item,
|
|
351
|
+
bid: b.bid,
|
|
352
|
+
name: b.name,
|
|
353
|
+
brewery: b.brewery,
|
|
354
|
+
style: b.style ?? m?.style ?? null,
|
|
355
|
+
abv: b.abv ?? m?.abv ?? null,
|
|
356
|
+
global_rating: rating !== null ? Math.round(rating * 100) / 100 : null,
|
|
357
|
+
...(m && rating !== null && b.rating == null && !fresh(m) ? { stale_rating: true } : {}),
|
|
358
|
+
};
|
|
359
|
+
});
|
|
360
|
+
not_had.sort((a, b) => (b.global_rating ?? -1) - (a.global_rating ?? -1));
|
|
361
|
+
const s = styleFilter?.toLowerCase();
|
|
362
|
+
const eligible = s
|
|
363
|
+
? not_had.filter((b) => {
|
|
364
|
+
const parent = metaByBid.get(b.bid)?.parent_style ?? '';
|
|
365
|
+
return (b.style ?? '').toLowerCase().includes(s) || parent.toLowerCase().includes(s);
|
|
366
|
+
})
|
|
367
|
+
: not_had;
|
|
368
|
+
const picks = eligible.slice(0, topN).map((b, i) => ({
|
|
369
|
+
...b,
|
|
370
|
+
reason: `not had · ` +
|
|
371
|
+
(b.global_rating !== null ? `${b.global_rating.toFixed(2)} global rating` : 'no global rating yet') +
|
|
372
|
+
` · #${i + 1} of ${eligible.length} not-had${s ? ` "${styleFilter}"` : ''} beer${eligible.length === 1 ? '' : 's'} on this list`,
|
|
373
|
+
}));
|
|
374
|
+
return { picks, not_had, had, possible, rating_fetch_deferred: deferred };
|
|
375
|
+
}
|
|
376
|
+
/** untappd_menu_pick: raw menu lines in, ranked not-had picks out. */
|
|
377
|
+
export async function menuPick(client, cache, input, deps = {}) {
|
|
378
|
+
const budget = new ApiBudget(input.apiBudget);
|
|
379
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
380
|
+
const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
|
|
381
|
+
const nowIso = new Date().toISOString();
|
|
382
|
+
const nowMs = Date.parse(nowIso);
|
|
383
|
+
const ctx = { client, cache, budget, sleep, venueCity: input.venueCity, nowIso, concurrency };
|
|
384
|
+
// Resolution cache first (no API) so the sync budget can leave room for misses.
|
|
385
|
+
const lines = input.items.map(normalizeLine);
|
|
386
|
+
const keys = lines.map((l) => resolutionKey(l, input.venueCity));
|
|
387
|
+
const cachedRows = input.refreshResolutions ? [] : await cache.getResolutions(keys.filter((_, i) => lines[i].kind !== 'generic'));
|
|
388
|
+
const byKey = new Map(cachedRows.map((r) => [r.key, r]));
|
|
389
|
+
const outcomes = lines.map((l, i) => {
|
|
390
|
+
if (l.kind === 'generic') {
|
|
391
|
+
return { kind: 'unresolved', unresolved: { item: l.raw, reason: 'ask bartender' }, cacheable: false, cached: false };
|
|
392
|
+
}
|
|
393
|
+
const row = byKey.get(keys[i]);
|
|
394
|
+
return row ? (fromCache(row, l.raw, nowMs) ?? undefined) : undefined;
|
|
395
|
+
});
|
|
396
|
+
const misses = outcomes.filter((o) => o === undefined).length;
|
|
397
|
+
// Reserve ~2 calls per unresolved line (search + fallback, or brewery search +
|
|
398
|
+
// beer list) and ~1 per beer for a rating lookup; the sync gets the rest, but
|
|
399
|
+
// never less than one page (the incremental refresh's cost).
|
|
400
|
+
const reserve = misses * 3;
|
|
401
|
+
const syncCalls = Math.min(budget.remaining, Math.max(1, budget.remaining - reserve));
|
|
402
|
+
const { report: sync, coverageComplete } = await ensureFresh(client, cache, input.username, syncCalls);
|
|
403
|
+
budget.used += sync.api_calls;
|
|
404
|
+
const misIdx = outcomes.map((o, i) => (o === undefined ? i : -1)).filter((i) => i >= 0);
|
|
405
|
+
const resolvedNow = await mapPool(misIdx, concurrency, async (i) => {
|
|
406
|
+
const line = lines[i];
|
|
407
|
+
try {
|
|
408
|
+
return line.kind === 'brewery_generic' ? await resolveBreweryLine(line, ctx) : await resolveBeerLine(line, ctx);
|
|
409
|
+
}
|
|
410
|
+
catch (e) {
|
|
411
|
+
return failure(line.raw, e);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
misIdx.forEach((i, j) => (outcomes[i] = resolvedNow[j]));
|
|
415
|
+
const toStore = misIdx
|
|
416
|
+
.map((i) => toResolution(keys[i], lines[i].raw, outcomes[i], nowIso))
|
|
417
|
+
.filter((r) => r !== null);
|
|
418
|
+
if (toStore.length)
|
|
419
|
+
await cache.upsertResolutions(toStore);
|
|
420
|
+
const resolved = [];
|
|
421
|
+
const possible = [];
|
|
422
|
+
const unresolved = [];
|
|
423
|
+
for (const o of outcomes) {
|
|
424
|
+
if (o.kind === 'resolved')
|
|
425
|
+
resolved.push(o.beer);
|
|
426
|
+
else if (o.kind === 'possible')
|
|
427
|
+
possible.push(o.possible);
|
|
428
|
+
else
|
|
429
|
+
unresolved.push(o.unresolved);
|
|
430
|
+
}
|
|
431
|
+
const ranked = await checkAndRank(ctx, input.username, resolved, possible, input.styleFilter, input.topN);
|
|
432
|
+
const { rating_fetch_deferred, ...rest } = ranked;
|
|
433
|
+
const retryable = unresolved.some((u) => /run again/.test(u.reason)) || rating_fetch_deferred > 0;
|
|
434
|
+
return {
|
|
435
|
+
...rest,
|
|
436
|
+
unresolved,
|
|
437
|
+
sync,
|
|
438
|
+
api_calls: budget.used,
|
|
439
|
+
api_budget: budget.limit,
|
|
440
|
+
another_run_needed: !coverageComplete || retryable,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
// Menu pages walk to completion within this many calls (menus are a handful of
|
|
444
|
+
// sections; 50 per page).
|
|
445
|
+
const VENUE_MENU_MAX_PAGES = 10;
|
|
446
|
+
/**
|
|
447
|
+
* untappd_venue_pick: pull a venue's FULL verified menu (walking sections to
|
|
448
|
+
* completion, within the budget), then run the same check + rank. Menu items
|
|
449
|
+
* already carry bids, so there is no search step.
|
|
450
|
+
*/
|
|
451
|
+
export async function venuePick(client, cache, input, deps = {}) {
|
|
452
|
+
const budget = new ApiBudget(input.apiBudget);
|
|
453
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
454
|
+
const concurrency = deps.concurrency ?? DEFAULT_CONCURRENCY;
|
|
455
|
+
const nowIso = new Date().toISOString();
|
|
456
|
+
// Leave at least one call for the sync's incremental refresh.
|
|
457
|
+
let menuCalls = 0;
|
|
458
|
+
const counting = {
|
|
459
|
+
get: (p, q) => (menuCalls++, client.get(p, q)),
|
|
460
|
+
};
|
|
461
|
+
// No budget at all (e.g. Untappd reported 0 calls left): don't fetch the
|
|
462
|
+
// menu — the floor of one page below is for a small budget, not an empty one.
|
|
463
|
+
const walk = budget.remaining < 1
|
|
464
|
+
? { beers: [], totalCount: 0, sawMenu: false, pagesFetched: 0, reachedEnd: false, nextOffset: 0, venueCity: null, venueName: null }
|
|
465
|
+
: await walkVenueMenu(counting, input.venueId, {
|
|
466
|
+
maxPages: Math.max(1, Math.min(VENUE_MENU_MAX_PAGES, budget.remaining - 1)),
|
|
467
|
+
});
|
|
468
|
+
budget.used += menuCalls;
|
|
469
|
+
const metaRows = walk.beers
|
|
470
|
+
.map((b) => beerMetaFrom(b.rawBeer, b.rawBrewery, nowIso))
|
|
471
|
+
.filter((m) => m !== null && ratingOf(m) !== null);
|
|
472
|
+
if (metaRows.length)
|
|
473
|
+
await cache.upsertBeerMeta(metaRows);
|
|
474
|
+
const resolved = walk.beers.map((b) => ({
|
|
475
|
+
item: [b.brewery, b.name].filter((x) => typeof x === 'string' && x).join(' ').trim(),
|
|
476
|
+
bid: b.bid,
|
|
477
|
+
name: typeof b.name === 'string' ? b.name.trim() : String(b.name ?? ''),
|
|
478
|
+
brewery: typeof b.brewery === 'string' ? b.brewery.trim() : '',
|
|
479
|
+
brewery_id: typeof b.rawBrewery?.brewery_id === 'number' ? b.rawBrewery.brewery_id : null,
|
|
480
|
+
style: typeof b.style === 'string' ? b.style : null,
|
|
481
|
+
abv: typeof b.abv === 'number' ? b.abv : null,
|
|
482
|
+
}));
|
|
483
|
+
const distinctBids = new Set(resolved.map((r) => r.bid)).size;
|
|
484
|
+
const cachedMeta = await cache.getBeerMeta([...new Set(resolved.map((r) => r.bid))]);
|
|
485
|
+
const reserve = distinctBids - cachedMeta.filter((m) => ratingOf(m) !== null).length;
|
|
486
|
+
const syncCalls = Math.min(budget.remaining, Math.max(1, budget.remaining - reserve));
|
|
487
|
+
const { report: sync, coverageComplete } = await ensureFresh(client, cache, input.username, syncCalls);
|
|
488
|
+
budget.used += sync.api_calls;
|
|
489
|
+
const ctx = { client, cache, budget, sleep, venueCity: walk.venueCity ?? undefined, nowIso, concurrency };
|
|
490
|
+
const ranked = await checkAndRank(ctx, input.username, resolved, [], input.styleFilter, input.topN);
|
|
491
|
+
const { rating_fetch_deferred, ...rest } = ranked;
|
|
492
|
+
const unresolved = walk.sawMenu
|
|
493
|
+
? []
|
|
494
|
+
: walk.pagesFetched === 0
|
|
495
|
+
? [{ item: `venue ${input.venueId}`, reason: 'api budget exhausted — run again to fetch the menu' }]
|
|
496
|
+
: [{ item: `venue ${input.venueId}`, reason: 'venue has no verified menu on Untappd — use untappd_menu_pick with the tap list' }];
|
|
497
|
+
return {
|
|
498
|
+
...rest,
|
|
499
|
+
unresolved,
|
|
500
|
+
venue: {
|
|
501
|
+
venue_id: input.venueId,
|
|
502
|
+
name: walk.venueName,
|
|
503
|
+
city: walk.venueCity,
|
|
504
|
+
menu_beers: walk.beers.length,
|
|
505
|
+
menu_total: walk.totalCount,
|
|
506
|
+
menu_complete: walk.reachedEnd,
|
|
507
|
+
menu_pages: walk.pagesFetched,
|
|
508
|
+
},
|
|
509
|
+
sync,
|
|
510
|
+
api_calls: budget.used,
|
|
511
|
+
api_budget: budget.limit,
|
|
512
|
+
another_run_needed: !coverageComplete || rating_fetch_deferred > 0 || !walk.reachedEnd,
|
|
513
|
+
};
|
|
514
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk a venue's verified menu sections via venue/info, spending at most
|
|
3
|
+
* `maxPages` API calls.
|
|
4
|
+
*
|
|
5
|
+
* venue/info pages its MENUS with limit/offset, but caps each menu's SECTION
|
|
6
|
+
* list — echoing `section_limit`/`section_offset` back as accepted params
|
|
7
|
+
* (the response even carries a `section_offset ` key with a stray trailing
|
|
8
|
+
* space; the real param name is the clean one the web menu UI sends). We
|
|
9
|
+
* forward them and walk sections, deduping by menu+section+bid so overlapping
|
|
10
|
+
* or param-ignoring pages can't double-count.
|
|
11
|
+
*
|
|
12
|
+
* Termination is driven by SECTION availability, not a running beer count:
|
|
13
|
+
* a page that returns fewer sections than we asked for is the end of the
|
|
14
|
+
* list. That's what makes resuming correct — the walk is stateless across
|
|
15
|
+
* calls, so on a resumed tail (section_offset > 0) `beers.length` covers
|
|
16
|
+
* only this run and can never equal the whole-menu total, which would
|
|
17
|
+
* otherwise mislabel a finished tail as truncated.
|
|
18
|
+
*/
|
|
19
|
+
export async function walkVenueMenu(client, venueId, opts) {
|
|
20
|
+
const pageSize = opts.sectionLimit ?? 50;
|
|
21
|
+
const menuId = opts.menuId;
|
|
22
|
+
let offset = opts.sectionOffset ?? 0;
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
const beers = [];
|
|
25
|
+
let totalCount = 0;
|
|
26
|
+
let sawMenu = false;
|
|
27
|
+
let pagesFetched = 0;
|
|
28
|
+
let reachedEnd = false;
|
|
29
|
+
let venueCity = null;
|
|
30
|
+
let venueName = null;
|
|
31
|
+
for (let page = 0; page < opts.maxPages; page++) {
|
|
32
|
+
const data = await client.get(`/venue/info/${venueId}`, {
|
|
33
|
+
section_limit: pageSize,
|
|
34
|
+
section_offset: offset,
|
|
35
|
+
menu_id: menuId,
|
|
36
|
+
sort: opts.sort,
|
|
37
|
+
});
|
|
38
|
+
pagesFetched++;
|
|
39
|
+
const venue = data?.venue;
|
|
40
|
+
if (page === 0 && venue) {
|
|
41
|
+
const loc = venue.location;
|
|
42
|
+
venueCity = typeof loc?.venue_city === 'string' ? loc.venue_city : null;
|
|
43
|
+
venueName = typeof venue.venue_name === 'string' ? venue.venue_name : null;
|
|
44
|
+
}
|
|
45
|
+
const vb = venue?.verfied_beers;
|
|
46
|
+
if (!vb) {
|
|
47
|
+
reachedEnd = true; // no menu payload — nothing more to page
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
sawMenu = true;
|
|
51
|
+
let added = 0;
|
|
52
|
+
let matchedItemCount = 0;
|
|
53
|
+
let sectionsReturned = 0;
|
|
54
|
+
for (const wrap of vb.items ?? []) {
|
|
55
|
+
const menu = wrap?.menu;
|
|
56
|
+
if (!menu)
|
|
57
|
+
continue;
|
|
58
|
+
if (menuId && menu.menu_id !== menuId)
|
|
59
|
+
continue;
|
|
60
|
+
if (typeof menu.total_item_count === 'number')
|
|
61
|
+
matchedItemCount += menu.total_item_count;
|
|
62
|
+
const sections = menu.sections?.items ?? [];
|
|
63
|
+
for (const section of sections) {
|
|
64
|
+
sectionsReturned++;
|
|
65
|
+
for (const it of section.items ?? []) {
|
|
66
|
+
const beer = it?.beer;
|
|
67
|
+
if (!beer || typeof beer.bid !== 'number')
|
|
68
|
+
continue;
|
|
69
|
+
const key = `${String(menu.menu_id)}:${String(section.section_id)}:${beer.bid}`;
|
|
70
|
+
if (seen.has(key))
|
|
71
|
+
continue;
|
|
72
|
+
seen.add(key);
|
|
73
|
+
const sectionName = section.section_name;
|
|
74
|
+
const brewery = it.brewery;
|
|
75
|
+
beers.push({
|
|
76
|
+
bid: beer.bid,
|
|
77
|
+
name: beer.beer_name,
|
|
78
|
+
brewery: brewery?.brewery_name,
|
|
79
|
+
style: beer.beer_style,
|
|
80
|
+
abv: beer.beer_abv,
|
|
81
|
+
price: it.price?.value,
|
|
82
|
+
serving_type: it.serving_type,
|
|
83
|
+
menu: menu.menu_name,
|
|
84
|
+
section: typeof sectionName === 'string' ? sectionName.trim() : sectionName,
|
|
85
|
+
rawBeer: beer,
|
|
86
|
+
rawBrewery: brewery,
|
|
87
|
+
});
|
|
88
|
+
added++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
// Coverage target: with a menu_id filter, aim for THAT menu's own item
|
|
93
|
+
// count — verfied_beers.total_count spans every menu, so a single-menu
|
|
94
|
+
// slice could never reach it (it would burn the whole page budget and
|
|
95
|
+
// wrongly report a shortfall). Without a filter, the venue-wide total.
|
|
96
|
+
totalCount = menuId ? matchedItemCount : typeof vb.total_count === 'number' ? vb.total_count : totalCount;
|
|
97
|
+
offset += pageSize;
|
|
98
|
+
if (totalCount > 0 && beers.length >= totalCount) {
|
|
99
|
+
reachedEnd = true; // full coverage from this walk
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
if (sectionsReturned < pageSize || added === 0) {
|
|
103
|
+
reachedEnd = true; // a short (or all-duplicate) page is the end of the section list
|
|
104
|
+
break;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { beers, totalCount, sawMenu, pagesFetched, reachedEnd, nextOffset: offset, venueCity, venueName };
|
|
108
|
+
}
|
package/dist/quota.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cap a per-call API budget to the calls Untappd says are left this hour, so
|
|
3
|
+
* two tools run back to back don't each plan as if they had the whole quota.
|
|
4
|
+
* When the quota is unknown (no response has carried X-Ratelimit-Remaining)
|
|
5
|
+
* the requested budget stands. `floor` keeps tools whose paging logic needs at
|
|
6
|
+
* least one page from being handed zero.
|
|
7
|
+
*/
|
|
8
|
+
export function clampToQuota(client, requested, floor = 0) {
|
|
9
|
+
// Test fakes and other stand-ins may not implement the getter at all.
|
|
10
|
+
const remaining = client.quotaRemaining ?? null;
|
|
11
|
+
if (remaining === null || remaining >= requested)
|
|
12
|
+
return { budget: requested, quota_remaining: remaining, clamped: false };
|
|
13
|
+
return { budget: Math.max(floor, remaining), quota_remaining: remaining, clamped: true };
|
|
14
|
+
}
|
package/dist/tools/beer.js
CHANGED
|
@@ -13,9 +13,12 @@ async function seedBeerMeta(cache, beers) {
|
|
|
13
13
|
return;
|
|
14
14
|
try {
|
|
15
15
|
const now = new Date().toISOString();
|
|
16
|
+
// Search results carry no rating. Seeding one would overwrite a real cached
|
|
17
|
+
// rating with null under a fresh timestamp, so the ranking tools would
|
|
18
|
+
// trust it for 30 days — only rows that actually carry a rating are seeded.
|
|
16
19
|
const rows = beers
|
|
17
20
|
.map((b) => beerMetaFrom(b.beer, b.brewery, now))
|
|
18
|
-
.filter((m) => m !== null);
|
|
21
|
+
.filter((m) => m !== null && (m.weighted_rating_score ?? m.rating_score) !== null);
|
|
19
22
|
if (rows.length)
|
|
20
23
|
await cache().upsertBeerMeta(rows);
|
|
21
24
|
}
|