avios-cli 0.4.1__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. {avios_cli-0.4.1 → avios_cli-0.4.3}/CHANGELOG.md +26 -1
  2. {avios_cli-0.4.1 → avios_cli-0.4.3}/PKG-INFO +22 -18
  3. {avios_cli-0.4.1 → avios_cli-0.4.3}/README.md +19 -15
  4. {avios_cli-0.4.1 → avios_cli-0.4.3}/SECURITY.md +4 -2
  5. {avios_cli-0.4.1 → avios_cli-0.4.3}/pyproject.toml +8 -6
  6. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/__init__.py +1 -1
  7. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/auth.py +104 -30
  8. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/cli.py +6 -1
  9. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/rewards.py +160 -0
  10. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/session.py +13 -2
  11. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_auth.py +64 -1
  12. avios_cli-0.4.3/tests/test_rewards.py +324 -0
  13. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_session.py +2 -0
  14. {avios_cli-0.4.1 → avios_cli-0.4.3}/uv.lock +4 -8
  15. avios_cli-0.4.1/tests/test_rewards.py +0 -142
  16. {avios_cli-0.4.1 → avios_cli-0.4.3}/.editorconfig +0 -0
  17. {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
  18. {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
  19. {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  20. {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/dependabot.yml +0 -0
  21. {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/workflows/ci.yml +0 -0
  22. {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/workflows/publish.yml +0 -0
  23. {avios_cli-0.4.1 → avios_cli-0.4.3}/.gitignore +0 -0
  24. {avios_cli-0.4.1 → avios_cli-0.4.3}/.pre-commit-config.yaml +0 -0
  25. {avios_cli-0.4.1 → avios_cli-0.4.3}/.python-version +0 -0
  26. {avios_cli-0.4.1 → avios_cli-0.4.3}/CONTRIBUTING.md +0 -0
  27. {avios_cli-0.4.1 → avios_cli-0.4.3}/LICENSE +0 -0
  28. {avios_cli-0.4.1 → avios_cli-0.4.3}/docs/dashboard.svg +0 -0
  29. {avios_cli-0.4.1 → avios_cli-0.4.3}/docs/reward-flights.svg +0 -0
  30. {avios_cli-0.4.1 → avios_cli-0.4.3}/scripts/screenshot.py +0 -0
  31. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/accounts.py +0 -0
  32. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/aggregate.py +0 -0
  33. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/client.py +0 -0
  34. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/config.py +0 -0
  35. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/endpoints.py +0 -0
  36. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/finnair.py +0 -0
  37. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/models.py +0 -0
  38. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/programmes.py +0 -0
  39. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/__init__.py +0 -0
  40. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/app.py +0 -0
  41. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/art.py +0 -0
  42. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/rewards.py +0 -0
  43. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/styles.tcss +0 -0
  44. {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/widgets.py +0 -0
  45. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/conftest.py +0 -0
  46. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/fixtures/balance.json +0 -0
  47. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/fixtures/reward_search.rsc +0 -0
  48. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/fixtures/transactions.json +0 -0
  49. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_accounts.py +0 -0
  50. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_aggregate.py +0 -0
  51. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_cli.py +0 -0
  52. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_client.py +0 -0
  53. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_finnair.py +0 -0
  54. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_models.py +0 -0
  55. {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_tui.py +0 -0
@@ -6,6 +6,29 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.3] - 2026-07-27
10
+
11
+ ### Changed
12
+ - **`uvx avios-cli login` now works with no extra flags.** Browser-assisted login
13
+ (Playwright + browser-cookie3) moved from the optional `[login]` extra into the
14
+ base package, so `uvx avios-cli login iberia` just works — no more
15
+ `uvx --from 'avios-cli[login]' …`. The `login` extra is kept as a no-op alias
16
+ for backward compatibility.
17
+ - If you have no system Chrome, `avios login` now **downloads Playwright's Chromium
18
+ automatically** on first use (~150 MB, one-time) instead of failing with an
19
+ install hint.
20
+
21
+ ## [0.4.2] - 2026-07-27
22
+
23
+ ### Fixed
24
+ - **BA reward-flight searches failed with 403 after a successful login.** The
25
+ flight finder has a separate Auth0 session and rejects plain HTTP or automated
26
+ headless requests at Akamai. BA login now waits for the second reward-flight
27
+ prompt, and searches drive the site's own form in a background Chrome window
28
+ before parsing the returned RSC availability.
29
+ - Stored Akamai cookies are no longer replayed over Chrome's fresher browser jar,
30
+ and a transient Next.js shell response is retried once.
31
+
9
32
  ## [0.4.1] - 2026-07-27
10
33
 
11
34
  ### Fixed
@@ -187,7 +210,9 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
187
210
  - Reward-flight **availability** search is not yet implemented (needs a British
188
211
  Airways capture).
189
212
 
190
- [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.4.1...HEAD
213
+ [Unreleased]: https://github.com/alexechoi/avios-cli/compare/v0.4.3...HEAD
214
+ [0.4.3]: https://github.com/alexechoi/avios-cli/compare/v0.4.2...v0.4.3
215
+ [0.4.2]: https://github.com/alexechoi/avios-cli/compare/v0.4.1...v0.4.2
191
216
  [0.4.1]: https://github.com/alexechoi/avios-cli/compare/v0.4.0...v0.4.1
192
217
  [0.4.0]: https://github.com/alexechoi/avios-cli/compare/v0.3.0...v0.4.0
193
218
  [0.3.0]: https://github.com/alexechoi/avios-cli/compare/v0.2.1...v0.3.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: avios-cli
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: A CLI and TUI for Avios balances, transactions, and BA reward-flight availability.
5
5
  Project-URL: Homepage, https://github.com/alexechoi/avios-cli
6
6
  Project-URL: Repository, https://github.com/alexechoi/avios-cli
@@ -21,7 +21,9 @@ Classifier: Programming Language :: Python :: 3.12
21
21
  Classifier: Programming Language :: Python :: 3.13
22
22
  Classifier: Topic :: Utilities
23
23
  Requires-Python: >=3.10
24
+ Requires-Dist: browser-cookie3>=0.19
24
25
  Requires-Dist: httpx>=0.27
26
+ Requires-Dist: playwright>=1.44
25
27
  Requires-Dist: pydantic-settings>=2.3
26
28
  Requires-Dist: pydantic>=2.7
27
29
  Requires-Dist: pyyaml>=6.0
@@ -29,8 +31,6 @@ Requires-Dist: rich>=13.7
29
31
  Requires-Dist: textual>=0.80
30
32
  Requires-Dist: typer>=0.12
31
33
  Provides-Extra: login
32
- Requires-Dist: browser-cookie3>=0.19; extra == 'login'
33
- Requires-Dist: playwright>=1.44; extra == 'login'
34
34
  Description-Content-Type: text/markdown
35
35
 
36
36
  # avios-cli
@@ -86,22 +86,26 @@ uv run avios --help
86
86
  avios.com has no credential API — login is Auth0 Universal Login behind hCaptcha and
87
87
  SMS/passkey MFA — so `avios login` opens a real browser, **waits while you finish
88
88
  logging in** (password, captcha, SMS code), and captures the session once you land
89
- on the dashboard. It needs the **`login` extra**:
89
+ on the dashboard:
90
90
 
91
91
  ```bash
92
- uvx --from 'avios-cli[login]' avios login
93
- uvx --from 'avios-cli[login]' avios login finnair
92
+ uvx avios-cli login
93
+ uvx avios-cli login iberia
94
+ uvx avios-cli login finnair
94
95
  ```
95
96
 
96
- It launches your installed **Google Chrome** (no extra download). If you don't have
97
- Chrome, install Playwright's browser once with
98
- `uvx --from playwright playwright install chromium`.
97
+ It launches your installed **Google Chrome** (no download). If you don't have
98
+ Chrome, it fetches Playwright's Chromium automatically on first login (~150 MB,
99
+ one-time). No extra flags — browser support ships in the package.
100
+
101
+ > Tip: if you'll use it often, `uv tool install avios-cli` once, then just run
102
+ > `avios login`, `avios balance`, etc. directly.
99
103
 
100
104
  Prefer not to open a browser? Import the cookie from a Chrome you're already logged
101
105
  into avios.com with:
102
106
 
103
107
  ```bash
104
- uvx --from 'avios-cli[login]' avios login --from-browser
108
+ uvx avios-cli login --from-browser
105
109
  ```
106
110
 
107
111
  This scans **all your Chrome profiles** and uses whichever one is actually logged
@@ -109,16 +113,12 @@ into avios.com (it tells you which). If you have several profiles, you can targe
109
113
  one directly:
110
114
 
111
115
  ```bash
112
- uvx --from 'avios-cli[login]' avios login --from-browser --profile "Profile 1"
116
+ uvx avios-cli login --from-browser --profile "Profile 1"
113
117
  ```
114
118
 
115
- Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
116
- the `login` extra.
117
-
118
119
  **Stuck in an endless captcha loop?** That's bot detection on the automated
119
120
  browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
120
- (you'll get one normal captcha), then run
121
- `uvx --from 'avios-cli[login]' avios login --from-browser`.
121
+ (you'll get one normal captcha), then run `uvx avios-cli login --from-browser`.
122
122
 
123
123
  Finnair uses a different CAS/OAuth flow. `avios login finnair` opens the Finnair Plus
124
124
  balance page, lets you complete password and MFA in the real browser, and captures the
@@ -128,8 +128,12 @@ for Finnair because browser-cookie import cannot read that token.
128
128
  Each programme session is stored at `~/.config/avios/accounts/<programme>.json`
129
129
  (mode `600`). Sessions expire; just log in to that programme again. Use
130
130
  `avios logout <programme>` for one account or `avios logout` for all accounts.
131
- For BA, browser-assisted login also opens the reward-flight finder briefly so its
132
- separate `spend-avios` session is captured.
131
+ For BA, browser-assisted login also opens the separate reward-flight application.
132
+ British Airways may show a second login prompt; keep the browser open until the
133
+ CLI confirms success. Reward searches drive the site's own form in a background
134
+ Chrome window because the flight-search route rejects plain HTTP and automated
135
+ headless requests, so the `login` extra remains required when running
136
+ `avios flights` or the Reward Flights TUI tab.
133
137
 
134
138
  ## Usage
135
139
 
@@ -51,22 +51,26 @@ uv run avios --help
51
51
  avios.com has no credential API — login is Auth0 Universal Login behind hCaptcha and
52
52
  SMS/passkey MFA — so `avios login` opens a real browser, **waits while you finish
53
53
  logging in** (password, captcha, SMS code), and captures the session once you land
54
- on the dashboard. It needs the **`login` extra**:
54
+ on the dashboard:
55
55
 
56
56
  ```bash
57
- uvx --from 'avios-cli[login]' avios login
58
- uvx --from 'avios-cli[login]' avios login finnair
57
+ uvx avios-cli login
58
+ uvx avios-cli login iberia
59
+ uvx avios-cli login finnair
59
60
  ```
60
61
 
61
- It launches your installed **Google Chrome** (no extra download). If you don't have
62
- Chrome, install Playwright's browser once with
63
- `uvx --from playwright playwright install chromium`.
62
+ It launches your installed **Google Chrome** (no download). If you don't have
63
+ Chrome, it fetches Playwright's Chromium automatically on first login (~150 MB,
64
+ one-time). No extra flags — browser support ships in the package.
65
+
66
+ > Tip: if you'll use it often, `uv tool install avios-cli` once, then just run
67
+ > `avios login`, `avios balance`, etc. directly.
64
68
 
65
69
  Prefer not to open a browser? Import the cookie from a Chrome you're already logged
66
70
  into avios.com with:
67
71
 
68
72
  ```bash
69
- uvx --from 'avios-cli[login]' avios login --from-browser
73
+ uvx avios-cli login --from-browser
70
74
  ```
71
75
 
72
76
  This scans **all your Chrome profiles** and uses whichever one is actually logged
@@ -74,16 +78,12 @@ into avios.com (it tells you which). If you have several profiles, you can targe
74
78
  one directly:
75
79
 
76
80
  ```bash
77
- uvx --from 'avios-cli[login]' avios login --from-browser --profile "Profile 1"
81
+ uvx avios-cli login --from-browser --profile "Profile 1"
78
82
  ```
79
83
 
80
- Plain `uvx avios-cli login` won't work — the isolated environment doesn't include
81
- the `login` extra.
82
-
83
84
  **Stuck in an endless captcha loop?** That's bot detection on the automated
84
85
  browser. Use `--from-browser` instead: log into avios.com in your normal Chrome
85
- (you'll get one normal captcha), then run
86
- `uvx --from 'avios-cli[login]' avios login --from-browser`.
86
+ (you'll get one normal captcha), then run `uvx avios-cli login --from-browser`.
87
87
 
88
88
  Finnair uses a different CAS/OAuth flow. `avios login finnair` opens the Finnair Plus
89
89
  balance page, lets you complete password and MFA in the real browser, and captures the
@@ -93,8 +93,12 @@ for Finnair because browser-cookie import cannot read that token.
93
93
  Each programme session is stored at `~/.config/avios/accounts/<programme>.json`
94
94
  (mode `600`). Sessions expire; just log in to that programme again. Use
95
95
  `avios logout <programme>` for one account or `avios logout` for all accounts.
96
- For BA, browser-assisted login also opens the reward-flight finder briefly so its
97
- separate `spend-avios` session is captured.
96
+ For BA, browser-assisted login also opens the separate reward-flight application.
97
+ British Airways may show a second login prompt; keep the browser open until the
98
+ CLI confirms success. Reward searches drive the site's own form in a background
99
+ Chrome window because the flight-search route rejects plain HTTP and automated
100
+ headless requests, so the `login` extra remains required when running
101
+ `avios flights` or the Reward Flights TUI tab.
98
102
 
99
103
  ## Usage
100
104
 
@@ -11,8 +11,10 @@
11
11
  - `avios logout` deletes stored sessions. They also expire on the server side.
12
12
  - `AVIOS_COOKIE` may be set to supply a cookie via the environment instead of the
13
13
  stored file — avoid committing it or storing it in shell history.
14
- - Reward-flight search sends the same saved BA cookie only to `www.avios.com`.
15
- The RSC response's short-lived action tokens are neither exposed nor persisted.
14
+ - Reward-flight search launches the isolated BA Chrome profile in the background;
15
+ Chrome sends the saved application cookie only to `www.avios.com` and refreshes
16
+ its own anti-bot cookies. The RSC response's short-lived action tokens are
17
+ neither exposed nor persisted.
16
18
 
17
19
  ## Unofficial software
18
20
 
@@ -33,16 +33,18 @@ dependencies = [
33
33
  "pydantic>=2.7",
34
34
  "pydantic-settings>=2.3",
35
35
  "pyyaml>=6.0",
36
- ]
37
-
38
- [project.optional-dependencies]
39
- # Browser-assisted login. Kept optional so the core stays lightweight and the
40
- # heavy Playwright browser download is only pulled when a user needs to log in.
41
- login = [
36
+ # Browser-assisted login ships in the base package so `uvx avios-cli login`
37
+ # works with no extra flags. The Chromium *binary* is still fetched on demand
38
+ # (only if you have no system Chrome) — see avios.auth._install_chromium.
42
39
  "playwright>=1.44",
43
40
  "browser-cookie3>=0.19",
44
41
  ]
45
42
 
43
+ [project.optional-dependencies]
44
+ # Retained for backward compatibility: the login dependencies now ship in the
45
+ # base package, so `avios-cli[login]` resolves to the same thing as `avios-cli`.
46
+ login = []
47
+
46
48
  # Dev tooling lives in a PEP 735 dependency-group (not an extra) so that `uv sync`
47
49
  # and `uv run` install it by default — an extra would be pruned by `uv run`.
48
50
  [dependency-groups]
@@ -3,4 +3,4 @@
3
3
  View your Avios balance, transactions and reward-flight availability from the terminal.
4
4
  """
5
5
 
6
- __version__ = "0.4.1"
6
+ __version__ = "0.4.3"
@@ -5,12 +5,12 @@ hCaptcha, Akamai Bot Manager and SMS/passkey MFA, so there is no way to POST a
5
5
  username/password over HTTP and obtain a session. Instead we let a real browser
6
6
  handle the login once and capture the resulting session cookie.
7
7
 
8
- Two strategies, both requiring the optional ``login`` extra
9
- (``pip install "avios-cli[login]"``):
8
+ Playwright and browser_cookie3 ship in the base package, so these work with a
9
+ plain ``avios`` install (no extra flags). Two strategies:
10
10
 
11
11
  - :func:`login_via_browser` — open a Playwright browser, the user logs in
12
- (password + captcha + MFA), and we grab the cookies. Also needs
13
- ``playwright install chromium``.
12
+ (password + captcha + MFA), and we grab the cookies. The Chromium binary is
13
+ fetched on demand only if you have no system Chrome (see :func:`_install_chromium`).
14
14
  - :func:`import_from_browser` — read the avios.com cookie straight out of a
15
15
  running browser via ``browser_cookie3`` (no popup) if already logged in there.
16
16
  """
@@ -33,6 +33,7 @@ from avios.session import cookie_header_from
33
33
  LOGIN_TIMEOUT_MS = 300_000 # 5 minutes to complete password + captcha + MFA
34
34
  LOGIN_POLL_MS = 2_000 # how often to check whether the session is authenticated
35
35
  SUPPORTED_BROWSERS = ("chrome", "firefox", "edge", "brave", "safari", "chromium")
36
+ BA_REWARD_SEARCH_PATH = "/en-GB/spend-avios/search-reward-flights"
36
37
 
37
38
  # Chromium-family user-data roots per browser (relative to home), used to discover
38
39
  # per-profile cookie databases (Default, "Profile 1", ...).
@@ -108,36 +109,67 @@ def login_via_browser(
108
109
  profile_dir = str(settings.config_dir / "chrome-profile" / programme.slug)
109
110
 
110
111
  with factory() as pw:
111
- ctx = _open_login_context(pw, headless=headless, user_data_dir=profile_dir)
112
+ ctx = _open_login_context(
113
+ pw,
114
+ headless=headless,
115
+ user_data_dir=profile_dir,
116
+ user_agent=settings.user_agent,
117
+ )
112
118
  page = ctx.new_page()
113
119
  page.goto(programme.login_url)
114
120
  authed = _wait_for_auth(ctx, page, programme.base_url, timeout_ms)
121
+ reward_authed = True
115
122
  if authed and programme.slug == "ba":
116
- _warm_ba_reward_search(page, programme.base_url)
123
+ reward_authed = _wait_for_ba_reward_auth(
124
+ ctx,
125
+ page,
126
+ programme.base_url,
127
+ timeout_ms,
128
+ )
117
129
  cookies = list(ctx.cookies())
118
130
  ctx.close()
119
131
 
120
132
  if not authed:
121
133
  raise LoginError("Timed out waiting for login. Run `avios login` again.")
134
+ if not reward_authed:
135
+ raise LoginError(
136
+ "British Airways account login succeeded, but reward-flight login did not. "
137
+ "Run `avios login ba` again, complete the second BA prompt, and keep the "
138
+ "browser open until this command confirms success."
139
+ )
122
140
  return _only_avios(cookies)
123
141
 
124
142
 
125
- def _warm_ba_reward_search(page: Any, base_url: str) -> None:
126
- """Open BA's separate flight-search app so its session cookies are captured.
143
+ def _has_ba_reward_session(ctx: Any) -> bool:
144
+ """Whether BA's separate reward-search application session is present."""
145
+ names = {
146
+ str(cookie.get("name", ""))
147
+ for cookie in ctx.cookies()
148
+ if "avios.com" in str(cookie.get("domain", ""))
149
+ }
150
+ return "__session" in names or any(name.startswith("appSession") for name in names)
127
151
 
128
- Balance/profile authentication alone does not necessarily initialise the
129
- ``spend-avios`` Next.js app. This best-effort navigation runs while the real
130
- login browser is already open; failure does not invalidate account login.
131
- """
152
+
153
+ def _wait_for_ba_reward_auth(ctx: Any, page: Any, base_url: str, timeout_ms: int) -> bool:
154
+ """Open BA's reward app and wait for its separate Auth0 flow to complete."""
132
155
  try:
133
156
  page.goto(
134
- f"{base_url}/en-GB/spend-avios/search-reward-flights",
157
+ f"{base_url}{BA_REWARD_SEARCH_PATH}",
135
158
  wait_until="domcontentloaded",
136
159
  timeout=60_000,
137
160
  )
138
- page.wait_for_timeout(3_000)
139
161
  except Exception:
140
- pass
162
+ return False
163
+
164
+ for _ in range(max(1, timeout_ms // LOGIN_POLL_MS)):
165
+ if _has_ba_reward_session(ctx) and page.url.startswith(
166
+ f"{base_url}{BA_REWARD_SEARCH_PATH}"
167
+ ):
168
+ with contextlib.suppress(Exception):
169
+ if "search reward flights" in page.title().lower():
170
+ return True
171
+ page.wait_for_timeout(LOGIN_POLL_MS)
172
+ return False
141
173
 
142
174
 
143
175
  def _import_sync_playwright() -> Any:
@@ -145,11 +177,9 @@ def _import_sync_playwright() -> Any:
145
177
  from playwright.sync_api import sync_playwright
146
178
  except ImportError as exc:
147
179
  raise LoginError(
148
- "Playwright isn't installed. Log in using the 'login' extra:\n"
149
- " uvx --from 'avios-cli[login]' avios login\n"
150
- " (first time only: uvx --from playwright playwright install chromium)\n"
151
- "Or import the cookie from Chrome instead (no browser download):\n"
152
- " uvx --from 'avios-cli[login]' avios login --from-browser"
180
+ "Playwright isn't available in this environment. Reinstall avios-cli "
181
+ "(e.g. `uv tool install avios-cli`), or import the cookie from your "
182
+ "browser instead:\n avios login --from-browser"
153
183
  ) from exc
154
184
  return sync_playwright
155
185
 
@@ -177,7 +207,14 @@ def _is_authenticated(ctx: Any, base_url: str) -> bool:
177
207
  return False
178
208
 
179
209
 
180
- def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
210
+ def _open_login_context(
211
+ pw: Any,
212
+ *,
213
+ headless: bool,
214
+ user_data_dir: str,
215
+ user_agent: str | None = None,
216
+ background: bool = False,
217
+ ) -> Any:
181
218
  """Open a browser context for login, tuned to avoid the hCaptcha bot loop.
182
219
 
183
220
  hCaptcha/Akamai serve endless challenges to obviously-automated browsers, so:
@@ -189,30 +226,67 @@ def _open_login_context(pw: Any, *, headless: bool, user_data_dir: str) -> Any:
189
226
  """
190
227
  args = ["--disable-blink-features=AutomationControlled"]
191
228
  ignore_default_args = ["--enable-automation"]
229
+ launch_options: dict[str, Any] = {}
230
+ if background:
231
+ args.append("--window-position=-32000,-32000")
232
+ launch_options["viewport"] = {"width": 1280, "height": 900}
233
+ if headless and user_agent:
234
+ # Chrome's native headless networking can reach BA's login page, but its
235
+ # default ``HeadlessChrome`` UA is rejected. Keep the normal Chrome UA.
236
+ launch_options["user_agent"] = user_agent
237
+
238
+ def launch(**extra: Any) -> Any:
239
+ return pw.chromium.launch_persistent_context(
240
+ user_data_dir,
241
+ headless=headless,
242
+ args=args,
243
+ ignore_default_args=ignore_default_args,
244
+ **launch_options,
245
+ **extra,
246
+ )
247
+
248
+ # Prefer the user's real Chrome, then bundled Chromium.
192
249
  for extra in ({"channel": "chrome"}, {}):
193
250
  try:
194
- return pw.chromium.launch_persistent_context(
195
- user_data_dir,
196
- headless=headless,
197
- args=args,
198
- ignore_default_args=ignore_default_args,
199
- **extra,
200
- )
251
+ return launch(**extra)
201
252
  except Exception:
202
253
  continue
254
+ # Neither is available (no system Chrome and no downloaded Chromium). Fetch
255
+ # the Chromium binary once, then retry the bundled browser.
256
+ if _install_chromium():
257
+ try:
258
+ return launch()
259
+ except Exception:
260
+ pass
203
261
  raise LoginError(
204
262
  "Couldn't launch a browser. Install Chromium once with:\n"
205
263
  " uvx --from playwright playwright install chromium"
206
264
  )
207
265
 
208
266
 
267
+ def _install_chromium() -> bool:
268
+ """Download Playwright's Chromium binary on demand. Returns True if it ran."""
269
+ import subprocess
270
+ import sys
271
+
272
+ print("No browser found — downloading Chromium (~150 MB, one-time)…", file=sys.stderr)
273
+ try:
274
+ subprocess.run(
275
+ [sys.executable, "-m", "playwright", "install", "chromium"],
276
+ check=True,
277
+ )
278
+ return True
279
+ except Exception:
280
+ return False
281
+
282
+
209
283
  def _bc3() -> Any:
210
284
  try:
211
285
  import browser_cookie3 as bc3
212
286
  except ImportError as exc:
213
287
  raise LoginError(
214
- "browser-cookie3 isn't installed. Run with the 'login' extra:\n"
215
- " uvx --from 'avios-cli[login]' avios login --from-browser"
288
+ "browser-cookie3 isn't available in this environment. "
289
+ "Reinstall avios-cli (e.g. `uv tool install avios-cli`)."
216
290
  ) from exc
217
291
  return bc3
218
292
 
@@ -179,9 +179,14 @@ def login(
179
179
  account = Account.from_programme(prog, cookies=result.cookies)
180
180
  saved_label = f"{result.count} cookie(s){where}"
181
181
  else:
182
+ ba_note = (
183
+ " British Airways may show a second login prompt for reward flights."
184
+ if prog.slug == "ba"
185
+ else ""
186
+ )
182
187
  console.print(
183
188
  f"Opening a browser — log in to [bold]{prog.name}[/] (password, captcha, "
184
- "SMS code). Keep the window open; it captures your session automatically."
189
+ f"SMS code).{ba_note} Keep the window open until this command confirms success."
185
190
  )
186
191
  cookies = login_via_browser(prog, headless=headless)
187
192
  account = Account.from_programme(prog, cookies=cookies)
@@ -89,6 +89,8 @@ class PassengerCounts(RewardModel):
89
89
  def infants_need_adults(self) -> PassengerCounts:
90
90
  if self.infants > self.adults:
91
91
  raise ValueError("infants cannot exceed adults")
92
+ if self.adults + self.young_adults + self.children + self.infants > 9:
93
+ raise ValueError("British Airways allows at most 9 travellers")
92
94
  return self
93
95
 
94
96
  def query_items(self) -> list[tuple[str, str]]:
@@ -354,6 +356,17 @@ def search_reward_calendar(session: Session, query: RewardSearchQuery) -> Reward
354
356
  "next-url": NEXT_URL,
355
357
  "referer": referer,
356
358
  }
359
+
360
+ if not session.has_custom_transport:
361
+ payload = _fetch_reward_rsc_via_browser(session, query)
362
+ try:
363
+ return parse_reward_rsc(payload, query)
364
+ except RewardSearchProtocolError:
365
+ # The Next.js router occasionally returns only its small shell during
366
+ # rapid consecutive searches. One fresh browser navigation recovers.
367
+ payload = _fetch_reward_rsc_via_browser(session, query)
368
+ return parse_reward_rsc(payload, query)
369
+
357
370
  try:
358
371
  payload = session.get_text(
359
372
  endpoints.REWARD_FLIGHT_RESULTS,
@@ -368,3 +381,150 @@ def search_reward_calendar(session: Session, query: RewardSearchQuery) -> Reward
368
381
  ) from exc
369
382
  raise
370
383
  return parse_reward_rsc(payload, query)
384
+
385
+
386
+ def _playwright_cookies(session: Session) -> list[dict[str, Any]]:
387
+ """Return only app-session cookies safe to seed into the Chrome profile.
388
+
389
+ Akamai cookies are bound to browser state and are refreshed by Chrome. Replaying
390
+ stored ``_abck``/``bm_*`` values over the profile's current jar triggers a 403.
391
+ """
392
+ allowed = {
393
+ "name",
394
+ "value",
395
+ "domain",
396
+ "path",
397
+ "expires",
398
+ "httpOnly",
399
+ "secure",
400
+ "sameSite",
401
+ "partitionKey",
402
+ }
403
+ normalised: list[dict[str, Any]] = []
404
+ for stored in session.browser_cookies():
405
+ name = str(stored.get("name", ""))
406
+ if name != "__session" and not name.startswith("appSession"):
407
+ continue
408
+ cookie = {key: value for key, value in stored.items() if key in allowed}
409
+ if not cookie.get("name") or not cookie.get("value") or not cookie.get("domain"):
410
+ continue
411
+ cookie.setdefault("path", "/")
412
+ normalised.append(cookie)
413
+ return normalised
414
+
415
+
416
+ def _fetch_reward_rsc_via_browser(
417
+ session: Session,
418
+ query: RewardSearchQuery,
419
+ *,
420
+ playwright_factory: Any = None,
421
+ search_driver: Any = None,
422
+ ) -> str:
423
+ """Drive BA's real search form and capture its RSC response through Chrome."""
424
+ from avios.auth import LoginError, _import_sync_playwright, _open_login_context
425
+
426
+ factory = playwright_factory or _import_sync_playwright()
427
+ driver = search_driver or _drive_reward_search
428
+ profile_dir = str(session.settings.config_dir / "chrome-profile" / "ba")
429
+
430
+ try:
431
+ with factory() as pw:
432
+ ctx = _open_login_context(
433
+ pw,
434
+ headless=False,
435
+ user_data_dir=profile_dir,
436
+ user_agent=session.settings.user_agent,
437
+ background=True,
438
+ )
439
+ try:
440
+ cookies = _playwright_cookies(session)
441
+ profile_cookie_names = {str(cookie.get("name", "")) for cookie in ctx.cookies()}
442
+ profile_has_reward_session = "__session" in profile_cookie_names or any(
443
+ name.startswith("appSession") for name in profile_cookie_names
444
+ )
445
+ if cookies and not profile_has_reward_session:
446
+ ctx.add_cookies(cookies)
447
+ page = ctx.new_page()
448
+ body = driver(page, session.base_url, query)
449
+ finally:
450
+ ctx.close()
451
+ except LoginError as exc:
452
+ raise RewardSearchAccessError(str(exc)) from exc
453
+ except RewardSearchAccessError:
454
+ raise
455
+ except Exception as exc:
456
+ raise RewardSearchAccessError(
457
+ "Chrome could not fetch British Airways reward flights. "
458
+ "Run `avios login ba` again, then retry."
459
+ ) from exc
460
+
461
+ if not isinstance(body, str):
462
+ raise RewardSearchProtocolError("Chrome returned a non-text reward-flight response")
463
+ return body
464
+
465
+
466
+ def _select_airport(page: Any, field: str, code: str) -> None:
467
+ airport = page.locator(f'input[name="{field}"]')
468
+ if airport.input_value().rstrip().endswith(f"({code})"):
469
+ return
470
+ airport.fill(code)
471
+ page.get_by_test_id(f"{field}-{code}-airport-option").click(timeout=20_000)
472
+
473
+
474
+ def _set_passengers(page: Any, passengers: PassengerCounts) -> None:
475
+ increments = {
476
+ "Adults": passengers.adults - 1,
477
+ "Young Adults": passengers.young_adults,
478
+ "Children": passengers.children,
479
+ "Infants": passengers.infants,
480
+ }
481
+ if not any(increments.values()):
482
+ return
483
+ selector = page.get_by_test_id("passenger number select")
484
+ selector.click()
485
+ for label, count in increments.items():
486
+ for _ in range(count):
487
+ page.get_by_test_id(f"{label}-plus").click()
488
+ selector.click()
489
+
490
+
491
+ def _drive_reward_search(page: Any, base_url: str, query: RewardSearchQuery) -> str:
492
+ """Use the site's form so Next.js and Akamai generate valid request state."""
493
+ from avios.auth import BA_REWARD_SEARCH_PATH
494
+
495
+ landing = page.goto(
496
+ f"{base_url}{BA_REWARD_SEARCH_PATH}",
497
+ wait_until="domcontentloaded",
498
+ timeout=60_000,
499
+ )
500
+ if landing is not None and landing.status == 403:
501
+ raise RewardSearchAccessError(
502
+ "British Airways rejected the Chrome reward-search session. Run `avios login ba` again."
503
+ )
504
+ if "accounts.britishairways.com" in page.url:
505
+ raise RewardSearchAccessError(
506
+ "British Airways reward-flight login is incomplete. Run `avios login ba` "
507
+ "again and complete the second BA prompt."
508
+ )
509
+ page.wait_for_timeout(1_500)
510
+ page.get_by_role("tab", name="One Way").click()
511
+ _select_airport(page, "origin", query.origin)
512
+ _select_airport(page, "destination", query.destination)
513
+ _set_passengers(page, query.passengers)
514
+
515
+ with page.expect_response(
516
+ lambda response: (
517
+ endpoints.REWARD_FLIGHT_RESULTS in response.url and response.request.method == "GET"
518
+ ),
519
+ timeout=60_000,
520
+ ) as response_info:
521
+ page.get_by_role("button", name="Search", exact=True).click()
522
+ response = response_info.value
523
+ body = str(response.text())
524
+ if response.status in (401, 403):
525
+ raise RewardSearchAccessError(
526
+ "British Airways rejected the Chrome reward-search session. Run `avios login ba` again."
527
+ )
528
+ if response.status >= 400:
529
+ raise RewardSearchError(f"British Airways reward search returned HTTP {response.status}")
530
+ return body