Youtube API v3を用いた全件取得
This commit is contained in:
parent
1289f1a374
commit
177a98d312
|
|
@ -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"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
18
src/shared/category-map.ts
Normal file
18
src/shared/category-map.ts
Normal 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",
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user