Pixiv Novel Downloader

Download novels from Pixiv

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

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

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

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

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

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

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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");
});