新增 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:
@@ -17,6 +17,7 @@
|
|||||||
## 功能
|
## 功能
|
||||||
|
|
||||||
- 增加 / 修改 / 删除**请求头**与**响应头**
|
- 增加 / 修改 / 删除**请求头**与**响应头**
|
||||||
|
- **URL 重定向**(Redirect):支持静态跳转和正则捕获替换
|
||||||
- 按 **URL 正则**或**资源类型**限定生效范围(Filter)
|
- 按 **URL 正则**或**资源类型**限定生效范围(Filter)
|
||||||
- 多套 **Profile** 配置,可快速切换
|
- 多套 **Profile** 配置,可快速切换
|
||||||
- 给每条 header 添加**备注**
|
- 给每条 header 添加**备注**
|
||||||
@@ -42,11 +43,23 @@
|
|||||||
2. 点右上角 **+**:
|
2. 点右上角 **+**:
|
||||||
- **Request header**:添加请求头(如 `User-Agent`、`Authorization` 等)
|
- **Request header**:添加请求头(如 `User-Agent`、`Authorization` 等)
|
||||||
- **Response header**:添加响应头(如 `Access-Control-Allow-Origin`)
|
- **Response header**:添加响应头(如 `Access-Control-Allow-Origin`)
|
||||||
|
- **Redirect**:添加 URL 重定向规则
|
||||||
- **Filter**:添加生效条件(URL 正则或资源类型)
|
- **Filter**:添加生效条件(URL 正则或资源类型)
|
||||||
3. 勾选每行左侧的复选框以**启用**该条修改;填写 Name / Value。
|
3. 勾选每行左侧的复选框以**启用**该条修改;填写 Name / Value。
|
||||||
4. 打开目标网站并刷新,在 DevTools 的 **Network** 面板中即可确认头已被修改。
|
4. 打开目标网站并刷新,在 DevTools 的 **Network** 面板中即可确认头已被修改。
|
||||||
5. 图标上的**数字角标**表示当前生效的 header 数量。
|
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 生效。
|
- **不加 Filter**:对所有 URL 生效。
|
||||||
|
|||||||
+74
-23
@@ -3,7 +3,13 @@
|
|||||||
// since MV3 no longer allows blocking webRequest.
|
// since MV3 no longer allows blocking webRequest.
|
||||||
|
|
||||||
const SPECIAL_CHARS = '^$&+?.()|{}[]/'.split('');
|
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 ---------------------------------------------------------
|
// -- Storage helpers ---------------------------------------------------------
|
||||||
|
|
||||||
@@ -64,17 +70,28 @@ function loadSelectedProfile(state) {
|
|||||||
appendMode: profile.appendMode,
|
appendMode: profile.appendMode,
|
||||||
headers: filterEnabled(profile.headers),
|
headers: filterEnabled(profile.headers),
|
||||||
respHeaders: filterEnabled(profile.respHeaders),
|
respHeaders: filterEnabled(profile.respHeaders),
|
||||||
|
// For redirects, name = "redirect from", value = "redirect to".
|
||||||
|
redirects: filterEnabled(profile.redirects),
|
||||||
filters
|
filters
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- DNR rule generation -----------------------------------------------------
|
// -- DNR rule generation -----------------------------------------------------
|
||||||
|
|
||||||
function anchor(regex) {
|
// Resource types a profile's rules apply to. If the user added a Resource Type
|
||||||
return regex.startsWith('^') ? regex : '^' + regex;
|
// 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 operation = profile.appendMode ? 'append' : 'set';
|
||||||
const requestHeaders = profile.headers.map(
|
const requestHeaders = profile.headers.map(
|
||||||
h => ({ header: h.name, operation, value: h.value }));
|
h => ({ header: h.name, operation, value: h.value }));
|
||||||
@@ -86,24 +103,19 @@ function buildRule(profile) {
|
|||||||
|
|
||||||
const condition = {};
|
const condition = {};
|
||||||
const urlRegexes = [];
|
const urlRegexes = [];
|
||||||
const resourceTypes = [];
|
|
||||||
for (const filter of profile.filters) {
|
for (const filter of profile.filters) {
|
||||||
if (!filter.enabled) {
|
if (filter.enabled && filter.type === 'urls' && filter.urlRegex) {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (filter.type === 'urls' && filter.urlRegex) {
|
|
||||||
urlRegexes.push(filter.urlRegex);
|
urlRegexes.push(filter.urlRegex);
|
||||||
} else if (filter.type === 'types' && filter.resourceType) {
|
|
||||||
resourceTypes.push(...filter.resourceType);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (urlRegexes.length === 1) {
|
if (urlRegexes.length === 1) {
|
||||||
condition.regexFilter = anchor(urlRegexes[0]);
|
condition.regexFilter = urlRegexes[0];
|
||||||
} else if (urlRegexes.length > 1) {
|
} else if (urlRegexes.length > 1) {
|
||||||
condition.regexFilter = urlRegexes.map(r => `(${anchor(r)})`).join('|');
|
condition.regexFilter = urlRegexes.map(r => `(${r})`).join('|');
|
||||||
}
|
}
|
||||||
if (resourceTypes.length) {
|
const resourceTypes = profileResourceTypes(profile);
|
||||||
condition.resourceTypes = [...new Set(resourceTypes)];
|
if (resourceTypes) {
|
||||||
|
condition.resourceTypes = resourceTypes;
|
||||||
}
|
}
|
||||||
|
|
||||||
const action = { type: 'modifyHeaders' };
|
const action = { type: 'modifyHeaders' };
|
||||||
@@ -113,7 +125,45 @@ function buildRule(profile) {
|
|||||||
if (responseHeaders.length) {
|
if (responseHeaders.length) {
|
||||||
action.responseHeaders = responseHeaders;
|
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() {
|
async function clearAllRules() {
|
||||||
@@ -136,20 +186,21 @@ async function updateRules(state, profile) {
|
|||||||
if (state.isPaused || !profile) {
|
if (state.isPaused || !profile) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const rule = buildRule(profile);
|
const rules = buildRules(profile);
|
||||||
if (!rule) {
|
if (!rules.length) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (state.lockedTabId) {
|
if (state.lockedTabId) {
|
||||||
// tabIds conditions are only allowed on session-scoped rules.
|
// tabIds conditions are only allowed on session-scoped rules.
|
||||||
rule.condition.tabIds = [Number(state.lockedTabId)];
|
const tabIds = [Number(state.lockedTabId)];
|
||||||
await chrome.declarativeNetRequest.updateSessionRules({ addRules: [rule] });
|
rules.forEach(rule => { rule.condition.tabIds = tabIds; });
|
||||||
|
await chrome.declarativeNetRequest.updateSessionRules({ addRules: rules });
|
||||||
} else {
|
} else {
|
||||||
await chrome.declarativeNetRequest.updateDynamicRules({ addRules: [rule] });
|
await chrome.declarativeNetRequest.updateDynamicRules({ addRules: rules });
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} 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;
|
return;
|
||||||
}
|
}
|
||||||
const numHeaders = profile
|
const numHeaders = profile
|
||||||
? profile.headers.length + profile.respHeaders.length
|
? profile.headers.length + profile.respHeaders.length + profile.redirects.length
|
||||||
: 0;
|
: 0;
|
||||||
if (numHeaders === 0) {
|
if (numHeaders === 0) {
|
||||||
await chrome.action.setBadgeText({ text: '' });
|
await chrome.action.setBadgeText({ text: '' });
|
||||||
|
|||||||
@@ -66,6 +66,12 @@
|
|||||||
Response header
|
Response header
|
||||||
</md-button>
|
</md-button>
|
||||||
</md-menu-item>
|
</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-menu-item>
|
||||||
<md-button md-no-ink
|
<md-button md-no-ink
|
||||||
ng-click="dataSource.addFilter(dataSource.selectedProfile.filters)">
|
ng-click="dataSource.addFilter(dataSource.selectedProfile.filters)">
|
||||||
@@ -295,6 +301,32 @@
|
|||||||
</md-button>
|
</md-button>
|
||||||
</div>
|
</div>
|
||||||
</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 ng-if='dataSource.selectedProfile.filters.length'>
|
||||||
<div class="section-title">Filters</div>
|
<div class="section-title">Filters</div>
|
||||||
<div ng-repeat="filter in dataSource.selectedProfile.filters"
|
<div ng-repeat="filter in dataSource.selectedProfile.filters"
|
||||||
|
|||||||
+19
-2
@@ -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) {
|
dataSource.removeFilter = function(filters, filter) {
|
||||||
filters.splice(filters.indexOf(filter), 1);
|
filters.splice(filters.indexOf(filter), 1);
|
||||||
};
|
};
|
||||||
@@ -175,6 +188,7 @@ modHeader.factory('dataSource', function($mdToast) {
|
|||||||
hideComment: true,
|
hideComment: true,
|
||||||
headers: [],
|
headers: [],
|
||||||
respHeaders: [],
|
respHeaders: [],
|
||||||
|
redirects: [],
|
||||||
filters: [],
|
filters: [],
|
||||||
appendMode: ''
|
appendMode: ''
|
||||||
};
|
};
|
||||||
@@ -207,6 +221,9 @@ modHeader.factory('dataSource', function($mdToast) {
|
|||||||
profile.respHeaders = [];
|
profile.respHeaders = [];
|
||||||
dataSource.addHeader(profile.respHeaders);
|
dataSource.addHeader(profile.respHeaders);
|
||||||
}
|
}
|
||||||
|
if (!profile.redirects) {
|
||||||
|
profile.redirects = [];
|
||||||
|
}
|
||||||
if (!profile.filters) {
|
if (!profile.filters) {
|
||||||
profile.filters = [];
|
profile.filters = [];
|
||||||
}
|
}
|
||||||
@@ -473,9 +490,9 @@ modHeader.factory('autocompleteService', function(
|
|||||||
autocompleteService.responseHeaderValues = [];
|
autocompleteService.responseHeaderValues = [];
|
||||||
|
|
||||||
function createFilterFor_(query) {
|
function createFilterFor_(query) {
|
||||||
var lowercaseQuery = angular.lowercase(query);
|
var lowercaseQuery = String(query).toLowerCase();
|
||||||
return function filterFn(item) {
|
return function filterFn(item) {
|
||||||
return (angular.lowercase(item).indexOf(lowercaseQuery) == 0);
|
return (String(item).toLowerCase().indexOf(lowercaseQuery) == 0);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -136,3 +136,8 @@ md-autocomplete.comment-autocomplete {
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
width: 140px;
|
width: 140px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
md-input-container.redirect-input {
|
||||||
|
width: 200px;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user