node-apiless-youtube-upload-nc
Version:
Upload videos to Youtube in Node.js without any Youtube API dependency by using Selenium.
147 lines (129 loc) • 6.17 kB
text/typescript
import chromeLocation from 'chrome-location'
import * as Path from 'path'
import * as fs from 'fs/promises'
import * as os from 'os'
import {spawn} from 'child_process'
import {pid2title, URL, makeWebDriver, Cookies} from '../helpers'
import {until, WebDriver} from 'selenium-webdriver'
const delayAsync = (ms: number) => new Promise((res) => setTimeout(res, ms))
const isRunning = (pid: number): boolean => {
try {
// @see https://nodejs.org/api/process.html#process_process_kill_pid_signal
return process.kill(pid, 0)
} catch (error) {
return error.code === 'EPERM'
}
}
interface ITempDirectory {
path: string
remove: () => Promise<void>
}
const createTmpDir = async (prefix: string): Promise<ITempDirectory> => {
const path = await fs.mkdtemp(Path.join(os.tmpdir(), prefix))
const remove = () => fs.rm(path, {recursive: true, force: true, maxRetries: 3})
return {path, remove}
}
// "Chrome didn't shut down correctly" dialog is caused by their
// crash handler detecting the process was killed. This might be confusing
// to the end user, so we remove it by editing profile preferences
const chromePreventCrashDialog = async (profilePath: string): Promise<void> => {
const preferencesPath = Path.join(profilePath, 'Default', 'Preferences')
const jsonBuf = await fs.readFile(preferencesPath)
const json = JSON.parse(jsonBuf.toString('utf-8'))
json.profile.exit_type = 'Normal'
await fs.writeFile(preferencesPath, JSON.stringify(json))
}
const hasNotLoggedIn = async (pid: number): Promise<boolean> => {
// The final destination title after a successful login
// includes '- YouTube Studio' regardless of language
return pid2title(pid)
.then((title) => !title.includes('- YouTube Studio'))
.catch((e) => {
console.error(e)
return true
})
}
const runUncontrolledChrome = async (userDataDir: string): Promise<void> => {
// Spawn a chrome WITHOUT automation features like --remote-debugging-port or --enable automation because they disable google login in most cases
// This uncontrolled chrome instance saves cookies to the tempDir. Login status is tracked on windows by process title.
const chromeProcess = spawn(chromeLocation, [
'https://studio.youtube.com',
'--no-first-run',
'--no-default-browser-check',
'--disable-translate',
'--disable-default-apps',
'--disable-popup-blocking',
'--disable-zero-browsers-open-for-tests',
`--user-data-dir=${userDataDir}`,
// possibly confusing "save password" prompt is not possible to
// hide because it's only possible by "--enable-automation"
])
const pid = chromeProcess.pid
do {
await delayAsync(1000)
} while (isRunning(pid) && (await hasNotLoggedIn(pid)))
await delayAsync(1000*30);//should not been killed immediately
chromeProcess.kill()
await delayAsync(1000)
await chromePreventCrashDialog(userDataDir)
}
const makeLoggedInChromeProfile = async (): Promise<ITempDirectory> => {
const modulePrefix = 'node-apiless-youtube-upload-'
const tempDir = await createTmpDir(modulePrefix)
// Adding a removal exit hook for tempDir is a bad idea, because it cant be
// done synchronously for EBUSY reasons (and no async hooks I tried did not
// work). Therefore we do a cleanup of previous runs rather than trying to
// clean up the current one on exit
const {base: tmpBase, dir} = Path.parse(tempDir.path)
for (const file of await fs.readdir(dir)) {
if (file === tmpBase || !file.startsWith(modulePrefix)) continue
const prevProfilePath = Path.join(dir, file)
console.log('Removing temp profile from previous run', prevProfilePath)
await fs.rm(prevProfilePath, {recursive: true, force: true, maxRetries: 3})
}
return runUncontrolledChrome(tempDir.path)
.then(() => tempDir)
.catch((err) => tempDir.remove().then(() => Promise.reject(err)))
}
const fetchCookies = async (driver: WebDriver): Promise<Cookies> => {
// go to google.com to trigger the saved profile to load faster
await driver.get(URL.LOADER)
await driver.sleep(4000)
await driver.get(URL.GOOGLE)
// Open youtube studio to test if the login is valid
await driver.get(URL.YOUTUBE_STUDIO)
// If cookies are valid, user is now either in Youtube Studio at studio.youtube.com,
// or User select page at youtube.com
const currentUrl = await driver.getCurrentUrl()
const isLoggedIn = [URL.YOUTUBE, URL.YOUTUBE_STUDIO].some((link) => currentUrl.startsWith(link))
if (!isLoggedIn) {
throw new Error(
'The login session could not be loaded (either user never logged in, ' +
"random lag or google account doesn't have a youtube attached)",
)
}
// Select youtube account (one google account might have many brand accounts)
await driver.get(URL.SELECT_ACCOUNT_YOUTUBE)
// Wait until url matches exactly URL.YOUTUBE. Note that account selection url includes URL.YOUTUBE, which we don't want to match.
await driver.wait(until.urlIs(URL.YOUTUBE), 60 * 1000)
const webDriverCookies = await driver.manage().getCookies()
return new Cookies(webDriverCookies)
}
export default async (): Promise<Cookies> => {
return new Promise((resolve, reject) =>{
(async ()=>{
let profilePath: ITempDirectory
let webDriver: WebDriver
profilePath = await makeLoggedInChromeProfile()
webDriver = await makeWebDriver({automation: true, userDataDir: profilePath.path})
fetchCookies(webDriver).then(function(cookies){
resolve(cookies);
}).catch(function(e){
reject(e);
}).finally(async () => {
if (webDriver) await webDriver.quit()
if (profilePath) await profilePath.remove()
});
})()
});
}