Greasy Fork

来自缓存

Greasy Fork is available in English.

Pixiv 小说下载器

从Pixiv下载小说

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 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");
});