channel-worker 2.5.47 → 2.5.49

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.47",
3
+ "version": "2.5.49",
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; }
@@ -107,6 +112,26 @@ async function dismissDialogs(page, log) {
107
112
  async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
108
113
  return page.evaluate(({ skip, wantLike }) => {
109
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; }
131
+ }
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)));
110
135
  const keyOf = (art) => {
111
136
  for (const a of art.querySelectorAll('a[href]')) {
112
137
  const h = a.getAttribute('href') || '';
@@ -120,9 +145,7 @@ async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
120
145
  const mid = window.innerHeight / 2;
121
146
  const posts = [];
122
147
  let best = null, bestDist = Infinity, bestKey = '';
123
- for (const art of document.querySelectorAll("[role='article']")) {
124
- // Comments render as NESTED articles — only top-level posts count.
125
- if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
148
+ for (const art of units) {
126
149
  const r = art.getBoundingClientRect();
127
150
  if (r.bottom < 0 || r.top > window.innerHeight || r.height < 80) continue;
128
151
  const text = art.innerText || '';
@@ -140,7 +163,6 @@ async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
140
163
  const aria = (b.getAttribute('aria-label') || '').trim();
141
164
  if (!/^(thích|like)$/i.test(aria)) continue;
142
165
  if (b.getAttribute('aria-pressed') === 'true') continue; // already liked
143
- if (b.closest("[role='article']") !== best) continue; // a comment's button
144
166
  const br = b.getBoundingClientRect();
145
167
  if (br.width < 8 || br.height < 8 || b.offsetParent === null) continue;
146
168
  b.setAttribute('__nur_like__', '1');
@@ -148,10 +170,25 @@ async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
148
170
  break;
149
171
  }
150
172
  }
173
+ // Which element is actually scrolling? Facebook has shipped both a
174
+ // window-scrolled feed and one inside its own overflow container; if we
175
+ // measure the wrong one, "the page didn't move" is a false alarm.
176
+ let scroller = 'window', scrollTop = Math.round(window.scrollY || 0);
177
+ if (!scrollTop) {
178
+ for (const el of document.querySelectorAll('div')) {
179
+ if (el.scrollTop > 50 && el.scrollHeight > el.clientHeight + 200) {
180
+ scroller = 'div.' + String(el.className || '').split(' ')[0];
181
+ scrollTop = Math.round(el.scrollTop);
182
+ break;
183
+ }
184
+ }
185
+ }
151
186
  return {
152
- posts, likeKey,
187
+ posts, likeKey, scroller, scrollTop,
153
188
  scrollY: Math.round(window.scrollY || document.documentElement.scrollTop || 0),
154
- articlesInDom: document.querySelectorAll("[role='article']").length,
189
+ articlesInDom: units.length,
190
+ selector: sel || '(none)',
191
+ sample: posts.slice(0, 3).map((p) => p.key.slice(0, 46)),
155
192
  };
156
193
  }, { skip: skipKeys, wantLike }).catch(() => ({ posts: [], likeKey: null, scrollY: 0, articlesInDom: 0 }));
157
194
  }
