channel-worker 2.5.148 → 2.5.149

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.
@@ -27,6 +27,8 @@ const SHOPEE_DEADLINE_MS = {
27
27
  get_affiliate_link: 3 * 60 * 1000,
28
28
  ingest_shopee_product: 3 * 60 * 1000,
29
29
  };
30
+ // Mỗi lượt gửi báo cáo về API mang tối đa ngần này đơn và ngần này click.
31
+ const REPORT_INGEST_BATCH = 5000;
30
32
  // Tripo Studio: một lượt HD multiview gồm tải ảnh + tạo (vài phút tới hơn
31
33
  // chục phút tuỳ hàng chờ của Tripo) + xuất GLB. Hết hạn thì bỏ lượt, Studio
32
34
  // vẫn chạy nốt trong tab — người mở profile vẫn thấy model.
@@ -549,14 +551,31 @@ class CommandPoller {
549
551
  days: payload.days || 90,
550
552
  log: (message) => console.log(message),
551
553
  });
554
+ // Click hỏng thì ĐƠN đã đọc xong vẫn phải lưu — 03/10/2026 click Shopee2
555
+ // vượt trần 100 trang, ba lượt sáng vứt luôn phần đơn đọc được. Biểu đồ
556
+ // tính mốc chốt click theo giờ đồng bộ của CLICK, nên lưu riêng đơn không
557
+ // làm ngày chưa có click bị gắn "đã chốt".
552
558
  if (clickResult.status === 'needs_verify') {
559
+ const saved = await this._sendAffiliateReport(result.rows, [], {
560
+ last_report_date: result.last_report_date,
561
+ last_update_time: result.last_update_time,
562
+ });
553
563
  return {
554
564
  status: 'done',
555
- result: { needs_verify: true, reason: clickResult.reason, count: result.rows.length, click_count: clickResult.rows?.length || 0 },
565
+ result: {
566
+ needs_verify: true, reason: clickResult.reason,
567
+ count: result.rows.length, saved: saved.saved,
568
+ click_count: clickResult.rows?.length || 0,
569
+ },
556
570
  };
557
571
  }
558
572
  if (clickResult.status !== 'ok') {
559
- return { status: 'failed', error: String(clickResult.reason || 'Không đọc được báo cáo click Shopee').slice(0, 500) };
573
+ const saved = await this._sendAffiliateReport(result.rows, [], {
574
+ last_report_date: result.last_report_date,
575
+ last_update_time: result.last_update_time,
576
+ });
577
+ const why = clickResult.reason || 'Không đọc được báo cáo click Shopee';
578
+ return { status: 'failed', error: `Click lỗi: ${why}; đã lưu ${saved.saved}/${result.rows.length} đơn`.slice(0, 500) };
560
579
  }
561
580
 
562
581
  // Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
@@ -576,8 +595,7 @@ class CommandPoller {
576
595
  }
577
596
  if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
578
597
 
