内部データを用いた取得テスト
This commit is contained in:
@@ -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.");
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user