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.
- amazon_subscriptions/__init__.py +0 -0
- amazon_subscriptions/cli.py +180 -0
- amazon_subscriptions/client.py +485 -0
- amazon_subscriptions/dates.py +63 -0
- amazon_subscriptions/exceptions.py +21 -0
- amazon_subscriptions/locales/__init__.py +68 -0
- amazon_subscriptions/locales/base.py +189 -0
- amazon_subscriptions/locales/en.py +65 -0
- amazon_subscriptions/models.py +163 -0
- amazon_subscriptions/parse/__init__.py +28 -0
- amazon_subscriptions/parse/acp.py +41 -0
- amazon_subscriptions/parse/deliveries.py +199 -0
- amazon_subscriptions/parse/detail.py +27 -0
- amazon_subscriptions/parse/matching.py +100 -0
- amazon_subscriptions/parse/product.py +202 -0
- amazon_subscriptions/parse/selectors.py +64 -0
- amazon_subscriptions/parse/subscriptions.py +85 -0
- amazon_subscriptions/parse/util.py +37 -0
- amazon_subscriptions/serialize.py +29 -0
- amazon_subscriptions-1.0.1.dist-info/METADATA +314 -0
- amazon_subscriptions-1.0.1.dist-info/RECORD +24 -0
- amazon_subscriptions-1.0.1.dist-info/WHEEL +4 -0
- amazon_subscriptions-1.0.1.dist-info/entry_points.txt +2 -0
- amazon_subscriptions-1.0.1.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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.
|