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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.46",
3
+ "version": "2.5.48",
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": {
@@ -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
- // Count feed posts that entered the viewport since the last call, marking them
97
- // so they're counted once. Sponsored posts are marked too (so they're never
98
- // liked or watched) but do NOT count as "seen" an ad isn't reading.
99
- async function markPostsInView(page) {
100
- return page.evaluate(() => {
101
- let fresh = 0;
102
- for (const art of document.querySelectorAll("[role='article']")) {
103
- // Comments render as NESTED articles only top-level posts count.
104
- if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
105
- if (art.hasAttribute('data-nur-seen')) continue;
106
- const r = art.getBoundingClientRect();
107
- const inView = r.bottom > 0 && r.top < window.innerHeight && r.height > 80;
108
- if (!inView) continue;
109
- art.setAttribute('data-nur-seen', '1');
110
- const head = (art.innerText || '').slice(0, 400);
111
- if (/Được tài trợ|Sponsored|Tài trợ/i.test(head)) { art.setAttribute('data-nur-ad', '1'); continue; }
112
- fresh++;
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
- return fresh;
115
- }).catch(() => 0);
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
- let best = null, bestDist = Infinity;
125
- for (const art of document.querySelectorAll("[role='article']")) {
126
- if (art.parentElement && art.parentElement.closest("[role='article']")) continue; // comment
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 < 60 || r.top > window.innerHeight - 60 || r.height < 120) continue;
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
- if (!best) return null;
134
- if (/Được tài trợ|Sponsored/i.test((best.innerText || '').slice(0, 400))) { best.setAttribute('data-nur-ad', '1'); return null; }
135
- for (const b of best.querySelectorAll("[role='button']")) {
136
- const aria = (b.getAttribute('aria-label') || '').trim();
137
- if (!/^(thích|like)$/i.test(aria)) continue;
138
- if (b.getAttribute('aria-pressed') === 'true') continue; // already liked
139
- if (b.closest("[role='article']") !== best) continue; // belongs to a comment
140
- const r = b.getBoundingClientRect();
141
- if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
142
- best.setAttribute('data-nur-liked', 'pending');
143
- b.setAttribute('__nur_like__', '1');
144
- return { aria };
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 null;
147
- }).catch(() => null);
148
- if (!found) return false;
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
- ok = await btn.evaluate((el) => el.getAttribute('aria-pressed') === 'true').catch(() => true);
162
- if (ok === null || ok === undefined) ok = true;
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((liked) => {
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 !!ok;
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("[role='article']");
183
- if (art && art.hasAttribute('data-nur-ad')) continue; // never watch ads
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("[role='article']")) {
223
- if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
224
- if (art.hasAttribute('data-nur-ad') || art.hasAttribute('data-nur-opened')) continue;
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("[role='article']");
283
- if (art && art.hasAttribute('data-nur-ad')) continue; // not an ad's CTA
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 fresh = await markPostsInView(page);
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
- if (likes < likesTarget && postsSinceLike >= likeGap) {
363
- if (await likeVisiblePost(page, log)) { likes++; postsSinceLike = 0; }
364
- else postsSinceLike = Math.max(0, postsSinceLike - 1); // retry a bit later
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