Custom Favicon Per Site

Safer per-domain favicon override with debounce & preflight.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Custom Favicon Per Site
// @namespace    harry297.favicon
// @version      1.3.0
// @description  Safer per-domain favicon override with debounce & preflight.
// @match        *://*/*
// @run-at       document-start
// @grant        GM_registerMenuCommand
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @license      MIT
// ==/UserScript==

(function () {
  'use strict';
  const KEY = 'custom_favicon_rules_v14';

  // ---------- Rule model ----------
  // { scope: 'host' | 'domain' | 'prefix', key: string, icon: string }
  // host:   key = full hostname (e.g. 'app.site.com')
  // domain: key = registrable domain (rough heuristic: last 2 labels; allow override)
  // prefix: key = URL prefix like 'https://site.com/path/'
  function load(){ try{ return JSON.parse(GM_getValue(KEY, '[]')); }catch{ return []; } }
  function save(list){ GM_setValue(KEY, JSON.stringify(list)); }

  function getHostname(){ return location.hostname; }
  function getDomainHeuristic(host){
    const parts = host.split('.');
    if (parts.length <= 2) return host;
    // Heuristic: last 2 labels (note: .co.uk 等会不准,必要时自己改成 example.co.uk)
    return parts.slice(-2).join('.');
  }
  function getPrefix(){
    // standardize to trailing slash
    const u = new URL(location.href);
    let p = `${u.origin}${u.pathname}`;
    if (!p.endsWith('/')) p = p.replace(/[^/]+$/, '');
    return p;
  }

  function matchRule(rule){
    if (rule.scope === 'host') return getHostname() === rule.key;
    if (rule.scope === 'domain') return (getHostname() === rule.key) || getHostname().endsWith('.' + rule.key);
    if (rule.scope === 'prefix') return location.href.startsWith(rule.key);
    return false;
  }

  function bestRule(rules){
    // Priority: prefix > host > domain (更具体的优先)
    const candidates = rules.filter(matchRule);
    const score = r => r.scope === 'prefix' ? 3 : r.scope === 'host' ? 2 : 1;
    candidates.sort((a,b)=>score(b)-score(a));
    return candidates[0] || null;
  }

  // ---------- favicon ops ----------
  function isMixedContent(url){
    try{
      const u = new URL(url, location.href);
      return location.protocol === 'https:' && u.protocol === 'http:';
    }catch{ return false; }
  }
  function preflight(url, cb){
    if (/^data:/i.test(url)) return cb(true);
    if (isMixedContent(url)) return cb(false, 'Mixed content: use HTTPS icon on HTTPS pages.');
    try{
      GM_xmlhttpRequest({
        method: 'GET',
        url: new URL(url, location.href).toString(),
        timeout: 4000,
        onload: r => cb(r.status >= 200 && r.status < 400),
        onerror: () => cb(false, 'Network error'),
        ontimeout: () => cb(false, 'Timeout'),
      });
    }catch{ cb(false, 'Invalid URL'); }
  }

  function removeIcons(){
    if (!document.head) return;
    document.head.querySelectorAll(
      "link[rel='icon'], link[rel='shortcut icon'], link[rel='apple-touch-icon']"
    ).forEach(n=>n.remove());
  }
  function addIcons(href){
    if (!document.head) return;
    const add = (rel, sizes) => {
      const l = document.createElement('link');
      l.rel = rel;
      if (sizes) l.sizes = sizes;
      l.href = href;
      document.head.appendChild(l);
    };
    add('icon');
    add('shortcut icon');
    add('apple-touch-icon','180x180');
  }

  let applied = null;
  const apply = (href) => {
    if (!href) return;
    removeIcons();
    addIcons(href);
    applied = href;
  };

  // 防抖,避免 SPA 频繁替换引发循环
  let timer = null;
  const debouncedApply = href => { clearTimeout(timer); timer = setTimeout(()=>apply(href), 60); };

  function watch(href){
    if (!document.head) return;
    const mo = new MutationObserver(()=>{
      // 如果站点又塞回了自己的 icon,我们再覆盖一次
      const siteIcon = document.head.querySelector("link[rel*='icon']");
      if (siteIcon && applied !== href) debouncedApply(href);
    });
    mo.observe(document.head, { childList: true, subtree: true });
  }

  // ---------- boot ----------
  const rules = load();
  const rule = bestRule(rules);
  if (rule) {
    const start = () => preflight(rule.icon, (ok,msg)=>{
      if (!ok) { console.warn('[favicon]', msg||'preflight failed'); return; }
      apply(rule.icon);
      watch(rule.icon);
      // 监听 pushState / popstate(单页应用内部跳转)
      const _pushState = history.pushState;
      history.pushState = function(){ const r = _pushState.apply(this, arguments); debouncedApply(rule.icon); return r; };
      window.addEventListener('popstate', ()=>debouncedApply(rule.icon));
    });
    if (document.head) start(); else document.addEventListener('DOMContentLoaded', start);
  }

  // ---------- menus ----------
  function addSetMenu(scope, labelBuilder){
    GM_registerMenuCommand(labelBuilder(), ()=>{
      const val = prompt(`Icon URL for scope "${scope}"\n(HTTPS SVG/PNG/ICO, or data: URL)`);
      if (!val) return;
      const icon = val.trim();
      preflight(icon, (ok,msg)=>{
        if (!ok) { alert('❌ ' + (msg||'Not usable')); return; }
        const list = load();
        let key = '';
        if (scope==='host')   key = getHostname();
        if (scope==='domain') key = getDomainHeuristic(getHostname());
        if (scope==='prefix') key = getPrefix();

        // replace existing same-scope+key rule
        const idx = list.findIndex(r=>r.scope===scope && r.key===key);
        if (idx>=0) list[idx].icon = icon; else list.push({scope,key,icon});
        save(list);
        apply(icon);
        alert(`✅ Set favicon for ${scope}: ${key}`);
      });
    });
  }
  function addRemoveMenu(scope, labelBuilder){
    GM_registerMenuCommand(labelBuilder(), ()=>{
      const list = load();
      let key = '';
      if (scope==='host')   key = getHostname();
      if (scope==='domain') key = getDomainHeuristic(getHostname());
      if (scope==='prefix') key = getPrefix();
      const idx = list.findIndex(r=>r.scope===scope && r.key===key);
      if (idx>=0){ list.splice(idx,1); save(list); location.reload(); }
      else alert(`No rule for ${scope}: ${key}`);
    });
  }

  const host = getHostname();
  const domain = getDomainHeuristic(host);
  const prefix = getPrefix();

  addSetMenu('host',   ()=>`Set favicon for host: ${host}`);
  addSetMenu('domain', ()=>`Set favicon for domain: *.${domain}`);
  addSetMenu('prefix', ()=>`Set favicon for prefix: ${prefix}`);

  addRemoveMenu('host',   ()=>`Remove host rule: ${host}`);
  addRemoveMenu('domain', ()=>`Remove domain rule: *.${domain}`);
  addRemoveMenu('prefix', ()=>`Remove prefix rule: ${prefix}`);

  GM_registerMenuCommand('Export rules (JSON)', ()=>prompt('Copy:', JSON.stringify(load(), null, 2)));
  GM_registerMenuCommand('Import rules (JSON)', ()=>{
    const txt = prompt('Paste JSON:'); if (!txt) return;
    try{ save(JSON.parse(txt)); alert('✅ Imported. Reload to apply.'); }
    catch{ alert('❌ Invalid JSON'); }
  });
})();