NOTICE: By continued use of this site you understand and agree to the binding Terms of Service and Privacy Policy.
// ==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
// ==/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();
}
})();