channel-worker 2.5.122 → 2.5.125

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.
@@ -26,6 +26,32 @@ function loadScript(scriptName) {
26
26
  return mod;
27
27
  }
28
28
 
29
+ // Chrome bóp requestAnimationFrame ở tab nền. Menu/dialog của FB dựng bằng
30
+ // animation, nên một tab không được focus có thể đứng hình giữa chừng: selector
31
+ // chờ cho tới hết timeout rồi báo "không tìm thấy nút", trong khi bệnh thật là
32
+ // TRANG CHƯA VẼ XONG. Emulation.setFocusEmulationEnabled làm tab tự coi là đang
33
+ // được focus mà KHÔNG phải kéo nó lên trước mặt (bringToFront) — nhiều profile
34
+ // chạy song song không giành nhau tab đang hiện, và người ngồi máy mở tab khác
35
+ // cũng không cắt ngang lượt đăng.
36
+ //
37
+ // Đây KHÔNG phải stealth script (xem cảnh báo đầu file): không chạm fingerprint
38
+ // của NST, chỉ gỡ throttle. Trang luôn-focus cũng là trạng thái bình thường của
39
+ // người thật đang thao tác.
40
+ //
41
+ // Session phải SỐNG suốt lượt chạy — detach là Chrome bỏ override ngay.
42
+ // Fail-open: bản Chrome nào không nhận thì chạy tiếp y như cũ.
43
+ async function enableFocusEmulation(page, log) {
44
+ try {
45
+ const cdp = await page.context().newCDPSession(page);
46
+ await cdp.send('Emulation.setFocusEmulationEnabled', { enabled: true });
47
+ log('info', '[pw] focus emulation: bật — tab nền vẫn vẽ, không cần kéo lên trước');
48
+ return cdp;
49
+ } catch (e) {
50
+ log('warn', `[pw] focus emulation: không bật được, chạy tiếp như cũ (${String(e?.message || e).slice(0, 100)})`);
51
+ return null;
52
+ }
53
+ }
54
+
29
55
  async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {}, keepOpen = false }) {
30
56
  let playwright;
31
57
  try { playwright = require('playwright-core'); }
@@ -65,17 +91,20 @@ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, l
65
91
 
66
92
  log('info', `[pw] connectOverCDP…`);
67
93
  const browser = await playwright.chromium.connectOverCDP(endpoint);
94
+ let focusCdp = null;
68
95
  try {
69
96
  const context = browser.contexts()[0] || await browser.newContext();
70
97
  const page = context.pages()[0] || await context.newPage();
98
+ focusCdp = await enableFocusEmulation(page, log);
71
99
  log('info', `[pw] running script: ${scriptName}`);
72
100
  const result = await script.run({ page, context, browser, payload, log });
73
101
  log('info', `[pw] script done: ${scriptName}`);
74
102
  return result;
75
103
  } finally {
104
+ if (focusCdp) await focusCdp.detach().catch(() => {});
76
105
  // connectOverCDP: this DETACHES Playwright, the NST browser keeps running.
77
106
  try { await browser.close(); } catch {}
78
107
  }
79
108
  }
80
109
 
81
- module.exports = { runPlaywrightScript };
110
+ module.exports = { runPlaywrightScript, enableFocusEmulation };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.122",
3
+ "version": "2.5.125",
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": {
@@ -594,6 +594,58 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw',
594
594
  return { enabled: false, sawPresent };
595
595
  }
596
596
 
