From 87e17963a4bc0690f66345a3eb80a8cdbe386123 Mon Sep 17 00:00:00 2001 From: haoyueb2 <742932097@qq.com> Date: Sun, 29 Mar 2026 17:05:23 +0800 Subject: [PATCH 1/4] Mute and pause YouTube watch pages for read commands --- src/clis/youtube/transcript.ts | 3 ++- src/clis/youtube/utils.test.ts | 22 ++++++++++++++++++++++ src/clis/youtube/utils.ts | 31 +++++++++++++++++++++++++++++++ src/clis/youtube/video.ts | 3 ++- 4 files changed, 57 insertions(+), 2 deletions(-) create mode 100644 src/clis/youtube/utils.test.ts diff --git a/src/clis/youtube/transcript.ts b/src/clis/youtube/transcript.ts index 441f9081..aa04c345 100644 --- a/src/clis/youtube/transcript.ts +++ b/src/clis/youtube/transcript.ts @@ -10,7 +10,7 @@ * --mode raw: every caption segment as-is with precise timestamps */ import { cli, Strategy } from '../../registry.js'; -import { parseVideoId } from './utils.js'; +import { parseVideoId, quietWatchPlayback } from './utils.js'; import { groupTranscriptSegments, formatGroupedTranscript, @@ -36,6 +36,7 @@ cli({ const videoId = parseVideoId(kwargs.url); const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; await page.goto(videoUrl); + await quietWatchPlayback(page); await page.wait(3); const lang = kwargs.lang || ''; diff --git a/src/clis/youtube/utils.test.ts b/src/clis/youtube/utils.test.ts new file mode 100644 index 00000000..14a15339 --- /dev/null +++ b/src/clis/youtube/utils.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it, vi } from 'vitest'; +import { buildQuietPlaybackJs, quietWatchPlayback } from './utils.js'; + +describe('youtube utils', () => { + it('buildQuietPlaybackJs mutes and pauses both player and media element', () => { + const js = buildQuietPlaybackJs(); + expect(js).toContain('player?.mute'); + expect(js).toContain('player?.pauseVideo'); + expect(js).toContain("document.querySelector('video')"); + expect(js).toContain('media.muted = true'); + expect(js).toContain('media.pause()'); + }); + + it('quietWatchPlayback ignores evaluation failures', async () => { + const page = { + evaluate: vi.fn().mockRejectedValue(new Error('boom')), + }; + + await expect(quietWatchPlayback(page as any)).resolves.toBeUndefined(); + expect(page.evaluate).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/clis/youtube/utils.ts b/src/clis/youtube/utils.ts index 9ceaf414..7425c1d2 100644 --- a/src/clis/youtube/utils.ts +++ b/src/clis/youtube/utils.ts @@ -1,6 +1,7 @@ /** * Shared YouTube utilities — URL parsing, video ID extraction, etc. */ +import type { IPage } from '../../types.js'; /** * Extract a YouTube video ID from a URL or bare video ID string. @@ -26,3 +27,33 @@ export function parseVideoId(input: string): string { return input; } + +export function buildQuietPlaybackJs(): string { + return ` + (async () => { + try { + const player = window.movie_player; + if (player?.mute) player.mute(); + if (player?.pauseVideo) player.pauseVideo(); + } catch {} + + try { + const media = document.querySelector('video'); + if (media) { + media.muted = true; + media.pause(); + } + } catch {} + + return true; + })() + `; +} + +export async function quietWatchPlayback(page: IPage): Promise { + try { + await page.evaluate(buildQuietPlaybackJs()); + } catch { + // Best-effort only — metadata/transcript extraction should continue. + } +} diff --git a/src/clis/youtube/video.ts b/src/clis/youtube/video.ts index 7ed4aab3..a1674904 100644 --- a/src/clis/youtube/video.ts +++ b/src/clis/youtube/video.ts @@ -2,7 +2,7 @@ * YouTube video metadata — read ytInitialPlayerResponse + ytInitialData from video page. */ import { cli, Strategy } from '../../registry.js'; -import { parseVideoId } from './utils.js'; +import { parseVideoId, quietWatchPlayback } from './utils.js'; import { CommandExecutionError } from '../../errors.js'; cli({ @@ -19,6 +19,7 @@ cli({ const videoId = parseVideoId(kwargs.url); const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; await page.goto(videoUrl); + await quietWatchPlayback(page); await page.wait(3); const data = await page.evaluate(` From 2defc7be9ad4f628c800f03c7500aeeafa093c55 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 29 Mar 2026 22:48:07 +0800 Subject: [PATCH 2/4] fix(youtube): quiet watch pages earlier --- src/clis/youtube/transcript.ts | 2 +- src/clis/youtube/utils.test.ts | 2 ++ src/clis/youtube/utils.ts | 47 +++++++++++++++++++++++----------- src/clis/youtube/video.ts | 2 +- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/src/clis/youtube/transcript.ts b/src/clis/youtube/transcript.ts index aa04c345..17c95583 100644 --- a/src/clis/youtube/transcript.ts +++ b/src/clis/youtube/transcript.ts @@ -35,7 +35,7 @@ cli({ func: async (page, kwargs) => { const videoId = parseVideoId(kwargs.url); const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; - await page.goto(videoUrl); + await page.goto(videoUrl, { waitUntil: 'none' }); await quietWatchPlayback(page); await page.wait(3); diff --git a/src/clis/youtube/utils.test.ts b/src/clis/youtube/utils.test.ts index 14a15339..e0591116 100644 --- a/src/clis/youtube/utils.test.ts +++ b/src/clis/youtube/utils.test.ts @@ -4,6 +4,8 @@ import { buildQuietPlaybackJs, quietWatchPlayback } from './utils.js'; describe('youtube utils', () => { it('buildQuietPlaybackJs mutes and pauses both player and media element', () => { const js = buildQuietPlaybackJs(); + expect(js).toContain('Date.now() + 5000'); + expect(js).toContain('await wait(100)'); expect(js).toContain('player?.mute'); expect(js).toContain('player?.pauseVideo'); expect(js).toContain("document.querySelector('video')"); diff --git a/src/clis/youtube/utils.ts b/src/clis/youtube/utils.ts index 7425c1d2..778e4728 100644 --- a/src/clis/youtube/utils.ts +++ b/src/clis/youtube/utils.ts @@ -31,21 +31,38 @@ export function parseVideoId(input: string): string { export function buildQuietPlaybackJs(): string { return ` (async () => { - try { - const player = window.movie_player; - if (player?.mute) player.mute(); - if (player?.pauseVideo) player.pauseVideo(); - } catch {} - - try { - const media = document.querySelector('video'); - if (media) { - media.muted = true; - media.pause(); - } - } catch {} - - return true; + const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + const deadline = Date.now() + 5000; + + while (Date.now() < deadline) { + let quieted = false; + + try { + const player = window.movie_player; + if (player?.mute) { + player.mute(); + quieted = true; + } + if (player?.pauseVideo) { + player.pauseVideo(); + quieted = true; + } + } catch {} + + try { + const media = document.querySelector('video'); + if (media) { + media.muted = true; + media.pause(); + quieted = true; + } + } catch {} + + if (quieted) return true; + await wait(100); + } + + return false; })() `; } diff --git a/src/clis/youtube/video.ts b/src/clis/youtube/video.ts index a1674904..758bc2d0 100644 --- a/src/clis/youtube/video.ts +++ b/src/clis/youtube/video.ts @@ -18,7 +18,7 @@ cli({ func: async (page, kwargs) => { const videoId = parseVideoId(kwargs.url); const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; - await page.goto(videoUrl); + await page.goto(videoUrl, { waitUntil: 'none' }); await quietWatchPlayback(page); await page.wait(3); From b346badced521bf091348e6b91524bd181b2b3f3 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 29 Mar 2026 23:09:13 +0800 Subject: [PATCH 3/4] refactor(youtube): avoid watch ui for read commands --- src/clis/youtube/transcript.ts | 7 +-- src/clis/youtube/utils.test.ts | 23 +++------ src/clis/youtube/utils.ts | 51 +++----------------- src/clis/youtube/video.ts | 86 +++++++++++++++++++++++++++------- 4 files changed, 85 insertions(+), 82 deletions(-) diff --git a/src/clis/youtube/transcript.ts b/src/clis/youtube/transcript.ts index 17c95583..107b3ec0 100644 --- a/src/clis/youtube/transcript.ts +++ b/src/clis/youtube/transcript.ts @@ -10,7 +10,7 @@ * --mode raw: every caption segment as-is with precise timestamps */ import { cli, Strategy } from '../../registry.js'; -import { parseVideoId, quietWatchPlayback } from './utils.js'; +import { parseVideoId, prepareYoutubeApiPage } from './utils.js'; import { groupTranscriptSegments, formatGroupedTranscript, @@ -34,10 +34,7 @@ cli({ // so we let the renderer auto-detect columns from the data keys. func: async (page, kwargs) => { const videoId = parseVideoId(kwargs.url); - const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; - await page.goto(videoUrl, { waitUntil: 'none' }); - await quietWatchPlayback(page); - await page.wait(3); + await prepareYoutubeApiPage(page); const lang = kwargs.lang || ''; const mode = kwargs.mode || 'grouped'; diff --git a/src/clis/youtube/utils.test.ts b/src/clis/youtube/utils.test.ts index e0591116..12aa5fda 100644 --- a/src/clis/youtube/utils.test.ts +++ b/src/clis/youtube/utils.test.ts @@ -1,24 +1,15 @@ import { describe, expect, it, vi } from 'vitest'; -import { buildQuietPlaybackJs, quietWatchPlayback } from './utils.js'; +import { prepareYoutubeApiPage } from './utils.js'; describe('youtube utils', () => { - it('buildQuietPlaybackJs mutes and pauses both player and media element', () => { - const js = buildQuietPlaybackJs(); - expect(js).toContain('Date.now() + 5000'); - expect(js).toContain('await wait(100)'); - expect(js).toContain('player?.mute'); - expect(js).toContain('player?.pauseVideo'); - expect(js).toContain("document.querySelector('video')"); - expect(js).toContain('media.muted = true'); - expect(js).toContain('media.pause()'); - }); - - it('quietWatchPlayback ignores evaluation failures', async () => { + it('prepareYoutubeApiPage loads the quiet API bootstrap page', async () => { const page = { - evaluate: vi.fn().mockRejectedValue(new Error('boom')), + goto: vi.fn().mockResolvedValue(undefined), + wait: vi.fn().mockResolvedValue(undefined), }; - await expect(quietWatchPlayback(page as any)).resolves.toBeUndefined(); - expect(page.evaluate).toHaveBeenCalledTimes(1); + await expect(prepareYoutubeApiPage(page as any)).resolves.toBeUndefined(); + expect(page.goto).toHaveBeenCalledWith('https://www.youtube.com', { waitUntil: 'none' }); + expect(page.wait).toHaveBeenCalledWith(2); }); }); diff --git a/src/clis/youtube/utils.ts b/src/clis/youtube/utils.ts index 778e4728..d2886a28 100644 --- a/src/clis/youtube/utils.ts +++ b/src/clis/youtube/utils.ts @@ -28,49 +28,10 @@ export function parseVideoId(input: string): string { return input; } -export function buildQuietPlaybackJs(): string { - return ` - (async () => { - const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); - const deadline = Date.now() + 5000; - - while (Date.now() < deadline) { - let quieted = false; - - try { - const player = window.movie_player; - if (player?.mute) { - player.mute(); - quieted = true; - } - if (player?.pauseVideo) { - player.pauseVideo(); - quieted = true; - } - } catch {} - - try { - const media = document.querySelector('video'); - if (media) { - media.muted = true; - media.pause(); - quieted = true; - } - } catch {} - - if (quieted) return true; - await wait(100); - } - - return false; - })() - `; -} - -export async function quietWatchPlayback(page: IPage): Promise { - try { - await page.evaluate(buildQuietPlaybackJs()); - } catch { - // Best-effort only — metadata/transcript extraction should continue. - } +/** + * Prepare a quiet YouTube API-capable page without opening the watch UI. + */ +export async function prepareYoutubeApiPage(page: IPage): Promise { + await page.goto('https://www.youtube.com', { waitUntil: 'none' }); + await page.wait(2); } diff --git a/src/clis/youtube/video.ts b/src/clis/youtube/video.ts index 758bc2d0..b889e521 100644 --- a/src/clis/youtube/video.ts +++ b/src/clis/youtube/video.ts @@ -1,8 +1,8 @@ /** - * YouTube video metadata — read ytInitialPlayerResponse + ytInitialData from video page. + * YouTube video metadata — fetch watch HTML and parse bootstrap data without opening the watch UI. */ import { cli, Strategy } from '../../registry.js'; -import { parseVideoId, quietWatchPlayback } from './utils.js'; +import { parseVideoId, prepareYoutubeApiPage } from './utils.js'; import { CommandExecutionError } from '../../errors.js'; cli({ @@ -17,25 +17,83 @@ cli({ columns: ['field', 'value'], func: async (page, kwargs) => { const videoId = parseVideoId(kwargs.url); - const videoUrl = `https://www.youtube.com/watch?v=${videoId}`; - await page.goto(videoUrl, { waitUntil: 'none' }); - await quietWatchPlayback(page); - await page.wait(3); + await prepareYoutubeApiPage(page); const data = await page.evaluate(` (async () => { - const player = window.ytInitialPlayerResponse; - const yt = window.ytInitialData; - if (!player) return { error: 'ytInitialPlayerResponse not found' }; + function extractJsonAssignment(html, keys) { + const candidates = Array.isArray(keys) ? keys : [keys]; + for (const key of candidates) { + const markers = [ + 'var ' + key + ' = ', + 'window["' + key + '"] = ', + 'window.' + key + ' = ', + key + ' = ', + ]; + for (const marker of markers) { + const markerIndex = html.indexOf(marker); + if (markerIndex === -1) continue; + + const jsonStart = html.indexOf('{', markerIndex + marker.length); + if (jsonStart === -1) continue; + + let depth = 0; + let inString = false; + let escaping = false; + for (let i = jsonStart; i < html.length; i++) { + const ch = html[i]; + if (inString) { + if (escaping) { + escaping = false; + } else if (ch === '\\\\') { + escaping = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') { + depth += 1; + continue; + } + if (ch === '}') { + depth -= 1; + if (depth === 0) { + try { + return JSON.parse(html.slice(jsonStart, i + 1)); + } catch { + break; + } + } + } + } + } + } + return null; + } + + const watchResp = await fetch('/watch?v=' + encodeURIComponent(${JSON.stringify(videoId)}), { + credentials: 'include', + }); + if (!watchResp.ok) return { error: 'Watch HTML returned HTTP ' + watchResp.status }; + + const html = await watchResp.text(); + const player = extractJsonAssignment(html, 'ytInitialPlayerResponse'); + const yt = extractJsonAssignment(html, 'ytInitialData'); + if (!player) return { error: 'ytInitialPlayerResponse not found in watch HTML' }; const details = player.videoDetails || {}; const microformat = player.microformat?.playerMicroformatRenderer || {}; + const contents = yt?.contents?.twoColumnWatchNextResults?.results?.results?.contents || []; - // Try to get full description from ytInitialData + // Try to get full description from watch bootstrap data let fullDescription = details.shortDescription || ''; try { - const contents = yt?.contents?.twoColumnWatchNextResults - ?.results?.results?.contents; if (contents) { for (const c of contents) { const desc = c.videoSecondaryInfoRenderer?.attributedDescription?.content; @@ -47,8 +105,6 @@ cli({ // Get like count if available let likes = ''; try { - const contents = yt?.contents?.twoColumnWatchNextResults - ?.results?.results?.contents; if (contents) { for (const c of contents) { const buttons = c.videoPrimaryInfoRenderer?.videoActions @@ -76,8 +132,6 @@ cli({ // Get channel subscriber count if available let subscribers = ''; try { - const contents = yt?.contents?.twoColumnWatchNextResults - ?.results?.results?.contents; if (contents) { for (const c of contents) { const owner = c.videoSecondaryInfoRenderer?.owner From 898bbc805fac337a48dcec612f64689a47a56d27 Mon Sep 17 00:00:00 2001 From: jackwener Date: Sun, 29 Mar 2026 23:19:12 +0800 Subject: [PATCH 4/4] test(youtube): cover html bootstrap parser --- src/clis/youtube/utils.test.ts | 30 +++++++++++++++- src/clis/youtube/utils.ts | 60 ++++++++++++++++++++++++++++++++ src/clis/youtube/video.ts | 62 +++------------------------------- 3 files changed, 93 insertions(+), 59 deletions(-) diff --git a/src/clis/youtube/utils.test.ts b/src/clis/youtube/utils.test.ts index 12aa5fda..4189e083 100644 --- a/src/clis/youtube/utils.test.ts +++ b/src/clis/youtube/utils.test.ts @@ -1,7 +1,35 @@ import { describe, expect, it, vi } from 'vitest'; -import { prepareYoutubeApiPage } from './utils.js'; +import { extractJsonAssignmentFromHtml, prepareYoutubeApiPage } from './utils.js'; describe('youtube utils', () => { + it('extractJsonAssignmentFromHtml parses bootstrap objects with nested braces in strings', () => { + const html = ` + + `; + + expect(extractJsonAssignmentFromHtml(html, 'ytInitialPlayerResponse')).toEqual({ + title: 'brace { inside } string', + nested: { count: 2, text: 'quote "value"' }, + }); + }); + + it('extractJsonAssignmentFromHtml supports window assignments', () => { + const html = ` + + `; + + expect(extractJsonAssignmentFromHtml(html, 'ytInitialData')).toEqual({ + contents: { items: [1, 2, 3] }, + }); + }); + it('prepareYoutubeApiPage loads the quiet API bootstrap page', async () => { const page = { goto: vi.fn().mockResolvedValue(undefined), diff --git a/src/clis/youtube/utils.ts b/src/clis/youtube/utils.ts index d2886a28..caa73050 100644 --- a/src/clis/youtube/utils.ts +++ b/src/clis/youtube/utils.ts @@ -28,6 +28,66 @@ export function parseVideoId(input: string): string { return input; } +/** + * Extract a JSON object assigned to a known bootstrap variable inside YouTube HTML. + */ +export function extractJsonAssignmentFromHtml(html: string, keys: string | string[]): Record | null { + const candidates = Array.isArray(keys) ? keys : [keys]; + for (const key of candidates) { + const markers = [ + `var ${key} = `, + `window["${key}"] = `, + `window.${key} = `, + `${key} = `, + ]; + for (const marker of markers) { + const markerIndex = html.indexOf(marker); + if (markerIndex === -1) continue; + + const jsonStart = html.indexOf('{', markerIndex + marker.length); + if (jsonStart === -1) continue; + + let depth = 0; + let inString = false; + let escaping = false; + for (let i = jsonStart; i < html.length; i += 1) { + const ch = html[i]; + if (inString) { + if (escaping) { + escaping = false; + } else if (ch === '\\') { + escaping = true; + } else if (ch === '"') { + inString = false; + } + continue; + } + + if (ch === '"') { + inString = true; + continue; + } + if (ch === '{') { + depth += 1; + continue; + } + if (ch === '}') { + depth -= 1; + if (depth === 0) { + try { + return JSON.parse(html.slice(jsonStart, i + 1)) as Record; + } catch { + break; + } + } + } + } + } + } + + return null; +} + /** * Prepare a quiet YouTube API-capable page without opening the watch UI. */ diff --git a/src/clis/youtube/video.ts b/src/clis/youtube/video.ts index b889e521..84cf883c 100644 --- a/src/clis/youtube/video.ts +++ b/src/clis/youtube/video.ts @@ -2,7 +2,7 @@ * YouTube video metadata — fetch watch HTML and parse bootstrap data without opening the watch UI. */ import { cli, Strategy } from '../../registry.js'; -import { parseVideoId, prepareYoutubeApiPage } from './utils.js'; +import { extractJsonAssignmentFromHtml, parseVideoId, prepareYoutubeApiPage } from './utils.js'; import { CommandExecutionError } from '../../errors.js'; cli({ @@ -21,61 +21,7 @@ cli({ const data = await page.evaluate(` (async () => { - function extractJsonAssignment(html, keys) { - const candidates = Array.isArray(keys) ? keys : [keys]; - for (const key of candidates) { - const markers = [ - 'var ' + key + ' = ', - 'window["' + key + '"] = ', - 'window.' + key + ' = ', - key + ' = ', - ]; - for (const marker of markers) { - const markerIndex = html.indexOf(marker); - if (markerIndex === -1) continue; - - const jsonStart = html.indexOf('{', markerIndex + marker.length); - if (jsonStart === -1) continue; - - let depth = 0; - let inString = false; - let escaping = false; - for (let i = jsonStart; i < html.length; i++) { - const ch = html[i]; - if (inString) { - if (escaping) { - escaping = false; - } else if (ch === '\\\\') { - escaping = true; - } else if (ch === '"') { - inString = false; - } - continue; - } - - if (ch === '"') { - inString = true; - continue; - } - if (ch === '{') { - depth += 1; - continue; - } - if (ch === '}') { - depth -= 1; - if (depth === 0) { - try { - return JSON.parse(html.slice(jsonStart, i + 1)); - } catch { - break; - } - } - } - } - } - } - return null; - } + const extractJsonAssignmentFromHtml = ${extractJsonAssignmentFromHtml.toString()}; const watchResp = await fetch('/watch?v=' + encodeURIComponent(${JSON.stringify(videoId)}), { credentials: 'include', @@ -83,8 +29,8 @@ cli({ if (!watchResp.ok) return { error: 'Watch HTML returned HTTP ' + watchResp.status }; const html = await watchResp.text(); - const player = extractJsonAssignment(html, 'ytInitialPlayerResponse'); - const yt = extractJsonAssignment(html, 'ytInitialData'); + const player = extractJsonAssignmentFromHtml(html, 'ytInitialPlayerResponse'); + const yt = extractJsonAssignmentFromHtml(html, 'ytInitialData'); if (!player) return { error: 'ytInitialPlayerResponse not found in watch HTML' }; const details = player.videoDetails || {};