Files
Mortal-Rating/main.py
e2hang 9f4ff8fdc6 无头化改造 + 移除 Turnstile 模拟点击 + 跨平台支持
- 删除 uc_gui_click_captcha / _poke_captcha,Turnstile 改为无头自动放行
- _wait_for_turnstile_token 改为纯轮询,新增 _acquire_turnstile_token 做 JS 重置 + 重开页面恢复
- headless 默认 True,--headless 改用 BooleanOptionalAction(--no-headless 调试)
- 新增 proxy: "direct" 配置,通过 chromium_arg --proxy-server=direct:// 绕过系统代理
  (mjai.ekyu.moe Turnstile 风控机房代理 IP,需直连)
- install_uc_driver.py 平台感知:Linux/Mac/Win 选对应 chromedriver 包和二进制名
- .gitignore 加 .DS_Store/venv/pytest_cache/个人配置
- README 加 Fedora 支持、proxy direct、headless 默认说明
- docs/headless-turnstile-troubleshooting.md 排查记录
2026-07-16 15:46:25 +08:00

781 lines
29 KiB
Python

import argparse
import logging
import os
import sys
import time
import urllib.request
from datetime import datetime, timezone
from batchmortal.api import build_paipu_urls, get_player_records, search_player, get_player_nickname_by_id
from batchmortal.browser import (
BrowserAutomator,
ReviewSubmissionCoordinator,
build_browser_kwargs,
normalize_review_language,
normalize_review_ui,
)
from batchmortal.results import ResultWriter, parse_metadata, get_processed_uuids, read_result_rows
from batchmortal.tenhou import (
build_tenhou_paipu_urls,
fetch_tenhou_player_records,
normalize_tenhou_modes,
)
from batchmortal.visualize import plot_results
from seleniumbase import SB
from batchmortal.config import (
load_config,
normalize_source_mode,
resolve_mode_config,
source_for_mode,
)
def configure_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s %(message)s",
datefmt="%H:%M:%S",
force=True,
)
def log_line(message=""):
logging.info(message)
def parse_review_language(value):
try:
return normalize_review_language(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def parse_review_ui(value):
try:
return normalize_review_ui(value)
except ValueError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def parse_args():
pre_parser = argparse.ArgumentParser(add_help=False)
pre_parser.add_argument("--config", help="Path to config file (yaml or toml)")
pre_parser.add_argument("--mode")
pre_parser.add_argument("--source", "--platform", dest="legacy_source")
pre_args, _ = pre_parser.parse_known_args()
raw_config = load_config(pre_args.config)
requested_mode = pre_args.mode or pre_args.legacy_source
try:
config_mode, _, config = resolve_mode_config(
raw_config,
requested_mode=requested_mode,
)
except ValueError as exc:
pre_parser.error(str(exc))
parser = argparse.ArgumentParser(
description="Batch Mortal Analysis Script (Python/SeleniumBase Edition)",
formatter_class=argparse.ArgumentDefaultsHelpFormatter
)
# -- General Options --
parser.add_argument("--config", help="Path to config file (yaml or toml)")
source_group = parser.add_mutually_exclusive_group()
source_group.add_argument(
"--mode",
choices=["mj", "th", "0", "1"],
default=config_mode,
help="Exclusive source mode: mj/0 for Mahjong Soul, th/1 for Tenhou",
)
source_group.add_argument(
"--source", "--platform",
choices=["majsoul", "tenhou"],
default=None,
help="Legacy source selector; use --mode for new configurations",
dest="legacy_source",
)
dry_run_default = config.get("dry_run", False)
parser.add_argument(
"--dry-run", "--dry_run",
action="store_true" if not dry_run_default else "store_false",
default=dry_run_default,
help="Only print URLs, skip browser",
dest="dry_run"
)
# -- Target Options --
target_group = parser.add_argument_group("Target Options")
target_group.add_argument(
"-p", "-u", "--player", dest="player", default=config.get("player") or config.get("nickname"), help="Player nickname on the selected source"
)
target_group.add_argument(
"-a", "--account-id", "--account_id", dest="account_id", type=int, default=config.get("account_id"), help="Directly specify player account ID"
)
# -- Analysis Options --
analysis_group = parser.add_argument_group("Analysis Options")
analysis_group.add_argument(
"--limit", type=int, default=config.get("limit", 10), help="Max records per mode"
)
analysis_group.add_argument(
"--modes",
default=None,
help="Comma-separated modes: Mahjong Soul numeric IDs, or Tenhou all/4p/4p-east/4p-south/3p/3p-east/3p-south",
)
analysis_group.add_argument(
"--model-tag", "--model_tag", default=config.get("model_tag", "4.1b"), help="Mortal network version", dest="model_tag"
)
review_language_config = config.get("review_language", config.get("lang"))
try:
review_language_default = normalize_review_language(review_language_config)
except ValueError as exc:
parser.error(str(exc))
analysis_group.add_argument(
"--review-language", "--review_language", "--lang",
default=review_language_default,
type=parse_review_language,
metavar="{zh-CN,en,ja,ko}",
help="Review page language; writes the mjai.ekyu.moe form field select[name='lang']",
dest="review_language"
)
review_ui_config = config.get("review_ui", config.get("ui"))
try:
review_ui_default = normalize_review_ui(review_ui_config)
except ValueError as exc:
parser.error(str(exc))
analysis_group.add_argument(
"--review-ui", "--review_ui", "--ui",
default=review_ui_default,
type=parse_review_ui,
metavar="{classic,killerducky}",
help="Review result UI; KillerDucky metadata and bad-move data are parsed from report JSON",
dest="review_ui",
)
analysis_group.add_argument(
"--retry", type=int, default=config.get("retry", 3), help="Retry failed review items this many times"
)
analyze_bad_move_rate_default = bool(config.get("analyze_bad_move_rate", False))
analysis_group.add_argument(
"--badmove",
action="store_true",
default=analyze_bad_move_rate_default,
help="Analyze bad move rate from the Mortal result page",
dest="analyze_bad_move_rate"
)
# -- Browser / Network Options --
browser_group = parser.add_argument_group("Browser & Network Options")
headless_default = config.get("headless", True)
browser_group.add_argument(
"--headless",
action=argparse.BooleanOptionalAction,
default=headless_default,
help="Run browser headlessly (default: on)",
dest="headless"
)
browser_group.add_argument(
"--proxy",
default=config.get("proxy"),
help='Proxy URL (e.g. http://127.0.0.1:7890), or "direct" to bypass all proxies including the system proxy (needed for mjai.ekyu.moe Turnstile)',
)
# -- Output Options --
output_group = parser.add_argument_group("Output Options")
output_group.add_argument(
"--output", choices=["csv", "xlsx"], default=config.get("output", "xlsx"), help="Output format"
)
output_group.add_argument(
"--plot", choices=["none", "html", "png", "both"], default=config.get("plot", "none"), help="Generate a plot after analysis"
)
output_group.add_argument(
"--plot-limit", "--plot_limit", type=int, default=config.get("plot_limit"), help="Only use the latest N records for chart (default: all)", dest="plot_limit"
)
save_local_paipu_default = bool(config.get("save_local_paipu", False))
output_group.add_argument(
"--save-local",
action="store_true",
default=save_local_paipu_default,
help="Save a local HTML copy of each Mortal result page",
dest="save_local_paipu"
)
save_screenshot_default = config.get("save_screenshot", False)
output_group.add_argument(
"--save-screenshot", "--save_screenshot",
action="store_true" if not save_screenshot_default else "store_false",
default=save_screenshot_default,
help="Save screenshot of the results",
dest="save_screenshot"
)
# -- Advanced Submission Options --
submit_group = parser.add_argument_group("Advanced Submission Options")
unsafe_parallel_default = config.get("unsafe_parallel_review", False)
submit_group.add_argument(
"--unsafe-parallel-review", "--unsafe_parallel_review",
action="store_true" if not unsafe_parallel_default else "store_false",
default=unsafe_parallel_default,
help="Allow concurrent review submissions",
dest="unsafe_parallel_review"
)
submit_group.add_argument(
"--submit-interval", "--submit_interval", type=float, default=config.get("submit_interval", 6.0), help="Minimum spacing between controlled submissions in seconds", dest="submit_interval"
)
submit_group.add_argument(
"--submit-cooldown", "--submit_cooldown", type=float, default=config.get("submit_cooldown", 30.0), help="Cooldown seconds after repeated review failures", dest="submit_cooldown"
)
prewarm_standby_default = config.get("prewarm_standby", False)
submit_group.add_argument(
"--prewarm-standby", "--prewarm_standby",
action="store_true" if not prewarm_standby_default else "store_false",
default=prewarm_standby_default,
help="Experimental: use two persistent windows and alternate focus",
dest="prewarm_standby"
)
# -- Legacy Options --
legacy_group = parser.add_argument_group("Legacy Options")
no_manual_verification_default = config.get("no_manual_verification", False)
legacy_group.add_argument(
"--no-manual-verification", "--no_manual_verification",
action="store_true" if not no_manual_verification_default else "store_false",
default=no_manual_verification_default,
help="Legacy flag kept for compatibility",
dest="no_manual_verification"
)
legacy_group.add_argument(
"--flare-url", "--flare_url", default=config.get("flare_url"), help="Legacy flag kept for compatibility", dest="flare_url"
)
args = parser.parse_args()
if args.legacy_source:
args.mode = normalize_source_mode(args.legacy_source)
else:
args.mode = normalize_source_mode(args.mode)
args.source = source_for_mode(args.mode)
if args.modes is None:
fallback = "all" if args.source == "tenhou" else "9"
args.modes = str(config.get("modes", fallback))
if args.source == "tenhou":
if not args.player:
parser.error("Tenhou source requires -p/--player (a Tenhou player name)")
if args.account_id:
parser.error("--account-id is only supported by the majsoul source")
elif not args.player and not args.account_id:
parser.error("Mahjong Soul source requires -p/--player or -a/--account-id")
args.target_name = args.player if args.player else str(args.account_id)
return args
def build_output_path(nickname: str, output_format: str, source: str = "majsoul") -> tuple[str, str]:
safe_nick = "".join(
c if c.isalnum() or c in ("_", "-", "\u4e00", "\u9fa5") else "_"
for c in nickname
)
results_root = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"results",
source,
)
output_root = os.path.join(results_root, safe_nick)
out_path = os.path.join(output_root, f"results.{output_format}")
return output_root, out_path
def detect_proxy(explicit_proxy: str | None) -> str | None:
if explicit_proxy:
if explicit_proxy.strip().lower() == "direct":
return "direct"
return explicit_proxy
sys_proxies = urllib.request.getproxies()
return sys_proxies.get("https") or sys_proxies.get("http")
def finalize_tasks(tasks: list[dict]) -> list[dict]:
total_tasks = len(tasks)
for index, task in enumerate(tasks, start=1):
task["idx"] = index
task["total"] = total_tasks
short_url = task["uuid"].split("-")[-1]
task["log_prefix"] = f"[{index}/{total_tasks}][{short_url}]"
return tasks
def collect_majsoul_tasks(account_id: int, modes: list[int], limit: int, output_root: str, processed_uuids: set) -> list[dict]:
tasks = []
for mode in modes:
log_line(f"[Mode {mode}] Fetching records...")
try:
records = get_player_records(account_id, limit, mode)
except Exception as exc:
logging.error(f"[ERROR] mode={mode}: {exc} - skipping this mode")
continue
if not records:
logging.info(f"[mode={mode}] No records found. Skipping.")
continue
items = build_paipu_urls(records, account_id)
mode_dir = os.path.join(output_root, f"mode_{mode}")
for item in items:
if item["uuid"] in processed_uuids:
log_line(f"[Skip] uuid={item['uuid']} already processed.")
continue
tasks.append(
{
"source": "majsoul",
"mode": mode,
"uuid": item["uuid"],
"paipu_url": item["paipuUrl"],
"start_time": item.get("startTime", ""),
"end_time": item.get("endTime", ""),
"mode_dir": mode_dir,
}
)
return finalize_tasks(tasks)
def collect_tenhou_tasks(
records: list[dict],
player_name: str,
modes: tuple[str, ...],
limit: int,
output_root: str,
processed_uuids: set,
) -> list[dict]:
items = build_tenhou_paipu_urls(records, player_name, modes=modes, limit=limit)
tasks = []
for item in items:
if item["uuid"] in processed_uuids:
log_line(f"[Skip] uuid={item['uuid']} already processed.")
continue
mode = item["mode"]
tasks.append(
{
"source": "tenhou",
"mode": mode,
"uuid": item["uuid"],
"paipu_url": item["paipuUrl"],
"start_time": item.get("startTime", ""),
"end_time": item.get("endTime", ""),
"mode_dir": os.path.join(output_root, f"mode_{mode}"),
}
)
return finalize_tasks(tasks)
def print_summary(args, modes):
log_line("=== Batch Mortal Analysis ===")
target_display = args.target_name + (f" (ID: {args.account_id})" if args.account_id and args.target_name != str(args.account_id) else "")
log_line(f" Target: {target_display}")
log_line(f" Mode: {args.mode} ({args.source})")
log_line(f" Modes: {modes}")
log_line(f" Limit: {args.limit} per mode")
log_line(f" ModelTag: {args.model_tag}")
log_line(f" Language: {args.review_language}")
log_line(f" ReviewUI: {args.review_ui}")
log_line(f" Headless: {args.headless}")
log_line(f" DryRun: {args.dry_run}")
log_line(f" Retry: {args.retry}")
log_line(f" BadMove: {args.analyze_bad_move_rate}")
log_line(f" SaveLocal: {args.save_local_paipu}")
log_line("=============================")
def parse_float(value) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip().replace("%", "")
if not text:
return None
try:
return float(text)
except ValueError:
return None
def create_analysis_stats() -> dict:
return {
"rating_sum": 0.0,
"rating_count": 0,
"ai_rate_sum": 0.0,
"ai_rate_count": 0,
"bad_move_5_sum": 0.0,
"bad_move_5_count": 0,
"bad_move_10_sum": 0.0,
"bad_move_10_count": 0,
}
def add_average_sample(stats: dict, prefix: str, value):
parsed = parse_float(value)
if parsed is None:
return
stats[f"{prefix}_sum"] += parsed
stats[f"{prefix}_count"] += 1
def format_average(stats: dict, prefix: str, suffix: str = "") -> str:
count = stats.get(f"{prefix}_count", 0)
if not count:
return "N/A"
return f"{stats[f'{prefix}_sum'] / count:.3f}{suffix}"
def log_final_averages(args, stats: dict):
log_line(f" AvgRating: {format_average(stats, 'rating')}")
log_line(f" AvgMatch: {format_average(stats, 'ai_rate', '%')}")
if args.analyze_bad_move_rate:
log_line(f" AvgBadMove5: {format_average(stats, 'bad_move_5', '%')}")
log_line(f" AvgBadMove10: {format_average(stats, 'bad_move_10', '%')}")
def add_result_row_to_stats(stats: dict, row: dict, include_bad_move: bool):
if str(row.get("rating", "")).strip() == "ERROR":
return
add_average_sample(stats, "rating", row.get("rating", ""))
add_average_sample(stats, "ai_rate", row.get("aiConsistencyRate", ""))
if include_bad_move:
add_average_sample(stats, "bad_move_5", row.get("badMoveRate5", ""))
add_average_sample(stats, "bad_move_10", row.get("badMoveRate10", ""))
def create_analysis_stats_from_rows(rows: list[dict], include_bad_move: bool) -> dict:
stats = create_analysis_stats()
for row in rows:
add_result_row_to_stats(stats, row, include_bad_move)
return stats
def load_final_analysis_stats(out_path: str, output_format: str, include_bad_move: bool) -> dict | None:
try:
rows = read_result_rows(out_path, output_format)
except Exception as exc:
logging.warning(f"Failed to read final result stats from {out_path}: {exc}")
return None
return create_analysis_stats_from_rows(rows, include_bad_move)
def consume_result_event(args, writer: ResultWriter, result_event: dict, stats: dict | None = None) -> tuple[int, int]:
task = result_event["task"]
timestamp = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
base_row = {
"nickname": args.target_name,
"source": task.get("source", args.source),
"mode": task["mode"],
"uuid": task["uuid"],
"paipuUrl": task["paipu_url"],
"startTime": task.get("start_time", ""),
"endTime": task.get("end_time", ""),
"timestamp": timestamp,
}
if result_event["status"] == "success":
result = result_event["result"]
parsed = parse_metadata(result["metadata"])
bad_move_stats = result.get("badMoveStats") or {}
rating_value = parse_float(parsed.get("rating", ""))
if stats is not None:
add_average_sample(stats, "rating", rating_value)
add_average_sample(stats, "ai_rate", parsed.get("aiConsistencyRate", ""))
if args.analyze_bad_move_rate:
add_average_sample(stats, "bad_move_5", bad_move_stats.get("badMoveRate5", ""))
add_average_sample(stats, "bad_move_10", bad_move_stats.get("badMoveRate10", ""))
writer.write_row(
{
**base_row,
"resultUrl": result["resultUrl"],
"localPaipuPath": result.get("localPaipuPath", ""),
"modelTag": parsed.get("modelTag") or args.model_tag,
"rating": rating_value if rating_value is not None else "",
"aiConsistencyRate": parsed.get("aiConsistencyRate", ""),
"aiConsistencyNumerator": parsed.get("aiConsistencyNumerator", ""),
"aiConsistencyDenominator": parsed.get("aiConsistencyDenominator", ""),
"temperature": parsed.get("temperature", ""),
"gameLength": parsed.get("gameLength", ""),
"playerId": parsed.get("playerId", ""),
"reviewDuration": parsed.get("reviewDuration", ""),
"screenshotPath": result.get("screenshotPath", ""),
"badMoveRate5": bad_move_stats.get("badMoveRate5", ""),
"badMoveCount5": bad_move_stats.get("badMoveCount5", ""),
"badMoveRate10": bad_move_stats.get("badMoveRate10", ""),
"badMoveCount10": bad_move_stats.get("badMoveCount10", ""),
"badMoveDenominator": bad_move_stats.get("badMoveDenominator", ""),
}
)
message = (
f"{task['log_prefix']} OK "
f"rating={parsed.get('rating', 'N/A')} "
f"match={parsed.get('aiConsistencyRate', 'N/A')}"
)
if args.analyze_bad_move_rate:
bad_move_denominator = bad_move_stats.get("badMoveDenominator", "")
bad_move_5 = (
f"{bad_move_stats.get('badMoveCount5', '')}/{bad_move_denominator}="
f"{bad_move_stats.get('badMoveRate5', '')}"
if bad_move_denominator
else "N/A"
)
bad_move_10 = (
f"{bad_move_stats.get('badMoveCount10', '')}/{bad_move_denominator}="
f"{bad_move_stats.get('badMoveRate10', '')}"
if bad_move_denominator
else "N/A"
)
message += f" badMove5={bad_move_5} badMove10={bad_move_10}"
log_line(message)
return 1, 0
writer.write_row(
{
**base_row,
"modelTag": args.model_tag,
"rating": "ERROR",
}
)
log_line(f"{task['log_prefix']} ERROR")
return 0, 1
def run_parallel_analysis(
args,
tasks: list[dict],
out_path: str,
automator: BrowserAutomator,
stats: dict | None = None,
) -> tuple[int, int]:
total_processed = 0
total_failed = 0
writer = ResultWriter(out_path, args.output)
for task in tasks:
task["model_tag"] = args.model_tag
task["save_screenshot"] = args.save_screenshot
task["save_local_paipu"] = args.save_local_paipu
task["analyze_bad_move_rate"] = args.analyze_bad_move_rate
log_line("[Serial] Starting analysis with 1 persistent browser")
try:
with SB(**build_browser_kwargs(automator.headless, automator.proxy)) as sb:
for task in tasks:
result_event = None
for attempt in range(args.retry + 1):
try:
result = automator.analyze_single(sb, task)
result_event = {"status": "success", "task": task, "result": result}
break
except Exception as exc:
prefix = task["log_prefix"]
logging.error(f"{prefix} ERROR exception: {exc}")
if attempt < args.retry:
logging.warning(
f"{prefix} RETRY ({attempt + 1}/{args.retry}) with a fresh page load."
)
continue
logging.error(
f"{prefix} SKIP permanently failed after {args.retry} retries."
)
result_event = {"status": "fail", "task": task}
break
succeeded, failed = consume_result_event(args, writer, result_event, stats)
total_processed += succeeded
total_failed += failed
finally:
writer.close()
return total_processed, total_failed
def run_controlled_pipeline_analysis(
args,
tasks: list[dict],
out_path: str,
automator: BrowserAutomator,
stats: dict | None = None,
) -> tuple[int, int]:
total_processed = 0
total_failed = 0
writer = ResultWriter(out_path, args.output)
for task in tasks:
task["model_tag"] = args.model_tag
task["save_screenshot"] = args.save_screenshot
task["save_local_paipu"] = args.save_local_paipu
task["analyze_bad_move_rate"] = args.analyze_bad_move_rate
log_line("[Alternate] Starting two-window alternating review flow")
try:
for result_event in automator.iter_alternating_windows(tasks, max_retries=args.retry):
succeeded, failed = consume_result_event(args, writer, result_event, stats)
total_processed += succeeded
total_failed += failed
finally:
writer.close()
return total_processed, total_failed
def ensure_uc_driver():
import seleniumbase
sb_dir = seleniumbase.__path__[0]
drivers_dir = os.path.join(sb_dir, 'drivers')
uc_name = 'uc_driver.exe' if os.name == 'nt' else 'uc_driver'
if not os.path.exists(os.path.join(drivers_dir, uc_name)):
logging.warning("uc_driver not found locally. Preparing to install via domestic mirror...")
try:
import install_uc_driver
install_uc_driver.install_uc_driver()
except ImportError:
logging.error("install_uc_driver module not found. Please ensure install_uc_driver.py is in the project root.")
except Exception as e:
logging.error(f"Auto-installation of uc_driver failed: {e}")
def main():
configure_logging()
start_time = time.time()
args = parse_args()
args.retry = max(0, args.retry)
try:
if args.source == "tenhou":
modes = normalize_tenhou_modes(args.modes)
else:
modes = [int(mode.strip()) for mode in args.modes.split(",") if mode.strip()]
if not modes:
raise ValueError("At least one Mahjong Soul mode is required.")
except ValueError as exc:
logging.error(f"[FATAL] {exc}")
sys.exit(2)
if not args.dry_run:
ensure_uc_driver()
account_id = None
tenhou_records = None
try:
if args.source == "tenhou":
args.target_name, tenhou_records = fetch_tenhou_player_records(args.player)
elif args.account_id:
account_id = args.account_id
if not args.player:
# Attempt to fetch nickname to use as the target
fetched_name = get_player_nickname_by_id(account_id)
if fetched_name:
args.target_name = fetched_name
logging.info(f"[API] Fetched nickname: '{fetched_name}' for account_id={account_id}")
else:
account_id = search_player(args.player)
except Exception as exc:
logging.error(f"[FATAL] {exc}")
sys.exit(1)
print_summary(args, modes)
output_root, out_path = build_output_path(args.target_name, args.output, args.source)
processed_uuids = get_processed_uuids(out_path, args.output)
proxy = detect_proxy(args.proxy)
if proxy == "direct":
logging.info("[Proxy] Forcing direct connection (bypassing system proxy) for browser.")
elif proxy:
logging.info(f"[Proxy] Using proxy for browser: {proxy}")
else:
logging.info("[Proxy] No system proxy detected, running directly.")
if args.source == "tenhou":
tasks = collect_tenhou_tasks(
tenhou_records,
args.target_name,
modes,
args.limit,
output_root,
processed_uuids,
)
else:
tasks = collect_majsoul_tasks(account_id, modes, args.limit, output_root, processed_uuids)
total_processed = 0
total_failed = 0
analysis_stats = create_analysis_stats()
if args.dry_run:
for task in tasks:
log_line(f"{task['log_prefix']} dry-run mode={task['mode']} paipu_url={task['paipu_url']}")
total_processed += 1
elif tasks:
if args.unsafe_parallel_review:
automator = BrowserAutomator(
headless=args.headless,
proxy=proxy,
review_language=args.review_language,
review_ui=args.review_ui,
submission_coordinator=None,
controlled_submission=False,
)
total_processed, total_failed = run_parallel_analysis(args, tasks, out_path, automator, analysis_stats)
elif args.prewarm_standby and len(tasks) >= 2:
submission_coordinator = ReviewSubmissionCoordinator(
base_interval=min(args.submit_interval, 1.0),
cooldown_seconds=args.submit_cooldown,
)
automator = BrowserAutomator(
headless=args.headless,
proxy=proxy,
review_language=args.review_language,
review_ui=args.review_ui,
submission_coordinator=submission_coordinator,
controlled_submission=True,
)
total_processed, total_failed = run_controlled_pipeline_analysis(args, tasks, out_path, automator, analysis_stats)
else:
submission_coordinator = ReviewSubmissionCoordinator(
base_interval=args.submit_interval,
cooldown_seconds=args.submit_cooldown,
)
automator = BrowserAutomator(
headless=args.headless,
proxy=proxy,
review_language=args.review_language,
review_ui=args.review_ui,
submission_coordinator=submission_coordinator,
controlled_submission=True,
)
total_processed, total_failed = run_parallel_analysis(args, tasks, out_path, automator, analysis_stats)
elapsed = time.time() - start_time
final_stats = analysis_stats
if not args.dry_run:
final_stats = load_final_analysis_stats(out_path, args.output, args.analyze_bad_move_rate) or analysis_stats
log_line("=== Done ===")
log_line(f" Succeeded: {total_processed}")
log_line(f" Failed: {total_failed}")
log_final_averages(args, final_stats)
log_line(f" Time: {elapsed:.2f}s")
if not args.dry_run:
log_line(f" Output: {out_path}")
plot_results(
args.target_name,
args.plot,
args.output,
plot_limit=args.plot_limit,
output_root=output_root,
)
log_line("============")
if __name__ == "__main__":
main()