UNPKG

node-apiless-youtube-upload-nc

Version:

Upload videos to Youtube in Node.js without any Youtube API dependency by using Selenium.

393 lines (392 loc) 20.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const selenium_webdriver_1 = require("selenium-webdriver"); const fs_1 = __importDefault(require("fs")); const helpers_1 = require("../helpers"); const GOOGLE_URL = `https://google.com`; const YOUTUBE_STUDIO_URL = `https://studio.youtube.com`; const validateVideoObj = (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_1.default.existsSync(videoObj.videoPath)) throw new Error(`VideoObj: given videoPath doesn't exist on disk (${videoObj.videoPath})`); if (videoObj.thumbnailPath && !fs_1.default.existsSync(videoObj.thumbnailPath)) throw new Error(`VideoObj: given thumbnailPath doesn't exist on disk (${videoObj.thumbnailPath})`); }; exports.default = async (videoObj, cookies, headlessMode = true, onProgress = console.log) => { 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 (0, helpers_1.makeWebDriver)({ headless: headlessMode, fullsize: true }); const enterEmojiString = async (webElement, 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) => { var webEls = await driver.findElements(selenium_webdriver_1.By.css(cssSelector)); if (webEls[0]) await driver.executeScript('arguments[0].scrollIntoViewIfNeeded()', webEls[0]); return webEls; }; const findElement = async (cssSelector) => { 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) => { 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(selenium_webdriver_1.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(selenium_webdriver_1.until.elementsLocated(selenium_webdriver_1.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(selenium_webdriver_1.until.elementsLocated(selenium_webdriver_1.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(selenium_webdriver_1.until.elementsLocated(selenium_webdriver_1.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(); } };