484 lines
19 KiB
Python
Executable File
484 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""验证第二阶段辅助脚本和参考文件是否可用。"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
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()
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
SKILL_DIR = SCRIPT_DIR.parent
|
|
|
|
|
|
def run_json(args: list[str]) -> dict[str, Any]:
|
|
completed = subprocess.run(
|
|
[sys.executable, "-X", "utf8", *args],
|
|
cwd=str(SKILL_DIR),
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
encoding="utf-8",
|
|
)
|
|
return json.loads(completed.stdout)
|
|
|
|
|
|
def check_references() -> dict[str, Any]:
|
|
required = {
|
|
"references/verification-workflow.md": ["进入条件", "动作分级", "真实网页", "成功样本基线", "evaluate_success_baseline.py", "5 次失败复盘门槛", "evaluate_verification_attempts.py"],
|
|
"references/browser-acquisition.md": ["用户手动成功样本", "至少 5 次成功样本", "每个新类型至少补到 2 次成功样本"],
|
|
"references/open-source-recipes.md": ["ddddocr", "OpenCV", "Whisper", "切片乱序", "analyze_tile_restore.py"],
|
|
"references/solver-platform-recipes.md": ["请求模板", "API key", "不默认发送", "授权 QA 对照"],
|
|
"references/motion-and-coordinate.md": ["坐标体系", "滑块轨迹", "真实网页执行前检查"],
|
|
"references/provider-execution-notes.md": ["极验", "Turnstile", "WAF"],
|
|
}
|
|
missing: list[str] = []
|
|
for relative, snippets in required.items():
|
|
path = SKILL_DIR / relative
|
|
if not path.exists():
|
|
missing.append(relative)
|
|
continue
|
|
content = path.read_text(encoding="utf-8")
|
|
for snippet in snippets:
|
|
if snippet not in content:
|
|
missing.append(f"{relative}:{snippet}")
|
|
return {"passed": not missing, "missing": missing}
|
|
|
|
|
|
def make_original_image(path: Path, rows: int, cols: int, tile_size: int) -> None:
|
|
from PIL import Image, ImageDraw
|
|
|
|
image = Image.new("RGB", (cols * tile_size, rows * tile_size), "white")
|
|
draw = ImageDraw.Draw(image)
|
|
for index in range(rows * cols):
|
|
row, col = divmod(index, cols)
|
|
left = col * tile_size
|
|
top = row * tile_size
|
|
color = (
|
|
30 + (index * 47) % 200,
|
|
40 + (index * 83) % 190,
|
|
50 + (index * 31) % 180,
|
|
)
|
|
draw.rectangle((left, top, left + tile_size - 1, top + tile_size - 1), fill=color)
|
|
draw.line((left, top, left + tile_size - 1, top + tile_size - 1), fill=(255, 255, 255), width=2)
|
|
draw.text((left + 3, top + 3), str(index), fill=(0, 0, 0))
|
|
image.save(path)
|
|
|
|
|
|
def scramble_image(original: Path, output: Path, rows: int, cols: int, tile_size: int, order_source_by_target: list[int]) -> None:
|
|
from PIL import Image
|
|
|
|
source = Image.open(original).convert("RGB")
|
|
tiles = []
|
|
for row in range(rows):
|
|
for col in range(cols):
|
|
left = col * tile_size
|
|
top = row * tile_size
|
|
tiles.append(source.crop((left, top, left + tile_size, top + tile_size)))
|
|
|
|
scrambled = Image.new("RGB", source.size)
|
|
for target_index, current_index in enumerate(order_source_by_target):
|
|
row, col = divmod(current_index, cols)
|
|
scrambled.paste(tiles[target_index], (col * tile_size, row * tile_size))
|
|
scrambled.save(output)
|
|
|
|
|
|
def images_equal(left: Path, right: Path) -> bool:
|
|
from PIL import Image, ImageChops
|
|
|
|
left_image = Image.open(left).convert("RGB")
|
|
right_image = Image.open(right).convert("RGB")
|
|
return ImageChops.difference(left_image, right_image).getbbox() is None
|
|
|
|
|
|
def check_tile_restore() -> dict[str, Any]:
|
|
try:
|
|
import PIL # noqa: F401
|
|
except ImportError:
|
|
return {"passed": False, "missing": ["Pillow"], "details": {}}
|
|
|
|
details: dict[str, Any] = {}
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
tmp_dir = Path(tmp)
|
|
|
|
cases = [
|
|
("3x3", 3, 3, 18, [1, 2, 0, 4, 5, 3, 7, 8, 6]),
|
|
("4x2", 4, 2, 18, [1, 0, 3, 2, 5, 4, 7, 6]),
|
|
]
|
|
for name, rows, cols, tile_size, order in cases:
|
|
original = tmp_dir / f"original-{name}.png"
|
|
scrambled = tmp_dir / f"scrambled-{name}.png"
|
|
restored = tmp_dir / f"restored-{name}.png"
|
|
make_original_image(original, rows, cols, tile_size)
|
|
scramble_image(original, scrambled, rows, cols, tile_size, order)
|
|
report = run_json(
|
|
[
|
|
"scripts/analyze_tile_restore.py",
|
|
"--image",
|
|
str(scrambled),
|
|
"--rows",
|
|
str(rows),
|
|
"--cols",
|
|
str(cols),
|
|
"--order-source-by-target",
|
|
",".join(str(item) for item in order),
|
|
"--output-image",
|
|
str(restored),
|
|
]
|
|
)
|
|
details[name] = {
|
|
"strategy": report["restore_strategy"],
|
|
"confidence": report["confidence"],
|
|
"image_equal": images_equal(original, restored),
|
|
"order": report["order_source_by_target"],
|
|
}
|
|
|
|
original = tmp_dir / "original-2x2.png"
|
|
scrambled = tmp_dir / "scrambled-2x2.png"
|
|
make_original_image(original, 2, 2, 24)
|
|
scramble_image(original, scrambled, 2, 2, 24, [1, 0, 3, 2])
|
|
image_only = run_json(
|
|
[
|
|
"scripts/analyze_tile_restore.py",
|
|
"--image",
|
|
str(scrambled),
|
|
"--rows",
|
|
"2",
|
|
"--cols",
|
|
"2",
|
|
]
|
|
)
|
|
details["image-only"] = {
|
|
"strategy": image_only["restore_strategy"],
|
|
"confidence": image_only["confidence"],
|
|
"order_len": len(image_only["order_source_by_target"] or []),
|
|
}
|
|
|
|
flat = tmp_dir / "flat.png"
|
|
from PIL import Image
|
|
|
|
Image.new("RGB", (48, 48), (120, 120, 120)).save(flat)
|
|
flat_report = run_json(
|
|
[
|
|
"scripts/analyze_tile_restore.py",
|
|
"--image",
|
|
str(flat),
|
|
"--rows",
|
|
"2",
|
|
"--cols",
|
|
"2",
|
|
]
|
|
)
|
|
details["flat"] = {
|
|
"strategy": flat_report["restore_strategy"],
|
|
"confidence": flat_report["confidence"],
|
|
"warnings": flat_report["warnings"],
|
|
}
|
|
|
|
passed = (
|
|
details["3x3"]["strategy"] == "page-order"
|
|
and details["3x3"]["image_equal"] is True
|
|
and details["4x2"]["strategy"] == "page-order"
|
|
and details["4x2"]["image_equal"] is True
|
|
and details["image-only"]["order_len"] == 4
|
|
and isinstance(details["image-only"]["confidence"], (int, float))
|
|
and details["flat"]["strategy"] == "manual-or-platform"
|
|
and details["flat"]["confidence"] <= 0.42
|
|
)
|
|
return {"passed": passed, "details": details, "missing": []}
|
|
|
|
|
|
def write_attempts(path: Path, captcha_type: str, attempts: list[dict[str, Any]], captcha_variant: str = "") -> None:
|
|
data = {
|
|
"authorization_scope": "授权测试目标",
|
|
"captcha_type": captcha_type,
|
|
"captcha_variant": captcha_variant,
|
|
"provider": "geetest" if captcha_type == "slider" else "custom-or-unknown",
|
|
"chosen_solution": f"open-source-{captcha_type}",
|
|
"attempts": attempts,
|
|
}
|
|
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
def failed_attempt(status_overrides: dict[str, str] | None = None) -> dict[str, Any]:
|
|
diagnosis = {
|
|
"image": "ok",
|
|
"coordinates": "ok",
|
|
"track": "ok",
|
|
"tile_restore": "ok",
|
|
"browser_env": "ok",
|
|
"challenge_freshness": "ok",
|
|
}
|
|
if status_overrides:
|
|
diagnosis.update(status_overrides)
|
|
return {
|
|
"success": False,
|
|
"diagnosis_status": diagnosis,
|
|
"failure_reason": "服务端仍判定失败,离线诊断未发现明显异常",
|
|
}
|
|
|
|
|
|
def check_attempt_evaluation() -> dict[str, Any]:
|
|
details: dict[str, Any] = {}
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
tmp_dir = Path(tmp)
|
|
|
|
switch_path = tmp_dir / "switch.json"
|
|
write_attempts(switch_path, "slider", [failed_attempt() for _ in range(5)])
|
|
switch_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(switch_path)])
|
|
details["switch"] = {
|
|
"switch_triggered": switch_report["switch_triggered"],
|
|
"route": switch_report["recommended_next_route"],
|
|
"decision": switch_report["escalation_decision"],
|
|
"send_request": switch_report["send_request"],
|
|
}
|
|
|
|
below_path = tmp_dir / "below.json"
|
|
write_attempts(below_path, "slider", [failed_attempt() for _ in range(4)])
|
|
below_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(below_path)])
|
|
details["below_threshold"] = {
|
|
"switch_triggered": below_report["switch_triggered"],
|
|
"route": below_report["recommended_next_route"],
|
|
}
|
|
|
|
blocking_path = tmp_dir / "blocking.json"
|
|
write_attempts(blocking_path, "slider", [failed_attempt() for _ in range(4)] + [failed_attempt({"coordinates": "error"})])
|
|
blocking_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(blocking_path)])
|
|
details["blocking"] = {
|
|
"switch_triggered": blocking_report["switch_triggered"],
|
|
"blocking_issue": blocking_report["blocking_issue"],
|
|
"route": blocking_report["recommended_next_route"],
|
|
}
|
|
|
|
success_path = tmp_dir / "success.json"
|
|
success_attempts = [failed_attempt() for _ in range(4)] + [failed_attempt()]
|
|
success_attempts[2]["success"] = True
|
|
write_attempts(success_path, "slider", success_attempts)
|
|
success_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(success_path)])
|
|
details["success"] = {
|
|
"switch_triggered": success_report["switch_triggered"],
|
|
"route": success_report["recommended_next_route"],
|
|
}
|
|
|
|
tile_path = tmp_dir / "tile.json"
|
|
write_attempts(tile_path, "image-restore", [failed_attempt() for _ in range(5)], captcha_variant="tile-scramble")
|
|
tile_report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(tile_path)])
|
|
details["tile_scramble"] = {
|
|
"switch_triggered": tile_report["switch_triggered"],
|
|
"route": tile_report["recommended_next_route"],
|
|
"has_platform_candidates": bool(tile_report["platform_candidates"]),
|
|
}
|
|
|
|
special: dict[str, Any] = {}
|
|
for captcha_type in ["pow-challenge", "waf-challenge", "biometric-liveness"]:
|
|
path = tmp_dir / f"{captcha_type}.json"
|
|
write_attempts(path, captcha_type, [failed_attempt() for _ in range(5)])
|
|
report = run_json(["scripts/evaluate_verification_attempts.py", "--attempts", str(path)])
|
|
special[captcha_type] = {
|
|
"switch_triggered": report["switch_triggered"],
|
|
"decision": report["escalation_decision"],
|
|
"platform_candidates": report["platform_candidates"],
|
|
}
|
|
details["special_types"] = special
|
|
|
|
passed = (
|
|
details["switch"]["switch_triggered"] is True
|
|
and details["switch"]["route"] == "platform-control"
|
|
and details["switch"]["decision"] == "recommend-platform-control"
|
|
and details["switch"]["send_request"] is False
|
|
and details["below_threshold"]["switch_triggered"] is False
|
|
and details["blocking"]["switch_triggered"] is False
|
|
and details["blocking"]["blocking_issue"] == "coordinates"
|
|
and details["success"]["switch_triggered"] is False
|
|
and details["success"]["route"] == "optimize-current-route"
|
|
and details["tile_scramble"]["switch_triggered"] is True
|
|
and details["tile_scramble"]["has_platform_candidates"] is True
|
|
and all(item["switch_triggered"] is False for item in details["special_types"].values())
|
|
and all(item["decision"] == "ordinary-platform-not-recommended" for item in details["special_types"].values())
|
|
and all(not item["platform_candidates"] for item in details["special_types"].values())
|
|
)
|
|
return {"passed": passed, "details": details}
|
|
|
|
|
|
def write_success_samples(path: Path, samples: list[dict[str, Any]]) -> None:
|
|
data = {
|
|
"authorization_scope": "授权测试目标",
|
|
"provider": "custom-or-unknown",
|
|
"success_samples": samples,
|
|
}
|
|
path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8")
|
|
|
|
|
|
def success_sample(captcha_type: str, sample_id: str) -> dict[str, Any]:
|
|
return {
|
|
"sample_id": sample_id,
|
|
"success": True,
|
|
"captcha_type": captcha_type,
|
|
"provider": "custom-or-unknown",
|
|
"evidence": {
|
|
"success_signal": "UI/callback/response 显示验证成功",
|
|
"timeline": ["rendered", "user solved", "success observed"],
|
|
},
|
|
}
|
|
|
|
|
|
def check_success_baseline() -> dict[str, Any]:
|
|
details: dict[str, Any] = {}
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
tmp_dir = Path(tmp)
|
|
|
|
sufficient_path = tmp_dir / "success-sufficient.json"
|
|
write_success_samples(
|
|
sufficient_path,
|
|
[
|
|
success_sample("slider", "s1"),
|
|
success_sample("slider", "s2"),
|
|
success_sample("slider", "s3"),
|
|
success_sample("click-select", "c1"),
|
|
success_sample("click-select", "c2"),
|
|
],
|
|
)
|
|
sufficient = run_json(["scripts/evaluate_success_baseline.py", "--samples", str(sufficient_path)])
|
|
details["sufficient"] = {
|
|
"status": sufficient["success_baseline_status"],
|
|
"route": sufficient["recommended_next_route"],
|
|
"dynamic": sufficient["success_baseline_summary"]["has_dynamic_type_switch"],
|
|
}
|
|
|
|
insufficient_total_path = tmp_dir / "success-insufficient-total.json"
|
|
write_success_samples(
|
|
insufficient_total_path,
|
|
[
|
|
success_sample("slider", "s1"),
|
|
success_sample("slider", "s2"),
|
|
success_sample("slider", "s3"),
|
|
],
|
|
)
|
|
insufficient_total = run_json(["scripts/evaluate_success_baseline.py", "--samples", str(insufficient_total_path)])
|
|
details["insufficient_total"] = {
|
|
"status": insufficient_total["success_baseline_status"],
|
|
"missing_scopes": [item["scope"] for item in insufficient_total["missing_success_samples"]],
|
|
"send_request": insufficient_total["send_request"],
|
|
}
|
|
|
|
insufficient_type_path = tmp_dir / "success-insufficient-type.json"
|
|
write_success_samples(
|
|
insufficient_type_path,
|
|
[
|
|
success_sample("slider", "s1"),
|
|
success_sample("slider", "s2"),
|
|
success_sample("slider", "s3"),
|
|
success_sample("slider", "s4"),
|
|
success_sample("click-select", "c1"),
|
|
],
|
|
)
|
|
insufficient_type = run_json(["scripts/evaluate_success_baseline.py", "--samples", str(insufficient_type_path)])
|
|
details["insufficient_type"] = {
|
|
"status": insufficient_type["success_baseline_status"],
|
|
"missing": insufficient_type["missing_success_samples"],
|
|
}
|
|
|
|
passed = (
|
|
details["sufficient"]["status"] == "sufficient"
|
|
and details["sufficient"]["route"] == "ready-for-verification-flow"
|
|
and details["sufficient"]["dynamic"] is True
|
|
and details["insufficient_total"]["status"] == "insufficient"
|
|
and "total" in details["insufficient_total"]["missing_scopes"]
|
|
and details["insufficient_total"]["send_request"] is False
|
|
and details["insufficient_type"]["status"] == "insufficient"
|
|
and any(item.get("scope") == "captcha_type" and item.get("captcha_type") == "click-select" for item in details["insufficient_type"]["missing"])
|
|
)
|
|
return {"passed": passed, "details": details}
|
|
|
|
|
|
def main() -> int:
|
|
coord = run_json(
|
|
[
|
|
"scripts/map_coordinates.py",
|
|
"--image-size",
|
|
"300x150",
|
|
"--display-size",
|
|
"300x150",
|
|
"--point",
|
|
"120,75",
|
|
"--element-left",
|
|
"20",
|
|
"--element-top",
|
|
"80",
|
|
]
|
|
)
|
|
track = run_json(
|
|
[
|
|
"scripts/generate_motion_track.py",
|
|
"--mode",
|
|
"slider",
|
|
"--distance",
|
|
"128",
|
|
"--duration-ms",
|
|
"1100",
|
|
]
|
|
)
|
|
template = run_json(
|
|
[
|
|
"scripts/solver_request_template.py",
|
|
"--platform",
|
|
"2captcha",
|
|
"--captcha-type",
|
|
"token-widget",
|
|
"--provider",
|
|
"recaptcha",
|
|
]
|
|
)
|
|
assets = run_json(
|
|
[
|
|
"scripts/inspect_assets.py",
|
|
"--captcha-type",
|
|
"slider",
|
|
"--provided",
|
|
"background_or_screenshot",
|
|
"--provided",
|
|
"track_width",
|
|
]
|
|
)
|
|
success_baseline = check_success_baseline()
|
|
tile_restore = check_tile_restore()
|
|
attempt_eval = check_attempt_evaluation()
|
|
refs = check_references()
|
|
checks = {
|
|
"coordinate_mapping": coord["element_css"]["x"] == 120 and coord["page_css"]["x"] == 140,
|
|
"motion_track": track["mode"] == "slider" and len(track["points"]) >= 2,
|
|
"solver_template": template["send_request"] is False and "api_key" in template,
|
|
"asset_inspection": assets["ready_for_offline_flow"] is True,
|
|
"success_baseline": success_baseline["passed"],
|
|
"tile_restore": tile_restore["passed"],
|
|
"attempt_evaluation": attempt_eval["passed"],
|
|
"references": refs["passed"],
|
|
}
|
|
report = {
|
|
"passed": all(checks.values()),
|
|
"checks": checks,
|
|
"success_baseline": success_baseline,
|
|
"tile_restore": tile_restore,
|
|
"attempt_evaluation": attempt_eval,
|
|
"reference_missing": refs["missing"],
|
|
}
|
|
print(json.dumps(report, ensure_ascii=False, indent=2))
|
|
return 0 if report["passed"] else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|