amazon-orders 4.9.2__tar.gz → 4.9.4__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 (49) hide show
  1. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/CHANGELOG.md +18 -1
  2. {amazon_orders-4.9.2/amazon_orders.egg-info → amazon_orders-4.9.4}/PKG-INFO +9 -4
  3. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/README.md +8 -3
  4. {amazon_orders-4.9.2 → amazon_orders-4.9.4/amazon_orders.egg-info}/PKG-INFO +9 -4
  5. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazon_orders.egg-info/SOURCES.txt +1 -0
  6. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/__init__.py +1 -1
  7. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/cli.py +40 -1
  8. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/constants.py +1 -0
  9. amazon_orders-4.9.4/amazonorders/entity/family_member.py +48 -0
  10. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/order.py +12 -1
  11. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/forms.py +89 -10
  12. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/orders.py +64 -8
  13. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/output.py +2 -2
  14. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/selectors.py +21 -6
  15. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/session.py +5 -6
  16. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/LICENSE +0 -0
  17. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/MANIFEST.in +0 -0
  18. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazon_orders.egg-info/dependency_links.txt +0 -0
  19. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazon_orders.egg-info/entry_points.txt +0 -0
  20. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazon_orders.egg-info/requires.txt +0 -0
  21. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazon_orders.egg-info/top_level.txt +0 -0
  22. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/banner.txt +0 -0
  23. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/conf.py +0 -0
  24. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/contrib/__init__.py +0 -0
  25. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/contrib/browser/__init__.py +0 -0
  26. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/contrib/browser/playwright.py +0 -0
  27. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/contrib/waf/__init__.py +0 -0
  28. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/contrib/waf/anticaptcha.py +0 -0
  29. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/contrib/waf/base.py +0 -0
  30. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/contrib/waf/capsolver.py +0 -0
  31. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/contrib/waf/twocaptcha.py +0 -0
  32. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/__init__.py +0 -0
  33. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/gift_card_activity.py +0 -0
  34. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/item.py +0 -0
  35. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/parsable.py +0 -0
  36. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/recipient.py +0 -0
  37. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/seller.py +0 -0
  38. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/shipment.py +0 -0
  39. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/tracking.py +0 -0
  40. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/entity/transaction.py +0 -0
  41. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/exception.py +0 -0
  42. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/gift_cards.py +0 -0
  43. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/paging.py +0 -0
  44. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/region_data.py +0 -0
  45. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/transactions.py +0 -0
  46. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/amazonorders/util.py +0 -0
  47. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/pyproject.toml +0 -0
  48. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/setup.cfg +0 -0
  49. {amazon_orders-4.9.2 → amazon_orders-4.9.4}/tests/testcase.py +0 -0
@@ -4,7 +4,24 @@ 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.2...HEAD)
7
+ ## [Unreleased](https://github.com/alexdlaird/amazon-orders/compare/4.9.4...HEAD)
8
+
9
+ ## [4.9.4](https://github.com/alexdlaird/amazon-orders/compare/4.9.3...4.9.4) - 2026-10-19
10
+
11
+ ### Added
12
+
13
+ - `FamilyMember` entity, `AmazonOrders.get_family_members()` and `parse_family_members()`, `family_member_id` on `get_order_history()` and `get_order_history_page()`, and the `family-members` CLI command and `history --family-member-id`, for the Orders of Amazon Household members (e.g. linked Teen accounts).
14
+ - Support for Amazon's new passkey auth flow, continuing with the password.
15
+
16
+ ### Fixed
17
+
18
+ - Bug fixes and stability improvements.
19
+
20
+ ## [4.9.3](https://github.com/alexdlaird/amazon-orders/compare/4.9.2...4.9.3) - 2026-10-18
21
+
22
+ ### Fixed
23
+
24
+ - Bug fixes and stability improvements.
8
25
 
9
26
  ## [4.9.2](https://github.com/alexdlaird/amazon-orders/compare/4.9.1...4.9.2) - 2026-10-15
10
27
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-orders
3
- Version: 4.9.2
3
+ Version: 4.9.4
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>
@@ -77,6 +77,7 @@ Dynamic: license-file
77
77
  <p align="center"><img alt="amazon-orders - A Python library (and CLI) for Amazon order history" src="https://amazon-orders.readthedocs.io/_images/logo.png" /></p>
78
78
 
