amazon-orders 4.2.2__tar.gz → 4.3.1__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.1}/CHANGELOG.md +33 -1
  2. {amazon_orders-4.2.2/amazon_orders.egg-info → amazon_orders-4.3.1}/PKG-INFO +16 -6
  3. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/README.md +13 -5
  4. {amazon_orders-4.2.2 → amazon_orders-4.3.1/amazon_orders.egg-info}/PKG-INFO +16 -6
  5. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/SOURCES.txt +2 -0
  6. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/requires.txt +3 -0
  7. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/__init__.py +1 -1
  8. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/constants.py +58 -23
  9. amazon_orders-4.3.1/amazonorders/contrib/browser/playwright.py +412 -0
  10. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/waf/anticaptcha.py +1 -1
  11. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/waf/capsolver.py +1 -1
  12. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/waf/twocaptcha.py +1 -1
  13. amazon_orders-4.3.1/amazonorders/entity/__init__.py +0 -0
  14. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/transaction.py +11 -0
  15. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/forms.py +6 -4
  16. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/orders.py +21 -0
  17. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/session.py +8 -2
  18. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/transactions.py +13 -7
  19. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/pyproject.toml +3 -0
  20. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/LICENSE +0 -0
  21. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/MANIFEST.in +0 -0
  22. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/dependency_links.txt +0 -0
  23. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/entry_points.txt +0 -0
  24. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/top_level.txt +0 -0
  25. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/banner.txt +0 -0
  26. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/cli.py +0 -0
  27. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/conf.py +0 -0
  28. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/__init__.py +0 -0
  29. {amazon_orders-4.2.2/amazonorders/contrib/waf → amazon_orders-4.3.1/amazonorders/contrib/browser}/__init__.py +0 -0
  30. {amazon_orders-4.2.2/amazonorders/entity → amazon_orders-4.3.1/amazonorders/contrib/waf}/__init__.py +0 -0
  31. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/waf/base.py +0 -0
  32. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/item.py +0 -0
  33. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/order.py +0 -0
  34. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/parsable.py +0 -0
  35. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/recipient.py +0 -0
  36. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/seller.py +0 -0
  37. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/shipment.py +0 -0
  38. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/exception.py +0 -0
  39. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/selectors.py +0 -0
  40. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/util.py +0 -0
  41. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/setup.cfg +0 -0
  42. {amazon_orders-4.2.2 → amazon_orders-4.3.1}/tests/testcase.py +0 -0
