Pixiv 情報拡張

サムネイルの内部および下に、ブックマーク数、日付、解像度などの追加情報を挿入します。ユーザーページ、検索ページ、おすすめリスト、ランキングに対応しています。

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

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

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name       Pixiv Info Enhancer
// @name:en    Pixiv Info Enhancer
// @name:zh-CN Pixiv 信息增强
// @name:zh-TW Pixiv 資訊增強
// @name:ja    Pixiv 情報拡張
// @name:ko    Pixiv 정보 강화
// @namespace  http://tampermonkey.net/
// @version    3.0.1
// @description       Inserts additional information such as bookmark count, date, and resolution into and below thumbnails. Supports user pages, search pages, recommendation lists, and rankings.
// @description:en    Inserts additional information such as bookmark count, date, and resolution into and below thumbnails. Supports user pages, search pages, recommendation lists, and rankings.
// @description:zh-CN 在缩略图内部和下方插入额外信息,如收藏数、日期、分辨率等。支持用户主页、搜索页、推荐列表和排行榜。
// @description:zh-TW 在縮圖內部和下方插入額外資訊,如收藏數、日期、解析度等。支援使用者主頁、搜尋頁、推薦列表和排行榜。
// @description:ja    サムネイルの内部および下に、ブックマーク数、日付、解像度などの追加情報を挿入します。ユーザーページ、検索ページ、おすすめリスト、ランキングに対応しています。
// @description:ko    썸네일 내부 및 아래에 북마크 수, 날짜, 해상도 등 추가 정보를 삽입합니다. 사용자 페이지, 검색 페이지, 추천 목록 및 랭킹 페이지를 지원합니다.
// @author  InMirrors
// @license GPL-3.0-or-later
// @icon    https://www.pixiv.net/favicon20250122.ico
// @match   https://www.pixiv.net/*
// @grant   GM_addStyle
// @grant   GM_getValue
// @grant   GM_setValue
// @grant   GM_registerMenuCommand
// @run-at  document-idle
// ==/UserScript==

