您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
导入本地字体替换网页字体,支持通配符 URL 自定义排除选择器
// ==UserScript== // @name 【更换网页字体】 // @namespace https://greasyfork.org/ // @version 250928 // @description 导入本地字体替换网页字体,支持通配符 URL 自定义排除选择器 // @author Kimi & 问小白 & 小艺 // @license MIT // @run-at document-start // @match *://*/* // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_setValue // @grant GM_getValue // @grant GM_deleteValue // ==/UserScript== (() => { 'use strict'; const main = () => { /* 0. 默认排除列表 */ const DEFAULT_EXCLUDE = 'i,span:empty,p:empty,div:empty,[class*=icon],[class*=Icon],[class*=ICON],[class*=font],[class*=Font],[class*=FONT]'; /* 1. 域名-选择器 存取 */ const getExcludeRules = () => GM_getValue('excludeRules', {}); const setExcludeRules = r => GM_setValue('excludeRules', r); /* 2. 工具:通配符 → 正则 */ const ruleRegexCache = new Map(); const wildcardToRegex = pattern => { if (!ruleRegexCache.has(pattern)) { const source = pattern .replace(/[.+?^${}()|[\]\\]/g, '\\$&') .replace(/\*/g, '.*'); ruleRegexCache.set(pattern, new RegExp(`^${source}$`, 'i')); } return ruleRegexCache.get(pattern); }; /* 3. 统一返回 域名 / 完整 URL(全部小写) */ const host = () => location.hostname.toLowerCase(); const fullUrl = () => location.href.split('#')[0].toLowerCase(); /* 4. 两层匹配:1.域名精确 2.通配符匹配完整 URL */ const matchWildcard = () => { const rules = getExcludeRules(); const h = host(); if (rules[h]) return h; const url = fullUrl(); if (rules[url]) return url; for (const key in rules) { if (key.includes('*') && wildcardToRegex(key).test(url)) return key; } return null; }; /* 5. 组装 :not(...) */ const buildExcludeSelector = () => { const key = matchWildcard(); return key ? getExcludeRules()[key] : DEFAULT_EXCLUDE; }; /* 6. 字体配置 */ const defaultFont = { name: 'serif(默认字体)', fontFamily: 'serif', isDefault: true }; const fontData = GM_getValue('fontData', { fonts: [defaultFont], currentFont: defaultFont.name, isTextStroke: false, isTextShadow: true, isCompatMode: false, onlyCJK: false }); /* 7. 样式元素 */ const createStyleElement = id => { let el = document.getElementById(id); if (!el) { el = document.createElement('style'); el.id = id; document.head.appendChild(el); } return el; }; const fontFaceStyleElement = createStyleElement('font-face-style'); const commonStyleElement = createStyleElement('font-common-style'); /* 8. 字体缓存 */ const cachedFontBlobUrls = {}; /* 9. 更新通用样式 */ const updateCommonStyles = () => { const selectedFont = fontData.fonts.find(f => f.name === fontData.currentFont); if (!selectedFont) return; const excludeSel = buildExcludeSelector(); const important = fontData.isCompatMode ? '' : '!important'; const cssRules = `body *:not(${excludeSel}){ font-family:'${selectedFont.fontFamily}' ${important}; ${fontData.isTextStroke ? '-webkit-text-stroke: .5px;' : ''} ${fontData.isTextShadow ? 'text-shadow: 0 0 .2px rgba(0,0,0,.9), 1px 1px 3px rgba(0,0,0,.2);' : ''}}`; commonStyleElement.textContent = cssRules; }; /* 10. 字体加载/缓存 */ const updateFontFaces = selectedFont => { if (!selectedFont || !selectedFont.storageKey) { fontFaceStyleElement.textContent = ''; updateCommonStyles(); return; } const fontBlobUrl = cachedFontBlobUrls[selectedFont.storageKey]; if (fontBlobUrl) { fontFaceStyleElement.textContent = buildFontFaceCSS( selectedFont.fontFamily, fontBlobUrl, selectedFont.format, fontData.onlyCJK ); updateCommonStyles(); return; } const fontChunks = GM_getValue(`font_${selectedFont.storageKey}_chunks`, []); const totalChunks = GM_getValue(`font_${selectedFont.storageKey}_total`, 0); if (fontChunks.length === totalChunks) { Promise.all(fontChunks.map(i => GM_getValue(`font_${selectedFont.storageKey}_chunk_${i}`))) .then(base64Chunks => { const base64Data = base64Chunks.join(''); const blob = base64ToBlob(base64Data, selectedFont.mimeType); const url = URL.createObjectURL(blob); cachedFontBlobUrls[selectedFont.storageKey] = url; fontFaceStyleElement.textContent = buildFontFaceCSS( selectedFont.fontFamily, url, selectedFont.format, fontData.onlyCJK ); updateCommonStyles(); }); } }; const buildFontFaceCSS = (fontFamily, fontUrl, fontFormat, onlyCJK) => `@font-face { font-family: '${fontFamily}'; src: url(${fontUrl}) format('${fontFormat}'); ${onlyCJK ? 'unicode-range: U+4E00-9FFF, U+3400-4DBF, U+20000-2A6DF, U+F900-FAFF;' : ''}}`; const base64ToBlob = (base64String, mimeType) => { const byteCharacters = atob(base64String); const byteArrays = []; for (let i = 0; i < byteCharacters.length; i += 512) { const slice = byteCharacters.slice(i, i + 512); const byteNumbers = new Array(slice.length); for (let j = 0; j < slice.length; j++) byteNumbers[j] = slice.charCodeAt(j); byteArrays.push(new Uint8Array(byteNumbers)); } return new Blob(byteArrays, { type: mimeType }); }; const getFontFormat = fileName => { const ext = fileName.split('.').pop().toLowerCase(); return { ttf:'truetype', otf:'opentype', woff:'woff', woff2:'woff2' }[ext] || 'truetype'; }; /* 11. 字体设置面板 */ const createFontPanel = () => { const overlay = document.createElement('div'); overlay.style = `position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.5);z-index:99998`; const panel = document.createElement('div'); panel.style = `position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);background:white;padding:20px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,0.2);z-index:99999;min-width:300px;max-width:98vw;max-height:90vh;overflow-y:auto;`; panel.innerHTML = `<h3 style="text-align:center;">字体设置</h3><div id="font-list" style="margin:20px 0"></div>`; const listBox = panel.querySelector('#font-list'); const renderFontList = () => { listBox.innerHTML = ''; fontData.fonts.forEach(font => { const row = document.createElement('div'); row.style = 'margin:8px 0;display:flex;align-items:center;padding:4px 8px;border-radius:4px;cursor:pointer'; if (font.name === fontData.currentFont) row.style.backgroundColor = '#e0e0e0'; const dot = document.createElement('span'); dot.style = 'width:1em;text-align:center'; dot.textContent = font.name === fontData.currentFont ? '✓' : ''; const name = document.createElement('span'); name.style = 'flex-grow:1;text-align:center'; name.textContent = font.name; const delBtn = document.createElement('button'); delBtn.style = 'width:1em;border:none;background:none;color:#ff4444'; delBtn.textContent = font.isDefault ? '' : '✕'; dot.onclick = name.onclick = () => { fontData.currentFont = font.name; GM_setValue('fontData', fontData); updateFontFaces(font); renderFontList(); }; delBtn.onclick = () => { if (font.isDefault) return; if (!confirm(`确定要删除字体 "${font.name}" 吗?`)) return; fontData.fonts = fontData.fonts.filter(f => f.name !== font.name); if (fontData.currentFont === font.name) fontData.currentFont = fontData.fonts[0].name; if (font.storageKey) { const chunks = GM_getValue(`font_${font.storageKey}_chunks`, []); chunks.forEach((_, i) => GM_deleteValue(`font_${font.storageKey}_chunk_${i}`)); GM_deleteValue(`font_${font.storageKey}_chunks`); GM_deleteValue(`font_${font.storageKey}_total`); if (cachedFontBlobUrls[font.storageKey]) { URL.revokeObjectURL(cachedFontBlobUrls[font.storageKey]); delete cachedFontBlobUrls[font.storageKey]; } } GM_setValue('fontData', fontData); updateFontFaces(fontData.fonts.find(f => f.name === fontData.currentFont)); renderFontList(); }; row.append(dot, name, delBtn); listBox.appendChild(row); }); }; const createToggle = (label, key) => { const box = document.createElement('div'); box.style = 'display:flex;justify-content:center;align-items:center;margin:20px 0;cursor:pointer'; const ind = document.createElement('span'); ind.style = 'margin-right:5px'; ind.textContent = fontData[key] ? '●' : '○'; const txt = document.createElement('span'); txt.textContent = label; box.append(ind, txt); box.onclick = () => { fontData[key] = !fontData[key]; ind.textContent = fontData[key] ? '●' : '○'; GM_setValue('fontData', fontData); updateFontFaces(fontData.fonts.find(f => f.name === fontData.currentFont)); }; return box; }; panel.appendChild(createToggle('描边加粗', 'isTextStroke')); panel.appendChild(createToggle('文本阴影', 'isTextShadow')); panel.appendChild(createToggle('兼容模式', 'isCompatMode')); panel.appendChild(createToggle('仅汉字符', 'onlyCJK')); const importBtn = document.createElement('button'); importBtn.textContent = '导入本地字体'; importBtn.style = 'display:block;margin:20px auto;padding:8px 16px;background:#2196F3;color:white;border:none;border-radius:4px;cursor:pointer'; importBtn.onclick = () => { const fileInput = document.createElement('input'); fileInput.type = 'file'; fileInput.accept = '.ttf,.otf,.woff,.woff2'; fileInput.style.display = 'none'; document.body.appendChild(fileInput); fileInput.onchange = e => { const file = e.target.files[0]; if (!file) return; const originalName = file.name.replace(/\.[^/.]+$/, ''); const exist = fontData.fonts.find(f => f.originalFileName === file.name && f.fileSize === file.size); if (exist) { alert(`字体 "${originalName}" 已存在,无需重复导入。`); document.body.removeChild(fileInput); return; } let newName = originalName; let idx = 2; while (fontData.fonts.some(f => f.name === newName)) newName = `${originalName}(${idx++})`; const reader = new FileReader(); reader.onload = () => { const [, base64Data] = reader.result.split(','); const mimeType = reader.result.split(',')[0].split(':')[1].split(';')[0]; const storageKey = 'font_' + Date.now(); const chunkSize = 500000; const chunks = []; for (let i = 0; i < base64Data.length; i += chunkSize) { const chunk = base64Data.substring(i, i + chunkSize); GM_setValue(`font_${storageKey}_chunk_${chunks.length}`, chunk); chunks.push(chunks.length); } GM_setValue(`font_${storageKey}_chunks`, chunks); GM_setValue(`font_${storageKey}_total`, chunks.length); fontData.fonts.push({ name: newName, fontFamily: newName, originalFileName: file.name, mimeType, storageKey, format: getFontFormat(file.name), fileSize: file.size }); fontData.currentFont = newName; GM_setValue('fontData', fontData); updateFontFaces(fontData.fonts.at(-1)); renderFontList(); }; reader.readAsDataURL(file); fileInput.remove(); }; fileInput.click(); }; panel.appendChild(importBtn); overlay.appendChild(panel); overlay.onclick = e => { if (e.target === overlay) overlay.remove(); }; document.body.appendChild(overlay); renderFontList(); }; /* 12. 通配符规则管理 UI */ const openAllRulesPanel = () => { const rules = getExcludeRules(); const keys = Object.keys(rules); if (!keys.length) { alert('暂无自定义排除规则'); return; } const overlay = document.createElement('div'); overlay.style.cssText = ` position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:2147483647; display:flex;align-items:center;justify-content:center;`; const panel = document.createElement('div'); panel.style.cssText = ` background:white;border-radius:8px;box-shadow:0 4px 20px rgba(0,0,0,.3); width:500px;max-height:70vh;overflow:auto;padding:20px;font-family:system-ui;`; panel.innerHTML = '<h3 style="margin:0 0 15px 0;text-align:center">全部规则</h3>'; const list = document.createElement('div'); list.style.cssText = 'display:flex;flex-direction:column;gap:8px'; keys.forEach(k => { const row = document.createElement('div'); row.style.cssText = 'display:flex;justify-content:space-between;align-items:center;word-break:break-all;padding:4px 0;border-bottom:1px solid #eee'; row.innerHTML = ` <div style="flex:1"><b>${k}</b><br><small style="color:#555">${rules[k]}</small></div> <button data-edit="${k}" style="margin-left:8px;background:#2196F3;color:white;border:none;padding:4px 8px;border-radius:3px;font-size:12px">编辑</button>`; list.appendChild(row); }); panel.appendChild(list); panel.addEventListener('click', e => { if (!e.target.dataset.edit) return; const key = e.target.dataset.edit; overlay.remove(); openEditRulePanel(key); }); overlay.appendChild(panel); document.body.appendChild(overlay); overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); }; const openEditRulePanel = (key0 = '') => { const overlay = document.createElement('div'); overlay.style.cssText = ` position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:2147483647; display:flex;align-items:center;justify-content:center;`; const panel = document.createElement('div'); panel.style.cssText = ` background:white;padding:20px;border-radius:8px;box-shadow:0 2px 10px rgba(0,0,0,.2); min-width:300px;max-width:500px;`; const rules = getExcludeRules(); const currentSelector = rules[key0] || ''; panel.innerHTML = ` <h3 style="text-align:center;margin-top:0;">编辑规则</h3> <label> <div style="margin-bottom:5px;font-weight:bold">域名/URL通配:</div> <input id="domain-input" value="${key0}" style="width:100%;padding:6px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box" placeholder="example.com 或 *example.com/wiki*"> </label> <label style="display:block;margin:10px 0 15px 0"> <div style="margin-bottom:5px;font-weight:bold">排除选择器:</div> <textarea id="selector-input" rows="4" style="width:100%;resize:vertical;padding:6px;border:1px solid #ccc;border-radius:4px;box-sizing:border-box" placeholder="填写CSS选择器(如:.icon, [class*='icon'], .fa),多个用逗号分隔">${currentSelector}</textarea> </label> <div style="display:flex;justify-content:space-between"> <button id="delete-btn" style="background:#f44336;color:white;border:none;padding:6px 12px;border-radius:4px">删除</button> <div style="display:flex;gap:10px"> <button id="cancel-btn" style="background:#9e9e9e;color:white;border:none;padding:6px 12px;border-radius:4px">取消</button> <button id="save-btn" style="background:#4caf50;color:white;border:none;padding:6px 12px;border-radius:4px">保存</button> </div> </div>`; const domainInput = panel.querySelector('#domain-input'); const selectorInput = panel.querySelector('#selector-input'); const deleteBtn = panel.querySelector('#delete-btn'); const cancelBtn = panel.querySelector('#cancel-btn'); const saveBtn = panel.querySelector('#save-btn'); deleteBtn.onclick = () => { const target = domainInput.value.trim(); const r = { ...getExcludeRules() }; if (target&&r[target]&&confirm(`确定删除规则 “${target}”?`)) { delete r[target]; setExcludeRules(r); updateFontFaces(fontData.fonts.find(f => f.name === fontData.currentFont)); registerDomainMenu(); overlay.remove(); } }; cancelBtn.onclick = () => document.body.removeChild(overlay); saveBtn.onclick = () => { const dom = domainInput.value.trim(); const sel = selectorInput.value.trim(); if (!dom) { alert('通配符不能为空'); return; } const r = { ...getExcludeRules() }; if (dom !== key0) delete r[key0]; if (sel === '') { delete r[dom]; } else { r[dom] = sel; } setExcludeRules(r); updateFontFaces(fontData.fonts.find(f => f.name === fontData.currentFont)); registerDomainMenu(); overlay.remove(); }; overlay.appendChild(panel); document.body.appendChild(overlay); overlay.addEventListener('click', e => { if (e.target === overlay) overlay.remove(); }); panel.addEventListener('click', e => e.stopPropagation()); }; /* 13. 动态菜单 */ let addMenuId = null, editMenuId = null; const registerDomainMenu = () => { const key = matchWildcard(); if (addMenuId) { GM_unregisterMenuCommand(addMenuId); addMenuId = null; } if (editMenuId) { GM_unregisterMenuCommand(editMenuId); editMenuId = null; } if (key) { editMenuId = GM_registerMenuCommand('✏️ 编辑当前规则', () => openEditRulePanel(key)); } else { addMenuId = GM_registerMenuCommand('➕ 添加本域规则', () => openEditRulePanel(host())); } }; /* 14. 注册菜单 */ GM_registerMenuCommand('🎨 字体设置', createFontPanel); //GM_registerMenuCommand('⚙️ 查看配置', () => alert(JSON.stringify(fontData, null, 2))); GM_registerMenuCommand('📋 管理全部规则', openAllRulesPanel); GM_registerMenuCommand('🔄 重新加载', main); GM_registerMenuCommand('❓ 帮助', () => { alert(`📖 【更换网页字体】脚本使用指南 1️⃣ 基本使用 • 点击菜单中的"🎨 字体设置"导入/选择字体 • 导入本地字体后,页面字体会自动替换 • 在列表里点选即可实时切换 • ✓ 表示当前正在使用的字体 2️⃣ 可选开关 • 描边加粗:让笔画更粗 • 文本阴影:增强可读性 • 兼容模式:遇图标乱码可勾选解决 • 仅汉字符:只替换中文,英文保持原样 3️⃣ 排除特定元素(图标/指定区域不被替换) A. 自动排除:脚本默认规则排除常见图标、视频控件 B. 添加排除规则: ① 打开需排除的网页 ② 点击"➕ 添加本域规则" ③ 在"排除选择器"里填 CSS 选择器,如: .icon, .fa, [class*="icon"], pre, code ④ 保存后立即生效 C. 通配符规则:使用域名添加规则相对简单,亦可URL搭配通配符添加通用规则 例:*example.com/wiki* 可匹配所有包含该段的网址 4️⃣ 其他 • "📋 管理全部规则" → 查看/编辑/删除已保存的排除规则 • "🔄 重新加载" → 重载脚本。使用场景示例:小说阅读模式开启后使用 💡 提示 • 如果浏览器(如:via)本身支持换字体,不建议使用这个脚本 • 建议把.ttf格式字体转换为.woff2格式,体积更小 • 字体文件大小建议5MB以内,文件越大网页显示延迟越明显 • 导入的字体保存在浏览器本地,理论上讲,删除这个脚本可清理所有导入的字体 • 如页面出现图标乱码,临时访问可勾选“兼容模式”解决;长期访问建议添加排除规则`); }); /* 15. 初始化 */ registerDomainMenu(); updateFontFaces(fontData.fonts.find(f => f.name === fontData.currentFont)); } main(); })();