Kemono助手

提供更好的Kemono使用体验

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Kemono助手
// @version      2.2.3
// @description  提供更好的Kemono使用体验
// @author       ZIDOUZI
// @match        https://*.kemono.party/*
// @match        https://*.kemono.su/*
// @match        https://*.kemono.cr/*
// @match        https://*.nekohouse.su/*
// @icon         https://kemono.su/static/favicon.ico
// @require      https://cdn.jsdelivr.net/npm/sweetalert2@11
// @require      https://kit.fontawesome.com/101092ae56.js
// @grant        GM_xmlhttpRequest
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @license      GPL-3.0
// @namespace https://greasyfork.org/users/448292
// ==/UserScript==

(async function () {
    try {
        const root = document.querySelector('#root')
        const observer = new MutationObserver(async function (mutations) {
            observer.disconnect();
            for (const mutation of mutations) {
                if (mutation.type === "childList" && mutation.addedNodes.length > 0 && await wrapper() !== undefined) break
            }
        });
        observer.observe(root, { childList: true, subtree: true })
    } catch (e) {
        console.log(e)
        await wrapper()
    }
})();

async function wrapper() {
    try {
        return await main()
    } catch (error) {
        console.log(error)
        alert(`Kemono助手发生错误: ${error.status} ${error.statusText}`)
        return undefined
    }
}

