channel-worker 2.5.142 → 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,6 +14,20 @@ 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Ả 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
+ };
30
+
17
31
  class CommandPoller {
18
32
  constructor(api, config) {
19
33
  this.api = api;
@@ -32,6 +46,7 @@ class CommandPoller {
32
46
  // pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
33
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ó.
34
48
  this._pollsInFlight = 0;
49
+ this.shopeeDeadlineMs = { ...SHOPEE_DEADLINE_MS };
35
50
 
36
51
  // Init Nstbrowser if API key available
37
52
  if (config.nst_api_key) {
@@ -230,65 +245,102 @@ class CommandPoller {
230
245
  }
231
246
 
232
247
  // Flow 1 — quét affiliate offer list (sort theo hoa hồng) → upsert vào kho.
233
- async handleScrapeAffiliateProducts(command) {
234
- const payload = command.payload || {};
235
- if (!(await this._ensureNst(command))) return;
236
- const scraper = require('./shopee-scraper');
237
- const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
238
- 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
+ });
239
269
  try {
240
- conn = await this._connectNstProfileByName(profileName);
241
- // Từ khoá và ngành hàng loại trừ nhau: có keyword thì KHÔNG được để
242
- // `category` rơi về mặc định 'women_clothes', không thì scrapeOffers nhận
243
- // cả hai và lượt tìm im lặng biến thành lượt duyệt tab quần áo nữ.
244
- const keyword = String(payload.keyword || '').trim();
245
- const res = await scraper.scrapeOffers(conn.context, {
246
- keyword: keyword || null,
247
- category: keyword ? null : (payload.category || 'women_clothes'), // Tier-1 default: quần áo nữ
248
- match_id: keyword ? null : (payload.match_id || null),
249
- // Ở chế độ từ khoá, sort_type 3 là GIÁ GIẢM DẦN chứ không phải doanh số
250
- // (đo 09/09) — scrapeOffers tự cắt, đây chỉ là không đưa vào cho đỡ rối.
251
- sort_types: payload.sort_types || (keyword ? [2] : [2, 3]), // commission + sales, merged
252
- pages: payload.pages ?? 2,
253
- page_limit: payload.page_limit ?? 20,
254
- delay_ms: payload.delay_ms ?? 2500, // human-like pacing (anti-bot)
255
- filters: payload.filters || {},
256
- log: (m) => console.log(m), // để thấy số tab đã dọn
257
- });
258
- if (res.status === 'needs_verify') {
259
- // Anti-bot bounce — DON'T fail/retry into more captchas. Report cleanly
260
- // so the dashboard can prompt the operator to re-verify via VNC.
261
- console.warn(`[shopee] needs_verify: ${res.reason}`);
262
- if (res.products?.length) await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
263
- await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason, count: res.products?.length || 0 } });
264
- return;
265
- }
266
- console.log(`[shopee] scraped ${res.raw_count} raw → ${res.products.length} after filters from ${profileName}${keyword ? ` (từ khoá "${keyword}")` : ''}`);
267
- await this.api.upsertAffiliateProducts(res.products, { user_id: command.user_id });
268
- // Lượt tìm theo từ khoá trả kèm CHÍNH danh sách tìm được, không chỉ đếm:
269
- // 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
270
- // thì lẫn hàng của các lượt trước và mất luôn thứ tự Shopee xếp.
271
- // Cắt gọn để kết quả lệnh không phình (ảnh/biến thể ở lại trong kho).
272
- const slim = keyword
273
- ? res.products.slice(0, 20).map((r) => ({
274
- shop_id: r.shop_id, item_id: r.item_id, name: r.name, price: r.price,
275
- commission_rate: r.commission_rate, commission_value: r.commission_value,
276
- sold_count: r.sold_count, rating: r.rating, cover_image: r.cover_image,
277
- product_url: r.product_url, category_group: r.category_group,
278
- }))
279
- : undefined;
280
- await this.api.updateCommand(command._id, {
281
- status: 'done',
282
- result: { count: res.products.length, raw: res.raw_count, keyword: res.keyword || null, ...(slim ? { products: slim } : {}) },
283
- });
270
+ const patch = await Promise.race([job, deadline]);
271
+ await this.api.updateCommand(command._id, patch);
284
272
  } catch (err) {
285
- console.error(`[shopee] scrape failed: ${err.message}`);
273
+ console.error(`${failLog}: ${err.message}`);
286
274
  await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
287
275
  } finally {
276
+ clearTimeout(timer);
277
+ abandoned = true;
288
278
  if (conn) conn.disconnect();
289
279
  }
