channel-worker 2.5.92 → 2.5.94

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/lib/api-client.js CHANGED
@@ -68,7 +68,7 @@ class ApiClient {
68
68
  async getNextCommand(workerId) {
69
69
  // Daemon-handled types. `_pw` variants route to the Playwright pipeline
70
70
  // (lib/playwright-runner → scripts/<base>.js) instead of the extension.
71
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,scrape_affiliate_products,ingest_shopee_product';
71
+ const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,upload_facebook_photo_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,nurture_facebook_pw,fetch_facebook_reel_stats_pw,scrape_affiliate_products,ingest_shopee_product,get_affiliate_link';
72
72
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
73
73
  }
74
74
 
@@ -94,6 +94,12 @@ class ApiClient {
94
94
  return this.request('POST', '/products/worker-ingest', { product, ...meta });
95
95
  }
96
96
 
97
+ // Trả LINK AFFILIATE thật về kho. `sub_ids` đi kèm để sau này biết link này
98
+ // gắn cho kênh/video nào — đó là cả lý do link phải sinh riêng từng bài.
99
+ async affiliateLinkResult(links, { user_id, sub_ids, idea_id } = {}) {
100
+ return this.request('POST', '/products/worker-link', { links, user_id, sub_ids, idea_id });
101
+ }
102
+
97
103
  // Return the calling daemon's own Worker doc — primarily for reading
98
104
  // parallel_limit (the per-daemon scene-generation concurrency cap that
99
105
  // replaced the legacy global flowkit_max_concurrent setting).
@@ -115,6 +115,9 @@ class CommandPoller {
115
115
  case 'ingest_shopee_product':
116
116
  await this.handleIngestShopeeProduct(command);
117
117
  break;
118
+ case 'get_affiliate_link':
119
+ await this.handleGetAffiliateLink(command);
120
+ break;
118
121
  default:
119
122
  // Playwright-based pipeline: any command whose type ends in '_pw'
120
123
  // is routed to scripts/<base>.js (BrowserClaw-style automation
@@ -252,6 +255,59 @@ class CommandPoller {
252
255
  }
253
256
  }
254
257
 
258
+ // Sinh LINK AFFILIATE THẬT (s.shopee.vn/...) cho một hoặc nhiều sản phẩm,
259
+ // kèm sub-id để đo click/đơn về tới từng kênh và từng video.
260
+ // Open API chính thức đã bị Shopee từ chối, nên phải đi qua phiên đăng nhập
261
+ // như mọi việc Shopee khác — xem ghi chú ở shopee-scraper.generateOfferLink.
262
+ async handleGetAffiliateLink(command) {
263
+ const payload = command.payload || {};
264
+ if (!(await this._ensureNst(command))) return;
265
+ const scraper = require('./shopee-scraper');
266
+ const profileName = payload.profile_name || (await this.api.getSetting('shopee_affiliate_profile').catch(() => null)) || 'Shopee1';
267
+
268
+ let items = Array.isArray(payload.items) ? payload.items : [];
269
+ if (!items.length && payload.url) {
270
+ const ids = scraper.parseProductUrl(payload.url);
271
+ if (ids) items = [ids];
272
+ }
273
+ if (!items.length && payload.shop_id && payload.item_id) items = [{ shop_id: payload.shop_id, item_id: payload.item_id }];
274
+ if (!items.length) {
275
+ await this.api.updateCommand(command._id, { status: 'failed', error: 'cần items[] hoặc url hoặc {shop_id,item_id}' });
276
+ return;
277
+ }
278
+
279
+ let conn;
280
+ try {
281
+ conn = await this._connectNstProfileByName(profileName);
282
+ const res = await scraper.generateOfferLink(conn.context, {
283
+ items,
284
+ subIds: Array.isArray(payload.sub_ids) ? payload.sub_ids : [],
285
+ });
286
+ if (res.status === 'needs_verify') {
287
+ // Anti-bot bounce — báo sạch, KHÔNG thử lại để khỏi đâm thêm captcha.
288
+ console.warn(`[shopee] lấy link: needs_verify — ${res.reason}`);
289
+ await this.api.updateCommand(command._id, { status: 'done', result: { needs_verify: true, reason: res.reason } });
290
+ return;
291
+ }
292
+ if (res.status !== 'ok') {
293
+ await this.api.updateCommand(command._id, { status: 'failed', error: String(res.reason || 'không lấy được link').slice(0, 500) });
294
+ return;
295
+ }
296
+ console.log(`[shopee] lấy được ${res.links.length} link (sub: ${JSON.stringify(res.sub_ids)})`);
297
+ await this.api.affiliateLinkResult(res.links, {
298
+ user_id: command.user_id,
299
+ sub_ids: res.sub_ids,
300
+ idea_id: payload.idea_id || null,
301
+ });
302
+ await this.api.updateCommand(command._id, { status: 'done', result: { links: res.links, sub_ids: res.sub_ids } });
303
+ } catch (err) {
304
+ console.error(`[shopee] lấy link thất bại: ${err.message}`);
305
+ await this.api.updateCommand(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
306
+ } finally {
307
+ if (conn) conn.disconnect();
308
+ }
309
+ }
310
+
255
311
  async handlePlaywrightCommand(command) {
256
312
  const { runPlaywrightScript } = require('./playwright-runner');
257
313
  const payload = command.payload || {};
@@ -277,13 +333,29 @@ class CommandPoller {
277
333
  }
278
334
  if (!this._pwInFlight) this._pwInFlight = new Map();
279
335
  const MUTEX_MAX_WAIT_MS = 30 * 60 * 1000;
336
+ // Nhịp báo còn sống trong lúc XẾP HÀNG. Bộ dọn của API coi lệnh đăng
337
+ // 'running' quá 20 phút là "worker crash giữa chừng": nó đánh failed +
338
+ // ambiguous, idea bị RÚT khỏi kho tự động và bắt người vào Facebook kiểm —
339
+ // trong khi bài còn chưa hề được thử đăng. Hai ngưỡng đá nhau (chờ ở đây
340
+ // tối đa 30 phút > 20 phút của bộ dọn), đo prod 2026-09-09 tối: 6 bài bị
341
+ // khai tử oan trong 7 phút (19:14→19:21) khi lệnh dồn cục vào một profile.
342
+ // Chạm vào lệnh mỗi 2 phút là đủ giữ `updatedAt` tươi mà không nện API.
343
+ const MUTEX_BEAT_MS = 2 * 60 * 1000;
280
344
  const waitStart = Date.now();
345
+ let lastBeat = Date.now();
281
346
  while (this._pwInFlight.has(profileId)) {
282
347
  if (Date.now() - waitStart > MUTEX_MAX_WAIT_MS) {
283
348
  await this.api.updateCommand(command._id, { status: 'failed', error: `pw mutex timeout — profile ${profileId} busy with ${this._pwInFlight.get(profileId)} for >30min` });
284
349
  return;
285
350
  }
286
351
  console.log(`[commands/pw] ${command.type} waiting — profile ${profileId} busy with ${this._pwInFlight.get(profileId)}`);
352
+ if (Date.now() - lastBeat > MUTEX_BEAT_MS) {
353
+ lastBeat = Date.now();
354
+ const waitedMin = Math.round((Date.now() - waitStart) / 60000);
355
+ await this.api.updateCommand(command._id, { status: 'running' })
356
+ .then(() => console.log(`[commands/pw] ${command.type} vẫn đang xếp hàng ${waitedMin} phút — báo còn sống để bộ dọn không khai tử`))
357
+ .catch(() => {});
358
+ }
287
359
  await new Promise(r => setTimeout(r, 5000));
288
360
  }
289
361
  this._pwInFlight.set(profileId, command.type);
@@ -323,6 +395,8 @@ class CommandPoller {
323
395
  platform: scriptName.replace(/^upload_/, ''),
324
396
  });
325
397
 
398
+ const keepOpen = await this._keepOpenCfg();
399
+
326
400
  try {
327
401
  const result = await runPlaywrightScript({
328
402
  nst: this.nst,
@@ -330,6 +404,7 @@ class CommandPoller {
330
404
  scriptName,
331
405
  payload,
332
406
  log,
407
+ keepOpen: keepOpen.enabled,
333
408
  });
334
409
  // postCommandResult (not updateCommand) so the existing extension-side
335
410
  // logic mirrors status into ContentJob + ContentIdea.publish_results +
@@ -356,7 +431,17 @@ class CommandPoller {
356
431
  // Scoped to the browse-only scripts. Publish keeps its profile open on
357
432
  // purpose: one profile serves several platform uploads back-to-back.
358
433
  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) {
434
+ // Cờ vừa bị TẮT trên web → bỏ profile khỏi sổ giữ mở, để nó quay về vòng
435
+ // đời đóng-mở bình thường ngay từ lượt này.
436
+ if (!keepOpen.enabled && this._keptOpenProfiles) this._keptOpenProfiles.delete(profileId);
437
+ if (keepOpen.enabled && this.nst) {
438
+ // Máy bị chặn số lượt mở/ngày: KHÔNG đóng, ghi sổ để lượt sau nối lại
439
+ // vào đúng browser này. Chỉ trần `keep_open_max` mới được đóng bớt.
440
+ if (!this._keptOpenProfiles) this._keptOpenProfiles = new Map();
441
+ this._keptOpenProfiles.set(profileId, Date.now());
442
+ console.log(`[keep-open] giữ profile ${profileId} mở sau ${scriptName} (${this._keptOpenProfiles.size}/${keepOpen.max})`);
443
+ await this._trimKeptProfiles(keepOpen.max).catch((e) => console.warn(`[keep-open] trim lỗi: ${e.message}`));
444
+ } else if (CLOSE_AFTER.has(scriptName) && this.nst) {
360
445
  try {
361
446
  await this.nst.stopProfile(profileId);
362
447
  console.log(`[commands/pw] closed profile ${profileId} after ${scriptName}`);
@@ -379,6 +464,62 @@ class CommandPoller {
379
464
  }
380
465
  }
381
466
 
467
+ // ─── Giữ profile mở (máy bị chặn số lượt mở/ngày) ─────────────────────────
468
+ // Gói Nstbrowser không nâng cấp chặn ~30 LƯỢT MỞ/ngày (relabs03, acc
469
+ // maithithu). Mặc định daemon đóng profile sau mỗi việc rồi mở lại cho việc
470
+ // sau → mỗi task tốn 1 lượt, cháy hạn mức từ giữa ngày. Cờ `keep_profiles_open`
471
+ // trên hàng Worker (bật trong web, trang Workers) đổi hành vi thành: giữ
472
+ // browser mở và tái dùng, mỗi profile chỉ tốn ĐÚNG 1 lượt/ngày.
473
+ //
474
+ // Đọc qua /workers/me, cache 30s — đổi cờ trên web có tác dụng trong nửa phút,
475
+ // không cần restart daemon.
476
+ async _keepOpenCfg() {
477
+ const CACHE_MS = 30 * 1000;
478
+ const now = Date.now();
479
+ if (this._keepOpenCache && (now - this._keepOpenCache.at) < CACHE_MS) return this._keepOpenCache;
480
+ // Không đọc được /workers/me → fail-safe về hành vi CŨ (vẫn đóng profile).
481
+ // Giữ mở nhầm nguy hiểm hơn đóng nhầm: profile mở tích luỹ sẽ ngốn hết RAM.
482
+ let cfg = { at: now, enabled: false, max: 6 };
483
+ try {
484
+ if (this.api.getMyWorker) {
485
+ const me = await this.api.getMyWorker();
486
+ if (me) {
487
+ cfg.enabled = !!me.keep_profiles_open;
488
+ const n = parseInt(me.keep_open_max, 10);
489
+ if (!Number.isNaN(n) && n >= 1) cfg.max = n;
490
+ }
491
+ }
492
+ } catch (e) {
493
+ if (this.config.verbose) console.warn('[keep-open] getMyWorker failed, giữ hành vi cũ:', e.message);
494
+ }
495
+ this._keepOpenCache = cfg;
496
+ return cfg;
497
+ }
498
+
499
+ // Trần RAM cho chế độ giữ mở: quá `max` thì đóng profile RẢNH LÂU NHẤT.
500
+ // Chỉ đụng tới profile do luồng _pw giữ (sổ `_keptOpenProfiles`) — KHÔNG đụng
501
+ // profile renderer, vòng đời của chúng do scene-dispatch quản.
502
+ async _trimKeptProfiles(max) {
503
+ if (!this._keptOpenProfiles || this._keptOpenProfiles.size <= max) return;
504
+ const candidates = [...this._keptOpenProfiles.entries()]
505
+ .filter(([id]) => !(this._pwInFlight && this._pwInFlight.has(id)))
506
+ .sort((a, b) => a[1] - b[1]);
507
+ let over = this._keptOpenProfiles.size - max;
508
+ for (const [id] of candidates) {
509
+ if (over <= 0) break;
510
+ try {
511
+ await this.nst.stopProfile(id);
512
+ console.log(`[keep-open] đóng ${id} — rảnh lâu nhất, quá trần ${max} profile giữ mở`);
513
+ } catch (e) {
514
+ console.warn(`[keep-open] đóng ${id} lỗi: ${e.message}`);
515
+ }
516
+ // Xoá khỏi sổ dù đóng lỗi: giữ lại chỉ khiến vòng sau thử lại vô ích và
517
+ // chặn mất suất của profile khác.
518
+ this._keptOpenProfiles.delete(id);
519
+ over--;
520
+ }
521
+ }
522
+
382
523
  // Hẹn đóng profile sau một quãng RẢNH. Lượt _pw kế tiếp của cùng profile huỷ
383
524
  // hẹn (xem đầu handlePlaywrightCommand), nên chuỗi upload nhiều nền tảng liên
384
525
  // tiếp vẫn dùng chung một browser như thiết kế cũ.
@@ -1319,6 +1460,16 @@ class CommandPoller {
1319
1460
 
1320
1461
  async handleCloseProfile(command) {
1321
1462
  const { profile_id } = command.payload || {};
1463
+ // Máy bị chặn số lượt mở/ngày: bỏ qua lệnh đóng của API. KHÔNG nhả lease
1464
+ // kèm theo — profile vẫn đang mở ở máy này, nhả lease là mời máy khác mở
1465
+ // cùng profile → hai browser một tài khoản, ghi đè cookie của nhau.
1466
+ // Lease tự hết hạn sau TTL 10 phút nếu máy này thật sự bỏ việc.
1467
+ const keepOpen = await this._keepOpenCfg();
1468
+ if (keepOpen.enabled) {
1469
+ console.log(`[keep-open] bỏ qua close_profile ${profile_id} — máy đang bật chế độ giữ profile mở`);
1470
+ await this.api.updateCommand(command._id, { status: 'done', result: { skipped: 'keep_profiles_open' } });
1471
+ return;
1472
+ }
1322
1473
  console.log(`[commands] Closing profile: ${profile_id}`);
1323
1474
  try {
1324
1475
  // NstManager exposes stopProfile — there has never been a closeProfile.
@@ -1440,7 +1591,8 @@ class CommandPoller {
1440
1591
  // Pass youtube_handle from command payload
1441
1592
  channel.youtube_handle = youtube_handle || null;
1442
1593
 
1443
- const syncer = new StatsSyncer(this.nst, this.api);
1594
+ const keepOpen = await this._keepOpenCfg();
1595
+ const syncer = new StatsSyncer(this.nst, this.api, { keepOpen: keepOpen.enabled });
1444
1596
  const stats = await syncer.syncYouTubeStats(channel);
1445
1597
 
1446
1598
  await this.api.updateCommand(command._id, {
@@ -1946,6 +2098,13 @@ class CommandPoller {
1946
2098
  async _checkProfileTimeouts() {
1947
2099
  try {
1948
2100
  if (!this.nst) return;
2101
+ // 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
2102
+ // lần nó đóng là lần sau tốn thêm 1 lượt mở. Tắt hẳn; trần RAM đã do
2103
+ // `keep_open_max` lo. (Vòng xoay round-robin của scene-dispatch thì KHÔNG
2104
+ // tắt — cái đó là chống captcha "2 phiên/1 tài khoản Google", an toàn tài
2105
+ // khoản đứng trên hạn mức lượt mở.)
2106
+ const keepOpen = await this._keepOpenCfg();
2107
+ if (keepOpen.enabled) return;
1949
2108
 
1950
2109
  const IDLE_TIMEOUT = 30 * 1000; // 30s — matches old extension-side behavior
1951
2110
  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,
@@ -281,6 +292,87 @@ async function scrapeOffers(ctx, {
281
292
  }
282
293
  }
283
294
 
295
+ // Chuẩn hoá một sub-id: CHỈ CHỮ VÀ SỐ. Đo 09/09/2026 — gửi `kenh_C` (có gạch
296
+ // dưới) thì Shopee trả `businessCode:282800002` và data null, còn `kenhB` thì
297
+ // 200 ra link. Placeholder của chính Shopee cũng là SportShoes / InstagramFeed
298
+ // / 1212BirthdaySale. Cắt sạch dấu thay vì để lệnh chết giữa chừng.
299
+ function cleanSubId(v) {
300
+ return String(v == null ? '' : v)
301
+ .replace(/đ/g, 'd').replace(/Đ/g, 'D') // 'đ' KHÔNG tách được bằng NFD (ký tự riêng, không phải chữ + dấu tổ hợp) — bỏ qua là "đợt" thành "ot", hai mã khác nhau có thể trùng nhau
302
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '') // bỏ dấu tiếng Việt
303
+ .replace(/[^A-Za-z0-9]/g, '')
304
+ .slice(0, 50);
305
+ }
306
+
307
+ // Sinh LINK AFFILIATE THẬT cho một hoặc nhiều sản phẩm.
308
+ //
309
+ // Đây là đường DUY NHẤT còn dùng được: Open API chính thức đã bị Shopee từ chối
310
+ // ("chỉ hỗ trợ một số nhóm KOL/KOC có nhân viên hỗ trợ trực tiếp", trả lời
311
+ // 09/09/2026), nên phải gọi trong phiên đăng nhập như mọi thứ khác ở file này.
312
+ //
313
+ // Bắt được payload bằng cách bấm thật nút Get Link → tick Advanced → điền 5 ô
314
+ // subId → "Add to Link". Link trả về dạng `s.shopee.vn/<mã>`, redirect qua
315
+ // `/opaanlp/` — KHÁC hẳn `long_link` của offer list (universal-link + chữ ký
316
+ // Google Ads, không qua đường ghi nhận affiliate nên không ra hoa hồng).
317
+ //
318
+ // subIds: mảng tối đa 5 phần tử. Quy ước của mình: [kênh, video, đợt].
319
+ async function generateOfferLink(ctx, { items = [], subIds = [], timeoutMs = 45000 } = {}) {
320
+ const list = (items || [])
321
+ .map((it) => ({ itemId: String(it.item_id || ''), shopId: Number(it.shop_id) || 0, trace: '' }))
322
+ .filter((it) => it.itemId && it.shopId);
323
+ if (!list.length) throw new Error('generateOfferLink cần ít nhất một {shop_id,item_id}');
324
+
325
+ const subs = {};
326
+ for (let i = 0; i < 5; i++) subs[`subId${i + 1}`] = cleanSubId(subIds[i]);
327
+
328
+ const pagesOpen = ctx.pages();
329
+ let page = pagesOpen.find((p) => p.url().includes('affiliate.shopee.vn'));
330
+ let opened = false;
331
+ if (!page) { page = await ctx.newPage(); opened = true; }
332
+ try {
333
+ if (!page.url().includes('affiliate.shopee.vn')) {
334
+ await page.goto('https://affiliate.shopee.vn/offer/product_offer', { waitUntil: 'networkidle', timeout: timeoutMs }).catch(() => {});
335
+ await sleep(1500);
336
+ }
337
+ if (isBlockedState(page.url(), null)) {
338
+ return { status: 'needs_verify', links: [], reason: 'session at captcha/login — re-verify via VNC' };
339
+ }
340
+ const res = await page.evaluate(async (q) => {
341
+ const body = {
342
+ operationName: 'batchGetProductOfferLink',
343
+ query: `query batchGetProductOfferLink($sourceCaller: SourceCaller!, $productOfferLinkParams: [ProductOfferLinkParam!]!, $advancedLinkParams: AdvancedLinkParams){ productOfferLinks(productOfferLinkParams: $productOfferLinkParams, sourceCaller: $sourceCaller, advancedLinkParams: $advancedLinkParams) { itemId shopId productOfferLink } }`,
344
+ variables: {
345
+ productOfferLinkParams: q.list,
346
+ sourceCaller: 'WEB_SITE_CALLER',
347
+ advancedLinkParams: q.subs,
348
+ },
349
+ };
350
+ try {
351
+ const r = await fetch('/api/v3/gql?q=productOfferLinks', {
352
+ method: 'POST', credentials: 'include',
353
+ headers: { 'Content-Type': 'application/json' },
354
+ body: JSON.stringify(body),
355
+ });
356
+ return { status: r.status, json: await r.json().catch(() => null) };
357
+ } catch (e) { return { status: 0, error: String(e.message) }; }
358
+ }, { list, subs });
359
+
360
+ if (isBlockedState(page.url(), res)) {
361
+ return { status: 'needs_verify', links: [], reason: `blocked (http ${res.status})` };
362
+ }
363
+ const err = res.json?.errors?.[0]?.message;
364
+ if (err) return { status: 'failed', links: [], reason: err };
365
+ const rows = res.json?.data?.productOfferLinks || [];
366
+ return {
367
+ status: 'ok',
368
+ sub_ids: subs,
369
+ links: rows.map((r) => ({ item_id: String(r.itemId), shop_id: String(r.shopId), link: r.productOfferLink })),
370
+ };
371
+ } finally {
372
+ if (opened) await page.close().catch(() => {});
373
+ }
374
+ }
375
+
284
376
  // Parse a Shopee product URL → { shop_id, item_id }. Accepts
285
377
  // /product/<shop>/<item> and i.<shop>.<item> forms.
286
378
  function parseProductUrl(url = '') {
@@ -291,4 +383,4 @@ function parseProductUrl(url = '') {
291
383
  return null;
292
384
  }
293
385
 
294
- module.exports = { ingestProduct, scrapeOffers, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters };
386
+ module.exports = { ingestProduct, scrapeOffers, generateOfferLink, cleanSubId, parseProductUrl, parseOfferRow, parsePdpItem, categoryGroup, pctToNumber, AFFILIATE_CATEGORIES, applyFilters };
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.92",
3
+ "version": "2.5.94",
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
  }
@@ -2784,35 +2869,24 @@ async function runOnce({ page, payload, log }) {
2784
2869
  }
2785
2870
  }
2786
2871
 
2787
- // (a.3) LAST-RESORT: first reel tile on the profile reels tab. UNRELIABLE —
2788
- // the just-published reel may not be on the tab yet (still
2789
- // processing), so the first tile can be a STALE older reel. Only used
2790
- // when every authoritative source above (fresh-timestamp tile,
2791
- // title-match, inline CTA, network capture, timed retry) returned
2792
- // nothing. Provably-stale filter: mọi reel ID đã bắt được TRƯỚC khi
2793
- // bấm Đăng reel sẵn trên page reel mới không thể là chúng.
2794
- // Thà post_url rỗng còn hơn ghi sai (2026-08-19: nhánh này ghi reel
2795
- // 02/08 cho bài vừa đăng link trong DB trỏ nhầm bài cũ).
2872
+ // (a.3) ĐÃ GỠ nhánh LAST-RESORT "bốc tile đầu tab Reels" 2026-09-09.
2873
+ // nguồn của mọi link sai: đo prod 30 ngày, 97 video mang link
2874
+ // của BÀI KHÁC (~10% số link), riêng 09/09 15 ca; log win-worker
2875
+ // cho thấy nhánh này chạy 250 lần mà bộ lọc "chắc chắn reel cũ" chỉ
2876
+ // chặn được 6, relabs03 chạy 107 lần chặn được 0. Bộ lọc vô dụng vì
2877
+ // chỉ biết những reel ID đã đi qua GÓI MẠNG trước lúc bấm Đăng,
2878
+ // luồng page-wall không hề mở tab Reels trước đó tile đầu tiên
2879
+ // của tab hầu như không nằm trong danh sách ấy. Nhánh "tile có mốc
2880
+ // thời gian mới" phía trên (a.2/a.2b) thì chưa từng khớp lần nào
2881
+ // (0/250) vì tile trên tab Reels chỉ hiện lượt xem, không hiện giờ
2882
+ // đăng — nên "last-resort" thực chất là "bốc đại cái đầu tiên".
2883
+ // Ca chủ tịch bắt 09/09 (kênh Bí Quyết Nhà Nông): bài 12:53 báo
2884
+ // thành công, link trỏ vào reel của bài 08/09; bài thật CHƯA lên.
2885
+ // Không có link thì API hạ xuống "mập mờ, chờ người kiểm"
2886
+ // (markUnverifiedPublish) — thà rỗng còn hơn sai, và giờ rỗng cũng
2887
+ // không còn hoá thành "đăng thành công" nữa.
2796
2888
  if (!postUrl) {
2797
- const preIds = capturedReelIds.slice(0, capturedReelIdsSnapshotLen);
2798
- const tileHref = await page.evaluate((staleIds) => {
2799
- const staleSet = new Set(staleIds);
2800
- const anchors = document.querySelectorAll("a[role='link']");
2801
- for (const a of anchors) {
2802
- const aria = (a.getAttribute('aria-label') || '').toLowerCase();
2803
- if (!/bản xem trước ô thước phim|reel tile preview|reel preview/.test(aria)) continue;
2804
- const href = a.getAttribute('href') || '';
2805
- const m = href.match(/\/reel\/(\d{8,20})/);
2806
- if (m) return { href, aria: aria.slice(0, 60), id: m[1], provablyStale: staleSet.has(m[1]) };
2807
- }
2808
- return null;
2809
- }, preIds).catch(() => null);
2810
- if (tileHref && tileHref.provablyStale) {
2811
- log('warn', `[fb-pw] LAST-RESORT tile ${tileHref.id} was already captured PRE-publish — provably an OLD reel; leaving post_url empty instead of recording a wrong one`);
2812
- } else if (tileHref) {
2813
- postUrl = tileHref.href.startsWith('http') ? tileHref.href : `https://www.facebook.com${tileHref.href}`;
2814
- log('warn', `[fb-pw] post URL from FIRST reel tile on reels_tab (LAST-RESORT, may be STALE): ${postUrl}`);
2815
- }
2889
+ log('warn', '[fb-pw] không nguồn nào xác nhận được reel mới (mạng/tile mới/tiêu đề đều trượt) — KHÔNG bốc tile đầu tab Reels nữa, để post_url rỗng cho API đánh mập mờ');
2816
2890
  }
2817
2891
 
2818
2892
  // (b) URL change — FB sometimes navigates to /reel/<id>/ after publish.
@@ -2854,7 +2928,13 @@ async function runOnce({ page, payload, log }) {
2854
2928
  // post_url empty than to record a wrong one. The spam-limit guard above
2855
2929
  // now catches the FB block case explicitly.
2856
2930
 
2857
- if (!postUrl) log('warn', '[fb-pw] post URL not captured upload still considered ok (no spam-block detected, FB likely accepted)');
2931
+ // Không lấy được link bài = KHÔNG reel mới nào hiện ra sau khi đăng
2932
+ // (nhánh last-resort đã từ chối tile cũ theo luật 2026-08-19). Trước đây
2933
+ // dòng này ghi "FB likely accepted" rồi cho qua; đo prod 30 ngày tới
2934
+ // 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ả
2935
+ // ok để KHÔNG tự đăng lại (đăng lại là nguy cơ đăng đúp) — việc hạ xuống
2936
+ // "mập mờ, chờ người kiểm" do API làm, ở markUnverifiedPublish().
2937
+ 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
2938
 
2859
2939
  // Strip FB tracking params (__cft__, __tn__, etc.) so the stored URL is
2860
2940
  // canonical. The reel ID alone uniquely identifies the post and the
@@ -2875,6 +2955,10 @@ async function runOnce({ page, payload, log }) {
2875
2955
  title: String(title || description || '').slice(0, 80),
2876
2956
  visibility,
2877
2957
  post_url: postUrl,
2958
+ // Hai cờ chẩn đoán cho ca "báo xong mà không có link bài" — API đọc
2959
+ // post_url để quyết, hai cờ này để người đọc log biết hỏng ở khâu nào.
2960
+ confirm_click_failed: confirmClickFailed,
2961
+ post_url_captured: !!postUrl,
2878
2962
  video_path_local: videoPath,
2879
2963
  };
2880
2964
  } finally {
@@ -2912,6 +2996,7 @@ module.exports = { run };
2912
2996
  // page (toast covering the CTA) without driving the whole upload flow.
2913
2997
  module.exports.__testables = {
2914
2998
  resilientClick,
2999
+ confirmPublishDialog,
2915
3000
  muteClickInterceptors,
2916
3001
  unmuteClickInterceptors,
2917
3002
  waitForPublishEnabled,