3 Commits
Author SHA1 Message Date
Aculix Technologies 9442bca696 chore: release 1.1.0
Additive: the old flat District variable names still work, so v1 configs run
unchanged. Two things to know when upgrading are called out in the release notes.
2026-08-26 18:01:43 +05:30
Aculix Technologies 5f86252df1 fix: make the SOCKS5 proxy work for BookMyShow too
BookMyShow blocks datacenter IPs on top of TLS fingerprinting, confirmed by CI
runners getting 403 with a working impersonation, so cloud hosts need the proxy
for BMS as well as District. impit resolves DNS locally for socks5:// and hands
an IPv6-only exit an unreachable IPv4 address, so the scheme is normalized to
socks5h://. Verified end to end: a proxied BMS check returns 200 and alerts.

Docs said the proxy did nothing for BookMyShow, which was wrong.
2026-08-26 17:54:22 +05:30
Aculix Technologies 608221fe18 feat: watch BookMyShow alongside District in one process
BookMyShow sits behind Cloudflare bot management that rejects curl and Node fetch
with 403 on the TLS fingerprint alone, so no header or cookie copying helps and a
proxy makes no difference. impit performs the request with a real Chrome
fingerprint; it ships musl and arm64 binaries, so the Alpine multi-arch image is
unaffected. BMS has no 204 either: a date that is not on sale returns 200 with no
showtimeWidgets key, so 'closed' is read from the payload.

Sites now live in src/providers/, each exposing check() and extract() and returning
one normalized session shape. State is namespaced per site, so each keeps its own
dedupe and failure tracking and neither can silence the other. Alert titles name the
site. Sessions without exact seat counts render without them, which BMS needs.

Old flat District variable names still work. CI now starts the built image and runs
a real check through both providers before publishing, since a broken native binary
only fails at runtime.
2026-08-26 17:50:56 +05:30
17 changed files with 1199 additions and 535 deletions

No files matched your search

