SPA対応

This commit is contained in:
Keisuke Hirata 2026-04-08 21:55:22 +09:00
parent 16ae1084da
commit bb64ed0492
2 changed files with 185 additions and 84 deletions

View File

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

View File

@ -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<void> {
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<string, string>,
): Promise<any | null> {
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<string, string> {
function buildAuthHeaders(cfg: any, baseContext: any): Record<string, string> {
const headers: Record<string, string> = {};
// 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;
}