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