@@ -4,7 +4,39 @@ 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.1...HEAD)
8
+
9
+ ## [4.3.1](https://github.com/alexdlaird/amazon-orders/compare/4.3.0...4.3.1) - 2026-06-07
10
+
11
+ ### Added
12
+
13
+ - `AmazonOrders.get_invoice()` to fetch an Order's print-friendly invoice page, returning the response (including its parsed HTML) for rendering or printing.
14
+ - `AmazonTransactions.get_transactions()` `order_id` parameter to scope results to a single Order server-side via Amazon's `transactionTag` filter, bypassing the `days` window.
15
+
16
+ ## [4.3.0](https://github.com/alexdlaird/amazon-orders/compare/4.2.2...4.3.0) - 2026-06-07
17
+
18
+ ### Added
19
+
20
+ - `[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.
21
+ - `PlaywrightAcicForm` handles the ACIC challenge page. If an embedded AWS WAF CAPTCHA is present, it delegates automatically to any configured WAF solver extra.
22
+ - `PlaywrightJSAuthForm` is a best-effort handler for the JS robot-detection page.
23
+ - `PlaywrightManualWafForm` opens a **visible** browser window for manual WAF CAPTCHA solving — a free alternative to the paid `[waf]` extras for local/interactive use.
24
+ - `browser` config key and `AMAZON_BROWSER` environment variable to select between `chromium` (default) and `firefox` browser fingerprints.
25
+ - `Transaction.payment_method_last_4`, the masked card digits parsed from `payment_method`, mirroring the existing field on `Order`.
26
+
27
+ ### Changed
28
+
29
+ - JavaScript-based authentication challenge errors now direct users to the `[browser]` extra rather than reporting the challenge as unsolvable.
30
+ - All user-facing error messages that include install commands now wrap those commands in backticks.
31
+
32
+ ### Added
33
+
34
+ - `[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.
35
+ - `PlaywrightAcicForm` handles the ACIC challenge page. If an embedded WAF is present, it delegates automatically to any configured WAF solver extra.
36
+ - `PlaywrightJSAuthForm` is a best-effort handler for the JS bot-detection page.
37
+ - `PlaywrightManualWafForm` opens a **visible** browser window for you to solve the challenge yourself, suitable when a display is available.
38
+ - `browser` config key and `AMAZON_BROWSER` environment variable to select between `chromium` (default) and `firefox` browser user agents.
39
+ - `Transaction.payment_method_last_4`, the masked card digits parsed from `payment_method`, mirroring the existing field on `Order`.
8
40
 
9
41
  ## [4.2.2](https://github.com/alexdlaird/amazon-orders/compare/4.2.1...4.2.2) - 2026-06-06
10
42
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-orders
3
- Version: 4.2.2
3
+ Version: 4.3.1
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,15 +172,23 @@ pip install amazon-orders[2captcha]
170
172
 
171
173
  See [Solving WAF Challenges](https://amazon-orders.readthedocs.io/waf.html) for details.
172
174
 
173
- To enable **Captcha auto-solve** on Python <=3.12 (via the optional [`amazoncaptcha`](https://pypi.org/project/amazoncaptcha/)
174
- dependency), install with the `captcha` extra:
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
+
185
+ For **legacy Captcha auto-solve** on Python <=3.12, install with `captcha` extra:
175
186
 
176
187
  ```sh
177
188
  pip install amazon-orders[captcha]
178
189
  ```
179
190
 
180
- 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.
191
+ See [Login Challenges](https://amazon-orders.readthedocs.io/troubleshooting.html#login-challenges) for details.
182
192
 
183
193
  ## Documentation
184
194
 
@@ -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,15 +97,23 @@ 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 **Captcha auto-solve** on Python <=3.12 (via the optional [`amazoncaptcha`](https://pypi.org/project/amazoncaptcha/)
101
- dependency), install with the `captcha` extra:
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
+
110
+ For **legacy Captcha auto-solve** on Python <=3.12, install with `captcha` extra:
102
111
 
103
112
  ```sh
104
113
  pip install amazon-orders[captcha]
105
114
  ```
106
115
 
107
- 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.
116
+ See [Login Challenges](https://amazon-orders.readthedocs.io/troubleshooting.html#login-challenges) for details.
109
117
 
110
118
  ## Documentation
111
119
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-orders
3
- Version: 4.2.2
3
+ Version: 4.3.1
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,15 +172,23 @@ pip install amazon-orders[2captcha]
170
172
 
171
173
  See [Solving WAF Challenges](https://amazon-orders.readthedocs.io/waf.html) for details.
172
174
 
173
- To enable **Captcha auto-solve** on Python <=3.12 (via the optional [`amazoncaptcha`](https://pypi.org/project/amazoncaptcha/)
174
- dependency), install with the `captcha` extra:
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
+
185
+ For **legacy Captcha auto-solve** on Python <=3.12, install with `captcha` extra:
175
186
 
176
187
  ```sh
177
188
  pip install amazon-orders[captcha]
178
189
  ```
179
190
 
180
- 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.
191
+ See [Login Challenges](https://amazon-orders.readthedocs.io/troubleshooting.html#login-challenges) for details.
182
192
 
183
193
  ## Documentation
184
194
 
@@ -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.1"
@@ -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
@@ -91,6 +110,7 @@ class Constants:
91
110
 
92
111
  ORDER_HISTORY_URL = f"{BASE_URL}/your-orders/orders"
93
112
  ORDER_DETAILS_URL = f"{BASE_URL}/gp/your-account/order-details"
113
+ ORDER_INVOICE_URL = f"{BASE_URL}/gp/css/summary/print.html"
94
114
  HISTORY_FILTER_QUERY_PARAM = "timeFilter"
95
115
 
96
116
  ##########################################################################
@@ -105,35 +125,21 @@ class Constants:
105
125
  ##########################################################################
106
126
 
107
127
  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",
128
+ "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
129
  "Accept-Encoding": "gzip, deflate, br, zstd",
111
130
  "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
131
  "Host": urlparse(BASE_URL).netloc,
119
- "Priority": "u=0, i",
132
+ "Origin": BASE_URL,
120
133
  "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",
134
+ "Sec-Ch-Ua": '"Chromium";v="149", "Google Chrome";v="149", "Not.A/Brand";v="24"',
125
135
  "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",
136
+ "Sec-Ch-Ua-Platform": '"macOS"',
129
137
  "Sec-Fetch-Dest": "document",
130
138
  "Sec-Fetch-Mode": "navigate",
131
139
  "Sec-Fetch-Site": "none",
132
140
  "Sec-Fetch-User": "?1",
133
141
  "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"
142
+ "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
143
  }
138
144
 
139
145
  ##########################################################################
@@ -152,13 +158,41 @@ class Constants:
152
158
  def __init__(self,
153
159
  config: Optional["AmazonOrdersConfig"] = None) -> None:
154
160
  domain = None
161
+ browser = None
155
162
  if config is not None:
156
163
  domain = config._data.get("domain")
164
+ browser = config._data.get("browser")
157
165
  if not domain:
158
166
  domain = os.environ.get("AMAZON_BASE_URL")
167
+ if not browser:
168
+ browser = os.environ.get("AMAZON_BROWSER")
169
+ self._apply_browser(browser or "chromium")
159
170
  if domain:
160
171
  self._apply_domain(domain)
161
172
 
173
+ def _apply_browser(self,
174
+ browser: str) -> None:
175
+ """
176
+ Apply browser-specific header overrides for the given browser engine.
177
+
178
+ :param browser: Browser engine name — ``"firefox"`` or ``"chromium"``. Unknown values
179
+ log a warning and leave ``BASE_HEADERS`` unchanged.
180
+ """
181
+ preset = _BROWSER_PRESETS.get(browser)
182
+ if preset is None:
183
+ logger.warning(
184
+ f"Unknown browser value {browser!r}; "
185
+ f"valid values are: {', '.join(_BROWSER_PRESETS)}. Using default headers."
186
+ )
187
+ return
188
+ headers = dict(type(self).BASE_HEADERS)
189
+ for key, value in preset.items():
190
+ if value is None:
191
+ headers.pop(key, None)
192
+ else:
193
+ headers[key] = value
194
+ self.BASE_HEADERS = headers
195
+
162
196
  def _apply_domain(self,
163
197
  domain: str) -> None:
164
198
  """
@@ -168,8 +202,8 @@ class Constants:
168
202
  """
169
203
  base_url = _normalize_base_url(domain)
170
204
 
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.
205
+ # Build from the instance-level BASE_HEADERS if _apply_browser has already set it;
206
+ # otherwise fall back to the class-level definition.
173
207
  sign_in_query_params = dict(type(self).SIGN_IN_QUERY_PARAMS)
174
208
  sign_in_query_params["openid.return_to"] = f"{base_url}/?ref_=nav_custrec_signin"
175
209
 
@@ -182,6 +216,7 @@ class Constants:
182
216
  self.SIGN_OUT_URL = f"{base_url}/gp/flex/sign-out.html"
183
217
  self.ORDER_HISTORY_URL = f"{base_url}/your-orders/orders"
184
218
  self.ORDER_DETAILS_URL = f"{base_url}/gp/your-account/order-details"
219
+ self.ORDER_INVOICE_URL = f"{base_url}/gp/css/summary/print.html"
185
220
  self.TRANSACTION_HISTORY_URL = f"{base_url}{self.TRANSACTION_HISTORY_ROUTE}"
186
221
 
187
222
  host = urlparse(base_url).netloc.lower().split(":")[0]
@@ -189,7 +224,7 @@ class Constants:
189
224
  host = host[len("www."):]
190
225
  tld = host[len("amazon."):] if host.startswith("amazon.") else ""
191
226
 
192
- headers = dict(type(self).BASE_HEADERS)
227
+ headers = dict(vars(self).get("BASE_HEADERS", type(self).BASE_HEADERS))
193
228
  headers["Origin"] = base_url
194
229
  headers["Host"] = urlparse(base_url).netloc
195
230
  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
@@ -77,6 +77,27 @@ class AmazonOrders:
77
77
 
78
78
  return order
79
79
 
80
+ def get_invoice(self,
81
+ order_id: str) -> util.AmazonSessionResponse:
82
+ """
83
+ Get the print-friendly invoice page for a given Amazon Order ID, returning the response
84
+ (including its parsed HTML) so callers can render or print the page.
85
+
86
+ :param order_id: The Amazon Order ID to lookup.
87
+ :return: The invoice page response.
88
+ """
89
+ if not self.amazon_session.is_authenticated:
90
+ raise AmazonOrdersError("Call AmazonSession.login() to authenticate first.")
91
+
92
+ invoice_response = self.amazon_session.get(
93
+ f"{self.config.constants.ORDER_INVOICE_URL}?orderID={order_id}")
94
+ self.amazon_session.check_response(invoice_response)
95
+
96
+ if not invoice_response.response.url.startswith(self.config.constants.ORDER_INVOICE_URL):
97
+ raise AmazonOrdersNotFoundError(f"Amazon redirected, which likely means Order {order_id} was not found.")
98
+
99
+ return invoice_response
100
+
80
101
  def get_order_history(self,
81
102
  year: Optional[int] = None,
82
103
  start_index: Optional[int] = None,
@@ -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
@@ -85,28 +85,34 @@ class AmazonTransactions:
85
85
  def get_transactions(self,
86
86
  days: int = 365,
87
87
  next_page_data: Optional[Dict[str, Any]] = None,
88
- keep_paging: bool = True) -> List[Transaction]:
88
+ keep_paging: bool = True,
89
+ order_id: Optional[str] = None) -> List[Transaction]:
89
90
  """
90
- Get Amazon Transaction history for a given number of days.
91
+ Get Amazon Transaction history for a given number of days, or for a single Order.
91
92
 
92
- :param days: The number of days worth of Transactions to get.
93
+ :param days: The number of days worth of Transactions to get. Ignored when ``order_id`` is given.
93
94
  :param next_page_data: If a call to this method previously errored out, passing the exception's
94
95
  :attr:`~amazonorders.exception.AmazonOrdersError.meta` will continue paging where it left off.
95
96
  :param keep_paging: ``False`` if only one page should be fetched.
97
+ :param order_id: If given, only Transactions for this Amazon Order ID are returned, scoped
98
+ server-side via Amazon's ``transactionTag`` filter (the ``days`` window does not apply).
96
99
  :return: A list of the requested Transactions.
97
100
  """
98
101
  if not self.amazon_session.is_authenticated:
99
102
  raise AmazonOrdersError("Call AmazonSession.login() to authenticate first.")
100
103
 
101
- min_date = datetime.date.today() - datetime.timedelta(days=days)
104
+ url = self.config.constants.TRANSACTION_HISTORY_URL
105
+ if order_id:
106
+ url = f"{url}?transactionTag={order_id}"
107
+ else:
108
+ min_date = datetime.date.today() - datetime.timedelta(days=days)
102
109
 
103
110
  transactions: List[Transaction] = []
104
111
  first_page = True
105
112
  while first_page or keep_paging:
106
113
  first_page = False
107
114
 
108
- page_response = self.amazon_session.post(self.config.constants.TRANSACTION_HISTORY_URL,
109
- data=next_page_data)
115
+ page_response = self.amazon_session.post(url, data=next_page_data)
110
116
  self.amazon_session.check_response(page_response, meta=next_page_data)
111
117
 
112
118
  form_tag = util.select_one(page_response.parsed,
@@ -125,7 +131,7 @@ class AmazonTransactions:
125
131
  )
126
132
 
127
133
  for transaction in loaded_transactions:
128
- if transaction.completed_date >= min_date:
134
+ if order_id or transaction.completed_date >= min_date:
129
135
  transactions.append(transaction)
130
136
  else:
131
137
  next_page_data = None
@@ -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