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,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")})