// ==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.2 // @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 // @downloadURL none // ==/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, .col-span-2'; 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,") !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) => `
${bmCount}
` }, { 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 `
${formattedDate}
`; } 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 `
${orientationMark}${width}x${height}
`; } }, ]; // 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: `
` } }; // ================================================================================= // 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.,
  • or
    ). * @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(listItem) { if (!listItem) { debugLog('listItem is null'); return; } // Not a valid item if (!listItem.querySelector('img')) { debugLog('listItem does not have a img tag'); 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) { debugLog('listItem does not have a artwork link'); 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) { debugLog('Can not extract ID from the listItem'); 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."); })();