amazon-orders-de 1.0.0__py3-none-any.whl

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.
@@ -0,0 +1,11 @@
1
+ """
2
+ An ``amazon-orders`` language package for amazon.de, which requests its pages in English. Enable it with
3
+ ``language_package: amazon_orders_de`` and ``domain: amazon.de`` in the config.
4
+ """
5
+
6
+ from amazon_orders_de.constants import Constants
7
+ from amazon_orders_de.order import Order
8
+ from amazon_orders_de.selectors import Selectors
9
+ from amazon_orders_de.transactions import TransactionsPage
10
+
11
+ __all__ = ["Constants", "Order", "Selectors", "TransactionsPage"]
@@ -0,0 +1,21 @@
1
+ from amazonorders import constants
2
+
3
+
4
+ class Constants(constants.Constants):
5
+ """
6
+ Constants for amazon.de, requested in English.
7
+ """
8
+
9
+ # Ask for the English version of the site, so its pages are parsed with the English words of ``amazon-orders``
10
+ BASE_HEADERS = {**constants.Constants.BASE_HEADERS, "Accept-Language": "en-GB,en;q=0.9"}
11
+
12
+ # amazon.de picks its language from this cookie rather than from ``Accept-Language``, and sets it again from the
13
+ # account's language on every response
14
+ REQUEST_COOKIES = {"lc-acbde": "en_GB"}
15
+
16
+ #: The internal API that loads further pages of Transactions.
17
+ TRANSACTION_HISTORY_API_ROUTE = "/payments-portal/data/iris/live/v1/data/manage/get-transactions"
18
+
19
+ @property
20
+ def TRANSACTION_HISTORY_API_URL(self) -> str:
21
+ return f"{self.BASE_URL}{self.TRANSACTION_HISTORY_API_ROUTE}"
@@ -0,0 +1,46 @@
1
+ import re
2
+
3
+ from amazonorders import util
4
+ from amazonorders.entity import order, recipient
5
+
6
+
7
+ class Recipient(recipient.Recipient):
8
+ """
9
+ A Recipient whose address is rendered as an unclassed list, one or more lines per list item.
10
+ """
11
+
12
+ def _parse_address(self) -> str | None:
13
+ return self._parse_address_lines() or super()._parse_address()
14
+
15
+ def _parse_address_lines(self) -> str | None:
16
+ lines: list[str] = []
17
+ for tag in util.select(self.parsed, self.config.selectors.FIELD_RECIPIENT_ADDRESS_LINES_SELECTOR):
18
+ # A list item may hold several lines separated by <br> or, when Amazon renders the address inline,
19
+ # by commas
20
+ lines.extend(line.strip() for line in re.split(r"[\n,]", tag.get_text("\n")) if line.strip())
21
+
22
+ return "\n".join(lines) or None
23
+
24
+
25
+ class Order(order.Order):
26
+ """
27
+ An Order on amazon.de.
28
+ """
29
+
30
+ def _parse_payment_method(self) -> str | None:
31
+ # When a Gift Card was used alongside a card, prefer the card (the instrument with a number), like its last
32
+ # digits are
33
+ selectors = self.config.selectors
34
+ for instrument in util.select(self.parsed, selectors.FIELD_ORDER_PAYMENT_INSTRUMENT_SELECTOR):
35
+ name_tag = util.select_one(instrument, selectors.FIELD_ORDER_PAYMENT_INSTRUMENT_NAME_SELECTOR)
36
+ if name_tag and util.select_one(instrument, selectors.FIELD_ORDER_PAYMENT_INSTRUMENT_NUMBER_SELECTOR):
37
+ return name_tag.text.strip() or None
38
+
39
+ return super()._parse_payment_method()
40
+
41
+ def _parse_recipient(self) -> recipient.Recipient | None:
42
+ value = super()._parse_recipient()
43
+ if value is None:
44
+ return None
45
+
46
+ return Recipient(value.parsed, self.config)
@@ -0,0 +1,30 @@
1
+ from amazonorders import selectors
2
+ from amazonorders.selectors import Selector
3
+
4
+
5
+ class Selectors(selectors.Selectors):
6
+ """
7
+ Selectors for amazon.de, requested in English, which uses British English labels.
8
+ """
9
+
10
+ # Rendered as "Postage & Packing"
11
+ FIELD_ORDER_SHIPPING_TOTAL_LABELS = [*selectors.Selectors.FIELD_ORDER_SHIPPING_TOTAL_LABELS, "postage"]
12
+ # Rendered as "Estimated VAT"
13
+ FIELD_ORDER_ESTIMATED_TAX_LABELS = [*selectors.Selectors.FIELD_ORDER_ESTIMATED_TAX_LABELS, "estimated vat"]
14
+
15
+ ORDER_SKIP_TOTALS = [
16
+ *selectors.Selectors.ORDER_SKIP_TOTALS,
17
+ # Identifies an Order of a cancelled service (e.g. a cancelled installation) on the history page
18
+ Selector("div.yohtmlc-shipment-status-primaryText", "Service cancelled"),
19
+ ]
20
+
21
+ #: The payment instruments an Order was paid with, and each one's name and masked number.
22
+ FIELD_ORDER_PAYMENT_INSTRUMENT_SELECTOR = "[data-testid='payment-instrument']"
23
+ FIELD_ORDER_PAYMENT_INSTRUMENT_NAME_SELECTOR = "[data-testid='payment-instrument-name']"
24
+ FIELD_ORDER_PAYMENT_INSTRUMENT_NUMBER_SELECTOR = "[data-testid='payment-instrument-number']"
25
+
26
+ #: The address lines that follow the Recipient's name in an unclassed list.
27
+ FIELD_RECIPIENT_ADDRESS_LINES_SELECTOR = "ul > li:not(:first-child)"
28
+
29
+ #: The Transactions, embedded as JSON in the Next.js page.
30
+ TRANSACTION_HISTORY_NEXT_DATA_SELECTOR = "script#__NEXT_DATA__"
@@ -0,0 +1,157 @@
1
+ import json
2
+ import logging
3
+ import re
4
+ from typing import Any
5
+
6
+ from amazonorders import transactions, util
7
+ from amazonorders.entity.transaction import Transaction
8
+ from amazonorders.exception import AmazonOrdersEntityError, AmazonOrdersError
9
+ from amazonorders.session import AmazonSession
10
+ from bs4 import Tag
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ #: An Order number within a Transaction's Order description.
15
+ _ORDER_NUMBER_RE = re.compile(r"\b(\d{3}-\d{7}-\d{7}|D\d{2}-\d{7}-\d{7})\b")
16
+
17
+ _PageResult = tuple[list[Transaction], dict[str, Any] | None]
18
+
19
+
20
+ class TransactionsPage(transactions.TransactionsPage):
21
+ """
22
+ The Transactions page of amazon.de, a Next.js page that embeds its first page of Transactions as JSON and loads
23
+ further pages from an internal API.
24
+ """
25
+
26
+ def get_page(
27
+ self, amazon_session: AmazonSession, url: str, next_page_data: dict[str, Any] | None = None
28
+ ) -> _PageResult:
29
+ if not next_page_data or "request" not in next_page_data:
30
+ return super().get_page(amazon_session, url, next_page_data)
31
+
32
+ page_response = amazon_session.post(
33
+ self.config.constants.TRANSACTION_HISTORY_API_URL,
34
+ json=next_page_data["request"],
35
+ headers={"x-amzn-upx-token": next_page_data["token"]},
36
+ )
37
+ amazon_session.check_response(page_response, meta=next_page_data)
38
+
39
+ try:
40
+ data = page_response.response.json()
41
+ except ValueError as e:
42
+ raise AmazonOrdersError(
43
+ "Could not parse Transaction history. Check if Amazon changed the API.", meta=next_page_data
44
+ ) from e
45
+
46
+ return self._parse_api_response(data, next_page_data)
47
+
48
+ def parse_page(self, parsed: Tag) -> _PageResult:
49
+ next_data_tag = util.select_one(parsed, self.config.selectors.TRANSACTION_HISTORY_NEXT_DATA_SELECTOR)
50
+ if next_data_tag:
51
+ result = self._parse_next_data(next_data_tag)
52
+ if result is not None:
53
+ return result
54
+
55
+ return super().parse_page(parsed)
56
+
57
+ def _parse_next_data(self, next_data_tag: Tag) -> _PageResult | None:
58
+ try:
59
+ page_props = json.loads(next_data_tag.text)["props"]["pageProps"]
60
+ state = page_props["state"]["transactionResponseState"]
61
+ transaction_list = state["visibleTransactionResponse"]["transactionList"]
62
+ except (ValueError, KeyError, TypeError):
63
+ return None
64
+
65
+ loaded_transactions = self._parse_transactions_data(transaction_list)
66
+
67
+ # Further pages are loaded from an internal API, authorized by the token embedded in the page
68
+ next_page_data = None
69
+ if state.get("hasMore") and state.get("lastEvaluatedPageKey"):
70
+ try:
71
+ next_page_data = {
72
+ "token": page_props["token"],
73
+ "request": {
74
+ "type": "GetTransactions",
75
+ "locale": page_props["request"]["locale"],
76
+ "surfaceInfo": page_props["manageWalletRequest"]["requestContext"]["surfaceInfo"],
77
+ "transactionsViewRequest": {"filtersControls": {"includeFilters": True}},
78
+ "widgetName": "ViewTransactions",
79
+ "exclusiveStartKey": state["lastEvaluatedPageKey"],
80
+ },
81
+ }
82
+ except (KeyError, TypeError):
83
+ logger.warning(
84
+ "Only the first page of Transactions could be loaded, as the data to load further "
85
+ "pages was not found."
86
+ )
87
+
88
+ return loaded_transactions, next_page_data
89
+
90
+ def _parse_api_response(self, data: dict[str, Any], next_page_data: dict[str, Any]) -> _PageResult:
91
+ try:
92
+ display_response = data["displayResponse"]
93
+ transaction_list = display_response["transactionsList"]
94
+ except (KeyError, TypeError) as e:
95
+ raise AmazonOrdersError(
96
+ "Could not parse Transaction history. Check if Amazon changed the API.", meta=next_page_data
97
+ ) from e
98
+
99
+ loaded_transactions = self._parse_transactions_data(transaction_list)
100
+
101
+ last_evaluated_key = display_response.get("lastEvaluatedKey")
102
+ if not last_evaluated_key:
103
+ return loaded_transactions, None
104
+
105
+ return loaded_transactions, {
106
+ "token": next_page_data["token"],
107
+ "request": dict(next_page_data["request"], exclusiveStartKey=last_evaluated_key),
108
+ }
109
+
110
+ def _parse_transactions_data(self, transaction_list: list[dict[str, Any]]) -> list[Transaction]:
111
+ config = self.config
112
+ loaded_transactions = []
113
+ for data in transaction_list:
114
+ date_str = data.get("formattedDate")
115
+ completed_date = config.constants.parse_date(date_str)
116
+ if completed_date is None:
117
+ err_msg = (
118
+ f"Transaction date {date_str!r} could not be parsed, but it's required. Check if "
119
+ f"Amazon changed the page"
120
+ )
121
+ if not config.warn_on_missing_required_field:
122
+ raise AmazonOrdersEntityError(f"{err_msg} or set warn_on_missing_required_field=True in config.")
123
+
124
+ logger.warning(f"{err_msg}.")
125
+ continue
126
+
127
+ grand_total = config.constants.parse_currency(data.get("formattedAmount"))
128
+ if grand_total is None:
129
+ logger.warning(
130
+ f"Transaction amount {data.get('formattedAmount')!r} could not be parsed, so the "
131
+ f"Transaction was skipped."
132
+ )
133
+ continue
134
+
135
+ payment_data = data.get("paymentMethodDisplayStringData") or {}
136
+ payment_method_name = payment_data.get("paymentMethodName")
137
+ last_digits = (payment_data.get("paymentMethodNumber") or {}).get("lastDigits")
138
+ # When a Transaction covers more than one Order, only the first one is used
139
+ order_data = (data.get("orderData") or [{}])[0]
140
+ order_number_match = _ORDER_NUMBER_RE.search(order_data.get("orderDisplayString") or "")
141
+
142
+ loaded_transactions.append(
143
+ config.transaction_cls.from_fields(
144
+ config,
145
+ completed_date,
146
+ grand_total,
147
+ payment_method=f"{payment_method_name} ****{last_digits}"
148
+ if payment_method_name and last_digits
149
+ else payment_method_name,
150
+ payment_method_last_4=last_digits,
151
+ order_number=order_number_match.group(1) if order_number_match else None,
152
+ order_details_link=order_data.get("orderDetailsUrl"),
153
+ seller=data.get("statementDescriptor"),
154
+ )
155
+ )
156
+
157
+ return loaded_transactions
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.5
2
+ Name: amazon-orders-de
3
+ Version: 1.0.0
4
+ Summary: An amazon-orders language package for amazon.de, which parses its pages in English
5
+ Project-URL: Source, https://github.com/udondan/amazon-orders-de
6
+ Author: Daniel Schroeder
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Classifier: Operating System :: OS Independent
10
+ Classifier: Programming Language :: Python :: 3
11
+ Requires-Python: >=3.10
12
+ Requires-Dist: amazon-orders<5,>=4.8.1
13
+ Description-Content-Type: text/markdown
14
+
15
+ # amazon-orders-de
16
+
17
+ A [language package](https://amazon-orders.readthedocs.io/language-packages.html) for
18
+ [amazon-orders](https://github.com/alexdlaird/amazon-orders) that adds support for amazon.de.
19
+
20
+ Rather than translating `amazon-orders` into German, it asks amazon.de for its pages in English and handles what's
21
+ different there:
22
+
23
+ - British English labels ("Postage & Packing", "Estimated VAT")
24
+ - Orders whose service was cancelled
25
+ - Recipient addresses rendered as a plain list
26
+ - The payment method of an Order paid with a Gift Card and a card
27
+ - The Transactions page, which embeds its first page as JSON and loads further pages from an API
28
+
29
+ ## Installation
30
+
31
+ ```sh
32
+ pip install amazon-orders-de
33
+ ```
34
+
35
+ ## Usage
36
+
37
+ Set `language_package` and `domain` in the `amazon-orders` config (`~/.config/amazonorders/config.yml`):
38
+
39
+ ```yaml
40
+ language_package: amazon_orders_de
41
+ domain: amazon.de
42
+ ```
43
+
44
+ or pass them in Python:
45
+
46
+ ```python
47
+ from amazonorders.conf import AmazonOrdersConfig
48
+ from amazonorders.orders import AmazonOrders
49
+ from amazonorders.session import AmazonSession
50
+
51
+ config = AmazonOrdersConfig(data={"language_package": "amazon_orders_de", "domain": "amazon.de"})
52
+ amazon_session = AmazonSession(config=config)
53
+ amazon_session.login()
54
+
55
+ orders = AmazonOrders(amazon_session).get_order_history(year=2026)
56
+ ```
57
+
58
+ Everything else works as described in the [amazon-orders docs](https://amazon-orders.readthedocs.io).
59
+
60
+ ## Development
61
+
62
+ With [mise](https://mise.jdx.dev/), which installs Python and uv:
63
+
64
+ ```sh
65
+ mise run install # uv sync
66
+ mise run lint # ruff and mypy; `mise run lint-fix` fixes what it can
67
+ mise run test
68
+ ```
69
+
70
+ Commits follow [Conventional Commits](https://www.conventionalcommits.org/), from which release-please creates the
71
+ releases and the changelog.
72
+
73
+ The unit tests parse real amazon.de pages in `tests/resources`. Before a page is added there, it's anonymized with
74
+ `scripts/anonymize-test-resources.py`, which removes personal and product details. The raw pages and the rules file
75
+ with the values to replace must never be committed.
76
+
77
+ The integration tests in `tests/integration` log in to amazon.de with `AMAZON_USERNAME`, `AMAZON_PASSWORD`, and
78
+ `AMAZON_OTP_SECRET_KEY`, and run weekly in CI.
@@ -0,0 +1,9 @@
1
+ amazon_orders_de/__init__.py,sha256=Rwo3_V20Q7-Db_lqypjMJwSxk4otMBoRO5OwVMCGSNM,459
2
+ amazon_orders_de/constants.py,sha256=AXOdeYhDFXXm_pjLBejVIuuJG-elwEfvy0PwKqmbi8Y,860
3
+ amazon_orders_de/order.py,sha256=Hw70lKVt-S0cjCEm5YZ9VcWHrudi3DxPBhHI_lpmYNI,1766
4
+ amazon_orders_de/selectors.py,sha256=ppMKmlB8KA-stYbNNMTb-J2QUOulUhMzkxDnHcFnDBY,1436
5
+ amazon_orders_de/transactions.py,sha256=kx3uM9M5nERqkegtnEa9x8GyrRo95dDsuAXuDHd4BWw,6881
6
+ amazon_orders_de-1.0.0.dist-info/METADATA,sha256=JqnrvQhsGDAP8NmejpG3X2I2dAB8vYS8JWWc_9SFtEQ,2692
7
+ amazon_orders_de-1.0.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
8
+ amazon_orders_de-1.0.0.dist-info/licenses/LICENSE,sha256=vB-LyFzAYUHN758HXTKXGZdIfJe0iiDEUHT_KA7Czh8,1073
9
+ amazon_orders_de-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Daniel Schroeder
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.