amazon-orders 4.2.2__tar.gz → 4.3.0__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 (42) hide show
  1. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/CHANGELOG.md +17 -1
  2. {amazon_orders-4.2.2/amazon_orders.egg-info → amazon_orders-4.3.0}/PKG-INFO +15 -3
  3. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/README.md +12 -2
  4. {amazon_orders-4.2.2 → amazon_orders-4.3.0/amazon_orders.egg-info}/PKG-INFO +15 -3
  5. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazon_orders.egg-info/SOURCES.txt +2 -0
  6. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazon_orders.egg-info/requires.txt +3 -0
  7. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/__init__.py +1 -1
  8. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/constants.py +56 -23
  9. amazon_orders-4.3.0/amazonorders/contrib/browser/playwright.py +412 -0
  10. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/contrib/waf/anticaptcha.py +1 -1
  11. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/contrib/waf/capsolver.py +1 -1
  12. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/contrib/waf/twocaptcha.py +1 -1
  13. amazon_orders-4.3.0/amazonorders/entity/__init__.py +0 -0
  14. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/entity/transaction.py +11 -0
  15. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/forms.py +6 -4
  16. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/session.py +8 -2
  17. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/pyproject.toml +3 -0
  18. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/LICENSE +0 -0
  19. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/MANIFEST.in +0 -0
  20. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazon_orders.egg-info/dependency_links.txt +0 -0
  21. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazon_orders.egg-info/entry_points.txt +0 -0
  22. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazon_orders.egg-info/top_level.txt +0 -0
  23. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/banner.txt +0 -0
  24. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/cli.py +0 -0
  25. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/conf.py +0 -0
  26. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/contrib/__init__.py +0 -0
  27. {amazon_orders-4.2.2/amazonorders/contrib/waf → amazon_orders-4.3.0/amazonorders/contrib/browser}/__init__.py +0 -0
  28. {amazon_orders-4.2.2/amazonorders/entity → amazon_orders-4.3.0/amazonorders/contrib/waf}/__init__.py +0 -0
  29. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/contrib/waf/base.py +0 -0
  30. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/entity/item.py +0 -0
  31. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/entity/order.py +0 -0
  32. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/entity/parsable.py +0 -0
  33. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/entity/recipient.py +0 -0
  34. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/entity/seller.py +0 -0
  35. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/entity/shipment.py +0 -0
  36. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/exception.py +0 -0
  37. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/orders.py +0 -0
  38. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/selectors.py +0 -0
  39. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/transactions.py +0 -0
  40. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/amazonorders/util.py +0 -0
  41. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/setup.cfg +0 -0
  42. {amazon_orders-4.2.2 → amazon_orders-4.3.0}/tests/testcase.py +0 -0
