尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证正在加载完整 README。 以下为已保存的摘录。
Aplikace, která pravidelně prohledává vybrané autobazary a prodejce ojetin, sjednocuje nalezené inzeráty do jednoho přehledu, hlídá historii ceny, odstraňuje duplicity napříč zdroji a posílá e-mailem upozornění na nové nabídky, které odpovídají uloženým hledáním. Návrh je popsán v DESIGN.md, tento README popisuje výslednou implementaci. Celé řešení běží na free tierech: Supabase (Postgres + Auth), Vercel Hobby (web) a GitHub Actions na veřejném repozitáři (scraper).查看完整 README ↗
Aplikace, která pravidelně prohledává vybrané autobazary a prodejce ojetin,
sjednocuje nalezené inzeráty do jednoho přehledu, hlídá historii ceny,
odstraňuje duplicity napříč zdroji a posílá e-mailem upozornění na nové
nabídky, které odpovídají uloženým hledáním. Návrh je popsán v
DESIGN.md, tento README popisuje výslednou implementaci.
Celé řešení běží na free tierech: Supabase (Postgres + Auth), Vercel Hobby (web) a GitHub Actions na veřejném repozitáři (scraper).
scrapping_auta/
├── apps/web/ Next.js 15 (App Router) → Vercel
│ ├── app/ přihlášení, hledání, výsledky, detail, stav zdrojů
│ ├── components/ sdílené UI komponenty
│ └── lib/supabase/ klienti pro Supabase (browser/server) + middleware
├── packages/core/ typy, zod schémata, normalizace, matcher, dedup
├── packages/scrapers/ HTTP klient, adaptéry zdrojů, runner, notifikace
├── supabase/migrations/ SQL schéma + RLS politiky + seed zdrojů
└── .github/workflows/ ci.yml (lint/typecheck/test/build), scrape.yml (cron)
searches/matches; listings, price_history,
sources, scrape_runs, exchange_rates jsou pro přihlášené uživatele
jen ke čtení — zapisuje do nich pouze scraper přes service_role klíč
(RLS pro service_role neplatí, takže žádné insert/update politiky pro tyto
tabulky nejsou potřeba).@supabase/ssr), middleware chrání všechny stránky kromě /login a
/auth/callback.0 5,11,17 * * * =
3× denně v 7/13/19 letního času + ruční spuštění s volitelným source
inputem). Jeden job spustí všechny zapnuté zdroje souběžně v procesu — pád
jednoho zdroje nezastaví ostatní, chyby se zapisují do scrape_runs. Běh
3× denně nijak nezvyšuje počet e-mailů/pushů — ty se posílají jen když
opravdu existuje něco nového k nahlášení (notified_at is null, viz
„Runner“ níže), nejvýš tedy 3 digesty denně a jen v dnech, kdy je co hlásit.fetch, žádné SDK) + volitelný
Web Push do nainstalované PWA (viz „Web Push“ níže). Posílá se souhrnný
digest (HTML + textová záloha) s kartičkami aut po jednotlivých hledáních
a sekcí „Oblíbené: zlevnění / prodáno“, max. ~20 aut na hledání v jednom
e-mailu. Bez RESEND_API_KEY/NOTIFY_EMAIL_TO se e-mail tiše přeskočí
(jen se zaloguje); bez VAPID klíčů v app_secrets se push tiše přeskočí
stejně tak.Vyžaduje Node 22 a pnpm 10.
pnpm install
pnpm lint && pnpm typecheck && pnpm test && pnpm build
apps/web)cp apps/web/.env.example apps/web/.env.local
# doplňte NEXT_PUBLIC_SUPABASE_URL a NEXT_PUBLIC_SUPABASE_ANON_KEY
pnpm --filter @scrapping-auta/web dev
Bez nastavených proměnných prostředí web nadále projde next build (build je
staticky negenerovaný, stránky běží dynamicky) a zobrazí uživateli hlášku „Aplikace
není nakonfigurovaná“ místo pádu.
packages/scrapers)cp .env.example .env
# doplňte SUPABASE_URL a SUPABASE_SERVICE_ROLE_KEY (volitelně i Resend proměnné)
pnpm scrape # plný běh, zapisuje do Supabase
pnpm scrape -- --dry-run # jen vypíše nalezené inzeráty, nic nezapisuje
pnpm scrape -- --source=sauto # jen jeden zdroj
Bez SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY skript skončí s exit code 0 a
varováním (stejné chování jako v GitHub Actions, dokud nejsou nastavené
secrets).
Projekt (lhzfrzfutlzqysyuldtw, region eu-central-1) už existuje. Migrace v
supabase/migrations/ je potřeba aplikovat (Supabase CLI nebo Dashboard →
SQL editor, v pořadí podle názvu souboru):
20260928120000_init.sql — tabulky, indexy, RLS politiky20260928120100_seed_sources.sql — počáteční seznam zdrojůsupabase/migrations/ podle názvu
(data/čas v názvu = pořadí), včetně 20260928220000_favorites.sql
(tabulka favorites, viz „Oblíbené“) a 20260928230000_gone_listings.sql
(sloupec listings.gone_at, viz „Smazané inzeráty“)V Supabase Auth zapněte metodu Email OTP / Magic Link (výchozí nastavení)
a nastavte Site URL a Redirect URLs na adresu nasazeného webu
(https://<vase-vercel-domena>/auth/callback).
TypeScript typy pro databázi jsou ručně napsané v
packages/core/src/database.types.ts podle migrace výše. Po aplikaci migrace
je možné je nahradit vygenerovanými typy:
supabase gen types typescript --project-id lhzfrzfutlzqysyuldtw > packages/core/src/database.types.ts
apps/web.NEXT_PUBLIC_SUPABASE_URLNEXT_PUBLIC_SUPABASE_ANON_KEYGITHUB_DISPATCH_TOKEN, GITHUB_REPO, GITHUB_REF — viz
„Ruční spuštění scraperu z webu“ níže; bez nich web dál funguje, jen
tlačítko „Spustit scraping“ zůstane neaktivní./api/scrape) pro ruční spuštění
scraperu, kromě /auth/callback.Stránka „Zdroje“ (a přehled) má tlačítko Spustit scraping, které přes
server-side route apps/web/app/api/scrape/route.ts zavolá GitHub REST API
(POST /repos/{repo}/actions/workflows/scrape.yml/dispatches) a spustí
workflow scrape.yml mimo pravidelný cron. Route vyžaduje přihlášeného
uživatele (ověřeno přes Supabase session na serveru — middleware cestu
/api/* nechrání, takže kontrola je uvnitř route) a token se nikdy neposílá
do prohlížeče.
Nastavení:
pmoravek188-bit/scrapping_auta:
Actions → Read and write, Metadata → Read
(přidá se automaticky)GITHUB_DISPATCH_TOKEN — vygenerovaný token z kroku 1 (server-only,
ne NEXT_PUBLIC_*)GITHUB_REPO — výchozí pmoravek188-bit/scrapping_auta, není nutné
nastavovat, pokud sedíGITHUB_REF — větev, na které se má scrape.yml spustit (výchozí
claude/car-search-app-y4b753)scrape.yml má concurrency: group: scrape,
takže se souběžné běhy nespouští — nový poběží až po dokončení předchozího.V Settings → Secrets and variables → Actions repozitáře nastavte:
| Secret | Povinný | Popis |
|---|---|---|
SUPABASE_URL | ano | URL Supabase projektu |
SUPABASE_SERVICE_ROLE_KEY | ano | service_role klíč (obchází RLS, nikdy nedávat do webu!) |
RESEND_API_KEY | ne | API klíč z resend.com — bez něj se e-maily neposílají |
NOTIFY_EMAIL_TO | ne | e-mail, na který chodí souhrn nových nabídek |
NOTIFY_EMAIL_FROM | ne | odchozí adresa, výchozí Scrapping auta <[email protected]> |
Poznámka k Resend free tieru: bez ověřené vlastní domény umí Resend
posílat pouze z adresy [email protected] a pouze na e-mail majitele
Resend účtu. Pro víc příjemců nebo vlastní odesílací adresu je potřeba
zdarma ověřit vlastní doménu v Resendu.
Workflow scrape.yml běží jednou denně v 05:00 UTC (workflow_dispatch navíc
umožňuje ruční spuštění, volitelně jen pro jeden zdroj) a zároveň drží
Supabase free projekt aktivní (jinak po týdnu nečinnosti pauzuje).
Všech 10 adaptérů bylo ověřeno proti živému webu 2026-09-28 (curl s
reálným Chrome User-Agentem a Accept-Language: cs-CZ). Detailní poznámky
k tomu, co bylo na daném webu potvrzeno (přesný tvar URL/API, pole v
odpovědi, co se nepodařilo ověřit) jsou v komentáři na začátku každého
souboru packages/scrapers/src/sources/<id>.ts. Parsování zůstává
defenzivní (zod safeParse / tolerantní selektory, tiché přeskočení
nevalidní položky, log počtu nalezených inzerátů) a otestované proti
fixturám (reálné, ořezané ukázky uložené z živého webu) v
packages/scrapers/test/fixtures/.
| Zdroj | Stav | Zdroj dat / server-side filtry | Poznámka |
|---|---|---|---|
sauto | zapnuto, ověřeno | JSON API sauto.cz/api/v1/items/search?category_id=838; filtry: značka/model, cena, nájezd, rok, palivo, převodovka, částečně karoserie, výkon | power (kW) skoro nikdy není na výpisu, jen na detailu — powerKw bývá null |
bazos | zapnuto, ověřeno | HTML auto.bazos.cz; rubrika podle značky (/skoda/, …) = filtr jen na osobní auta, + hledat/cenaod/cenado, stránkování crp | rok/km/palivo/převodovka se parsují z volného textu titulku+popisu — u stručných inzerátů (bez roku/km v textu) zůstanou null, hledání s filtrem na rok/km takové inzeráty přeskočí |
tipcars | zapnuto, ověřeno | HTML tipcars.com/ojete/<značka>-<model> (JSON-LD ItemList); cena/rok/km filtry na serveru nefungují (jen klientský matcher) | rok/km/výkon se čtou z data-measure-data-value atributu karty, ne z JSON-LD |
carvago | zapnuto, ověřeno | JSON v __NEXT_DATA__ na carvago.com/cs/auta/<značka>/<model>; filtry: cena, nájezd, rok registrace, výkon, palivo/převodovka/karoserie (přes tag const_key) | výhradně dealeři (import ze zahraničí) |
dasweltauto | zapnuto, ověřeno | JSON API dasweltauto.cz/api/locales/cs_CZ/vehicles/search/; filtry: značka (brands), cena, rok registrace, nájezd | — |
aaaauto | zapnuto, ověřeno | JSON-LD @graph/ItemList na aaaauto.cz/ojete-vozy/<značka>/<model>; filtry: cena, rok, nájezd (jen 2 hodnoty karoserie ověřeny) | výhradně dealer AAA AUTO |
havex | nově zapnuto touto migrací, ověřeno | HTML havex.cz/cz/ojete-vozy-<skoda|seat|cupra>; filtr jen značka (přes cestu), cena/rok/km jen klientský matcher | prodává jen Škoda/Seat/Cupra |
autoesa | nově zapnuto touto migrací, ověřeno |
Zdroje se zapínají/vypínají v tabulce sources (sloupec enabled).
packages/scrapers/src/sources/<id>.ts implementující rozhraní
SourceAdapter z packages/scrapers/src/adapter.ts (metoda
search(query, ctx) vracející RawListing[]). Pro jednoduché HTML
zdroje lze použít makeGenericHtmlAdapter z sources/generic-html.ts.packages/scrapers/test/fixtures/ a test parsování v
packages/scrapers/test/<id>.test.ts (bez síťového přístupu).packages/scrapers/src/registry.ts.supabase/migrations/ (nová migrace, ne úprava staré) s
insert into public.sources (id, name, enabled) values (...).pnpm lint && pnpm typecheck && pnpm test musí projít.packages/core)make-model.ts, alias tabulka
"škoda"→"skoda", "vw"→"volkswagen", …), palivo/převodovka/karoserie
(enums.ts), převod ceny na CZK podle kurzu ČNB (currency.ts).fingerprint.ts): VIN, pokud je k dispozici, jinak
značka+model+rok+nájezd (zaokrouhleno na 1000 km)+výkon. Stejný
fingerprint napříč zdroji → stejné group_id, ve výsledcích se ukazuje
jako jedno auto s „více nabídkami“.matcher.ts): rozhoduje, jestli normalizovaný inzerát
odpovídá uloženému hledání — používá ho jak runner (nezávisle na tom, co
zdroj uměl filtrovat na serveru), tak testy. Chybějící rok (year: null)
u filtru na rok je normálně zamítnutí — výjimka je bazos
(YEAR_NULL_LENIENT_SOURCES), protože tam rok často vůbec není na
výpisu a dá se jen odhadnout regexem z textu; u ostatních zdrojů chybějící
rok dál strict selže. Runner navíc pro tipcars (nemá žádný
server-side filtr na rok, viz komentář v sources/tipcars.ts) zvedá
limit stránek na 10 (místo sdíleného výchozího 5), když má hledání
yearFrom/yearTo — jinak by starší/novější shody mohly být za
hranicí výchozího limitu stránek./results): scope, okamžitý rematch, „NOVÉ“/results má přepínač scope nahoře:
public.listings (všechny
aktivní inzeráty), bez ohledu na to, jestli mají uložené hledání.<název>“ — jedna záložka na každé uložené hledání,
ukazuje jen jeho matches (se stavem různým od hidden).Obě scope běží přes jednu security-invoker RPC funkci
public.search_listings (viz migrace
supabase/migrations/20260928200000_results_rework.sql), která v jednom
dotazu:
price_czk/year/mileage_km/power_kw
rozsahy, fuel/body/transmission/sources/drive seznamy —
fuel/body/transmission jsou null-tolerantní stejně jako
packages/core's matchesSearch, drive je striktní),
a pro chip filtry "Výbava"/"Verze" jen OR-ILIKE prefiltr (viz níže),group_id (jedna karta na skupinu, nejlevnější nabídka,
počet ostatních nabídek jako group_offer_count → badge „více nabídek
(N)“),is_new (viz sekce „NOVÉ“ níže),?page=N) s přesným count(*) over().Omezení textového filtrování: whole-token porovnání (packages/core's
text-match.ts/hasAllFeatures) nejde levně vyjádřit v SQL. RPC parametr
p_text_terms je jen OR-spojený ILIKE prefiltr přes všechny synonyma
zaškrtnutých „Výbava“/„Verze“ chipů; apps/web/app/results/page.tsx pak nad
vrácenou stránkou ještě spustí přesnou whole-token kontrolu
(hasAllFeatures, AND napříč zaškrtnutými chips). Při 2+ zaškrtnutých
chipech to může legitimně vrátit méně než 30 karet na stránku a
total_count/počet stránek je pak horní odhad, ne přesné číslo (v UI
označeno „(přibližně)“) — přesné řešení by vyžadovalo whole-token matching
přímo v Postgresu (např. přes tsvector/regex), což je mimo rozsah této
migrace.
Uložení hledání (/searches/new, /searches/[id]/edit, i „Uložit jako
hledání“ na /results) jde přes server akci
apps/web/app/actions/rematch.ts#saveSearchAndRematch, běžící s Supabase
session přihlášeného uživatele (ne service_role):
searches.rematchSearch: DB-side prefiltr aktivních inzerátů podle
značky/modelu/ceny/roku, pak přesný matchesSearch (packages/core) v
kódu — stejná funkce jako v packages/scrapers/src/runner.ts. Nově
odpovídající inzeráty se vloží do matches s notified_at = now(),
takže e-mailový digest (packages/scrapers/src/runner.ts, posílá jen
matches kde notified_at is null) je znovu nepošle — uživatel je právě
viděl okamžitě na webu. Řádky matches, které už neodpovídají upravenému
hledání, se smažou.maybeAutoTriggerScrape (apps/web/lib/scrape-trigger.server.ts, sdíleno
s /api/scrape): pokud je nastaven GITHUB_DISPATCH_TOKEN a žádný běh
scrape.yml není queued/in_progress, spustí nový běh, aby se do
hledání brzy dostaly i úplně nové inzeráty ze zdrojů.Toast po uložení: „Hledání uloženo · nalezeno X aut · spuštěno stahování nových“ (poslední část jen když se scraping opravdu spustil).
RLS: public.matches mělo z 20260928120000_init.sql jen
select/update politiky pro vlastníka (řádky dřív zapisoval jen
service_role runner). Protože rematch teď zapisuje jako přihlášený
uživatel, 20260928200000_results_rework.sql přidává
matches_insert_own/matches_delete_own_rematch, obě podmíněné exists (select 1 from public.searches s where s.id = search_id and s.user_id = (select auth.uid())) — uživatel může vkládat/mazat jen matches svých
vlastních hledání.
public.user_state (jeden řádek na uživatele, vlastní-only RLS) drží dva
časy:
results_seen_prev — vodítko, proti kterému se počítá „NOVÉ“
(first_seen > results_seen_prev ve scope „Všechna auta“, `matches.matched_at
results_seen_prev` ve scope hledání).
results_seen_at — kdy začala aktuální návštěva.Při každé návštěvě /results (apps/web/app/actions/user-state.ts#touchResultsSeen):
pokud je results_seen_at starší než 30 minut, results_seen_prev se
posune na starou hodnotu results_seen_at a results_seen_at na teď — jinak
se nic neposouvá, takže badge „NOVÉ“ vydrží celé jedno prohlížení (ne
zmizí hned po refresh). Tlačítko „Označit vše jako viděné“ obě hodnoty
okamžitě sjednotí na teď.
Zelený badge „NOVÉ“ je na kartě první (vlevo), chip „Jen nové“ ve filtrech
filtruje jen na is_new. Badge „Zlevněno“ (žlutá) se počítá z posledních
dvou záznamů price_history pro danou kartu (nejlevnější/aktuální nabídku
skupiny). Nav položka „Výsledky“ má počítadlo (public.new_matches_count
RPC) — počet matches napříč všemi hledáními uživatele novějších než jeho
results_seen_prev.
packages/scrapers/src/runner.ts)Pro každý zapnutý zdroj: pro každé zapnuté hledání, které daný zdroj
používá (prázdné sources[] = všechny), zavolá adaptér a normalizuje
výsledky. Z normalizovaných inzerátů se přes selectListingsToStore
(packages/scrapers/src/selection.ts) vyberou jen ty, které odpovídají
aspoň jednomu z těchto hledání (matchesSearch) — jen ty se dál upsertují
do listings (klíč source+source_id), při změně ceny se jim zapíše
řádek do price_history, spočítá/najde se jim group_id podle
fingerprintu a upsertne matches. V --dry-run se loguje jak počet
nalezených, tak počet těch, co by se uložily (viz „Co se ukládá do
databáze“ níže). Po každém zdroji se navíc ověří, jestli inzeráty, které
tento běh nenašel, opravdu zmizely ze zdroje — viz „Smazané inzeráty“ níže.
Na konci celého běhu (ne při --source) se navíc smažou inzeráty, které
neodpovídají žádnému hledání a nebyly viděné 3+ dny (cleanupUnmatchedListings)
— price_history/matches/favorites mažou kaskádově; oblíbený inzerát se
takto nikdy nesmaže (viz „Oblíbené“ níže). Po dokončení všech zdrojů se pro každý z nich porovná found tohoto běhu
s mediánem posledních 5 úspěšných běhů (packages/scrapers/src/health-alert.ts)
— vrátil-li zdroj 0 (nebo selhal), přestože běžně vrací víc, pošle se
e-mail + push „⚠️ zdroj … nevrací auta“, nejvýš 1× za 24 h na zdroj
(sources.last_alert_at). Na úplný závěr pošle e-mailový souhrn nových
shod (notified_at is null, viz výše — proto běh 3× denně neposílá víc
e-mailů, jen rychleji doručí to nové) a označí je jako odeslané, dále
push s „Nové auto: … / N nových aut pro …" vlastníkovi každého hledání, a
zkontroluje oblíbené položky na zlevnění/zmizení
(packages/scrapers/src/favorites-alert.ts) — viz „Oblíbené“ níže.
Do databáze (listings, price_history, matches) se ukládají jen
inzeráty, které odpovídají aspoň jednomu uloženému a zapnutému hledání —
ne všechno, co scraper na daném zdroji najde. Důvod je prostý: bez filtru by
tabulka listings rostla nekontrolovaně počtem inzerátů napříč všemi
bazary, i když je nikdo nikdy neuvidí.
Z toho plynou dvě praktická pravidla:
matches dá
spočítat okamžitě pro inzeráty, co v DB už jsou, ale nové inzeráty ze
zdroje potřebují opravdový scraping).last_seen,
…) zůstane zmrazený, protože ho runner přestane znovu zapisovat. Po 3+
dnech ho úklid na konci plného běhu (viz výše) smaže i s historií ceny.
Krátká prodleva 3 dnů je záměrná — aby čerstvě stažený inzerát nezmizel
hned po úpravě hledání, než se stačí spočítat nový rematch./favorites je samostatný, na hledáních nezávislý seznam — tabulka
public.favorites (supabase/migrations/20260928220000_favorites.sql,
user_id+listing_id, RLS jen vlastník). Srdíčko u karty (komponenta
apps/web/components/favorite-button.tsx) funguje úplně všude — na
domovské stránce, v obou scope /results (i „Všechna auta“, kde předtím
oblíbení nešlo, protože neexistoval řádek v matches), i na detailu
inzerátu — a přepíná řádek v favorites, ne matches.status. Stránka
/favorites navíc umí:
favorites.note, editace přímo na kartě),price_history od created_at
dál, porovnaný s aktuální cenou),listings.is_active = false,Stará logika matches.status = 'favorite' byla plně nahrazena (migrace ji
při aplikaci jednorázově přenese do favorites); matches.status = 'hidden'
(„Skrýt nabídku“) zůstává beze změny — pořád vyžaduje řádek v matches, a
proto je dostupné jen ve scope „Moje hledání“.
Scraper (packages/scrapers/src/runner.ts) nikdy nesmaže oblíbený inzerát
(cleanupUnmatchedListings ho přeskočí i po 3+ dnech bez shody) a i
smazaný/prodaný oblíbený inzerát se jen deaktivuje, ne smaže rovnou — viz
„Smazané inzeráty“ níže. Runner jinak dál upsertuje jen inzeráty, které
aktuálně odpovídají nějakému hledání — oblíbený inzerát, který přestal
odpovídat všem hledáním, si tedy podrží svůj poslední známý stav (cenu,
last_seen, …), dokud ho zdroj sám nesmaže.
Upozornění na zlevnění/zmizení: po každém běhu
(packages/scrapers/src/favorites-alert.ts) se každá oblíbená položka
zkontroluje — zlevnila oproti poslední nahlášené ceně, nebo nově zmizela
(gone_at nastaveno)? Pokud ano, pošle se vlastníkovi push a přidá se
řádek do sekce „Oblíbené: zlevnění / prodáno“ v e-mailovém digestu.
Sledování „už nahlášeno“ (favorites.last_notified_price/
last_notified_gone_at, viz migrace 20261003000100_favorites_notify_tracking.sql)
zajišťuje, že stejná změna nikdy nepřijde dvakrát — nová nižší cena nebo
návrat inzerátu mezi aktivní pak „odemkne“ další upozornění v budoucnu.
Dřívější logika „neviděno 3+ dny → is_active=false“ byla nahrazena
ověřeným mazáním: po každém zdroji (checkGoneListings v
packages/scrapers/src/runner.ts) se pro inzeráty daného zdroje, které
tento běh nenašel, ověří, jestli už opravdu na zdroji neexistují —
teprve pak se s nimi něco udělá:
is_active=true,
gone_at is null), který nebyl mezi touto dávkou stažených výsledků. Bere
se v potaz jen když se zdroji tento běh podařilo něco stáhnout bez chyby
(jinak by "nenašel" mohlo jen znamenat, že zdroj zrovna nejde). Kvůli
časovému rozpočtu běhu se na zdroj kontroluje nejvýš 50 kandidátů za běh
(MAX_GONE_CHECKS_PER_SOURCE) — zbytek se zkusí příští běh.fetchForGoneCheck, přes
stejný zdvořilý/throttlovaný politeFetch jako cokoliv jiného) a
vyhodnotí se isGone() z packages/scrapers/src/gone-detection.ts — 404/410,
přesměrování pryč z tvaru detailní URL (adaptér-specifické: sauto
mimo /osobni/detail/…, tipcars mimo .html, bazos mimo
/inzerat/…), nebo 200 stránka s frází typu „inzerát byl smazán/
prodán/neexistuje“ (česky/německy/anglicky) — u zdrojů bez vlastního
pravidla se použije jen tato obecná fráze/stavová kontrola.price_history/matches/favorites kaskádově).is_active=false a gone_at=now(); stránka „Oblíbené“ pak u něj ukáže
„Prodáno / nedostupné od <gone_at>“. Po GONE_FAVORITE_RETENTION_DAYS
(výchozí 7 dní — konstanta v runner.ts, klidně si ji upravte) se
i takový inzerát smaže (deleteExpiredGoneFavorites) — uživatel do té
doby vidí, že vůz zmizel.--dry-run nikdy nic nemaže ani neoznačuje — celá tahle logika běží jen
při skutečném zápisu do databáze. Runner loguje za každý zdroj řádek
checked / gone / deleted / kept-favourite.
Heuristiky pro sauto/tipcars/bazos vycházejí z tvaru jejich detailních
URL (viz komentáře v gone-detection.ts), ale nebyly ověřeny živě proti
skutečně smazanému inzerátu (žádný nebyl po ruce k otestování) — berte je
jako rozumný odhad, ne jako 100% ověřené chování jako u zbytku adaptérů.
Push notifikace do nainstalované PWA (iOS 16.4+ po přidání na plochu, Android/desktop i bez instalace) pro: nové shody, zlevnění/zmizení oblíbených a upozornění na nefunkční zdroj. Nepřidává žádný nový GitHub Secret ani Vercel env var — VAPID klíče žijí v DB.
Jednorázové nastavení (dělá operátor/admin, ne kód):
npx web-push generate-vapid-keys --json.apps/web/lib/push.ts (VAPID_PUBLIC_KEY) —
je to skutečně veřejná hodnota, bezpečná v klientském kódu.public.app_secrets
(migrace 20261003000200_web_push.sql tabulku jen zakládá, hodnoty do ní
schválně nevkládá):
insert into public.app_secrets (key, value) values
('vapid_public_key', '<veřejný klíč>'),
('vapid_private_key', '<soukromý klíč>'),
('vapid_subject', 'mailto:[email protected]')
on conflict (key) do update set value = excluded.value;
vapid_subject je kontaktní e-mail posílaný push službám (FCM/Mozilla)
jako součást protokolu — dejte tam libovolnou adresu, kterou chcete mít
jako kontakt pro tyto služby, nemusí to být váš osobní účet.Jak to funguje:
apps/web/lib/push.ts: subscribeToPush/unsubscribeFromPush —
požádá o Notification oprávnění, přihlásí se přes PushManager a uloží
endpoint/p256dh/auth do public.push_subscriptions (RLS: jen
vlastní řádky). subscribe() se volá hned po requestPermission() (ne až
po dotazech na Supabase) — na iOS Safari se "user gesture" aktivace z
kliknutí může ztratit, pokud mezi tím běží něco pomalého jako síťový
dotaz. Toggle "Notifikace" je na /sources
(components/notifications-toggle.tsx) — na iOS zobrazí hlášku, že push
funguje jen po přidání appky na plochu, a po úspěšném zapnutí zobrazí
"Uloženo (zařízení: ...)" s počtem uložených zařízení z DB, aby šlo
rovnou vidět, že se řádek skutečně zapsal.syncPushSubscription (voláno při načtení stránky): pokud prohlížeč už
má PushManager subscription, idempotentně ji znovu uloží do
push_subscriptions (upsert na endpoint). Tohle opravuje stav, kdy
toggle tvrdil "zapnuto" navždy, i když v DB nebyl žádný řádek — dřív se
kontrolovat jen prohlížeč, takže ztracený/neúspěšný upsert (nebo smazání
řádku runnerem po 404/410) nikdy nikoho nenapadlo zkusit znovu. Pokud má
uložená subscription jiný (starý/rotovaný) VAPID klíč než aktuální
VAPID_PUBLIC_KEY, zkusí se odhlásit a přihlásit znovu s novým klíčem; na
iOS to bez gesta může selhat — pak se zobrazí chyba a jde to spravit
kliknutím na tlačítko.apps/web/public/sw.js: push handler zobrazí notifikaci z JSON payloadu
({title, body, url}), notificationclick fokusne/otevře danou URL.packages/scrapers/src/push.ts: čte VAPID klíče z app_secrets, posílá
přes web-push; odpověď 404/410 znamená mrtvou subscription a řádek se
rovnou smaže.packages/scrapers/scripts/send-test-push.ts: ruční ověření bez čekání na
reálný scrape — pošle testovací notifikaci všem subscriptions daného
uživatele (SUPABASE_URL/SUPABASE_SERVICE_ROLE_KEY + user id), nebo
jedné subscription přímo bez DB (VAPID_* + SUBSCRIPTION_JSON). Nejede
v CI, spouští se ručně přes pnpm --filter @scrapping-auta/scrapers send-test-push -- <user-id>.scrape_runs.errors.bazos u stručných inzerátů (bez roku/km v titulku či popisu) nemá odkud
rok/nájezd vzít — takové inzeráty projdou dál s year/mileageKm null
a hledání s aktivním filtrem na rok/km je proto vynechá.autoscout24 je záměrně jen německý inventář (cy=D) — autoscout24.cz
nemá žádné tuzemské (CZ) inzeráty vůbec. Ceny jsou v EUR a runner je
převádí na CZK; zobrazená cena je vždy včetně DPH (brutto) — u inzerátů,
kde je DPH odpočitatelná pro firmy (isVatLabelLegallyRequired), se cena
nijak nepřepočítává, jen se to označí „· odpočet DPH“ v titulku.DESIGN.md označené jako další fáze a nejsou implementované (badge
„Zlevněno“ pro pokles ceny už ano, viz sekce výsledků výše)./results je jen
OR-ILIKE prefiltr v SQL + přesný whole-token check v kódu nad vrácenou
stránkou — u 2+ zaškrtnutých chipů proto total_count/počet stránek může
být jen horní odhad (viz „Omezení textového filtrování“ výše).matches, který v scope „Všechna auta“ neexistuje, dokud se inzerát
nestane součástí nějakého uloženého hledání) — v scope „Všechna auta“ se
toto tlačítko u karty nezobrazuje. Oblíbit funguje všude (viz „Oblíbené“
výše) — je to samostatná tabulka, ne řádek v matches.new_matches_count (počítadlo u „Výsledky“ v navigaci) se dotazuje přes
klientský Supabase klient při každé změně route; u velmi aktivního účtu
s desítkami hledání by šlo do budoucna nahradit realtime subscription
místo pollování při navigaci.HTML autoesa.cz/<značka> nebo /vsechna-auta; filtr jen značka (přes cestu), cena/rok/km jen klientský matcher |
| — |
skodaplus | nově zapnuto touto migrací, ověřeno | GraphQL skodaplus.cz/graphql (cars(filter: CarFilterInput)); filtry: značka (carMakes id), cena, rok registrace, nájezd | přes branding "Škoda Plus" prodává i jiné značky VW koncernu (ověřeno na Audi) |
autoscout24 | zapnuto touto migrací (jen Německo) | JSON v __NEXT_DATA__ na autoscout24.cz/lst/<značka>/<model>?cy=D; filtry: značka/model, rok, nájezd, cena (CZK→EUR podle eurCzkRate, zaokrouhleno směrem ven) | záměrně jen zahraniční (německý) inventář — autoscout24.cz nemá žádné tuzemské inzeráty vůbec (cy=CZ dává 0 výsledků); zobrazená cena (priceRaw) je vždy cena včetně DPH (brutto) — u inzerátů s isVatLabelLegallyRequired (DPH je odpočitatelná pro firmy) se cena nepřepočítává, jen se do titulku přidá „· odpočet DPH“ |