channel-worker 2.5.46 → 2.5.48
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 +122 -72
package/package.json
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
|
|
22
22
|
const { assertAccountUsable } = require('./lib/fb-guard');
|
|
23
23
|
|
|
24
|
+
// Every markup shape Facebook has used for "one post in the feed". Used by the
|
|
25
|
+
// secondary helpers (ad check, open-post) via closest()/querySelectorAll;
|
|
26
|
+
// scanFeed picks the single best-yielding one per render instead.
|
|
27
|
+
const UNIT_SEL = "div[role='feed'] > div, div[aria-posinset], [data-pagelet^='FeedUnit'], [role='article']";
|
|
28
|
+
|
|
24
29
|
// ─── helpers ────────────────────────────────────────────────────────────────
|
|
25
30
|
function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
|
|
26
31
|
function chance(p) { return Math.random() < p; }
|
|
@@ -93,60 +98,91 @@ async function dismissDialogs(page, log) {
|
|
|
93
98
|
}
|
|
94
99
|
}
|
|
95
100
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
101
|
+
// Scan the posts currently in the viewport.
|
|
102
|
+
//
|
|
103
|
+
// Identity is the POST, never the DOM node. Facebook virtualises the feed: it
|
|
104
|
+
// recycles the same <div role="article"> element for a different post as you
|
|
105
|
+
// scroll, so a "seen" flag written onto the node marks every future post that
|
|
106
|
+
// lands in it as already-seen. First live run: 1 post counted in 4.5 minutes of
|
|
107
|
+
// real scrolling. The key is the permalink id (falling back to a text prefix),
|
|
108
|
+
// which travels with the content instead of the container.
|
|
109
|
+
//
|
|
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.
|
|
112
|
+
async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
|
|
113
|
+
return page.evaluate(({ skip, wantLike }) => {
|
|
114
|
+
const skipSet = new Set(skip);
|
|
115
|
+
|
|
116
|
+
// WHICH element is "a post"? Not a fixed selector — Facebook's feed markup
|
|
117
|
+
// moves. Measured on the live account 2026-08-11: the whole document held
|
|
118
|
+
// exactly ONE [role='article'] while the feed scrolled fine, so a hardcoded
|
|
119
|
+
// selector counted 1 post per 10 minutes. Try the known shapes and keep
|
|
120
|
+
// whichever yields the most post-sized blocks on THIS render.
|
|
121
|
+
const CANDIDATES = [
|
|
122
|
+
"div[role='feed'] > div",
|
|
123
|
+
'div[aria-posinset]',
|
|
124
|
+
"[data-pagelet^='FeedUnit']",
|
|
125
|
+
"[role='article']",
|
|
126
|
+
];
|
|
127
|
+
let sel = null, nodes = [];
|
|
128
|
+
for (const s of CANDIDATES) {
|
|
129
|
+
const found = [...document.querySelectorAll(s)].filter((e) => e.getBoundingClientRect().height > 80);
|
|
130
|
+
if (found.length > nodes.length) { nodes = found; sel = s; }
|
|
113
131
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
132
|
+
// Drop blocks nested inside another block of the same kind (comments,
|
|
133
|
+
// embedded shares) — only outermost units are posts.
|
|
134
|
+
const units = nodes.filter((n) => !nodes.some((o) => o !== n && o.contains(n)));
|
|
135
|
+
const keyOf = (art) => {
|
|
136
|
+
for (const a of art.querySelectorAll('a[href]')) {
|
|
137
|
+
const h = a.getAttribute('href') || '';
|
|
138
|
+
const m = h.match(/\/posts\/[\w.]+|story_fbid=\d+|\/reel\/\d+|\/videos\/\d+|\/permalink\/\d+|\/photo\/?\?fbid=\d+/);
|
|
139
|
+
if (m) return m[0];
|
|
140
|
+
}
|
|
141
|
+
// No permalink (some suggested/aggregated cards) — fall back to content.
|
|
142
|
+
return 'txt:' + (art.innerText || '').replace(/\s+/g, ' ').slice(0, 90);
|
|
143
|
+
};
|
|
117
144
|
|
|
118
|
-
// Like ONE organic post near the centre of the viewport. Returns true on a
|
|
119
|
-
// confirmed like. Deliberately picky: skips ads, skips already-liked posts, and
|
|
120
|
-
// never grabs a COMMENT's like button (nested article).
|
|
121
|
-
async function likeVisiblePost(page, log) {
|
|
122
|
-
const found = await page.evaluate(() => {
|
|
123
145
|
const mid = window.innerHeight / 2;
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if (art.hasAttribute('data-nur-ad') || art.hasAttribute('data-nur-liked')) continue;
|
|
146
|
+
const posts = [];
|
|
147
|
+
let best = null, bestDist = Infinity, bestKey = '';
|
|
148
|
+
for (const art of units) {
|
|
128
149
|
const r = art.getBoundingClientRect();
|
|
129
|
-
if (r.bottom <
|
|
150
|
+
if (r.bottom < 0 || r.top > window.innerHeight || r.height < 80) continue;
|
|
151
|
+
const text = art.innerText || '';
|
|
152
|
+
const isAd = /Được tài trợ|Sponsored|Tài trợ/i.test(text.slice(0, 400));
|
|
153
|
+
const key = keyOf(art);
|
|
154
|
+
posts.push({ key, isAd });
|
|
155
|
+
if (!wantLike || isAd || skipSet.has(key)) continue;
|
|
130
156
|
const dist = Math.abs((r.top + r.bottom) / 2 - mid);
|
|
131
|
-
if (dist < bestDist) { best = art; bestDist = dist; }
|
|
157
|
+
if (dist < bestDist) { best = art; bestDist = dist; bestKey = key; }
|
|
132
158
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
159
|
+
|
|
160
|
+
let likeKey = null;
|
|
161
|
+
if (best) {
|
|
162
|
+
for (const b of best.querySelectorAll("[role='button']")) {
|
|
163
|
+
const aria = (b.getAttribute('aria-label') || '').trim();
|
|
164
|
+
if (!/^(thích|like)$/i.test(aria)) continue;
|
|
165
|
+
if (b.getAttribute('aria-pressed') === 'true') continue; // already liked
|
|
166
|
+
const br = b.getBoundingClientRect();
|
|
167
|
+
if (br.width < 8 || br.height < 8 || b.offsetParent === null) continue;
|
|
168
|
+
b.setAttribute('__nur_like__', '1');
|
|
169
|
+
likeKey = bestKey;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
145
172
|
}
|
|
146
|
-
return
|
|
147
|
-
|
|
148
|
-
|
|
173
|
+
return {
|
|
174
|
+
posts, likeKey,
|
|
175
|
+
scrollY: Math.round(window.scrollY || document.documentElement.scrollTop || 0),
|
|
176
|
+
articlesInDom: units.length,
|
|
177
|
+
selector: sel || '(none)',
|
|
178
|
+
};
|
|
179
|
+
}, { skip: skipKeys, wantLike }).catch(() => ({ posts: [], likeKey: null, scrollY: 0, articlesInDom: 0 }));
|
|
180
|
+
}
|
|
149
181
|
|
|
182
|
+
// Click the like button scanFeed already marked (`__nur_like__`). Returns true
|
|
183
|
+
// on a confirmed like. The picking — skip ads, skip already-liked, never a
|
|
184
|
+
// comment's button — happened in scanFeed; this only performs the click.
|
|
185
|
+
async function clickMarkedLike(page, log) {
|
|
150
186
|
const btn = page.locator("[__nur_like__='1']").first();
|
|
151
187
|
let ok = false;
|
|
152
188
|
try {
|
|
@@ -158,36 +194,35 @@ async function likeVisiblePost(page, log) {
|
|
|
158
194
|
// and React's handler ignores it (same lesson as the YouTube ad-skip).
|
|
159
195
|
await btn.click({ timeout: 4000 });
|
|
160
196
|
await page.waitForTimeout(randInt(900, 1800));
|
|
161
|
-
|
|
162
|
-
|
|
197
|
+
const pressed = await btn.evaluate((el) => el.getAttribute('aria-pressed')).catch(() => null);
|
|
198
|
+
ok = pressed === null ? true : pressed === 'true'; // button gone/re-rendered → treat as done
|
|
163
199
|
} catch (e) {
|
|
164
200
|
log('info', `[nurture-fb] like failed: ${String(e.message || e).slice(0, 80)}`);
|
|
165
201
|
}
|
|
166
|
-
await page.evaluate((
|
|
167
|
-
document.querySelectorAll('[__nur_like__]').forEach(e => e.removeAttribute('__nur_like__'));
|
|
168
|
-
document.querySelectorAll("[data-nur-liked='pending']").forEach(e => e.setAttribute('data-nur-liked', liked ? '1' : 'fail'));
|
|
169
|
-
}, !!ok).catch(() => {});
|
|
202
|
+
await page.evaluate(() => document.querySelectorAll('[__nur_like__]').forEach(e => e.removeAttribute('__nur_like__'))).catch(() => {});
|
|
170
203
|
if (ok) log('info', '[nurture-fb] liked a post');
|
|
171
|
-
return
|
|
204
|
+
return ok;
|
|
172
205
|
}
|
|
173
206
|
|
|
174
207
|
// Watch a video that's playing in the feed, counting only seconds where
|
|
175
208
|
// playback actually ADVANCES (a frozen/buffering player must not bank time).
|
|
176
209
|
async function watchFeedVideo(page, minSec, maxSec, log) {
|
|
177
|
-
const has = await page.evaluate(() => {
|
|
210
|
+
const has = await page.evaluate((UNIT) => {
|
|
178
211
|
const vids = [...document.querySelectorAll('video')];
|
|
179
212
|
for (const v of vids) {
|
|
180
213
|
const r = v.getBoundingClientRect();
|
|
181
214
|
if (r.height < 100 || r.bottom < 0 || r.top > window.innerHeight) continue;
|
|
182
|
-
const art = v.closest(
|
|
183
|
-
|
|
215
|
+
const art = v.closest(UNIT);
|
|
216
|
+
// Ads are re-detected from text — a node flag would be stale the moment
|
|
217
|
+
// FB recycles the article for a different post.
|
|
218
|
+
if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue;
|
|
184
219
|
if (v.hasAttribute('data-nur-watched')) continue;
|
|
185
220
|
v.setAttribute('data-nur-watched', '1');
|
|
186
221
|
if (v.paused) { try { v.play(); } catch {} }
|
|
187
222
|
return true;
|
|
188
223
|
}
|
|
189
224
|
return false;
|
|
190
|
-
}).catch(() => false);
|
|
225
|
+
}, UNIT_SEL).catch(() => false);
|
|
191
226
|
if (!has) return false;
|
|
192
227
|
|
|
193
228
|
const budgetMs = randInt(minSec, maxSec) * 1000;
|
|
@@ -216,12 +251,14 @@ async function watchFeedVideo(page, minSec, maxSec, log) {
|
|
|
216
251
|
// Phase 2+. Best-effort: if the click doesn't navigate, nothing is lost.
|
|
217
252
|
async function openPostAndRead(page, log) {
|
|
218
253
|
const urlBefore = page.url();
|
|
219
|
-
const marked = await page.evaluate(() => {
|
|
254
|
+
const marked = await page.evaluate((UNIT) => {
|
|
220
255
|
const mid = window.innerHeight / 2;
|
|
221
256
|
let best = null, bestDist = Infinity;
|
|
222
|
-
for (const art of document.querySelectorAll(
|
|
223
|
-
if (art.parentElement && art.parentElement.closest(
|
|
224
|
-
|
|
257
|
+
for (const art of document.querySelectorAll(UNIT)) {
|
|
258
|
+
if (art.parentElement && art.parentElement.closest(UNIT)) continue;
|
|
259
|
+
// Node-level flags can't be trusted (FB recycles articles), so ads are
|
|
260
|
+
// re-checked from the text right here.
|
|
261
|
+
if (/Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue;
|
|
225
262
|
const r = art.getBoundingClientRect();
|
|
226
263
|
if (r.bottom < 60 || r.top > window.innerHeight - 60) continue;
|
|
227
264
|
const dist = Math.abs((r.top + r.bottom) / 2 - mid);
|
|
@@ -231,12 +268,11 @@ async function openPostAndRead(page, log) {
|
|
|
231
268
|
for (const a of best.querySelectorAll("a[href*='/posts/'], a[href*='/permalink/'], a[href*='story_fbid'], a[href*='/photo']")) {
|
|
232
269
|
const r = a.getBoundingClientRect();
|
|
233
270
|
if (r.width < 8 || r.height < 8 || a.offsetParent === null) continue;
|
|
234
|
-
best.setAttribute('data-nur-opened', '1');
|
|
235
271
|
a.setAttribute('__nur_open__', '1');
|
|
236
272
|
return true;
|
|
237
273
|
}
|
|
238
274
|
return false;
|
|
239
|
-
}).catch(() => false);
|
|
275
|
+
}, UNIT_SEL).catch(() => false);
|
|
240
276
|
if (!marked) return false;
|
|
241
277
|
|
|
242
278
|
try {
|
|
@@ -270,7 +306,7 @@ async function openPostAndRead(page, log) {
|
|
|
270
306
|
// Follow a page/creator surfaced by the feed itself (phase 3, ≤ config max).
|
|
271
307
|
// Only clicks a Follow/Like-Page button that's already on screen — never hunts.
|
|
272
308
|
async function followVisiblePage(page, log) {
|
|
273
|
-
const marked = await page.evaluate(() => {
|
|
309
|
+
const marked = await page.evaluate((UNIT) => {
|
|
274
310
|
for (const b of document.querySelectorAll("[role='button'], a[role='button']")) {
|
|
275
311
|
const t = (b.innerText || '').trim();
|
|
276
312
|
const aria = (b.getAttribute('aria-label') || '').trim();
|
|
@@ -279,13 +315,13 @@ async function followVisiblePage(page, log) {
|
|
|
279
315
|
const r = b.getBoundingClientRect();
|
|
280
316
|
if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
|
|
281
317
|
if (r.bottom < 0 || r.top > window.innerHeight) continue;
|
|
282
|
-
const art = b.closest(
|
|
283
|
-
if (art && art.
|
|
318
|
+
const art = b.closest(UNIT);
|
|
319
|
+
if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue; // not an ad's CTA
|
|
284
320
|
b.setAttribute('__nur_follow__', '1');
|
|
285
321
|
return label;
|
|
286
322
|
}
|
|
287
323
|
return null;
|
|
288
|
-
}).catch(() => null);
|
|
324
|
+
}, UNIT_SEL).catch(() => null);
|
|
289
325
|
if (!marked) return false;
|
|
290
326
|
let ok = false;
|
|
291
327
|
try {
|
|
@@ -334,9 +370,21 @@ async function run({ page, payload, log }) {
|
|
|
334
370
|
let ticks = 0, emptyTicks = 0, stuckScrolls = 0;
|
|
335
371
|
const deadline = t0 + sessionSec * 1000;
|
|
336
372
|
|
|
373
|
+
const seenKeys = new Set(); // post ids already counted (survives DOM recycling)
|
|
374
|
+
const likedKeys = new Set(); // post ids already attempted — never twice
|
|
375
|
+
let lastScan = { scrollY: 0, articlesInDom: 0 };
|
|
376
|
+
|
|
337
377
|
while (Date.now() < deadline) {
|
|
338
378
|
ticks++;
|
|
339
|
-
const
|
|
379
|
+
const wantLike = likes < likesTarget && postsSinceLike >= likeGap;
|
|
380
|
+
const scan = await scanFeed(page, { skipKeys: [...likedKeys], wantLike });
|
|
381
|
+
lastScan = scan;
|
|
382
|
+
let fresh = 0;
|
|
383
|
+
for (const p of scan.posts) {
|
|
384
|
+
if (seenKeys.has(p.key)) continue;
|
|
385
|
+
seenKeys.add(p.key);
|
|
386
|
+
if (!p.isAd) fresh++; // scrolling past an ad isn't reading
|
|
387
|
+
}
|
|
340
388
|
postsSeen += fresh;
|
|
341
389
|
postsSinceLike += fresh;
|
|
342
390
|
// Feed that stops yielding posts for ~15 ticks = end of feed, a wall, or a
|
|
@@ -359,9 +407,11 @@ async function run({ page, payload, log }) {
|
|
|
359
407
|
}
|
|
360
408
|
|
|
361
409
|
// Like — phase 2+ only, never two posts in a row (enforced by likeGap).
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
410
|
+
// scanFeed already marked the button; a post is only ever attempted once.
|
|
411
|
+
if (wantLike && scan.likeKey) {
|
|
412
|
+
likedKeys.add(scan.likeKey);
|
|
413
|
+
if (await clickMarkedLike(page, log)) { likes++; postsSinceLike = 0; }
|
|
414
|
+
else postsSinceLike = Math.max(0, postsSinceLike - 1); // try another post later
|
|
365
415
|
}
|
|
366
416
|
|
|
367
417
|
// Open a post to read comments — phase 2+.
|
|
@@ -392,7 +442,7 @@ async function run({ page, payload, log }) {
|
|
|
392
442
|
// moment activity trips a threshold — better to stop than to keep poking.
|
|
393
443
|
if (ticks % 12 === 0) {
|
|
394
444
|
await assertAccountUsable(page, log);
|
|
395
|
-
log('info', `[nurture-fb] …${Math.round((Date.now() - t0) / 1000)}s: posts=${postsSeen} likes=${likes}/${likesTarget} videos=${videos}`);
|
|
445
|
+
log('info', `[nurture-fb] …${Math.round((Date.now() - t0) / 1000)}s: posts=${postsSeen} likes=${likes}/${likesTarget} videos=${videos} scrollY=${lastScan.scrollY} units=${lastScan.articlesInDom} sel=${lastScan.selector}`);
|
|
396
446
|
}
|
|
397
447
|
|
|
398
448
|
// Page refuses to move at all → this is not a nurture session, it's ten
|