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.
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/CHANGELOG.md +22 -1
- {amazon_orders-4.4.2/amazon_orders.egg-info → amazon_orders-4.4.4}/PKG-INFO +1 -1
- {amazon_orders-4.4.2 → amazon_orders-4.4.4/amazon_orders.egg-info}/PKG-INFO +1 -1
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/__init__.py +1 -1
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/cli.py +66 -1
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/constants.py +1 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/item.py +31 -6
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/order.py +57 -12
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/orders.py +39 -3
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/selectors.py +40 -10
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/LICENSE +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/MANIFEST.in +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/README.md +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/SOURCES.txt +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/dependency_links.txt +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/entry_points.txt +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/requires.txt +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazon_orders.egg-info/top_level.txt +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/banner.txt +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/conf.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/__init__.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/browser/__init__.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/browser/playwright.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/__init__.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/anticaptcha.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/base.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/capsolver.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/contrib/waf/twocaptcha.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/__init__.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/parsable.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/recipient.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/seller.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/shipment.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/entity/transaction.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/exception.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/forms.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/session.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/transactions.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/amazonorders/util.py +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/pyproject.toml +0 -0
- {amazon_orders-4.4.2 → amazon_orders-4.4.4}/setup.cfg +0 -0
- {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.
|
|
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
|
|
|
@@ -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
|
-
|
|
37
|
-
|
|
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.
|
|
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
|
-
|
|
90
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
171
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|