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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.46",
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": {
@@ -93,60 +93,73 @@ async function dismissDialogs(page, log) {
93
93
  }
94
94
  }
95
95
 
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++;
113
- }
114
- return fresh;
115
- }).catch(() => 0);
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
- let best = null, bestDist = Infinity;
121
+ const posts = [];
122
+ let best = null, bestDist = Infinity, bestKey = '';
125
123
  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;
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 < 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;
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
- 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 };
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 null;
147
- }).catch(() => null);
148
- 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
+ }
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
- ok = await btn.evaluate((el) => el.getAttribute('aria-pressed') === 'true').catch(() => true);
162
- 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
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((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(() => {});
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 !!ok;
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
- 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;
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
- 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;
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.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
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 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
+ }
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
- 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
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