channel-worker 1.5.1 → 1.5.2

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 +185 -251
  2. package/package.json +1 -1
@@ -82,10 +82,9 @@ class StatsSyncer {
82
82
 
83
83
  ws.on('open', async () => {
84
84
  try {
85
- // Get a page target
86
85
  const targets = await send('Target.getTargets');
87
86
  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;
87
+ const targetId = pages.length > 0 ? pages[0].targetId : (await send('Target.createTarget', { url: 'about:blank' })).targetId;
89
88
 
90
89
  const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
91
90
  const s = (method, params = {}) => send(method, params, sessionId);
@@ -93,286 +92,221 @@ class StatsSyncer {
93
92
  await s('Page.enable');
94
93
  await s('Runtime.enable');
95
94
 
96
- // Step 1: Go to YouTube Studio it auto-redirects to the correct channel
95
+ let stats = { subscribers: 0, total_views: 0, video_count: 0 };
96
+
97
+ // Step 1: Go to YouTube Studio → auto-redirects to correct channel
97
98
  console.log('[stats] Navigating to YouTube Studio...');
98
99
  await s('Page.navigate', { url: 'https://studio.youtube.com' });
99
100
  await this.waitForLoad(s);
100
- await this.sleep(6000); // SPA needs time to render
101
+ await this.sleep(5000);
101
102
 
102
- // Log current URL for debugging
103
+ // Get channel ID from URL
103
104
  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
- }
105
+ const studioUrl = urlResult?.result?.value || '';
106
+ console.log(`[stats] Studio URL: ${studioUrl}`);
128
107
 
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
- }
108
+ const channelIdMatch = studioUrl.match(/channel\/(UC[\w-]+)/);
109
+ const channelId = channelIdMatch ? channelIdMatch[1] : null;
138
110
 
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
- }
111
+ if (!channelId) {
112
+ // Try ytcfg
113
+ const cfgResult = await s('Runtime.evaluate', {
114
+ expression: '(window.ytcfg && window.ytcfg.get) ? window.ytcfg.get("CHANNEL_ID") : ""',
115
+ returnByValue: true,
116
+ });
117
+ console.log(`[stats] ytcfg CHANNEL_ID: ${cfgResult?.result?.value}`);
118
+ }
152
119
 
153
- return JSON.stringify(result);
154
- })()
155
- `,
120
+ const cid = channelId || (await s('Runtime.evaluate', {
121
+ expression: '(window.ytcfg && window.ytcfg.get) ? window.ytcfg.get("CHANNEL_ID") : ""',
156
122
  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
- }
123
+ }))?.result?.value;
124
+
125
+ // Step 2: Navigate to Studio Analytics page (English URL works regardless of UI language)
126
+ if (cid) {
127
+ const analyticsUrl = `https://studio.youtube.com/channel/${cid}/analytics/tab-overview/period-default`;
128
+ console.log(`[stats] Navigating to analytics: ${analyticsUrl}`);
129
+ await s('Page.navigate', { url: analyticsUrl });
130
+ await this.waitForLoad(s);
131
+ await this.sleep(6000);
132
+
133
+ // Scrape analytics page — look for numbers regardless of language
134
+ const analyticsResult = await s('Runtime.evaluate', {
135
+ expression: `
136
+ (function() {
137
+ var result = { subscribers: 0, total_views: 0, video_count: 0, debug_text: '' };
138
+ var text = document.body ? document.body.innerText : '';
139
+ result.debug_text = text.substring(0, 800);
140
+
141
+ // YouTube Studio Analytics shows metric cards with labels and numbers
142
+ // The layout is: Label (in any language) followed by a number
143
+ // We look for large numbers near subscriber/view keywords in ANY language
144
+
145
+ // Strategy: find all metric-like elements by looking for number patterns
146
+ // Studio analytics typically shows: Views, Watch time, Subscribers
147
+ // in cards with the number prominently displayed
148
+
149
+ // Try to find subscriber-related numbers
150
+ // Multi-language patterns: subscribers, người đăng ký, abonnés, Abonnenten, etc.
151
+ var subKeywords = /subscri|đăng ký|abonn|подписч|구독/i;
152
+ var viewKeywords = /views?|lượt xem|vues?|aufrufe|просмотр|조회/i;
153
+
154
+ var lines = text.split('\\n').map(function(l) { return l.trim(); }).filter(function(l) { return l.length > 0; });
155
+
156
+ for (var i = 0; i < lines.length; i++) {
157
+ var line = lines[i];
158
+ var nextLine = lines[i + 1] || '';
159
+ var prevLine = lines[i - 1] || '';
160
+
161
+ // Check if this line or adjacent lines contain keywords
162
+ if (subKeywords.test(line) || subKeywords.test(prevLine)) {
163
+ // Look for a number in this line or nearby
164
+ var numInLine = extractNumber(line);
165
+ var numInNext = extractNumber(nextLine);
166
+ if (numInLine > 0 && result.subscribers === 0) result.subscribers = numInLine;
167
+ else if (numInNext > 0 && result.subscribers === 0) result.subscribers = numInNext;
168
+ }
185
169
 
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
- }
170
+ if (viewKeywords.test(line) || viewKeywords.test(prevLine)) {
171
+ var numInLine2 = extractNumber(line);
172
+ var numInNext2 = extractNumber(nextLine);
173
+ if (numInLine2 > 0 && result.total_views === 0) result.total_views = numInLine2;
174
+ else if (numInNext2 > 0 && result.total_views === 0) result.total_views = numInNext2;
221
175
  }
222
176
  }
223
177
 
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
- });
178
+ // Also try: Current subscribers count shown in Studio sidebar
179
+ // Pattern: "Current subscribers\\n123" or "Người đăng ký hiện tại\\n123"
180
+ var currentSubMatch = text.match(/(?:current subscribers|người đăng ký hiện tại|subscriber|đăng ký)[\\s\\n]*([\\d,\\.]+[KMB]?)/i);
181
+ if (currentSubMatch && result.subscribers === 0) {
182
+ result.subscribers = parseHumanNum(currentSubMatch[1]);
232
183
  }
233
184
 
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...');
185
+ function extractNumber(str) {
186
+ if (!str) return 0;
187
+ // Match standalone numbers like "1,234" or "5.2K" or "123"
188
+ var m = str.match(/^([\\d,\\.]+[KMB]?)$/i) || str.match(/\\b([\\d,\\.]+[KMB]?)\\b/);
189
+ if (m) return parseHumanNum(m[1]);
190
+ return 0;
191
+ }
252
192
 
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';
193
+ function parseHumanNum(str) {
194
+ if (!str) return 0;
195
+ str = str.replace(/,/g, '').trim();
196
+ var m = str.match(/([\\d\\.]+)\\s*([KMB])/i);
197
+ if (m) {
198
+ var n = parseFloat(m[1]);
199
+ var s = m[2].toUpperCase();
200
+ if (s === 'K') return Math.round(n * 1000);
201
+ if (s === 'M') return Math.round(n * 1000000);
202
+ if (s === 'B') return Math.round(n * 1000000000);
203
+ }
204
+ return parseInt(str, 10) || 0;
266
205
  }
267
206
 
268
- // Fallback: go to own channel
269
- return 'https://www.youtube.com/@me';
207
+ return JSON.stringify(result);
270
208
  })()
271
209
  `,
272
210
  returnByValue: true,
273
211
  });
