channel-worker 1.5.0 → 1.5.1
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 +292 -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,297 @@ class StatsSyncer {
|
|
|
94
82
|
|
|
95
83
|
ws.on('open', async () => {
|
|
96
84
|
try {
|
|
97
|
-
// Get
|
|
85
|
+
// Get a page target
|
|
98
86
|
const targets = await send('Target.getTargets');
|
|
99
87
|
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;
|
|
89
|
+
|
|
90
|
+
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
|
|
91
|
+
const s = (method, params = {}) => send(method, params, sessionId);
|
|
92
|
+
|
|
93
|
+
await s('Page.enable');
|
|
94
|
+
await s('Runtime.enable');
|
|
95
|
+
|
|
96
|
+
// Step 1: Go to YouTube Studio — it auto-redirects to the correct channel
|
|
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(6000); // SPA needs time to render
|
|
101
|
+
|
|
102
|
+
// Log current URL for debugging
|
|
103
|
+
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
|
+
}
|
|
100
128
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
+
}
|
|
109
138
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
+
}
|
|
115
152
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
ws.send(JSON.stringify({ id, method, params, sessionId }));
|
|
122
|
-
});
|
|
123
|
-
};
|
|
153
|
+
return JSON.stringify(result);
|
|
154
|
+
})()
|
|
155
|
+
`,
|
|
156
|
+
returnByValue: true,
|
|
157
|
+
});
|
|
124
158
|
|
|
125
|
-
|
|
126
|
-
|
|
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 {}
|
|
127
165
|
|
|
128
|
-
//
|
|
129
|
-
|
|
130
|
-
|
|
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
|
+
}
|
|
185
|
+
|
|
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
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
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
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
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,
|
|
131
242
|
});
|
|
132
243
|
|
|
133
|
-
|
|
134
|
-
|
|
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 {}
|
|
135
248
|
|
|
136
|
-
//
|
|
137
|
-
|
|
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...');
|
|
252
|
+
|
|
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';
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Fallback: go to own channel
|
|
269
|
+
return 'https://www.youtube.com/@me';
|
|
270
|
+
})()
|
|
271
|
+
`,
|
|
272
|
+
returnByValue: true,
|
|
273
|
+
});
|
|
138
274
|
|
|
139
|
-
|
|
140
|
-
|
|
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
|
+
}
|
|
318
|
+
|
|
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
|
+
}
|
|
330
|
+
|
|
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
|
+
}
|
|
336
|
+
|
|
337
|
+
// Try to get view count from about page data
|
|
338
|
+
const microformat = data?.microformat?.microformatDataRenderer;
|
|
339
|
+
if (microformat) {
|
|
340
|
+
// Some data here
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
} catch (e) {
|
|
344
|
+
return JSON.stringify({ error: e.message });
|
|
345
|
+
}
|
|
346
|
+
|
|
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;
|
|
359
|
+
}
|
|
360
|
+
|
|
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
|
+
}
|
|
141
376
|
|
|
142
377
|
clearTimeout(timeout);
|
|
143
378
|
ws.close();
|
|
@@ -151,232 +386,17 @@ class StatsSyncer {
|
|
|
151
386
|
});
|
|
152
387
|
}
|
|
153
388
|
|
|
154
|
-
async waitForLoad(
|
|
389
|
+
async waitForLoad(s, timeoutMs = 15000) {
|
|
155
390
|
const start = Date.now();
|
|
156
391
|
while (Date.now() - start < timeoutMs) {
|
|
157
392
|
try {
|
|
158
|
-
const
|
|
159
|
-
|
|
160
|
-
returnByValue: true,
|
|
161
|
-
});
|
|
162
|
-
if (result?.result?.value === 'complete') return;
|
|
393
|
+
const r = await s('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true });
|
|
394
|
+
if (r?.result?.value === 'complete') return;
|
|
163
395
|
} catch {}
|
|
164
396
|
await this.sleep(500);
|
|
165
397
|
}
|
|
166
398
|
}
|
|
167
399
|
|
|
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
400
|
sleep(ms) {
|
|
381
401
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
382
402
|
}
|