Skip to content

Repository files navigation

FlightWatch ✈

An open, free, self-hosting fare tracker for New Zealand ↔ Sri Lanka & India — the Christchurch ↔ Colombo and Auckland ↔ Colombo / Delhi / Mumbai corridors out of the box (and any route you add). It scrapes the full fare list several times a day, builds an intraday price history, forecasts where the fare is heading with a calibrated model, and shows a buy / wait decision with a confidence rate — plus deal scores, a predicted booking curve, a cheapest-day heatmap and free push alerts — all running for $0 on GitHub's free tier.

  • Data: Google Flights, scraped with real headless browsers (Playwright) — no API key, no token, no paid proxy. Shared Chromium and Firefox browsers run every itinerary concurrently (a lightweight context + rotating fingerprint/engine each — a genuine Chrome/Firefox mix, not just spoofed UA strings), harvesting every offer per scan to build a large open dataset
  • Coverage: a rolling ~6-month grid — every day a departure × every trip length 20–40 days (~5,300 itineraries, auto-generated relative to today) — sharded across scan slots and days so the whole grid is covered every 2 days without any single job growing past its usual size (or blowing the CI limit), on top of a dense fixed set for deep intraday curves
  • Cadence: multiple scans/day; rows carry an hourly scan_slot so intraday moves accumulate instead of overwriting
  • Intelligence (100% offline): quantile gradient-boosting + split-conformal bands, seasonality features, a walk-forward backtest, deal scores, anomaly detection, best-time-to-book and templated natural-language summaries — no LLM, no API cost
  • Alerts: optional free Telegram / email pushes for new lows, price drops and BUY calls
  • Automation: GitHub Actions (free & unlimited for public repos) · Hosting: GitHub Pages (free)
  • Dataset: every observation is committed as plain CSV in data/ — open for anyone

Fares are informational. Always confirm the live price before booking.

Experimental — Singapore Airlines' own site. Besides Google Flights, an opt-in source (flightwatch/provider_sq.py) scrapes Singapore Airlines' public Best Fare Finder for SQ's own end-to-end fares — the genuine "SQ flying the whole trip" price. It is off by default (the scheduled scan only uses Google Flights, which stays rock-solid) because singaporeair.com sits behind heavy bot protection and needs selector tuning against a live page. Try it with python -m flightwatch sq-diag CHC CMB 2026-09-05 2026-09-26, which prints what it found and saves a screenshot + HTML to debug/.

