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
|
@@ -0,0 +1,1151 @@
|
|
|
1
|
+
// YouTube Studio upload flow via Playwright attached to NSTBrowser (CDP).
|
|
2
|
+
//
|
|
3
|
+
// Payload fields (matches what publishIdea builds for upload_youtube):
|
|
4
|
+
// video_url, title, description, tags[], visibility ('public'|'unlisted'|'private'),
|
|
5
|
+
// thumbnail_url, account_name, account_url, job_id.
|
|
6
|
+
//
|
|
7
|
+
// Selectors are pulled from selectors/youtube.json (hot-reloadable). We prefer
|
|
8
|
+
// accessibility (getByRole/getByLabel + Vietnamese fallback) and only fall
|
|
9
|
+
// back to CSS for shadow-DOM custom elements YouTube doesn't expose via ARIA.
|
|
10
|
+
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { downloadToTemp, safeUnlink } = require('./lib/download');
|
|
14
|
+
|
|
15
|
+
const SELECTORS_PATH = path.join(__dirname, 'selectors', 'youtube.json');
|
|
16
|
+
|
|
17
|
+
function loadSelectors() {
|
|
18
|
+
// Re-read on each invocation so hotfixing selectors.json doesn't need a
|
|
19
|
+
// daemon restart.
|
|
20
|
+
return JSON.parse(fs.readFileSync(SELECTORS_PATH, 'utf8'));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Pick the first VISIBLE match among the locator's results (up to 5 to keep
|
|
24
|
+
// the scan cheap). Studio's Polymer markup keeps hidden duplicates in the DOM
|
|
25
|
+
// — count() > 0 isn't enough; we must require visibility or click() will hang
|
|
26
|
+
// waiting for the hidden one to become actionable.
|
|
27
|
+
async function firstVisible(locator, max = 5) {
|
|
28
|
+
const n = Math.min(await locator.count().catch(() => 0), max);
|
|
29
|
+
for (let i = 0; i < n; i++) {
|
|
30
|
+
if (await locator.nth(i).isVisible().catch(() => false)) return locator.nth(i);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Check if an element is interactive — not just visible, but also enabled. The
|
|
36
|
+
// Next button in Studio's wizard stays disabled until the video upload validates
|
|
37
|
+
// (a few seconds to a couple minutes for large files), so a plain `.click()`
|
|
38
|
+
// will hang waiting. Polymer custom elements often use `aria-disabled="true"`
|
|
39
|
+
// instead of the native `disabled` attribute; check both.
|
|
40
|
+
async function isActionable(el) {
|
|
41
|
+
if (!(await el.isVisible().catch(() => false))) return false;
|
|
42
|
+
if (!(await el.isEnabled().catch(() => true))) return false;
|
|
43
|
+
const ariaDisabled = await el.getAttribute('aria-disabled').catch(() => null);
|
|
44
|
+
if (ariaDisabled === 'true') return false;
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Wait for a located button to become actionable (visible + enabled) and then
|
|
49
|
+
// click. Used for transitions like Studio's Next which is disabled during
|
|
50
|
+
// video upload — the button is in the DOM the whole time, but only clickable
|
|
51
|
+
// once Studio finishes its validation.
|
|
52
|
+
async function waitAndClick(loc, { timeoutMs = 120_000, log, label = 'button' } = {}) {
|
|
53
|
+
const deadline = Date.now() + timeoutMs;
|
|
54
|
+
while (Date.now() < deadline) {
|
|
55
|
+
if (await isActionable(loc)) {
|
|
56
|
+
await loc.click();
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
await loc.page().waitForTimeout(800);
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`${label} never became actionable within ${timeoutMs}ms`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Resolve an interactive element. Tries CSS candidates FIRST (more specific —
|
|
65
|
+
// scoped to the upload modal), THEN accessibility-based matches as fallback.
|
|
66
|
+
// The original "role first" order was matching wrong Tiếp/Next buttons in
|
|
67
|
+
// Studio's tutorial overlays before the actual wizard Next button.
|
|
68
|
+
async function locateButton(page, spec, { timeoutMs = 15000 } = {}) {
|
|
69
|
+
const deadline = Date.now() + timeoutMs;
|
|
70
|
+
const probe = async () => {
|
|
71
|
+
if (Array.isArray(spec?.cssCandidates)) {
|
|
72
|
+
for (const css of spec.cssCandidates) {
|
|
73
|
+
try {
|
|
74
|
+
const hit = await firstVisible(page.locator(css));
|
|
75
|
+
if (hit) return hit;
|
|
76
|
+
} catch { /* invalid selector for this Playwright version — try next */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (Array.isArray(spec?.byRoleName)) {
|
|
80
|
+
for (const name of spec.byRoleName) {
|
|
81
|
+
const hit = await firstVisible(page.getByRole('button', { name: new RegExp(name, 'i') }));
|
|
82
|
+
if (hit) return hit;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (Array.isArray(spec?.byMenuItemName)) {
|
|
86
|
+
for (const name of spec.byMenuItemName) {
|
|
87
|
+
const hit = await firstVisible(page.getByRole('menuitem', { name: new RegExp(name, 'i') }));
|
|
88
|
+
if (hit) return hit;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (Array.isArray(spec?.byLabel)) {
|
|
92
|
+
for (const label of spec.byLabel) {
|
|
93
|
+
const hit = await firstVisible(page.getByLabel(new RegExp(label, 'i')));
|
|
94
|
+
if (hit) return hit;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
};
|
|
99
|
+
while (Date.now() < deadline) {
|
|
100
|
+
const hit = await probe();
|
|
101
|
+
if (hit) return hit;
|
|
102
|
+
await page.waitForTimeout(400);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`button not found (no visible match): ${JSON.stringify(spec).slice(0, 200)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// On failure dump everything we need to debug remotely: screenshot, URL/title,
|
|
108
|
+
// body head, AND the list of visible interactive elements (buttons + menu items)
|
|
109
|
+
// with their id/text/aria-label so we can patch selectors without ssh-ing into
|
|
110
|
+
// the win-worker browser. Best-effort: any sub-step failure just logs and moves
|
|
111
|
+
// on so the original error still propagates.
|
|
112
|
+
async function dumpFailure(page, tag, log) {
|
|
113
|
+
try {
|
|
114
|
+
const path = require('path');
|
|
115
|
+
const os = require('os');
|
|
116
|
+
const dir = path.join(os.tmpdir(), 'cm-worker-pw');
|
|
117
|
+
const png = path.join(dir, `${tag}-${Date.now()}.png`);
|
|
118
|
+
await page.screenshot({ path: png, fullPage: false, timeout: 8000 }).catch(() => {});
|
|
119
|
+
const url = page.url();
|
|
120
|
+
const title = await page.title().catch(() => '');
|
|
121
|
+
const bodyHead = await page.evaluate(() => (document.body?.innerText || '').slice(0, 400)).catch(() => '');
|
|
122
|
+
log('warn', `[yt-pw] page dump on ${tag} — url=${url} | title=${title.slice(0, 80)} | head=${bodyHead.replace(/\s+/g, ' ').slice(0, 200)} | screenshot=${png}`);
|
|
123
|
+
|
|
124
|
+
// Snapshot of every VISIBLE interactive element — so when Studio renames
|
|
125
|
+
// an id or swaps a tag we see what's there instead.
|
|
126
|
+
const inventory = await page.evaluate(() => {
|
|
127
|
+
const out = [];
|
|
128
|
+
const sels = [
|
|
129
|
+
'button', '[role="button"]', '[role="menuitem"]',
|
|
130
|
+
'ytcp-button', 'ytcp-icon-button', 'tp-yt-paper-item', 'tp-yt-paper-button', 'ytcp-text-menu-item',
|
|
131
|
+
];
|
|
132
|
+
const els = document.querySelectorAll(sels.join(','));
|
|
133
|
+
for (const el of els) {
|
|
134
|
+
const r = el.getBoundingClientRect();
|
|
135
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
136
|
+
const cs = getComputedStyle(el);
|
|
137
|
+
if (cs.visibility === 'hidden' || cs.display === 'none' || cs.opacity === '0') continue;
|
|
138
|
+
const text = (el.innerText || el.textContent || '').trim().slice(0, 50);
|
|
139
|
+
const id = el.id || '';
|
|
140
|
+
const aria = el.getAttribute('aria-label') || '';
|
|
141
|
+
const role = el.getAttribute('role') || '';
|
|
142
|
+
const disabled = el.disabled || el.getAttribute('aria-disabled') === 'true';
|
|
143
|
+
out.push(`<${el.tagName.toLowerCase()} id="${id}" role="${role}" aria="${aria.slice(0, 40)}" disabled="${disabled}">${text}</>`);
|
|
144
|
+
if (out.length >= 30) break;
|
|
145
|
+
}
|
|
146
|
+
return out;
|
|
147
|
+
}).catch(() => []);
|
|
148
|
+
if (inventory.length) {
|
|
149
|
+
log('warn', `[yt-pw] visible buttons/menuitems on ${tag}:\n ${inventory.join('\n ')}`);
|
|
150
|
+
}
|
|
151
|
+
} catch (e) {
|
|
152
|
+
log('warn', `[yt-pw] page dump failed: ${e.message}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function locateField(page, spec, { timeoutMs = 15000 } = {}) {
|
|
157
|
+
const deadline = Date.now() + timeoutMs;
|
|
158
|
+
while (Date.now() < deadline) {
|
|
159
|
+
if (Array.isArray(spec?.byLabel)) {
|
|
160
|
+
for (const label of spec.byLabel) {
|
|
161
|
+
const hit = await firstVisible(page.getByLabel(new RegExp(label, 'i')));
|
|
162
|
+
if (hit) return hit;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (Array.isArray(spec?.cssCandidates)) {
|
|
166
|
+
for (const css of spec.cssCandidates) {
|
|
167
|
+
try {
|
|
168
|
+
const hit = await firstVisible(page.locator(css));
|
|
169
|
+
if (hit) return hit;
|
|
170
|
+
} catch {}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
await page.waitForTimeout(300);
|
|
174
|
+
}
|
|
175
|
+
throw new Error(`field not found (no visible match): ${JSON.stringify(spec).slice(0, 200)}`);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
async function jitter(page, baseMs = 1200) {
|
|
179
|
+
// Human-ish pacing between actions.
|
|
180
|
+
await page.waitForTimeout(baseMs + Math.floor(Math.random() * 1500));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Best-effort dismiss of a blocking Polymer overlay backdrop. Only runs when
|
|
184
|
+
// `<tp-yt-iron-overlay-backdrop opened>` is actually present (otherwise the
|
|
185
|
+
// click sweep can hit unrelated "Bỏ qua / Skip" buttons in the page and break
|
|
186
|
+
// the flow — saw this in v29d run where multiple dismiss clicks closed the
|
|
187
|
+
// Create dropdown). Stops after the FIRST visible match per sweep.
|
|
188
|
+
async function dismissOverlays(page, log) {
|
|
189
|
+
// Phrases ordered by priority. Welcome / continue verbs first — when the
|
|
190
|
+
// backdrop is up the visible dialog is usually a multi-step welcome flow
|
|
191
|
+
// ("Chào mừng bạn đến với YouTube Studio" → "Tiếp tục"). Skip/dismiss verbs
|
|
192
|
+
// come later as fallback.
|
|
193
|
+
const phrases = [
|
|
194
|
+
'Tiếp tục', 'Tiếp theo', 'Bắt đầu',
|
|
195
|
+
'Continue', 'Next', 'Get started', 'Start',
|
|
196
|
+
'Got it', 'Đã hiểu',
|
|
197
|
+
'Skip the tutorial', 'Skip tutorial', 'Skip tour', 'Skip',
|
|
198
|
+
'Dismiss', 'Not now', 'No thanks', 'Close',
|
|
199
|
+
'Bỏ qua phần hướng dẫn', 'Bỏ qua hướng dẫn', 'Bỏ qua',
|
|
200
|
+
'Để sau', 'Không phải bây giờ', 'Đóng',
|
|
201
|
+
];
|
|
202
|
+
|
|
203
|
+
// CRITICAL: only consider buttons INSIDE an opened dialog. Previous bug:
|
|
204
|
+
// when the backdrop was up, we matched "Bỏ qua phần hướng dẫn" (sidebar
|
|
205
|
+
// tutorial-skip, OUTSIDE the modal) — click intercepted by backdrop, button
|
|
206
|
+
// still visible, loop spun 6 times doing nothing. Studio's dialog wrappers:
|
|
207
|
+
// tp-yt-paper-dialog[opened]
|
|
208
|
+
// ytcp-dialog[opened]
|
|
209
|
+
// ytcp-confirmation-dialog[opened]
|
|
210
|
+
// any element with role="dialog" that's a sibling of the opened backdrop
|
|
211
|
+
const dialogScopes = [
|
|
212
|
+
"tp-yt-paper-dialog[opened]",
|
|
213
|
+
"ytcp-dialog[opened]",
|
|
214
|
+
"ytcp-confirmation-dialog[opened]",
|
|
215
|
+
"[role='dialog']:visible",
|
|
216
|
+
];
|
|
217
|
+
|
|
218
|
+
for (let pass = 0; pass < 6; pass++) {
|
|
219
|
+
const backdropOpen = await page.locator("tp-yt-iron-overlay-backdrop[opened]").count().catch(() => 0);
|
|
220
|
+
if (!backdropOpen) return;
|
|
221
|
+
|
|
222
|
+
let clicked = false;
|
|
223
|
+
// Try each visible dialog scope in order.
|
|
224
|
+
for (const dlgSel of dialogScopes) {
|
|
225
|
+
if (clicked) break;
|
|
226
|
+
const dlg = page.locator(dlgSel).first();
|
|
227
|
+
const dlgCount = await dlg.count().catch(() => 0);
|
|
228
|
+
if (dlgCount === 0) continue;
|
|
229
|
+
if (!(await dlg.isVisible().catch(() => false))) continue;
|
|
230
|
+
// Search clickables INSIDE this dialog only.
|
|
231
|
+
for (const p of phrases) {
|
|
232
|
+
const candidates = [
|
|
233
|
+
`button:has-text('${p}')`,
|
|
234
|
+
`tp-yt-paper-button:has-text('${p}')`,
|
|
235
|
+
`ytcp-button:has-text('${p}')`,
|
|
236
|
+
];
|
|
237
|
+
for (const sel of candidates) {
|
|
238
|
+
try {
|
|
239
|
+
const hit = await firstVisible(dlg.locator(sel), 3);
|
|
240
|
+
if (!hit) continue;
|
|
241
|
+
log('info', `[yt-pw] dismissing overlay via "${dlgSel} ${sel}" (pass ${pass + 1})`);
|
|
242
|
+
await hit.click({ timeout: 3000 }).catch(() => {});
|
|
243
|
+
await page.waitForTimeout(700);
|
|
244
|
+
clicked = true;
|
|
245
|
+
break;
|
|
246
|
+
} catch {}
|
|
247
|
+
}
|
|
248
|
+
if (clicked) break;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
if (!clicked) {
|
|
252
|
+
// No dialog matched — try Escape as last resort, then bail.
|
|
253
|
+
log('info', `[yt-pw] no dismissable button found in opened dialog (pass ${pass + 1}) — trying Escape`);
|
|
254
|
+
try { await page.keyboard.press('Escape'); } catch {}
|
|
255
|
+
await page.waitForTimeout(500);
|
|
256
|
+
// If Escape didn't help on this pass, don't burn more passes.
|
|
257
|
+
const stillOpen = await page.locator("tp-yt-iron-overlay-backdrop[opened]").count().catch(() => 0);
|
|
258
|
+
if (stillOpen) return;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Deep diagnostic: dump file inputs across the entire document (incl. open
|
|
264
|
+
// shadow roots), every Polymer custom element whose tag contains 'thumb',
|
|
265
|
+
// and every visible button-like element whose text suggests "Show more" /
|
|
266
|
+
// "Add" / "Hiển thị thêm" / "Tải lên" — enough surface area to patch
|
|
267
|
+
// selectors for thumbnail + tags without manually inspecting in DevTools.
|
|
268
|
+
async function inspectDetailsForm(page, log) {
|
|
269
|
+
try {
|
|
270
|
+
const inv = await page.evaluate(() => {
|
|
271
|
+
const out = [];
|
|
272
|
+
// Walk document + open shadow roots — Studio's Polymer wrappers hide
|
|
273
|
+
// file inputs and rich controls behind shadowRoot.
|
|
274
|
+
const allEls = [];
|
|
275
|
+
const visit = (root) => {
|
|
276
|
+
allEls.push(...root.querySelectorAll('*'));
|
|
277
|
+
root.querySelectorAll('*').forEach((el) => {
|
|
278
|
+
if (el.shadowRoot && el.shadowRoot.mode === 'open') visit(el.shadowRoot);
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
visit(document);
|
|
282
|
+
|
|
283
|
+
// 1) Every file input anywhere (likely all hidden — what matters is the
|
|
284
|
+
// parent/grandparent chain so we can pick a stable Polymer ancestor).
|
|
285
|
+
const fileInputs = allEls.filter((el) => el.tagName === 'INPUT' && el.type === 'file');
|
|
286
|
+
fileInputs.forEach((el, i) => {
|
|
287
|
+
const ancestors = [];
|
|
288
|
+
let p = el;
|
|
289
|
+
for (let n = 0; n < 6 && p; n++) {
|
|
290
|
+
ancestors.push(`${p.tagName.toLowerCase()}${p.id ? '#' + p.id : ''}${p.className && typeof p.className === 'string' ? '.' + p.className.split(/\s+/).filter(Boolean).slice(0, 2).join('.') : ''}`);
|
|
291
|
+
p = p.parentElement || (p.getRootNode() && p.getRootNode().host);
|
|
292
|
+
}
|
|
293
|
+
out.push(`file-input[${i}] accept="${(el.accept || '').slice(0, 40)}" name="${el.name || ''}" id="${el.id || ''}" ancestors=${ancestors.slice(0, 5).join(' > ')}`);
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// 2) Polymer custom elements tagged with 'thumb' anywhere in their name —
|
|
297
|
+
// ytcp-thumbnail-*, ytcp-thumbnails-*, etc. Capture their immediate
|
|
298
|
+
// child-input pattern so we know how to setInputFiles.
|
|
299
|
+
const thumbEls = allEls.filter((el) => /thumb/i.test(el.tagName));
|
|
300
|
+
thumbEls.slice(0, 8).forEach((el) => {
|
|
301
|
+
const id = el.id || '';
|
|
302
|
+
const r = el.getBoundingClientRect();
|
|
303
|
+
const visible = r.width > 0 && r.height > 0;
|
|
304
|
+
const childInput = el.querySelector("input[type='file']") || (el.shadowRoot && el.shadowRoot.querySelector("input[type='file']"));
|
|
305
|
+
out.push(`<${el.tagName.toLowerCase()}${id ? ' id="' + id + '"' : ''}> visible=${visible} has-file-input=${!!childInput}`);
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// 3) ALL visible buttons inside the upload modal, with text + aria —
|
|
309
|
+
// first pass had a regex filter that missed Studio's actual button
|
|
310
|
+
// labels (Show more was probably "Hiện cài đặt nâng cao" or icon-only).
|
|
311
|
+
// Dump everything so the next iteration can pick the right one.
|
|
312
|
+
const dlg2 = document.querySelector('ytcp-uploads-dialog');
|
|
313
|
+
if (dlg2) {
|
|
314
|
+
const dlgButtons = dlg2.querySelectorAll('button, ytcp-button, ytcp-icon-button, ytcp-text-menu-item, [role="button"]');
|
|
315
|
+
let dumped = 0;
|
|
316
|
+
for (const el of dlgButtons) {
|
|
317
|
+
const r = el.getBoundingClientRect();
|
|
318
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
319
|
+
const text = (el.innerText || el.textContent || '').trim().replace(/\s+/g, ' ');
|
|
320
|
+
const aria = el.getAttribute('aria-label') || '';
|
|
321
|
+
if (!text && !aria) continue;
|
|
322
|
+
out.push(`btn <${el.tagName.toLowerCase()}${el.id ? ' id="' + el.id + '"' : ''} aria="${aria.slice(0, 40)}">${text.slice(0, 50)}</>`);
|
|
323
|
+
if (++dumped >= 40) break;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 4) ALL radio buttons — Made for kids / Age restriction / Altered
|
|
328
|
+
// content / etc. Dump everything so unfamiliar ones (AI disclosure,
|
|
329
|
+
// shorts-only questions) are visible without another iteration.
|
|
330
|
+
allEls.filter((el) => el.tagName === 'TP-YT-PAPER-RADIO-BUTTON').forEach((el) => {
|
|
331
|
+
out.push(`radio name="${el.getAttribute('name') || ''}" text="${(el.innerText || '').trim().slice(0, 50)}"`);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
return out;
|
|
335
|
+
}).catch(() => []);
|
|
336
|
+
if (inv.length) {
|
|
337
|
+
log('info', `[yt-pw] details-form inventory:\n ${inv.join('\n ')}`);
|
|
338
|
+
}
|
|
339
|
+
} catch (e) {
|
|
340
|
+
log('warn', `[yt-pw] details-form inventory failed: ${e.message}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Tick the "Altered/synthetic content" YES radio on the Details step. YouTube
|
|
345
|
+
// requires creators to disclose AI-generated content that depicts realistic
|
|
346
|
+
// people/events. Our pipeline outputs are Veo3 + edited — always YES.
|
|
347
|
+
// Polymer name pattern observed in Studio:
|
|
348
|
+
// tp-yt-paper-radio-button[name='ALTERED_CONTENT_YES'] (newer)
|
|
349
|
+
// tp-yt-paper-radio-button[name='ALTERED_OR_SYNTHETIC_CONTENT_YES'] (older)
|
|
350
|
+
// Tick the "Altered/synthetic content" YES radio. STRICT: if no selector
|
|
351
|
+
// matches, throw — every input step must complete or the cmd is failed.
|
|
352
|
+
// Caller can wrap in try/catch if a particular channel is exempt.
|
|
353
|
+
async function selectAlteredContentYes(page, log) {
|
|
354
|
+
const candidates = [
|
|
355
|
+
"tp-yt-paper-radio-button[name='VIDEO_HAS_ALTERED_CONTENT_YES']",
|
|
356
|
+
"tp-yt-paper-radio-button[name='ALTERED_CONTENT_YES']",
|
|
357
|
+
"tp-yt-paper-radio-button[name='ALTERED_OR_SYNTHETIC_CONTENT_YES']",
|
|
358
|
+
"tp-yt-paper-radio-button[name*='ALTERED_CONTENT'][name$='YES']",
|
|
359
|
+
"tp-yt-paper-radio-button[name*='ALTERED'][name$='_YES']",
|
|
360
|
+
"tp-yt-paper-radio-button[name*='SYNTHETIC'][name$='_YES']",
|
|
361
|
+
];
|
|
362
|
+
for (const sel of candidates) {
|
|
363
|
+
try {
|
|
364
|
+
const hit = await firstVisible(page.locator(sel), 3);
|
|
365
|
+
if (hit) {
|
|
366
|
+
log('info', `[yt-pw] picking 'Altered/AI content = YES' via "${sel}"`);
|
|
367
|
+
await hit.click({ timeout: 3000 });
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
} catch {}
|
|
371
|
+
}
|
|
372
|
+
throw new Error('altered/AI content radio not found — Studio markup changed; update selectAlteredContentYes selectors');
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Pick "Not made for kids" on Studio's required Audience question. STRICT:
|
|
376
|
+
// throw if not found — without this, Studio leaves Next disabled and the
|
|
377
|
+
// flow stalls anyway.
|
|
378
|
+
async function selectNotMadeForKids(page, log) {
|
|
379
|
+
const candidates = [
|
|
380
|
+
"tp-yt-paper-radio-button[name='VIDEO_MADE_FOR_KIDS_NOT_MFK']",
|
|
381
|
+
"tp-yt-paper-radio-button[name='NOT_MFK']",
|
|
382
|
+
"input[type='radio'][value*='NOT_MFK']",
|
|
383
|
+
];
|
|
384
|
+
for (const sel of candidates) {
|
|
385
|
+
try {
|
|
386
|
+
const hit = await firstVisible(page.locator(sel), 3);
|
|
387
|
+
if (hit) {
|
|
388
|
+
log('info', `[yt-pw] picking 'Not made for kids' via "${sel}"`);
|
|
389
|
+
await hit.click({ timeout: 3000 });
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
} catch {}
|
|
393
|
+
}
|
|
394
|
+
// Label-based fallback — Studio's i18n: "No, it's not made for kids" /
|
|
395
|
+
// "Không phải video dành cho trẻ em".
|
|
396
|
+
try {
|
|
397
|
+
const byLabel = await firstVisible(page.getByText(/Không phải video dành cho trẻ em|No, it'?s not made for kids/i), 3);
|
|
398
|
+
if (byLabel) {
|
|
399
|
+
log('info', `[yt-pw] picking 'Not made for kids' via text match`);
|
|
400
|
+
await byLabel.click({ timeout: 3000 });
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
} catch {}
|
|
404
|
+
throw new Error("'Not made for kids' radio not found — Studio markup changed; update selectNotMadeForKids selectors");
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async function run({ page, payload, log }) {
|
|
408
|
+
const { video_url, title, description = '', tags = [], visibility = 'public', thumbnail_url, account_name } = payload || {};
|
|
409
|
+
if (!video_url) throw new Error('No video_url provided');
|
|
410
|
+
if (!title) throw new Error('No title provided');
|
|
411
|
+
|
|
412
|
+
const S = loadSelectors();
|
|
413
|
+
log('info', `[yt-pw] selectors version=${S.version}`);
|
|
414
|
+
|
|
415
|
+
// Auto-accept any beforeunload / confirmation dialog so a stuck previous-run
|
|
416
|
+
// upload modal doesn't block our goto(studio) with "Are you sure you want to
|
|
417
|
+
// leave? Your upload will be cancelled." Saw this trigger silently when the
|
|
418
|
+
// last run left ytcp-uploads-dialog open in the same NST profile.
|
|
419
|
+
page.on('dialog', (d) => { d.accept().catch(() => {}); });
|
|
420
|
+
|
|
421
|
+
// Capture the freshly-uploaded video's ID directly from YouTube's network
|
|
422
|
+
// responses. Studio's youtubei mutations carry { videoId: 'XXXXXXXXXXX' } on
|
|
423
|
+
// upload/save calls, which is far more reliable than scraping the share
|
|
424
|
+
// dialog (it auto-closes, gets covered by toasts, or never appears for
|
|
425
|
+
// Shorts). We hold onto the LAST observed videoId — the one issued for our
|
|
426
|
+
// upload, not a stale Creators recommendation.
|
|
427
|
+
const networkVideoIds = [];
|
|
428
|
+
page.on('response', async (resp) => {
|
|
429
|
+
try {
|
|
430
|
+
const u = resp.url();
|
|
431
|
+
if (!/youtubei\/v1\/(upload|video|video_manager)/i.test(u)) return;
|
|
432
|
+
// Fast path: many endpoints expose videoId in the URL or response JSON.
|
|
433
|
+
const inUrl = u.match(/videoId=([A-Za-z0-9_-]{11})/);
|
|
434
|
+
if (inUrl) { networkVideoIds.push(inUrl[1]); return; }
|
|
435
|
+
const ct = (resp.headers()['content-type'] || '');
|
|
436
|
+
if (!/json/i.test(ct)) return;
|
|
437
|
+
const text = await resp.text().catch(() => '');
|
|
438
|
+
if (!text) return;
|
|
439
|
+
const m = text.match(/"videoId"\s*:\s*"([A-Za-z0-9_-]{11})"/);
|
|
440
|
+
if (m) networkVideoIds.push(m[1]);
|
|
441
|
+
} catch { /* ignore */ }
|
|
442
|
+
});
|
|
443
|
+
// Expose to the URL-extraction step via the run closure.
|
|
444
|
+
// eslint-disable-next-line no-param-reassign
|
|
445
|
+
payload.__networkVideoIds = networkVideoIds;
|
|
446
|
+
|
|
447
|
+
// 1) Download video to local disk — Playwright setInputFiles needs a path.
|
|
448
|
+
log('info', `[yt-pw] downloading video to local…`);
|
|
449
|
+
const videoPath = await downloadToTemp(video_url, { prefix: 'yt', ext: '.mp4' });
|
|
450
|
+
log('info', `[yt-pw] video at ${videoPath}`);
|
|
451
|
+
|
|
452
|
+
let thumbPath = null;
|
|
453
|
+
if (thumbnail_url) {
|
|
454
|
+
try {
|
|
455
|
+
thumbPath = await downloadToTemp(thumbnail_url, { prefix: 'thumb', ext: '.png' });
|
|
456
|
+
log('info', `[yt-pw] thumbnail at ${thumbPath}`);
|
|
457
|
+
} catch (e) { log('warn', `[yt-pw] thumbnail download failed: ${e.message}`); }
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
// 2) Open Studio (cookie-logged via NST).
|
|
462
|
+
log('info', `[yt-pw] open studio.youtube.com…`);
|
|
463
|
+
await page.goto(S.studioUrl, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
464
|
+
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {});
|
|
465
|
+
// Studio's chrome takes a moment to render after DOM ready — give it some
|
|
466
|
+
// grace before scanning for buttons.
|
|
467
|
+
await page.waitForTimeout(2500);
|
|
468
|
+
log('info', `[yt-pw] page loaded — url=${page.url()} title=${(await page.title().catch(()=>''))}`);
|
|
469
|
+
|
|
470
|
+
// 2b) Dismiss any onboarding/tutorial overlay. Studio sometimes shows a
|
|
471
|
+
// first-visit tutorial with a `tp-yt-iron-overlay-backdrop` that intercepts
|
|
472
|
+
// every click on the page (saw "Bỏ qua phần hướng dẫn / Tạo Kênh của bạn"
|
|
473
|
+
// in a recent dump → wizard Next couldn't be clicked). Best-effort: find any
|
|
474
|
+
// visible "Skip / Dismiss / Got it" button (EN + VI) and click it.
|
|
475
|
+
await dismissOverlays(page, log);
|
|
476
|
+
|
|
477
|
+
// 3) (Optional) account_name check is left to the caller — switching
|
|
478
|
+
// channels in Studio is its own dance and out of scope for the pilot.
|
|
479
|
+
|
|
480
|
+
// 4) Click Create → "Upload videos".
|
|
481
|
+
log('info', `[yt-pw] click Create…`);
|
|
482
|
+
let createBtn;
|
|
483
|
+
try { createBtn = await locateButton(page, S.createButton, { timeoutMs: 20_000 }); }
|
|
484
|
+
catch (e) { await dumpFailure(page, 'create-not-found', log); throw e; }
|
|
485
|
+
await createBtn.click();
|
|
486
|
+
// Studio's Create dropdown animates open — give it time to render before
|
|
487
|
+
// we scan for the menu item, otherwise we may match a hidden duplicate.
|
|
488
|
+
await page.waitForTimeout(1200);
|
|
489
|
+
|
|
490
|
+
// Confirm the dropdown actually opened. If clicking the wrapper failed to
|
|
491
|
+
// trigger it (e.g., match landed on an inner element with no handler), we
|
|
492
|
+
// want to know FAST — otherwise the later locateButton just times out and
|
|
493
|
+
// hides the root cause. Studio's dropdown contains menuitem elements with
|
|
494
|
+
// text containing 'video' (Upload videos / Tải video lên / Go live / Đi
|
|
495
|
+
// trực tiếp / Create post / Tạo bài đăng).
|
|
496
|
+
const dropdownOpen = await page.locator("tp-yt-paper-listbox tp-yt-paper-item, ytcp-text-menu-item, [role='menu'] [role='menuitem']").first().isVisible({ timeout: 3000 }).catch(() => false);
|
|
497
|
+
if (!dropdownOpen) {
|
|
498
|
+
log('warn', `[yt-pw] Create dropdown did not open after click — retrying with a different match`);
|
|
499
|
+
// Retry: re-locate Create and try clicking the inner native button this
|
|
500
|
+
// time (some Studio builds register the handler on the button child).
|
|
501
|
+
try {
|
|
502
|
+
const altCreate = page.locator("ytcp-button:has-text('Tạo'), ytcp-button:has-text('Create')").last();
|
|
503
|
+
if (await altCreate.isVisible().catch(() => false)) {
|
|
504
|
+
await altCreate.click({ timeout: 3000 });
|
|
505
|
+
await page.waitForTimeout(1200);
|
|
506
|
+
}
|
|
507
|
+
} catch {}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
log('info', `[yt-pw] click "Upload videos"…`);
|
|
511
|
+
let uploadItem;
|
|
512
|
+
try { uploadItem = await locateButton(page, S.uploadMenuItem, { timeoutMs: 15_000 }); }
|
|
513
|
+
catch (e) { await dumpFailure(page, 'upload-menu-not-found', log); throw e; }
|
|
514
|
+
await uploadItem.click();
|
|
515
|
+
await jitter(page, 800);
|
|
516
|
+
|
|
517
|
+
// 5) The KEY step — set the video file.
|
|
518
|
+
log('info', `[yt-pw] setInputFiles(video)…`);
|
|
519
|
+
await page.locator(S.fileInput).setInputFiles(videoPath);
|
|
520
|
+
log('info', `[yt-pw] file set; waiting for the metadata form to appear…`);
|
|
521
|
+
|
|
522
|
+
// 6) Wait for the Title field to be ready (Studio renders it as the file
|
|
523
|
+
// starts processing). Default title = filename; we'll overwrite.
|
|
524
|
+
//
|
|
525
|
+
// Use POSITIONAL lookup, not id/label: in Studio 2026 the title/description
|
|
526
|
+
// ids changed, and getByLabel(/Mô tả/) sometimes matched the title field
|
|
527
|
+
// → we ended up typing title + description into the SAME field (271/100).
|
|
528
|
+
// Inside the upload dialog there are exactly two top-level contenteditable
|
|
529
|
+
// rich-text boxes — [0] = title, [1] = description.
|
|
530
|
+
// Check for hard upload blockers BEFORE entering the scrim-wait loop, so
|
|
531
|
+
// we fail fast with a clear message instead of hanging on a scrim that
|
|
532
|
+
// never clears. Currently checks the daily upload quota banner — Studio
|
|
533
|
+
// shows it as a red alert inside ytcp-uploads-dialog while the scrim
|
|
534
|
+
// stays up indefinitely (upload was rejected, modal can't progress).
|
|
535
|
+
const checkUploadBlockers = async () => {
|
|
536
|
+
return page.evaluate(() => {
|
|
537
|
+
const dlg = document.querySelector('ytcp-uploads-dialog');
|
|
538
|
+
if (!dlg) return null;
|
|
539
|
+
const text = (dlg.innerText || '').slice(0, 4000);
|
|
540
|
+
const patterns = [
|
|
541
|
+
// Vietnamese
|
|
542
|
+
/Đã đạt giới hạn tải video lên hằng ngày/i,
|
|
543
|
+
/giới hạn tải video lên/i,
|
|
544
|
+
/vượt quá giới hạn tải/i,
|
|
545
|
+
// English
|
|
546
|
+
/Daily upload limit reached/i,
|
|
547
|
+
/You'?ve reached your daily upload limit/i,
|
|
548
|
+
/Upload limit reached/i,
|
|
549
|
+
];
|
|
550
|
+
for (const re of patterns) {
|
|
551
|
+
const m = text.match(re);
|
|
552
|
+
if (m) return { kind: 'quota_exceeded', match: m[0] };
|
|
553
|
+
}
|
|
554
|
+
return null;
|
|
555
|
+
}).catch(() => null);
|
|
556
|
+
};
|
|
557
|
+
|
|
558
|
+
const blocker = await checkUploadBlockers();
|
|
559
|
+
if (blocker) {
|
|
560
|
+
await dumpFailure(page, `upload-blocked-${blocker.kind}`, log);
|
|
561
|
+
throw new Error(`YouTube upload blocked: ${blocker.kind} — "${blocker.match}". Channel hit the daily upload quota (~15 unverified / 50+ verified). Wait ~24h or complete phone verification at studio.youtube.com.`);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
const editables = page.locator("ytcp-uploads-dialog [contenteditable='true']");
|
|
565
|
+
// Wait for both contenteditables to render AND for the upload dialog's
|
|
566
|
+
// scrim overlay to clear. Studio puts a `<div class="dialog-scrim">` over
|
|
567
|
+
// the form during the open transition + initial file processing — it
|
|
568
|
+
// doesn't hide the field (isVisible() returns true) but it intercepts every
|
|
569
|
+
// pointer event ("dialog-scrim intercepts pointer events" in Playwright's
|
|
570
|
+
// retry log). Waiting for fields alone is not enough.
|
|
571
|
+
// Recheck the quota banner each iteration — it sometimes appears after
|
|
572
|
+
// the initial pre-upload validation completes.
|
|
573
|
+
const formDeadline = Date.now() + 120_000;
|
|
574
|
+
let titleField = editables.nth(0);
|
|
575
|
+
let formReady = false;
|
|
576
|
+
while (Date.now() < formDeadline) {
|
|
577
|
+
const lateBlocker = await checkUploadBlockers();
|
|
578
|
+
if (lateBlocker) {
|
|
579
|
+
await dumpFailure(page, `upload-blocked-${lateBlocker.kind}`, log);
|
|
580
|
+
throw new Error(`YouTube upload blocked: ${lateBlocker.kind} — "${lateBlocker.match}". Channel hit the daily upload quota. Wait ~24h or complete phone verification.`);
|
|
581
|
+
}
|
|
582
|
+
const n = await editables.count().catch(() => 0);
|
|
583
|
+
if (n >= 2) {
|
|
584
|
+
const scrimBlocking = await page.evaluate(() => {
|
|
585
|
+
const scrim = document.querySelector('ytcp-uploads-dialog .dialog-scrim');
|
|
586
|
+
if (!scrim) return false;
|
|
587
|
+
const r = scrim.getBoundingClientRect();
|
|
588
|
+
if (r.width === 0 || r.height === 0) return false;
|
|
589
|
+
const cs = getComputedStyle(scrim);
|
|
590
|
+
if (cs.display === 'none' || cs.visibility === 'hidden') return false;
|
|
591
|
+
if (cs.pointerEvents === 'none') return false;
|
|
592
|
+
return true;
|
|
593
|
+
}).catch(() => false);
|
|
594
|
+
if (!scrimBlocking && await isActionable(editables.nth(0))) {
|
|
595
|
+
formReady = true;
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
await page.waitForTimeout(500);
|
|
600
|
+
}
|
|
601
|
+
if (!formReady) {
|
|
602
|
+
await dumpFailure(page, 'title-field-not-actionable', log);
|
|
603
|
+
throw new Error('title field never became actionable (scrim never cleared or modal stuck)');
|
|
604
|
+
}
|
|
605
|
+
const descField = editables.nth(1);
|
|
606
|
+
|
|
607
|
+
// YouTube hard-caps title at 100 chars — over the limit, Studio rejects
|
|
608
|
+
// input mid-typing and blocks Next. Clip with ellipsis on overflow. Safety
|
|
609
|
+
// net here even though publishIdea also clips, in case the cmd payload
|
|
610
|
+
// came from another path (manual mongosh, legacy, etc.).
|
|
611
|
+
const titleForUpload = (title && title.length > 100) ? (title.slice(0, 99) + '…') : title;
|
|
612
|
+
log('info', `[yt-pw] fill title (${titleForUpload.length}/${title.length} chars${title.length > 100 ? ' — clipped from ' + title.length : ''})…`);
|
|
613
|
+
try {
|
|
614
|
+
await titleField.click({ timeout: 10_000 });
|
|
615
|
+
} catch (e) {
|
|
616
|
+
await dumpFailure(page, 'title-click-failed', log);
|
|
617
|
+
throw e;
|
|
618
|
+
}
|
|
619
|
+
await page.keyboard.press('Control+A');
|
|
620
|
+
await page.keyboard.press('Delete');
|
|
621
|
+
await titleField.type(titleForUpload, { delay: 25 });
|
|
622
|
+
await jitter(page, 600);
|
|
623
|
+
|
|
624
|
+
if (description) {
|
|
625
|
+
log('info', `[yt-pw] fill description (${description.length} chars)…`);
|
|
626
|
+
try {
|
|
627
|
+
// descField.click() places the cursor INSIDE the description field
|
|
628
|
+
// (different element than title, guaranteed by .nth(1)).
|
|
629
|
+
await descField.click();
|
|
630
|
+
await page.keyboard.press('Control+A');
|
|
631
|
+
await page.keyboard.press('Delete');
|
|
632
|
+
await descField.type(description, { delay: 15 });
|
|
633
|
+
} catch (e) { throw new Error(`description fill failed: ${e.message}`); }
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// tags handled below — after Show more expand, see section 7d.
|
|
637
|
+
|
|
638
|
+
// 7) Thumbnail (custom). YouTube web UI does NOT allow custom thumbnail
|
|
639
|
+
// uploads for Shorts — that's a mobile-app-only feature. For Shorts, the
|
|
640
|
+
// pipeline pre-bakes the custom thumbnail as the FIRST FRAME of the rendered
|
|
641
|
+
// video (see render pipeline), so YT's auto-extracted first-frame thumbnail
|
|
642
|
+
// IS our custom thumbnail. Skip the web upload attempt entirely for Shorts.
|
|
643
|
+
const isShortFormat = String(payload.format || '').toLowerCase().includes('short');
|
|
644
|
+
if (isShortFormat && thumbPath) {
|
|
645
|
+
log('info', `[yt-pw] thumbnail skipped for Shorts — YT auto-uses video's first frame (custom thumb pre-baked in render)`);
|
|
646
|
+
}
|
|
647
|
+
if (thumbPath && !isShortFormat) {
|
|
648
|
+
try {
|
|
649
|
+
log('info', `[yt-pw] uploading custom thumbnail…`);
|
|
650
|
+
// Wide probe — Studio sometimes mounts the file input/icon as a
|
|
651
|
+
// sibling of ytcp-video-thumbnail-editor (under ytcp-thumbnail-uploader)
|
|
652
|
+
// or lazily under <body>. Scoping to just the editor missed it.
|
|
653
|
+
const thumbInventory = await page.evaluate(() => {
|
|
654
|
+
const out = { fileInputs: [], buttons: [], hostFound: [] };
|
|
655
|
+
const editor = document.querySelector('ytcp-uploads-dialog')
|
|
656
|
+
|| document.querySelector('ytcp-video-thumbnail-editor')
|
|
657
|
+
|| document.body;
|
|
658
|
+
if (!editor) return out;
|
|
659
|
+
// Note hosts that EXIST in the doc — helps narrow next iteration.
|
|
660
|
+
['ytcp-thumbnail-uploader', 'ytcp-thumbnail-with-title', 'ytcp-video-thumbnail-editor', 'ytcp-uploads-dialog'].forEach((tag) => {
|
|
661
|
+
if (document.querySelector(tag)) out.hostFound.push(tag);
|
|
662
|
+
});
|
|
663
|
+
// Look for the magic icon ANYWHERE in the document.
|
|
664
|
+
['#add-photo-icon', '#file-loader', "input[name='Filedata']"].forEach((sel) => {
|
|
665
|
+
const el = document.querySelector(sel);
|
|
666
|
+
if (el) out.fileInputs.push({ accept: el.accept || '', name: el.name || sel, id: el.id || '', hidden: el.offsetParent === null, ancestor: el.tagName.toLowerCase() });
|
|
667
|
+
});
|
|
668
|
+
const walk = [];
|
|
669
|
+
const visit = (root) => {
|
|
670
|
+
walk.push(...root.querySelectorAll('*'));
|
|
671
|
+
root.querySelectorAll('*').forEach((el) => {
|
|
672
|
+
if (el.shadowRoot && el.shadowRoot.mode === 'open') visit(el.shadowRoot);
|
|
673
|
+
});
|
|
674
|
+
};
|
|
675
|
+
visit(editor);
|
|
676
|
+
for (const el of walk) {
|
|
677
|
+
if (el.tagName === 'INPUT' && el.type === 'file') {
|
|
678
|
+
out.fileInputs.push({
|
|
679
|
+
accept: el.accept || '',
|
|
680
|
+
name: el.name || '',
|
|
681
|
+
id: el.id || '',
|
|
682
|
+
hidden: el.offsetParent === null,
|
|
683
|
+
ancestor: el.parentElement?.tagName?.toLowerCase() || '',
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
// Click-like elements (buttons, divs with role, labels) — record
|
|
687
|
+
// class/id/aria so we can target the actual trigger.
|
|
688
|
+
const isClickable = el.tagName === 'BUTTON' || el.tagName === 'YTCP-BUTTON'
|
|
689
|
+
|| el.tagName === 'YTCP-ICON-BUTTON' || el.tagName === 'LABEL'
|
|
690
|
+
|| el.getAttribute?.('role') === 'button' || el.getAttribute?.('tabindex') === '0';
|
|
691
|
+
if (!isClickable) continue;
|
|
692
|
+
const r = el.getBoundingClientRect();
|
|
693
|
+
if (r.width === 0 || r.height === 0) continue;
|
|
694
|
+
out.buttons.push({
|
|
695
|
+
tag: el.tagName.toLowerCase(),
|
|
696
|
+
id: el.id || '',
|
|
697
|
+
aria: el.getAttribute('aria-label') || '',
|
|
698
|
+
text: (el.innerText || el.textContent || '').trim().slice(0, 40),
|
|
699
|
+
cls: (typeof el.className === 'string' ? el.className.split(/\s+/).slice(0, 2).join('.') : ''),
|
|
700
|
+
});
|
|
701
|
+
if (out.buttons.length >= 20) break;
|
|
702
|
+
}
|
|
703
|
+
return out;
|
|
704
|
+
}).catch(() => ({ fileInputs: [], buttons: [] }));
|
|
705
|
+
log('info', `[yt-pw] thumb-probe: hosts=${thumbInventory.hostFound.join(',') || '(none)'} | file-input(s)=${thumbInventory.fileInputs.length} | clickable(s)=${thumbInventory.buttons.length}`);
|
|
706
|
+
thumbInventory.fileInputs.forEach((fi, i) => log('info', ` thumb-fileinput[${i}] accept=${fi.accept} hidden=${fi.hidden} parent=${fi.ancestor}`));
|
|
707
|
+
thumbInventory.buttons.slice(0, 10).forEach((b, i) => log('info', ` thumb-clickable[${i}] <${b.tag}> id=${b.id} aria="${b.aria}" text="${b.text}"`));
|
|
708
|
+
|
|
709
|
+
let thumbDone = false;
|
|
710
|
+
|
|
711
|
+
// Path A — legacy hidden input. Some Studio cohorts still expose
|
|
712
|
+
// <input id="file-loader" type="file"> in light DOM; setInputFiles
|
|
713
|
+
// bypasses any click flow entirely.
|
|
714
|
+
const legacyInput = page.locator("#file-loader, input[name='Filedata']").first();
|
|
715
|
+
if (await legacyInput.count().catch(() => 0) > 0) {
|
|
716
|
+
try {
|
|
717
|
+
await legacyInput.setInputFiles(thumbPath);
|
|
718
|
+
log('info', `[yt-pw] thumbnail set via legacy input#file-loader`);
|
|
719
|
+
thumbDone = true;
|
|
720
|
+
await page.waitForTimeout(800);
|
|
721
|
+
} catch (e) {
|
|
722
|
+
log('info', `[yt-pw] legacy input setInputFiles failed: ${e.message.slice(0, 80)}`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
// Path B — modern: click into the thumbnail editor via raw DOM walk
|
|
727
|
+
// that pierces ANY shadow boundary (open). Playwright's CSS locator
|
|
728
|
+
// matched count=0 for `ytcp-uploads-dialog ytcp-thumbnail-with-title`
|
|
729
|
+
// even though the deep-walk probe found the element — confirms it's
|
|
730
|
+
// inside an open shadow root that page.locator can't reach with the
|
|
731
|
+
// descendant combinator. Race the filechooser with a JS click.
|
|
732
|
+
if (!thumbDone) {
|
|
733
|
+
// Try several candidate targets in priority order.
|
|
734
|
+
// B1. el.click() via shadow walk — synthetic click (sometimes fails).
|
|
735
|
+
// B2. page.mouse.click(bboxCenter) — real CDP mouse, trusted event.
|
|
736
|
+
// Studio's handler is gated on isTrusted=true on some builds.
|
|
737
|
+
const targetCandidates = [
|
|
738
|
+
{ tag: 'YTCP-THUMBNAIL-UPLOADER', why: 'Studio 2024+ uploader wrapper' },
|
|
739
|
+
{ tag: 'YTCP-THUMBNAIL-WITH-TITLE', why: 'wrapper containing upload slot' },
|
|
740
|
+
{ tag: 'YTCP-THUMBNAIL', why: 'first thumbnail tile = upload trigger' },
|
|
741
|
+
];
|
|
742
|
+
|
|
743
|
+
// Helper: dump SHADOW DOM contents of a target so we can patch
|
|
744
|
+
// selectors when none of these work. Only fires on the LAST attempt.
|
|
745
|
+
const dumpShadowChildren = async (tagName) => {
|
|
746
|
+
const dump = await page.evaluate(({ tagName }) => {
|
|
747
|
+
const walk = (root) => {
|
|
748
|
+
for (const el of root.querySelectorAll('*')) {
|
|
749
|
+
if (el.tagName === tagName) return el;
|
|
750
|
+
if (el.shadowRoot && el.shadowRoot.mode === 'open') {
|
|
751
|
+
const f = walk(el.shadowRoot);
|
|
752
|
+
if (f) return f;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return null;
|
|
756
|
+
};
|
|
757
|
+
const target = walk(document);
|
|
758
|
+
if (!target) return null;
|
|
759
|
+
const out = [];
|
|
760
|
+
const inner = (root, depth) => {
|
|
761
|
+
if (depth > 3) return;
|
|
762
|
+
for (const el of root.children || []) {
|
|
763
|
+
const id = el.id ? `#${el.id}` : '';
|
|
764
|
+
const cls = (typeof el.className === 'string' && el.className) ? `.${el.className.split(/\s+/).slice(0, 2).join('.')}` : '';
|
|
765
|
+
const aria = el.getAttribute?.('aria-label');
|
|
766
|
+
out.push(`${' '.repeat(depth)}<${el.tagName.toLowerCase()}${id}${cls}${aria ? ` aria="${aria.slice(0, 30)}"` : ''}>`);
|
|
767
|
+
if (out.length >= 30) return;
|
|
768
|
+
if (el.shadowRoot && el.shadowRoot.mode === 'open') {
|
|
769
|
+
out.push(`${' '.repeat(depth)}#shadow-root (open)`);
|
|
770
|
+
inner(el.shadowRoot, depth + 1);
|
|
771
|
+
} else {
|
|
772
|
+
inner(el, depth + 1);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
};
|
|
776
|
+
inner(target.shadowRoot || target, 0);
|
|
777
|
+
return out;
|
|
778
|
+
}, { tagName });
|
|
779
|
+
if (dump && dump.length) log('info', `[yt-pw] <${tagName.toLowerCase()}> tree:\n ${dump.join('\n ')}`);
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
for (const { tag, why } of targetCandidates) {
|
|
783
|
+
if (thumbDone) break;
|
|
784
|
+
// Locate the target element in DOM via shadow walk + get bbox.
|
|
785
|
+
const targetInfo = await page.evaluate(({ tagName }) => {
|
|
786
|
+
const walk = (root) => {
|
|
787
|
+
for (const el of root.querySelectorAll('*')) {
|
|
788
|
+
if (el.tagName === tagName) return el;
|
|
789
|
+
if (el.shadowRoot && el.shadowRoot.mode === 'open') {
|
|
790
|
+
const f = walk(el.shadowRoot);
|
|
791
|
+
if (f) return f;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
return null;
|
|
795
|
+
};
|
|
796
|
+
const target = walk(document);
|
|
797
|
+
if (!target) return null;
|
|
798
|
+
const r = target.getBoundingClientRect();
|
|
799
|
+
if (r.width === 0 || r.height === 0) return { found: true, visible: false };
|
|
800
|
+
return { found: true, visible: true, x: r.x + r.width / 2, y: r.y + r.height / 2 };
|
|
801
|
+
}, { tagName: tag });
|
|
802
|
+
|
|
803
|
+
if (!targetInfo?.found) {
|
|
804
|
+
log('info', `[yt-pw] thumbnail target <${tag.toLowerCase()}> not in DOM`);
|
|
805
|
+
continue;
|
|
806
|
+
}
|
|
807
|
+
if (!targetInfo.visible) {
|
|
808
|
+
log('info', `[yt-pw] thumbnail target <${tag.toLowerCase()}> not visible`);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// B2: try real CDP mouse click — trusted event.
|
|
813
|
+
try {
|
|
814
|
+
const [chooser] = await Promise.all([
|
|
815
|
+
page.waitForEvent('filechooser', { timeout: 5000 }),
|
|
816
|
+
page.mouse.click(targetInfo.x, targetInfo.y),
|
|
817
|
+
]);
|
|
818
|
+
await chooser.setFiles(thumbPath);
|
|
819
|
+
log('info', `[yt-pw] thumbnail set via mouse.click on <${tag.toLowerCase()}> @ (${Math.round(targetInfo.x)},${Math.round(targetInfo.y)}) — ${why}`);
|
|
820
|
+
thumbDone = true;
|
|
821
|
+
await page.waitForTimeout(800);
|
|
822
|
+
} catch (e) {
|
|
823
|
+
log('info', `[yt-pw] mouse.click on <${tag.toLowerCase()}> did not open picker: ${e.message.slice(0, 80)}`);
|
|
824
|
+
// Dump children on the last attempted target — so next iteration
|
|
825
|
+
// can patch selectors knowing exactly what's inside.
|
|
826
|
+
if (tag === 'YTCP-THUMBNAIL-WITH-TITLE' || tag === 'YTCP-THUMBNAIL') {
|
|
827
|
+
await dumpShadowChildren(tag).catch(() => {});
|
|
828
|
+
}
|
|
829
|
+
if (tag === 'YTCP-THUMBNAIL') {
|
|
830
|
+
await dumpShadowChildren('YTCP-VIDEO-THUMBNAIL-EDITOR').catch(() => {});
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Last-resort: also try the legacy CSS selectors in case some Studio
|
|
836
|
+
// builds DO expose them in light DOM.
|
|
837
|
+
if (!thumbDone) {
|
|
838
|
+
const cssTriggers = [
|
|
839
|
+
"#add-photo-icon",
|
|
840
|
+
"ytcp-uploads-dialog #add-photo-icon",
|
|
841
|
+
];
|
|
842
|
+
for (const sel of cssTriggers) {
|
|
843
|
+
if (thumbDone) break;
|
|
844
|
+
const loc = page.locator(sel).first();
|
|
845
|
+
if (await loc.count().catch(() => 0) === 0) continue;
|
|
846
|
+
if (!(await loc.isVisible().catch(() => false))) continue;
|
|
847
|
+
try {
|
|
848
|
+
const [chooser] = await Promise.all([
|
|
849
|
+
page.waitForEvent('filechooser', { timeout: 5000 }),
|
|
850
|
+
loc.click({ timeout: 3000 }),
|
|
851
|
+
]);
|
|
852
|
+
await chooser.setFiles(thumbPath);
|
|
853
|
+
log('info', `[yt-pw] thumbnail set via CSS ${sel}`);
|
|
854
|
+
thumbDone = true;
|
|
855
|
+
await page.waitForTimeout(800);
|
|
856
|
+
} catch (e) {
|
|
857
|
+
log('info', `[yt-pw] CSS trigger "${sel}" did not open picker: ${e.message.slice(0, 80)}`);
|
|
858
|
+
}
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
if (!thumbDone) throw new Error('thumbnail upload — no working trigger found; Studio markup changed');
|
|
864
|
+
} catch (e) {
|
|
865
|
+
// Re-throw to fail the cmd. Caller (cmd-result mirror) will surface
|
|
866
|
+
// the message on the idea's publish_results.
|
|
867
|
+
throw new Error(`thumbnail upload failed: ${e.message}`);
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// 7c) Show more (Hiện thêm) → reveals Tags input + other advanced fields.
|
|
872
|
+
// Toggle button id="toggle-button" with aria="Hiện chế độ cài đặt nâng cao".
|
|
873
|
+
// Best-effort: if the dialog already had it expanded, skip.
|
|
874
|
+
const showMoreClicked = await (async () => {
|
|
875
|
+
const sels = [
|
|
876
|
+
"ytcp-uploads-dialog #toggle-button",
|
|
877
|
+
"ytcp-uploads-dialog ytcp-button#toggle-button",
|
|
878
|
+
"ytcp-uploads-dialog ytcp-button[aria-label*='cài đặt nâng cao']",
|
|
879
|
+
"ytcp-uploads-dialog ytcp-button[aria-label*='advanced']",
|
|
880
|
+
"ytcp-uploads-dialog ytcp-button:has-text('Hiện thêm')",
|
|
881
|
+
"ytcp-uploads-dialog ytcp-button:has-text('Show more')",
|
|
882
|
+
];
|
|
883
|
+
for (const s of sels) {
|
|
884
|
+
const loc = page.locator(s).first();
|
|
885
|
+
if (await loc.count().catch(() => 0) === 0) continue;
|
|
886
|
+
if (!(await loc.isVisible().catch(() => false))) continue;
|
|
887
|
+
// Don't click again if already expanded — text flips to "Ẩn bớt".
|
|
888
|
+
const txt = (await loc.innerText().catch(() => '')).toLowerCase();
|
|
889
|
+
if (/ẩn|fewer|hide/.test(txt)) {
|
|
890
|
+
log('info', `[yt-pw] advanced settings already expanded`);
|
|
891
|
+
return true;
|
|
892
|
+
}
|
|
893
|
+
try {
|
|
894
|
+
await loc.click({ timeout: 3000 });
|
|
895
|
+
await page.waitForTimeout(600);
|
|
896
|
+
log('info', `[yt-pw] Show more clicked via ${s}`);
|
|
897
|
+
return true;
|
|
898
|
+
} catch (e) {
|
|
899
|
+
log('info', `[yt-pw] Show more click failed via ${s}: ${e.message.slice(0, 80)}`);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
return false;
|
|
903
|
+
})();
|
|
904
|
+
// STRICT: if caller sent tags, the advanced section MUST be expanded —
|
|
905
|
+
// tags are not optional once requested. If no tags requested, skip Show
|
|
906
|
+
// more entirely (cmd success doesn't depend on it).
|
|
907
|
+
if (tags?.length && !showMoreClicked) {
|
|
908
|
+
throw new Error('Show more / advanced settings toggle not found — tags requested but cannot be filled');
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
// 7d) Tags — fill only when caller requested them. STRICT: input must
|
|
912
|
+
// exist, every tag must be typed (modulo 480-char cap which is YT's own
|
|
913
|
+
// limit). Silent skipping was hiding selector regressions.
|
|
914
|
+
if (showMoreClicked && tags?.length) {
|
|
915
|
+
const tagSelCandidates = [
|
|
916
|
+
"ytcp-uploads-dialog ytcp-form-input-container[label*='Tag'] input",
|
|
917
|
+
"ytcp-uploads-dialog ytcp-form-input-container[label*='Thẻ'] input",
|
|
918
|
+
"ytcp-uploads-dialog ytcp-form-input-container[label*='标签'] input",
|
|
919
|
+
"ytcp-uploads-dialog ytcp-chip-bar input",
|
|
920
|
+
"ytcp-uploads-dialog ytcp-video-keywords input",
|
|
921
|
+
"ytcp-uploads-dialog input[aria-label*='Tag']",
|
|
922
|
+
"ytcp-uploads-dialog input[aria-label*='Thẻ']",
|
|
923
|
+
];
|
|
924
|
+
let tagInput = null;
|
|
925
|
+
let usedSel = '';
|
|
926
|
+
for (const s of tagSelCandidates) {
|
|
927
|
+
const loc = page.locator(s).first();
|
|
928
|
+
if (await loc.count().catch(() => 0) > 0 && await loc.isVisible().catch(() => false)) {
|
|
929
|
+
tagInput = loc;
|
|
930
|
+
usedSel = s;
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (!tagInput) {
|
|
935
|
+
throw new Error('tag input not found inside ytcp-uploads-dialog — Studio markup changed; update tagSelCandidates');
|
|
936
|
+
}
|
|
937
|
+
log('info', `[yt-pw] tag input found via ${usedSel}`);
|
|
938
|
+
try {
|
|
939
|
+
// Chip-bar: each tag added by typing then Enter. YT 500-char cap;
|
|
940
|
+
// clip with margin to avoid mid-type rejection.
|
|
941
|
+
let consumed = 0;
|
|
942
|
+
let filled = 0;
|
|
943
|
+
for (const raw of tags) {
|
|
944
|
+
const t = String(raw || '').replace(/[#,]/g, '').trim();
|
|
945
|
+
if (!t) continue;
|
|
946
|
+
if (consumed + t.length + 1 > 480) {
|
|
947
|
+
log('info', `[yt-pw] tag cap reached (~480 chars) — stopping at ${filled}/${tags.length}`);
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
await tagInput.click({ timeout: 2000 });
|
|
951
|
+
await tagInput.type(t, { delay: 20 });
|
|
952
|
+
await page.keyboard.press('Enter');
|
|
953
|
+
consumed += t.length + 1;
|
|
954
|
+
filled += 1;
|
|
955
|
+
await page.waitForTimeout(120);
|
|
956
|
+
}
|
|
957
|
+
log('info', `[yt-pw] tags filled: ${filled}/${tags.length}, ~${consumed} chars`);
|
|
958
|
+
} catch (e) {
|
|
959
|
+
throw new Error(`tags fill failed: ${e.message}`);
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Diagnostic: dump the Details-step DOM once BEFORE Show-more and once
|
|
964
|
+
// AFTER, so we can see how Studio reveals the tags + advanced inputs.
|
|
965
|
+
await inspectDetailsForm(page, log);
|
|
966
|
+
if (showMoreClicked) {
|
|
967
|
+
log('info', `[yt-pw] details-form inventory (POST show-more):`);
|
|
968
|
+
await inspectDetailsForm(page, log);
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// 7b) Audience question — Studio leaves Next disabled until the user
|
|
972
|
+
// answers "Made for kids? Yes/No". Default to "Not made for kids".
|
|
973
|
+
await selectNotMadeForKids(page, log);
|
|
974
|
+
|
|
975
|
+
// 7b.2) AI/Altered content disclosure — required when the video contains
|
|
976
|
+
// synthetic / AI-generated content. Our pipeline always uses Veo3 → YES.
|
|
977
|
+
await selectAlteredContentYes(page, log);
|
|
978
|
+
// (Removed second dismissOverlays sweep — once the upload modal is open,
|
|
979
|
+
// its own backdrop trips our gate, and clicking a "Bỏ qua / Skip" button
|
|
980
|
+
// anywhere on the page CLOSES the modal as a side effect. Dashboard
|
|
981
|
+
// onboarding gets dismissed only at page-load, before the modal exists.)
|
|
982
|
+
await jitter(page, 600);
|
|
983
|
+
|
|
984
|
+
// 8) Next → Next → Next (3 steps: Details, Video elements, Checks).
|
|
985
|
+
// First Next can take 1-2 min waiting for Studio's upload-validation step;
|
|
986
|
+
// later Nexts usually flip enabled within seconds. Poll for actionable.
|
|
987
|
+
for (let step = 0; step < 3; step++) {
|
|
988
|
+
let nextBtn;
|
|
989
|
+
try { nextBtn = await locateButton(page, S.nextButton, { timeoutMs: 15_000 }); }
|
|
990
|
+
catch (e) { await dumpFailure(page, `next-step${step + 1}-not-found`, log); throw e; }
|
|
991
|
+
try {
|
|
992
|
+
await waitAndClick(nextBtn, { timeoutMs: step === 0 ? 180_000 : 30_000, log, label: `Next(${step + 1}/3)` });
|
|
993
|
+
} catch (e) {
|
|
994
|
+
await dumpFailure(page, `next-step${step + 1}-not-actionable`, log);
|
|
995
|
+
throw e;
|
|
996
|
+
}
|
|
997
|
+
log('info', `[yt-pw] clicked Next (${step + 1}/3)`);
|
|
998
|
+
await jitter(page, 1000);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// 9) Visibility — Public / Unlisted / Private. STRICT: must be set or
|
|
1002
|
+
// Publish stays at whatever default Studio remembered (last upload's
|
|
1003
|
+
// visibility); silently leaving Public a private upload as Unlisted etc.
|
|
1004
|
+
// is exactly the kind of bypass the user wants to fail loudly.
|
|
1005
|
+
const visSpec = S.visibilityRadio[visibility] || S.visibilityRadio.public;
|
|
1006
|
+
log('info', `[yt-pw] visibility=${visibility}`);
|
|
1007
|
+
try {
|
|
1008
|
+
const visBtn = await locateField(page, visSpec, { timeoutMs: 10_000 });
|
|
1009
|
+
await visBtn.click();
|
|
1010
|
+
} catch (e) {
|
|
1011
|
+
throw new Error(`visibility radio (${visibility}) not clickable: ${e.message}`);
|
|
1012
|
+
}
|
|
1013
|
+
await jitter(page, 600);
|
|
1014
|
+
|
|
1015
|
+
// 10) Publish — waits up to 5 min for upload processing before it enables.
|
|
1016
|
+
log('info', `[yt-pw] waiting for Publish to enable…`);
|
|
1017
|
+
let pubBtn;
|
|
1018
|
+
try { pubBtn = await locateButton(page, S.publishButton, { timeoutMs: 60_000 }); }
|
|
1019
|
+
catch (e) { await dumpFailure(page, 'publish-not-found', log); throw e; }
|
|
1020
|
+
try {
|
|
1021
|
+
await waitAndClick(pubBtn, { timeoutMs: 5 * 60_000, log, label: 'Publish' });
|
|
1022
|
+
} catch (e) {
|
|
1023
|
+
await dumpFailure(page, 'publish-not-actionable', log);
|
|
1024
|
+
throw e;
|
|
1025
|
+
}
|
|
1026
|
+
log('info', `[yt-pw] Publish clicked`);
|
|
1027
|
+
|
|
1028
|
+
// 11) Studio shows a "Video đã xuất bản / Video published" confirmation
|
|
1029
|
+
// dialog after Publish with a share-URL input. Poll for it up to 60s —
|
|
1030
|
+
// initial encode is short for our 8s scenes, so this normally lands in
|
|
1031
|
+
// a few seconds. If we can't find it (Shorts vs Long flow, scheduled, A/B
|
|
1032
|
+
// test), we still return ok=true — the upload itself succeeded.
|
|
1033
|
+
log('info', `[yt-pw] waiting for share URL…`);
|
|
1034
|
+
let postUrl = '';
|
|
1035
|
+
|
|
1036
|
+
// Fast path: did we capture the videoId from a network response earlier?
|
|
1037
|
+
// Take the LAST one observed during the run — that's the upload we just
|
|
1038
|
+
// performed, not an earlier Creators/recommendation tile.
|
|
1039
|
+
const networkIds = payload.__networkVideoIds || [];
|
|
1040
|
+
if (networkIds.length) {
|
|
1041
|
+
const lastId = networkIds[networkIds.length - 1];
|
|
1042
|
+
// Shorts vs long: detect by format param if caller hinted, else default
|
|
1043
|
+
// to /watch?v= (works for both — YT auto-redirects shorts there).
|
|
1044
|
+
const isShorts = String(payload.format || '').toLowerCase().includes('short');
|
|
1045
|
+
postUrl = isShorts ? `https://www.youtube.com/shorts/${lastId}` : `https://www.youtube.com/watch?v=${lastId}`;
|
|
1046
|
+
log('info', `[yt-pw] post URL from network: ${postUrl}`);
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Extract a YouTube video URL only if it looks like an actual video link
|
|
1050
|
+
// (youtu.be/<id>, youtube.com/watch?v=<id>, or youtube.com/shorts/<id>).
|
|
1051
|
+
// Reject Studio internal links (studio.youtube.com/...), Creators/marketing
|
|
1052
|
+
// links (youtube.com/creators, /howyoutubeworks, /about, etc.), and channel
|
|
1053
|
+
// pages. Past bug: the published-dialog has a "YouTube Creators" footer
|
|
1054
|
+
// link that the generic a[href*='youtube.com/watch'] selector matched
|
|
1055
|
+
// first, so we returned a marketing URL as the post URL.
|
|
1056
|
+
const looksLikeVideoUrl = (raw) => {
|
|
1057
|
+
if (!raw) return null;
|
|
1058
|
+
const s = String(raw).trim();
|
|
1059
|
+
// studio.* is the editor URL — never the public video.
|
|
1060
|
+
if (/studio\.youtube\.com/i.test(s)) return null;
|
|
1061
|
+
// Creators / marketing / docs / about pages.
|
|
1062
|
+
if (/youtube\.com\/(creators|howyoutubeworks|about|t\/|kids|premium|music)\b/i.test(s)) return null;
|
|
1063
|
+
const m =
|
|
1064
|
+
s.match(/https?:\/\/youtu\.be\/([A-Za-z0-9_-]{8,15})(?:[/?#]|$)/)
|
|
1065
|
+
|| s.match(/https?:\/\/(?:www\.|m\.)?youtube\.com\/watch\?[^"\s]*v=([A-Za-z0-9_-]{8,15})/)
|
|
1066
|
+
|| s.match(/https?:\/\/(?:www\.|m\.)?youtube\.com\/shorts\/([A-Za-z0-9_-]{8,15})/);
|
|
1067
|
+
return m ? m[0] : null;
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
// Scope the search to the ACTUAL share dialog/card container — never the
|
|
1071
|
+
// entire page. The post-publish surface is always one of these wrappers:
|
|
1072
|
+
// ytcp-video-info-share-card (newer Studio)
|
|
1073
|
+
// ytcp-uploads-still-processing-dialog (encoding-in-progress dialog)
|
|
1074
|
+
// ytcp-video-share / ytcp-video-share-url (older Studio)
|
|
1075
|
+
// Inside that scope, prefer the readonly input (canonical share URL), then
|
|
1076
|
+
// fall back to anchors. The anchor sweep is bounded to the same scope so
|
|
1077
|
+
// marketing links elsewhere on the page can't poison the result.
|
|
1078
|
+
const scopeSelectors = [
|
|
1079
|
+
'ytcp-video-info-share-card',
|
|
1080
|
+
'ytcp-video-share',
|
|
1081
|
+
'ytcp-video-share-url',
|
|
1082
|
+
'ytcp-uploads-still-processing-dialog',
|
|
1083
|
+
// Last-resort fallback — but still must contain a copy-button to count
|
|
1084
|
+
// as a publish dialog, not a stray button somewhere on the page.
|
|
1085
|
+
"ytcp-uploads-dialog ytcp-copy-link, ytcp-uploads-dialog [aria-label*='ideo URL']",
|
|
1086
|
+
];
|
|
1087
|
+
|
|
1088
|
+
const tryExtract = async () => {
|
|
1089
|
+
for (const scopeSel of scopeSelectors) {
|
|
1090
|
+
const scope = page.locator(scopeSel).first();
|
|
1091
|
+
if (await scope.count().catch(() => 0) === 0) continue;
|
|
1092
|
+
// 1) readonly inputs inside scope
|
|
1093
|
+
const inputs = scope.locator("input[readonly], input[type='url'], textarea[readonly]");
|
|
1094
|
+
const inputCount = Math.min(await inputs.count().catch(() => 0), 4);
|
|
1095
|
+
for (let i = 0; i < inputCount; i++) {
|
|
1096
|
+
const v = await inputs.nth(i).getAttribute('value').catch(() => null);
|
|
1097
|
+
const hit = looksLikeVideoUrl(v);
|
|
1098
|
+
if (hit) return hit;
|
|
1099
|
+
}
|
|
1100
|
+
// 2) anchors inside scope
|
|
1101
|
+
const anchors = scope.locator("a[href]");
|
|
1102
|
+
const anchorCount = Math.min(await anchors.count().catch(() => 0), 6);
|
|
1103
|
+
for (let i = 0; i < anchorCount; i++) {
|
|
1104
|
+
const v = await anchors.nth(i).getAttribute('href').catch(() => null);
|
|
1105
|
+
const hit = looksLikeVideoUrl(v);
|
|
1106
|
+
if (hit) return hit;
|
|
1107
|
+
}
|
|
1108
|
+
// 3) text content (some Studio variants put the URL plain-text in a div)
|
|
1109
|
+
const text = await scope.innerText().catch(() => '');
|
|
1110
|
+
const hit = looksLikeVideoUrl(text);
|
|
1111
|
+
if (hit) return hit;
|
|
1112
|
+
}
|
|
1113
|
+
return null;
|
|
1114
|
+
};
|
|
1115
|
+
|
|
1116
|
+
const urlDeadline = Date.now() + 60_000;
|
|
1117
|
+
while (Date.now() < urlDeadline && !postUrl) {
|
|
1118
|
+
const hit = await tryExtract();
|
|
1119
|
+
if (hit) { postUrl = hit; break; }
|
|
1120
|
+
await page.waitForTimeout(1500);
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (postUrl) {
|
|
1124
|
+
log('info', `[yt-pw] post URL: ${postUrl}`);
|
|
1125
|
+
} else {
|
|
1126
|
+
// Last-ditch — sometimes the dialog never appears (scheduled/processing),
|
|
1127
|
+
// but YT writes a recent-upload row to the page. Dump for diagnostics.
|
|
1128
|
+
await dumpFailure(page, 'post-url-not-found', log);
|
|
1129
|
+
log('warn', `[yt-pw] could not extract post URL — upload still considered ok`);
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
// Close the confirmation dialog if any (best-effort — doesn't affect result).
|
|
1133
|
+
try {
|
|
1134
|
+
const closeBtn = await firstVisible(page.locator("ytcp-uploads-still-processing-dialog button, ytcp-video-share + button, button:has-text('Close'), button:has-text('Đóng')"), 3);
|
|
1135
|
+
if (closeBtn) await closeBtn.click({ timeout: 2000 }).catch(() => {});
|
|
1136
|
+
} catch {}
|
|
1137
|
+
|
|
1138
|
+
return {
|
|
1139
|
+
ok: true,
|
|
1140
|
+
title,
|
|
1141
|
+
visibility,
|
|
1142
|
+
post_url: postUrl,
|
|
1143
|
+
video_path_local: videoPath,
|
|
1144
|
+
};
|
|
1145
|
+
} finally {
|
|
1146
|
+
safeUnlink(videoPath);
|
|
1147
|
+
if (thumbPath) safeUnlink(thumbPath);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
1151
|
+
module.exports = { run };
|