channel-worker 2.5.135 → 2.5.137

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.135",
3
+ "version": "2.5.137",
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": {
@@ -741,9 +741,26 @@ const NEW_COMPOSER_URL_RE = /^https?:\/\/(www\.)?facebook\.com\/(post\/create|re
741
741
  function isNewComposerUrl(url) {
742
742
  return NEW_COMPOSER_URL_RE.test(String(url || ''));
743
743
  }
744
+ // /reel/ hoặc /reel/<id>: Facebook có lúc PHỦ hộp thoại composer lên feed
745
+ // Reels (10/09; Nuôi Gà Làm Giàu 23/09 13:16Z — đúng composer KIỂU CŨ "Cài đặt
746
+ // thước phim" + nút "Chỉnh sửa" trên ảnh video, ảnh dump lệnh 6ab3d12d). Trước
747
+ // đây cả hai khối ảnh bìa đều bỏ qua URL này → "Custom thumbnail required".
748
+ // Ở đây phải hỏi DOM xem là composer nào (xem vòng bước đăng).
749
+ const REEL_OVERLAY_URL_RE = /^https?:\/\/(www\.)?facebook\.com\/reel\/(\d+\/?)?($|\?|#)/i;
750
+ function isReelOverlayUrl(url) {
751
+ return REEL_OVERLAY_URL_RE.test(String(url || ''));
752
+ }
744
753
  // 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
754
  // khoảng trắng) để không dính caption hay nhãn mục "Hình thu nhỏ".
746
755
  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;
756
+ // Biến thể thứ ba (đo thật 23/09, page Gà Vườn Mỗi Ngày, wizard
757
+ // /reels/create/?surface=ADDL_PROFILE_PLUS): KHÔNG có hàng ô, ảnh bìa là một
758
+ // nút nhỏ 72x20 chữ "Ảnh bìa", aria-label "Chọn ảnh bìa", có ở cả bước
759
+ // "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 ô
760
+ // nào, đi luồng riêng (applyCoverButtonThumb) và xác minh bằng nội dung ảnh.
761
+ const COVER_BUTTON_RE = /^(?:Chọn ảnh bìa|(?:Choose|Select|Add|Edit) (?:a )?cover(?: photo| image)?)$/i;
762
+ // 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.
763
+ 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
764
 
748
765
  // Đọc hàng ô ảnh bìa của composer mới. Trả về ảnh chụp trạng thái để so
749
766
  // trước/sau khi dán (xem newComposerThumbVerdict), và gắn cờ DOM cho:
@@ -753,7 +770,7 @@ const THUMB_PICKER_TEXT_RE = /^(?:Chọn hình thu nhỏ|Chọn ảnh thu nhỏ|
753
770
  // __fbpw_thumb_tile__ — ô thứ `tagIndex` (dùng khi phải bấm chọn lại)
754
771
  // Không bao giờ ném: đọc hỏng trả null, như isCustomThumbApplied.
755
772
  async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {}) {
756
- return page.evaluate(({ pickSrc, diag, tagIndex }) => {
773
+ return page.evaluate(({ pickSrc, coverSrc, diag, tagIndex }) => {
757
774
  const PICK = new RegExp(pickSrc, 'i');
758
775
  const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
759
776
  const vis = (el) => {
@@ -880,6 +897,23 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
880
897
  }
881
898
  const customMarker = [...document.querySelectorAll('[aria-label]')].some((el) =>
882
899
  /Gỡ hình thu nhỏ tùy chỉnh|Remove( your)? custom thumbnail/i.test(el.getAttribute('aria-label') || '') && vis(el));
900
+ if (!row && !pickLeaf) {
901
+ // Không có ô "Chọn hình thu nhỏ" → thử nút "Chọn ảnh bìa" (biến thể wizard).
902
+ const COVER = new RegExp(coverSrc, 'i');
903
+ for (const root of roots) {
904
+ const btn = [...root.querySelectorAll('[aria-label]')].find((el) => vis(el)
905
+ && COVER.test(norm(el.getAttribute('aria-label'))) && !el.closest('[__fbpw_thumb_modal__]'));
906
+ if (!btn) continue;
907
+ let target = btn;
908
+ for (let n = btn, d = 0; n && d < 3; n = n.parentElement, d++) {
909
+ if (n.matches("[role='button'], button, a, [tabindex]")) { target = n; break; }
910
+ }
911
+ target.setAttribute('__fbpw_thumb_pick__', '1');
912
+ const r = btn.getBoundingClientRect();
913
+ return { found: false, pickerPresent: true, mode: 'cover-button', roots: roots.length, customMarker,
914
+ cover: { aria: norm(btn.getAttribute('aria-label')), text: norm(btn.innerText).slice(0, 30), size: `${Math.round(r.width)}x${Math.round(r.height)}` } };
915
+ }
916
+ }
883
917
  if (!row) {
884
918
  const miss = { found: false, pickerPresent: !!pickLeaf, roots: roots.length, customMarker };
885
919
  if (diag) {
@@ -954,7 +988,7 @@ async function readNewComposerThumbRow(page, { diag = false, tagIndex = -1 } = {
954
988
  });
955
989
  }
956
990
  return res;
957
- }, { pickSrc: THUMB_PICKER_TEXT_RE.source, diag, tagIndex }).catch(() => null);
991
+ }, { pickSrc: THUMB_PICKER_TEXT_RE.source, coverSrc: COVER_BUTTON_RE.source, diag, tagIndex }).catch(() => null);
958
992
  }
959
993
 
960
994
  // Ảnh bìa tự chọn đã VÀO composer mới chưa? So hàng ô trước và sau khi dán
@@ -997,6 +1031,7 @@ function newComposerThumbVerdict(before, after) {
997
1031
  // Tóm tắt một hàng ô cho log: "5 ô [chọn-ảnh, *khung, khung…] chọn=1 input=image/*".
998
1032
  function describeThumbRow(snap) {
999
1033
  if (!snap) return 'không đọc được';
1034
+ 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})`;
1000
1035
  if (!snap.found) return `không thấy hàng ô (composer=${snap.roots || 0}, ô chọn-ảnh=${snap.pickerPresent ? 'có' : 'không'})`;
1001
1036
  const cells = (snap.tiles || []).map((t) => `${t.selected ? '*' : ''}${t.placeholder ? 'chọn-ảnh' : (t.img ? (/^(blob|data):/.test(t.img) ? 'ảnh-local' : 'khung') : '?')}`);
1002
1037
  return `${cells.length} ô [${cells.join(', ')}] chọn=${snap.selectedIndex ?? '?'} input=${snap.input ? snap.input.accept || '?' : 'không'} via=${snap.via}`;
@@ -1047,8 +1082,10 @@ async function confirmNewComposerThumbDialog(page, log) {
1047
1082
  // Gắn cờ: __fbpw_thumb_modal__ (khối), _upload__ (nút Tải lên), _save__ (mọi
1048
1083
  // nút Lưu, đánh số từ cuối), _input__ (input ảnh). Trả null nếu không thấy.
1049
1084
  async function findThumbPickerModal(page) {
1050
- return page.evaluate(({ pickSrc }) => {
1085
+ return page.evaluate(({ pickSrc, coverHeadSrc }) => {
1051
1086
  const PICK = new RegExp(pickSrc, 'i');
1087
+ const COVER_HEAD = new RegExp(coverHeadSrc, 'i');
1088
+ const isHead = (t) => PICK.test(t) || COVER_HEAD.test(t);
1052
1089
  const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim();
1053
1090
  const vis = (el) => {
1054
1091
  const r = el.getBoundingClientRect();
@@ -1061,17 +1098,18 @@ async function findThumbPickerModal(page) {
1061
1098
  }
1062
1099
  const says = (el, re) => re.test(norm(el.getAttribute('aria-label'))) || re.test(norm(el.innerText));
1063
1100
  const UP = /^(Tải lên|Tải ảnh lên|Upload|Upload image)$/i;
1064
- const SAVE = /^(Lưu|Save)$/i;
1101
+ const SAVE = /^(Lưu|Save|Xong|Done|Áp dụng|Apply)$/i;
1065
1102
  const POST = /^(Đăng|Post|Share now)$/i;
1066
- const heads = [...document.querySelectorAll('[aria-label]')].filter((el) => PICK.test(norm(el.getAttribute('aria-label'))) && vis(el));
1103
+ const heads = [...document.querySelectorAll('[aria-label]')].filter((el) => isHead(norm(el.getAttribute('aria-label'))) && vis(el));
1067
1104
  const tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
1068
1105
  for (let n = tw.nextNode(); n; n = tw.nextNode()) {
1069
- if (!/hình|thumbnail/i.test(n.data)) continue;
1106
+ if (!/hình|thumbnail|ảnh bìa|cover/i.test(n.data)) continue;
1070
1107
  const el = n.parentElement;
1071
- if (el && vis(el) && PICK.test(norm(el.innerText))) heads.push(el);
1108
+ if (el && vis(el) && isHead(norm(el.innerText))) heads.push(el);
1072
1109
  }
1073
1110
  for (const h of heads) {
1074
1111
  if (h.closest('[__fbpw_thumb_row__]')) continue; // chính ô chọn-ảnh trong hàng
1112
+ if (h.closest('[__fbpw_thumb_pick__]')) continue; // chính nút "Chọn ảnh bìa"
1075
1113
  let box = h;
1076
1114
  for (let d = 0; d < 10 && box && box !== document.body; d++, box = box.parentElement) {
1077
1115
  const btns = [...box.querySelectorAll("[role='button'], button")].filter(vis);
@@ -1102,7 +1140,7 @@ async function findThumbPickerModal(page) {
1102
1140
  }
1103
1141
  }
1104
1142
  return null;
1105
- }, { pickSrc: THUMB_PICKER_TEXT_RE.source }).catch(() => null);
1143
+ }, { pickSrc: THUMB_PICKER_TEXT_RE.source, coverHeadSrc: COVER_HEAD_RE.source }).catch(() => null);
1106
1144
  }
1107
1145
 
1108
1146
  // Ảnh xem trước trong hộp thoại có đúng là ảnh bìa của MÌNH không? Thu cả hai
@@ -1195,13 +1233,18 @@ async function uploadViaThumbPickerModal(page, thumbPath, log, modal, { previewM
1195
1233
  while (Date.now() - t0 < previewMs) {
1196
1234
  await pause(page, 1500);
1197
1235
  m = await findThumbPickerModal(page);
1198
- if (!m) return { ok: true, via, reason: 'hộp thoại tự đóng sau khi nhận ảnh' };
1236
+ if (!m) return { ok: true, via, verified: false, reason: 'hộp thoại tự đóng sau khi nhận ảnh' };
1199
1237
  if (!m.preview || m.preview === beforePreview) continue;
1200
1238
  // URL đổi rồi — so NỘI DUNG. Lệch nhiều = FB đổi khung chứ chưa phải ảnh
1201
1239
  // của mình: chờ tiếp. Không so được (ảnh CDN khoá canvas) thì rơi về luật
1202
1240
  // đổi-URL, kèm ghi rõ vào log. Ảnh chưa nạp xong thì chờ nhịp sau.
1203
1241
  lastCmp = await previewMatchesThumb(page, thumbDataUrl);
1204
1242
  if (lastCmp.err === 'preview-not-loaded') continue;
1243
+ if (lastCmp.err === 'tainted') {
1244
+ // Ảnh xem trước từ CDN → tải về phía Node mà so, không đoán theo URL.
1245
+ const rm = await remoteImagesMatchThumb(page, [m.preview], thumbDataUrl);
1246
+ if (rm) lastCmp = { diff: rm.diff, via: 'cdn' };
1247
+ }
1205
1248
  if (lastCmp.diff !== undefined && lastCmp.diff > THUMB_PREVIEW_MAX_DIFF) {
1206
1249
  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`);
1207
1250
  loggedMismatch = true;
@@ -1232,23 +1275,203 @@ async function uploadViaThumbPickerModal(page, thumbPath, log, modal, { previewM
1232
1275
  while (Date.now() - t1 < closeMs) {
1233
1276
  await pause(page, 1500);
1234
1277
  if (await findThumbPickerModal(page)) { gone = 0; continue; }
1235
- if (++gone >= 2) return { ok: true, via };
1278
+ if (++gone >= 2) return { ok: true, via, verified: !!(lastCmp && lastCmp.diff !== undefined) };
1236
1279
  }
1237
1280
  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`);
1238
1281
  return { ok: false, reason: 'modal-not-closed' };
1239
1282
  }
1240
1283
 
1284
+ // Bấm nút/ô chọn ảnh bìa (đã gắn cờ __fbpw_thumb_pick__) rồi chờ MỘT trong
1285
+ // 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
1286
+ // thật 23/09). Trả { via, verified, failed, reason }: `via` rỗng = chưa đưa được
1287
+ // file vào (người gọi còn đường dự phòng); `failed` = hộp thoại đã mở mà dán hỏng.
1288
+ async function clickPickerAndUpload(page, thumbPath, log) {
1289
+ let chooser = null;
1290
+ const chooserP = page.waitForEvent('filechooser', { timeout: 8000 }).then((c) => { chooser = c; }).catch(() => {});
1291
+ let clickErr = '';
1292
+ await page.locator("[__fbpw_thumb_pick__='1']").first().click({ timeout: 5000 }).catch((e) => { clickErr = String(e && e.message || e).slice(0, 80); });
1293
+ let modal = null;
1294
+ const tClick = Date.now();
1295
+ while (!chooser && !modal && Date.now() - tClick < 8000) {
1296
+ await pause(page, 800);
1297
+ modal = await findThumbPickerModal(page);
1298
+ }
1299
+ void chooserP;
1300
+ if (chooser) {
1301
+ try {
1302
+ await chooser.setFiles(thumbPath);
1303
+ return { via: 'hộp chọn file', verified: false };
1304
+ } catch (e) {
1305
+ log('info', `[fb-pw] composer mới — hộp chọn file từ chối ảnh: ${String(e && e.message || e).slice(0, 80)}`);
1306
+ return { via: '' };
1307
+ }
1308
+ }
1309
+ if (modal) {
1310
+ const r = await uploadViaThumbPickerModal(page, thumbPath, log, modal);
1311
+ if (!r.ok) {
1312
+ log('warn', `[fb-pw] composer mới — dán qua hộp thoại chọn ảnh bìa KHÔNG xong (${r.reason})`);
1313
+ return { via: '', failed: true, reason: r.reason };
1314
+ }
1315
+ return { via: r.via + (r.reason ? ` (${r.reason})` : ''), verified: !!r.verified };
1316
+ }
1317
+ 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}` : ''}`);
1318
+ return { via: '' };
1319
+ }
1320
+
1321
+ // So ảnh nạp từ CDN của Facebook (khác nguồn → canvas bị khoá) với ảnh bìa:
1322
+ // tải bytes bằng page.request phía Node (không qua CORS), đưa lại vào trang
1323
+ // dưới dạng data: URL (cùng nguồn) rồi so 16x16 như previewMatchesThumb. Cần
1324
+ // cho lượt Gà Vườn 23/09 12:55Z: hộp thoại nhận ảnh xong, composer hiện ảnh bìa
1325
+ // bằng URL scontent → "đọc được 0 ảnh, khoá 4" → không so được → chặn oan.
1326
+ // `cache` (Map src→diff) để vòng chờ không tải lại cùng một ảnh mỗi nhịp.
1327
+ async function remoteImagesMatchThumb(page, srcs, thumbDataUrl, cache = new Map()) {
1328
+ let best = null;
1329
+ for (const src of [...new Set(srcs || [])].slice(0, 6)) {
1330
+ if (!/^https?:/i.test(src)) continue;
1331
+ let diff = cache.has(src) ? cache.get(src) : undefined;
1332
+ if (diff === undefined) {
1333
+ diff = null;
1334
+ try {
1335
+ const resp = await page.request.get(src, { timeout: 10_000 });
1336
+ const buf = resp.ok() ? await resp.body() : null;
1337
+ if (buf && buf.length && buf.length < 8 * 1024 * 1024) {
1338
+ const ct = String(resp.headers()['content-type'] || 'image/jpeg').split(';')[0];
1339
+ diff = await page.evaluate(async ({ a, b }) => {
1340
+ const load = (u) => new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => res(null); i.src = u; });
1341
+ const [ia, ib] = await Promise.all([load(a), load(b)]);
1342
+ if (!ia || !ib) return null;
1343
+ const sig = (img) => {
1344
+ const c = document.createElement('canvas');
1345
+ c.width = 16; c.height = 16;
1346
+ const x = c.getContext('2d');
1347
+ x.drawImage(img, 0, 0, 16, 16);
1348
+ return x.getImageData(0, 0, 16, 16).data;
1349
+ };
1350
+ const p = sig(ia), q = sig(ib);
1351
+ let d = 0;
1352
+ for (let k = 0; k < p.length; k += 4) d += Math.abs(p[k] - q[k]) + Math.abs(p[k + 1] - q[k + 1]) + Math.abs(p[k + 2] - q[k + 2]);
1353
+ return Math.round(d / (256 * 3));
1354
+ }, { a: `data:${ct};base64,${buf.toString('base64')}`, b: thumbDataUrl }).catch(() => null);
1355
+ }
1356
+ } catch { /* tải hỏng = không tính ảnh này */ }
1357
+ cache.set(src, diff);
1358
+ }
1359
+ if (diff !== null && diff !== undefined && (!best || diff < best.diff)) best = { diff, src: src.slice(0, 80) };
1360
+ }
1361
+ return best;
1362
+ }
1363
+
1364
+ // Ảnh bìa của MÌNH có đang hiện trong composer không? Quét mọi ảnh ≥40px trong
1365
+ // composer (dialog Reel + vùng main của trang tạo), trừ hộp thoại chọn ảnh, so
1366
+ // nội dung 16x16 với file ảnh bìa như previewMatchesThumb. Dùng cho biến thể nút
1367
+ // "Chọn ảnh bìa": không có hàng ô để so trước/sau, nên bằng chứng duy nhất đứng
1368
+ // vững là thấy chính ảnh đó. Ảnh CDN khác nguồn khoá canvas → không tính.
1369
+ async function composerShowsThumb(page, thumbDataUrl) {
1370
+ return page.evaluate(async ({ ours }) => {
1371
+ const vis = (el) => {
1372
+ const r = el.getBoundingClientRect();
1373
+ if (r.width < 8 || r.height < 8) return false;
1374
+ const cs = getComputedStyle(el);
1375
+ return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0';
1376
+ };
1377
+ const roots = [...document.querySelectorAll("[role='dialog']")].filter((d) => vis(d)
1378
+ && /thước phim|reel/i.test(`${d.getAttribute('aria-label') || ''}\n${(d.innerText || '').slice(0, 600)}`));
1379
+ const main = document.querySelector("[role='main'], main");
1380
+ if (main && vis(main)) roots.push(main);
1381
+ const sig = (img) => {
1382
+ const c = document.createElement('canvas');
1383
+ c.width = 16; c.height = 16;
1384
+ const x = c.getContext('2d');
1385
+ x.drawImage(img, 0, 0, 16, 16);
1386
+ return x.getImageData(0, 0, 16, 16).data;
1387
+ };
1388
+ const mine = await new Promise((res) => { const i = new Image(); i.onload = () => res(i); i.onerror = () => res(null); i.src = ours; });
1389
+ if (!mine) return { err: 'thumb-not-decodable' };
1390
+ const b = sig(mine);
1391
+ const seen = new Set();
1392
+ let readable = 0, locked = 0, best = null;
1393
+ const lockedSrcs = [];
1394
+ for (const root of roots) {
1395
+ for (const im of root.querySelectorAll('img')) {
1396
+ if (seen.has(im) || im.closest('[__fbpw_thumb_modal__]')) continue;
1397
+ seen.add(im);
1398
+ const r = im.getBoundingClientRect();
1399
+ if (r.width < 40 || r.height < 40 || !vis(im) || !im.complete || !im.naturalWidth) continue;
1400
+ let a;
1401
+ try { a = sig(im); } catch { locked++; lockedSrcs.push(String(im.currentSrc || im.src || '')); continue; }
1402
+ readable++;
1403
+ let d = 0;
1404
+ 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]);
1405
+ d = Math.round(d / (256 * 3));
1406
+ if (!best || d < best.diff) best = { diff: d, size: `${Math.round(r.width)}x${Math.round(r.height)}` };
1407
+ }
1408
+ }
1409
+ return { readable, locked, best, lockedSrcs: lockedSrcs.slice(0, 8) };
1410
+ }, { ours: thumbDataUrl }).catch((e) => ({ err: String(e && e.message || e).slice(0, 60) }));
1411
+ }
1412
+
1413
+ // Biến thể nút "Chọn ảnh bìa" (wizard, không có hàng ô). Chỉ nhận là đã dán khi:
1414
+ // 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
1415
+ // Lưu và đóng, hoặc
1416
+ // b) sau khi dán, composer hiện một ảnh giống ảnh bìa của mình (lệch ≤ ngưỡng)
1417
+ // hai nhịp liền, hoặc nút "Gỡ hình thu nhỏ tùy chỉnh" hiện.
1418
+ // Không thì trả không-dán, cổng trước Đăng chặn như mọi biến thể khác.
1419
+ async function applyCoverButtonThumb(page, thumbPath, log, snap, { verifyMs = 30_000 } = {}) {
1420
+ log('info', `[fb-pw] composer mới — ${describeThumbRow(snap)} → dán ảnh bìa qua nút này`);
1421
+ let thumbDataUrl = '';
1422
+ try { thumbDataUrl = `data:image/png;base64,${fs.readFileSync(thumbPath).toString('base64')}`; } catch { /* composerShowsThumb sẽ báo thumb-not-decodable */ }
1423
+ const base = await composerShowsThumb(page, thumbDataUrl);
1424
+ const up = await clickPickerAndUpload(page, thumbPath, log);
1425
+ if (up.failed || !up.via) {
1426
+ const d = await readNewComposerThumbRow(page, { diag: true });
1427
+ 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)}` : ''}`);
1428
+ return { applied: false, reason: up.reason || 'no-upload' };
1429
+ }
1430
+ if (up.verified) {
1431
+ 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`);
1432
+ return { applied: true, reason: 'hộp thoại xác nhận đúng ảnh' };
1433
+ }
1434
+ log('info', `[fb-pw] composer mới — đã đưa file ảnh bìa vào qua ${up.via}, tìm chính ảnh đó trong composer…`);
1435
+ let streak = 0;
1436
+ let scan = null;
1437
+ let remote = null;
1438
+ const remoteCache = new Map();
1439
+ const deadline = Date.now() + verifyMs;
1440
+ while (Date.now() < deadline) {
1441
+ await pause(page, 2500);
1442
+ if (await isCustomThumbApplied(page)) { if (++streak >= 2) break; continue; }
1443
+ scan = await composerShowsThumb(page, thumbDataUrl);
1444
+ if (scan.best && scan.best.diff <= THUMB_PREVIEW_MAX_DIFF) { if (++streak >= 2) break; continue; }
1445
+ // Ảnh CDN khoá canvas → tải về phía Node mà so (xem remoteImagesMatchThumb).
1446
+ if (scan.lockedSrcs && scan.lockedSrcs.length) {
1447
+ remote = await remoteImagesMatchThumb(page, scan.lockedSrcs, thumbDataUrl, remoteCache);
1448
+ if (remote && remote.diff <= THUMB_PREVIEW_MAX_DIFF) { if (++streak >= 2) break; continue; }
1449
+ }
1450
+ streak = 0;
1451
+ }
1452
+ 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})` : '—'}${x === scan && remote ? `, ảnh CDN tải về gần nhất lệch ${remote.diff}/255` : ''}` : `lỗi ${x && x.err}`);
1453
+ if (streak >= 2) {
1454
+ 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)})`);
1455
+ return { applied: true, reason: 'composer hiện đúng ảnh' };
1456
+ }
1457
+ const d = await readNewComposerThumbRow(page, { diag: true });
1458
+ 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)}` : ''}`);
1459
+ return { applied: false, reason: 'cover-not-shown' };
1460
+ }
1461
+
1241
1462
  // Dán ảnh bìa vào composer mới. Trả { applied, reason }. Không ném: thất bại
1242
1463
  // thì cờ customThumbDone không bật và cổng trước Đăng chặn như cũ.
1243
1464
  async function applyNewComposerThumb(page, thumbPath, log, { waitPickerMs = 20_000, verifyMs = 30_000 } = {}) {
1244
1465
  const t0 = Date.now();
1245
1466
  const ready = (s) => !!(s && s.found && s.pickerPresent);
1467
+ const coverMode = (s) => !!(s && s.mode === 'cover-button');
1246
1468
  let snap = await readNewComposerThumbRow(page);
1247
1469
  // Hàng ảnh bìa chỉ hiện sau khi FB cắt xong khung từ video.
1248
- while (!ready(snap) && Date.now() - t0 < waitPickerMs) {
1470
+ while (!ready(snap) && !coverMode(snap) && Date.now() - t0 < waitPickerMs) {
1249
1471
  await pause(page, 2500);
1250
1472
  snap = await readNewComposerThumbRow(page);
1251
1473
  }
1474
+ if (coverMode(snap)) return applyCoverButtonThumb(page, thumbPath, log, snap, { verifyMs });
1252
1475
  if (!ready(snap)) {
1253
1476
  const d = await readNewComposerThumbRow(page, { diag: true });
1254
1477
  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)}` : ''}`);
@@ -1267,34 +1490,9 @@ async function applyNewComposerThumb(page, thumbPath, log, { waitPickerMs = 20_0
1267
1490
  const before = snap;
1268
1491
  log('info', `[fb-pw] composer mới — hàng ảnh bìa trước khi dán: ${describeThumbRow(before)}`);
1269
1492
 
1270
- // Bấm ô chọn-ảnh rồi chờ MỘT trong hai: hộp chọn file (biến thể mở thẳng)
1271
- // hoặc hộp thoại "Chọn hình thu nhỏ" (biến thể đo thật 23/09).
1272
- let via = '';
1273
- let chooser = null;
1274
- const chooserP = page.waitForEvent('filechooser', { timeout: 8000 }).then((c) => { chooser = c; }).catch(() => {});
1275
- let clickErr = '';
1276
- await page.locator("[__fbpw_thumb_pick__='1']").first().click({ timeout: 5000 }).catch((e) => { clickErr = String(e && e.message || e).slice(0, 80); });
1277
- let modal = null;
1278
- const tClick = Date.now();
1279
- while (!chooser && !modal && Date.now() - tClick < 8000) {
1280
- await pause(page, 800);
1281
- modal = await findThumbPickerModal(page);
1282
- }
1283
- if (chooser) {
1284
- await chooser.setFiles(thumbPath).then(() => { via = 'hộp chọn file'; })
1285
- .catch((e) => log('info', `[fb-pw] composer mới — hộp chọn file từ chối ảnh: ${String(e && e.message || e).slice(0, 80)}`));
1286
- } else if (modal) {
1287
- const r = await uploadViaThumbPickerModal(page, thumbPath, log, modal);
1288
- if (!r.ok) {
1289
- const d = await readNewComposerThumbRow(page, { diag: true });
1290
- log('warn', `[fb-pw] composer mới — dán qua hộp thoại "Chọn hình thu nhỏ" KHÔNG xong (${r.reason}) — hàng ô: ${describeThumbRow(d)}`);
1291
- return { applied: false, reason: r.reason };
1292
- }
1293
- via = r.via + (r.reason ? ` (${r.reason})` : '');
1294
- } else {
1295
- log('info', `[fb-pw] composer mới — bấm ô "Chọn hình thu nhỏ" không mở hộp chọn file lẫn hộp thoại${clickErr ? `: ${clickErr}` : ''}`);
1296
- }
1297
- void chooserP;
1493
+ const up = await clickPickerAndUpload(page, thumbPath, log);
1494
+ let via = up.via;
1495
+ if (up.failed) return { applied: false, reason: up.reason };
1298
1496
  if (!via) {
1299
1497
  await readNewComposerThumbRow(page); // cú bấm có thể làm FB dựng lại DOM → gắn cờ lại
1300
1498
  const input = page.locator("[__fbpw_thumb_input__='1']").first();
@@ -1861,7 +2059,7 @@ async function runOnce({ page, payload, log }) {
1861
2059
  } = payload || {};
1862
2060
  if (!video_url) throw new Error('No video_url provided');
1863
2061
 
1864
- log('info', '[fb-pw] selectors version=2026.09.23-thumb-picker-modal');
2062
+ log('info', '[fb-pw] selectors version=2026.09.23-reel-overlay-cdn');
1865
2063
 
1866
2064
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
1867
2065
 
@@ -2730,7 +2928,16 @@ async function runOnce({ page, payload, log }) {
2730
2928
  // thu nhỏ" trong hàng khung, không có nút "Chỉnh sửa hình thu nhỏ". Xem
2731
2929
  // applyNewComposerThumb. Chạy ở mọi bước cho tới khi dán được: form một
2732
2930
  // trang chỉ có một bước, wizard có thể để hàng ô ở bước sau.
2733
- if (!customThumbDone && thumbPath && isNewComposerUrl(page.url())) {
2931
+ // Ở /reel/ (composer phủ lên feed Reels) hỏi DOM: có ô "Chọn hình thu
2932
+ // nhỏ"/nút "Chọn ảnh bìa" thì là composer mới, không thì khối cũ lo.
2933
+ const onReelOverlay = isReelOverlayUrl(page.url());
2934
+ let reelOverlayNew = false;
2935
+ if (!customThumbDone && thumbPath && onReelOverlay) {
2936
+ const probe = await readNewComposerThumbRow(page);
2937
+ reelOverlayNew = !!(probe && probe.pickerPresent);
2938
+ log('info', `[fb-pw] composer phủ trên /reel/ — ${reelOverlayNew ? 'có dấu hiệu composer mới → luồng mới' : 'composer kiểu cũ → khối ảnh bìa cũ'}`);
2939
+ }
2940
+ if (!customThumbDone && thumbPath && (isNewComposerUrl(page.url()) || reelOverlayNew)) {
2734
2941
  const nt = await applyNewComposerThumb(page, thumbPath, log);
2735
2942
  if (nt.applied) customThumbDone = true;
2736
2943
  }
@@ -2739,8 +2946,8 @@ async function runOnce({ page, payload, log }) {
2739
2946
  // video preview → opens "Chỉnh sửa hình thu nhỏ" modal → "Tải lên" →
2740
2947
  // file picker → "Lưu"). Per user screenshots, this is the only way to
2741
2948
  // attach a custom thumbnail in the Page-wall composer.
2742
- if (!customThumbDone && thumbPath && /^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url())) {
2743
- const findThumbBtn = () => page.evaluate(() => {
2949
+ if (!customThumbDone && thumbPath && (/^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url()) || (onReelOverlay && !reelOverlayNew))) {
2950
+ const findThumbBtn = () => page.evaluate((guardNew) => {
2744
2951
  // Look for the thumbnail-edit button inside the REEL COMPOSER dialog
2745
2952
  // only — scanning all dialogs risked matching a button on the
2746
2953
  // page-wall photo composer (which would post a separate "Tin dạng
@@ -2760,6 +2967,11 @@ async function runOnce({ page, payload, log }) {
2760
2967
  if (r.width < 8 || r.height < 8) continue;
2761
2968
  const cs = getComputedStyle(dlg);
2762
2969
  if (cs.visibility === 'hidden' || cs.display === 'none') continue;
2970
+ // Trên /reel/: composer MỚI có nút "Chỉnh sửa" riêng trên ô video —
2971
+ // bộ dò narrow-pill dưới đây sẽ bấm nhầm vào trình sửa video. Thấy
2972
+ // dấu hiệu composer mới thì không đụng (luồng mới sẽ lo ở bước sau).
2973
+ if (guardNew && (/Chọn hình thu nhỏ|File phương tiện đã tải lên/i.test(dlg.innerText || '')
2974
+ || dlg.querySelector("[aria-label='Chọn ảnh bìa']"))) continue;
2763
2975
  const cands = dlg.querySelectorAll("[role='button'], button, [aria-label]");
2764
2976
  for (const el of cands) {
2765
2977
  const t = (el.innerText || '').trim();
@@ -2782,7 +2994,7 @@ async function runOnce({ page, payload, log }) {
2782
2994
  }
2783
2995
  }
2784
2996
  return null;
2785
- }).catch(() => null);
2997
+ }, onReelOverlay).catch(() => null);
2786
2998
  let editBtn = await findThumbBtn();
2787
2999
  // The "Chỉnh sửa hình thu nhỏ" pill appears AFTER FB finishes processing
2788
3000
  // the video AND, on the Page-wall composer, only on a LATER step (the
@@ -3795,7 +4007,7 @@ async function runOnce({ page, payload, log }) {
3795
4007
  // any "Chia sẻ" text on the page falls through to a feed-item's share
3796
4008
  // button or sidebar nav, navigating AWAY from the composer and
3797
4009
  // breaking the flow (iter-38 ended up on /profile.php?sk=reels_tab).
3798
- const onPageWall = /^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url());
4010
+ const onPageWall = /^https?:\/\/(www\.)?facebook\.com\/?($|\?|#)/.test(page.url()) || isReelOverlayUrl(page.url());
3799
4011
  if (step >= 2 && !published && !onPageWall) {
3800
4012
  log('info', '[fb-pw] Tiếp ineffective — trying side-wizard jump to "Chia sẻ" step (BS only)');
3801
4013
  const jumpBbox = await page.evaluate(() => {
@@ -4340,6 +4552,13 @@ module.exports.__testables = {
4340
4552
  uploadViaThumbPickerModal,
4341
4553
  previewMatchesThumb,
4342
4554
  THUMB_PREVIEW_MAX_DIFF,
4555
+ COVER_BUTTON_RE,
4556
+ COVER_HEAD_RE,
4557
+ composerShowsThumb,
4558
+ applyCoverButtonThumb,
4559
+ remoteImagesMatchThumb,
4560
+ isReelOverlayUrl,
4561
+ clickPickerAndUpload,
4343
4562
  // Dùng chung cho comment_facebook: script nào trượt giữa chừng cũng cần chụp
4344
4563
  // lại màn hình, viết bản thứ hai là sớm muộn hai bên lệch nhau.
4345
4564
  dumpFailure,