reverse-skills-max: initial public release (restored, redacted, hardened)

This commit is contained in:
baradok609hecl1pq6qi
2026-10-20 02:17:15 +03:00
commit da24c2bd20
2227 changed files with 443931 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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