Bluesky bots that post traffic camera videos for every U.S. state. Each bot runs on a 30-minute cron and posts a timelapse or live video clip from a randomly chosen camera.
There are two core bot types, plus hybrids that support both:
Image timelapse bots download a series of snapshots from a DOT camera API at a fixed interval, deduplicate them by hash (to detect frozen cameras), stitch them into an MP4 with ffmpeg, and post to Bluesky. The resulting video is sped up to target a ~30-second output.
Live video clip bots capture a segment of a live HLS stream directly with ffmpeg, then re-encode it at a dynamic speed (2β16Γ) to target a ~30-second output. Some states require authentication: Florida, Georgia, and Pennsylvania use DIVAS token exchange; Arkansas and Massachusetts use token-redirect auth.
Video speed is calculated dynamically: speed = clamp(captureDuration / 30s, 2Γ, 16Γ). A 60-second clip encodes at 2Γ (30s output); a 480-second clip encodes at 16Γ (30s output). The speed is shown in every post (e.g., 4x speed).
Each bot tracks the last 48 chosen cameras and prefers cameras it hasn't used recently. Posts include the camera name or a reverse-geocoded location, timestamp, speed, and weather (temperature and conditions at start and end of the capture window, via Open-Meteo).
Montana posts 24-hour timelapses (images captured every 15 minutes over a full day). Ohio has a 25% chance of posting a multi-camera thread instead of a single post.
Clip durations are sampled randomly from 1β8 minutes per run and encoded at 2β16Γ speed.
DIVAS auth: The 511 platform (FL511, 511GA, 511PA) requires a two-step token exchange β a session token from the 511 portal is exchanged with the arcadis-ivds.com API to get an authenticated HLS URL. Georgia and Pennsylvania use the image ID (not the camera ID) for this exchange.
These bots post a live video clip if the chosen camera has an HLS stream, or fall back to an image timelapse otherwise.
| State | Account | Cameras | Source |
|---|---|---|---|
| California | @californiatrafficcams.bsky.social | 2000+ across 12 Caltrans districts | Caltrans |
| Colorado | @coloradotrafficcams.bsky.social | 1000+ (800+ video, 200+ image-only) | COtrip |
| Iowa | @iowatrafficcams.bsky.social | 1170+ (620+ video, 540+ image-only) | 511 Iowa |
| Hawaii | @hawaiitrafficcams.bsky.social | 310+ (280+ video, 30+ image-only) | GoAkamai |
| Minnesota | @minnesotatrafficcams.bsky.social | 1510+ (1230+ video, 270+ image-only) | 511MN |
| Illinois | @illinoistrafficcams.bsky.social | 870+ image cameras + Jane Byrne Interchange live video | Travel Midwest |
Road trip mode posts a Bluesky thread showing live traffic across multiple states along a single interstate β one post per state, each reply linking to the next.
./run-road-trip.sh # picks the stalest highway automatically
./run-road-trip.sh --highway I-75
./run-road-trip.sh --highway I-95 --dry-run
The thread is started by a dedicated roadtrip Bluesky account, which posts an intro with a generated map image showing the highway's route. The intro names the highway's endpoints, e.g. "I-75 from Hialeah, FL to Sault Ste. Marie, MI π£οΈ β 1,786 miles.". Each state bot then replies using its own account, with a post title like "I-75 through Tennessee π£οΈ" including the clip time range, speed, and weather.
The thread requires at least 2 states to succeed. All states use the same randomly sampled clip duration (1β8 minutes). States with slow-refresh image cameras (60s+ intervals) are unlikely to produce enough unique frames within the clip window and are soft-skipped.
Camera selection (findCameraOnHighway) happens in two passes. Pass 1 matches camera names against the highway (I-75, I 75, Interstate 75). Pass 2 β the fallback β reverse-geocodes a random sample of cameras via the Google Maps Geocoding API (result_type=route) and keeps any whose nearby routes include the target highway.
Fair highway rotation: when invoked without --highway, the stalest highway (oldest lastRunAt, or never-run) is chosen. Per-highway last-run timestamps and the last 5 camera IDs picked per state are persisted to cron/roadtrip-state.json, so subsequent road trips on the same highway avoid repeating recent cameras.
Concurrency: state captures run with a bounded concurrency of 6 (see CAPTURE_CONCURRENCY in road-trip.js) rather than sequentially, which meaningfully shortens longer road trips (e.g. I-95's 14 states).
Supported interstates and US highways: see roadtrip/highways.json (I-10, I-20, I-26, I-35, I-40, I-49, I-55, I-59, I-64, I-70, I-75, I-77, I-80, I-81, I-85, I-90, I-94, I-95, US-1, US-2, US-11, US-20, US-30, US-41, US-50, US-101). Each entry defines ordered states, total miles, and start/end endpoints.
@classictraffic.bsky.social is the project's umbrella account. It doesn't post original content β it exists to host the fleet-wide starter pack and surface notable posts from the state bots. Credentials live in keys.js under the meta key.
meta/engagement-repost.js scans each state bot's recent posts, scores by engagement, and reposts the top N from the meta account. It's idempotent β re-reading the meta account's existing repost records on each run to skip anything already reposted. Each run:
- Fetches the meta account's existing repost records so posts aren't reposted twice.
- For each state bot, pulls posts from the last
LOOKBACK_DAYS(default 3) that are older thanPOST_AGE_MIN_HOURS(default 6, so posts have time to accumulate engagement). - Scores each candidate:
likes + 2Β·reposts + 3Β·replies, filtered byMIN_LIKES(default 3). - Reposts the top
MAX_REPOSTS_PER_RUN(default 3) from the meta account.
Tune the constants at the top of the file. Suggested cadence: 1β2Γ per day via cron. Supports --dry-run.
node roadtrip/fetch-highway-routes.js I-22 I-68 I-82
This fetches route geometry from OpenStreetMap, computes total mileage, determines the ordered list of states via Nominatim reverse geocoding, and writes the entry to roadtrip/highways.json. Re-run with a highway name to force a refresh.
Create a keys.js file (gitignored):
module.exports = {
googleKey: '...', // Google Maps API key (reverse geocoding)
service: 'https://bsky.social',
videoService: 'https://video.bsky.app',
launchDarklyKey: '...', // Optional: LaunchDarkly SDK key for per-bot feature flags
grafana: { // Optional: Grafana Loki run telemetry
lokiUrl: '...',
user: '...',
apiKey: '...',
},
accounts: {
ohio: { identifier: '...', password: '...' },
// one entry per state, plus:
roadtrip: { identifier: '...', password: '...' },
},
};Then install dependencies:
npm ci
node states/<state>.js
# or
npm run <state>
| Flag | Description |
|---|---|
--list |
List available cameras and exit (no login required) |
--dry-run |
Capture and encode but skip posting to Bluesky |
--persist |
Keep the assets folder after the run |
--id <id> |
Force a specific camera instead of random selection |
Bots are normally invoked by run-bot.sh, which adds file locking (prevents overlapping runs), Grafana Loki telemetry, LaunchDarkly feature flag checks, and process timeout enforcement.
TrafficBot.js # Base class: image loop, deduplication, video encoding, Bluesky posting
states/ # One file per state, extends TrafficBot
meta/ # Scripts for the @classictraffic umbrella account
roadtrip/ # Road trip thread logic
road-trip.js # Entry point invoked by run-road-trip.sh
generate-road-trip-map.js # Generates the highway map image for intro posts
fetch-highway-routes.js # Fetches route GeoJSON from Overpass API, populates highways.json
highways.json # Interstate/US-highway definitions (ordered states, miles, endpoints)
highway-routes/ # Cached route GeoJSON per highway (gitignored)
scripts/ # Standalone utility scripts
status.js # Reports last successful post time per bot
check-flag.js # LaunchDarkly per-bot enabled check (called by run-bot.sh)
sun-times.js # NOAA sunrise/sunset calculator (called by schedule-skydeck.sh)
run-bot.sh # Cron wrapper: locking, timeouts, feature flags, telemetry
run-meta.sh # Cron wrapper for meta/ scripts: locking, timeouts, logging
run-road-trip.sh # Cron wrapper for road trip mode
schedule-skydeck.sh # Schedules Chicago skydeck sunrise/sunset captures via `at`
keys.js # Credentials (gitignored)
assets/ # Temporary download directory (gitignored)
cron/ # Per-bot logs, lock files, recent camera lists, roadtrip-state.json (gitignored)
Handles the shared workflow for image timelapse bots:
- Login to Bluesky
- Fetch and select a camera (prefers cameras not used in the last 48 runs)
- Download images at a fixed interval, deduplicating by MD5 hash
- If duplicates are detected, increase the interval by 1.5Γ (up to 4Γ the base) and reset on the next unique image; abort if the camera appears frozen
- Create a timelapse video with ffmpeg
- Upload and post to Bluesky with location, timestamp, speed factor, and weather
- Clean up temp files
Constructor config:
| Property | Type | Description |
|---|---|---|
accountName |
string | Key in keys.js accounts (e.g. 'ohio') |
timezone |
string | IANA timezone for timestamps (e.g. 'America/New_York') |
tzAbbrev |
string | Timezone abbreviation shown in posts (e.g. 'ET') |
framerate |
number | Video playback fps |
delayBetweenImageFetches |
number | Base milliseconds between image downloads |
maxImageCollectionMs |
number | Optional cap on total collection time |
targetOutputSeconds |
number | Target output video length in seconds (default: 30) |
is24HourTimelapse |
boolean | If true, labels the post as a "24-Hour Timelapse:" |
threadProbability |
number | Probability (0β1) of posting a multi-camera thread instead of a single post |
Methods subclasses implement:
| Method | Returns | Description |
|---|---|---|
fetchCameras() |
Promise<Camera[]> |
Fetch available cameras from the state's data source |
downloadImage(index) |
Promise<boolean> |
Download one image; return true if unique |
getNumImages() |
number |
How many images to capture per run |
shouldAbort() |
boolean |
Return true to skip video and post (e.g. frozen camera). Default: abort after 10 consecutive duplicates |
getTimeout() |
number |
Max runtime in seconds before the process is killed (default: 7200) |
Helper methods available to subclasses:
| Method | Description |
|---|---|
this.checkAndStoreImage(path, index) |
Validates JPEG, deduplicates by hash, deletes duplicates; returns true if unique |
this.getImagePath(index) |
Returns the file path for a given image index |
this.getSetpts(captureDurationS) |
Computes the ffmpeg setpts factor for dynamic speed (2β16Γ) |
this.downloadVideoSegment(duration) |
(video bots) Capture and encode an HLS stream segment |
this.reverseGeocode(lat, lon) |
Returns a location string via Google Maps Geocoding API |
this.fetchWeather(lat, lon) |
Returns {tempF, description} via Open-Meteo (no API key needed) |
this.sleep(ms) |
Async delay |
Camera object:
{
id: string, // Unique identifier (source-specific)
name: string, // Display name
url: string, // Snapshot URL or HLS stream URL
latitude: number, // GPS latitude (0 if unknown)
longitude: number, // GPS longitude (0 if unknown)
hasVideo?: boolean, // Hybrid bots: true if camera has an HLS stream
imageId?: string, // 511 platform bots: image ID used for DIVAS auth (may differ from camera ID)
}- Add credentials to
keys.js - Create
states/<state>.jsextendingTrafficBot:- Image timelapse: use
states/ohio.jsas a template β implementfetchCameras(),downloadImage(), andgetNumImages() - Live video clip: use
states/nevada.jsas a template β overriderun()and adddownloadVideoSegment() - Live video with DIVAS auth: use
states/florida.jsas a template - Hybrid: use
states/california.jsas a template
- Image timelapse: use
- Add an npm script to
package.json:"newstate": "node states/newstate.js"
status.js reads all cron/*.log files and reports when each bot last successfully posted. Bots run every 30 minutes, so any bot without a post in over an hour is flagged as stale.
node scripts/status.js
Example output:
Bot post status β 2/20/2026, 3:45:00 PM
Stale threshold: >1 hour without a successful post (bots run every 30 min)
State Last posted Since
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
[!] connecticut Thu Feb 20 10:12:44 PST 2026 5h 32m ago
[!] alabama (no log file)
ohio Thu Feb 20 15:41:22 PST 2026 3m ago
florida Thu Feb 20 15:40:01 PST 2026 5m ago
2 bot(s) stale, 48 healthy
