channel-worker 1.4.0 → 1.5.1

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,405 @@
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
+ async syncYouTubeStats(channel) {
14
+ const profileId = channel.nst_profile_id;
15
+ if (!profileId) throw new Error(`Channel "${channel.name}" has no nst_profile_id`);
16
+
17
+ console.log(`[stats] Syncing YouTube stats for "${channel.name}" (profile: ${profileId})`);
18
+
19
+ const { wsEndpoint } = await this.nst.launchProfile(profileId, {
20
+ proxy: channel.proxy || null,
21
+ });
22
+
23
+ if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
24
+
25
+ try {
26
+ const stats = await this.scrapeViaCDP(wsEndpoint);
27
+ console.log(`[stats] "${channel.name}" → subs: ${stats.subscribers}, views: ${stats.total_views}, videos: ${stats.video_count}`);
28
+
29
+ await this.api.request('POST', '/analytics/sync', {
30
+ channel_id: channel._id,
31
+ platform: 'youtube',
32
+ subscribers: stats.subscribers,
33
+ total_views: stats.total_views,
34
+ video_count: stats.video_count,
35
+ });
36
+
37
+ console.log(`[stats] "${channel.name}" stats saved to API`);
38
+ return stats;
39
+ } finally {
40
+ try {
41
+ await this.nst.stopProfile(profileId);
42
+ } catch (err) {
43
+ console.warn(`[stats] Failed to stop profile: ${err.message}`);
44
+ }
45
+ }
46
+ }
47
+
48
+ scrapeViaCDP(wsEndpoint) {
49
+ return new Promise((resolve, reject) => {
50
+ const ws = new WebSocket(wsEndpoint);
51
+ let msgId = 1;
52
+ const pending = new Map();
53
+
54
+ const send = (method, params = {}, sid) => {
55
+ return new Promise((res, rej) => {
56
+ const id = msgId++;
57
+ pending.set(id, { resolve: res, reject: rej });
58
+ const msg = { id, method, params };
59
+ if (sid) msg.sessionId = sid;
60
+ ws.send(JSON.stringify(msg));
61
+ });
62
+ };
63
+
64
+ const timeout = setTimeout(() => {
65
+ ws.close();
66
+ reject(new Error('Stats scrape timed out (90s)'));
67
+ }, 90000);
68
+
69
+ ws.on('error', (err) => { clearTimeout(timeout); reject(err); });
70
+
71
+ ws.on('message', (data) => {
72
+ try {
73
+ const msg = JSON.parse(data);
74
+ if (msg.id && pending.has(msg.id)) {
75
+ const p = pending.get(msg.id);
76
+ pending.delete(msg.id);
77
+ if (msg.error) p.reject(new Error(msg.error.message));
78
+ else p.resolve(msg.result);
79
+ }
80
+ } catch {}
81
+ });
82
+
83
+ ws.on('open', async () => {
84
+ try {
85
+ // Get a page target
86
+ const targets = await send('Target.getTargets');
87
+ const pages = targets.targetInfos.filter(t => t.type === 'page');
88
+ let targetId = pages.length > 0 ? pages[0].targetId : (await send('Target.createTarget', { url: 'about:blank' })).targetId;
89
+
90
+ const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
91
+ const s = (method, params = {}) => send(method, params, sessionId);
92
+
93
+ await s('Page.enable');
94
+ await s('Runtime.enable');
95
+
96
+ // Step 1: Go to YouTube Studio — it auto-redirects to the correct channel
97
+ console.log('[stats] Navigating to YouTube Studio...');
98
+ await s('Page.navigate', { url: 'https://studio.youtube.com' });
99
+ await this.waitForLoad(s);
100
+ await this.sleep(6000); // SPA needs time to render
101
+
102
+ // Log current URL for debugging
103
+ const urlResult = await s('Runtime.evaluate', { expression: 'window.location.href', returnByValue: true });
104
+ console.log(`[stats] Current URL: ${urlResult?.result?.value}`);
105
+
106
+ // Step 2: Try to extract stats
107
+ let stats = { subscribers: 0, total_views: 0, video_count: 0 };
108
+
109
+ // Method A: Dump all visible text and parse (most reliable for YouTube Studio)
110
+ const dumpResult = await s('Runtime.evaluate', {
111
+ expression: `
112
+ (function() {
113
+ const result = { subscribers: 0, total_views: 0, video_count: 0, debug: '' };
114
+ const text = document.body?.innerText || '';
115
+ result.debug = text.substring(0, 500);
116
+
117
+ // YouTube Studio shows "X subscribers" in the dashboard
118
+ // Format varies: "123 subscribers", "1.2K subscribers", "0 subscribers"
119
+ const subPatterns = [
120
+ /Current subscribers\\n([\\d,\\.]+[KMB]?)/i,
121
+ /([\\d,\\.]+[KMB]?)\\s*subscriber/i,
122
+ /Subscribers?\\n([\\d,\\.]+[KMB]?)/i,
123
+ ];
124
+ for (const p of subPatterns) {
125
+ const m = text.match(p);
126
+ if (m) { result.subscribers = parseNum(m[1]); break; }
127
+ }
128
+
129
+ // Views pattern
130
+ const viewPatterns = [
131
+ /([\\d,\\.]+[KMB]?)\\s*views?\\b/i,
132
+ /Views?\\n([\\d,\\.]+[KMB]?)/i,
133
+ ];
134
+ for (const p of viewPatterns) {
135
+ const m = text.match(p);
136
+ if (m) { result.total_views = parseNum(m[1]); break; }
137
+ }
138
+
139
+ function parseNum(str) {
140
+ if (!str) return 0;
141
+ str = str.replace(/,/g, '').trim();
142
+ const m = str.match(/([\\d\\.]+)\\s*([KMB])/i);
143
+ if (m) {
144
+ const n = parseFloat(m[1]);
145
+ const s = m[2].toUpperCase();
146
+ if (s === 'K') return Math.round(n * 1000);
147
+ if (s === 'M') return Math.round(n * 1000000);
148
+ if (s === 'B') return Math.round(n * 1000000000);
149
+ }
150
+ return parseInt(str, 10) || 0;
151
+ }
152
+
153
+ return JSON.stringify(result);
154
+ })()
155
+ `,
156
+ returnByValue: true,
157
+ });
158
+
159
+ try {
160
+ const parsed = JSON.parse(dumpResult?.result?.value || '{}');
161
+ console.log(`[stats] Studio text dump (first 200): ${(parsed.debug || '').substring(0, 200)}`);
162
+ if (parsed.subscribers) stats.subscribers = parsed.subscribers;
163
+ if (parsed.total_views) stats.total_views = parsed.total_views;
164
+ } catch {}
165
+
166
+ // Method B: Use YouTube's internal youtubei API (works from studio.youtube.com origin)
167
+ console.log('[stats] Trying YouTube internal API...');
168
+ const apiResult = await s('Runtime.evaluate', {
169
+ expression: `
170
+ (async function() {
171
+ try {
172
+ // Get SAPISIDHASH for auth (YouTube uses this for internal API calls)
173
+ function getSapisidHash(origin) {
174
+ const cookies = document.cookie.split(';').reduce((acc, c) => {
175
+ const [k, v] = c.trim().split('=');
176
+ acc[k] = v;
177
+ return acc;
178
+ }, {});
179
+ const sapisid = cookies['SAPISID'] || cookies['__Secure-3PAPISID'];
180
+ if (!sapisid) return null;
181
+ const timestamp = Math.floor(Date.now() / 1000);
182
+ // Can't compute SHA1 in plain JS easily, but we can use the session cookies directly
183
+ return null;
184
+ }
185
+
186
+ // Method 1: Use the ytcfg data that Studio loads
187
+ if (window.ytcfg && window.ytcfg.get) {
188
+ const channelId = window.ytcfg.get('CHANNEL_ID');
189
+ if (channelId) {
190
+ // Try fetching channel data from YouTube Data API v3 (internal, no key needed from same origin)
191
+ const r = await fetch('https://www.youtube.com/youtubei/v1/browse?prettyPrint=false', {
192
+ method: 'POST',
193
+ headers: {
194
+ 'Content-Type': 'application/json',
195
+ 'X-Origin': 'https://www.youtube.com',
196
+ },
197
+ credentials: 'include',
198
+ body: JSON.stringify({
199
+ browseId: channelId,
200
+ context: {
201
+ client: {
202
+ clientName: 'WEB',
203
+ clientVersion: '2.20240101.00.00',
204
+ },
205
+ },
206
+ }),
207
+ });
208
+ if (r.ok) {
209
+ const d = await r.json();
210
+ // Extract from header
211
+ const header = d?.header?.c4TabbedHeaderRenderer || {};
212
+ const subText = header?.subscriberCountText?.simpleText || '';
213
+ const meta = d?.metadata?.channelMetadataRenderer || {};
214
+
215
+ return JSON.stringify({
216
+ source: 'youtubei_browse',
217
+ channelId,
218
+ subscribers_text: subText,
219
+ });
220
+ }
221
+ }
222
+ }
223
+
224
+ // Method 2: Parse ytcfg directly for channel info
225
+ if (window.ytcfg && window.ytcfg.data_) {
226
+ return JSON.stringify({
227
+ source: 'ytcfg',
228
+ keys: Object.keys(window.ytcfg.data_).filter(k =>
229
+ k.toLowerCase().includes('channel') || k.toLowerCase().includes('subscriber')
230
+ ),
231
+ });
232
+ }
233
+
234
+ return JSON.stringify({ source: 'none' });
235
+ } catch (e) {
236
+ return JSON.stringify({ error: e.message });
237
+ }
238
+ })()
239
+ `,
240
+ awaitPromise: true,
241
+ returnByValue: true,
242
+ });
243
+
244
+ try {
245
+ const apiData = JSON.parse(apiResult?.result?.value || '{}');
246
+ console.log(`[stats] Internal API result: ${JSON.stringify(apiData).substring(0, 300)}`);
247
+ } catch {}
248
+
249
+ // Method C: Navigate to channel page to get video count via ytInitialData
250
+ if (stats.video_count === 0) {
251
+ console.log('[stats] Getting video count from channel page...');
252
+
253
+ // Get the channel URL from Studio
254
+ const channelUrlResult = await s('Runtime.evaluate', {
255
+ expression: `
256
+ (function() {
257
+ // Try to find channel URL/ID from Studio page
258
+ const url = window.location.href;
259
+ const m = url.match(/channel\\/(UC[\\w-]+)/);
260
+ if (m) return 'https://www.youtube.com/channel/' + m[1] + '/videos';
261
+
262
+ // Try ytcfg
263
+ if (window.ytcfg && window.ytcfg.get) {
264
+ const cid = window.ytcfg.get('CHANNEL_ID');
265
+ if (cid) return 'https://www.youtube.com/channel/' + cid + '/videos';
266
+ }
267
+
268
+ // Fallback: go to own channel
269
+ return 'https://www.youtube.com/@me';
270
+ })()
271
+ `,
272
+ returnByValue: true,
273
+ });
274
+
275
+ const channelUrl = channelUrlResult?.result?.value;
276
+ if (channelUrl) {
277
+ console.log(`[stats] Navigating to: ${channelUrl}`);
278
+ await s('Page.navigate', { url: channelUrl });
279
+ await this.waitForLoad(s);
280
+ await this.sleep(4000);
281
+
282
+ const ytResult = await s('Runtime.evaluate', {
283
+ expression: `
284
+ (function() {
285
+ const result = { subscribers: 0, total_views: 0, video_count: 0 };
286
+ try {
287
+ const data = window.ytInitialData;
288
+ if (!data) return JSON.stringify({ error: 'no ytInitialData' });
289
+
290
+ // Subscriber count from header
291
+ const header = data?.header?.c4TabbedHeaderRenderer
292
+ || data?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel;
293
+ if (header) {
294
+ // c4TabbedHeaderRenderer path
295
+ const subText = header?.subscriberCountText?.simpleText || '';
296
+ if (subText) {
297
+ const m = subText.match(/([\\d,\\.]+[KMB]?)/);
298
+ if (m) result.subscribers = parseNum(m[1]);
299
+ }
300
+ // pageHeaderViewModel path
301
+ const metadata = header?.metadata?.contentMetadataViewModel?.metadataRows;
302
+ if (metadata) {
303
+ for (const row of metadata) {
304
+ for (const part of (row?.metadataParts || [])) {
305
+ const t = part?.text?.content || '';
306
+ if (t.includes('subscriber')) {
307
+ const m = t.match(/([\\d,\\.]+[KMB]?)/);
308
+ if (m) result.subscribers = parseNum(m[1]);
309
+ }
310
+ if (t.includes('video')) {
311
+ const m = t.match(/([\\d,\\.]+)/);
312
+ if (m) result.video_count = parseInt(m[1].replace(/,/g, ''), 10);
313
+ }
314
+ }
315
+ }
316
+ }
317
+ }
318
+
319
+ // Video count from tabs
320
+ const tabs = data?.contents?.twoColumnBrowseResultsRenderer?.tabs || [];
321
+ for (const tab of tabs) {
322
+ const tabData = tab?.tabRenderer;
323
+ if (tabData?.title === 'Videos') {
324
+ // Sometimes the tab content has total count
325
+ const content = tabData?.content;
326
+ const grid = content?.richGridRenderer || content?.sectionListRenderer;
327
+ // Check for video items count
328
+ }
329
+ }
330
+
331
+ // Total views from about tab data or metadata
332
+ const aboutData = data?.metadata?.channelMetadataRenderer;
333
+ if (aboutData) {
334
+ // metadata doesn't have view count directly, but try
335
+ }
336
+
337
+ // Try to get view count from about page data
338
+ const microformat = data?.microformat?.microformatDataRenderer;
339
+ if (microformat) {
340
+ // Some data here
341
+ }
342
+
343
+ } catch (e) {
344
+ return JSON.stringify({ error: e.message });
345
+ }
346
+
347
+ function parseNum(str) {
348
+ if (!str) return 0;
349
+ str = str.replace(/,/g, '').trim();
350
+ const m = str.match(/([\\d\\.]+)\\s*([KMB])/i);
351
+ if (m) {
352
+ const n = parseFloat(m[1]);
353
+ const s = m[2].toUpperCase();
354
+ if (s === 'K') return Math.round(n * 1000);
355
+ if (s === 'M') return Math.round(n * 1000000);
356
+ if (s === 'B') return Math.round(n * 1000000000);
357
+ }
358
+ return parseInt(str, 10) || 0;
359
+ }
360
+
361
+ return JSON.stringify(result);
362
+ })()
363
+ `,
364
+ returnByValue: true,
365
+ });
366
+
367
+ try {
368
+ const ytData = JSON.parse(ytResult?.result?.value || '{}');
369
+ console.log(`[stats] ytInitialData result: ${JSON.stringify(ytData)}`);
370
+ if (ytData.subscribers > 0 && stats.subscribers === 0) stats.subscribers = ytData.subscribers;
371
+ if (ytData.video_count > 0) stats.video_count = ytData.video_count;
372
+ if (ytData.total_views > 0 && stats.total_views === 0) stats.total_views = ytData.total_views;
373
+ } catch {}
374
+ }
375
+ }
376
+
377
+ clearTimeout(timeout);
378
+ ws.close();
379
+ resolve(stats);
380
+ } catch (err) {
381
+ clearTimeout(timeout);
382
+ ws.close();
383
+ reject(err);
384
+ }
385
+ });
386
+ });
387
+ }
388
+
389
+ async waitForLoad(s, timeoutMs = 15000) {
390
+ const start = Date.now();
391
+ while (Date.now() - start < timeoutMs) {
392
+ try {
393
+ const r = await s('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true });
394
+ if (r?.result?.value === 'complete') return;
395
+ } catch {}
396
+ await this.sleep(500);
397
+ }
398
+ }
399
+
400
+ sleep(ms) {
401
+ return new Promise(resolve => setTimeout(resolve, ms));
402
+ }
403
+ }
404
+
405
+ 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.1",
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": {