Anna's Archive 搜索结果增强器 v1.3

年份/版本/格式/大小徽章 + 动态高亮连线:鼠标悬停时仅突出显示同书多版本,其余淡化;增强纯数字版本识别。

// ==UserScript==
// @name         Anna's Archive 搜索结果增强器 v1.3
// @namespace    http://tampermonkey.net/
// @version      1.3.0
// @license MIT
// @description  年份/版本/格式/大小徽章 + 动态高亮连线:鼠标悬停时仅突出显示同书多版本,其余淡化;增强纯数字版本识别。
// @author       Assistant
// @match        *://*.annas-archive.org/*
// @match        *://annas-archive.org/*
// @grant        none
// @run-at       document-end
// ==/UserScript==

(function () {
  'use strict';

  /****************************
   * 1. CONFIGURATION
   ***************************/
  const CONFIG = {
    PREFERRED_FORMATS: ['pdf'],
    NON_PREFERRED_FORMATS: ['epub', 'fb2', 'mobi', 'azw3', 'djvu', 'cbz', 'cbr', 'rar', 'zip'],
    CURRENT_YEAR: new Date().getFullYear(),
    MIN_YEAR: 1900,
    NEW_YEAR_THRESHOLD: 10,

    COLORS: {
      PREFERRED: '#22c55e',
      WARNING: '#ef4444',
      NEUTRAL: '#64748b',
      SIZE: '#14b8a6',
      YEAR_NEW: ['#3b82f6', '#1d4ed8'],
      YEAR_OLD: ['#d1d5db', '#6b7280'],
      VERSION_NEW: ['#8b5cf6', '#7c3aed'],
      VERSION_OLD: ['#e5e7eb', '#9ca3af']
    },

    CLUSTER_COLORS: [
      '#60a5fa', '#34d399', '#fbbf24', '#f472b6', '#a78bfa', '#f87171', '#2dd4bf', '#facc15'
    ],

    TITLE_SIM_THRESHOLD: 0.8,
    TITLE_DIST_THRESHOLD: 5
  };

  /****************************
   * 2. UTILITIES
   ***************************/
  const clean = (t = '') => t.replace(/\s+/g, ' ').trim();
  const normTitle = (t = '') => t.toLowerCase().replace(/[^\w\s]/g, '').trim();

  function levenshtein(a, b) {
    const m = a.length, n = b.length;
    if (!m) return n; if (!n) return m;
    const v0 = Array.from({ length: n + 1 }, (_, i) => i);
    const v1 = new Array(n + 1);
    for (let i = 0; i < m; i++) {
      v1[0] = i + 1;
      for (let j = 0; j < n; j++) {
        const cost = a[i] === b[j] ? 0 : 1;
        v1[j + 1] = Math.min(v1[j] + 1, v0[j + 1] + 1, v0[j] + cost);
      }
      for (let j = 0; j <= n; j++) v0[j] = v1[j];
    }
    return v1[n];
  }

  function jaroWinkler(s1, s2) {
    if (s1 === s2) return 1;
    const len1 = s1.length, len2 = s2.length;
    if (!len1 || !len2) return 0;
    const range = Math.max(len1, len2) / 2 - 1;
    const s2Match = new Array(len2).fill(false);
    let matches = 0, transpositions = 0;

    for (let i = 0; i < len1; i++) {
      const start = Math.max(0, i - range);
      const end = Math.min(i + range + 1, len2);
      for (let j = start; j < end; j++) {
        if (!s2Match[j] && s1[i] === s2[j]) { s2Match[j] = true; matches++; break; }
      }
    }
    if (!matches) return 0;
    let k = 0;
    for (let i = 0; i < len1; i++) {
      if (s1[i] === s2[[...s2Match.keys()].find(j => s2Match[j] && j >= k)]) {
        k = [...s2Match.keys()].find(j => s2Match[j] && j >= k) + 1;
      } else transpositions++;
    }
    transpositions /= 2;
    let sim = (matches / len1 + matches / len2 + (matches - transpositions) / matches) / 3;
    let l = 0; while (l < 4 && s1[l] === s2[l]) l++;
    return sim + l * 0.1 * (1 - sim);
  }

  function extractYear(text) {
    const years = (text.match(/\b(19|20)\d{2}\b/g) || []).map(Number).filter(y => y >= CONFIG.MIN_YEAR && y <= CONFIG.CURRENT_YEAR);
    return years.length ? Math.max(...years) : null;
  }

  function extractVersion(text) {
    const ordMap = { first: 1, second: 2, third: 3, fourth: 4, fifth: 5, sixth: 6, seventh: 7, eighth: 8, ninth: 9, tenth: 10 };
    let m;
    if (m = text.match(/\b(first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\s*(edition|ed\.?)/i)) return ordMap[m[1].toLowerCase()];
    if (m = text.match(/\b(\d{1,3})(?:st|nd|rd|th)?\s*(edition|ed\.?|版)/i)) return m[1];
    if (m = text.match(/v(?:er\.?|ersion)?\s*(\d+(?:\.\d+)*)/i)) return m[1];
    if (m = text.match(/第\s*(\d+)\s*版/)) return m[1];
    // fallback: lone digit 1‑20 near year comma or in parentheses
    if (m = text.match(/[\(,\-]\s*(\d{1,2})\s*(?:[\),]|,\s*\d{4})/)) {
      const n = parseInt(m[1]); if (n >= 1 && n <= 20) return n;
    }
    return null;
  }

  function extractFormats(text) {
    const set = new Set();
    text.replace(/\b(pdf|epub|fb2|mobi|azw3|djvu|cbz|cbr|rar|zip)\b/gi, (_, f) => { set.add(f.toLowerCase()); return _; });
    return Array.from(set);
  }

  function extractSize(text) {
    const m = text.match(/(\d+(?:\.\d+)?)\s*(kb|mb|gb)/i); return m ? `${m[1]}${m[2].toUpperCase()}` : null;
  }

  const grad = c => Array.isArray(c) ? `linear-gradient(135deg, ${c[0]}, ${c[1]})` : c;

  function badge(label, cls, color, tip='') {
    const s = document.createElement('span');
    s.className = `aa-badge ${cls}`.trim(); s.textContent = label;
    s.style.background = grad(color); if (tip) s.title = tip; return s;
  }

  /****************************
   * 3. STYLE
   ***************************/
  function injectStyles() {
    if (document.getElementById('aa-style')) return;
    const css = `
      .aa-container{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0;font-size:12px}
      .aa-badge{display:inline-flex;align-items:center;padding:2px 8px;border-radius:12px;font-weight:600;color:#fff;line-height:1.2;text-shadow:0 1px 2px rgba(0,0,0,.25)}
      .aa-badge.format.preferred::before{content:'✔ ';font-weight:bold}
      .aa-badge.format.warning::before{content:'⚠ ';font-weight:bold}
      .aa-cluster-line{border-left:4px solid var(--cluster-color,transparent);padding-left:6px;opacity:.25;transition:opacity .2s ease}
      .aa-cluster-line.aa-active{opacity:1;}
    `;
    const style = document.createElement('style'); style.id='aa-style'; style.textContent = css; document.head.appendChild(style);
  }

  /****************************
   * 4. RESULT ENHANCEMENT
   ***************************/
  function enhanceResult(el) {
    if (el.querySelector('.aa-container')) return;
    const text = clean(el.textContent.toLowerCase());
    const year = extractYear(text); const ver = extractVersion(text); const size = extractSize(text); const formats = extractFormats(text);
    const wrap = document.createElement('div'); wrap.className='aa-container';
    if (year) { const isNew = CONFIG.CURRENT_YEAR - year <= CONFIG.NEW_YEAR_THRESHOLD; wrap.appendChild(badge(year,'year',isNew?CONFIG.COLORS.YEAR_NEW:CONFIG.COLORS.YEAR_OLD,`出版年份 ${year}`)); }
    if (ver){ const isLatest = parseFloat(ver)>=2; wrap.appendChild(badge(`v${ver}`,'version',isLatest?CONFIG.COLORS.VERSION_NEW:CONFIG.COLORS.VERSION_OLD,`版本 ${ver}`)); }
    if (size) wrap.appendChild(badge(size,'size',CONFIG.COLORS.SIZE,`文件大小 ${size}`));
    formats.forEach(f=>{ let col=CONFIG.COLORS.NEUTRAL,cls='format',tip=`格式 ${f.toUpperCase()}`; if(CONFIG.PREFERRED_FORMATS.includes(f)){col=CONFIG.COLORS.PREFERRED;cls+=' preferred';tip='推荐格式 '+f.toUpperCase();} else if(CONFIG.NON_PREFERRED_FORMATS.includes(f)){col=CONFIG.COLORS.WARNING;cls+=' warning';tip='不推荐格式 '+f.toUpperCase();} wrap.appendChild(badge(f.toUpperCase(),cls,col,tip)); });
    el.appendChild(wrap);
  }

  /****************************
   * 5. CLUSTERING WITH DYNAMIC HOVER
   ***************************/
  function clusterAndDecorate(items){
    const clusters=[];
    items.forEach(el=>{
      const tNode=el.querySelector('h3,h2,.title,.bookTitle'); if(!tNode) return; const title=normTitle(tNode.textContent);
      let c=null; for(const cl of clusters){ if(jaroWinkler(title,cl.rep)>=CONFIG.TITLE_SIM_THRESHOLD||levenshtein(title,cl.rep)<=CONFIG.TITLE_DIST_THRESHOLD){c=cl;break;} }
      if(!c){ c={rep:title,items:[],color:CONFIG.CLUSTER_COLORS[clusters.length%CONFIG.CLUSTER_COLORS.length]}; clusters.push(c);} c.items.push(el);
    });
    clusters.forEach((c,idx)=>{
      if(c.items.length<2) return; c.items.forEach(el=>{ el.classList.add('aa-cluster-line'); el.style.setProperty('--cluster-color',c.color); el.dataset.aaCluster=idx; });
    });
  }

  function attachHoverLogic(){
    document.addEventListener('mouseover',e=>{
      const target=e.target.closest('[data-aa-cluster]');
      document.querySelectorAll('.aa-cluster-line').forEach(el=>el.classList.remove('aa-active'));
      if(target){ const cluster=target.dataset.aaCluster; document.querySelectorAll(`[data-aa-cluster="${cluster}"]`).forEach(el=>el.classList.add('aa-active')); }
    });
  }

  /****************************
   * 6. FIND & ENHANCE
   ***************************/
  function findResults(){
    const sels=['[class*="result"]','[class*="item"]','[class*="book"]','[class*="entry"]','[class*="card"]','article','.search-result','.result-item','.book-item','[data-testid*="result"]'];
    let res=[]; sels.forEach(sel=>{const els=document.querySelectorAll(sel); if(els.length){ const f=[...els].filter(el=>{const t=el.textContent||''; return t.length>50&&(/[\.](pdf|epub|mobi)|MB|KB|GB|\d{4}/i.test(t));}); if(f.length>res.length) res=f;}}); return res;
  }

  function enhance(){ const items=findResults(); if(!items.length) return; items.forEach(enhanceResult); clusterAndDecorate(items); }

  /****************************
   * 7. INIT
   ***************************/
  function init(){ injectStyles(); enhance(); attachHoverLogic(); const obs=new MutationObserver(m=>{ if(m.some(x=>x.addedNodes.length)) setTimeout(enhance,300);}); obs.observe(document.body,{childList:true,subtree:true}); }
  document.readyState==='loading'?document.addEventListener('DOMContentLoaded',init):init();
})();