channel-worker 2.5.29 → 2.5.31

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.29",
3
+ "version": "2.5.31",
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": {
@@ -231,8 +231,25 @@ async function clickResultByHref(page, href) {
231
231
  }
232
232
  }
233
233
 
234
+ // Read the open reel/video's playback state — used to advance early when it
235
+ // ENDS or STALLS instead of idling until watch_time elapses.
236
+ async function readVideoState(page) {
237
+ return page.evaluate(() => {
238
+ const v = document.querySelector('video');
239
+ if (!v) return null;
240
+ if (v.paused && !v.ended) { try { v.play(); } catch {} }
241
+ return {
242
+ ended: !!v.ended,
243
+ currentTime: Number(v.currentTime) || 0,
244
+ duration: (isFinite(v.duration) && v.duration > 0) ? v.duration : 0,
245
+ loop: !!v.loop,
246
+ };
247
+ }).catch(() => null);
248
+ }
249
+
234
250
  // Watch whatever reel/video is currently open (after clicking a result): ensure
235
- // the <video> plays, watch a random span with a mid-view scroll. Best-effort.
251
+ // the <video> plays, watch a random span but ADVANCE EARLY if a non-looping
252
+ // video ends/stalls (don't sit on a finished video). Mid-view scroll. Best-effort.
236
253
  async function watchCurrent(page, minSec, maxSec, log) {
237
254
  try {
238
255
  await page.waitForTimeout(randInt(2500, 4500)); // let the reel viewer mount
@@ -244,12 +261,27 @@ async function watchCurrent(page, minSec, maxSec, log) {
244
261
  return true;
245
262
  }).catch(() => false);
246
263
  if (!hasVideo) { log('info', '[warmup-fb] no <video> after click — skipping'); return false; }
264
+
247
265
  const watchMs = randInt(minSec, maxSec) * 1000;
248
266
  log('info', `[warmup-fb] watching reel for ~${Math.round(watchMs / 1000)}s (url=${page.url().slice(-28)})`);
249
- const half = Math.floor(watchMs / 2);
250
- await page.waitForTimeout(half);
251
- await organicScroll(page, randInt(1, 2));
252
- await page.waitForTimeout(watchMs - half);
267
+ const TICK = 1000;
268
+ let watched = 0, scrolled = false, lastT = -1, stallTicks = 0;
269
+ while (watched < watchMs) {
270
+ const st = await readVideoState(page);
271
+ // A non-looping video that ended/stalled → stop waiting, go to next.
272
+ if (st && !st.loop) {
273
+ if (st.ended || (st.duration > 0 && st.currentTime >= st.duration - 0.6)) {
274
+ log('info', `[warmup-fb] reel ended at ${Math.round(st.currentTime)}s — advancing`);
275
+ break;
276
+ }
277
+ if (Math.abs(st.currentTime - lastT) < 0.15) { stallTicks++; if (stallTicks >= 6) { log('info', '[warmup-fb] reel stalled — advancing'); break; } }
278
+ else stallTicks = 0;
279
+ lastT = st.currentTime;
280
+ }
281
+ if (!scrolled && watched >= watchMs / 2) { await organicScroll(page, randInt(1, 2)); scrolled = true; }
282
+ await page.waitForTimeout(TICK);
283
+ watched += TICK;
284
+ }
253
285
  return true;
254
286
  } catch (e) {
255
287
  log('info', `[warmup-fb] watch failed: ${String(e.message || e).slice(0, 90)}`);
@@ -289,6 +321,7 @@ async function run({ page, payload, log }) {
289
321
 
290
322
  const keywordsSearched = [];
291
323
  let videosWatched = 0;
324
+ const watchedGlobal = new Set(); // dedupe reels across ALL keywords this session
292
325
 
293
326
  for (const kw of sessionKeywords) {
294
327
  // 3) Search the keyword via the top bar (from the Reels surface).
@@ -313,15 +346,25 @@ async function run({ page, payload, log }) {
313
346
  continue; // top-bar search persists; next keyword searches from here
314
347
  }
315
348
  log('info', `[warmup-fb] "${kw}" → ${urls.length} results, opening up to ${videosPerKeyword}`);
316
- const watched = new Set();
317
349
  for (let n = 0; n < videosPerKeyword; n++) {
318
350
  // Re-collect each iteration — clicking + goBack mutates the DOM.
319
351
  const current = await collectResultUrls(page);
320
- const next = current.find(u => !watched.has(u.split('?')[0]));
352
+ const next = current.find(u => !watchedGlobal.has(u.split('?')[0]));
321
353
  if (!next) break;
322
- watched.add(next.split('?')[0]);
354
+ watchedGlobal.add(next.split('?')[0]);
355
+ const urlBefore = page.url();
323
356
  const clicked = await clickResultByHref(page, next);
324
357
  if (!clicked) { log('info', `[warmup-fb] couldn't click result ${next.slice(-24)} — skipping`); continue; }
358
+ // Verify the click actually navigated into a reel/video — otherwise we'd
359
+ // re-watch whatever was already open (saw the same reel id reused across
360
+ // keywords). Wait up to 8s for the URL to change to /reel/ or /watch.
361
+ let navigated = false;
362
+ for (let w = 0; w < 8; w++) {
363
+ await page.waitForTimeout(1000);
364
+ const u = page.url();
365
+ if (u !== urlBefore && /\/(reel|watch|videos)\//.test(u)) { navigated = true; break; }
366
+ }
367
+ if (!navigated) { log('info', `[warmup-fb] click didn't navigate (still ${page.url().slice(-30)}) — skipping`); continue; }
325
368
  const ok = await watchCurrent(page, watchMin, watchMax, log);
326
369
  if (ok) videosWatched++;
327
370
  // Back to the results list for the next pick (Escape closes a reel overlay
@@ -145,6 +145,23 @@ async function isAdShowing(page) {
145
145
  }).catch(() => false);
146
146
  }
147
147
 
148
+ // Read the main <video> playback state. Used to advance early when a video
149
+ // ENDS or STALLS instead of sitting idle on a finished video until watch_time
150
+ // elapses (the "video chạy hết rồi không nhảy" report). Returns null if no video.
151
+ async function readVideoState(page) {
152
+ return page.evaluate(() => {
153
+ const v = document.querySelector('video');
154
+ if (!v) return null;
155
+ if (v.paused && !v.ended) { try { v.play(); } catch {} } // nudge autoplay
156
+ return {
157
+ ended: !!v.ended,
158
+ currentTime: Number(v.currentTime) || 0,
159
+ duration: (isFinite(v.duration) && v.duration > 0) ? v.duration : 0,
160
+ loop: !!v.loop,
161
+ };
162
+ }).catch(() => null);
163
+ }
164
+
148
165
  // Watch one video: navigate, SKIP any ads (so we watch the real video, not the
149
166
  // ad), then let the real content play for a random span — counting only
150
167
  // non-ad time toward the budget — with a small mid-view scroll like a real
@@ -168,6 +185,7 @@ async function watchVideo(page, url, minSec, maxSec, log) {
168
185
  let adWaited = 0; // ms spent sitting through/again skipping ads
169
186
  let scrolled = false;
170
187
  let skips = 0;
188
+ let lastT = -1, stallTicks = 0;
171
189
 
172
190
  while (realWatched < watchMs) {
173
191
  const adOn = await isAdShowing(page);
@@ -177,6 +195,18 @@ async function watchVideo(page, url, minSec, maxSec, log) {
177
195
  if (adWaited >= MAX_AD_WAIT_MS) { log('warn', `[warmup] ${url.slice(-11)} ads exceeded ${MAX_AD_WAIT_MS/1000}s — moving on`); break; }
178
196
  } else {
179
197
  realWatched += TICK;
198
+ // Advance early if the (non-looping) video ENDED or STALLED, instead of
199
+ // idling on a finished video until watch_time runs out.
200
+ const st = await readVideoState(page);
201
+ if (st && !st.loop) {
202
+ if (st.ended || (st.duration > 0 && st.currentTime >= st.duration - 0.6)) {
203
+ log('info', `[warmup] ${url.slice(-11)} ended at ${Math.round(st.currentTime)}s — advancing`);
204
+ break;
205
+ }
206
+ if (Math.abs(st.currentTime - lastT) < 0.15) { stallTicks++; if (stallTicks >= 6) { log('info', `[warmup] ${url.slice(-11)} stalled — advancing`); break; } }
207
+ else stallTicks = 0;
208
+ lastT = st.currentTime;
209
+ }
180
210
  // Mid-view scroll once, roughly halfway through real watch time.
181
211
  if (!scrolled && realWatched >= watchMs / 2) { await organicScroll(page, randInt(1, 2)); scrolled = true; }
182
212
  }