diff --git a/src/anchorfm-pupeteer/index.js b/src/anchorfm-pupeteer/index.js index 794e3fcf..e629bb19 100644 --- a/src/anchorfm-pupeteer/index.js +++ b/src/anchorfm-pupeteer/index.js @@ -1,5 +1,7 @@ const puppeteer = require('puppeteer'); const env = require('../environment-variables'); +const { compareDates } = require('../dateutils'); +const { isEmpty } = require('../stringutils'); function addUrlToDescription(youtubeVideoInfo) { return env.URL_IN_DESCRIPTION @@ -7,146 +9,178 @@ function addUrlToDescription(youtubeVideoInfo) { : youtubeVideoInfo.description; } -async function setPublishDate(page, navigationPromise, date) { +async function setPublishDate(page, date) { console.log('-- Setting publish date'); - await clickXpath(page, '//span[contains(text(),"Publish date:")]/following-sibling::button'); - await navigationPromise; - - await resetDatePickerToSelectYears(page, navigationPromise); - await selectYearInDatePicker(page, navigationPromise, date.year); - await selectMonthInDatePicker(page, navigationPromise, date.month); - await selectDayInDatePicker(page, navigationPromise, date.day); - - await clickXpath(page, '//span[contains(text(),"Confirm")]/parent::button'); - await navigationPromise; -} + await clickSelector(page, 'input[type="radio"][id="publish-date-schedule"]'); + await page.waitForSelector('#date-input', { visible: true }); + await clickSelector(page, '#date-input'); + + await selectCorrectYearAndMonthInDatePicker(); + await selectCorrectDayInDatePicker(); + + async function selectCorrectYearAndMonthInDatePicker() { + const dateForComparison = `${date.monthAsFullWord} ${date.year}`; + const currentDateCaptionElementSelector = + 'div[class*="CalendarMonth"][data-visible="true"] div[class*="CalendarMonth_caption"] > strong'; + let currentDate = await getTextContentFromSelector(page, currentDateCaptionElementSelector); + const navigationButtonSelector = + compareDates(dateForComparison, currentDate) === -1 + ? 'div[class*="DayPickerNavigation_leftButton__horizontalDefault"]' + : 'div[class*="DayPickerNavigation_rightButton__horizontalDefault"]'; + + while (currentDate !== dateForComparison) { + await clickSelector(page, navigationButtonSelector); + currentDate = await getTextContentFromSelector(page, currentDateCaptionElementSelector); + } + } -async function resetDatePickerToSelectYears(page, navigationPromise) { - for (let i = 0; i < 2; i += 1) { - await clickSelector(page, 'th[class="rdtSwitch"]'); - await navigationPromise; + async function selectCorrectDayInDatePicker() { + const dayWithoutLeadingZero = parseInt(date.day, 10); + const dayXpath = `//div[contains(@class, "CalendarMonth") and @data-visible="true"]//td[contains(text(), "${dayWithoutLeadingZero}")]`; + await clickXpath(page, dayXpath); } } -async function selectYearInDatePicker(page, navigationPromise, year) { - const rdtPrev = await page.$('th[class="rdtPrev"]'); - let currentLowestYear = await page.$eval('tbody > tr:first-child > td:first-child', (e) => - e.getAttribute('data-value') - ); - while (parseInt(currentLowestYear, 10) > parseInt(year, 10)) { - await rdtPrev.click(); - await navigationPromise; - - currentLowestYear = await page.$eval('tbody > tr:first-child > td:first-child', (e) => - e.getAttribute('data-value') - ); - } +async function postEpisode(youtubeVideoInfo) { + let browser; + let page; - const rdtNext = await page.$('th[class="rdtNext"]'); - let currentHighestYear = await page.$eval('tbody > tr:last-child > td:last-child', (e) => - e.getAttribute('data-value') - ); - while (parseInt(currentHighestYear, 10) < parseInt(year, 10)) { - await rdtNext.click(); - await navigationPromise; + try { + console.log('Launching puppeteer'); + browser = await puppeteer.launch({ args: ['--no-sandbox'], headless: env.PUPETEER_HEADLESS }); - currentHighestYear = await page.$eval('tbody > tr:last-child > td:last-child', (e) => e.getAttribute('data-value')); - } + page = await openNewPage('https://podcasters.spotify.com/pod/dashboard/episode/wizard'); - await clickSelector(page, `tbody > tr > td[data-value="${year}"]`); - await navigationPromise; -} + console.log('Setting language to English'); + await setLanguageToEnglish(); -async function selectMonthInDatePicker(page, navigationPromise, month) { - await clickXpath(page, `//tbody/tr/td[contains(text(),"${month}")]`); - await navigationPromise; -} + console.log('Trying to log in'); + await login(); -async function selectDayInDatePicker(page, navigationPromise, day) { - const dayWithRemovedZeroPad = parseInt(day, 10); - await clickSelector( - page, - `tbody > tr > td[data-value="${dayWithRemovedZeroPad}"][class*="rdtDay"]:not([class*="rdtOld"]):not([class*="rtdNew"])` - ); - await navigationPromise; -} + console.log('Opening new episode wizard'); + await waitForNewEpisodeWizard(); -async function setLanguageToEnglish(page) { - await clickSelector(page, 'button[aria-label="Change language"]'); - await clickSelector(page, 'div[aria-label="Language selection modal"] a[data-testid="language-option-en"]'); -} + console.log('Uploading audio file'); + await uploadEpisode(); -async function postEpisode(youtubeVideoInfo) { - let browser; - try { - console.log('Launching puppeteer'); - browser = await puppeteer.launch({ args: ['--no-sandbox'], headless: env.PUPETEER_HEADLESS }); - const page = await browser.newPage(); + console.log('Filling required podcast details'); + await fillRequiredDetails(); - const navigationPromise = page.waitForNavigation(); + console.log('Filling optional podcast details'); + await fillOptionalDetails(); - await page.goto('https://podcasters.spotify.com/pod/dashboard/episode/new'); + console.log('Skipping Interact step'); + await skipInteractStep(); - await page.setViewport({ width: 1600, height: 789 }); + console.log('Save draft or publish'); + await saveDraftOrScheduleOrPublish(); - await navigationPromise; + /* + This is a workaround solution of the problem where the podcast + is sometimes saved as draft with title "Untitled" and no other metadata. + We navigate to the spotify/anchorfm dashboard immediately after podcast is + published/scheduled. + */ + await goToDashboard(); - console.log('Setting language to English'); - await setLanguageToEnglish(page); + console.log('Yay'); + } catch (err) { + if (page !== undefined) { + console.log('Screenshot base64:'); + const screenshotBase64 = await page.screenshot({ encoding: 'base64' }); + console.log(`data:image/png;base64,${screenshotBase64}`); + } + throw new Error(`Unable to post episode to anchorfm: ${err}`); + } finally { + if (browser !== undefined) { + await browser.close(); + } + } - console.log('Accessing Log in with email'); + async function openNewPage(url) { + const newPage = await browser.newPage(); + await newPage.goto(url); + await newPage.setViewport({ width: 1600, height: 789 }); + return newPage; + } + + async function setLanguageToEnglish() { + await clickSelector(page, 'button[aria-label="Change language"]'); + await clickSelector(page, 'div[aria-label="Language selection modal"] a[data-testid="language-option-en"]'); + } + + async function login() { + console.log('-- Accessing Log in with email'); await clickXpath(page, '//button[contains(text(), "Log in with email")]'); - console.log('Trying to log in'); + console.log('-- Logging in'); /* The reason for the wait is because - anchorfm can take a little longer to load the form for logging in - and because pupeteer treats the page as loaded(or navigated to) + anchorfm can take a little longer to load the form for logging in + and because pupeteer treats the page as loaded(or navigated to) even when the form is not showed */ await page.waitForSelector('#email'); await page.type('#email', env.ANCHOR_EMAIL); await page.type('#password', env.ANCHOR_PASSWORD); await clickSelector(page, 'button[type=submit]'); - await navigationPromise; - console.log('Logged in'); + await page.waitForNavigation(); + console.log('-- Logged in'); + } - console.log('Uploading audio file'); + async function waitForNewEpisodeWizard() { + await sleepSeconds(1); + console.log('-- Waiting for episode wizard to open'); + await page.waitForXPath('//span[contains(text(),"Select a file")]'); + } + + async function uploadEpisode() { + console.log('-- Uploading audio file'); await page.waitForSelector('input[type=file]'); const inputFile = await page.$('input[type=file]'); await inputFile.uploadFile(env.AUDIO_FILE); - console.log('Waiting for upload to finish'); - await new Promise((r) => { - setTimeout(r, 25 * 1000); - }); - - await clickXpath(page, '//span[contains(text(),"Save")]/parent::button[not(boolean(@disabled))]', { - timeout: env.UPLOAD_TIMEOUT, - }); - await navigationPromise; + console.log('-- Waiting for upload to finish'); + await page.waitForXPath('//span[contains(text(),"Preview ready!")]', { timeout: env.UPLOAD_TIMEOUT }); + console.log('-- Audio file is uploaded'); + } + async function fillRequiredDetails() { console.log('-- Adding title'); - await page.waitForSelector('#title', { visible: true }); + const titleInputSelector = '#title-input'; + await page.waitForSelector(titleInputSelector, { visible: true }); // Wait some time so any field refresh doesn't mess up with our input - await new Promise((r) => { - setTimeout(r, 2000); - }); - await page.type('#title', youtubeVideoInfo.title); + await sleepSeconds(2); + await page.type(titleInputSelector, youtubeVideoInfo.title); console.log('-- Adding description'); - await page.waitForSelector('div[role="textbox"]', { visible: true }); + const textboxInputSelector = 'div[role="textbox"]'; + await page.waitForSelector(textboxInputSelector, { visible: true }); const finalDescription = addUrlToDescription(youtubeVideoInfo); - await page.type('div[role="textbox"]', finalDescription); + if (isEmpty(finalDescription)) { + await page.type(textboxInputSelector, `Video: ${youtubeVideoInfo.url}`); + } else { + await page.type('div[role="textbox"]', finalDescription); + } if (env.SET_PUBLISH_DATE) { - await setPublishDate(page, navigationPromise, youtubeVideoInfo.uploadDate); + const dateDisplay = `${youtubeVideoInfo.uploadDate.day} ${youtubeVideoInfo.uploadDate.monthAsFullWord}, ${youtubeVideoInfo.uploadDate.year}`; + console.log('-- Schedule publishing for date: ', dateDisplay); + await setPublishDate(page, youtubeVideoInfo.uploadDate); + } else { + console.log('-- No schedule, should publish immediately'); + await clickSelector(page, 'input[type="radio"][id="publish-date-now"]'); } - console.log('-- Selecting content type'); + console.log('-- Selecting content type(explicit or no explicit)'); const selectorForExplicitContentLabel = env.IS_EXPLICIT - ? 'label[for="podcastEpisodeIsExplicit-true"]' - : 'label[for="podcastEpisodeIsExplicit-false"]'; + ? 'input[type="radio"][id="explicit-content"]' + : 'input[type="radio"][id="no-explicit-content"]'; await clickSelector(page, selectorForExplicitContentLabel, { visible: true }); + } + + async function fillOptionalDetails() { + console.log('-- Clicking Additional Details'); + await clickXpath(page, '//button[contains(text(), "Additional details")]'); if (env.LOAD_THUMBNAIL) { console.log('-- Uploading episode art'); @@ -157,69 +191,72 @@ async function postEpisode(youtubeVideoInfo) { console.log('-- Saving uploaded episode art'); await clickXpath(page, '//span[text()="Save"]/parent::button'); + console.log('-- Waiting for uploaded episode art to be saved'); await page.waitForXPath('//div[@aria-label="image uploader"]', { hidden: true, timeout: env.UPLOAD_TIMEOUT }); } + } - const saveDraftOrPublishOrScheduleButtonDescription = getSaveDraftOrPublishOrScheduleButtonDescription(); - console.log(`-- ${saveDraftOrPublishOrScheduleButtonDescription.message}`); - await clickXpath(page, saveDraftOrPublishOrScheduleButtonDescription.xpath); - await navigationPromise; - - /* - This is a workaround solution of the problem where the podcast - is sometimes saved as draft with title "Untitled" and no other metadata. - We navigate to the spotify/anchorfm dashboard immediately after podcast is - published/scheduled. - */ - await page.goto('https://podcasters.spotify.com/pod/dashboard/episodes'); - - console.log('Yay'); - } catch (err) { - throw new Error(`Unable to post episode to anchorfm: ${err}`); - } finally { - if (browser !== undefined) { - await browser.close(); - } + async function skipInteractStep() { + console.log('-- Going to Interact step so we can skip it'); + await clickXpath(page, '//span[text()="Next"]/parent::button'); + console.log('-- Waiting before clicking next again to skip Interact step'); + await sleepSeconds(1); + console.log('-- Going to final step by skipping Interact step'); + await clickXpath(page, '//span[text()="Next"]/parent::button'); } -} -function getSaveDraftOrPublishOrScheduleButtonDescription() { - if (env.SAVE_AS_DRAFT) { - return { - xpath: '//button[text()="Save as draft"]', - message: 'Saving draft', - }; + async function saveDraftOrScheduleOrPublish() { + if (env.SAVE_AS_DRAFT) { + console.log('-- Saving draft'); + await clickSelector(page, 'header > button > span'); + await page.waitForNavigation(); + await clickXpath(page, '//span[text()="Save draft"]/parent::button'); + } else if (env.SET_PUBLISH_DATE) { + console.log('-- Scheduling'); + await clickXpath(page, '//span[text()="Schedule"]/parent::button'); + } else { + console.log('-- Publishing'); + await clickXpath(page, '//span[text()="Publish"]/parent::button'); + } + await sleepSeconds(3); } - if (env.SET_PUBLISH_DATE) { - return { - xpath: '//span[text()="Schedule episode"]/parent::button', - message: 'Scheduling', - }; + async function goToDashboard() { + await page.goto('https://podcasters.spotify.com/pod/dashboard/episodes'); + await sleepSeconds(3); } +} - return { - xpath: '//span[text()="Publish now"]/parent::button', - message: 'Publishing', - }; +async function sleepSeconds(seconds) { + await new Promise((r) => { + setTimeout(r, seconds * 1000); + }); } async function clickSelector(page, selector, options = {}) { await page.waitForSelector(selector, options); - const element = await page.$(selector); - await clickDom(page, element); + const elementHandle = await page.$(selector); + await clickDom(page, elementHandle); } async function clickXpath(page, xpath, options = {}) { await page.waitForXPath(xpath, options); - const [xpathBtn] = await page.$x(xpath); - await clickDom(page, xpathBtn); + const [elementHandle] = await page.$x(xpath); + await clickDom(page, elementHandle); +} + +async function clickDom(page, domElementHandle) { + await page.evaluate((element) => element.click(), domElementHandle); +} + +async function getTextContentFromSelector(page, selector, options = {}) { + await page.waitForSelector(selector, options); + const elementHandle = await page.$(selector); + return getTextContentFromDom(page, elementHandle); } -async function clickDom(page, domBtn) { - await page.evaluate((elem) => { - elem.click(); - }, domBtn); +async function getTextContentFromDom(page, domElementHandle) { + return page.evaluate((element) => element.textContent, domElementHandle); } module.exports = { diff --git a/src/dateutils/index.js b/src/dateutils/index.js new file mode 100644 index 00000000..89feea64 --- /dev/null +++ b/src/dateutils/index.js @@ -0,0 +1,113 @@ +const monthsAsWord = { + '01': 'Jan', + '02': 'Feb', + '03': 'Mar', + '04': 'Apr', + '05': 'May', + '06': 'Jun', + '07': 'Jul', + '08': 'Aug', + '09': 'Sep', + 10: 'Oct', + 11: 'Nov', + 12: 'Dec', +}; + +const monthsAsFullWord = { + '01': 'January', + '02': 'February', + '03': 'March', + '04': 'April', + '05': 'May', + '06': 'June', + '07': 'July', + '08': 'August', + '09': 'September', + 10: 'October', + 11: 'November', + 12: 'December', +}; + +const monthsAsNumber = { + january: '01', + february: '02', + march: '03', + april: '04', + may: '05', + june: '06', + july: '07', + august: '08', + september: '09', + october: '10', + november: '11', + december: '12', + jan: '01', + feb: '02', + mar: '03', + apr: '04', + jun: '06', + jul: '07', + aug: '08', + sep: '09', + oct: '10', + nov: '11', + dec: '12', +}; + +function getMonthAsNumber(month) { + return monthsAsNumber[month.toLowerCase()]; +} + +/** + * Parses dates of the form `20231022`(year 2023, month 10(October), day 22) + * @param date + * @returns {{month: *, year: string, monthAsNumber: string, monthAsFullWord: *, day: string}} + */ +function parseDate(date) { + const year = date.substring(0, 4); + const month = date.substring(4, 6); + const day = date.substring(6, 8); + + return { year, month: monthsAsWord[month], monthAsNumber: month, monthAsFullWord: monthsAsFullWord[month], day }; +} + +function getMonthAsFullWord(monthAsNumber) { + return monthsAsFullWord[monthAsNumber]; +} + +/** + * Compare dates of the form 'October 2023' + * Example: if date1 is 'October 2023' and date2 is 'December 2023' the function will return 0 + * @param date1 + * @param date2 + * @return -1 if date1 is before date2, 0 if date1 is equal to date2, 1 if date1 is after date2 + */ +function compareDates(date1, date2) { + const date1AsNumber = getDateAsNumber(date1); + const date2AsNumber = getDateAsNumber(date2); + if (date1AsNumber < date2AsNumber) { + return -1; + } + if (date1AsNumber === date2AsNumber) { + return 0; + } + return 1; +} + +/** + * Gets date of the form 'October 2023' as number + * Example: 'October 2023' is 202310 + * Example: 'June 2022' is 202206 + * @param date + */ +function getDateAsNumber(date) { + const [monthAsFullWord, year] = date.split(' '); + const monthAsNumber = getMonthAsNumber(monthAsFullWord); + return parseInt(`${year}${monthAsNumber}`, 10); +} + +module.exports = { + parseDate, + getMonthAsFullWord, + compareDates, +}; diff --git a/src/stringutils/index.js b/src/stringutils/index.js new file mode 100644 index 00000000..7edb8781 --- /dev/null +++ b/src/stringutils/index.js @@ -0,0 +1,18 @@ +/** + * Check if trimmed string value is empty. + * Note: If value is not type of string, it is considered empty + * @param value + * @returns {boolean} + */ +function isEmpty(value) { + if (value === undefined || value === null || typeof value !== 'string') { + return true; + } + + const trimmedValue = value.trim(); + return trimmedValue === ''; +} + +module.exports = { + isEmpty, +}; diff --git a/src/youtube-yt-dlp/index.js b/src/youtube-yt-dlp/index.js index d9a70f4f..d28d896e 100644 --- a/src/youtube-yt-dlp/index.js +++ b/src/youtube-yt-dlp/index.js @@ -1,6 +1,7 @@ const youtubedl = require('youtube-dl-exec'); const env = require('../environment-variables'); const { AUDIO_FILE_FORMAT, THUMBNAIL_FILE_FORMAT } = require('../environment-variables'); +const { parseDate } = require('../dateutils'); const youtubeDlOptions = { noCheckCertificates: true, @@ -55,29 +56,6 @@ async function getVideoInfo(videoId) { } } -function parseDate(date) { - const monthAsWord = { - '01': 'Jan', - '02': 'Feb', - '03': 'Mar', - '04': 'Apr', - '05': 'May', - '06': 'Jun', - '07': 'Jul', - '08': 'Aug', - '09': 'Sep', - 10: 'Oct', - 11: 'Nov', - 12: 'Dec', - }; - - const year = date.substring(0, 4); - const month = date.substring(4, 6); - const day = date.substring(6, 8); - - return { year, month: monthAsWord[month], day }; -} - async function downloadThumbnail(videoId) { console.log(`Downloading thumbnail for video id ${videoId}`); try {