channel-worker 1.3.9 → 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/bin/cli.js +81 -4
- package/lib/api-client.js +6 -3
- package/lib/command-poller.js +46 -0
- package/lib/daemon.js +1 -1
- package/lib/heartbeat.js +8 -2
- package/lib/stats-syncer.js +385 -0
- package/lib/updater.js +19 -11
- package/package.json +1 -1
package/bin/cli.js
CHANGED
|
@@ -128,9 +128,32 @@ if (cmd === 'pair') {
|
|
|
128
128
|
// Save merged config for next time
|
|
129
129
|
saveConfig(config);
|
|
130
130
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
131
|
+
if (args._daemon) {
|
|
132
|
+
// Actually run the daemon (spawned by ourselves)
|
|
133
|
+
const { Daemon } = require('../lib/daemon');
|
|
134
|
+
const daemon = new Daemon(config);
|
|
135
|
+
daemon.start();
|
|
136
|
+
} else {
|
|
137
|
+
// Spawn detached background process and exit
|
|
138
|
+
const { spawn } = require('child_process');
|
|
139
|
+
const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
|
|
140
|
+
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
141
|
+
|
|
142
|
+
const child = spawn(process.execPath, [__filename, 'start', '--_daemon'], {
|
|
143
|
+
detached: true,
|
|
144
|
+
stdio: ['ignore', logFd, logFd],
|
|
145
|
+
cwd: os.homedir(),
|
|
146
|
+
});
|
|
147
|
+
child.unref();
|
|
148
|
+
|
|
149
|
+
// Save PID for stop command
|
|
150
|
+
const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
|
|
151
|
+
fs.writeFileSync(pidFile, String(child.pid));
|
|
152
|
+
|
|
153
|
+
console.log(`[channel-worker] Daemon started (PID: ${child.pid})`);
|
|
154
|
+
console.log(`[channel-worker] Logs: ${LOG_FILE}`);
|
|
155
|
+
process.exit(0);
|
|
156
|
+
}
|
|
134
157
|
|
|
135
158
|
} else if (cmd === 'update') {
|
|
136
159
|
const { checkAndUpdate, getLocalVersion } = require('../lib/updater');
|
|
@@ -150,6 +173,55 @@ if (cmd === 'pair') {
|
|
|
150
173
|
}
|
|
151
174
|
})();
|
|
152
175
|
|
|
176
|
+
} else if (cmd === 'stop') {
|
|
177
|
+
const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
|
|
178
|
+
if (!fs.existsSync(pidFile)) {
|
|
179
|
+
console.log('[channel-worker] No daemon running (no PID file).');
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
183
|
+
try {
|
|
184
|
+
process.kill(pid, 'SIGTERM');
|
|
185
|
+
fs.unlinkSync(pidFile);
|
|
186
|
+
console.log(`[channel-worker] Daemon stopped (PID: ${pid})`);
|
|
187
|
+
} catch (err) {
|
|
188
|
+
if (err.code === 'ESRCH') {
|
|
189
|
+
fs.unlinkSync(pidFile);
|
|
190
|
+
console.log(`[channel-worker] Daemon was not running (stale PID: ${pid}). Cleaned up.`);
|
|
191
|
+
} else {
|
|
192
|
+
console.error(`[channel-worker] Failed to stop: ${err.message}`);
|
|
193
|
+
process.exit(1);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
} else if (cmd === 'logs') {
|
|
198
|
+
const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
|
|
199
|
+
if (!fs.existsSync(LOG_FILE)) {
|
|
200
|
+
console.log('[channel-worker] No log file found.');
|
|
201
|
+
process.exit(0);
|
|
202
|
+
}
|
|
203
|
+
const lines = parseInt(args.lines || '50', 10);
|
|
204
|
+
const content = fs.readFileSync(LOG_FILE, 'utf-8');
|
|
205
|
+
const tail = content.split('\n').slice(-lines).join('\n');
|
|
206
|
+
console.log(tail);
|
|
207
|
+
|
|
208
|
+
} else if (cmd === 'restart') {
|
|
209
|
+
// Stop existing daemon, then start new one
|
|
210
|
+
const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
|
|
211
|
+
if (fs.existsSync(pidFile)) {
|
|
212
|
+
const pid = parseInt(fs.readFileSync(pidFile, 'utf-8').trim(), 10);
|
|
213
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
214
|
+
fs.unlinkSync(pidFile);
|
|
215
|
+
console.log(`[channel-worker] Stopped old daemon (PID: ${pid})`);
|
|
216
|
+
}
|
|
217
|
+
// Re-invoke start
|
|
218
|
+
const { spawn } = require('child_process');
|
|
219
|
+
const child = spawn(process.execPath, [__filename, 'start'], {
|
|
220
|
+
stdio: 'inherit',
|
|
221
|
+
cwd: process.cwd(),
|
|
222
|
+
});
|
|
223
|
+
child.on('exit', (code) => process.exit(code));
|
|
224
|
+
|
|
153
225
|
} else if (cmd === 'config') {
|
|
154
226
|
const config = loadConfig();
|
|
155
227
|
// Hide token in display
|
|
@@ -164,7 +236,10 @@ channel-worker — Channel Manager worker daemon
|
|
|
164
236
|
Commands:
|
|
165
237
|
pair Pair with dashboard using a one-time code (recommended)
|
|
166
238
|
init Configure worker manually
|
|
167
|
-
start Start the daemon
|
|
239
|
+
start Start the daemon in background
|
|
240
|
+
stop Stop the daemon
|
|
241
|
+
restart Restart the daemon
|
|
242
|
+
logs Show recent daemon logs (--lines <n>, default 50)
|
|
168
243
|
update Check and install updates manually
|
|
169
244
|
config Show current config
|
|
170
245
|
|
|
@@ -182,6 +257,8 @@ Options:
|
|
|
182
257
|
Examples:
|
|
183
258
|
channel-worker pair --code A3F1B2 --api https://api.channel.tunasm.art
|
|
184
259
|
channel-worker start
|
|
260
|
+
channel-worker logs --lines 100
|
|
261
|
+
channel-worker stop
|
|
185
262
|
`);
|
|
186
263
|
}
|
|
187
264
|
|
package/lib/api-client.js
CHANGED
|
@@ -29,8 +29,11 @@ class ApiClient {
|
|
|
29
29
|
return this.request('POST', '/workers/register', workerData);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
async heartbeat(workerId) {
|
|
33
|
-
|
|
32
|
+
async heartbeat(workerId, version, extensionVersion) {
|
|
33
|
+
const body = { worker_id: workerId };
|
|
34
|
+
if (version) body.version = version;
|
|
35
|
+
if (extensionVersion) body.extension_version = extensionVersion;
|
|
36
|
+
return this.request('POST', '/workers/heartbeat', body);
|
|
34
37
|
}
|
|
35
38
|
|
|
36
39
|
// Jobs
|
|
@@ -62,7 +65,7 @@ class ApiClient {
|
|
|
62
65
|
|
|
63
66
|
// Commands
|
|
64
67
|
async getNextCommand(workerId) {
|
|
65
|
-
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';
|
|
66
69
|
return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
|
|
67
70
|
}
|
|
68
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 };
|
package/lib/daemon.js
CHANGED
|
@@ -9,7 +9,7 @@ class Daemon {
|
|
|
9
9
|
constructor(config) {
|
|
10
10
|
this.config = config;
|
|
11
11
|
this.api = new ApiClient(config.api_url, config.worker_token);
|
|
12
|
-
this.heartbeat = new Heartbeat(this.api, config.worker_id);
|
|
12
|
+
this.heartbeat = new Heartbeat(this.api, config.worker_id, 30000, config);
|
|
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
|
package/lib/heartbeat.js
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
const { getLocalVersion } = require('./updater');
|
|
2
|
+
const { getLocalExtensionVersion } = require('./extension-updater');
|
|
3
|
+
|
|
1
4
|
class Heartbeat {
|
|
2
|
-
constructor(api, workerId, intervalMs = 30000) {
|
|
5
|
+
constructor(api, workerId, intervalMs = 30000, config = {}) {
|
|
3
6
|
this.api = api;
|
|
4
7
|
this.workerId = workerId;
|
|
5
8
|
this.intervalMs = intervalMs;
|
|
9
|
+
this.config = config;
|
|
6
10
|
this.timer = null;
|
|
7
11
|
}
|
|
8
12
|
|
|
@@ -20,7 +24,9 @@ class Heartbeat {
|
|
|
20
24
|
|
|
21
25
|
async send() {
|
|
22
26
|
try {
|
|
23
|
-
|
|
27
|
+
const version = getLocalVersion();
|
|
28
|
+
const extVersion = getLocalExtensionVersion(this.config.extension_path);
|
|
29
|
+
await this.api.heartbeat(this.workerId, version, extVersion);
|
|
24
30
|
} catch (err) {
|
|
25
31
|
console.error(`[heartbeat] Failed: ${err.message}`);
|
|
26
32
|
}
|
|
@@ -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 };
|
package/lib/updater.js
CHANGED
|
@@ -43,23 +43,31 @@ async function checkAndUpdate({ autoRestart = false } = {}) {
|
|
|
43
43
|
installUpdate(latest);
|
|
44
44
|
|
|
45
45
|
if (autoRestart) {
|
|
46
|
-
console.log('[updater] Restarting daemon...');
|
|
46
|
+
console.log('[updater] Restarting daemon with new version...');
|
|
47
47
|
const { spawn } = require('child_process');
|
|
48
|
-
const
|
|
48
|
+
const fs = require('fs');
|
|
49
|
+
const path = require('path');
|
|
50
|
+
const os = require('os');
|
|
49
51
|
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
: ['-c', 'sleep 2 && channel-worker start'];
|
|
52
|
+
const CONFIG_DIR = path.join(os.homedir(), '.channel-worker');
|
|
53
|
+
const pidFile = path.join(CONFIG_DIR, 'daemon.pid');
|
|
54
|
+
const LOG_FILE = path.join(CONFIG_DIR, 'daemon.log');
|
|
55
|
+
const logFd = fs.openSync(LOG_FILE, 'a');
|
|
55
56
|
|
|
56
|
-
|
|
57
|
+
// Spawn new daemon using global binary (picks up new version)
|
|
58
|
+
const isWindows = process.platform === 'win32';
|
|
59
|
+
const binName = isWindows ? 'channel-worker.cmd' : 'channel-worker';
|
|
60
|
+
const child = spawn(binName, ['start'], {
|
|
57
61
|
detached: true,
|
|
58
|
-
stdio: 'ignore',
|
|
59
|
-
cwd:
|
|
60
|
-
shell:
|
|
62
|
+
stdio: ['ignore', logFd, logFd],
|
|
63
|
+
cwd: os.homedir(),
|
|
64
|
+
shell: isWindows,
|
|
61
65
|
});
|
|
62
66
|
child.unref();
|
|
67
|
+
|
|
68
|
+
// Update PID file
|
|
69
|
+
fs.writeFileSync(pidFile, String(child.pid));
|
|
70
|
+
|
|
63
71
|
process.exit(0);
|
|
64
72
|
}
|
|
65
73
|
|