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 +1 -1
- package/scripts/upload_facebook.js +26 -4
- package/scripts/warmup_youtube.js +212 -0
package/package.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
|
|
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 };
|