All projects Axioma · Write-up

Pricing cards from real listingsKartenpreise aus echten Angeboten

Axioma tracks a Pokémon card collection and prices it with its own Cardmarket scraper. Most of the work is deciding which listing a card’s price should come from.Axioma verwaltet eine Pokémon-Kartensammlung und bepreist sie mit einem eigenen Cardmarket-Scraper. Die meiste Arbeit steckt in der Frage, aus welchem Angebot der Preis einer Karte kommen soll.

axioma.ewolution.cloud

Type
Web appWeb-App
Stack
Svelte · Express · Python
Year
2026
Code
PrivatePrivat

One card, many prices

On Cardmarket a single card has dozens of listings from different sellers, countries, conditions and languages, some of them graded and slabbed. “The price” is what a buyer would actually pay, and that depends on where they buy and which condition they accept. Axioma reads real listings with those filters applied, and keeps every price it has ever seen.

Two stages

The first stage finds each card’s product page: a site-restricted web search for the card’s name and number, with the first matching URL written back. When the search engine answers with its rate-limit page, the scraper stops and waits for a person to clear it in the open browser window, instead of pretending it found something.

The second stage visits each page and reads the prices. The stages run independently, so re-pricing a collection never repeats the URL hunt.

One page, read twice

Each card’s page is read twice: once filtered to German sellers, once across all countries. If the German price is out of line with the cheapest listing elsewhere in the EU, it’s flagged as inflated and both numbers are kept. What counts as out of line tightens as the price rises: 50% above for cards up to €500, 30% up to €1,000, 20% beyond.

When there is no exact match, the price walks down a ladder instead of leaving a blank:

  1. GermanyGerman sellers, in the card’s exact condition.
  2. Selected EU countriesA shortlist of EU countries, not all of them.
  3. WorldwideEvery other country.
  4. SwitzerlandLast among the countries.
  5. Same condition or betterThe cheapest listing at least as good as the card, when nothing matches exactly.
  6. Price GuideCardmarket’s 30-day average, when there is no usable listing at all.

Two filters sit in front of the ladder. Graded listings, recognized from comments and tooltips, are skipped, so a slabbed outlier can’t drag a raw card’s price. And a worn copy is never priced above a better one: if a cheaper listing in better condition exists, it caps the price. The example in the code is an EX copy at €70 while eight NM copies of the same card were listed between €34.93 and €49.99. A buyer would take the better card for half the money, so €70 isn’t a price.

A ledger, not a number

Every update is appended to a price ledger, and each card in the grid carries a sparkline of its own history. CSV files are the source of truth; SQLite, through node:sqlite, is a query layer synced from them and never the other way round. Portfolio files are copied before every write, and restoring one is a button.

A card’s display name isn’t a stable identity, because the scraper replaces it with Cardmarket’s own title on the first successful scrape. So anything that refers to a card, from custom sets and binder slots to the price history, resolves through both its current name and the name it was imported under. The history survives the rename.

The day it scraped nothing

A daily run takes the most valuable cards, plus a number of cards whose last price is older than a threshold, say three days. One morning it delivered 0 of an intended 20 cards in two portfolios. Every candidate had been scraped in one batch three days earlier, and the check measured elapsed time: to a run starting at 09:42, a card scraped at 09:55 three days ago is only 2.99 days old.

0 of 20Cards delivered that morning, in each of two portfolios.
11–14 minHow far short of three days every candidate was.
1 functionFor “days ago”, now shared by the scheduler and the statistics.

Age is now counted in calendar days, through one function the scheduler shares with the collection statistics, so the two can’t disagree about what “three days ago” means. And every rule that makes the scraper skip a card now runs before the ranking is sliced, so cards that would be skipped anyway no longer take a slot.

The public instance is a demo

axioma.ewolution.cloud runs in mock mode: a randomized collection of about 200 cards in two portfolios, a year of plausible price history, and scraping stubbed out. The app can be shown without exposing a real collection or sending a single request to Cardmarket.

Eine Karte, viele Preise

Auf Cardmarket hat eine einzelne Karte Dutzende Angebote von verschiedenen Verkäufern, aus verschiedenen Ländern, in verschiedenen Zuständen und Sprachen, manche davon gegradet und eingeschweißt. „Der Preis“ ist das, was man tatsächlich bezahlen würde, und das hängt davon ab, wo man kauft und welchen Zustand man akzeptiert. Axioma liest echte Angebote mit genau diesen Filtern und behält jeden Preis, den es je gesehen hat.

Zwei Stufen

Die erste Stufe findet die Produktseite jeder Karte: eine auf Cardmarket beschränkte Websuche nach Name und Nummer der Karte, die erste passende URL wird zurückgeschrieben. Antwortet die Suchmaschine mit ihrer Rate-Limit-Seite, hält der Scraper an und wartet, bis ein Mensch sie im offenen Browserfenster bestätigt, statt so zu tun, als hätte er etwas gefunden.

