amazon-orders 4.9.1__tar.gz → 4.9.2__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.9.1 → amazon_orders-4.9.2}/CHANGELOG.md +16 -1
- {amazon_orders-4.9.1/amazon_orders.egg-info → amazon_orders-4.9.2}/PKG-INFO +11 -7
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/README.md +10 -6
- {amazon_orders-4.9.1 → amazon_orders-4.9.2/amazon_orders.egg-info}/PKG-INFO +11 -7
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/__init__.py +1 -1
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/cli.py +2 -2
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/constants.py +171 -170
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/browser/playwright.py +27 -3
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/anticaptcha.py +4 -4
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/base.py +47 -12
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/capsolver.py +8 -8
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/twocaptcha.py +4 -4
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/order.py +2 -1
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/transaction.py +2 -1
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/forms.py +21 -13
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/orders.py +10 -8
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/selectors.py +1 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/session.py +58 -19
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/LICENSE +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/MANIFEST.in +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/SOURCES.txt +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/dependency_links.txt +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/entry_points.txt +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/requires.txt +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/top_level.txt +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/banner.txt +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/conf.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/__init__.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/browser/__init__.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/__init__.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/__init__.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/gift_card_activity.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/item.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/parsable.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/recipient.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/seller.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/shipment.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/tracking.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/exception.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/gift_cards.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/output.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/paging.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/region_data.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/transactions.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/util.py +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/pyproject.toml +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/setup.cfg +0 -0
- {amazon_orders-4.9.1 → amazon_orders-4.9.2}/tests/testcase.py +0 -0
|
@@ -4,7 +4,22 @@ 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.9.
|
|
7
|
+
## [Unreleased](https://github.com/alexdlaird/amazon-orders/compare/4.9.2...HEAD)
|
|
8
|
+
|
|
9
|
+
## [4.9.2](https://github.com/alexdlaird/amazon-orders/compare/4.9.1...4.9.2) - 2026-10-15
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `Constants.BROWSER_HEADERS`, `REGION_CURRENCY_OVERRIDES`, `ZERO_DECIMAL_CURRENCY_SYMBOLS`, `region_auth_cookie()`, and `region_currency_symbol()`, so a `constants_class` can override a browser's headers, or a site's auth cookie or currency symbol.
|
|
14
|
+
|
|
15
|
+
### Deprecated
|
|
16
|
+
|
|
17
|
+
- `AwsWafForm`'s `_solve_token()` and `_solve_visual_captcha()`, renamed to `solve_token()` and `solve_visual_captcha()`.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- Sign-in on Amazon sites other than `.com`, `.ca`, `.co.jp`, `.co.uk`, `.com.au`, `.de`, `.in`, and `.it` now checks for that site's own auth cookie.
|
|
22
|
+
- Bug fixes and stability improvements.
|
|
8
23
|
|
|
9
24
|
## [4.9.1](https://github.com/alexdlaird/amazon-orders/compare/4.9.0...4.9.1) - 2026-10-14
|
|
10
25
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: amazon-orders
|
|
3
|
-
Version: 4.9.
|
|
3
|
+
Version: 4.9.2
|
|
4
4
|
Summary: A Python library (and CLI) for Amazon order history, line items, and transactions
|
|
5
5
|
Author-email: Alex Laird <contact@alexlaird.com>
|
|
6
6
|
Maintainer-email: Alex Laird <contact@alexlaird.com>
|
|
@@ -112,6 +112,7 @@ to interact with account data. [`get_order_history`](https://amazon-orders.readt
|
|
|
112
112
|
and [`get_order`](https://amazon-orders.readthedocs.io/api.html#amazonorders.orders.AmazonOrders.get_order) are good places to start.
|
|
113
113
|
|
|
114
114
|
```python
|
|
115
|
+
from amazonorders.constants import Constants
|
|
115
116
|
from amazonorders.session import AmazonSession
|
|
116
117
|
from amazonorders.orders import AmazonOrders
|
|
117
118
|
|
|
@@ -125,8 +126,8 @@ amazon_orders = AmazonOrders(amazon_session)
|
|
|
125
126
|
orders = amazon_orders.get_order_history(year=2023)
|
|
126
127
|
|
|
127
128
|
# Or use time filters for recent orders
|
|
128
|
-
orders = amazon_orders.get_order_history(time_filter=
|
|
129
|
-
orders = amazon_orders.get_order_history(time_filter=
|
|
129
|
+
orders = amazon_orders.get_order_history(time_filter=Constants.LAST_30_DAYS_TIME_FILTER)
|
|
130
|
+
orders = amazon_orders.get_order_history(time_filter=Constants.LAST_3_MONTHS_TIME_FILTER)
|
|
130
131
|
|
|
131
132
|
for order in orders:
|
|
132
133
|
print(f"{order.order_number} - {order.grand_total}")
|
|
@@ -210,6 +211,9 @@ environment variables `amazon-orders` looks for are:
|
|
|
210
211
|
|
|
211
212
|
Any other config value can be set the same way, as `AMAZON_<KEY>` (see [`AmazonOrdersConfig`](https://amazon-orders.readthedocs.io/api.html#amazonorders.conf.AmazonOrdersConfig)).
|
|
212
213
|
|
|
214
|
+
A configured username also applies to `amazon-orders login --browser`: it's filled in and submitted for you, so the
|
|
215
|
+
browser opens on Amazon's password or passkey step. The browser never fills in your password.
|
|
216
|
+
|
|
213
217
|
### Languages and Regions
|
|
214
218
|
|
|
215
219
|
`amazon-orders` reads Amazon's pages in English. The `.com` site is validated nightly.
|
|
@@ -223,10 +227,10 @@ Any other config value can be set the same way, as `AMAZON_<KEY>` (see [`AmazonO
|
|
|
223
227
|
|
|
224
228
|
### AI Assistants
|
|
225
229
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
+
An AI assistant has two ways to use `amazon-orders`. One that runs commands uses the CLI or the library directly, just
|
|
231
|
+
as shown above. Any MCP client gets the same as tools through
|
|
232
|
+
[`amazon-orders-mcp`](https://github.com/alexdlaird/amazon-orders-mcp#readme), a server built on `amazon-orders` — see
|
|
233
|
+
its README for setup.
|
|
230
234
|
|
|
231
235
|
## Documentation
|
|
232
236
|
|
|
@@ -36,6 +36,7 @@ to interact with account data. [`get_order_history`](https://amazon-orders.readt
|
|
|
36
36
|
and [`get_order`](https://amazon-orders.readthedocs.io/api.html#amazonorders.orders.AmazonOrders.get_order) are good places to start.
|
|
37
37
|
|
|
38
38
|
```python
|
|
39
|
+
from amazonorders.constants import Constants
|
|
39
40
|
from amazonorders.session import AmazonSession
|
|
40
41
|
from amazonorders.orders import AmazonOrders
|
|
41
42
|
|
|
@@ -49,8 +50,8 @@ amazon_orders = AmazonOrders(amazon_session)
|
|
|
49
50
|
orders = amazon_orders.get_order_history(year=2023)
|
|
50
51
|
|
|
51
52
|
# Or use time filters for recent orders
|
|
52
|
-
orders = amazon_orders.get_order_history(time_filter=
|
|
53
|
-
orders = amazon_orders.get_order_history(time_filter=
|
|
53
|
+
orders = amazon_orders.get_order_history(time_filter=Constants.LAST_30_DAYS_TIME_FILTER)
|
|
54
|
+
orders = amazon_orders.get_order_history(time_filter=Constants.LAST_3_MONTHS_TIME_FILTER)
|
|
54
55
|
|
|
55
56
|
for order in orders:
|
|
56
57
|
print(f"{order.order_number} - {order.grand_total}")
|
|
@@ -134,6 +135,9 @@ environment variables `amazon-orders` looks for are:
|
|
|
134
135
|
|
|
135
136
|
Any other config value can be set the same way, as `AMAZON_<KEY>` (see [`AmazonOrdersConfig`](https://amazon-orders.readthedocs.io/api.html#amazonorders.conf.AmazonOrdersConfig)).
|
|
136
137
|
|
|
138
|
+
A configured username also applies to `amazon-orders login --browser`: it's filled in and submitted for you, so the
|
|
139
|
+
browser opens on Amazon's password or passkey step. The browser never fills in your password.
|
|
140
|
+
|
|
137
141
|
### Languages and Regions
|
|
138
142
|
|
|
139
143
|
`amazon-orders` reads Amazon's pages in English. The `.com` site is validated nightly.
|
|
@@ -147,10 +151,10 @@ Any other config value can be set the same way, as `AMAZON_<KEY>` (see [`AmazonO
|
|
|
147
151
|
|
|
148
152
|
### AI Assistants
|
|
149
153
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
+
An AI assistant has two ways to use `amazon-orders`. One that runs commands uses the CLI or the library directly, just
|
|
155
|
+
as shown above. Any MCP client gets the same as tools through
|
|
156
|
+
[`amazon-orders-mcp`](https://github.com/alexdlaird/amazon-orders-mcp#readme), a server built on `amazon-orders` — see
|
|
157
|
+
its README for setup.
|
|
154
158
|
|
|
155
159
|
## Documentation
|
|
156
160
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: amazon-orders
|
|
3
|
-
Version: 4.9.
|
|
3
|
+
Version: 4.9.2
|
|
4
4
|
Summary: A Python library (and CLI) for Amazon order history, line items, and transactions
|
|
5
5
|
Author-email: Alex Laird <contact@alexlaird.com>
|
|
6
6
|
Maintainer-email: Alex Laird <contact@alexlaird.com>
|
|
@@ -112,6 +112,7 @@ to interact with account data. [`get_order_history`](https://amazon-orders.readt
|
|
|
112
112
|
and [`get_order`](https://amazon-orders.readthedocs.io/api.html#amazonorders.orders.AmazonOrders.get_order) are good places to start.
|
|
113
113
|
|
|
114
114
|
```python
|
|
115
|
+
from amazonorders.constants import Constants
|
|
115
116
|
from amazonorders.session import AmazonSession
|
|
116
117
|
from amazonorders.orders import AmazonOrders
|
|
117
118
|
|
|
@@ -125,8 +126,8 @@ amazon_orders = AmazonOrders(amazon_session)
|
|
|
125
126
|
orders = amazon_orders.get_order_history(year=2023)
|
|
126
127
|
|
|
127
128
|
# Or use time filters for recent orders
|
|
128
|
-
orders = amazon_orders.get_order_history(time_filter=
|
|
129
|
-
orders = amazon_orders.get_order_history(time_filter=
|
|
129
|
+
orders = amazon_orders.get_order_history(time_filter=Constants.LAST_30_DAYS_TIME_FILTER)
|
|
130
|
+
orders = amazon_orders.get_order_history(time_filter=Constants.LAST_3_MONTHS_TIME_FILTER)
|
|
130
131
|
|
|
131
132
|
for order in orders:
|
|
132
133
|
print(f"{order.order_number} - {order.grand_total}")
|
|
@@ -210,6 +211,9 @@ environment variables `amazon-orders` looks for are:
|
|
|
210
211
|
|
|
211
212
|
Any other config value can be set the same way, as `AMAZON_<KEY>` (see [`AmazonOrdersConfig`](https://amazon-orders.readthedocs.io/api.html#amazonorders.conf.AmazonOrdersConfig)).
|
|
212
213
|
|
|
214
|
+
A configured username also applies to `amazon-orders login --browser`: it's filled in and submitted for you, so the
|
|
215
|
+
browser opens on Amazon's password or passkey step. The browser never fills in your password.
|
|
216
|
+
|
|
213
217
|
### Languages and Regions
|
|
214
218
|
|
|
215
219
|
`amazon-orders` reads Amazon's pages in English. The `.com` site is validated nightly.
|
|
@@ -223,10 +227,10 @@ Any other config value can be set the same way, as `AMAZON_<KEY>` (see [`AmazonO
|
|
|
223
227
|
|
|
224
228
|
### AI Assistants
|
|
225
229
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
+
An AI assistant has two ways to use `amazon-orders`. One that runs commands uses the CLI or the library directly, just
|
|
231
|
+
as shown above. Any MCP client gets the same as tools through
|
|
232
|
+
[`amazon-orders-mcp`](https://github.com/alexdlaird/amazon-orders-mcp#readme), a server built on `amazon-orders` — see
|
|
233
|
+
its README for setup.
|
|
230
234
|
|
|
231
235
|
## Documentation
|
|
232
236
|
|
|
@@ -331,10 +331,10 @@ def history(ctx: Context,
|
|
|
331
331
|
# Determine time filter
|
|
332
332
|
time_filter = None
|
|
333
333
|
if last_30_days:
|
|
334
|
-
time_filter =
|
|
334
|
+
time_filter = amazon_session.config.constants.LAST_30_DAYS_TIME_FILTER
|
|
335
335
|
filter_description = "last 30 days"
|
|
336
336
|
elif last_3_months:
|
|
337
|
-
time_filter =
|
|
337
|
+
time_filter = amazon_session.config.constants.LAST_3_MONTHS_TIME_FILTER
|
|
338
338
|
filter_description = "past 3 months"
|
|
339
339
|
else:
|
|
340
340
|
if year is None:
|
|
@@ -5,7 +5,7 @@ import logging
|
|
|
5
5
|
import os
|
|
6
6
|
import re
|
|
7
7
|
from datetime import date
|
|
8
|
-
from typing import Dict, Optional, Union
|
|
8
|
+
from typing import Dict, List, Optional, Union
|
|
9
9
|
from urllib.parse import urlencode, urlparse
|
|
10
10
|
|
|
11
11
|
from amazonorders import util
|
|
@@ -14,64 +14,6 @@ from amazonorders.region_data import REGION_SITES
|
|
|
14
14
|
|
|
15
15
|
logger = logging.getLogger(__name__)
|
|
16
16
|
|
|
17
|
-
#: Browser-specific header overrides applied on top of the class-level ``BASE_HEADERS``
|
|
18
|
-
#: (which already reflects the Chromium fingerprint). A ``None`` value removes the key
|
|
19
|
-
#: (used to strip headers absent in that engine). ``Accept-Language`` here is the browser
|
|
20
|
-
#: default; domain-specific TLD overrides still apply on top via :func:`~Constants._apply_domain`.
|
|
21
|
-
_BROWSER_PRESETS: Dict[str, Dict[str, Optional[str]]] = {
|
|
22
|
-
"chromium": {}, # BASE_HEADERS already reflects the Chromium fingerprint; no overrides needed.
|
|
23
|
-
"firefox": {
|
|
24
|
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
25
|
-
"Accept-Language": "en-US,en;q=0.5",
|
|
26
|
-
"Sec-Ch-Ua": None,
|
|
27
|
-
"Sec-Ch-Ua-Mobile": None,
|
|
28
|
-
"Sec-Ch-Ua-Platform": None,
|
|
29
|
-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0",
|
|
30
|
-
},
|
|
31
|
-
"webkit": {
|
|
32
|
-
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
33
|
-
"Accept-Encoding": "gzip, deflate",
|
|
34
|
-
"Priority": "u=0, i",
|
|
35
|
-
"Sec-Ch-Ua": None,
|
|
36
|
-
"Sec-Ch-Ua-Mobile": None,
|
|
37
|
-
"Sec-Ch-Ua-Platform": None,
|
|
38
|
-
"Sec-Fetch-User": None,
|
|
39
|
-
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Safari/605.1.15", # noqa: E501
|
|
40
|
-
},
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
#: ``CURRENCY_SYMBOL`` values for Amazon sites that their own pages don't give, or give wrong, keyed by the TLD
|
|
44
|
-
#: suffix that follows ``amazon.``. These take precedence over the generated region data.
|
|
45
|
-
_REGION_CURRENCY_OVERRIDES: Dict[str, str] = {}
|
|
46
|
-
|
|
47
|
-
#: Currency symbols whose amounts are rendered without decimals (yen has no minor unit).
|
|
48
|
-
_ZERO_DECIMAL_CURRENCY_SYMBOLS = ["¥", "¥"]
|
|
49
|
-
|
|
50
|
-
#: The cookie that marks an authenticated session on regional Amazon sites, keyed by the TLD
|
|
51
|
-
#: suffix that follows ``amazon.``. Applied only when ``COOKIES_SET_WHEN_AUTHENTICATED`` still
|
|
52
|
-
#: has the default ``x-main``, so a ``constants_class`` override wins. Unknown TLDs keep the default.
|
|
53
|
-
_REGION_AUTH_COOKIES = {
|
|
54
|
-
"ca": "x-acbca",
|
|
55
|
-
"co.jp": "x-acbjp",
|
|
56
|
-
"co.uk": "x-acbuk",
|
|
57
|
-
"com.au": "x-acbau",
|
|
58
|
-
"de": "x-acbde",
|
|
59
|
-
"in": "x-acbin",
|
|
60
|
-
"it": "x-acbit",
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
def _region_currency_symbol(tld: str) -> Optional[str]:
|
|
65
|
-
"""
|
|
66
|
-
The currency symbol an Amazon site's prices are shown with: its ``_REGION_CURRENCY_OVERRIDES`` entry, if it has
|
|
67
|
-
one, otherwise its region data.
|
|
68
|
-
|
|
69
|
-
:param tld: The TLD suffix that follows ``amazon.`` (e.g. ``co.uk``).
|
|
70
|
-
:return: The currency symbol, or ``None`` if the site's isn't known.
|
|
71
|
-
"""
|
|
72
|
-
site = REGION_SITES.get(tld)
|
|
73
|
-
return _REGION_CURRENCY_OVERRIDES.get(tld) or (site.currency_symbol if site else None)
|
|
74
|
-
|
|
75
17
|
|
|
76
18
|
class Constants:
|
|
77
19
|
"""
|
|
@@ -129,6 +71,10 @@ class Constants:
|
|
|
129
71
|
ORDER_FILTER_QUERY_PARAM = "orderFilter"
|
|
130
72
|
HISTORY_PAGE_QUERY_PARAM = "page"
|
|
131
73
|
HISTORY_START_INDEX_QUERY_PARAM = "startIndex"
|
|
74
|
+
ORDER_ID_QUERY_PARAM = "orderID"
|
|
75
|
+
LAST_30_DAYS_TIME_FILTER = "last30"
|
|
76
|
+
LAST_3_MONTHS_TIME_FILTER = "months-3"
|
|
77
|
+
YEAR_TIME_FILTER_PREFIX = "year-"
|
|
132
78
|
ORDER_HISTORY_PAGE_SIZE = 10
|
|
133
79
|
WHOLE_FOODS_DETAILS_ROUTES = ["/fopo/order-details", "/wholefoodsmarket/receipts/order/"]
|
|
134
80
|
TRACKING_ROUTE = "/progress-tracker/package"
|
|
@@ -174,6 +120,27 @@ class Constants:
|
|
|
174
120
|
"Upgrade-Insecure-Requests": "1",
|
|
175
121
|
"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
|
|
176
122
|
}
|
|
123
|
+
#: ``BASE_HEADERS`` changes for each ``browser`` that isn't Chromium-based. A ``None`` value removes the header.
|
|
124
|
+
BROWSER_HEADERS: Dict[str, Dict[str, Optional[str]]] = {
|
|
125
|
+
"firefox": {
|
|
126
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
127
|
+
"Accept-Language": "en-US,en;q=0.5",
|
|
128
|
+
"Sec-Ch-Ua": None,
|
|
129
|
+
"Sec-Ch-Ua-Mobile": None,
|
|
130
|
+
"Sec-Ch-Ua-Platform": None,
|
|
131
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:146.0) Gecko/20100101 Firefox/146.0",
|
|
132
|
+
},
|
|
133
|
+
"webkit": {
|
|
134
|
+
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
135
|
+
"Accept-Encoding": "gzip, deflate",
|
|
136
|
+
"Priority": "u=0, i",
|
|
137
|
+
"Sec-Ch-Ua": None,
|
|
138
|
+
"Sec-Ch-Ua-Mobile": None,
|
|
139
|
+
"Sec-Ch-Ua-Platform": None,
|
|
140
|
+
"Sec-Fetch-User": None,
|
|
141
|
+
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.6 Safari/605.1.15", # noqa: E501
|
|
142
|
+
},
|
|
143
|
+
}
|
|
177
144
|
#: Cookies sent with every request to the active Amazon domain, replacing any cookie of the same name Amazon
|
|
178
145
|
#: set, for a site that picks something like its language from a cookie (e.g. ``{"lc-acbde": "de_DE"}`` for
|
|
179
146
|
#: German pages on amazon.de). Left at its default, it's set to the English language cookie of the active site,
|
|
@@ -189,6 +156,7 @@ class Constants:
|
|
|
189
156
|
JS_ROBOT_TEXT_REGEX = r"[.\s\S]*verify that you're not a robot[.\s\S]*Enable JavaScript[.\s\S]*"
|
|
190
157
|
GOKU_PROPS_REGEX = r"window\.gokuProps\s*=\s*(\{.*?\});"
|
|
191
158
|
ACIC_CHALLENGE_PATH = "/ax/aaut/verify/ap/challenge"
|
|
159
|
+
WAF_TOKEN_COOKIE = "aws-waf-token"
|
|
192
160
|
|
|
193
161
|
##########################################################################
|
|
194
162
|
# Formats
|
|
@@ -198,6 +166,10 @@ class Constants:
|
|
|
198
166
|
THOUSANDS_SEPARATOR = ","
|
|
199
167
|
#: The symbol :func:`format_currency` renders. Each known Amazon site sets its own; this is the fallback.
|
|
200
168
|
CURRENCY_SYMBOL = "$"
|
|
169
|
+
#: ``CURRENCY_SYMBOL`` for each site by the TLD that follows ``amazon.``, taking precedence over its region data.
|
|
170
|
+
REGION_CURRENCY_OVERRIDES: Dict[str, str] = {}
|
|
171
|
+
#: Currency symbols :func:`format_currency` renders without decimals.
|
|
172
|
+
ZERO_DECIMAL_CURRENCY_SYMBOLS: List[str] = ["¥", "¥"]
|
|
201
173
|
CURRENCY_FORMAT = "{symbol}{amount}"
|
|
202
174
|
CURRENCY_FREE_TEXT = "free"
|
|
203
175
|
#: Order numbers by shape: ``111-…`` and digital ``D01-…`` IDs, older ``4000-…`` IDs (still listed on
|
|
@@ -213,18 +185,134 @@ class Constants:
|
|
|
213
185
|
domain, browser = environment.get("domain"), environment.get("browser")
|
|
214
186
|
if not domain:
|
|
215
187
|
domain = self._deprecated_base_url()
|
|
216
|
-
|
|
188
|
+
if browser:
|
|
189
|
+
self._apply_browser(browser)
|
|
217
190
|
if domain:
|
|
218
191
|
self._apply_domain(domain)
|
|
219
192
|
self._apply_language_cookie(self._tld(self.BASE_URL))
|
|
220
193
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
194
|
+
def format_currency(self,
|
|
195
|
+
amount: float) -> str:
|
|
196
|
+
"""
|
|
197
|
+
Format an amount for display, using ``CURRENCY_SYMBOL``, ``DECIMAL_SEPARATOR``,
|
|
198
|
+
``THOUSANDS_SEPARATOR``, and the ``CURRENCY_FORMAT`` template (which places the
|
|
199
|
+
``{symbol}`` and ``{amount}``, e.g. ``"{amount} {symbol}"`` renders ``1.234,56 €``). A
|
|
200
|
+
negative amount is prefixed with ``-``.
|
|
201
|
+
|
|
202
|
+
:param amount: The amount to format.
|
|
203
|
+
:return: The formatted amount.
|
|
204
|
+
"""
|
|
205
|
+
decimals = 0 if self.CURRENCY_SYMBOL in self.ZERO_DECIMAL_CURRENCY_SYMBOLS else 2
|
|
206
|
+
integer, _, fraction = "{amount:,.{decimals}f}".format(amount=abs(amount), decimals=decimals).partition(".")
|
|
207
|
+
formatted_number = integer.replace(",", self.THOUSANDS_SEPARATOR)
|
|
208
|
+
if fraction:
|
|
209
|
+
formatted_number += f"{self.DECIMAL_SEPARATOR}{fraction}"
|
|
210
|
+
formatted_amt = self.CURRENCY_FORMAT.format(symbol=self.CURRENCY_SYMBOL, amount=formatted_number)
|
|
211
|
+
if round(amount, decimals) < 0:
|
|
212
|
+
return f"-{formatted_amt}"
|
|
213
|
+
return formatted_amt
|
|
214
|
+
|
|
215
|
+
def parse_currency(self,
|
|
216
|
+
value: Union[str, int, float]) -> Union[int, float, None]:
|
|
217
|
+
"""
|
|
218
|
+
Parse a currency amount as rendered on the page, stripping non-numeric values and returning it
|
|
219
|
+
as a primitive.
|
|
220
|
+
|
|
221
|
+
Strips any currency symbol (e.g. ``$``, ``€``, or the fullwidth ``¥`` used by amazon.co.jp) and
|
|
222
|
+
currency-code letters (e.g. ``CDN$``, ``AED``, or ``zł``), accepts accounting-style negatives in
|
|
223
|
+
parentheses (e.g. ``($1.99)``), and treats :attr:`CURRENCY_FREE_TEXT` as ``0.0``. Either decimal mark
|
|
224
|
+
is accepted: a trailing ``.`` or ``,`` followed by one or two digits is the decimal mark, and other
|
|
225
|
+
``.``, ``,``, ``'``, and space separators group thousands (e.g. ``1,234.56``, ``1.234,56 €``, or
|
|
226
|
+
``12,99 €``).
|
|
227
|
+
|
|
228
|
+
:param value: The currency to parse.
|
|
229
|
+
:return: The currency as a primitive, or ``None`` if it could not be parsed.
|
|
230
|
+
"""
|
|
231
|
+
if isinstance(value, (int, float)):
|
|
232
|
+
return value
|
|
233
|
+
|
|
234
|
+
if not value:
|
|
235
|
+
return None
|
|
236
|
+
|
|
237
|
+
value = value.strip()
|
|
238
|
+
|
|
239
|
+
if value.casefold() == self.CURRENCY_FREE_TEXT.casefold():
|
|
240
|
+
return 0.0
|
|
241
|
+
|
|
242
|
+
if value.startswith("(") and value.endswith(")"):
|
|
243
|
+
value = "-" + value[1:-1]
|
|
244
|
+
|
|
245
|
+
value = util.strip_currency_text(value.replace("\u2212", "-"))
|
|
246
|
+
currency = util.to_type(util.to_decimal_point(value))
|
|
247
|
+
|
|
248
|
+
if isinstance(currency, str):
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
return currency
|
|
252
|
+
|
|
253
|
+
def parse_date(self,
|
|
254
|
+
value: Optional[str],
|
|
255
|
+
fuzzy: bool = False) -> Optional[date]:
|
|
256
|
+
"""
|
|
257
|
+
Parse a date as rendered on the page. Delegates to :func:`~amazonorders.util.to_date`.
|
|
258
|
+
|
|
259
|
+
:param value: The date string to parse.
|
|
260
|
+
:param fuzzy: Whether to ignore unknown tokens when parsing.
|
|
261
|
+
:return: The parsed ``date``, or ``None`` if it could not be parsed.
|
|
262
|
+
"""
|
|
263
|
+
return util.to_date(value, fuzzy=fuzzy)
|
|
264
|
+
|
|
265
|
+
def parse_count(self,
|
|
266
|
+
value: str,
|
|
267
|
+
pattern: str = r"^\s*{count}") -> Optional[int]:
|
|
268
|
+
"""
|
|
269
|
+
Parse a whole number as rendered on the page. Delegates to :func:`~amazonorders.util.to_count`.
|
|
270
|
+
|
|
271
|
+
:param value: The text containing the number.
|
|
272
|
+
:param pattern: A regex locating the number, with ``{count}`` marking where it appears. Defaults to a
|
|
273
|
+
number at the start of the text.
|
|
274
|
+
:return: The number, or ``None`` if ``pattern`` does not match.
|
|
275
|
+
"""
|
|
276
|
+
return util.to_count(value, pattern)
|
|
277
|
+
|
|
278
|
+
def parse_order_number(self,
|
|
279
|
+
value: str) -> Optional[str]:
|
|
280
|
+
"""
|
|
281
|
+
Find an Order number, as matched by :attr:`ORDER_NUMBER_REGEX`, in text from the page.
|
|
282
|
+
|
|
283
|
+
:param value: The text containing the Order number.
|
|
284
|
+
:return: The Order number, or ``None`` if the text does not contain one.
|
|
285
|
+
"""
|
|
286
|
+
match = re.search(self.ORDER_NUMBER_REGEX, value)
|
|
287
|
+
return match.group(0) if match else None
|
|
288
|
+
|
|
289
|
+
@classmethod
|
|
290
|
+
def region_auth_cookie(cls,
|
|
291
|
+
tld: str) -> Optional[str]:
|
|
292
|
+
"""
|
|
293
|
+
The cookie that marks an authenticated session on an Amazon site: its language cookie, with ``lc-`` swapped
|
|
294
|
+
for ``x-`` (e.g. ``lc-acbde`` and ``x-acbde``, or ``.com``'s ``lc-main`` and ``x-main``).
|
|
295
|
+
|
|
296
|
+
:param tld: The TLD suffix that follows ``amazon.`` (e.g. ``co.uk``).
|
|
297
|
+
:return: The cookie's name, or ``None`` if the site's isn't known.
|
|
298
|
+
"""
|
|
299
|
+
site = REGION_SITES.get(tld)
|
|
300
|
+
if not site or not site.language_cookie or not site.language_cookie.startswith("lc-"):
|
|
301
|
+
return None
|
|
302
|
+
return f"x-{site.language_cookie[len('lc-'):]}"
|
|
303
|
+
|
|
304
|
+
@classmethod
|
|
305
|
+
def region_currency_symbol(cls,
|
|
306
|
+
tld: str) -> Optional[str]:
|
|
307
|
+
"""
|
|
308
|
+
The currency symbol an Amazon site's prices are shown with: its ``REGION_CURRENCY_OVERRIDES`` entry, if it
|
|
309
|
+
has one, otherwise its region data.
|
|
310
|
+
|
|
311
|
+
:param tld: The TLD suffix that follows ``amazon.`` (e.g. ``co.uk``).
|
|
312
|
+
:return: The currency symbol, or ``None`` if the site's isn't known.
|
|
313
|
+
"""
|
|
314
|
+
site = REGION_SITES.get(tld)
|
|
315
|
+
return cls.REGION_CURRENCY_OVERRIDES.get(tld) or (site.currency_symbol if site else None)
|
|
228
316
|
|
|
229
317
|
def _apply_browser(self,
|
|
230
318
|
browser: str) -> None:
|
|
@@ -232,14 +320,14 @@ class Constants:
|
|
|
232
320
|
Apply browser-specific header overrides for the given browser engine. A header a subclass changed from
|
|
233
321
|
the default keeps its value.
|
|
234
322
|
|
|
235
|
-
:param browser: The browser that signs in, e.g. ``"chrome"``, ``"msedge"``, ``"firefox"``. A browser
|
|
236
|
-
|
|
323
|
+
:param browser: The browser that signs in, e.g. ``"chrome"``, ``"msedge"``, ``"firefox"``. A browser not in
|
|
324
|
+
``BROWSER_HEADERS``, like Chrome or Edge, is Chromium-based, so ``BASE_HEADERS`` is left unchanged.
|
|
237
325
|
"""
|
|
238
|
-
|
|
239
|
-
if
|
|
326
|
+
browser_headers = self.BROWSER_HEADERS.get(browser)
|
|
327
|
+
if browser_headers is None:
|
|
240
328
|
return
|
|
241
329
|
headers = dict(type(self).BASE_HEADERS)
|
|
242
|
-
for key, value in
|
|
330
|
+
for key, value in browser_headers.items():
|
|
243
331
|
if headers.get(key) != Constants.BASE_HEADERS.get(key):
|
|
244
332
|
continue
|
|
245
333
|
if value is None:
|
|
@@ -294,13 +382,13 @@ class Constants:
|
|
|
294
382
|
headers["Accept-Language"] = self._english_accept_language(site.english_locale, headers["Accept-Language"])
|
|
295
383
|
self.BASE_HEADERS = headers
|
|
296
384
|
|
|
297
|
-
currency_symbol =
|
|
385
|
+
currency_symbol = self.region_currency_symbol(tld)
|
|
298
386
|
if currency_symbol and type(self).CURRENCY_SYMBOL == Constants.CURRENCY_SYMBOL:
|
|
299
387
|
self.CURRENCY_SYMBOL = currency_symbol
|
|
300
388
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
self.COOKIES_SET_WHEN_AUTHENTICATED = [
|
|
389
|
+
auth_cookie = self.region_auth_cookie(tld)
|
|
390
|
+
if auth_cookie and type(self).COOKIES_SET_WHEN_AUTHENTICATED == Constants.COOKIES_SET_WHEN_AUTHENTICATED:
|
|
391
|
+
self.COOKIES_SET_WHEN_AUTHENTICATED = [auth_cookie]
|
|
304
392
|
|
|
305
393
|
def _apply_language_cookie(self,
|
|
306
394
|
tld: str) -> None:
|
|
@@ -344,97 +432,10 @@ class Constants:
|
|
|
344
432
|
return f"https://{value}"
|
|
345
433
|
return f"https://www.{value}"
|
|
346
434
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
""
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
:param amount: The amount to format.
|
|
356
|
-
:return: The formatted amount.
|
|
357
|
-
"""
|
|
358
|
-
decimals = 0 if self.CURRENCY_SYMBOL in _ZERO_DECIMAL_CURRENCY_SYMBOLS else 2
|
|
359
|
-
integer, _, fraction = "{amount:,.{decimals}f}".format(amount=abs(amount), decimals=decimals).partition(".")
|
|
360
|
-
formatted_number = integer.replace(",", self.THOUSANDS_SEPARATOR)
|
|
361
|
-
if fraction:
|
|
362
|
-
formatted_number += f"{self.DECIMAL_SEPARATOR}{fraction}"
|
|
363
|
-
formatted_amt = self.CURRENCY_FORMAT.format(symbol=self.CURRENCY_SYMBOL, amount=formatted_number)
|
|
364
|
-
if round(amount, decimals) < 0:
|
|
365
|
-
return f"-{formatted_amt}"
|
|
366
|
-
return formatted_amt
|
|
367
|
-
|
|
368
|
-
def parse_currency(self,
|
|
369
|
-
value: Union[str, int, float]) -> Union[int, float, None]:
|
|
370
|
-
"""
|
|
371
|
-
Parse a currency amount as rendered on the page, stripping non-numeric values and returning it
|
|
372
|
-
as a primitive.
|
|
373
|
-
|
|
374
|
-
Strips any currency symbol (e.g. ``$``, ``€``, or the fullwidth ``¥`` used by amazon.co.jp) and
|
|
375
|
-
currency-code letters (e.g. ``CDN$``, ``AED``, or ``zł``), accepts accounting-style negatives in
|
|
376
|
-
parentheses (e.g. ``($1.99)``), and treats :attr:`CURRENCY_FREE_TEXT` as ``0.0``. Either decimal mark
|
|
377
|
-
is accepted: a trailing ``.`` or ``,`` followed by one or two digits is the decimal mark, and other
|
|
378
|
-
``.``, ``,``, ``'``, and space separators group thousands (e.g. ``1,234.56``, ``1.234,56 €``, or
|
|
379
|
-
``12,99 €``).
|
|
380
|
-
|
|
381
|
-
:param value: The currency to parse.
|
|
382
|
-
:return: The currency as a primitive, or ``None`` if it could not be parsed.
|
|
383
|
-
"""
|
|
384
|
-
if isinstance(value, (int, float)):
|
|
385
|
-
return value
|
|
386
|
-
|
|
387
|
-
if not value:
|
|
388
|
-
return None
|
|
389
|
-
|
|
390
|
-
value = value.strip()
|
|
391
|
-
|
|
392
|
-
if value.casefold() == self.CURRENCY_FREE_TEXT.casefold():
|
|
393
|
-
return 0.0
|
|
394
|
-
|
|
395
|
-
if value.startswith("(") and value.endswith(")"):
|
|
396
|
-
value = "-" + value[1:-1]
|
|
397
|
-
|
|
398
|
-
value = util.strip_currency_text(value.replace("\u2212", "-"))
|
|
399
|
-
currency = util.to_type(util.to_decimal_point(value))
|
|
400
|
-
|
|
401
|
-
if isinstance(currency, str):
|
|
402
|
-
return None
|
|
403
|
-
|
|
404
|
-
return currency
|
|
405
|
-
|
|
406
|
-
def parse_date(self,
|
|
407
|
-
value: Optional[str],
|
|
408
|
-
fuzzy: bool = False) -> Optional[date]:
|
|
409
|
-
"""
|
|
410
|
-
Parse a date as rendered on the page. Delegates to :func:`~amazonorders.util.to_date`.
|
|
411
|
-
|
|
412
|
-
:param value: The date string to parse.
|
|
413
|
-
:param fuzzy: Whether to ignore unknown tokens when parsing.
|
|
414
|
-
:return: The parsed ``date``, or ``None`` if it could not be parsed.
|
|
415
|
-
"""
|
|
416
|
-
return util.to_date(value, fuzzy=fuzzy)
|
|
417
|
-
|
|
418
|
-
def parse_count(self,
|
|
419
|
-
value: str,
|
|
420
|
-
pattern: str = r"^\s*{count}") -> Optional[int]:
|
|
421
|
-
"""
|
|
422
|
-
Parse a whole number as rendered on the page. Delegates to :func:`~amazonorders.util.to_count`.
|
|
423
|
-
|
|
424
|
-
:param value: The text containing the number.
|
|
425
|
-
:param pattern: A regex locating the number, with ``{count}`` marking where it appears. Defaults to a
|
|
426
|
-
number at the start of the text.
|
|
427
|
-
:return: The number, or ``None`` if ``pattern`` does not match.
|
|
428
|
-
"""
|
|
429
|
-
return util.to_count(value, pattern)
|
|
430
|
-
|
|
431
|
-
def parse_order_number(self,
|
|
432
|
-
value: str) -> Optional[str]:
|
|
433
|
-
"""
|
|
434
|
-
Find an Order number, as matched by :attr:`ORDER_NUMBER_REGEX`, in text from the page.
|
|
435
|
-
|
|
436
|
-
:param value: The text containing the Order number.
|
|
437
|
-
:return: The Order number, or ``None`` if the text does not contain one.
|
|
438
|
-
"""
|
|
439
|
-
match = re.search(self.ORDER_NUMBER_REGEX, value)
|
|
440
|
-
return match.group(0) if match else None
|
|
435
|
+
@staticmethod
|
|
436
|
+
def _deprecated_base_url() -> Optional[str]:
|
|
437
|
+
base_url = os.environ.get("AMAZON_BASE_URL")
|
|
438
|
+
if base_url:
|
|
439
|
+
logger.warning("AMAZON_BASE_URL is deprecated and will be removed in the next major release. Use "
|
|
440
|
+
"AMAZON_DOMAIN instead.")
|
|
441
|
+
return base_url
|
|
@@ -36,6 +36,7 @@ logger = logging.getLogger(__name__)
|
|
|
36
36
|
BODY_TEXT_LENGTH_JS = "() => (document.body && document.body.innerText || '').length"
|
|
37
37
|
SIGN_IN_POLL_INTERVAL_MS = 2000
|
|
38
38
|
SIGN_IN_SETTLE_MS = 2000
|
|
39
|
+
SIGN_IN_PREFILL_TIMEOUT_MS = 5000
|
|
39
40
|
|
|
40
41
|
|
|
41
42
|
class PlaywrightBrowserLauncher:
|
|
@@ -488,7 +489,7 @@ class PlaywrightAcicForm(PlaywrightAuthForm):
|
|
|
488
489
|
return False
|
|
489
490
|
|
|
490
491
|
try:
|
|
491
|
-
token = waf_form.
|
|
492
|
+
token = waf_form.solve_token(page.url, goku, challenge_script)
|
|
492
493
|
except AmazonOrdersWafSolverError:
|
|
493
494
|
raise
|
|
494
495
|
except Exception:
|
|
@@ -501,7 +502,7 @@ class PlaywrightAcicForm(PlaywrightAuthForm):
|
|
|
501
502
|
|
|
502
503
|
domain = urlparse(page.url).netloc
|
|
503
504
|
context.add_cookies([{
|
|
504
|
-
"name":
|
|
505
|
+
"name": self.config.constants.WAF_TOKEN_COOKIE,
|
|
505
506
|
"value": token,
|
|
506
507
|
"domain": f".{domain}",
|
|
507
508
|
"path": "/",
|
|
@@ -575,7 +576,7 @@ class PlaywrightAcicForm(PlaywrightAuthForm):
|
|
|
575
576
|
return False
|
|
576
577
|
|
|
577
578
|
try:
|
|
578
|
-
answer = waf_form.
|
|
579
|
+
answer = waf_form.solve_visual_captcha(page.url, challenge_images, question)
|
|
579
580
|
except AmazonOrdersWafSolverError:
|
|
580
581
|
raise
|
|
581
582
|
except Exception:
|
|
@@ -825,6 +826,7 @@ class PlaywrightBrowserLogin:
|
|
|
825
826
|
context = browser.new_context(user_agent=constants.BASE_HEADERS.get("User-Agent"))
|
|
826
827
|
page = context.new_page()
|
|
827
828
|
page.goto(sign_in_url)
|
|
829
|
+
self._prefill_username(page)
|
|
828
830
|
|
|
829
831
|
message = ("Info: A browser window has opened. Sign in to Amazon there, and this will continue "
|
|
830
832
|
"once you're signed in.")
|
|
@@ -841,6 +843,28 @@ class PlaywrightBrowserLogin:
|
|
|
841
843
|
|
|
842
844
|
self._verify_session()
|
|
843
845
|
|
|
846
|
+
def _prefill_username(self,
|
|
847
|
+
page: Any) -> None:
|
|
848
|
+
"""
|
|
849
|
+
Best effort: if a username is configured, fill it into Amazon's email field and submit it, so the browser
|
|
850
|
+
opens on the password or passkey step. Skipped silently when there's no username, the field is missing or
|
|
851
|
+
already filled, or anything about it fails, since the sign-in then continues by hand as usual.
|
|
852
|
+
"""
|
|
853
|
+
username = self.amazon_session.username
|
|
854
|
+
if not username:
|
|
855
|
+
return
|
|
856
|
+
|
|
857
|
+
try:
|
|
858
|
+
email_input = page.locator(self.config.selectors.SIGN_IN_EMAIL_INPUT_SELECTOR).first
|
|
859
|
+
email_input.wait_for(state="visible", timeout=SIGN_IN_PREFILL_TIMEOUT_MS)
|
|
860
|
+
if email_input.input_value():
|
|
861
|
+
return
|
|
862
|
+
|
|
863
|
+
email_input.fill(username)
|
|
864
|
+
email_input.press("Enter")
|
|
865
|
+
except Exception as e:
|
|
866
|
+
logger.debug(f"Skipped pre-filling the username in the browser: {e}")
|
|
867
|
+
|
|
844
868
|
def _require_display(self) -> None:
|
|
845
869
|
if (not self.headless and sys.platform.startswith("linux") and
|
|
846
870
|
not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))):
|
|
@@ -20,10 +20,10 @@ class AntiCaptchaWafForm(AwsWafForm):
|
|
|
20
20
|
API_KEY_ENV_VAR = "ANTICAPTCHA_API_KEY"
|
|
21
21
|
PROVIDER_NAME = "Anti-Captcha"
|
|
22
22
|
|
|
23
|
-
def
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
23
|
+
def solve_token(self,
|
|
24
|
+
url: str,
|
|
25
|
+
goku: Dict[str, Any],
|
|
26
|
+
challenge_script: str) -> str:
|
|
27
27
|
"""
|
|
28
28
|
Solve the AWS WAF challenge via Anti-Captcha's ``AmazonTaskProxyless``
|
|
29
29
|
task type and return the ``aws-waf-token`` cookie value.
|
|
@@ -5,6 +5,7 @@ import json
|
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
7
|
import re
|
|
8
|
+
import warnings
|
|
8
9
|
from typing import Any, ClassVar, Dict, Optional, TYPE_CHECKING
|
|
9
10
|
from urllib.parse import urlparse
|
|
10
11
|
|
|
@@ -25,7 +26,7 @@ logger = logging.getLogger(__name__)
|
|
|
25
26
|
class AwsWafForm(AuthForm):
|
|
26
27
|
"""
|
|
27
28
|
Shared base for AWS WAF JavaScript challenge solvers. Subclasses implement
|
|
28
|
-
:func:`
|
|
29
|
+
:func:`solve_token` to call a third-party solver and return the
|
|
29
30
|
``aws-waf-token`` cookie value.
|
|
30
31
|
|
|
31
32
|
This base class handles detection (the ``window.gokuProps`` blob plus the
|
|
@@ -101,7 +102,7 @@ class AwsWafForm(AuthForm):
|
|
|
101
102
|
def submit(self,
|
|
102
103
|
last_response: Response) -> AmazonSessionResponse:
|
|
103
104
|
"""
|
|
104
|
-
Hand the WAF challenge off to :func:`
|
|
105
|
+
Hand the WAF challenge off to :func:`solve_token`, set the resulting
|
|
105
106
|
``aws-waf-token`` cookie on the session, and re-fetch the challenged URL.
|
|
106
107
|
|
|
107
108
|
:param last_response: The response that returned the WAF challenge page.
|
|
@@ -114,7 +115,7 @@ class AwsWafForm(AuthForm):
|
|
|
114
115
|
"Call AwsWafForm.select_form() first."
|
|
115
116
|
) # pragma: no cover
|
|
116
117
|
|
|
117
|
-
token = self.
|
|
118
|
+
token = self.solve_token(last_response.url, self._goku, self._challenge_script)
|
|
118
119
|
|
|
119
120
|
# Always surface that a paid third-party service was used, regardless of debug mode.
|
|
120
121
|
message = (
|
|
@@ -124,14 +125,14 @@ class AwsWafForm(AuthForm):
|
|
|
124
125
|
self.amazon_session.io.echo(message)
|
|
125
126
|
|
|
126
127
|
domain = urlparse(self.config.constants.BASE_URL).netloc
|
|
127
|
-
self.amazon_session.session.cookies.set(
|
|
128
|
+
self.amazon_session.session.cookies.set(self.config.constants.WAF_TOKEN_COOKIE, token, domain=domain)
|
|
128
129
|
|
|
129
130
|
return self.amazon_session.get(last_response.url)
|
|
130
131
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
132
|
+
def solve_token(self,
|
|
133
|
+
url: str,
|
|
134
|
+
goku: Dict[str, Any],
|
|
135
|
+
challenge_script: str) -> str:
|
|
135
136
|
"""
|
|
136
137
|
Subclass hook. Call the third-party solver with the supplied challenge
|
|
137
138
|
parameters and return the resulting ``aws-waf-token`` cookie value to be
|
|
@@ -145,12 +146,18 @@ class AwsWafForm(AuthForm):
|
|
|
145
146
|
:return: The ``aws-waf-token`` cookie value.
|
|
146
147
|
:raises NotImplementedError: if a subclass does not override this method.
|
|
147
148
|
"""
|
|
149
|
+
if type(self)._solve_token is not AwsWafForm._solve_token:
|
|
150
|
+
warnings.warn(f"{type(self).__name__} overrides _solve_token(), which is deprecated and "
|
|
151
|
+
f"will be removed in the next major release. Override solve_token() instead.",
|
|
152
|
+
DeprecationWarning, stacklevel=2)
|
|
153
|
+
return self._solve_token(url, goku, challenge_script)
|
|
154
|
+
|
|
148
155
|
raise NotImplementedError
|
|
149
156
|
|
|
150
|
-
def
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
157
|
+
def solve_visual_captcha(self,
|
|
158
|
+
url: str,
|
|
159
|
+
image_data: list,
|
|
160
|
+
question: str) -> Optional[list]:
|
|
154
161
|
"""
|
|
155
162
|
Subclass hook. Solve a visual grid Puzzle (e.g. "Choose all the buckets")
|
|
156
163
|
and return the indices of the correct grid cells.
|
|
@@ -161,4 +168,32 @@ class AwsWafForm(AuthForm):
|
|
|
161
168
|
:return: A list of zero-based grid cell indices to select, or ``None`` if
|
|
162
169
|
this solver does not support Puzzle classification.
|
|
163
170
|
"""
|
|
171
|
+
if type(self)._solve_visual_captcha is not AwsWafForm._solve_visual_captcha:
|
|
172
|
+
warnings.warn(f"{type(self).__name__} overrides _solve_visual_captcha(), which is deprecated and "
|
|
173
|
+
f"will be removed in the next major release. Override solve_visual_captcha() instead.",
|
|
174
|
+
DeprecationWarning, stacklevel=2)
|
|
175
|
+
return self._solve_visual_captcha(url, image_data, question)
|
|
176
|
+
|
|
164
177
|
return None
|
|
178
|
+
|
|
179
|
+
def _solve_token(self,
|
|
180
|
+
url: str,
|
|
181
|
+
goku: Dict[str, Any],
|
|
182
|
+
challenge_script: str) -> str:
|
|
183
|
+
"""
|
|
184
|
+
Deprecated, and will be removed in the next major release. Use :func:`solve_token` instead.
|
|
185
|
+
"""
|
|
186
|
+
warnings.warn("AwsWafForm._solve_token() is deprecated and will be removed in the next major release. Use "
|
|
187
|
+
"solve_token() instead.", DeprecationWarning, stacklevel=2)
|
|
188
|
+
return self.solve_token(url, goku, challenge_script)
|
|
189
|
+
|
|
190
|
+
def _solve_visual_captcha(self,
|
|
191
|
+
url: str,
|
|
192
|
+
image_data: list,
|
|
193
|
+
question: str) -> Optional[list]:
|
|
194
|
+
"""
|
|
195
|
+
Deprecated, and will be removed in the next major release. Use :func:`solve_visual_captcha` instead.
|
|
196
|
+
"""
|
|
197
|
+
warnings.warn("AwsWafForm._solve_visual_captcha() is deprecated and will be removed in the next major "
|
|
198
|
+
"release. Use solve_visual_captcha() instead.", DeprecationWarning, stacklevel=2)
|
|
199
|
+
return self.solve_visual_captcha(url, image_data, question)
|
|
@@ -19,10 +19,10 @@ class CapSolverWafForm(AwsWafForm):
|
|
|
19
19
|
API_KEY_ENV_VAR = "CAPSOLVER_API_KEY"
|
|
20
20
|
PROVIDER_NAME = "CapSolver"
|
|
21
21
|
|
|
22
|
-
def
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
22
|
+
def solve_token(self,
|
|
23
|
+
url: str,
|
|
24
|
+
goku: Dict[str, Any],
|
|
25
|
+
challenge_script: str) -> str:
|
|
26
26
|
"""
|
|
27
27
|
Solve the AWS WAF challenge via CapSolver's ``AntiAwsWafTaskProxyLess``
|
|
28
28
|
task type and return the ``aws-waf-token`` cookie value.
|
|
@@ -68,10 +68,10 @@ class CapSolverWafForm(AwsWafForm):
|
|
|
68
68
|
meta={"provider": self.PROVIDER_NAME, "provider_error": repr(solution)}
|
|
69
69
|
) from e
|
|
70
70
|
|
|
71
|
-
def
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
71
|
+
def solve_visual_captcha(self,
|
|
72
|
+
url: str,
|
|
73
|
+
image_data: List[str],
|
|
74
|
+
question: str) -> Optional[List[int]]:
|
|
75
75
|
"""
|
|
76
76
|
Solve a visual grid Puzzle via CapSolver's ``AwsWafClassification`` task
|
|
77
77
|
type and return the indices of the correct grid cells.
|
|
@@ -21,10 +21,10 @@ class TwoCaptchaWafForm(AwsWafForm):
|
|
|
21
21
|
API_KEY_ENV_VAR = "TWOCAPTCHA_API_KEY"
|
|
22
22
|
PROVIDER_NAME = "2Captcha"
|
|
23
23
|
|
|
24
|
-
def
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
def solve_token(self,
|
|
25
|
+
url: str,
|
|
26
|
+
goku: Dict[str, Any],
|
|
27
|
+
challenge_script: str) -> str:
|
|
28
28
|
"""
|
|
29
29
|
Solve the AWS WAF challenge via 2Captcha's ``amazon_waf`` method and
|
|
30
30
|
return the ``aws-waf-token`` cookie value (extracted from the
|
|
@@ -193,7 +193,8 @@ class Order(Parsable):
|
|
|
193
193
|
value = self.simple_parse(self.config.selectors.FIELD_ORDER_DETAILS_LINK_SELECTOR, attr_name="href")
|
|
194
194
|
|
|
195
195
|
if not value and self.order_number:
|
|
196
|
-
|
|
196
|
+
constants = self.config.constants
|
|
197
|
+
value = f"{constants.ORDER_DETAILS_URL}?{constants.ORDER_ID_QUERY_PARAM}={self.order_number}"
|
|
197
198
|
|
|
198
199
|
return value
|
|
199
200
|
|
|
@@ -152,7 +152,8 @@ class Transaction(Parsable):
|
|
|
152
152
|
if not self.order_number:
|
|
153
153
|
return None
|
|
154
154
|
|
|
155
|
-
|
|
155
|
+
constants = self.config.constants
|
|
156
|
+
return f"{constants.ORDER_DETAILS_URL}?{constants.ORDER_ID_QUERY_PARAM}={self.order_number}"
|
|
156
157
|
|
|
157
158
|
def _parse_payment_method_last_4(self) -> Optional[str]:
|
|
158
159
|
if not self.payment_method:
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
__copyright__ = "Copyright (c) 2024-2025 Alex Laird"
|
|
2
2
|
__license__ = "MIT"
|
|
3
3
|
|
|
4
|
+
import importlib.util
|
|
4
5
|
import re
|
|
5
6
|
from abc import ABC
|
|
6
7
|
from io import BytesIO
|
|
7
|
-
from typing import Any, Dict, Optional, TYPE_CHECKING, Union
|
|
8
|
+
from typing import Any, Dict, NoReturn, Optional, TYPE_CHECKING, Union
|
|
8
9
|
from urllib.parse import urlparse
|
|
9
10
|
|
|
10
11
|
import pyotp
|
|
@@ -226,8 +227,7 @@ class SignInForm(AuthForm):
|
|
|
226
227
|
) # pragma: no cover
|
|
227
228
|
|
|
228
229
|
additional_attrs.update({self.solution_attr_key: self.amazon_session.username,
|
|
229
|
-
"password": self.amazon_session.password
|
|
230
|
-
"rememberMe": "true"})
|
|
230
|
+
"password": self.amazon_session.password})
|
|
231
231
|
self.data.update(additional_attrs)
|
|
232
232
|
|
|
233
233
|
|
|
@@ -260,8 +260,7 @@ class ClaimForm(AuthForm):
|
|
|
260
260
|
) # pragma: no cover
|
|
261
261
|
|
|
262
262
|
additional_attrs.update({self.solution_attr_key: self.amazon_session.username,
|
|
263
|
-
"password": self.amazon_session.password
|
|
264
|
-
"rememberMe": "true"})
|
|
263
|
+
"password": self.amazon_session.password})
|
|
265
264
|
self.data.update(additional_attrs)
|
|
266
265
|
|
|
267
266
|
|
|
@@ -464,6 +463,21 @@ class CaptchaForm(AuthForm):
|
|
|
464
463
|
self.data.update(additional_attrs)
|
|
465
464
|
|
|
466
465
|
|
|
466
|
+
def _raise_js_challenge_error() -> NoReturn:
|
|
467
|
+
if importlib.util.find_spec("playwright"):
|
|
468
|
+
raise AmazonOrdersAuthError(
|
|
469
|
+
"Amazon returned a JavaScript-based authentication challenge. Add "
|
|
470
|
+
"amazonorders.contrib.browser.playwright.PlaywrightAcicForm, then "
|
|
471
|
+
"amazonorders.contrib.browser.playwright.PlaywrightJSAuthForm, to auth_forms_classes to handle this "
|
|
472
|
+
"automatically, or sign in with `amazon-orders login --browser`. "
|
|
473
|
+
"See https://amazon-orders.readthedocs.io/browser.html for details.")
|
|
474
|
+
|
|
475
|
+
raise AmazonOrdersAuthError(
|
|
476
|
+
"Amazon returned a JavaScript-based authentication challenge. Install the [browser] extra to "
|
|
477
|
+
"handle this automatically: `pip install amazon-orders[browser]`. "
|
|
478
|
+
"See https://amazon-orders.readthedocs.io/browser.html for details.")
|
|
479
|
+
|
|
480
|
+
|
|
467
481
|
class AcicAuthBlocker(AuthForm):
|
|
468
482
|
def __init__(self,
|
|
469
483
|
config: AmazonOrdersConfig) -> None:
|
|
@@ -473,10 +487,7 @@ class AcicAuthBlocker(AuthForm):
|
|
|
473
487
|
amazon_session: "AmazonSession",
|
|
474
488
|
parsed: Tag) -> bool:
|
|
475
489
|
if parsed.find(id="aa-challenge-page-captcha-container"):
|
|
476
|
-
|
|
477
|
-
"Amazon returned a JavaScript-based authentication challenge. Install the [browser] extra to "
|
|
478
|
-
"handle this automatically: `pip install amazon-orders[browser]`. "
|
|
479
|
-
"See https://amazon-orders.readthedocs.io/browser.html for details.")
|
|
490
|
+
_raise_js_challenge_error()
|
|
480
491
|
|
|
481
492
|
return False
|
|
482
493
|
|
|
@@ -496,9 +507,6 @@ class JSAuthBlocker(AuthForm):
|
|
|
496
507
|
raise AmazonOrdersError("Must set a regex first.") # pragma: no cover
|
|
497
508
|
|
|
498
509
|
if re.search(self.regex, parsed.text):
|
|
499
|
-
|
|
500
|
-
"Amazon returned a JavaScript-based authentication challenge. Install the [browser] extra to "
|
|
501
|
-
"handle this automatically: `pip install amazon-orders[browser]`. "
|
|
502
|
-
"See https://amazon-orders.readthedocs.io/browser.html for details.")
|
|
510
|
+
_raise_js_challenge_error()
|
|
503
511
|
|
|
504
512
|
return False
|
|
@@ -112,7 +112,7 @@ class AmazonOrders:
|
|
|
112
112
|
meta = {"index": clone.index} if clone else None
|
|
113
113
|
|
|
114
114
|
order_details_response = self.amazon_session.get(
|
|
115
|
-
f"{self.config.constants.ORDER_DETAILS_URL}?
|
|
115
|
+
f"{self.config.constants.ORDER_DETAILS_URL}?{self.config.constants.ORDER_ID_QUERY_PARAM}={order_id}")
|
|
116
116
|
self.amazon_session.check_response(order_details_response, meta=meta)
|
|
117
117
|
|
|
118
118
|
response_url = order_details_response.response.url
|
|
@@ -233,7 +233,7 @@ class AmazonOrders:
|
|
|
233
233
|
raise AmazonOrdersError("Call AmazonSession.login() to authenticate first.")
|
|
234
234
|
|
|
235
235
|
invoice_response = self.amazon_session.get(
|
|
236
|
-
f"{self.config.constants.ORDER_INVOICE_URL}?
|
|
236
|
+
f"{self.config.constants.ORDER_INVOICE_URL}?{self.config.constants.ORDER_ID_QUERY_PARAM}={order_id}")
|
|
237
237
|
self.amazon_session.check_response(invoice_response)
|
|
238
238
|
|
|
239
239
|
response_url = invoice_response.response.url
|
|
@@ -353,8 +353,8 @@ class AmazonOrders:
|
|
|
353
353
|
|
|
354
354
|
return PagedResults(orders, next_cursor)
|
|
355
355
|
|
|
356
|
-
|
|
357
|
-
|
|
356
|
+
def _history_filter_value(self,
|
|
357
|
+
year: Optional[int],
|
|
358
358
|
time_filter: Optional[str]) -> str:
|
|
359
359
|
if time_filter and year:
|
|
360
360
|
raise AmazonOrdersError("Only one of 'year' or 'time_filter' may be used at a time.")
|
|
@@ -362,18 +362,20 @@ class AmazonOrders:
|
|
|
362
362
|
# Determine the filter value to use
|
|
363
363
|
if time_filter:
|
|
364
364
|
# Validate time_filter value
|
|
365
|
-
|
|
366
|
-
|
|
365
|
+
constants = self.config.constants
|
|
366
|
+
valid_filters = [constants.LAST_30_DAYS_TIME_FILTER, constants.LAST_3_MONTHS_TIME_FILTER]
|
|
367
|
+
year_prefix = constants.YEAR_TIME_FILTER_PREFIX
|
|
368
|
+
is_year_filter = time_filter.startswith(year_prefix) and time_filter[len(year_prefix):].isdigit()
|
|
367
369
|
if time_filter not in valid_filters and not is_year_filter:
|
|
368
370
|
raise AmazonOrdersError(
|
|
369
371
|
f"Invalid time_filter '{time_filter}'. "
|
|
370
|
-
f"Valid values are '
|
|
372
|
+
f"Valid values are '{valid_filters[0]}', '{valid_filters[1]}', or '{year_prefix}YYYY'."
|
|
371
373
|
)
|
|
372
374
|
filter_value = time_filter
|
|
373
375
|
else:
|
|
374
376
|
if year is None:
|
|
375
377
|
year = datetime.date.today().year
|
|
376
|
-
filter_value = f"
|
|
378
|
+
filter_value = f"{self.config.constants.YEAR_TIME_FILTER_PREFIX}{year}"
|
|
377
379
|
|
|
378
380
|
return filter_value
|
|
379
381
|
|
|
@@ -95,6 +95,7 @@ class Selectors:
|
|
|
95
95
|
AWS_WAF_CHALLENGE_SCRIPT_SELECTOR = 'script[src*="awswaf.com"]'
|
|
96
96
|
|
|
97
97
|
SIGN_IN_FORM_SELECTOR = "form[name='signIn']"
|
|
98
|
+
SIGN_IN_EMAIL_INPUT_SELECTOR = "input[name='email']"
|
|
98
99
|
CLAIM_FORM_SELECTOR = "form[name='signIn'].auth-validate-form"
|
|
99
100
|
INTENT_FORM_SELECTOR = "form#intent-confirmation-form"
|
|
100
101
|
INTENT_MESSAGE_SELECTOR = "div#intent-confirmation-container"
|
|
@@ -4,8 +4,9 @@ __license__ = "MIT"
|
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
6
|
import os
|
|
7
|
+
import tempfile
|
|
7
8
|
import time
|
|
8
|
-
from typing import Any, List, Optional,
|
|
9
|
+
from typing import Any, Dict, List, Optional, Union
|
|
9
10
|
from urllib.parse import urlencode, urlparse
|
|
10
11
|
|
|
11
12
|
import requests
|
|
@@ -136,15 +137,23 @@ class AmazonSession:
|
|
|
136
137
|
#: If :func:`login` has been executed and successfully logged in the session.
|
|
137
138
|
self.is_authenticated: bool = False
|
|
138
139
|
self._sign_in_required: bool = False
|
|
140
|
+
self._cookie_jar_mtime: Optional[float] = None
|
|
139
141
|
|
|
140
142
|
with cookies_file_lock:
|
|
141
143
|
os.makedirs(os.path.dirname(self.config.cookie_jar_path), exist_ok=True)
|
|
142
144
|
|
|
143
145
|
if os.path.exists(self.config.cookie_jar_path):
|
|
144
146
|
with open(self.config.cookie_jar_path, "r", encoding="utf-8") as f:
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
147
|
+
self._cookie_jar_mtime = os.fstat(f.fileno()).st_mtime
|
|
148
|
+
self._load_cookie_jar_data(json.loads(f.read()))
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def cookie_jar_mtime(self) -> Optional[float]:
|
|
152
|
+
"""
|
|
153
|
+
The modification time of the persisted cookie jar when this session last loaded or saved it, or ``None`` if
|
|
154
|
+
there was no jar then. If the file's modification time no longer matches, another process has saved it since.
|
|
155
|
+
"""
|
|
156
|
+
return self._cookie_jar_mtime
|
|
148
157
|
|
|
149
158
|
@staticmethod
|
|
150
159
|
def default_auth_forms(config: AmazonOrdersConfig) -> List[AuthForm]:
|
|
@@ -176,14 +185,16 @@ class AmazonSession:
|
|
|
176
185
|
def request(self,
|
|
177
186
|
method: str,
|
|
178
187
|
url: str,
|
|
179
|
-
persist_cookies: bool =
|
|
188
|
+
persist_cookies: Optional[bool] = None,
|
|
180
189
|
**kwargs: Any) -> AmazonSessionResponse:
|
|
181
190
|
"""
|
|
182
191
|
Execute the request against Amazon with base headers, parsing and storing the response.
|
|
183
192
|
|
|
184
193
|
:param method: The request method to execute.
|
|
185
194
|
:param url: The URL to execute ``method`` on.
|
|
186
|
-
:param persist_cookies: If ``True``, cookies from the response will be persisted to a file
|
|
195
|
+
:param persist_cookies: If ``True``, cookies from the response will be persisted to a file, and if ``False``,
|
|
196
|
+
they won't be. By default, they're persisted when the response changed them, unless another process has
|
|
197
|
+
saved the file since this session did, so a stale session can't overwrite a newer sign-in.
|
|
187
198
|
:param kwargs: Remaining ``kwargs`` will be passed to :func:`requests.request`.
|
|
188
199
|
:return: The response from the executed request.
|
|
189
200
|
"""
|
|
@@ -201,6 +212,7 @@ class AmazonSession:
|
|
|
201
212
|
kwargs.setdefault("timeout", self.config.request_timeout)
|
|
202
213
|
|
|
203
214
|
self._set_request_cookies()
|
|
215
|
+
cookie_jar_data = self._cookie_jar_data()
|
|
204
216
|
|
|
205
217
|
try:
|
|
206
218
|
response = self.session.request(method, url, **kwargs)
|
|
@@ -213,10 +225,11 @@ class AmazonSession:
|
|
|
213
225
|
amazon_session_response = AmazonSessionResponse(response,
|
|
214
226
|
self.config.bs4_parser)
|
|
215
227
|
|
|
228
|
+
if persist_cookies is None:
|
|
229
|
+
persist_cookies = self._cookie_jar_data() != cookie_jar_data and self._cookie_jar_unchanged_on_disk()
|
|
216
230
|
if persist_cookies:
|
|
217
|
-
cookies = dict_from_cookiejar(self.session.cookies)
|
|
218
231
|
with cookies_file_lock:
|
|
219
|
-
self._write_cookie_jar(
|
|
232
|
+
self._write_cookie_jar()
|
|
220
233
|
|
|
221
234
|
if self.debug:
|
|
222
235
|
url_str = ""
|
|
@@ -346,22 +359,48 @@ class AmazonSession:
|
|
|
346
359
|
self.session.close()
|
|
347
360
|
self.session = self._create_session()
|
|
348
361
|
|
|
349
|
-
# Ensure authentication cookies are unset, since we can get inconsistent persistence behavior otherwise
|
|
350
362
|
with cookies_file_lock:
|
|
351
|
-
|
|
352
|
-
for cookie in self.config.constants.COOKIES_SET_WHEN_AUTHENTICATED:
|
|
353
|
-
cookies.pop(cookie, None)
|
|
354
|
-
self._write_cookie_jar(cookies)
|
|
363
|
+
self._write_cookie_jar()
|
|
355
364
|
|
|
356
365
|
self.is_authenticated = False
|
|
357
366
|
self._sign_in_required = False
|
|
358
367
|
|
|
359
|
-
def
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
368
|
+
def _cookie_jar_data(self) -> List[Dict[str, Any]]:
|
|
369
|
+
return [{"name": cookie.name, "value": cookie.value, "domain": cookie.domain, "path": cookie.path,
|
|
370
|
+
"secure": cookie.secure, "expires": cookie.expires} for cookie in self.session.cookies]
|
|
371
|
+
|
|
372
|
+
def _load_cookie_jar_data(self,
|
|
373
|
+
data: Union[Dict[str, str], List[Dict[str, Any]]]) -> None:
|
|
374
|
+
if isinstance(data, dict):
|
|
375
|
+
domain = "." + (urlparse(self.config.constants.BASE_URL).hostname or "").removeprefix("www.")
|
|
376
|
+
data = [{"name": name, "value": value, "domain": domain} for name, value in data.items()]
|
|
377
|
+
|
|
378
|
+
for cookie in data:
|
|
379
|
+
self.session.cookies.set(cookie["name"], cookie["value"], domain=cookie.get("domain", ""),
|
|
380
|
+
path=cookie.get("path", "/"), secure=cookie.get("secure", False),
|
|
381
|
+
expires=cookie.get("expires"))
|
|
382
|
+
|
|
383
|
+
def _write_cookie_jar(self) -> None:
|
|
384
|
+
file_descriptor, temp_path = tempfile.mkstemp(dir=os.path.dirname(self.config.cookie_jar_path),
|
|
385
|
+
prefix=".cookies-", suffix=".tmp")
|
|
386
|
+
try:
|
|
387
|
+
with os.fdopen(file_descriptor, "w", encoding="utf-8") as f:
|
|
388
|
+
f.write(json.dumps(self._cookie_jar_data()))
|
|
389
|
+
os.chmod(temp_path, 0o600)
|
|
390
|
+
os.replace(temp_path, self.config.cookie_jar_path)
|
|
391
|
+
except BaseException:
|
|
392
|
+
os.remove(temp_path)
|
|
393
|
+
raise
|
|
394
|
+
self._cookie_jar_mtime = self._read_cookie_jar_mtime()
|
|
395
|
+
|
|
396
|
+
def _read_cookie_jar_mtime(self) -> Optional[float]:
|
|
397
|
+
try:
|
|
398
|
+
return os.path.getmtime(self.config.cookie_jar_path)
|
|
399
|
+
except OSError:
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
def _cookie_jar_unchanged_on_disk(self) -> bool:
|
|
403
|
+
return self._read_cookie_jar_mtime() == self._cookie_jar_mtime
|
|
365
404
|
|
|
366
405
|
def build_response_error(self,
|
|
367
406
|
response: Response) -> str:
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|