channel-worker 2.5.27 → 2.5.29

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/api-client.js CHANGED
@@ -68,7 +68,7 @@ class ApiClient {
68
68
  async getNextCommand(workerId) {
69
69
  // Daemon-handled types. `_pw` variants route to the Playwright pipeline
70
70
  // (lib/playwright-runner → scripts/<base>.js) instead of the extension.
71
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,warmup_youtube_pw,scrape_affiliate_products,ingest_shopee_product';
71
+ const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw,warmup_youtube_pw,warmup_facebook_pw,warmup_tiktok_pw,scrape_affiliate_products,ingest_shopee_product';
72
72
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
73
73
  }
74
74
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.5.27",
3
+ "version": "2.5.29",
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": {
@@ -0,0 +1,342 @@
1
+ // Facebook page warmup — algorithm seeding for new channels.
2
+ //
3
+ // Runs on the channel's own NST profile (logged-in FB session, own residential
4
+ // proxy → FB sees organic per-account activity, not one IP crawling many).
5
+ // One run = one "session". Natural flow (per requirement — NO deep-linking to
6
+ // search URLs):
7
+ // home (facebook.com) → scroll a bit → click the "Reels" entry → from there
8
+ // use the top search bar to search each niche keyword → open + watch a few
9
+ // result reels/videos → back.
10
+ //
11
+ // Contract: run({ page, payload, log }) → { keywords_searched, videos_watched,
12
+ // duration_sec }. payload = { keywords[], config{...}, platform:'facebook' }.
13
+ //
14
+ // NOT a publish/engagement flow — only search + watch + scroll (no like /
15
+ // follow / comment). Best-effort per keyword/video; the search box + Reels
16
+ // entry are hard requirements — their absence (not logged in / consent wall /
17
+ // layout change) FAILS the session loudly (no silent bypass).
18
+
19
+ // ─── helpers ────────────────────────────────────────────────────────────────
20
+ function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
21
+ function shuffle(arr) {
22
+ const a = [...arr];
23
+ for (let i = a.length - 1; i > 0; i--) { const j = randInt(0, i); [a[i], a[j]] = [a[j], a[i]]; }
24
+ return a;
25
+ }
26
+
27
+ async function firstVisible(locator, max = 8) {
28
+ const n = Math.min(await locator.count().catch(() => 0), max);
29
+ for (let i = 0; i < n; i++) {
30
+ if (await locator.nth(i).isVisible().catch(() => false)) return locator.nth(i);
31
+ }
32
+ return null;
33
+ }
34
+
35
+ async function typeHuman(page, text) {
36
+ for (const ch of text) {
37
+ await page.keyboard.type(ch);
38
+ await page.waitForTimeout(randInt(60, 160));
39
+ }
40
+ }
41
+
42
+ async function organicScroll(page, rounds = null) {
43
+ const n = rounds ?? randInt(2, 4);
44
+ for (let i = 0; i < n; i++) {
45
+ await page.mouse.wheel(0, randInt(300, 850)).catch(() => {});
46
+ await page.waitForTimeout(randInt(500, 1500));
47
+ }
48
+ }
49
+
50
+ // Dismiss FB cross-sell / cookie / "not now" popups that overlay the page.
51
+ // Best-effort — never throws. Avoids touching the composer or search.
52
+ async function dismissDialogs(page, log) {
53
+ 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'];
54
+ for (let round = 0; round < 3; round++) {
55
+ const hit = await page.evaluate((vs) => {
56
+ const dlgs = document.querySelectorAll("[role='dialog']");
57
+ for (const dlg of dlgs) {
58
+ const r = dlg.getBoundingClientRect();
59
+ if (r.width < 8 || r.height < 8) continue;
60
+ const aria = (dlg.getAttribute('aria-label') || '').toLowerCase();
61
+ if (/tạo bài viết|create post|thước phim|reel/i.test(aria)) continue; // never the composer/reels
62
+ for (const v of vs) {
63
+ for (const b of dlg.querySelectorAll("[role='button'], button")) {
64
+ const t = (b.innerText || '').trim();
65
+ if ((t === v || (b.getAttribute('aria-label') || '').trim() === v) && b.offsetParent !== null) {
66
+ b.setAttribute('__warm_dismiss__', '1');
67
+ return v;
68
+ }
69
+ }
70
+ }
71
+ }
72
+ return null;
73
+ }, verbs).catch(() => null);
74
+ if (!hit) break;
75
+ try { await page.locator("[__warm_dismiss__='1']").click({ timeout: 2500 }); } catch {}
76
+ await page.evaluate(() => document.querySelectorAll("[__warm_dismiss__]").forEach(e => e.removeAttribute('__warm_dismiss__'))).catch(() => {});
77
+ await page.waitForTimeout(800);
78
+ }
79
+ }
80
+
81
+ // Click the "Reels" entry in the left nav / shortcut bar (organic entry — NOT a
82
+ // direct URL). Matches the nav link to the reels surface (href /reel/ + aria/
83
+ // text Reels|Thước phim), scoped to navigation so we don't hit a feed item.
84
+ async function clickReelsEntry(page, log) {
85
+ for (let attempt = 0; attempt < 3; attempt++) {
86
+ const found = await page.evaluate(() => {
87
+ const isReel = (el) => {
88
+ const href = el.getAttribute('href') || '';
89
+ const aria = (el.getAttribute('aria-label') || '').trim();
90
+ const txt = (el.innerText || '').trim();
91
+ const hrefReel = /\/reel(s)?\b|\/reel\//i.test(href);
92
+ const labelReel = /^reels?$|^thước phim$/i.test(aria) || /^reels?$|^thước phim$/i.test(txt);
93
+ return (hrefReel && (labelReel || aria === '' )) || (labelReel && (hrefReel || /reel/i.test(href)));
94
+ };
95
+ // Prefer links inside navigation regions (left rail / shortcut bar).
96
+ const navs = [...document.querySelectorAll("[role='navigation'] a, [role='banner'] a, a")];
97
+ for (const a of navs) {
98
+ if (!a.getAttribute) continue;
99
+ const r = a.getBoundingClientRect();
100
+ if (r.width < 8 || r.height < 8) continue;
101
+ if (a.offsetParent === null) continue;
102
+ if (isReel(a)) { a.setAttribute('__warm_reels__', '1'); return { href: a.getAttribute('href') || '', aria: (a.getAttribute('aria-label') || a.innerText || '').slice(0, 30) }; }
103
+ }
104
+ return null;
105
+ }).catch(() => null);
106
+ if (found) {
107
+ try {
108
+ await page.locator("[__warm_reels__='1']").first().click({ timeout: 4000 });
109
+ await page.evaluate(() => document.querySelectorAll("[__warm_reels__]").forEach(e => e.removeAttribute('__warm_reels__'))).catch(() => {});
110
+ await page.waitForTimeout(randInt(3000, 5000));
111
+ log('info', `[warmup-fb] clicked Reels entry (aria="${found.aria}")`);
112
+ return true;
113
+ } catch (e) {
114
+ log('info', `[warmup-fb] Reels click failed: ${String(e.message || e).slice(0, 80)}`);
115
+ }
116
+ }
117
+ await page.waitForTimeout(1500);
118
+ }
119
+ return false;
120
+ }
121
+
122
+ // Focus the FB top-bar search input. FB sometimes renders a collapsed button
123
+ // that expands into an input on click — handle both. Returns true if focused.
124
+ async function focusFbSearch(page, log) {
125
+ const inputSels = [
126
+ "input[aria-label*='Tìm kiếm']",
127
+ "input[aria-label*='earch']",
128
+ "input[type='search']",
129
+ "[role='banner'] input[type='text']",
130
+ ];
131
+ const triggerSels = [
132
+ "[aria-label='Tìm kiếm trên Facebook']",
133
+ "[aria-label='Search Facebook']",
134
+ "[role='banner'] [role='button'][aria-label*='earch']",
135
+ "[role='banner'] [aria-label*='Tìm kiếm']",
136
+ ];
137
+ for (let attempt = 0; attempt < 3; attempt++) {
138
+ for (const sel of inputSels) {
139
+ const box = await firstVisible(page.locator(sel), 3);
140
+ if (box) {
141
+ try {
142
+ await box.click({ timeout: 2500 });
143
+ await page.keyboard.down('Control'); await page.keyboard.press('A'); await page.keyboard.up('Control');
144
+ await page.keyboard.press('Backspace');
145
+ return true;
146
+ } catch {}
147
+ }
148
+ }
149
+ // No visible input → click a search trigger to expand it, then retry.
150
+ for (const sel of triggerSels) {
151
+ const t = await firstVisible(page.locator(sel), 3);
152
+ if (t) { try { await t.click({ timeout: 2500 }); await page.waitForTimeout(1200); } catch {} break; }
153
+ }
154
+ await page.waitForTimeout(1000);
155
+ }
156
+ return false;
157
+ }
158
+
159
+ // On a search-results page, click the "Reels" filter tab (fallback "Videos") so
160
+ // results bias toward watchable reels/videos. Best-effort.
161
+ async function clickResultsFilter(page, log) {
162
+ const labels = ['Thước phim', 'Reels', 'Video', 'Videos'];
163
+ const found = await page.evaluate((labs) => {
164
+ const els = document.querySelectorAll("[role='link'], [role='tab'], a, [role='button']");
165
+ for (const lab of labs) {
166
+ for (const el of els) {
167
+ const t = (el.innerText || '').trim();
168
+ if (t === lab && el.offsetParent !== null) {
169
+ const r = el.getBoundingClientRect();
170
+ if (r.width < 8 || r.height < 8) continue;
171
+ el.setAttribute('__warm_filter__', '1');
172
+ return lab;
173
+ }
174
+ }
175
+ }
176
+ return null;
177
+ }, labels).catch(() => null);
178
+ if (found) {
179
+ try {
180
+ await page.locator("[__warm_filter__='1']").first().click({ timeout: 3000 });
181
+ await page.evaluate(() => document.querySelectorAll("[__warm_filter__]").forEach(e => e.removeAttribute('__warm_filter__'))).catch(() => {});
182
+ await page.waitForTimeout(randInt(2000, 3500));
183
+ log('info', `[warmup-fb] results filtered → "${found}"`);
184
+ return found;
185
+ } catch {}
186
+ }
187
+ return null;
188
+ }
189
+
190
+ // Collect watchable result hrefs (reels/videos) on the current results page.
191
+ async function collectResultUrls(page) {
192
+ return page.evaluate(() => {
193
+ const out = []; const seen = new Set();
194
+ for (const a of document.querySelectorAll("a[href*='/reel/'], a[href*='/watch'], a[href*='/videos/']")) {
195
+ let href = a.getAttribute('href') || '';
196
+ if (!href) continue;
197
+ if (href.startsWith('/')) href = 'https://www.facebook.com' + href;
198
+ if (!/facebook\.com/.test(href)) continue;
199
+ const key = href.split('?')[0];
200
+ if (seen.has(key)) continue;
201
+ const r = a.getBoundingClientRect();
202
+ if (r.width < 8 || r.height < 8) continue;
203
+ seen.add(key); out.push(href);
204
+ }
205
+ return out;
206
+ }).catch(() => []);
207
+ }
208
+
209
+ // CLICK the result anchor matching `href` (natural — not a URL navigation).
210
+ // Marks the element then clicks via Playwright (trusted). Returns true on click.
211
+ async function clickResultByHref(page, href) {
212
+ const key = href.split('?')[0];
213
+ const found = await page.evaluate((k) => {
214
+ for (const a of document.querySelectorAll("a[href*='/reel/'], a[href*='/watch'], a[href*='/videos/']")) {
215
+ let h = a.getAttribute('href') || '';
216
+ if (h.startsWith('/')) h = 'https://www.facebook.com' + h;
217
+ if (h.split('?')[0] === k && a.offsetParent !== null) { a.setAttribute('__warm_click__', '1'); return true; }
218
+ }
219
+ return false;
220
+ }, key).catch(() => false);
221
+ if (!found) return false;
222
+ const loc = page.locator("[__warm_click__='1']").first();
223
+ try {
224
+ await loc.scrollIntoViewIfNeeded({ timeout: 3000 }).catch(() => {});
225
+ await loc.click({ timeout: 4000 });
226
+ await page.evaluate(() => document.querySelectorAll("[__warm_click__]").forEach(e => e.removeAttribute('__warm_click__'))).catch(() => {});
227
+ return true;
228
+ } catch {
229
+ await page.evaluate(() => document.querySelectorAll("[__warm_click__]").forEach(e => e.removeAttribute('__warm_click__'))).catch(() => {});
230
+ return false;
231
+ }
232
+ }
233
+
234
+ // 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.
236
+ async function watchCurrent(page, minSec, maxSec, log) {
237
+ try {
238
+ await page.waitForTimeout(randInt(2500, 4500)); // let the reel viewer mount
239
+ await dismissDialogs(page, log);
240
+ const hasVideo = await page.evaluate(() => {
241
+ const v = document.querySelector('video');
242
+ if (!v) return false;
243
+ if (v.paused) { try { v.play(); } catch {} }
244
+ return true;
245
+ }).catch(() => false);
246
+ if (!hasVideo) { log('info', '[warmup-fb] no <video> after click — skipping'); return false; }
247
+ const watchMs = randInt(minSec, maxSec) * 1000;
248
+ 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);
253
+ return true;
254
+ } catch (e) {
255
+ log('info', `[warmup-fb] watch failed: ${String(e.message || e).slice(0, 90)}`);
256
+ return false;
257
+ }
258
+ }
259
+
260
+ // ─── main ─────────────────────────────────────────────────────────────────
261
+ async function run({ page, payload, log }) {
262
+ const t0 = Date.now();
263
+ const allKeywords = Array.isArray(payload?.keywords) ? payload.keywords.filter(Boolean) : [];
264
+ if (!allKeywords.length) throw new Error('warmup-fb: no keywords provided');
265
+
266
+ const cfg = payload.config || {};
267
+ const keywordsPerSession = Math.max(1, cfg.keywords_per_session ?? 5);
268
+ const videosPerKeyword = Math.max(1, cfg.videos_per_keyword ?? 3);
269
+ const watchMin = Math.max(5, cfg.watch_time_min_sec ?? 30);
270
+ const watchMax = Math.max(watchMin, cfg.watch_time_max_sec ?? 90);
271
+ const sessionKeywords = shuffle(allKeywords).slice(0, keywordsPerSession);
272
+ log('info', `[warmup-fb] session start — ${sessionKeywords.length}/${allKeywords.length} keywords, ${videosPerKeyword} videos/keyword, watch ${watchMin}-${watchMax}s`);
273
+
274
+ page.on('dialog', (d) => { d.accept().catch(() => {}); });
275
+
276
+ // 1) Land on the FB home feed (organic entry — not a deep link).
277
+ await page.goto('https://www.facebook.com/', { waitUntil: 'domcontentloaded', timeout: 60000 });
278
+ await page.waitForTimeout(randInt(3000, 5000));
279
+ await dismissDialogs(page, log);
280
+ await organicScroll(page, randInt(1, 3));
281
+
282
+ // 2) Click the "Reels" entry (left nav / shortcut) — natural navigation.
283
+ const reelsOk = await clickReelsEntry(page, log);
284
+ if (!reelsOk) {
285
+ throw new Error('warmup-fb: "Reels" entry not found on FB home — check the profile is logged in to Facebook and the left-nav Reels shortcut is present');
286
+ }
287
+ await dismissDialogs(page, log);
288
+ await organicScroll(page, randInt(1, 2));
289
+
290
+ const keywordsSearched = [];
291
+ let videosWatched = 0;
292
+
293
+ for (const kw of sessionKeywords) {
294
+ // 3) Search the keyword via the top bar (from the Reels surface).
295
+ const focused = await focusFbSearch(page, log);
296
+ if (!focused) {
297
+ throw new Error('warmup-fb: search box not found — FB layout/login issue (cannot warm up without searching)');
298
+ }
299
+ log('info', `[warmup-fb] searching: "${kw}"`);
300
+ await typeHuman(page, kw);
301
+ await page.waitForTimeout(randInt(500, 1100));
302
+ await page.keyboard.press('Enter');
303
+ await page.waitForTimeout(randInt(3000, 5000));
304
+ await dismissDialogs(page, log);
305
+ keywordsSearched.push(kw);
306
+
307
+ // 4) Bias results toward reels/videos, then CLICK + watch a few (no URL nav).
308
+ await clickResultsFilter(page, log);
309
+ await organicScroll(page, randInt(1, 3));
310
+ const urls = await collectResultUrls(page);
311
+ if (!urls.length) {
312
+ log('info', `[warmup-fb] no reel/video results for "${kw}" — next keyword`);
313
+ continue; // top-bar search persists; next keyword searches from here
314
+ }
315
+ log('info', `[warmup-fb] "${kw}" → ${urls.length} results, opening up to ${videosPerKeyword}`);
316
+ const watched = new Set();
317
+ for (let n = 0; n < videosPerKeyword; n++) {
318
+ // Re-collect each iteration — clicking + goBack mutates the DOM.
319
+ const current = await collectResultUrls(page);
320
+ const next = current.find(u => !watched.has(u.split('?')[0]));
321
+ if (!next) break;
322
+ watched.add(next.split('?')[0]);
323
+ const clicked = await clickResultByHref(page, next);
324
+ if (!clicked) { log('info', `[warmup-fb] couldn't click result ${next.slice(-24)} — skipping`); continue; }
325
+ const ok = await watchCurrent(page, watchMin, watchMax, log);
326
+ if (ok) videosWatched++;
327
+ // Back to the results list for the next pick (Escape closes a reel overlay
328
+ // if goBack didn't return to results).
329
+ await page.goBack({ waitUntil: 'domcontentloaded', timeout: 30000 }).catch(() => {});
330
+ await page.waitForTimeout(randInt(1500, 2800));
331
+ if (/\/reel\//.test(page.url())) { await page.keyboard.press('Escape').catch(() => {}); await page.waitForTimeout(1200); }
332
+ }
333
+ // Next keyword searches from the persistent top-bar (no home bounce).
334
+ await page.waitForTimeout(randInt(1000, 2000));
335
+ }
336
+
337
+ const durationSec = Math.round((Date.now() - t0) / 1000);
338
+ log('info', `[warmup-fb] session done — keywords=${keywordsSearched.length} videos=${videosWatched} duration=${durationSec}s`);
339
+ return { keywords_searched: keywordsSearched, videos_watched: videosWatched, duration_sec: durationSec };
340
+ }
341
+
342
+ module.exports = { run };
@@ -114,28 +114,24 @@ async function collectResultVideoUrls(page) {
114
114
  // skippable ad; non-skippable ads have no button (we wait them out). Multiple
115
115
  // pre-rolls can chain, so this is called every tick of the watch loop.
116
116
  async function trySkipAd(page) {
117
- return page.evaluate(() => {
118
- // ONLY click inside the player's real skip-ad button (stable YT classes).
119
- // A loose text match like /skip/i clicked random buttons every tick on
120
- // videos with no ad keep it strict to the ad-skip control.
121
- const sels = [
122
- '.ytp-ad-skip-button-modern',
123
- '.ytp-ad-skip-button',
124
- '.ytp-skip-ad-button',
125
- '.ytp-ad-skip-button-container button',
126
- ];
127
- for (const s of sels) {
128
- const btn = document.querySelector(s);
129
- if (btn && btn.offsetParent !== null) { btn.click(); return true; }
130
- }
131
- // Strict text fallback require an ad-specific phrase, NOT bare "skip"/"bỏ qua".
132
- const phrases = /bỏ qua quảng cáo|skip ad\b|skip ads/i;
133
- for (const b of document.querySelectorAll('.ytp-ad-skip-button-text, .ytp-ad-skip-button button, button')) {
134
- const t = (b.innerText || b.textContent || '').trim();
135
- if (t && phrases.test(t) && b.offsetParent !== null) { b.click(); return true; }
117
+ // Use Playwright's real .click() (trusted pointer event) a synthetic
118
+ // el.click() from page.evaluate is ignored by YouTube's player, so the ad
119
+ // never actually skipped (saw 52 "skips" with the ad still playing). The
120
+ // skip button only becomes clickable ~5s into the ad; an early click times
121
+ // out return false → retried next tick.
122
+ const sels = [
123
+ '.ytp-ad-skip-button-modern',
124
+ '.ytp-ad-skip-button',
125
+ '.ytp-skip-ad-button',
126
+ '.ytp-ad-skip-button-container button',
127
+ ];
128
+ for (const s of sels) {
129
+ const loc = page.locator(s).first();
130
+ if (await loc.isVisible().catch(() => false)) {
131
+ try { await loc.click({ timeout: 1500 }); return true; } catch { /* not yet clickable */ }
136
132
  }
137
- return false;
138
- }).catch(() => false);
133
+ }
134
+ return false;
139
135
  }
140
136
 
141
137
  // True while an ad is ACTUALLY playing (pre/mid-roll). Relies on the player's
@@ -187,7 +183,8 @@ async function watchVideo(page, url, minSec, maxSec, log) {
187
183
  await page.waitForTimeout(TICK);
188
184
  }
189
185
  if (skips) log('info', `[warmup] ${url.slice(-11)} — skipped ${skips} ad tick(s), real watch ${Math.round(realWatched/1000)}s`);
190
- return true;
186
+ // Only count as a watched video if real (non-ad) content actually played.
187
+ return realWatched > 0;
191
188
  } catch (e) {
192
189
  log('warn', `[warmup] watch failed (${url.slice(-11)}): ${String(e.message || e).slice(0, 100)}`);
193
190
  return false;