amazon-orders 4.4.2__tar.gz → 4.4.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 (42) hide show
  1. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/CHANGELOG.md +22 -1
  2. {amazon_orders-4.4.2/amazon_orders.egg-info → amazon_orders-4.4.4}/PKG-INFO +1 -1
  3. {amazon_orders-4.4.2 → amazon_orders-4.4.4/amazon_orders.egg-info}/PKG-INFO +1 -1
  4. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/__init__.py +1 -1
  5. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/cli.py +66 -1
  6. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/constants.py +1 -0
  7. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/item.py +31 -6
  8. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/order.py +57 -12
  9. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/orders.py +39 -3
  10. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/selectors.py +40 -10
  11. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/LICENSE +0 -0
  12. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/MANIFEST.in +0 -0
  13. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/README.md +0 -0
  14. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/SOURCES.txt +0 -0
  15. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/dependency_links.txt +0 -0
  16. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/entry_points.txt +0 -0
  17. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/requires.txt +0 -0
  18. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/top_level.txt +0 -0
  19. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/banner.txt +0 -0
  20. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/conf.py +0 -0
  21. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/__init__.py +0 -0
  22. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/browser/__init__.py +0 -0
  23. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/browser/playwright.py +0 -0
  24. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/__init__.py +0 -0
  25. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/anticaptcha.py +0 -0
  26. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/base.py +0 -0
  27. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/capsolver.py +0 -0
  28. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/twocaptcha.py +0 -0
  29. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/__init__.py +0 -0
  30. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/parsable.py +0 -0
  31. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/recipient.py +0 -0
  32. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/seller.py +0 -0
  33. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/shipment.py +0 -0
  34. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/transaction.py +0 -0
  35. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/exception.py +0 -0
  36. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/forms.py +0 -0
  37. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/session.py +0 -0
  38. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/transactions.py +0 -0
  39. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/util.py +0 -0
  40. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/pyproject.toml +0 -0
  41. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/setup.cfg +0 -0
  42. {amazon_orders-4.4.2 → amazon_orders-4.4.4}/tests/testcase.py +0 -0
@@ -4,17 +4,38 @@ 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.4.2...HEAD)
7
+ ## [Unreleased](https://github.com/alexdlaird/amazon-orders/compare/4.4.4...HEAD)
8
+
9
+ ## [4.4.4](https://github.com/alexdlaird/amazon-orders/compare/4.4.3...4.4.4) - 2026-07-07
10
+
11
+ ### Added
12
+
13
+ - `Order.is_whole_foods`, identifying Whole Foods Market in-store and receipt orders.
14
+ - `Order.item_count`, for orders where Amazon shows an item count rather than a line-item list (e.g. Whole Foods Market, eGift orders).
15
+ - Full-details parsing for Whole Foods Market in-store (FOPO) orders via their dedicated `/fopo/order-details` page, populating `items`, `subtotal`, `estimated_tax`, and `payment_method`.
16
+ - `Item.asin`, the product ASIN parsed from `Item.link` (`None` when the link is not a product page).
17
+
18
+ ### Changed
19
+
20
+ - `Order.grand_total` now populated for Whole Foods Market orders (previously skipped as unsupported).
21
+ - `Item.link` is now `Optional[str]` (`None` for items without an Amazon detail page).
22
+
23
+ ## [4.4.3](https://github.com/alexdlaird/amazon-orders/compare/4.4.2...4.4.3) - 2026-07-06
8
24
 
9
25
  ## [4.4.2](https://github.com/alexdlaird/amazon-orders/compare/4.4.1...4.4.2) - 2026-07-05
10
26
 
11
27
  ### Added
12
28
 
13
29
  - `request_timeout` config key (defaults to `None`) controlling the timeout passed to each HTTP request.
30
+ - `Order.is_whole_foods`, identifying Whole Foods Market purchases (in-store/FOPO purchases and Whole Foods receipt orders).
31
+ - `Order.item_count`, the number of items in a purchase when Amazon summarizes the count instead of listing the items (e.g. Whole Foods Market and eGift orders).
32
+ - Full-details support for Whole Foods Market in-store purchases: when `full_details=True`, these orders are fetched from their dedicated `/fopo/order-details` page (which the standard order-details endpoint does not serve) and populate `items` (title, price, image, link, and whole-unit `quantity`), `subtotal`, `estimated_tax`, `payment_method`, and `payment_method_last_4`.
14
33
 
