channel-worker 2.5.123 → 2.5.126

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.123",
3
+ "version": "2.5.126",
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": {
@@ -68,8 +68,15 @@ const BOX_SELECTORS = [
68
68
  const OPEN_COMMENTS_SELECTORS = [
69
69
  "[aria-label='Bình luận'], [aria-label='Comment']",
70
70
  "[aria-label*='Để lại bình luận'], [aria-label*='Leave a comment']",
71
+ // Reel có thanh hành động riêng, nhãn khác hai dạng trên.
72
+ "[aria-label*='Xem bình luận'], [aria-label*='View comments']",
71
73
  ];
72
74
 
75
+ // Khung bình luận của reel mở bằng animation. Máy đang chạy nhiều lượt đăng
76
+ // song song thì vẽ chậm hẳn — chờ rộng tay, vì hỏng cả lượt tốn hơn nhiều so
77
+ // với đứng thêm mấy giây.
78
+ const OPEN_WAIT_MS = 12_000;
79
+
73
80
  function sel(list) { return list.join(', '); }
74
81
 
75
82
  // ─── tìm ô soạn ─────────────────────────────────────────────────────────────
@@ -93,16 +100,48 @@ async function findCommentBox(page) {
93
100
  return null;
94
101
  }
95
102
 
103
+ // Bấm xong PHẢI chờ tới khi ô soạn thật sự dựng, đừng ngủ cứng rồi đi tiếp.
104
+ //
105
+ // Bản đầu bấm nút rồi `pause(1800)` và trả về ngay. Khung bình luận của reel
106
+ // mở bằng animation, nên 1,8s chỉ đủ khi máy rảnh; 21/09 hai lượt trượt sạch
107
+ // đúng lúc máy đang chạy 5 lượt đăng song song, log dừng ở "bấm mở khung
108
+ // bình luận" rồi báo "không tìm thấy ô soạn (đã thử 7 nhãn)" — đọc lên tưởng
109
+ // bài tắt bình luận, thật ra khung chưa kịp vẽ. Các lượt thành công cùng ngày
110
+ // đều là bài có sẵn ô soạn, không qua nhánh này lần nào.
111
+ //
112
+ // Chờ tối đa OPEN_WAIT_MS và thử NÚT KHÁC nếu nút đầu không dựng được khung —
113
+ // trên reel có nhiều thứ mang nhãn "Bình luận" (nút ở thanh hành động, nút
114
+ // dưới bài), bấm trúng cái không mở panel là hỏng cả lượt.
96
115
  async function openCommentsIfNeeded(page, log) {
116
+ let clicked = false;
97
117
  for (const s of OPEN_COMMENTS_SELECTORS) {
98
118
  const loc = page.locator(s).first();
99
119
  if (!(await loc.isVisible().catch(() => false))) continue;
100
120
  log('info', `[fb-cmt] chưa thấy ô soạn — bấm mở khung bình luận ("${s}")`);
101
121
  await humanClick(page, loc).catch(() => {});
102
- await pause(page, 1800);
103
- return true;
122
+ clicked = true;
123
+ const box = await waitForCommentBox(page, OPEN_WAIT_MS);
124
+ if (box) {
125
+ log('info', '[fb-cmt] khung bình luận đã dựng');
126
+ return true;
127
+ }
128
+ log('warn', `[fb-cmt] bấm "${s}" mà khung chưa dựng sau ${Math.round(OPEN_WAIT_MS / 1000)}s — thử nút khác`);
129
+ }
130
+ return clicked;
131
+ }
132
+
133
+ // Dò lại ô soạn theo nhịp cho tới khi thấy hoặc hết giờ. Dùng vòng lặp chứ
134
+ // không `waitForSelector` vì ô soạn phải qua được bộ lọc của findCommentBox
135
+ // (ô soạn BÀI trên tường Page cũng khớp selector — gõ nhầm vào đó là đăng một
136
+ // bài mới toàn link).
137
+ async function waitForCommentBox(page, timeoutMs) {
138
+ const deadline = Date.now() + timeoutMs;
139
+ for (;;) {
140
+ const box = await findCommentBox(page);
141
+ if (box) return box;
142
+ if (Date.now() >= deadline) return null;
143
+ await pause(page, 700);
104
144
  }
105
- return false;
106
145
  }
107
146
 
108
147
  // ─── đọc lại để xác minh ────────────────────────────────────────────────────
@@ -261,13 +300,23 @@ async function run({ page, payload = {}, log }) {
261
300
  if (gone) throw new Error(`Bài không mở được (đã xoá, đổi quyền riêng tư, hoặc link sai): ${postUrl}`);
262
301
 
263
302
  let box = await findCommentBox(page);
303
+ // Chưa thấy ngay thì chờ một nhịp trước khi đi bấm nút: trang vừa mở, khối
304
+ // bình luận có khi còn đang vẽ.
305
+ if (!box) box = await waitForCommentBox(page, 4000);
306
+ let opened = false;
264
307
  if (!box) {
265
- await openCommentsIfNeeded(page, log);
308
+ opened = await openCommentsIfNeeded(page, log);
266
309
  box = await findCommentBox(page);
267
310
  }
268
311
  if (!box) {
269
312
  await assertAccountUsable(page, log);
270
- throw new Error(`Không tìm thấy ô soạn bình luận trên ${postUrl} (đã thử ${BOX_SELECTORS.length} nhãn) — nhiều khả năng bài tắt bình luận hoặc Facebook đổi giao diện`);
313
+ // Nói đúng bệnh. Đã bấm được nút mở khung mà vẫn không có ô soạn thì bài
314
+ // KHÔNG tắt bình luận — nút đó chỉ hiện khi còn bình luận được; bệnh là
315
+ // khung không dựng kịp (máy bận) hoặc Facebook đổi cách dựng panel. Gộp
316
+ // hai chuyện vào một câu là lần sau đọc log đi sai hướng ngay từ đầu.
317
+ throw new Error(opened
318
+ ? `Bấm mở khung bình luận rồi mà không có ô soạn sau ${Math.round(OPEN_WAIT_MS / 1000)}s trên ${postUrl} — máy đang bận vẽ chậm, hoặc Facebook đổi cách dựng khung bình luận của reel`
319
+ : `Không tìm thấy ô soạn bình luận trên ${postUrl} (đã thử ${BOX_SELECTORS.length} nhãn, cũng không thấy nút mở khung) — nhiều khả năng bài tắt bình luận hoặc Facebook đổi giao diện`);
271
320
  }
272
321
 
273
322
  // Đo NỀN trước khi gõ: link có thể đã nằm sẵn trong caption, hoặc bài đã
@@ -329,4 +378,7 @@ async function run({ page, payload = {}, log }) {
329
378
  }
330
379
 
331
380
  module.exports = { run };
332
- module.exports.__testables = { findPostedComment, countLinkOnPage, waitForPostedComment, findCommentBox, BOX_SELECTORS, sel };
381
+ module.exports.__testables = {
382
+ findPostedComment, countLinkOnPage, waitForPostedComment, findCommentBox, BOX_SELECTORS, sel,
383
+ openCommentsIfNeeded, waitForCommentBox, OPEN_COMMENTS_SELECTORS, OPEN_WAIT_MS,
384
+ };
@@ -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) => {
@@ -880,7 +932,7 @@ async function discoverReelsTabUrl(page, log = null) {
880
932
  // một link trông giống", nên lượt lấy link sau khi đăng ưu tiên nó.
881
933
  // Page thật sự chưa có reel nào cũng ra verified=false; khi đó ids rỗng là
882
934
  // đúng, không phải hỏng.
883
- 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 } = {}) {
884
936
  const list = [...new Set((urls || []).filter(Boolean))];
885
937
  let lastUrl = null;
886
938
  for (let i = 0; i < list.length; i++) {
@@ -888,13 +940,70 @@ async function openReelsTabWithTiles(page, urls, { log = null, settleMs = 4000,
888
940
  lastUrl = url;
889
941
  await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45_000 });
890
942
  if (wait) await wait(page, settleMs); else await page.waitForTimeout(settleMs);
891
- const ids = await scrapeReelTileIds(page);
892
- 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
+ }
893
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})`);
894
949
  }
895
950
  return { url: lastUrl, ids: [], verified: false, tried: list.length };
896
951
  }
897
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
+
898
1007
  // Mọi reel ID đang hiện trên tab Reels, theo thứ tự DOM (mới nhất trước).
899
1008
  async function scrapeReelTileIds(page) {
900
1009
  return page.evaluate(() => {
@@ -944,9 +1053,18 @@ function pickPostPublishReelsTabUrl({ discovered, remembered, rememberedVerified
944
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
945
1054
  // thành "mập mờ". Ảnh nền tab Reels chụp trước khi mở composer là bằng chứng
946
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.
947
1064
  function networkIdVerdict({ id, beforeIds, tabIds }) {
948
1065
  if (!id) return 'none';
949
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
950
1068
  if ((tabIds || []).includes(id)) return 'confirmed'; // của page mình và mới
951
1069
  return 'candidate'; // chưa thấy trên tab, chờ
952
1070
  }
@@ -1074,7 +1192,7 @@ async function runOnce({ page, payload, log }) {
1074
1192
  } = payload || {};
1075
1193
  if (!video_url) throw new Error('No video_url provided');
1076
1194
 
1077
- log('info', '[fb-pw] selectors version=2026.09.14-reels-tab-candidates');
1195
+ log('info', '[fb-pw] selectors version=2026.09.14-deep-baseline');
1078
1196
 
1079
1197
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
1080
1198
 
@@ -1198,7 +1316,7 @@ async function runOnce({ page, payload, log }) {
1198
1316
  try {
1199
1317
  const candidates = await discoverReelsTabUrls(page, log);
1200
1318
  if (candidates.length) {
1201
- 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 });
1202
1320
  reelsTabUrlBefore = opened.url;
1203
1321
  reelsTabVerifiedBefore = opened.verified;
1204
1322
  reelIdsBefore = opened.ids;
@@ -1465,34 +1583,7 @@ async function runOnce({ page, payload, log }) {
1465
1583
  // small clips, too short for big ones), POLL for an ENABLED "Tiếp" —
1466
1584
  // proceed the instant it's ready, but give large clips up to 120s.
1467
1585
  log('info', '[fb-pw] waiting for FB to finish processing the video…');
1468
- const procT0 = Date.now();
1469
- const procDeadline = procT0 + 120_000;
1470
- await pause(page, 2500); // let the upload register first
1471
- let procReady = false;
1472
- while (Date.now() < procDeadline) {
1473
- procReady = await page.evaluate(() => {
1474
- const dlgs = document.querySelectorAll("[role='dialog']");
1475
- const roots = dlgs.length ? Array.from(dlgs) : [document];
1476
- const verbs = ['Tiếp', 'Tiếp theo', 'Next', 'Continue'];
1477
- for (const root of roots) {
1478
- for (const el of root.querySelectorAll("button, [role='button']")) {
1479
- const t = (el.innerText || '').trim();
1480
- if (!verbs.includes(t)) continue;
1481
- const r = el.getBoundingClientRect();
1482
- if (r.width < 8 || r.height < 8) continue;
1483
- const cs = getComputedStyle(el);
1484
- if (cs.visibility === 'hidden' || cs.display === 'none') continue;
1485
- if (el.getAttribute('aria-disabled') === 'true' || el.disabled) continue;
1486
- return true; // an enabled Tiếp/Next → processing done, can advance
1487
- }
1488
- }
1489
- return false;
1490
- }).catch(() => false);
1491
- if (procReady) break;
1492
- await pause(page, 2500);
1493
- }
1494
- if (procReady) log('info', `[fb-pw] video processed in ~${Math.round((Date.now() - procT0) / 1000)}s — caption step ready`);
1495
- 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' });
1496
1587
 
1497
1588
  // 5) Fill metadata. FB Reels has THREE separate fields (verified visually
1498
1589
  // by user — different from how YT lumps everything into title + desc):
@@ -2455,10 +2546,12 @@ async function runOnce({ page, payload, log }) {
2455
2546
  await chooser.setFiles(thumbPath);
2456
2547
  uploaded = true;
2457
2548
  log('info', `[fb-pw] custom thumbnail set via <a> "${sel}"`);
2458
- // FB needs ~10s after thumbnail upload to process the image
2459
- // and re-enable the Tiếp button. Without this wait, Tiếp
2460
- // stays aria-disabled and our findByVerbs skips it.
2461
- 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' });
2462
2555
  break;
2463
2556
  } catch (e1) {
2464
2557
  log('info', `[fb-pw] <a> click via "${sel}" failed (normal): ${e1.message.slice(0, 80)}`);
@@ -3378,11 +3471,27 @@ async function runOnce({ page, payload, log }) {
3378
3471
  // re-scrape cứu được phần lớn ca đó, vẫn giữ luật fresh-timestamp
3379
3472
  // nên không thể nhận nhầm tile cũ.
3380
3473
  if (!postUrl && reelsTabUrl) {
3381
- log('info', '[fb-pw] no post URL yet — waiting 75s for FB to surface the new reel on reels_tab, then re-scraping…');
3382
- await page.waitForTimeout(75_000);
3383
- await page.goto(reelsTabUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
3384
- await pause(page, 5000);
3385
- 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
+ }
3386
3495
  if (fresh2) {
3387
3496
  postUrl = fresh2.href.startsWith('http') ? fresh2.href : `https://www.facebook.com${fresh2.href}`;
