mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 12:15:57 +00:00
feat(browser): 增加 richer request matching 能力
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
cb415b4127
commit
bd073acf47
+192
-14
@@ -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(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user