foilstack 0.2.0__py3-none-any.whl
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.
- foilstack/__init__.py +1 -0
- foilstack/cli.py +634 -0
- foilstack/config.py +149 -0
- foilstack/db.py +471 -0
- foilstack/embedder/__init__.py +0 -0
- foilstack/embedder/app.py +122 -0
- foilstack/embedding.py +36 -0
- foilstack/images.py +72 -0
- foilstack/importing.py +385 -0
- foilstack/inventory.py +439 -0
- foilstack/migrations/README +11 -0
- foilstack/migrations/env.py +93 -0
- foilstack/migrations/script.py.mako +24 -0
- foilstack/migrations/versions/2026_08_22_2105-04a130322022_pgvector_extension.py +33 -0
- foilstack/migrations/versions/2026_08_22_2105-327c4a89b5f1_baseline_schema.py +195 -0
- foilstack/migrations/versions/2026_08_22_2106-ece6f4eba34c_card_embedding_hnsw_index.py +54 -0
- foilstack/migrations/versions/2026_08_22_2139-a6e3135d319e_scan_best_score.py +27 -0
- foilstack/migrations/versions/2026_08_22_2213-fd1c94a2fc8f_inventory_finish_sold_state_and_notes.py +65 -0
- foilstack/migrations/versions/2026_08_22_2229-ab360b649e52_drop_inventory_quantity.py +41 -0
- foilstack/migrations/versions/2026_08_22_2326-36ee08bea9f1_card_prices_price_history_and_sync_state.py +71 -0
- foilstack/migrations/versions/2026_08_22_2349-d46268462eb9_declared_printing_on_inventory.py +27 -0
- foilstack/migrations/versions/2026_08_23_0918-6842dd86917f_scan_size_in_bytes.py +37 -0
- foilstack/migrations/versions/2026_08_23_1400-b91c4e2f7a10_card_name_trigram_index.py +37 -0
- foilstack/migrations/versions/2026_08_23_1520-c73f8a1d4b62_scan_chosen_card.py +35 -0
- foilstack/migrations/versions/2026_08_24_0210-d18b3f9c5e41_remember_missing_card_images.py +27 -0
- foilstack/plugins/__init__.py +45 -0
- foilstack/plugins/base.py +104 -0
- foilstack/plugins/exports/ebay.toml +31 -0
- foilstack/plugins/exports/tcgplayer.toml +44 -0
- foilstack/plugins/exports.py +95 -0
- foilstack/plugins/sources/__init__.py +0 -0
- foilstack/plugins/sources/tcgcsv.py +265 -0
- foilstack/prices.py +162 -0
- foilstack/search.py +144 -0
- foilstack/web/app.py +235 -0
- foilstack/web/auth.py +209 -0
- foilstack/web/chrome.py +178 -0
- foilstack/web/deps.py +72 -0
- foilstack/web/joblog.py +51 -0
- foilstack/web/proof.py +54 -0
- foilstack/web/ratelimit.py +103 -0
- foilstack/web/routes/__init__.py +18 -0
- foilstack/web/routes/accounts.py +227 -0
- foilstack/web/routes/inventory.py +384 -0
- foilstack/web/routes/listings.py +216 -0
- foilstack/web/routes/media.py +181 -0
- foilstack/web/routes/scans.py +527 -0
- foilstack/web/static/app.css +1165 -0
- foilstack/web/static/brand/apple-touch-icon.png +0 -0
- foilstack/web/static/brand/favicon-32.png +0 -0
- foilstack/web/static/brand/favicon.svg +15 -0
- foilstack/web/static/brand/mark.svg +16 -0
- foilstack/web/static/demo/foilstack.gif +0 -0
- foilstack/web/static/demo/foilstack.webp +0 -0
- foilstack/web/static/fonts/OFL.txt +93 -0
- foilstack/web/static/fonts/README.md +15 -0
- foilstack/web/static/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- foilstack/web/static/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- foilstack/web/static/fonts/jetbrains-mono-greek.woff2 +0 -0
- foilstack/web/static/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- foilstack/web/static/fonts/jetbrains-mono-latin.woff2 +0 -0
- foilstack/web/static/fonts/jetbrains-mono-vietnamese.woff2 +0 -0
- foilstack/web/static/zoom.js +93 -0
- foilstack/web/templates/_card_panel.html +141 -0
- foilstack/web/templates/_match_options.html +17 -0
- foilstack/web/templates/_match_panel.html +28 -0
- foilstack/web/templates/analytics.html +126 -0
- foilstack/web/templates/base.html +91 -0
- foilstack/web/templates/card.html +295 -0
- foilstack/web/templates/import.html +484 -0
- foilstack/web/templates/inventory.html +177 -0
- foilstack/web/templates/landing.html +208 -0
- foilstack/web/templates/listings.html +124 -0
- foilstack/web/templates/login.html +78 -0
- foilstack/web/templates/plugins.html +90 -0
- foilstack-0.2.0.dist-info/METADATA +241 -0
- foilstack-0.2.0.dist-info/RECORD +80 -0
- foilstack-0.2.0.dist-info/WHEEL +4 -0
- foilstack-0.2.0.dist-info/entry_points.txt +2 -0
- foilstack-0.2.0.dist-info/licenses/LICENSE +661 -0
foilstack/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.2.0"
|
foilstack/cli.py
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
"""Command line: ingest a catalogue, then encode it.
|
|
2
|
+
|
|
3
|
+
Two commands and not one, because they fail for different reasons and take
|
|
4
|
+
wildly different amounts of time. Ingest is a few minutes of HTTP against the
|
|
5
|
+
upstream source. Encoding downloads every reference image and pushes it through
|
|
6
|
+
the model, which is the long pole. Separating them means a network blip in hour
|
|
7
|
+
two does not cost you hour one.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import argparse
|
|
13
|
+
import asyncio
|
|
14
|
+
import datetime as dt
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from sqlalchemy import func, select, text
|
|
22
|
+
|
|
23
|
+
from foilstack import __version__, db
|
|
24
|
+
from foilstack.config import get_settings
|
|
25
|
+
from foilstack.embedding import embed_image
|
|
26
|
+
from foilstack.plugins import export_plugins, source_plugins
|
|
27
|
+
|
|
28
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
29
|
+
# Distinguishes "upstream has no image for this card", which is ordinary and
|
|
30
|
+
# permanent, from "we could not get it", which is worth another go later.
|
|
31
|
+
MISSING = object()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def image_is_permanently_missing(status_code: int, body: bytes) -> bool:
|
|
35
|
+
"""Whether upstream has told us, for good, that there is no image here.
|
|
36
|
+
|
|
37
|
+
Two ways it says so. A 4xx is the obvious one. The other is a 200 carrying
|
|
38
|
+
an empty body — `image/jpeg`, zero bytes — which a handful of products
|
|
39
|
+
answer with and which is just as permanent, but arrives looking like
|
|
40
|
+
success. Untreated it reached the encoder, failed to decode, and was
|
|
41
|
+
retried four times with backoff on every run, forever.
|
|
42
|
+
|
|
43
|
+
429 is excluded even though it is a 4xx. The caller handles it before ever
|
|
44
|
+
reaching here, so in practice it never arrives — but "too many requests" is
|
|
45
|
+
the opposite of a permanent answer, and a policy function that is only
|
|
46
|
+
correct because of what its one caller happens to do first is a trap for
|
|
47
|
+
the second caller.
|
|
48
|
+
|
|
49
|
+
Deliberately not a method on anything: it is the whole retry policy for
|
|
50
|
+
catalogue images in two lines, and it is worth being able to read and test
|
|
51
|
+
it without a network.
|
|
52
|
+
"""
|
|
53
|
+
if status_code == 429:
|
|
54
|
+
return False
|
|
55
|
+
return (400 <= status_code < 500) or (status_code == 200 and not body)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
IMAGE_HEADERS = {
|
|
59
|
+
"User-Agent": f"foilstack/{__version__} (+https://github.com/foilstack/foilstack)",
|
|
60
|
+
"Accept": "image/*,*/*",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
log = logging.getLogger("foilstack")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def cmd_ingest(args) -> int:
|
|
67
|
+
settings = get_settings()
|
|
68
|
+
db.init(settings.database_url)
|
|
69
|
+
plugins = source_plugins()
|
|
70
|
+
plugin = plugins.get(args.source)
|
|
71
|
+
if plugin is None:
|
|
72
|
+
log.error("unknown source %r; available: %s", args.source, sorted(plugins))
|
|
73
|
+
return 2
|
|
74
|
+
|
|
75
|
+
if args.game or getattr(args, "set", None):
|
|
76
|
+
plugin = type(plugin)(game=args.game or plugin.game, set_code=getattr(args, "set", None))
|
|
77
|
+
|
|
78
|
+
session = db.session()
|
|
79
|
+
seen = 0
|
|
80
|
+
async for record in plugin.fetch(limit=args.limit):
|
|
81
|
+
namespaced = f"{plugin.name}:{record.source_id}"
|
|
82
|
+
existing = session.scalar(select(db.Card).where(db.Card.source_id == namespaced))
|
|
83
|
+
if existing is None:
|
|
84
|
+
session.add(
|
|
85
|
+
db.Card(
|
|
86
|
+
source=plugin.name,
|
|
87
|
+
source_id=namespaced,
|
|
88
|
+
name=record.name,
|
|
89
|
+
game=record.game,
|
|
90
|
+
set_name=record.set_name,
|
|
91
|
+
number=record.number,
|
|
92
|
+
variant=record.variant,
|
|
93
|
+
image_url=record.image_url,
|
|
94
|
+
market=record.market,
|
|
95
|
+
currency=record.currency,
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
else:
|
|
99
|
+
existing.market = record.market
|
|
100
|
+
existing.image_url = record.image_url
|
|
101
|
+
seen += 1
|
|
102
|
+
if seen % 250 == 0:
|
|
103
|
+
session.commit()
|
|
104
|
+
log.info(" %s cards", seen)
|
|
105
|
+
session.commit()
|
|
106
|
+
log.info("ingested %s cards from %s", seen, plugin.name)
|
|
107
|
+
return 0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
async def cmd_embed(args) -> int:
|
|
111
|
+
"""Encode every catalogue image that this encoder has not already done.
|
|
112
|
+
|
|
113
|
+
Resumable by default, which matters because this is the long pole: on a
|
|
114
|
+
real catalogue it is hours, and a network blip an hour in should cost the
|
|
115
|
+
blip, not the hour. `--all` forces a re-encode, which is what a model
|
|
116
|
+
change requires — vectors from two encoders are not comparable.
|
|
117
|
+
"""
|
|
118
|
+
settings = get_settings()
|
|
119
|
+
db.init(settings.database_url)
|
|
120
|
+
session = db.session()
|
|
121
|
+
|
|
122
|
+
query = select(db.Card).where(db.Card.image_url.is_not(None))
|
|
123
|
+
if not args.retry_missing:
|
|
124
|
+
# Cards upstream has already said it has no image for. Skipped by
|
|
125
|
+
# default because that answer does not change: re-asking costs a
|
|
126
|
+
# request per card per run and returns the same 404.
|
|
127
|
+
query = query.where(db.Card.image_missing_at.is_(None))
|
|
128
|
+
if not args.all:
|
|
129
|
+
done = select(db.CardEmbedding.card_id).where(
|
|
130
|
+
db.CardEmbedding.model == settings.embed_model
|
|
131
|
+
)
|
|
132
|
+
query = query.where(db.Card.id.not_in(done))
|
|
133
|
+
cards = session.scalars(query.order_by(db.Card.id)).all()
|
|
134
|
+
if args.limit:
|
|
135
|
+
cards = cards[: args.limit]
|
|
136
|
+
if not cards:
|
|
137
|
+
total = session.scalar(select(func.count(db.Card.id))) or 0
|
|
138
|
+
if total == 0:
|
|
139
|
+
log.error("no cards with images — run `ingest` first")
|
|
140
|
+
return 2
|
|
141
|
+
skipped = (
|
|
142
|
+
session.scalar(
|
|
143
|
+
select(func.count(db.Card.id)).where(db.Card.image_missing_at.is_not(None))
|
|
144
|
+
)
|
|
145
|
+
or 0
|
|
146
|
+
)
|
|
147
|
+
log.info("nothing to do — all %s cards are encoded with %s", total, settings.embed_model)
|
|
148
|
+
if skipped:
|
|
149
|
+
# Named rather than left as a silent gap between the catalogue
|
|
150
|
+
# count and the vector count, which otherwise looks like a
|
|
151
|
+
# half-finished encode that never finishes.
|
|
152
|
+
log.info(" (%s have no image upstream and are skipped)", skipped)
|
|
153
|
+
return 0
|
|
154
|
+
|
|
155
|
+
log.info(
|
|
156
|
+
"encoding %s cards with %s (%s at a time)",
|
|
157
|
+
len(cards),
|
|
158
|
+
settings.embed_model,
|
|
159
|
+
args.concurrency,
|
|
160
|
+
)
|
|
161
|
+
written = failed = missing = processed = 0
|
|
162
|
+
# Bounded, not unbounded. The reference images come from a CDN rather than
|
|
163
|
+
# from the catalogue API, so the pacing that applies to `sync-prices` is not
|
|
164
|
+
# the constraint here — but "not the constraint" is not a licence to open a
|
|
165
|
+
# thousand sockets at somebody else's expense. A small pool overlaps the
|
|
166
|
+
# download of the next card with the encoding of the current one, which is
|
|
167
|
+
# where nearly all of the wall clock goes.
|
|
168
|
+
limiter = asyncio.Semaphore(max(1, args.concurrency))
|
|
169
|
+
|
|
170
|
+
async def fetch_and_encode(client, card):
|
|
171
|
+
"""One card, with the manners a shared CDN deserves.
|
|
172
|
+
|
|
173
|
+
Retries what might work and gives up immediately on what will not. A
|
|
174
|
+
catalogue this size is full of promo and staff entries that upstream
|
|
175
|
+
has no image for at all — those answer 403 or 404, forever, and
|
|
176
|
+
retrying one four times with backoff costs seven seconds to learn
|
|
177
|
+
nothing. Thousands of them turned a run that should take an hour into
|
|
178
|
+
one that had not finished a tenth of it.
|
|
179
|
+
|
|
180
|
+
"Will not work" includes a 200 carrying an empty body, which is the
|
|
181
|
+
same permanent answer dressed as success.
|
|
182
|
+
"""
|
|
183
|
+
async with limiter:
|
|
184
|
+
for attempt in range(4):
|
|
185
|
+
try:
|
|
186
|
+
image = await client.get(card.image_url, headers=IMAGE_HEADERS)
|
|
187
|
+
if image.status_code in (429, 502, 503, 504):
|
|
188
|
+
# Honour Retry-After when they send one; they know
|
|
189
|
+
# better than a guess does.
|
|
190
|
+
wait = float(image.headers.get("Retry-After") or 2**attempt)
|
|
191
|
+
log.warning(
|
|
192
|
+
" %s on %s — waiting %.0fs", image.status_code, card.name, wait
|
|
193
|
+
)
|
|
194
|
+
await asyncio.sleep(min(wait, 60))
|
|
195
|
+
continue
|
|
196
|
+
if image_is_permanently_missing(image.status_code, image.content):
|
|
197
|
+
# Not a warning: on this catalogue it is thousands of
|
|
198
|
+
# lines saying the same ordinary thing, and the count
|
|
199
|
+
# at the end says it once.
|
|
200
|
+
log.debug(" no image upstream for %s (%s)", card.name, image.status_code)
|
|
201
|
+
return card.id, MISSING
|
|
202
|
+
image.raise_for_status()
|
|
203
|
+
vector = await embed_image(settings.embedder_url, image.content)
|
|
204
|
+
except Exception as exc: # noqa: BLE001 - one bad image must not end the run
|
|
205
|
+
if attempt == 3:
|
|
206
|
+
log.warning(" skip %s (%s)", card.name, type(exc).__name__)
|
|
207
|
+
return card.id, None
|
|
208
|
+
await asyncio.sleep(2**attempt)
|
|
209
|
+
continue
|
|
210
|
+
return card.id, vector
|
|
211
|
+
return card.id, None
|
|
212
|
+
|
|
213
|
+
async with httpx.AsyncClient(timeout=60.0, follow_redirects=True) as client:
|
|
214
|
+
pending = [asyncio.create_task(fetch_and_encode(client, c)) for c in cards]
|
|
215
|
+
for coro in asyncio.as_completed(pending):
|
|
216
|
+
card_id, vector = await coro
|
|
217
|
+
processed += 1
|
|
218
|
+
if vector is MISSING:
|
|
219
|
+
missing += 1
|
|
220
|
+
# Recorded, not just counted. This is the whole point of the
|
|
221
|
+
# sentinel: a permanent answer is worth keeping.
|
|
222
|
+
card = session.get(db.Card, card_id)
|
|
223
|
+
if card is not None:
|
|
224
|
+
card.image_missing_at = dt.datetime.now(dt.UTC)
|
|
225
|
+
elif vector is None:
|
|
226
|
+
failed += 1
|
|
227
|
+
else:
|
|
228
|
+
session.merge(
|
|
229
|
+
db.CardEmbedding(
|
|
230
|
+
card_id=card_id,
|
|
231
|
+
embedding=[float(x) for x in vector],
|
|
232
|
+
model=settings.embed_model,
|
|
233
|
+
)
|
|
234
|
+
)
|
|
235
|
+
written += 1
|
|
236
|
+
# Committed in batches so an interrupted run keeps its work: the
|
|
237
|
+
# point of resumability is lost if everything lives in one
|
|
238
|
+
# transaction that a Ctrl-C rolls back.
|
|
239
|
+
if processed % 100 == 0:
|
|
240
|
+
session.commit()
|
|
241
|
+
log.info(
|
|
242
|
+
" encoded %s/%s (%s no image, %s failed)",
|
|
243
|
+
processed,
|
|
244
|
+
len(cards),
|
|
245
|
+
missing,
|
|
246
|
+
failed,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
session.commit()
|
|
250
|
+
log.info("wrote %s vectors (%s have no image upstream, %s failed)", written, missing, failed)
|
|
251
|
+
if missing:
|
|
252
|
+
log.info(" those %s will be skipped from now on — `--retry-missing` to ask again", missing)
|
|
253
|
+
# A batch where every remaining card simply has no image upstream is a
|
|
254
|
+
# finished job, not a failed one — and on a resumed run over a large
|
|
255
|
+
# catalogue that is exactly what the last batch looks like.
|
|
256
|
+
return 0 if (written or missing) else 1
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
async def cmd_sets(args) -> int:
|
|
260
|
+
"""List the sets a source can fetch, so `--set` is a choice not a guess."""
|
|
261
|
+
plugins = source_plugins()
|
|
262
|
+
plugin = plugins.get(args.source)
|
|
263
|
+
if plugin is None:
|
|
264
|
+
log.error("unknown source %r; available: %s", args.source, sorted(plugins))
|
|
265
|
+
return 2
|
|
266
|
+
plugin = type(plugin)(game=args.game)
|
|
267
|
+
if not hasattr(plugin, "sets"):
|
|
268
|
+
log.error("%s does not publish a set list", plugin.name)
|
|
269
|
+
return 2
|
|
270
|
+
|
|
271
|
+
rows = await plugin.sets()
|
|
272
|
+
log.info("%-10s %-46s %s", "CODE", "SET", "RELEASED")
|
|
273
|
+
for row in rows:
|
|
274
|
+
if args.contains and args.contains.lower() not in (row["name"] or "").lower():
|
|
275
|
+
continue
|
|
276
|
+
log.info(
|
|
277
|
+
"%-10s %-46s %s",
|
|
278
|
+
row["abbreviation"] or row["group_id"],
|
|
279
|
+
(row["name"] or "")[:46],
|
|
280
|
+
row["published_on"],
|
|
281
|
+
)
|
|
282
|
+
log.info("%s sets in %s", len(rows), args.game)
|
|
283
|
+
return 0
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
async def cmd_rematch(args) -> int:
|
|
287
|
+
"""Re-run matching over scans already imported.
|
|
288
|
+
|
|
289
|
+
The reason this exists: ingesting the set a seller actually collects is the
|
|
290
|
+
fix for "everything matched the wrong game", and without this the only way
|
|
291
|
+
to benefit from a newly ingested set is to upload the same archive again.
|
|
292
|
+
The images are already on disk and already paid for.
|
|
293
|
+
"""
|
|
294
|
+
settings = get_settings()
|
|
295
|
+
db.init(settings.database_url)
|
|
296
|
+
session = db.session()
|
|
297
|
+
|
|
298
|
+
from foilstack.importing import rematch_scan
|
|
299
|
+
|
|
300
|
+
query = select(db.Scan)
|
|
301
|
+
if args.status != "all":
|
|
302
|
+
query = query.where(db.Scan.status == args.status)
|
|
303
|
+
if args.user:
|
|
304
|
+
user = session.scalar(select(db.User).where(db.User.email == args.user.lower()))
|
|
305
|
+
if user is None:
|
|
306
|
+
log.error("no account with email %s", args.user)
|
|
307
|
+
return 2
|
|
308
|
+
query = query.where(db.Scan.user_id == user.id)
|
|
309
|
+
scans = session.scalars(query.order_by(db.Scan.id)).all()
|
|
310
|
+
if not scans:
|
|
311
|
+
log.info("no scans with status %r", args.status)
|
|
312
|
+
return 0
|
|
313
|
+
|
|
314
|
+
log.info("re-matching %s scans", len(scans))
|
|
315
|
+
changed = failed = 0
|
|
316
|
+
for i, scan in enumerate(scans, 1):
|
|
317
|
+
try:
|
|
318
|
+
if await rematch_scan(session, scan, settings):
|
|
319
|
+
changed += 1
|
|
320
|
+
except Exception as exc: # noqa: BLE001 - one bad scan must not end the run
|
|
321
|
+
log.warning(" skip %s (%s)", scan.filename, type(exc).__name__)
|
|
322
|
+
failed += 1
|
|
323
|
+
if i % 25 == 0:
|
|
324
|
+
session.commit()
|
|
325
|
+
log.info(" %s/%s", i, len(scans))
|
|
326
|
+
session.commit()
|
|
327
|
+
log.info("re-matched %s scans (%s now have a match, %s failed)", len(scans), changed, failed)
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
async def cmd_sync_prices(args) -> int:
|
|
332
|
+
"""Refresh prices for one game, or for every game in the catalogue.
|
|
333
|
+
|
|
334
|
+
`--game all` exists because the alternative was a list someone has to
|
|
335
|
+
remember to update. Prices were synced for whatever games were named in an
|
|
336
|
+
environment variable, so ingesting a game and forgetting to add it there
|
|
337
|
+
left it priced at whatever `ingest` first saw — for good, with nothing
|
|
338
|
+
anywhere saying so. What should be synced is not a setting; it is whatever
|
|
339
|
+
has been ingested, which the database already knows.
|
|
340
|
+
"""
|
|
341
|
+
settings = get_settings()
|
|
342
|
+
db.init(settings.database_url)
|
|
343
|
+
session = db.session()
|
|
344
|
+
|
|
345
|
+
plugins = source_plugins()
|
|
346
|
+
plugin = plugins.get(args.source)
|
|
347
|
+
if plugin is None:
|
|
348
|
+
log.error("unknown source %r; available: %s", args.source, sorted(plugins))
|
|
349
|
+
return 2
|
|
350
|
+
if not hasattr(plugin, "fetch_prices"):
|
|
351
|
+
log.error("%s does not publish prices", plugin.name)
|
|
352
|
+
return 2
|
|
353
|
+
|
|
354
|
+
if args.game == "all":
|
|
355
|
+
games = list(
|
|
356
|
+
session.scalars(
|
|
357
|
+
select(db.Card.game)
|
|
358
|
+
.where(db.Card.source == plugin.name)
|
|
359
|
+
.distinct()
|
|
360
|
+
.order_by(db.Card.game)
|
|
361
|
+
).all()
|
|
362
|
+
)
|
|
363
|
+
if not games:
|
|
364
|
+
log.error("no %s cards ingested yet — run `foilstack ingest` first", plugin.name)
|
|
365
|
+
return 2
|
|
366
|
+
log.info("syncing every ingested game: %s", " ".join(games))
|
|
367
|
+
else:
|
|
368
|
+
games = [args.game]
|
|
369
|
+
|
|
370
|
+
# Read once and shared across games. The build timestamp is one file
|
|
371
|
+
# covering the whole source, so asking for it per game would multiply the
|
|
372
|
+
# cheapest part of this by the number of catalogues for no new information.
|
|
373
|
+
stamp = None
|
|
374
|
+
probe = type(plugin)(game=games[0], set_code=args.set)
|
|
375
|
+
if hasattr(probe, "last_updated"):
|
|
376
|
+
try:
|
|
377
|
+
stamp = await probe.last_updated()
|
|
378
|
+
except Exception as exc: # noqa: BLE001 - a missing stamp is not fatal
|
|
379
|
+
log.warning("could not read upstream timestamp (%s)", type(exc).__name__)
|
|
380
|
+
|
|
381
|
+
worst = 0
|
|
382
|
+
for game in games:
|
|
383
|
+
code = await _sync_one_game(session, plugin, game, stamp, args)
|
|
384
|
+
worst = max(worst, code)
|
|
385
|
+
return worst
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
async def _sync_one_game(session, plugin, game: str, stamp: str | None, args) -> int:
|
|
389
|
+
"""Refresh prices for one game.
|
|
390
|
+
|
|
391
|
+
Follows TCGCSV's stated usage guidelines rather than polling blindly: their
|
|
392
|
+
files rebuild exactly once a day, they publish the build timestamp, and
|
|
393
|
+
they ask that a full sync run only when it is newer than your last pull.
|
|
394
|
+
So on an unchanged stamp this returns having made no requests at all.
|
|
395
|
+
|
|
396
|
+
History is appended only where a number actually changed. A catalogue that
|
|
397
|
+
has not moved writes nothing.
|
|
398
|
+
"""
|
|
399
|
+
plugin = type(plugin)(game=game, set_code=args.set)
|
|
400
|
+
|
|
401
|
+
# Keyed per game, not per source. One row for the whole source meant a
|
|
402
|
+
# successful Magic sync recorded the upstream stamp globally, and every
|
|
403
|
+
# other catalogue then saw its own first run as "already up to date" and
|
|
404
|
+
# never pulled a price.
|
|
405
|
+
kind = f"prices:{game}"
|
|
406
|
+
state = session.get(db.SyncState, (plugin.name, kind))
|
|
407
|
+
if stamp and state is not None and state.upstream_stamp == stamp and not args.force:
|
|
408
|
+
log.info("%s: upstream unchanged since %s — nothing to do", game, stamp)
|
|
409
|
+
state.last_run_at = _now()
|
|
410
|
+
session.commit()
|
|
411
|
+
return 0
|
|
412
|
+
log.info("syncing %s prices (upstream build %s)", game, stamp or "unknown")
|
|
413
|
+
|
|
414
|
+
# Map upstream ids to our card rows once, rather than querying per price.
|
|
415
|
+
cards = {
|
|
416
|
+
source_id.split(":", 1)[-1]: card_id
|
|
417
|
+
for card_id, source_id in session.execute(
|
|
418
|
+
select(db.Card.id, db.Card.source_id).where(
|
|
419
|
+
db.Card.source == plugin.name, db.Card.game == game
|
|
420
|
+
)
|
|
421
|
+
).all()
|
|
422
|
+
}
|
|
423
|
+
if not cards:
|
|
424
|
+
log.error("no %s %s cards ingested yet — run `foilstack ingest` first", plugin.name, game)
|
|
425
|
+
return 2
|
|
426
|
+
|
|
427
|
+
today = dt.date.today()
|
|
428
|
+
seen = changed = 0
|
|
429
|
+
async for record in plugin.fetch_prices():
|
|
430
|
+
card_id = cards.get(record.source_id)
|
|
431
|
+
if card_id is None:
|
|
432
|
+
continue # a printing we have not ingested
|
|
433
|
+
seen += 1
|
|
434
|
+
current = session.get(db.CardPrice, (card_id, record.sub_type))
|
|
435
|
+
fields = ("market", "low", "mid", "high")
|
|
436
|
+
incoming = {f: getattr(record, f) for f in fields}
|
|
437
|
+
|
|
438
|
+
if current is None:
|
|
439
|
+
session.add(db.CardPrice(card_id=card_id, sub_type=record.sub_type, **incoming))
|
|
440
|
+
moved = True
|
|
441
|
+
else:
|
|
442
|
+
moved = any(getattr(current, f) != incoming[f] for f in fields)
|
|
443
|
+
for f in fields:
|
|
444
|
+
setattr(current, f, incoming[f])
|
|
445
|
+
|
|
446
|
+
if moved:
|
|
447
|
+
# `merge` rather than `add`: a second run on the same day should
|
|
448
|
+
# correct that day's reading, not collide with it.
|
|
449
|
+
session.merge(
|
|
450
|
+
db.CardPriceHistory(
|
|
451
|
+
card_id=card_id,
|
|
452
|
+
sub_type=record.sub_type,
|
|
453
|
+
recorded_on=today,
|
|
454
|
+
**incoming,
|
|
455
|
+
)
|
|
456
|
+
)
|
|
457
|
+
changed += 1
|
|
458
|
+
if seen % 500 == 0:
|
|
459
|
+
session.commit()
|
|
460
|
+
log.info(" %s printings (%s changed)", seen, changed)
|
|
461
|
+
|
|
462
|
+
# Keep the card's headline price current too. It is the fallback used when
|
|
463
|
+
# a printing has no row of its own, and left alone it would still hold
|
|
464
|
+
# whatever `ingest` saw months ago — a stale number quietly standing in for
|
|
465
|
+
# a fresh one is worse than no number at all.
|
|
466
|
+
session.execute(
|
|
467
|
+
text("""
|
|
468
|
+
UPDATE cards c SET market = p.market, updated_at = now()
|
|
469
|
+
FROM (
|
|
470
|
+
SELECT DISTINCT ON (card_id) card_id, market
|
|
471
|
+
FROM card_prices
|
|
472
|
+
WHERE market IS NOT NULL
|
|
473
|
+
ORDER BY card_id, (sub_type ILIKE '%foil%'), market
|
|
474
|
+
) p
|
|
475
|
+
WHERE p.card_id = c.id AND c.source = :source AND c.game = :game
|
|
476
|
+
AND (c.market IS DISTINCT FROM p.market)
|
|
477
|
+
"""),
|
|
478
|
+
{"source": plugin.name, "game": game},
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
session.merge(
|
|
482
|
+
db.SyncState(
|
|
483
|
+
source=plugin.name,
|
|
484
|
+
kind=kind,
|
|
485
|
+
upstream_stamp=stamp,
|
|
486
|
+
last_run_at=_now(),
|
|
487
|
+
rows_changed=changed,
|
|
488
|
+
message=f"{seen} printings, {changed} changed",
|
|
489
|
+
)
|
|
490
|
+
)
|
|
491
|
+
session.commit()
|
|
492
|
+
log.info("%s: synced %s printings, %s price changes recorded", game, seen, changed)
|
|
493
|
+
return 0
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def _now() -> dt.datetime:
|
|
497
|
+
return dt.datetime.now(dt.UTC)
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def cmd_migrate(args) -> int:
|
|
501
|
+
"""Bring the database up to the current schema.
|
|
502
|
+
|
|
503
|
+
Exists so a pip-installed foilstack can create its own tables. The compose
|
|
504
|
+
deployment runs `alembic upgrade head` from the repository, which needs
|
|
505
|
+
`alembic.ini` and a migrations directory beside it — neither of which a
|
|
506
|
+
wheel installed into site-packages has any reason to have.
|
|
507
|
+
|
|
508
|
+
The revisions therefore live inside the package, at
|
|
509
|
+
`foilstack/migrations`, and this points alembic at wherever that turned out
|
|
510
|
+
to be. `env.py` already reads the URL from `foilstack.config`, so there is
|
|
511
|
+
nothing for a config file to carry.
|
|
512
|
+
"""
|
|
513
|
+
from alembic import command
|
|
514
|
+
from alembic.config import Config
|
|
515
|
+
|
|
516
|
+
settings = get_settings()
|
|
517
|
+
here = Path(__file__).resolve().parent / "migrations"
|
|
518
|
+
if not here.is_dir():
|
|
519
|
+
log.error("migrations are missing from the installed package (%s)", here)
|
|
520
|
+
return 2
|
|
521
|
+
|
|
522
|
+
cfg = Config()
|
|
523
|
+
cfg.set_main_option("script_location", str(here))
|
|
524
|
+
cfg.set_main_option("sqlalchemy.url", settings.database_url)
|
|
525
|
+
log.info("migrating %s", settings.database_url.rsplit("@", 1)[-1])
|
|
526
|
+
command.upgrade(cfg, args.revision)
|
|
527
|
+
log.info("schema is up to date")
|
|
528
|
+
return 0
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def cmd_plugins(_args) -> int:
|
|
532
|
+
sources = source_plugins()
|
|
533
|
+
exports = export_plugins()
|
|
534
|
+
print("sources:")
|
|
535
|
+
for name, plugin in sources.items():
|
|
536
|
+
print(f" {name:12s} games: {', '.join(plugin.games)}")
|
|
537
|
+
print("exports:")
|
|
538
|
+
for name, spec in exports.items():
|
|
539
|
+
print(f" {name:12s} {len(spec.columns)} columns -> {spec.filename}")
|
|
540
|
+
return 0
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def main(argv: list[str] | None = None) -> int:
|
|
544
|
+
parser = argparse.ArgumentParser(prog="foilstack")
|
|
545
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
546
|
+
|
|
547
|
+
p_ingest = sub.add_parser("ingest", help="pull a catalogue from a source plugin")
|
|
548
|
+
p_ingest.add_argument("--source", default="tcgcsv")
|
|
549
|
+
p_ingest.add_argument("--game", default=None)
|
|
550
|
+
p_ingest.add_argument(
|
|
551
|
+
"--set",
|
|
552
|
+
default=None,
|
|
553
|
+
help="one set only, by code or name (see `foilstack sets`)",
|
|
554
|
+
)
|
|
555
|
+
p_ingest.add_argument("--limit", type=int, default=None)
|
|
556
|
+
p_ingest.set_defaults(fn=cmd_ingest, is_async=True)
|
|
557
|
+
|
|
558
|
+
p_embed = sub.add_parser("embed", help="encode catalogue images into vectors")
|
|
559
|
+
p_embed.add_argument("--limit", type=int, default=None)
|
|
560
|
+
p_embed.add_argument(
|
|
561
|
+
"--concurrency",
|
|
562
|
+
type=int,
|
|
563
|
+
default=int(os.getenv("FOILSTACK_EMBED_CONCURRENCY", "8")),
|
|
564
|
+
help="cards in flight at once (default 8)",
|
|
565
|
+
)
|
|
566
|
+
p_embed.add_argument(
|
|
567
|
+
"--all",
|
|
568
|
+
action="store_true",
|
|
569
|
+
help="re-encode cards that already have a vector for this model",
|
|
570
|
+
)
|
|
571
|
+
p_embed.add_argument(
|
|
572
|
+
"--retry-missing",
|
|
573
|
+
action="store_true",
|
|
574
|
+
help="also try cards upstream previously had no image for",
|
|
575
|
+
)
|
|
576
|
+
p_embed.set_defaults(fn=cmd_embed, is_async=True)
|
|
577
|
+
|
|
578
|
+
p_sets = sub.add_parser("sets", help="list the sets a source can fetch")
|
|
579
|
+
p_sets.add_argument("--source", default="tcgcsv")
|
|
580
|
+
p_sets.add_argument("--game", default="pokemon")
|
|
581
|
+
p_sets.add_argument("--contains", default=None, help="filter by name substring")
|
|
582
|
+
p_sets.set_defaults(fn=cmd_sets, is_async=True)
|
|
583
|
+
|
|
584
|
+
p_rematch = sub.add_parser(
|
|
585
|
+
"rematch",
|
|
586
|
+
help="re-run matching over scans already imported",
|
|
587
|
+
)
|
|
588
|
+
p_rematch.add_argument(
|
|
589
|
+
"--status",
|
|
590
|
+
default="unmatched",
|
|
591
|
+
choices=["unmatched", "pending", "error", "all"],
|
|
592
|
+
help="which scans to redo (default: unmatched)",
|
|
593
|
+
)
|
|
594
|
+
p_rematch.add_argument("--user", default=None, help="limit to one account's scans")
|
|
595
|
+
p_rematch.set_defaults(fn=cmd_rematch, is_async=True)
|
|
596
|
+
|
|
597
|
+
p_sync = sub.add_parser(
|
|
598
|
+
"sync-prices",
|
|
599
|
+
help="refresh prices and record the ones that changed",
|
|
600
|
+
)
|
|
601
|
+
p_sync.add_argument("--source", default="tcgcsv")
|
|
602
|
+
p_sync.add_argument(
|
|
603
|
+
"--game",
|
|
604
|
+
default="all",
|
|
605
|
+
help="one game, or 'all' for every game in the catalogue (default)",
|
|
606
|
+
)
|
|
607
|
+
p_sync.add_argument("--set", default=None, help="one set only")
|
|
608
|
+
p_sync.add_argument(
|
|
609
|
+
"--force",
|
|
610
|
+
action="store_true",
|
|
611
|
+
help="sync even if upstream reports no new build",
|
|
612
|
+
)
|
|
613
|
+
p_sync.set_defaults(fn=cmd_sync_prices, is_async=True)
|
|
614
|
+
|
|
615
|
+
p_migrate = sub.add_parser("migrate", help="create or update the database schema")
|
|
616
|
+
p_migrate.add_argument(
|
|
617
|
+
"revision",
|
|
618
|
+
nargs="?",
|
|
619
|
+
default="head",
|
|
620
|
+
help="target revision (default: head)",
|
|
621
|
+
)
|
|
622
|
+
p_migrate.set_defaults(fn=cmd_migrate, is_async=False)
|
|
623
|
+
|
|
624
|
+
p_plugins = sub.add_parser("plugins", help="list installed plugins")
|
|
625
|
+
p_plugins.set_defaults(fn=cmd_plugins, is_async=False)
|
|
626
|
+
|
|
627
|
+
args = parser.parse_args(argv)
|
|
628
|
+
if getattr(args, "is_async", False):
|
|
629
|
+
return asyncio.run(args.fn(args))
|
|
630
|
+
return args.fn(args)
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
if __name__ == "__main__":
|
|
634
|
+
sys.exit(main())
|