290
280
  }
291
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
+
292
344
  // Flow 2 — ingest 1 sản phẩm từ link bất kỳ (bắt PDP get_pc) → full product.
293
345
  async handleIngestShopeeProduct(command) {
294
346
  const payload = command.payload || {};
@@ -301,19 +353,18 @@ class CommandPoller {
301
353
  await this.api.updateCommand(command._id, { status: 'failed', error: 'need {shop_id,item_id} or a parseable {url}' });
302
354
  return;
303
355
  }
304
- let conn;
305
- try {
306
- conn = await this._connectNstProfileByName(profileName);
307
- const product = await scraper.ingestProduct(conn.context, ids);
308
- console.log(`[shopee] ingested: ${product.name} (${product.images.length} imgs)`);
309
- await this.api.ingestShopeeResult(product, { user_id: command.user_id, idea_id: payload.idea_id || null });
310
- await this.api.updateCommand(command._id, { status: 'done', result: { name: product.name, images: product.images.length } });
311
- } catch (err) {
312
- console.error(`[shopee] ingest failed: ${err.message}`);
313
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
314
- } finally {
315
- if (conn) conn.disconnect();
316
- }
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
+ });
317
368
  }
318
369
 
319
370
  // Sinh LINK AFFILIATE THẬT (s.shopee.vn/...) cho một hoặc nhiều sản phẩm,
@@ -337,139 +388,127 @@ class CommandPoller {
337
388
  return;
338
389
  }
339
390
 
340
- let conn;
341
- try {
342
- conn = await this._connectNstProfileByName(profileName);
343
- const res = await scraper.generateOfferLink(conn.context, {
344
- items,
345
- subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
346
- log: (m) => console.log(m), // để thấy số tab đã dọn
347
- });
348
- if (res.status === 'needs_verify') {
349
- // Anti-bot bounce — báo sạch, KHÔNG thử lại để khỏi đâm thêm captcha.
350
- // Ghi kèm tên profile: có hai phiên (Shopee1, Shopee2) và người xử lý
351
- // phải biết mở đúng cái nào để kéo captcha.
352
- const reason = `${res.reason} [${profileName}]`;
353
- console.warn(`[shopee] lấy link: needs_verify — ${reason}`);
354
- await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason } });
355
- return;
356
- }
357
- if (res.status !== 'ok') {
358
- await this.api.updateCommand(command._id, { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) });
359
- return;
360
- }
361
- console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
362
- await this.api.affiliateLinkResult(res.links, {
363
- user_id: command.user_id,
364
- sub_ids: res.sub_ids,
365
- idea_id: payload.idea_id || null,
366
- });
367
- await this.api.updateCommand(command._id, { status: 'done', result: { links: res.links, sub_ids: res.sub_ids } });
368
- } catch (err) {
369
- console.error(`[shopee] lấy link thất bại: ${err.message}`);
370
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
371
- } finally {
372
- if (conn) conn.disconnect();
373
- }
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
+ });
374
422
  }
375
423
 
376
424
  async handleFetchAffiliateReport(command) {
377
425
  const payload = command.payload || {};
378
426
  if (!(await this._ensureNst(command))) return;
379
- const report = require('./shopee-report');
380
427
  const profileName = payload.profile_name
381
428
  || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
382
429
  || 'Shopee1';
383
- let conn;
384
- try {
385
- conn = await this._connectNstProfileByName(profileName);
386
- const result = await report.fetchConversionReport(conn.context, {
387
- days: payload.days || 90,
388
- log: (message) => console.log(message),
389
- });
390
- if (result.status === 'needs_verify') {
391
- await this.api.updateCommand(command._id, {
392
- status: 'done',
393
- result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 },
394
- });
395
- return;
396
- }
397
- if (result.status !== 'ok') {
398
- await this.api.updateCommand(command._id, {
399
- status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500),
400
- });
401
- return;
402
- }
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),
436
+ });
437
+ }
403
438
 