597
+ // Poll tới khi có một nút "Tiếp"/"Next" ĐANG BẬT, thay cho ngủ cứng một số giây
598
+ // đoán mò. FB tắt "Tiếp" trong lúc nó xử lý thứ vừa nhận (video sau khi upload,
599
+ // ảnh bìa sau khi thay) rồi bật lại — thời gian xử lý phụ thuộc file, nên số
600
+ // giây cố định vừa phí lúc nhanh vừa hụt lúc chậm.
601
+ //
602
+ // SETTLE TRƯỚC KHI POLL — đừng bỏ: ngay sau cú set file, FB thường CHƯA KỊP tắt
603
+ // "Tiếp". Poll ngay lập tức sẽ thấy nút cũ còn bật và trả về "xong" tức thì,
604
+ // rồi bấm Tiếp khi FB chưa xử lý gì — tức là tệ hơn cả ngủ cứng. Ngủ cứng trước
605
+ // đây vô tình che mất chuyện này. Nhịp settle ngắn cho FB kịp phản ứng, rồi mới
606
+ // tin vào trạng thái nút.
607
+ async function waitForNextEnabled(page, log, {
608
+ timeoutMs = 120_000,
609
+ settleMs = 2500,
610
+ pollMs = 2500,
611
+ tag = 'fb-pw',
612
+ what = 'video',
613
+ } = {}) {
614
+ const t0 = Date.now();
615
+ await pause(page, settleMs);
616
+ const deadline = t0 + timeoutMs;
617
+ while (Date.now() < deadline) {
618
+ const ready = await page.evaluate(() => {
619
+ const dlgs = document.querySelectorAll("[role='dialog']");
620
+ const roots = dlgs.length ? Array.from(dlgs) : [document];
621
+ const verbs = ['Tiếp', 'Tiếp theo', 'Next', 'Continue'];
622
+ for (const root of roots) {
623
+ for (const el of root.querySelectorAll("button, [role='button']")) {
624
+ const t = (el.innerText || '').trim();
625
+ if (!verbs.includes(t)) continue;
626
+ const r = el.getBoundingClientRect();
627
+ if (r.width < 8 || r.height < 8) continue;
628
+ const cs = getComputedStyle(el);
629
+ if (cs.visibility === 'hidden' || cs.display === 'none') continue;
630
+ if (el.getAttribute('aria-disabled') === 'true' || el.disabled) continue;
631
+ return true; // "Tiếp" đang bật → FB xử lý xong, đi tiếp được
632
+ }
633
+ }
634
+ return false;
635
+ }).catch(() => false);
636
+ const elapsedMs = Date.now() - t0;
637
+ if (ready) {
638
+ log('info', `[${tag}] ${what}: FB xử lý xong sau ~${Math.round(elapsedMs / 1000)}s — "Tiếp" đã bật`);
639
+ return { ready: true, elapsedMs };
640
+ }
641
+ await pause(page, pollMs);
642
+ }
643
+ // Không ném: "Tiếp" có thể đang bật mà chữ trên nút khác bộ verbs, và cú bấm
644
+ // Tiếp phía sau vẫn tự chờ theo cách của nó. Chỉ cảnh báo rồi đi tiếp.
645
+ log('warn', `[${tag}] ${what}: chờ hết ${Math.round(timeoutMs / 1000)}s mà không thấy "Tiếp" bật — đi tiếp`);
646
+ return { ready: false, elapsedMs: Date.now() - t0 };
647
+ }
648
+
597
649
  async function isThumbnailEditorVisible(page) {
598
650
  return page.evaluate((textMarkers) => {
599
651
  const isVisible = (el) => {
@@ -640,7 +692,34 @@ async function isCustomThumbApplied(page) {
640
692
  if (/Gỡ hình thu nhỏ tùy chỉnh|Remove( your)? custom thumbnail/i.test(al) && vis(el)) return true;
641
693
  }
642
694
  return false;
643
- }).catch(() => false);
695
+ // `null` = KHÔNG ĐO ĐƯỢC (page.evaluate ném giữa lúc FB dựng lại panel).
696
+ // Bản cũ nuốt thành `false` = "không có thumb", và một mẫu đơn lẻ như thế
697
+ // được quyền bấm Hủy = GỠ ảnh bìa vừa dán. Xem thumbVerdict().
698
+ }).catch(() => null);
699
+ }
700
+
701
+ // Một lần đo KHÔNG được quyết định hành động PHÁ. Đo trên log prod 20/09/2026:
702
+ // trong 152 lượt "hết giờ chờ hộp ảnh bìa đóng", 138 lượt đo thấy thumb đang
703
+ // áp rồi đăng bình thường, còn 14 lượt mẫu CUỐI trả "không áp" (trong khi suốt
704
+ // vòng chờ trước đó mẫu nào cũng "đang áp") → rơi vào nhánh ESC+Hủy, gỡ mất
705
+ // ảnh bìa, rồi chết ở cổng "Custom thumbnail required but not applied".
706
+ // 'applied' — có ít nhất một mẫu thấy nút "Gỡ hình thu nhỏ tùy chỉnh"
707
+ // 'unknown' — không mẫu nào thấy, nhưng có mẫu đo hỏng
708
+ // 'absent' — mọi mẫu đo được và đều không thấy
709
+ function thumbVerdict(samples) {
710
+ const list = Array.isArray(samples) ? samples : [];
711
+ if (list.some((s) => s === true)) return 'applied';
712
+ if (list.some((s) => s === null || s === undefined)) return 'unknown';
713
+ return 'absent';
714
+ }
715
+
716
+ async function sampleCustomThumb(page, { samples = 3, gapMs = 1500 } = {}) {
717
+ const out = [];
718
+ for (let i = 0; i < samples; i++) {
719
+ if (i) await pause(page, gapMs);
720
+ out.push(await isCustomThumbApplied(page));
721
+ }
722
+ return { verdict: thumbVerdict(out), samples: out };
644
723
  }
