#!/usr/bin/env python3 """评估用户手动成功验证码样本是否足够形成成功基线。""" from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any def configure_utf8_stdio() -> None: for stream in (sys.stdout, sys.stderr): if hasattr(stream, "reconfigure"): stream.reconfigure(encoding="utf-8") configure_utf8_stdio() MIN_TOTAL_SUCCESS_SAMPLES = 5 MIN_SUCCESS_SAMPLES_PER_TYPE = 2 def load_json_source(value: str) -> Any: stripped = value.lstrip() if stripped.startswith("{") or stripped.startswith("["): return json.loads(value) path = Path(value) try: if path.exists() and path.is_file(): return json.loads(path.read_text(encoding="utf-8")) except OSError: pass return json.loads(value) def get_first(mapping: dict[str, Any], names: list[str], default: Any = None) -> Any: for name in names: if name in mapping and mapping[name] not in (None, ""): return mapping[name] return default def normalize_samples(data: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]: if isinstance(data, list): return [item for item in data if isinstance(item, dict)], {} if isinstance(data, dict): for key in ("success_samples", "samples", "manual_success_samples"): value = data.get(key) if isinstance(value, list): defaults = {name: item for name, item in data.items() if name != key} return [item for item in value if isinstance(item, dict)], defaults return [data], {} raise ValueError("success baseline JSON 必须是数组,或包含 success_samples/samples 数组的对象") def is_success(sample: dict[str, Any]) -> bool: if isinstance(sample.get("success"), bool): return bool(sample["success"]) status = str(get_first(sample, ["status", "result", "outcome"], "")).strip().lower() return status in {"success", "succeeded", "passed", "pass", "ok", "成功", "通过"} def normalize_type(value: Any) -> str: text = str(value or "").strip() return text or "unknown-custom" def sample_context(sample: dict[str, Any], defaults: dict[str, Any]) -> dict[str, str]: return { "authorization_scope": str(get_first(sample, ["authorization_scope", "scope", "target"], get_first(defaults, ["authorization_scope", "scope", "target"], "未声明授权目标"))), "provider": str(get_first(sample, ["provider"], get_first(defaults, ["provider"], "custom-or-unknown"))), "captcha_type": normalize_type(get_first(sample, ["captcha_type", "type"], get_first(defaults, ["captcha_type", "type"], "unknown-custom"))), "captcha_variant": str(get_first(sample, ["captcha_variant", "variant"], get_first(defaults, ["captcha_variant", "variant"], ""))), } def evaluate(data: Any) -> dict[str, Any]: samples, defaults = normalize_samples(data) success_samples = [sample for sample in samples if is_success(sample)] contexts = [sample_context(sample, defaults) for sample in success_samples] by_type: dict[str, int] = {} by_variant: dict[str, int] = {} scopes: dict[str, int] = {} providers: dict[str, int] = {} for context in contexts: captcha_type = context["captcha_type"] variant_key = captcha_type if context["captcha_variant"]: variant_key = f"{captcha_type}/{context['captcha_variant']}" by_type[captcha_type] = by_type.get(captcha_type, 0) + 1 by_variant[variant_key] = by_variant.get(variant_key, 0) + 1 scopes[context["authorization_scope"]] = scopes.get(context["authorization_scope"], 0) + 1 providers[context["provider"]] = providers.get(context["provider"], 0) + 1 missing: list[dict[str, Any]] = [] total_success = len(success_samples) if total_success < MIN_TOTAL_SUCCESS_SAMPLES: missing.append( { "scope": "total", "required": MIN_TOTAL_SUCCESS_SAMPLES, "actual": total_success, "missing": MIN_TOTAL_SUCCESS_SAMPLES - total_success, } ) for captcha_type, count in sorted(by_type.items()): if count < MIN_SUCCESS_SAMPLES_PER_TYPE: missing.append( { "scope": "captcha_type", "captcha_type": captcha_type, "required": MIN_SUCCESS_SAMPLES_PER_TYPE, "actual": count, "missing": MIN_SUCCESS_SAMPLES_PER_TYPE - count, } ) if success_samples and not by_type: missing.append( { "scope": "captcha_type", "required": MIN_SUCCESS_SAMPLES_PER_TYPE, "actual": 0, "missing": MIN_SUCCESS_SAMPLES_PER_TYPE, "reason": "成功样本缺少 captcha_type,无法判断动态验证码覆盖。", } ) status = "sufficient" if success_samples and not missing else "insufficient" dynamic_switch = len(by_type) > 1 return { "success_baseline_status": status, "success_baseline_summary": { "total_samples": len(samples), "total_success_samples": total_success, "min_total_success_samples": MIN_TOTAL_SUCCESS_SAMPLES, "min_success_samples_per_type": MIN_SUCCESS_SAMPLES_PER_TYPE, "observed_captcha_types": sorted(by_type), "success_samples_by_type": dict(sorted(by_type.items())), "success_samples_by_type_variant": dict(sorted(by_variant.items())), "authorization_scopes": dict(sorted(scopes.items())), "providers": dict(sorted(providers.items())), "has_dynamic_type_switch": dynamic_switch, }, "missing_success_samples": missing, "recommended_next_route": "ready-for-verification-flow" if status == "sufficient" else "collect-more-manual-success-samples", "collection_plan": { "role": "建立真实成功基线,用于对照本地方案、平台方案和失败复盘。", "default_target": "每个授权目标至少 5 次用户手动成功样本;出现新验证码类型时,该类型至少 2 次成功样本。", "required_evidence": [ "成功前后截图或截图元信息", "验证码区域 HTML/DOM 摘要", "脚本/iframe URL", "关键请求摘要", "captcha_type/provider/captcha_variant", "成功 UI、callback、response token 或服务端成功状态", "challenge id、时间线和刷新/切题信息", ], }, "requires_user_confirmation": [ "成功基线不足时,必须明确提示风险;用户确认后仍可继续离线分析或受控验证。", "采集真实网页成功样本前,必须确认授权目标和浏览器取证模式。", ], "send_request": False, } def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="评估用户手动成功验证码样本基线") parser.add_argument("--samples", required=True, help="成功样本 JSON 文件路径,或直接传 JSON 字符串") parser.add_argument("--pretty", action="store_true", help="以缩进格式输出 JSON") parser.add_argument("--json", action="store_true", help="兼容参数;脚本始终输出 JSON") return parser def main() -> int: args = build_parser().parse_args() report = evaluate(load_json_source(args.samples)) print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None)) return 0 if __name__ == "__main__": raise SystemExit(main())