Comic Fuz Downloader

Userscript for download comics on Comic Fuz

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name              Comic Fuz Downloader
// @name:en           Comic Fuz Downloader
// @namespace         http://circleliu.cn
// @version           0.4.10
// @description       Userscript for download comics on Comic Fuz
// @description:en    Userscript for download comics on Comic Fuz
// @author            Circle
// @license           MIT
// @match             https://comic-fuz.com/book/viewer*
// @match             https://comic-fuz.com/magazine/viewer*
// @match             https://comic-fuz.com/manga/viewer*
// @run-at            document-start
// @grant             none

// @require           https://cdn.jsdelivr.net/npm/[email protected]/crypto-js.js
// @require           https://unpkg.com/axios/dist/axios.min.js
// @require           https://unpkg.com/[email protected]/dist/jszip.min.js
// @require           https://unpkg.com/[email protected]/dist/jszip-utils.min.js
// @require           https://unpkg.com/[email protected]/vendor/FileSaver.js
// @require           https://unpkg.com/[email protected]/dist/jquery.min.js
// @require           https://cdn.jsdelivr.net/npm/[email protected]/dist/protobuf.min.js
// @require           https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js
// @require           https://cdn.jsdelivr.net/npm/[email protected]/piexif.min.js

// @require           https://greasyfork.org/scripts/435461-comic-fuz-downloader-protobuf-message/code/Comic%20Fuz%20Downloader%20Protobuf%20Message.js?version=987894

// @homepageURL       https://circleliu.github.io/Comic-Fuz-Downloader/
// @supportURL        https://github.com/CircleLiu/Comic-Fuz-Downloader
// ==/UserScript==

