├── config.json ├── .gitignore ├── .github └── FUNDING.yml ├── stylesheet.css ├── package.json ├── LICENSE ├── README.md ├── .eslintrc.json └── index.js /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "keepTemp": false, 3 | "maxInProgress": 50, 4 | "maxRetries": 5, 5 | "minBytes": 50000, 6 | "outputPath": "C:/eguides", 7 | "overwrite": false 8 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | cookies.json 2 | guides/* 3 | *.bat 4 | *.log 5 | node_modules 6 | package-lock.json 7 | .git/* 8 | .vscode/* 9 | !.vscode/settings.json 10 | !.vscode/tasks.json 11 | !.vscode/launch.json 12 | !.vscode/extensions.json 13 | *.code-workspace 14 | */.DS_Store 15 | .DS_Store -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: Revadike 2 | patreon: Revadike 3 | custom: [ 4 | "https://steamcommunity.com/tradeoffer/new/?partner=82699538&token=V7DQVtra", 5 | "https://www.paypal.me/Revadike", 6 | "https://coinrequest.io/request/mBAhcRTlknrpSJZ", 7 | "https://www.blockchain.com/btc/address/149MawSVcw2gzNNbwhW84ZCwpHY4rU2uUB" 8 | ] 9 | -------------------------------------------------------------------------------- /stylesheet.css: -------------------------------------------------------------------------------- 1 | * { 2 | -webkit-print-color-adjust: exact; 3 | } 4 | 5 | #content article>section table.tablesorter>thead>tr .header { 6 | background-image: none !important; 7 | zoom: 0%; 8 | } 9 | 10 | @page, 11 | #content article { 12 | size: auto; 13 | } 14 | 15 | @media print { 16 | .page { 17 | page-break-after: always; 18 | } 19 | } 20 | 21 | .button.annotation-count, 22 | header>div.topright>div.buttons, 23 | #content>div.footer, 24 | input, 25 | tr.table-options>td>div, 26 | .tableFloatingHeader, 27 | .left-gutter-outer { 28 | display: none !important; 29 | visibility: hidden !important; 30 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "offline-primagames-eguides", 3 | "version": "1.2.7", 4 | "description": "Download your e-guides from primagames for offline use.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "start": "node index.js" 9 | }, 10 | "author": "Revadike ", 11 | "license": "MIT", 12 | "repository": { 13 | "type": "git", 14 | "url": "git+https://github.com/revadike/offline-primagames-eguides.git" 15 | }, 16 | "bugs": { 17 | "url": "https://github.com/revadike/offline-primagames-eguides/issues" 18 | }, 19 | "homepage": "https://github.com/revadike/offline-primagames-eguides", 20 | "devDependencies": { 21 | "eslint": "^6.8.0" 22 | }, 23 | "dependencies": { 24 | "fs-extra": "^10.1.0", 25 | "pdf-merger-js": "^4.1.1", 26 | "promise-parallel-throttle": "^3.3.0", 27 | "puppeteer": "^12.0.1" 28 | } 29 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Revadike 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 | # Offline PrimaGames eGuides 2 | ![](https://eguides.primagames.com/localstatic/img/logo.png) 3 | 4 | ## Description 5 | Download [your eGuides from PrimaGames](https://eguides.primagames.com/accounts/account/my_guides) for offline use. 6 | 7 | ## Requirements 8 | * [Node.js](https://nodejs.org/en/) 9 | * [EditThisCookie](http://www.editthiscookie.com/) or [Cookie-Editor](https://cookie-editor.cgagnier.ca/) 10 | * ☆ Star this repository 11 | 12 | ## Instructions 13 | 1. Download/clone this repository 14 | 2. Run `npm install` 15 | 3. Edit `config.json` to change settings, like output path 16 | 4. [Login to PrimaGames](https://eguides.primagames.com/accounts/login) 17 | 5. Export your cookies and save them to `cookies.json`
Format: `[{name, value, domain, path}, { ... }, ...]` 18 | 6. Run `node index.js` or `npm start` 19 | 20 | ## Tips 21 | 1. If it fails and the `overwrite` option is set to false, simply restart and it keep your previous progression. 22 | 2. Because all raw web data is being stored in the output pdf, I highly recommend compressing the pdf's afterwards. Example with [ghostscript](https://www.ghostscript.com/releases/gsdnld.html): 23 | ```bash 24 | MKDIR C:\eguides\compressed 25 | FOR %i IN (C:\eguides\*.pdf) DO start gswin64c -sDEVICE=pdfwrite -dCompatibilityLevel=1.4 -dPDFSETTINGS=/screen -dNOPAUSE -dBATCH -sOutputFile="C:\eguides\compressed\%~ni.pdf" "C:\eguides\%~ni.pdf" 26 | ``` 27 | 28 | ## Warning 29 | Depending on your settings and number of guide, this can be quite resource-intensive, since it runs a headless browser in the background. 30 | Ensure you allocate enough system resources and space beforehand! 31 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "es6": true 6 | }, 7 | "extends": "eslint:recommended", 8 | "parserOptions": { 9 | "ecmaVersion": 2018 10 | }, 11 | "rules": { 12 | "accessor-pairs": "error", 13 | "array-bracket-newline": ["error", "consistent"], 14 | "array-bracket-spacing": ["error", "never", { "arraysInArrays": true }], 15 | "array-callback-return": "error", 16 | "array-element-newline": ["error", "consistent"], 17 | "arrow-body-style": "error", 18 | "arrow-parens": ["error", "as-needed"], 19 | "arrow-spacing": "error", 20 | "block-scoped-var": "error", 21 | "block-spacing": "error", 22 | "brace-style": ["error", "1tbs", { "allowSingleLine": true }], 23 | "camelcase": "error", 24 | "class-methods-use-this": "error", 25 | "comma-dangle": ["error", "only-multiline"], 26 | "comma-spacing": "error", 27 | "comma-style": "error", 28 | "complexity": "error", 29 | "computed-property-spacing": "error", 30 | "consistent-return": "error", 31 | "consistent-this": "error", 32 | "curly": "error", 33 | "default-param-last": "error", 34 | "dot-location": ["error", "property"], 35 | "dot-notation": "error", 36 | "eol-last": "error", 37 | "eqeqeq": ["error", "smart"], 38 | "func-call-spacing": "error", 39 | "func-name-matching": "error", 40 | "func-names": "error", 41 | "func-style": ["error", "declaration", { "allowArrowFunctions": true }], 42 | "function-call-argument-newline": ["error", "consistent"], 43 | "function-paren-newline": ["error", "consistent"], 44 | "generator-star-spacing": "error", 45 | "grouped-accessor-pairs": ["error", "getBeforeSet"], 46 | "implicit-arrow-linebreak": "error", 47 | "indent": ["error", 4, { "SwitchCase": 1 }], 48 | "init-declarations": "error", 49 | "key-spacing": ["error", { "mode": "minimum", "align": "value" }], 50 | "keyword-spacing": "error", 51 | "linebreak-style": "error", 52 | "lines-between-class-members": ["error", "always"], 53 | "max-classes-per-file": "error", 54 | "max-depth": "error", 55 | "max-len": ["error", 130], 56 | "max-nested-callbacks": ["error", 4], 57 | "max-statements-per-line": ["error", { "max": 2 }], 58 | "new-parens": "error", 59 | "newline-per-chained-call": "error", 60 | "no-alert": "error", 61 | "no-array-constructor": "error", 62 | "no-caller": "error", 63 | "no-case-declarations": "error", 64 | "no-confusing-arrow": "error", 65 | "no-constructor-return": "error", 66 | "no-dupe-else-if": "error", 67 | "no-duplicate-imports": "error", 68 | "no-else-return": "error", 69 | "no-empty-function": "error", 70 | "no-eq-null": "error", 71 | "no-eval": "error", 72 | "no-extend-native": "error", 73 | "no-floating-decimal": "error", 74 | "no-implicit-coercion": "error", 75 | "no-implied-eval": "error", 76 | "no-invalid-this": "error", 77 | "no-iterator": "error", 78 | "no-labels": "error", 79 | "no-lone-blocks": "error", 80 | "no-lonely-if": "error", 81 | "no-loop-func": "error", 82 | "no-mixed-operators": "error", 83 | "no-multi-assign": "error", 84 | "no-multi-spaces": "error", 85 | "no-multi-str": "error", 86 | "no-multiple-empty-lines": ["error", { "max": 1, "maxEOF": 0, "maxBOF": 0 }], 87 | "no-negated-condition": "error", 88 | "no-nested-ternary": "error", 89 | "no-new": "error", 90 | "no-new-func": "error", 91 | "no-new-object": "error", 92 | "no-new-wrappers": "error", 93 | "no-octal": "error", 94 | "no-octal-escape": "error", 95 | "no-param-reassign": "error", 96 | "no-proto": "error", 97 | "no-redeclare": "error", 98 | "no-return-assign": "error", 99 | "no-return-await": "error", 100 | "no-script-url": "error", 101 | "no-self-compare": "error", 102 | "no-sequences": "error", 103 | "no-setter-return": "error", 104 | "no-tabs": "error", 105 | "no-template-curly-in-string": "error", 106 | "no-throw-literal": "error", 107 | "no-trailing-spaces": "error", 108 | "no-undef-init": "error", 109 | "no-undefined": "error", 110 | "no-unmodified-loop-condition": "error", 111 | "no-unneeded-ternary": ["error", { "defaultAssignment": false }], 112 | "no-unused-expressions": "error", 113 | "no-use-before-define": "error", 114 | "no-useless-call": "error", 115 | "no-useless-computed-key": "error", 116 | "no-useless-concat": "error", 117 | "no-useless-constructor": "error", 118 | "no-useless-rename": "error", 119 | "no-useless-return": "error", 120 | "no-var": "error", 121 | "no-void": "error", 122 | "no-with": "error", 123 | "no-whitespace-before-property": "error", 124 | "nonblock-statement-body-position": "error", 125 | "object-curly-newline": "error", 126 | "object-curly-spacing": ["error", "always"], 127 | "object-property-newline": ["error", { "allowAllPropertiesOnSameLine": true }], 128 | "object-shorthand": "error", 129 | "one-var-declaration-per-line": "error", 130 | "operator-assignment": "error", 131 | "operator-linebreak": ["error", "before"], 132 | "padded-blocks": ["error", { "blocks": "never", "classes": "always", "switches": "never" }, { "allowSingleLineBlocks": true }], 133 | "padding-line-between-statements": [ 134 | "error", 135 | { "blankLine": "always", "prev": "function", "next": "function" } 136 | ], 137 | "prefer-arrow-callback": "error", 138 | "prefer-destructuring": ["error", { "array": false, "object": true }], 139 | "prefer-exponentiation-operator": "error", 140 | "prefer-numeric-literals": "error", 141 | "prefer-object-spread": "error", 142 | "prefer-promise-reject-errors": "error", 143 | "prefer-regex-literals": "error", 144 | "prefer-rest-params": "error", 145 | "prefer-spread": "error", 146 | "prefer-template": "error", 147 | "quote-props": "error", 148 | "quotes": ["error", "double", { "avoidEscape": true }], 149 | "radix": ["error", "as-needed"], 150 | "require-await": "error", 151 | "rest-spread-spacing": "error", 152 | "semi": ["error", "always"], 153 | "semi-spacing": "error", 154 | "semi-style": "error", 155 | "sort-imports": "error", 156 | "space-before-blocks": "error", 157 | "space-before-function-paren": ["error", "never"], 158 | "space-in-parens": "error", 159 | "space-infix-ops": "error", 160 | "space-unary-ops": "error", 161 | "spaced-comment": "error", 162 | "strict": ["error", "global"], 163 | "switch-colon-spacing": "error", 164 | "symbol-description": "error", 165 | "template-curly-spacing": "error", 166 | "template-tag-spacing": "error", 167 | "unicode-bom": "error", 168 | "vars-on-top": "error", 169 | "wrap-iife": ["error", "inside"], 170 | "yield-star-spacing": "error", 171 | "yoda": "error" 172 | } 173 | } -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const IO = require("fs-extra"); 2 | const PDFMerger = require("pdf-merger-js"); 3 | const Throttle = require("promise-parallel-throttle"); 4 | const Puppeteer = require("puppeteer"); 5 | const { keepTemp, maxInProgress, maxRetries, minBytes, outputPath, overwrite } = require("./config.json"); 6 | 7 | async function newPage(browser, cookies) { 8 | let page = await browser.newPage(); 9 | await page.setJavaScriptEnabled(true); 10 | await page.setDefaultNavigationTimeout(90000); 11 | await page.setCookie(...cookies.map(c => ({ "name": c.name, "value": c.value, "domain": c.domain, "path": c.path }))); 12 | if (page.emulateMedia) { 13 | await page.emulateMedia("print"); 14 | } else if (page.emulateMediaType) { 15 | await page.emulateMediaType("print"); 16 | } 17 | return page; 18 | } 19 | 20 | async function ensureGoTo(page, url, retries = 0) { 21 | let retry = retries; 22 | let response = await page.goto(url, { "waitUntil": "networkidle0" }).catch(() => false); 23 | 24 | while (response && response.status() !== 200 && retry < maxRetries) { 25 | if (page.waitForTimeout) { 26 | await page.waitForTimeout(10000); 27 | } else { 28 | await page.wait(10000); 29 | } 30 | retry++; 31 | response = await page.reload().catch(() => false); 32 | } 33 | 34 | if (!response && retry < maxRetries) { 35 | if (page.waitForTimeout) { 36 | await page.waitForTimeout(10000); 37 | } else { 38 | await page.wait(10000); 39 | } 40 | let newPage = await ensureGoTo(page, url, ++retry); 41 | return newPage; 42 | } 43 | 44 | return retry < maxRetries ? page : false; 45 | } 46 | 47 | async function ensurePDFSize(page, path, height, width) { 48 | if (page.waitForTimeout) { 49 | await page.waitForTimeout(1000); 50 | } else { 51 | await page.wait(1000); 52 | } 53 | await page.pdf({ path, height, width, "printBackground": true }); 54 | 55 | let retries = 0; 56 | let { size } = await IO.stat(path); 57 | while (size < minBytes && retries < maxRetries) { 58 | if (page.waitForTimeout) { 59 | await page.waitForTimeout(1000); 60 | } else { 61 | await page.wait(1000); 62 | } 63 | await page.pdf({ path, height, width, "printBackground": true, "timeout": 300000 }); 64 | 65 | retries++; 66 | ({ size } = await IO.stat(path)); 67 | } 68 | 69 | return retries >= maxRetries; 70 | } 71 | 72 | async function convertToPDF(tab, url, name, i, stylesheet) { 73 | let filename = `${i}.pdf`; 74 | let path = `${outputPath}/${name}/${filename}`; 75 | 76 | if (!overwrite && await IO.pathExists(path)) { 77 | return path; 78 | } 79 | 80 | await IO.ensureDir(path.replace(filename, "")); 81 | let page = await ensureGoTo(tab, url); 82 | if (!page) { 83 | return false; 84 | } 85 | 86 | await page.addStyleTag({ "content": stylesheet }); 87 | let { height, width } = await page.evaluate(() => { 88 | let result = { 89 | "height": { 90 | "value": 0, 91 | "estimated": false 92 | }, 93 | "width": { 94 | "value": 0, 95 | "estimated": false 96 | } 97 | }; 98 | 99 | let article = document.querySelector("#content article"); 100 | if (article) { 101 | result.height.value = article.scrollHeight; 102 | result.width.value = article.scrollWidth; 103 | } else { 104 | result.height.estimated = true; 105 | result.width.estimated = true; 106 | let main = document.querySelector("[tabindex=\"0\"]"); 107 | let content = document.querySelector("#content"); 108 | // best height estimate: 109 | result.height.value = Math.max( 110 | main ? main.scrollHeight : 0, 111 | content ? content.scrollHeight : 0, 112 | document.body.scrollHeight 113 | ); 114 | // best width estimate: 115 | result.width.value = Math.max( 116 | main ? main.scrollWidth : 0, 117 | content ? content.scrollWidth : 0, 118 | document.body.scrollWidth 119 | ); 120 | } 121 | 122 | let header = document.querySelector("body > header"); 123 | if (header) { 124 | result.height.value += header.scrollHeight; 125 | } else { 126 | result.height.estimated = true; 127 | result.height.value += 90; // header estimated height 128 | } 129 | 130 | result.height.value += 35; // 35 is bottom margin of article 131 | result.height.value += "px"; 132 | result.width.value += "px"; 133 | return result; 134 | }); 135 | 136 | if (height.estimated) { 137 | console.log(`Notice - The following page has a non-standard height: ${url}`); 138 | } 139 | 140 | if (width.estimated) { 141 | console.log(`Notice - The following page has a non-standard width: ${url}`); 142 | } 143 | 144 | await ensurePDFSize(page, path, height.value, width.value); 145 | return path; 146 | } 147 | 148 | async function scrapeGuide(guide, browser, cookies, stylesheet) { 149 | let { url, title } = guide; 150 | let path = `${outputPath}/${title}.pdf`; 151 | if (!overwrite && await IO.pathExists(path)) { 152 | console.log(path); 153 | return; 154 | } 155 | 156 | let merger = new PDFMerger(); 157 | merger.loadOptions = { 158 | "ignoreEncryption": true, 159 | "throwOnInvalidObject": false 160 | }; 161 | let page = await newPage(browser, cookies); 162 | page = await ensureGoTo(page, url); 163 | if (!page) { 164 | console.log(`Failed to fetch ${url}`); 165 | return; 166 | } 167 | 168 | let pages = await page.evaluate(() => [...document.querySelectorAll("#toc a[data-section-id]")].map(e => e.href)); 169 | if (pages.length === 0) { 170 | console.log(`No pages found for ${title}`); 171 | return; 172 | } 173 | 174 | for (let i = 1; i <= pages.length; i++) { 175 | let path = await convertToPDF(page, pages[i - 1], title, i, stylesheet); 176 | if (path) { 177 | merger.add(path); 178 | console.log(path); 179 | } else { 180 | console.log(`Failed to convert to pdf ${pages[i - 1]}`); 181 | } 182 | } 183 | 184 | if (page.waitForTimeout) { 185 | await page.waitForTimeout(2000); 186 | } else { 187 | await page.wait(2000); 188 | } 189 | await merger.save(path); 190 | await page.close(); 191 | console.log(path); 192 | 193 | if (!keepTemp) { 194 | await IO.remove(`${outputPath}/${title}/`); 195 | } 196 | } 197 | 198 | (async() => { 199 | const stylesheet = await IO.readFile("stylesheet.css", "utf8"); 200 | const cookies = await IO.readJSON("cookies.json"); 201 | const browser = await Puppeteer.launch(); 202 | 203 | let page = await newPage(browser, cookies); 204 | page = await ensureGoTo(page, "https://eguides.primagames.com/accounts/account/my_guides"); 205 | if (!page) { 206 | throw new Error("Unable to fetch PrimaGames eGuides"); 207 | } 208 | 209 | let guides = await page.evaluate(() => [...document.querySelectorAll("a.cover")].map(e => ({ 210 | "url": e.href, 211 | "title": e.nextSiblings(".title")[0].innerText.replace(/[^A-Za-z0-9 ]+/g, "").replace(/[ ]+/g, " ") 212 | }))); 213 | 214 | await page.close(); 215 | console.log(`Found ${guides.length} eGuides`); 216 | 217 | await Throttle.all(guides.map(guide => () => scrapeGuide(guide, browser, cookies, stylesheet)), { maxInProgress }); 218 | await browser.close(); 219 | process.exit(0); 220 | })().catch(err => { 221 | console.log(err); 222 | process.exit(1); 223 | }); 224 | --------------------------------------------------------------------------------