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.
- {avios_cli-0.4.1 → avios_cli-0.4.3}/CHANGELOG.md +26 -1
- {avios_cli-0.4.1 → avios_cli-0.4.3}/PKG-INFO +22 -18
- {avios_cli-0.4.1 → avios_cli-0.4.3}/README.md +19 -15
- {avios_cli-0.4.1 → avios_cli-0.4.3}/SECURITY.md +4 -2
- {avios_cli-0.4.1 → avios_cli-0.4.3}/pyproject.toml +8 -6
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/__init__.py +1 -1
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/auth.py +104 -30
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/cli.py +6 -1
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/rewards.py +160 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/session.py +13 -2
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_auth.py +64 -1
- avios_cli-0.4.3/tests/test_rewards.py +324 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_session.py +2 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/uv.lock +4 -8
- avios_cli-0.4.1/tests/test_rewards.py +0 -142
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.editorconfig +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/dependabot.yml +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/workflows/ci.yml +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.github/workflows/publish.yml +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.gitignore +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.pre-commit-config.yaml +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/.python-version +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/CONTRIBUTING.md +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/LICENSE +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/docs/dashboard.svg +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/docs/reward-flights.svg +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/scripts/screenshot.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/accounts.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/aggregate.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/client.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/config.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/endpoints.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/finnair.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/models.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/programmes.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/__init__.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/app.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/art.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/rewards.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/styles.tcss +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/src/avios/tui/widgets.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/conftest.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/fixtures/balance.json +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/fixtures/reward_search.rsc +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/fixtures/transactions.json +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_accounts.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_aggregate.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_cli.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_client.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_finnair.py +0 -0
- {avios_cli-0.4.1 → avios_cli-0.4.3}/tests/test_models.py +0 -0
- {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.
|
|
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.
|
|
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
|
|
89
|
+
on the dashboard:
|
|
90
90
|
|
|
91
91
|
```bash
|
|
92
|
-
uvx
|
|
93
|
-
uvx
|
|
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
|
|
97
|
-
Chrome,
|
|
98
|
-
|
|
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
|
|
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
|
|
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
|
|
132
|
-
|
|
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
|
|
54
|
+
on the dashboard:
|
|
55
55
|
|
|
56
56
|
```bash
|
|
57
|
-
uvx
|
|
58
|
-
uvx
|
|
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
|
|
62
|
-
Chrome,
|
|
63
|
-
|
|
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
|
|
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
|
|
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
|
|
97
|
-
|
|
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
|
|
15
|
-
|
|
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
|
-
|
|
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]
|
|
@@ -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
|
-
|
|
9
|
-
|
|
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.
|
|
13
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
126
|
-
"""
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
|
149
|
-
"
|
|
150
|
-
"
|
|
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(
|
|
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
|
|
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
|
|
215
|
-
"
|
|
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
|
|
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
|