channel-worker 1.6.1 → 1.6.3
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/command-poller.js +5 -2
- package/lib/stats-syncer.js +54 -54
- package/package.json +1 -1
package/lib/command-poller.js
CHANGED
|
@@ -899,8 +899,8 @@ class CommandPoller {
|
|
|
899
899
|
}
|
|
900
900
|
|
|
901
901
|
async handleSyncYoutubeStats(command) {
|
|
902
|
-
const { channel_id } = command.payload || {};
|
|
903
|
-
console.log(`[commands] Syncing YouTube stats for channel: ${channel_id}`);
|
|
902
|
+
const { channel_id, youtube_handle } = command.payload || {};
|
|
903
|
+
console.log(`[commands] Syncing YouTube stats for channel: ${channel_id} (handle: ${youtube_handle || 'none'})`);
|
|
904
904
|
|
|
905
905
|
try {
|
|
906
906
|
if (!this.nst) {
|
|
@@ -917,6 +917,9 @@ class CommandPoller {
|
|
|
917
917
|
if (!channel) throw new Error(`Channel ${channel_id} not found`);
|
|
918
918
|
if (!channel.nst_profile_id) throw new Error(`Channel "${channel.name}" has no Nstbrowser profile`);
|
|
919
919
|
|
|
920
|
+
// Pass youtube_handle from command payload
|
|
921
|
+
channel.youtube_handle = youtube_handle || null;
|
|
922
|
+
|
|
920
923
|
const syncer = new StatsSyncer(this.nst, this.api);
|
|
921
924
|
const stats = await syncer.syncYouTubeStats(channel);
|
|
922
925
|
|
package/lib/stats-syncer.js
CHANGED
|
@@ -2,8 +2,7 @@ const WebSocket = require('ws');
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Sync YouTube stats via Nstbrowser CDP.
|
|
5
|
-
* Flow: Launch profile →
|
|
6
|
-
* navigate to channel About page → read #additional-info-container table
|
|
5
|
+
* Flow: Launch profile → navigate to youtube.com/@handle/about → read stats table
|
|
7
6
|
*/
|
|
8
7
|
class StatsSyncer {
|
|
9
8
|
constructor(nstManager, apiClient) {
|
|
@@ -23,8 +22,10 @@ class StatsSyncer {
|
|
|
23
22
|
|
|
24
23
|
if (!wsEndpoint) throw new Error('No wsEndpoint returned from Nstbrowser');
|
|
25
24
|
|
|
25
|
+
const youtubeHandle = channel.youtube_handle;
|
|
26
|
+
|
|
26
27
|
try {
|
|
27
|
-
const stats = await this.scrapeViaCDP(wsEndpoint);
|
|
28
|
+
const stats = await this.scrapeViaCDP(wsEndpoint, youtubeHandle);
|
|
28
29
|
console.log(`[stats] "${channel.name}" → subs: ${stats.subscribers}, views: ${stats.total_views}, videos: ${stats.video_count}`);
|
|
29
30
|
|
|
30
31
|
await this.api.request('POST', '/analytics/sync', {
|
|
@@ -33,6 +34,7 @@ class StatsSyncer {
|
|
|
33
34
|
subscribers: stats.subscribers,
|
|
34
35
|
total_views: stats.total_views,
|
|
35
36
|
video_count: stats.video_count,
|
|
37
|
+
avatar_url: stats.avatar_url,
|
|
36
38
|
});
|
|
37
39
|
|
|
38
40
|
console.log(`[stats] "${channel.name}" stats saved to API`);
|
|
@@ -46,7 +48,7 @@ class StatsSyncer {
|
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
|
|
49
|
-
scrapeViaCDP(wsEndpoint) {
|
|
51
|
+
scrapeViaCDP(wsEndpoint, youtubeHandle) {
|
|
50
52
|
return new Promise((resolve, reject) => {
|
|
51
53
|
const ws = new WebSocket(wsEndpoint);
|
|
52
54
|
let msgId = 1;
|
|
@@ -93,86 +95,77 @@ class StatsSyncer {
|
|
|
93
95
|
await s('Page.enable');
|
|
94
96
|
await s('Runtime.enable');
|
|
95
97
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
const urlResult = await s('Runtime.evaluate', { expression: 'window.location.href', returnByValue: true });
|
|
103
|
-
const studioUrl = urlResult?.result?.value || '';
|
|
104
|
-
console.log(`[stats] Studio URL: ${studioUrl}`);
|
|
105
|
-
|
|
106
|
-
// Extract channel ID
|
|
107
|
-
let cid = null;
|
|
108
|
-
const cidMatch = studioUrl.match(/channel\/(UC[\w-]+)/);
|
|
109
|
-
if (cidMatch) {
|
|
110
|
-
cid = cidMatch[1];
|
|
98
|
+
// Determine the about URL
|
|
99
|
+
let aboutUrl;
|
|
100
|
+
if (youtubeHandle) {
|
|
101
|
+
// Direct: youtube.com/@handle/about
|
|
102
|
+
const handle = youtubeHandle.startsWith('@') ? youtubeHandle : '@' + youtubeHandle;
|
|
103
|
+
aboutUrl = `https://www.youtube.com/${handle}/about`;
|
|
111
104
|
} else {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
105
|
+
// Fallback: go to Studio first to get channel ID
|
|
106
|
+
console.log('[stats] No youtube_handle, falling back to Studio...');
|
|
107
|
+
await s('Page.navigate', { url: 'https://studio.youtube.com' });
|
|
108
|
+
await this.waitForLoad(s);
|
|
109
|
+
await this.sleep(4000);
|
|
110
|
+
|
|
111
|
+
const urlResult = await s('Runtime.evaluate', { expression: 'window.location.href', returnByValue: true });
|
|
112
|
+
const studioUrl = urlResult?.result?.value || '';
|
|
113
|
+
const cidMatch = studioUrl.match(/channel\/(UC[\w-]+)/);
|
|
114
|
+
let cid = cidMatch ? cidMatch[1] : null;
|
|
115
|
+
|
|
116
|
+
if (!cid) {
|
|
117
|
+
const cfgResult = await s('Runtime.evaluate', {
|
|
118
|
+
expression: '(window.ytcfg && window.ytcfg.get) ? window.ytcfg.get("CHANNEL_ID") : ""',
|
|
119
|
+
returnByValue: true,
|
|
120
|
+
});
|
|
121
|
+
cid = cfgResult?.result?.value || null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (!cid) throw new Error('Could not find channel ID from YouTube Studio');
|
|
125
|
+
aboutUrl = `https://www.youtube.com/channel/${cid}/about`;
|
|
117
126
|
}
|
|
118
127
|
|
|
119
|
-
|
|
120
|
-
throw new Error('Could not find channel ID from YouTube Studio');
|
|
121
|
-
}
|
|
122
|
-
console.log(`[stats] Channel ID: ${cid}`);
|
|
123
|
-
|
|
124
|
-
// Step 2: Navigate to channel About page
|
|
125
|
-
const aboutUrl = `https://www.youtube.com/channel/${cid}/about`;
|
|
128
|
+
// Navigate directly to about page
|
|
126
129
|
console.log(`[stats] Navigating to: ${aboutUrl}`);
|
|
127
130
|
await s('Page.navigate', { url: aboutUrl });
|
|
128
131
|
await this.waitForLoad(s);
|
|
129
132
|
await this.sleep(4000);
|
|
130
133
|
|
|
131
|
-
//
|
|
134
|
+
// Read #additional-info-container table
|
|
132
135
|
const result = await s('Runtime.evaluate', {
|
|
133
136
|
expression: `
|
|
134
137
|
(function() {
|
|
135
138
|
var stats = { subscribers: -1, total_views: -1, video_count: -1, debug: '' };
|
|
136
139
|
|
|
137
|
-
// Find the table inside #additional-info-container
|
|
138
140
|
var container = document.querySelector('#additional-info-container');
|
|
139
141
|
if (!container) {
|
|
140
|
-
stats.debug = 'no #additional-info-container
|
|
141
|
-
// Fallback: try to find stats from page text
|
|
142
|
-
var text = document.body ? document.body.innerText : '';
|
|
143
|
-
stats.debug = text.substring(0, 500);
|
|
142
|
+
stats.debug = 'no #additional-info-container';
|
|
144
143
|
return JSON.stringify(stats);
|
|
145
144
|
}
|
|
146
145
|
|
|
147
146
|
var table = container.querySelector('table');
|
|
148
147
|
if (!table) {
|
|
149
|
-
stats.debug = 'no table
|
|
148
|
+
stats.debug = 'no table, text: ' + container.innerText.substring(0, 300);
|
|
150
149
|
return JSON.stringify(stats);
|
|
151
150
|
}
|
|
152
151
|
|
|
153
|
-
// Each row has: icon cell + text cell (e.g. "1 subscriber", "3 videos", "743 views")
|
|
154
152
|
var rows = table.querySelectorAll('tr');
|
|
155
153
|
var texts = [];
|
|
156
154
|
rows.forEach(function(row) {
|
|
157
155
|
var text = row.innerText.trim();
|
|
158
156
|
texts.push(text);
|
|
159
157
|
|
|
160
|
-
// Match patterns: "X subscriber(s)", "X video(s)", "X view(s)"
|
|
161
|
-
// Also Vietnamese: "X người đăng ký", "X video", "X lượt xem"
|
|
162
158
|
var num = 0;
|
|
163
|
-
var numMatch = text.match(/([\\d,\\.]+)/);
|
|
164
|
-
if (numMatch) {
|
|
165
|
-
num = parseInt(numMatch[1].replace(/,/g, ''), 10);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
// Check for K/M/B suffix
|
|
169
159
|
var suffixMatch = text.match(/([\\d,\\.]+)\\s*([KMB])/i);
|
|
170
160
|
if (suffixMatch) {
|
|
171
161
|
var n = parseFloat(suffixMatch[1].replace(/,/g, ''));
|
|
172
|
-
var
|
|
173
|
-
if (
|
|
174
|
-
else if (
|
|
175
|
-
else if (
|
|
162
|
+
var sx = suffixMatch[2].toUpperCase();
|
|
163
|
+
if (sx === 'K') num = Math.round(n * 1000);
|
|
164
|
+
else if (sx === 'M') num = Math.round(n * 1000000);
|
|
165
|
+
else if (sx === 'B') num = Math.round(n * 1000000000);
|
|
166
|
+
} else {
|
|
167
|
+
var numMatch = text.match(/([\\d,]+)/);
|
|
168
|
+
if (numMatch) num = parseInt(numMatch[1].replace(/,/g, ''), 10);
|
|
176
169
|
}
|
|
177
170
|
|
|
178
171
|
if (/subscriber|đăng ký/i.test(text)) {
|
|
@@ -184,6 +177,12 @@ class StatsSyncer {
|
|
|
184
177
|
}
|
|
185
178
|
});
|
|
186
179
|
|
|
180
|
+
// Get channel avatar
|
|
181
|
+
var avatarEl = document.querySelector('#channel-header-container img, yt-decorated-avatar img, yt-avatar-shape img');
|
|
182
|
+
if (avatarEl && avatarEl.src) {
|
|
183
|
+
stats.avatar_url = avatarEl.src;
|
|
184
|
+
}
|
|
185
|
+
|
|
187
186
|
stats.debug = texts.join(' | ');
|
|
188
187
|
return JSON.stringify(stats);
|
|
189
188
|
})()
|
|
@@ -191,15 +190,16 @@ class StatsSyncer {
|
|
|
191
190
|
returnByValue: true,
|
|
192
191
|
});
|
|
193
192
|
|
|
194
|
-
var stats = { subscribers: 0, total_views: 0, video_count: 0 };
|
|
193
|
+
var stats = { subscribers: 0, total_views: 0, video_count: 0, avatar_url: null };
|
|
195
194
|
try {
|
|
196
195
|
var parsed = JSON.parse(result?.result?.value || '{}');
|
|
197
|
-
console.log(
|
|
196
|
+
console.log('[stats] About page data:', parsed.debug);
|
|
198
197
|
if (parsed.subscribers >= 0) stats.subscribers = parsed.subscribers;
|
|
199
198
|
if (parsed.total_views >= 0) stats.total_views = parsed.total_views;
|
|
200
199
|
if (parsed.video_count >= 0) stats.video_count = parsed.video_count;
|
|
200
|
+
if (parsed.avatar_url) stats.avatar_url = parsed.avatar_url;
|
|
201
201
|
} catch (e) {
|
|
202
|
-
console.error(
|
|
202
|
+
console.error('[stats] Parse error:', e.message);
|
|
203
203
|
}
|
|
204
204
|
|
|
205
205
|
clearTimeout(timeout);
|