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.
@@ -0,0 +1,7 @@
1
+ runtime/
2
+ __pycache__/
3
+ *.pyc
4
+ secrets.json
5
+ docs/site/
6
+ docs/site.zip
7
+ runtime/admin_key.txt
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"]