Singapore Airlines on the dashboard. The command-center spotlights a configured carrier (featured_airline, default Singapore Airlines). Because SQ often flies these corridors as a connection (from Christchurch it's CHC→SIN→CMB with Air New Zealand), the dashboard distinguishes SQ operating one leg from SQ flying the whole trip on its own metal (e.g. AKL→SIN→CMB all on SQ) and shows the cheapest whole-trip SQ fare separately. The trip finder lets you filter for any single carrier — even one that only flies a leg of a connection.

Why scrape Google Flights? FlightWatch first used the Amadeus Self-Service API (dropped its free tier), then the Travelpayouts Data API (free, but a cache of Aviasales searches that simply has no data for a thin route like CHC↔CMB — it returned zero fares). Google Flights actually has the fares, and driving a real browser needs no key and stays free — so the tracker can be fully autonomous.

Heads up: scraping is inherently more fragile than an official API. Google can change its markup or rate-limit a runner. FlightWatch is built to degrade gracefully (it records no_results instead of crashing and saves debug screenshots), and the scraper is isolated in one file so it's easy to repair.


How it works

config.yaml ──► flightwatch/collect.py ──► data/flights_YYYY_MM.csv (append-only, intraday slots)
                     (async, shared browser)        │
                     flightwatch/predict.py ───►  model + backtest ┐
                     flightwatch/analytics.py ─►  offline AI layer ┤
                     flightwatch/dashboard.py ─►  docs/index.html  │ (GitHub Pages)
                     flightwatch/alerts.py ────►  Telegram / email ┘ (optional)

A GitHub Actions cron runs the collector several times a day — and again on every merge to main, so the published dashboard refreshes the moment a change lands. Shared Chromium + Firefox browsers scrape every itinerary concurrently (each context using a different fingerprint/engine to dodge soft-blocks), appends every fare found as its own CSV row stamped with an hourly scan_slot, retrains the forecast model, regenerates the dashboard, sends any fresh alerts, and commits everything back to the repo. Over weeks, the per-itinerary history becomes an intraday booking curve — which is what makes a real "should I book now?" prediction (and an honest backtest of it) possible.

When does the prediction run? (scheduled, not per-request)

All modelling is precomputed on a schedule and baked into a static site — it does not run when a visitor opens the page. Each CI run trains the model once, writes the forecasts/analytics into docs/index.html (plus a lazy-loaded explore.json), and commits them; visitors just download that static JSON from GitHub Pages. This is the right design for a $0/static stack: training gradient-boosting models + cross-validation

  • conformal calibration per visitor would need an always-on server, cost money, add latency, and recompute identical numbers for everyone. Precompute-once, serve-static is cheaper, faster and reproducible.

Two cadences keep it fresh:

Workflow Cadence Does Cost
scan (daily-scan.yml) every 6 h (+ on merge) scrape fares → retrain → rebuild → commit heavy (real browsers)
exogenous (exogenous.yml) hourly refresh FX + jet-fuel → rebuild predictions (no scrape) → commit tiny (a few HTTP calls)

So fares (the rate-limited, browser-heavy part) refresh every 6 h, while the cheap exogenous signals — and the predictions that fold them in — refresh hourly. Bump the scan cron for fresher fares (watch CI minutes / soft-block risk), or the exogenous cron toward */30 for an even fresher fuel proxy.


Project layout

flightwatch/            # the Python package (importable, no path hacks)
  __init__.py           #   shared paths (repo root, data/, docs/, config.yaml)
  provider.py           #   fare source — Google Flights scraper, sync + async (swap providers here)
  collect.py            #   async shared-browser scan -> appends to data/
  storage.py            #   append-only monthly CSVs, intraday scan_slot de-duplication
  predict.py            #   forecast + buy/wait/watch with conformal bands, seasonality, backtest
  analytics.py          #   offline AI: deal scores, anomalies, heatmap, digest, narratives
  dashboard.py          #   renders docs/index.html
  seo.py                #   renders docs/routes/<o>-<d>/ SEO pages + sitemap.xml/robots.txt
  alerts.py             #   optional free Telegram / email pushes + the channel digest
  publish.py            #   OPTIONAL scan -> Firestore writer (no-op without creds)
  exogenous.py          #   route-aware events + offline FX/jet-fuel features (per-route, NZD-based)
  exogenous_fetch.py    #   pulls FX + Brent (jet-fuel proxy) from free, key-less sources
  __main__.py           #   CLI:  python -m flightwatch [collect|build|all|alert|publish|backtest|exo|diag]
config.yaml             # what to track + concurrency + monetization
data/                   # the open dataset (monthly CSVs)
docs/                   # the published three-view app (GitHub Pages)
functions/              # OPTIONAL Cloud Function: per-user alert fan-out
firestore.rules         # OPTIONAL Firestore security rules
firebase.json           # OPTIONAL Firebase deploy config
.github/workflows/      # the scan cron + the pytest CI job
tests/                  # pytest suite (storage, model, analytics, alerts)

Everything runs through the package, so the provider lives in exactly one file (flightwatch/provider.py). To use a different fare source later, keep its search_flight_offers / search_flight_offers_async shape and the rest is unchanged.


Setup (about 10 minutes, all free — no API keys at all)

The scraper needs no token or secret, so setup is just GitHub settings.

1. Create the repo

  1. Make a public GitHub repository (public = free Actions + Pages).
  2. Upload these files (or git push them).

2. Allow the workflow to commit

Repo → Settings → Actions → General → Workflow permissions → select Read and write permissions → Save. (The workflow commits the daily data back.)

3. Turn on Pages

Repo → Settings → Pages → Source: Deploy from a branch → Branch: main, Folder: /docs → Save. Your dashboard will be at https://<your-username>.github.io/<repo-name>/.

4. Run it once

Repo → Actions tab → scanRun workflow. The workflow installs a headless Chromium, scrapes each route, and commits the first data point to data/. From then on it runs automatically several times a day. If a run scrapes 0 fares, download the scrape-debug artifact from that run to see exactly what Google returned.


Run locally (optional)

pip install -r requirements.txt
python -m playwright install --with-deps chromium firefox   # one-time browser download (both engines)

python -m flightwatch collect    # one scrape -> appends to data/
python -m flightwatch build      # regenerate docs/index.html
python -m flightwatch all        # both at once
python -m flightwatch alert      # push fresh signals (dry-prints if no secrets set)
python -m flightwatch digest     # post the public Telegram channel digest (--dry-run to preview)
python -m flightwatch backtest   # how the engine's past calls actually fared
python -m flightwatch diag       # scrape one route verbosely + save debug screenshot
python -m flightwatch sq-diag    # EXPERIMENTAL: scrape Singapore Airlines' own site
open docs/index.html

Run the tests with pytest (they use synthetic data — no network or browser needed).

Set FLIGHTWATCH_HEADFUL=1 (the CI default, via xvfb-run) to drive non-headless browsers, which Google is less likely to soft-block than pure headless.


Configure what gets tracked

Edit config.yaml. The fixed itineraries: list is the dense core — daily depth on a few date-pairs that builds tight intraday curves:

itineraries:
  - {origin: CHC, destination: CMB, depart_date: 2026-09-01, return_date: 2026-09-22}
  - {origin: AKL, destination: CMB, depart_date: 2026-09-01, return_date: 2026-09-22}

For breadth, the auto_generate: block sweeps a rolling window of departures across the next ~6 months for each route — every day a departure, paired with every trip length in a range (e.g. 20–40 days). It's recomputed from today every run, so it always covers the next 6 months. Each route can override any grid setting (depart_step_days, horizon_days, min_days_out, trip_length_*), so a dense core corridor and a lighter new route can share one grid without the additions blowing the CI budget:

auto_generate:
  enabled: true
  horizon_days: 180         # ~6 months out (block default)
  min_days_out: 1
  depart_step_days: 1       # every day is a departure date (block default)
  trip_length_min: 20       # trip duration 20..40 days inclusive
  trip_length_max: 40
  trip_length_step: 1
  routes:
    - {origin: CHC, destination: CMB}                      # dense: every day
    - {origin: AKL, destination: CMB, depart_step_days: 4} # lighter sweep
    - {origin: AKL, destination: DEL, depart_step_days: 14}
    - {origin: AKL, destination: BOM, depart_step_days: 14}
  shard_across_slots: true  # see below
  slots_per_day: 4          # must match the cron's daily run count (every 6h -> 4)
  shards: 8

That full grid is ~5,300 itineraries — far too many for one run. So the generated grid is sharded across scan slots (and, when shards exceeds slots_per_day, across days too): each run scrapes one shard (~1/shards, a few hundred itineraries, comfortably inside a 6-hourly slot), and the whole grid is covered once every shards / slots_per_day days (here: 8/4 = every 2 days). Sharding is stable per itinerary, so the same date/length always lands in the same recurring slot (a clean per-slot history). The fixed itineraries: are never sharded — they run every scan for tight intraday curves.

Widening the window without growing any single run: this is how horizon_days went from 90 to 180 days — shards was doubled right alongside it (4 → 8), so each run's slice of the grid stayed the same size (~650–700 itineraries, same as before) and only the cycle length changed (1 day → 2 days). To widen further, keep shards a multiple of slots_per_day and scale it with the grid size; to shrink runs instead, raise depart_step_days / trip_length_step, or drop shard_across_slots for a smaller, unsharded grid. slots_per_day should always equal the cron's actual daily run count — it's also what gates Singapore Airlines' own-site scrape cadence (sq.slots), independent of however big shards grows.

Tune concurrency (how many bots scrape at once) / jitter_seconds here too. Change the scan frequency in .github/workflows/daily-scan.yml (the cron, in UTC — currently every 6 hours). Scheduled runs only fire on the default branch, so a new cadence takes effect once it's merged there.


The buy / wait decision (with confidence)

flightwatch/predict.py is the decision engine. While history is thin it uses a transparent heuristic (today's price vs the route's own min/median and days left) and honestly reports low confidence. Once there are enough cheapest-per-day observations (~120) it trains gradient-boosting models over the booking curve with:

  • Honest validation — error is time-series cross-validated (TimeSeriesSplit, forward-chaining), never a shuffled split that would leak the future and overstate skill.
  • Calibrated bands — a split-conformal interval whose width is learned from real held-out residuals, and now per route (a thin corridor gets its own wider band), with the engine auto-picking conformal vs quantile bands by whichever actually hits the target coverage on held-out data, so an "80% band" really covers ~80% of outcomes.
  • Seasonality + market features — cyclical day-of-week / month / week-of-year and booking-day encodings, lead-time buckets, a per-route price level, plus:
    • Per-route demand calendar — each route's NZ-origin holidays unioned with its own destination's festivals (Sri Lankan New Year for CHC↔CMB, Diwali for AKL↔DEL), kept separate per route, not one blurred global set.
    • Hierarchical pooling — a shrunk (route × departure-month) price level so thin nearby dates borrow strength from each other instead of overfitting.
    • Competition / supply — carriers and offers on sale, nonstop availability and the nonstop premium, fed straight into the model.
    • Exogenous hooks (offline) — a jet-fuel index and destination-currency FX read from optional CSVs in data/exogenous/ (the traveller always books in NZD, so FX is keyed on the foreign end); absent ⇒ a neutral signal, present ⇒ it sharpens automatically. See data/exogenous/README.md.
  • Direct drop probability — a gradient-boosting classifier estimates P(the fare falls meaningfully before departure), instead of only inferring it from the quantile spread.

Each recommendation exposes its full reasoning — signal (BUY / WAIT / WATCH), confidence (0–100%), the whole predicted forward curve with bands, the expected forecast low, expected savings from waiting and the drop probability.

Route context (which airports map to which country/currency, and each country's hot months) ships with sensible NZ/LK/IN defaults and is overridable in config.yaml under route_context: — no code changes needed to add a destination.

Offline AI layer (flightwatch/analytics.py, no LLM or API) adds, on the dashboard:

  • Deal score (0–100) for each route's current cheapest fare
  • Predicted booking curve fan chart + best time to book
  • Cheapest day to fly heatmap (which departure/return weekday is cheapest)
  • What changed since the last scan, and anomaly drop/spike detection
  • Model accuracy — a walk-forward backtest of whether past BUY/WAIT calls paid off
  • A per-route natural-language summary generated purely from the numbers

…alongside the existing market insights and latest-offers tables.

Free push alerts (optional)

flightwatch/alerts.py turns the freshest signals into Telegram / email notifications, de-duplicated via data/alert_state.json. It is a no-op until you add secrets, so the project stays free and runnable by anyone:

  • Telegram — message @BotFather to create a bot, then set repo Settings → Secrets and variables → Actions secrets TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID.
  • Email (optional) — set SMTP_HOST, SMTP_USER, SMTP_PASS, ALERT_EMAIL_TO (e.g. a Gmail address + app password).

Alerts fire on a fresh all-time low, an unusual price drop, a new BUY call, or a "book now — window closing" nudge.


The app: Answer · Watch · Lab

The published dashboard (docs/index.html, regenerated by python -m flightwatch build) is a single self-contained three-view app driven entirely by the scraped data — see redesign/UX_REVAMP.md:

  • Answer — tell it your trip (?o=&d=&dep=&len= is deep-linkable) and get one honest BUY / WAIT / WATCH verdict: the best-buy window, a confidence rate with provenance (observations + this route's backtested hit-rate), a 7-day fare-weather strip built from the model's conformal forecast curve, and the fare story from real daily history. The headline verdict is pre-rendered as static HTML so it reads before any JavaScript runs. Below it:
    • Flights on this trip — the distinct flights tracked on that exact trip in the latest scan: operating carrier(s), stops, the real connection airport(s) and total duration, with the cheapest tagged. The same airline can appear more than once (different routing/timing) — we show each, never collapsing it to one line. Exact flight numbers and departure times are confirmed on the booking page (the data source doesn't expose them, so we don't invent them — the Book CTA deep-links there).
    • Save by being flexible — the Flexibility Engine: for your trip it finds the cheapest nearby alternative across three levers — shift the departure date, change the trip length, or fly from another NZ origin — each with the saving and a real Book link, plus a price-by-departure-date bar. Every suggested saving must clear a gate of max(4%, the model's conformal band), so it only surfaces real money, never sampling noise.
  • Lab — pick a route, then dig into that corridor: a real, auditable walk-forward backtest scorecard (accuracy + money saved vs booking on the day you first searched), a price surface (a departure-date × trip-length heatmap — flexibility at a glance, cheapest cell starred), the forecast fan with its conformal band, a cheapest-day heatmap, and a trip finder over the route's slice of the ~1,900-itinerary grid (lazy-loaded from explore.json, kept off the Answer's critical path).
  • Watch — pin trips and get told when to buy. Firebase is wired and live (project flightproject-299a8): anonymous/Google Auth, Firestore-synced trips across devices, and per-user alerts (below). If the config is ever absent or the network drops, the same code paths fall back to device-only mode (localStorage) so the page never breaks.

Every "Book" button deep-links into a flight search for the exact route+dates via the monetization: providers in config.yaml — add your Travelpayouts marker and the links start earning, with zero code changes. Fares stay informational and the honest empty/collecting states remain; accuracy and savings are only ever shown when there's enough history to back them (otherwise "still learning").

Turn on affiliate earnings (about 10 minutes)

The booking links are wired and clickable out of the box — but with no marker set they earn $0. The build prints a loud warning (WARNING: ... UNTRACKED, earning $0) whenever monetization.enabled is true and the marker is still blank, so this is never silently missed.

  1. Sign up at travelpayouts.com (free) — one marker powers Aviasales, WayAway and Kiwi.com.
  2. Grab your numeric marker from the dashboard.
  3. Paste it into config.yamlmonetization.travelpayouts_marker.
  4. (Optional, separate) Sign up for a Skyscanner partner associateid and paste it into monetization.skyscanner_associateid.
  5. python -m flightwatch build (or just push — the next scheduled build picks it up) — the warning disappears and every "Book" click is now tracked.

Growth & earnings funnel

The scraper/model/dashboard machinery works on its own; this layer makes the product findable, shareable and measurable, so the founder's one-time account signups actually switch on a complete revenue funnel. Full context and the reasoning behind it: redesign/EARN-2026-07.md. Every piece below is config/env-gated and off by default — the site builds and works with zero of these accounts set up.

  • SEO corridor pages (flightwatch/seo.py) — python -m flightwatch build generates a self-contained, JS-free page per tracked corridor at docs/routes/<origin>-<dest>/index.html (e.g. docs/routes/chc-cmb/), answering the cheapest month to fly, the typical price range, how far ahead fares tend to be cheapest, today's BUY/WAIT verdict and a handful of current best deals — with real "last updated" dates, FAQPage structured data, and the same affiliate Book links (and disclosure) as the dashboard. It also writes docs/sitemap.xml and docs/robots.txt, and links every route page from the dashboard footer ("Route guides"). config.yamlsite.base_url sets the public URL used for canonical links, the sitemap and OG/Twitter meta — change it if you fork or rename the repo.
  • Analytics (optional, off by default) — GoatCounter is a free, keyless, cookie-less page/event analytics service. Sign up (free), paste your site code into config.yamlanalytics.goatcounter_code, and rebuild: the tracking script (plus Book-link-click and copy-share events) is only emitted when this is non-empty — an empty code means zero analytics ship with the site.
  • Telegram channel digestpython -m flightwatch digest [--dry-run] composes one public-channel post of the top current deals per corridor (price, verdict, a link to the route page), skipping deals already posted (its own dedupe state, data/digest_state.json) and posting nothing when there's nothing new. Needs repo secrets TELEGRAM_BOT_TOKEN + TELEGRAM_CHANNEL_ID (a channel, not the personal-alerts TELEGRAM_CHAT_ID above) — without them it's a silent no-op, matching the rest of the project. daily-scan.yml runs it automatically after each build (continue-on-error: true, so a digest hiccup can never fail the scan).
  • Share-ready deals — a one-tap "Copy deal" action on the verdict card and the Explore page's standout-deals rows copies plain text (route, dates, price, a one-line verdict, and the matching route-page link tagged ?utm_source=share) ready to paste into WhatsApp/Facebook, plus real Open Graph/Twitter meta on the dashboard so shared links unfurl with real text.

Turn on earnings — the founder's activation checklist

Three independent, free signups turn this from "built" to "earning and growing" — none required for the site to build or work:

  1. Affiliate marker — see "Turn on affiliate earnings" above (monetization.travelpayouts_marker). Missing this makes the build print a loud, unmissable warning in the Actions log (monetization.enabled is true but the marker is blank) — every "Book" click is live but earning $0.
  2. Telegram channel — create a public channel + a bot via @BotFather, add the bot as an admin, and set TELEGRAM_BOT_TOKEN + TELEGRAM_CHANNEL_ID as repo secrets so python -m flightwatch digest has an audience to post to.
  3. Analytics code — sign up at goatcounter.com (free) and paste the code into config.yamlanalytics.goatcounter_code, so Book clicks and shares are actually measurable.

Optional: real accounts, sync & per-user alerts (Firebase)

Everything above runs for $0 with no accounts. Firebase is a fully optional, additive edge that turns the Watch into a real product — accounts that sync across devices, live-updating verdicts, and a Cloud Function that pushes each user a personal alert when their trip hits BUY / a price target / a closing window. The scraper, model, CSV pipeline and data/ are untouched; with no config the site builds and runs exactly as before. Full design: redesign/FIREBASE.md.

Files in this repo for it: firestore.rules (security rules), firebase.json / firestore.indexes.json (deploy config), and functions/ (the scheduled fanOutAlerts Cloud Function, which reuses alerts.py's Telegram logic).

Setup (one-time):

  1. Create a Firebase project (free Spark tier is enough for everything except the scheduled function, which needs Blaze — at this volume it's cents/month).
  2. Auth → enable Anonymous and Google sign-in.
  3. Firestore → create a database, then deploy the rules: firebase deploy --only firestore:rules.
  4. Functions (for personal alerts) → cd functions && npm install, then firebase deploy --only functions. Set the shared Telegram token with firebase functions:config:set / an env var TELEGRAM_BOT_TOKEN; for email, install the Trigger Email extension (the function writes to the mail collection).
  5. Add the project's public web config as a GitHub repo Variable (not a secret — it's not sensitive) named FARO_FIREBASE_CONFIG, holding the JSON Firebase gives you ({apiKey, authDomain, projectId, messagingSenderId, appId, …}). The build injects it as window.FARO_FIREBASE and the Watch goes live. Optionally add FARO_FCM_VAPID_KEY (Cloud Messaging → Web Push certificates) to enable browser push.
  6. For the scan→Firestore writer, add the Admin service-account JSON as a GitHub Secret named FARO_FIREBASE_SERVICE_ACCOUNT. The workflow then runs python -m flightwatch publish after each scan to upsert routes/{route} verdicts. Keep this secret out of the repo — it's the only credential that can write routes/*.
GitHub setting Name What it is
Variable FARO_FIREBASE_CONFIG Public web config JSON (injected inline)
Variable FARO_FCM_VAPID_KEY Optional Web Push VAPID key (enables push)
Secret FARO_FIREBASE_SERVICE_ACCOUNT Admin service-account JSON (Firestore writes)

python -m flightwatch publish is a clean no-op when no service account is configured, and never touches data/ or the commit step — so it can't affect the open dataset or the always-free build.


Troubleshooting: "it ran but scraped 0 fares"

Scraping Google Flights is the trade-off for being keyless and free. If a run records no_results, it's almost always one of:

  1. Google soft-blocked the runner — datacenter IPs are fingerprinted. The workflow already runs a non-headless Chromium under xvfb (via FLIGHTWATCH_HEADFUL=1) to look more like a real browser, which is usually enough for a once-a-day, few-route scrape. A later daily run typically succeeds.
  2. Google changed its markup — the extractor keys off the stable "… round trip total" ARIA label rather than CSS class names, but if Google reshapes the page the selectors in provider.py may need a tweak.

To see exactly what Google returned, scrape one route locally and inspect the saved screenshot + HTML:

python -m flightwatch diag
ls debug/        # diag-CHC-CMB.png / .html

Every failed CI scrape also uploads those files as the scrape-debug artifact on the run, so you can debug a remote run without reproducing it locally.

If Google blocking ever becomes persistent on GitHub's shared IPs, the most reliable free fix is to run the same python -m flightwatch all on any small always-on machine (a home server / Raspberry Pi) on a cron and push the commits — the code is identical; only the runner's IP changes.


Cost reality check

Piece Service Cost
Automation (several runs/day) GitHub Actions (public repo, standard runner) Free, unlimited
Dashboard hosting GitHub Pages Free
Data storage CSV in the repo Free
Fare data Google Flights scrape (headless Chromium + Firefox) Free, no key
Alerts Telegram Bot API / SMTP Free
Forecasts & AI features scikit-learn, offline (no LLM) Free
Accounts, sync & live data (optional) Firebase Auth + Firestore (Spark tier) Free
Per-user push alerts (optional) Cloud Functions (Blaze) + FCM ~cents/month

Each run takes a few Actions minutes (browser install + scraping one shard of the rolling ~6-month grid across Chromium + Firefox — the shard count doubled with the horizon, so a single run's workload is unchanged); several runs a day plus per-merge refreshes stay comfortably inside the free unlimited allowance for public repos, and a timeout-minutes cap keeps any single run well inside its 6-hourly slot.


License

Code: MIT. Data in data/: open — attribution appreciated (CC-BY).

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages