channel-worker 1.6.0 → 1.6.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.
@@ -899,8 +899,8 @@ class CommandPoller {
899
899
  }
900
900
 
901
901
  async handleSyncYoutubeStats(command) {
902
- const { channel_id } = command.payload || {};
903
- console.log(`[commands] Syncing YouTube stats for channel: ${channel_id}`);
902
+ const { channel_id, youtube_handle } = command.payload || {};
903
+ console.log(`[commands] Syncing YouTube stats for channel: ${channel_id} (handle: ${youtube_handle || 'none'})`);
904
904
 
905
905
  try {
906
906
  if (!this.nst) {
@@ -917,6 +917,9 @@ class CommandPoller {
917
917
  if (!channel) throw new Error(`Channel ${channel_id} not found`);
918
918
  if (!channel.nst_profile_id) throw new Error(`Channel "${channel.name}" has no Nstbrowser profile`);
919
919
 
920
+ // Pass youtube_handle from command payload
921
+ channel.youtube_handle = youtube_handle || null;
922
+
920
923
  const syncer = new StatsSyncer(this.nst, this.api);
921
924
  const stats = await syncer.syncYouTubeStats(channel);
922
925
 
@@ -1,8 +1,8 @@
1
1
  const WebSocket = require('ws');
2
2
 
3
3
  /**
4
- * Sync YouTube Studio stats via Nstbrowser CDP.
5
- * Flow: Launch profile → connect CDP → navigate to Studioscrape stats → close
4
+ * Sync YouTube stats via Nstbrowser CDP.
5
+ * Flow: Launch profile → navigate to youtube.com/@handle/aboutread stats table
6
6
  */
7
7
  class StatsSyncer {
8
8
  constructor(nstManager, apiClient) {
@@ -22,8 +22,10 @@ class StatsSyncer {
22
22
 
23
23
  if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
24
24
 
25
+ const youtubeHandle = channel.youtube_handle;
26
+
25
27
  try {
26
- const stats = await this.scrapeViaCDP(wsEndpoint);
28
+ const stats = await this.scrapeViaCDP(wsEndpoint, youtubeHandle);
27
29
  console.log(`[stats] "${channel.name}" → subs: ${stats.subscribers}, views: ${stats.total_views}, videos: ${stats.video_count}`);
28
30
 
29
31
  await this.api.request('POST', '/analytics/sync', {
@@ -45,7 +47,7 @@ class StatsSyncer {
45
47
  }
46
48
  }
47
49
 
48
- scrapeViaCDP(wsEndpoint) {
50
+ scrapeViaCDP(wsEndpoint, youtubeHandle) {
49
51
  return new Promise((resolve, reject) => {
50
52
  const ws = new WebSocket(wsEndpoint);
51
53
  let msgId = 1;
@@ -92,221 +94,105 @@ class StatsSyncer {
92
94
  await s('Page.enable');
93
95
  await s('Runtime.enable');
94
96
 
95
- let stats = { subscribers: 0, total_views: 0, video_count: 0 };
96
-
97
- // Step 1: Go to YouTube Studio → auto-redirects to correct channel
98
- console.log('[stats] Navigating to YouTube Studio...');
99
- await s('Page.navigate', { url: 'https://studio.youtube.com' });
100
- await this.waitForLoad(s);
101
- await this.sleep(5000);
102
-
103
- // Get channel ID from URL
104
- const urlResult = await s('Runtime.evaluate', { expression: 'window.location.href', returnByValue: true });
105
- const studioUrl = urlResult?.result?.value || '';
106
- console.log(`[stats] Studio URL: ${studioUrl}`);
107
-
108
- const channelIdMatch = studioUrl.match(/channel\/(UC[\w-]+)/);
109
- const channelId = channelIdMatch ? channelIdMatch[1] : null;
110
-
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
- }
119
-
120
- const cid = channelId || (await s('Runtime.evaluate', {
121
- expression: '(window.ytcfg && window.ytcfg.get) ? window.ytcfg.get("CHANNEL_ID") : ""',
122
- returnByValue: true,
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 });
97
+ // Determine the about URL
98
+ let aboutUrl;
99
+ if (youtubeHandle) {
100
+ // Direct: youtube.com/@handle/about
101
+ const handle = youtubeHandle.startsWith('@') ? youtubeHandle : '@' + youtubeHandle;
102
+ aboutUrl = `https://www.youtube.com/${handle}/about`;
103
+ } else {
104
+ // Fallback: go to Studio first to get channel ID
105
+ console.log('[stats] No youtube_handle, falling back to Studio...');
106
+ await s('Page.navigate', { url: 'https://studio.youtube.com' });
130
107
  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
- }
169
-
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;
175
- }
176
- }
177
-
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]);
183
- }
184
-
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
- }
192
-
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;
205
- }
206
-
207
- return JSON.stringify(result);
208
- })()
209
- `,
210
- returnByValue: true,
211
- });
212
-
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 {}
108
+ await this.sleep(4000);
109
+
110
+ const urlResult = await s('Runtime.evaluate', { expression: 'window.location.href', returnByValue: true });
111
+ const studioUrl = urlResult?.result?.value || '';
112
+ const cidMatch = studioUrl.match(/channel\/(UC[\w-]+)/);
113
+ let cid = cidMatch ? cidMatch[1] : null;
114
+
115
+ if (!cid) {
116
+ const cfgResult = await s('Runtime.evaluate', {
117
+ expression: '(window.ytcfg && window.ytcfg.get) ? window.ytcfg.get("CHANNEL_ID") : ""',
118
+ returnByValue: true,
119
+ });
120
+ cid = cfgResult?.result?.value || null;
121
+ }
122
+
123
+ if (!cid) throw new Error('Could not find channel ID from YouTube Studio');
124
+ aboutUrl = `https://www.youtube.com/channel/${cid}/about`;
219
125
  }
220
126
 
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';
225
-
226
- console.log('[stats] Navigating to channel page:', channelPageUrl);
227
- await s('Page.navigate', { url: channelPageUrl });
127
+ // Navigate directly to about page
128
+ console.log(`[stats] Navigating to: ${aboutUrl}`);
129
+ await s('Page.navigate', { url: aboutUrl });
228
130
  await this.waitForLoad(s);
229
131
  await this.sleep(4000);
230
132
 
231
- var ytResult = await s('Runtime.evaluate', {
133
+ // Read #additional-info-container table
134
+ const result = await s('Runtime.evaluate', {
232
135
  expression: `
233
136
  (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
- }
260
- }
261
- }
262
- }
137
+ var stats = { subscribers: -1, total_views: -1, video_count: -1, debug: '' };
263
138
 
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);
276
- }
277
- }
139
+ var container = document.querySelector('#additional-info-container');
140
+ if (!container) {
141
+ stats.debug = 'no #additional-info-container';
142
+ return JSON.stringify(stats);
143
+ }
278
144
 
279
- } catch (e) {
280
- return JSON.stringify({ error: e.message });
145
+ var table = container.querySelector('table');
146
+ if (!table) {
147
+ stats.debug = 'no table, text: ' + container.innerText.substring(0, 300);
148
+ return JSON.stringify(stats);
281
149
  }
282
150
 
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);
151
+ var rows = table.querySelectorAll('tr');
152
+ var texts = [];
153
+ rows.forEach(function(row) {
154
+ var text = row.innerText.trim();
155
+ texts.push(text);
156
+
157
+ var num = 0;
158
+ var suffixMatch = text.match(/([\\d,\\.]+)\\s*([KMB])/i);
159
+ if (suffixMatch) {
160
+ var n = parseFloat(suffixMatch[1].replace(/,/g, ''));
161
+ var sx = suffixMatch[2].toUpperCase();
162
+ if (sx === 'K') num = Math.round(n * 1000);
163
+ else if (sx === 'M') num = Math.round(n * 1000000);
164
+ else if (sx === 'B') num = Math.round(n * 1000000000);
165
+ } else {
166
+ var numMatch = text.match(/([\\d,]+)/);
167
+ if (numMatch) num = parseInt(numMatch[1].replace(/,/g, ''), 10);
293
168
  }
294
- return parseInt(str, 10) || 0;
295
- }
296
169
 
297
- return JSON.stringify(result);
170
+ if (/subscriber|đăng ký/i.test(text)) {
171
+ stats.subscribers = num;
172
+ } else if (/\\bvideos?\\b/i.test(text)) {
173
+ stats.video_count = num;
174
+ } else if (/\\bviews?\\b|lượt xem/i.test(text)) {
175
+ stats.total_views = num;
176
+ }
177
+ });
178
+
179
+ stats.debug = texts.join(' | ');
180
+ return JSON.stringify(stats);
298
181
  })()
299
182
  `,
300
183
  returnByValue: true,
301
184
  });
302
185
 
186
+ var stats = { subscribers: 0, total_views: 0, video_count: 0 };
303
187
  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 {}
188
+ var parsed = JSON.parse(result?.result?.value || '{}');
189
+ console.log('[stats] About page data:', parsed.debug);
190
+ if (parsed.subscribers >= 0) stats.subscribers = parsed.subscribers;
191
+ if (parsed.total_views >= 0) stats.total_views = parsed.total_views;
192
+ if (parsed.video_count >= 0) stats.video_count = parsed.video_count;
193
+ } catch (e) {
194
+ console.error('[stats] Parse error:', e.message);
195
+ }
310
196
 
311
197
  clearTimeout(timeout);
312
198
  ws.close();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "1.6.0",
3
+ "version": "1.6.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": {