From 16ae1084daf9fc94a93da0fa78ca18e96e0f90b5 Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 8 Apr 2026 03:38:25 +0900 Subject: [PATCH] =?UTF-8?q?=E8=AA=8D=E8=A8=BC=E5=91=A8=E3=82=8A=E3=81=AE?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content/extractor.ts | 86 ++++++----- src/content/index.ts | 66 ++++++-- src/injected/page-script.ts | 291 ++++++++++++++++++++++++++++++++++-- 3 files changed, 374 insertions(+), 69 deletions(-) diff --git a/src/content/extractor.ts b/src/content/extractor.ts index c03cb49..74bfe33 100644 --- a/src/content/extractor.ts +++ b/src/content/extractor.ts @@ -7,70 +7,75 @@ import type { /* eslint-disable @typescript-eslint/no-explicit-any */ -const LOG = "[yt-playlist-features]"; +export interface InitialParseResult { + metadata: PlaylistMetadata; + videos: PlaylistVideo[]; +} -export function parsePlaylistData(raw: any): PlaylistData | null { +export function parseInitialData(raw: any): InitialParseResult | null { try { const metadata = extractMetadata(raw); - if (!metadata) { - console.warn(LOG, "Could not extract metadata"); - return null; - } + if (!metadata) return null; const videoListContents = findVideoListContents(raw); - if (!videoListContents) { - console.warn(LOG, "Could not find video list"); - return null; - } + if (!videoListContents) return null; - const videos: PlaylistVideo[] = []; - let hasMore = false; - - for (const item of videoListContents) { - if (item.playlistVideoRenderer) { - const video = parseVideo(item.playlistVideoRenderer); - if (video) videos.push(video); - } - if (item.continuationItemRenderer) { - hasMore = true; - } - } - - return { - metadata, - videos, - extractedAt: new Date().toISOString(), - isComplete: !hasMore, - extractedCount: videos.length, - }; + const { videos } = parseVideoList(videoListContents); + return { metadata, videos }; } catch (e) { - console.error(LOG, "Failed to parse playlist data:", e); + console.error("[yt-playlist-features] Failed to parse playlist data:", e); return null; } } +export function buildPlaylistData( + metadata: PlaylistMetadata, + videos: PlaylistVideo[], + isComplete: boolean, +): PlaylistData { + return { + metadata, + videos, + extractedAt: new Date().toISOString(), + isComplete, + extractedCount: videos.length, + }; +} + +// --- internal helpers --- + +function parseVideoList(contents: any[]): { + videos: PlaylistVideo[]; +} { + const videos: PlaylistVideo[] = []; + + for (const item of contents) { + if (item.playlistVideoRenderer) { + const video = parseVideo(item.playlistVideoRenderer); + if (video) videos.push(video); + } + } + + return { videos }; +} + function extractMetadata(raw: any): PlaylistMetadata | null { - // Extract playlist ID from URL as fallback const playlistId = extractPlaylistId(raw); if (!playlistId) return null; - // Title from metadata.playlistMetadataRenderer const metadataRenderer = raw?.metadata?.playlistMetadataRenderer; const title = metadataRenderer?.title ?? ""; - // Sidebar has primary and secondary info const sidebarItems = raw?.sidebar?.playlistSidebarRenderer?.items ?? []; const primaryInfo = sidebarItems[0]?.playlistSidebarPrimaryInfoRenderer; const secondaryInfo = sidebarItems[1]?.playlistSidebarSecondaryInfoRenderer; - // Stats from primary info (e.g. "87 本の動画", "視聴回数 1,234 回", "最終更新日...") const stats = primaryInfo?.stats ?? []; const videoCount = parseVideoCount(stats[0]); const viewCountText = extractText(stats[1]) || null; const lastUpdatedText = extractText(stats[2]) || null; - // Description from pageHeaderViewModel or primary info const pageHeaderVM = raw?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel; const description = @@ -78,14 +83,12 @@ function extractMetadata(raw: any): PlaylistMetadata | null { extractText(primaryInfo?.description) || ""; - // Owner from secondary info const ownerRenderer = secondaryInfo?.videoOwner?.videoOwnerRenderer; const ownerRun = ownerRenderer?.title?.runs?.[0]; const ownerEndpoint = ownerRun?.navigationEndpoint?.browseEndpoint; - // Thumbnails from pageHeaderViewModel heroImage or primary info const thumbnails = extractPlaylistThumbnails(pageHeaderVM, primaryInfo); return { @@ -93,7 +96,7 @@ function extractMetadata(raw: any): PlaylistMetadata | null { title, description, videoCount, - totalDurationText: null, // not available in current structure + totalDurationText: null, viewCountText, lastUpdatedText, thumbnails, @@ -108,21 +111,18 @@ function extractMetadata(raw: any): PlaylistMetadata | null { } function extractPlaylistId(raw: any): string | null { - // Try microformat const microformat = raw?.microformat?.microformatDataRenderer?.urlCanonical; if (microformat) { const match = microformat.match(/[?&]list=([^&]+)/); if (match) return match[1]; } - // Try from appindexing link const appLink = raw?.metadata?.playlistMetadataRenderer?.androidAppindexingLink; if (appLink) { const match = appLink.match(/[?&]list=([^&]+)/); if (match) return match[1]; } - // Fallback to URL const url = new URL(window.location.href); return url.searchParams.get("list"); } @@ -193,7 +193,6 @@ function extractPlaylistThumbnails( pageHeaderVM: any, primaryInfo: any, ): Thumbnail[] { - // Try heroImage from pageHeaderViewModel const heroThumbnails = pageHeaderVM?.heroImage?.contentPreviewImageViewModel?.image?.sources; if (heroThumbnails?.length) { @@ -203,7 +202,6 @@ function extractPlaylistThumbnails( height: t.height ?? 0, })); } - // Fallback to primary info thumbnail return ( primaryInfo?.thumbnailRenderer?.playlistVideoThumbnailRenderer?.thumbnail ?.thumbnails ?? diff --git a/src/content/index.ts b/src/content/index.ts index 309b490..069cace 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -1,11 +1,11 @@ import browser from "webextension-polyfill"; import { onPlaylistPageReady, getPlaylistId } from "./navigation"; -import { parsePlaylistData } from "./extractor"; +import { parseInitialData, buildPlaylistData } from "./extractor"; +import type { PlaylistVideo } from "../types/playlist"; import type { Message } from "../shared/messages"; -const LOG_PREFIX = "[yt-playlist-features]"; +const LOG = "[yt-playlist-features]"; -// Track the last extracted playlist ID to avoid duplicate extractions let lastExtractedId: string | null = null; function injectPageScript(): void { @@ -19,29 +19,66 @@ function handlePlaylistData(event: Event): void { const detail = (event as CustomEvent).detail; if (!detail) return; - let raw: any; + let payload: any; try { - raw = JSON.parse(detail); + payload = JSON.parse(detail); } catch { - console.error(LOG_PREFIX, "Failed to parse ytInitialData JSON"); + console.error(LOG, "Failed to parse payload JSON"); return; } - const playlistData = parsePlaylistData(raw); - if (!playlistData) { - console.warn(LOG_PREFIX, "Could not extract playlist data from ytInitialData"); + if (payload.error) { + console.warn(LOG, "Page script error:", payload.error); return; } + const { videos: domVideos, initialData } = payload; + + // Extract metadata from ytInitialData + const parsed = initialData ? parseInitialData(initialData) : null; + const metadata = parsed?.metadata; + if (!metadata) { + console.warn(LOG, "Could not extract playlist metadata"); + return; + } + + // Convert DOM-extracted videos to PlaylistVideo[] + const videos: PlaylistVideo[] = domVideos.map((v: any) => { + const bylineRun = v.shortBylineText?.runs?.[0]; + const bylineEndpoint = bylineRun?.navigationEndpoint?.browseEndpoint; + + return { + videoId: v.videoId, + title: v.title, + index: v.index, + durationSeconds: v.lengthSeconds, + durationText: v.lengthText || null, + thumbnails: v.thumbnails, + channel: { + name: bylineRun?.text ?? "", + channelId: bylineEndpoint?.browseId ?? "", + url: bylineRun?.navigationEndpoint?.commandMetadata + ?.webCommandMetadata?.url ?? "", + }, + isPlayable: v.isPlayable, + isLive: + v.badges?.some( + (b: any) => + b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW", + ) ?? false, + } satisfies PlaylistVideo; + }); + + const playlistData = buildPlaylistData(metadata, videos, true); + console.log( - LOG_PREFIX, - `Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount} videos)`, + LOG, + `Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount}/${metadata.videoCount} videos)`, ); - // Send to background service worker for storage const message: Message = { type: "PLAYLIST_EXTRACTED", data: playlistData }; browser.runtime.sendMessage(message).catch((err) => { - console.error(LOG_PREFIX, "Failed to send playlist data to background:", err); + console.error(LOG, "Failed to send playlist data to background:", err); }); } @@ -54,8 +91,7 @@ onPlaylistPageReady(() => { if (!playlistId || playlistId === lastExtractedId) return; lastExtractedId = playlistId; - // Small delay to ensure ytInitialData is updated after SPA navigation setTimeout(() => { injectPageScript(); }, 100); -}); +}); \ No newline at end of file diff --git a/src/injected/page-script.ts b/src/injected/page-script.ts index 38a0f32..84cc7a8 100644 --- a/src/injected/page-script.ts +++ b/src/injected/page-script.ts @@ -1,11 +1,282 @@ -// Runs in the page's JS context (not isolated world). -// Reads ytInitialData and sends it to the content script via CustomEvent. +// Runs in page context. +// 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. -const data = (window as any).ytInitialData; -if (data) { - document.dispatchEvent( - new CustomEvent("__yt_playlist_ext_data", { - detail: JSON.stringify(data), - }), - ); -} +(async () => { + const LOG = "[yt-playlist-features]"; + const w = window as any; + + const initialData = w.ytInitialData; + if (!initialData) { + sendResult({ error: "no-ytInitialData" }); + return; + } + + 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); + } + if (item.continuationItemRenderer) { + const extracted = extractContinuationInfo(item.continuationItemRenderer); + continuation = extracted.token; + continuationClickTracking = extracted.clickTracking; + } + } + + // Build auth headers matching YouTube's internal requests + const authHeaders = buildAuthHeaders(); + + // 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 --- + + function buildAuthHeaders(): Record { + const headers: Record = {}; + + // SAPISIDHASH authorization + const sapisid = getCookie("SAPISID") || getCookie("__Secure-3PAPISID"); + if (sapisid) { + const timestamp = Math.floor(Date.now() / 1000); + const origin = window.location.origin; + // SHA-1 hash of "timestamp SAPISID origin" + headers["Authorization"] = `SAPISIDHASH ${timestamp}_${sha1(`${timestamp} ${sapisid} ${origin}`)}`; + } + + // Auth user + const authUser = cfg.SESSION_INDEX ?? "0"; + headers["X-Goog-AuthUser"] = String(authUser); + + // Visitor ID + const visitorData = baseContext?.client?.visitorData; + if (visitorData) { + headers["X-Goog-Visitor-Id"] = visitorData; + } + + return headers; + } + + function getCookie(name: string): string | null { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; + } + + function sha1(str: string): string { + const utf8 = new TextEncoder().encode(str); + const len = utf8.length; + + // Pre-processing + const bitLen = len * 8; + const padLen = (((len + 8) >> 6) + 1) << 6; + const padded = new Uint8Array(padLen); + padded.set(utf8); + padded[len] = 0x80; + // Length in bits as big-endian 64-bit + const view = new DataView(padded.buffer); + view.setUint32(padLen - 4, bitLen, false); + + let h0 = 0x67452301; + let h1 = 0xefcdab89; + let h2 = 0x98badcfe; + let h3 = 0x10325476; + let h4 = 0xc3d2e1f0; + + const w = new Uint32Array(80); + + for (let offset = 0; offset < padLen; offset += 64) { + for (let i = 0; i < 16; i++) { + w[i] = view.getUint32(offset + i * 4, false); + } + for (let i = 16; i < 80; i++) { + const x = w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]; + w[i] = (x << 1) | (x >>> 31); + } + + let a = h0, b = h1, c = h2, d = h3, e = h4; + + for (let i = 0; i < 80; i++) { + let f: number, k: number; + if (i < 20) { + f = (b & c) | (~b & d); + k = 0x5a827999; + } else if (i < 40) { + f = b ^ c ^ d; + k = 0x6ed9eba1; + } else if (i < 60) { + f = (b & c) | (b & d) | (c & d); + k = 0x8f1bbcdc; + } else { + f = b ^ c ^ d; + k = 0xca62c1d6; + } + const temp = (((a << 5) | (a >>> 27)) + f + e + k + w[i]) | 0; + e = d; + d = c; + c = (b << 30) | (b >>> 2); + b = a; + a = temp; + } + + h0 = (h0 + a) | 0; + h1 = (h1 + b) | 0; + h2 = (h2 + c) | 0; + h3 = (h3 + d) | 0; + h4 = (h4 + e) | 0; + } + + return [h0, h1, h2, h3, h4] + .map((v) => (v >>> 0).toString(16).padStart(8, "0")) + .join(""); + } + + function buildRequestBody( + ctx: any, + token: string, + clickTracking: string | null, + ): any { + const enrichedContext = JSON.parse(JSON.stringify(ctx)); + + // Use continuation endpoint's clickTrackingParams + if (clickTracking) { + enrichedContext.clickTracking = { clickTrackingParams: clickTracking }; + } + + return { context: enrichedContext, continuation: token }; + } + + 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; + if (!section?.length) return null; + const items = section[0]?.itemSectionRenderer?.contents; + if (!items?.length) return null; + return items[0]?.playlistVideoListRenderer?.contents ?? null; + } + + function extractContinuationInfo(cir: any): { token: string | null; clickTracking: string | null } { + const ep = cir?.continuationEndpoint; + if (!ep) return { token: null, clickTracking: null }; + + const clickTracking = ep.clickTrackingParams ?? null; + + if (ep.continuationCommand?.token) { + return { token: ep.continuationCommand.token, clickTracking }; + } + + const cmds = ep.commandExecutorCommand?.commands; + if (cmds) { + for (const cmd of cmds) { + if (cmd.continuationCommand?.token) { + return { token: cmd.continuationCommand.token, clickTracking }; + } + } + } + + return { token: deepFindToken(ep), clickTracking }; + } + + function deepFindToken(obj: any, depth = 0): string | null { + if (!obj || typeof obj !== "object" || depth > 6) return null; + if (typeof obj.token === "string" && obj.token.length > 10) return obj.token; + for (const k of Object.keys(obj)) { + const r = deepFindToken(obj[k], depth + 1); + if (r) return r; + } + return null; + } + + function textOf(obj: any): string { + if (!obj) return ""; + if (typeof obj === "string") return obj; + if (obj.simpleText) return obj.simpleText; + if (obj.runs) return obj.runs.map((r: any) => r.text).join(""); + return ""; + } + + function sendResult(payload: any) { + document.dispatchEvent( + new CustomEvent("__yt_playlist_ext_data", { + detail: JSON.stringify(payload), + }), + ); + } +})();