channel-worker 1.5.0 → 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.
- package/lib/stats-syncer.js +226 -272
- package/package.json +1 -1
package/lib/stats-syncer.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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('
|
|
76
|
-
},
|
|
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,231 @@ class StatsSyncer {
|
|
|
94
82
|
|
|
95
83
|
ws.on('open', async () => {
|
|
96
84
|
try {
|
|
97
|
-
// Get list of targets (pages)
|
|
98
85
|
const targets = await send('Target.getTargets');
|
|
99
86
|
const pages = targets.targetInfos.filter(t => t.type === 'page');
|
|
87
|
+
const targetId = pages.length > 0 ? pages[0].targetId : (await send('Target.createTarget', { url: 'about:blank' })).targetId;
|
|
100
88
|
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
}
|
|
89
|
+
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
|
|
90
|
+
const s = (method, params = {}) => send(method, params, sessionId);
|
|
109
91
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
92
|
+
await s('Page.enable');
|
|
93
|
+
await s('Runtime.enable');
|
|
94
|
+
|
|
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;
|
|
115
110
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
ws.send(JSON.stringify({ id, method, params, sessionId }));
|
|
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,
|
|
122
116
|
});
|
|
123
|
-
|
|
117
|
+
console.log(`[stats] ytcfg CHANNEL_ID: ${cfgResult?.result?.value}`);
|
|
118
|
+
}
|
|
124
119
|
|
|
125
|
-
|
|
126
|
-
|
|
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 });
|
|
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
|
+
}
|
|
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
|
+
});
|
|
127
212
|
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
+
}
|
|
132
220
|
|
|
133
|
-
//
|
|
134
|
-
|
|
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 });
|
|
228
|
+
await this.waitForLoad(s);
|
|
229
|
+
await this.sleep(4000);
|
|
230
|
+
|
|
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
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
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
|
+
}
|
|
278
|
+
|
|
279
|
+
} catch (e) {
|
|
280
|
+
return JSON.stringify({ error: e.message });
|
|
281
|
+
}
|
|
135
282
|
|
|
136
|
-
|
|
137
|
-
|
|
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
|
+
});
|
|
138
302
|
|
|
139
|
-
|
|
140
|
-
|
|
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 {}
|
|
141
310
|
|
|
142
311
|
clearTimeout(timeout);
|
|
143
312
|
ws.close();
|
|
@@ -151,232 +320,17 @@ class StatsSyncer {
|
|
|
151
320
|
});
|
|
152
321
|
}
|
|
153
322
|
|
|
154
|
-
async waitForLoad(
|
|
323
|
+
async waitForLoad(s, timeoutMs = 15000) {
|
|
155
324
|
const start = Date.now();
|
|
156
325
|
while (Date.now() - start < timeoutMs) {
|
|
157
326
|
try {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
returnByValue: true,
|
|
161
|
-
});
|
|
162
|
-
if (result?.result?.value === 'complete') return;
|
|
327
|
+
const r = await s('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true });
|
|
328
|
+
if (r?.result?.value === 'complete') return;
|
|
163
329
|
} catch {}
|
|
164
330
|
await this.sleep(500);
|
|
165
331
|
}
|
|
166
332
|
}
|
|
167
333
|
|
|
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
334
|
sleep(ms) {
|
|
381
335
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
382
336
|
}
|