@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.
- package/README.md +220 -0
- package/index.js +11 -0
- package/package.json +43 -0
- package/src/Constants.js +1181 -0
- package/src/SessionBuilder.js +283 -0
- package/src/Utils.js +154 -0
- package/src/WBCatalog.js +37 -0
- package/src/WBFeedback.js +26 -0
- package/src/WBPrivateAPI.js +527 -0
- package/src/WBProduct.js +295 -0
- package/src/WBQuestion.js +7 -0
package/src/WBProduct.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/* eslint-disable no-nested-ternary */
|
|
2
|
+
const format = require("string-format");
|
|
3
|
+
const Constants = require("./Constants");
|
|
4
|
+
const SessionBuilder = require("./SessionBuilder");
|
|
5
|
+
const WBFeedback = require("./WBFeedback");
|
|
6
|
+
const WBQuestion = require("./WBQuestion");
|
|
7
|
+
const { getBasketNumber, videoURL } = require("./Utils").Card;
|
|
8
|
+
|
|
9
|
+
function numToUint8Array(r) {
|
|
10
|
+
const t = new Uint8Array(8);
|
|
11
|
+
for (let n = 0; n < 8; n++) ((t[n] = r % 256), (r = Math.floor(r / 256)));
|
|
12
|
+
return t;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function crc16Arc(r) {
|
|
16
|
+
const t = numToUint8Array(r);
|
|
17
|
+
let n = 0;
|
|
18
|
+
for (let i = 0; i < t.length; i++) {
|
|
19
|
+
n ^= t[i];
|
|
20
|
+
for (let j = 0; j < 8; j++) (1 & n) > 0 ? (n = (n >> 1) ^ 40961) : (n >>= 1);
|
|
21
|
+
}
|
|
22
|
+
return n;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
class WBProduct {
|
|
26
|
+
stocks = [];
|
|
27
|
+
promo = {};
|
|
28
|
+
feedbacks = [];
|
|
29
|
+
_rawResponse = {};
|
|
30
|
+
|
|
31
|
+
constructor(product, { session, destination } = {}) {
|
|
32
|
+
this.session = session || SessionBuilder.create();
|
|
33
|
+
if (!session) {
|
|
34
|
+
const token = SessionBuilder.readToken();
|
|
35
|
+
if (token) SessionBuilder.setAntibotToken(this.session, token);
|
|
36
|
+
}
|
|
37
|
+
this.destination = destination || Constants.DESTINATIONS.MOSCOW;
|
|
38
|
+
this.dest = this.destination?.ids?.at(-1) ?? null;
|
|
39
|
+
if (typeof product !== "number") {
|
|
40
|
+
Object.assign(this, product);
|
|
41
|
+
} else {
|
|
42
|
+
this.id = product;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
static async create(productId, options = {}) {
|
|
47
|
+
const instance = new WBProduct(productId, options);
|
|
48
|
+
await Promise.all([instance.getProductData(), instance.getDetailsData(), instance.getSellerData()]);
|
|
49
|
+
await instance.getQuestionsCount();
|
|
50
|
+
return instance;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The function returns the value of the afterSale property of the price
|
|
55
|
+
* @returns The afterSale price.
|
|
56
|
+
*/
|
|
57
|
+
get currentPrice() {
|
|
58
|
+
return this._rawResponse.details?.sizes?.[0]?.price?.product;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* It takes the array of stocks, and for each stock, it adds the quantity of
|
|
63
|
+
* that stock to the sum
|
|
64
|
+
* @returns The total number of stocks.
|
|
65
|
+
*/
|
|
66
|
+
get totalStocks() {
|
|
67
|
+
return (this._rawResponse.details?.sizes?.[0]?.stocks || []).reduce((sum, x) => sum + x.qty, 0);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
_cardUrl(urlTemplate) {
|
|
71
|
+
const basket = getBasketNumber(this.id);
|
|
72
|
+
const vol = Math.floor(this.id / 100000);
|
|
73
|
+
const part = Math.floor(this.id / 1000);
|
|
74
|
+
return format(urlTemplate, basket, vol, part, this.id);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async getProductData() {
|
|
78
|
+
const res = await this.session.get(this._cardUrl(Constants.URLS.PRODUCT.CARD));
|
|
79
|
+
Object.assign(this._rawResponse, res.data);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async getSellerData() {
|
|
83
|
+
const res = await this.session.get(this._cardUrl(Constants.URLS.PRODUCT.SELLERS));
|
|
84
|
+
Object.assign(this._rawResponse, {
|
|
85
|
+
seller: res.data,
|
|
86
|
+
supplier_id: res.data.supplierId,
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async getDetailsData() {
|
|
91
|
+
const options = {
|
|
92
|
+
params: {
|
|
93
|
+
appType: Constants.APPTYPES.DESKTOP,
|
|
94
|
+
curr: Constants.CURRENCIES.RUB,
|
|
95
|
+
dest: this.dest,
|
|
96
|
+
spp: "30",
|
|
97
|
+
lang: Constants.LOCALES.RU,
|
|
98
|
+
nm: this.id,
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const res = await this.session.get(Constants.URLS.PRODUCT.DETAILS, options);
|
|
103
|
+
const rawData = res.data?.products?.[0] ?? null;
|
|
104
|
+
Object.assign(this._rawResponse, { details: rawData });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* If the product has stocks, return the stocks. If the product has sizes,
|
|
109
|
+
* return the stocks of the first size. If the product doesn't have sizes, get
|
|
110
|
+
* the product data and return the stocks
|
|
111
|
+
* @returns {object} - The stocks of the product.
|
|
112
|
+
*/
|
|
113
|
+
async getStocks() {
|
|
114
|
+
if (!this._rawResponse?.details?.sizes) {
|
|
115
|
+
await this.getDetailsData();
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return this._rawResponse?.details?.sizes?.[0]?.stocks || [];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* It returns the promo object for a product, but if it doesn't exist, it calls
|
|
123
|
+
* the getProductData function to get the product data, and then calls itself
|
|
124
|
+
* again to get the promo object
|
|
125
|
+
* @returns {object} - the product.promo object.
|
|
126
|
+
*/
|
|
127
|
+
async getPromo() {
|
|
128
|
+
if (!this._rawResponse.imt_id) {
|
|
129
|
+
await this.getProductData();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if ("panelPromoId" in this.promo) {
|
|
133
|
+
return this.promo;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if ("panelPromoId" in this._rawResponse) {
|
|
137
|
+
this.promo = {
|
|
138
|
+
active: true,
|
|
139
|
+
panelPromoId: this._rawResponse.panelPromoId,
|
|
140
|
+
promoTextCard: this._rawResponse.promoTextCard,
|
|
141
|
+
promoTextCat: this._rawResponse.promoTextCat,
|
|
142
|
+
};
|
|
143
|
+
return this.promo;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
this.promo = {
|
|
147
|
+
active: false,
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
return this.promo;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* It gets all feedbacks.
|
|
155
|
+
* @param [page=0] - page number
|
|
156
|
+
* @returns An array of WBFeedback objects
|
|
157
|
+
*/
|
|
158
|
+
async getFeedbacks() {
|
|
159
|
+
const imt_id = this.imt_id ?? this._rawResponse.imt_id;
|
|
160
|
+
if (!imt_id) {
|
|
161
|
+
this.feedbacks = [];
|
|
162
|
+
return [];
|
|
163
|
+
}
|
|
164
|
+
const partition_id = crc16Arc(imt_id) % 100 >= 50 ? "2" : "1";
|
|
165
|
+
const url = format(Constants.URLS.PRODUCT.FEEDBACKS, partition_id, imt_id);
|
|
166
|
+
const res = await this.session.get(url);
|
|
167
|
+
this.feedbacks = (res.data.feedbacks || []).map((fb) => new WBFeedback(fb));
|
|
168
|
+
return this.feedbacks;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* It returns the total number of questions for a given imt_id
|
|
173
|
+
* @returns The total number of questions for the product.
|
|
174
|
+
*/
|
|
175
|
+
async getQuestionsCount() {
|
|
176
|
+
const imtId = this.imt_id ?? this._rawResponse.imt_id;
|
|
177
|
+
if (!imtId) {
|
|
178
|
+
this.totalQuestions = 0;
|
|
179
|
+
return 0;
|
|
180
|
+
}
|
|
181
|
+
const res = await this.session.get(Constants.URLS.PRODUCT.QUESTIONS, {
|
|
182
|
+
params: { imtId, onlyCount: true },
|
|
183
|
+
});
|
|
184
|
+
this.totalQuestions = res.data?.count ?? 0;
|
|
185
|
+
return this.totalQuestions;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Fetches all available questions for the product.
|
|
190
|
+
*
|
|
191
|
+
* The WB API hard-limits questions to ~510 records (skip < 510).
|
|
192
|
+
* When `totalQuestions` exceeds this limit the result is truncated.
|
|
193
|
+
* Check `result.truncated` to detect partial data.
|
|
194
|
+
*
|
|
195
|
+
* @returns {{ items: WBQuestion[], totalQuestions: number, fetchedQuestions: number, truncated: boolean }}
|
|
196
|
+
*/
|
|
197
|
+
async getQuestions() {
|
|
198
|
+
if (!("totalQuestions" in this)) {
|
|
199
|
+
await this.getQuestionsCount();
|
|
200
|
+
}
|
|
201
|
+
const totalPages = Math.ceil(this.totalQuestions / Constants.QUESTIONS_PER_PAGE);
|
|
202
|
+
const allQuestions = [];
|
|
203
|
+
for (let page = 1; page <= totalPages; page++) {
|
|
204
|
+
const pageQuestions = await this._fetchQuestionsPage(page);
|
|
205
|
+
if (pageQuestions.length === 0) break;
|
|
206
|
+
allQuestions.push(...pageQuestions);
|
|
207
|
+
}
|
|
208
|
+
const result = {
|
|
209
|
+
items: allQuestions,
|
|
210
|
+
totalQuestions: this.totalQuestions,
|
|
211
|
+
fetchedQuestions: allQuestions.length,
|
|
212
|
+
truncated: allQuestions.length < this.totalQuestions,
|
|
213
|
+
};
|
|
214
|
+
this.questions = result.items;
|
|
215
|
+
this.questionsMeta = result;
|
|
216
|
+
return result;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async _fetchQuestionsPage(page) {
|
|
220
|
+
const imt_id = this.imt_id ?? this._rawResponse.imt_id;
|
|
221
|
+
const res = await this.session.get(Constants.URLS.PRODUCT.QUESTIONS, {
|
|
222
|
+
params: {
|
|
223
|
+
imtId: imt_id,
|
|
224
|
+
skip: (page - 1) * Constants.QUESTIONS_PER_PAGE,
|
|
225
|
+
take: Constants.QUESTIONS_PER_PAGE,
|
|
226
|
+
},
|
|
227
|
+
});
|
|
228
|
+
return res.data.questions.map((q) => new WBQuestion(q));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Returns video info for the product.
|
|
233
|
+
*
|
|
234
|
+
* WB JS source always requests "1440p" for HLS and "360p" for the MP4 preview —
|
|
235
|
+
* both quality values are hardcoded in WB's frontend (product_dist JS).
|
|
236
|
+
* hasVideo is determined by bit 4 (0x10) of viewFlags from the card details API,
|
|
237
|
+
* with fallback to media.has_video from card.json.
|
|
238
|
+
*
|
|
239
|
+
* Fetches the HLS playlist to get chunk count and duration.
|
|
240
|
+
* The full video is available only as HLS (.ts chunks).
|
|
241
|
+
* The MP4 preview (360p) is a single short file — not the full video.
|
|
242
|
+
*
|
|
243
|
+
* @param {string} [quality="1440p"] - HLS quality (WB always uses "1440p")
|
|
244
|
+
* @returns {Promise<{
|
|
245
|
+
* hasVideo: boolean,
|
|
246
|
+
* quality: string,
|
|
247
|
+
* playlistUrl: string,
|
|
248
|
+
* duration: number,
|
|
249
|
+
* chunks: number,
|
|
250
|
+
* hls: string[],
|
|
251
|
+
* mp4Preview: string
|
|
252
|
+
* } | {hasVideo: false}>}
|
|
253
|
+
*/
|
|
254
|
+
async getVideo(quality = "1440p") {
|
|
255
|
+
// bit 4 (16) of viewFlags = hasVideo (from WB source: _q.hasVideo = BigInt(16))
|
|
256
|
+
const viewFlags = this._rawResponse?.details?.viewFlags;
|
|
257
|
+
const hasVideo = viewFlags != null ? !!(viewFlags & 16) : this._rawResponse?.media?.has_video;
|
|
258
|
+
if (!hasVideo) return { hasVideo: false };
|
|
259
|
+
|
|
260
|
+
const playlistUrl = videoURL(this.id, "hls", quality);
|
|
261
|
+
|
|
262
|
+
let chunks = 0;
|
|
263
|
+
let duration = 0;
|
|
264
|
+
try {
|
|
265
|
+
const res = await this.session.get(playlistUrl, { responseType: "text" });
|
|
266
|
+
const m3u8 = typeof res.data === "string" ? res.data : "";
|
|
267
|
+
const extinf = m3u8.match(/#EXTINF:([\d.]+)/g) || [];
|
|
268
|
+
chunks = extinf.length;
|
|
269
|
+
duration = extinf.reduce((sum, line) => {
|
|
270
|
+
const val = parseFloat(line.replace("#EXTINF:", ""));
|
|
271
|
+
return sum + (isNaN(val) ? 0 : val);
|
|
272
|
+
}, 0);
|
|
273
|
+
} catch (_) {
|
|
274
|
+
return { hasVideo: true, quality, playlistUrl, error: "playlist fetch failed" };
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const hls = Array.from({ length: chunks }, (_, i) => playlistUrl.replace("index.m3u8", `${i + 1}.ts`));
|
|
278
|
+
|
|
279
|
+
// MP4 exists only as a single 360p preview file (WB autoplay preview).
|
|
280
|
+
// It does not cover the full video duration.
|
|
281
|
+
const mp4Preview = videoURL(this.id, "mp4", "360p");
|
|
282
|
+
|
|
283
|
+
return {
|
|
284
|
+
hasVideo: true,
|
|
285
|
+
quality,
|
|
286
|
+
playlistUrl,
|
|
287
|
+
duration: Math.round(duration),
|
|
288
|
+
chunks,
|
|
289
|
+
hls,
|
|
290
|
+
mp4Preview,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
module.exports = WBProduct;
|