// ==UserScript== // @name YouTube Analytics // @namespace http://tampermonkey.net/ // @version 1.1.7 // @description 获取7天前视频和Shorts的播放量统计并显示图表 // @author NIL // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_xmlhttpRequest // @match https://www.youtube.com/@* // @match https://www.youtube.com/channel/* // @match https://www.youtube.com/c/* // @match https://www.youtube.com/*/shorts // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @require https://cdn.jsdelivr.net/npm/chart.js // ==/UserScript== (function () { "use strict"; const API_BASE = "https://www.googleapis.com/youtube/v3"; const DEFAULT_PARAMS = { part: "snippet", maxResults: 20, order: "date", }; // 注册菜单命令 GM_registerMenuCommand("设置 API KEY", () => { const currentApiKey = GM_getValue("apiKey", ""); const userInput = prompt("请输入 YouTube Data API 密钥", currentApiKey); if (userInput !== null) GM_setValue("apiKey", userInput); }); GM_registerMenuCommand("设置 CPM", () => { const currentCPM = GM_getValue("cpm", 20); const userInput = prompt("请输入CPM", currentCPM); if (userInput !== null) GM_setValue("cpm", Number(userInput)); }); let isMainRunning = false; async function main() { if (isMainRunning) return; isMainRunning = true; try { // 清理旧元素 document.querySelectorAll("#yt-analytics").forEach((el) => el.remove()); const apiKey = GM_getValue("apiKey"); if (!apiKey) return alert("请先设置API密钥"); const channelId = await getChannelId(); if (!channelId) throw new Error("获取频道ID失败"); const results = await Promise.all([ processPlaylist(`UULF${channelId.slice(2)}`, apiKey), processPlaylist(`UUSH${channelId.slice(2)}`, apiKey), ]); showResults({ normal: results[0], shorts: results[1], }); } catch (error) { alert(`错误: ${error.message}`); console.error("运行错误:", error); } finally { isMainRunning = false; } } async function processPlaylist(playlistId, apiKey) { try { const rawItems = await fetchPlaylistItems(playlistId, apiKey); const filteredItems = filterOldVideos(rawItems, 7); console.log(filteredItems); if (filteredItems.length === 0) return null; const statsData = await fetchVideoStats(filteredItems, apiKey); const mergedData = mergeVideoData(filteredItems, statsData); // 通过播放列表ID前缀判断类型 const type = playlistId.startsWith("UUSH") ? "shorts" : "normal"; console.log(type, statsData); return { ...calculateMetrics(mergedData), type, dataPoints: mergedData, }; } catch (error) { console.error(`处理播放列表 ${playlistId} 失败:`, error); return null; } } function fetchPlaylistItems(playlistId, apiKey) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: `${API_BASE}/playlistItems?${new URLSearchParams({ ...DEFAULT_PARAMS, playlistId, key: apiKey, })}`, onload: (res) => { const data = JSON.parse(res.responseText); data.items ? resolve(data.items) : reject("无效的播放列表"); }, onerror: (err) => reject(`播放列表请求失败: ${err}`), }); }); } function filterOldVideos(items, days) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); return items .filter((item) => { const pubDate = new Date(item.snippet.publishedAt); return pubDate < cutoff; }) .slice(0, 10); } function fetchVideoStats(items, apiKey) { const videoIds = items.map((item) => item.snippet.resourceId.videoId); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: "GET", url: `${API_BASE}/videos?${new URLSearchParams({ part: "statistics", id: videoIds.join(","), key: apiKey, })}`, onload: (res) => { const data = JSON.parse(res.responseText); resolve(data.items || []); }, onerror: (err) => reject(`统计请求失败 ${err}`), }); }); } function mergeVideoData(playlistItems, statsItems) { return playlistItems .map((item) => { const stats = statsItems.find( (s) => s.id === item.snippet.resourceId.videoId, ); return { views: stats ? parseInt(stats.statistics.viewCount) : 0, date: new Date(item.snippet.publishedAt), }; }) .sort((a, b) => a.date - b.date); // 按日期排序 } function calculateMetrics(dataPoints) { const validData = dataPoints.filter((d) => d.views > 0); if (validData.length === 0) return null; const totalViews = validData.reduce((sum, d) => sum + d.views, 0); const avgViews = totalViews / validData.length; const cpm = GM_getValue("cpm", 20); return { avgViewsK: avgViews / 1000, earnings: (avgViews / 1000) * cpm, sampleSize: validData.length, }; } function createErrorMessage(container) { const errorMsg = document.createElement("div"); errorMsg.style.color = "#ff4444"; errorMsg.style.padding = "10px"; errorMsg.style.textAlign = "center"; errorMsg.textContent = "没有找到有效数据!"; container.appendChild(errorMsg); } function showResults({ normal, shorts }) { const container = document.createElement("div"); const isDarkTheme = document.documentElement.hasAttribute("dark") && document.documentElement.hasAttribute("darker-dark-theme"); let backgroundColor = `rgba(255, 255, 255, 0.8)`; let fontColor = `rgb(113, 113, 113)`; //检测是否为暗色模式 if (isDarkTheme === true) { backgroundColor = `rgba(71, 71, 71, 0.8)`; fontColor = `rgb(214, 206, 206)`; } container.id = "yt-analytics"; container.style.cssText = ` position: absolute; top: 100px; right: 60px; background: ${backgroundColor}; backdrop-filter: blur(10px); padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.2); z-index: 2019; font-family: Roboto, sans-serif; font-size: 1.5rem; color: ${fontColor}; `; const contentWrapper = document.createElement("div"); contentWrapper.id = "content-container"; contentWrapper.style.display = "flex"; contentWrapper.style.justifyContent = "space-between"; // 显示统计数据 if (normal) createStatsSection("长视频", normal, contentWrapper); if (shorts) createStatsSection("Shorts", shorts, contentWrapper); if (!normal && !shorts) createErrorMessage(contentWrapper); // 添加图表容器 const chartsContainer = document.createElement("div"); chartsContainer.style.marginTop = "20px"; chartsContainer.style.display = "flex"; if (normal) createChartContainer("normal", "长视频播放趋势", chartsContainer); if (shorts) createChartContainer("shorts", "Shorts播放趋势", chartsContainer); container.appendChild(contentWrapper); container.appendChild(chartsContainer); document.body.appendChild(container); // 延迟渲染图表确保容器已存在 setTimeout(() => { if (normal) renderChart("chart-normal", normal.dataPoints, "#FF6384", fontColor); if (shorts) renderChart("chart-shorts", shorts.dataPoints, "#36A2EB", fontColor); }, 100); } function createStatsSection(titleText, data, container) { const section = document.createElement("div"); section.style.marginBottom = "20px"; section.style.paddingBottom = "15px"; const title = document.createElement("h3"); title.textContent = `${titleText} (${data.sampleSize}个)`; title.style.margin = "0 0 10px 0"; section.appendChild(title); const views = document.createElement("div"); views.textContent = `平均千次播放量: ${data.avgViewsK.toFixed(1)}k`; views.style.fontSize = "1.2rem"; section.appendChild(views); const earnings = document.createElement("div"); earnings.style.color = "#FFA500"; earnings.style.marginTop = "8px"; earnings.textContent = `预估佣金: $${data.earnings.toFixed(2)}`; section.appendChild(earnings); container.appendChild(section); } function createChartContainer(type, title, container) { const chartSection = document.createElement("div"); chartSection.style.marginBottom = "25px"; const chartTitle = document.createElement("h4"); chartTitle.textContent = title; chartTitle.style.margin = "0 0 10px 0"; chartTitle.style.fontSize = "13px"; chartSection.appendChild(chartTitle); const canvas = document.createElement("canvas"); canvas.id = `chart-${type}`; canvas.style.maxHeight = "200px"; chartSection.appendChild(canvas); container.appendChild(chartSection); } function renderChart(canvasId, dataPoints, color, fontColor) { const ctx = document.getElementById(canvasId).getContext("2d"); new Chart(ctx, { type: "line", data: { labels: dataPoints.map((d) => formatDate(d.date)), datasets: [ { label: "播放量", data: dataPoints.map((d) => d.views), borderColor: color, tension: 0.4, fill: false, pointRadius: 3, pointHitRadius: 10, pointHoverRadius: 6, }, ], }, options: { responsive: true, plugins: { legend: { display: false, }, tooltip: { mode: "nearest", intersect: false, }, }, interaction: { mode: "nearest", intersect: false, }, scales: { x: { grid: { display: false, color: fontColor, }, ticks: { color: fontColor, }, }, y: { beginAtZero: true, grid: { color: fontColor, display: true, }, border: { display: false, }, ticks: { color: fontColor, callback: (value) => value >= 1000 ? `${(value / 1000).toFixed(0)}k` : value, }, }, }, }, }); } //格式化日期 function formatDate(date) { return new Date(date) .toLocaleDateString("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", }) .replace(/\//g, "-"); } function getChannelId() { return new Promise((resolve) => { const checkExist = setInterval(() => { const metaTag = document.querySelector('meta[itemprop="identifier"]'); const linkHref = document.querySelector('meta[property="og:url"]'); // 添加识别提高识别成功率 if (metaTag) { clearInterval(checkExist); resolve(metaTag.getAttribute("content")); } else if (linkHref) { clearInterval(checkExist); const url = new URL( document.querySelector('meta[property="og:url"]').content, ); resolve(url.pathname.split("/")[2]); } }, 500); setTimeout(() => { clearInterval(checkExist); resolve(null); }, 10000); }); } let lastHref = window.location.href; setInterval(() => { const currentHref = window.location.href; if (currentHref !== lastHref) { lastHref = currentHref; document.querySelectorAll("#yt-analytics").forEach((el) => el.remove()); } }, 1000); window.addEventListener("load", () => setTimeout(main, 2000)); })();