// ==UserScript== // @name YouTube 影片庫自動化匯入撥放清單工具 // @namespace https://github.com/downwarjers/WebTweaks // @version 1.2.0 // @description 批次匯入影片至指定清單,並自動掃描帳號內所有播放清單,確保影片在全域收藏中不重複。 // @author downwarjers // @license MIT // @match https://www.youtube.com/* // @icon https://www.google.com/s2/favicons?sz=64&domain=youtube.com // @grant GM_addStyle // @run-at document-idle // @downloadURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/youtube-playlist-auto-importer/youtube-playlist-auto-importer.user.js // @updateURL https://raw.githubusercontent.com/downwarjers/WebTweaks/main/UserScripts/youtube-playlist-auto-importer/youtube-playlist-auto-importer.user.js // ==/UserScript== (function () { 'use strict'; // --- UI 樣式 --- GM_addStyle(` #yt-global-panel { position: fixed; bottom: 20px; right: 20px; width: 400px; background: #1f1f1f; border: 1px solid #ff4e45; border-radius: 12px; z-index: 9999; padding: 15px; box-shadow: 0 4px 12px rgba(0,0,0,0.5); color: #fff; font-family: Roboto, Arial, sans-serif; display: none; } #yt-global-panel.visible { display: block; } #yt-global-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; } #yt-global-title { font-size: 16px; font-weight: bold; color: #ff4e45; } #yt-global-close { cursor: pointer; font-size: 18px; } .yt-select-container { display: flex; gap: 5px; margin-bottom: 10px; align-items: center; } #yt-target-select { flex-grow: 1; background: #0f0f0f; border: 1px solid #444; color: #eee; padding: 8px; border-radius: 4px; font-size: 13px; cursor: pointer; } #yt-refresh-list { cursor: pointer; padding: 8px; background: #333; border-radius: 4px; font-size: 14px; user-select: none; min-width: 20px; text-align: center; } #yt-refresh-list:hover { background: #444; } .yt-global-input { width: 100%; background: #0f0f0f; border: 1px solid #444; color: #eee; padding: 8px; margin-bottom: 10px; border-radius: 4px; box-sizing: border-box; resize: vertical; font-family: monospace; font-size: 12px; } #yt-global-btn { width: 100%; background: #ff4e45; color: #fff; border: none; padding: 8px; border-radius: 18px; cursor: pointer; font-weight: bold; } #yt-global-btn:disabled { background: #555; color: #888; cursor: not-allowed; } #yt-global-log { margin-top: 10px; font-size: 12px; color: #aaa; max-height: 250px; /* 增加高度 */ overflow-y: auto; white-space: pre-wrap; border-top: 1px solid #333; padding-top: 5px; } #yt-embedded-toggle { display: inline-flex; align-items: center; justify-content: center; width: 40px; height: 40px; cursor: pointer; border-radius: 50%; background: transparent; border: none; color: var(--yt-spec-text-primary, #fff); margin-right: 8px; vertical-align: middle; } #yt-embedded-toggle:hover { background-color: rgba(255, 255, 255, 0.1); } #yt-embedded-toggle svg { width: 24px; height: 24px; fill: currentColor; } `); // --- 核心工具 --- function getCookie(name) { const value = `; ${document.cookie}`; const parts = value.split(`; ${name}=`); if (parts.length === 2) { return parts.pop().split(';').shift(); } } async function generateSAPISIDHASH() { const sapisid = getCookie('SAPISID'); if (!sapisid) { return null; } const timestamp = Math.floor(Date.now() / 1000); const origin = window.location.origin; const str = `${timestamp} ${sapisid} ${origin}`; const buffer = new TextEncoder().encode(str); const hashBuffer = await crypto.subtle.digest('SHA-1', buffer); const hashHex = Array.from(new Uint8Array(hashBuffer)) .map((b) => { return b.toString(16).padStart(2, '0'); }) .join(''); return `SAPISIDHASH ${timestamp}_${hashHex}`; } // --- Auth --- function scanDOMForAuth() { const scripts = document.getElementsByTagName('script'); let apiKey = null; let clientVersion = null; for (let i = 0; i < scripts.length; i++) { const content = scripts[i].innerHTML; if (!content) { continue; } if (!apiKey) { const matchKey = content.match(/"INNERTUBE_API_KEY"\s*:\s*"([^"]+)"/); if (matchKey) { apiKey = matchKey[1]; } } if (!clientVersion) { const matchVer = content.match(/"INNERTUBE_CONTEXT_CLIENT_VERSION"\s*:\s*"([^"]+)"/); if (matchVer) { clientVersion = matchVer[1]; } } if (apiKey && clientVersion) { break; } } if (apiKey && clientVersion) { return { apiKey: apiKey, context: { client: { hl: 'zh-TW', gl: 'TW', clientName: 'WEB', clientVersion: clientVersion }, }, source: 'DOM_SCAN', }; } return null; } function waitForAuth(timeout = 5000) { return new Promise((resolve, reject) => { const start = Date.now(); const check = () => { let apiKey = null; let context = null; let source = ''; if (window.ytcfg && window.ytcfg.data_ && window.ytcfg.data_.INNERTUBE_API_KEY) { apiKey = window.ytcfg.data_.INNERTUBE_API_KEY; context = { client: { hl: window.ytcfg.data_.HL, gl: window.ytcfg.data_.GL, clientName: window.ytcfg.data_.INNERTUBE_CONTEXT_CLIENT_NAME, clientVersion: window.ytcfg.data_.INNERTUBE_CONTEXT_CLIENT_VERSION, }, }; source = 'Global_ytcfg'; } if (!apiKey && window.ytcfg && typeof window.ytcfg.get === 'function') { apiKey = window.ytcfg.get('INNERTUBE_API_KEY'); if (apiKey) { context = { client: { hl: window.ytcfg.get('HL'), gl: window.ytcfg.get('GL'), clientName: window.ytcfg.get('INNERTUBE_CONTEXT_CLIENT_NAME'), clientVersion: window.ytcfg.get('INNERTUBE_CONTEXT_CLIENT_VERSION'), }, }; source = 'Global_ytcfg_get'; } } if (!apiKey) { const domResult = scanDOMForAuth(); if (domResult) { apiKey = domResult.apiKey; context = domResult.context; source = domResult.source; } } if (apiKey) { resolve({ apiKey, context, source }); } else if (Date.now() - start > timeout) { reject(new Error('Timeout: Auth Failed')); } else { setTimeout(check, 500); } }; check(); }); } function getTitleText(obj) { if (!obj) { return null; } if (typeof obj === 'string') { return obj; } if (obj.simpleText) { return obj.simpleText; } if (obj.runs && obj.runs.length > 0) { return obj.runs[0].text; } if (obj.content) { return getTitleText(obj.content); } return null; } // --- API 1: 列表 --- async function fetchAccountPlaylists(apiKey, context, authHeader) { const response = await fetch(`https://www.youtube.com/youtubei/v1/browse?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authHeader, 'X-Origin': window.location.origin, }, body: JSON.stringify({ context: context, browseId: 'FEplaylist_aggregation' }), }); if (!response.ok) { throw new Error(`API Error: ${response.status}`); } const json = await response.json(); const playlists = new Map(); function findPlaylists(obj) { if (!obj || typeof obj !== 'object') { return; } if (obj.gridPlaylistRenderer || obj.playlistListItemRenderer) { const r = obj.gridPlaylistRenderer || obj.playlistListItemRenderer; const id = r.playlistId; const title = getTitleText(r.title) || id; if (id) { playlists.set(id, title); } } if (obj.lockupViewModel) { const r = obj.lockupViewModel; let id = r.contentId; if ( !id && r.rendererContext?.commandContext?.onTap?.innertubeCommand?.browseEndpoint?.browseId ) { id = r.rendererContext.commandContext.onTap.innertubeCommand.browseEndpoint.browseId; } if (id && id.startsWith('PL')) { let title = null; if (r.metadata?.lockupMetadataViewModel?.title) { title = getTitleText(r.metadata.lockupMetadataViewModel.title); } if ( !title && r.contentImage?.collectionThumbnailViewModel?.primaryThumbnail?.accessibility ?.accessibilityData?.label ) { const label = r.contentImage.collectionThumbnailViewModel.primaryThumbnail.accessibility .accessibilityData.label; title = label.replace(/^播放清單:/, '').replace(/^Playlist: /, ''); } playlists.set(id, title || id); } } for (const key in obj) { findPlaylists(obj[key]); } } findPlaylists(json); if (!playlists.has('WL')) { playlists.set('WL', '稍後觀看'); } if (!playlists.has('LL')) { playlists.set('LL', '喜歡的影片'); } return playlists; } // --- API 2: 分頁抓取 (Heartbeat Update) --- async function fetchFullPlaylistItems(playlistId, apiKey, context, authHeader, onProgress) { const browseId = playlistId.startsWith('VL') ? playlistId : 'VL' + playlistId; const allIds = new Set(); let continuation = null; let isFirst = true; let retryCount = 0; function findContinuationToken(obj) { if (!obj || typeof obj !== 'object') { return null; } if (obj.continuationCommand) { return obj.continuationCommand.token; } if (obj.nextContinuationData) { return obj.nextContinuationData.continuation; } for (const key in obj) { const found = findContinuationToken(obj[key]); if (found) { return found; } } return null; } function extractIds(obj) { if (!obj || typeof obj !== 'object') { return; } if (obj.playlistVideoRenderer && obj.playlistVideoRenderer.videoId) { allIds.add(obj.playlistVideoRenderer.videoId); } for (const key in obj) { extractIds(obj[key]); } } do { try { const endpoint = `https://www.youtube.com/youtubei/v1/browse?key=${apiKey}`; const payload = { context: context }; if (isFirst) { payload.browseId = browseId; } else { payload.continuation = continuation; } const response = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authHeader, 'X-Origin': window.location.origin, }, body: JSON.stringify(payload), }); if (!response.ok) { if (response.status === 429 && retryCount < 3) { retryCount++; await new Promise((r) => { return setTimeout(r, 2000); }); continue; } throw new Error(`Fetch error ${response.status}`); } const json = await response.json(); extractIds(json); continuation = findContinuationToken(json); isFirst = false; // 即時通知 if (onProgress) { onProgress(allIds.size); } // 強制讓出執行緒 0ms,讓瀏覽器有機會渲染畫面 await new Promise((r) => { return setTimeout(r, 0); }); } catch (e) { console.warn(`Error fetching playlist page: ${e.message}`); break; } } while (continuation); return allIds; } // --- API 3: 寫入 --- async function batchAddVideos(playlistId, videoIds, apiKey, context, authHeader) { const cleanPlaylistId = playlistId.replace(/^VL/, ''); const actions = videoIds.map((vid) => { return { action: 'ACTION_ADD_VIDEO', addedVideoId: vid }; }); const response = await fetch( `https://www.youtube.com/youtubei/v1/browse/edit_playlist?key=${apiKey}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: authHeader, 'X-Origin': window.location.origin, }, body: JSON.stringify({ context: context, playlistId: cleanPlaylistId, actions: actions }), }, ); if (!response.ok) { throw new Error('加入失敗'); } return await response.json(); } // --- UI 建構 --- function createUI() { // 模組 1: 初始化主控制面板 const initPanel = () => { // 1. 清理舊面板 const oldPanel = document.getElementById('yt-global-panel'); if (oldPanel) { oldPanel.remove(); } // 2. 建立新面板結構 const panel = document.createElement('div'); panel.id = 'yt-global-panel'; panel.innerHTML = `