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.
@@ -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Ả lượt đọc báo cáo Shopee (nối profile + đơn + click + liên
18
- // kết). Lượt thật 90 ngày mất ~1 phút (26/09: 808 đơn + 9.268 click trong 54s).
19
- // Không có hạn thì một bước kẹt giữ lệnh `running` vô hạn: 28/09/2026 lệnh của
20
- // Shopee2 treo 12 tiếng, khoá nút Đồng bộ, lịch của máy và cả lượt xả daemon
21
- // (drain chờ `_handling` rỗng). API có bộ dọn riêng ở ngưỡng dài hơn.
22
- const AFF_REPORT_DEADLINE_MS = 10 * 60 * 1000;
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.affReportDeadlineMs = AFF_REPORT_DEADLINE_MS;
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
- async handleScrapeAffiliateProducts(command) {
242
- const payload = command.payload || {};
243
- if (!(await this._ensureNst(command))) return;
244
- const scraper = require('./shopee-scraper');
245
- const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
246
- let conn;
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
- conn = await this._connectNstProfileByName(profileName);
249
- // Từ khoá và ngành hàng loại trừ nhau: có keyword thì KHÔNG được để
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(`[shopee] scrape failed: ${err.message}`);
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
- let conn;
313
- try {
314
- conn = await this._connectNstProfileByName(profileName);
315
- const product = await scraper.ingestProduct(conn.context, ids);
316
- console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
317
- await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
318
- await this.api.updateCommand(command._id, { status: 'done', result: { name: product.name, images: product.images.length } });
319
- } catch (err) {
320
- console.error(`[shopee] ingest failed: ${err.message}`);
321
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
322
- } finally {
323
- if (conn) conn.disconnect();
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
- let conn;
349
- try {
350
- conn = await this._connectNstProfileByName(profileName);
351
- const res = await scraper.generateOfferLink(conn.context, {
352
- items,
353
- subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
354
- log: (m) => console.log(m), // để thấy số tab đã dọn
355
- });
356
- if (res.status === 'needs_verify') {
357
- // Anti-bot bounce — báo sạch, KHÔNG thử lại để khỏi đâm thêm captcha.
358
- // Ghi kèm tên profile: có hai phiên (Shopee1, Shopee2) và người xử lý
359
- // phải biết mở đúng cái nào để kéo captcha.
360
- const reason = `${res.reason} [${profileName}]`;
361
- console.warn(`[shopee] lấy link: needs_verify — ${reason}`);
362
- await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason } });
363
- return;
364
- }
365
- if (res.status !== 'ok') {
366
- await this.api.updateCommand(command._id, { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) });
367
- return;
368
- }
369
- console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
370
- await this.api.affiliateLinkResult(res.links, {
371
- user_id: command.user_id,
372
- sub_ids: res.sub_ids,
373
- idea_id: payload.idea_id || null,
374
- });
375
- await this.api.updateCommand(command._id, { status: 'done', result: { links: res.links, sub_ids: res.sub_ids } });
376
- } catch (err) {
377
- console.error(`[shopee] lấy link thất bại: ${err.message}`);
378
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
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
- // Việc thật chạy trong `work`, chạy đua với hạn chót. Hết hạn thì chốt
392
- // `failed` ngay và cắt kết nối CDP (evaluate đang chờ sẽ văng ra); kết nối
393
- // nào về SAU lúc đó cũng bị nhả luôn, không để rơi.
394
- let conn = null;
395
- let abandoned = false;
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.
@@ -1,31 +1,9 @@
1
- const { findAffiliatePage, isBlockedState, isAntiBotBody, ANTI_BOT_REASON } = require('./shopee-scraper');
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);
@@ -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
- setTimeout(() => resolve(null), timeoutMs);
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.evaluate(async (q) => {
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.evaluate(async (q) => {
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.143",
3
+ "version": "2.5.144",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {