channel-worker 2.5.134 → 2.5.136

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.134",
3
+ "version": "2.5.136",
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": {
@@ -744,6 +744,14 @@ function isNewComposerUrl(url) {
744
744
  // Chữ trên ô "tải ảnh bìa của mình lên" — khớp NGUYÊN chuỗi (sau khi gộp
745
745
  // khoảng trắng) để không dính caption hay nhãn mục "Hình thu nhỏ".
746
746
  const THUMB_PICKER_TEXT_RE = /^(?:Chọn hình thu nhỏ|Chọn ảnh thu nhỏ|Thêm hình thu nhỏ|Tải hình thu nhỏ lên|(?:Choose|Select|Add|Upload) (?:a )?(?:custom )?thumbnail)$/i;
747
+ // Biến thể thứ ba (đo thật 23/09, page Gà Vườn Mỗi Ngày, wizard
748
+ // /reels/create/?surface=ADDL_PROFILE_PLUS): KHÔNG có hàng ô, ảnh bìa là một
749
+ // nút nhỏ 72x20 chữ "Ảnh bìa", aria-label "Chọn ảnh bìa", có ở cả bước
750
+ // "Chỉnh sửa" lẫn "Cài đặt". Tách regex riêng: nút này không nằm trong hàng ô
751
+ // nào, đi luồng riêng (applyCoverButtonThumb) và xác minh bằng nội dung ảnh.
752
+ const COVER_BUTTON_RE = /^(?:Chọn ảnh bìa|(?:Choose|Select|Add|Edit) (?:a )?cover(?: photo| image)?)$/i;
753
+ // Tiêu đề hộp thoại có thể mở ra từ nút đó — chưa thấy tận mắt, nhận rộng.
754
+ const COVER_HEAD_RE = /^(?:Chọn ảnh bìa|Ảnh bìa|Chỉnh sửa ảnh bìa|(?:Choose |Select |Edit )?(?:a )?cover(?: photo| image)?)$/i;
747
755
 
748
756
  // Đọc hàng ô ảnh bìa của composer mới. Trả về ảnh chụp trạng thái để so
749
757
  // trước/sau khi dán (xem newComposerThumbVerdict), và gắn cờ DOM cho:
@@ -753,7 +761,7 @@ const THUMB_PICKER_TEXT_RE = /^(?:Chọn hình thu nhỏ|Chọn ảnh thu nhỏ|
753
761
  // __fbpw_thumb_tile__ — ô thứ `tagIndex` (dùng khi phải bấm chọn lại)
754
762
  // Không bao giờ ném: đọc hỏng trả null, như isCustomThumbApplied.
755
763
  async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {}) {
756
- return page.evaluate(({ pickSrc, diag, tagIndex }) => {
764
+ return page.evaluate(({ pickSrc, coverSrc, diag, tagIndex }) => {
757
765
  const PICK = new RegExp(pickSrc, 'i');
758
766
  const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
759
767
  const vis = (el) => {
@@ -782,7 +790,10 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
782
790
  }
783
791
  return '';
784
792
  };
785
- const tileLike = (el) => vis(el) && (!!mediaOf(el) || isPick(el));
793
+ // Ô thật rộng ~90px. Chặn trên 320px để thân hộp thoại "Chọn hình thu nhỏ"
794
+ // (rộng 500, có ảnh xem trước) không bị nhận là một ô — lượt test thật
795
+ // 23/09 đọc nhầm hộp thoại thành "2 ô [chọn-ảnh, ảnh-local]".
796
+ const tileLike = (el) => vis(el) && el.getBoundingClientRect().width <= 320 && (!!mediaOf(el) || isPick(el));
786
797
  // Ô đang chọn: viền/bóng xanh của FB (#0866FF, #1877F2) hoặc aria-*.
787
798
  const blueish = (c) => {
788
799
  const m = String(c || '').match(/rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/);
@@ -835,8 +846,16 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
835
846
  };
836
847
  let pickLeaf = null;
837
848
  for (const root of roots) {
849
+ // Bỏ qua tiêu đề của hộp thoại "Chọn hình thu nhỏ": cùng chữ với ô, và
850
+ // FB gắn role=dialog + aria-label đó lên phần đầu hộp thoại (đo 23/09).
851
+ const inPickerModal = (el) => {
852
+ if (el.closest('[__fbpw_thumb_modal__]')) return true;
853
+ const d = el.closest("[role='dialog']");
854
+ return !!d && d !== root && PICK.test(norm(d.getAttribute('aria-label')));
855
+ };
838
856
  const hits = [...root.querySelectorAll('[aria-label]')].filter((el) => PICK.test(norm(el.getAttribute('aria-label'))) && vis(el))
839
- .concat(textHits(root, /hình|ảnh|thumbnail/i, PICK));
857
+ .concat(textHits(root, /hình|ảnh|thumbnail/i, PICK))
858
+ .filter((el) => !inPickerModal(el));
840
859
  if (hits.length) { pickLeaf = hits[0]; break; }
841
860
  }
842
861
  let row = null;
@@ -869,6 +888,23 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
869
888
  }
870
889
  const customMarker = [...document.querySelectorAll('[aria-label]')].some((el) =>
871
890
  /Gỡ hình thu nhỏ tùy chỉnh|Remove( your)? custom thumbnail/i.test(el.getAttribute('aria-label') || '') && vis(el));
891
+ if (!row && !pickLeaf) {
892
+ // Không có ô "Chọn hình thu nhỏ" → thử nút "Chọn ảnh bìa" (biến thể wizard).
893
+ const COVER = new RegExp(coverSrc, 'i');
894
+ for (const root of roots) {
895
+ const btn = [...root.querySelectorAll('[aria-label]')].find((el) => vis(el)
896
+ && COVER.test(norm(el.getAttribute('aria-label'))) && !el.closest('[__fbpw_thumb_modal__]'));
897
+ if (!btn) continue;
898
+ let target = btn;
899
+ for (let n = btn, d = 0; n && d < 3; n = n.parentElement, d++) {
900
+ if (n.matches("[role='button'], button, a, [tabindex]")) { target = n; break; }
901
+ }
902
+ target.setAttribute('__fbpw_thumb_pick__', '1');
903
+ const r = btn.getBoundingClientRect();
904
+ return { found: false, pickerPresent: true, mode: 'cover-button', roots: roots.length, customMarker,
905
+ cover: { aria: norm(btn.getAttribute('aria-label')), text: norm(btn.innerText).slice(0, 30), size: `${Math.round(r.width)}x${Math.round(r.height)}` } };
906
+ }
907
+ }
872
908
  if (!row) {
873
909
  const miss = { found: false, pickerPresent: !!pickLeaf, roots: roots.length, customMarker };
874
910
  if (diag) {
@@ -911,7 +947,9 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
911
947
  for (let scope = row, d = 0; scope && d < 3 && !input; scope = scope.parentElement, d++) {
912
948
  for (const el of scope.querySelectorAll("input[type='file']")) {
913
949
  const accept = (el.getAttribute('accept') || '').toLowerCase();
914
- if (!/image/.test(accept) || /video/.test(accept)) continue; // input của "Thêm file khác" nhận cả video
950
+ // Nhận cả dạng đuôi file (".png,.jpg,.jpeg" — đo thật 23/09); input của
951
+ // "Thêm file khác" nhận cả video nên bị loại.
952
+ if (!/image|\.png|\.jpe?g/.test(accept) || /video|\.mp4|\.mov/.test(accept)) continue;
915
953
  el.setAttribute('__fbpw_thumb_input__', '1');
916
954
  input = { accept: accept.slice(0, 60) };
917
955
  break;
@@ -941,7 +979,7 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
941
979
  });
942
980
  }
943
981
  return res;
944
- }, { pickSrc: THUMB_PICKER_TEXT_RE.source, diag, tagIndex }).catch(() => null);
982
+ }, { pickSrc: THUMB_PICKER_TEXT_RE.source, coverSrc: COVER_BUTTON_RE.source, diag, tagIndex }).catch(() => null);
945
983
  }
