Appearance
Mises à jour du catalogue — nouveaux sets, nouvelles cartes
Runbook opérationnel pour garder le backend en phase avec l'amont quand de nouvelles cartes ou de nouvelles extensions sortent. À lire quand un nouveau set Pokémon arrive, quand Bandai sort un starter deck OP, quand Wizards imprime un set Standard — tout ce qui change l'univers des cartes scannables.
TL;DR
- Toutes les étapes ingest + embed sont idempotentes. Relancer la même commande après un run partiel reprend exactement où ça s'était arrêté ; un re-run complet après nouveau set ajoute le delta et skip ce qui est déjà sur disque + en DB.
- Un nouveau set demande ~1 à 5 min de temps opérateur selon le TCG. Le plus lent c'est l'embed des nouvelles cartes (~10 cartes/s sur le sidecar DINOv2).
Une asymétrie : les scans Pokémon et One Piece dépendent de l'index FAISS de Tormknd (gelé au build de notre container).Résolu le 2026-05-07 — la Phase F a flippé l'app RN vers/scan-labpour les trois TCG, donc l'asymétrie a disparu. Les nouvelles cartes de n'importe quel TCG sont scannables immédiatement après embed.
Impact utilisateur quand un nouveau set sort
L'utilisateur de l'app mobile interagit avec deux choses : le catalogue (parcourir les cartes, voir leurs métadonnées + image) et le scanner (prendre une photo, recevoir l'identification). Ces deux fonctions touchent des axes de stockage différents qui ne se mettent pas à jour au même rythme.
| Ce que l'utilisateur fait | Donnée touchée | Devient disponible quand… | Étape côté nous |
|---|---|---|---|
| Naviguer le catalogue par set / rareté | Lignes cards + sets en Postgres | Dès la fin de l'ingest (~30 s - 1 min) | INGEST_TCG=… ingest |
| Voir l'image d'une carte dans sa fiche | Fichiers data/images/<set>/<num>/large.jpg | Dès la fin de l'ingest (téléchargement inclus) | INGEST_TCG=… ingest (pas d'option à ajouter) |
| Rechercher par nom | Index trigram sur cards.name | Dès la fin de l'ingest (l'index trigram se maintient automatiquement) | INGEST_TCG=… ingest |
| Scanner une carte du nouveau set (MTG) | Vecteurs card_embeddings_lab (HNSW pgvector) | Après embed (~30 s - 5 min selon taille du set) | INGEST_LAB_TCG=mtg embedCatalogLab |
| Scanner une carte du nouveau set (Pokémon / OP) | Index FAISS de Tormknd (gelé au build) | Quand Tormknd rebuild son repo OU quand on bascule sur /scan-lab (Phase F) | Aucune action de notre côté ne suffit aujourd'hui |
En clair, pour un nouveau set :
- MTG → Friction zéro. Ingest + embed (~1-2 min total) = scannable et naviguable.
- Pokémon / OP → Catalogue + images + recherche par nom : instantané dès l'ingest. Scan via l'app : nécessite Tormknd, ou attendre la Phase F.
Cette asymétrie est la friction principale qu'on cherche à supprimer avec la Phase F. Tant qu'elle n'est pas faite, scanner une carte fraîchement sortie d'un booster Pokémon retournera "no-match" sur l'app mobile, même si nous avons les données en DB.
Cycle de vie : de la sortie upstream à la carte scannable
Jour 0 Jour 0 - 7 Jour 0 (nous) Jour 0+ (nous)
[Set sorti] → [Upstream publie] → [Opérateur lance ingest] → [Opérateur lance embed] → [Scannable]
(Wizards / Bandai / (Scryfall / (~10 s - 1 min (~30 s - 5 min)
The Pokémon TCGdex / par TCG)
Company) OPTCG API)Le délai entre "set sorti" et "upstream publie" varie beaucoup :
- Scryfall : refresh du bulk
default_cardsle jour J ou J+1. Best in class. - TCGdex : typiquement 2-7 jours après la sortie officielle Pokémon. Communautaire.
- OPTCG API : généralement la même semaine que Bandai.
Cadence et commandes par TCG
| TCG | Source | Quand l'amont voit le nouveau set | Notre commande | Temps |
|---|---|---|---|---|
| Magic | Bulk default_cards Scryfall | Jour J (souvent dans les heures qui suivent la mise en ligne du set) | INGEST_TCG=mtg ingest puis INGEST_LAB_TCG=mtg embedCatalogLab | ~1 min ingest + ~30 s embed pour un set de ~300 cartes |
| Pokémon | API publique TCGdex | 2-7 jours post-release | INGEST_TCG=pokemon INGEST_SETS=<id> ingest puis embed | ~30 s ingest + ~30 s embed pour ~200 cartes |
| One Piece | OPTCG API (optcgapi.com) | Même semaine que Bandai | INGEST_TCG=onepiece INGEST_SETS=<id> ingest puis embed | ~10 s ingest + ~30 s embed pour ~200 cartes |
Recettes étape par étape
Toutes les commandes se lancent depuis la racine du repo tcg-collector-backend, avec .env rempli.
Magic — un nouveau set vient de sortir sur Scryfall
bash
# 1. Re-ingest. Le fichier bulk est auto-rafraîchi si Scryfall a un
# `updated_at` plus récent — donc les nouvelles cartes sont prises
# de manière transparente. Les images existantes sur disque sont skippées.
docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_TCG=mtg ingest
# 2. Embed seulement les nouvelles cartes (le filtre NOT EXISTS skip les déjà-embeddées).
docker compose --env-file .env -f docker/compose.yml run --rm \
--entrypoint "node dist/embedCatalogLab.js" \
-e INGEST_LAB_TCG=mtg ingest
# 3. Smoke — prends une carte du nouveau set, scan via /scan-lab.
curl -s -F "photo=@/path/to/test.jpg" http://<vps>:8080/scan-lab | jq .matchPokémon — un nouveau set vient d'arriver sur TCGdex
bash
# 1. Re-ingest seulement le nouveau set (utilise INGEST_SETS=all si tu veux tout balayer).
docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_TCG=pokemon -e INGEST_SETS=<set-id> ingest
# 2. Embed pour le path /scan-lab. Tant que Phase F n'est pas faite,
# /scan-compat ne verra pas les nouvelles cartes (FAISS de Tormknd
# gelé) — mais quand on flip l'app RN sur /scan-lab, elles deviennent
# scannables.
docker compose --env-file .env -f docker/compose.yml run --rm \
--entrypoint "node dist/embedCatalogLab.js" \
-e INGEST_LAB_TCG=pokemon -e INGEST_LAB_SETS=<set-id> ingest
# 3. Smoke — pareil que Magic.One Piece — un nouveau booster set ou starter deck
bash
# 1. Trouve le set id sur optcgapi.com (ex. OP-16 pour le prochain booster).
docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_TCG=onepiece -e INGEST_SETS=<set-id> ingest
# 2. Embed.
docker compose --env-file .env -f docker/compose.yml run --rm \
--entrypoint "node dist/embedCatalogLab.js" \
-e INGEST_LAB_TCG=onepiece -e INGEST_LAB_SETS=<set-id> ingest
# 3. Smoke. Note : les starter decks ST sont toujours exclus depuis le
# revert #101 jusqu'à ce qu'on exerce la logique de variants dessus —
# voir scan-lab-plan.md.Vérifications post-update
Les lignes ont-elles atterri ?
sql
-- Comptes par TCG cartes + sets (à lancer dans le container postgres).
SELECT tcg, count(*) AS cards, count(DISTINCT set_id) AS sets
FROM cards GROUP BY tcg;
-- Nouvelles cartes des dernières 24 h.
SELECT id, name, set_id FROM cards
WHERE fetched_at > now() - interval '24 hours'
ORDER BY fetched_at DESC LIMIT 20;Les embeddings ont-ils atterri ?
sql
-- Doit matcher (count cartes - cartes sans image).
SELECT count(*) FROM card_embeddings_lab ce
JOIN cards c ON c.id = ce.card_id
WHERE c.tcg = '<tcg>';Sanity check de self-recall
Prends n'importe quelle carte indexée, query son propre embedding, attends sim ≈ 1.0 :
sql
WITH q AS (SELECT embedding FROM card_embeddings_lab WHERE card_id = '<some-id>' LIMIT 1)
SELECT ce.card_id, c.name, (1 - (ce.embedding <=> q.embedding) / 2)::numeric(5,4) AS sim
FROM card_embeddings_lab ce
JOIN cards c ON c.id = ce.card_id, q
WHERE c.tcg = '<tcg>'
ORDER BY ce.embedding <=> q.embedding
LIMIT 3;La première ligne doit avoir sim = 1.0000. Sinon l'embed a raté → relance avec EMBEDDINGS_FORCE=true pour ce subset afin d'écraser les vecteurs périmés.
Cas particuliers
Logos de séries et de promos fournis localement
Un fichier présent dans data/images/set-logos/<set-id>.webp n'est publiable dans l'application que lorsque la ligne du set porte aussi _snag.logoPath avec cette valeur. Les promos Pokémon dont l'amont ne fournit pas de logo (30th, mep, svp, smp, xyp, bwp, hgssp, dpp et basep) réutilisent leur miroir WebP validé lors des ingests ultérieurs. Ne pas remplacer un tel visuel par un placeholder ni modifier les cartes, les prix ou les données utilisateur.
Pour réparer une association manquante en production, sauvegarder d'abord les métadonnées de la ligne ciblée, appliquer uniquement logoPath, puis vérifier la réponse publique /api/sets?lang=fr et le média invité /api/catalogue-media/images/set-logos/<set-id>.webp. Aucun redémarrage de service n'est requis.
Les codes affichés par l'app sont des libellés français : EB pour Épée et Bouclier, SL pour Soleil et Lune, NB pour Noir & Blanc et ADL pour Appel des Légendes. Les identifiants du catalogue restent inchangés pour les requêtes.
Errata / réimpression avec une nouvelle image
Si une carte est ré-illustrée (rare mais arrive pour les promos Pokémon et MTG Mystery Booster), l'URL image_uris upstream change mais l'id reste le même. Notre ingest va :
- Upsert la ligne
cards(image_base mise à jour) - Re-télécharger l'image (le chemin disque embarque l'id — collision détectée, fichier écrasé)
- NE PAS re-embedder par défaut — le skip
NOT EXISTSgarde l'ancien vecteur. Override avecEMBEDDINGS_FORCE=truepour rafraîchir les embeddings d'un TCG donné.
Nouvelle variante pour une carte existante (parallel art OP, showcase MTG)
Pour OP, assignVariantIds gère déjà les collisions de parallel art : une nouvelle entrée partageant card_set_id avec une existante devient id=<base>_v2 (ou _v3, etc.) au prochain ingest. Le chemin disque clé est l'id suffixé du variant, donc pas d'écrasement.
Pour MTG, Scryfall émet déjà un collector_number distinct par variante — donc la variante atterrit comme une nouvelle ligne avec son propre id (ex. neo-100 vs neo-327 pour le showcase). Pas de collision.
Nouvelle langue pour un set déjà ingéré
Concerne uniquement Pokémon (TCGdex multi-langues). Relancer avec INGEST_LANG=ja (ou n'importe quelle nouvelle langue) atterrit les entrées spécifiques de la langue comme lignes séparées, gated par la dédup first-seen-wins dans index.ts.
Lag FAISS Tormknd pour Pokémon / OP
Tant que la Phase F n'a pas flippé l'app RN sur /scan-lab, les scans Pokémon et OP passent par la FAISS bakée du tcg-vision. Les nouvelles cartes qu'on ingère ne seront pas scannables via /scan-compat jusqu'à :
- Tormknd rebuild ses dossiers d'images FAISS + ship une nouvelle release ; on bump
TCG_APP_COMMITet on rebuild notre imagetcg-vision. L'inertie dépend de lui. - On finit la Phase F et on route tout via
/scan-lab. Alors un nouveau set Pokémon est identique à un nouveau set MTG : ingest + embed, fini.
Entre-temps les nouvelles cartes restent utiles pour la navigation catalogue (elles sont dans cards), juste pas pour le scan via l'app RN live.
Pourquoi tout est idempotent
Chaque couche du pipeline a une règle de skip déterministe — relancer n'importe où dans la chaîne est safe :
| Couche | Règle de skip |
|---|---|
| Upserts sets / cards | INSERT … ON CONFLICT (id) DO UPDATE — rafraîchit la ligne, jamais de duplicat |
| Download d'image | if (await exists(abs)) return skipped — même chemin, deuxième run skip l'écriture disque |
| Download bulk Scryfall | Le nom de fichier cache embarque updated_at ; même manifest = même nom = ré-utilisation |
| Embedding | NOT EXISTS (… ce.card_id = c.id AND ce.model = $1) — skip les cartes déjà embeddées pour ce modèle |
Conséquence : on ne peut pas casser le catalogue en relançant. Le pire c'est de gaspiller quelques secondes à ne rien faire. C'est la propriété qui rend un cron de refresh nocturne viable.
Automatisation future (pas encore en place)
Un cron nocturne pourrait lancer les trois TCG ingest + embed :
bash
# /etc/cron.d/tcg-catalog-refresh (illustratif — pas encore wired)
0 4 * * * cd /opt/tcg-collector-backend && \
docker compose --env-file .env -f docker/compose.yml run --rm \
-e INGEST_TCG=mtg ingest >> /var/log/tcg-ingest.log 2>&1
0 5 * * * cd /opt/tcg-collector-backend && \
docker compose --env-file .env -f docker/compose.yml run --rm \
--entrypoint "node dist/embedCatalogLab.js" -e INGEST_LAB_TCG=mtg ingest \
>> /var/log/tcg-embed.log 2>&1Coût : ~2-5 min de compute par nuit par TCG quand rien n'a changé (check du manifest Scryfall + skip), beaucoup plus quand un set sort. Le cache du bulk file + les règles idempotentes rendent ça safe même si Claude / un humain oublie de trigger manuellement.
Troubleshooting
"J'ai ingéré mais /scan-lab dit toujours no-match"
Tu as oublié l'étape embed. L'ingest écrit dans cards + data/images/ seulement ; sans l'étape embed, card_embeddings_lab n'a pas de ligne pour la nouvelle carte et pgvector ne retourne rien. Lance la commande embed pour ce TCG.
"L'embed retourne done: 0"
Le skip NOT EXISTS pense que tout est déjà embeddé. Si tu veux vraiment re-embedder (ex. après un refresh d'image Scryfall), set EMBEDDINGS_FORCE=true pour bypass le skip.
"Upstream retourne 4xx / 5xx"
- Scryfall : rare ; leur endpoint bulk est solide. Check
https://api.scryfall.com/bulk-dataà la main. - TCGdex : occasionnel ; le retry-with-backoff de l'ingest gère les échecs transitoires. Si ça échoue systématiquement pour un set, ses données ne sont peut-être pas encore publiées.
- OPTCG API : leur host (optcgapi.com) a eu des downtimes ; le fallback Bandai L3 reporté (voir
docs/INGEST.md§One Piece) fermerait ce gap mais n'est pas wired.
"Je vois moins de lignes que prévu"
Probablement une collision après sanitisation. Pour MTG spécifiquement, ~1.7 % des lignes default_cards collisionnent avec une autre édition après sanitisation Unicode du collector_number (100 et 100★ deviennent tous deux set-100). L'image est identique entre les lignes collisionnées donc le scan résout toujours bien — mais le count de lignes est plus bas que le "rowsKept" du log d'ingest.
Voir aussi
- Vue d'ensemble de l'architecture — la big picture
- Architecture du scan de cartes — vue plus détaillée par composant
- Ingest Magic the Gathering — le précédent pour ajouter un nouveau TCG
tcg-collector-backend/docs/INGEST.md— référence technique côté backend (pipeline, sources par TCG, multi-langues)