aioamazondevices 6.4.5__py3-none-any.whl → 9.0.2__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.
- aioamazondevices/__init__.py +1 -1
- aioamazondevices/api.py +333 -825
- aioamazondevices/const/__init__.py +1 -0
- aioamazondevices/{const.py → const/devices.py} +47 -106
- aioamazondevices/const/http.py +36 -0
- aioamazondevices/const/metadata.py +44 -0
- aioamazondevices/const/queries.py +97 -0
- aioamazondevices/const/schedules.py +61 -0
- aioamazondevices/{sounds.py → const/sounds.py} +2 -1
- aioamazondevices/http_wrapper.py +349 -0
- aioamazondevices/login.py +445 -0
- aioamazondevices/structures.py +65 -0
- aioamazondevices/utils.py +23 -1
- {aioamazondevices-6.4.5.dist-info → aioamazondevices-9.0.2.dist-info}/METADATA +18 -4
- aioamazondevices-9.0.2.dist-info/RECORD +19 -0
- aioamazondevices/query.py +0 -84
- aioamazondevices-6.4.5.dist-info/RECORD +0 -12
- {aioamazondevices-6.4.5.dist-info → aioamazondevices-9.0.2.dist-info}/WHEEL +0 -0
- {aioamazondevices-6.4.5.dist-info → aioamazondevices-9.0.2.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
"""aioamazondevices HTTP wrapper module."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import base64
|
|
5
|
+
import secrets
|
|
6
|
+
from collections.abc import Callable, Coroutine
|
|
7
|
+
from http import HTTPStatus
|
|
8
|
+
from http.cookies import Morsel
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
import orjson
|
|
12
|
+
from aiohttp import (
|
|
13
|
+
ClientConnectorError,
|
|
14
|
+
ClientResponse,
|
|
15
|
+
ClientSession,
|
|
16
|
+
ContentTypeError,
|
|
17
|
+
)
|
|
18
|
+
from bs4 import BeautifulSoup
|
|
19
|
+
from langcodes import Language, standardize_tag
|
|
20
|
+
from yarl import URL
|
|
21
|
+
|
|
22
|
+
from . import __version__
|
|
23
|
+
from .const.http import (
|
|
24
|
+
AMAZON_APP_BUNDLE_ID,
|
|
25
|
+
AMAZON_APP_ID,
|
|
26
|
+
AMAZON_APP_VERSION,
|
|
27
|
+
AMAZON_DEVICE_SOFTWARE_VERSION,
|
|
28
|
+
ARRAY_WRAPPER,
|
|
29
|
+
CSRF_COOKIE,
|
|
30
|
+
DEFAULT_HEADERS,
|
|
31
|
+
HTTP_ERROR_199,
|
|
32
|
+
HTTP_ERROR_299,
|
|
33
|
+
REQUEST_AGENT,
|
|
34
|
+
URI_SIGNIN,
|
|
35
|
+
)
|
|
36
|
+
from .exceptions import (
|
|
37
|
+
CannotAuthenticate,
|
|
38
|
+
CannotConnect,
|
|
39
|
+
CannotRetrieveData,
|
|
40
|
+
)
|
|
41
|
+
from .utils import _LOGGER, scrub_fields
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AmazonSessionStateData:
|
|
45
|
+
"""Amazon session state data class."""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
domain: str,
|
|
50
|
+
login_email: str,
|
|
51
|
+
login_password: str,
|
|
52
|
+
login_data: dict[str, Any] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Init state class."""
|
|
55
|
+
self._login_email: str = login_email
|
|
56
|
+
self._login_password: str = login_password
|
|
57
|
+
self._login_stored_data: dict[str, Any] = login_data or {}
|
|
58
|
+
self.country_specific_data(domain)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def country_code(self) -> str:
|
|
62
|
+
"""Return country code."""
|
|
63
|
+
return self._country_code
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def domain(self) -> str:
|
|
67
|
+
"""Return domain."""
|
|
68
|
+
return self._domain
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def language(self) -> str:
|
|
72
|
+
"""Return language."""
|
|
73
|
+
return self._language
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def login_email(self) -> str:
|
|
77
|
+
"""Return login email."""
|
|
78
|
+
return self._login_email
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def login_password(self) -> str:
|
|
82
|
+
"""Return login password."""
|
|
83
|
+
return self._login_password
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def login_stored_data(self) -> dict[str, Any]:
|
|
87
|
+
"""Return login stored data."""
|
|
88
|
+
return self._login_stored_data
|
|
89
|
+
|
|
90
|
+
def country_specific_data(self, domain: str) -> None:
|
|
91
|
+
"""Set country specific data."""
|
|
92
|
+
# Force lower case
|
|
93
|
+
domain = domain.replace("https://www.amazon.", "").lower()
|
|
94
|
+
country_code = domain.split(".")[-1] if domain != "com" else "us"
|
|
95
|
+
|
|
96
|
+
lang_object = Language.make(territory=country_code.upper())
|
|
97
|
+
lang_maximized = lang_object.maximize()
|
|
98
|
+
|
|
99
|
+
self._country_code: str = country_code
|
|
100
|
+
self._domain: str = domain
|
|
101
|
+
language = f"{lang_maximized.language}-{lang_maximized.territory}"
|
|
102
|
+
self._language: str = standardize_tag(language)
|
|
103
|
+
|
|
104
|
+
_LOGGER.debug(
|
|
105
|
+
"Initialize country <%s>: domain <amazon.%s>, language <%s>",
|
|
106
|
+
country_code.upper(),
|
|
107
|
+
self._domain,
|
|
108
|
+
self._language,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def load_login_stored_data(self, data: dict[str, Any]) -> dict[str, Any]:
|
|
112
|
+
"""Load to Amazon using previously stored data."""
|
|
113
|
+
self._login_stored_data = data
|
|
114
|
+
return self._login_stored_data
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class AmazonHttpWrapper:
|
|
118
|
+
"""Amazon HTTP wrapper class."""
|
|
119
|
+
|
|
120
|
+
def __init__(
|
|
121
|
+
self,
|
|
122
|
+
client_session: ClientSession,
|
|
123
|
+
session_state_data: AmazonSessionStateData,
|
|
124
|
+
save_to_file: Callable[[str | dict, str, str], Coroutine[Any, Any, None]]
|
|
125
|
+
| None = None,
|
|
126
|
+
) -> None:
|
|
127
|
+
"""Initialize HTTP wrapper."""
|
|
128
|
+
self._session = client_session
|
|
129
|
+
self._session_state_data: AmazonSessionStateData = session_state_data
|
|
130
|
+
self._save_to_file = save_to_file
|
|
131
|
+
|
|
132
|
+
self._csrf_cookie: str | None = None
|
|
133
|
+
self._cookies: dict[str, str] = self._build_init_cookies()
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def cookies(self) -> dict[str, str]:
|
|
137
|
+
"""Return the current cookies."""
|
|
138
|
+
return self._cookies
|
|
139
|
+
|
|
140
|
+
def _load_website_cookies(self, language: str) -> dict[str, str]:
|
|
141
|
+
"""Get website cookies, if available."""
|
|
142
|
+
if not self._session_state_data.login_stored_data:
|
|
143
|
+
return {}
|
|
144
|
+
|
|
145
|
+
website_cookies: dict[str, Any] = self._session_state_data.login_stored_data[
|
|
146
|
+
"website_cookies"
|
|
147
|
+
]
|
|
148
|
+
website_cookies.update(
|
|
149
|
+
{
|
|
150
|
+
"session-token": self._session_state_data.login_stored_data[
|
|
151
|
+
"store_authentication_cookie"
|
|
152
|
+
]["cookie"]
|
|
153
|
+
}
|
|
154
|
+
)
|
|
155
|
+
website_cookies.update({"lc-acbit": language})
|
|
156
|
+
|
|
157
|
+
return website_cookies
|
|
158
|
+
|
|
159
|
+
def _build_init_cookies(self) -> dict[str, str]:
|
|
160
|
+
"""Build initial cookies to prevent captcha in most cases."""
|
|
161
|
+
token_bytes = secrets.token_bytes(313)
|
|
162
|
+
frc = base64.b64encode(token_bytes).decode("ascii").rstrip("=")
|
|
163
|
+
|
|
164
|
+
map_md_dict = {
|
|
165
|
+
"device_user_dictionary": [],
|
|
166
|
+
"device_registration_data": {
|
|
167
|
+
"software_version": AMAZON_DEVICE_SOFTWARE_VERSION,
|
|
168
|
+
},
|
|
169
|
+
"app_identifier": {
|
|
170
|
+
"app_version": AMAZON_APP_VERSION,
|
|
171
|
+
"bundle_id": AMAZON_APP_BUNDLE_ID,
|
|
172
|
+
},
|
|
173
|
+
}
|
|
174
|
+
map_md_str = orjson.dumps(map_md_dict).decode("utf-8")
|
|
175
|
+
map_md = base64.b64encode(map_md_str.encode()).decode().rstrip("=")
|
|
176
|
+
|
|
177
|
+
return {"amzn-app-id": AMAZON_APP_ID, "frc": frc, "map-md": map_md}
|
|
178
|
+
|
|
179
|
+
async def _ignore_ap_signin_error(self, response: ClientResponse) -> bool:
|
|
180
|
+
"""Return true if error is due to signin endpoint."""
|
|
181
|
+
# Endpoint URI_SIGNIN replies with error 404
|
|
182
|
+
# but reports the needed parameters anyway
|
|
183
|
+
if history := response.history:
|
|
184
|
+
return (
|
|
185
|
+
response.status == HTTPStatus.NOT_FOUND
|
|
186
|
+
and URI_SIGNIN in history[0].request_info.url.path
|
|
187
|
+
)
|
|
188
|
+
return False
|
|
189
|
+
|
|
190
|
+
async def set_session_state_data(
|
|
191
|
+
self, session_state_data: AmazonSessionStateData
|
|
192
|
+
) -> None:
|
|
193
|
+
"""Set the current session state data."""
|
|
194
|
+
self._session_state_data = session_state_data
|
|
195
|
+
|
|
196
|
+
async def http_phrase_error(self, error: int) -> str:
|
|
197
|
+
"""Convert numeric error in human phrase."""
|
|
198
|
+
if error == HTTP_ERROR_199:
|
|
199
|
+
return "Miscellaneous Warning"
|
|
200
|
+
|
|
201
|
+
if error == HTTP_ERROR_299:
|
|
202
|
+
return "Miscellaneous Persistent Warning"
|
|
203
|
+
|
|
204
|
+
return HTTPStatus(error).phrase
|
|
205
|
+
|
|
206
|
+
async def session_request(
|
|
207
|
+
self,
|
|
208
|
+
method: str,
|
|
209
|
+
url: str,
|
|
210
|
+
input_data: dict[str, Any] | list[dict[str, Any]] | None = None,
|
|
211
|
+
json_data: bool = False,
|
|
212
|
+
agent: str = "Amazon",
|
|
213
|
+
) -> tuple[BeautifulSoup, ClientResponse]:
|
|
214
|
+
"""Return request response context data."""
|
|
215
|
+
_LOGGER.debug(
|
|
216
|
+
"%s request: %s with payload %s [json=%s]",
|
|
217
|
+
method,
|
|
218
|
+
url,
|
|
219
|
+
scrub_fields(input_data) if input_data else None,
|
|
220
|
+
json_data,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
headers = DEFAULT_HEADERS.copy()
|
|
224
|
+
headers.update({"User-Agent": REQUEST_AGENT[agent]})
|
|
225
|
+
headers.update({"Accept-Language": self._session_state_data.language})
|
|
226
|
+
headers.update({"x-amzn-client": "github.com/chemelli74/aioamazondevices"})
|
|
227
|
+
headers.update({"x-amzn-build-version": __version__})
|
|
228
|
+
|
|
229
|
+
if self._csrf_cookie:
|
|
230
|
+
csrf = {CSRF_COOKIE: self._csrf_cookie}
|
|
231
|
+
_LOGGER.debug("Adding to headers: %s", csrf)
|
|
232
|
+
headers.update(csrf)
|
|
233
|
+
|
|
234
|
+
if json_data:
|
|
235
|
+
json_header = {"Content-Type": "application/json; charset=utf-8"}
|
|
236
|
+
_LOGGER.debug("Adding to headers: %s", json_header)
|
|
237
|
+
headers.update(json_header)
|
|
238
|
+
|
|
239
|
+
_cookies = (
|
|
240
|
+
self._load_website_cookies(self._session_state_data.language)
|
|
241
|
+
if self._session_state_data.login_stored_data
|
|
242
|
+
else self._cookies
|
|
243
|
+
)
|
|
244
|
+
self._session.cookie_jar.update_cookies(
|
|
245
|
+
_cookies, URL(f"amazon.{self._session_state_data.domain}")
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
resp: ClientResponse | None = None
|
|
249
|
+
for delay in [0, 1, 2, 5, 8, 12, 21]:
|
|
250
|
+
if delay:
|
|
251
|
+
_LOGGER.info(
|
|
252
|
+
"Sleeping for %s seconds before retrying API call to %s", delay, url
|
|
253
|
+
)
|
|
254
|
+
await asyncio.sleep(delay)
|
|
255
|
+
|
|
256
|
+
try:
|
|
257
|
+
resp = await self._session.request(
|
|
258
|
+
method,
|
|
259
|
+
URL(url, encoded=True),
|
|
260
|
+
data=input_data if not json_data else orjson.dumps(input_data),
|
|
261
|
+
headers=headers,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
except (TimeoutError, ClientConnectorError) as exc:
|
|
265
|
+
_LOGGER.warning("Connection error to %s: %s", url, repr(exc))
|
|
266
|
+
raise CannotConnect(f"Connection error during {method}") from exc
|
|
267
|
+
|
|
268
|
+
# Retry with a delay only for specific HTTP status
|
|
269
|
+
# that can benefits of a back-off
|
|
270
|
+
if resp.status not in [
|
|
271
|
+
HTTPStatus.INTERNAL_SERVER_ERROR,
|
|
272
|
+
HTTPStatus.SERVICE_UNAVAILABLE,
|
|
273
|
+
HTTPStatus.TOO_MANY_REQUESTS,
|
|
274
|
+
]:
|
|
275
|
+
break
|
|
276
|
+
|
|
277
|
+
if resp is None:
|
|
278
|
+
_LOGGER.error("No response received from %s", url)
|
|
279
|
+
raise CannotConnect(f"No response received from {url}")
|
|
280
|
+
|
|
281
|
+
if csrf := resp.cookies.get(CSRF_COOKIE, Morsel()).value:
|
|
282
|
+
self._csrf_cookie = csrf
|
|
283
|
+
_LOGGER.debug("CSRF cookie value: <%s> [%s]", self._csrf_cookie, url)
|
|
284
|
+
|
|
285
|
+
content_type: str = resp.headers.get("Content-Type", "")
|
|
286
|
+
_LOGGER.debug(
|
|
287
|
+
"Response for url %s :\nstatus : %s \
|
|
288
|
+
\ncontent type: %s ",
|
|
289
|
+
url,
|
|
290
|
+
resp.status,
|
|
291
|
+
content_type,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
if resp.status != HTTPStatus.OK:
|
|
295
|
+
if resp.status in [
|
|
296
|
+
HTTPStatus.FORBIDDEN,
|
|
297
|
+
HTTPStatus.PROXY_AUTHENTICATION_REQUIRED,
|
|
298
|
+
HTTPStatus.UNAUTHORIZED,
|
|
299
|
+
]:
|
|
300
|
+
raise CannotAuthenticate(await self.http_phrase_error(resp.status))
|
|
301
|
+
if not await self._ignore_ap_signin_error(resp):
|
|
302
|
+
raise CannotRetrieveData(
|
|
303
|
+
f"Request failed: {await self.http_phrase_error(resp.status)}"
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
raw_content = await resp.read()
|
|
307
|
+
|
|
308
|
+
if self._save_to_file:
|
|
309
|
+
await self._save_to_file(
|
|
310
|
+
raw_content.decode("utf-8"),
|
|
311
|
+
url,
|
|
312
|
+
content_type,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
return BeautifulSoup(raw_content or "", "html.parser"), resp
|
|
316
|
+
|
|
317
|
+
async def response_to_json(
|
|
318
|
+
self, raw_resp: ClientResponse, description: str | None = None
|
|
319
|
+
) -> dict[str, Any]:
|
|
320
|
+
"""Convert response to JSON, if possible."""
|
|
321
|
+
try:
|
|
322
|
+
data = await raw_resp.json(loads=orjson.loads)
|
|
323
|
+
if not data:
|
|
324
|
+
_LOGGER.warning("Empty JSON data received")
|
|
325
|
+
data = {}
|
|
326
|
+
if isinstance(data, list):
|
|
327
|
+
# if anonymous array is returned wrap it inside
|
|
328
|
+
# generated key to convert list to dict
|
|
329
|
+
data = {ARRAY_WRAPPER: data}
|
|
330
|
+
if description:
|
|
331
|
+
_LOGGER.debug("JSON '%s' data: %s", description, scrub_fields(data))
|
|
332
|
+
return cast("dict[str, Any]", data)
|
|
333
|
+
except ContentTypeError as exc:
|
|
334
|
+
raise ValueError("Response not in JSON format") from exc
|
|
335
|
+
except orjson.JSONDecodeError as exc:
|
|
336
|
+
raise ValueError("Response with corrupted JSON format") from exc
|
|
337
|
+
|
|
338
|
+
async def clear_cookies(self) -> None:
|
|
339
|
+
"""Clear session cookies."""
|
|
340
|
+
self._session.cookie_jar.clear()
|
|
341
|
+
await self.clear_csrf_cookie()
|
|
342
|
+
|
|
343
|
+
async def clear_csrf_cookie(self) -> None:
|
|
344
|
+
"""Clear CSRF cookie."""
|
|
345
|
+
self._csrf_cookie = None
|
|
346
|
+
|
|
347
|
+
async def set_cookies(self, cookies: dict[str, str], domain_url: URL) -> None:
|
|
348
|
+
"""Set session cookies."""
|
|
349
|
+
self._session.cookie_jar.update_cookies(cookies, domain_url)
|