A CLI to search China Southern Airlines (CZ) flights and show seats available per cabin for a route and date.
Requires Go 1.26+ and Google Chrome / Chromium (used once to mint the anti-bot token; see Auth).
# install the binary to $(go env GOPATH)/bin
go install github.com/kkweon/csair@latest
# …or build from source
git clone https://github.com/kkweon/csair && cd csair
go build -o csair .Ensure $(go env GOPATH)/bin is on your PATH.
# 1) one-time: mint the anti-bot token (launches headless Chrome, caches it)
csair auth
# 2) search a route + date
csair search SFO CAN 2026-06-14SFO → CAN · 2026-06-14 · 1 adult(s)
FLIGHT ROUTING DEP ARR DUR STOPS SEATS FROM
CZ658 SFO–CAN 00:35 06:20 14h45m direct Business 8 · Economy 9+ USD 1284
AS1602+CZ328 SFO–LAX–CAN 10:15 06:05 28h50m 1-stop Economy 9+ USD 1662
…
The cabin number is the true seats available (the highest fare tier; 9+ means
"9 or more") — see Availability semantics.
Route and date are accepted positionally or via flags:
csair search --from SFO --to CAN --date 2026-06-14| Flag | Default | Meaning |
|---|---|---|
-a, --adults |
1 |
adult passengers |
-c, --children |
0 |
children (2–11) |
-i, --infants |
0 |
lap infants (<2) |
--cabin |
all |
economy | premium | business | first | all |
--carrier |
— | only this marketing carrier, e.g. CZ |
--direct |
false |
nonstop only |
--min-seats |
0 |
only cabins with ≥ N seats |
--sort |
price |
price | duration | departure |
Table on a terminal, JSON when piped/redirected:
csair search SFO CAN 2026-06-14 --cabin business --json | jqForce a format with --json, --csv, or --table. JSON/CSV include the per-RBD
fare-tier breakdown under each cabin.
scan searches each date in a range and ranks the dates by how open the flights
are — handy for standby/non-rev travel where you want the emptiest dates:
csair scan SFO CAN 2026-06-10..2026-06-20 --cabin economySFO → CAN · direct · Economy · 2026-06-10 → 2026-06-20
DATE DOW FLIGHT DEP→ARR SEATS OPEN CLASSES FROM
2026-06-14 Sun CZ658 00:35→06:20 9+ 2 (A/M) USD 1284
…
no Economy availability: 06-15, 06-16
Direct-only by default (--any to include connections), --cabin selects the
cabin to rank, range capped at 31 days. Ranking favors the number of open
booking classes first — a better emptiness signal than the seat count, which
the engine caps at 9 (9+ = "9 or more"). Note: this is bookable inventory, not
the airline's true non-rev seat count (which isn't public) — but open-class depth
is a strong proxy for an empty flight.
The /ita engine is gated only by the Aliyun acw_sc__v2 cookie. csair auth
drives Chrome to mint it and caches it at ~/.config/csair/cookies.json.
csair auth # bootstrap + cache (headless)
csair auth --headed # show the browser (use if headless is challenged)
csair auth --status # show the cached token + expiry
csair auth --clear # forget itsearch/scan auto-bootstrap when the token is missing/expired, and on an
anti-bot block they re-run the browser auth and retry once (--reauth, on by
default; disable with --no-bootstrap). To supply a token yourself instead, use
--acw <token> or the CSAIR_ACW env var.
To stay human-like and avoid blocks, requests reuse the full cookie set from
the bootstrap browser session, send a complete Chrome header set, run paced with
jitter, and the bootstrap browser masks common automation tells (navigator.webdriver,
languages, plugins).
| Var | Purpose |
|---|---|
CSAIR_ACW |
acw_sc__v2 token (skips the browser bootstrap) |
CSAIR_ACW_TC |
acw_tc cookie (optional companion) |
CSAIR_CHROME |
path to a Chrome/Chromium binary |
CSAIR_CURRENCY |
preferred display currency |
Run csair --help or csair <command> --help for the full reference.
China Southern has no public flight-search API. Reverse-engineering the consumer booking sites turned up three engines with very different ergonomics:
| Engine | Host | Protection | Verdict |
|---|---|---|---|
| Overseas NDC | oversea.csair.com/tka/... |
AWS WAF + interactive captcha on rate-limit; grid is single-use and arrives via a skeleton→poll pattern | Hard to automate |
| Mainland | www.csair.com/cn/zh |
Geo-blocked outside China | Unreachable from non-CN IPs |
Intl B2C (/ita) |
b2c.csair.com/ita/... |
openresty, no AWS WAF, light Aliyun cookie (acw_sc__v2); full grid on first call |
Target |
This project targets the /ita engine, which returns the full availability grid in
one JSON call, including the bookingClassAvails field (seats per RBD).
# 1. Create a search session -> HTML containing .../zh/shop/?execution=<EXEC>
POST https://b2c.csair.com/ita/intl/app (application/x-www-form-urlencoded)
language=zh&country=zh&m=0&flexible=1&adt=1&cnn=0&inf=0
&dep[]=SFO&arr[]=CAN&depArea[]=US&arrArea[]=CN&date[]=YYYY-MM-DD
# 2. Query flights -> JSON
POST https://b2c.csair.com/ita/rest/intl/main/aoa/inter/queryInterFlight (application/json)
{"adults":1,"children":0,"infantsInLap":0,
"slices":[{"date":"YYYY-MM-DD","origin":"SFO","destination":"CAN",
"depCityFlag":true,"arrCityFlag":true}],
"sliceIndex":0,"lang":"zh","flightType":"singlePass",
"execution":"<EXEC>","page":1}
data.data.dateFlights[] # one per itinerary option
.segments[] # flightNo, carrier, depPort/arrPort, depTime/arrTime, plane, ...
.stopNumber, .flyTime
.prices[]
.displayPrice / .displayCurrency
.cabins[]
.type # First | Business | PremiumEconomy | Economy
.name # booking class / RBD letter (e.g. C, I, A, M)
.bookingClassAvails # seats available in that RBD ("9" = capped, "9 or more")
.brandCode # fare brand (e.g. JFFA, JSTA, YSTA)
A cabin is sold through several fare tiers (e.g. business 优惠/灵活/尊享), each its
own booking class (RBD) with its own bookingClassAvails. The true seats
available for the cabin is the maximum across those tiers — buying the top
(most expensive) fare covers the cheaper seats. So CabinAvail.Seats = max(tiers)
and From = cheapest tier; the per-RBD breakdown is kept as detail. Example:
business I=6 ($4,029) + C=8 ($6,660) → Business: 8 seats, from $4,029.
No WAF and no interactive captcha. The only gate is the Aliyun cookie acw_sc__v2
(set by g.alicdn.com/.../antidom.js), plus acw_tc. Model:
- Bootstrap once from a real browser session to harvest
acw_sc__v2(+acw_tc), then call the API directly until it expires; re-harvest on expiry. - Be polite: one search per session, throttle, cache. Hammering the other engine
triggered an IP-scoped captcha (
CZWEB000010); same courtesy applies here.
Watches business class for the routes/dates in monitor.toml, keyed by
flight number (e.g. CZ658). Nonstop-only by default; a target may set an
optional flights = ["CZ660"] allowlist to instead track specific itineraries by
flight key — including a 1-stop through-flight ("CZ660") or a named connection
("CZ660+CZ8004"), which bypasses the nonstop filter. Price changes are ignored.
Snapshots live in data/monitor/<FROM>-<TO>-<date>.json; when you run
report diff --write (by hand or via the local-cron fallback), changed
snapshots are committed back, so git history is a free log of every seat change.
The scheduled GitHub Actions monitor only emails a status digest and does not
touch snapshots.
The search/diff/digest logic is in the Go binary (internal/monitor,
unit-tested); two subcommands render one combined body across all targets:
csair report diff --config monitor.toml [--write] # change report; empty if nothing changed
csair report status --config monitor.toml # current-status digest (all targets)report diff prints nothing when no business seat count moved (so callers treat
empty output as "no email"); --write persists the fresh snapshot for new
(baseline) and changed targets. scripts/report-mail.sh diff|status is the thin
orchestration: it mints the token, runs the right subcommand, and ships/sends the
body.
Behavior: a blocked/expired fetch never overwrites the last-good snapshot or sends a bogus alert (non-zero exit); a first-seen target writes a silent baseline; unchanged or price-only results do nothing.
Two workflows live in .github/workflows/:
probe-token.yml— run this first from the Actions tab. It checks whether a GitHub-hosted runner can mint theacw_sc__v2token (the token has a ~15-min TTL, so it must be minted fresh each run — it can't be stored as a secret). The token mint needs real headless Chrome and may be blocked from GitHub's datacenter IPs. Run it a few times across different hours; read the "VERDICT GUIDE" in the logs.monitor.yml— the scheduled monitor. Enable only after the probe shows the runner can mint. It emails a status digest (one combined email of current seats) four times a day — 08:00 / 12:00 / 16:00 / 20:00 America/Los_Angeles (morning / lunch / afternoon / evening, DST-aware via the crontimezone:field) — and on manual dispatch. It does not diff or commit snapshots; for that, runreport diffby hand (see below). Requires repo secrets (Settings → Secrets and variables → Actions):GMAIL_USER,GMAIL_APP_PASSWORD(a 16-char Gmail app password, not your login password), andNOTIFY_TO; optionalNOTIFY_CC.
If the probe shows the runner is walled, run the fetch from a machine on a residential IP instead — same scripts, end-to-end including email:
The scripts need jq on PATH (preinstalled on GitHub runners) to read the
report's JSON result.
export CSAIR_LOCAL_EMAIL=1 CSAIR_MAIL_TO=you@example.com # needs msmtp/sendmail + jq
# export CSAIR_MAIL_CC=partner@example.com # optional Cc
# crontab -e (times are this machine's local time):
17 8,12,16,20 * * * cd ~/github/csair && ./scripts/report-mail.sh status >> ~/.csair-monitor.log 2>&1
# Optional change watch — emails only when seats move, and commits the changed
# data/monitor/*.json snapshots back, so git history logs every seat change:
# 17 */3 * * * cd ~/github/csair && ./scripts/report-mail.sh diff >> ~/.csair-monitor.log 2>&1Only the diff run git commit/pushes snapshots; the status digest leaves
them untouched. No GitHub Actions needed in this mode.
Working: auth (headless Chrome token bootstrap) and search — validated live
end-to-end for SFO→CAN. Output as table / JSON / CSV.
Not done yet: calendar (stub), and the airport→country table is a seed set
(internal/airport) rather than a full embedded dataset.
Unofficial, for personal use. Not affiliated with or endorsed by China Southern Airlines. Respect the site's terms of service and rate limits.