(function () {
    'use strict';

    // Cache for artwork data (bookmark count and create date)
    const artworkDataCache = {};

    const debug = GM_getValue('debug', false);
    function debugLog(...args) {
        if (debug) {
            console.log('[PIE]', ...args);
        }
    }

    const BASE_SELECTOR = 'li, section.ranking-item';

    const CLASSES = {
        bookmarkCount: 'pie-bookmark-count',
        date: 'pie-date',
        resolution: 'pie-resolution',
        footer: 'pie-footer',
    };

    const ATTR_PROCESSED = 'data-insertion-processed'



    // =================================================================================
    // Style Customization
    // =================================================================================

    GM_addStyle(`
    /* 书签数量元素本体 */
    .${CLASSES.bookmarkCount} {
        position: absolute !important; /* 绝对定位 */
        z-index: 10; /* 确保在图片上方显示 */

        bottom: 2px;
        left  : 2px;
        right : auto;
        top   : auto;

        background-color: rgba(220, 220, 220, 0.5);
        border-radius: 8px;

        /* 移除旧的布局样式 */
        text-align: initial !important; /* 取消居中 */
        padding-bottom: 0 !important; /* 移除底部填充 */
    }

    /* 书签数量链接和文本 */
    .${CLASSES.bookmarkCount} a {
        display: block; /* 使 padding 生效 */
        height: initial !important;
        width: initial !important;
        border-radius: inherit !important; /* 继承父元素的圆角 */
        padding: 3px 6px 3px 18px; /* 文本和链接内边距变量 */

        font-family: sans-serif;
        font-size  : 12px !important;
        font-weight: bold !important;
        color      : rgba(0, 105, 177, 1) !important;
        opacity    : 1.0;
        text-decoration: none !important;

        /* 图标样式 */
        background-image: url("data:image/svg+xml;charset=utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 24 24' fill='rgba(0, 105, 177, 1)'><path d='M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z' transform='translate(12, 12) scale(1.19) translate(-12, -12.175)'/></svg>") !important;
        background-position: center left 6px !important; /* 垂直居中 水平靠左 距离左侧6px */
        background-size    : 10px !important;
        background-repeat  : no-repeat !important;
    }

    /* 在图片下方插入的元素的容器 */
    .${CLASSES.footer} {
        font-family: sans-serif;
        font-size  : 12px !important;
        font-weight: normal !important;
        color      : #000000 !important;
        opacity    : 0.7;
        line-height: 20px;
        text-decoration: none !important; /* 移除可能的下划线 */
        display: flex;
        flex-wrap: wrap;                /* Allow wrapping to next line */
        gap: 10px;                      /* Minimum gap between items */
        justify-content: space-between; /* Distribute space between left and right */
        width: 100%;                    /* Make sure the container spans full width */
    }

    /* 日期元素 */
    .${CLASSES.date} {
        white-space: nowrap; /* 防止日期换行 */
    }

    /* 分辨率元素 */
    .${CLASSES.resolution} {
        white-space: nowrap;
    }
`);



    // =================================================================================
    // Utils
    // =================================================================================

    /**
     * 基于 ISO 字符串快速生成 "yy-MM-dd HH:mm"
     * 因为 pixiv 的时间精度只到分,秒部分全是 0,所以本函数去掉秒和微秒部分
     * @param {Date} date - 要格式化的 Date 对象
     * @returns {string} 格式化后的字符串
     */
    function formatFromISO(date) {
        const iso = date.toISOString();              // e.g. "yyyy-MM-ddTHH:mm:ss.fffZ"
        const [datePart, timePart] = iso.split('T'); // ["yyyy-MM-dd", "HH:mm:ss.fffZ"]
        const dateShort = datePart.slice(2);         // "yy-MM-dd"
        const time = timePart.slice(0, 5);           // "HH:mm"
        return `${dateShort} ${time}`;               // "yy-MM-dd HH:mm:ss"
    }

    /**
     * 按目标时区偏移后再格式化
     * @param {Date} date - 原始 Date 对象(本地时区或任意时区)
     * @param {number} timeZoneCode - 目标时区,例如 +8。默认使用当前时区
     * @returns {string} 格式化后的目标时区时间字符串
     */
    function formatWithTimezone(date, timeZoneCode = -date.getTimezoneOffset() / 60) {
        // 其实 getTime() 返回的已经是当前时区的时间戳了,但之后的 toISOString() 会引入偏移
        const utcTimestamp = date.getTime();
        // 所以加上一个偏移以抵消 toISOString() 引入的偏移
        const targetTimestamp = utcTimestamp + timeZoneCode * 60 * 60000;
        const targetDate = new Date(targetTimestamp);
        return formatFromISO(targetDate);
    }

    /**
     * Debounce function to limit the rate at which a function gets called.
     * @param {Function} func The function to debounce.
     * @param {number} delay The debounce delay in milliseconds.
     * @returns {Function} The debounced function.
     */
    function debounce(func, delay) {
        let timeout;
        return function (...args) {
            const context = this;
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(context, args), delay);
        };
    }



    // =================================================================================
    // Configuration for elements to be inserted
    // =================================================================================

    const elementConfigs = [
        {
            keys: ['bookmarkCount'], // The key for the data in the artworkData object
            settingName: 'showBookmarkCount', // Unique name for storing the setting with GM_setValue
            menuLabel: 'Show Bookmark Count (显示收藏数)', // Label for the Tampermonkey menu
            isEnabledByDefault: true, // Default state if no setting is saved
            // Function to find the parent element for insertion
            getTarget: (listItem) => listItem.querySelector('a[href*="/artworks/"]'),
            // Position for insertAdjacentHTML (e.g., 'beforeend', 'afterend')
            position: 'beforeend',
            getHTMLValueNum: 1,
            // Function to generate the HTML string for the element
            getHTML: (bmCount, id) => `<div class="${CLASSES.bookmarkCount}"><a href="/bookmark_detail.php?illust_id=${id}">${bmCount}</a></div>`
        },
        {
            keys: ['createDate'],
            settingName: 'showCreateDate',
            menuLabel: 'Show Creation Date (显示创建日期)',
            isEnabledByDefault: true,
            group: 'footer', // Each group corresponds to a CSS class, set the value to apply specific styles.
            getHTMLValueNum: 1,
            // The generated HTML does NOT include its own wrapper, as it will be placed inside the group container.
            getHTML: (createDate, id) => {
                try {
                    // Format date to yyyy-MM-dd HH:mm
                    const date = new Date(createDate);
                    const formattedDate = formatWithTimezone(date);
                    // Return just the content's HTML. The container is handled by the group.
                    return `<div class="${CLASSES.date}">${formattedDate}</div>`;
                } catch (e) {
                    console.error("Error formatting or inserting create date for ID", id, ":", e);
                    return null; // Return null on error to prevent insertion
                }
            },
            // Whether to exclude this element from the normal list items (normal pages)
            excludeNormal: false,
            // Whether to exclude this element from the ranking pages
            excludeRanking: true
            // Note: No 'getTarget' or 'position' is needed here, as the group config handles placement.
        },
        {
            keys: ['width', 'height'],
            settingName: 'showResolution',
            menuLabel: 'Show Image Resolution (显示图像分辨率)',
            isEnabledByDefault: true,
            group: 'footer',
            getHTMLValueNum: 2,
            getHTML: (width, height, id) => {
                const widthInt = parseInt(width);
                const heightInt = parseInt(height);
                let orientationMark = '';

                if (Math.abs(widthInt - heightInt) <= Math.max(widthInt, heightInt) * 0.20) {
                    orientationMark = '='; // Approximately square
                } else if (widthInt > heightInt) {
                    orientationMark = '–'; // Landscape
                } else {
                    orientationMark = '|'; // Portrait
                }

                return `<div class="${CLASSES.resolution}">${orientationMark}${width}x${height}</div>`;
            }
        },
    ];

    // Object to hold the current state of all settings
    const SCRIPT_SETTINGS = {};

    /**
     * Initializes settings from GM_getValue and registers menu commands.
     * Call this function once when the script starts.
     */
    function initializeSettings() {
        console.log("Initializing script settings and menu...");
        elementConfigs.forEach(config => {
            // Load the saved setting, or use the default value
            SCRIPT_SETTINGS[config.settingName] = GM_getValue(config.settingName, config.isEnabledByDefault);

            // Register a command in the Tampermonkey menu for each element
            GM_registerMenuCommand(
                `${SCRIPT_SETTINGS[config.settingName] ? '✅' : '❌'} ${config.menuLabel}`,
                () => {
                    // Toggle the setting
                    const newValue = !SCRIPT_SETTINGS[config.settingName];
                    GM_setValue(config.settingName, newValue);
                    alert(`'${config.menuLabel}' 已${newValue ? '开启' : '关闭'}。\n请刷新页面以应用更改。`);
                }
            );
        });
    }

    initializeSettings();

    // Configuration for element groups/containers
    // Defines shared containers for multiple elements.
    const groupConfigs = {
        // A group for elements to be placed at the end of the list item
        footer: {
            selector: `.${CLASSES.footer}`, // The CSS selector for the container div
            // Function to find the anchor element to insert the container relative to
            getTarget: (listItem) => listItem.querySelector(':scope > div'),
            // Where to insert the container relative to the target ('afterend', 'beforebegin', etc.)
            position: 'afterend',
            // The HTML for the container itself. Elements will be inserted inside this.
            containerHTML: `<div class="${CLASSES.footer}"></div>`
        }
    };



    // =================================================================================
    // Core
    // =================================================================================

    async function fetchArtworkData(id) {
        //console.log(`Attempting to fetch data for ID: ${id}`);
        // Check cache first
        if (artworkDataCache[id]) {
            //console.log(`Cache hit for ID: ${id}`);
            return artworkDataCache[id];
        }

        try {
            const response = await fetch("https://www.pixiv.net/ajax/illust/" + id, { credentials: "omit" });
            if (!response.ok) {
                throw new Error(`HTTP error! status: ${response.status}`);
            }
            const data = await response.json();
            const artworkData = data?.body;

            // Store in cache
            artworkDataCache[id] = artworkData;
            //console.log(`Fetched and cached data for ID ${id}:`, artworkData);
            return artworkData;

        } catch (error) {
            console.error("Error fetching artwork data for ID", id, ":", error);
            // Store error state in cache to avoid repeated failed requests
            artworkDataCache[id] = { error: true };
            throw error; // Re-throw to be caught by the caller
        }
    } // fetchArtworkData

    /**
     * Inserts various elements into an artwork list item based on configuration.
     * This function is data-driven by the 'elementConfigs' array.
     * @param {HTMLElement} listItem - The list item element (e.g., <li> or <div>).
     * @param {string} id - The artwork ID.
     * @param {object} artworkData - The object containing artwork details.
     */
    function insertArtworkElements(listItem, id, artworkData) {
        // Check if elements already exist within this item.
        // Use a generic data attribute to mark as processed by this script.
        if (listItem.hasAttribute(ATTR_PROCESSED)) {
            return;
        }

        // Mark the listItem as processed to prevent re-insertion
        listItem.dataset.insertionProcessed = 'true';

        // Iterate over all configured elements
        elementConfigs.forEach(config => {
            // 1. Check if this element is enabled in the settings
            if (!SCRIPT_SETTINGS[config.settingName]) return;

            // 2. Check for exclusion conditions
            if (config.excludeRanking && listItem.tagName === 'SECTION') return;
            if (config.excludeNormal && listItem.tagName === 'LI') return;

            // 3. Check if the required data is available in artworkData
            // Get all missing keys (i.e., keys not present in artworkData)
            const missingKeys = config.keys.filter(key => !(key in artworkData));
            if (missingKeys.length > 0) {
                console.warn("Missing keys:", missingKeys);
                return;
            }
            const dataValues = config.keys.map(key => artworkData[key]);

            // 4. Generate the element's inner HTML
            function getHTML(config, id) {
                if (config.getHTMLValueNum === 2) {
                    return config.getHTML(dataValues[0], dataValues[1], id);
                } else { // Defaults to one value
                    return config.getHTML(dataValues[0], id);
                }
            }
            const elementHTML = getHTML(config, id);
            if (!elementHTML) return; // Skip if HTML generation failed

            // 5. Determine insertion logic: Grouped or Standalone
            if (config.group) {
                // --- Logic for Grouped Elements ---
                // Elements in the same group share the same CSS
                const groupConfig = groupConfigs[config.group];
                if (!groupConfig) {
                    console.warn(`Group '${config.group}' is not defined in groupConfigs.`);
                    return;
                }

                // Find or create the container for this group within the listItem
                let container = listItem.querySelector(groupConfig.selector);
                if (!container) {
                    // Container does not exist, so create it.
                    const parentForContainer = groupConfig.getTarget(listItem);
                    if (parentForContainer) {
                        parentForContainer.insertAdjacentHTML(groupConfig.position, groupConfig.containerHTML);
                        // Now that it's created, find it to get the element reference.
                        container = listItem.querySelector(groupConfig.selector);
                    }
                }

                // If container exists (or was just created), insert the element's HTML into it.
                if (container) {
                    container.insertAdjacentHTML('beforeend', elementHTML);
                } else {
                    console.warn(`Could not find or create container for group '${config.group}' in item ID ${id}.`);
                }
            }
            // --- Logic for Standalone Elements ---
            else {
                const insertionParent = config.getTarget(listItem);
                if (insertionParent) {
                    insertionParent.insertAdjacentHTML(config.position, elementHTML);
                } else {
                    console.warn(`Insertion parent for '${config.key}' not found for item ID ${id}.`);
                }
            }
        });
    } // insertArtworkElements

    // 处理元素,添加书签数和创建日期
    async function processSingleArtworkElement(Each) {
        // Each could be an LI or a SECTION.ranking-item
        const listItem = (Each.tagName === 'LI' || Each.tagName === 'SECTION') ? Each : Each.closest('li, section');

        if (!listItem) {
            return;
        }

        // Not a valid li
        if (!listItem.querySelector('img')) {
            return;
        }

        // Check if it's a valid item and hasn't been fully processed
        if (listItem.hasAttribute(ATTR_PROCESSED)) {
            return;
        }

        const artworkLinkElem = listItem.querySelector('a[href*="/artworks/"]');
        if (!artworkLinkElem) {
            return;
        }

        let id = null;
        // Extract ID from the href attribute
        // Format: "/artworks/ID" or "/lang/artworks/ID"
        id = /\d+$/.exec(artworkLinkElem.href)?.[0];
        if (!id) {
            return;
        }

        debugLog("Processing valid item:", listItem);

        // Check cache first
        let artworkData = artworkDataCache[id];

        // If data is in cache (even error state), attempt insertion
        if (artworkData) {
            if (artworkData.error) {
                // If cached data indicates error, mark item as processed to avoid retries
                listItem.dataset.insertionProcessed = 'error';
            } else {
                insertArtworkElements(listItem, id, artworkData);
            }
        } else {
            // Data not in cache, fetch it
            // Use a temporary marker to prevent duplicate fetches for the same element
            if (listItem.hasAttribute("data-fetching-bmc-cd")) {
                return;
            }
            listItem.dataset.fetchingBmcCd = 'true';

            try {
                artworkData = await fetchArtworkData(id);
                insertArtworkElements(listItem, id, artworkData);
            } catch (error) {
                // Error already logged in fetchArtworkData
                listItem.dataset.insertionProcessed = 'error'; // Mark item as processed with error
            } finally {
                // Remove temporary fetching marker
                delete listItem.dataset.fetchingBmcCd;
            }
        }
    } // processSingleArtworkElement()



    // =========================================================================
    // Bootstrap
    // =========================================================================

    // The core function that finds and processes all relevant artwork elements on the page.
    function processAllArtworks() {
        debugLog("Scanning for new artwork elements...");
        document.querySelectorAll(BASE_SELECTOR).forEach(processSingleArtworkElement);
    }

    // Create a debounced version of the processing function to avoid excessive runs
    // during rapid DOM changes (e.g., fast scrolling).
    const debouncedProcessAllArtworks = debounce(processAllArtworks, 50);

    const observer = new MutationObserver((mutations) => {
        debouncedProcessAllArtworks();
    });

    // Initial run: Process any elements that are already on the page when the script loads.
    processAllArtworks();

    observer.observe(document.body, { childList: true, subtree: true });

    console.log("[Pixiv Info Enhancer] script started.");

})();