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.
Files changed (48) hide show
  1. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/CHANGELOG.md +16 -1
  2. {amazon_orders-4.9.1/amazon_orders.egg-info → amazon_orders-4.9.2}/PKG-INFO +11 -7
  3. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/README.md +10 -6
  4. {amazon_orders-4.9.1 → amazon_orders-4.9.2/amazon_orders.egg-info}/PKG-INFO +11 -7
  5. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/__init__.py +1 -1
  6. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/cli.py +2 -2
  7. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/constants.py +171 -170
  8. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/browser/playwright.py +27 -3
  9. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/anticaptcha.py +4 -4
  10. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/base.py +47 -12
  11. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/capsolver.py +8 -8
  12. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/twocaptcha.py +4 -4
  13. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/order.py +2 -1
  14. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/transaction.py +2 -1
  15. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/forms.py +21 -13
  16. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/orders.py +10 -8
  17. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/selectors.py +1 -0
  18. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/session.py +58 -19
  19. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/LICENSE +0 -0
  20. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/MANIFEST.in +0 -0
  21. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/SOURCES.txt +0 -0
  22. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/dependency_links.txt +0 -0
  23. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/entry_points.txt +0 -0
  24. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/requires.txt +0 -0
  25. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazon_orders.egg-info/top_level.txt +0 -0
  26. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/banner.txt +0 -0
  27. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/conf.py +0 -0
  28. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/__init__.py +0 -0
  29. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/browser/__init__.py +0 -0
  30. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/contrib/waf/__init__.py +0 -0
  31. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/__init__.py +0 -0
  32. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/gift_card_activity.py +0 -0
  33. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/item.py +0 -0
  34. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/parsable.py +0 -0
  35. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/recipient.py +0 -0
  36. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/seller.py +0 -0
  37. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/shipment.py +0 -0
  38. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/entity/tracking.py +0 -0
  39. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/exception.py +0 -0
  40. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/gift_cards.py +0 -0
  41. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/output.py +0 -0
  42. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/paging.py +0 -0
  43. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/region_data.py +0 -0
  44. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/transactions.py +0 -0
  45. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/amazonorders/util.py +0 -0
  46. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/pyproject.toml +0 -0
  47. {amazon_orders-4.9.1 → amazon_orders-4.9.2}/setup.cfg +0 -0
  48. {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.1...HEAD)
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.1
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="last30") # Last 30 days
129
- orders = amazon_orders.get_order_history(time_filter="months-3") # Past 3 months
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
- To ask an AI assistant about your Amazon orders, use
227
- [`amazon-orders-mcp`](https://github.com/alexdlaird/amazon-orders-mcp#readme), an MCP server built on `amazon-orders`. It
228
- reuses the session `amazon-orders login --browser` saves, so it never sees your credentials. See its README for setup
229
- with Claude and other MCP clients.
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="last30") # Last 30 days
53
- orders = amazon_orders.get_order_history(time_filter="months-3") # Past 3 months
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
- To ask an AI assistant about your Amazon orders, use
151
- [`amazon-orders-mcp`](https://github.com/alexdlaird/amazon-orders-mcp#readme), an MCP server built on `amazon-orders`. It
152
- reuses the session `amazon-orders login --browser` saves, so it never sees your credentials. See its README for setup
153
- with Claude and other MCP clients.
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.1
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="last30") # Last 30 days
129
- orders = amazon_orders.get_order_history(time_filter="months-3") # Past 3 months
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
- To ask an AI assistant about your Amazon orders, use
227
- [`amazon-orders-mcp`](https://github.com/alexdlaird/amazon-orders-mcp#readme), an MCP server built on `amazon-orders`. It
228
- reuses the session `amazon-orders login --browser` saves, so it never sees your credentials. See its README for setup
229
- with Claude and other MCP clients.
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
 
@@ -1,3 +1,3 @@
1
1
  __copyright__ = "Copyright (c) 2024-2025 Alex Laird"
2
2
  __license__ = "MIT"
3
- __version__ = "4.9.1"
3
+ __version__ = "4.9.2"
@@ -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 = "last30"
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 = "months-3"
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
- self._apply_browser(browser or "chromium")
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
- @staticmethod
222
- def _deprecated_base_url() -> Optional[str]:
223
- base_url = os.environ.get("AMAZON_BASE_URL")
224
- if base_url:
225
- logger.warning("AMAZON_BASE_URL is deprecated and will be removed in the next major release. Use "
226
- "AMAZON_DOMAIN instead.")
227
- return base_url
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 without
236
- headers here, like Chrome or Edge, is Chromium-based, so ``BASE_HEADERS`` is left unchanged.
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
- preset = _BROWSER_PRESETS.get(browser)
239
- if preset is None:
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 preset.items():
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 = _region_currency_symbol(tld)
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
- if (tld in _REGION_AUTH_COOKIES and
302
- type(self).COOKIES_SET_WHEN_AUTHENTICATED == Constants.COOKIES_SET_WHEN_AUTHENTICATED):
303
- self.COOKIES_SET_WHEN_AUTHENTICATED = [_REGION_AUTH_COOKIES[tld]]
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
- def format_currency(self,
348
- amount: float) -> str:
349
- """
350
- Format an amount for display, using ``CURRENCY_SYMBOL``, ``DECIMAL_SEPARATOR``,
351
- ``THOUSANDS_SEPARATOR``, and the ``CURRENCY_FORMAT`` template (which places the
352
- ``{symbol}`` and ``{amount}``, e.g. ``"{amount} {symbol}"`` renders ``1.234,56 €``). A
353
- negative amount is prefixed with ``-``.
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._solve_token(page.url, goku, challenge_script)
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": "aws-waf-token",
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._solve_visual_captcha(page.url, challenge_images, question)
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 _solve_token(self,
24
- url: str,
25
- goku: Dict[str, Any],
26
- challenge_script: str) -> str:
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:`_solve_token` to call a third-party solver and return the
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:`_solve_token`, set the resulting
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._solve_token(last_response.url, self._goku, self._challenge_script)
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("aws-waf-token", token, domain=domain)
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 _solve_token(self,
132
- url: str,
133
- goku: Dict[str, Any],
134
- challenge_script: str) -> str:
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 _solve_visual_captcha(self,
151
- url: str,
152
- image_data: list,
153
- question: str) -> Optional[list]:
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 _solve_token(self,
23
- url: str,
24
- goku: Dict[str, Any],
25
- challenge_script: str) -> str:
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 _solve_visual_captcha(self,
72
- url: str,
73
- image_data: List[str],
74
- question: str) -> Optional[List[int]]:
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 _solve_token(self,
25
- url: str,
26
- goku: Dict[str, Any],
27
- challenge_script: str) -> str:
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
- value = f"{self.config.constants.ORDER_DETAILS_URL}?orderID={self.order_number}"
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
- return f"{self.config.constants.ORDER_DETAILS_URL}?orderID={self.order_number}"
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
- raise AmazonOrdersAuthError(
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
- raise AmazonOrdersAuthError(
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}?orderID={order_id}")
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}?orderID={order_id}")
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
- @staticmethod
357
- def _history_filter_value(year: Optional[int],
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
- valid_filters = ["last30", "months-3"]
366
- is_year_filter = time_filter.startswith("year-") and time_filter[5:].isdigit()
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 'last30', 'months-3', or 'year-YYYY'."
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"year-{year}"
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, Dict
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
- data = json.loads(f.read())
146
- cookies = requests.utils.cookiejar_from_dict(data)
147
- self.session.cookies.update(cookies)
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 = False,
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(cookies)
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
- cookies = dict_from_cookiejar(self.session.cookies)
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 _write_cookie_jar(self,
360
- cookies: Dict[str, str]) -> None:
361
- file_descriptor = os.open(self.config.cookie_jar_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
362
- with os.fdopen(file_descriptor, "w", encoding="utf-8") as f:
363
- f.write(json.dumps(cookies))
364
- os.chmod(self.config.cookie_jar_path, 0o600)
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