15
34
  ### Changed
16
35
 
17
36
  - `Order.order_number` now uses the caller-supplied value as a fallback when the order number cannot be parsed from the details page; previously documented but unintuitive behavior.
37
+ - `Order.grand_total` is now populated for Whole Foods Market orders from the order history page (previously skipped as an unsupported order type).
38
+ - `Item.link` is now optional (`None` for line items without an Amazon detail page, such as ASINLESS Whole Foods Market items) rather than raising when no link is present.
18
39
 
19
40
  ## [4.4.1](https://github.com/alexdlaird/amazon-orders/compare/4.4.0...4.4.1) - 2026-07-04
20
41
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-orders
3
- Version: 4.4.2
3
+ Version: 4.4.4
4
4
  Summary: A Python library (and CLI) for Amazon order history
5
5
  Author-email: Alex Laird <contact@alexlaird.com>
6
6
  Maintainer-email: Alex Laird <contact@alexlaird.com>
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: amazon-orders
3
- Version: 4.4.2
3
+ Version: 4.4.4
4
4
  Summary: A Python library (and CLI) for Amazon order history
5
5
  Author-email: Alex Laird <contact@alexlaird.com>
6
6
  Maintainer-email: Alex Laird <contact@alexlaird.com>
@@ -1,3 +1,3 @@
1
1
  __copyright__ = "Copyright (c) 2024-2025 Alex Laird"
2
2
  __license__ = "MIT"
