seepuls/.claude/skills/seepuls-curate-events.md
Till JS a66edb6986
Some checks are pending
CI / validate (push) Waiting to run
echte Lösung: iCal/RSS-Feed-First-Pattern (Apollo nun 14 Events mit exakten Zeiten)
Problem: 22 von 35 Venues hatten keine crawlbaren Events, weil ihre
Programm-Seiten JS-rendered, hinter Auth-Wall, oder via Bot-Protection
für WebFetch blockt.

Untersucht (drei Angriffsflanken):
1. mana-research prod: läuft nicht auf mana-server (Container fehlt,
   research.mana.how → 530). Self-host wäre Aufwand.
2. Plain bun fetch mit Mozilla-UA: Theater Konstanz liefert 42 KB HTML
   ABER hinter Cookie-Auth-Wall (/auth/index.html?secure=true).
   K9 liefert 2 KB Skeleton → wirklich JS-only.
3. iCal/RSS-Feed-Probe: Apollo Kulturhaus exponiert TEC-Plugin-iCal
   unter /?ical=1 mit 14 Events und exakten DTSTART-Zeiten.

Lösung: Feed-First-Pattern in Skill seepuls-curate-events.
- Vor WebFetch: probiere TEC-Standard-Endpoints (/?ical=1,
  /events/?ical=1, /?post_type=tribe_events&ical=1 etc.).
- Wenn ≥1 BEGIN:VEVENT: pure-Python-iCal-Parser (RFC 5545 strict).
- Vorteile: strukturiertes Format (kein LLM-Halluzinations-Risiko),
  exakte Zeiten, kein Bot-Protection, kein Jahres-Drift.

Apollo iCal-Re-Import:
- 12 alte HTML-gescrapte Apollo-Events hard-deleted (mit falschen
  default-20:00-Zeiten).
- 14 frische iCal-Events inserted, alle mit präzisen DTSTART-Zeiten.
- Neu entdeckt: Babykonzert hat 2 Termine am 20.05 (09:00+10:45),
  + Wladimir Kaminer am 16.10 (hatte HTML-Scrape übersehen).
- Slug-Kollisions-Fix: bei doppelten Titel+Tag-Slugs HHMM-Suffix
  anhängen (z.B. "...sonnenzauber-babykonzert-...-1045").

Skill-Update:
- Stage 2 neu: 2.0 (Feed) → 2.1 (WebFetch) → 2.2 (Multi-Step) →
  2.3 (Aggregator-Fallback).
- Pure-Python-iCal-Parser-Snippet im Skill (~20 Zeilen, no deps).
- Known-Endpoint-Tabelle: derzeit Apollo bestätigt.

TEC-Probe-Scan über 19 weitere Venue-Domains: keine weiteren echten
Event-Feeds. Andere Sites (zebra, theaterandergrenze, kult-x) haben
zwar /feed/ aber als Post-Feed (Articles), nicht Event-Feed.

Total events live: 58 (Apollo jetzt 14 statt 10).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:32:03 +02:00

470 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