@@ -4,7 +4,23 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
- ## [Unreleased](https://github.com/alexdlaird/amazon-orders/compare/4.2.2...HEAD)
7
+ ## [Unreleased](https://github.com/alexdlaird/amazon-orders/compare/4.3.0...HEAD)
8
+
9
+ ## [4.3.0](https://github.com/alexdlaird/amazon-orders/compare/4.2.2...4.3.0) - 2026-06-07
10
+
11
+ ### Added
12
+
13
+ - `[browser]` optional extra (`pip install amazon-orders[browser]`) for handling JavaScript-based authentication challenges via a headless browser. See [the docs](https://amazon-orders.readthedocs.io/browser.html) for setup.
14
+ - `PlaywrightAcicForm` handles the ACIC challenge page. If an embedded AWS WAF CAPTCHA is present, it delegates automatically to any configured WAF solver extra.
15
+ - `PlaywrightJSAuthForm` is a best-effort handler for the JS robot-detection page.
16
+ - `PlaywrightManualWafForm` opens a **visible** browser window for manual WAF CAPTCHA solving — a free alternative to the paid `[waf]` extras for local/interactive use.
17
+ - `browser` config key and `AMAZON_BROWSER` environment variable to select between `chromium` (default) and `firefox` browser fingerprints.
18
+ - `Transaction.payment_method_last_4`, the masked card digits parsed from `payment_method`, mirroring the existing field on `Order`.
19
+
20
+ ### Changed
21
+
22
+ - JavaScript-based authentication challenge errors now direct users to the `[browser]` extra rather than reporting the challenge as unsolvable.
23
+ - All user-facing error messages that include install commands now wrap those commands in backticks.
8
24
 
9
25
  ## [4.2.2](https://github.com/alexdlaird/amazon-orders/compare/4.2.1...4.2.2) - 2026-06-06
10
26
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-orders
3
- Version: 4.2.2
3
+ Version: 4.3.0
4
4
  Summary: A Python library (and CLI) for Amazon order history
5
5
  Author-email: Alex Laird <contact@alexlaird.com>
6
6
  Maintainer-email: Alex Laird <contact@alexlaird.com>
@@ -43,6 +43,8 @@ Provides-Extra: anticaptcha
43
43
  Requires-Dist: anticaptchaofficial; extra == "anticaptcha"
44
44
  Provides-Extra: 2captcha
45
45
  Requires-Dist: 2captcha-python; extra == "2captcha"
46
+ Provides-Extra: browser
47
+ Requires-Dist: playwright>=1.47.0; extra == "browser"
46
48
  Provides-Extra: lxml
47
49
  Requires-Dist: lxml; extra == "lxml"
48
50
  Provides-Extra: dev
@@ -101,7 +103,7 @@ pip install amazon-orders --upgrade
101
103
 
102
104
  That's it! `amazon-orders` is now available as a package to your Python projects and from the command line.
103
105
 
104
- If pinning, be sure to use a wildcard for the [minor version](https://semver.org/) (ex. `==4.2.*`, not `==4.2.2`) to
106
+ If pinning, be sure to use a wildcard for the [minor version](https://semver.org/) (ex. `==4.3.*`, not `==4.2.1`) to
105
107
  ensure you always get the latest stable release.
106
108
 
107
109
  ## Basic Usage
@@ -170,6 +172,16 @@ pip install amazon-orders[2captcha]
170
172
 
171
173
  See [Solving WAF Challenges](https://amazon-orders.readthedocs.io/waf.html) for details.
172
174
 
175
+ To enable **browser-based challenge handling** (ACIC and JavaScript bot-detection pages) via
176
+ a headless browser, install with the `browser` extra:
177
+
178
+ ```sh
179
+ pip install amazon-orders[browser]
180
+ playwright install chromium
181
+ ```
182
+
183
+ See [Browser Automation](https://amazon-orders.readthedocs.io/browser.html) for details.
184
+
173
185
  To enable **Captcha auto-solve** on Python <=3.12 (via the optional [`amazoncaptcha`](https://pypi.org/project/amazoncaptcha/)
174
186
  dependency), install with the `captcha` extra:
175
187
 
@@ -178,7 +190,7 @@ pip install amazon-orders[captcha]
178
190
  ```
179
191
 
180
192
  Without this extra, Captcha challenges fall back to manual entry. `amazoncaptcha` is not available on Python 3.13+; see
181
- [Captcha Blocking Login](https://amazon-orders.readthedocs.io/troubleshooting.html#captcha-blocking-login) for details.
193
+ [Login Challenges](https://amazon-orders.readthedocs.io/troubleshooting.html#login-challenges) for details.
182
194
 
183
195
  ## Documentation
184
196
 
@@ -28,7 +28,7 @@ pip install amazon-orders --upgrade
28
28
 
29
29
  That's it! `amazon-orders` is now available as a package to your Python projects and from the command line.
30
30
 
31
- If pinning, be sure to use a wildcard for the [minor version](https://semver.org/) (ex. `==4.2.*`, not `==4.2.2`) to
31
+ If pinning, be sure to use a wildcard for the [minor version](https://semver.org/) (ex. `==4.3.*`, not `==4.2.1`) to
32
32
  ensure you always get the latest stable release.
33
33
 
34
34
  ## Basic Usage
@@ -97,6 +97,16 @@ pip install amazon-orders[2captcha]
97
97
 
98
98
  See [Solving WAF Challenges](https://amazon-orders.readthedocs.io/waf.html) for details.
99
99
 
100
+ To enable **browser-based challenge handling** (ACIC and JavaScript bot-detection pages) via
101
+ a headless browser, install with the `browser` extra:
102
+
103
+ ```sh
104
+ pip install amazon-orders[browser]
105
+ playwright install chromium
106
+ ```
107
+
108
+ See [Browser Automation](https://amazon-orders.readthedocs.io/browser.html) for details.
109
+
100
110
  To enable **Captcha auto-solve** on Python <=3.12 (via the optional [`amazoncaptcha`](https://pypi.org/project/amazoncaptcha/)
101
111
  dependency), install with the `captcha` extra:
102
112
 
@@ -105,7 +115,7 @@ pip install amazon-orders[captcha]
105
115
  ```
106
116
 
107
117
  Without this extra, Captcha challenges fall back to manual entry. `amazoncaptcha` is not available on Python 3.13+; see
108
- [Captcha Blocking Login](https://amazon-orders.readthedocs.io/troubleshooting.html#captcha-blocking-login) for details.
118
+ [Login Challenges](https://amazon-orders.readthedocs.io/troubleshooting.html#login-challenges) for details.
109
119
 
110
120
  ## Documentation
111
121
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-orders
3
- Version: 4.2.2
3
+ Version: 4.3.0
4
4
  Summary: A Python library (and CLI) for Amazon order history
5
5
  Author-email: Alex Laird <contact@alexlaird.com>
6
6
  Maintainer-email: Alex Laird <contact@alexlaird.com>
@@ -43,6 +43,8 @@ Provides-Extra: anticaptcha
43
43
  Requires-Dist: anticaptchaofficial; extra == "anticaptcha"
44
44
  Provides-Extra: 2captcha
45
45
  Requires-Dist: 2captcha-python; extra == "2captcha"
46
+ Provides-Extra: browser
47
+ Requires-Dist: playwright>=1.47.0; extra == "browser"
46
48
  Provides-Extra: lxml
47
49
  Requires-Dist: lxml; extra == "lxml"
48
50
  Provides-Extra: dev
@@ -101,7 +103,7 @@ pip install amazon-orders --upgrade
101
103
 
102
104
  That's it! `amazon-orders` is now available as a package to your Python projects and from the command line.
103
105
 
104
- If pinning, be sure to use a wildcard for the [minor version](https://semver.org/) (ex. `==4.2.*`, not `==4.2.2`) to
106
+ If pinning, be sure to use a wildcard for the [minor version](https://semver.org/) (ex. `==4.3.*`, not `==4.2.1`) to
105
107
  ensure you always get the latest stable release.
106
108
 
107
109
  ## Basic Usage
@@ -170,6 +172,16 @@ pip install amazon-orders[2captcha]
170
172
 
171
173
  See [Solving WAF Challenges](https://amazon-orders.readthedocs.io/waf.html) for details.
172
174
 
175
+ To enable **browser-based challenge handling** (ACIC and JavaScript bot-detection pages) via
176
+ a headless browser, install with the `browser` extra:
177
+
178
+ ```sh
179
+ pip install amazon-orders[browser]
180
+ playwright install chromium
181
+ ```
182
+
183
+ See [Browser Automation](https://amazon-orders.readthedocs.io/browser.html) for details.
184
+
173
185
  To enable **Captcha auto-solve** on Python <=3.12 (via the optional [`amazoncaptcha`](https://pypi.org/project/amazoncaptcha/)
174
186
  dependency), install with the `captcha` extra:
175
187
 
@@ -178,7 +190,7 @@ pip install amazon-orders[captcha]
178
190
  ```
179
191
 
180
192
  Without this extra, Captcha challenges fall back to manual entry. `amazoncaptcha` is not available on Python 3.13+; see
181
- [Captcha Blocking Login](https://amazon-orders.readthedocs.io/troubleshooting.html#captcha-blocking-login) for details.
193
+ [Login Challenges](https://amazon-orders.readthedocs.io/troubleshooting.html#login-challenges) for details.
182
194
 
183
195
  ## Documentation
184
196
 
@@ -22,6 +22,8 @@ amazonorders/session.py
22
22
  amazonorders/transactions.py
23
23
  amazonorders/util.py
24
24
  amazonorders/contrib/__init__.py
25
+ amazonorders/contrib/browser/__init__.py
26
+ amazonorders/contrib/browser/playwright.py
25
27
  amazonorders/contrib/waf/__init__.py
26
28
  amazonorders/contrib/waf/anticaptcha.py
27
29
  amazonorders/contrib/waf/base.py
@@ -12,6 +12,9 @@ pyotp>=2.9
12
12
  [anticaptcha]
13
13
  anticaptchaofficial
14
14
 
15
+ [browser]
16
+ playwright>=1.47.0
17
+
15
18
  [capsolver]
16
19
  capsolver
17
20
 
@@ -1,3 +1,3 @@
1
1
  __copyright__ = "Copyright (c) 2024-2025 Alex Laird"
2
2
  __license__ = "MIT"
3
- __version__ = "4.2.2"
3
+ __version__ = "4.3.0"
@@ -1,13 +1,32 @@
1
1
  __copyright__ = "Copyright (c) 2024-2025 Alex Laird"
2
2
  __license__ = "MIT"
3
3
 
4
+ import logging
4
5
  import os
5
- from typing import Optional, TYPE_CHECKING
6
+ from typing import Dict, Optional, TYPE_CHECKING
6
7
  from urllib.parse import urlencode, urlparse
7
8
 
8
9
  if TYPE_CHECKING:
9
10
  from amazonorders.conf import AmazonOrdersConfig
10
11
 
12
+ logger = logging.getLogger(__name__)
13
+
14
+ #: Browser-specific header overrides applied on top of the class-level ``BASE_HEADERS``
15
+ #: (which already reflects the Chromium fingerprint). A ``None`` value removes the key
16
+ #: (used to strip headers absent in that engine). ``Accept-Language`` here is the browser
17
+ #: default; domain-specific TLD overrides still apply on top via :func:`~Constants._apply_domain`.
18
+ _BROWSER_PRESETS: Dict[str, Dict[str, Optional[str]]] = {
19
+ "chromium": {}, # BASE_HEADERS already reflects the Chromium fingerprint; no overrides needed.
20
+ "firefox": {
21
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
22
+ "Accept-Language": "en-US,en;q=0.5",
23
+ "Sec-Ch-Ua": None,
24
+ "Sec-Ch-Ua-Mobile": None,
25
+ "Sec-Ch-Ua-Platform": None,
26
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0",
27
+ },
28
+ }
29
+
11
30
  #: ``Accept-Language`` values for English-locale Amazon sites, keyed by the TLD suffix that
12
31
  #: follows ``amazon.``. Looked up dynamically from the user-supplied domain; unknown TLDs keep
13
32
  #: the base ``en-US`` value. This map only governs the ``Accept-Language`` header — it is not
@@ -105,35 +124,21 @@ class Constants:
105
124
  ##########################################################################
106
125
 
107
126
  BASE_HEADERS = {
108
- "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,"
109
- "application/signed-exchange;v=b3;q=0.7",
127
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7", # noqa: E501
110
128
  "Accept-Encoding": "gzip, deflate, br, zstd",
111
129
  "Accept-Language": "en-US,en;q=0.9",
112
- "Cache-Control": "max-age=0",
113
- "Device-Memory": "8",
114
- "Downlink": "10",
115
- "Dpr": "2",
116
- "Ect": "4g",
117
- "Origin": BASE_URL,
118
130
  "Host": urlparse(BASE_URL).netloc,
119
- "Priority": "u=0, i",
131
+ "Origin": BASE_URL,
120
132
  "Referer": f"{SIGN_IN_URL}?{urlencode(SIGN_IN_QUERY_PARAMS)}",
121
- "Rtt": "0",
122
- "Sec-Ch-Device-Memory": "8",
123
- "Sec-Ch-Dpr": "2",
124
- "Sec-Ch-Ua": "Chromium\";v=\"140\", \"Not=A?Brand\";v=\"24\", \"Google Chrome\";v=\"140",
133
+ "Sec-Ch-Ua": '"Chromium";v="149", "Google Chrome";v="149", "Not.A/Brand";v="24"',
125
134
  "Sec-Ch-Ua-Mobile": "?0",
126
- "Sec-Ch-Ua-Platform": "macOS",
127
- "Sec-Ch-Ua-Platform-Version": "15.6.1",
128
- "Sec-Ch-Viewport-Width": "1512",
135
+ "Sec-Ch-Ua-Platform": '"macOS"',
129
136
  "Sec-Fetch-Dest": "document",
130
137
  "Sec-Fetch-Mode": "navigate",
131
138
  "Sec-Fetch-Site": "none",
132
139
  "Sec-Fetch-User": "?1",
133
140
  "Upgrade-Insecure-Requests": "1",
134
- "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_6_1) AppleWebKit/537.36 (KHTML, like Gecko) "
135
- "Chrome/140.0.0.0 Safari/537.36",
136
- "Viewport-Width": "1512"
141
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36", # noqa: E501
137
142
  }
138
143
 
139
144
  ##########################################################################
@@ -152,13 +157,41 @@ class Constants:
152
157
  def __init__(self,
153
158
  config: Optional["AmazonOrdersConfig"] = None) -> None:
154
159
  domain = None
160
+ browser = None
155
161
  if config is not None:
156
162
  domain = config._data.get("domain")
163
+ browser = config._data.get("browser")
157
164
  if not domain:
158
165
  domain = os.environ.get("AMAZON_BASE_URL")
166
+ if not browser:
167
+ browser = os.environ.get("AMAZON_BROWSER")
168
+ self._apply_browser(browser or "chromium")
159
169
  if domain:
160
170
  self._apply_domain(domain)
161
171
 
172
+ def _apply_browser(self,
173
+ browser: str) -> None:
174
+ """
175
+ Apply browser-specific header overrides for the given browser engine.
176
+
177
+ :param browser: Browser engine name — ``"firefox"`` or ``"chromium"``. Unknown values
178
+ log a warning and leave ``BASE_HEADERS`` unchanged.
179
+ """
180
+ preset = _BROWSER_PRESETS.get(browser)
181
+ if preset is None:
182
+ logger.warning(
183
+ f"Unknown browser value {browser!r}; "
184
+ f"valid values are: {', '.join(_BROWSER_PRESETS)}. Using default headers."
185
+ )
186
+ return
187
+ headers = dict(type(self).BASE_HEADERS)
188
+ for key, value in preset.items():
189
+ if value is None:
190
+ headers.pop(key, None)
191
+ else:
192
+ headers[key] = value
193
+ self.BASE_HEADERS = headers
194
+
162
195
  def _apply_domain(self,
163
196
  domain: str) -> None:
164
197
  """
@@ -168,8 +201,8 @@ class Constants:
168
201
  """
169
202
  base_url = _normalize_base_url(domain)
170
203
 
171
- # Read non-URL fields from the class so subclass-level overrides (assoc_handle,
172
- # Accept-Language, etc.) are preserved; only rewrite the URL-shaped values.
204
+ # Build from the instance-level BASE_HEADERS if _apply_browser has already set it;
205
+ # otherwise fall back to the class-level definition.
173
206
  sign_in_query_params = dict(type(self).SIGN_IN_QUERY_PARAMS)
174
207
  sign_in_query_params["openid.return_to"] = f"{base_url}/?ref_=nav_custrec_signin"
175
208
 
@@ -189,7 +222,7 @@ class Constants:
189
222
  host = host[len("www."):]
190
223
  tld = host[len("amazon."):] if host.startswith("amazon.") else ""
191
224
 
192
- headers = dict(type(self).BASE_HEADERS)
225
+ headers = dict(vars(self).get("BASE_HEADERS", type(self).BASE_HEADERS))
193
226
  headers["Origin"] = base_url
194
227
  headers["Host"] = urlparse(base_url).netloc
195
228
  headers["Referer"] = f"{sign_in_url}?{urlencode(sign_in_query_params)}"
@@ -0,0 +1,412 @@
1
+ __copyright__ = "Copyright (c) 2024-2025 Alex Laird"
2
+ __license__ = "MIT"
3
+
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ from abc import abstractmethod
9
+ from typing import Any, Dict, Optional, TYPE_CHECKING
10
+ from urllib.parse import urlparse
11
+
12
+ from bs4 import Tag
13
+ from requests import Response
14
+
15
+ from amazonorders.conf import AmazonOrdersConfig
16
+ from amazonorders.contrib.waf.base import _GOKU_PROPS_RE
17
+ from amazonorders.exception import AmazonOrdersError
18
+ from amazonorders.forms import AuthForm
19
+ from amazonorders.util import AmazonSessionResponse
20
+
21
+ if TYPE_CHECKING:
22
+ from amazonorders.session import AmazonSession
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class PlaywrightAuthForm(AuthForm):
28
+ """
29
+ Shared base for Playwright-based JavaScript challenge solvers. Subclasses implement
30
+ :func:`select_form` to detect the challenge page and :func:`_is_challenge_url` to
31
+ signal when navigation has completed.
32
+
33
+ This base class handles the Playwright browser lifecycle, bidirectional cookie bridging
34
+ between :mod:`requests` and the Playwright browser context, and re-fetching the final
35
+ URL once the challenge resolves.
36
+
37
+ Requires the ``[browser]`` extra: ``pip install amazon-orders[browser]``,
38
+ then ``playwright install chromium``.
39
+ """
40
+
41
+ def __init__(self,
42
+ config: AmazonOrdersConfig) -> None:
43
+ super().__init__(config, selector=None)
44
+ #: Whether to launch the browser in headless mode. Defaults to ``True``.
45
+ #: Set to ``False`` in subclasses that require user interaction.
46
+ self.headless: bool = True
47
+
48
+ def fill_form(self,
49
+ additional_attrs: Optional[Dict[str, Any]] = None) -> None:
50
+ """JavaScript challenge pages have no ``<form>`` to populate; no-op override."""
51
+ pass
52
+
53
+ def submit(self,
54
+ last_response: Response) -> AmazonSessionResponse:
55
+ """
56
+ Launch a headless browser, bridge the current session cookies into it,
57
+ navigate to the challenge URL, wait for the challenge to resolve, harvest the
58
+ resulting cookies back into the session, and re-fetch the final URL.
59
+
60
+ :param last_response: The response that returned the JavaScript challenge page.
61
+ :return: The :class:`~amazonorders.util.AmazonSessionResponse` from re-fetching
62
+ the URL after the challenge resolves.
63
+ :raises AmazonOrdersError: if the ``playwright`` package is not installed, if
64
+ :func:`select_form` was not called first, or if the challenge does not resolve
65
+ within the timeout.
66
+ """
67
+ if not self.amazon_session:
68
+ raise AmazonOrdersError(
69
+ "Call PlaywrightAuthForm.select_form() first."
70
+ ) # pragma: no cover
71
+
72
+ try:
73
+ from playwright.sync_api import ( # type: ignore[import-not-found]
74
+ sync_playwright,
75
+ TimeoutError as PlaywrightTimeoutError,
76
+ )
77
+ except ImportError as e:
78
+ raise AmazonOrdersError(
79
+ f"{type(self).__name__} requires the [browser] extra. "
80
+ "Install it with: `pip install amazon-orders[browser]`, then: `playwright install chromium`"
81
+ ) from e
82
+
83
+ debug = self.amazon_session.debug
84
+ if debug:
85
+ logger.setLevel(logging.DEBUG)
86
+
87
+ message = "Info: A browser is handling a JavaScript authentication challenge."
88
+ logger.info(message)
89
+ self.amazon_session.io.echo(message)
90
+ output_dir = self.config.output_dir if debug else None
91
+ original_url = last_response.url
92
+
93
+ browser_name = self.config.browser or "chromium"
94
+ with sync_playwright() as pw:
95
+ browser_launcher = getattr(pw, browser_name, None)
96
+ if browser_launcher is None:
97
+ raise AmazonOrdersError(
98
+ f"Unsupported browser: {browser_name!r}. "
99
+ f"Valid values: firefox, chromium."
100
+ )
101
+ browser = browser_launcher.launch(headless=self.headless)
102
+ context = browser.new_context()
103
+
104
+ self._inject_cookies(context, original_url)
105
+
106
+ page = context.new_page()
107
+ page.goto(original_url)
108
+ logger.debug(f"Browser navigated to challenge URL: {page.url}")
109
+
110
+ self._save_debug_snapshot(page, output_dir, "browser-challenge")
111
+ self._on_challenge_page(page, context, output_dir)
112
+
113
+ try:
114
+ page.wait_for_url(
115
+ lambda url: not self._is_challenge_url(url, original_url),
116
+ timeout=30000
117
+ )
118
+ except PlaywrightTimeoutError as e:
119
+ logger.debug(f"Browser timed out at URL: {page.url}")
120
+ self._save_debug_snapshot(page, output_dir, "browser-timeout")
121
+ browser.close()
122
+ raise AmazonOrdersError(
123
+ "Browser timed out waiting for the JavaScript challenge to resolve."
124
+ ) from e
125
+
126
+ final_url = page.url
127
+ logger.debug(f"Browser challenge resolved, final URL: {final_url}")
128
+ self._save_debug_snapshot(page, output_dir, "browser-resolved")
129
+ self._harvest_cookies(context)
130
+ browser.close()
131
+
132
+ response = self.amazon_session.get(final_url, persist_cookies=True)
133
+ self.clear_form()
134
+ return response
135
+
136
+ def _on_challenge_page(self, page: Any, context: Any, output_dir: Optional[str]) -> None:
137
+ """
138
+ Hook called after navigating to the challenge page and saving the initial
139
+ snapshot, but before waiting for the challenge URL to resolve. Override in
140
+ subclasses to take additional action (e.g. solving an embedded CAPTCHA).
141
+
142
+ :param page: The Playwright ``Page`` currently on the challenge URL.
143
+ :param context: The Playwright ``BrowserContext``.
144
+ :param output_dir: Directory for debug snapshots, or ``None`` when not in debug mode.
145
+ """
146
+ pass
147
+
148
+ @abstractmethod
149
+ def _is_challenge_url(self, url: str, original_url: str) -> bool:
150
+ """
151
+ Return ``True`` if ``url`` is still on the challenge page; ``False`` once
152
+ the challenge has resolved and navigation may stop.
153
+
154
+ :param url: The current browser URL.
155
+ :param original_url: The URL of the page that first showed the challenge.
156
+ :return: ``True`` while the challenge is active.
157
+ """
158
+ raise NotImplementedError # pragma: no cover
159
+
160
+ def _inject_cookies(self, context: Any, url: str) -> None:
161
+ if not self.amazon_session:
162
+ return # pragma: no cover
163
+ domain = urlparse(url).netloc
164
+ playwright_cookies = []
165
+ for cookie in self.amazon_session.session.cookies:
166
+ playwright_cookies.append({
167
+ "name": cookie.name,
168
+ "value": cookie.value or "",
169
+ "domain": cookie.domain or domain,
170
+ "path": cookie.path or "/",
171
+ })
172
+ if playwright_cookies:
173
+ context.add_cookies(playwright_cookies)
174
+
175
+ def _harvest_cookies(self, context: Any) -> None:
176
+ if not self.amazon_session:
177
+ return # pragma: no cover
178
+ for pw_cookie in context.cookies():
179
+ self.amazon_session.session.cookies.set(
180
+ pw_cookie["name"],
181
+ pw_cookie["value"],
182
+ domain=pw_cookie.get("domain"),
183
+ path=pw_cookie.get("path", "/"),
184
+ )
185
+
186
+ def _save_debug_snapshot(self, page: Any, output_dir: Optional[str], name: str) -> None:
187
+ if not output_dir:
188
+ return
189
+ try:
190
+ os.makedirs(output_dir, exist_ok=True)
191
+ page.screenshot(path=os.path.join(output_dir, f"{name}.png"))
192
+ with open(os.path.join(output_dir, f"{name}.html"), "w", encoding="utf-8") as f:
193
+ f.write(page.content())
194
+ logger.debug(f"Debug snapshot saved: {name} (url={page.url})")
195
+ except Exception:
196
+ logger.debug(f"Debug snapshot failed to save: {name}", exc_info=True)
197
+
198
+
199
+ class PlaywrightAcicForm(PlaywrightAuthForm):
200
+ """
201
+ Handles Amazon's ACIC (Amazon Challenge and Identity Component) JavaScript challenge
202
+ by running it in a headless browser. If an embedded AWS WAF challenge is present on
203
+ the ACIC page, it will be solved automatically using the first
204
+ :class:`~amazonorders.contrib.waf.base.AwsWafForm` found in ``auth_forms_classes``.
205
+
206
+ Detects the challenge via the ``#aa-challenge-page-captcha-container`` element and
207
+ waits for navigation away from ``/ax/aaut/verify/ap/challenge``.
208
+
209
+ Register via ``auth_forms_classes`` in :class:`~amazonorders.conf.AmazonOrdersConfig`:
210
+
211
+ .. code-block:: yaml
212
+
213
+ auth_forms_classes:
214
+ - "amazonorders.contrib.browser.playwright.PlaywrightAcicForm"
215
+ """
216
+
217
+ def select_form(self,
218
+ amazon_session: "AmazonSession",
219
+ parsed: Tag) -> bool:
220
+ """
221
+ Detect an ACIC challenge page by the presence of
222
+ ``#aa-challenge-page-captcha-container``.
223
+
224
+ :param amazon_session: The ``AmazonSession`` on which to submit the form.
225
+ :param parsed: The ``Tag`` for the page being inspected.
226
+ :return: ``True`` if an ACIC challenge was detected, ``False`` otherwise.
227
+ """
228
+ self.amazon_session = amazon_session
229
+ return bool(parsed.find(id="aa-challenge-page-captcha-container"))
230
+
231
+ def _on_challenge_page(self, page: Any, context: Any, output_dir: Optional[str]) -> None:
232
+ self._try_solve_embedded_waf(page, context, output_dir)
233
+
234
+ def _try_solve_embedded_waf(self, page: Any, context: Any, output_dir: Optional[str]) -> bool:
235
+ """
236
+ If the ACIC challenge page contains an embedded AWS WAF challenge, solve it
237
+ using the first :class:`~amazonorders.contrib.waf.base.AwsWafForm` found in
238
+ ``amazon_session.auth_forms``, inject the resulting ``aws-waf-token`` cookie
239
+ into the browser context, and reload the page.
240
+
241
+ :param page: The Playwright ``Page`` on the ACIC challenge URL.
242
+ :param context: The Playwright ``BrowserContext``.
243
+ :param output_dir: Directory for debug snapshots, or ``None``.
244
+ :return: ``True`` if a WAF token was obtained and injected, ``False`` otherwise.
245
+ """
246
+ try:
247
+ from playwright.sync_api import TimeoutError as PlaywrightTimeoutError # type: ignore[import-not-found]
248
+ except ImportError:
249
+ return False # pragma: no cover
250
+
251
+ try:
252
+ page.wait_for_function("() => typeof window.gokuProps !== 'undefined'", timeout=8000)
253
+ except PlaywrightTimeoutError:
254
+ logger.debug("No window.gokuProps in ACIC page — no embedded WAF challenge to solve.")
255
+ return False
256
+
257
+ try:
258
+ goku = page.evaluate("() => window.gokuProps")
259
+ challenge_script = page.evaluate(
260
+ "() => (Array.from(document.querySelectorAll('script[src]'))"
261
+ ".find(s => s.src.includes('awswaf.com')) || {}).src || null"
262
+ )
263
+ except Exception:
264
+ logger.debug("Failed to extract WAF props from ACIC page.", exc_info=True)
265
+ return False
266
+
267
+ if not goku or not challenge_script:
268
+ logger.debug("Incomplete WAF props in ACIC page — goku=%r, script=%r.", goku, challenge_script)
269
+ return False
270
+
271
+ if not self.amazon_session:
272
+ return False # pragma: no cover
273
+
274
+ from amazonorders.contrib.waf.base import AwsWafForm
275
+ waf_form = next(
276
+ (f for f in self.amazon_session.auth_forms if isinstance(f, AwsWafForm)),
277
+ None,
278
+ )
279
+ if not waf_form:
280
+ logger.debug("Embedded WAF challenge found but no AwsWafForm configured — skipping solve.")
281
+ return False
282
+
283
+ try:
284
+ token = waf_form._solve_token(page.url, goku, challenge_script)
285
+ except Exception:
286
+ logger.debug("WAF solver raised an exception solving embedded ACIC CAPTCHA.", exc_info=True)
287
+ return False
288
+
289
+ message = f"Info: Solved embedded WAF challenge via {waf_form.PROVIDER_NAME}."
290
+ logger.info(message)
291
+ self.amazon_session.io.echo(message)
292
+
293
+ domain = urlparse(page.url).netloc
294
+ context.add_cookies([{
295
+ "name": "aws-waf-token",
296
+ "value": token,
297
+ "domain": f".{domain}",
298
+ "path": "/",
299
+ }])
300
+ page.reload(wait_until="load")
301
+ logger.debug("Reloaded ACIC page after WAF token injection.")
302
+ self._save_debug_snapshot(page, output_dir, "browser-waf-injected")
303
+ return True
304
+
305
+ def _is_challenge_url(self, url: str, original_url: str) -> bool:
306
+ return "/ax/aaut/verify/ap/challenge" in url
307
+
308
+
309
+ class PlaywrightJSAuthForm(PlaywrightAuthForm):
310
+ """
311
+ Handles Amazon's JavaScript bot-detection challenge page by running it in a
312
+ headless browser. This is a best-effort form; effectiveness
313
+ depends on whether the challenge can be resolved by a real browser without a visual puzzle.
314
+
315
+ Detects the challenge via :attr:`~amazonorders.constants.Constants.JS_ROBOT_TEXT_REGEX`
316
+ and waits for navigation away from the original challenge URL path.
317
+
318
+ Register via ``auth_forms_classes`` in :class:`~amazonorders.conf.AmazonOrdersConfig`:
319
+
320
+ .. code-block:: yaml
321
+
322
+ auth_forms_classes:
323
+ - "amazonorders.contrib.browser.playwright.PlaywrightJSAuthForm"
324
+ """
325
+
326
+ def __init__(self,
327
+ config: AmazonOrdersConfig) -> None:
328
+ super().__init__(config)
329
+ #: The regex used to detect the JavaScript bot-detection page text.
330
+ self.regex: str = config.constants.JS_ROBOT_TEXT_REGEX
331
+
332
+ def select_form(self,
333
+ amazon_session: "AmazonSession",
334
+ parsed: Tag) -> bool:
335
+ """
336
+ Detect a JavaScript bot-detection page by matching
337
+ :attr:`~amazonorders.constants.Constants.JS_ROBOT_TEXT_REGEX` against the page text.
338
+
339
+ :param amazon_session: The ``AmazonSession`` on which to submit the form.
340
+ :param parsed: The ``Tag`` for the page being inspected.
341
+ :return: ``True`` if a JavaScript bot challenge was detected, ``False`` otherwise.
342
+ """
343
+ self.amazon_session = amazon_session
344
+ return bool(re.search(self.regex, parsed.text))
345
+
346
+ def _is_challenge_url(self, url: str, original_url: str) -> bool:
347
+ return url.split("?")[0] == original_url.split("?")[0]
348
+
349
+
350
+ class PlaywrightManualWafForm(PlaywrightAuthForm):
351
+ """
352
+ Handles Amazon's AWS WAF JavaScript challenge by opening a **visible** browser
353
+ window so the user can solve the CAPTCHA manually. Once the challenge
354
+ resolves and the browser navigates away, cookies are harvested back into the
355
+ session automatically.
356
+
357
+ Because it opens a browser window it requires a display and a user at the
358
+ keyboard, making it suitable for local/interactive use but not for headless
359
+ servers or CI.
360
+
361
+ Detects the challenge via the ``window.gokuProps`` blob and the
362
+ ``challenge.js`` script tag (same signals as
363
+ :class:`~amazonorders.contrib.waf.base.AwsWafForm`), and waits for navigation
364
+ away from the original challenge URL path.
365
+
366
+ Register via ``auth_forms_classes`` in :class:`~amazonorders.conf.AmazonOrdersConfig`:
367
+
368
+ .. code-block:: yaml
369
+
370
+ auth_forms_classes:
371
+ - amazonorders.contrib.browser.playwright.PlaywrightManualWafForm
372
+ """
373
+
374
+ def __init__(self,
375
+ config: AmazonOrdersConfig) -> None:
376
+ super().__init__(config)
377
+ self.headless = False
378
+
379
+ def select_form(self,
380
+ amazon_session: "AmazonSession",
381
+ parsed: Tag) -> bool:
382
+ """
383
+ Detect an AWS WAF challenge page by matching the ``window.gokuProps``
384
+ blob and the ``challenge.js`` script tag.
385
+
386
+ :param amazon_session: The ``AmazonSession`` on which to submit the form.
387
+ :param parsed: The ``Tag`` for the page being inspected.
388
+ :return: ``True`` if a WAF challenge was detected, ``False`` otherwise.
389
+ """
390
+ self.amazon_session = amazon_session
391
+
392
+ match = _GOKU_PROPS_RE.search(str(parsed))
393
+ if not match:
394
+ return False
395
+ try:
396
+ json.loads(match.group(1))
397
+ except (json.JSONDecodeError, ValueError):
398
+ return False
399
+
400
+ challenge_tag = parsed.select_one('script[src*="awswaf.com"]')
401
+ return challenge_tag is not None and isinstance(challenge_tag.get("src"), str)
402
+
403
+ def _on_challenge_page(self, page: Any, context: Any, output_dir: Optional[str]) -> None:
404
+ message = (
405
+ "Info: A browser window has opened — solve the CAPTCHA, then return here when done."
406
+ )
407
+ logger.info(message)
408
+ if self.amazon_session:
409
+ self.amazon_session.io.echo(message)
410
+
411
+ def _is_challenge_url(self, url: str, original_url: str) -> bool:
412
+ return url.split("?")[0] == original_url.split("?")[0]
@@ -40,7 +40,7 @@ class AntiCaptchaWafForm(AwsWafForm):
40
40
  except ImportError as e:
41
41
  raise AmazonOrdersError(
42
42
  "AntiCaptchaWafForm requires the 'anticaptchaofficial' package. "
43
- "Install it with: pip install amazon-orders[anticaptcha]"
43
+ "Install it with: `pip install amazon-orders[anticaptcha]`"
44
44
  ) from e
45
45
 
46
46
  solver = amazonProxyless()
@@ -40,7 +40,7 @@ class CapSolverWafForm(AwsWafForm):
40
40
  except ImportError as e:
41
41
  raise AmazonOrdersError(
42
42
  "CapSolverWafForm requires the 'capsolver' package. "
43
- "Install it with: pip install amazon-orders[capsolver]"
43
+ "Install it with: `pip install amazon-orders[capsolver]`"
44
44
  ) from e
45
45
 
46
46
  capsolver.api_key = self.api_key
@@ -43,7 +43,7 @@ class TwoCaptchaWafForm(AwsWafForm):
43
43
  except ImportError as e:
44
44
  raise AmazonOrdersError(
45
45
  "TwoCaptchaWafForm requires the '2captcha-python' package. "
46
- "Install it with: pip install amazon-orders[2captcha]"
46
+ "Install it with: `pip install amazon-orders[2captcha]`"
47
47
  ) from e
48
48
 
49
49
  solver = TwoCaptcha(self.api_key)
File without changes
@@ -32,6 +32,9 @@ class Transaction(Parsable):
32
32
  self.payment_method: str = self.safe_simple_parse(
33
33
  selector=self.config.selectors.FIELD_TRANSACTION_PAYMENT_METHOD_SELECTOR
34
34
  )
35
+ #: The Transaction payment method's last digits, parsed from :attr:`payment_method`.
36
+ #: ``None`` if no masked digits.
37
+ self.payment_method_last_4: Optional[str] = self.safe_parse(self._parse_payment_method_last_4)
35
38
  #: The Transaction grand total.
36
39
  self.grand_total: float = self.safe_parse(self._parse_grand_total)
37
40
  #: The Transaction was a refund or not.
@@ -91,3 +94,11 @@ class Transaction(Parsable):
91
94
  value = f"{self.config.constants.ORDER_DETAILS_URL}?orderID={self.order_number}"
92
95
 
93
96
  return value
97
+
98
+ def _parse_payment_method_last_4(self) -> Optional[str]:
99
+ if not self.payment_method:
100
+ return None
101
+
102
+ match = re.search(r"\*+(\d+)$", self.payment_method)
103
+
104
+ return match.group(1) if match else None
@@ -461,8 +461,9 @@ class AcicAuthBlocker(AuthForm):
461
461
  parsed: Tag) -> bool:
462
462
  if parsed.find(id="aa-challenge-page-captcha-container"):
463
463
  raise AmazonOrdersAuthError(
464
- "Amazon returned a JavaScript-based authentication challenge that this library cannot solve. See "
465
- "https://amazon-orders.readthedocs.io/troubleshooting.html#captcha-blocking-login for help.")
464
+ "Amazon returned a JavaScript-based authentication challenge. Install the [browser] extra to "
465
+ "handle this automatically: `pip install amazon-orders[browser]`, then `playwright install chromium`. "
466
+ "See https://amazon-orders.readthedocs.io/browser.html for details.")
466
467
 
467
468
  return False
468
469
 
@@ -483,7 +484,8 @@ class JSAuthBlocker(AuthForm):
483
484
 
484
485
  if re.search(self.regex, parsed.text):
485
486
  raise AmazonOrdersAuthError(
486
- "Amazon returned a JavaScript-based authentication challenge that this library cannot solve. See "
487
- "https://amazon-orders.readthedocs.io/troubleshooting.html#captcha-blocking-login for help.")
487
+ "Amazon returned a JavaScript-based authentication challenge. Install the [browser] extra to "
488
+ "handle this automatically: `pip install amazon-orders[browser]`, then `playwright install chromium`. "
489
+ "See https://amazon-orders.readthedocs.io/browser.html for details.")
488
490
 
489
491
  return False
@@ -82,6 +82,7 @@ class AmazonSession:
82
82
  config.set_domain(domain)
83
83
  if not auth_forms:
84
84
  auth_forms = AmazonSession.default_auth_forms(config)
85
+ custom_forms = []
85
86
  for path in config.auth_forms_classes or []:
86
87
  try:
87
88
  module_path, class_name = path.rsplit(".", 1)
@@ -97,8 +98,13 @@ class AmazonSession:
97
98
  )
98
99
  # AuthForm subclasses registered via auth_forms_classes are expected to take
99
100
  # only ``config`` (e.g. AwsWafForm subclasses); the base AuthForm signature
100
- # additionally requires ``selector``
101
- auth_forms.insert(-1, cls(config)) # type: ignore[call-arg]
101
+ # additionally requires ``selector``.
102
+ custom_forms.append(cls(config)) # type: ignore[call-arg]
103
+ # Insert as a block before the blocker pair (AcicAuthBlocker, JSAuthBlocker)
104
+ # so that solver forms run first while preserving auth_forms_classes order.
105
+ insert_pos = len(auth_forms) - 2
106
+ for offset, form_instance in enumerate(custom_forms):
107
+ auth_forms.insert(insert_pos + offset, form_instance)
102
108
 
103
109
  #: An Amazon username. Environment variable ``AMAZON_USERNAME`` will override passed in or config value.
104
110
  self.username: Optional[str] = os.environ.get("AMAZON_USERNAME") or username or config.username
@@ -48,6 +48,9 @@ anticaptcha = [
48
48
  2captcha = [
49
49
  "2captcha-python",
50
50
  ]
51
+ browser = [
52
+ "playwright>=1.47.0",
53
+ ]
51
54
  lxml = [
52
55
  "lxml",
53
56
  ]
File without changes
File without changes
File without changes