channel-worker 1.6.1 → 1.6.2
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 +45 -53
- 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', {
|
|
@@ -46,7 +47,7 @@ class StatsSyncer {
|
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
scrapeViaCDP(wsEndpoint) {
|
|
50
|
+
scrapeViaCDP(wsEndpoint, youtubeHandle) {
|
|
50
51
|
return new Promise((resolve, reject) => {
|
|
51
52
|
const ws = new WebSocket(wsEndpoint);
|
|
52
53
|
let msgId = 1;
|
|
@@ -93,86 +94,77 @@ class StatsSyncer {
|
|
|
93
94
|
await s('Page.enable');
|
|
94
95
|
await s('Runtime.enable');
|
|
95
96
|
|
|
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];
|
|
97
|
+
// Determine the about URL
|
|
98
|
+
let aboutUrl;
|
|
99
|
+
if (youtubeHandle) {
|
|
100
|
+
// Direct: youtube.com/@handle/about
|
|
101
|
+
const handle = youtubeHandle.startsWith('@') ? youtubeHandle : '@' + youtubeHandle;
|
|
102
|
+
aboutUrl = `https://www.youtube.com/${handle}/about`;
|
|
111
103
|
} else {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
104
|
+
// Fallback: go to Studio first to get channel ID
|
|
105
|
+
console.log('[stats] No youtube_handle, falling back to Studio...');
|
|
106
|
+
await s('Page.navigate', { url: 'https://studio.youtube.com' });
|
|
107
|
+
await this.waitForLoad(s);
|
|
108
|
+
await this.sleep(4000);
|
|
109
|
+
|
|
110
|
+
const urlResult = await s('Runtime.evaluate', { expression: 'window.location.href', returnByValue: true });
|
|
111
|
+
const studioUrl = urlResult?.result?.value || '';
|
|
112
|
+
const cidMatch = studioUrl.match(/channel\/(UC[\w-]+)/);
|
|
113
|
+
let cid = cidMatch ? cidMatch[1] : null;
|
|
114
|
+
|
|
115
|
+
if (!cid) {
|
|
116
|
+
const cfgResult = await s('Runtime.evaluate', {
|
|
117
|
+
expression: '(window.ytcfg && window.ytcfg.get) ? window.ytcfg.get("CHANNEL_ID") : ""',
|
|
118
|
+
returnByValue: true,
|
|
119
|
+
});
|
|
120
|
+
cid = cfgResult?.result?.value || null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (!cid) throw new Error('Could not find channel ID from YouTube Studio');
|
|
124
|
+
aboutUrl = `https://www.youtube.com/channel/${cid}/about`;
|
|
117
125
|
}
|
|
118
126
|
|
|
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`;
|
|
127
|
+
// Navigate directly to about page
|
|
126
128
|
console.log(`[stats] Navigating to: ${aboutUrl}`);
|
|
127
129
|
await s('Page.navigate', { url: aboutUrl });
|
|
128
130
|
await this.waitForLoad(s);
|
|
129
131
|
await this.sleep(4000);
|
|
130
132
|
|
|
131
|
-
//
|
|
133
|
+
// Read #additional-info-container table
|
|
132
134
|
const result = await s('Runtime.evaluate', {
|
|
133
135
|
expression: `
|
|
134
136
|
(function() {
|
|
135
137
|
var stats = { subscribers: -1, total_views: -1, video_count: -1, debug: '' };
|
|
136
138
|
|
|
137
|
-
// Find the table inside #additional-info-container
|
|
138
139
|
var container = document.querySelector('#additional-info-container');
|
|
139
140
|
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);
|
|
141
|
+
stats.debug = 'no #additional-info-container';
|
|
144
142
|
return JSON.stringify(stats);
|
|
145
143
|
}
|
|
146
144
|
|
|
147
145
|
var table = container.querySelector('table');
|
|
148
146
|
if (!table) {
|
|
149
|
-
stats.debug = 'no table
|
|
147
|
+
stats.debug = 'no table, text: ' + container.innerText.substring(0, 300);
|
|
150
148
|
return JSON.stringify(stats);
|
|
151
149
|
}
|
|
152
150
|
|
|
153
|
-
// Each row has: icon cell + text cell (e.g. "1 subscriber", "3 videos", "743 views")
|
|
154
151
|
var rows = table.querySelectorAll('tr');
|
|
155
152
|
var texts = [];
|
|
156
153
|
rows.forEach(function(row) {
|
|
157
154
|
var text = row.innerText.trim();
|
|
158
155
|
texts.push(text);
|
|
159
156
|
|
|
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
157
|
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
158
|
var suffixMatch = text.match(/([\\d,\\.]+)\\s*([KMB])/i);
|
|
170
159
|
if (suffixMatch) {
|
|
171
160
|
var n = parseFloat(suffixMatch[1].replace(/,/g, ''));
|
|
172
|
-
var
|
|
173
|
-
if (
|
|
174
|
-
else if (
|
|
175
|
-
else if (
|
|
161
|
+
var sx = suffixMatch[2].toUpperCase();
|
|
162
|
+
if (sx === 'K') num = Math.round(n * 1000);
|
|
163
|
+
else if (sx === 'M') num = Math.round(n * 1000000);
|
|
164
|
+
else if (sx === 'B') num = Math.round(n * 1000000000);
|
|
165
|
+
} else {
|
|
166
|
+
var numMatch = text.match(/([\\d,]+)/);
|
|
167
|
+
if (numMatch) num = parseInt(numMatch[1].replace(/,/g, ''), 10);
|
|
176
168
|
}
|
|
177
169
|
|
|
178
170
|
if (/subscriber|đăng ký/i.test(text)) {
|
|
@@ -194,12 +186,12 @@ class StatsSyncer {
|
|
|
194
186
|
var stats = { subscribers: 0, total_views: 0, video_count: 0 };
|
|
195
187
|
try {
|
|
196
188
|
var parsed = JSON.parse(result?.result?.value || '{}');
|
|
197
|
-
console.log(
|
|
189
|
+
console.log('[stats] About page data:', parsed.debug);
|
|
198
190
|
if (parsed.subscribers >= 0) stats.subscribers = parsed.subscribers;
|
|
199
191
|
if (parsed.total_views >= 0) stats.total_views = parsed.total_views;
|
|
200
192
|
if (parsed.video_count >= 0) stats.video_count = parsed.video_count;
|
|
201
193
|
} catch (e) {
|
|
202
|
-
console.error(
|
|
194
|
+
console.error('[stats] Parse error:', e.message);
|
|
203
195
|
}
|
|
204
196
|
|
|
205
197
|
clearTimeout(timeout);
|