reverse-skills-max: initial public release (restored, redacted, hardened)
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
#!/usr/bin/env python3
|
||||
"""离线分析切片乱序图片验证码并尝试还原。
|
||||
|
||||
脚本只读取本地证据,不打开网页、不控制浏览器、不提交验证码、
|
||||
不发送网络请求。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageStat
|
||||
except ImportError: # pragma: no cover - depends on local environment
|
||||
Image = None # type: ignore[assignment]
|
||||
ImageStat = None # type: ignore[assignment]
|
||||
|
||||
|
||||
def configure_utf8_stdio() -> None:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8")
|
||||
|
||||
|
||||
configure_utf8_stdio()
|
||||
|
||||
|
||||
def read_value(value: str | None) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
path = Path(value)
|
||||
if path.exists() and path.is_file():
|
||||
for encoding in ("utf-8", "utf-8-sig", "gb18030"):
|
||||
try:
|
||||
return path.read_text(encoding=encoding)
|
||||
except UnicodeDecodeError:
|
||||
continue
|
||||
return path.read_text(errors="replace")
|
||||
return value
|
||||
|
||||
|
||||
def parse_order(value: str | None) -> list[int] | None:
|
||||
if not value:
|
||||
return None
|
||||
numbers = [int(item) for item in re.findall(r"-?\d+", value)]
|
||||
return numbers or None
|
||||
|
||||
|
||||
def compact(text: str, limit: int = 100) -> str:
|
||||
cleaned = re.sub(r"\s+", " ", text.strip())
|
||||
if len(cleaned) <= limit:
|
||||
return cleaned
|
||||
return cleaned[: limit - 1] + "…"
|
||||
|
||||
|
||||
def collect_text_signals(text: str) -> list[dict[str, str]]:
|
||||
patterns = [
|
||||
("切片乱序中文文案", r"切片乱序|分块乱序|图片分割|瓦片重排|分割顺序打乱|乱序图片还原|图片被切成多块|顺序打乱"),
|
||||
("切片乱序英文文案", r"\b(?:scrambled tiles|tile scramble|scrambled image tiles|image tile restore|reorder tiles)\b"),
|
||||
("tileOrder/pieceOrder 字段", r"\b(?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)\b"),
|
||||
("CSS 背景切片线索", r"background-position|background-size|sprite"),
|
||||
("Canvas 绘制切片线索", r"\bdrawImage\s*\("),
|
||||
("布局位置线索", r"\b(?:transform|translate3d|translate|left|top)\b.{0,80}\b(?:px|tile|piece|slice)\b"),
|
||||
("shuffle/scramble 线索", r"\b(?:shuffle|scramble|unscramble|slice|tile|piece)\b.{0,80}\b(?:captcha|verify|challenge|image)\b"),
|
||||
]
|
||||
signals: list[dict[str, str]] = []
|
||||
for label, pattern in patterns:
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL)
|
||||
if match:
|
||||
signals.append({"label": label, "evidence": compact(match.group(0)), "source": "text"})
|
||||
return signals
|
||||
|
||||
|
||||
def extract_order_arrays(text: str) -> tuple[list[int] | None, list[dict[str, str]]]:
|
||||
signals: list[dict[str, str]] = []
|
||||
for pattern in (
|
||||
r"\b(?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)\b\s*[:=]\s*\[([^\]]+)\]",
|
||||
r"[\"'](?:tileOrder|pieceOrder|tile_order|piece_order|restoreOrder|sliceOrder)[\"']\s*:\s*\[([^\]]+)\]",
|
||||
):
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE | re.DOTALL)
|
||||
if not match:
|
||||
continue
|
||||
order = [int(item) for item in re.findall(r"-?\d+", match.group(1))]
|
||||
if order:
|
||||
signals.append({"label": "抽取显式切片顺序数组", "evidence": compact(match.group(0)), "source": "html"})
|
||||
return order, signals
|
||||
return None, signals
|
||||
|
||||
|
||||
def extract_background_position_order(
|
||||
text: str,
|
||||
rows: int | None,
|
||||
cols: int | None,
|
||||
tile_width: int | None,
|
||||
tile_height: int | None,
|
||||
) -> tuple[list[int] | None, list[dict[str, str]]]:
|
||||
if not rows or not cols or not tile_width or not tile_height:
|
||||
return None, []
|
||||
pattern = r"background-position\s*:\s*(-?\d+(?:\.\d+)?)px\s+(-?\d+(?:\.\d+)?)px"
|
||||
order: list[int] = []
|
||||
signals: list[dict[str, str]] = []
|
||||
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
|
||||
x = abs(float(match.group(1)))
|
||||
y = abs(float(match.group(2)))
|
||||
col = int(round(x / tile_width))
|
||||
row = int(round(y / tile_height))
|
||||
if 0 <= row < rows and 0 <= col < cols:
|
||||
order.append(row * cols + col)
|
||||
signals.append({"label": "抽取 background-position 切片来源", "evidence": compact(match.group(0)), "source": "html"})
|
||||
expected = rows * cols
|
||||
if len(order) == expected:
|
||||
return order, signals
|
||||
return None, signals
|
||||
|
||||
|
||||
def extract_draw_image_order(
|
||||
text: str,
|
||||
rows: int | None,
|
||||
cols: int | None,
|
||||
tile_width: int | None,
|
||||
tile_height: int | None,
|
||||
) -> tuple[list[int] | None, list[dict[str, str]]]:
|
||||
if not rows or not cols or not tile_width or not tile_height:
|
||||
return None, []
|
||||
pattern = r"drawImage\s*\([^,]+,\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+),\s*([-\d.]+)"
|
||||
found: dict[int, int] = {}
|
||||
signals: list[dict[str, str]] = []
|
||||
for match in re.finditer(pattern, text, flags=re.IGNORECASE):
|
||||
sx = float(match.group(1))
|
||||
sy = float(match.group(2))
|
||||
dx = float(match.group(5))
|
||||
dy = float(match.group(6))
|
||||
src_col = int(round(sx / tile_width))
|
||||
src_row = int(round(sy / tile_height))
|
||||
dst_col = int(round(dx / tile_width))
|
||||
dst_row = int(round(dy / tile_height))
|
||||
if 0 <= src_row < rows and 0 <= src_col < cols and 0 <= dst_row < rows and 0 <= dst_col < cols:
|
||||
found[dst_row * cols + dst_col] = src_row * cols + src_col
|
||||
signals.append({"label": "抽取 drawImage 源/目标切片", "evidence": compact(match.group(0)), "source": "html"})
|
||||
expected = rows * cols
|
||||
if len(found) == expected:
|
||||
return [found[index] for index in range(expected)], signals
|
||||
return None, signals
|
||||
|
||||
|
||||
def normalize_order(order: list[int], total: int) -> tuple[list[int] | None, str | None]:
|
||||
if len(order) != total:
|
||||
return None, f"顺序数组长度为 {len(order)},但网格需要 {total} 个切片"
|
||||
if sorted(order) == list(range(total)):
|
||||
return order, None
|
||||
if sorted(item - 1 for item in order) == list(range(total)):
|
||||
return [item - 1 for item in order], None
|
||||
return None, "顺序数组不是 0 基或 1 基的完整排列"
|
||||
|
||||
|
||||
def infer_grid(
|
||||
image: Any,
|
||||
rows: int | None,
|
||||
cols: int | None,
|
||||
tile_width: int | None,
|
||||
tile_height: int | None,
|
||||
) -> tuple[int | None, int | None, int | None, int | None, list[str]]:
|
||||
warnings: list[str] = []
|
||||
width, height = image.size
|
||||
if tile_width and not cols:
|
||||
cols = width // tile_width
|
||||
if tile_height and not rows:
|
||||
rows = height // tile_height
|
||||
if rows and not tile_height:
|
||||
tile_height = height // rows
|
||||
if cols and not tile_width:
|
||||
tile_width = width // cols
|
||||
if not rows or not cols or not tile_width or not tile_height:
|
||||
warnings.append("需要提供 rows/cols 或 tile-width/tile-height 才能切片分析图片")
|
||||
return rows, cols, tile_width, tile_height, warnings
|
||||
if rows * tile_height > height or cols * tile_width > width:
|
||||
warnings.append("网格尺寸超过图片尺寸")
|
||||
return rows, cols, tile_width, tile_height, warnings
|
||||
|
||||
|
||||
def split_tiles(image: Any, rows: int, cols: int, tile_width: int, tile_height: int) -> list[Any]:
|
||||
tiles = []
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
left = col * tile_width
|
||||
top = row * tile_height
|
||||
tiles.append(image.crop((left, top, left + tile_width, top + tile_height)).convert("RGB"))
|
||||
return tiles
|
||||
|
||||
|
||||
def edge_cost(a: Any, b: Any, direction: str) -> float:
|
||||
if direction == "right":
|
||||
edge_a = a.crop((a.width - 1, 0, a.width, a.height))
|
||||
edge_b = b.crop((0, 0, 1, b.height))
|
||||
else:
|
||||
edge_a = a.crop((0, a.height - 1, a.width, a.height))
|
||||
edge_b = b.crop((0, 0, b.width, 1))
|
||||
diff = ImageStat.Stat(Image.eval(ImageChopsCompat.difference(edge_a, edge_b), lambda px: px)).mean
|
||||
return float(sum(diff) / len(diff))
|
||||
|
||||
|
||||
class ImageChopsCompat:
|
||||
@staticmethod
|
||||
def difference(left: Any, right: Any) -> Any:
|
||||
from PIL import ImageChops
|
||||
|
||||
return ImageChops.difference(left, right)
|
||||
|
||||
|
||||
def arrangement_cost(order: tuple[int, ...], tiles: list[Any], rows: int, cols: int) -> float:
|
||||
total = 0.0
|
||||
count = 0
|
||||
for row in range(rows):
|
||||
for col in range(cols):
|
||||
current = tiles[order[row * cols + col]]
|
||||
if col + 1 < cols:
|
||||
total += edge_cost(current, tiles[order[row * cols + col + 1]], "right")
|
||||
count += 1
|
||||
if row + 1 < rows:
|
||||
total += edge_cost(current, tiles[order[(row + 1) * cols + col]], "down")
|
||||
count += 1
|
||||
return total / max(count, 1)
|
||||
|
||||
|
||||
def tile_diversity(tiles: list[Any]) -> float:
|
||||
means: list[float] = []
|
||||
for tile in tiles:
|
||||
stat = ImageStat.Stat(tile)
|
||||
means.extend(float(value) for value in stat.mean)
|
||||
if not means:
|
||||
return 0.0
|
||||
avg = sum(means) / len(means)
|
||||
variance = sum((value - avg) ** 2 for value in means) / len(means)
|
||||
return math.sqrt(variance)
|
||||
|
||||
|
||||
def image_edge_match_order(tiles: list[Any], rows: int, cols: int) -> tuple[list[int] | None, float, float, list[str]]:
|
||||
total = rows * cols
|
||||
warnings: list[str] = []
|
||||
if total != len(tiles):
|
||||
return None, 0.0, 0.0, ["切片数量和网格不一致"]
|
||||
if total > 9:
|
||||
warnings.append("切片超过 9 个,第一版不做全排列搜索;请优先使用页面顺序或显式 order")
|
||||
return list(range(total)), 0.25, 0.0, warnings
|
||||
|
||||
best_order: tuple[int, ...] | None = None
|
||||
best_cost = float("inf")
|
||||
second_cost = float("inf")
|
||||
for order in itertools.permutations(range(total)):
|
||||
cost = arrangement_cost(order, tiles, rows, cols)
|
||||
if cost < best_cost:
|
||||
second_cost = best_cost
|
||||
best_cost = cost
|
||||
best_order = order
|
||||
elif cost < second_cost:
|
||||
second_cost = cost
|
||||
|
||||
diversity = tile_diversity(tiles)
|
||||
separation = 0.0 if not math.isfinite(second_cost) else max(0.0, (second_cost - best_cost) / (second_cost + 1e-6))
|
||||
confidence = max(0.25, min(0.95, separation * 4.0 + min(diversity / 128.0, 0.35)))
|
||||
if separation < 0.02:
|
||||
confidence = min(confidence, 0.42)
|
||||
warnings.append("候选排列分数接近,可能是重复纹理、纯色块或边缘线索不足")
|
||||
if diversity < 6:
|
||||
confidence = min(confidence, 0.35)
|
||||
warnings.append("切片视觉差异很低,建议改用页面顺序或人工/平台复核")
|
||||
return list(best_order or range(total)), round(confidence, 2), round(best_cost, 4), warnings
|
||||
|
||||
|
||||
def restore_image(
|
||||
image: Any,
|
||||
rows: int,
|
||||
cols: int,
|
||||
tile_width: int,
|
||||
tile_height: int,
|
||||
order_source_by_target: list[int],
|
||||
output_path: Path,
|
||||
) -> None:
|
||||
tiles = split_tiles(image, rows, cols, tile_width, tile_height)
|
||||
restored = Image.new("RGB", (cols * tile_width, rows * tile_height))
|
||||
for target_index, source_index in enumerate(order_source_by_target):
|
||||
row, col = divmod(target_index, cols)
|
||||
restored.paste(tiles[source_index], (col * tile_width, row * tile_height))
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
restored.save(output_path)
|
||||
|
||||
|
||||
def build_report(args: argparse.Namespace) -> dict[str, Any]:
|
||||
html_text = read_value(args.html)
|
||||
plain_text = read_value(args.text)
|
||||
combined_text = "\n".join(part for part in (html_text, plain_text) if part)
|
||||
signals = collect_text_signals(combined_text)
|
||||
warnings: list[str] = []
|
||||
|
||||
rows = args.rows
|
||||
cols = args.cols
|
||||
tile_width = args.tile_width
|
||||
tile_height = args.tile_height
|
||||
explicit_order = parse_order(args.order_source_by_target)
|
||||
extracted_order: list[int] | None = None
|
||||
|
||||
for extractor in (
|
||||
extract_order_arrays,
|
||||
lambda text: extract_background_position_order(text, rows, cols, tile_width, tile_height),
|
||||
lambda text: extract_draw_image_order(text, rows, cols, tile_width, tile_height),
|
||||
):
|
||||
order, found_signals = extractor(combined_text)
|
||||
signals.extend(found_signals)
|
||||
if order:
|
||||
extracted_order = order
|
||||
break
|
||||
|
||||
image = None
|
||||
if args.image:
|
||||
if Image is None:
|
||||
warnings.append("当前 Python 环境缺少 Pillow,无法读取图片;只能分析文本/HTML 线索")
|
||||
else:
|
||||
image_path = Path(args.image)
|
||||
if image_path.exists() and image_path.is_file():
|
||||
image = Image.open(image_path).convert("RGB")
|
||||
rows, cols, tile_width, tile_height, grid_warnings = infer_grid(image, rows, cols, tile_width, tile_height)
|
||||
warnings.extend(grid_warnings)
|
||||
else:
|
||||
warnings.append("图片文件不存在")
|
||||
|
||||
total = rows * cols if rows and cols else 0
|
||||
normalized_order: list[int] | None = None
|
||||
order_warning: str | None = None
|
||||
order_source = ""
|
||||
if explicit_order and total:
|
||||
normalized_order, order_warning = normalize_order(explicit_order, total)
|
||||
order_source = "argument"
|
||||
elif extracted_order and total:
|
||||
normalized_order, order_warning = normalize_order(extracted_order, total)
|
||||
order_source = "page"
|
||||
if order_warning:
|
||||
warnings.append(order_warning)
|
||||
|
||||
confidence = 0.0
|
||||
edge_cost_value: float | None = None
|
||||
restore_strategy = "manual-or-platform"
|
||||
if normalized_order:
|
||||
confidence = 0.92 if order_source == "argument" else 0.86
|
||||
restore_strategy = "page-order"
|
||||
elif image is not None and rows and cols and tile_width and tile_height:
|
||||
tiles = split_tiles(image, rows, cols, tile_width, tile_height)
|
||||
normalized_order, confidence, edge_cost_value, edge_warnings = image_edge_match_order(tiles, rows, cols)
|
||||
warnings.extend(edge_warnings)
|
||||
restore_strategy = "image-edge-match" if confidence >= 0.55 else "manual-or-platform"
|
||||
elif signals:
|
||||
confidence = 0.58
|
||||
|
||||
restored_image: str | None = None
|
||||
if image is not None and rows and cols and tile_width and tile_height and normalized_order and args.output_image:
|
||||
if len(normalized_order) == rows * cols:
|
||||
output_path = Path(args.output_image)
|
||||
restore_image(image, rows, cols, tile_width, tile_height, normalized_order, output_path)
|
||||
restored_image = str(output_path)
|
||||
else:
|
||||
warnings.append("顺序长度不匹配,未输出还原图片")
|
||||
|
||||
is_tile_scramble = bool(signals or normalized_order or (image is not None and rows and cols))
|
||||
if not signals and normalized_order:
|
||||
signals.append({"label": "提供显式切片顺序", "evidence": ",".join(str(item) for item in normalized_order), "source": "argument"})
|
||||
if image is not None and rows and cols:
|
||||
signals.append({"label": "图片网格切片证据", "evidence": f"{rows}x{cols}", "source": "image"})
|
||||
|
||||
return {
|
||||
"is_tile_scramble": is_tile_scramble,
|
||||
"grid": {
|
||||
"rows": rows,
|
||||
"cols": cols,
|
||||
"tile_width": tile_width,
|
||||
"tile_height": tile_height,
|
||||
"total": total,
|
||||
},
|
||||
"order_source_by_target": normalized_order,
|
||||
"confidence": round(confidence, 2),
|
||||
"restore_strategy": restore_strategy,
|
||||
"edge_match_cost": edge_cost_value,
|
||||
"signals": signals,
|
||||
"warnings": warnings,
|
||||
"restored_image": restored_image,
|
||||
"notes": [
|
||||
"脚本只做离线切片判断和图片还原,不打开网页、不点击页面、不提交验证。",
|
||||
"优先相信页面逻辑中的顺序字段;纯图片边缘匹配低置信度时应切换人工或平台复核。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="离线分析切片乱序图片验证码")
|
||||
parser.add_argument("--image", help="待分析的乱序图片路径")
|
||||
parser.add_argument("--html", help="HTML/CSS/JS 片段或文件路径")
|
||||
parser.add_argument("--text", help="页面文案或分析备注")
|
||||
parser.add_argument("--rows", type=int, help="切片行数")
|
||||
parser.add_argument("--cols", type=int, help="切片列数")
|
||||
parser.add_argument("--tile-width", type=int, help="单块宽度")
|
||||
parser.add_argument("--tile-height", type=int, help="单块高度")
|
||||
parser.add_argument("--order-source-by-target", help="还原顺序:每个目标位置使用哪个当前切片索引,支持逗号分隔")
|
||||
parser.add_argument("--output-image", help="输出还原图片路径")
|
||||
parser.add_argument("--pretty", action="store_true", help="以缩进格式输出 JSON")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
report = build_report(args)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,189 @@
|
||||
#!/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())
|
||||
@@ -0,0 +1,436 @@
|
||||
#!/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_FAILURES_FOR_SWITCH = 5
|
||||
NO_ORDINARY_PLATFORM_TYPES = {"pow-challenge", "waf-challenge", "biometric-liveness"}
|
||||
OK_STATUSES = {
|
||||
"ok",
|
||||
"pass",
|
||||
"passed",
|
||||
"success",
|
||||
"successful",
|
||||
"ready",
|
||||
"clean",
|
||||
"normal",
|
||||
"valid",
|
||||
"verified",
|
||||
"no_issue",
|
||||
"no-issue",
|
||||
"none",
|
||||
"无异常",
|
||||
"正常",
|
||||
"通过",
|
||||
}
|
||||
|
||||
STATUS_ALIASES = {
|
||||
"coordinate": "coordinates",
|
||||
"coord": "coordinates",
|
||||
"coords": "coordinates",
|
||||
"motion": "track",
|
||||
"trajectory": "track",
|
||||
"tile": "tile_restore",
|
||||
"restore": "tile_restore",
|
||||
"env": "browser_env",
|
||||
"environment": "browser_env",
|
||||
"browser": "browser_env",
|
||||
"freshness": "challenge_freshness",
|
||||
"ttl": "challenge_freshness",
|
||||
"challenge": "challenge_freshness",
|
||||
}
|
||||
|
||||
AXIS_LABELS = {
|
||||
"image": "图片/识别结果",
|
||||
"coordinates": "坐标映射",
|
||||
"track": "轨迹/交互行为",
|
||||
"tile_restore": "切片乱序还原",
|
||||
"browser_env": "补环境/浏览器环境",
|
||||
"challenge_freshness": "challenge 新鲜度/TTL",
|
||||
}
|
||||
|
||||
BLOCKING_ROUTE_BY_AXIS = {
|
||||
"image": "fix-image-recognition",
|
||||
"coordinates": "fix-coordinate-mapping",
|
||||
"track": "fix-motion-track",
|
||||
"tile_restore": "fix-tile-restore",
|
||||
"browser_env": "fix-browser-env",
|
||||
"challenge_freshness": "refresh-challenge",
|
||||
}
|
||||
|
||||
PLATFORM_CANDIDATES = {
|
||||
"text": ["云码/JFBYM", "超级鹰", "2Captcha ImageToText", "Anti-Captcha Image"],
|
||||
"math": ["云码/JFBYM", "超级鹰", "2Captcha Normal Captcha"],
|
||||
"slider": ["云码/JFBYM 滑块类型", "超级鹰滑块/坐标类型", "CapSolver", "2Captcha GeeTest/slider"],
|
||||
"image-restore": ["云码/JFBYM 拼图/图片任务", "超级鹰滑块/坐标类型", "CapSolver 图片任务", "2Captcha 图片任务"],
|
||||
"click-select": ["云码/JFBYM 坐标/点选类型", "超级鹰坐标类型", "2Captcha Coordinates", "CapSolver ComplexImageTask"],
|
||||
"grid": ["2Captcha Grid/reCAPTCHA", "CapSolver", "CapMonster Cloud", "Anti-Captcha", "NopeCHA"],
|
||||
"rotate": ["云码/JFBYM 旋转类型", "超级鹰旋转/坐标类型", "CapSolver 图片任务", "2Captcha 图片任务"],
|
||||
"audio": ["2Captcha Audio", "CapMonster/Anti-Captcha 音频任务", "人工复核"],
|
||||
"drag-drop": ["云码/JFBYM 坐标类型", "超级鹰坐标类型", "CapSolver ComplexImageTask", "人工接管"],
|
||||
"trace-draw": ["云码/JFBYM 轨迹/坐标类型", "超级鹰坐标/轨迹类", "人工标注"],
|
||||
"scratch": ["云码/JFBYM 定制类型", "超级鹰定制/人工", "人工接管"],
|
||||
"area-select": ["云码/JFBYM 坐标/区域类型", "超级鹰坐标类型", "CapSolver ComplexImageTask"],
|
||||
"difference-click": ["云码/JFBYM 坐标类型", "超级鹰坐标类型", "人工标注"],
|
||||
"font-identify": ["云码/JFBYM 定制/坐标类型", "超级鹰人工/坐标类型", "人工标注"],
|
||||
"semantic-reasoning": ["云码/JFBYM 定制/坐标类型", "超级鹰人工/坐标类型", "CapSolver ComplexImageTask"],
|
||||
"game-challenge": ["2Captcha FunCaptcha/Arkose", "CapSolver FunCaptcha", "Anti-Captcha FunCaptcha", "NopeCHA", "人工接管"],
|
||||
"risk-score": ["2Captcha reCAPTCHA v3/Enterprise", "CapSolver", "CapMonster Cloud", "Anti-Captcha"],
|
||||
"one-click": ["2Captcha token 任务", "CapSolver", "CapMonster Cloud", "人工接管"],
|
||||
"multi-step": ["按每轮子类型选择平台", "人工接管多轮流程"],
|
||||
"qa-logic": ["云码/JFBYM 文字/定制类型", "超级鹰人工类型", "2Captcha Normal Captcha"],
|
||||
"token-widget": ["2Captcha", "CapSolver", "CapMonster Cloud", "Anti-Captcha", "YesCaptcha/NoCaptchaAI"],
|
||||
"unknown-custom": ["云码/JFBYM 定制", "超级鹰人工/定制", "2Captcha/CapSolver 通用图片或坐标任务"],
|
||||
}
|
||||
|
||||
|
||||
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_attempts(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):
|
||||
attempts = data.get("attempts")
|
||||
if isinstance(attempts, list):
|
||||
defaults = {key: value for key, value in data.items() if key != "attempts"}
|
||||
return [item for item in attempts if isinstance(item, dict)], defaults
|
||||
return [data], {}
|
||||
raise ValueError("attempts JSON 必须是数组,或包含 attempts 数组的对象")
|
||||
|
||||
|
||||
def context_value(attempt: dict[str, Any], defaults: dict[str, Any], names: list[str], fallback: str) -> str:
|
||||
value = get_first(attempt, names, get_first(defaults, names, fallback))
|
||||
return str(value)
|
||||
|
||||
|
||||
def attempt_context(attempt: dict[str, Any], defaults: dict[str, Any]) -> dict[str, str]:
|
||||
return {
|
||||
"authorization_scope": context_value(attempt, defaults, ["authorization_scope", "scope", "target"], "未声明授权目标"),
|
||||
"captcha_type": context_value(attempt, defaults, ["captcha_type", "type"], "unknown-custom"),
|
||||
"captcha_variant": context_value(attempt, defaults, ["captcha_variant", "variant"], ""),
|
||||
"provider": context_value(attempt, defaults, ["provider"], "custom-or-unknown"),
|
||||
"chosen_solution": context_value(attempt, defaults, ["chosen_solution", "solution", "route"], "未声明方案"),
|
||||
}
|
||||
|
||||
|
||||
def same_context(left: dict[str, str], right: dict[str, str]) -> bool:
|
||||
return (
|
||||
left["authorization_scope"] == right["authorization_scope"]
|
||||
and left["captcha_type"] == right["captcha_type"]
|
||||
and left["chosen_solution"] == right["chosen_solution"]
|
||||
)
|
||||
|
||||
|
||||
def is_success(attempt: dict[str, Any]) -> bool:
|
||||
if isinstance(attempt.get("success"), bool):
|
||||
return bool(attempt["success"])
|
||||
status = str(get_first(attempt, ["status", "result", "outcome"], "")).strip().lower()
|
||||
return status in {"success", "succeeded", "passed", "pass", "ok", "成功", "通过"}
|
||||
|
||||
|
||||
def normalize_axis(name: str) -> str:
|
||||
return STATUS_ALIASES.get(name.strip().lower(), name.strip().lower())
|
||||
|
||||
|
||||
def normalize_status(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "ok" if value else "issue"
|
||||
if value is None:
|
||||
return "unknown"
|
||||
status = str(value).strip().lower()
|
||||
if not status:
|
||||
return "unknown"
|
||||
return "ok" if status in OK_STATUSES else status
|
||||
|
||||
|
||||
def extract_diagnosis(attempt: dict[str, Any]) -> dict[str, str]:
|
||||
raw = get_first(attempt, ["diagnosis_status", "diagnosis", "checks"], {})
|
||||
diagnosis: dict[str, str] = {}
|
||||
if isinstance(raw, dict):
|
||||
for key, value in raw.items():
|
||||
normalized = normalize_axis(str(key))
|
||||
if isinstance(value, dict):
|
||||
value = get_first(value, ["status", "result", "state"], "unknown")
|
||||
diagnosis[normalized] = normalize_status(value)
|
||||
|
||||
for key, axis in [
|
||||
("image_status", "image"),
|
||||
("coordinate_status", "coordinates"),
|
||||
("coordinates_status", "coordinates"),
|
||||
("track_status", "track"),
|
||||
("tile_restore_status", "tile_restore"),
|
||||
("browser_env_status", "browser_env"),
|
||||
("environment_status", "browser_env"),
|
||||
("challenge_freshness_status", "challenge_freshness"),
|
||||
("challenge_status", "challenge_freshness"),
|
||||
]:
|
||||
if key in attempt:
|
||||
diagnosis[axis] = normalize_status(attempt[key])
|
||||
return diagnosis
|
||||
|
||||
|
||||
def applicable_axes(captcha_type: str, captcha_variant: str) -> list[str]:
|
||||
if captcha_type in {"text", "math", "audio", "qa-logic"}:
|
||||
return ["image", "challenge_freshness"]
|
||||
if captcha_type == "slider":
|
||||
return ["image", "coordinates", "track", "browser_env", "challenge_freshness"]
|
||||
if captcha_type == "image-restore":
|
||||
axes = ["image", "coordinates", "track", "browser_env", "challenge_freshness"]
|
||||
if captcha_variant == "tile-scramble":
|
||||
axes.insert(1, "tile_restore")
|
||||
return axes
|
||||
if captcha_type in {"click-select", "grid", "area-select", "difference-click", "font-identify", "semantic-reasoning"}:
|
||||
return ["image", "coordinates", "browser_env", "challenge_freshness"]
|
||||
if captcha_type in {"rotate", "drag-drop", "trace-draw", "scratch"}:
|
||||
return ["image", "coordinates", "track", "browser_env", "challenge_freshness"]
|
||||
if captcha_type in {"token-widget", "risk-score", "one-click", "game-challenge", "multi-step"}:
|
||||
return ["browser_env", "challenge_freshness"]
|
||||
if captcha_type in NO_ORDINARY_PLATFORM_TYPES:
|
||||
return ["browser_env", "challenge_freshness"]
|
||||
return ["image", "coordinates", "browser_env", "challenge_freshness"]
|
||||
|
||||
|
||||
def infer_blocking_axis_from_text(attempts: list[dict[str, Any]]) -> str | None:
|
||||
text = "\n".join(
|
||||
str(get_first(attempt, ["failure_reason", "reason", "error", "notes"], ""))
|
||||
for attempt in attempts
|
||||
).lower()
|
||||
keyword_axes = [
|
||||
("tile_restore", ["乱序", "切片", "分块", "tile", "restore", "scramble", "还原"]),
|
||||
("coordinates", ["坐标", "偏移", "映射", "dpr", "coordinate", "offset"]),
|
||||
("track", ["轨迹", "速度", "拖动", "motion", "track", "trajectory"]),
|
||||
("browser_env", ["补环境", "环境", "指纹", "cdp", "webdriver", "browser", "fingerprint"]),
|
||||
("challenge_freshness", ["过期", "ttl", "expired", "stale", "刷新", "challenge id"]),
|
||||
("image", ["图片", "识别", "ocr", "缺口", "角度", "目标", "image", "recognition"]),
|
||||
]
|
||||
for axis, keywords in keyword_axes:
|
||||
if any(keyword in text for keyword in keywords):
|
||||
return axis
|
||||
return None
|
||||
|
||||
|
||||
def find_blocking_issue(
|
||||
recent_attempts: list[dict[str, Any]],
|
||||
context: dict[str, str],
|
||||
) -> tuple[str | None, dict[str, str]]:
|
||||
latest_diagnosis = extract_diagnosis(recent_attempts[-1]) if recent_attempts else {}
|
||||
axes = applicable_axes(context["captcha_type"], context["captcha_variant"])
|
||||
normalized = {axis: latest_diagnosis.get(axis, "unknown") for axis in axes}
|
||||
|
||||
for axis in axes:
|
||||
if normalized[axis] != "ok":
|
||||
return axis, normalized
|
||||
|
||||
inferred = infer_blocking_axis_from_text(recent_attempts)
|
||||
if inferred and inferred in axes:
|
||||
normalized[inferred] = "suspected"
|
||||
return inferred, normalized
|
||||
return None, normalized
|
||||
|
||||
|
||||
def candidates_for(context: dict[str, str]) -> list[str]:
|
||||
captcha_type = context["captcha_type"]
|
||||
if captcha_type == "image-restore" and context.get("captcha_variant") == "tile-scramble":
|
||||
return ["云码/JFBYM 拼图/图片任务", "超级鹰坐标/拼图类型", "CapSolver 图片任务", "2Captcha 图片任务", "人工复核"]
|
||||
return PLATFORM_CANDIDATES.get(captcha_type, PLATFORM_CANDIDATES["unknown-custom"])
|
||||
|
||||
|
||||
def special_route_for(captcha_type: str) -> tuple[str, list[str], str] | None:
|
||||
if captcha_type == "pow-challenge":
|
||||
return (
|
||||
"official-protocol-diagnostics",
|
||||
[],
|
||||
"PoW/工作量证明不默认推荐普通打码平台;优先检查官方协议、difficulty、nonce、TTL、防重放和服务端签名绑定。",
|
||||
)
|
||||
if captcha_type == "waf-challenge":
|
||||
return (
|
||||
"browser-env-and-vendor-diagnostics",
|
||||
[],
|
||||
"WAF challenge 不等同普通图片验证码;优先做浏览器环境、TLS/HTTP 指纹、IP/session 绑定和厂商日志诊断。",
|
||||
)
|
||||
if captcha_type == "biometric-liveness":
|
||||
return (
|
||||
"manual-review-or-vendor-support",
|
||||
[],
|
||||
"活体/人脸验证涉及隐私与合规,不默认推荐普通打码平台;优先走官方 SDK、人工复核、可访问性替代或厂商支持。",
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def build_platform_control_plan(context: dict[str, str], candidates: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"role": "授权 QA 对照,不是默认替代所有本地方案",
|
||||
"send_request": False,
|
||||
"candidate_platforms": candidates,
|
||||
"template_command": (
|
||||
"python scripts/solver_request_template.py "
|
||||
f"--platform <platform> --captcha-type {context['captcha_type']} "
|
||||
f"--provider {context['provider']} --pretty"
|
||||
),
|
||||
"steps": [
|
||||
"先生成请求模板和字段映射,只填非秘密字段。",
|
||||
"让用户再次确认授权目标、平台、题型、代理/session 绑定要求和是否允许发送。",
|
||||
"API key 只由用户运行时提供;不要写入文件、不要保存、不要回显。",
|
||||
"平台结果只用于对照本地识别/交互链路与厂商服务端绑定问题。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def evaluate(data: Any) -> dict[str, Any]:
|
||||
attempts, defaults = normalize_attempts(data)
|
||||
if not attempts:
|
||||
return {
|
||||
"switch_triggered": False,
|
||||
"reason": "没有可评估的验证尝试记录。",
|
||||
"blocking_issue": "attempts-missing",
|
||||
"recommended_next_route": "collect-attempts",
|
||||
"platform_candidates": [],
|
||||
"attempt_summary": {"total_attempts": 0, "consecutive_same_context": 0, "failures": 0, "successes": 0},
|
||||
"diagnosis_status": {},
|
||||
"platform_control_plan": {},
|
||||
"requires_user_confirmation": ["补充 attempts JSON 后再复盘"],
|
||||
"send_request": False,
|
||||
}
|
||||
|
||||
context = attempt_context(attempts[-1], defaults)
|
||||
consecutive: list[dict[str, Any]] = []
|
||||
for attempt in reversed(attempts):
|
||||
if same_context(attempt_context(attempt, defaults), context):
|
||||
consecutive.append(attempt)
|
||||
else:
|
||||
break
|
||||
consecutive.reverse()
|
||||
|
||||
successes = sum(1 for attempt in consecutive if is_success(attempt))
|
||||
failures = len(consecutive) - successes
|
||||
special_route = special_route_for(context["captcha_type"])
|
||||
platform_candidates = candidates_for(context) if not special_route else special_route[1]
|
||||
blocking_axis, diagnosis_status = find_blocking_issue(consecutive, context)
|
||||
summary = {
|
||||
"total_attempts": len(attempts),
|
||||
"consecutive_same_context": len(consecutive),
|
||||
"failures": failures,
|
||||
"successes": successes,
|
||||
"context": context,
|
||||
"failure_threshold": MIN_FAILURES_FOR_SWITCH,
|
||||
}
|
||||
|
||||
base: dict[str, Any] = {
|
||||
"attempt_summary": summary,
|
||||
"diagnosis_status": diagnosis_status,
|
||||
"platform_candidates": platform_candidates,
|
||||
"platform_control_plan": build_platform_control_plan(context, platform_candidates) if platform_candidates else {},
|
||||
"requires_user_confirmation": [
|
||||
"实际发送打码平台请求前,必须再次确认授权范围、平台、题型和执行动作。",
|
||||
"API key 只在运行时由用户提供,不保存、不回显。",
|
||||
],
|
||||
"send_request": False,
|
||||
}
|
||||
|
||||
if special_route:
|
||||
route, candidates, reason = special_route
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": False,
|
||||
"escalation_decision": "ordinary-platform-not-recommended",
|
||||
"reason": reason,
|
||||
"blocking_issue": None,
|
||||
"recommended_next_route": route,
|
||||
"platform_candidates": candidates,
|
||||
"platform_control_plan": {},
|
||||
}
|
||||
|
||||
if successes:
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": False,
|
||||
"escalation_decision": "keep-and-optimize",
|
||||
"reason": "同一授权目标和同一方案下已有成功记录;当前方案可用,但需要继续优化稳定性和失败样本原因。",
|
||||
"blocking_issue": None,
|
||||
"recommended_next_route": "optimize-current-route",
|
||||
}
|
||||
|
||||
if failures < MIN_FAILURES_FOR_SWITCH:
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": False,
|
||||
"escalation_decision": "wait-for-more-evidence",
|
||||
"reason": f"连续失败 {failures} 次,未达到 {MIN_FAILURES_FOR_SWITCH} 次失败复盘门槛。",
|
||||
"blocking_issue": None,
|
||||
"recommended_next_route": "continue-current-route-with-diagnostics",
|
||||
}
|
||||
|
||||
if blocking_axis:
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": False,
|
||||
"escalation_decision": "fix-blocking-issue-first",
|
||||
"reason": f"已达到失败次数门槛,但仍存在明确阻塞项:{AXIS_LABELS.get(blocking_axis, blocking_axis)}。",
|
||||
"blocking_issue": blocking_axis,
|
||||
"recommended_next_route": BLOCKING_ROUTE_BY_AXIS.get(blocking_axis, "fix-blocking-issue"),
|
||||
}
|
||||
|
||||
return {
|
||||
**base,
|
||||
"switch_triggered": True,
|
||||
"escalation_decision": "recommend-platform-control",
|
||||
"reason": (
|
||||
f"同一授权目标、同一验证码类型、同一方案连续 {failures} 次验证失败且无成功记录,"
|
||||
"图片/坐标/轨迹/还原/补环境/challenge 新鲜度未见明显异常;建议切换到打码平台做授权 QA 对照。"
|
||||
),
|
||||
"blocking_issue": None,
|
||||
"recommended_next_route": "platform-control",
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="评估验证码验证尝试是否应切换方案")
|
||||
parser.add_argument("--attempts", required=True, help="attempts 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.attempts))
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成授权验证分析用的鼠标/触摸轨迹 JSON。
|
||||
|
||||
脚本只生成离线轨迹,不控制浏览器、不点击页面、不提交验证码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import sys
|
||||
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()
|
||||
|
||||
|
||||
def parse_pair(value: str) -> tuple[float, float]:
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
if len(parts) != 2:
|
||||
raise argparse.ArgumentTypeError(f"需要 x,y: {value}")
|
||||
return float(parts[0]), float(parts[1])
|
||||
|
||||
|
||||
def parse_box(value: str) -> tuple[float, float, float, float]:
|
||||
parts = [part.strip() for part in value.split(",")]
|
||||
if len(parts) != 4:
|
||||
raise argparse.ArgumentTypeError(f"需要 left,top,right,bottom: {value}")
|
||||
left, top, right, bottom = (float(part) for part in parts)
|
||||
if right <= left or bottom <= top:
|
||||
raise argparse.ArgumentTypeError("box 的 right/bottom 必须大于 left/top")
|
||||
return left, top, right, bottom
|
||||
|
||||
|
||||
def parse_points(value: str) -> list[tuple[float, float]]:
|
||||
points: list[tuple[float, float]] = []
|
||||
for item in value.split():
|
||||
points.append(parse_pair(item))
|
||||
if len(points) < 2:
|
||||
raise argparse.ArgumentTypeError("至少需要两个点")
|
||||
return points
|
||||
|
||||
|
||||
def ease_out_cubic(t: float) -> float:
|
||||
return 1 - (1 - t) ** 3
|
||||
|
||||
|
||||
def make_line_track(
|
||||
start: tuple[float, float],
|
||||
end: tuple[float, float],
|
||||
duration_ms: int,
|
||||
steps: int,
|
||||
jitter: float,
|
||||
rng: random.Random,
|
||||
) -> list[dict[str, float]]:
|
||||
if steps < 2:
|
||||
raise ValueError("steps 必须至少为 2")
|
||||
result: list[dict[str, float]] = []
|
||||
sx, sy = start
|
||||
ex, ey = end
|
||||
for index in range(steps):
|
||||
progress = index / (steps - 1)
|
||||
eased = ease_out_cubic(progress)
|
||||
x = sx + (ex - sx) * eased
|
||||
y = sy + (ey - sy) * eased
|
||||
if 0 < index < steps - 1 and jitter:
|
||||
x += rng.uniform(-jitter, jitter)
|
||||
y += rng.uniform(-jitter * 0.45, jitter * 0.45)
|
||||
t = round(duration_ms * progress)
|
||||
result.append({"x": round(x, 3), "y": round(y, 3), "t": t})
|
||||
return result
|
||||
|
||||
|
||||
def make_scratch_track(
|
||||
box: tuple[float, float, float, float],
|
||||
duration_ms: int,
|
||||
rows: int,
|
||||
rng: random.Random,
|
||||
) -> list[dict[str, float]]:
|
||||
left, top, right, bottom = box
|
||||
rows = max(2, rows)
|
||||
points: list[tuple[float, float]] = []
|
||||
for row in range(rows):
|
||||
y = top + (bottom - top) * row / (rows - 1)
|
||||
y += rng.uniform(-1.5, 1.5)
|
||||
if row % 2 == 0:
|
||||
points.append((left, y))
|
||||
points.append((right, y))
|
||||
else:
|
||||
points.append((right, y))
|
||||
points.append((left, y))
|
||||
return resample_polyline(points, duration_ms, max(2, rows * 8), jitter=0.8, rng=rng)
|
||||
|
||||
|
||||
def polyline_length(points: list[tuple[float, float]]) -> float:
|
||||
total = 0.0
|
||||
for first, second in zip(points, points[1:]):
|
||||
total += math.dist(first, second)
|
||||
return total
|
||||
|
||||
|
||||
def resample_polyline(
|
||||
points: list[tuple[float, float]],
|
||||
duration_ms: int,
|
||||
steps: int,
|
||||
jitter: float,
|
||||
rng: random.Random,
|
||||
) -> list[dict[str, float]]:
|
||||
total_length = polyline_length(points)
|
||||
if total_length <= 0:
|
||||
raise ValueError("路径长度必须大于 0")
|
||||
result: list[dict[str, float]] = []
|
||||
segment_index = 0
|
||||
segment_start_distance = 0.0
|
||||
segment_length = math.dist(points[0], points[1])
|
||||
for index in range(steps):
|
||||
target_distance = total_length * index / (steps - 1)
|
||||
while segment_index < len(points) - 2 and segment_start_distance + segment_length < target_distance:
|
||||
segment_start_distance += segment_length
|
||||
segment_index += 1
|
||||
segment_length = math.dist(points[segment_index], points[segment_index + 1])
|
||||
local = 0.0 if segment_length == 0 else (target_distance - segment_start_distance) / segment_length
|
||||
x1, y1 = points[segment_index]
|
||||
x2, y2 = points[segment_index + 1]
|
||||
x = x1 + (x2 - x1) * local
|
||||
y = y1 + (y2 - y1) * local
|
||||
if 0 < index < steps - 1 and jitter:
|
||||
x += rng.uniform(-jitter, jitter)
|
||||
y += rng.uniform(-jitter, jitter)
|
||||
result.append({"x": round(x, 3), "y": round(y, 3), "t": round(duration_ms * index / (steps - 1))})
|
||||
return result
|
||||
|
||||
|
||||
def build_track(args: argparse.Namespace) -> dict[str, Any]:
|
||||
rng = random.Random(args.seed)
|
||||
if args.mode == "slider":
|
||||
start = args.start or (0.0, 0.0)
|
||||
end = (start[0] + args.distance, start[1] + args.vertical)
|
||||
points = make_line_track(start, end, args.duration_ms, args.steps, args.jitter, rng)
|
||||
elif args.mode == "drag-drop":
|
||||
if args.start is None or args.end is None:
|
||||
raise ValueError("drag-drop 需要 --start 和 --end")
|
||||
points = make_line_track(args.start, args.end, args.duration_ms, args.steps, args.jitter, rng)
|
||||
elif args.mode == "scratch":
|
||||
if args.box is None:
|
||||
raise ValueError("scratch 需要 --box")
|
||||
points = make_scratch_track(args.box, args.duration_ms, args.rows, rng)
|
||||
elif args.mode == "trace":
|
||||
if args.points is None:
|
||||
raise ValueError("trace 需要 --points")
|
||||
points = resample_polyline(args.points, args.duration_ms, args.steps, args.jitter, rng)
|
||||
else:
|
||||
raise ValueError(f"未知 mode: {args.mode}")
|
||||
return {
|
||||
"mode": args.mode,
|
||||
"coordinate_space": "element-css",
|
||||
"duration_ms": args.duration_ms,
|
||||
"points": points,
|
||||
"notes": [
|
||||
"轨迹仅用于授权验证分析。",
|
||||
"真实页面执行前必须再次确认授权范围和浏览器取证模式。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="生成验证码验证分析用轨迹 JSON")
|
||||
parser.add_argument("--mode", choices=["slider", "drag-drop", "scratch", "trace"], required=True)
|
||||
parser.add_argument("--distance", type=float, default=0.0, help="slider 水平距离")
|
||||
parser.add_argument("--vertical", type=float, default=0.0, help="slider 垂直偏移")
|
||||
parser.add_argument("--start", type=parse_pair, help="起点 x,y")
|
||||
parser.add_argument("--end", type=parse_pair, help="终点 x,y")
|
||||
parser.add_argument("--box", type=parse_box, help="scratch 区域 left,top,right,bottom")
|
||||
parser.add_argument("--points", type=parse_points, help='trace 点列,如 "10,10 80,30 120,90"')
|
||||
parser.add_argument("--duration-ms", type=int, default=1200)
|
||||
parser.add_argument("--steps", type=int, default=24)
|
||||
parser.add_argument("--rows", type=int, default=6)
|
||||
parser.add_argument("--jitter", type=float, default=1.2)
|
||||
parser.add_argument("--seed", type=int, default=7)
|
||||
parser.add_argument("--pretty", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
result = build_track(args)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""检查第二阶段验证码验证所需的离线证据是否齐备。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import imghdr
|
||||
import json
|
||||
import sys
|
||||
import wave
|
||||
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()
|
||||
|
||||
|
||||
REQUIRED_BY_TYPE = {
|
||||
"text": ["image"],
|
||||
"math": ["image_or_text"],
|
||||
"slider": ["background_or_screenshot", "track_width"],
|
||||
"click-select": ["screenshot", "prompt"],
|
||||
"rotate": ["image", "track_width"],
|
||||
"grid": ["screenshot", "prompt"],
|
||||
"audio": ["audio"],
|
||||
"drag-drop": ["screenshot", "source_target_bounds"],
|
||||
"trace-draw": ["screenshot_or_path_points"],
|
||||
"scratch": ["box"],
|
||||
"image-restore": ["image_or_tiles"],
|
||||
"area-select": ["screenshot", "prompt"],
|
||||
"difference-click": ["image_pair_or_screenshot"],
|
||||
"font-identify": ["screenshot", "prompt"],
|
||||
"semantic-reasoning": ["screenshot", "prompt"],
|
||||
"game-challenge": ["screenshot", "prompt_or_provider_key"],
|
||||
"pow-challenge": ["challenge_payload"],
|
||||
"risk-score": ["sitekey", "action_or_server_log"],
|
||||
"one-click": ["component_html"],
|
||||
"multi-step": ["round_screenshots"],
|
||||
"qa-logic": ["question"],
|
||||
"token-widget": ["sitekey", "pageurl"],
|
||||
"waf-challenge": ["headers_or_challenge_html"],
|
||||
}
|
||||
|
||||
|
||||
def inspect_file(path: Path) -> dict[str, Any]:
|
||||
item: dict[str, Any] = {
|
||||
"path": str(path),
|
||||
"exists": path.exists(),
|
||||
"is_file": path.is_file() if path.exists() else False,
|
||||
}
|
||||
if not item["is_file"]:
|
||||
return item
|
||||
item["size_bytes"] = path.stat().st_size
|
||||
image_type = imghdr.what(path)
|
||||
if image_type:
|
||||
item["kind"] = f"image/{image_type}"
|
||||
elif path.suffix.lower() in {".mp3", ".ogg", ".m4a", ".flac"}:
|
||||
item["kind"] = f"audio/{path.suffix.lower().lstrip('.')}"
|
||||
elif path.suffix.lower() == ".wav":
|
||||
item["kind"] = "audio/wav"
|
||||
try:
|
||||
with wave.open(str(path), "rb") as handle:
|
||||
item["audio"] = {
|
||||
"channels": handle.getnchannels(),
|
||||
"sample_width": handle.getsampwidth(),
|
||||
"frame_rate": handle.getframerate(),
|
||||
"frames": handle.getnframes(),
|
||||
}
|
||||
except wave.Error:
|
||||
item["audio_error"] = "无法读取 wav 元信息"
|
||||
else:
|
||||
item["kind"] = "text-or-binary"
|
||||
return item
|
||||
|
||||
|
||||
def build_report(args: argparse.Namespace) -> dict[str, Any]:
|
||||
files = [inspect_file(Path(value)) for value in args.file or []]
|
||||
provided = set(args.provided or [])
|
||||
captcha_type = args.captcha_type
|
||||
required = REQUIRED_BY_TYPE.get(captcha_type, [])
|
||||
missing = [name for name in required if name not in provided]
|
||||
return {
|
||||
"captcha_type": captcha_type,
|
||||
"required_evidence": required,
|
||||
"provided_evidence": sorted(provided),
|
||||
"missing_evidence": missing,
|
||||
"files": files,
|
||||
"ready_for_offline_flow": not missing and all(item.get("exists") for item in files),
|
||||
"notes": [
|
||||
"只检查离线证据齐备性,不打开网页、不读取 Cookie/Storage、不提交验证。",
|
||||
"如果要真实网页执行,必须按 verification-workflow.md 再次确认。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="检查验证码验证流程所需证据")
|
||||
parser.add_argument("--captcha-type", required=True)
|
||||
parser.add_argument("--file", action="append", help="证据文件路径,可重复")
|
||||
parser.add_argument("--provided", action="append", help="已具备的证据项,如 image/sitekey/pageurl")
|
||||
parser.add_argument("--pretty", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
report = build_report(args)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env python3
|
||||
"""验证码截图坐标换算工具。
|
||||
|
||||
只做离线坐标换算,不打开浏览器、不点击页面、不提交验证。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
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()
|
||||
|
||||
|
||||
def parse_pair(value: str, sep: str = ",") -> tuple[float, float]:
|
||||
parts = [part.strip() for part in value.lower().split(sep)]
|
||||
if len(parts) != 2:
|
||||
raise argparse.ArgumentTypeError(f"需要两个数值: {value}")
|
||||
return float(parts[0]), float(parts[1])
|
||||
|
||||
|
||||
def parse_size(value: str) -> tuple[float, float]:
|
||||
if "x" in value.lower():
|
||||
return parse_pair(value, sep="x")
|
||||
return parse_pair(value)
|
||||
|
||||
|
||||
def map_point(
|
||||
image_size: tuple[float, float],
|
||||
display_size: tuple[float, float],
|
||||
point: tuple[float, float],
|
||||
element_left: float,
|
||||
element_top: float,
|
||||
scroll_x: float,
|
||||
scroll_y: float,
|
||||
device_pixel_ratio: float,
|
||||
point_space: str,
|
||||
) -> dict[str, Any]:
|
||||
image_width, image_height = image_size
|
||||
display_width, display_height = display_size
|
||||
if min(image_width, image_height, display_width, display_height) <= 0:
|
||||
raise ValueError("图片尺寸和显示尺寸必须大于 0")
|
||||
if device_pixel_ratio <= 0:
|
||||
raise ValueError("device_pixel_ratio 必须大于 0")
|
||||
|
||||
x, y = point
|
||||
if point_space == "image":
|
||||
element_x = x * display_width / image_width
|
||||
element_y = y * display_height / image_height
|
||||
elif point_space == "viewport-device":
|
||||
element_x = x / device_pixel_ratio - element_left
|
||||
element_y = y / device_pixel_ratio - element_top
|
||||
elif point_space == "element-css":
|
||||
element_x = x
|
||||
element_y = y
|
||||
else:
|
||||
raise ValueError(f"未知 point_space: {point_space}")
|
||||
|
||||
viewport_x = element_left + element_x
|
||||
viewport_y = element_top + element_y
|
||||
page_x = viewport_x + scroll_x
|
||||
page_y = viewport_y + scroll_y
|
||||
|
||||
return {
|
||||
"input": {
|
||||
"point": [x, y],
|
||||
"point_space": point_space,
|
||||
"image_size": [image_width, image_height],
|
||||
"display_size": [display_width, display_height],
|
||||
"element_left": element_left,
|
||||
"element_top": element_top,
|
||||
"scroll_x": scroll_x,
|
||||
"scroll_y": scroll_y,
|
||||
"device_pixel_ratio": device_pixel_ratio,
|
||||
},
|
||||
"element_css": {"x": round(element_x, 3), "y": round(element_y, 3)},
|
||||
"viewport_css": {"x": round(viewport_x, 3), "y": round(viewport_y, 3)},
|
||||
"page_css": {"x": round(page_x, 3), "y": round(page_y, 3)},
|
||||
"notes": [
|
||||
"结果仅用于授权验证分析。",
|
||||
"真实页面点击前需要再次确认浏览器模式和授权范围。",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="验证码截图坐标换算工具")
|
||||
parser.add_argument("--image-size", required=True, type=parse_size, help="原图尺寸,如 300x150")
|
||||
parser.add_argument("--display-size", required=True, type=parse_size, help="页面显示尺寸,如 300x150")
|
||||
parser.add_argument("--point", required=True, type=parse_pair, help="输入点,如 120,75")
|
||||
parser.add_argument(
|
||||
"--point-space",
|
||||
choices=["image", "element-css", "viewport-device"],
|
||||
default="image",
|
||||
help="输入点所属坐标系",
|
||||
)
|
||||
parser.add_argument("--element-left", type=float, default=0.0, help="元素视口 left")
|
||||
parser.add_argument("--element-top", type=float, default=0.0, help="元素视口 top")
|
||||
parser.add_argument("--scroll-x", type=float, default=0.0, help="页面横向滚动")
|
||||
parser.add_argument("--scroll-y", type=float, default=0.0, help="页面纵向滚动")
|
||||
parser.add_argument("--device-pixel-ratio", type=float, default=1.0, help="DPR")
|
||||
parser.add_argument("--pretty", action="store_true", help="缩进输出 JSON")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
result = map_point(
|
||||
image_size=args.image_size,
|
||||
display_size=args.display_size,
|
||||
point=args.point,
|
||||
element_left=args.element_left,
|
||||
element_top=args.element_top,
|
||||
scroll_x=args.scroll_x,
|
||||
scroll_y=args.scroll_y,
|
||||
device_pixel_ratio=args.device_pixel_ratio,
|
||||
point_space=args.point_space,
|
||||
)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""生成打码平台请求模板。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
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()
|
||||
|
||||
|
||||
def template_for(platform: str, captcha_type: str, provider: str) -> dict[str, Any]:
|
||||
base: dict[str, Any] = {
|
||||
"platform": platform,
|
||||
"captcha_type": captcha_type,
|
||||
"provider": provider,
|
||||
"send_request": False,
|
||||
"api_key": "<由用户运行时提供,不要写入文件>",
|
||||
"authorization_required": True,
|
||||
"notes": [
|
||||
"这是请求模板,不会发送网络请求。",
|
||||
"只用于自有系统或明确授权 QA。",
|
||||
"脚本不读取 API key,不保存凭据。",
|
||||
"不要把 token 注入未授权流程。",
|
||||
],
|
||||
}
|
||||
|
||||
token_providers = {
|
||||
"recaptcha",
|
||||
"hcaptcha",
|
||||
"cloudflare-turnstile",
|
||||
"arkose-funcaptcha",
|
||||
"mtcaptcha",
|
||||
"yandex-smartcaptcha",
|
||||
"captchafox",
|
||||
"prosopo-procaptcha",
|
||||
"trustcaptcha",
|
||||
}
|
||||
|
||||
if captcha_type in {"text", "math"}:
|
||||
base["payload"] = {
|
||||
"method": "image",
|
||||
"image": "<captcha image base64 or file>",
|
||||
"constraints": {"charset": "<optional>", "length": "<optional>", "case_sensitive": False},
|
||||
}
|
||||
elif captcha_type in {
|
||||
"click-select",
|
||||
"grid",
|
||||
"area-select",
|
||||
"difference-click",
|
||||
"font-identify",
|
||||
"semantic-reasoning",
|
||||
}:
|
||||
base["payload"] = {
|
||||
"method": "coordinates",
|
||||
"image": "<challenge screenshot base64 or file>",
|
||||
"prompt": "<题面文本>",
|
||||
"coordinate_space": "image",
|
||||
"expected_output": "ordered_points_or_grid_indexes",
|
||||
}
|
||||
elif captcha_type in {"slider", "rotate", "image-restore"}:
|
||||
base["payload"] = {
|
||||
"method": "image_or_slider",
|
||||
"background": "<background image>",
|
||||
"piece": "<optional slider piece>",
|
||||
"screenshot": "<optional single challenge image>",
|
||||
"expected_output": "offset_or_angle",
|
||||
}
|
||||
elif captcha_type == "audio":
|
||||
base["payload"] = {
|
||||
"method": "audio",
|
||||
"audio": "<audio file or base64>",
|
||||
"language": "<optional>",
|
||||
"answer_format": "<digits/letters/text>",
|
||||
}
|
||||
elif captcha_type == "game-challenge" or provider == "arkose-funcaptcha":
|
||||
base["payload"] = {
|
||||
"method": "funcaptcha",
|
||||
"websiteURL": "<page url>",
|
||||
"websitePublicKey": "<arkose public key>",
|
||||
"funcaptchaApiJSSubdomain": "<optional surl>",
|
||||
"data": {"blob": "<optional blob>"},
|
||||
}
|
||||
elif captcha_type in {"token-widget", "risk-score", "one-click"} or provider in token_providers:
|
||||
base["payload"] = {
|
||||
"method": "token",
|
||||
"websiteURL": "<page url>",
|
||||
"websiteKey": "<sitekey>",
|
||||
"action": "<optional action>",
|
||||
"enterprise": "<optional bool>",
|
||||
"extra": {"cdata": "<optional>", "rqdata": "<optional>"},
|
||||
}
|
||||
elif captcha_type == "pow-challenge":
|
||||
base["payload"] = {
|
||||
"method": "pow",
|
||||
"challenge": "<challenge/payload>",
|
||||
"difficulty": "<optional>",
|
||||
"nonce": "<optional>",
|
||||
"note": "优先按官方协议在自有系统中校验,通常不需要打码平台。",
|
||||
}
|
||||
elif captcha_type == "waf-challenge":
|
||||
base["payload"] = {
|
||||
"method": "waf_diagnostic",
|
||||
"pageURL": "<page url>",
|
||||
"headers": "<response headers>",
|
||||
"challengeHTML": "<challenge html>",
|
||||
"note": "优先做 WAF/浏览器环境诊断;平台仅作授权对照。",
|
||||
}
|
||||
else:
|
||||
base["payload"] = {
|
||||
"method": "custom",
|
||||
"evidence": "<html/scripts/screenshot/prompt>",
|
||||
"expected_output": "<answer/coordinates/offset/token/diagnosis>",
|
||||
}
|
||||
|
||||
return base
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description="生成打码平台请求模板")
|
||||
parser.add_argument("--platform", required=True, help="平台名,如 2captcha/capsolver/jfbym")
|
||||
parser.add_argument("--captcha-type", required=True)
|
||||
parser.add_argument("--provider", default="custom-or-unknown")
|
||||
parser.add_argument("--pretty", action="store_true")
|
||||
return parser
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = build_parser().parse_args()
|
||||
result = template_for(args.platform, args.captcha_type, args.provider)
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2 if args.pretty else None))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,483 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user