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]";
|
||||
|
||||
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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user