channel-worker 2.5.23 → 2.5.25

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,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,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.23",
3
+ "version": "2.5.25",
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,212 @@
1
+ // YouTube channel warmup — algorithm seeding for new channels.
2
+ //
3
+ // Runs on the channel's own NST profile (logged-in YouTube session, own
4
+ // residential proxy → platform sees organic per-channel activity, not one IP
5
+ // crawling many channels). One run = one "session": search a handful of niche
6
+ // keywords in the real search box (typed char-by-char), open a few result
7
+ // videos, and watch each for a random span before going back. This builds
8
+ // search + watch history so YouTube indexes the channel for the right niche
9
+ // BEFORE it ever uploads.
10
+ //
11
+ // Contract: run({ page, payload, log }) → { keywords_searched, videos_watched,
12
+ // duration_sec }. payload = { keywords: string[], config: {...}, youtube_handle }.
13
+ //
14
+ // NOT a publish flow — no uploads, no idea state. Best-effort per keyword, but
15
+ // if the search box itself can't be found (layout change / not logged in) the
16
+ // whole session FAILS loudly (no silent bypass) so the user can investigate.
17
+
18
+ // ─── helpers ────────────────────────────────────────────────────────────────
19
+ function randInt(min, max) { return Math.floor(min + Math.random() * (max - min + 1)); }
20
+ function pick(arr) { return arr[randInt(0, arr.length - 1)]; }
21
+
22
+ // Fisher-Yates shuffle (non-mutating) — used to pick a random keyword subset
23
+ // + randomize which result videos we open, so the pattern isn't identical
24
+ // every session.
25
+ function shuffle(arr) {
26
+ const a = [...arr];
27
+ for (let i = a.length - 1; i > 0; i--) {
28
+ const j = randInt(0, i);
29
+ [a[i], a[j]] = [a[j], a[i]];
30
+ }
31
+ return a;
32
+ }
33
+
34
+ async function firstVisible(locator, max = 8) {
35
+ const n = Math.min(await locator.count().catch(() => 0), max);
36
+ for (let i = 0; i < n; i++) {
37
+ if (await locator.nth(i).isVisible().catch(() => false)) return locator.nth(i);
38
+ }
39
+ return null;
40
+ }
41
+
42
+ // Type a string into the focused element one character at a time with a small
43
+ // 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
+ }
50
+
51
+ // A few organic scroll nudges down the page (with pauses) before/while picking
52
+ // a video. Wheel events look more human than a jump-to-element click.
53
+ async function organicScroll(page, rounds = null) {
54
+ const n = rounds ?? randInt(2, 4);
55
+ for (let i = 0; i < n; i++) {
56
+ await page.mouse.wheel(0, randInt(300, 850)).catch(() => {});
57
+ await page.waitForTimeout(randInt(500, 1500));
58
+ }
59
+ }
60
+
61
+ // Locate + focus the YouTube search box. Tries the common selectors across
62
+ // the (frequently A/B-tested) home layouts. Returns true if focused.
63
+ async function focusSearchBox(page, log) {
64
+ const candidates = [
65
+ "input#search",
66
+ "input[name='search_query']",
67
+ "ytd-searchbox input#search",
68
+ "div#search-input input",
69
+ "input[aria-label*='earch']",
70
+ ];
71
+ for (let attempt = 0; attempt < 3; attempt++) {
72
+ for (const sel of candidates) {
73
+ const box = await firstVisible(page.locator(sel), 3);
74
+ if (box) {
75
+ try {
76
+ await box.click({ timeout: 3000 });
77
+ // Clear any leftover query from a previous keyword.
78
+ await page.keyboard.down('Control'); await page.keyboard.press('A'); await page.keyboard.up('Control');
79
+ await page.keyboard.press('Backspace');
80
+ return true;
81
+ } catch { /* try next selector */ }
82
+ }
83
+ }
84
+ await page.waitForTimeout(1500);
85
+ }
86
+ return false;
87
+ }
88
+
89
+ // Collect candidate watch-video hrefs from the current search results page.
90
+ // Skips Shorts (/shorts/ — different FYP surface) + ads/mixes. Returns absolute
91
+ // /watch?v= URLs in DOM order; caller shuffles + slices.
92
+ async function collectResultVideoUrls(page) {
93
+ return page.evaluate(() => {
94
+ const out = [];
95
+ const seen = new Set();
96
+ const anchors = document.querySelectorAll(
97
+ "ytd-video-renderer a#video-title, ytd-video-renderer a#thumbnail, a#video-title-link, ytd-rich-item-renderer a#video-title-link",
98
+ );
99
+ for (const a of anchors) {
100
+ const href = a.getAttribute('href') || '';
101
+ if (!href.includes('/watch?v=')) continue; // skip shorts / channels / playlists
102
+ const m = href.match(/v=([\w-]{11})/);
103
+ if (!m) continue;
104
+ if (seen.has(m[1])) continue;
105
+ seen.add(m[1]);
106
+ out.push('https://www.youtube.com' + href.split('&')[0]);
107
+ }
108
+ return out;
109
+ }).catch(() => []);
110
+ }
111
+
112
+ // Watch one video: navigate, let it play for a random span, do a small scroll
113
+ // (into comments/related) like a real viewer. Best-effort — a single failed
114
+ // video never aborts the session.
115
+ async function watchVideo(page, url, minSec, maxSec, log) {
116
+ try {
117
+ await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 45000 });
118
+ await page.waitForTimeout(randInt(2500, 4500));
119
+ // Best-effort: make sure it's actually playing (autoplay sometimes pauses).
120
+ await page.evaluate(() => {
121
+ const v = document.querySelector('video');
122
+ if (v && v.paused) { try { v.play(); } catch {} }
123
+ }).catch(() => {});
124
+ const watchMs = randInt(minSec, maxSec) * 1000;
125
+ log('info', `[warmup] watching ${url.slice(-11)} for ~${Math.round(watchMs / 1000)}s`);
126
+ // Split the watch time so we can scroll mid-view (human behaviour).
127
+ const half = Math.floor(watchMs / 2);
128
+ await page.waitForTimeout(half);
129
+ await organicScroll(page, randInt(1, 2));
130
+ await page.waitForTimeout(watchMs - half);
131
+ return true;
132
+ } catch (e) {
133
+ log('warn', `[warmup] watch failed (${url.slice(-11)}): ${String(e.message || e).slice(0, 100)}`);
134
+ return false;
135
+ }
136
+ }
137
+
138
+ // ─── main ─────────────────────────────────────────────────────────────────
139
+ async function run({ page, payload, log }) {
140
+ const t0 = Date.now();
141
+ const allKeywords = Array.isArray(payload?.keywords) ? payload.keywords.filter(Boolean) : [];
142
+ if (!allKeywords.length) throw new Error('warmup: no keywords provided');
143
+
144
+ const cfg = payload.config || {};
145
+ const keywordsPerSession = Math.max(1, cfg.keywords_per_session ?? 5);
146
+ const videosPerKeyword = Math.max(1, cfg.videos_per_keyword ?? 3);
147
+ const watchMin = Math.max(5, cfg.watch_time_min_sec ?? 30);
148
+ const watchMax = Math.max(watchMin, cfg.watch_time_max_sec ?? 90);
149
+
150
+ // Pick a random subset for THIS session so repeated runs vary.
151
+ const sessionKeywords = shuffle(allKeywords).slice(0, keywordsPerSession);
152
+ log('info', `[warmup] session start — ${sessionKeywords.length}/${allKeywords.length} keywords, ${videosPerKeyword} videos/keyword, watch ${watchMin}-${watchMax}s`);
153
+
154
+ page.on('dialog', (d) => { d.accept().catch(() => {}); });
155
+
156
+ // Land on the home feed first (organic entry point).
157
+ await page.goto('https://www.youtube.com/', { waitUntil: 'domcontentloaded', timeout: 60000 });
158
+ await page.waitForTimeout(randInt(2500, 4500));
159
+ await organicScroll(page, randInt(1, 3));
160
+
161
+ const keywordsSearched = [];
162
+ let videosWatched = 0;
163
+
164
+ for (const kw of sessionKeywords) {
165
+ // Always return to home so the search box is in its canonical spot.
166
+ if (!page.url().match(/youtube\.com\/?($|\?|#)/)) {
167
+ await page.goto('https://www.youtube.com/', { waitUntil: 'domcontentloaded', timeout: 60000 }).catch(() => {});
168
+ await page.waitForTimeout(randInt(1500, 3000));
169
+ }
170
+
171
+ const focused = await focusSearchBox(page, log);
172
+ if (!focused) {
173
+ // The search box is the one hard requirement — its absence means the
174
+ // page isn't a logged-in YouTube (proxy block / consent wall / layout
175
+ // change). Fail the session loudly rather than pretend success.
176
+ throw new Error('warmup: YouTube search box not found — check the profile is logged in to YouTube and not on a consent/captcha wall');
177
+ }
178
+
179
+ log('info', `[warmup] searching: "${kw}"`);
180
+ await typeHuman(page, kw);
181
+ await page.waitForTimeout(randInt(400, 1000));
182
+ await page.keyboard.press('Enter');
183
+ await page.waitForTimeout(randInt(3000, 5000));
184
+ keywordsSearched.push(kw);
185
+
186
+ await organicScroll(page, randInt(2, 4));
187
+ const urls = await collectResultVideoUrls(page);
188
+ if (!urls.length) {
189
+ log('warn', `[warmup] no result videos for "${kw}" — skipping to next keyword`);
190
+ continue;
191
+ }
192
+ const chosen = shuffle(urls).slice(0, videosPerKeyword);
193
+ log('info', `[warmup] "${kw}" → ${urls.length} results, opening ${chosen.length}`);
194
+
195
+ for (const url of chosen) {
196
+ const ok = await watchVideo(page, url, watchMin, watchMax, log);
197
+ if (ok) videosWatched++;
198
+ // Brief idle between videos.
199
+ await page.waitForTimeout(randInt(1500, 3500));
200
+ }
201
+ }
202
+
203
+ const durationSec = Math.round((Date.now() - t0) / 1000);
204
+ log('info', `[warmup] session done — keywords=${keywordsSearched.length} videos=${videosWatched} duration=${durationSec}s`);
205
+ return {
206
+ keywords_searched: keywordsSearched,
207
+ videos_watched: videosWatched,
208
+ duration_sec: durationSec,
209
+ };
210
+ }
211
+
212
+ module.exports = { run };