404
- const clickResult = await report.fetchClickReport(conn.context, {
405
- days: payload.days || 90,
406
- log: (message) => console.log(message),
407
- });
408
- if (clickResult.status === 'needs_verify') {
409
- await this.api.updateCommand(command._id, {
410
- status: 'done',
411
- result: { needs_verify: true, reason: clickResult.reason, count: result.rows.length, click_count: clickResult.rows?.length || 0 },
412
- });
413
- return;
414
- }
415
- if (clickResult.status !== 'ok') {
416
- await this.api.updateCommand(command._id, {
417
- status: 'failed', error: String(clickResult.reason || 'Không đọc được báo cáo click Shopee').slice(0, 500),
418
- });
419
- return;
420
- }
439
+ // Đọc đơn + click + liên kết trên một phiên đã nối, trả về bản vá cho lệnh.
440
+ async _readAffiliateReport(context, payload) {
441
+ const report = require('./shopee-report');
442
+ const result = await report.fetchConversionReport(context, {
443
+ days: payload.days || 90,
444
+ log: (message) => console.log(message),
445
+ });
446
+ if (result.status === 'needs_verify') {
447
+ return { status: 'done', result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 } };
448
+ }
449
+ if (result.status !== 'ok') {
450
+ return { status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500) };
451
+ }
421
452
 
