feat: 添加算术验证码与字符验证码处理能力

新增算术验证码解析、字符清洗能力及测试文件,注册OCR_CalculationCode和OCR_CommonEnglishNumbersCode两个新能力,补充验证码处理逻辑
This commit is contained in:
yu committed 2026-11-05 16:48:20 +08:00
1 parent 902ee0cec4
commit 5ef797dcc0
6 files changed
+344 -96

No files matched your search

@@ -0,0 +1,56 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./remaining-core";
import "./captcha-caps"; // last-wins:覆盖 remaining-core 的通用实现
import { cleanCodeText, parseCalculation } from "./captcha-caps";
const ctx = { signal: new AbortController().signal, log: () => {}, vars: {}, evidenceDir: "D:/office-test2/ocr-test" } as unknown as RunContext;
const node: FlowNode = { id: "smoke", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} };
describe("parseCalculation 解析层(纯逻辑)", () => {
it("基础四则与等号问号", () => {
expect(parseCalculation("3+5=?")).toEqual({ expression: "3+5", result: 8 });
});
it("x/X/* 归一为乘", () => {
expect(parseCalculation("6x4=?")!.result).toBe(24);
expect(parseCalculation("9X9=?")!.result).toBe(81);
});
it("全角与杂字符", () => {
expect(parseCalculation("12+7=?")!.result).toBe(19);
});
it("长破折号/Unicode 减号", () => {
expect(parseCalculation("12—7")!.result).toBe(5);
});
it("除法整除与小数", () => {
expect(parseCalculation("8/2")!.result).toBe(4);
expect(parseCalculation("7/2")!.result).toBe(3.5);
});
it("无算式返回 null", () => {
expect(parseCalculation("ab12cd")).toBeNull();
});
it("字符验证码清洗", () => {
expect(cleanCodeText("a b 7 X ? = ")).toBe("ab7X");
});
});
describe.skipIf(!existsSync("D:/office-test2/ocr-test/captcha_0_3p5_q.png"))("OCR_CalculationCode 端到端(真实模型)", () => {
const cases: Array<[string, string]> = [
["captcha_0_3p5_q", "8"],
["captcha_1_12m7_q", "5"],
["captcha_2_6t4_q", "24"],
["captcha_3_15p28_q", "43"],
["captcha_4_9t9_q", "81"],
];
for (const [file, expected] of cases) {
it(`${file} -> ${expected}`, async () => {
const handler = getHandler("OCR_CalculationCode");
expect(handler).toBeTruthy();
const out = await handler!(ctx, node, { path: `D:/office-test2/ocr-test/${file}.png` }, {} as CatalogComponent);
console.log(`[${file}]`, String(out));
expect(String(out)).toBe(expected);
}, 300000);
}
});
@@ -0,0 +1,82 @@
/**
* 验证码类 OCR 专门实现。
*
* 此前 OCR_CalculationCode / OCR_CommonEnglishNumbersCode 与其他 30 个 OCR
* 组件共用通用实现(remaining-core 的 ocrImage),只返回整图 OCR 原文:
* - 算术验证码返回的是 "3+5=?" 这样的算式原文,没有计算结果,流程无法直接填入答案;
* - 真实验证码的干扰线/噪点会让原文混入杂字符,符号常被认成 x/X/*、全角等。
*
* 这里提供专门处理:OCR 原文 -> 字符归一化 -> 算式解析 -> 计算结果。
* 解析失败时返回清洗后的原文并记 warning,不让节点直接失败(调用方可自行判断)。
*/
import { getHandler, register } from "../engine/registry";
import { RuntimeError, type RunContext } from "../engine/types";
import type { CatalogComponent, FlowNode } from "../types";
interface CalculationParse {
expression: string;
result: number;
}
/**
* 从 OCR 原文中解析算术算式。
* 归一化范围:全角数字/符号、常见运算符误认(x/X/* -> 乘,全角+-×÷,长破折号 -> 减)。
*/
export function parseCalculation(raw: string): CalculationParse | null {
const s = String(raw ?? "")
.replace(/[0-9]/g, (c) => String.fromCharCode(c.charCodeAt(0) - 0xfee0))
.replace(/+/g, "+")
.replace(/[-―—–−]/g, "-")
.replace(/[×✕✖xX*X*]/g, "*")
.replace(/[÷/]/g, "/")
.replace(/[==??。,,、\s]/g, "");
const match = s.match(/(\d+)([+\-*/])(\d+)/);
if (!match) return null;
const a = Number(match[1]);
const b = Number(match[3]);
const op = match[2];
let result: number;
if (op === "+") result = a + b;
else if (op === "-") result = a - b;
else if (op === "*") result = a * b;
else {
if (b === 0) return null;
result = a / b;
}
if (!Number.isFinite(result)) return null;
result = Math.round(result * 100) / 100;
const prettyOp = op === "*" ? "×" : op === "/" ? "÷" : op;
return { expression: `${a}${prettyOp}${b}`, result };
}
/** 普通字符验证码:去除空白与常见杂字符,保留字母数字 */
export function cleanCodeText(raw: string): string {
return String(raw ?? "")
.replace(/\s+/g, "")
.replace(/[==??.,,、;;:"'()()\[\]{}]/g, "");
}
async function ocrRaw(args: Record<string, unknown>, node: FlowNode, ctx: RunContext): Promise<string> {
// 复用通用 OCR(remaining-core 已注册的 OCR_GeneralTextV2)拿原文,避免重复加载模型
const base = getHandler("OCR_GeneralTextV2");
if (!base) throw new RuntimeError("capability_not_implemented", "通用 OCR 能力不可用", { nodeId: node.id, componentId: node.componentId });
return String(await base(ctx, node, args, {} as CatalogComponent));
}
register("OCR_CalculationCode", async (ctx, node, args) => {
const raw = await ocrRaw(args, node, ctx);
const parsed = parseCalculation(raw);
if (!parsed) {
await ctx.log(`算术验证码未能解析出算式(原文:${raw}),已返回清洗原文`, "warn", node.id);
return raw.replace(/\s+/g, "");
}
await ctx.log(`算术验证码:${parsed.expression} = ${parsed.result}(OCR 原文:${raw})`, "info", node.id);
return String(parsed.result);
});
register("OCR_CommonEnglishNumbersCode", async (ctx, node, args) => {
const raw = await ocrRaw(args, node, ctx);
const cleaned = cleanCodeText(raw);
await ctx.log(`字符验证码:${cleaned}(OCR 原文:${raw})`, "info", node.id);
return cleaned;
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./remaining-core";
const ctx = { signal: new AbortController().signal, log: () => {}, vars: {}, evidenceDir: "D:/office-test2/ocr-test" } as unknown as RunContext;
const node: FlowNode = { id: "smoke", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} };
describe("算术验证码现状侦察", () => {
const files = ["captcha_0_3p5_q", "captcha_1_12m7_q", "captcha_2_6t4_q", "captcha_3_15p28_q", "captcha_4_9t9_q"];
it("当前 OCR 原始输出", async () => {
for (const f of files) {
const handler = getHandler("OCR_CalculationCode");
const out = await handler!(ctx, node, { path: `D:/office-test2/ocr-test/${f}.png` }, {} as CatalogComponent);
console.log(`[${f}]`, JSON.stringify(out));
}
expect(true).toBe(true);
}, 600000);
});
@@ -36,6 +36,7 @@ import "./data-table-common";
import "./basic-encryption";
import "./mail-remaining";
import "./remaining-core";
import "./captcha-caps";
import "./common-final";
import "./human-dialog";
import "./ai-service";