946
984
 
947
985
  // Ảnh bìa tự chọn đã VÀO composer mới chưa? So hàng ô trước và sau khi dán
@@ -984,6 +1022,7 @@ function newComposerThumbVerdict(before, after) {
984
1022
  // Tóm tắt một hàng ô cho log: "5 ô [chọn-ảnh, *khung, khung…] chọn=1 input=image/*".
985
1023
  function describeThumbRow(snap) {
986
1024
  if (!snap) return 'không đọc được';
1025
+ if (snap.mode === 'cover-button') return `không có hàng ô — nút ảnh bìa "${snap.cover.text || snap.cover.aria}" (aria "${snap.cover.aria}", ${snap.cover.size})`;
987
1026
  if (!snap.found) return `không thấy hàng ô (composer=${snap.roots || 0}, ô chọn-ảnh=${snap.pickerPresent ? 'có' : 'không'})`;
988
1027
  const cells = (snap.tiles || []).map((t) => `${t.selected ? '*' : ''}${t.placeholder ? 'chọn-ảnh' : (t.img ? (/^(blob|data):/.test(t.img) ? 'ảnh-local' : 'khung') : '?')}`);
989
1028
  return `${cells.length} ô [${cells.join(', ')}] chọn=${snap.selectedIndex ?? '?'} input=${snap.input ? snap.input.accept || '?' : 'không'} via=${snap.via}`;
@@ -993,13 +1032,17 @@ function describeThumbRow(snap) {
993
1032
  // tận mắt — chỉ bấm nút xác nhận của một dialog KHÁC composer mà trong chữ
994
1033
  // có nhắc ảnh bìa, để không bao giờ trúng nút "Lưu" (= lưu nháp) của composer.
995
1034
  async function confirmNewComposerThumbDialog(page, log) {
996
- const hit = await page.evaluate((compSrc) => {
1035
+ const hit = await page.evaluate(({ compSrc, pickSrc }) => {
997
1036
  const COMP = new RegExp(compSrc, 'i');
1037
+ const PICK = new RegExp(pickSrc, 'i');
998
1038
  for (const dlg of document.querySelectorAll("[role='dialog']")) {
999
1039
  const r = dlg.getBoundingClientRect();
1000
1040
  if (r.width < 8 || r.height < 8) continue;
1001
1041
  const head = `${dlg.getAttribute('aria-label') || ''} | ${(dlg.innerText || '').slice(0, 300)}`;
1002
1042
  if (COMP.test(head)) continue;
1043
+ // Hộp "Chọn hình thu nhỏ" có luồng riêng (chỉ Lưu khi ảnh xem trước đã
1044
+ // đổi) — bấm Lưu ở đây lúc ảnh chưa đổi là lưu khung tự động làm ảnh bìa.
1045
+ if (PICK.test((dlg.getAttribute('aria-label') || '').trim()) || dlg.closest('[__fbpw_thumb_modal__]')) continue;
1003
1046
  if (!/hình thu nhỏ|ảnh thu nhỏ|thumbnail|cắt ảnh|crop/i.test(head)) continue;
1004
1047
  for (const b of dlg.querySelectorAll("button, [role='button']")) {
1005
1048
  const t = (b.innerText || '').trim();
@@ -1011,7 +1054,7 @@ async function confirmNewComposerThumbDialog(page, log) {
1011
1054
  }
1012
1055
  }
1013
1056
  return null;
1014
- }, COMPOSER_SIGNATURE_RE.source).catch(() => null);
1057
+ }, { compSrc: COMPOSER_SIGNATURE_RE.source, pickSrc: THUMB_PICKER_TEXT_RE.source }).catch(() => null);
1015
1058
  if (!hit) return false;
1016
1059
  log('info', `[fb-pw] composer mới — hộp phụ ảnh bìa "${hit.head}" → bấm "${hit.verb}"`);
1017
1060
  await page.locator("[__fbpw_thumb_confirm__='1']").click({ timeout: 3000 }).catch(() => {});
@@ -1019,17 +1062,351 @@ async function confirmNewComposerThumbDialog(page, log) {
1019
1062
  return true;
1020
1063
  }
1021
1064
 
1065
+ // Hộp thoại "Chọn hình thu nhỏ" — thứ THẬT mở ra khi bấm ô chọn-ảnh. Đo trên
1066
+ // Facebook 23/09/2026 (lượt test page LACCA, lệnh 6ab39643): cú bấm KHÔNG mở
1067
+ // hộp chọn file mà mở hộp thoại 500x326 gồm ảnh xem trước 203x360, dải khung để
1068
+ // tua video (input range "Tua trên dòng thời gian…"), input file ẩn
1069
+ // accept=".png,.jpg,.jpeg", nút "Tải lên" và "Lưu". FB gắn role=dialog +
1070
+ // aria-label "Chọn hình thu nhỏ" lên PHẦN ĐẦU (500x60) chứ không lên cả hộp,
1071
+ // nên không dò theo role: leo từ chữ tiêu đề lên tới khối có cả "Tải lên" lẫn
1072
+ // "Lưu" (và chưa chạm nút "Đăng" của composer).
1073
+ // Gắn cờ: __fbpw_thumb_modal__ (khối), _upload__ (nút Tải lên), _save__ (mọi
1074
+ // nút Lưu, đánh số từ cuối), _input__ (input ảnh). Trả null nếu không thấy.
1075
+ async function findThumbPickerModal(page) {
1076
+ return page.evaluate(({ pickSrc, coverHeadSrc }) => {
1077
+ const PICK = new RegExp(pickSrc, 'i');
1078
+ const COVER_HEAD = new RegExp(coverHeadSrc, 'i');
1079
+ const isHead = (t) => PICK.test(t) || COVER_HEAD.test(t);
1080
+ const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1081
+ const vis = (el) => {
1082
+ const r = el.getBoundingClientRect();
1083
+ if (r.width < 8 || r.height < 8) return false;
1084
+ const cs = getComputedStyle(el);
1085
+ return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0';
1086
+ };
1087
+ for (const a of ['__fbpw_thumb_modal__', '__fbpw_thumb_modal_upload__', '__fbpw_thumb_modal_save__', '__fbpw_thumb_modal_input__']) {
1088
+ document.querySelectorAll(`[${a}]`).forEach((el) => el.removeAttribute(a));
1089
+ }
1090
+ const says = (el, re) => re.test(norm(el.getAttribute('aria-label'))) || re.test(norm(el.innerText));
1091
+ const UP = /^(Tải lên|Tải ảnh lên|Upload|Upload image)$/i;
1092
+ const SAVE = /^(Lưu|Save|Xong|Done|Áp dụng|Apply)$/i;
1093
+ const POST = /^(Đăng|Post|Share now)$/i;
1094
+ const heads = [...document.querySelectorAll('[aria-label]')].filter((el) => isHead(norm(el.getAttribute('aria-label'))) && vis(el));
1095
+ const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
1096
+ for (let n = tw.nextNode(); n; n = tw.nextNode()) {
1097
+ if (!/hình|thumbnail|ảnh bìa|cover/i.test(n.data)) continue;
1098
+ const el = n.parentElement;
1099
+ if (el && vis(el) && isHead(norm(el.innerText))) heads.push(el);
1100
+ }
1101
+ for (const h of heads) {
1102
+ if (h.closest('[__fbpw_thumb_row__]')) continue; // chính ô chọn-ảnh trong hàng
1103
+ if (h.closest('[__fbpw_thumb_pick__]')) continue; // chính nút "Chọn ảnh bìa"
1104
+ let box = h;
1105
+ for (let d = 0; d < 10 && box && box !== document.body; d++, box = box.parentElement) {
1106
+ const btns = [...box.querySelectorAll("[role='button'], button")].filter(vis);
1107
+ if (btns.some((b) => says(b, POST))) break; // đã leo tới composer
1108
+ const up = btns.find((b) => says(b, UP));
1109
+ const saves = btns.filter((b) => says(b, SAVE));
1110
+ if (!up || !saves.length) continue;
1111
+ box.setAttribute('__fbpw_thumb_modal__', '1');
1112
+ up.setAttribute('__fbpw_thumb_modal_upload__', '1');
1113
+ saves.reverse().forEach((b, i) => b.setAttribute('__fbpw_thumb_modal_save__', String(i)));
1114
+ const inp = [...box.querySelectorAll("input[type='file']")].find((i) => {
1115
+ const a = (i.getAttribute('accept') || '').toLowerCase();
1116
+ return /image|\.png|\.jpe?g/.test(a) && !/video|\.mp4|\.mov/.test(a);
1117
+ });
1118
+ if (inp) inp.setAttribute('__fbpw_thumb_modal_input__', '1');
1119
+ const big = [...box.querySelectorAll('img')].find((i) => {
1120
+ const ir = i.getBoundingClientRect();
1121
+ return ir.width >= 150 && ir.height >= 150;
1122
+ });
1123
+ const r = box.getBoundingClientRect();
1124
+ return {
1125
+ w: Math.round(r.width),
1126
+ h: Math.round(r.height),
1127
+ preview: big ? String(big.currentSrc || big.src || '').slice(0, 500) : '',
1128
+ input: inp ? (inp.getAttribute('accept') || '') : null,
1129
+ saves: saves.length,
1130
+ };
1131
+ }
1132
+ }
1133
+ return null;
1134
+ }, { pickSrc: THUMB_PICKER_TEXT_RE.source, coverHeadSrc: COVER_HEAD_RE.source }).catch(() => null);
1135
+ }
1136
+
1137
+ // Ảnh xem trước trong hộp thoại có đúng là ảnh bìa của MÌNH không? Thu cả hai
1138
+ // về 16x16 rồi lấy độ lệch trung bình mỗi kênh màu (0-255). "Ảnh xem trước đổi
1139
+ // URL" chưa đủ: FB có thể tự thay bằng bản nét hơn của khung đang chọn, và Lưu
1140
+ // lúc đó là lưu khung tự động. Ảnh blob:/data: đọc được; ảnh CDN khác nguồn làm
1141
+ // canvas bị khoá → trả { err: 'tainted' } để người gọi tự quyết.
1142
+ const THUMB_PREVIEW_MAX_DIFF = 18;
1143
+ async function previewMatchesThumb(page, thumbDataUrl) {
1144
+ return page.evaluate(async ({ ours }) => {
1145
+ const box = document.querySelector('[__fbpw_thumb_modal__]');
1146
+ if (!box) return { err: 'no-modal' };
1147
+ const prev = [...box.querySelectorAll('img')].find((i) => {
1148
+ const r = i.getBoundingClientRect();
1149
+ return r.width >= 150 && r.height >= 150;
1150
+ });
1151
+ if (!prev || !prev.complete || !prev.naturalWidth) return { err: 'preview-not-loaded' };
1152
+ const sig = (img) => {
1153
+ const c = document.createElement('canvas');
1154
+ c.width = 16; c.height = 16;
1155
+ const x = c.getContext('2d');
1156
+ x.drawImage(img, 0, 0, 16, 16);
1157
+ return x.getImageData(0, 0, 16, 16).data;
1158
+ };
1159
+ let a;
1160
+ try { a = sig(prev); } catch { return { err: 'tainted' }; }
1161
+ const mine = await new Promise((res) => {
1162
+ const i = new Image();
1163
+ i.onload = () => res(i);
1164
+ i.onerror = () => res(null);
1165
+ i.src = ours;
1166
+ });
1167
+ if (!mine) return { err: 'thumb-not-decodable' };
1168
+ const b = sig(mine);
1169
+ let d = 0;
1170
+ for (let k = 0; k < a.length; k += 4) d += Math.abs(a[k] - b[k]) + Math.abs(a[k + 1] - b[k + 1]) + Math.abs(a[k + 2] - b[k + 2]);
1171
+ return { diff: Math.round(d / (256 * 3)) };
1172
+ }, { ours: thumbDataUrl }).catch((e) => ({ err: String(e && e.message || e).slice(0, 60) }));
1173
+ }
1174
+
1175
+ // Đưa ảnh bìa vào hộp thoại "Chọn hình thu nhỏ" rồi Lưu. Chỉ bấm Lưu khi ẢNH
1176
+ // XEM TRƯỚC ĐÃ ĐỔI: hộp này mở sẵn một khung cắt từ video, Lưu lúc ảnh chưa đổi
1177
+ // là lưu khung đó làm ảnh bìa — bài lên với ảnh FB tự chọn, đúng thứ cổng
1178
+ // trước Đăng sinh ra để chặn. Trả { ok, via, reason }.
1179
+ async function uploadViaThumbPickerModal(page, thumbPath, log, modal, { previewMs = 20_000, closeMs = 30_000 } = {}) {
1180
+ log('info', `[fb-pw] composer mới — ô "Chọn hình thu nhỏ" mở hộp thoại chọn ảnh bìa (${modal.w}x${modal.h}, input=${modal.input === null ? 'không' : modal.input || '?'}, nút Lưu=${modal.saves})`);
1181
+ // Mốc "trước" chỉ chốt khi ảnh xem trước ĐÃ ĐỨNG YÊN: chụp lúc nó còn trống
1182
+ // hay đang nạp thì lần nạp xong kế tiếp sẽ bị tưởng là ảnh của mình.
1183
+ let beforePreview = modal.preview;
1184
+ for (let i = 0; i < 5; i++) {
1185
+ await pause(page, 1000);
1186
+ const again = await findThumbPickerModal(page);
1187
+ if (!again) break;
1188
+ const stable = again.preview && again.preview === beforePreview;
1189
+ beforePreview = again.preview;
1190
+ if (stable) break;
1191
+ }
1192
+ let thumbDataUrl = '';
1193
+ try { thumbDataUrl = `data:image/png;base64,${fs.readFileSync(thumbPath).toString('base64')}`; } catch { /* so nội dung sẽ báo lỗi, rơi về luật đổi-URL */ }
1194
+ let via = '';
1195
+ try {
1196
+ const [chooser] = await Promise.all([
1197
+ page.waitForEvent('filechooser', { timeout: 8000 }),
1198
+ page.locator("[__fbpw_thumb_modal__='1'] [__fbpw_thumb_modal_upload__='1']").first().click({ timeout: 5000 }),
1199
+ ]);
1200
+ await chooser.setFiles(thumbPath);
1201
+ via = 'nút "Tải lên" trong hộp thoại';
1202
+ } catch (e) {
1203
+ log('info', `[fb-pw] composer mới — nút "Tải lên" không mở hộp chọn file: ${String(e && e.message || e).slice(0, 80)}`);
1204
+ }
1205
+ if (!via && await findThumbPickerModal(page)) {
1206
+ const inp = page.locator("[__fbpw_thumb_modal__='1'] [__fbpw_thumb_modal_input__='1']").first();
1207
+ if (await inp.count().catch(() => 0)) {
1208
+ try {
1209
+ await inp.setInputFiles(thumbPath);
1210
+ via = 'input ảnh ẩn trong hộp thoại';
1211
+ } catch (e) {
1212
+ log('info', `[fb-pw] composer mới — input ảnh trong hộp thoại từ chối file: ${String(e && e.message || e).slice(0, 80)}`);
1213
+ }
1214
+ }
1215
+ }
1216
+ if (!via) return { ok: false, reason: 'modal-no-upload' };
1217
+ log('info', `[fb-pw] composer mới — đã đưa file vào hộp thoại qua ${via}, chờ ảnh xem trước đổi…`);
1218
+
1219
+ const t0 = Date.now();
1220
+ let m = null;
1221
+ let changed = false;
1222
+ let lastCmp = null;
1223
+ let loggedMismatch = false;
1224
+ while (Date.now() - t0 < previewMs) {
1225
+ await pause(page, 1500);
1226
+ m = await findThumbPickerModal(page);
1227
+ if (!m) return { ok: true, via, verified: false, reason: 'hộp thoại tự đóng sau khi nhận ảnh' };
1228
+ if (!m.preview || m.preview === beforePreview) continue;
1229
+ // URL đổi rồi — so NỘI DUNG. Lệch nhiều = FB đổi khung chứ chưa phải ảnh
1230
+ // của mình: chờ tiếp. Không so được (ảnh CDN khoá canvas) thì rơi về luật
1231
+ // đổi-URL, kèm ghi rõ vào log. Ảnh chưa nạp xong thì chờ nhịp sau.
1232
+ lastCmp = await previewMatchesThumb(page, thumbDataUrl);
1233
+ if (lastCmp.err === 'preview-not-loaded') continue;
1234
+ if (lastCmp.diff !== undefined && lastCmp.diff > THUMB_PREVIEW_MAX_DIFF) {
1235
+ if (!loggedMismatch) log('info', `[fb-pw] composer mới — ảnh xem trước đổi nhưng KHÔNG giống ảnh bìa của mình (lệch ${lastCmp.diff}/255) — chờ tiếp`);
1236
+ loggedMismatch = true;
1237
+ continue;
1238
+ }
1239
+ changed = true;
1240
+ break;
1241
+ }
1242
+ if (!changed) {
1243
+ log('warn', `[fb-pw] composer mới — ảnh xem trước chưa thành ảnh bìa của mình sau ${Math.round(previewMs / 1000)}s${lastCmp && lastCmp.diff !== undefined ? ` (lệch ${lastCmp.diff}/255)` : ''} → KHÔNG bấm Lưu (Lưu lúc này là lưu khung tự động làm ảnh bìa)`);
1244
+ return { ok: false, reason: 'modal-preview-unchanged' };
1245
+ }
1246
+ log('info', `[fb-pw] composer mới — ảnh xem trước đã thành ảnh bìa của mình sau ${Math.round((Date.now() - t0) / 1000)}s (${lastCmp && lastCmp.diff !== undefined ? `lệch ${lastCmp.diff}/255` : `không so được nội dung: ${lastCmp && lastCmp.err}`}), bấm Lưu…`);
1247
+ let saved = false;
1248
+ for (let i = 0; i < (m.saves || 1) && !saved; i++) {
1249
+ try {
1250
+ await page.locator(`[__fbpw_thumb_modal__='1'] [__fbpw_thumb_modal_save__='${i}']`).first().click({ timeout: 5000 });
1251
+ saved = true;
1252
+ } catch (e) {
1253
+ log('info', `[fb-pw] composer mới — nút Lưu #${i} không bấm được: ${String(e && e.message || e).slice(0, 80)}`);
1254
+ await findThumbPickerModal(page);
1255
+ }
1256
+ }
1257
+ if (!saved) return { ok: false, reason: 'modal-save-failed' };
1258
+ // Đòi hai nhịp liền không thấy hộp thoại — FB có thể gỡ rồi dựng lại khung.
1259
+ const t1 = Date.now();
1260
+ let gone = 0;
1261
+ while (Date.now() - t1 < closeMs) {
1262
+ await pause(page, 1500);
1263
+ if (await findThumbPickerModal(page)) { gone = 0; continue; }
1264
+ if (++gone >= 2) return { ok: true, via, verified: !!(lastCmp && lastCmp.diff !== undefined) };
1265
+ }
1266
+ log('warn', `[fb-pw] composer mới — hộp thoại "Chọn hình thu nhỏ" chưa đóng ${Math.round(closeMs / 1000)}s sau khi bấm Lưu`);
1267
+ return { ok: false, reason: 'modal-not-closed' };
1268
+ }
1269
+
1270
+ // Bấm nút/ô chọn ảnh bìa (đã gắn cờ __fbpw_thumb_pick__) rồi chờ MỘT trong
1271
+ // hai: hộp chọn file (biến thể mở thẳng) hoặc hộp thoại chọn ảnh (biến thể đo
1272
+ // thật 23/09). Trả { via, verified, failed, reason }: `via` rỗng = chưa đưa được
1273
+ // file vào (người gọi còn đường dự phòng); `failed` = hộp thoại đã mở mà dán hỏng.
1274
+ async function clickPickerAndUpload(page, thumbPath, log) {
1275
+ let chooser = null;
1276
+ const chooserP = page.waitForEvent('filechooser', { timeout: 8000 }).then((c) => { chooser = c; }).catch(() => {});
1277
+ let clickErr = '';
1278
+ await page.locator("[__fbpw_thumb_pick__='1']").first().click({ timeout: 5000 }).catch((e) => { clickErr = String(e && e.message || e).slice(0, 80); });
1279
+ let modal = null;
1280
+ const tClick = Date.now();
1281
+ while (!chooser && !modal && Date.now() - tClick < 8000) {
1282
+ await pause(page, 800);
1283
+ modal = await findThumbPickerModal(page);
1284
+ }
1285
+ void chooserP;
1286
+ if (chooser) {
1287
+ try {
1288
+ await chooser.setFiles(thumbPath);
1289
+ return { via: 'hộp chọn file', verified: false };
1290
+ } catch (e) {
1291
+ log('info', `[fb-pw] composer mới — hộp chọn file từ chối ảnh: ${String(e && e.message || e).slice(0, 80)}`);
1292
+ return { via: '' };
1293
+ }
1294
+ }
1295
+ if (modal) {
1296
+ const r = await uploadViaThumbPickerModal(page, thumbPath, log, modal);
1297
+ if (!r.ok) {
1298
+ log('warn', `[fb-pw] composer mới — dán qua hộp thoại chọn ảnh bìa KHÔNG xong (${r.reason})`);
1299
+ return { via: '', failed: true, reason: r.reason };
1300
+ }
1301
+ return { via: r.via + (r.reason ? ` (${r.reason})` : ''), verified: !!r.verified };
1302
+ }
1303
+ log('info', `[fb-pw] composer mới — bấm chỗ chọn ảnh bìa không mở hộp chọn file lẫn hộp thoại${clickErr ? `: ${clickErr}` : ''}`);
1304
+ return { via: '' };
1305
+ }
1306
+
1307
+ // Ảnh bìa của MÌNH có đang hiện trong composer không? Quét mọi ảnh ≥40px trong
1308
+ // composer (dialog Reel + vùng main của trang tạo), trừ hộp thoại chọn ảnh, so
1309
+ // nội dung 16x16 với file ảnh bìa như previewMatchesThumb. Dùng cho biến thể nút
1310
+ // "Chọn ảnh bìa": không có hàng ô để so trước/sau, nên bằng chứng duy nhất đứng
1311
+ // vững là thấy chính ảnh đó. Ảnh CDN khác nguồn khoá canvas → không tính.
1312
+ async function composerShowsThumb(page, thumbDataUrl) {
1313
+ return page.evaluate(async ({ ours }) => {
1314
+ const vis = (el) => {
1315
+ const r = el.getBoundingClientRect();
1316
+ if (r.width < 8 || r.height < 8) return false;
1317
+ const cs = getComputedStyle(el);
1318
+ return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0';
1319
+ };
1320
+ const roots = [...document.querySelectorAll("[role='dialog']")].filter((d) => vis(d)
1321
+ && /thước phim|reel/i.test(`${d.getAttribute('aria-label') || ''}\n${(d.innerText || '').slice(0, 600)}`));
1322
+ const main = document.querySelector("[role='main'], main");
1323
+ if (main && vis(main)) roots.push(main);
1324
+ const sig = (img) => {
1325
+ const c = document.createElement('canvas');
1326
+ c.width = 16; c.height = 16;
1327
+ const x = c.getContext('2d');
1328
+ x.drawImage(img, 0, 0, 16, 16);
1329
+ return x.getImageData(0, 0, 16, 16).data;
1330
+ };
1331
+ const mine = await new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => res(null); i.src = ours; });
1332
+ if (!mine) return { err: 'thumb-not-decodable' };
1333
+ const b = sig(mine);
1334
+ const seen = new Set();
1335
+ let readable = 0, locked = 0, best = null;
1336
+ for (const root of roots) {
1337
+ for (const im of root.querySelectorAll('img')) {
1338
+ if (seen.has(im) || im.closest('[__fbpw_thumb_modal__]')) continue;
1339
+ seen.add(im);
1340
+ const r = im.getBoundingClientRect();
1341
+ if (r.width < 40 || r.height < 40 || !vis(im) || !im.complete || !im.naturalWidth) continue;
1342
+ let a;
1343
+ try { a = sig(im); } catch { locked++; continue; }
1344
+ readable++;
1345
+ let d = 0;
1346
+ for (let k = 0; k < a.length; k += 4) d += Math.abs(a[k] - b[k]) + Math.abs(a[k + 1] - b[k + 1]) + Math.abs(a[k + 2] - b[k + 2]);
1347
+ d = Math.round(d / (256 * 3));
1348
+ if (!best || d < best.diff) best = { diff: d, size: `${Math.round(r.width)}x${Math.round(r.height)}` };
1349
+ }
1350
+ }
1351
+ return { readable, locked, best };
1352
+ }, { ours: thumbDataUrl }).catch((e) => ({ err: String(e && e.message || e).slice(0, 60) }));
1353
+ }
1354
+
1355
+ // Biến thể nút "Chọn ảnh bìa" (wizard, không có hàng ô). Chỉ nhận là đã dán khi:
1356
+ // a) hộp thoại chọn ảnh đã cho thấy ĐÚNG ảnh của mình (so nội dung) rồi mới
1357
+ // Lưu và đóng, hoặc
1358
+ // b) sau khi dán, composer hiện một ảnh giống ảnh bìa của mình (lệch ≤ ngưỡng)
1359
+ // hai nhịp liền, hoặc nút "Gỡ hình thu nhỏ tùy chỉnh" hiện.
1360
+ // Không thì trả không-dán, cổng trước Đăng chặn như mọi biến thể khác.
1361
+ async function applyCoverButtonThumb(page, thumbPath, log, snap, { verifyMs = 30_000 } = {}) {
1362
+ log('info', `[fb-pw] composer mới — ${describeThumbRow(snap)} → dán ảnh bìa qua nút này`);
1363
+ let thumbDataUrl = '';
1364
+ try { thumbDataUrl = `data:image/png;base64,${fs.readFileSync(thumbPath).toString('base64')}`; } catch { /* composerShowsThumb sẽ báo thumb-not-decodable */ }
1365
+ const base = await composerShowsThumb(page, thumbDataUrl);
1366
+ const up = await clickPickerAndUpload(page, thumbPath, log);
1367
+ if (up.failed || !up.via) {
1368
+ const d = await readNewComposerThumbRow(page, { diag: true });
1369
+ log('warn', `[fb-pw] composer mới — nút ảnh bìa: KHÔNG đưa được file vào (${up.reason || 'không mở được chỗ chọn ảnh'})${d && d.inventory ? ` nút=${JSON.stringify(d.inventory)}` : ''}`);
1370
+ return { applied: false, reason: up.reason || 'no-upload' };
1371
+ }
1372
+ if (up.verified) {
1373
+ log('info', `[fb-pw] composer mới — ảnh bìa đã vào qua ${up.via}: hộp thoại cho thấy đúng ảnh của mình rồi mới Lưu`);
1374
+ return { applied: true, reason: 'hộp thoại xác nhận đúng ảnh' };
1375
+ }
1376
+ log('info', `[fb-pw] composer mới — đã đưa file ảnh bìa vào qua ${up.via}, tìm chính ảnh đó trong composer…`);
1377
+ let streak = 0;
1378
+ let scan = null;
1379
+ const deadline = Date.now() + verifyMs;
1380
+ while (Date.now() < deadline) {
1381
+ await pause(page, 2500);
1382
+ if (await isCustomThumbApplied(page)) { if (++streak >= 2) break; continue; }
1383
+ scan = await composerShowsThumb(page, thumbDataUrl);
1384
+ if (scan.best && scan.best.diff <= THUMB_PREVIEW_MAX_DIFF) { if (++streak >= 2) break; continue; }
1385
+ streak = 0;
1386
+ }
1387
+ const fmt = (x) => (x && !x.err ? `đọc được ${x.readable} ảnh, khoá ${x.locked}, gần nhất lệch ${x.best ? `${x.best.diff}/255 (${x.best.size})` : '—'}` : `lỗi ${x && x.err}`);
1388
+ if (streak >= 2) {
1389
+ log('info', `[fb-pw] composer mới — ảnh bìa đã vào: composer hiện đúng ảnh của mình (${fmt(scan)}; trước khi dán: ${fmt(base)})`);
1390
+ return { applied: true, reason: 'composer hiện đúng ảnh' };
1391
+ }
1392
+ const d = await readNewComposerThumbRow(page, { diag: true });
1393
+ log('warn', `[fb-pw] composer mới — nút ảnh bìa: KHÔNG thấy ảnh của mình trong composer sau ${Math.round(verifyMs / 1000)}s (${fmt(scan)}; trước khi dán: ${fmt(base)})${d && d.inventory ? ` nút=${JSON.stringify(d.inventory)}` : ''}`);
1394
+ return { applied: false, reason: 'cover-not-shown' };
1395
+ }
1396
+
1022
1397
  // Dán ảnh bìa vào composer mới. Trả { applied, reason }. Không ném: thất bại
1023
1398
  // thì cờ customThumbDone không bật và cổng trước Đăng chặn như cũ.
1024
1399
  async function applyNewComposerThumb(page, thumbPath, log, { waitPickerMs = 20_000, verifyMs = 30_000 } = {}) {
1025
1400
  const t0 = Date.now();
1026
1401
  const ready = (s) => !!(s && s.found && s.pickerPresent);
1402
+ const coverMode = (s) => !!(s && s.mode === 'cover-button');
1027
1403
  let snap = await readNewComposerThumbRow(page);
1028
1404
  // Hàng ảnh bìa chỉ hiện sau khi FB cắt xong khung từ video.
1029
- while (!ready(snap) && Date.now() - t0 < waitPickerMs) {
1405
+ while (!ready(snap) && !coverMode(snap) && Date.now() - t0 < waitPickerMs) {
1030
1406
  await pause(page, 2500);
1031
1407
  snap = await readNewComposerThumbRow(page);
1032
1408
  }
1409
+ if (coverMode(snap)) return applyCoverButtonThumb(page, thumbPath, log, snap, { verifyMs });
1033
1410
  if (!ready(snap)) {
1034
1411
  const d = await readNewComposerThumbRow(page, { diag: true });
1035
1412
  log('info', `[fb-pw] composer mới — chưa thấy ô "Chọn hình thu nhỏ" sau ${Math.round((Date.now() - t0) / 1000)}s: ${describeThumbRow(d)}${d && d.skeleton ? ` skeleton=${JSON.stringify(d.skeleton)}` : ''}${d && d.inventory ? ` nút=${JSON.stringify(d.inventory)}` : ''}`);
@@ -1048,17 +1425,9 @@ async function applyNewComposerThumb(page, thumbPath, log, { waitPickerMs = 20_0
1048
1425
  const before = snap;
1049
1426
  log('info', `[fb-pw] composer mới — hàng ảnh bìa trước khi dán: ${describeThumbRow(before)}`);
1050
1427
 
1051
- let via = '';
1052
- try {
1053
- const [chooser] = await Promise.all([
1054
- page.waitForEvent('filechooser', { timeout: 8000 }),
1055
- page.locator("[__fbpw_thumb_pick__='1']").first().click({ timeout: 5000 }),
1056
- ]);
1057
- await chooser.setFiles(thumbPath);
1058
- via = 'hộp chọn file';
1059
- } catch (e) {
1060
- log('info', `[fb-pw] composer mới — bấm ô "Chọn hình thu nhỏ" không mở hộp chọn file: ${String(e && e.message || e).slice(0, 80)}`);
1061
- }
1428
+ const up = await clickPickerAndUpload(page, thumbPath, log);
1429
+ let via = up.via;
1430
+ if (up.failed) return { applied: false, reason: up.reason };
1062
1431
  if (!via) {
1063
1432
  await readNewComposerThumbRow(page); // cú bấm có thể làm FB dựng lại DOM → gắn cờ lại
1064
1433
  const input = page.locator("[__fbpw_thumb_input__='1']").first();
@@ -1625,7 +1994,7 @@ async function runOnce({ page, payload, log }) {
1625
1994
  } = payload || {};
1626
1995
  if (!video_url) throw new Error('No video_url provided');
1627
1996
 
1628
- log('info', '[fb-pw] selectors version=2026.09.23-new-composer-thumb');
1997
+ log('info', '[fb-pw] selectors version=2026.09.23-cover-button');
1629
1998
 
1630
1999
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
1631
2000
 
@@ -4100,6 +4469,15 @@ module.exports.__testables = {
4100
4469
  describeThumbRow,
4101
4470
  readNewComposerThumbRow,
4102
4471
  applyNewComposerThumb,
4472
+ findThumbPickerModal,
4473
+ uploadViaThumbPickerModal,
4474
+ previewMatchesThumb,
4475
+ THUMB_PREVIEW_MAX_DIFF,
4476
+ COVER_BUTTON_RE,
4477
+ COVER_HEAD_RE,
4478
+ composerShowsThumb,
4479
+ applyCoverButtonThumb,
4480
+ clickPickerAndUpload,
4103
4481
  // Dùng chung cho comment_facebook: script nào trượt giữa chừng cũng cần chụp
4104
4482
  // lại màn hình, viết bản thứ hai là sớm muộn hai bên lệch nhau.
4105
4483
  dumpFailure,