@rrrublev/wb-private-api 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,283 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { fetch, Agent } = require("undici");
4
+ const { stringify } = require("qs");
5
+ const Constants = require("./Constants");
6
+
7
+ const TOKEN_FILE = path.resolve(__dirname, "../.wbaas_token");
8
+
9
+ const noopLogger = {
10
+ debug() {},
11
+ info() {},
12
+ warn() {},
13
+ error() {},
14
+ };
15
+
16
+ function createHttpError(status, url, method) {
17
+ const err = new Error(`Request failed with status code ${status}`);
18
+ err.response = { status };
19
+ err.config = { url, method };
20
+ return err;
21
+ }
22
+
23
+ function sleep(ms) {
24
+ return new Promise((resolve) => setTimeout(resolve, ms));
25
+ }
26
+
27
+ // Домены из исходников WB (urls.json), для которых подтверждён proxy-путь /__internal/<subdomain>/
28
+ const PROXY_DOMAINS = new Set([
29
+ "catalog", "search", "card", "suggests",
30
+ "recom", "meta", "banners", "user-geo-data",
31
+ "u-catalog", "u-search", "u-card", "u-suggests",
32
+ "u-recom", "search-tags", "u-search-tags",
33
+ ]);
34
+
35
+ function toProxyUrl(url) {
36
+ return url.replace(
37
+ /^https:\/\/([\w-]+)\.wb\.ru\//,
38
+ (match, subdomain) =>
39
+ PROXY_DOMAINS.has(subdomain)
40
+ ? `https://www.wildberries.ru/__internal/${subdomain}/`
41
+ : match
42
+ );
43
+ }
44
+
45
+ class Session {
46
+ constructor(config) {
47
+ this._config = config;
48
+ this._logger = config.logger || noopLogger;
49
+ this._agent = new Agent({
50
+ keepAliveTimeout: 30000,
51
+ keepAliveMaxTimeout: 30000,
52
+ connections: config.maxSockets,
53
+ });
54
+ this.defaults = {
55
+ headers: { common: {} },
56
+ };
57
+ }
58
+
59
+ _hasToken() {
60
+ return !!this.defaults.headers.common["Cookie"];
61
+ }
62
+
63
+ /** @returns {Promise<{status: number, data: any}>} */
64
+ async get(url, options = {}) {
65
+ const { params = {}, headers = {}, retryOptions, responseType = "auto" } = options;
66
+
67
+ const resolved = this._hasToken() ? toProxyUrl(url) : url;
68
+ const queryString = Object.keys(params).length
69
+ ? "?" + stringify(params, { arrayFormat: "comma", encode: false })
70
+ : "";
71
+ const fullUrl = resolved + queryString;
72
+
73
+ const mergedHeaders = {
74
+ ...this._config.headers,
75
+ ...this.defaults.headers.common,
76
+ ...headers,
77
+ };
78
+
79
+ const retries = retryOptions?.retries ?? this._config.retries;
80
+ const retryCondition =
81
+ retryOptions?.retryCondition ??
82
+ ((ctx) => ctx.status === 429 || ctx.status >= 500 || Boolean(ctx.error));
83
+
84
+ let lastError;
85
+ for (let attempt = 0; attempt <= retries; attempt++) {
86
+ if (attempt > 0) {
87
+ const delay = Math.min(Math.pow(2, attempt) * 1000 + Math.random() * 1000, 10000);
88
+ this._logger.debug("Retry attempt", { attempt, url });
89
+ await sleep(delay);
90
+ }
91
+
92
+ const startTime = Date.now();
93
+ let response;
94
+ try {
95
+ response = await fetch(fullUrl, {
96
+ method: "GET",
97
+ headers: mergedHeaders,
98
+ dispatcher: this._agent,
99
+ signal: AbortSignal.timeout(this._config.timeout),
100
+ });
101
+ } catch (error) {
102
+ lastError = error;
103
+ const shouldRetry =
104
+ attempt < retries &&
105
+ retryCondition({ status: null, error, attempt, url, method: "get" });
106
+ if (!shouldRetry) {
107
+ error.config = { url, method: "get" };
108
+ throw error;
109
+ }
110
+ continue;
111
+ }
112
+
113
+ const duration = Date.now() - startTime;
114
+ if (duration > 5000) {
115
+ this._logger.warn("Slow request detected", { url, duration });
116
+ }
117
+
118
+ if (response.status >= 200 && response.status < 300) {
119
+ let data;
120
+ if (responseType === "text") {
121
+ data = await response.text();
122
+ } else if (responseType === "json") {
123
+ data = await response.json();
124
+ } else {
125
+ const contentType = response.headers.get("content-type") || "";
126
+ if (contentType.includes("application/json")) {
127
+ data = await response.json();
128
+ } else {
129
+ const text = await response.text();
130
+ const trimmed = text.trimStart();
131
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
132
+ try { data = JSON.parse(text); } catch { data = text; }
133
+ } else {
134
+ data = text;
135
+ }
136
+ }
137
+ }
138
+ return { status: response.status, data };
139
+ }
140
+
141
+ const shouldRetry =
142
+ attempt < retries &&
143
+ retryCondition({ status: response.status, error: null, attempt, url, method: "get" });
144
+
145
+ if (shouldRetry) {
146
+ lastError = createHttpError(response.status, url, "get");
147
+ continue;
148
+ }
149
+
150
+ const err = createHttpError(response.status, url, "get");
151
+ this._logger.error("Request failed", { url, status: response.status });
152
+ throw err;
153
+ }
154
+
155
+ if (lastError) {
156
+ lastError.config = { url, method: "get" };
157
+ throw lastError;
158
+ }
159
+ }
160
+
161
+ /** @returns {Promise<{status: number, data: any}>} */
162
+ async post(url, body, options = {}) {
163
+ const { headers = {}, retryOptions } = options;
164
+
165
+ const mergedHeaders = {
166
+ ...this._config.headers,
167
+ ...this.defaults.headers.common,
168
+ "Content-Type": "application/json",
169
+ ...headers,
170
+ };
171
+
172
+ const retries = retryOptions?.retries ?? this._config.retries;
173
+ const retryCondition =
174
+ retryOptions?.retryCondition ??
175
+ ((ctx) => ctx.status === 429 || ctx.status >= 500 || Boolean(ctx.error));
176
+
177
+ let lastError;
178
+ for (let attempt = 0; attempt <= retries; attempt++) {
179
+ if (attempt > 0) {
180
+ const delay = Math.min(Math.pow(2, attempt) * 1000 + Math.random() * 1000, 10000);
181
+ this._logger.debug("Retry attempt", { attempt, url });
182
+ await sleep(delay);
183
+ }
184
+
185
+ let response;
186
+ try {
187
+ response = await fetch(url, {
188
+ method: "POST",
189
+ headers: mergedHeaders,
190
+ body: JSON.stringify(body),
191
+ dispatcher: this._agent,
192
+ signal: AbortSignal.timeout(this._config.timeout),
193
+ });
194
+ } catch (error) {
195
+ lastError = error;
196
+ const shouldRetry =
197
+ attempt < retries &&
198
+ retryCondition({ status: null, error, attempt, url, method: "post" });
199
+ if (!shouldRetry) {
200
+ error.config = { url, method: "post" };
201
+ throw error;
202
+ }
203
+ continue;
204
+ }
205
+
206
+ if (response.status >= 200 && response.status < 300) {
207
+ let data;
208
+ const contentType = response.headers.get("content-type") || "";
209
+ if (contentType.includes("application/json")) {
210
+ data = await response.json();
211
+ } else {
212
+ const text = await response.text();
213
+ const trimmed = text.trimStart();
214
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) {
215
+ try { data = JSON.parse(text); } catch { data = text; }
216
+ } else {
217
+ data = text;
218
+ }
219
+ }
220
+ return { status: response.status, data };
221
+ }
222
+
223
+ const shouldRetry =
224
+ attempt < retries &&
225
+ retryCondition({ status: response.status, error: null, attempt, url, method: "post" });
226
+
227
+ if (shouldRetry) {
228
+ lastError = createHttpError(response.status, url, "post");
229
+ continue;
230
+ }
231
+
232
+ const err = createHttpError(response.status, url, "post");
233
+ this._logger.error("Request failed", { url, status: response.status });
234
+ throw err;
235
+ }
236
+
237
+ if (lastError) {
238
+ lastError.config = { url, method: "post" };
239
+ throw lastError;
240
+ }
241
+ }
242
+ }
243
+
244
+ class SessionBuilder {
245
+ static create(options = {}) {
246
+ const config = {
247
+ timeout: options.timeout || 30000,
248
+ retries: options.retries ?? 3,
249
+ maxSockets: options.maxSockets || 10,
250
+ logger: options.logger || noopLogger,
251
+ headers: {
252
+ "User-Agent": options.userAgent || Constants.USERAGENT,
253
+ "Accept-Encoding": "gzip, deflate, br",
254
+ "Accept": "application/json, text/plain, */*",
255
+ "Accept-Language": "en-US,en;q=0.9,ru;q=0.8",
256
+ "Origin": "https://www.wildberries.ru",
257
+ "Referer": "https://www.wildberries.ru/",
258
+ "Cache-Control": "no-cache",
259
+ },
260
+ };
261
+ return new Session(config);
262
+ }
263
+
264
+ static readToken() {
265
+ try {
266
+ const data = JSON.parse(fs.readFileSync(TOKEN_FILE, "utf8"));
267
+ if (data.token && data.expires_at > Date.now()) return data.token;
268
+ } catch {}
269
+ return null;
270
+ }
271
+
272
+ /**
273
+ * Устанавливает антибот-токен в заголовок Cookie сессии.
274
+ * Только для запросов к wildberries.ru/__internal/*.
275
+ * @param {Session} session
276
+ * @param {string} token — значение cookie x_wbaas_token
277
+ */
278
+ static setAntibotToken(session, token) {
279
+ session.defaults.headers.common["Cookie"] = `x_wbaas_token=${token}`;
280
+ }
281
+ }
282
+
283
+ module.exports = SessionBuilder;
package/src/Utils.js ADDED
@@ -0,0 +1,154 @@
1
+ /* eslint-disable no-nested-ternary */
2
+ const format = require("string-format");
3
+ const Constants = require("./Constants");
4
+
5
+ // vol = Math.floor(nm_id / 100000); basket number = index + 1 (zero-padded).
6
+ // Source: WB JS volHostV2() in product_dist JS (staticbasket_route_map).
7
+ const BASKETS = [
8
+ [0, 143], // basket-01
9
+ [144, 287], // basket-02
10
+ [288, 431], // basket-03
11
+ [432, 719], // basket-04
12
+ [720, 1007], // basket-05
13
+ [1008, 1061], // basket-06
14
+ [1062, 1115], // basket-07
15
+ [1116, 1169], // basket-08
16
+ [1170, 1313], // basket-09
17
+ [1314, 1601], // basket-10
18
+ [1602, 1655], // basket-11
19
+ [1656, 1919], // basket-12
20
+ [1920, 2045], // basket-13
21
+ [2046, 2189], // basket-14
22
+ [2190, 2405], // basket-15
23
+ [2406, 2621], // basket-16
24
+ [2622, 2837], // basket-17
25
+ [2838, 3053], // basket-18
26
+ [3054, 3269], // basket-19
27
+ [3270, 3485], // basket-20
28
+ [3486, 3701], // basket-21
29
+ [3702, 3917], // basket-22
30
+ [3918, 4133], // basket-23
31
+ [4134, 4349], // basket-24
32
+ [4350, 4565], // basket-25
33
+ [4566, 4877], // basket-26
34
+ [4878, 5189], // basket-27
35
+ [5190, 5501], // basket-28
36
+ [5502, 5813], // basket-29
37
+ [5814, 6125], // basket-30
38
+ [6126, 6437], // basket-31
39
+ [6438, 6749], // basket-32
40
+ [6750, 7061], // basket-33
41
+ [7062, 7373], // basket-34
42
+ [7374, 7685], // basket-35
43
+ [7686, 7997], // basket-36
44
+ [7998, 8309], // basket-37
45
+ [8310, 8741], // basket-38
46
+ [8742, 9173], // basket-39
47
+ [9174, 9605], // basket-40
48
+ [9606, 10373], // basket-41
49
+ [10374, 11141], // basket-42
50
+ [11142, 11909], // basket-43
51
+ [11910, Infinity], // basket-44
52
+ ];
53
+
54
+ // vol = nm_id % 144; basket number = index + 1 (zero-padded).
55
+ // Source: WB JS videonme_route_map switch statement in volVideoHost().
56
+ const VIDEO_BASKETS = [
57
+ [0, 11], // basket-01
58
+ [12, 23], // basket-02
59
+ [24, 35], // basket-03
60
+ [36, 47], // basket-04
61
+ [48, 59], // basket-05
62
+ [60, 71], // basket-06
63
+ [72, 83], // basket-07
64
+ [84, 95], // basket-08
65
+ [96, 107], // basket-09
66
+ [108, 119], // basket-10
67
+ [120, 131], // basket-11
68
+ [132, 143], // basket-12
69
+ ];
70
+
71
+ const getBasketNumber = (productId) => {
72
+ const vol = Math.floor(productId / 100000);
73
+ const index = BASKETS.findIndex(([from, to]) => vol >= from && vol <= to);
74
+ return String(index + 1).padStart(2, "0");
75
+ };
76
+
77
+ const getVideoBasket = (vol) => {
78
+ const index = VIDEO_BASKETS.findIndex(([from, to]) => vol >= from && vol <= to);
79
+ return String(index >= 0 ? index + 1 : VIDEO_BASKETS.length + 1).padStart(2, "0");
80
+ };
81
+
82
+ const imageURL = (productId, imageType = "SMALL", order = 1) => {
83
+ const vol = Math.floor(productId / 100000);
84
+ const part = Math.floor(productId / 1000);
85
+ const basket = getBasketNumber(productId);
86
+ const random = Date.now();
87
+ const URL = Constants.URLS.IMAGES[imageType];
88
+
89
+ return `${format(URL, basket, vol, part, productId, order)}?r=${random}`;
90
+ };
91
+
92
+ /**
93
+ * Generates a video URL for a WB product.
94
+ * Mirrors WB's own urlVideoProduct() from their frontend JS.
95
+ *
96
+ * @param {number|string} productId - nm_id of the product
97
+ * @param {"hls"|"mp4"} [videoFormat] - "hls" → m3u8 playlist, "mp4" → preview file (360p)
98
+ * @param {string} [quality] - HLS quality; WB always uses "1440p". MP4 preview is always "360p"
99
+ * @returns {string} full video URL
100
+ */
101
+ const videoURL = (productId, videoFormat = "hls", quality = "1440p") => {
102
+ const id = parseInt(productId, 10);
103
+ const vol = id % 144;
104
+ const part = Math.floor(id / 10000);
105
+ const basket = getVideoBasket(vol);
106
+ if (videoFormat === "mp4") {
107
+ return format(Constants.URLS.VIDEO.MP4, basket, vol, part, id, quality);
108
+ }
109
+ return format(Constants.URLS.VIDEO.HLS, basket, vol, part, id, quality);
110
+ };
111
+
112
+ const brandImageURL = (brandId) => format(Constants.URLS.BRAND.IMAGE, brandId);
113
+
114
+ const genNewUserID = function () {
115
+ const t = Math.floor(new Date().getTime() / 1e3);
116
+ const e = Math.floor(Math.random() * Math.pow(2, 30)).toString() + t.toString();
117
+ return e;
118
+ };
119
+
120
+ function pad(value) {
121
+ return String(value).padStart(2, "0");
122
+ }
123
+
124
+ function formatDateForQueryId(date = new Date()) {
125
+ return [
126
+ date.getFullYear(),
127
+ pad(date.getMonth() + 1),
128
+ pad(date.getDate()),
129
+ pad(date.getHours()),
130
+ pad(date.getMinutes()),
131
+ pad(date.getSeconds()),
132
+ ].join("");
133
+ }
134
+
135
+ const getQueryIdForSearch = function () {
136
+ return `qid${genNewUserID()}${formatDateForQueryId()}`;
137
+ };
138
+
139
+ const Utils = {
140
+ Card: {
141
+ imageURL,
142
+ getBasketNumber,
143
+ videoURL,
144
+ getVideoBasket,
145
+ },
146
+ Brand: {
147
+ imageURL: brandImageURL,
148
+ },
149
+ Search: {
150
+ getQueryIdForSearch,
151
+ },
152
+ };
153
+
154
+ module.exports = Utils;
@@ -0,0 +1,37 @@
1
+ const Constants = require("./Constants");
2
+
3
+ class WBCatalog {
4
+ /* Creating a new instance of the class WBCatalog. */
5
+ constructor(data) {
6
+ this.catalog_type = data.catalog_type;
7
+ this.catalog_value = data.catalog_value;
8
+ this.pages = data.pages;
9
+ this.products = data.products;
10
+ this.totalProducts = data.totalProducts;
11
+ }
12
+
13
+ /**
14
+ * It takes a page number and returns a slice of plain product objects
15
+ * for that page.
16
+ * @param {number} number - the page number (1-based)
17
+ * @returns {array} - A slice of plain product objects from the catalog.
18
+ */
19
+ page(number) {
20
+ if (!Number.isInteger(number) || number < 1) return [];
21
+ const startIndex = (number - 1) * Constants.PRODUCTS_PER_PAGE;
22
+ if (startIndex >= this.products.length) return [];
23
+ return this.products.slice(startIndex, startIndex + Constants.PRODUCTS_PER_PAGE);
24
+ }
25
+
26
+ /**
27
+ * It returns the position of the product in the products array, or -1 if the
28
+ * product is not in the array
29
+ * @param {number} productId - The SKU of the product.
30
+ * @returns {number} - The position of the product in the array.
31
+ */
32
+ getPosition(productId) {
33
+ return this.products.findIndex((item) => item.id === productId);
34
+ }
35
+ }
36
+
37
+ module.exports = WBCatalog;
@@ -0,0 +1,26 @@
1
+ const Constants = require("./Constants");
2
+
3
+ class WBFeedback {
4
+ constructor(feedback) {
5
+ Object.assign(this, feedback);
6
+ }
7
+
8
+ /**
9
+ * It takes a string as an argument, and if that string is not 'min', it adds
10
+ * 'SizeUri' to the end of it. Then it returns an array of photos, where each
11
+ * photo is a string that is the concatenation of the base URL for feedback
12
+ * images, and the photo's size URI
13
+ * @param [size=min] - The size of the image you want to get.
14
+ * @returns An array of the photos with the size specified.
15
+ */
16
+ getPhotos(size = "min") {
17
+ const field = `${size}SizeUri`;
18
+ const photos = Array.isArray(this.photos) ? this.photos : [];
19
+ return photos
20
+ .map((photo) => photo?.[field])
21
+ .filter(Boolean)
22
+ .map((p) => Constants.URLS.IMAGES.FEEDBACK_BASE + p);
23
+ }
24
+ }
25
+
26
+ module.exports = WBFeedback;