fix(events): Eventbrite provider — switch from dead API to web scraping

Eventbrite shut down their public Event Search API (/v3/events/search)
in 2023. The provider now uses the website extractor pipeline
(mana-research + LLM) to scrape Eventbrite's public search pages.
No API key needed — same pipeline as any website source.

Also adds mana-events to generate-env.mjs for automatic .env generation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-18 16:51:58 +02:00
parent 536fc89050
commit 97abd251e3
5 changed files with 60 additions and 155 deletions

View file

@ -106,6 +106,22 @@ const APP_CONFIGS = [
}, },
}, },
// Mana Events Service (Hono + Bun, Port 3065)
{
path: 'services/mana-events/.env',
vars: {
PORT: (env) => env.MANA_EVENTS_PORT || '3065',
DATABASE_URL: (env) =>
env.MANA_EVENTS_DATABASE_URL ||
'postgresql://mana:devpassword@localhost:5432/mana_platform',
MANA_AUTH_URL: (env) => env.MANA_AUTH_URL || 'http://localhost:3001',
CORS_ORIGINS: (env) => env.CORS_ORIGINS || 'http://localhost:5173',
MANA_RESEARCH_URL: (env) => env.MANA_RESEARCH_URL || 'http://localhost:3068',
MANA_LLM_URL: (env) => env.MANA_LLM_URL || 'http://localhost:3025',
MEETUP_API_KEY: (env) => env.MEETUP_API_KEY || '',
},
},
// Chat Server (Hono/Bun) // Chat Server (Hono/Bun)
{ {
path: 'apps/chat/apps/server/.env', path: 'apps/chat/apps/server/.env',

View file

@ -42,7 +42,6 @@ const TEST_CONFIG: Config = {
}, },
manaResearchUrl: 'http://localhost:3068', manaResearchUrl: 'http://localhost:3068',
manaLlmUrl: 'http://localhost:3025', manaLlmUrl: 'http://localhost:3025',
eventbriteApiKey: null,
meetupApiKey: null, meetupApiKey: null,
}; };

View file

@ -29,7 +29,14 @@ describe('Provider registry', () => {
}); });
describe('Eventbrite provider', () => { describe('Eventbrite provider', () => {
it('returns empty with error when API key is not set', async () => { it('requires a region label', async () => {
const provider = getProvider('eventbrite')!;
const result = await provider.fetchEvents('', 'Test');
expect(result.events).toHaveLength(0);
expect(result.error).toContain('Region label');
});
it('requires external service config', async () => {
const provider = getProvider('eventbrite')!; const provider = getProvider('eventbrite')!;
const result = await provider.fetchEvents('', 'Test', { const result = await provider.fetchEvents('', 'Test', {
lat: 47.997, lat: 47.997,
@ -38,15 +45,7 @@ describe('Eventbrite provider', () => {
regionLabel: 'Freiburg', regionLabel: 'Freiburg',
}); });
expect(result.events).toHaveLength(0); expect(result.events).toHaveLength(0);
expect(result.error).toContain('EVENTBRITE_API_KEY'); expect(result.error).toContain('config');
});
it('gracefully handles missing coordinates (after API key check)', async () => {
// Without API key, the key check fires first — that's the expected behavior
const provider = getProvider('eventbrite')!;
const result = await provider.fetchEvents('', 'Test');
expect(result.events).toHaveLength(0);
expect(result.error).toBeTruthy();
}); });
}); });

View file

@ -18,8 +18,7 @@ export interface Config {
// External service URLs for event discovery // External service URLs for event discovery
manaResearchUrl: string; manaResearchUrl: string;
manaLlmUrl: string; manaLlmUrl: string;
// Platform API keys (optional — providers gracefully skip when unconfigured) // Platform API key (optional — provider gracefully skips when unconfigured)
eventbriteApiKey: string | null;
meetupApiKey: string | null; meetupApiKey: string | null;
} }
@ -46,7 +45,6 @@ export function loadConfig(): Config {
}, },
manaResearchUrl: process.env.MANA_RESEARCH_URL || 'http://localhost:3068', manaResearchUrl: process.env.MANA_RESEARCH_URL || 'http://localhost:3068',
manaLlmUrl: process.env.MANA_LLM_URL || 'http://localhost:3025', manaLlmUrl: process.env.MANA_LLM_URL || 'http://localhost:3025',
eventbriteApiKey: process.env.EVENTBRITE_API_KEY || null,
meetupApiKey: process.env.MEETUP_API_KEY || null, meetupApiKey: process.env.MEETUP_API_KEY || null,
}; };
} }

View file

