Pixiv 小说下载/小说系列打包下载

Pixiv 下载小说/小说系列打包下载

// ==UserScript==
// @name        Pixiv 小说下载/小说系列打包下载
// @name:en     Pixiv Novel download/Novel series batch download
// @name:zh-cn  Pixiv 小说下载/小说系列打包下载
// @name:zh-tw  Pixiv 小說下載/小說系列打包下載
// @namespace   https://pixiv.net/
// @version     1.1
// @author      huyaoi
// @description Pixiv 下载小说/小说系列打包下载
// @description:en Pixiv Novel download/Novel series download
// @description:zh-cn Pixiv 下载小说/小说系列
// @description:zh-tw Pixiv 下載小說/小說系列
// @match       https://www.pixiv.net/*
// @icon        data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant       GM_registerMenuCommand
// @grant       GM_setValue
// @grant       GM_getValue
// @run-at      document-end
// @license     MIT
// @require     https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @require     https://scriptcat.org/lib/513/2.0.0/ElementGetter.js#sha256=KbLWud5OMbbXZHRoU/GLVgvIgeosObRYkDEbE/YanRU=
// ==/UserScript==

(function() {
    'use strict';
    if (window.self !== window.top) {
        return;
    }
    const apiEndpoint = "https://www.pixiv.net/ajax";

    const translations = {
        en: {
            dlSeries:"Download this novel series",
            dlSeriesWithInfo:"Download this novel series (With information)",
            dlSeriesNotID:"The current page is not a novel series page!",
            dlNovel:"Download this novel",
            dlNovelWithInfo:"Download this novel (With information)",
            dlNovelNotID:"The current page is not a novel page!",
            dlSeriesMerge:"Download this series (combined into one file without any information)",
            dlSeriesMergeWithInfo:"Download this series (combined into one file)",
            panel: 'File Naming Convention'
        },
        zh: {
            dlSeries:"打包下载这个小说系列 (仅内容)",
            dlSeriesWithInfo:"打包下载这个小说系列 (带信息)",
            dlSeriesNotID:"当前页面不是小说系列页面!",
            dlNovel:"下载此小说 (仅内容)",
            dlNovelWithInfo:"下载此小说 (带信息)",
            dlNovelNotID:"当前页面不是小说页面!",
            dlSeriesMerge:"下载这个小说系列(合并为一个文件并且不带任何信息)",
            dlSeriesMergeWithInfo:"下载这个小说系列(合并为一个文件)",
            panel: '文件名设定'
        }
    };

    function translate(key) {
        if(navigator.language.startsWith("zh")){
            return translations['zh'][key];
        }else{
            return translations['en'][key];
        }
    }

    const style = document.createElement('style');
    style.innerHTML = `
.btn-style {
    color: var(--charcoal-text5-hover);
    background-color: var(--charcoal-brand-hover);
    font-size: 14px;
    line-height: 1;
    font-weight: bold;
    border-radius: 20px;
    -moz-box-pack: center;
    justify-content: center;
    cursor: pointer;
    user-select: none;
    border-style: none;
    margin-left: 8px;
    padding: 0 24px;
}

.btn-style-novel {
    color: var(--charcoal-text5-hover);
    background-color: var(--charcoal-brand-hover);
    font-size: 14px;
    line-height: 30px;
    font-weight: bold;
    border-radius: 20px;
    -moz-box-pack: center;
    cursor: pointer;
    user-select: none;
    border-style: none;
    margin-left: 8px;
    padding: 0 24px;
    display: flex;
}

.novel-dl-panel {
    position: fixed;
    top: 80px;
    right: 40px;
    width: 280px;
    background: rgba(255, 255, 255, 0.95);
    border: 1px solid #ccc;
    border-radius: 10px;
    box-shadow: 0 2px 8px rgba(0,0,0,0.2);
    z-index: 99999;
    padding: 12px;
    font-family: "Segoe UI", sans-serif;
}
.novel-dl-panel input {
    width: 100%;
    box-sizing: border-box;
    margin-top: 5px;
    margin-bottom: 8px;
    padding: 4px;
    border: 1px solid #aaa;
    border-radius: 5px;
}

`;
    document.head.appendChild(style);

    window.onhashchange=function(event){
        if(GetURLQueryValue("id",event.newURL) == null && event.newURL.split('/series/').length != 2){
            return;
        }
        elmGetter.each('section>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)', document, ele => {
            let element = ele.lastChild;
            let btn = document.createElement('button');
            btn.setAttribute('class', 'btn-style');
            btn.addEventListener('mouseup',function(){
                DownloadSeries(false);
            });
            btn.innerText = translate('dlSeries');
            element.appendChild(btn);
        });

        elmGetter.each('section>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)', document, ele => {
            let element = ele.lastChild;
            let btn = document.createElement('button');
            btn.setAttribute('class', 'btn-style-novel');
            btn.addEventListener('mouseup',function(){
                DownloadNovel(false);
            });
            btn.innerText = translate('dlNovel');
            element.appendChild(btn);
        });
    }

    if(GetQueryValue("id") != null || window.location.href.split('/series/').length == 2){
        elmGetter.each('section>div:nth-child(1)>div:nth-child(2)>div:nth-child(2)', document, ele => {
            let element = ele.lastChild;
            let btn = document.createElement('button');
            btn.setAttribute('class', 'btn-style');
            btn.addEventListener('mouseup',function(){
                DownloadSeries(false);
            });
            btn.innerText = translate('dlSeries');
            element.appendChild(btn);
        });

        elmGetter.each('section>div:nth-child(1)>div:nth-child(1)>div:nth-child(2)', document, ele => {
            let element = ele.lastChild;
            let btn = document.createElement('button');
            btn.setAttribute('class', 'btn-style-novel');
            btn.addEventListener('mouseup',function(){
                DownloadNovel(false);
            });
            btn.innerText = translate('dlNovel');
            element.appendChild(btn);
        });
    }

    async function fetchJson(url) {
        return await fetch(url).then(result => result.json());
    }

    function GetQueryValue(queryName) {
        let query = decodeURI(window.location.search.substring(1));
        let vars = query.split("&");
        for (let i = 0; i < vars.length; i++) {
            let pair = vars[i].split("=");
            if (pair[0] === queryName) { return pair[1]; }
        }
        return null;
    }

    function GetURLQueryValue(queryName,url) {
        if(url.lastIndexOf('?') == -1){
            return;
        }
        let query = decodeURI(url.substring(url.lastIndexOf('?') + 1,url.length));
        let vars = query.split("&");
        for (let i = 0; i < vars.length; i++) {
            let pair = vars[i].split("=");
            if (pair[0] === queryName) { return pair[1]; }
        }
        return null;
    }

    function CreateHeader(data){
        //替换掉","和"/"
        let tags = data.tags.tags.map(tag => tag.tag).join(", ");
        tags = tags.replaceAll(",",", ");
        tags = tags.replaceAll("/",", ");
        return `id: ${data.id}
user: ${data.userName} [${data.userId}]
title: ${data.title}
lang: ${data.language}
tags: ${tags}
count: ${data.characterCount}
description: ${data.description}
create: ${data.createDate}
update: ${data.uploadDate}
content:
${data.content}
`
    }

    function CreateSeriesHeader(data){
        //替换掉","和"/"
        let tags = data.tags.map(tag => tag.tag).join(", ");
        tags = tags.replaceAll(",",", ");
        tags = tags.replaceAll("/",", ");
        return `id: ${data.id}
user: ${data.userName} [${data.userId}]
title: ${data.title}
lang: ${data.language}
tags: ${tags}
count: ${data.characterCount}
caption: ${data.caption}
create: ${data.createDate}
update: ${data.uploadDate}
content:

`
    }

    function CreateSeriesNovelHeader(index,data){
        return `---
#${index} ${data.title}
---
`
    }

    function DownloadFile(content,filename){
        const blob = new Blob([content]);
        const t = document.createElement('a');
        const href = URL.createObjectURL(blob);
        t.setAttribute('href', href);
        t.setAttribute('download', filename);
        t.click();
        window.URL.revokeObjectURL(href);
    }

    async function DownloadNovel(withInfo){
        if(GetQueryValue("id") == null){
            alert(translate('dlNovelNotID'));
            return;
        }
        let novelID = GetQueryValue("id");

        const data = await GetNovel(novelID);
        if(data != null){
            let Content = "";
            if(withInfo){
                Content = CreateHeader(data);
            }else{
                Content = data.content;
            }
            let template = GM_getValue('novelname', '%novelID%_%novelTitle%');
            let name = renderTemplate(template, {
                            novelID: data.id,
                            novelUserID: data.userId,
                            novelTitle: data.title,
                            novelUserName: data.userName,
                        });
            DownloadFile(Content,`${name}.txt`);
        }
    }

    async function GetNovel(novelID){
        let url = apiEndpoint + `/novel/${novelID}`;

        return await fetchJson(url).then(data => {
            return data.body;
        })
        .catch(err => {
            console.log("获取失败");
            console.log(err);
            return null;
        });
    }

    async function GetSeriesContent(id,last){
        let contentUrl = apiEndpoint + `/novel/series_content/${id}?limit=30&last_order=${last}&order_by=asc`;
        return await fetchJson(contentUrl).then(data => {
            return data.body;
        })
        .catch(err => {
            console.log("获取失败");
            console.log(err);
            return null;
        });
    }

    async function DownloadSeries(withInfo){
        let tmp = window.location.href.split('/series/');
        if(tmp.length != 2){
            alert(translate('dlSeriesNotID'));
            return;
        }
        let seriesID = tmp[1];

        let novelInfoUrl = apiEndpoint + `/novel/series/${seriesID}`;
        let displaySeriesContentCount = 0;
        let title = "";

        await fetchJson(novelInfoUrl).then(data => {
            displaySeriesContentCount = data.body.displaySeriesContentCount || 0;
            title = data.body.title;
        })
        .catch(err => {
            console.log("获取失败");
            console.log(err);
            return;
        });

        console.log(displaySeriesContentCount);
        if(displaySeriesContentCount == 0){
            return;
        }

        let zip = new JSZip();
        let index = 0;
        let template = GM_getValue('seriesname', '%seriesID%_%seriesIndex%_%novelID%_%novelTitle%');

        let maxPage = Math.ceil(displaySeriesContentCount/30);
        for(let o = 0;o < maxPage;o++){
            let data = await GetSeriesContent(seriesID,o * 30);
            if(data!=null){
                for(let i = 0;i <data.page.seriesContents.length;i ++){
                    let novel = await GetNovel(data.page.seriesContents[i].id);
                    if(novel != null){
                        let Content = "";
                        if(withInfo){
                            Content = CreateHeader(novel);
                        }else{
                            Content = novel.content;
                        }
                        let name = renderTemplate(template, {
                            seriesID: seriesID,
                            seriesName: title,
                            seriesIndex: index,
                            novelID: novel.id,
                            novelUserID: novel.userId,
                            novelTitle: novel.title,
                            novelUserName: novel.userName,
                        });
                        await zip.file(`${name}.txt`, Content);
                        index++;
                        if(index >= displaySeriesContentCount){
                            console.log("Start");
                            DownloadFile(zip.generate({type:"blob"}), `${seriesID}_${title}.zip`);
                        }
                    }
                }
            }
        }
    }

    async function DownloadSeriesMarge(withInfo){
        let tmp = window.location.href.split('/series/');
        if(tmp.length != 2){
            alert(translate('dlSeriesNotID'));
            return;
        }
        let seriesID = tmp[1];

        let novelInfoUrl = apiEndpoint + `/novel/series/${seriesID}`;
        let displaySeriesContentCount = 0;
        let title = "";
        let seriesHeader = "";

        await fetchJson(novelInfoUrl).then(data => {
            displaySeriesContentCount = data.body.displaySeriesContentCount || 0;
            title = data.body.title;
            seriesHeader = CreateSeriesHeader(data.body);
        })
        .catch(err => {
            console.log("获取失败");
            console.log(err);
            return;
        });

        console.log(displaySeriesContentCount);
        if(displaySeriesContentCount == 0){
            return;
        }

        let novelContent = "";

        let index = 0;

        let maxPage = Math.ceil(displaySeriesContentCount/30);
        for(let o = 0;o < maxPage;o++){
            let data = await GetSeriesContent(seriesID,o * 30);
            if(data!=null){
                for(let i = 0;i <data.page.seriesContents.length;i ++){
                    let novel = await GetNovel(data.page.seriesContents[i].id);
                    if(novel != null){
                        if(withInfo && index == 0){
                            novelContent += seriesHeader;
                        }
                        if(withInfo){
                            novelContent += CreateSeriesNovelHeader(index+1,novel.body);
                        }
                        novelContent += novel.content;
                        index++;
                        if(index >= displaySeriesContentCount){
                            console.log("Start");
                            DownloadFile(novelContent, `${seriesID}_${title}.txt`);
                        }
                    }
                }
            }
        }
    }

    const validseriesVars = ["novelID", "novelUserID", "novelTitle", "novelUserName", "seriesID", "seriesName", "seriesIndex"];
    const validnovelVars = ["novelID", "novelUserID", "novelTitle", "novelUserName"];

    function createPanel() {
        const panel = document.createElement('div');
        panel.className = 'novel-dl-panel';
        panel.innerHTML = `
        <h3>文件命名设置</h3>
            <label>模板:</label>
            <br />
            <div>%novelID% 小说ID</div>
            <div>%novelUserID% 小说作者ID</div>
            <div>%novelTitle% 小说名称</div>
            <div>%novelUserName% 小说作者ID</div>
            <div>%seriesID% 小说合集ID</div>
            <div>%seriesName% 小说合集名称</div>
            <div>%seriesIndex% 小说在合集中的顺序</div>
            <br />
            <div>单独下载的小说文件名(无需.txt后缀)</div>
            <input type="text" id="dlnovel-filename" placeholder="%novelID%_%novelTitle%" />
            <div>下载合集时的小说文件名(无需.txt后缀)</div>
            <input type="text" id="dlseries-filename" placeholder="%seriesID%_%seriesIndex%_%novelID%_%novelTitle%" />
        <div style="text-align:right;">
            <button class="btn-style" id="save-template">保存</button>
            <button class="btn-style" id="close-panel">关闭</button>
        </div>
        `;
        document.body.appendChild(panel);

        const novelinput = panel.querySelector('#dlnovel-filename');
        novelinput.value = GM_getValue('novelname', '%novelID%_%novelTitle%');
        const seriesinput = panel.querySelector('#dlseries-filename');
        seriesinput.value = GM_getValue('seriesname', '%seriesID%_%seriesIndex%_%novelID%_%novelTitle%');

        // 保存
        panel.querySelector('#save-template').addEventListener('click', () => {
            let t1 = ["novelID"];
            let t2 = ["seriesID", "seriesIndex"];
            let r1 = validateTemplate(novelinput.value, validnovelVars,t1);
            let r2 = validateTemplate(seriesinput.value,validseriesVars,t2);
            if(!r1.ok){
                alert(r1.message);
                return;
            }
            if(!r2.ok){
                alert(r2.message);
            }
            GM_setValue('novelname', novelinput.value);
            GM_setValue('seriesname', seriesinput.value);
            alert('已保存模板');
        });

        // 关闭
        panel.querySelector('#close-panel').addEventListener('click', () => {
            panel.remove();
        });
    }

    function renderTemplate(template, context) {
        return template.replace(/%(\w+)%/g, (_, key) => context[key] || '');
    }

    function validateTemplate(template, validVars, requiredVars = []) {
        // 只把形如 %name%(变量名由字母数字下划线组成)当作完整变量
        const varRegex = /%([A-Za-z0-9_]+)%/g;
        const matches = [];
        const matchRanges = [];
        let m;

        while ((m = varRegex.exec(template)) !== null) {
            matches.push(m[1]);
            // m.index 是匹配起始位置,varRegex.lastIndex 是匹配结束后的索引
            matchRanges.push({ start: m.index, end: varRegex.lastIndex - 1 });
        }

        // 标记被完整匹配覆盖的字符范围
        const covered = new Array(template.length).fill(false);
        for (const r of matchRanges) {
            for (let i = r.start; i <= r.end; i++) covered[i] = true;
        }

        // 查找不在完整匹配范围内的孤立 '%'(即未配对的 %)
        const unmatchedSnippets = [];
        for (let i = 0; i < template.length; i++) {
            if (template[i] === '%' && !covered[i]) {
            // 截取示例片段便于提示(前后各最多 10 个字符)
            const s = Math.max(0, i - 10);
            const e = Math.min(template.length, i + 11);
            unmatchedSnippets.push(template.slice(s, e));
            }
        }
        if (unmatchedSnippets.length > 0) {
            // 去重示例并返回错误
            const uniq = [...new Set(unmatchedSnippets)];
            return {
            ok: false,
            message: `检测到未配对的 '%' 或不完整变量边界(示例):${uniq.join(',')}`
            };
        }

        // 检查非法变量(完整形式但是变量名未在允许列表中)
        const invalids = [...new Set(matches.filter(v => !validVars.includes(v)))];
        if (invalids.length > 0) {
            return {
            ok: false,
            message: `发现无效变量:${invalids.map(v => '%' + v + '%').join(', ')};允许的有:${validVars.map(v => '%' + v + '%').join(', ')}`
            };
        }

        // 检查必需变量是否存在
        const missing = requiredVars.filter(v => !matches.includes(v));
        if (missing.length > 0) {
            return {
            ok: false,
            message: `缺少必需变量:${missing.map(v => '%' + v + '%').join(', ')}`
            };
        }

        return { ok: true, message: '模板合法', matches: [...new Set(matches)] };
    }

    GM_registerMenuCommand(translate('dlNovel'), () => DownloadNovel(false));
    GM_registerMenuCommand(translate('dlSeries'), () => DownloadSeries(false));
    GM_registerMenuCommand(translate('dlNovelWithInfo'), () => DownloadNovel(true));
    GM_registerMenuCommand(translate('dlSeriesWithInfo'), () => DownloadSeries(true));
    GM_registerMenuCommand(translate('dlSeriesMerge'), () => DownloadSeriesMarge(false));
    GM_registerMenuCommand(translate('dlSeriesMergeWithInfo'), () => DownloadSeriesMarge(true));
    GM_registerMenuCommand(translate('panel'), () => createPanel());
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址