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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.45",
3
+ "version": "2.5.47",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -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
- // Count feed posts that entered the viewport since the last call, marking them
59
- // so they're counted once. Sponsored posts are marked too (so they're never
60
- // liked or watched) but do NOT count as "seen" an ad isn't reading.
61
- async function markPostsInView(page) {
62
- return page.evaluate(() => {
63
- let fresh = 0;
64
- for (const art of document.querySelectorAll("[role='article']")) {
65
- // Comments render as NESTED articles only top-level posts count.
66
- if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
67
- if (art.hasAttribute('data-nur-seen')) continue;
68
- const r = art.getBoundingClientRect();
69
- const inView = r.bottom > 0 && r.top < window.innerHeight && r.height > 80;
70
- if (!inView) continue;
71
- art.setAttribute('data-nur-seen', '1');
72
- const head = (art.innerText || '').slice(0, 400);
73
- if (/Được tài trợ|Sponsored|Tài trợ/i.test(head)) { art.setAttribute('data-nur-ad', '1'); continue; }
74
- fresh++;
75
- }
76
- return fresh;
77
- }).catch(() => 0);
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
- let best = null, bestDist = Infinity;
121
+ const posts = [];
122
+ let best = null, bestDist = Infinity, bestKey = '';
87
123
  for (const art of document.querySelectorAll("[role='article']")) {
88
- if (art.parentElement && art.parentElement.closest("[role='article']")) continue; // comment
89
- if (art.hasAttribute('data-nur-ad') || art.hasAttribute('data-nur-liked')) continue;
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 < 60 || r.top > window.innerHeight - 60 || r.height < 120) continue;
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
- if (!best) return null;
96
- if (/Được tài trợ|Sponsored/i.test((best.innerText || '').slice(0, 400))) { best.setAttribute('data-nur-ad', '1'); return null; }
97
- for (const b of best.querySelectorAll("[role='button']")) {
98
- const aria = (b.getAttribute('aria-label') || '').trim();
99
- if (!/^(thích|like)$/i.test(aria)) continue;
100
- if (b.getAttribute('aria-pressed') === 'true') continue; // already liked
101
- if (b.closest("[role='article']") !== best) continue; // belongs to a comment
102
- const r = b.getBoundingClientRect();
103
- if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
104
- best.setAttribute('data-nur-liked', 'pending');
105
- b.setAttribute('__nur_like__', '1');
106
- return { aria };
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 null;
109
- }).catch(() => null);
110
- if (!found) return false;
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
- ok = await btn.evaluate((el) => el.getAttribute('aria-pressed') === 'true').catch(() => true);
124
- if (ok === null || ok === undefined) ok = true;
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((liked) => {
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 !!ok;
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
- if (art && art.hasAttribute('data-nur-ad')) continue; // never watch ads
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
- if (art.hasAttribute('data-nur-ad') || art.hasAttribute('data-nur-opened')) continue;
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.mouse.wheel(0, randInt(200, 600)).catch(() => {});
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.hasAttribute('data-nur-ad')) continue; // not an ad's CTA
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 fresh = await markPostsInView(page);
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
- if (likes < likesTarget && postsSinceLike >= likeGap) {
322
- if (await likeVisiblePost(page, log)) { likes++; postsSinceLike = 0; }
323
- else postsSinceLike = Math.max(0, postsSinceLike - 1); // retry a bit later
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.mouse.wheel(0, -randInt(150, 400)).catch(() => {});
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.mouse.wheel(0, randInt(250, 900)).catch(() => {});
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) await assertAccountUsable(page, log);
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);