@ -1,115 +1,53 @@
/** /**
* Eventbrite provider fetches events from the Eventbrite API. * Eventbrite provider discovers events via Eventbrite's public search pages.
* *
* Uses the public Event Search API v3 which requires an API token * Eventbrite shut down their public Event Search API (/v3/events/search)
* but is free for read operations. * in 2023. Location-based search is no longer available via API.
* *
* API docs: https://www.eventbrite.com/platform/api#/reference/event-search * Strategy: use the website extractor (mana-research + LLM) to scrape
* * Eventbrite's public search pages (eventbrite.com/d/{region}/events/).
* When no API key is configured, this provider is a no-op (returns empty). * No API key required this uses the same pipeline as any website source.
*/ */
import type { EventProvider, ProviderContext, ProviderResult } from './base'; import type { EventProvider, ProviderContext, ProviderResult, ExternalServiceConfig } from './base';
import type { NormalizedEvent } from '../types'; import { extractEventsFromWebsite } from '../website-extractor';
const API_BASE = 'https://www.eventbriteapi.com/v3';
const FETCH_TIMEOUT_MS = 15_000;
/** Eventbrite category ID → our category mapping. */
const CATEGORY_MAP: Record<string, string> = {
'103': 'music',
'105': 'art',
'101': 'tech',
'108': 'sport',
'110': 'food',
'115': 'family',
'113': 'community',
'109': 'nature',
'104': 'art', // film & media
'107': 'education', // health
'102': 'tech', // science
'106': 'nightlife', // fashion
'111': 'community', // charity
'112': 'community', // government
'114': 'other', // spirituality
'116': 'other', // seasonal
'117': 'education', // home & lifestyle
'199': 'other',
};
interface EventbriteEvent {
id: string;
name: { text: string; html?: string };
description: { text: string; html?: string } | null;
url: string;
start: { utc: string; local: string; timezone: string };
end: { utc: string; local: string; timezone: string } | null;
venue?: {
name: string;
address: {
localized_address_display: string;
latitude: string;
longitude: string;
};
} | null;
logo?: { url: string } | null;
category_id: string | null;
is_free: boolean;
ticket_availability?: {
minimum_ticket_price?: { display: string };
maximum_ticket_price?: { display: string };
};
}
interface EventbriteResponse {
events: EventbriteEvent[];
pagination: { page_count: number; page_number: number };
}
function getApiKey(): string | null {
return process.env.EVENTBRITE_API_KEY || null;
}
export const eventbriteProvider: EventProvider = { export const eventbriteProvider: EventProvider = {
type: 'eventbrite', type: 'eventbrite',
async fetchEvents(_url: string, _name: string, ctx?: ProviderContext): Promise<ProviderResult> { async fetchEvents(
const apiKey = getApiKey(); _url: string,
if (!apiKey) { _name: string,
return { events: [], error: 'EVENTBRITE_API_KEY not configured' }; ctx?: ProviderContext,
config?: ExternalServiceConfig
): Promise<ProviderResult> {
if (!ctx?.regionLabel) {
return { events: [], error: 'Region label required for Eventbrite search' };
}
if (!config) {
return { events: [], error: 'Missing research/LLM config for Eventbrite extraction' };
} }
if (!ctx?.lat || !ctx?.lon) { const regionSlug = ctx.regionLabel
return { events: [], error: 'Region coordinates required for Eventbrite search' }; .toLowerCase()
} .replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
const searchUrl = `https://www.eventbrite.com/d/germany--${regionSlug}/events/`;
try { try {
const params = new URLSearchParams({ const events = await extractEventsFromWebsite(
'location.latitude': String(ctx.lat), searchUrl,
'location.longitude': String(ctx.lon), `Eventbrite ${ctx.regionLabel}`,
'location.within': `${ctx.radiusKm ?? 25}km`, config.manaResearchUrl,
'start_date.range_start': new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'), config.manaLlmUrl
sort_by: 'date', );
expand: 'venue,ticket_availability',
});
// Add keyword search if region label available for (const event of events) {
if (ctx.regionLabel) { if (!event.externalId) {
params.set('q', ctx.regionLabel); event.externalId = `eventbrite:${event.title.slice(0, 30)}`;
}
} }
const res = await fetch(`${API_BASE}/events/search/?${params}`, {
headers: { Authorization: `Bearer ${apiKey}` },
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
if (!res.ok) {
const body = await res.text().catch(() => '');
return { events: [], error: `Eventbrite API ${res.status}: ${body.slice(0, 200)}` };
}
const data = (await res.json()) as EventbriteResponse;
const events = data.events.map(toNormalizedEvent).filter(Boolean) as NormalizedEvent[];
return { events }; return { events };
} catch (err) { } catch (err) {
return { return {
@ -119,48 +57,3 @@ export const eventbriteProvider: EventProvider = {
} }
}, },
}; };
function toNormalizedEvent(eb: EventbriteEvent): NormalizedEvent | null {
const title = eb.name?.text?.trim();
if (!title) return null;
const startAt = new Date(eb.start.utc);
if (isNaN(startAt.getTime())) return null;
const endAt = eb.end ? new Date(eb.end.utc) : null;
let location: string | null = null;
let lat: number | null = null;
let lon: number | null = null;
if (eb.venue) {
location = eb.venue.name || eb.venue.address?.localized_address_display || null;
lat = eb.venue.address?.latitude ? parseFloat(eb.venue.address.latitude) : null;
lon = eb.venue.address?.longitude ? parseFloat(eb.venue.address.longitude) : null;
}
let priceInfo: string | null = null;
if (eb.is_free) {
priceInfo = 'Eintritt frei';
} else if (eb.ticket_availability?.minimum_ticket_price) {
const min = eb.ticket_availability.minimum_ticket_price.display;
const max = eb.ticket_availability.maximum_ticket_price?.display;
priceInfo = max && max !== min ? `${min} ${max}` : min;
}
return {
title,
description: eb.description?.text?.slice(0, 2000) ?? null,
location,
lat,
lon,
startAt,
endAt,
allDay: false,
imageUrl: eb.logo?.url ?? null,
sourceUrl: eb.url,
category: eb.category_id ? (CATEGORY_MAP[eb.category_id] ?? 'other') : null,
priceInfo,
externalId: `eventbrite:${eb.id}`,
};
}