edx-archive
Version:
Simple tool to download edX course pages (including excercises with answers, course materials, errata, etc).
257 lines (213 loc) • 7.96 kB
JavaScript
const path = require('path');
const fs = require('fs');
const sanitize = require("sanitize-filename");
const puppeteer = require('puppeteer');
const prompt = require('async-prompt');
const program = require('commander');
const retry = require('async-retry');
const PromisePool = require('es6-promise-pool');
function parseFormat(value, previous) {
if (!["pdf", "png"].includes(value)) {
console.log(`invalid format: ${value}`);
process.exit(1);
}
return value;
}
async function getConfiguration() {
const courseUrlPattern = /^https:\/\/learning.edx.org\/course\/.*$/
function parseInteger(v) { return parseInt(v); }
program
.name("edx-archive")
.arguments('<course_url>')
.option('-u, --user <email>', 'edx login (email)')
.option('-p, --password <password>', 'edx password')
.option('-o, --output <directory>', 'output directory', 'Archive')
.option('-f, --format <format>', 'save pages as pdf or png', parseFormat, 'pdf')
.option('-r, --retries <retries>', 'number of retry attempts in case of failure', parseInteger, 3)
.option('-d, --delay <seconds>', 'delay before saving page', parseInteger, 1)
.option('-c, --concurrency <number>', 'number of pages to save in parallel', parseInteger, 4)
.option('--debug', 'output extra debugging', false)
.parse(process.argv);
if (program.args.length !== 1) {
program.help();
}
if (!program.args[0].match(courseUrlPattern)) {
console.log("Invalid course url.\nCourse url should look like: https://learning.edx.org/course/<course_id>");
process.exit(1);
}
const configuration = Object.assign({}, program.opts());
configuration.courseUrl = program.args[0];
if (!configuration.user) {
configuration.user = await prompt('User: ');
}
if (!configuration.password) {
configuration.password = await prompt.password('Password: ');
}
return configuration;
}
async function openPage(url, browser, configuration) {
const page = await browser.newPage();
const navigationPromise = page.waitForNavigation({waitUntil: "domcontentloaded"});
await page.goto(url);
await navigationPromise;
console.log('did I get this far?');
return page;
}
async function loginBrowser(browser, configuration) {
const page = await openPage('https://courses.edx.org/login', browser, configuration);
// await page.waitForNavigation({waitUntil: "domcontentloaded"});
console.log("test1");
// page.evaluate("")
// console.log(console.dir(page));
// var x = page.document.querySelectorAll("");
// console.log(x);
await page.waitForSelector("#emailOrUsername");
await page.waitForSelector("#password");
console.log("test2");
await page.type('#emailOrUsername', configuration.user);
await page.type('#password', configuration.password);
// await page.click('#password');
// await page.keyboard.type(configuration.password);
console.log("test3");
await page.waitForSelector(".pgn__stateful-btn");
console.log("test4");
// await page.keyboard.press("Enter");
const loginEndpoints = [
"https://courses.edx.org/api/user/v2/account/login_session/",
// "https://courses.edx.org/login_ajax"
];
const [response] = await Promise.all([
page.waitForResponse(r => loginEndpoints.includes(r.url())),
page.click('.pgn__stateful-btn')
// page.waitForNavigation({ waitUntil: 'networkidle0' })
// page.click('#password'),
// page.keyboard.type(configuration.password),
]);
if (configuration.debug) {
console.log(`Login response status code: ${response.status()}`);
}
if (response.status() !== 200) {
console.log("Login failed.");
process.exit(1);
}
}
async function getPages(browser, configuration) {
const page = await openPage(configuration.courseUrl, browser, configuration);
const pages = await page.evaluate(() => {
return $("a.outline-item").map(function(i, e) {
return { "index": i, "url": e.href };
}).toArray();
});
await page.close();
if (configuration.debug) {
console.log("Fetched pages:");
console.log(pages);
}
return pages;
}
async function savePage(pageData, page, configuration) {
const filename = path.join(configuration.output, `${pageData.index + 1} - ${pageData.title}`);
if (configuration.debug) {
console.log(`Saving page: ${pageData.url} as: ${filename}`);
}
if (!fs.existsSync(configuration.output)) {
fs.mkdirSync(configuration.output);
}
if (configuration.format === "png") {
await page.screenshot({ path: filename + '.png', fullPage: true });
}
if (configuration.format === "pdf") {
await page.pdf({ path: filename + '.pdf' });
}
}
function prettifyPage() {
$(".show").trigger("click");
$(".hideshowbottom").trigger("click");
$(".discussion-show.shown").trigger("click");
$(".discussion-module").hide();
$("header").hide();
$("#footer-edx-v3").hide();
$(".course-tabs").hide();
$(".course-expiration-message").hide();
$(".verification-sock").hide();
$("#frontend-component-cookie-policy-banner").hide();
$(".sequence-bottom").hide();
$(".sequence-nav").hide();
$(".nav-utilities").hide();
$(".course-license").hide();
$(".bookmark-button-wrapper").hide();
$(".subtitles").hide();
$(".video-wrapper").hide();
}
function buildTitle(breadcumbs) {
return breadcumbs.split(/\n/).map((part) => {
return sanitize(part.trim())
.replace(/\s+/g, " ")
.replace(/^(Course)$/, "");
}).filter((Boolean)).join(" - ");
}
function waitForMathJax() {
return new Promise(function(resolve, reject) {
try {
// TODO this might be unreliable. Try to find a better way to detect
// whether math has been processed
MathJax.Hub.Queue(() => { resolve(); });
} catch (e) {
reject(e);
}
});
}
async function processPage(pageData, browser, configuration) {
const page = await openPage(pageData.url, browser, configuration);
pageData.title = buildTitle(await page.evaluate(() => {
return $(".breadcrumbs").first().text();
}));
await page.evaluate(prettifyPage);
await page.evaluate(waitForMathJax)
await page.waitFor(configuration.delay * 1000);
await savePage(pageData, page, configuration);
await page.close();
}
async function main() {
try {
// build configuration
const configuration = await getConfiguration();
if (configuration.debug) {
console.log("Configuration:");
console.log(Object.assign(Object.assign({}, configuration), { user: "<censored>", password: "<censored>" }));
}
// log in browser
const browser = await puppeteer.launch();
await loginBrowser(browser, configuration);
// build list of pages that should be saved
const pages = await retry(async () => {
return await getPages(browser, configuration);
}, {
retries: configuration.retries,
onRetry: () => { console.log("Failed to fetch pages. Retrying."); }
});
// process pages
const jobGenerator = function * () {
for (const pageData of pages) {
console.log(`Processing page: ${pageData.url}.`);
yield retry(async () => {
return await processPage(pageData, browser, configuration);
}, {
retries: configuration.retries,
onRetry: () => { console.log(`Failed to process page: ${pageData.url}. Retrying.`); }
});
}
}
const pool = new PromisePool(jobGenerator, configuration.concurrency);
await pool.start();
//
await browser.close();
console.log(`\nSaved ${pages.length} pages to: ${path.resolve(configuration.output)}`);
console.log("Done.");
} catch (e) {
console.error(e);
process.exit(1);
}
}
main();