Tracks the maximum fuel prices (Natural 95 gasoline, diesel) published by the Czech Ministry of Finance in the Cenový věstník, and shows the current values plus a 30-day history chart.
Next.js (App Router) app deployed on Vercel with Neon Postgres for storage and Vercel Cron for scheduled scraping.
- Next.js 14 + TypeScript + Tailwind
- Neon Postgres (
@neondatabase/serverless) axios,pdf-parse,cheerio- Recharts (history chart)
- pino (logging)
- Vitest (unit tests)
- Vercel Analytics
npm install
npm run devYou need a DATABASE_URL pointing to a Neon Postgres database (set in .env or via vercel env pull).
Open http://localhost:3000. Initially the page shows "no data" — trigger a scrape:
curl -X POST -H "x-admin-token: change-me" http://localhost:3000/api/refreshRun once to create the prices table:
npm run db:migrate| Method | Path | Description |
|---|---|---|
| GET | /api/latest |
Most recent stored prices (404 if empty) |
| GET | /api/history |
Last 30 records, newest first |
| POST | /api/refresh |
Manually trigger a scrape (admin token req.) |
| GET | /api/cron/scrape |
Vercel Cron endpoint (requires CRON_SECRET auth) |
A single-file JavaScript widget (public/widget.js) can be dropped into any website to display the current fuel price cap. No build step, no dependencies, Shadow-DOM-isolated so it won't clash with host-page CSS.
Drop this anywhere in your HTML — the widget auto-mounts at the end of <body>:
<script src="https://zakolikdnes.cz/widget.js"></script>Control theme and language:
<script src="https://zakolikdnes.cz/widget.js"
data-theme="dark"
data-lang="cs"></script>Mount into a specific element instead of auto-appending:
<div id="fuel-cap-widget"></div>
<script src="https://zakolikdnes.cz/widget.js" data-theme="dark"></script>All attributes go on the <script> tag (or on the host element, which takes precedence — useful when the script is injected dynamically):
| Attribute | Values | Default | Description |
|---|---|---|---|
data-theme |
light | dark |
light |
Colour scheme. |
data-lang |
en | cs |
en |
UI labels, number and date formatting. |
data-target |
CSS selector | — | Explicit mount target. Overrides #fuel-cap-widget. |
data-api |
URL | production | API base override (for local testing or self-hosting). |
- Fetches
/api/latest(3 s timeout) and rendersGasoline/Diesel/Updatedin an isolated Shadow DOM. - Adds a ↑ ↓ — trend indicator by diffing against
/api/history[1](best-effort; widget still renders if the history call fails). - Animates the numbers on load (respects
prefers-reduced-motion). - Shows
Data unavailableunder the title on any fetch / validation failure.
data-targetselector (on the script), if it resolves to one or more elements.- Every
.fuel-cap-widgeton the page (multi-instance). #fuel-cap-widget, if present.- A fresh
<div>appended to<body>.
/embed renders all four theme × lang variants side-by-side with copy-paste snippets.
npm testCovers the text-level extractor, validation rules (range, NaN, bad date) and a malformed input case that must not produce false positives.
Vercel Cron for /api/cron/scrape is disabled (empty crons in vercel.json) after the Ministry of Finance ended fuel price regulation on 19 July 2026. The scrape endpoint remains available for manual use via /api/refresh if needed.
| Var | Purpose |
|---|---|
DATABASE_URL |
Neon Postgres connection string |
SOURCE_URL |
Index page to discover bulletins |
ADMIN_TOKEN |
Header token for /api/refresh |
CRON_SECRET |
Vercel Cron authentication secret |
src/
app/ Next.js App Router (UI + API routes)
components/ PriceCard, PriceChart, RefreshButton
lib/ config, db, date, http, logger
scraper/ source discovery, PDF/HTML parsing, validation, orchestrator
scripts/
migrate.ts One-time schema bootstrap
- There is no official API for the bulletin — the scraper picks the newest "Cenový věstník" link from the configured index page, prefers the PDF, and falls back to HTML.
- Parsing uses regex/heuristics tolerant to whitespace and Czech diacritics. Validation enforces a 20–100 CZK/l range; out-of-range values are rejected and never written.
- Prices are de-duplicated by
bulletin_id(primary key +ON CONFLICT DO NOTHING).effective_datehas a unique constraint as well.