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,63 @@
|
|
|
1
|
+
"""Resolve the year of dates that Amazon renders without one (e.g. "1. Okt.").
|
|
2
|
+
|
|
3
|
+
The year is never guessed from the text. It follows from a reference date that is known to be close, with a rule
|
|
4
|
+
per kind of date:
|
|
5
|
+
|
|
6
|
+
* a next delivery lies in the future, so it is the first occurrence on or after the reference date (minus a grace
|
|
7
|
+
period for deliveries that are late or on their way),
|
|
8
|
+
* a change deadline lies before its delivery, so it is the last occurrence on or before the delivery date,
|
|
9
|
+
* a date next to a precise timestamp is the occurrence nearest to that timestamp.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import date, timedelta
|
|
15
|
+
|
|
16
|
+
#: A next delivery date may lie up to this many days before the reference date, e.g. while it is on its way.
|
|
17
|
+
NEXT_DELIVERY_GRACE_DAYS = 30
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class DateParts:
|
|
22
|
+
"""A date as rendered, which may lack the year."""
|
|
23
|
+
|
|
24
|
+
day: int
|
|
25
|
+
month: int
|
|
26
|
+
year: int | None = None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def resolve(parts: DateParts | None, resolve_year: Callable[[int, int], date | None]) -> date | None:
|
|
30
|
+
"""The date of the parts, using ``resolve_year(day, month)`` if they have no year."""
|
|
31
|
+
if parts is None:
|
|
32
|
+
return None
|
|
33
|
+
if parts.year is not None:
|
|
34
|
+
return date(parts.year, parts.month, parts.day)
|
|
35
|
+
return resolve_year(parts.day, parts.month)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _occurrence(year: int, month: int, day: int) -> date | None:
|
|
39
|
+
try:
|
|
40
|
+
return date(year, month, day)
|
|
41
|
+
except ValueError: # e.g. 29 February
|
|
42
|
+
return None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _candidates(day: int, month: int, reference: date) -> list[date]:
|
|
46
|
+
candidates = (_occurrence(year, month, day) for year in (reference.year - 1, reference.year, reference.year + 1))
|
|
47
|
+
return [candidate for candidate in candidates if candidate is not None]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def first_on_or_after(day: int, month: int, not_before: date) -> date | None:
|
|
51
|
+
return min((c for c in _candidates(day, month, not_before) if c >= not_before), default=None)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def last_on_or_before(day: int, month: int, not_after: date) -> date | None:
|
|
55
|
+
return max((c for c in _candidates(day, month, not_after) if c <= not_after), default=None)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def nearest(day: int, month: int, reference: date) -> date | None:
|
|
59
|
+
return min(_candidates(day, month, reference), key=lambda c: abs(c - reference), default=None)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def next_delivery(day: int, month: int, today: date) -> date | None:
|
|
63
|
+
return first_on_or_after(day, month, today - timedelta(days=NEXT_DELIVERY_GRACE_DAYS))
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
class SubscriptionsError(Exception):
|
|
2
|
+
"""Base class of all errors of amazon-subscriptions."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class SessionExpiredError(SubscriptionsError):
|
|
6
|
+
"""The session is not signed in (anymore)."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class CaptchaError(SubscriptionsError):
|
|
10
|
+
"""Amazon answered with a captcha or robot check instead of the requested page."""
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class PageStructureError(SubscriptionsError):
|
|
14
|
+
"""A page does not have the expected structure, most likely because Amazon changed it."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, message: str, page: str | None = None, selector: str | None = None) -> None:
|
|
17
|
+
super().__init__(f"{message} (page: {page})" if page else message)
|
|
18
|
+
self.message = message
|
|
19
|
+
self.page = page
|
|
20
|
+
#: CSS selector of the element that was not found, if any.
|
|
21
|
+
self.selector = selector
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
from urllib.parse import urlparse
|
|
2
|
+
|
|
3
|
+
from amazonorders.constants import Constants
|
|
4
|
+
from amazonorders.region_data import REGION_SITES
|
|
5
|
+
|
|
6
|
+
from amazon_subscriptions.exceptions import SubscriptionsError
|
|
7
|
+
from amazon_subscriptions.locales.base import SubscriptionLocale
|
|
8
|
+
from amazon_subscriptions.locales.en import EnglishSubscriptionLocale
|
|
9
|
+
|
|
10
|
+
#: ISO 4217 code of the currency of each Amazon site, keyed by the TLD suffix that follows ``amazon.``.
|
|
11
|
+
#:
|
|
12
|
+
#: amazon-orders only knows the symbol a site shows its prices with, and ``$`` alone does not tell US, Canadian and
|
|
13
|
+
#: Australian dollars apart. The pages never show the code.
|
|
14
|
+
SITE_CURRENCIES: dict[str, str] = {
|
|
15
|
+
"ae": "AED",
|
|
16
|
+
"ca": "CAD",
|
|
17
|
+
"co.jp": "JPY",
|
|
18
|
+
"co.uk": "GBP",
|
|
19
|
+
"co.za": "ZAR",
|
|
20
|
+
"com": "USD",
|
|
21
|
+
"com.au": "AUD",
|
|
22
|
+
"com.be": "EUR",
|
|
23
|
+
"com.br": "BRL",
|
|
24
|
+
"com.mx": "MXN",
|
|
25
|
+
"com.tr": "TRY",
|
|
26
|
+
"de": "EUR",
|
|
27
|
+
"eg": "EGP",
|
|
28
|
+
"es": "EUR",
|
|
29
|
+
"fr": "EUR",
|
|
30
|
+
"ie": "EUR",
|
|
31
|
+
"in": "INR",
|
|
32
|
+
"it": "EUR",
|
|
33
|
+
"nl": "EUR",
|
|
34
|
+
"pl": "PLN",
|
|
35
|
+
"sa": "SAR",
|
|
36
|
+
"se": "SEK",
|
|
37
|
+
"sg": "SGD",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def tld_of(domain_or_url: str) -> str:
|
|
42
|
+
"""The TLD suffix of a storefront, e.g. ``de`` for ``amazon.de`` or ``https://www.amazon.de``."""
|
|
43
|
+
host = urlparse(domain_or_url).hostname if "://" in domain_or_url else domain_or_url
|
|
44
|
+
host = (host or "").lower()
|
|
45
|
+
if "amazon." not in host:
|
|
46
|
+
raise SubscriptionsError(f"{domain_or_url!r} is not an Amazon storefront.")
|
|
47
|
+
return host.split("amazon.", 1)[1]
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_locale(domain_or_url: str) -> SubscriptionLocale:
|
|
51
|
+
"""The locale of a storefront, given by domain (``amazon.de``) or base URL (``https://www.amazon.de``).
|
|
52
|
+
|
|
53
|
+
Every storefront that offers its pages in English is supported, as amazon-orders requests them in English.
|
|
54
|
+
|
|
55
|
+
:raises SubscriptionsError: If the storefront is not known or offers no English pages.
|
|
56
|
+
"""
|
|
57
|
+
tld = tld_of(domain_or_url)
|
|
58
|
+
site = REGION_SITES.get(tld)
|
|
59
|
+
if site is None or tld not in SITE_CURRENCIES:
|
|
60
|
+
raise SubscriptionsError(f"amazon.{tld} is not a known Amazon storefront.")
|
|
61
|
+
if not site.english_locale:
|
|
62
|
+
raise SubscriptionsError(
|
|
63
|
+
f"amazon.{tld} does not offer its pages in English, which is the only language supported."
|
|
64
|
+
)
|
|
65
|
+
return EnglishSubscriptionLocale(SITE_CURRENCIES[tld], Constants.region_currency_symbol(tld))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["SITE_CURRENCIES", "EnglishSubscriptionLocale", "SubscriptionLocale", "get_locale", "tld_of"]
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import html
|
|
2
|
+
import logging
|
|
3
|
+
import re
|
|
4
|
+
from datetime import date
|
|
5
|
+
from decimal import Decimal, InvalidOperation
|
|
6
|
+
from typing import ClassVar
|
|
7
|
+
|
|
8
|
+
from amazon_subscriptions.dates import DateParts
|
|
9
|
+
from amazon_subscriptions.models import Interval, IntervalUnit, ListPriceType, SubscriptionStatus
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def normalize_space(text: str) -> str:
|
|
15
|
+
"""Collapse all whitespace, including non-breaking spaces, into single spaces."""
|
|
16
|
+
return re.sub(r"\s+", " ", text).strip()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class SubscriptionLocale:
|
|
20
|
+
"""Texts and formats of the Subscribe & Save pages in one language.
|
|
21
|
+
|
|
22
|
+
A language is supported by a subclass that sets the class attributes. The currency is the storefront's, so one
|
|
23
|
+
subclass serves every storefront that shows its pages in that language, see
|
|
24
|
+
:func:`amazon_subscriptions.locales.get_locale`.
|
|
25
|
+
|
|
26
|
+
:param currency: ISO 4217 code of the storefront's currency.
|
|
27
|
+
:param currency_symbol: The symbol the storefront shows its prices with. It is removed from amounts, as is the
|
|
28
|
+
code.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
#: Month names and abbreviations, lower case.
|
|
32
|
+
MONTHS: ClassVar[dict[str, int]]
|
|
33
|
+
#: Patterns of a date, with ``{months}`` for the alternatives of :attr:`MONTHS`. Groups: ``day``, ``month``,
|
|
34
|
+
#: ``year`` (may be empty).
|
|
35
|
+
DATE_PATTERNS: ClassVar[list[str]]
|
|
36
|
+
|
|
37
|
+
#: Text before the next delivery date of a subscription tile.
|
|
38
|
+
NEXT_DELIVERY_PREFIX: ClassVar[str]
|
|
39
|
+
#: Texts of a subscription tile that are neither title, date, quantity nor a status (e.g. links).
|
|
40
|
+
TILE_ACTION_TEXTS: ClassVar[list[str]]
|
|
41
|
+
#: Status texts of a subscription tile, matched as prefix. Texts not listed give ``unknown`` and a warning.
|
|
42
|
+
STATUS_TEXTS: ClassVar[dict[str, SubscriptionStatus]]
|
|
43
|
+
#: Text shown instead of the subscriptions when there are none. ``None`` if not known yet.
|
|
44
|
+
NO_SUBSCRIPTIONS_TEXT: ClassVar[str | None] = None
|
|
45
|
+
|
|
46
|
+
#: Text before the arrival date of a delivery card.
|
|
47
|
+
ARRIVAL_PREFIX: ClassVar[str]
|
|
48
|
+
#: Text before the change deadline of a delivery card.
|
|
49
|
+
DEADLINE_PREFIX: ClassVar[str]
|
|
50
|
+
|
|
51
|
+
#: Quantity and interval, e.g. "3 units every 2 months". Groups: quantity, every (optional), unit.
|
|
52
|
+
QUANTITY_INTERVAL_RE: ClassVar[re.Pattern[str]]
|
|
53
|
+
#: Unit words of intervals, lower case.
|
|
54
|
+
INTERVAL_UNITS: ClassVar[dict[str, IntervalUnit]]
|
|
55
|
+
#: Discount of an item, e.g. "Save 15%". Group: percent.
|
|
56
|
+
DISCOUNT_PERCENT_RE: ClassVar[re.Pattern[str]]
|
|
57
|
+
#: Number of items of a delivery, e.g. "11 items". Group: count.
|
|
58
|
+
ITEM_COUNT_RE: ClassVar[re.Pattern[str]]
|
|
59
|
+
#: Total savings of a delivery. Group: amount. ``None`` if not known yet.
|
|
60
|
+
SAVINGS_RE: ClassVar[re.Pattern[str] | None] = None
|
|
61
|
+
#: Text stating that the maximum discount of a delivery is unlocked.
|
|
62
|
+
MAX_DISCOUNT_UNLOCKED_TEXT: ClassVar[str]
|
|
63
|
+
#: Item alerts stating that a backup product is sent instead of the subscribed one.
|
|
64
|
+
SUBSTITUTE_ALERT_TEXTS: ClassVar[list[str]]
|
|
65
|
+
|
|
66
|
+
#: Unit price of a product page, e.g. "€24.99 per l". Groups: amount, unit.
|
|
67
|
+
UNIT_PRICE_RE: ClassVar[re.Pattern[str]]
|
|
68
|
+
#: Labels of the list price of a product page by the type they state, matched as prefix.
|
|
69
|
+
LIST_PRICE_TYPES: ClassVar[dict[str, ListPriceType]]
|
|
70
|
+
#: Captions of the buybox rows with other offers of the product, e.g. of other sellers.
|
|
71
|
+
ALTERNATIVE_OFFERS_CAPTIONS: ClassVar[list[str]]
|
|
72
|
+
#: Label of the entry of the product itself in a comparison widget, matched as prefix.
|
|
73
|
+
THIS_ITEM_LABEL: ClassVar[str]
|
|
74
|
+
|
|
75
|
+
#: Amount pattern without currency, e.g. "1,234.56". Groups: integer part, decimals (optional).
|
|
76
|
+
AMOUNT_RE: ClassVar[re.Pattern[str]]
|
|
77
|
+
#: Thousands separator of :attr:`AMOUNT_RE`.
|
|
78
|
+
THOUSANDS_SEPARATOR: ClassVar[str]
|
|
79
|
+
|
|
80
|
+
def __init__(self, currency: str, currency_symbol: str | None = None) -> None:
|
|
81
|
+
#: ISO 4217 code of the storefront's currency.
|
|
82
|
+
self.currency = currency
|
|
83
|
+
noise = sorted({currency, *([currency_symbol] if currency_symbol else [])}, key=len, reverse=True)
|
|
84
|
+
#: Texts around an amount that are not part of it: the currency's symbol and code.
|
|
85
|
+
self.amount_noise_re = re.compile("|".join(map(re.escape, noise)))
|
|
86
|
+
months = "|".join(sorted(map(re.escape, self.MONTHS), key=len, reverse=True))
|
|
87
|
+
self._date_res = [
|
|
88
|
+
re.compile(pattern.replace("{months}", months), re.IGNORECASE) for pattern in self.DATE_PATTERNS
|
|
89
|
+
]
|
|
90
|
+
|
|
91
|
+
def parse_amount(self, text: str | None) -> Decimal | None:
|
|
92
|
+
"""Parse an amount such as ``€1,234.56``. The text must contain nothing else."""
|
|
93
|
+
if not text:
|
|
94
|
+
return None
|
|
95
|
+
value = normalize_space(self.amount_noise_re.sub("", normalize_space(text))).replace(" ", "")
|
|
96
|
+
match = self.AMOUNT_RE.fullmatch(value)
|
|
97
|
+
if not match:
|
|
98
|
+
logger.warning(f"Amount {text!r} was not recognized, so it was not parsed.")
|
|
99
|
+
return None
|
|
100
|
+
integer, decimals = match.group(1), match.group(2) or "0"
|
|
101
|
+
try:
|
|
102
|
+
return Decimal(f"{integer.replace(self.THOUSANDS_SEPARATOR, '')}.{decimals}")
|
|
103
|
+
except InvalidOperation: # pragma: no cover, the pattern only matches digits
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
def parse_date_parts(self, text: str | None) -> DateParts | None:
|
|
107
|
+
"""Parse the single date in a text such as ``Arriving: Thu, 1 Oct``, with or without year."""
|
|
108
|
+
if not text:
|
|
109
|
+
return None
|
|
110
|
+
text = normalize_space(text)
|
|
111
|
+
matches = sorted((m for date_re in self._date_res for m in date_re.finditer(text)), key=lambda m: m.start())
|
|
112
|
+
found: set[tuple[int, int, int | None]] = set()
|
|
113
|
+
end = 0
|
|
114
|
+
for match in matches:
|
|
115
|
+
# Patterns of both orders may match the same month, e.g. "Oct 12" in "5 Oct 12 units", the first wins
|
|
116
|
+
if match.start() < end:
|
|
117
|
+
continue
|
|
118
|
+
end = match.end()
|
|
119
|
+
year = int(match["year"]) if match["year"] else None
|
|
120
|
+
found.add((int(match["day"]), self.MONTHS[match["month"].lower()], year))
|
|
121
|
+
if len(found) != 1:
|
|
122
|
+
logger.warning(f"Date {text!r} has {'no' if not found else 'more than one'} date, so it was not parsed.")
|
|
123
|
+
return None
|
|
124
|
+
day, month, year = found.pop()
|
|
125
|
+
try:
|
|
126
|
+
# 2000 is a leap year, so 29 Feb without year is valid
|
|
127
|
+
date(year or 2000, month, day)
|
|
128
|
+
except ValueError:
|
|
129
|
+
logger.warning(f"Date {text!r} is not a valid date, so it was not parsed.")
|
|
130
|
+
return None
|
|
131
|
+
return DateParts(day, month, year)
|
|
132
|
+
|
|
133
|
+
def parse_quantity_interval(self, text: str | None, warn: bool = True) -> tuple[int, Interval] | None:
|
|
134
|
+
"""Parse quantity and interval such as ``3 units every 2 months``."""
|
|
135
|
+
match = self.QUANTITY_INTERVAL_RE.fullmatch(normalize_space(text or ""))
|
|
136
|
+
if not match:
|
|
137
|
+
if text and warn:
|
|
138
|
+
logger.warning(f"Quantity and interval {text!r} were not recognized, so they were not parsed.")
|
|
139
|
+
return None
|
|
140
|
+
quantity, every, unit = match.groups()
|
|
141
|
+
return int(quantity), Interval(int(every or 1), self.INTERVAL_UNITS[unit.lower()])
|
|
142
|
+
|
|
143
|
+
def parse_discount_percent(self, text: str | None) -> int | None:
|
|
144
|
+
match = self.DISCOUNT_PERCENT_RE.search(normalize_space(text or ""))
|
|
145
|
+
return int(match.group(1)) if match else None
|
|
146
|
+
|
|
147
|
+
def parse_item_count(self, text: str | None) -> int | None:
|
|
148
|
+
match = self.ITEM_COUNT_RE.search(normalize_space(text or ""))
|
|
149
|
+
return int(match.group(1)) if match else None
|
|
150
|
+
|
|
151
|
+
def parse_savings(self, text: str | None) -> Decimal | None:
|
|
152
|
+
match = self.SAVINGS_RE.search(normalize_space(text or "")) if self.SAVINGS_RE else None
|
|
153
|
+
return self.parse_amount(match.group(1)) if match else None
|
|
154
|
+
|
|
155
|
+
def parse_unit_price(self, text: str | None) -> tuple[Decimal, str] | None:
|
|
156
|
+
"""Parse a unit price such as ``€24.99 per l`` into amount and unit."""
|
|
157
|
+
match = self.UNIT_PRICE_RE.fullmatch(normalize_space(html.unescape(text or "")))
|
|
158
|
+
if not match:
|
|
159
|
+
if text:
|
|
160
|
+
logger.warning(f"Unit price {text!r} was not recognized, so it was not parsed.")
|
|
161
|
+
return None
|
|
162
|
+
amount = self.parse_amount(match.group(1))
|
|
163
|
+
return (amount, match.group(2)) if amount is not None else None
|
|
164
|
+
|
|
165
|
+
def list_price_type_of(self, label: str | None) -> ListPriceType | None:
|
|
166
|
+
"""The type of a list price by its label, ``None`` if the label is not one of a list price."""
|
|
167
|
+
label = normalize_space(label or "").lower()
|
|
168
|
+
for prefix, list_price_type in self.LIST_PRICE_TYPES.items():
|
|
169
|
+
if label.startswith(prefix.lower()):
|
|
170
|
+
return list_price_type
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
def is_alternative_offers_caption(self, text: str | None) -> bool:
|
|
174
|
+
text = normalize_space(text or "").lower()
|
|
175
|
+
return any(text.startswith(caption.lower()) for caption in self.ALTERNATIVE_OFFERS_CAPTIONS)
|
|
176
|
+
|
|
177
|
+
def is_this_item_label(self, text: str | None) -> bool:
|
|
178
|
+
return normalize_space(text or "").lower().startswith(self.THIS_ITEM_LABEL.lower())
|
|
179
|
+
|
|
180
|
+
def status_of(self, text: str) -> SubscriptionStatus:
|
|
181
|
+
"""The status of a subscription tile's status text."""
|
|
182
|
+
for prefix, status in self.STATUS_TEXTS.items():
|
|
183
|
+
if normalize_space(text).lower().startswith(prefix.lower()):
|
|
184
|
+
return status
|
|
185
|
+
logger.warning(f"Subscription status {text!r} is not known, so it was not parsed.")
|
|
186
|
+
return SubscriptionStatus.UNKNOWN
|
|
187
|
+
|
|
188
|
+
def is_substitute_alert(self, text: str) -> bool:
|
|
189
|
+
return any(alert.lower() in normalize_space(text).lower() for alert in self.SUBSTITUTE_ALERT_TEXTS)
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from typing import ClassVar
|
|
3
|
+
|
|
4
|
+
from amazon_subscriptions.locales.base import SubscriptionLocale
|
|
5
|
+
from amazon_subscriptions.models import IntervalUnit, ListPriceType, SubscriptionStatus
|
|
6
|
+
|
|
7
|
+
_MONTH_NAMES = [
|
|
8
|
+
"january", "february", "march", "april", "may", "june",
|
|
9
|
+
"july", "august", "september", "october", "november", "december",
|
|
10
|
+
] # fmt: skip
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class EnglishSubscriptionLocale(SubscriptionLocale):
|
|
14
|
+
"""The Subscribe & Save pages in English, as every storefront that offers English shows them.
|
|
15
|
+
|
|
16
|
+
Dates are read in both orders, "1 Oct" and "Oct 1", and amounts as "1,234.56".
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
MONTHS: ClassVar[dict[str, int]] = {
|
|
20
|
+
**{name: number for number, name in enumerate(_MONTH_NAMES, 1)},
|
|
21
|
+
**{name[:3]: number for number, name in enumerate(_MONTH_NAMES, 1)},
|
|
22
|
+
"sept": 9,
|
|
23
|
+
}
|
|
24
|
+
DATE_PATTERNS: ClassVar[list[str]] = [
|
|
25
|
+
# 1 Oct, 1st October 2026, Thu, 1 Oct
|
|
26
|
+
r"(?<![\d.,])(?P<day>\d{1,2})(?:st|nd|rd|th)?\s+(?P<month>{months})\.?(?![a-z])(?:,?\s+(?P<year>\d{4}))?(?!\d)",
|
|
27
|
+
# Oct 1, October 1st, 2026, Thu, Oct 1
|
|
28
|
+
r"(?<![a-z])(?P<month>{months})\.?\s+(?P<day>\d{1,2})(?:st|nd|rd|th)?(?![\d.,]\d)(?:,?\s+(?P<year>\d{4}))?(?!\d)",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
NEXT_DELIVERY_PREFIX = "Next delivery:"
|
|
32
|
+
TILE_ACTION_TEXTS: ClassVar[list[str]] = ["Edit or cancel"]
|
|
33
|
+
# TODO: capture tiles of paused and unavailable subscriptions to fill this
|
|
34
|
+
STATUS_TEXTS: ClassVar[dict[str, SubscriptionStatus]] = {}
|
|
35
|
+
ARRIVAL_PREFIX = "Arriving"
|
|
36
|
+
DEADLINE_PREFIX = "Last day to edit"
|
|
37
|
+
|
|
38
|
+
QUANTITY_INTERVAL_RE = re.compile(
|
|
39
|
+
r"(\d+) units? every (?:(\d+) )?(day|days|week|weeks|month|months)", re.IGNORECASE
|
|
40
|
+
)
|
|
41
|
+
INTERVAL_UNITS: ClassVar[dict[str, IntervalUnit]] = {
|
|
42
|
+
"day": IntervalUnit.DAY, "days": IntervalUnit.DAY,
|
|
43
|
+
"week": IntervalUnit.WEEK, "weeks": IntervalUnit.WEEK,
|
|
44
|
+
"month": IntervalUnit.MONTH, "months": IntervalUnit.MONTH,
|
|
45
|
+
} # fmt: skip
|
|
46
|
+
DISCOUNT_PERCENT_RE = re.compile(r"Saving (\d{1,2}) ?%", re.IGNORECASE)
|
|
47
|
+
ITEM_COUNT_RE = re.compile(r"(\d+) items?\b")
|
|
48
|
+
# "Saving €10.58 - Max savings unlocked", not the "Saving 15%" of an item
|
|
49
|
+
SAVINGS_RE = re.compile(r"\bSaving (?!\d+ ?%)(.+?)(?: - |$)", re.IGNORECASE)
|
|
50
|
+
MAX_DISCOUNT_UNLOCKED_TEXT = "Max savings unlocked"
|
|
51
|
+
SUBSTITUTE_ALERT_TEXTS: ClassVar[list[str]] = ["Backup product will be shipped"]
|
|
52
|
+
|
|
53
|
+
UNIT_PRICE_RE = re.compile(r"(.+?) per (.+)")
|
|
54
|
+
# TODO: capture the label of the lowest price of the last 30 days to add it. In October 2026, no product page of
|
|
55
|
+
# amazon.de showed it, in German either.
|
|
56
|
+
LIST_PRICE_TYPES: ClassVar[dict[str, ListPriceType]] = {
|
|
57
|
+
"RRP": ListPriceType.RRP,
|
|
58
|
+
"Was": ListPriceType.WAS,
|
|
59
|
+
}
|
|
60
|
+
# "Get it faster" is a faster delivery of the same offer, not an alternative offer.
|
|
61
|
+
ALTERNATIVE_OFFERS_CAPTIONS: ClassVar[list[str]] = ["Another way to buy"]
|
|
62
|
+
THIS_ITEM_LABEL = "This item:"
|
|
63
|
+
|
|
64
|
+
AMOUNT_RE = re.compile(r"(-?(?:\d{1,3}(?:,\d{3})+|\d+))(?:\.(\d{1,2}))?")
|
|
65
|
+
THOUSANDS_SEPARATOR = ","
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from decimal import Decimal
|
|
4
|
+
from enum import Enum
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class IntervalUnit(str, Enum):
|
|
8
|
+
DAY = "day"
|
|
9
|
+
WEEK = "week"
|
|
10
|
+
MONTH = "month"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SubscriptionStatus(str, Enum):
|
|
14
|
+
ACTIVE = "active"
|
|
15
|
+
PAUSED = "paused"
|
|
16
|
+
#: The subscribed product is not available, e.g. a backup product is sent instead.
|
|
17
|
+
UNAVAILABLE = "unavailable"
|
|
18
|
+
SKIPPED = "skipped"
|
|
19
|
+
UNKNOWN = "unknown"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ListPriceType(str, Enum):
|
|
23
|
+
"""What the list price of a product page is, by its label."""
|
|
24
|
+
|
|
25
|
+
#: Recommended retail price, "RRP" on English pages.
|
|
26
|
+
RRP = "rrp"
|
|
27
|
+
#: Lowest price of the last 30 days.
|
|
28
|
+
LOWEST_30D = "lowest_30d"
|
|
29
|
+
#: A former price, "Was" on English pages.
|
|
30
|
+
WAS = "was"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ListPriceSource(str, Enum):
|
|
34
|
+
"""The block of a product page the list price was read from."""
|
|
35
|
+
|
|
36
|
+
#: The price block of the offer in the buybox.
|
|
37
|
+
BUYBOX = "buybox"
|
|
38
|
+
#: The entry of the product itself ("This item:") in a comparison widget. Only recommended retail prices are
|
|
39
|
+
#: read from there, as the widget may show another offer than the buybox.
|
|
40
|
+
WIDGET = "widget"
|
|
41
|
+
#: The price block of an alternative offer of the same product. Only recommended retail prices are read from there.
|
|
42
|
+
ALTERNATIVE_OFFER = "alternative_offer"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class ParseError:
|
|
47
|
+
"""A page that could not be loaded or read completely, so the object it belongs to is incomplete."""
|
|
48
|
+
|
|
49
|
+
#: URL of the page.
|
|
50
|
+
url: str | None
|
|
51
|
+
message: str
|
|
52
|
+
#: CSS selector of the element that was not found, if known.
|
|
53
|
+
selector: str | None = None
|
|
54
|
+
#: Path of the file the page was saved to for diagnosis, ``None`` if no page was received.
|
|
55
|
+
html_path: str | None = None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@dataclass(frozen=True)
|
|
59
|
+
class Interval:
|
|
60
|
+
every: int
|
|
61
|
+
unit: IntervalUnit
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class AlternativeOffer:
|
|
66
|
+
"""Another offer of the same product, listed next to the buybox offer ("Another way to buy"), e.g. of another
|
|
67
|
+
seller."""
|
|
68
|
+
|
|
69
|
+
#: Price of a one-time purchase.
|
|
70
|
+
price: Decimal
|
|
71
|
+
unit_price: Decimal | None = None
|
|
72
|
+
unit_price_unit: str | None = None
|
|
73
|
+
#: Name of the seller as the offer shows it, ``Amazon`` if Amazon sells it, ``None`` if the page does not show it.
|
|
74
|
+
seller: str | None = None
|
|
75
|
+
#: Merchant ID of the seller, from the link to its seller profile. ``None`` if Amazon sells it.
|
|
76
|
+
seller_id: str | None = None
|
|
77
|
+
#: Alternative offers cannot be subscribed to.
|
|
78
|
+
subscribable: bool = False
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@dataclass
|
|
82
|
+
class Subscription:
|
|
83
|
+
subscription_id: str
|
|
84
|
+
asin: str | None = None
|
|
85
|
+
title: str | None = None
|
|
86
|
+
product_url: str | None = None
|
|
87
|
+
image_url: str | None = None
|
|
88
|
+
quantity: int | None = None
|
|
89
|
+
interval: Interval | None = None
|
|
90
|
+
next_delivery_date: datetime.date | None = None
|
|
91
|
+
status: SubscriptionStatus = SubscriptionStatus.UNKNOWN
|
|
92
|
+
#: Regular (one-time purchase) price of one unit. Only read with ``with_prices``, from the product page.
|
|
93
|
+
price: Decimal | None = None
|
|
94
|
+
#: List price of one unit, if the product page shows one for this product. Only read with ``with_prices``.
|
|
95
|
+
list_price: Decimal | None = None
|
|
96
|
+
#: What ``list_price`` is, by its label.
|
|
97
|
+
list_price_type: ListPriceType | None = None
|
|
98
|
+
#: The block of the product page ``list_price`` was read from.
|
|
99
|
+
list_price_source: ListPriceSource | None = None
|
|
100
|
+
#: Subscribe & Save discount of the next delivery in percent.
|
|
101
|
+
discount_percent: int | None = None
|
|
102
|
+
#: Price of the next delivery of this subscription, for the whole quantity and with the discount applied.
|
|
103
|
+
subscription_price: Decimal | None = None
|
|
104
|
+
#: Price per unit of measure of the regular price, e.g. per ``kg``. Only read with ``with_prices``.
|
|
105
|
+
unit_price: Decimal | None = None
|
|
106
|
+
#: The unit of measure of ``unit_price`` as shown by Amazon, e.g. ``kg``, ``100 ml`` or ``count``.
|
|
107
|
+
unit_price_unit: str | None = None
|
|
108
|
+
#: The cheapest other offer of the same product on the product page. Only read with ``with_prices``.
|
|
109
|
+
alternative_offer: AlternativeOffer | None = None
|
|
110
|
+
#: ISO 4217 code of the amounts.
|
|
111
|
+
currency: str | None = None
|
|
112
|
+
#: Status text shown by Amazon, if any.
|
|
113
|
+
raw_status_text: str | None = None
|
|
114
|
+
#: Pages of this subscription that could not be read, so some of its fields may be ``None``.
|
|
115
|
+
parse_errors: list[ParseError] = field(default_factory=list)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@dataclass
|
|
119
|
+
class DeliveryItem:
|
|
120
|
+
title: str
|
|
121
|
+
quantity: int | None = None
|
|
122
|
+
interval: Interval | None = None
|
|
123
|
+
#: Price for the whole quantity, with the discount applied.
|
|
124
|
+
price: Decimal | None = None
|
|
125
|
+
discount_percent: int | None = None
|
|
126
|
+
#: Alert shown for the item, e.g. that a backup product is sent.
|
|
127
|
+
alert_text: str | None = None
|
|
128
|
+
#: ``True`` if a backup product is sent instead of the subscribed one.
|
|
129
|
+
substitute: bool = False
|
|
130
|
+
#: The subscription of this item. Delivery pages do not link items to subscriptions, so they are matched by
|
|
131
|
+
#: title (see :func:`amazon_subscriptions.parse.matching.match_deliveries`). ``None`` if no unique match.
|
|
132
|
+
subscription_id: str | None = None
|
|
133
|
+
#: ASIN of the subscribed product, also if a backup product is sent instead.
|
|
134
|
+
asin: str | None = None
|
|
135
|
+
#: ASIN of the backup product if ``substitute`` is ``True``. ``title``, ``price`` and ``discount_percent`` are then
|
|
136
|
+
#: the ones of the backup product. ``None`` if it could not be read from the subscription's detail sheet.
|
|
137
|
+
substitute_asin: str | None = None
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass
|
|
141
|
+
class DiscountTier:
|
|
142
|
+
#: Number of items (products) in the delivery.
|
|
143
|
+
item_count: int | None = None
|
|
144
|
+
#: ``True`` if Amazon states that the maximum discount is unlocked for this delivery.
|
|
145
|
+
max_discount_unlocked: bool | None = None
|
|
146
|
+
#: Total savings of the delivery as stated by Amazon.
|
|
147
|
+
savings: Decimal | None = None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass
|
|
151
|
+
class UpcomingDelivery:
|
|
152
|
+
date: datetime.date
|
|
153
|
+
change_deadline: datetime.date | None = None
|
|
154
|
+
items: list[DeliveryItem] = field(default_factory=list)
|
|
155
|
+
discount_tier: DiscountTier = field(default_factory=DiscountTier)
|
|
156
|
+
#: Sum of the item prices. ``None`` if any item has no price (yet).
|
|
157
|
+
total: Decimal | None = None
|
|
158
|
+
currency: str | None = None
|
|
159
|
+
delivery_bundle_id: str | None = None
|
|
160
|
+
#: URL of the delivery page.
|
|
161
|
+
url: str | None = None
|
|
162
|
+
#: Pages of this delivery that could not be read, so some of its fields may be ``None`` and items missing.
|
|
163
|
+
parse_errors: list[ParseError] = field(default_factory=list)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Pure functions that turn Subscribe & Save pages into objects. They send no requests."""
|
|
2
|
+
|
|
3
|
+
from amazon_subscriptions.parse.acp import AcpWidget, parse_acp_widget
|
|
4
|
+
from amazon_subscriptions.parse.deliveries import (
|
|
5
|
+
parse_deliveries_next_url,
|
|
6
|
+
parse_delivery_cards,
|
|
7
|
+
parse_delivery_page,
|
|
8
|
+
)
|
|
9
|
+
from amazon_subscriptions.parse.detail import BackupItem, parse_subscription_detail
|
|
10
|
+
from amazon_subscriptions.parse.matching import match_deliveries, titles_match
|
|
11
|
+
from amazon_subscriptions.parse.product import ProductPrices, parse_product_page
|
|
12
|
+
from amazon_subscriptions.parse.subscriptions import parse_subscriptions, parse_subscriptions_next_url
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AcpWidget",
|
|
16
|
+
"BackupItem",
|
|
17
|
+
"ProductPrices",
|
|
18
|
+
"match_deliveries",
|
|
19
|
+
"parse_acp_widget",
|
|
20
|
+
"parse_deliveries_next_url",
|
|
21
|
+
"parse_delivery_cards",
|
|
22
|
+
"parse_delivery_page",
|
|
23
|
+
"parse_product_page",
|
|
24
|
+
"parse_subscription_detail",
|
|
25
|
+
"parse_subscriptions",
|
|
26
|
+
"parse_subscriptions_next_url",
|
|
27
|
+
"titles_match",
|
|
28
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Parse the widgets of the landing page that load further pages by XHR (Amazon's "ACP" widgets)."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
|
|
6
|
+
from amazon_subscriptions.exceptions import PageStructureError
|
|
7
|
+
from amazon_subscriptions.parse.util import Html, attr_of, soup_of
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class AcpWidget:
|
|
12
|
+
"""What the browser sends to load the next page of a widget, see ``SubscriptionsClient._paginate``."""
|
|
13
|
+
|
|
14
|
+
#: Path of the widget, e.g. ``/acp/myd-hub-subscriptions-card-desktop/<id>/``.
|
|
15
|
+
path: str
|
|
16
|
+
#: Value of the ``x-amz-acp-params`` header.
|
|
17
|
+
params: str
|
|
18
|
+
stamp: str
|
|
19
|
+
#: Tracking parameters of the widget (``pf_rd_p``, ``pf_rd_r``).
|
|
20
|
+
tracking: dict[str, str]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def parse_acp_widget(html: Html, next_page_selector: str, page: str | None = None) -> AcpWidget | None:
|
|
24
|
+
"""The widget around the next page marker ``next_page_selector``, or ``None`` if there is no next page.
|
|
25
|
+
|
|
26
|
+
:raises PageStructureError: If the marker is not inside a widget with the expected attributes.
|
|
27
|
+
"""
|
|
28
|
+
marker = soup_of(html).select_one(next_page_selector)
|
|
29
|
+
if marker is None:
|
|
30
|
+
return None
|
|
31
|
+
widget = marker.find_parent(attrs={"data-acp-path": True})
|
|
32
|
+
path = attr_of(widget, "data-acp-path")
|
|
33
|
+
params = attr_of(widget, "data-acp-params")
|
|
34
|
+
stamp = attr_of(widget, "data-acp-stamp")
|
|
35
|
+
try:
|
|
36
|
+
tracking = json.loads(attr_of(widget, "data-acp-tracking") or "{}")
|
|
37
|
+
except ValueError:
|
|
38
|
+
tracking = None
|
|
39
|
+
if not (path and params and stamp and isinstance(tracking, dict)):
|
|
40
|
+
raise PageStructureError(f"Next page {next_page_selector!r} is not inside a widget", page)
|
|
41
|
+
return AcpWidget(path, params, stamp, {k: v for k, v in tracking.items() if k in ("pf_rd_p", "pf_rd_r")})
|