Youtube API v3を用いた全件取得

This commit is contained in:
Keisuke Hirata 2026-04-09 04:11:57 +09:00
parent 1289f1a374
commit 177a98d312
10 changed files with 158 additions and 73 deletions

View File

@ -3,7 +3,8 @@
"name": "YT Playlist Features",
"version": "0.1.1",
"description": "Extract and work with YouTube playlist data",
"permissions": [],
"permissions": ["storage"],
"host_permissions": ["https://www.googleapis.com/*"],
"background": {
"service_worker": "background/service-worker.js"
},

View File

@ -3,7 +3,8 @@
"name": "YT Playlist Features",
"version": "0.1.1",
"description": "Extract and work with YouTube playlist data",
"permissions": [],
"permissions": ["storage"],
"host_permissions": ["https://www.googleapis.com/*"],
"background": {
"scripts": ["background/service-worker.js"]
},

View File

@ -1,19 +1,118 @@
import browser from "webextension-polyfill";
import type { Message } from "../shared/messages";
import type { DetailUpdate } from "../types/playlist";
import { CATEGORY_MAP } from "../shared/category-map";
const LOG_PREFIX = "[yt-playlist-features:bg]";
const LOG = "[yt-playlist-features:bg]";
const DEFAULT_API_KEY = "AIzaSyDPyWG3ABnVV3en_KBhIxUH6O2_A0oP4Wk";
const BATCH_SIZE = 50;
async function getApiKey(): Promise<string> {
const result = await browser.storage.sync.get("apiKey");
return (result.apiKey as string) || DEFAULT_API_KEY;
}
const BATCH_DELAY = 500; // ms between batches
const RETRY_DELAY = 5000; // ms before retrying after rate limit
const MAX_RETRIES = 3;
async function fetchVideoDetails(
videoIds: string[],
tabId: number,
): Promise<void> {
const apiKey = await getApiKey();
if (!apiKey) {
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_ERROR",
error: "no-api-key",
} satisfies Message);
return;
}
for (let i = 0; i < videoIds.length; i += BATCH_SIZE) {
if (i > 0) await new Promise((r) => setTimeout(r, BATCH_DELAY));
const batch = videoIds.slice(i, i + BATCH_SIZE);
const ids = batch.join(",");
const url = `https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics&id=${ids}&key=${apiKey}`;
let success = false;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
if (attempt > 0) {
console.log(LOG, `Retry ${attempt} after rate limit, waiting ${RETRY_DELAY}ms...`);
await new Promise((r) => setTimeout(r, RETRY_DELAY));
}
const res = await fetch(url);
if (res.status === 403) {
console.warn(LOG, `Rate limited (attempt ${attempt + 1}/${MAX_RETRIES})`);
continue;
}
if (!res.ok) {
const body = await res.text();
console.error(LOG, `API error ${res.status}:`, body);
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_ERROR",
error: `api-error-${res.status}`,
} satisfies Message);
return;
}
const data = await res.json();
const updates: DetailUpdate[] = (data.items ?? []).map((item: any) => ({
videoId: item.id,
viewCountText: item.statistics?.viewCount
? parseInt(item.statistics.viewCount, 10).toLocaleString()
: null,
publishedAt: item.snippet?.publishedAt?.slice(0, 10) ?? null,
category: CATEGORY_MAP[item.snippet?.categoryId] ?? null,
}));
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_UPDATE",
updates,
} satisfies Message);
success = true;
break;
} catch (e) {
console.error(LOG, "Fetch failed:", e);
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_ERROR",
error: "network-error",
} satisfies Message);
return;
}
}
if (!success) {
console.error(LOG, "Max retries exceeded for batch");
browser.tabs.sendMessage(tabId, {
type: "VIDEO_DETAILS_ERROR",
error: "rate-limit-exceeded",
} satisfies Message);
return;
}
}
}
browser.runtime.onMessage.addListener(
async (message: unknown, _sender: browser.Runtime.MessageSender) => {
async (message: unknown, sender: browser.Runtime.MessageSender) => {
const msg = message as Message;
if (msg.type === "PLAYLIST_EXTRACTED") {
console.log(
LOG_PREFIX,
LOG,
`Playlist: "${msg.data.metadata.title}" (${msg.data.extractedCount} videos)`,
);
}
if (msg.type === "FETCH_VIDEO_DETAILS") {
const tabId = sender.tab?.id;
if (!tabId) return;
fetchVideoDetails(msg.videoIds, tabId);
}
},
);
console.log(LOG_PREFIX, "Service worker started.");
console.log(LOG, "Service worker started.");

View File

@ -108,11 +108,6 @@ function handlePlaylistAppend(event: Event): void {
return;
}
if (payload.type === "details_update") {
updateTableDetails(payload.updates);
return;
}
if (payload.type === "append") {
const newVideos: PlaylistVideo[] = payload.videos.map(mapRawVideo);
appendToTable(newVideos);
@ -126,6 +121,17 @@ function handlePlaylistAppend(event: Event): void {
document.addEventListener("__yt_playlist_ext_data", handlePlaylistData);
document.addEventListener("__yt_playlist_ext_data", handlePlaylistAppend);
// Listen for detail updates from background service worker
browser.runtime.onMessage.addListener((message: unknown) => {
const msg = message as Message;
if (msg.type === "VIDEO_DETAILS_UPDATE") {
updateTableDetails(msg.updates);
}
if (msg.type === "VIDEO_DETAILS_ERROR") {
console.error(LOG, "Detail fetch error:", msg.error);
}
});
// Detect playlist page navigation and trigger extraction
onPlaylistPageReady(() => {
const playlistId = getPlaylistId();

View File

@ -1,6 +1,7 @@
import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
import { injectStyles } from "./styles";
import { renderPlaylistTable, type PlaylistTableHandle, type DetailUpdate } from "./table-renderer";
import type { DetailUpdate } from "../../types/playlist";
import { renderPlaylistTable, type PlaylistTableHandle } from "./table-renderer";
const CONTAINER_ID = "ytpf-playlist-table";

View File

@ -1,4 +1,6 @@
import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
import browser from "webextension-polyfill";
import type { PlaylistData, PlaylistVideo, DetailUpdate } from "../../types/playlist";
import type { Message } from "../../shared/messages";
import { t } from "./i18n";
type SortKey = "index" | "title" | "channel" | "duration" | "views" | "published" | "category" | "addedBy" | "votes";
@ -332,13 +334,6 @@ function buildRow(
return tr;
}
export interface DetailUpdate {
videoId: string;
viewCountText: string | null;
publishedAt: string | null;
category: string | null;
}
export interface PlaylistTableHandle {
element: HTMLElement;
appendVideos(newVideos: PlaylistVideo[]): void;
@ -395,11 +390,10 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle {
detailsFetched = true;
fetchBtn.disabled = true;
fetchBtn.textContent = `${t("fetchViewsProgress")} 0/${targetIds.length}`;
document.dispatchEvent(
new CustomEvent("__yt_playlist_ext_fetch_details", {
detail: JSON.stringify({ videoIds: targetIds }),
}),
);
browser.runtime.sendMessage({
type: "FETCH_VIDEO_DETAILS",
videoIds: targetIds,
} satisfies Message);
});
header.append(titleSpan, metaSpan, fetchBtn, toggle);

View File

@ -19,8 +19,6 @@
let isInitialLoad = true;
let collabCache: Map<string, string> = new Map();
let collabCachePlaylistId: string | null = null;
let lastBaseContext: any = null;
let lastAuthHeaders: Record<string, string> = {};
function runExtraction() {
if (!isPlaylistUrl()) return;
@ -39,48 +37,6 @@
// Listen for explicit trigger from content script
document.addEventListener("__yt_playlist_ext_trigger", runExtraction);
// Handle on-demand detail fetching via player API
document.addEventListener("__yt_playlist_ext_fetch_details", async (e) => {
const { videoIds } = JSON.parse((e as CustomEvent).detail);
if (!videoIds?.length || !lastBaseContext) return;
const concurrency = 5;
for (let i = 0; i < videoIds.length; i += concurrency) {
const batch = videoIds.slice(i, i + concurrency);
const results = await Promise.all(
batch.map(async (id: string) => {
try {
const res = await fetch(
"https://www.youtube.com/youtubei/v1/player?prettyPrint=false",
{
method: "POST",
headers: { "Content-Type": "application/json", ...lastAuthHeaders },
credentials: "same-origin",
body: JSON.stringify({ context: lastBaseContext, videoId: id }),
},
);
if (!res.ok) return null;
const data = await res.json();
const vc = data.videoDetails?.viewCount;
const mf = data.microformat?.playerMicroformatRenderer;
return {
videoId: id,
viewCountText: vc ? parseInt(vc, 10).toLocaleString() : null,
publishedAt: mf?.publishDate ?? null,
category: mf?.category ?? null,
};
} catch {
return null;
}
}),
);
const updates = results.filter(Boolean);
if (updates.length > 0) {
sendResult({ type: "details_update", updates });
}
}
});
// Reset on navigation away
document.addEventListener("yt-navigate-start", () => {
extractingUrl = null;
@ -108,8 +64,6 @@
async function extractAndSend(): Promise<void> {
const { cfg, apiKey, baseContext } = getConfig();
const authHeaders = buildAuthHeaders(cfg, baseContext);
lastBaseContext = baseContext;
lastAuthHeaders = authHeaders;
// Try ytInitialData only on initial page load.
// On SPA navigation ytInitialData is stale (still holds the previous page's data),

View File

@ -0,0 +1,18 @@
/** YouTube video category ID to name mapping */
export const CATEGORY_MAP: Record<string, string> = {
"1": "Film & Animation",
"2": "Autos & Vehicles",
"10": "Music",
"15": "Pets & Animals",
"17": "Sports",
"19": "Travel & Events",
"20": "Gaming",
"22": "People & Blogs",
"23": "Comedy",
"24": "Entertainment",
"25": "News & Politics",
"26": "Howto & Style",
"27": "Education",
"28": "Science & Technology",
"29": "Nonprofits & Activism",
};

View File

@ -1,3 +1,7 @@
import type { PlaylistData } from "../types/playlist";
import type { PlaylistData, DetailUpdate } from "../types/playlist";
export type Message = { type: "PLAYLIST_EXTRACTED"; data: PlaylistData };
export type Message =
| { type: "PLAYLIST_EXTRACTED"; data: PlaylistData }
| { type: "FETCH_VIDEO_DETAILS"; videoIds: string[] }
| { type: "VIDEO_DETAILS_UPDATE"; updates: DetailUpdate[] }
| { type: "VIDEO_DETAILS_ERROR"; error: string };

View File

@ -50,6 +50,13 @@ export interface PlaylistMetadata {
privacy: "public" | "unlisted" | "private" | "unknown";
}
export interface DetailUpdate {
videoId: string;
viewCountText: string | null;
publishedAt: string | null;
category: string | null;
}
export interface PlaylistData {
metadata: PlaylistMetadata;
videos: PlaylistVideo[];