| name | event-scraper |
| description | Create new event scraping scripts for websites. Use when adding a new event source to the Asheville Event Feed. ALWAYS start by detecting the CMS/platform and trying known API endpoints first. Browser scraping is NOT supported (Vercel limitation). Handles API-based, HTML/JSON-LD, and hybrid patterns with comprehensive testing workflows. |
Event Scraper Skill
Create new event scrapers that integrate with the Asheville Event Feed codebase. This skill provides patterns and guidance for the full lifecycle: exploration, development, testing, and production integration.
⚠️ CRITICAL: API-First Approach
Scrapers run automatically on Vercel which does NOT support browser automation.
You MUST find the site's API before considering any other approach. Modern websites almost always fetch event data from a backend API - your job is to find and use that same API.
Priority Order (STRICTLY follow this order):
- 🥇 Known CMS API - Check the Quick API Lookup table below FIRST
- 🥈 Internal JSON API - Site's own API endpoints (found via page analysis)
- 🥉 Public API - Official documented API (Ticketmaster, Eventbrite, etc.)
- 🏅 HTML with JSON-LD - Structured data embedded in HTML pages
- ❌ Browser scraping - NOT SUPPORTED on Vercel!
🚀 Quick API Endpoint Lookup (TRY THESE FIRST!)
Before doing any exploration, check if the site uses a known CMS/platform and try these endpoints directly:
| CMS/Plugin | Detection Signs | API Endpoint | Key Parameters |
|---|
| WordPress + Tribe Events | /wp-content/, "The Events Calendar" | /wp-json/tribe/events/v1/events | start_date, per_page, page |
| WordPress + All Events | "All-in-One Event Calendar" | /wp-json/osec/v1/events | start, end |
| WordPress REST | /wp-content/, /wp-admin/ | /wp-json/wp/v2/posts?type=event | per_page, page |
| Squarespace | squarespace.com, static1.squarespace.com | {any-page}?format=json | Append to URL |
| Next.js | /_next/, __NEXT_DATA__ | /_next/data/{buildId}/{page}.json | Check page source |
| Eventbrite | eventbrite.com | Internal API (see eventbrite.ts) | Complex - see example |
| Ticketmaster Venues | Venue ticket sales | Discovery API | venueId, apikey |
Example: Detecting and Using Tribe Events API
If you detect WordPress + Tribe Events, immediately try:
GET https://example.com/wp-json/tribe/events/v1/events?start_date=2025-01-01&per_page=50&page=1
This often returns rich JSON with all event data, proper timezone handling, and pagination.
Required Output Format
Every scraper MUST return ScrapedEvent[]:
interface ScrapedEvent {
sourceId: string;
source: EventSource;
title: string;
description?: string;
startDate: Date;
location?: string;
zip?: string;
organizer?: string;
price?: string;
url: string;
imageUrl?: string;
interestedCount?: number;
goingCount?: number;
timeUnknown?: boolean;
}
PHASE 1: EXPLORATION
Step 1.1: Detect CMS/Platform
Use WebFetch to analyze the target site:
WebFetch URL: https://example.com/events/
Prompt: "Analyze this page:
1. What CMS/platform is it? (WordPress, Squarespace, Next.js, custom)
2. Look for: wp-content, wp-json, squarespace, _next, __NEXT_DATA__
3. Is there JSON-LD structured data in script tags?
4. What event plugin is used? (Tribe Events, All Events Calendar, etc.)
5. Any hints about API endpoints in the HTML?"
Step 1.2: Try Known API Endpoints
Based on CMS detection, immediately try the known API endpoints from the Quick Lookup table:
WebFetch URL: https://example.com/wp-json/tribe/events/v1/events?per_page=5
Prompt: "Analyze this API response:
1. Is it returning JSON event data?
2. What fields are available? (title, start_date, venue, cost, etc.)
3. Is there timezone information?
4. What pagination mechanism is used?
5. List all available fields for each event"
Step 1.3: Test API Parameters
Once you find a working API, test common parameters:
| Parameter | Common Names | Purpose |
|---|
| Future filter | start_date, after, from, startDate | Only get future events |
| Page size | per_page, limit, count, pageSize | Control results per page |
| Pagination | page, offset, cursor, skip | Navigate pages |
| Sort | orderby, sort, sortValue | Order results |
WebFetch URL: https://example.com/wp-json/tribe/events/v1/events?start_date=2025-01-01&per_page=50
Prompt: "Does this API support:
1. start_date parameter for filtering future events?
2. per_page parameter for controlling page size?
3. What's the maximum per_page allowed?
4. How does pagination work (page number, next_url, etc.)?"
Step 1.4: Document Field Mapping
Create a mental map of API fields to ScrapedEvent fields:
| API Field | ScrapedEvent Field | Transform Needed |
|---|
id | sourceId | Prefix: "mx-${id}" |
title | title | decodeHtmlEntities() |
utc_start_date | startDate | new Date(utc + 'Z') |
cost | price | Use directly or "Unknown" |
venue.venue | location | Build string, decode entities |
venue.zip | zip | Use directly or fallback |
url | url | Use directly |
⏰ Timezone Decision Tree (CRITICAL!)
Getting timezone right is crucial. Follow this decision tree:
Does the API provide a UTC field (utc_start_date, utc_time, etc.)?
├─ YES → Use directly: new Date(utcField.replace(' ', 'T') + 'Z')
│ This is the SIMPLEST and most reliable approach.
│
└─ NO → Does the API provide ISO 8601 with offset? (e.g., "2025-12-16T19:00:00-05:00")
├─ YES → Use directly: new Date(isoString)
│
└─ NO → Does the API provide local time + timezone name? (e.g., "America/New_York")
├─ YES → Use parseAsEastern(dateStr, timeStr)
│
└─ NO → DANGER! Ambiguous local time.
- Assume Eastern for NC events
- Use parseAsEastern(dateStr, timeStr)
- Verify with test insertion!
Timezone Verification
ALWAYS verify timezone handling by comparing:
- API's local time field (e.g.,
start_date: "2025-12-16 19:00:00")
- API's UTC field (e.g.,
utc_start_date: "2025-12-17 00:00:00")
- Your parsed Date displayed in Eastern (should match #1)
Example verification:
API local: 19:00:00 (7 PM Eastern)
API UTC: 00:00:00 next day (midnight UTC = 7 PM EST, correct!)
Our parsed: 7:00:00 PM Eastern ✓
📍 Location String Best Practices
Location strings often have issues. Follow these rules:
1. Always Decode HTML Entities
const venueName = decodeHtmlEntities(venue.venue);
const address = decodeHtmlEntities(venue.address);
2. Avoid Duplicate City Names
APIs often include city in both venue name and city field:
if (venue.city && !venue.address?.includes(venue.city)) {
parts.push(venue.city);
}
3. Standard Format
const parts = [venueName];
if (venue.address) parts.push(decodeHtmlEntities(venue.address));
if (venue.city && !venue.address?.includes(venue.city)) {
parts.push(venue.city);
}
if (venue.state) parts.push(venue.state);
location = parts.join(', ');
4. Zip Code Fallbacks
let zip = venue?.zip || undefined;
if (!zip && venue?.geo_lat && venue?.geo_lng) {
zip = getZipFromCoords(venue.geo_lat, venue.geo_lng);
}
if (!zip && venue?.city) {
zip = getZipFromCity(venue.city);
}
PHASE 2: DEVELOPMENT
Step 2.1: Add Source Type
Add to lib/scrapers/types.ts:
export type EventSource = 'AVL_TODAY' | ... | 'YOUR_SOURCE';
Step 2.2: Create Scraper
Create lib/scrapers/yoursource.ts:
import { ScrapedEvent } from './types';
import { fetchWithRetry } from '@/lib/utils/retry';
import { isNonNCEvent } from '@/lib/utils/geo';
import { decodeHtmlEntities } from '@/lib/utils/parsers';
import { getZipFromCoords, getZipFromCity } from '@/lib/utils/geo';
import { getTodayStringEastern } from '@/lib/utils/timezone';
const API_BASE = 'https://example.com/wp-json/tribe/events/v1/events';
const PER_PAGE = 50;
const MAX_PAGES = 40;
const DELAY_MS = 200;
const API_HEADERS = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'application/json',
};
export async function scrapeYourSource(): Promise<ScrapedEvent[]> {
console.log('[YourSource] Starting scrape...');
const allEvents: [] = [];
today = ();
page = ;
hasMore = ;
(hasMore && page <= ) {
{
url = ();
url..(, today);
url..(, .());
url..(, page.());
.();
response = (
url.(),
{ : , : },
{ : , : }
);
data = response.();
events = data. || [];
.();
( event events) {
formatted = (event);
(formatted) allEvents.(formatted);
}
hasMore = !!data. && page < data.;
page++;
(hasMore) ( (r, ));
} (error) {
.(, error);
;
}
}
ncEvents = allEvents.( !(ev., ev.));
.();
ncEvents;
}
(): | {
startDate = (event..(, ) + );
((startDate.()) || startDate < ()) {
;
}
venue = event.;
: | ;
(venue?.) {
parts = [(venue.)];
(venue.) parts.((venue.));
(venue. && !venue.?.(venue.)) parts.(venue.);
(venue.) parts.(venue.);
location = parts.();
}
zip = venue?. || ;
(!zip && venue?. && venue?.) {
zip = (venue., venue.);
}
{
: ,
: ,
: (event.),
: event. ? (event.) : ,
startDate,
location,
zip,
: event.?.[]?.,
: event. || ,
: event.,
: event.?.,
: event. || ,
};
}
Step 2.3: Create Test Script
Create scripts/scrapers/test-yoursource.ts:
import 'dotenv/config';
import * as fs from 'fs';
import * as path from 'path';
const DEBUG_DIR = path.join(process.cwd(), 'debug-scraper-yoursource');
if (!fs.existsSync(DEBUG_DIR)) {
fs.mkdirSync(DEBUG_DIR, { recursive: true });
}
async function main() {
console.log('='.repeat(60));
console.log('SCRAPER TEST - YourSource');
console.log('='.repeat(60));
const { scrapeYourSource } = await import('../lib/scrapers/yoursource');
const startTime = Date.now();
const events = await scrapeYourSource();
duration = .() - startTime;
fs.(
path.(, ),
.(events, , )
);
.();
.();
withImages = events.( e.).;
withPrices = events.( e. && e. !== ).;
withZips = events.( e.).;
.();
.();
.();
.();
.();
( e events.(, )) {
.();
.();
.();
.();
.();
}
.();
}
().(.);
Step 2.4: Add to package.json
"test:yoursource": "npx tsx scripts/scrapers/test-yoursource.ts"
PHASE 3: VALIDATION
Run the test script and verify output:
npm run test:yoursource
Validation Checklist
PHASE 4: DATABASE TESTING
⚠️ MANDATORY: You MUST Complete This Phase
DO NOT declare production-ready until you have inserted test events into the real database and verified they display correctly.
Scraper output validation alone is NOT sufficient. Database insertion can reveal:
- Timezone conversion issues
- Field truncation
- Constraint violations
- Display problems
Step 4.1: Insert Test Events
import 'dotenv/config';
import { db } from '../lib/db';
import { events } from '../lib/db/schema';
import { eq } from 'drizzle-orm';
import { scrapeYourSource } from '../lib/scrapers/yoursource';
async function main() {
const existing = await db.select().from(events).where(eq(events.source, 'YOUR_SOURCE'));
console.log(`Existing YOUR_SOURCE events: ${existing.length}`);
const scraped = await scrapeYourSource();
const testEvents = scraped.slice(0, 5);
for (const event of testEvents) {
await db.insert(events).values({
...event,
tags: [],
lastSeenAt: new (),
}).({
: events.,
: { : () },
});
.();
}
.();
inserted = db.().(events).((events., ));
( e inserted) {
.();
.();
.();
.();
.();
.();
.();
}
.();
}
().(.);
Step 4.2: Verify Checklist
Step 4.3: Cleanup Test Data
npx tsx -e "
import 'dotenv/config';
import { db } from './lib/db';
import { events } from './lib/db/schema';
import { eq } from 'drizzle-orm';
db.delete(events).where(eq(events.source, 'YOUR_SOURCE')).then(() => console.log('Cleaned up'));
"
PHASE 5: PRODUCTION INTEGRATION
Step 5.1: Update Cron Route
Edit app/api/cron/scrape/route.ts:
import { scrapeYourSource } from '@/lib/scrapers/yoursource';
const [..., yourSourceResult] = await Promise.allSettled([
...,
scrapeYourSource(),
]);
const yourSourceEvents = yourSourceResult.status === 'fulfilled' ? yourSourceResult.value : [];
if (yourSourceResult.status === 'rejected')
console.error('[Scrape] YourSource failed:', yourSourceResult.reason);
stats.scraping.total = ... + yourSourceEvents.length;
const allEvents = [..., ...yourSourceEvents];
console.log(`... YourSource: ${yourSourceEvents.length} ...`);
Step 5.2: Verify TypeScript Compiles
npx tsc --noEmit
PHASE 6: CLEANUP
rm -rf debug-scraper-yoursource
rm scripts/scrapers/test-yoursource-db.ts
Integration Checklist
Common Utilities Reference
Timezone
import { getTodayStringEastern, parseAsEastern } from '@/lib/utils/timezone';
const today = getTodayStringEastern();
const date = parseAsEastern('2025-12-25', '19:00:00');
Price Formatting
import { formatPrice } from '@/lib/utils/parsers';
formatPrice(0);
formatPrice(25.50);
formatPrice(null);
HTML Entities
import { decodeHtmlEntities } from '@/lib/utils/parsers';
decodeHtmlEntities('Rock & Roll – Live');
Location Filtering
import { isNonNCEvent } from '@/lib/utils/geo';
if (isNonNCEvent(event.title, event.location)) continue;
Zip Code Fallbacks
import { getZipFromCoords, getZipFromCity } from '@/lib/utils/geo';
let zip = venue.zip || getZipFromCoords(lat, lng) || getZipFromCity(city);
Troubleshooting
API Returns 403/429
- Add realistic headers (User-Agent, Accept, Referer)
- Increase delays between requests (200-500ms)
- Some APIs require
Referer header matching the site
Dates Off by Hours
- Check Timezone Decision Tree above
- Verify API returns UTC vs local time
- Compare API local time with your parsed Eastern time
Duplicate Events
- Ensure
url is unique per event
- For recurring events, append date to URL:
${url}#${date}
Missing Events
- Check pagination (off-by-one errors)
- Verify
start_date parameter format
- API may have max page limit
HTML in Titles/Locations
- Apply
decodeHtmlEntities() to ALL text fields
- Check for
<br>, <p> tags that need stripping
Duplicate City in Location
- Check if city already in address before appending
- Common with APIs that include full address + separate city field