579
- const saved = await this.api.affiliateReportResult(result.rows, {
580
- clicks: clickResult.rows,
598
+ const saved = await this._sendAffiliateReport(result.rows, clickResult.rows, {
581
599
  // `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
582
600
  // liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
583
601
  // chỉ vì một lượt đọc hỏng.
@@ -587,18 +605,18 @@ class CommandPoller {
587
605
  from: result.from,
588
606
  to: result.to,
589
607
  });
590
- console.log(`[shopee-report] đã lưu ${saved?.saved || 0}/${result.rows.length} đơn, ${saved?.click_saved || 0}/${clickResult.rows.length} click`);
608
+ console.log(`[shopee-report] đã lưu ${saved.saved}/${result.rows.length} đơn, ${saved.click_saved}/${clickResult.rows.length} click`);
591
609
  return {
592
610
  status: 'done',
593
611
  result: {
594
612
  count: result.rows.length,
595
- saved: saved?.saved || 0,
596
- matched: saved?.matched || 0,
597
- unmatched: saved?.unmatched || 0,
613
+ saved: saved.saved,
614
+ matched: saved.matched,
615
+ unmatched: saved.unmatched,
598
616
  click_count: clickResult.rows.length,
599
- click_saved: saved?.click_saved || 0,
600
- click_matched: saved?.click_matched || 0,
601
- click_unmatched: saved?.click_unmatched || 0,
617
+ click_saved: saved.click_saved,
618
+ click_matched: saved.click_matched,
619
+ click_unmatched: saved.click_unmatched,
602
620
  media_accounts: mediaAccounts ? mediaAccounts.length : null,
603
621
  media_error: mediaError || undefined,
604
622
  last_report_date: result.last_report_date,
@@ -606,6 +624,25 @@ class CommandPoller {
606
624
  };
607
625
  }
608
626
 
627
+ // Gửi đơn + click về API theo lô. API chỉ nhận 10.000 đơn / 20.000 click
628
+ // mỗi lượt gửi (AffiliateReportController.workerIngest cắt phần thừa, không
629
+ // báo gì) — đọc được quá trần 100 trang Shopee rồi mà gửi một cục thì vẫn mất.
630
+ // `media_accounts` chỉ đi ở lô đầu: lô sau thiếu trường đó = API giữ nguyên.
631
+ async _sendAffiliateReport(orders, clicks, { media_accounts, ...meta } = {}) {
632
+ const sum = { saved: 0, matched: 0, unmatched: 0, click_saved: 0, click_matched: 0, click_unmatched: 0 };
633
+ const lots = Math.max(1, Math.ceil(orders.length / REPORT_INGEST_BATCH), Math.ceil(clicks.length / REPORT_INGEST_BATCH));
634
+ for (let i = 0; i < lots; i++) {
635
+ const cut = (list) => list.slice(i * REPORT_INGEST_BATCH, (i + 1) * REPORT_INGEST_BATCH);
636
+ const res = await this.api.affiliateReportResult(cut(orders), {
637
+ ...meta,
638
+ clicks: cut(clicks),
639
+ ...(i === 0 && media_accounts !== undefined ? { media_accounts } : {}),
640
+ });
641
+ for (const key of Object.keys(sum)) sum[key] += Number(res?.[key] || 0);
642
+ }
643
+ return sum;
644
+ }
645
+
609
646
  async handlePlaywrightCommand(command) {
610
647
  const { runPlaywrightScript } = require('./playwright-runner');
611
648
  const payload = command.payload || {};
@@ -13,6 +13,87 @@ function reportWindow(days = 90, nowMs = Date.now()) {
13
13
  return { start, end, days: safeDays };
14
14
  }
15
15
 
16
+ // Shopee chỉ cho mỗi truy vấn danh sách báo cáo đọc tới trang 100. Đo 03/10/2026
17
+ // trên store maithithu (Shopee2): 10.410 click trong 90 ngày, page_size 100 →
18
+ // trang 1–100 trả đủ, trang 101 trả `msg: "page num out of limit"` và cả lượt
19
+ // đồng bộ chết. Chưa thử page_size khác nên chưa biết trần tính theo số trang
20
+ // hay theo vị trí dòng — lấy mức thấp hơn của hai cách hiểu.
21
+ const SHOPEE_MAX_PAGE = 100;
22
+ const SHOPEE_MAX_ROWS = 10000;
23
+ const PAGE_LIMIT_RE = /page num out of limit/i;
24
+ // Khoảng ngắn hơn mức này mà vẫn quá trần thì thôi chẻ, báo lỗi.
25
+ const MIN_SPLIT_SEC = 3600;
26
+
27
+ function vnDay(sec) {
28
+ return new Date(sec * 1000 + 7 * 3600e3).toISOString().slice(0, 10);
29
+ }
30
+
31
+ // Đọc mọi dòng của khoảng [start, end] (giây). `readPage(pageNum, start, end)`
32
+ // gọi Shopee một trang, trả { status: 'ok', batch, total } hoặc
33
+ // { status: 'needs_verify' | 'failed', reason }. Khoảng nào Shopee báo nhiều
34
+ // dòng hơn một truy vấn đọc được thì chẻ đôi rồi đọc từng nửa. Hai nửa dùng
35
+ // CHUNG mốc giữa (chưa rõ Shopee có tính dòng nằm đúng mốc cuối không) nên
36
+ // dòng ở mốc đó có thể về hai lần — lọc trùng bằng `keyOf`.
37
+ async function readRange(readPage, {
38
+ start, end, pageSize, maxPages, delayMs, keyOf, name, log,
39
+ }) {
40
+ const cap = Math.min(pageSize * SHOPEE_MAX_PAGE, SHOPEE_MAX_ROWS);
41
+ const rows = [];
42
+ const seen = new Set();
43
+
44
+ const take = (batch) => {
45
+ for (const row of batch) {
46
+ const key = keyOf(row);
47
+ if (key) {
48
+ if (seen.has(key)) continue;
49
+ seen.add(key);
50
+ }
51
+ rows.push(row);
52
+ }
53
+ };
54
+
55
+ async function split(s, e, why) {
56
+ const mid = Math.floor((s + e) / 2);
57
+ if (log) log(`[shopee-report] ${name} ${vnDay(s)}→${vnDay(e)}: ${why} — chia đôi khoảng đọc`);
58
+ const first = await read(s, mid);
59
+ if (first.status !== 'ok') return first;
60
+ await sleep(delayMs);
61
+ return read(mid, e);
62
+ }
63
+
64
+ async function read(s, e) {
65
+ let got = 0;
66
+ let total = null;
67
+ for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
68
+ const r = await readPage(pageNum, s, e);
69
+ if (r.status !== 'ok') {
70
+ // Lưới thứ hai: trần thật thấp hơn mức đã đo thì Shopee vẫn chặn giữa chừng.
71
+ if (r.status === 'failed' && PAGE_LIMIT_RE.test(r.reason || '') && e - s > MIN_SPLIT_SEC) {
72
+ return split(s, e, `Shopee chặn ở trang ${pageNum}`);
73
+ }
74
+ return r;
75
+ }
76
+ total = r.total;
77
+ if (pageNum === 1 && total > cap) {
78
+ if (e - s > MIN_SPLIT_SEC) return split(s, e, `${total} dòng > ${cap} dòng/lượt đọc`);
79
+ return { status: 'failed', reason: `${name} ${vnDay(s)}: ${total} dòng trong chưa tới 1 giờ, vượt trần Shopee ${cap} dòng/lượt đọc` };
80
+ }
81
+ got += r.batch.length;
82
+ take(r.batch);
83
+ if (log) log(`[shopee-report] ${name} trang ${pageNum}: ${r.batch.length} dòng (${got}/${total})`);
84
+ if (!r.batch.length || got >= total || r.batch.length < pageSize) break;
85
+ await sleep(delayMs);
86
+ }
87
+ if (total != null && got < total) {
88
+ return { status: 'failed', reason: `${name}: phân trang dừng ở ${got}/${total} dòng` };
89
+ }
90
+ return { status: 'ok' };
91
+ }
92
+
93
+ const outcome = await read(start, end);
94
+ return { ...outcome, rows };
95
+ }
96
+
16
97
  async function fetchConversionReport(ctx, {
17
98
  days = 90,
18
99
  pageSize = 100,
@@ -41,8 +122,6 @@ async function fetchConversionReport(ctx, {
41
122
  }
42
123
 
43
124
  const window = reportWindow(days);
44
- const rows = [];
45
- let total = null;
46
125
  let lastReportDate = null;
47
126
  let lastUpdateTime = null;
48
127
 
@@ -68,7 +147,7 @@ async function fetchConversionReport(ctx, {
68
147
  lastReportDate = updated?.data?.conversionReportUpdateTime?.lastReportDate || null;
69
148
  lastUpdateTime = updated?.data?.conversionReportUpdateTime?.lastUpdateTime || null;
70
149
 
71
- for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
150
+ const readPage = async (pageNum, start, end) => {
72
151
  const result = await evaluateWithin(page, async (q) => {
73
152
  const url = `/api/v3/report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
74
153
  + `&purchase_time_s=${q.start}&purchase_time_e=${q.end}&version=1`;
@@ -78,33 +157,35 @@ async function fetchConversionReport(ctx, {
78
157
  } catch (e) {
79
158
  return { status: 0, error: String(e?.message || e) };
80
159
  }
81
- }, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
160
+ }, { pageSize, pageNum, start, end }, evalTimeoutMs);
82
161
 
83
162
  if (isAntiBotBody(result.json)) {
84
- return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
163
+ return { status: 'needs_verify', reason: ANTI_BOT_REASON };
85
164
  }
86
165
  if (isBlockedState(page.url(), result)) {
87
- return { status: 'needs_verify', rows, reason: `blocked while reading report (http ${result.status}, code ${result.json?.code})` };
166
+ return { status: 'needs_verify', reason: `blocked while reading report (http ${result.status}, code ${result.json?.code})` };
88
167
  }
89
168
  if (result.status !== 200 || result.json?.code !== 0) {
90
- return { status: 'failed', rows, reason: result.json?.msg || result.error || `report HTTP ${result.status}` };
169
+ return { status: 'failed', reason: result.json?.msg || result.error || `report HTTP ${result.status}` };
91
170
  }
92
171
 
93
172
  const data = result.json?.data || {};
94
- const batch = Array.isArray(data.list) ? data.list : [];
95
- total = Number(data.total_count || 0);
96
- rows.push(...batch);
97
- if (log) log(`[shopee-report] trang ${pageNum}: ${batch.length} dòng (${rows.length}/${total})`);
98
- if (!batch.length || rows.length >= total || batch.length < pageSize) break;
99
- await sleep(delayMs);
100
- }
173
+ return {
174
+ status: 'ok',
175
+ batch: Array.isArray(data.list) ? data.list : [],
176
+ total: Number(data.total_count || 0),
177
+ };
178
+ };
101
179
 
102
- if (total != null && rows.length < total) {
103
- return { status: 'failed', rows, reason: `report pagination stopped at ${rows.length}/${total} rows` };
104
- }
180
+ const read = await readRange(readPage, {
181
+ start: window.start, end: window.end, pageSize, maxPages, delayMs,
182
+ keyOf: (row) => row?.checkout_id, name: 'đơn', log,
183
+ });
184
+ const { rows } = read;
185
+ if (read.status !== 'ok') return { status: read.status, rows, reason: read.reason };
105
186
 
106
187
  return {
107
- status: 'ok', rows, total: total ?? rows.length,
188
+ status: 'ok', rows, total: rows.length,
108
189
  from: new Date(window.start * 1000).toISOString(),
109
190
  to: new Date(window.end * 1000).toISOString(),
110
191
  last_report_date: lastReportDate,
@@ -143,10 +224,8 @@ async function fetchClickReport(ctx, {
143
224
  }
144
225
 
145
226
  const window = reportWindow(days);
146
- const rows = [];
147
- let total = null;
148
227
 
149
- for (let pageNum = 1; pageNum <= maxPages; pageNum++) {
228
+ const readPage = async (pageNum, start, end) => {
150
229
  const result = await evaluateWithin(page, async (q) => {
151
230
  const url = `/api/v1/click_report/list?page_size=${q.pageSize}&page_num=${q.pageNum}`
152
231
  + `&click_time_s=${q.start}&click_time_e=${q.end}`;
@@ -156,40 +235,43 @@ async function fetchClickReport(ctx, {
156
235
  } catch (e) {
157
236
  return { status: 0, error: String(e?.message || e) };
158
237
  }
159
- }, { pageSize, pageNum, start: window.start, end: window.end }, evalTimeoutMs);
238
+ }, { pageSize, pageNum, start, end }, evalTimeoutMs);
160
239
 
161
240
  if (isAntiBotBody(result.json)) {
162
- return { status: 'needs_verify', rows, reason: ANTI_BOT_REASON };
241
+ return { status: 'needs_verify', reason: ANTI_BOT_REASON };
163
242
  }
164
243
  if (isBlockedState(page.url(), result)) {
165
- return { status: 'needs_verify', rows, reason: `blocked while reading click report (http ${result.status}, code ${result.json?.code})` };
244
+ return { status: 'needs_verify', reason: `blocked while reading click report (http ${result.status}, code ${result.json?.code})` };
166
245
  }
167
246
  if (result.status !== 200 || result.json?.code !== 0) {
168
- return { status: 'failed', rows, reason: result.json?.msg || result.error || `click report HTTP ${result.status}` };
247
+ return { status: 'failed', reason: result.json?.msg || result.error || `click report HTTP ${result.status}` };
169
248
  }
170
249
 
171
250
  const data = result.json?.data || {};
172
251
  const batch = Array.isArray(data.list) ? data.list : [];
173
- total = Number(data.total_count || 0);
174
- rows.push(...batch.map((row) => ({
175
- click_id: row.click_id,
176
- click_time: row.click_time,
177
- sub_id: row.sub_id,
178
- click_region: row.click_region,
179
- referrer: row.referrer,
180
- direct_source: row.direct_source,
181
- })));
182
- if (log) log(`[shopee-report] click trang ${pageNum}: ${batch.length} dòng (${rows.length}/${total})`);
183
- if (!batch.length || rows.length >= total || batch.length < pageSize) break;
184
- await sleep(delayMs);
185
- }
252
+ return {
253
+ status: 'ok',
254
+ batch: batch.map((row) => ({
255
+ click_id: row.click_id,
256
+ click_time: row.click_time,
257
+ sub_id: row.sub_id,
258
+ click_region: row.click_region,
259
+ referrer: row.referrer,
260
+ direct_source: row.direct_source,
261
+ })),
262
+ total: Number(data.total_count || 0),
263
+ };
264
+ };
186
265
 
187
- if (total != null && rows.length < total) {
188
- return { status: 'failed', rows, reason: `click pagination stopped at ${rows.length}/${total} rows` };
189
- }
266
+ const read = await readRange(readPage, {
267
+ start: window.start, end: window.end, pageSize, maxPages, delayMs,
268
+ keyOf: (row) => row.click_id, name: 'click', log,
269
+ });
270
+ const { rows } = read;
271
+ if (read.status !== 'ok') return { status: read.status, rows, reason: read.reason };
190
272
 
191
273
  return {
192
- status: 'ok', rows, total: total ?? rows.length,
274
+ status: 'ok', rows, total: rows.length,
193
275
  from: new Date(window.start * 1000).toISOString(),
194
276
  to: new Date(window.end * 1000).toISOString(),
195
277
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.148",
3
+ "version": "2.5.149",
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": {