+43 -28
View File
@@ -1,31 +1,13 @@
# Copy to .env and fill in. Every value below is read from the environment, # Copy to .env and fill in. Every value is read from the environment, so you can set
# so you can also set them directly (Docker, systemd, CI) instead of using a file. # these directly (Docker, systemd, CI) instead of using a file.
# The example values watch IMAX shows of one film at one Ahmedabad cinema. #
# Configure District, BookMyShow, or both. Both are watched in one process, each with
# its own dedupe and failure tracking, so one site breaking never silences the other.
# ── What to watch ───────────────────────────────────────────────────────────── # ── Shared ────────────────────────────────────────────────────────────────────
# Open the film's page on district.in, then read these off the network request to TARGET_DATE=2026-09-25 # the date you are waiting for (YYYY-MM-DD)
# /gw/consumer/movies/v5/movie (see "Finding your IDs" in the README).
MOVIE_CODE=y7EZNLXN5Y # ?frmtid= in the page URL — identifies movie + language
CONTENT_ID=187151 # MV<number> in the page URL
CITY_KEY=ahmedabad # city slug as it appears in the page URL
LAT=23.033938956712916 # city coordinates; the API rejects requests without them
LNG=72.57696301365426
TARGET_DATE=2026-08-10 # the date you are waiting for (YYYY-MM-DD)
# Restrict to one cinema. Leave empty to watch every cinema in the city.
CINEMA_ID=1032362
# Which shows count as a match. A session matches if it carries FORMAT_TAG *or* if its
# screen format contains FORMAT_MATCH (case-insensitive), so "IMAX" also catches
# "IMAX 3D". Set BOTH to empty to match every show regardless of format.
FORMAT_TAG=imax_2d
FORMAT_MATCH=IMAX
FORMAT_LABEL=IMAX # cosmetic: how the format is named in alerts FORMAT_LABEL=IMAX # cosmetic: how the format is named in alerts
# Opened when you tap the notification.
BOOKING_URL=https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-08-10
# ── Notifications (ntfy) ──────────────────────────────────────────────────────
# Subscribe to this topic in the ntfy app. Treat it as a password: anyone who knows # Subscribe to this topic in the ntfy app. Treat it as a password: anyone who knows
# it can read your alerts and publish to you. Use a long random string. # it can read your alerts and publish to you. Use a long random string.
NTFY_TOPIC=change-me-to-something-random NTFY_TOPIC=change-me-to-something-random
@@ -35,10 +17,41 @@ NTFY_TOPIC=change-me-to-something-random
NTFY_URL=https://ntfy.sh NTFY_URL=https://ntfy.sh
# NTFY_TOKEN=tk_... # only if your server (or topic) requires auth # NTFY_TOKEN=tk_... # only if your server (or topic) requires auth
# ── District (district.in) ────────────────────────────────────────────────────
# Leave DISTRICT_MOVIE_CODE unset to skip District entirely.
# Read these off the movie's page URL, e.g.
# district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y
DISTRICT_MOVIE_CODE=y7EZNLXN5Y # ?frmtid= — identifies movie + language
DISTRICT_CONTENT_ID=187151 # digits after MV
DISTRICT_CITY_KEY=ahmedabad # city slug in the path
DISTRICT_LAT=23.033938956712916 # any coordinates in that city; the API needs them
DISTRICT_LNG=72.57696301365426
DISTRICT_CINEMA_ID=1032362 # empty = watch every cinema in the city
DISTRICT_FORMAT_TAG=imax_2d # session tag that counts as a match
DISTRICT_FORMAT_MATCH=IMAX # or a screen-format substring; both empty = any show
DISTRICT_BOOKING_URL=https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-09-25
# ── BookMyShow (in.bookmyshow.com) ────────────────────────────────────────────
# Leave BMS_EVENT_CODE unset to skip BookMyShow entirely.
# From the movie URL, e.g. in.bookmyshow.com/movies/ahmedabad/avengers-endgame-encore/ET00514163
BMS_EVENT_CODE=ET00514163 # the ET... code
# BMS_REF_EVENT_CODE= # only if the URL's refEventCode differs from above
BMS_REGION_CODE=AHD # BMS region code (AHD, BANG, MUMBAI, NCR ...)
BMS_REGION_SLUG=ahmedabad # city slug in the path
BMS_LAT=23.0395677
BMS_LNG=72.5660045
BMS_VENUE_CODE=PPAM # empty = watch every cinema in the region
# BMS_LANGUAGE=hindi # only if you want one language's shows
BMS_FORMAT_MATCH=IMAX # matched against the screen format; empty = any show
BMS_BOOKING_URL=https://in.bookmyshow.com/movies/ahmedabad/avengers-endgame-encore/ET00514163
# Watch only some of the configured providers. Unset = every provider you configured.
# PROVIDERS=district,bookmyshow
# ── Behaviour ───────────────────────────────────────────────────────────────── # ── Behaviour ─────────────────────────────────────────────────────────────────
CHECK_INTERVAL_SECONDS=60 # how often to poll; don't go lower without good reason CHECK_INTERVAL_SECONDS=60 # how often to poll; don't go lower without good reason
FAILURE_THRESHOLD=6 # consecutive failures before the "monitor broken" alert FAILURE_THRESHOLD=6 # consecutive failures before the "monitor broken" alert
TZ_OFFSET_MINUTES=330 # showtimes are rendered in this offset (330 = IST) TZ_OFFSET_MINUTES=330 # timezone used for showtimes and dates (330 = IST)
HEARTBEAT_HOUR=9 # daily "still alive" push at this local hour; -1 disables HEARTBEAT_HOUR=9 # daily "still alive" push at this local hour; -1 disables
CURRENCY=₹ CURRENCY=₹
# EXPIRY_UTC= # auto: local midnight after TARGET_DATE. Override to extend. # EXPIRY_UTC= # auto: local midnight after TARGET_DATE. Override to extend.
@@ -47,6 +60,8 @@ CURRENCY=₹
STATE_PATH=./state.json # Docker image defaults this to /data/state.json STATE_PATH=./state.json # Docker image defaults this to /data/state.json
PORT=4733 # status endpoint (--loop mode only) PORT=4733 # status endpoint (--loop mode only)
# Routes only the site checks through a SOCKS5 proxy; notifications stay direct. # Routes both sites' checks through a SOCKS5 proxy; notifications stay direct.
# Needed on datacenter hosts, whose IPs the site's WAF is likely to block. # Both District and BookMyShow block datacenter IPs, so a cloud host usually needs this.
# socks5:// is rewritten to socks5h:// internally, so the proxy resolves DNS. That is
# required for IPv6-only exits and harmless otherwise.
# PROXY_URL=socks5://user:pass@proxy.example.net:1080 # PROXY_URL=socks5://user:pass@proxy.example.net:1080
+20
View File
@@ -39,6 +39,26 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
# Build amd64 first and actually start it. The native TLS-impersonation binary
# only fails at runtime, so a plain build would happily publish a broken image.
- uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64
load: true
tags: ticket-monitoring:smoke
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Smoke test the image
run: |
docker run --rm ticket-monitoring:smoke \
node -e "import('impit').then(m => { if (!m.Impit) { throw new Error('impit missing'); } console.log('impit loaded'); })"
docker run --rm --env-file smoke.env ticket-monitoring:smoke \
node src/index.js 2>&1 | tee /tmp/smoke.log
grep -q "\[district\]" /tmp/smoke.log
grep -q "\[bookmyshow\]" /tmp/smoke.log
- uses: docker/build-push-action@v6 - uses: docker/build-push-action@v6
with: with:
context: . context: .
+129 -70
View File
@@ -2,36 +2,41 @@
[![docker](https://github.com/aculix/ticket-monitoring/actions/workflows/docker.yml/badge.svg)](https://github.com/aculix/ticket-monitoring/actions/workflows/docker.yml) [![docker](https://github.com/aculix/ticket-monitoring/actions/workflows/docker.yml/badge.svg)](https://github.com/aculix/ticket-monitoring/actions/workflows/docker.yml)
Watches [district.in](https://www.district.in) showtimes and sends a max-priority push to your Watches [District](https://www.district.in) and [BookMyShow](https://in.bookmyshow.com)
phone the second tickets go on sale for the movie, date, format and cinema you care about. showtimes and sends a max-priority push to your phone the second tickets go on sale for the
movie, date, format and cinema you care about.
![Example notification: IMAX shows for a target date, with showtimes, seat counts and prices](docs/alert.png) ![Example notification: IMAX shows for a target date, with showtimes, seat counts and prices](docs/alert.png)
I built this because IMAX shows kept selling out before I even knew they'd opened. Refreshing the I built this because IMAX shows kept selling out before I even knew they'd opened. Refreshing
page a few times a day wasn't working. Now something else checks every 60 seconds and yells at me the page a few times a day wasn't working. Now something else checks every 60 seconds and yells
when it matters. at me when it matters.
## How it works ## How it works
Every 60 seconds it calls the same showtimes endpoint the District site uses itself. A date that Every 60 seconds it calls the same showtime APIs the two sites use themselves, and filters the
isn't on sale yet answers with `204 No Content`. The moment it opens, you get the full session result down to your cinema and format. When something matches, it pushes to
list back. The monitor filters that list down to your cinema and format, then pushes to
[ntfy](https://ntfy.sh), a free open-source notification service with iOS and Android apps. [ntfy](https://ntfy.sh), a free open-source notification service with iOS and Android apps.
The part I care most about: silence always means "not open yet". If checks start failing you get a Both sites can be watched at once, in one process. Each keeps its own dedupe and failure state,
*Monitor BROKEN* push after six failures in a row, plus an optional daily heartbeat. A monitor so tickets appearing on one site never suppress the alert for the other, and one site going down
that died at 3am and one that's quietly working should never look the same from the outside. doesn't stop the other from being checked.
Alerts are deduplicated by session id in a small state file, so you hear about each show once. The part I care most about: silence always means "not open yet". If checks start failing you get
Shows added later still get through. a *BROKEN* push after six failures in a row, naming the site that's failing, plus an optional
daily heartbeat. A monitor that died at 3am and one that's quietly working should never look the
same from the outside.
Alerts are deduplicated by session id, so you hear about each show once. Shows added later still
get through.
| Notification | Priority | What it means | | Notification | Priority | What it means |
|---|---|---| |---|---|---|
| `<FORMAT> <DATE> OPEN - BOOK NOW` | max | The one you're waiting for. Tap it to book. | | `<FORMAT> <DATE> OPEN on <SITE> - BOOK NOW` | max | The one you're waiting for. Tap it to book. |
| `<DATE> bookings are OPEN (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. | | `<DATE> is OPEN on <SITE> (no <FORMAT> yet)` | high | Date opened, your format hasn't shown up yet. |
| `Monitor BROKEN - check manually` | high | Checks are failing. Don't trust the silence. | | `<SITE> checks BROKEN - check manually` | high | That site's checks are failing. Don't trust the silence. |
| `Monitor recovered` | default | Back to normal. | | `<SITE> checks recovered` | default | Back to normal. |
| `Still watching <DATE>` | min | Daily heartbeat. | | `Still watching <DATE>` | min | Daily heartbeat, with a line per site. |
| `Monitor retired` | default | Target date passed, so it stopped on its own. | | `Monitor retired` | default | Target date passed, so it stopped on its own. |
## Requirements ## Requirements
@@ -44,8 +49,8 @@ A prebuilt image is published to GitHub Container Registry on every push to `mai
`linux/amd64` and `linux/arm64`. It runs as-is on a normal server, an Apple Silicon Mac or a `linux/amd64` and `linux/arm64`. It runs as-is on a normal server, an Apple Silicon Mac or a
Raspberry Pi, with nothing to clone and nothing to build. Raspberry Pi, with nothing to clone and nothing to build.
Grab [`.env.example`](.env.example), save it as `.env`, fill it in using "Finding your IDs" below, Grab [`.env.example`](.env.example), save it as `.env`, fill it in using "Finding your IDs"
then: below, then:
```bash ```bash
docker run -d --name ticket-monitoring \ docker run -d --name ticket-monitoring \
@@ -60,11 +65,11 @@ docker run -d --name ticket-monitoring \
docker logs -f ticket-monitoring docker logs -f ticket-monitoring
``` ```
You want to see `tick: closed failures: 0` scrolling past once a minute. You want to see a line per site once a minute, like `[district] closed | matched: 0 | failures: 0`.
The volume is what stops it re-alerting you about shows it already found, so keep it around across The volume is what stops it re-alerting you about shows it already found, so keep it around
restarts. Drop the `-p` flag if you don't want the status endpoint reachable. To update, pull the across restarts. Drop the `-p` flag if you don't want the status endpoint reachable. To update,
image again and recreate the container: pull the image again and recreate the container:
```bash ```bash
docker pull ghcr.io/aculix/ticket-monitoring:latest docker pull ghcr.io/aculix/ticket-monitoring:latest
@@ -90,7 +95,7 @@ docker compose logs -f
```bash ```bash
npm install npm install
cp .env.example .env # edit it cp .env.example .env # edit it
npm run check # one check, tells you what it found npm run check # one check against every configured site
npm start # run it for real npm start # run it for real
``` ```
@@ -100,79 +105,126 @@ support.
## Finding your IDs ## Finding your IDs
Everything movie-specific is sitting in one URL. Open the film's District page for your city: Configure either site, or both. Skip a site by leaving its main ID unset: no
`DISTRICT_MOVIE_CODE` means District isn't watched, no `BMS_EVENT_CODE` means BookMyShow isn't.
### District
Everything is sitting in the movie's page URL for your city:
``` ```
https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-08-10 https://www.district.in/movies/the-odyssey-movie-tickets-in-ahmedabad-MV187151?frmtid=y7EZNLXN5Y&fromdate=2026-09-25
└── CITY_KEY ──┘ └CONTENT_ID┘ └─MOVIE_CODE─┘ └── CITY_KEY ──┘ └CONTENT_ID┘ └─MOVIE_CODE─┘
``` ```
- `CONTENT_ID` is the number after `MV` - `DISTRICT_CONTENT_ID` is the number after `MV`
- `MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language - `DISTRICT_MOVIE_CODE` is the `frmtid` parameter, which pins down the movie *and* the language
- `CITY_KEY` is the city slug in the path - `DISTRICT_CITY_KEY` is the city slug in the path
- `LAT` and `LNG` can be any coordinates inside that city. The API refuses to answer without them, - `DISTRICT_LAT` and `DISTRICT_LNG` can be any coordinates inside that city. The API refuses to
though results are city-wide either way. answer without them, though results are city-wide either way.
- `BOOKING_URL` is the whole URL with `fromdate` set to your target date - `DISTRICT_BOOKING_URL` is the whole URL with `fromdate` set to your target date
`CINEMA_ID` is the fiddly one, since it isn't in the URL. Easiest approach: leave it empty at ### BookMyShow
first, which watches every cinema in the city, then run `npm run check` against a date that's
already bookable and read the ids out of the response. Or just leave it empty for good and let the
alert tell you where the shows are.
Want any format, not only IMAX? Set both `FORMAT_TAG` and `FORMAT_MATCH` to empty. From the movie's page URL:
```
https://in.bookmyshow.com/movies/ahmedabad/avengers-endgame-encore/ET00514163
└REGION_SLUG┘ └EVENT_CODE┘
```
- `BMS_EVENT_CODE` is the `ET...` code
- `BMS_REGION_SLUG` is the city slug, and `BMS_REGION_CODE` is the short code BMS uses internally
(`AHD` for Ahmedabad). If you don't know yours, open a showtimes page for your city and look at
the `regionCode` parameter in the network request, or try the obvious abbreviation.
- `BMS_REF_EVENT_CODE` only matters when a URL carries a `refEventCode` that differs from the
event code, which happens for format-specific listings. Leave it unset otherwise.
### Both
`DISTRICT_CINEMA_ID` and `BMS_VENUE_CODE` restrict alerts to one cinema. The fiddly part is that
neither is in the URL. Easiest approach: leave them empty at first, which watches every cinema in
the city, run `npm run check`, and read the ids out of the output. Or leave them empty for good
and let the alert tell you where the shows are.
Want any format, not only IMAX? Clear `DISTRICT_FORMAT_TAG`, `DISTRICT_FORMAT_MATCH` and
`BMS_FORMAT_MATCH`.
## Configuration ## Configuration
Everything is environment variables. `.env` is only a convenient place to put them. Everything is environment variables. `.env` is only a convenient place to put them. Full list
with comments in [`.env.example`](.env.example).
| Variable | Default | Purpose | | Variable | Default | Purpose |
|---|---|---| |---|---|---|
| `MOVIE_CODE` | *required* | `frmtid`, identifies movie and language |
| `CONTENT_ID` | *required* | Movie id, the digits after `MV` |
| `CITY_KEY` | *required* | City slug, e.g. `ahmedabad` |
| `LAT`, `LNG` | *required* | Coordinates somewhere in that city |
| `TARGET_DATE` | *required* | The date you're waiting for, `YYYY-MM-DD` | | `TARGET_DATE` | *required* | The date you're waiting for, `YYYY-MM-DD` |
| `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. | | `NTFY_TOPIC` | *required* | The topic you subscribe to. Treat it like a password. |
| `CINEMA_ID` | *(any)* | Restrict to one cinema | | `DISTRICT_MOVIE_CODE` | | Set to watch District. Plus `DISTRICT_CONTENT_ID`, `DISTRICT_CITY_KEY`, `DISTRICT_LAT`, `DISTRICT_LNG` |
| `FORMAT_TAG` | `imax_2d` | Session tag that counts as a match | | `DISTRICT_CINEMA_ID` | *(any)* | Restrict District to one cinema |
| `FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match | | `DISTRICT_FORMAT_TAG` | `imax_2d` | Session tag that counts as a match |
| `DISTRICT_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match |
| `DISTRICT_BOOKING_URL` | | Opens when you tap a District notification |
| `BMS_EVENT_CODE` | | Set to watch BookMyShow. Plus `BMS_REGION_CODE`, `BMS_REGION_SLUG` |
| `BMS_VENUE_CODE` | *(any)* | Restrict BookMyShow to one cinema |
| `BMS_FORMAT_MATCH` | `IMAX` | Screen-format substring that counts as a match |
| `BMS_LANGUAGE` | *(all)* | Restrict to one language's shows |
| `BMS_BOOKING_URL` | | Opens when you tap a BookMyShow notification |
| `PROVIDERS` | *(all configured)* | Allow-list, e.g. `district` to temporarily watch only one |
| `FORMAT_LABEL` | `IMAX` | What the format is called in the alert text | | `FORMAT_LABEL` | `IMAX` | What the format is called in the alert text |
| `BOOKING_URL` | | Opens when you tap the notification |
| `NTFY_URL` | `https://ntfy.sh` | Your own ntfy server, if you run one | | `NTFY_URL` | `https://ntfy.sh` | Your own ntfy server, if you run one |
| `NTFY_TOKEN` | | Only if your server wants auth | | `NTFY_TOKEN` | | Only if your server wants auth |
| `CHECK_INTERVAL_SECONDS` | `60` | Poll interval | | `CHECK_INTERVAL_SECONDS` | `60` | Poll interval |
| `FAILURE_THRESHOLD` | `6` | Failures in a row before the BROKEN alert | | `FAILURE_THRESHOLD` | `6` | Failures in a row before the BROKEN alert |
| `TZ_OFFSET_MINUTES` | `330` | Timezone for showtimes, 330 is IST | | `TZ_OFFSET_MINUTES` | `330` | Timezone for showtimes and dates, 330 is IST |
| `HEARTBEAT_HOUR` | `9` | Local hour for the daily heartbeat, `-1` turns it off | | `HEARTBEAT_HOUR` | `9` | Local hour for the daily heartbeat, `-1` turns it off |
| `CURRENCY` | `₹` | Price prefix | | `CURRENCY` | `₹` | Price prefix |
| `EXPIRY_UTC` | *auto* | When to retire. Defaults to local midnight after `TARGET_DATE`. | | `EXPIRY_UTC` | *auto* | When to retire. Defaults to local midnight after `TARGET_DATE`. |
| `STATE_PATH` | `./state.json` | Where dedupe state lives, `/data/state.json` in Docker | | `STATE_PATH` | `./state.json` | Where dedupe state lives, `/data/state.json` in Docker |
| `PORT` | `4733` | Status endpoint | | `PORT` | `4733` | Status endpoint |
| `PROXY_URL` | | SOCKS5 proxy for the site checks, see below | | `PROXY_URL` | | SOCKS5 proxy for both sites' checks, see below |
v1 used flat names (`MOVIE_CODE`, `CINEMA_ID` and friends) for District. Those still work, so an
existing `.env` keeps running unchanged.
## Status endpoint ## Status endpoint
While running continuously it serves JSON on `PORT`, which defaults to 4733: While running continuously it serves JSON on `PORT`, which defaults to 4733:
- `/` gives you state, `lastTick` and `proxied` - `/` gives you state and `lastTick` per site, plus `proxied`
- `/?force=1` adds a live check right now, with no notifications and no state changes - `/?force=1` adds a live check of every site right now, with no notifications and no state changes
- `/healthz` returns `{"ok":true}` - `/healthz` returns `{"ok":true}`
```bash ```bash
curl -s localhost:4733/ | jq curl -s localhost:4733/ | jq
``` ```
Watch `lastTick.at`. If it's more than a couple of minutes old, the loop is stuck. Watch `lastTick`. If a site's entry is more than a couple of minutes old, the loop is stuck.
## Troubleshooting ## Troubleshooting
### Every check returns HTTP 403 ### District returns HTTP 403
The site's WAF is blocking your host. This happens reliably on cloud providers. An earlier version District's WAF is blocking your host. This happens reliably on cloud providers. An earlier
of this ran as a Cloudflare Worker, worked fine for two days, then got blocked permanently, while version of this ran as a Cloudflare Worker, worked fine for two days, then got blocked
a home connection was never touched once. Either run it from a residential connection, or point permanently, while a home connection was never touched once. Either run it from a residential
`PROXY_URL` at a SOCKS5 proxy. Only the site checks go through the proxy. Notifications stay connection, or point `PROXY_URL` at a SOCKS5 proxy. Only the site checks go through the proxy.
direct, so your alerts don't depend on it. Notifications stay direct, so your alerts don't depend on it.
One gotcha if your proxy is IPv6-only: a `socks5://` URL makes some clients resolve DNS locally
and hand the proxy an IPv4 address it cannot reach. The monitor rewrites the scheme to `socks5h://`
so the proxy does the resolving, which fixes that and changes nothing for other proxies.
### BookMyShow returns HTTP 403
BookMyShow blocks on two things at once. First the TLS fingerprint of the client, which is why
plain `curl` and Node's `fetch` get 403 no matter which headers or cookies you send. That part is
handled by [impit](https://github.com/apify/impit), which performs the request with a real Chrome
fingerprint. Second your IP, if it belongs to a datacenter: BookMyShow 403s cloud hosts even with
a perfect fingerprint, which I confirmed from GitHub Actions runners.
So on a cloud host you need both, and setting `PROXY_URL` covers the second half. If you are still
getting 403s from a residential connection, the impersonation has probably stopped matching the
current Chrome, so update the image or run `npm update impit`.
### ntfy returns HTTP 429 ### ntfy returns HTTP 429
@@ -186,9 +238,9 @@ messages through Do Not Disturb.
### Alerts repeat, or never arrive again ### Alerts repeat, or never arrive again
Dedupe lives in the state file at `STATE_PATH`, or in the `data/` volume under Docker. Delete it to Dedupe lives in the state file at `STATE_PATH`, or in the `data/` volume under Docker. Delete it
start fresh. Do this whenever you change `TARGET_DATE`, otherwise it still thinks it has already to start fresh. Do this whenever you change `TARGET_DATE`, otherwise it still thinks it has
told you about those shows. already told you about those shows.
### Nothing happens at all ### Nothing happens at all
@@ -197,24 +249,31 @@ Run `npm run check` for a single noisy check. Configuration problems are reporte
## Development ## Development
```bash ```bash
npm test # 16 tests, no network needed npm test # 24 tests, no network needed
``` ```
The tests run the alert state machine and session parsing against a real captured API response in The tests run the alert state machine and both site parsers against real captured API responses
`test/fixtures/`. `src/monitor.js` has the pure logic and the API client, `src/index.js` is the in `test/fixtures/`. Layout:
runner plus state file and status server, `src/config.js` reads the environment.
If you're forking this for another site, [docs/design-notes.md](docs/design-notes.md) covers what I - `src/providers/district.js` and `src/providers/bookmyshow.js` fetch and normalize showtimes.
learned about the API and why the alert logic is shaped the way it is. Each exports `check()` and `extract()`, and nothing else knows which site a session came from.
- `src/monitor.js` decides what to alert about and when to stay quiet
- `src/index.js` is the runner, state file and status server
- `src/config.js` reads the environment
- `src/lib.js` has the shared time helpers
Adding a third site means writing one file in `src/providers/` and registering it in
`config.js`. [docs/design-notes.md](docs/design-notes.md) covers what I learned about both APIs
and why the alert logic is shaped the way it is.
## Notes ## Notes
This polls a public endpoint once a minute, which is roughly what a person refreshing the page This polls public endpoints once a minute, which is roughly what a person refreshing the page
would do, to buy their own tickets. It doesn't automate purchases, hold seats or skip any queue. would do, to buy their own tickets. It doesn't automate purchases, hold seats or skip any queue.
Please keep it that way: don't drop the interval, don't run ten copies, don't use it to scalp. Please keep it that way: don't drop the interval, don't run ten copies, don't use it to scalp.
Not affiliated with District, PVR or IMAX. The API is undocumented and can change whenever they Not affiliated with District, BookMyShow, PVR or IMAX. Both APIs are undocumented and can change
feel like it. whenever they feel like it.
## License ## License
+44 -3
View File
@@ -3,7 +3,11 @@
Why this is built the way it is, and what broke along the way. Useful if you're forking it, Why this is built the way it is, and what broke along the way. Useful if you're forking it,
or writing something similar against a different site. or writing something similar against a different site.
## The API Two sites are supported. Each lives in `src/providers/` and exposes the same two functions,
`check()` and `extract()`, returning sessions in one normalized shape. Everything above that
layer, including the alert state machine, has no idea which site a session came from.
## District: the API
District's movie pages are server-rendered Next.js, but showtimes load client-side from: District's movie pages are server-rendered Next.js, but showtimes load client-side from:
@@ -29,9 +33,42 @@ Findings that shaped the design:
`204` while closed, the daily heartbeat makes one extra call for *today* purely to report how `204` while closed, the daily heartbeat makes one extra call for *today* purely to report how
far ahead bookings currently run. That probe is best-effort and never counts as a failure. far ahead bookings currently run. That probe is best-effort and never counts as a failure.
## BookMyShow: the API
```
GET /api/movies-data/v5/showtimes-by-event/primary-dynamic
?etCodes=<ET code>&dateCode=YYYYMMDD&regionCode=AHD
&isDesktop=true&xLocationShared=false&memberId=&lsId=&subCode=
&appCode=WEB&refEventCode=<ET code>[&language=hindi]
```
Sent with `x-region-code`, `x-region-slug`, `x-latitude`, `x-longitude`, `x-app-code: WEB` and
an `x-bms-id` that, like District's guest token, the web client makes up locally.
- **Cloudflare blocks on TLS fingerprint, not IP or headers.** curl and Node's `fetch` both get
403 with a byte-perfect copy of the browser's headers, and no `cf_clearance` cookie is involved,
so there is nothing to copy across. A proxy does not help either, since the block happens during
the TLS handshake. [impit](https://github.com/apify/impit) performs the request with a real
Chrome fingerprint and returns 200. It ships prebuilt binaries for linux x64 and arm64 in both
gnu and musl flavours, which is what keeps the Alpine image and the arm64 build working.
- **There is no 204.** A date that is not on sale answers 200 with a short body containing only
`header`, `additionalData` and `addOnWidgets`. The tell is that `data.showtimeWidgets` is
absent, so "closed" is a property of the payload rather than the status code.
- **A past or invalid date returns 400**, which counts as a failure rather than "closed". That is
deliberate: it means something is wrong with the configuration, and you should hear about it.
- **Sessions are buried in generic UI widgets.** Venue cards sit several layers down inside
`showtimeWidgets`, so the parser walks the tree looking for `type: "venue-card"` instead of
indexing through fixed positions, which would break the moment BMS reorders its layout.
- **Seat detail lives in a bottom sheet.** Each showtime carries a `customGestureCTA` describing
the panel shown on double-tap, and that is the only place per-tier prices appear. BMS reports
availability as words (`AVAILABLE`, `FILLING FAST`, `ALMOST FULL`) and never as exact counts,
unlike District, so the normalized session leaves `avail` and `total` null and alert lines omit
the seat figures for this site.
- **Times are already local**, so unlike District's UTC timestamps they are passed through as-is.
## Alert state machine ## Alert state machine
`decide()` in `src/monitor.js` is pure: state in, `{ baseState, alerts }` out. That makes every `decideProvider()` in `src/monitor.js` is pure: state in, `{ baseState, alerts }` out. That makes every
edge case testable without network or clock mocking, and it's where the interesting rules live. edge case testable without network or clock mocking, and it's where the interesting rules live.
- **Publish before persist.** Each alert carries its own `statePatch`, merged only after that - **Publish before persist.** Each alert carries its own `statePatch`, merged only after that
@@ -45,9 +82,13 @@ edge case testable without network or clock mocking, and it's where the interest
- **State is written only when it actually changed.** Originally this ran on Cloudflare KV with a - **State is written only when it actually changed.** Originally this ran on Cloudflare KV with a
1,000 writes/day free limit against 1,440 daily ticks, which forced the discipline; it's still 1,000 writes/day free limit against 1,440 daily ticks, which forced the discipline; it's still
the right behaviour on a filesystem. the right behaviour on a filesystem.
- **State is namespaced per site.** Each provider gets its own dedupe map, failure counter and
BROKEN flag under `state.providers.<key>`, so the same session id can alert once per site and a
site going down never marks the other as broken. Retirement and the daily heartbeat are the only
tick-level decisions, and they live in `decideGlobal()`.
The guiding rule throughout: **degrade toward duplicate notifications, never toward silence.** The guiding rule throughout: **degrade toward duplicate notifications, never toward silence.**
Every failure path — unreadable state, failed push, failed write — errs that way. Every failure path, whether it is unreadable state, a failed push or a failed write, errs that way.
## What broke in production ## What broke in production
+148
View File
@@ -10,6 +10,7 @@
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"fetch-socks": "^1.3.3", "fetch-socks": "^1.3.3",
"impit": "^0.14.4",
"undici": "^8.10.0" "undici": "^8.10.0"
}, },
"engines": { "engines": {
@@ -24,6 +25,153 @@
"undici": ">=7" "undici": ">=7"
} }
}, },
"node_modules/impit": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit/-/impit-0.14.4.tgz",
"integrity": "sha512-XXAafVQpWIYIchSxT6UufZgVNLewKVg9BJSwQYom7b1VWt/l0m+GPMhc4agK9O7QlAnEiFm1QQcX2RpsmLV6gQ==",
"license": "Apache-2.0",
"engines": {
"node": ">= 20"
},
"optionalDependencies": {
"impit-darwin-arm64": "0.14.4",
"impit-darwin-x64": "0.14.4",
"impit-linux-arm64-gnu": "0.14.4",
"impit-linux-arm64-musl": "0.14.4",
"impit-linux-x64-gnu": "0.14.4",
"impit-linux-x64-musl": "0.14.4",
"impit-win32-arm64-msvc": "0.14.4",
"impit-win32-x64-msvc": "0.14.4"
}
},
"node_modules/impit-darwin-arm64": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-darwin-arm64/-/impit-darwin-arm64-0.14.4.tgz",
"integrity": "sha512-i9fkPXXCB/1qBTGIlS26Z2krmE0wk/1UUlDUFeE1bMtihoUIe8TbeslSnx6pgaSY5ai9H0seUAgS9D4i66Kscg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-darwin-x64": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-darwin-x64/-/impit-darwin-x64-0.14.4.tgz",
"integrity": "sha512-p+W0enCZDCsnSwEWOMImpHq71jmVR7J9CjYvqgiFtQD3W+rQb7ilFLYYRalQ0rmrrH8imLRm2skG6V7YQkPwOg==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-linux-arm64-gnu": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-linux-arm64-gnu/-/impit-linux-arm64-gnu-0.14.4.tgz",
"integrity": "sha512-9VVb905sNSPh0dF2sDFbbjSz5JqXCoMPxTery77PyqMCEuxybtW+Yv3U1tCM+CFKSuC39wXmueW+FLwOCS+zIw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-linux-arm64-musl": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-linux-arm64-musl/-/impit-linux-arm64-musl-0.14.4.tgz",
"integrity": "sha512-PajwrCPCWPey4wjLSWSVFS/mRQXbh8WVrxXdgo6Ej530dyplxIiqcDh0Gr9fWYPriOC8pkcOpRqQ4O+1mH1xfQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-linux-x64-gnu": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-linux-x64-gnu/-/impit-linux-x64-gnu-0.14.4.tgz",
"integrity": "sha512-Ly8CweOZ9Qj6BZNhvglJMo56i7tCF7Nw33OdXLZLZRxnBzxd2kI/LXpFsd1LiMkTDaiuLvqLGo7ZpxVt08LdwA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-linux-x64-musl": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-linux-x64-musl/-/impit-linux-x64-musl-0.14.4.tgz",
"integrity": "sha512-dS9wj2phkB+o8OFdWBIFYx23ExH+pcsFMiXmoOwRVdDfUX9reZTmZL4KaiPuhSEBysN/wd46dxB/OzNbnn5hnQ==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-win32-arm64-msvc": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-win32-arm64-msvc/-/impit-win32-arm64-msvc-0.14.4.tgz",
"integrity": "sha512-Nga0z+Hmp/HmxEKX0jv1wy2vaT4J44nE6+Z+CNZvvyVWiodWCG9+FvVn4McivgXix7UerspeyPvc174QBHwXEw==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/impit-win32-x64-msvc": {
"version": "0.14.4",
"resolved": "https://registry.npmjs.org/impit-win32-x64-msvc/-/impit-win32-x64-msvc-0.14.4.tgz",
"integrity": "sha512-v15fn/kcjLEzALB4z+VWTeG1UrtqZQq9n2HNsRUfUgfcgzM1i+KKtKP2RCJ1Fg1YeojQ7B6XGhRtpVVTjh8u+A==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.4.0", "version": "10.4.0",
"license": "MIT", "license": "MIT",
+3 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "ticket-monitoring", "name": "ticket-monitoring",
"version": "1.0.0", "version": "1.1.0",
"description": "Watches district.in showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.", "description": "Watches district.in and BookMyShow showtimes and sends a phone notification the moment tickets for a specific movie, date, format and cinema go on sale.",
"license": "MIT", "license": "MIT",
"type": "module", "type": "module",
"engines": { "engines": {
@@ -14,6 +14,7 @@
}, },
"dependencies": { "dependencies": {
"fetch-socks": "^1.3.3", "fetch-socks": "^1.3.3",
"impit": "^0.14.4",
"undici": "^8.10.0" "undici": "^8.10.0"
} }
} }
+17
View File
@@ -0,0 +1,17 @@
# Config used only by the CI smoke test: proves both providers load, parse config and
# complete a real check inside the image. Alerts go nowhere real.
TARGET_DATE=2030-01-01
NTFY_TOPIC=ci-smoke-test-does-not-exist-000
NTFY_URL=http://127.0.0.1:9
HEARTBEAT_HOUR=-1
STATE_PATH=/tmp/smoke-state.json
DISTRICT_MOVIE_CODE=y7EZNLXN5Y
DISTRICT_CONTENT_ID=187151
DISTRICT_CITY_KEY=ahmedabad
DISTRICT_LAT=23.033938956712916
DISTRICT_LNG=72.57696301365426
BMS_EVENT_CODE=ET00514163
BMS_REGION_CODE=AHD
BMS_REGION_SLUG=ahmedabad
BMS_LAT=23.0395677
BMS_LNG=72.5660045
+88 -21
View File
@@ -1,5 +1,12 @@
// Configuration is read entirely from environment variables (see .env.example). // Configuration comes entirely from environment variables (see .env.example).
// Load a .env file with Node's built-in flag: node --env-file-if-exists=.env src/index.js // Load a .env file with Node's built-in flag:
// node --env-file-if-exists=.env src/index.js
import * as district from "./providers/district.js";
import * as bookmyshow from "./providers/bookmyshow.js";
const MODULES = { district, bookmyshow };
const env = (name, fallback = "") => process.env[name] ?? fallback;
function required(name) { function required(name) {
const v = process.env[name]; const v = process.env[name];
@@ -15,6 +22,55 @@ const num = (name, fallback) => {
return n; return n;
}; };
/** Old single-provider variable names, still honoured so v1 .env files keep working. */
function legacyDistrict() {
if (!process.env.MOVIE_CODE || process.env.DISTRICT_MOVIE_CODE) return null;
return {
movieCode: env("MOVIE_CODE"),
contentId: env("CONTENT_ID"),
cityKey: env("CITY_KEY"),
lat: env("LAT"),
lng: env("LNG"),
venueId: env("CINEMA_ID"),
formatTag: process.env.FORMAT_TAG ?? "imax_2d",
formatMatch: process.env.FORMAT_MATCH ?? "IMAX",
bookingUrl: env("BOOKING_URL"),
};
}
function districtConfig() {
const legacy = legacyDistrict();
if (legacy) return legacy;
if (!process.env.DISTRICT_MOVIE_CODE) return null;
return {
movieCode: required("DISTRICT_MOVIE_CODE"),
contentId: required("DISTRICT_CONTENT_ID"),
cityKey: required("DISTRICT_CITY_KEY"),
lat: required("DISTRICT_LAT"),
lng: required("DISTRICT_LNG"),
venueId: env("DISTRICT_CINEMA_ID"),
formatTag: process.env.DISTRICT_FORMAT_TAG ?? "imax_2d",
formatMatch: process.env.DISTRICT_FORMAT_MATCH ?? "IMAX",
bookingUrl: env("DISTRICT_BOOKING_URL"),
};
}
function bookmyshowConfig() {
if (!process.env.BMS_EVENT_CODE) return null;
return {
eventCode: required("BMS_EVENT_CODE"),
refEventCode: env("BMS_REF_EVENT_CODE"),
regionCode: required("BMS_REGION_CODE"),
regionSlug: env("BMS_REGION_SLUG", "").toLowerCase(),
lat: env("BMS_LAT", "0"),
lng: env("BMS_LNG", "0"),
venueId: env("BMS_VENUE_CODE"),
language: env("BMS_LANGUAGE"),
formatMatch: process.env.BMS_FORMAT_MATCH ?? "IMAX",
bookingUrl: env("BMS_BOOKING_URL"),
};
}
export function loadConfig() { export function loadConfig() {
const targetDate = required("TARGET_DATE"); const targetDate = required("TARGET_DATE");
if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) { if (!/^\d{4}-\d{2}-\d{2}$/.test(targetDate)) {
@@ -26,37 +82,48 @@ export function loadConfig() {
const expiryUtc = process.env.EXPIRY_UTC || const expiryUtc = process.env.EXPIRY_UTC ||
new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString(); new Date(Date.parse(`${targetDate}T00:00:00Z`) + 86400000 - tzOffsetMinutes * 60000).toISOString();
const built = { district: districtConfig(), bookmyshow: bookmyshowConfig() };
// PROVIDERS is an explicit allow-list; without it, every configured provider runs.
const wanted = env("PROVIDERS").split(",").map((s) => s.trim()).filter(Boolean);
for (const w of wanted) {
if (!MODULES[w]) throw new Error(`Unknown provider "${w}" in PROVIDERS. Valid: ${Object.keys(MODULES).join(", ")}`);
}
const keys = (wanted.length ? wanted : Object.keys(built)).filter((k) => built[k]);
if (!keys.length) {
throw new Error("No provider configured. Set DISTRICT_MOVIE_CODE and/or BMS_EVENT_CODE (see .env.example)");
}
for (const w of wanted) {
if (!built[w]) throw new Error(`PROVIDERS lists "${w}" but its settings are missing (see .env.example)`);
}
const providers = keys.map((k) => ({
key: k,
label: MODULES[k].label,
module: MODULES[k],
...built[k],
}));
return { return {
// what to watch
movieCode: required("MOVIE_CODE"),
contentId: required("CONTENT_ID"),
cityKey: required("CITY_KEY"),
lat: required("LAT"),
lng: required("LNG"),
targetDate, targetDate,
cinemaId: process.env.CINEMA_ID || "", // empty = any cinema providers,
formatTag: process.env.FORMAT_TAG ?? "imax_2d", // empty + empty match = any format formatLabel: env("FORMAT_LABEL", "IMAX"),
formatMatch: process.env.FORMAT_MATCH ?? "IMAX", // Used by alerts that are not tied to one provider (heartbeat, retirement).
formatLabel: process.env.FORMAT_LABEL || "IMAX", bookingUrl: providers[0].bookingUrl || "",
bookingUrl: process.env.BOOKING_URL || "",
// notifications ntfyUrl: env("NTFY_URL", "https://ntfy.sh").replace(/\/+$/, ""),
ntfyUrl: (process.env.NTFY_URL || "https://ntfy.sh").replace(/\/+$/, ""),
ntfyTopic: required("NTFY_TOPIC"), ntfyTopic: required("NTFY_TOPIC"),
ntfyToken: process.env.NTFY_TOKEN || "", ntfyToken: env("NTFY_TOKEN"),
// behaviour
intervalMs: num("CHECK_INTERVAL_SECONDS", 60) * 1000, intervalMs: num("CHECK_INTERVAL_SECONDS", 60) * 1000,
failureThreshold: num("FAILURE_THRESHOLD", 6), failureThreshold: num("FAILURE_THRESHOLD", 6),
tzOffsetMinutes, tzOffsetMinutes,
heartbeatHour: num("HEARTBEAT_HOUR", 9), // -1 disables the daily heartbeat heartbeatHour: num("HEARTBEAT_HOUR", 9), // -1 disables the daily heartbeat
currency: process.env.CURRENCY || "₹", currency: env("CURRENCY", "₹"),
expiryUtc, expiryUtc,
// runtime statePath: env("STATE_PATH", "./state.json"),
statePath: process.env.STATE_PATH || "./state.json",
port: num("PORT", 4733), port: num("PORT", 4733),
proxyUrl: process.env.PROXY_URL || "", proxyUrl: env("PROXY_URL"),
dispatcher: null, // set by attachProxy() when PROXY_URL is present dispatcher: null, // set by attachProxy() when PROXY_URL is present
}; };
} }
+89 -52
View File
@@ -1,18 +1,20 @@
#!/usr/bin/env node #!/usr/bin/env node
// Entry point. Runs one check (default) or loops forever with a status server (--loop). // Entry point. Runs one check across every configured provider (default), or loops
// forever with a status server (--loop).
import { createServer } from "node:http"; import { createServer } from "node:http";
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs"; import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { localDate } from "./lib.js";
import { import {
decide, defaultState, extractSessions, checkDistrict, notify, heartbeatDue, localDate, decideProvider, decideGlobal, defaultState, providerState, heartbeatDue, notify,
} from "./monitor.js"; } from "./monitor.js";
const ts = () => new Date().toISOString(); const ts = () => new Date().toISOString();
// Last tick's outcome, surfaced by the status server. Persisted state records only // Last tick per provider, surfaced by the status server. Persisted state records only
// transitions, so "is it ticking right now" has to live in memory. // transitions, so "is it ticking right now" has to live in memory.
const lastTick = { at: null, kind: null, reason: null }; const lastTick = {};
function readState(cfg) { function readState(cfg) {
try { try {
@@ -30,9 +32,12 @@ function writeState(cfg, state) {
} }
/** /**
* Routes District requests through a SOCKS5 proxy. Notifications stay direct, so the * Routes provider requests through a SOCKS5 proxy. Notifications stay direct, so the
* alerting path never depends on the proxy. Needed on datacenter hosts whose IPs the * alerting path never depends on the proxy.
* site's WAF blocks. *
* Both sites block datacenter IPs, so a cloud host generally needs this for both. It is
* only half the story for BookMyShow, which also blocks on TLS fingerprint: impit deals
* with the fingerprint, the proxy deals with the IP, and BMS needs both.
*/ */
async function attachProxy(cfg) { async function attachProxy(cfg) {
if (!cfg.proxyUrl) return; if (!cfg.proxyUrl) return;
@@ -51,49 +56,81 @@ async function attachProxy(cfg) {
console.log(ts(), `checks routed via SOCKS5 ${u.hostname}:${u.port}`); console.log(ts(), `checks routed via SOCKS5 ${u.hostname}:${u.port}`);
} }
/** Check one provider and return its alerts plus the next sub-state. */
async function tickProvider(cfg, p, state, now) {
const pstate = providerState(state, p.key);
// Best-effort daily probe, only from providers that support it and only on the
// heartbeat tick. Its failure never counts toward the failure streak.
let probeMax = null;
if (p.module.probeShowDates && heartbeatDue(state, now, cfg) && !pstate.brokenAlerted) {
try {
probeMax = await p.module.probeShowDates(cfg, p, localDate(now, cfg.tzOffsetMinutes));
} catch (err) {
console.error(ts(), `[${p.key}] showDates probe failed (best-effort):`, err.message);
}
}
let input;
try {
const res = await p.module.check(cfg, p, cfg.targetDate);
input = res.kind === "open"
? { kind: "open", extract: p.module.extract(res.data, cfg, p), now, probeMax }
: { kind: "closed", now, probeMax };
} catch (err) {
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
}
const { baseState, alerts } = decideProvider(pstate, input, cfg, p);
lastTick[p.key] = { at: ts(), kind: input.kind, reason: input.reason ?? null };
const matched = input.extract?.matched?.length ?? 0;
console.log(ts(), `[${p.key}] ${input.kind}${input.reason ? ` (${input.reason})` : ""}`,
`| matched: ${matched} | failures: ${baseState.failures}`);
return { pstate: baseState, alerts, input };
}
export async function tick(cfg) { export async function tick(cfg) {
const now = new Date(); const now = new Date();
const state = readState(cfg); const state = readState(cfg);
if (state.retired) return "retired"; if (state.retired) return "retired";
let input; const merged = structuredClone(state);
if (now.getTime() >= Date.parse(cfg.expiryUtc)) { merged.providers ??= {};
input = { kind: "closed", now }; // decide() retires before it looks at kind const summaries = [];
} else { const expired = now.getTime() >= Date.parse(cfg.expiryUtc);
// Best-effort daily probe: showDates only exists in a 200 body, and the target date
// returns an empty 204 while closed. Its failure never counts toward the streak. const send = async (alert, apply) => {
let probeMax = null;
if (heartbeatDue(state, now, cfg) && !state.brokenAlerted) {
try { try {
const probe = await checkDistrict(cfg, localDate(now, cfg.tzOffsetMinutes)); await notify(cfg, alert);
if (probe.kind === "open") { apply();
const dates = probe.data?.meta?.showDates ?? []; console.log(ts(), "alert sent:", alert.title);
if (dates.length) probeMax = dates.slice().sort().at(-1);
}
} catch (err) { } catch (err) {
console.error(ts(), "showDates probe failed (best-effort):", err.message); // Patch not applied, so the push is retried on the next tick.
console.error(ts(), `notify failed for "${alert.title}" (will retry):`, err.message);
} }
};
if (!expired) {
for (const p of cfg.providers) {
const { pstate, alerts, input } = await tickProvider(cfg, p, state, now);
merged.providers[p.key] = pstate;
for (const alert of alerts) {
await send(alert, () => {
merged.providers[p.key] = { ...merged.providers[p.key], ...alert.statePatch };
});
} }
try { summaries.push({
const res = await checkDistrict(cfg, cfg.targetDate); label: p.label,
input = res.kind === "open" kind: input.kind === "error" ? "checks failing" : merged.providers[p.key].lastGood?.kind,
? { kind: "open", extract: extractSessions(res.data, cfg), now, probeMax } showDatesMax: merged.providers[p.key].lastGood?.showDatesMax,
: { kind: "closed", now, probeMax }; });
} catch (err) {
input = { kind: "error", reason: String(err.message ?? err), now, probeMax };
} }
} }
const { baseState, alerts } = decide(state, input, cfg); const global = decideGlobal(state, cfg, now, summaries);
let merged = baseState; Object.assign(merged, global.baseState, { providers: merged.providers });
for (const alert of alerts) { for (const alert of global.alerts) {
try { await send(alert, () => Object.assign(merged, alert.statePatch));
await notify(cfg, alert);
merged = { ...merged, ...alert.statePatch };
console.log(ts(), "alert sent:", alert.title);
} catch (err) {
console.error(ts(), `notify failed for "${alert.title}" (will retry next tick):`, err.message);
}
} }
if (JSON.stringify(merged) !== JSON.stringify(state)) { if (JSON.stringify(merged) !== JSON.stringify(state)) {
@@ -104,12 +141,7 @@ export async function tick(cfg) {
console.error(ts(), "state write failed (may duplicate pushes):", err.message); console.error(ts(), "state write failed (may duplicate pushes):", err.message);
} }
} }
return merged.retired ? "retired" : "ok";
lastTick.at = ts();
lastTick.kind = input.kind;
lastTick.reason = input.reason ?? null;
console.log(ts(), "tick:", input.kind, input.kind === "error" ? `(${input.reason})` : "", "failures:", merged.failures);
return input.kind;
} }
function startStatusServer(cfg) { function startStatusServer(cfg) {
@@ -125,6 +157,7 @@ function startStatusServer(cfg) {
const out = { const out = {
now: ts(), now: ts(),
targetDate: cfg.targetDate, targetDate: cfg.targetDate,
providers: cfg.providers.map((p) => p.key),
proxied: Boolean(cfg.dispatcher), proxied: Boolean(cfg.dispatcher),
retired: state.retired, retired: state.retired,
lastTick, lastTick,
@@ -132,13 +165,16 @@ function startStatusServer(cfg) {
}; };
if (url.searchParams.get("force") === "1") { if (url.searchParams.get("force") === "1") {
// Side-effect-free: no state writes, no notifications. // Side-effect-free: no state writes, no notifications.
out.live = {};
for (const p of cfg.providers) {
try { try {
const r = await checkDistrict(cfg, cfg.targetDate); const r = await p.module.check(cfg, p, cfg.targetDate);
out.live = r.kind === "open" out.live[p.key] = r.kind === "open"
? { kind: "open", ...extractSessions(r.data, cfg) } ? { kind: "open", ...p.module.extract(r.data, cfg, p) }
: { kind: "closed" }; : { kind: "closed" };
} catch (err) { } catch (err) {
out.live = { kind: "error", reason: String(err.message ?? err) }; out.live[p.key] = { kind: "error", reason: String(err.message ?? err) };
}
} }
} }
send(200, out); send(200, out);
@@ -153,12 +189,13 @@ await attachProxy(cfg);
if (process.argv.includes("--loop")) { if (process.argv.includes("--loop")) {
startStatusServer(cfg); startStatusServer(cfg);
console.log(ts(), `watching ${cfg.targetDate} every ${cfg.intervalMs / 1000}s (expires ${cfg.expiryUtc})`); console.log(ts(), `watching ${cfg.targetDate} on ${cfg.providers.map((p) => p.label).join(" + ")}`,
`every ${cfg.intervalMs / 1000}s (expires ${cfg.expiryUtc})`);
for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(0)); for (const sig of ["SIGINT", "SIGTERM"]) process.on(sig, () => process.exit(0));
for (;;) { for (;;) {
const kind = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; }); const r = await tick(cfg).catch((err) => { console.error(ts(), "tick crashed:", err); return "crash"; });
if (kind === "retired") break; if (r === "retired") break;
await new Promise((r) => setTimeout(r, cfg.intervalMs)); await new Promise((r2) => setTimeout(r2, cfg.intervalMs));
} }
} else { } else {
await tick(cfg); await tick(cfg);
+29
View File
@@ -0,0 +1,29 @@
// Small shared helpers used by providers and the alert logic.
export class CheckError extends Error {}
/**
* Providers report showtimes in local wall-clock terms. District returns UTC
* timestamps, so they get shifted; BookMyShow already returns local strings.
*/
export function toLocalTime(showTime, tzOffsetMinutes) {
let s = showTime;
if (/T\d{2}:\d{2}$/.test(s)) s += ":00";
if (!s.endsWith("Z")) s += "Z";
const d = new Date(Date.parse(s) + tzOffsetMinutes * 60000);
let h = d.getUTCHours();
const m = d.getUTCMinutes();
const ampm = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
return `${h}:${String(m).padStart(2, "0")} ${ampm}`;
}
/** YYYY-MM-DD in the configured timezone. */
export function localDate(now, tzOffsetMinutes) {
return new Date(now.getTime() + tzOffsetMinutes * 60000).toISOString().slice(0, 10);
}
export function localMinutes(now, tzOffsetMinutes) {
const d = new Date(now.getTime() + tzOffsetMinutes * 60000);
return d.getUTCHours() * 60 + d.getUTCMinutes();
}
+119 -236
View File
@@ -1,146 +1,148 @@
// Core logic: District API client, session matching, alert state machine, ntfy publisher. // Provider-agnostic alert logic: what to notify about, once, and when to stay quiet.
// Everything here is runtime-agnostic and side-effect-free except checkDistrict/notify. // Providers supply normalized sessions; nothing here knows which site they came from.
import { localDate, localMinutes } from "./lib.js";
export class CheckError extends Error {} export function defaultProviderState() {
return { dateOpenAlerted: false, sidsAlerted: {}, failures: 0, brokenAlerted: false, lastGood: null };
const UA = "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36";
/**
* District's web client mints this token locally on every request — it is not
* server-issued, and any value of this shape is accepted.
*/
export function guestToken() {
let digits = "";
for (let i = 0; i < 18; i++) digits += Math.floor(Math.random() * 10);
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
let suffix = "";
for (let i = 0; i < 11; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
return `${Date.now()}_${digits}_${suffix}`;
} }
/** API showTime is UTC without a zone suffix ("2026-08-05T03:30"). */ export function defaultState() {
export function toLocalTime(showTime, tzOffsetMinutes) { return { hbDate: "", retired: false, providers: {} };
let s = showTime;
if (/T\d{2}:\d{2}$/.test(s)) s += ":00";
if (!s.endsWith("Z")) s += "Z";
const d = new Date(Date.parse(s) + tzOffsetMinutes * 60000);
let h = d.getUTCHours();
const m = d.getUTCMinutes();
const ampm = h >= 12 ? "PM" : "AM";
h = h % 12 || 12;
return `${h}:${String(m).padStart(2, "0")} ${ampm}`;
} }
/** YYYY-MM-DD in the configured timezone. */ /** Provider sub-state, defaulted, so older state files keep working. */
export function localDate(now, tzOffsetMinutes) { export function providerState(state, key) {
return new Date(now.getTime() + tzOffsetMinutes * 60000).toISOString().slice(0, 10); return { ...defaultProviderState(), ...((state.providers ?? {})[key] ?? {}) };
} }
function localMinutes(now, tzOffsetMinutes) {
const d = new Date(now.getTime() + tzOffsetMinutes * 60000);
return d.getUTCHours() * 60 + d.getUTCMinutes();
}
/** Single source of truth for "should this tick send today's heartbeat". */
export function heartbeatDue(state, now, cfg) { export function heartbeatDue(state, now, cfg) {
if (cfg.heartbeatHour < 0) return false; if (cfg.heartbeatHour < 0) return false;
return localMinutes(now, cfg.tzOffsetMinutes) >= cfg.heartbeatHour * 60 && return localMinutes(now, cfg.tzOffsetMinutes) >= cfg.heartbeatHour * 60 &&
state.hbDate !== localDate(now, cfg.tzOffsetMinutes); state.hbDate !== localDate(now, cfg.tzOffsetMinutes);
} }
function isMatch(sess, cfg) { function sessionLine(s) {
// Deliberately broad: either the format tag or a substring of the screen format, const head = [s.time, s.cinema, s.audi || s.format, s.avail != null ? `seats ${s.avail}/${s.total}` : null]
// so "IMAX" also catches "IMAX 3D" and similar variants. .filter(Boolean).join(" | ");
if (!cfg.formatTag && !cfg.formatMatch) return true; return s.tiers ? `${head}\n${s.tiers}` : head;
return (cfg.formatTag && (sess.tags || []).includes(cfg.formatTag)) ||
(cfg.formatMatch && String(sess.scrnFmt || "").toUpperCase().includes(cfg.formatMatch.toUpperCase()));
}
function normalize(sess, cinemaName, cfg) {
return {
sid: String(sess.sid),
cinema: cinemaName,
time: toLocalTime(sess.showTime, cfg.tzOffsetMinutes),
audi: sess.audi || "",
avail: sess.avail,
total: sess.total,
tiers: (sess.areas || [])
.map((a) => `${a.label} ${cfg.currency}${Math.round(a.price)} (${a.sAvail}/${a.sTotal})`)
.join(" · "),
};
} }
/** /**
* Split a District response into matching sessions. * Decide what to alert for ONE provider on one tick.
* matched — at cfg.cinemaId (or every cinema when cinemaId is unset) * input: { kind: "closed"|"open"|"error", extract?, reason?, now, probeMax? }
* other — matching format at a different cinema (reported as secondary lines) * Returns { baseState, alerts } for that provider's sub-state. Each alert carries its own
* statePatch, which the caller merges only after that alert's push succeeds, so a failed
* notification retries next tick instead of being recorded as delivered.
*/ */
export function extractSessions(data, cfg) { export function decideProvider(pstate, input, cfg, p) {
const cinemas = [ const base = structuredClone(pstate);
...(data?.pageData?.nearbyCinemas ?? []), const alerts = [];
...(data?.pageData?.farCinemas ?? []), const now = input.now;
]; const tag = (a) => ({ ...a, provider: p.key });
const out = {
matched: [], other: [], if (input.probeMax && base.lastGood?.showDatesMax !== input.probeMax) {
cinemaCount: cinemas.length, sessionCount: 0, base.lastGood = { ...(base.lastGood ?? {}), showDatesMax: input.probeMax, at: now.toISOString() };
showDates: data?.meta?.showDates ?? [],
};
for (const c of cinemas) {
const name = c?.cinemaInfo?.name || `cinema ${c?.id}`;
for (const s of c?.sessions ?? []) {
out.sessionCount++;
if (!isMatch(s, cfg)) continue;
const wanted = !cfg.cinemaId || String(c.id) === String(cfg.cinemaId);
(wanted ? out.matched : out.other).push(normalize(s, name, cfg));
}
}
return out;
} }
function apiUrl(cfg, date) { if (input.kind === "error") {
const p = new URLSearchParams({ base.failures = Math.min((pstate.failures ?? 0) + 1, cfg.failureThreshold);
version: "3", site_id: "1", channel: "mweb", child_site_id: "1", platform: "district", if (base.failures >= cfg.failureThreshold && !pstate.brokenAlerted) {
movieCode: cfg.movieCode, city_key: cfg.cityKey, content_id: cfg.contentId, alerts.push(tag({
date, latitude: cfg.lat, longitude: cfg.lng, cinemaOrderLogic: "3", title: `${p.label} checks BROKEN - check manually`,
}); priority: "4",
return `https://www.district.in/gw/consumer/movies/v5/movie?${p}`; tags: "warning",
click: p.bookingUrl,
body: `${cfg.failureThreshold} consecutive failures against ${p.label} (last: ${input.reason}). The site may be blocking this host, or be down. Until this recovers, silence does NOT mean "not open yet" - check the page yourself.`,
statePatch: { brokenAlerted: true },
}));
}
return { baseState: base, alerts };
}
base.failures = 0;
if (pstate.brokenAlerted) {
alerts.push(tag({
title: `${p.label} checks recovered`,
priority: "3",
tags: "white_check_mark",
click: p.bookingUrl,
body: `${p.label} checks are succeeding again. Normal service resumed.`,
statePatch: { brokenAlerted: false },
}));
}
const ex = input.kind === "open" ? input.extract : null;
const effectiveKind = ex && ex.sessionCount > 0 ? "open" : "closed"; // open-but-empty = closed
if (base.lastGood?.kind !== effectiveKind) {
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, at: now.toISOString() };
}
if (effectiveKind === "open") {
const fresh = ex.matched.filter((s) => !pstate.sidsAlerted?.[s.sid]);
if (fresh.length > 0) {
const lines = fresh.map(sessionLine);
for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`);
const sidsAlerted = { ...pstate.sidsAlerted };
for (const s of fresh) sidsAlerted[s.sid] = true;
alerts.push(tag({
title: `${cfg.formatLabel} ${cfg.targetDate} OPEN on ${p.label} - BOOK NOW`,
priority: "5",
tags: "rotating_light,movie_camera",
click: p.bookingUrl,
body: lines.join("\n\n"),
statePatch: { sidsAlerted, dateOpenAlerted: true },
}));
} else if (!pstate.dateOpenAlerted && ex.matched.length === 0) {
alerts.push(tag({
title: `${cfg.targetDate} is OPEN on ${p.label} (no ${cfg.formatLabel} yet)`,
priority: "4",
tags: "eyes",
click: p.bookingUrl,
body: `${p.label} is listing ${cfg.targetDate} at ${ex.cinemaCount} cinemas, but nothing matching "${cfg.formatLabel}" so far. You'll get a max-priority push the moment it appears.${ex.showDates.length ? ` Open dates: ${ex.showDates.join(", ")}.` : ""}`,
statePatch: { dateOpenAlerted: true },
}));
}
}
return { baseState: base, alerts };
} }
/** /**
* One API call, classified by HTTP outcome only: * Tick-level decisions that are not per-provider: retirement and the daily heartbeat.
* 204 -> date not open for booking yet * summaries: [{ label, kind, showDatesMax }] describing each provider this tick.
* 200 -> open (open-but-empty is decided later, in decide())
* Anything else throws CheckError, which counts toward the failure streak.
*/ */
export async function checkDistrict(cfg, date) { export function decideGlobal(state, cfg, now, summaries) {
let res; const base = structuredClone(state);
try { const alerts = [];
res = await fetch(apiUrl(cfg, date), {
headers: { if (base.retired) return { baseState: base, alerts };
accept: "*/*",
api_source: "district", if (now.getTime() >= Date.parse(cfg.expiryUtc)) {
"x-app-type": "ed_mweb", alerts.push({
"x-guest-token": guestToken(), title: "Monitor retired",
"x-request-id": crypto.randomUUID(), priority: "3",
"user-agent": UA, tags: "checkered_flag",
...(cfg.bookingUrl ? { referer: cfg.bookingUrl } : {}), click: cfg.bookingUrl,
}, body: `${cfg.targetDate} has passed, so this monitor is now idle. Stop it when convenient.`,
signal: AbortSignal.timeout(15000), statePatch: { retired: true },
// Node-only: routes this call through a SOCKS5 proxy when one is configured.
...(cfg.dispatcher ? { dispatcher: cfg.dispatcher } : {}),
}); });
} catch (err) { return { baseState: base, alerts };
throw new CheckError(`fetch failed: ${err.message}`);
} }
if (res.status === 204) return { kind: "closed" };
if (res.status === 200) { if (heartbeatDue(state, now, cfg)) {
try { const lines = summaries.map((s) =>
return { kind: "open", data: await res.json() }; `${s.label}: ${s.kind ?? "unknown"}${s.showDatesMax ? ` (open through ${s.showDatesMax})` : ""}`);
} catch (err) { alerts.push({
throw new CheckError(`unparseable 200 body: ${err.message}`); title: `Still watching ${cfg.targetDate}`,
priority: "1",
tags: "hourglass",
click: cfg.bookingUrl,
body: `Monitor healthy.\n${lines.join("\n")}`,
statePatch: { hbDate: localDate(now, cfg.tzOffsetMinutes) },
});
} }
}
throw new CheckError(`HTTP ${res.status}`); return { baseState: base, alerts };
} }
export async function notify(cfg, alert) { export async function notify(cfg, alert) {
@@ -161,122 +163,3 @@ export async function notify(cfg, alert) {
}); });
if (!res.ok) throw new Error(`ntfy HTTP ${res.status}`); if (!res.ok) throw new Error(`ntfy HTTP ${res.status}`);
} }
export function defaultState() {
return {
dateOpenAlerted: false,
sidsAlerted: {},
failures: 0,
brokenAlerted: false,
hbDate: "",
lastGood: null,
retired: false,
};
}
/**
* Pure decision core.
* input: { kind: "closed"|"open"|"error", extract?, reason?, now, probeMax? }
* Returns { baseState, alerts }. Each alert carries its own statePatch, which the
* caller merges ONLY after that alert's push succeeds, so a failed push retries
* next tick instead of being silently marked as delivered.
*/
export function decide(state, input, cfg) {
const base = structuredClone(state);
const alerts = [];
const now = input.now;
if (base.retired) return { baseState: base, alerts };
if (now.getTime() >= Date.parse(cfg.expiryUtc)) {
alerts.push({
title: "Monitor retired",
priority: "3",
tags: "checkered_flag",
click: cfg.bookingUrl,
body: `${cfg.targetDate} has passed, so this monitor is now idle. Stop it when convenient.`,
statePatch: { retired: true },
});
return { baseState: base, alerts };
}
// Only write when it actually changed, so a repeating probe cannot churn state.
if (input.probeMax && base.lastGood?.showDatesMax !== input.probeMax) {
base.lastGood = { ...(base.lastGood ?? {}), showDatesMax: input.probeMax, at: now.toISOString() };
}
if (input.kind === "error") {
base.failures = Math.min((state.failures ?? 0) + 1, cfg.failureThreshold);
if (base.failures >= cfg.failureThreshold && !state.brokenAlerted) {
alerts.push({
title: "Monitor BROKEN - check manually",
priority: "4",
tags: "warning",
click: cfg.bookingUrl,
body: `${cfg.failureThreshold} consecutive check failures (last: ${input.reason}). The site may be blocking this host, or be down. Until this recovers, silence does NOT mean "not open yet" - check the page yourself.`,
statePatch: { brokenAlerted: true },
});
}
return { baseState: base, alerts };
}
// --- success tick ---
base.failures = 0;
if (state.brokenAlerted) {
alerts.push({
title: "Monitor recovered",
priority: "3",
tags: "white_check_mark",
click: cfg.bookingUrl,
body: "Checks are succeeding again. Normal service resumed.",
statePatch: { brokenAlerted: false },
});
}
const ex = input.kind === "open" ? input.extract : null;
const effectiveKind = ex && ex.sessionCount > 0 ? "open" : "closed"; // open-but-empty = closed
if (base.lastGood?.kind !== effectiveKind) {
base.lastGood = { ...(base.lastGood ?? {}), kind: effectiveKind, at: now.toISOString() };
}
if (effectiveKind === "open") {
const fresh = ex.matched.filter((s) => !state.sidsAlerted?.[s.sid]);
if (fresh.length > 0) {
const lines = fresh.map((s) =>
`${s.time} | ${s.cinema}${s.audi ? ` | ${s.audi}` : ""} | seats ${s.avail}/${s.total}\n${s.tiers}`);
for (const s of ex.other) lines.push(`Also at ${s.cinema}: ${s.time}`);
const sidsAlerted = { ...state.sidsAlerted };
for (const s of fresh) sidsAlerted[s.sid] = true;
alerts.push({
title: `${cfg.formatLabel} ${cfg.targetDate} OPEN - BOOK NOW`,
priority: "5",
tags: "rotating_light,movie_camera",
click: cfg.bookingUrl,
body: lines.join("\n\n"),
statePatch: { sidsAlerted, dateOpenAlerted: true },
});
} else if (!state.dateOpenAlerted && ex.matched.length === 0) {
alerts.push({
title: `${cfg.targetDate} bookings are OPEN (no ${cfg.formatLabel} yet)`,
priority: "4",
tags: "eyes",
click: cfg.bookingUrl,
body: `${ex.cinemaCount} cinemas are listing ${cfg.targetDate}, but nothing matching "${cfg.formatLabel}" so far. You'll get a max-priority push the moment it appears. Open dates: ${ex.showDates.join(", ") || "n/a"}.`,
statePatch: { dateOpenAlerted: true },
});
}
}
if (heartbeatDue(state, now, cfg)) {
alerts.push({
title: `Still watching ${cfg.targetDate}`,
priority: "1",
tags: "hourglass",
click: cfg.bookingUrl,
body: `Monitor healthy. Bookings open through: ${base.lastGood?.showDatesMax ?? "unavailable"}. Target date is still ${effectiveKind}.`,
statePatch: { hbDate: localDate(now, cfg.tzOffsetMinutes) },
});
}
return { baseState: base, alerts };
}
+149
View File
@@ -0,0 +1,149 @@
// BookMyShow (in.bookmyshow.com) provider.
//
// BMS sits behind Cloudflare bot management that rejects Node's fetch and curl with 403
// regardless of headers or cookies: the block is on the TLS/HTTP2 fingerprint. `impit`
// performs the request with a real Chrome fingerprint, which is why it is a dependency.
//
// Unlike District there is no 204. A date that is not on sale yet answers 200 with a
// short body that simply has no `showtimeWidgets` key, so "closed" is inferred from the
// payload rather than the status code.
import { CheckError } from "../lib.js";
export const key = "bookmyshow";
export const label = "BookMyShow";
let Impit = null;
let client = null;
async function getClient(cfg) {
if (client) return client;
if (!Impit) ({ Impit } = await import("impit"));
// With socks5:// impit resolves DNS locally and asks the proxy for that address,
// which fails outright on an IPv6-only exit that cannot reach the IPv4 we picked.
// socks5h:// leaves resolution to the proxy, so normalize to it.
const proxyUrl = cfg.proxyUrl ? cfg.proxyUrl.replace(/^socks5:\/\//i, "socks5h://") : "";
client = new Impit({
browser: "chrome",
...(proxyUrl ? { proxyUrl } : {}),
});
return client;
}
/** BMS dates are YYYYMMDD; the rest of the app speaks YYYY-MM-DD. */
export const toDateCode = (isoDate) => isoDate.replaceAll("-", "");
function apiUrl(p, date) {
const q = new URLSearchParams({
etCodes: p.eventCode,
dateCode: toDateCode(date),
isDesktop: "true",
regionCode: p.regionCode,
xLocationShared: "false",
memberId: "", lsId: "", subCode: "",
appCode: "WEB",
refEventCode: p.refEventCode || p.eventCode,
...(p.language ? { language: p.language } : {}),
});
return `https://in.bookmyshow.com/api/movies-data/v5/showtimes-by-event/primary-dynamic?${q}`;
}
/** Generated by the BMS web client itself, not issued by the server. */
const bmsId = () => `1.${Math.floor(Math.random() * 9e8 + 1e8)}.${Date.now()}`;
export async function check(cfg, p, date) {
const impit = await getClient(cfg);
let res, text;
try {
res = await impit.fetch(apiUrl(p, date), {
headers: {
accept: "application/json, text/plain, */*",
"x-region-code": p.regionCode,
"x-region-slug": p.regionSlug,
"x-latitude": p.lat,
"x-longitude": p.lng,
"x-bms-id": bmsId(),
"x-app-code": "WEB",
"x-platform-code": "WEB",
"x-platform": "WEB",
},
});
text = await res.text();
} catch (err) {
throw new CheckError(`fetch failed: ${err.message}`);
}
if (res.status !== 200) throw new CheckError(`HTTP ${res.status}`);
let data;
try {
data = JSON.parse(text);
} catch (err) {
throw new CheckError(`unparseable body: ${err.message}`);
}
// No showtime widgets at all means the date has not gone on sale.
if (!data?.data?.showtimeWidgets) return { kind: "closed" };
return { kind: "open", data };
}
/** Venue cards sit several widget layers deep, so walk rather than index blindly. */
function collectVenueCards(node, out = []) {
if (Array.isArray(node)) {
for (const n of node) collectVenueCards(n, out);
} else if (node && typeof node === "object") {
if (node.type === "venue-card") out.push(node);
for (const v of Object.values(node)) collectVenueCards(v, out);
}
return out;
}
/** Seat tiers live in the double-tap bottom sheet: type, price and an availability word. */
function tiersOf(showtime, cfg) {
const widgets = showtime?.customGestureCTA?.additionalData?.bottomSheetData?.widgets ?? [];
return widgets
.filter((w) => w?.variableData?.seatType)
.map((w) => {
const v = w.variableData;
const cost = String(v.seatCost || "").replace(/[^\d.]/g, "");
const price = cost ? `${cfg.currency}${Math.round(Number(cost))}` : "";
return `${v.seatType} ${price} (${v.seatAvalibility || ""})`.replace(/\s+/g, " ").trim();
})
.join(" · ");
}
function formatOf(showtime) {
return String(showtime.screenAttr || showtime?.additionalData?.attributes || "");
}
function isMatch(showtime, p) {
if (!p.formatMatch) return true;
return formatOf(showtime).toUpperCase().includes(p.formatMatch.toUpperCase());
}
export function extract(data, cfg, p) {
const cards = collectVenueCards(data?.data?.showtimeWidgets ?? []);
const out = { matched: [], other: [], cinemaCount: cards.length, sessionCount: 0, showDates: [] };
for (const card of cards) {
const info = card.additionalData ?? {};
const name = info.venueName || info.venueCode || "unknown venue";
for (const section of card.showtimesSections ?? []) {
for (const st of section.showtimes ?? []) {
out.sessionCount++;
if (!isMatch(st, p)) continue;
const ad = st.additionalData ?? {};
const session = {
sid: String(ad.sessionId || `${info.venueCode}-${ad.showTimeCode || st.title}`),
cinema: name,
// BMS already returns local wall-clock time, so no timezone shift is applied.
time: ad.showTime || st.title || "",
audi: "",
format: formatOf(st),
// BMS exposes availability words per tier, never exact seat counts.
avail: null,
total: null,
tiers: tiersOf(st, cfg),
};
const wanted = !p.venueId || String(info.venueCode) === String(p.venueId);
(wanted ? out.matched : out.other).push(session);
}
}
}
return out;
}
+112
View File
@@ -0,0 +1,112 @@
// District (district.in) provider.
// The showtimes endpoint answers 204 while a date is not on sale yet, and 200 with the
// full session list once it opens. Plain fetch works, so this provider needs no special
// HTTP client, and it honours a SOCKS5 dispatcher when one is configured.
import { CheckError, toLocalTime } from "../lib.js";
export const key = "district";
export const label = "District";
const UA = "Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Mobile Safari/537.36";
/** Generated client-side by District's own web app, not issued by the server. */
function guestToken() {
let digits = "";
for (let i = 0; i < 18; i++) digits += Math.floor(Math.random() * 10);
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789";
let suffix = "";
for (let i = 0; i < 11; i++) suffix += alphabet[Math.floor(Math.random() * alphabet.length)];
return `${Date.now()}_${digits}_${suffix}`;
}
function apiUrl(p, date) {
const q = new URLSearchParams({
version: "3", site_id: "1", channel: "mweb", child_site_id: "1", platform: "district",
movieCode: p.movieCode, city_key: p.cityKey, content_id: p.contentId,
date, latitude: p.lat, longitude: p.lng, cinemaOrderLogic: "3",
});
return `https://www.district.in/gw/consumer/movies/v5/movie?${q}`;
}
/** @returns {{kind:"closed"}|{kind:"open",data:object}} */
export async function check(cfg, p, date) {
let res;
try {
res = await fetch(apiUrl(p, date), {
headers: {
accept: "*/*",
api_source: "district",
"x-app-type": "ed_mweb",
"x-guest-token": guestToken(),
"x-request-id": crypto.randomUUID(),
"user-agent": UA,
...(p.bookingUrl ? { referer: p.bookingUrl } : {}),
},
signal: AbortSignal.timeout(15000),
...(cfg.dispatcher ? { dispatcher: cfg.dispatcher } : {}),
});
} catch (err) {
throw new CheckError(`fetch failed: ${err.message}`);
}
if (res.status === 204) return { kind: "closed" };
if (res.status === 200) {
try {
return { kind: "open", data: await res.json() };
} catch (err) {
throw new CheckError(`unparseable 200 body: ${err.message}`);
}
}
throw new CheckError(`HTTP ${res.status}`);
}
function isMatch(sess, p) {
if (!p.formatTag && !p.formatMatch) return true;
return (p.formatTag && (sess.tags || []).includes(p.formatTag)) ||
(p.formatMatch && String(sess.scrnFmt || "").toUpperCase().includes(p.formatMatch.toUpperCase()));
}
export function extract(data, cfg, p) {
const cinemas = [
...(data?.pageData?.nearbyCinemas ?? []),
...(data?.pageData?.farCinemas ?? []),
];
const out = {
matched: [], other: [],
cinemaCount: cinemas.length, sessionCount: 0,
showDates: data?.meta?.showDates ?? [],
};
for (const c of cinemas) {
const name = c?.cinemaInfo?.name || `cinema ${c?.id}`;
for (const s of c?.sessions ?? []) {
out.sessionCount++;
if (!isMatch(s, p)) continue;
const session = {
sid: String(s.sid),
cinema: name,
time: toLocalTime(s.showTime, cfg.tzOffsetMinutes),
audi: s.audi || "",
format: s.scrnFmt || "",
avail: s.avail,
total: s.total,
tiers: (s.areas || [])
.map((a) => `${a.label} ${cfg.currency}${Math.round(a.price)} (${a.sAvail}/${a.sTotal})`)
.join(" · "),
};
const wanted = !p.venueId || String(c.id) === String(p.venueId);
(wanted ? out.matched : out.other).push(session);
}
}
return out;
}
/**
* Optional capability: how far ahead bookings currently run. `meta.showDates` only
* appears in a 200 body, and the target date returns an empty 204 while closed, so this
* asks about today instead. Best effort; the caller ignores failures.
*/
export async function probeShowDates(cfg, p, todayIso) {
const res = await check(cfg, p, todayIso);
if (res.kind !== "open") return null;
const dates = res.data?.meta?.showDates ?? [];
return dates.length ? dates.slice().sort().at(-1) : null;
}
+1
View File
@@ -0,0 +1 @@
{"data":{"header":{"title":{"styleId":"header-title-v2","text":"Avengers Endgame: Encore"},"subtitle":{"styleId":"screen-subtitle","text":"Movie runtime: 3h 5m"},"startActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/left-chevron.png","cta":{"type":"closeScreen","analytics":{"event_action":"back","event_name":"showtime_actions","event_type":"click"}}}],"endActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/search.png","cta":{"type":"searchView","analytics":{"event_action":"search","event_name":"showtime_actions","event_type":"click"}}}],"backgroundColor":"#FFFFFF","searchBackgroundColor":"#FFFFFF","searchForegroundColor":"#000000","additionalData":{"genre":"Action|Adventure|Fantasy"}},"additionalData":{},"addOnWidgets":{}},"metadata":{"error":{"title":"Uh-oh! We couldn't find anything.","message":"There are no shows available in this region.","errorCode":"1a.1b.2001","imageUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/errors/no-showtimes-found-eventcode.png","action":{"label":"Change Location","backgroundColor":"#EB4E62","textColor":"#FFFFFF","cta":{"type":"changeLocation"}}}}}
+1
View File
@@ -0,0 +1 @@
{"data":{"meta":{"version":23},"header":{"title":{"styleId":"header-title-v2","text":"Toxic: A Fairy Tale for Grown-ups"},"subtitle":{"styleId":"screen-subtitle","text":"Movie runtime: 3h 14m"},"startActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/left-chevron.png","cta":{"type":"closeScreen","analytics":{"event_action":"back","event_name":"showtime_actions","event_type":"click"}}}],"endActions":[{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/search.png","cta":{"type":"searchView","analytics":{"event_action":"search","event_name":"showtime_actions","event_type":"click"}}},{"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/filter.png","cta":{"type":"bottomsheet","id":"filter","analytics":{"event_action":"filter_icon","event_name":"showtime_actions","event_type":"click"}}}],"backgroundColor":"#FFFFFF","searchBackgroundColor":"#FFFFFF","searchForegroundColor":"#000000","additionalData":{"eventCensor":"A","genre":"Action|Crime|Period|Thriller"}},"bottomSheetData":{"filter":{"type":"filter","widgets":[{"type":"vertical-text-list","data":[{"text":"Filters","styleId":"bottomsheet-title"}]},{"type":"chip-list","text":"Price Range","data":[{"type":"chip","title":"₹0 - ₹500","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"0-500"},"additionalData":{"filterID":"pf5","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹501 - ₹600","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"501-600"},"additionalData":{"filterID":"pf6","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹601 - ₹700","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"601-700"},"additionalData":{"filterID":"pf7","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹701 - ₹800","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"701-800"},"additionalData":{"filterID":"pf8","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹801 - ₹900","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"801-900"},"additionalData":{"filterID":"pf9","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹901 - ₹1000","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"901-1000"},"additionalData":{"filterID":"pf91","filterLevel":"both","categoryId":"price"}}},{"type":"chip","title":"₹1001 - ₹1200","cta":{"type":"selectFilter","analytics":{"category":"price","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"1001-1200"},"additionalData":{"filterID":"pf93","filterLevel":"both","categoryId":"price"}}}]},{"type":"chip-list","text":"Special Formats","secondaryText":"No special formats available in your region"},{"type":"chip-list","text":"Other Filters","data":[{"type":"chip","title":"Cancellable","cta":{"type":"selectFilter","analytics":{"category":"cancel","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"Cancellable"},"additionalData":{"filterID":"cancellation","filterLevel":"parent","categoryId":"cancellation"}}}]},{"type":"multi-selector","text":"Preferred Time","data":[{"title":{"styleId":"filter-title","text":"Morning"},"subtitle":{"styleId":"filter-subtitle","text":"12:00 AM - 11:59 AM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/morning.png","cta":{"type":"selectFilter","analytics":{"category":"time","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"morning"},"additionalData":{"filterID":"tf1","filterLevel":"both","categoryId":"time"}}},{"title":{"styleId":"filter-title","text":"Afternoon"},"subtitle":{"styleId":"filter-subtitle","text":"12:00 PM - 3:59 PM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/afternoon.png","cta":{"type":"selectFilter","analytics":{"category":"time","event_action":"bottom_filter","event_name":"showtime_actions","event_type":"click","label":"afternoon"},"additionalData":{"filterID":"tf2","filterLevel":"both","categoryId":"time"}}},{"title":{"styleId":"filter-title-disabled","text":"Evening"},"subtitle":{"styleId":"filter-subtitle-disabled","text":"4:00 PM - 6:59 PM"},"iconUrl":"https://assets-in.bmscdn.com/moviesmaster/movies-showtimes/v4/common/evening-disabled.png"},{"title":{"styleId":"filter-title","text":"Night"},"subtiLine truncated
+101 -123
View File
@@ -1,165 +1,129 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import {
import { fileURLToPath } from "node:url"; decideProvider, decideGlobal, defaultProviderState, defaultState, providerState, heartbeatDue,
import { guestToken, toLocalTime, extractSessions, decide, defaultState } from "../src/monitor.js"; } from "../src/monitor.js";
const fixture = JSON.parse(readFileSync(
fileURLToPath(new URL("./fixtures/response-open-date.json", import.meta.url)), "utf8"));
// Matches the sample in .env.example: IMAX at one specific cinema, IST.
const CFG = { const CFG = {
targetDate: "2026-08-10", targetDate: "2026-09-25",
expiryUtc: "2026-08-10T18:30:00Z", expiryUtc: "2026-09-25T18:30:00Z",
bookingUrl: "https://example.com/book", bookingUrl: "https://example.com/book",
failureThreshold: 6, failureThreshold: 6,
tzOffsetMinutes: 330, tzOffsetMinutes: 330,
heartbeatHour: 9, heartbeatHour: 9,
formatTag: "imax_2d",
formatMatch: "IMAX",
formatLabel: "IMAX", formatLabel: "IMAX",
cinemaId: "1032362",
currency: "₹", currency: "₹",
}; };
const P = { key: "district", label: "District", bookingUrl: "https://example.com/d" };
const P2 = { key: "bookmyshow", label: "BookMyShow", bookingUrl: "https://example.com/b" };
test("guestToken matches District's client-generated shape", () => { const T0 = new Date("2026-09-20T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window
const t = guestToken(); const HB = new Date("2026-09-20T03:31:00Z"); // inside it
assert.match(t, /^\d{13}_\d{18}_[a-z0-9]{11}$/);
assert.notEqual(guestToken(), t, "fresh token per call");
});
test("toLocalTime converts API UTC showTime to local clock time", () => {
assert.equal(toLocalTime("2026-08-05T03:30", 330), "9:00 AM"); // verified against the site UI
assert.equal(toLocalTime("2026-08-05T06:30", 330), "12:00 PM"); // noon boundary
assert.equal(toLocalTime("2026-08-05T12:00", 330), "5:30 PM");
assert.equal(toLocalTime("2026-08-05T18:45", 330), "12:15 AM"); // past midnight, clock time only
assert.equal(toLocalTime("2026-08-05T03:30:00", 330), "9:00 AM"); // seconds already present
assert.equal(toLocalTime("2026-08-05T03:30", 0), "3:30 AM"); // other timezones
});
test("extractSessions finds the target cinema's sessions in a real capture", () => {
const ex = extractSessions(fixture, CFG);
assert.equal(ex.cinemaCount, 5);
assert.ok(ex.sessionCount > ex.matched.length);
assert.equal(ex.matched.length, 4);
assert.equal(ex.other.length, 0);
const s = ex.matched[0];
assert.equal(s.sid, "36874");
assert.equal(s.cinema, "PVR Palladium Mall, Thaltej, Ahmedabad");
assert.equal(s.time, "9:00 AM");
assert.equal(s.audi, "SCREEN 9");
assert.ok(s.tiers.includes("CLASSIC ₹260"));
assert.ok(s.tiers.includes("RECLINER ₹640"));
assert.equal(ex.showDates.at(-1), "2026-08-09");
});
test("extractSessions: no cinemaId watches every cinema; no format filter matches all", () => {
const anyCinema = extractSessions(fixture, { ...CFG, cinemaId: "" });
assert.equal(anyCinema.matched.length, 4, "still only IMAX sessions");
assert.equal(anyCinema.other.length, 0, "nothing is 'other' when no cinema is targeted");
const anyFormat = extractSessions(fixture, { ...CFG, cinemaId: "", formatTag: "", formatMatch: "" });
assert.equal(anyFormat.matched.length, anyFormat.sessionCount, "every session matches");
const otherCinema = extractSessions(fixture, { ...CFG, cinemaId: "125" });
assert.equal(otherCinema.matched.length, 0);
assert.equal(otherCinema.other.length, 4, "IMAX elsewhere becomes a secondary line");
});
test("extractSessions tolerates empty/malformed payloads", () => {
assert.deepEqual(extractSessions({}, CFG),
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
});
const T0 = new Date("2026-08-06T02:00:00Z"); // before the 03:30 UTC (09:00 IST) heartbeat window
const openInput = (extract, now = T0) => ({ kind: "open", extract, now }); const openInput = (extract, now = T0) => ({ kind: "open", extract, now });
const EX_MATCH = { const EX_MATCH = {
matched: [{ sid: "111", cinema: "Test Cinema", time: "9:00 AM", audi: "SCREEN 9", avail: 100, total: 300, tiers: "CLASSIC ₹260 (66/66)" }], matched: [{ sid: "111", cinema: "Test Cinema", time: "9:00 AM", audi: "SCREEN 9", format: "IMAX", avail: 100, total: 300, tiers: "CLASSIC ₹260 (66/66)" }],
other: [], cinemaCount: 3, sessionCount: 7, showDates: [], other: [], cinemaCount: 3, sessionCount: 7, showDates: [],
}; };
const EX_NO_MATCH = { matched: [], other: [], cinemaCount: 3, sessionCount: 7, showDates: [] }; const EX_NO_MATCH = { matched: [], other: [], cinemaCount: 3, sessionCount: 7, showDates: [] };
const EX_EMPTY = { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] }; const EX_EMPTY = { matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] };
/** Mirrors the runner: merge each alert's patch only after its push succeeds. */ /** Mirrors the runner: merge each alert's patch only after its push succeeds. */
function apply(state, decision) { function apply(pstate, decision) {
let merged = decision.baseState; let merged = decision.baseState;
for (const a of decision.alerts) merged = { ...merged, ...a.statePatch }; for (const a of decision.alerts) merged = { ...merged, ...a.statePatch };
return merged; return merged;
} }
test("closed steady state produces no alerts and no state change", () => { test("closed steady state produces no alerts and no state change", () => {
const s1 = apply(defaultState(), decide(defaultState(), { kind: "closed", now: T0 }, CFG)); const s1 = apply(defaultProviderState(), decideProvider(defaultProviderState(), { kind: "closed", now: T0 }, CFG, P));
const d2 = decide(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG); const d2 = decideProvider(s1, { kind: "closed", now: new Date(T0.getTime() + 60000) }, CFG, P);
assert.equal(d2.alerts.length, 0); assert.equal(d2.alerts.length, 0);
assert.equal(JSON.stringify(apply(s1, d2)), JSON.stringify(s1), "no state write when nothing changed"); assert.equal(JSON.stringify(apply(s1, d2)), JSON.stringify(s1), "no state write when nothing changed");
}); });
test("a match fires a max-priority alert once, re-firing only for new session ids", () => { test("a match fires a max-priority alert once, re-firing only for new session ids", () => {
const d1 = decide(defaultState(), openInput(EX_MATCH), CFG); const d1 = decideProvider(defaultProviderState(), openInput(EX_MATCH), CFG, P);
assert.equal(d1.alerts.length, 1); assert.equal(d1.alerts.length, 1);
assert.equal(d1.alerts[0].priority, "5"); assert.equal(d1.alerts[0].priority, "5");
assert.equal(d1.alerts[0].provider, "district", "alerts carry their provider");
assert.ok(d1.alerts[0].title.includes("District"), "the site is named in the title");
assert.ok(d1.alerts[0].statePatch.dateOpenAlerted, "first open response sets dateOpenAlerted"); assert.ok(d1.alerts[0].statePatch.dateOpenAlerted, "first open response sets dateOpenAlerted");
assert.ok(d1.alerts[0].statePatch.sidsAlerted["111"]); assert.ok(d1.alerts[0].statePatch.sidsAlerted["111"]);
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, openInput(EX_MATCH), CFG).alerts.length, 0, "deduped"); const s1 = apply(defaultProviderState(), d1);
assert.equal(decideProvider(s1, openInput(EX_MATCH), CFG, P).alerts.length, 0, "deduped");
const withNew = { ...EX_MATCH, matched: [...EX_MATCH.matched, { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] }; const withNew = { ...EX_MATCH, matched: [...EX_MATCH.matched, { ...EX_MATCH.matched[0], sid: "222", time: "1:15 PM" }] };
const d3 = decide(s1, openInput(withNew), CFG); const d3 = decideProvider(s1, openInput(withNew), CFG, P);
assert.equal(d3.alerts.length, 1, "new session id re-alerts"); assert.equal(d3.alerts.length, 1, "new session id re-alerts");
assert.ok(d3.alerts[0].body.includes("1:15 PM")); assert.ok(d3.alerts[0].body.includes("1:15 PM"));
assert.ok(!d3.alerts[0].body.includes("9:00 AM"), "already-alerted session not repeated"); assert.ok(!d3.alerts[0].body.includes("9:00 AM"), "already-alerted session not repeated");
}); });
test("sessions at other cinemas ride along as secondary lines, untracked", () => { test("sessions without seat counts still render (BookMyShow shape)", () => {
const bmsLike = {
matched: [{ sid: "37929", cinema: "PVR: Palladium", time: "08:00 AM", audi: "", format: "IMAX", avail: null, total: null, tiers: "CLASSIC ₹470 (AVAILABLE)" }],
other: [], cinemaCount: 1, sessionCount: 5, showDates: [],
};
const d = decideProvider(defaultProviderState(), openInput(bmsLike), CFG, P2);
const body = d.alerts[0].body;
assert.ok(body.includes("08:00 AM | PVR: Palladium | IMAX"), body);
assert.ok(!body.includes("seats"), "no seat counts when the provider has none");
assert.ok(d.alerts[0].title.includes("BookMyShow"));
});
test("sessions at other venues ride along as secondary lines, untracked", () => {
const withOther = { const withOther = {
...EX_MATCH, ...EX_MATCH,
other: [{ sid: "999", cinema: "Other Cinema", time: "6:00 PM", audi: "", avail: 50, total: 100, tiers: "CLASSIC ₹300 (50/100)" }], other: [{ sid: "999", cinema: "Other Cinema", time: "6:00 PM", audi: "", format: "IMAX", avail: null, total: null, tiers: "" }],
}; };
const d = decide(defaultState(), openInput(withOther), CFG); const d = decideProvider(defaultProviderState(), openInput(withOther), CFG, P);
assert.equal(d.alerts.length, 1, "no separate push for other venues"); assert.equal(d.alerts.length, 1, "no separate push for other venues");
assert.ok(d.alerts[0].body.includes("Also at Other Cinema: 6:00 PM")); assert.ok(d.alerts[0].body.includes("Also at Other Cinema: 6:00 PM"));
assert.ok(!d.alerts[0].statePatch.sidsAlerted["999"], "other-venue ids are not dedupe-tracked"); assert.ok(!d.alerts[0].statePatch.sidsAlerted["999"], "other-venue ids are not dedupe-tracked");
}); });
test("date open without a match fires DATE_OPEN once; a later match still fires", () => { test("date open without a match fires once; a later match still fires", () => {
const d1 = decide(defaultState(), openInput(EX_NO_MATCH), CFG); const d1 = decideProvider(defaultProviderState(), openInput(EX_NO_MATCH), CFG, P);
assert.equal(d1.alerts.length, 1); assert.equal(d1.alerts.length, 1);
assert.equal(d1.alerts[0].priority, "4"); assert.equal(d1.alerts[0].priority, "4");
const s1 = apply(defaultState(), d1); const s1 = apply(defaultProviderState(), d1);
assert.equal(decide(s1, openInput(EX_NO_MATCH), CFG).alerts.length, 0); assert.equal(decideProvider(s1, openInput(EX_NO_MATCH), CFG, P).alerts.length, 0);
const d3 = decide(s1, openInput(EX_MATCH), CFG); const d3 = decideProvider(s1, openInput(EX_MATCH), CFG, P);
assert.equal(d3.alerts.length, 1); assert.equal(d3.alerts.length, 1);
assert.equal(d3.alerts[0].priority, "5"); assert.equal(d3.alerts[0].priority, "5");
}); });
test("failed match push then sessions vanish -> DATE_OPEN still reachable", () => { test("failed match push then sessions vanish -> date-open alert still reachable", () => {
const d1 = decide(defaultState(), openInput(EX_MATCH), CFG); const d1 = decideProvider(defaultProviderState(), openInput(EX_MATCH), CFG, P);
const s1 = d1.baseState; // push failed: statePatch not merged const s1 = d1.baseState; // push failed: statePatch not merged
const d2 = decide(s1, openInput(EX_NO_MATCH), CFG); const d2 = decideProvider(s1, openInput(EX_NO_MATCH), CFG, P);
assert.equal(d2.alerts.length, 1); assert.equal(d2.alerts.length, 1);
assert.equal(d2.alerts[0].priority, "4", "user still learns the date opened"); assert.equal(d2.alerts[0].priority, "4", "user still learns the date opened");
}); });
test("open-but-empty counts as closed", () => { test("open-but-empty counts as closed", () => {
const d = decide(defaultState(), openInput(EX_EMPTY), CFG); const d = decideProvider(defaultProviderState(), openInput(EX_EMPTY), CFG, P);
assert.equal(d.alerts.length, 0); assert.equal(d.alerts.length, 0);
assert.equal(d.baseState.lastGood.kind, "closed"); assert.equal(d.baseState.lastGood.kind, "closed");
}); });
test("BROKEN fires at the threshold, once; RECOVERED on the next success", () => { test("BROKEN fires at the threshold, once, and names the site; RECOVERED on next success", () => {
let s = defaultState(); let s = defaultProviderState();
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {
const d = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG); const d = decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2);
assert.equal(d.alerts.length, 0, `no alert at failure ${i}`); assert.equal(d.alerts.length, 0, `no alert at failure ${i}`);
s = apply(s, d); s = apply(s, d);
assert.equal(s.failures, i); assert.equal(s.failures, i);
} }
const d6 = decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG); const d6 = decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2);
assert.equal(d6.alerts.length, 1); assert.equal(d6.alerts.length, 1);
assert.ok(d6.alerts[0].title.includes("BookMyShow"));
assert.ok(d6.alerts[0].title.includes("BROKEN")); assert.ok(d6.alerts[0].title.includes("BROKEN"));
s = apply(s, d6); s = apply(s, d6);
assert.equal(decide(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG).alerts.length, 0, "not repeated"); assert.equal(decideProvider(s, { kind: "error", reason: "HTTP 403", now: T0 }, CFG, P2).alerts.length, 0, "not repeated");
const d8 = decide(s, { kind: "closed", now: T0 }, CFG); const d8 = decideProvider(s, { kind: "closed", now: T0 }, CFG, P2);
assert.equal(d8.alerts.length, 1); assert.equal(d8.alerts.length, 1);
assert.ok(d8.alerts[0].title.includes("recovered")); assert.ok(d8.alerts[0].title.includes("recovered"));
s = apply(s, d8); s = apply(s, d8);
@@ -167,48 +131,62 @@ test("BROKEN fires at the threshold, once; RECOVERED on the next success", () =>
assert.equal(s.brokenAlerted, false); assert.equal(s.brokenAlerted, false);
}); });
test("heartbeat fires once per local day, never on error ticks, and can be disabled", () => { test("providers keep independent dedupe state", () => {
const hbTime = new Date("2026-08-06T03:31:00Z"); // 09:01 IST const state = defaultState();
const early = new Date("2026-08-06T03:29:00Z"); const dA = decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P);
assert.equal(decide(defaultState(), { kind: "closed", now: early }, CFG).alerts.length, 0); state.providers.district = apply(providerState(state, "district"), dA);
// BookMyShow has never alerted, so the same session id must still notify there.
const d1 = decide(defaultState(), { kind: "closed", now: hbTime }, CFG); const dB = decideProvider(providerState(state, "bookmyshow"), openInput(EX_MATCH), CFG, P2);
assert.equal(d1.alerts.length, 1); assert.equal(dB.alerts.length, 1, "one site alerting must not silence the other");
assert.equal(d1.alerts[0].priority, "1"); assert.ok(dB.alerts[0].title.includes("BookMyShow"));
assert.equal(d1.alerts[0].statePatch.hbDate, "2026-08-06"); assert.equal(decideProvider(providerState(state, "district"), openInput(EX_MATCH), CFG, P).alerts.length, 0);
const s1 = apply(defaultState(), d1);
assert.equal(decide(s1, { kind: "closed", now: new Date("2026-08-06T09:00:00Z") }, CFG).alerts.length, 0, "once per day");
assert.equal(decide(defaultState(), { kind: "error", reason: "x", now: hbTime }, CFG).alerts.length, 0, "not on errors");
assert.equal(decide(defaultState(), { kind: "closed", now: hbTime }, { ...CFG, heartbeatHour: -1 }).alerts.length, 0, "disabled");
}); });
test("heartbeat reports the probe-refreshed showDates maximum", () => { test("providerState defaults unknown providers and preserves stored ones", () => {
const hbTime = new Date("2026-08-06T03:31:00Z"); const state = { ...defaultState(), providers: { district: { failures: 3 } } };
const d = decide(defaultState(), { kind: "closed", now: hbTime, probeMax: "2026-08-11" }, CFG); assert.equal(providerState(state, "district").failures, 3);
assert.equal(d.baseState.lastGood.showDatesMax, "2026-08-11", "probe result rides baseState"); assert.equal(providerState(state, "district").brokenAlerted, false, "missing fields are defaulted");
assert.ok(d.alerts[0].body.includes("2026-08-11")); assert.deepEqual(providerState(state, "bookmyshow"), defaultProviderState());
}); });
test("recovery, match and heartbeat can co-fire with disjoint patches", () => { // ---------------------------------------------------------- global alerts
const hbTime = new Date("2026-08-06T03:31:00Z");
const s0 = { ...defaultState(), brokenAlerted: true, failures: 6 }; test("heartbeat fires once per local day, summarises every provider, and can be disabled", () => {
const d = decide(s0, openInput(EX_MATCH, hbTime), CFG); const summaries = [
assert.deepEqual(d.alerts.map((a) => a.priority), ["3", "5", "1"]); { label: "District", kind: "closed", showDatesMax: "2026-09-20" },
assert.deepEqual(d.alerts.map((a) => Object.keys(a.statePatch)), { label: "BookMyShow", kind: "closed" },
[["brokenAlerted"], ["sidsAlerted", "dateOpenAlerted"], ["hbDate"]]); ];
const merged = apply(s0, d); assert.equal(decideGlobal(defaultState(), CFG, new Date("2026-09-20T03:29:00Z"), summaries).alerts.length, 0, "before the window");
assert.equal(merged.brokenAlerted, false);
assert.equal(merged.failures, 0); const d = decideGlobal(defaultState(), CFG, HB, summaries);
assert.ok(merged.sidsAlerted["111"]); assert.equal(d.alerts.length, 1);
assert.equal(merged.hbDate, "2026-08-06"); assert.equal(d.alerts[0].priority, "1");
assert.ok(d.alerts[0].body.includes("District: closed (open through 2026-09-20)"));
assert.ok(d.alerts[0].body.includes("BookMyShow: closed"));
assert.equal(d.alerts[0].statePatch.hbDate, "2026-09-20");
const after = { ...defaultState(), hbDate: "2026-09-20" };
assert.equal(decideGlobal(after, CFG, new Date("2026-09-20T09:00:00Z"), summaries).alerts.length, 0, "once per day");
assert.equal(decideGlobal(defaultState(), { ...CFG, heartbeatHour: -1 }, HB, summaries).alerts.length, 0, "disabled");
});
test("heartbeatDue tracks the local day, so it is not fooled by UTC rollover", () => {
assert.equal(heartbeatDue({ hbDate: "" }, HB, CFG), true);
assert.equal(heartbeatDue({ hbDate: "2026-09-20" }, HB, CFG), false);
}); });
test("retirement: one alert at expiry, then a permanent no-op", () => { test("retirement: one alert at expiry, then a permanent no-op", () => {
const past = new Date("2026-08-10T18:31:00Z"); const past = new Date("2026-09-25T18:31:00Z");
const d1 = decide(defaultState(), { kind: "closed", now: past }, CFG); const d1 = decideGlobal(defaultState(), CFG, past, []);
assert.equal(d1.alerts.length, 1); assert.equal(d1.alerts.length, 1);
assert.ok(d1.alerts[0].statePatch.retired); assert.ok(d1.alerts[0].statePatch.retired);
const s1 = apply(defaultState(), d1); const s1 = { ...defaultState(), ...d1.alerts[0].statePatch };
assert.equal(decide(s1, { kind: "closed", now: past }, CFG).alerts.length, 0); assert.equal(decideGlobal(s1, CFG, past, []).alerts.length, 0);
});
test("retirement takes precedence over the heartbeat", () => {
const past = new Date("2026-09-26T03:31:00Z"); // after expiry and inside the heartbeat window
const d = decideGlobal(defaultState(), CFG, past, []);
assert.equal(d.alerts.length, 1);
assert.ok(d.alerts[0].title.includes("retired"));
}); });
+106
View File
@@ -0,0 +1,106 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { toLocalTime, localDate } from "../src/lib.js";
import * as district from "../src/providers/district.js";
import * as bms from "../src/providers/bookmyshow.js";
const load = (f) => JSON.parse(readFileSync(fileURLToPath(new URL(`./fixtures/${f}`, import.meta.url)), "utf8"));
const CFG = { tzOffsetMinutes: 330, currency: "₹" };
test("toLocalTime converts API UTC timestamps to local clock time", () => {
assert.equal(toLocalTime("2026-08-05T03:30", 330), "9:00 AM"); // verified against the site UI
assert.equal(toLocalTime("2026-08-05T06:30", 330), "12:00 PM"); // noon boundary
assert.equal(toLocalTime("2026-08-05T12:00", 330), "5:30 PM");
assert.equal(toLocalTime("2026-08-05T18:45", 330), "12:15 AM"); // past midnight, clock time only
assert.equal(toLocalTime("2026-08-05T03:30:00", 330), "9:00 AM"); // seconds already present
assert.equal(toLocalTime("2026-08-05T03:30", 0), "3:30 AM"); // other timezones
});
test("localDate rolls over at local midnight, not UTC midnight", () => {
assert.equal(localDate(new Date("2026-08-05T19:00:00Z"), 330), "2026-08-06");
assert.equal(localDate(new Date("2026-08-05T17:00:00Z"), 330), "2026-08-05");
});
// ---------------------------------------------------------------- District
const D_PARAMS = { venueId: "1032362", formatTag: "imax_2d", formatMatch: "IMAX" };
test("district: extracts the target cinema's sessions from a real capture", () => {
const ex = district.extract(load("response-open-date.json"), CFG, D_PARAMS);
assert.equal(ex.cinemaCount, 5);
assert.ok(ex.sessionCount > ex.matched.length);
assert.equal(ex.matched.length, 4);
assert.equal(ex.other.length, 0);
const s = ex.matched[0];
assert.equal(s.sid, "36874");
assert.equal(s.cinema, "PVR Palladium Mall, Thaltej, Ahmedabad");
assert.equal(s.time, "9:00 AM");
assert.equal(s.audi, "SCREEN 9");
assert.equal(s.avail, 103); // District reports exact seat counts
assert.ok(s.tiers.includes("CLASSIC ₹260"));
assert.ok(s.tiers.includes("RECLINER ₹640"));
assert.equal(ex.showDates.at(-1), "2026-08-09");
});
test("district: no venue watches every cinema, no format filter matches everything", () => {
const data = load("response-open-date.json");
const anyVenue = district.extract(data, CFG, { ...D_PARAMS, venueId: "" });
assert.equal(anyVenue.matched.length, 4, "still only IMAX");
assert.equal(anyVenue.other.length, 0);
const anyFormat = district.extract(data, CFG, { venueId: "", formatTag: "", formatMatch: "" });
assert.equal(anyFormat.matched.length, anyFormat.sessionCount);
const elsewhere = district.extract(data, CFG, { ...D_PARAMS, venueId: "125" });
assert.equal(elsewhere.matched.length, 0);
assert.equal(elsewhere.other.length, 4, "IMAX at another venue becomes a secondary line");
});
test("district: tolerates empty payloads", () => {
assert.deepEqual(district.extract({}, CFG, D_PARAMS),
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
});
// ------------------------------------------------------------- BookMyShow
const B_PARAMS = { venueId: "PPAM", formatMatch: "IMAX" };
test("bookmyshow: date codes drop the dashes", () => {
assert.equal(bms.toDateCode("2026-09-25"), "20260925");
});
test("bookmyshow: extracts venue sessions and per-tier availability from a real capture", () => {
const ex = bms.extract(load("bms-open-date.json"), CFG, B_PARAMS);
assert.equal(ex.cinemaCount, 1);
assert.equal(ex.sessionCount, 5);
assert.equal(ex.matched.length, 5);
const s = ex.matched[0];
assert.equal(s.sid, "37929");
assert.equal(s.cinema, "PVR: Palladium Mall, Ahmedabad");
assert.equal(s.time, "08:00 AM"); // already local, so it is passed through untouched
assert.equal(s.format, "IMAX");
assert.equal(s.avail, null, "BMS never exposes exact seat counts");
assert.ok(s.tiers.includes("RECLINER ₹960 (ALMOST FULL)"));
assert.ok(s.tiers.includes("CLASSIC ₹470 (AVAILABLE)"));
});
test("bookmyshow: a different venue code moves sessions to the secondary bucket", () => {
const ex = bms.extract(load("bms-open-date.json"), CFG, { ...B_PARAMS, venueId: "XXXX" });
assert.equal(ex.matched.length, 0);
assert.equal(ex.other.length, 5);
});
test("bookmyshow: a non-matching format filter yields nothing", () => {
const ex = bms.extract(load("bms-open-date.json"), CFG, { ...B_PARAMS, formatMatch: "4DX" });
assert.equal(ex.matched.length, 0);
assert.equal(ex.sessionCount, 5, "sessions are still counted, just not matched");
});
test("bookmyshow: a not-yet-on-sale response has no widgets and extracts to nothing", () => {
const closed = load("bms-closed-date.json");
assert.equal(closed.data.showtimeWidgets, undefined, "this is how 'not open yet' looks");
assert.deepEqual(bms.extract(closed, CFG, B_PARAMS),
{ matched: [], other: [], cinemaCount: 0, sessionCount: 0, showDates: [] });
});