channel-worker 1.5.1 → 1.6.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 +37 -0
- package/lib/daemon.js +18 -0
- package/lib/stats-syncer.js +185 -251
- 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,sync_youtube_stats';
|
|
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,restart_worker';
|
|
69
69
|
return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
|
|
70
70
|
}
|
|
71
71
|
|
package/lib/command-poller.js
CHANGED
|
@@ -72,6 +72,9 @@ class CommandPoller {
|
|
|
72
72
|
case 'sync_youtube_stats':
|
|
73
73
|
await this.handleSyncYoutubeStats(command);
|
|
74
74
|
break;
|
|
75
|
+
case 'restart_worker':
|
|
76
|
+
await this.handleRestartWorker(command);
|
|
77
|
+
break;
|
|
75
78
|
default:
|
|
76
79
|
// Other commands (scan_facebook_pages, etc.) handled by extension
|
|
77
80
|
console.log(`[commands] Skipping ${command.type} — handled by extension`);
|
|
@@ -861,6 +864,40 @@ class CommandPoller {
|
|
|
861
864
|
await this.api.updateCommand(command._id, { status: 'done' });
|
|
862
865
|
}
|
|
863
866
|
|
|
867
|
+
async handleRestartWorker(command) {
|
|
868
|
+
console.log('[commands] Restart worker requested — spawning new process and exiting...');
|
|
869
|
+
try {
|
|
870
|
+
await this.api.updateCommand(command._id, {
|
|
871
|
+
status: 'done',
|
|
872
|
+
result: { message: 'Worker restarting', restarted_at: new Date().toISOString() },
|
|
873
|
+
});
|
|
874
|
+
} catch {}
|
|
875
|
+
|
|
876
|
+
// Spawn a new daemon process, then exit current one
|
|
877
|
+
const { spawn } = require('child_process');
|
|
878
|
+
const path = require('path');
|
|
879
|
+
const fs = require('fs');
|
|
880
|
+
const os = require('os');
|
|
881
|
+
const cliPath = path.resolve(__dirname, '../bin/cli.js');
|
|
882
|
+
const configDir = path.join(os.homedir(), '.channel-worker');
|
|
883
|
+
const logFile = path.join(configDir, 'daemon.log');
|
|
884
|
+
const logFd = fs.openSync(logFile, 'a');
|
|
885
|
+
|
|
886
|
+
const child = spawn(process.execPath, [cliPath, 'start', '--_daemon'], {
|
|
887
|
+
detached: true,
|
|
888
|
+
stdio: ['ignore', logFd, logFd],
|
|
889
|
+
cwd: os.homedir(),
|
|
890
|
+
});
|
|
891
|
+
child.unref();
|
|
892
|
+
|
|
893
|
+
// Update PID file
|
|
894
|
+
const pidFile = path.join(configDir, 'daemon.pid');
|
|
895
|
+
fs.writeFileSync(pidFile, String(child.pid));
|
|
896
|
+
console.log(`[commands] New daemon spawned (PID: ${child.pid}), exiting current process...`);
|
|
897
|
+
|
|
898
|
+
process.exit(0);
|
|
899
|
+
}
|
|
900
|
+
|
|
864
901
|
async handleSyncYoutubeStats(command) {
|
|
865
902
|
const { channel_id } = command.payload || {};
|
|
866
903
|
console.log(`[commands] Syncing YouTube stats for channel: ${channel_id}`);
|
package/lib/daemon.js
CHANGED
|
@@ -13,6 +13,7 @@ class Daemon {
|
|
|
13
13
|
this.poller = new JobPoller(this.api, config);
|
|
14
14
|
this.commandPoller = new CommandPoller(this.api, config);
|
|
15
15
|
this.updateChecker = new UpdateChecker(5 * 60 * 1000); // check every 5min
|
|
16
|
+
this.extCheckTimer = null;
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
async start() {
|
|
@@ -71,6 +72,22 @@ class Daemon {
|
|
|
71
72
|
// Start auto-update checker
|
|
72
73
|
this.updateChecker.start();
|
|
73
74
|
console.log('[daemon] Auto-update checker started (every 5min)');
|
|
75
|
+
|
|
76
|
+
// Start extension auto-update checker (every 5min)
|
|
77
|
+
if (this.config.extension_path) {
|
|
78
|
+
this.extCheckTimer = setInterval(async () => {
|
|
79
|
+
try {
|
|
80
|
+
const result = await checkAndUpdateExtension(this.api, this.config.extension_path);
|
|
81
|
+
if (result.updated) {
|
|
82
|
+
console.log(`[daemon] Extension auto-updated: ${result.from || 'none'} → ${result.to}`);
|
|
83
|
+
}
|
|
84
|
+
} catch (err) {
|
|
85
|
+
// silent — don't spam logs on failure
|
|
86
|
+
}
|
|
87
|
+
}, 5 * 60 * 1000);
|
|
88
|
+
console.log('[daemon] Extension auto-update checker started (every 5min)');
|
|
89
|
+
}
|
|
90
|
+
|
|
74
91
|
console.log('[daemon] Waiting for jobs & commands...\n');
|
|
75
92
|
|
|
76
93
|
// Graceful shutdown
|
|
@@ -80,6 +97,7 @@ class Daemon {
|
|
|
80
97
|
this.poller.stop();
|
|
81
98
|
this.commandPoller.stop();
|
|
82
99
|
this.updateChecker.stop();
|
|
100
|
+
if (this.extCheckTimer) clearInterval(this.extCheckTimer);
|
|
83
101
|
|
|
84
102
|
// Mark offline
|
|
85
103
|
try {
|
package/lib/stats-syncer.js
CHANGED
|
@@ -82,10 +82,9 @@ class StatsSyncer {
|
|
|
82
82
|
|
|
83
83
|
ws.on('open', async () => {
|
|
84
84
|
try {
|
|
85
|
-
// Get a page target
|
|
86
85
|
const targets = await send('Target.getTargets');
|
|
87
86
|
const pages = targets.targetInfos.filter(t => t.type === 'page');
|
|
88
|
-
|
|
87
|
+
const targetId = pages.length > 0 ? pages[0].targetId : (await send('Target.createTarget', { url: 'about:blank' })).targetId;
|
|
89
88
|
|
|
90
89
|
const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true });
|
|
91
90
|
const s = (method, params = {}) => send(method, params, sessionId);
|
|
@@ -93,286 +92,221 @@ class StatsSyncer {
|
|
|
93
92
|
await s('Page.enable');
|
|
94
93
|
await s('Runtime.enable');
|
|
95
94
|
|
|
96
|
-
|
|
95
|
+
let stats = { subscribers: 0, total_views: 0, video_count: 0 };
|
|
96
|
+
|
|
97
|
+
// Step 1: Go to YouTube Studio → auto-redirects to correct channel
|
|
97
98
|
console.log('[stats] Navigating to YouTube Studio...');
|
|
98
99
|
await s('Page.navigate', { url: 'https://studio.youtube.com' });
|
|
99
100
|
await this.waitForLoad(s);
|
|
100
|
-
await this.sleep(
|
|
101
|
+
await this.sleep(5000);
|
|
101
102
|
|
|
102
|
-
//
|
|
103
|
+
// Get channel ID from URL
|
|
103
104
|
const urlResult = await s('Runtime.evaluate', { expression: 'window.location.href', returnByValue: true });
|
|
104
|
-
|
|
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
|
-
}
|
|
105
|
+
const studioUrl = urlResult?.result?.value || '';
|
|
106
|
+
console.log(`[stats] Studio URL: ${studioUrl}`);
|
|
128
107
|
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
}
|
|
108
|
+
const channelIdMatch = studioUrl.match(/channel\/(UC[\w-]+)/);
|
|
109
|
+
const channelId = channelIdMatch ? channelIdMatch[1] : null;
|
|
138
110
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
|
152
119
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
`,
|
|
120
|
+
const cid = channelId || (await s('Runtime.evaluate', {
|
|
121
|
+
expression: '(window.ytcfg && window.ytcfg.get) ? window.ytcfg.get("CHANNEL_ID") : ""',
|
|
156
122
|
returnByValue: true,
|
|
157
|
-
});
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
+
}
|
|
185
169
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
}
|
|
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;
|
|
221
175
|
}
|
|
222
176
|
}
|
|
223
177
|
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
k.toLowerCase().includes('channel') || k.toLowerCase().includes('subscriber')
|
|
230
|
-
),
|
|
231
|
-
});
|
|
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]);
|
|
232
183
|
}
|
|
233
184
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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...');
|
|
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
|
+
}
|
|
252
192
|
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
if (cid) return 'https://www.youtube.com/channel/' + cid + '/videos';
|
|
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;
|
|
266
205
|
}
|
|
267
206
|
|
|
268
|
-
|
|
269
|
-
return 'https://www.youtube.com/@me';
|
|
207
|
+
return JSON.stringify(result);
|
|
270
208
|
})()
|
|
271
209
|
`,
|
|
272
210
|
returnByValue: true,
|
|
273
211
|
});
|
|
274
212
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
console.log(
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
|
318
220
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
}
|
|
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';
|
|
330
225
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
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);
|
|
336
230
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
+
}
|
|
341
260
|
}
|
|
342
|
-
|
|
343
|
-
} catch (e) {
|
|
344
|
-
return JSON.stringify({ error: e.message });
|
|
345
261
|
}
|
|
262
|
+
}
|
|
346
263
|
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
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);
|
|
359
276
|
}
|
|
277
|
+
}
|
|
360
278
|
|
|
361
|
-
|
|
362
|
-
})
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
279
|
+
} catch (e) {
|
|
280
|
+
return JSON.stringify({ error: e.message });
|
|
281
|
+
}
|
|
282
|
+
|
|
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
|
+
});
|
|
302
|
+
|
|
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 {}
|
|
376
310
|
|
|
377
311
|
clearTimeout(timeout);
|
|
378
312
|
ws.close();
|