jsonseo 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 +510 -0
- package/dist/cjs/client.d.ts +71 -0
- package/dist/cjs/client.js +135 -0
- package/dist/cjs/common.d.ts +40 -0
- package/dist/cjs/common.js +2 -0
- package/dist/cjs/errors.d.ts +71 -0
- package/dist/cjs/errors.js +123 -0
- package/dist/cjs/http.d.ts +77 -0
- package/dist/cjs/http.js +307 -0
- package/dist/cjs/index.d.ts +9 -0
- package/dist/cjs/index.js +23 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/params.d.ts +361 -0
- package/dist/cjs/params.js +2 -0
- package/dist/cjs/responses.d.ts +352 -0
- package/dist/cjs/responses.js +2 -0
- package/dist/esm/client.d.ts +71 -0
- package/dist/esm/client.js +131 -0
- package/dist/esm/common.d.ts +40 -0
- package/dist/esm/common.js +1 -0
- package/dist/esm/errors.d.ts +71 -0
- package/dist/esm/errors.js +106 -0
- package/dist/esm/http.d.ts +77 -0
- package/dist/esm/http.js +302 -0
- package/dist/esm/index.d.ts +9 -0
- package/dist/esm/index.js +5 -0
- package/dist/esm/package.json +3 -0
- package/dist/esm/params.d.ts +361 -0
- package/dist/esm/params.js +1 -0
- package/dist/esm/responses.d.ts +352 -0
- package/dist/esm/responses.js +1 -0
- package/package.json +64 -0
package/dist/cjs/http.js
ADDED
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.HttpClient = void 0;
|
|
4
|
+
exports.encodeParams = encodeParams;
|
|
5
|
+
const errors_js_1 = require("./errors.js");
|
|
6
|
+
const DEFAULT_BASE_URL = 'https://jsonseo.ru/api';
|
|
7
|
+
/**
|
|
8
|
+
* Ключи проверяются типом: забытая здесь настройка из ClientOptions
|
|
9
|
+
* отвергалась бы у пользователя как незнакомая.
|
|
10
|
+
*/
|
|
11
|
+
const KNOWN_OPTIONS = {
|
|
12
|
+
apiKey: true,
|
|
13
|
+
baseUrl: true,
|
|
14
|
+
timeoutMs: true,
|
|
15
|
+
attempts: true,
|
|
16
|
+
retryDelayMs: true,
|
|
17
|
+
maxRetryDelayMs: true,
|
|
18
|
+
auth: true,
|
|
19
|
+
userAgent: true,
|
|
20
|
+
fetch: true,
|
|
21
|
+
};
|
|
22
|
+
const KNOWN_OPTION_NAMES = Object.keys(KNOWN_OPTIONS);
|
|
23
|
+
const VERSION = '1.0.0';
|
|
24
|
+
/**
|
|
25
|
+
* Транспорт: собирает запрос, разбирает ответ и решает, повторять ли отказ.
|
|
26
|
+
*
|
|
27
|
+
* @internal
|
|
28
|
+
*/
|
|
29
|
+
class HttpClient {
|
|
30
|
+
constructor(options) {
|
|
31
|
+
if (typeof options.apiKey !== 'string' || options.apiKey.trim() === '') {
|
|
32
|
+
throw new errors_js_1.InvalidArgumentError('Нужен API-ключ: возьмите его в личном кабинете на https://jsonseo.ru.');
|
|
33
|
+
}
|
|
34
|
+
// undefined из спреда частичного конфига — не настройка, а её отсутствие.
|
|
35
|
+
const unknown = Object.keys(options).filter((name) => !KNOWN_OPTION_NAMES.includes(name) && options[name] !== undefined);
|
|
36
|
+
if (unknown.length > 0) {
|
|
37
|
+
throw new errors_js_1.InvalidArgumentError(`Неизвестные настройки клиента: ${unknown.join(', ')}. Доступны: ${KNOWN_OPTION_NAMES.join(', ')}.`);
|
|
38
|
+
}
|
|
39
|
+
// NaN сюда приезжает из Number(process.env.ЧЕГО_НЕТ), и без проверки
|
|
40
|
+
// сравнение с ним всегда ложно — повторы платного запроса не кончались бы.
|
|
41
|
+
if (options.attempts !== undefined && (!Number.isInteger(options.attempts) || options.attempts < 1)) {
|
|
42
|
+
throw new errors_js_1.InvalidArgumentError(`Настройка attempts ожидает целое число не меньше 1, получено: ${String(options.attempts)}.`);
|
|
43
|
+
}
|
|
44
|
+
if (options.auth !== undefined && options.auth !== 'header' && options.auth !== 'query') {
|
|
45
|
+
throw new errors_js_1.InvalidArgumentError(`Настройка auth принимает "header" или "query", получено: ${String(options.auth)}.`);
|
|
46
|
+
}
|
|
47
|
+
const fetchImpl = options.fetch ?? (typeof fetch === 'function' ? fetch : undefined);
|
|
48
|
+
if (!fetchImpl) {
|
|
49
|
+
throw new errors_js_1.InvalidArgumentError('В этой среде нет глобального fetch. Нужен Node 18+, Bun или Deno — либо передайте свою реализацию настройкой fetch.');
|
|
50
|
+
}
|
|
51
|
+
this.apiKey = options.apiKey.trim();
|
|
52
|
+
this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, '');
|
|
53
|
+
this.timeoutMs = options.timeoutMs ?? 300000;
|
|
54
|
+
this.attempts = options.attempts ?? 3;
|
|
55
|
+
this.retryDelayMs = options.retryDelayMs ?? 1000;
|
|
56
|
+
this.maxRetryDelayMs = options.maxRetryDelayMs ?? 30000;
|
|
57
|
+
this.auth = options.auth ?? 'header';
|
|
58
|
+
this.userAgent = options.userAgent ?? `jsonseo-node/${VERSION}`;
|
|
59
|
+
this.fetchImpl = fetchImpl;
|
|
60
|
+
}
|
|
61
|
+
/** Запрос, ответ которого разбирается как JSON. */
|
|
62
|
+
async json(path, params, options) {
|
|
63
|
+
const body = await this.send(path, params, 'application/json', options);
|
|
64
|
+
try {
|
|
65
|
+
return JSON.parse(body);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
// Тело кладём в ошибку: страница выдачи уже оплачена.
|
|
69
|
+
throw new errors_js_1.ParseError(`Ответ JSON SEO API не разобрался как JSON: ${error.message}.`, body);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/** Запрос, ответ которого возвращается строкой без разбора. */
|
|
73
|
+
text(path, params, options) {
|
|
74
|
+
return this.send(path, params, 'application/xml, text/xml', options);
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Выполняет запрос, повторяя те отказы, за которые сервис не берёт денег:
|
|
78
|
+
* 429, 5xx и обрывы связи до того, как ответ начал приходить.
|
|
79
|
+
*/
|
|
80
|
+
async send(path, params, accept, options) {
|
|
81
|
+
const query = encodeParams(params);
|
|
82
|
+
if (this.auth === 'query') {
|
|
83
|
+
query.set('key', this.apiKey);
|
|
84
|
+
}
|
|
85
|
+
const headers = {
|
|
86
|
+
Accept: accept,
|
|
87
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
88
|
+
'User-Agent': this.userAgent,
|
|
89
|
+
};
|
|
90
|
+
if (this.auth === 'header') {
|
|
91
|
+
headers.Authorization = `Bearer ${this.apiKey}`;
|
|
92
|
+
}
|
|
93
|
+
// Всегда POST: длинные списки фраз в GET не помещаются.
|
|
94
|
+
const url = `${this.baseUrl}/${path.replace(/^\/+/, '')}`;
|
|
95
|
+
const body = query.toString();
|
|
96
|
+
for (let attempt = 0;; attempt++) {
|
|
97
|
+
let response;
|
|
98
|
+
try {
|
|
99
|
+
response = await this.fetchOnce(url, headers, body, options);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
// Таймаут и обрыв на середине тела не повторяем: выдача уже
|
|
103
|
+
// собрана и оплачена.
|
|
104
|
+
if (error instanceof errors_js_1.NetworkError &&
|
|
105
|
+
!(error instanceof errors_js_1.TimeoutError) &&
|
|
106
|
+
!(error instanceof errors_js_1.IncompleteResponseError) &&
|
|
107
|
+
!this.isLastAttempt(attempt)) {
|
|
108
|
+
try {
|
|
109
|
+
await sleep(this.backoff(attempt), options?.signal);
|
|
110
|
+
}
|
|
111
|
+
catch (aborted) {
|
|
112
|
+
// Отменили во время паузы: причину ожидания не теряем.
|
|
113
|
+
aborted.cause = error;
|
|
114
|
+
throw aborted;
|
|
115
|
+
}
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
throw error;
|
|
119
|
+
}
|
|
120
|
+
if (response.ok) {
|
|
121
|
+
return response.body;
|
|
122
|
+
}
|
|
123
|
+
const { retryAfter } = response;
|
|
124
|
+
const error = (0, errors_js_1.apiErrorFor)(response.status, response.body, parseQuietly(response.body), retryAfter);
|
|
125
|
+
// Проснуться раньше названного срока — снова получить тот же отказ.
|
|
126
|
+
// Ждать дольше потолка не станем: отдаём ошибку.
|
|
127
|
+
if (this.isLastAttempt(attempt) ||
|
|
128
|
+
!isRetryable(response.status) ||
|
|
129
|
+
(retryAfter !== null && retryAfter * 1000 > this.maxRetryDelayMs)) {
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
// Не раньше, чем просит сервис, и не чаще своего бэкоффа:
|
|
133
|
+
// Retry-After прошедшей датой даёт ноль.
|
|
134
|
+
const pause = retryAfter === null ? this.backoff(attempt) : Math.max(retryAfter * 1000, this.backoff(attempt));
|
|
135
|
+
await sleep(pause, options?.signal);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Один заход в сеть: таймаут и внешняя отмена сводятся в один сигнал.
|
|
140
|
+
* Тело читается здесь же — fetch отдаёт ответ сразу по заголовкам, и
|
|
141
|
+
* снаружи застрявшая передача висела бы без ограничения по времени.
|
|
142
|
+
*/
|
|
143
|
+
async fetchOnce(url, headers, body, options) {
|
|
144
|
+
const external = options?.signal;
|
|
145
|
+
if (external?.aborted) {
|
|
146
|
+
throw new errors_js_1.AbortError('Запрос отменён до отправки.');
|
|
147
|
+
}
|
|
148
|
+
const controller = new AbortController();
|
|
149
|
+
const timeoutMs = options?.timeoutMs ?? this.timeoutMs;
|
|
150
|
+
let timedOut = false;
|
|
151
|
+
const timer = setTimeout(() => {
|
|
152
|
+
timedOut = true;
|
|
153
|
+
controller.abort();
|
|
154
|
+
}, timeoutMs);
|
|
155
|
+
const forward = () => controller.abort();
|
|
156
|
+
external?.addEventListener('abort', forward);
|
|
157
|
+
try {
|
|
158
|
+
const response = await this.fetchImpl(url, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers,
|
|
161
|
+
body,
|
|
162
|
+
signal: controller.signal,
|
|
163
|
+
redirect: 'follow',
|
|
164
|
+
});
|
|
165
|
+
let text;
|
|
166
|
+
try {
|
|
167
|
+
text = await response.text();
|
|
168
|
+
}
|
|
169
|
+
catch (error) {
|
|
170
|
+
if (timedOut) {
|
|
171
|
+
throw new errors_js_1.TimeoutError(`Ответа от JSON SEO API не дождались за ${timeoutMs} мс.`, error);
|
|
172
|
+
}
|
|
173
|
+
if (external?.aborted) {
|
|
174
|
+
throw new errors_js_1.AbortError('Запрос отменён.');
|
|
175
|
+
}
|
|
176
|
+
// Тело дочитать не вышло, а выдача уже оплачена — отдельный
|
|
177
|
+
// класс ошибки, повторять такое нельзя.
|
|
178
|
+
throw new errors_js_1.IncompleteResponseError(`Ответ от JSON SEO API пришёл не целиком: ${error.message}.`, error);
|
|
179
|
+
}
|
|
180
|
+
return {
|
|
181
|
+
status: response.status,
|
|
182
|
+
ok: response.ok,
|
|
183
|
+
retryAfter: parseRetryAfter(response.headers.get('retry-after')),
|
|
184
|
+
body: text,
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
if (error instanceof errors_js_1.JsonSeoError) {
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
if (timedOut) {
|
|
192
|
+
throw new errors_js_1.TimeoutError(`Ответа от JSON SEO API не дождались за ${timeoutMs} мс.`, error);
|
|
193
|
+
}
|
|
194
|
+
if (external?.aborted) {
|
|
195
|
+
throw new errors_js_1.AbortError('Запрос отменён.');
|
|
196
|
+
}
|
|
197
|
+
throw new errors_js_1.NetworkError(`Запрос к JSON SEO API не удался: ${error.message}.`, error);
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
clearTimeout(timer);
|
|
201
|
+
external?.removeEventListener('abort', forward);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
/** Попытки нумеруются с нуля: при attempts = 3 последняя — вторая. */
|
|
205
|
+
isLastAttempt(attempt) {
|
|
206
|
+
return attempt + 1 >= this.attempts;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Пауза удваивается с каждой попыткой; случайная добавка разводит
|
|
210
|
+
* параллельные запросы, чтобы они не вернулись разом.
|
|
211
|
+
*/
|
|
212
|
+
backoff(attempt) {
|
|
213
|
+
const delay = this.retryDelayMs * 2 ** attempt;
|
|
214
|
+
// Потолок накладывается после добавки, иначе она бы его превышала.
|
|
215
|
+
return Math.min(delay + delay * 0.25 * Math.random(), this.maxRetryDelayMs);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
exports.HttpClient = HttpClient;
|
|
219
|
+
/** Приводит параметры к тому виду, в каком их ждёт форма запроса. */
|
|
220
|
+
function encodeParams(params) {
|
|
221
|
+
const query = new URLSearchParams();
|
|
222
|
+
for (const [name, value] of Object.entries(params)) {
|
|
223
|
+
if (value === null || value === undefined) {
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (Array.isArray(value)) {
|
|
227
|
+
// Пустой список — «параметр не задан»: от region= сервис откажет.
|
|
228
|
+
if (value.length === 0) {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
// Фразы — переводом строки: запятая в них встречается.
|
|
232
|
+
query.set(name, value.map((item, index) => scalar(`${name}[${index}]`, item)).join(name === 'phrases' ? '\n' : ','));
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
query.set(name, scalar(name, value));
|
|
236
|
+
}
|
|
237
|
+
return query;
|
|
238
|
+
}
|
|
239
|
+
function scalar(name, value) {
|
|
240
|
+
if (typeof value === 'boolean') {
|
|
241
|
+
return value ? '1' : '0';
|
|
242
|
+
}
|
|
243
|
+
if (typeof value === 'number') {
|
|
244
|
+
if (!Number.isFinite(value)) {
|
|
245
|
+
throw new errors_js_1.InvalidArgumentError(`Параметр ${name} получил не число: ${String(value)}.`);
|
|
246
|
+
}
|
|
247
|
+
return String(value);
|
|
248
|
+
}
|
|
249
|
+
if (typeof value === 'string') {
|
|
250
|
+
return value;
|
|
251
|
+
}
|
|
252
|
+
throw new errors_js_1.InvalidArgumentError(`Параметр ${name} должен быть строкой, числом, флагом или массивом таких значений.`);
|
|
253
|
+
}
|
|
254
|
+
function isRetryable(status) {
|
|
255
|
+
return status === 429 || status >= 500;
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* Сколько секунд просит подождать сервис. RFC 9110 разрешает число секунд
|
|
259
|
+
* и HTTP-дату, разбираются обе.
|
|
260
|
+
*/
|
|
261
|
+
function parseRetryAfter(header) {
|
|
262
|
+
if (header === null) {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
const value = header.trim();
|
|
266
|
+
if (/^\d+$/.test(value)) {
|
|
267
|
+
return Number(value);
|
|
268
|
+
}
|
|
269
|
+
const timestamp = Date.parse(value);
|
|
270
|
+
if (Number.isNaN(timestamp)) {
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
return Math.max(0, Math.round((timestamp - Date.now()) / 1000));
|
|
274
|
+
}
|
|
275
|
+
/** Тело ошибки может быть и не JSON — тогда подробностей просто нет. */
|
|
276
|
+
function parseQuietly(body) {
|
|
277
|
+
try {
|
|
278
|
+
const parsed = JSON.parse(body);
|
|
279
|
+
return parsed !== null && typeof parsed === 'object' ? parsed : {};
|
|
280
|
+
}
|
|
281
|
+
catch {
|
|
282
|
+
return {};
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
/**
|
|
286
|
+
* Прерываемая пауза: без учёта сигнала отмена замечалась бы только через
|
|
287
|
+
* всю паузу целиком, до 30 секунд при значениях по умолчанию.
|
|
288
|
+
*/
|
|
289
|
+
function sleep(ms, signal) {
|
|
290
|
+
if (!signal) {
|
|
291
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
292
|
+
}
|
|
293
|
+
if (signal.aborted) {
|
|
294
|
+
return Promise.reject(new errors_js_1.AbortError('Запрос отменён.'));
|
|
295
|
+
}
|
|
296
|
+
return new Promise((resolve, reject) => {
|
|
297
|
+
const timer = setTimeout(() => {
|
|
298
|
+
signal.removeEventListener('abort', onAbort);
|
|
299
|
+
resolve();
|
|
300
|
+
}, ms);
|
|
301
|
+
function onAbort() {
|
|
302
|
+
clearTimeout(timer);
|
|
303
|
+
reject(new errors_js_1.AbortError('Запрос отменён.'));
|
|
304
|
+
}
|
|
305
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
306
|
+
});
|
|
307
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { JsonSeoClient } from './client.js';
|
|
2
|
+
export { encodeParams } from './http.js';
|
|
3
|
+
export type { AuthMode, ClientOptions, FetchLike, RequestOptions } from './http.js';
|
|
4
|
+
export { AbortError, IncompleteResponseError, InvalidArgumentError, JsonSeoApiError, JsonSeoError, NetworkError, ParseError, PaymentRequiredError, RateLimitError, ServiceUnavailableError, TimeoutError, UnauthorizedError, ValidationError, } from './errors.js';
|
|
5
|
+
export type * from './common.js';
|
|
6
|
+
export type * from './params.js';
|
|
7
|
+
export type * from './responses.js';
|
|
8
|
+
import { JsonSeoClient } from './client.js';
|
|
9
|
+
export default JsonSeoClient;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.ValidationError = exports.UnauthorizedError = exports.TimeoutError = exports.ServiceUnavailableError = exports.RateLimitError = exports.PaymentRequiredError = exports.ParseError = exports.NetworkError = exports.JsonSeoError = exports.JsonSeoApiError = exports.InvalidArgumentError = exports.IncompleteResponseError = exports.AbortError = exports.encodeParams = exports.JsonSeoClient = void 0;
|
|
4
|
+
var client_js_1 = require("./client.js");
|
|
5
|
+
Object.defineProperty(exports, "JsonSeoClient", { enumerable: true, get: function () { return client_js_1.JsonSeoClient; } });
|
|
6
|
+
var http_js_1 = require("./http.js");
|
|
7
|
+
Object.defineProperty(exports, "encodeParams", { enumerable: true, get: function () { return http_js_1.encodeParams; } });
|
|
8
|
+
var errors_js_1 = require("./errors.js");
|
|
9
|
+
Object.defineProperty(exports, "AbortError", { enumerable: true, get: function () { return errors_js_1.AbortError; } });
|
|
10
|
+
Object.defineProperty(exports, "IncompleteResponseError", { enumerable: true, get: function () { return errors_js_1.IncompleteResponseError; } });
|
|
11
|
+
Object.defineProperty(exports, "InvalidArgumentError", { enumerable: true, get: function () { return errors_js_1.InvalidArgumentError; } });
|
|
12
|
+
Object.defineProperty(exports, "JsonSeoApiError", { enumerable: true, get: function () { return errors_js_1.JsonSeoApiError; } });
|
|
13
|
+
Object.defineProperty(exports, "JsonSeoError", { enumerable: true, get: function () { return errors_js_1.JsonSeoError; } });
|
|
14
|
+
Object.defineProperty(exports, "NetworkError", { enumerable: true, get: function () { return errors_js_1.NetworkError; } });
|
|
15
|
+
Object.defineProperty(exports, "ParseError", { enumerable: true, get: function () { return errors_js_1.ParseError; } });
|
|
16
|
+
Object.defineProperty(exports, "PaymentRequiredError", { enumerable: true, get: function () { return errors_js_1.PaymentRequiredError; } });
|
|
17
|
+
Object.defineProperty(exports, "RateLimitError", { enumerable: true, get: function () { return errors_js_1.RateLimitError; } });
|
|
18
|
+
Object.defineProperty(exports, "ServiceUnavailableError", { enumerable: true, get: function () { return errors_js_1.ServiceUnavailableError; } });
|
|
19
|
+
Object.defineProperty(exports, "TimeoutError", { enumerable: true, get: function () { return errors_js_1.TimeoutError; } });
|
|
20
|
+
Object.defineProperty(exports, "UnauthorizedError", { enumerable: true, get: function () { return errors_js_1.UnauthorizedError; } });
|
|
21
|
+
Object.defineProperty(exports, "ValidationError", { enumerable: true, get: function () { return errors_js_1.ValidationError; } });
|
|
22
|
+
const client_js_2 = require("./client.js");
|
|
23
|
+
exports.default = client_js_2.JsonSeoClient;
|