内部データを用いた取得テスト

This commit is contained in:
2026-04-08 01:07:10 +09:00
commit a05af1a208
15 changed files with 1098 additions and 0 deletions
+27
View File
@@ -0,0 +1,27 @@
import browser from "webextension-polyfill";
import type { Message } from "../shared/messages";
import { savePlaylist, getPlaylist } from "../shared/storage";
const LOG_PREFIX = "[yt-playlist-features:bg]";
browser.runtime.onMessage.addListener(
async (message: unknown, _sender: browser.Runtime.MessageSender) => {
const msg = message as Message;
if (msg.type === "PLAYLIST_EXTRACTED") {
console.log(
LOG_PREFIX,
`Saving playlist: "${msg.data.metadata.title}" (${msg.data.extractedCount} videos)`,
);
await savePlaylist(msg.data);
return;
}
if (msg.type === "GET_PLAYLIST") {
const data = await getPlaylist(msg.playlistId);
return { type: "PLAYLIST_RESPONSE", data } satisfies Message;
}
},
);
console.log(LOG_PREFIX, "Service worker started.");
+220
View File
@@ -0,0 +1,220 @@
import type {
PlaylistData,
PlaylistMetadata,
PlaylistVideo,
Thumbnail,
} from "../types/playlist";
/* eslint-disable @typescript-eslint/no-explicit-any */
const LOG = "[yt-playlist-features]";
export function parsePlaylistData(raw: any): PlaylistData | null {
try {
const metadata = extractMetadata(raw);
if (!metadata) {
console.warn(LOG, "Could not extract metadata");
return null;
}
const videoListContents = findVideoListContents(raw);
if (!videoListContents) {
console.warn(LOG, "Could not find video list");
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,
};
} catch (e) {
console.error(LOG, "Failed to parse playlist data:", e);
return null;
}
}
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 =
extractText(pageHeaderVM?.description?.descriptionPreviewViewModel?.description) ||
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 {
playlistId,
title,
description,
videoCount,
totalDurationText: null, // not available in current structure
viewCountText,
lastUpdatedText,
thumbnails,
owner: {
name: ownerRun?.text ?? "",
channelId: ownerEndpoint?.browseId ?? "",
url: ownerRun?.navigationEndpoint?.commandMetadata
?.webCommandMetadata?.url ?? "",
},
privacy: "unknown",
};
}
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");
}
function findVideoListContents(raw: any): any[] | null {
const tabs =
raw?.contents?.twoColumnBrowseResultsRenderer?.tabs;
if (!tabs?.length) return null;
const tabContent = tabs[0]?.tabRenderer?.content;
const sectionContents =
tabContent?.sectionListRenderer?.contents;
if (!sectionContents?.length) return null;
const itemSection =
sectionContents[0]?.itemSectionRenderer?.contents;
if (!itemSection?.length) return null;
return itemSection[0]?.playlistVideoListRenderer?.contents ?? null;
}
function parseVideo(renderer: any): PlaylistVideo | null {
const videoId = renderer.videoId;
if (!videoId) return null;
const bylineRun = renderer.shortBylineText?.runs?.[0];
const bylineEndpoint =
bylineRun?.navigationEndpoint?.browseEndpoint;
const lengthSeconds = renderer.lengthSeconds
? parseInt(renderer.lengthSeconds, 10)
: null;
return {
videoId,
title: extractText(renderer.title),
index: parseInt(extractText(renderer.index) || "0", 10),
durationSeconds: lengthSeconds,
durationText: extractText(renderer.lengthText) || null,
thumbnails: renderer.thumbnail?.thumbnails ?? [],
channel: {
name: bylineRun?.text ?? "",
channelId: bylineEndpoint?.browseId ?? "",
url: bylineRun?.navigationEndpoint?.commandMetadata
?.webCommandMetadata?.url ?? "",
},
isPlayable: renderer.isPlayable !== false,
isLive:
renderer.badges?.some(
(b: any) =>
b.metadataBadgeRenderer?.style === "BADGE_STYLE_TYPE_LIVE_NOW",
) ?? false,
};
}
function extractText(textObj: any): string {
if (!textObj) return "";
if (typeof textObj === "string") return textObj;
if (textObj.simpleText) return textObj.simpleText;
if (textObj.runs) {
return textObj.runs.map((r: any) => r.text).join("");
}
if (textObj.content) return extractText(textObj.content);
return "";
}
function extractPlaylistThumbnails(
pageHeaderVM: any,
primaryInfo: any,
): Thumbnail[] {
// Try heroImage from pageHeaderViewModel
const heroThumbnails =
pageHeaderVM?.heroImage?.contentPreviewImageViewModel?.image?.sources;
if (heroThumbnails?.length) {
return heroThumbnails.map((t: any) => ({
url: t.url ?? "",
width: t.width ?? 0,
height: t.height ?? 0,
}));
}
// Fallback to primary info thumbnail
return (
primaryInfo?.thumbnailRenderer?.playlistVideoThumbnailRenderer?.thumbnail
?.thumbnails ??
primaryInfo?.thumbnailRenderer?.playlistCustomThumbnailRenderer?.thumbnail
?.thumbnails ??
[]
);
}
function parseVideoCount(textObj: any): number {
const text = extractText(textObj);
const match = text.replace(/,/g, "").match(/(\d+)/);
return match ? parseInt(match[1], 10) : 0;
}
+61
View File
@@ -0,0 +1,61 @@
import browser from "webextension-polyfill";
import { onPlaylistPageReady, getPlaylistId } from "./navigation";
import { parsePlaylistData } from "./extractor";
import type { Message } from "../shared/messages";
const LOG_PREFIX = "[yt-playlist-features]";
// Track the last extracted playlist ID to avoid duplicate extractions
let lastExtractedId: string | null = null;
function injectPageScript(): void {
const script = document.createElement("script");
script.src = browser.runtime.getURL("injected/page-script.js");
script.onload = () => script.remove();
(document.head || document.documentElement).appendChild(script);
}
function handlePlaylistData(event: Event): void {
const detail = (event as CustomEvent).detail;
if (!detail) return;
let raw: any;
try {
raw = JSON.parse(detail);
} catch {
console.error(LOG_PREFIX, "Failed to parse ytInitialData JSON");
return;
}
const playlistData = parsePlaylistData(raw);
if (!playlistData) {
console.warn(LOG_PREFIX, "Could not extract playlist data from ytInitialData");
return;
}
console.log(
LOG_PREFIX,
`Extracted playlist: "${playlistData.metadata.title}" (${playlistData.extractedCount} 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);
});
}
// Listen for data from the injected page script
document.addEventListener("__yt_playlist_ext_data", handlePlaylistData);
// Detect playlist page navigation and trigger extraction
onPlaylistPageReady(() => {
const playlistId = getPlaylistId();
if (!playlistId || playlistId === lastExtractedId) return;
lastExtractedId = playlistId;
// Small delay to ensure ytInitialData is updated after SPA navigation
setTimeout(() => {
injectPageScript();
}, 100);
});
+38
View File
@@ -0,0 +1,38 @@
type NavigationCallback = () => void;
export function onPlaylistPageReady(callback: NavigationCallback): void {
// Check current page immediately
if (isPlaylistPage()) {
callback();
}
// YouTube's SPA navigation fires this custom event on completion
document.addEventListener("yt-navigate-finish", () => {
if (isPlaylistPage()) {
callback();
}
});
// Fallback: watch <title> changes as a signal of navigation
const titleEl = document.querySelector("title");
if (titleEl) {
const observer = new MutationObserver(() => {
if (isPlaylistPage()) {
callback();
}
});
observer.observe(titleEl, { childList: true });
}
}
function isPlaylistPage(): boolean {
const url = new URL(window.location.href);
return (
url.pathname === "/playlist" && url.searchParams.has("list")
);
}
export function getPlaylistId(): string | null {
const url = new URL(window.location.href);
return url.searchParams.get("list");
}
+11
View File
@@ -0,0 +1,11 @@
// Runs in the page's JS context (not isolated world).
// Reads ytInitialData and sends it to the content script via CustomEvent.
const data = (window as any).ytInitialData;
if (data) {
document.dispatchEvent(
new CustomEvent("__yt_playlist_ext_data", {
detail: JSON.stringify(data),
}),
);
}
+6
View File
@@ -0,0 +1,6 @@
import type { PlaylistData } from "../types/playlist";
export type Message =
| { type: "PLAYLIST_EXTRACTED"; data: PlaylistData }
| { type: "GET_PLAYLIST"; playlistId: string }
| { type: "PLAYLIST_RESPONSE"; data: PlaylistData | null };
+25
View File
@@ -0,0 +1,25 @@
import browser from "webextension-polyfill";
import type { PlaylistData } from "../types/playlist";
const STORAGE_PREFIX = "playlist:";
export async function savePlaylist(data: PlaylistData): Promise<void> {
await browser.storage.local.set({
[STORAGE_PREFIX + data.metadata.playlistId]: data,
});
}
export async function getPlaylist(
playlistId: string,
): Promise<PlaylistData | null> {
const key = STORAGE_PREFIX + playlistId;
const result = await browser.storage.local.get(key);
return (result[key] as PlaylistData) ?? null;
}
export async function getAllPlaylists(): Promise<PlaylistData[]> {
const all = await browser.storage.local.get(null);
return Object.entries(all)
.filter(([key]) => key.startsWith(STORAGE_PREFIX))
.map(([, value]) => value as PlaylistData);
}
+51
View File
@@ -0,0 +1,51 @@
export interface Thumbnail {
url: string;
width: number;
height: number;
}
export interface PlaylistVideo {
videoId: string;
title: string;
/** 0-indexed position in the playlist */
index: number;
/** Duration in seconds; null if live or unknown */
durationSeconds: number | null;
/** Human-readable duration, e.g. "12:34" */
durationText: string | null;
thumbnails: Thumbnail[];
channel: {
name: string;
channelId: string;
url: string;
};
isPlayable: boolean;
isLive: boolean;
}
export interface PlaylistMetadata {
playlistId: string;
title: string;
description: string;
videoCount: number;
totalDurationText: string | null;
viewCountText: string | null;
lastUpdatedText: string | null;
thumbnails: Thumbnail[];
owner: {
name: string;
channelId: string;
url: string;
};
privacy: "public" | "unlisted" | "private" | "unknown";
}
export interface PlaylistData {
metadata: PlaylistMetadata;
videos: PlaylistVideo[];
/** ISO timestamp of extraction */
extractedAt: string;
/** Whether all videos were loaded or only the first page */
isComplete: boolean;
extractedCount: number;
}