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.
- package/lib/command-poller.js +5 -2
- package/lib/stats-syncer.js +85 -199
- package/package.json +1 -1
package/lib/command-poller.js
CHANGED
|
@@ -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
|
|
package/lib/stats-syncer.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
const WebSocket = require('ws');
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Sync YouTube
|
|
5
|
-
* Flow: Launch profile →
|
|
4
|
+
* Sync YouTube stats via Nstbrowser CDP.
|
|
5
|
+
* Flow: Launch profile → navigate to youtube.com/@handle/about → read 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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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(
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
//
|
|
222
|
-
|
|
223
|
-
|
|
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
|
-
|
|
133
|
+
// Read #additional-info-container table
|
|
134
|
+
const result = await s('Runtime.evaluate', {
|
|
232
135
|
expression: `
|
|
233
136
|
(function() {
|
|
234
|
-
var
|
|
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
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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
|
-
|
|
280
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
var
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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
|
|
305
|
-
console.log('[stats]
|
|
306
|
-
if (
|
|
307
|
-
if (
|
|
308
|
-
if (
|
|
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();
|