channel-worker 1.4.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/api-client.js +1 -1
- package/lib/command-poller.js +46 -0
- package/lib/stats-syncer.js +405 -0
- package/package.json +1 -1
package/lib/api-client.js
CHANGED
|
@@ -65,7 +65,7 @@ class ApiClient {
|
|
|
65
65
|
|
|
66
66
|
// Commands
|
|
67
67
|
async getNextCommand(workerId) {
|
|
68
|
-
const workerTypes = 'launch_profile,close_profile,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension';
|
|
68
|
+
const workerTypes = 'launch_profile,close_profile,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats';
|
|
69
69
|
return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
|
|
70
70
|
}
|
|
71
71
|
|
package/lib/command-poller.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
const { NstManager } = require('./nst-manager');
|
|
2
2
|
const { checkAndUpdateExtension } = require('./extension-updater');
|
|
3
|
+
const { StatsSyncer } = require('./stats-syncer');
|
|
3
4
|
|
|
4
5
|
class CommandPoller {
|
|
5
6
|
constructor(api, config) {
|
|
@@ -68,6 +69,9 @@ class CommandPoller {
|
|
|
68
69
|
case 'update_extension':
|
|
69
70
|
await this.handleUpdateExtension(command);
|
|
70
71
|
break;
|
|
72
|
+
case 'sync_youtube_stats':
|
|
73
|
+
await this.handleSyncYoutubeStats(command);
|
|
74
|
+
break;
|
|
71
75
|
default:
|
|
72
76
|
// Other commands (scan_facebook_pages, etc.) handled by extension
|
|
73
77
|
console.log(`[commands] Skipping ${command.type} — handled by extension`);
|
|
@@ -856,6 +860,48 @@ class CommandPoller {
|
|
|
856
860
|
// TODO: launch headless, check cookies for Google/Facebook/TikTok
|
|
857
861
|
await this.api.updateCommand(command._id, { status: 'done' });
|
|
858
862
|
}
|
|
863
|
+
|
|
864
|
+
async handleSyncYoutubeStats(command) {
|
|
865
|
+
const { channel_id } = command.payload || {};
|
|
866
|
+
console.log(`[commands] Syncing YouTube stats for channel: ${channel_id}`);
|
|
867
|
+
|
|
868
|
+
try {
|
|
869
|
+
if (!this.nst) {
|
|
870
|
+
const apiKey = await this.api.getSetting('nst_api_key');
|
|
871
|
+
if (apiKey) {
|
|
872
|
+
this.nst = new NstManager(apiKey);
|
|
873
|
+
} else {
|
|
874
|
+
throw new Error('Nstbrowser API key not configured.');
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// Fetch channel info
|
|
879
|
+
const channel = await this.api.getChannel(channel_id);
|
|
880
|
+
if (!channel) throw new Error(`Channel ${channel_id} not found`);
|
|
881
|
+
if (!channel.nst_profile_id) throw new Error(`Channel "${channel.name}" has no Nstbrowser profile`);
|
|
882
|
+
|
|
883
|
+
const syncer = new StatsSyncer(this.nst, this.api);
|
|
884
|
+
const stats = await syncer.syncYouTubeStats(channel);
|
|
885
|
+
|
|
886
|
+
await this.api.updateCommand(command._id, {
|
|
887
|
+
status: 'done',
|
|
888
|
+
result: {
|
|
889
|
+
channel_id,
|
|
890
|
+
channel_name: channel.name,
|
|
891
|
+
...stats,
|
|
892
|
+
synced_at: new Date().toISOString(),
|
|
893
|
+
},
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
console.log(`[commands] YouTube stats synced for "${channel.name}"`);
|
|
897
|
+
} catch (err) {
|
|
898
|
+
console.error(`[commands] YouTube stats sync failed: ${err.message}`);
|
|
899
|
+
await this.api.updateCommand(command._id, {
|
|
900
|
+
status: 'failed',
|
|
901
|
+
error: err.message,
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
}
|
|
859
905
|
}
|
|
860
906
|
|
|
861
907
|
module.exports = { CommandPoller };
|
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
const WebSocket = require('ws');
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sync YouTube Studio stats via Nstbrowser CDP.
|
|
5
|
+
* Flow: Launch profile → connect CDP → navigate to Studio → scrape stats → close
|
|
6
|
+
*/
|
|
7
|
+
class StatsSyncer {
|
|
8
|
+
constructor(nstManager, apiClient) {
|
|
9
|
+
this.nst = nstManager;
|
|
10
|
+
this.api = apiClient;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
async syncYouTubeStats(channel) {
|
|
14
|
+
const profileId = channel.nst_profile_id;
|
|
15
|
+
if (!profileId) throw new Error(`Channel "${channel.name}" has no nst_profile_id`);
|
|
16
|
+
|
|
17
|
+
console.log(`[stats] Syncing YouTube stats for "${channel.name}" (profile: ${profileId})`);
|
|
18
|
+
|
|
19
|
+
const { wsEndpoint } = await this.nst.launchProfile(profileId, {
|
|
20
|
+
proxy: channel.proxy || null,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const stats = await this.scrapeViaCDP(wsEndpoint);
|
|
27
|
+
console.log(`[stats] "${channel.name}" → subs: ${stats.subscribers}, views: ${stats.total_views}, videos: ${stats.video_count}`);
|
|
28
|
+
|
|
29
|
+
await this.api.request('POST', '/analytics/sync', {
|
|
30
|
+
channel_id: channel._id,
|
|
31
|
+
platform: 'youtube',
|
|
32
|
+
subscribers: stats.subscribers,
|
|
33
|
+
total_views: stats.total_views,
|
|
34
|
+
video_count: stats.video_count,
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
console.log(`[stats] "${channel.name}" stats saved to API`);
|
|
38
|
+
return stats;
|
|
39
|
+
} finally {
|
|
40
|
+
try {
|
|
41
|
+
await this.nst.stopProfile(profileId);
|
|
42
|
+
} catch (err) {
|
|
43
|
+
console.warn(`[stats] Failed to stop profile: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
scrapeViaCDP(wsEndpoint) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
const ws = new WebSocket(wsEndpoint);
|
|
51
|
+
let msgId = 1;
|
|
52
|
+
const pending = new Map();
|
|
53
|
+
|
|
54
|
+
const send = (method, params = {}, sid) => {
|
|
55
|
+
return new Promise((res, rej) => {
|
|
56
|
+
const id = msgId++;
|
|
57
|
+
pending.set(id, { resolve: res, reject: rej });
|
|
58
|
+
const msg = { id, method, params };
|
|
59
|
+
if (sid) msg.sessionId = sid;
|
|
60
|
+
ws.send(JSON.stringify(msg));
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const timeout = setTimeout(() => {
|
|
65
|
+
ws.close();
|
|
66
|
+
reject(new Error('Stats scrape timed out (90s)'));
|
|
67
|
+
}, 90000);
|
|
68
|
+
|
|
69
|
+
ws.on('error', (err) => { clearTimeout(timeout); reject(err); });
|
|
70
|
+
|
|
71
|
+
ws.on('message', (data) => {
|
|
72
|
+
try {
|
|
73
|
+
const msg = JSON.parse(data);
|
|
74
|
+
if (msg.id && pending.has(msg.id)) {
|
|
75
|
+
const p = pending.get(msg.id);
|
|
76
|
+
pending.delete(msg.id);
|
|
77
|
+
if (msg.error) p.reject(new Error(msg.error.message));
|
|
78
|
+
else p.resolve(msg.result);
|
|
79
|
+
}
|
|
80
|
+
} catch {}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
ws.on('open', async () => {
|
|
84
|
+
try {
|
|
85
|
+
// Get a page target
|
|
86
|
+
const targets = await send('Target.getTargets');
|
|
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
|
+
}
|
|
128
|
+
|
|
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
|
+
}
|
|
138
|
+
|
|
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
|
+
}
|
|
152
|
+
|
|
153
|
+
return JSON.stringify(result);
|
|
154
|
+
})()
|
|
155
|
+
`,
|
|
156
|
+
returnByValue: true,
|
|
157
|
+
});
|
|
158
|
+
|
|
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 {}
|
|
165
|
+
|
|
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,
|
|
242
|
+
});
|
|
243
|
+
|
|
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 {}
|
|
248
|
+
|
|
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
|
+
});
|
|
274
|
+
|
|
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
|
+
}
|
|
376
|
+
|
|
377
|
+
clearTimeout(timeout);
|
|
378
|
+
ws.close();
|
|
379
|
+
resolve(stats);
|
|
380
|
+
} catch (err) {
|
|
381
|
+
clearTimeout(timeout);
|
|
382
|
+
ws.close();
|
|
383
|
+
reject(err);
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async waitForLoad(s, timeoutMs = 15000) {
|
|
390
|
+
const start = Date.now();
|
|
391
|
+
while (Date.now() - start < timeoutMs) {
|
|
392
|
+
try {
|
|
393
|
+
const r = await s('Runtime.evaluate', { expression: 'document.readyState', returnByValue: true });
|
|
394
|
+
if (r?.result?.value === 'complete') return;
|
|
395
|
+
} catch {}
|
|
396
|
+
await this.sleep(500);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
sleep(ms) {
|
|
401
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
module.exports = { StatsSyncer };
|