@@ -184,12 +221,12 @@ async function clickMarkedLike(page, log) {
184
221
  // Watch a video that's playing in the feed, counting only seconds where
185
222
  // playback actually ADVANCES (a frozen/buffering player must not bank time).
186
223
  async function watchFeedVideo(page, minSec, maxSec, log) {
187
- const has = await page.evaluate(() => {
224
+ const has = await page.evaluate((UNIT) => {
188
225
  const vids = [...document.querySelectorAll('video')];
189
226
  for (const v of vids) {
190
227
  const r = v.getBoundingClientRect();
191
228
  if (r.height < 100 || r.bottom < 0 || r.top > window.innerHeight) continue;
192
- const art = v.closest("[role='article']");
229
+ const art = v.closest(UNIT);
193
230
  // Ads are re-detected from text — a node flag would be stale the moment
194
231
  // FB recycles the article for a different post.
195
232
  if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue;
@@ -199,7 +236,7 @@ async function watchFeedVideo(page, minSec, maxSec, log) {
199
236
  return true;
200
237
  }
201
238
  return false;
202
- }).catch(() => false);
239
+ }, UNIT_SEL).catch(() => false);
203
240
  if (!has) return false;
204
241
 
205
242
  const budgetMs = randInt(minSec, maxSec) * 1000;
@@ -228,11 +265,11 @@ async function watchFeedVideo(page, minSec, maxSec, log) {
228
265
  // Phase 2+. Best-effort: if the click doesn't navigate, nothing is lost.
229
266
  async function openPostAndRead(page, log) {
230
267
  const urlBefore = page.url();
231
- const marked = await page.evaluate(() => {
268
+ const marked = await page.evaluate((UNIT) => {
232
269
  const mid = window.innerHeight / 2;
233
270
  let best = null, bestDist = Infinity;
234
- for (const art of document.querySelectorAll("[role='article']")) {
235
- if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
271
+ for (const art of document.querySelectorAll(UNIT)) {
272
+ if (art.parentElement && art.parentElement.closest(UNIT)) continue;
236
273
  // Node-level flags can't be trusted (FB recycles articles), so ads are
237
274
  // re-checked from the text right here.
238
275
  if (/Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue;
@@ -249,7 +286,7 @@ async function openPostAndRead(page, log) {
249
286
  return true;
250
287
  }
251
288
  return false;
252
- }).catch(() => false);
289
+ }, UNIT_SEL).catch(() => false);
253
290
  if (!marked) return false;
254
291
 
255
292
  try {
@@ -283,7 +320,7 @@ async function openPostAndRead(page, log) {
283
320
  // Follow a page/creator surfaced by the feed itself (phase 3, ≤ config max).
284
321
  // Only clicks a Follow/Like-Page button that's already on screen — never hunts.
285
322
  async function followVisiblePage(page, log) {
286
- const marked = await page.evaluate(() => {
323
+ const marked = await page.evaluate((UNIT) => {
287
324
  for (const b of document.querySelectorAll("[role='button'], a[role='button']")) {
288
325
  const t = (b.innerText || '').trim();
289
326
  const aria = (b.getAttribute('aria-label') || '').trim();
@@ -292,13 +329,13 @@ async function followVisiblePage(page, log) {
292
329
  const r = b.getBoundingClientRect();
293
330
  if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
294
331
  if (r.bottom < 0 || r.top > window.innerHeight) continue;
295
- const art = b.closest("[role='article']");
332
+ const art = b.closest(UNIT);
296
333
  if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue; // not an ad's CTA
297
334
  b.setAttribute('__nur_follow__', '1');
298
335
  return label;
299
336
  }
300
337
  return null;
301
- }).catch(() => null);
338
+ }, UNIT_SEL).catch(() => null);
302
339
  if (!marked) return false;
303
340
  let ok = false;
304
341
  try {
@@ -362,6 +399,11 @@ async function run({ page, payload, log }) {
362
399
  seenKeys.add(p.key);
363
400
  if (!p.isAd) fresh++; // scrolling past an ad isn't reading
364
401
  }
402
+ // debug:true (probe runs only) — dumps what the scan actually saw so a
403
+ // wrong selector / duplicate key shows up as data instead of a guess.
404
+ if (payload.debug && ticks % 3 === 0) {
405
+ log('info', `[nurture-fb][dbg] t${ticks} scroller=${scan.scroller}@${scan.scrollTop} winY=${scan.scrollY} sel=${scan.selector} units=${scan.articlesInDom} fresh=${fresh} keys=${JSON.stringify(scan.sample)}`);
406
+ }
365
407
  postsSeen += fresh;
366
408
  postsSinceLike += fresh;
367
409
  // Feed that stops yielding posts for ~15 ticks = end of feed, a wall, or a
@@ -419,7 +461,7 @@ async function run({ page, payload, log }) {
419
461
  // moment activity trips a threshold — better to stop than to keep poking.
420
462
  if (ticks % 12 === 0) {
421
463
  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}`);
464
+ 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}`);
423
465
  }
424
466
 
425
467
  // Page refuses to move at all → this is not a nurture session, it's ten