新增 URL 重定向功能并修复若干问题

- 新增 Redirect(URL 重定向):+ 菜单可添加,支持静态跳转(redirect.url)
  与正则捕获替换(regexSubstitution);界面加 Redirects 区块与样式
- 修复重定向/头对页面导航不生效:DNR 规则显式声明 resourceTypes 含 main_frame
- URL 过滤/重定向去掉强制 ^ 锚定,改为子串匹配,无需手写协议头
- 重定向与 Filter 解耦,仅由自身 from 决定生效
- 修复 angular.lowercase 报错(AngularJS 1.7 已移除该 API)
- README 补充重定向功能与用法说明
This commit is contained in:
呈祥
2026-07-10 17:15:21 +08:00
parent 5df5fab875
commit 7e2830722e
5 changed files with 143 additions and 25 deletions
+13
View File
@@ -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 生效。
+74 -23
View File
@@ -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: '' });
+32
View File
@@ -66,6 +66,12 @@
Response header
</md-button>
</md-menu-item>
<md-menu-item>
<md-button md-no-ink
ng-click="dataSource.addRedirect(dataSource.selectedProfile.redirects)">
Redirect
</md-button>
</md-menu-item>
<md-menu-item>
<md-button md-no-ink
ng-click="dataSource.addFilter(dataSource.selectedProfile.filters)">
@@ -295,6 +301,32 @@
</md-button>
</div>
</div>
<div ng-if="dataSource.selectedProfile.redirects.length">
<div class="section-title">Redirects</div>
<div ng-repeat="redirect in dataSource.selectedProfile.redirects"
class="header-row" layout="row">
<md-checkbox ng-model="redirect.enabled" md-no-ink
class="enable-checkbox"
aria-label="Enabled"></md-checkbox>
<md-input-container md-no-float class="redirect-input">
<input ng-model="redirect.name" type="text"
placeholder="Redirect from (regex)">
</md-input-container>
<md-input-container md-no-float flex>
<input ng-model="redirect.value" type="text"
placeholder="Redirect to (use \1 for capture groups)">
</md-input-container>
<md-button class="md-icon-button" aria-label="Help" md-no-ink
ng-click="openLink('https://developer.chrome.com/docs/extensions/reference/api/declarativeNetRequest#property-Redirect-regexSubstitution')">
<md-icon md-svg-src="images/ic_help_24px.svg"></md-icon>
</md-button>
<md-button class="md-icon-button" md-no-ink
ng-click="dataSource.removeRedirect(dataSource.selectedProfile.redirects, redirect)">
<md-icon md-svg-icon="md-close"
class="remove-button-icon"></md-icon>
</md-button>
</div>
</div>
<div ng-if='dataSource.selectedProfile.filters.length'>
<div class="section-title">Filters</div>
<div ng-repeat="filter in dataSource.selectedProfile.filters"
+19 -2
View File
@@ -96,6 +96,19 @@ modHeader.factory('dataSource', function($mdToast) {
});
};
dataSource.addRedirect = function(redirects) {
redirects.push({
enabled: true,
name: '',
value: '',
comment: ''
});
};
dataSource.removeRedirect = function(redirects, redirect) {
redirects.splice(redirects.indexOf(redirect), 1);
};
dataSource.removeFilter = function(filters, filter) {
filters.splice(filters.indexOf(filter), 1);
};
@@ -175,6 +188,7 @@ modHeader.factory('dataSource', function($mdToast) {
hideComment: true,
headers: [],
respHeaders: [],
redirects: [],
filters: [],
appendMode: ''
};
@@ -207,6 +221,9 @@ modHeader.factory('dataSource', function($mdToast) {
profile.respHeaders = [];
dataSource.addHeader(profile.respHeaders);
}
if (!profile.redirects) {
profile.redirects = [];
}
if (!profile.filters) {
profile.filters = [];
}
@@ -473,9 +490,9 @@ modHeader.factory('autocompleteService', function(
autocompleteService.responseHeaderValues = [];
function createFilterFor_(query) {
var lowercaseQuery = angular.lowercase(query);
var lowercaseQuery = String(query).toLowerCase();
return function filterFn(item) {
return (angular.lowercase(item).indexOf(lowercaseQuery) == 0);
return (String(item).toLowerCase().indexOf(lowercaseQuery) == 0);
};
}
+5
View File
@@ -135,4 +135,9 @@ md-select.filter-type {
md-autocomplete.comment-autocomplete {
min-width: 140px;
width: 140px;
}
md-input-container.redirect-input {
width: 200px;
min-width: 200px;
}