channel-worker 2.5.55 → 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.
@@ -20,6 +20,8 @@
20
20
  // lib/fb-guard so the API can stop the schedule and raise a notification.
21
21
 
22
22
  const { assertAccountUsable } = require('./lib/fb-guard');
23
+ const { pick, clickHandle } = require('./lib/dom-pick');
24
+ const { humanMove, humanWheel, humanClick } = require('./lib/human');
23
25
 
24
26
  // Every markup shape Facebook has used for "one post in the feed". Used by the
25
27
  // secondary helpers (ad check, open-post) via closest()/querySelectorAll;
@@ -38,22 +40,23 @@ function chance(p) { return Math.random() < p; }
38
40
  async function centerMouse(page) {
39
41
  const vp = await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight })).catch(() => null);
40
42
  if (!vp) return;
41
- const jitter = (n) => n + randInt(-40, 40);
42
- await page.mouse.move(jitter(Math.floor(vp.w / 2)), jitter(Math.floor(vp.h / 2))).catch(() => {});
43
+ const jitter = (n) => n + randInt(-120, 120);
44
+ await humanMove(page, jitter(Math.floor(vp.w / 2)), jitter(Math.floor(vp.h / 2)));
43
45
  }
44
46
 
45
47
  // Scroll by `dy` and VERIFY the page moved. Falls back through three rungs:
46
- // wheel re-center the cursor and wheel again window.scrollBy. Returns the
48
+ // wheel burst (lib/human: several decelerating notches, not one big event)
49
+ // re-center the cursor and wheel again → window.scrollBy. Returns the
47
50
  // distance actually scrolled.