---
name: seepuls-curate-events
description: Crawlt Events einer Venue von ihrer offiziellen Programm-Seite und schreibt sie in seepuls.events. Pipeline Plan→Recherche (WebFetch+AI)→Design→Validate→Publish mit Reviewer-Stops und Dedupe via external_id_hash.
argument-hint: '<venue-slug> [--source-url <url>]'
allowed-tools:
- Read
- Write
- Edit
- WebSearch
- WebFetch
- Bash(ls *)
- Bash(mkdir *)
- Bash(grep *)
- Bash(cat *)
- Bash(curl *)
- Bash(jq *)
- Bash(docker exec *)
- Bash(ssh *)
- Bash(python3 *)
- Bash(openssl *)
- AskUserQuestion
---
# `/seepuls-curate-events` — Event-Crawl für eine Venue
Crawlt eine Event-Listing-Seite, extrahiert strukturierte Events, und
schreibt sie nach **Reviewer-Stop** in `seepuls.events` auf prod-DB.
DB ist SOT für Events (kurzlebig, hoher Volumen). Audit-Trail in
`~/Documents/seepuls-drafts/<venue-slug>-events-<YYYY-MM-DD>/`.
Schwester-Skills: [`/seepuls-curate-venue`](./seepuls-curate-venue.md),
[`/seepuls-validate-venue`](./seepuls-validate-venue.md).
## Pflicht-Lektüre
- `seepuls/STATUS.md` + `seepuls/docs/CURATOR.md`
- `mana/docs/AGGREGATOR_POLICY.md` — Hard-Rules (robots, Attribution,
≤200 chars Description, Hot-Link Bilder)
- `seepuls/apps/api/src/db/schema/events.ts` — Schema mit
`external_id_hash`
## Inputs
1. **`<venue-slug>`** (Pflicht) — muss in `seepuls.venues` existieren.
2. **`--source-url <url>`** (optional) — Programm-/Event-Listing-URL.
Wenn fehlt: vom Skill aus venue.websiteUrl + WebSearch abgeleitet.
## Workspace
```
~/Documents/seepuls-drafts/<venue-slug>-events-<YYYY-MM-DD>/
├─ plan.md Stufe 1
├─ research/
│ └─ raw.md WebFetch-Roh-Output
├─ design/
│ └─ events.jsonl Strukturierte Kandidaten
├─ validate/
│ └─ report.md
└─ publish/
├─ insert.sql Generiertes SQL
└─ run.log Output Insert
```
## Pipeline (5 Stufen)
### Stufe 1 — Plan
1. Venue-Existenz:
```bash
ssh mana-server "docker exec seepuls-postgres psql -U seepuls -d seepuls -tAc \"SELECT id, name, website_url, source_domain FROM seepuls.venues WHERE slug='<venue-slug>'\""
```
Treffer-Miss → stoppen, `/seepuls-curate-venue <slug>` zuerst.
2. **Listing-URL bestimmen**:
- Wenn `--source-url` gegeben → die nehmen.
- Sonst: venue.websiteUrl, plus mit WebSearch nach
`"<venue-name>" programm OR veranstaltungen OR kalender` schauen.
3. **AGGREGATOR_POLICY-Pre-Check** (robots.txt der Source-Domain):
```bash
curl -sf -H "User-Agent: seepuls/0.0.1 (+https://seepuls.mana.how; kontakt@mana.how)" \
--max-time 5 "https://<domain>/robots.txt" | head -40
```
`Disallow: /` für `*` oder `seepuls`? → STOPP, `blocked_domains`
eintragen statt crawlen.
4. `plan.md`:
- Venue-Slug + Name + venue-ID (aus Stufe 1)
- Listing-URL
- Erwartete Event-Zahl (Range, z.B. 320)
- Streitfälle (z.B. „Vorverkauf-Daten gemischt", „Wiederholungs-
Termine derselben Aufführung")
5. **Reviewer-Stop**: User sieht Plan + Listing-URL, sagt go.
### Stufe 2 — Recherche (Feed-First, dann WebFetch)
**Goldstandard-Pfad (Stage 2.0 — iCal/RSS-Feed)**: BEVOR `WebFetch`
versucht wird, prüfe TEC-WordPress-Standard-Endpoints. iCal-Daten sind
strukturiert (RFC 5545) — kein LLM-Halluzinations-Risiko, kein
Jahres-Drift, exakte Zeiten in DTSTART, kein Bot-Protection.
**Probe-Liste** (mit Mozilla-User-Agent, 48 Pfade pro Domain):
```bash
for path in "/?ical=1" "/events/?ical=1" "/event/?ical=1" \
"/veranstaltungen/?ical=1" "/?post_type=tribe_events&ical=1" \
"/?post_type=event&ical=1"; do
curl -s -H "User-Agent: Mozilla/5.0" --max-time 4 \
"https://<domain>$path" | head -c 300
done
```
**Feed gefunden** (`BEGIN:VCALENDAR` + ≥1 `BEGIN:VEVENT`)? → direkt
zu Stage 3 mit Pure-Python-iCal-Parser:
```python
import re
def parse_ical(text):
text = re.sub(r'\r?\n[ \t]', '', text) # Line-folding auflösen
events = []
for block in re.findall(r'BEGIN:VEVENT(.*?)END:VEVENT', text, re.DOTALL):
ev = {}
for line in block.strip().split('\n'):
line = line.strip()
if ':' not in line: continue
kr, val = line.split(':', 1)
key = kr.split(';')[0] # Param wie TZID;VALUE=DATE strippen
ev[key] = val.replace('\\n', ' ').replace('\\,', ',').replace('\\;', ';')
events.append(ev)
return events
# DTSTART:20260520T090000 → ISO-8601 + CEST
def parse_dtstart(s):
if 'T' in s:
dt = datetime.datetime.strptime(s, '%Y%m%dT%H%M%S')
return dt.strftime('%Y-%m-%dT%H:%M:%S+02:00'), dt.strftime('%H%M')
dt = datetime.datetime.strptime(s, '%Y%m%d')
return dt.strftime('%Y-%m-%dT20:00:00+02:00'), '2000'
# Bei Slug-Kollision (gleicher Event-Titel, gleicher Tag, 2 Termine):
# Anhang HH:MM aus DTSTART
seen = set()
for e in events:
base = f"{starts_at[:10]}-{slugify(summary)}"
slug = base
while slug in seen:
slug = f"{base}-{hhmm[:2]}{hhmm[2:]}"
seen.add(slug)
```
**Bekannt-funktionierende iCal-Endpoints** (Stand 2026-05-15):
| Domain | Endpoint | Events |
|---|---|---|
| `apollokreuzlingen.ch` | `/?ical=1` oder `/events/?ical=1` | 14 (mit exakten Zeiten) |
Weitere Sites mit RSS aber **leeren Event-Feeds**: theaterandergrenze.ch,
zebra-kino.de, kult-x.ch (die haben TEC nicht aktiviert oder events
sind custom-post-type ohne feed). Bei denen weiter WebFetch oder
Aggregator.
**Standard-Pfad (Stage 2.1 — WebFetch + AI-Extraktion)**: wenn kein
iCal-Feed da. `WebFetch` auf die Listing-URL mit dem Prompt unten.
Wenn das ≥3 Events mit `starts_at != null` liefert, ist Stage 2.1
fertig.
**Multi-Step-Pfad (Stage 2.2 — wenn Listing keine Daten liefert)**:
1. Stage 2a: WebFetch der Listing-URL → bekommt Event-Stubs mit `source_url`-
Detail-Links, aber `starts_at: null`.
2. Stage 2b: pro Detail-URL ein eigener WebFetch mit kürzerem Prompt
(siehe „Detail-Page-Prompt" unten). 510 Detail-Fetches parallel
sind OK — alle laufen über dieselbe Domain → robots-Politeness gilt
trotzdem (max 1 req/s). Bei großen Listings: in Batches à 5 mit 1.5s
Pause zwischen Batches.
3. Stage 2c: Detail-Ergebnisse mit Listing-Stubs joinen (über
`source_url`).
**Fallback-Pfad (JS-heavy Sites, Listing rendert leer)**:
- **Aggregator-Sites** anzapfen: `konstanz-info.com`, `bodensee.de`,
`party-news.de`, `thurgaukultur.ch`, `kreuzlinger.net`,
`bodensee-kultur.info`. Diese listen oft Events vieler Venues
konsolidiert.
- Pro Aggregator: WebFetch der venue-spezifischen Subseite (z.B.
`konstanz-info.com/veranstaltungen/<venue-slug>`).
- Attribution: `source_url` ist der Aggregator-Listing-Pfad, im
`description`-Feld bleibt die Original-Venue.
- **Achtung**: nicht alle Aggregator-Listings sind aktuell. Cross-Check
mit Original-Venue-Site stichprobenartig.
**WebFetch-Prompt (Listing-Variante)**:
```
Extrahiere alle anstehenden Veranstaltungen aus dieser Programm-Seite
als JSON-Array. Pro Event:
- title (string, Originalsprache, ohne Zusätze wie "ABO 1" oder Wochentag-Präfixe)
- starts_at (ISO-8601 wenn möglich: YYYY-MM-DDTHH:mm:ss+02:00; fallback YYYY-MM-DD; null wenn unklar)
- ends_at (ISO-8601 wenn auffindbar, sonst null)
- description (≤200 Zeichen, Originalsprache, KEIN Re-Phrasing)
- ticket_url (Direkt-Link, wenn vorhanden, sonst null)
- image_url (absolute URL, wenn auf Seite eingebunden, sonst null)
- source_url (Detail-Link auf das Event wenn vorhanden, sonst die Listing-URL)
Regeln:
- Nur kommende Events (in der Zukunft).
- Maximal 50 Events.
- Wenn Datum NICHT eindeutig ist (z.B. "demnächst"): starts_at = null setzen
(NICHT droppen — Multi-Step-Pfad kann Datum aus Detail-Page nachholen).
- Wenn keine Events auf der Seite: leeres Array [].
Antworte AUSSCHLIESSLICH mit dem JSON-Array, kein Markdown-Wrap.
```
**WebFetch-Prompt (Detail-Page-Variante)** — wenn Listing nur Stubs hatte:
```
Extrahiere für diese eine Veranstaltung:
- title
- starts_at (ISO-8601 oder YYYY-MM-DD)
- ends_at (oder null)
- description (≤200 chars, Original)
- ticket_url (oder null)
- image_url (oder null)
Antworte als compact JSON object, kein Wrap.
```
Output → `research/raw.json` (Listing-Ergebnis) + `research/details/<n>.json`
(pro Detail-Page). Anzahl prüfen vs Plan-Erwartung. Wenn 0: explizit
flag im plan.md was die Quelle ist (JS-rendered, Login, fail) und dem
User vorschlagen, Aggregator-Fallback oder anderen Listing-Pfad zu
versuchen.
### Stufe 3 — Design (Datum parsen + Hash + Slug)
Lese das JSON-Array, kanonisiere jedes Event. **Ready-to-copy
Python-Helpers** (Unicode-sicher dank NFKD-Normalisierung):
```python
import json, hashlib, datetime, re, secrets, unicodedata
def parse_date(s):
if not s: return None
# 1. Strict ISO-8601 mit Time + TZ
if re.match(r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}', s):
try: return datetime.datetime.fromisoformat(s.replace('Z', '+00:00'))
except: pass
# 2. YYYY-MM-DD: 20:00 CEST als Konzert-Default
if re.match(r'^\d{4}-\d{2}-\d{2}$', s):
return datetime.datetime.fromisoformat(f"{s}T20:00:00+02:00")
# 3. DD.MM.YYYY (deutsches Format)
m = re.match(r'^(\d{2})\.(\d{2})\.(\d{4})$', s)
if m:
d, mo, y = m.groups()
return datetime.datetime.fromisoformat(f"{y}-{mo}-{d}T20:00:00+02:00")
# KEIN free-form-Parsing — "Samstag 15. Juni" → None (Event droppen)
return None
def event_hash(title, starts_at_iso, venue_id):
norm = re.sub(r'\s+', ' ', title.lower()).strip()
iso_min = starts_at_iso[:16]
return hashlib.sha256(f"{norm}|{iso_min}|{venue_id}".encode()).hexdigest()[:32]
def slugify(text, max_len=48):
"""Unicode-sicher: Á→a, č→c, ß→ss, é→e. Nutzt NFKD-Normalisierung."""
if not text: return ''
# 1. NFKD trennt Akzente vom Basis-Buchstaben
t = unicodedata.normalize('NFKD', text)
# 2. Mb-Combining-Chars filtern
t = ''.join(c for c in t if not unicodedata.combining(c))
# 3. Deutsche Sonder-Replaces (ß ist nicht NFKD-zerlegbar)
t = t.lower().replace('ß', 'ss')
# 4. Alles außer [a-z0-9-] zu Bindestrich, Trim Bindestriche
t = re.sub(r'[^a-z0-9]+', '-', t).strip('-')
return t[:max_len].rstrip('-')
def event_slug(title, dt):
date_part = dt.strftime('%Y-%m-%d')
title_part = slugify(title)
return f"{date_part}-{title_part}" if title_part else date_part
def truncate_at_sentence(s, max_len=220):
"""Beschreibung am Satzende kürzen statt mid-word."""
if not s or len(s) <= max_len: return s
cut = s[:max_len]
# Suche letztes Satzende
for sep in ['. ', '! ', '? ', '… ']:
i = cut.rfind(sep)
if i > max_len * 0.6:
return cut[:i+1]
# Fallback: letztes Leerzeichen
i = cut.rfind(' ')
return (cut[:i] + '…') if i > 0 else cut
```
**Wichtig**: `slugify` ist NFKD-basiert — kein Manual-`ä→ae`/`á→a`-
Mapping mehr nötig. Funktioniert für alle europäischen Sprachen (CZ,
PL, FR, ES, IT). Beispiel: `"CALLE MÁLAGA"` → `calle-malaga` (statt
früher `calle-m-laga`).
Pro Kandidat: `{title, slug, starts_at, ends_at, description, ticket_url,
image_url, source_url, external_id_hash}` in `design/events.jsonl`.
**Filter**:
- starts_at < now - 12h → droppen (vergangen)
- description > 220 chars → trimmen am Satzende
- external_id_hash collision innerhalb der Charge → erster bleibt
**Reviewer-Stop**: User sieht Markdown-Tabelle (Datum · Titel · Quelle)
mit 10 Stichproben + Gesamtzahl + Streitfälle (events ohne ticket_url
o.Ä.). Sagt go.
### Stufe 4 — Validate
Schreibe `validate/report.md` mit:
- **venue-exists** ✓ (aus Stufe 1)
- **datum-future**: alle starts_at ≥ now 12h
- **datum-parsed**: alle starts_at sind gültige ISO-8601-Datums
- **dedupe-existing** gegen DB:
```bash
ssh mana-server "docker exec seepuls-postgres psql -U seepuls -d seepuls -tAc \
\"SELECT external_id_hash FROM seepuls.events WHERE venue_id='<vid>'\""
```
Existing-Hashes-Set bilden. Pro Kandidat: schon da? → wird zu UPDATE
statt INSERT (Stufe 5).
- **description-length**: alle ≤ 220 Zeichen
- **source-coverage**: alle haben `source_url` nicht null
- **image-https**: image_url ist `https://` oder null (kein data: oder relative)
Bei rotem Finding: nicht eigenmächtig fixen — User informieren.
### Stufe 5 — Publish (SQL gegen prod-DB)
Generiere `publish/insert.sql`:
```sql
INSERT INTO seepuls.events
(id, slug, venue_id, event_source_id, title, description, starts_at,
ends_at, source_url, ticket_url, image_url, external_id_hash,
first_seen_at, last_seen_at, raw_payload)
VALUES
('ev_<random12>', '<slug>', '<vid>', NULL, '<title>', '<desc>',
'<starts_at_iso>'::timestamptz, NULL, '<src>', '<ticket>', '<img>',
'<hash>', NOW(), NOW(), '<json>'::jsonb)
ON CONFLICT (external_id_hash) DO UPDATE
SET title = EXCLUDED.title,
description = EXCLUDED.description,
starts_at = EXCLUDED.starts_at,
ends_at = EXCLUDED.ends_at,
ticket_url = EXCLUDED.ticket_url,
image_url = EXCLUDED.image_url,
source_url = EXCLUDED.source_url,
last_seen_at = NOW(),
soft_deleted_at = NULL,
raw_payload = EXCLUDED.raw_payload,
updated_at = NOW();
```
**Strings escapen** (Python-Heredoc bevorzugt). Pro Event eine
INSERT-Anweisung.
Ausführen:
```bash
ssh mana-server "docker exec -i seepuls-postgres psql -U seepuls -d seepuls" \
< ~/Documents/seepuls-drafts/<…>/publish/insert.sql 2>&1 \
| tee publish/run.log
```
Plus **crawl_jobs-Audit-Eintrag** als Compliance-Beweis:
```sql
INSERT INTO seepuls.crawl_jobs
(id, target_url, target_domain, job_kind, source_id, user_agent_used,
robots_check_passed, respected_robots_delay, status, http_status,
new_items, updated_items, started_at, finished_at)
VALUES
('cj_<random16>', '<listing-url>', '<domain>', 'event-source-crawl',
NULL, 'seepuls/0.0.1 (+…)', true, true, 'ok', 200,
<new>, <updated>, NOW() - interval '5 seconds', NOW());
```
## Anti-Halluzinations-Regeln
- **Niemals Datum raten.** Wenn aus der Seite nicht eindeutig: droppen.
- **Niemals Description LLM-paraphrasieren.** Direkt aus Quelle, ≤ 220
Zeichen, am Satzende kürzen.
- **Niemals Bilder lokal cachen.** Hot-Link via image_url (Policy §3).
- **Niemals Pre-Verkauf-Daten als Event.** „Tickets ab 10.5." ist kein
Event-Datum.
- **Niemals außerhalb der Venue.** Wenn das Listing Events einer
anderen Venue enthält („zu Gast bei …"), nicht übernehmen.
## Reviewer-Stops sind Pflicht
Nach Stufe 1 (Plan + Listing-URL) und Stufe 3 (Vorschau-Liste).
Auch bei „mach einfach". Stops retten den Korpus vor Falsch-Daten.
## Was NICHT der Skill macht
- **Crawl-Pipeline-Scheduler aktivieren.** Der scheduler.ts läuft
via SEEPULS_SCHEDULER=on und braucht eine event_source-Tabelle —
außerhalb dieses Skills.
- **Take-Down-Handling.** /api/v1/takedown + Mod-Workflow.
- **Bilder cachen.** Hot-Link Default laut Policy.
- **Verlängern auf 100+ Events ohne Reviewer-Sampling.** Bei großen
Listings (Saison-Programm 200 Events): Sampling-Stop in Stufe 3.
## Known-Patterns pro Domain (Lessons aus Demo-Runs)
| Pattern | Domains (Beispiele) | Skill-Strategie |
|---|---|---|
| Statisches HTML mit Events im Body | `zebra-kino.de`, `bodensee-kultur.info` | Single-Step Listing-Fetch reicht |
| WordPress mit Event-Detail-Pages | `apollokreuzlingen.ch`, `museumrosenegg.ch` | Multi-Step: Listing → Detail-Pages |
| JS-Single-Page-App (Splash ohne Daten) | `kantine-kn.de`, `cinestar.de`, `theaterkonstanz.de`, `k9-kulturzentrum.de` | Aggregator-Fallback nutzen |
| Aggregator-Sites mit konsolidierten Events | `konstanz-info.com`, `bodensee.de`, `party-news.de`, `kreuzlinger.net`, `thurgaukultur.ch` | Venue-Subseite des Aggregators fetchen |
| iCal-Feed verlinkt | manche Wordpress-Sites | Direct-Fetch der `.ics`-URL (Python `icalendar`-Lib) |
| Ticket-Plattform-Subdomain | `seetickets.com`, `eventfrog.ch`, `ticketino.com` | Manchmal pro Venue separate Subdomain mit konkreten Daten |
**Anti-Pattern**: nicht versuchen, JS-Rendering durch Wiederholtes
WebFetchen anderer Subseiten zu kompensieren. Wenn 2 Versuche leer:
direkt auf Aggregator switchen.
## Venue-ID-Stability (wichtig)
Venue-IDs sind **nicht stable** über Slug-Form abzuleiten. Die ersten
5 Venues kamen via direktem SQL-Insert (IDs `v_seed_*`), die späteren
33 via YAML-Importer (IDs `v_yaml_*`). Beim Insert-SQL **NIE** die
ID aus dem Slug raten — immer aus DB lesen:
```python
import subprocess, json
def venue_id_for_slug(slug):
out = subprocess.check_output([
'ssh', 'mana-server',
f"docker exec seepuls-postgres psql -U seepuls -d seepuls -tAc "
f"\"SELECT id FROM seepuls.venues WHERE slug='{slug}'\""
], text=True).strip()
return out or None
```
Bei FK-Constraint-Violation (`Key (venue_id)=(…) is not present in
table "venues"`) → IDs in DB checken, Mapping korrigieren.
## DB-Migration-Quirk
`db:push` legt `uniqueIndex` aus dem Drizzle-Schema NICHT immer an
(prüfbar mit `\\di seepuls.events*`). Wenn der erste echte Insert
einer neuen Venue mit `ON CONFLICT (external_id_hash)` failt mit
„no unique or exclusion constraint matching":
```bash
echo "CREATE UNIQUE INDEX IF NOT EXISTS events_dedupe_uq ON seepuls.events(external_id_hash);
CREATE UNIQUE INDEX IF NOT EXISTS events_slug_uq ON seepuls.events(slug);
CREATE INDEX IF NOT EXISTS events_venue_idx ON seepuls.events(venue_id);
CREATE INDEX IF NOT EXISTS events_starts_at_idx ON seepuls.events(starts_at);" \
| ssh mana-server "bash -lc 'docker exec -i seepuls-postgres psql -U seepuls -d seepuls'"
```
Idempotent — kann jederzeit re-run. Saubere Lösung: drizzle-kit
generate + migrate statt push. V2.
## Dependencies
- WebFetch (Claude-Skill-Tool, AI-Extraktion ohne mana-llm/research)
- ssh + docker exec auf mana-server für SQL
- Sub-Agent `mana-compliance` für Veto bei sensiblen Themen
- Python 3 mit `unicodedata`, `hashlib`, `secrets`, `re`, `datetime`
(alle stdlib)
## Referenzen
- Schwester-Skill: `/seepuls-curate-venue`
- Schema: `apps/api/src/db/schema/events.ts`
- Crawl-Policy: `../mana/docs/AGGREGATOR_POLICY.md`
- DB-Pattern: gleiche Hash-Heuristik wie
`apps/api/src/services/event-extraction.ts` (Server-Side)