From 177a98d312c773b8b6c547b37f23c83a078487b7 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 9 Apr 2026 04:11:57 +0900 Subject: [PATCH] =?UTF-8?q?Youtube=20API=20v3=E3=82=92=E7=94=A8=E3=81=84?= =?UTF-8?q?=E3=81=9F=E5=85=A8=E4=BB=B6=E5=8F=96=E5=BE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- manifest.chrome.json | 3 +- manifest.firefox.json | 3 +- src/background/service-worker.ts | 107 +++++++++++++++++++++++++++++-- src/content/index.ts | 16 +++-- src/content/ui/lifecycle.ts | 3 +- src/content/ui/table-renderer.ts | 20 ++---- src/injected/page-script.ts | 46 ------------- src/shared/category-map.ts | 18 ++++++ src/shared/messages.ts | 8 ++- src/types/playlist.ts | 7 ++ 10 files changed, 158 insertions(+), 73 deletions(-) create mode 100644 src/shared/category-map.ts diff --git a/manifest.chrome.json b/manifest.chrome.json index 310040e..b219589 100644 --- a/manifest.chrome.json +++ b/manifest.chrome.json @@ -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" }, diff --git a/manifest.firefox.json b/manifest.firefox.json index c5ae2b9..a14a152 100644 --- a/manifest.firefox.json +++ b/manifest.firefox.json @@ -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"] }, diff --git a/src/background/service-worker.ts b/src/background/service-worker.ts index 13a34d0..18befc3 100644 --- a/src/background/service-worker.ts +++ b/src/background/service-worker.ts @@ -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 { + 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 { + 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."); diff --git a/src/content/index.ts b/src/content/index.ts index 4788f9e..27b7e35 100644 --- a/src/content/index.ts +++ b/src/content/index.ts @@ -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(); diff --git a/src/content/ui/lifecycle.ts b/src/content/ui/lifecycle.ts index 86375fd..5d62816 100644 --- a/src/content/ui/lifecycle.ts +++ b/src/content/ui/lifecycle.ts @@ -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"; diff --git a/src/content/ui/table-renderer.ts b/src/content/ui/table-renderer.ts index 870104b..8efbb26 100644 --- a/src/content/ui/table-renderer.ts +++ b/src/content/ui/table-renderer.ts @@ -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); diff --git a/src/injected/page-script.ts b/src/injected/page-script.ts index f2e2049..fb6c44c 100644 --- a/src/injected/page-script.ts +++ b/src/injected/page-script.ts @@ -19,8 +19,6 @@ let isInitialLoad = true; let collabCache: Map = new Map(); let collabCachePlaylistId: string | null = null; - let lastBaseContext: any = null; - let lastAuthHeaders: Record = {}; 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 { 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), diff --git a/src/shared/category-map.ts b/src/shared/category-map.ts new file mode 100644 index 0000000..320a04c --- /dev/null +++ b/src/shared/category-map.ts @@ -0,0 +1,18 @@ +/** YouTube video category ID to name mapping */ +export const CATEGORY_MAP: Record = { + "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", +}; diff --git a/src/shared/messages.ts b/src/shared/messages.ts index a9ddd1f..303efe0 100644 --- a/src/shared/messages.ts +++ b/src/shared/messages.ts @@ -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 }; diff --git a/src/types/playlist.ts b/src/types/playlist.ts index a3bc7f2..5bd38e6 100644 --- a/src/types/playlist.ts +++ b/src/types/playlist.ts @@ -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[];