認証周りの修正
This commit is contained in:
parent
a05af1a208
commit
16ae1084da
|
|
@ -7,70 +7,75 @@ import type {
|
|||
|
||||
/* 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 {
|
||||
const metadata = extractMetadata(raw);
|
||||
if (!metadata) {
|
||||
console.warn(LOG, "Could not extract metadata");
|
||||
return null;
|
||||
}
|
||||
if (!metadata) return null;
|
||||
|
||||
const videoListContents = findVideoListContents(raw);
|
||||
if (!videoListContents) {
|
||||
console.warn(LOG, "Could not find video list");
|
||||
return null;
|
||||
}
|
||||
if (!videoListContents) return null;
|
||||
|
||||
const videos: PlaylistVideo[] = [];
|
||||
let hasMore = false;
|
||||
|
||||
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,
|
||||
};
|
||||
const { videos } = parseVideoList(videoListContents);
|
||||
return { metadata, videos };
|
||||
} catch (e) {
|
||||
console.error(LOG, "Failed to parse playlist data:", e);
|
||||
console.error("[yt-playlist-features] Failed to parse playlist data:", e);
|
||||
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 {
|
||||
// Extract playlist ID from URL as fallback
|
||||
const playlistId = extractPlaylistId(raw);
|
||||
if (!playlistId) return null;
|
||||
|
||||
// Title from metadata.playlistMetadataRenderer
|
||||
const metadataRenderer = raw?.metadata?.playlistMetadataRenderer;
|
||||
const title = metadataRenderer?.title ?? "";
|
||||
|
||||
// Sidebar has primary and secondary info
|
||||
const sidebarItems =
|
||||
raw?.sidebar?.playlistSidebarRenderer?.items ?? [];
|
||||
const primaryInfo = sidebarItems[0]?.playlistSidebarPrimaryInfoRenderer;
|
||||
const secondaryInfo = sidebarItems[1]?.playlistSidebarSecondaryInfoRenderer;
|
||||
|
||||
// Stats from primary info (e.g. "87 本の動画", "視聴回数 1,234 回", "最終更新日...")
|
||||
const stats = primaryInfo?.stats ?? [];
|
||||
const videoCount = parseVideoCount(stats[0]);
|
||||
const viewCountText = extractText(stats[1]) || null;
|
||||
const lastUpdatedText = extractText(stats[2]) || null;
|
||||
|
||||
// Description from pageHeaderViewModel or primary info
|
||||
const pageHeaderVM =
|
||||
raw?.header?.pageHeaderRenderer?.content?.pageHeaderViewModel;
|
||||
const description =
|
||||
|
|
@ -78,14 +83,12 @@ function extractMetadata(raw: any): PlaylistMetadata | null {
|
|||
extractText(primaryInfo?.description) ||
|
||||
"";
|
||||
|
||||
// Owner from secondary info
|
||||
const ownerRenderer =
|
||||
secondaryInfo?.videoOwner?.videoOwnerRenderer;
|
||||
const ownerRun = ownerRenderer?.title?.runs?.[0];
|
||||
const ownerEndpoint =
|
||||
ownerRun?.navigationEndpoint?.browseEndpoint;
|
||||
|
||||
// Thumbnails from pageHeaderViewModel heroImage or primary info
|
||||
const thumbnails = extractPlaylistThumbnails(pageHeaderVM, primaryInfo);
|
||||
|
||||
return {
|
||||
|
|
@ -93,7 +96,7 @@ function extractMetadata(raw: any): PlaylistMetadata | null {
|
|||
title,
|
||||
description,
|
||||
videoCount,
|
||||
totalDurationText: null, // not available in current structure
|
||||
totalDurationText: null,
|
||||
viewCountText,
|
||||
lastUpdatedText,
|
||||
thumbnails,
|
||||
|
|
@ -108,21 +111,18 @@ function extractMetadata(raw: any): PlaylistMetadata | null {
|
|||
}
|
||||
|
||||
function extractPlaylistId(raw: any): string | null {
|
||||
// Try microformat
|
||||
const microformat =
|
||||
raw?.microformat?.microformatDataRenderer?.urlCanonical;
|
||||
if (microformat) {
|
||||
const match = microformat.match(/[?&]list=([^&]+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
// Try from appindexing link
|
||||
const appLink =
|
||||
raw?.metadata?.playlistMetadataRenderer?.androidAppindexingLink;
|
||||
if (appLink) {
|
||||
const match = appLink.match(/[?&]list=([^&]+)/);
|
||||
if (match) return match[1];
|
||||
}
|
||||
// Fallback to URL
|
||||
const url = new URL(window.location.href);
|
||||
return url.searchParams.get("list");
|
||||
}
|
||||
|
|
@ -193,7 +193,6 @@ function extractPlaylistThumbnails(
|
|||
pageHeaderVM: any,
|
||||
primaryInfo: any,
|
||||
): Thumbnail[] {
|
||||
// Try heroImage from pageHeaderViewModel
|
||||
const heroThumbnails =
|
||||
pageHeaderVM?.heroImage?.contentPreviewImageViewModel?.image?.sources;
|
||||
if (heroThumbnails?.length) {
|
||||
|
|
@ -203,7 +202,6 @@ function extractPlaylistThumbnails(
|
|||
height: t.height ?? 0,
|
||||
}));
|
||||
}
|
||||
// Fallback to primary info thumbnail
|
||||
return (
|
||||
primaryInfo?.thumbnailRenderer?.playlistVideoThumbnailRenderer?.thumbnail
|
||||
?.thumbnails ??
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import browser from "webextension-polyfill";
|
||||
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";
|
||||
|
||||
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;
|
||||
|
||||
function injectPageScript(): void {
|
||||
|
|
@ -19,29 +19,66 @@ function handlePlaylistData(event: Event): void {
|
|||
const detail = (event as CustomEvent).detail;
|
||||
if (!detail) return;
|
||||
|
||||
let raw: any;
|
||||
let payload: any;
|
||||
try {
|
||||
raw = JSON.parse(detail);
|
||||
payload = JSON.parse(detail);
|
||||
} catch {
|
||||
console.error(LOG_PREFIX, "Failed to parse ytInitialData JSON");
|
||||
console.error(LOG, "Failed to parse payload JSON");
|
||||
return;
|
||||
}
|
||||
|
||||
const playlistData = parsePlaylistData(raw);
|
||||
if (!playlistData) {
|
||||
console.warn(LOG_PREFIX, "Could not extract playlist data from ytInitialData");
|
||||
if (payload.error) {
|
||||
console.warn(LOG, "Page script error:", payload.error);
|
||||
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(
|
||||
LOG_PREFIX,
|
||||
`Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount} videos)`,
|
||||
LOG,
|
||||
`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 };
|
||||
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;
|
||||
lastExtractedId = playlistId;
|
||||
|
||||
// Small delay to ensure ytInitialData is updated after SPA navigation
|
||||
setTimeout(() => {
|
||||
injectPageScript();
|
||||
}, 100);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,282 @@
|
|||
// Runs in the page's JS context (not isolated world).
|
||||
// Reads ytInitialData and sends it to the content script via CustomEvent.
|
||||
// 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.
|
||||
|
||||
const data = (window as any).ytInitialData;
|
||||
if (data) {
|
||||
(async () => {
|
||||
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(
|
||||
new CustomEvent("__yt_playlist_ext_data", {
|
||||
detail: JSON.stringify(data),
|
||||
detail: JSON.stringify(payload),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user