// ==UserScript==
// @name Pixiv Novel Downloader
// @name:zh-CN Pixiv 小说下载器
// @namespace http://tampermonkey.net/
// @version 0.8
// @description:vi Tải Truyện Novel Cho Pixiv
// @description:zh-CN 从Pixiv下载小说
// @author TieuThanhNhi
// @license GPL-3.0
// @include http*://www.pixiv.net*
// @match https://www.pixiv.net/*
// @icon http://www.pixiv.net/favicon.ico
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @grant none
// @run-at document-end
// @description Download novels from Pixiv
// ==/UserScript==
(function () {
'use strict';
// Internationalization
const lang = (window.navigator.language || window.navigator.browserLanguage || "vi-vn").toLowerCase();
const i18nMap = {
"vi-vn": {
"ui_title": "Trình Tải Tiểu Thuyết",
"ui_dl_page": "Tải xuống Tác phẩm",
"ui_dl_author": "Tải Xuống Toàn Bộ Tác Giả",
"ui_dl_series": "Tải Xuống Toàn Bộ Loạt Truyện",
"ui_dl_list": "Tải Xuống Toàn Bộ Danh Sách",
"ui_dl_favlist": "Tải Xuống Danh Sách Yêu Thích",
"ui_start": "BẮT ĐẦU",
"ui_pause": "TẠM DỪNG",
"ui_resume": "TIẾP TỤC",
"ui_retry": "THỬ LẠI",
"ui_cancel": "HỦY",
"ui_dl": "Tải xuống",
"ui_mode": "Chế độ",
"ui_page": "Trang",
"ui_all": "Tất Cả",
"ui_current": "Hiện tại",
"ui_inludelikes": "Tên Tập Tin Bao Gồm Likes",
"error_default": "Đã xảy ra sự cố",
"error_notpage": "Đây không phải trang tiểu thuyết.",
"error_notauthor": "Đây không phải trang tác giả.",
"error_notseries": "Đây không phải trang loạt truyện.",
"error_notlist": "Đây không phải trang danh sách.",
"error_notfavlist": "Đây không phải trang yêu thích",
"txt_title": "Tiêu đề: ",
"txt_novelid": "ID Tiểu Thuyết: ",
"txt_author": "Tác giả: ",
"txt_authorid": "ID Tác Giả: ",
"txt_words": "Số Từ: ",
"txt_likes": "Likes: ",
"txt_createtime": "Thời Gian Tạo: ",
"txt_updatetime": "Thời Gian Cập Nhật: ",
"txt_tags": "Từ Khóa: ",
"txt_desc": "Mô Tả: ",
"txt_words2": "Từ",
"txt_likes2": "Likes",
"txt_pageno": "Trang {0}",
"txt_fav": "Yêu Thích",
},
"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_mode": "模式",
"ui_page": "页",
"ui_all": "全部",
"ui_current": "当前",
"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["zh-cn"][key];
args.forEach((value, index) => {
str = str.replace(`{${index}}`, value);
});
return str;
};
// Constants
const website = "pixiv";
const fontFamily = "Arial, 'Microsoft Yahei', Helvetica, sans-serif";
const noop = () => { };
// jQuery UI Panel
const $panel = jQuery(`
<div class="pnd-panel">
<h3>${i18n("ui_title")}</h3>
<div class="pnd-group">
<label>
${i18n("ui_dl")}:
<input type="radio" name="dl_mode" value="all" checked>${i18n("ui_all")}
<input type="radio" name="dl_mode" value="page">${i18n("ui_current")}
</label>
</div>
<div class="pnd-group">
<label>
${i18n("ui_inludelikes")}:
<input type="checkbox" name="dl_includelikes" value="1">
</label>
</div>
<div class="pnd-tasks"></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(jQuery("body"));
// Utility Functions
function baseRequest(config) {
return new Promise((resolve, reject) => {
jQuery.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 Promise.reject(new Error(message));
}
return body;
});
}
// 过滤文件名非法字符
function filterFilename(filename) {
return filename.replace(/[\?*\:"<>\\\/\|]/g, "");
}
function wait(delay, ctrl = {}) {
return new Promise((resolve, reject) => setTimeout(resolve, delay));
}
// Task Class
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 = jQuery(`
<div class="pnd-task">
<span class="pnd-task-title">${i18n(this.title)}</span>
<button class="start pnd-btn pnd-btn-primary">${i18n("ui_start")}</button>
<button class="pause pnd-btn pnd-btn-warning">${i18n("ui_pause")}</button>
<button class="resume pnd-btn pnd-btn-success">${i18n("ui_resume")}</button>
<button class="retry pnd-btn pnd-btn-danger">${i18n("ui_retry")}</button>
<button class="cancel pnd-btn pnd-btn-secondary">${i18n("ui_cancel")}</button>
<span class="status"> -
<span class="current">-</span> /
<span class="total">-</span>
(${i18n("ui_page")}: <span class="page">-</span>)
</span>
</div>
`).appendTo($panel.find('.pnd-tasks'));
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");
this.$totalStatus = $item.find(".status .total");
}
start() {
this.status = "running";
this.includeLikes = jQuery("input[name='dl_includelikes']:checked").prop('checked');
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(/\r\n|\n|\r/g, "\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,
};
});
}
}
// Multi-Page Task Class
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 = jQuery("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) {
if (this.mode === 'all' && this.page < this.pages) {
this.page++;
this.getNextList();
} else {
this.cancel();
}
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;
this.promises = Array.from(ids).map((id) => {
return this.getWork(id)
.then((work) => {
this.checkRunning();
this.finished++;
this.ids.delete(id);
this.entries[id] = work;
this.updateStatus();
return wait(100);
})
.catch(this.errorHandler);
});
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}/${pages}`
);
this.$totalStatus.html(`${total}`);
}
}
// Single Page Task Class
class TaskPage extends Task {
promise = null;
init() {
super.init();
this.$pause.remove();
this.$resume.remove();
this.$retry.remove();
this.$pageStatus.parent().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);
});
}
}
// Author Task Class
class TaskAuthor extends TaskMultiPage {
defaultParams = {
limit: 24,
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,
};
}
return {
total: this.total,
data: this.workIds.slice(0, this.limit).map((id) => {
return { id };
}),
};
}
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";
}
}
// Series Task Class
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"
);
}
}
// List Task Class
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";
}
}
// Favorite List Task Class
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"
);
}
}
// Initialize Tasks
jQuery(function ($) {
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");
});
})();