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.
- package/lib/command-poller.js +38 -2
- package/lib/nst-manager.js +25 -29
- package/package.json +1 -1
- package/scripts/lib/dom-pick.js +53 -0
- package/scripts/lib/human.js +147 -0
- package/scripts/nurture_facebook.js +222 -110
- package/scripts/upload_facebook.js +93 -66
- package/scripts/upload_facebook_photo.js +19 -17
- package/scripts/warmup_facebook.js +58 -65
- package/scripts/warmup_youtube.js +60 -19
|
@@ -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
|
-
|
|
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
|
|
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
|
|
69
|
-
return v;
|
|
67
|
+
return { el: b, verb: v };
|
|
70
68
|
}
|
|
71
69
|
}
|
|
72
70
|
}
|
|
73
71
|
}
|
|
74
|
-
return null;
|
|
75
|
-
}, verbs)
|
|
76
|
-
if (!
|
|
77
|
-
|
|
78
|
-
await page.
|
|
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
|
|
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
|
|
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
|
-
})
|
|
108
|
-
if (
|
|
109
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
186
|
-
return lab;
|
|
179
|
+
return { el, lab };
|
|
187
180
|
}
|
|
188
181
|
}
|
|
189
182
|
}
|
|
190
|
-
return null;
|
|
191
|
-
}, labels)
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
221
|
+
if (h.split('?')[0] === k && a.offsetParent !== null) return { el: a };
|
|
232
222
|
}
|
|
233
|
-
return
|
|
234
|
-
}, key)
|
|
235
|
-
if (!
|
|
236
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
let
|
|
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
|
-
|
|
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 >
|
|
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
|
-
|
|
347
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
232
|
-
|
|
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 (
|
|
282
|
-
const ok = await watchVideo(page,
|
|
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
|
|
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
|
|