channel-worker 2.5.22 → 2.5.24

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.22",
3
+ "version": "2.5.24",
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": {
@@ -283,7 +283,7 @@ async function run({ page, payload, log }) {
283
283
  } = payload || {};
284
284
  if (!video_url) throw new Error('No video_url provided');
285
285
 
286
- log('info', '[fb-pw] selectors version=2026.06.19c-posturl-network-first');
286
+ log('info', '[fb-pw] selectors version=2026.06.19d-reels-link-fallback');
287
287
 
288
288
  page.on('dialog', (d) => { d.accept().catch(() => {}); });
289
289
 
@@ -454,9 +454,31 @@ async function run({ page, payload, log }) {
454
454
  .filter({ has: page.locator(":scope :text-matches('^Tạo thước phim$|^Create reel$|^Create a reel$', 'i')") })
455
455
  .first();
456
456
  if (!(await reelsDialog.isVisible({ timeout: 6000 }).catch(() => false))) {
457
- await dumpInventory(page, log, 'no-reels-modal');
458
- await dumpFailure(page, 'no-reels-modal', log);
459
- throw new Error('FB Reels modal "Tạo thước phim" did not open after clicking Thước phim entry');
457
+ // FALLBACK: some accounts render a different UI from the create-post
458
+ // "Thước phim" entry → the composer modal never mounts. Click the Reels
459
+ // link (any <a href*='/reels'>) instead, then re-wait for the modal.
460
+ log('warn', '[fb-pw] Reels modal not open — fallback: click <a href*="/reels"> then re-wait…');
461
+ let xpClicked = false;
462
+ try {
463
+ const reelsLink = page.locator("xpath=//a[contains(@href, '/reels')]").first();
464
+ if (await reelsLink.isVisible({ timeout: 3000 }).catch(() => false)) {
465
+ await reelsLink.click({ timeout: 4000 });
466
+ xpClicked = true;
467
+ log('info', '[fb-pw] clicked Reels link via xpath //a[contains(@href,"/reels")]');
468
+ } else {
469
+ log('warn', '[fb-pw] fallback: no visible <a href*="/reels"> found');
470
+ }
471
+ } catch (e) { log('warn', `[fb-pw] fallback Reels-link click failed: ${e.message.slice(0, 80)}`); }
472
+ if (xpClicked) {
473
+ await page.waitForTimeout(5000);
474
+ await dismissBsOnboarding(page, log).catch(() => {});
475
+ }
476
+ if (!(await reelsDialog.isVisible({ timeout: 8000 }).catch(() => false))) {
477
+ await dumpInventory(page, log, 'no-reels-modal');
478
+ await dumpFailure(page, 'no-reels-modal', log);
479
+ throw new Error('FB Reels modal "Tạo thước phim" did not open (kể cả sau fallback xpath //a[href*="/reels"])');
480
+ }
481
+ log('info', '[fb-pw] Reels modal opened after xpath /reels fallback');
460
482
  }
461
483
  // Candidates inside the modal only:
462
484
  // - "Tải lên" — the explicit blue upload button at the bottom (preferred)
@@ -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 };