channel-worker 1.6.1 → 1.6.3

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
 
@@ -2,8 +2,7 @@ const WebSocket = require('ws');
2
2
 
3
3
  /**
4
4
  * Sync YouTube stats via Nstbrowser CDP.
5
- * Flow: Launch profile → connect CDPget channel ID from Studio →
6
- * navigate to channel About page → read #additional-info-container table
5
+ * Flow: Launch profile → navigate to youtube.com/@handle/about read stats table
7
6
  */
8
7
  class StatsSyncer {
9
8
  constructor(nstManager, apiClient) {
@@ -23,8 +22,10 @@ class StatsSyncer {
23
22
 
24
23
  if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
25
24
 
25
+ const youtubeHandle = channel.youtube_handle;
26
+
26
27
  try {
27
- const stats = await this.scrapeViaCDP(wsEndpoint);
28
+ const stats = await this.scrapeViaCDP(wsEndpoint, youtubeHandle);
28
29
  console.log(`[stats] "${channel.name}" → subs: ${stats.subscribers}, views: ${stats.total_views}, videos: ${stats.video_count}`);
29
30
 
30
31
  await this.api.request('POST', '/analytics/sync', {
@@ -33,6 +34,7 @@ class StatsSyncer {
33
34
  subscribers: stats.subscribers,
34
35
  total_views: stats.total_views,
35
36
  video_count: stats.video_count,
37
+ avatar_url: stats.avatar_url,
36
38
  });
37
39
 
38
40
  console.log(`[stats] "${channel.name}" stats saved to API`);
@@ -46,7 +48,7 @@ class StatsSyncer {
46
48
  }
47
49
  }
48
50
 
49
- scrapeViaCDP(wsEndpoint) {
51
+ scrapeViaCDP(wsEndpoint, youtubeHandle) {
50
52
  return new Promise((resolve, reject) => {
51
53
  const ws = new WebSocket(wsEndpoint);
52
54
  let msgId = 1;
@@ -93,86 +95,77 @@ class StatsSyncer {
93
95
  await s('Page.enable');
94
96
  await s('Runtime.enable');
95
97
 
96
- // Step 1: Go to YouTube Studio to get channel ID
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(4000);
101
-
102
- const urlResult = await s('Runtime.evaluate', { expression: 'window.location.href', returnByValue: true });
103
- const studioUrl = urlResult?.result?.value || '';
104
- console.log(`[stats] Studio URL: ${studioUrl}`);
105
-
106
- // Extract channel ID
107
- let cid = null;
108
- const cidMatch = studioUrl.match(/channel\/(UC[\w-]+)/);
109
- if (cidMatch) {
110
- cid = cidMatch[1];
98
+ // Determine the about URL
99
+ let aboutUrl;
100
+ if (youtubeHandle) {
101
+ // Direct: youtube.com/@handle/about
102
+ const handle = youtubeHandle.startsWith('@') ? youtubeHandle : '@' + youtubeHandle;
103
+ aboutUrl = `https://www.youtube.com/${handle}/about`;
111
104
  } else {
112
- const cfgResult = await s('Runtime.evaluate', {
113
- expression: '(window.ytcfg && window.ytcfg.get) ? window.ytcfg.get("CHANNEL_ID") : ""',
114
- returnByValue: true,
115
- });
116
- cid = cfgResult?.result?.value || null;
105
+ // Fallback: go to Studio first to get channel ID
106
+ console.log('[stats] No youtube_handle, falling back to Studio...');
107
+ await s('Page.navigate', { url: 'https://studio.youtube.com' });
108
+ await this.waitForLoad(s);
109
+ await this.sleep(4000);
110
+
111
+ const urlResult = await s('Runtime.evaluate', { expression: 'window.location.href', returnByValue: true });
112
+ const studioUrl = urlResult?.result?.value || '';
113
+ const cidMatch = studioUrl.match(/channel\/(UC[\w-]+)/);
114
+ let cid = cidMatch ? cidMatch[1] : null;
115
+
116
+ if (!cid) {
117
+ const cfgResult = await s('Runtime.evaluate', {
118
+ expression: '(window.ytcfg && window.ytcfg.get) ? window.ytcfg.get("CHANNEL_ID") : ""',
119
+ returnByValue: true,
120
+ });
121
+ cid = cfgResult?.result?.value || null;
122
+ }
123
+
124
+ if (!cid) throw new Error('Could not find channel ID from YouTube Studio');
125
+ aboutUrl = `https://www.youtube.com/channel/${cid}/about`;
117
126
  }
118
127
 
119
- if (!cid) {
120
- throw new Error('Could not find channel ID from YouTube Studio');
121
- }
122
- console.log(`[stats] Channel ID: ${cid}`);
123
-
124
- // Step 2: Navigate to channel About page
125
- const aboutUrl = `https://www.youtube.com/channel/${cid}/about`;
128
+ // Navigate directly to about page
126
129
  console.log(`[stats] Navigating to: ${aboutUrl}`);
127
130
  await s('Page.navigate', { url: aboutUrl });
128
131
  await this.waitForLoad(s);
129
132
  await this.sleep(4000);
130
133
 
131
- // Step 3: Read #additional-info-container table
134
+ // Read #additional-info-container table
132
135
  const result = await s('Runtime.evaluate', {
133
136
  expression: `
134
137
  (function() {
135
138
  var stats = { subscribers: -1, total_views: -1, video_count: -1, debug: '' };
136
139
 
137
- // Find the table inside #additional-info-container
138
140
  var container = document.querySelector('#additional-info-container');
139
141
  if (!container) {
140
- stats.debug = 'no #additional-info-container found';
141
- // Fallback: try to find stats from page text
142
- var text = document.body ? document.body.innerText : '';
143
- stats.debug = text.substring(0, 500);
142
+ stats.debug = 'no #additional-info-container';
144
143
  return JSON.stringify(stats);
145
144
  }
146
145
 
147
146
  var table = container.querySelector('table');
148
147
  if (!table) {
149
- stats.debug = 'no table in container, text: ' + container.innerText.substring(0, 300);
148
+ stats.debug = 'no table, text: ' + container.innerText.substring(0, 300);
150
149
  return JSON.stringify(stats);
151
150
  }
152
151
 
153
- // Each row has: icon cell + text cell (e.g. "1 subscriber", "3 videos", "743 views")
154
152
  var rows = table.querySelectorAll('tr');
155
153
  var texts = [];
156
154
  rows.forEach(function(row) {
157
155
  var text = row.innerText.trim();
158
156
  texts.push(text);
159
157
 
160
- // Match patterns: "X subscriber(s)", "X video(s)", "X view(s)"
161
- // Also Vietnamese: "X người đăng ký", "X video", "X lượt xem"
162
158
  var num = 0;
163
- var numMatch = text.match(/([\\d,\\.]+)/);
164
- if (numMatch) {
165
- num = parseInt(numMatch[1].replace(/,/g, ''), 10);
166
- }
167
-
168
- // Check for K/M/B suffix
169
159
  var suffixMatch = text.match(/([\\d,\\.]+)\\s*([KMB])/i);
170
160
  if (suffixMatch) {
171
161
  var n = parseFloat(suffixMatch[1].replace(/,/g, ''));
172
- var suffix = suffixMatch[2].toUpperCase();
173
- if (suffix === 'K') num = Math.round(n * 1000);
174
- else if (suffix === 'M') num = Math.round(n * 1000000);
175
- else if (suffix === 'B') num = Math.round(n * 1000000000);
162
+ var sx = suffixMatch[2].toUpperCase();
163
+ if (sx === 'K') num = Math.round(n * 1000);
164
+ else if (sx === 'M') num = Math.round(n * 1000000);
165
+ else if (sx === 'B') num = Math.round(n * 1000000000);
166
+ } else {
167
+ var numMatch = text.match(/([\\d,]+)/);
168
+ if (numMatch) num = parseInt(numMatch[1].replace(/,/g, ''), 10);
176
169
  }
177
170
 
178
171
  if (/subscriber|đăng ký/i.test(text)) {
@@ -184,6 +177,12 @@ class StatsSyncer {
184
177
  }
185
178
  });
186
179
 
180
+ // Get channel avatar
181
+ var avatarEl = document.querySelector('#channel-header-container img, yt-decorated-avatar img, yt-avatar-shape img');
182
+ if (avatarEl && avatarEl.src) {
183
+ stats.avatar_url = avatarEl.src;
184
+ }
185
+
187
186
  stats.debug = texts.join(' | ');
188
187
  return JSON.stringify(stats);
189
188
  })()
@@ -191,15 +190,16 @@ class StatsSyncer {
191
190
  returnByValue: true,
192
191
  });
193
192
 
194
- var stats = { subscribers: 0, total_views: 0, video_count: 0 };
193
+ var stats = { subscribers: 0, total_views: 0, video_count: 0, avatar_url: null };
195
194
  try {
196
195
  var parsed = JSON.parse(result?.result?.value || '{}');
197
- console.log(`[stats] About page data: ${parsed.debug}`);
196
+ console.log('[stats] About page data:', parsed.debug);
198
197
  if (parsed.subscribers >= 0) stats.subscribers = parsed.subscribers;
199
198
  if (parsed.total_views >= 0) stats.total_views = parsed.total_views;
200
199
  if (parsed.video_count >= 0) stats.video_count = parsed.video_count;
200
+ if (parsed.avatar_url) stats.avatar_url = parsed.avatar_url;
201
201
  } catch (e) {
202
- console.error(`[stats] Parse error: ${e.message}`);
202
+ console.error('[stats] Parse error:', e.message);
203
203
  }
204
204
 
205
205
  clearTimeout(timeout);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
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": {