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.
- package/lib/api-client.js +1 -1
- package/lib/command-poller.js +38 -2
- package/lib/nst-manager.js +25 -29
- package/package.json +1 -1
- package/scripts/lib/dom-pick.js +53 -0
- package/scripts/lib/human.js +147 -0
- package/scripts/nurture_facebook.js +222 -110
- package/scripts/upload_facebook.js +93 -66
- package/scripts/upload_facebook_photo.js +19 -17
- package/scripts/warmup_facebook.js +58 -65
- package/scripts/warmup_youtube.js +60 -19
|
@@ -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(-
|
|
42
|
-
await page
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
87
|
-
return v;
|
|
89
|
+
return { el: b, verb: v };
|
|
88
90
|
}
|
|
89
91
|
}
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
|
-
return null;
|
|
93
|
-
}, verbs)
|
|
94
|
-
if (!
|
|
95
|
-
|
|
96
|
-
await page.
|
|
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
|
|
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
|
-
|
|
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
|
|
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 })
|
|
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
|
|
197
|
-
//
|
|
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
|
|
200
|
-
|
|
203
|
+
async function clickPickedLike(page, btn, log) {
|
|
204
|
+
if (!btn) return false;
|
|
201
205
|
let ok = false;
|
|
202
206
|
try {
|
|
203
|
-
|
|
204
|
-
//
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
|
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
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
if (
|
|
236
|
-
return
|
|
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
|
|
239
|
-
}, UNIT_SEL
|
|
240
|
-
if (!
|
|
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
|
|
246
|
-
|
|
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
|
-
|
|
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(
|
|
285
|
+
await page.waitForTimeout(step);
|
|
257
286
|
}
|
|
258
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
294
|
-
}, UNIT_SEL)
|
|
295
|
-
if (!
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
await page
|
|
304
|
-
|
|
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(
|
|
342
|
+
await centerMouse(reader);
|
|
308
343
|
for (let i = 0; i < randInt(2, 4); i++) {
|
|
309
|
-
await humanScroll(
|
|
310
|
-
await
|
|
344
|
+
await humanScroll(reader, randInt(200, 600));
|
|
345
|
+
await reader.waitForTimeout(randInt(1500, 4000));
|
|
311
346
|
}
|
|
312
|
-
log('info',
|
|
347
|
+
log('info', `[nurture-fb] opened a post and read comments${popup ? ' (tab mới)' : ''}`);
|
|
313
348
|
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
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
|
|
340
|
-
return label;
|
|
374
|
+
return { el: b, label };
|
|
341
375
|
}
|
|
342
|
-
return null;
|
|
343
|
-
}, UNIT_SEL)
|
|
344
|
-
if (!
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
|
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
|
|
410
|
-
//
|
|
411
|
-
//
|
|
412
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|