// ==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.2.2 // @description Your best Pixiv novel downloader, supporting downloads of single works, series, author collections, tag-based collections, and bookmarks // @description:zh-CN 您最好的pixiv小说下载助手,支持下载单页、系列、作者、标签、收藏夹的小说 // @description:zh-TW 您最好的pixiv小說下載助手,支持下載單頁、系列、作者、標籤、收藏夾的小說 // @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 // @downloadURL https://update.greasyfork.icu/scripts/523666/Pixiv%20Novel%20Download%20King.user.js // @updateURL https://update.greasyfork.icu/scripts/523666/Pixiv%20Novel%20Download%20King.meta.js // ==/UserScript== (function() { 'use strict'; const config = { maxConcurrent: 5, // 默认并发数 }; 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 = $(`
${i18n(title)} -
`); 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(/\[pixivimage:\d+\]/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(/(.+?)(? 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; $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 queue = novelIdsToDownload.slice(); const results = []; const workers = Array(config.maxConcurrent).fill(null).map(() => { return new Promise(async (resolve, reject) => { while (queue.length > 0) { if (this.isPaused() || this.isCancelled()) return resolve(); const id = queue.shift(); try { const currentRequestController = new AbortController(); // 仍然需要 AbortController const signal = currentRequestController.signal; const work = await this.getWork(id, false, signal); if (this.isCancelled()) { this.finish(); return resolve(); } results.push(work); this.entries[id] = work; this.updateStatus(); //更新状态 } catch (error) { if(error.message !== "CANCELLED"){ this.errorHandler(error); } else { this.finish(); return resolve(); } } } resolve(); }); }); await Promise.all(workers); if (!this.isPaused() && !this.isCancelled()) { results.forEach(work => { this.entries[work.id] = work; }); if (this.batchFormat === 'txt') { this.downloadMergedText(); } else { this.downloadZipped(); } } } 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]+)?)|第[0-9]+[章回节節集話话幕段]|(?:卷|冊|辑|輯)[\s]?[\d]+|(?:Chapter|Chap|Part|Section|Segment|Book)[\.\s]?[\d]+|(?:Ch|Bk)[\.\s]?[\d]+|(?:\d+[章回节集節話话幕段])|(?:序章|尾[声聲]|引子|楔子|序[言]?|终章|終章|[正]?番外|彩蛋|花絮|完[结結]|[终終]回|最[终終](?:[话話回囘]))[\s]?[\d]*[\s]?[\d]*|#\d+/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 downloadedCount = Object.keys(this.entries).length; $batchProgress.html(`Downloaded: ${downloadedCount}/${this.total}`); // 移除可选部分:显示当前下载的章节 // if (this.currentWork) { // $batchProgress.append(`
${this.currentWork.title}`); //} } } 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(/\[pixivimage:\d+\]/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(/(.+?)(? { 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 = $(` `); $('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(` `); $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(); } }); })();