channel-worker 2.5.43 → 2.5.44
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 +176 -21
package/package.json
CHANGED
|
@@ -348,10 +348,17 @@ async function setVideoFile(page, inputHandle, filePath, log, tag = 'fb-pw') {
|
|
|
348
348
|
// throw no-advance. Poll until a bottom-half publish-verb button is present
|
|
349
349
|
// AND enabled. Returns true once enabled; false on timeout, or fast-false if no
|
|
350
350
|
// publish button ever appears (we're not actually on the final step).
|
|
351
|
-
async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw') {
|
|
351
|
+
async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw', { onPoll = null } = {}) {
|
|
352
352
|
const deadline = Date.now() + timeoutMs;
|
|
353
353
|
let announced = false, sawPresent = false, absent = 0;
|
|
354
354
|
while (Date.now() < deadline) {
|
|
355
|
+
// The final metadata form mounts lazily on some FB cohorts. The first
|
|
356
|
+
// fillMetadata() immediately after Tiếp can run before its textbox exists,
|
|
357
|
+
// leaving the form blank and Đăng disabled forever. Let the caller retry
|
|
358
|
+
// its idempotent metadata fill while we wait for video processing.
|
|
359
|
+
if (onPoll) await onPoll().catch((e) => {
|
|
360
|
+
log('warn', `[${tag}] final metadata retry failed: ${e.message.slice(0, 100)}`);
|
|
361
|
+
});
|
|
355
362
|
const st = await page.evaluate((vbs) => {
|
|
356
363
|
const dlgs = document.querySelectorAll("[role='dialog']");
|
|
357
364
|
const roots = dlgs.length ? Array.from(dlgs) : [document];
|
|
@@ -374,28 +381,53 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw')
|
|
|
374
381
|
}, verbs).catch(() => ({ present: false, enabled: false }));
|
|
375
382
|
if (st.enabled) {
|
|
376
383
|
if (announced) log('info', `[${tag}] "Đăng" is now enabled — video finished processing`);
|
|
377
|
-
return true;
|
|
384
|
+
return { enabled: true, sawPresent: true };
|
|
378
385
|
}
|
|
379
386
|
if (st.present) {
|
|
380
387
|
sawPresent = true;
|
|
381
388
|
if (!announced) { log('info', `[${tag}] final step: "Đăng" disabled (video still processing) — waiting up to ${Math.round(timeoutMs / 1000)}s…`); announced = true; }
|
|
382
389
|
} else if (!sawPresent && ++absent >= 4) {
|
|
383
|
-
return false; // no publish CTA after ~10s → not the final step
|
|
390
|
+
return { enabled: false, sawPresent: false }; // no publish CTA after ~10s → not the final step
|
|
384
391
|
}
|
|
385
392
|
await page.waitForTimeout(2500);
|
|
386
393
|
}
|
|
387
394
|
log('warn', `[${tag}] "Đăng" never became enabled within ${Math.round(timeoutMs / 1000)}s`);
|
|
388
|
-
return false;
|
|
395
|
+
return { enabled: false, sawPresent };
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const SAFE_RETRY_COMPOSER_CLOSED = 'FB_SAFE_RETRY_COMPOSER_CLOSED';
|
|
399
|
+
|
|
400
|
+
function safeComposerRetryError(message) {
|
|
401
|
+
const err = new Error(message);
|
|
402
|
+
err.code = SAFE_RETRY_COMPOSER_CLOSED;
|
|
403
|
+
return err;
|
|
389
404
|
}
|
|
390
405
|
|
|
391
|
-
async function
|
|
406
|
+
async function hasVisibleReelComposer(page) {
|
|
407
|
+
return page.evaluate(() => {
|
|
408
|
+
for (const dlg of document.querySelectorAll("[role='dialog']")) {
|
|
409
|
+
const r = dlg.getBoundingClientRect();
|
|
410
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
411
|
+
const cs = getComputedStyle(dlg);
|
|
412
|
+
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
|
|
413
|
+
const aria = dlg.getAttribute('aria-label') || '';
|
|
414
|
+
const text = (dlg.innerText || '').slice(0, 500);
|
|
415
|
+
const signature = `${aria}\n${text}`;
|
|
416
|
+
if (/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(signature)) continue;
|
|
417
|
+
if (/Tạo thước phim|Chỉnh sửa thước phim|Cài đặt thước phim|Create (?:a )?reel|Edit reel|Reel settings/i.test(signature)) return true;
|
|
418
|
+
}
|
|
419
|
+
return false;
|
|
420
|
+
}).catch(() => false);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
async function runOnce({ page, payload, log }) {
|
|
392
424
|
const {
|
|
393
425
|
video_url, title, description = '', tags = [],
|
|
394
426
|
visibility = 'public', format = 'short',
|
|
395
427
|
} = payload || {};
|
|
396
428
|
if (!video_url) throw new Error('No video_url provided');
|
|
397
429
|
|
|
398
|
-
log('info', '[fb-pw] selectors version=2026.08.
|
|
430
|
+
log('info', '[fb-pw] selectors version=2026.08.06-composer-retry-metadata-poll');
|
|
399
431
|
|
|
400
432
|
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
401
433
|
|
|
@@ -842,6 +874,13 @@ async function run({ page, payload, log }) {
|
|
|
842
874
|
"[role='textbox'][aria-placeholder*='Mô tả thước phim']",
|
|
843
875
|
"[role='textbox'][aria-placeholder*='Mô tả']",
|
|
844
876
|
"[role='textbox'][aria-placeholder*='Describe your reel']",
|
|
877
|
+
// Newer Lexical/contenteditable cohorts expose data-placeholder
|
|
878
|
+
// instead of aria-placeholder. Scope these to a dialog so a feed
|
|
879
|
+
// comment box can never receive the reel caption.
|
|
880
|
+
"[role='dialog'] [contenteditable='true'][data-placeholder*='Mô tả thước phim']",
|
|
881
|
+
"[role='dialog'] [contenteditable='true'][data-placeholder*='Mô tả']",
|
|
882
|
+
"[role='dialog'] [contenteditable='true'][data-placeholder*='Describe your reel']",
|
|
883
|
+
"[role='dialog'] [contenteditable='true'][aria-label*='Mô tả thước phim']",
|
|
845
884
|
// BS composer description — legacy.
|
|
846
885
|
"[role='textbox'][aria-label*='Mô tả']",
|
|
847
886
|
"[role='textbox'][aria-label*='hộp thoại']",
|
|
@@ -856,11 +895,61 @@ async function run({ page, payload, log }) {
|
|
|
856
895
|
const descText = (fbCaption || description || title || '').toString().slice(0, 2100);
|
|
857
896
|
if (!descText) break;
|
|
858
897
|
await f.type(descText, { delay: 12 });
|
|
898
|
+
// Blur commits the Lexical/React value and re-runs the form's
|
|
899
|
+
// validation. Without this, the text can be visible while Đăng
|
|
900
|
+
// remains disabled against the previous empty state.
|
|
901
|
+
await page.keyboard.press('Tab').catch(() => {});
|
|
859
902
|
fillState.description = true;
|
|
860
903
|
log('info', `[fb-pw] description filled (${descText.length} chars) via "${sel}"`);
|
|
861
904
|
break;
|
|
862
905
|
} catch (e) { log('info', `[fb-pw] desc via "${sel}" failed: ${e.message.slice(0, 80)}`); }
|
|
863
906
|
}
|
|
907
|
+
// Attribute shapes move frequently. Last-resort probe the visible Reel
|
|
908
|
+
// dialog for an editable whose combined accessibility signature names
|
|
909
|
+
// the description, tag it, and let Playwright type through the normal
|
|
910
|
+
// input path. This specifically covers the production cohort where the
|
|
911
|
+
// placeholder rendered after step 3 but none of the static selectors
|
|
912
|
+
// matched it on the first pass.
|
|
913
|
+
if (!fillState.description) {
|
|
914
|
+
const probe = await page.evaluate(() => {
|
|
915
|
+
document.querySelectorAll('[__fbpw_desc__]').forEach((el) => el.removeAttribute('__fbpw_desc__'));
|
|
916
|
+
const dialogs = [...document.querySelectorAll("[role='dialog']")];
|
|
917
|
+
for (const dlg of dialogs) {
|
|
918
|
+
const dr = dlg.getBoundingClientRect();
|
|
919
|
+
if (dr.width < 8 || dr.height < 8) continue;
|
|
920
|
+
const ds = `${dlg.getAttribute('aria-label') || ''}\n${(dlg.innerText || '').slice(0, 500)}`;
|
|
921
|
+
if (!/thước phim|reel/i.test(ds)) continue;
|
|
922
|
+
for (const el of dlg.querySelectorAll("[role='textbox'], [contenteditable='true'], textarea, input")) {
|
|
923
|
+
const r = el.getBoundingClientRect();
|
|
924
|
+
if (r.width < 8 || r.height < 8) continue;
|
|
925
|
+
const sig = [
|
|
926
|
+
el.getAttribute('aria-placeholder'), el.getAttribute('data-placeholder'),
|
|
927
|
+
el.getAttribute('placeholder'), el.getAttribute('aria-label'),
|
|
928
|
+
el.textContent,
|
|
929
|
+
].filter(Boolean).join('|');
|
|
930
|
+
if (!/Mô tả(?: thước phim)?|Describe (?:your )?reel/i.test(sig)) continue;
|
|
931
|
+
el.setAttribute('__fbpw_desc__', '1');
|
|
932
|
+
return "[__fbpw_desc__='1']";
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return null;
|
|
936
|
+
}).catch(() => null);
|
|
937
|
+
if (probe) {
|
|
938
|
+
try {
|
|
939
|
+
const f = page.locator(probe);
|
|
940
|
+
const descText = (fbCaption || description || title || '').toString().slice(0, 2100);
|
|
941
|
+
await f.click({ timeout: 3000 });
|
|
942
|
+
await f.type(descText, { delay: 12 });
|
|
943
|
+
await page.keyboard.press('Tab').catch(() => {});
|
|
944
|
+
fillState.description = true;
|
|
945
|
+
log('info', `[fb-pw] description filled (${descText.length} chars) via dynamic-probe`);
|
|
946
|
+
} catch (e) {
|
|
947
|
+
log('info', `[fb-pw] desc dynamic-probe failed: ${e.message.slice(0, 80)}`);
|
|
948
|
+
} finally {
|
|
949
|
+
await page.evaluate(() => document.querySelectorAll('[__fbpw_desc__]').forEach((el) => el.removeAttribute('__fbpw_desc__'))).catch(() => {});
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
864
953
|
}
|
|
865
954
|
|
|
866
955
|
// Tags (Thêm thẻ) — comma-separated; STRIP leading "#" since FB's tag
|
|
@@ -1169,15 +1258,32 @@ async function run({ page, payload, log }) {
|
|
|
1169
1258
|
// Wait for the thumb-edit modal to mount. Header text =
|
|
1170
1259
|
// "Chỉnh sửa hình thu nhỏ".
|
|
1171
1260
|
await page.evaluate(() => document.querySelectorAll("[__fbpw_thumb_edit__]").forEach((el) => el.removeAttribute('__fbpw_thumb_edit__'))).catch(() => {});
|
|
1261
|
+
const thumbDialogReady = await page.evaluate(() => {
|
|
1262
|
+
document.querySelectorAll('[__fbpw_thumb_dialog__]').forEach((el) => el.removeAttribute('__fbpw_thumb_dialog__'));
|
|
1263
|
+
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;
|
|
1268
|
+
const sig = `${dlg.getAttribute('aria-label') || ''}\n${(dlg.innerText || '').slice(0, 300)}`;
|
|
1269
|
+
if (!/Chỉnh sửa hình thu nhỏ|Edit thumbnail/i.test(sig)) continue;
|
|
1270
|
+
dlg.setAttribute('__fbpw_thumb_dialog__', '1');
|
|
1271
|
+
return true;
|
|
1272
|
+
}
|
|
1273
|
+
return false;
|
|
1274
|
+
}).catch(() => false);
|
|
1275
|
+
if (!thumbDialogReady) throw new Error('thumbnail editor dialog did not mount');
|
|
1172
1276
|
|
|
1173
1277
|
// Click "Tải lên" button — opens OS file picker. Use filechooser
|
|
1174
|
-
// race to inject the file path directly.
|
|
1278
|
+
// race to inject the file path directly. Scope every action to the
|
|
1279
|
+
// exact thumbnail dialog: Facebook can simultaneously keep its
|
|
1280
|
+
// Notifications dialog and the outer Reel composer in the DOM.
|
|
1175
1281
|
const uploadCandidates = [
|
|
1176
|
-
"[
|
|
1177
|
-
"[
|
|
1178
|
-
"[
|
|
1179
|
-
"[
|
|
1180
|
-
"[
|
|
1282
|
+
"[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Tải lên')",
|
|
1283
|
+
"[__fbpw_thumb_dialog__='1'] button:has-text('Tải lên')",
|
|
1284
|
+
"[__fbpw_thumb_dialog__='1'] [aria-label='Tải lên']",
|
|
1285
|
+
"[__fbpw_thumb_dialog__='1'] [aria-label*='Tải hình thu nhỏ']",
|
|
1286
|
+
"[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Upload')",
|
|
1181
1287
|
];
|
|
1182
1288
|
let uploaded = false;
|
|
1183
1289
|
for (const sel of uploadCandidates) {
|
|
@@ -1201,7 +1307,7 @@ async function run({ page, payload, log }) {
|
|
|
1201
1307
|
// Fallback — direct setInputFiles on a hidden image-accepting
|
|
1202
1308
|
// file input inside the modal.
|
|
1203
1309
|
if (!uploaded) {
|
|
1204
|
-
const directInput = page.locator("[
|
|
1310
|
+
const directInput = page.locator("[__fbpw_thumb_dialog__='1'] input[type='file'][accept*='image']").last();
|
|
1205
1311
|
if (await directInput.count().catch(() => 0) > 0) {
|
|
1206
1312
|
try {
|
|
1207
1313
|
await directInput.setInputFiles(thumbPath);
|
|
@@ -1217,10 +1323,10 @@ async function run({ page, payload, log }) {
|
|
|
1217
1323
|
if (uploaded) {
|
|
1218
1324
|
// Click "Lưu" to save the new thumbnail.
|
|
1219
1325
|
const saveCandidates = [
|
|
1220
|
-
"[
|
|
1221
|
-
"[
|
|
1222
|
-
"[
|
|
1223
|
-
"[
|
|
1326
|
+
"[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Lưu')",
|
|
1327
|
+
"[__fbpw_thumb_dialog__='1'] button:has-text('Lưu')",
|
|
1328
|
+
"[__fbpw_thumb_dialog__='1'] [aria-label='Lưu']",
|
|
1329
|
+
"[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Save')",
|
|
1224
1330
|
];
|
|
1225
1331
|
let saved = false;
|
|
1226
1332
|
for (const sel of saveCandidates) {
|
|
@@ -1280,13 +1386,21 @@ async function run({ page, payload, log }) {
|
|
|
1280
1386
|
await page.waitForTimeout(1000);
|
|
1281
1387
|
}
|
|
1282
1388
|
if (modalClosed) {
|
|
1389
|
+
// Closing the nested thumbnail editor is NOT enough. In the
|
|
1390
|
+
// 2026-08-05 failure Facebook unmounted the OUTER composer at
|
|
1391
|
+
// the same moment, leaving us on the Page feed. Retrying is
|
|
1392
|
+
// safe here because the publish branch has not run yet.
|
|
1393
|
+
if (!(await hasVisibleReelComposer(page))) {
|
|
1394
|
+
await dumpFailure(page, 'composer-closed-after-thumb-save', log);
|
|
1395
|
+
throw safeComposerRetryError('FB Reel composer closed after saving thumbnail (before publish)');
|
|
1396
|
+
}
|
|
1283
1397
|
thumbApplied = true;
|
|
1284
1398
|
log('info', `[fb-pw] page-wall thumb — modal closed after save (retries=${retryCount})`);
|
|
1285
1399
|
} else {
|
|
1286
1400
|
log('warn', '[fb-pw] page-wall thumb — modal still open 60s after Lưu click; trying ESC + click Hủy fallback');
|
|
1287
1401
|
await page.keyboard.press('Escape').catch(() => {});
|
|
1288
1402
|
await page.waitForTimeout(800);
|
|
1289
|
-
const cancel = await firstVisible(page.locator("[
|
|
1403
|
+
const cancel = await firstVisible(page.locator("[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Hủy'), [__fbpw_thumb_dialog__='1'] button:has-text('Hủy')"), 2);
|
|
1290
1404
|
if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
|
|
1291
1405
|
await page.waitForTimeout(1500);
|
|
1292
1406
|
}
|
|
@@ -1295,11 +1409,12 @@ async function run({ page, payload, log }) {
|
|
|
1295
1409
|
}
|
|
1296
1410
|
} else {
|
|
1297
1411
|
log('warn', '[fb-pw] page-wall thumb upload failed — closing modal via Hủy to continue');
|
|
1298
|
-
const cancel = await firstVisible(page.locator("[
|
|
1412
|
+
const cancel = await firstVisible(page.locator("[__fbpw_thumb_dialog__='1'] div[role='button']:has-text('Hủy'), [__fbpw_thumb_dialog__='1'] button:has-text('Hủy')"), 2);
|
|
1299
1413
|
if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
|
|
1300
1414
|
await page.waitForTimeout(1500);
|
|
1301
1415
|
}
|
|
1302
1416
|
} catch (e) {
|
|
1417
|
+
if (e?.code === SAFE_RETRY_COMPOSER_CLOSED) throw e;
|
|
1303
1418
|
log('warn', `[fb-pw] page-wall thumb flow failed: ${e.message.slice(0, 100)}`);
|
|
1304
1419
|
}
|
|
1305
1420
|
customThumbDone = thumbApplied;
|
|
@@ -1863,7 +1978,21 @@ async function run({ page, payload, log }) {
|
|
|
1863
1978
|
if (!pubWaitDone) {
|
|
1864
1979
|
pubWaitDone = true;
|
|
1865
1980
|
log('info', `[fb-pw] no "Tiếp" + no enabled publish at step ${step + 1} — waiting for "Đăng" to enable (large-video processing)…`);
|
|
1866
|
-
|
|
1981
|
+
const waitResult = await waitForPublishEnabled(page, publishVerbs, log, 180_000, 'fb-pw', {
|
|
1982
|
+
// Idempotent: once the field is filled, fillState makes this a
|
|
1983
|
+
// no-op. Until then it catches a lazily-mounted final form that
|
|
1984
|
+
// the first post-Tiếp pass raced past.
|
|
1985
|
+
onPoll: fillMetadata,
|
|
1986
|
+
});
|
|
1987
|
+
if (waitResult.enabled) { step--; continue; }
|
|
1988
|
+
await dumpInventory(page, log, `no-advance-${step + 1}`);
|
|
1989
|
+
await dumpFailure(page, `no-advance-${step + 1}`, log);
|
|
1990
|
+
if (!fillState.description) {
|
|
1991
|
+
throw new Error(`FB final description field not found at step ${step + 1}`);
|
|
1992
|
+
}
|
|
1993
|
+
if (waitResult.sawPresent) {
|
|
1994
|
+
throw new Error(`FB publish remained disabled for 180s at step ${step + 1}`);
|
|
1995
|
+
}
|
|
1867
1996
|
}
|
|
1868
1997
|
await dumpInventory(page, log, `no-advance-${step + 1}`);
|
|
1869
1998
|
await dumpFailure(page, `no-advance-${step + 1}`, log);
|
|
@@ -2336,7 +2465,33 @@ async function run({ page, payload, log }) {
|
|
|
2336
2465
|
}
|
|
2337
2466
|
}
|
|
2338
2467
|
|
|
2468
|
+
async function run(args) {
|
|
2469
|
+
const { page, log } = args;
|
|
2470
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
2471
|
+
try {
|
|
2472
|
+
return await runOnce(args);
|
|
2473
|
+
} catch (e) {
|
|
2474
|
+
const safeRetry = e?.code === SAFE_RETRY_COMPOSER_CLOSED;
|
|
2475
|
+
if (!safeRetry || attempt === 2) throw e;
|
|
2476
|
+
log('warn', `[fb-pw] composer vanished before publish — retrying the full upload once (${attempt}/1)`);
|
|
2477
|
+
// Force a clean React tree before runOnce returns to facebook.com and
|
|
2478
|
+
// opens a fresh composer. The first attempt's finally has already removed
|
|
2479
|
+
// its downloaded temp files; runOnce downloads clean copies on retry.
|
|
2480
|
+
await page.goto('about:blank', { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
|
|
2481
|
+
await page.waitForTimeout(1200);
|
|
2482
|
+
}
|
|
2483
|
+
}
|
|
2484
|
+
throw new Error('FB upload exhausted safe retries');
|
|
2485
|
+
}
|
|
2486
|
+
|
|
2339
2487
|
module.exports = { run };
|
|
2340
2488
|
// Exposed so the overlay-click escalation can be exercised against a synthetic
|
|
2341
2489
|
// page (toast covering the CTA) without driving the whole upload flow.
|
|
2342
|
-
module.exports.__testables = {
|
|
2490
|
+
module.exports.__testables = {
|
|
2491
|
+
resilientClick,
|
|
2492
|
+
muteClickInterceptors,
|
|
2493
|
+
unmuteClickInterceptors,
|
|
2494
|
+
waitForPublishEnabled,
|
|
2495
|
+
hasVisibleReelComposer,
|
|
2496
|
+
SAFE_RETRY_COMPOSER_CLOSED,
|
|
2497
|
+
};
|