Skip to content

Repository files navigation

Dovolenky

Co to je

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.

Zdroje

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

Setup krok za krokem

  1. npm install
  2. cp .env.example .env
  3. Založ Telegram bota:
    1. Otevři Telegram, najdi @BotFather, pošli /newbot.
    2. Zadej jméno bota a username (musí končit na bot).
    3. BotFather vrátí token — vlož ho do .env jako TELEGRAM_BOT_TOKEN=....
  4. npm run telegram:setup — počká na první zprávu botovi (pošli mu cokoliv) a uloží TELEGRAM_CHAT_ID do .env automaticky.
  5. 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áh min_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).
  6. 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).
  7. 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řes SCAN_EXCLUDE_SOURCES vynechá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říkazy

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

Jak to počítá reálnou slevu

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 ⚠️ „nadsazená sleva". Zdroj tím slevu nadhodnocuje vůči tomu, za co se termín reálně prodává.

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á.

Jak přidat zdroj

  1. Vytvoř src/sources/<jmeno>.ts s exportem, který splňuje SourceAdapter z src/core/types.ts:

    export interface SourceAdapter {
      name: string;
      fetchOffers(ctx: SourceContext): Promise<NormalizedOffer[]>;
    }

    fetchOffers dostane ctx.http (rate-limitovaný fetch wrapper), ctx.adults a ctx.log; musí vrátit pole NormalizedOffer (viz stejný soubor pro tvar). Chyby zdroje nechej probublat — runScan prochází zdroje sekvenčně a každý izoluje v samostatném try/catch (pád jednoho zdroje neshodí ostatní). Když fetchOffers vrátí prázdné pole, zdroj se označí jako partial a markMissedOffers se pro něj přeskočí (nula nabídek nikdy neznamená „trh je prázdný").

  2. 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 viz tests/cedok.test.ts nebo kterýkoliv jiný existující test zdroje.

  3. Zaregistruj adaptér v src/sources/index.ts (přidej do importu a do pole adapters).

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.

Známá omezení & backlog

  • eTravel Omnibus — pole lowestPrice je 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; departureDate a nights jsou vždy null (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=priceAsc na 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 /api routing) je poslední krok, který provádí uživatel dle runbooku.
  • Telegram příkazy (/top, /pause), web UI, více uživatelů — mimo scope v1.

Dashboard „Terminál"

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):

  1. npm run web:build — sestaví frontend do web/dist (vite build v web/).
  2. 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řes PORT. Server poslouchá jen na 127.0.0.1 (localhost), ne na všech rozhraních; přepsat lze přes HOST.
  3. 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.

Nasazení (Vercel + cron-job.org + Turso)

Stav 2026-09: scan dnes běží přes GitHub Actions (.github/workflows/scan.yml, každé 2 h, SCAN_EXCLUDE_SOURCES vynechává dovolenkovani, firo a zajezdy) plus Mac launchd fallback. Route api/cron/scan.ts a 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řes hono/vercel), maxDuration 30 s. Rewrites v vercel.json smě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, maxDuration 300 s, chráněná Bearer tokenem (CRON_SECRET). Filesystem-exact match /api/cron/scan se uplatní před rewrites, takže ji /api/* rewrite nezastíní.
  • web/dist — sestavený frontend (build npm run web:build).

1. Turso databáze

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.

2. CRON_SECRET

Náhodný tajný klíč, kterým se autorizuje scan route:

openssl rand -hex 32

3. Vercel projekt + env

npm i -g vercel
vercel login
vercel link                            # v rootu repa; framework „Other", build/output bere z vercel.json

Nastav 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).

4. Preview deploy + smoke test

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 300

Obojí 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).

5. Ruční test scan route

curl -s -H "Authorization: Bearer <CRON_SECRET>" \
  https://<preview>.vercel.app/api/cron/scan

Bez 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.

6. Produkce + cron

vercel --prod

Na 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

7. Vypnout lokální launchd (volitelné)

Když scan běží na Vercelu, lokální launchd job už není potřeba:

launchctl unload ~/Library/LaunchAgents/<label>.plist

Loká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.

8. Zabezpečení veřejného dashboardu

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

  1. 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.
  2. 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).
  3. Na cron-job.org nastav k existující hlavičce obě: Authorization: Bearer <CRON_SECRET> (aplikační auth pro /api/cron/scan, krok 6) a x-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.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages