From bb64ed04920351fda5980d90b42634351a1e3be6 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Apr 2026 21:55:22 +0900 Subject: [PATCH] =?UTF-8?q?SPA=E5=AF=BE=E5=BF=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/index.ts | 12 +- src/injected/page-script.ts | 257 ++++++++++++++++++++++++------------ 2 files changed, 185 insertions(+), 84 deletions(-) diff --git a/src/content/index.ts b/src/content/index.ts index 069cace..09b3c4f 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -7,12 +7,15 @@ import type { Message } from "../shared/messages"; const LOG = "[yt-playlist-features]"; let lastExtractedId: string | null = null; +let pageScriptInjected = false; -function injectPageScript(): void { +function ensurePageScript(): void { + if (pageScriptInjected) return; const script = document.createElement("script"); script.src = browser.runtime.getURL("injected/page-script.js"); script.onload = () => script.remove(); (document.head || document.documentElement).appendChild(script); + pageScriptInjected = true; } function handlePlaylistData(event: Event): void { @@ -91,7 +94,10 @@ onPlaylistPageReady(() => { if (!playlistId || playlistId === lastExtractedId) return; lastExtractedId = playlistId; + ensurePageScript(); + // Page script handles yt-navigate-finish itself, but send a trigger + // as a fallback in case it missed the event (e.g. timing edge cases) setTimeout(() => { - injectPageScript(); - }, 100); + document.dispatchEvent(new CustomEvent("__yt_playlist_ext_trigger")); + }, 50); }); \ No newline at end of file diff --git a/src/injected/page-script.ts b/src/injected/page-script.ts index 84cc7a8..3520c70 100644 --- a/src/injected/page-script.ts +++ b/src/injected/page-script.ts @@ -2,50 +2,184 @@ // Fetches all playlist videos using YouTube's browse API from page context // (includes cookies/auth via same-origin credentials). // Replicates YouTube's internal request format including auth headers. +// +// Injected once and persists across SPA navigations. +// - Initial load: reads window.ytInitialData +// - SPA navigation: fetches playlist data via browse API (ytInitialData is stale) -(async () => { +(() => { const LOG = "[yt-playlist-features]"; const w = window as any; - const initialData = w.ytInitialData; - if (!initialData) { - sendResult({ error: "no-ytInitialData" }); - return; + // Prevent double-injection + if (w.__yt_playlist_ext_injected) return; + w.__yt_playlist_ext_injected = true; + + // Run extraction for the current page if it's a playlist + if (isPlaylistUrl()) { + extractAndSend(); } - const cfg = w.ytcfg?.data_ ?? {}; - const apiKey = cfg.INNERTUBE_API_KEY ?? ""; - const baseContext = cfg.INNERTUBE_CONTEXT ?? null; - - const videoList = findVideoListContents(initialData); - if (!videoList) { - sendResult({ error: "no-video-list" }); - return; - } - - const allRenderers: any[] = []; - let continuation: string | null = null; - let continuationClickTracking: string | null = null; - - for (const item of videoList) { - if (item.playlistVideoRenderer) { - allRenderers.push(item.playlistVideoRenderer); + // Listen for SPA navigation + document.addEventListener("yt-navigate-finish", () => { + if (isPlaylistUrl()) { + extractAndSend(); } - if (item.continuationItemRenderer) { - const extracted = extractContinuationInfo(item.continuationItemRenderer); - continuation = extracted.token; - continuationClickTracking = extracted.clickTracking; + }); + + // Listen for explicit trigger from content script + document.addEventListener("__yt_playlist_ext_trigger", () => { + if (isPlaylistUrl()) { + extractAndSend(); } + }); + + function isPlaylistUrl(): boolean { + const url = new URL(window.location.href); + return url.pathname === "/playlist" && url.searchParams.has("list"); } - // Build auth headers matching YouTube's internal requests - const authHeaders = buildAuthHeaders(); + function getPlaylistId(): string | null { + const url = new URL(window.location.href); + return url.searchParams.get("list"); + } - // Fetch remaining pages - let page = 1; - while (continuation && page < 50) { + function getConfig() { + const cfg = w.ytcfg?.data_ ?? {}; + return { + cfg, + apiKey: cfg.INNERTUBE_API_KEY ?? "", + baseContext: cfg.INNERTUBE_CONTEXT ?? null, + }; + } + + async function extractAndSend(): Promise { + const { cfg, apiKey, baseContext } = getConfig(); + const authHeaders = buildAuthHeaders(cfg, baseContext); + + // Try ytInitialData first (works on initial page load) + let initialData = w.ytInitialData; + let videoList = initialData ? findVideoListContents(initialData) : null; + + // If ytInitialData doesn't have the video list (SPA navigation), + // fetch the playlist data ourselves via the browse API + if (!videoList) { + const playlistId = getPlaylistId(); + if (!playlistId) { + sendResult({ error: "no-playlist-id" }); + return; + } + + console.log(LOG, "ytInitialData stale, fetching via browse API"); + const browseData = await fetchBrowseData( + `VL${playlistId}`, baseContext, authHeaders, + ); + if (!browseData) { + sendResult({ error: "browse-fetch-failed" }); + return; + } + + initialData = browseData; + videoList = findVideoListContents(browseData); + if (!videoList) { + sendResult({ error: "no-video-list" }); + return; + } + } + + const allRenderers: any[] = []; + let continuation: string | null = null; + let continuationClickTracking: string | null = null; + + for (const item of videoList) { + if (item.playlistVideoRenderer) { + allRenderers.push(item.playlistVideoRenderer); + } + if (item.continuationItemRenderer) { + const extracted = extractContinuationInfo(item.continuationItemRenderer); + continuation = extracted.token; + continuationClickTracking = extracted.clickTracking; + } + } + + // Fetch remaining pages + let page = 1; + while (continuation && page < 50) { + try { + const body = buildRequestBody(baseContext, continuation, continuationClickTracking); + const res = await fetch( + `https://www.youtube.com/youtubei/v1/browse?prettyPrint=false`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + ...authHeaders, + }, + credentials: "same-origin", + body: JSON.stringify(body), + }, + ); + if (!res.ok) break; + + const data = await res.json(); + continuation = null; + continuationClickTracking = null; + + for (const action of data.onResponseReceivedActions ?? []) { + const items = action?.appendContinuationItemsAction?.continuationItems; + if (!items) continue; + for (const item of items) { + if (item.playlistVideoRenderer) { + allRenderers.push(item.playlistVideoRenderer); + } + if (item.continuationItemRenderer) { + const extracted = extractContinuationInfo(item.continuationItemRenderer); + continuation = extracted.token; + continuationClickTracking = extracted.clickTracking; + } + } + } + page++; + } catch (e) { + console.error(LOG, `Continuation page ${page} failed:`, e); + break; + } + } + + const videos = allRenderers.map((d, i) => ({ + videoId: d.videoId, + title: textOf(d.title), + index: i, + lengthSeconds: d.lengthSeconds ? parseInt(d.lengthSeconds, 10) : null, + lengthText: textOf(d.lengthText), + thumbnails: d.thumbnail?.thumbnails ?? [], + shortBylineText: d.shortBylineText, + isPlayable: d.isPlayable !== false, + badges: d.badges ?? [], + })); + + console.log(LOG, `Extraction: ${videos.length} videos (${page - 1} continuation pages)`); + + sendResult({ + videos, + initialData, + innertubeApiKey: apiKey, + innertubeContext: baseContext, + }); + } + + // --- helpers --- + + async function fetchBrowseData( + browseId: string, + baseContext: any, + authHeaders: Record, + ): Promise { try { - const body = buildRequestBody(baseContext, continuation, continuationClickTracking); + const body = { + context: baseContext ? JSON.parse(JSON.stringify(baseContext)) : {}, + browseId, + }; const res = await fetch( `https://www.youtube.com/youtubei/v1/browse?prettyPrint=false`, { @@ -58,57 +192,15 @@ body: JSON.stringify(body), }, ); - if (!res.ok) break; - - const data = await res.json(); - continuation = null; - continuationClickTracking = null; - - for (const action of data.onResponseReceivedActions ?? []) { - const items = action?.appendContinuationItemsAction?.continuationItems; - if (!items) continue; - for (const item of items) { - if (item.playlistVideoRenderer) { - allRenderers.push(item.playlistVideoRenderer); - } - if (item.continuationItemRenderer) { - const extracted = extractContinuationInfo(item.continuationItemRenderer); - continuation = extracted.token; - continuationClickTracking = extracted.clickTracking; - } - } - } - page++; + if (!res.ok) return null; + return await res.json(); } catch (e) { - console.error(LOG, `Continuation page ${page} failed:`, e); - break; + console.error(LOG, "Browse fetch failed:", e); + return null; } } - const videos = allRenderers.map((d, i) => ({ - videoId: d.videoId, - title: textOf(d.title), - index: i, - lengthSeconds: d.lengthSeconds ? parseInt(d.lengthSeconds, 10) : null, - lengthText: textOf(d.lengthText), - thumbnails: d.thumbnail?.thumbnails ?? [], - shortBylineText: d.shortBylineText, - isPlayable: d.isPlayable !== false, - badges: d.badges ?? [], - })); - - console.log(LOG, `Extraction: ${videos.length} videos (${page - 1} continuation pages)`); - - sendResult({ - videos, - initialData, - innertubeApiKey: apiKey, - innertubeContext: baseContext, - }); - - // --- helpers --- - - function buildAuthHeaders(): Record { + function buildAuthHeaders(cfg: any, baseContext: any): Record { const headers: Record = {}; // SAPISIDHASH authorization @@ -224,11 +316,14 @@ function findVideoListContents(raw: any): any[] | null { const tabs = raw?.contents?.twoColumnBrowseResultsRenderer?.tabs; if (!tabs?.length) return null; - const section = - tabs[0]?.tabRenderer?.content?.sectionListRenderer?.contents; + + const tabContent = tabs[0]?.tabRenderer?.content; + const section = tabContent?.sectionListRenderer?.contents; if (!section?.length) return null; + const items = section[0]?.itemSectionRenderer?.contents; if (!items?.length) return null; + return items[0]?.playlistVideoListRenderer?.contents ?? null; }