UNPKG

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
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(/&nbsp/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(); } }