Note: This API uses Server-Sent Events (SSE) for real-time progress monitoring.
POST /api/scraper/triggerAuthentication: Required (Bearer token)
Request Body (optional):
{
"theaterId": "C0153", // Optional: scrape only this theater (must exist in database)
"movieId": 12345 // Optional: scrape only this movie
}Behavior:
- No parameters → Full scrape (all theaters, all movies, all dates)
theaterIdonly → Scrape this theater (all movies, all dates for this theater)movieIdonly → Scrape this movie (all theaters showing this movie)- Both
theaterIdandmovieId→ Scrape this movie at this specific theater only
Response (200 — started):
{
"success": true,
"data": {
"reportId": 43,
"message": "Scrape job queued for microservice",
"queueDepth": 2
}
}Response Fields:
reportId- Unique scrape report ID (can be used to track progress in database)message- Human-readable status messagequeueDepth- Number of jobs in the Redis queue after this job was added
Response (404 — theater not found):
{
"success": false,
"error": "Theater not found: CXXXX"
}Response (409 — already running):
{
"success": false,
"error": "A scrape is already in progress",
"data": {
"current_scrape": {
"started_at": "2024-02-15T10:00:00.000Z",
"trigger_type": "manual"
}
}
}Examples:
# Get auth token first
TOKEN=$(curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r '.data.token')
# Full scrape (all theaters, all movies)
curl -X POST http://localhost:3000/api/scraper/trigger \
-H "Authorization: Bearer $TOKEN"
# Theater-specific scrape (C-prefix)
curl -X POST http://localhost:3000/api/scraper/trigger \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"theaterId": "C0153"}'
# Theater-specific scrape (W-prefix)
curl -X POST http://localhost:3000/api/scraper/trigger \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"theaterId": "W7515"}'
# Movie-specific scrape
curl -X POST http://localhost:3000/api/scraper/trigger \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"movieId": 12345}'
# Combined: scrape specific movie at specific theater
curl -X POST http://localhost:3000/api/scraper/trigger \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"theaterId": "C0153", "movieId": 12345}'GET /api/scraper/statusAuthentication: Not required (public endpoint)
Response (200 — success):
{
"success": true,
"data": {
"isRunning": false,
"currentSession": null,
"latestReport": {
"id": 42,
"completed_at": "2024-02-15T10:15:23.000Z",
"status": "success"
}
}
}Response Fields:
isRunning- Whether a scrape is currently being processed by a workercurrentSession- Current scrape session details (null when no scrape is running)latestReport- Most recent completed scrape report from database
Example:
curl http://localhost:3000/api/scraper/statusGET /api/scraper/progressOpens a persistent Server-Sent Events connection. All previously accumulated events are replayed to new clients, then new events are streamed in real time. A heartbeat (: heartbeat) is sent every 15 seconds to keep the connection alive.
Response Headers:
Content-Type: text/event-streamCache-Control: no-cacheConnection: keep-aliveX-Accel-Buffering: no
Event Format:
All events are sent as plain data: lines (no named event: field). Each line is a JSON object with a type discriminator:
data: {"type":"started","total_theaters":3,"total_dates":7}
data: {"type":"theater_started","theater_name":"Épée de Bois","theater_id":"W7504","index":1}
data: {"type":"date_started","date":"2026-02-19","theater_name":"Épée de Bois"}
data: {"type":"movie_started","movie_title":"Mon Movie","movie_id":123456}
data: {"type":"movie_completed","movie_title":"Mon Movie","showtimes_count":5}
data: {"type":"movie_failed","movie_title":"Mon Movie","error":"HTTP 404"}
data: {"type":"date_completed","date":"2026-02-19","movies_count":12}
data: {"type":"date_failed","date":"2026-02-19","theater_name":"Épée de Bois","error":"HTTP 503"}
data: {"type":"theater_completed","theater_name":"Épée de Bois","total_movies":42}
data: {"type":"completed","summary":{"total_theaters":3,"successful_theaters":3,"failed_theaters":0,"total_movies":87,"total_showtimes":412,"total_dates":7,"duration_ms":34210,"errors":[]}}
data: {"type":"failed","error":"Fatal error message"}
Event Types:
| Type | Emitted | Payload fields |
|---|---|---|
started |
Once at start | total_theaters, total_dates |
theater_started |
Per theater | theater_name, theater_id, index |
date_started |
Per theater × date | date, theater_name |
movie_started |
Per movie | movie_title, movie_id |
movie_completed |
Per movie (success) | movie_title, showtimes_count |
movie_failed |
Per movie (error) | movie_title, error |
date_completed |
Per date (success) | date, movies_count |
date_failed |
Per date (error) | date, theater_name, error |
theater_completed |
Per theater (≥1 date ok) | theater_name, total_movies |
completed |
Once on success | summary (ScrapeSummary object) |
failed |
Once on fatal error | error |
Rate Limit Handling:
When the scraper detects an HTTP 429 (Too Many Requests) response from the source server:
- The scrape stops immediately to avoid further rate limiting
- Status is set to
rate_limitedinstead offailed - A
theater_failedevent is emitted with error_typehttp_429 - The final
completedevent includes"status": "rate_limited"in the summary
Example Rate Limited Summary:
{
"type": "completed",
"summary": {
"total_theaters": 10,
"successful_theaters": 3,
"failed_theaters": 7,
"total_movies": 45,
"total_showtimes": 212,
"total_dates": 7,
"duration_ms": 12340,
"status": "rate_limited",
"errors": [{
"theater_name": "Example Theater",
"theater_id": "C0123",
"date": "2026-03-24",
"error": "HTTP 429 Too Many Requests",
"error_type": "http_429",
"http_status_code": 429
}]
}
}Example:
curl -N http://localhost:3000/api/scraper/progressJavaScript Example:
const eventSource = new EventSource('http://localhost:3000/api/scraper/progress');
// All events arrive via onmessage (no named event: field)
eventSource.onmessage = (e) => {
const data = JSON.parse(e.data);
console.log('Event:', data.type, data);
if (data.type === 'completed') {
console.log('Scraping complete:', data.summary);
eventSource.close();
}
if (data.type === 'failed') {
console.error('Scraping failed:', data.error);
eventSource.close();
}
};
eventSource.onerror = (err) => {
console.error('SSE connection error:', err);
eventSource.close();
};POST /api/scraper/resume/:reportIdAuthentication: Required (Bearer token)
Parameters:
reportId(integer): ID of the parent report to resume (must have statusrate_limited,failed, orpartial_success)
Description: Creates a new scrape report that retries only the theater/date combinations that weren't successfully scraped in the parent report. The new report links to the parent via parent_report_id.
Response (200 — started):
{
"success": true,
"data": {
"reportId": 124,
"parentReportId": 123,
"message": "Resume job queued for microservice",
"queueDepth": 1,
"pendingAttempts": [
{ "theater_id": "C0042", "date": "2026-03-26" },
{ "theater_id": "C0089", "date": "2026-03-25" }
]
}
}Response Fields:
reportId- New scrape report ID for the resume operationparentReportId- Original report ID that is being resumedmessage- Human-readable status messagependingAttempts- List of theater/date combinations that will be retriedqueueDepth- Number of jobs in queue after this job was added
Response (404 — report not found):
{
"success": false,
"error": "Report not found: 999"
}Response (400 — no pending attempts):
{
"success": false,
"error": "No pending attempts found for report 123"
}Response (409 — already running):
{
"success": false,
"error": "A scrape is already in progress"
}Examples:
# Get auth token
TOKEN=$(curl -X POST http://localhost:3000/api/auth/login \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"admin"}' | jq -r '.data.token')
# Resume rate-limited scrape
curl -X POST http://localhost:3000/api/scraper/resume/123 \
-H "Authorization: Bearer $TOKEN"
# Check new report status
curl http://localhost:3000/api/reports/124 \
-H "Authorization: Bearer $TOKEN"Schedule recurring scrapes using cron expressions.
GET /api/scraper/schedulesAuthentication: Required (Bearer token)
Permission: scraper:schedules:list
Response (200 — success):
{
"success": true,
"data": [
{
"id": 1,
"name": "Daily Morning Scrape",
"description": "Scrape all theaters every morning at 6 AM",
"cron_expression": "0 6 * * *",
"enabled": true,
"target_theaters": ["W7504", "C0072"],
"created_by": 1,
"created_at": "2026-03-15T10:30:00Z",
"updated_at": "2026-03-15T10:30:00Z"
}
]
}GET /api/scraper/schedules/:idAuthentication: Required (Bearer token)
Permission: scraper:schedules:list
Response (200 — success):
{
"success": true,
"data": {
"id": 1,
"name": "Daily Morning Scrape",
"description": "Scrape all theaters every morning at 6 AM",
"cron_expression": "0 6 * * *",
"enabled": true,
"target_theaters": ["W7504", "C0072"],
"created_by": 1,
"created_at": "2026-03-15T10:30:00Z",
"updated_at": "2026-03-15T10:30:00Z"
}
}Response (404 — not found):
{
"success": false,
"error": "Schedule not found"
}POST /api/scraper/schedulesAuthentication: Required (Bearer token)
Permission: scraper:schedules:create
Request Body:
{
"name": "Daily Morning Scrape",
"description": "Scrape all theaters every morning at 6 AM",
"cron_expression": "0 6 * * *",
"enabled": true,
"target_theaters": ["W7504", "C0072"]
}Request Fields:
name(string, required) - Schedule name (must be unique)description(string, optional) - Human-readable descriptioncron_expression(string, required) - Cron expression (e.g.,0 6 * * *for 6 AM daily)enabled(boolean, optional, default: true) - Whether schedule is activetarget_theaters(array, optional) - List of theater IDs to scrape (empty = all theaters)
Response (201 — created):
{
"success": true,
"data": {
"id": 1,
"name": "Daily Morning Scrape",
"description": "Scrape all theaters every morning at 6 AM",
"cron_expression": "0 6 * * *",
"enabled": true,
"target_theaters": ["W7504", "C0072"],
"created_by": 1,
"created_at": "2026-03-15T10:30:00Z",
"updated_at": "2026-03-15T10:30:00Z"
}
}Response (400 — validation error):
{
"success": false,
"error": "Schedule name is required"
}PUT /api/scraper/schedules/:idAuthentication: Required (Bearer token)
Permission: scraper:schedules:update
Request Body (all fields optional):
{
"name": "Updated Schedule Name",
"description": "Updated description",
"cron_expression": "0 12 * * *",
"enabled": false,
"target_theaters": ["W7504"]
}Response (200 — success):
{
"success": true,
"data": {
"id": 1,
"name": "Updated Schedule Name",
"description": "Updated description",
"cron_expression": "0 12 * * *",
"enabled": false,
"target_theaters": ["W7504"],
"created_by": 1,
"created_at": "2026-03-15T10:30:00Z",
"updated_at": "2026-03-18T15:45:00Z"
}
}DELETE /api/scraper/schedules/:idAuthentication: Required (Bearer token)
Permission: scraper:schedules:delete
Response (204 — deleted):
(no body)
Response (404 — not found):
{
"success": false,
"error": "Schedule not found"
}POST /api/scraper/schedules/:id/triggerAuthentication: Required (Bearer token)
Permission: scraper:schedules:update
Description: Immediately trigger a schedule, bypassing the cron timing.
Response (200 — started):
{
"success": true,
"data": {
"reportId": 43,
"message": "Scrape job queued for microservice",
"queueDepth": 2
}
}