Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bugfix/#99 fix script to work on the updated website UI #100

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
317 changes: 177 additions & 140 deletions src/anchorfm-pupeteer/index.js
Original file line number Diff line number Diff line change
@@ -1,152 +1,186 @@
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
? `${youtubeVideoInfo.description}\n${youtubeVideoInfo.url}`
: 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');
Expand All @@ -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 = {
Expand Down
Loading