// ==UserScript==
// @name URLCleaner - 通用链接净化
// @namespace You Boy
// @version 1.1
// @description 自动净化链接,移除烦人的追踪参数,让您的网络足迹更干净、隐私更安全。性能至上,静默运行,对网页零侵入。支持灵活的自定义规则,是您掌控链接、保护隐私的终极利器。
// @author You Boy
// @match *://*/*
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant GM_registerMenuCommand
// @grant GM_addValueChangeListener
// @grant unsafeWindow
// @run-at document-start
// @license MIT
// ==/UserScript==
(function () {
'use strict';
if (window.self !== window.top) {
return;
}
// --- 沙箱环境 ---
const Sandbox = {
DEFAULT_CONFIG: {
general: { params: ['spm_id_from', 'from_source', 'utm_source'] },
rules: []
},
config: null,
// 加载配置
loadConfig() {
let config = GM_getValue('ulcConfig');
if (!config || typeof config.general === 'undefined' || typeof config.rules === 'undefined') {
config = JSON.parse(JSON.stringify(this.DEFAULT_CONFIG));
}
config.general = config.general || { params: [] };
config.rules = config.rules || [];
config.rules.forEach(rule => {
if (typeof rule.match === 'string') {
rule.match = [rule.match];
}
});
this.config = config;
},
// 初始化事件和菜单命令
init() {
// 监听来自注入代码的保存请求
window.addEventListener('ulc-save-config', (event) => {
GM_setValue('ulcConfig', event.detail);
});
// 注册(不可用)油猴菜单
GM_registerMenuCommand('设置', () => {
window.dispatchEvent(new CustomEvent('ulc-open-settings'));
});
// 监听存储变化
GM_addValueChangeListener('ulcConfig', (name, old_value, new_value, remote) => {
if (remote) {
this.config = new_value;
// 通知更新
window.dispatchEvent(new CustomEvent('ulc-config-updated', {
detail: new_value
}));
}
});
}
};
const StyleInjector = {
inject() {
GM_addStyle(`
#ulc-settings-panel { all: initial; display: none; position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2147483647; width: 90vw; min-width: 600px; max-width: 800px; height: 500px; max-height: 80vh; background: #fff; border-radius: 8px; box-shadow: 0 8px 20px rgba(0,0,0,0.2); display: flex; flex-direction: row; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; color: #333; font-size: 14px; }
#ulc-settings-panel *, #ulc-settings-panel *::before, #ulc-settings-panel *::after { box-sizing: border-box; margin: 0; padding: 0; border: 0; font: inherit; vertical-align: baseline; background: transparent; color: inherit; text-align: left; line-height: 1.5; }
#ulc-settings-panel div, #ulc-settings-panel span, #ulc-settings-panel ul, #ulc-settings-panel li, #ulc-settings-panel label { all: unset; box-sizing: border-box; }
#ulc-settings-panel h3 { all: unset; box-sizing: border-box; display: block; font-size: 16px; font-weight: 600; }
#ulc-settings-panel button { all: unset; box-sizing: border-box; display: inline-block; text-align: center; cursor: pointer; border-radius: 4px; padding: 8px 15px; font-size: 14px; transition: background-color 0.2s, color 0.2s; line-height: 1; white-space: nowrap; }
#ulc-settings-panel input, #ulc-settings-panel textarea { all: unset; box-sizing: border-box; display: block; width: 100%; border: 1px solid #ccc; border-radius: 4px; padding: 10px; font-size: 14px; margin-bottom: 15px; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; background-color: #fff; line-height: 1.4; }
#ulc-settings-panel input[type="checkbox"] { all: unset; box-sizing: border-box; appearance: none; -webkit-appearance: none; display: inline-block; width: 16px; height: 16px; border: 1px solid #ccc; border-radius: 4px; margin-right: 8px; vertical-align: middle; position: relative; cursor: pointer; flex-shrink: 0; }
#ulc-settings-panel textarea { min-height: 80px; resize: vertical; }
#ulc-settings-panel textarea::placeholder { white-space: pre-wrap; word-wrap: break-word; }
#ulc-settings-panel code { width: initial; height: initial; display: initial; font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; }
#ulc-settings-panel button.ulc-btn-primary { background-color: #00a1d6; color: #fff; padding-block: 12px; }
#ulc-settings-panel button.ulc-btn-primary:hover { background-color: #00b5e5; }
#ulc-settings-panel button.ulc-btn-secondary { background-color: #fff; color: #767676; border: 1px solid #e3e3e3; }
#ulc-settings-panel button.ulc-btn-secondary:hover { background-color: #e0e0e0; }
#ulc-settings-panel button.ulc-btn-danger { border: 1px solid #ff4d4d; color: #ff4d4d; }
#ulc-settings-panel button.ulc-btn-danger:hover { background-color: #ff4d4d; color: white; }
#ulc-settings-panel .ulc-sidebar { display:flex; width: 180px; border-right: 1px solid #eee; flex-shrink: 0; flex-direction: column; }
#ulc-settings-panel .ulc-search-container { padding: 10px 15px 0; }
#ulc-settings-panel #ulc-rule-search { all: unset; box-sizing: border-box; width: 100%; border: 1px solid #ddd; border-radius: 4px; padding: 6px 10px; font-size: 13px; background-color: #fff; }
#ulc-settings-panel .ulc-tabs { display: block; list-style: none; padding: 13px 0 10px; flex-grow: 1; overflow-y: auto; }
#ulc-settings-panel .ulc-tab { position: relative; display: flex; align-items: center; height: 40px; padding: 0 18px; cursor: pointer; contain: strict; content-visibility: auto; contain-intrinsic-size: auto 40px; min-width: 0; }
#ulc-settings-panel .ulc-tab::before { content: ''; position: absolute; left: 0; top: 50%; transform: translateY(-50%); width: 3px; height: 50%; background-color: transparent; transition: background-color 0.2s; }
#ulc-settings-panel .ulc-tab:hover { background: #f5f5f5; }
#ulc-settings-panel .ulc-tab.active { font-weight: 600; color: #00a1d6; }
#ulc-settings-panel .ulc-tab.active::before { background-color: #00a1d6; }
#ulc-settings-panel .ulc-tab > span { display: block; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#ulc-settings-panel #ulc-add-rule-btn { display: block; text-align: center; padding: 12px; cursor: pointer; background: #fafafa; border-top: 1px solid #eee; color: #333; font-size: 14px; flex-shrink: 0; }
#ulc-settings-panel #ulc-add-rule-btn::after { content: ' 新增规则'; }
#ulc-settings-panel #ulc-add-rule-btn:hover { background: #f0f0f0; }
#ulc-settings-panel .ulc-main-content { display:flex; flex-grow: 1; flex-direction: column; overflow: hidden; }
#ulc-settings-panel .ulc-header { display: flex; justify-content: space-between; align-items: center; padding: 8px 15px; min-height: 55px; border-bottom: 1px solid #eee; flex-shrink: 0; }
#ulc-settings-panel .ulc-title-container { display: flex; align-items: center; flex-grow: 1; max-width: 80%; }
#ulc-settings-panel .ulc-title-container > h3 { max-width: 80%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
#ulc-settings-panel .ulc-edit-icon { display: none; cursor: pointer; margin-left: 8px; width: 16px; height: 16px; vertical-align: middle; }
#ulc-settings-panel .ulc-title-container:hover .ulc-edit-icon { display: inline-block; }
#ulc-settings-panel #ulc-close-btn { font-size: 24px; cursor: pointer; color: #999; padding: 5px; line-height: 1; flex-shrink: 0; }
#ulc-settings-panel #ulc-close-btn:hover { color: #333; }
#ulc-settings-panel .ulc-sub-header { display: flex; align-items: flex-start; justify-content: flex-start; padding: 8px 15px; background: #f9f9f9; border-bottom: 1px solid #eee; font-size: 12px; color: #666; flex-shrink: 0; }
#ulc-settings-panel .ulc-sub-header > span { display: inline; flex-shrink: 0; margin-right: 8px; line-height: 22px; }
#ulc-settings-panel .ulc-match-tags { display: flex; flex-wrap: wrap; gap: 6px; max-height: 26px; overflow: hidden; transition: max-height 0.3s ease; flex-grow: 1; }
#ulc-settings-panel .ulc-match-tags:hover { max-height: 200px; }
#ulc-settings-panel .ulc-match-tags code { display: inline; background: #e9e9e9; color: #c7254e; padding: 2px 6px; border-radius: 4px; font-size: 12px; white-space: nowrap; }
#ulc-settings-panel .ulc-add { display: flex; align-items: center; padding: 10px 15px; border-bottom: 1px solid #eee; flex-shrink: 0; }
#ulc-settings-panel #ulc-new-param { margin-right: 10px; padding: 8px; margin-bottom: 0; }
#ulc-settings-panel .ulc-list { display: flex; padding: 10px; overflow-y: auto; flex-grow: 1; flex-wrap: wrap; align-content: flex-start; }
#ulc-settings-panel .ulc-list:empty::before { content: "未添加参数"; display: block; width: 100%; text-align: center; color: #999; font-size: 14px; padding: 20px; }
#ulc-settings-panel .ulc-list-transform { position: relative; max-height: 100px; }
#ulc-settings-panel .ulc-list-transform::before { content:"跳转参数"; display: inline-block; background: #FFF; position: absolute; top: -10px; left: 10px; padding: 0 5px; font-size: 12px; color: #999; }
#ulc-settings-panel .ulc-list-transform .ulc-list-transform-content { display: flex; flex-wrap: wrap; gap: 8px; padding: 10px; border-top: 1px solid #eee; flex-shrink: 0; overflow-y: auto; height: 100%; }
#ulc-settings-panel .ulc-list-transform .ulc-list-transform-content > span { display: inline-block; background: #fceeee; color: #333; padding: 3px 6px; border-radius: 3px; margin: 0; font-size: 14px; }
#ulc-settings-panel .ulc-param { display: inline-flex; align-items: center; background: #eef0f2; color: #333; padding: 5px 10px; border-radius: 6px; margin: 5px; font-size: 14px; }
#ulc-settings-panel .ulc-param span { display: inline; margin-right: 8px; }
#ulc-settings-panel .ulc-delete { color: #999; cursor: pointer; font-weight: bold; font-size: 16px; line-height: 1; padding: 4px 8px; margin: -4px -8px; border-radius: 6px; }
#ulc-settings-panel .ulc-delete:hover { color: #ff4d4d; }
#ulc-settings-panel .ulc-rule-settings-footer { display: flex; justify-content: space-between; align-items: center; padding: 15px; border-top: 1px solid #eee; font-size: 13px; color: #555; flex-shrink: 0; }
#ulc-settings-panel .ulc-rule-settings-footer label { display: flex; align-items: center; cursor: pointer; }
#ulc-settings-panel .ulc-rule-settings-footer input[type="checkbox"]:checked { background-color: #00a1d6; border-color: #00a1d6; }
#ulc-settings-panel .ulc-rule-settings-footer input[type="checkbox"]:checked::after { all: unset; box-sizing: border-box; content: ''; display: block; width: 5px; height: 9px; border: solid white; border-width: 0 2px 2px 0; transform: rotate(45deg); position: absolute; left: 5px; top: 2px; }
#ulc-settings-panel .ulc-rule-settings-footer #ulc-config-text-btn { border-style: dashed; }
#ulc-settings-panel .ulc-form-content { display: block; padding: 8px; flex-grow: 1; overflow-y: auto; }
#ulc-settings-panel .ulc-form-content label { display: block; margin-bottom: 8px; font-weight: 500; margin-top: 3em; }
#ulc-settings-panel .ulc-form-content label:first-child { margin-top: 0; }
#ulc-settings-panel .ulc-form-content p { font-size: 12px; display: block; color: #999; }
#ulc-settings-panel .ulc-form-actions { display: flex; padding: 15px; border-top: 1px solid #eee; justify-content: flex-end; gap: 10px; flex-shrink: 0; }
#ulc-settings-panel .ulc-form-hint { display: block; font-size: 12px; color: #666; margin-top: -5px; margin-bottom: 15px; }
#ulc-settings-panel .ulc-hint-title { display: block; font-weight: bold; margin-top: 8px; }
#ulc-settings-panel .ulc-hint-line { display: flex; align-items: center; margin-top: 4px; }
#ulc-settings-panel .ulc-hint-line code { display: inline-block; flex-shrink: 0; background: #f5f5f5; color: #c7254e; padding: 2px 6px; border-radius: 4px; font-size: 12px; }
#ulc-settings-panel .ulc-hint-line span { display: inline; margin-left: 8px; }
#ulc-settings-panel #ulc-config-textarea { height: 100%; min-height: 100px; resize: vertical; margin-bottom: 0; }
#ulc-settings-panel #ulc-toast { all: initial; box-sizing: border-box; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; position: absolute; top: 60px; left: calc( 50% + 90px ); transform: translateX(-50%); background-color: rgba(0, 0, 0, 0.75); color: white; padding: 10px 20px; border-radius: 20px; font-size: 14px; z-index: 10; opacity: 0; visibility: hidden; transition: opacity 0.3s, visibility 0.3s; white-space: pre-wrap; line-height: 1.4; display: inline-block; max-width: 560px; }
#ulc-settings-panel #ulc-toast.show { opacity: 1; visibility: visible; }
@media (max-width: 600px) {
#ulc-settings-panel { width: 100vw; height: 100vh; max-height: 100vh; min-width: 0; border-radius: 0; flex-direction: column; }
#ulc-settings-panel .ulc-sidebar { width: 100%; height: auto; flex-direction: row; flex-wrap: wrap; align-items: center; border-right: 0; border-bottom: 1px solid #eee; flex-shrink: 0; padding: 12px 12px 0; gap: 10px; }
#ulc-settings-panel .ulc-search-container { order: 1; flex-grow: 1; padding: 0; }
#ulc-settings-panel #ulc-rule-search { font-size: 15px; padding: 10px 12px; }
#ulc-settings-panel #ulc-add-rule-btn { order: 2; flex-shrink: 0; padding: 0; margin: 0; border: 1px solid #ddd; font-size: 0; line-height: 1; background: #fff; position: relative; display: flex; justify-content: center; align-items: center; height: 39px; width: 39px; }
#ulc-settings-panel #ulc-add-rule-btn::after { font-size: 20px; content: '+'; }
#ulc-settings-panel .ulc-tabs { order: 3; flex-basis: 100%; height: auto; display: flex; flex-direction: row; overflow-x: auto; white-space: nowrap; padding: 0; margin-top: 10px; border-top: 1px solid #f0f0f0; }
#ulc-settings-panel .ulc-tab { display: flex; justify-content: center; align-items: center; width: 90px; height: 40px; padding: 0 10px; border-left: 0; border-bottom: 3px solid transparent; font-size: 15px; flex-shrink: 0; contain: strict; content-visibility: auto; contain-intrinsic-size: 90px 40px; min-width: 0; }
#ulc-settings-panel .ulc-tab::before { display: none; }
#ulc-settings-panel .ulc-tab.active { border-bottom-color: #00a1d6; color: #00a1d6; background-color: transparent; }
#ulc-settings-panel .ulc-param { padding: 8px 12px; font-size: 15px; }
#ulc-settings-panel .ulc-delete { padding: 8px; }
#ulc-settings-panel .ulc-edit-icon { display:inline-block; }
#ulc-settings-panel #ulc-toast { left: 50%; }
}
`);
}
};
const CodeInjector = {
injectedCode: function (sandboxConfig, sandboxDefaultConfig) {
(() => {
// 发送“心跳”信号
window.dispatchEvent(new CustomEvent('ulc-injection-success'));
const isFallbackMode = !!sandboxConfig;
const GENERAL_TAB_ID = 'general';
const IS_DEBUG = false;
const Logger = {
_styles: {
brand: 'background: #00a1d6; color: white; border-radius: 3px; padding: 2px 6px;',
tagBase: 'color: white; border-radius: 3px; padding: 1px 5px; font-size: 0.8em; margin-left: 4px;',
get INFO() { return `background: #3498db; ${this.tagBase}`; },
get WARN() { return `background: #f39c12; ${this.tagBase}`; },
get ERROR() { return `background: #e74c3c; ${this.tagBase}`; },
get GROUP() { return `background: #95a5a6; ${this.tagBase}`; }, // 新增GROUP样式
title: 'font-weight: bold;',
},
_createLog(type, isGrouped, ...args) {
if (!IS_DEBUG) return;
const brand = '%cURLCleaner';
const tag = `%c${type.toUpperCase()}`;
const brandStyle = this._styles.brand;
const tagStyle = this._styles[type];
if (!isGrouped) {
console.log(brand + tag, brandStyle, tagStyle, ...args);
} else {
const [title, ...content] = args;
const titleStyle = `${this._styles.title} color: ${tagStyle.match(/background: (#\w+);/)[1] || 'inherit'};`;
console.groupCollapsed(brand + tag + `%c ${title}`, brandStyle, tagStyle, titleStyle);
if (content.length > 0) {
const consoleMethod = type === 'INFO' ? 'log' : type.toLowerCase();
content.forEach(item => console[consoleMethod](item));
}
console.groupEnd();
}
},
log(...args) { this._createLog('INFO', false, ...args); },
warn(title, ...content) { this._createLog('WARN', true, title, ...content); },
error(title, ...content) { this._createLog('ERROR', true, title, ...content); },
group(title) {
if (IS_DEBUG) {
console.groupCollapsed(
`%cURLCleaner%cGROUP%c ${title}`,
this._styles.brand,
this._styles.GROUP,
this._styles.title
);
}
},
groupEnd() { if (IS_DEBUG) { console.groupEnd(); } },
info(...args) { if (IS_DEBUG) { console.log(...args); } }
};
if (isFallbackMode) {
Logger.log('Fallback mode activated due to CSP.');
} else {
Logger.log('Script injected and running in standard mode.');
}
// --- Utils (工具函数) ---
const Utils = {
debounce(func, delay = 250) {
let timeoutId;
return function (...args) {
clearTimeout(timeoutId);
timeoutId = setTimeout(() => {
func.apply(this, args);
}, delay);
};
},
// 字符串转为正则表达式对象
wildcardToRegex(pattern) {
try {
if (pattern.startsWith('re:')) {
return new RegExp(pattern.substring(3));
}
let protocol = '*';
let host = pattern;
let path = '/*';
if (host.includes('://')) {
const parts = host.split('://');
protocol = parts[0];
host = parts[1];
}
if (host.includes('/')) {
const hostParts = host.split('/');
host = hostParts.shift();
path = '/' + hostParts.join('/');
if (!path.endsWith('*')) {
path += '*';
}
}
const protocolRegex = protocol.replace(/\*/g, 'https?');
const hostRegex = host.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^/]*');
const pathRegex = path.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
const finalRegexString = `^${protocolRegex}://${hostRegex}${pathRegex}$`;
return new RegExp(finalRegexString);
} catch (e) {
Logger.warn(`Invalid regex pattern provided, falling back to non-matching pattern.`, { pattern, error: e.message });
return new RegExp('$.');
}
},
// 严格检查是否是有效的URL
isValidAbsoluteURL(str) {
if (typeof str !== 'string' || str.trim() === '') return false;
try {
const url = new URL(str);
return ['http:', 'https:', 'ftp:', 'ftps:'].includes(url.protocol);
} catch (e) { return false; }
},
// 宽松地尝试解析URL
tryParseURL(str) {
if (typeof str !== 'string' || str.trim() === '') return null;
try {
if (str.includes('://') || str.startsWith('/') || str.startsWith('?') || str.startsWith('#')) {
return new URL(str, window.location.href);
}
return null;
} catch (e) {
return null;
}
},
// 尝试所有解码方式
tryAllDecodes(value) {
if (!value) return null;
// 解码函数
const decoders = [
(val) => atob(val),
(val) => decodeURIComponent(val),
(val) => decodeURIComponent(decodeURIComponent(val)),
];
const applyDecoders = (input) => {
if (Utils.isValidAbsoluteURL(input)) return input;
for (const decoder of decoders) {
try {
const decoded = decoder(input);
if (decoded && Utils.isValidAbsoluteURL(decoded)) {
return decoded;
}
} catch (error) { /* Silently ignore decoding errors */ }
}
return null;
};
const variants = [
value, // 原始值
value.split('').reverse().join(''), // 反转字符串
];
for (const variant of variants) {
const decoded = applyDecoders(variant);
if (decoded) return decoded;
}
return null;
},
// 从奇怪的参数中提取URL
extractUrlFromWeirdParam(input) {
try {
const url = input instanceof URL ? input : new URL(input);
const [key] = url.searchParams.entries().next().value || [];
if (url.searchParams.size === 1 && key && !url.searchParams.get(key)) {
const decoded = Utils.tryAllDecodes(key);
if (decoded) return decoded;
}
} catch (_) { /* Silently ignore parsing errors */ }
return null;
},
// 生成规则的唯一ID
getRuleTabId(ruleOrName) {
if (typeof ruleOrName === 'string' && ruleOrName === GENERAL_TAB_ID) {
return GENERAL_TAB_ID;
}
const name = (typeof ruleOrName === 'object' && ruleOrName.name) ? ruleOrName.name : ruleOrName;
if (name === GENERAL_TAB_ID || typeof name !== 'string' || name.trim() === '') {
return GENERAL_TAB_ID;
}
try {
return `rule-${btoa(encodeURIComponent(name))}`;
} catch (e) {
Logger.error(`Failed to generate ID for rule name: ${name}`, e);
return `rule-error-${Date.now()}`;
}
},
isValidHttpLink(linkElement) {
if (!linkElement || linkElement.tagName !== 'A') return false;
const hrefAttr = linkElement.getAttribute('href');
if (!hrefAttr || hrefAttr.trim().startsWith('#') || hrefAttr.trim().startsWith('javascript:')) return false;
try {
const url = new URL(linkElement.href);
return ['http:', 'https:'].includes(url.protocol);
} catch (error) { return false; }
},
randomString() {
const length = Math.floor(Math.random() * 7) + 6;
let result = '';
while (result.length < length) result += Math.random().toString(36).substring(2);
result = result.substring(0, length);
if (/^[0-9]/.test(result)) result = 'p' + result.substring(1);
return result;
},
// 验证配置文件合法性
validateConfigObject(config) {
if (typeof config !== 'object' || config === null) return "配置必须是一个对象。";
if (typeof config.general !== 'object' || config.general === null) return `配置缺少 '${GENERAL_TAB_ID}' 对象。`;
if (!Array.isArray(config.general.params)) return "'general.params' 必须是一个数组。";
if (config.general.params.some(p => typeof p !== 'string')) return "'general.params' 数组中包含了非字符串元素。";
if (!Array.isArray(config.rules)) return "配置缺少 'rules' 数组。";
const ruleNames = new Set();
for (let i = 0; i < config.rules.length; i++) {
const rule = config.rules[i];
if (typeof rule !== 'object' || rule === null) return `规则 #${i + 1} 不是一个有效的对象。`;
if (typeof rule.name !== 'string' || !rule.name.trim()) return `规则 #${i + 1} 缺少有效的 'name' 属性。`;
// 检查规则名称是否重复
const ruleName = rule.name.trim().toLowerCase();
if (ruleNames.has(ruleName)) {
return `配置中存在重复的规则名称: "${rule.name}"`;
}
ruleNames.add(ruleName);
if (typeof rule.match === 'string') {
rule.match = [rule.match];
}
if (!Array.isArray(rule.match) || rule.match.length === 0) return `规则 "${rule.name}" 缺少有效的 'match' 数组。`;
if (rule.match.some(m => typeof m !== 'string' || !m.trim())) return `规则 "${rule.name}" 的 'match' 数组中包含无效或空元素。`;
if (rule.params && !Array.isArray(rule.params)) return `规则 "${rule.name}" 的 'params' 必须是数组。`;
if (rule.params && rule.params.some(p => typeof p !== 'string')) return `规则 "${rule.name}" 的 'params' 数组中包含非字符串元素。`;
if (rule.transform && !Array.isArray(rule.transform)) return `规则 "${rule.name}" 的 'transform' 必须是数组。`;
if (rule.transform && rule.transform.some(t => typeof t !== 'string')) return `规则 "${rule.name}" 的 'transform' 数组中包含非字符串元素。`;
}
return null;
},
};
// --- State (状态管理) ---
const State = {
config: null,
DEFAULT_CONFIG: null,
paramsToRemove: new Set(),
transformKeysToUse: new Set(),
cleanedAttrName: '',
invalidAttrName: '',
ui: {
activeTab: GENERAL_TAB_ID,
activeRuleIndex: -1,
view: 'list', // 'list', 'add', 'edit', 'config-text'
searchQuery: ''
},
dom: {
settingsPanel: null,
sidebarContainer: null,
mainContentContainer: null,
},
toastTimer: null,
init(config, defaultConfig) {
this.config = config;
this.DEFAULT_CONFIG = defaultConfig;
},
};
// --- Core (核心净化与转换逻辑) ---
const Core = {
saveConfig() {
window.dispatchEvent(new CustomEvent('ulc-save-config', { detail: State.config }));
this.setActiveParameters();
},
setActiveParameters() {
const matchingRules = [];
for (const rule of State.config.rules) {
for (const match of rule.match) {
if (Utils.wildcardToRegex(match).test(window.location.href)) {
matchingRules.push(rule);
break;
}
}
}
const params = new Set();
const transforms = new Set();
if (matchingRules.length > 0) {
let shouldApplyGeneral = false;
matchingRules.forEach(rule => {
(rule.params || []).forEach(p => params.add(p));
if (Array.isArray(rule.transform)) {
rule.transform.forEach(t => transforms.add(t));
}
if (rule.applyGeneral) {
shouldApplyGeneral = true;
}
});
if (shouldApplyGeneral) {
(State.config.general.params || []).forEach(p => params.add(p));
}
} else {
(State.config.general.params || []).forEach(p => params.add(p));
}
State.paramsToRemove = params;
State.transformKeysToUse = transforms;
Logger.group('Active Parameters Updated', true);
Logger.info('URL:', window.location.href);
Logger.info('Matching rules found:', matchingRules.map(r => r.name));
Logger.info('Params to remove:', [...State.paramsToRemove]);
Logger.info('Transform keys to use:', [...State.transformKeysToUse]);
Logger.groupEnd();
},
cleanUrl(urlString, recursionDepth = 0) {
// 增加熔断机制防止无限递归
const MAX_RECURSION_DEPTH = 3;
if (recursionDepth > MAX_RECURSION_DEPTH) {
return urlString;
}
if (!urlString || typeof urlString !== 'string') return urlString;
const originalUrlString = urlString;
const isOriginalRelative = !/^(https?:)?\/\//.test(originalUrlString);
let currentUrl;
try {
currentUrl = new URL(originalUrlString, window.location.href);
} catch (e) { return originalUrlString; }
// --- 1:链接转换 ---
if (State.transformKeysToUse.size > 0) {
if (currentUrl.searchParams.size === 1) {
const weirdUrl = Utils.extractUrlFromWeirdParam(currentUrl.href);
if (weirdUrl) return weirdUrl;
}
for (const key of currentUrl.searchParams.keys()) {
if (State.transformKeysToUse.has(key)) {
const value = currentUrl.searchParams.get(key);
const transformedUrl = Utils.tryAllDecodes(value);
if (transformedUrl) {
return this.cleanUrl(transformedUrl, recursionDepth + 1);
}
}
}
}
// --- 2:参数净化 ---
const finalUrlObject = new URL(currentUrl.href);
let modified = false;
// 创建一个临时的Set来检查,避免重复遍历
const paramsToCheck = new Set(finalUrlObject.searchParams.keys());
if (paramsToCheck.size > 0) {
for (const param of State.paramsToRemove) {
if (paramsToCheck.has(param)) {
finalUrlObject.searchParams.delete(param);
modified = true;
}
}
}
if (!modified) return originalUrlString;
if (isOriginalRelative) {
return finalUrlObject.pathname + finalUrlObject.search + finalUrlObject.hash;
} else {
return finalUrlObject.href;
}
},
};
// --- UI (界面渲染) ---
const UI = {
setSafelyInnerHTML(element, htmlString) {
if (window.trustedTypes && window.trustedTypes.createPolicy) {
try {
const policy = window.trustedTypes.createPolicy('safe-html-setter#3', { createHTML: string => string });
element.innerHTML = policy.createHTML(htmlString);
} catch (e) { element.innerHTML = htmlString; }
} else { element.innerHTML = htmlString; }
},
showToast(message, duration = 2000) {
const toast = document.getElementById('ulc-toast');
if (!toast) return;
toast.textContent = message;
toast.classList.add('show');
if (State.toastTimer) clearTimeout(State.toastTimer);
State.toastTimer = setTimeout(() => {
toast.classList.remove('show');
State.toastTimer = null;
}, duration);
},
createSettingsPanel() {
if (State.dom.settingsPanel) return;
const panel = document.createElement('div');
panel.id = 'ulc-settings-panel';
this.setSafelyInnerHTML(panel, `
<div class="ulc-sidebar"></div>
<div class="ulc-main-content"></div>
<div id="ulc-toast"></div>
`);
document.body.appendChild(panel);
State.dom.settingsPanel = panel;
State.dom.sidebarContainer = panel.querySelector('.ulc-sidebar');
State.dom.mainContentContainer = panel.querySelector('.ulc-main-content');
panel.addEventListener('click', Events.handlePanelClick);
panel.addEventListener('keydown', e => {
if (e.key === 'Enter' && e.target.id === 'ulc-new-param') Events.addParamsFromInput();
});
},
renderPanel() {
if (!State.dom.settingsPanel) return;
this.renderSidebar();
this.renderMainContent();
const input = document.getElementById('ulc-new-param') || document.getElementById('ulc-rule-name');
if (input) input.focus();
},
updateRuleList() {
const tabsContainer = State.dom.sidebarContainer.querySelector('.ulc-tabs');
if (!tabsContainer) return;
const searchQuery = (State.ui.searchQuery || '').toLowerCase();
const fragment = document.createDocumentFragment();
const generalTab = document.createElement('li');
generalTab.className = 'ulc-tab';
generalTab.dataset.tabId = GENERAL_TAB_ID;
generalTab.dataset.ruleIndex = '-1';
generalTab.textContent = '通用规则';
if (State.ui.activeTab === GENERAL_TAB_ID) {
generalTab.classList.add('active');
}
fragment.appendChild(generalTab);
State.config.rules.forEach((rule, index) => {
const li = document.createElement('li');
li.className = 'ulc-tab';
li.dataset.tabId = Utils.getRuleTabId(rule);
li.dataset.ruleIndex = index.toString();
li.title = `${rule.name}\n${rule.match.join('\n')}`;
const textSpan = document.createElement('span');
textSpan.textContent = rule.name;
li.appendChild(textSpan);
const isVisible = searchQuery ? rule.name.toLowerCase().includes(searchQuery) : true;
if (!isVisible) {
li.style.display = 'none';
}
if (State.ui.activeTab === Utils.getRuleTabId(rule)) {
li.classList.add('active');
}
fragment.appendChild(li);
});
tabsContainer.innerHTML = '';
tabsContainer.appendChild(fragment);
const activeTabEl = tabsContainer.querySelector('.ulc-tab.active');
if (activeTabEl) {
requestAnimationFrame(() => activeTabEl.scrollIntoView({ block: 'nearest', behavior: 'auto' }));
}
},
renderSidebar() {
if (!State.dom.sidebarContainer) return;
const sidebarHtml = `
<div class="ulc-search-container">
<input type="search" id="ulc-rule-search" placeholder="搜索规则">
</div>
<ul class="ulc-tabs"></ul>
<div id="ulc-add-rule-btn">+</div>
`;
this.setSafelyInnerHTML(State.dom.sidebarContainer, sidebarHtml);
// 绑定事件到稳定的搜索框
const searchInput = State.dom.sidebarContainer.querySelector('#ulc-rule-search');
if (searchInput) {
searchInput.value = State.ui.searchQuery || '';
if (Events.onSearchInputDebounced) {
searchInput.addEventListener('input', Events.onSearchInputDebounced);
}
const isMobile = window.innerWidth <= 600;
if (!isMobile && document.activeElement !== searchInput) {
searchInput.focus();
searchInput.selectionStart = searchInput.selectionEnd = searchInput.value.length;
}
}
// 列表渲染
this.updateRuleList();
},
renderMainContent() {
if (!State.dom.mainContentContainer) return;
let contentHtml = '';
if (State.ui.view === 'list') contentHtml = this.renderRuleDetails();
else if (State.ui.view === 'add' || State.ui.view === 'edit') contentHtml = this.renderRuleForm();
else if (State.ui.view === 'config-text') contentHtml = this.renderConfigTextForm();
this.setSafelyInnerHTML(State.dom.mainContentContainer, contentHtml);
const input = document.getElementById('ulc-new-param') || document.getElementById('ulc-rule-name') || document.getElementById('ulc-config-textarea');
if (input) input.focus();
},
renderRuleDetails() {
const isGeneral = State.ui.activeTab === GENERAL_TAB_ID;
const rule = !isGeneral ? State.config.rules[State.ui.activeRuleIndex] : null;
if (!isGeneral && !rule) {
State.ui.activeTab = GENERAL_TAB_ID;
State.ui.activeRuleIndex = -1;
return this.renderRuleDetails();
}
const params = isGeneral ? State.config.general.params : (rule.params || []);
const transform = isGeneral ? [] : (rule.transform || []);
const title = isGeneral ? '通用参数列表' : rule.name;
const editIcon = !isGeneral ? `<svg class="ulc-edit-icon" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path fill="currentColor" d="M832 512a32 32 0 1 1 64 0v352a96 96 0 0 1-96 96H160a96 96 0 0 1-96-96V160a96 96 0 0 1 96-96h352a32 32 0 0 1 0 64H160a32 32 0 0 0-32 32v704a32 32 0 0 0 32 32h704a32 32 0 0 0 32-32V512zm-101.056-405.504a32 32 0 0 1 45.248 0L904.96 235.264a32 32 0 0 1 0 45.248L583.424 601.984a32 32 0 0 1-18.112 9.088L400 640l28.928-165.312a32 32 0 0 1 9.088-18.112l321.536-321.536zM855.04 256l-45.248-45.248L704.96 315.648l45.248 45.248L855.04 256zm-45.248 45.248L588.224 522.816 542.976 477.568 764.544 256l45.248 45.248z"/></svg>` : '';
const subHeader = !isGeneral ? `<div class="ulc-sub-header"><span>匹配地址:</span><div class="ulc-match-tags">${rule.match.map(m => `<code>${m}</code>`).join('')}</div></div>` : '';
const footerHtml = isGeneral ? `
<div class="ulc-rule-settings-footer">
<div><button id="ulc-config-text-btn" class="ulc-btn-secondary">配置文本</button></div>
<button id="ulc-reset-btn" class="ulc-btn-secondary">重置为默认</button>
</div>` : `
<div class="ulc-rule-settings-footer">
<label><input type="checkbox" id="ulc-apply-general" ${rule.applyGeneral ? 'checked' : ''}><span>应用通用规则</span></label>
<button id="ulc-delete-rule-btn" class="ulc-btn-danger">删除此规则</button>
</div>`;
return `
<div class="ulc-header"><div class="ulc-title-container"><h3 title="${title}">${title}</h3>${editIcon}</div><button id="ulc-close-btn">×</button></div>
${subHeader}
<div class="ulc-add"><input type="text" id="ulc-new-param" placeholder="输入参数,可英文逗号分隔批量添加,或输入一个链接自动提取"/><button id="ulc-add-btn" class="ulc-btn-primary">添加</button></div>
<div class="ulc-list">${[...params].sort().map(p => `<div class="ulc-param"><span>${p}</span><div class="ulc-delete" data-param="${p}">×</div></div>`).join('')}</div>
${transform.length > 0 ? `<div class="ulc-list-transform"><div class="ulc-list-transform-content">${transform.map(t => `<span>${t}</span>`).join('')}</div></div>` : ''}
${footerHtml}`;
},
renderRuleForm() {
const isEdit = State.ui.view === 'edit';
const rule = isEdit ? State.config.rules[State.ui.activeRuleIndex] : null;
const title = isEdit ? '编辑净化规则' : '新增净化规则';
let ruleName = '', matchPatterns = '', transformKeys = '';
if (isEdit && rule) {
ruleName = rule.name;
matchPatterns = rule.match.join('\n');
transformKeys = Array.isArray(rule.transform) ? rule.transform.join('\n') : '';
} else {
try {
const hostname = window.location.hostname;
if (hostname && hostname !== 'localhost') {
const parts = hostname.split('.').filter(p => p);
ruleName = parts.length > 1 ? parts.slice(-2).join('.') : hostname;
matchPatterns = hostname;
}
} catch (e) { console.error("Could not get domain", e); }
}
return `
<div class="ulc-main-content">
<div class="ulc-header"><h3>${title}</h3></div>
<div class="ulc-form-content">
<label for="ulc-rule-name">规则名称</label>
<input type="text" id="ulc-rule-name" placeholder="规则名称" maxlength="30" value="${ruleName}">
<label for="ulc-rule-match">匹配地址 (每行一个)</label>
<textarea id="ulc-rule-match" placeholder="www.example.com\n*example.com\nhttps://www.youtube.com/watch*">${matchPatterns}</textarea>
<div class="ulc-form-hint">
<b class="ulc-hint-title">常用示例:</b>
<div class="ulc-hint-line"><code>www.example.com</code><span>仅匹配指定子域名 (推荐)</span></div>
<div class="ulc-hint-line"><code>*example.com</code><span>匹配主域名及其所有子域名</span></div>
<b class="ulc-hint-title">进阶示例:</b>
<div class="ulc-hint-line"><code>https://www.youtube.com/watch*</code><span>匹配特定开头的路径</span></div>
<div class="ulc-hint-line"><code>re:[^/]+\\.example\\.com/path/</code><span>使用正则表达式</span></div>
</div>
<label for="ulc-transform-keys">跳转参数 (可选, 每行一个)</label>
<textarea id="ulc-transform-keys" placeholder="例如: target\nurl\nto">${transformKeys}</textarea>
<p>部分网站跳转外链的时候会跳转到一个确认网页,配置参数会把对应参数内的外链直接转换为可点击链接。</p>
</div>
<div class="ulc-form-actions">
<button id="ulc-cancel-add-rule-btn" class="ulc-btn-secondary">取消</button>
<button id="ulc-save-rule-btn" class="ulc-btn-primary">保存规则</button>
</div>
</div>`;
},
renderConfigTextForm() {
const configString = JSON.stringify(State.config, null, 2);
return `
<div class="ulc-main-content">
<div class="ulc-header"><h3>配置文本</h3></div>
<div class="ulc-form-content">
<textarea id="ulc-config-textarea">${configString}</textarea>
</div>
<div class="ulc-form-actions">
<button id="ulc-cancel-config-text-btn" class="ulc-btn-secondary">取消</button>
<button id="ulc-save-config-text-btn" class="ulc-btn-primary">保存</button>
</div>
</div>`;
}
};
// --- Events (事件处理与数据逻辑) ---
const Events = {
_getCurrentContext() {
const isGeneral = State.ui.activeTab === GENERAL_TAB_ID;
if (isGeneral) {
return {
isGeneral: true,
params: State.config.general.params || [],
rule: null
};
}
const rule = State.config.rules[State.ui.activeRuleIndex];
return {
isGeneral: false,
params: rule ? (rule.params || []) : [],
rule: rule
};
},
addParamsFromInput() {
const input = document.getElementById('ulc-new-param');
if (!input || !input.value) return false;
const inputValue = input.value.trim();
let newParams = [];
const parsedUrl = Utils.tryParseURL(inputValue);
if (parsedUrl) {
if (parsedUrl.searchParams.size > 0) {
newParams = [...parsedUrl.searchParams.keys()];
UI.showToast(`已从链接中提取 ${newParams.length} 个参数`);
} else {
input.value = '';
return false;
}
} else {
newParams = inputValue.split(',').map(p => p.trim()).filter(p => p);
}
if (newParams.length === 0) {
input.value = '';
return false;
}
const context = this._getCurrentContext();
if (!context.isGeneral && !context.rule) {
return false;
}
const paramsList = context.params;
const paramsSet = new Set(paramsList);
let addedCount = 0;
newParams.forEach(p => {
if (!paramsSet.has(p)) {
paramsSet.add(p);
addedCount++;
}
});
if (addedCount === 0) {
input.value = '';
return false;
}
const sortedParams = Array.from(paramsSet).sort();
if (context.isGeneral) {
State.config.general.params = sortedParams;
} else {
if (!context.rule.params) {
context.rule.params = [];
}
context.rule.params = sortedParams;
}
Logger.log(`Added ${addedCount} parameter(s) to "${context.isGeneral ? 'General Rules' : context.rule.name}".`, newParams.filter(p => !new Set(paramsList).has(p)));
input.value = '';
return true;
},
deleteParam(paramToDelete) {
const context = this._getCurrentContext();
const params = context.params;
if ((!context.isGeneral && !context.rule) || !params) {
return false;
}
const index = params.indexOf(paramToDelete);
if (index > -1) {
params.splice(index, 1);
const contextName = context.isGeneral ? 'General Rules' : context.rule.name;
Logger.log(`Parameter "${paramToDelete}" deleted from "${contextName}".`);
return true;
}
return false;
},
saveRule() {
const nameInput = document.getElementById('ulc-rule-name');
const matchInput = document.getElementById('ulc-rule-match');
const transformInput = document.getElementById('ulc-transform-keys');
const newName = nameInput.value.trim();
const newMatches = [...new Set(matchInput.value.split('\n').map(m => m.trim()).filter(m => m))];
const newTransformKeys = [...new Set(transformInput.value.split('\n').map(k => k.trim()).filter(k => k))];
if (!newName || newMatches.length === 0) {
UI.showToast('规则名称和匹配地址不能为空。');
return false;
}
const isEdit = State.ui.view === 'edit';
const ruleIndex = isEdit ? State.ui.activeRuleIndex : -1;
if (State.config.rules.some((r, i) => r.name.toLowerCase() === newName.toLowerCase() && i !== ruleIndex)) {
Logger.warn('Save failed: Duplicate rule name detected.', newName);
UI.showToast('错误:已存在同名规则,请使用其他名称。');
return false;
}
const ruleData = { name: newName, match: newMatches, ...(newTransformKeys.length > 0 && { transform: newTransformKeys }) };
if (isEdit) {
const rule = State.config.rules[ruleIndex];
Object.assign(rule, ruleData);
if (newTransformKeys.length === 0) delete rule.transform;
} else {
const newRule = { ...ruleData, params: [], applyGeneral: true };
State.config.rules.push(newRule);
State.ui.activeRuleIndex = State.config.rules.length - 1;
State.ui.activeTab = Utils.getRuleTabId(newRule);
}
Logger.log(`Rule "${newName}" has been saved (${isEdit ? 'edited' : 'newly created'}).`);
UI.showToast(`规则 "${newName}" 已保存`);
return true;
},
deleteCurrentRule() {
const rule = State.config.rules[State.ui.activeRuleIndex];
if (State.ui.activeTab === GENERAL_TAB_ID || !rule) return false;
if (confirm(`确定要删除规则 "${rule.name}" 吗?`)) {
State.config.rules.splice(State.ui.activeRuleIndex, 1);
Logger.log(`Rule "${rule.name}" has been deleted.`);
UI.showToast('已删除');
return true;
}
return false;
},
saveConfigFromText() {
const textarea = document.getElementById('ulc-config-textarea');
if (!textarea) return false;
let newConfig;
try {
newConfig = JSON.parse(textarea.value);
} catch (e) {
Logger.error('Failed to parse configuration text as JSON.', { error: e.message, text: textarea.value });
UI.showToast('JSON 格式无效,请检查您的输入。\n错误信息: ' + e.message, 3000);
return false;
}
const validationError = Utils.validateConfigObject(newConfig);
if (validationError) {
Logger.warn('Save from text failed: Invalid configuration.', { error: validationError, config: newConfig });
UI.showToast('配置结构不正确:\n' + validationError, 4000);
return false;
}
State.config.general = newConfig.general;
State.config.rules = newConfig.rules;
UI.showToast('配置已成功保存');
return true;
},
resetConfig() {
if (confirm('确定要将通用参数列表重置为默认吗?此操作不可撤销。')) {
State.config.general.params = JSON.parse(JSON.stringify(State.DEFAULT_CONFIG.general.params));
Logger.warn('General rules have been reset to default.');
UI.showToast('通用参数已重置为默认');
return true;
}
return false;
},
toggleApplyGeneral(isChecked) {
if (State.ui.activeTab !== GENERAL_TAB_ID && State.config.rules[State.ui.activeRuleIndex]) {
State.config.rules[State.ui.activeRuleIndex].applyGeneral = isChecked;
Core.saveConfig();
}
},
onSearchInputDebounced: null, // 防抖处理的 input 事件处理器
_performSearch(query) {
if (query !== State.ui.searchQuery) {
State.ui.searchQuery = query;
UI.updateRuleList();
}
},
// 核心事件处理器
handlePanelClick(e) {
const target = e.target;
const closest = (selector) => target.closest(selector);
if (closest('#ulc-close-btn')) {
if (State.dom.settingsPanel) { State.dom.settingsPanel.remove(); State.dom.settingsPanel = null; }
} else if (closest('.ulc-tab')) {
const clickedTab = closest('.ulc-tab');
if (clickedTab.classList.contains('active')) return;
State.ui.activeTab = clickedTab.dataset.tabId;
State.ui.activeRuleIndex = parseInt(clickedTab.dataset.ruleIndex, 10);
State.ui.view = 'list';
const currentActiveTab = State.dom.sidebarContainer.querySelector('.ulc-tab.active');
if (currentActiveTab) currentActiveTab.classList.remove('active');
clickedTab.classList.add('active');
UI.renderMainContent();
} else if (closest('#ulc-add-btn')) {
if (this.addParamsFromInput()) {
Core.saveConfig();
UI.renderMainContent();
}
} else if (closest('.ulc-delete')) {
if (this.deleteParam(closest('.ulc-delete').dataset.param)) {
Core.saveConfig();
UI.renderMainContent();
}
} else if (closest('#ulc-add-rule-btn')) {
State.ui.view = 'add'; UI.renderMainContent();
} else if (closest('.ulc-edit-icon')) {
State.ui.view = 'edit'; UI.renderMainContent();
} else if (closest('#ulc-delete-rule-btn')) {
if (this.deleteCurrentRule()) {
State.ui.activeTab = GENERAL_TAB_ID; State.ui.activeRuleIndex = -1;
Core.saveConfig();
UI.renderPanel();
}
} else if (closest('#ulc-reset-btn')) {
if (this.resetConfig()) {
State.ui.activeTab = GENERAL_TAB_ID; State.ui.activeRuleIndex = -1;
Core.saveConfig();
UI.renderMainContent();
}
} else if (closest('#ulc-apply-general')) {
this.toggleApplyGeneral(target.checked);
} else if (closest('#ulc-save-rule-btn')) {
if (this.saveRule()) {
State.ui.view = 'list';
Core.saveConfig();
UI.renderPanel();
}
} else if (closest('#ulc-cancel-add-rule-btn')) {
State.ui.view = 'list'; UI.renderMainContent();
} else if (closest('#ulc-config-text-btn')) {
State.ui.view = 'config-text'; UI.renderMainContent();
} else if (closest('#ulc-save-config-text-btn')) {
if (this.saveConfigFromText()) {
State.ui.view = 'list'; State.ui.activeTab = GENERAL_TAB_ID; State.ui.activeRuleIndex = -1;
Core.saveConfig();
UI.renderPanel();
}
} else if (closest('#ulc-cancel-config-text-btn')) {
State.ui.view = 'list'; UI.renderMainContent();
}
},
initEventListeners() {
const preCleanLink = (e) => {
if (e.target.closest('#ulc-settings-panel')) return;
const link = e.target.closest('a[href]');
if (link && !link.dataset[State.cleanedAttrName] && !link.dataset[State.invalidAttrName]) {
if (Utils.isValidHttpLink(link)) {
const cleanedHref = Core.cleanUrl(link.href);
if (link.href !== cleanedHref) {
Logger.log('Link purified on hover:', { from: link.href, to: cleanedHref });
link.href = cleanedHref;
}
link.dataset[State.cleanedAttrName] = cleanedHref;
if (link.hostname !== window.location.hostname) {
link.setAttribute('referrerpolicy', 'no-referrer');
}
} else {
link.dataset[State.invalidAttrName] = 'true';
}
}
};
document.addEventListener('mouseover', preCleanLink, true);
const finalClickFix = e => {
const link = e.target.closest('a[href]');
if (link && typeof link.dataset[State.invalidAttrName] === 'undefined') {
const cleanedHref = link.dataset[State.cleanedAttrName] || Core.cleanUrl(link.href);
if (link.href !== cleanedHref) {
Logger.warn('Link purified on click (final fix):', { from: link.href, to: cleanedHref });
link.href = cleanedHref;
}
if (link.hostname !== window.location.hostname) {
link.setAttribute('referrerpolicy', 'no-referrer');
}
e.stopImmediatePropagation();
}
};
['mousedown', 'click', 'contextmenu'].forEach(evt => document.addEventListener(evt, finalClickFix, true));
const wrapHistoryMethod = (method) => {
const original = history[method];
history[method] = function (state, title, url, ...rest) {
const originalUrl = url ? url.toString() : '';
const cleanedUrl = Core.cleanUrl(originalUrl);
if (originalUrl !== cleanedUrl) {
Logger.log(`history.${method} intercepted and URL purified.`, {
from: originalUrl,
to: cleanedUrl,
state: state
});
}
const oldHref = window.location.href;
const result = original.apply(this, [state, title, cleanedUrl, ...rest]);
requestAnimationFrame(() => {
// 重新计算规则
if (window.location.href !== oldHref) {
Logger.log('SPA navigation detected. Recalculating parameters...');
Core.setActiveParameters();
}
});
return result;
};
};
wrapHistoryMethod('pushState');
wrapHistoryMethod('replaceState');
// 沙盒模式下window.open无法正确拦截,所以需要使用unsafeWindow
const openContext = isFallbackMode ? unsafeWindow : window;
const originalOpen = openContext.open;
openContext.open = function (url, target, features) {
const originalUrl = url ? url.toString() : '';
const cleanedUrl = Core.cleanUrl(originalUrl);
if (originalUrl !== cleanedUrl) {
Logger.log('window.open call intercepted and URL purified.', {
from: originalUrl,
to: cleanedUrl,
target: target || '_blank'
});
}
return originalOpen.apply(openContext, [cleanedUrl, target, features]);
};
window.addEventListener('ulc-open-settings', () => {
if (State.dom.settingsPanel) {
State.dom.settingsPanel.remove();
State.dom.settingsPanel = null;
return;
}
const open = () => {
UI.createSettingsPanel();
State.ui.view = 'list';
State.ui.activeTab = GENERAL_TAB_ID;
State.ui.activeRuleIndex = -1;
UI.renderPanel();
State.dom.settingsPanel.style.display = 'flex';
};
document.body ? open() : document.addEventListener('DOMContentLoaded', open);
});
window.addEventListener('ulc-config-updated', (event) => {
Logger.log('Configuration synced from another tab. Updating state...');
const newConfig = event.detail;
// 更新配置
State.config = newConfig;
Core.setActiveParameters();
if (State.dom.settingsPanel) {
State.ui.view = 'list';
if (State.ui.activeTab !== GENERAL_TAB_ID) {
const ruleIndex = State.config.rules.findIndex(r => Utils.getRuleTabId(r) === State.ui.activeTab);
if (ruleIndex === -1) {
State.ui.activeTab = GENERAL_TAB_ID;
State.ui.activeRuleIndex = -1;
} else {
State.ui.activeRuleIndex = ruleIndex;
}
}
UI.renderPanel();
}
});
}
};
// --- 初始化 ---
function main() {
const config = isFallbackMode ? sandboxConfig : JSON.parse(document.getElementById('ulc-injected-script').dataset.config);
const defaultConfig = isFallbackMode ? sandboxDefaultConfig : JSON.parse(document.getElementById('ulc-injected-script').dataset.defaultConfig);
State.init(config, defaultConfig);
// --- 初始化防抖的搜索处理器 ---
Events.onSearchInputDebounced = Utils.debounce((e) => {
Events._performSearch(e.target.value);
}, 250);
State.cleanedAttrName = Utils.randomString();
State.invalidAttrName = Utils.randomString();
Core.setActiveParameters();
const cleanedPageUrl = Core.cleanUrl(window.location.href);
if (window.location.href !== cleanedPageUrl) {
history.replaceState(history.state, '', cleanedPageUrl);
}
// 绑定所有事件,但 handlePanelClick 需要绑定 Events 对象的上下文
Events.handlePanelClick = Events.handlePanelClick.bind(Events);
Events.initEventListeners();
}
main();
})();
},
inject(config, defaultConfig) {
const nonce = document.querySelector('script[nonce]')?.nonce || document.querySelector('style[nonce]')?.nonce;
const finalCodeString = `(${this.injectedCode.toString()})();`;
const injectedScript = document.createElement('script');
injectedScript.id = 'ulc-injected-script';
injectedScript.nonce = nonce;
injectedScript.dataset.config = JSON.stringify(config);
injectedScript.dataset.defaultConfig = JSON.stringify(defaultConfig);
if (window.trustedTypes && window.trustedTypes.createPolicy) {
try {
const policy = window.trustedTypes.createPolicy('UniversalLinkCleanerPolicy', { createScript: s => s });
injectedScript.textContent = policy.createScript(finalCodeString);
} catch (e) {
injectedScript.textContent = finalCodeString;
}
} else {
injectedScript.textContent = finalCodeString;
}
(document.head || document.documentElement).appendChild(injectedScript);
injectedScript.remove();
}
};
// --- 主执行流程 ---
function main() {
Sandbox.loadConfig();
Sandbox.init();
StyleInjector.inject();
let injectionSuccessful = false;
const successListener = () => {
injectionSuccessful = true;
window.removeEventListener('ulc-injection-success', successListener);
};
window.addEventListener('ulc-injection-success', successListener);
// 注入模式
CodeInjector.inject(Sandbox.config, Sandbox.DEFAULT_CONFIG);
setTimeout(() => {
if (!injectionSuccessful) {
// 注入失败,降级为沙箱模式
window.removeEventListener('ulc-injection-success', successListener);
CodeInjector.injectedCode(Sandbox.config, Sandbox.DEFAULT_CONFIG);
}
}, 0);
}
main();
})();