channel-worker 1.4.0 → 1.5.0
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 +385 -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,385 @@
|
|
|
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
|
+
/**
|
|
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
|
+
async syncYouTubeStats(channel) {
|
|
19
|
+
const profileId = channel.nst_profile_id;
|
|
20
|
+
if (!profileId) throw new Error(`Channel "${channel.name}" has no nst_profile_id`);
|
|
21
|
+
|
|
22
|
+
console.log(`[stats] Syncing YouTube stats for "${channel.name}" (profile: ${profileId})`);
|
|
23
|
+
|
|
24
|
+
// Launch profile (no extension needed for stats)
|
|
25
|
+
const { wsEndpoint } = await this.nst.launchProfile(profileId, {
|
|
26
|
+
proxy: channel.proxy || null,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const stats = await this.scrapeYouTubeStudio(wsEndpoint);
|
|
33
|
+
console.log(`[stats] "${channel.name}" → subs: ${stats.subscribers}, views: ${stats.total_views}, videos: ${stats.video_count}`);
|
|
34
|
+
|
|
35
|
+
// Send to API
|
|
36
|
+
await this.api.request('POST', '/analytics/sync', {
|
|
37
|
+
channel_id: channel._id,
|
|
38
|
+
platform: 'youtube',
|
|
39
|
+
subscribers: stats.subscribers,
|
|
40
|
+
total_views: stats.total_views,
|
|
41
|
+
video_count: stats.video_count,
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
console.log(`[stats] "${channel.name}" stats saved to API`);
|
|
45
|
+
return stats;
|
|
46
|
+
} finally {
|
|
47
|
+
// Close browser after scraping
|
|
48
|
+
try {
|
|
49
|
+
await this.nst.stopProfile(profileId);
|
|
50
|
+
} catch (err) {
|
|
51
|
+
console.warn(`[stats] Failed to stop profile: ${err.message}`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Connect to browser via CDP WebSocket and scrape YouTube Studio
|
|
58
|
+
*/
|
|
59
|
+
async scrapeYouTubeStudio(wsEndpoint) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
const ws = new WebSocket(wsEndpoint);
|
|
62
|
+
let msgId = 1;
|
|
63
|
+
const pending = new Map();
|
|
64
|
+
|
|
65
|
+
const send = (method, params = {}) => {
|
|
66
|
+
return new Promise((res, rej) => {
|
|
67
|
+
const id = msgId++;
|
|
68
|
+
pending.set(id, { resolve: res, reject: rej });
|
|
69
|
+
ws.send(JSON.stringify({ id, method, params }));
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const timeout = setTimeout(() => {
|
|
74
|
+
ws.close();
|
|
75
|
+
reject(new Error('YouTube Studio scrape timed out (60s)'));
|
|
76
|
+
}, 60000);
|
|
77
|
+
|
|
78
|
+
ws.on('error', (err) => {
|
|
79
|
+
clearTimeout(timeout);
|
|
80
|
+
reject(err);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
ws.on('message', (data) => {
|
|
84
|
+
try {
|
|
85
|
+
const msg = JSON.parse(data);
|
|
86
|
+
if (msg.id && pending.has(msg.id)) {
|
|
87
|
+
const p = pending.get(msg.id);
|
|
88
|
+
pending.delete(msg.id);
|
|
89
|
+
if (msg.error) p.reject(new Error(msg.error.message));
|
|
90
|
+
else p.resolve(msg.result);
|
|
91
|
+
}
|
|
92
|
+
} catch {}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
ws.on('open', async () => {
|
|
96
|
+
try {
|
|
97
|
+
// Get list of targets (pages)
|
|
98
|
+
const targets = await send('Target.getTargets');
|
|
99
|
+
const pages = targets.targetInfos.filter(t => t.type === 'page');
|
|
100
|
+
|
|
101
|
+
// Find or create a page
|
|
102
|
+
let targetId;
|
|
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
|
+
}
|
|
109
|
+
|
|
110
|
+
// Attach to target
|
|
111
|
+
const { sessionId } = await send('Target.attachToTarget', {
|
|
112
|
+
targetId,
|
|
113
|
+
flatten: true,
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Helper to send commands to the attached session
|
|
117
|
+
const sessionSend = (method, params = {}) => {
|
|
118
|
+
return new Promise((res, rej) => {
|
|
119
|
+
const id = msgId++;
|
|
120
|
+
pending.set(id, { resolve: res, reject: rej });
|
|
121
|
+
ws.send(JSON.stringify({ id, method, params, sessionId }));
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Enable page events
|
|
126
|
+
await sessionSend('Page.enable');
|
|
127
|
+
|
|
128
|
+
// Navigate to YouTube Studio dashboard
|
|
129
|
+
await sessionSend('Page.navigate', {
|
|
130
|
+
url: 'https://studio.youtube.com',
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// Wait for page to load
|
|
134
|
+
await this.waitForLoad(sessionSend, 15000);
|
|
135
|
+
|
|
136
|
+
// Wait a bit for Studio SPA to render
|
|
137
|
+
await this.sleep(5000);
|
|
138
|
+
|
|
139
|
+
// Try to scrape stats from YouTube Studio dashboard
|
|
140
|
+
const stats = await this.extractStudioStats(sessionSend);
|
|
141
|
+
|
|
142
|
+
clearTimeout(timeout);
|
|
143
|
+
ws.close();
|
|
144
|
+
resolve(stats);
|
|
145
|
+
} catch (err) {
|
|
146
|
+
clearTimeout(timeout);
|
|
147
|
+
ws.close();
|
|
148
|
+
reject(err);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async waitForLoad(sessionSend, timeoutMs = 15000) {
|
|
155
|
+
const start = Date.now();
|
|
156
|
+
while (Date.now() - start < timeoutMs) {
|
|
157
|
+
try {
|
|
158
|
+
const result = await sessionSend('Runtime.evaluate', {
|
|
159
|
+
expression: 'document.readyState',
|
|
160
|
+
returnByValue: true,
|
|
161
|
+
});
|
|
162
|
+
if (result?.result?.value === 'complete') return;
|
|
163
|
+
} catch {}
|
|
164
|
+
await this.sleep(500);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
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
|
+
sleep(ms) {
|
|
381
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
module.exports = { StatsSyncer };
|