async function main() {

    const language = navigator.language || navigator.userLanguage;

    const domain = window.location.href.match(/https:\/\/([^/]+)/)[1];

    const menuId = GM_registerMenuCommand('Kemono助手设置', showSettingsDialog);

    const data = await (async () => {
        let _vimMode = GM_getValue('vimMode', false)
        let _rpc = GM_getValue('rpc', 'http://localhost:6800/jsonrpc')
        let _token = GM_getValue('token', '')
        let _dir = GM_getValue('dir', '')
        let _first = GM_getValue('first', true)
        let _saveSource = GM_getValue('saveSource', false)
        if (_dir === '' && !_first) try {
            _dir = await fetchDownloadDir(_rpc, _token) + "/kemono/{service}-{artist_id}/{post}-{post_id}/{index}_{auto_named}.{extension}";
        } finally {
            GM_setValue('dir', _dir);
        }
        return {
            get vimMode() {
                return _vimMode;
            }, set vimMode(value) {
                _vimMode = value;
                GM_setValue('vimMode', value);
            }, get rpc() {
                return _rpc;
            }, set rpc(value) {
                _rpc = value;
                GM_setValue('rpc', value);
            }, get token() {
                return _token;
            }, set token(value) {
                _token = value;
                GM_setValue('token', value);
            }, get dir() {
                return _dir;
            }, set dir(value) {
                // if (/[:*?"<>|]/g.test(value)) {
                //     alert('下载目录中不能包含以下字符: : * ? " < > |\n您可以选择换为全角字符')
                //     return;
                // }
                _dir = value;
                GM_setValue('dir', value);
            }, get saveSource() {
                return _saveSource
            }, set saveSource(value) {
                _saveSource = value
                GM_setValue('saveSource', value)
            }, format(date) {
                return date.toISOString().split('.')[0].replace('T', ' ').replaceAll(':', '-')
            }, formatDir(post, index, name, extension, padStart = 3, pattern = undefined) {
                const indexString = index.toString().padStart(padStart, '0');
                const shouldReplace = /^([0-9a-fA-F]{4}-?){8}$/.test(name)
                    || (/^[a-zA-Z0-9]+$/.test(name) && post.service === "fanbox") || name.length + extension.length > 255;
                if (name.length + extension.length > 255) name = name.slice(0, 255 - extension.length);
                return (pattern || _dir)
                    .replaceAll(/\{js:(.*?)#}/g, function (_, code) {
                        return eval(code);
                    })
                    .replaceAll(/[\/\\]/g, '≸∱').replaceAll(':', '∱≸')
                    .replaceAll('{service}', post.service)
                    .replaceAll('{artist_id}', post.user)
                    .replaceAll('{date}', this.format(new Date(Date.parse(post.published))))
                    .replaceAll('{post}', post.title)
                    .replaceAll('{post_id}', post.id)
                    .replaceAll('{time}', this.format(new Date()))
                    .replaceAll('{index0}', indexString)
                    .replaceAll('{index}', index === 0 ? '' : indexString)
                    .replaceAll('{auto_named}', shouldReplace ? indexString : name)
                    .replaceAll('{name}', name)
                    .replaceAll('{extension}', extension)
                    // avoid illegal characters in windows folder and file name
                    .replaceAll(':', ':')
                    .replaceAll('*', '*')
                    .replaceAll('?', '?')
                    .replaceAll('"', '“')
                    .replaceAll('<', '《')
                    .replaceAll('>', '》')
                    .replaceAll('|', '|')
                    .replaceAll('/', '/')
                    .replaceAll('\\', '\')
                    .replaceAll('≸∱', '/').replace('∱≸', ':')
                    .replaceAll(/[\s.]+[\/\\]/g, '/'); // remove space and dot
            }
        }
    })()

    const postContent = document.querySelector('.post__content')
    if (postContent) {
        replaceAsync(postContent.innerHTML, /(?<!a href="|<a [^>]+">)(https?:\/\/[^\s<]+)/g, async function (match) {
            let [service, id, post] = await getKemonoUrl(match);
            if (service === undefined) return `<a href="${match}" target="_self">${match}</a>`;
            id = id || window.location.href.match(/\/user\/(\d+)/)[1];
            const domain = window.location.href.match(/https:\/\/([^/]+)/)[1];
            const url = `${service}/user/${id}${post ? `/post/${post}` : ""}`;
            return `<a href="https://${domain}/${url}" target="_self">[已替换]${match}</a>`;
        }).then(function (result) {
            postContent.innerHTML = result
                .replace(/<a href="(https:\/\/[^\s<]+)">\1<\/a>\n?(#[^\s<]+)/g, `<a href="$1$2">$1$2</a>`)
                .replace(/<a href="(https:\/\/[^\s<]+)">(.*?)<\/a>\n?(#[^\s<]+)/g, `<a href="$1$3">$2</a>`)
        })
    }

    const prev = document.querySelector(".post__nav-link.prev");
    if (prev) {
        document.addEventListener("keydown", function (e) {
            if (e.key === "Right" || e.key === "ArrowRight" || data.vimMode && (e.key === "h" || e.key === "H")) {
                prev.click();
            }
        });
    }

    const next = document.querySelector(".post__nav-link.next");
    if (next) {
        document.addEventListener("keydown", function (e) {
            if (e.key === "Left" || e.key === "ArrowLeft" || data.vimMode && (e.key === "l" || e.key === "L")) {
                next.click();
            }
        });
    }

    if (language === 'zh-CN') {
        const dms = document.querySelector('.user-header__dms');

        if (dms) dms.innerHTML = '私信'

        const flagText = document.querySelector('.post__flag')
            ?.querySelector('span:last-child');

        if (flagText) {
            flagText.textContent = '标记';
            flagText.title = '标记为需要重新导入的内容'
        }
    }

    async function downloadPostContent(post, params = undefined) {
        if (Object.keys(post.file).length !== 0) post.attachments.unshift(post.file)
        const padStart = Math.max(3, post.attachments.length.toString().length);
        const published = new Date(Date.parse(post.published));
        if (!params.after.isEmpty() && published < new Date(params.after)) return;
        if (!params.before.isEmpty() && published > new Date(params.before)) return;
        if (!params.regex.isEmpty() && !new RegExp(params.regex).test(post.title)) return;
        for (let [i, {name, path}] of post.attachments.entries()) {
            let extension = path.split('.').pop();
            if (extension == "bin" || extension == "zip") extension = name.split('.').pop();
            if (!params.extension.includes(extension) && !params.extension.includes('*')) continue;
            if(!name) name = Math.floor(Math.random()* 1000000) + "_random";
            const filename = name.replace(/\.\w+$/, '');
            const dir = data.formatDir(post, i, filename, extension, padStart, params.pattern);
            if (!params.js.isEmpty() && !eval(params.js)) continue;
            await downloadContent(data.rpc, data.token, dir, `https://${domain}/data${path}`);
        }
        if (params.saveSource) {
            await downloadContent(
                data.rpc, data.token,
                data.formatDir(post, 0, 'content', 'json', 0, params.pattern),
                `https://${domain}/api/v1/${post.service}/user/${post.user}/post/${post.id}`);
            // aria2 may cannot download this successfully. error 22
        }
    }

    function showSettingsDialog() {
        swal.fire({
            title: '设置', html: `
                <div>
                    <label for="rpc">Aria2 RPC地址</label>
                    <input type="text" id="rpc" value="${data.rpc}">
                </div>
                <div>
                    <label for="token">Aria2 Token</label>
                    <input type="text" id="token" value="${data.token}">
                </div>
                <div>
                    <label for="dir">下载目录</label>
                    <i class="fa-solid fa-info-circle" title="支持的占位符:
                     {service}: 服务器, 如fanbox
                     {artist_id}: 作者ID
                     {date}: 发布时间
                     {post}: 作品标题
                     {post_id}: 作品ID
                     {time}: 下载时间
                     {index0}: 从0开始的序号。cover将编号为0
                     {index}: 从1开始的序号。cover的编号为空白
                     {auto_named}: 当文件名为uuid时将使用空命名。否则使用文件名
                     {name}: 文件名
                     {extension}: 文件扩展名。必须带有此项
                     {js:...#}: js代码. 可用参数请参考源代码85行处formatDir方法"></i>
                    <textarea cols="20" id="dir">${data.dir}</textarea>
                </div>
                <div>
                    <label for="save-sourcedata">保存原始数据</label>
                    <input type="checkbox" id="save-sourcedata" ${data.saveSource ? 'checked' : ''} >
                </div>
                <div>
                    <label for="vimMode">Vim模式</label>
                    <input type="checkbox" id="vimMode" ${data.vimMode ? 'checked' : ''}>
                </div>
            `, showCancelButton: true, confirmButtonText: '保存', cancelButtonText: '取消'
        }).then((result) => {
            if (result.isConfirmed) {
                data.rpc = document.getElementById('rpc').value;
                data.token = document.getElementById('token').value;
                data.dir = document.getElementById('dir').value;
                data.vimMode = document.getElementById('vimMode').checked;
                data.saveSource = document.getElementById('save-sourcedata').checked;
                location.reload();
            }
        });
    }

    function showDownloadDialog() {
        swal.fire({
            title: '下载选项', html: `
                <div>
                    <label for="final-dir">下载目录</label>
                    <textarea cols="20" id="final-dir">${data.dir}</textarea>
                </div>
                <div>
                    <label for="save-sourcedata-final">保存原始数据</label>
                    <input type="checkbox" id="save-sourcedata-final" ${data.saveSource ? 'checked' : ''} >
                </div>
                <div>
                    <label for="date-after">时间上限(较早日期)</label>
                    <input type="datetime-local" id="date-after" value="${new Date(0).toDateInputString()}">
                </div>
                <div>
                    <label for="date-before">时间下限(较晚日期)</label>
                    <input type="datetime-local" id="date-before" value="${new Date().toDateInputString()}">
                </div>
                <div>
                    <label for="regex">正则检查标题</label>
                    <input type="text" id="regex" value="">
                </div>
                <div>
                    <label for="extension">保留扩展名</label>
                    <input type="text" id="extension" value="*" placeholder="rar,zip,jpg">
                </div>
                <div>
                    <label for="costomize-js">自定义JS</label>
                    <textarea cols="20" id="customize-js"></textarea>
                </div>
            `, showCancelButton: true, confirmButtonText: '下载', cancelButtonText: '取消'
        }).then(async (result) => {
            if (result.isConfirmed) {
                await listener({
                    pattern: document.getElementById('final-dir').value,
                    after: document.getElementById('date-after').value,
                    before: document.getElementById('date-before').value,
                    regex: document.getElementById('regex').value,
                    extension: document.getElementById('extension').value.split(','),
                    js: document.getElementById('customize-js').value,
                    saveSource: document.getElementById('save-sourcedata-final').checked,
                });
                swal.fire({title: '下载任务已添加', icon: 'success'});
            }
        })
    }

    let mode;
    let header
    let listener;
    if (window.location.href.match(/\/user\/\w+\/post\/\w+\/revision\/\w+/)) {
        mode = 'post';
        header = document.querySelector('.post__actions');
        listener = async (p) => {
            let revisions = await (await fetch(`/api/v1${window.location.pathname.replace(/revision\/\w+/, "revisions")}`)).json()
            let revision = window.location.pathname.split("/").at(-1)
            await downloadPostContent(revisions.find(v => v.revision_id.toString() === revision), p)
        }
    } else if (window.location.href.match(/\/user\/\w+\/post\/\w+/)) {
        mode = 'post';
        header = document.querySelector('.post__actions');
        listener = async (p) => await downloadPostContent((await (await fetch(`/api/v1${window.location.pathname}`)).json()).post, p)
    } else if (window.location.href.match(/\/user\/\w+/)) {
        mode = 'user';
        header = document.querySelector('.user-header__actions');
        listener = async (p) => {
            for (let post of await getPosts(window.location.pathname)) await downloadPostContent(post, p)
        }
    } else if (window.location.href.match(/\/favorites/)) {
        mode = 'favor';
        const content = document.querySelector('.site-section.site-section--favorites');
        header = document.createElement('div');
        header.classList.add(`favorites-header__actions`);
        header.style.display = 'flex';
        header.style.justifyContent = 'center';
        content.insertBefore(header, content.querySelector('form#filter-favorites').nextSibling);
        const type = window.location.href.match(/\/favorites\/(\w+)/).pop();
        listener = async (p) => {
            const posts = type === 'post'
                ? await (await fetch(`/api/v1/account/favorites?type=post`)).json()
                : await (async () => {
                    const response = await fetch(`/api/v1/account/favorites?type=artist`)
                    const result = []
                    for (const artist of await response.json()) {
                        result.push(...await getPosts(`${artist.service}/user/${artist.id}`))
                    }
                    return result;
                })()
            for (let post of posts) await downloadPostContent(post, p)
        }
    }

    if (header) {
        const settings = document.createElement('button');
        settings.classList.add(`${mode}-header__settings`);
        settings.style.backgroundColor = 'transparent';
        settings.style.borderColor = 'transparent';
        settings.style.color = 'white';
        settings.innerHTML = `
            <i class="fa-solid fa-gear ${mode}-header_settings-icon"/>
        `;
        settings.addEventListener('click', showSettingsDialog);

        const download = document.createElement('button');
        download.classList.add(`${mode}-header__download`);
        download.style.backgroundColor = 'transparent';
        download.style.borderColor = 'transparent';
        download.style.color = 'white';
        download.innerHTML = `
            <i class="fa-solid fa-download ${mode}-header_download-icon"/>
            <span class="${mode}-header__download-text">下载</span>
        `;

        download.addEventListener('click', showDownloadDialog);

        header.appendChild(settings);
        header.appendChild(download);
        return mode;
    } else if (mode !== undefined) {
        alert('未找到插入位置, 请将本页源代码发送给开发者以解决此问题');
        return undefined
    }
}

async function replaceAsync(str, regex, asyncFn) {
    const promises = [];
    str.replace(regex, (match, ...args) => {
        const promise = asyncFn(match, ...args);
        promises.push(promise);
    });
    const data = await Promise.all(promises);
    return str.replace(regex, () => data.shift());
}

async function getKemonoUrl(url) {

    function getFanbox(creatorId) {
        // 同步执行promise
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: "GET", url: `https://api.fanbox.cc/creator.get?creatorId=${creatorId}`, headers: {
                    "Content-Type": "application/json",
                    "Accept": "application/json",
                    "Origin": "https://www.fanbox.cc",
                    "Referer": "https://www.fanbox.cc/"
                }, onload: function (response) {
                    if (response.status === 200) {
                        resolve(JSON.parse(response.responseText))
                    } else {
                        reject({status: response.status, statusText: response.statusText})
                    }
                }, onerror: function (response) {
                    reject({status: response.status, statusText: response.statusText})
                }
            })
        })
    }

    const pixiv_user = /https:\/\/www\.pixiv\.net\/users\/(\d+)/i;
    const fantia_user = /https:\/\/fantia\.jp\/fanclubs\/(\d+)(\/posts(\S+))?/i;
    const fanbox_user1 = /https:\/\/www\.fanbox\.cc\/@([^/]+)(?:\/posts\/(\d+))?/i;
    const fanbox_user2 = /https:\/\/(.+)\.fanbox\.cc(?:\/posts\/(\d+))?/i;
    const dlsite_user = /https:\/\/www.dlsite.com\/.+?\/profile\/=\/maker_id\/(RG\d+).html/i;
    const patreon_user1 = /https:\/\/www.patreon.com\/user\?u=(\d+)/i;
    const patreon_user2 = /https:\/\/www.patreon.com\/(\w+)/i;
    const patreon_post1 = /https:\/\/www.patreon.com\/posts\/(\d+)/i;
    const patreon_post2 = /https:\/\/www.patreon.com\/posts\/video-download-(\d+)/i;

    let service;
    let id;
    let post = null;

    if (pixiv_user.test(url)) {
        //pixiv artist
        service = "fanbox"
        id = url.match(pixiv_user)[1]
    } else if (fantia_user.test(url)) {
        //fantia
        service = "fantia"
        id = url.match(fantia_user)[1]
    } else if (dlsite_user.test(url)) {
        service = "dlsite"
        id = url.match(dlsite_user)[1]
    } else if (fanbox_user1.test(url) || fanbox_user2.test(url)) {
        //fanbox
        service = "fanbox"
        let matches = fanbox_user1.test(url) ? url.match(fanbox_user1) : url.match(fanbox_user2);
        id = (await getFanbox(matches[1])).body.user.userId.toString()
        post = matches[2]
    } else if (patreon_user1.test(url)) {
        // patreon
        service = "patreon"
        id = url.match(patreon_user1)[1]
    } else if (patreon_post1.test(url)) {
        // patreon post
        service = "patreon"
        post = url.match(patreon_post1)[1]
    } else if (patreon_post2.test(url)) {
        // patreon post
        service = "patreon"
        post = url.match(patreon_post2)[1]
    } else {
        return [undefined, undefined, undefined];
    }

    return [service, id, post]
}

async function getPosts(path, order = 0) {
    let posts = [];
    while (true) {
        const response = await fetch(`/api/v1/${path}?o=${order}`)
        // TODO: 429 too many requests, 80 request per minute
        if (response.status === 429) {
            await new Promise(resolve => setTimeout(resolve, 60000))
            continue;
        }
        if (response.status !== 200) throw {status: response.status, statusText: response.statusText}
        const items = await response.json();
        posts.push(...items);
        if (items.length < 50) break;
        order += items.length;
    }
    return posts;
}

/**
 * send request to aria2 for download
 * @param {string} rpc
 * @param {string} token
 * @param {string} file
 * @param {string} url
 */
async function downloadContent(rpc, token, file, ...url) {
    const dir = file.replace(/(.+?[\/\\])[^\/\\]+$/, "$1");
    const out = file.slice(dir.length);
    const params = token === undefined
        ? out === ""
            ? [url, {"dir": dir}]
            : [url, {"dir": dir, "out": out}]
        : out === ""
            ? [`token:${token}`, url, {"dir": dir}]
            : [`token:${token}`, url, {"dir": dir, "out": out}]
    return new Promise((resolve) => {
        GM_xmlhttpRequest({
            method: "POST", url: rpc, data: JSON.stringify({
                jsonrpc: "2.0", id: `kemono-${crypto.randomUUID()}`, method: "aria2.addUri", params: params
            }), onload: function (response) {
                if (response.status === 200) {
                    resolve(JSON.parse(response.responseText))
                } else {
                    console.log(`添加下载任务失败: ${response.status} ${response.statusText}`)
                }
            }, onerror: function (response) {
                console.log(`添加下载任务失败: ${response.status} ${response.statusText}`)
            }
        })
    })
}

async function fetchDownloadDir(rpc, token) {
    return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
            method: "POST", url: rpc, headers: {
                "Content-Type": "application/json", "Accept": "application/json",
            }, data: JSON.stringify({
                jsonrpc: "2.0", id: "Kemono", method: "aria2.getGlobalOption", params: token ? [`token:${token}`] : []
            }), onload: function (response) {
                if (response.status === 200) {
                    resolve(JSON.parse(response.responseText))
                } else {
                    reject({status: response.status, statusText: response.statusText})
                }
            }, onerror: function (response) {
                reject({status: response.status, statusText: response.statusText})
            }
        })
    }).then(function (result) {
        return result.result.dir;
    })
}

String.prototype.isEmpty = function() {
    return (this.length === 0 || !this.trim())
}

Date.prototype.toDateInputString = function () {
    return new Date().toJSON().slice(0, 10)
}