// ==UserScript==
// @name Pixiv Novel Download King
// @name:zh-CN Pixiv小说下载王
// @name:zh-TW Pixiv小說下載王
// @name:ja Pixiv小説ダウンロード王
// @name:ko Pixiv소설 다운로드 킹
// @namespace calary.tampermonkey
// @version 0.1
// @description Your pixiv novel download friend
// @description:zh-CN 您最好的pxiv小说下载朋友
// @description:zh-TW 您最好的pxiv小說下載朋友
// @description:ja Pixivから小説をダウンロードします
// @description:ko Pixiv에서 소설을 다운로드합니다
// @author eyeyani
// @license GPL-3.0
// @match https://*.pixiv.net/*
// @icon https://www.pixiv.net/favicon.ico
// @require https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/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
// ==/UserScript==
(function() {
'use strict';
const lang = (
window.navigator.language ||
window.navigator.browserLanguage ||
"en-us"
).toLowerCase();
const i18nMap = {
"en-us": {
ui_title: "Novel Download King",
ui_dl_page: "Download This Page",
ui_dl_author: "Batch Download This Author",
ui_dl_series: "Batch Download This Series",
ui_dl_list: "Batch Download This List",
ui_dl_favlist: "Batch Download Bookmark List",
ui_start: "START",
ui_pause: "PAUSE",
ui_resume: "RESUME",
ui_retry: "RETRY",
ui_cancel: "CANCEL",
ui_dl_current_page: "Current Page",
ui_all: "ZIP All",
ui_specific: "Specific Chapters",
ui_merge: "Merge into Single TXT",
ui_chapter: "Chapter(s)",
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 a bookmark page",
error_invalid_chapter_input: "Invalid chapter input.",
error_no_chapters_found: "No chapters found matching your input.",
ui_page: "Page",
ui_batch_download: "Batch Download",
ui_batch_download_options: "Batch Download Options",
ui_single_download: "Single Download",
ui_start_download: "Start Download",
ui_download_scope: "Download Scope:",
ui_auto_detect: "Auto Detect",
ui_scope_author: "Author",
ui_scope_series: "Series",
ui_scope_list: "List",
ui_scope_favlist: "Favorites",
ui_chapter_selection: "Chapter Selection:",
ui_all_chapters: "All",
ui_specific_chapters: "Specific",
ui_output_format: "Output Format:",
ui_format_zip: "ZIP",
ui_format_txt: "TXT",
ui_start_batch_download: "Start Batch Download"
},
"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_current_page: "当前页",
ui_all: "打包为ZIP",
ui_specific: "指定章节",
ui_merge: "合并TXT",
ui_chapter: "章节",
error_default: "出错了",
error_notpage: "该页不是小说页。",
error_notauthor: "该页不是作者主页。",
error_notseries: "该页不是系列页。",
error_notlist: "该页不是列表页。",
error_notfavlist: "该页不是收藏列表。",
error_invalid_chapter_input: "无效的章节输入。",
error_no_chapters_found: "没有找到符合您输入的章节。",
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: "收藏",
ui_page: "页",
ui_batch_download: "批量下载",
ui_batch_download_options: "批量下载选项",
ui_single_download: "单页下载",
ui_start_download: "开始下载此章",
ui_download_scope: "下载范围:",
ui_auto_detect: "自动检测",
ui_scope_author: "作者",
ui_scope_series: "系列",
ui_scope_list: "列表",
ui_scope_favlist: "收藏",
ui_chapter_selection: "章节选择:",
ui_all_chapters: "全部",
ui_specific_chapters: "指定",
ui_output_format: "输出格式:",
ui_format_zip: "ZIP",
ui_format_txt: "TXT",
ui_start_batch_download: "开始批量下载"
},
"zh-tw": {
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_current_page: "當前頁",
ui_all: "打包爲ZIP",
ui_specific: "指定章節",
ui_merge: "合併TXT",
ui_chapter: "章節",
error_default: "出錯了",
error_notpage: "該頁不是小說頁。",
error_notauthor: "該頁不是作者主頁。",
error_notseries: "該頁不是系列頁。",
error_notlist: "該頁不是列表頁。",
error_notfavlist: "該頁不是收藏列表。",
error_invalid_chapter_input: "無效的章節輸入。",
error_no_chapters_found: "沒有找到符合您輸入的章節。",
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: "收藏",
ui_page: "頁",
ui_batch_download: "批量下載",
ui_batch_download_options: "批量下載選項",
ui_single_download: "單頁下載",
ui_start_download: "開始下載此章",
ui_download_scope: "下載範圍:",
ui_auto_detect: "自動檢測",
ui_scope_author: "作者",
ui_scope_series: "系列",
ui_scope_list: "列表",
ui_scope_favlist: "收藏",
ui_chapter_selection: "章節選擇:",
ui_all_chapters: "全部",
ui_specific_chapters: "指定",
ui_output_format: "輸出格式:",
ui_format_zip: "ZIP",
ui_format_txt: "TXT",
ui_start_batch_download: "開始批量下載"
},
"ja": {
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_current_page: "現在のページ",
ui_all: "全部ZIPにする",
ui_specific: "指定章",
ui_merge: "TXTを結合",
ui_chapter: "チャプター",
error_default: "問題が発生しました",
error_notpage: "これは小説ページではありません。",
error_notauthor: "これは作者ページではありません。",
error_notseries: "これはシリーズページではありません。",
error_notlist: "これはリストページではありません。",
error_notfavlist: "これはブックマークページではありません",
error_invalid_chapter_input: "無効なチャプター入力です。",
error_no_chapters_found: "入力に一致するチャプターが見つかりませんでした。",
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: "お気に入り",
ui_page: "ページ",
ui_batch_download: "バッチダウンロード",
ui_batch_download_options: "バッチダウンロードオプション",
ui_single_download: "シングルダウンロード",
ui_start_download: "ダウンロードを開始",
ui_download_scope: "ダウンロード範囲:",
ui_auto_detect: "自動検出",
ui_scope_author: "作者",
ui_scope_series: "シリーズ",
ui_scope_list: "リスト",
ui_scope_favlist: "お気に入り",
ui_chapter_selection: "章の選択:",
ui_all_chapters: "すべて",
ui_specific_chapters: "特定",
ui_output_format: "出力フォーマット:",
ui_format_zip: "ZIP",
ui_format_txt: "TXT",
ui_start_batch_download: "バッチダウンロードを開始"
},
"ko": {
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_current_page: "현재 페이지",
ui_all: "전부 ZIP으로 묶기",
ui_specific: "지정 장",
ui_merge: "TXT를 병합",
ui_chapter: "챕터",
error_default: "문제가 발생했습니다",
error_notpage: "이것은 소설 페이지가 아닙니다.",
error_notauthor: "이것은 작가 페이지가 아닙니다.",
error_notseries: "이것은 시리즈 페이지가 아닙니다.",
error_notlist: "이것은 목록 페이지가 아닙니다.",
error_notfavlist: "이것은 북마크 페이지가 아닙니다",
error_invalid_chapter_input: "유효하지 않은 챕터 입력입니다.",
error_no_chapters_found: "입력과 일치하는 챕터를 찾을 수 없습니다。",
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: "즐겨찾기",
ui_page: "페이지",
ui_batch_download: "일괄 다운로드",
ui_batch_download_options: "일괄 다운로드 옵션",
ui_single_download: "단일 다운로드",
ui_start_download: "다운로드 시작",
ui_download_scope: "다운로드 범위 :",
ui_auto_detect: "자동 감지",
ui_scope_author: "작가",
ui_scope_series: "시리즈",
ui_scope_list: "목록",
ui_scope_favlist: "즐겨 찾기",
ui_chapter_selection: "장 선택 :",
ui_all_chapters: "모두",
ui_specific_chapters: "특정",
ui_output_format: "출력 형식 :",
ui_format_zip: "ZIP",
ui_format_txt: "TXT",
ui_start_batch_download: "일괄 다운로드 시작"
},
};
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 fontFamily = 'sans-serif';
function filterFilename(filename) {
return filename.replace(/\?|[*:"<>\\/|]/g, "");
}
function baseRequest(config) {
return new Promise((resolve, reject) => {
$.ajax({
timeout: 50000,
...config,
success: resolve,
error: (xhr, status, error) => {
if (config.signal && config.signal.aborted) {
reject(new Error("Request aborted"));
} else {
reject(new Error(i18n("error_default")));
}
},
});
});
}
function request(config) {
return baseRequest(config).then(({ error, message, body }) => {
if (error) {
throw new Error(message);
}
return body;
});
}
class Task {
constructor(title) {
this.title = title;
this.status = '';
this.$item = $(`<div class="task-item">
<span class="task-title">${i18n(title)}</span>
<span class="task-status">
<span class="current"></span> -
<span class="page"></span>
</span>
</div>`);
this.$status = this.$item.find(".task-status").hide();
this.$currentStatus = this.$item.find(".task-status .current");
this.$pageStatus = this.$item.find(".task-status .page");
}
start() {
this.status = 'running';
}
cancel() {
this.status = '';
}
isRunning() {
return this.status === 'running';
}
isCancelled() {
return this.status === 'cancelled';
}
isPaused() {
return this.status === 'paused';
}
checkRunning() {
if (!this.isRunning()) {
throw new Error("CANCELLED");
}
}
errorHandler(e) {
if (e.message === "CANCELLED") return;
this.error();
console.error(e);
alert(e);
}
async getWork(id, isSingle = false, signal) {
try {
const body = await request({
url: `/ajax/novel/${id}`,
responseType: "json",
signal: signal,
});
let content = body.content
.replace(/\[uploadedimage:\d+\]/g, '')
.replace(/\[PARAGRAPH\]/g, "\n")
.replace(/\[\[rb:(.+?) > (.+?)\]\]/g, '$1')
.replace(/\[newpage\]/g, '')
.replace(/^\s*\[chapter:(.*?)\]\s*/gim, '$1\n')
.replace(/^[ \t ]+/gm, '')
.replace(/(\r?\n|\r|\u2028|\u2029)(第[零一二三四五六七八九十]{1,3}章)/g, "$1\n$2")
.replace(/\\n/g, "\n")
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\n{2,}/g, '\n')
.replace(/(.+?)(?<!\n)$/gm, '$1\n')
.replace(/^\n+|\n+$/g, '');
let filename = '';
let chapterTitleForFilename = filterFilename(body.title);
if (isSingle) {
filename = `1_${chapterTitleForFilename}.txt`;
} else {
this.chapterCounter++;
const chapterNumber = String(this.chapterCounter);
filename = `${chapterNumber}_${chapterTitleForFilename}.txt`
}
// Add indentation to the beginning of the content
content = ` ${content}`;
return { id, filename, content: content.trim() };
} catch (error) {
if (error.name === 'AbortError') {
console.log(`Fetch aborted for novel ${id}`);
throw new Error("CANCELLED");
} else {
console.error(`Error fetching novel ${id}:`, error);
throw error;
}
}
}
}
class TaskMultiPage extends Task {
constructor(title) {
super(title);
this.bookTitle = "";
this.pageParam = "p";
this.offsetParam = "offset";
this.limitParam = "limit";
this.defaultParams = {};
this.page = 1;
this.finished = 0;
this.limit = 24;
this.total = 0;
this.pages = 0;
this.chapterCounter = 0;
this.mode = "all";
this.step = "";
this.url = null;
this.params = null;
this.promise = null;
this.entries = {};
this.allNovelIds = new Set();
this.specificChapters = null;
this.batchScope = "auto";
this.batchChapters = "all";
this.batchFormat = "zip";
this.paused = false;
this.cancelled = false;
this.currentWork = null;
this.activeControllers = {};
}
getUrl() { return ""; }
getSaveFilename() { return filterFilename(this.bookTitle) + ".zip"; }
check() {}
getInitData() { return Promise.resolve(); }
parseList(payload) { return payload; }
start() {
try {
this.check();
this.chapterCounter = 0;
} catch (e) {
alert(e);
return;
}
this.status = 'running';
$panel.find('.batch-download-btn').hide();
$panel.find('.pause-btn').show();
$panel.find('.cancel-btn').show();
$panel.find('.resume-btn').hide();
$batchProgress.hide();
this.batchScope = $panel.find('select[name="batch_scope"]').val();
this.batchChapters = $panel.find('input[name="batch_chapters"]:checked').val();
this.batchFormat = $panel.find('input[name="batch_format"]:checked').val();
if (this.batchChapters === 'specific') {
const chaptersInput = $panel.find('input[name="specific_chapters_input"]').val();
this.specificChapters = this.parseChapterInput(chaptersInput);
if (!this.specificChapters) {
alert(i18n('error_invalid_chapter_input'));
this.cancel();
return;
}
} else {
this.specificChapters = null;
}
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.allNovelIds.clear();
this.entries = {};
this.paused = false;
this.cancelled = false;
this.currentWork = null;
this.activeControllers = {};
this.getInitData().then(() => this.getNextList()).catch(this.errorHandler.bind(this));
}
parseChapterInput(input) {
if (!input) return null;
const ranges = input.split(',');
const chapters = new Set();
for (const range of ranges) {
const match = range.match(/(\d+)(?:-(\d+))?/);
if (!match) return null;
const start = parseInt(match[1], 10);
const end = match[2] ? parseInt(match[2], 10) : start;
if (isNaN(start) || isNaN(end) || start < 1 || end < start) return null;
for (let i = start; i <= end; i++) {
chapters.add(i);
}
}
return Array.from(chapters).sort((a, b) => a - b);
}
pause() {
this.paused = true;
$panel.find('.pause-btn').hide();
$panel.find('.resume-btn').show();
$batchProgress.show();
}
resume() {
this.paused = false;
$panel.find('.resume-btn').hide();
$panel.find('.pause-btn').show();
$batchProgress.show();
if (this.step === "list") {
this.getNextList();
} else if (this.step === "works") {
this.getWorks();
}
}
retry() {
this.page = 1;
this.allNovelIds.clear();
this.entries = {};
this.paused = false;
this.cancelled = false;
$panel.find('.resume-btn').hide();
$panel.find('.cancel-btn').hide();
$panel.find('.pause-btn').show();
$batchProgress.show();
this.getInitData().then(() => this.getNextList()).catch(this.errorHandler.bind(this));
}
isPaused() {
return this.paused;
}
isCancelled() {
return this.cancelled;
}
cancel() {
this.cancelled = true;
Object.values(this.activeControllers).forEach(controller => controller.abort());
this.activeControllers = {};
this.currentWork = null;
$panel.find('.batch-download-btn').show();
$panel.find('.pause-btn').hide();
$panel.find('.cancel-btn').hide();
$panel.find('.resume-btn').hide();
this.clearProgress();
this.finish();
}
finish() {
this.step = "idle";
this.clearProgress();
$panel.find('.batch-download-btn').show();
$panel.find('.pause-btn').hide();
$panel.find('.cancel-btn').hide();
$panel.find('.resume-btn').hide();
}
setParams() {
this.params[this.pageParam] = this.page;
this.params[this.limitParam] = this.limit;
this.params[this.offsetParam] = (this.page - 1) * this.limit;
}
getNextList() {
if (this.isPaused() || this.isCancelled()) return;
this.step = "list";
this.setParams();
this.promise = this.getList()
.then(({ data = [], total }) => {
if (this.isPaused() || this.isCancelled()) return;
this.total = total;
this.pages = Math.ceil(total / this.limit);
this.finished = 0;
this.updateStatus();
if (data.length === 0) return;
data.forEach(item => this.allNovelIds.add(item.id));
if (this.batchChapters === "all" && this.allNovelIds.size < this.total) {
this.page++;
this.getNextList();
} else {
this.getWorks();
}
})
.catch(this.errorHandler.bind(this));
}
async getList() {
if (this.isPaused() || this.isCancelled()) return { data: [], total: 0 };
this.setParams();
try {
const body = await request({
url: this.url,
data: this.params,
method: "get",
responseType: "json",
});
if (this.isPaused() || this.isCancelled()) return { data: [], total: 0 };
return this.parseList(body.page);
} catch (error) {
this.errorHandler(error);
return { data: [], total: 0 };
}
}
async getWorks() {
if (this.isPaused() || this.isCancelled()) return;
this.step = "works";
const novelIdsToDownload = this.batchChapters === 'specific'
? Array.from(this.allNovelIds).filter(id => this.specificChapters.includes(parseInt(id, 10)))
: Array.from(this.allNovelIds);
if (this.batchChapters === 'specific' && novelIdsToDownload.length === 0) {
alert(i18n('error_no_chapters_found'));
this.cancel();
return;
}
const getWorkSequentially = async () => {
for (const id of novelIdsToDownload) {
if (this.isPaused()) {
return;
}
if (this.isCancelled()) {
this.finish();
return;
}
try {
const currentRequestController = new AbortController();
this.activeControllers[id] = currentRequestController;
const signal = currentRequestController.signal;
const work = await this.getWork(id, false, signal);
delete this.activeControllers[id];
if (this.isCancelled()) {
this.finish();
return;
}
this.entries[id] = work;
this.updateStatus();
} catch (error) {
if (error.message !== "CANCELLED") {
this.errorHandler(error);
} else {
this.finish();
return;
}
if (this.isCancelled()) {
this.finish();
return;
}
}
}
if (!this.isPaused() && !this.isCancelled()) {
if (this.batchFormat === 'txt') {
this.downloadMergedText();
} else {
this.downloadZipped();
}
}
};
await getWorkSequentially();
}
downloadZipped() {
const zip = new JSZip();
let hasFile = false;
Object.values(this.entries).forEach(({ filename, content }) => {
hasFile = true;
zip.file(filename, content);
});
if (hasFile) {
zip.generateAsync({ type: "blob" })
.then(content => saveAs(content, this.getSaveFilename()));
}
this.finish();
}
downloadMergedText() {
let mergedContent = '';
const sortedEntries = Object.values(this.entries).sort((a, b) => parseInt(a.id, 10) - parseInt(b.id, 10));
const bookTitleForMerge = filterFilename(this.bookTitle);
let chapterNumber = 1;
let isFirstChapter = true;
sortedEntries.forEach(entry => {
const filenameParts = entry.filename.split('_');
let chapterTitle = '';
if (filenameParts.length > 1) {
chapterTitle = filenameParts.slice(1).join('_').replace('.txt', '');
} else {
chapterTitle = entry.filename.replace('.txt', '');
}
const chapterNumberRegex = /(?:(?:第(?=[零一二三四五六七八九十〇\d壹貳參肆伍陸柒捌玖拾佰仟])(?:[零一二三四五六七八九十〇\d壹貳參肆伍陸柒捌玖拾佰仟]+)[章話节節])(?:之[\d]+)?|第[\d]+[章話节節])|(?:卷|冊|辑|輯)[\s]?[\d]+|(?:Chapter|Chap|Part|Section|Segment|Book)[\.\s]?[\w\d]+|(?:Ch|Bk)[\.\s]?[\d]+|[\u4E00-\u9FFF]+(?:[\s]?[\d]+)?|(?:[第]?[\u4E00\u4E8C\u4E09\u56DB\u4E94\u516D\u4E03\u516B\u4E5D\u5341百千]+[章節話編巻書])(?:[\s・\-])?(?:[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\d]+)?|(?:\d+[章節話編巻書])(?:[\s・\-])?(?:[\u4E00-\u9FFF\u3040-\u309F\u30A0-\u30FF\d]+)?|\b节\b/gui;
if (!chapterNumberRegex.test(chapterTitle) && !/^(第[\d一二三四五六七八九十]+章)/.test(chapterTitle)) {
chapterTitle = `第${chapterNumber}章 ${chapterTitle}`;
}
if (!isFirstChapter) {
mergedContent += `\n\n${chapterTitle}\n\n`;
} else {
mergedContent += `${chapterTitle}\n\n`;
isFirstChapter = false;
}
mergedContent += entry.content;
chapterNumber++;
});
mergedContent = mergedContent.replace(/\[newpage\]/g, "");
mergedContent = mergedContent.replace(/\\n/g, "\n");
const filename = filterFilename(`${this.userName}_${bookTitleForMerge}`) + ".txt";
saveAs(new Blob([mergedContent], { type: "text/plain;charset=UTF-8" }), filename);
this.finish();
}
clearProgress() {
$batchProgress.empty().hide();
}
updateStatus() {
$batchProgress.show();
const { finished, total, pages } = this;
$batchProgress.html(`${Object.keys(this.entries).length}/${total}`);
}
}
class TaskPage extends Task {
constructor(title) {
super(title);
this.promise = null;
}
async getWork(id, isSingle = true, signal) {
try {
const body = await request({
url: `/ajax/novel/${id}`,
responseType: "json",
signal: signal,
});
const chapterNumber = String(1);
const chapterTitle = filterFilename(body.title);
const filename = `${chapterNumber}_${chapterTitle}.txt`;
let content = body.content
.replace(/\[uploadedimage:\d+\]/g, '')
.replace(/\[PARAGRAPH\]/g, "\n")
.replace(/\[\[rb:(.+?) > (.+?)\]\]/g, '$1')
.replace(/\[newpage\]/g, '')
.replace(/^\s*\[chapter:(.*?)\]\s*/gim, '$1\n')
.replace(/^[ \t ]+/gm, '')
.replace(/(\r?\n|\r|\u2028|\u2029)(第[零一二三四五六七八九十]{1,3}章)/g, "$1\n$2")
.replace(/\\n/g, "\n")
.replace(/\r\n/g, '\n')
.replace(/\r/g, '\n')
.replace(/\n{2,}/g, '\n')
.replace(/(.+?)(?<!\n)$/gm, '$1\n')
.replace(/^\n+|\n+$/g, '');
content = ` ${content}`;
return { id, filename, content: content.trim() };
} catch (error) {
if (error.name === 'AbortError') {
console.log(`Fetch aborted for novel ${id}`);
throw new Error("CANCELLED");
} else {
console.error(`Error fetching novel ${id}:`, error);
throw error;
}
}
}
start() {
const exec = /\/novel\/show.php\?id=(\d+)/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 (e.message !== "CANCELLED") {
this.errorHandler(e);
}
});
}
}
class TaskAuthor extends TaskMultiPage {
constructor(title) {
super(title);
this.defaultParams = {
limit: 10,
last_order: 0,
order_by: "asc",
lang: "zh",
};
this.id = "";
this.limit = 24;
this.tag = "";
this.userName = "";
this.workIds = null;
this.total = 0;
}
check() {
const pathname = window.location.pathname;
const exec2 = /^\/users\/(\d+)\/novels\/(.+)$/.exec(pathname);
const exec1 = /^\/users\/(\d+)(\/novels)*$/.exec(pathname);
this.id = "";
this.tag = "";
if (this.batchScope === "auto") {
if (exec2) {
this.id = exec2[1];
this.tag = decodeURIComponent(exec2[2]);
this.batchScope = "author";
} else if (exec1) {
this.id = exec1[1];
this.batchScope = "author";
} else {
throw new Error(i18n("error_notauthor"));
}
} else if (this.batchScope === "author"){
if (exec2) {
this.id = exec2[1];
this.tag = decodeURIComponent(exec2[2]);
} else if (exec1) {
this.id = exec1[1];
} else {
throw new Error(i18n("error_notauthor"));
}
} else {
throw new Error(i18n("error_notauthor"));
}
}
async getInitData() {
const [infoPayload, workPayload] = await Promise.all([
request({
url: `/ajax/user/${this.id}`,
method: "get",
data: { full: 1, lang: "zh" },
}),
request({
url: `/ajax/user/${this.id}/profile/all`,
method: "get",
data: { lang: "zh" },
})
]);
this.userName = infoPayload.name;
this.workIds = Object.keys(workPayload.novels).sort((a, b) => b - a);
this.total = this.workIds.length;
}
async getList() {
if (this.tag) {
return super.getList();
}
const { limit, page, workIds } = this;
const offset = limit * (page - 1);
return Promise.resolve({
total: workIds.length,
data: workIds.slice(offset, offset + limit).map(id => ({ id })),
});
}
parseList(payload) {
if (this.tag) {
return { data: payload.works, total: payload.total };
}
return { total: this.total, works: Object.values(payload.works) };
}
getUrl() {
return `/ajax/user/${this.id}/novels/tag`;
}
setParams() {
const { tag, limit, page } = this;
const offset = limit * (page - 1);
this.params = { tag, limit, offset, lang: "zh" };
}
getSaveFilename() {
return filterFilename(`${this.userName}_${this.batchFormat === 'txt' ? '合集' : '作品集'}`) + (this.batchFormat === 'txt' ? '.txt' : '.zip');
}
}
class TaskSeries extends TaskMultiPage {
constructor(title) {
super(title);
this.defaultParams = {
last_order: 0,
order_by: "asc",
lang: "zh",
};
this.id = "";
this.limit = 10;
this.title = "";
this.userName = "";
this.total = 0;
}
check() {
if (this.batchScope === "auto" || this.batchScope === "series"){
const exec = /^\/novel\/series\/(\d+)/i.exec(window.location.pathname);
if (!exec) {
throw new Error(i18n("error_notseries"));
}
this.id = exec[1];
} else {
throw new Error(i18n("error_notseries"));
}
}
async getInitData() {
const payload = await request({
url: `/ajax/novel/series/${this.id}`,
method: "get",
data: { lang: "zh" },
});
this.bookTitle = filterFilename(payload.title);
this.title = payload.title;
this.userName = payload.userName;
this.total = payload.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() {
return filterFilename(`${this.userName}_${this.bookTitle}`) + (this.batchFormat === 'txt' ? '.txt' : '.zip');
}
}
class TaskList extends TaskMultiPage {
constructor(title) {
super(title);
this.defaultParams = {
word: "",
order: "date_d",
mode: "all",
p: 1,
s_mode: "s_tag_full",
gs: 0,
lang: "zh",
};
this.tag = "";
}
check() {
if (this.batchScope === "auto" || this.batchScope === "list"){
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;
this.bookTitle = filterFilename(this.tag);
} else {
throw new Error(i18n("error_notlist"));
}
}
async getList() {
try {
const payload = await request({
url: `/ajax/search/novels/${encodeURIComponent(this.tag)}`,
responseType: "json",
});
this.checkRunning();
return this.parseList(payload.novel);
} catch (error) {
this.errorHandler(error);
return { data: [], total: 0 };
}
}
parseList(payload) {
const { data, total } = payload;
return { data, total };
}
getUrl() {
return `/ajax/search/novels/${encodeURIComponent(this.tag)}`;
}
getSaveFilename() {
return filterFilename(this.bookTitle) + (this.batchFormat === 'txt' ? '.txt' : '.zip');
}
}
class TaskFavList extends TaskMultiPage {
constructor(title) {
super(title);
this.defaultParams = {
tag: "",
offset: 0,
limit: 24,
rest: "show",
lang: "zh",
};
this.userId = "";
}
check() {
if (this.batchScope === "auto" || this.batchScope === "favlist"){
const exec = /^\/users\/(\d+)\/bookmarks\/novels$/i.exec(window.location.pathname);
if (!exec) {
throw new Error(i18n("error_notfavlist"));
}
this.userId = exec[1];
this.bookTitle = i18n("txt_fav");
} else {
throw new Error(i18n("error_notfavlist"));
}
}
async getList() {
try {
const payload = await request({
url: `/ajax/user/${this.userId}/novels/bookmarks`,
responseType: "json",
});
this.checkRunning();
return this.parseList(payload);
} catch (error) {
this.errorHandler(error);
return { data: [], total: 0 };
}
}
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() {
return filterFilename(this.bookTitle) + (this.batchFormat === 'txt' ? '.txt' : '.zip');
}
}
const taskPage = new TaskPage("ui_dl_page");
const taskAuthor = new TaskAuthor("ui_dl_author");
const taskSeries = new TaskSeries("ui_dl_series");
const taskList = new TaskList("ui_dl_list");
const taskFavList = new TaskFavList("ui_dl_favlist");
const $panel = $(`
<div class="pixiv-downloader-panel collapsed">
<span class="download-icon">⬇</span>
<h4 class="downloader-title">${i18n("ui_title")}</h4>
<div class="downloader-section single-download-section">
<h5 class="section-title">${i18n("ui_single_download")}</h5>
<button class="downloader-btn single-download-btn">${i18n("ui_start_download")}</button>
</div>
<div class="downloader-section batch-download-section">
<h5 class="section-title">${i18n("ui_batch_download")}</h5>
<div class="downloader-option">
<span>${i18n("ui_download_scope")}</span>
<select name="batch_scope">
<option value="auto" selected>${i18n("ui_auto_detect")}</option>
<option value="author">${i18n("ui_scope_author")}</option>
<option value="series">${i18n("ui_scope_series")}</option>
<option value="list">${i18n("ui_scope_list")}</option>
<option value="favlist">${i18n("ui_scope_favlist")}</option>
</select>
</div>
<div class="downloader-option">
<span>${i18n("ui_chapter_selection")}</span>
<label><input type="radio" name="batch_chapters" value="all" checked> ${i18n("ui_all_chapters")}</label>
<label><input type="radio" name="batch_chapters" value="specific"> ${i18n("ui_specific_chapters")}</label>
</div>
<div class="downloader-option specific-chapters" style="display: none;">
<span>${i18n("ui_chapter")}:</span>
<input type="text" name="specific_chapters_input" placeholder="id,id-id,id" />
</div>
<div class="downloader-option">
<span>${i18n("ui_output_format")}</span>
<label><input type="radio" name="batch_format" value="zip" checked> ${i18n("ui_format_zip")}</label>
<label><input type="radio" name="batch_format" value="txt"> ${i18n("ui_format_txt")}</label>
</div>
<button class="downloader-btn batch-download-btn">${i18n("ui_start_batch_download")}</button>
<button class="downloader-btn pause-btn" style="display: none;">${i18n("ui_pause")}</button>
<button class="downloader-btn resume-btn" style="display: none;">${i18n("ui_resume")}</button>
<button class="downloader-btn cancel-btn" style="display: none;">${i18n("ui_cancel")}</button>
<span class="batch-progress" style="margin-left: 10px;"></span>
</div>
<div class="collapse-btn"></div>
</div>
`);
$('body').append($panel);
const $collapseBtn = $panel.find('.collapse-btn').html('');
const $downloadIcon = $panel.find('.download-icon');
const $specificChapters = $panel.find('.specific-chapters');
const $batchProgress = $panel.find('.batch-progress');
$panel.on('change', 'input[name="batch_chapters"]', function() {
$specificChapters.toggle($(this).val() === 'specific');
});
$downloadIcon.on('click', function() {
if ($panel.hasClass('collapsed')) {
$collapseBtn.click();
}
});
$collapseBtn.on('click', function() {
const fullWidth = $panel.data('fullWidth') || 300;
const collapsedWidth = 30;
const isCollapsed = $panel.hasClass('collapsed');
if (isCollapsed) {
$panel.removeClass('collapsed').animate({
width: fullWidth,
paddingLeft: '10px'
}, 300, function() {
$panel.find('> *:not(.collapse-btn, .download-icon)').fadeIn(100);
$downloadIcon.hide();
$collapseBtn.html('◀');
});
} else {
$panel.addClass('collapsed').animate({
width: collapsedWidth,
paddingLeft: '5px'
}, 300, function() {
$panel.find('> *:not(.collapse-btn, .download-icon)').fadeOut(100);
$downloadIcon.show();
$collapseBtn.html('');
});
}
});
$panel.data('fullWidth', $panel.width());
$panel.find('> *:not(.download-icon, .collapse-btn)').hide();
$('head').append(`
<style>
.pixiv-downloader-panel {
position: fixed;
left: 10px;
top: 10px;
z-index: 999999;
background: #f8f8f8;
color: #333;
font-size: 14px;
font-family: ${fontFamily};
padding: 10px;
border-radius: 8px;
border: 1px solid #eee;
display: flex;
flex-direction: column;
align-items: center;
transition: width 0.3s ease-in-out, padding-left 0.3s ease-in-out, opacity 0.3s ease-in-out;
width: auto;
max-width: 400px;
overflow: hidden;
opacity: 0.95;
}
.pixiv-downloader-panel.collapsed {
width: 30px;
padding: 5px;
display: flex;
justify-content: center;
align-items: center;
}
.pixiv-downloader-panel.collapsed > .download-icon {
display: block;
}
.pixiv-downloader-panel.collapsed > *:not(.download-icon, .collapse-btn) {
display: none !important;
}
.pixiv-downloader-panel:not(.collapsed) {
padding-left: 10px;
}
.pixiv-downloader-panel:not(.collapsed) > .download-icon {
display: none;
}
.download-icon {
font-size: 1.5em;
color: black;
cursor: pointer;
display: block;
}
.downloader-title {
margin: 0 0 10px 0;
padding: 0;
font-size: 1.4em;
font-weight: bold;
display: block;
text-align: center;
width: 100%;
margin-bottom: 10px;
}
.downloader-section {
margin-bottom: 1px;
padding-bottom: 1px;
border-bottom: 1px solid #eee;
width: 100%;
}
.downloader-section:last-child {
border-bottom: none;
}
.downloader-section.single-download-section {
margin-bottom: 10px;
padding-bottom: 1px;
border-bottom: 1px solid #eee;
width: 100%;
}
.section-title {
margin-top: 0;
margin-bottom: 5px;
font-size: 1.1em;
font-weight: bold;
}
.downloader-option {
margin-bottom: 2px;
display: flex;
align-items: center;
font-size: 0.95em;
flex-wrap: wrap;
}
.downloader-option > span {
margin-right: 8px;
flex-shrink: 0;
display: flex;
align-items: center;
}
.downloader-option label {
display: flex;
align-items: center;
margin-right: 10px;
}
.downloader-option input[type="radio"],
.downloader-option input[type="checkbox"] {
margin: 0 5px 0 0;
flex-shrink: 0;
}
.downloader-option input[type="text"],
.downloader-option select {
margin-left: 5px;
padding: 6px;
border-radius: 4px;
border: 1px solid #ccc;
flex-grow: 1;
min-width: 0;
}
.downloader-btn {
background-color: #e0e0e0;
border: 1px solid #ccc;
border-radius: 4px;
padding: 8px 12px;
cursor: pointer;
font-size: 1.em;
margin-top: 5px;
}
.downloader-btn:hover {
background-color: #d0d0d0;
}
.collapse-btn {
position: absolute;
top: 5px;
right: 5px;
background: none;
border: none;
cursor: pointer;
font-size: 1em;
line-height: 1;
padding: 0;
color: #666;
transition: right 0.3s ease-in-out;
}
.pixiv-downloader-panel.collapsed .collapse-btn {
right: -5px;
}
.task-item {
flex-basis: 100%;
margin-bottom: 2px;
padding-bottom: 5px;
}
.task-item:last-child {
border-bottom: none;
}
.task-title {
flex-basis: 100%;
margin-bottom: 2px;
}
.task-status {
font-size: 0.9em;
color: #777;
margin-left: auto;
}
</style>
`);
$panel.find('.single-download-btn').off('click').on('click', () => taskPage.start());
$panel.find('.batch-download-btn').off('click').on('click', () => {
const selectedScope = $panel.find('select[name="batch_scope"]').val();
switch (selectedScope) {
case 'author':
taskAuthor.start();
break;
case 'series':
taskSeries.start();
break;
case 'list':
taskList.start();
break;
case 'favlist':
taskFavList.start();
break;
default:
try {
taskAuthor.check();
taskAuthor.start();
} catch (e) {
try {
taskSeries.check();
taskSeries.start();
} catch (e) {
try {
taskList.check();
taskList.start();
} catch (e) {
try {
taskFavList.check();
taskFavList.start();
} catch (e) {
alert(i18n("error_default"));
}
}
}
}
}
});
$panel.find('.pause-btn').on('click', () => {
if (taskAuthor.isRunning()) {
taskAuthor.pause();
} else if (taskSeries.isRunning()) {
taskSeries.pause();
} else if (taskList.isRunning()) {
taskList.pause();
} else if (taskFavList.isRunning()) {
taskFavList.pause();
}
});
$panel.find('.resume-btn').on('click', () => {
if (taskAuthor.isPaused()) {
taskAuthor.resume();
} else if (taskSeries.isPaused()) {
taskSeries.resume();
} else if (taskList.isPaused()) {
taskList.resume();
} else if (taskFavList.isPaused()) {
taskFavList.resume();
}
});
$panel.find('.cancel-btn').on('click', () => {
if (taskAuthor.isRunning() || taskAuthor.isPaused()) {
taskAuthor.cancel();
} else if (taskSeries.isRunning() || taskSeries.isPaused()) {
taskSeries.cancel();
} else if (taskList.isRunning() || taskList.isPaused()) {
taskList.cancel();
} else if (taskFavList.isRunning() || taskFavList.isPaused()) {
taskFavList.cancel();
}
});
})();