;(function () {
  'use strict'

  const DEFAULT_CONFIGS = {
    // `timeout` specifies the number of milliseconds before the request times out.
    // If the request takes longer than `timeout`, the request will be aborted/retried.
    // `0` is never timeout
    timeout: 60000,
    // The number of times to retry before failing.
    maxRetries: 3,
    //the delay in milliseconds between retried requests.
    retryDelay: 1000,
  }

  const api = getApi()

  const imgBaseUrl = 'https://img.comic-fuz.com'
  const apiBaseUrl = 'https://api.comic-fuz.com'

  const client = axios.create({
    baseURL: imgBaseUrl,
    ...DEFAULT_CONFIGS,
  })

  client.interceptors.response.use(null, (error) => {
    if (error.config && shouldRetry(error)) {
      const { __retryCount: retryCount = 0 } = error.config
      error.config.__retryCount = retryCount + 1
      const delay = error.config.retryDelay
      return new Promise((resolve) => {
        setTimeout(() => resolve(client(error.config)), delay)
      })
    }
    return Promise.reject(error)
  })

  const shouldRetry = (error) => {
    const { maxRetries, __retryCount: retryCount = 0 } = error.config
    if (retryCount < maxRetries) {
      return true
    }
    return false
  }

  const delay = ms => new Promise(resolve => setTimeout(resolve, ms))

  class Comic {
    constructor (path, request, response) {
      const deviceInfo = {
        deviceType: 2,
      }
      this.url = `${apiBaseUrl}/v1/${path}`
      this.requestBody = {
        deviceInfo,
      }
      this.request = request
      this.response = response
    }

    async fetchMetadata() {
      const response = await fetch(this.url, {
        method: 'POST',
        credentials: 'include',
        body: this.request.encode(this.requestBody).finish(),
      })
      this.metadata = await this.decodeResponse(response)
    }

    async decodeResponse(response) {
      const data = await response.arrayBuffer()
      const res = this.response.decode(new Uint8Array(data))
      return res
    }
  }

  class Book extends Comic {
    constructor (bookIssueId) {
      super('book_viewer_2', api.v1.BookViewer2Request, api.v1.BookViewer2Response)
      this.requestBody = {
        deviceInfo: this.requestBody.deviceInfo,
        bookIssueId,
        consumePaidPoint: 0,
        purchaseRequest: false,
      }
    }
  }

  class Magazine extends Comic {
    constructor (magazineIssueId) {
      super('magazine_viewer_2', api.v1.MagazineViewer2Request, api.v1.MagazineViewer2Response)
      this.requestBody = {
        deviceInfo: this.requestBody.deviceInfo,
        magazineIssueId,
        consumePaidPoint: 0,
        purchaseRequest: false,
      }
    }
  }

  class Manga extends Comic {
    constructor (chapterId) {
      super('manga_viewer', api.v1.MangaViewerRequest, api.v1.MangaViewerResponse)
      this.requestBody = {
        deviceInfo: this.requestBody.deviceInfo,
        chapterId,
        consumePoint: {
          event: 0,
          paid: 0,
        },
        useTicket: false,
      }
    }
  }

  let comic
  async function initialize() {
    const path = new URL(window.location.href).pathname.split('/')
    const type = path[path.length - 3]
    const id = path[path.length - 1]
    switch (type.toLowerCase()) {
      case 'book':
        comic = new Book(id)
        break
      case 'magazine':
        comic = new Magazine(id)
        break
      case 'manga':
        comic = new Manga(id)
        break
    }
    await comic.fetchMetadata()
  }

  async function decryptImage({imageUrl, encryptionKey, iv}) {
    const res = await client.get(imageUrl, {
      responseType: 'arraybuffer',
    })

    if (!imageUrl.includes('.enc')) {
      return btoa([].reduce.call(new Uint8Array(res.data),function(p,c){return p+String.fromCharCode(c)},''))
    }

    const cipherParams = CryptoJS.lib.CipherParams.create({
      ciphertext: CryptoJS.lib.WordArray.create(res.data)
    })
    const key = CryptoJS.enc.Hex.parse(encryptionKey)
    const _iv = CryptoJS.enc.Hex.parse(iv)
    const dcWordArray = CryptoJS.AES.decrypt(cipherParams, key, {
      iv: _iv,
      mode: CryptoJS.mode.CBC,
    })
    return dcWordArray.toString(CryptoJS.enc.Base64)
  }

  $(document).ready($ => {
    const downloadIcon = 'https://circleliu.github.io/Comic-Fuz-Downloader/icons/download.png'
    const loadingIcon = 'https://circleliu.github.io/Comic-Fuz-Downloader/icons/loading.gif'
    // const downloadIcon = 'http://localhost:5000/icons/download.png'
    // const loadingIcon = 'http://localhost:5000/icons/loading.gif'
    const divDownload = $(`
      <div id="downloader"></div>
    `)
    const path = new URL(window.location.href).pathname.split('/')
    const is_manga = (path[path.length - 3].toLowerCase()) === 'manga'
    if (is_manga) {
      divDownload.css({
        "grid-area": 'rright',
        display: 'flex',
        "align-items": 'center',
        gap: '24px',
        color: '#929ea5',
      })
    } else {
      divDownload.css({
        'margin-left': '24px',
        flex: '1 1',
        color: '#2c3438',
        width: 'fit-content',
      })
    }

    const spanDownloadButton = $(`
      <span id="downloadButton">
        <img id="downloaderIcon" src="${downloadIcon}">
        <img id="downloadingIcon" src="${loadingIcon}">
        <span id="downloaderText">Initializing</span>
      </span>
    `)
    spanDownloadButton.css({
      cursor: 'pointer',
    })
    spanDownloadButton.on('click', async () => {
      setDownloaderBusy()
      try {
        await downloadAsZip(comic.metadata, +$('#downloadFrom').val(), +$('#downloadTo').val())
        setDownloaderReady()
      } catch (error) {
        console.error(error)
        setDownloaderReady(error.message)
      }
    })

    const spanDownloadRange = $(`
      <span id="downloadRange">
        <input id="downloadFrom" type="number">~<input id="downloadTo" type="number">
      </span>
    `)
    spanDownloadRange.children('input').css({
      width: '3rem',
    })


    function initRange() {
      if (!comic.metadata) {
        throw new Error('No metadata')
      }
      const maxLength = comic.metadata.pages.filter(({image}) => !!image).length
      spanDownloadRange.children('input').attr({
        min: 1,
        max: maxLength,
      })

      $('#downloadFrom').val(1)
      $('#downloadFrom').on('input', _.debounce(() => {
        if (!$('#downloadFrom').val()) return

        const max = Math.min(+$('#downloadFrom').attr('max'), +$('#downloadTo').val())
        if (+$('#downloadFrom').val() < +$('#downloadFrom').attr('min')) {
          $('#downloadFrom').val($('#downloadFrom').attr('min'))
        } else if (+$('#downloadFrom').val() > max) {
          $('#downloadFrom').val(max)
        }
      }, 300))

      $('#downloadTo').val(maxLength)
      $('#downloadTo').on('input', _.debounce(() => {
        if (!$('#downloadTo').val()) return

        const min = Math.max(+$('#downloadTo').attr('min'), +$('#downloadFrom').val())
        if (+$('#downloadTo').val() > +$('#downloadTo').attr('max')) {
          $('#downloadTo').val($('#downloadTo').attr('max'))
        } else if (+$('#downloadTo').val() < min) {
          $('#downloadTo').val(min)
        }
      }, 300))
    }

    divDownload.append(spanDownloadButton)
    divDownload.append(spanDownloadRange)


    function setDownloaderReady(msg) {
      $('#downloaderIcon').show()
      $('#downloadingIcon').hide()
      setText(msg || 'Download')
    }

    function setDownloaderBusy() {
      $('#downloaderIcon').hide()
      $('#downloadingIcon').show()
    }

    function setText(text) {
      $('#downloaderText').text(text)
    }

    function updateDownloadProgress(progress) {
      setText(`Loading: ${progress.done}/${progress.total}`)
    }

    function checkAndLoad() {
      if ($('#downloader').length === 0) {
        if (is_manga) {
          $('div[class^="InternalViewerFooter_footer__wrapper__"]:first').append(divDownload)
        } else {
          $('div[class^="ViewerFooter_footer__buttons__"]:first').append(divDownload)
        }
      }
    }

    const maxRetry = 10
    ;(async () => {
      for (let i = 0; i < maxRetry; ++i) {
        const old_ui = !is_manga && $('div[class^="ViewerFooter_footer__"]').length
        const new_ui = is_manga && $('div[class^="InternalViewerFooter_footer__wrapper__"]').length
        if (old_ui || new_ui) {

          if (old_ui) {
            const zoomContainer = $('div[class^="ViewerFooter_footer__zoomContainer__"]:first').attr('class')
            $('head').append(`<style type="text/css">
                .${zoomContainer} {
                  flex: 0 1 270px;
                }
              </style>`)
          } else {
            const footer = $('div[class^="InternalViewerFooter_footer__wrapper__"]:first').attr('class')
            $('head').append(`<style type="text/css">
                .${footer} {
                  grid-template-areas: "left center right rright";
                  grid-template-columns: auto 1fr auto auto;
                }
              </style>`)
          }

          checkAndLoad()
          $(document).on('click', checkAndLoad)
          setDownloaderBusy()
          setText('Initializing...')
          try {
            await initialize()
            initRange()
            setDownloaderReady()
          } catch (err) {
            setDownloaderReady('Initialization failed!')
          }
          break
        } else {
          await delay(500)
        }
      }
    })()

    async function downloadAsZip(metadata, pageFrom, pageTo) {
      if (!metadata) {
        throw new Error('Failed to load data!')
      } else if (!pageFrom || !pageTo || pageFrom > pageTo) {
        throw new Error('Incorrect Range!')
      }

      const zipName = getNameFromMetadata(metadata)
      const zip = new JSZip()
      if (metadata.tableOfContents){
        zip.file('TableOfContents.txt', JSON.stringify(metadata.tableOfContents, null, '  '))
      }

      const progress = {
        total: 0,
        done: 0,
      }
      const promises = []
      const images = metadata.pages.slice(pageFrom - 1, pageTo)
      for (let i = 0; i < images.length; i++) {
        await delay(i > 0 ? 100 : 0)
        const {image} = images[i]
        if (image) {
          progress.total++
          promises.push(getImageToZip(image, zip, progress, pageFrom + i))
        }
      }
      await Promise.all(promises)

      const content = await zip.generateAsync({ type: 'blob' }, ({ percent }) => {
        setText(`Packaging: ${percent.toFixed(2)}%`)
      })
      saveAs(content, `${zipName}.zip`)
    }

    function getNameFromMetadata(metadata) {
      if (metadata.bookIssue) {
        return metadata.bookIssue.bookIssueName.trim()
      } else if (metadata.viewerTitle) {
        return metadata.sns?.body?.match(/(?<=「).*(?=」)/)?.[0]?.trim() ?? metadata.viewerTitle.trim()
      } else if (metadata.magazineIssue) {
        return metadata.magazineIssue.magazineName.trim() + ' ' + metadata.magazineIssue.magazineIssueName.trim()
      }
    }

    async function getImageToZip(image, zip, progress, index) {
      const fileName = `${index.toString().padStart(3, '0')}.jpeg`
      try {
        const imageData = await decryptImage(image)
        const imageData72Dpi = modifyExif(imageData)
        addImageToZip(fileName, imageData72Dpi, zip)
      } catch (err) {
        console.error(err)
      }
      if (progress) {
        progress.done++
        updateDownloadProgress(progress)
      }
    }

    function addImageToZip(name, base64Data, zip) {
      zip.file(name, base64Data, {
        base64: true,
      })
    }

    function modifyExif(base64Data) {
      const imageString = atob(base64Data)
      const exif = piexif.load(imageString)

      exif['0th'][piexif.ImageIFD.XResolution] = [720000,10000]
      exif['0th'][piexif.ImageIFD.YResolution] = [720000,10000]

      const newExifDump = piexif.dump(exif)

      const newData = piexif.insert(newExifDump, imageString)
      const newBase64 = btoa(newData)

      return newBase64
    }
  })
})()