No GIF Avatars

Convert GIF avatars into static images with enhanced performance and error handling

// ==UserScript==
// @name                 No GIF Avatars
// @name:zh-CN           屏蔽 GIF 头像
// @namespace            https://www.pipecraft.net/
// @homepageURL          https://github.com/utags/userscripts#readme
// @supportURL           https://github.com/utags/userscripts/issues
// @version              0.1.1
// @description          Convert GIF avatars into static images with enhanced performance and error handling
// @description:zh-CN    将动图头像转换为静态图片,具有增强的性能和错误处理。
// @author               Pipecraft
// @license              MIT
// @match                https://linux.do/*
// @match                https://www.nodeloc.com/*
// @icon                 https://www.google.com/s2/favicons?sz=64&domain=github.com
// @grant                GM_addStyle
// ==/UserScript==

;(function () {
  'use strict'

  // Configuration constants
  const CONFIG = {
    OBSERVER_DELAY: 50, // Delay before processing mutations (ms)
    AVATAR_SELECTORS: [
      'img[src*="avatar"]',
      'img[src*="user"]',
      '.avatar img',
      '.user-avatar img',
      '.PostUser img',
    ],
    GIF_EXTENSIONS: ['.gif', '.webp'], // Extensions to replace
    REPLACEMENT_EXTENSION: '.png', // Target extension
    DEBUG: false, // Enable debug logging
  }

  let processingTimeout = null

  /**
   * Log debug messages if debug mode is enabled
   * @param {string} message - The message to log
   * @param {any} data - Optional data to log
   */
  function debugLog(message, data = null) {
    if (CONFIG.DEBUG) {
      console.log(`[No GIF Avatars] ${message}`, data || '')
    }
  }

  /**
   * Apply CSS styles to disable animations and hide decorative elements
   */
  function applyStyles() {
    const style = `
      /* Disable text animations and effects */
      .PostUser-name .username {
        text-shadow: unset !important;
        animation: unset !important;
      }

      /* Disable icon animations */
      .fa-beat,
      .fa-bounce,
      .fa-fade,
      .fa-beat-fade,
      .fa-flip,
      .fa-shake,
      .fa-spin {
        animation-name: unset !important;
        animation: unset !important;
      }

      /* Disable badge animations */
      .UserBadge {
        animation: unset !important;
      }

      /* Hide decorative avatar frames */
      .decorationAvatarFrameImageSource {
        display: none !important;
      }

      /* Disable CSS animations on avatars */
      img[src*="avatar"],
      .avatar img,
      .user-avatar img {
        animation: unset !important;
        transition: unset !important;
      }
    `

    try {
      GM_addStyle(style)
      debugLog('Styles applied successfully')
    } catch (error) {
      debugLog('Error applying styles:', error)
    }
  }

  /**
   * Check if an image URL contains GIF or other animated formats
   * @param {string} src - The image source URL
   * @returns {boolean} True if the image is animated
   */
  function isAnimatedImage(src) {
    if (!src || typeof src !== 'string') {
      return false
    }

    const lowerSrc = src.toLowerCase()
    return CONFIG.GIF_EXTENSIONS.some((ext) => lowerSrc.includes(ext))
  }

  /**
   * Convert animated image URL to static version
   * @param {string} src - The original image source URL
   * @returns {string} The converted static image URL
   */
  function convertToStaticImage(src) {
    let convertedSrc = src

    // Replace animated extensions with static one
    CONFIG.GIF_EXTENSIONS.forEach((ext) => {
      convertedSrc = convertedSrc.replace(
        new RegExp(ext.replace('.', '\\.'), 'gi'),
        CONFIG.REPLACEMENT_EXTENSION
      )
    })

    return convertedSrc
  }

  /**
   * Process a single image element
   * @param {HTMLImageElement} img - The image element to process
   * @returns {boolean} True if the image was processed
   */
  function processImage(img) {
    try {
      const originalSrc = img.src
      if (!isAnimatedImage(originalSrc)) {
        return false
      }

      const newSrc = convertToStaticImage(originalSrc)
      if (newSrc !== originalSrc) {
        img.src = newSrc
        debugLog(`Converted image: ${originalSrc} -> ${newSrc}`)
        return true
      }

      return false
    } catch (error) {
      debugLog('Error processing image:', error)
      return false
    }
  }

  /**
   * Process all images on the page
   * @param {NodeList|Array} targetImages - Specific images to process (optional)
   */
  function processImages(targetImages = null) {
    try {
      let images

      if (targetImages) {
        // Process specific images
        images = Array.from(targetImages).filter(
          (node) =>
            node.nodeType === Node.ELEMENT_NODE && node.tagName === 'IMG'
        )
      } else {
        // Process all avatar images using optimized selectors
        images = []
        CONFIG.AVATAR_SELECTORS.forEach((selector) => {
          try {
            const found = document.querySelectorAll(selector)
            images.push(...Array.from(found))
          } catch (error) {
            debugLog(`Error with selector ${selector}:`, error)
          }
        })

        // Fallback: process all images if no avatars found
        if (images.length === 0) {
          images = Array.from(document.querySelectorAll('img'))
        }
      }

      let processedCount = 0
      images.forEach((img) => {
        if (processImage(img)) {
          processedCount++
        }
      })

      if (processedCount > 0) {
        debugLog(`Processed ${processedCount} images`)
      }
    } catch (error) {
      debugLog('Error in processImages:', error)
    }
  }

  /**
   * Check if mutation contains relevant image changes
   * @param {NodeList} addedNodes - The added nodes from mutation
   * @returns {boolean} True if images were added
   */
  function hasImageChanges(addedNodes) {
    if (!addedNodes || addedNodes.length === 0) {
      return false
    }

    for (const node of addedNodes) {
      if (node.nodeType === Node.ELEMENT_NODE) {
        // Check if the node itself is an image
        if (node.tagName === 'IMG') {
          return true
        }

        // Check if the node contains images
        if (node.querySelector && node.querySelector('img')) {
          return true
        }
      }
    }

    return false
  }

  /**
   * Handle DOM mutations with debouncing
   * @param {MutationRecord[]} mutationsList - List of mutations
   */
  function handleMutations(mutationsList) {
    let hasRelevantChanges = false
    const newImages = []

    for (const mutation of mutationsList) {
      // FIXME: 没有找到原因,只处理新的节点不管用
      if (1 || hasImageChanges(mutation.addedNodes)) {
        hasRelevantChanges = true

        // Collect new images for targeted processing
        for (const node of mutation.addedNodes) {
          if (node.nodeType === Node.ELEMENT_NODE) {
            if (node.tagName === 'IMG') {
              newImages.push(node)
            } else if (node.querySelector) {
              const imgs = node.querySelectorAll('img')
              newImages.push(...Array.from(imgs))
            }
          }
        }
      }
    }

    if (hasRelevantChanges) {
      // Clear existing timeout
      if (processingTimeout) {
        clearTimeout(processingTimeout)
      }

      // Debounce processing
      processingTimeout = setTimeout(() => {
        debugLog('Processing mutations with new images')
        processImages(newImages.length > 0 ? newImages : null)
      }, CONFIG.OBSERVER_DELAY)
    }
  }

  /**
   * Initialize the script
   */
  function initialize() {
    try {
      debugLog('Initializing No GIF Avatars script')

      // Apply CSS styles
      applyStyles()

      // Process existing images
      processImages()

      // Set up mutation observer
      const observer = new MutationObserver(handleMutations)
      observer.observe(document, {
        childList: true,
        subtree: true,
      })

      debugLog('Script initialized successfully')
    } catch (error) {
      debugLog('Error during initialization:', error)
    }
  }

  // Initialize when DOM is ready
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', initialize)
  } else {
    initialize()
  }
})()