diff --git a/README.md b/README.md index 93ac343..d8842a5 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ ## 功能 - 增加 / 修改 / 删除**请求头**与**响应头** +- **URL 重定向**(Redirect):支持静态跳转和正则捕获替换 - 按 **URL 正则**或**资源类型**限定生效范围(Filter) - 多套 **Profile** 配置,可快速切换 - 给每条 header 添加**备注** @@ -42,11 +43,23 @@ 2. 点右上角 **+**: - **Request header**:添加请求头(如 `User-Agent`、`Authorization` 等) - **Response header**:添加响应头(如 `Access-Control-Allow-Origin`) + - **Redirect**:添加 URL 重定向规则 - **Filter**:添加生效条件(URL 正则或资源类型) 3. 勾选每行左侧的复选框以**启用**该条修改;填写 Name / Value。 4. 打开目标网站并刷新,在 DevTools 的 **Network** 面板中即可确认头已被修改。 5. 图标上的**数字角标**表示当前生效的 header 数量。 +### URL 重定向说明 + +在 **+ → Redirect** 添加一条规则,填「Redirect from」和「Redirect to」: + +- **静态跳转**:from 填匹配的正则(如 `https://old\.com/.*`),to 填目标 URL(如 `https://new.com/`)。 +- **正则捕获替换**:from 里用捕获组 `(...)`,to 里用 `\1`、`\2` 引用。 + 例如 from = `^https://old\.com/(.*)`,to = `https://new.com/\1`,会把路径原样带过去。 +- from 是正则、**子串匹配**(不强制从头匹配)。真实 URL 含协议头(如 + `http://localhost:5173/...`),所以写 `localhost:5173/.*` 即可,无需写协议; + 如需从头匹配可自行加 `^`(注意那样要带上 `https?://`)。 + ### 生效范围说明 - **不加 Filter**:对所有 URL 生效。 diff --git a/src/background.js b/src/background.js index 317e63e..41c1e51 100644 --- a/src/background.js +++ b/src/background.js @@ -3,7 +3,13 @@ // since MV3 no longer allows blocking webRequest. const SPECIAL_CHARS = '^$&+?.()|{}[]/'.split(''); -const RULE_ID = 1; + +// DNR does NOT match main_frame (top-level navigations) unless resourceTypes is +// specified explicitly, so we default to the full list instead of leaving it out. +const ALL_RESOURCE_TYPES = [ + 'main_frame', 'sub_frame', 'stylesheet', 'script', 'image', 'font', + 'object', 'xmlhttprequest', 'ping', 'csp_report', 'media', 'websocket', 'other' +]; // -- Storage helpers --------------------------------------------------------- @@ -64,17 +70,28 @@ function loadSelectedProfile(state) { appendMode: profile.appendMode, headers: filterEnabled(profile.headers), respHeaders: filterEnabled(profile.respHeaders), + // For redirects, name = "redirect from", value = "redirect to". + redirects: filterEnabled(profile.redirects), filters }; } // -- DNR rule generation ----------------------------------------------------- -function anchor(regex) { - return regex.startsWith('^') ? regex : '^' + regex; +// Resource types a profile's rules apply to. If the user added a Resource Type +// filter, honor it; otherwise apply to all types (explicitly, so that top-level +// navigations / main_frame are included — DNR skips main_frame by default). +function profileResourceTypes(profile) { + const resourceTypes = []; + for (const filter of profile.filters) { + if (filter.enabled && filter.type === 'types' && filter.resourceType) { + resourceTypes.push(...filter.resourceType); + } + } + return resourceTypes.length ? [...new Set(resourceTypes)] : ALL_RESOURCE_TYPES; } -function buildRule(profile) { +function buildHeaderRule(profile) { const operation = profile.appendMode ? 'append' : 'set'; const requestHeaders = profile.headers.map( h => ({ header: h.name, operation, value: h.value })); @@ -86,24 +103,19 @@ function buildRule(profile) { const condition = {}; const urlRegexes = []; - const resourceTypes = []; for (const filter of profile.filters) { - if (!filter.enabled) { - continue; - } - if (filter.type === 'urls' && filter.urlRegex) { + if (filter.enabled && filter.type === 'urls' && filter.urlRegex) { urlRegexes.push(filter.urlRegex); - } else if (filter.type === 'types' && filter.resourceType) { - resourceTypes.push(...filter.resourceType); } } if (urlRegexes.length === 1) { - condition.regexFilter = anchor(urlRegexes[0]); + condition.regexFilter = urlRegexes[0]; } else if (urlRegexes.length > 1) { - condition.regexFilter = urlRegexes.map(r => `(${anchor(r)})`).join('|'); + condition.regexFilter = urlRegexes.map(r => `(${r})`).join('|'); } - if (resourceTypes.length) { - condition.resourceTypes = [...new Set(resourceTypes)]; + const resourceTypes = profileResourceTypes(profile); + if (resourceTypes) { + condition.resourceTypes = resourceTypes; } const action = { type: 'modifyHeaders' }; @@ -113,7 +125,45 @@ function buildRule(profile) { if (responseHeaders.length) { action.responseHeaders = responseHeaders; } - return { id: RULE_ID, priority: 1, action, condition }; + return { priority: 1, action, condition }; +} + +// Builds a DNR redirect rule from a single "redirect from -> redirect to" entry. +// - If "from" contains a capture group, "to" is treated as a regex substitution +// (e.g. from "^https://a\.com/(.*)" to "https://b.com/\1"). +// - Otherwise "to" is used as a static target URL. +function buildRedirectRule(redirect, profile) { + const from = redirect.name; + const to = redirect.value; + if (!from || !to) { + return null; + } + // A redirect is matched purely by its own "from" pattern (substring match, + // like the URL filters); it is intentionally NOT constrained by the profile's + // Filter section. resourceTypes is set explicitly so main_frame navigations + // (the usual redirect target) are matched. + const condition = { regexFilter: from, resourceTypes: ALL_RESOURCE_TYPES }; + const action = { type: 'redirect' }; + action.redirect = from.includes('(') + ? { regexSubstitution: to } + : { url: to }; + return { priority: 1, action, condition }; +} + +function buildRules(profile) { + const rules = []; + const headerRule = buildHeaderRule(profile); + if (headerRule) { + rules.push(headerRule); + } + for (const redirect of profile.redirects) { + const rule = buildRedirectRule(redirect, profile); + if (rule) { + rules.push(rule); + } + } + rules.forEach((rule, index) => { rule.id = index + 1; }); + return rules; } async function clearAllRules() { @@ -136,20 +186,21 @@ async function updateRules(state, profile) { if (state.isPaused || !profile) { return; } - const rule = buildRule(profile); - if (!rule) { + const rules = buildRules(profile); + if (!rules.length) { return; } try { if (state.lockedTabId) { // tabIds conditions are only allowed on session-scoped rules. - rule.condition.tabIds = [Number(state.lockedTabId)]; - await chrome.declarativeNetRequest.updateSessionRules({ addRules: [rule] }); + const tabIds = [Number(state.lockedTabId)]; + rules.forEach(rule => { rule.condition.tabIds = tabIds; }); + await chrome.declarativeNetRequest.updateSessionRules({ addRules: rules }); } else { - await chrome.declarativeNetRequest.updateDynamicRules({ addRules: [rule] }); + await chrome.declarativeNetRequest.updateDynamicRules({ addRules: rules }); } } catch (e) { - console.error('ModHeader: failed to apply rules', e, rule); + console.error('ModHeader: failed to apply rules', e, rules); } } @@ -163,7 +214,7 @@ async function updateBadge(state, profile) { return; } const numHeaders = profile - ? profile.headers.length + profile.respHeaders.length + ? profile.headers.length + profile.respHeaders.length + profile.redirects.length : 0; if (numHeaders === 0) { await chrome.action.setBadgeText({ text: '' }); diff --git a/src/popup.html b/src/popup.html index 262652c..ca26c00 100644 --- a/src/popup.html +++ b/src/popup.html @@ -66,6 +66,12 @@ Response header + + + Redirect + + @@ -295,6 +301,32 @@ +
+
Redirects
+
+ + + + + + + + + + + + + +
+
Filters