channel-worker 2.5.56 → 2.5.57

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.
@@ -16,6 +16,7 @@
16
16
  // surfaces the exact problem.
17
17
 
18
18
  const fs = require('fs');
19
+ const { humanMove, humanClick, humanType, humanWheel, pause, randInt: hRandInt, chance: hChance } = require('./lib/human');
19
20
  const path = require('path');
20
21
  const { downloadToTemp, safeUnlink } = require('./lib/download');
21
22
 
@@ -42,7 +43,7 @@ async function waitAndClick(loc, { timeoutMs = 60_000, log, label = 'button', cl
42
43
  await loc.click(clickOpts || undefined);
43
44
  return;
44
45
  }
45
- await loc.page().waitForTimeout(800);
46
+ await loc.page().waitForTimeout(hRandInt(560, 1120));
46
47
  }
47
48
  throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
48
49
  }
@@ -110,11 +111,21 @@ async function unmuteClickInterceptors(page) {
110
111
  // for "the UI already moved on" so we don't fire a second click on top of it.
111
112
  async function resilientClick(page, loc, selector, { timeout = 10_000, log, label = 'button', jsFallback = true, attempts = 3, landedCheck = null } = {}) {
112
113
  try { await loc.scrollIntoViewIfNeeded({ timeout: 3000 }); } catch {}
113
- await page.mouse.move(2, 2).catch(() => {});
114
+ // Park the cursor in a corner first — travelled there, not teleported to
115
+ // (2,2) — so no hover-card from the feed behind the modal sits on the CTA.
116
+ await humanMove(page, hRandInt(6, 70), hRandInt(6, 70)).catch(() => {});
114
117
  let lastErr = null;
115
118
  for (let attempt = 0; attempt < attempts; attempt++) {
116
119
  try {
117
- await loc.click({ timeout });
120
+ if (attempt === 0) {
121
+ // First try: human-shaped click (cursor travels, lands off-centre).
122
+ // Visibility is checked first so a not-yet-rendered button falls
123
+ // through to the actionability-checked native click below.
124
+ await loc.waitFor({ state: 'visible', timeout: Math.min(timeout, 5000) });
125
+ await humanClick(page, loc, { timeout, hoverMs: [150, 500] });
126
+ } else {
127
+ await loc.click({ timeout });
128
+ }
118
129
  await unmuteClickInterceptors(page);
119
130
  return 'native';
120
131
  } catch (e) {
@@ -126,7 +137,7 @@ async function resilientClick(page, loc, selector, { timeout = 10_000, log, labe
126
137
  }
127
138
  const muted = await muteClickInterceptors(page, selector);
128
139
  if (log) log('warn', `[fb-pw] ${label} click attempt ${attempt + 1}/${attempts} failed (${e.message.split('\n')[0].slice(0, 70)}) — muted ${muted} overlay layer(s)`);
129
- await page.waitForTimeout(500);
140
+ await pause(page, 500);
130
141
  }
131
142
  }
132
143
  if (jsFallback) {
@@ -275,7 +286,7 @@ async function dismissBsOnboarding(page, log) {
275
286
  try {
276
287
  log('info', `[fb-pw] dismissing BS onboarding via "${sel}" (dlg="${dlgInfo.preview}")`);
277
288
  await hit.click({ timeout: 2500 });
278
- await page.waitForTimeout(700);
289
+ await pause(page, 700);
279
290
  clicked = true;
280
291
  break;
281
292
  } catch {}
@@ -389,7 +400,7 @@ async function waitForPublishEnabled(page, verbs, log, timeoutMs, tag = 'fb-pw',
389
400
  } else if (!sawPresent && ++absent >= 4) {
390
401
  return { enabled: false, sawPresent: false }; // no publish CTA after ~10s → not the final step
391
402
  }
392
- await page.waitForTimeout(2500);
403
+ await pause(page, 2500);
393
404
  }
394
405
  log('warn', `[${tag}] "Đăng" never became enabled within ${Math.round(timeoutMs / 1000)}s`);
395
406
  return { enabled: false, sawPresent };
@@ -502,9 +513,31 @@ async function runOnce({ page, payload, log }) {
502
513
  log('info', '[fb-pw] open https://www.facebook.com/ (page wall flow) …');
503
514
  await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
504
515
  await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
505
- await page.waitForTimeout(4000);
516
+ await pause(page, 4000);
506
517
  log('info', `[fb-pw] home loaded — url=${page.url()}`);
507
518
 
519
+ // Look around before posting. The old flow was goto → 4s → straight into
520
+ // "Thước phim", every single run: a session whose only actions are
521
+ // open→post→leave. A person glances at the wall first. 2-5 wheel bursts
522
+ // with reading pauses, then back up to the composer (it sits at the top).
523
+ if (hChance(0.85)) {
524
+ const vp = await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight })).catch(() => null);
525
+ if (vp) await humanMove(page, Math.floor(vp.w * (0.35 + Math.random() * 0.3)), Math.floor(vp.h * (0.4 + Math.random() * 0.3)));
526
+ const bursts = hRandInt(2, 5);
527
+ let down = 0;
528
+ for (let i = 0; i < bursts; i++) {
529
+ const dy = hRandInt(300, 800);
530
+ down += await humanWheel(page, dy);
531
+ await page.waitForTimeout(hRandInt(1800, 6000));
532
+ if (hChance(0.2)) { down += await humanWheel(page, -hRandInt(120, 300)); await page.waitForTimeout(hRandInt(800, 2200)); }
533
+ }
534
+ // Back to the top in a few bursts (not one jump).
535
+ while (down > 0) { down += await humanWheel(page, -Math.min(down, hRandInt(500, 1100))); await page.waitForTimeout(hRandInt(250, 700)); }
536
+ await page.evaluate(() => { if (window.scrollY > 0) window.scrollTo({ top: 0, behavior: 'smooth' }); }).catch(() => {});
537
+ await page.waitForTimeout(hRandInt(800, 2000));
538
+ log('info', `[fb-pw] browsed the wall for a bit (${bursts} scrolls) before opening the composer`);
539
+ }
540
+
508
541
  // 2) Click the "Thước phim" entry inside the Page's "Tạo bài viết" widget.
509
542
  // This opens a FRESH Reel composer modal each time (no stale-draft
510
543
  // issue that plagued the BS composer). CRITICAL: scope to the "Tạo
@@ -525,7 +558,7 @@ async function runOnce({ page, payload, log }) {
525
558
  const btn = await firstVisible(page.locator(sel), 5);
526
559
  if (!btn) continue;
527
560
  try {
528
- await btn.click({ timeout: 3000 });
561
+ await humanClick(page, btn, { timeout: 3000 });
529
562
  log('info', `[fb-pw] Thước phim clicked via "${sel}"`);
530
563
  reelOpened = true;
531
564
  break;
@@ -569,7 +602,7 @@ async function runOnce({ page, payload, log }) {
569
602
  await dumpFailure(page, 'no-reel-entry', log);
570
603
  throw new Error('FB home: "Thước phim" entry not found in "Tạo bài viết" widget (sidebar Reels feed link doesn\'t count — it navigates instead of opening composer)');
571
604
  }
572
- await page.waitForTimeout(4000);
605
+ await pause(page, 4000);
573
606
  const afterUrl = page.url();
574
607
  log('info', `[fb-pw] after Thước phim click — url=${afterUrl}`);
575
608
  // Sanity check: clicking the sidebar Reels link navigates to /reel/<id>.
@@ -614,7 +647,7 @@ async function runOnce({ page, payload, log }) {
614
647
  }
615
648
  } catch (e) { log('warn', `[fb-pw] fallback Reels-link click failed: ${e.message.slice(0, 80)}`); }
616
649
  if (xpClicked) {
617
- await page.waitForTimeout(5000);
650
+ await pause(page, 5000);
618
651
  await dismissBsOnboarding(page, log).catch(() => {});
619
652
  }
620
653
  if (!(await reelsDialog.isVisible({ timeout: 8000 }).catch(() => false))) {
@@ -685,7 +718,7 @@ async function runOnce({ page, payload, log }) {
685
718
  log('info', '[fb-pw] waiting for FB to finish processing the video…');
686
719
  const procT0 = Date.now();
687
720
  const procDeadline = procT0 + 120_000;
688
- await page.waitForTimeout(2500); // let the upload register first
721
+ await pause(page, 2500); // let the upload register first
689
722
  let procReady = false;
690
723
  while (Date.now() < procDeadline) {
691
724
  procReady = await page.evaluate(() => {
@@ -707,7 +740,7 @@ async function runOnce({ page, payload, log }) {
707
740
  return false;
708
741
  }).catch(() => false);
709
742
  if (procReady) break;
710
- await page.waitForTimeout(2500);
743
+ await pause(page, 2500);
711
744
  }
712
745
  if (procReady) log('info', `[fb-pw] video processed in ~${Math.round((Date.now() - procT0) / 1000)}s — caption step ready`);
713
746
  else log('warn', '[fb-pw] processing wait hit 120s cap — proceeding (Tiếp click auto-waits anyway)');
@@ -770,7 +803,7 @@ async function runOnce({ page, payload, log }) {
770
803
  }).catch(() => false);
771
804
  if (variantBDeleted) {
772
805
  log('info', '[fb-pw] removed stale empty variant-B title row');
773
- await page.waitForTimeout(800);
806
+ await pause(page, 800);
774
807
  }
775
808
  const titleSels = [
776
809
  // PAGE-WALL form 1 — "Bạn đang nghĩ gì?" caption textbox is the
@@ -799,11 +832,11 @@ async function runOnce({ page, payload, log }) {
799
832
  try {
800
833
  await f.click({ timeout: 3000 });
801
834
  const tag = await f.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
802
- if (tag === 'input' || tag === 'textarea') {
803
- await f.fill(clippedTitle);
804
- } else {
805
- await f.type(clippedTitle, { delay: 12 });
806
- }
835
+ // Typed with a human cadence whatever the element is (fill() set
836
+ // the value in one synthetic step). typo:0 — the title must land
837
+ // exactly as written. Focus came from the click above.
838
+ void tag;
839
+ await humanType(page, clippedTitle, { typo: 0 });
807
840
  // Blur to commit (FB's React validation re-renders on blur).
808
841
  await page.keyboard.press('Tab').catch(() => {});
809
842
  fillState.title = true;
@@ -839,15 +872,16 @@ async function runOnce({ page, payload, log }) {
839
872
  if (probe) {
840
873
  log('info', `[fb-pw] title dynamic-probe found <${probe.tag}> attrs="${probe.attrs.slice(0, 80)}"`);
841
874
  try {
842
- await page.mouse.click(probe.bbox.x, probe.bbox.y);
843
- await page.waitForTimeout(300);
875
+ await humanMove(page, probe.bbox.x + hRandInt(-8, 8), probe.bbox.y + hRandInt(-4, 4));
876
+ await page.mouse.down(); await page.waitForTimeout(hRandInt(55, 130)); await page.mouse.up();
877
+ await pause(page, 300);
844
878
  // FB Reels title cap is 80 chars (BS composer shows red invalid
845
879
  // state above that and blocks Tiếp). Be conservative — clip with
846
880
  // ellipsis suffix so the AI-generated hook is preserved at front.
847
881
  const clipped = String(title).length > 80
848
882
  ? String(title).slice(0, 79) + '…'
849
883
  : String(title);
850
- await page.keyboard.type(clipped, { delay: 12 });
884
+ await humanType(page, clipped, { typo: 0 });
851
885
  // Tab away to blur — commits the value and triggers React's
852
886
  // validation re-render, otherwise the form keeps the invalid
853
887
  // state from a previous typing session.
@@ -894,7 +928,7 @@ async function runOnce({ page, payload, log }) {
894
928
  await f.click({ timeout: 3000 });
895
929
  const descText = (fbCaption || description || title || '').toString().slice(0, 2100);
896
930
  if (!descText) break;
897
- await f.type(descText, { delay: 12 });
931
+ await humanType(page, descText, { typo: 0, base: descText.length > 600 ? 60 : 95 }); // long captions: faster cadence, still keystrokes
898
932
  // Blur commits the Lexical/React value and re-runs the form's
899
933
  // validation. Without this, the text can be visible while Đăng
900
934
  // remains disabled against the previous empty state.
@@ -939,7 +973,7 @@ async function runOnce({ page, payload, log }) {
939
973
  const f = page.locator(probe);
940
974
  const descText = (fbCaption || description || title || '').toString().slice(0, 2100);
941
975
  await f.click({ timeout: 3000 });
942
- await f.type(descText, { delay: 12 });
976
+ await humanType(page, descText, { typo: 0, base: descText.length > 600 ? 60 : 95 }); // long captions: faster cadence, still keystrokes
943
977
  await page.keyboard.press('Tab').catch(() => {});
944
978
  fillState.description = true;
945
979
  log('info', `[fb-pw] description filled (${descText.length} chars) via dynamic-probe`);
@@ -1021,16 +1055,12 @@ async function runOnce({ page, payload, log }) {
1021
1055
  if (tagLoc) log('info', `[fb-pw] tag input located via ${tagLoc.via}${tagLoc.sig ? ` (sig=${tagLoc.sig.slice(0, 60)})` : ''}${tagLoc.label ? ` (label="${tagLoc.label}")` : ''}`);
1022
1056
  if (tagLoc) {
1023
1057
  try {
1024
- await page.waitForTimeout(400);
1058
+ await pause(page, 400);
1025
1059
  const f = page.locator(tagLoc.selector);
1026
1060
  await f.click({ timeout: 5000 });
1027
1061
  const cleaned = tags.map((t) => String(t).replace(/^#+/, '').trim()).filter(Boolean);
1028
1062
  const tagsStr = cleaned.join(', ');
1029
- if (tagLoc.tag === 'input' || tagLoc.tag === 'textarea') {
1030
- await f.fill(tagsStr);
1031
- } else {
1032
- await f.type(tagsStr, { delay: 10 });
1033
- }
1063
+ await humanType(page, tagsStr, { typo: 0 });
1034
1064
  fillState.tags = true;
1035
1065
  log('info', `[fb-pw] tags filled (${cleaned.length}: ${tagsStr.slice(0, 60)}…) via scrollIntoView`);
1036
1066
  await page.evaluate((sel) => document.querySelectorAll(sel).forEach((el) => el.removeAttribute('__fbpw_tag__')), tagLoc.selector).catch(() => {});
@@ -1049,11 +1079,8 @@ async function runOnce({ page, payload, log }) {
1049
1079
  const tagsStr = cleaned.join(', ');
1050
1080
  await f.click({ timeout: 3000 });
1051
1081
  const tag = await f.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
1052
- if (tag === 'input' || tag === 'textarea') {
1053
- await f.fill(tagsStr);
1054
- } else {
1055
- await f.type(tagsStr, { delay: 10 });
1056
- }
1082
+ void tag;
1083
+ await humanType(page, tagsStr, { typo: 0 });
1057
1084
  fillState.tags = true;
1058
1085
  log('info', `[fb-pw] tags filled (${cleaned.length}: ${tagsStr.slice(0, 60)}…) via "${sel}"`);
1059
1086
  break;
@@ -1065,7 +1092,7 @@ async function runOnce({ page, payload, log }) {
1065
1092
 
1066
1093
  log('info', '[fb-pw] fill metadata (title/description/tags) — step 1…');
1067
1094
  await fillMetadata();
1068
- await page.waitForTimeout(800);
1095
+ await pause(page, 800);
1069
1096
 
1070
1097
  // 6) Multi-step "Tiếp" → "Tiếp" → "Đăng" loop. FB Reels composer is 3
1071
1098
  // pages: caption (now) → thumbnail (auto-selected, just advance) →
@@ -1183,7 +1210,7 @@ async function runOnce({ page, payload, log }) {
1183
1210
  let customThumbDone = false;
1184
1211
  let pubWaitDone = false; // guard: wait for a disabled "Đăng" at most once
1185
1212
  for (let step = 0; step < 7 && !published; step++) {
1186
- await page.waitForTimeout(3000);
1213
+ await pause(page, 3000);
1187
1214
  await fillMetadata();
1188
1215
 
1189
1216
  // PAGE-WALL thumb flow (form 2 has a "Chỉnh sửa" overlay button on the
@@ -1244,7 +1271,7 @@ async function runOnce({ page, payload, log }) {
1244
1271
  if (!editBtn) {
1245
1272
  log('info', '[fb-pw] thumb-edit pill not visible on this step — polling up to 18s…');
1246
1273
  const tdl = Date.now() + 18_000;
1247
- while (!editBtn && Date.now() < tdl) { await page.waitForTimeout(3000); editBtn = await findThumbBtn(); }
1274
+ while (!editBtn && Date.now() < tdl) { await pause(page, 3000); editBtn = await findThumbBtn(); }
1248
1275
  if (editBtn) log('info', '[fb-pw] thumb-edit pill appeared after wait');
1249
1276
  }
1250
1277
 
@@ -1253,7 +1280,7 @@ async function runOnce({ page, payload, log }) {
1253
1280
  log('info', `[fb-pw] page-wall thumb — opening "Chỉnh sửa hình thu nhỏ" modal (via=${editBtn.via}, aria="${editBtn.aria}", text="${editBtn.text}"${editBtn.size ? `, size=${editBtn.size}` : ''})…`);
1254
1281
  try {
1255
1282
  await page.locator(editBtn.selector).click({ timeout: 5000 });
1256
- await page.waitForTimeout(2500);
1283
+ await pause(page, 2500);
1257
1284
 
1258
1285
  // Wait for the thumb editor to mount. FB serves (A/B, seen
1259
1286
  // 2026-08-19) TWO variants and any given account can get either:
@@ -1302,7 +1329,7 @@ async function runOnce({ page, payload, log }) {
1302
1329
  let thumbEditorVia = await detectThumbEditor();
1303
1330
  const mountDeadline = Date.now() + 12_000;
1304
1331
  while (!thumbEditorVia && Date.now() < mountDeadline) {
1305
- await page.waitForTimeout(2000);
1332
+ await pause(page, 2000);
1306
1333
  thumbEditorVia = await detectThumbEditor();
1307
1334
  }
1308
1335
  if (!thumbEditorVia) throw new Error('thumbnail editor dialog did not mount');
@@ -1331,7 +1358,7 @@ async function runOnce({ page, payload, log }) {
1331
1358
  await chooser.setFiles(thumbPath);
1332
1359
  log('info', `[fb-pw] page-wall thumb — file set via "${sel}"`);
1333
1360
  uploaded = true;
1334
- await page.waitForTimeout(3500); // FB processes image
1361
+ await pause(page, 3500); // FB processes image
1335
1362
  break;
1336
1363
  } catch (e) {
1337
1364
  log('info', `[fb-pw] "Tải lên" via "${sel}" failed: ${e.message.slice(0, 80)}`);
@@ -1347,7 +1374,7 @@ async function runOnce({ page, payload, log }) {
1347
1374
  await directInput.setInputFiles(thumbPath);
1348
1375
  log('info', '[fb-pw] page-wall thumb — file set via direct input[type=file]');
1349
1376
  uploaded = true;
1350
- await page.waitForTimeout(3500);
1377
+ await pause(page, 3500);
1351
1378
  } catch (e) {
1352
1379
  log('warn', `[fb-pw] direct file input failed: ${e.message.slice(0, 80)}`);
1353
1380
  }
@@ -1431,7 +1458,7 @@ async function runOnce({ page, payload, log }) {
1431
1458
  } catch {}
1432
1459
  }
1433
1460
  }
1434
- await page.waitForTimeout(1000);
1461
+ await pause(page, 1000);
1435
1462
  }
1436
1463
  if (modalClosed) {
1437
1464
  // Closing the nested thumbnail editor is NOT enough. In the
@@ -1447,10 +1474,10 @@ async function runOnce({ page, payload, log }) {
1447
1474
  } else {
1448
1475
  log('warn', '[fb-pw] page-wall thumb — modal still open 60s after Lưu click; trying ESC + click Hủy fallback');
1449
1476
  await page.keyboard.press('Escape').catch(() => {});
1450
- await page.waitForTimeout(800);
1477
+ await pause(page, 800);
1451
1478
  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);
1452
1479
  if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
1453
- await page.waitForTimeout(1500);
1480
+ await pause(page, 1500);
1454
1481
  }
1455
1482
  } else {
1456
1483
  log('warn', '[fb-pw] page-wall thumb upload OK but "Lưu" click failed — modal may stay open');
@@ -1459,7 +1486,7 @@ async function runOnce({ page, payload, log }) {
1459
1486
  log('warn', '[fb-pw] page-wall thumb upload failed — closing modal via Hủy to continue');
1460
1487
  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);
1461
1488
  if (cancel) await cancel.click({ timeout: 2000 }).catch(() => {});
1462
- await page.waitForTimeout(1500);
1489
+ await pause(page, 1500);
1463
1490
  }
1464
1491
  } catch (e) {
1465
1492
  if (e?.code === SAFE_RETRY_COMPOSER_CLOSED) throw e;
@@ -1541,7 +1568,7 @@ async function runOnce({ page, payload, log }) {
1541
1568
  await directImgInputs.nth(i).setInputFiles(thumbPath);
1542
1569
  uploaded = true;
1543
1570
  log('info', `[fb-pw] custom thumbnail set via direct image input[${i}]`);
1544
- await page.waitForTimeout(3000);
1571
+ await pause(page, 3000);
1545
1572
  } catch (e) { log('info', `[fb-pw] direct img input[${i}] failed: ${e.message.slice(0, 80)}`); }
1546
1573
  }
1547
1574
 
@@ -1557,7 +1584,7 @@ async function runOnce({ page, payload, log }) {
1557
1584
  await anyFile.nth(i).setInputFiles(thumbPath);
1558
1585
  uploaded = true;
1559
1586
  log('info', `[fb-pw] custom thumbnail set via any-file input[${i}]`);
1560
- await page.waitForTimeout(3000);
1587
+ await pause(page, 3000);
1561
1588
  } catch (e) { log('info', `[fb-pw] any-file input[${i}] failed: ${e.message.slice(0, 80)}`); }
1562
1589
  }
1563
1590
  }
@@ -1591,7 +1618,7 @@ async function runOnce({ page, payload, log }) {
1591
1618
  }
1592
1619
  }
1593
1620
  if (tabClicked) {
1594
- await page.waitForTimeout(2500); // let pane render fully
1621
+ await pause(page, 2500); // let pane render fully
1595
1622
  // Step 2: click the actual <a> trigger inside the Upload pane.
1596
1623
  // Per user's xpath `.//a[contains(text(), "Upload")]`. VN UI
1597
1624
  // shows "Tải hình ảnh lên". Try multiple variants + force-click
@@ -1639,7 +1666,7 @@ async function runOnce({ page, payload, log }) {
1639
1666
  await chooser.setFiles(thumbPath);
1640
1667
  uploaded = true;
1641
1668
  log('info', `[fb-pw] custom thumbnail set via <a> "${sel}" (force)`);
1642
- await page.waitForTimeout(3000);
1669
+ await pause(page, 3000);
1643
1670
  break;
1644
1671
  } catch (e2) {
1645
1672
  log('info', `[fb-pw] <a> click via "${sel}" failed (force): ${e2.message.slice(0, 80)}`);
@@ -1656,7 +1683,7 @@ async function runOnce({ page, payload, log }) {
1656
1683
  await chooser.setFiles(thumbPath);
1657
1684
  uploaded = true;
1658
1685
  log('info', `[fb-pw] custom thumbnail set via <a> "${sel}" (mouse @ bbox)`);
1659
- await page.waitForTimeout(3000);
1686
+ await pause(page, 3000);
1660
1687
  break;
1661
1688
  } catch (e3) {
1662
1689
  log('info', `[fb-pw] <a> mouse click via "${sel}" failed: ${e3.message.slice(0, 80)}`);
@@ -1689,14 +1716,14 @@ async function runOnce({ page, payload, log }) {
1689
1716
  log('info', '[fb-pw] JS-click on Tải hình ảnh lên fired — polling for file input…');
1690
1717
  // Wait + poll for input[type=file] to materialize
1691
1718
  for (let i = 0; i < 12 && !uploaded; i++) {
1692
- await page.waitForTimeout(500);
1719
+ await pause(page, 500);
1693
1720
  const lazy = page.locator("input[type='file']").last();
1694
1721
  if (await lazy.count().catch(() => 0) > 0) {
1695
1722
  try {
1696
1723
  await lazy.setInputFiles(thumbPath);
1697
1724
  uploaded = true;
1698
1725
  log('info', `[fb-pw] custom thumbnail set via JS-click + lazy input (poll #${i + 1})`);
1699
- await page.waitForTimeout(3000);
1726
+ await pause(page, 3000);
1700
1727
  break;
1701
1728
  } catch (e) { /* keep polling */ }
1702
1729
  }
@@ -1731,7 +1758,7 @@ async function runOnce({ page, payload, log }) {
1731
1758
  await chooser.setFiles(thumbPath);
1732
1759
  uploaded = true;
1733
1760
  log('info', `[fb-pw] custom thumbnail set via CDP-mouse @ (${Math.round(bbox.x)},${Math.round(bbox.y)})`);
1734
- await page.waitForTimeout(3000);
1761
+ await pause(page, 3000);
1735
1762
  } catch (e) {
1736
1763
  log('info', `[fb-pw] CDP-mouse + filechooser failed: ${e.message.slice(0, 80)}`);
1737
1764
  }
@@ -1745,7 +1772,7 @@ async function runOnce({ page, payload, log }) {
1745
1772
  log('warn', '[fb-pw] custom thumb upload failed — falling back to auto-thumb #1');
1746
1773
  const auto1 = await firstVisible(page.locator("[aria-label='Hình thu nhỏ tạo tự động 1'], [aria-label*='Auto-generated thumbnail 1']"), 3);
1747
1774
  if (auto1) await auto1.click({ timeout: 3000 }).catch(() => {});
1748
- await page.waitForTimeout(1500);
1775
+ await pause(page, 1500);
1749
1776
  }
1750
1777
  customThumbDone = true;
1751
1778
  } else {
@@ -1753,7 +1780,7 @@ async function runOnce({ page, payload, log }) {
1753
1780
  const auto1 = await firstVisible(page.locator("[aria-label='Hình thu nhỏ tạo tự động 1'], [aria-label*='Auto-generated thumbnail 1']"), 3);
1754
1781
  if (auto1) {
1755
1782
  await auto1.click({ timeout: 3000 }).catch(() => {});
1756
- await page.waitForTimeout(1500);
1783
+ await pause(page, 1500);
1757
1784
  }
1758
1785
  customThumbDone = true;
1759
1786
  }
@@ -1796,7 +1823,7 @@ async function runOnce({ page, payload, log }) {
1796
1823
  log('info', `[fb-pw] pre-publish dismissing overlay via "${dh.verb}"`);
1797
1824
  try {
1798
1825
  await page.locator(dh.selector).click({ timeout: 3000 });
1799
- await page.waitForTimeout(1500);
1826
+ await pause(page, 1500);
1800
1827
  await page.evaluate(() => document.querySelectorAll("[__fbpw_predismiss__]").forEach((el) => el.removeAttribute('__fbpw_predismiss__'))).catch(() => {});
1801
1828
  } catch (e) {
1802
1829
  log('warn', `[fb-pw] pre-dismiss click failed: ${e.message.slice(0, 80)}`);
@@ -1871,7 +1898,7 @@ async function runOnce({ page, payload, log }) {
1871
1898
  const target = attempt === 0 ? pub : await findByVerbs(publishVerbs, { requireBottomHalf: true });
1872
1899
  if (!target) {
1873
1900
  if (attemptedClick) { clickedPublish = true; log('info', '[fb-pw] publish button gone after click → post accepted'); break; }
1874
- await page.waitForTimeout(1500); continue;
1901
+ await pause(page, 1500); continue;
1875
1902
  }
1876
1903
  attemptedClick = true;
1877
1904
  try {
@@ -1900,7 +1927,7 @@ async function runOnce({ page, payload, log }) {
1900
1927
  }).catch(() => false);
1901
1928
  if (js) { clickedPublish = true; log('info', '[fb-pw] publish via JS-dispatch fallback'); }
1902
1929
  }
1903
- if (!clickedPublish) await page.waitForTimeout(2000);
1930
+ if (!clickedPublish) await pause(page, 2000);
1904
1931
  }
1905
1932
  }
1906
1933
  if (!clickedPublish) throw new Error(`publish button not clickable after 3 attempts`);
@@ -1938,7 +1965,7 @@ async function runOnce({ page, payload, log }) {
1938
1965
  }
1939
1966
  return null;
1940
1967
  }, confirmVerbs).catch(() => null);
1941
- if (!confirmHit) await page.waitForTimeout(600);
1968
+ if (!confirmHit) await pause(page, 600);
1942
1969
  }
1943
1970
  if (confirmHit) {
1944
1971
  log('info', `[fb-pw] confirming publish via dialog button "${confirmHit.verb}"`);
@@ -1955,7 +1982,7 @@ async function runOnce({ page, payload, log }) {
1955
1982
  label: `confirm "${confirmHit.verb}"`,
1956
1983
  attempts: 2,
1957
1984
  }).catch((e) => log('warn', `[fb-pw] confirm click failed: ${e.message.split('\n')[0].slice(0, 80)}`));
1958
- await page.waitForTimeout(3000);
1985
+ await pause(page, 3000);
1959
1986
  } else {
1960
1987
  log('info', '[fb-pw] no confirmation dialog detected after 8s — assuming direct publish');
1961
1988
  }
@@ -1998,7 +2025,7 @@ async function runOnce({ page, payload, log }) {
1998
2025
  log('info', `[fb-pw] dismissing post-publish prompt via "${dismissHit.verb}"`);
1999
2026
  try {
2000
2027
  await page.locator(dismissHit.selector).click({ timeout: 3000 });
2001
- await page.waitForTimeout(2000);
2028
+ await pause(page, 2000);
2002
2029
  await page.evaluate(() => document.querySelectorAll("[__fbpw_dismiss__]").forEach((el) => el.removeAttribute('__fbpw_dismiss__'))).catch(() => {});
2003
2030
  } catch (e) {
2004
2031
  log('warn', `[fb-pw] dismiss click failed: ${e.message.slice(0, 80)}`);
@@ -2109,7 +2136,7 @@ async function runOnce({ page, payload, log }) {
2109
2136
  if (jumpBbox) {
2110
2137
  await page.mouse.click(jumpBbox.x, jumpBbox.y);
2111
2138
  log('info', `[fb-pw] jumped to Chia sẻ step @ (${Math.round(jumpBbox.x)},${Math.round(jumpBbox.y)})`);
2112
- await page.waitForTimeout(3000);
2139
+ await pause(page, 3000);
2113
2140
  }
2114
2141
  }
2115
2142
  }
@@ -2202,7 +2229,7 @@ async function runOnce({ page, payload, log }) {
2202
2229
  await page.locator("[__fbpw_recommit__='1']").click({ timeout: 5000 }).catch(() => {});
2203
2230
  await page.evaluate(() => document.querySelectorAll("[__fbpw_recommit__]").forEach((el) => el.removeAttribute('__fbpw_recommit__'))).catch(() => {});
2204
2231
  }
2205
- await page.waitForTimeout(8000);
2232
+ await pause(page, 8000);
2206
2233
  }
2207
2234
  if (await composerStillOpen()) {
2208
2235
  await dumpInventory(page, log, 'composer-stuck-open');
@@ -2299,7 +2326,7 @@ async function runOnce({ page, payload, log }) {
2299
2326
  reelsTabUrl = reelsTabHref;
2300
2327
  log('info', `[fb-pw] navigating to page reels tab: ${reelsTabHref.slice(0, 100)}`);
2301
2328
  await page.goto(reelsTabHref, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
2302
- await page.waitForTimeout(5000);
2329
+ await pause(page, 5000);
2303
2330
  // Reel tiles on the reels tab — each is an <a href="/reel/<id>/">
2304
2331
  // wrapping a thumbnail + meta, DOM order = newest first. Only a tile
2305
2332
  // with a FRESH timestamp counts (see scrapeFreshReelTile).
@@ -2435,7 +2462,7 @@ async function runOnce({ page, payload, log }) {
2435
2462
  log('info', '[fb-pw] no post URL yet — waiting 75s for FB to surface the new reel on reels_tab, then re-scraping…');
2436
2463
  await page.waitForTimeout(75_000);
2437
2464
  await page.goto(reelsTabUrl, { waitUntil: 'domcontentloaded', timeout: 30_000 }).catch(() => {});
2438
- await page.waitForTimeout(5000);
2465
+ await pause(page, 5000);
2439
2466
  const fresh2 = await scrapeFreshReelTile();
2440
2467
  if (fresh2) {
2441
2468
  postUrl = fresh2.href.startsWith('http') ? fresh2.href : `https://www.facebook.com${fresh2.href}`;
@@ -2557,7 +2584,7 @@ async function run(args) {
2557
2584
  // opens a fresh composer. The first attempt's finally has already removed
2558
2585
  // its downloaded temp files; runOnce downloads clean copies on retry.
2559
2586
  await page.goto('about:blank', { waitUntil: 'domcontentloaded', timeout: 15_000 }).catch(() => {});
2560
- await page.waitForTimeout(1200);
2587
+ await pause(page, 1200);
2561
2588
  }
2562
2589
  }
2563
2590
  throw new Error('FB upload exhausted safe retries');
@@ -10,6 +10,7 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
+ const { humanType, pause, randInt: hRandInt } = require('./lib/human');
13
14
  const { downloadToTemp, safeUnlink } = require('./lib/download');
14
15
 
15
16
  async function firstVisible(locator, max = 8) {
@@ -36,7 +37,7 @@ async function waitAndClick(el, { timeoutMs = 60_000, log, label = 'button' } =
36
37
  try { await el.click({ timeout: 3000 }); return; }
37
38
  catch { /* retry */ }
38
39
  }
39
- await el.page().waitForTimeout(500);
40
+ await el.page().waitForTimeout(hRandInt(350, 700));
40
41
  }
41
42
  throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
42
43
  }
@@ -146,7 +147,7 @@ async function run({ page, payload, log }) {
146
147
  log('info', '[fbphoto] navigating to facebook.com…');
147
148
  await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60_000 });
148
149
  await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
149
- await page.waitForTimeout(2500);
150
+ await pause(page, 2500);
150
151
 
151
152
  // 2) Open the "Tạo bài viết" composer via the "Ảnh/video" entry (this opens
152
153
  // the post composer directly in photo-attach mode). Fall back to the
@@ -172,14 +173,14 @@ async function run({ page, payload, log }) {
172
173
  }
173
174
  // Fallback: click the status box to open the composer, then click Ảnh/video inside.
174
175
  const box = await firstVisible(page.locator("div[role='button']:has-text('Bạn đang nghĩ gì'), div[role='button']:has-text(\"What's on your mind\")"), 3);
175
- if (box) { await box.click({ timeout: 4000 }).catch(() => {}); await page.waitForTimeout(1500); return true; }
176
+ if (box) { await box.click({ timeout: 4000 }).catch(() => {}); await pause(page, 1500); return true; }
176
177
  return false;
177
178
  };
178
179
  if (!(await openPhotoComposer())) {
179
180
  await dumpFailure(page, 'no-composer-entry', log);
180
181
  throw new Error('FB: không tìm thấy nút "Ảnh/video" / ô "Tạo bài viết" trên trang. Kiểm tra layout Page.');
181
182
  }
182
- await page.waitForTimeout(2500);
183
+ await pause(page, 2500);
183
184
 
184
185
  // 3) Ensure the composer modal is open; click its "Ảnh/video" if the file
185
186
  // input isn't ready yet.
@@ -197,12 +198,12 @@ async function run({ page, payload, log }) {
197
198
  if (inModalPhoto) {
198
199
  await page.locator("[__fbphoto_add__='1']").click({ timeout: 4000 }).catch(() => {});
199
200
  await page.evaluate(() => document.querySelectorAll('[__fbphoto_add__]').forEach((e) => e.removeAttribute('__fbphoto_add__'))).catch(() => {});
200
- await page.waitForTimeout(1500);
201
+ await pause(page, 1500);
201
202
  }
202
203
  // Wait for the file input to appear.
203
204
  for (let i = 0; i < 10 && !fileInput; i++) {
204
205
  fileInput = await page.$("input[type='file'][accept*='image']");
205
- if (!fileInput) await page.waitForTimeout(1000);
206
+ if (!fileInput) await pause(page, 1000);
206
207
  }
207
208
  }
208
209
  if (!fileInput) {
@@ -214,7 +215,7 @@ async function run({ page, payload, log }) {
214
215
  log('info', '[fbphoto] attaching image…');
215
216
  await setImageFile(page, fileInput, imagePath, log);
216
217
  // Wait for the image preview to render inside the composer.
217
- await page.waitForTimeout(4000);
218
+ await pause(page, 4000);
218
219
 
219
220
  // 5) Fill caption into the composer's contenteditable textbox.
220
221
  log('info', '[fbphoto] filling caption…');
@@ -224,12 +225,13 @@ async function run({ page, payload, log }) {
224
225
  throw new Error('FB: không tìm thấy ô nhập caption.');
225
226
  }
226
227
  await editable.click({ timeout: 4000 }).catch(() => {});
227
- await page.waitForTimeout(400);
228
- await page.keyboard.insertText(fullCaption).catch(async () => {
229
- // insertText can fail on some FB editors fall back to type.
230
- await editable.type(fullCaption, { delay: 5 }).catch(() => {});
228
+ await pause(page, 400);
229
+ // Typed, not pasted: insertText dropped the whole caption in one
230
+ // synthetic step with zero keystrokes. typo:0 caption must be exact.
231
+ await humanType(page, fullCaption, { typo: 0, base: fullCaption.length > 600 ? 60 : 95 }).catch(async () => {
232
+ await editable.type(fullCaption, { delay: 40 }).catch(() => {});
231
233
  });
232
- await page.waitForTimeout(1000);
234
+ await pause(page, 1000);
233
235
 
234
236
  // 6) Click "Đăng".
235
237
  log('info', '[fbphoto] clicking publish…');
@@ -281,7 +283,7 @@ async function run({ page, payload, log }) {
281
283
  const pubHit = await tagCta(publishVerbs);
282
284
  if (pubHit) { log('info', `[fbphoto] publish via "${pubHit}" (step ${step + 1})`); await clickTaggedCta(); published = true; break; }
283
285
  const nextHit = await tagCta(nextVerbs);
284
- if (nextHit) { log('info', `[fbphoto] advance via "${nextHit}" (step ${step + 1})`); await clickTaggedCta(); await page.waitForTimeout(2800); stalls = 0; continue; }
286
+ if (nextHit) { log('info', `[fbphoto] advance via "${nextHit}" (step ${step + 1})`); await clickTaggedCta(); await pause(page, 2800); stalls = 0; continue; }
285
287
  // Neither publish nor Tiếp enabled — FB still ingesting the photo. Wait.
286
288
  if (++stalls > 10) break; // ~30s of no CTA → give up
287
289
  if (stalls === 1 || stalls === 6) {
@@ -301,7 +303,7 @@ async function run({ page, payload, log }) {
301
303
  }
302
304
  log('info', `[fbphoto] no CTA yet at step ${step + 1} — waiting…`);
303
305
  step--; // don't count a pure wait against the step budget
304
- await page.waitForTimeout(3000);
306
+ await pause(page, 3000);
305
307
  }
306
308
  if (!published) {
307
309
  await dumpFailure(page, 'no-publish-btn', log);
@@ -309,11 +311,11 @@ async function run({ page, payload, log }) {
309
311
  }
310
312
 
311
313
  // Verify commit + retry — a click can register without FB posting.
312
- await page.waitForTimeout(6000);
314
+ await pause(page, 6000);
313
315
  for (let commitTry = 0; commitTry < 3 && (await composerStillOpen(page, publishVerbs)); commitTry++) {
314
316
  log('warn', `[fbphoto] composer still open — re-clicking publish (retry ${commitTry + 1}/3)`);
315
317
  if (await tagCta(publishVerbs)) await clickTaggedCta();
316
- await page.waitForTimeout(6000);
318
+ await pause(page, 6000);
317
319
  }
318
320
  if (await composerStillOpen(page, publishVerbs)) {
319
321
  await dumpFailure(page, 'composer-stuck-open', log);
@@ -321,7 +323,7 @@ async function run({ page, payload, log }) {
321
323
  }
322
324
 
323
325
  // 8) Best-effort post URL.
324
- await page.waitForTimeout(4000);
326
+ await pause(page, 4000);
325
327
  let postUrl = '';
326
328
  if (capturedPostIds.length) {
327
329
  postUrl = `https://www.facebook.com/${capturedPostIds[capturedPostIds.length - 1]}`;