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.
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/CHANGELOG.md +33 -1
- {amazon_orders-4.2.2/amazon_orders.egg-info → amazon_orders-4.3.1}/PKG-INFO +16 -6
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/README.md +13 -5
- {amazon_orders-4.2.2 → amazon_orders-4.3.1/amazon_orders.egg-info}/PKG-INFO +16 -6
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/SOURCES.txt +2 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/requires.txt +3 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/__init__.py +1 -1
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/constants.py +58 -23
- amazon_orders-4.3.1/amazonorders/contrib/browser/playwright.py +412 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/waf/anticaptcha.py +1 -1
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/waf/capsolver.py +1 -1
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/waf/twocaptcha.py +1 -1
- amazon_orders-4.3.1/amazonorders/entity/__init__.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/transaction.py +11 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/forms.py +6 -4
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/orders.py +21 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/session.py +8 -2
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/transactions.py +13 -7
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/pyproject.toml +3 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/LICENSE +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/MANIFEST.in +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/dependency_links.txt +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/entry_points.txt +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazon_orders.egg-info/top_level.txt +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/banner.txt +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/cli.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/conf.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/__init__.py +0 -0
- {amazon_orders-4.2.2/amazonorders/contrib/waf → amazon_orders-4.3.1/amazonorders/contrib/browser}/__init__.py +0 -0
- {amazon_orders-4.2.2/amazonorders/entity → amazon_orders-4.3.1/amazonorders/contrib/waf}/__init__.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/contrib/waf/base.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/item.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/order.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/parsable.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/recipient.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/seller.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/entity/shipment.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/exception.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/selectors.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/amazonorders/util.py +0 -0
- {amazon_orders-4.2.2 → amazon_orders-4.3.1}/setup.cfg +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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 **
|
|
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
|
+
|
|
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
|
-
|
|
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.
|
|
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 **
|
|
101
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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 **
|
|
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
|
+
|
|
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
|
-
|
|
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
|
|
@@ -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
|
-
"
|
|
132
|
+
"Origin": BASE_URL,
|
|
120
133
|
"Referer": f"{SIGN_IN_URL}?{urlencode(SIGN_IN_QUERY_PARAMS)}",
|
|
121
|
-
"
|
|
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
|
|
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
|
-
#
|
|
172
|
-
#
|
|
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
|
|
465
|
-
"
|
|
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
|
|
487
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{amazon_orders-4.2.2/amazonorders/entity → amazon_orders-4.3.1/amazonorders/contrib/waf}/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|