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,26 +2,90 @@
// 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" });
// 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();
}
// Listen for SPA navigation
document.addEventListener("yt-navigate-finish", () => {
if (isPlaylistUrl()) {
extractAndSend();
}
});
// 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");
}
function getPlaylistId(): string | null {
const url = new URL(window.location.href);
return url.searchParams.get("list");
}
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;
}
const cfg = w.ytcfg?.data_ ?? {};
const apiKey = cfg.INNERTUBE_API_KEY ?? "";
const baseContext = cfg.INNERTUBE_CONTEXT ?? null;
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;
}
const videoList = findVideoListContents(initialData);
initialData = browseData;
videoList = findVideoListContents(browseData);
if (!videoList) {
sendResult({ error: "no-video-list" });
return;
}
}
const allRenderers: any[] = [];
let continuation: string | null = null;
@ -38,9 +102,6 @@
}
}
// Build auth headers matching YouTube's internal requests
const authHeaders = buildAuthHeaders();
// Fetch remaining pages
let page = 1;
while (continuation && page < 50) {
@ -105,10 +166,41 @@
innertubeApiKey: apiKey,
innertubeContext: baseContext,
});
}
// --- helpers ---
function buildAuthHeaders(): Record<string, string> {
async function fetchBrowseData(
browseId: string,
baseContext: any,
authHeaders: Record<string, string>,
): Promise<any | null> {
try {
const body = {
context: baseContext ? JSON.parse(JSON.stringify(baseContext)) : {},
browseId,
};
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) return null;
return await res.json();
} catch (e) {
console.error(LOG, "Browse fetch failed:", e);
return null;
}
}
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;
}