channel-worker 2.4.5 → 2.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 CHANGED
@@ -66,7 +66,9 @@ class ApiClient {
66
66
 
67
67
  // Commands
68
68
  async getNextCommand(workerId) {
69
- const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker';
69
+ // Daemon-handled types. `_pw` variants route to the Playwright pipeline
70
+ // (lib/playwright-runner → scripts/<base>.js) instead of the extension.
71
+ const workerTypes = 'launch_profile,close_profile,launch_veo3_profile,set_profile_proxy,save_file,set_thumbnail,set_tags,set_file_input,click_and_upload,type_text,verify_logins,update_extension,sync_youtube_stats,restart_worker,upload_youtube_pw,upload_tiktok_pw,upload_facebook_pw';
70
72
  return this.request('GET', `/workers/commands?worker_id=${workerId}&types=${encodeURIComponent(workerTypes)}`);
71
73
  }
72
74
 
@@ -74,6 +76,14 @@ class ApiClient {
74
76
  return this.request('PUT', `/workers/commands/${commandId}`, data);
75
77
  }
76
78
 
79
+ // Report a command result via the extension-style endpoint, which also
80
+ // mirrors per-platform status onto the ContentJob + ContentIdea (so the
81
+ // idea UI reflects pw-pipeline outcomes the same way it does for extension
82
+ // uploads). Use this for upload_*_pw results.
83
+ async postCommandResult(commandId, { status, result, error } = {}) {
84
+ return this.request('POST', `/extension/commands/${commandId}/result`, { status, result: result || {}, error: error || null });
85
+ }
86
+
77
87
  // Scene dispatch
78
88
  async getRenderers() {
79
89
  return this.request('GET', '/workers/renderers');
@@ -107,6 +117,20 @@ class ApiClient {
107
117
  return this.request('POST', '/workers/reset-renderer-commands', { nst_profile_id: nstProfileId });
108
118
  }
109
119
 
120
+ // Atomic acquire/refresh lease — daemon MUST call this and check success
121
+ // BEFORE invoking nst.launch(). Without the lease, two daemons sharing the
122
+ // same NST account could both open the same profile and corrupt sessions.
123
+ // Returns { success: bool, message?: string, data?: { nst_profile_id, leased_at, lessee } }.
124
+ async leaseRenderer(nstProfileId) {
125
+ return this.request('POST', '/veo3-workers/lease', { nst_profile_id: nstProfileId });
126
+ }
127
+
128
+ // Atomic release — call when daemon decides to close the profile (manual
129
+ // stop, parallel_limit drop, crash recovery, etc.).
130
+ async releaseRenderer(nstProfileId) {
131
+ return this.request('POST', '/veo3-workers/release', { nst_profile_id: nstProfileId });
132
+ }
133
+
110
134
  // Extension download
111
135
  async getExtensionVersion() {
112
136
  const data = await this.request('GET', '/extension-download/version');
@@ -125,6 +149,13 @@ class ApiClient {
125
149
  fs.writeFileSync(destPath, buffer);
126
150
  return destPath;
127
151
  }
152
+
153
+ // Ship a log line into the extension_log stream (same backend store the
154
+ // browser extension uses). Lets Playwright-script logs show up next to
155
+ // extension logs in the dashboard. Fire-and-forget — caller .catch()'s.
156
+ async postExtensionLog({ level = 'info', message = '', data = null, profile_id = '', job_id = null } = {}) {
157
+ return this.request('POST', '/extension/log', { level, message, data, profile_id, job_id });
158
+ }
128
159
  }
129
160
 
130
161
  module.exports = { ApiClient };
@@ -105,6 +105,14 @@ class CommandPoller {
105
105
  await this.handleSetProfileProxy(command);
106
106
  break;
107
107
  default:
108
+ // Playwright-based pipeline: any command whose type ends in '_pw'
109
+ // is routed to scripts/<base>.js (BrowserClaw-style automation
110
+ // attached to the NST profile via CDP). Keeps the legacy extension
111
+ // path alive for non-_pw commands during migration.
112
+ if (command.type.endsWith('_pw')) {
113
+ await this.handlePlaywrightCommand(command);
114
+ break;
115
+ }
108
116
  // Other commands (scan_facebook_pages, etc.) handled by extension
109
117
  console.log(`[commands] Skipping ${command.type} — handled by extension`);
110
118
  // Reset to pending so extension can pick it up
@@ -117,6 +125,61 @@ class CommandPoller {
117
125
  }
118
126
  }
119
127
 
128
+ // Playwright-based command dispatch (pipeline replacing channel-manager-ext
129
+ // for upload + page-management tasks). Command type 'upload_youtube_pw' maps
130
+ // to scripts/upload_youtube.js, etc. The script handles its own profile
131
+ // launch via NST + CDP attach; the daemon orchestrates + ships the result.
132
+ async handlePlaywrightCommand(command) {
133
+ const { runPlaywrightScript } = require('./playwright-runner');
134
+ const payload = command.payload || {};
135
+ const profileId = payload.profile_id;
136
+ if (!profileId) {
137
+ await this.api.updateCommand(command._id, { status: 'failed', error: 'no profile_id in payload' });
138
+ return;
139
+ }
140
+ // Strip the '_pw' suffix to derive the script name (upload_youtube_pw → upload_youtube).
141
+ const scriptName = command.type.replace(/_pw$/, '');
142
+ console.log(`[commands/pw] ${command.type} → scripts/${scriptName}.js (profile=${profileId})`);
143
+
144
+ // Lazy-init NST manager (same pattern as the other handlers).
145
+ if (!this.nst) {
146
+ try {
147
+ const apiKey = await this.api.getSetting('nst_api_key');
148
+ if (apiKey) { const NstManager = require('./nst-manager'); this.nst = new NstManager(apiKey); }
149
+ } catch {}
150
+ if (!this.nst) {
151
+ await this.api.updateCommand(command._id, { status: 'failed', error: 'NST API key not configured' });
152
+ return;
153
+ }
154
+ }
155
+
156
+ // Funnel progress/info into the same extension_log stream so the dashboard
157
+ // can show pw commands' logs the same way as extension logs.
158
+ const log = (level, message, data) => {
159
+ console.log(`[pw:${scriptName}] [${level}] ${message}`);
160
+ this.api.postExtensionLog?.({ level, message, data, profile_id: profileId, job_id: payload.job_id || null })
161
+ .catch(() => {});
162
+ };
163
+
164
+ try {
165
+ const result = await runPlaywrightScript({
166
+ nst: this.nst,
167
+ profileId,
168
+ scriptName,
169
+ payload,
170
+ log,
171
+ });
172
+ // postCommandResult (not updateCommand) so the existing extension-side
173
+ // logic mirrors status into ContentJob + ContentIdea.publish_results +
174
+ // broadcasts the WS update — pw and extension paths share idea-state UX.
175
+ await this.api.postCommandResult(command._id, { status: 'done', result: result || {} });
176
+ console.log(`[commands/pw] ${command.type} done`);
177
+ } catch (err) {
178
+ console.error(`[commands/pw] ${command.type} failed: ${err.message}`);
179
+ await this.api.postCommandResult(command._id, { status: 'failed', error: String(err.message || err).slice(0, 500) });
180
+ }
181
+ }
182
+
120
183
  async handleLaunchProfile(command) {
121
184
  const { profile_id, channel_id } = command.payload || {};
122
185
  console.log(`[commands] Launching Nstbrowser profile: ${profile_id}`);
@@ -150,13 +213,11 @@ class CommandPoller {
150
213
  const ts = Date.now();
151
214
  const uniqueExtPath = baseExtPath + '-' + profile_id + '-' + ts;
152
215
  try {
153
- fs.mkdirSync(uniqueExtPath, { recursive: true });
154
- const files = fs.readdirSync(baseExtPath);
155
- for (const f of files) {
156
- const src = path.join(baseExtPath, f);
157
- if (fs.statSync(src).isFile()) fs.copyFileSync(src, path.join(uniqueExtPath, f));
158
- }
159
- // Write profile-specific config.json
216
+ // Recursive copy base extension has subfolders (icons/, src/) that
217
+ // the manifest references; a file-only copy drops them and Chromium
218
+ // fails with "Could not load icon 'icons/icon16.png'".
219
+ fs.cpSync(baseExtPath, uniqueExtPath, { recursive: true });
220
+ // Write profile-specific config.json (overwrites the copied one)
160
221
  fs.writeFileSync(path.join(uniqueExtPath, 'config.json'), JSON.stringify({
161
222
  channelManagerApi: 'https://api.channel.tunasm.art',
162
223
  profileId: profile_id,
@@ -1037,7 +1098,18 @@ class CommandPoller {
1037
1098
  async handleCloseProfile(command) {
1038
1099
  const { profile_id } = command.payload || {};
1039
1100
  console.log(`[commands] Closing profile: ${profile_id}`);
1040
- // TODO: close Nstbrowser profile
1101
+ try {
1102
+ if (profile_id && this.nst && typeof this.nst.closeProfile === 'function') {
1103
+ await this.nst.closeProfile(profile_id).catch((e) => console.warn(`[commands] nst.closeProfile failed: ${e.message}`));
1104
+ }
1105
+ } finally {
1106
+ // Release the lease so another machine can pick up the profile if
1107
+ // user re-assigns ownership. Safe to call even if not currently held
1108
+ // (API no-ops when lessee mismatch).
1109
+ if (profile_id && this.api.releaseRenderer) {
1110
+ try { await this.api.releaseRenderer(profile_id); } catch {}
1111
+ }
1112
+ }
1041
1113
  await this.api.updateCommand(command._id, { status: 'done' });
1042
1114
  }
1043
1115
 
@@ -1312,6 +1384,20 @@ class CommandPoller {
1312
1384
  return;
1313
1385
  }
1314
1386
 
1387
+ // 6.5. Renew leases for ALL running renderers we manage. The lease has
1388
+ // a TTL (10 min server-side) — if we forget to renew, another
1389
+ // daemon could takeover after the TTL. Renewing every 5s poll
1390
+ // cycle keeps launched_at fresh well within the TTL.
1391
+ // Fire-and-forget; lease failures are logged but don't disrupt
1392
+ // dispatch (they're checked again at next _launchRendererProfile).
1393
+ if (this.api.leaseRenderer) {
1394
+ for (const r of runningRenderers) {
1395
+ this.api.leaseRenderer(r.nst_profile_id).catch((e) => {
1396
+ console.warn(`[scene-dispatch] lease renew failed for ${r.name || r.nst_profile_id}: ${e.message}`);
1397
+ });
1398
+ }
1399
+ }
1400
+
1315
1401
  // 7. Assign 1 command per free profile (no pending/running commands)
1316
1402
  let assigned = 0;
1317
1403
  for (const r of runningRenderers) {
@@ -1332,6 +1418,27 @@ class CommandPoller {
1332
1418
  }
1333
1419
 
1334
1420
  async _launchRendererProfile(renderer, options = {}) {
1421
+ // LEASE GUARD — acquire (or renew) the lease BEFORE physically launching
1422
+ // the Nstbrowser profile. The API atomic-update either grants or refuses;
1423
+ // if refused, another daemon (sharing the same NST account) currently
1424
+ // holds the profile and we must NOT touch it. Without this check the
1425
+ // two daemons would happily double-launch and corrupt cookies / kill
1426
+ // sessions for the underlying Google/FB accounts.
1427
+ if (renderer.nst_profile_id && this.api.leaseRenderer) {
1428
+ try {
1429
+ const leaseRes = await this.api.leaseRenderer(renderer.nst_profile_id);
1430
+ if (!leaseRes || leaseRes.success !== true) {
1431
+ const reason = leaseRes && leaseRes.message ? leaseRes.message : 'unknown';
1432
+ console.warn(`[scene-dispatch] LEASE DENIED for ${renderer.name || renderer.nst_profile_id}: ${reason} — skipping launch`);
1433
+ return;
1434
+ }
1435
+ } catch (e) {
1436
+ // Treat lease failure as a soft block — the API might be down OR
1437
+ // running an older version. Better to skip than risk double-launch.
1438
+ console.warn(`[scene-dispatch] lease request failed for ${renderer.name || renderer.nst_profile_id}: ${e.message} — skipping launch`);
1439
+ return;
1440
+ }
1441
+ }
1335
1442
  await this.nst.ensureProfile(renderer.nst_profile_id, { os: renderer.os || 'windows', proxy: renderer.proxy || null });
1336
1443
 
1337
1444
  const path = require('path');
@@ -0,0 +1,67 @@
1
+ // Runs a worker-script attached to an NSTBrowser profile over CDP.
2
+ // - Calls nst.launchProfile to get the live ws:// debugger URL.
3
+ // - playwright-core connectOverCDP — DOES NOT launch a new browser.
4
+ // - Script receives { page, context, browser, payload, log } and returns a result.
5
+ // - On exit we disconnect Playwright (browser.close on a CDP-attached instance is
6
+ // detach, NOT terminate) so the NST profile keeps running. The daemon's idle
7
+ // timer closes the profile later.
8
+ //
9
+ // IMPORTANT: do NOT inject stealth scripts here — NSTBrowser owns the
10
+ // fingerprint. Overlaying our own anti-detect would weaken it.
11
+
12
+ const path = require('path');
13
+ const fs = require('fs');
14
+
15
+ function loadScript(scriptName) {
16
+ // Allow only [a-z0-9_-] to avoid path traversal in case scriptName ever comes
17
+ // from a payload field. Resolved relative to ../scripts/.
18
+ if (!/^[a-zA-Z0-9_-]+$/.test(scriptName)) throw new Error(`bad script name: ${scriptName}`);
19
+ const scriptPath = path.resolve(__dirname, '..', 'scripts', `${scriptName}.js`);
20
+ if (!fs.existsSync(scriptPath)) throw new Error(`script not found: ${scriptPath}`);
21
+ // Clear cache so hot-edits to scripts on disk apply on the next dispatch
22
+ // without restarting the daemon (useful while tuning selectors).
23
+ delete require.cache[require.resolve(scriptPath)];
24
+ const mod = require(scriptPath);
25
+ if (typeof mod.run !== 'function') throw new Error(`${scriptName} has no exported run()`);
26
+ return mod;
27
+ }
28
+
29
+ async function runPlaywrightScript({ nst, profileId, scriptName, payload = {}, log = () => {} }) {
30
+ let playwright;
31
+ try { playwright = require('playwright-core'); }
32
+ catch (e) { throw new Error(`playwright-core not installed in channel-worker (npm i playwright-core in the worker dir): ${e.message}`); }
33
+
34
+ const script = loadScript(scriptName);
35
+
36
+ log('info', `[pw] launching NST profile ${profileId}…`);
37
+ // Don't load any Chromium extension — Playwright is the automation, no need
38
+ // for the channel-manager-ext on this path. (The chagpt cgpt path still
39
+ // launches with content-creator-ext via a different code path.)
40
+ const launchRes = await nst.launchProfile(profileId, { /* extensionPath omitted */ });
41
+ let wsEndpoint = launchRes?.wsEndpoint;
42
+
43
+ // If the profile was already running, launchProfile returns { alreadyRunning:
44
+ // true } without a wsEndpoint. Force-relaunch to get a fresh endpoint.
45
+ if (!wsEndpoint && launchRes?.alreadyRunning) {
46
+ log('info', `[pw] profile already running — force-relaunching for fresh CDP endpoint`);
47
+ const r = await nst.launchProfile(profileId, { forceRelaunch: true });
48
+ wsEndpoint = r?.wsEndpoint;
49
+ }
50
+ if (!wsEndpoint) throw new Error(`No webSocketDebuggerUrl from NST for profile ${profileId}`);
51
+
52
+ log('info', `[pw] connectOverCDP…`);
53
+ const browser = await playwright.chromium.connectOverCDP(wsEndpoint);
54
+ try {
55
+ const context = browser.contexts()[0] || await browser.newContext();
56
+ const page = context.pages()[0] || await context.newPage();
57
+ log('info', `[pw] running script: ${scriptName}`);
58
+ const result = await script.run({ page, context, browser, payload, log });
59
+ log('info', `[pw] script done: ${scriptName}`);
60
+ return result;
61
+ } finally {
62
+ // connectOverCDP: this DETACHES Playwright, the NST browser keeps running.
63
+ try { await browser.close(); } catch {}
64
+ }
65
+ }
66
+
67
+ module.exports = { runPlaywrightScript };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "channel-worker",
3
- "version": "2.4.5",
3
+ "version": "2.5.0",
4
4
  "description": "Channel Manager worker daemon — runs on remote machines to execute video pipeline jobs",
5
5
  "main": "lib/daemon.js",
6
6
  "bin": {
@@ -8,7 +8,8 @@
8
8
  },
9
9
  "files": [
10
10
  "bin/",
11
- "lib/"
11
+ "lib/",
12
+ "scripts/"
12
13
  ],
13
14
  "scripts": {
14
15
  "start": "node bin/cli.js start",
@@ -16,6 +17,7 @@
16
17
  },
17
18
  "dependencies": {
18
19
  "node-fetch": "^3.3.2",
20
+ "playwright-core": "^1.48.0",
19
21
  "ws": "^8.18.0"
20
22
  },
21
23
  "engines": {
@@ -0,0 +1,50 @@
1
+ // Stream a URL to a temp file on disk. Returns the absolute path.
2
+ // Used by upload scripts: Playwright's setInputFiles needs a local path, and
3
+ // the NST profile is local so the local file is reachable.
4
+
5
+ const fs = require('fs');
6
+ const os = require('os');
7
+ const path = require('path');
8
+ const https = require('https');
9
+ const http = require('http');
10
+
11
+ async function downloadToTemp(url, { prefix = 'pw', ext = '.mp4', maxRedirects = 5 } = {}) {
12
+ const dir = path.join(os.tmpdir(), 'cm-worker-pw');
13
+ try { fs.mkdirSync(dir, { recursive: true }); } catch {}
14
+ const dest = path.join(dir, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}${ext}`);
15
+
16
+ const fetchOnce = (u, redirectsLeft) => new Promise((resolve, reject) => {
17
+ const get = u.startsWith('https:') ? https.get : http.get;
18
+ const req = get(u, (res) => {
19
+ // Follow redirects manually — the media server signed URLs may bounce.
20
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
21
+ if (redirectsLeft <= 0) { reject(new Error('too many redirects')); return; }
22
+ res.resume();
23
+ const next = new URL(res.headers.location, u).toString();
24
+ fetchOnce(next, redirectsLeft - 1).then(resolve, reject);
25
+ return;
26
+ }
27
+ if (res.statusCode !== 200) {
28
+ let body = '';
29
+ res.on('data', (c) => { body += c.toString().slice(0, 200); });
30
+ res.on('end', () => reject(new Error(`download ${res.statusCode} from ${u}: ${body.slice(0, 200)}`)));
31
+ return;
32
+ }
33
+ const file = fs.createWriteStream(dest);
34
+ res.pipe(file);
35
+ file.on('finish', () => file.close(() => resolve(dest)));
36
+ file.on('error', (e) => { try { fs.unlinkSync(dest); } catch {} reject(e); });
37
+ });
38
+ req.on('error', reject);
39
+ req.setTimeout(60_000, () => req.destroy(new Error('download timeout')));
40
+ });
41
+
42
+ return fetchOnce(url, maxRedirects);
43
+ }
44
+
45
+ function safeUnlink(p) {
46
+ if (!p) return;
47
+ try { fs.unlinkSync(p); } catch {}
48
+ }
49
+
50
+ module.exports = { downloadToTemp, safeUnlink };
@@ -0,0 +1,85 @@
1
+ {
2
+ "_notes": "YouTube Studio upload selectors. Prefer accessibility (role/name) — patched in scripts via getByRole/getByLabel. Falls back to CSS for things Studio doesn't expose nicely (Polymer ytcp-* shadow DOM). Bump 'version' after each fix so it's clear in logs which set is live.",
3
+ "version": "2026.06.01-strict-inputs",
4
+ "studioUrl": "https://studio.youtube.com",
5
+ "createButton": {
6
+ "_notes": "2026 Studio dropped the #create-icon id — Create is now a plain <ytcp-button>Tạo</ytcp-button> (no id, no aria). Target the WRAPPER ytcp-button with exact text 'Tạo'/'Create' so click hits the Polymer handler that opens the dropdown (clicking the inner native button doesn't propagate the dropdown-open).",
7
+ "cssCandidates": [
8
+ "ytcp-button:text-is('Tạo')",
9
+ "ytcp-button:text-is('Create')",
10
+ "ytcp-button-shape:has(button:text-is('Tạo'))",
11
+ "ytcp-button-shape:has(button:text-is('Create'))",
12
+ "ytcp-icon-button#create-icon",
13
+ "ytcp-button#create-icon"
14
+ ],
15
+ "byRoleName": ["Create", "Tạo"]
16
+ },
17
+ "uploadMenuItem": {
18
+ "cssCandidates": [
19
+ "tp-yt-paper-item[role='menuitem']:has-text('Upload videos')",
20
+ "tp-yt-paper-item[role='menuitem']:has-text('Tải video lên')",
21
+ "ytcp-text-menu-item:has-text('Upload videos')",
22
+ "ytcp-text-menu-item:has-text('Tải video lên')",
23
+ "tp-yt-paper-item:has-text('Upload videos')",
24
+ "tp-yt-paper-item:has-text('Tải video lên')"
25
+ ],
26
+ "byMenuItemName": ["Upload videos", "Tải video lên"]
27
+ },
28
+ "fileInput": "input[type='file']",
29
+ "titleField": {
30
+ "cssCandidates": [
31
+ "ytcp-uploads-dialog #title-textarea #textbox",
32
+ "ytcp-uploads-dialog ytcp-mention-textbox[label='Title'] #textbox",
33
+ "#title-textarea #textbox"
34
+ ],
35
+ "byLabel": ["Title", "Tiêu đề"]
36
+ },
37
+ "descriptionField": {
38
+ "cssCandidates": [
39
+ "ytcp-uploads-dialog #description-textarea #textbox",
40
+ "ytcp-uploads-dialog ytcp-mention-textbox[label='Description'] #textbox",
41
+ "#description-textarea #textbox"
42
+ ],
43
+ "byLabel": ["Description", "Mô tả"]
44
+ },
45
+ "nextButton": {
46
+ "_notes": "Scope inside ytcp-uploads-dialog so we don't match Tiếp/Next from Studio's tutorial onboarding overlay (separate widget).",
47
+ "cssCandidates": [
48
+ "ytcp-uploads-dialog #next-button",
49
+ "ytcp-uploads-dialog ytcp-button#next-button",
50
+ "ytcp-uploads-dialog ytcp-button-shape #next-button"
51
+ ]
52
+ },
53
+ "visibilityRadio": {
54
+ "public": {
55
+ "cssCandidates": [
56
+ "ytcp-uploads-dialog tp-yt-paper-radio-button[name='PUBLIC']",
57
+ "tp-yt-paper-radio-button[name='PUBLIC']"
58
+ ]
59
+ },
60
+ "unlisted": {
61
+ "cssCandidates": [
62
+ "ytcp-uploads-dialog tp-yt-paper-radio-button[name='UNLISTED']",
63
+ "tp-yt-paper-radio-button[name='UNLISTED']"
64
+ ]
65
+ },
66
+ "private": {
67
+ "cssCandidates": [
68
+ "ytcp-uploads-dialog tp-yt-paper-radio-button[name='PRIVATE']",
69
+ "tp-yt-paper-radio-button[name='PRIVATE']"
70
+ ]
71
+ }
72
+ },
73
+ "publishButton": {
74
+ "cssCandidates": [
75
+ "ytcp-uploads-dialog #done-button",
76
+ "ytcp-uploads-dialog ytcp-button#done-button"
77
+ ]
78
+ },
79
+ "thumbnailUploadButton": {
80
+ "cssCandidates": [
81
+ "#file-loader",
82
+ "ytcp-thumbnails-compact-editor-uploader input[type='file']"
83
+ ]
84
+ }
85
+ }