All projects Prospekt · Write-up
Reading the flyer’s text, not its picturesDen Text des Prospekts lesen, nicht seine Bilder
Prospekt gathers the week’s supermarket offers for one household and judges each price against its own history. famila’s and Marktkauf’s flyers are print PDFs, read as positioned text without a model, and a price only counts once the unit price printed beside it agrees.Prospekt sammelt die Supermarkt-Angebote der Woche für einen Haushalt und misst jeden Preis an seiner eigenen Geschichte. Die Prospekte von famila und Marktkauf sind Druck-PDFs, gelesen als Text mit Positionen und ohne Modell, und ein Preis zählt erst, wenn der Grundpreis daneben passt.
prospekt.ewolution.cloud Sign-in only · private
- Type
- Web appWeb-App
- Stack
- Svelte 5 · Node · SQLite · pdf.js
- Year
- 2026
- Code
- PrivatePrivat
A flyer is text with positions
famila and Marktkauf publish their weekly flyers as print PDFs. Opened with pdf.js, every price, every product name and every Grundpreis, the price per kilo or litre, is real text with a position and a size. So the flyers are read without a model: a layout parser turns the text on each page into offers, the same way for both shops.
- Price boxesThe big numerals, with their label (“MIT APP”, “APP PREIS”) and the price they’re compared to (“statt 2.29”, “2.29* spar 56%”, “UVP 9.99”).
- Text blocksLines that start at the same left edge, one under the other: brand and name, details, pack size, Grundpreis.
- PairsEvery plausible pairing of a price with a block, scored by distance, taken best first, one price per block.
- TilesThe block and its price, grown into the empty space around them up to the neighbours’ text. That space is where the product photo is, so the offer’s picture is cut out of the page image.
Each shop sets its tiles its own way, so a layout only says what its prices look like and where they sit. On famila’s pages the text is at a tile’s left and the price at its bottom right; on Marktkauf’s the price sits under its text, on the same left edge. The rest is shared.
The flyer checks itself
Pairing by distance alone goes wrong where tiles crowd together. But almost every price in the flyer comes with a second number: the Grundpreis printed beside it. Pack size times Grundpreis gives the price back, so every pairing can be checked against the flyer’s own print.
Printed text is exact, so confirming takes a close match: within 1.5%, at least three cents. A neighbour’s price is often only a few per cent off, close enough to fool a loose check. A Grundpreis that confirms a price wins that pairing outright; one more than 6% off, on a plain pack, all but rules it out. Ranges and variants (“300–540 g” at “4.97–2.76”) match on any of their values.
Only confirmed prices go into the price history. An offer the Grundpreis can’t confirm is still shown, with a line saying so. On Marktkauf’s pages a price and its text can only pair one way, so a Grundpreis that disagrees there is the flyer’s own typo: the offer is kept, and kept out of the history.
115 MB, read once
famila’s flyer for week 41 is a 115 MB PDF. The server checks both shops every six hours, and a flyer that hasn’t changed costs one HEAD request: it’s only downloaded again when its size or date changed. Marktkauf puts no page images online, so poppler’s pdftoppm renders the pages the pictures are cut from.
Every payload is archived before it’s interpreted, gzipped, and only when it changed. For a flyer that’s the text the parser read, about 50 KB, not the PDF, so a better parser can be replayed over every past week with npm run reparse. The pictures go through the server too, so the page’s CSP stays at img-src 'self' and the shops’ image servers never see who is looking.
Is it actually a good price?
Every trusted price goes into a history, one row per product, shop and week. An offer is judged by its unit price where it has one, since pack sizes change, against up to 52 weeks of that history at every shop, and the verdict comes with its reason in a sentence.
| Lowest price | As cheap as it has ever been, with at least three weeks of record. |
|---|---|
| Good price | In the cheapest quarter of its record, or well below the price printed beside it. |
| Usual price | Nothing special. |
| Pricey | In the most expensive quarter: an offer that isn’t one. |
Until a product has a history, the discount the shop prints is the fallback. Above 60% it’s ignored: a discount that big is nearly always measured against a manufacturer’s RRP.
Butter isn’t Butterkäse
The shopping list, the recipes and the watchlist all ask the same question: which of this week’s offers is “Butter”? Each matches by its main noun on its own, the last part of a word or of a hyphenated name, within that noun’s category. “Kerrygold Butter” and “Joghurtbutter” are butter; “Butterkäse” and “Sahne-Heringsfilets” aren’t, and a tin of tomatoes doesn’t come from the vegetable crate.
Where a match is wrong, it’s taught in one tap: “This is it”, “Not this” or “Only Kerrygold” saves a product under that noun, and every recipe, list item and watch with it uses it from then on. Flyer names drift from week to week, so what was confirmed is stored by the offer’s product key, the source’s own id where it has one, not by its wording.
Recipes that know the week
A recipe comes from a link, from pasted text or typed in. Nearly every recipe site embeds its recipe as schema.org data for search engines, so a link is read without a model, picture included. The page is fetched once, on request, only from a public address: every redirect is checked again, nothing on the home network or the server itself is reachable, and a page may be 4 MB at most. Pasted text goes to the model when there’s an API key and through simple rules when there isn’t.
Each ingredient shows where it’s cheapest this week, through the same matching as the list. One button puts a recipe on the shopping list, scaled to the servings chosen; the same ingredient from two recipes becomes one line, and salt and pepper stay off it.
Behind a sign-in
Prospekt is one household’s app, so its address sits behind Cloudflare Access, and the link on this page leads to a sign-in. Access signs every request it lets through, and the server checks that signature itself against the team’s published keys, so the port published on the NAS is of no use without one. Visits are counted by Census behind the same check, so only signed-in visits count.
On the phone it installs to the home screen and keeps the last week, the watchlist and the shopping list offline, with ticking things off as a big target for one thumb.
Ein Prospekt ist Text mit Positionen
famila und Marktkauf veröffentlichen ihre Wochenprospekte als Druck-PDFs. Mit pdf.js geöffnet ist jeder Preis, jeder Produktname und jeder Grundpreis echter Text mit Position und Größe. Also werden die Prospekte ohne Modell gelesen: Ein Layout-Parser macht aus dem Text jeder Seite Angebote, für beide Läden auf dieselbe Weise.
- PreisfelderDie großen Ziffern, mit ihrem Etikett („MIT APP“, „APP PREIS“) und dem Preis, mit dem sie verglichen werden („statt 2.29“, „2.29* spar 56%“, „UVP 9.99“).
- TextblöckeZeilen, die an derselben linken Kante beginnen, eine unter der anderen: Marke und Name, Details, Packungsgröße, Grundpreis.
- PaareJede plausible Paarung aus Preis und Block, nach Abstand bewertet, die besten zuerst, ein Preis pro Block.
- KachelnBlock und Preis, in den freien Raum um sie herum erweitert, bis an den Text der Nachbarn. In diesem Raum steht das Produktfoto, also wird das Bild des Angebots aus der Seite ausgeschnitten.
Jeder Laden setzt seine Kacheln auf seine Art, also sagt ein Layout nur, wie seine Preise aussehen und wo sie stehen. Auf famila-Seiten steht der Text links in der Kachel und der Preis unten rechts; bei Marktkauf steht der Preis unter seinem Text, an derselben linken Kante. Der Rest ist gemeinsam.
Der Prospekt prüft sich selbst
Nur nach Abstand zu paaren geht schief, wo die Kacheln dicht stehen. Aber fast jeder Preis im Prospekt kommt mit einer zweiten Zahl: dem Grundpreis daneben. Packungsgröße mal Grundpreis ergibt wieder den Preis, also lässt sich jede Paarung am Druck des Prospekts selbst prüfen.
Gedruckter Text ist exakt, also braucht eine Bestätigung eine knappe Übereinstimmung: höchstens 1,5 % daneben, mindestens drei Cent. Der Preis eines Nachbarn liegt oft nur ein paar Prozent daneben, nah genug, um eine lockere Prüfung zu täuschen. Ein Grundpreis, der einen Preis bestätigt, gewinnt diese Paarung sofort; einer, der mehr als 6 % abweicht, schließt sie bei einer einfachen Packung so gut wie aus. Spannen und Varianten („300–540 g“ zu „4.97–2.76“) passen mit jedem ihrer Werte.
Nur bestätigte Preise kommen in die Preisgeschichte. Ein Angebot, das der Grundpreis nicht bestätigen kann, wird trotzdem gezeigt, mit einer Zeile, die das sagt. Auf Marktkauf-Seiten passen Preis und Text nur auf eine Weise zusammen, also ist ein widersprechender Grundpreis dort ein Tippfehler des Prospekts: Das Angebot bleibt, nur nicht in der Preisgeschichte.
115 MB, einmal gelesen
Der Prospekt von famila für Woche 41 ist ein PDF mit 115 MB. Der Server schaut alle sechs Stunden bei beiden Läden nach, und ein unveränderter Prospekt kostet eine HEAD-Anfrage: Neu geladen wird er nur, wenn sich Größe oder Datum geändert haben. Marktkauf stellt keine Seitenbilder online, also rendert pdftoppm aus poppler die Seiten, aus denen die Bilder geschnitten werden.
Jede Antwort wird archiviert, bevor sie ausgewertet wird, gzip-komprimiert und nur, wenn sie sich geändert hat. Bei einem Prospekt ist das der Text, den der Parser gelesen hat, etwa 50 KB, nicht das PDF. So lässt sich ein besserer Parser mit npm run reparse über jede vergangene Woche laufen lassen. Auch die Bilder gehen über den Server, also bleibt die CSP der Seite bei img-src 'self', und die Bildserver der Läden sehen nie, wer schaut.
Ist das wirklich ein guter Preis?
Jeder vertrauenswürdige Preis kommt in eine Preisgeschichte, eine Zeile pro Produkt, Laden und Woche. Ein Angebot wird am Grundpreis gemessen, wo es einen hat, weil sich Packungsgrößen ändern, gegen bis zu 52 Wochen dieser Geschichte in jedem Laden, und das Urteil kommt mit seiner Begründung in einem Satz.
| Bestpreis | So günstig wie nie, bei mindestens drei Wochen Aufzeichnung. |
|---|---|
| Guter Preis | Im günstigsten Viertel seiner Geschichte, oder deutlich unter dem Preis, der daneben gedruckt ist. |
| Üblicher Preis | Nichts Besonderes. |
| Eher teuer | Im teuersten Viertel: ein Angebot, das keins ist. |
Solange ein Produkt keine Geschichte hat, zählt der Rabatt, den der Laden druckt. Über 60 % wird er ignoriert: So ein Rabatt ist fast immer an einer unverbindlichen Preisempfehlung des Herstellers gemessen.
Butter ist kein Butterkäse
Der Einkaufszettel, die Rezepte und die Merkliste stellen alle dieselbe Frage: Welche Angebote dieser Woche sind „Butter“? Jedes passt von selbst über sein Hauptwort, den letzten Teil eines Wortes oder eines Namens mit Bindestrich, innerhalb der Kategorie dieses Wortes. „Kerrygold Butter“ und „Joghurtbutter“ sind Butter; „Butterkäse“ und „Sahne-Heringsfilets“ nicht, und eine Dose Tomaten kommt nicht aus der Gemüsekiste.
Wo ein Treffer falsch ist, lernt die App mit einem Tipp: „Das ist es“, „Nicht das“ oder „Nur Kerrygold“ speichert ein Produkt unter diesem Wort, und jedes Rezept, jeder Eintrag auf dem Zettel und jeder Merkposten damit nutzt es ab dann. Prospektnamen ändern sich von Woche zu Woche, also wird das Bestätigte am Produktschlüssel des Angebots gespeichert, der eigenen ID der Quelle, wo sie eine hat, und nicht an seinem Wortlaut.
Rezepte, die die Woche kennen
Ein Rezept kommt aus einem Link, aus eingefügtem Text oder wird getippt. Fast jede Rezeptseite bettet ihr Rezept für Suchmaschinen als schema.org-Daten ein, also wird ein Link ohne Modell gelesen, samt Bild. Die Seite wird einmal geholt, auf Anfrage und nur von einer öffentlichen Adresse: Jede Weiterleitung wird neu geprüft, nichts im Heimnetz und nichts auf dem Server selbst ist erreichbar, und eine Seite darf höchstens 4 MB groß sein. Eingefügter Text geht an das Modell, wenn ein API-Schlüssel da ist, und sonst durch einfache Regeln.
Jede Zutat zeigt, wo sie diese Woche am günstigsten ist, über denselben Abgleich wie der Einkaufszettel. Ein Knopf setzt ein Rezept auf den Zettel, umgerechnet auf die gewählten Portionen; dieselbe Zutat aus zwei Rezepten wird eine Zeile, und Salz und Pfeffer bleiben draußen.
Hinter einer Anmeldung
Prospekt ist die App eines Haushalts, also steht ihre Adresse hinter Cloudflare Access, und der Link auf dieser Seite führt zu einer Anmeldung. Access signiert jede Anfrage, die es durchlässt, und der Server prüft diese Signatur selbst gegen die veröffentlichten Schlüssel des Teams, also nützt der Port auf dem NAS ohne Anmeldung nichts. Besuche zählt Census hinter derselben Prüfung, also zählen nur angemeldete Besuche.
Auf dem Telefon lässt sie sich auf den Home-Bildschirm legen und hält die letzte Woche, die Merkliste und den Einkaufszettel offline bereit, mit dem Abhaken als großem Ziel für einen Daumen.