#!/usr/bin/env python3 """ Arena.ai Leaderboard Parser Парсер лидербордов arena.ai: подаёшь модель — получаешь рейтинг. Кеш на 24 часа. Разделы: text — text/overall: Arena Score (ELO), голоса, цена, контекст (413 моделей) agent — agent/overall: tool-calling метрики (Net Improvement, Bash Recovery, Tool Hallucination, Steerability, Cost/Task) — 51 модель Standalone: только stdlib (urllib + re + json). CLI: python3 arena_leaderboard.py # text: топ-10 python3 arena_leaderboard.py glm-5.3-max # text: рейтинг модели python3 arena_leaderboard.py --board agent # agent: топ-10 python3 arena_leaderboard.py --board agent claude # agent: модель/поиск python3 arena_leaderboard.py --refresh glm # игнорировать кеш """ import json import re import sys import time import urllib.request from pathlib import Path from typing import Optional BOARDS = { "text": "https://arena.ai/leaderboard/text/overall", "agent": "https://arena.ai/leaderboard/agent", } CACHE_DIR = Path("/opt/data/cache") CACHE_TTL = 86400 # 24h HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " "(KHTML, like Gecko) Chrome/131.0 Safari/537.36", "Accept": "text/html,application/xhtml+xml", "Accept-Language": "en-US,en;q=0.9", } def _cache_file(board: str) -> Path: return CACHE_DIR / f"arena_leaderboard_{board}.json" # ---------------------------------------------------------------- fetching def _fetch_content(board: str) -> tuple: """Получает контент страницы (markdown/html). Сначала пробует firecrawl (даёт полный Markdown таблицы на 416 строк), затем failover-кольцо Hermes (обход Cloudflare WAF). При сбое — fallback на прямой urllib запрос. """ url = BOARDS[board] # 1. Попытка через Firecrawl / Keyless ring Hermes try: if "/opt/hermes" not in sys.path: sys.path.insert(0, "/opt/hermes") from plugins.web.keyless_mcp import firecrawl_extract_keyless, extract_with_failover # Firecrawl стабильно рендерит полную markdown таблицу (68KB, 416 моделей) try: res = firecrawl_extract_keyless([url]) if res and not res[0].get("error"): c = res[0].get("content", "") if c and len(c) > 20000: return "markdown", c except Exception: pass res = extract_with_failover("firecrawl", [url]) if res and not res[0].get("error"): c = res[0].get("content", "") if c and len(c) > 500: return "markdown", c except Exception: pass # 2. Fallback на прямой urllib try: req = urllib.request.Request(url, headers=HEADERS) with urllib.request.urlopen(req, timeout=30) as r: html = r.read().decode("utf-8", errors="replace") return "html", html except Exception as e: raise RuntimeError(f"Не удалось получить {url} (Cloudflare block?): {e}") # ---------------------------------------------------------------- parsing helpers def _strip_tags(s: str) -> str: return re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s)).strip() def _pct(cell_html: str) -> str: """Процентная ячейка agent-борда: '13.82%' + '±2.17%' -> '13.82%±2.17%'.""" txt = re.sub(r"", "", cell_html) m = re.search(r"(-?\d+(?:\.\d+)?)\s*%", txt) if not m: return "" out = f"{m.group(1)}%" ci = re.search(r"±\s*(\d+(?:\.\d+)?)\s*%", txt) if ci: out += f"±{ci.group(1)}%" return out def _model_from_cell(model_cell: str) -> tuple: """(name, provider) из ячейки модели.""" mm = (re.search(r']*title="([^"]+)"', model_cell) or re.search(r"]*>(.*?)", model_cell, re.S)) if not mm: return "", "" name = mm.group(1) if "<" in name: name = _strip_tags(name) pm = re.search(r']*class="[^"]*text-text-secondary[^"]*"[^>]*>(.*?)', model_cell, re.S) provider = _strip_tags(pm.group(1)) if pm else "" return name.strip(), provider def _parse_text_rows(html: str) -> list: """text/overall: Rank | Rank Spread | Model | Score | Votes | Price | Context.""" start = html.find("") if start == -1: raise ValueError("Таблица text не найдена (нет )") tr_re = re.compile(r"]*>(.*?)", re.S) td_re = re.compile(r"]*>(.*?)", re.S) rows = [] for tr_html in tr_re.findall(html[start:]): cells = td_re.findall(tr_html) if len(cells) < 6: continue rm = re.search(r">\s*(\d+)\s*<", cells[0]) if not rm: continue model, provider = _model_from_cell(cells[2]) if not model: continue score_cell = cells[3] sm = re.search(r">\s*(\d{3,4})\s*<", score_cell) score = sm.group(1) if sm else "" if score: ci = re.search(r"±\s*(\d+)", score_cell) if ci: score += f"±{ci.group(1)}" rows.append({ "rank": int(rm.group(1)), "model": model, "provider": provider, "score": score, "preliminary": "Preliminary" in score_cell, "rank_spread": " ".join(re.findall(r">\s*(\d+)\s*<", cells[1])), "votes": int(re.sub(r"[^\d]", "", _strip_tags(cells[4])) or 0), "price": _strip_tags(cells[5]), "context": _strip_tags(cells[6]) if len(cells) > 6 else "", }) return rows def _parse_agent_rows(html: str) -> list: """agent/overall: Rank | Model | Net Improvement | Confirmed Success | Praise vs Complaint | Steerability | Bash Recovery | Tool Hallucination | Sessions | Cost/Task | Tokens/Task | Price.""" start = html.find("") if start == -1: raise ValueError("Таблица agent не найдена (нет )") tr_re = re.compile(r"]*>(.*?)", re.S) td_re = re.compile(r"]*>(.*?)", re.S) rows = [] for tr_html in tr_re.findall(html[start:]): cells = td_re.findall(tr_html) if len(cells) < 9: continue rm = re.search(r">\s*(\d+)\s*<", cells[0]) if not rm: continue model, provider = _model_from_cell(cells[1]) if not model: continue def pct(i): return _pct(cells[i]) if len(cells) > i else "" def plain(i): return _strip_tags(re.sub(r"", "", cells[i])) if len(cells) > i else "" rows.append({ "rank": int(rm.group(1)), "model": model, "provider": provider, "net_improvement": pct(2), "confirmed_success": pct(3), "praise_vs_complaint": pct(4), "steerability": pct(5), "bash_recovery": pct(6), "tool_hallucination": pct(7), "sessions": int(re.sub(r"[^\d]", "", plain(8)) or 0), "cost_per_task": plain(9), "output_tokens_per_task": plain(10), "price": plain(11), }) return rows def _parse_text_markdown(md: str) -> list: """Парсинг text/overall из Markdown таблицы.""" rows = [] for line in md.splitlines(): if line.startswith("|") and not line.startswith("| ---") and not line.startswith("| Rank"): parts = [p.strip() for p in line.split("|")[1:-1]] if len(parts) >= 6 and parts[0].isdigit(): rank = int(parts[0]) rank_spread = parts[1] if len(parts) > 1 else "" raw_model = parts[2] m = re.search(r"\[([^\]]+)\]", raw_model) model_name = m.group(1).strip() if m else raw_model.split("
")[0].strip() provider = "" if "
" in raw_model: p_part = raw_model.split("
")[-1].strip() provider = re.sub(r"<[^>]+>", "", p_part).strip() if "[" in provider: provider = raw_model.split("
")[0].strip() score_cell = parts[3] sm = re.search(r">?\s*(\d{3,4})\s* 4 else "0" votes = int(votes_clean) if votes_clean else 0 price = parts[5] if len(parts) > 5 else "" context = parts[6] if len(parts) > 6 else "" rows.append({ "rank": rank, "model": model_name, "provider": provider, "score": score, "preliminary": "Preliminary" in score_cell, "rank_spread": rank_spread, "votes": votes, "price": price, "context": context, }) return rows def _parse_agent_markdown(md: str) -> list: """Парсинг agent/overall из Markdown таблицы.""" rows = [] for line in md.splitlines(): if line.startswith("|") and not line.startswith("| ---") and "Rank" not in line: parts = [p.strip() for p in line.split("|")[1:-1]] if len(parts) >= 8: rm = re.search(r"^\d+", parts[0]) if not rm: continue rank = int(rm.group(0)) raw_model = parts[1] m = re.search(r"\[([^\]]+)\]", raw_model) model_name = m.group(1).strip() if m else raw_model provider = "" if "
" in raw_model: provider = re.sub(r"<[^>]+>", "", raw_model.split("
")[-1]).strip() elif "·" in raw_model: # e.g. 'Claude Opus 5.5 (Max) Anthropic · Proprietary' provider = raw_model.split("(", 1)[-1].split(")", 1)[-1].strip() if ")" in raw_model else "" def clean_val(idx): return parts[idx] if len(parts) > idx else "" sessions_clean = re.sub(r"[^\d]", "", clean_val(8)) sessions = int(sessions_clean) if sessions_clean else 0 rows.append({ "rank": rank, "model": model_name, "provider": provider, "net_improvement": clean_val(2), "confirmed_success": clean_val(3), "praise_vs_complaint": clean_val(4), "steerability": clean_val(5), "bash_recovery": clean_val(6), "tool_hallucination": clean_val(7), "sessions": sessions, "cost_per_task": clean_val(9), "output_tokens_per_task": clean_val(10), "price": clean_val(11), }) return rows _PARSERS = {"text": _parse_text_rows, "agent": _parse_agent_rows} def _parse_page(board: str, content_type: str, content: str) -> dict: if content_type == "markdown": if board == "text": models = _parse_text_markdown(content) elif board == "agent": models = _parse_agent_markdown(content) else: models = [] else: models = _PARSERS[board](content) if not models: raise ValueError(f"Таблица {board} распарсилась пустой — страница изменилась?") dm = re.search(r"(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\s+\d{1,2},\s+\d{4}", content) return { "board": board, "updated": dm.group(0) if dm else "", "models": models, "total_models": len(models), "source_url": BOARDS[board], } # ---------------------------------------------------------------- cache def _load_cache(board: str) -> Optional[dict]: f = _cache_file(board) if not f.exists(): return None try: with open(f) as fh: data = json.load(fh) if time.time() - data.get("timestamp", 0) < CACHE_TTL: return data except (json.JSONDecodeError, OSError, ValueError): pass return None def _save_cache(board: str, data: dict) -> None: f = _cache_file(board) f.parent.mkdir(parents=True, exist_ok=True) data["timestamp"] = time.time() data["fetched_at"] = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime()) with open(f, "w") as fh: json.dump(data, fh, ensure_ascii=False, indent=1) # ---------------------------------------------------------------- public API def get_leaderboard(board: str = "text", force_refresh: bool = False) -> dict: """Полный лидерборд ('text' или 'agent'): {'updated', 'models', ...}.""" if board not in BOARDS: raise ValueError(f"board должен быть одним из {list(BOARDS)}") if not force_refresh: cached = _load_cache(board) if cached: out = dict(cached) out["cached"] = True return out content_type, content = _fetch_content(board) data = _parse_page(board, content_type, content) _save_cache(board, data) out = dict(data) out["cached"] = False return out def get_model_rating(model_name: str, board: str = "text", force_refresh: bool = False) -> Optional[dict]: """Рейтинг модели: точное совпадение, потом подстрока.""" lb = get_leaderboard(board, force_refresh) q = model_name.strip().lower() for m in lb["models"]: if m["model"].lower() == q: return {**m, "cached": lb["cached"]} for m in lb["models"]: if q in m["model"].lower(): return {**m, "cached": lb["cached"]} return None def search_models(query: str, board: str = "text", force_refresh: bool = False) -> list: """Все модели борда, чьё имя содержит подстроку.""" lb = get_leaderboard(board, force_refresh) q = query.strip().lower() return [{**m, "cached": lb["cached"]} for m in lb["models"] if q in m["model"].lower()] def clear_cache(board: Optional[str] = None) -> None: """Стереть кеш (конкретного борда или весь).""" files = [_cache_file(board)] if board else \ [_cache_file(b) for b in BOARDS] for f in files: if f.exists(): f.unlink() # ---------------------------------------------------------------- CLI def _fmt(m: dict) -> str: if m.get("net_improvement") is not None and "net_improvement" in m: return (f"#{m['rank']:>3} net={m['net_improvement']:<14}" f"succ={m['confirmed_success']:<14}" f"halluc={m['tool_hallucination']:<10}{m['model']}") pre = " (Preliminary)" if m.get("preliminary") else "" return (f"#{m['rank']:>3} {m['score']:<10}{m['model']:<40}" f"votes={m['votes']:>7,} {m['price']:<18}ctx={m['context']}{pre}") def _print_model(r: dict, board: str) -> None: src = "кеш" if r["cached"] else "свежая выгрузка" print(f"{r['model']} [{r['provider']}] ({src}, board={board})") print(f" Ранг: #{r['rank']}") if board == "agent": print(f" Net Improvement: {r['net_improvement']}") print(f" Confirmed Success: {r['confirmed_success']}") print(f" Praise vs Complaint: {r['praise_vs_complaint']}") print(f" Steerability: {r['steerability']}") print(f" Bash Recovery: {r['bash_recovery']}") print(f" Tool Hallucination: {r['tool_hallucination']}") print(f" Sessions: {r['sessions']:,}") print(f" Cost/Task: {r['cost_per_task']}") print(f" Tokens/Task: {r['output_tokens_per_task']}") print(f" Цена $/M: {r['price']}") else: print(f" Скор: {r['score']}" + (" Preliminary" if r["preliminary"] else "")) print(f" Спред: {r['rank_spread']}") print(f" Голосов: {r['votes']:,}") print(f" Цена $/M: {r['price']}") print(f" Контекст: {r['context']}") def main() -> None: args = sys.argv[1:] board = "text" refresh = False if "--refresh" in args: refresh = True args.remove("--refresh") if "--board" in args: i = args.index("--board") board = args[i + 1] if len(args) > i + 1 else "text" del args[i:i + 2] if board not in BOARDS: print(f"Неизвестный board '{board}', доступны: {', '.join(BOARDS)}") sys.exit(1) if "--search" in args: i = args.index("--search") query = args[i + 1] if len(args) > i + 1 else "" found = search_models(query, board=board, force_refresh=refresh) if not found: print(f"По '{query}' в {board}-лидерборде ничего нет") sys.exit(1) print(f"Найдено {len(found)} по '{query}' (board={board}):") for m in found: print(" " + _fmt(m)) return if args: name = " ".join(args) r = get_model_rating(name, board=board, force_refresh=refresh) if not r: print(f"'{name}' не найдена в {board}-лидерборде") sys.exit(1) _print_model(r, board) return lb = get_leaderboard(board, force_refresh=refresh) src = "кеш" if lb["cached"] else "свежая выгрузка" title = "Agent Arena (tool calling)" if board == "agent" else "Text Overall" print(f"Arena.ai {title} — обновлено {lb['updated'] or '?'} ({src})") print(f"Всего моделей: {lb['total_models']}\n") for m in lb["models"][:10]: print(_fmt(m)) if __name__ == "__main__": main()