認証周りの修正

This commit is contained in:
Keisuke Hirata 2026-04-08 03:38:25 +09:00
parent a05af1a208
commit 16ae1084da
3 changed files with 374 additions and 69 deletions

View File

@ -7,70 +7,75 @@ import type {
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
const LOG = "[yt-playlist-features]"; export interface InitialParseResult {
metadata: PlaylistMetadata;
videos: PlaylistVideo[];
}
export function parsePlaylistData(raw: any): PlaylistData | null { export function parseInitialData(raw: any): InitialParseResult | null {
try { try {
const metadata = extractMetadata(raw); const metadata = extractMetadata(raw);
if (!metadata) { if (!metadata) return null;
console.warn(LOG, "Could not extract metadata");
return null;
}
const videoListContents = findVideoListContents(raw); const videoListContents = findVideoListContents(raw);
if (!videoListContents) { if (!videoListContents) return null;
console.warn(LOG, "Could not find video list");
return null;
}
const videos: PlaylistVideo[] = []; const { videos } = parseVideoList(videoListContents);
let hasMore = false; return { metadata, videos };
for (const item of videoListContents) {
if (item.playlistVideoRenderer) {
const video = parseVideo(item.playlistVideoRenderer);
if (video) videos.push(video);
}
if (item.continuationItemRenderer) {
hasMore = true;
}
}
return {
metadata,
videos,
extractedAt: new Date().toISOString(),
isComplete: !hasMore,
extractedCount: videos.length,
};
} catch (e) { } catch (e) {
console.error(LOG, "Failed to parse playlist data:", e); console.error("[yt-playlist-features] Failed to parse playlist data:", e);
return null; return null;
} }
} }
export function buildPlaylistData(
metadata: PlaylistMetadata,
videos: PlaylistVideo[],
isComplete: boolean,
): PlaylistData {
return {
metadata,
videos,
extractedAt: new Date().toISOString(),
isComplete,
extractedCount: videos.length,
};
}
// --- internal helpers ---
function parseVideoList(contents: any[]): {
videos: PlaylistVideo[];
} {
const videos: PlaylistVideo[] = [];
for (const item of contents) {
if (item.playlistVideoRenderer) {
const video = parseVideo(item.playlistVideoRenderer);
if (video) videos.push(video);
}
}
return { videos };
}
function extractMetadata(raw: any): PlaylistMetadata | null { function extractMetadata(raw: any): PlaylistMetadata | null {
// Extract playlist ID from URL as fallback
const playlistId = extractPlaylistId(raw); const playlistId = extractPlaylistId(raw);
if (!playlistId) return null; if (!playlistId) return null;
// Title from metadata.playlistMetadataRenderer
const metadataRenderer = raw?.metadata?.playlistMetadataRenderer; const metadataRenderer = raw?.metadata?.playlistMetadataRenderer;
const title = metadataRenderer?.title ?? ""; const title = metadataRenderer?.title ?? "";
// Sidebar has primary and secondary info
const sidebarItems = const sidebarItems =
raw?.sidebar?.playlistSidebarRenderer?.items ?? []; raw?.sidebar?.playlistSidebarRenderer?.items ?? [];
const primaryInfo = sidebarItems[0]?.playlistSidebarPrimaryInfoRenderer; const primaryInfo = sidebarItems[0]?.playlistSidebarPrimaryInfoRenderer;
const secondaryInfo = sidebarItems[1]?.playlistSidebarSecondaryInfoRenderer; const secondaryInfo = sidebarItems[1]?.playlistSidebarSecondaryInfoRenderer;
// Stats from primary info (e.g. "87 本の動画", "視聴回数 1,234 回", "最終更新日...")
const stats = primaryInfo?.stats ?? []; const stats = primaryInfo?.stats ?? [];
const videoCount = parseVideoCount(stats[0]); const videoCount = parseVideoCount(stats[0]);
const viewCountText = extractText(stats[1]) || null; const viewCountText = extractText(stats[1]) || null;
const lastUpdatedText = extractText(stats[2]) || null; const lastUpdatedText = extractText(stats[2]) || null;
// Description from pageHeaderViewModel or primary info
const pageHeaderVM = const pageHeaderVM =
raw?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel; raw?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel;
const description = const description =
@ -78,14 +83,12 @@ function extractMetadata(raw: any): PlaylistMetadata | null {
extractText(primaryInfo?.description) || extractText(primaryInfo?.description) ||
""; "";
// Owner from secondary info
const ownerRenderer = const ownerRenderer =
secondaryInfo?.videoOwner?.videoOwnerRenderer; secondaryInfo?.videoOwner?.videoOwnerRenderer;
const ownerRun = ownerRenderer?.title?.runs?.[0]; const ownerRun = ownerRenderer?.title?.runs?.[0];
const ownerEndpoint = const ownerEndpoint =
ownerRun?.navigationEndpoint?.browseEndpoint; ownerRun?.navigationEndpoint?.browseEndpoint;
// Thumbnails from pageHeaderViewModel heroImage or primary info
const thumbnails = extractPlaylistThumbnails(pageHeaderVM, primaryInfo); const thumbnails = extractPlaylistThumbnails(pageHeaderVM, primaryInfo);
return { return {
@ -93,7 +96,7 @@ function extractMetadata(raw: any): PlaylistMetadata | null {
title, title,
description, description,
videoCount, videoCount,
totalDurationText: null, // not available in current structure totalDurationText: null,
viewCountText, viewCountText,
lastUpdatedText, lastUpdatedText,
thumbnails, thumbnails,
@ -108,21 +111,18 @@ function extractMetadata(raw: any): PlaylistMetadata | null {
} }
function extractPlaylistId(raw: any): string | null { function extractPlaylistId(raw: any): string | null {
// Try microformat
const microformat = const microformat =
raw?.microformat?.microformatDataRenderer?.urlCanonical; raw?.microformat?.microformatDataRenderer?.urlCanonical;
if (microformat) { if (microformat) {
const match = microformat.match(/[?&]list=([^&]+)/); const match = microformat.match(/[?&]list=([^&]+)/);
if (match) return match[1]; if (match) return match[1];
} }
// Try from appindexing link
const appLink = const appLink =
raw?.metadata?.playlistMetadataRenderer?.androidAppindexingLink; raw?.metadata?.playlistMetadataRenderer?.androidAppindexingLink;
if (appLink) { if (appLink) {
const match = appLink.match(/[?&]list=([^&]+)/); const match = appLink.match(/[?&]list=([^&]+)/);
if (match) return match[1]; if (match) return match[1];
} }
// Fallback to URL
const url = new URL(window.location.href); const url = new URL(window.location.href);
return url.searchParams.get("list"); return url.searchParams.get("list");
} }
@ -193,7 +193,6 @@ function extractPlaylistThumbnails(
pageHeaderVM: any, pageHeaderVM: any,
primaryInfo: any, primaryInfo: any,
): Thumbnail[] { ): Thumbnail[] {
// Try heroImage from pageHeaderViewModel
const heroThumbnails = const heroThumbnails =
pageHeaderVM?.heroImage?.contentPreviewImageViewModel?.image?.sources; pageHeaderVM?.heroImage?.contentPreviewImageViewModel?.image?.sources;
if (heroThumbnails?.length) { if (heroThumbnails?.length) {
@ -203,7 +202,6 @@ function extractPlaylistThumbnails(
height: t.height ?? 0, height: t.height ?? 0,
})); }));
} }
// Fallback to primary info thumbnail
return ( return (
primaryInfo?.thumbnailRenderer?.playlistVideoThumbnailRenderer?.thumbnail primaryInfo?.thumbnailRenderer?.playlistVideoThumbnailRenderer?.thumbnail
?.thumbnails ?? ?.thumbnails ??

View File

@ -1,11 +1,11 @@
import browser from "webextension-polyfill"; import browser from "webextension-polyfill";
import { onPlaylistPageReady, getPlaylistId } from "./navigation"; import { onPlaylistPageReady, getPlaylistId } from "./navigation";
import { parsePlaylistData } from "./extractor"; import { parseInitialData, buildPlaylistData } from "./extractor";
import type { PlaylistVideo } from "../types/playlist";
import type { Message } from "../shared/messages"; import type { Message } from "../shared/messages";
const LOG_PREFIX = "[yt-playlist-features]"; const LOG = "[yt-playlist-features]";
// Track the last extracted playlist ID to avoid duplicate extractions
let lastExtractedId: string | null = null; let lastExtractedId: string | null = null;
function injectPageScript(): void { function injectPageScript(): void {
@ -19,29 +19,66 @@ function handlePlaylistData(event: Event): void {
const detail = (event as CustomEvent).detail; const detail = (event as CustomEvent).detail;
if (!detail) return; if (!detail) return;
let raw: any; let payload: any;
try { try {
raw = JSON.parse(detail); payload = JSON.parse(detail);
} catch { } catch {
console.error(LOG_PREFIX, "Failed to parse ytInitialData JSON"); console.error(LOG, "Failed to parse payload JSON");
return; return;
} }
const playlistData = parsePlaylistData(raw); if (payload.error) {
if (!playlistData) { console.warn(LOG, "Page script error:", payload.error);
console.warn(LOG_PREFIX, "Could not extract playlist data from ytInitialData");
return; return;
} }
const { videos: domVideos, initialData } = payload;
// Extract metadata from ytInitialData
const parsed = initialData ? parseInitialData(initialData) : null;
const metadata = parsed?.metadata;
if (!metadata) {
console.warn(LOG, "Could not extract playlist metadata");
return;
}
// Convert DOM-extracted videos to PlaylistVideo[]
const videos: PlaylistVideo[] = domVideos.map((v: any) => {
const bylineRun = v.shortBylineText?.runs?.[0];
const bylineEndpoint = bylineRun?.navigationEndpoint?.browseEndpoint;
return {
videoId: v.videoId,
title: v.title,
index: v.index,
durationSeconds: v.lengthSeconds,
durationText: v.lengthText || null,
thumbnails: v.thumbnails,
channel: {
name: bylineRun?.text ?? "",
channelId: bylineEndpoint?.browseId ?? "",
url: bylineRun?.navigationEndpoint?.commandMetadata
?.webCommandMetadata?.url ?? "",
},
isPlayable: v.isPlayable,
isLive:
v.badges?.some(
(b: any) =>
b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW",
) ?? false,
} satisfies PlaylistVideo;
});
const playlistData = buildPlaylistData(metadata, videos, true);
console.log( console.log(
LOG_PREFIX, LOG,
`Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount} videos)`, `Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount}/${metadata.videoCount} videos)`,
); );
// Send to background service worker for storage
const message: Message = { type: "PLAYLIST_EXTRACTED", data: playlistData }; const message: Message = { type: "PLAYLIST_EXTRACTED", data: playlistData };
browser.runtime.sendMessage(message).catch((err) => { browser.runtime.sendMessage(message).catch((err) => {
console.error(LOG_PREFIX, "Failed to send playlist data to background:", err); console.error(LOG, "Failed to send playlist data to background:", err);
}); });
} }
@ -54,7 +91,6 @@ onPlaylistPageReady(() => {
if (!playlistId || playlistId === lastExtractedId) return; if (!playlistId || playlistId === lastExtractedId) return;
lastExtractedId = playlistId; lastExtractedId = playlistId;
// Small delay to ensure ytInitialData is updated after SPA navigation
setTimeout(() => { setTimeout(() => {
injectPageScript(); injectPageScript();
}, 100); }, 100);

View File

@ -1,11 +1,282 @@
// Runs in the page's JS context (not isolated world). // Runs in page context.
// Reads ytInitialData and sends it to the content script via CustomEvent. // 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.
const data = (window as any).ytInitialData; (async () => {
if (data) { const LOG = "[yt-playlist-features]";
const w = window as any;
const initialData = w.ytInitialData;
if (!initialData) {
sendResult({ error: "no-ytInitialData" });
return;
}
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);
}
if (item.continuationItemRenderer) {
const extracted = extractContinuationInfo(item.continuationItemRenderer);
continuation = extracted.token;
continuationClickTracking = extracted.clickTracking;
}
}
// Build auth headers matching YouTube's internal requests
const authHeaders = buildAuthHeaders();
// 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 ---
function buildAuthHeaders(): 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 section =
tabs[0]?.tabRenderer?.content?.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( document.dispatchEvent(
new CustomEvent("__yt_playlist_ext_data", { new CustomEvent("__yt_playlist_ext_data", {
detail: JSON.stringify(data), detail: JSON.stringify(payload),
}), }),
); );
} }
})();