mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 04:41:09 +02:00
feat(events): Phase 4 — provider adapters for Eventbrite + Meetup
- Add EventProvider interface (base.ts) with fetchEvents(url, name, ctx, config) - Refactor iCal parser and website extractor as provider adapters - Add Eventbrite provider: API v3 search by location, category mapping, price info extraction. Requires EVENTBRITE_API_KEY env var. - Add Meetup provider: GraphQL API search by location, topic→category mapping, HTML stripping. Requires MEETUP_API_KEY env var. - Provider registry (getProvider, PROVIDER_TYPES) replaces hardcoded switch in crawl-scheduler - Crawl scheduler now joins sources with regions for ProviderContext (lat/lon/radius/label) — platform providers need this for geo-search - Source creation accepts 'eventbrite' and 'meetup' types (url optional) - Both providers gracefully return empty when API keys unconfigured 116 tests (all passing), no regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
4d82381737
commit
ed801cf725
13 changed files with 708 additions and 44 deletions
|
|
@ -95,8 +95,8 @@ export async function getSources(): Promise<DiscoverySource[]> {
|
|||
}
|
||||
|
||||
export async function createSource(input: {
|
||||
type: 'ical' | 'website';
|
||||
url: string;
|
||||
type: 'ical' | 'website' | 'eventbrite' | 'meetup';
|
||||
url?: string;
|
||||
name: string;
|
||||
regionId: string;
|
||||
crawlIntervalHours?: number;
|
||||
|
|
|
|||
|
|
@ -20,9 +20,11 @@ export interface DiscoveryInterest {
|
|||
createdAt: string;
|
||||
}
|
||||
|
||||
export type SourceType = 'ical' | 'website' | 'eventbrite' | 'meetup';
|
||||
|
||||
export interface DiscoverySource {
|
||||
id: string;
|
||||
type: 'ical' | 'website';
|
||||
type: SourceType;
|
||||
url: string | null;
|
||||
name: string;
|
||||
regionId: string | null;
|
||||
|
|
|
|||
|
|
@ -42,6 +42,8 @@ const TEST_CONFIG: Config = {
|
|||
},
|
||||
manaResearchUrl: 'http://localhost:3068',
|
||||
manaLlmUrl: 'http://localhost:3025',
|
||||
eventbriteApiKey: null,
|
||||
meetupApiKey: null,
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
|
|||
90
services/mana-events/src/__tests__/providers.test.ts
Normal file
90
services/mana-events/src/__tests__/providers.test.ts
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
/**
|
||||
* Provider unit tests — tests the provider registry and individual
|
||||
* provider adapters without hitting real APIs.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'bun:test';
|
||||
import { getProvider, PROVIDER_TYPES } from '../discovery/providers';
|
||||
|
||||
describe('Provider registry', () => {
|
||||
it('has all expected provider types', () => {
|
||||
expect(PROVIDER_TYPES).toContain('ical');
|
||||
expect(PROVIDER_TYPES).toContain('website');
|
||||
expect(PROVIDER_TYPES).toContain('eventbrite');
|
||||
expect(PROVIDER_TYPES).toContain('meetup');
|
||||
});
|
||||
|
||||
it('returns a provider for each type', () => {
|
||||
for (const type of PROVIDER_TYPES) {
|
||||
const provider = getProvider(type);
|
||||
expect(provider).not.toBeNull();
|
||||
expect(provider!.type).toBe(type);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns null for unknown type', () => {
|
||||
expect(getProvider('unknown')).toBeNull();
|
||||
expect(getProvider('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Eventbrite provider', () => {
|
||||
it('returns empty with error when API key is not set', async () => {
|
||||
const provider = getProvider('eventbrite')!;
|
||||
const result = await provider.fetchEvents('', 'Test', {
|
||||
lat: 47.997,
|
||||
lon: 7.842,
|
||||
radiusKm: 25,
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Meetup provider', () => {
|
||||
it('returns empty with error when API key is not set', async () => {
|
||||
const provider = getProvider('meetup')!;
|
||||
const result = await provider.fetchEvents('', 'Test', {
|
||||
lat: 47.997,
|
||||
lon: 7.842,
|
||||
radiusKm: 25,
|
||||
regionLabel: 'Freiburg',
|
||||
});
|
||||
expect(result.events).toHaveLength(0);
|
||||
expect(result.error).toContain('MEETUP_API_KEY');
|
||||
});
|
||||
|
||||
it('gracefully handles missing coordinates (after API key check)', async () => {
|
||||
const provider = getProvider('meetup')!;
|
||||
const result = await provider.fetchEvents('', 'Test');
|
||||
expect(result.events).toHaveLength(0);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('iCal provider', () => {
|
||||
it('returns error for invalid URL', async () => {
|
||||
const provider = getProvider('ical')!;
|
||||
const result = await provider.fetchEvents('not-a-url', 'Test');
|
||||
expect(result.events).toHaveLength(0);
|
||||
expect(result.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Website provider', () => {
|
||||
it('returns error when config is missing', async () => {
|
||||
const provider = getProvider('website')!;
|
||||
const result = await provider.fetchEvents('https://example.com', 'Test');
|
||||
expect(result.events).toHaveLength(0);
|
||||
expect(result.error).toContain('config');
|
||||
});
|
||||
});
|
||||
|
|
@ -15,9 +15,12 @@ export interface Config {
|
|||
// Hard cap on total RSVPs per token
|
||||
rsvpMaxPerToken: number;
|
||||
};
|
||||
// Phase 2: external service URLs for event discovery
|
||||
// External service URLs for event discovery
|
||||
manaResearchUrl: string;
|
||||
manaLlmUrl: string;
|
||||
// Platform API keys (optional — providers gracefully skip when unconfigured)
|
||||
eventbriteApiKey: string | null;
|
||||
meetupApiKey: string | null;
|
||||
}
|
||||
|
||||
export function loadConfig(): Config {
|
||||
|
|
@ -43,5 +46,7 @@ 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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,19 +13,25 @@
|
|||
|
||||
import { and, eq, lt, or, isNull, sql } from 'drizzle-orm';
|
||||
import type { Database } from '../db/connection';
|
||||
import { discoverySources, discoveredEvents } from '../db/schema/discovery';
|
||||
import { parseIcalFeed } from './ical-parser';
|
||||
import { extractEventsFromWebsite } from './website-extractor';
|
||||
import { discoverySources, discoveredEvents, discoveryRegions } from '../db/schema/discovery';
|
||||
import { getProvider, type ExternalServiceConfig } from './providers';
|
||||
import { computeDedupeHash } from './deduplicator';
|
||||
import type { NormalizedEvent } from './types';
|
||||
|
||||
const MAX_ERROR_COUNT = 5;
|
||||
|
||||
/** Find all sources due for a crawl. */
|
||||
/** Find all sources due for a crawl, joined with their region for context. */
|
||||
async function getDueSources(db: Database) {
|
||||
return db
|
||||
.select()
|
||||
.select({
|
||||
source: discoverySources,
|
||||
regionLat: discoveryRegions.lat,
|
||||
regionLon: discoveryRegions.lon,
|
||||
regionRadiusKm: discoveryRegions.radiusKm,
|
||||
regionLabel: discoveryRegions.label,
|
||||
})
|
||||
.from(discoverySources)
|
||||
.leftJoin(discoveryRegions, eq(discoverySources.regionId, discoveryRegions.id))
|
||||
.where(
|
||||
and(
|
||||
eq(discoverySources.isActive, true),
|
||||
|
|
@ -37,39 +43,48 @@ async function getDueSources(db: Database) {
|
|||
);
|
||||
}
|
||||
|
||||
/** External service URLs for Phase 2 website extraction. */
|
||||
/** External service URLs for website extraction + LLM. */
|
||||
interface CrawlConfig {
|
||||
manaResearchUrl: string;
|
||||
manaLlmUrl: string;
|
||||
}
|
||||
|
||||
/** Crawl a single source and return normalized events. */
|
||||
/** Crawl a single source via its provider and return normalized events. */
|
||||
async function crawlSource(
|
||||
source: typeof discoverySources.$inferSelect,
|
||||
config?: CrawlConfig
|
||||
config?: CrawlConfig,
|
||||
regionCtx?: {
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
radiusKm: number | null;
|
||||
label: string | null;
|
||||
}
|
||||
): Promise<{ events: NormalizedEvent[]; error?: string }> {
|
||||
const provider = getProvider(source.type);
|
||||
if (!provider) {
|
||||
return { events: [], error: `Unknown source type: ${source.type}` };
|
||||
}
|
||||
|
||||
if (!source.url && !['eventbrite', 'meetup'].includes(source.type)) {
|
||||
return { events: [], error: 'No URL configured' };
|
||||
}
|
||||
|
||||
try {
|
||||
switch (source.type) {
|
||||
case 'ical': {
|
||||
if (!source.url) return { events: [], error: 'No URL configured' };
|
||||
const events = await parseIcalFeed(source.url, source.name);
|
||||
return { events };
|
||||
}
|
||||
case 'website': {
|
||||
if (!source.url) return { events: [], error: 'No URL configured' };
|
||||
if (!config)
|
||||
return { events: [], error: 'Missing research/LLM config for website extraction' };
|
||||
const events = await extractEventsFromWebsite(
|
||||
source.url,
|
||||
source.name,
|
||||
config.manaResearchUrl,
|
||||
config.manaLlmUrl
|
||||
);
|
||||
return { events };
|
||||
}
|
||||
default:
|
||||
return { events: [], error: `Unsupported source type: ${source.type}` };
|
||||
}
|
||||
const ctx =
|
||||
regionCtx?.lat != null
|
||||
? {
|
||||
lat: regionCtx.lat!,
|
||||
lon: regionCtx.lon!,
|
||||
radiusKm: regionCtx.radiusKm ?? 25,
|
||||
regionLabel: regionCtx.label ?? undefined,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const extConfig: ExternalServiceConfig | undefined = config
|
||||
? { manaResearchUrl: config.manaResearchUrl, manaLlmUrl: config.manaLlmUrl }
|
||||
: undefined;
|
||||
|
||||
return await provider.fetchEvents(source.url ?? '', source.name, ctx, extConfig);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
return { events: [], error: message };
|
||||
|
|
@ -137,9 +152,15 @@ async function upsertEvents(
|
|||
async function processSource(
|
||||
db: Database,
|
||||
source: typeof discoverySources.$inferSelect,
|
||||
config?: CrawlConfig
|
||||
config?: CrawlConfig,
|
||||
regionCtx?: {
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
radiusKm: number | null;
|
||||
label: string | null;
|
||||
}
|
||||
): Promise<void> {
|
||||
const { events, error } = await crawlSource(source, config);
|
||||
const { events, error } = await crawlSource(source, config, regionCtx);
|
||||
const now = new Date();
|
||||
|
||||
if (error) {
|
||||
|
|
@ -194,8 +215,13 @@ async function cleanupExpiredEvents(db: Database): Promise<number> {
|
|||
export async function runCrawlTick(db: Database, config?: CrawlConfig): Promise<void> {
|
||||
try {
|
||||
const due = await getDueSources(db);
|
||||
for (const source of due) {
|
||||
await processSource(db, source, config);
|
||||
for (const row of due) {
|
||||
await processSource(db, row.source, config, {
|
||||
lat: row.regionLat,
|
||||
lon: row.regionLon,
|
||||
radiusKm: row.regionRadiusKm,
|
||||
label: row.regionLabel,
|
||||
});
|
||||
}
|
||||
|
||||
const expired = await cleanupExpiredEvents(db);
|
||||
|
|
@ -237,16 +263,29 @@ export async function crawlSourceNow(
|
|||
sourceId: string,
|
||||
config?: CrawlConfig
|
||||
): Promise<{ upserted: number; error?: string }> {
|
||||
const sources = await db
|
||||
.select()
|
||||
const rows = await db
|
||||
.select({
|
||||
source: discoverySources,
|
||||
regionLat: discoveryRegions.lat,
|
||||
regionLon: discoveryRegions.lon,
|
||||
regionRadiusKm: discoveryRegions.radiusKm,
|
||||
regionLabel: discoveryRegions.label,
|
||||
})
|
||||
.from(discoverySources)
|
||||
.leftJoin(discoveryRegions, eq(discoverySources.regionId, discoveryRegions.id))
|
||||
.where(eq(discoverySources.id, sourceId))
|
||||
.limit(1);
|
||||
|
||||
if (!sources[0]) return { upserted: 0, error: 'Source not found' };
|
||||
if (!rows[0]) return { upserted: 0, error: 'Source not found' };
|
||||
|
||||
const source = sources[0];
|
||||
const { events, error } = await crawlSource(source, config);
|
||||
const { source } = rows[0];
|
||||
const regionCtx = {
|
||||
lat: rows[0].regionLat,
|
||||
lon: rows[0].regionLon,
|
||||
radiusKm: rows[0].regionRadiusKm,
|
||||
label: rows[0].regionLabel,
|
||||
};
|
||||
const { events, error } = await crawlSource(source, config, regionCtx);
|
||||
const now = new Date();
|
||||
|
||||
if (error) {
|
||||
|
|
|
|||
56
services/mana-events/src/discovery/providers/base.ts
Normal file
56
services/mana-events/src/discovery/providers/base.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/**
|
||||
* Base interface for event discovery providers.
|
||||
*
|
||||
* Each provider knows how to fetch events from a specific source type
|
||||
* (iCal, website, Eventbrite, Meetup, etc.). The crawl scheduler
|
||||
* dispatches to the correct provider based on the source type.
|
||||
*/
|
||||
|
||||
import type { NormalizedEvent } from '../types';
|
||||
|
||||
/** Context passed to providers for region-aware searches. */
|
||||
export interface ProviderContext {
|
||||
/** Region center latitude. */
|
||||
lat?: number;
|
||||
/** Region center longitude. */
|
||||
lon?: number;
|
||||
/** Search radius in km. */
|
||||
radiusKm?: number;
|
||||
/** Region label (e.g. "Freiburg"). */
|
||||
regionLabel?: string;
|
||||
}
|
||||
|
||||
/** Result from a provider fetch. */
|
||||
export interface ProviderResult {
|
||||
events: NormalizedEvent[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** Configuration for external services (mana-research, mana-llm). */
|
||||
export interface ExternalServiceConfig {
|
||||
manaResearchUrl: string;
|
||||
manaLlmUrl: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Event provider interface — all source types implement this.
|
||||
*/
|
||||
export interface EventProvider {
|
||||
/** Source type this provider handles. */
|
||||
readonly type: string;
|
||||
|
||||
/**
|
||||
* Fetch events from a source.
|
||||
*
|
||||
* @param url - Source URL (iCal feed, website, or API endpoint)
|
||||
* @param name - Human-readable source name
|
||||
* @param ctx - Region context for location-aware providers
|
||||
* @param config - External service URLs (for website/LLM providers)
|
||||
*/
|
||||
fetchEvents(
|
||||
url: string,
|
||||
name: string,
|
||||
ctx?: ProviderContext,
|
||||
config?: ExternalServiceConfig
|
||||
): Promise<ProviderResult>;
|
||||
}
|
||||
166
services/mana-events/src/discovery/providers/eventbrite.ts
Normal file
166
services/mana-events/src/discovery/providers/eventbrite.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Eventbrite provider — fetches events from the Eventbrite API.
|
||||
*
|
||||
* Uses the public Event Search API v3 which requires an API token
|
||||
* but is free for read operations.
|
||||
*
|
||||
* 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).
|
||||
*/
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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' };
|
||||
}
|
||||
|
||||
if (!ctx?.lat || !ctx?.lon) {
|
||||
return { events: [], error: 'Region coordinates required for Eventbrite search' };
|
||||
}
|
||||
|
||||
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',
|
||||
});
|
||||
|
||||
// Add keyword search if region label available
|
||||
if (ctx.regionLabel) {
|
||||
params.set('q', ctx.regionLabel);
|
||||
}
|
||||
|
||||
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 {
|
||||
events: [],
|
||||
error: err instanceof Error ? err.message : 'Eventbrite fetch failed',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
19
services/mana-events/src/discovery/providers/ical.ts
Normal file
19
services/mana-events/src/discovery/providers/ical.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
/**
|
||||
* iCal provider — wraps the existing ical-parser as an EventProvider.
|
||||
*/
|
||||
|
||||
import type { EventProvider, ProviderResult } from './base';
|
||||
import { parseIcalFeed } from '../ical-parser';
|
||||
|
||||
export const icalProvider: EventProvider = {
|
||||
type: 'ical',
|
||||
|
||||
async fetchEvents(url: string, name: string): Promise<ProviderResult> {
|
||||
try {
|
||||
const events = await parseIcalFeed(url, name);
|
||||
return { events };
|
||||
} catch (err) {
|
||||
return { events: [], error: err instanceof Error ? err.message : 'iCal parse failed' };
|
||||
}
|
||||
},
|
||||
};
|
||||
26
services/mana-events/src/discovery/providers/index.ts
Normal file
26
services/mana-events/src/discovery/providers/index.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
/**
|
||||
* Provider registry — maps source types to their EventProvider implementation.
|
||||
*/
|
||||
|
||||
import type { EventProvider } from './base';
|
||||
import { icalProvider } from './ical';
|
||||
import { websiteProvider } from './website';
|
||||
import { eventbriteProvider } from './eventbrite';
|
||||
import { meetupProvider } from './meetup';
|
||||
|
||||
export type { EventProvider, ProviderContext, ProviderResult, ExternalServiceConfig } from './base';
|
||||
|
||||
const PROVIDERS: Record<string, EventProvider> = {
|
||||
ical: icalProvider,
|
||||
website: websiteProvider,
|
||||
eventbrite: eventbriteProvider,
|
||||
meetup: meetupProvider,
|
||||
};
|
||||
|
||||
/** Get the provider for a source type, or null if unknown. */
|
||||
export function getProvider(type: string): EventProvider | null {
|
||||
return PROVIDERS[type] ?? null;
|
||||
}
|
||||
|
||||
/** All registered provider types. */
|
||||
export const PROVIDER_TYPES = Object.keys(PROVIDERS);
|
||||
224
services/mana-events/src/discovery/providers/meetup.ts
Normal file
224
services/mana-events/src/discovery/providers/meetup.ts
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
/**
|
||||
* Meetup provider — fetches events from the Meetup GraphQL API.
|
||||
*
|
||||
* Uses the public GraphQL endpoint which requires an API key (OAuth token).
|
||||
* Free for read operations.
|
||||
*
|
||||
* API docs: https://www.meetup.com/api/schema/#p02-GraphQL-API
|
||||
*
|
||||
* When no API key is configured, this provider is a no-op (returns empty).
|
||||
*/
|
||||
|
||||
import type { EventProvider, ProviderContext, ProviderResult } from './base';
|
||||
import type { NormalizedEvent } from '../types';
|
||||
|
||||
const GRAPHQL_URL = 'https://api.meetup.com/gql';
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
|
||||
/** Meetup topic category → our category mapping. */
|
||||
const TOPIC_CATEGORY_MAP: Record<string, string> = {
|
||||
tech: 'tech',
|
||||
'science-tech': 'tech',
|
||||
music: 'music',
|
||||
'arts-culture': 'art',
|
||||
sports: 'sport',
|
||||
'food-drink': 'food',
|
||||
'outdoors-adventure': 'nature',
|
||||
'health-wellbeing': 'education',
|
||||
'language-ethnic-identity': 'community',
|
||||
'social-activities': 'community',
|
||||
career: 'education',
|
||||
education: 'education',
|
||||
'parents-family': 'family',
|
||||
};
|
||||
|
||||
const SEARCH_EVENTS_QUERY = `
|
||||
query SearchEvents($filter: SearchConnectionFilter!) {
|
||||
keywordSearch(filter: $filter) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
result {
|
||||
... on Event {
|
||||
id
|
||||
title
|
||||
description
|
||||
dateTime
|
||||
endTime
|
||||
eventUrl
|
||||
going
|
||||
venue {
|
||||
name
|
||||
address
|
||||
city
|
||||
state
|
||||
lat
|
||||
lng
|
||||
}
|
||||
featuredEventPhoto {
|
||||
highResUrl
|
||||
}
|
||||
group {
|
||||
name
|
||||
topicCategory {
|
||||
urlkey
|
||||
}
|
||||
}
|
||||
feeSettings {
|
||||
amount
|
||||
currency
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
interface MeetupEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
dateTime: string;
|
||||
endTime: string | null;
|
||||
eventUrl: string;
|
||||
going: number;
|
||||
venue: {
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
} | null;
|
||||
featuredEventPhoto: { highResUrl: string } | null;
|
||||
group: {
|
||||
name: string;
|
||||
topicCategory: { urlkey: string } | null;
|
||||
};
|
||||
feeSettings: { amount: number; currency: string } | null;
|
||||
}
|
||||
|
||||
function getApiKey(): string | null {
|
||||
return process.env.MEETUP_API_KEY || null;
|
||||
}
|
||||
|
||||
export const meetupProvider: EventProvider = {
|
||||
type: 'meetup',
|
||||
|
||||
async fetchEvents(_url: string, _name: string, ctx?: ProviderContext): Promise<ProviderResult> {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
return { events: [], error: 'MEETUP_API_KEY not configured' };
|
||||
}
|
||||
|
||||
if (!ctx?.lat || !ctx?.lon) {
|
||||
return { events: [], error: 'Region coordinates required for Meetup search' };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(GRAPHQL_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
query: SEARCH_EVENTS_QUERY,
|
||||
variables: {
|
||||
filter: {
|
||||
query: ctx.regionLabel ?? '',
|
||||
lat: ctx.lat,
|
||||
lon: ctx.lon,
|
||||
radius: ctx.radiusKm ?? 25,
|
||||
source: 'EVENTS',
|
||||
startDateRange: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
}),
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
return { events: [], error: `Meetup API ${res.status}: ${body.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
const edges = data?.data?.keywordSearch?.edges ?? [];
|
||||
const events: NormalizedEvent[] = [];
|
||||
|
||||
for (const edge of edges) {
|
||||
const node = edge?.node?.result;
|
||||
if (!node || !node.title) continue;
|
||||
|
||||
const normalized = toNormalizedEvent(node);
|
||||
if (normalized) events.push(normalized);
|
||||
}
|
||||
|
||||
return { events };
|
||||
} catch (err) {
|
||||
return {
|
||||
events: [],
|
||||
error: err instanceof Error ? err.message : 'Meetup fetch failed',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
function toNormalizedEvent(m: MeetupEvent): NormalizedEvent | null {
|
||||
const title = m.title?.trim();
|
||||
if (!title) return null;
|
||||
|
||||
const startAt = new Date(m.dateTime);
|
||||
if (isNaN(startAt.getTime())) return null;
|
||||
|
||||
// Skip past events
|
||||
if (startAt.getTime() < Date.now() - 24 * 60 * 60 * 1000) return null;
|
||||
|
||||
const endAt = m.endTime ? new Date(m.endTime) : null;
|
||||
|
||||
let location: string | null = null;
|
||||
let lat: number | null = null;
|
||||
let lon: number | null = null;
|
||||
|
||||
if (m.venue) {
|
||||
const parts = [m.venue.name, m.venue.address, m.venue.city].filter(Boolean);
|
||||
location = parts.join(', ') || null;
|
||||
lat = m.venue.lat || null;
|
||||
lon = m.venue.lng || null;
|
||||
}
|
||||
|
||||
let priceInfo: string | null = null;
|
||||
if (m.feeSettings) {
|
||||
priceInfo = `${m.feeSettings.amount} ${m.feeSettings.currency}`;
|
||||
}
|
||||
|
||||
const topicKey = m.group?.topicCategory?.urlkey;
|
||||
const category = topicKey ? (TOPIC_CATEGORY_MAP[topicKey] ?? 'community') : 'community';
|
||||
|
||||
// Strip HTML from description
|
||||
const description = m.description
|
||||
? m.description
|
||||
.replace(/<[^>]*>/g, '')
|
||||
.trim()
|
||||
.slice(0, 2000)
|
||||
: null;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
location,
|
||||
lat,
|
||||
lon,
|
||||
startAt,
|
||||
endAt,
|
||||
allDay: false,
|
||||
imageUrl: m.featuredEventPhoto?.highResUrl ?? null,
|
||||
sourceUrl: m.eventUrl,
|
||||
category,
|
||||
priceInfo,
|
||||
externalId: `meetup:${m.id}`,
|
||||
};
|
||||
}
|
||||
35
services/mana-events/src/discovery/providers/website.ts
Normal file
35
services/mana-events/src/discovery/providers/website.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Website provider — wraps the LLM-based website extractor as an EventProvider.
|
||||
*/
|
||||
|
||||
import type { EventProvider, ProviderResult, ExternalServiceConfig } from './base';
|
||||
import { extractEventsFromWebsite } from '../website-extractor';
|
||||
|
||||
export const websiteProvider: EventProvider = {
|
||||
type: 'website',
|
||||
|
||||
async fetchEvents(
|
||||
url: string,
|
||||
name: string,
|
||||
_ctx,
|
||||
config?: ExternalServiceConfig
|
||||
): Promise<ProviderResult> {
|
||||
if (!config) {
|
||||
return { events: [], error: 'Missing research/LLM config for website extraction' };
|
||||
}
|
||||
try {
|
||||
const events = await extractEventsFromWebsite(
|
||||
url,
|
||||
name,
|
||||
config.manaResearchUrl,
|
||||
config.manaLlmUrl
|
||||
);
|
||||
return { events };
|
||||
} catch (err) {
|
||||
return {
|
||||
events: [],
|
||||
error: err instanceof Error ? err.message : 'Website extraction failed',
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -38,8 +38,8 @@ const interestCreateSchema = z.object({
|
|||
});
|
||||
|
||||
const sourceCreateSchema = z.object({
|
||||
type: z.enum(['ical', 'website']),
|
||||
url: z.string().url().max(2000),
|
||||
type: z.enum(['ical', 'website', 'eventbrite', 'meetup']),
|
||||
url: z.string().url().max(2000).optional(), // optional for platform providers
|
||||
name: z.string().min(1).max(200),
|
||||
regionId: z.string().uuid(),
|
||||
crawlIntervalHours: z.number().int().min(1).max(168).optional(), // max 7 days
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue