├── .gitignore ├── LICENSE ├── README.md ├── config.json ├── lib ├── dd.js ├── decodeJPEGfromStream.js ├── get-seed.js ├── http-client.js ├── piccoma-fr.js ├── piccoma.js ├── unscramble.js └── unscrambleImg.js ├── package-lock.json ├── package.json ├── piccoma.js ├── usage-list.png └── usage.png /.gitignore: -------------------------------------------------------------------------------- 1 | data 2 | manga 3 | node_modules 4 | dist 5 | \.* -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 jude 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. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Piccoma-downloader 2 | Download manga from [Piccoma](https://piccoma.com/) 3 | 4 | ## Basic Usage 5 | 1. Download [release](https://github.com/Elastic1/piccoma-downloader/releases) 6 | 2. Run `./piccoma.exe` 7 | 3. Enter your mail and password 8 | 4. Select mangas from the bookmarks list (all books are selected by default). 9 | ![usage-list](usage-list.png) 10 | 11 | 5. Wait for the download to finish. 12 | ![usage](usage.png) 13 | 14 | ## Options 15 | 16 | Examples: 17 | ``` 18 | ./piccoma.exe --config config.json 19 | ``` 20 | ``` 21 | ./piccoma.exe --mail user@example.com --password mypassword --all 22 | ``` 23 | ``` 24 | ./piccoma.exe --sessionid 25 | ``` 26 | 27 | #### `-h, --help` 28 | Display help message 29 | #### `--type` 30 | `jp` or `fr` (default: jp) 31 | #### `--mail` 32 | Account mail 33 | #### `--password` 34 | Account password 35 | #### `--sessionid` 36 | Session id of your piccoma login. For accounts that do not support email address login. Only for JP version. 37 | #### `--all` 38 | Download all mangas in bookmarks. If not specified, the selection cli will be displayed. 39 | #### `--manga` 40 | `chapter` or `volume` for manga (default: volume) 41 | #### `--webtoon` 42 | `chapter` or `volume` for webtoon (default: chapter) 43 | #### `--timeout` 44 | Maximum navigation time in milliseconds. If `0` no timeout. (default: 60000ms) 45 | #### `--use-free` 46 | Try to use one free ticket 47 | #### `--format` 48 | `png` or `jpg` (default: png) 49 | #### `--quality` 50 | jpg quality(default: 85) 51 | #### `--out` 52 | Output directory (default: manga) 53 | #### `--config` 54 | Path of config file. You can set the cli options in config file. Here's a [sample](https://github.com/Elastic1/piccoma-downloader/blob/main/config.json) 55 | #### `--chapter-url` 56 | Download chapter url 57 | #### `--limit` 58 | max concurrency limit (default: 2) 59 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "jp", 3 | "mail": "", 4 | "password": "", 5 | "sessionid": "", 6 | "manga": "volume", 7 | "webtoon": "chapter", 8 | "all": true, 9 | "timeout": 60000, 10 | "useFree": true, 11 | "format": "png", 12 | "out": "manga" 13 | } -------------------------------------------------------------------------------- /lib/dd.js: -------------------------------------------------------------------------------- 1 | const magic = 'AGFzbQEAAAABBgFgAn9/AAMCAQAFAwEAEQcPAgZtZW1vcnkCAAJkZAAACk0BSwECfwNAIAEgAkcEQEEBIAJ0QcfFxQFxRSACQRVLckUEQCAAIAJqIgMgAy0AACIDIANBAXRBAnFrQQFqOgAACyACQQFqIQIMAQsLCwA7CXByb2R1Y2VycwEMcHJvY2Vzc2VkLWJ5AgZ3YWxydXMGMC4yMC4zDHdhc20tYmluZGdlbgYwLjIuODk=' 2 | 3 | let wasm; 4 | 5 | async function init() { 6 | if (wasm != null) { 7 | return; 8 | } 9 | const buf = Buffer.from(magic, 'base64') 10 | const res = await WebAssembly.instantiate(buf, {}) 11 | wasm = res.instance.exports 12 | } 13 | 14 | export default async function dd(seed) { 15 | await init(); 16 | const enc = new TextEncoder("utf-8"); 17 | const bytes = new Uint8Array(wasm.memory.buffer, 0, seed.length); 18 | bytes.set(enc.encode(seed)); 19 | wasm.dd(bytes.byteOffset, bytes.length) 20 | const dec = new TextDecoder("utf-8"); 21 | return dec.decode(bytes); 22 | } -------------------------------------------------------------------------------- /lib/decodeJPEGfromStream.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import JPEG from 'jpeg-js' 3 | import { Bitmap } from 'pureimage/src/bitmap.js' 4 | 5 | export function decodeJPEGFromStream(data) { 6 | return new Promise((res, rej) => { 7 | try { 8 | const chunks = [] 9 | data.on('data', chunk => chunks.push(chunk)) 10 | data.on('end', () => { 11 | const buf = Buffer.concat(chunks) 12 | let rawImageData = null 13 | try { 14 | rawImageData = JPEG.decode(buf, { maxMemoryUsageInMB: 1024 }) 15 | } catch (err) { 16 | rej(err) 17 | return 18 | } 19 | const bitmap = new Bitmap(rawImageData.width, rawImageData.height, {}) 20 | for (let x_axis = 0; x_axis < rawImageData.width; x_axis++) { 21 | for (let y_axis = 0; y_axis < rawImageData.height; y_axis++) { 22 | const n = (y_axis * rawImageData.width + x_axis) * 4 23 | bitmap.setPixelRGBA_i(x_axis, y_axis, 24 | rawImageData.data[n + 0], 25 | rawImageData.data[n + 1], 26 | rawImageData.data[n + 2], 27 | rawImageData.data[n + 3] 28 | ) 29 | } 30 | } 31 | res(bitmap) 32 | }) 33 | data.on("error", (err) => { 34 | rej(err) 35 | }) 36 | } catch (e) { 37 | console.log(e) 38 | rej(e) 39 | } 40 | }) 41 | } -------------------------------------------------------------------------------- /lib/get-seed.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import dd from './dd.js' 3 | 4 | function getChecksum(path) { 5 | return path.split('/').slice(-2)[0] 6 | } 7 | 8 | function getSeedInternal(checksum, expires) { 9 | const total = expires.split('').reduce((total, num2) => total + parseInt(num2), 0) 10 | const ch = total % checksum.length 11 | return checksum.slice(ch * -1) + checksum.slice(0, ch * -1) 12 | } 13 | 14 | export default async function getSeed(url) { 15 | const isFr = /cdn\.fr\.piccoma\.com/.test(url); 16 | const checksum = isFr ? new URL(url).searchParams.get('q') : getChecksum(url) 17 | const expires = new URL(url).searchParams.get('expires') 18 | const seed = getSeedInternal(checksum, expires) 19 | return await dd(seed) 20 | } -------------------------------------------------------------------------------- /lib/http-client.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import axios from 'axios' 3 | import { wrapper } from 'axios-cookiejar-support' 4 | import { CookieJar } from 'tough-cookie' 5 | 6 | class HttpClient { 7 | constructor(options) { 8 | this.options = options 9 | this.jar = new CookieJar() 10 | this.jar.setCookieSync(`sessionid=${options.sessionid}`, 'https://piccoma.com/') 11 | this.client = wrapper(axios.create({ jar: this.jar, timeout: options.timeout })) 12 | } 13 | 14 | get(url) { 15 | return this.request(url, { method: 'GET' }) 16 | } 17 | 18 | post(url, data) { 19 | return this.request(url, { method: 'POST', data }) 20 | } 21 | 22 | request(url, options) { 23 | const headers = { 24 | ...options.headers, 25 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:102.0) Gecko/20100101 Firefox/102.0', 26 | 'Referer': url, 27 | } 28 | const csrfTokenCookie = this.jar.getCookiesSync('https://piccoma.com/').find(cookie => cookie.key == 'csrftoken') 29 | if (csrfTokenCookie != null) { 30 | headers['X-CSRFToken'] = csrfTokenCookie.value 31 | } 32 | return this.client.request({ 33 | ...options, 34 | url, 35 | headers, 36 | withCredentials: true, 37 | }) 38 | } 39 | } 40 | 41 | export default HttpClient -------------------------------------------------------------------------------- /lib/piccoma-fr.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import * as cheerio from 'cheerio' 3 | import path from 'path' 4 | import Piccoma from './piccoma.js' 5 | export default class PiccomaFr extends Piccoma { 6 | async login(email, password) { 7 | console.log('login...') 8 | const params = new URLSearchParams() 9 | params.set('redirect', '/fr/') 10 | params.set('email', email) 11 | params.set('password', password) 12 | await this.client.post('https://piccoma.com/fr/api/auth/signin', params) 13 | } 14 | 15 | async getBookmarks() { 16 | const bookmarks = [] 17 | let page = 1 18 | for (let i = 1; i <= page; i++) { 19 | const url = `https://piccoma.com/fr/api/haribo/api/bookshelf/products?bookshelf_type=B&page=${i}` 20 | const res = await this.client.get(url) 21 | if (res.data.data.next != null) { 22 | page = res.data.data.next 23 | } 24 | bookmarks.push(...res.data.data.products.map(pr => { 25 | return { 26 | id: pr.id, 27 | url: `https://piccoma.com/fr/product/episode/${pr.id}`, 28 | title: this._sanatizePath(pr.title), 29 | webtoon: pr.icon_type == 'U' 30 | } 31 | })) 32 | } 33 | return bookmarks 34 | } 35 | 36 | async getEpisodes(id) { 37 | const url = `https://piccoma.com/fr/product/episode/${id}` 38 | const res = await this.client.get(url) 39 | const $ = await cheerio.load(res.data) 40 | const __NEXT_DATA__ = this._getNextData($) 41 | const { category_id, first_episode_id, first_volume_id } = __NEXT_DATA__.props.pageProps.initialState.productHome.productHome.product 42 | const bookType = category_id == 2 ? this.options.webtoon : this.options.manga 43 | if ((bookType == 'volume' || first_episode_id == null) && first_volume_id != null) { 44 | return this.getVolumes(id) 45 | } 46 | const episodes = __NEXT_DATA__.props.pageProps.initialState.episode.episodeList.episode_list 47 | return episodes 48 | .filter(ep => ['FR01', 'RD01', 'AB01'].includes(ep.use_type)) 49 | .map(ep => ({ 50 | id: ep.id, 51 | url: `https://piccoma.com/fr/viewer/${id}/${ep.id}`, 52 | name: this._sanatizePath(ep.title) 53 | })) 54 | } 55 | 56 | async getVolumes(id) { 57 | const url = `https://piccoma.com/fr/product/volume/${id}` 58 | const res = await this.client.get(url) 59 | const $ = await cheerio.load(res.data) 60 | const __NEXT_DATA__ = this._getNextData($) 61 | const episodes = __NEXT_DATA__.props.pageProps.initialState.episode.episodeList.episode_list 62 | return episodes 63 | .filter(ep => ['FR01', 'RD01', 'AB01'].includes(ep.use_type)) 64 | .map(ep => ({ 65 | id: ep.id, 66 | url: `https://piccoma.com/fr/viewer/${id}/${ep.id}`, 67 | name: this._sanatizePath(ep.title) 68 | })) 69 | } 70 | 71 | async saveEpisode(url, dist, progress) { 72 | const res = await this.client.get(url) 73 | const $ = await cheerio.load(res.data) 74 | const pdata = await this._getPdata($, url) 75 | if (pdata == null) { 76 | console.log('failed to get image list.') 77 | return 78 | } 79 | await this._saveEpisodeImages(pdata, dist, progress) 80 | } 81 | 82 | async saveEpisodeDirect(url, volumeRename) { 83 | const res = await this.client.get(url) 84 | const $ = await cheerio.load(res.data) 85 | const pdata = await this._getPdata($, url) 86 | if (pdata == null) { 87 | console.log('failed to get image list.') 88 | return 89 | } 90 | const title = this._getTitle($) 91 | const chapterTitle = this._sanatizePath(pdata.title) 92 | if (volumeRename == null) { 93 | volumeRename = title; 94 | } 95 | const dist = path.resolve(this.options.out, volumeRename, chapterTitle) 96 | await this._saveEpisodeImages(pdata, dist, (current, imgLen) => { 97 | process.stdout.write(`\r- ${volumeRename} ${chapterTitle} ${current}/${imgLen}`) 98 | }) 99 | } 100 | 101 | _getTitle($) { 102 | const __NEXT_DATA__ = this._getNextData($) 103 | return __NEXT_DATA__.props.pageProps.initialState.productHome.productHome.product.title 104 | } 105 | 106 | async _getPdata($, chapterURL) { 107 | const __NEXT_DATA__ = this._getNextData($) 108 | const params = new URL(chapterURL).pathname.split('/') 109 | const episodeId = params[4] 110 | const productId = params[3] 111 | const url = `https://piccoma.com/fr/_next/data/${__NEXT_DATA__.buildId}/fr/viewer/${productId}/${episodeId}.json` 112 | const res = await this.client.get(url) 113 | return res.data.pageProps.initialState.viewer.pData 114 | } 115 | 116 | _getNextData($) { 117 | return JSON.parse($('#__NEXT_DATA__').text()) 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /lib/piccoma.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import * as cheerio from 'cheerio' 3 | import fs from 'fs' 4 | import os from 'os' 5 | import pLimit from 'p-limit' 6 | import path from 'path' 7 | import PureImage from 'pureimage' 8 | import FormData from 'form-data' 9 | import { decodeJPEGFromStream } from './decodeJPEGfromStream.js' 10 | import getSeed from './get-seed.js' 11 | import HttpClient from './http-client.js' 12 | import unscramble from './unscramble.js' 13 | export default class Piccoma { 14 | constructor(options) { 15 | this.options = options 16 | this.client = new HttpClient(options) 17 | this.platform = os.platform() 18 | } 19 | 20 | async checkAuth() { 21 | await this.client.get('https://piccoma.com/web/acc/email/signin') // for csrf cookie 22 | const res = await this.client.get('https://piccoma.com/web/acc/top') 23 | const $ = await cheerio.load(res.data) 24 | return $('.PCM-loginMenu').length == 0 25 | } 26 | 27 | async login(email, password) { 28 | console.log('login...') 29 | const res = await this.client.get('https://piccoma.com/web/acc/email/signin') 30 | const $ = cheerio.load(res.data) 31 | const params = new URLSearchParams() 32 | const csrfToken = $('[name=csrfmiddlewaretoken]').val() ?? '' 33 | params.set('csrfmiddlewaretoken', String(csrfToken)) 34 | params.set('next_url', '/web/') 35 | params.set('email', email) 36 | params.set('password', password) 37 | await this.client.post('https://piccoma.com/web/acc/email/signin', params) 38 | } 39 | 40 | async getBookmarks() { 41 | const res = await this.client.get('https://piccoma.com/web/bookshelf/list?type=B') 42 | const productIds = res.data.data.bookmark.map(book => book.id).join(',') 43 | const formData = new FormData() 44 | formData.append('products', productIds) 45 | const resProduct = await this.client.post('https://piccoma.com/web/bookshelf/product', formData) 46 | return resProduct.data.data.products.map(product => { 47 | return { 48 | id: product.id, 49 | url: `https://piccoma.com/web/product/${product.id}`, 50 | title: this._sanatizePath(product.title), 51 | webtoon: product.is_smartoon === 1, 52 | } 53 | }) 54 | } 55 | 56 | async getEpisodes(id, webtoon) { 57 | const bookType = webtoon ? this.options.webtoon : this.options.manga 58 | const etype = bookType == 'chapter' ? 'E' : 'V' 59 | const url = `https://piccoma.com/web/product/${id}/episodes?etype=${etype}` 60 | const res = await this.client.get(url) 61 | const $ = await cheerio.load(res.data) 62 | if (bookType == 'chapter') { 63 | return this.getChapters($, id) 64 | } else { 65 | return this.getVolumes($, id) 66 | } 67 | } 68 | 69 | async getChapters($, id) { 70 | const $episodes = $('.PCM-epList li a') 71 | const episodes = [] 72 | $episodes.each((i, el) => { 73 | const $el = $(el) 74 | const freeEl = $el.find('.PCM-epList_status_webwaitfree img').length 75 | const pointEl = $el.find('.PCM-epList_status_point .js_point').length 76 | const zeroPlusEl = $el.find('.PCM-epList_status_zeroPlus img').length 77 | if (freeEl || pointEl || zeroPlusEl) { 78 | return 79 | } 80 | episodes.push({ 81 | name: this._sanatizePath($el.find('.PCM-epList_title h2').text()), 82 | url: `https://piccoma.com/web/viewer/${id}/${$el.data('episode_id')}`, 83 | id: $el.data('episode_id'), 84 | }) 85 | }) 86 | if (this.options.useFree) { 87 | let breakOut = false 88 | $episodes.each((i, el) => { 89 | const $el = $(el) 90 | if (breakOut || $el.parent().hasClass('PCM-epList_read') || !$el.find('.PCM-epList_status_webwaitfree img').length) { 91 | return 92 | } 93 | episodes.push({ 94 | name: this._sanatizePath($el.find('.PCM-epList_title h2').text()), 95 | url: `https://piccoma.com/web/viewer/${id}/${$el.data('episode_id')}`, 96 | id: $el.data('episode_id'), 97 | }) 98 | breakOut = true 99 | }) 100 | } 101 | return episodes 102 | } 103 | 104 | async getVolumes($, id) { 105 | const $volumes = $('.PCM-volList li') 106 | const volumes = [] 107 | $volumes.each((i, el) => { 108 | const $el = $(el) 109 | const freeButton = $el.find('.PCM-prdVol_freeBtn') 110 | const readButton = $el.find('.PCM-prdVol_readBtn') 111 | const name = $el.find('.PCM-prdVol_title h2').text() 112 | if (freeButton == null && readButton == null) { 113 | return 114 | } 115 | const episodeId = freeButton.length ? freeButton.attr('data-episode_id') : readButton.attr('data-episode_id') 116 | volumes.push({ 117 | name: this._sanatizePath(String(name)), 118 | url: `https://piccoma.com/web/viewer/${id}/${episodeId}`, 119 | id: episodeId, 120 | }) 121 | }) 122 | return volumes 123 | } 124 | 125 | async saveEpisode(url, dist, progress) { 126 | const res = await this.client.get(url) 127 | const $ = await cheerio.load(res.data) 128 | const pdata = await this._getPdata($) 129 | if (pdata == null) { 130 | console.log('failed to get image list.') 131 | return 132 | } 133 | await this._saveEpisodeImages(pdata, dist, progress) 134 | } 135 | 136 | async saveEpisodeDirect(url, volumeRename) { 137 | const res = await this.client.get(url) 138 | const $ = await cheerio.load(res.data) 139 | const pdata = await this._getPdata($) 140 | if (pdata == null) { 141 | console.log('failed to get image list.') 142 | return 143 | } 144 | const title = this._getTitle($) 145 | const chapterTitle = this._sanatizePath(pdata.title) 146 | if (volumeRename == null) { 147 | volumeRename = title; 148 | } 149 | const dist = path.resolve(this.options.out, volumeRename, chapterTitle) 150 | await this._saveEpisodeImages(pdata, dist, (current, imgLen) => { 151 | process.stdout.write(`\r- ${volumeRename} ${chapterTitle} ${current}/${imgLen}`) 152 | }) 153 | } 154 | 155 | _getTitle($) { 156 | return this._sanatizePath($('title').text().split('|')[1]) 157 | } 158 | 159 | async _getPdata($) { 160 | const index = Array.from($('script')).findIndex(el => $(el).html().includes('_pdata_ = ')) 161 | const script = $($('script')[index]).html() 162 | if (script == '' || script == null) { 163 | return null 164 | } 165 | const pdataObj = script.split(`_pdata_ = `)[1].split(' var ')[0] 166 | return Function(`return ${pdataObj}`)() 167 | } 168 | 169 | async _saveEpisodeImages(pdata, dist, progress) { 170 | let cnt = 0 171 | await fs.promises.mkdir(dist, { recursive: true }) 172 | const downloadLimit = pLimit(this.options.limit) 173 | const saveImagePromises = [] 174 | const downloadPromises = [] 175 | const imgList = this.options.type == 'jp' ? pdata.img : pdata.contents; 176 | for (let i = 0; i < imgList.length; i++) { 177 | const img = imgList[i] 178 | const url = img.path.includes('https') ? img.path : `https:${img.path}` 179 | const imagePath = path.resolve(dist, `${i + 1}.${this.options.format}`) 180 | if (fs.existsSync(imagePath)) { 181 | continue 182 | } 183 | downloadPromises.push(downloadLimit(async () => { 184 | const bitmap = await this._getBitmap(pdata, url) 185 | const promise = this._saveImage(bitmap, imagePath) 186 | saveImagePromises.push(promise) 187 | progress(++cnt, imgList.length) 188 | })) 189 | } 190 | await Promise.all(downloadPromises) 191 | await Promise.all(saveImagePromises) 192 | } 193 | 194 | _saveImage(bitmap, path) { 195 | if (this.options.format == 'jpg') { 196 | return PureImage.encodeJPEGToStream(bitmap, fs.createWriteStream(path), this.options.quality ?? 85) 197 | } else { 198 | return PureImage.encodePNGToStream(bitmap, fs.createWriteStream(path)) 199 | } 200 | } 201 | 202 | async _getBitmap(pdata, url) { 203 | let err = null 204 | for (let i = 0; i < 3; i++) { 205 | try { 206 | const res = await this.client.request(url, { method: 'GET', responseType: 'stream' }) 207 | const image = await this._decodeStream(res, url) 208 | const bitmap = unscramble(pdata, image, 50, await getSeed(url)) 209 | return bitmap 210 | } catch (error) { 211 | err = error 212 | await this._sleep(1000) 213 | } 214 | } 215 | throw err 216 | } 217 | 218 | _decodeStream(res, url) { 219 | if (res.headers['content-type'] == 'image/jpeg') { 220 | return decodeJPEGFromStream(res.data) 221 | } else if (res.headers['content-type'] == 'image/png') { 222 | return PureImage.decodePNGFromStream(res.data) 223 | } 224 | const ext = new URL(url).pathname.split('.').pop() 225 | if (ext == 'png') { 226 | return PureImage.decodePNGFromStream(res.data) 227 | } 228 | return decodeJPEGFromStream(res.data) 229 | } 230 | 231 | _sleep(ms) { 232 | return new Promise(resolve => setTimeout(resolve, ms)) 233 | } 234 | 235 | _sanatizePath(path) { 236 | if (this.platform.indexOf('win') === 0) { 237 | path = path.replace(/[\\/:*?"<>|\r\n\t]/g, '') 238 | } 239 | if (this.platform.indexOf('linux') === 0) { 240 | path = path.replace(/[/\r\n\t]/g, '') 241 | } 242 | if (this.platform.indexOf('darwin') === 0) { 243 | path = path.replace(/[/:\r\n\t]/g, '') 244 | } 245 | return path.replace(/[.\s]+$/g, '').trim() 246 | } 247 | } -------------------------------------------------------------------------------- /lib/unscramble.js: -------------------------------------------------------------------------------- 1 | //@ts-check 2 | import unscramble from './unscrambleImg.js' 3 | import PureImage from 'pureimage' 4 | 5 | export default function (pdata, image, num, seed) { 6 | const bitmap = PureImage.make(image.width, image.height, {}) 7 | const ctx = bitmap.getContext("2d") 8 | bitmap.width = image.width 9 | bitmap.height = image.height 10 | if (!pdata.isScrambled) { 11 | ctx.drawImage(image, 0, 0, image.width, image.height, 0, 0, image.width, image.height) 12 | return bitmap 13 | } 14 | unscramble(image, num, seed, ctx) 15 | return bitmap 16 | } -------------------------------------------------------------------------------- /lib/unscrambleImg.js: -------------------------------------------------------------------------------- 1 | import shuffleSeed from 'shuffle-seed' 2 | 3 | function unscrambleImg(img, sliceSize, seed, ctx) { 4 | const totalParts = Math.ceil(img.width / sliceSize) * Math.ceil(img.height / sliceSize) 5 | const inds = [] 6 | for (let i = 0; i < totalParts; i++) { 7 | inds.push(i) 8 | } 9 | 10 | const slices = getSlices(img, sliceSize) 11 | for (const g in slices) { 12 | const group = getGroup(slices[g]) 13 | let shuffleInd = [] 14 | for (let i = 0; i < slices[g].length; i++) { 15 | shuffleInd.push(i) 16 | } 17 | shuffleInd = shuffleSeed.shuffle(shuffleInd, seed) 18 | for (let i = 0; i < slices[g].length; i++) { 19 | const s = shuffleInd[i] 20 | const row = Math.floor(s / group.cols) 21 | const col = s - row * group.cols 22 | const x = col * slices[g][i].width 23 | const y = row * slices[g][i].height 24 | ctx.drawImage( 25 | img, 26 | group.x + x, 27 | group.y + y, 28 | slices[g][i].width, 29 | slices[g][i].height, 30 | slices[g][i].x, 31 | slices[g][i].y, 32 | slices[g][i].width, 33 | slices[g][i].height 34 | ) 35 | } 36 | } 37 | return ctx 38 | } 39 | 40 | function getGroup(slices) { 41 | const self = {} 42 | self.slices = slices.length 43 | self.cols = getColsInGroup(slices) 44 | self.rows = slices.length / self.cols 45 | self.width = slices[0].width * self.cols 46 | self.height = slices[0].height * self.rows 47 | self.x = slices[0].x 48 | self.y = slices[0].y 49 | return self 50 | } 51 | 52 | function getSlices(img, sliceSize) { 53 | const totalParts = Math.ceil(img.width / sliceSize) * Math.ceil(img.height / sliceSize) 54 | const verticalSlices = Math.ceil(img.width / sliceSize) 55 | const slices = {} 56 | for (let i = 0; i < totalParts; i++) { 57 | const slice = {} 58 | const row = Math.floor(i / verticalSlices) 59 | const col = i - row * verticalSlices 60 | slice.x = col * sliceSize 61 | slice.y = row * sliceSize 62 | slice.width = (sliceSize - (slice.x + sliceSize <= img.width ? 0 : (slice.x + sliceSize) - img.width)) 63 | slice.height = (sliceSize - (slice.y + sliceSize <= img.height ? 0 : (slice.y + sliceSize) - img.height)) 64 | const key = `${slice.width}-${slice.height}` 65 | if (slices[key] == null) { 66 | slices[key] = [] 67 | } 68 | slices[key].push(slice) 69 | } 70 | return slices 71 | } 72 | 73 | function getColsInGroup(slices) { 74 | if (slices.length == 1) { 75 | return 1 76 | } 77 | let t = 'init' 78 | for (let i = 0; i < slices.length; i++) { 79 | if (t == 'init') { 80 | t = slices[i].y 81 | } 82 | if (t != slices[i].y) { 83 | return i 84 | } 85 | } 86 | return slices.length 87 | } 88 | 89 | export default unscrambleImg -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "piccoma-downloader", 3 | "version": "1.1.16", 4 | "description": "Piccoma downloader", 5 | "type": "module", 6 | "main": "piccoma.js", 7 | "bin": "dist/bundle.js", 8 | "license": "MIT", 9 | "pkg": { 10 | "assets": "assets/**/*", 11 | "outputPath": "dist" 12 | }, 13 | "scripts": { 14 | "build": "esbuild piccoma.js --bundle --outfile=dist/bundle.js --platform=node --external:deasync --minify", 15 | "pkg": "pkg dist/bundle.js -o dist/piccoma-downloader -t node16-win,node16-macos,node16-linux --options no-deprecation" 16 | }, 17 | "dependencies": { 18 | "archiver": "^5.3.1", 19 | "axios": "^1.4.0", 20 | "axios-cookiejar-support": "^4.0.7", 21 | "cac": "^6.7.14", 22 | "cheerio": "^1.0.0-rc.12", 23 | "inquirer": "^9.2.8", 24 | "jpeg-js": "^0.4.4", 25 | "p-limit": "^4.0.0", 26 | "pureimage": "^0.3.17", 27 | "shuffle-seed": "^1.1.6", 28 | "simple-get": "^4.0.1", 29 | "tough-cookie": "^4.1.3" 30 | }, 31 | "devDependencies": { 32 | "esbuild": "^0.18.15", 33 | "pkg": "^5.8.1" 34 | } 35 | } -------------------------------------------------------------------------------- /piccoma.js: -------------------------------------------------------------------------------- 1 | import cac from 'cac' 2 | import fs from 'fs' 3 | import inquirer from 'inquirer' 4 | import path from 'path' 5 | import Piccoma from './lib/piccoma.js' 6 | import PiccomaFr from './lib/piccoma-fr.js' 7 | const cli = cac('piccoma-downloader') 8 | cli.option('--type [type]', 'jp or fr (default: jp)') 9 | cli.option('--config [path]', 'path for config file') 10 | cli.option('--mail [mail]', 'Account mail') 11 | cli.option('--password [password]', 'Account password') 12 | cli.option('--all', 'Download all mangas in bookmarks') 13 | cli.option('--manga [type]', 'chapter or volume (default: volume)') 14 | cli.option('--webtoon [type]', 'chapter or volume (default: chapter)') 15 | cli.option('--timeout [ms]', 'timeout time in milliseconds(default: 60000ms)') 16 | cli.option('--use-free', 'try to use one free ticket') 17 | cli.option('--format [format]', 'jpg or png (default: png)') 18 | cli.option('--quality [quality]', 'jpg quality (default: 85)') 19 | cli.option('--out [path]', 'output directory (default: manga)') 20 | cli.option('--chapter-url [url]', 'Download chapter url (support multiple url)') 21 | cli.option('--volume-rename', 'Rename volume') 22 | cli.option('--limit [limit]', 'max concurrency limit (default: 2)') 23 | cli.help() 24 | const cliOptions = cli.parse().options 25 | if (cliOptions.help) { 26 | process.exit() 27 | } 28 | 29 | main().catch(err => console.log(err.message, err.stack)).finally(() => { 30 | console.log('end') 31 | }) 32 | async function main() { 33 | const options = await readOptions(cliOptions) 34 | options.type = await askType(options) 35 | const piccoma = options.type == 'fr' ? new PiccomaFr(options) : new Piccoma(options) 36 | if (options.chapterUrl) { 37 | if (options.type == 'jp' && options.sessionid && await piccoma.checkAuth()) { 38 | console.log('use sessionid') 39 | } else if (options.mail && options.password) { 40 | await piccoma.login(options.mail, options.password) 41 | } 42 | const chapterUrls = [].concat(options.chapterUrl); 43 | for (let y = 0; y < chapterUrls.length; y++) { 44 | for (let i = 0; i < 2; i++) { 45 | try { 46 | const startTime = Date.now() 47 | await piccoma.saveEpisodeDirect(chapterUrls[y], options.volumeRename) 48 | const endTime = Date.now() 49 | process.stdout.write(`. spent time ${Math.floor((endTime - startTime) / 1000)}s\n`) 50 | break 51 | } catch (error) { 52 | console.log('error occurred. retry ', error.message) 53 | } 54 | } 55 | } 56 | return 57 | } 58 | if (options.sessionid && await piccoma.checkAuth()) { 59 | console.log('use sessionid') 60 | } else { 61 | const mail = await askMail(options) 62 | const password = await askPassword(options) 63 | await piccoma.login(mail, password) 64 | } 65 | await sleep(1000) 66 | const bookmarks = await piccoma.getBookmarks() 67 | const books = await selectBooks(options, bookmarks) 68 | for (const book of books) { 69 | process.stdout.write(`accessing ${book.title}...`) 70 | const episodes = await getEpisodes(piccoma, book) 71 | if (episodes.length === 0) { 72 | process.stdout.write(`\n`) 73 | await sleep(1000) 74 | continue 75 | } 76 | console.log(`${episodes[0].name}~${episodes[episodes.length - 1].name}`) 77 | for (const episode of episodes) { 78 | const episodeName = episode.name 79 | const title = options.volumeRename == null ? book.title : options.volumeRename; 80 | const distDir = path.resolve(options.out, title, episodeName) 81 | if (fs.existsSync(distDir)) { 82 | continue 83 | } 84 | await sleep(1000) 85 | for (let i = 0; i < 2; i++) { 86 | try { 87 | const startTime = Date.now() 88 | await piccoma.saveEpisode(episode.url, distDir, (current, imgLen) => { 89 | process.stdout.write(`\r - ${episodeName} ${current}/${imgLen}`) 90 | }) 91 | const endTime = Date.now() 92 | process.stdout.write(`. spent time ${Math.floor((endTime - startTime) / 1000)}s\n`) 93 | break 94 | } catch (error) { 95 | fs.rmSync(distDir, { force: true, recursive: true }) 96 | console.log('error occurred. retry ', error.stack) 97 | } 98 | } 99 | } 100 | } 101 | } 102 | 103 | async function getEpisodes(piccoma, book) { 104 | try { 105 | const episodes = await piccoma.getEpisodes(book.id, book.webtoon) 106 | return episodes 107 | } catch (error) { 108 | console.log('error occurred. skip', error) 109 | return [] 110 | } 111 | } 112 | 113 | async function askType(options) { 114 | if (options.type) { 115 | return options.type 116 | } 117 | const prompt = await inquirer.prompt([ 118 | { 119 | type: 'list', 120 | name: 'type', 121 | message: 'Piccoma type:', 122 | choices: [ 123 | 'jp', 124 | 'fr' 125 | ] 126 | } 127 | ]) 128 | return prompt.type 129 | } 130 | 131 | async function askPassword(options) { 132 | if (options.password || options.sessionid) { 133 | return options.password 134 | } 135 | const prompt = await inquirer.prompt([ 136 | { 137 | type: 'password', 138 | name: 'password', 139 | message: 'Account password:' 140 | } 141 | ]) 142 | return prompt.password 143 | } 144 | 145 | async function askMail(options) { 146 | if (options.mail) { 147 | return options.mail 148 | } 149 | const prompt = await inquirer.prompt([ 150 | { 151 | type: 'input', 152 | name: 'mail', 153 | message: 'Account mail:' 154 | } 155 | ]) 156 | return prompt.mail 157 | } 158 | 159 | async function selectBooks(options, bookmarks) { 160 | if (options.all) { 161 | return bookmarks 162 | } 163 | const prompt = await inquirer.prompt([ 164 | { 165 | type: 'checkbox', 166 | name: 'books', 167 | choices: bookmarks.map(book => ({ 168 | checked: true, 169 | name: book.title, 170 | value: book 171 | })) 172 | } 173 | ]) 174 | return prompt.books 175 | } 176 | 177 | function sleep(ms) { 178 | return new Promise(r => setTimeout(r, ms)) 179 | } 180 | 181 | async function readOptions(cliOptions) { 182 | const defaultOptions = { 183 | webtoon: 'chapter', 184 | manga: 'volume', 185 | timeout: 60000, 186 | format: 'png', 187 | quality: 85, 188 | out: 'manga', 189 | limit: 2, 190 | } 191 | if (cliOptions.config == null || cliOptions.config == '') { 192 | return Object.assign(defaultOptions, cliOptions) 193 | } 194 | const configStr = await fs.promises.readFile(cliOptions.config) 195 | const config = JSON.parse(configStr) 196 | return Object.assign(defaultOptions, cliOptions, config) 197 | } 198 | -------------------------------------------------------------------------------- /usage-list.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elastic1/piccoma-downloader/0866dc27654346b2d71ed9d4e6c6ce3753b43b90/usage-list.png -------------------------------------------------------------------------------- /usage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Elastic1/piccoma-downloader/0866dc27654346b2d71ed9d4e6c6ce3753b43b90/usage.png --------------------------------------------------------------------------------