Pixiv图片牌堆生成器

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

目前為 2025-11-02 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name Pixiv图片牌堆生成器
// @version 1.21
// @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 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 tags = [];
        for (const tag in data) {
            if (data[tag].some(item => {
                try {
                    return JSON.parse(item).catLink === catLink;
                } 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;

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

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

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

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

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

            data[tag] = data[tag].filter(item => {
                try {
                    return JSON.parse(item).catLink !== catLink;
                } 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}`);
            } else {
                 alert(`该链接已存在于标签: ${tag}`);
            }

            updateButtonTitle(button, catLink);
            imgElement.setAttribute('data-cat-link', catLink);
            imgElement.setAttribute('data-original-src', originalSrc);
            imgElement.src = catLink;

            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) {
                img.src = catLink;
                img.setAttribute('data-cat-link', catLink);
                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": ["Gemini"],
            "_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);

})();