422
- // Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
423
- // nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
424
- // profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
425
- // kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
426
- let mediaAccounts = null;
427
- let mediaError = null;
428
- try {
429
- const media = await report.fetchSocialMediaAccounts(conn.context, {
430
- log: (message) => console.log(message),
431
- });
432
- if (media.status === 'ok') mediaAccounts = media.accounts;
433
- else mediaError = media.reason || media.status;
434
- } catch (err) {
435
- mediaError = String(err.message || err).slice(0, 200);
436
- }
437
- if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
438
-
439
- const saved = await this.api.affiliateReportResult(result.rows, {
440
- clicks: clickResult.rows,
441
- // `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
442
- // liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
443
- // chỉ vì một lượt đọc hỏng.
444
- media_accounts: mediaAccounts,
445
- last_report_date: result.last_report_date,
446
- last_update_time: result.last_update_time,
447
- from: result.from,
448
- to: result.to,
449
- });
450
- await this.api.updateCommand(command._id, {
453
+ const clickResult = await report.fetchClickReport(context, {
454
+ days: payload.days || 90,
455
+ log: (message) => console.log(message),
456
+ });
457
+ if (clickResult.status === 'needs_verify') {
458
+ return {
451
459
  status: 'done',
452
- result: {
453
- count: result.rows.length,
454
- saved: saved?.saved || 0,
455
- matched: saved?.matched || 0,
456
- unmatched: saved?.unmatched || 0,
457
- click_count: clickResult.rows.length,
458
- click_saved: saved?.click_saved || 0,
459
- click_matched: saved?.click_matched || 0,
460
- click_unmatched: saved?.click_unmatched || 0,
461
- media_accounts: mediaAccounts ? mediaAccounts.length : null,
462
- media_error: mediaError || undefined,
463
- last_report_date: result.last_report_date,
464
- },
460
+ result: { needs_verify: true, reason: clickResult.reason, count: result.rows.length, click_count: clickResult.rows?.length || 0 },
461
+ };
462
+ }
463
+ if (clickResult.status !== 'ok') {
464
+ return { status: 'failed', error: String(clickResult.reason || 'Không đọc được báo cáo click Shopee').slice(0, 500) };
465
+ }
466
+
467
+ // Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
468
+ // nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
469
+ // profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
470
+ // kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
471
+ let mediaAccounts = null;
472
+ let mediaError = null;
473
+ try {
474
+ const media = await report.fetchSocialMediaAccounts(context, {
475
+ log: (message) => console.log(message),
465
476
  });
466
- console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} đơn, ${saved?.click_saved || 0}/${clickResult.rows.length} click`);
477
+ if (media.status === 'ok') mediaAccounts = media.accounts;
478
+ else mediaError = media.reason || media.status;
467
479
  } catch (err) {
468
- console.error(`[shopee-report] đồng bộ thất bại: ${err.message}`);
469
- await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
470
- } finally {
471
- if (conn) conn.disconnect();
480
+ mediaError = String(err.message || err).slice(0, 200);
472
481
  }
482
+ if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
483
+
484
+ const saved = await this.api.affiliateReportResult(result.rows, {
485
+ clicks: clickResult.rows,
486
+ // `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
487
+ // liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
488
+ // chỉ vì một lượt đọc hỏng.
489
+ media_accounts: mediaAccounts,
490
+ last_report_date: result.last_report_date,
491
+ last_update_time: result.last_update_time,
492
+ from: result.from,
493
+ to: result.to,
494
+ });
495
+ console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} đơn, ${saved?.click_saved || 0}/${clickResult.rows.length} click`);
496
+ return {
497
+ status: 'done',
498
+ result: {
499
+ count: result.rows.length,
500
+ saved: saved?.saved || 0,
501
+ matched: saved?.matched || 0,
502
+ unmatched: saved?.unmatched || 0,
503
+ click_count: clickResult.rows.length,
504
+ click_saved: saved?.click_saved || 0,
505
+ click_matched: saved?.click_matched || 0,
506
+ click_unmatched: saved?.click_unmatched || 0,
507
+ media_accounts: mediaAccounts ? mediaAccounts.length : null,
508
+ media_error: mediaError || undefined,
509
+ last_report_date: result.last_report_date,
510
+ },
511
+ };
473
512
  }
474
513
 
475
514
  async handlePlaywrightCommand(command) {
@@ -1,4 +1,6 @@
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
 
@@ -17,6 +19,7 @@ async function fetchConversionReport(ctx, {
17
19
  maxPages = 200,
18
20
  delayMs = 750,
19
21
  timeoutMs = 60000,
22
+ evalTimeoutMs = EVAL_TIMEOUT_MS,
20
23
  log = null,
21
24
  } = {}) {
22
25
  let page = findAffiliatePage(ctx.pages());
@@ -43,7 +46,7 @@ async function fetchConversionReport(ctx, {
43
46
  let lastReportDate = null;
44
47
  let lastUpdateTime = null;
45
48
 
46
- const updated = await page.evaluate(async () => {
49
+ const updated = await evaluateWithin(page, async () => {
47
50
  try {
48
51
  const r = await fetch('/api/v3/gql?q=conversionReportUpdateTime', {
49
52
  method: 'POST', credentials: 'include',
@@ -56,12 +59,17 @@ async function fetchConversionReport(ctx, {
56
59
  });
57
60
  return await r.json().catch(() => null);
58
61
  } catch { return null; }
59
- });
62
+ }, undefined, evalTimeoutMs);
63
+ if (updated?.timedOut) {
64
+ return isBlockedState(page.url(), null)
65
+ ? { status: 'needs_verify', rows: [], reason: 'session at captcha/login — re-verify via VNC' }
66
+ : { status: 'failed', rows: [], reason: updated.error };
67
+ }
60
68
  lastReportDate = updated?.data?.conversionReportUpdateTime?.lastReportDate || null;
61
69
  lastUpdateTime = updated?.data?.conversionReportUpdateTime?.lastUpdateTime || null;
62
70
 
63
71
  for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
64
- const result = await page.evaluate(async (q) => {
72
+ const result = await evaluateWithin(page, async (q) => {
65
73
  const url = `/api/v3/report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
66
74
  + `&purchase_time_s=${q.start}&purchase_time_e=${q.end}&version=1`;
67
75
  try {
@@ -70,7 +78,7 @@ async function fetchConversionReport(ctx, {
70
78
  } catch (e) {
71
79
  return { status: 0, error: String(e?.message || e) };
72
80
  }
73
- }, { pageSize, pageNum, start: window.start, end: window.end });
81
+ }, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
74
82
 
