yt-playlist-features/src/injected/page-script.ts
2026-04-09 02:46:06 +09:00

493 lines
15 KiB
TypeScript

// 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.
//
// Injected once and persists across SPA navigations.
// - Initial load: reads window.ytInitialData
// - SPA navigation: fetches playlist data via browse API (ytInitialData is stale)
(() => {
const LOG = "[yt-playlist-features]";
const w = window as any;
// Prevent double-injection
if (w.__yt_playlist_ext_injected) return;
w.__yt_playlist_ext_injected = true;
let extractingUrl: string | null = null;
let isInitialLoad = true;
let collabCache: Map<string, string> = new Map();
let collabCachePlaylistId: string | null = null;
function runExtraction() {
if (!isPlaylistUrl()) return;
const url = window.location.href;
if (url === extractingUrl) return;
extractingUrl = url;
extractAndSend();
}
// Run extraction for the current page if it's a playlist
runExtraction();
// Listen for SPA navigation
document.addEventListener("yt-navigate-finish", runExtraction);
// Listen for explicit trigger from content script
document.addEventListener("__yt_playlist_ext_trigger", runExtraction);
// Reset on navigation away
document.addEventListener("yt-navigate-start", () => {
extractingUrl = null;
});
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 only on initial page load.
// On SPA navigation ytInitialData is stale (still holds the previous page's data),
// so we must always fetch fresh data via the browse API.
let initialData = isInitialLoad ? w.ytInitialData : null;
let videoList = initialData ? findVideoListContents(initialData) : null;
isInitialLoad = false;
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;
}
}
// Fetch collaborator names for collaborative playlists
const avatarToName = await fetchCollaboratorMap(initialData, cfg, baseContext, authHeaders);
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 ?? [],
voteCount: typeof d.voteCount === "number" ? d.voteCount : null,
addedBy: resolveAddedBy(d, avatarToName),
}));
console.log(LOG, `Extraction: ${videos.length} videos (${page - 1} continuation pages)`);
sendResult({
videos,
initialData,
innertubeApiKey: apiKey,
innertubeContext: baseContext,
});
}
// --- helpers ---
function normalizeAvatarUrl(url: string): string {
// Strip size parameter to make URLs comparable (e.g. =s48 vs =s176)
return url.replace(/=s\d+/, "=s0");
}
function extractCollabParams(initialData: any): string | null {
const vm = initialData?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel;
const rows = vm?.metadata?.contentMetadataViewModel?.metadataRows;
if (!rows) return null;
for (const row of rows) {
for (const part of row.metadataParts ?? []) {
const cmd = part?.avatarStack?.avatarStackViewModel?.rendererContext
?.commandContext?.onTap?.innertubeCommand;
const params = cmd?.showEngagementPanelEndpoint?.globalConfiguration?.params;
if (params) return params;
}
}
return null;
}
async function fetchCollaboratorMap(
initialData: any,
cfg: any,
baseContext: any,
authHeaders: Record<string, string>,
): Promise<Map<string, string>> {
const params = extractCollabParams(initialData);
if (!params) return new Map<string, string>();
// Return cached map if same playlist
const playlistId = new URL(window.location.href).searchParams.get("list");
if (playlistId && playlistId === collabCachePlaylistId && collabCache.size > 0) {
return collabCache;
}
const map = new Map<string, string>();
try {
const res = await fetch(
"https://www.youtube.com/youtubei/v1/get_panel?prettyPrint=false",
{
method: "POST",
headers: {
"Content-Type": "application/json",
...authHeaders,
"X-Origin": window.location.origin,
"X-Youtube-Bootstrap-Logged-In": "true",
"X-Youtube-Client-Name": String(cfg.INNERTUBE_CONTEXT_CLIENT_NAME ?? "1"),
"X-Youtube-Client-Version": cfg.INNERTUBE_CLIENT_VERSION ?? "",
},
credentials: "same-origin",
body: JSON.stringify({
context: baseContext,
panelId: "PAplaylist_collaborate",
params,
}),
},
);
if (!res.ok) return map;
const data = await res.json();
const collaborators =
data?.content?.engagementPanelSectionListRenderer?.content
?.playlistCollaborationViewModel?.playlistCollaborators ?? [];
for (const c of collaborators) {
const item = c.contentListItemViewModel;
if (!item) continue;
const name = item.title?.content ?? "";
const avatarUrl =
item.avatar?.avatarViewModel?.image?.sources?.[0]?.url;
if (name && avatarUrl) {
map.set(normalizeAvatarUrl(avatarUrl), name);
}
}
console.log(LOG, `Fetched ${map.size} collaborators`);
collabCache = map;
collabCachePlaylistId = playlistId;
} catch (e) {
console.error(LOG, "Failed to fetch collaborators:", e);
}
return map;
}
function resolveAddedBy(renderer: any, avatarToName: Map<string, string>): string | null {
if (avatarToName.size === 0) return null;
const overlays = renderer.thumbnailOverlays ?? [];
for (const o of overlays) {
const avatars =
o.thumbnailOverlayAvatarStackViewModel?.avatarStack
?.avatarStackViewModel?.avatars;
if (!avatars) continue;
for (const a of avatars) {
const url = a.avatarViewModel?.image?.sources?.[0]?.url;
if (url) {
const name = avatarToName.get(normalizeAvatarUrl(url));
if (name) return name;
}
}
}
return null;
}
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
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 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;
}
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),
}),
);
}
})();