channel-worker 2.5.143 → 2.5.144
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/lib/command-poller.js +151 -133
- package/lib/shopee-report.js +3 -25
- package/lib/shopee-scraper.js +42 -7
- package/package.json +1 -1
package/lib/command-poller.js
CHANGED
|
@@ -14,12 +14,19 @@ const PW_IDLE_CLOSE_MS = Number(process.env.PW_IDLE_CLOSE_MS || 5 * 60 * 1000);
|
|
|
14
14
|
// trước mũi chủ tịch (đo thật: kenh-21, mở 11:42 → bị đóng vài phút sau).
|
|
15
15
|
const HUMAN_HOLD_MS = Number(process.env.HUMAN_HOLD_MS || 15 * 60 * 1000);
|
|
16
16
|
|
|
17
|
-
// Hạn chót cho CẢ
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
|
|
17
|
+
// Hạn chót cho CẢ một lệnh Shopee (nối profile + mọi lượt gọi của lệnh). Không
|
|
18
|
+
// có hạn thì một bước kẹt giữ lệnh `running` vô hạn: 28/09/2026 lượt đọc báo
|
|
19
|
+
// cáo của Shopee2 treo 12 tiếng, khoá nút Đồng bộ, lịch của máy và cả lượt xả
|
|
20
|
+
// daemon (drain chờ `_handling` rỗng). Bốn loại dùng CHUNG một phiên và chung
|
|
21
|
+
// kiểu gọi nên chung một cửa treo. Số đo lượt thật: báo cáo 90 ngày ~1 phút
|
|
22
|
+
// (26/09: 808 đơn + 9.268 click trong 54s); lấy link 3-4 giây; quét từ khoá
|
|
23
|
+
// ~10-16 giây. API có bộ dọn riêng ở ngưỡng dài hơn giá trị lớn nhất ở đây.
|
|
24
|
+
const SHOPEE_DEADLINE_MS = {
|
|
25
|
+
fetch_affiliate_report: 10 * 60 * 1000,
|
|
26
|
+
scrape_affiliate_products: 10 * 60 * 1000,
|
|
27
|
+
get_affiliate_link: 3 * 60 * 1000,
|
|
28
|
+
ingest_shopee_product: 3 * 60 * 1000,
|
|
29
|
+
};
|
|
23
30
|
|
|
24
31
|
class CommandPoller {
|
|
25
32
|
constructor(api, config) {
|
|
@@ -39,7 +46,7 @@ class CommandPoller {
|
|
|
39
46
|
// pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
|
|
40
47
|
// "rảnh rồi, tắt" sẽ mang về một lệnh đã bị nhận — tắt lúc đó là giết nó.
|
|
41
48
|
this._pollsInFlight = 0;
|
|
42
|
-
this.
|
|
49
|
+
this.shopeeDeadlineMs = { ...SHOPEE_DEADLINE_MS };
|
|
43
50
|
|
|
44
51
|
// Init Nstbrowser if API key available
|
|
45
52
|
if (config.nst_api_key) {
|
|
@@ -238,65 +245,102 @@ class CommandPoller {
|
|
|
238
245
|
}
|
|
239
246
|
|
|
240
247
|
// Flow 1 — quét affiliate offer list (sort theo hoa hồng) → upsert vào kho.
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
248
|
+
// Chạy một lệnh Shopee trên profile `profileName`, chạy đua với hạn chót
|
|
249
|
+
// theo loại lệnh `type` (SHOPEE_DEADLINE_MS). `work(context)` làm việc thật — kể cả
|
|
250
|
+
// gửi dữ liệu về API — và trả bản vá cho lệnh ({status, result|error}).
|
|
251
|
+
// Hết hạn thì chốt `failed` ngay và cắt kết nối CDP (evaluate đang chờ sẽ
|
|
252
|
+
// văng ra); kết nối nào về SAU lúc đó cũng bị nhả luôn, không để rơi.
|
|
253
|
+
async _runShopeeCommand(command, { type, profileName, label, failLog, work }) {
|
|
254
|
+
const ms = this.shopeeDeadlineMs[type];
|
|
255
|
+
let conn = null;
|
|
256
|
+
let abandoned = false;
|
|
257
|
+
const job = (async () => {
|
|
258
|
+
const c = await this._connectNstProfileByName(profileName);
|
|
259
|
+
if (abandoned) { c.disconnect(); return null; }
|
|
260
|
+
conn = c;
|
|
261
|
+
return work(c.context);
|
|
262
|
+
})();
|
|
263
|
+
let timer;
|
|
264
|
+
const deadline = new Promise((_, reject) => {
|
|
265
|
+
timer = setTimeout(() => reject(new Error(
|
|
266
|
+
`quá ${Math.round(ms / 60000)} phút chưa ${label} [${profileName}] — huỷ lượt này`,
|
|
267
|
+
)), ms);
|
|
268
|
+
});
|
|
247
269
|
try {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
// `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
|
|
251
|
-
// cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
|
|
252
|
-
const keyword = String(payload.keyword || '').trim();
|
|
253
|
-
const res = await scraper.scrapeOffers(conn.context, {
|
|
254
|
-
keyword: keyword || null,
|
|
255
|
-
category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
|
|
256
|
-
match_id: keyword ? null : (payload.match_id || null),
|
|
257
|
-
// Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
|
|
258
|
-
// (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
|
|
259
|
-
sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
|
|
260
|
-
pages: payload.pages ?? 2,
|
|
261
|
-
page_limit: payload.page_limit ?? 20,
|
|
262
|
-
delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
|
|
263
|
-
filters: payload.filters || {},
|
|
264
|
-
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
265
|
-
});
|
|
266
|
-
if (res.status === 'needs_verify') {
|
|
267
|
-
// Anti-bot bounce — DON'T fail/retry into more captchas. Report cleanly
|
|
268
|
-
// so the dashboard can prompt the operator to re-verify via VNC.
|
|
269
|
-
console.warn(`[shopee] needs_verify: ${res.reason}`);
|
|
270
|
-
if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
271
|
-
await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } });
|
|
272
|
-
return;
|
|
273
|
-
}
|
|
274
|
-
console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
|
|
275
|
-
await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
276
|
-
// Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
|
|
277
|
-
// bên gọi phải chấm chọn đúng trên những món của lượt này. Đọc lại từ kho
|
|
278
|
-
// thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
|
|
279
|
-
// Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
|
|
280
|
-
const slim = keyword
|
|
281
|
-
? res.products.slice(0, 20).map((r) => ({
|
|
282
|
-
shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
|
|
283
|
-
commission_rate: r.commission_rate, commission_value: r.commission_value,
|
|
284
|
-
sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
|
|
285
|
-
product_url: r.product_url, category_group: r.category_group,
|
|
286
|
-
}))
|
|
287
|
-
: undefined;
|
|
288
|
-
await this.api.updateCommand(command._id, {
|
|
289
|
-
status: 'done',
|
|
290
|
-
result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
|
|
291
|
-
});
|
|
270
|
+
const patch = await Promise.race([job, deadline]);
|
|
271
|
+
await this.api.updateCommand(command._id, patch);
|
|
292
272
|
} catch (err) {
|
|
293
|
-
console.error(
|
|
273
|
+
console.error(`${failLog}: ${err.message}`);
|
|
294
274
|
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
295
275
|
} finally {
|
|
276
|
+
clearTimeout(timer);
|
|
277
|
+
abandoned = true;
|
|
296
278
|
if (conn) conn.disconnect();
|
|
297
279
|
}
|
|
298
280
|
}
|
|
299
281
|
|
|
282
|
+
async handleScrapeAffiliateProducts(command) {
|
|
283
|
+
const payload = command.payload || {};
|
|
284
|
+
if (!(await this._ensureNst(command))) return;
|
|
285
|
+
const scraper = require('./shopee-scraper');
|
|
286
|
+
const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
|
|
287
|
+
await this._runShopeeCommand(command, {
|
|
288
|
+
type: 'scrape_affiliate_products',
|
|
289
|
+
profileName,
|
|
290
|
+
label: 'quét xong sản phẩm Shopee',
|
|
291
|
+
failLog: '[shopee] scrape failed',
|
|
292
|
+
work: async (context) => {
|
|
293
|
+
// Từ khoá và ngành hàng loại trừ nhau: có keyword thì KHÔNG được để
|
|
294
|
+
// `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
|
|
295
|
+
// cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
|
|
296
|
+
const keyword = String(payload.keyword || '').trim();
|
|
297
|
+
const res = await scraper.scrapeOffers(context, {
|
|
298
|
+
keyword: keyword || null,
|
|
299
|
+
category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
|
|
300
|
+
match_id: keyword ? null : (payload.match_id || null),
|
|
301
|
+
// Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
|
|
302
|
+
// (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
|
|
303
|
+
sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
|
|
304
|
+
pages: payload.pages ?? 2,
|
|
305
|
+
page_limit: payload.page_limit ?? 20,
|
|
306
|
+
delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
|
|
307
|
+
filters: payload.filters || {},
|
|
308
|
+
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
309
|
+
});
|
|
310
|
+
if (res.status === 'needs_verify') {
|
|
311
|
+
// Anti-bot bounce — DON'T fail/retry into more captchas. Report cleanly
|
|
312
|
+
// so the dashboard can prompt the operator to re-verify via VNC.
|
|
313
|
+
console.warn(`[shopee] needs_verify: ${res.reason}`);
|
|
314
|
+
if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
315
|
+
return { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } };
|
|
316
|
+
}
|
|
317
|
+
if (res.status !== 'ok') {
|
|
318
|
+
// Shopee ngừng trả lời giữa chừng: món đã quét được vẫn vào kho.
|
|
319
|
+
if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
320
|
+
return { status: 'failed', error: String(res.reason || 'Không quét được sản phẩm Shopee').slice(0, 500) };
|
|
321
|
+
}
|
|
322
|
+
console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
|
|
323
|
+
await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
|
|
324
|
+
// Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
|
|
325
|
+
// bên gọi phải chấm chọn đúng trên những món của lượt này. Đọc lại từ kho
|
|
326
|
+
// thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
|
|
327
|
+
// Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
|
|
328
|
+
const slim = keyword
|
|
329
|
+
? res.products.slice(0, 20).map((r) => ({
|
|
330
|
+
shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
|
|
331
|
+
commission_rate: r.commission_rate, commission_value: r.commission_value,
|
|
332
|
+
sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
|
|
333
|
+
product_url: r.product_url, category_group: r.category_group,
|
|
334
|
+
}))
|
|
335
|
+
: undefined;
|
|
336
|
+
return {
|
|
337
|
+
status: 'done',
|
|
338
|
+
result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
|
|
339
|
+
};
|
|
340
|
+
},
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
300
344
|
// Flow 2 — ingest 1 sản phẩm từ link bất kỳ (bắt PDP get_pc) → full product.
|
|
301
345
|
async handleIngestShopeeProduct(command) {
|
|
302
346
|
const payload = command.payload || {};
|
|
@@ -309,19 +353,18 @@ class CommandPoller {
|
|
|
309
353
|
await this.api.updateCommand(command._id, { status: 'failed', error: 'need {shop_id,item_id} or a parseable {url}' });
|
|
310
354
|
return;
|
|
311
355
|
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
}
|
|
356
|
+
await this._runShopeeCommand(command, {
|
|
357
|
+
type: 'ingest_shopee_product',
|
|
358
|
+
profileName,
|
|
359
|
+
label: 'nạp xong sản phẩm Shopee',
|
|
360
|
+
failLog: '[shopee] ingest failed',
|
|
361
|
+
work: async (context) => {
|
|
362
|
+
const product = await scraper.ingestProduct(context, ids);
|
|
363
|
+
console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
|
|
364
|
+
await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
|
|
365
|
+
return { status: 'done', result: { name: product.name, images: product.images.length } };
|
|
366
|
+
},
|
|
367
|
+
});
|
|
325
368
|
}
|
|
326
369
|
|
|
327
370
|
// Sinh LINK AFFILIATE THẬT (s.shopee.vn/...) cho một hoặc nhiều sản phẩm,
|
|
@@ -345,40 +388,37 @@ class CommandPoller {
|
|
|
345
388
|
return;
|
|
346
389
|
}
|
|
347
390
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
} finally {
|
|
380
|
-
if (conn) conn.disconnect();
|
|
381
|
-
}
|
|
391
|
+
await this._runShopeeCommand(command, {
|
|
392
|
+
type: 'get_affiliate_link',
|
|
393
|
+
profileName,
|
|
394
|
+
label: 'lấy được link Shopee',
|
|
395
|
+
failLog: '[shopee] lấy link thất bại',
|
|
396
|
+
work: async (context) => {
|
|
397
|
+
const res = await scraper.generateOfferLink(context, {
|
|
398
|
+
items,
|
|
399
|
+
subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
|
|
400
|
+
log: (m) => console.log(m), // để thấy số tab đã dọn
|
|
401
|
+
});
|
|
402
|
+
if (res.status === 'needs_verify') {
|
|
403
|
+
// Anti-bot bounce — báo sạch, KHÔNG thử lại để khỏi đâm thêm captcha.
|
|
404
|
+
// Ghi kèm tên profile: có hai phiên (Shopee1, Shopee2) và người xử lý
|
|
405
|
+
// phải biết mở đúng cái nào để kéo captcha.
|
|
406
|
+
const reason = `${res.reason} [${profileName}]`;
|
|
407
|
+
console.warn(`[shopee] lấy link: needs_verify — ${reason}`);
|
|
408
|
+
return { status: 'done', result: { needs_verify: true, reason } };
|
|
409
|
+
}
|
|
410
|
+
if (res.status !== 'ok') {
|
|
411
|
+
return { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) };
|
|
412
|
+
}
|
|
413
|
+
console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
|
|
414
|
+
await this.api.affiliateLinkResult(res.links, {
|
|
415
|
+
user_id: command.user_id,
|
|
416
|
+
sub_ids: res.sub_ids,
|
|
417
|
+
idea_id: payload.idea_id || null,
|
|
418
|
+
});
|
|
419
|
+
return { status: 'done', result: { links: res.links, sub_ids: res.sub_ids } };
|
|
420
|
+
},
|
|
421
|
+
});
|
|
382
422
|
}
|
|
383
423
|
|
|
384
424
|
async handleFetchAffiliateReport(command) {
|
|
@@ -387,35 +427,13 @@ class CommandPoller {
|
|
|
387
427
|
const profileName = payload.profile_name
|
|
388
428
|
|| (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
|
|
389
429
|
|| 'Shopee1';
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const work = (async () => {
|
|
397
|
-
const c = await this._connectNstProfileByName(profileName);
|
|
398
|
-
if (abandoned) { c.disconnect(); return null; }
|
|
399
|
-
conn = c;
|
|
400
|
-
return this._readAffiliateReport(c.context, payload);
|
|
401
|
-
})();
|
|
402
|
-
let timer;
|
|
403
|
-
const deadline = new Promise((_, reject) => {
|
|
404
|
-
timer = setTimeout(() => reject(new Error(
|
|
405
|
-
`quá ${Math.round(this.affReportDeadlineMs / 60000)} phút chưa đọc xong báo cáo Shopee [${profileName}] — huỷ lượt này`,
|
|
406
|
-
)), this.affReportDeadlineMs);
|
|
430
|
+
await this._runShopeeCommand(command, {
|
|
431
|
+
type: 'fetch_affiliate_report',
|
|
432
|
+
profileName,
|
|
433
|
+
label: 'đọc xong báo cáo Shopee',
|
|
434
|
+
failLog: '[shopee-report] đồng bộ thất bại',
|
|
435
|
+
work: (context) => this._readAffiliateReport(context, payload),
|
|
407
436
|
});
|
|
408
|
-
try {
|
|
409
|
-
const patch = await Promise.race([work, deadline]);
|
|
410
|
-
await this.api.updateCommand(command._id, patch);
|
|
411
|
-
} catch (err) {
|
|
412
|
-
console.error(`[shopee-report] đồng bộ thất bại: ${err.message}`);
|
|
413
|
-
await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
|
|
414
|
-
} finally {
|
|
415
|
-
clearTimeout(timer);
|
|
416
|
-
abandoned = true;
|
|
417
|
-
if (conn) conn.disconnect();
|
|
418
|
-
}
|
|
419
437
|
}
|
|
420
438
|
|
|
421
439
|
// Đọc đơn + click + liên kết trên một phiên đã nối, trả về bản vá cho lệnh.
|
package/lib/shopee-report.js
CHANGED
|
@@ -1,31 +1,9 @@
|
|
|
1
|
-
const {
|
|
1
|
+
const {
|
|
2
|
+
findAffiliatePage, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, evaluateWithin, EVAL_TIMEOUT_MS,
|
|
3
|
+
} = require('./shopee-scraper');
|
|
2
4
|
|
|
3
5
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
6
|
|
|
5
|
-
// Một lượt evaluate phải có hạn. Playwright không có timeout cho evaluate, và
|
|
6
|
-
// fetch chạy trong trang chống bot của Shopee có thể không bao giờ về: 28/09/2026
|
|
7
|
-
// (Shopee2, relabs03) một lượt nằm im 12 tiếng, chỉ nhả khi người gỡ captcha
|
|
8
|
-
// làm trang chuyển hướng — suốt lúc đó lệnh `running` khoá nút Đồng bộ và lịch
|
|
9
|
-
// của máy. Hết hạn thì trả `{ status: 0, timedOut }` để bên gọi xử như một
|
|
10
|
-
// lượt hỏng; trang lúc đó đã sang captcha thì isBlockedState đọc ra needs_verify.
|
|
11
|
-
const EVAL_TIMEOUT_MS = 60000;
|
|
12
|
-
|
|
13
|
-
async function evaluateWithin(page, fn, arg, ms = EVAL_TIMEOUT_MS) {
|
|
14
|
-
let timer;
|
|
15
|
-
const expired = new Promise((resolve) => {
|
|
16
|
-
timer = setTimeout(() => resolve({
|
|
17
|
-
status: 0,
|
|
18
|
-
timedOut: true,
|
|
19
|
-
error: `Shopee không trả lời sau ${Math.round(ms / 1000)} giây (trang ${page.url()})`,
|
|
20
|
-
}), ms);
|
|
21
|
-
});
|
|
22
|
-
try {
|
|
23
|
-
return await Promise.race([page.evaluate(fn, arg), expired]);
|
|
24
|
-
} finally {
|
|
25
|
-
clearTimeout(timer);
|
|
26
|
-
}
|
|
27
|
-
}
|
|
28
|
-
|
|
29
7
|
function reportWindow(days = 90, nowMs = Date.now()) {
|
|
30
8
|
const safeDays = Math.min(180, Math.max(1, Number(days || 90)));
|
|
31
9
|
const vnNow = new Date(nowMs + 7 * 3600e3);
|
package/lib/shopee-scraper.js
CHANGED
|
@@ -72,13 +72,16 @@ function parsePdpItem(item) {
|
|
|
72
72
|
async function ingestProduct(ctx, { shop_id, item_id, timeoutMs = 60000 } = {}) {
|
|
73
73
|
if (!shop_id || !item_id) throw new Error('ingestProduct needs shop_id + item_id');
|
|
74
74
|
const page = await ctx.newPage();
|
|
75
|
+
let timer;
|
|
75
76
|
try {
|
|
76
77
|
const captured = new Promise((resolve) => {
|
|
77
78
|
page.on('response', async (res) => {
|
|
78
79
|
if (!res.url().includes('/api/v4/pdp/get_pc')) return;
|
|
79
80
|
try { const j = await res.json(); if (j?.data?.item) resolve(j.data.item); } catch { /* keep waiting */ }
|
|
80
81
|
});
|
|
81
|
-
|
|
82
|
+
// Trang bị đóng (lệnh hết hạn chót → cắt kết nối) thì thôi chờ ngay.
|
|
83
|
+
page.once('close', () => resolve(null));
|
|
84
|
+
timer = setTimeout(() => resolve(null), timeoutMs);
|
|
82
85
|
});
|
|
83
86
|
await page.goto(`https://shopee.vn/product/${shop_id}/${item_id}`, { waitUntil: 'domcontentloaded', timeout: timeoutMs });
|
|
84
87
|
const item = await captured;
|
|
@@ -87,6 +90,7 @@ async function ingestProduct(ctx, { shop_id, item_id, timeoutMs = 60000 } = {})
|
|
|
87
90
|
parsed.product_url = `https://shopee.vn/product/${shop_id}/${item_id}`;
|
|
88
91
|
return parsed;
|
|
89
92
|
} finally {
|
|
93
|
+
clearTimeout(timer);
|
|
90
94
|
await page.close().catch(() => {});
|
|
91
95
|
}
|
|
92
96
|
}
|
|
@@ -169,6 +173,31 @@ const AFFILIATE_CATEGORIES = {
|
|
|
169
173
|
|
|
170
174
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
171
175
|
|
|
176
|
+
// Một lượt evaluate phải có hạn. Playwright không có timeout cho evaluate, và
|
|
177
|
+
// fetch chạy trong trang chống bot của Shopee có thể không bao giờ về: 28/09/2026
|
|
178
|
+
// (Shopee2, relabs03) một lượt đọc báo cáo nằm im 12 tiếng, chỉ nhả khi người
|
|
179
|
+
// gỡ captcha làm trang chuyển hướng. Mọi luồng Shopee (báo cáo, lấy link, quét
|
|
180
|
+
// món) dùng chung phiên và chung kiểu gọi nên dùng chung chốt này. Hết hạn thì
|
|
181
|
+
// trả `{ status: 0, timedOut }` để bên gọi xử như một lượt hỏng; trang lúc đó
|
|
182
|
+
// đã sang captcha thì isBlockedState đọc ra needs_verify.
|
|
183
|
+
const EVAL_TIMEOUT_MS = 60000;
|
|
184
|
+
|
|
185
|
+
async function evaluateWithin(page, fn, arg, ms = EVAL_TIMEOUT_MS) {
|
|
186
|
+
let timer;
|
|
187
|
+
const expired = new Promise((resolve) => {
|
|
188
|
+
timer = setTimeout(() => resolve({
|
|
189
|
+
status: 0,
|
|
190
|
+
timedOut: true,
|
|
191
|
+
error: `Shopee không trả lời sau ${Math.round(ms / 1000)} giây (trang ${page.url()})`,
|
|
192
|
+
}), ms);
|
|
193
|
+
});
|
|
194
|
+
try {
|
|
195
|
+
return await Promise.race([page.evaluate(fn, arg), expired]);
|
|
196
|
+
} finally {
|
|
197
|
+
clearTimeout(timer);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
172
201
|
// True when the session got bounced to the anti-bot captcha (scene=crawler_item)
|
|
173
202
|
// or logged out — in either case the offer API returns 404/empty and we must
|
|
174
203
|
// STOP (not keep hammering) and ask the operator to re-verify via VNC.
|
|
@@ -342,6 +371,7 @@ async function scrapeOffers(ctx, {
|
|
|
342
371
|
delay_ms = 2500, // human-like pause between calls (anti-bot)
|
|
343
372
|
filters = {},
|
|
344
373
|
timeoutMs = 45000,
|
|
374
|
+
evalTimeoutMs = EVAL_TIMEOUT_MS,
|
|
345
375
|
log = null,
|
|
346
376
|
// legacy single-sort param (back-compat with the first build)
|
|
347
377
|
sort_type = null,
|
|
@@ -389,7 +419,7 @@ async function scrapeOffers(ctx, {
|
|
|
389
419
|
for (const st of sort_types) {
|
|
390
420
|
for (let i = 0; i < pages; i++) {
|
|
391
421
|
const offset = i * page_limit;
|
|
392
|
-
const res = await page
|
|
422
|
+
const res = await evaluateWithin(page, async (q) => {
|
|
393
423
|
// Từ khoá đứng TRƯỚC ngành hàng: hai chế độ loại trừ nhau, gửi cả hai
|
|
394
424
|
// thì Shopee bỏ qua keyword và mình tưởng đang tìm mà thật ra vẫn
|
|
395
425
|
// đang duyệt tab cũ.
|
|
@@ -403,7 +433,7 @@ async function scrapeOffers(ctx, {
|
|
|
403
433
|
const r = await fetch(url, { credentials: 'include' });
|
|
404
434
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
405
435
|
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
406
|
-
}, { st, offset, page_limit, mid, listType, kw, ft: filter_types });
|
|
436
|
+
}, { st, offset, page_limit, mid, listType, kw, ft: filter_types }, evalTimeoutMs);
|
|
407
437
|
|
|
408
438
|
if (isAntiBotBody(res.json)) {
|
|
409
439
|
return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: ANTI_BOT_REASON };
|
|
@@ -411,6 +441,11 @@ async function scrapeOffers(ctx, {
|
|
|
411
441
|
if (isBlockedState(page.url(), res)) {
|
|
412
442
|
return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: `blocked mid-scrape (http ${res.status}, code ${res.json?.code})` };
|
|
413
443
|
}
|
|
444
|
+
// Hết hạn KHÔNG phải hết dữ liệu: đi tiếp là thêm một lượt chờ nữa trên
|
|
445
|
+
// đúng phiên đang kẹt. Dừng hẳn, giữ những món đã quét được.
|
|
446
|
+
if (res.timedOut) {
|
|
447
|
+
return { status: 'failed', products: [...byId.values()], raw_count: rawCount, reason: res.error };
|
|
448
|
+
}
|
|
414
449
|
if (res.status !== 200 || res.json?.code !== 0) {
|
|
415
450
|
// soft stop — treat as end-of-data for this sort, move on
|
|
416
451
|
break;
|
|
@@ -483,7 +518,7 @@ function linkFailureReason(res, wanted = 0) {
|
|
|
483
518
|
}
|
|
484
519
|
|
|
485
520
|
// subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
|
|
486
|
-
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000, log = null } = {}) {
|
|
521
|
+
async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000, evalTimeoutMs = EVAL_TIMEOUT_MS, log = null } = {}) {
|
|
487
522
|
const list = (items || [])
|
|
488
523
|
.map((it) => ({ itemId: String(it.item_id || ''), shopId: Number(it.shop_id) || 0, trace: '' }))
|
|
489
524
|
.filter((it) => it.itemId && it.shopId);
|
|
@@ -504,7 +539,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
|
|
|
504
539
|
if (isBlockedState(page.url(), null)) {
|
|
505
540
|
return { status: 'needs_verify', links: [], reason: 'session at captcha/login — re-verify via VNC' };
|
|
506
541
|
}
|
|
507
|
-
const res = await page
|
|
542
|
+
const res = await evaluateWithin(page, async (q) => {
|
|
508
543
|
const body = {
|
|
509
544
|
operationName: 'batchGetProductOfferLink',
|
|
510
545
|
query: `query batchGetProductOfferLink($sourceCaller: SourceCaller!, $productOfferLinkParams: [ProductOfferLinkParam!]!, $advancedLinkParams: AdvancedLinkParams){ productOfferLinks(productOfferLinkParams: $productOfferLinkParams, sourceCaller: $sourceCaller, advancedLinkParams: $advancedLinkParams) { itemId shopId productOfferLink } }`,
|
|
@@ -522,7 +557,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
|
|
|
522
557
|
});
|
|
523
558
|
return { status: r.status, json: await r.json().catch(() => null) };
|
|
524
559
|
} catch (e) { return { status: 0, error: String(e.message) }; }
|
|
525
|
-
}, { list, subs });
|
|
560
|
+
}, { list, subs }, evalTimeoutMs);
|
|
526
561
|
|
|
527
562
|
if (isAntiBotBody(res.json)) {
|
|
528
563
|
return { status: 'needs_verify', links: [], reason: ANTI_BOT_REASON };
|
|
@@ -581,4 +616,4 @@ function parseProductUrl(url = '') {
|
|
|
581
616
|
return null;
|
|
582
617
|
}
|
|
583
618
|
|
|
584
|
-
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, findAffiliatePage, pruneTabs, isJunkTab };
|
|
619
|
+
module.exports = { ingestProduct, scrapeOffers, generateOfferLink, linkFailureReason, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters, isBlockedState, isAntiBotBody, ANTI_BOT_REASON, findAffiliatePage, pruneTabs, isJunkTab, evaluateWithin, EVAL_TIMEOUT_MS };
|