48
51
  async function humanScroll(page, dy) {
49
52
  const readY = () => page.evaluate(() => window.scrollY || document.documentElement.scrollTop || 0).catch(() => 0);
50
53
  const before = await readY();
51
- await page.mouse.wheel(0, dy).catch(() => {});
54
+ await humanWheel(page, dy);
52
55
  await page.waitForTimeout(randInt(350, 700));
53
56
  let after = await readY();
54
57
  if (Math.abs(after - before) < 5) {
55
58
  await centerMouse(page);
56
- await page.mouse.wheel(0, dy).catch(() => {});
59
+ await humanWheel(page, dy);
57
60
  await page.waitForTimeout(randInt(350, 700));
58
61
  after = await readY();
59
62
  }
@@ -73,7 +76,7 @@ async function humanScroll(page, dy) {
73
76
  async function dismissDialogs(page, log) {
74
77
  const verbs = ['Cho phép tất cả cookie', 'Allow all cookies', 'Để sau', 'Not now', 'Lúc khác', 'Không phải bây giờ', 'Bỏ qua', 'Skip', 'Đóng', 'Close', 'OK', 'Đã hiểu', 'Got it'];
75
78
  for (let round = 0; round < 3; round++) {
76
- const hit = await page.evaluate((vs) => {
79
+ const { el } = await pick(page, (vs) => {
77
80
  for (const dlg of document.querySelectorAll("[role='dialog']")) {
78
81
  const r = dlg.getBoundingClientRect();
79
82
  if (r.width < 8 || r.height < 8) continue;
@@ -83,18 +86,16 @@ async function dismissDialogs(page, log) {
83
86
  for (const b of dlg.querySelectorAll("[role='button'], button")) {
84
87
  const t = (b.innerText || '').trim();
85
88
  if ((t === v || (b.getAttribute('aria-label') || '').trim() === v) && b.offsetParent !== null) {
86
- b.setAttribute('__nur_dismiss__', '1');
87
- return v;
89
+ return { el: b, verb: v };
88
90
  }
89
91
  }
90
92
  }
91
93
  }
92
- return null;
93
- }, verbs).catch(() => null);
94
- if (!hit) break;
95
- try { await page.locator("[__nur_dismiss__='1']").click({ timeout: 2500 }); } catch {}
96
- await page.evaluate(() => document.querySelectorAll('[__nur_dismiss__]').forEach(e => e.removeAttribute('__nur_dismiss__'))).catch(() => {});
97
- await page.waitForTimeout(800);
94
+ return { el: null };
95
+ }, verbs);
96
+ if (!el) break;
97
+ await clickHandle(page, el, { hoverMs: [300, 900], timeout: 2500, scroll: false });
98
+ await page.waitForTimeout(randInt(600, 1300));
98
99
  }
99
100
  }
100
101
 
@@ -107,10 +108,11 @@ async function dismissDialogs(page, log) {
107
108
  // real scrolling. The key is the permalink id (falling back to a text prefix),
108
109
  // which travels with the content instead of the container.
109
110
  //
110
- // Also marks a like candidate in the same pass — one evaluate keeps the key
111
- // derivation in exactly one place instead of drifting across two copies.
111
+ // Also picks a like candidate in the same pass — one evaluate keeps the key
112
+ // derivation in exactly one place instead of drifting across two copies. The
113
+ // candidate comes back as an ElementHandle (`likeEl`), never as a DOM mark.
112
114
  async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
113
- return page.evaluate(({ skip, wantLike }) => {
115
+ const { el, data } = await pick(page, ({ skip, wantLike }) => {
114
116
  const skipSet = new Set(skip);
115
117
 
116
118
  // WHICH element is "a post"? Not a fixed selector — Facebook's feed markup
@@ -157,7 +159,7 @@ async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
157
159
  if (dist < bestDist) { best = art; bestDist = dist; bestKey = key; }
158
160
  }
159
161
 
160
- let likeKey = null;
162
+ let likeKey = null, likeBtn = null;
161
163
  if (best) {
162
164
  for (const b of best.querySelectorAll("[role='button']")) {
163
165
  const aria = (b.getAttribute('aria-label') || '').trim();
@@ -165,7 +167,7 @@ async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
165
167
  if (b.getAttribute('aria-pressed') === 'true') continue; // already liked
166
168
  const br = b.getBoundingClientRect();
167
169
  if (br.width < 8 || br.height < 8 || b.offsetParent === null) continue;
168
- b.setAttribute('__nur_like__', '1');
170
+ likeBtn = b;
169
171
  likeKey = bestKey;
170
172
  break;
171
173
  }
@@ -184,88 +186,121 @@ async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
184
186
  }
185
187
  }
186
188
  return {
189
+ el: likeBtn,
187
190
  posts, likeKey, scroller, scrollTop,
188
191
  scrollY: Math.round(window.scrollY || document.documentElement.scrollTop || 0),
189
192
  articlesInDom: units.length,
190
193
  selector: sel || '(none)',
191
194
  sample: posts.slice(0, 3).map((p) => p.key.slice(0, 46)),
192
195
  };
193
- }, { skip: skipKeys, wantLike }).catch(() => ({ posts: [], likeKey: null, scrollY: 0, articlesInDom: 0 }));
196
+ }, { skip: skipKeys, wantLike });
197
+ return { posts: [], likeKey: null, scrollY: 0, articlesInDom: 0, ...data, likeEl: el };
194
198
  }
195
199
 
196
- // Click the like button scanFeed already marked (`__nur_like__`). Returns true
197
- // on a confirmed like. The picking — skip ads, skip already-liked, never a
200
+ // Click the like button scanFeed already picked (`likeEl`). Returns true on a
201
+ // confirmed like. The picking — skip ads, skip already-liked, never a
198
202
  // comment's button — happened in scanFeed; this only performs the click.
