channel-worker 2.5.45 → 2.5.47
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/scripts/nurture_facebook.js +144 -66
package/package.json
CHANGED
|
@@ -25,6 +25,44 @@ const { assertAccountUsable } = require('./lib/fb-guard');
|
|
|
25
25
|
function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
|
|
26
26
|
function chance(p) { return Math.random() < p; }
|
|
27
27
|
|
|
28
|
+
// Park the cursor over the middle of the feed column. MUST be done before any
|
|
29
|
+
// wheel event: mouse.wheel scrolls whatever is under the pointer, and the
|
|
30
|
+
// pointer starts at (0,0) — FB's fixed top-nav — so every wheel tick landed on
|
|
31
|
+
// a non-scrolling element. Measured on the first live session: 3 posts in 9.6
|
|
32
|
+
// minutes, six "feed idle" bailouts, because the page never actually moved.
|
|
33
|
+
async function centerMouse(page) {
|
|
34
|
+
const vp = await page.evaluate(() => ({ w: window.innerWidth, h: window.innerHeight })).catch(() => null);
|
|
35
|
+
if (!vp) return;
|
|
36
|
+
const jitter = (n) => n + randInt(-40, 40);
|
|
37
|
+
await page.mouse.move(jitter(Math.floor(vp.w / 2)), jitter(Math.floor(vp.h / 2))).catch(() => {});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Scroll by `dy` and VERIFY the page moved. Falls back through three rungs:
|
|
41
|
+
// wheel → re-center the cursor and wheel again → window.scrollBy. Returns the
|
|
42
|
+
// distance actually scrolled.
|
|
43
|
+
async function humanScroll(page, dy) {
|
|
44
|
+
const readY = () => page.evaluate(() => window.scrollY || document.documentElement.scrollTop || 0).catch(() => 0);
|
|
45
|
+
const before = await readY();
|
|
46
|
+
await page.mouse.wheel(0, dy).catch(() => {});
|
|
47
|
+
await page.waitForTimeout(randInt(350, 700));
|
|
48
|
+
let after = await readY();
|
|
49
|
+
if (Math.abs(after - before) < 5) {
|
|
50
|
+
await centerMouse(page);
|
|
51
|
+
await page.mouse.wheel(0, dy).catch(() => {});
|
|
52
|
+
await page.waitForTimeout(randInt(350, 700));
|
|
53
|
+
after = await readY();
|
|
54
|
+
}
|
|
55
|
+
if (Math.abs(after - before) < 5) {
|
|
56
|
+
// Last resort — a programmatic scroll. Not a trusted event, but scrolling
|
|
57
|
+
// isn't gated on trust the way clicks are, and a feed that never moves is
|
|
58
|
+
// worth less than a feed that moves synthetically.
|
|
59
|
+
await page.evaluate((d) => window.scrollBy(0, d), dy).catch(() => {});
|
|
60
|
+
await page.waitForTimeout(randInt(300, 600));
|
|
61
|
+
after = await readY();
|
|
62
|
+
}
|
|
63
|
+
return Math.abs(after - before);
|
|
64
|
+
}
|
|
65
|
+
|
|
28
66
|
// Dismiss FB cookie / cross-sell / "not now" popups. Best-effort, never throws.
|
|
29
67
|
// Never touches the composer or a reel viewer.
|
|
30
68
|
async function dismissDialogs(page, log) {
|
|
@@ -55,60 +93,73 @@ async function dismissDialogs(page, log) {
|
|
|
55
93
|
}
|
|
56
94
|
}
|
|
57
95
|
|
|
58
|
-
//
|
|
59
|
-
//
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
96
|
+
// Scan the posts currently in the viewport.
|
|
97
|
+
//
|
|
98
|
+
// Identity is the POST, never the DOM node. Facebook virtualises the feed: it
|
|
99
|
+
// recycles the same <div role="article"> element for a different post as you
|
|
100
|
+
// scroll, so a "seen" flag written onto the node marks every future post that
|
|
101
|
+
// lands in it as already-seen. First live run: 1 post counted in 4.5 minutes of
|
|
102
|
+
// real scrolling. The key is the permalink id (falling back to a text prefix),
|
|
103
|
+
// which travels with the content instead of the container.
|
|
104
|
+
//
|
|
105
|
+
// Also marks a like candidate in the same pass — one evaluate keeps the key
|
|
106
|
+
// derivation in exactly one place instead of drifting across two copies.
|
|
107
|
+
async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
|
|
108
|
+
return page.evaluate(({ skip, wantLike }) => {
|
|
109
|
+
const skipSet = new Set(skip);
|
|
110
|
+
const keyOf = (art) => {
|
|
111
|
+
for (const a of art.querySelectorAll('a[href]')) {
|
|
112
|
+
const h = a.getAttribute('href') || '';
|
|
113
|
+
const m = h.match(/\/posts\/[\w.]+|story_fbid=\d+|\/reel\/\d+|\/videos\/\d+|\/permalink\/\d+|\/photo\/?\?fbid=\d+/);
|
|
114
|
+
if (m) return m[0];
|
|
115
|
+
}
|
|
116
|
+
// No permalink (some suggested/aggregated cards) — fall back to content.
|
|
117
|
+
return 'txt:' + (art.innerText || '').replace(/\s+/g, ' ').slice(0, 90);
|
|
118
|
+
};
|
|
79
119
|
|
|
80
|
-
// Like ONE organic post near the centre of the viewport. Returns true on a
|
|
81
|
-
// confirmed like. Deliberately picky: skips ads, skips already-liked posts, and
|
|
82
|
-
// never grabs a COMMENT's like button (nested article).
|
|
83
|
-
async function likeVisiblePost(page, log) {
|
|
84
|
-
const found = await page.evaluate(() => {
|
|
85
120
|
const mid = window.innerHeight / 2;
|
|
86
|
-
|
|
121
|
+
const posts = [];
|
|
122
|
+
let best = null, bestDist = Infinity, bestKey = '';
|
|
87
123
|
for (const art of document.querySelectorAll("[role='article']")) {
|
|
88
|
-
|
|
89
|
-
if (art.
|
|
124
|
+
// Comments render as NESTED articles — only top-level posts count.
|
|
125
|
+
if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
|
|
90
126
|
const r = art.getBoundingClientRect();
|
|
91
|
-
if (r.bottom <
|
|
127
|
+
if (r.bottom < 0 || r.top > window.innerHeight || r.height < 80) continue;
|
|
128
|
+
const text = art.innerText || '';
|
|
129
|
+
const isAd = /Được tài trợ|Sponsored|Tài trợ/i.test(text.slice(0, 400));
|
|
130
|
+
const key = keyOf(art);
|
|
131
|
+
posts.push({ key, isAd });
|
|
132
|
+
if (!wantLike || isAd || skipSet.has(key)) continue;
|
|
92
133
|
const dist = Math.abs((r.top + r.bottom) / 2 - mid);
|
|
93
|
-
if (dist < bestDist) { best = art; bestDist = dist; }
|
|
134
|
+
if (dist < bestDist) { best = art; bestDist = dist; bestKey = key; }
|
|
94
135
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
const
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
136
|
+
|
|
137
|
+
let likeKey = null;
|
|
138
|
+
if (best) {
|
|
139
|
+
for (const b of best.querySelectorAll("[role='button']")) {
|
|
140
|
+
const aria = (b.getAttribute('aria-label') || '').trim();
|
|
141
|
+
if (!/^(thích|like)$/i.test(aria)) continue;
|
|
142
|
+
if (b.getAttribute('aria-pressed') === 'true') continue; // already liked
|
|
143
|
+
if (b.closest("[role='article']") !== best) continue; // a comment's button
|
|
144
|
+
const br = b.getBoundingClientRect();
|
|
145
|
+
if (br.width < 8 || br.height < 8 || b.offsetParent === null) continue;
|
|
146
|
+
b.setAttribute('__nur_like__', '1');
|
|
147
|
+
likeKey = bestKey;
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
107
150
|
}
|
|
108
|
-
return
|
|
109
|
-
|
|
110
|
-
|
|
151
|
+
return {
|
|
152
|
+
posts, likeKey,
|
|
153
|
+
scrollY: Math.round(window.scrollY || document.documentElement.scrollTop || 0),
|
|
154
|
+
articlesInDom: document.querySelectorAll("[role='article']").length,
|
|
155
|
+
};
|
|
156
|
+
}, { skip: skipKeys, wantLike }).catch(() => ({ posts: [], likeKey: null, scrollY: 0, articlesInDom: 0 }));
|
|
157
|
+
}
|
|
111
158
|
|
|
159
|
+
// Click the like button scanFeed already marked (`__nur_like__`). Returns true
|
|
160
|
+
// on a confirmed like. The picking — skip ads, skip already-liked, never a
|
|
161
|
+
// comment's button — happened in scanFeed; this only performs the click.
|
|
162
|
+
async function clickMarkedLike(page, log) {
|
|
112
163
|
const btn = page.locator("[__nur_like__='1']").first();
|
|
113
164
|
let ok = false;
|
|
114
165
|
try {
|
|
@@ -120,17 +171,14 @@ async function likeVisiblePost(page, log) {
|
|
|
120
171
|
// and React's handler ignores it (same lesson as the YouTube ad-skip).
|
|
121
172
|
await btn.click({ timeout: 4000 });
|
|
122
173
|
await page.waitForTimeout(randInt(900, 1800));
|
|
123
|
-
|
|
124
|
-
|
|
174
|
+
const pressed = await btn.evaluate((el) => el.getAttribute('aria-pressed')).catch(() => null);
|
|
175
|
+
ok = pressed === null ? true : pressed === 'true'; // button gone/re-rendered → treat as done
|
|
125
176
|
} catch (e) {
|
|
126
177
|
log('info', `[nurture-fb] like failed: ${String(e.message || e).slice(0, 80)}`);
|
|
127
178
|
}
|
|
128
|
-
await page.evaluate((
|
|
129
|
-
document.querySelectorAll('[__nur_like__]').forEach(e => e.removeAttribute('__nur_like__'));
|
|
130
|
-
document.querySelectorAll("[data-nur-liked='pending']").forEach(e => e.setAttribute('data-nur-liked', liked ? '1' : 'fail'));
|
|
131
|
-
}, !!ok).catch(() => {});
|
|
179
|
+
await page.evaluate(() => document.querySelectorAll('[__nur_like__]').forEach(e => e.removeAttribute('__nur_like__'))).catch(() => {});
|
|
132
180
|
if (ok) log('info', '[nurture-fb] liked a post');
|
|
133
|
-
return
|
|
181
|
+
return ok;
|
|
134
182
|
}
|
|
135
183
|
|
|
136
184
|
// Watch a video that's playing in the feed, counting only seconds where
|
|
@@ -142,7 +190,9 @@ async function watchFeedVideo(page, minSec, maxSec, log) {
|
|
|
142
190
|
const r = v.getBoundingClientRect();
|
|
143
191
|
if (r.height < 100 || r.bottom < 0 || r.top > window.innerHeight) continue;
|
|
144
192
|
const art = v.closest("[role='article']");
|
|
145
|
-
|
|
193
|
+
// Ads are re-detected from text — a node flag would be stale the moment
|
|
194
|
+
// FB recycles the article for a different post.
|
|
195
|
+
if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue;
|
|
146
196
|
if (v.hasAttribute('data-nur-watched')) continue;
|
|
147
197
|
v.setAttribute('data-nur-watched', '1');
|
|
148
198
|
if (v.paused) { try { v.play(); } catch {} }
|
|
@@ -183,7 +233,9 @@ async function openPostAndRead(page, log) {
|
|
|
183
233
|
let best = null, bestDist = Infinity;
|
|
184
234
|
for (const art of document.querySelectorAll("[role='article']")) {
|
|
185
235
|
if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
|
|
186
|
-
|
|
236
|
+
// Node-level flags can't be trusted (FB recycles articles), so ads are
|
|
237
|
+
// re-checked from the text right here.
|
|
238
|
+
if (/Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue;
|
|
187
239
|
const r = art.getBoundingClientRect();
|
|
188
240
|
if (r.bottom < 60 || r.top > window.innerHeight - 60) continue;
|
|
189
241
|
const dist = Math.abs((r.top + r.bottom) / 2 - mid);
|
|
@@ -193,7 +245,6 @@ async function openPostAndRead(page, log) {
|
|
|
193
245
|
for (const a of best.querySelectorAll("a[href*='/posts/'], a[href*='/permalink/'], a[href*='story_fbid'], a[href*='/photo']")) {
|
|
194
246
|
const r = a.getBoundingClientRect();
|
|
195
247
|
if (r.width < 8 || r.height < 8 || a.offsetParent === null) continue;
|
|
196
|
-
best.setAttribute('data-nur-opened', '1');
|
|
197
248
|
a.setAttribute('__nur_open__', '1');
|
|
198
249
|
return true;
|
|
199
250
|
}
|
|
@@ -211,8 +262,9 @@ async function openPostAndRead(page, log) {
|
|
|
211
262
|
await page.waitForTimeout(randInt(2500, 4000));
|
|
212
263
|
|
|
213
264
|
// Read comments: a couple of small scrolls with human pauses.
|
|
265
|
+
await centerMouse(page);
|
|
214
266
|
for (let i = 0; i < randInt(2, 4); i++) {
|
|
215
|
-
await page
|
|
267
|
+
await humanScroll(page, randInt(200, 600));
|
|
216
268
|
await page.waitForTimeout(randInt(1500, 4000));
|
|
217
269
|
}
|
|
218
270
|
log('info', '[nurture-fb] opened a post and read comments');
|
|
@@ -241,7 +293,7 @@ async function followVisiblePage(page, log) {
|
|
|
241
293
|
if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
|
|
242
294
|
if (r.bottom < 0 || r.top > window.innerHeight) continue;
|
|
243
295
|
const art = b.closest("[role='article']");
|
|
244
|
-
if (art && art.
|
|
296
|
+
if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue; // not an ad's CTA
|
|
245
297
|
b.setAttribute('__nur_follow__', '1');
|
|
246
298
|
return label;
|
|
247
299
|
}
|
|
@@ -288,14 +340,28 @@ async function run({ page, payload, log }) {
|
|
|
288
340
|
await assertAccountUsable(page, log);
|
|
289
341
|
await dismissDialogs(page, log);
|
|
290
342
|
|
|
343
|
+
await centerMouse(page); // wheel events need the cursor over the feed column
|
|
344
|
+
|
|
291
345
|
let postsSeen = 0, likes = 0, videos = 0, pagesFollowed = 0, postsOpened = 0;
|
|
292
346
|
let postsSinceLike = likeGap; // allow the first like once enough posts scroll by
|
|
293
|
-
let ticks = 0, emptyTicks = 0;
|
|
347
|
+
let ticks = 0, emptyTicks = 0, stuckScrolls = 0;
|
|
294
348
|
const deadline = t0 + sessionSec * 1000;
|
|
295
349
|
|
|
350
|
+
const seenKeys = new Set(); // post ids already counted (survives DOM recycling)
|
|
351
|
+
const likedKeys = new Set(); // post ids already attempted — never twice
|
|
352
|
+
let lastScan = { scrollY: 0, articlesInDom: 0 };
|
|
353
|
+
|
|
296
354
|
while (Date.now() < deadline) {
|
|
297
355
|
ticks++;
|
|
298
|
-
const
|
|
356
|
+
const wantLike = likes < likesTarget && postsSinceLike >= likeGap;
|
|
357
|
+
const scan = await scanFeed(page, { skipKeys: [...likedKeys], wantLike });
|
|
358
|
+
lastScan = scan;
|
|
359
|
+
let fresh = 0;
|
|
360
|
+
for (const p of scan.posts) {
|
|
361
|
+
if (seenKeys.has(p.key)) continue;
|
|
362
|
+
seenKeys.add(p.key);
|
|
363
|
+
if (!p.isAd) fresh++; // scrolling past an ad isn't reading
|
|
364
|
+
}
|
|
299
365
|
postsSeen += fresh;
|
|
300
366
|
postsSinceLike += fresh;
|
|
301
367
|
// Feed that stops yielding posts for ~15 ticks = end of feed, a wall, or a
|
|
@@ -318,9 +384,11 @@ async function run({ page, payload, log }) {
|
|
|
318
384
|
}
|
|
319
385
|
|
|
320
386
|
// Like — phase 2+ only, never two posts in a row (enforced by likeGap).
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
387
|
+
// scanFeed already marked the button; a post is only ever attempted once.
|
|
388
|
+
if (wantLike && scan.likeKey) {
|
|
389
|
+
likedKeys.add(scan.likeKey);
|
|
390
|
+
if (await clickMarkedLike(page, log)) { likes++; postsSinceLike = 0; }
|
|
391
|
+
else postsSinceLike = Math.max(0, postsSinceLike - 1); // try another post later
|
|
324
392
|
}
|
|
325
393
|
|
|
326
394
|
// Open a post to read comments — phase 2+.
|
|
@@ -339,16 +407,26 @@ async function run({ page, payload, log }) {
|
|
|
339
407
|
if (roll < 0.15) {
|
|
340
408
|
await page.waitForTimeout(randInt(6000, 20000)); // stop and read
|
|
341
409
|
} else if (roll < 0.23) {
|
|
342
|
-
await page
|
|
410
|
+
await humanScroll(page, -randInt(150, 400)); // re-read what just passed
|
|
343
411
|
await page.waitForTimeout(randInt(1200, 3000));
|
|
344
412
|
} else {
|
|
345
|
-
await page
|
|
413
|
+
const moved = await humanScroll(page, randInt(250, 900));
|
|
414
|
+
if (moved < 5) stuckScrolls++; else stuckScrolls = 0;
|
|
346
415
|
await page.waitForTimeout(randInt(800, 4000));
|
|
347
416
|
}
|
|
348
417
|
|
|
349
418
|
// Mid-session health check (~ every 12 ticks). FB throws a checkpoint the
|
|
350
419
|
// moment activity trips a threshold — better to stop than to keep poking.
|
|
351
|
-
if (ticks % 12 === 0)
|
|
420
|
+
if (ticks % 12 === 0) {
|
|
421
|
+
await assertAccountUsable(page, log);
|
|
422
|
+
log('info', `[nurture-fb] …${Math.round((Date.now() - t0) / 1000)}s: posts=${postsSeen} likes=${likes}/${likesTarget} videos=${videos} scrollY=${lastScan.scrollY} articles=${lastScan.articlesInDom}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Page refuses to move at all → this is not a nurture session, it's ten
|
|
426
|
+
// minutes of pretending. Fail loudly instead of banking a fake session.
|
|
427
|
+
if (stuckScrolls >= 20) {
|
|
428
|
+
throw new Error(`nurture-fb: trang không cuộn được sau ${stuckScrolls} lần thử (posts=${postsSeen}) — layout FB đổi hoặc feed không tải`);
|
|
429
|
+
}
|
|
352
430
|
}
|
|
353
431
|
|
|
354
432
|
const durationSec = Math.round((Date.now() - t0) / 1000);
|