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.
Files changed (4) hide show
  1. package/README.md +8 -8
  2. package/index.d.ts +236 -9
  3. package/index.js +340 -32
  4. package/package.json +6 -4
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # parreq
2
2
 
3
- Клиент [ParReq](https://req.akuraq.dev) — поисковая выдача Google и Яндекса в
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 { ParReq } from "parreq";
11
+ import { Parreq } from "parreq-client";
12
12
 
13
- const client = new ParReq({ apiKey: "pr_ВАШКЛЮЧ" });
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 { ParReq, ParReqError, NoWorkers, RateLimited } from "parreq";
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 ParReqError) console.log(err.status, err.code, err.requestId);
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 ParReq({ apiKey: "pr_…", retries: 0 }); // выключить повторы
69
- new ParReq({ apiKey: "pr_…", timeoutMs: 240_000 }); // запрос с капчей бывает долгим
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 declare class ParReqError extends Error {
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 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 {}
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 ParReq {
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 default ParReq;
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
- * ParReq — клиент поисковой выдачи Google и Яндекса.
2
+ * Parreq — клиент поисковой выдачи Google и Яндекса.
3
3
  *
4
4
  * Зависимостей нет: используется встроенный fetch, поэтому нужен Node 18+.
5
5
  * Тянуть axios ради одного запроса значит навязывать чужому проекту лишнее.
6
6
  */
7
7
 
8
- export const DEFAULT_BASE_URL = "https://req.akuraq.dev";
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.0";
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 ParReqError extends Error {
31
+ export class ParreqError extends Error {
19
32
  constructor(status, code, message, { requestId = "", retryAfter = null, details = {} } = {}) {
20
33
  super(`${code}: ${message}`);
21
- this.name = "ParReqError";
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 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 {}
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
- export class ParReq {
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("нужен ключ ParReq");
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 ParReqError) || !err.retriable || attempt === this.retries) throw err;
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
- headers: {
168
- Authorization: `Bearer ${this.apiKey}`,
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
- 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
- }
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, body, ra ? Number(ra) : null);
492
+ throw errorFrom(resp.status, failure, ra ? Number(ra) : null);
190
493
  }
191
- return body;
494
+ return bytes;
192
495
  }
193
496
  }
194
497
 
195
- export default ParReq;
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.0.0",
4
- "description": "Клиент ParReq: поисковая выдача Google и Яндекса в JSON",
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://req.akuraq.dev",
33
+ "homepage": "https://parreq.com",
32
34
  "scripts": {
33
35
  "smoke": "node smoke.mjs"
34
36
  }