Akce Ostrava - Aggregator akcí s AI enrichmentem

Auto-scrapery (TicketPortal, GoOut), bulk AI enrichment, personalized recommendations, admin dashboard.

VitePlaywrightCheerioAnthropic SDK

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é slovoMěs. objemPozice předPozice po (T+90)
akce ostrava8 100n/a#3
co dělat v ostravě2 400n/a#5
vstupenky zoo ostrava1 600n/a#2
ostrava akce dnes1 300n/a#4
koncerty ostrava 2026880n/a#6
goout ostrava alternativa110n/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.