mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
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:
parent
536fc89050
commit
97abd251e3
5 changed files with 60 additions and 155 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -42,7 +42,6 @@ const TEST_CONFIG: Config = {
|
|||
},
|
||||
manaResearchUrl: 'http://localhost:3068',
|
||||
manaLlmUrl: 'http://localhost:3025',
|
||||
eventbriteApiKey: null,
|
||||
meetupApiKey: null,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue