├── .devcontainer ├── Dockerfile └── devcontainer.json ├── .eslintrc.js ├── .gitignore ├── LICENSE ├── README.md ├── bin └── gkpdf ├── index.js ├── instructions.png ├── package.json └── src ├── api.js ├── app.js ├── index.js └── utils.js /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | # See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/javascript-node/.devcontainer/base.Dockerfile 2 | 3 | # [Choice] Node.js version: 16, 14, 12 4 | ARG VARIANT="16-buster" 5 | FROM mcr.microsoft.com/vscode/devcontainers/javascript-node:0-${VARIANT} 6 | 7 | # [Optional] Uncomment this section to install additional OS packages. 8 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 9 | # && apt-get -y install --no-install-recommends 10 | 11 | RUN apt-get update \ 12 | && apt-get install -y wget gnupg \ 13 | && wget -q -O - https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add - \ 14 | && sh -c 'echo "deb [arch=amd64] http://dl.google.com/linux/chrome/deb/ stable main" >> /etc/apt/sources.list.d/google.list' \ 15 | && apt-get update \ 16 | && apt-get install -y google-chrome-stable fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg fonts-kacst fonts-freefont-ttf libxss1 \ 17 | --no-install-recommends \ 18 | && apt-get install -y gconf-service libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgcc1 libgconf-2-4 libgdk-pixbuf2.0-0 libglib2.0-0 libgtk-3-0 libnspr4 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxss1 libxtst6 libappindicator1 libnss3 libasound2 libatk1.0-0 libc6 ca-certificates fonts-liberation lsb-release xdg-utils \ 19 | && rm -rf /var/lib/apt/lists/* 20 | 21 | RUN 22 | 23 | # [Optional] Uncomment if you want to install an additional version of node using nvm 24 | # ARG EXTRA_NODE_VERSION=10 25 | # RUN su node -c "source /usr/local/share/nvm/nvm.sh && nvm install ${EXTRA_NODE_VERSION}" 26 | 27 | # [Optional] Uncomment if you want to install more global node modules 28 | RUN su node -c "npm install -g puppeteer geek-time-topdf" 29 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at: 2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.187.0/containers/javascript-node 3 | { 4 | "name": "Node.js", 5 | "build": { 6 | "dockerfile": "Dockerfile", 7 | // Update 'VARIANT' to pick a Node version: 12, 14, 16 8 | "args": { "VARIANT": "16" } 9 | }, 10 | 11 | // Set *default* container specific settings.json values on container create. 12 | "settings": {}, 13 | 14 | // Add the IDs of extensions you want installed when the container is created. 15 | "extensions": [ 16 | "dbaeumer.vscode-eslint" 17 | ], 18 | 19 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 20 | // "forwardPorts": [], 21 | 22 | // Use 'postCreateCommand' to run commands after the container is created. 23 | // "postCreateCommand": "yarn install", 24 | 25 | // Comment out connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root. 26 | "remoteUser": "node", 27 | "runArgs": [ "--cap-add=SYS_ADMIN"] 28 | } 29 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | parser: 'babel-eslint', 4 | parserOptions: { 5 | ecmaVersion: 8, 6 | sourceType: 'module', 7 | //想使用的额外的语言特性: 8 | ecmaFeatures: { 9 | parser: 'babel-eslint', 10 | // 允许在全局作用域下使用 return 语句 11 | globalReturn: true, 12 | // impliedStric 严格模式 13 | impliedStrict: true, 14 | // 启用 JSX 15 | jsx: true, 16 | // experimentalObjectRestSpread: true, 17 | modules: true 18 | } 19 | }, 20 | env: { 21 | browser: true, 22 | commonjs: true, 23 | node: true, 24 | es6: true 25 | }, 26 | extends: ['eslint:recommended', 'prettier'], 27 | plugins: ['prettier'], 28 | parserOptions: { 29 | ecmaVersion: 2018 30 | }, 31 | rules: { 32 | 'no-console': [0], 33 | indent: ['error', 2], 34 | 'linebreak-style': ['error', 'unix'], 35 | quotes: ['error', 'single'], 36 | // 要求或禁止使用分号而不是 ASI(这个才是控制行尾部分号的,) 37 | semi: [2, 'never'], 38 | // 强制使用一致的反勾号、双引号或单引号 39 | quotes: [2, 'single', 'avoid-escape'], 40 | 'prettier/prettier': [ 41 | 'error', 42 | { 43 | singleQuote: true, 44 | printWidth: 120, 45 | tabWidth: 2, 46 | semi: false, 47 | trailingComma: 'none', 48 | arrowParens: 'avoid', 49 | bracketSpacing: true, 50 | bracesSpacing: true, 51 | jsxBracketSameLine: true 52 | // insertPragma: true, 53 | // requirePragma: false 54 | } 55 | ] 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Output PDF 2 | *.pdf 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | 11 | # Runtime data 12 | pids 13 | *.pid 14 | *.seed 15 | *.pid.lock 16 | 17 | # Directory for instrumented libs generated by jscoverage/JSCover 18 | lib-cov 19 | 20 | # Coverage directory used by tools like istanbul 21 | coverage 22 | 23 | # nyc test coverage 24 | .nyc_output 25 | 26 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 27 | .grunt 28 | 29 | # Bower dependency directory (https://bower.io/) 30 | bower_components 31 | 32 | # node-waf configuration 33 | .lock-wscript 34 | 35 | # Compiled binary addons (https://nodejs.org/api/addons.html) 36 | build/Release 37 | 38 | # Dependency directories 39 | node_modules/ 40 | jspm_packages/ 41 | 42 | # TypeScript v1 declaration files 43 | typings/ 44 | 45 | # Optional npm cache directory 46 | .npm 47 | 48 | # Optional eslint cache 49 | .eslintcache 50 | 51 | # Optional REPL history 52 | .node_repl_history 53 | 54 | # Output of 'npm pack' 55 | *.tgz 56 | 57 | # Yarn Integrity file 58 | .yarn-integrity 59 | 60 | # dotenv environment variables file 61 | .env 62 | 63 | # next.js build output 64 | .next 65 | cookie.json -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # geek-time-topdf 2 | 3 | print your geek time course to pdf. If you have already bought it 4 | 5 | need phone, password, course name. 6 | 7 | Do not record any information... 8 | 9 | ```sh 10 | npm install geek-time-topdf -g # in china use cnpm | 推荐使用 cnpm,加快安装速度 11 | 12 | gkpdf init # 账号初始化 13 | gkpdf clear # 清除账号信息 14 | ``` 15 | 16 | 将你**已经购买**的极客时间的课程,打印为 pdf。 17 | 18 | 需要手机号码和密码。课程名称 19 | 20 | **不记录任何信息** 21 | 22 | 操作说明: 23 | 24 | ![instructions](./instructions.png) 25 | 26 | ## Run in docker container via VSCode 27 | 28 | Open this folder in container in VSCode. 29 | 30 | Or add `--cap-add=SYS_ADMIN` when run and build the docker image alone. 31 | 32 | 通过 VSCode 在 docker 容器中运行 33 | 34 | 在 VSCode 容器中打开此文件夹。 35 | 36 | 或者在单独运行并构建 docker 映像时添加 `--cap-add=SYS_ADMIN`。 37 | 38 | 39 | changeLogs: 40 | - 4.1.0: feat: 增加国家代码,调整样式过滤 41 | - 4.0.5: feat: 添加 clear 命令。用于切换账号 42 | - 4.0.4: fix: 修复 windows 下文件名存在非法字符导致的下载出错 43 | - 4.0.3: fix: 修复课程打印不出来的问题 44 | - 4.0.2: fix: 课程名称处理正则未全局匹配 45 | - 4.0.1: fix: 课程展示不全 46 | - 4.0.0: 调整为接口获取课程方式。(**必须更新,旧的包已不可使用**) 47 | - 3.2.0: 调整了课程搜索的滚动方式,增加成功率 48 | - 3.0.0: 处理极客时间登陆改版,导致登陆不了的问题,并且加了一个重复打印的选项 49 | - 2.0.2: 修改页面滚动的等待时间,提高成功率 50 | - 2.0.1: 修复 windows 系统下 特殊中文字符 pdf 生成报错的问题 51 | - 2.0.0: 更新代码来支持极客时间的最新版页面(**必须更新,旧的包已不可使用**) 52 | - 1.1.4: 修复自定义目录时,未正确创建目录的 bug 53 | - 1.1.2: 版本添加 png 输出选项 54 | 55 | TODO: 56 | 57 | - [ ] 一键下载所有课程 58 | - [ ] 出错自动重试 59 | - [ ] puppeteer page 并行 60 | -------------------------------------------------------------------------------- /bin/gkpdf: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | module.exports = require('../') -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('./src/index') 2 | -------------------------------------------------------------------------------- /instructions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/guowenfh/geek-time-topdf/0f2ec9e983edb151a0e3ff4128f727da69ed491f/instructions.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geek-time-topdf", 3 | "version": "4.1.0", 4 | "description": "use puppeteer geek time course to pdf", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index init", 8 | "clear": "node index clear", 9 | "format-code": "prettier-eslint --write \"src/**/*.js\"", 10 | "lint-staged": "lint-staged", 11 | "precommit-msg": "echo '🐉 Start pre-commit checks...' && exit 0", 12 | "postinstall": "node node_modules/husky/lib/installer/bin install" 13 | }, 14 | "engines": { 15 | "node": ">=8.9.0" 16 | }, 17 | "repository": { 18 | "type": "git", 19 | "url": "git+https://github.com/guowenfh/geek-time-topdf.git" 20 | }, 21 | "keywords": [ 22 | "puppeteer", 23 | "inquirer", 24 | "commander", 25 | "pdf" 26 | ], 27 | "author": "guowenfh", 28 | "license": "MIT", 29 | "bugs": { 30 | "url": "https://github.com/guowenfh/geek-time-topdf/issues" 31 | }, 32 | "homepage": "https://github.com/guowenfh/geek-time-topdf#readme", 33 | "bin": { 34 | "gkpdf": "./bin/gkpdf" 35 | }, 36 | "dependencies": { 37 | "axios": "^0.21.1", 38 | "commander": "^5.0.0", 39 | "husky": "^4.2.3", 40 | "inquirer": "^7.1.0", 41 | "progress": "^2.0.3", 42 | "puppeteer": "^2.0.0", 43 | "set-cookie-parser": "^2.3.5" 44 | }, 45 | "devDependencies": { 46 | "babel-eslint": "^10.0.1", 47 | "eslint": "^6.0.1", 48 | "eslint-config-prettier": "^6.0.0", 49 | "eslint-plugin-prettier": "^3.0.1", 50 | "lint-staged": "^10.0.9", 51 | "prettier": "^2.0.2", 52 | "prettier-eslint-cli": "^5.0.0" 53 | }, 54 | "lint-staged": { 55 | "src/**/*.{js,ts,json}": [ 56 | "prettier-eslint --write", 57 | "git add" 58 | ] 59 | }, 60 | "pre-commit": [ 61 | "precommit-msg", 62 | "lint-staged" 63 | ], 64 | "husky": { 65 | "hooks": { 66 | "pre-commit": "npm run precommit-msg && lint-staged" 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | //Geek Time API calls 2 | const axios = require('axios') 3 | const setCookie = require('set-cookie-parser') 4 | const { saveCookie, getCookie } = require('./utils') 5 | const urls = { 6 | login: 'https://account.geekbang.org/account/ticket/login', 7 | productsAll: 'https://time.geekbang.org/serv/v1/my/products/list', 8 | articles: 'https://time.geekbang.org/serv/v1/column/articles' 9 | } 10 | const axiosInstance = axios.create({}) 11 | function updateHeaders(cookie = []) { 12 | axiosInstance.defaults.headers = { 13 | Host: 'time.geekbang.org', 14 | 'Content-Type': 'application/json', 15 | Origin: 'https://time.geekbang.org', 16 | Cookie: cookie.reduce((a, b) => a + `${b.name}=${b.value};`, ''), 17 | Referer: 'https://account.geekbang.org/dashboard/buy', 18 | 'User-Agent': 19 | ' Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36' 20 | } 21 | } 22 | 23 | updateHeaders(getCookie()) 24 | 25 | axiosInstance.interceptors.response.use( 26 | function (response) { 27 | const failLoginCode = [-2000] 28 | let errorCode = response.data.error.code 29 | if (failLoginCode.includes(errorCode)) { 30 | clearEffects() 31 | return Promise.reject('登录失效') 32 | } 33 | return response 34 | }, 35 | function (error) { 36 | if (error.response.status == 452) { 37 | clearEffects() 38 | return Promise.reject('登录失效') 39 | } 40 | console.log(error) 41 | } 42 | ) 43 | 44 | function getList() { 45 | // TODO 不知道分页的页码传输 先写 1000 46 | return axiosInstance.get(urls.productsAll, { data: { size: 1000, nav_id: 1 } }).then(res => { 47 | const data = res.data.data 48 | return data.list.map((item, index) => { 49 | return { ...item, index } 50 | }) 51 | }) 52 | } 53 | 54 | function getArticle(cid) { 55 | const params = { 56 | cid, 57 | size: 200, 58 | prev: 0, 59 | order: 'earliest', 60 | sample: false 61 | } 62 | return axiosInstance.post(urls.articles, params).then(res => { 63 | return res.data.data.list.map(item => { 64 | item.href = `https://time.geekbang.org/column/article/${item.id}` 65 | return item 66 | }) 67 | }) 68 | } 69 | 70 | //geek time login api 71 | function login({ cellphone, password, countryCode }) { 72 | const params = { 73 | country: countryCode, 74 | cellphone, 75 | password, 76 | captcha: '', 77 | remember: 1, 78 | platform: 3, 79 | appid: 1 80 | } 81 | const headers = { 82 | Accept: 'application/json, text/plain, */*', 83 | 'Content-Type': 'application/json', 84 | Origin: 'https://account.geekbang.org', 85 | Referer: 'https://account.geekbang.org/login', 86 | 'User-Agent': 87 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36' 88 | } 89 | return axios 90 | .post(urls.login, params, { 91 | headers 92 | }) 93 | .then(res => { 94 | const data = res.data 95 | if (data.code != 0) { 96 | return Promise.reject(data.error.msg) 97 | } else { 98 | const cookie = setCookie.parse(res) 99 | saveCookie(cookie) 100 | updateHeaders(cookie) 101 | return data 102 | } 103 | }) 104 | } 105 | 106 | function clearEffects() { 107 | saveCookie([]) 108 | updateHeaders([]) 109 | } 110 | 111 | module.exports = { 112 | getList, 113 | login, 114 | getArticle 115 | } 116 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | const puppeteer = require('puppeteer') 2 | const fs = require('fs') 3 | const path = require('path') 4 | const util = require('util') 5 | const mkdir = util.promisify(fs.mkdir) 6 | const access = util.promisify(fs.access) 7 | const api = require('./api.js') 8 | const ProgressBar = require('progress') 9 | const utils = require('./utils') 10 | let browser 11 | let page 12 | 13 | async function initBrowser() { 14 | try { 15 | browser = await puppeteer.launch({ 16 | ignoreHTTPSErrors: true, 17 | timeout: 30000, 18 | headless: true // 是否启用无头模式页面 19 | }) 20 | page = await browser.newPage() 21 | await page.setDefaultNavigationTimeout(0) 22 | // 设置头加上跳转 23 | await page.setUserAgent( 24 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36' 25 | ) 26 | await page.setExtraHTTPHeaders({ 27 | Origin: 'https://account.geekbang.org' 28 | }) 29 | const cookie = utils.getCookie() 30 | await page.setCookie(...cookie) 31 | } catch (error) { 32 | console.error('初始化浏览器失败', error) 33 | } 34 | } 35 | 36 | async function downArticle({ article, pagePrint }) { 37 | if (!browser) { 38 | await initBrowser() 39 | } 40 | const curr = article.subList.find(item => item.title.indexOf(article.course.trim()) !== -1) 41 | let task = await api.getArticle(curr.extra.column_id) 42 | console.log(`找到${task.length}节课程`) 43 | await pageToFile(task, article.course, pagePrint.path, pagePrint.fileType) 44 | } 45 | /** 46 | * 把文件进行打印 47 | * 48 | * @param {Array} articleList 文章列表 49 | * @param {String} course 打印的课程名称 (文件夹名称 50 | * @param {String} basePath 路径前缀 51 | * @param {String}} fileType 打印的类型 pdf png 52 | */ 53 | async function pageToFile(articleList, course, basePath, fileType) { 54 | try { 55 | // 路径处理 56 | if (basePath) { 57 | basePath = path.join(path.resolve(path.normalize(basePath)), course) 58 | } else { 59 | basePath = path.join(process.cwd(), course) 60 | } 61 | const err = fs.existsSync(basePath) 62 | if (!err) { 63 | await mkdir(basePath) 64 | } 65 | // 进度条 66 | const progressBar = new ProgressBar('打印中: :current/:total [:bar] :title', { 67 | complete: '=', 68 | width: 20, 69 | total: articleList.length 70 | }) 71 | // 这里也可以使用 Promise.all,但 cpu 和网络可能都吃紧,谨慎操作 72 | for (let i = 0, len = articleList.length; i < len; i++) { 73 | let articlePage = await browser.newPage() 74 | let a = articleList[i] 75 | const fileName = filterName(`${i}-${a.article_title}`) 76 | const fileFullName = `${fileName}.${fileType}`.replace(/\\|\/|:|\*|\?|"|<|>|\|/g, '') 77 | const fileFullPath = path.join(basePath, fileFullName) 78 | progressBar.tick({ title: a.article_title }) 79 | // 检查当前目录中是否存在该文件。 80 | try { 81 | await access(fileFullPath, fs.constants.F_OK) 82 | console.log(`${fileFullName} 已经存在, 进行下一个`) 83 | continue 84 | } catch (e) { 85 | // console.log('开始下载') 86 | } 87 | await setPageInfo(articlePage, a.href) 88 | await new Promise(res => setTimeout(res, 2000)) 89 | // 打印 90 | await printPage(articlePage, fileFullPath, fileType) 91 | articlePage.close() 92 | } 93 | console.log(`《${course}》: 任务完成`) 94 | return true 95 | } catch (error) { 96 | console.error('打印出错', error) 97 | } 98 | } 99 | 100 | async function setPageInfo(pageInstance, href) { 101 | await pageInstance.setViewport({ 102 | width: 1380, 103 | height: 800 104 | }) 105 | await pageInstance.setExtraHTTPHeaders({ 106 | Origin: 'https://time.geekbang.org', 107 | 'User-Agent': 108 | 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.119 Safari/537.36' 109 | }) 110 | await pageInstance.goto(href, { 111 | referer: 'https://time.geekbang.org/', 112 | waitUntil: 'networkidle0', 113 | timeout: 0 114 | }) 115 | 116 | await setCss(pageInstance) 117 | } 118 | 119 | async function printPage(pageInstance, fileFullPath, fileType) { 120 | if (fileType == 'pdf') { 121 | await pageInstance.pdf({ 122 | path: fileFullPath, 123 | height: 1080 + 'px', 124 | width: 920 + 'px' 125 | }) 126 | } else if (fileType == 'png') { 127 | await pageInstance.screenshot({ 128 | path: fileFullPath, 129 | type: 'png', 130 | fullPage: true 131 | }) 132 | } 133 | } 134 | /** 135 | *注入css,美化打印后的效果 136 | * 137 | * @param {*} pageInstance 138 | */ 139 | async function setCss(pageInstance) { 140 | await pageInstance.evaluate(async () => { 141 | const $ = document.querySelector.bind(document) 142 | const contentDom = $('#app > div >div:nth-child(2)') 143 | const hideDomMap = { 144 | // 去订阅文字 145 | goSubscriptionText: $('.bottom'), 146 | // 侧边栏菜单 147 | sideMenu: $('#app > div >div:nth-child(1)'), 148 | // 音频播放 149 | //audio: contentDom.querySelector('div:nth-child(2) > div:nth-child(1) > div:nth-child(3) > div:nth-child(2)'), 150 | audio: contentDom.querySelector( 151 | 'div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(3) > div:nth-child(2)' 152 | ), 153 | // 课程推荐 154 | courseAD: contentDom.querySelector( 155 | 'div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(3) > div:nth-last-child(2)' 156 | ), 157 | // 顶部菜单 158 | topBar: contentDom.querySelector('div'), 159 | // 评论输入框 160 | //commentInputBlock: $('#app > div >div:nth-child(2) > div:nth-child(2) > div:nth-child(1) > div:nth-child(4)'), 161 | commentInputBlock: contentDom.querySelector( 162 | 'div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(1) > div:nth-child(4)' 163 | ), 164 | // 两侧导航icon 165 | iconLeft: $('#app > div >div:nth-child(2) > div:nth-child(3)'), 166 | iconRight: $('#app > div >div:nth-child(2) > div:nth-child(4)') 167 | } 168 | const fixPosDomMap = { 169 | body: $('#app > div'), 170 | bodyChild: $('#app > div >div:nth-child(2)'), 171 | bodyLast: $('#app > div >div:nth-child(2) >div:nth-child(2)') 172 | } 173 | setStyleObjetc(hideDomMap, 'display', 'none') 174 | setStyleObjetc(fixPosDomMap, 'position', 'initial') 175 | 176 | function setStyleObjetc(obj, attr, value) { 177 | Object.values(obj).map(dom => { 178 | if (dom) { 179 | dom.style[attr] = value 180 | } 181 | }) 182 | } 183 | }) 184 | } 185 | 186 | function close() { 187 | page.close() 188 | browser.close() 189 | process.exit() 190 | } 191 | /** 192 | *格式化文件名,防止特殊字符导致错误 193 | * 194 | * @param {string} name 195 | * @returns 196 | */ 197 | function filterName(name) { 198 | const reg = /[`~!@#$%^&*()_+<>?:"{},./;'[\]]/gim 199 | return name.replace(reg, '') 200 | } 201 | exports.downArticle = downArticle 202 | exports.close = close 203 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | const program = require('commander') 2 | const inquirer = require('inquirer') 3 | const utils = require('./utils') 4 | const pkg = require('../package.json') 5 | const app = require('./app') 6 | const api = require('./api.js') 7 | const start = async () => { 8 | try { 9 | // 获取登录帐号信息 10 | if (!utils.getCookie().length) { 11 | await login() 12 | } 13 | 14 | // 获取课程列表 15 | const subList = await getList() 16 | console.log(subList.length ? `共查找到 ${subList.length} 门课程。` : '无已订阅课程') 17 | await searchPrint(subList) 18 | } catch (error) { 19 | console.error(error) 20 | } 21 | } 22 | 23 | async function login() { 24 | const account = await inquirer.prompt(utils.getAccountPromptList()) 25 | await api.login(account).catch(e => { 26 | console.log(e) 27 | return login() 28 | }) 29 | } 30 | 31 | async function getList() { 32 | let data 33 | while (!data) { 34 | try { 35 | data = await api.getList() 36 | break 37 | } catch (err) { 38 | await login() 39 | } 40 | } 41 | return data 42 | } 43 | 44 | /** 45 | * 搜索和打印课程 46 | * @param {Array} subList 搜索到的课程列表 47 | * @returns 48 | */ 49 | const searchPrint = async subList => { 50 | //Select course to print 51 | const courses = utils.getCoursePromptList(subList.map(item => item.title)) 52 | const { course } = await inquirer.prompt(courses) 53 | const { path } = await inquirer.prompt(utils.getCoursePathPromptList()) 54 | const { fileType } = await inquirer.prompt(utils.getOutputFileType()) 55 | await app.downArticle({ 56 | article: { 57 | course, 58 | subList 59 | }, 60 | pagePrint: { 61 | path, 62 | fileType 63 | } 64 | }) 65 | const { isRepeat } = await inquirer.prompt(utils.getIsRepeatType()) 66 | if (isRepeat) { 67 | searchPrint(subList) 68 | } else { 69 | return app.close() 70 | } 71 | } 72 | 73 | //command entry 74 | program 75 | .version(pkg.version, '-v, --version') 76 | .command('init') 77 | .description('打印 pdf 初始化需要填写信息:手机号码,密码。') 78 | .action(start) 79 | program.command('clear').description('清除账户信息。').action(utils.clear) 80 | program.parse(process.argv) 81 | if (process.argv.length < 3) { 82 | program.help() 83 | } 84 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | const jsonPath = path.resolve(__dirname, '../cookie.json') 4 | /** 5 | * 获取账号名密码配置 6 | * @returns {Array} 7 | */ 8 | exports.getAccountPromptList = function () { 9 | return [ 10 | //account number : cellphone 11 | { 12 | type: 'input', 13 | name: 'cellphone', 14 | message: '请输入你的手机号码:', 15 | validate: function (input) { 16 | // this.async() is inquirer use 17 | var done = this.async() 18 | if (isNaN(Number(input))) return done('手机号码必须是数字') 19 | return done(null, true) 20 | } 21 | }, 22 | //password 23 | { 24 | type: 'password', 25 | name: 'password', 26 | message: '请输入你的密码:', 27 | validate: function (input) { 28 | // this.async() is inquirer use 29 | var done = this.async() 30 | if (input.length < 6 || input.length > 24) { 31 | return done('请输入6-24位的密码') 32 | } 33 | return done(null, true) 34 | } 35 | }, 36 | //country code 37 | { 38 | type: 'number', 39 | name: 'countryCode', 40 | message: '请输入你的国家代码:', 41 | default: 86, 42 | validate: function (input) { 43 | // this.async() is inquirer use 44 | var done = this.async() 45 | if (isNaN(Number(input)) && input.length > 4) return done('国家代码必须是数字且不超过 4 位') 46 | return done(null, true) 47 | } 48 | } 49 | ] 50 | } 51 | 52 | /** 53 | * 搜索课程列表的配置 54 | * @param {Array} choices 55 | * @returns {Array} 56 | */ 57 | exports.getCoursePromptList = function (choices) { 58 | return [ 59 | { 60 | type: 'list', 61 | name: 'course', 62 | message: '请选择你要打印的课程:', 63 | choices 64 | } 65 | ] 66 | } 67 | 68 | /** 69 | * 目录配置 70 | * @returns {Array} 71 | */ 72 | exports.getCoursePathPromptList = function () { 73 | return [ 74 | { 75 | type: 'input', 76 | name: 'path', 77 | message: '请输入你想要输出的目录(默认会在当前目录下创建课程目录):' 78 | } 79 | ] 80 | } 81 | 82 | /** 83 | * 输出类型配置 84 | * @returns {Array} 85 | */ 86 | exports.getOutputFileType = function () { 87 | return [ 88 | { 89 | type: 'rawlist', 90 | name: 'fileType', 91 | choices: ['pdf', 'png'], 92 | default: 'pdf', 93 | message: '输出的文件类型(默认 pdf ):' 94 | } 95 | ] 96 | } 97 | /** 98 | * 输出类型配置 99 | * @returns {Array} 100 | */ 101 | exports.getIsRepeatType = function () { 102 | return [ 103 | { 104 | type: 'confirm', 105 | name: 'isRepeat', 106 | default: 'Y', 107 | message: '是否继续搜索课程:' 108 | } 109 | ] 110 | } 111 | /** 112 | * 保存cookie到配置文件 113 | * @returns {undefined} 114 | */ 115 | exports.saveCookie = function (cookieArr) { 116 | cookieArr = cookieArr.map(item => { 117 | // 在puppeteer设置cookie的时候必须设置url,去掉时间,避免 118 | item.url = 'https://time.geekbang.org' 119 | delete item.expires 120 | delete item.maxAge 121 | return item 122 | }) 123 | const str = JSON.stringify(cookieArr, null, 2) 124 | fs.writeFileSync(path.resolve(__dirname, '../cookie.json'), str) 125 | } 126 | /** 127 | * 取出cookie到配置文件 128 | * @returns {Array} 129 | */ 130 | exports.getCookie = function () { 131 | if (!fs.existsSync(jsonPath)) return [] 132 | const cookieStr = fs.readFileSync(jsonPath, 'utf8') 133 | return JSON.parse(cookieStr) 134 | } 135 | /** 136 | * 调试用 137 | * @returns {undefined} 138 | */ 139 | exports.savePage = function (page) { 140 | fs.writeFileSync(path.resolve(__dirname, '../page.html'), page) 141 | } 142 | 143 | exports.clear = function () { 144 | fs.writeFileSync(jsonPath, '[]') 145 | } 146 | --------------------------------------------------------------------------------