mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 03:46:41 +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)
|
// Chat Server (Hono/Bun)
|
||||||
{
|
{
|
||||||
path: 'apps/chat/apps/server/.env',
|
path: 'apps/chat/apps/server/.env',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue