channel-worker 2.5.91 → 2.5.93

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
@@ -201,6 +201,42 @@ if (cmd === 'pair') {
201
201
  process.exit(0);
202
202
  }
203
203
 
204
+ } else if (cmd === 'selftest') {
205
+ // Nạp thử MỌI module trong lib/ rồi thoát 0. Bộ tự-cập-nhật chạy lệnh này
206
+ // sau khi cài và chỉ khởi động lại khi nó xanh.
207
+ //
208
+ // Vì sao cần: 08/09/2026, một bản npm publish làm mọi daemon tự lao vào
209
+ // `npm install -g` ĐÈ LÊN CHÍNH MODULE ĐANG CHẠY. Trên Windows npm xoá cây
210
+ // cũ trước khi ghi cây mới, và lượt đó dừng giữa chừng: lib/daemon.js và
211
+ // lib/command-poller.js biến mất, package.json vẫn ghi version mới nên nhìn
212
+ // vào đó thấy "cài xong". Daemon thay thế chết ngay MODULE_NOT_FOUND, hai
213
+ // máy Windows tắt lịm ĐÚNG LÚC đang đăng 9 video — 9 lượt đăng thành "mập
214
+ // mờ" phải kiểm tay. Đọc version là không đủ; phải nạp thử thật.
215
+ const fs = require('fs');
216
+ const libDir = path.join(__dirname, '..', 'lib');
217
+ const bad = [];
218
+ let files = [];
219
+ try {
220
+ files = fs.readdirSync(libDir).filter((f) => f.endsWith('.js')).sort();
221
+ } catch (e) {
222
+ console.error(`[channel-worker] selftest FAILED — không đọc nổi ${libDir}: ${e.code || e.message}`);
223
+ process.exit(1);
224
+ }
225
+ if (files.length < 10) {
226
+ // Cây đủ có 15 file; ít hơn 10 là chắc chắn cụt, không cần require từng cái.
227
+ console.error(`[channel-worker] selftest FAILED — lib/ chỉ còn ${files.length} file (cây cài dở).`);
228
+ process.exit(1);
229
+ }
230
+ for (const f of files) {
231
+ try { require(path.join(libDir, f)); } catch (e) { bad.push(`${f}: ${e.code || e.message}`); }
232
+ }
233
+ if (bad.length) {
234
+ console.error(`[channel-worker] selftest FAILED — ${bad.length}/${files.length} module không nạp được:\n ${bad.join('\n ')}`);
235
+ process.exit(1);
236
+ }
237
+ console.log(`[channel-worker] selftest OK — v${require('../package.json').version}, ${files.length} module nạp được.`);
238
+ process.exit(0);
239
+
204
240
  } else if (cmd === 'update') {
205
241
  const { checkAndUpdate, getLocalVersion } = require('../lib/updater');
206
242
  console.log(`[channel-worker] Current version: ${getLocalVersion()}`);
@@ -323,6 +323,8 @@ class CommandPoller {
323
323
  platform: scriptName.replace(/^upload_/, ''),
324
324
  });
325
325
 
