A Bluesky bot that posts weekly updates about free museum days in Chicago, built on Val Town.
π€ Follow the bot: chicagomuseumdays.bsky.social
Many Chicago museums offer free admission days for Illinois residents, but finding this information is scattered and hard to track. This project:
- Scrapes free museum day data from Choose Chicago
- Normalizes dates (handles ranges, recurring patterns like "every Wednesday")
- Stores data in SQLite with manual override support
- Posts weekly summaries to Bluesky every Monday morning
- Serves data via a public JSON API
Choose Chicago (HTML)
β
scrapeChooseChicago() β [{institution, rawLines[], url}]
β
normalizeDates() β [{institution, date, time, proof_required, notes}]
β
loadToSQLite() β scraped_free_days table
β
βββ bot.cron.ts β Bluesky post (weekly)
βββ api.http.ts β JSON API (on-demand)
| File | Type | Description |
|---|---|---|
db.ts | Script | SQLite database functions |
scrapeChooseChicago.ts | Script | HTML scraper using cheerio |
utils.ts | Script | Date normalization with chrono-node |
scraper.cron.ts | Cron | Monthly scraper orchestrator |
bot.cron.ts | Cron | Weekly Bluesky poster |
api.http.ts | HTTP | Public JSON API |
Once deployed, the HTTP val exposes:
| Endpoint | Description |
|---|---|
GET / | API documentation |
GET /free-days?start=YYYY-MM-DD&end=YYYY-MM-DD | Free days in date range |
GET /upcoming | Next 7 days |
GET /today | Today's free days |
GET /institutions | List all museums |
GET /health | Health check |
{ "data": [ { "institution": "Art Institute of Chicago", "date": "2025-01-27", "time": "11am-close", "proof_required": "Illinois residents with ID", "notes": null } ], "count": 1 }
Two SQLite tables with override support:
scraped_free_daysβ Auto-populated by scrapers, replaced on each runfree_days_overridesβ Manual corrections that take precedence
Uses institution_key (slugified name) for reliable matching across sources.
Overrides can also cancel incorrect scraped entries via is_cancelled.
Copy the backend files to your Val Town project. Files ending in .cron.ts
become cron vals, .http.ts becomes an HTTP val.
In Val Town secrets, add:
BLUESKY_HANDLE=your-handle.bsky.social
BLUESKY_PASSWORD=your-app-password
Tip: Use a Bluesky App Password instead of your main password.
Run scraper.cron.ts manually to populate initial data.
| Val | Schedule | Cron Expression |
|---|---|---|
scraper.cron.ts | First Monday of month | 0 14 1-7 * 1 (UTC) |
bot.cron.ts | Every Monday 8am Central | See note below |
Timezone Note: Val Town cron runs in UTC. For 8am Central Time:
- Winter (CST, UTC-6):
0 14 * * 1 - Summer (CDT, UTC-5):
0 13 * * 1
The utils module handles various date formats:
- Specific dates: "January 15" β
2025-01-15 - Date ranges: "January 5 - February 28" β expands to individual dates
- Recurring: "every Wednesday" β generates dates for next 12 months
- Weekdays: "weekdays only" β generates Mon-Fri for next 12 months
Lines are classified as either date-ish or note-ish using pattern matching, since Choose Chicago mixes dates and eligibility requirements in the same lists.
cheerioβ HTML parsingchrono-nodeβ Natural language date parsing@atproto/apiβ Bluesky SDKhonoβ Lightweight web framework
Data corrections can be added via the free_days_overrides table:
import { addOverride } from "./db.ts";
await addOverride("Art Institute of Chicago", "2025-02-15", {
time: "10am-5pm",
notes: "Presidents Day special hours",
override_reason: "Confirmed via museum website",
});
To cancel an incorrect scraped entry:
await addOverride("Some Museum", "2025-03-01", {
is_cancelled: true,
override_reason: "Event was cancelled per museum announcement",
});
Monthly:
- Review scraper logs for parsing failures
- Add overrides for unparseable dates
Quarterly:
- Audit data against official museum websites
- Update parser patterns if needed
- Implementation Spec β Full technical details
- Val Town Docs
- Choose Chicago Source
MIT