1
0

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:
2026-05-10 00:10:42 +08:00
parent 57d3a5cfb4
commit 599d973cbd
22 changed files with 923 additions and 80 deletions

View 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;
}

View File

@@ -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;
}

View File

@@ -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();
}

View File

@@ -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 {