199
- async function clickMarkedLike(page, log) {
200
- const btn = page.locator("[__nur_like__='1']").first();
203
+ async function clickPickedLike(page, btn, log) {
204
+ if (!btn) return false;
201
205
  let ok = false;
202
206
  try {
203
- await btn.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
204
- // Hover first, then a short beat a real cursor travels before it clicks.
205
- await btn.hover({ timeout: 3000 }).catch(() => {});
206
- await page.waitForTimeout(randInt(500, 1400));
207
- // MUST be a Playwright click: an el.click() from page.evaluate is synthetic
208
- // and React's handler ignores it (same lesson as the YouTube ad-skip).
209
- await btn.click({ timeout: 4000 });
207
+ // MUST be a Playwright input click: an el.click() from page.evaluate is
208
+ // synthetic and React's handler ignores it (same lesson as the YouTube
209
+ // ad-skip). humanClick travels the cursor there, hovers a beat, clicks
210
+ // off-centre.
211
+ await humanClick(page, btn, { hoverMs: [500, 1400], timeout: 4000 });
210
212
  await page.waitForTimeout(randInt(900, 1800));
211
213
  const pressed = await btn.evaluate((el) => el.getAttribute('aria-pressed')).catch(() => null);
212
214
  ok = pressed === null ? true : pressed === 'true'; // button gone/re-rendered → treat as done
213
215
  } catch (e) {
214
216
  log('info', `[nurture-fb] like failed: ${String(e.message || e).slice(0, 80)}`);
215
217
  }
216
- await page.evaluate(() => document.querySelectorAll('[__nur_like__]').forEach(e => e.removeAttribute('__nur_like__'))).catch(() => {});
218
+ await btn.dispose().catch(() => {});
217
219
  if (ok) log('info', '[nurture-fb] liked a post');
218
220
  return ok;
219
221
  }
220
222
 
221
223
  // Watch a video that's playing in the feed, counting only seconds where
222
224
  // playback actually ADVANCES (a frozen/buffering player must not bank time).
223
- async function watchFeedVideo(page, minSec, maxSec, log) {
224
- const has = await page.evaluate((UNIT) => {
225
- const vids = [...document.querySelectorAll('video')];
226
- for (const v of vids) {
225
+ //
226
+ // "Already watched" lives in `watchedKeys` on the Node side, keyed by the
227
+ // video's source (falling back to the post's text prefix) — the same idea as
228
+ // likedKeys. It used to be a `data-nur-watched` attribute written onto the
229
+ // <video> element and never removed; see lib/dom-pick.js for why that's gone.
230
+ async function watchFeedVideo(page, minSec, maxSec, watchedKeys, log) {
231
+ const { el: vid, data } = await pick(page, ({ UNIT, skip }) => {
232
+ const skipSet = new Set(skip);
233
+ for (const v of document.querySelectorAll('video')) {
227
234
  const r = v.getBoundingClientRect();
228
235
  if (r.height < 100 || r.bottom < 0 || r.top > window.innerHeight) continue;
229
236
  const art = v.closest(UNIT);
230
237
  // Ads are re-detected from text — a node flag would be stale the moment
231
238
  // FB recycles the article for a different post.
232
239
  if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue;
233
- if (v.hasAttribute('data-nur-watched')) continue;
234
- v.setAttribute('data-nur-watched', '1');
235
- if (v.paused) { try { v.play(); } catch {} }
236
- return true;
240
+ const key = v.currentSrc || v.src || v.poster
241
+ || ('txt:' + ((art && art.innerText) || '').replace(/\s+/g, ' ').slice(0, 90));
242
+ if (skipSet.has(key)) continue;
243
+ return { el: v, key };
237
244
  }
238
- return false;
239
- }, UNIT_SEL).catch(() => false);
240
- if (!has) return false;
245
+ return { el: null };
246
+ }, { UNIT: UNIT_SEL, skip: [...watchedKeys] });
247
+ if (!vid) return false;
248
+ watchedKeys.add(data.key);
249
+
250
+ // A real viewer clicks the player to start it, not HTMLMediaElement.play().
251
+ // The click is on the video itself; if it's already rolling FB ignores it
252
+ // (or toggles mute/fullscreen UI — harmless). Fall back to play() only if the
253
+ // click didn't get it going.
254
+ // Feed videos normally autoplay muted once in view, so this branch is rare.
255
+ const paused0 = await vid.evaluate((v) => v.paused).catch(() => true);
256
+ if (paused0) {
257
+ const urlBefore = page.url();
258
+ await humanClick(page, vid, { hoverMs: [300, 800], timeout: 3000 }).catch(() => {});
259
+ await page.waitForTimeout(randInt(600, 1200));
260
+ if (page.url() !== urlBefore) {
261
+ // Some cards open a viewer instead of playing inline — back out, and
262
+ // let the feed loop pick something else next tick.
263
+ await page.keyboard.press('Escape').catch(() => {});
264
+ await page.waitForTimeout(randInt(500, 1000));
265
+ if (page.url() !== urlBefore) await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
266
+ await vid.dispose().catch(() => {});
267
+ return false;
268
+ }
269
+ await vid.evaluate((v) => { if (v.paused && !v.ended) { try { v.play(); } catch {} } }).catch(() => {});
270
+ }
241
271
 
242
272
  const budgetMs = randInt(minSec, maxSec) * 1000;
243
273
  let watched = 0, lastT = -1, frozen = 0;
244
274
  while (watched < budgetMs) {
245
- const st = await page.evaluate(() => {
246
- const v = document.querySelector("video[data-nur-watched='1']");
247
- if (!v) return null;
248
- if (v.paused && !v.ended) { try { v.play(); } catch {} }
275
+ const st = await vid.evaluate((v) => {
276
+ if (!v.isConnected) return null;
249
277
  return { t: Number(v.currentTime) || 0, ended: !!v.ended, dur: (isFinite(v.duration) && v.duration > 0) ? v.duration : 0, loop: !!v.loop };
250
278
  }).catch(() => null);
251
279
  if (!st) break;
252
280
  if (!st.loop && (st.ended || (st.dur > 0 && st.t >= st.dur - 0.6))) break;
253
281
  const advanced = st.t > lastT + 0.2 || st.t < lastT - 0.5; // forward, or loop wrap
254
- if (advanced) { watched += 1000; frozen = 0; } else if (++frozen >= 6) break;
282
+ const step = randInt(700, 1400);
283
+ if (advanced) { watched += step; frozen = 0; } else if (++frozen >= 6) break;
255
284
  lastT = st.t;
256
- await page.waitForTimeout(1000);
285
+ await page.waitForTimeout(step);
257
286
  }
258
- // Release the marker so a later video in the feed can be picked.
259
- await page.evaluate(() => document.querySelectorAll("video[data-nur-watched='1']").forEach(v => v.setAttribute('data-nur-watched', 'done'))).catch(() => {});
287
+ await vid.dispose().catch(() => {});
260
288
  if (watched > 0) log('info', `[nurture-fb] watched a feed video ~${Math.round(watched / 1000)}s`);
261
289
  return watched > 0;
262
290
  }
263
291
 
264
292
  // Open a post's permalink, read the comments for a bit, then go back.
265
293
  // Phase 2+. Best-effort: if the click doesn't navigate, nothing is lost.
266
- async function openPostAndRead(page, log) {
294
+ //
295
+ // Facebook ships most permalinks with target="_blank". This used to be patched
296
+ // to target="_self" before the click — a DOM mutation on FB's own anchor, ~1s
297
+ // before it gets clicked. Now the link is clicked exactly as rendered: if a
298
+ // new tab opens, the reading happens THERE and the tab is closed afterwards
299
+ // (that is what a person does; the feed also keeps its scroll position). If
300
+ // it navigates in place or opens an overlay, fall back to goBack/Escape.
301
+ async function openPostAndRead(page, context, tabGate, log) {
267
302
  const urlBefore = page.url();
268
- const marked = await page.evaluate((UNIT) => {
303
+ const { el } = await pick(page, (UNIT) => {
269
304
  const mid = window.innerHeight / 2;
270
305
  let best = null, bestDist = Infinity;
271
306
  for (const art of document.querySelectorAll(UNIT)) {
@@ -278,42 +313,42 @@ async function openPostAndRead(page, log) {
278
313
  const dist = Math.abs((r.top + r.bottom) / 2 - mid);
279
314
  if (dist < bestDist) { best = art; bestDist = dist; }
280
315
  }
281
- if (!best) return false;
316
+ if (!best) return { el: null };
282
317
  for (const a of best.querySelectorAll("a[href*='/posts/'], a[href*='/permalink/'], a[href*='story_fbid'], a[href*='/photo']")) {
283
318
  const r = a.getBoundingClientRect();
284
319
  if (r.width < 8 || r.height < 8 || a.offsetParent === null) continue;
285
- // Facebook ships most permalinks with target="_blank": clicked as-is the
286
- // post lands in a NEW tab while the script keeps driving the old one, so
287
- // every opened post leaves a dead tab behind. Force same-tab — goBack()
288
- // below is what returns us to the feed.
289
- a.setAttribute('target', '_self');
290
- a.setAttribute('__nur_open__', '1');
291
- return true;
320
+ return { el: a };
292
321
  }
293
- return false;
294
- }, UNIT_SEL).catch(() => false);
295
- if (!marked) return false;
296
-
297
- try {
298
- await page.locator("[__nur_open__='1']").first().click({ timeout: 4000 });
299
- } catch {
300
- await page.evaluate(() => document.querySelectorAll('[__nur_open__]').forEach(e => e.removeAttribute('__nur_open__'))).catch(() => {});
301
- return false;
302
- }
303
- await page.evaluate(() => document.querySelectorAll('[__nur_open__]').forEach(e => e.removeAttribute('__nur_open__'))).catch(() => {});
304
- await page.waitForTimeout(randInt(2500, 4000));
322
+ return { el: null };
323
+ }, UNIT_SEL);
324
+ if (!el) return false;
325
+
326
+ // Let guardSingleTab hand us the next tab instead of killing it.
327
+ let popup = null;
328
+ const waitPopup = context
329
+ ? new Promise((resolve) => { tabGate.resolve = resolve; })
330
+ : Promise.resolve(null);
331
+ tabGate.open = true;
332
+ const clicked = await clickHandle(page, el, { hoverMs: [400, 1100] });
333
+ if (!clicked) { tabGate.open = false; tabGate.resolve = null; return false; }
334
+ popup = await Promise.race([waitPopup, page.waitForTimeout(4000).then(() => null)]);
335
+ tabGate.open = false; tabGate.resolve = null;
336
+
337
+ const reader = popup || page;
338
+ if (popup) await popup.waitForLoadState('domcontentloaded', { timeout: 30000 }).catch(() => {});
339
+ await reader.waitForTimeout(randInt(2500, 4000));
305
340
 
306
341
  // Read comments: a couple of small scrolls with human pauses.
307
- await centerMouse(page);
342
+ await centerMouse(reader);
308
343
  for (let i = 0; i < randInt(2, 4); i++) {
309
- await humanScroll(page, randInt(200, 600));
310
- await page.waitForTimeout(randInt(1500, 4000));
344
+ await humanScroll(reader, randInt(200, 600));
345
+ await reader.waitForTimeout(randInt(1500, 4000));
311
346
  }
312
- log('info', '[nurture-fb] opened a post and read comments');
347
+ log('info', `[nurture-fb] opened a post and read comments${popup ? ' (tab mới)' : ''}`);
313
348
 
314
- // Back to the feed. A photo/post overlay closes with Escape, a real
315
- // navigation needs goBack try both, feed check happens next loop.
316
- if (page.url() !== urlBefore) {
349
+ if (popup) {
350
+ await popup.close({ runBeforeUnload: false }).catch(() => {});
351
+ } else if (page.url() !== urlBefore) {
317
352
  await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
318
353
  } else {
319
354
  await page.keyboard.press('Escape').catch(() => {});
@@ -325,7 +360,7 @@ async function openPostAndRead(page, log) {
325
360
  // Follow a page/creator surfaced by the feed itself (phase 3, ≤ config max).
326
361
  // Only clicks a Follow/Like-Page button that's already on screen — never hunts.
327
362
  async function followVisiblePage(page, log) {
328
- const marked = await page.evaluate((UNIT) => {
363
+ const { el, data } = await pick(page, (UNIT) => {
329
364
  for (const b of document.querySelectorAll("[role='button'], a[role='button']")) {
330
365
  const t = (b.innerText || '').trim();
331
366
  const aria = (b.getAttribute('aria-label') || '').trim();
@@ -336,26 +371,70 @@ async function followVisiblePage(page, log) {
336
371
  if (r.bottom < 0 || r.top > window.innerHeight) continue;
337
372
  const art = b.closest(UNIT);
338
373
  if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue; // not an ad's CTA
339
- b.setAttribute('__nur_follow__', '1');
340
- return label;
374
+ return { el: b, label };
341
375
  }
342
- return null;
343
- }, UNIT_SEL).catch(() => null);
344
- if (!marked) return false;
345
- let ok = false;
346
- try {
347
- const btn = page.locator("[__nur_follow__='1']").first();
348
- await btn.hover({ timeout: 2500 }).catch(() => {});
349
- await page.waitForTimeout(randInt(400, 1100));
350
- await btn.click({ timeout: 4000 });
376
+ return { el: null };
377
+ }, UNIT_SEL);
378
+ if (!el) return false;
379
+ const ok = await clickHandle(page, el, { hoverMs: [400, 1100], scroll: false });
380
+ if (ok) {
351
381
  await page.waitForTimeout(randInt(1200, 2200));
352
- ok = true;
353
- log('info', `[nurture-fb] followed a page ("${marked}")`);
354
- } catch {}
355
- await page.evaluate(() => document.querySelectorAll('[__nur_follow__]').forEach(e => e.removeAttribute('__nur_follow__'))).catch(() => {});
382
+ log('info', `[nurture-fb] followed a page ("${data.label}")`);
383
+ }
356
384
  return ok;
357
385
  }
358
386
 
387
+ // A short detour off the feed — the kind of thing that happens in any real
388
+ // session and never happened in ours: peek at notifications, or open one's
389
+ // own profile and glance at it. Best-effort, bilingual, never throws; if the
390
+ // control isn't where we expect, we simply stay on the feed.
391
+ async function sideTrip(page, log) {
392
+ const which = chance(0.6) ? 'notifications' : 'profile';
393
+ if (which === 'notifications') {
394
+ const { el } = await pick(page, () => {
395
+ for (const b of document.querySelectorAll("[role='banner'] [role='button'], [role='banner'] a, [aria-label]")) {
396
+ const a = (b.getAttribute('aria-label') || '').trim();
397
+ if (!/^(thông báo|notifications)$/i.test(a)) continue;
398
+ const r = b.getBoundingClientRect();
399
+ if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
400
+ return { el: b };
401
+ }
402
+ return { el: null };
403
+ });
404
+ if (!el) return false;
405
+ if (!(await clickHandle(page, el, { hoverMs: [300, 900], scroll: false }))) return false;
406
+ await page.waitForTimeout(randInt(2500, 6000));
407
+ if (chance(0.6)) { await humanWheel(page, randInt(150, 400)); await page.waitForTimeout(randInt(1500, 4000)); }
408
+ await page.keyboard.press('Escape').catch(() => {});
409
+ await page.waitForTimeout(randInt(800, 1800));
410
+ log('info', '[nurture-fb] ghé xem thông báo');
411
+ return true;
412
+ }
413
+ const urlBefore = page.url();
414
+ const { el } = await pick(page, () => {
415
+ for (const a of document.querySelectorAll("[role='navigation'] a[href], [role='banner'] a[href]")) {
416
+ const href = a.getAttribute('href') || '';
417
+ const label = ((a.getAttribute('aria-label') || '') + ' ' + (a.innerText || '')).trim();
418
+ if (!(/\/me\/?(\?|$)/.test(href) || /trang cá nhân của bạn|your profile/i.test(label))) continue;
419
+ const r = a.getBoundingClientRect();
420
+ if (r.width < 8 || r.height < 8 || a.offsetParent === null) continue;
421
+ return { el: a };
422
+ }
423
+ return { el: null };
424
+ });
425
+ if (!el) return false;
426
+ if (!(await clickHandle(page, el, { hoverMs: [300, 900], scroll: false }))) return false;
427
+ await page.waitForTimeout(randInt(3000, 6000));
428
+ if (page.url() === urlBefore) return false;
429
+ await centerMouse(page);
430
+ for (let i = 0; i < randInt(1, 3); i++) { await humanScroll(page, randInt(200, 600)); await page.waitForTimeout(randInt(1500, 4000)); }
431
+ await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
432
+ await page.waitForTimeout(randInt(2000, 4000));
433
+ await centerMouse(page);
434
+ log('info', '[nurture-fb] ghé trang cá nhân rồi quay lại feed');
435
+ return true;
436
+ }
437
+
359
438
  // Is this profile browsing AS A PAGE rather than as the person?
360
439
  //
361
440
  // Why it matters: a Page's feed contains only the Page's own posts, so there is
@@ -392,7 +471,7 @@ async function detectProfileMode(page) {
392
471
  // window is a wall of tabs.
393
472
  //
394
473
  // Closing them is safe: nurturing needs exactly one tab (the feed), and the
395
- // permalink click is forced to same-tab navigation in openPostAndRead.
474
+ // permalink tab openPostAndRead deliberately opens is closed by it as well.
396
475
  async function closeOtherTabs(context, keep, log) {
397
476
  const before = context.pages().length;
398
477
  let closed = 0;
@@ -406,12 +485,19 @@ async function closeOtherTabs(context, keep, log) {
406
485
  return closed;
407
486
  }
408
487
 
409
- // Anything Facebook manages to pop open mid-session dies on arrival. Registered
410
- // once per session; the listener is scoped to this context, which the runner
411
- // detaches from when the script returns.
412
- function guardSingleTab(context, keep, log) {
488
+ // Anything Facebook manages to pop open mid-session dies on arrival — EXCEPT
489
+ // while `gate.open` is set: then the tab is handed to whoever is waiting on
490
+ // `gate.resolve` (openPostAndRead clicking a target=_blank permalink) and they
491
+ // own closing it. Registered once per session; the listener is scoped to this
492
+ // context, which the runner detaches from when the script returns.
493
+ function guardSingleTab(context, keep, gate, log) {
413
494
  context.on('page', async (p) => {
414
495
  if (p === keep || p.isClosed()) return;
496
+ if (gate.open && typeof gate.resolve === 'function') {
497
+ const r = gate.resolve; gate.resolve = null; gate.open = false;
498
+ r(p);
499
+ return;
500
+ }
415
501
  try {
416
502
  await p.close({ runBeforeUnload: false });
417
503
  log('info', '[nurture-fb] tab mới bị Facebook bung ra — đã đóng ngay');
@@ -446,23 +532,33 @@ async function run({ page, context, payload, log }) {
446
532
  const phase = Math.max(1, Math.min(3, parseInt(payload.phase, 10) || 1));
447
533
  const dayIndex = parseInt(payload.day_index, 10) || 1;
448
534
 
449
- const sessionSec = randInt(cfg.session_min_sec ?? 480, cfg.session_max_sec ?? 900);
535
+ let sessionSec = randInt(cfg.session_min_sec ?? 480, cfg.session_max_sec ?? 900);
536
+ // ~8% of sessions end early — people get interrupted. The full-length
537
+ // "always runs to the deadline" shape was one of the tells.
538
+ const cutShort = chance(0.08);
539
+ if (cutShort) sessionSec = Math.round(sessionSec * randInt(35, 65) / 100);
450
540
  const likesTarget = randInt(cfg.likes_min ?? 0, cfg.likes_max ?? 0);
451
- const likeGap = Math.max(1, cfg.like_gap_posts ?? 5);
541
+ // like_gap_posts is a centre: the actual gap is re-rolled after every like
542
+ // (gap-2 .. gap+3), so likes never land on a fixed every-Nth-post beat.
543
+ const likeGapBase = Math.max(1, cfg.like_gap_posts ?? 5);
544
+ const rollLikeGap = () => randInt(Math.max(1, likeGapBase - 2), likeGapBase + 3);
545
+ let likeGap = rollLikeGap();
452
546
  const videosMax = cfg.videos_max ?? 3;
453
547
  const openPostMax = cfg.open_post_max ?? 0;
454
548
  const followMax = cfg.follow_page_max ?? 0;
455
549
  const watchMin = cfg.video_watch_min_sec ?? 10;
456
550
  const watchMax = Math.max(watchMin, cfg.video_watch_max_sec ?? 30);
457
551
 
458
- log('info', `[nurture-fb] session start — phase ${phase} (ngày ${dayIndex}), ~${Math.round(sessionSec / 60)} phút, like tối đa ${likesTarget}, video ≤${videosMax}`);
552
+ log('info', `[nurture-fb] session start — phase ${phase} (ngày ${dayIndex}), ~${Math.round(sessionSec / 60)} phút${cutShort ? ' (phiên ngắn)' : ''}, like tối đa ${likesTarget}, video ≤${videosMax}`);
459
553
 
460
554
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
461
555
 
462
556
  // Clear the profile's leftovers BEFORE loading the feed, then keep it clear.
557
+ // tabGate lets openPostAndRead claim ONE deliberately-opened tab.
558
+ const tabGate = { open: false, resolve: null };
463
559
  if (context) {
464
560
  await closeOtherTabs(context, page, log);
465
- guardSingleTab(context, page, log);
561
+ guardSingleTab(context, page, tabGate, log);
466
562
  }
467
563
 
468
564
  await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60000 });
@@ -489,12 +585,17 @@ async function run({ page, context, payload, log }) {
489
585
  if (payload.debug) await dumpFeedShot(page, log, 'feed-start');
490
586
 
491
587
  let postsSeen = 0, likes = 0, videos = 0, pagesFollowed = 0, postsOpened = 0;
492
- let postsSinceLike = likeGap; // allow the first like once enough posts scroll by
588
+ // Start part-way through the gap, never at it: the first like used to be
589
+ // allowed on the very first post seen, ~10s into every session.
590
+ let postsSinceLike = randInt(0, Math.max(0, likeGap - 2));
591
+ let sideTrips = 0;
592
+ const sideTripsMax = randInt(0, 2);
493
593
  let ticks = 0, emptyTicks = 0, stuckScrolls = 0;
494
594
  const deadline = t0 + sessionSec * 1000;
495
595
 
496
596
  const seenKeys = new Set(); // post ids already counted (survives DOM recycling)
497
597
  const likedKeys = new Set(); // post ids already attempted — never twice
598
+ const watchedKeys = new Set(); // feed videos already watched (by source)
498
599
  let lastScan = { scrollY: 0, articlesInDom: 0 };
499
600
 
500
601
  while (Date.now() < deadline) {
@@ -522,7 +623,13 @@ async function run({ page, context, payload, log }) {
522
623
  emptyTicks = 0;
523
624
  await assertAccountUsable(page, log);
524
625
  log('info', '[nurture-fb] feed idle — quay lại đầu trang');
525
- await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'smooth' })).catch(() => {});
626
+ for (let i = 0; i < 6; i++) {
627
+ const y = await page.evaluate(() => window.scrollY || document.documentElement.scrollTop || 0).catch(() => 0);
628
+ if (y < 50) break;
629
+ await humanWheel(page, -Math.min(y, randInt(700, 1400)));
630
+ await page.waitForTimeout(randInt(250, 700));
631
+ }
632
+ await page.evaluate(() => { if (window.scrollY > 50) window.scrollTo({ top: 0, behavior: 'smooth' }); }).catch(() => {});
526
633
  await page.waitForTimeout(randInt(2000, 4000));
527
634
  }
528
635
  } else {
@@ -531,20 +638,25 @@ async function run({ page, context, payload, log }) {
531
638
 
532
639
  // Watch a feed video now and then (all phases — watching is passive).
533
640
  if (videos < videosMax && chance(0.22)) {
534
- if (await watchFeedVideo(page, watchMin, watchMax, log)) videos++;
641
+ if (await watchFeedVideo(page, watchMin, watchMax, watchedKeys, log)) videos++;
535
642
  }
536
643
 
537
644
  // Like — phase 2+ only, never two posts in a row (enforced by likeGap).
538
645
  // scanFeed already marked the button; a post is only ever attempted once.
539
646
  if (wantLike && scan.likeKey) {
540
647
  likedKeys.add(scan.likeKey);
541
- if (await clickMarkedLike(page, log)) { likes++; postsSinceLike = 0; }
648
+ if (await clickPickedLike(page, scan.likeEl, log)) { likes++; postsSinceLike = 0; likeGap = rollLikeGap(); }
542
649
  else postsSinceLike = Math.max(0, postsSinceLike - 1); // try another post later
543
650
  }
544
651
 
652
+ // A detour off the feed now and then (any phase — it's just looking).
653
+ if (sideTrips < sideTripsMax && ticks > 6 && chance(0.035)) {
654
+ if (await sideTrip(page, log)) sideTrips++;
655
+ }
656
+
545
657
  // Open a post to read comments — phase 2+.
546
658
  if (postsOpened < openPostMax && chance(0.05)) {
547
- if (await openPostAndRead(page, log)) postsOpened++;
659
+ if (await openPostAndRead(page, context, tabGate, log)) postsOpened++;
548
660
  }
549
661
 
550
662
  // Follow a page the feed already showed — phase 3.