您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Show Flickr images when hovering over bird species names (IOC nomenclature) on a webpage.
// ==UserScript== // @name Bird Species Image Preview // @namespace http://tampermonkey.net/ // @version 3.2.8 // @description Show Flickr images when hovering over bird species names (IOC nomenclature) on a webpage. // @author Isidro Vila Verde // @match *://*/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant GM_setValue // @grant GM_getValue // @grant GM_registerMenuCommand // @connect api.flickr.com // @connect api.github.com // @connect flickr.com // @connect live.staticflickr.com // @run-at document-end // ==/UserScript== (async function () { 'use strict'; // Generate a random postfix for IDs to avoid conflicts const randomPostfix = `_${Math.random().toString(36).substring(2, 9)}`; // e.g., "_a1b2c3d" // Constants and Configurations const FLICKR_API_KEY = 'c161f42fac23abc42328d8abd9f14fc5'; const GISTID = ['70598ae6bef6da21ade780c12d907452','d31217a5c802d1018893907df29d1d45','01ce512accba1ebd38be9f6b301e437c']; const API_CALL_DELAY = 300; // 300 milliseconds delay between API calls const DEBOUNCE_DELAY = 300; // Debounce delay for mouseover events const highlightColor = GM_getValue('highlightColor', 'yellow') let speciesList = GM_getValue('speciesList', []); // Default sort order let currentSortOrder = GM_getValue('flickrSortOrder', 'interestingness-desc'); // Default search mode let currentSearchMode = GM_getValue('flickrSearchMode', 'tags'); // State Variables let currentIndex = 0; let currentImages = []; let popupVisible = false; let observer; let observerActive = false; let lastApiCallTime = 0; let cursorPosition = { x: 0, y: 0 }; let isKeydownListenerAdded = false; console.log('Script loaded.'); // Initialize the script async function initializeScript() { console.log('Initializing script.'); // Try to load speciesList from GitHub Gists if (speciesList.length === 0) { let mergedSpeciesList = []; for (const gistId of GISTID) { // Iterate over multiple Gist IDs console.log(`Get names from gist ${gistId}`); const gistUrl = `https://api.github.com/gists/${gistId}`; try { const gistData = await gmFetch(gistUrl); // Reuse gmFetch function const speciesSubset = loadAndMergeSpeciesLists(gistData); // Load, merge, sort, and deduplicate mergedSpeciesList.push(...speciesSubset); } catch (error) { console.warn(`Failed to load or process species list from Gist ${gistId}:`, error); } } // Remove duplicates and sort speciesList = [...new Set(mergedSpeciesList)].sort((a, b) => a.localeCompare(b)); console.log(`Species list loaded and processed: ${speciesList.length} unique names`); // Cache the speciesList GM_setValue('speciesList', speciesList); } } // Function to load, merge, sort, and deduplicate species lists from Gist files function loadAndMergeSpeciesLists(gistData) { const mergedList = []; // Iterate over all files in the Gist for (const file of Object.values(gistData.files)) { // Check if the file name matches 'birdnames' if (file.filename.includes('birdnames')) { try { // Parse the file content as JSON and add to the merged list const content = JSON.parse(file.content); if (Array.isArray(content)) { mergedList.push(...content); } else { console.warn(`File ${file.filename} does not contain a valid array.`); } } catch (error) { console.error(`Error parsing file ${file.filename}:`, error); } } } return mergedList; } // Add style for add-on function add_style() { console.log('Adding styles.'); GM_addStyle(` .bird-popup { position: fixed; z-index: 10000; background: #121212; /* Dark background */ border: 1px solid #333; /* Dark border */ border-radius: 10px; padding: 10px; box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); max-width: 80vw; display: none; text-align: center; transition: opacity 0.3s ease; resize: both; overflow: auto; color: #ffffff; /* Light text color */ } /* Ensure all child elements inherit the dark mode styles */ .bird-popup * { color: inherit; /* Inherit light text color */ background-color: transparent; /* Transparent background for child elements */ border-color: #555; /* Dark border for child elements */ } /* Style buttons for dark mode */ .bird-popup button { background-color: #333; /* Dark background for buttons */ color: #ffffff; /* Light text color for buttons */ border: 1px solid #555; /* Dark border for buttons */ padding: 5px 10px; border-radius: 5px; cursor: pointer; } .bird-popup button:hover { background-color: #444; /* Slightly lighter background for hovered buttons */ } .bird-popup img { max-width: 100%; height: auto; display: block; margin: auto; } .bird-highlight { background-color: ${highlightColor}; cursor: pointer; } .bird-popup .nav-button { position: absolute; top: 50%; transform: translateY(-50%); font-size: 24px; background: rgba(0, 0, 0, 0.5); color: white; border: none; padding: 10px; cursor: pointer; user-select: none; } .bird-popup .nav-button:hover { background: rgba(0, 0, 0, 0.8); } #prev-button${randomPostfix} { left: 10px; } #next-button${randomPostfix} { right: 10px; } .dismiss-button { display: block; margin: 10px auto; padding: 5px 10px; background-color: red; color: white; cursor: pointer; border: none; border-radius: 5px; } .loading-indicator { font-size: 18px; color: #333; padding: 20px; } .photo-info { margin: 10px 0; font-size: 14px; color: #555; } `); } // Initialize popup element function initializePopup() { console.log('Initializing popup.'); const popup = document.createElement('div'); popup.id = `bird-popup${randomPostfix}`; // Add random postfix to ID popup.className = 'bird-popup'; popup.setAttribute('role', 'dialog'); popup.setAttribute('aria-labelledby', 'popup-title'); document.body.appendChild(popup); return popup; } // Show popup with images function showPopup(e, imageData) { if (imageData.length === 0) { console.log('No images to display.'); return; } currentImages = imageData; currentIndex = 0; let popup = document.getElementById(`bird-popup${randomPostfix}`); // Add random postfix to ID if (!popup) { popup = initializePopup(); } popup.innerHTML = `<div class="loading-indicator">Loading...</div>`; popup.style.display = 'block'; pauseObserver(); popup.innerHTML = ` <button class="nav-button" id="prev-button${randomPostfix}">‹</button> <img src="${currentImages[currentIndex].url}" alt="Bird Image" /> <button class="nav-button" id="next-button${randomPostfix}">›</button> <div class="photo-info"> <strong>${currentImages[currentIndex].title}</strong> by ${currentImages[currentIndex].author || "Loading author..."} </div> <a href="${currentImages[currentIndex].flickrPage}" target="_blank">View on Flickr</a> <button class="dismiss-button" id="dismiss-button${randomPostfix}">Close</button> `; resumeObserver(); cursorPosition = { x: e.clientX, y: e.clientY }; const img = popup.querySelector('img'); if (img.complete) { positionPopup(popup); } else { img.addEventListener('load', () => positionPopup(popup)); } setupNavigation(popup); updateNavigationButtons(); popupVisible = true; window.addEventListener('scroll', () => positionPopup(popup), { passive: true }); window.addEventListener('resize', () => positionPopup(popup), { passive: true }); } // Position popup relative to viewport with scroll handling function positionPopup(popup) { console.log('Positioning popup.'); // const popupWidth = popup.offsetWidth; // const popupHeight = popup.offsetHeight; const viewportWidth = window.innerWidth; const viewportHeight = window.innerHeight; const scrollY = window.scrollY || window.pageYOffset || document.documentElement.scrollTop; const cursorX = cursorPosition.x; const cursorY = cursorPosition.y + scrollY; const spaceBelow = viewportHeight + scrollY - cursorY; const spaceAbove = cursorY - scrollY; const spaceLeft = cursorX; const spaceRigth = viewportWidth - cursorX; pauseObserver(); if (spaceBelow <= spaceAbove) { const top = 10; popup.style.top = `${top}px`; popup.style.removeProperty('bottom'); } else { const bottom = 10; popup.style.bottom = `${bottom}px`; popup.style.removeProperty('top'); } if (spaceRigth <= spaceLeft) { const left = 10; popup.style.left = `${left}px`; if (popup.style.right) popup.style.removeProperty('right'); } else { const right = 10; popup.style.right = `${right}px`; popup.style.removeProperty('left'); } resumeObserver(); } // Setup navigation buttons function setupNavigation(popup) { console.log('Setting up navigation buttons.'); // Button event listeners document.getElementById(`prev-button${randomPostfix}`).addEventListener('click', () => navigate(-1)); document.getElementById(`next-button${randomPostfix}`).addEventListener('click', () => navigate(1)); document.getElementById(`dismiss-button${randomPostfix}`).addEventListener('click', hidePopup); // Keyboard event listeners if (!isKeydownListenerAdded) { document.addEventListener('keydown', function (e) { if (popupVisible) { if (e.key === 'ArrowLeft') { navigate(-1); } else if (e.key === 'ArrowRight') { navigate(1); } else if (e.key === 'Escape') { hidePopup(); } } }); isKeydownListenerAdded = true; } // Touch event listeners for swipe gestures popup.addEventListener('touchstart', handleTouchStart, false); popup.addEventListener('touchmove', handleTouchMove, false); popup.addEventListener('touchend', handleTouchEnd, false); } // Variables to track touch positions let touchStartX = null; let touchStartY = null; // Handle touch start event function handleTouchStart(event) { const firstTouch = event.touches[0]; touchStartX = firstTouch.clientX; touchStartY = firstTouch.clientY; } // Handle touch move event function handleTouchMove(event) { if (!touchStartX || !touchStartY) return; const touchEndX = event.touches[0].clientX; const touchEndY = event.touches[0].clientY; const deltaX = touchEndX - touchStartX; const deltaY = touchEndY - touchStartY; // Determine if the movement is primarily horizontal if (Math.abs(deltaX) > Math.abs(deltaY)) { // Prevent vertical scrolling during horizontal swipe event.preventDefault(); } } // Handle touch end event function handleTouchEnd(event) { if (!touchStartX || !touchStartY) return; const touchEndX = event.changedTouches[0].clientX; const touchEndY = event.changedTouches[0].clientY; const deltaX = touchEndX - touchStartX; const deltaY = touchEndY - touchStartY; // Define a threshold for swipe detection (e.g., 50 pixels) const swipeThreshold = 50; // Check if the swipe is horizontal and exceeds the threshold if (Math.abs(deltaX) > Math.abs(deltaY) && Math.abs(deltaX) > swipeThreshold) { if (deltaX > 0) { // Swipe right -> navigate to the previous photo navigate(-1); } else { // Swipe left -> navigate to the next photo navigate(1); } } // Reset touch coordinates touchStartX = null; touchStartY = null; } // Navigate between images function navigate(direction) { currentIndex = Math.max(0, Math.min(currentImages.length - 1, currentIndex + direction)); console.log(`Navigating to image index: ${currentIndex}`); updateImage(); updateNavigationButtons(); } // Update displayed image and info function updateImage() { console.log('Updating displayed image.'); const popup = document.getElementById(`bird-popup${randomPostfix}`); if (popup) { pauseObserver(); popup.querySelector('img').src = currentImages[currentIndex].url; popup.querySelector('.photo-info').innerHTML = ` <strong>${currentImages[currentIndex].title}</strong> by ${currentImages[currentIndex].author || "Loading author..."} `; popup.querySelector('a').href = currentImages[currentIndex].flickrPage; resumeObserver(); } } // Update navigation buttons visibility function updateNavigationButtons() { console.log('Updating navigation buttons visibility.'); const prevButton = document.getElementById(`prev-button${randomPostfix}`); const nextButton = document.getElementById(`next-button${randomPostfix}`); if (prevButton) prevButton.style.display = currentIndex > 0 ? 'block' : 'none'; if (nextButton) nextButton.style.display = currentIndex < currentImages.length - 1 ? 'block' : 'none'; } // Hide popup function hidePopup() { console.log('Hiding popup.'); pauseObserver(); const popup = document.getElementById(`bird-popup${randomPostfix}`); if (popup) { popup.style.display = 'none'; popupVisible = false; } resumeObserver(); } // --- Core Functionality --- // Process species list and highlight species in text function processSpecies() { console.log('Processing species list.'); // Find and highlight species in text function findSpeciesInText(root) { const treeWalker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); const nodesToReplace = []; const speciesSet = new Set(speciesList); // Fast lookup const speciesRegex = new RegExp(`\\b(${[...speciesSet].join("|")})\\b`, "gi"); // Single regex let node; while ((node = treeWalker.nextNode())) { let text = node.nodeValue; let replacedText = text.replace(speciesRegex, (match) => { console.log(`Found species: ${match}`); return `<span class="bird-highlight">${match}</span>`; // Inline replacement }); if (text !== replacedText) { nodesToReplace.push({ node, replacedText }); } } console.log(`Found ${nodesToReplace.length} occurencies`) // DOM updates pauseObserver(); nodesToReplace.forEach(({ node, replacedText }) => { const wrapper = document.createElement("span"); wrapper.innerHTML = replacedText; node.parentNode.replaceChild(wrapper, node); }); resumeObserver(); } findSpeciesInText(document.body); observer = new MutationObserver((mutations) => { if (!observerActive) return; mutations.forEach((mutation) => { mutation.addedNodes.forEach((node) => { if (node.nodeType === 1) { findSpeciesInText(node); } }); }); }); observerActive = true; observer.observe(document.body, { childList: true, subtree: true }); } // Pause the observer function pauseObserver() { if (observer && observerActive) { console.log('Pausing observer.'); observer.disconnect(); observerActive = false; } } // Resume the observer function resumeObserver() { if (observer && !observerActive) { console.log('Resuming observer.'); observer.observe(document.body, { childList: true, subtree: true }); observerActive = true; } } // Fetch images from Flickr API with rate limiting async function fetchFlickrImages(species, callback) { const now = Date.now(); if (now - lastApiCallTime < API_CALL_DELAY) { console.log('Rate limiting API calls. Waiting...'); await new Promise(resolve => setTimeout(resolve, API_CALL_DELAY - (now - lastApiCallTime))); } lastApiCallTime = Date.now(); try { const searchUrl = `https://www.flickr.com/services/rest/?method=flickr.photos.search&api_key=${FLICKR_API_KEY}&${currentSearchMode}=${encodeURIComponent(species)}&sort=${currentSortOrder}&format=json&nojsoncallback=1&per_page=10`; console.log(`Fetching images for species: ${species}`); const searchResponse = await gmFetch(searchUrl); if (!searchResponse.photos || searchResponse.photos.photo.length === 0) { console.warn(`No images found for species: ${species}`); callback([]); return; } const photos = searchResponse.photos.photo.slice(0, 10); const images = photos.map(photo => ({ url: `https://farm${photo.farm}.staticflickr.com/${photo.server}/${photo.id}_${photo.secret}_c.jpg`, title: photo.title, author: null, flickrPage: `https://www.flickr.com/photos/${photo.owner}/${photo.id}` })); callback(images); photos.forEach(async (photo, index) => { const ownerName = await fetchOwnerName(photo.owner); images[index].author = ownerName; if (currentIndex === index) { updateImage(); } }); } catch (error) { console.error(`Error fetching images for ${species}:`, error); callback([]); } } // Helper function to fetch data using GM_xmlhttpRequest function gmFetch(url) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, headers: { 'Origin': 'null' }, anonymous: true, onload: function (response) { if (response.status >= 200 && response.status < 300) { try { resolve(JSON.parse(response.responseText)); } catch (error) { console.error('Error parsing response:', error); reject(`Error parsing response: ${error}`); } } else { console.error(`API error: ${response.statusText}`); reject(`API error: ${response.statusText}`); } }, onerror: function (error) { console.error('Request failed:', error); reject(`Request failed: ${error}`); } }); }); } // Fetch the owner's real name (or username if real name is missing) async function fetchOwnerName(ownerId) { try { const ownerUrl = `https://www.flickr.com/services/rest/?method=flickr.people.getInfo&api_key=${FLICKR_API_KEY}&user_id=${ownerId}&format=json&nojsoncallback=1`; console.log(`Fetching owner info for ID: ${ownerId}`); const ownerData = await gmFetch(ownerUrl); const person = ownerData.person; if (person) { return (person.realname ? person.realname._content : null) || (person.username ? person.username._content : null) || person.path_alias || "Unknown"; } return "Unknown"; } catch (error) { console.error(`Error fetching owner info:`, error); return "Unknown"; } } // Function to handle species highlight (for both mouse and touch events) function handleSpeciesHighlight(event) { let target; if (event.type === 'touchstart' || event.type === 'touchend') { // For touch events, use the first touch point target = event.touches ? event.touches[0].target : event.target; } else { // For mouse events, use the event target directly target = event.target; } // Check if the target has the 'bird-highlight' class if (target.classList.contains('bird-highlight')) { const species = target.textContent; console.log(`Highlight detected on species: ${species}`); fetchFlickrImages(species, (imageData) => showPopup(event, imageData)); // Prevent default behavior for touchend events on bird-highlight elements if (event.type === 'touchend') { event.preventDefault(); } } } // Debounce function to limit the rate of execution function debounce(func, delay) { let timeout; return function (...args) { clearTimeout(timeout); timeout = setTimeout(() => func.apply(this, args), delay); }; } // Attach event listeners for both mouse and touch events const debouncedHighlight = debounce(handleSpeciesHighlight, DEBOUNCE_DELAY); // Mouse events for desktop document.addEventListener('mouseover', debouncedHighlight); // Touch events for smartphones and tablets let touchStartX2, touchStartY2; document.addEventListener('touchstart', (e) => { const touch = e.touches[0]; touchStartX2 = touch.clientX; touchStartY2 = touch.clientY; }); document.addEventListener('touchend', (e) => { const touch = e.changedTouches[0]; const deltaX = Math.abs(touch.clientX - touchStartX2); const deltaY = Math.abs(touch.clientY - touchStartY2); // Only trigger if the touch movement is small (e.g., less than 10 pixels) if (deltaX < 10 && deltaY < 10) { debouncedHighlight(e); } }); await initializeScript(); processSpecies(); add_style(); GM_registerMenuCommand('Clear Species List', function () { GM_setValue('speciesList', []); alert('Species list cleared. You should reload the page now'); }); /* ------------------------- This an an extra code to allow a user to configure some parameters ------------------------- */ /* ------------------------- We can live without the code bellow ------------------------- * /* A lot of code just to allow user to pickup a color for hightligths */ GM_addStyle(` #colorPickerContainer${randomPostfix} { position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); background: #121212; /* Dark theme */ border: 1px solid #333; border-radius: 10px; padding: 15px; z-index: 10001; display: flex; flex-direction: column; /* Column layout for input, button, and text */ justify-content: center; align-items: center; box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); min-width: 35vw; min-height: 20vh; text-align: center; } #colorPickerContainer${randomPostfix} * { color: #ffffff; /* Light text */ background-color: transparent; border-color: #555; } #instructionText${randomPostfix} { margin-bottom: 10px; font-size: 16px; color: #dddddd; font-weight: normal; } .picker-row${randomPostfix} { display: flex; flex-direction: row; width: 100%; margin: 10px; } #highlightColorInput${randomPostfix} { height: 75px; flex-grow: 3; border-radius: 5px; padding: 5px; cursor: pointer; box-sizing: border-box; } .picker-buttons${randomPostfix} { display: flex; flex-direction: column; flex-grow: 1; margin: 10px; } #applyHighlightColor${randomPostfix}, #dismissButton${randomPostfix} { padding: 10px 0; border-radius: 5px; cursor: pointer; font-size: 14px; transition: background 0.2s ease; } #applyHighlightColor${randomPostfix} { background: #333; color: #ffffff; border: 1px solid #555; } #applyHighlightColor${randomPostfix}:hover { background: #444; } #dismissButton${randomPostfix} { background: #e74c3c; color: #ffffff; border: 1px solid #555; margin-top: 10px; } #dismissButton${randomPostfix}:hover { background: #c0392b; } `); function openColorPicker() { if (document.getElementById(`colorPickerContainer${randomPostfix}`)) return; const picker = document.createElement('div'); picker.id = `colorPickerContainer${randomPostfix}`; picker.innerHTML = ` <div id="instructionText${randomPostfix}"> Select a color to highlight the bird species on the page, then click "Apply". </div> <div class="picker-row${randomPostfix}"> <input type='color' id="highlightColorInput${randomPostfix}"> <div class="picker-buttons${randomPostfix}"> <button id="applyHighlightColor${randomPostfix}">Apply</button> <button id="dismissButton${randomPostfix}">Dismiss</button> </div> </div> `; document.body.appendChild(picker); // Set default color const colorInput = document.getElementById(`highlightColorInput${randomPostfix}`); colorInput.value = GM_getValue('highlightColor', '#FFFF00'); // Apply event listener document.getElementById(`applyHighlightColor${randomPostfix}`).addEventListener('click', () => { GM_setValue('highlightColor', colorInput.value); updateHighlightElements(colorInput.value); removePicker(); }); // Dismiss event listener document.getElementById(`dismissButton${randomPostfix}`).addEventListener('click', removePicker); // Attach event listeners setTimeout(() => { document.addEventListener('click', closeOnClickOutside); document.addEventListener('keydown', closeOnEscape); }, 0); function closeOnClickOutside(e) { if (!picker.contains(e.target)) { removePicker(); } } function closeOnEscape(e) { if (e.key === 'Escape') { removePicker(); } } function removePicker() { document.removeEventListener('click', closeOnClickOutside); document.removeEventListener('keydown', closeOnEscape); picker.remove(); } } // Function to update all .bird-highlight elements dynamically function updateHighlightElements(color) { document.querySelectorAll('.bird-highlight').forEach(el => { el.style.backgroundColor = color; }); } // Add context menu option to open the color picker GM_registerMenuCommand('Change Highlight Color', openColorPicker); /* Allow user to change of flickr should sort the results */ // Constants for sorting options const SORT_OPTIONS = [ { value: 'date-posted-asc', label: 'Date Posted (Oldest First)' }, { value: 'date-posted-desc', label: 'Date Posted (Newest First)' }, { value: 'date-taken-asc', label: 'Date Taken (Oldest First)' }, { value: 'date-taken-desc', label: 'Date Taken (Newest First)' }, { value: 'interestingness-desc', label: 'Interestingness (Most First)' }, { value: 'interestingness-asc', label: 'Interestingness (Least First)' }, { value: 'relevance', label: 'Relevance' } ]; // Constants for search modes const SEARCH_MODES = [ { value: 'tags', label: 'Search by Tags' }, { value: 'text', label: 'Search by Text' } ]; // Function to update sort order menu dynamically function updateSortOrderMenu() { let currentSortOrder = GM_getValue('flickrSortOrder', 'relevance'); SORT_OPTIONS.forEach(option => { const isSelected = option.value === currentSortOrder; const label = `${isSelected ? '✔ ' : ''}${option.label}`; // Update the existing menu item or create a new one GM_registerMenuCommand(label, () => { GM_setValue('flickrSortOrder', option.value); location.reload(); }); }); } // Function to update search mode menu dynamically function updateSearchModeMenu() { let currentSearchMode = GM_getValue('flickrSearchMode', 'tags'); SEARCH_MODES.forEach(mode => { const isSelected = mode.value === currentSearchMode; const label = `${isSelected ? '✔ ' : ''}${mode.label}`; // Update the existing menu item or create a new one GM_registerMenuCommand(label, () => { GM_setValue('flickrSearchMode', mode.value); location.reload(); }); }); } // Call the functions to register the menus updateSortOrderMenu(); updateSearchModeMenu(); })();
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址