channel-worker 2.5.142 → 2.5.143

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,13 @@ 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;
23
+
17
24
  class CommandPoller {
18
25
  constructor(api, config) {
19
26
  this.api = api;
@@ -32,6 +39,7 @@ class CommandPoller {
32
39
  // pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
33
40
  // "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
41
  this._pollsInFlight = 0;
42
+ this.affReportDeadlineMs = AFF_REPORT_DEADLINE_MS;
35
43
 
36
44
  // Init Nstbrowser if API key available
37
45
  if (config.nst_api_key) {
@@ -376,102 +384,115 @@ class CommandPoller {
376
384
  async handleFetchAffiliateReport(command) {
377
385
  const payload = command.payload || {};
378
386
  if (!(await this._ensureNst(command))) return;
379
- const report = require('./shopee-report');
380
387
  const profileName = payload.profile_name
381
388
  || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null))
382
389
  || '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
- }
403
390
 
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
- }
421
-
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, {
451
- 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
- },
465
- });
466
- console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} đơn, ${saved?.click_saved || 0}/${clickResult.rows.length} click`);
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);
407
+ });
408
+ try {
409
+ const patch = await Promise.race([work, deadline]);
410
+ await this.api.updateCommand(command._id, patch);
467
411
  } catch (err) {
468
412
  console.error(`[shopee-report] đồng bộ thất bại: ${err.message}`);
469
413
  await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
470
414
  } finally {
415
+ clearTimeout(timer);
416
+ abandoned = true;
471
417
  if (conn) conn.disconnect();
472
418
  }
473
419
  }
474
420
 
421
+ // Đọc đơn + click + liên kết trên một phiên đã nối, trả về bản vá cho lệnh.
422
+ async _readAffiliateReport(context, payload) {
423
+ const report = require('./shopee-report');
424
+ const result = await report.fetchConversionReport(context, {
425
+ days: payload.days || 90,
426
+ log: (message) => console.log(message),
427
+ });
428
+ if (result.status === 'needs_verify') {
429
+ return { status: 'done', result: { needs_verify: true, reason: result.reason, count: result.rows?.length || 0 } };
430
+ }
431
+ if (result.status !== 'ok') {
432
+ return { status: 'failed', error: String(result.reason || 'Không đọc được báo cáo Shopee').slice(0, 500) };
433
+ }
434
+
435
+ const clickResult = await report.fetchClickReport(context, {
436
+ days: payload.days || 90,
437
+ log: (message) => console.log(message),
438
+ });
439
+ if (clickResult.status === 'needs_verify') {
440
+ return {
441
+ status: 'done',
442
+ result: { needs_verify: true, reason: clickResult.reason, count: result.rows.length, click_count: clickResult.rows?.length || 0 },
443
+ };
444
+ }
445
+ if (clickResult.status !== 'ok') {
446
+ return { status: 'failed', error: String(clickResult.reason || 'Không đọc được báo cáo click Shopee').slice(0, 500) };
447
+ }
448
+
449
+ // Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
450
+ // nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
451
+ // profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
452
+ // kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
453
+ let mediaAccounts = null;
454
+ let mediaError = null;
455
+ try {
456
+ const media = await report.fetchSocialMediaAccounts(context, {
457
+ log: (message) => console.log(message),
458
+ });
459
+ if (media.status === 'ok') mediaAccounts = media.accounts;
460
+ else mediaError = media.reason || media.status;
461
+ } catch (err) {
462
+ mediaError = String(err.message || err).slice(0, 200);
463
+ }
464
+ if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
465
+
466
+ const saved = await this.api.affiliateReportResult(result.rows, {
467
+ clicks: clickResult.rows,
468
+ // `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
469
+ // liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
470
+ // chỉ vì một lượt đọc hỏng.
471
+ media_accounts: mediaAccounts,
472
+ last_report_date: result.last_report_date,
473
+ last_update_time: result.last_update_time,
474
+ from: result.from,
475
+ to: result.to,
476
+ });
477
+ console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} đơn, ${saved?.click_saved || 0}/${clickResult.rows.length} click`);
478
+ return {
479
+ status: 'done',
480
+ result: {
481
+ count: result.rows.length,
482
+ saved: saved?.saved || 0,
483
+ matched: saved?.matched || 0,
484
+ unmatched: saved?.unmatched || 0,
485
+ click_count: clickResult.rows.length,
486
+ click_saved: saved?.click_saved || 0,
487
+ click_matched: saved?.click_matched || 0,
488
+ click_unmatched: saved?.click_unmatched || 0,
489
+ media_accounts: mediaAccounts ? mediaAccounts.length : null,
490
+ media_error: mediaError || undefined,
491
+ last_report_date: result.last_report_date,
492
+ },
493
+ };
494
+ }
495
+
475
496
  async handlePlaywrightCommand(command) {
476
497
  const { runPlaywrightScript } = require('./playwright-runner');
477
498
  const payload = command.payload || {};
@@ -2,6 +2,30 @@ const { findAffiliatePage, isBlockedState, isAntiBotBody, ANTI_BOT_REASON } = re
2
2
 
3
3
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
4
4
 
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
+
5
29
  function reportWindow(days = 90, nowMs = Date.now()) {
6
30
  const safeDays = Math.min(180, Math.max(1, Number(days || 90)));
7
31
  const vnNow = new Date(nowMs + 7 * 3600e3);
@@ -17,6 +41,7 @@ async function fetchConversionReport(ctx, {
17
41
  maxPages = 200,
18
42
  delayMs = 750,
19
43
  timeoutMs = 60000,
44
+ evalTimeoutMs = EVAL_TIMEOUT_MS,
20
45
  log = null,
21
46
  } = {}) {
22
47
  let page = findAffiliatePage(ctx.pages());
@@ -43,7 +68,7 @@ async function fetchConversionReport(ctx, {
43
68
  let lastReportDate = null;
44
69
  let lastUpdateTime = null;
45
70
 
46
- const updated = await page.evaluate(async () => {
71
+ const updated = await evaluateWithin(page, async () => {
47
72
  try {
48
73
  const r = await fetch('/api/v3/gql?q=conversionReportUpdateTime', {
49
74
  method: 'POST', credentials: 'include',
@@ -56,12 +81,17 @@ async function fetchConversionReport(ctx, {
56
81
  });
57
82
  return await r.json().catch(() => null);
58
83
  } catch { return null; }
59
- });
84
+ }, undefined, evalTimeoutMs);
85
+ if (updated?.timedOut) {
86
+ return isBlockedState(page.url(), null)
87
+ ? { status: 'needs_verify', rows: [], reason: 'session at captcha/login — re-verify via VNC' }
88
+ : { status: 'failed', rows: [], reason: updated.error };
89
+ }
60
90
  lastReportDate = updated?.data?.conversionReportUpdateTime?.lastReportDate || null;
61
91
  lastUpdateTime = updated?.data?.conversionReportUpdateTime?.lastUpdateTime || null;
62
92
 
63
93
  for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
64
- const result = await page.evaluate(async (q) => {
94
+ const result = await evaluateWithin(page, async (q) => {
65
95
  const url = `/api/v3/report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