3388
3497
  log('info', `[fb-pw] post URL from reels_tab RETRY (id=${fresh2.id}, timestamp="${fresh2.timestamp}"): ${postUrl}`);
@@ -3394,7 +3503,7 @@ async function runOnce({ page, payload, log }) {
3394
3503
  const diff2 = diffNewReelIds(reelIdsBefore, after2);
3395
3504
  if (diff2.length === 1) {
3396
3505
  postUrl = `https://www.facebook.com/reel/${diff2[0]}/`;
3397
- 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}`);
3398
3507
  } else {
3399
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})`);
3400
3509
  }
@@ -3534,6 +3643,9 @@ module.exports = { run };
3534
3643
  // Exposed so the overlay-click escalation can be exercised against a synthetic
3535
3644
  // page (toast covering the CTA) without driving the whole upload flow.
3536
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,
3537
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
3538
3650
  // được biến thành "chưa áp" rồi kéo theo hành động PHÁ (Hủy = gỡ ảnh bìa).
3539
3651
  thumbVerdict,
@@ -3549,6 +3661,8 @@ module.exports.__testables = {
3549
3661
  discoverReelsTabUrls,
3550
3662
  openReelsTabWithTiles,
3551
3663
  networkIdVerdict,
3664
+ shouldKeepScrollingReels,
3665
+ scrollReelsTabForMoreTiles,
3552
3666
  classifyComposerText,
3553
3667
  readPageAlerts,
3554
3668
  composerClosedError,