channel-worker 2.5.46 → 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 +89 -62
package/package.json
CHANGED
|
@@ -93,60 +93,73 @@ async function dismissDialogs(page, log) {
|
|
|
93
93
|
}
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
+
};
|
|
117
119
|
|
|
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
120
|
const mid = window.innerHeight / 2;
|
|
124
|
-
|
|
121
|
+
const posts = [];
|
|
122
|
+
let best = null, bestDist = Infinity, bestKey = '';
|
|
125
123
|
for (const art of document.querySelectorAll("[role='article']")) {
|
|
126
|
-
|
|
127
|
-
if (art.
|
|
124
|
+
// Comments render as NESTED articles — only top-level posts count.
|
|
125
|
+
if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
|
|
128
126
|
const r = art.getBoundingClientRect();
|
|
129
|
-
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;
|
|
130
133
|
const dist = Math.abs((r.top + r.bottom) / 2 - mid);
|
|
131
|
-
if (dist < bestDist) { best = art; bestDist = dist; }
|
|
134
|
+
if (dist < bestDist) { best = art; bestDist = dist; bestKey = key; }
|
|
132
135
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
}
|
|
145
150
|
}
|
|
146
|
-
return
|
|
147
|
-
|
|
148
|
-
|
|
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
|
+
}
|
|
149
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) {
|
|
150
163
|
const btn = page.locator("[__nur_like__='1']").first();
|
|
151
164
|
let ok = false;
|
|
152
165
|
try {
|
|
@@ -158,17 +171,14 @@ async function likeVisiblePost(page, log) {
|
|
|
158
171
|
// and React's handler ignores it (same lesson as the YouTube ad-skip).
|
|
159
172
|
await btn.click({ timeout: 4000 });
|
|
160
173
|
await page.waitForTimeout(randInt(900, 1800));
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
163
176
|
} catch (e) {
|
|
164
177
|
log('info', `[nurture-fb] like failed: ${String(e.message || e).slice(0, 80)}`);
|
|
165
178
|
}
|
|
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(() => {});
|
|
179
|
+
await page.evaluate(() => document.querySelectorAll('[__nur_like__]').forEach(e => e.removeAttribute('__nur_like__'))).catch(() => {});
|
|
170
180
|
if (ok) log('info', '[nurture-fb] liked a post');
|
|
171
|
-
return
|
|
181
|
+
return ok;
|
|
172
182
|
}
|
|
173
183
|
|
|
174
184
|
// Watch a video that's playing in the feed, counting only seconds where
|
|
@@ -180,7 +190,9 @@ async function watchFeedVideo(page, minSec, maxSec, log) {
|
|
|
180
190
|
const r = v.getBoundingClientRect();
|
|
181
191
|
if (r.height < 100 || r.bottom < 0 || r.top > window.innerHeight) continue;
|
|
182
192
|
const art = v.closest("[role='article']");
|
|
183
|
-
|
|
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;
|
|
184
196
|
if (v.hasAttribute('data-nur-watched')) continue;
|
|
185
197
|
v.setAttribute('data-nur-watched', '1');
|
|
186
198
|
if (v.paused) { try { v.play(); } catch {} }
|
|
@@ -221,7 +233,9 @@ async function openPostAndRead(page, log) {
|
|
|
221
233
|
let best = null, bestDist = Infinity;
|
|
222
234
|
for (const art of document.querySelectorAll("[role='article']")) {
|
|
223
235
|
if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
|
|
224
|
-
|
|
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;
|
|
225
239
|
const r = art.getBoundingClientRect();
|
|
226
240
|
if (r.bottom < 60 || r.top > window.innerHeight - 60) continue;
|
|
227
241
|
const dist = Math.abs((r.top + r.bottom) / 2 - mid);
|
|
@@ -231,7 +245,6 @@ async function openPostAndRead(page, log) {
|
|
|
231
245
|
for (const a of best.querySelectorAll("a[href*='/posts/'], a[href*='/permalink/'], a[href*='story_fbid'], a[href*='/photo']")) {
|
|
232
246
|
const r = a.getBoundingClientRect();
|
|
233
247
|
if (r.width < 8 || r.height < 8 || a.offsetParent === null) continue;
|
|
234
|
-
best.setAttribute('data-nur-opened', '1');
|
|
235
248
|
a.setAttribute('__nur_open__', '1');
|
|
236
249
|
return true;
|
|
237
250
|
}
|
|
@@ -280,7 +293,7 @@ async function followVisiblePage(page, log) {
|
|
|
280
293
|
if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
|
|
281
294
|
if (r.bottom < 0 || r.top > window.innerHeight) continue;
|
|
282
295
|
const art = b.closest("[role='article']");
|
|
283
|
-
if (art && art.
|
|
296
|
+
if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue; // not an ad's CTA
|
|
284
297
|
b.setAttribute('__nur_follow__', '1');
|
|
285
298
|
return label;
|
|
286
299
|
}
|
|
@@ -334,9 +347,21 @@ async function run({ page, payload, log }) {
|
|
|
334
347
|
let ticks = 0, emptyTicks = 0, stuckScrolls = 0;
|
|
335
348
|
const deadline = t0 + sessionSec * 1000;
|
|
336
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
|
+
|
|
337
354
|
while (Date.now() < deadline) {
|
|
338
355
|
ticks++;
|
|
339
|
-
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
|
+
}
|
|
340
365
|
postsSeen += fresh;
|
|
341
366
|
postsSinceLike += fresh;
|
|
342
367
|
// Feed that stops yielding posts for ~15 ticks = end of feed, a wall, or a
|
|
@@ -359,9 +384,11 @@ async function run({ page, payload, log }) {
|
|
|
359
384
|
}
|
|
360
385
|
|
|
361
386
|
// Like — phase 2+ only, never two posts in a row (enforced by likeGap).
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
|
365
392
|
}
|
|
366
393
|
|
|
367
394
|
// Open a post to read comments — phase 2+.
|
|
@@ -392,7 +419,7 @@ async function run({ page, payload, log }) {
|
|
|
392
419
|
// moment activity trips a threshold — better to stop than to keep poking.
|
|
393
420
|
if (ticks % 12 === 0) {
|
|
394
421
|
await assertAccountUsable(page, log);
|
|
395
|
-
log('info', `[nurture-fb] …${Math.round((Date.now() - t0) / 1000)}s: posts=${postsSeen} likes=${likes}/${likesTarget} videos=${videos}`);
|
|
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}`);
|
|
396
423
|
}
|
|
397
424
|
|
|
398
425
|
// Page refuses to move at all → this is not a nurture session, it's ten
|