Files
reverse-skills-max/vendor-new/xbsReverseSkill/web-verify-patcher/scripts/analyze_tile_restore.py

422 lines
17 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())