274
212
 
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
- }
213
+ try {
214
+ var parsed = JSON.parse(analyticsResult?.result?.value || '{}');
215
+ console.log('[stats] Analytics debug text (first 300):', (parsed.debug_text || '').substring(0, 300));
216
+ if (parsed.subscribers > 0) stats.subscribers = parsed.subscribers;
217
+ if (parsed.total_views > 0) stats.total_views = parsed.total_views;
218
+ } catch {}
219
+ }
318
220
 
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
- }
221
+ // Step 3: Get video count + subscriber from channel page via ytInitialData
222
+ var channelPageUrl = cid
223
+ ? 'https://www.youtube.com/channel/' + cid
224
+ : 'https://www.youtube.com/@me';
330
225
 
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
- }
226
+ console.log('[stats] Navigating to channel page:', channelPageUrl);
227
+ await s('Page.navigate', { url: channelPageUrl });
228
+ await this.waitForLoad(s);
229
+ await this.sleep(4000);
336
230
 
337
- // Try to get view count from about page data
338
- const microformat = data?.microformat?.microformatDataRenderer;
339
- if (microformat) {
340
- // Some data here
231
+ var ytResult = await s('Runtime.evaluate', {
232
+ expression: `
233
+ (function() {
234
+ var result = { subscribers: 0, total_views: 0, video_count: 0 };
235
+ try {
236
+ var data = window.ytInitialData;
237
+ if (!data) return JSON.stringify({ error: 'no ytInitialData' });
238
+
239
+ // Try pageHeaderRenderer (new layout)
240
+ var pageHeader = data.header && data.header.pageHeaderRenderer;
241
+ if (pageHeader) {
242
+ var content = pageHeader.content && pageHeader.content.pageHeaderViewModel;
243
+ if (content && content.metadata && content.metadata.contentMetadataViewModel) {
244
+ var rows = content.metadata.contentMetadataViewModel.metadataRows || [];
245
+ for (var r = 0; r < rows.length; r++) {
246
+ var parts = rows[r].metadataParts || [];
247
+ for (var p = 0; p < parts.length; p++) {
248
+ var t = (parts[p].text && parts[p].text.content) || '';
249
+ // subscriber text like "123 subscribers" or "0 người đăng ký"
250
+ if (/subscri|đăng ký/i.test(t)) {
251
+ var m = t.match(/([\\d,\\.]+[KMB]?)/);
252
+ if (m) result.subscribers = parseN(m[1]);
253
+ }
254
+ // video count like "1 video" or "15 video"
255
+ if (/video/i.test(t)) {
256
+ var m2 = t.match(/([\\d,]+)/);
257
+ if (m2) result.video_count = parseInt(m2[1].replace(/,/g, ''), 10);
258
+ }
259
+ }
341
260
  }
342
-
343
- } catch (e) {
344
- return JSON.stringify({ error: e.message });
345
261
  }
262
+ }
346
263
 
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;
264
+ // Try c4TabbedHeaderRenderer (old layout)
265
+ var c4Header = data.header && data.header.c4TabbedHeaderRenderer;
266
+ if (c4Header) {
267
+ var subText = c4Header.subscriberCountText && c4Header.subscriberCountText.simpleText || '';
268
+ if (subText) {
269
+ var m3 = subText.match(/([\\d,\\.]+[KMB]?)/);
270
+ if (m3 && result.subscribers === 0) result.subscribers = parseN(m3[1]);
271
+ }
272
+ if (c4Header.videosCountText) {
273
+ var vText = c4Header.videosCountText.runs ? c4Header.videosCountText.runs.map(function(r){return r.text}).join('') : (c4Header.videosCountText.simpleText || '');
274
+ var m4 = vText.match(/([\\d,]+)/);
275
+ if (m4) result.video_count = parseInt(m4[1].replace(/,/g, ''), 10);
359
276
  }
277
+ }
360
278
 
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
- }
279
+ } catch (e) {
280
+ return JSON.stringify({ error: e.message });
281
+ }
282
+
283
+ function parseN(str) {
284
+ if (!str) return 0;
285
+ str = str.replace(/,/g, '').trim();
286
+ var m = str.match(/([\\d\\.]+)\\s*([KMB])/i);
287
+ if (m) {
288
+ var n = parseFloat(m[1]);
289
+ var s = m[2].toUpperCase();
290
+ if (s === 'K') return Math.round(n * 1000);
291
+ if (s === 'M') return Math.round(n * 1000000);
292
+ if (s === 'B') return Math.round(n * 1000000000);
293
+ }
294
+ return parseInt(str, 10) || 0;
295
+ }
296
+
297
+ return JSON.stringify(result);
298
+ })()
299
+ `,
300
+ returnByValue: true,
301
+ });
302
+
303
+ try {
304
+ var ytData = JSON.parse(ytResult?.result?.value || '{}');
305
+ console.log('[stats] ytInitialData result:', JSON.stringify(ytData));
306
+ if (ytData.subscribers > 0 && stats.subscribers === 0) stats.subscribers = ytData.subscribers;
307
+ if (ytData.video_count > 0) stats.video_count = ytData.video_count;
308
+ if (ytData.total_views > 0 && stats.total_views === 0) stats.total_views = ytData.total_views;
309
+ } catch {}
376
310
 
377
311
  clearTimeout(timeout);
378
312
  ws.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "1.5.1",
3
+ "version": "1.5.2",
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": {