Pixiv图片牌堆生成器

缓存链接时,同时抓取插画的来源链接和标题,并在牌堆导出时包含所有信息,用 \n 分隔。小键盘可以实现翻页,有更换需求请自行处理。可以打tag!理论上适配所有pixiv的界面(包括背景图)

当前为 2025-11-03 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name Pixiv图片牌堆生成器
// @version 1.25
// @description 缓存链接时,同时抓取插画的来源链接和标题,并在牌堆导出时包含所有信息,用 \n 分隔。小键盘可以实现翻页,有更换需求请自行处理。可以打tag!理论上适配所有pixiv的界面(包括背景图)
// @author Yog-Sothoth
// @match *://www.pixiv.net/*
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @grant GM_addStyle
// @license MIT
// @run-at document-idle
// @namespace https://greasyfork.org/users/1397928
// ==/UserScript==

(function() {
    'use strict';

    const STORAGE_KEY = 'pixivCatTaggedLinks_JSON';
    const CACHE_NAME = 'PixivCat_Tagged_Export.json';
    const PIXIV_BASE_URL = 'https://www.pixiv.net';

    const IMG_SELECTOR_LIST = 'img[src*="i.pximg.net/c/"]:not([data-tag-processed])';
    const IMG_SELECTOR_ARTWORK = 'img[src*="i.pximg.net/img-original/img"]:not([data-tag-processed])';
    const ANCESTOR_LEVEL = 6;

    const IS_ARTWORK_PAGE = window.location.pathname.includes('/artworks/');

    GM_addStyle(`
        #tag-select-menu {
            position: absolute;
            right: 40px;
            top: 50%;
            transform: translateY(-50%);
            width: 280px;
            background: #333333;
            border: 1px solid #222222;
            box-shadow: 0 4px 8px rgba(0,0,0,0.5);
            padding: 10px;
            border-radius: 4px;
            z-index: 1001;
            color: white;
            box-sizing: border-box;
            text-align: left;
            font-size: 13px;
        }

        #tag-select-menu input {
            width: 100%;
            padding: 4px;
            margin-top: 3px
            margin-bottom: 8px;
            border: 1px solid #495057;
            background-color: #f8f9fa;
            color: #212529;
            border-radius: 3px;
            box-sizing: border-box;
        }

        #tag-select-menu button {
            width: 100%;
            background: #4CAF50;
            color: white;
            border: none;
            padding: 5px 10px;
            cursor: pointer;
            border-radius: 3px;
            transition: background-color .2s;
        }

        #tag-select-menu button:hover {
            background: #45a049;
        }
    `);

    function getIllustrationIdentifier(catLink) {
        const match = catLink.match(/(\d+)_p(\d+)/);
        if (match) {
            return `${match[1]}_p${match[2]}`;
        }
        return catLink;
    }

    function normalizeCatLink(catLink) {
        const identifier = getIllustrationIdentifier(catLink);
        if (identifier.includes('_p')) {
            const parts = identifier.split('_p');
            const id = parts[0];
            const page = parts[1];

            const extMatch = catLink.match(/\.([a-z]{3,4})$/i);
            const ext = extMatch ? extMatch[1].toLowerCase() : 'png';

            const dateMatch = catLink.match(/img\/(\d{4}\/\d{2}\/\d{2}\/\d{2}\/\d{2}\/\d{2})/);
            const datePath = dateMatch ? dateMatch[1] : '2099/01/01/00/00/00';

            return `https://i.pixiv.cat/img-original/img/${datePath}/${id}_p${page}.${ext}`;
        }
        return catLink;
    }

    function getCatLink(originalSrc) {
        return originalSrc.replace('i.pximg.net', 'i.pixiv.cat');
    }

    function formatToCQImage(cachedItem) {
        try {
            const data = JSON.parse(cachedItem);
            return (
                `[CQ:image,file=${data.catLink}]` +
                `${data.sourceLink}` +
                `\n${data.title}`
            );
        } catch (e) {
            return `[CQ:image,file=N/A] (Error reading source info for: ${cachedItem})`;
        }
    }

    function getIllustrationSourceInfo(imgElement) {
        if (IS_ARTWORK_PAGE) {
            const urlMatch = window.location.pathname.match(/\/artworks\/(\d+)/);
            const sourceLink = urlMatch ? PIXIV_BASE_URL + urlMatch[0] : 'Source: N/A (URL)';

            let title = document.title.replace(/ | - pixiv$/, '').trim();
            if (!title || title.includes('pixiv')) {
                title = imgElement.alt.trim() || 'No Title Found (Alt)';
            }

            return { sourceLink, title };

        } else {
            try {
                let liAncestor = imgElement.closest('li');
                if (!liAncestor) return { sourceLink: 'Source: N/A (li)', title: 'Title: N/A (li)' };

                let firstDiv = liAncestor.querySelector(':scope > div');
                if (!firstDiv) return { sourceLink: 'Source: N/A (div1)', title: 'Title: N/A (div1)' };

                let secondDiv = Array.from(firstDiv.children).filter(el => el.tagName === 'DIV')[1];
                if (!secondDiv) {
                    secondDiv = liAncestor.querySelectorAll('div > div')[1];
                    if (!secondDiv) return { sourceLink: 'Source: N/A (div2)', title: 'Title: N/A (div2)' };
                }

                let aElement = secondDiv.querySelector('a');

                if (aElement) {
                    const relativeHref = aElement.getAttribute('href') || '';
                    const sourceLink = relativeHref.startsWith(PIXIV_BASE_URL) ? relativeHref : PIXIV_BASE_URL + relativeHref;
                    const title = aElement.textContent.trim() || 'No Title Found';

                    return { sourceLink, title };
                }
            } catch (e) {
                console.error("Error retrieving list source info:", e);
            }
            return { sourceLink: 'Source: Error', title: 'Title: Error' };
        }
    }

    function getCachedData() {
        try {
            return JSON.parse(GM_getValue(STORAGE_KEY, '{}'));
        } catch (e) {
            return {};
        }
    }

    function setCachedData(data) {
        GM_setValue(STORAGE_KEY, JSON.stringify(data));
    }

    function getLinkTags(catLink) {
        const data = getCachedData();
        const targetIdentifier = getIllustrationIdentifier(catLink);
        const tags = [];

        if (targetIdentifier === catLink) return tags;

        for (const tag in data) {
            if (data[tag].some(item => {
                try {
                    const cachedIdentifier = getIllustrationIdentifier(JSON.parse(item).catLink);
                    return cachedIdentifier === targetIdentifier;
                } catch (e) {
                    return false;
                }
            })) {
                tags.push(tag);
            }
        }
        return tags;
    }

    function isLinkCached(catLink) {
        return getLinkTags(catLink).length > 0;
    }

    function addLinkToTag(itemJsonString, catLink, tag) {
        const data = getCachedData();
        const finalTag = tag || '未分类';
        let isAdded = false;

        const targetIdentifier = getIllustrationIdentifier(catLink);
        const normalizedCatLink = normalizeCatLink(catLink);

        if (targetIdentifier === catLink) return false;

        const sourceInfo = JSON.parse(itemJsonString);
        const itemToCache = JSON.stringify({
            catLink: normalizedCatLink,
            sourceLink: sourceInfo.sourceLink,
            title: sourceInfo.title
        });

        if (!data[finalTag]) {
            data[finalTag] = [];
        }

        const existingInTag = data[finalTag].some(item => {
            try {
                return getIllustrationIdentifier(JSON.parse(item).catLink) === targetIdentifier;
            } catch (e) {
                return false;
            }
        });

        if (!existingInTag) {
            data[finalTag].push(itemToCache);
            setCachedData(data);
            isAdded = true;
        }
        return isAdded;
    }

    function removeLinkFromAllCache(catLink) {
        const data = getCachedData();
        let removed = false;
        const targetIdentifier = getIllustrationIdentifier(catLink);

        if (targetIdentifier === catLink) return false;

        for (const tag in data) {
            const originalLength = data[tag].length;

            data[tag] = data[tag].filter(item => {
                try {
                    return getIllustrationIdentifier(JSON.parse(item).catLink) !== targetIdentifier;
                } catch (e) {
                    return true;
                }
            });

            if (data[tag].length < originalLength) {
                removed = true;
            }

            if (data[tag].length === 0) {
                delete data[tag];
            }
        }

        if (removed) {
            setCachedData(data);
        }
        return removed;
    }

    function clearCache() {
        if (confirm('确定要清空所有缓存的 Pixiv Cat 链接和标签吗?')) {
            GM_deleteValue(STORAGE_KEY);
            alert('缓存已清空。页面需要刷新以清除按钮状态。');
            location.reload();
        }
    }

    function createTagButton(isCached) {
        const button = document.createElement('div');
        button.style.cssText = 'position:absolute;top:50%;right:0;transform:translateY(-50%);width:24px;height:24px;border-radius:4px;display:flex;align-items:center;justify-content:center;color:white;font-weight:bold;cursor:pointer;z-index:1000;transition:background-color .2s,transform .1s;font-family:sans-serif;font-size:16px;';

        updateButtonState(button, isCached);
        return button;
    }

    function updateButtonState(button, isCached) {
        button.style.backgroundColor = isCached ? '#4CAF50' : '#2196F3';
        button.innerHTML = '&#43;';
    }

    function updateButtonTitle(button, catLink) {
        const tags = getLinkTags(catLink);
        let title = '';
        if (tags.length === 0) {
            title = '未被任何标签缓存 | 单击添加 | 双击取消所有标签';
        } else {
            title = `已缓存到: ${tags.join(', ')} | 单击添加 | 双击取消所有标签`;
        }
        button.title = title;
        updateButtonState(button, tags.length > 0);
    }

    function createTagInputMenu(button, imgElement, originalSrc) {
        const cachedData = getCachedData();
        const existingTags = Object.keys(cachedData);
        const catLink = getCatLink(originalSrc);
        const sourceInfo = getIllustrationSourceInfo(imgElement);

        const menu = document.createElement('div');
        menu.id = 'tag-select-menu';

        const info = document.createElement('div');
        info.innerHTML = `
            <strong style="color: #f0f0f0;">来源:</strong> <span style="word-break: break-all;">${sourceInfo.sourceLink}</span><br>
            <strong style="color: #f0f0f0;">标题:</strong> ${sourceInfo.title}
            <hr style="border-top: 1px solid #999; margin: 8px 0;">
            <i style="font-size: 11px;">(已存在标签: ${getLinkTags(catLink).join(', ') || '无'})</i>
        `;
        menu.appendChild(info);

        const input = document.createElement('input');
        input.type = 'text';
        input.placeholder = '选择或输入新标签名';
        input.setAttribute('list', 'pixiv-tag-list');

        const datalist = document.createElement('datalist');
        datalist.id = 'pixiv-tag-list';
        existingTags.forEach(tag => {
            const option = document.createElement('option');
            option.value = tag;
            datalist.appendChild(option);
        });

        const confirmButton = document.createElement('button');
        confirmButton.textContent = '添加并缓存到标签';

        menu.appendChild(datalist);
        menu.appendChild(input);
        menu.appendChild(confirmButton);

        confirmButton.onclick = () => {
            const tag = input.value.trim();

            if (!tag) {
                alert('标签名不能为空!');
                return;
            }

            const itemToCache = JSON.stringify({
                catLink: catLink,
                sourceLink: sourceInfo.sourceLink,
                title: sourceInfo.title
            });

            if (addLinkToTag(itemToCache, catLink, tag)) {
                alert(`已成功添加到标签: ${tag}`);

                const normalizedCatLink = normalizeCatLink(catLink);
                imgElement.src = normalizedCatLink;
                imgElement.setAttribute('data-cat-link', normalizedCatLink);
                imgElement.setAttribute('data-original-src', originalSrc);

            } else {
                alert(`该链接已存在于标签: ${tag}`);
            }

            updateButtonTitle(button, catLink);
            imgElement.setAttribute('data-tag-processed', 'true');
            menu.remove();
        };

        setTimeout(() => {
            const clickHandler = (e) => {
                if (!menu.contains(e.target) && e.target !== button && e.target !== input) {
                    menu.remove();
                    document.removeEventListener('click', clickHandler);
                }
            };
            document.addEventListener('click', clickHandler);
        }, 10);

        return menu;
    }

    function showTagInputMenu(button, imgElement, originalSrc) {
        if (button.parentElement.querySelector('#tag-select-menu')) {
            return;
        }
        const menu = createTagInputMenu(button, imgElement, originalSrc);
        button.parentElement.appendChild(menu);
        menu.querySelector('input').focus();
    }

    function handleButtonClick(event) {
        event.stopPropagation();
        const button = event.currentTarget;
        const img = button.imgElement;
        const originalSrc = img.src;

        if (event.detail === 1) {
            showTagInputMenu(button, img, originalSrc);
        }
    }

    function handleButtonDoubleClick(event) {
        event.stopPropagation();
        const button = event.currentTarget;
        const img = button.imgElement;
        const originalSrc = img.src;
        const catLink = getCatLink(originalSrc);

        if (confirm(`确定要从所有标签中移除该链接吗?\n当前标签: ${getLinkTags(catLink).join(', ') || '无'}`)) {
            if (removeLinkFromAllCache(catLink)) {
                alert('该链接已从所有标签中移除。');
            } else {
                alert('该链接未被缓存。');
            }

            if (img.hasAttribute('data-original-src')) {
                img.src = img.getAttribute('data-original-src');
                img.removeAttribute('data-original-src');
                img.removeAttribute('data-cat-link');
            }
            updateButtonTitle(button, catLink);
        }
    }

    function handleButtonMouseOver(event) {
        const button = event.currentTarget;
        const img = button.imgElement;
        const catLink = getCatLink(img.src);

        updateButtonTitle(button, catLink);
    }

    function findAncestor(element, n) {
        let ancestor = element;
        for (let i = 0; i < n; i++) {
            if (ancestor) {
                ancestor = ancestor.parentElement;
            } else {
                return null;
            }
        }
        return ancestor;
    }

    function processImages() {
        let selectors = [IMG_SELECTOR_LIST];
        if (IS_ARTWORK_PAGE) {
            selectors.push(IMG_SELECTOR_ARTWORK);
        }

        let images = [];
        selectors.forEach(selector => {
            images.push(...document.querySelectorAll(selector));
        });

        images.forEach(img => {
            img.setAttribute('data-tag-processed', 'true');

            let targetAncestor;

            if (IS_ARTWORK_PAGE) {
                let aParent = img.closest('a');
                if (aParent) {
                    targetAncestor = findAncestor(aParent, 2);
                }

                if (!targetAncestor) {
                    targetAncestor = img.parentElement;
                    if (targetAncestor && window.getComputedStyle(targetAncestor).position === 'static') {
                        targetAncestor = targetAncestor.parentElement;
                    }
                }

            } else {
                targetAncestor = findAncestor(img, ANCESTOR_LEVEL);
            }

            if (!targetAncestor) {
                return;
            }

            const originalSrc = img.src;
            const catLink = getCatLink(originalSrc);
            const isCached = isLinkCached(catLink);

            const button = createTagButton(isCached);
            button.imgElement = img;

            updateButtonTitle(button, catLink);

            if (isCached) {
                const normalizedCatLink = normalizeCatLink(catLink);
                img.src = normalizedCatLink;
                img.setAttribute('data-cat-link', normalizedCatLink);
                img.setAttribute('data-original-src', originalSrc);
            }

            if (window.getComputedStyle(targetAncestor).position === 'static') {
                targetAncestor.style.position = 'relative';
            }

            if (!targetAncestor.querySelector('div[data-state]')) {
                button.addEventListener('click', handleButtonClick);
                button.addEventListener('dblclick', handleButtonDoubleClick);
                button.addEventListener('mouseover', handleButtonMouseOver);
                targetAncestor.appendChild(button);
            }
        });
    }

    function exportTagToJson(tagToExport) {
        const data = getCachedData();
        const linksData = data[tagToExport];

        if (!linksData || linksData.length === 0) {
            alert(`标签 "${tagToExport}" 下没有缓存链接可导出。`);
            return;
        }

        const jsonContent = {
            "_title": ["pixiv图片转载"],
            "_author": ["Yog-Sothoth"],
            "_date": [new Date().toISOString().slice(0, 10).replace(/-/g, '/')],
            "_version": ["1.0"],
        };

        jsonContent[tagToExport] = linksData.map(formatToCQImage);
        const content = JSON.stringify(jsonContent, null, 2);

        const blob = new Blob([content], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = `${tagToExport}_${CACHE_NAME}`;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);

        const menu = document.getElementById('tag-export-menu');
        if (menu) menu.remove();
        alert(`标签 "${tagToExport}" 的 JSON 文件已生成并开始下载。`);
    }

    function createExportMenuUI() {
        const existingMenu = document.getElementById('tag-export-menu');
        if (existingMenu) {
            existingMenu.remove();
            return;
        }

        const data = getCachedData();
        const tags = Object.keys(data).filter(tag => data[tag].length > 0);

        if (tags.length === 0) {
            alert('缓存中没有可导出的标签。');
            return;
        }

        const menu = document.createElement('div');
        menu.id = 'tag-export-menu';
        menu.style.cssText = 'position: fixed; top: 10%; left: 50%; transform: translateX(-50%); background: #f9f9f9; border: 1px solid #ccc; box-shadow: 0 5px 15px rgba(0,0,0,0.3); padding: 15px; border-radius: 8px; z-index: 9999; max-height: 80vh; overflow-y: auto;';

        const title = document.createElement('h3');
        title.textContent = '⬇️ 选择要导出的标签 (JSON)';
        title.style.cssText = 'margin-top: 0; border-bottom: 1px solid #ddd; padding-bottom: 5px; color: #333;';
        menu.appendChild(title);

        tags.forEach(tag => {
            const count = data[tag].length;
            const button = document.createElement('button');
            button.textContent = `${tag} (${count} links)`;
            button.style.cssText = 'display: block; width: 100%; padding: 8px; margin: 5px 0; background-color: #007bff; color: white; border: none; border-radius: 4px; cursor: pointer; transition: background-color 0.2s;';

            button.onmouseover = () => button.style.backgroundColor = '#0056b3';
            button.onmouseout = () => button.style.backgroundColor = '#007bff';
            button.onclick = () => exportTagToJson(tag);

            menu.appendChild(button);
        });

        const closeButton = document.createElement('button');
        closeButton.textContent = '关闭';
        closeButton.style.cssText = 'display: block; width: 100%; padding: 8px; margin-top: 15px; background-color: #6c757d; color: white; border: none; border-radius: 4px; cursor: pointer;';
        closeButton.onclick = () => menu.remove();
        menu.appendChild(closeButton);

        document.body.appendChild(menu);
    }

    function registerMenuCommands() {
        GM_registerMenuCommand('⬇️ 导出标签链接为 JSON', createExportMenuUI);
        GM_registerMenuCommand('🗑️ 清空所有缓存链接和标签', clearCache);
    }

    function quickPageTurn(step) {
        const url = new URL(window.location.href);
        const params = url.searchParams;
        let currentPage = parseInt(params.get('p'), 10);
        if (isNaN(currentPage) || currentPage < 1) {
            currentPage = 1;
        }
        let newPage = currentPage + step;
        if (newPage < 1) {
            newPage = 1;
        }
        params.set('p', newPage);
        const newUrl = url.origin + url.pathname + '?' + params.toString() + url.hash;
        window.location.href = newUrl;
    }

    function handleKeydown(event) {
        const activeElement = document.activeElement;
        if (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA') {
            return;
        }
        switch (event.code) {
            case 'Numpad4':
                event.preventDefault();
                quickPageTurn(-1);
                break;
            case 'Numpad6':
                event.preventDefault();
                quickPageTurn(1);
                break;
        }
    }

    function observeDOMChanges() {
        processImages();
        const observer = new MutationObserver((mutationsList, observer) => {
            for (const mutation of mutationsList) {
                if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                    setTimeout(processImages, 100);
                    break;
                }
            }
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    registerMenuCommands();
    observeDOMChanges();
    window.addEventListener('keydown', handleKeydown);

})();