SPA対応
This commit is contained in:
parent
16ae1084da
commit
bb64ed0492
|
|
@ -7,12 +7,15 @@ import type { Message } from "../shared/messages";
|
||||||
const LOG = "[yt-playlist-features]";
|
const LOG = "[yt-playlist-features]";
|
||||||
|
|
||||||
let lastExtractedId: string | null = null;
|
let lastExtractedId: string | null = null;
|
||||||
|
let pageScriptInjected = false;
|
||||||
|
|
||||||
function injectPageScript(): void {
|
function ensurePageScript(): void {
|
||||||
|
if (pageScriptInjected) return;
|
||||||
const script = document.createElement("script");
|
const script = document.createElement("script");
|
||||||
script.src = browser.runtime.getURL("injected/page-script.js");
|
script.src = browser.runtime.getURL("injected/page-script.js");
|
||||||
script.onload = () => script.remove();
|
script.onload = () => script.remove();
|
||||||
(document.head || document.documentElement).appendChild(script);
|
(document.head || document.documentElement).appendChild(script);
|
||||||
|
pageScriptInjected = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePlaylistData(event: Event): void {
|
function handlePlaylistData(event: Event): void {
|
||||||
|
|
@ -91,7 +94,10 @@ onPlaylistPageReady(() => {
|
||||||
if (!playlistId || playlistId === lastExtractedId) return;
|
if (!playlistId || playlistId === lastExtractedId) return;
|
||||||
lastExtractedId = playlistId;
|
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(() => {
|
setTimeout(() => {
|
||||||
injectPageScript();
|
document.dispatchEvent(new CustomEvent("__yt_playlist_ext_trigger"));
|
||||||
}, 100);
|
}, 50);
|
||||||
});
|
});
|
||||||
|
|
@ -2,26 +2,90 @@
|
||||||
// Fetches all playlist videos using YouTube's browse API from page context
|
// Fetches all playlist videos using YouTube's browse API from page context
|
||||||
// (includes cookies/auth via same-origin credentials).
|
// (includes cookies/auth via same-origin credentials).
|
||||||
// Replicates YouTube's internal request format including auth headers.
|
// 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 LOG = "[yt-playlist-features]";
|
||||||
const w = window as any;
|
const w = window as any;
|
||||||
|
|
||||||
const initialData = w.ytInitialData;
|
// Prevent double-injection
|
||||||
if (!initialData) {
|
if (w.__yt_playlist_ext_injected) return;
|
||||||
sendResult({ error: "no-ytInitialData" });
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cfg = w.ytcfg?.data_ ?? {};
|
console.log(LOG, "ytInitialData stale, fetching via browse API");
|
||||||
const apiKey = cfg.INNERTUBE_API_KEY ?? "";
|
const browseData = await fetchBrowseData(
|
||||||
const baseContext = cfg.INNERTUBE_CONTEXT ?? null;
|
`VL${playlistId}`, baseContext, authHeaders,
|
||||||
|
);
|
||||||
|
if (!browseData) {
|
||||||
|
sendResult({ error: "browse-fetch-failed" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const videoList = findVideoListContents(initialData);
|
initialData = browseData;
|
||||||
|
videoList = findVideoListContents(browseData);
|
||||||
if (!videoList) {
|
if (!videoList) {
|
||||||
sendResult({ error: "no-video-list" });
|
sendResult({ error: "no-video-list" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const allRenderers: any[] = [];
|
const allRenderers: any[] = [];
|
||||||
let continuation: string | null = null;
|
let continuation: string | null = null;
|
||||||
|
|
@ -38,9 +102,6 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build auth headers matching YouTube's internal requests
|
|
||||||
const authHeaders = buildAuthHeaders();
|
|
||||||
|
|
||||||
// Fetch remaining pages
|
// Fetch remaining pages
|
||||||
let page = 1;
|
let page = 1;
|
||||||
while (continuation && page < 50) {
|
while (continuation && page < 50) {
|
||||||
|
|
@ -105,10 +166,41 @@
|
||||||
innertubeApiKey: apiKey,
|
innertubeApiKey: apiKey,
|
||||||
innertubeContext: baseContext,
|
innertubeContext: baseContext,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// --- helpers ---
|
// --- 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> = {};
|
const headers: Record<string, string> = {};
|
||||||
|
|
||||||
// SAPISIDHASH authorization
|
// SAPISIDHASH authorization
|
||||||
|
|
@ -224,11 +316,14 @@
|
||||||
function findVideoListContents(raw: any): any[] | null {
|
function findVideoListContents(raw: any): any[] | null {
|
||||||
const tabs = raw?.contents?.twoColumnBrowseResultsRenderer?.tabs;
|
const tabs = raw?.contents?.twoColumnBrowseResultsRenderer?.tabs;
|
||||||
if (!tabs?.length) return null;
|
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;
|
if (!section?.length) return null;
|
||||||
|
|
||||||
const items = section[0]?.itemSectionRenderer?.contents;
|
const items = section[0]?.itemSectionRenderer?.contents;
|
||||||
if (!items?.length) return null;
|
if (!items?.length) return null;
|
||||||
|
|
||||||
return items[0]?.playlistVideoListRenderer?.contents ?? null;
|
return items[0]?.playlistVideoListRenderer?.contents ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user