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.
@@ -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 };