untappd-mcp 2.4.0 → 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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +74 -2
- package/dist/bundle.js +5438 -1664
- package/dist/cache/brewery-beers.js +116 -0
- package/dist/cache/favorites.js +244 -0
- package/dist/cache/multi-user.js +61 -0
- package/dist/cache/response-cache.js +23 -0
- package/dist/cache/store.js +1203 -12
- package/dist/cache/sync-beers.js +24 -2
- package/dist/cache/sync-brewery-likes.js +124 -0
- package/dist/cache/sync-venues.js +200 -0
- package/dist/cache/sync-wishlist.js +126 -0
- package/dist/cache/sync.js +61 -12
- package/dist/cache/venue-drinks.js +101 -0
- package/dist/client.js +29 -15
- package/dist/compact.js +47 -42
- package/dist/index.js +20 -3
- package/dist/menu/pick.js +7 -1
- package/dist/ratelimit.js +236 -0
- package/dist/tools/brewery.js +49 -11
- package/dist/tools/cache-optional.js +22 -0
- package/dist/tools/cache.js +457 -35
- package/dist/tools/favorites.js +110 -0
- package/dist/tools/trip.js +384 -0
- package/dist/tools/user.js +38 -7
- package/dist/tools/venue.js +92 -38
- package/dist/version.js +1 -1
- package/mint.yaml +5 -0
- package/package.json +1 -1
- package/server.json +9 -2
- package/skills/untappd-mcp/SKILL.md +30 -0
package/dist/cache/sync-beers.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
2
2
|
import { mapBeerRow } 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/beers.
|
|
4
5
|
// A resumed backfill re-reads this many already-fetched items: if beers dropped
|
|
5
6
|
// out of the list between runs, everything below shifted UP past the stored
|
|
@@ -65,8 +66,25 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
|
|
|
65
66
|
}
|
|
66
67
|
};
|
|
67
68
|
const gapNow = async () => total === null ? 0 : total - (await cache.distinctBeersCount(rawUsername));
|
|
69
|
+
let rateStop;
|
|
68
70
|
for (; pages < maxPages && !complete;) {
|
|
69
|
-
|
|
71
|
+
// Stop BEFORE a call that would run into the hourly limit; progress so far
|
|
72
|
+
// is already persisted (setState after every page), so the next run resumes.
|
|
73
|
+
const verdict = await checkRateBudget(client, cache);
|
|
74
|
+
if (!verdict.ok) {
|
|
75
|
+
rateStop = stopFromVerdict(verdict);
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
let page;
|
|
79
|
+
try {
|
|
80
|
+
page = pages === 0 ? await firstFetch(offset) : await fetchBeersPage(client, encodedUser, offset);
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
if (!(e instanceof RateLimitError))
|
|
84
|
+
throw e;
|
|
85
|
+
rateStop = stopFrom429(e);
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
70
88
|
pages++;
|
|
71
89
|
if (page.total !== null)
|
|
72
90
|
total = page.total;
|
|
@@ -146,7 +164,9 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
|
|
|
146
164
|
const anotherRunNeeded = !complete;
|
|
147
165
|
const note = complete
|
|
148
166
|
? 'Full distinct-beers list is cached — has-had coverage is complete for this user. Re-run occasionally to pick up newly-had beers.'
|
|
149
|
-
:
|
|
167
|
+
: rateStop
|
|
168
|
+
? `Distinct-beers sync paused (${cachedBeers}${total !== null ? `/${total}` : ''}). ${resumeAdvice(rateStop, 'untappd_sync_user_beers')}`
|
|
169
|
+
: `Distinct-beers sync in progress (${cachedBeers}${total !== null ? `/${total}` : ''}). Run untappd_sync_user_beers again to fetch the next ${maxPages} pages.`;
|
|
150
170
|
return {
|
|
151
171
|
username: rawUsername,
|
|
152
172
|
beers_added: added,
|
|
@@ -158,6 +178,8 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
|
|
|
158
178
|
incremental,
|
|
159
179
|
stopped_at_bid: stoppedAtBid,
|
|
160
180
|
another_run_needed: anotherRunNeeded,
|
|
181
|
+
stopped_for_rate_limit: rateStop !== undefined,
|
|
182
|
+
...(rateStop ? { rate_limit: rateStop } : {}),
|
|
161
183
|
last_synced_at: now(),
|
|
162
184
|
note,
|
|
163
185
|
};
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
2
|
+
import { mapBreweryLikeItem } from './store.js';
|
|
3
|
+
import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
|
|
4
|
+
/** The `list_sync_state` key for a user's brewery-likes cursor (literal also in store.ts breweryLikesCoverage). */
|
|
5
|
+
export const BREWERY_LIKES_LIST = 'brewery_likes';
|
|
6
|
+
/**
|
|
7
|
+
* The `list_sync_state` key whose `last_synced_at` holds the CURRENT pass's
|
|
8
|
+
* stamp (same scheme as the wishlist mirror): rows a finished pass did not
|
|
9
|
+
* stamp were unliked upstream.
|
|
10
|
+
*/
|
|
11
|
+
export const BREWERY_LIKES_PASS_LIST = 'brewery_likes_pass';
|
|
12
|
+
/** user/brewerylikes honours limit=50 (live, 6a); still advance by what it returns. */
|
|
13
|
+
export const BREWERY_LIKES_PAGE_REQUEST = 50;
|
|
14
|
+
/** A resumed pass re-reads this many items (the list may have shifted up between runs). */
|
|
15
|
+
export const BREWERY_LIKES_RESUME_OVERLAP = 5;
|
|
16
|
+
export const BREWERY_LIKES_MAX_PAGES_CAP = 50;
|
|
17
|
+
const offsetOf = (po) => typeof po === 'number' ? po : typeof po === 'string' && /^\d+$/.test(po) ? Number(po) : null;
|
|
18
|
+
/**
|
|
19
|
+
* Mirror the breweries a user "likes" on Untappd (user/brewerylikes — the
|
|
20
|
+
* app's "following"; works for non-self users) into `brewery_likes`, where they
|
|
21
|
+
* become the "untappd" favorite source. Same shape as the wishlist mirror: each
|
|
22
|
+
* sync is a full pass from the top, resumable across runs with the cursor
|
|
23
|
+
* persisted every page and the stop-before-429 guard before every call; rows
|
|
24
|
+
* are stamped with the pass and only a FINISHED pass removes unliked breweries.
|
|
25
|
+
*/
|
|
26
|
+
export async function syncBreweryLikes(client, cache, rawUsername, maxPages = 10) {
|
|
27
|
+
const pagesBudget = Math.max(1, Math.min(BREWERY_LIKES_MAX_PAGES_CAP, Math.floor(maxPages)));
|
|
28
|
+
const encodedUser = encodeURIComponent(rawUsername);
|
|
29
|
+
const now = () => new Date().toISOString();
|
|
30
|
+
const prior = await cache.getListState(rawUsername, BREWERY_LIKES_LIST);
|
|
31
|
+
const passRow = await cache.getListState(rawUsername, BREWERY_LIKES_PASS_LIST);
|
|
32
|
+
const resuming = !!prior && !prior.complete && prior.next_offset !== null && !!passRow?.last_synced_at;
|
|
33
|
+
let passStamp;
|
|
34
|
+
let offset;
|
|
35
|
+
if (resuming) {
|
|
36
|
+
passStamp = passRow.last_synced_at;
|
|
37
|
+
offset = Math.max(0, prior.next_offset - BREWERY_LIKES_RESUME_OVERLAP);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
const prev = passRow?.last_synced_at ? Date.parse(passRow.last_synced_at) : 0;
|
|
41
|
+
passStamp = new Date(Math.max(Date.now(), prev + 1)).toISOString();
|
|
42
|
+
offset = 0;
|
|
43
|
+
await cache.setListState(rawUsername, BREWERY_LIKES_PASS_LIST, { last_synced_at: passStamp, complete: false, next_offset: null });
|
|
44
|
+
}
|
|
45
|
+
let total = prior?.total_count ?? null;
|
|
46
|
+
let pages = 0;
|
|
47
|
+
let added = 0;
|
|
48
|
+
let removed = 0;
|
|
49
|
+
let complete = false;
|
|
50
|
+
let rateStop;
|
|
51
|
+
while (pages < pagesBudget && !complete) {
|
|
52
|
+
const verdict = await checkRateBudget(client, cache);
|
|
53
|
+
if (!verdict.ok) {
|
|
54
|
+
rateStop = stopFromVerdict(verdict);
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
let data;
|
|
58
|
+
try {
|
|
59
|
+
data = await client.get(`/user/brewerylikes/${encodedUser}`, { limit: BREWERY_LIKES_PAGE_REQUEST, offset });
|
|
60
|
+
}
|
|
61
|
+
catch (e) {
|
|
62
|
+
if (e instanceof RateLimitError) {
|
|
63
|
+
rateStop = stopFrom429(e);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
if (pages > 0)
|
|
67
|
+
throw e;
|
|
68
|
+
throw createHelpfulError(`Could not fetch the liked breweries for "${rawUsername}": ${messageOf(e)}`, {
|
|
69
|
+
hint: "Untappd only returns another user's lists if their account is public or they're your friend.",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
pages++;
|
|
73
|
+
const items = Array.isArray(data?.items) ? data.items : [];
|
|
74
|
+
if (typeof data?.total_count === 'number')
|
|
75
|
+
total = data.total_count;
|
|
76
|
+
const rows = [];
|
|
77
|
+
for (const it of items) {
|
|
78
|
+
const r = mapBreweryLikeItem(it);
|
|
79
|
+
if (r)
|
|
80
|
+
rows.push(r);
|
|
81
|
+
}
|
|
82
|
+
added += await cache.upsertBreweryLikes(rawUsername, rows, passStamp);
|
|
83
|
+
// pagination.offset is ABSENT on an empty list (live, 6a) — fall back to offset + items.
|
|
84
|
+
const next = offsetOf(data?.pagination?.offset);
|
|
85
|
+
const advanced = next !== null && next > offset ? next : offset + items.length;
|
|
86
|
+
complete =
|
|
87
|
+
items.length === 0 || (total !== null ? advanced >= total : items.length < BREWERY_LIKES_PAGE_REQUEST);
|
|
88
|
+
offset = advanced;
|
|
89
|
+
if (complete)
|
|
90
|
+
removed = await cache.pruneBreweryLikes(rawUsername, passStamp);
|
|
91
|
+
await cache.setListState(rawUsername, BREWERY_LIKES_LIST, {
|
|
92
|
+
next_offset: complete ? null : offset,
|
|
93
|
+
total_count: total,
|
|
94
|
+
complete,
|
|
95
|
+
last_synced_at: now(),
|
|
96
|
+
});
|
|
97
|
+
if (complete)
|
|
98
|
+
await cache.setListState(rawUsername, BREWERY_LIKES_PASS_LIST, { complete: true });
|
|
99
|
+
}
|
|
100
|
+
const cached = await cache.breweryLikesCount(rawUsername);
|
|
101
|
+
const percent = total !== null && total > 0 ? Math.min(100, Math.round((cached / total) * 100)) : complete ? 100 : null;
|
|
102
|
+
const progress = `${cached}${total !== null ? `/${total}` : ''}`;
|
|
103
|
+
const note = complete
|
|
104
|
+
? `Liked breweries mirrored (${progress}). They now count as favorites with source "untappd". Re-run to pick up changes — each sync is a fresh pass from the top.`
|
|
105
|
+
: rateStop
|
|
106
|
+
? `Brewery-likes sync paused (pass at offset ${offset}). ${resumeAdvice(rateStop, 'untappd_sync_brewery_likes')}`
|
|
107
|
+
: `Brewery-likes sync in progress (pass at offset ${offset}). Run untappd_sync_brewery_likes again to continue; unlikes apply when the pass finishes.`;
|
|
108
|
+
return {
|
|
109
|
+
username: rawUsername,
|
|
110
|
+
likes_added: added,
|
|
111
|
+
likes_removed: removed,
|
|
112
|
+
pages_fetched: pages,
|
|
113
|
+
cached,
|
|
114
|
+
total,
|
|
115
|
+
percent,
|
|
116
|
+
complete,
|
|
117
|
+
another_run_needed: !complete,
|
|
118
|
+
next_offset: complete ? null : offset,
|
|
119
|
+
stopped_for_rate_limit: rateStop !== undefined,
|
|
120
|
+
...(rateStop ? { rate_limit: rateStop } : {}),
|
|
121
|
+
last_synced_at: now(),
|
|
122
|
+
note,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
2
|
+
import { mapUserVenueItem } from './store.js';
|
|
3
|
+
import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
|
|
4
|
+
/** The `list_sync_state` key for a user's venue history. */
|
|
5
|
+
export const VENUES_LIST = 'venues';
|
|
6
|
+
/**
|
|
7
|
+
* The `list_sync_state` key for an incremental refresh in flight. A row with
|
|
8
|
+
* `complete = false` means a refresh was cut short (max_pages, the rate guard
|
|
9
|
+
* or a 429) before it reached its stop page; `next_offset` is where it resumes.
|
|
10
|
+
* Without it an interrupted refresh would restart at 0, find the pages it had
|
|
11
|
+
* already upserted unchanged, and stop short of the changes further down.
|
|
12
|
+
*/
|
|
13
|
+
export const VENUES_REFRESH_LIST = 'venues_refresh';
|
|
14
|
+
/**
|
|
15
|
+
* What we ASK for per page. Untappd returns at most 25 venues whatever the
|
|
16
|
+
* limit (live, 2026-11), so the cursor advances by the returned
|
|
17
|
+
* `pagination.offset` — never by this number.
|
|
18
|
+
*/
|
|
19
|
+
export const VENUES_PAGE_REQUEST = 50;
|
|
20
|
+
/** The most venues a page has been seen to hold; a shorter page is the last one. */
|
|
21
|
+
const VENUES_SERVER_PAGE_MAX = 25;
|
|
22
|
+
/**
|
|
23
|
+
* A resumed backfill re-reads this many already-fetched venues: if the list
|
|
24
|
+
* shifted up between runs (a venue dropped out), the ones that crossed the
|
|
25
|
+
* stored offset would otherwise be skipped. Upserts are idempotent.
|
|
26
|
+
*/
|
|
27
|
+
export const VENUES_RESUME_OVERLAP = 5;
|
|
28
|
+
/** Hard cap on pages per call, whatever the caller asks. */
|
|
29
|
+
export const VENUES_MAX_PAGES_CAP = 50;
|
|
30
|
+
async function fetchVenuesPage(client, encodedUser, offset) {
|
|
31
|
+
const data = await client.get(`/user/venues/${encodedUser}`, { limit: VENUES_PAGE_REQUEST, offset, sort: 'date' });
|
|
32
|
+
const items = Array.isArray(data?.venues?.items)
|
|
33
|
+
? data.venues.items
|
|
34
|
+
: Array.isArray(data?.items)
|
|
35
|
+
? data.items
|
|
36
|
+
: [];
|
|
37
|
+
const total = typeof data?.total_count === 'number'
|
|
38
|
+
? data.total_count
|
|
39
|
+
: typeof data?.venues?.total_count === 'number'
|
|
40
|
+
? data.venues.total_count
|
|
41
|
+
: null;
|
|
42
|
+
const po = data?.pagination?.offset;
|
|
43
|
+
const nextOffset = typeof po === 'number' ? po : typeof po === 'string' && /^\d+$/.test(po) ? Number(po) : null;
|
|
44
|
+
return { items, total, nextOffset };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Sync a user's complete venue history (user/venues) into `venues` +
|
|
48
|
+
* `user_venues`. Unlike user/checkins, user/venues pages a NON-self account's
|
|
49
|
+
* whole history, so this is how "has this person been here?" gets answered.
|
|
50
|
+
*
|
|
51
|
+
* Resumable backfill: offset-paged (sort=date), the cursor and total persisted
|
|
52
|
+
* after EVERY page, at most `maxPages` (capped at 50) per call, stopping before
|
|
53
|
+
* the hourly rate limit. Once the whole list is cached, a re-run is an
|
|
54
|
+
* incremental refresh: page from offset 0 and stop at the first page on which
|
|
55
|
+
* every venue's recent_visit (and visit count) is unchanged. A refresh cut short
|
|
56
|
+
* before that stop page persists its own cursor and the next run resumes it
|
|
57
|
+
* (the history counts as incomplete meanwhile); only a finished refresh
|
|
58
|
+
* restarts at 0.
|
|
59
|
+
*/
|
|
60
|
+
export async function syncUserVenues(client, cache, rawUsername, maxPages = 10) {
|
|
61
|
+
const pagesBudget = Math.max(1, Math.min(VENUES_MAX_PAGES_CAP, Math.floor(maxPages)));
|
|
62
|
+
const encodedUser = encodeURIComponent(rawUsername);
|
|
63
|
+
const now = () => new Date().toISOString();
|
|
64
|
+
const prior = await cache.getListState(rawUsername, VENUES_LIST);
|
|
65
|
+
const incremental = (prior?.complete ?? false) && !!prior?.last_synced_at;
|
|
66
|
+
const refreshPrior = incremental ? await cache.getListState(rawUsername, VENUES_REFRESH_LIST) : undefined;
|
|
67
|
+
const resumeFrom = (o) => Math.max(0, (o ?? 0) - (o ? VENUES_RESUME_OVERLAP : 0));
|
|
68
|
+
// A finished refresh (or none yet) starts at the top; an unfinished one resumes.
|
|
69
|
+
let offset = incremental
|
|
70
|
+
? refreshPrior && !refreshPrior.complete
|
|
71
|
+
? resumeFrom(refreshPrior.next_offset)
|
|
72
|
+
: 0
|
|
73
|
+
: resumeFrom(prior?.next_offset);
|
|
74
|
+
let total = prior?.total_count ?? null;
|
|
75
|
+
let complete = false; // backfill: the whole list cached; refresh: the stop page (or the end) reached
|
|
76
|
+
let done = false;
|
|
77
|
+
let pages = 0;
|
|
78
|
+
let added = 0;
|
|
79
|
+
let updated = 0;
|
|
80
|
+
let rateStop;
|
|
81
|
+
while (pages < pagesBudget && !done) {
|
|
82
|
+
const verdict = await checkRateBudget(client, cache);
|
|
83
|
+
if (!verdict.ok) {
|
|
84
|
+
rateStop = stopFromVerdict(verdict);
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
let page;
|
|
88
|
+
try {
|
|
89
|
+
page = await fetchVenuesPage(client, encodedUser, offset);
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
if (e instanceof RateLimitError) {
|
|
93
|
+
rateStop = stopFrom429(e);
|
|
94
|
+
break;
|
|
95
|
+
}
|
|
96
|
+
if (pages > 0)
|
|
97
|
+
throw e;
|
|
98
|
+
throw createHelpfulError(`Could not fetch the venue history for "${rawUsername}": ${messageOf(e)}`, {
|
|
99
|
+
hint: "Untappd only returns another user's venues if their account is public or they're your friend. If the account is private, add them as a friend (untappd_add_friend) first.",
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
pages++;
|
|
103
|
+
if (page.total !== null)
|
|
104
|
+
total = page.total;
|
|
105
|
+
const mapped = [];
|
|
106
|
+
for (const it of page.items) {
|
|
107
|
+
const m = mapUserVenueItem(rawUsername, it);
|
|
108
|
+
if (m)
|
|
109
|
+
mapped.push(m);
|
|
110
|
+
}
|
|
111
|
+
// Incremental: a page on which every venue is already cached with the same
|
|
112
|
+
// recent_visit and count is where this refresh meets the last one — the
|
|
113
|
+
// list is newest-visit first, so nothing below it has changed.
|
|
114
|
+
let unchangedPage = false;
|
|
115
|
+
if (incremental && mapped.length > 0) {
|
|
116
|
+
const known = new Map((await cache.getUserVenueVisits(rawUsername, mapped.map((m) => m.visit.venue_id))).map((r) => [r.venue_id, r]));
|
|
117
|
+
unchangedPage = mapped.every((m) => {
|
|
118
|
+
const k = known.get(m.visit.venue_id);
|
|
119
|
+
return k !== undefined && k.recent_visit === m.visit.recent_visit && k.total_count === m.visit.total_count;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
if (!unchangedPage && mapped.length > 0) {
|
|
123
|
+
const r = await cache.upsertUserVenues(rawUsername, mapped);
|
|
124
|
+
added += r.added;
|
|
125
|
+
updated += r.updated;
|
|
126
|
+
}
|
|
127
|
+
// Advance by what Untappd says the next offset is (it serves 25 per page
|
|
128
|
+
// even when asked for 50); fall back to counting the items it sent.
|
|
129
|
+
const advanced = page.nextOffset !== null && page.nextOffset > offset ? page.nextOffset : offset + page.items.length;
|
|
130
|
+
const atEnd = page.items.length === 0 ||
|
|
131
|
+
(total !== null ? advanced >= total : page.items.length < VENUES_SERVER_PAGE_MAX);
|
|
132
|
+
offset = advanced;
|
|
133
|
+
if (unchangedPage || atEnd) {
|
|
134
|
+
done = true;
|
|
135
|
+
complete = true;
|
|
136
|
+
}
|
|
137
|
+
if (incremental) {
|
|
138
|
+
// The backfill row stays complete (its cursor at the end of the list);
|
|
139
|
+
// the refresh's own progress lives in VENUES_REFRESH_LIST.
|
|
140
|
+
await cache.setListState(rawUsername, VENUES_REFRESH_LIST, {
|
|
141
|
+
next_offset: complete ? null : offset,
|
|
142
|
+
total_count: total,
|
|
143
|
+
complete,
|
|
144
|
+
last_synced_at: now(),
|
|
145
|
+
});
|
|
146
|
+
await cache.setListState(rawUsername, VENUES_LIST, {
|
|
147
|
+
next_offset: total ?? offset,
|
|
148
|
+
total_count: total,
|
|
149
|
+
complete: true,
|
|
150
|
+
...(complete ? { last_synced_at: now() } : {}),
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
await cache.setListState(rawUsername, VENUES_LIST, {
|
|
155
|
+
next_offset: complete ? (total ?? offset) : offset,
|
|
156
|
+
total_count: total,
|
|
157
|
+
complete,
|
|
158
|
+
last_synced_at: now(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// A refresh that fetched nothing (rate-stopped before page 1) leaves any
|
|
163
|
+
// earlier unfinished refresh as it was; if there was none, the history is
|
|
164
|
+
// as complete as after the last finished refresh — but this one still has
|
|
165
|
+
// to run.
|
|
166
|
+
const refreshInProgress = incremental && (pages > 0 ? !complete : !!refreshPrior && !refreshPrior.complete);
|
|
167
|
+
const historyComplete = incremental ? !refreshInProgress : complete;
|
|
168
|
+
const cached = await cache.userVenuesCount(rawUsername);
|
|
169
|
+
const percent = total && total > 0 ? Math.min(100, Math.round((cached / total) * 100)) : historyComplete ? 100 : null;
|
|
170
|
+
const anotherRunNeeded = !complete;
|
|
171
|
+
const progress = `${cached}${total !== null ? `/${total}` : ''}`;
|
|
172
|
+
const note = complete
|
|
173
|
+
? incremental
|
|
174
|
+
? `Venue history refreshed (${progress} cached). Re-run occasionally to pick up new visits.`
|
|
175
|
+
: `Full venue history is cached (${progress}) — untappd_cache_has_visited answers are complete for this user.`
|
|
176
|
+
: refreshInProgress
|
|
177
|
+
? `Venue history refresh unfinished (${progress} cached; resumes at offset ${offset}) — re-visited or new venues further down the list may not be cached yet. ${rateStop ? resumeAdvice(rateStop, 'untappd_sync_user_venues') : 'Run untappd_sync_user_venues again to continue the refresh.'}`
|
|
178
|
+
: rateStop
|
|
179
|
+
? `Venue history sync paused (${progress}). ${resumeAdvice(rateStop, 'untappd_sync_user_venues')}`
|
|
180
|
+
: `Venue history sync in progress (${progress}). Run untappd_sync_user_venues again to fetch the next ${pagesBudget} pages.`;
|
|
181
|
+
const state = await cache.getListState(rawUsername, VENUES_LIST);
|
|
182
|
+
return {
|
|
183
|
+
username: rawUsername,
|
|
184
|
+
venues_added: added,
|
|
185
|
+
venues_updated: updated,
|
|
186
|
+
pages_fetched: pages,
|
|
187
|
+
cached_venues: cached,
|
|
188
|
+
total_venues: total,
|
|
189
|
+
backfill_percent: percent,
|
|
190
|
+
venues_complete: historyComplete,
|
|
191
|
+
incremental,
|
|
192
|
+
refresh_in_progress: refreshInProgress,
|
|
193
|
+
another_run_needed: anotherRunNeeded,
|
|
194
|
+
next_offset: refreshInProgress ? offset : (state?.next_offset ?? null),
|
|
195
|
+
stopped_for_rate_limit: rateStop !== undefined,
|
|
196
|
+
...(rateStop ? { rate_limit: rateStop } : {}),
|
|
197
|
+
last_synced_at: now(),
|
|
198
|
+
note,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
2
|
+
import { mapWishlistItem } from './store.js';
|
|
3
|
+
import { checkRateBudget, resumeAdvice, stopFrom429, stopFromVerdict } from '../ratelimit.js';
|
|
4
|
+
/** The `list_sync_state` key for a user's wishlist cursor (literal also in store.ts wishlistCoverage). */
|
|
5
|
+
export const WISHLIST_LIST = 'wishlist';
|
|
6
|
+
/**
|
|
7
|
+
* The `list_sync_state` key whose `last_synced_at` holds the CURRENT pass's
|
|
8
|
+
* stamp. Every row a pass sees is stamped with it, so when the pass reaches the
|
|
9
|
+
* end, rows carrying any other stamp were removed from the wishlist upstream.
|
|
10
|
+
*/
|
|
11
|
+
export const WISHLIST_PASS_LIST = 'wishlist_pass';
|
|
12
|
+
/** What we ask for per page; Untappd may serve fewer, so advance by what it returns. */
|
|
13
|
+
export const WISHLIST_PAGE_REQUEST = 50;
|
|
14
|
+
const WISHLIST_SERVER_PAGE_MAX = 25;
|
|
15
|
+
/** A resumed pass re-reads this many items (the list may have shifted up between runs). */
|
|
16
|
+
export const WISHLIST_RESUME_OVERLAP = 5;
|
|
17
|
+
export const WISHLIST_MAX_PAGES_CAP = 50;
|
|
18
|
+
/**
|
|
19
|
+
* Mirror a user's wishlist (user/wishlist) into the `wishlist` table so pick
|
|
20
|
+
* tools can boost wishlisted beers without an API call. Wishlists change in
|
|
21
|
+
* both directions, so each sync is a full pass from the top (they are small:
|
|
22
|
+
* ~25 beers per call), resumable across runs with the cursor persisted every
|
|
23
|
+
* page and the stop-before-429 guard before every call. Removals are applied
|
|
24
|
+
* only when a pass reaches the end — a partial pass never deletes.
|
|
25
|
+
*/
|
|
26
|
+
export async function syncWishlist(client, cache, rawUsername, maxPages = 10) {
|
|
27
|
+
const pagesBudget = Math.max(1, Math.min(WISHLIST_MAX_PAGES_CAP, Math.floor(maxPages)));
|
|
28
|
+
const encodedUser = encodeURIComponent(rawUsername);
|
|
29
|
+
const now = () => new Date().toISOString();
|
|
30
|
+
const prior = await cache.getListState(rawUsername, WISHLIST_LIST);
|
|
31
|
+
const passRow = await cache.getListState(rawUsername, WISHLIST_PASS_LIST);
|
|
32
|
+
// Resume an unfinished pass; otherwise start a new one from the top, with a
|
|
33
|
+
// stamp strictly later than the previous pass's so old rows never share it.
|
|
34
|
+
const resuming = !!prior && !prior.complete && prior.next_offset !== null && !!passRow?.last_synced_at;
|
|
35
|
+
let passStamp;
|
|
36
|
+
let offset;
|
|
37
|
+
if (resuming) {
|
|
38
|
+
passStamp = passRow.last_synced_at;
|
|
39
|
+
offset = Math.max(0, prior.next_offset - WISHLIST_RESUME_OVERLAP);
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
const prev = passRow?.last_synced_at ? Date.parse(passRow.last_synced_at) : 0;
|
|
43
|
+
passStamp = new Date(Math.max(Date.now(), prev + 1)).toISOString();
|
|
44
|
+
offset = 0;
|
|
45
|
+
await cache.setListState(rawUsername, WISHLIST_PASS_LIST, { last_synced_at: passStamp, complete: false, next_offset: null });
|
|
46
|
+
}
|
|
47
|
+
let total = prior?.total_count ?? null;
|
|
48
|
+
let pages = 0;
|
|
49
|
+
let added = 0;
|
|
50
|
+
let removed = 0;
|
|
51
|
+
let complete = false;
|
|
52
|
+
let rateStop;
|
|
53
|
+
while (pages < pagesBudget && !complete) {
|
|
54
|
+
const verdict = await checkRateBudget(client, cache);
|
|
55
|
+
if (!verdict.ok) {
|
|
56
|
+
rateStop = stopFromVerdict(verdict);
|
|
57
|
+
break;
|
|
58
|
+
}
|
|
59
|
+
let data;
|
|
60
|
+
try {
|
|
61
|
+
data = await client.get(`/user/wishlist/${encodedUser}`, { limit: WISHLIST_PAGE_REQUEST, offset, sort: 'date' });
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
if (e instanceof RateLimitError) {
|
|
65
|
+
rateStop = stopFrom429(e);
|
|
66
|
+
break;
|
|
67
|
+
}
|
|
68
|
+
if (pages > 0)
|
|
69
|
+
throw e;
|
|
70
|
+
throw createHelpfulError(`Could not fetch the wishlist for "${rawUsername}": ${messageOf(e)}`, {
|
|
71
|
+
hint: "Untappd only returns another user's wishlist if their account is public or they're your friend.",
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
pages++;
|
|
75
|
+
const items = Array.isArray(data?.beers?.items) ? data.beers.items : [];
|
|
76
|
+
if (typeof data?.total_count === 'number')
|
|
77
|
+
total = data.total_count;
|
|
78
|
+
else if (typeof data?.beers?.total_count === 'number')
|
|
79
|
+
total = data.beers.total_count;
|
|
80
|
+
const rows = [];
|
|
81
|
+
for (const it of items) {
|
|
82
|
+
const r = mapWishlistItem(it);
|
|
83
|
+
if (r)
|
|
84
|
+
rows.push(r);
|
|
85
|
+
}
|
|
86
|
+
added += await cache.upsertWishlist(rawUsername, rows, passStamp);
|
|
87
|
+
const po = data?.pagination?.offset;
|
|
88
|
+
const next = typeof po === 'number' ? po : typeof po === 'string' && /^\d+$/.test(po) ? Number(po) : null;
|
|
89
|
+
const advanced = next !== null && next > offset ? next : offset + items.length;
|
|
90
|
+
complete =
|
|
91
|
+
items.length === 0 || (total !== null ? advanced >= total : items.length < WISHLIST_SERVER_PAGE_MAX);
|
|
92
|
+
offset = advanced;
|
|
93
|
+
if (complete)
|
|
94
|
+
removed = await cache.pruneWishlist(rawUsername, passStamp);
|
|
95
|
+
await cache.setListState(rawUsername, WISHLIST_LIST, {
|
|
96
|
+
next_offset: complete ? null : offset,
|
|
97
|
+
total_count: total,
|
|
98
|
+
complete,
|
|
99
|
+
last_synced_at: now(),
|
|
100
|
+
});
|
|
101
|
+
if (complete)
|
|
102
|
+
await cache.setListState(rawUsername, WISHLIST_PASS_LIST, { complete: true });
|
|
103
|
+
}
|
|
104
|
+
const cached = await cache.wishlistCount(rawUsername);
|
|
105
|
+
const progress = `${cached}${total !== null ? `/${total}` : ''}`;
|
|
106
|
+
const note = complete
|
|
107
|
+
? `Wishlist mirrored (${progress} beers). Re-run to pick up changes — each sync is a fresh pass from the top.`
|
|
108
|
+
: rateStop
|
|
109
|
+
? `Wishlist sync paused (pass at offset ${offset}). ${resumeAdvice(rateStop, 'untappd_sync_wishlist')}`
|
|
110
|
+
: `Wishlist sync in progress (pass at offset ${offset}). Run untappd_sync_wishlist again to continue; removals apply when the pass finishes.`;
|
|
111
|
+
return {
|
|
112
|
+
username: rawUsername,
|
|
113
|
+
wishlist_added: added,
|
|
114
|
+
wishlist_removed: removed,
|
|
115
|
+
pages_fetched: pages,
|
|
116
|
+
cached,
|
|
117
|
+
total,
|
|
118
|
+
complete,
|
|
119
|
+
another_run_needed: !complete,
|
|
120
|
+
next_offset: complete ? null : offset,
|
|
121
|
+
stopped_for_rate_limit: rateStop !== undefined,
|
|
122
|
+
...(rateStop ? { rate_limit: rateStop } : {}),
|
|
123
|
+
last_synced_at: now(),
|
|
124
|
+
note,
|
|
125
|
+
};
|
|
126
|
+
}
|
package/dist/cache/sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
292
|
-
?
|
|
293
|
-
:
|
|
294
|
-
? '
|
|
295
|
-
:
|
|
296
|
-
? '
|
|
297
|
-
:
|
|
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
|
};
|