untappd-mcp 2.1.2 → 2.2.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 +18 -2
- package/dist/bundle.js +861 -189
- package/dist/cache/store.js +14 -4
- package/dist/cache/sync-beers.js +54 -25
- package/dist/tools/checkin.js +302 -84
- package/dist/tools/confirm.js +20 -0
- package/dist/tools/friends.js +19 -15
- package/dist/tools/wishlist.js +32 -25
- package/dist/version.js +1 -1
- package/mint.yaml +9 -0
- package/package.json +2 -2
- package/server.json +2 -2
- package/skills/untappd-mcp/SKILL.md +10 -5
package/dist/cache/store.js
CHANGED
|
@@ -175,7 +175,9 @@ export const SCHEMA_STATEMENTS = [
|
|
|
175
175
|
beers_offset INTEGER,
|
|
176
176
|
beers_total INTEGER,
|
|
177
177
|
beers_complete INTEGER NOT NULL DEFAULT 0,
|
|
178
|
-
served_backfill_last INTEGER NOT NULL DEFAULT 0
|
|
178
|
+
served_backfill_last INTEGER NOT NULL DEFAULT 0,
|
|
179
|
+
beers_accepted_gap INTEGER NOT NULL DEFAULT 0,
|
|
180
|
+
beers_rescan_target INTEGER
|
|
179
181
|
)`,
|
|
180
182
|
];
|
|
181
183
|
/** Columns added after the initial release, each applied idempotently on open. */
|
|
@@ -186,6 +188,8 @@ export const MIGRATIONS = [
|
|
|
186
188
|
'ALTER TABLE sync_state ADD COLUMN beers_total INTEGER',
|
|
187
189
|
'ALTER TABLE sync_state ADD COLUMN beers_complete INTEGER NOT NULL DEFAULT 0',
|
|
188
190
|
'ALTER TABLE sync_state ADD COLUMN served_backfill_last INTEGER NOT NULL DEFAULT 0',
|
|
191
|
+
'ALTER TABLE sync_state ADD COLUMN beers_accepted_gap INTEGER NOT NULL DEFAULT 0',
|
|
192
|
+
'ALTER TABLE sync_state ADD COLUMN beers_rescan_target INTEGER',
|
|
189
193
|
];
|
|
190
194
|
/**
|
|
191
195
|
* The cache logic over a synchronous {@link SqlDriver}. Usernames are keyed
|
|
@@ -298,6 +302,8 @@ export class CheckinStoreCore {
|
|
|
298
302
|
beers_total: num(row.beers_total),
|
|
299
303
|
beers_complete: Number(row.beers_complete) === 1,
|
|
300
304
|
served_backfill_last: Number(row.served_backfill_last) === 1,
|
|
305
|
+
beers_accepted_gap: num(row.beers_accepted_gap) ?? 0,
|
|
306
|
+
beers_rescan_target: num(row.beers_rescan_target),
|
|
301
307
|
};
|
|
302
308
|
}
|
|
303
309
|
/**
|
|
@@ -322,20 +328,24 @@ export class CheckinStoreCore {
|
|
|
322
328
|
beers_total: pick('beers_total', null),
|
|
323
329
|
beers_complete: pick('beers_complete', false),
|
|
324
330
|
served_backfill_last: pick('served_backfill_last', false),
|
|
331
|
+
beers_accepted_gap: pick('beers_accepted_gap', 0),
|
|
332
|
+
beers_rescan_target: pick('beers_rescan_target', null),
|
|
325
333
|
};
|
|
326
334
|
this.db.run(`INSERT INTO sync_state
|
|
327
|
-
(username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins, checkins_truncated, beers_offset, beers_total, beers_complete, served_backfill_last)
|
|
328
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
335
|
+
(username, oldest_max_id, newest_checkin_id, catchup_max_id, last_synced_at, backfill_complete, total_checkins, checkins_truncated, beers_offset, beers_total, beers_complete, served_backfill_last, beers_accepted_gap, beers_rescan_target)
|
|
336
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
329
337
|
ON CONFLICT(username) DO UPDATE SET
|
|
330
338
|
oldest_max_id=excluded.oldest_max_id, newest_checkin_id=excluded.newest_checkin_id,
|
|
331
339
|
catchup_max_id=excluded.catchup_max_id, last_synced_at=excluded.last_synced_at,
|
|
332
340
|
backfill_complete=excluded.backfill_complete, total_checkins=excluded.total_checkins,
|
|
333
341
|
checkins_truncated=excluded.checkins_truncated, beers_offset=excluded.beers_offset,
|
|
334
342
|
beers_total=excluded.beers_total, beers_complete=excluded.beers_complete,
|
|
335
|
-
served_backfill_last=excluded.served_backfill_last
|
|
343
|
+
served_backfill_last=excluded.served_backfill_last, beers_accepted_gap=excluded.beers_accepted_gap,
|
|
344
|
+
beers_rescan_target=excluded.beers_rescan_target`, [
|
|
336
345
|
key, next.oldest_max_id, next.newest_checkin_id, next.catchup_max_id, next.last_synced_at,
|
|
337
346
|
next.backfill_complete ? 1 : 0, next.total_checkins, next.checkins_truncated ? 1 : 0,
|
|
338
347
|
next.beers_offset, next.beers_total, next.beers_complete ? 1 : 0, next.served_backfill_last ? 1 : 0,
|
|
348
|
+
next.beers_accepted_gap, next.beers_rescan_target,
|
|
339
349
|
]);
|
|
340
350
|
}
|
|
341
351
|
/**
|
package/dist/cache/sync-beers.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { createHelpfulError, RateLimitError, messageOf } from '@chrischall/mcp-utils';
|
|
2
2
|
import { mapBeerRow } from './store.js';
|
|
3
3
|
const PAGE_LIMIT = 50; // Untappd's max page size for /user/beers.
|
|
4
|
+
// A resumed backfill re-reads this many already-fetched items: if beers dropped
|
|
5
|
+
// out of the list between runs, everything below shifted UP past the stored
|
|
6
|
+
// offset, and without the overlap the beers that crossed it would be skipped.
|
|
7
|
+
const RESUME_OVERLAP = 10;
|
|
4
8
|
/** One `/user/beers` page fetch (offset-paged), tolerant of the fat shape. */
|
|
5
9
|
async function fetchBeersPage(client, encodedUser, offset) {
|
|
6
10
|
const data = await client.get(`/user/beers/${encodedUser}`, { limit: PAGE_LIMIT, offset, sort: 'date' });
|
|
@@ -35,8 +39,15 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
|
|
|
35
39
|
// An already-complete cache re-scans from the top to pick up newly-had beers,
|
|
36
40
|
// stopping on the first page with nothing new. Otherwise resume the backfill.
|
|
37
41
|
const incremental = (prior?.beers_complete ?? false) && !!prior?.last_synced_at;
|
|
38
|
-
|
|
42
|
+
// `refreshing` is the live mode: it starts as `incremental` and is cleared when
|
|
43
|
+
// a shortfall forces a rescan (which must page past "nothing new" pages).
|
|
44
|
+
let refreshing = incremental;
|
|
45
|
+
let offset = incremental ? 0 : Math.max(0, (prior?.beers_offset ?? 0) - RESUME_OVERLAP);
|
|
39
46
|
let total = prior?.beers_total ?? null;
|
|
47
|
+
let acceptedGap = prior?.beers_accepted_gap ?? 0;
|
|
48
|
+
// Non-null while a coverage rescan is walking down from the top (possibly
|
|
49
|
+
// across runs): it stops as soon as the shortfall is back within this target.
|
|
50
|
+
let rescanTarget = incremental ? null : (prior?.beers_rescan_target ?? null);
|
|
40
51
|
let complete = false;
|
|
41
52
|
let pages = 0;
|
|
42
53
|
let added = 0;
|
|
@@ -52,42 +63,60 @@ export async function syncUserBeers(client, cache, rawUsername, maxPages = 10) {
|
|
|
52
63
|
});
|
|
53
64
|
}
|
|
54
65
|
};
|
|
66
|
+
const gapNow = async () => total === null ? 0 : total - (await cache.distinctBeersCount(rawUsername));
|
|
55
67
|
for (; pages < maxPages && !complete;) {
|
|
56
68
|
const page = pages === 0 ? await firstFetch(offset) : await fetchBeersPage(client, encodedUser, offset);
|
|
57
69
|
pages++;
|
|
58
70
|
if (page.total !== null)
|
|
59
71
|
total = page.total;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
await cache.
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
// An empty (e.g. first) page still means the list is fully paged.
|
|
73
|
+
let atEnd = page.items.length === 0;
|
|
74
|
+
if (!atEnd) {
|
|
75
|
+
const rows = rowsOf(rawUsername, page.items);
|
|
76
|
+
const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
|
|
77
|
+
added += netNew;
|
|
78
|
+
offset += page.items.length;
|
|
79
|
+
// End conditions: caught the whole list, a short (final) page, or — during an
|
|
80
|
+
// incremental top refresh — a page that added nothing new.
|
|
81
|
+
if (total !== null && offset >= total)
|
|
82
|
+
atEnd = true;
|
|
83
|
+
else if (page.items.length < PAGE_LIMIT)
|
|
84
|
+
atEnd = true;
|
|
85
|
+
else if (refreshing && netNew === 0)
|
|
86
|
+
atEnd = true;
|
|
87
|
+
else if (rescanTarget !== null && (await gapNow()) <= rescanTarget)
|
|
88
|
+
atEnd = true;
|
|
89
|
+
}
|
|
90
|
+
if (atEnd) {
|
|
91
|
+
// Offset paging across runs is not a snapshot: when the list churns between
|
|
92
|
+
// runs (a beer drops out, or is re-sorted) a beer can slip behind the resumed
|
|
93
|
+
// offset and never be fetched. Reaching the end is therefore only "complete"
|
|
94
|
+
// if the cache actually holds total_count beers — less whatever shortfall a
|
|
95
|
+
// previous full pass already proved the list can never close (a count Untappd
|
|
96
|
+
// reports but never lists). A NEW shortfall means skipped beers: rescan from
|
|
97
|
+
// the top (upserts are idempotent) instead of reporting false coverage.
|
|
98
|
+
const gap = await gapNow();
|
|
99
|
+
if (gap > acceptedGap && rescanTarget === null) {
|
|
100
|
+
rescanTarget = acceptedGap;
|
|
101
|
+
acceptedGap = gap; // a rescan that finds nothing new accepts this gap
|
|
102
|
+
refreshing = false;
|
|
103
|
+
offset = 0;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
complete = true;
|
|
107
|
+
rescanTarget = null;
|
|
108
|
+
acceptedGap = Math.max(0, gap);
|
|
109
|
+
}
|
|
71
110
|
}
|
|
72
|
-
const rows = rowsOf(rawUsername, page.items);
|
|
73
|
-
const netNew = await cache.upsertDistinctBeers(rawUsername, rows);
|
|
74
|
-
added += netNew;
|
|
75
|
-
offset += page.items.length;
|
|
76
|
-
// End conditions: caught the whole list, a short (final) page, or — during an
|
|
77
|
-
// incremental top refresh — a page that added nothing new.
|
|
78
|
-
if (total !== null && offset >= total)
|
|
79
|
-
complete = true;
|
|
80
|
-
else if (page.items.length < PAGE_LIMIT)
|
|
81
|
-
complete = true;
|
|
82
|
-
else if (incremental && netNew === 0)
|
|
83
|
-
complete = true;
|
|
84
111
|
// Persist progress after EVERY page. During an incremental refresh the stored
|
|
85
112
|
// offset stays at the completed total so the next run refreshes from the top
|
|
86
113
|
// again rather than resuming a stale cursor.
|
|
87
114
|
await cache.setState(rawUsername, {
|
|
88
|
-
beers_offset:
|
|
115
|
+
beers_offset: refreshing ? (total ?? offset) : offset,
|
|
89
116
|
beers_total: total,
|
|
90
117
|
beers_complete: complete,
|
|
118
|
+
beers_accepted_gap: acceptedGap,
|
|
119
|
+
beers_rescan_target: rescanTarget,
|
|
91
120
|
last_synced_at: now(),
|
|
92
121
|
});
|
|
93
122
|
}
|