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.
File without changes
@@ -0,0 +1,180 @@
1
+ """The ``amazon-subscriptions`` command.
2
+
3
+ ``login``, ``logout`` and ``check-session`` are the commands of amazon-orders, so the session is shared with it.
4
+ """
5
+
6
+ import logging
7
+ from typing import Any
8
+
9
+ import click
10
+ from amazonorders.cli import IOClick, check_session, login, logout
11
+ from amazonorders.conf import AmazonOrdersConfig
12
+ from amazonorders.exception import AmazonOrdersError
13
+ from amazonorders.session import AmazonSession
14
+
15
+ from amazon_subscriptions.client import SubscriptionsClient
16
+ from amazon_subscriptions.exceptions import SessionExpiredError, SubscriptionsError
17
+ from amazon_subscriptions.models import Interval, Subscription, UpcomingDelivery
18
+ from amazon_subscriptions.serialize import to_json
19
+
20
+
21
+ class _Group(click.Group):
22
+ """Show errors of both packages as messages instead of tracebacks."""
23
+
24
+ def invoke(self, ctx: click.Context) -> Any:
25
+ try:
26
+ return super().invoke(ctx)
27
+ except SessionExpiredError as e:
28
+ domain = f" --domain {ctx.params['domain']}" if ctx.params.get("domain") else ""
29
+ raise click.ClickException(
30
+ f"{e} Sign in again with:\n amazon-subscriptions{domain} logout\n amazon-subscriptions{domain} login"
31
+ ) from e
32
+ except (SubscriptionsError, AmazonOrdersError) as e:
33
+ logging.getLogger(__name__).debug("An error occurred.", exc_info=True)
34
+ raise click.ClickException(str(e)) from e
35
+
36
+
37
+ @click.group(cls=_Group)
38
+ @click.option(
39
+ "--domain",
40
+ help="The Amazon domain, e.g. amazon.de. Defaults to the domain configured for amazon-orders.",
41
+ )
42
+ @click.option(
43
+ "--debug",
44
+ is_flag=True,
45
+ default=False,
46
+ help="Log the requests to stderr and write every page to the output directory.",
47
+ )
48
+ @click.option("--output-dir", help="The directory for the pages written with --debug.")
49
+ @click.pass_context
50
+ def cli(ctx: click.Context, domain: str | None, debug: bool, output_dir: str | None) -> None:
51
+ """Read Amazon Subscribe & Save subscriptions and upcoming deliveries. Strictly read-only."""
52
+ if debug:
53
+ handler = logging.StreamHandler()
54
+ for name in ("amazon_subscriptions", "amazonorders"):
55
+ logging.getLogger(name).setLevel(logging.DEBUG)
56
+ logging.getLogger(name).addHandler(handler)
57
+
58
+ # The pages are only read in English, which amazon-orders requests unless a language package is configured.
59
+ # This only applies to this run, the configuration file is not changed.
60
+ data: dict[str, Any] = {"language_package": None}
61
+ if domain:
62
+ data["domain"] = domain
63
+ if output_dir:
64
+ data["output_dir"] = output_dir
65
+ conf = AmazonOrdersConfig(data=data)
66
+ ctx.ensure_object(dict)
67
+ # The keys the commands of amazon-orders expect
68
+ ctx.obj["conf"] = conf
69
+ ctx.obj["amazon_session"] = AmazonSession(debug=debug, io=IOClick(), config=conf)
70
+
71
+
72
+ cli.add_command(login)
73
+ cli.add_command(logout)
74
+ cli.add_command(check_session)
75
+
76
+
77
+ #: Exit status if some pages could not be read, so the result is incomplete.
78
+ EXIT_PARTIAL = 2
79
+
80
+
81
+ def _client(ctx: click.Context, with_prices: bool = False) -> SubscriptionsClient:
82
+ return SubscriptionsClient(ctx.obj["amazon_session"], with_prices=with_prices)
83
+
84
+
85
+ def _exit_on_errors(ctx: click.Context, client: SubscriptionsClient) -> None:
86
+ """Print the errors of the pages that could not be read and exit with :data:`EXIT_PARTIAL` if there are any."""
87
+ if not client.errors:
88
+ return
89
+ for error in client.errors:
90
+ saved = f", page saved to {error.html_path}" if error.html_path else ""
91
+ click.echo(f"Error: {error.message} (page: {error.url}){saved}", err=True)
92
+ click.echo("The result is incomplete, see parse_errors.", err=True)
93
+ ctx.exit(EXIT_PARTIAL)
94
+
95
+
96
+ @cli.command("list")
97
+ @click.option("--json", "as_json", is_flag=True, default=False, help="Print JSON.")
98
+ @click.option(
99
+ "--with-prices",
100
+ is_flag=True,
101
+ default=False,
102
+ help="Also load each product page for the regular, list, unit and alternative offer prices (one request each).",
103
+ )
104
+ @click.pass_context
105
+ def list_subscriptions(ctx: click.Context, as_json: bool, with_prices: bool) -> None:
106
+ """List all subscriptions."""
107
+ client = _client(ctx, with_prices)
108
+ subscriptions = client.get_subscriptions()
109
+ if as_json:
110
+ click.echo(to_json(subscriptions))
111
+ else:
112
+ for subscription in sorted(subscriptions, key=_next_delivery_key):
113
+ click.echo(_format_subscription(subscription))
114
+ click.echo(f"\n{len(subscriptions)} subscriptions")
115
+ _exit_on_errors(ctx, client)
116
+
117
+
118
+ @cli.command()
119
+ @click.option("--json", "as_json", is_flag=True, default=False, help="Print JSON.")
120
+ @click.pass_context
121
+ def upcoming(ctx: click.Context, as_json: bool) -> None:
122
+ """List the upcoming deliveries with their items."""
123
+ client = _client(ctx)
124
+ deliveries = client.get_upcoming_deliveries()
125
+ if as_json:
126
+ click.echo(to_json(deliveries))
127
+ else:
128
+ for index, delivery in enumerate(deliveries):
129
+ if index:
130
+ click.echo()
131
+ click.echo(_format_delivery(delivery))
132
+ _exit_on_errors(ctx, client)
133
+
134
+
135
+ def _next_delivery_key(subscription: Subscription) -> tuple[bool, str, str]:
136
+ date = subscription.next_delivery_date
137
+ return date is None, date.isoformat() if date else "", subscription.title or ""
138
+
139
+
140
+ def _format_interval(interval: Interval | None) -> str:
141
+ if interval is None:
142
+ return "?"
143
+ return f"every {interval.every} {interval.unit.value}{'s' if interval.every != 1 else ''}"
144
+
145
+
146
+ def _format_amount(amount: Any, currency: str | None) -> str:
147
+ return f"{amount} {currency or ''}".strip() if amount is not None else "-"
148
+
149
+
150
+ def _format_subscription(s: Subscription) -> str:
151
+ date = s.next_delivery_date.isoformat() if s.next_delivery_date else "?"
152
+ price = f" {_format_amount(s.subscription_price, s.currency)}" if s.subscription_price is not None else ""
153
+ if s.price is not None:
154
+ unit = f", {_format_amount(s.unit_price, s.currency)}/{s.unit_price_unit}" if s.unit_price is not None else ""
155
+ list_price = ""
156
+ if s.list_price is not None and s.list_price_type is not None:
157
+ list_price = f", {s.list_price_type.value} {_format_amount(s.list_price, s.currency)}"
158
+ alternative = ""
159
+ if s.alternative_offer is not None:
160
+ seller = f" from {s.alternative_offer.seller}" if s.alternative_offer.seller else ""
161
+ alternative = f", alternative offer {_format_amount(s.alternative_offer.price, s.currency)}{seller}"
162
+ price += f" (regular {_format_amount(s.price, s.currency)}{unit}{list_price}{alternative})"
163
+ status = f" [{s.status.value}]" if s.status.value not in ("active", "unknown") else ""
164
+ status += " [incomplete]" if s.parse_errors else ""
165
+ return f"{date} {s.quantity or '?'} x {s.title or s.asin} ({_format_interval(s.interval)}){price}{status}"
166
+
167
+
168
+ def _format_delivery(d: UpcomingDelivery) -> str:
169
+ deadline = f", changes until {d.change_deadline.isoformat()}" if d.change_deadline else ""
170
+ incomplete = " [incomplete]" if d.parse_errors else ""
171
+ total = _format_amount(d.total, d.currency)
172
+ lines = [f"{d.date.isoformat()}: {len(d.items)} items, total {total}{deadline}{incomplete}"]
173
+ for item in d.items:
174
+ discount = f" (-{item.discount_percent}%)" if item.discount_percent is not None else ""
175
+ substitute = ""
176
+ if item.substitute:
177
+ substitute = f" [backup product {item.substitute_asin}]" if item.substitute_asin else " [backup product]"
178
+ price = _format_amount(item.price, d.currency)
179
+ lines.append(f" {item.quantity or '?'} x {item.title} {price}{discount}{substitute}")
180
+ return "\n".join(lines)
@@ -0,0 +1,485 @@
1
+ """Load the Subscribe & Save pages with an authenticated :class:`~amazonorders.session.AmazonSession`.
2
+
3
+ The client is strictly read-only: every URL is checked against :data:`READ_ONLY_URLS` before it is requested, so
4
+ links and forms that change subscriptions (skip, pause, cancel, deliver now, ...) can never be requested, even if a
5
+ page changes.
6
+ """
7
+
8
+ import dataclasses
9
+ import json
10
+ import logging
11
+ import os
12
+ import re
13
+ import time
14
+ from collections.abc import Callable, Iterator
15
+ from dataclasses import dataclass, field
16
+ from datetime import date, datetime
17
+ from typing import TypeVar
18
+ from urllib.parse import urlencode, urljoin, urlparse
19
+
20
+ from amazonorders.constants import Constants
21
+ from amazonorders.region_data import REGION_SITES
22
+ from amazonorders.session import AmazonSession
23
+ from bs4 import BeautifulSoup
24
+ from requests import RequestException, Response
25
+
26
+ from amazon_subscriptions.exceptions import (
27
+ CaptchaError,
28
+ PageStructureError,
29
+ SessionExpiredError,
30
+ SubscriptionsError,
31
+ )
32
+ from amazon_subscriptions.locales import get_locale, tld_of
33
+ from amazon_subscriptions.locales.base import SubscriptionLocale
34
+ from amazon_subscriptions.models import DiscountTier, ParseError, Subscription, UpcomingDelivery
35
+ from amazon_subscriptions.parse import (
36
+ AcpWidget,
37
+ BackupItem,
38
+ match_deliveries,
39
+ parse_acp_widget,
40
+ parse_deliveries_next_url,
41
+ parse_delivery_cards,
42
+ parse_delivery_page,
43
+ parse_product_page,
44
+ parse_subscription_detail,
45
+ parse_subscriptions,
46
+ parse_subscriptions_next_url,
47
+ selectors,
48
+ titles_match,
49
+ )
50
+ from amazon_subscriptions.parse.util import Html
51
+
52
+ logger = logging.getLogger(__name__)
53
+
54
+ T = TypeVar("T")
55
+
56
+ LANDING_PATH = "/auto-deliveries"
57
+ DETAIL_PATH = "/auto-deliveries/ajax/subscription/"
58
+
59
+ #: The only URLs (path and query) the client requests. Everything else is refused with a
60
+ #: :class:`~amazon_subscriptions.exceptions.SubscriptionsError` before it is sent.
61
+ READ_ONLY_URLS = [
62
+ # Landing page
63
+ re.compile(r"/auto-deliveries/?"),
64
+ # Delivery page, as linked by a delivery card
65
+ re.compile(r"/auto-deliveries/?\?(?!.*ajax)(?=.*\bdeliveryDate=\d+).*"),
66
+ # Detail sheet of a subscription, as opened by a subscription tile
67
+ re.compile(r"/auto-deliveries/ajax/subscription/?\?(?=.*\bsubscriptionId=)[^#]*"),
68
+ # Product page, only loaded with ``with_prices``
69
+ re.compile(r"/dp/[A-Z0-9]{10}"),
70
+ # Next page of the subscriptions and deliveries widgets of the landing page
71
+ re.compile(r"/acp/myd-hub-(?:subscriptions|deliveries)-card-desktop/[^/?]+/paginate\?[^/]*"),
72
+ ]
73
+ #: The only ``nextUrl`` values sent to the widgets, which request them on Amazon's side.
74
+ READ_ONLY_NEXT_URLS = re.compile(
75
+ r"/api/marketplaces/[A-Z0-9]+/customer/replenishment/(?:subscriptions|fulfillment/deliveries)\?[^/]*"
76
+ )
77
+
78
+ #: The language segment that links of pages in another language than the storefront's start with, e.g. ``/-/en``.
79
+ LANGUAGE_PREFIX = re.compile(r"^(https://[^/]+)/-/[a-z]{2}(?:_[a-z]{2})?(?=/)", re.IGNORECASE)
80
+
81
+ #: Headers of the browser's XHR that loads the next page of a widget, on top of the session's base headers.
82
+ ACP_HEADERS = {
83
+ "Accept": "text/html, application/json",
84
+ "Content-Type": "application/json",
85
+ "Sec-Fetch-Dest": "empty",
86
+ "Sec-Fetch-Mode": "cors",
87
+ "Sec-Fetch-Site": "same-origin",
88
+ "X-Requested-With": "XMLHttpRequest",
89
+ "x-amz-amabot-click-attributes": "disable",
90
+ }
91
+ #: Base headers of a page navigation, which the XHR does not send.
92
+ NAVIGATION_ONLY_HEADERS = ("Sec-Fetch-User", "Upgrade-Insecure-Requests")
93
+
94
+
95
+ def default_failed_dir() -> str:
96
+ """``amazon-subscriptions/failed`` in the user's cache directory, where pages that could not be read are saved."""
97
+ cache_dir = os.environ.get("XDG_CACHE_HOME") or os.path.join(os.path.expanduser("~"), ".cache")
98
+ return os.path.join(cache_dir, "amazon-subscriptions", "failed")
99
+
100
+
101
+ @dataclass
102
+ class _Pages:
103
+ """Everything loaded for one call, so both results come from the same state of the account."""
104
+
105
+ subscriptions: list[Subscription]
106
+ deliveries: list[UpcomingDelivery] = field(default_factory=list)
107
+
108
+
109
+ class SubscriptionsClient:
110
+ """Read the Subscribe & Save subscriptions and upcoming deliveries of the session's account.
111
+
112
+ :param session: A session of amazon-orders. Its cookies must hold a login (``amazon-subscriptions login``);
113
+ the client never logs in by itself.
114
+ :param today: The date to resolve dates rendered without year, defaults to today.
115
+ :param max_pages: The maximum number of further pages loaded per widget, to never loop forever.
116
+ :param with_prices: Also load the product page of each subscription for its regular price, list price and unit
117
+ price, which the Subscribe & Save pages do not show. One more request per subscription.
118
+ :param retry_delay: Seconds to wait before loading a delivery or product page a second time that could not be
119
+ loaded or read.
120
+ :param failed_dir: The directory where delivery and product pages that could not be read are saved, defaults
121
+ to :func:`default_failed_dir`.
122
+
123
+ A delivery or product page that still fails the second time does not fail the call: the delivery or
124
+ subscription is returned with what is known from the other pages, and the error is added to its
125
+ ``parse_errors`` and to :attr:`errors`. Only the overview (landing page and its further pages) and an expired
126
+ session are fatal.
127
+ """
128
+
129
+ def __init__(
130
+ self,
131
+ session: AmazonSession,
132
+ today: date | None = None,
133
+ max_pages: int = 20,
134
+ with_prices: bool = False,
135
+ retry_delay: float = 2.0,
136
+ failed_dir: str | None = None,
137
+ ) -> None:
138
+ self.session = session
139
+ self.today = today or date.today()
140
+ self.max_pages = max_pages
141
+ self.with_prices = with_prices
142
+ self.retry_delay = retry_delay
143
+ self.failed_dir = failed_dir or default_failed_dir()
144
+ self.base_url: str = session.config.constants.BASE_URL.rstrip("/")
145
+ self.locale: SubscriptionLocale = get_locale(self.base_url)
146
+ self._check_english(session.config.constants)
147
+ #: The errors of all pages that could not be loaded or read, empty if the result is complete.
148
+ self.errors: list[ParseError] = []
149
+ self._pages: _Pages | None = None
150
+ #: The captcha of a delivery or product page, after which no further such pages are requested.
151
+ self._captcha: CaptchaError | None = None
152
+
153
+ def _check_english(self, constants: Constants) -> None:
154
+ """Check that amazon-orders requests the pages in English, the only language they are read in.
155
+
156
+ :raises SubscriptionsError: If a ``language_package`` or ``constants_class`` requests another language.
157
+ """
158
+ site = REGION_SITES[tld_of(self.base_url)]
159
+ if site.language_cookie and constants.REQUEST_COOKIES.get(site.language_cookie) != site.english_locale:
160
+ raise SubscriptionsError(
161
+ f"amazon-orders is configured to request the pages of {self.base_url} in another language than "
162
+ f"English (cookie {site.language_cookie}={constants.REQUEST_COOKIES.get(site.language_cookie)!r}). "
163
+ "Remove the language_package or constants_class from its configuration, only English pages can be "
164
+ "read."
165
+ )
166
+
167
+ def get_subscriptions(self) -> list[Subscription]:
168
+ """All subscriptions, with their next delivery and its price if Amazon shows it."""
169
+ return self._load().subscriptions
170
+
171
+ def get_upcoming_deliveries(self) -> list[UpcomingDelivery]:
172
+ """The upcoming deliveries with their items, linked to the subscriptions."""
173
+ return self._load().deliveries
174
+
175
+ def _load(self) -> _Pages:
176
+ if self._pages is not None:
177
+ return self._pages
178
+ if not self.session.auth_cookies_stored():
179
+ raise SessionExpiredError("There is no stored Amazon session.")
180
+
181
+ landing_url = self._absolute(LANDING_PATH)
182
+ landing = self._soup(self._get(landing_url))
183
+ subscriptions = self._load_subscriptions(landing, landing_url)
184
+ deliveries = self._load_deliveries(self._load_delivery_cards(landing, landing_url))
185
+ match_deliveries(subscriptions, deliveries)
186
+ for delivery in deliveries:
187
+ # The next delivery of these subscriptions, and so their price, may be incomplete.
188
+ for subscription in subscriptions:
189
+ if delivery.parse_errors and subscription.next_delivery_date == delivery.date:
190
+ subscription.parse_errors += delivery.parse_errors
191
+ self._load_substitutes(subscriptions, deliveries)
192
+ if self.with_prices:
193
+ self._load_prices(subscriptions)
194
+ self._pages = _Pages(subscriptions, deliveries)
195
+ return self._pages
196
+
197
+ def _load_subscriptions(self, landing: BeautifulSoup, landing_url: str) -> list[Subscription]:
198
+ subscriptions = parse_subscriptions(landing, self.locale, self.today, landing_url)
199
+ for fragment, page in self._paginate(
200
+ landing, selectors.SUBSCRIPTIONS_NEXT_PAGE, parse_subscriptions_next_url, "subscriptions"
201
+ ):
202
+ subscriptions += parse_subscriptions(fragment, self.locale, self.today, page)
203
+
204
+ ids = [s.subscription_id for s in subscriptions]
205
+ if len(set(ids)) != len(ids):
206
+ raise PageStructureError("The subscription pages repeat subscriptions", landing_url)
207
+ for subscription in subscriptions:
208
+ if subscription.product_url:
209
+ subscription.product_url = self._absolute(subscription.product_url)
210
+ return subscriptions
211
+
212
+ def _load_delivery_cards(self, landing: BeautifulSoup, landing_url: str) -> list[UpcomingDelivery]:
213
+ cards = parse_delivery_cards(landing, self.locale, landing_url)
214
+ for fragment, page in self._paginate(
215
+ landing, selectors.DELIVERIES_NEXT_PAGE, parse_deliveries_next_url, "deliveries"
216
+ ):
217
+ cards += parse_delivery_cards(fragment, self.locale, page, fragment=True)
218
+ return cards
219
+
220
+ def _load_deliveries(self, cards: list[UpcomingDelivery]) -> list[UpcomingDelivery]:
221
+ """The delivery pages of the cards. A captcha stops loading them, as every further request would get one too;
222
+ the remaining deliveries are then the cards."""
223
+ deliveries = []
224
+ for card in cards:
225
+ if not card.url:
226
+ raise PageStructureError(f"Delivery card of {card.date} without link", LANDING_PATH)
227
+ url = self._absolute(card.url)
228
+ try:
229
+ delivery, errors = self._load_page(
230
+ url, f"delivery-{card.date}", lambda soup, page: parse_delivery_page(soup, self.locale, page)
231
+ )
232
+ except CaptchaError as e:
233
+ delivery, errors = None, [self._error(ParseError(url=url, message=str(e)))]
234
+ deliveries.append(self._merge_delivery(card, delivery, url, errors))
235
+ return deliveries
236
+
237
+ def _merge_delivery(
238
+ self, card: UpcomingDelivery, delivery: UpcomingDelivery | None, url: str, errors: list[ParseError]
239
+ ) -> UpcomingDelivery:
240
+ """The delivery of a delivery page with the card's link and bundle, or the card if its page failed."""
241
+ if delivery is None:
242
+ return UpcomingDelivery(
243
+ date=card.date,
244
+ change_deadline=card.change_deadline,
245
+ discount_tier=DiscountTier(item_count=card.discount_tier.item_count),
246
+ currency=card.currency,
247
+ delivery_bundle_id=card.delivery_bundle_id,
248
+ url=url,
249
+ parse_errors=errors,
250
+ )
251
+ delivery.parse_errors = errors
252
+ if delivery.date != card.date:
253
+ logger.warning(f"The delivery card shows {card.date}, but its page shows {delivery.date}.")
254
+ if delivery.discount_tier.item_count is None:
255
+ delivery.discount_tier.item_count = card.discount_tier.item_count
256
+ delivery.change_deadline = delivery.change_deadline or card.change_deadline
257
+ delivery.delivery_bundle_id = card.delivery_bundle_id
258
+ delivery.url = url
259
+ return delivery
260
+
261
+ def _load_substitutes(self, subscriptions: list[Subscription], deliveries: list[UpcomingDelivery]) -> None:
262
+ """Set the ASIN of the backup products sent instead of subscribed ones.
263
+
264
+ Delivery pages show no ASINs, so the detail sheet of the subscription is loaded, which names its backup
265
+ product. It is only used if its title matches the item's title.
266
+ """
267
+ by_id = {s.subscription_id: s for s in subscriptions}
268
+ backups: dict[str, BackupItem | None] = {}
269
+ for delivery in deliveries:
270
+ for item in delivery.items:
271
+ subscription = by_id.get(item.subscription_id or "")
272
+ if not item.substitute or subscription is None:
273
+ continue
274
+ if subscription.subscription_id not in backups:
275
+ backups[subscription.subscription_id] = self._load_backup_item(subscription)
276
+ backup = backups[subscription.subscription_id]
277
+ if backup is not None and titles_match(backup.title, item.title):
278
+ item.substitute_asin = backup.asin
279
+ else:
280
+ logger.warning(
281
+ f"The backup product of subscription {subscription.subscription_id} is not the item "
282
+ f"{item.title!r} of {delivery.date}, so its ASIN is not known."
283
+ )
284
+
285
+ def _load_backup_item(self, subscription: Subscription) -> BackupItem | None:
286
+ query = {"subscriptionId": subscription.subscription_id, "enableMydExperience": "1", "clientName": "mydHub"}
287
+ if subscription.asin:
288
+ query["subAsin"] = subscription.asin
289
+ url = self._absolute(f"{DETAIL_PATH}?{urlencode(query)}")
290
+ try:
291
+ self._raise_on_captcha()
292
+ return parse_subscription_detail(self._soup(self._get(url)), url)
293
+ except SessionExpiredError:
294
+ raise
295
+ except SubscriptionsError as e:
296
+ if isinstance(e, CaptchaError):
297
+ self._captcha = e
298
+ logger.warning(f"The detail sheet of subscription {subscription.subscription_id} was not loaded: {e}")
299
+ return None
300
+
301
+ def _load_prices(self, subscriptions: list[Subscription]) -> None:
302
+ """Set the prices of the product pages of the subscriptions.
303
+
304
+ A product page that fails leaves the prices of its subscription ``None`` and adds the error to its
305
+ ``parse_errors``. A captcha stops loading them, as every further request would get one too.
306
+ """
307
+ for subscription in subscriptions:
308
+ if not subscription.asin:
309
+ continue
310
+ url = self._absolute(f"/dp/{subscription.asin}")
311
+ try:
312
+ prices, errors = self._load_page(
313
+ url, f"product-{subscription.asin}", lambda soup, page: parse_product_page(soup, self.locale, page)
314
+ )
315
+ except CaptchaError as e:
316
+ subscription.parse_errors.append(self._error(ParseError(url=url, message=str(e))))
317
+ logger.debug(f"Stopped loading the product pages for their prices: {e}")
318
+ return
319
+ subscription.parse_errors += errors
320
+ if prices is None:
321
+ continue
322
+ subscription.price = prices.price
323
+ subscription.list_price = prices.list_price
324
+ subscription.list_price_type = prices.list_price_type
325
+ subscription.list_price_source = prices.list_price_source
326
+ subscription.unit_price = prices.unit_price
327
+ subscription.unit_price_unit = prices.unit_price_unit
328
+ subscription.alternative_offer = prices.alternative_offer
329
+
330
+ def _paginate(
331
+ self, landing: BeautifulSoup, next_page_selector: str, next_url_of: Callable[[Html], str | None], name: str
332
+ ) -> Iterator[tuple[BeautifulSoup, str]]:
333
+ """Yield the further pages of the widget of the landing page with the next page marker, as the browser
334
+ loads them when scrolling."""
335
+ widget = parse_acp_widget(landing, next_page_selector, LANDING_PATH)
336
+ next_url = next_url_of(landing)
337
+ seen: set[str] = set()
338
+ while widget is not None and next_url:
339
+ if next_url in seen:
340
+ raise PageStructureError(f"The {name} pages repeat the next page {next_url!r}", LANDING_PATH)
341
+ if len(seen) >= self.max_pages:
342
+ raise SubscriptionsError(f"More than {self.max_pages} pages of {name}, stopped loading them.")
343
+ seen.add(next_url)
344
+ html, page = self._post_acp(widget, next_url, name)
345
+ fragment = self._soup(html)
346
+ yield fragment, page
347
+ next_url = next_url_of(fragment)
348
+
349
+ def _post_acp(self, widget: AcpWidget, next_url: str, name: str) -> tuple[str, str]:
350
+ if not READ_ONLY_NEXT_URLS.fullmatch(next_url):
351
+ raise SubscriptionsError(f"Refused to load the next page {next_url!r}, it is not a known read-only URL.")
352
+ query = urlencode({"page-type": "RCXSubs", **widget.tracking, "stamp": widget.stamp})
353
+ url = self._absolute(f"{widget.path.rstrip('/')}/paginate?{query}")
354
+ self._check_read_only(url)
355
+
356
+ base_headers = self.session.config.constants.BASE_HEADERS
357
+ headers = {k: v for k, v in base_headers.items() if k not in NAVIGATION_ONLY_HEADERS}
358
+ headers.update(ACP_HEADERS)
359
+ headers.update(
360
+ {
361
+ "Origin": self.base_url,
362
+ "Referer": self._absolute(LANDING_PATH),
363
+ "x-amz-acp-params": widget.params,
364
+ }
365
+ )
366
+ body = json.dumps({"nextUrl": next_url}, separators=(",", ":"))
367
+ logger.debug(f"POST request: {url} {body}")
368
+ # The session's post() would overwrite these headers with its base headers of a page navigation.
369
+ response = self.session.session.post(
370
+ url, headers=headers, data=body, timeout=self.session.config.request_timeout
371
+ )
372
+ html = self._text(response, url)
373
+ page = self._write_debug_file(f"paginate-{name}", html) or url
374
+ return html, page
375
+
376
+ def _load_page(
377
+ self, url: str, name: str, parse: Callable[[BeautifulSoup, str], T]
378
+ ) -> tuple[T | None, list[ParseError]]:
379
+ """Load and parse a page, and a second time after :attr:`retry_delay` if that fails.
380
+
381
+ An attempt fails if the page cannot be loaded, the parser raises, or the result has ``parse_errors`` (e.g. a
382
+ date taken from the URL). Every received page of a failed attempt is saved to :attr:`failed_dir`.
383
+
384
+ :returns: The result of the first successful attempt and no errors. If both fail, the result of the second
385
+ attempt (``None`` if it raised) and its errors, which are also added to :attr:`errors`.
386
+ :raises SessionExpiredError: If the session has expired.
387
+ :raises CaptchaError: If Amazon answers with a captcha, now or before. It is not retried.
388
+ """
389
+ result: T | None = None
390
+ errors: list[ParseError] = []
391
+ for attempt in (1, 2):
392
+ if attempt == 2:
393
+ logger.info(f"Loading {url} again in {self.retry_delay} s: {errors[0].message}")
394
+ time.sleep(self.retry_delay)
395
+ self._raise_on_captcha()
396
+ html = None
397
+ try:
398
+ html = self._get(url)
399
+ result = parse(self._soup(html), url)
400
+ errors = list(getattr(result, "parse_errors", None) or [])
401
+ except CaptchaError as e:
402
+ self._captcha = e
403
+ raise
404
+ except SessionExpiredError:
405
+ raise
406
+ except (SubscriptionsError, RequestException) as e:
407
+ result = None
408
+ message = e.message if isinstance(e, PageStructureError) else str(e)
409
+ errors = [ParseError(url=url, message=message, selector=getattr(e, "selector", None))]
410
+ if not errors:
411
+ return result, []
412
+ html_path = self._write_failed_file(f"{name}_{attempt}", html) if html is not None else None
413
+ errors = [dataclasses.replace(error, html_path=html_path) for error in errors]
414
+ for error in errors:
415
+ self._error(error)
416
+ return result, errors
417
+
418
+ def _raise_on_captcha(self) -> None:
419
+ if self._captcha is not None:
420
+ raise self._captcha
421
+
422
+ def _error(self, error: ParseError) -> ParseError:
423
+ """Add an error to :attr:`errors`."""
424
+ logger.debug(f"Error: {error}")
425
+ self.errors.append(error)
426
+ return error
427
+
428
+ def _write_failed_file(self, name: str, text: str) -> str | None:
429
+ """Save a page that could not be read, also without ``debug``, so its structure can be analyzed."""
430
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
431
+ path = os.path.join(self.failed_dir, f"{timestamp}_{name}.html")
432
+ try:
433
+ os.makedirs(self.failed_dir, exist_ok=True)
434
+ with open(path, "w", encoding="utf-8") as f:
435
+ f.write(text)
436
+ except OSError as e:
437
+ logger.warning(f"The page that could not be read was not saved to {path}: {e}")
438
+ return None
439
+ logger.debug(f"Page that could not be read written to file: {path}")
440
+ return path
441
+
442
+ def _get(self, url: str) -> str:
443
+ self._check_read_only(url)
444
+ return self._text(self.session.get(url).response, url)
445
+
446
+ def _check_read_only(self, url: str) -> None:
447
+ parsed = urlparse(url)
448
+ path = parsed.path + (f"?{parsed.query}" if parsed.query else "")
449
+ if (
450
+ f"{parsed.scheme}://{parsed.netloc}" != self.base_url
451
+ or parsed.fragment
452
+ or not any(pattern.fullmatch(path) for pattern in READ_ONLY_URLS)
453
+ ):
454
+ raise SubscriptionsError(f"Refused to request {url!r}, it is not a known read-only URL.")
455
+
456
+ def _text(self, response: Response, url: str) -> str:
457
+ if response.url.startswith(self.session.config.constants.SIGN_IN_URL):
458
+ raise SessionExpiredError(f"Amazon redirected {url} to the sign-in page.")
459
+ if not response.ok:
460
+ raise SubscriptionsError(self.session.build_response_error(response))
461
+ # Amazon sends the fragments without charset, where requests would fall back to ISO-8859-1.
462
+ charset = response.encoding if "charset" in response.headers.get("Content-Type", "").lower() else None
463
+ return response.content.decode(charset or "utf-8", errors="replace")
464
+
465
+ def _soup(self, html: str) -> BeautifulSoup:
466
+ return BeautifulSoup(html, self.session.config.bs4_parser)
467
+
468
+ def _write_debug_file(self, name: str, text: str) -> str | None:
469
+ """Write a response the session did not write itself, as it does for its own requests with ``debug``."""
470
+ if not self.session.debug:
471
+ return None
472
+ output_dir = self.session.config.output_dir
473
+ os.makedirs(output_dir, exist_ok=True)
474
+ index = 0
475
+ while os.path.exists(path := os.path.join(output_dir, f"{name}_{index}.html")):
476
+ index += 1
477
+ with open(path, "w", encoding="utf-8") as f:
478
+ f.write(text)
479
+ logger.debug(f"Response written to file: {path}")
480
+ return path
481
+
482
+ def _absolute(self, url: str) -> str:
483
+ """The absolute URL of a link, without the language prefix of English pages (``/-/en/auto-deliveries``),
484
+ as the language cookie already requests them in English."""
485
+ return LANGUAGE_PREFIX.sub(r"\1", urljoin(self.base_url + "/", url), count=1)