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
|
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)
|