3
- __version__ = "4.4.2"
3
+ __version__ = "4.4.4"
@@ -126,6 +126,8 @@ def amazon_orders_cli(ctx: Context,
126
126
  @click.option("--full-details", is_flag=True, default=False,
127
127
  help="Get the full details for each Order in the history. "
128
128
  "This will execute an additional request per Order.")
129
+ @click.option("--order-filter", "order_filter", default=None,
130
+ help="The order type filter to use.")
129
131
  def history(ctx: Context,
130
132
  **kwargs: Any) -> None:
131
133
  """
@@ -142,6 +144,7 @@ def history(ctx: Context,
142
144
  start_index = kwargs["start_index"]
143
145
  single_page = kwargs["single_page"]
144
146
  full_details = kwargs["full_details"]
147
+ order_filter = kwargs["order_filter"]
145
148
 
146
149
  exclusive_flags = [year, last_3_months, last_30_days]
147
150
  if not all(not item for item in exclusive_flags) and sum(exclusive_flags) == 1:
@@ -180,7 +183,8 @@ Order History for {filter_description}{optional_start_index}{optional_full_detai
180
183
  start_index=start_index,
181
184
  full_details=full_details,
182
185
  keep_paging=not single_page,
183
- time_filter=time_filter):
186
+ time_filter=time_filter,
187
+ order_filter=order_filter):
184
188
  click.echo(f"{_order_output(o, config)}\n")
185
189
  total += 1
186
190
  end_time = time.time()
@@ -222,6 +226,67 @@ def order(ctx: Context,
222
226
  ctx.fail(str(e))
223
227
 
224
228
 
229
+ @amazon_orders_cli.command()
230
+ @click.pass_context
231
+ @click.argument("order_id")
232
+ def invoice(ctx: Context,
233
+ order_id: str) -> None:
234
+ """
235
+ Get the invoice for a given Amazon Order ID.
236
+ """
237
+ amazon_session = ctx.obj["amazon_session"]
238
+
239
+ try:
240
+ _authenticate(amazon_session)
241
+
242
+ config = ctx.obj["conf"]
243
+ amazon_orders = AmazonOrders(amazon_session,
244
+ config=config)
245
+
246
+ invoice_response = amazon_orders.get_invoice(order_id)
247
+
248
+ click.echo(invoice_response.response.text)
249
+ except AmazonOrdersAuthRedirectError:
250
+ _prompt_to_reauth_flow()
251
+ except AmazonOrdersError as e:
252
+ logger.debug("An error occurred.", exc_info=True)
253
+ ctx.fail(str(e))
254
+
255
+
256
+ @amazon_orders_cli.command("order-transactions")
257
+ @click.pass_context
258
+ @click.argument("order_id")
259
+ def order_transactions(ctx: Context,
260
+ order_id: str) -> None:
261
+ """
262
+ Get the Transactions for a given Amazon Order ID.
263
+ """
264
+ amazon_session = ctx.obj["amazon_session"]
265
+
266
+ try:
267
+ _authenticate(amazon_session)
268
+
269
+ config = ctx.obj["conf"]
270
+ amazon_transactions = AmazonTransactions(amazon_session,
271
+ config=config)
272
+
273
+ start_time = time.time()
274
+ total = 0
275
+ for t in amazon_transactions.get_transactions(order_id=order_id):
276
+ click.echo(f"{_transaction_output(t, config)}\n")
277
+ total += 1
278
+ end_time = time.time()
279
+
280
+ click.echo(
281
+ "... {total} Transactions parsed in {time} seconds.\n".format(total=total,
282
+ time=int(end_time - start_time)))
283
+ except AmazonOrdersAuthRedirectError:
284
+ _prompt_to_reauth_flow()
285
+ except AmazonOrdersError as e:
286
+ logger.debug("An error occurred.", exc_info=True)
287
+ ctx.fail(str(e))
288
+
289
+
225
290
  @amazon_orders_cli.command()
226
291
  @click.pass_context
227
292
  @click.option("--days", default=365,
@@ -112,6 +112,7 @@ class Constants:
112
112
  ORDER_DETAILS_URL = f"{BASE_URL}/gp/your-account/order-details"
113
113
  ORDER_INVOICE_URL = f"{BASE_URL}/gp/css/summary/print.html"
114
114
  HISTORY_FILTER_QUERY_PARAM = "timeFilter"
115
+ ORDER_FILTER_QUERY_PARAM = "orderFilter"
115
116
 
116
117
  ##########################################################################
117
118
  # URLs for Transactions
@@ -2,11 +2,13 @@ __copyright__ = "Copyright (c) 2024-2025 Alex Laird"
2
2
  __license__ = "MIT"
3
3
 
4
4
  import logging
5
+ import re
5
6
  from datetime import date
6
7
  from typing import Optional, TypeVar
7
8
 
8
9
  from bs4 import Tag
9
10
 
11
+ from amazonorders import util
10
12
  from amazonorders.conf import AmazonOrdersConfig
11
13
  from amazonorders.entity.parsable import Parsable
12
14
  from amazonorders.entity.seller import Seller
@@ -32,9 +34,12 @@ class Item(Parsable):
32
34
  #: The Item title.
33
35
  self.title: str = self.safe_simple_parse(selector=self.config.selectors.FIELD_ITEM_TITLE_SELECTOR,
34
36
  required=True)
35
- #: The Item link.
36
- self.link: str = self.safe_simple_parse(selector=self.config.selectors.FIELD_ITEM_LINK_SELECTOR,
37
- attr_name="href", required=True)
37
+ #: The Item link. ``None`` for items without an Amazon detail page (e.g. ASINLESS Whole Foods
38
+ #: Market line items).
39
+ self.link: Optional[str] = self.safe_simple_parse(selector=self.config.selectors.FIELD_ITEM_LINK_SELECTOR,
40
+ attr_name="href")
41
+ #: The product ASIN, derived from :attr:`link`; ``None`` when the link is not a product page.
42
+ self.asin: Optional[str] = self.safe_parse(self._parse_asin)
38
43
  #: The Item price.
39
44
  self.price: Optional[float] = self.to_currency(
40
45
  self.safe_simple_parse(selector=self.config.selectors.FIELD_ITEM_PRICE_SELECTOR)
@@ -57,9 +62,8 @@ class Item(Parsable):
57
62
  self.image_link: Optional[str] = self.safe_simple_parse(
58
63
  selector=self.config.selectors.FIELD_ITEM_IMG_LINK_SELECTOR,
59
64
  attr_name="src")
60
- #: The Item quantity.
61
- self.quantity: Optional[int] = self.safe_simple_parse(
62
- selector=self.config.selectors.FIELD_ITEM_QUANTITY_SELECTOR)
65
+ #: The Item quantity. ``None`` for items sold by weight (e.g. Whole Foods), which have no whole-unit count.
66
+ self.quantity: Optional[int] = self.safe_parse(self._parse_quantity)
63
67
 
64
68
  def __repr__(self) -> str:
65
69
  return f"<Item: \"{self.title}\">"
@@ -70,3 +74,24 @@ class Item(Parsable):
70
74
  def __lt__(self,
71
75
  other: ItemEntity) -> bool:
72
76
  return self.title < other.title
77
+
78
+ def _parse_asin(self) -> Optional[str]:
79
+ if not self.link:
80
+ return None
81
+ # ASIN only appears in the product URL path (/dp/, /gp/product/); no keyed attribute exists on order pages.
82
+ match = re.search(r"/(?:dp|gp/product|product)/([A-Z0-9]{10})(?:[/?]|$)", self.link)
83
+ return match.group(1) if match else None
84
+
85
+ def _parse_quantity(self) -> Optional[int]:
86
+ value = self.simple_parse(self.config.selectors.FIELD_ITEM_QUANTITY_SELECTOR)
87
+ if isinstance(value, int):
88
+ return value
89
+
90
+ # Whole Foods Market line items render quantity as "Qty: 1" (a whole count) or "Qty: 0.31 lb"
91
+ # (sold by weight, which has no integer quantity).
92
+ for tag in util.select(self.parsed, self.config.selectors.FIELD_ITEM_WHOLE_FOODS_QUANTITY_SELECTOR):
93
+ match = re.fullmatch(r"Qty:\s*(\d+)", tag.get_text(strip=True))
94
+ if match:
95
+ return int(match.group(1))
96
+
97
+ return None
@@ -3,6 +3,7 @@ __license__ = "MIT"
3
3
 
4
4
  import json
5
5
  import logging
6
+ import re
6
7
  from datetime import date
7
8
  from typing import Any, List, Optional, TypeVar, Union
8
9
 
@@ -52,6 +53,12 @@ class Order(Parsable):
52
53
  self.cancelled: bool = clone.cancelled if clone else bool(
53
54
  self.parsed and util.select(self.parsed, self.config.selectors.ORDER_SKIP_TOTALS))
54
55
 
56
+ #: ``True`` if this is a Whole Foods Market purchase (an in-store/FOPO purchase or a Whole Foods receipt
57
+ #: order). Unlike other unsupported order types, these expose a :attr:`grand_total` and (often) an
58
+ #: :attr:`item_count` on the history page, so those fields are populated.
59
+ self.is_whole_foods: bool = clone.is_whole_foods if clone else bool(
60
+ self.parsed and util.select(self.parsed, self.config.selectors.ORDER_WHOLE_FOODS))
61
+
55
62
  #: The Order Shipments.
56
63
  self.shipments: List[Shipment] = clone.shipments if clone else self._parse_shipments()
57
64
  #: The Order Items.
@@ -82,19 +89,19 @@ class Order(Parsable):
82
89
  parse_date=True)
83
90
  #: The Order Recipients.
84
91
  self.recipient: Recipient = clone.recipient if clone else self.safe_parse(self._parse_recipient)
92
+ #: The number of items in the purchase, when Amazon summarizes the count instead of listing the items
93
+ #: (e.g. Whole Foods Market orders show "N items in this purchase"). ``None`` when no such summary is shown.
94
+ self.item_count: Optional[int] = clone.item_count if clone else self.safe_parse(self._parse_item_count)
85
95
 
86
96
  # Fields below this point are only populated if `full_details` is True
87
97
 
88
- #: The Order payment method. Only populated when ``full_details`` is ``True``.
89
- self.payment_method: Optional[str] = self._if_full_details(
90
- self.safe_simple_parse(selector=self.config.selectors.FIELD_ORDER_PAYMENT_METHOD_SELECTOR,
91
- attr_name="alt"))
98
+ #: The Order payment method. Only populated when ``full_details`` is ``True``. For Whole Foods Market
99
+ #: orders this is the card brand of the first payment method on the receipt (e.g. "Visa").
100
+ self.payment_method: Optional[str] = self._if_full_details(self._parse_payment_method())
92
101
  #: The Order payment method's last 4 digits. Only populated when ``full_details`` is ``True``.
93
- self.payment_method_last_4: Optional[int] = self._if_full_details(
94
- self.safe_simple_parse(selector=self.config.selectors.FIELD_ORDER_PAYMENT_METHOD_LAST_4_SELECTOR,
95
- prefix_split="ending in"))
102
+ self.payment_method_last_4: Optional[int] = self._if_full_details(self._parse_payment_method_last_4())
96
103
  #: The Order subtotal. Only populated when ``full_details`` is ``True``.
97
- self.subtotal: Optional[float] = self._if_full_details(self._parse_currency("subtotal"))
104
+ self.subtotal: Optional[float] = self._if_full_details(self._parse_subtotal())
98
105
  #: The Order shipping total. Only populated when ``full_details`` is ``True``.
99
106
  self.shipping_total: Optional[float] = self._if_full_details(self._parse_currency("shipping"))
100
107
  #: The Order free shipping. Only populated when ``full_details`` is ``True``.
@@ -115,8 +122,9 @@ class Order(Parsable):
115
122
  else subscription_discount
116
123
  #: The Order total before tax. Only populated when ``full_details`` is ``True``.
117
124
  self.total_before_tax: Optional[float] = self._if_full_details(self._parse_currency("before tax"))
118
- #: The Order estimated tax. Only populated when ``full_details`` is ``True``.
119
- self.estimated_tax: Optional[float] = self._if_full_details(self._parse_currency("estimated tax"))
125
+ #: The Order estimated tax. Only populated when ``full_details`` is ``True``. For Whole Foods Market
126
+ #: orders this is the "Tax and Fees" total from the receipt.
127
+ self.estimated_tax: Optional[float] = self._if_full_details(self._parse_estimated_tax())
120
128
  #: The Order refund total. Only populated when ``full_details`` is ``True``.
121
129
  self.refund_total: Optional[float] = self._if_full_details(self._parse_currency("refund total"))
122
130
  #: The Multibuy discount. Only populated when ``full_details`` is ``True``.
@@ -167,8 +175,9 @@ class Order(Parsable):
167
175
  if len(util.select(self.parsed, self.config.selectors.ORDER_SKIP_TOTALS)) > 0:
168
176
  return None
169
177
 
170
- # Skip totals parsing for unsupported order types (Fresh, Whole Foods, physical stores)
171
- if len(util.select(self.parsed, self.config.selectors.ORDER_SKIP_ITEMS)) > 0:
178
+ # Skip totals parsing for unsupported order types (Amazon Fresh, physical stores). Whole Foods Market
179
+ # orders also match ORDER_SKIP_ITEMS, but they expose a grand total on the history page, so parse it.
180
+ if not self.is_whole_foods and len(util.select(self.parsed, self.config.selectors.ORDER_SKIP_ITEMS)) > 0:
172
181
  return None
173
182
 
174
183
  value = self.simple_parse(self.config.selectors.FIELD_ORDER_GRAND_TOTAL_SELECTOR)
@@ -193,6 +202,42 @@ class Order(Parsable):
193
202
 
194
203
  return value
195
204
 
205
+ def _parse_whole_foods_amount(self,
206
+ selector: str) -> Optional[float]:
207
+ return self.to_currency(self.simple_parse(selector))
208
+
209
+ def _parse_payment_method(self) -> Optional[str]:
210
+ if self.is_whole_foods:
211
+ return self.safe_simple_parse(
212
+ selector=self.config.selectors.FIELD_ORDER_WHOLE_FOODS_PAYMENT_METHOD_SELECTOR)
213
+ return self.safe_simple_parse(selector=self.config.selectors.FIELD_ORDER_PAYMENT_METHOD_SELECTOR,
214
+ attr_name="alt")
215
+
216
+ def _parse_payment_method_last_4(self) -> Optional[int]:
217
+ if self.is_whole_foods:
218
+ return self.safe_simple_parse(
219
+ selector=self.config.selectors.FIELD_ORDER_WHOLE_FOODS_PAYMENT_LAST_4_SELECTOR, prefix_split="*")
220
+ return self.safe_simple_parse(selector=self.config.selectors.FIELD_ORDER_PAYMENT_METHOD_LAST_4_SELECTOR,
221
+ prefix_split="ending in")
222
+
223
+ def _parse_subtotal(self) -> Optional[float]:
224
+ if self.is_whole_foods:
225
+ return self._parse_whole_foods_amount(self.config.selectors.FIELD_ORDER_WHOLE_FOODS_SUBTOTAL_SELECTOR)
226
+ return self._parse_currency("subtotal")
227
+
228
+ def _parse_estimated_tax(self) -> Optional[float]:
229
+ if self.is_whole_foods:
230
+ return self._parse_whole_foods_amount(self.config.selectors.FIELD_ORDER_WHOLE_FOODS_TAX_SELECTOR)
231
+ return self._parse_currency("estimated tax")
232
+
233
+ def _parse_item_count(self) -> Optional[int]:
234
+ for tag in util.select(self.parsed, self.config.selectors.FIELD_ORDER_ITEM_COUNT_SELECTOR):
235
+ match = re.search(r"(\d+)\s+items?\s+in this purchase", tag.text)
236
+ if match:
237
+ return int(match.group(1))
238
+
239
+ return None
240
+
196
241
  def _parse_recipient(self) -> Optional[Recipient]:
197
242
  # At least for now, we don't populate Recipient data for digital orders
198
243
  if util.select_one(self.parsed, self.config.selectors.FIELD_ORDER_GIFT_CARD_INSTANCE_SELECTOR):
@@ -6,6 +6,7 @@ import concurrent.futures
6
6
  import datetime
7
7
  import logging
8
8
  from typing import Any, Callable, List, Optional
9
+ from urllib.parse import quote
9
10
 
10
11
  from bs4 import Tag
11
12
 
@@ -103,7 +104,8 @@ class AmazonOrders:
103
104
  start_index: Optional[int] = None,
104
105
  full_details: bool = False,
105
106
  keep_paging: bool = True,
106
- time_filter: Optional[str] = None) -> List[Order]:
107
+ time_filter: Optional[str] = None,
108
+ order_filter: Optional[str] = None) -> List[Order]:
107
109
  """
108
110
  Get the Amazon Order history for a given time period.
109
111
 
@@ -119,6 +121,7 @@ class AmazonOrders:
119
121
  :param time_filter: The time filter to use. Supported values are ``"last30"`` (last 30 days),
120
122
  ``"months-3"`` (past 3 months), or ``"year-YYYY"`` (specific year). If provided, this takes
121
123
  precedence over the ``year`` parameter.
124
+ :param order_filter: The order type filter to use. If provided, appended alongside the time filter.
122
125
  :return: A list of the requested Orders.
123
126
  """
124
127
  if not self.amazon_session.is_authenticated:
@@ -144,12 +147,17 @@ class AmazonOrders:
144
147
  filter_value = f"year-{year}"
145
148
 
146
149
  optional_start_index = f"&startIndex={start_index}" if start_index else ""
150
+ optional_order_filter = (
151
+ f"&{self.config.constants.ORDER_FILTER_QUERY_PARAM}={quote(order_filter, safe='')}"
152
+ if order_filter else ""
153
+ )
147
154
  next_page: Optional[str] = (
148
- "{url}?{query_param}={filter_value}{optional_start_index}"
155
+ "{url}?{query_param}={filter_value}{optional_order_filter}{optional_start_index}"
149
156
  ).format(
150
157
  url=self.config.constants.ORDER_HISTORY_URL,
151
158
  query_param=self.config.constants.HISTORY_FILTER_QUERY_PARAM,
152
159
  filter_value=filter_value,
160
+ optional_order_filter=optional_order_filter,
153
161
  optional_start_index=optional_start_index
154
162
  )
155
163
 
@@ -208,7 +216,17 @@ class AmazonOrders:
208
216
  order: Order = self.config.order_cls(order_tag, self.config, index=current_index)
209
217
 
210
218
  if full_details:
211
- if len(util.select(order.parsed, self.config.selectors.ORDER_SKIP_ITEMS)) > 0:
219
+ if order.is_whole_foods:
220
+ # Whole Foods orders are served by a dedicated details page (the in-store/FOPO page or the
221
+ # receipt page), never the standard order-details endpoint, so only fetch full details when
222
+ # the history page linked us to one of those routes.
223
+ link = order.order_details_link or ""
224
+ if "/fopo/order-details" in link or "/wholefoodsmarket/receipts/order/" in link:
225
+ order = self._get_whole_foods_order(order, link)
226
+ else:
227
+ logger.warning(f"Order {order.order_number} is a Whole Foods Market order whose details "
228
+ f"page could not be located, so it was left partially populated.")
229
+ elif len(util.select(order.parsed, self.config.selectors.ORDER_SKIP_ITEMS)) > 0:
212
230
  logger.warning(f"Order {order.order_number} was partially populated, "
213
231
  f"since it is an unsupported Order type.")
214
232
  elif not order.order_number:
@@ -219,6 +237,24 @@ class AmazonOrders:
219
237
 
220
238
  return order
221
239
 
240
+ def _get_whole_foods_order(self,
241
+ order: Order,
242
+ url: str) -> Order:
243
+ """Fetches the WFM dedicated details page and returns the order enriched with per-item details."""
244
+ details_response = self.amazon_session.get(url)
245
+ self.amazon_session.check_response(details_response, meta={"index": order.index})
246
+
247
+ details_tag = util.select_one(details_response.parsed,
248
+ self.config.selectors.ORDER_DETAILS_ENTITY_SELECTOR)
249
+
250
+ if not details_tag:
251
+ logger.warning(f"Could not parse Whole Foods Market details for Order {order.order_number}, "
252
+ f"so it was left partially populated.")
253
+ return order
254
+
255
+ return self.config.order_cls(details_tag, self.config, full_details=True, clone=order,
256
+ order_number=order.order_number)
257
+
222
258
  async def _async_wrapper(self,
223
259
  func: Callable,
224
260
  *args: Any) -> Order:
@@ -86,23 +86,34 @@ class Selectors:
86
86
  "div.order"]
87
87
  ORDER_HISTORY_COUNT_SELECTOR = ".js-yo-container span.num-orders"
88
88
  ORDER_DETAILS_ENTITY_SELECTOR = ["div#orderDetails",
89
- "div#ordersContainer"]
89
+ "div#ordersContainer",
90
+ "div#odp-main-section"]
90
91
  ITEM_ENTITY_SELECTOR = ["[data-component='purchasedItems'] .a-fixed-left-grid",
91
92
  "div:has(> div.yohtmlc-item)",
92
- ".item-box"]
93
+ ".item-box",
94
+ # WFM in-store line items
95
+ "div.a-row.a-spacing-base:has(img.ufpo-itemListWidget-image)"]
93
96
  SHIPMENT_ENTITY_SELECTOR = ["[data-component='orderCard'] [data-component='shipments'] .a-box",
94
97
  "div.shipment",
95
98
  "div.delivery-box"]
96
99
  # Selectors defined here mean we don't have a reliable way to parse all details in an Order, so Items and
97
100
  # Shipments will be skipped
98
101
  ORDER_SKIP_ITEMS = [
99
- # Identifies an Amazon Fresh order
102
+ # Identifies an Amazon Fresh order (also matched by WFM in-store; distinguished via ORDER_WHOLE_FOODS)
100
103
  ".brand-info-box .brand-logo img",
101
- # Identifies a Whole Foods Market order
104
+ # Identifies a Whole Foods Market receipt order
102
105
  "a.yohtmlc-order-details-link[href^='/wholefoodsmarket']",
103
106
  # Identifies an order from a physical Amazon store
104
107
  Selector("div.yohtmlc-shipment-status-primaryText", "Purchased at Amazon")
105
108
  ]
109
+ # Selectors identifying a Whole Foods Market purchase. Unlike ORDER_SKIP_ITEMS entries, WFM orders
110
+ # expose a grand_total (and often item_count) on the history page, so those fields are populated.
111
+ ORDER_WHOLE_FOODS = [
112
+ "a[href*='/wholefoodsmarket/receipts/order/']",
113
+ "a[href*='/fopo/order-details']",
114
+ Selector("div.yohtmlc-shipment-status-primaryText", text_contains="Whole Foods Market"),
115
+ "img.ufpo-itemListWidget-image"
116
+ ]
106
117
  # Selectors defined here mean the Order will not have parsable totals
107
118
  ORDER_SKIP_TOTALS = [
108
119
  # Identifies a cancelled order on the history page
@@ -115,19 +126,28 @@ class Selectors:
115
126
  # CSS selectors for Item fields
116
127
  #####################################
117
128
 
118
- FIELD_ITEM_IMG_LINK_SELECTOR = "a img"
129
+ # Last entry matches WFM in-store line items
130
+ FIELD_ITEM_IMG_LINK_SELECTOR = ["a img",
131
+ "img.ufpo-itemListWidget-image"]
119
132
  FIELD_ITEM_QUANTITY_SELECTOR = [".od-item-view-qty",
120
133
  "span.item-view-qty",
121
134
  "span.product-image__qty"]
135
+ # WFM items render "Qty: N" or "Qty: 0.31 lb"; extracted in Item._parse_quantity
136
+ FIELD_ITEM_WHOLE_FOODS_QUANTITY_SELECTOR = ["span.a-size-small"]
122
137
  FIELD_ITEM_TITLE_SELECTOR = ["[data-component='itemTitle']",
123
- ".yohtmlc-item a", ".yohtmlc-product-title"]
138
+ ".yohtmlc-item a", ".yohtmlc-product-title",
139
+ "div.a-column.a-span10 > a",
140
+ # ASINLESS WFM items render the title in a span rather than a link
141
+ "div.a-column.a-span10 > span"]
124
142
  FIELD_ITEM_LINK_SELECTOR = ["[data-component='itemTitle'] a",
125
143
  ".yohtmlc-item a",
126
144
  "a:has(> .yohtmlc-product-title)",
127
- ".yohtmlc-product-title a"]
145
+ ".yohtmlc-product-title a",
146
+ "div.a-column.a-span10 > a"]
128
147
  FIELD_ITEM_TAG_ITERATOR_SELECTOR = [".yohtmlc-item div"]
129
148
  FIELD_ITEM_PRICE_SELECTOR = ["[data-component='unitPrice'] .a-text-price :not(.a-offscreen)",
130
- ".yohtmlc-item .a-color-price"]
149
+ ".yohtmlc-item .a-color-price",
150
+ "div.a-section.a-text-right span.a-size-small"]
131
151
  FIELD_ITEM_SELLER_SELECTOR = ["[data-component='orderedMerchant']"] + FIELD_ITEM_TAG_ITERATOR_SELECTOR
132
152
  FIELD_ITEM_RETURN_SELECTOR = ["[data-component='itemReturnEligibility']"] + FIELD_ITEM_TAG_ITERATOR_SELECTOR
133
153
 
@@ -138,7 +158,9 @@ class Selectors:
138
158
  FIELD_ORDER_DETAILS_LINK_SELECTOR = ["a.yohtmlc-order-details-link",
139
159
  # Would like to use this or similar, but not yet sure how consistent it is
140
160
  # ".order-header__header-link-list-item:first-of-type a"
141
- ]
161
+ # WFM receipt and FOPO orders link to dedicated pages, not standard endpoint
162
+ "a[href*='/wholefoodsmarket/receipts/order/']",
163
+ "a[href*='/fopo/order-details']"]
142
164
  FIELD_ORDER_NUMBER_SELECTOR = ["[data-component='orderId']",
143
165
  "[data-component='briefOrderInfo'] div.a-column",
144
166
  ".order-date-invoice-item :is(bdi, span)[dir='ltr']",
@@ -146,7 +168,13 @@ class Selectors:
146
168
  ":is(bdi, span)[dir='ltr']"]
147
169
  FIELD_ORDER_GRAND_TOTAL_SELECTOR = ["div.yohtmlc-order-total span.value",
148
170
  "div.order-header div.a-column.a-span2",
149
- "div.order-header div.a-col-left .a-span9"]
171
+ "div.order-header div.a-col-left .a-span9",
172
+ "#wfm-grand-total-amount"]
173
+ # WFM in-store (FOPO) details page amounts
174
+ FIELD_ORDER_WHOLE_FOODS_SUBTOTAL_SELECTOR = "#wfm-subtotal-amount"
175
+ FIELD_ORDER_WHOLE_FOODS_TAX_SELECTOR = "#wfm-tax-total-amount"
176
+ FIELD_ORDER_WHOLE_FOODS_PAYMENT_METHOD_SELECTOR = "#wfm-0-card-brand"
177
+ FIELD_ORDER_WHOLE_FOODS_PAYMENT_LAST_4_SELECTOR = "#wfm-0-card-tail"
150
178
  FIELD_ORDER_PLACED_DATE_SELECTOR = ["[data-component='orderDate']",
151
179
  "span.order-date-invoice-item",
152
180
  "[data-component='briefOrderInfo'] div.a-column",
@@ -162,6 +190,8 @@ class Selectors:
162
190
  FIELD_ORDER_ADDRESS_FALLBACK_1_SELECTOR = "div.recipient span.a-declarative"
163
191
  FIELD_ORDER_ADDRESS_FALLBACK_2_SELECTOR = "script[id^='shipToData']"
164
192
  FIELD_ORDER_GIFT_CARD_INSTANCE_SELECTOR = ".gift-card-instance"
193
+ FIELD_ORDER_ITEM_COUNT_SELECTOR = ["div.a-fixed-left-grid-col.a-col-right span",
194
+ "span"]
165
195
 
166
196
  #####################################
167
197
  # CSS selectors for Shipment fields
File without changes
File without changes
File without changes
File without changes