// ==UserScript==
// @name Aibooru AI Metadata On Hover
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Show AI metadata when hovering over images on Aibooru posts page
// @author LeechKing
// @license MIT
// @match https://*.aibooru.online/posts*
// @match https://*.aibooru.online/
// @match https://*.aibooru.online
// @require https://cdn.jsdelivr.net/npm/[email protected]/dist/exif-reader.min.js
// @grant GM_xmlhttpRequest
// @connect *.aibooru.online
// ==/UserScript==
(function() {
'use strict';
// Check if we're on a posts listing page (not individual post page)
const currentUrl = window.location.pathname;
const isPostsListingPage = /^\/posts\/?$/i.test(currentUrl) ||
currentUrl === '/posts' ||
currentUrl === '/' ||
currentUrl === '';
// Exit early if we're on an individual post page (e.g., /posts/134262)
if (!isPostsListingPage) {
console.log('Aibooru Metadata Hover: Not on posts listing page, exiting...');
return;
}
console.log('Aibooru Metadata Hover: On posts listing page, initializing...');
// Cache for storing fetched metadata to avoid repeated requests
const metadataCache = new Map();
// Create and style the metadata display div
const metadataDiv = document.createElement('div');
metadataDiv.id = 'aibooru-metadata-tooltip';
metadataDiv.style.cssText = `
position: fixed;
background: rgba(0, 0, 0, 0.95);
color: white;
padding: 20px;
border-radius: 8px;
border: 2px solid #4a90e2;
max-width: 800px;
max-height: 1000px;
overflow-y: auto;
z-index: 10000;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
font-size: 14px;
line-height: 1.4;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
display: none;
word-wrap: break-word;
pointer-events: auto;
`;
document.body.appendChild(metadataDiv);
// Add hover events to the tooltip itself
metadataDiv.addEventListener('mouseenter', function() {
isHoveringTooltip = true;
if (closeTimeout) {
clearTimeout(closeTimeout);
closeTimeout = null;
}
});
metadataDiv.addEventListener('mouseleave', function() {
isHoveringTooltip = false;
scheduleClose();
});
// Function to schedule tooltip closing with delay
function scheduleClose() {
if (closeTimeout) {
clearTimeout(closeTimeout);
}
closeTimeout = setTimeout(() => {
if (!isHoveringTooltip && !currentHoverElement) {
hideMetadata();
}
}, CLOSE_DELAY);
}
// Function to extract post ID from various link formats
function getPostIdFromElement(element) {
console.log('Extracting post ID from element:', element);
// Try link href first
const link = element.closest('a[href*="/posts/"]');
const linkMatch = link?.href.match(/\/posts\/(\d+)/);
if (linkMatch) return linkMatch[1];
// Try image src patterns (excluding hash patterns)
const img = element.tagName === 'IMG' ? element : element.querySelector('img');
if (img?.src) {
const patterns = [/\/(\d+)_/, /post_(\d+)/, /\/(\d+)\//];
for (const pattern of patterns) {
const match = img.src.match(pattern);
if (match) return match[1];
}
}
// Try data attributes
const parent = element.closest('[data-id], [data-post-id], .post');
if (parent?.dataset.id) return parent.dataset.id;
if (parent?.dataset.postId) return parent.dataset.postId;
if (element.dataset?.id) return element.dataset.id;
// Try parent link
const parentLink = parent?.querySelector('a[href*="/posts/"]');
const parentMatch = parentLink?.href.match(/\/posts\/(\d+)/);
if (parentMatch) return parentMatch[1];
console.log('Could not extract post ID from element');
return null;
}
// Function to fetch AI metadata from a post page
function fetchMetadata(postId) {
if (metadataCache.has(postId)) return Promise.resolve(metadataCache.get(postId));
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `${window.location.origin}/posts/${postId}`,
onload: (response) => {
if (response.status === 200) {
const metadata = extractMetadataFromPage(new DOMParser().parseFromString(response.responseText, 'text/html'));
metadataCache.set(postId, metadata);
resolve(metadata);
} else {
reject(new Error(`HTTP ${response.status}`));
}
},
onerror: reject
});
});
}
// Function to extract metadata from the fetched post page
function extractMetadataFromPage(doc) {
const metadata = { aiMetadataRows: [], model: '', artist: '', tags: [] };
// Helper to extract table rows
const extractTableRows = (table) => {
if (!table) return;
table.querySelectorAll('tr').forEach(row => {
const th = row.querySelector('th');
const td = row.querySelector('td');
if (th && td) {
const label = th.textContent.trim();
const value = td.textContent.trim();
metadata.aiMetadataRows.push({ label, value });
console.log(`Found metadata: ${label} = ${value.substring(0, 50)}${value.length > 50 ? '...' : ''}`);
}
});
};
// Try to find AI metadata table
const aiMetadataTable = doc.querySelector('.ai-metadata-table tbody, table.ai-metadata-table tbody, .striped.ai-metadata-table tbody') ||
doc.querySelector('#artist-commentary table tbody, .ai-metadata-tab table tbody');
extractTableRows(aiMetadataTable);
// Helper to extract section data
const extractSection = (sectionName) => {
const section = Array.from(doc.querySelectorAll('h4, h5, h6')).find(h => h.textContent.trim() === sectionName);
const link = section?.parentElement.querySelector('a[href*="/posts?tags="]');
return link?.textContent.trim() || '';
};
metadata.artist = extractSection('Artist');
metadata.model = extractSection('Model');
// Extract general tags
const generalSection = Array.from(doc.querySelectorAll('h4, h5, h6')).find(h => h.textContent.trim() === 'General');
if (generalSection) {
metadata.tags = Array.from(generalSection.parentElement.querySelectorAll('a[href*="/posts?tags="]'))
.map(a => a.textContent.trim())
.filter(tag => tag && !tag.includes('?'))
.slice(0, 10);
}
return metadata;
}
// Function to fetch original image URL from Aibooru API
function getOriginalImageUrl(postId) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: `${window.location.origin}/posts.json?tags=id:${postId}`,
onload: (response) => {
try {
if (response.status !== 200) throw new Error(`API request failed with status ${response.status}`);
const data = JSON.parse(response.responseText);
if (!data?.length) throw new Error('No post data found in API response');
const originalUrl = data[0].file_url || data[0].large_file_url || data[0].preview_file_url;
if (!originalUrl) throw new Error('No image URL found in API response');
console.log('Found original image URL:', originalUrl);
resolve(originalUrl);
} catch (e) {
reject(new Error(`Error parsing API response: ${e.message}`));
}
},
onerror: (error) => reject(new Error(`API request failed: ${error}`))
});
});
}
// Function to read image metadata with API fallback
function readImageMetadata(postId, thumbnailUrl = null) {
console.log('Attempting to read image metadata for post ID:', postId);
return getOriginalImageUrl(postId)
.then(originalImageUrl => {
console.log('Got original image URL, reading metadata from:', originalImageUrl);
return readImageFromUrl(originalImageUrl);
})
.catch(apiError => {
console.log('Failed to get original URL from API:', apiError);
if (thumbnailUrl) {
console.log('Falling back to thumbnail URL:', thumbnailUrl);
return readImageFromUrl(thumbnailUrl);
}
throw apiError;
});
}
// Helper function to read image metadata from a specific URL
function readImageFromUrl(imageUrl) {
return fetch(imageUrl)
.then(response => response.arrayBuffer())
.then(fileBuffer => extractImageMetadata(fileBuffer))
.catch(() => new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET",
url: imageUrl,
responseType: "arraybuffer",
onload: (res) => {
try {
resolve(extractImageMetadata(res.response));
} catch (e) {
reject(e);
}
},
onerror: reject
});
}));
}
// Function to extract metadata from image buffer
function extractImageMetadata(fileBuffer) {
const metadata = { aiMetadataRows: [], model: '', artist: '', tags: [] };
if (!fileBuffer) return metadata;
try {
const tags = ExifReader.load(fileBuffer, {expanded: true});
const prompt = getPromptFromImage(tags);
// Add prompt data
if (prompt.positive) metadata.aiMetadataRows.push({ label: 'Prompt', value: prompt.positive });
if (prompt.negative) metadata.aiMetadataRows.push({ label: 'Negative Prompt', value: prompt.negative });
if (prompt.others) {
// Extract and add parameters in a more compact way
const params = [
['Steps', /Steps:\s*(\d+)/],
['Sampler', /Sampler:\s*([^,\n]+)/],
['CFG Scale', /CFG scale:\s*([\d.]+)/],
['Seed', /Seed:\s*(\d+)/],
['Size', /Size:\s*(\d+x\d+)/]
];
params.forEach(([label, regex]) => {
const match = prompt.others.match(regex);
if (match) metadata.aiMetadataRows.push({ label, value: match[1].trim?.() || match[1] });
});
const modelMatch = prompt.others.match(/Model:\s*([^,\n]+)/);
if (modelMatch) metadata.model = modelMatch[1].trim();
metadata.aiMetadataRows.push({ label: 'Raw Parameters', value: prompt.others });
}
} catch(e) {
console.log('Error extracting image metadata:', e);
}
return metadata;
}
// Function to extract prompt data from EXIF tags (adapted from the reference script)
function getPromptFromImage(tags) {
const prompt = { positive: "", negative: "", others: "" };
let com = "";
// Helper function to parse standard A1111 format
const parseA1111 = (text) => {
try {
prompt.positive = text.match(/([^]+)Negative prompt: /)?.[1] || "";
prompt.negative = text.match(/Negative prompt: ([^]+)Steps: /)?.[1] || "";
prompt.others = text.match(/(Steps: [^]+)/)?.[1] || text;
} catch (e) {
prompt.others = text;
}
};
if (tags.exif?.UserComment) {
com = decodeUnicode(tags.exif.UserComment.value);
if (com) parseA1111(com);
} else if (tags.pngText?.parameters) {
parseA1111(tags.pngText.parameters.description);
} else if (tags.pngText?.Dream) {
com = tags.pngText.Dream.description + (tags.pngText["sd-metadata"] ? "\r\n" + tags.pngText["sd-metadata"].description : "");
try {
prompt.positive = com.match(/([^]+?)\[[^[]+\]/)?.[1] || "";
prompt.negative = com.match(/\[([^[]+?)(\]|Steps: )/)?.[1] || "";
prompt.others = com.match(/\]([^]+)/)?.[1] || com;
} catch (e) {
prompt.others = com;
}
} else if (tags.pngText?.Comment) {
try {
const comment = tags.pngText.Comment.description.replaceAll(/\\u00a0/g, " ");
const parsed = JSON.parse(comment);
prompt.positive = tags.pngText.Description?.description || parsed.prompt || "";
prompt.negative = parsed.uc || "";
prompt.others = [comment, tags.pngText.Software?.description, tags.pngText.Title?.description,
tags.pngText.Source?.description, tags.pngText["Generation time"] &&
"Generation time: " + tags.pngText["Generation time"].description].filter(Boolean).join("\r\n");
} catch (e) {
prompt.others = tags.pngText.Comment.description;
}
} else if (tags.pngText) {
prompt.others = Object.values(tags.pngText).map(t => t.description).join("");
}
return prompt;
}
// Function to decode Unicode from EXIF data (from reference script)
const decodeUnicode = (array) => {
const plain = array.map(t => t.toString(16).padStart(2, "0")).join("");
if (!plain.match(/^554e49434f44450/)) return;
const hex = plain.replace(/^554e49434f44450[0-9]/, "").replace(/[0-9a-f]{4}/g, ",0x$&").replace(/^,/, "");
return hex.split(",").map(v => String.fromCodePoint(v)).join("");
};
// Function to format and display metadata with improved positioning
function displayMetadata(metadata, imageElement, isImageMetadata = false) {
const sourceText = isImageMetadata ? ' (from image)' : '';
let html = `<div style="margin-bottom: 15px; font-weight: bold; color: #4a90e2; border-bottom: 1px solid #4a90e2; padding-bottom: 8px; font-size: 16px;">AI Metadata${sourceText}</div>`;
// Add artist and model if available
if (metadata.artist) html += `<div style="margin-bottom: 8px;"><strong>Artist:</strong> ${escapeHtml(metadata.artist)}</div>`;
if (metadata.model) html += `<div style="margin-bottom: 8px;"><strong>Model:</strong> ${escapeHtml(metadata.model)}</div>`;
// Display AI metadata rows
metadata.aiMetadataRows?.forEach(row => {
const isPrompt = row.label.toLowerCase().includes('prompt');
const bgColor = isPrompt ? (row.label.toLowerCase().includes('negative') ? 'rgba(255,100,100,0.1)' : 'rgba(255,255,255,0.1)') : '';
if (isPrompt) {
html += `<div style="margin-top: 10px;"><strong>${escapeHtml(row.label)}:</strong><br><div style="background: ${bgColor}; padding: 8px; margin-top: 5px; border-radius: 4px; font-size: 13px; max-height: 150px; overflow-y: auto; word-break: break-word;">${escapeHtml(row.value)}</div></div>`;
} else {
html += `<div style="margin-top: 6px;"><strong>${escapeHtml(row.label)}:</strong> ${escapeHtml(row.value)}</div>`;
}
});
// Display tags
if (metadata.tags?.length) {
html += `<div style="margin-top: 12px;"><strong>Tags:</strong><br><div style="background: rgba(255,255,255,0.05); padding: 8px; margin-top: 5px; border-radius: 4px; font-size: 13px;">${metadata.tags.map(escapeHtml).join(', ')}</div></div>`;
}
// No metadata message
const hasContent = metadata.aiMetadataRows?.length || metadata.artist || metadata.model || metadata.tags?.length;
if (!hasContent) html += '<div style="color: #888; font-style: italic;">No AI metadata found for this image</div>';
metadataDiv.innerHTML = html;
// Positioning logic
const imageRect = imageElement.getBoundingClientRect();
const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
const marginPercent = 2;
// Calculate responsive width (30% of window, clamped between 300-800px)
const targetWidth = Math.max(300, Math.min(800, windowWidth * 0.3));
const widthPercent = (targetWidth / windowWidth) * 100;
Object.assign(metadataDiv.style, {
width: widthPercent + '%',
maxWidth: widthPercent + '%'
});
// Horizontal positioning based on image location
const isImageOnLeft = (imageRect.left + imageRect.width / 2) < (windowWidth / 2);
metadataDiv.style.left = (isImageOnLeft ? (100 - widthPercent - marginPercent) : marginPercent) + '%';
// Vertical positioning with bounds checking
const marginPx = windowHeight * marginPercent / 100;
const preferredTop = imageRect.top;
const updatedRect = metadataDiv.getBoundingClientRect();
let top = preferredTop + updatedRect.height <= windowHeight - marginPx
? Math.max(marginPx, preferredTop)
: windowHeight - updatedRect.height - marginPx;
top = Math.max(marginPx, Math.min(top, windowHeight - updatedRect.height - marginPx));
Object.assign(metadataDiv.style, {
top: top + 'px',
display: 'block'
});
}
// Utility functions
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
const hideMetadata = () => metadataDiv.style.display = 'none';
// Functions to handle image scaling
const enlargeImage = (img, scale = 1.2) => {
Object.assign(img.style, {
transition: 'transform 0.3s ease, z-index 0s',
transform: `scale(${scale})`,
zIndex: '1000',
position: 'relative'
});
};
const restoreImage = (img) => {
Object.assign(img.style, { transform: 'scale(1)', zIndex: '', position: '' });
};
// Global tracking for current hover session
let currentHoverElement = null;
let currentHoverSession = 0;
let metadataFetched = false;
let lastExecutionTime = 0;
const MINIMUM_DELAY = 200; // 200ms minimum delay between executions
const CLOSE_DELAY = 300; // 300ms delay before closing tooltip
let closeTimeout = null;
let isHoveringTooltip = false;
// Set up event listeners for images
function setupImageListeners() {
// Find all images on the posts page - be more comprehensive
const images = document.querySelectorAll('img[src*="aibooru"], img[src*="cdn."], a[href*="/posts/"] img, .post img, .post-preview img, img[src*="sample"], img[src*="preview"]');
console.log(`Found ${images.length} images to process`);
images.forEach(img => {
// Skip if already processed
if (img.dataset.metadataListener) return;
img.dataset.metadataListener = 'true';
let hoverTimeout;
let isHovering = false;
img.addEventListener('mouseenter', function(e) {
const currentTime = Date.now();
// Check minimum delay since last execution - silent skip
if (currentTime - lastExecutionTime < MINIMUM_DELAY) {
return;
}
// Check if this is a new hover session - silent skip
if (currentHoverElement === this && metadataFetched) {
return;
}
// Update last execution time
lastExecutionTime = currentTime;
// Start new hover session
currentHoverElement = this;
currentHoverSession++;
metadataFetched = false;
isHovering = true;
console.log(`Starting hover session ${currentHoverSession} for element:`, this);
enlargeImage(img);
// Store initial mouse position for tooltip
const initialX = e.clientX;
const initialY = e.clientY;
const sessionId = currentHoverSession; // Capture current session ID
// Delay metadata fetching slightly to avoid excessive requests
hoverTimeout = setTimeout(() => {
// Only proceed if this is still the current session and we haven't fetched metadata yet
if (!isHovering || sessionId !== currentHoverSession || metadataFetched) {
console.log('Hover session changed or metadata already fetched, aborting...');
return;
}
metadataFetched = true; // Mark as fetched to prevent duplicates
const postId = getPostIdFromElement(this);
if (postId) {
console.log(`Fetching metadata for post ID: ${postId}`);
// Show loading indicator
metadataDiv.innerHTML = '<div style="text-align: center; color: #4a90e2;">Loading metadata...</div>';
displayMetadata({aiMetadataRows: [], tags: []}, img, false);
fetchMetadata(postId).then(metadata => {
// Only show if this is still the current session
if (isHovering && sessionId === currentHoverSession) {
// Check if we got useful metadata from the page
const hasPageMetadata = (metadata.aiMetadataRows && metadata.aiMetadataRows.length > 0) ||
metadata.artist || metadata.model ||
(metadata.tags && metadata.tags.length > 0);
if (hasPageMetadata) {
console.log('Displaying page metadata for session:', sessionId);
displayMetadata(metadata, img, false);
} else {
// Try to get metadata from the image itself as fallback
console.log('No page metadata found, trying image metadata fallback');
metadataDiv.innerHTML = '<div style="text-align: center; color: #4a90e2;">Reading image metadata...</div>';
displayMetadata({aiMetadataRows: [], tags: []}, img, false);
readImageMetadata(postId, img.src).then(imageMetadata => {
if (isHovering && sessionId === currentHoverSession) {
if (imageMetadata.aiMetadataRows && imageMetadata.aiMetadataRows.length > 0) {
console.log('Displaying image metadata for session:', sessionId);
displayMetadata(imageMetadata, img, true);
} else {
console.log('No metadata found in image either');
metadataDiv.innerHTML = '<div style="color: #888; font-style: italic;">No AI metadata found for this image</div>';
displayMetadata({aiMetadataRows: [], tags: []}, img, false);
}
}
}).catch(imageError => {
if (isHovering && sessionId === currentHoverSession) {
console.log('Error reading image metadata:', imageError);
metadataDiv.innerHTML = '<div style="color: #888; font-style: italic;">No AI metadata found for this image</div>';
displayMetadata({aiMetadataRows: [], tags: []}, img, false);
}
});
}
} else {
console.log('Session changed, not displaying metadata');
}
}).catch(error => {
if (isHovering && sessionId === currentHoverSession) {
console.log('Error fetching page metadata, trying image metadata fallback:', error);
metadataDiv.innerHTML = '<div style="text-align: center; color: #4a90e2;">Reading image metadata...</div>';
displayMetadata({aiMetadataRows: [], tags: []}, img, false);
readImageMetadata(postId, img.src).then(imageMetadata => {
if (isHovering && sessionId === currentHoverSession) {
if (imageMetadata.aiMetadataRows && imageMetadata.aiMetadataRows.length > 0) {
console.log('Displaying fallback image metadata for session:', sessionId);
displayMetadata(imageMetadata, img, true);
} else {
metadataDiv.innerHTML = '<div style="color: #ff6b6b;">Failed to load metadata</div>';
displayMetadata({aiMetadataRows: [], tags: []}, img, false);
}
}
}).catch(imageError => {
if (isHovering && sessionId === currentHoverSession) {
console.log('Error with both page and image metadata:', imageError);
metadataDiv.innerHTML = '<div style="color: #ff6b6b;">Failed to load metadata</div>';
displayMetadata({aiMetadataRows: [], tags: []}, img, false);
}
});
}
});
} else {
if (isHovering && sessionId === currentHoverSession) {
console.log('Post ID not found');
metadataDiv.innerHTML = '<div style="color: #888;">Post ID not found</div>';
displayMetadata({aiMetadataRows: [], tags: []}, img, false);
}
}
}, 300);
});
img.addEventListener('mouseleave', function() {
console.log(`Mouse left element, ending hover session ${currentHoverSession}`);
isHovering = false;
clearTimeout(hoverTimeout);
restoreImage(img);
// Reset hover tracking when leaving the element
currentHoverElement = null;
metadataFetched = false;
currentHoverSession++;
// Schedule tooltip closing with delay
scheduleClose();
});
});
}
// Initial setup
setupImageListeners();
// Re-setup listeners when new content is loaded (for infinite scroll or dynamic content)
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.addedNodes.length > 0) {
// Delay to ensure new images are properly loaded
setTimeout(setupImageListeners, 100);
}
});
});
observer.observe(document.body, {
childList: true,
subtree: true
});
// Hide metadata when clicking elsewhere
document.addEventListener('click', function(e) {
// Don't hide if clicking on the tooltip itself
if (!metadataDiv.contains(e.target)) {
if (closeTimeout) {
clearTimeout(closeTimeout);
closeTimeout = null;
}
hideMetadata();
}
});
console.log('Aibooru AI Metadata Hover userscript loaded successfully!');
})();