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)
{
path: 'apps/chat/apps/server/.env',

View file

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

View file

@ -29,7 +29,14 @@ describe('Provider registry', () => {
});
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 result = await provider.fetchEvents('', 'Test', {
lat: 47.997,
@ -38,15 +45,7 @@ describe('Eventbrite provider', () => {
regionLabel: 'Freiburg',
});
expect(result.events).toHaveLength(0);
expect(result.error).toContain('EVENTBRITE_API_KEY');
});
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();
expect(result.error).toContain('config');
});
});

View file

@ -18,8 +18,7 @@ export interface Config {
// External service URLs for event discovery
manaResearchUrl: string;
manaLlmUrl: string;
// Platform API keys (optional — providers gracefully skip when unconfigured)
eventbriteApiKey: string | null;
// Platform API key (optional — provider gracefully skips when unconfigured)
meetupApiKey: string | null;
}
@ -46,7 +45,6 @@ export function loadConfig(): Config {
},
manaResearchUrl: process.env.MANA_RESEARCH_URL || 'http://localhost:3068',
manaLlmUrl: process.env.MANA_LLM_URL || 'http://localhost:3025',
eventbriteApiKey: process.env.EVENTBRITE_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
* but is free for read operations.
* Eventbrite shut down their public Event Search API (/v3/events/search)
* in 2023. Location-based search is no longer available via API.
*
* API docs: https://www.eventbrite.com/platform/api#/reference/event-search
*
* When no API key is configured, this provider is a no-op (returns empty).
* Strategy: use the website extractor (mana-research + LLM) to scrape
* Eventbrite's public search pages (eventbrite.com/d/{region}/events/).
* No API key required this uses the same pipeline as any website source.
*/
import type { EventProvider, ProviderContext, ProviderResult } from './base';
import type { NormalizedEvent } from '../types';
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;
}
import type { EventProvider, ProviderContext, ProviderResult, ExternalServiceConfig } from './base';
import { extractEventsFromWebsite } from '../website-extractor';
export const eventbriteProvider: EventProvider = {
type: 'eventbrite',
async fetchEvents(_url: string, _name: string, ctx?: ProviderContext): Promise<ProviderResult> {
const apiKey = getApiKey();
if (!apiKey) {
return { events: [], error: 'EVENTBRITE_API_KEY not configured' };
async fetchEvents(
_url: string,
_name: string,
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) {
return { events: [], error: 'Region coordinates required for Eventbrite search' };
}
const regionSlug = ctx.regionLabel
.toLowerCase()
.replace(/\s+/g, '-')
.replace(/[^a-z0-9-]/g, '');
const searchUrl = `https://www.eventbrite.com/d/germany--${regionSlug}/events/`;
try {
const params = new URLSearchParams({
'location.latitude': String(ctx.lat),
'location.longitude': String(ctx.lon),
'location.within': `${ctx.radiusKm ?? 25}km`,
'start_date.range_start': new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
sort_by: 'date',
expand: 'venue,ticket_availability',
});
const events = await extractEventsFromWebsite(
searchUrl,
`Eventbrite ${ctx.regionLabel}`,
config.manaResearchUrl,
config.manaLlmUrl
);
// Add keyword search if region label available
if (ctx.regionLabel) {
params.set('q', ctx.regionLabel);
for (const event of events) {
if (!event.externalId) {
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 };
} catch (err) {
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}`,
};
}