feat(browser): 增加 richer request matching 能力

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
walker1211
2026-04-19 00:43:37 +08:00
co-authored by Claude Opus 4.7
parent cb415b4127
commit bd073acf47
+192 -14
View File
@@ -477,9 +477,26 @@ function getTools() {
pageId: { type: 'string' },
urlIncludes: { type: 'string' },
method: { type: 'string' },
resourceType: { type: 'string' },
urlPattern: { type: 'string' },
urlRegex: { type: 'string' },
headerMatchers: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
valueIncludes: { type: 'string' },
valueRegex: { type: 'string' },
},
required: ['name'],
additionalProperties: false,
},
},
priority: { type: 'integer' },
action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] },
statusCode: { type: 'integer', minimum: 100, maximum: 599 },
headers: {
responseHeaders: {
type: 'array',
items: {
type: 'object',
@@ -832,19 +849,19 @@ function parseOptionalStatusCode(value) {
return value;
}
function parseOptionalHeaders(value, contentType) {
function parseOptionalResponseHeaders(value, contentType) {
const headers = [];
if (Array.isArray(value)) {
for (const entry of value) {
if (!entry || typeof entry !== 'object') {
throw new Error('headers entries must be objects with name and value');
throw new Error('responseHeaders entries must be objects with name and value');
}
const name = requireNonEmptyString(entry.name, 'headers.name');
const name = requireNonEmptyString(entry.name, 'responseHeaders.name');
const headerValue = String(entry.value ?? '');
headers.push({ name, value: headerValue });
}
} else if (value !== undefined) {
throw new Error('headers must be an array');
throw new Error('responseHeaders must be an array');
}
if (contentType && !headers.some((header) => header.name.toLowerCase() === 'content-type')) {
headers.push({ name: 'Content-Type', value: contentType });
@@ -880,6 +897,89 @@ function parseOptionalUrlIncludes(value) {
return requireNonEmptyString(value, 'urlIncludes');
}
function parseOptionalResourceType(value) {
if (value === undefined) {
return '';
}
return requireNonEmptyString(value, 'resourceType');
}
function parseOptionalUrlPattern(value) {
if (value === undefined) {
return '';
}
return requireNonEmptyString(value, 'urlPattern');
}
function parseOptionalUrlRegex(value) {
if (value === undefined) {
return '';
}
const raw = requireNonEmptyString(value, 'urlRegex');
try {
new RegExp(raw);
} catch {
throw new Error('urlRegex must be a valid regular expression');
}
return raw;
}
function parseOptionalPriority(value) {
if (value === undefined) {
return 0;
}
if (!Number.isInteger(value)) {
throw new Error('priority must be an integer');
}
return value;
}
function parseOptionalHeaderMatchers(value) {
if (value === undefined) {
return [];
}
if (!Array.isArray(value)) {
throw new Error('headerMatchers must be an array');
}
return value.map((entry) => {
if (!entry || typeof entry !== 'object') {
throw new Error('headerMatchers entries must be objects');
}
const name = requireNonEmptyString(entry.name, 'headerMatchers.name');
const valueIncludes =
entry.valueIncludes === undefined ? '' : requireNonEmptyString(entry.valueIncludes, 'headerMatchers.valueIncludes');
const valueRegex =
entry.valueRegex === undefined ? '' : requireNonEmptyString(entry.valueRegex, 'headerMatchers.valueRegex');
if (!valueIncludes && !valueRegex) {
throw new Error('headerMatchers entry must include valueIncludes or valueRegex');
}
if (valueRegex) {
try {
new RegExp(valueRegex);
} catch {
throw new Error('headerMatchers.valueRegex must be a valid regular expression');
}
}
return { name, valueIncludes, valueRegex };
});
}
function validateInterceptMatcherSet({
urlIncludes,
method,
resourceType,
urlPattern,
urlRegex,
headerMatchers,
}) {
if (urlPattern && urlRegex) {
throw new Error('urlPattern and urlRegex cannot be used together');
}
if (!urlIncludes && !method && !resourceType && !urlPattern && !urlRegex && headerMatchers.length === 0) {
throw new Error('at least one matching condition is required');
}
}
function createInterceptRuleId() {
return `rule-${nextInterceptRuleCounter++}`;
}
@@ -920,6 +1020,11 @@ function formatInterceptRules(rules) {
`pageTitle: ${rule.pageTitleSnapshot || '<untitled>'}`,
`urlIncludes: ${rule.urlIncludes || '<any>'}`,
`method: ${rule.method || '<any>'}`,
`resourceType: ${rule.resourceType || '<any>'}`,
`urlPattern: ${rule.urlPattern || '<none>'}`,
`urlRegex: ${rule.urlRegex || '<none>'}`,
`headerMatchers: ${Array.isArray(rule.headerMatchers) ? rule.headerMatchers.length : 0}`,
`priority: ${Number.isInteger(rule.priority) ? rule.priority : 0}`,
`action: ${rule.action}`,
`statusCode: ${rule.action === 'fulfill' ? rule.statusCode : 'n/a'}`,
`contentType: ${rule.action === 'fulfill' ? rule.contentType || '<none>' : 'n/a'}`,
@@ -2519,11 +2624,69 @@ function getRulesForPage(pageId) {
return interceptRules.filter((rule) => rule.pageId === pageId);
}
function getRulesForMatching(pageId) {
return getRulesForPage(pageId)
.slice()
.sort((left, right) => right.priority - left.priority);
}
function matchesUrlPattern(pattern, url) {
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*');
return new RegExp(`^${escaped}$`).test(url);
}
function normalizePausedRequestHeaders(paused) {
const normalized = new Map();
const headers = paused.request?.headers;
if (!headers || typeof headers !== 'object') {
return normalized;
}
for (const [name, value] of Object.entries(headers)) {
normalized.set(name.toLowerCase(), String(value));
}
return normalized;
}
function matchesHeaderMatchers(headerMatchers, paused) {
if (!headerMatchers.length) {
return true;
}
const normalizedHeaders = normalizePausedRequestHeaders(paused);
return headerMatchers.every((matcher) => {
const headerValue = normalizedHeaders.get(matcher.name.toLowerCase());
if (headerValue === undefined) {
return false;
}
if (matcher.valueIncludes && !headerValue.includes(matcher.valueIncludes)) {
return false;
}
if (matcher.valueRegex && !new RegExp(matcher.valueRegex).test(headerValue)) {
return false;
}
return true;
});
}
function matchesInterceptRule(rule, paused) {
if (rule.method && rule.method !== String(paused.request?.method || '').toUpperCase()) {
const requestMethod = String(paused.request?.method || '').toUpperCase();
const requestUrl = String(paused.request?.url || '');
const requestResourceType = String(paused.resourceType || '');
if (rule.method && rule.method !== requestMethod) {
return false;
}
if (rule.urlIncludes && !String(paused.request?.url || '').includes(rule.urlIncludes)) {
if (rule.urlIncludes && !requestUrl.includes(rule.urlIncludes)) {
return false;
}
if (rule.urlPattern && !matchesUrlPattern(rule.urlPattern, requestUrl)) {
return false;
}
if (rule.urlRegex && !new RegExp(rule.urlRegex).test(requestUrl)) {
return false;
}
if (rule.resourceType && rule.resourceType.toLowerCase() !== requestResourceType.toLowerCase()) {
return false;
}
if (!matchesHeaderMatchers(rule.headerMatchers || [], paused)) {
return false;
}
return true;
@@ -2626,7 +2789,7 @@ async function ensureInterceptSession(page) {
return;
}
const paused = message.params || {};
const matchedRule = getRulesForPage(page.id).find((rule) => matchesInterceptRule(rule, paused));
const matchedRule = getRulesForMatching(page.id).find((rule) => matchesInterceptRule(rule, paused));
const action = matchedRule ? matchedRule.action : 'continue';
if (action === 'fail') {
ws.send(
@@ -2644,7 +2807,7 @@ async function ensureInterceptSession(page) {
params: {
requestId: paused.requestId,
responseCode: matchedRule.statusCode,
responseHeaders: matchedRule.headers,
responseHeaders: matchedRule.responseHeaders,
body: encodeFulfillBody(matchedRule.body),
},
})
@@ -2772,25 +2935,40 @@ async function handleAddInterceptRule(toolArgs) {
}
const urlIncludes = parseOptionalUrlIncludes(toolArgs.urlIncludes);
const method = parseOptionalMethod(toolArgs.method);
const resourceType = parseOptionalResourceType(toolArgs.resourceType);
const urlPattern = parseOptionalUrlPattern(toolArgs.urlPattern);
const urlRegex = parseOptionalUrlRegex(toolArgs.urlRegex);
const headerMatchers = parseOptionalHeaderMatchers(toolArgs.headerMatchers);
const priority = parseOptionalPriority(toolArgs.priority);
const action = parseInterceptAction(toolArgs.action);
if (!urlIncludes && !method) {
throw new Error('urlIncludes or method is required');
}
validateInterceptMatcherSet({
urlIncludes,
method,
resourceType,
urlPattern,
urlRegex,
headerMatchers,
});
const statusCode = parseOptionalStatusCode(toolArgs.statusCode);
const contentType =
toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType');
const body = parseOptionalBody(toolArgs.body);
const headers = parseOptionalHeaders(toolArgs.headers, contentType);
const responseHeaders = parseOptionalResponseHeaders(toolArgs.responseHeaders, contentType);
const rule = {
ruleId: createInterceptRuleId(),
pageId: page.id,
pageTitleSnapshot: page.title || '',
urlIncludes,
method,
resourceType,
urlPattern,
urlRegex,
headerMatchers,
priority,
action,
statusCode: action === 'fulfill' ? statusCode : 0,
contentType: action === 'fulfill' ? contentType : '',
headers: action === 'fulfill' ? headers : [],
responseHeaders: action === 'fulfill' ? responseHeaders : [],
body: action === 'fulfill' ? body : '',
createdAt: new Date().toISOString(),
};