channel-worker 2.5.137 → 2.5.139

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/cli.js CHANGED
@@ -285,6 +285,93 @@ if (cmd === 'pair') {
285
285
  }
286
286
  }
287
287
 
288
+ } else if (cmd === 'drain') {
289
+ // Tắt NHẸ: daemon ngừng nhận việc mới, chờ việc đang chạy xong rồi tự tắt.
290
+ // Dùng thay `taskkill /f` / `stop` khi phát hành — xem lib/drain.js (soát
291
+ // 23/09/2026: 9/9 phiên nuôi "Timed out" còn log là bị giết giữa phiên lúc
292
+ // thay bản). Mã thoát: 0 = daemon đã tắt sạch (hoặc không có daemon);
293
+ // 2 = hết hạn, daemon QUAY LẠI làm việc, người quyết định dừng cứng hay chờ;
294
+ // 3 = daemon cũ tắt nhưng đã có daemon MỚI giữ khoá (watchdog/updater bật lại).
295
+ const singleton = require('../lib/singleton');
296
+ const drain = require('../lib/drain');
297
+ const lock = singleton.readLock(CONFIG_DIR);
298
+ if (args.cancel) {
299
+ drain.clearRequest(CONFIG_DIR);
300
+ console.log('[channel-worker] Đã huỷ yêu cầu xả — daemon nhận việc lại ở nhịp kế (≤5s).');
301
+ process.exit(0);
302
+ }
303
+ if (!lock || singleton.isStale(lock)) {
304
+ drain.clearRequest(CONFIG_DIR);
305
+ console.log(`[channel-worker] Không có daemon nào đang sống ở ${CONFIG_DIR} — không có gì để xả.`);
306
+ process.exit(0);
307
+ }
308
+ const timeoutMin = Number(args.timeout || drain.DEFAULT_TIMEOUT_MIN);
309
+ if (!Number.isFinite(timeoutMin) || timeoutMin <= 0) {
310
+ console.error('[channel-worker] --timeout phải là số phút > 0');
311
+ process.exit(1);
312
+ }
313
+ const req = drain.writeRequest(CONFIG_DIR, { timeoutMin });
314
+ console.log(`[channel-worker] Đã gửi yêu cầu xả tới daemon PID ${lock.pid} (hạn ${req.deadline_at}).`);
315
+ if (process.platform === 'win32') {
316
+ console.log('[channel-worker] Nhớ: watchdog bật lại daemon sau ≤5 phút nếu chưa có C:\\agents\\watchdog.pause.');
317
+ }
318
+ if (lock.supervisor === 'pm2') {
319
+ console.log('[channel-worker] Daemon này chạy dưới pm2: xả xong nó tự tắt nhưng pm2 dựng lại ngay (lệnh sẽ trả mã 3). '
320
+ + 'Trên máy pm2 cứ để bộ tự cập nhật lo — từ 2.5.139 nó tự xả trước khi cài.');
321
+ }
322
+ if (args['no-wait']) process.exit(0);
323
+
324
+ (async () => {
325
+ // Daemon soi yêu cầu mỗi 5s và tự quay lại làm việc khi hết hạn — chờ quá
326
+ // hạn thêm 30s là chắc chắn đã có kết cục.
327
+ const hardStop = Date.parse(req.deadline_at) + 30000;
328
+ let lastSay = 0;
329
+ let deadAt = 0;
330
+ for (;;) {
331
+ await new Promise((r) => setTimeout(r, 2000));
332
+ const cur = singleton.readLock(CONFIG_DIR);
333
+ const newHolder = cur && cur.pid !== lock.pid && !singleton.isStale(cur) ? cur : null;
334
+ if (!singleton.pidAlive(lock.pid)) {
335
+ // "Đã tắt" chỉ tin khi TIẾN TRÌNH chết thật — khoá nhả sớm hơn thế (tắt
336
+ // dở, hoặc bộ tự cập nhật nhả khoá rồi đẻ bản thay). Chết rồi thì soi
337
+ // thêm 2 nhịp: bản thay của updater giành khoá ngay sau đó, và báo
338
+ // "đã dừng" lúc ấy là để người phát hành cài đè lên cây một daemon MỚI
339
+ // đang nạp (đúng loại sự cố MODULE_NOT_FOUND 08/09).
340
+ if (newHolder) {
341
+ console.error(`[channel-worker] Daemon PID ${lock.pid} đã tắt, nhưng daemon MỚI PID ${newHolder.pid} đang giữ khoá `
342
+ + `(${newHolder.supervisor === 'pm2' ? 'pm2 dựng lại' : 'watchdog hoặc bộ tự cập nhật vừa bật lại'}) — chưa phải trạng thái "đã dừng".`);
343
+ process.exit(3);
344
+ }
345
+ if (!deadAt) deadAt = Date.now();
346
+ if (Date.now() - deadAt >= 4000) {
347
+ console.log(`[channel-worker] Xả xong — daemon PID ${lock.pid} đã tự tắt, không cắt việc nào.`);
348
+ process.exit(0);
349
+ }
350
+ continue;
351
+ }
352
+ if (Date.now() > hardStop) {
353
+ console.error(`[channel-worker] Quá hạn mà daemon PID ${lock.pid} vẫn sống `
354
+ + `(${cur && cur.pid === lock.pid ? 'vẫn giữ khoá — bản daemon cũ chưa biết lệnh drain?' : 'đã nhả khoá nhưng chưa thoát'}). `
355
+ + 'Kiểm "channel-worker logs".');
356
+ process.exit(2);
357
+ }
358
+ // Khoá đã nhả / sang tay khác mà tiến trình cũ còn sống = đang tắt dở →
359
+ // chờ nó thoát hẳn.
360
+ if (!cur || cur.pid !== lock.pid) continue;
361
+ const status = drain.readStatus(CONFIG_DIR);
362
+ if (!drain.readRequest(CONFIG_DIR)) {
363
+ console.error('[channel-worker] Daemon đã THÔI XẢ và quay lại làm việc (hết hạn hoặc bị huỷ). '
364
+ + `Việc còn dở lúc đó: ${drain.describeBusy(status && status.busy)}. `
365
+ + 'Chờ thêm: chạy lại "channel-worker drain". Chấp nhận cắt: "channel-worker stop".');
366
+ process.exit(2);
367
+ }
368
+ if (Date.now() - lastSay >= 30000) {
369
+ lastSay = Date.now();
370
+ console.log(`[channel-worker] … đang chờ: ${status ? drain.describeBusy(status.busy) : 'daemon chưa nhận yêu cầu (nhịp 5s)'}`);
371
+ }
372
+ }
373
+ })();
374
+
288
375
  } else if (cmd === 'logs') {
289
376
  const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
290
377
  if (!fs.existsSync(LOG_FILE)) {
@@ -337,7 +424,9 @@ Commands:
337
424
  pair Pair with dashboard using a one-time code (recommended)
338
425
  init Configure worker manually
339
426
  start Start the daemon in background
340
- stop Stop the daemon
427
+ stop Stop the daemon (cắt ngang việc đang chạy)
428
+ drain Xả: ngừng nhận việc mới, chờ việc đang chạy xong rồi tự tắt
429
+ (--timeout <phút>, mặc định 25 · --no-wait · --cancel)
341
430
  restart Restart the daemon
342
431
  logs Show recent daemon logs (--lines <n>, default 50)
343
432
  update Check and install updates manually
@@ -21,6 +21,17 @@ class CommandPoller {
21
21
  this.timer = null;
22
22
  this.pollIntervalMs = 3000;
23
23
  this.nst = null;
24
+ // XẢ (lib/drain.js): tạm ngừng NHẬN việc mới, việc đang chạy vẫn chạy tiếp.
25
+ this._paused = false;
26
+ // Mọi lệnh đang xử lý, MỌI loại (không riêng _pw): id → type. poll() chạy
27
+ // theo setInterval, không chờ lệnh trước xong, nên một phiên nuôi 15 phút
28
+ // chạy song song với vài lượt lấy số liệu — daemon cần sổ này để biết lúc
29
+ // nào hết việc thật.
30
+ this._handling = new Map();
31
+ // Lượt poll đang bay (đã gửi GET /workers/commands, chưa về). API lật lệnh
32
+ // pending→running NGAY lúc trả về, nên một lượt đang bay lúc daemon quyết
33
+ // "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
+ this._pollsInFlight = 0;
24
35
 
25
36
  // Init Nstbrowser if API key available
26
37
  if (config.nst_api_key) {
@@ -69,10 +80,25 @@ class CommandPoller {
69
80
  if (this._profileTimeoutTimer) { clearInterval(this._profileTimeoutTimer); this._profileTimeoutTimer = null; }
70
81
  }
71
82
 
83
+ // Không dừng timer: đồng bộ profile và đóng profile rảnh vẫn phải chạy trong
84
+ // lúc xả, chỉ riêng việc NHẬN lệnh / phát cảnh là ngừng.
85
+ pauseClaims() { this._paused = true; }
86
+ resumeClaims() { this._paused = false; }
87
+
72
88
  async poll() {
89
+ if (this._paused) return;
90
+ let command = null;
91
+ this._pollsInFlight++;
73
92
  try {
74
- const command = await this.api.getNextCommand(this.config.worker_id);
93
+ try {
94
+ command = await this.api.getNextCommand(this.config.worker_id);
95
+ } finally {
96
+ this._pollsInFlight--;
97
+ }
75
98
  if (!command) return;
99
+ // Lượt poll đã gửi đi TRƯỚC lúc xả vẫn có thể nhận về một lệnh — nó được
100
+ // ghi sổ như mọi lệnh khác, và daemon chờ nó xong mới tắt.
101
+ this._handling.set(String(command._id), command.type);
76
102
 
77
103
  console.log(`[commands] Received: ${command.type} (${command._id})`);
78
104
 
@@ -146,6 +172,8 @@ class CommandPoller {
146
172
  if (this.config.verbose) {
147
173
  console.error(`[commands] Poll error: ${err.message}`);
148
174
  }
175
+ } finally {
176
+ if (command) this._handling.delete(String(command._id));
149
177
  }
150
178
  }
151
179
 
@@ -388,8 +416,29 @@ class CommandPoller {
388
416
  return;
389
417
  }
390
418
 
419
+ // Danh sách page đã liên kết với tài khoản Shopee — đi CHUNG lượt này vì
420
+ // nó dùng đúng phiên vừa mở, thêm một request chứ không thêm lần mở
421
+ // profile. LỖI MỀM: đọc hỏng thì mất phần cảnh báo liên kết, không được
422
+ // kéo theo cả lượt đồng bộ click/đơn (đó mới là việc chính).
423
+ let mediaAccounts = null;
424
+ let mediaError = null;
425
+ try {
426
+ const media = await report.fetchSocialMediaAccounts(conn.context, {
427
+ log: (message) => console.log(message),
428
+ });
429
+ if (media.status === 'ok') mediaAccounts = media.accounts;
430
+ else mediaError = media.reason || media.status;
431
+ } catch (err) {
432
+ mediaError = String(err.message || err).slice(0, 200);
433
+ }
434
+ if (mediaError) console.warn(`[shopee-report] không đọc được danh sách liên kết: ${mediaError}`);
435
+
391
436
  const saved = await this.api.affiliateReportResult(result.rows, {
392
437
  clicks: clickResult.rows,
438
+ // `null` = KHÔNG đọc được, khác hẳn `[]` = đọc được và tài khoản chưa
439
+ // liên kết page nào. API phải phân biệt để không xoá sạch bản ghi cũ
440
+ // chỉ vì một lượt đọc hỏng.
441
+ media_accounts: mediaAccounts,
393
442
  last_report_date: result.last_report_date,
394
443
  last_update_time: result.last_update_time,
395
444
  from: result.from,
@@ -406,6 +455,8 @@ class CommandPoller {
406
455
  click_saved: saved?.click_saved || 0,
407
456
  click_matched: saved?.click_matched || 0,
408
457
  click_unmatched: saved?.click_unmatched || 0,
458
+ media_accounts: mediaAccounts ? mediaAccounts.length : null,
459
+ media_error: mediaError || undefined,
409
460
  last_report_date: result.last_report_date,
410
461
  },
411
462
  });
@@ -1837,7 +1888,7 @@ class CommandPoller {
1837
1888
  // Processes ONE command per cycle — sequential, no race conditions.
1838
1889
 
1839
1890
  async _dispatchScenes() {
1840
- if (this._dispatching) return;
1891
+ if (this._paused || this._dispatching) return;
1841
1892
  this._dispatching = true;
1842
1893
  try {
1843
1894
  // 1. Init Nstbrowser if needed
@@ -2247,7 +2298,7 @@ class CommandPoller {
2247
2298
  // generate_thumbnail command is pending, so Veo3 renderers stay untouched.
2248
2299
 
2249
2300
  async _dispatchChatgpt() {
2250
- if (this._chatgptDispatching) return;
2301
+ if (this._paused || this._chatgptDispatching) return;
2251
2302
  this._chatgptDispatching = true;
2252
2303
  try {
2253
2304
  // Lazy-init NstManager — the daemon config often ships with an empty
package/lib/daemon.js CHANGED
@@ -7,9 +7,19 @@ const { checkAndUpdateExtension } = require('./extension-updater');
7
7
  const { CacheServer } = require('./cache-server');
8
8
  const { agentAddressForUser } = require('./nst-agent-locator');
9
9
  const singleton = require('./singleton');
10
+ const drain = require('./drain');
10
11
  const os = require('os');
11
12
  const path = require('path');
12
13
 
14
+ // Bộ tự cập nhật thấy bản mới mà đang bận → xả tối đa ngần này rồi cài luôn.
15
+ // Quá hạn mà còn việc = việc dài bất thường; cài luôn còn hơn kẹt mãi bản cũ.
16
+ const UPDATE_DRAIN_MS = drain.DEFAULT_TIMEOUT_MIN * 60e3;
17
+ // Lượt cài sau khi xả mà hỏng (registry chưa công bố tarball, cây cài cụt…)
18
+ // thì chờ ngần này mới được xả lại để cập nhật. Không có nó, máy bận cứ quay
19
+ // vòng: xả → cài hỏng → nhận việc 10 giây → xả tiếp — tệ hơn bản trước, vốn
20
+ // cứ làm việc bình thường và thử lại 5 phút một lần.
21
+ const UPDATE_RETRY_MS = 15 * 60e3;
22
+
13
23
  class Daemon {
14
24
  constructor(config) {
15
25
  this.config = config;
@@ -30,20 +40,45 @@ class Daemon {
30
40
  // Pass daemon URL down so command-poller can write it into per-profile config
31
41
  config.daemon_url = this.cacheServer.url();
32
42
  this.commandPoller = new CommandPoller(this.api, config);
33
- this.updateChecker = new UpdateChecker(5 * 60 * 1000); // check every 5min
43
+ // Có bản mới mà đang bận thì KHÔNG khởi động lại ngang — xả trước (xem
44
+ // _tickDrain), rảnh rồi mới cài.
45
+ this.updateChecker = new UpdateChecker(5 * 60 * 1000, {
46
+ isBusy: () => this._isBusy(),
47
+ onDeferred: (r) => this._onUpdateDeferred(r),
48
+ });
49
+ this._updateRetryAt = 0;
34
50
  this.extCheckTimer = null;
35
51
  this.nstAgentTimer = null;
36
52
  this.lockTimer = null;
53
+ this.drainTimer = null;
37
54
  this.configDir = path.join(os.homedir(), '.channel-worker');
38
55
  this._evictLogged = false;
56
+ // Yêu cầu xả ghi trước mốc này là của đời daemon trước — xem drain.isForMe.
57
+ this.startedAt = Date.now();
58
+ this._drain = null; // { reason: 'cli'|'update', since, deadline, latest }
59
+ this._drainFinishing = false;
39
60
  }
40
61
 
41
- /** Đang bận = có job đang chạy, hoặc có phiên Playwright đang giữ profile. */
62
+ /**
63
+ * Đang bận = có job đang chạy, có phiên Playwright đang giữ profile, hoặc có
64
+ * lệnh nào đang xử lý dở (mọi loại — sổ _handling của command poller).
65
+ */
42
66
  _isBusy() {
67
+ const b = this._busyDetail();
68
+ return b.jobs > 0 || b.pw > 0 || b.commands.length > 0 || b.polls > 0;
69
+ }
70
+
71
+ _busyDetail() {
43
72
  const jobs = (this.poller && this.poller.running) ? this.poller.running.size : 0;
44
73
  const pw = (this.commandPoller && this.commandPoller._pwInFlight)
45
74
  ? this.commandPoller._pwInFlight.size : 0;
46
- return jobs > 0 || pw > 0;
75
+ const commands = (this.commandPoller && this.commandPoller._handling)
76
+ ? [...this.commandPoller._handling.values()] : [];
77
+ // Lượt hỏi việc đang bay: API nhận lệnh/job ngay lúc trả về, tắt lúc này là
78
+ // giết một việc vừa bị nhận (xem CommandPoller._pollsInFlight).
79
+ const polls = ((this.commandPoller && this.commandPoller._pollsInFlight) || 0)
80
+ + ((this.poller && this.poller.pollsInFlight) || 0);
81
+ return { jobs, pw, commands, polls };
47
82
  }
48
83
 
49
84
  /**
@@ -76,6 +111,158 @@ class Daemon {
76
111
  else process.exit(0);
77
112
  }
78
113
 
114
+ // ─── XẢ: ngừng nhận việc mới, chờ việc đang chạy xong rồi mới tắt ──────────
115
+ // Hai đường vào:
116
+ // 'cli' — người phát hành chạy `channel-worker drain` (ghi drain.request).
117
+ // Xả xong thì TẮT. Hết hạn mà còn việc thì QUAY LẠI làm việc —
118
+ // người quyết định dừng cứng hay chờ thêm, đàn không bao giờ đứng
119
+ // im chỉ vì một lệnh xả bị bỏ quên.
120
+ // 'update' — bộ tự cập nhật thấy bản mới mà đang bận. Xả xong thì cài +
121
+ // khởi động lại. Hết hạn thì cài luôn (cắt việc dài bất thường).
122
+ // Trong lúc xả: heartbeat, nhịp khoá, đồng bộ/đóng profile rảnh vẫn chạy;
123
+ // chỉ ngừng nhận lệnh, nhận job, phát cảnh, và tắt bộ tự cập nhật (một lượt
124
+ // cài + khởi động lại lọt vào giữa là cắt đúng việc đang chờ).
125
+ _enterDrain({ reason, deadline, latest = null }) {
126
+ if (this._drain) return;
127
+ this._drain = { reason, since: Date.now(), deadline, latest };
128
+ if (this.commandPoller.pauseClaims) this.commandPoller.pauseClaims();
129
+ if (this.poller.pause) this.poller.pause();
130
+ this.updateChecker.stop();
131
+ const why = reason === 'update' ? `để cập nhật lên ${latest}` : 'theo lệnh "channel-worker drain"';
132
+ console.log(`[daemon] XẢ ${why}: ngừng nhận việc mới, chờ ${drain.describeBusy(this._busyDetail())} `
133
+ + `— hạn ${new Date(deadline).toISOString()}`);
134
+ this._writeDrainStatus();
135
+ }
136
+
137
+ _resumeFromDrain(why) {
138
+ console.warn(`[daemon] Thôi xả (${why}) — nhận việc lại bình thường.`);
139
+ const wasCli = !!this._drain && this._drain.reason === 'cli';
140
+ this._drain = null;
141
+ // Chỉ dọn yêu cầu khi đang xả VÌ nó (hết hạn / huỷ) — đó là tín hiệu cho lệnh
142
+ // drain đang chờ. Thôi xả để cập nhật mà xoá luôn là nuốt mất lệnh drain
143
+ // người phát hành vừa gửi giữa lúc npm đang cài; để nguyên thì nhịp sau
144
+ // nhận nó và xả để TẮT.
145
+ if (wasCli) drain.clearRequest(this.configDir);
146
+ drain.clearStatus(this.configDir);
147
+ if (this.commandPoller.resumeClaims) this.commandPoller.resumeClaims();
148
+ if (this.poller.resume) this.poller.resume();
149
+ // Không bật lại lượt kiểm 10 giây đầu: vừa thôi xả vì cài hỏng mà kiểm ngay
150
+ // là quay lại đúng vòng xả-cài-hỏng.
151
+ this.updateChecker.start({ initialDelay: false });
152
+ }
153
+
154
+ _onUpdateDeferred(r, now = Date.now()) {
155
+ if (now < this._updateRetryAt) return; // vừa cài hỏng — đợi, đừng xả lại liên tục
156
+ this._enterDrain({ reason: 'update', deadline: now + UPDATE_DRAIN_MS, latest: r.latest });
157
+ }
158
+
159
+ _writeDrainStatus() {
160
+ if (!this._drain) return;
161
+ drain.writeStatus(this.configDir, {
162
+ pid: process.pid,
163
+ reason: this._drain.reason,
164
+ since: new Date(this._drain.since).toISOString(),
165
+ deadline: new Date(this._drain.deadline).toISOString(),
166
+ busy: this._busyDetail(),
167
+ });
168
+ }
169
+
170
+ async _tickDrain(now = Date.now()) {
171
+ if (this._drainFinishing) return;
172
+ // Lệnh drain là cho CHỦ KHOÁ. Daemon thừa (mất khoá) đã tự tắt lúc rảnh ở
173
+ // _tickLock; để nó nghe lệnh là nó xả xong trước rồi xoá mất yêu cầu, chủ
174
+ // khoá đang xả dở thấy "bị huỷ" và quay lại nhận việc.
175
+ if (!singleton.owns(this.configDir)) return;
176
+ const req = drain.readRequest(this.configDir);
177
+ if (req && !drain.isForMe(req, this.startedAt)) {
178
+ console.log(`[daemon] Bỏ yêu cầu xả cũ (${req.requested_at}) — của daemon đời trước.`);
179
+ drain.clearRequest(this.configDir);
180
+ } else if (req && (!this._drain || this._drain.reason !== 'cli')) {
181
+ // Người phát hành thắng bộ tự cập nhật: họ muốn daemon TẮT, không muốn
182
+ // nó tự cài rồi bật lại ngay dưới tay họ.
183
+ if (this._drain) {
184
+ this._drain.reason = 'cli';
185
+ this._drain.deadline = Date.parse(req.deadline_at);
186
+ console.log('[daemon] Lệnh drain đến giữa lúc xả để cập nhật — chuyển sang xả để TẮT.');
187
+ } else {
188
+ this._enterDrain({ reason: 'cli', deadline: Date.parse(req.deadline_at) });
189
+ }
190
+ } else if (!req && this._drain && this._drain.reason === 'cli') {
191
+ this._resumeFromDrain('yêu cầu xả đã bị huỷ');
192
+ return;
193
+ }
194
+ if (!this._drain) return;
195
+
196
+ this._writeDrainStatus();
197
+ if (!this._isBusy()) {
198
+ await this._finishDrain();
199
+ return;
200
+ }
201
+ if (now > this._drain.deadline) {
202
+ const left = drain.describeBusy(this._busyDetail());
203
+ if (this._drain.reason === 'update') {
204
+ console.warn(`[daemon] Hết hạn xả mà vẫn còn ${left} — cài bản mới luôn, việc đó sẽ bị cắt.`);
205
+ await this._finishDrain();
206
+ } else {
207
+ this._resumeFromDrain(`hết hạn xả, vẫn còn ${left}`);
208
+ }
209
+ }
210
+ }
211
+
212
+ async _finishDrain() {
213
+ this._drainFinishing = true;
214
+ try {
215
+ if (this._drain.reason === 'update') {
216
+ console.log('[daemon] Xả xong — cài bản mới rồi khởi động lại.');
217
+ // Cài xong thì check() tự process.exit. Tới được dòng sau = KHÔNG khởi
218
+ // động lại (registry chưa công bố tarball, cây cài hỏng…) → làm tiếp.
219
+ const r = await this.updateChecker.check({ ignoreBusy: true });
220
+ this._updateRetryAt = Date.now() + UPDATE_RETRY_MS;
221
+ this._resumeFromDrain(`chưa cập nhật được${r && r.error ? `: ${r.error}` : ''}, `
222
+ + `${UPDATE_RETRY_MS / 60e3} phút nữa mới xả lại để cập nhật`);
223
+ return;
224
+ }
225
+ console.log('[daemon] Xả xong — không còn việc dở, tự tắt.');
226
+ // shutdown nhả khoá rồi mới dọn drain.request (xem ghi chú ở đó).
227
+ if (this._shutdown) await this._shutdown();
228
+ else process.exit(0);
229
+ } finally {
230
+ this._drainFinishing = false;
231
+ }
232
+ }
233
+
234
+ /** Dừng mọi nhịp, nhả khoá, dọn file xả, báo offline — mọi thứ trừ process.exit. */
235
+ async _teardown() {
236
+ this.heartbeat.stop();
237
+ this.poller.stop();
238
+ this.commandPoller.stop();
239
+ this.updateChecker.stop();
240
+ if (this.extCheckTimer) clearInterval(this.extCheckTimer);
241
+ if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
242
+ if (this.lockTimer) clearInterval(this.lockTimer);
243
+ if (this.drainTimer) clearInterval(this.drainTimer);
244
+ try { this.cacheServer.stop(); } catch {}
245
+ // Nhả khoá để con kế tiếp vào ngay, khỏi phải chờ hết hạn STALE.
246
+ let wasOwner = false;
247
+ try { wasOwner = singleton.release(this.configDir); } catch {}
248
+ // Dọn file xả SAU khi nhả khoá: lệnh drain đang chờ thấy khoá nhả trước
249
+ // → "xả xong"; ngược thứ tự nó thấy yêu cầu biến mất khi khoá còn đó và
250
+ // báo nhầm "daemon thôi xả". Chỉ dọn khi CHÍNH mình là chủ khoá đang xả —
251
+ // daemon thừa tự tắt (kể cả con từng xả dở trước khi bị `start --force`
252
+ // cướp khoá) mà xoá thì yêu cầu gửi cho chủ khoá MỚI biến mất giữa chừng.
253
+ if (wasOwner && this._drain) {
254
+ drain.clearRequest(this.configDir);
255
+ drain.clearStatus(this.configDir);
256
+ }
257
+
258
+ // Mark offline
259
+ try {
260
+ await this.api.request('PUT', `/workers/${this.config.worker_id}`, {
261
+ status: 'offline',
262
+ });
263
+ } catch { /* ignore */ }
264
+ }
265
+
79
266
  /**
80
267
  * Point NST_API_ADDRESS at the agent of the Windows user we're paired with.
81
268
  *
@@ -195,6 +382,14 @@ class Daemon {
195
382
  this.lockTimer = setInterval(() => { this._tickLock().catch(() => {}); }, singleton.REFRESH_MS);
196
383
  console.log(`[daemon] Singleton lock watcher started (every ${singleton.REFRESH_MS / 1000}s)`);
197
384
 
385
+ // Nhịp xả — soi drain.request (lệnh "channel-worker drain") và theo dõi
386
+ // việc dở trong lúc xả. Trạng thái xả của đời trước là rác, dọn luôn.
387
+ drain.clearStatus(this.configDir);
388
+ this.drainTimer = setInterval(() => {
389
+ this._tickDrain().catch((e) => console.warn(`[daemon] nhịp xả lỗi: ${e.message}`));
390
+ }, drain.TICK_MS);
391
+ console.log(`[daemon] Drain watcher started (every ${drain.TICK_MS / 1000}s)`);
392
+
198
393
  // Start auto-update checker
199
394
  this.updateChecker.start();
200
395
  console.log('[daemon] Auto-update checker started (every 5min)');
@@ -219,24 +414,7 @@ class Daemon {
219
414
  // Graceful shutdown
220
415
  const shutdown = async () => {
221
416
  console.log('\n[daemon] Shutting down...');
222
- this.heartbeat.stop();
223
- this.poller.stop();
224
- this.commandPoller.stop();
225
- this.updateChecker.stop();
226
- if (this.extCheckTimer) clearInterval(this.extCheckTimer);
227
- if (this.nstAgentTimer) clearInterval(this.nstAgentTimer);
228
- if (this.lockTimer) clearInterval(this.lockTimer);
229
- try { this.cacheServer.stop(); } catch {}
230
- // Nhả khoá để con kế tiếp vào ngay, khỏi phải chờ hết hạn STALE.
231
- try { singleton.release(this.configDir); } catch {}
232
-
233
- // Mark offline
234
- try {
235
- await this.api.request('PUT', `/workers/${this.config.worker_id}`, {
236
- status: 'offline',
237
- });
238
- } catch { /* ignore */ }
239
-
417
+ await this._teardown();
240
418
  console.log('[daemon] Goodbye.');
241
419
  process.exit(0);
242
420
  };
package/lib/drain.js ADDED
@@ -0,0 +1,93 @@
1
+ /**
2
+ * XẢ daemon: ngừng nhận việc mới, chờ việc đang chạy xong rồi mới tắt.
3
+ *
4
+ * Vì sao có (soát 23/09/2026): phát hành worker tắt daemon bằng
5
+ * `taskkill /f /im node.exe`, còn bộ tự cập nhật thấy bản mới là cài + khởi
6
+ * động lại ngay — cả hai đều không nhìn việc đang chạy. 9/9 phiên nuôi FB
7
+ * "Timed out … crash giữa chừng" còn log đều là daemon bị thay bằng bản mới
8
+ * GIỮA phiên (31 bản trong 10 ngày), và cùng cơ chế đã cắt ngang lượt đăng bài
9
+ * (kenh-71 lúc 22:29 ngày 23/09, đang ở khung soạn Reel).
10
+ *
11
+ * Giao tiếp qua FILE trong CONFIG_DIR, không qua tín hiệu: trên Windows
12
+ * `process.kill(pid, 'SIGTERM')` là TerminateProcess — không có cách nào nhờ
13
+ * tiến trình khác "tắt nhẹ nhàng". Người phát hành chạy `channel-worker drain`,
14
+ * lệnh đó ghi `drain.request`; daemon soi file mỗi TICK_MS.
15
+ */
16
+
17
+ const fs = require('fs');
18
+ const path = require('path');
19
+
20
+ const REQUEST_FILE = 'drain.request';
21
+ const STATUS_FILE = 'drain.status';
22
+ // Phiên nuôi dài nhất đo được 18 phút, lượt đăng FB có thể chờ nút Đăng 10 phút
23
+ // cộng các bước trước — 25 phút phủ cả hai mà không treo lượt phát hành cả buổi.
24
+ const DEFAULT_TIMEOUT_MIN = 25;
25
+ const TICK_MS = 5000;
26
+
27
+ const requestPath = (dir) => path.join(dir, REQUEST_FILE);
28
+ const statusPath = (dir) => path.join(dir, STATUS_FILE);
29
+
30
+ function writeRequest(dir, { timeoutMin = DEFAULT_TIMEOUT_MIN, now = Date.now() } = {}) {
31
+ const req = {
32
+ requested_at: new Date(now).toISOString(),
33
+ deadline_at: new Date(now + timeoutMin * 60e3).toISOString(),
34
+ by_pid: process.pid,
35
+ };
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ fs.writeFileSync(requestPath(dir), JSON.stringify(req));
38
+ return req;
39
+ }
40
+
41
+ function readRequest(dir) {
42
+ try {
43
+ const req = JSON.parse(fs.readFileSync(requestPath(dir), 'utf-8'));
44
+ if (!req || !Number.isFinite(Date.parse(req.requested_at)) || !Number.isFinite(Date.parse(req.deadline_at))) return null;
45
+ return req;
46
+ } catch {
47
+ return null;
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Yêu cầu có phải cho daemon này không. Yêu cầu ghi TRƯỚC lúc daemon khởi
53
+ * động là rác của đời trước (daemon cũ chết trước khi kịp dọn) — nghe theo nó
54
+ * thì daemon mới vừa lên đã tự tắt, và watchdog bật lại vô tận.
55
+ */
56
+ function isForMe(req, startedAt) {
57
+ return !!req && Date.parse(req.requested_at) >= startedAt;
58
+ }
59
+
60
+ function clearRequest(dir) {
61
+ try { fs.unlinkSync(requestPath(dir)); } catch { /* đã không có */ }
62
+ }
63
+
64
+ function writeStatus(dir, status) {
65
+ try {
66
+ fs.writeFileSync(statusPath(dir), JSON.stringify({ ...status, at: new Date().toISOString() }));
67
+ } catch { /* trạng thái chỉ để người xem, hỏng thì thôi */ }
68
+ }
69
+
70
+ function readStatus(dir) {
71
+ try { return JSON.parse(fs.readFileSync(statusPath(dir), 'utf-8')); } catch { return null; }
72
+ }
73
+
74
+ function clearStatus(dir) {
75
+ try { fs.unlinkSync(statusPath(dir)); } catch { /* đã không có */ }
76
+ }
77
+
78
+ /** "2 lệnh (nurture_facebook_pw, upload_facebook_pw) · 1 job" — cho log và lệnh drain. */
79
+ function describeBusy(busy) {
80
+ if (!busy) return 'không rõ';
81
+ const parts = [];
82
+ const cmds = busy.commands || [];
83
+ if (cmds.length) parts.push(`${cmds.length} lệnh (${cmds.join(', ')})`);
84
+ if (busy.jobs) parts.push(`${busy.jobs} job`);
85
+ if (busy.polls) parts.push(`${busy.polls} lượt hỏi việc đang bay`);
86
+ return parts.length ? parts.join(' · ') : 'không còn việc';
87
+ }
88
+
89
+ module.exports = {
90
+ REQUEST_FILE, STATUS_FILE, DEFAULT_TIMEOUT_MIN, TICK_MS,
91
+ requestPath, statusPath, writeRequest, readRequest, isForMe, clearRequest,
92
+ writeStatus, readStatus, clearStatus, describeBusy,
93
+ };
package/lib/job-poller.js CHANGED
@@ -8,8 +8,14 @@ class JobPoller {
8
8
  this.running = new Set(); // job IDs currently executing
9
9
  this.timer = null;
10
10
  this.pollIntervalMs = 5000;
11
+ this.paused = false; // XẢ: ngừng nhận job mới, job đang chạy vẫn chạy tiếp
12
+ // Lượt hỏi/nhận job đang bay — xem CommandPoller._pollsInFlight.
13
+ this.pollsInFlight = 0;
11
14
  }
12
15
 
16
+ pause() { this.paused = true; }
17
+ resume() { this.paused = false; }
18
+
13
19
  start() {
14
20
  console.log(`[poller] Started — max concurrent: ${this.config.max_concurrent}`);
15
21
  this.poll(); // immediate first poll
@@ -28,8 +34,11 @@ class JobPoller {
28
34
  }
29
35
 
30
36
  async poll() {
31
- if (this.availableSlots <= 0) return;
37
+ if (this.paused || this.availableSlots <= 0) return;
32
38
 
39
+ this.pollsInFlight++;
40
+ let inFlight = true;
41
+ const settle = () => { if (inFlight) { inFlight = false; this.pollsInFlight--; } };
33
42
  try {
34
43
  const job = await this.api.getNextJob(this.config.worker_id);
35
44
  if (!job) return; // no jobs in queue
@@ -39,7 +48,7 @@ class JobPoller {
39
48
 
40
49
  // Claim the job
41
50
  await this.api.claimJob(jobId, this.config.worker_id);
42
- this.running.add(jobId);
51
+ this.running.add(jobId); // từ đây `running` giữ nó trong sổ bận (finally trả lượt bay)
43
52
 
44
53
  console.log(`[poller] Claimed job ${jobId} (${this.running.size}/${this.config.max_concurrent} slots used)`);
45
54
 
@@ -56,6 +65,8 @@ class JobPoller {
56
65
  if (this.config.verbose) {
57
66
  console.error(`[poller] Poll error: ${err.message}`);
58
67
  }
68
+ } finally {
69
+ settle();
59
70
  }
60
71
  }
61
72
  }
@@ -183,4 +183,78 @@ async function fetchClickReport(ctx, {
183
183
  }
184
184
  }
185
185
 
186
- module.exports = { fetchConversionReport, fetchClickReport, reportWindow };
186
+ // Danh sách tài khoản mạng xã hội ĐÃ LIÊN KẾT với tài khoản affiliate — cùng
187
+ // thứ trang `account_setting` hiện ở tab "Media Information".
188
+ //
189
+ // KHÔNG mở account_setting và KHÔNG bấm tab: đo thật 22/09/2026 trên phiên
190
+ // Shopee1, endpoint này trả đủ dữ liệu khi gọi từ MỘT tab affiliate bất kỳ
191
+ // (thử từ /dashboard). Đi đường DOM thì phải mở thêm trang, bấm đúng nhãn
192
+ // tiếng Anh "Media Information", và nhãn đó đổi theo ngôn ngữ giao diện.
193
+ //
194
+ // Mỗi dòng có `user_id` là ID Facebook THẬT của page (vd 1298751909986110) —
195
+ // đúng con số nằm trong link `facebook.com/profile.php?id=…`. Nhờ vậy việc so
196
+ // khớp với kênh của mình không phải parse URL, vốn có nhiều biến thể
197
+ // (?referral_source=…, /<handle>/, m.facebook.com…).
198
+ //
199
+ // `platform`: 6 = Facebook, 3 = TikTok (đo được trên tài khoản thật; các mã
200
+ // khác giữ nguyên số để sau này đọc lại, không đoán tên).
201
+ const SM_PLATFORM = { 6: 'facebook', 3: 'tiktok' };
202
+
203
+ async function fetchSocialMediaAccounts(ctx, { timeoutMs = 45000, log = null } = {}) {
204
+ let page = findAffiliatePage(ctx.pages());
205
+ let opened = false;
206
+ if (!page) {
207
+ page = await ctx.newPage();
208
+ opened = true;
209
+ }
210
+
211
+ try {
212
+ if (!page.url().includes('affiliate.shopee.vn/')) {
213
+ await page.goto('https://affiliate.shopee.vn/offer/product_offer', {
214
+ waitUntil: 'domcontentloaded', timeout: timeoutMs,
215
+ }).catch(() => {});
216
+ await sleep(1500);
217
+ }
218
+ if (isBlockedState(page.url(), null)) {
219
+ return { status: 'needs_verify', accounts: [], reason: 'session at captcha/login — re-verify via VNC' };
220
+ }
221
+
222
+ const result = await page.evaluate(async () => {
223
+ try {
224
+ const r = await fetch('/api/v3/affiliates/social_media_accounts', { credentials: 'include' });
225
+ return { status: r.status, json: await r.json().catch(() => null) };
226
+ } catch (e) {
227
+ return { status: 0, error: String(e?.message || e) };
228
+ }
229
+ });
230
+
231
+ if (isBlockedState(page.url(), result) || result.json?.code === 90309999) {
232
+ return { status: 'needs_verify', accounts: [], reason: `blocked while reading media info (http ${result.status}, code ${result.json?.code})` };
233
+ }
234
+ if (result.status !== 200 || result.json?.code !== 0) {
235
+ return { status: 'failed', accounts: [], reason: result.json?.msg || result.error || `media info HTTP ${result.status}` };
236
+ }
237
+
238
+ const list = Array.isArray(result.json?.data?.accounts) ? result.json.data.accounts : [];
239
+ const accounts = list.map((row) => ({
240
+ media_user_id: String(row.user_id || row.unique_id || '').trim(),
241
+ media_name: String(row.user_name || '').trim(),
242
+ website_url: String(row.website_url || '').trim(),
243
+ platform_code: Number(row.platform) || 0,
244
+ platform: SM_PLATFORM[Number(row.platform)] || 'other',
245
+ link_status: Number(row.link_status) || 0,
246
+ facebook_page_type: Number(row.facebook_page_type) || 0,
247
+ partner_link_status: Number(row.partner_link_status) || 0,
248
+ follower_number: Number(row.follower_number) || 0,
249
+ // Shopee trả giây, không phải mili-giây.
250
+ verification_time: Number(row.verification_time) || 0,
251
+ })).filter((a) => a.media_user_id || a.website_url);
252
+
253
+ if (log) log(`[shopee-report] media: ${accounts.length} tài khoản liên kết (${accounts.filter((a) => a.platform === 'facebook').length} Facebook)`);
254
+ return { status: 'ok', accounts };
255
+ } finally {
256
+ if (opened) await page.close().catch(() => {});
257
+ }
258
+ }
259
+
260
+ module.exports = { fetchConversionReport, fetchClickReport, fetchSocialMediaAccounts, reportWindow, SM_PLATFORM };
package/lib/singleton.js CHANGED
@@ -70,6 +70,10 @@ function writeLock(configDir, info, { overwrite = false } = {}) {
70
70
  version: info.version || '',
71
71
  started_at: info.started_at || new Date().toISOString(),
72
72
  refreshed_at: new Date().toISOString(),
73
+ // Ai dựng lại daemon khi nó tắt: 'pm2' thì `channel-worker drain` biết
74
+ // trước là xả xong pm2 sẽ bật lại ngay (relabs03). Làm tươi khoá chép
75
+ // nguyên giá trị cũ; lần ghi đầu đọc từ môi trường của chính daemon.
76
+ supervisor: info.supervisor !== undefined ? info.supervisor : (process.env.pm_id !== undefined ? 'pm2' : ''),
73
77
  });
74
78
  // 'wx' = tạo mới, thất bại nếu đã có → chính là phép thử nguyên tử.
75
79
  const fd = fs.openSync(lockPath(configDir), overwrite ? 'w' : 'wx');
package/lib/updater.js CHANGED
@@ -38,18 +38,26 @@ function restartBlocker() {
38
38
  // Quyết định THUẦN của một lượt kiểm (test được, không đụng đĩa/mạng):
39
39
  // 'none' — đang chạy bản mới nhất
40
40
  // 'blocked' — có bản mới nhưng không khởi động lại được → KHÔNG cài
41
+ // 'deferred-busy'— có bản mới nhưng daemon đang có việc dở → CHƯA cài, CHƯA
42
+ // khởi động lại; daemon chuyển sang XẢ rồi quay lại (drain.js)
41
43
  // 'restart-only' — bản mới đã có sẵn trên đĩa → chỉ kiểm + khởi động lại
42
44
  // 'install' — cài rồi kiểm rồi khởi động lại
43
45
  //
46
+ // Vì sao có 'deferred-busy' (soát 23/09/2026): khởi động lại = process.exit, và
47
+ // mọi việc đang chạy chết theo — 9/9 phiên nuôi FB "Timed out … crash giữa
48
+ // chừng" còn log là daemon bị thay bằng bản mới giữa phiên. Cài khi bận cũng
49
+ // không được: npm xoá cây đang chạy trước khi ghi cây mới.
50
+ //
44
51
  // Vì sao phải có 'blocked' và 'restart-only' (sự cố 17-19/09/2026, win-worker):
45
52
  // worker-1 được bật tay qua start-worker.bat nên restart luôn chết EBUSY. Bản cũ
46
53
  // so bản ĐANG CHẠY (2.5.117) với registry (2.5.120) nên lượt nào cũng
47
54
  // `npm i -g` lại từ đầu rồi mới vấp EBUSY: 413 lần cài đè trong ~34 giờ, lên
48
55
  // đúng thư mục cài mà worker-2 đang chạy. Cài đè cây đang chạy chính là thứ đã
49
56
  // giết worker Windows nhiều lần (lib/ bị xoá giữa chừng).
50
- function planUpdate({ running, onDisk, latest, restartBlocked = '' }) {
57
+ function planUpdate({ running, onDisk, latest, restartBlocked = '', busy = false }) {
51
58
  if (!latest || !isNewer(latest, running)) return 'none';
52
59
  if (restartBlocked) return 'blocked';
60
+ if (busy) return 'deferred-busy';
53
61
  if (onDisk && !isNewer(latest, onDisk)) return 'restart-only';
54
62
  return 'install';
55
63
  }
@@ -136,7 +144,7 @@ function verifyInstall() {
136
144
  return { ok: out.status === 0, detail: detail.slice(-600) };
137
145
  }
138
146
 
139
- async function checkAndUpdate({ autoRestart = false } = {}) {
147
+ async function checkAndUpdate({ autoRestart = false, isBusy = null } = {}) {
140
148
  const local = getLocalVersion();
141
149
  const latest = await getLatestVersion();
142
150
 
@@ -148,7 +156,11 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
148
156
  // update` gõ tay thì cứ cài như cũ.
149
157
  const onDisk = getInstalledVersion();
150
158
  const blocker = autoRestart ? restartBlocker() : '';
151
- const plan = planUpdate({ running: local, onDisk, latest, restartBlocked: blocker });
159
+ const busy = !!(autoRestart && typeof isBusy === 'function' && isBusy());
160
+ const plan = planUpdate({ running: local, onDisk, latest, restartBlocked: blocker, busy });
161
+ if (plan === 'deferred-busy') {
162
+ return { updated: false, local, latest, deferredBusy: true };
163
+ }
152
164
  if (plan === 'blocked') {
153
165
  if (Date.now() - lastBlockedWarnAt > BLOCKED_WARN_EVERY_MS) {
154
166
  lastBlockedWarnAt = Date.now();
@@ -238,33 +250,52 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
238
250
  }
239
251
 
240
252
  class UpdateChecker {
241
- constructor(intervalMs = 5 * 60 * 1000) {
253
+ /**
254
+ * @param {object} [opts]
255
+ * @param {() => boolean} [opts.isBusy] daemon còn việc dở không
256
+ * @param {(r: object) => void} [opts.onDeferred] có bản mới mà đang bận —
257
+ * daemon chuyển sang XẢ, rảnh rồi gọi lại check({ ignoreBusy: true })
258
+ * @param {Function} [opts.checkFn] thay checkAndUpdate (test)
259
+ */
260
+ constructor(intervalMs = 5 * 60 * 1000, { isBusy = null, onDeferred = null, checkFn = checkAndUpdate } = {}) {
242
261
  this.intervalMs = intervalMs;
243
262
  this.timer = null;
263
+ this.startTimer = null;
264
+ this.isBusy = isBusy;
265
+ this.onDeferred = onDeferred;
266
+ this.checkFn = checkFn;
244
267
  }
245
268
 
246
- start() {
247
- // Check on startup after 10s delay
248
- setTimeout(() => this.check(), 10000);
269
+ start({ initialDelay = true } = {}) {
270
+ // Check on startup after 10s delay (bỏ qua khi daemon vừa thôi xả — xem
271
+ // Daemon._resumeFromDrain)
272
+ if (initialDelay) this.startTimer = setTimeout(() => this.check(), 10000);
249
273
  // Then periodically
250
274
  this.timer = setInterval(() => this.check(), this.intervalMs);
251
275
  }
252
276
 
253
277
  stop() {
278
+ // Dọn cả lượt kiểm 10s đầu: dừng ngay sau start() mà để nó chạy là một lượt
279
+ // cài + khởi động lại lọt ra giữa lúc daemon đang xả.
280
+ if (this.startTimer) { clearTimeout(this.startTimer); this.startTimer = null; }
254
281
  if (this.timer) {
255
282
  clearInterval(this.timer);
256
283
  this.timer = null;
257
284
  }
258
285
  }
259
286
 
260
- async check() {
287
+ /** @param {{ignoreBusy?: boolean}} [opts] ignoreBusy = daemon đã xả xong (hoặc hết hạn xả) */
288
+ async check({ ignoreBusy = false } = {}) {
261
289
  try {
262
- const result = await checkAndUpdate({ autoRestart: true });
263
- if (!result.updated) {
264
- // Silent — only log in verbose or when there's an update
290
+ const result = await this.checkFn({ autoRestart: true, isBusy: ignoreBusy ? null : this.isBusy });
291
+ if (result && result.deferredBusy) {
292
+ console.log(`[updater] Có bản ${result.latest} nhưng đang có việc dở — xả trước, rảnh rồi mới cài.`);
293
+ if (this.onDeferred) this.onDeferred(result);
265
294
  }
295
+ return result;
266
296
  } catch (err) {
267
297
  console.warn(`[updater] Check failed: ${err.message}`);
298
+ return { updated: false, error: err.message };
268
299
  }
269
300
  }
270
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.137",
3
+ "version": "2.5.139",
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": {
@@ -38,7 +38,7 @@ function parseViews(txt) {
38
38
  // 09/09 tìm được tab để so trước/sau. Bản riêng còn thiếu cả bộ lọc "anchor
39
39
  // của bài trong bảng tin" (gốc 2 link page lạ sáng 10/09) nên có thể quét
40
40
  // nhầm tab Reels của page KHÁC mà không hay.
41
- const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles, parsePageRef } = require('./upload_facebook').__testables;
41
+ const { discoverReelsTabUrl, discoverReelsTabUrls, openReelsTabWithTiles, parsePageRef, readPageIdFromDom, readDelegatePageId } = require('./upload_facebook').__testables;
42
42
 
43
43
  // Số người theo dõi của page, đọc ngay trên chính trang đang đứng (header hiện
44
44
  // ở mọi tab của page/profile) — không tốn thêm lượt mở profile nào. Ô
@@ -821,6 +821,29 @@ async function run({ page, payload, log }) {
821
821
  // được với danh sách page đã liên kết bên Shopee. Không có thì bỏ qua, lượt
822
822
  // ghi số liệu không phụ thuộc vào nó.
823
823
  const pageRef = opened.verified ? parsePageRef(opened.url) : null;
824
+ // URL chỉ có TÊN RIÊNG thì chưa đủ để đối chiếu với Shopee: 6/8 page đang liên
825
+ // kết bên đó lưu dạng `profile.php?id=…`, không mang tên riêng. Thiếu số ID là
826
+ // kênh bị báo "chưa liên kết" oan. Đọc thêm từ chính trang đang mở (đã chứng
827
+ // minh có tile ⇒ đúng page của kênh). Đọc hỏng thì giữ nguyên tên riêng, không
828
+ // làm hỏng lượt ghi số liệu.
829
+ if (pageRef && !pageRef.id) {
830
+ const hit = await readPageIdFromDom(page).catch(() => null);
831
+ if (hit) {
832
+ pageRef.id = hit.id;
833
+ log('info', `[fb-stats] page id đọc từ trang: ${hit.id} (${hit.sources} nguồn khớp: ${hit.from.join(', ')})`);
834
+ } else {
835
+ log('warn', '[fb-stats] URL chỉ có tên riêng và KHÔNG đọc được id từ trang — kênh sẽ hiện "chưa rõ Page" thay vì báo sai liên kết');
836
+ }
837
+ }
838
+ // MÃ TRANG Shopee dùng để đối chiếu (`delegate_page_id`) — KHÁC profile id.
839
+ // Không có nó thì API không kết luận được kênh đã liên kết hay chưa, và cố
840
+ // tình để ở "chưa rõ" còn hơn báo sai. Xem readDelegatePageId.
841
+ if (pageRef) {
842
+ const dg = await readDelegatePageId(page, pageRef.id).catch(() => ({ id: '', checked: false }));
843
+ pageRef.delegate_id = dg.id;
844
+ pageRef.delegate_checked = dg.checked;
845
+ log('info', `[fb-stats] mã Trang (Shopee dùng): ${dg.id || (dg.checked ? 'không có — page kiểu cổ điển' : 'CHƯA rõ')}`);
846
+ }
824
847
  if (pageRef) log('info', `[fb-stats] page: ${pageRef.id ? `id ${pageRef.id}` : `handle ${pageRef.handle}`}`);
825
848
 
826
849
  const d = await log.apiRequest('POST', '/analytics/post-stats', {
@@ -1990,6 +1990,115 @@ function parsePageRef(reelsTabUrl) {
1990
1990
  return { id: '', handle: seg, url: raw };
1991
1991
  }
1992
1992
 
1993
+ // ID page ĐỌC TỪ CHÍNH TRANG, cho ca URL chỉ có tên riêng (`/<handle>/?sk=reels_tab`).
1994
+ //
1995
+ // Vì sao cần: bên Shopee, 6/8 page đang liên kết được lưu dạng
1996
+ // `profile.php?id=…`, KHÔNG có tên riêng trong link. Kênh nào chỉ biết tên riêng
1997
+ // thì không có gì để so → bị báo "chưa liên kết" OAN (đo bằng
1998
+ // services/AffiliateMediaLink 22/09). Dạng tên riêng có thật: ca "Chuyện Nghề
1999
+ // Nhà Nông" 14/09 — dạng profile.php ra 0 tile, chỉ `/chuyennghenhanong/` chạy.
2000
+ //
2001
+ // CHỈ gọi khi đang đứng trên trang tab Reels ĐÃ chứng minh có tile. Đứng ở trang
2002
+ // khác thì mấy khoá dưới đây là của trang đó, không phải của page mình.
2003
+ //
2004
+ // Đo thật 22/09 trên 6 profile của đàn (Toc-xinh, Meo-luoi, Tam-cai-tao-nha,
2005
+ // Beo-banh-beo, duong-nhan-cung-anh, Do-nghe-len-hinh):
2006
+ // `profile_id` == `actorID` == ID page trong URL ở CẢ 6/6.
2007
+ // ⛔ `delegate_page_id` LUÔN là một số KHÁC (vd page 61591917189886 →
2008
+ // delegate 1240255979169594). Bắt nhầm khoá này là ghi sai danh tính rồi
2009
+ // báo sai liên kết cho đúng cái kênh mình định cứu.
2010
+ //
2011
+ // Cổng an toàn: phải có **ít nhất hai nguồn độc lập cùng ra một số** mới nhận.
2012
+ // Một khoá JSON đứng lẻ không đủ — trang Facebook đầy số 15-16 chữ số, và nếu
2013
+ // profile đang đăng nhập bằng tài khoản CÁ NHÂN quản lý page thì `userID` là của
2014
+ // người, không phải của page. Hai nguồn khớp nhau thì khả năng cùng sai rất thấp.
2015
+ const PAGE_ID_JSON_KEYS = ['profile_id', 'actorID', 'pageID'];
2016
+
2017
+ async function readPageIdFromDom(page) {
2018
+ const votes = await page.evaluate((keys) => {
2019
+ const out = {};
2020
+ const add = (src, v) => {
2021
+ if (!/^\d{5,25}$/.test(String(v || ''))) return;
2022
+ (out[src] = out[src] || []).push(String(v));
2023
+ };
2024
+ // Nguồn 1: anchor profile.php NGOÀI bảng tin. Anchor trong feed là của bài
2025
+ // người khác — đúng cái bẫy đã đẻ ra 2 link sai sáng 10/09.
2026
+ for (const a of document.querySelectorAll('a[href]')) {
2027
+ const href = a.getAttribute('href') || '';
2028
+ if (/[?&]__cft__|[?&]__tn__/.test(href)) continue;
2029
+ if (a.closest("[role='feed'], [role='article'], [aria-posinset]")) continue;
2030
+ const m = href.match(/profile\.php\?id=(\d{5,25})/);
2031
+ if (m) add('anchor', m[1]);
2032
+ }
2033
+ // Nguồn 2..n: khoá JSON trong bundle. `delegate_page_id` cố ý KHÔNG có mặt.
2034
+ const html = document.documentElement.innerHTML;
2035
+ for (const key of keys) {
2036
+ const re = new RegExp(`"${key}"\\s*:\\s*"?(\\d{5,25})"?`, 'g');
2037
+ let m; let n = 0;
2038
+ while ((m = re.exec(html)) && n < 8) { add(key, m[1]); n++; }
2039
+ }
2040
+ return out;
2041
+ }, PAGE_ID_JSON_KEYS).catch(() => ({}));
2042
+
2043
+ // Đếm theo NGUỒN, không theo số lần xuất hiện: một khoá lặp 8 lần trong bundle
2044
+ // vẫn chỉ là MỘT nguồn, không được tự nó đủ phiếu.
2045
+ const bySource = new Map();
2046
+ for (const [src, vals] of Object.entries(votes || {})) {
2047
+ for (const v of new Set(vals)) {
2048
+ if (!bySource.has(v)) bySource.set(v, new Set());
2049
+ bySource.get(v).add(src);
2050
+ }
2051
+ }
2052
+ let best = null;
2053
+ for (const [id, srcs] of bySource) {
2054
+ if (srcs.size < 2) continue;
2055
+ if (!best || srcs.size > best.sources) best = { id, sources: srcs.size, from: [...srcs].sort() };
2056
+ }
2057
+ return best;
2058
+ }
2059
+
2060
+ // MÃ TRANG mà Shopee dùng để đối chiếu — `delegate_page_id`.
2061
+ //
2062
+ // ⚠️ Page Facebook kiểu mới (New Pages Experience) mang HAI số: `profile id`
2063
+ // nằm trong URL (14 số, mở đầu `61…`) và `delegate_page_id` là ID Trang cổ
2064
+ // điển nằm sau nó (15-16 số, mở đầu `1…`). **Shopee lưu số THỨ HAI.**
2065
+ // Đo 22/09: page `chuyennghenhanong` (profile 61591308775391) hiện trên Shopee
2066
+ // là 1165088293355326 = đúng delegate của nó. So bằng profile id là KHÔNG BAO
2067
+ // GIỜ khớp — lỗi đã lỡ đưa lên prod một lần.
2068
+ //
2069
+ // Khác `profile_id`/`actorID` (là của NGƯỜI ĐANG XEM), `delegate_page_id` nói
2070
+ // về TRANG ĐANG MỞ: đo trên 3 page lạ, nó trả đúng delegate của trang đó.
2071
+ //
2072
+ // Trả { id, checked }:
2073
+ // - đúng MỘT giá trị → id = nó, checked = true
2074
+ // - KHÔNG có giá trị nào → page kiểu CỔ ĐIỂN (profile id chính là khoá
2075
+ // Shopee) → id rỗng, checked = true. Chỉ dám kết luận vậy khi profile id
2076
+ // KHÔNG mang hình dạng page kiểu mới; nếu nó là `61…` mà không đọc được
2077
+ // delegate thì đó là đọc HỤT, không phải page cổ điển.
2078
+ // - NHIỀU giá trị → profile quản nhiều page (đo thật ở
2079
+ // `duong-nhan-cung-anh`: 2 delegate). Không đoán → checked = false, để bên
2080
+ // API giữ trạng thái "chưa rõ" thay vì báo sai.
2081
+ const NEW_STYLE_PROFILE_ID = /^61\d{12}$/;
2082
+
2083
+ async function readDelegatePageId(page, profileId = '') {
2084
+ const found = await page.evaluate(() => {
2085
+ const out = new Set();
2086
+ const re = /"delegate_page_id"\s*:\s*"?(\d{5,25})"?/g;
2087
+ const html = document.documentElement.innerHTML;
2088
+ let m; let n = 0;
2089
+ while ((m = re.exec(html)) && n < 20) { out.add(m[1]); n++; }
2090
+ return [...out];
2091
+ }).catch(() => null);
2092
+
2093
+ if (found === null) return { id: '', checked: false };
2094
+ if (found.length === 1) return { id: found[0], checked: true };
2095
+ if (found.length === 0) {
2096
+ // Không có delegate. Chỉ coi là page cổ điển khi profile id không phải dạng mới.
2097
+ return { id: '', checked: !NEW_STYLE_PROFILE_ID.test(String(profileId || '')) };
2098
+ }
2099
+ return { id: '', checked: false }; // nhiều page trong cùng profile — không đoán
2100
+ }
2101
+
1993
2102
  async function hasVisibleReelComposer(page) {
1994
2103
  return page.evaluate(() => {
1995
2104
  for (const dlg of document.querySelectorAll("[role='dialog']")) {
@@ -4569,6 +4678,8 @@ module.exports.__testables = {
4569
4678
  pickReelsTabUrl,
4570
4679
  pickReelsTabUrls,
4571
4680
  parsePageRef,
4681
+ readPageIdFromDom,
4682
+ readDelegatePageId,
4572
4683
  discoverReelsTabUrls,
4573
4684
  openReelsTabWithTiles,
4574
4685
  networkIdVerdict,