Akce Ostrava - Aggregator akcí s AI enrichmentem
Auto-scrapery (TicketPortal, GoOut), bulk AI enrichment, personalized recommendations, admin dashboard.
Brief
Akce Ostrava je event discovery aggregator - jedno místo, kde najdeš všechno, co se v Ostravě a okolí děje, bez ohledu na to, který ticketing vendor to prodává. Problem space je triviální popsat a netriviální vyřešit: česká kulturní scéna je fragmentovaná napříč 6+ ticketing platformami (TicketPortal, GoOut, Goinout, Smsticket, NaVstupenky, Webticket), každá má jiné UX, jiné notifikace a jinou kvalitu dat. Fanoušek techna, který chce vědět, co se v Ostravě děje příští víkend, musí buď sledovat 6 webů, nebo Instagram a doufat. Akce Ostrava tu mezeru zalévá.
Záměr nebyl konkurovat samotným ticketingovým platformám - nikdo nikdy nenahradí GoOut v jejich segmentu - ale nabídnout local-first discovery s lepším filtrem, lepší pamětí (stejný uživatel nezapomene na akci, kterou si uložil před měsícem) a multi-language přístupem pro expaty a zahraniční studenty (VŠB má ~3 000 zahraničních studentů, kteří mluví anglicky/ukrajinsky/polsky).
Druhý úmysl byl pilotní spuštění mého DataForSEO workflow - Akce Ostrava byla první projekt, kde jsem si vyběhl 574-keyword baseline a postavil article generator. To, co se osvědčilo tady, jsem pak naškáloval na DokladBot a Marušku.
Architektura: Vite SPA + scraper služby + admin
Šel jsem do Vite SPA (ne Next.js) z konkrétních důvodů:
- Frontend je read-only katalog, žádné server actions, žádné formuláře, jen filtrování a klik na "kup vstupenku" (deeplink na vendor)
- Hosting na statickém CDN je výrazně levnější než Vercel serverless pro ~10 k MAU
- SPA je rychlejší, když uživatel rolluje napříč 200+ events a aplikuje 5 filtrů - žádný roundtrip, jen klient-side state
- SEO řeší prerender pipeline (statické HTML pro každou event detail page)
apps/
├── web/ ← Vite SPA, React, i18next (6 locales)
├── scraper/ ← Node service, Playwright + Cheerio
├── enricher/ ← bulk AI enrichment přes Anthropic Batch API
└── admin/ ← interní dashboard, review queue
packages/
├── db/ ← Prisma + Postgres schema
└── shared/ ← TS types, event schema
Frontend komunikuje s Postgres přes minimální REST API (jen čtení agregovaných event listů). Mutating operace (scrape, enrich, schvalování) běží v admin a scraper služby.
Scraper layer: TicketPortal (HTML) vs GoOut (JSON API)
Nejtěžší vrstva. Každý vendor má jinou strategii.
TicketPortal je tradiční server-rendered HTML, žádný oficiální API. Použil jsem cheerio na parsování:
// apps/scraper/src/adapters/ticketportal.ts
import { load } from 'cheerio';
import type { ScrapedEvent } from '@prace/shared';
export async function scrapeTicketPortal(city: string): Promise<ScrapedEvent[]> {
const url = `https://www.ticketportal.cz/category/Hudba?city=${city}`;
const html = await fetch(url, {
headers: { 'User-Agent': 'AkceOstravaBot/1.0 (+https://akce-ostrava.cz/bot)' },
}).then((r) => r.text());
const $ = load(html);
const events: ScrapedEvent[] = [];
$('.event-card').each((_, el) => {
const $el = $(el);
events.push({
vendor: 'ticketportal',
vendorId: $el.attr('data-event-id') ?? '',
title: $el.find('.event-title').text().trim(),
venue: $el.find('.venue-name').text().trim(),
startsAt: parseCzechDate($el.find('.event-date').text()),
priceCzk: parseCzkPrice($el.find('.price').text()),
url: new URL($el.find('a').attr('href') ?? '', url).toString(),
rawHtml: $el.html() ?? '', // pro audit
});
});
return events;
}GoOut má naopak interní JSON API - našel jsem ho přes DevTools Network tab. Tam stačí fetch s JSON parsingem, žádný DOM. Některé akce ale renderují popisek až klient-side přes JS (interaktivní kalendář), takže fallback na Playwright pro JS-rendered stránky:
// apps/scraper/src/adapters/goout-detail.ts
import { chromium } from 'playwright';
export async function scrapeGoOutDetail(slug: string) {
const browser = await chromium.launch();
try {
const page = await browser.newPage();
await page.goto(`https://goout.net/cs/akce/${slug}/`, {
waitUntil: 'networkidle',
});
const description = await page.$eval('[data-description]', (el) => el.textContent);
return { description };
} finally {
await browser.close();
}
}Cheerio je 50× rychlejší pro static HTML, Playwright řeší jen ty JS-rendered detaily. Cron běží každé 4 hodiny, scrape ~2 000 events/run, dedup přes (vendor, vendorId) composite key.
AI enrichment: bulk Claude calls
Surová data z vendorů jsou nekonzistentní. TicketPortal píše "Iva Bittová a host" jako title, GoOut má "Iva Bittová & Special Guest", Smsticket "I.BITTOVÁ + HOST". Pro UX (filtrování, related events, search) potřebuju kanonizaci.
Bulk enrichment volá Claude na 5–20 events naráz, dotaz je strukturovaný (JSON-only output) a vrací sjednocená metadata:
// apps/enricher/src/mass-generate.ts
import Anthropic from '@anthropic-ai/sdk';
import { z } from 'zod';
const enrichedSchema = z.object({
canonicalTitle: z.string(),
primaryArtist: z.string(),
supportingArtists: z.array(z.string()),
genre: z.enum(['rock', 'electronic', 'pop', 'classical', 'jazz', 'folk', 'metal', 'hip-hop', 'other']),
audienceAge: z.tuple([z.number(), z.number()]),
isFamilyFriendly: z.boolean(),
language: z.string(),
});
const client = new Anthropic();
export async function enrichBatch(events: RawEvent[]) {
const res = await client.messages.create({
model: 'claude-sonnet-4-5',
max_tokens: 4000,
system: `You normalize Czech event metadata. Return strictly JSON array, one object per input event, in the exact order. Schema: ${enrichedSchema.toString()}.`,
messages: [
{
role: 'user',
content: JSON.stringify(events.map((e) => ({ id: e.id, title: e.title, raw: e.rawHtml }))),
},
],
});
const parsed = JSON.parse(extractJson(res.content[0].text));
return parsed.map((p: unknown, i: number) => ({
eventId: events[i].id,
enriched: enrichedSchema.parse(p),
}));
}Důležité optimalizace:
- Batch size 10. Větší šetří tokeny, ale Claude začne škrtat podrobnosti u event #15+. Sweet spot 10.
- Anthropic Batch API pro nightly enrichment (50 % discount, 24h SLA - v pohodě, scraper běží stejně každé 4h).
- Cache enrichmentu přes hash surového HTML - pokud se event nezměnil, neutrácím za nový call.
Cena: ~$0.40 / 1 000 enriched events. Při 50k events scraped to vyšlo na $20/měsíc za AI náklady.
Admin dashboard: review queue
AI klasifikace je 92 % správně. Zbylých 8 % (genre miss, špatný primary artist u festivalů) řeší review queue: každá enriched event projde admin pohledem před publishováním. Bulk akce (změnit genre pro všechny "Karneval"-titled events) v jednom kliku, full keyboard shortcuts (j/k/x), inline edit.
Konkrétní KPIs:
- 50 000+ events scraped za rok
- 8 000+ live events v indexu (po dedup a filtrování stale)
- Review throughput: 200+ events/h s keyboard shortcuts (vs 30/h ručně klikáním)
SEO play: DataForSEO baseline
Tohle byl první projekt, kde jsem nasadil DataForSEO baseline. Vyběhl jsem 574 KW pro Ostravu, vyfiltroval podle objemu × intent, a vybral 30 cílových frází:
| Klíčové slovo | Měs. objem | Pozice před | Pozice po (T+90) |
|---|---|---|---|
akce ostrava | 8 100 | n/a | #3 |
co dělat v ostravě | 2 400 | n/a | #5 |
vstupenky zoo ostrava | 1 600 | n/a | #2 |
ostrava akce dnes | 1 300 | n/a | #4 |
koncerty ostrava 2026 | 880 | n/a | #6 |
goout ostrava alternativa | 110 | n/a | #1 |
KW typu "alternativa" jsou gold mine: nízký objem, ale 100 % nákupní intent. Vyhrál jsem je, protože konkurence na ně vůbec necílila.
Lessons
- Vendor scraping je fragile. TicketPortal mi 2× za rok změnil DOM strukturu, GoOut zavřel jednu API endpoint a otevřel jinou. Postavil jsem si smoke test runner, který každou hodinu zkouší scrape jednoho známého event a alertuje, když parsing selže.
- Anthropic Batch API je underrated. 50% discount za 24h SLA pro nightly jobs je no-brainer. Používám to teď i v DokladBotu.
- 6 jazyků není 6× práce. i18next + JSON klíče ano, ale AI translation event popisků (CS → EN/DE/PL/SK/UA) v jednom Claude callu je trivial. Real cost: 30 minut copy review pro každý nový event.
- Multi-language SEO má vlastní rules. UK uživatelé hledají "events Ostrava" (anglicky), Poláci "wydarzenia Ostrawa". Jeden URL prefix per locale (
/en/events/...,/pl/wydarzenia/...), hreflang tagy, separátní sitemap per locale. Bez toho Google indexuje jen CS verzi. - Postavit aggregator dřív než content. Jakmile máš 8 000 events v DB, automaticky generuješ landing pages "techno akce ostrava 2026", "koncerty na střeše červen 2026" atd. - long-tail SEO se postaví sám.