66
96
  + `&purchase_time_s=${q.start}&purchase_time_e=${q.end}&version=1`;
67
97
  try {
@@ -70,7 +100,7 @@ async function fetchConversionReport(ctx, {
70
100
  } catch (e) {
71
101
  return { status: 0, error: String(e?.message || e) };
72
102
  }
73
- }, { pageSize, pageNum, start: window.start, end: window.end });
103
+ }, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
74
104
 
75
105
  if (isAntiBotBody(result.json)) {
76
106
  return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
@@ -113,6 +143,7 @@ async function fetchClickReport(ctx, {
113
143
  maxPages = 200,
114
144
  delayMs = 250,
115
145
  timeoutMs = 60000,
146
+ evalTimeoutMs = EVAL_TIMEOUT_MS,
116
147
  log = null,
117
148
  } = {}) {
118
149
  let page = findAffiliatePage(ctx.pages());
@@ -138,7 +169,7 @@ async function fetchClickReport(ctx, {
138
169
  let total = null;
139
170
 
140
171
  for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
141
- const result = await page.evaluate(async (q) => {
172
+ const result = await evaluateWithin(page, async (q) => {
142
173
  const url = `/api/v1/click_report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
143
174
  + `&click_time_s=${q.start}&click_time_e=${q.end}`;
144
175
  try {
@@ -147,7 +178,7 @@ async function fetchClickReport(ctx, {
147
178
  } catch (e) {
148
179
  return { status: 0, error: String(e?.message || e) };
149
180
  }
150
- }, { pageSize, pageNum, start: window.start, end: window.end });
181
+ }, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
151
182
 
152
183
  if (isAntiBotBody(result.json)) {
153
184
  return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
@@ -206,7 +237,7 @@ async function fetchClickReport(ctx, {
206
237
  // khác giữ nguyên số để sau này đọc lại, không đoán tên).
207
238
  const SM_PLATFORM = { 6: 'facebook', 3: 'tiktok' };
208
239
 
209
- async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } = {}) {
240
+ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, evalTimeoutMs = EVAL_TIMEOUT_MS, log = null } = {}) {
210
241
  let page = findAffiliatePage(ctx.pages());
211
242
  let opened = false;
212
243
  if (!page) {
@@ -225,14 +256,14 @@ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } =
225
256
  return { status: 'needs_verify', accounts: [], reason: 'session at captcha/login — re-verify via VNC' };
226
257
  }
227
258
 
228
- const result = await page.evaluate(async () => {
259
+ const result = await evaluateWithin(page, async () => {
229
260
  try {
230
261
  const r = await fetch('/api/v3/affiliates/social_media_accounts', { credentials: 'include' });
231
262
  return { status: r.status, json: await r.json().catch(() => null) };
232
263
  } catch (e) {
233
264
  return { status: 0, error: String(e?.message || e) };
234
265
  }
235
- });
266
+ }, undefined, evalTimeoutMs);
236
267
 
237
268
  if (isAntiBotBody(result.json)) {
238
269
  return { status: 'needs_verify', accounts: [], reason: ANTI_BOT_REASON };
@@ -266,4 +297,4 @@ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } =
266
297
  }
267
298
  }
268
299
 
269
- module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, SM_PLATFORM };
300
+ module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, evaluateWithin, SM_PLATFORM };
@@ -178,7 +178,12 @@ function isBlockedState(pageUrl, res) {
178
178
  try { u = new URL(pageUrl || ''); } catch { return false; }
179
179
  // CHỈ xét path + query của CHÍNH trang này. Regex quét cả chuỗi URL đọc nhầm
180
180
  // tham số `next=` của trang khác — xem ghi chú ở findAffiliatePage.
181
- if (/^\/(verify\/captcha|buyer\/login)/.test(u.pathname)) return true;
181
+ //
182
+ // MỌI trang /verify/*, không riêng captcha: Shopee đẩy qua `verify/traffic`
183
+ // trước rồi mới tới `verify/captcha`. 28/09/2026 (Shopee2) lượt đọc báo cáo
184
+ // gặp đúng trang traffic, không nhận ra, chạy fetch trên trang chống bot và
185
+ // treo 12 tiếng tới khi người gỡ captcha.
186
+ if (/^\/(verify\/|buyer\/login)/.test(u.pathname)) return true;
182
187
  if (u.searchParams.has('is_from_login')) return true;
183
188
  return false;
184
189
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.142",
3
+ "version": "2.5.143",
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": {