channel-worker 2.5.52 → 2.5.54

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.52",
3
+ "version": "2.5.54",
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": {
@@ -1255,24 +1255,58 @@ async function runOnce({ page, payload, log }) {
1255
1255
  await page.locator(editBtn.selector).click({ timeout: 5000 });
1256
1256
  await page.waitForTimeout(2500);
1257
1257
 
1258
- // Wait for the thumb-edit modal to mount. Header text =
1259
- // "Chỉnh sửa hình thu nhỏ".
1258
+ // Wait for the thumb editor to mount. FB serves (A/B, seen
1259
+ // 2026-08-19) TWO variants and any given account can get either:
1260
+ // modal — a separate [role='dialog'] titled "Chỉnh sửa hình
1261
+ // thu nhỏ" (the original form).
1262
+ // nested — the editor renders INSIDE the reel-composer dialog
1263
+ // ("Cài đặt thước phim"): no own dialog, and the
1264
+ // header sits too deep in innerText for a 300-char
1265
+ // signature check. Recognize it by its upload button
1266
+ // (aria "Tải hình thu nhỏ tùy chỉnh lên…"), which only
1267
+ // exists while the editor is open, and tag the nearest
1268
+ // container that also holds Lưu + Hủy so the scoped
1269
+ // selectors below keep working for both variants.
1270
+ // Check modal FIRST so old-UI accounts behave exactly as before.
1260
1271
  await page.evaluate(() => document.querySelectorAll("[__fbpw_thumb_edit__]").forEach((el) => el.removeAttribute('__fbpw_thumb_edit__'))).catch(() => {});
1261
- const thumbDialogReady = await page.evaluate(() => {
1272
+ const detectThumbEditor = () => page.evaluate(() => {
1262
1273
  document.querySelectorAll('[__fbpw_thumb_dialog__]').forEach((el) => el.removeAttribute('__fbpw_thumb_dialog__'));
1274
+ const vis = (el) => {
1275
+ const r = el.getBoundingClientRect();
1276
+ if (r.width < 8 || r.height < 8) return false;
1277
+ const cs = getComputedStyle(el);
1278
+ return cs.visibility !== 'hidden' && cs.display !== 'none' && cs.opacity !== '0';
1279
+ };
1263
1280
  for (const dlg of document.querySelectorAll("[role='dialog']")) {
1264
- const r = dlg.getBoundingClientRect();
1265
- if (r.width < 8 || r.height < 8) continue;
1266
- const cs = getComputedStyle(dlg);
1267
- if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
1281
+ if (!vis(dlg)) continue;
1268
1282
  const sig = `${dlg.getAttribute('aria-label') || ''}\n${(dlg.innerText || '').slice(0, 300)}`;
1269
1283
  if (!/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(sig)) continue;
1270
1284
  dlg.setAttribute('__fbpw_thumb_dialog__', '1');
1271
- return true;
1285
+ return 'modal';
1286
+ }
1287
+ const uploadBtn = [...document.querySelectorAll('[aria-label]')].find((el) => {
1288
+ const al = el.getAttribute('aria-label') || '';
1289
+ return /Tải hình thu nhỏ|thumbnail/i.test(al) && /Tải|upload/i.test(al) && vis(el);
1290
+ });
1291
+ if (uploadBtn) {
1292
+ const hasBtn = (root, re) => [...root.querySelectorAll("[role='button'], button")]
1293
+ .some((b) => re.test((b.innerText || '').trim()) && vis(b));
1294
+ let box = uploadBtn.parentElement;
1295
+ while (box && box !== document.body && !(hasBtn(box, /^(Lưu|Save)$/) && hasBtn(box, /^(Hủy|Cancel)$/))) box = box.parentElement;
1296
+ if (box === document.body) box = null;
1297
+ (box || uploadBtn.closest("[role='dialog']") || document.body).setAttribute('__fbpw_thumb_dialog__', '1');
1298
+ return 'nested';
1272
1299
  }
1273
- return false;
1274
- }).catch(() => false);
1275
- if (!thumbDialogReady) throw new Error('thumbnail editor dialog did not mount');
1300
+ return null;
1301
+ }).catch(() => null);
1302
+ let thumbEditorVia = await detectThumbEditor();
1303
+ const mountDeadline = Date.now() + 12_000;
1304
+ while (!thumbEditorVia && Date.now() < mountDeadline) {
1305
+ await page.waitForTimeout(2000);
1306
+ thumbEditorVia = await detectThumbEditor();
1307
+ }
1308
+ if (!thumbEditorVia) throw new Error('thumbnail editor dialog did not mount');
1309
+ log('info', `[fb-pw] page-wall thumb — editor mounted (variant=${thumbEditorVia})`);
1276
1310
 
1277
1311
  // Click "Tải lên" button — opens OS file picker. Use filechooser
1278
1312
  // race to inject the file path directly. Scope every action to the
@@ -1355,6 +1389,11 @@ async function runOnce({ page, payload, log }) {
1355
1389
  const RETRY_AFTER_MS = 12_000;
1356
1390
  let lastRetryAt = Date.now();
1357
1391
  while (Date.now() < closeDeadline) {
1392
+ // "Editor still open" must cover both variants: the modal
1393
+ // form (dialog whose text leads with the header) and the
1394
+ // nested form (no own dialog — its upload button, aria
1395
+ // "Tải hình thu nhỏ tùy chỉnh lên…", only exists while the
1396
+ // editor is showing).
1358
1397
  const still = await page.evaluate(() => {
1359
1398
  const dlgs = document.querySelectorAll("[role='dialog']");
1360
1399
  for (const dlg of dlgs) {
@@ -1364,6 +1403,15 @@ async function runOnce({ page, payload, log }) {
1364
1403
  if (r.width > 8 && r.height > 8) return true;
1365
1404
  }
1366
1405
  }
1406
+ for (const el of document.querySelectorAll('[aria-label]')) {
1407
+ const al = el.getAttribute('aria-label') || '';
1408
+ if (!(/Tải hình thu nhỏ|thumbnail/i.test(al) && /Tải|upload/i.test(al))) continue;
1409
+ const r = el.getBoundingClientRect();
1410
+ if (r.width < 8 || r.height < 8) continue;
1411
+ const cs = getComputedStyle(el);
1412
+ if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
1413
+ return true;
1414
+ }
1367
1415
  return false;
1368
1416
  }).catch(() => false);
1369
1417
  if (!still) { modalClosed = true; break; }
@@ -2184,6 +2232,30 @@ async function runOnce({ page, payload, log }) {
2184
2232
  // - Page wall display order isn't strictly chronological
2185
2233
  // - "Quảng bá thước phim" appears on ALL Page reels, not just new
2186
2234
  // - Title match alone can't distinguish same-title duplicates
2235
+ // Fresh-tile scrape, dùng ở lượt đầu VÀ lượt retry (a.2b) — cùng một luật:
2236
+ // chỉ nhận tile có timestamp tươi ("Vừa xong"/"X phút"), tuyệt đối không
2237
+ // nhận tile cũ.
2238
+ const scrapeFreshReelTile = () => page.evaluate(() => {
2239
+ const FRESH_RE = /vừa xong|vài giây|^\s*\d{1,2}\s*giây|^\s*[1-5]\s*phút\b|\b[1-5]\s*phút trước|just now|few seconds ago|\b[1-5] min(ute)?s? ago/i;
2240
+ const anchors = document.querySelectorAll("a[href*='/reel/']");
2241
+ for (const a of anchors) {
2242
+ const href = a.getAttribute('href') || '';
2243
+ const m = href.match(/\/reel\/(\d{8,18})/);
2244
+ if (!m) continue;
2245
+ const r = a.getBoundingClientRect();
2246
+ if (r.width < 8 || r.height < 8) continue;
2247
+ let ctx = a;
2248
+ for (let depth = 0; depth < 5 && ctx; depth++) {
2249
+ const raw = (ctx.innerText || ctx.textContent || '').slice(0, 500);
2250
+ if (FRESH_RE.test(raw)) {
2251
+ return { href, id: m[1], depth, timestamp: (raw.match(FRESH_RE) || [''])[0] };
2252
+ }
2253
+ ctx = ctx.parentElement;
2254
+ }
2255
+ }
2256
+ return null;
2257
+ }).catch(() => null);
2258
+ let reelsTabUrl = null; // giữ lại cho lượt retry (a.2b)
2187
2259
  try {
2188
2260
  // Find a link to the page's reels tab. Multi-strategy:
2189
2261
  // 1. Any existing href containing "sk=reels_tab"
@@ -2224,35 +2296,14 @@ async function runOnce({ page, payload, log }) {
2224
2296
  return null;
2225
2297
  }).catch(() => null);
2226
2298
  if (reelsTabHref) {
2299
+ reelsTabUrl = reelsTabHref;
2227
2300
  log('info', `[fb-pw] navigating to page reels tab: ${reelsTabHref.slice(0, 100)}`);
2228
2301
  await page.goto(reelsTabHref, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
2229
2302
  await page.waitForTimeout(5000);
2230
- const fresh = await page.evaluate(() => {
2231
- const norm = (s) => (s || '').toLowerCase().normalize('NFD').replace(/\p{Diacritic}/gu, '');
2232
- const FRESH_RE = /vừa xong|vài giây|^\s*\d{1,2}\s*giây|^\s*[1-5]\s*phút\b|\b[1-5]\s*phút trước|just now|few seconds ago|\b[1-5] min(ute)?s? ago/i;
2233
- // Reel tiles on the reels tab — each is an <a href="/reel/<id>/">
2234
- // wrapping a thumbnail + meta. Iterate in DOM order (newest first
2235
- // on reels tab). For each tile, check if its subtree has a fresh
2236
- // timestamp.
2237
- const anchors = document.querySelectorAll("a[href*='/reel/']");
2238
- for (const a of anchors) {
2239
- const href = a.getAttribute('href') || '';
2240
- const m = href.match(/\/reel\/(\d{8,18})/);
2241
- if (!m) continue;
2242
- const r = a.getBoundingClientRect();
2243
- if (r.width < 8 || r.height < 8) continue;
2244
- // Look within the anchor's subtree + closest meaningful ancestor.
2245
- let ctx = a;
2246
- for (let depth = 0; depth < 5 && ctx; depth++) {
2247
- const raw = (ctx.innerText || ctx.textContent || '').slice(0, 500);
2248
- if (FRESH_RE.test(raw)) {
2249
- return { href, id: m[1], depth, timestamp: (raw.match(FRESH_RE) || [''])[0] };
2250
- }
2251
- ctx = ctx.parentElement;
2252
- }
2253
- }
2254
- return null;
2255
- }).catch(() => null);
2303
+ // Reel tiles on the reels tab — each is an <a href="/reel/<id>/">
2304
+ // wrapping a thumbnail + meta, DOM order = newest first. Only a tile
2305
+ // with a FRESH timestamp counts (see scrapeFreshReelTile).
2306
+ const fresh = await scrapeFreshReelTile();
2256
2307
  if (fresh) {
2257
2308
  const full = fresh.href.startsWith('http') ? fresh.href : `https://www.facebook.com${fresh.href}`;
2258
2309
  postUrl = full;
@@ -2374,24 +2425,52 @@ async function runOnce({ page, payload, log }) {
2374
2425
  }
2375
2426
  }
2376
2427
 
2428
+ // (a.2b) RETRY reels_tab sau khi chờ — reel mới thường cần 1-3 phút xử lý
2429
+ // phía FB rồi mới hiện lên tab, nên lượt scrape đầu (chạy ngay sau
2430
+ // khi bấm Đăng) hay trượt và mọi thứ rơi xuống last-resort bốc nhầm
2431
+ // reel cũ (4 lần gần nhất 2026-08-19 đều thế). Một lượt chờ 75s +
2432
+ // re-scrape cứu được phần lớn ca đó, vẫn giữ luật fresh-timestamp
2433
+ // nên không thể nhận nhầm tile cũ.
2434
+ if (!postUrl && reelsTabUrl) {
2435
+ log('info', '[fb-pw] no post URL yet — waiting 75s for FB to surface the new reel on reels_tab, then re-scraping…');
2436
+ await page.waitForTimeout(75_000);
2437
+ await page.goto(reelsTabUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
2438
+ await page.waitForTimeout(5000);
2439
+ const fresh2 = await scrapeFreshReelTile();
2440
+ if (fresh2) {
2441
+ postUrl = fresh2.href.startsWith('http') ? fresh2.href : `https://www.facebook.com${fresh2.href}`;
2442
+ log('info', `[fb-pw] post URL from reels_tab RETRY (id=${fresh2.id}, timestamp="${fresh2.timestamp}"): ${postUrl}`);
2443
+ } else {
2444
+ log('warn', '[fb-pw] reels_tab retry: still no fresh tile');
2445
+ }
2446
+ }
2447
+
2377
2448
  // (a.3) LAST-RESORT: first reel tile on the profile reels tab. UNRELIABLE —
2378
2449
  // the just-published reel may not be on the tab yet (still
2379
2450
  // processing), so the first tile can be a STALE older reel. Only used
2380
2451
  // when every authoritative source above (fresh-timestamp tile,
2381
- // title-match, inline CTA, network capture) returned nothing.
2452
+ // title-match, inline CTA, network capture, timed retry) returned
2453
+ // nothing. Provably-stale filter: mọi reel ID đã bắt được TRƯỚC khi
2454
+ // bấm Đăng là reel có sẵn trên page — reel mới không thể là chúng.
2455
+ // Thà post_url rỗng còn hơn ghi sai (2026-08-19: nhánh này ghi reel
2456
+ // 02/08 cho bài vừa đăng → link trong DB trỏ nhầm bài cũ).
2382
2457
  if (!postUrl) {
2383
- const tileHref = await page.evaluate(() => {
2458
+ const preIds = capturedReelIds.slice(0, capturedReelIdsSnapshotLen);
2459
+ const tileHref = await page.evaluate((staleIds) => {
2460
+ const staleSet = new Set(staleIds);
2384
2461
  const anchors = document.querySelectorAll("a[role='link']");
2385
2462
  for (const a of anchors) {
2386
2463
  const aria = (a.getAttribute('aria-label') || '').toLowerCase();
2387
2464
  if (!/bản xem trước ô thước phim|reel tile preview|reel preview/.test(aria)) continue;
2388
2465
  const href = a.getAttribute('href') || '';
2389
2466
  const m = href.match(/\/reel\/(\d{8,20})/);
2390
- if (m) return { href, aria: aria.slice(0, 60) };
2467
+ if (m) return { href, aria: aria.slice(0, 60), id: m[1], provablyStale: staleSet.has(m[1]) };
2391
2468
  }
2392
2469
  return null;
2393
- }).catch(() => null);
2394
- if (tileHref) {
2470
+ }, preIds).catch(() => null);
2471
+ if (tileHref && tileHref.provablyStale) {
2472
+ log('warn', `[fb-pw] LAST-RESORT tile ${tileHref.id} was already captured PRE-publish — provably an OLD reel; leaving post_url empty instead of recording a wrong one`);
2473
+ } else if (tileHref) {
2395
2474
  postUrl = tileHref.href.startsWith('http') ? tileHref.href : `https://www.facebook.com${tileHref.href}`;
2396
2475
  log('warn', `[fb-pw] post URL from FIRST reel tile on reels_tab (LAST-RESORT, may be STALE): ${postUrl}`);
2397
2476
  }