channel-worker 2.4.5 → 2.5.1
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 +39 -1
- package/lib/command-poller.js +138 -18
- package/lib/playwright-runner.js +67 -0
- package/package.json +4 -2
- package/scripts/lib/download.js +50 -0
- package/scripts/selectors/youtube.json +85 -0
- package/scripts/upload_facebook.js +1754 -0
- package/scripts/upload_youtube.js +1151 -0
package/lib/api-client.js
CHANGED
|
@@ -66,7 +66,9 @@ class ApiClient {
|
|
|
66
66
|
|
|
67
67
|
// Commands
|
|
68
68
|
async getNextCommand(workerId) {
|
|
69
|
-
|
|
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,21 @@ 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
|
+
|
|
87
|
+
// Return the calling daemon's own Worker doc — primarily for reading
|
|
88
|
+
// parallel_limit (the per-daemon scene-generation concurrency cap that
|
|
89
|
+
// replaced the legacy global flowkit_max_concurrent setting).
|
|
90
|
+
async getMyWorker() {
|
|
91
|
+
return this.request('GET', '/workers/me');
|
|
92
|
+
}
|
|
93
|
+
|
|
77
94
|
// Scene dispatch
|
|
78
95
|
async getRenderers() {
|
|
79
96
|
return this.request('GET', '/workers/renderers');
|
|
@@ -107,6 +124,20 @@ class ApiClient {
|
|
|
107
124
|
return this.request('POST', '/workers/reset-renderer-commands', { nst_profile_id: nstProfileId });
|
|
108
125
|
}
|
|
109
126
|
|
|
127
|
+
// Atomic acquire/refresh lease — daemon MUST call this and check success
|
|
128
|
+
// BEFORE invoking nst.launch(). Without the lease, two daemons sharing the
|
|
129
|
+
// same NST account could both open the same profile and corrupt sessions.
|
|
130
|
+
// Returns { success: bool, message?: string, data?: { nst_profile_id, leased_at, lessee } }.
|
|
131
|
+
async leaseRenderer(nstProfileId) {
|
|
132
|
+
return this.request('POST', '/veo3-workers/lease', { nst_profile_id: nstProfileId });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Atomic release — call when daemon decides to close the profile (manual
|
|
136
|
+
// stop, parallel_limit drop, crash recovery, etc.).
|
|
137
|
+
async releaseRenderer(nstProfileId) {
|
|
138
|
+
return this.request('POST', '/veo3-workers/release', { nst_profile_id: nstProfileId });
|
|
139
|
+
}
|
|
140
|
+
|
|
110
141
|
// Extension download
|
|
111
142
|
async getExtensionVersion() {
|
|
112
143
|
const data = await this.request('GET', '/extension-download/version');
|
|
@@ -125,6 +156,13 @@ class ApiClient {
|
|
|
125
156
|
fs.writeFileSync(destPath, buffer);
|
|
126
157
|
return destPath;
|
|
127
158
|
}
|
|
159
|
+
|
|
160
|
+
// Ship a log line into the extension_log stream (same backend store the
|
|
161
|
+
// browser extension uses). Lets Playwright-script logs show up next to
|
|
162
|
+
// extension logs in the dashboard. Fire-and-forget — caller .catch()'s.
|
|
163
|
+
async postExtensionLog({ level = 'info', message = '', data = null, profile_id = '', job_id = null } = {}) {
|
|
164
|
+
return this.request('POST', '/extension/log', { level, message, data, profile_id, job_id });
|
|
165
|
+
}
|
|
128
166
|
}
|
|
129
167
|
|
|
130
168
|
module.exports = { ApiClient };
|
package/lib/command-poller.js
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -1241,16 +1313,29 @@ class CommandPoller {
|
|
|
1241
1313
|
const domQ = typeof queueInfo === 'number' ? queueCount : (queueInfo?.dom_count || 0);
|
|
1242
1314
|
if (!queueCount) { this._dispatching = false; return; }
|
|
1243
1315
|
|
|
1244
|
-
// 5.
|
|
1245
|
-
//
|
|
1246
|
-
//
|
|
1247
|
-
//
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1316
|
+
// 5. Parallel limit — sourced from THIS daemon's Worker.parallel_limit
|
|
1317
|
+
// via /workers/me. Replaces the legacy global Settings
|
|
1318
|
+
// (flowkit_max_concurrent / veo3_parallel_limit) which throttled
|
|
1319
|
+
// across all daemons of a user — making multi-machine setup
|
|
1320
|
+
// impossible to scale (set global=1 → only one machine runs at a
|
|
1321
|
+
// time). Server-side enforcement in /scene-dispatch +
|
|
1322
|
+
// /flowkit/claim-command uses the same field, so a daemon trying
|
|
1323
|
+
// to over-launch profiles will simply get yielded responses on
|
|
1324
|
+
// overflow. We still cap launches here to avoid keeping idle
|
|
1325
|
+
// profiles open (each NST profile holds RAM + a Chromium tab).
|
|
1326
|
+
let parallelLimit = 2; // sane default if /workers/me fails or is
|
|
1327
|
+
// unavailable on an older API; effective limit
|
|
1328
|
+
// is still bounded by server-side enforcement.
|
|
1329
|
+
try {
|
|
1330
|
+
if (this.api.getMyWorker) {
|
|
1331
|
+
const me = await this.api.getMyWorker();
|
|
1332
|
+
if (me && typeof me.parallel_limit === 'number') {
|
|
1333
|
+
parallelLimit = Math.max(0, me.parallel_limit);
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
} catch (e) {
|
|
1337
|
+
if (this.config.verbose) console.warn('[scene-dispatch] getMyWorker failed, using default:', e.message);
|
|
1338
|
+
}
|
|
1254
1339
|
const stillOffline = renderers.filter(r => !runningRenderers.includes(r));
|
|
1255
1340
|
console.log(`[scene-dispatch] running=${runningRenderers.length}/${parallelLimit} (flowkit=${flowkitQ} dom=${domQ}) offline=${stillOffline.length} queue=${queueCount} names=[${runningRenderers.map(r=>r.name)}]`);
|
|
1256
1341
|
|
|
@@ -1312,6 +1397,20 @@ class CommandPoller {
|
|
|
1312
1397
|
return;
|
|
1313
1398
|
}
|
|
1314
1399
|
|
|
1400
|
+
// 6.5. Renew leases for ALL running renderers we manage. The lease has
|
|
1401
|
+
// a TTL (10 min server-side) — if we forget to renew, another
|
|
1402
|
+
// daemon could takeover after the TTL. Renewing every 5s poll
|
|
1403
|
+
// cycle keeps launched_at fresh well within the TTL.
|
|
1404
|
+
// Fire-and-forget; lease failures are logged but don't disrupt
|
|
1405
|
+
// dispatch (they're checked again at next _launchRendererProfile).
|
|
1406
|
+
if (this.api.leaseRenderer) {
|
|
1407
|
+
for (const r of runningRenderers) {
|
|
1408
|
+
this.api.leaseRenderer(r.nst_profile_id).catch((e) => {
|
|
1409
|
+
console.warn(`[scene-dispatch] lease renew failed for ${r.name || r.nst_profile_id}: ${e.message}`);
|
|
1410
|
+
});
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1315
1414
|
// 7. Assign 1 command per free profile (no pending/running commands)
|
|
1316
1415
|
let assigned = 0;
|
|
1317
1416
|
for (const r of runningRenderers) {
|
|
@@ -1332,6 +1431,27 @@ class CommandPoller {
|
|
|
1332
1431
|
}
|
|
1333
1432
|
|
|
1334
1433
|
async _launchRendererProfile(renderer, options = {}) {
|
|
1434
|
+
// LEASE GUARD — acquire (or renew) the lease BEFORE physically launching
|
|
1435
|
+
// the Nstbrowser profile. The API atomic-update either grants or refuses;
|
|
1436
|
+
// if refused, another daemon (sharing the same NST account) currently
|
|
1437
|
+
// holds the profile and we must NOT touch it. Without this check the
|
|
1438
|
+
// two daemons would happily double-launch and corrupt cookies / kill
|
|
1439
|
+
// sessions for the underlying Google/FB accounts.
|
|
1440
|
+
if (renderer.nst_profile_id && this.api.leaseRenderer) {
|
|
1441
|
+
try {
|
|
1442
|
+
const leaseRes = await this.api.leaseRenderer(renderer.nst_profile_id);
|
|
1443
|
+
if (!leaseRes || leaseRes.success !== true) {
|
|
1444
|
+
const reason = leaseRes && leaseRes.message ? leaseRes.message : 'unknown';
|
|
1445
|
+
console.warn(`[scene-dispatch] LEASE DENIED for ${renderer.name || renderer.nst_profile_id}: ${reason} — skipping launch`);
|
|
1446
|
+
return;
|
|
1447
|
+
}
|
|
1448
|
+
} catch (e) {
|
|
1449
|
+
// Treat lease failure as a soft block — the API might be down OR
|
|
1450
|
+
// running an older version. Better to skip than risk double-launch.
|
|
1451
|
+
console.warn(`[scene-dispatch] lease request failed for ${renderer.name || renderer.nst_profile_id}: ${e.message} — skipping launch`);
|
|
1452
|
+
return;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1335
1455
|
await this.nst.ensureProfile(renderer.nst_profile_id, { os: renderer.os || 'windows', proxy: renderer.proxy || null });
|
|
1336
1456
|
|
|
1337
1457
|
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.
|
|
3
|
+
"version": "2.5.1",
|
|
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
|
+
}
|