parreq-client 1.0.0
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.
- package/LICENSE +21 -0
- package/README.md +77 -0
- package/index.d.ts +86 -0
- package/index.js +195 -0
- package/package.json +35 -0
package/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.
|
package/README.md
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# parreq
|
|
2
|
+
|
|
3
|
+
Клиент [ParReq](https://req.akuraq.dev) — поисковая выдача Google и Яндекса в
|
|
4
|
+
JSON. Без зависимостей, на встроенном `fetch` (нужен Node 18+). Типы в комплекте.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npm i parreq
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
```js
|
|
11
|
+
import { ParReq } from "parreq";
|
|
12
|
+
|
|
13
|
+
const client = new ParReq({ apiKey: "pr_ВАШКЛЮЧ" });
|
|
14
|
+
|
|
15
|
+
const res = await client.yandex({ q: "кофемашина", gl: "by", hl: "ru",
|
|
16
|
+
include: ["ads", "shopping"] });
|
|
17
|
+
console.log(res.organic.length, "органических,", res.ads.length, "объявлений");
|
|
18
|
+
|
|
19
|
+
for (const item of res.organic) {
|
|
20
|
+
console.log(item.position, item.title, item.link);
|
|
21
|
+
}
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
`engine` обязателен, поэтому у общего метода он в объекте параметров, а для двух
|
|
25
|
+
движков есть сахар:
|
|
26
|
+
|
|
27
|
+
```js
|
|
28
|
+
await client.search({ q: "coffee machine", engine: "google", gl: "us" });
|
|
29
|
+
await client.google({ q: "coffee machine", gl: "us" });
|
|
30
|
+
await client.yandex({ q: "кофемашина", gl: "by", hl: "ru" });
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Секции
|
|
34
|
+
|
|
35
|
+
По умолчанию приходит только органика. Остальное — через `include`:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
const res = await client.google({
|
|
39
|
+
q: "купить кофемашину", gl: "by", hl: "ru",
|
|
40
|
+
include: ["ads", "shopping", "videos", "related_searches"],
|
|
41
|
+
});
|
|
42
|
+
res.ads; res.shopping; res.videos; res.related;
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Доступно: `ads`, `shopping`, `local`, `knowledge_graph`, `ai_overview`,
|
|
46
|
+
`answer_box`, `people_also_ask`, `related_searches`, `images`, `videos`, `news`,
|
|
47
|
+
`total_results`, либо `include: "all"`.
|
|
48
|
+
|
|
49
|
+
## Ошибки
|
|
50
|
+
|
|
51
|
+
```js
|
|
52
|
+
import { ParReq, ParReqError, NoWorkers, RateLimited } from "parreq";
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
const res = await client.google({ q: "coffee", gl: "us" });
|
|
56
|
+
} catch (err) {
|
|
57
|
+
if (err instanceof NoWorkers) console.log("повторить через", err.retryAfter);
|
|
58
|
+
else if (err instanceof RateLimited) console.log(err.code, err.retryAfter);
|
|
59
|
+
else if (err instanceof ParReqError) console.log(err.status, err.code, err.requestId);
|
|
60
|
+
else throw err;
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
Клиент **сам повторяет** то, что лечится повтором (429, 502, 503, 504), выжидая
|
|
65
|
+
столько, сколько просит сервер в `Retry-After`. По умолчанию три попытки:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
new ParReq({ apiKey: "pr_…", retries: 0 }); // выключить повторы
|
|
69
|
+
new ParReq({ apiKey: "pr_…", timeoutMs: 240_000 }); // запрос с капчей бывает долгим
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
## Свой расход
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
const u = await client.usage();
|
|
76
|
+
console.log(u.used_today, "из", u.limits.daily);
|
|
77
|
+
```
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
export interface SearchOptions {
|
|
2
|
+
/** Поисковый запрос. */
|
|
3
|
+
q: string;
|
|
4
|
+
/** Обязателен: умолчания нет намеренно. */
|
|
5
|
+
engine: "google" | "yandex";
|
|
6
|
+
device?: "desktop" | "desktop_mac" | "mobile" | "mobile_ios" | "tablet";
|
|
7
|
+
gl?: string;
|
|
8
|
+
hl?: string;
|
|
9
|
+
page?: number;
|
|
10
|
+
num?: number;
|
|
11
|
+
location?: string;
|
|
12
|
+
domain?: string;
|
|
13
|
+
/** Секции сверх органики: ["ads","shopping"] либо "all". */
|
|
14
|
+
include?: string[] | string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type EngineOptions = Omit<SearchOptions, "engine">;
|
|
18
|
+
|
|
19
|
+
export interface ClientOptions {
|
|
20
|
+
apiKey: string;
|
|
21
|
+
baseUrl?: string;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
retries?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SearchMetadata {
|
|
27
|
+
id: string;
|
|
28
|
+
status: string;
|
|
29
|
+
engine: string;
|
|
30
|
+
requested_url: string;
|
|
31
|
+
final_url: string;
|
|
32
|
+
fetch_ms: number;
|
|
33
|
+
total_ms: number;
|
|
34
|
+
html_size: number;
|
|
35
|
+
detected_location: string | null;
|
|
36
|
+
created_at: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export declare class SearchResult {
|
|
40
|
+
raw: Record<string, any>;
|
|
41
|
+
readonly metadata: SearchMetadata;
|
|
42
|
+
readonly parameters: Record<string, any>;
|
|
43
|
+
readonly requestId: string;
|
|
44
|
+
readonly organic: Record<string, any>[];
|
|
45
|
+
readonly ads: Record<string, any>[];
|
|
46
|
+
readonly shopping: Record<string, any>[];
|
|
47
|
+
readonly local: Record<string, any>[];
|
|
48
|
+
readonly videos: Record<string, any>[];
|
|
49
|
+
readonly images: Record<string, any>[];
|
|
50
|
+
readonly news: Record<string, any>[];
|
|
51
|
+
readonly related: Record<string, any>[];
|
|
52
|
+
readonly peopleAlsoAsk: Record<string, any>[];
|
|
53
|
+
readonly knowledgeGraph: Record<string, any> | null;
|
|
54
|
+
readonly answerBox: Record<string, any> | null;
|
|
55
|
+
readonly aiOverview: Record<string, any> | null;
|
|
56
|
+
readonly totalResults: number | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export declare class ParReqError extends Error {
|
|
60
|
+
status: number;
|
|
61
|
+
code: string;
|
|
62
|
+
detail: string;
|
|
63
|
+
requestId: string;
|
|
64
|
+
retryAfter: number | null;
|
|
65
|
+
details: Record<string, any>;
|
|
66
|
+
readonly retriable: boolean;
|
|
67
|
+
}
|
|
68
|
+
export declare class BadRequest extends ParReqError {}
|
|
69
|
+
export declare class AuthError extends ParReqError {}
|
|
70
|
+
export declare class RateLimited extends ParReqError {}
|
|
71
|
+
export declare class NoWorkers extends ParReqError {}
|
|
72
|
+
export declare class SearchBlocked extends ParReqError {}
|
|
73
|
+
export declare class ServerError extends ParReqError {}
|
|
74
|
+
|
|
75
|
+
export declare class ParReq {
|
|
76
|
+
constructor(options: ClientOptions | string);
|
|
77
|
+
search(options: SearchOptions): Promise<SearchResult>;
|
|
78
|
+
google(options: EngineOptions): Promise<SearchResult>;
|
|
79
|
+
yandex(options: EngineOptions): Promise<SearchResult>;
|
|
80
|
+
usage(): Promise<Record<string, any>>;
|
|
81
|
+
meta(): Promise<Record<string, any>>;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export declare const DEFAULT_BASE_URL: string;
|
|
85
|
+
export declare const DEFAULT_TIMEOUT_MS: number;
|
|
86
|
+
export default ParReq;
|
package/index.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ParReq — клиент поисковой выдачи Google и Яндекса.
|
|
3
|
+
*
|
|
4
|
+
* Зависимостей нет: используется встроенный fetch, поэтому нужен Node 18+.
|
|
5
|
+
* Тянуть axios ради одного запроса значит навязывать чужому проекту лишнее.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_BASE_URL = "https://req.akuraq.dev";
|
|
9
|
+
// Запрос идёт в настоящий браузер: 3–10 секунд на прогретом профиле и до двух
|
|
10
|
+
// минут, если пришлось решать капчу. Таймаут меньше рвёт нормальные запросы.
|
|
11
|
+
export const DEFAULT_TIMEOUT_MS = 180_000;
|
|
12
|
+
// Коды, которые лечатся повтором. Остальные повторять бессмысленно: неверный
|
|
13
|
+
// параметр и отозванный ключ от этого не исправятся.
|
|
14
|
+
const RETRIABLE = new Set([429, 502, 503, 504]);
|
|
15
|
+
const USER_AGENT = "parreq-node/1.0";
|
|
16
|
+
|
|
17
|
+
/** Ошибка API. Разбирайте `code`, а не текст: текст переписывается. */
|
|
18
|
+
export class ParReqError extends Error {
|
|
19
|
+
constructor(status, code, message, { requestId = "", retryAfter = null, details = {} } = {}) {
|
|
20
|
+
super(`${code}: ${message}`);
|
|
21
|
+
this.name = "ParReqError";
|
|
22
|
+
this.status = status;
|
|
23
|
+
this.code = code;
|
|
24
|
+
this.detail = message;
|
|
25
|
+
this.requestId = requestId;
|
|
26
|
+
this.retryAfter = retryAfter;
|
|
27
|
+
this.details = details;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Имеет ли смысл повторить этот запрос. */
|
|
31
|
+
get retriable() {
|
|
32
|
+
return RETRIABLE.has(this.status);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export class BadRequest extends ParReqError {}
|
|
37
|
+
export class AuthError extends ParReqError {}
|
|
38
|
+
export class RateLimited extends ParReqError {}
|
|
39
|
+
export class NoWorkers extends ParReqError {}
|
|
40
|
+
export class SearchBlocked extends ParReqError {}
|
|
41
|
+
export class ServerError extends ParReqError {}
|
|
42
|
+
|
|
43
|
+
const BY_CODE = {
|
|
44
|
+
invalid_request: BadRequest,
|
|
45
|
+
missing_api_key: AuthError,
|
|
46
|
+
invalid_api_key: AuthError,
|
|
47
|
+
key_revoked: AuthError,
|
|
48
|
+
admin_only: AuthError,
|
|
49
|
+
rate_limited: RateLimited,
|
|
50
|
+
quota_exceeded: RateLimited,
|
|
51
|
+
concurrency_limit: RateLimited,
|
|
52
|
+
no_workers_available: NoWorkers,
|
|
53
|
+
search_blocked: SearchBlocked,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
function errorFrom(status, body, retryAfter) {
|
|
57
|
+
const err = (body && body.error) || {};
|
|
58
|
+
const code = err.code || `http_${status}`;
|
|
59
|
+
let Cls = BY_CODE[code];
|
|
60
|
+
if (!Cls) {
|
|
61
|
+
Cls = status === 400 ? BadRequest : status === 401 || status === 403 ? AuthError : ServerError;
|
|
62
|
+
}
|
|
63
|
+
return new Cls(status, code, err.message || "без описания", {
|
|
64
|
+
requestId: err.request_id || "",
|
|
65
|
+
retryAfter,
|
|
66
|
+
details: err.details || {},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
71
|
+
|
|
72
|
+
/** Ответ поиска: секции удобными геттерами плюс исходный JSON в `raw`. */
|
|
73
|
+
export class SearchResult {
|
|
74
|
+
constructor(raw) {
|
|
75
|
+
this.raw = raw;
|
|
76
|
+
}
|
|
77
|
+
get metadata() { return this.raw.search_metadata || {}; }
|
|
78
|
+
get parameters() { return this.raw.search_parameters || {}; }
|
|
79
|
+
get requestId() { return this.metadata.id || ""; }
|
|
80
|
+
get organic() { return this.raw.organic_results || []; }
|
|
81
|
+
get ads() { return this.raw.ads || []; }
|
|
82
|
+
get shopping() { return this.raw.shopping_results || []; }
|
|
83
|
+
get local() { return this.raw.local_results || []; }
|
|
84
|
+
get videos() { return this.raw.inline_videos || []; }
|
|
85
|
+
get images() { return this.raw.inline_images || []; }
|
|
86
|
+
get news() { return this.raw.top_stories || []; }
|
|
87
|
+
get related() { return this.raw.related_searches || []; }
|
|
88
|
+
get peopleAlsoAsk() { return this.raw.people_also_ask || []; }
|
|
89
|
+
get knowledgeGraph() { return this.raw.knowledge_graph ?? null; }
|
|
90
|
+
get answerBox() { return this.raw.answer_box ?? null; }
|
|
91
|
+
get aiOverview() { return this.raw.ai_overview ?? null; }
|
|
92
|
+
get totalResults() { return this.raw.total_results ?? null; }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export class ParReq {
|
|
96
|
+
/**
|
|
97
|
+
* @param {object|string} options ключ строкой либо объект настроек
|
|
98
|
+
* @param {string} options.apiKey ключ вида `pr_…`
|
|
99
|
+
* @param {string} [options.baseUrl]
|
|
100
|
+
* @param {number} [options.timeoutMs]
|
|
101
|
+
* @param {number} [options.retries] сколько раз повторять то, что лечится повтором
|
|
102
|
+
*/
|
|
103
|
+
constructor(options) {
|
|
104
|
+
const opts = typeof options === "string" ? { apiKey: options } : options || {};
|
|
105
|
+
if (!opts.apiKey) throw new Error("нужен ключ ParReq");
|
|
106
|
+
this.apiKey = opts.apiKey;
|
|
107
|
+
this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
108
|
+
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
109
|
+
this.retries = Math.max(0, opts.retries ?? 3);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Поиск. `engine` обязателен: "google" или "yandex".
|
|
114
|
+
* `include` — секции сверх органики: ["ads", "shopping"] либо "all".
|
|
115
|
+
*/
|
|
116
|
+
async search({ q, engine, device = "desktop", gl = "us", hl = "en", page = 1,
|
|
117
|
+
num, location, domain, include } = {}) {
|
|
118
|
+
if (!q) throw new Error("нужен запрос q");
|
|
119
|
+
if (!engine) throw new Error("нужен engine: google или yandex");
|
|
120
|
+
const params = { q, engine, device, gl, hl, page };
|
|
121
|
+
if (num != null) params.num = num;
|
|
122
|
+
if (location) params.location = location;
|
|
123
|
+
if (domain) params.domain = domain;
|
|
124
|
+
if (include) params.include = Array.isArray(include) ? include.join(",") : include;
|
|
125
|
+
return new SearchResult(await this.#request("GET", "/v1/search", params));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Сахар: `client.google({ q: "coffee machine", gl: "us" })`. */
|
|
129
|
+
async google(options) { return this.search({ ...options, engine: "google" }); }
|
|
130
|
+
|
|
131
|
+
/** Сахар: `client.yandex({ q: "кофемашина", gl: "by", hl: "ru" })`. */
|
|
132
|
+
async yandex(options) { return this.search({ ...options, engine: "yandex" }); }
|
|
133
|
+
|
|
134
|
+
/** Остаток лимитов по своему ключу. */
|
|
135
|
+
async usage() { return this.#request("GET", "/v1/usage"); }
|
|
136
|
+
|
|
137
|
+
/** Справочники: движки, устройства, страны, языки, секции. */
|
|
138
|
+
async meta() { return this.#request("GET", "/v1/meta"); }
|
|
139
|
+
|
|
140
|
+
async #request(method, path, params) {
|
|
141
|
+
const url = new URL(this.baseUrl + path);
|
|
142
|
+
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
|
|
143
|
+
|
|
144
|
+
let last;
|
|
145
|
+
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
146
|
+
try {
|
|
147
|
+
return await this.#once(method, url);
|
|
148
|
+
} catch (err) {
|
|
149
|
+
if (!(err instanceof ParReqError) || !err.retriable || attempt === this.retries) throw err;
|
|
150
|
+
last = err;
|
|
151
|
+
// ждём столько, сколько попросил сервер: у суточной квоты это время до
|
|
152
|
+
// полуночи, и «подождать 5 секунд» там ничего не изменит
|
|
153
|
+
await sleep((err.retryAfter ?? 5) * 1000);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
throw last;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async #once(method, url) {
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
162
|
+
let resp;
|
|
163
|
+
try {
|
|
164
|
+
resp = await fetch(url, {
|
|
165
|
+
method,
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
headers: {
|
|
168
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
169
|
+
Accept: "application/json",
|
|
170
|
+
"User-Agent": USER_AGENT,
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
} catch (err) {
|
|
174
|
+
const reason = err.name === "AbortError" ? `превышен таймаут ${this.timeoutMs} мс` : String(err);
|
|
175
|
+
throw new ServerError(0, "connection_error", reason);
|
|
176
|
+
} finally {
|
|
177
|
+
clearTimeout(timer);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const text = await resp.text();
|
|
181
|
+
let body;
|
|
182
|
+
try {
|
|
183
|
+
body = text ? JSON.parse(text) : {};
|
|
184
|
+
} catch {
|
|
185
|
+
body = { error: { code: `http_${resp.status}`, message: text.slice(0, 200) } };
|
|
186
|
+
}
|
|
187
|
+
if (!resp.ok) {
|
|
188
|
+
const ra = resp.headers.get("retry-after");
|
|
189
|
+
throw errorFrom(resp.status, body, ra ? Number(ra) : null);
|
|
190
|
+
}
|
|
191
|
+
return body;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export default ParReq;
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "parreq-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Клиент ParReq: поисковая выдача Google и Яндекса в JSON",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"types": "./index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./index.d.ts",
|
|
11
|
+
"import": "./index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"index.js",
|
|
16
|
+
"index.d.ts",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"engines": {
|
|
21
|
+
"node": ">=18"
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"serp",
|
|
25
|
+
"google",
|
|
26
|
+
"yandex",
|
|
27
|
+
"search-api",
|
|
28
|
+
"scraping"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"homepage": "https://req.akuraq.dev",
|
|
32
|
+
"scripts": {
|
|
33
|
+
"smoke": "node smoke.mjs"
|
|
34
|
+
}
|
|
35
|
+
}
|