Die zweite Stufe besucht jede Seite und liest die Preise. Beide Stufen laufen unabhängig voneinander, eine Sammlung neu zu bepreisen wiederholt also nie die URL-Suche.

Eine Seite, zweimal gelesen

Jede Kartenseite wird zweimal gelesen: einmal gefiltert auf deutsche Verkäufer, einmal über alle Länder. Liegt der deutsche Preis deutlich über dem günstigsten Angebot im übrigen EU-Raum, wird er als überhöht markiert, und beide Zahlen bleiben erhalten. Was als deutlich gilt, wird mit steigendem Preis strenger: 50 % darüber bei Karten bis 500 €, 30 % bis 1.000 €, 20 % darüber.

Gibt es keinen exakten Treffer, steigt der Preis eine Leiter hinab, statt eine Lücke zu lassen:

  1. DeutschlandDeutsche Verkäufer, im exakten Zustand der Karte.
  2. Ausgewählte EU-LänderEine Auswahl an EU-Ländern, nicht alle.
  3. WeltweitAlle übrigen Länder.
  4. SchweizAls letztes Land.
  5. Gleicher Zustand oder besserDas günstigste Angebot, das mindestens so gut ist wie die Karte, wenn nichts exakt passt.
  6. PreisübersichtCardmarkets 30-Tage-Durchschnitt, wenn es gar kein brauchbares Angebot gibt.

Zwei Filter stehen vor der Leiter. Gegradete Angebote, erkannt an Kommentaren und Tooltips, werden übersprungen, damit ein eingeschweißter Ausreißer nicht den Preis einer ungegradeten Karte verzieht. Und ein abgenutztes Exemplar wird nie teurer bepreist als ein besseres: Gibt es ein günstigeres Angebot in besserem Zustand, deckelt es den Preis. Das Beispiel im Code ist ein EX-Exemplar für 70 €, während acht NM-Exemplare derselben Karte zwischen 34,93 € und 49,99 € angeboten wurden. Jeder würde die bessere Karte für den halben Preis nehmen, also sind 70 € kein Preis.

Ein Preisbuch statt einer Zahl

Jede Aktualisierung wird an ein Preisbuch angehängt, und jede Karte im Raster trägt eine Sparkline ihres eigenen Verlaufs. CSV-Dateien sind die Quelle der Wahrheit; SQLite, über node:sqlite, ist eine daraus synchronisierte Abfrageschicht und nie umgekehrt. Portfolio-Dateien werden vor jedem Schreiben kopiert, und eine davon wiederherzustellen ist ein Klick.

Der Anzeigename einer Karte ist keine stabile Identität, denn der Scraper ersetzt ihn beim ersten erfolgreichen Durchlauf durch Cardmarkets eigenen Titel. Alles, was auf eine Karte verweist, von eigenen Sets und Binder-Plätzen bis zum Preisverlauf, löst deshalb über den aktuellen Namen und den Importnamen auf. Der Verlauf überlebt die Umbenennung.

Der Tag, an dem nichts gescrapt wurde

Ein täglicher Lauf nimmt die wertvollsten Karten, dazu eine Anzahl Karten, deren letzter Preis älter als eine Schwelle ist, etwa drei Tage. Eines Morgens lieferte er in zwei Portfolios 0 von geplanten 20 Karten. Alle Kandidaten waren drei Tage zuvor in einem Rutsch gescrapt worden, und die Prüfung maß die verstrichene Zeit: Für einen Lauf, der um 09:42 startet, ist eine Karte, die vor drei Tagen um 09:55 gescrapt wurde, erst 2,99 Tage alt.

0 von 20Karten an diesem Morgen, in jedem von zwei Portfolios.
11–14 Min.Fehlten jedem Kandidaten zu drei Tagen.
1 FunktionFür „vor x Tagen“, jetzt geteilt von Scheduler und Statistik.

Das Alter wird jetzt in Kalendertagen gezählt, über eine Funktion, die sich der Scheduler mit der Sammlungsstatistik teilt; die beiden können sich nicht mehr uneinig sein, was „vor drei Tagen“ heißt. Und jede Regel, wegen der der Scraper eine Karte überspringt, greift jetzt, bevor die Rangliste zugeschnitten wird. Karten, die ohnehin übersprungen würden, belegen keinen Platz mehr.

Die öffentliche Instanz ist eine Demo

axioma.ewolution.cloud läuft im Mock-Modus: eine zufällig erzeugte Sammlung von rund 200 Karten in zwei Portfolios, ein Jahr plausibler Preisverlauf, und das Scraping ist abgeschaltet. So lässt sich die App zeigen, ohne eine echte Sammlung offenzulegen oder auch nur eine Anfrage an Cardmarket zu schicken.