// ==UserScript== // @name YouTube auto draft2publish // @namespace http://tampermonkey.net/ // @version 0.2 // @description There are many videos in your YouTube studio in draft status.If you want to publish them in batches with a few clicks, then you should try this tool. Of course, you can also customize the number of videos to be published each time.批量发布YouTube草稿视频,支持选择发布数量、状态和多语言切换 // @author Dex Zhou // @match https://studio.youtube.com/* // @grant none // @license AGPL-3.0-or-later // @downloadURL none // ==/UserScript== (() => { // 多语言支持 - 语言包定义 const translations = { 'English': { title: 'YouTube auto draft2publish', publishCount: 'Publish count:', allDrafts: 'All drafts', customNumber: 'Custom number:', publishStatus: 'Publish status:', public: 'Public', unlisted: 'Unlisted', private: 'Private', startPublishing: 'Start Publishing', language: 'Language:', publishing: 'Publishing...', publishedSuccess: 'Successfully published', videos: 'videos', errorOccurred: 'An error occurred', pleaseWait: 'Please wait...' }, '简体中文': { title: 'YouTube 草稿批量发布工具', publishCount: '发布数量:', allDrafts: '所有草稿', customNumber: '指定数量:', publishStatus: '发布状态:', public: '公开', unlisted: '不公开', private: '私有', startPublishing: '开始发布', language: '语言:', publishing: '发布中...', publishedSuccess: '已成功发布', videos: '个视频', errorOccurred: '发生错误', pleaseWait: '请稍候...' }, 'Español': { title: 'Herramienta de publicación de borradores de YouTube', publishCount: 'Recuento de publicación:', allDrafts: 'Todos los borradores', customNumber: 'Número personalizado:', publishStatus: 'Estado de publicación:', public: 'Público', unlisted: 'No listado', private: 'Privado', startPublishing: 'Iniciar publicación', language: 'Idioma:', publishing: 'Publicando...', publishedSuccess: 'Publicado con éxito', videos: 'vídeos', errorOccurred: 'Ocurrió un error', pleaseWait: 'Por favor, espere...' }, 'Français': { title: 'Outil de publication de brouillons YouTube', publishCount: 'Nombre de publications:', allDrafts: 'Tous les brouillons', customNumber: 'Nombre personnalisé:', publishStatus: 'Statut de publication:', public: 'Public', unlisted: 'Non listé', private: 'Privé', startPublishing: 'Démarrer la publication', language: 'Langue:', publishing: 'Publication en cours...', publishedSuccess: 'Publié avec succès', videos: 'vidéos', errorOccurred: 'Une erreur est survenue', pleaseWait: 'Veuillez patienter...' }, 'Русский': { title: 'Инструмент для публикации черновиков YouTube', publishCount: 'Количество публикаций:', allDrafts: 'Все черновики', customNumber: 'Пользовательское количество:', publishStatus: 'Статус публикации:', public: 'Публичный', unlisted: 'Не перечисленный', private: 'Частный', startPublishing: 'Начать публикацию', language: 'Язык:', publishing: 'Публикация...', publishedSuccess: 'Успешно опубликовано', videos: 'видео', errorOccurred: 'Произошла ошибка', pleaseWait: 'Пожалуйста, подождите...' } }; // 当前选中的语言 let currentLanguage = '简体中文'; // 获取翻译文本的函数 function t(key) { return translations[currentLanguage][key] || translations['English'][key] || key; } // ----------------------------------------------------------------- // CONFIG (you're safe to edit this) // ----------------------------------------------------------------- // ~ GLOBAL CONFIG // ----------------------------------------------------------------- const MODE = 'publish_drafts'; // 'publish_drafts' / 'sort_playlist'; const DEBUG_MODE = true; // true / false, enable for more context // ----------------------------------------------------------------- // ~ PUBLISH CONFIG // ----------------------------------------------------------------- const MADE_FOR_KIDS = false; // true / false; // ----------------------------------------------------------------- // ~ SORT PLAYLIST CONFIG // ----------------------------------------------------------------- const SORTING_KEY = (one, other) => { const numberRegex = /\d+/; const number = (name) => name.match(numberRegex)[0]; if (number(one.name) === undefined || number(other.name) === undefined) { return one.name.localeCompare(other.name); } return number(one.name) - number(other.name); }; // END OF CONFIG (not safe to edit stuff below) // ---------------------------------- // COMMON STUFF // --------------------------------- const TIMEOUT_STEP_MS = 20; const DEFAULT_ELEMENT_TIMEOUT_MS = 10000; function debugLog(...args) { if (!DEBUG_MODE) { return; } console.debug(...args); } const sleep = (ms) => new Promise((resolve, _) => setTimeout(resolve, ms)); async function waitForElement(selector, baseEl, timeoutMs) { if (timeoutMs === undefined) { timeoutMs = DEFAULT_ELEMENT_TIMEOUT_MS; } if (baseEl === undefined) { baseEl = document; } let timeout = timeoutMs; while (timeout > 0) { let element = baseEl.querySelector(selector); if (element !== null) { return element; } await sleep(TIMEOUT_STEP_MS); timeout -= TIMEOUT_STEP_MS; } debugLog(`could not find ${selector} inside`, baseEl); return null; } function click(element) { const event = document.createEvent('MouseEvents'); event.initMouseEvent('mousedown', true, false, window, 0, 0, 0, 0, 0, false, false, false, false, 0, null); element.dispatchEvent(event); element.click(); debugLog(element, 'clicked'); } // 添加用户界面元素 function createUserInterface() { // 检查是否已有界面,防止重复创建 const existingUI = document.getElementById('youtube-publish-tool'); if (existingUI) { return; } // 创建容器 const container = document.createElement('div'); container.id = 'youtube-publish-tool'; container.style.position = 'fixed'; container.style.top = '20px'; container.style.right = '20px'; container.style.zIndex = '9999'; container.style.backgroundColor = 'white'; container.style.padding = '15px'; container.style.borderRadius = '8px'; container.style.boxShadow = '0 2px 10px rgba(0,0,0,0.2)'; container.style.fontFamily = 'Arial, sans-serif'; container.style.minWidth = '300px'; // 状态指示器 const statusIndicator = document.createElement('div'); statusIndicator.id = 'publish-status-indicator'; statusIndicator.style.color = '#666'; statusIndicator.style.fontSize = '12px'; statusIndicator.style.marginBottom = '10px'; statusIndicator.style.minHeight = '16px'; container.appendChild(statusIndicator); // 语言选择 const languageContainer = document.createElement('div'); languageContainer.style.marginBottom = '15px'; const languageLabel = document.createElement('label'); languageLabel.textContent = t('language'); languageLabel.style.marginRight = '8px'; languageContainer.appendChild(languageLabel); const languageSelect = document.createElement('select'); languageSelect.id = 'language-select'; languageSelect.style.padding = '4px'; languageSelect.style.borderRadius = '4px'; // 添加语言选项 const languages = [ {code: 'English', name: 'English'}, {code: '简体中文', name: '简体中文'}, {code: 'Español', name: 'Español'}, {code: 'Français', name: 'Français'}, {code: 'Русский', name: 'Русский'} ]; languages.forEach(lang => { const option = document.createElement('option'); option.value = lang.code; option.textContent = lang.name; if (lang.code === currentLanguage) { option.selected = true; } languageSelect.appendChild(option); }); languageContainer.appendChild(languageSelect); container.appendChild(languageContainer); // 添加标题 const title = document.createElement('h3'); title.id = 'tool-title'; title.textContent = t('title'); title.style.marginTop = '0'; title.style.color = '#333'; container.appendChild(title); // 发布数量选择 const countContainer = document.createElement('div'); countContainer.style.marginBottom = '10px'; const countLabel = document.createElement('label'); countLabel.id = 'count-label'; countLabel.textContent = t('publishCount'); countLabel.style.display = 'block'; countLabel.style.marginBottom = '5px'; countContainer.appendChild(countLabel); const allRadio = document.createElement('input'); allRadio.type = 'radio'; allRadio.name = 'publishCount'; allRadio.id = 'publishAll'; allRadio.value = 'all'; allRadio.checked = true; countContainer.appendChild(allRadio); const allLabel = document.createElement('label'); allLabel.id = 'all-label'; allLabel.htmlFor = 'publishAll'; allLabel.textContent = t('allDrafts'); allLabel.style.marginRight = '15px'; countContainer.appendChild(allLabel); const customCountContainer = document.createElement('span'); const customRadio = document.createElement('input'); customRadio.type = 'radio'; customRadio.name = 'publishCount'; customRadio.id = 'publishCustom'; customRadio.value = 'custom'; customCountContainer.appendChild(customRadio); const customLabel = document.createElement('label'); customLabel.id = 'custom-label'; customLabel.htmlFor = 'publishCustom'; customLabel.textContent = t('customNumber'); customCountContainer.appendChild(customLabel); const countInput = document.createElement('input'); countInput.type = 'number'; countInput.id = 'publishNumber'; countInput.min = '1'; countInput.value = '1'; countInput.disabled = true; countInput.style.width = '50px'; customCountContainer.appendChild(countInput); countContainer.appendChild(customCountContainer); container.appendChild(countContainer); // 可见性选择 const visibilityContainer = document.createElement('div'); visibilityContainer.style.marginBottom = '15px'; const visibilityLabel = document.createElement('label'); visibilityLabel.id = 'visibility-label'; visibilityLabel.textContent = t('publishStatus'); visibilityLabel.style.display = 'block'; visibilityLabel.style.marginBottom = '5px'; visibilityContainer.appendChild(visibilityLabel); const visibilityOptions = [ { id: 'public', label: t('public'), value: 'Public' }, { id: 'unlisted', label: t('unlisted'), value: 'Unlisted' }, { id: 'private', label: t('private'), value: 'Private' } ]; visibilityOptions.forEach(option => { const radio = document.createElement('input'); radio.type = 'radio'; radio.name = 'visibility'; radio.id = option.id; radio.value = option.value; radio.checked = option.value === 'Public'; // 默认公开 visibilityContainer.appendChild(radio); const label = document.createElement('label'); label.id = `${option.id}-label`; label.htmlFor = option.id; label.textContent = option.label; label.style.marginRight = '15px'; visibilityContainer.appendChild(label); }); container.appendChild(visibilityContainer); // 开始按钮 const startButton = document.createElement('button'); startButton.id = 'start-button'; startButton.textContent = t('startPublishing'); startButton.style.backgroundColor = '#4CAF50'; startButton.style.color = 'white'; startButton.style.border = 'none'; startButton.style.padding = '8px 16px'; startButton.style.borderRadius = '4px'; startButton.style.cursor = 'pointer'; startButton.style.fontSize = '14px'; startButton.style.width = '100%'; container.appendChild(startButton); // 添加到页面 document.body.appendChild(container); // 事件监听 - 启用/禁用数量输入 customRadio.addEventListener('change', () => { countInput.disabled = !customRadio.checked; }); allRadio.addEventListener('change', () => { countInput.disabled = !customRadio.checked; }); // 语言选择事件 languageSelect.addEventListener('change', (e) => { currentLanguage = e.target.value; updateInterfaceLanguage(); }); // 返回配置值的函数 return { getPublishCount: () => { if (allRadio.checked) { return 'all'; } return parseInt(countInput.value, 10) || 1; }, getVisibility: () => { const selected = document.querySelector('input[name="visibility"]:checked'); return selected ? selected.value : 'Public'; }, startButton, setStatus: (text) => { statusIndicator.textContent = text; }, disableControls: () => { startButton.disabled = true; startButton.style.opacity = '0.7'; allRadio.disabled = true; customRadio.disabled = true; countInput.disabled = true; document.querySelectorAll('input[name="visibility"]').forEach(radio => { radio.disabled = true; }); languageSelect.disabled = true; }, enableControls: () => { startButton.disabled = false; startButton.style.opacity = '1'; allRadio.disabled = false; customRadio.disabled = false; countInput.disabled = !customRadio.checked; document.querySelectorAll('input[name="visibility"]').forEach(radio => { radio.disabled = false; }); languageSelect.disabled = false; } }; } // 更新界面语言 function updateInterfaceLanguage() { document.getElementById('tool-title').textContent = t('title'); document.getElementById('count-label').textContent = t('publishCount'); document.getElementById('all-label').textContent = t('allDrafts'); document.getElementById('custom-label').textContent = t('customNumber'); document.getElementById('visibility-label').textContent = t('publishStatus'); document.getElementById('public-label').textContent = t('public'); document.getElementById('unlisted-label').textContent = t('unlisted'); document.getElementById('private-label').textContent = t('private'); document.getElementById('start-button').textContent = t('startPublishing'); } // ---------------------------------- // PUBLISH STUFF // ---------------------------------- const VISIBILITY_PUBLISH_ORDER = { 'Private': 0, 'Unlisted': 1, 'Public': 2, }; // SELECTORS // --------- const VIDEO_ROW_SELECTOR = 'ytcp-video-row'; const DRAFT_MODAL_SELECTOR = '.style-scope.ytcp-uploads-dialog'; const DRAFT_BUTTON_SELECTOR = '.edit-draft-button'; const MADE_FOR_KIDS_SELECTOR = '#made-for-kids-group'; const RADIO_BUTTON_SELECTOR = 'tp-yt-paper-radio-button'; const VISIBILITY_STEPPER_SELECTOR = '#step-badge-3'; const VISIBILITY_PAPER_BUTTONS_SELECTOR = 'tp-yt-paper-radio-group'; const SAVE_BUTTON_SELECTOR = '#done-button'; const SUCCESS_ELEMENT_SELECTOR = 'ytcp-video-thumbnail-with-info'; const DIALOG_SELECTOR = 'ytcp-dialog.ytcp-video-share-dialog > tp-yt-paper-dialog:nth-child(1)'; const DIALOG_CLOSE_BUTTON_SELECTOR = 'tp-yt-iron-icon'; class SuccessDialog { constructor(raw) { this.raw = raw; } async closeDialogButton() { return await waitForElement(DIALOG_CLOSE_BUTTON_SELECTOR, this.raw); } async close() { click(await this.closeDialogButton()); await sleep(50); debugLog('closed'); } } class VisibilityModal { constructor(raw, visibility) { this.raw = raw; this.visibility = visibility; // 从用户输入获取 } async radioButtonGroup() { return await waitForElement(VISIBILITY_PAPER_BUTTONS_SELECTOR, this.raw); } async visibilityRadioButton() { const group = await this.radioButtonGroup(); const value = VISIBILITY_PUBLISH_ORDER[this.visibility]; return [...group.querySelectorAll(RADIO_BUTTON_SELECTOR)][value]; } async setVisibility() { click(await this.visibilityRadioButton()); debugLog(`visibility set to ${this.visibility}`); await sleep(50); } async saveButton() { return await waitForElement(SAVE_BUTTON_SELECTOR, this.raw); } async isSaved() { await waitForElement(SUCCESS_ELEMENT_SELECTOR, document); } async dialog() { return await waitForElement(DIALOG_SELECTOR); } async save() { click(await this.saveButton()); await this.isSaved(); debugLog('saved'); const dialogElement = await this.dialog(); const success = new SuccessDialog(dialogElement); return success; } } class DraftModal { constructor(raw, visibility) { this.raw = raw; this.visibility = visibility; } async madeForKidsToggle() { return await waitForElement(MADE_FOR_KIDS_SELECTOR, this.raw); } async madeForKidsPaperButton() { const nthChild = MADE_FOR_KIDS ? 1 : 2; return await waitForElement(`${RADIO_BUTTON_SELECTOR}:nth-child(${nthChild})`, this.raw); } async selectMadeForKids() { click(await this.madeForKidsPaperButton()); await sleep(50); debugLog(`"Made for kids" set as ${MADE_FOR_KIDS}`); } async visibilityStepper() { return await waitForElement(VISIBILITY_STEPPER_SELECTOR, this.raw); } async goToVisibility() { debugLog('going to Visibility'); await sleep(50); click(await this.visibilityStepper()); const visibility = new VisibilityModal(this.raw, this.visibility); await sleep(50); await waitForElement(VISIBILITY_PAPER_BUTTONS_SELECTOR, visibility.raw); return visibility; } } class VideoRow { constructor(raw) { this.raw = raw; } get editDraftButton() { return waitForElement(DRAFT_BUTTON_SELECTOR, this.raw, 20); } async openDraft(visibility) { debugLog('focusing draft button'); click(await this.editDraftButton); return new DraftModal(await waitForElement(DRAFT_MODAL_SELECTOR), visibility); } } function allVideos() { return [...document.querySelectorAll(VIDEO_ROW_SELECTOR)].map((el) => new VideoRow(el)); } async function editableVideos() { let editable = []; for (let video of allVideos()) { if ((await video.editDraftButton) !== null) { editable = [...editable, video]; } } return editable; } async function publishDrafts(config) { try { config.disableControls(); config.setStatus(t('pleaseWait')); const videos = await editableVideos(); const publishCount = config.getPublishCount(); const visibility = config.getVisibility(); // 根据用户选择确定要发布的视频数量 const videosToPublish = publishCount === 'all' ? videos : videos.slice(0, Math.min(publishCount, videos.length)); debugLog(`found ${videos.length} videos, will publish ${videosToPublish.length}`); if (videosToPublish.length === 0) { config.setStatus(t('errorOccurred') + ': ' + 'No videos to publish'); config.enableControls(); return; } // 逐个发布视频 for (let i = 0; i < videosToPublish.length; i++) { config.setStatus(`${t('publishing')} (${i+1}/${videosToPublish.length})`); const video = videosToPublish[i]; try { const draft = await video.openDraft(visibility); await draft.selectMadeForKids(); const visibilityModal = await draft.goToVisibility(); await visibilityModal.setVisibility(); const dialog = await visibilityModal.save(); await dialog.close(); await sleep(100); } catch (error) { debugLog(`Error publishing video ${i+1}:`, error); config.setStatus(`${t('errorOccurred')} (${i+1}/${videosToPublish.length})`); await sleep(1000); // 出错时等待更长时间 } } config.setStatus(`${t('publishedSuccess')}: ${videosToPublish.length} ${t('videos')}`); } catch (error) { debugLog('General error:', error); config.setStatus(t('errorOccurred')); } finally { config.enableControls(); } } // ---------------------------------- // SORTING STUFF (保持不变) // ---------------------------------- const SORTING_MENU_BUTTON_SELECTOR = 'button'; const SORTING_ITEM_MENU_SELECTOR = 'paper-listbox#items'; const SORTING_ITEM_MENU_ITEM_SELECTOR = 'ytd-menu-service-item-renderer'; const MOVE_TO_TOP_INDEX = 4; const MOVE_TO_BOTTOM_INDEX = 5; class SortingDialog { constructor(raw) { this.raw = raw; } async anyMenuItem() { const item = await waitForElement(SORTING_ITEM_MENU_ITEM_SELECTOR, this.raw); if (item === null) { throw new Error("could not locate any menu item"); } return item; } menuItems() { return [...this.raw.querySelectorAll(SORTING_ITEM_MENU_ITEM_SELECTOR)]; } async moveToTop() { click(this.menuItems()[MOVE_TO_TOP_INDEX]); } async moveToBottom() { click(this.menuItems()[MOVE_TO_BOTTOM_INDEX]); } } class PlaylistVideo { constructor(raw) { this.raw = raw; } get name() { return this.raw.querySelector('#video-title').textContent; } async dialog() { return this.raw.querySelector(SORTING_MENU_BUTTON_SELECTOR); } async openDialog() { click(await this.dialog()); const dialog = new SortingDialog(await waitForElement(SORTING_ITEM_MENU_SELECTOR)); await dialog.anyMenuItem(); return dialog; } } async function playlistVideos() { return [...document.querySelectorAll('ytd-playlist-video-renderer')] .map((el) => new PlaylistVideo(el)); } async function sortPlaylist() { debugLog('sorting playlist'); const videos = await playlistVideos(); debugLog(`found ${videos.length} videos`); videos.sort(SORTING_KEY); const videoNames = videos.map((v) => v.name); let index = 1; for (let name of videoNames) { debugLog({index, name}); const video = videos.find((v) => v.name === name); const dialog = await video.openDialog(); await dialog.moveToBottom(); await sleep(1000); index += 1; } } // ---------------------------------- // ENTRY POINT // ---------------------------------- if (MODE === 'publish_drafts') { // 创建用户界面 const ui = createUserInterface(); if (ui) { // 绑定开始按钮事件 ui.startButton.addEventListener('click', () => { publishDrafts(ui); }); } } else if (MODE === 'sort_playlist') { sortPlaylist(); } })();