mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-02 22:17:14 +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' },
|
pageId: { type: 'string' },
|
||||||
urlIncludes: { type: 'string' },
|
urlIncludes: { type: 'string' },
|
||||||
method: { 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'] },
|
action: { type: 'string', enum: ['continue', 'fail', 'fulfill'] },
|
||||||
statusCode: { type: 'integer', minimum: 100, maximum: 599 },
|
statusCode: { type: 'integer', minimum: 100, maximum: 599 },
|
||||||
headers: {
|
responseHeaders: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
items: {
|
items: {
|
||||||
type: 'object',
|
type: 'object',
|
||||||
@@ -832,19 +849,19 @@ function parseOptionalStatusCode(value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseOptionalHeaders(value, contentType) {
|
function parseOptionalResponseHeaders(value, contentType) {
|
||||||
const headers = [];
|
const headers = [];
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
for (const entry of value) {
|
for (const entry of value) {
|
||||||
if (!entry || typeof entry !== 'object') {
|
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 ?? '');
|
const headerValue = String(entry.value ?? '');
|
||||||
headers.push({ name, value: headerValue });
|
headers.push({ name, value: headerValue });
|
||||||
}
|
}
|
||||||
} else if (value !== undefined) {
|
} 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')) {
|
if (contentType && !headers.some((header) => header.name.toLowerCase() === 'content-type')) {
|
||||||
headers.push({ name: 'Content-Type', value: contentType });
|
headers.push({ name: 'Content-Type', value: contentType });
|
||||||
@@ -880,6 +897,89 @@ function parseOptionalUrlIncludes(value) {
|
|||||||
return requireNonEmptyString(value, 'urlIncludes');
|
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() {
|
function createInterceptRuleId() {
|
||||||
return `rule-${nextInterceptRuleCounter++}`;
|
return `rule-${nextInterceptRuleCounter++}`;
|
||||||
}
|
}
|
||||||
@@ -920,6 +1020,11 @@ function formatInterceptRules(rules) {
|
|||||||
`pageTitle: ${rule.pageTitleSnapshot || '<untitled>'}`,
|
`pageTitle: ${rule.pageTitleSnapshot || '<untitled>'}`,
|
||||||
`urlIncludes: ${rule.urlIncludes || '<any>'}`,
|
`urlIncludes: ${rule.urlIncludes || '<any>'}`,
|
||||||
`method: ${rule.method || '<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}`,
|
`action: ${rule.action}`,
|
||||||
`statusCode: ${rule.action === 'fulfill' ? rule.statusCode : 'n/a'}`,
|
`statusCode: ${rule.action === 'fulfill' ? rule.statusCode : 'n/a'}`,
|
||||||
`contentType: ${rule.action === 'fulfill' ? rule.contentType || '<none>' : '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);
|
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) {
|
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;
|
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 false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -2626,7 +2789,7 @@ async function ensureInterceptSession(page) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const paused = message.params || {};
|
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';
|
const action = matchedRule ? matchedRule.action : 'continue';
|
||||||
if (action === 'fail') {
|
if (action === 'fail') {
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -2644,7 +2807,7 @@ async function ensureInterceptSession(page) {
|
|||||||
params: {
|
params: {
|
||||||
requestId: paused.requestId,
|
requestId: paused.requestId,
|
||||||
responseCode: matchedRule.statusCode,
|
responseCode: matchedRule.statusCode,
|
||||||
responseHeaders: matchedRule.headers,
|
responseHeaders: matchedRule.responseHeaders,
|
||||||
body: encodeFulfillBody(matchedRule.body),
|
body: encodeFulfillBody(matchedRule.body),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -2772,25 +2935,40 @@ async function handleAddInterceptRule(toolArgs) {
|
|||||||
}
|
}
|
||||||
const urlIncludes = parseOptionalUrlIncludes(toolArgs.urlIncludes);
|
const urlIncludes = parseOptionalUrlIncludes(toolArgs.urlIncludes);
|
||||||
const method = parseOptionalMethod(toolArgs.method);
|
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);
|
const action = parseInterceptAction(toolArgs.action);
|
||||||
if (!urlIncludes && !method) {
|
validateInterceptMatcherSet({
|
||||||
throw new Error('urlIncludes or method is required');
|
urlIncludes,
|
||||||
}
|
method,
|
||||||
|
resourceType,
|
||||||
|
urlPattern,
|
||||||
|
urlRegex,
|
||||||
|
headerMatchers,
|
||||||
|
});
|
||||||
const statusCode = parseOptionalStatusCode(toolArgs.statusCode);
|
const statusCode = parseOptionalStatusCode(toolArgs.statusCode);
|
||||||
const contentType =
|
const contentType =
|
||||||
toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType');
|
toolArgs.contentType === undefined ? '' : requireNonEmptyString(toolArgs.contentType, 'contentType');
|
||||||
const body = parseOptionalBody(toolArgs.body);
|
const body = parseOptionalBody(toolArgs.body);
|
||||||
const headers = parseOptionalHeaders(toolArgs.headers, contentType);
|
const responseHeaders = parseOptionalResponseHeaders(toolArgs.responseHeaders, contentType);
|
||||||
const rule = {
|
const rule = {
|
||||||
ruleId: createInterceptRuleId(),
|
ruleId: createInterceptRuleId(),
|
||||||
pageId: page.id,
|
pageId: page.id,
|
||||||
pageTitleSnapshot: page.title || '',
|
pageTitleSnapshot: page.title || '',
|
||||||
urlIncludes,
|
urlIncludes,
|
||||||
method,
|
method,
|
||||||
|
resourceType,
|
||||||
|
urlPattern,
|
||||||
|
urlRegex,
|
||||||
|
headerMatchers,
|
||||||
|
priority,
|
||||||
action,
|
action,
|
||||||
statusCode: action === 'fulfill' ? statusCode : 0,
|
statusCode: action === 'fulfill' ? statusCode : 0,
|
||||||
contentType: action === 'fulfill' ? contentType : '',
|
contentType: action === 'fulfill' ? contentType : '',
|
||||||
headers: action === 'fulfill' ? headers : [],
|
responseHeaders: action === 'fulfill' ? responseHeaders : [],
|
||||||
body: action === 'fulfill' ? body : '',
|
body: action === 'fulfill' ? body : '',
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user