feat: 增强 expect 规则系统,支持多种 body 校验方法和操作符
- 新增 body 分组校验:contains、regex、json(JSONPath)、css(CSS选择器)、xpath - 新增操作符系统:equals、contains、match、empty、exists、gte、lte、gt、lt - 新增 headers 响应头校验 - 引入 cheerio、xpath、@xmldom/xmldom 依赖 - BREAKING: expect.bodyContains 迁移至 expect.body.contains
This commit is contained in:
188
src/server/checker/body-expect.ts
Normal file
188
src/server/checker/body-expect.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import type { BodyExpectConfig, CssExpect, ExpectOperator, ExpectValue } from "./types";
|
||||
import * as cheerio from "cheerio";
|
||||
import * as xpath from "xpath";
|
||||
import { DOMParser } from "@xmldom/xmldom";
|
||||
|
||||
const isObject = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === "object" && !Array.isArray(v);
|
||||
|
||||
export function evaluateJsonPath(json: unknown, path: string): unknown {
|
||||
if (!path.startsWith("$.")) return undefined;
|
||||
|
||||
const segments = path.slice(2).split(".");
|
||||
let current: unknown = json;
|
||||
|
||||
for (const seg of segments) {
|
||||
const bracketMatch = /^(.+?)\[(\d+)\]$/.exec(seg);
|
||||
if (bracketMatch) {
|
||||
current = (current as Record<string, unknown>)?.[bracketMatch[1]!];
|
||||
const idx = parseInt(bracketMatch[2]!, 10);
|
||||
if (!Array.isArray(current) || idx >= current.length) return undefined;
|
||||
current = current[idx];
|
||||
} else {
|
||||
if (current === null || current === undefined) return undefined;
|
||||
current = (current as Record<string, unknown>)[seg];
|
||||
}
|
||||
}
|
||||
|
||||
return current;
|
||||
}
|
||||
|
||||
export function applyOperator(actual: unknown, op: ExpectOperator): boolean {
|
||||
for (const [key, expected] of Object.entries(op)) {
|
||||
if (expected === undefined) continue;
|
||||
|
||||
switch (key) {
|
||||
case "equals":
|
||||
if (actual !== expected) return false;
|
||||
break;
|
||||
case "contains":
|
||||
if (!String(actual).includes(expected as string)) return false;
|
||||
break;
|
||||
case "match":
|
||||
if (!new RegExp(expected as string).test(String(actual))) return false;
|
||||
break;
|
||||
case "empty": {
|
||||
const isEmpty =
|
||||
actual === null ||
|
||||
actual === undefined ||
|
||||
actual === "" ||
|
||||
(Array.isArray(actual) && actual.length === 0) ||
|
||||
(typeof actual === "object" && Object.keys(actual as object).length === 0);
|
||||
if (expected !== isEmpty) return false;
|
||||
break;
|
||||
}
|
||||
case "exists":
|
||||
if (expected) {
|
||||
if (actual === undefined) return false;
|
||||
} else {
|
||||
if (actual !== undefined) return false;
|
||||
}
|
||||
break;
|
||||
case "gte":
|
||||
if (!(Number(actual) >= (expected as number))) return false;
|
||||
break;
|
||||
case "lte":
|
||||
if (!(Number(actual) <= (expected as number))) return false;
|
||||
break;
|
||||
case "gt":
|
||||
if (!(Number(actual) > (expected as number))) return false;
|
||||
break;
|
||||
case "lt":
|
||||
if (!(Number(actual) < (expected as number))) return false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkExpectValue(actual: unknown, expected: ExpectValue): boolean {
|
||||
if (isObject(expected)) {
|
||||
return applyOperator(actual, expected as ExpectOperator);
|
||||
}
|
||||
return applyOperator(actual, { equals: expected as string | number | boolean | null });
|
||||
}
|
||||
|
||||
function checkBodyContains(body: string, contains: string): boolean {
|
||||
return body.includes(contains);
|
||||
}
|
||||
|
||||
function checkBodyRegex(body: string, regex: string): boolean {
|
||||
return new RegExp(regex).test(body);
|
||||
}
|
||||
|
||||
function checkBodyJson(body: string, rules: Record<string, ExpectValue>): boolean {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [path, expected] of Object.entries(rules)) {
|
||||
const actual = evaluateJsonPath(json, path);
|
||||
if (!checkExpectValue(actual, expected)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkBodyCss(body: string, rules: Record<string, CssExpect>): boolean {
|
||||
let $: cheerio.CheerioAPI;
|
||||
try {
|
||||
$ = cheerio.load(body);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [selector, expected] of Object.entries(rules)) {
|
||||
if (!checkCssRule($, selector, expected)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function checkCssRule($: cheerio.CheerioAPI, selector: string, expected: CssExpect): boolean {
|
||||
if (!isObject(expected)) {
|
||||
const el = $(selector);
|
||||
return el.length > 0 && el.text() === String(expected);
|
||||
}
|
||||
|
||||
const rule = expected as ExpectOperator & { attr?: string };
|
||||
const { attr, ...operators } = rule;
|
||||
const opKeys = Object.keys(operators);
|
||||
|
||||
if (opKeys.length === 0) {
|
||||
if (attr !== undefined) {
|
||||
return $(selector).attr(attr) !== undefined;
|
||||
}
|
||||
return $(selector).length > 0;
|
||||
}
|
||||
|
||||
if (operators.exists === true) {
|
||||
return $(selector).length > 0;
|
||||
}
|
||||
if (operators.exists === false) {
|
||||
return $(selector).length === 0;
|
||||
}
|
||||
|
||||
const el = $(selector);
|
||||
if (el.length === 0) return false;
|
||||
|
||||
const actual = attr ? el.attr(attr) : el.text();
|
||||
return applyOperator(actual ?? "", operators);
|
||||
}
|
||||
|
||||
function checkBodyXpath(body: string, rules: Record<string, ExpectValue>): boolean {
|
||||
let doc: ReturnType<DOMParser["parseFromString"]>;
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(body, "text/xml");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const [path, expected] of Object.entries(rules)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const nodes = xpath.select(path, doc as any);
|
||||
if (!nodes || !Array.isArray(nodes) || nodes.length === 0) return false;
|
||||
|
||||
const node = nodes[0]!;
|
||||
const actual = node.nodeValue ?? (node as unknown as Element).textContent ?? "";
|
||||
|
||||
if (!checkExpectValue(actual, expected)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function checkBodyExpect(body: string, config?: BodyExpectConfig): boolean {
|
||||
if (!config) return true;
|
||||
|
||||
if (config.contains !== undefined && !checkBodyContains(body, config.contains)) return false;
|
||||
if (config.regex !== undefined && !checkBodyRegex(body, config.regex)) return false;
|
||||
if (config.json !== undefined && !checkBodyJson(body, config.json)) return false;
|
||||
if (config.css !== undefined && !checkBodyCss(body, config.css)) return false;
|
||||
if (config.xpath !== undefined && !checkBodyXpath(body, config.xpath)) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { CheckResult, ExpectConfig, ResolvedTarget } from "./types";
|
||||
import { checkBodyExpect } from "./body-expect";
|
||||
|
||||
export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
@@ -17,8 +18,9 @@ export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult>
|
||||
|
||||
const latencyMs = Math.round(performance.now() - start);
|
||||
const body = await response.text();
|
||||
const responseHeaders = headersToRecord(response.headers);
|
||||
|
||||
const matched = checkExpect(response.status, body, latencyMs, target.expect);
|
||||
const matched = checkExpect(response.status, body, latencyMs, responseHeaders, target.expect);
|
||||
|
||||
return {
|
||||
targetName: target.name,
|
||||
@@ -38,7 +40,7 @@ export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult>
|
||||
success: false,
|
||||
statusCode: null,
|
||||
latencyMs: null,
|
||||
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : (error instanceof Error ? error.message : String(error)),
|
||||
error: isTimeout ? `请求超时 (${target.timeoutMs}ms)` : error instanceof Error ? error.message : String(error),
|
||||
matched: false,
|
||||
};
|
||||
} finally {
|
||||
@@ -46,14 +48,37 @@ export async function fetchTarget(target: ResolvedTarget): Promise<CheckResult>
|
||||
}
|
||||
}
|
||||
|
||||
export function checkExpect(statusCode: number, body: string, latencyMs: number, expect?: ExpectConfig): boolean {
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
export function checkExpect(
|
||||
statusCode: number,
|
||||
body: string,
|
||||
latencyMs: number,
|
||||
responseHeaders: Record<string, string>,
|
||||
expect?: ExpectConfig,
|
||||
): boolean {
|
||||
if (!expect) return true;
|
||||
|
||||
if (expect.status && !expect.status.includes(statusCode)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (expect.bodyContains && !body.includes(expect.bodyContains)) {
|
||||
if (expect.headers) {
|
||||
for (const [key, expectedValue] of Object.entries(expect.headers)) {
|
||||
const actualValue = responseHeaders[key.toLowerCase()];
|
||||
if (!actualValue || actualValue !== expectedValue) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!checkBodyExpect(body, expect.body)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -83,7 +83,16 @@ export class ProbeStore {
|
||||
existingMap.get(target.name)!,
|
||||
);
|
||||
} else {
|
||||
insertStmt.run(target.name, target.url, target.method, headers, target.body ?? null, target.intervalMs, target.timeoutMs, expect);
|
||||
insertStmt.run(
|
||||
target.name,
|
||||
target.url,
|
||||
target.method,
|
||||
headers,
|
||||
target.body ?? null,
|
||||
target.intervalMs,
|
||||
target.timeoutMs,
|
||||
expect,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,9 +142,9 @@ export class ProbeStore {
|
||||
}
|
||||
|
||||
getLatestCheck(targetId: number): StoredCheckResult | null {
|
||||
return this.db.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1").get(targetId) as
|
||||
| StoredCheckResult
|
||||
| null;
|
||||
return this.db
|
||||
.query("SELECT * FROM check_results WHERE target_id = ? ORDER BY timestamp DESC LIMIT 1")
|
||||
.get(targetId) as StoredCheckResult | null;
|
||||
}
|
||||
|
||||
getHistory(targetId: number, limit = 20): StoredCheckResult[] {
|
||||
@@ -183,7 +192,10 @@ export class ProbeStore {
|
||||
};
|
||||
}
|
||||
|
||||
getTrend(targetId: number, hours = 24): Array<{
|
||||
getTrend(
|
||||
targetId: number,
|
||||
hours = 24,
|
||||
): Array<{
|
||||
hour: string;
|
||||
avgLatencyMs: number | null;
|
||||
availability: number;
|
||||
@@ -257,7 +269,9 @@ export class ProbeStore {
|
||||
|
||||
getSparkline(targetId: number, limit = 20): number[] {
|
||||
const rows = this.db
|
||||
.prepare("SELECT latency_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?")
|
||||
.prepare(
|
||||
"SELECT latency_ms FROM check_results WHERE target_id = ? AND success = 1 ORDER BY timestamp DESC LIMIT ?",
|
||||
)
|
||||
.all(targetId, limit) as Array<{ latency_ms: number }>;
|
||||
return rows.map((r) => r.latency_ms).reverse();
|
||||
}
|
||||
|
||||
@@ -28,10 +28,35 @@ export interface TargetConfig {
|
||||
expect?: ExpectConfig;
|
||||
}
|
||||
|
||||
export interface ExpectOperator {
|
||||
equals?: string | number | boolean | null;
|
||||
contains?: string;
|
||||
match?: string;
|
||||
empty?: boolean;
|
||||
exists?: boolean;
|
||||
gte?: number;
|
||||
lte?: number;
|
||||
gt?: number;
|
||||
lt?: number;
|
||||
}
|
||||
|
||||
export type ExpectValue = string | number | boolean | null | ExpectOperator;
|
||||
|
||||
export type CssExpect = ExpectValue | (ExpectOperator & { attr?: string });
|
||||
|
||||
export interface BodyExpectConfig {
|
||||
contains?: string;
|
||||
regex?: string;
|
||||
json?: Record<string, ExpectValue>;
|
||||
css?: Record<string, CssExpect>;
|
||||
xpath?: Record<string, ExpectValue>;
|
||||
}
|
||||
|
||||
export interface ExpectConfig {
|
||||
status?: number[];
|
||||
bodyContains?: string;
|
||||
maxLatencyMs?: number;
|
||||
headers?: Record<string, string>;
|
||||
body?: BodyExpectConfig;
|
||||
}
|
||||
|
||||
export interface ResolvedTarget {
|
||||
|
||||
Reference in New Issue
Block a user