channel-worker 2.5.47 → 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.47",
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; }
@@ -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');
@@ -151,7 +173,8 @@ async function scanFeed(page, { skipKeys = [], wantLike = false } = {}) {
151
173
  return {
152
174
  posts, likeKey,
153
175
  scrollY: Math.round(window.scrollY || document.documentElement.scrollTop || 0),
154
- articlesInDom: document.querySelectorAll("[role='article']").length,
176
+ articlesInDom: units.length,
177
+ selector: sel || '(none)',
155
178
  };
156
179
  }, { skip: skipKeys, wantLike }).catch(() => ({ posts: [], likeKey: null, scrollY: 0, articlesInDom: 0 }));
157
180
  }
@@ -184,12 +207,12 @@ async function clickMarkedLike(page, log) {
184
207
  // Watch a video that's playing in the feed, counting only seconds where
185
208
  // playback actually ADVANCES (a frozen/buffering player must not bank time).
186
209
  async function watchFeedVideo(page, minSec, maxSec, log) {
187
- const has = await page.evaluate(() => {
210
+ const has = await page.evaluate((UNIT) => {
188
211
  const vids = [...document.querySelectorAll('video')];
189
212
  for (const v of vids) {
190
213
  const r = v.getBoundingClientRect();
191
214
  if (r.height < 100 || r.bottom < 0 || r.top > window.innerHeight) continue;
192
- const art = v.closest("[role='article']");
215
+ const art = v.closest(UNIT);
193
216
  // Ads are re-detected from text — a node flag would be stale the moment
194
217
  // FB recycles the article for a different post.
195
218
  if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue;
@@ -199,7 +222,7 @@ async function watchFeedVideo(page, minSec, maxSec, log) {
199
222
  return true;
200
223
  }
201
224
  return false;
202
- }).catch(() => false);
225
+ }, UNIT_SEL).catch(() => false);
203
226
  if (!has) return false;
204
227
 
205
228
  const budgetMs = randInt(minSec, maxSec) * 1000;
@@ -228,11 +251,11 @@ async function watchFeedVideo(page, minSec, maxSec, log) {
228
251
  // Phase 2+. Best-effort: if the click doesn't navigate, nothing is lost.
229
252
  async function openPostAndRead(page, log) {
230
253
  const urlBefore = page.url();
231
- const marked = await page.evaluate(() => {
254
+ const marked = await page.evaluate((UNIT) => {
232
255
  const mid = window.innerHeight / 2;
233
256
  let best = null, bestDist = Infinity;
234
- for (const art of document.querySelectorAll("[role='article']")) {
235
- if (art.parentElement && art.parentElement.closest("[role='article']")) continue;
257
+ for (const art of document.querySelectorAll(UNIT)) {
258
+ if (art.parentElement && art.parentElement.closest(UNIT)) continue;
236
259
  // Node-level flags can't be trusted (FB recycles articles), so ads are
237
260
  // re-checked from the text right here.
238
261
  if (/Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue;
@@ -249,7 +272,7 @@ async function openPostAndRead(page, log) {
249
272
  return true;
250
273
  }
251
274
  return false;
252
- }).catch(() => false);
275
+ }, UNIT_SEL).catch(() => false);
253
276
  if (!marked) return false;
254
277
 
255
278
  try {
@@ -283,7 +306,7 @@ async function openPostAndRead(page, log) {
283
306
  // Follow a page/creator surfaced by the feed itself (phase 3, ≤ config max).
284
307
  // Only clicks a Follow/Like-Page button that's already on screen — never hunts.
285
308
  async function followVisiblePage(page, log) {
286
- const marked = await page.evaluate(() => {
309
+ const marked = await page.evaluate((UNIT) => {
287
310
  for (const b of document.querySelectorAll("[role='button'], a[role='button']")) {
288
311
  const t = (b.innerText || '').trim();
289
312
  const aria = (b.getAttribute('aria-label') || '').trim();
@@ -292,13 +315,13 @@ async function followVisiblePage(page, log) {
292
315
  const r = b.getBoundingClientRect();
293
316
  if (r.width < 8 || r.height < 8 || b.offsetParent === null) continue;
294
317
  if (r.bottom < 0 || r.top > window.innerHeight) continue;
295
- const art = b.closest("[role='article']");
318
+ const art = b.closest(UNIT);
296
319
  if (art && /Được tài trợ|Sponsored/i.test((art.innerText || '').slice(0, 400))) continue; // not an ad's CTA
297
320
  b.setAttribute('__nur_follow__', '1');
298
321
  return label;
299
322
  }
300
323
  return null;
301
- }).catch(() => null);
324
+ }, UNIT_SEL).catch(() => null);
302
325
  if (!marked) return false;
303
326
  let ok = false;
304
327
  try {
@@ -419,7 +442,7 @@ async function run({ page, payload, log }) {
419
442
  // moment activity trips a threshold — better to stop than to keep poking.
420
443
  if (ticks % 12 === 0) {
421
444
  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}`);
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}`);
423
446
  }
424
447
 
425
448
  // Page refuses to move at all → this is not a nurture session, it's ten