645
724
 
646
725
  // Composer đang ở trạng thái gì? Đọc từ CHỮ trong hộp thoại — ba chữ ký nhìn
@@ -853,7 +932,7 @@ async function discoverReelsTabUrl(page, log = null) {
853
932
  // một link trông giống", nên lượt lấy link sau khi đăng ưu tiên nó.
854
933
  // Page thật sự chưa có reel nào cũng ra verified=false; khi đó ids rỗng là
855
934
  // đúng, không phải hỏng.
856
- async function openReelsTabWithTiles(page, urls, { log = null, settleMs = 4000, wait = null, tag = '[fb-pw]' } = {}) {
935
+ async function openReelsTabWithTiles(page, urls, { log = null, settleMs = 4000, wait = null, tag = '[fb-pw]', deep = false } = {}) {
857
936
  const list = [...new Set((urls || []).filter(Boolean))];
858
937
  let lastUrl = null;
859
938
  for (let i = 0; i < list.length; i++) {
@@ -861,13 +940,70 @@ async function openReelsTabWithTiles(page, urls, { log = null, settleMs = 4000,
861
940
  lastUrl = url;
862
941
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
863
942
  if (wait) await wait(page, settleMs); else await page.waitForTimeout(settleMs);
864
- const ids = await scrapeReelTileIds(page);
865
- if (ids.length) return { url, ids, verified: true, tried: i + 1 };
943
+ let ids = await scrapeReelTileIds(page);
944
+ if (ids.length) {
945
+ if (deep) ids = await scrollReelsTabForMoreTiles(page, ids, { log, tag });
946
+ return { url, ids, verified: true, tried: i + 1 };
947
+ }
866
948
  if (log && i + 1 < list.length) log('warn', `${tag} tab Reels ${url.slice(0, 80)} không có tile nào — thử ứng viên kế (${i + 2}/${list.length})`);
867
949
  }
868
950
  return { url: lastUrl, ids: [], verified: false, tried: list.length };
869
951
  }
870
952
 
953
+ // ẢNH NỀN SÂU — gốc ca 14/09 "Ngôi nhà tiện ích 01" (kenh-55, idea
954
+ // 6aa2f071b6ee902c8426c35b). `scrapeReelTileIds` chỉ đọc DOM đang có: ngay sau
955
+ // `goto` tab Reels, FB mới render ~10 tile đầu, nên ảnh nền = 10 reel — đo trên
956
+ // prod 12→14/09: 495/710 lượt chụp ảnh nền ra ĐÚNG 10. Sau khi đăng, trang đã ở
957
+ // tab đó lâu hơn nên quét được 20-33 tile ⇒ mọi bài CŨ nằm ở vị trí 11+ trông
958
+ // như "reel mới". Hai hậu quả đo được cùng ngày:
959
+ // 1. nhánh gói mạng nhận nhầm reel CŨ — cả 3/3 lượt "network + tab Reels xác
960
+ // nhận" đều SAI: kenh-46 (reel 07:55 sáng cùng kênh), kenh-55 (reel bài
961
+ // 10/09), kenh-53 (reel 1094951852951660 đã thấy từ nhiều ngày trước, API
962
+ // không bắt được vì reel đó chưa thuộc idea nào → bài đang mang link SAI
963
+ // mà không ai biết). Đối chiếu: nhánh so trước/sau thắng 623 lượt.
964
+ // 2. so trước/sau ra "trước 10, sau 20, mới 10 — nhiều hơn 1, không nhận"
965
+ // ⇒ bài LÊN THẬT vẫn thành mập mờ, bắt người đi kiểm tay.
966
+ // Cuộn thêm ở ảnh nền an toàn cả hai chiều: ảnh nền càng SÂU thì `diffNewReelIds`
967
+ // càng NHỎ, nên không bao giờ đẻ ra "reel mới" giả — chỉ bớt đi. Trần để khỏi
968
+ // cuộn vô tận trên page có hàng trăm reel.
969
+ const REELS_DEEP_MAX_ROUNDS = 8;
970
+ const REELS_DEEP_MAX_TILES = 60;
971
+ // Thuần, test được: còn nạp thêm được tile thì còn cuộn; tile không tăng nữa
972
+ // (hết reel, hoặc FB ngừng lazy-load) thì dừng.
973
+ function shouldKeepScrollingReels({ round, prevCount, count, maxRounds = REELS_DEEP_MAX_ROUNDS, maxTiles = REELS_DEEP_MAX_TILES }) {
974
+ if (round >= maxRounds) return false;
975
+ if (count >= maxTiles) return false;
976
+ if (round > 0 && count <= prevCount) return false;
977
+ return true;
978
+ }
979
+
980
+ // Cuộn tab Reels cho tới khi số tile ngừng tăng. Hỏng thì trả lại danh sách
981
+ // đang có — ảnh nền nông vẫn hơn không có ảnh nền.
982
+ async function scrollReelsTabForMoreTiles(page, ids, { log = null, tag = '[fb-pw]', maxRounds = REELS_DEEP_MAX_ROUNDS, maxTiles = REELS_DEEP_MAX_TILES } = {}) {
983
+ const shallow = ids.length;
984
+ let out = ids;
985
+ let round = 0;
986
+ let prevCount = -1;
987
+ try {
988
+ while (shouldKeepScrollingReels({ round, prevCount, count: out.length, maxRounds, maxTiles })) {
989
+ prevCount = out.length;
990
+ await humanWheel(page, hRandInt(600, 1200));
991
+ await page.waitForTimeout(hRandInt(700, 1400));
992
+ const next = await scrapeReelTileIds(page);
993
+ // CHỈ nhận khi dài hơn. `scrapeReelTileIds` nuốt lỗi và trả [] (evaluate
994
+ // hỏng, trang đang điều hướng) — gán thẳng thì một lượt quét trượt biến
995
+ // ảnh nền TỐT thành RỖNG, mà ảnh nền rỗng lại đúng là thứ sinh ra ca bốc
996
+ // nhầm. Danh sách chỉ dài thêm theo lazy-load nên lấy bản dài hơn là đúng.
997
+ if (next.length > out.length) out = next;
998
+ round += 1;
999
+ }
1000
+ } catch (e) {
1001
+ if (log) log('warn', `${tag} cuộn sâu tab Reels hỏng: ${String(e && e.message || e).slice(0, 60)} — giữ ${out.length} tile`);
1002
+ }
1003
+ if (log && out.length !== shallow) log('info', `${tag} ảnh nền tab Reels: cuộn ${round} lượt, ${shallow} → ${out.length} tile`);
1004
+ return out;
1005
+ }
1006
+
871
1007
  // Mọi reel ID đang hiện trên tab Reels, theo thứ tự DOM (mới nhất trước).
872
1008
  async function scrapeReelTileIds(page) {
873
1009
  return page.evaluate(() => {
@@ -917,9 +1053,18 @@ function pickPostPublishReelsTabUrl({ discovered, remembered, rememberedVerified
917
1053
  // 07:55 sáng CỦA CHÍNH KÊNH) → nhận → API thấy link trùng idea khác → gỡ → bài
918
1054
  // thành "mập mờ". Ảnh nền tab Reels chụp trước khi mở composer là bằng chứng
919
1055
  // "cũ": ID nằm trong ảnh nền thì KHÔNG BAO GIỜ là bài vừa đăng. Thuần, test được.
1056
+ // KHÔNG có ảnh nền (chụp hỏng, hoặc tab quét ra 0 tile — 19/710 lượt trên prod
1057
+ // 12→14/09) thì chẳng có bằng chứng "cũ" nào, nên "tab Reels xác nhận" chỉ còn
1058
+ // nghĩa "reel này thuộc page mình" — đúng cái đã bốc nhầm ở kenh-46 và kenh-53
1059
+ // (cả hai snapshotLen=0). Hạ xuống ứng viên: bài lên thật vẫn được lượt chờ 75s
1060
+ // và nhánh so trước/sau cứu; cứu không được thì để post_url rỗng cho API đánh
1061
+ // mập mờ. Thà rỗng còn hơn gắn link SAI (luật 19/08) — link sai TỆ HƠN mập mờ
1062
+ // vì nó im lặng: cổng chống link-trùng của API chỉ bắt được khi reel đó đã
1063
+ // thuộc idea khác, còn reel lạ thì lọt thẳng vào DB.
920
1064
  function networkIdVerdict({ id, beforeIds, tabIds }) {
921
1065
  if (!id) return 'none';
922
1066
  if ((beforeIds || []).includes(id)) return 'stale'; // đã có trước khi đăng
1067
+ if (!beforeIds || !beforeIds.length) return 'candidate'; // không có ảnh nền → không dám chốt
923
1068
  if ((tabIds || []).includes(id)) return 'confirmed'; // của page mình và mới
924
1069
  return 'candidate'; // chưa thấy trên tab, chờ
925
1070
  }
@@ -1047,7 +1192,7 @@ async function runOnce({ page, payload, log }) {
1047
1192
  } = payload || {};
1048
1193
  if (!video_url) throw new Error('No video_url provided');
1049
1194
 
1050
- log('info', '[fb-pw] selectors version=2026.09.14-reels-tab-candidates');
1195
+ log('info', '[fb-pw] selectors version=2026.09.14-deep-baseline');
1051
1196
 
1052
1197
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
1053
1198
 
@@ -1171,7 +1316,7 @@ async function runOnce({ page, payload, log }) {
1171
1316
  try {
1172
1317
  const candidates = await discoverReelsTabUrls(page, log);
1173
1318
  if (candidates.length) {
1174
- const opened = await openReelsTabWithTiles(page, candidates, { log, settleMs: 3500, wait: pause });
1319
+ const opened = await openReelsTabWithTiles(page, candidates, { log, settleMs: 3500, wait: pause, deep: true });
1175
1320
  reelsTabUrlBefore = opened.url;
1176
1321
  reelsTabVerifiedBefore = opened.verified;
1177
1322
  reelIdsBefore = opened.ids;
@@ -1438,34 +1583,7 @@ async function runOnce({ page, payload, log }) {
1438
1583
  // small clips, too short for big ones), POLL for an ENABLED "Tiếp" —
1439
1584
  // proceed the instant it's ready, but give large clips up to 120s.
1440
1585
  log('info', '[fb-pw] waiting for FB to finish processing the video…');
1441
- const procT0 = Date.now();
1442
- const procDeadline = procT0 + 120_000;
1443
- await pause(page, 2500); // let the upload register first
1444
- let procReady = false;
1445
- while (Date.now() < procDeadline) {
1446
- procReady = await page.evaluate(() => {
1447
- const dlgs = document.querySelectorAll("[role='dialog']");
1448
- const roots = dlgs.length ? Array.from(dlgs) : [document];
1449
- const verbs = ['Tiếp', 'Tiếp theo', 'Next', 'Continue'];
1450
- for (const root of roots) {
1451
- for (const el of root.querySelectorAll("button, [role='button']")) {
1452
- const t = (el.innerText || '').trim();
1453
- if (!verbs.includes(t)) continue;
1454
- const r = el.getBoundingClientRect();
1455
- if (r.width < 8 || r.height < 8) continue;
1456
- const cs = getComputedStyle(el);
1457
- if (cs.visibility === 'hidden' || cs.display === 'none') continue;
1458
- if (el.getAttribute('aria-disabled') === 'true' || el.disabled) continue;
1459
- return true; // an enabled Tiếp/Next → processing done, can advance
1460
- }
1461
- }
1462
- return false;
1463
- }).catch(() => false);
1464
- if (procReady) break;
1465
- await pause(page, 2500);
1466
- }
1467
- if (procReady) log('info', `[fb-pw] video processed in ~${Math.round((Date.now() - procT0) / 1000)}s — caption step ready`);
1468
- else log('warn', '[fb-pw] processing wait hit 120s cap — proceeding (Tiếp click auto-waits anyway)');
1586
+ await waitForNextEnabled(page, log, { timeoutMs: 120_000, what: 'video' });
1469
1587
 
1470
1588
  // 5) Fill metadata. FB Reels has THREE separate fields (verified visually
1471
1589
  // by user — different from how YT lumps everything into title + desc):
@@ -2136,6 +2254,7 @@ async function runOnce({ page, payload, log }) {
2136
2254
  // doesn't close within 12s of polling, RE-CLICK Lưu (in case
2137
2255
  // the first click didn't register because the button was
2138
2256
  // disabled while FB was still hashing the upload).
2257
+ let thumbTimeoutSt = { verdict: 'unknown', samples: [] };
2139
2258
  const closeDeadline = Date.now() + 60_000;
2140
2259
  let modalClosed = false;
2141
2260
  let closedPolls = 0;
@@ -2230,7 +2349,11 @@ async function runOnce({ page, payload, log }) {
2230
2349
  }
2231
2350
  thumbApplied = true;
2232
2351
  log('info', `[fb-pw] page-wall thumb — modal closed after save (retries=${retryCount})`);
2233
- } else if (await isCustomThumbApplied(page)) {
2352
+ } else if (await (async () => {
2353
+ // Giữ kết quả đo để ghi vào log: bằng chứng vì sao KHÔNG Hủy.
2354
+ thumbTimeoutSt = await sampleCustomThumb(page);
2355
+ return thumbTimeoutSt.verdict !== 'absent';
2356
+ })()) {
2234
2357
  // Hết giờ chờ đóng nhưng bằng chứng áp thumb còn đó — tuyệt
2235
2358
  // đối không ESC/Hủy (Hủy trên panel này là GỠ thumb).
2236
2359
  //
@@ -2241,10 +2364,10 @@ async function runOnce({ page, payload, log }) {
2241
2364
  // "Custom thumbnail required but not applied" (đo 04/09/2026,
2242
2365
  // 3 lượt liên tiếp, log ghi rõ variant=modal). Nút "Gỡ" là
2243
2366
  // bằng chứng vật lý thumb đang áp, biến thể nào cũng đúng.
2244
- log('info', `[fb-pw] page-wall thumb — hết giờ chờ đóng nhưng nút "Gỡ hình thu nhỏ" đang hiện (variant=${thumbEditorVia}) → giữ panel, coi như dán xong`);
2367
+ log('info', `[fb-pw] page-wall thumb — hết giờ chờ đóng, đo 3 nhịp ${JSON.stringify(thumbTimeoutSt.samples)} → ${thumbTimeoutSt.verdict} (variant=${thumbEditorVia}) → giữ panel, coi như dán xong, KHÔNG Hủy`);
2245
2368
  thumbApplied = true;
2246
2369
  } else {
2247
- log('warn', '[fb-pw] page-wall thumb — modal still open 60s after Lưu click; trying ESC + click Hủy fallback');
2370
+ log('warn', `[fb-pw] page-wall thumb — modal still open 60s after Lưu click, đo 3 nhịp ${JSON.stringify(thumbTimeoutSt.samples)} → CHẮC CHẮN chưa áp; ESC + click Hủy fallback`);
2248
2371
  // Chụp lại trước khi ESC/Hủy: đây là nhánh phá lượt nhiều
2249
2372
  // nhất mà lại không có ảnh nào để soi (04/09/2026).
2250
2373
  await dumpFailure(page, 'thumb-modal-stuck-before-cancel', log);
@@ -2423,10 +2546,12 @@ async function runOnce({ page, payload, log }) {
2423
2546
  await chooser.setFiles(thumbPath);
2424
2547
  uploaded = true;
2425
2548
  log('info', `[fb-pw] custom thumbnail set via <a> "${sel}"`);
2426
- // FB needs ~10s after thumbnail upload to process the image
2427
- // and re-enable the Tiếp button. Without this wait, Tiếp
2428
- // stays aria-disabled and our findByVerbs skips it.
2429
- await page.waitForTimeout(10000);
2549
+ // FB tắt "Tiếp" trong lúc xử lý ảnh bìa vừa nhận rồi bật lại;
2550
+ // findByVerbs bỏ qua nút đang tắt nên bấm sớm là trượt bước.
2551
+ // Trước đây ngủ cứng 10s: ảnh nhỏ phí gần hết, máy chậm thì
2552
+ // vẫn hụt. Poll thoát ngay khi nút bật, trần rộng hơn để cứu
2553
+ // ca chậm.
2554
+ await waitForNextEnabled(page, log, { timeoutMs: 30_000, what: 'ảnh bìa' });
2430
2555
  break;
2431
2556
  } catch (e1) {
2432
2557
  log('info', `[fb-pw] <a> click via "${sel}" failed (normal): ${e1.message.slice(0, 80)}`);
@@ -2625,8 +2750,9 @@ async function runOnce({ page, payload, log }) {
2625
2750
  // customThumbDone lỡ false trong khi nút "Gỡ hình thu nhỏ tùy chỉnh"
2626
2751
  // ngay trên bước này chứng minh thumb ĐANG được áp — cả 5 lượt fail
2627
2752
  // 02-03/09 của idea 6a965a32 đều ở trạng thái này (diag 158386).
2628
- if (await isCustomThumbApplied(page)) {
2629
- log('info', '[fb-pw] pre-publish: nút "Gỡ hình thu nhỏ tùy chỉnh" đang hiện — thumb đã áp dù cờ chưa bật, tiếp tục đăng');
2753
+ const gateSt = await sampleCustomThumb(page);
2754
+ if (gateSt.verdict === 'applied') {
2755
+ log('info', `[fb-pw] pre-publish: nút "Gỡ hình thu nhỏ tùy chỉnh" đang hiện (${JSON.stringify(gateSt.samples)}) — thumb đã áp dù cờ chưa bật, tiếp tục đăng`);
2630
2756
  customThumbDone = true;
2631
2757
  } else {
2632
2758
  // Defensive: we normally fail at the caption step (before any Tiếp)
@@ -3345,11 +3471,27 @@ async function runOnce({ page, payload, log }) {
3345
3471
  // re-scrape cứu được phần lớn ca đó, vẫn giữ luật fresh-timestamp
3346
3472
  // nên không thể nhận nhầm tile cũ.
3347
3473
  if (!postUrl && reelsTabUrl) {
3348
- log('info', '[fb-pw] no post URL yet — waiting 75s for FB to surface the new reel on reels_tab, then re-scraping…');
3349
- await page.waitForTimeout(75_000);
3350
- await page.goto(reelsTabUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
3351
- await pause(page, 5000);
3352
- const fresh2 = await scrapeFreshReelTile();
3474
+ // Một lượt chờ 75s rồi scrape ĐÚNG MỘT LẦN sai cả hai chiều: reel lên sau
3475
+ // 20s thì đứng phí gần một phút, còn ca chạm mốc "1-3 phút" ở comment
3476
+ // trên thì 75s là HỤT — scrape trượt, rơi xuống các nhánh đoán phía dưới.
3477
+ // Chia thành nhiều lượt re-scrape: thấy tile là đi ngay, chưa thấy thì
3478
+ // chờ tiếp tới hết 3 phút. Luật fresh-timestamp vẫn nguyên nên không thể
3479
+ // nhận nhầm tile cũ dù scrape thêm bao nhiêu lượt.
3480
+ const SURFACE_TIMEOUT_MS = 180_000;
3481
+ const SURFACE_STEP_MS = 25_000;
3482
+ log('info', `[fb-pw] no post URL yet — re-scraping reels_tab mỗi ~${Math.round(SURFACE_STEP_MS / 1000)}s tới ${Math.round(SURFACE_TIMEOUT_MS / 1000)}s cho tới khi reel mới hiện…`);
3483
+ const surfaceT0 = Date.now();
3484
+ let fresh2 = null;
3485
+ let round = 0;
3486
+ while (Date.now() - surfaceT0 < SURFACE_TIMEOUT_MS) {
3487
+ await pause(page, SURFACE_STEP_MS);
3488
+ round += 1;
3489
+ await page.goto(reelsTabUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
3490
+ await pause(page, 5000);
3491
+ fresh2 = await scrapeFreshReelTile();
3492
+ if (fresh2) break;
3493
+ log('info', `[fb-pw] lượt ${round} (~${Math.round((Date.now() - surfaceT0) / 1000)}s): tab Reels chưa có reel mới`);
3494
+ }
3353
3495
  if (fresh2) {
3354
3496
  postUrl = fresh2.href.startsWith('http') ? fresh2.href : `https://www.facebook.com${fresh2.href}`;
3355
3497
  log('info', `[fb-pw] post URL from reels_tab RETRY (id=${fresh2.id}, timestamp="${fresh2.timestamp}"): ${postUrl}`);
@@ -3361,7 +3503,7 @@ async function runOnce({ page, payload, log }) {
3361
3503
  const diff2 = diffNewReelIds(reelIdsBefore, after2);
3362
3504
  if (diff2.length === 1) {
3363
3505
  postUrl = `https://www.facebook.com/reel/${diff2[0]}/`;
3364
- log('info', `[fb-pw] post URL từ so trước/sau tab Reels (lượt chờ 75s; trước ${reelIdsBefore.length}, sau ${after2.length}): ${postUrl}`);
3506
+ log('info', `[fb-pw] post URL từ so trước/sau tab Reels (sau ${round} lượt re-scrape; trước ${reelIdsBefore.length}, sau ${after2.length}): ${postUrl}`);
3365
3507
  } else {
3366
3508
  log('warn', `[fb-pw] reels_tab retry: vẫn chưa thấy reel mới (so trước/sau: mới ${diff2.length})`);
3367
3509
  }
@@ -3501,6 +3643,12 @@ module.exports = { run };
3501
3643
  // Exposed so the overlay-click escalation can be exercised against a synthetic
3502
3644
  // page (toast covering the CTA) without driving the whole upload flow.
3503
3645
  module.exports.__testables = {
3646
+ // Chờ FB xử lý xong bằng cách POLL nút "Tiếp", thay cho ngủ cứng đoán mò.
3647
+ // Dùng ở hai chỗ (video vừa upload, ảnh bìa vừa thay) nên hỏng là hỏng đôi.
3648
+ waitForNextEnabled,
3649
+ // Phán quyết "ảnh bìa đã áp chưa" từ nhiều mẫu đo — một mẫu đo hỏng không
3650
+ // được biến thành "chưa áp" rồi kéo theo hành động PHÁ (Hủy = gỡ ảnh bìa).
3651
+ thumbVerdict,
3504
3652
  // Dùng chung cho comment_facebook: script nào trượt giữa chừng cũng cần chụp
3505
3653
  // lại màn hình, viết bản thứ hai là sớm muộn hai bên lệch nhau.
3506
3654
  dumpFailure,
@@ -3513,6 +3661,8 @@ module.exports.__testables = {
3513
3661
  discoverReelsTabUrls,
3514
3662
  openReelsTabWithTiles,
3515
3663
  networkIdVerdict,
3664
+ shouldKeepScrollingReels,
3665
+ scrollReelsTabForMoreTiles,
3516
3666
  classifyComposerText,
3517
3667
  readPageAlerts,
3518
3668
  composerClosedError,