75
83
  if (isAntiBotBody(result.json)) {
76
84
  return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
@@ -113,6 +121,7 @@ async function fetchClickReport(ctx, {
113
121
  maxPages = 200,
114
122
  delayMs = 250,
115
123
  timeoutMs = 60000,
124
+ evalTimeoutMs = EVAL_TIMEOUT_MS,
116
125
  log = null,
117
126
  } = {}) {
118
127
  let page = findAffiliatePage(ctx.pages());
@@ -138,7 +147,7 @@ async function fetchClickReport(ctx, {
138
147
  let total = null;
139
148
 
140
149
  for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
141
- const result = await page.evaluate(async (q) => {
150
+ const result = await evaluateWithin(page, async (q) => {
142
151
  const url = `/api/v1/click_report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
143
152
  + `&click_time_s=${q.start}&click_time_e=${q.end}`;
144
153
  try {
@@ -147,7 +156,7 @@ async function fetchClickReport(ctx, {
147
156
  } catch (e) {
148
157
  return { status: 0, error: String(e?.message || e) };
149
158
  }
150
- }, { pageSize, pageNum, start: window.start, end: window.end });
159
+ }, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
151
160
 
152
161
  if (isAntiBotBody(result.json)) {
153
162
  return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
@@ -206,7 +215,7 @@ async function fetchClickReport(ctx, {
206
215
  // khác giữ nguyên số để sau này đọc lại, không đoán tên).
207
216
  const SM_PLATFORM = { 6: 'facebook', 3: 'tiktok' };
208
217
 
209
- async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } = {}) {
218
+ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, evalTimeoutMs = EVAL_TIMEOUT_MS, log = null } = {}) {
210
219
  let page = findAffiliatePage(ctx.pages());
211
220
  let opened = false;
212
221
  if (!page) {
@@ -225,14 +234,14 @@ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } =
225
234
  return { status: 'needs_verify', accounts: [], reason: 'session at captcha/login — re-verify via VNC' };
226
235
  }
227
236
 
228
- const result = await page.evaluate(async () => {
237
+ const result = await evaluateWithin(page, async () => {
229
238
  try {
230
239
  const r = await fetch('/api/v3/affiliates/social_media_accounts', { credentials: 'include' });
231
240
  return { status: r.status, json: await r.json().catch(() => null) };
232
241
  } catch (e) {
233
242
  return { status: 0, error: String(e?.message || e) };
234
243
  }
235
- });
244
+ }, undefined, evalTimeoutMs);
236
245
 
237
246
  if (isAntiBotBody(result.json)) {
238
247
  return { status: 'needs_verify', accounts: [], reason: ANTI_BOT_REASON };
@@ -266,4 +275,4 @@ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } =
266
275
  }
267
276
  }
268
277
 
269
- module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, SM_PLATFORM };
278
+ module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, evaluateWithin, SM_PLATFORM };
@@ -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.
@@ -178,7 +207,12 @@ function isBlockedState(pageUrl, res) {
178
207
  try { u = new URL(pageUrl || ''); } catch { return false; }
179
208
  // CHỈ xét path + query của CHÍNH trang này. Regex quét cả chuỗi URL đọc nhầm
180
209
  // tham số `next=` của trang khác — xem ghi chú ở findAffiliatePage.
181
- if (/^\/(verify\/captcha|buyer\/login)/.test(u.pathname)) return true;
210
+ //
211
+ // MỌI trang /verify/*, không riêng captcha: Shopee đẩy qua `verify/traffic`
212
+ // trước rồi mới tới `verify/captcha`. 28/09/2026 (Shopee2) lượt đọc báo cáo
213
+ // gặp đúng trang traffic, không nhận ra, chạy fetch trên trang chống bot và
214
+ // treo 12 tiếng tới khi người gỡ captcha.
215
+ if (/^\/(verify\/|buyer\/login)/.test(u.pathname)) return true;
182
216
  if (u.searchParams.has('is_from_login')) return true;
183
217
  return false;
184
218
  }
@@ -337,6 +371,7 @@ async function scrapeOffers(ctx, {
337
371
  delay_ms = 2500, // human-like pause between calls (anti-bot)
338
372
  filters = {},
339
373
  timeoutMs = 45000,
374
+ evalTimeoutMs = EVAL_TIMEOUT_MS,
340
375
  log = null,
341
376
  // legacy single-sort param (back-compat with the first build)
342
377
  sort_type = null,
@@ -384,7 +419,7 @@ async function scrapeOffers(ctx, {
384
419
  for (const st of sort_types) {
385
420
  for (let i = 0; i < pages; i++) {
386
421
  const offset = i * page_limit;
387
- const res = await page.evaluate(async (q) => {
422
+ const res = await evaluateWithin(page, async (q) => {
388
423
  // Từ khoá đứng TRƯỚC ngành hàng: hai chế độ loại trừ nhau, gửi cả hai
389
424
  // thì Shopee bỏ qua keyword và mình tưởng đang tìm mà thật ra vẫn
390
425
  // đang duyệt tab cũ.
@@ -398,7 +433,7 @@ async function scrapeOffers(ctx, {
398
433
  const r = await fetch(url, { credentials: 'include' });
399
434
  return { status: r.status, json: await r.json().catch(() => null) };
400
435
  } catch (e) { return { status: 0, error: String(e.message) }; }
401
- }, { st, offset, page_limit, mid, listType, kw, ft: filter_types });
436
+ }, { st, offset, page_limit, mid, listType, kw, ft: filter_types }, evalTimeoutMs);
402
437
 
403
438
  if (isAntiBotBody(res.json)) {
404
439
  return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: ANTI_BOT_REASON };
@@ -406,6 +441,11 @@ async function scrapeOffers(ctx, {
406
441
  if (isBlockedState(page.url(), res)) {
407
442
  return { status: 'needs_verify', products: [...byId.values()], raw_count: rawCount, reason: `blocked mid-scrape (http ${res.status}, code ${res.json?.code})` };
408
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
+ }
409
449
  if (res.status !== 200 || res.json?.code !== 0) {
410
450
  // soft stop — treat as end-of-data for this sort, move on
411
451
  break;
@@ -478,7 +518,7 @@ function linkFailureReason(res, wanted = 0) {
478
518
  }
479
519
 
480
520
  // subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
481
- 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 } = {}) {
482
522
  const list = (items || [])
483
523
  .map((it) => ({ itemId: String(it.item_id || ''), shopId: Number(it.shop_id) || 0, trace: '' }))
484
524
  .filter((it) => it.itemId && it.shopId);
@@ -499,7 +539,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
499
539
  if (isBlockedState(page.url(), null)) {
500
540
  return { status: 'needs_verify', links: [], reason: 'session at captcha/login — re-verify via VNC' };
501
541
  }
502
- const res = await page.evaluate(async (q) => {
542
+ const res = await evaluateWithin(page, async (q) => {
503
543
  const body = {
504
544
  operationName: 'batchGetProductOfferLink',
505
545
  query: `query batchGetProductOfferLink($sourceCaller: SourceCaller!, $productOfferLinkParams: [ProductOfferLinkParam!]!, $advancedLinkParams: AdvancedLinkParams){ productOfferLinks(productOfferLinkParams: $productOfferLinkParams, sourceCaller: $sourceCaller, advancedLinkParams: $advancedLinkParams) { itemId shopId productOfferLink } }`,
@@ -517,7 +557,7 @@ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 450
517
557
  });
518
558
  return { status: r.status, json: await r.json().catch(() => null) };
519
559
  } catch (e) { return { status: 0, error: String(e.message) }; }
520
- }, { list, subs });
560
+ }, { list, subs }, evalTimeoutMs);
521
561
 
522
562
  if (isAntiBotBody(res.json)) {
523
563
  return { status: 'needs_verify', links: [], reason: ANTI_BOT_REASON };
@@ -576,4 +616,4 @@ function parseProductUrl(url = '') {
576
616
  return null;
577
617
  }
578
618
 
579
- 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.142",
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": {