Pixiv图片牌堆生成器 (Fix MasterImg & Hover UI)

清洗JSON对象存储结构。缓存链接时抓取来源和标题,导出纯净JSON。优化了性能和UI交互(悬停触发、双列导出)。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Pixiv图片牌堆生成器 (Fix MasterImg & Hover UI)
// @version      1.41
// @description  清洗JSON对象存储结构。缓存链接时抓取来源和标题,导出纯净JSON。优化了性能和UI交互(悬停触发、双列导出)。
// @author       Yog-Sothoth
// @match        *://www.pixiv.net/*
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @grant        GM_addStyle
// @grant        GM_xmlhttpRequest
// @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]), img[src*="i.pximg.net/img-master/img"]:not([data-tag-processed])';

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

    // === 样式注入 ===
    GM_addStyle(`
        .ppg-btn {
            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: 999; transition: background-color .2s, transform .1s;
            font-family: sans-serif; font-size: 16px; user-select: none;
        }
        .ppg-btn.cached { background-color: #4CAF50; }
        .ppg-btn.uncached { background-color: #2196F3; }
        .ppg-btn.error { background-color: #FF0000; }
        .ppg-btn:hover { transform: translateY(-50%) scale(1.1); }

        /* 详情页的大按钮 */
        .ppg-btn-large { width: 34px; height: 34px; font-size: 20px; }

        /* 菜单样式 - 宽度调整为约200px */
        #tag-select-menu {
            background: #333; border: 1px solid #222; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
            padding: 8px; border-radius: 4px; z-index: 10001; color: white;
            box-sizing: border-box; text-align: left; font-size: 12px; font-family: sans-serif;
            width: 200px; /* 调整后的宽度 */
        }
        #tag-select-menu input {
            width: 100%; padding: 4px; margin: 8px 0 6px; border: 1px solid #495057;
            background-color: #f8f9fa; color: #212529; border-radius: 3px; box-sizing: border-box;
        }
        #tag-select-menu button {
            width: 100%; border: none; padding: 5px 8px; cursor: pointer; border-radius: 3px;
            color: white; transition: opacity .2s; margin-top: 4px;
        }
        #tag-select-menu button:disabled { cursor: wait; opacity: 0.7; }

        .ppg-btn-confirm { background-color: #4CAF50; }
        .ppg-btn-confirm:hover { background-color: #45a049; }

        /* 导出菜单样式 */
        #tag-export-menu {
            position: fixed; top: 10%; left: 50%; transform: translateX(-50%);
            background: #f9f9f9; border: 1px solid #ccc; color: #333;
            max-height: 80vh; overflow-y: auto; width: 340px;
            padding: 15px; border-radius: 8px; box-shadow: 0 5px 15px rgba(0,0,0,0.3); z-index: 10002;
        }
        #tag-export-menu h3 { margin: 0 0 10px 0; border-bottom: 1px solid #ddd; padding-bottom: 5px; }

        /* 导出菜单 - 顶部操作区 */
        .export-actions {
            display: flex; gap: 10px; margin-bottom: 15px;
        }
        .export-actions button {
            flex: 1; padding: 8px; border: none; border-radius: 4px; cursor: pointer; color: white; font-weight: bold;
        }

        /* 导出菜单 - 网格布局 (双列) */
        .export-grid {
            display: grid; grid-template-columns: 1fr 1fr; gap: 8px;
        }
        .ppg-btn-export {
            background-color: #007bff; border: none; padding: 8px; color: white;
            border-radius: 4px; cursor: pointer; font-size: 12px; text-overflow: ellipsis; overflow: hidden; white-space: nowrap;
        }
        .ppg-btn-export:hover { background-color: #0056b3; }

        .btn-green { background-color: #28a745; }
        .btn-green:hover { background-color: #218838; }
        .btn-gray { background-color: #6c757d; }
        .btn-gray:hover { background-color: #5a6268; }
    `);

    // === 全局状态 ===
    let linkCacheIndex = new Map();
    let menuCloseTimer = null; // 用于控制菜单关闭的定时器
    let activeMenuButton = null; // 当前激活菜单对应的按钮

    // === 工具函数 ===

    function debounce(func, wait) {
        let timeout;
        return function(...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }

    function normalizeItem(item) {
        if (typeof item === 'string') {
            try { return JSON.parse(item); } catch (e) { return null; }
        }
        return item;
    }

    // 数据迁移
    (function migrateOldData() {
        try {
            const rawData = GM_getValue(STORAGE_KEY, '{}');
            const data = JSON.parse(rawData);
            let hasChanges = false;
            for (const tag in data) {
                if (Array.isArray(data[tag])) {
                    data[tag] = data[tag].map(item => {
                        if (typeof item === 'string') {
                            try { return JSON.parse(item); } catch (e) { return item; }
                        }
                        return item;
                    });
                }
            }
            if (hasChanges) GM_setValue(STORAGE_KEY, JSON.stringify(data));
        } catch (e) { console.error('Data migration failed:', e); }
    })();

    function parseImgSrc(src) {
        const regex = /img\/(\d{4}\/\d{2}\/\d{2}\/\d{2}\/\d{2}\/\d{2})\/(\d+)_p(\d+)/;
        const match = src.match(regex);
        if (match) {
            return {
                timestamp: match[1], illustId: match[2],
                pageIndex: parseInt(match[3], 10), pageString: `p${match[3]}`,
                linkKey: `${match[2]}_p${match[3]}`
            };
        }
        if (IS_ARTWORK_PAGE) {
            const parts = src.match(/img\/(\d{4}\/\d{2}\/\d{2}\/\d{2}\/\d{2}\/\d{2})\/(\d+)_p(\d+)/);
            const artworkMatch = window.location.pathname.match(/\/artworks\/(\d+)/);
            if (parts && artworkMatch) {
                return {
                    timestamp: parts[1], illustId: artworkMatch[1],
                    pageIndex: parseInt(parts[3], 10), pageString: `p${parts[3]}`,
                    linkKey: `${artworkMatch[1]}_p${parts[3]}`
                };
            }
        }
        return null;
    }

    function buildCacheIndex() {
        linkCacheIndex.clear();
        const data = getCachedData();
        for (const tag in data) {
            data[tag].forEach(item => {
                const parsedItem = normalizeItem(item);
                if (!parsedItem) return;
                const parsedInfo = parseImgSrc(parsedItem.catLink);
                if (!parsedInfo) return;
                const linkKey = parsedInfo.linkKey;
                if (!linkCacheIndex.has(linkKey)) linkCacheIndex.set(linkKey, { realUrl: parsedItem.catLink, tags: [] });
                const entry = linkCacheIndex.get(linkKey);
                if (!entry.tags.includes(tag)) entry.tags.push(tag);
            });
        }
    }

    // 格式化输出 (确保包含换行符)
    function formatToCQImage(cachedItem) {
        try {
            const data = normalizeItem(cachedItem);
            // 使用模板字符串显式保留 \n
            return `[CQ:image,file=${data.catLink}]${data.sourceLink}\n${data.title}`;
        } catch (e) {
            return `[CQ:image,file=N/A] (Error reading source info)`;
        }
    }

    function getIllustrationSourceInfo(imgElement) {
        let sourceLink = 'Source: N/A';
        let title = 'No Title';

        if (IS_ARTWORK_PAGE) {
            const urlMatch = window.location.pathname.match(/\/artworks\/(\d+)/);
            sourceLink = urlMatch ? PIXIV_BASE_URL + urlMatch[0] : window.location.href;
            title = document.title.replace(/ | - pixiv$/, '').trim();
            if (!title || title.includes('pixiv')) title = imgElement.alt.trim() || 'No Title';
        } else {
            try {
                const li = imgElement.closest('li');
                const aElement = li?.querySelector('a[href*="/artworks/"]:not([class*="user"])');
                if (!aElement) {
                     const directLink = imgElement.closest('a[href*="/artworks/"]');
                     if(directLink) {
                         sourceLink = directLink.href;
                         title = directLink.textContent.trim() || imgElement.alt || 'No Title';
                     }
                } else {
                    sourceLink = aElement.href;
                    title = aElement.textContent.trim() || imgElement.alt || 'No Title';
                }
            } catch (e) { console.error(e); }
        }
        return { sourceLink, title };
    }

    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(linkKey) { return linkCacheIndex.get(linkKey)?.tags || []; }
    function isLinkCached(linkKey) { return linkCacheIndex.has(linkKey); }

    function addLinkToTag(itemData, realUrl, tag) {
        const data = getCachedData();
        const finalTag = tag || '未分类';
        const parsedInfo = parseImgSrc(realUrl);
        if (!parsedInfo) return false;

        const linkKey = parsedInfo.linkKey;
        if (!data[finalTag]) data[finalTag] = [];

        const exists = data[finalTag].some(item => {
            const n = normalizeItem(item);
            return n && n.catLink === realUrl;
        });

        if (!exists) {
            data[finalTag].push(itemData);
            setCachedData(data);
            if (!linkCacheIndex.has(linkKey)) linkCacheIndex.set(linkKey, { realUrl: realUrl, tags: [] });
            const entry = linkCacheIndex.get(linkKey);
            if (!entry.tags.includes(finalTag)) entry.tags.push(finalTag);
            return true;
        }
        return false;
    }

    function removeLinkFromAllCache(linkKey) {
        const cacheEntry = linkCacheIndex.get(linkKey);
        if (!cacheEntry) return false;
        const realUrl = cacheEntry.realUrl;
        const data = getCachedData();
        let removed = false;

        for (const tag in data) {
            const initialLen = data[tag].length;
            data[tag] = data[tag].filter(item => {
                const n = normalizeItem(item);
                return n && n.catLink !== realUrl;
            });
            if (data[tag].length !== initialLen) removed = true;
            if (data[tag].length === 0) delete data[tag];
        }

        if (removed) {
            setCachedData(data);
            linkCacheIndex.delete(linkKey);
        }
        return removed;
    }

    // === UI 交互控制 (定时器逻辑) ===

    function scheduleMenuClose() {
        if (menuCloseTimer) clearTimeout(menuCloseTimer);
        menuCloseTimer = setTimeout(() => {
            const menu = document.getElementById('tag-select-menu');
            if (menu) menu.remove();
            activeMenuButton = null;
        }, 3000); // 1秒后消失
    }

    function cancelMenuClose() {
        if (menuCloseTimer) {
            clearTimeout(menuCloseTimer);
            menuCloseTimer = null;
        }
    }

    // === UI 生成函数 ===

    function createTagButton(isCached, isArtworkPage) {
        const button = document.createElement('div');
        button.className = 'ppg-btn ' + (isCached ? 'cached' : 'uncached');
        if (isArtworkPage) button.classList.add('ppg-btn-large');
        button.innerHTML = '+';
        return button;
    }

    function updateButtonVisuals(button, linkKey) {
        if (!linkKey) {
            button.className = 'ppg-btn error';
            button.title = '无法解析ID';
            return;
        }
        const tags = getLinkTags(linkKey);
        const isCached = tags.length > 0;
        button.classList.remove('cached', 'uncached', 'error');
        button.classList.add(isCached ? 'cached' : 'uncached');
        button.title = isCached
            ? `已存: ${tags.join(', ')} | 双击移除`
            : `未缓存`;
    }

    function createTagInputMenu(button, imgElement, linkKey, illustId, pageIndex) {
        const cachedData = getCachedData();
        const existingTags = Object.keys(cachedData);
        const sourceInfo = getIllustrationSourceInfo(imgElement);

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

        const currentTags = linkKey ? getLinkTags(linkKey).join(', ') : '无';
        const infoText = linkKey ? `(标签: ${currentTags})` : '(未缓存)';

        menu.innerHTML = `
            <div style="word-break: break-all; color:#aaa; margin-bottom:2px;">${sourceInfo.sourceLink}</div>
            <div style="font-weight:bold; margin-bottom:4px;">${sourceInfo.title}</div>
            <i style="font-size: 11px; color: #ccc;">${infoText}</i>
        `;

        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 = '缓存';
        confirmButton.className = 'ppg-btn-confirm';

        confirmButton.onclick = async () => {
            const tag = input.value.trim();
            if (!tag) { input.style.borderColor = 'red'; return; }
            if (!illustId) { alert("ID Error"); return; }

            confirmButton.disabled = true;
            confirmButton.textContent = '...';

            try {
                const res = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: "GET", url: `/ajax/illust/${illustId}/pages`,
                        onload: (r) => resolve(JSON.parse(r.responseText)), onerror: reject
                    });
                });

                if (res.error) throw new Error(res.message);
                const pageData = res.body[pageIndex];
                if (!pageData) throw new Error('No page index');

                const finalUrlToCache = pageData.urls.original.replace('i.pximg.net', 'i.pixiv.cat');
                const itemToCache = {
                    catLink: finalUrlToCache, sourceLink: sourceInfo.sourceLink, title: sourceInfo.title
                };

                addLinkToTag(itemToCache, finalUrlToCache, tag);

                const newParsed = parseImgSrc(finalUrlToCache);
                if (newParsed) button.linkKey = newParsed.linkKey;

                updateButtonVisuals(button, button.linkKey);
                button.innerHTML = '&#10003;';
                setTimeout(() => button.innerHTML = '&#43;', 1000);
                menu.remove();
                activeMenuButton = null;

            } catch (e) {
                console.error(e);
                confirmButton.disabled = false;
                confirmButton.textContent = '重试';
            }
        };

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

        // 菜单自身的悬停事件
        menu.addEventListener('mouseenter', cancelMenuClose);
        menu.addEventListener('mouseleave', scheduleMenuClose);

        return menu;
    }

    function showTagInputMenu(button, imgElement, linkKey, illustId, pageIndex) {
        // 如果当前已经打开了该按钮的菜单,则只取消关闭定时器
        if (document.getElementById('tag-select-menu') && activeMenuButton === button) {
            cancelMenuClose();
            return;
        }

        document.getElementById('tag-select-menu')?.remove();

        const menu = createTagInputMenu(button, imgElement, linkKey, illustId, pageIndex);
        document.body.appendChild(menu);
        activeMenuButton = button;

        const rect = button.getBoundingClientRect();
        const left = (window.innerWidth - rect.right < 220) ? rect.left - 210 : rect.right + 10;

        menu.style.position = 'fixed';
        menu.style.top = `${Math.max(10, rect.top)}px`;
        menu.style.left = `${left}px`;

        menu.querySelector('input').focus();
    }

    // === 主逻辑 ===

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

        const images = document.querySelectorAll(selectors.join(','));
        if (images.length === 0) return;

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

            if (!IS_ARTWORK_PAGE) {
                 const linkParent = img.closest('a');
                 if (linkParent && linkParent.parentElement) targetContainer = linkParent.parentElement;
            } else {
                 const linkParent = img.closest('a');
                 if(linkParent) targetContainer = linkParent.parentElement;
            }

            const style = window.getComputedStyle(targetContainer);
            if (style.position === 'static') targetContainer.style.position = 'relative';

            const parsedInfo = parseImgSrc(img.src);
            let illustId = parsedInfo?.illustId;
            let pageIndex = parsedInfo?.pageIndex || 0;

            if (!illustId) {
                if (IS_ARTWORK_PAGE) {
                     const m = window.location.pathname.match(/\/artworks\/(\d+)/);
                     if(m) illustId = m[1];
                } else {
                     const link = img.closest('a[href*="/artworks/"]');
                     if(link) {
                         const m = link.href.match(/\/artworks\/(\d+)/);
                         if(m) illustId = m[1];
                     }
                }
            }

            const linkKey = (illustId) ? `${illustId}_p${pageIndex}` : null;
            const isCached = linkKey ? isLinkCached(linkKey) : false;

            const button = createTagButton(isCached, IS_ARTWORK_PAGE);
            button.linkKey = linkKey;
            button.illustId = illustId;
            button.pageIndex = pageIndex;
            button.imgElement = img;

            updateButtonVisuals(button, linkKey);

            // === 悬停触发逻辑 ===
            button.addEventListener('mouseenter', () => {
                cancelMenuClose(); // 取消可能存在的关闭操作
                showTagInputMenu(button, img, button.linkKey, button.illustId, button.pageIndex);
            });

            button.addEventListener('mouseleave', () => {
                scheduleMenuClose(); // 启动1秒后关闭
            });
            // ==================

            button.ondblclick = (e) => {
                e.stopPropagation();
                if (!button.linkKey) return;
                const url = linkCacheIndex.get(button.linkKey)?.realUrl || "Unknown";
                if (confirm(`从所有标签移除此图?`)) {
                    removeLinkFromAllCache(button.linkKey);
                    updateButtonVisuals(button, button.linkKey);
                }
            };

            if (!targetContainer.querySelector('.ppg-btn')) targetContainer.appendChild(button);
        });
    }

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

        const blob = new Blob([JSON.stringify(jsonContent, null, 2)], { type: 'application/json' });
        const url = URL.createObjectURL(blob);
        const a = document.createElement('a');
        a.href = url;
        a.download = filename;
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
        URL.revokeObjectURL(url);
    }

    function createExportMenuUI() {
        document.getElementById('tag-export-menu')?.remove();
        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.innerHTML = `<h3>导出 JSON</h3>`;

        // 顶部操作区
        const actionDiv = document.createElement('div');
        actionDiv.className = 'export-actions';

        const btnAll = document.createElement('button');
        btnAll.textContent = '➡️ 导出全部';
        btnAll.className = 'btn-green';
        btnAll.onclick = () => {
            const exportData = {};
            tags.forEach(t => exportData[t] = data[t].map(formatToCQImage));
            exportJson(exportData, `All_${CACHE_NAME}`);
        };

        const btnClose = document.createElement('button');
        btnClose.textContent = '关闭';
        btnClose.className = 'btn-gray';
        btnClose.onclick = () => menu.remove();

        actionDiv.appendChild(btnAll);
        actionDiv.appendChild(btnClose);
        menu.appendChild(actionDiv);

        // 标签网格区 (双列)
        const gridDiv = document.createElement('div');
        gridDiv.className = 'export-grid';

        tags.forEach(tag => {
            const btn = document.createElement('button');
            btn.textContent = `${tag} (${data[tag].length})`;
            btn.className = 'ppg-btn-export';
            btn.title = `导出标签: ${tag}`;
            btn.onclick = () => {
                const exportData = {};
                exportData[tag] = data[tag].map(formatToCQImage);
                exportJson(exportData, `${tag}_${CACHE_NAME}`);
            };
            gridDiv.appendChild(btn);
        });

        menu.appendChild(gridDiv);
        document.body.appendChild(menu);
    }

    // === 初始化 ===
    function init() {
        buildCacheIndex();
        GM_registerMenuCommand('⬇️ 导出数据', createExportMenuUI);
        GM_registerMenuCommand('🗑️ 清空缓存', () => { if(confirm('确定清空?')) { GM_deleteValue(STORAGE_KEY); location.reload(); }});

        window.addEventListener('keydown', (e) => {
            if (['INPUT', 'TEXTAREA'].includes(document.activeElement.tagName)) return;
            if (e.code === 'Numpad4') navigatePage(-1);
            if (e.code === 'Numpad6') navigatePage(1);
        });

        processImages();
        const debouncedProcess = debounce(processImages, 300);
        const observer = new MutationObserver((mutations) => {
            if (mutations.some(m => m.addedNodes.length > 0)) debouncedProcess();
        });
        observer.observe(document.body, { childList: true, subtree: true });
    }

    function navigatePage(step) {
        const url = new URL(window.location.href);
        let p = parseInt(url.searchParams.get('p') || '1', 10);
        p = Math.max(1, p + step);
        url.searchParams.set('p', p);
        window.location.href = url.href;
    }

    init();
})();