Comic Fuz Downloader

Userscript for download comics on Comic Fuz

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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
    }
  })
})()