parreq-client 1.0.0 → 1.4.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/README.md +8 -8
- package/index.d.ts +236 -9
- package/index.js +340 -32
- package/package.json +6 -4
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# parreq
|
|
2
2
|
|
|
3
|
-
Клиент [
|
|
3
|
+
Клиент [Parreq](https://parreq.com) — поисковая выдача Google и Яндекса в
|
|
4
4
|
JSON. Без зависимостей, на встроенном `fetch` (нужен Node 18+). Типы в комплекте.
|
|
5
5
|
|
|
6
6
|
```bash
|
|
7
|
-
npm i parreq
|
|
7
|
+
npm i parreq-client
|
|
8
8
|
```
|
|
9
9
|
|
|
10
10
|
```js
|
|
11
|
-
import {
|
|
11
|
+
import { Parreq } from "parreq-client";
|
|
12
12
|
|
|
13
|
-
const client = new
|
|
13
|
+
const client = new Parreq({ apiKey: "pr_ВАШКЛЮЧ" });
|
|
14
14
|
|
|
15
15
|
const res = await client.yandex({ q: "кофемашина", gl: "by", hl: "ru",
|
|
16
16
|
include: ["ads", "shopping"] });
|
|
@@ -49,14 +49,14 @@ res.ads; res.shopping; res.videos; res.related;
|
|
|
49
49
|
## Ошибки
|
|
50
50
|
|
|
51
51
|
```js
|
|
52
|
-
import {
|
|
52
|
+
import { Parreq, ParreqError, NoWorkers, RateLimited } from "parreq-client";
|
|
53
53
|
|
|
54
54
|
try {
|
|
55
55
|
const res = await client.google({ q: "coffee", gl: "us" });
|
|
56
56
|
} catch (err) {
|
|
57
57
|
if (err instanceof NoWorkers) console.log("повторить через", err.retryAfter);
|
|
58
58
|
else if (err instanceof RateLimited) console.log(err.code, err.retryAfter);
|
|
59
|
-
else if (err instanceof
|
|
59
|
+
else if (err instanceof ParreqError) console.log(err.status, err.code, err.requestId);
|
|
60
60
|
else throw err;
|
|
61
61
|
}
|
|
62
62
|
```
|
|
@@ -65,8 +65,8 @@ try {
|
|
|
65
65
|
столько, сколько просит сервер в `Retry-After`. По умолчанию три попытки:
|
|
66
66
|
|
|
67
67
|
```js
|
|
68
|
-
new
|
|
69
|
-
new
|
|
68
|
+
new Parreq({ apiKey: "pr_…", retries: 0 }); // выключить повторы
|
|
69
|
+
new Parreq({ apiKey: "pr_…", timeoutMs: 240_000 }); // запрос с капчей бывает долгим
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
## Свой расход
|
package/index.d.ts
CHANGED
|
@@ -16,6 +16,48 @@ export interface SearchOptions {
|
|
|
16
16
|
|
|
17
17
|
export type EngineOptions = Omit<SearchOptions, "engine">;
|
|
18
18
|
|
|
19
|
+
/** Платные надстройки Fetch, заказываемые через include. */
|
|
20
|
+
export type FetchExtra = "js" | "css" | "network" | "screenshot" | "iframe";
|
|
21
|
+
|
|
22
|
+
export interface FetchOptions {
|
|
23
|
+
/** Адрес страницы; схема http или https. */
|
|
24
|
+
url: string;
|
|
25
|
+
device?: "desktop" | "desktop_mac" | "mobile" | "mobile_ios" | "tablet";
|
|
26
|
+
gl?: string;
|
|
27
|
+
hl?: string;
|
|
28
|
+
/** Платные надстройки: ["js","network"] либо "all". */
|
|
29
|
+
include?: FetchExtra[] | string;
|
|
30
|
+
/** false — сбор браузером с окном, платно. */
|
|
31
|
+
headless?: boolean;
|
|
32
|
+
waitMs?: number;
|
|
33
|
+
/** Ждать появления блока: CSS, XPath или название блока. */
|
|
34
|
+
waitFor?: string;
|
|
35
|
+
/** Окно наблюдения за сетью после загрузки, мс. По умолчанию 1000. */
|
|
36
|
+
networkMs?: number;
|
|
37
|
+
scroll?: boolean;
|
|
38
|
+
/** Начать разметку с этого блока. */
|
|
39
|
+
startAt?: string;
|
|
40
|
+
/** Отдать только этот блок. */
|
|
41
|
+
select?: string;
|
|
42
|
+
selectAll?: boolean;
|
|
43
|
+
/** Вырезать блоки. */
|
|
44
|
+
exclude?: string[] | string;
|
|
45
|
+
/** Убрать script, style, noscript и комментарии. */
|
|
46
|
+
strip?: boolean;
|
|
47
|
+
/** Добавить текст страницы отдельным полем. */
|
|
48
|
+
text?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* Разбор страницы в поля, описанные словами: имя поля → что в нём должно
|
|
51
|
+
* оказаться. Результат приходит в `parsed`, причина неудачи — в
|
|
52
|
+
* `fetch_metadata.extract_note`. Три кредита, и они не списываются, если
|
|
53
|
+
* разбор не состоялся.
|
|
54
|
+
*/
|
|
55
|
+
extract?: ExtractSpec;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Что вынуть со страницы: имя поля → описание словами. */
|
|
59
|
+
export type ExtractSpec = Record<string, string>;
|
|
60
|
+
|
|
19
61
|
export interface ClientOptions {
|
|
20
62
|
apiKey: string;
|
|
21
63
|
baseUrl?: string;
|
|
@@ -56,7 +98,165 @@ export declare class SearchResult {
|
|
|
56
98
|
readonly totalResults: number | null;
|
|
57
99
|
}
|
|
58
100
|
|
|
59
|
-
export
|
|
101
|
+
export interface FetchMetadata {
|
|
102
|
+
id: string;
|
|
103
|
+
status: string;
|
|
104
|
+
requested_url: string;
|
|
105
|
+
final_url: string;
|
|
106
|
+
http_status: number;
|
|
107
|
+
title: string;
|
|
108
|
+
fetch_ms: number;
|
|
109
|
+
total_ms: number;
|
|
110
|
+
html_size: number;
|
|
111
|
+
page_size: number;
|
|
112
|
+
matched: number;
|
|
113
|
+
note: string;
|
|
114
|
+
credits: number;
|
|
115
|
+
credits_breakdown: Record<string, number>;
|
|
116
|
+
credits_left: number;
|
|
117
|
+
created_at: number;
|
|
118
|
+
/** Почему не вышел разбор по `extract`. Пусто, если его не заказывали. */
|
|
119
|
+
extract_note?: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Один скрипт или один файл стилей. */
|
|
123
|
+
export interface Asset {
|
|
124
|
+
url: string;
|
|
125
|
+
inline: boolean;
|
|
126
|
+
bytes: number;
|
|
127
|
+
content: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Один сетевой запрос страницы. */
|
|
131
|
+
export interface NetworkEntry {
|
|
132
|
+
id: string;
|
|
133
|
+
url: string;
|
|
134
|
+
method: string;
|
|
135
|
+
type: string;
|
|
136
|
+
status: number;
|
|
137
|
+
mime: string;
|
|
138
|
+
bytes: number;
|
|
139
|
+
started_ms: number;
|
|
140
|
+
finished_ms: number | null;
|
|
141
|
+
error: string;
|
|
142
|
+
redirected_from: string | null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export declare class FetchResult {
|
|
146
|
+
raw: Record<string, any>;
|
|
147
|
+
readonly metadata: FetchMetadata;
|
|
148
|
+
readonly parameters: Record<string, any>;
|
|
149
|
+
readonly id: string;
|
|
150
|
+
readonly html: string;
|
|
151
|
+
readonly text: string | null;
|
|
152
|
+
/** Разбор по `extract`; null и когда не заказывали, и когда не удался. */
|
|
153
|
+
readonly parsed: Record<string, any> | null;
|
|
154
|
+
readonly title: string;
|
|
155
|
+
readonly finalUrl: string;
|
|
156
|
+
readonly httpStatus: number;
|
|
157
|
+
readonly credits: number;
|
|
158
|
+
readonly creditsLeft: number;
|
|
159
|
+
readonly js: Asset[] | null;
|
|
160
|
+
readonly css: Asset[] | null;
|
|
161
|
+
readonly network: NetworkEntry[] | null;
|
|
162
|
+
readonly screenshotUrl: string | null;
|
|
163
|
+
readonly iframeUrl: string | null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Состояние задания обхода. Последние четыре — завершённые. */
|
|
167
|
+
export type CrawlStatus = "queued" | "running" | "done" | "partial" | "failed" | "canceled";
|
|
168
|
+
|
|
169
|
+
export interface CrawlOptions {
|
|
170
|
+
/** Адрес, с которого начинается обход. */
|
|
171
|
+
url: string;
|
|
172
|
+
/** Сколько страниц обойти, 1..200. Обязателен: это и есть потолок расхода. */
|
|
173
|
+
pages: number;
|
|
174
|
+
/** Селектор ссылок на карточки: CSS или XPath. */
|
|
175
|
+
follow?: string;
|
|
176
|
+
/** Селектор кнопки следующей страницы. */
|
|
177
|
+
next?: string;
|
|
178
|
+
/** Насколько глубоко заходить по ссылкам, 0..5 (по умолчанию 1). */
|
|
179
|
+
depth?: number;
|
|
180
|
+
/** Не выпускать обход на чужие домены (по умолчанию true). */
|
|
181
|
+
sameSite?: boolean;
|
|
182
|
+
/** Регулярные выражения: адрес берётся, только если подходит хотя бы одному. */
|
|
183
|
+
allow?: string[] | string;
|
|
184
|
+
/** Регулярные выражения: подходящий адрес не берётся. */
|
|
185
|
+
deny?: string[] | string;
|
|
186
|
+
/** Что вынуть с каждой страницы. */
|
|
187
|
+
extract?: ExtractSpec;
|
|
188
|
+
/** Обычные параметры Fetch для каждой страницы, именами API. */
|
|
189
|
+
fetch?: Record<string, any>;
|
|
190
|
+
/** Не ждать результат: ответ придёт одним `crawl_metadata`. */
|
|
191
|
+
async?: boolean;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Правило обхода плюс то, как его ждать. Вместо него можно дать id задания. */
|
|
195
|
+
export interface CrawlWaitOptions extends Omit<CrawlOptions, "async"> {
|
|
196
|
+
/** Как часто спрашивать состояние, мс (по умолчанию 3000). */
|
|
197
|
+
pollMs?: number;
|
|
198
|
+
/** Сколько всего ждать, мс (по умолчанию 1800000). */
|
|
199
|
+
timeoutMs?: number;
|
|
200
|
+
/** По сколько страниц забирать результат (по умолчанию 50). */
|
|
201
|
+
pageSize?: number;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface CrawlMetadata {
|
|
205
|
+
id: string;
|
|
206
|
+
status: CrawlStatus;
|
|
207
|
+
code: string;
|
|
208
|
+
note: string;
|
|
209
|
+
pages_planned: number;
|
|
210
|
+
pages_done: number;
|
|
211
|
+
pages_failed: number;
|
|
212
|
+
credits: number;
|
|
213
|
+
credits_max: number;
|
|
214
|
+
created_at: number;
|
|
215
|
+
started_at: number | null;
|
|
216
|
+
finished_at: number | null;
|
|
217
|
+
solution: string;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Одна страница обхода — в той же форме, что ответ Fetch. */
|
|
221
|
+
export interface CrawlPage {
|
|
222
|
+
fetch_metadata: {
|
|
223
|
+
n: number;
|
|
224
|
+
status: string;
|
|
225
|
+
code: string;
|
|
226
|
+
requested_url: string;
|
|
227
|
+
final_url: string;
|
|
228
|
+
http_status: number;
|
|
229
|
+
title: string;
|
|
230
|
+
depth: number;
|
|
231
|
+
fetch_ms: number;
|
|
232
|
+
credits: number;
|
|
233
|
+
extract_note: string;
|
|
234
|
+
};
|
|
235
|
+
parsed: Record<string, any> | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export declare class CrawlResult {
|
|
239
|
+
raw: Record<string, any>;
|
|
240
|
+
readonly metadata: CrawlMetadata;
|
|
241
|
+
readonly parameters: Record<string, any>;
|
|
242
|
+
readonly id: string;
|
|
243
|
+
readonly status: CrawlStatus | "";
|
|
244
|
+
/** Дошло ли задание до конца — любого, включая неудачный. */
|
|
245
|
+
readonly finished: boolean;
|
|
246
|
+
readonly note: string;
|
|
247
|
+
readonly pages: CrawlPage[];
|
|
248
|
+
/** Только удавшийся разбор, без страниц, где его нет. */
|
|
249
|
+
readonly parsed: Record<string, any>[];
|
|
250
|
+
readonly pagesPlanned: number;
|
|
251
|
+
readonly pagesDone: number;
|
|
252
|
+
readonly pagesFailed: number;
|
|
253
|
+
readonly credits: number;
|
|
254
|
+
readonly creditsMax: number;
|
|
255
|
+
/** Смещение следующего среза или null, если это был последний. */
|
|
256
|
+
readonly nextOffset: number | null;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export declare class ParreqError extends Error {
|
|
60
260
|
status: number;
|
|
61
261
|
code: string;
|
|
62
262
|
detail: string;
|
|
@@ -65,22 +265,49 @@ export declare class ParReqError extends Error {
|
|
|
65
265
|
details: Record<string, any>;
|
|
66
266
|
readonly retriable: boolean;
|
|
67
267
|
}
|
|
68
|
-
export declare class BadRequest extends
|
|
69
|
-
export declare class AuthError extends
|
|
70
|
-
export declare class RateLimited extends
|
|
71
|
-
export declare class NoWorkers extends
|
|
72
|
-
export declare class SearchBlocked extends
|
|
73
|
-
export declare class ServerError extends
|
|
268
|
+
export declare class BadRequest extends ParreqError {}
|
|
269
|
+
export declare class AuthError extends ParreqError {}
|
|
270
|
+
export declare class RateLimited extends ParreqError {}
|
|
271
|
+
export declare class NoWorkers extends ParreqError {}
|
|
272
|
+
export declare class SearchBlocked extends ParreqError {}
|
|
273
|
+
export declare class ServerError extends ParreqError {}
|
|
274
|
+
/** 403 feature_not_enabled — надстройка Fetch не подключена ключу. */
|
|
275
|
+
export declare class FeatureNotEnabled extends ParreqError {}
|
|
276
|
+
/** 402 — кредитов на ключе не хватает на этот запрос. */
|
|
277
|
+
export declare class OutOfCredits extends ParreqError {}
|
|
278
|
+
/** `crawlWait` перестал ждать, а задание продолжается: id в `details.job_id`. */
|
|
279
|
+
export declare class CrawlTimeout extends ParreqError {}
|
|
74
280
|
|
|
75
|
-
export declare class
|
|
281
|
+
export declare class Parreq {
|
|
76
282
|
constructor(options: ClientOptions | string);
|
|
77
283
|
search(options: SearchOptions): Promise<SearchResult>;
|
|
78
284
|
google(options: EngineOptions): Promise<SearchResult>;
|
|
79
285
|
yandex(options: EngineOptions): Promise<SearchResult>;
|
|
286
|
+
fetch(options: FetchOptions): Promise<FetchResult>;
|
|
287
|
+
crawl(options: CrawlOptions): Promise<CrawlResult>;
|
|
288
|
+
crawlStatus(jobId: string, options?: { offset?: number; limit?: number }): Promise<CrawlResult>;
|
|
289
|
+
crawlPages(jobId: string, options?: { offset?: number; limit?: number }): Promise<CrawlResult>;
|
|
290
|
+
crawlCancel(jobId: string): Promise<CrawlResult>;
|
|
291
|
+
crawlJobs(options?: { limit?: number }): Promise<CrawlMetadata[]>;
|
|
292
|
+
/**
|
|
293
|
+
* Ждёт задание и возвращает все страницы сразу: правилом — поставит своё,
|
|
294
|
+
* строкой — дождётся уже поставленного.
|
|
295
|
+
*/
|
|
296
|
+
crawlWait(options: CrawlWaitOptions | string): Promise<CrawlResult>;
|
|
297
|
+
screenshot(fetchId: string): Promise<Uint8Array>;
|
|
298
|
+
iframeHtml(fetchId: string): Promise<string>;
|
|
299
|
+
requestInfo(fetchId: string): Promise<Record<string, any>>;
|
|
80
300
|
usage(): Promise<Record<string, any>>;
|
|
81
301
|
meta(): Promise<Record<string, any>>;
|
|
82
302
|
}
|
|
83
303
|
|
|
84
304
|
export declare const DEFAULT_BASE_URL: string;
|
|
85
305
|
export declare const DEFAULT_TIMEOUT_MS: number;
|
|
86
|
-
export
|
|
306
|
+
export declare const DEFAULT_CRAWL_POLL_MS: number;
|
|
307
|
+
export declare const DEFAULT_CRAWL_TIMEOUT_MS: number;
|
|
308
|
+
/** Состояния, после которых задание больше не меняется. */
|
|
309
|
+
export declare const CRAWL_FINAL: Set<string>;
|
|
310
|
+
export default Parreq;
|
|
311
|
+
|
|
312
|
+
/** Имена из 1.0.0, оставлены псевдонимами. */
|
|
313
|
+
export { Parreq as ParReq, ParreqError as ParReqError };
|
package/index.js
CHANGED
|
@@ -1,24 +1,37 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Parreq — клиент поисковой выдачи Google и Яндекса.
|
|
3
3
|
*
|
|
4
4
|
* Зависимостей нет: используется встроенный fetch, поэтому нужен Node 18+.
|
|
5
5
|
* Тянуть axios ради одного запроса значит навязывать чужому проекту лишнее.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
export const DEFAULT_BASE_URL = "https://
|
|
8
|
+
export const DEFAULT_BASE_URL = "https://api.parreq.com";
|
|
9
9
|
// Запрос идёт в настоящий браузер: 3–10 секунд на прогретом профиле и до двух
|
|
10
10
|
// минут, если пришлось решать капчу. Таймаут меньше рвёт нормальные запросы.
|
|
11
11
|
export const DEFAULT_TIMEOUT_MS = 180_000;
|
|
12
12
|
// Коды, которые лечатся повтором. Остальные повторять бессмысленно: неверный
|
|
13
13
|
// параметр и отозванный ключ от этого не исправятся.
|
|
14
14
|
const RETRIABLE = new Set([429, 502, 503, 504]);
|
|
15
|
-
const USER_AGENT = "parreq-node/1.
|
|
15
|
+
const USER_AGENT = "parreq-node/1.4";
|
|
16
|
+
// Обход живёт дольше HTTP-запроса, поэтому у него свои умолчания ожидания.
|
|
17
|
+
// Три секунды между опросами — чтобы задание на десяток страниц не пришлось
|
|
18
|
+
// ждать лишнюю минуту, и чтобы задание на две сотни не превратилось в тысячу
|
|
19
|
+
// лишних запросов.
|
|
20
|
+
export const DEFAULT_CRAWL_POLL_MS = 3_000;
|
|
21
|
+
// Полчаса. Обход на двести страниц столько и идёт; истёкшее ожидание не
|
|
22
|
+
// отменяет задание — оно доходит на сервере, и его забирают по идентификатору.
|
|
23
|
+
export const DEFAULT_CRAWL_TIMEOUT_MS = 1_800_000;
|
|
24
|
+
// Сколько страниц забираем за один заход, когда собираем результат целиком.
|
|
25
|
+
// Потолок ручки — 200, но полсотни страниц с разбором это уже мегабайты.
|
|
26
|
+
const CRAWL_PAGE_SIZE = 50;
|
|
27
|
+
/** Состояния, после которых задание больше не меняется. */
|
|
28
|
+
export const CRAWL_FINAL = new Set(["done", "partial", "failed", "canceled"]);
|
|
16
29
|
|
|
17
30
|
/** Ошибка API. Разбирайте `code`, а не текст: текст переписывается. */
|
|
18
|
-
export class
|
|
31
|
+
export class ParreqError extends Error {
|
|
19
32
|
constructor(status, code, message, { requestId = "", retryAfter = null, details = {} } = {}) {
|
|
20
33
|
super(`${code}: ${message}`);
|
|
21
|
-
this.name = "
|
|
34
|
+
this.name = "ParreqError";
|
|
22
35
|
this.status = status;
|
|
23
36
|
this.code = code;
|
|
24
37
|
this.detail = message;
|
|
@@ -33,12 +46,24 @@ export class ParReqError extends Error {
|
|
|
33
46
|
}
|
|
34
47
|
}
|
|
35
48
|
|
|
36
|
-
export class BadRequest extends
|
|
37
|
-
export class AuthError extends
|
|
38
|
-
export class RateLimited extends
|
|
39
|
-
export class NoWorkers extends
|
|
40
|
-
export class SearchBlocked extends
|
|
41
|
-
export class ServerError extends
|
|
49
|
+
export class BadRequest extends ParreqError {}
|
|
50
|
+
export class AuthError extends ParreqError {}
|
|
51
|
+
export class RateLimited extends ParreqError {}
|
|
52
|
+
export class NoWorkers extends ParreqError {}
|
|
53
|
+
export class SearchBlocked extends ParreqError {}
|
|
54
|
+
export class ServerError extends ParreqError {}
|
|
55
|
+
/** 403 feature_not_enabled — надстройка Fetch не подключена ключу. */
|
|
56
|
+
export class FeatureNotEnabled extends ParreqError {}
|
|
57
|
+
/** 402 — кредитов на ключе не хватает на этот запрос. */
|
|
58
|
+
export class OutOfCredits extends ParreqError {}
|
|
59
|
+
/**
|
|
60
|
+
* `crawlWait` перестал ждать, а задание ещё идёт.
|
|
61
|
+
*
|
|
62
|
+
* Не отказ сервиса, а решение клиента: обход продолжается на сервере и деньги
|
|
63
|
+
* за него спишутся. Идентификатор лежит в `details.job_id` — по нему задание
|
|
64
|
+
* забирают через `crawlStatus` или останавливают через `crawlCancel`.
|
|
65
|
+
*/
|
|
66
|
+
export class CrawlTimeout extends ParreqError {}
|
|
42
67
|
|
|
43
68
|
const BY_CODE = {
|
|
44
69
|
invalid_request: BadRequest,
|
|
@@ -51,6 +76,8 @@ const BY_CODE = {
|
|
|
51
76
|
concurrency_limit: RateLimited,
|
|
52
77
|
no_workers_available: NoWorkers,
|
|
53
78
|
search_blocked: SearchBlocked,
|
|
79
|
+
feature_not_enabled: FeatureNotEnabled,
|
|
80
|
+
out_of_credits: OutOfCredits,
|
|
54
81
|
};
|
|
55
82
|
|
|
56
83
|
function errorFrom(status, body, retryAfter) {
|
|
@@ -92,7 +119,76 @@ export class SearchResult {
|
|
|
92
119
|
get totalResults() { return this.raw.total_results ?? null; }
|
|
93
120
|
}
|
|
94
121
|
|
|
95
|
-
|
|
122
|
+
/**
|
|
123
|
+
* Ответ Fetch. Надстройки приходят только заказанные: `js`, `css` и `network`
|
|
124
|
+
* равны null, если их не просили, и пустому массиву, если просили, а на
|
|
125
|
+
* странице их нет. Разница важна — иначе «не заказывал» неотличимо от «нет».
|
|
126
|
+
*/
|
|
127
|
+
export class FetchResult {
|
|
128
|
+
constructor(raw) {
|
|
129
|
+
this.raw = raw;
|
|
130
|
+
}
|
|
131
|
+
get metadata() { return this.raw.fetch_metadata || {}; }
|
|
132
|
+
get parameters() { return this.raw.fetch_parameters || {}; }
|
|
133
|
+
get id() { return this.metadata.id || ""; }
|
|
134
|
+
get html() { return this.raw.html || ""; }
|
|
135
|
+
get text() { return this.raw.text ?? null; }
|
|
136
|
+
/**
|
|
137
|
+
* Разбор страницы в поля, заказанный через `extract`. null и когда разбор не
|
|
138
|
+
* заказывали, и когда он не удался: отличить одно от другого можно по
|
|
139
|
+
* `metadata.extract_note` — у неудачи там причина, у незаказанного пусто.
|
|
140
|
+
*/
|
|
141
|
+
get parsed() { return this.raw.parsed ?? null; }
|
|
142
|
+
get title() { return this.metadata.title || ""; }
|
|
143
|
+
get finalUrl() { return this.metadata.final_url || ""; }
|
|
144
|
+
get httpStatus() { return this.metadata.http_status || 0; }
|
|
145
|
+
get credits() { return this.metadata.credits || 0; }
|
|
146
|
+
get creditsLeft() { return this.metadata.credits_left || 0; }
|
|
147
|
+
get js() { return this.raw.js ?? null; }
|
|
148
|
+
get css() { return this.raw.css ?? null; }
|
|
149
|
+
get network() { return this.raw.network ?? null; }
|
|
150
|
+
get screenshotUrl() { return this.raw.screenshot_url ?? null; }
|
|
151
|
+
get iframeUrl() { return this.raw.iframe_url ?? null; }
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Ответ обхода: состояние задания и срез его страниц.
|
|
156
|
+
*
|
|
157
|
+
* Одна форма на все ручки обхода — постановку, состояние, остановку, — потому
|
|
158
|
+
* что задание одно и то же в разные моменты жизни. У свежепоставленного
|
|
159
|
+
* большого задания `pages` пуст: сервис ответил 202 и одним `crawl_metadata`,
|
|
160
|
+
* страницы появятся по мере обхода.
|
|
161
|
+
*/
|
|
162
|
+
export class CrawlResult {
|
|
163
|
+
constructor(raw) {
|
|
164
|
+
this.raw = raw;
|
|
165
|
+
}
|
|
166
|
+
get metadata() { return this.raw.crawl_metadata || {}; }
|
|
167
|
+
get parameters() { return this.raw.crawl_parameters || {}; }
|
|
168
|
+
get id() { return this.metadata.id || ""; }
|
|
169
|
+
get status() { return this.metadata.status || ""; }
|
|
170
|
+
/** Дошло ли задание до конца — любого, включая неудачный. */
|
|
171
|
+
get finished() { return CRAWL_FINAL.has(this.status); }
|
|
172
|
+
get note() { return this.metadata.note || ""; }
|
|
173
|
+
/** Страницы этого среза: у каждой `fetch_metadata` и `parsed`. */
|
|
174
|
+
get pages() { return this.raw.pages || []; }
|
|
175
|
+
/**
|
|
176
|
+
* Только удавшийся разбор, без страниц, где его нет. Сахар для главного
|
|
177
|
+
* случая: когда обход заказывали ради `extract`, нужен список товаров, а не
|
|
178
|
+
* список страниц, часть которых пустая.
|
|
179
|
+
*/
|
|
180
|
+
get parsed() { return this.pages.filter((p) => p.parsed).map((p) => p.parsed); }
|
|
181
|
+
get pagesPlanned() { return this.metadata.pages_planned || 0; }
|
|
182
|
+
get pagesDone() { return this.metadata.pages_done || 0; }
|
|
183
|
+
get pagesFailed() { return this.metadata.pages_failed || 0; }
|
|
184
|
+
get credits() { return this.metadata.credits || 0; }
|
|
185
|
+
/** Потолок расхода: столько задание займёт, если пройдёт целиком. */
|
|
186
|
+
get creditsMax() { return this.metadata.credits_max || 0; }
|
|
187
|
+
/** Смещение следующего среза или null, если это был последний. */
|
|
188
|
+
get nextOffset() { return this.raw.next_offset ?? null; }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export class Parreq {
|
|
96
192
|
/**
|
|
97
193
|
* @param {object|string} options ключ строкой либо объект настроек
|
|
98
194
|
* @param {string} options.apiKey ключ вида `pr_…`
|
|
@@ -102,7 +198,7 @@ export class ParReq {
|
|
|
102
198
|
*/
|
|
103
199
|
constructor(options) {
|
|
104
200
|
const opts = typeof options === "string" ? { apiKey: options } : options || {};
|
|
105
|
-
if (!opts.apiKey) throw new Error("нужен ключ
|
|
201
|
+
if (!opts.apiKey) throw new Error("нужен ключ Parreq");
|
|
106
202
|
this.apiKey = opts.apiKey;
|
|
107
203
|
this.baseUrl = (opts.baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, "");
|
|
108
204
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -131,22 +227,223 @@ export class ParReq {
|
|
|
131
227
|
/** Сахар: `client.yandex({ q: "кофемашина", gl: "by", hl: "ru" })`. */
|
|
132
228
|
async yandex(options) { return this.search({ ...options, engine: "yandex" }); }
|
|
133
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Страница по адресу глазами браузера.
|
|
232
|
+
*
|
|
233
|
+
* Бесплатно приходит разметка. Платные надстройки — через `include`:
|
|
234
|
+
* ["js", "css", "network", "screenshot", "iframe"] либо "all".
|
|
235
|
+
* `headless: false` — сбор браузером с окном, тоже платно.
|
|
236
|
+
*
|
|
237
|
+
* Разметку можно подрезать: `select` отдаёт один блок, `startAt` отрезает
|
|
238
|
+
* всё до блока, `exclude` вырезает лишнее, `strip` убирает скрипты, стили и
|
|
239
|
+
* комментарии. Селектор — CSS, XPath или просто название блока.
|
|
240
|
+
*
|
|
241
|
+
* `extract` — разбор страницы в поля, описанные словами:
|
|
242
|
+
* `{ name: "Название товара", price: "Цена числом, без валюты" }`. Результат
|
|
243
|
+
* приходит в `parsed`, причина неудачи — в `metadata.extract_note`. Стоит три
|
|
244
|
+
* кредита, и они не списываются, если разбор не состоялся.
|
|
245
|
+
*/
|
|
246
|
+
async fetch({ url, device = "desktop", gl = "us", hl = "en", include,
|
|
247
|
+
headless = true, waitMs, waitFor, networkMs, scroll = false,
|
|
248
|
+
startAt, select, selectAll = false, exclude, strip = false,
|
|
249
|
+
text = false, extract } = {}) {
|
|
250
|
+
if (!url) throw new Error("нужен адрес url");
|
|
251
|
+
const body = { url, device, gl, hl, headless, scroll, select_all: selectAll, strip, text };
|
|
252
|
+
if (include) body.include = Array.isArray(include) ? include.join(",") : include;
|
|
253
|
+
if (waitMs != null) body.wait_ms = waitMs;
|
|
254
|
+
if (waitFor) body.wait_for = waitFor;
|
|
255
|
+
if (networkMs != null) body.network_ms = networkMs;
|
|
256
|
+
if (startAt) body.start_at = startAt;
|
|
257
|
+
if (select) body.select = select;
|
|
258
|
+
if (exclude) body.exclude = Array.isArray(exclude) ? exclude.join(",") : exclude;
|
|
259
|
+
if (extract) body.extract = { ...extract };
|
|
260
|
+
return new FetchResult(await this.#request("POST", "/v1/fetch", null, body));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Скачать скриншот выполненного запроса как Uint8Array.
|
|
265
|
+
*
|
|
266
|
+
* Отдельным вызовом, а не полем ответа: картинка весит сотни килобайт, и
|
|
267
|
+
* загонять её в json значит утроить размер и заставить платить за трафик
|
|
268
|
+
* даже тех, кому нужна была только ссылка.
|
|
269
|
+
*/
|
|
270
|
+
async screenshot(fetchId) {
|
|
271
|
+
return this.#binary(`/v1/fetch/${fetchId}/screenshot`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Копия страницы, пригодная для вставки в iframe, строкой. */
|
|
275
|
+
async iframeHtml(fetchId) {
|
|
276
|
+
const bytes = await this.#binary(`/v1/fetch/${fetchId}/iframe`);
|
|
277
|
+
return new TextDecoder().decode(bytes);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Что известно о выполненном запросе: статус, расход, артефакты. */
|
|
281
|
+
async requestInfo(fetchId) { return this.#request("GET", `/v1/fetch/${fetchId}`); }
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Поставить обход: пройти страницы по правилу и разобрать их.
|
|
285
|
+
*
|
|
286
|
+
* Идти дальше можно двумя способами, и хотя бы один обязателен: `follow` —
|
|
287
|
+
* селектор ссылок на карточки, `next` — селектор кнопки следующей страницы.
|
|
288
|
+
* Их можно сочетать: `next` листает список, `follow` заходит в каждую
|
|
289
|
+
* карточку. `depth` ограничивает, насколько глубоко заходить, `sameSite` не
|
|
290
|
+
* выпускает обход на чужие домены, `allow` и `deny` — списки регулярных
|
|
291
|
+
* выражений по адресу.
|
|
292
|
+
*
|
|
293
|
+
* `fetch` — обычные параметры Fetch для каждой страницы одним объектом:
|
|
294
|
+
* `{ device: "mobile", wait_for: ".price", strip: true }`.
|
|
295
|
+
*
|
|
296
|
+
* Маленький обход — до десяти страниц и без `async` — возвращается целиком:
|
|
297
|
+
* сервис дожидается его сам. Всё, что больше, приходит одним `crawl_metadata`
|
|
298
|
+
* со статусом `queued`, а страницы забираются потом через `crawlStatus` и
|
|
299
|
+
* `crawlPages`. Проще всего не разбирать этот случай руками, а взять
|
|
300
|
+
* `crawlWait`.
|
|
301
|
+
*/
|
|
302
|
+
async crawl({ url, pages, follow, next, depth = 1, sameSite = true, allow, deny,
|
|
303
|
+
extract, fetch: fetchOptions, async: background = false } = {}) {
|
|
304
|
+
if (!url) throw new Error("нужен адрес url");
|
|
305
|
+
if (!pages) throw new Error("нужен pages: сколько страниц обходим");
|
|
306
|
+
if (!follow && !next) {
|
|
307
|
+
throw new Error("нужен follow или next: без них обходу некуда идти");
|
|
308
|
+
}
|
|
309
|
+
const body = { url, pages, depth, same_site: sameSite };
|
|
310
|
+
if (follow) body.follow = follow;
|
|
311
|
+
if (next) body.next = next;
|
|
312
|
+
if (allow) body.allow = Array.isArray(allow) ? allow : [allow];
|
|
313
|
+
if (deny) body.deny = Array.isArray(deny) ? deny : [deny];
|
|
314
|
+
if (extract) body.extract = { ...extract };
|
|
315
|
+
if (fetchOptions) body.fetch = { ...fetchOptions };
|
|
316
|
+
if (background) body.async = true;
|
|
317
|
+
return new CrawlResult(await this.#request("POST", "/v1/crawl", null, body));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Состояние задания и срез его страниц с `offset`. */
|
|
321
|
+
async crawlStatus(jobId, { offset = 0, limit = 50 } = {}) {
|
|
322
|
+
return new CrawlResult(await this.#request("GET", `/v1/crawl/${jobId}`, { offset, limit }));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Только страницы задания, без его состояния.
|
|
327
|
+
*
|
|
328
|
+
* Тем же типом, что и `crawlStatus`: у ответа нет `crawl_metadata`, зато есть
|
|
329
|
+
* `pages` и `nextOffset` — а разбирать их полезно одним кодом.
|
|
330
|
+
*/
|
|
331
|
+
async crawlPages(jobId, { offset = 0, limit = 50 } = {}) {
|
|
332
|
+
return new CrawlResult(
|
|
333
|
+
await this.#request("GET", `/v1/crawl/${jobId}/pages`, { offset, limit }));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Остановить обход. Пройденные страницы остаются и остаются платными. */
|
|
337
|
+
async crawlCancel(jobId) {
|
|
338
|
+
return new CrawlResult(await this.#request("POST", `/v1/crawl/${jobId}/cancel`));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** Свои задания обхода, свежие сверху. */
|
|
342
|
+
/**
|
|
343
|
+
* Свои задания обхода, свежие сверху.
|
|
344
|
+
*
|
|
345
|
+
* Отдаём массив, а не конверт `{ jobs: [...] }`, которым отвечает ручка: по
|
|
346
|
+
* нему хочется пройтись циклом, а не разворачивать его на каждом вызове.
|
|
347
|
+
*/
|
|
348
|
+
async crawlJobs({ limit = 20 } = {}) {
|
|
349
|
+
const got = await this.#request("GET", "/v1/crawl", { limit });
|
|
350
|
+
return (got && got.jobs) || [];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Дождаться обхода целиком. Один вызов вместо цикла.
|
|
355
|
+
*
|
|
356
|
+
* Принимает и правило, и уже поставленное задание — по тому, что дали:
|
|
357
|
+
*
|
|
358
|
+
* await client.crawlWait({ url, pages: 30, next: "a.next", follow: "a.card" });
|
|
359
|
+
* await client.crawlWait(job.id); // то же для чужого задания
|
|
360
|
+
*
|
|
361
|
+
* Разбираемся по типу: строка — это идентификатор уже поставленного задания,
|
|
362
|
+
* объект — правило, которое надо поставить. Два имени для одного и того же
|
|
363
|
+
* ожидания были бы хуже: у человека всё равно один вопрос — «разбуди меня,
|
|
364
|
+
* когда обход закончится».
|
|
365
|
+
*
|
|
366
|
+
* Своё задание ставим в фоне намеренно, даже когда страниц мало: держать
|
|
367
|
+
* соединение открытым полчаса нечестно по отношению к сети между вами и
|
|
368
|
+
* сервисом — прокси и балансировщики рвут такие соединения без объяснений.
|
|
369
|
+
* Поэтому HTTP остаётся коротким, а ожидание — здесь: раз в `pollMs` клиент
|
|
370
|
+
* спрашивает состояние, а по завершении забирает все страницы срезами по
|
|
371
|
+
* `pageSize`.
|
|
372
|
+
*
|
|
373
|
+
* Остальные параметры — те же, что у `crawl`.
|
|
374
|
+
*
|
|
375
|
+
* Если за `timeoutMs` задание не закончилось, бросается `CrawlTimeout`. Обход
|
|
376
|
+
* при этом продолжается: остановить его — это `crawlCancel`, забрать позже —
|
|
377
|
+
* `crawlStatus` по `details.job_id`.
|
|
378
|
+
*/
|
|
379
|
+
async crawlWait(options) {
|
|
380
|
+
const isJobId = typeof options === "string";
|
|
381
|
+
const { pollMs = DEFAULT_CRAWL_POLL_MS, timeoutMs = DEFAULT_CRAWL_TIMEOUT_MS,
|
|
382
|
+
pageSize = CRAWL_PAGE_SIZE, ...rule } = isJobId ? {} : options || {};
|
|
383
|
+
const jobId = isJobId ? options : (await this.crawl({ ...rule, async: true })).id;
|
|
384
|
+
const deadline = Date.now() + timeoutMs;
|
|
385
|
+
|
|
386
|
+
// Опрашиваем с limit=1: пока задание идёт, страницы нужны не нам, а только
|
|
387
|
+
// его состояние. Тянуть на каждом опросе полсотни страниц — это тот же
|
|
388
|
+
// результат, переданный по сети столько раз, сколько было опросов.
|
|
389
|
+
let state;
|
|
390
|
+
for (;;) {
|
|
391
|
+
state = await this.crawlStatus(jobId, { limit: 1 });
|
|
392
|
+
if (state.finished) break;
|
|
393
|
+
if (Date.now() >= deadline) {
|
|
394
|
+
throw new CrawlTimeout(0, "crawl_timeout",
|
|
395
|
+
`задание ${jobId} не уложилось в ${Math.round(timeoutMs / 1000)} с ` +
|
|
396
|
+
`(пройдено ${state.pagesDone} из ${state.pagesPlanned}); ` +
|
|
397
|
+
"обход продолжается, заберите его позже",
|
|
398
|
+
{ details: { job_id: jobId, status: state.status, pages_done: state.pagesDone } });
|
|
399
|
+
}
|
|
400
|
+
await sleep(pollMs);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Страницы забираем отдельно и постранично: у обхода на две сотни страниц
|
|
404
|
+
// с разбором один ответ целиком весит десятки мегабайт, и ручка его не
|
|
405
|
+
// отдаст.
|
|
406
|
+
const all = [];
|
|
407
|
+
let offset = 0;
|
|
408
|
+
for (;;) {
|
|
409
|
+
const chunk = await this.crawlPages(jobId, { offset, limit: pageSize });
|
|
410
|
+
all.push(...chunk.pages);
|
|
411
|
+
const next = chunk.nextOffset;
|
|
412
|
+
// Страховка от бесконечного круга: сервис отдаёт null на последнем срезе,
|
|
413
|
+
// но пустой срез без null означал бы вечный цикл.
|
|
414
|
+
if (next == null || chunk.pages.length === 0 || next <= offset) break;
|
|
415
|
+
offset = next;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return new CrawlResult({ ...state.raw, pages: all, next_offset: null });
|
|
419
|
+
}
|
|
420
|
+
|
|
134
421
|
/** Остаток лимитов по своему ключу. */
|
|
135
422
|
async usage() { return this.#request("GET", "/v1/usage"); }
|
|
136
423
|
|
|
137
424
|
/** Справочники: движки, устройства, страны, языки, секции. */
|
|
138
425
|
async meta() { return this.#request("GET", "/v1/meta"); }
|
|
139
426
|
|
|
140
|
-
async #request(method, path, params) {
|
|
427
|
+
async #request(method, path, params, body) {
|
|
428
|
+
const raw = await this.#retrying(method, path, params, body);
|
|
429
|
+
return raw.length ? JSON.parse(new TextDecoder().decode(raw)) : {};
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Артефакт как есть: скриншот — это jpeg, а не json. */
|
|
433
|
+
async #binary(path) {
|
|
434
|
+
return this.#retrying("GET", path, null, null);
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
async #retrying(method, path, params, body) {
|
|
141
438
|
const url = new URL(this.baseUrl + path);
|
|
142
439
|
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
|
|
143
440
|
|
|
144
441
|
let last;
|
|
145
442
|
for (let attempt = 0; attempt <= this.retries; attempt++) {
|
|
146
443
|
try {
|
|
147
|
-
return await this.#once(method, url);
|
|
444
|
+
return await this.#once(method, url, body);
|
|
148
445
|
} catch (err) {
|
|
149
|
-
if (!(err instanceof
|
|
446
|
+
if (!(err instanceof ParreqError) || !err.retriable || attempt === this.retries) throw err;
|
|
150
447
|
last = err;
|
|
151
448
|
// ждём столько, сколько попросил сервер: у суточной квоты это время до
|
|
152
449
|
// полуночи, и «подождать 5 секунд» там ничего не изменит
|
|
@@ -156,19 +453,22 @@ export class ParReq {
|
|
|
156
453
|
throw last;
|
|
157
454
|
}
|
|
158
455
|
|
|
159
|
-
async #once(method, url) {
|
|
456
|
+
async #once(method, url, body) {
|
|
160
457
|
const controller = new AbortController();
|
|
161
458
|
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
459
|
+
const headers = {
|
|
460
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
461
|
+
Accept: "application/json",
|
|
462
|
+
"User-Agent": USER_AGENT,
|
|
463
|
+
};
|
|
464
|
+
if (body != null) headers["Content-Type"] = "application/json";
|
|
162
465
|
let resp;
|
|
163
466
|
try {
|
|
164
467
|
resp = await fetch(url, {
|
|
165
468
|
method,
|
|
166
469
|
signal: controller.signal,
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
Accept: "application/json",
|
|
170
|
-
"User-Agent": USER_AGENT,
|
|
171
|
-
},
|
|
470
|
+
body: body == null ? undefined : JSON.stringify(body),
|
|
471
|
+
headers,
|
|
172
472
|
});
|
|
173
473
|
} catch (err) {
|
|
174
474
|
const reason = err.name === "AbortError" ? `превышен таймаут ${this.timeoutMs} мс` : String(err);
|
|
@@ -177,19 +477,27 @@ export class ParReq {
|
|
|
177
477
|
clearTimeout(timer);
|
|
178
478
|
}
|
|
179
479
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
body = text ? JSON.parse(text) : {};
|
|
184
|
-
} catch {
|
|
185
|
-
body = { error: { code: `http_${resp.status}`, message: text.slice(0, 200) } };
|
|
186
|
-
}
|
|
480
|
+
// Читаем байтами: этой же дорогой ходят скриншоты, а jpeg через строку не
|
|
481
|
+
// проходит без потерь. Разбор в json остаётся выше, где он уместен.
|
|
482
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
187
483
|
if (!resp.ok) {
|
|
484
|
+
const text = new TextDecoder().decode(bytes);
|
|
485
|
+
let failure;
|
|
486
|
+
try {
|
|
487
|
+
failure = text ? JSON.parse(text) : {};
|
|
488
|
+
} catch {
|
|
489
|
+
failure = { error: { code: `http_${resp.status}`, message: text.slice(0, 200) } };
|
|
490
|
+
}
|
|
188
491
|
const ra = resp.headers.get("retry-after");
|
|
189
|
-
throw errorFrom(resp.status,
|
|
492
|
+
throw errorFrom(resp.status, failure, ra ? Number(ra) : null);
|
|
190
493
|
}
|
|
191
|
-
return
|
|
494
|
+
return bytes;
|
|
192
495
|
}
|
|
193
496
|
}
|
|
194
497
|
|
|
195
|
-
export default
|
|
498
|
+
export default Parreq;
|
|
499
|
+
|
|
500
|
+
// --- совместимость с 1.0.0 -------------------------------------------------
|
|
501
|
+
// Тогда класс назывался ParReq — с заглавной R. Написание бренда изменилось на
|
|
502
|
+
// Parreq, но ломать чужой импорт из-за этого нельзя.
|
|
503
|
+
export { Parreq as ParReq, ParreqError as ParReqError };
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "parreq-client",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "Клиент
|
|
3
|
+
"version": "1.4.0",
|
|
4
|
+
"description": "Клиент Parreq: поисковая выдача Google и Яндекса и загрузка страниц браузером",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
7
7
|
"types": "./index.d.ts",
|
|
@@ -25,10 +25,12 @@
|
|
|
25
25
|
"google",
|
|
26
26
|
"yandex",
|
|
27
27
|
"search-api",
|
|
28
|
-
"scraping"
|
|
28
|
+
"scraping",
|
|
29
|
+
"headless-browser",
|
|
30
|
+
"screenshot"
|
|
29
31
|
],
|
|
30
32
|
"license": "MIT",
|
|
31
|
-
"homepage": "https://
|
|
33
|
+
"homepage": "https://parreq.com",
|
|
32
34
|
"scripts": {
|
|
33
35
|
"smoke": "node smoke.mjs"
|
|
34
36
|
}
|