Osobní hlídač zájezdů. Každé 2 hodiny stahuje nabídky z 16 českých cestovních zdrojů,
ukládá cenovou historii a počítá reálnou slevu: skutečný rozdíl proti vlastní
historii nabídky nebo proti trhu, ne přeškrtnutou cenu, kterou napíše zdroj. Když
nabídka splní prahy nastavené v config/watch.yaml, pošle zprávu na Telegram;
jednou denně navíc souhrnný digest top 10 nabídek.
| Zdroj | Co z něj bereme |
|---|---|
| Invia | ajax-boxes výpis last-minute karet, GA4 data atributy + JWT z detail linku (hotel, termín, strava, doprava) |
| Fischer | hydration → TourList/TourHotelList API; ceny za osobu, bez přeškrtnuté ceny (odloženo, viz níže) |
| Exim | getsearch HTML payload s cenami i přeškrtnutou cenou a dopočtenou slevou v % |
| Čedok | SSR výpis /last-minute/?order=priceAsc, přeškrtnutá cena na ~80 % karet |
| Blue Style | __NEXT_DATA__ JSON, cena po slevě + uváděné %; původní cena dopočtená |
| Zajezdy.cz | window.searchData JSON z sady destinačních stránek, jen v okně 08–24 h (robots) |
| Dovolena.cz | tripListing API (provozuje Student Agency); hotel-level ceny, bez konkrétních termínů |
| eTravel | getsearchresult API; jediný zdroj s oficiálním Omnibus 30denním minimem (lowestPrice) |
| Dovolenkovani.cz | CESYS white-label API (dates-list) — termíny, ceny za osobu i uváděné slevy; jména hotelů dopočtená ze sitemapy (accommodations.xml) |
| FIRO Travel | CESYS white-label API (dates-list, stejná továrna jako Dovolenkovani.cz) — exotické lety, agreguje mj. zájezdy Fischer CK |
| Alexandria | bck-new JSON API (web-search) — dotazy na exotické location id; cena za skupinu → dopočet na osobu, sleva z original_price |
| Deluxea | Nette data-json embedded v HTML — nabídky přímo ze stránky, bez API |
| ESO travel | SSR HTML listingy; 100% letecký operátor, karty bez markeru dopravy (transport unknown) |
| Adventura | okružní a expediční zájezdy (Nepál, Peru, Madagaskar aj.); část karet bez markeru dopravy |
| Datour | anchoice.cz white-label JSON API (web-search) — termíny za osobu (unit_price), agreguje 23k+ nabídek napříč CK |
| Slevomat | přímo neběží (Cloudflare) — nabídky bereme přes Skrz.cz, který je agreguje |
npm installcp .env.example .env- Založ Telegram bota:
- Otevři Telegram, najdi
@BotFather, pošli/newbot. - Zadej jméno bota a username (musí končit na
bot). - BotFather vrátí token — vlož ho do
.envjakoTELEGRAM_BOT_TOKEN=....
- Otevři Telegram, najdi
npm run telegram:setup— počká na první zprávu botovi (pošli mu cokoliv) a uložíTELEGRAM_CHAT_IDdo.envautomaticky.- Uprav
config/watch.yaml:- profily (
leto-more,last-minute, můžeš přidat další) — každý má vlastní filtry (země, měsíc odletu, strava, doprava) a vlastní práhmin_real_discount_pct. - prahy v
notificationsřídí, kdy se posílá zpráva o poklesu ceny (price_drop_pct), kdy se stejná nabídka připomene znovu (renotify_drop_pct,renotify_after_days) a v kolik hodin jde denní digest (digest_hour).
- profily (
npm run scan -- --dry-run— ověří, že zdroje odpovídají a config je platný; neposílá na Telegram ani neoznačuje zmizelé nabídky, ale nabídky a cenové snapshoty do DB zapisuje (sbírá historii).ops/install-launchd.sh— zaregistruje pravidelný běh (viz níže). Skript si spouští uživatel sám, až je připravený. Tenhle launchd běh z rezidenční IP je zároveň jediné pokrytí pro dovolenkovani, firo a zajezdy: cloudový scan (GitHub Actions,.github/workflows/scan.yml) je přesSCAN_EXCLUDE_SOURCESvynechává, protože z datacentrové IP je za 7 dní nedal ani jednou (0/55, 0/55, 0/56 úspěšných běhů do 2026-09-03).
| Příkaz | Co dělá |
|---|---|
npm run scan |
proběhne všemi zdroji, zapíše do DB, pošle notifikace/digest |
npm run scan -- --source=invia |
jen jeden zdroj (jméno viz src/sources/index.ts) |
npm run scan -- --exclude=firo,zajezdy |
všechny (nebo --source= vybrané) zdroje kromě vyjmenovaných; totéž přes env SCAN_EXCLUDE_SOURCES — takhle cloud vynechává dovolenkovani, firo a zajezdy |
npm run scan -- --dry-run |
neposílá zprávy, nezapisuje notifikace a neoznačuje zmizelé nabídky; nabídky a cenové snapshoty do DB ukládá (sbírá historii) |
npm run scan -- --no-notify |
zapíše do DB, ale neposílá na Telegram |
npm run digest |
ruční vyvolání denního digestu (mimo běžný rozvrh) |
npm run telegram:setup |
zjistí a uloží TELEGRAM_CHAT_ID |
npm test |
spustí testy (vitest) |
npm run typecheck |
tsc --noEmit |
npm run db:push |
promítne Drizzle schéma do SQLite |
Pro každou nabídku se hledá referenční cena, od nejsilnější k nejslabší: vlastní historie nabídky (medián cen ze snapshotů za posledních 30 dní, pokud existují aspoň 3 s rozpětím ≥5 dní), pak Omnibus 30denní minimum (jen u eTravel), a nakonec tržní medián — cena aktivních nabídek ve stejném koši (země × měsíc odletu × pásmo nocí × strava × hvězdy), pokud v koši je aspoň 8 nabídek napříč zdroji.
realDiscountPct je rozdíl mezi referenční cenou a aktuální cenou. Když uváděná
sleva (ta na přeškrtnuté ceně) převyšuje reálnou slevu o 15 procentních bodů a víc,
nabídka dostane flag
Prvních ~14 dní provozu žádná reference není (databáze se teprve plní historií). V tomto období notifikace uvádí jen deklarovanou slevu s poznámkou, že reálná se zatím sbírá.
-
Vytvoř
src/sources/<jmeno>.tss exportem, který splňujeSourceAdapterzsrc/core/types.ts:export interface SourceAdapter { name: string; fetchOffers(ctx: SourceContext): Promise<NormalizedOffer[]>; }
fetchOffersdostanectx.http(rate-limitovaný fetch wrapper),ctx.adultsactx.log; musí vrátit poleNormalizedOffer(viz stejný soubor pro tvar). Chyby zdroje nechej probublat —runScanprochází zdroje sekvenčně a každý izoluje v samostatném try/catch (pád jednoho zdroje neshodí ostatní). KdyžfetchOffersvrátí prázdné pole, zdroj se označí jakopartialamarkMissedOffersse pro něj přeskočí (nula nabídek nikdy neznamená „trh je prázdný"). -
Ulož reálnou odpověď zdroje (HTML/JSON) do
tests/fixtures/<jmeno>/a napištests/<jmeno>.test.ts, který z fixture vyparsuje očekávané nabídky — vzor viztests/cedok.test.tsnebo kterýkoliv jiný existující test zdroje. -
Zaregistruj adaptér v
src/sources/index.ts(přidej do importu a do poleadapters).
Pokud zdroj sdílí platformu s DER Touristik (Fischer/Exim/eTravel), použij společný
helper src/sources/der.ts místo psaní parseru od nuly.
- eTravel Omnibus — pole
lowestPriceje v živých datech zatím konzistentněnull(ověřeno na stovkách vzorků); jakmile se začne plnit, reálná sleva pro eTravel automaticky přejde na tuto referenci. - Fischer, Dovolena.cz — bez přeškrtnuté/původní ceny v datech zdroje; reálná sleva se u nich počítá jen z vlastní historie nebo tržního mediánu, nikdy z uváděné slevy.
- Dovolena.cz — nabídky jsou hotel-level, ne term-level;
departureDateanightsjsou vždynull(zdroj termíny na detail nabídky nevystavuje). - Zajezdy.cz — scan respektuje robots.txt okno 08:00–24:00 Europe/Prague; běhy mimo toto okno zdroj přeskočí beze zpracování.
- Čedok — pokrytí jde přes
order=priceAscna hlavním/last-minute/výpisu; country-specific podcesty (/last-minute/recko/apod.) jsou kandidát na rozšíření pokrytí, zatím nejsou zapojené. - Slevomat — přímý adaptér vyžaduje headless prohlížeč kvůli Cloudflare managed challenge; odloženo. Ve v1 se jeho nabídky berou přes Skrz.cz.
- Cross-source dedup — stejný hotel/termín u více zdrojů se ve v1 eviduje jako samostatné nabídky, bez sloučení.
- Vercel deploy — hotový ve fázi 2 (serverless
api/+ Turso + cron-job.org), postup viz sekce „Nasazení" níže. Live smoke test na reálném Vercel účtu (libSQL v serverless runtime, nested/apirouting) je poslední krok, který provádí uživatel dle runbooku. - Telegram příkazy (
/top,/pause), web UI, více uživatelů — mimo scope v1.
Lokální webový přehled nabídek — odletová tabule s reálnou slevou, cenový graf v detailu řádku a dvě souhrnné karty (TRH DNES, ZDROJE). Čte přímo ze stejné SQLite DB, kterou plní scan; sám nic nestahuje ani neposílá (read-only, spec §14).
Spuštění (produkční režim):
npm run web:build— sestaví frontend doweb/dist(vite build vweb/).npm run web— nastartuje Hono server na portu 4141; obsluhuje API (/api/offers,/api/offers/:id/history,/api/sources,/api/stats) i sestavený SPA ze stejného originu. Port lze přepsat přesPORT. Server poslouchá jen na127.0.0.1(localhost), ne na všech rozhraních; přepsat lze přesHOST.- Otevři
http://localhost:4141.
Když web/dist neexistuje, server místo SPA vrátí textovou hlášku „spusť
npm run web:build", takže chybějící build selže hlasitě, ne 404.
Vývoj frontendu: npm run web:dev spustí Vite dev server (port 5173)
s hot-reloadem; ten proxuje /api na :4141, takže vedle sebe běží
npm run web (API) a npm run web:dev (UI) bez CORS.
E2E smoke: npm run test:e2e (Playwright, chromium). Config
playwright.config.ts si sám sestaví frontend, naseeduje throwaway SQLite DB
(tests/e2e/seed.ts, deterministická data přes reálný ingest pipeline),
nastartuje server a projede board render, filtry (země/profil), rozbalení
detailu s grafem, cross-source alternativy a stav karet — plus kontrolu, že
během běhu nespadne žádná console chyba. Prohlížeč se instaluje jednorázově:
npx playwright install chromium. Root unit testy (npm test) e2e adresář
nesbírají (jiný runner).
Design tokeny a pravidla copy jsou v design-system/MASTER.md, referenční
mockup v docs/design/terminal-mockup.html.
Stav 2026-09: scan dnes běží přes GitHub Actions (
.github/workflows/scan.yml, každé 2 h,SCAN_EXCLUDE_SOURCESvynechává dovolenkovani, firo a zajezdy) plus Mac launchd fallback. Routeapi/cron/scan.tsa cron-job.org níže jsou záložní varianta — ta exclude nečte, takže kdyby ses k ní vracel, nastav ho i tam.
Fáze 2: dashboard „Terminál" jako statické SPA na Vercelu, API + scan jako
serverless funkce (api/), data v Turso (libSQL v cloudu),
scan spouští externí cron (cron-job.org) přes chráněnou
route. Lokální provoz (SQLite soubor + launchd) tím není dotčen — je to jen jiný
DATABASE_URL.
Co se nasazuje:
api/index.ts— dashboard API (Hono přeshono/vercel),maxDuration30 s. Rewrites vvercel.jsonsměrují/api/*sem, takže všechny nested routy (/api/offers,/api/offers/:id/history,/api/sources,/api/stats,/api/exclusions) obsluhuje jedna funkce.api/cron/scan.ts— scan route,maxDuration300 s, chráněná Bearer tokenem (CRON_SECRET). Filesystem-exact match/api/cron/scanse uplatní před rewrites, takže ji/api/*rewrite nezastíní.web/dist— sestavený frontend (buildnpm run web:build).
turso auth login
turso db create dovolenky
turso db show dovolenky --url # → libsql://dovolenky-<org>.turso.io
turso db tokens create dovolenky # → auth token (dlouhý JWT)Prázdné schéma se vytvoří samo při prvním requestu (bootstrap() volá
ensureSchema). Historii cen si DB naplní až běhy scanu.
Náhodný tajný klíč, kterým se autorizuje scan route:
openssl rand -hex 32npm i -g vercel
vercel login
vercel link # v rootu repa; framework „Other", build/output bere z vercel.jsonNastav 5 proměnných prostředí (Production, případně i Preview pro smoke test):
vercel env add DATABASE_URL # libsql://…turso.io z kroku 1
vercel env add DATABASE_AUTH_TOKEN # token z kroku 1
vercel env add TELEGRAM_BOT_TOKEN # stejný jako lokálně
vercel env add TELEGRAM_CHAT_ID # stejný jako lokálně
vercel env add CRON_SECRET # hex z kroku 2(Seznam viz .env.example. Nikdy je necommituj — .env je v .gitignore.)
Checkpoint: Fluid Compute. Ověř, že je zapnutý Fluid Compute (Vercel →
Project → Settings → Functions, sekce Fluid Compute) — na Hobby (free)
plánu teprve ten zvedá maxDuration na 300 s (výchozí i maximum). Pro nové
projekty je zapnutý automaticky (default od 23. dubna 2025), ale starší nebo
ručně přenastavený projekt může být na starém 60s stropu — tam by ~90s scan
skončil timeoutem dřív, než doběhne. Pokud toggle chybí nebo je vypnutý,
zapni ho, ulož a nasaď znovu.
Checkpoint: Node verze. Ověř, že Vercel funkce běží na Node 24.x
(Settings → Build and Deployment → Node.js Version) — repo má
engines.node: ">=24" v package.json, což odpovídá aktuálnímu Vercel
defaultu (24.x je aktuální výchozí LTS). Pokud dashboard ukazuje starší
verzi, připni ji explicitně: "node": "24.x" v engines v package.json
(přebije volbu v Project Settings).
Nejdřív preview (ne produkci), ať ověříš, že libSQL i nested routing na Vercelu fungují:
vercel # vytvoří preview URL, vypíše ji na konci
curl -s https://<preview>.vercel.app/api/sources | head -c 300
curl -s https://<preview>.vercel.app/api/offers | head -c 300Obojí má vrátit JSON (klidně prázdné {"sources":[]} / {"offers":[]} u čerstvé
DB), ne 500. Tím je potvrzené, že se @libsql/client v serverless runtime načte
a že rewrite /api/* → /api/index doručuje na správné nested routy.
Fallback, když /api/offers hodí 500 (nativní @libsql/client se v runtime
nenačte): přepni v src/core/db/index.ts import z @libsql/client na
@libsql/client/web a nasaď znovu. Turso se čte přes HTTPS, takže HTTP-only web
klient stačí; jediný konzument lokální file: DB je CLI dev, který pak drž na
node klientovi (např. samostatným openDb pro web build).
curl -s -H "Authorization: Bearer <CRON_SECRET>" \
https://<preview>.vercel.app/api/cron/scanBez hlavičky (nebo se špatným tokenem) musí vrátit 401 {"error":"unauthorized"}.
Se správným tokenem proběhne plný scan (concurrent fetch, ~do 300 s) a vrátí
ScanSummary JSON.
vercel --prodNa cron-job.org založ job:
- URL:
https://<projekt>.vercel.app/api/cron/scan - Custom header:
Authorization: Bearer <CRON_SECRET>(NE query parametr — ten by unikl do logů) - Rozvrh:
0 6-23/2 * * *, timezone Europe/Prague (každé 2 h mezi 6–24 h; respektuje robots.txt okno Zajezdy.cz 08–24 h) - Timeout: ať pokrývá 300 s běh funkce
Když scan běží na Vercelu, lokální launchd job už není potřeba:
launchctl unload ~/Library/LaunchAgents/<label>.plistLokální dashboard (npm run web) a CLI (npm run scan) fungují dál beze změny —
míří na lokální SQLite podle DATABASE_URL v .env.
Po vercel --prod je SPA i celé /api/* veřejně dostupné komukoliv na
internetu — Vercel projekt sám přístup nijak neomezuje. GET endpointy
(/api/offers, /api/offers/:id/history, /api/sources, /api/stats,
/api/exclusions GET) vrací jen veřejná data o zájezdech, žádné přihlašovací
údaje ani interní detaily — samy o sobě problém nejsou. Jediné mutující
místo je PUT /api/exclusions: kdokoliv zná URL, může bez ověření
přepsat seznam vyloučených zemí, který krmí scan/notify pipeline (mute
Telegramu i board). /api/cron/scan je pro srovnání už chráněná vlastním
CRON_SECRET Bearer tokenem nezávisle na tomhle (krok 5–6) — týká se to
čistě dashboardu.
Doporučená mitigace: Vercel Authentication + Protection Bypass for Automation
- Project → Settings → Deployment Protection → sekce Vercel Authentication → zapni toggle, zvol prostředí (Production i Preview), Save. Nepřihlášený návštěvník dostane Vercel login redirect místo obsahu.
- Na stejné stránce, sekce Protection Bypass for Automation → vygeneruj
secret (Vercel ho zároveň sám nastaví jako env proměnnou
VERCEL_AUTOMATION_BYPASS_SECRET). - Na cron-job.org nastav k existující hlavičce obě:
Authorization: Bearer <CRON_SECRET>(aplikační auth pro/api/cron/scan, krok 6) ax-vercel-protection-bypass: <secret z kroku 2>(Vercel edge auth — bez ní by Vercel request zastavil dřív, než se dostane do route handleru).
Ověřené omezení (vercel.com/docs, červenec 2026): na Hobby plánu tohle
nezakryje produkční doménu. Jediný „protection scope" dostupný na Hobby je
Standard Protection — ten chrání preview deploye a jednotlivé (hash)
deployment URL, ale explicitně ne produkční doménu
(<projekt>.vercel.app nebo vlastní doména), která zůstává veřejná i se
zapnutou Vercel Authentication. Scope All Deployments, který zakryje i
produkci, je jen na Pro/Enterprise (Pro $20/měsíc). Jinými slovy: na Hobby
tenhle krok ochrání preview URL (užitečné, krok 4 preview vytváří), ale
PUT /api/exclusions na ostré doméně zůstane veřejně volatelný i po něm.
Alternativa (a na Hobby reálně jediná plná ochrana pro tenhle konkrétní endpoint): přijmi nízké riziko. URL je obskurní, mutace jen přepíná filtr zemí — žádná citlivá data neuniknou ani se neztratí, nejhorší dopad je „někdo mi dočasně přepne pár zemí ve filtru" — a nech dashboard veřejný.
Doporučení pro tenhle osobní deploy: zapni Vercel Authentication (kroky
1–3) jako defense-in-depth zdarma navíc — nic to nestojí a ochrání aspoň
preview URL ze smoke testu (krok 4). Pro PUT /api/exclusions na produkci
ale realisticky přijmi variantu „nízké riziko", pokud nechceš platit Pro
plán jen kvůli jednomu low-stakes endpointu.