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 +1 -1
- package/scripts/upload_facebook.js +121 -42
package/package.json
CHANGED
|
@@ -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
|
|
1259
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
1274
|
-
}).catch(() =>
|
|
1275
|
-
|
|
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
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
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
|
|
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
|
|
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
|
}
|