326
+ const keepOpen = await this._keepOpenCfg();
327
+
326
328
  try {
327
329
  const result = await runPlaywrightScript({
328
330
  nst: this.nst,
@@ -330,6 +332,7 @@ class CommandPoller {
330
332
  scriptName,
331
333
  payload,
332
334
  log,
335
+ keepOpen: keepOpen.enabled,
333
336
  });
334
337
  // postCommandResult (not updateCommand) so the existing extension-side
335
338
  // logic mirrors status into ContentJob + ContentIdea.publish_results +
@@ -356,7 +359,17 @@ class CommandPoller {
356
359
  // Scoped to the browse-only scripts. Publish keeps its profile open on
357
360
  // purpose: one profile serves several platform uploads back-to-back.
358
361
  const CLOSE_AFTER = new Set(['nurture_facebook', 'warmup_facebook', 'warmup_youtube', 'warmup_tiktok', 'fetch_facebook_reel_stats']);
359
- if (CLOSE_AFTER.has(scriptName) && this.nst) {
362
+ // Cờ vừa bị TẮT trên web → bỏ profile khỏi sổ giữ mở, để nó quay về vòng
363
+ // đời đóng-mở bình thường ngay từ lượt này.
364
+ if (!keepOpen.enabled && this._keptOpenProfiles) this._keptOpenProfiles.delete(profileId);
365
+ if (keepOpen.enabled && this.nst) {
366
+ // Máy bị chặn số lượt mở/ngày: KHÔNG đóng, ghi sổ để lượt sau nối lại
367
+ // vào đúng browser này. Chỉ trần `keep_open_max` mới được đóng bớt.
368
+ if (!this._keptOpenProfiles) this._keptOpenProfiles = new Map();
369
+ this._keptOpenProfiles.set(profileId, Date.now());
370
+ console.log(`[keep-open] giữ profile ${profileId} mở sau ${scriptName} (${this._keptOpenProfiles.size}/${keepOpen.max})`);
371
+ await this._trimKeptProfiles(keepOpen.max).catch((e) => console.warn(`[keep-open] trim lỗi: ${e.message}`));
372
+ } else if (CLOSE_AFTER.has(scriptName) && this.nst) {
360
373
  try {
361
374
  await this.nst.stopProfile(profileId);
362
375
  console.log(`[commands/pw] closed profile ${profileId} after ${scriptName}`);
@@ -379,6 +392,62 @@ class CommandPoller {
379
392
  }
380
393
  }
381
394
 
395
+ // ─── Giữ profile mở (máy bị chặn số lượt mở/ngày) ─────────────────────────
396
+ // Gói Nstbrowser không nâng cấp chặn ~30 LƯỢT MỞ/ngày (relabs03, acc
397
+ // maithithu). Mặc định daemon đóng profile sau mỗi việc rồi mở lại cho việc
398
+ // sau → mỗi task tốn 1 lượt, cháy hạn mức từ giữa ngày. Cờ `keep_profiles_open`
399
+ // trên hàng Worker (bật trong web, trang Workers) đổi hành vi thành: giữ
400
+ // browser mở và tái dùng, mỗi profile chỉ tốn ĐÚNG 1 lượt/ngày.
401
+ //
402
+ // Đọc qua /workers/me, cache 30s — đổi cờ trên web có tác dụng trong nửa phút,
403
+ // không cần restart daemon.
404
+ async _keepOpenCfg() {
405
+ const CACHE_MS = 30 * 1000;
406
+ const now = Date.now();
407
+ if (this._keepOpenCache && (now - this._keepOpenCache.at) < CACHE_MS) return this._keepOpenCache;
408
+ // Không đọc được /workers/me → fail-safe về hành vi CŨ (vẫn đóng profile).
409
+ // Giữ mở nhầm nguy hiểm hơn đóng nhầm: profile mở tích luỹ sẽ ngốn hết RAM.
410
+ let cfg = { at: now, enabled: false, max: 6 };
411
+ try {
412
+ if (this.api.getMyWorker) {
413
+ const me = await this.api.getMyWorker();
414
+ if (me) {
415
+ cfg.enabled = !!me.keep_profiles_open;
416
+ const n = parseInt(me.keep_open_max, 10);
417
+ if (!Number.isNaN(n) && n >= 1) cfg.max = n;
418
+ }
419
+ }
420
+ } catch (e) {
421
+ if (this.config.verbose) console.warn('[keep-open] getMyWorker failed, giữ hành vi cũ:', e.message);
422
+ }
423
+ this._keepOpenCache = cfg;
424
+ return cfg;
425
+ }
426
+
427
+ // Trần RAM cho chế độ giữ mở: quá `max` thì đóng profile RẢNH LÂU NHẤT.
428
+ // Chỉ đụng tới profile do luồng _pw giữ (sổ `_keptOpenProfiles`) — KHÔNG đụng
429
+ // profile renderer, vòng đời của chúng do scene-dispatch quản.
430
+ async _trimKeptProfiles(max) {
431
+ if (!this._keptOpenProfiles || this._keptOpenProfiles.size <= max) return;
432
+ const candidates = [...this._keptOpenProfiles.entries()]
433
+ .filter(([id]) => !(this._pwInFlight && this._pwInFlight.has(id)))
434
+ .sort((a, b) => a[1] - b[1]);
435
+ let over = this._keptOpenProfiles.size - max;
436
+ for (const [id] of candidates) {
437
+ if (over <= 0) break;
438
+ try {
439
+ await this.nst.stopProfile(id);
440
+ console.log(`[keep-open] đóng ${id} — rảnh lâu nhất, quá trần ${max} profile giữ mở`);
441
+ } catch (e) {
442
+ console.warn(`[keep-open] đóng ${id} lỗi: ${e.message}`);
443
+ }
444
+ // Xoá khỏi sổ dù đóng lỗi: giữ lại chỉ khiến vòng sau thử lại vô ích và
445
+ // chặn mất suất của profile khác.
446
+ this._keptOpenProfiles.delete(id);
447
+ over--;
448
+ }
449
+ }
450
+
382
451
  // Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
383
452
  // hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
384
453
  // tiếp vẫn dùng chung một browser như thiết kế cũ.
@@ -1319,6 +1388,16 @@ class CommandPoller {
1319
1388
 
1320
1389
  async handleCloseProfile(command) {
1321
1390
  const { profile_id } = command.payload || {};
1391
+ // Máy bị chặn số lượt mở/ngày: bỏ qua lệnh đóng của API. KHÔNG nhả lease
1392
+ // kèm theo — profile vẫn đang mở ở máy này, nhả lease là mời máy khác mở
1393
+ // cùng profile → hai browser một tài khoản, ghi đè cookie của nhau.
1394
+ // Lease tự hết hạn sau TTL 10 phút nếu máy này thật sự bỏ việc.
1395
+ const keepOpen = await this._keepOpenCfg();
1396
+ if (keepOpen.enabled) {
1397
+ console.log(`[keep-open] bỏ qua close_profile ${profile_id} — máy đang bật chế độ giữ profile mở`);
1398
+ await this.api.updateCommand(command._id, { status: 'done', result: { skipped: 'keep_profiles_open' } });
1399
+ return;
1400
+ }
1322
1401
  console.log(`[commands] Closing profile: ${profile_id}`);
1323
1402
  try {
1324
1403
  // NstManager exposes stopProfile — there has never been a closeProfile.
@@ -1440,7 +1519,8 @@ class CommandPoller {
1440
1519
  // Pass youtube_handle from command payload
1441
1520
  channel.youtube_handle = youtube_handle || null;
1442
1521
 
1443
- const syncer = new StatsSyncer(this.nst, this.api);
1522
+ const keepOpen = await this._keepOpenCfg();
1523
+ const syncer = new StatsSyncer(this.nst, this.api, { keepOpen: keepOpen.enabled });
1444
1524
  const stats = await syncer.syncYouTubeStats(channel);
1445
1525
 
1446
1526
  await this.api.updateCommand(command._id, {
@@ -1946,6 +2026,13 @@ class CommandPoller {
1946
2026
  async _checkProfileTimeouts() {
1947
2027
  try {
1948
2028
  if (!this.nst) return;
2029
+ // Máy bị chặn số lượt mở/ngày: bộ quét này thuần tiết kiệm RAM, mà mỗi
2030
+ // lần nó đóng là lần sau tốn thêm 1 lượt mở. Tắt hẳn; trần RAM đã do
2031
+ // `keep_open_max` lo. (Vòng xoay round-robin của scene-dispatch thì KHÔNG
2032
+ // tắt — cái đó là chống captcha "2 phiên/1 tài khoản Google", an toàn tài
2033
+ // khoản đứng trên hạn mức lượt mở.)
2034
+ const keepOpen = await this._keepOpenCfg();
2035
+ if (keepOpen.enabled) return;
1949
2036
 
1950
2037
  const IDLE_TIMEOUT = 30 * 1000; // 30s — matches old extension-side behavior
1951
2038
  const now = Date.now();
@@ -107,6 +107,38 @@ class NstManager {
107
107
  }
108
108
  }
109
109
 
110
+ /**
111
+ * Điểm nối CDP của một profile ĐANG CHẠY, lấy từ cổng debug của chính nó.
112
+ *
113
+ * Sinh ra cho máy dùng gói Nstbrowser bị chặn số LƯỢT MỞ/ngày (relabs03 —
114
+ * 30 lượt). Trước đây mọi đường muốn có endpoint đều tắt-mở lại profile
115
+ * (`forceRelaunch`), nên một profile đang mở sẵn vẫn tốn thêm 1 lượt cho mỗi
116
+ * việc. Đọc thẳng `remoteDebuggingPort` thì không tốn lượt nào.
117
+ *
118
+ * → { httpEndpoint, wsEndpoint, port } | null nếu profile không chạy.
119
+ */
120
+ async resolveRunningEndpoint(profileIdOrName) {
121
+ let profileId = profileIdOrName;
122
+ if (!this.isUUID(profileIdOrName)) {
123
+ profileId = await this.findProfile(profileIdOrName);
124
+ if (!profileId) return null;
125
+ }
126
+ const running = await this.getRunningBrowsers();
127
+ const b = running.find(x => x.profileId === profileId);
128
+ const port = b?.remoteDebuggingPort;
129
+ if (!port) return null;
130
+ const httpEndpoint = `http://127.0.0.1:${port}`;
131
+ // Playwright nối được bằng httpEndpoint, nhưng đường CDP thô (stats-syncer)
132
+ // cần đúng ws:// — /json/version của chính Chrome trả về nó.
133
+ let wsEndpoint = null;
134
+ try {
135
+ const res = await fetch(`${httpEndpoint}/json/version`);
136
+ const data = await res.json();
137
+ wsEndpoint = data?.webSocketDebuggerUrl || null;
138
+ } catch { /* cổng chưa sẵn sàng — httpEndpoint vẫn dùng được cho Playwright */ }
139
+ return { profileId, httpEndpoint, wsEndpoint, port };
140
+ }
141
+
110
142
  // Check if profile is already running
111
143
  async isProfileRunning(profileId) {
112
144
  const running = await this.getRunningBrowsers();
@@ -26,7 +26,7 @@ function loadScript(scriptName) {
26
26
  return mod;
27
27
  }
28
28
 
29
- async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {} }) {
29
+ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {}, keepOpen = false }) {
30
30
  let playwright;
31
31
  try { playwright = require('playwright-core'); }
32
32
  catch (e) { throw new Error(`playwright-core not installed in channel-worker (npm i playwright-core in the worker dir): ${e.message}`); }
@@ -38,19 +38,33 @@ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, l
38
38
  // for the channel-manager-ext on this path. (The chagpt cgpt path still
39
39
  // launches with content-creator-ext via a different code path.)
40
40
  const launchRes = await nst.launchProfile(profileId, { /* extensionPath omitted */ });
41
- let wsEndpoint = launchRes?.wsEndpoint;
41
+ let endpoint = launchRes?.wsEndpoint;
42
42
 
43
43
  // If the profile was already running, launchProfile returns { alreadyRunning:
44
- // true } without a wsEndpoint. Force-relaunch to get a fresh endpoint.
45
- if (!wsEndpoint && launchRes?.alreadyRunning) {
46
- log('info', `[pw] profile already running force-relaunching for fresh CDP endpoint`);
47
- const r = await nst.launchProfile(profileId, { forceRelaunch: true });
48
- wsEndpoint = r?.wsEndpoint;
44
+ // true } without a wsEndpoint.
45
+ if (!endpoint && launchRes?.alreadyRunning) {
46
+ // keepOpen: máy bị chặn số lượt mở/ngày. Tắt-mở lại một profile đang chạy
47
+ // sẵn chỉ để lấy endpoint mới đốt 1 lượt cho không — nối thẳng vào cổng
48
+ // debug của nó thay vì relaunch.
49
+ if (keepOpen) {
50
+ const live = await nst.resolveRunningEndpoint(profileId).catch(() => null);
51
+ if (live?.httpEndpoint) {
52
+ endpoint = live.httpEndpoint;
53
+ log('info', `[pw] profile đang chạy — nối vào cổng debug ${live.port} (không tốn lượt mở)`);
54
+ } else {
55
+ log('warn', `[pw] profile đang chạy nhưng không đọc được cổng debug — buộc phải mở lại (tốn 1 lượt)`);
56
+ }
57
+ }
58
+ if (!endpoint) {
59
+ log('info', `[pw] profile already running — force-relaunching for fresh CDP endpoint`);
60
+ const r = await nst.launchProfile(profileId, { forceRelaunch: true });
61
+ endpoint = r?.wsEndpoint;
62
+ }
49
63
  }
50
- if (!wsEndpoint) throw new Error(`No webSocketDebuggerUrl from NST for profile ${profileId}`);
64
+ if (!endpoint) throw new Error(`No webSocketDebuggerUrl from NST for profile ${profileId}`);
51
65
 
52
66
  log('info', `[pw] connectOverCDP…`);
53
- const browser = await playwright.chromium.connectOverCDP(wsEndpoint);
67
+ const browser = await playwright.chromium.connectOverCDP(endpoint);
54
68
  try {
55
69
  const context = browser.contexts()[0] || await browser.newContext();
56
70
  const page = context.pages()[0] || await context.newPage();
@@ -117,7 +117,18 @@ function parseOfferRow(row) {
117
117
  name,
118
118
  category_group: categoryGroup(name),
119
119
  product_url: row.product_link || (shopId && itemId ? `https://shopee.vn/product/${shopId}/${itemId}` : ''),
120
- affiliate_link: row.long_link || row.offer_link || '',
120
+ // ĐỪNG gán `long_link` vào đây (đo 09/09/2026). Offer list chỉ trả hai
121
+ // link và KHÔNG cái nào là link affiliate:
122
+ // long_link = shopee.vn/universal-link/...?gads_t_sig=… (chữ ký
123
+ // Google Ads, không mang mã cộng tác viên)
124
+ // product_link = link sản phẩm trần
125
+ // Gán nhầm thì kho đầy link trông y như thật, gắn vào caption chạy trơn,
126
+ // người xem mua được — mà mình KHÔNG nhận đồng hoa hồng nào và không một
127
+ // dòng lỗi nào báo. 34/34 sản phẩm trong kho đang dính đúng lỗi này.
128
+ // Link affiliate thật phải sinh qua nút "Get Link" trên trang; cách tự
129
+ // động hoá còn đang tìm, nên để TRỐNG thay vì để một link giả.
130
+ affiliate_link: '',
131
+ universal_link: row.long_link || '',
121
132
  commission_rate: rate,
122
133
  commission_seller_rate: sellerRate,
123
134
  commission_value: price && rate ? Math.round(price * rate / 100) : null,
@@ -5,9 +5,12 @@ const WebSocket = require('ws');
5
5
  * Flow: Launch profile → navigate to youtube.com/@handle/about → read stats table
6
6
  */
7
7
  class StatsSyncer {
8
- constructor(nstManager, apiClient) {
8
+ constructor(nstManager, apiClient, options = {}) {
9
9
  this.nst = nstManager;
10
10
  this.api = apiClient;
11
+ // Máy bị chặn số lượt mở Nstbrowser/ngày → không đóng profile sau khi đọc
12
+ // số liệu, và tái dùng browser đang chạy thay vì mở lại.
13
+ this.keepOpen = !!options.keepOpen;
11
14
  }
12
15
 
13
16
  async syncYouTubeStats(channel) {
@@ -16,9 +19,21 @@ class StatsSyncer {
16
19
 
17
20
  console.log(`[stats] Syncing YouTube stats for "${channel.name}" (profile: ${profileId})`);
18
21
 
19
- const { wsEndpoint } = await this.nst.launchProfile(profileId, {
22
+ const launched = await this.nst.launchProfile(profileId, {
20
23
  proxy: channel.proxy || null,
21
24
  });
25
+ let wsEndpoint = launched?.wsEndpoint;
26
+
27
+ // Profile đang chạy sẵn → launchProfile trả { alreadyRunning: true } và
28
+ // KHÔNG có wsEndpoint. Trước đây chỗ này ném lỗi luôn; chế độ giữ mở khiến
29
+ // tình huống đó thành mặc định, nên lấy endpoint từ cổng debug đang chạy.
30
+ if (!wsEndpoint && launched?.alreadyRunning) {
31
+ const live = await this.nst.resolveRunningEndpoint(profileId).catch(() => null);
32
+ if (live?.wsEndpoint) {
33
+ wsEndpoint = live.wsEndpoint;
34
+ console.log(`[stats] Dùng lại browser đang chạy (cổng debug ${live.port})`);
35
+ }
36
+ }
22
37
 
23
38
  if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
24
39
 
@@ -40,10 +55,14 @@ class StatsSyncer {
40
55
  console.log(`[stats] "${channel.name}" stats saved to API`);
41
56
  return stats;
42
57
  } finally {
43
- try {
44
- await this.nst.stopProfile(profileId);
45
- } catch (err) {
46
- console.warn(`[stats] Failed to stop profile: ${err.message}`);
58
+ if (this.keepOpen) {
59
+ console.log(`[stats] Giữ profile ${profileId} mở (máy bị chặn số lượt mở/ngày)`);
60
+ } else {
61
+ try {
62
+ await this.nst.stopProfile(profileId);
63
+ } catch (err) {
64
+ console.warn(`[stats] Failed to stop profile: ${err.message}`);
65
+ }
47
66
  }
48
67
  }
49
68
  }
package/lib/updater.js CHANGED
@@ -31,6 +31,28 @@ function installUpdate(version) {
31
31
  console.log(`[updater] Installed ${PKG_NAME}@${version}`);
32
32
  }
33
33
 
34
+ /**
35
+ * Cây vừa cài có nạp được không? `channel-worker selftest` require từng file
36
+ * trong lib/ và thoát 0 nếu đủ.
37
+ *
38
+ * npm cài ĐÈ LÊN module đang chạy, và trên Windows nó xoá cây cũ trước khi ghi
39
+ * cây mới. Lượt cài dừng giữa chừng để lại package.json version MỚI cạnh một
40
+ * lib/ THIẾU FILE — nhìn version thì thấy "cài xong". 08/09/2026 daemon thay
41
+ * thế chết ngay MODULE_NOT_FOUND (lib/daemon.js + lib/command-poller.js biến
42
+ * mất), hai máy Windows tắt giữa lúc đăng 9 video.
43
+ */
44
+ function verifyInstall() {
45
+ const { spawnSync } = require('child_process');
46
+ const isWindows = process.platform === 'win32';
47
+ const bin = isWindows ? 'channel-worker.cmd' : 'channel-worker';
48
+ const out = spawnSync(bin, ['selftest'], {
49
+ shell: isWindows, windowsHide: true, encoding: 'utf8', timeout: 120000,
50
+ });
51
+ // status null = không chạy nổi (timeout, binary không thấy) → coi như hỏng.
52
+ const detail = `${out.stdout || ''}${out.stderr || ''}`.trim().split('\n').slice(-6).join(' | ');
53
+ return { ok: out.status === 0, detail: detail.slice(-600) };
54
+ }
55
+
34
56
  async function checkAndUpdate({ autoRestart = false } = {}) {
35
57
  const local = getLocalVersion();
36
58
  const latest = await getLatestVersion();
@@ -42,6 +64,26 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
42
64
  console.log(`[updater] Update available: ${local} → ${latest}`);
43
65
  installUpdate(latest);
44
66
 
67
+ // Cài xong CHƯA CHẮC chạy được — xem verifyInstall(). Hỏng thì thử cài lại
68
+ // đúng một lần, vẫn hỏng thì Ở YÊN: tiến trình này vẫn đang chạy code cũ đã
69
+ // nạp trong bộ nhớ, nên không restart là còn worker; restart vào cây cụt là
70
+ // mất worker. Bản mới sẽ vào ở lượt kiểm kế tiếp, hoặc do người cài tay.
71
+ let check = verifyInstall();
72
+ if (!check.ok) {
73
+ console.warn(`[updater] Bản vừa cài KHÔNG nạp được (${check.detail}) — cài lại một lần.`);
74
+ try {
75
+ installUpdate(latest);
76
+ check = verifyInstall();
77
+ } catch (e) {
78
+ check = { ok: false, detail: `cài lại lỗi: ${e.message}` };
79
+ }
80
+ }
81
+ if (!check.ok) {
82
+ console.error('[updater] BỎ khởi động lại: cây cài dở, daemon cũ chạy tiếp. '
83
+ + `Cần cài tay: npm i -g ${PKG_NAME}@${latest} --prefer-online (dừng daemon trước). Chi tiết: ${check.detail}`);
84
+ return { updated: false, local, latest, verifyFailed: true, detail: check.detail };
85
+ }
86
+
45
87
  if (autoRestart) {
46
88
  console.log('[updater] Restarting daemon with new version...');
47
89
  const { spawn } = require('child_process');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.91",
3
+ "version": "2.5.93",
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": {
@@ -631,6 +631,41 @@ async function dismissToasts(page) {
631
631
  }).catch(() => 0);
632
632
  }
633
633
 
634
+ // Vòng bấm nút xác nhận đăng của FB. Tách khỏi runOnce để test được bằng hàm
635
+ // THẬT: đây đúng là chỗ đẻ ra "đăng thành công" giả ngày 2026-09-09 (3 video
636
+ // kênh Kho Mẹo Nhà Nông báo đã đăng, Facebook không có bài).
637
+ //
638
+ // Luật, đổi bằng máu:
639
+ // 1. MỖI lượt phải ĐÁNH DẤU LẠI nút. Log prod của lượt hỏng cho thấy cả 2 cú
640
+ // bấm đều timeout và KHÔNG có dòng JS-dispatch — tức thẻ `__fbpw_confirm__`
641
+ // nằm trên node React đã bị dựng lại, `querySelector` trả null. Dùng lại
642
+ // thẻ của lượt trước là bấm vào hư không.
643
+ // 2. Bấm trượt thì chốt bằng TÌM-VÀ-BẤM trong cùng một evaluate — không chừa
644
+ // khoảng hở cho re-render.
645
+ // 3. Hết lượt mà hộp còn đó thì TRẢ VỀ confirmed=false. Code cũ nuốt lỗi rồi
646
+ // vẫn `published = true`.
647
+ // Trả { confirmed } — KHÔNG ném lỗi: bài có thể đã lên thật, ném lỗi ở đây là
648
+ // mở đường cho lịch đăng lại → đăng đúp (đã xảy ra 2026-08-19).
649
+ async function confirmPublishDialog({ firstHit, tagConfirmButton, clickTagged, clickInPlace, wait, log, rounds = 3 }) {
650
+ let pending = true;
651
+ for (let round = 0; round < rounds; round++) {
652
+ const hit = round === 0 ? firstHit : await tagConfirmButton();
653
+ if (!hit) { pending = false; break; } // hộp đã đóng = đã xác nhận
654
+ let clicked = false;
655
+ try {
656
+ await clickTagged(hit);
657
+ clicked = true;
658
+ } catch (e) {
659
+ log('warn', `[fb-pw] confirm click lượt ${round + 1}/${rounds} hỏng (${String(e && e.message || e).split('\n')[0].slice(0, 70)}) — bấm thẳng trong DOM`);
660
+ clicked = await clickInPlace();
661
+ if (clicked) log('info', '[fb-pw] confirm bấm được bằng tìm-và-bấm cùng lượt');
662
+ }
663
+ await wait(clicked ? 3000 : 1500);
664
+ if (!(await tagConfirmButton())) { pending = false; break; }
665
+ }
666
+ return { confirmed: !pending };
667
+ }
668
+
634
669
  async function hasVisibleReelComposer(page) {
635
670
  return page.evaluate(() => {
636
671
  for (const dlg of document.querySelectorAll("[role='dialog']")) {
@@ -1479,6 +1514,9 @@ async function runOnce({ page, payload, log }) {
1479
1514
  // (publish if available, else Tiếp). If we never find publish in 6
1480
1515
  // iterations, throw with diagnostics.
1481
1516
  let published = false;
1517
+ // Bấm nút xác nhận của FB hỏng cả 3 lượt → kết quả lệnh mang cờ này để
1518
+ // phía API biết đây là ca "báo xong nhưng chưa chắc đã lên".
1519
+ let confirmClickFailed = false;
1482
1520
  let customThumbDone = false;
1483
1521
  let pubWaitDone = false;
1484
1522
  let reelViewerRecoveries = 0; // bấm Tiếp trượt vào toast → FB mở reel viewer, xem bên dưới
@@ -2242,51 +2280,98 @@ async function runOnce({ page, payload, log }) {
2242
2280
  // doesn't actually publish. Poll for the dialog up to 8s (FB's
2243
2281
  // dialog mount is slow — a single 2.5s wait often missed it).
2244
2282
  const confirmVerbs = ['Xác nhận', 'Xác minh', 'Đồng ý', 'Confirm', 'Continue', 'OK', 'Đăng'];
2245
- let confirmHit = null;
2246
- const cfmDeadline = Date.now() + 8000;
2247
- while (Date.now() < cfmDeadline && !confirmHit) {
2248
- confirmHit = await page.evaluate((verbs) => {
2249
- const dlgs = document.querySelectorAll("[role='dialog']");
2250
- for (const dlg of dlgs) {
2251
- const r = dlg.getBoundingClientRect();
2252
- if (r.width < 8 || r.height < 8) continue;
2253
- const cs = getComputedStyle(dlg);
2254
- if (cs.visibility === 'hidden' || cs.display === 'none') continue;
2255
- const btns = dlg.querySelectorAll("button, [role='button']");
2256
- for (const v of verbs) {
2257
- for (const b of btns) {
2258
- const t = (b.innerText || '').trim();
2259
- if (t === v) {
2260
- // Clear stale markers first — a previous confirm attempt
2261
- // that threw left its tag behind, and two tagged nodes
2262
- // make the locator fail on strict mode.
2263
- document.querySelectorAll('[__fbpw_confirm__]').forEach((n) => n.removeAttribute('__fbpw_confirm__'));
2264
- b.setAttribute('__fbpw_confirm__', '1');
2265
- return { selector: "[__fbpw_confirm__='1']", verb: v };
2266
- }
2283
+ // Tìm nút xác nhận và ĐÁNH DẤU nó. Tách thành hàm vì phải gọi LẠI
2284
+ // trước MỖI lượt bấm: FB dựng lại cây React liên tục, thẻ đánh dấu
2285
+ // rụng theo node nên dùng lại thẻ của lượt trước là trỏ vào hư
2286
+ // không (xem ghi chú ở khối bấm bên dưới).
2287
+ const tagConfirmButton = () => page.evaluate((verbs) => {
2288
+ const dlgs = document.querySelectorAll("[role='dialog']");
2289
+ for (const dlg of dlgs) {
2290
+ const r = dlg.getBoundingClientRect();
2291
+ if (r.width < 8 || r.height < 8) continue;
2292
+ const cs = getComputedStyle(dlg);
2293
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
2294
+ const btns = dlg.querySelectorAll("button, [role='button']");
2295
+ for (const v of verbs) {
2296
+ for (const b of btns) {
2297
+ const t = (b.innerText || '').trim();
2298
+ if (t === v) {
2299
+ // Clear stale markers first a previous confirm attempt
2300
+ // that threw left its tag behind, and two tagged nodes
2301
+ // make the locator fail on strict mode.
2302
+ document.querySelectorAll('[__fbpw_confirm__]').forEach((n) => n.removeAttribute('__fbpw_confirm__'));
2303
+ b.setAttribute('__fbpw_confirm__', '1');
2304
+ return { selector: "[__fbpw_confirm__='1']", verb: v };
2267
2305
  }
2268
2306
  }
2269
2307
  }
2270
- return null;
2271
- }, confirmVerbs).catch(() => null);
2308
+ }
2309
+ return null;
2310
+ }, confirmVerbs).catch(() => null);
2311
+ // Cú chốt: TÌM VÀ BẤM trong cùng một evaluate — không có khoảng hở
2312
+ // giữa lúc đánh dấu và lúc bấm để React chen vào.
2313
+ const clickConfirmInPlace = () => page.evaluate((verbs) => {
2314
+ const dlgs = document.querySelectorAll("[role='dialog']");
2315
+ for (const dlg of dlgs) {
2316
+ const r = dlg.getBoundingClientRect();
2317
+ if (r.width < 8 || r.height < 8) continue;
2318
+ const cs = getComputedStyle(dlg);
2319
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
2320
+ const btns = dlg.querySelectorAll("button, [role='button']");
2321
+ for (const v of verbs) {
2322
+ for (const b of btns) {
2323
+ const t = (b.innerText || '').trim();
2324
+ if (t !== v) continue;
2325
+ if (b.getAttribute('aria-disabled') === 'true') continue;
2326
+ b.scrollIntoView({ block: 'center' });
2327
+ b.click();
2328
+ return true;
2329
+ }
2330
+ }
2331
+ }
2332
+ return false;
2333
+ }, confirmVerbs).catch(() => false);
2334
+ let confirmHit = null;
2335
+ const cfmDeadline = Date.now() + 8000;
2336
+ while (Date.now() < cfmDeadline && !confirmHit) {
2337
+ confirmHit = await tagConfirmButton();
2272
2338
  if (!confirmHit) await pause(page, 600);
2273
2339
  }
2274
2340
  if (confirmHit) {
2275
2341
  log('info', `[fb-pw] confirming publish via dialog button "${confirmHit.verb}"`);
2276
- const cLoc = page.locator(confirmHit.selector);
2277
- // Was a plain 5s click: it failed on nearly EVERY run ("confirm
2278
- // click failed: Timeout 5000ms exceeded") because the new-post
2279
- // toast covers this dialog's CTA too. Escalate the same way —
2280
- // mute the overlay, then JS-dispatch. The confirm button lives in
2281
- // a dialog that disappears once it fires, so the fallback can't
2282
- // double-publish (querySelector returns null on the second pass).
2283
- await resilientClick(page, cLoc, confirmHit.selector, {
2284
- timeout: 8000,
2342
+ // ⚠️ CHỖ NÀY TỪNG NUỐT LỖI VÀ ĐẺ RA "ĐĂNG THÀNH CÔNG" GIẢ.
2343
+ // Chủ tịch bắt được 2026-09-09 (kênh Kho Mẹo Nhà Nông): 3 video
2344
+ // báo đã đăng, vào Facebook kiểm thì KHÔNG có bài. Log prod của
2345
+ // đúng lượt đó: "confirm click attempt 1/2 failed (locator.waitFor:
2346
+ // Timeout 5000ms)" "2/2 failed (locator.click: Timeout 8000ms)"
2347
+ // "confirm click failed", KHÔNG hề dòng JS-dispatch
2348
+ // fallback tức `document.querySelector('[__fbpw_confirm__]')` trả
2349
+ // null: nút đã bị React dựng lại, thẻ đánh dấu nằm trên node cũ đã
2350
+ // rời DOM. Bấm trượt xong code cũ chỉ ghi một dòng warn rồi vẫn
2351
+ // `published = true` → lệnh báo done, API cho idea sang "Đã đăng",
2352
+ // lịch không bao giờ đăng lại. Đo prod 30 ngày: ~39 ca.
2353
+ //
2354
+ // Sửa: mỗi lượt ĐÁNH DẤU LẠI từ đầu; bấm trượt thì chốt bằng
2355
+ // tìm-và-bấm trong cùng một evaluate; hết 3 lượt mà hộp xác nhận
2356
+ // vẫn còn thì GHI NHẬN thất bại thay vì im lặng.
2357
+ const { confirmed } = await confirmPublishDialog({
2358
+ firstHit: confirmHit,
2359
+ tagConfirmButton,
2360
+ clickTagged: (hit) => resilientClick(page, page.locator(hit.selector), hit.selector, {
2361
+ timeout: 6000,
2362
+ log,
2363
+ label: `confirm "${hit.verb}"`,
2364
+ attempts: 1,
2365
+ }),
2366
+ clickInPlace: clickConfirmInPlace,
2367
+ wait: (ms) => pause(page, ms),
2285
2368
  log,
2286
- label: `confirm "${confirmHit.verb}"`,
2287
- attempts: 2,
2288
- }).catch((e) => log('warn', `[fb-pw] confirm click failed: ${e.message.split('\n')[0].slice(0, 80)}`));
2289
- await pause(page, 3000);
2369
+ });
2370
+ if (!confirmed) {
2371
+ confirmClickFailed = true;
2372
+ log('warn', '[fb-pw] hộp xác nhận đăng VẪN MỞ sau 3 lượt bấm — bài nhiều khả năng CHƯA lên');
2373
+ await dumpFailure(page, 'confirm-stuck', log);
2374
+ }
2290
2375
  } else {
2291
2376
  log('info', '[fb-pw] no confirmation dialog detected after 8s — assuming direct publish');
2292
2377
  }
@@ -2854,7 +2939,13 @@ async function runOnce({ page, payload, log }) {
2854
2939
  // post_url empty than to record a wrong one. The spam-limit guard above
2855
2940
  // now catches the FB block case explicitly.
2856
2941
 
2857
- if (!postUrl) log('warn', '[fb-pw] post URL not captured upload still considered ok (no spam-block detected, FB likely accepted)');
2942
+ // Không lấy được link bài = KHÔNG reel mới nào hiện ra sau khi đăng
2943
+ // (nhánh last-resort đã từ chối tile cũ theo luật 2026-08-19). Trước đây
2944
+ // dòng này ghi "FB likely accepted" rồi cho qua; đo prod 30 ngày tới
2945
+ // 2026-09-09 cho thấy phần lớn ca như vậy là bài CHƯA lên. Lệnh vẫn trả
2946
+ // ok để KHÔNG tự đăng lại (đăng lại là nguy cơ đăng đúp) — việc hạ xuống
2947
+ // "mập mờ, chờ người kiểm" do API làm, ở markUnverifiedPublish().
2948
+ if (!postUrl) log('warn', `[fb-pw] KHÔNG lấy được link bài — bài có thể CHƯA lên (confirm_click_failed=${confirmClickFailed}). API sẽ đánh dấu mập mờ để người kiểm.`);
2858
2949
 
2859
2950
  // Strip FB tracking params (__cft__, __tn__, etc.) so the stored URL is
2860
2951
  // canonical. The reel ID alone uniquely identifies the post and the
@@ -2875,6 +2966,10 @@ async function runOnce({ page, payload, log }) {
2875
2966
  title: String(title || description || '').slice(0, 80),
2876
2967
  visibility,
2877
2968
  post_url: postUrl,
2969
+ // Hai cờ chẩn đoán cho ca "báo xong mà không có link bài" — API đọc
2970
+ // post_url để quyết, hai cờ này để người đọc log biết hỏng ở khâu nào.
2971
+ confirm_click_failed: confirmClickFailed,
2972
+ post_url_captured: !!postUrl,
2878
2973
  video_path_local: videoPath,
2879
2974
  };
2880
2975
  } finally {
@@ -2912,6 +3007,7 @@ module.exports = { run };
2912
3007
  // page (toast covering the CTA) without driving the whole upload flow.
2913
3008
  module.exports.__testables = {
2914
3009
  resilientClick,
3010
+ confirmPublishDialog,
2915
3011
  muteClickInterceptors,
2916
3012
  unmuteClickInterceptors,
2917
3013
  waitForPublishEnabled,