您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Safer per-domain favicon override with debounce & preflight.
// ==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'); } }); })();