79
79
  [![Version](https://img.shields.io/pypi/v/amazon-orders)](https://pypi.org/project/amazon-orders)
80
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/amazon-orders)](https://pypistats.org/packages/amazon-orders)
80
81
  [![Python Versions](https://img.shields.io/pypi/pyversions/amazon-orders.svg)](https://pypi.org/project/amazon-orders)
81
82
  [![Coverage](https://img.shields.io/codecov/c/github/alexdlaird/amazon-orders)](https://codecov.io/gh/alexdlaird/amazon-orders)
82
83
  [![Build](https://img.shields.io/github/actions/workflow/status/alexdlaird/amazon-orders/build.yml)](https://github.com/alexdlaird/amazon-orders/actions/workflows/build.yml)
@@ -151,9 +152,10 @@ amazon-orders history --last-3-months
151
152
 
152
153
  ### Output Formats
153
154
 
154
- The `history`, `order`, `transactions`, `order-transactions`, and `gift-card-activity` commands accept
155
- `--output`, which renders Orders, Transactions, and Gift Card activity as `text` (the default), `json`,
156
- `yaml`, or `csv`. Progress messages are written to `stderr`, so redirecting `stdout` captures only the data.
155
+ The `history`, `order`, `transactions`, `order-transactions`, `gift-card-activity`, and `family-members` commands
156
+ accept `--output`, which renders Orders, Transactions, Gift Card activity, and Amazon Household members as `text` (the
157
+ default), `json`, `yaml`, or `csv`. Progress messages are written to `stderr`, so redirecting `stdout` captures only
158
+ the data.
157
159
 
158
160
  ```sh
159
161
  amazon-orders history --year 2023 --output json > orders.json
@@ -232,6 +234,9 @@ as shown above. Any MCP client gets the same as tools through
232
234
  [`amazon-orders-mcp`](https://github.com/alexdlaird/amazon-orders-mcp#readme), a server built on `amazon-orders` — see
233
235
  its README for setup.
234
236
 
237
+ The server signs you in with a browser, so it needs a display. Without one, use the CLI or library with
238
+ [Automating Authentication](#automating-authentication).
239
+
235
240
  ## Documentation
236
241
 
237
242
  For more advanced usage, `amazon-orders`'s official documentation is available
@@ -1,6 +1,7 @@
1
1
  <p align="center"><img alt="amazon-orders - A Python library (and CLI) for Amazon order history" src="https://amazon-orders.readthedocs.io/_images/logo.png" /></p>
2
2
 
3
3
  [![Version](https://img.shields.io/pypi/v/amazon-orders)](https://pypi.org/project/amazon-orders)
4
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/amazon-orders)](https://pypistats.org/packages/amazon-orders)
4
5
  [![Python Versions](https://img.shields.io/pypi/pyversions/amazon-orders.svg)](https://pypi.org/project/amazon-orders)
5
6
  [![Coverage](https://img.shields.io/codecov/c/github/alexdlaird/amazon-orders)](https://codecov.io/gh/alexdlaird/amazon-orders)
6
7
  [![Build](https://img.shields.io/github/actions/workflow/status/alexdlaird/amazon-orders/build.yml)](https://github.com/alexdlaird/amazon-orders/actions/workflows/build.yml)
@@ -75,9 +76,10 @@ amazon-orders history --last-3-months
75
76
 
76
77
  ### Output Formats
77
78
 
78
- The `history`, `order`, `transactions`, `order-transactions`, and `gift-card-activity` commands accept
79
- `--output`, which renders Orders, Transactions, and Gift Card activity as `text` (the default), `json`,
80
- `yaml`, or `csv`. Progress messages are written to `stderr`, so redirecting `stdout` captures only the data.
79
+ The `history`, `order`, `transactions`, `order-transactions`, `gift-card-activity`, and `family-members` commands
80
+ accept `--output`, which renders Orders, Transactions, Gift Card activity, and Amazon Household members as `text` (the
81
+ default), `json`, `yaml`, or `csv`. Progress messages are written to `stderr`, so redirecting `stdout` captures only
82
+ the data.
81
83
 
82
84
  ```sh
83
85
  amazon-orders history --year 2023 --output json > orders.json
@@ -156,6 +158,9 @@ as shown above. Any MCP client gets the same as tools through
156
158
  [`amazon-orders-mcp`](https://github.com/alexdlaird/amazon-orders-mcp#readme), a server built on `amazon-orders` — see
157
159
  its README for setup.
158
160
 
161
+ The server signs you in with a browser, so it needs a display. Without one, use the CLI or library with
162
+ [Automating Authentication](#automating-authentication).
163
+
159
164
  ## Documentation
160
165
 
161
166
  For more advanced usage, `amazon-orders`'s official documentation is available
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-orders
3
- Version: 4.9.2
3
+ Version: 4.9.4
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>
@@ -77,6 +77,7 @@ Dynamic: license-file
77
77
  <p align="center"><img alt="amazon-orders - A Python library (and CLI) for Amazon order history" src="https://amazon-orders.readthedocs.io/_images/logo.png" /></p>
78
78
 
79
79
  [![Version](https://img.shields.io/pypi/v/amazon-orders)](https://pypi.org/project/amazon-orders)
80
+ [![PyPI - Downloads](https://img.shields.io/pypi/dm/amazon-orders)](https://pypistats.org/packages/amazon-orders)
80
81
  [![Python Versions](https://img.shields.io/pypi/pyversions/amazon-orders.svg)](https://pypi.org/project/amazon-orders)
81
82
  [![Coverage](https://img.shields.io/codecov/c/github/alexdlaird/amazon-orders)](https://codecov.io/gh/alexdlaird/amazon-orders)
82
83
  [![Build](https://img.shields.io/github/actions/workflow/status/alexdlaird/amazon-orders/build.yml)](https://github.com/alexdlaird/amazon-orders/actions/workflows/build.yml)
@@ -151,9 +152,10 @@ amazon-orders history --last-3-months
151
152
 
152
153
  ### Output Formats
153
154
 
154
- The `history`, `order`, `transactions`, `order-transactions`, and `gift-card-activity` commands accept
155
- `--output`, which renders Orders, Transactions, and Gift Card activity as `text` (the default), `json`,
156
- `yaml`, or `csv`. Progress messages are written to `stderr`, so redirecting `stdout` captures only the data.
155
+ The `history`, `order`, `transactions`, `order-transactions`, `gift-card-activity`, and `family-members` commands
156
+ accept `--output`, which renders Orders, Transactions, Gift Card activity, and Amazon Household members as `text` (the
157
+ default), `json`, `yaml`, or `csv`. Progress messages are written to `stderr`, so redirecting `stdout` captures only
158
+ the data.
157
159
 
158
160
  ```sh
159
161
  amazon-orders history --year 2023 --output json > orders.json
@@ -232,6 +234,9 @@ as shown above. Any MCP client gets the same as tools through
232
234
  [`amazon-orders-mcp`](https://github.com/alexdlaird/amazon-orders-mcp#readme), a server built on `amazon-orders` — see
233
235
  its README for setup.
234
236
 
237
+ The server signs you in with a browser, so it needs a display. Without one, use the CLI or library with
238
+ [Automating Authentication](#automating-authentication).
239
+
235
240
  ## Documentation
236
241
 
237
242
  For more advanced usage, `amazon-orders`'s official documentation is available
@@ -34,6 +34,7 @@ amazonorders/contrib/waf/base.py
34
34
  amazonorders/contrib/waf/capsolver.py
35
35
  amazonorders/contrib/waf/twocaptcha.py
36
36
  amazonorders/entity/__init__.py
37
+ amazonorders/entity/family_member.py
37
38
  amazonorders/entity/gift_card_activity.py
38
39
  amazonorders/entity/item.py
39
40
  amazonorders/entity/order.py
@@ -1,3 +1,3 @@
1
1
  __copyright__ = "Copyright (c) 2024-2025 Alex Laird"
2
2
  __license__ = "MIT"
3
- __version__ = "4.9.2"
3
+ __version__ = "4.9.4"
@@ -302,7 +302,11 @@ def amazon_orders_cli(ctx: Context,
302
302
  help="Get the full details for each Order in the history. "
303
303
  "This will execute an additional request per Order.")
304
304
  @click.option("--order-filter", "order_filter", default=None,
305
- help="The order type filter to use.")
305
+ help="The order type filter to use, e.g. digital-orders for Kindle and other digital purchases, "
306
+ "which the default history doesn't include.")
307
+ @click.option("--family-member-id", "family_member_id", default=None,
308
+ help="Get the Orders of this Amazon Household member, such as a linked Teen account, instead. "
309
+ "See the family-members command for IDs.")
306
310
  @click.option("-o", "--output", type=click.Choice(OutputFormatter.OUTPUT_FORMATS), default="text",
307
311
  help="The output format. Defaults to text.")
308
312
  def history(ctx: Context,
@@ -322,6 +326,7 @@ def history(ctx: Context,
322
326
  single_page = kwargs["single_page"]
323
327
  full_details = kwargs["full_details"]
324
328
  order_filter = kwargs["order_filter"]
329
+ family_member_id = kwargs["family_member_id"]
325
330
  output = kwargs["output"]
326
331
 
327
332
  exclusive_flags = [year, last_3_months, last_30_days]
@@ -365,6 +370,7 @@ Order History for {filter_description}{optional_start_index}{optional_full_detai
365
370
  keep_paging=not single_page,
366
371
  time_filter=time_filter,
367
372
  order_filter=order_filter,
373
+ family_member_id=family_member_id,
368
374
  progress_with_total=progress)
369
375
  end_time = time.time()
370
376
 
@@ -525,6 +531,39 @@ Transaction History for {days} days
525
531
  ctx.fail(str(e))
526
532
 
527
533
 
534
+ @amazon_orders_cli.command("family-members")
535
+ @click.pass_context
536
+ @click.option("-o", "--output", type=click.Choice(OutputFormatter.OUTPUT_FORMATS), default="text",
537
+ help="The output format. Defaults to text.")
538
+ def family_members(ctx: Context, **kwargs: Any) -> None:
539
+ """
540
+ Get the Amazon Household members whose Orders this account can view, such as linked Teen accounts.
541
+ """
542
+ amazon_session = ctx.obj["amazon_session"]
543
+
544
+ try:
545
+ CliAuth(amazon_session).authenticate()
546
+
547
+ config = ctx.obj["conf"]
548
+ amazon_orders = AmazonOrders(amazon_session,
549
+ config=config)
550
+
551
+ start_time = time.time()
552
+ members = amazon_orders.get_family_members()
553
+ end_time = time.time()
554
+
555
+ click.echo(config.output_cls(config).format(members, kwargs["output"]))
556
+
557
+ click.echo(
558
+ "... {total} Amazon Household members parsed in {time} seconds.\n".format(
559
+ total=len(members), time=int(end_time - start_time)), err=True)
560
+ except AmazonOrdersAuthRedirectError:
561
+ IOClick.echo_session_expired()
562
+ except AmazonOrdersError as e:
563
+ logger.debug("An error occurred.", exc_info=True)
564
+ ctx.fail(str(e))
565
+
566
+
528
567
  @amazon_orders_cli.command()
529
568
  @click.pass_context
530
569
  def gift_card_balance(ctx: Context) -> None:
@@ -69,6 +69,7 @@ class Constants:
69
69
  ORDER_INVOICE_URL = f"{BASE_URL}/gp/css/summary/print.html"
70
70
  HISTORY_FILTER_QUERY_PARAM = "timeFilter"
71
71
  ORDER_FILTER_QUERY_PARAM = "orderFilter"
72
+ FAMILY_MEMBER_QUERY_PARAM = "familyMemberId"
72
73
  HISTORY_PAGE_QUERY_PARAM = "page"
73
74
  HISTORY_START_INDEX_QUERY_PARAM = "startIndex"
74
75
  ORDER_ID_QUERY_PARAM = "orderID"
@@ -0,0 +1,48 @@
1
+ __copyright__ = "Copyright (c) 2024-2025 Alex Laird"
2
+ __license__ = "MIT"
3
+
4
+ import logging
5
+ from typing import Optional
6
+
7
+ from bs4 import Tag
8
+
9
+ from amazonorders.conf import AmazonOrdersConfig
10
+ from amazonorders.entity.parsable import Parsable
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+
15
+ class FamilyMember(Parsable):
16
+ """
17
+ A member of the Amazon Household whose Orders the account can view, such as a linked Teen account. Pass its
18
+ :attr:`id` as ``family_member_id`` to :func:`~amazonorders.orders.AmazonOrders.get_order_history` to get their
19
+ Orders.
20
+ """
21
+
22
+ def __init__(self,
23
+ parsed: Tag,
24
+ config: AmazonOrdersConfig) -> None:
25
+ super().__init__(parsed, config)
26
+
27
+ #: The FamilyMember ID, Amazon's ``familyMemberId``.
28
+ self.id: Optional[str] = self.safe_parse(self._parse_id)
29
+ #: The FamilyMember name.
30
+ self.name: Optional[str] = self.safe_parse(self._parse_name)
31
+
32
+ def __repr__(self) -> str:
33
+ return f"<FamilyMember {self.id}: \"{self.name}\">"
34
+
35
+ def __str__(self) -> str: # pragma: no cover
36
+ return f"FamilyMember {self.id}: {self.name}"
37
+
38
+ def _parse_id(self) -> Optional[str]:
39
+ value = self.parsed.get(self.config.selectors.FAMILY_MEMBER_ID_ATTR)
40
+ return str(value).strip() if value else None
41
+
42
+ def _parse_name(self) -> Optional[str]:
43
+ # Amazon renders a NUL byte between the name and the suffix, e.g. "Jane Doe\x00's Orders"
44
+ value = self.parsed.text.replace("\x00", " ").strip()
45
+ suffix = self.config.selectors.FIELD_FAMILY_MEMBER_NAME_SUFFIX
46
+ if suffix and suffix in value:
47
+ value = value.split(suffix)[0]
48
+ return value.strip() or None
@@ -252,7 +252,18 @@ class Order(Parsable):
252
252
  tag = util.select_one(self.parsed, self.config.selectors.FIELD_ORDER_PAYMENT_METHOD_SELECTOR)
253
253
  if tag is None:
254
254
  return None
255
- return tag.attrs.get("alt") or tag.text.strip()
255
+ return tag.attrs.get("alt") or self._without_masked_digits(tag.text).strip()
256
+
257
+ def _without_masked_digits(self,
258
+ text: str) -> str:
259
+ """
260
+ The payment method's name, when Amazon shows it in one line with the masked digits that follow it.
261
+ """
262
+ match = re.search(self.config.selectors.FIELD_ORDER_PAYMENT_METHOD_LAST_4_REGEX, text)
263
+ if match and match.start() > 0:
264
+ return text[:match.start()]
265
+
266
+ return text
256
267
 
257
268
  def _parse_masked_digits(self,
258
269
  selector: Union[str, list],
@@ -6,7 +6,7 @@ import re
6
6
  from abc import ABC
7
7
  from io import BytesIO
8
8
  from typing import Any, Dict, NoReturn, Optional, TYPE_CHECKING, Union
9
- from urllib.parse import urlparse
9
+ from urllib.parse import urljoin, urlparse
10
10
 
11
11
  import pyotp
12
12
  from PIL import Image
@@ -190,7 +190,8 @@ class AuthForm(ABC):
190
190
 
191
191
  error_tag = util.select_one(form_response.parsed, self.error_selector)
192
192
  if error_tag:
193
- error_msg = f"Error from Amazon: {util.cleanup_html_text(error_tag.text)}"
193
+ error_text = util.cleanup_html_text(error_tag.get_text("\n"))
194
+ error_msg = f"Error from Amazon: {error_text}"
194
195
 
195
196
  if self.critical:
196
197
  raise AmazonOrdersAuthError(error_msg)
@@ -226,10 +227,23 @@ class SignInForm(AuthForm):
226
227
  "Check if Amazon changed their login flow."
227
228
  ) # pragma: no cover
228
229
 
229
- additional_attrs.update({self.solution_attr_key: self.amazon_session.username,
230
- "password": self.amazon_session.password})
230
+ additional_attrs[self.solution_attr_key] = self.amazon_session.username
231
+ if not self._collects_email_only():
232
+ additional_attrs["password"] = self.amazon_session.password
231
233
  self.data.update(additional_attrs)
232
234
 
235
+ def _collects_email_only(self) -> bool:
236
+ """
237
+ Whether the ``<form>`` is the email-first step of sign-in, which posts to the claim URL and asks for the
238
+ password on the page that follows. A browser sends only the email here, and Amazon's current sign-in rejects
239
+ a password sent along with it.
240
+ """
241
+ if not self.form:
242
+ return False
243
+
244
+ action_path = urlparse(str(self.form.get("action", ""))).path
245
+ return action_path == urlparse(self.config.constants.SIGN_IN_CLAIM_URL).path
246
+
233
247
 
234
248
  class ClaimForm(AuthForm):
235
249
  def __init__(self,
@@ -259,10 +273,64 @@ class ClaimForm(AuthForm):
259
273
  "Check if Amazon changed their login flow."
260
274
  ) # pragma: no cover
261
275
 
262
- additional_attrs.update({self.solution_attr_key: self.amazon_session.username,
263
- "password": self.amazon_session.password})
276
+ if self._asks_for_email():
277
+ additional_attrs[self.solution_attr_key] = self.amazon_session.username
278
+ additional_attrs["password"] = self.amazon_session.password
264
279
  self.data.update(additional_attrs)
265
280
 
281
+ def _asks_for_email(self) -> bool:
282
+ """
283
+ Whether the ``<form>`` has a field for the email. The older sign-in asks for it again with the password, while
284
+ the newer one already has it from the step before and asks for the password alone.
285
+ """
286
+ return bool(self.form and self.form.find("input", attrs={"name": self.solution_attr_key}))
287
+
288
+
289
+ class PasskeyForm(AuthForm):
290
+ """
291
+ The page that offers a passkey before the password, when the account has one. A passkey needs the user's device,
292
+ so this declines it and follows the page's link to the password instead.
293
+ """
294
+
295
+ def __init__(self,
296
+ config: AmazonOrdersConfig,
297
+ selector: Optional[str] = None) -> None:
298
+ if not selector:
299
+ selector = config.selectors.PASSKEY_FORM_SELECTOR
300
+
301
+ super().__init__(config, selector, critical=True)
302
+
303
+ #: The link to the password instead of the passkey.
304
+ self.password_challenge_link: Optional[Tag] = None
305
+
306
+ def select_form(self,
307
+ amazon_session: "AmazonSession",
308
+ parsed: Tag) -> bool:
309
+ if not super().select_form(amazon_session, parsed):
310
+ return False
311
+
312
+ self.password_challenge_link = util.select_one(parsed,
313
+ self.config.selectors.PASSKEY_PASSWORD_CHALLENGE_LINK_SELECTOR)
314
+ return self.password_challenge_link is not None
315
+
316
+ def submit(self, last_response: Response) -> AmazonSessionResponse:
317
+ if not self.amazon_session or not self.password_challenge_link:
318
+ raise AmazonOrdersError(
319
+ "Call AuthForm.select_form() first."
320
+ ) # pragma: no cover
321
+
322
+ url = urljoin(last_response.url, str(self.password_challenge_link["href"]))
323
+ password_response = self.amazon_session.get(url, persist_cookies=True)
324
+
325
+ self._handle_errors(password_response)
326
+ self.clear_form()
327
+
328
+ return password_response
329
+
330
+ def clear_form(self) -> None:
331
+ super().clear_form()
332
+ self.password_challenge_link = None
333
+
266
334
 
267
335
  class IntentForm(AuthForm):
268
336
  def __init__(self,
@@ -392,12 +460,23 @@ class MfaForm(AuthForm):
392
460
  otp = self.amazon_session.io.prompt("Enter the one-time passcode from your preferred 2FA method")
393
461
  self.amazon_session.io.echo("")
394
462
 
395
- additional_attrs.update({self.solution_attr_key: otp,
396
- "rememberDevice": ""})
463
+ additional_attrs[self.solution_attr_key] = otp
464
+ if not self._posts_to_claim():
465
+ additional_attrs["rememberDevice"] = ""
466
+ if "deviceId" not in self.data:
467
+ additional_attrs["deviceId"] = ""
397
468
  self.data.update(additional_attrs)
398
469
 
399
- if "deviceId" not in self.data:
400
- self.data["deviceId"] = ""
470
+ def _posts_to_claim(self) -> bool:
471
+ """
472
+ Whether the ``<form>`` is part of the newer sign-in that posts under the claim URL, which takes the code alone,
473
+ without the older form's device fields.
474
+ """
475
+ if not self.form:
476
+ return False
477
+
478
+ action_path = urlparse(str(self.form.get("action", ""))).path
479
+ return action_path.startswith(urlparse(self.config.constants.SIGN_IN_CLAIM_URL).path)
401
480
 
402
481
 
403
482
  class CaptchaForm(AuthForm):
@@ -12,6 +12,7 @@ from bs4 import BeautifulSoup, Tag
12
12
 
13
13
  from amazonorders import util
14
14
  from amazonorders.conf import AmazonOrdersConfig
15
+ from amazonorders.entity.family_member import FamilyMember
15
16
  from amazonorders.entity.order import Order
16
17
  from amazonorders.entity.tracking import Tracking
17
18
  from amazonorders.exception import AmazonOrdersError, AmazonOrdersNotFoundError, AmazonOrdersUnavailableError
@@ -96,6 +97,20 @@ class AmazonOrders:
96
97
  raise AmazonOrdersError("Could not parse Order details. Check if Amazon changed the HTML.")
97
98
  return order
98
99
 
100
+ @classmethod
101
+ def parse_family_members(cls,
102
+ html: str,
103
+ config: AmazonOrdersConfig) -> List[FamilyMember]:
104
+ """
105
+ Parse the Amazon Household members from an already-fetched Order history page, without a session driving
106
+ the fetch. Useful for parsing HTML obtained elsewhere and for network-free testing.
107
+
108
+ :param html: The Order history page HTML to parse.
109
+ :param config: The config providing the selectors used for parsing.
110
+ :return: The parsed FamilyMembers, or an empty list if the page lists none.
111
+ """
112
+ return cls._parse_family_members(BeautifulSoup(html, config.bs4_parser), config)
113
+
99
114
  def get_order(self,
100
115
  order_id: str,
101
116
  clone: Optional[Order] = None) -> Order:
@@ -251,6 +266,23 @@ class AmazonOrders:
251
266
 
252
267
  return invoice_response
253
268
 
269
+ def get_family_members(self) -> List[FamilyMember]:
270
+ """
271
+ Get the members of the Amazon Household whose Orders this account can view, such as linked Teen accounts,
272
+ from the Order history's member filter. Pass a member's
273
+ :attr:`~amazonorders.entity.family_member.FamilyMember.id` as ``family_member_id`` to
274
+ :func:`get_order_history` to get their Orders.
275
+
276
+ :return: The FamilyMembers, or an empty list if the account has none.
277
+ """
278
+ if not self.amazon_session.is_authenticated:
279
+ raise AmazonOrdersError("Call AmazonSession.login() to authenticate first.")
280
+
281
+ page_response = self.amazon_session.get(self.config.constants.ORDER_HISTORY_URL)
282
+ self.amazon_session.check_response(page_response)
283
+
284
+ return self._parse_family_members(page_response.parsed, self.config)
285
+
254
286
  def get_order_history(self,
255
287
  year: Optional[int] = None,
256
288
  start_index: Optional[int] = None,
@@ -259,7 +291,8 @@ class AmazonOrders:
259
291
  time_filter: Optional[str] = None,
260
292
  order_filter: Optional[str] = None,
261
293
  progress: Optional[Callable[[int], None]] = None,
262
- progress_with_total: Optional[Callable[[int, Optional[int]], None]] = None) -> List[Order]:
294
+ progress_with_total: Optional[Callable[[int, Optional[int]], None]] = None,
295
+ family_member_id: Optional[str] = None) -> List[Order]:
263
296
  """
264
297
  Get the Amazon Order history for a given time period.
265
298
 
@@ -275,18 +308,21 @@ class AmazonOrders:
275
308
  :param time_filter: The time filter to use. Supported values are ``"last30"`` (last 30 days),
276
309
  ``"months-3"`` (past 3 months), or ``"year-YYYY"`` (specific year). If provided, this takes
277
310
  precedence over the ``year`` parameter.
278
- :param order_filter: The order type filter to use. If provided, appended alongside the time filter.
311
+ :param order_filter: The order type filter to use, e.g. ``"digital-orders"`` for Kindle and other digital
312
+ purchases, which the default history doesn't include. If provided, appended alongside the time filter.
279
313
  :param progress: Called after each page of history is fetched, with the number of Orders found so far
280
314
  (before their full details are fetched, if ``full_details`` is ``True``).
281
315
  :param progress_with_total: Like ``progress``, but also called with the number of Orders expected in total,
282
316
  from the count Amazon shows on the history page. The total is ``None`` if Amazon doesn't show one, or if
283
317
  ``order_filter`` is provided, since Amazon's count may not reflect it.
318
+ :param family_member_id: The ID of an Amazon Household member whose Orders to get, such as a linked Teen
319
+ account, from :func:`get_family_members`.
284
320
  :return: A list of the requested Orders.
285
321
  """
286
322
  if not self.amazon_session.is_authenticated:
287
323
  raise AmazonOrdersError("Call AmazonSession.login() to authenticate first.")
288
324
 
289
- next_page = self._order_history_url(year, start_index, time_filter, order_filter)
325
+ next_page = self._order_history_url(year, start_index, time_filter, order_filter, family_member_id)
290
326
  current_index = int(start_index) if start_index else 0
291
327
 
292
328
  orders, _, _ = asyncio.run(self._build_orders_async(next_page, keep_paging, full_details, current_index,
@@ -304,7 +340,8 @@ class AmazonOrders:
304
340
  limit: Optional[int] = None,
305
341
  cursor: Optional[Dict[str, Any]] = None,
306
342
  progress: Optional[Callable[[int], None]] = None,
307
- progress_with_total: Optional[Callable[[int, Optional[int]], None]] = None
343
+ progress_with_total: Optional[Callable[[int, Optional[int]], None]] = None,
344
+ family_member_id: Optional[str] = None
308
345
  ) -> PagedResults[Order]:
309
346
  """
310
347
  Get a batch of the Amazon Order history for a given time period, with a cursor to fetch the next batch.
@@ -318,7 +355,8 @@ class AmazonOrders:
318
355
  request per Order.
319
356
  :param time_filter: The time filter to use, as in :func:`get_order_history`. Ignored if ``cursor`` is
320
357
  provided.
321
- :param order_filter: The order type filter to use. Ignored if ``cursor`` is provided.
358
+ :param order_filter: The order type filter to use, as in :func:`get_order_history`. Ignored if ``cursor`` is
359
+ provided.
322
360
  :param limit: Stop fetching history pages once at least this many Orders are collected. Whole pages are
323
361
  fetched, so the batch can hold up to a page more than ``limit``. ``None`` fetches every page.
324
362
  :param cursor: The :attr:`~amazonorders.paging.PagedResults.cursor` of the previous batch, to fetch the
@@ -327,6 +365,8 @@ class AmazonOrders:
327
365
  this batch (before their full details are fetched, if ``full_details`` is ``True``).
328
366
  :param progress_with_total: Like ``progress``, but also called with the number of Orders expected in this
329
367
  batch, as in :func:`get_order_history`, and at most ``limit`` until the batch ends.
368
+ :param family_member_id: The ID of an Amazon Household member whose Orders to get, as in
369
+ :func:`get_order_history`. Ignored if ``cursor`` is provided.
330
370
  :return: The batch of Orders, and the cursor for the next one.
331
371
  """
332
372
  if not self.amazon_session.is_authenticated:
@@ -337,9 +377,11 @@ class AmazonOrders:
337
377
  start_index = cursor["start_index"]
338
378
  time_filter = cursor["time_filter"]
339
379
  order_filter = cursor["order_filter"]
380
+ family_member_id = cursor.get("family_member_id")
340
381
  time_filter = self._history_filter_value(year, time_filter)
341
382
 
342
- next_page: Optional[str] = self._order_history_url(None, start_index, time_filter, order_filter)
383
+ next_page: Optional[str] = self._order_history_url(None, start_index, time_filter, order_filter,
384
+ family_member_id)
343
385
  current_index = int(start_index) if start_index else 0
344
386
 
345
387
  orders, next_page, current_index = asyncio.run(
@@ -349,6 +391,7 @@ class AmazonOrders:
349
391
  "start_index": current_index,
350
392
  "time_filter": time_filter,
351
393
  "order_filter": order_filter,
394
+ **({"family_member_id": family_member_id} if family_member_id else {}),
352
395
  } if next_page else None
353
396
 
354
397
  return PagedResults(orders, next_cursor)
@@ -383,7 +426,8 @@ class AmazonOrders:
383
426
  year: Optional[int],
384
427
  start_index: Optional[int],
385
428
  time_filter: Optional[str],
386
- order_filter: Optional[str]) -> str:
429
+ order_filter: Optional[str],
430
+ family_member_id: Optional[str] = None) -> str:
387
431
  constants = self.config.constants
388
432
  filter_value = self._history_filter_value(year, time_filter)
389
433
  page = int(start_index) // constants.ORDER_HISTORY_PAGE_SIZE if start_index else 0
@@ -396,16 +440,28 @@ class AmazonOrders:
396
440
  f"&{constants.ORDER_FILTER_QUERY_PARAM}={quote(order_filter, safe='')}"
397
441
  if order_filter else ""
398
442
  )
443
+ optional_family_member = (
444
+ f"&{constants.FAMILY_MEMBER_QUERY_PARAM}={quote(family_member_id, safe='')}"
445
+ if family_member_id else ""
446
+ )
399
447
  return (
400
- "{url}?{query_param}={filter_value}{optional_order_filter}{optional_page}"
448
+ "{url}?{query_param}={filter_value}{optional_order_filter}{optional_family_member}{optional_page}"
401
449
  ).format(
402
450
  url=constants.ORDER_HISTORY_URL,
403
451
  query_param=constants.HISTORY_FILTER_QUERY_PARAM,
404
452
  filter_value=filter_value,
405
453
  optional_order_filter=optional_order_filter,
454
+ optional_family_member=optional_family_member,
406
455
  optional_page=optional_page
407
456
  )
408
457
 
458
+ @staticmethod
459
+ def _parse_family_members(parsed: Tag,
460
+ config: AmazonOrdersConfig) -> List[FamilyMember]:
461
+ members = [FamilyMember(tag, config)
462
+ for tag in util.select(parsed, config.selectors.FAMILY_MEMBER_ENTITY_SELECTOR)]
463
+ return [member for member in members if member.id]
464
+
409
465
  @staticmethod
410
466
  def _parse_order_count(order_count_tag: Optional[Tag],
411
467
  config: AmazonOrdersConfig) -> Optional[int]:
@@ -105,7 +105,7 @@ Order #{order_number}
105
105
 
106
106
  order_str += f"\n Shipments: {order.shipments}"
107
107
  order_str += f"\n Order Details Link: {order.order_details_link}"
108
- if order.grand_total:
108
+ if order.grand_total is not None:
109
109
  order_str += f"\n Grand Total: {self.config.constants.format_currency(order.grand_total)}"
110
110
  order_str += f"\n Order Placed Date: {order.order_placed_date}"
111
111
  if order.recipient:
@@ -147,7 +147,7 @@ Order #{order_number}
147
147
  """
148
148
  transaction_str = f"Transaction: {transaction.completed_date}"
149
149
  transaction_str += f"\n Order #{transaction.order_number}"
150
- if transaction.grand_total:
150
+ if transaction.grand_total is not None:
151
151
  transaction_str += f"\n Grand Total: {self.config.constants.format_currency(transaction.grand_total)}"
152
152
  transaction_str += f"\n Order Details Link: {transaction.order_details_link}"
153
153
 
@@ -95,19 +95,22 @@ 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
+ NAV_GREETING_SELECTOR = "#nav-link-accountList-nav-line-1"
98
99
  SIGN_IN_EMAIL_INPUT_SELECTOR = "input[name='email']"
99
- CLAIM_FORM_SELECTOR = "form[name='signIn'].auth-validate-form"
100
+ CLAIM_FORM_SELECTOR = ["form[name='signIn'].auth-validate-form", "form#unified-password-form"]
100
101
  INTENT_FORM_SELECTOR = "form#intent-confirmation-form"
101
102
  INTENT_MESSAGE_SELECTOR = "div#intent-confirmation-container"
102
103
  MFA_DEVICE_SELECT_FORM_SELECTOR = "form#auth-select-device-form"
104
+ PASSKEY_FORM_SELECTOR = "form#unified-passkey-form"
105
+ PASSKEY_PASSWORD_CHALLENGE_LINK_SELECTOR = "a[href*='selectedChallengeType=password']"
103
106
  MFA_DEVICE_SELECT_INPUT_SELECTOR = "input[name='otpDeviceContext']"
104
107
  MFA_DEVICE_SELECT_INPUT_SELECTOR_VALUE = "value"
105
108
  MFA_DEVICE_SELECT_LABEL_SELECTOR = "span.a-label.a-radio-label"
106
- MFA_FORM_SELECTOR = "form#auth-mfa-form"
109
+ MFA_FORM_SELECTOR = ["form#auth-mfa-form", "form#unified-otp-form"]
107
110
  CAPTCHA_1_FORM_SELECTOR = "form.cvf-widget-form-captcha"
108
111
  CAPTCHA_2_FORM_SELECTOR = ["form:has(input[id^='captchacharacters'])", "form[action$='validateCaptcha']"]
109
112
  CAPTCHA_OTP_FORM_SELECTOR = "form#verification-code-form"
110
- DEFAULT_ERROR_TAG_SELECTOR = "div#auth-error-message-box"
113
+ DEFAULT_ERROR_TAG_SELECTOR = "div#auth-error-message-box:not(.aok-hidden *)"
111
114
  CAPTCHA_1_ERROR_SELECTOR = "div.cvf-widget-alert"
112
115
  CAPTCHA_2_ERROR_SELECTOR = "div.a-alert-info"
113
116
 
@@ -117,6 +120,16 @@ class Selectors:
117
120
 
118
121
  NEXT_PAGE_LINK_SELECTOR = "ul.a-pagination li.a-last a"
119
122
 
123
+ ##########################################################################
124
+ # CSS selectors for Amazon Household family members
125
+ ##########################################################################
126
+
127
+ #: The Order history's "My Orders" / "<name>'s Orders" dropdown; the account's own entry has an empty value
128
+ FAMILY_MEMBER_ENTITY_SELECTOR = ["select#family-member-filter option[value]:not([value=''])",
129
+ "select[name='familyMemberId'] option[value]:not([value=''])"]
130
+ FAMILY_MEMBER_ID_ATTR = "value"
131
+ FIELD_FAMILY_MEMBER_NAME_SUFFIX = "'s Orders"
132
+
120
133
  ##########################################################################
121
134
  # CSS selectors for Entities and Fields
122
135
  #
@@ -249,10 +262,12 @@ class Selectors:
249
262
  "div:is(.a-span3, .a-span12)"]
250
263
  FIELD_ORDER_PLACED_DATE_SUFFIX = "Order #"
251
264
  FIELD_ORDER_PAYMENT_METHOD_SELECTOR = ["[data-testid='payment-instrument-name']",
252
- "img.pmts-payment-credit-card-instrument-logo"]
265
+ "img.pmts-payment-credit-card-instrument-logo",
266
+ "[data-testid='payment-instrument-message']"]
253
267
  FIELD_ORDER_PAYMENT_METHOD_LAST_4_SELECTOR = ["[data-testid='payment-instrument-number']",
254
- "span:has(img.pmts-payment-credit-card-instrument-logo):last-child"]
255
- FIELD_ORDER_PAYMENT_METHOD_LAST_4_REGEX = r"(?:ending in\s+|^\s*)(\d+)"
268
+ "span:has(img.pmts-payment-credit-card-instrument-logo):last-child",
269
+ "[data-testid='payment-instrument-message']"]
270
+ FIELD_ORDER_PAYMENT_METHOD_LAST_4_REGEX = r"(?:ending in\s+|^\s*|•+\s*)(\d+)"
256
271
  FIELD_ORDER_SUBTOTALS_TAG_ITERATOR_SELECTOR = ["[data-component='orderSubtotals'] div.a-row",
257
272
  "div#od-subtotals div.a-row",
258
273
  "[data-component='chargeSummary'] div.od-line-item-row"]
@@ -17,8 +17,8 @@ from requests.utils import dict_from_cookiejar
17
17
  from amazonorders.conf import AmazonOrdersConfig, cookies_file_lock, debug_output_file_lock
18
18
  from amazonorders.exception import AmazonOrdersAuthError, AmazonOrdersError, AmazonOrdersAuthRedirectError
19
19
  from amazonorders.forms import (AuthForm, AcicAuthBlocker, CaptchaForm, JSAuthBlocker, MfaDeviceSelectForm, MfaForm,
20
- SignInForm, ClaimForm, IntentForm)
21
- from amazonorders.util import AmazonSessionResponse, load_class
20
+ SignInForm, ClaimForm, IntentForm, PasskeyForm)
21
+ from amazonorders.util import AmazonSessionResponse, load_class, select_one
22
22
 
23
23
  logger = logging.getLogger(__name__)
24
24
 
@@ -169,6 +169,7 @@ class AmazonSession:
169
169
  return [ClaimForm(config),
170
170
  IntentForm(config),
171
171
  SignInForm(config),
172
+ PasskeyForm(config),
172
173
  MfaDeviceSelectForm(config),
173
174
  MfaForm(config),
174
175
  CaptchaForm(config),
@@ -313,11 +314,9 @@ class AmazonSession:
313
314
  form_found = False
314
315
  attempts = 0
315
316
  while not self.is_authenticated and attempts < self.config.max_auth_attempts:
316
- # TODO: BeautifulSoup doesn't let us query for #nav-item-signout, maybe because it's dynamic on the page,
317
- # but we should find a better way to do this
317
+ greeting = select_one(last_response.parsed, self.config.selectors.NAV_GREETING_SELECTOR)
318
318
  if self.auth_cookies_stored() or \
319
- (self.config.constants.SIGNED_OUT_TEXT not in last_response.response.text and
320
- "nav-item-signout" in last_response.response.text):
319
+ (greeting is not None and self.config.constants.SIGNED_OUT_TEXT not in greeting.get_text()):
321
320
  self.is_authenticated = True
322
321
  break
323
322
 
File without changes
File without changes
File without changes