Pixiv Novel Downloader

Download novels from Pixiv

// ==UserScript==
// @name         Pixiv Novel Downloader
// @name:zh-CN   Pixiv 小说下载器
// @namespace    http://tampermonkey.net/
// @version      0.2
// @description  Download novels from Pixiv
// @description:zh-CN 从Pixiv下载小说
// @author       calary
// @license      GPL-3.0
// @include      http*://www.pixiv.net*
// @match        https://www.pixiv.net/*
// @icon         http://www.pixiv.net/favicon.ico
// @require      https://cdn.bootcdn.net/ajax/libs/jquery/2.2.4/jquery.min.js
// @require      https://cdn.bootcdn.net/ajax/libs/jszip/3.7.1/jszip.min.js
// @require      https://cdn.bootcdn.net/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @grant        none
// @run-at       document-end
// ==/UserScript==

jQuery(function ($) {
  const lang = (
    window.navigator.language ||
    window.navigator.browserLanguage ||
    "en-us"
  ).toLowerCase();

  const i18nMap = {
    "en-us": {
      ui_title: "Novel Downloader",
      ui_dl_page: "DL This Work",
      ui_dl_author: "Batch DL This Author",
      ui_dl_series: "Batch DL This Series",
      ui_dl_list: "Batch DL This List",
      ui_dl_favlist: "Batch DL Bookmark List",
      ui_start: "START",
      ui_pause: "PAUSE",
      ui_resume: "RESUME",
      ui_retry: "RETRY",
      ui_cancel: "CANCEL",
      ui_dl: "Download",
      ui_page: "P",
      ui_all: "All",
      ui_inludelikes: "Filename inludes likes",
      error_default: "Something went wrong",
      error_notpage: "This is not a novel page.",
      error_notauthor: "This is not an author page.",
      error_notseries: "This is not a series page.",
      error_notlist: "This is not a list page.",
      error_notfavlist: "This is not bookmark page",
      txt_title: "Title: ",
      txt_novelid: "Novel ID: ",
      txt_author: "Author: ",
      txt_authorid: "Author ID: ",
      txt_words: "Words: ",
      txt_likes: "Likes: ",
      txt_createtime: "Create Time: ",
      txt_updatetime: "Update Time: ",
      txt_tags: "Tags: ",
      txt_desc: "Description: ",
      txt_words2: "Words",
      txt_likes2: "Likes",
      txt_pageno: "Page {0}",
      txt_fav: "Bookmark",
    },
    "zh-cn": {
      ui_title: "小说下载器",
      ui_dl_page: "下载此小说",
      ui_dl_author: "批量下载此作者",
      ui_dl_series: "批量下载此系列",
      ui_dl_list: "批量下载此列表页",
      ui_dl_favlist: "批量下载收藏列表",
      ui_start: "开始",
      ui_pause: "暂停",
      ui_resume: "继续",
      ui_retry: "重试",
      ui_cancel: "取消",
      ui_dl: "下载",
      ui_page: "页",
      ui_all: "全部",
      ui_inludelikes: "文件命名包含喜欢数",
      error_default: "出错了",
      error_notpage: "该页不是小说页。",
      error_notauthor: "该页不是作者主页。",
      error_notseries: "该页不是系列页。",
      error_notlist: "该页不是列表页。",
      error_notfavlist: "该页不是收藏列表。",
      txt_title: "标题:",
      txt_novelid: "作品id:",
      txt_author: "作者:",
      txt_authorid: "Pixiv ID:",
      txt_words: "字数:",
      txt_likes: "喜欢:",
      txt_createtime: "创建时间:",
      txt_updatetime: "更新时间:",
      txt_tags: "标签:",
      txt_desc: "描述:",
      txt_words2: "字",
      txt_likes2: "喜欢",
      txt_pageno: "第{0}页",
      txt_fav: "收藏",
    },
  };
  const i18n = (key, ...args) => {
    let str = (i18nMap[lang] && i18nMap[lang][key]) || i18nMap["en-us"][key];
    args.forEach((value, index) => {
      str = str.replace(`{${index}}`, value);
    });
    return str;
  };

  const website = "pixiv";
  const fontFamily = "Arial, 'Microsoft Yahei', Helvetica, sans-serif";
  const noop = () => {};

  const $panel = $(`<div>
    <h4 style="padding: 0; margin: 0 0 10px;">${i18n("ui_title")}</h4>
    <div>
      <span>${i18n("ui_dl")}: </span>
      <label><input type="radio" name="dl_mode" value="single"> 1 ${i18n(
        "ui_page"
      )}</label>
      <label><input type="radio" name="dl_mode" value="all" checked> ${i18n(
        "ui_all"
      )}</label>
    </div>
    <div>
      <span>${i18n("ui_inludelikes")}: </span>
      <input type="checkbox" name="dl_includelikes" />
    </div>
  </div>`)
    .css({
      position: "fixed",
      left: 0,
      bottom: 50,
      zIndex: 999999,
      background: "#fff",
      color: "#333",
      fontSize: 16,
      fontFamily: fontFamily,
      padding: 10,
      borderRadius: 6,
      boxShadow: "0 0 10px rgba(0,0,0,0.3)",
    })
    .appendTo($("body"));

  /*
    功能规划
    下载单页
    下载搜索页全部
    下载作者页全部
    每页保存成一个.zip
    自动下载下一页
    出错时可以重启
    保存文件名标识r18和r18g

    列表页url类型

    列表地址     https://www.pixiv.net/tags/标签/novels
    参数
      标签       word
      标签匹配   s_mode
        完全一致 s_tag_full <default>
        部分一致 s_tag_only
      排序       order
        从新到旧 date_d <default>
        从旧到新 date
      页数       p
      其他参数   mode=all lang=zh

    作者页面     https://www.pixiv.net/users/作者id
    作者小说列表 https://www.pixiv.net/users/作者id/novels

    小说系列列表 https://www.pixiv.net/novel/series/系列id
    /ajax/novel/series/系列id?lang=zh
    /ajax/novel/series_content/系列id?limit=10&last_order=0&order_by=asc&lang=zh

    小说地址     https://www.pixiv.net/novel/show.php?id=小说id

  */

  function baseRequest(config) {
    return new Promise((resolve, reject) => {
      $.ajax({
        timeout: 5000,
        ...config,
        success: (response) => {
          resolve(response);
        },
        error: () => {
          reject(new Error(i18n("error_default")));
        },
      });
    });
  }

  function request(config) {
    return baseRequest(config).then(({ error, message, body }) => {
      if (error) {
        return new Error(message);
      }

      return body;
    });
  }

  // 过滤文件名非法字符
  function filterFilename(filename) {
    return filename.replace(/\?|\*|\:|\"|\<|\>|\\|\/|\|/g, "");
  }

  function wait(delay, ctrl = {}) {
    return new Promise((resolve, reject) => setTimeout(resolve, delay));
  }

  class Task {
    title = "";
    $item = null;

    // unstarted=''; running; paused; error.
    status = "";

    // 文件命名包含喜欢数
    includeLikes = false;

    constructor(title) {
      this.title = title;
      this.start = this.start.bind(this);
      this.pause = this.pause.bind(this);
      this.resume = this.resume.bind(this);
      this.retry = this.retry.bind(this);
      this.cancel = this.cancel.bind(this);
      this.errorHandler = this.errorHandler.bind(this);
      this.init();
    }
    init() {
      const $item = $(`<div>
        ${i18n(this.title)}
        <button class="start">${i18n("ui_start")}</button>
        <button class="pause">${i18n("ui_pause")}</button>
        <button class="resume">${i18n("ui_resume")}</button>
        <button class="retry">${i18n("ui_retry")}</button>
        <button class="cancel">${i18n("ui_cancel")}</button>
        <span class="status">
          <span class="current"></span> -
          <span class="page"></span>
        </span>
      </div>`).appendTo($panel);

      this.$item = $item;
      this.$start = $item.find(".start").on("click", this.start);
      this.$pause = $item.find(".pause").hide().on("click", this.pause);
      this.$resume = $item.find(".resume").hide().on("click", this.resume);
      this.$retry = $item.find(".retry").hide().on("click", this.retry);
      this.$cancel = $item.find(".cancel").hide().on("click", this.cancel);
      this.$status = $item.find(".status").hide();
      this.$currentStatus = $item.find(".status .current");
      this.$pageStatus = $item.find(".status .page");
    }
    start() {
      this.status = "running";
      this.includeLikes = $("input[name='dl_includelikes']:checked").val();
      this.$start.hide();
      this.$pause.show();
      this.$resume.hide();
      this.$retry.hide();
      this.$cancel.show();
      this.$status.hide();
    }
    pause() {
      this.status = "paused";
      this.$start.hide();
      this.$pause.hide();
      this.$resume.show();
      this.$retry.hide();
      this.$cancel.show();
      this.$status.show();
    }
    resume() {
      this.status = "running";
      this.$start.hide();
      this.$pause.show();
      this.$resume.hide();
      this.$retry.hide();
      this.$cancel.show();
      this.$status.show();
    }
    error() {
      this.status = "error";
      this.$start.hide();
      this.$pause.hide();
      this.$resume.hide();
      this.$retry.show();
      this.$cancel.show();
      this.$status.show();
    }
    retry() {
      this.status = "running";
      this.$start.hide();
      this.$pause.show();
      this.$resume.hide();
      this.$retry.hide();
      this.$cancel.show();
      this.$status.show();
    }
    cancel() {
      this.status = "";
      this.$start.show();
      this.$pause.hide();
      this.$resume.hide();
      this.$retry.hide();
      this.$cancel.hide();
      this.$status.hide();
    }
    isRunning() {
      return this.status === "running";
    }
    checkRunning() {
      if (!this.isRunning()) {
        throw new Error("CANCEL");
      }
    }
    errorHandler(e) {
      if (e.message === "CANCEL") {
        return;
      }
      this.error();
      console.trace(e);
      alert(e);
    }
    getWork(id) {
      return request({
        url: `/ajax/novel/${id}`,
        responseType: "json",
      }).then((body) => {
        let title = [];
        let output = [];

        title.push(`[${body.userName}]`);
        title.push(`[${website}]`);
        title.push(`[${body.id}]`);
        if (body.xRestrict === 1) {
          title.push("[R18]");
        } else if (body.xRestrict === 2) {
          title.push("[R18G]");
        }
        title.push(`[${body.title}]`);
        title.push(`[${body.content.length}${i18n("txt_words2")}]`);
        if (this.includeLikes) {
          title.push(`[${body.likeCount}${i18n("txt_likes2")}]`);
        }

        output.push(i18n("txt_title") + body.title);
        output.push(i18n("txt_novelid") + body.id);
        output.push(i18n("txt_author") + body.userName);
        output.push(i18n("txt_authorid") + body.userId);
        output.push(i18n("txt_words") + body.content.length);
        output.push(i18n("txt_likes") + body.likeCount);
        output.push(i18n("txt_createtime") + body.createDate);
        output.push(i18n("txt_updatetime") + body.uploadDate);
        output.push(
          i18n("txt_tags") +
            body.tags.tags
              .map(function (tag) {
                if (tag.userId === body.userId) {
                  return "#" + tag.tag;
                }
                return "(#" + tag.tag + ")";
              })
              .join(" ")
        );
        output.push("");
        output.push("");
        output.push(i18n("txt_desc"));
        output.push(body.description.replace(/<br \/>/gi, "\n"));
        output.push("");
        output.push("");
        output.push("");
        output.push("");

        let pageCount = 1;
        output.push(
          body.content
            .replace(/\\n/g, "\n")
            .replace(/\[jump:(\d+)\]/g, (_, $1) => {
              return `[${i18n("txt_pageno", $1)}]`;
            })
            .replace(/\[newpage\]/g, () => {
              return `\n\n[${i18n("txt_pageno", ++pageCount)}]\n\n`;
            })
        );

        const filename = filterFilename(title.join("")) + ".txt";
        const content = output.join("\n");

        return {
          filename,
          content,
        };
      });
    }
  }

  class TaskMultiPage extends Task {
    pageParam = "p";
    offsetParam = "offset";
    limitParam = "limit";
    defaultParams = {};

    // 当前页
    page = 1;
    // 当前页完成数量
    finished = 0;
    // 每页数量
    limit = 24;
    // 作品总数
    total = 0;
    // 总页数
    pages = 0;
    // 下载模式
    mode = "all";
    // 下载阶段 list=列表 works=作品
    step = "";

    url = null;
    params = null;
    promise = null;
    ids = null;
    entries = null;

    getUrl() {
      return "";
    }

    getSaveFilename() {
      return "";
    }

    check() {}

    start() {
      try {
        this.check();
      } catch (e) {
        alert(e);
        return;
      }
      super.start();

      this.mode = $("input[name='dl_mode']:checked").val();

      const curPageUrl = new URL(window.location.href);
      this.url = this.getUrl();
      this.params = Object.assign(
        {},
        this.defaultParams,
        Object.fromEntries(curPageUrl.searchParams)
      );
      this.page = parseInt(this.params[this.pageParam]) || 1;

      this.getInitData().then(() => this.getNextList());
    }

    resume() {
      super.resume();
      this.resumeOrRetry();
    }

    retry() {
      super.retry();
      this.resumeOrRetry();
    }

    resumeOrRetry() {
      if (this.step === "works") {
        this.getWorks();
      } else {
        this.getNextList();
      }
    }

    setParams() {
      this.params[this.pageParam] = this.page;
      this.params[this.limitParam] = this.limit;
      this.params[this.offsetParam] = (this.page - 1) * this.limit;
    }

    getInitData() {
      return Promise.resolve();
    }

    getNextList() {
      if (!this.isRunning()) {
        return;
      }
      this.step = "list";
      this.setParams();

      this.promise = this.getList()
        .then(({ data = [], total }) => {
          this.checkRunning();

          this.total = total;
          this.pages = Math.ceil(total / this.limit);
          this.finished = 0;
          this.entries = {};
          this.updateStatus();

          if (data.length < 0) {
            return;
          }

          const ids = (this.ids = new Set());
          data.forEach((item) => ids.add(item.id));

          this.getWorks();
        })
        .catch(this.errorHandler);
    }

    getList() {
      this.setParams();
      return request({
        url: this.url,
        data: this.params,
        method: "get",
        responseType: "json",
      }).then((body) => {
        this.checkRunning();
        return this.parseList(body);
      });
    }

    parseList(payload) {
      return payload;
    }

    getWorks() {
      if (!this.isRunning()) {
        return;
      }
      this.step = "works";

      const { ids } = this;

      let i = 0;

      this.promises = ids.map((id) => {
        return new Promise((resolve, reject) => {
          setTimeout(() => {
            try {
              this.checkRunning();
              resolve();
            } catch (e) {
              reject(e);
            }
          }, i++ * 100);
        })
          .then(() => {
            this.checkRunning();
            return this.getWork(id);
          })
          .then((work) => {
            this.checkRunning();

            this.finished++;
            this.ids.delete(id);
            this.entries[id] = work;
            this.updateStatus();
          });
      });

      Promise.all(this.promises)
        .then(() => {
          if (!this.isRunning()) {
            return;
          }

          const zip = new JSZip();
          let hasFile = false;
          Object.values(this.entries).forEach(({ filename, content }) => {
            hasFile = true;
            zip.file(filename, content);
          });

          if (hasFile) {
            this.savedPage = this.page;

            zip
              .generateAsync({ type: "blob" })
              .then((content) => saveAs(content, this.getSaveFilename()));
          }

          if (this.mode === "all" && this.page < this.pages) {
            this.page++;
            this.getNextList();
          } else {
            this.cancel();
          }
        })
        .catch(this.errorHandler);
    }

    updateStatus() {
      this.$status.show();

      const { finished, limit, total, page, pages } = this;
      let curPageTotal = limit;
      if (page === pages) {
        curPageTotal = total - limit * (page - 1);
      }

      this.$currentStatus.html(`${finished}/${curPageTotal}`);
      this.$pageStatus.html(
        `${page}${i18n("ui_page")}/${pages}${i18n("ui_page")}`
      );
    }
  }

  class TaskPage extends Task {
    promise = null;

    init() {
      super.init();
      this.$pause.remove();
      this.$resume.remove();
      this.$retry.remove();
    }

    start() {
      // https://www.pixiv.net/novel/show.php?id=作品id
      const exec = /\/novel\/show.php\?id=(.+)$/i.exec(window.location.href);
      if (!exec) {
        alert(i18n("error_notpage"));
        return;
      }
      const id = exec[1];
      super.start();

      this.promise = this.getWork(id)
        .then(({ filename, content }) => {
          if (!this.isRunning()) {
            return;
          }

          this.cancel();

          saveAs(
            new Blob([content], { type: "text/plain;charset=UTF-8" }),
            filename
          );
        })
        .catch((e) => {
          if (!this.isRunning()) {
            return;
          }
          this.cancel();
          alert(e.message);
        });
    }
  }

  // /ajax/user/7855356/profile/novels?ids%5B%5D=7783432&lang=zh
  class TaskAuthor extends TaskMultiPage {
    defaultParams = {
      limit: 10,
      last_order: 0,
      order_by: "asc",
      lang: "zh",
    };
    id = "";
    limit = 24;
    tag = "";
    userName = "";
    workIds = null;
    total = 0;

    check() {
      // /users/作者id
      // /users/作者id/novels
      // /users/作者id/novels/标签
      const pathname = window.location.pathname;
      const exec2 = /^\/users\/(\d+)\/novels\/(.+)$/.exec(pathname);
      const exec1 = /^\/users\/(\d+)(\/novels)*$/.exec(pathname);

      this.id = "";
      this.tag = "";

      if (exec2) {
        this.id = exec2[1];
        this.tag = decodeURIComponent(exec2[2]);
        if (!this.tag) {
          throw new Error(i18n("error_notauthor"));
        }
      } else if (exec1) {
        this.id = exec1[1];
      } else {
        throw new Error(i18n("error_notauthor"));
      }
    }

    getInitData() {
      // /ajax/user/44820588?full=1&lang=zh
      let infoPromise = request({
        url: `/ajax/user/${this.id}`,
        method: "get",
        data: {
          full: 1,
          lang: "zh",
        },
      }).then((payload) => {
        this.userName = payload.name;
      });

      let workPromise = request({
        url: `/ajax/user/${this.id}/profile/all`,
        method: "get",
        data: {
          lang: "zh",
        },
      }).then((payload) => {
        const { novels } = payload;
        this.workIds = Object.keys(novels).sort((a, b) => b - a);
        this.total = this.workIds.length;
      });

      return Promise.all([infoPromise, workPromise]);
    }

    getList() {
      if (this.tag) {
        return super.getList();
      }

      const { limit, page, workIds } = this;
      let offset = limit * (page - 1);
      return Promise.resolve({
        total: workIds.length,
        data: workIds.slice(offset, offset + limit).map((id) => {
          return { id };
        }),
      });
    }

    parseList(payload) {
      if (this.tag) {
        return {
          data: payload.works,
          total: payload.total,
        };
      }

      // 不用调用列表,直接查询id就完事了
      // 但如何结合现有的getList?
      return {
        total: this.total,
        works: Object.values(payload.works),
      };
    }

    getUrl() {
      return `/ajax/user/${this.id}/novels/tag`;
    }

    setParams() {
      const { tag, limit, page } = this;
      let offset = limit * (page - 1);
      this.params = {
        tag,
        limit,
        offset,
        lang: "zh",
      };
    }

    getSaveFilename() {
      const date = new Date().toISOString().substring(0, 10);
      let arr = [];

      arr.push(this.userName);
      if (this.tag) {
        arr.push(this.tag);
      }
      arr.push("p" + this.savedPage);
      arr.push(date);

      return filterFilename(arr.join("_")) + ".zip";
    }
  }

  class TaskSeries extends TaskMultiPage {
    defaultParams = {
      limit: 10,
      last_order: 0,
      order_by: "asc",
      lang: "zh",
    };
    id = "";
    limit = 10;
    title = "";
    userName = "";
    total = 0;

    check() {
      // https://www.pixiv.net/novel/series/系列id
      const exec = /^\/novel\/series\/(.+)$/i.exec(window.location.pathname);
      if (!exec) {
        throw new Error(i18n("error_notseries"));
      }

      this.id = exec[1];
    }

    getInitData() {
      return request({
        url: "/ajax/novel/series/" + this.id,
        method: "get",
        data: {
          lang: "zh",
        },
      }).then((payload) => {
        const { title, userName, displaySeriesContentCount } = payload;
        this.title = title;
        this.userName = userName;
        this.total = displaySeriesContentCount;
      });
    }

    parseList(payload) {
      return { data: payload.seriesContents, total: this.total };
    }

    getUrl() {
      return "/ajax/novel/series_content/" + this.id;
    }

    setParams() {
      this.params.last_order = this.limit * (this.page - 1);
    }

    getSaveFilename() {
      const date = new Date().toISOString().substring(0, 10);
      return (
        filterFilename(
          `${this.userName}_${this.id}_${this.title}_p${this.savedPage}_${date}`
        ) + ".zip"
      );
    }
  }

  class TaskList extends TaskMultiPage {
    defaultParams = {
      word: "",
      order: "date_d",
      mode: "all",
      p: 1,
      s_mode: "s_tag_full",
      gs: 0,
      lang: "zh",
    };
    tag = "";

    check() {
      // https://www.pixiv.net/tags/标签/
      const exec = /^\/tags\/(.+)\/novels$/i.exec(window.location.pathname);
      if (!exec) {
        throw new Error(i18n("error_notlist"));
      }

      this.tag = decodeURIComponent(exec[1]);
      this.defaultParams.word = this.tag;
    }

    parseList(payload) {
      const { data, total } = payload.novel;
      return { data, total };
    }

    getUrl() {
      return "/ajax/search/novels/" + encodeURIComponent(this.tag);
    }

    getSaveFilename() {
      const date = new Date().toISOString().substring(0, 10);
      return filterFilename(`${this.tag}_p${this.savedPage}_${date}`) + ".zip";
    }
  }

  class TaskFavList extends TaskMultiPage {
    defaultParams = {
      tag: "",
      offset: 0,
      limit: 24,
      rest: "show",
      lang: "zh",
    };

    userId = "";

    check() {
      // https://www.pixiv.net/users/本人id/bookmarks/novels
      const exec = /^\/users\/(.+)\/bookmarks\/novels$/i.exec(
        window.location.pathname
      );
      if (!exec) {
        throw new Error(i18n("error_notfavlist"));
      }
      this.userId = exec[1];
    }

    parseList(payload) {
      const { works, total } = payload;
      const data = works.filter((item) => !!item.xRestrict);
      return { data, total };
    }

    getUrl() {
      return `/ajax/user/${this.userId}/novels/bookmarks`;
    }

    getSaveFilename() {
      const date = new Date().toISOString().substring(0, 10);
      return (
        filterFilename(`${i18n("txt_fav")}_p${this.savedPage}_${date}`) + ".zip"
      );
    }
  }

  new TaskPage("ui_dl_page");
  new TaskAuthor("ui_dl_author");
  new TaskSeries("ui_dl_series");
  new TaskList("ui_dl_list");
  new TaskFavList("ui_dl_favlist");
});