├── downloads └── .gitkeep ├── .prettierrc.json ├── src ├── constants.js ├── config.js ├── utils.js ├── index.js ├── input.js └── app.js ├── .eslintrc.json ├── LICENSE ├── package.json ├── README.md └── .gitignore /downloads/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "printWidth": 100, 4 | "tabWidth": 2, 5 | "trailingComma": "none", 6 | "arrowParens": "avoid" 7 | } -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | URLS: { 3 | UNDERGRADUATE_COURSES: 'http://met.guc.edu.eg/Courses/Undergrad.aspx', 4 | POSTGRADUATE_COURSES: 'http://met.guc.edu.eg/Courses/Grad.aspx', 5 | HOMEPAGE: 'http://met.guc.edu.eg/' 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "commonjs": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "globals": { 9 | "Atomics": "readonly", 10 | "SharedArrayBuffer": "readonly" 11 | }, 12 | "parserOptions": { 13 | "ecmaVersion": 2018 14 | }, 15 | "rules": {} 16 | } 17 | -------------------------------------------------------------------------------- /src/config.js: -------------------------------------------------------------------------------- 1 | let isHeadless = true; 2 | if (process.env.IS_HEADLESS !== undefined && process.env.IS_HEADLESS === 'false') { 3 | isHeadless = false; 4 | } 5 | module.exports.systemDownloadDirectory = require('downloads-folder')(); 6 | module.exports.isHeadless = isHeadless; 7 | module.exports.chromiumPath = process.env.CHROMIUM_EXECUTABLE_PATH; 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Abdullah Elkady 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "met-downloader", 3 | "version": "1.1.3", 4 | "description": "Downloads the material of a course hosted on the MET (GUC) website, and organizes the materials into their respective folders accordingly", 5 | "main": "src/index.js", 6 | "bin": "src/index.js", 7 | "homepage": "https://github.com/AbdullahKady/met-downloader", 8 | "repository": { 9 | "type": "git", 10 | "url": "git+https://github.com/AbdullahKady/met-downloader.git" 11 | }, 12 | "bugs": { 13 | "url": "https://github.com/AbdullahKady/met-downloader/issues" 14 | }, 15 | "scripts": { 16 | "start": "node src/index.js" 17 | }, 18 | "keywords": [ 19 | "guc", 20 | "met", 21 | "automation", 22 | "downloader", 23 | "headless" 24 | ], 25 | "author": "Abdullah Elkady ", 26 | "license": "MIT", 27 | "dependencies": { 28 | "boxen": "^4.2.0", 29 | "chalk": "^4.1.0", 30 | "downloads-folder": "^3.0.1", 31 | "fuzzy": "^0.1.3", 32 | "inquirer": "^7.3.3", 33 | "inquirer-autocomplete-prompt": "^1.1.0", 34 | "ora": "^5.1.0", 35 | "puppeteer": "^5.3.1", 36 | "sanitize-filename": "^1.6.3" 37 | }, 38 | "devDependencies": { 39 | "eslint": "^7.10.0", 40 | "prettier": "^2.1.2" 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | // Credits: https://stackoverflow.com/a/56951024/7502260 5 | module.exports.isDoneDownloading = (filePath, timeout) => 6 | new Promise((resolve, reject) => { 7 | const timer = setTimeout(() => { 8 | watcher.close(); 9 | reject(new Error('File did not exists and was not created during the timeout.')); 10 | }, timeout); 11 | 12 | fs.access(filePath, fs.constants.R_OK, err => { 13 | if (!err) { 14 | clearTimeout(timer); 15 | watcher.close(); 16 | resolve(); 17 | } 18 | }); 19 | 20 | const dir = path.dirname(filePath); 21 | const basename = path.basename(filePath); 22 | const watcher = fs.watch(dir, (eventType, filename) => { 23 | if (eventType === 'rename' && filename === basename) { 24 | clearTimeout(timer); 25 | watcher.close(); 26 | resolve(); 27 | } 28 | }); 29 | }); 30 | 31 | module.exports.constructMaterialLink = inputURL => { 32 | // Note that the input URL can be any valid course URL (not necessarily the materials tab). 33 | const courseIDQueryParam = inputURL.match(/crsEdId=\d+/).pop(); // crsEdId=912 34 | return `http://met.guc.edu.eg/Courses/Material.aspx?${courseIDQueryParam}`; 35 | }; 36 | 37 | /** 38 | * @param {Array} list 39 | * @param {String} key 40 | * Takes a list of objects "list", and returns a list 41 | * containing the unique elements according to some key "key". 42 | * */ 43 | module.exports.uniqueBy = (list, key) => { 44 | const seen = {}; 45 | return list.filter(item => { 46 | const value = item[key]; 47 | if (seen[value]) { 48 | return false; 49 | } 50 | seen[value] = true; 51 | return true; 52 | }); 53 | }; 54 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const boxen = require('boxen'); 3 | const chalk = require('chalk'); 4 | 5 | const { runApplication } = require('./app'); 6 | 7 | class SignalRef { 8 | // This is a bug with inquirer, refer to: 9 | // https://github.com/SBoudrias/Inquirer.js/issues/293#issuecomment-422890996 10 | constructor(signal, handler) { 11 | this.signal = signal; 12 | this.handler = handler; 13 | 14 | process.on(this.signal, this.handler); 15 | this.interval = setInterval(() => {}, 10000); 16 | } 17 | 18 | unref() { 19 | clearInterval(this.interval); 20 | process.removeListener(this.signal, this.handler); 21 | } 22 | } 23 | 24 | const exitApplication = () => { 25 | let signOff = '\n'; 26 | signOff += chalk.blue('Thank you for using ') + chalk.blue.bold('met-downloader') + '.\n'; 27 | signOff += `${chalk.blue('If you enjoy it, feel free to leave a')} ${chalk.red.bold('star')}\n`; 28 | signOff += chalk.yellow.bold.italic('https://github.com/AbdullahKady/met-downloader\n\n'); 29 | signOff += chalk.gray.italic('Feedback and contribution is welcome as well :)'); 30 | console.log( 31 | boxen(signOff, { 32 | padding: 1, 33 | margin: 1, 34 | borderStyle: 'double', 35 | borderColor: 'green', 36 | align: 'center' 37 | }) 38 | ); 39 | process.exit(0); 40 | }; 41 | 42 | if (process.platform === 'win32') { 43 | // To be able to capture SIGINT in windows systems :) 44 | const rl = require('readline').createInterface({ 45 | input: process.stdin, 46 | output: process.stdout 47 | }); 48 | 49 | rl.on('SIGINT', () => { 50 | process.emit('SIGINT'); 51 | }); 52 | } 53 | 54 | const main = async () => { 55 | const signalRef = new SignalRef('SIGINT', exitApplication); 56 | 57 | try { 58 | await runApplication(); 59 | } finally { 60 | signalRef.unref(); 61 | } 62 | }; 63 | 64 | main(); 65 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A puppeteer (headless browser) script that downloads the material of any course hosted on the MET (GUC) website, and organizes the materials into their respective folders accordingly. 2 | 3 | ## Showcase 4 | 5 | [![asciicast](https://asciinema.org/a/2ojibKGuBzk5IuUzgXMG2q33g.svg)](https://asciinema.org/a/2ojibKGuBzk5IuUzgXMG2q33g?speed=2) 6 | 7 | ## Usage 8 | 9 | ### npx (Recommended) 10 | If you have `npx` installed, you can avoid installing the tool on your system. However chromium binaries will be downloaded every time you run the application, so you can use the below setup to avoid doing so. 11 | 12 | ```bash 13 | export CHROMIUM_EXECUTABLE_PATH=/usr/bin/google-chrome 14 | export PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true 15 | npx met-downloader 16 | ``` 17 | 18 | When `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD` is set to `true`, puppeteer skips downloading the binaries for chromium, however you **must** provide an executable path to a chromium binary (which is done via the `CHROMIUM_EXECUTABLE_PATH` environment variable). In the above example, it's assumed the default path to google-chrome, on an Ubuntu machine. 19 | 20 | ### Installing globally 21 | If you don't have `npx` installed, or you would like to install the CLI tool so that you can use it anytime you can install it globally by running 22 | 23 | ```bash 24 | npm i met-downloader -g 25 | ``` 26 | Note that this will fetch the chromium binaries, you can skip them as described above, but you will have to provide the executable chromium path as an environment every time you run the command (you can obviously configure this through your bash to avoid exporting the same environment repeatedly) 27 | Once the installation is done, simply run 28 | 29 | ```bash 30 | met-downloader 31 | ``` 32 | 33 | ### From the source code 34 | 35 | Clone the repo 36 | 37 | ```bash 38 | git clone git@github.com:AbdullahKady/met-downloader.git 39 | ``` 40 | 41 | After cloning, install the dependencies by running 42 | 43 | ```bash 44 | npm i 45 | ``` 46 | 47 | Finally run the application normally, and follow the interactive input 48 | 49 | ```bash 50 | npm start 51 | ``` 52 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | downloads/ 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | 80 | # Next.js build output 81 | .next 82 | 83 | # Nuxt.js build / generate output 84 | .nuxt 85 | dist 86 | 87 | # Gatsby files 88 | .cache/ 89 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 90 | # https://nextjs.org/blog/next-9-1#public-directory-support 91 | # public 92 | 93 | # vuepress build output 94 | .vuepress/dist 95 | 96 | # Serverless directories 97 | .serverless/ 98 | 99 | # FuseBox cache 100 | .fusebox/ 101 | 102 | # DynamoDB Local files 103 | .dynamodb/ 104 | 105 | # TernJS port file 106 | .tern-port 107 | -------------------------------------------------------------------------------- /src/input.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const fuzzy = require('fuzzy'); 4 | const chalk = require('chalk'); 5 | const inquirer = require('inquirer'); 6 | inquirer.registerPrompt('autocomplete', require('inquirer-autocomplete-prompt')); 7 | 8 | const { systemDownloadDirectory } = require('./config'); 9 | 10 | const isValidGucEmail = string => { 11 | return /^[a-zA-Z0-9_\-.]+@student\.guc\.edu\.eg$/.test(string); 12 | }; 13 | 14 | module.exports.getCredentials = () => 15 | inquirer.prompt([ 16 | { 17 | type: 'input', 18 | name: 'email', 19 | message: 'Enter email:', 20 | validate: email => { 21 | if (isValidGucEmail(email)) { 22 | return true; 23 | } 24 | 25 | return ( 26 | 'Please use your GUC email that is registered on' + 27 | ' the MET website (eg. your.name@student.guc.edu.eg)' 28 | ); 29 | } 30 | }, 31 | { 32 | type: 'password', 33 | mask: '*', 34 | name: 'password', 35 | message: 'Enter password:' 36 | } 37 | ]); 38 | 39 | module.exports.getDownloadRootPath = async () => { 40 | const { downloadRootPath } = await inquirer.prompt([ 41 | { 42 | name: 'downloadRootPath', 43 | message: `Enter a path to the ${chalk.bold( 44 | 'parent' 45 | )} directory (this is not the final directory, a ${chalk.bold( 46 | 'sub-directory' 47 | )} will be created for each course):`, 48 | validate: path => { 49 | const isValid = fs.existsSync(path) && fs.lstatSync(path).isDirectory(); 50 | return isValid || `Directory doesn't exist, please try again.`; 51 | }, 52 | default: systemDownloadDirectory 53 | } 54 | ]); 55 | return downloadRootPath; 56 | }; 57 | 58 | const validateDirectory = path => 59 | /^\w+[\w\-\s:()\][]*$/.test(path) || 60 | 'Directory name can only contain letters, numbers, dashes, underscores, colons, brackets, and whitespaces.'; 61 | 62 | module.exports.getCourseDirectory = async (defaultDirectory, rootPath) => { 63 | let { courseDirectory } = await inquirer.prompt([ 64 | { 65 | name: 'courseDirectory', 66 | message: `Enter a directory name to be created for the course's material:`, 67 | validate: validateDirectory, 68 | default: defaultDirectory 69 | } 70 | ]); 71 | let downloadRootPath = path.resolve(rootPath, courseDirectory); 72 | while (fs.existsSync(downloadRootPath)) { 73 | ({ courseDirectory } = await inquirer.prompt([ 74 | { 75 | name: 'courseDirectory', 76 | message: `"${chalk.italic(courseDirectory)}" already exists. Provide another name please`, 77 | validate: validateDirectory 78 | } 79 | ])); // Since re-assigning with destructuring, it has to be wrapped in parens. 80 | downloadRootPath = path.resolve(rootPath, courseDirectory); 81 | } 82 | return downloadRootPath; 83 | }; 84 | 85 | const coursesFuzzySearch = courses => async (answers, input) => { 86 | if (input === undefined) { 87 | return courses; 88 | } 89 | 90 | const results = fuzzy.filter(input, courses); 91 | return results.map(c => c.original); 92 | }; 93 | 94 | module.exports.getCourse = coursesList => 95 | inquirer 96 | .prompt([ 97 | { 98 | type: 'autocomplete', 99 | pageSize: 5, 100 | name: 'course', 101 | message: `Select the course to be downloaded. Start typing to search (hit ${chalk.bold.red( 102 | 'CTRL + C' 103 | )} to exit the program)`, 104 | source: coursesFuzzySearch(coursesList.map(c => c.name)) 105 | } 106 | ]) 107 | .then(res => coursesList.find(c => c.name === res.course)); 108 | 109 | module.exports.getShouldOrderByFileType = () => 110 | inquirer 111 | .prompt([ 112 | { 113 | type: 'list', 114 | choices: [ 115 | { name: 'By Week', value: false }, 116 | { name: 'By File Type (Lectures, Assignments, etc.)', value: true } 117 | ], 118 | name: 'orderByFileType', 119 | message: 'How would you like to organize the files downloaded' 120 | } 121 | ]) 122 | .then(res => res.orderByFileType); 123 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | /** @typedef {import('puppeteer').Page} Page */ 2 | const puppeteer = require('puppeteer'); 3 | const fs = require('fs'); 4 | const path = require('path'); 5 | const ora = require('ora'); 6 | const sanitizeFilename = require('sanitize-filename'); 7 | 8 | const { URLS } = require('./constants'); 9 | const input = require('./input'); 10 | const { isHeadless, chromiumPath } = require('./config'); 11 | const { isDoneDownloading, constructMaterialLink, uniqueBy } = require('./utils'); 12 | 13 | /** 14 | * @param {puppeteer.Page} page 15 | * Expects that the page paramter is already navigated to the right 16 | * URL (course materials page) 17 | * */ 18 | const downloadMaterial = async (page, downloadDirectoryPath, spinner, orderByFileType) => { 19 | if (orderByFileType) { 20 | await Promise.all([ 21 | page.click('#ctl00_AcademicsMasterContent_fileTypeLinkBtn'), 22 | page.waitForNavigation({ waitUntil: 'load' }) 23 | ]); 24 | } 25 | 26 | const materialsSections = await page.$$eval( 27 | '.badgeContainer', 28 | containers => 29 | containers 30 | .map(container => { 31 | // The element containing the course name differs based on the view (by type vs by week) 32 | // and since the boolean "orderByFileType" won't be available in the page context, this 33 | // hack is the easiest way to get a compatible solution for both cases. 34 | let directory = container.querySelector('.badgeDetails > h3'); 35 | if (directory) { 36 | directory = directory.innerText; 37 | } else { 38 | directory = container.querySelector('.badgeHeader h3').innerText; 39 | } 40 | 41 | const files = [...container.querySelectorAll('a')] 42 | .filter(a => a.href && a.innerText) // They contain extra empty anchor tags \_0_/ 43 | .map(node => ({ 44 | fileName: node 45 | .getAttribute('href') 46 | .split('file=') 47 | .pop(), 48 | id: node.id 49 | })); 50 | 51 | return { 52 | directory, 53 | files 54 | }; 55 | }) 56 | .filter(({ files }) => files.length > 0) // Ignore empty weeks (exams week for instance) 57 | ); 58 | 59 | for (const [i, { directory, files }] of materialsSections.entries()) { 60 | const sectionDirectory = path.resolve( 61 | downloadDirectoryPath, 62 | `${i + 1}-${sanitizeFilename(directory, { replacement: '-' })}` 63 | ); 64 | fs.mkdirSync(sectionDirectory); 65 | await page._client.send('Page.setDownloadBehavior', { 66 | behavior: 'allow', 67 | downloadPath: sectionDirectory 68 | }); 69 | 70 | for (const [j, { id, fileName }] of files.entries()) { 71 | spinner.text = `downloading ${i + 1}/${materialsSections.length} (${j + 1}/${files.length})`; 72 | await page.click(`#${id}`); 73 | await isDoneDownloading(path.resolve(sectionDirectory, fileName), 100000); 74 | } 75 | } 76 | }; 77 | 78 | /** 79 | * @param {puppeteer.Page} page 80 | * @param {string} email 81 | * @param {string} password 82 | * */ 83 | const login = async (page, email, password) => { 84 | await page.goto(URLS.HOMEPAGE); 85 | await page.focus('.userNameTBox'); 86 | await page.keyboard.type(email); 87 | await page.focus('.passwordTBox'); 88 | await page.keyboard.type(password); 89 | 90 | await Promise.all([page.click('.loginBtn'), page.waitForNavigation({ waitUntil: 'load' })]); 91 | 92 | // Easiest way to check for non-logged in is to search for a div with id=logged 93 | // which is only available after the user is successfully logged in. 94 | return (await page.$('#logged')) !== null; 95 | }; 96 | 97 | /** 98 | * @param {puppeteer.Page} page 99 | * Fetches all courses available on the website (post&under graduate), and sorts 100 | * them so that if the user has 'my courses' populated, they would be shown first. 101 | * */ 102 | const fetchAllCourses = async page => { 103 | await page.goto(URLS.UNDERGRADUATE_COURSES); 104 | const undergraduateCourses = await page.$$eval('.coursesLst', elements => 105 | elements.map(e => ({ name: e.innerText, url: e.href })) 106 | ); 107 | 108 | await page.goto(URLS.POSTGRADUATE_COURSES); 109 | const postgraduateCourses = await page.$$eval('#list a', elements => 110 | elements.map(e => ({ name: e.innerText, url: e.href })) 111 | ); 112 | 113 | await page.goto(URLS.HOMEPAGE); 114 | const suggestedCourses = await page.$$eval('#courses_menu a', elements => 115 | elements.slice(1, -1).map(a => `${a.innerText} ${a.title}`) 116 | ); 117 | 118 | // Courses are often duplicated (say CSEN 102 under both MET, and DMET) 119 | // therefore duplicates are eliminated 120 | const availableCourses = uniqueBy([...undergraduateCourses, ...postgraduateCourses], 'name'); 121 | 122 | // Sort the courses according to the user's set of chosen courses 123 | // available under 'my courses' on the website 124 | return availableCourses.sort((a, b) => { 125 | let indexA = suggestedCourses.indexOf(a.name); 126 | let indexB = suggestedCourses.indexOf(b.name); 127 | indexA = indexA === -1 ? 99 : indexA; 128 | indexB = indexB === -1 ? 99 : indexB; 129 | 130 | return indexA < indexB ? -1 : 1; 131 | }); 132 | }; 133 | 134 | module.exports.runApplication = async () => { 135 | const { email, password } = await input.getCredentials(); 136 | 137 | // 'chromiumPath' can be provided via environment to avoid downloading chromium. 138 | const browser = await puppeteer.launch({ headless: isHeadless, executablePath: chromiumPath }); 139 | const context = await browser.createIncognitoBrowserContext(); 140 | const page = await context.newPage(); 141 | 142 | const spinner = ora('Verifying credentials').start(); 143 | if (!(await login(page, email, password))) { 144 | spinner.stop(); 145 | console.log('You have entered invalid credentials, please try again.'); 146 | await browser.close(); 147 | return; 148 | } 149 | spinner.stop(); 150 | const rootDownloadPath = await input.getDownloadRootPath(); 151 | 152 | spinner.start('Fetching available courses'); 153 | const coursesList = await fetchAllCourses(page); 154 | // Application is only terminated via SIGINT 155 | // eslint-disable-next-line 156 | while (true) { 157 | spinner.stop(); 158 | const selectedCourse = await input.getCourse(coursesList); 159 | spinner.start('Opening the course page'); 160 | await page.goto(constructMaterialLink(selectedCourse.url)); 161 | 162 | spinner.stop(); 163 | const courseDirectoryPath = await input.getCourseDirectory( 164 | selectedCourse.name, 165 | rootDownloadPath 166 | ); 167 | fs.mkdirSync(courseDirectoryPath); 168 | 169 | const orderByFileType = await input.getShouldOrderByFileType(); 170 | spinner.start(); 171 | await downloadMaterial(page, courseDirectoryPath, spinner, orderByFileType); 172 | spinner.stop(); 173 | console.log(`\n"${selectedCourse.name}" finished downloading.\n`); 174 | } 175 | }; 176 | --------------------------------------------------------------------------------