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

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

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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();
})();