channel-worker 2.5.56 → 2.5.57

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.
@@ -17,8 +17,11 @@
17
17
  // layout change) FAILS the session loudly (no silent bypass).
18
18
 
19
19
  const { assertAccountUsable } = require('./lib/fb-guard');
20
+ const { humanType, humanWheel, humanClick, humanMove, cursorOf } = require('./lib/human');
21
+ const { pick, clickHandle } = require('./lib/dom-pick');
20
22
 
21
23
  // ─── helpers ────────────────────────────────────────────────────────────────
24
+ function chance(p) { return Math.random() < p; }
22
25
  function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
23
26
  function shuffle(arr) {
24
27
  const a = [...arr];
@@ -34,17 +37,13 @@ async function firstVisible(locator, max = 8) {
34
37
  return null;
35
38
  }
36
39
 
37
- async function typeHuman(page, text) {
38
- for (const ch of text) {
39
- await page.keyboard.type(ch);
40
- await page.waitForTimeout(randInt(60, 160));
41
- }
42
- }
40
+ async function typeHuman(page, text) { await humanType(page, text); }
43
41
 
44
42
  async function organicScroll(page, rounds = null) {
45
43
  const n = rounds ?? randInt(2, 4);
46
44
  for (let i = 0; i < n; i++) {
47
- await page.mouse.wheel(0, randInt(300, 850)).catch(() => {});
45
+ // Mostly down; now and then a short scroll back up to re-read something.
46
+ await humanWheel(page, chance(0.1) ? -randInt(120, 380) : randInt(300, 850));
48
47
  await page.waitForTimeout(randInt(500, 1500));
49
48
  }
50
49
  }
@@ -54,7 +53,7 @@ async function organicScroll(page, rounds = null) {
54
53
  async function dismissDialogs(page, log) {
55
54
  const verbs = ['Cho phép tất cả cookie', 'Allow all cookies', 'Để sau', 'Not now', 'Lúc khác', 'Không phải bây giờ', 'Bỏ qua', 'Skip', 'Đóng', 'Close', 'OK', 'Đã hiểu', 'Got it'];
56
55
  for (let round = 0; round < 3; round++) {
57
- const hit = await page.evaluate((vs) => {
56
+ const { el } = await pick(page, (vs) => {
58
57
  const dlgs = document.querySelectorAll("[role='dialog']");
59
58
  for (const dlg of dlgs) {
60
59
  const r = dlg.getBoundingClientRect();
@@ -65,18 +64,16 @@ async function dismissDialogs(page, log) {
65
64
  for (const b of dlg.querySelectorAll("[role='button'], button")) {
66
65
  const t = (b.innerText || '').trim();
67
66
  if ((t === v || (b.getAttribute('aria-label') || '').trim() === v) && b.offsetParent !== null) {
68
- b.setAttribute('__warm_dismiss__', '1');
69
- return v;
67
+ return { el: b, verb: v };
70
68
  }
71
69
  }
72
70
  }
73
71
  }
74
- return null;
75
- }, verbs).catch(() => null);
76
- if (!hit) break;
77
- try { await page.locator("[__warm_dismiss__='1']").click({ timeout: 2500 }); } catch {}
78
- await page.evaluate(() => document.querySelectorAll("[__warm_dismiss__]").forEach(e => e.removeAttribute('__warm_dismiss__'))).catch(() => {});
79
- await page.waitForTimeout(800);
72
+ return { el: null };
73
+ }, verbs);
74
+ if (!el) break;
75
+ await clickHandle(page, el, { hoverMs: [300, 900], timeout: 2500, scroll: false });
76
+ await page.waitForTimeout(randInt(600, 1300));
80
77
  }
81
78
  }
82
79
 
@@ -85,7 +82,7 @@ async function dismissDialogs(page, log) {
85
82
  // text Reels|Thước phim), scoped to navigation so we don't hit a feed item.
86
83
  async function clickReelsEntry(page, log) {
87
84
  for (let attempt = 0; attempt < 3; attempt++) {
88
- const found = await page.evaluate(() => {
85
+ const { el, data: found } = await pick(page, () => {
89
86
  const isReel = (el) => {
90
87
  const href = el.getAttribute('href') || '';
91
88
  const aria = (el.getAttribute('aria-label') || '').trim();
@@ -101,20 +98,17 @@ async function clickReelsEntry(page, log) {
101
98
  const r = a.getBoundingClientRect();
102
99
  if (r.width < 8 || r.height < 8) continue;
103
100
  if (a.offsetParent === null) continue;
104
- if (isReel(a)) { a.setAttribute('__warm_reels__', '1'); return { href: a.getAttribute('href') || '', aria: (a.getAttribute('aria-label') || a.innerText || '').slice(0, 30) }; }
101
+ if (isReel(a)) return { el: a, href: a.getAttribute('href') || '', aria: (a.getAttribute('aria-label') || a.innerText || '').slice(0, 30) };
105
102
  }
106
- return null;
107
- }).catch(() => null);
108
- if (found) {
109
- try {
110
- await page.locator("[__warm_reels__='1']").first().click({ timeout: 4000 });
111
- await page.evaluate(() => document.querySelectorAll("[__warm_reels__]").forEach(e => e.removeAttribute('__warm_reels__'))).catch(() => {});
103
+ return { el: null };
104
+ });
105
+ if (el) {
106
+ if (await clickHandle(page, el, { hoverMs: [300, 900], scroll: false })) {
112
107
  await page.waitForTimeout(randInt(3000, 5000));
113
108
  log('info', `[warmup-fb] clicked Reels entry (aria="${found.aria}")`);
114
109
  return true;
115
- } catch (e) {
116
- log('info', `[warmup-fb] Reels click failed: ${String(e.message || e).slice(0, 80)}`);
117
110
  }
111
+ log('info', '[warmup-fb] Reels click failed');
118
112
  }
119
113
  await page.waitForTimeout(1500);
120
114
  }
@@ -141,7 +135,7 @@ async function focusFbSearch(page, log) {
141
135
  const box = await firstVisible(page.locator(sel), 3);
142
136
  if (box) {
143
137
  try {
144
- await box.click({ timeout: 2500 });
138
+ await humanClick(page, box, { timeout: 2500, scroll: false });
145
139
  // CLEAR the previous keyword first. FB's search is a React-controlled
146
140
  // input — Ctrl+A+Backspace didn't reset its value (keywords appended:
147
141
  // "kw1kw2"). locator.fill('') dispatches the input events React needs,
@@ -155,7 +149,7 @@ async function focusFbSearch(page, log) {
155
149
  }
156
150
  if (cur && cur.trim()) { log('info', `[warmup-fb] search box not fully cleared (still "${cur.slice(0, 20)}") — typing anyway`); }
157
151
  // Re-focus so the subsequent human-typing lands in the box.
158
- await box.click({ timeout: 2000 }).catch(() => {});
152
+ await humanClick(page, box, { timeout: 2000, scroll: false, hoverMs: [80, 250] }).catch(() => {});
159
153
  return true;
160
154
  } catch {}
161
155
  }
@@ -163,7 +157,7 @@ async function focusFbSearch(page, log) {
163
157
  // No visible input → click a search trigger to expand it, then retry.
164
158
  for (const sel of triggerSels) {
165
159
  const t = await firstVisible(page.locator(sel), 3);
166
- if (t) { try { await t.click({ timeout: 2500 }); await page.waitForTimeout(1200); } catch {} break; }
160
+ if (t) { try { await humanClick(page, t, { timeout: 2500, scroll: false }); await page.waitForTimeout(randInt(900, 1600)); } catch {} break; }
167
161
  }
168
162
  await page.waitForTimeout(1000);
169
163
  }
@@ -174,7 +168,7 @@ async function focusFbSearch(page, log) {
174
168
  // results bias toward watchable reels/videos. Best-effort.
175
169
  async function clickResultsFilter(page, log) {
176
170
  const labels = ['Thước phim', 'Reels', 'Video', 'Videos'];
177
- const found = await page.evaluate((labs) => {
171
+ const { el, data } = await pick(page, (labs) => {
178
172
  const els = document.querySelectorAll("[role='link'], [role='tab'], a, [role='button']");
179
173
  for (const lab of labs) {
180
174
  for (const el of els) {
@@ -182,21 +176,16 @@ async function clickResultsFilter(page, log) {
182
176
  if (t === lab && el.offsetParent !== null) {
183
177
  const r = el.getBoundingClientRect();
184
178
  if (r.width < 8 || r.height < 8) continue;
185
- el.setAttribute('__warm_filter__', '1');
186
- return lab;
179
+ return { el, lab };
187
180
  }
188
181
  }
189
182
  }
190
- return null;
191
- }, labels).catch(() => null);
192
- if (found) {
193
- try {
194
- await page.locator("[__warm_filter__='1']").first().click({ timeout: 3000 });
195
- await page.evaluate(() => document.querySelectorAll("[__warm_filter__]").forEach(e => e.removeAttribute('__warm_filter__'))).catch(() => {});
196
- await page.waitForTimeout(randInt(2000, 3500));
197
- log('info', `[warmup-fb] results filtered → "${found}"`);
198
- return found;
199
- } catch {}
183
+ return { el: null };
184
+ }, labels);
185
+ if (el && await clickHandle(page, el, { hoverMs: [300, 900], timeout: 3000, scroll: false })) {
186
+ await page.waitForTimeout(randInt(2000, 3500));
187
+ log('info', `[warmup-fb] results filtered → "${data.lab}"`);
188
+ return data.lab;
200
189
  }
201
190
  return null;
202
191
  }
@@ -221,28 +210,20 @@ async function collectResultUrls(page) {
221
210
  }
222
211
 
223
212
  // CLICK the result anchor matching `href` (natural — not a URL navigation).
224
- // Marks the element then clicks via Playwright (trusted). Returns true on click.
213
+ // Picks the anchor as an ElementHandle (no DOM mark) and clicks it via
214
+ // Playwright (trusted). Returns true on click.
225
215
  async function clickResultByHref(page, href) {
226
216
  const key = href.split('?')[0];
227
- const found = await page.evaluate((k) => {
217
+ const { el } = await pick(page, (k) => {
228
218
  for (const a of document.querySelectorAll("a[href*='/reel/'], a[href*='/watch'], a[href*='/videos/']")) {
229
219
  let h = a.getAttribute('href') || '';
230
220
  if (h.startsWith('/')) h = 'https://www.facebook.com' + h;
231
- if (h.split('?')[0] === k && a.offsetParent !== null) { a.setAttribute('__warm_click__', '1'); return true; }
221
+ if (h.split('?')[0] === k && a.offsetParent !== null) return { el: a };
232
222
  }
233
- return false;
234
- }, key).catch(() => false);
235
- if (!found) return false;
236
- const loc = page.locator("[__warm_click__='1']").first();
237
- try {
238
- await loc.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
239
- await loc.click({ timeout: 4000 });
240
- await page.evaluate(() => document.querySelectorAll("[__warm_click__]").forEach(e => e.removeAttribute('__warm_click__'))).catch(() => {});
241
- return true;
242
- } catch {
243
- await page.evaluate(() => document.querySelectorAll("[__warm_click__]").forEach(e => e.removeAttribute('__warm_click__'))).catch(() => {});
244
- return false;
245
- }
223
+ return { el: null };
224
+ }, key);
225
+ if (!el) return false;
226
+ return clickHandle(page, el, { hoverMs: [300, 900] });
246
227
  }
247
228
 
248
229
  // Read the open reel/video's playback state — used to advance early when it
@@ -302,9 +283,11 @@ async function watchCurrent(page, minSec, maxSec, log) {
302
283
 
303
284
  const watchMs = randInt(minSec, maxSec) * 1000;
304
285
  log('info', `[warmup-fb] watching reel for ~${Math.round(watchMs / 1000)}s (url=${page.url().slice(-28)})`);
305
- const TICK = 1000;
306
- let watched = 0; // counts ONLY progressing seconds
307
- let scrolled = false, lastT = -1, frozenTicks = 0, adReChecks = 0;
286
+ // Poll at an uneven cadence (700-1400ms) and bank the elapsed step, not
287
+ // a flat 1000 the old exact-1s heartbeat was itself a pattern.
288
+ let watched = 0; // counts ONLY progressing time
289
+ let drifted = false, lastT = -1, frozenTicks = 0, adReChecks = 0;
290
+ const driftAt = randInt(25, 75) / 100;
308
291
  while (watched < watchMs) {
309
292
  const st = await readVideoState(page);
310
293
  // Non-looping video finished → go to next.
@@ -314,10 +297,19 @@ async function watchCurrent(page, minSec, maxSec, log) {
314
297
  }
315
298
  // Progress = currentTime moved forward, OR wrapped back (loop restart).
316
299
  const advanced = !!st && (st.currentTime > lastT + 0.2 || st.currentTime < lastT - 0.5);
300
+ const TICK = randInt(700, 1400);
317
301
  if (advanced) {
318
302
  watched += TICK;
319
303
  frozenTicks = 0;
320
- if (!scrolled && watched >= watchMs / 2) { await organicScroll(page, randInt(1, 2)); scrolled = true; }
304
+ // Inside the reel viewer a wheel tick flips to the NEXT reel, so the
305
+ // old mid-watch "organic scroll" silently changed what was being
306
+ // watched. A viewer's hand drifts instead: small cursor move, once,
307
+ // at a random point.
308
+ if (!drifted && watched >= watchMs * driftAt) {
309
+ const c = cursorOf(page);
310
+ await humanMove(page, c.x + randInt(-90, 90), c.y + randInt(-60, 60)).catch(() => {});
311
+ drifted = true;
312
+ }
321
313
  } else {
322
314
  // Not progressing (frozen / buffering / paused-and-won't-play) — do NOT
323
315
  // count this second; bail after ~8s stuck.
@@ -326,7 +318,7 @@ async function watchCurrent(page, minSec, maxSec, log) {
326
318
  }
327
319
  lastT = st ? st.currentTime : lastT;
328
320
  // Re-check sponsored a couple times (a mid-scroll ad reel can swap in).
329
- if (adReChecks < 2 && watched > 0 && watched % 8000 === 0) { adReChecks++; if (await isSponsoredReel(page)) { log('info', '[warmup-fb] became sponsored mid-watch — advancing'); break; } }
321
+ if (adReChecks < 2 && watched > 8000 * (adReChecks + 1)) { adReChecks++; if (await isSponsoredReel(page)) { log('info', '[warmup-fb] became sponsored mid-watch — advancing'); break; } }
330
322
  await page.waitForTimeout(TICK);
331
323
  }
332
324
  return watched > 0; // only a video with real progressing playback counts
@@ -343,8 +335,9 @@ async function run({ page, payload, log }) {
343
335
  if (!allKeywords.length) throw new Error('warmup-fb: no keywords provided');
344
336
 
345
337
  const cfg = payload.config || {};
346
- const keywordsPerSession = Math.max(1, cfg.keywords_per_session ?? 5);
347
- const videosPerKeyword = Math.max(1, cfg.videos_per_keyword ?? 3);
338
+ // Configured counts are a CENTRE, not a constant (see warmup_youtube).
339
+ const keywordsPerSession = Math.max(1, (cfg.keywords_per_session ?? 5) + randInt(-2, 1));
340
+ const videosPerKeyword = Math.max(1, (cfg.videos_per_keyword ?? 3) + randInt(-1, 1));
348
341
  const watchMin = Math.max(5, cfg.watch_time_min_sec ?? 30);
349
342
  const watchMax = Math.max(watchMin, cfg.watch_time_max_sec ?? 90);
350
343
  const sessionKeywords = shuffle(allKeywords).slice(0, keywordsPerSession);
@@ -16,7 +16,11 @@
16
16
  // whole session FAILS loudly (no silent bypass) so the user can investigate.
17
17
 
18
18
  // ─── helpers ────────────────────────────────────────────────────────────────
19
+ const { humanType, humanWheel, humanClick } = require('./lib/human');
20
+ const { pick: pickEl, clickHandle } = require('./lib/dom-pick');
21
+
19
22
  function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
23
+ function chance(p) { return Math.random() < p; }
20
24
  function pick(arr) { return arr[randInt(0, arr.length - 1)]; }
21
25
 
22
26
  // Fisher-Yates shuffle (non-mutating) — used to pick a random keyword subset
@@ -41,19 +45,15 @@ async function firstVisible(locator, max = 8) {
41
45
 
42
46
  // Type a string into the focused element one character at a time with a small
43
47
  // random per-keystroke delay — mimics human cadence instead of an instant fill.
44
- async function typeHuman(page, text) {
45
- for (const ch of text) {
46
- await page.keyboard.type(ch);
47
- await page.waitForTimeout(randInt(60, 160));
48
- }
49
- }
48
+ async function typeHuman(page, text) { await humanType(page, text); }
50
49
 
51
50
  // A few organic scroll nudges down the page (with pauses) before/while picking
52
51
  // a video. Wheel events look more human than a jump-to-element click.
53
52
  async function organicScroll(page, rounds = null) {
54
53
  const n = rounds ?? randInt(2, 4);
55
54
  for (let i = 0; i < n; i++) {
56
- await page.mouse.wheel(0, randInt(300, 850)).catch(() => {});
55
+ // Mostly down; now and then a short scroll back up to re-read something.
56
+ await humanWheel(page, chance(0.1) ? -randInt(120, 380) : randInt(300, 850));
57
57
  await page.waitForTimeout(randInt(500, 1500));
58
58
  }
59
59
  }
@@ -73,7 +73,7 @@ async function focusSearchBox(page, log) {
73
73
  const box = await firstVisible(page.locator(sel), 3);
74
74
  if (box) {
75
75
  try {
76
- await box.click({ timeout: 3000 });
76
+ await humanClick(page, box, { timeout: 3000, scroll: false });
77
77
  // Clear any leftover query from a previous keyword.
78
78
  await page.keyboard.down('Control'); await page.keyboard.press('A'); await page.keyboard.up('Control');
79
79
  await page.keyboard.press('Backspace');
@@ -109,6 +109,29 @@ async function collectResultVideoUrls(page) {
109
109
  }).catch(() => []);
110
110
  }
111
111
 
112
+ // CLICK the result whose href carries this video id — the way a viewer gets
113
+ // from search to watch. Until 2026-08-22 watchVideo did page.goto(url), which
114
+ // YouTube logs as direct/typed traffic: no referrer from the results page, no
115
+ // trusted click, and the search→click attribution warmup exists to build
116
+ // never formed. Scrolls the anchor into view, cursor travels, clicks
117
+ // off-centre. Returns true if the click navigated to that video.
118
+ async function clickResultById(page, id) {
119
+ const { el } = await pickEl(page, (vid) => {
120
+ for (const a of document.querySelectorAll("ytd-video-renderer a#video-title, a#video-title-link, ytd-rich-item-renderer a#video-title-link, ytd-video-renderer a#thumbnail")) {
121
+ const href = a.getAttribute('href') || '';
122
+ if (!href.includes('v=' + vid)) continue;
123
+ const r = a.getBoundingClientRect();
124
+ if (r.width < 8 || r.height < 8 || a.offsetParent === null) continue;
125
+ return { el: a };
126
+ }
127
+ return { el: null };
128
+ }, id);
129
+ if (!el) return false;
130
+ if (!(await clickHandle(page, el, { hoverMs: [300, 900] }))) return false;
131
+ try { await page.waitForURL((u) => u.href.includes('v=' + id), { timeout: 15000 }); } catch { return false; }
132
+ return true;
133
+ }
134
+
112
135
  // Click "Bỏ qua quảng cáo" / "Skip Ad" if present + actionable. Returns true if
113
136
  // a skip was clicked this tick. The button only becomes clickable ~5s into a
114
137
  // skippable ad; non-skippable ads have no button (we wait them out). Multiple
@@ -128,7 +151,7 @@ async function trySkipAd(page) {
128
151
  for (const s of sels) {
129
152
  const loc = page.locator(s).first();
130
153
  if (await loc.isVisible().catch(() => false)) {
131
- try { await loc.click({ timeout: 1500 }); return true; } catch { /* not yet clickable */ }
154
+ try { await humanClick(page, loc, { timeout: 1500, scroll: false, hoverMs: [120, 400] }); return true; } catch { /* not yet clickable */ }
132
155
  }
133
156
  }
134
157
  return false;
@@ -168,7 +191,14 @@ async function readVideoState(page) {
168
191
  // viewer. Best-effort — a single failed video never aborts the session.
169
192
  async function watchVideo(page, url, minSec, maxSec, log) {
170
193
  try {
171
- await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
194
+ const id = (url.match(/v=([\w-]{11})/) || [])[1] || '';
195
+ const clicked = id ? await clickResultById(page, id) : false;
196
+ if (!clicked) {
197
+ // Anchor scrolled out / re-rendered — fall back to navigation rather
198
+ // than skip the video, but say so in the log.
199
+ log('info', `[warmup] ${url.slice(-11)} result anchor not clickable — falling back to goto`);
200
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
201
+ }
172
202
  await page.waitForTimeout(randInt(2500, 4500));
173
203
  // Best-effort: make sure it's actually playing (autoplay sometimes pauses).
174
204
  await page.evaluate(() => {
@@ -183,7 +213,9 @@ async function watchVideo(page, url, minSec, maxSec, log) {
183
213
  const MAX_AD_WAIT_MS = 60_000; // cap total ad-waiting so a non-skippable ad reel can't hang the session
184
214
  let realWatched = 0; // ms of actual (non-ad) playback counted
185
215
  let adWaited = 0; // ms spent sitting through/again skipping ads
186
- let scrolled = false;
216
+ // Nudge the page at a random point (not always halfway), sometimes twice.
217
+ const scrollAt = [randInt(20, 75) / 100, chance(0.35) ? randInt(60, 95) / 100 : 2];
218
+ let scrollIdx = 0;
187
219
  let skips = 0;
188
220
  let lastT = -1, stallTicks = 0;
189
221
 
@@ -207,10 +239,9 @@ async function watchVideo(page, url, minSec, maxSec, log) {
207
239
  else stallTicks = 0;
208
240
  lastT = st.currentTime;
209
241
  }
210
- // Mid-view scroll once, roughly halfway through real watch time.
211
- if (!scrolled && realWatched >= watchMs / 2) { await organicScroll(page, randInt(1, 2)); scrolled = true; }
242
+ if (scrollIdx < scrollAt.length && realWatched >= watchMs * scrollAt[scrollIdx]) { await organicScroll(page, randInt(1, 2)); scrollIdx++; }
212
243
  }
213
- await page.waitForTimeout(TICK);
244
+ await page.waitForTimeout(randInt(700, 1400));
214
245
  }
215
246
  if (skips) log('info', `[warmup] ${url.slice(-11)} — skipped ${skips} ad tick(s), real watch ${Math.round(realWatched/1000)}s`);
216
247
  // Only count as a watched video if real (non-ad) content actually played.
@@ -228,8 +259,10 @@ async function run({ page, payload, log }) {
228
259
  if (!allKeywords.length) throw new Error('warmup: no keywords provided');
229
260
 
230
261
  const cfg = payload.config || {};
231
- const keywordsPerSession = Math.max(1, cfg.keywords_per_session ?? 5);
232
- const videosPerKeyword = Math.max(1, cfg.videos_per_keyword ?? 3);
262
+ // Configured counts are a CENTRE, not a constant: ±1-2 per session so
263
+ // "5 keywords × 3 videos, every time" stops being the session's shape.
264
+ const keywordsPerSession = Math.max(1, (cfg.keywords_per_session ?? 5) + randInt(-2, 1));
265
+ const videosPerKeyword = Math.max(1, (cfg.videos_per_keyword ?? 3) + randInt(-1, 1));
233
266
  const watchMin = Math.max(5, cfg.watch_time_min_sec ?? 30);
234
267
  const watchMax = Math.max(watchMin, cfg.watch_time_max_sec ?? 90);
235
268
 
@@ -278,11 +311,19 @@ async function run({ page, payload, log }) {
278
311
  const chosen = shuffle(urls).slice(0, videosPerKeyword);
279
312
  log('info', `[warmup] "${kw}" → ${urls.length} results, opening ${chosen.length}`);
280
313
 
281
- for (const url of chosen) {
282
- const ok = await watchVideo(page, url, watchMin, watchMax, log);
314
+ for (let i = 0; i < chosen.length; i++) {
315
+ const ok = await watchVideo(page, chosen[i], watchMin, watchMax, log);
283
316
  if (ok) videosWatched++;
284
- // Brief idle between videos.
317
+ // Brief idle, then back to the results page so the next pick is a click
318
+ // on a result rather than a typed URL. Occasionally bail on the rest of
319
+ // this keyword — people do.
285
320
  await page.waitForTimeout(randInt(1500, 3500));
321
+ if (i < chosen.length - 1) {
322
+ if (chance(0.12)) { log('info', `[warmup] "${kw}" — leaving the remaining ${chosen.length - 1 - i} result(s) unwatched`); break; }
323
+ await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
324
+ await page.waitForTimeout(randInt(1500, 3000));
325
+ await organicScroll(page, randInt(0, 2));
326
+ }
286
327
  }
287
328
  }
288
329