amazon-subscriptions 1.0.1__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,85 @@
1
+ """Parse the subscription tiles of the landing page and of its paginated grid fragments."""
2
+
3
+ import logging
4
+ from datetime import date
5
+ from urllib.parse import parse_qs, urlparse
6
+
7
+ from bs4 import Tag
8
+
9
+ from amazon_subscriptions import dates
10
+ from amazon_subscriptions.exceptions import PageStructureError
11
+ from amazon_subscriptions.locales.base import SubscriptionLocale, normalize_space
12
+ from amazon_subscriptions.models import Subscription, SubscriptionStatus
13
+ from amazon_subscriptions.parse import selectors
14
+ from amazon_subscriptions.parse.util import Html, attr_of, check_page, soup_of, text_of
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+
19
+ def parse_subscriptions(
20
+ html: Html, locale: SubscriptionLocale, today: date, page: str | None = None
21
+ ) -> list[Subscription]:
22
+ """Parse the subscriptions of the landing page or of a fragment of its paginated grid.
23
+
24
+ :param html: The landing page, or a fragment returned by the grid's pagination.
25
+ :param locale: The locale of the storefront.
26
+ :param today: The date the page was loaded, to resolve dates rendered without year.
27
+ :param page: Name of the page for error messages (e.g. the debug file).
28
+ :raises PageStructureError: If the page has no subscriptions and does not say so.
29
+ """
30
+ soup = soup_of(html)
31
+ check_page(soup, page)
32
+ tiles = soup.select(selectors.SUBSCRIPTION_TILE)
33
+ if not tiles:
34
+ if locale.NO_SUBSCRIPTIONS_TEXT and locale.NO_SUBSCRIPTIONS_TEXT in soup.get_text(" "):
35
+ return []
36
+ raise PageStructureError(f"No subscriptions found ({selectors.SUBSCRIPTION_TILE!r})", page)
37
+ return [_parse_tile(tile, locale, today, page) for tile in tiles]
38
+
39
+
40
+ def parse_subscriptions_next_url(html: Html) -> str | None:
41
+ """The URL of the next page of the subscriptions grid, if there is one."""
42
+ return attr_of(soup_of(html).select_one(selectors.SUBSCRIPTIONS_NEXT_PAGE), "data-next-url")
43
+
44
+
45
+ def _parse_tile(tile: Tag, locale: SubscriptionLocale, today: date, page: str | None) -> Subscription:
46
+ edit_url = attr_of(tile, "data-edit-url") or ""
47
+ query = parse_qs(urlparse(edit_url).query)
48
+ subscription_id = next(iter(query.get("subscriptionId", [])), None)
49
+ if not subscription_id:
50
+ raise PageStructureError(f"Subscription tile without subscription id: {edit_url!r}", page)
51
+ asin = next(iter(query.get("subAsin", [])), None)
52
+
53
+ title_tag = tile.select_one(selectors.SUBSCRIPTION_TILE_TITLE)
54
+ image = tile.select_one(selectors.SUBSCRIPTION_TILE_IMAGE)
55
+ subscription = Subscription(
56
+ subscription_id=subscription_id,
57
+ asin=asin,
58
+ title=text_of(title_tag) or attr_of(image, "alt"),
59
+ product_url=f"/dp/{asin}" if asin else None,
60
+ image_url=attr_of(image, "src"),
61
+ currency=locale.currency,
62
+ )
63
+
64
+ status_texts = []
65
+ for string in tile.find_all(string=True):
66
+ text = normalize_space(string)
67
+ if not text or any(parent is title_tag for parent in string.parents):
68
+ continue
69
+ if text.startswith(locale.NEXT_DELIVERY_PREFIX):
70
+ subscription.next_delivery_date = dates.resolve(
71
+ locale.parse_date_parts(text), lambda d, m: dates.next_delivery(d, m, today)
72
+ )
73
+ elif (quantity_interval := locale.parse_quantity_interval(text, warn=False)) is not None:
74
+ subscription.quantity, subscription.interval = quantity_interval
75
+ elif text not in locale.TILE_ACTION_TEXTS:
76
+ status_texts.append(text)
77
+
78
+ if status_texts:
79
+ subscription.raw_status_text = " | ".join(status_texts)
80
+ subscription.status = locale.status_of(status_texts[0])
81
+ elif subscription.next_delivery_date is not None:
82
+ subscription.status = SubscriptionStatus.ACTIVE
83
+ if subscription.quantity is None:
84
+ logger.warning(f"Subscription {subscription_id} has no quantity and interval.")
85
+ return subscription
@@ -0,0 +1,37 @@
1
+ from bs4 import BeautifulSoup, Tag
2
+
3
+ from amazon_subscriptions.exceptions import CaptchaError, SessionExpiredError
4
+ from amazon_subscriptions.locales.base import normalize_space
5
+ from amazon_subscriptions.parse import selectors
6
+
7
+ #: A page as text, or already parsed to avoid parsing large pages more than once.
8
+ Html = str | BeautifulSoup
9
+
10
+
11
+ def soup_of(html: Html) -> BeautifulSoup:
12
+ return html if isinstance(html, BeautifulSoup) else BeautifulSoup(html, "html.parser")
13
+
14
+
15
+ def text_of(tag: Tag | None, separator: str = " ") -> str | None:
16
+ """The normalized text of a tag, or ``None`` if the tag is missing or empty."""
17
+ if tag is None:
18
+ return None
19
+ return normalize_space(tag.get_text(separator)) or None
20
+
21
+
22
+ def attr_of(tag: Tag | None, name: str) -> str | None:
23
+ value = tag.get(name) if tag is not None else None
24
+ if isinstance(value, list):
25
+ value = " ".join(value)
26
+ return value or None
27
+
28
+
29
+ def check_page(soup: BeautifulSoup, page: str | None = None) -> None:
30
+ """Raise if the page is a captcha or sign-in page instead of the requested one."""
31
+ if any(soup.select_one(selector) for selector in selectors.CAPTCHA):
32
+ raise CaptchaError(
33
+ "Amazon answered with a captcha. Open Amazon in a browser, solve it there and try again later."
34
+ + (f" (page: {page})" if page else "")
35
+ )
36
+ if any(soup.select_one(selector) for selector in selectors.SIGN_IN):
37
+ raise SessionExpiredError("The Amazon session has expired" + (f" (page: {page})" if page else "") + ".")
@@ -0,0 +1,29 @@
1
+ """Convert the models to JSON: dates as ISO 8601 strings, amounts as decimal strings and enums as their value."""
2
+
3
+ import dataclasses
4
+ import datetime
5
+ import json
6
+ from decimal import Decimal
7
+ from enum import Enum
8
+ from typing import Any
9
+
10
+
11
+ def to_json_value(value: Any) -> Any:
12
+ """A JSON-compatible copy of ``value``, which may be a model, a list of models or a plain value."""
13
+ if dataclasses.is_dataclass(value) and not isinstance(value, type):
14
+ return {f.name: to_json_value(getattr(value, f.name)) for f in dataclasses.fields(value)}
15
+ if isinstance(value, (list, tuple)):
16
+ return [to_json_value(v) for v in value]
17
+ if isinstance(value, dict):
18
+ return {str(k): to_json_value(v) for k, v in value.items()}
19
+ if isinstance(value, Enum):
20
+ return value.value
21
+ if isinstance(value, Decimal):
22
+ return str(value)
23
+ if isinstance(value, (datetime.date, datetime.datetime)):
24
+ return value.isoformat()
25
+ return value
26
+
27
+
28
+ def to_json(value: Any, indent: int | None = 2) -> str:
29
+ return json.dumps(to_json_value(value), indent=indent, ensure_ascii=False)
@@ -0,0 +1,314 @@
1
+ Metadata-Version: 2.5
2
+ Name: amazon-subscriptions
3
+ Version: 1.0.1
4
+ Summary: Read Amazon Subscribe & Save subscriptions and upcoming deliveries as JSON (read-only)
5
+ Author: Daniel Schroeder
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.10
9
+ Requires-Dist: amazon-orders<6,>=5.2.0
10
+ Requires-Dist: beautifulsoup4==4.15.0
11
+ Requires-Dist: click==8.5.0
12
+ Description-Content-Type: text/markdown
13
+
14
+ # amazon-subscriptions
15
+
16
+ Read Amazon Subscribe & Save subscriptions and upcoming deliveries as JSON. Strictly read-only.
17
+
18
+ Built on [amazon-orders](https://github.com/alexdlaird/amazon-orders), which handles login and the session, and
19
+ requests every page in English, whatever language the account is set to. Every Amazon site that offers its pages in
20
+ English is supported; so far it is tested with amazon.de. See [Other sites](#other-sites).
21
+
22
+ ## Installation
23
+
24
+ Requires Python 3.10 or later. Install the CLI from [PyPI](https://pypi.org/project/amazon-subscriptions/) with
25
+ [uv](https://docs.astral.sh/uv/):
26
+
27
+ ```sh
28
+ uv tool install amazon-subscriptions
29
+ ```
30
+
31
+ Or with [pipx](https://pipx.pypa.io/):
32
+
33
+ ```sh
34
+ pipx install amazon-subscriptions
35
+ ```
36
+
37
+ To use it as a [library](#library), add it to your project:
38
+
39
+ ```sh
40
+ uv add amazon-subscriptions # or: pip install amazon-subscriptions
41
+ ```
42
+
43
+ Or from a checkout, prefixing the commands below with `uv run`:
44
+
45
+ ```sh
46
+ uv sync
47
+ ```
48
+
49
+ The installation includes amazon-orders; its CLI is not needed.
50
+
51
+ ## Usage
52
+
53
+ ```sh
54
+ amazon-subscriptions login # asks for username, password and one-time password
55
+ amazon-subscriptions list # all subscriptions
56
+ amazon-subscriptions upcoming # upcoming deliveries with their items
57
+ amazon-subscriptions list --json
58
+ amazon-subscriptions list --with-prices # also regular, list, unit and alternative prices from the product pages
59
+ amazon-subscriptions upcoming --json
60
+ amazon-subscriptions check-session
61
+ amazon-subscriptions logout
62
+ ```
63
+
64
+ `login`, `logout` and `check-session` are the commands of amazon-orders. Each Amazon site has its own session, stored
65
+ in `~/.config/amazonorders/cookies.<site>.json` (e.g. `cookies.amazon.de.json`, for amazon.com `cookies.json`) and
66
+ shared with amazon-orders. A session that older versions of amazon-orders stored in `cookies.json` for another site is
67
+ still used. Credentials are only asked for by `login` and never stored; the other commands never log in by themselves.
68
+ `login` signs out of the site's stored session first, so it always starts a new one. `login --browser` signs in
69
+ through a browser window instead, which needs the `browser` extra of amazon-orders. `check-session --verify` asks
70
+ Amazon whether it still accepts the session.
71
+
72
+ A `language_package` configured for amazon-orders is ignored, as the pages are only read in English. The configuration
73
+ file is not changed.
74
+
75
+ Both `list` and `upcoming` load the same pages, about 3 requests plus one per upcoming delivery: `list` needs the
76
+ delivery pages for the prices of the next delivery. `list --with-prices` loads the product page of every subscription
77
+ on top, one request each.
78
+
79
+ Options (before the command):
80
+
81
+ | Option | Description |
82
+ | --- | --- |
83
+ | `--domain amazon.de` | The Amazon domain. Defaults to `domain` in `~/.config/amazonorders/config.yml`. |
84
+ | `--debug` | Log the requests to stderr and write every loaded page to the output directory. |
85
+ | `--output-dir DIR` | The directory for the pages written with `--debug`, defaults to `./output`. |
86
+
87
+ ### Errors
88
+
89
+ | Exit status | Meaning |
90
+ | --- | --- |
91
+ | 0 | Complete result. |
92
+ | 1 | No result, only a message. |
93
+ | 2 | Partial result: some delivery or product pages could not be read. The output is complete JSON (or text), the affected entries have `parse_errors` (text output: `[incomplete]`), and each error is printed to stderr. |
94
+
95
+ The command exits with status 1 if
96
+
97
+ - there is no session or it has expired. Run `amazon-subscriptions logout` and `amazon-subscriptions login` (with the
98
+ same `--domain`, if any),
99
+ - the overview (`/auto-deliveries` and its further pages) cannot be loaded, e.g. because Amazon answers with a
100
+ captcha (open Amazon in a browser, solve it there and try again later) or has changed the page. The message names
101
+ the page; run again with `--debug` to keep it. An empty result is only returned if Amazon says so.
102
+
103
+ A delivery or product page that cannot be loaded or read is loaded once more after 2 seconds, as Amazon sometimes
104
+ serves another variant of a page. If it fails again, the result is partial (status 2):
105
+
106
+ - A delivery without date is dated by the `deliveryDate` of its URL, and keeps its items.
107
+ - A delivery page without items gives the delivery as its card on the overview shows it: date, change deadline and
108
+ number of items, but `items` is empty and `total` is `null`. The subscriptions of that delivery get its error too,
109
+ as their `discount_percent` and `subscription_price` may be missing.
110
+ - A product page leaves the prices of its subscription `null`.
111
+ - A captcha is not retried. It stops loading the remaining delivery and product pages, which then fail too.
112
+
113
+ Every received page that could not be read is saved to `~/.cache/amazon-subscriptions/failed/` (or
114
+ `$XDG_CACHE_HOME/amazon-subscriptions/failed/`), also without `--debug`, as `<timestamp>_<page>_<attempt>.html`. The
115
+ path is printed to stderr and is in `html_path` of the error:
116
+
117
+ ```text
118
+ Error: No delivery date found, it was taken from the URL (page: https://www.amazon.de/auto-deliveries/?…), page saved to ~/.cache/amazon-subscriptions/failed/20261021-220012_delivery-2026-11-01_2.html
119
+ ```
120
+
121
+ The pages written with `--debug` and to the failed pages directory contain your subscriptions, addresses and more.
122
+ Keep them private.
123
+
124
+ ## JSON
125
+
126
+ - Dates are ISO 8601 (`2026-11-01`).
127
+ - Amounts are decimal strings (`"12.34"`), in the currency of the ISO 4217 code in `currency`.
128
+ - Values Amazon does not show are `null`.
129
+ - Every subscription and delivery has `parse_errors`, the pages of it that could not be read (see
130
+ [Errors](#errors)). It is empty (`[]`) if the entry is complete:
131
+
132
+ ```json
133
+ {
134
+ "url": "https://www.amazon.de/auto-deliveries/?…&deliveryDate=1793487600000&…",
135
+ "message": "No delivery date found, it was taken from the URL",
136
+ "selector": "[data-testid='ddp-atd-delivery-date']",
137
+ "html_path": "/Users/…/.cache/amazon-subscriptions/failed/20261021-220012_delivery-2026-11-01_2.html"
138
+ }
139
+ ```
140
+
141
+ | Field | Description |
142
+ | --- | --- |
143
+ | `url` | The page that could not be read. |
144
+ | `message` | What went wrong, e.g. a missing element, an HTTP status or a captcha. |
145
+ | `selector` | CSS selector of the missing element, `null` if the page was not read at all. |
146
+ | `html_path` | Where the page was saved, `null` if no page was received (e.g. an HTTP error). |
147
+
148
+ ### `list --json`
149
+
150
+ A list of subscriptions:
151
+
152
+ ```json
153
+ [
154
+ {
155
+ "subscription_id": "…",
156
+ "asin": "B0TEST0001",
157
+ "title": "Product 01",
158
+ "product_url": "https://www.amazon.de/dp/B0TEST0001",
159
+ "image_url": "https://m.media-amazon.com/images/I/….jpg",
160
+ "quantity": 1,
161
+ "interval": {"every": 2, "unit": "month"},
162
+ "next_delivery_date": "2026-11-01",
163
+ "status": "active",
164
+ "price": "8.69",
165
+ "list_price": "11.99",
166
+ "list_price_type": "rrp",
167
+ "list_price_source": "buybox",
168
+ "discount_percent": 15,
169
+ "subscription_price": "7.39",
170
+ "unit_price": "57.93",
171
+ "unit_price_unit": "l",
172
+ "alternative_offer": null,
173
+ "currency": "EUR",
174
+ "raw_status_text": null,
175
+ "parse_errors": []
176
+ }
177
+ ]
178
+ ```
179
+
180
+ | Field | Description |
181
+ | --- | --- |
182
+ | `interval.unit` | `day`, `week` or `month`. |
183
+ | `status` | `active`, `paused`, `unavailable` (a backup product is sent instead), `skipped` or `unknown`. |
184
+ | `discount_percent`, `subscription_price` | Discount and price of the whole quantity in the next delivery. Only set if that delivery shows prices, usually only the next one. |
185
+ | `price`, `list_price` | Regular (one-time purchase) price of one unit in the buybox of the product page, and the strike-through price shown for it. Only read with `--with-prices`, `null` if the product is not available or the page shows no list price. Prices of other variants, sponsored products and recommendations on the page are never used. |
186
+ | `list_price_type` | What `list_price` is, by its label: `rrp` (recommended retail price, "RRP"), `lowest_30d` (lowest price in the last 30 days, see [Limitations](#limitations)) or `was` (previous price, "Was"). `null` without `list_price`. |
187
+ | `list_price_source` | Where `list_price` was read: `buybox` (price block of the buybox, any type), `widget` (the entry of this product, "This item:", in a comparison widget, only `rrp`) or `alternative_offer` (price block of an alternative offer of the same product, only `rrp`). Tried in this order. |
188
+ | `unit_price`, `unit_price_unit` | Price per unit of measure of `price` and its unit as Amazon shows it, e.g. `"57.93"` and `"l"`, `"kg"` or `"count"`. Only read with `--with-prices`. |
189
+ | `alternative_offer` | The cheapest other offer of the same product next to the buybox offer ("Another way to buy"), e.g. of another seller, or `null`: `{"price": "5.45", "unit_price": "1.36", "unit_price_unit": "count", "seller": "…", "seller_id": "A1B2C3D4E5F6G7", "subscribable": false}`. `seller` is the seller name as the offer shows it, `Amazon` if Amazon sells it, or `null` if the page does not show a name. `seller_id` is the merchant ID from the link to the seller profile, `null` if Amazon sells the offer or the page has no such link. These offers cannot be subscribed to, so `subscribable` is always `false`. Only read with `--with-prices`. |
190
+ | `raw_status_text` | The status or alert text shown by Amazon, e.g. for a backup product. |
191
+ | `parse_errors` | The errors of the product page (`--with-prices`) and of the page of the next delivery, if they could not be read. |
192
+
193
+ ### `upcoming --json`
194
+
195
+ A list of deliveries:
196
+
197
+ ```json
198
+ [
199
+ {
200
+ "date": "2026-11-01",
201
+ "change_deadline": "2026-10-27",
202
+ "items": [
203
+ {
204
+ "title": "Product 01",
205
+ "quantity": 1,
206
+ "interval": {"every": 2, "unit": "month"},
207
+ "price": "7.39",
208
+ "discount_percent": 15,
209
+ "alert_text": null,
210
+ "substitute": false,
211
+ "subscription_id": "…",
212
+ "asin": "B0TEST0001",
213
+ "substitute_asin": null
214
+ }
215
+ ],
216
+ "discount_tier": {"item_count": 11, "max_discount_unlocked": true, "savings": "10.58"},
217
+ "total": "86.20",
218
+ "currency": "EUR",
219
+ "delivery_bundle_id": "…",
220
+ "url": "https://www.amazon.de/auto-deliveries/?…",
221
+ "parse_errors": []
222
+ }
223
+ ]
224
+ ```
225
+
226
+ | Field | Description |
227
+ | --- | --- |
228
+ | `change_deadline` | The last day to change the delivery. |
229
+ | `items[].price` | Price of the whole quantity with the discount applied. Amazon shows prices only for the next delivery. |
230
+ | `items[].substitute` | `true` if a backup product is sent instead of the subscribed one; `alert_text` has Amazon's note. |
231
+ | `items[].asin`, `items[].substitute_asin` | `asin` is always the subscribed product. If `substitute` is `true`, `title`, `price` and `discount_percent` are those of the backup product, and `substitute_asin` is its ASIN. It is read from the subscription's detail sheet and is `null` if that sheet names another backup product. |
232
+ | `items[].subscription_id` | The subscription of the item. Delivery pages do not link items to subscriptions, so they are matched by title, quantity and interval; `null` if there is no unique match. |
233
+ | `discount_tier` | `item_count` is the number of items the discount is based on, `max_discount_unlocked` whether the delivery has the highest discount ("Max savings unlocked"), `savings` the amount saved as Amazon states it ("Saving €10.58"), usually only for the next delivery, otherwise `null`. |
234
+ | `total` | Sum of the item prices, `null` if an item has no price. |
235
+ | `parse_errors` | The errors of the delivery page, if it could not be read. `items` is then empty if the page had no items. |
236
+
237
+ ## Library
238
+
239
+ ```python
240
+ from amazonorders.conf import AmazonOrdersConfig
241
+ from amazonorders.session import AmazonSession
242
+
243
+ from amazon_subscriptions.client import SubscriptionsClient
244
+ from amazon_subscriptions.serialize import to_json
245
+
246
+ session = AmazonSession(config=AmazonOrdersConfig()) # logged in with `amazon-subscriptions login`
247
+ client = SubscriptionsClient(session) # with_prices=True also loads the product pages
248
+ print(to_json(client.get_subscriptions()))
249
+ print(to_json(client.get_upcoming_deliveries()))
250
+ if client.errors: # the result is partial, see parse_errors
251
+ print(to_json(client.errors))
252
+ ```
253
+
254
+ `SubscriptionsClient` also takes `retry_delay` (seconds before loading a failed page again, default 2) and
255
+ `failed_dir` (where failed pages are saved, default `~/.cache/amazon-subscriptions/failed`).
256
+
257
+ The parsers in `amazon_subscriptions.parse` are pure functions of the page HTML and send no requests.
258
+
259
+ ## Read-only
260
+
261
+ The client only requests the Subscribe & Save overview, the further pages of its lists (loaded like the browser does
262
+ when scrolling), the page of each upcoming delivery and, if a backup product is sent instead of a subscribed one, the
263
+ detail sheet of that subscription. With `--with-prices` (`with_prices=True`) it also requests the product page
264
+ (`/dp/<ASIN>`) of each subscription. Every URL is checked against an allow-list before it is requested, so links that
265
+ change subscriptions (skip, pause, cancel, change quantity or interval, deliver now) are never requested.
266
+
267
+ ## Limitations
268
+
269
+ - Prices are only known for deliveries that show them, which is usually only the next one. `subscription_price` and
270
+ `discount_percent` of a subscription are therefore only set if its next delivery is that one.
271
+ - Regular, list and unit prices are not shown on the Subscribe & Save pages. They are only read with `--with-prices`,
272
+ from the product pages of the subscribed products, so a backup product sent instead has none. Loading 30 or more
273
+ product pages at once can make Amazon answer with a captcha; the remaining prices then stay `null` with a warning.
274
+ - Some English texts are not known yet, as no page with them has been captured. Until they are added to
275
+ `amazon_subscriptions/locales/en.py`:
276
+ - paused, skipped and unavailable subscriptions on the overview give `unknown` and a warning;
277
+ - `list_price_type` is never `lowest_30d`, as that label is not known. amazon.de no longer showed the lowest price
278
+ of the last 30 days on any product page checked, in German either.
279
+
280
+ Please contribute such pages, see [CONTRIBUTING.md](CONTRIBUTING.md).
281
+ - Only the deliveries shown on the overview and its further pages are read, which Amazon limits to a few months.
282
+
283
+ ## Other sites
284
+
285
+ All Amazon sites that offer their pages in English use the same English locale: dates are read as `1 Oct` and as
286
+ `Oct 1`, amounts as `12.34` and `1,234.56`, and the currency is the ISO 4217 code of the site (the pages only show its
287
+ symbol, and `$` alone does not tell US, Canadian and Australian dollars apart). Sites without English pages, such as
288
+ amazon.com.br, amazon.com.mx, amazon.com.tr and amazon.pl, are refused.
289
+
290
+ Only amazon.de has been tested so far. On other sites, the texts, the markup of the pages or the URLs may differ, so
291
+ some values may be missing (with a warning or an `[incomplete]` result) or a page may not be read at all. Login,
292
+ session and cookies are handled by amazon-orders for all sites.
293
+
294
+ If you use another site, please contribute anonymized pages of it, so it can be tested.
295
+ [CONTRIBUTING.md](CONTRIBUTING.md) explains step by step how to capture and anonymize them.
296
+
297
+ ## Development
298
+
299
+ With [mise](https://mise.jdx.dev/), which installs Python and uv:
300
+
301
+ ```sh
302
+ mise run install # uv sync
303
+ mise run lint # ruff, ruff format and mypy
304
+ mise run lint-fix
305
+ mise run test # pytest
306
+ ```
307
+
308
+ Pull request titles and commits follow [Conventional Commits](https://www.conventionalcommits.org/); release-please
309
+ creates the releases and the changelog from them.
310
+
311
+ The test fixtures in `tests/fixtures/<tld>/` are anonymized English pages, see [CONTRIBUTING.md](CONTRIBUTING.md) for
312
+ how to capture and anonymize them. The product page fixtures (`product*.html`) are reduced to their title and price
313
+ blocks with `scripts/extract_product_fixture.py`. It also keeps the variants, the comparison widget and a sponsored
314
+ product, as prices the parser must not read.
@@ -0,0 +1,24 @@
1
+ amazon_subscriptions/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ amazon_subscriptions/cli.py,sha256=qwNlG0n0Sofkd9O554mq6H96BTYp0g22tSTdoqe_DRE,7710
3
+ amazon_subscriptions/client.py,sha256=45JvMU5Ctrxbs7r1l-73BmzmAv1xWL5K2FWOlYt6Pzg,23751
4
+ amazon_subscriptions/dates.py,sha256=yBgvgwNHNG6AVSYqMyoOb2Q-5jMw3TUtqU1Woozzv1U,2476
5
+ amazon_subscriptions/exceptions.py,sha256=Rp3kOKqz1pFtKv4L35i7fq6Xp5EwOnziO-Wm9BOkocQ,799
6
+ amazon_subscriptions/models.py,sha256=iuB938XZkFM_dgCAm3IiIXqJH2v370_Dy0XTh8fdvKY,6490
7
+ amazon_subscriptions/serialize.py,sha256=7XH-BAABBfqUjhPhoPIq4-iaSPIbVvXuy9pOSCFRHNE,1108
8
+ amazon_subscriptions/locales/__init__.py,sha256=gj7qRzKOZ6IzQLEgpFgoQ3NaBJn4nWi76_UmOlmgoVg,2453
9
+ amazon_subscriptions/locales/base.py,sha256=YlTQW1g_rBgeKwpmq0jo9AHGEFg5Eo51wqoU7vEeMhI,9633
10
+ amazon_subscriptions/locales/en.py,sha256=5HVcGRb8_kPLBnBLIo-7uy3w9PUGoZ-t7WhVCJIPiuU,3016
11
+ amazon_subscriptions/parse/__init__.py,sha256=_nWo9l0E3K_8JUgLy3SKle2MxwTXpZs-dPVJc7GQcnE,995
12
+ amazon_subscriptions/parse/acp.py,sha256=Q1AxA_YaOzlG01oUSCtB6YYRmXDtZSmoUFyLLqIvU1w,1741
13
+ amazon_subscriptions/parse/deliveries.py,sha256=IIo64snfZLBud8CrGrkmxZPVu5yFgNEhe9PtX4XCkCA,9525
14
+ amazon_subscriptions/parse/detail.py,sha256=ed2M5g_ayN1_onlVzVvJ3lwErZ0fPYMfJK6PGWdBdvU,1084
15
+ amazon_subscriptions/parse/matching.py,sha256=Lc2w57tVcgIUH-l-e8hx3kXHDE25oxSKwVHYYTqbnlI,4738
16
+ amazon_subscriptions/parse/product.py,sha256=WxC5PkGeOHrpUzXh8FVSBvbdXpDBclv_yrHUy8wGFic,9251
17
+ amazon_subscriptions/parse/selectors.py,sha256=htvhfXwD-atfIBc8G00jCrJ04J9N3m4tCqC4lp173fo,3445
18
+ amazon_subscriptions/parse/subscriptions.py,sha256=nt8ZHc72p8-yHcAf3SkxkkFBDXlCp3jbA7Oty2bR3HU,3877
19
+ amazon_subscriptions/parse/util.py,sha256=YgT7f43Ul-DvsQVMnyZ28FZGyT2fNSLzjhb0ZQR48MY,1538
20
+ amazon_subscriptions-1.0.1.dist-info/METADATA,sha256=L0k48lNl4g8VTMpTHIKkGBZHLLCrvZ984aaL0sH8oOk,16320
21
+ amazon_subscriptions-1.0.1.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
22
+ amazon_subscriptions-1.0.1.dist-info/entry_points.txt,sha256=Ho8QXmdFxXoZyOTbQTCiH7ST9aCIPWOLL3LH1edKSis,70
23
+ amazon_subscriptions-1.0.1.dist-info/licenses/LICENSE,sha256=vB-LyFzAYUHN758HXTKXGZdIfJe0iiDEUHT_KA7Czh8,1073
24
+ amazon_subscriptions-1.0.1.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.33.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ amazon-subscriptions = amazon_subscriptions.cli:cli
@@ -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.