node-apiless-youtube-upload-nc
Version:
Upload videos to Youtube in Node.js without any Youtube API dependency by using Selenium.
448 lines (378 loc) • 20.4 kB
text/typescript
import {until, By, WebElement} from 'selenium-webdriver'
import fs from 'fs'
import {Cookies, makeWebDriver} from '../helpers'
const GOOGLE_URL = `https://google.com`;
const YOUTUBE_STUDIO_URL = `https://studio.youtube.com`;
export interface VideoObj {
videoPath: string
title: string
thumbnailPath?: string
description?: string
tags?: string
category?: string
monetization?: boolean
visibility?: 'private' | 'unlisted' | 'public'
tc?: string
}
const validateVideoObj = (videoObj : VideoObj) => {
if (!videoObj.videoPath) throw new Error("VideoObj: missing required property videoPath")
if (!videoObj.title) throw new Error("VideoObj: missing required property title")
if (videoObj.title.length > 100) throw new Error("VideoObj: given title is longer than max 100 characters")
if (videoObj.description.length > 5000) throw new Error("VideoObj: given description is longer than max 5000 characters")
if (videoObj.tags.length > 500) throw new Error("VideoObj: given tags is longer than max 500 characters")
if (videoObj.visibility && !['private', 'unlisted', 'public'].includes(videoObj.visibility)) throw new Error(`VideoObj: given visibility value "${videoObj.visibility}" is not valid. Should be private, unlisted or public`)
if (!fs.existsSync(videoObj.videoPath)) throw new Error(`VideoObj: given videoPath doesn't exist on disk (${videoObj.videoPath})`)
if (videoObj.thumbnailPath && !fs.existsSync(videoObj.thumbnailPath)) throw new Error(`VideoObj: given thumbnailPath doesn't exist on disk (${videoObj.thumbnailPath})`)
}
export default async (videoObj : VideoObj, cookies : Cookies, headlessMode = true, onProgress = console.log):Promise<string> => {
if (!cookies || !cookies.length) throw new Error("Can't upload video: cookies not set.")
validateVideoObj(videoObj)
// Fill default values
videoObj = {
visibility: 'public',
monetization: false,
description: '',
tags:'',
category:'CREATOR_VIDEO_CATEGORY_PEOPLE',
tc:'',
...videoObj
}
const driver = await makeWebDriver({headless: headlessMode, fullsize: true})
const enterEmojiString = async (webElement : WebElement, string : string) => {
// sendKeys(string) doesn't support emojis (causes a crash)
// youtube custom input elements don't have "value" property (but webEl.clear() still works)
// clipboard hack works but not in headless mode
// also editing innerHTML causes racing conditions with the underlying javascript mechanism
// solution is to use an obsolete method document.execCommand('insreText')
await driver.sleep(500)
webElement.click()
await driver.sleep(250)
webElement.clear()
await driver.sleep(250)
await driver.executeScript(`
arguments[0].focus();
document.execCommand('insertText', false, arguments[1]);
`, webElement, string)
}
const ensureNoSecurityWarning = async () => {
await driver.executeScript("if (document.querySelector('ytcp-auth-confirmation-dialog')) document.querySelector('ytcp-auth-confirmation-dialog').remove()")
}
const findElements = async (cssSelector : string) => {
var webEls = await driver.findElements(By.css(cssSelector))
if (webEls[0]) await driver.executeScript('arguments[0].scrollIntoViewIfNeeded()', webEls[0])
return webEls
}
const findElement = async (cssSelector : string) => {
var els = await findElements(cssSelector)
if (els.length === 0) throw new Error(`Element was not found with selector '${cssSelector}'`)
return els[0]
}
const tryFindElement = async (cssSelector : string) => {
var els = await findElements(cssSelector)
if (els.length == 0) return false
return els[0]
}
const tryMonetization = async () => {
const monetizationTabButton = await tryFindElement('button[test-id=MONETIZATION]')
if (!monetizationTabButton) return onProgress('Monetization options are not available on this channel. Continuing..')
onProgress("Applying monetization settings..")
await monetizationTabButton.click()
await driver.sleep(500)
await (await findElement('ytcp-icon-button[class~=ytcp-video-monetization]')).click()
await driver.sleep(500)
var isAlreadyOff = (await (await findElement('paper-radio-button[id=radio-off][class~=ytcp-video-monetization-edit-dialog]')).getAttribute('aria-selected')) === "true" ? true : false
if (isAlreadyOff && videoObj.monetization) {
onProgress('Setting monetization on...');
await (await findElement('paper-radio-button[id=radio-on][class~=ytcp-video-monetization-edit-dialog]')).click();
await driver.sleep(500)
await (await findElement('ytcp-button[id=save-button][class~=ytcp-video-monetization-edit-dialog]')).click();
} else if (!isAlreadyOff && !videoObj.monetization) {
onProgress('Setting monetization off...');
await (await findElement('paper-radio-button[id=radio-off][class~=ytcp-video-monetization-edit-dialog]')).click();
await driver.sleep(500)
await (await findElement('ytcp-button[id=save-button][class~=ytcp-video-monetization-edit-dialog]')).click()
} else {
onProgress(`Monetization is already the desired value (${isAlreadyOff ? 'off' : 'on'})...`);
await (await findElement('iron-overlay-backdrop[opened]')).click()
}
await driver.sleep(500)
}
const setVisibility = async (visibility) => {
// Select proper visibility setting
var [hiddenButton, unlistedButton, publicButton] = await findElements("#privacy-radios > tp-yt-paper-radio-button")
switch (visibility) {
case 'private':
hiddenButton.click()
break;
case 'unlisted':
unlistedButton.click()
break;
case 'public':
publicButton.click()
break;
default:
throw new Error("Unrecognized visibility option")
}
}
const setTc = async (url, tc) => {
try{
onProgress('Opening ' + url);
await driver.get(url);
await driver.sleep(5*1000);
onProgress('add comment..');
if(url.indexOf('/shorts/')!=-1){
var commentBtns = await findElements('[id=comments-button] > ytd-button-renderer');
if(commentBtns.length){
onProgress('remark..');
await commentBtns[0].click();
await driver.sleep(1000);
await (await findElement('#simplebox-placeholder')).click();
await driver.sleep(1000);
var inputElem = await findElement('[id=contenteditable-root]');
await driver.executeScript('arguments[0].scrollIntoViewIfNeeded()', inputElem);
await inputElem.sendKeys(tc);
await driver.sleep(3*1000);
await (await findElement('#submit-button [id=button]')).click();
await driver.sleep(5*1000);
var commentEls = await findElements('ytd-comment-renderer[id=comment]');
if(commentEls.length){
var commentEl = commentEls[0];
await driver.executeScript(`var evObj = document.createEvent('MouseEvents');evObj.initEvent('mouseover', true, false);arguments[0].dispatchEvent(evObj)`, commentEl);
await driver.sleep(1000);
var actionBtns = await findElements('ytd-comment-renderer[id=comment] [id=action-menu] yt-icon-button[id=button]');
if(actionBtns.length){
await actionBtns[0].click();
await driver.sleep(1000);
var menuItems = await findElements('ytd-menu-navigation-item-renderer > a');
if(menuItems.length){
await menuItems[0].click();
await driver.sleep(1000);
var confirmBtns = await findElements('yt-confirm-dialog-renderer [id=main] [id=confirm-button]');
if(confirmBtns.length){
await confirmBtns[0].click();
await driver.sleep(5*1000);
onProgress('set tc success!');
}
else{
onProgress('no confirm buttons found!');
}
}
else{
onProgress('no menu items found!')
}
}
else{
onProgress('no action buttons found!');
}
}
else{
onProgress('no comment elements found!');
}
}
else{
onProgress('no comment buttons found!');
}
}
else{
var metaEl = await findElement('[id=primary-inner] [id=meta]');
var location = await metaEl.getRect();
await driver.executeScript('window.scrollTo(0, arguments[0])', location.y+location.height);
await driver.sleep(5 * 1000);
onProgress('remark..');
await (await findElement('#simplebox-placeholder')).click();
await driver.sleep(1000);
var inputElem = await findElement('[id=contenteditable-root]');
await driver.executeScript('arguments[0].scrollIntoViewIfNeeded()', inputElem);
await inputElem.sendKeys(tc);
await driver.sleep(3*1000);
await (await findElement('#submit-button [id=button]')).click();
await driver.sleep(5*1000);
var commentEls = await findElements('ytd-comment-renderer[id=comment]');
if(commentEls.length){
var commentEl = commentEls[0];
await driver.executeScript(`var evObj = document.createEvent('MouseEvents');evObj.initEvent('mouseover', true, false);arguments[0].dispatchEvent(evObj)`, commentEl);
await driver.sleep(1000);
var actionBtns = await findElements('ytd-comment-renderer[id=comment] [id=action-menu] yt-icon-button[id=button]');
if(actionBtns.length){
await actionBtns[0].click();
await driver.sleep(1000);
var menuItems = await findElements('ytd-menu-navigation-item-renderer > a');
if(menuItems.length){
await menuItems[0].click();
await driver.sleep(1000);
var confirmBtns = await findElements('yt-confirm-dialog-renderer [id=main] [id=confirm-button]');
if(confirmBtns.length){
await confirmBtns[0].click();
await driver.sleep(5*1000);
onProgress('set tc success!');
}
else{
onProgress('no confirm buttons found!');
}
}
else{
onProgress('no menu items found!')
}
}
else{
onProgress('no action buttons found!');
}
}
else{
onProgress('no comment elements found!');
}
}
}
catch(e){
onProgress(e);
}
}
var securityIgnoreInterval = null
var successInterval = null
try {
onProgress('Settings cookies..')
// Load google page to set up cookies
await driver.get(GOOGLE_URL)
// Add cookies
for (let cookie of cookies) await driver.manage().addCookie(cookie)
onProgress('Opening Youtube Studio..')
// Open Youtube Studio page
await driver.get(YOUTUBE_STUDIO_URL);
// Wait for stuff to fully load
await driver.sleep(1000)
securityIgnoreInterval = setInterval(() => {
ensureNoSecurityWarning()
}, 500)
// Check if url is still studio.youtube.com and not accounts.google.com (which is the case if cookies are not valid / are expired)
var url = (await driver.getCurrentUrl())
if (!url.includes('studio.youtube.com/')) {
throw new Error(`Cookies are expired or not valid. (tried to upload, was redirected to ${url}`)
}
//hack:hide tp-yt-iron-overlay-backdrop
var backdrops = await driver.findElements(By.css("tp-yt-iron-overlay-backdrop"));
if(backdrops){
for(var i = 0; i < backdrops.length; i++){
await driver.executeScript('arguments[0].style.height="0px";', backdrops[i]);
}
}
// Click upload
await (await findElement("#upload-icon > .remove-defaults")).click()
// Wait for file input to appear
await driver.wait(until.elementsLocated(By.css("input[type=file]")), 10000)
onProgress('Initializing video..');
// Enter file path
await (await findElement("input[type=file]")).sendKeys(videoObj.videoPath)
// Wait for file metadata to upload
await driver.wait(until.elementsLocated(By.css("#textbox")), 10000)
// Wait for random javascript garbage to load
await driver.sleep(10000)
var editBoxes = await findElements("#textbox")
onProgress('Initializing title and description..')
// Enter title and description
var titleBox = editBoxes[0]
var descriptionBox = editBoxes[1]
await enterEmojiString(titleBox, videoObj.title)
await enterEmojiString(descriptionBox, videoObj.description)
await driver.sleep(10*1000)
// Youtube has some weird draft mechanism that auto fills the title and description.
// There is already 10s sleep before this, but it seems like sometimes the draft mechanism only triggers
// after text is entered into the field. That's why we enter title and description twice.
onProgress('Confirming title and description..');
if((await titleBox.getText()).trim()!=videoObj.title.trim()){
await enterEmojiString(titleBox, videoObj.title);
}
if((await descriptionBox.getText()).trim()!=videoObj.description.trim()){
await enterEmojiString(descriptionBox, videoObj.description);
}
onProgress('Entering custom thumbnail..')
// Enter custom thumbnail
if (videoObj.thumbnailPath) {
var thumbnailBox = await findElement("#file-loader")
await thumbnailBox.sendKeys(videoObj.thumbnailPath)
}
// Wait for thumbnail to load
await driver.sleep(5000)
onProgress('Setting "not made for kids" (the only supported options right now)..')
await (await findElement('[name=VIDEO_MADE_FOR_KIDS_NOT_MFK]')).click()
await driver.sleep(1000)
//expand more
await (await findElement('[id=toggle-button]')).click()
await driver.sleep(1000);
//set tags
onProgress('Setting tags..');
await enterEmojiString(await findElement("#text-input"), videoObj.tags);
await driver.sleep(1000);
//set license (only support creative commons)
onProgress('Setting license (the only support creative commons)..');
await (await findElement("#license ytcp-text-dropdown-trigger")).click();
await driver.sleep(1000);
if((await findElements("[test-id=VIDEO_LICENSE_CREATIVE_COMMONS]")).length){
(await findElement("[test-id=VIDEO_LICENSE_CREATIVE_COMMONS]")).click();
await driver.sleep(1000);
}
//set category
onProgress('Setting category..')
await (await findElement("#category ytcp-text-dropdown-trigger")).click();
await driver.sleep(1000);
(await findElement("[test-id="+videoObj.category+"]")).click();
await driver.sleep(1000);
await tryMonetization()
onProgress(`Setting visibility option to ${videoObj.visibility}..`)
// Go to visibility tab
await (await findElement('button[test-id=REVIEW]')).click()
// Wait for it to load
await driver.wait(until.elementsLocated(By.css("#privacy-radios")), 10000)
await setVisibility(videoObj.visibility);
onProgress('Uploading..')
// Wait for uploading to finish
return await new Promise((resolve, reject) => {
// Poll progress updates
var ustime = Date.now();
successInterval = setInterval(async () => {
try {
var progressEl = await findElement("ytcp-video-upload-progress > .progress-label")
var innerHTML = (await progressEl.getText()).replace(/ /g, ' ')
onProgress(innerHTML)
var stateText = (await (await findElement("#step-badge-2")).getAttribute("state"));
if (stateText!="inactive"||(Date.now()-ustime>10*60*1000)) {
clearInterval(successInterval)
if(stateText!="inactive"){
var ret = {status:0, videoUrl:await (await findElement(".video-url-fadeable a")).getAttribute('href')};
if(stateText=="completed"){
onProgress("Publishing..");
await setVisibility("public");
ret.status = 1;
}
// Click Publish on the video
await (await findElement("#done-button")).click()
await driver.sleep(2000)
// There is an additional confirmation, if the video is set public and monetization is enabled
var confirmationMaybe = await tryFindElement('ytcp-button[id=publish-button][class~=ytcp-prechecks-warning-dialog]')
if (confirmationMaybe) confirmationMaybe.click()
await driver.sleep(1000)
onProgress('Done! (video may still be processing, but it is uploaded)')
if(ret.status==1&&videoObj.tc){
await driver.sleep(5*1000);
await setTc(ret.videoUrl, videoObj.tc);
}
resolve(JSON.stringify(ret))
}
else{
resolve('上传超时!');
}
}
} catch(e) {
resolve(e)
}
}, 4000)
});
}
catch(e){
return Promise.resolve(e);
}
finally {
if (securityIgnoreInterval) clearInterval(securityIgnoreInterval)
if (successInterval) clearInterval(successInterval)
await driver.quit();
}
}