channel-worker 1.4.0 → 1.5.0

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
@@ -65,7 +65,7 @@ class ApiClient {
65
65
 
66
66
  // Commands
67
67
  async getNextCommand(workerId) {
68
- const workerTypes = 'launch_profile,close_profile,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension';
68
+ const workerTypes = 'launch_profile,close_profile,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats';
69
69
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
70
70
  }
71
71
 
@@ -1,5 +1,6 @@
1
1
  const { NstManager } = require('./nst-manager');
2
2
  const { checkAndUpdateExtension } = require('./extension-updater');
3
+ const { StatsSyncer } = require('./stats-syncer');
3
4
 
4
5
  class CommandPoller {
5
6
  constructor(api, config) {
@@ -68,6 +69,9 @@ class CommandPoller {
68
69
  case 'update_extension':
69
70
  await this.handleUpdateExtension(command);
70
71
  break;
72
+ case 'sync_youtube_stats':
73
+ await this.handleSyncYoutubeStats(command);
74
+ break;
71
75
  default:
72
76
  // Other commands (scan_facebook_pages, etc.) handled by extension
73
77
  console.log(`[commands] Skipping ${command.type} — handled by extension`);
@@ -856,6 +860,48 @@ class CommandPoller {
856
860
  // TODO: launch headless, check cookies for Google/Facebook/TikTok
857
861
  await this.api.updateCommand(command._id, { status: 'done' });
858
862
  }
863
+
864
+ async handleSyncYoutubeStats(command) {
865
+ const { channel_id } = command.payload || {};
866
+ console.log(`[commands] Syncing YouTube stats for channel: ${channel_id}`);
867
+
868
+ try {
869
+ if (!this.nst) {
870
+ const apiKey = await this.api.getSetting('nst_api_key');
871
+ if (apiKey) {
872
+ this.nst = new NstManager(apiKey);
873
+ } else {
874
+ throw new Error('Nstbrowser API key not configured.');
875
+ }
876
+ }
877
+
878
+ // Fetch channel info
879
+ const channel = await this.api.getChannel(channel_id);
880
+ if (!channel) throw new Error(`Channel ${channel_id} not found`);
881
+ if (!channel.nst_profile_id) throw new Error(`Channel "${channel.name}" has no Nstbrowser profile`);
882
+
883
+ const syncer = new StatsSyncer(this.nst, this.api);
884
+ const stats = await syncer.syncYouTubeStats(channel);
885
+
886
+ await this.api.updateCommand(command._id, {
887
+ status: 'done',
888
+ result: {
889
+ channel_id,
890
+ channel_name: channel.name,
891
+ ...stats,
892
+ synced_at: new Date().toISOString(),
893
+ },
894
+ });
895
+
896
+ console.log(`[commands] YouTube stats synced for "${channel.name}"`);
897
+ } catch (err) {
898
+ console.error(`[commands] YouTube stats sync failed: ${err.message}`);
899
+ await this.api.updateCommand(command._id, {
900
+ status: 'failed',
901
+ error: err.message,
902
+ });
903
+ }
904
+ }
859
905
  }
860
906
 
861
907
  module.exports = { CommandPoller };
@@ -0,0 +1,385 @@
1
+ const WebSocket = require('ws');
2
+
3
+ /**
4
+ * Sync YouTube Studio stats via Nstbrowser CDP.
5
+ * Flow: Launch profile → connect CDP → navigate to Studio → scrape stats → close
6
+ */
7
+ class StatsSyncer {
8
+ constructor(nstManager, apiClient) {
9
+ this.nst = nstManager;
10
+ this.api = apiClient;
11
+ }
12
+
13
+ /**
14
+ * Sync stats for a single channel via YouTube Studio
15
+ * @param {object} channel - { _id, name, nst_profile_id, proxy }
16
+ * @returns {object} { subscribers, total_views, video_count }
17
+ */
18
+ async syncYouTubeStats(channel) {
19
+ const profileId = channel.nst_profile_id;
20
+ if (!profileId) throw new Error(`Channel "${channel.name}" has no nst_profile_id`);
21
+
22
+ console.log(`[stats] Syncing YouTube stats for "${channel.name}" (profile: ${profileId})`);
23
+
24
+ // Launch profile (no extension needed for stats)
25
+ const { wsEndpoint } = await this.nst.launchProfile(profileId, {
26
+ proxy: channel.proxy || null,
27
+ });
28
+
29
+ if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
30
+
31
+ try {
32
+ const stats = await this.scrapeYouTubeStudio(wsEndpoint);
33
+ console.log(`[stats] "${channel.name}" → subs: ${stats.subscribers}, views: ${stats.total_views}, videos: ${stats.video_count}`);
34
+
35
+ // Send to API
36
+ await this.api.request('POST', '/analytics/sync', {
37
+ channel_id: channel._id,
38
+ platform: 'youtube',
39
+ subscribers: stats.subscribers,
40
+ total_views: stats.total_views,
41
+ video_count: stats.video_count,
42
+ });
43
+
44
+ console.log(`[stats] "${channel.name}" stats saved to API`);
45
+ return stats;
46
+ } finally {
47
+ // Close browser after scraping
48
+ try {
49
+ await this.nst.stopProfile(profileId);
50
+ } catch (err) {
51
+ console.warn(`[stats] Failed to stop profile: ${err.message}`);
52
+ }
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Connect to browser via CDP WebSocket and scrape YouTube Studio
58
+ */
59
+ async scrapeYouTubeStudio(wsEndpoint) {
60
+ return new Promise((resolve, reject) => {
61
+ const ws = new WebSocket(wsEndpoint);
62
+ let msgId = 1;
63
+ const pending = new Map();
64
+
65
+ const send = (method, params = {}) => {
66
+ return new Promise((res, rej) => {
67
+ const id = msgId++;
68
+ pending.set(id, { resolve: res, reject: rej });
69
+ ws.send(JSON.stringify({ id, method, params }));
70
+ });
71
+ };
72
+
73
+ const timeout = setTimeout(() => {
74
+ ws.close();
75
+ reject(new Error('YouTube Studio scrape timed out (60s)'));
76
+ }, 60000);
77
+
78
+ ws.on('error', (err) => {
79
+ clearTimeout(timeout);
80
+ reject(err);
81
+ });
82
+
83
+ ws.on('message', (data) => {
84
+ try {
85
+ const msg = JSON.parse(data);
86
+ if (msg.id && pending.has(msg.id)) {
87
+ const p = pending.get(msg.id);
88
+ pending.delete(msg.id);
89
+ if (msg.error) p.reject(new Error(msg.error.message));
90
+ else p.resolve(msg.result);
91
+ }
92
+ } catch {}
93
+ });
94
+
95
+ ws.on('open', async () => {
96
+ try {
97
+ // Get list of targets (pages)
98
+ const targets = await send('Target.getTargets');
99
+ const pages = targets.targetInfos.filter(t => t.type === 'page');
100
+
101
+ // Find or create a page
102
+ let targetId;
103
+ if (pages.length > 0) {
104
+ targetId = pages[0].targetId;
105
+ } else {
106
+ const created = await send('Target.createTarget', { url: 'about:blank' });
107
+ targetId = created.targetId;
108
+ }
109
+
110
+ // Attach to target
111
+ const { sessionId } = await send('Target.attachToTarget', {
112
+ targetId,
113
+ flatten: true,
114
+ });
115
+
116
+ // Helper to send commands to the attached session
117
+ const sessionSend = (method, params = {}) => {
118
+ return new Promise((res, rej) => {
119
+ const id = msgId++;
120
+ pending.set(id, { resolve: res, reject: rej });
121
+ ws.send(JSON.stringify({ id, method, params, sessionId }));
122
+ });
123
+ };
124
+
125
+ // Enable page events
126
+ await sessionSend('Page.enable');
127
+
128
+ // Navigate to YouTube Studio dashboard
129
+ await sessionSend('Page.navigate', {
130
+ url: 'https://studio.youtube.com',
131
+ });
132
+
133
+ // Wait for page to load
134
+ await this.waitForLoad(sessionSend, 15000);
135
+
136
+ // Wait a bit for Studio SPA to render
137
+ await this.sleep(5000);
138
+
139
+ // Try to scrape stats from YouTube Studio dashboard
140
+ const stats = await this.extractStudioStats(sessionSend);
141
+
142
+ clearTimeout(timeout);
143
+ ws.close();
144
+ resolve(stats);
145
+ } catch (err) {
146
+ clearTimeout(timeout);
147
+ ws.close();
148
+ reject(err);
149
+ }
150
+ });
151
+ });
152
+ }
153
+
154
+ async waitForLoad(sessionSend, timeoutMs = 15000) {
155
+ const start = Date.now();
156
+ while (Date.now() - start < timeoutMs) {
157
+ try {
158
+ const result = await sessionSend('Runtime.evaluate', {
159
+ expression: 'document.readyState',
160
+ returnByValue: true,
161
+ });
162
+ if (result?.result?.value === 'complete') return;
163
+ } catch {}
164
+ await this.sleep(500);
165
+ }
166
+ }
167
+
168
+ async extractStudioStats(sessionSend) {
169
+ // YouTube Studio dashboard shows channel-level stats
170
+ // We'll try multiple approaches to get the data
171
+
172
+ // Approach 1: Navigate to channel analytics overview and extract from the page
173
+ await sessionSend('Page.navigate', {
174
+ url: 'https://studio.youtube.com/channel/UC/analytics/tab-overview/period-default',
175
+ });
176
+ await this.sleep(5000);
177
+
178
+ // Try to get subscriber count from the Studio header/sidebar
179
+ const statsResult = await sessionSend('Runtime.evaluate', {
180
+ expression: `
181
+ (function() {
182
+ const stats = { subscribers: 0, total_views: 0, video_count: 0 };
183
+
184
+ // Try to get subscriber count from channel dashboard
185
+ // YouTube Studio shows subscriber count in various places
186
+ const allText = document.body.innerText || '';
187
+
188
+ // Method 1: Look for subscriber count in the top bar
189
+ // YouTube Studio typically shows "X subscribers" somewhere
190
+ const subMatch = allText.match(/([\\d,\\.]+[KMB]?)\\s*subscribers?/i);
191
+ if (subMatch) {
192
+ stats.subscribers = parseHumanNumber(subMatch[1]);
193
+ }
194
+
195
+ // Method 2: Look for specific YouTube Studio elements
196
+ // The channel dashboard header often has subscriber count
197
+ const headerEl = document.querySelector('#channel-title .subscriber-count, .subscriber-count, [class*="subscriber"]');
198
+ if (headerEl) {
199
+ const text = headerEl.textContent.trim();
200
+ const num = text.match(/([\\d,\\.]+[KMB]?)/);
201
+ if (num) stats.subscribers = parseHumanNumber(num[1]);
202
+ }
203
+
204
+ // Try ytInitialData or ytcfg for exact numbers
205
+ try {
206
+ if (window.ytcfg) {
207
+ const data = window.ytcfg.data_ || {};
208
+ // Look through ytcfg for subscriber info
209
+ }
210
+ } catch {}
211
+
212
+ function parseHumanNumber(str) {
213
+ if (!str) return 0;
214
+ str = str.replace(/,/g, '').trim();
215
+ const mult = str.match(/([\\d\\.]+)\\s*([KMB])/i);
216
+ if (mult) {
217
+ const num = parseFloat(mult[1]);
218
+ const suffix = mult[2].toUpperCase();
219
+ if (suffix === 'K') return Math.round(num * 1000);
220
+ if (suffix === 'M') return Math.round(num * 1000000);
221
+ if (suffix === 'B') return Math.round(num * 1000000000);
222
+ }
223
+ return parseInt(str, 10) || 0;
224
+ }
225
+
226
+ return JSON.stringify(stats);
227
+ })()
228
+ `,
229
+ returnByValue: true,
230
+ });
231
+
232
+ let stats = { subscribers: 0, total_views: 0, video_count: 0 };
233
+ try {
234
+ stats = JSON.parse(statsResult?.result?.value || '{}');
235
+ } catch {}
236
+
237
+ // Approach 2: Use YouTube Studio's internal API via fetch in page context
238
+ // This gives exact numbers from the Studio API
239
+ const apiResult = await sessionSend('Runtime.evaluate', {
240
+ expression: `
241
+ (async function() {
242
+ try {
243
+ // YouTube Studio uses internal API calls
244
+ // We can intercept the channel data from the page's __INITIAL_DATA__ or similar
245
+
246
+ // Try getting data from the Studio SPA state
247
+ const scripts = document.querySelectorAll('script');
248
+ for (const s of scripts) {
249
+ const text = s.textContent || '';
250
+
251
+ // Look for serialized channel data
252
+ if (text.includes('subscriberCount') || text.includes('subscriber_count')) {
253
+ const subMatch = text.match(/"subscriberCount"\\s*:\\s*"?(\\d+)"?/);
254
+ const viewMatch = text.match(/"viewCount"\\s*:\\s*"?(\\d+)"?/);
255
+ const videoMatch = text.match(/"videoCount"\\s*:\\s*"?(\\d+)"?/);
256
+
257
+ if (subMatch || viewMatch || videoMatch) {
258
+ return JSON.stringify({
259
+ subscribers: parseInt(subMatch?.[1] || '0', 10),
260
+ total_views: parseInt(viewMatch?.[1] || '0', 10),
261
+ video_count: parseInt(videoMatch?.[1] || '0', 10),
262
+ });
263
+ }
264
+ }
265
+ }
266
+
267
+ // Fallback: try YouTube Data endpoint from within the page (same origin, authenticated)
268
+ const res = await fetch('https://studio.youtube.com/youtubei/v1/creator/get_creator_channels?key=AIzaSyBUPetSUmoZL-OhlxA7wSac5XinrGktHmQ', {
269
+ method: 'POST',
270
+ headers: { 'Content-Type': 'application/json' },
271
+ body: JSON.stringify({
272
+ context: {
273
+ client: { clientName: 62, clientVersion: '1.0' },
274
+ },
275
+ }),
276
+ });
277
+
278
+ if (res.ok) {
279
+ const data = await res.json();
280
+ const ch = data?.channels?.[0] || {};
281
+ return JSON.stringify({
282
+ subscribers: parseInt(ch.subscriberCount || '0', 10),
283
+ total_views: parseInt(ch.viewCount || '0', 10),
284
+ video_count: parseInt(ch.videoCount || '0', 10),
285
+ });
286
+ }
287
+ } catch (e) {
288
+ return JSON.stringify({ error: e.message });
289
+ }
290
+ return JSON.stringify({});
291
+ })()
292
+ `,
293
+ awaitPromise: true,
294
+ returnByValue: true,
295
+ });
296
+
297
+ try {
298
+ const apiStats = JSON.parse(apiResult?.result?.value || '{}');
299
+ if (!apiStats.error) {
300
+ // Merge: prefer API data if available
301
+ if (apiStats.subscribers > 0) stats.subscribers = apiStats.subscribers;
302
+ if (apiStats.total_views > 0) stats.total_views = apiStats.total_views;
303
+ if (apiStats.video_count > 0) stats.video_count = apiStats.video_count;
304
+ }
305
+ } catch {}
306
+
307
+ // Approach 3: Navigate to the public channel page as fallback
308
+ if (stats.subscribers === 0 && stats.total_views === 0) {
309
+ console.log('[stats] Studio scrape returned no data, trying public channel page...');
310
+ await sessionSend('Page.navigate', {
311
+ url: 'https://www.youtube.com/@me',
312
+ });
313
+ await this.waitForLoad(sessionSend, 10000);
314
+ await this.sleep(3000);
315
+
316
+ const publicResult = await sessionSend('Runtime.evaluate', {
317
+ expression: `
318
+ (function() {
319
+ const stats = { subscribers: 0, total_views: 0, video_count: 0 };
320
+
321
+ // ytInitialData contains channel info
322
+ try {
323
+ const data = window.ytInitialData;
324
+ const header = data?.header?.c4TabbedHeaderRenderer || data?.header?.pageHeaderRenderer;
325
+
326
+ // Subscriber count
327
+ const subText = header?.subscriberCountText?.simpleText || '';
328
+ const subMatch = subText.match(/([\\d,\\.]+[KMB]?)/);
329
+ if (subMatch) {
330
+ stats.subscribers = parseHumanNumber(subMatch[1]);
331
+ }
332
+
333
+ // Video count from tabs
334
+ const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
335
+ for (const tab of tabs) {
336
+ const title = tab?.tabRenderer?.title || '';
337
+ if (title === 'Videos' || title === 'Shorts') {
338
+ // Video tab might show count
339
+ }
340
+ }
341
+
342
+ // Try metadata
343
+ const meta = data?.metadata?.channelMetadataRenderer;
344
+ if (meta) {
345
+ // Some data available here
346
+ }
347
+ } catch {}
348
+
349
+ function parseHumanNumber(str) {
350
+ if (!str) return 0;
351
+ str = str.replace(/,/g, '').trim();
352
+ const mult = str.match(/([\\d\\.]+)\\s*([KMB])/i);
353
+ if (mult) {
354
+ const num = parseFloat(mult[1]);
355
+ const suffix = mult[2].toUpperCase();
356
+ if (suffix === 'K') return Math.round(num * 1000);
357
+ if (suffix === 'M') return Math.round(num * 1000000);
358
+ if (suffix === 'B') return Math.round(num * 1000000000);
359
+ }
360
+ return parseInt(str, 10) || 0;
361
+ }
362
+
363
+ return JSON.stringify(stats);
364
+ })()
365
+ `,
366
+ returnByValue: true,
367
+ });
368
+
369
+ try {
370
+ const publicStats = JSON.parse(publicResult?.result?.value || '{}');
371
+ if (publicStats.subscribers > 0) stats.subscribers = publicStats.subscribers;
372
+ if (publicStats.total_views > 0) stats.total_views = publicStats.total_views;
373
+ if (publicStats.video_count > 0) stats.video_count = publicStats.video_count;
374
+ } catch {}
375
+ }
376
+
377
+ return stats;
378
+ }
379
+
380
+ sleep(ms) {
381
+ return new Promise(resolve => setTimeout(resolve, ms));
382
+ }
383
+ }
384
+
385
+ module.exports = { StatsSyncer };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
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": {