jsonseo 1.0.0__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.
jsonseo/__init__.py ADDED
@@ -0,0 +1,45 @@
1
+ """
2
+ Официальный Python SDK для JSON SEO API.
3
+
4
+ >>> from jsonseo import Client
5
+ >>> client = Client("ВАШ_КЛЮЧ")
6
+ >>> serp = client.yandex("купить ноутбук", region=213)
7
+ """
8
+
9
+ from .client import VERSION, Client
10
+ from .errors import (
11
+ ApiError,
12
+ IncompleteResponseError,
13
+ InvalidArgumentError,
14
+ JsonSeoError,
15
+ NetworkError,
16
+ ParseError,
17
+ PaymentRequiredError,
18
+ RateLimitError,
19
+ ServiceUnavailableError,
20
+ TimeoutError,
21
+ UnauthorizedError,
22
+ ValidationError,
23
+ )
24
+ from .transport import Response, UrllibTransport
25
+
26
+ __version__ = VERSION
27
+
28
+ __all__ = [
29
+ "Client",
30
+ "Response",
31
+ "UrllibTransport",
32
+ "JsonSeoError",
33
+ "ApiError",
34
+ "PaymentRequiredError",
35
+ "UnauthorizedError",
36
+ "ValidationError",
37
+ "RateLimitError",
38
+ "ServiceUnavailableError",
39
+ "NetworkError",
40
+ "TimeoutError",
41
+ "IncompleteResponseError",
42
+ "ParseError",
43
+ "InvalidArgumentError",
44
+ "__version__",
45
+ ]
jsonseo/client.py ADDED
@@ -0,0 +1,394 @@
1
+ """Клиент JSON SEO API."""
2
+
3
+ import email.utils
4
+ import json
5
+ import random
6
+ import time
7
+ import urllib.parse
8
+ from typing import Any, Dict, Optional, Sequence, Union
9
+
10
+ from .errors import (
11
+ IncompleteResponseError,
12
+ InvalidArgumentError,
13
+ NetworkError,
14
+ ParseError,
15
+ TimeoutError,
16
+ api_error_for,
17
+ )
18
+ from .transport import Response, UrllibTransport
19
+ from .types import (
20
+ BalanceResponse,
21
+ DirectResponse,
22
+ GeoipResponse,
23
+ GoogleRegionsResponse,
24
+ ImagesResponse,
25
+ SearchResponse,
26
+ SuggestResponse,
27
+ VideoResponse,
28
+ WordstatFrequencyResponse,
29
+ WordstatGraphResponse,
30
+ WordstatMapResponse,
31
+ WordstatResponse,
32
+ YandexRegionsResponse,
33
+ )
34
+
35
+ __all__ = ["Client"]
36
+
37
+ VERSION = "1.0.0"
38
+ DEFAULT_BASE_URL = "https://jsonseo.ru/api"
39
+
40
+ Primary = Union[str, int, Sequence[str], None]
41
+
42
+
43
+ class Client:
44
+ """
45
+ Клиент JSON SEO API.
46
+
47
+ >>> client = Client("ВАШ_КЛЮЧ")
48
+ >>> serp = client.yandex("купить ноутбук", region=213)
49
+ """
50
+
51
+ def __init__(
52
+ self,
53
+ api_key: str,
54
+ *,
55
+ base_url: str = DEFAULT_BASE_URL,
56
+ timeout: float = 300.0,
57
+ attempts: int = 3,
58
+ retry_delay: float = 1.0,
59
+ max_retry_delay: float = 30.0,
60
+ auth: str = "header",
61
+ user_agent: Optional[str] = None,
62
+ transport: Any = None,
63
+ ) -> None:
64
+ if not isinstance(api_key, str) or api_key.strip() == "":
65
+ raise InvalidArgumentError(
66
+ "Нужен API-ключ: возьмите его в личном кабинете на https://jsonseo.ru."
67
+ )
68
+
69
+ if auth not in ("header", "query"):
70
+ raise InvalidArgumentError(
71
+ 'Настройка auth принимает "header" или "query", получено: {!r}.'.format(auth)
72
+ )
73
+
74
+ if not isinstance(attempts, int) or isinstance(attempts, bool) or attempts < 1:
75
+ raise InvalidArgumentError(
76
+ "Настройка attempts ожидает целое число не меньше 1, получено: {!r}.".format(attempts)
77
+ )
78
+
79
+ self._api_key = api_key.strip()
80
+ self._base_url = base_url.rstrip("/")
81
+ # Многостраничная выдача идёт минутами, и оборванный запрос всё
82
+ # равно будет досчитан и оплачен.
83
+ self._timeout = float(timeout)
84
+ self._attempts = attempts
85
+ self._retry_delay = float(retry_delay)
86
+ self._max_retry_delay = float(max_retry_delay)
87
+ self._auth = auth
88
+ self._user_agent = user_agent or "jsonseo-python/{}".format(VERSION)
89
+ self._transport = transport or UrllibTransport()
90
+
91
+ # --- Яндекс ---
92
+
93
+ def yandex(self, text: Primary = None, **params: Any) -> SearchResponse:
94
+ """Органическая выдача Яндекса: мобильная, регион 213. 0.01 ₽ за страницу."""
95
+ return self._request("yandex", self._primary(text, "text", params))
96
+
97
+ def yandex_suggest(self, text: Primary = None, **params: Any) -> SuggestResponse:
98
+ """Подсказки Яндекса: до 50 фраз с учётом региона. 0.01 ₽ за запрос."""
99
+ return self._request("yandex/suggest", self._primary(text, "text", params))
100
+
101
+ def yandex_regions(self, name: Primary = None, **params: Any) -> YandexRegionsResponse:
102
+ """Код региона (lr) по названию города или области. Бесплатно, нужен ключ."""
103
+ return self._request("yandex/regions", self._primary(name, "name", params))
104
+
105
+ def yandex_images(self, q: Primary = None, **params: Any) -> ImagesResponse:
106
+ """Картинки Яндекса: 20 карточек на страницу, 0.01 ₽ за страницу."""
107
+ return self._request("yandex/images", self._primary(q, "q", params))
108
+
109
+ def yandex_video(self, q: Primary = None, **params: Any) -> VideoResponse:
110
+ """Видео Яндекса: 20 карточек на страницу, 0.01 ₽ за страницу."""
111
+ return self._request("yandex/video", self._primary(q, "q", params))
112
+
113
+ # --- Google ---
114
+
115
+ def google(self, q: Primary = None, **params: Any) -> SearchResponse:
116
+ """Органическая выдача google.com: мобильная, 0.01 ₽ за страницу."""
117
+ return self._request("google", self._primary(q, "q", params))
118
+
119
+ def google_suggest(self, q: Primary = None, **params: Any) -> SuggestResponse:
120
+ """Подсказки Google: до ~15 фраз. 0.01 ₽ за запрос."""
121
+ return self._request("google/suggest", self._primary(q, "q", params))
122
+
123
+ def google_regions(self, name: Primary = None, **params: Any) -> GoogleRegionsResponse:
124
+ """ID региона Google по названию и готовый uule. Бесплатно, нужен ключ."""
125
+ return self._request("google/regions", self._primary(name, "name", params))
126
+
127
+ def google_images(self, q: Primary = None, **params: Any) -> ImagesResponse:
128
+ """Картинки Google: 100 карточек на страницу, 0.01 ₽ за страницу."""
129
+ return self._request("google/images", self._primary(q, "q", params))
130
+
131
+ def google_video(self, q: Primary = None, **params: Any) -> VideoResponse:
132
+ """Видео Google: 10 карточек на страницу, 0.01 ₽ за страницу."""
133
+ return self._request("google/video", self._primary(q, "q", params))
134
+
135
+ # --- Bing ---
136
+
137
+ def bing(self, q: Primary = None, **params: Any) -> SearchResponse:
138
+ """Органическая выдача bing.com: без локации — Россия, 0.01 ₽ за страницу."""
139
+ return self._request("bing", self._primary(q, "q", params))
140
+
141
+ def bing_suggest(self, q: Primary = None, **params: Any) -> SuggestResponse:
142
+ """Подсказки Bing. 0.01 ₽ за запрос."""
143
+ return self._request("bing/suggest", self._primary(q, "q", params))
144
+
145
+ def bing_images(self, q: Primary = None, **params: Any) -> ImagesResponse:
146
+ """Картинки Bing: count карточек (по умолчанию 35), дальше 700-й не листает."""
147
+ return self._request("bing/images", self._primary(q, "q", params))
148
+
149
+ def bing_video(self, q: Primary = None, **params: Any) -> VideoResponse:
150
+ """Видео Bing: count карточек на страницу, по умолчанию 105."""
151
+ return self._request("bing/video", self._primary(q, "q", params))
152
+
153
+ # --- Вордстат ---
154
+
155
+ def wordstat(self, text: Primary = None, **params: Any) -> WordstatResponse:
156
+ """Популярные и похожие запросы. 0.01 ₽ за запрос."""
157
+ return self._request("wordstat", self._primary(text, "text", params))
158
+
159
+ def wordstat_frequency(self, text: Primary = None, **params: Any) -> WordstatFrequencyResponse:
160
+ """Частота запроса одним числом — results.totalValue. 0.01 ₽ за запрос."""
161
+ return self._request("wordstat/frequency", self._primary(text, "text", params))
162
+
163
+ def wordstat_graph(self, text: Primary = None, **params: Any) -> WordstatGraphResponse:
164
+ """Динамика показов: month и week — с 2018 года, day — последние 60 дней."""
165
+ return self._request("wordstat/graph", self._primary(text, "text", params))
166
+
167
+ def wordstat_map(self, text: Primary = None, **params: Any) -> WordstatMapResponse:
168
+ """Показы по регионам и городам. popularity — affinity-индекс, 100 — средний."""
169
+ return self._request("wordstat/map", self._primary(text, "text", params))
170
+
171
+ # --- Директ и служебные ---
172
+
173
+ def direct(self, phrases: Primary = None, **params: Any) -> DirectResponse:
174
+ """
175
+ Прогноз показов Директа со ставками и бюджетом. Кабинет не нужен.
176
+
177
+ Список фраз передаётся как есть — SDK склеит его сам. Вид частотности
178
+ задаётся операторами во фразе: "ремонт айфона" — фразовая,
179
+ "!ремонт !айфона" — точная.
180
+ """
181
+ return self._request("direct", self._primary(phrases, "phrases", params))
182
+
183
+ def geoip(self, ip: Primary = None, **params: Any) -> GeoipResponse:
184
+ """Страна, регион и координаты по IPv4. Бесплатно, нужен ключ."""
185
+ return self._request("geoip", self._primary(ip, "ip", params))
186
+
187
+ def balance(self) -> BalanceResponse:
188
+ """Текущий баланс. Бесплатно, нужен ключ."""
189
+ return self._request("balance", {})
190
+
191
+ # --- Запасной выход ---
192
+
193
+ def call(self, path: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
194
+ """Произвольный метод API — если в сервисе появился новый."""
195
+ return self._request(path, dict(params or {}))
196
+
197
+ def call_raw(self, path: str, params: Optional[Dict[str, Any]] = None) -> str:
198
+ """То же, но ответ возвращается строкой без разбора."""
199
+ return self._request(path, dict(params or {}), decode_json=False)
200
+
201
+ # --- Внутреннее ---
202
+
203
+ def _primary(self, value: Primary, name: str, params: Dict[str, Any]) -> Dict[str, Any]:
204
+ """
205
+ Даёт вызывать метод позиционно: yandex("купить ноутбук").
206
+
207
+ Дубль (и позиционно, и по имени) ловит сам Python: у аргумента то же
208
+ имя, что у параметра, и вызов не состоится.
209
+ """
210
+ if value is not None:
211
+ params[name] = value
212
+
213
+ return params
214
+
215
+ def _request(self, path: str, params: Dict[str, Any], decode_json: bool = True) -> Any:
216
+ """
217
+ Выполняет запрос, повторяя те отказы, за которые сервис не берёт
218
+ денег: 429, 5xx и обрывы связи до того, как ответ начал приходить.
219
+ """
220
+ query = self._normalize(params)
221
+
222
+ if self._auth == "query":
223
+ query["key"] = self._api_key
224
+
225
+ headers = {
226
+ "Accept": "application/json" if decode_json else "application/xml, text/xml",
227
+ "Content-Type": "application/x-www-form-urlencoded",
228
+ "User-Agent": self._user_agent,
229
+ }
230
+
231
+ if self._auth == "header":
232
+ headers["Authorization"] = "Bearer " + self._api_key
233
+
234
+ # Всегда POST: длинные списки фраз в GET не помещаются.
235
+ url = "{}/{}".format(self._base_url, path.lstrip("/"))
236
+ body = urllib.parse.urlencode(query)
237
+
238
+ attempt = 0
239
+
240
+ while True:
241
+ try:
242
+ response = self._transport.send("POST", url, headers, body, self._timeout)
243
+ except NetworkError as error:
244
+ # Таймаут и обрыв на середине тела не повторяем: выдача уже
245
+ # собрана и оплачена.
246
+ if (
247
+ self._is_last_attempt(attempt)
248
+ or isinstance(error, (TimeoutError, IncompleteResponseError))
249
+ ):
250
+ raise
251
+
252
+ time.sleep(self._backoff(attempt))
253
+ attempt += 1
254
+
255
+ continue
256
+
257
+ if 200 <= response.status < 300:
258
+ return self._decode(response.body) if decode_json else response.body
259
+
260
+ retry_after = self._retry_after(response)
261
+ error = api_error_for(
262
+ response.status, response.body, self._decode_quietly(response.body), retry_after
263
+ )
264
+
265
+ # Проснуться раньше названного срока — снова получить тот же
266
+ # отказ. Ждать дольше потолка не станем: отдаём ошибку.
267
+ if (
268
+ self._is_last_attempt(attempt)
269
+ or not self._is_retryable(response.status)
270
+ or (retry_after is not None and retry_after > self._max_retry_delay)
271
+ ):
272
+ raise error
273
+
274
+ # Не раньше, чем просит сервис, и не чаще своего бэкоффа:
275
+ # Retry-After прошедшей датой даёт ноль.
276
+ pause = self._backoff(attempt)
277
+ time.sleep(pause if retry_after is None else max(float(retry_after), pause))
278
+ attempt += 1
279
+
280
+ def _is_last_attempt(self, attempt: int) -> bool:
281
+ """Попытки нумеруются с нуля: при attempts = 3 у последней индекс 2."""
282
+ return attempt + 1 >= self._attempts
283
+
284
+ def _is_retryable(self, status: int) -> bool:
285
+ return status == 429 or status >= 500
286
+
287
+ def _normalize(self, params: Dict[str, Any]) -> Dict[str, str]:
288
+ """Приводит параметры к тому виду, в каком их ждёт форма запроса."""
289
+ normalized = {}
290
+
291
+ for name, value in params.items():
292
+ if value is None:
293
+ continue
294
+
295
+ if isinstance(value, bool):
296
+ normalized[name] = "1" if value else "0"
297
+
298
+ continue
299
+
300
+ if isinstance(value, (list, tuple)):
301
+ items = list(value)
302
+
303
+ # Пустой список — «параметр не задан»: от region= сервис
304
+ # отказал бы валидацией.
305
+ if not items:
306
+ continue
307
+
308
+ # Фразы — переводом строки: запятая в них встречается.
309
+ separator = "\n" if name == "phrases" else ","
310
+ normalized[name] = separator.join(
311
+ self._scalar(item, "{}[{}]".format(name, index)) for index, item in enumerate(items)
312
+ )
313
+
314
+ continue
315
+
316
+ normalized[name] = self._scalar(value, name)
317
+
318
+ return normalized
319
+
320
+ def _scalar(self, value: Any, name: str) -> str:
321
+ if isinstance(value, bool):
322
+ return "1" if value else "0"
323
+
324
+ if isinstance(value, (int, float)):
325
+ return str(value)
326
+
327
+ if isinstance(value, str):
328
+ return value
329
+
330
+ if isinstance(value, (set, frozenset)):
331
+ raise InvalidArgumentError(
332
+ "Параметр {} получил множество: порядок его обхода не определён, "
333
+ "и запрос перестал бы быть воспроизводимым. Передайте список.".format(name)
334
+ )
335
+
336
+ raise InvalidArgumentError(
337
+ "Значение параметра {} должно быть строкой, числом, флагом или списком "
338
+ "таких значений, получено: {!r}.".format(name, value)
339
+ )
340
+
341
+ def _decode(self, body: str) -> Any:
342
+ try:
343
+ return json.loads(body)
344
+ except ValueError as error:
345
+ # Тело кладём в ошибку: страница выдачи уже оплачена.
346
+ raise ParseError(
347
+ "Ответ JSON SEO API не разобрался как JSON: {}.".format(error), body
348
+ ) from error
349
+
350
+ def _decode_quietly(self, body: str) -> Dict[str, Any]:
351
+ """Тело ошибки может быть и не JSON — тогда подробностей просто нет."""
352
+ try:
353
+ parsed = json.loads(body)
354
+ except ValueError:
355
+ return {}
356
+
357
+ return parsed if isinstance(parsed, dict) else {}
358
+
359
+ def _retry_after(self, response: Response) -> Optional[int]:
360
+ """
361
+ Сколько секунд просит подождать сервис. RFC 9110 разрешает число
362
+ секунд и HTTP-дату, разбираются обе.
363
+ """
364
+ value = response.header("retry-after")
365
+
366
+ if value is None:
367
+ return None
368
+
369
+ value = value.strip()
370
+
371
+ # isdigit() истинно и для юникод-цифр вроде «²», которые int() не
372
+ # берёт: без проверки на ASCII запрос падал бы голым ValueError.
373
+ if value.isascii() and value.isdigit():
374
+ return int(value)
375
+
376
+ try:
377
+ when = email.utils.parsedate_to_datetime(value)
378
+ except (TypeError, ValueError):
379
+ return None
380
+
381
+ if when is None:
382
+ return None
383
+
384
+ return max(0, int(when.timestamp() - time.time()))
385
+
386
+ def _backoff(self, attempt: int) -> float:
387
+ """
388
+ Пауза удваивается с каждой попыткой; случайная добавка разводит
389
+ параллельные запросы, чтобы они не вернулись разом.
390
+ """
391
+ delay = self._retry_delay * (2 ** attempt)
392
+
393
+ # Потолок накладывается после добавки, иначе она бы его превышала.
394
+ return min(delay + delay * 0.25 * random.random(), self._max_retry_delay)
jsonseo/errors.py ADDED
@@ -0,0 +1,127 @@
1
+ """Ошибки SDK."""
2
+
3
+ from typing import Any, Dict, List, Optional
4
+
5
+ _BuiltinTimeoutError = TimeoutError
6
+
7
+
8
+ class JsonSeoError(Exception):
9
+ """Общий предок всех ошибок SDK."""
10
+
11
+
12
+ class ApiError(JsonSeoError):
13
+ """Сервис ответил отказом. Тело сохраняется целиком."""
14
+
15
+ def __init__(
16
+ self,
17
+ message: str,
18
+ status: int,
19
+ body: str = "",
20
+ payload: Optional[Dict[str, Any]] = None,
21
+ retry_after: Optional[int] = None,
22
+ ) -> None:
23
+ super().__init__(message)
24
+ self.message = message
25
+ self.status = status
26
+ self.body = body
27
+ self.payload = payload or {}
28
+ # Через сколько секунд вернуться. None, если срок не назван.
29
+ self.retry_after = retry_after
30
+
31
+
32
+ class PaymentRequiredError(ApiError):
33
+ """402: на счёте не хватает средств, см. balance()."""
34
+
35
+
36
+ class UnauthorizedError(ApiError):
37
+ """403 или 401: ключ не передан или недействителен."""
38
+
39
+
40
+ class ServiceUnavailableError(ApiError):
41
+ """503: выдачу получить не вышло. Деньги не списаны, SDK повторит сам."""
42
+
43
+
44
+ class RateLimitError(ApiError):
45
+ """429: превышен лимит частоты. Срок повтора — в retry_after."""
46
+
47
+
48
+ class ValidationError(ApiError):
49
+ """422: параметры не приняты. Деньги не списываются."""
50
+
51
+ @property
52
+ def errors(self) -> Dict[str, List[str]]:
53
+ """Ошибки по именам параметров: {'text': ['Введите запрос']}."""
54
+ raw = self.payload.get("errors")
55
+
56
+ if not isinstance(raw, dict):
57
+ return {}
58
+
59
+ return {
60
+ field: [str(m) for m in messages] if isinstance(messages, list) else [str(messages)]
61
+ for field, messages in raw.items()
62
+ }
63
+
64
+ @property
65
+ def fields(self) -> List[str]:
66
+ """Забракованные параметры."""
67
+ return list(self.errors)
68
+
69
+
70
+ class NetworkError(JsonSeoError):
71
+ """До сервиса не достучались: сеть, DNS, TLS. Статуса нет."""
72
+
73
+
74
+ class TimeoutError(NetworkError, _BuiltinTimeoutError): # noqa: A001
75
+ """
76
+ Ответа не дождались. Автоматически не повторяется: выдача всё равно
77
+ будет собрана и оплачена. Нужен ответ — поднимайте timeout, не attempts.
78
+
79
+ Наследует и встроенный TimeoutError, чтобы привычный except его ловил.
80
+ """
81
+
82
+
83
+ class IncompleteResponseError(NetworkError):
84
+ """
85
+ Пришло меньше, чем обещал сервис. Автоматически не повторяется: выдача
86
+ уже собрана и оплачена, обрыв случился на отдаче.
87
+ """
88
+
89
+
90
+ class ParseError(JsonSeoError):
91
+ """
92
+ Успех, но тело не разобралось как JSON. Тело сохраняется: страница уже
93
+ оплачена, и достать из неё данные руками лучше, чем не иметь ничего.
94
+ """
95
+
96
+ def __init__(self, message: str, body: str) -> None:
97
+ super().__init__(message)
98
+ self.body = body
99
+
100
+
101
+ class InvalidArgumentError(JsonSeoError):
102
+ """SDK забраковал аргументы, запрос не отправлялся."""
103
+
104
+
105
+ _BY_STATUS = {
106
+ 401: UnauthorizedError,
107
+ 402: PaymentRequiredError,
108
+ 403: UnauthorizedError,
109
+ 422: ValidationError,
110
+ 429: RateLimitError,
111
+ 503: ServiceUnavailableError,
112
+ }
113
+
114
+
115
+ def api_error_for(
116
+ status: int,
117
+ body: str,
118
+ payload: Dict[str, Any],
119
+ retry_after: Optional[int],
120
+ ) -> ApiError:
121
+ """Собирает ошибку под этот HTTP-статус."""
122
+ message = payload.get("message")
123
+
124
+ if not isinstance(message, str) or message == "":
125
+ message = "JSON SEO API вернул ошибку {}.".format(status)
126
+
127
+ return _BY_STATUS.get(status, ApiError)(message, status, body, payload, retry_after)
jsonseo/py.typed ADDED
File without changes
jsonseo/transport.py ADDED
@@ -0,0 +1,107 @@
1
+ """Как SDK ходит в сеть. Подменяется в тестах и в проектах со своим клиентом."""
2
+
3
+ import http.client
4
+ import socket
5
+ import urllib.error
6
+ import urllib.request
7
+ from typing import Dict, Optional
8
+
9
+ from .errors import IncompleteResponseError, JsonSeoError, NetworkError, TimeoutError
10
+
11
+
12
+ class Response:
13
+ """Сырой ответ транспорта: статус, заголовки и тело как есть."""
14
+
15
+ __slots__ = ("status", "headers", "body")
16
+
17
+ def __init__(self, status: int, headers: Dict[str, str], body: str) -> None:
18
+ self.status = int(status)
19
+ # Имена в нижнем регистре — HTTP их регистр не различает.
20
+ self.headers = {name.lower(): value for name, value in headers.items()}
21
+ self.body = body
22
+
23
+ def header(self, name: str) -> Optional[str]:
24
+ return self.headers.get(name.lower())
25
+
26
+
27
+ class UrllibTransport:
28
+ """
29
+ Транспорт на стандартной библиотеке: пакет остаётся без зависимостей.
30
+
31
+ Свой транспорт — это объект с таким же методом send. Он обязан бросать
32
+ ошибки SDK: от их класса зависит, повторит клиент запрос или нет.
33
+ """
34
+
35
+ def send(
36
+ self,
37
+ method: str,
38
+ url: str,
39
+ headers: Dict[str, str],
40
+ body: Optional[str],
41
+ timeout: float,
42
+ ) -> Response:
43
+ data = body.encode("utf-8") if body is not None else None
44
+ request = urllib.request.Request(url, data=data, method=method)
45
+
46
+ for name, value in headers.items():
47
+ # urllib сам подставляет свой User-Agent, если его не задать.
48
+ request.add_header(name, value)
49
+
50
+ response = self._open(request, timeout)
51
+
52
+ # Чтение тела намеренно вынесено из-под обработки ошибок соединения:
53
+ # сюда мы попадаем, только когда заголовки уже пришли, а значит
54
+ # выдача собрана и оплачена. Любой сбой отсюда повторять нельзя.
55
+ with response:
56
+ return self._read(response)
57
+
58
+ def _open(self, request: urllib.request.Request, timeout: float):
59
+ try:
60
+ return urllib.request.urlopen(request, timeout=timeout)
61
+ except urllib.error.HTTPError as error:
62
+ # Это не сбой, а ответ с кодом 4xx или 5xx: сообщение сервиса
63
+ # о причине отказа лежит в теле, и терять его нельзя.
64
+ return error
65
+ except socket.timeout as error:
66
+ raise TimeoutError("Ответа от JSON SEO API не дождались.") from error
67
+ except urllib.error.URLError as error:
68
+ if isinstance(error.reason, socket.timeout):
69
+ raise TimeoutError("Ответа от JSON SEO API не дождались.") from error
70
+
71
+ raise NetworkError("Запрос к JSON SEO API не удался: {}.".format(error.reason)) from error
72
+ except OSError as error:
73
+ raise NetworkError("Запрос к JSON SEO API не удался: {}.".format(error)) from error
74
+
75
+ def _read(self, response) -> Response:
76
+ try:
77
+ raw = response.read()
78
+ except http.client.IncompleteRead as error:
79
+ raise IncompleteResponseError(self._incomplete_message(error)) from error
80
+ except socket.timeout as error:
81
+ raise TimeoutError("Ответа от JSON SEO API не дождались: тело пришло не целиком.") from error
82
+ except JsonSeoError:
83
+ # Наши собственные ошибки наследуют OSError и не должны попадать
84
+ # под общий перехват ниже.
85
+ raise
86
+ except OSError as error:
87
+ # Сброс соединения на середине тела: чистого EOF не было, но
88
+ # выдача всё равно уже собрана и оплачена.
89
+ raise IncompleteResponseError(
90
+ "Ответ от JSON SEO API пришёл не целиком: {}.".format(error)
91
+ ) from error
92
+
93
+ headers = {name: value for name, value in response.headers.items()}
94
+
95
+ return Response(response.status, headers, raw.decode("utf-8", errors="replace"))
96
+
97
+ def _incomplete_message(self, error: http.client.IncompleteRead) -> str:
98
+ # У обрыва chunked-ответа expected равен None: сколько обещали,
99
+ # неизвестно, и придумывать число нельзя.
100
+ if error.expected is None:
101
+ return "Ответ от JSON SEO API пришёл не целиком: получено {} байт, передача оборвалась.".format(
102
+ len(error.partial)
103
+ )
104
+
105
+ return "Ответ от JSON SEO API пришёл не целиком: получено {} из {} байт.".format(
106
+ len(error.partial), len(error.partial) + error.expected
107
+ )