channel-worker 1.5.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.
Files changed (2) hide show
  1. package/lib/stats-syncer.js +292 -272
  2. package/package.json +1 -1
@@ -10,18 +10,12 @@ class StatsSyncer {
10
10
  this.api = apiClient;
11
11
  }
12
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
13
  async syncYouTubeStats(channel) {
19
14
  const profileId = channel.nst_profile_id;
20
15
  if (!profileId) throw new Error(`Channel "${channel.name}" has no nst_profile_id`);
21
16
 
22
17
  console.log(`[stats] Syncing YouTube stats for "${channel.name}" (profile: ${profileId})`);
23
18
 
24
- // Launch profile (no extension needed for stats)
25
19
  const { wsEndpoint } = await this.nst.launchProfile(profileId, {
26
20
  proxy: channel.proxy || null,
27
21
  });
@@ -29,10 +23,9 @@ class StatsSyncer {
29
23
  if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
30
24
 
31
25
  try {
32
- const stats = await this.scrapeYouTubeStudio(wsEndpoint);
26
+ const stats = await this.scrapeViaCDP(wsEndpoint);
33
27
  console.log(`[stats] "${channel.name}" → subs: ${stats.subscribers}, views: ${stats.total_views}, videos: ${stats.video_count}`);
34
28
 
35
- // Send to API
36
29
  await this.api.request('POST', '/analytics/sync', {
37
30
  channel_id: channel._id,
38
31
  platform: 'youtube',
@@ -44,7 +37,6 @@ class StatsSyncer {
44
37
  console.log(`[stats] "${channel.name}" stats saved to API`);
45
38
  return stats;
46
39
  } finally {
47
- // Close browser after scraping
48
40
  try {
49
41
  await this.nst.stopProfile(profileId);
50
42
  } catch (err) {
@@ -53,32 +45,28 @@ class StatsSyncer {
53
45
  }
54
46
  }
55
47
 
56
- /**
57
- * Connect to browser via CDP WebSocket and scrape YouTube Studio
58
- */
59
- async scrapeYouTubeStudio(wsEndpoint) {
48
+ scrapeViaCDP(wsEndpoint) {
60
49
  return new Promise((resolve, reject) => {
61
50
  const ws = new WebSocket(wsEndpoint);
62
51
  let msgId = 1;
63
52
  const pending = new Map();
64
53
 
65
- const send = (method, params = {}) => {
54
+ const send = (method, params = {}, sid) => {
66
55
  return new Promise((res, rej) => {
67
56
  const id = msgId++;
68
57
  pending.set(id, { resolve: res, reject: rej });
69
- ws.send(JSON.stringify({ id, method, params }));
58
+ const msg = { id, method, params };
59
+ if (sid) msg.sessionId = sid;
60
+ ws.send(JSON.stringify(msg));
70
61
  });
71
62
  };
72
63
 
73
64
  const timeout = setTimeout(() => {
74
65
  ws.close();
75
- reject(new Error('YouTube Studio scrape timed out (60s)'));
76
- }, 60000);
66
+ reject(new Error('Stats scrape timed out (90s)'));
67
+ }, 90000);
77
68
 
78
- ws.on('error', (err) => {
79
- clearTimeout(timeout);
80
- reject(err);
81
- });
69
+ ws.on('error', (err) => { clearTimeout(timeout); reject(err); });
82
70
 
83
71
  ws.on('message', (data) => {
84
72
  try {
@@ -94,50 +82,297 @@ class StatsSyncer {
94
82
 
95
83
  ws.on('open', async () => {
96
84
  try {
97
- // Get list of targets (pages)
85
+ // Get a page target
98
86
  const targets = await send('Target.getTargets');
99
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
+ }
100
128
 
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
- }
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
+ }
109
138
 
110
- // Attach to target
111
- const { sessionId } = await send('Target.attachToTarget', {
112
- targetId,
113
- flatten: true,
114
- });
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
+ }
115
152
 
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
- };
153
+ return JSON.stringify(result);
154
+ })()
155
+ `,
156
+ returnByValue: true,
157
+ });
124
158
 
125
- // Enable page events
126
- await sessionSend('Page.enable');
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 {}
127
165
 
128
- // Navigate to YouTube Studio dashboard
129
- await sessionSend('Page.navigate', {
130
- url: 'https://studio.youtube.com',
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,
131
242
  });
132
243
 
133
- // Wait for page to load
134
- await this.waitForLoad(sessionSend, 15000);
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 {}
135
248
 
136
- // Wait a bit for Studio SPA to render
137
- await this.sleep(5000);
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
+ });
138
274
 
139
- // Try to scrape stats from YouTube Studio dashboard
140
- const stats = await this.extractStudioStats(sessionSend);
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
+ }
141
376
 
142
377
  clearTimeout(timeout);
143
378
  ws.close();
@@ -151,232 +386,17 @@ class StatsSyncer {
151
386
  });
152
387
  }
153
388
 
154
- async waitForLoad(sessionSend, timeoutMs = 15000) {
389
+ async waitForLoad(s, timeoutMs = 15000) {
155
390
  const start = Date.now();
156
391
  while (Date.now() - start < timeoutMs) {
157
392
  try {
158
- const result = await sessionSend('Runtime.evaluate', {
159
- expression: 'document.readyState',
160
- returnByValue: true,
161
- });
162
- if (result?.result?.value === 'complete') return;
393
+ const r = await s('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true });
394
+ if (r?.result?.value === 'complete') return;
163
395
  } catch {}
164
396
  await this.sleep(500);
165
397
  }
166
398
  }
167
399
 
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
400
  sleep(ms) {
381
401
  return new Promise(resolve => setTimeout(resolve, ms));
382
402
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "1.5.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": {