Skip to content

kkweon/csair

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

33 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

csair

A CLI to search China Southern Airlines (CZ) flights and show seats available per cabin for a route and date.

Install

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.

Usage

# 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-14
SFO → 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

search flags

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

Output formats

Table on a terminal, JSON when piped/redirected:

csair search SFO CAN 2026-06-14 --cabin business --json | jq

Force a format with --json, --csv, or --table. JSON/CSV include the per-RBD fare-tier breakdown under each cabin.

Scan a date range (standby)

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 economy
SFO → 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.

Auth / token

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 it

search/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).

Environment

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.

Why this exists

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).

API flow

# 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}

Response shape (the parts we use)

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)

Availability semantics

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.

Auth / anti-bot

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.

Seat monitor (cron + email alerts)

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.

GitHub Actions

Two workflows live in .github/workflows/:

  1. probe-token.yml — run this first from the Actions tab. It checks whether a GitHub-hosted runner can mint the acw_sc__v2 token (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.
  2. 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 cron timezone: field) — and on manual dispatch. It does not diff or commit snapshots; for that, run report diff by 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), and NOTIFY_TO; optional NOTIFY_CC.

Local cron fallback

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>&1

Only the diff run git commit/pushes snapshots; the status digest leaves them untouched. No GitHub Actions needed in this mode.

Status

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.

Disclaimer

Unofficial, for personal use. Not affiliated with or endorsed by China Southern Airlines. Respect the site's terms of service and rate limits.

About

CLI to search China Southern Airlines (CZ) flights and show seats available per booking class

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors