Youtube API v3を用いた全件取得
This commit is contained in:
parent
1289f1a374
commit
177a98d312
|
|
@ -3,7 +3,8 @@
|
||||||
"name": "YT Playlist Features",
|
"name": "YT Playlist Features",
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"description": "Extract and work with YouTube playlist data",
|
"description": "Extract and work with YouTube playlist data",
|
||||||
"permissions": [],
|
"permissions": ["storage"],
|
||||||
|
"host_permissions": ["https://www.googleapis.com/*"],
|
||||||
"background": {
|
"background": {
|
||||||
"service_worker": "background/service-worker.js"
|
"service_worker": "background/service-worker.js"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,8 @@
|
||||||
"name": "YT Playlist Features",
|
"name": "YT Playlist Features",
|
||||||
"version": "0.1.1",
|
"version": "0.1.1",
|
||||||
"description": "Extract and work with YouTube playlist data",
|
"description": "Extract and work with YouTube playlist data",
|
||||||
"permissions": [],
|
"permissions": ["storage"],
|
||||||
|
"host_permissions": ["https://www.googleapis.com/*"],
|
||||||
"background": {
|
"background": {
|
||||||
"scripts": ["background/service-worker.js"]
|
"scripts": ["background/service-worker.js"]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,118 @@
|
||||||
import browser from "webextension-polyfill";
|
import browser from "webextension-polyfill";
|
||||||
import type { Message } from "../shared/messages";
|
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(
|
browser.runtime.onMessage.addListener(
|
||||||
async (message: unknown, _sender: browser.Runtime.MessageSender) => {
|
async (message: unknown, sender: browser.Runtime.MessageSender) => {
|
||||||
const msg = message as Message;
|
const msg = message as Message;
|
||||||
|
|
||||||
if (msg.type === "PLAYLIST_EXTRACTED") {
|
if (msg.type === "PLAYLIST_EXTRACTED") {
|
||||||
console.log(
|
console.log(
|
||||||
LOG_PREFIX,
|
LOG,
|
||||||
`Playlist: "${msg.data.metadata.title}" (${msg.data.extractedCount} videos)`,
|
`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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (payload.type === "details_update") {
|
|
||||||
updateTableDetails(payload.updates);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (payload.type === "append") {
|
if (payload.type === "append") {
|
||||||
const newVideos: PlaylistVideo[] = payload.videos.map(mapRawVideo);
|
const newVideos: PlaylistVideo[] = payload.videos.map(mapRawVideo);
|
||||||
appendToTable(newVideos);
|
appendToTable(newVideos);
|
||||||
|
|
@ -126,6 +121,17 @@ function handlePlaylistAppend(event: Event): void {
|
||||||
document.addEventListener("__yt_playlist_ext_data", handlePlaylistData);
|
document.addEventListener("__yt_playlist_ext_data", handlePlaylistData);
|
||||||
document.addEventListener("__yt_playlist_ext_data", handlePlaylistAppend);
|
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
|
// Detect playlist page navigation and trigger extraction
|
||||||
onPlaylistPageReady(() => {
|
onPlaylistPageReady(() => {
|
||||||
const playlistId = getPlaylistId();
|
const playlistId = getPlaylistId();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
|
import type { PlaylistData, PlaylistVideo } from "../../types/playlist";
|
||||||
import { injectStyles } from "./styles";
|
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";
|
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";
|
import { t } from "./i18n";
|
||||||
|
|
||||||
type SortKey = "index" | "title" | "channel" | "duration" | "views" | "published" | "category" | "addedBy" | "votes";
|
type SortKey = "index" | "title" | "channel" | "duration" | "views" | "published" | "category" | "addedBy" | "votes";
|
||||||
|
|
@ -332,13 +334,6 @@ function buildRow(
|
||||||
return tr;
|
return tr;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetailUpdate {
|
|
||||||
videoId: string;
|
|
||||||
viewCountText: string | null;
|
|
||||||
publishedAt: string | null;
|
|
||||||
category: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PlaylistTableHandle {
|
export interface PlaylistTableHandle {
|
||||||
element: HTMLElement;
|
element: HTMLElement;
|
||||||
appendVideos(newVideos: PlaylistVideo[]): void;
|
appendVideos(newVideos: PlaylistVideo[]): void;
|
||||||
|
|
@ -395,11 +390,10 @@ export function renderPlaylistTable(data: PlaylistData): PlaylistTableHandle {
|
||||||
detailsFetched = true;
|
detailsFetched = true;
|
||||||
fetchBtn.disabled = true;
|
fetchBtn.disabled = true;
|
||||||
fetchBtn.textContent = `${t("fetchViewsProgress")} 0/${targetIds.length}`;
|
fetchBtn.textContent = `${t("fetchViewsProgress")} 0/${targetIds.length}`;
|
||||||
document.dispatchEvent(
|
browser.runtime.sendMessage({
|
||||||
new CustomEvent("__yt_playlist_ext_fetch_details", {
|
type: "FETCH_VIDEO_DETAILS",
|
||||||
detail: JSON.stringify({ videoIds: targetIds }),
|
videoIds: targetIds,
|
||||||
}),
|
} satisfies Message);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
header.append(titleSpan, metaSpan, fetchBtn, toggle);
|
header.append(titleSpan, metaSpan, fetchBtn, toggle);
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,6 @@
|
||||||
let isInitialLoad = true;
|
let isInitialLoad = true;
|
||||||
let collabCache: Map<string, string> = new Map();
|
let collabCache: Map<string, string> = new Map();
|
||||||
let collabCachePlaylistId: string | null = null;
|
let collabCachePlaylistId: string | null = null;
|
||||||
let lastBaseContext: any = null;
|
|
||||||
let lastAuthHeaders: Record<string, string> = {};
|
|
||||||
|
|
||||||
function runExtraction() {
|
function runExtraction() {
|
||||||
if (!isPlaylistUrl()) return;
|
if (!isPlaylistUrl()) return;
|
||||||
|
|
@ -39,48 +37,6 @@
|
||||||
// Listen for explicit trigger from content script
|
// Listen for explicit trigger from content script
|
||||||
document.addEventListener("__yt_playlist_ext_trigger", runExtraction);
|
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
|
// Reset on navigation away
|
||||||
document.addEventListener("yt-navigate-start", () => {
|
document.addEventListener("yt-navigate-start", () => {
|
||||||
extractingUrl = null;
|
extractingUrl = null;
|
||||||
|
|
@ -108,8 +64,6 @@
|
||||||
async function extractAndSend(): Promise<void> {
|
async function extractAndSend(): Promise<void> {
|
||||||
const { cfg, apiKey, baseContext } = getConfig();
|
const { cfg, apiKey, baseContext } = getConfig();
|
||||||
const authHeaders = buildAuthHeaders(cfg, baseContext);
|
const authHeaders = buildAuthHeaders(cfg, baseContext);
|
||||||
lastBaseContext = baseContext;
|
|
||||||
lastAuthHeaders = authHeaders;
|
|
||||||
|
|
||||||
// Try ytInitialData only on initial page load.
|
// Try ytInitialData only on initial page load.
|
||||||
// On SPA navigation ytInitialData is stale (still holds the previous page's data),
|
// 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";
|
privacy: "public" | "unlisted" | "private" | "unknown";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DetailUpdate {
|
||||||
|
videoId: string;
|
||||||
|
viewCountText: string | null;
|
||||||
|
publishedAt: string | null;
|
||||||
|
category: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface PlaylistData {
|
export interface PlaylistData {
|
||||||
metadata: PlaylistMetadata;
|
metadata: PlaylistMetadata;
|
||||||
videos: PlaylistVideo[];
|
videos: PlaylistVideo[];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user