parreq 1.0.0__tar.gz
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.
- parreq-1.0.0/.gitignore +7 -0
- parreq-1.0.0/LICENSE +21 -0
- parreq-1.0.0/PKG-INFO +87 -0
- parreq-1.0.0/README.md +75 -0
- parreq-1.0.0/parreq/__init__.py +281 -0
- parreq-1.0.0/pyproject.toml +22 -0
parreq-1.0.0/.gitignore
ADDED
parreq-1.0.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ParReq
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
parreq-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: parreq
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Клиент ParReq: поисковая выдача Google и Яндекса в JSON
|
|
5
|
+
Project-URL: Documentation, https://req.akuraq.dev/docs
|
|
6
|
+
Project-URL: Homepage, https://req.akuraq.dev
|
|
7
|
+
License: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Keywords: google,scraping,search-api,serp,yandex
|
|
10
|
+
Requires-Python: >=3.9
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# parreq
|
|
14
|
+
|
|
15
|
+
Клиент [ParReq](https://req.akuraq.dev) — поисковая выдача Google и Яндекса в
|
|
16
|
+
JSON. Без зависимостей, только стандартная библиотека.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install parreq
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```python
|
|
23
|
+
from parreq import ParReq
|
|
24
|
+
|
|
25
|
+
client = ParReq("pr_ВАШКЛЮЧ")
|
|
26
|
+
|
|
27
|
+
res = client.yandex("кофемашина", gl="by", hl="ru", include=["ads", "shopping"])
|
|
28
|
+
print(len(res.organic), "органических,", len(res.ads), "объявлений")
|
|
29
|
+
|
|
30
|
+
for item in res.organic:
|
|
31
|
+
print(item["position"], item["title"], item["link"])
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`engine` обязателен, поэтому у общего метода он позиционно-именованный, а для
|
|
35
|
+
двух движков есть сахар:
|
|
36
|
+
|
|
37
|
+
```python
|
|
38
|
+
client.search("coffee machine", engine="google", gl="us", hl="en")
|
|
39
|
+
client.google("coffee machine", gl="us")
|
|
40
|
+
client.yandex("кофемашина", gl="by", hl="ru")
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Секции
|
|
44
|
+
|
|
45
|
+
По умолчанию приходит только органика. Остальное — через `include`:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
res = client.google("купить кофемашину", gl="by", hl="ru",
|
|
49
|
+
include=["ads", "shopping", "videos", "related_searches"])
|
|
50
|
+
res.ads, res.shopping, res.videos, res.related
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Доступно: `ads`, `shopping`, `local`, `knowledge_graph`, `ai_overview`,
|
|
54
|
+
`answer_box`, `people_also_ask`, `related_searches`, `images`, `videos`, `news`,
|
|
55
|
+
`total_results`, либо `include="all"`.
|
|
56
|
+
|
|
57
|
+
## Ошибки
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from parreq import ParReq, RateLimited, NoWorkers, SearchBlocked, ParReqError
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
res = client.google("coffee", gl="us")
|
|
64
|
+
except NoWorkers as exc: # 503: нет прогретого профиля под движок
|
|
65
|
+
print("повторить через", exc.retry_after, "с")
|
|
66
|
+
except RateLimited as exc: # 429: частота, квота или одновременность
|
|
67
|
+
print(exc.code, exc.retry_after)
|
|
68
|
+
except SearchBlocked: # 502: защиту обойти не удалось
|
|
69
|
+
...
|
|
70
|
+
except ParReqError as exc: # всё остальное
|
|
71
|
+
print(exc.status, exc.code, exc.request_id)
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Клиент **сам повторяет** то, что лечится повтором (429, 502, 503, 504), выжидая
|
|
75
|
+
столько, сколько просит сервер в `Retry-After`. По умолчанию три попытки:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
client = ParReq("pr_…", retries=0) # выключить повторы
|
|
79
|
+
client = ParReq("pr_…", timeout=240) # запрос с решением капчи бывает долгим
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Свой расход
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
u = client.usage()
|
|
86
|
+
print(u["used_today"], "из", u["limits"]["daily"])
|
|
87
|
+
```
|
parreq-1.0.0/README.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# parreq
|
|
2
|
+
|
|
3
|
+
Клиент [ParReq](https://req.akuraq.dev) — поисковая выдача Google и Яндекса в
|
|
4
|
+
JSON. Без зависимостей, только стандартная библиотека.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
pip install parreq
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from parreq import ParReq
|
|
12
|
+
|
|
13
|
+
client = ParReq("pr_ВАШКЛЮЧ")
|
|
14
|
+
|
|
15
|
+
res = client.yandex("кофемашина", gl="by", hl="ru", include=["ads", "shopping"])
|
|
16
|
+
print(len(res.organic), "органических,", len(res.ads), "объявлений")
|
|
17
|
+
|
|
18
|
+
for item in res.organic:
|
|
19
|
+
print(item["position"], item["title"], item["link"])
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
`engine` обязателен, поэтому у общего метода он позиционно-именованный, а для
|
|
23
|
+
двух движков есть сахар:
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
client.search("coffee machine", engine="google", gl="us", hl="en")
|
|
27
|
+
client.google("coffee machine", gl="us")
|
|
28
|
+
client.yandex("кофемашина", gl="by", hl="ru")
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Секции
|
|
32
|
+
|
|
33
|
+
По умолчанию приходит только органика. Остальное — через `include`:
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
res = client.google("купить кофемашину", gl="by", hl="ru",
|
|
37
|
+
include=["ads", "shopping", "videos", "related_searches"])
|
|
38
|
+
res.ads, res.shopping, res.videos, res.related
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Доступно: `ads`, `shopping`, `local`, `knowledge_graph`, `ai_overview`,
|
|
42
|
+
`answer_box`, `people_also_ask`, `related_searches`, `images`, `videos`, `news`,
|
|
43
|
+
`total_results`, либо `include="all"`.
|
|
44
|
+
|
|
45
|
+
## Ошибки
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from parreq import ParReq, RateLimited, NoWorkers, SearchBlocked, ParReqError
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
res = client.google("coffee", gl="us")
|
|
52
|
+
except NoWorkers as exc: # 503: нет прогретого профиля под движок
|
|
53
|
+
print("повторить через", exc.retry_after, "с")
|
|
54
|
+
except RateLimited as exc: # 429: частота, квота или одновременность
|
|
55
|
+
print(exc.code, exc.retry_after)
|
|
56
|
+
except SearchBlocked: # 502: защиту обойти не удалось
|
|
57
|
+
...
|
|
58
|
+
except ParReqError as exc: # всё остальное
|
|
59
|
+
print(exc.status, exc.code, exc.request_id)
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Клиент **сам повторяет** то, что лечится повтором (429, 502, 503, 504), выжидая
|
|
63
|
+
столько, сколько просит сервер в `Retry-After`. По умолчанию три попытки:
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
client = ParReq("pr_…", retries=0) # выключить повторы
|
|
67
|
+
client = ParReq("pr_…", timeout=240) # запрос с решением капчи бывает долгим
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## Свой расход
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
u = client.usage()
|
|
74
|
+
print(u["used_today"], "из", u["limits"]["daily"])
|
|
75
|
+
```
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"""ParReq — клиент поисковой выдачи Google и Яндекса.
|
|
2
|
+
|
|
3
|
+
Зависимостей нет намеренно: библиотека тонкая, а `requests` в проекте, который
|
|
4
|
+
берёт её ради одного запроса, тянет за собой лишнее и конфликтует с чужими
|
|
5
|
+
версиями. Всё нужное есть в стандартной библиотеке.
|
|
6
|
+
|
|
7
|
+
from parreq import ParReq
|
|
8
|
+
|
|
9
|
+
client = ParReq("pr_…")
|
|
10
|
+
res = client.search("кофемашина", engine="yandex", gl="by", hl="ru",
|
|
11
|
+
include=["ads", "shopping"])
|
|
12
|
+
for item in res.organic:
|
|
13
|
+
print(item["position"], item["title"])
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Any, Dict, Iterable, List, Optional
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ParReq", "SearchResult", "ParReqError", "BadRequest", "AuthError",
|
|
27
|
+
"RateLimited", "NoWorkers", "SearchBlocked", "ServerError", "DEFAULT_BASE_URL",
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
DEFAULT_BASE_URL = "https://req.akuraq.dev"
|
|
31
|
+
# Запрос идёт в настоящий браузер: на прогретом профиле это 3–10 секунд, а с
|
|
32
|
+
# решением капчи — до двух минут. Таймаут меньше рвёт нормальные запросы.
|
|
33
|
+
DEFAULT_TIMEOUT = 180.0
|
|
34
|
+
# Коды, которые лечатся повтором. Остальные повторять бессмысленно: неверный
|
|
35
|
+
# параметр и отозванный ключ от этого не исправятся.
|
|
36
|
+
RETRIABLE = frozenset({429, 502, 503, 504})
|
|
37
|
+
USER_AGENT = "parreq-python/1.0"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ParReqError(Exception):
|
|
41
|
+
"""Базовая ошибка. Разбирать стоит `code`, а не текст: текст переписывается."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, status: int, code: str, message: str,
|
|
44
|
+
request_id: str = "", retry_after: Optional[int] = None,
|
|
45
|
+
details: Optional[Dict[str, Any]] = None) -> None:
|
|
46
|
+
super().__init__(f"{code}: {message}")
|
|
47
|
+
self.status = status
|
|
48
|
+
self.code = code
|
|
49
|
+
self.message = message
|
|
50
|
+
self.request_id = request_id
|
|
51
|
+
self.retry_after = retry_after
|
|
52
|
+
self.details = details or {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class BadRequest(ParReqError):
|
|
56
|
+
"""400 — параметры. Смотрите `details`, там перечислено допустимое."""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AuthError(ParReqError):
|
|
60
|
+
"""401 и 403 — ключ не передан, не найден или отозван."""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RateLimited(ParReqError):
|
|
64
|
+
"""429 — частота, суточная квота или одновременность. Ждите `retry_after`."""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class NoWorkers(ParReqError):
|
|
68
|
+
"""503 — нет прогретого профиля под этот движок. Повтор имеет смысл."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SearchBlocked(ParReqError):
|
|
72
|
+
"""502 search_blocked — защиту поисковика на этом запросе обойти не вышло."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class ServerError(ParReqError):
|
|
76
|
+
"""Прочие 5xx, включая таймаут поисковика."""
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_BY_CODE = {
|
|
80
|
+
"invalid_request": BadRequest,
|
|
81
|
+
"missing_api_key": AuthError,
|
|
82
|
+
"invalid_api_key": AuthError,
|
|
83
|
+
"key_revoked": AuthError,
|
|
84
|
+
"admin_only": AuthError,
|
|
85
|
+
"rate_limited": RateLimited,
|
|
86
|
+
"quota_exceeded": RateLimited,
|
|
87
|
+
"concurrency_limit": RateLimited,
|
|
88
|
+
"no_workers_available": NoWorkers,
|
|
89
|
+
"search_blocked": SearchBlocked,
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _error_from(status: int, body: Dict[str, Any], retry_after: Optional[int]) -> ParReqError:
|
|
94
|
+
err = body.get("error") or {}
|
|
95
|
+
code = err.get("code") or f"http_{status}"
|
|
96
|
+
cls = _BY_CODE.get(code)
|
|
97
|
+
if cls is None:
|
|
98
|
+
cls = AuthError if status in (401, 403) else (
|
|
99
|
+
BadRequest if status == 400 else ServerError)
|
|
100
|
+
return cls(status, code, err.get("message") or "без описания",
|
|
101
|
+
err.get("request_id", ""), retry_after, err.get("details"))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class SearchResult:
|
|
106
|
+
"""Ответ поиска. Секции доступны и как атрибуты, и целиком через `raw`."""
|
|
107
|
+
|
|
108
|
+
raw: Dict[str, Any] = field(repr=False)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def metadata(self) -> Dict[str, Any]:
|
|
112
|
+
return self.raw.get("search_metadata", {})
|
|
113
|
+
|
|
114
|
+
@property
|
|
115
|
+
def parameters(self) -> Dict[str, Any]:
|
|
116
|
+
return self.raw.get("search_parameters", {})
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def request_id(self) -> str:
|
|
120
|
+
return self.metadata.get("id", "")
|
|
121
|
+
|
|
122
|
+
@property
|
|
123
|
+
def organic(self) -> List[Dict[str, Any]]:
|
|
124
|
+
return self.raw.get("organic_results", [])
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def ads(self) -> List[Dict[str, Any]]:
|
|
128
|
+
return self.raw.get("ads", [])
|
|
129
|
+
|
|
130
|
+
@property
|
|
131
|
+
def shopping(self) -> List[Dict[str, Any]]:
|
|
132
|
+
return self.raw.get("shopping_results", [])
|
|
133
|
+
|
|
134
|
+
@property
|
|
135
|
+
def local(self) -> List[Dict[str, Any]]:
|
|
136
|
+
return self.raw.get("local_results", [])
|
|
137
|
+
|
|
138
|
+
@property
|
|
139
|
+
def videos(self) -> List[Dict[str, Any]]:
|
|
140
|
+
return self.raw.get("inline_videos", [])
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def images(self) -> List[Dict[str, Any]]:
|
|
144
|
+
return self.raw.get("inline_images", [])
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def news(self) -> List[Dict[str, Any]]:
|
|
148
|
+
return self.raw.get("top_stories", [])
|
|
149
|
+
|
|
150
|
+
@property
|
|
151
|
+
def related(self) -> List[Dict[str, Any]]:
|
|
152
|
+
return self.raw.get("related_searches", [])
|
|
153
|
+
|
|
154
|
+
@property
|
|
155
|
+
def people_also_ask(self) -> List[Dict[str, Any]]:
|
|
156
|
+
return self.raw.get("people_also_ask", [])
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def knowledge_graph(self) -> Optional[Dict[str, Any]]:
|
|
160
|
+
return self.raw.get("knowledge_graph")
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def answer_box(self) -> Optional[Dict[str, Any]]:
|
|
164
|
+
return self.raw.get("answer_box")
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def ai_overview(self) -> Optional[Dict[str, Any]]:
|
|
168
|
+
return self.raw.get("ai_overview")
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def total_results(self) -> Optional[int]:
|
|
172
|
+
return self.raw.get("total_results")
|
|
173
|
+
|
|
174
|
+
def __getitem__(self, key: str) -> Any:
|
|
175
|
+
return self.raw[key]
|
|
176
|
+
|
|
177
|
+
def __repr__(self) -> str:
|
|
178
|
+
md = self.metadata
|
|
179
|
+
return (f"<SearchResult {md.get('engine')} "
|
|
180
|
+
f"organic={len(self.organic)} {md.get('total_ms')}ms>")
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class ParReq:
|
|
184
|
+
"""Клиент.
|
|
185
|
+
|
|
186
|
+
:param api_key: ключ вида ``pr_…``
|
|
187
|
+
:param base_url: адрес сервиса
|
|
188
|
+
:param timeout: сколько ждать ответ, секунд
|
|
189
|
+
:param retries: сколько раз повторять то, что лечится повтором
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
def __init__(self, api_key: str, base_url: str = DEFAULT_BASE_URL,
|
|
193
|
+
timeout: float = DEFAULT_TIMEOUT, retries: int = 3) -> None:
|
|
194
|
+
if not api_key:
|
|
195
|
+
raise ValueError("нужен ключ ParReq")
|
|
196
|
+
self.api_key = api_key
|
|
197
|
+
self.base_url = base_url.rstrip("/")
|
|
198
|
+
self.timeout = timeout
|
|
199
|
+
self.retries = max(0, retries)
|
|
200
|
+
|
|
201
|
+
# --- публичное ----------------------------------------------------------
|
|
202
|
+
def search(self, q: str, engine: str, *, device: str = "desktop",
|
|
203
|
+
gl: str = "us", hl: str = "en", page: int = 1,
|
|
204
|
+
num: Optional[int] = None, location: Optional[str] = None,
|
|
205
|
+
domain: Optional[str] = None,
|
|
206
|
+
include: Optional[Iterable[str]] = None) -> SearchResult:
|
|
207
|
+
"""Поиск. `engine` обязателен: `google` или `yandex`.
|
|
208
|
+
|
|
209
|
+
`include` — секции сверх органики: ``["ads", "shopping", "videos"]`` или
|
|
210
|
+
``"all"``. По умолчанию приходит только органика.
|
|
211
|
+
"""
|
|
212
|
+
params: Dict[str, Any] = {
|
|
213
|
+
"q": q, "engine": engine, "device": device, "gl": gl, "hl": hl,
|
|
214
|
+
"page": page,
|
|
215
|
+
}
|
|
216
|
+
if num is not None:
|
|
217
|
+
params["num"] = num
|
|
218
|
+
if location:
|
|
219
|
+
params["location"] = location
|
|
220
|
+
if domain:
|
|
221
|
+
params["domain"] = domain
|
|
222
|
+
if include:
|
|
223
|
+
params["include"] = include if isinstance(include, str) else ",".join(include)
|
|
224
|
+
return SearchResult(self._request("GET", "/v1/search", params=params))
|
|
225
|
+
|
|
226
|
+
def google(self, q: str, **kwargs: Any) -> SearchResult:
|
|
227
|
+
"""Сахар: ``client.google("coffee machine", gl="us")``."""
|
|
228
|
+
return self.search(q, engine="google", **kwargs)
|
|
229
|
+
|
|
230
|
+
def yandex(self, q: str, **kwargs: Any) -> SearchResult:
|
|
231
|
+
"""Сахар: ``client.yandex("кофемашина", gl="by", hl="ru")``."""
|
|
232
|
+
return self.search(q, engine="yandex", **kwargs)
|
|
233
|
+
|
|
234
|
+
def usage(self) -> Dict[str, Any]:
|
|
235
|
+
"""Остаток лимитов по своему ключу."""
|
|
236
|
+
return self._request("GET", "/v1/usage")
|
|
237
|
+
|
|
238
|
+
def meta(self) -> Dict[str, Any]:
|
|
239
|
+
"""Справочники: движки, устройства, страны, языки, секции."""
|
|
240
|
+
return self._request("GET", "/v1/meta")
|
|
241
|
+
|
|
242
|
+
# --- внутреннее ---------------------------------------------------------
|
|
243
|
+
def _request(self, method: str, path: str,
|
|
244
|
+
params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
|
|
245
|
+
url = self.base_url + path
|
|
246
|
+
if params:
|
|
247
|
+
url += "?" + urllib.parse.urlencode(params)
|
|
248
|
+
|
|
249
|
+
last: Optional[ParReqError] = None
|
|
250
|
+
for attempt in range(self.retries + 1):
|
|
251
|
+
try:
|
|
252
|
+
return self._once(method, url)
|
|
253
|
+
except ParReqError as exc:
|
|
254
|
+
if exc.status not in RETRIABLE or attempt == self.retries:
|
|
255
|
+
raise
|
|
256
|
+
last = exc
|
|
257
|
+
# ждём столько, сколько попросил сервер: у квоты это время до
|
|
258
|
+
# полуночи, и «подождать 5 секунд» там ничего не изменит
|
|
259
|
+
time.sleep(exc.retry_after if exc.retry_after is not None else 5)
|
|
260
|
+
raise last # недостижимо, но делает намерение явным
|
|
261
|
+
|
|
262
|
+
def _once(self, method: str, url: str) -> Dict[str, Any]:
|
|
263
|
+
req = urllib.request.Request(url, method=method, headers={
|
|
264
|
+
"Authorization": f"Bearer {self.api_key}",
|
|
265
|
+
"Accept": "application/json",
|
|
266
|
+
"User-Agent": USER_AGENT,
|
|
267
|
+
})
|
|
268
|
+
try:
|
|
269
|
+
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
270
|
+
return json.loads(resp.read().decode("utf-8"))
|
|
271
|
+
except urllib.error.HTTPError as exc:
|
|
272
|
+
raw = exc.read().decode("utf-8", "replace")
|
|
273
|
+
try:
|
|
274
|
+
body = json.loads(raw)
|
|
275
|
+
except ValueError:
|
|
276
|
+
body = {"error": {"code": f"http_{exc.code}", "message": raw[:200]}}
|
|
277
|
+
retry_after = exc.headers.get("Retry-After") if exc.headers else None
|
|
278
|
+
raise _error_from(exc.code, body,
|
|
279
|
+
int(retry_after) if retry_after and retry_after.isdigit() else None)
|
|
280
|
+
except urllib.error.URLError as exc:
|
|
281
|
+
raise ServerError(0, "connection_error", f"не удалось соединиться: {exc.reason}")
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "parreq"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Клиент ParReq: поисковая выдача Google и Яндекса в JSON"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["serp", "google", "yandex", "scraping", "search-api"]
|
|
13
|
+
# Зависимостей нет намеренно: библиотека тонкая, и тянуть requests ради одного
|
|
14
|
+
# запроса значит навязывать чужому проекту разрешение версий.
|
|
15
|
+
dependencies = []
|
|
16
|
+
|
|
17
|
+
[project.urls]
|
|
18
|
+
Documentation = "https://req.akuraq.dev/docs"
|
|
19
|
+
Homepage = "https://req.akuraq.dev"
|
|
20
|
+
|
|
21
|
+
[tool.hatch.build.targets.wheel]
|
|
22
|
+
packages = ["parreq"]
|