抓取 OpenRouter 每天的免费模型 Top10 榜单 Python代码!
抓取 OpenRouter 每天的免费模型 Top10 榜单 Python代码!
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
OpenRouter 免费模型 Top10 每日抓取
数据源(均为公开接口,无需 API Key):
1. https://openrouter.ai/api/v1/models -> 免费模型清单(pricing.prompt == "0")
2. https://openrouter.ai/api/frontend/v1/rankings/* -> 官方榜单(仅覆盖全球 Top10,用于补充真实 token 量)
产出:
reports/YYYY-MM-DD.md 当天 Markdown 榜单
data/YYYY-MM-DD.json 当天结构化数据
history.jsonl 追加式历史记录(用于对比排名变化)
用法:
python fetch_free_top10.py # 抓今天
python fetch_free_top10.py --top 20 # 改成 Top20
"""
import argparse
import csv
import gzip
import json
import math
import os
import ssl
import sys
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone, timedelta
BASE = os.path.dirname(os.path.abspath(__file__))
REPORTS = os.path.join(BASE, "reports")
DATA = os.path.join(BASE, "data")
HISTORY = os.path.join(BASE, "history.jsonl")
MODELS_URL = "https://openrouter.ai/api/v1/models"
RANK_ENDPOINTS = [
"natural-language",
"programming-language",
"context-length",
"image-output",
"rerank-documents",
]
RANK_URL = "https://openrouter.ai/api/frontend/v1/rankings/{}"
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36"
CST = timezone(timedelta(hours=8))
def http_get(url, timeout=60, retries=4):
"""带重试的 GET,返回 bytes。"""
ctx = ssl.create_default_context()
last = None
for attempt in range(retries):
try:
req = urllib.request.Request(
url,
headers={"User-Agent": UA, "Accept-Encoding": "gzip", "Accept": "application/json"},
)
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
data = r.read()
if r.headers.get("Content-Encoding") == "gzip":
data = gzip.decompress(data)
return data
except Exception as e: # noqa: BLE001
last = e
wait = 2 ** attempt
print(f" [retry {attempt + 1}/{retries}] {type(e).__name__}: {e} -> sleep {wait}s", file=sys.stderr)
time.sleep(wait)
raise RuntimeError(f"GET failed after {retries} tries: {url} ({last})")
def get_json(url, **kw):
return json.loads(http_get(url, **kw).decode("utf-8"))
# ---------------------------------------------------------------- 抓取
def fetch_free_models():
"""返回免费模型列表。判定:prompt 与 completion 均为 0。"""
data = get_json(MODELS_URL)
models = data.get("data", [])
free = []
for m in models:
pr = m.get("pricing") or {}
try:
p = float(pr.get("prompt", "1"))
c = float(pr.get("completion", "1"))
except (TypeError, ValueError):
continue
if p == 0 and c == 0:
free.append(m)
return models, free
def fetch_rankings():
"""抓官方榜单,返回 {endpoint: {'bucket': date, 'tokens': {model: n}}}。"""
out = {}
for ep in RANK_ENDPOINTS:
try:
d = get_json(RANK_URL.format(ep))
series = d.get("data") or []
if not series:
continue
last = series[-1]
out[ep] = {"bucket": last.get("x"), "tokens": last.get("ys") or {}}
print(f" rankings/{ep}: bucket={last.get('x')} entries={len(out[ep]['tokens'])}")
except Exception as e: # noqa: BLE001
print(f" rankings/{ep}: FAILED ({e})", file=sys.stderr)
out[ep] = {"bucket": None, "tokens": {}, "error": str(e)}
return out
# ---------------------------------------------------------------- 排名打分
def score_models(free, rankings):
"""
综合打分。官方榜单只覆盖全球 Top10,多数免费模型拿不到 token 量,
因此以「上线时间新」为主序,「上下文长度」为辅,
官方榜出现情况作为加权加成与真实热度标记。
"""
# 各维度取最大 token 量(不跨维度累加:维度间口径不同,累加会失真)
token_max = {}
bucket_of = {}
dims_of = {}
for ep, info in rankings.items():
for mid, n in (info.get("tokens") or {}).items():
if n > token_max.get(mid, 0):
token_max[mid] = n
bucket_of.setdefault(mid, []).append(info.get("bucket"))
dims_of.setdefault(mid, []).append(ep)
now = int(time.time())
max_ctx = max([m.get("context_length") or 0 for m in free] or [1]) or 1
all_tokens = [v for v in token_max.values() if v > 0]
log_max = (max((math.log10(v) for v in all_tokens), default=0.0)) or 1.0
rows = []
for m in free:
mid = m["id"]
created = int(m.get("created") or 0)
ctx = int(m.get("context_length") or 0)
tokens = token_max.get(mid, 0)
age_days = max(0, (now - created) // 86400) if created else 9999
# 新鲜度:越新越高(0~100),90 天以上视为 0
freshness = max(0.0, 100.0 * (1 - age_days / 90.0)) if created else 0.0
ctx_score = 100.0 * ctx / max_ctx
# 官方榜加成:进过榜加权,未进榜不罚分;token 量做对数归一化
official_bonus = 35.0 if tokens > 0 else 0.0
token_score = 15.0 * (math.log10(tokens) / log_max) if tokens > 0 else 0.0
total = 0.50 * freshness + 0.28 * ctx_score + official_bonus + token_score
rows.append({
"id": mid,
"name": m.get("name") or mid,
"context_length": ctx,
"created": created,
"created_date": datetime.fromtimestamp(created, CST).strftime("%Y-%m-%d") if created else "",
"age_days": age_days,
"is_free_suffix": mid.endswith(":free"),
"official_tokens": int(tokens),
"ranked_dims": sorted(set(dims_of.get(mid, []))),
"ranked_buckets": sorted(set(b for b in bucket_of.get(mid, []) if b)),
"supports_tools": "tools" in (m.get("supported_parameters") or []),
"supports_vision": "image" in (m.get("supported_parameters") or []),
"reasoning": bool(m.get("reasoning")),
"score": round(total, 2),
"modalities": m.get("architecture", {}).get("modality") or "",
"description": (m.get("description") or "").strip().replace("\n", " ")[:160],
})
rows.sort(key=lambda r: (-r["score"], -r["context_length"], r["id"]))
for i, r in enumerate(rows, 1):
r["rank"] = i
return rows
# ---------------------------------------------------------------- 历史对比
def load_history():
if not os.path.exists(HISTORY):
return {}
hist = {}
with open(HISTORY, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
except json.JSONDecodeError:
continue
hist[rec.get("date")] = {m["id"]: m.get("rank") for m in rec.get("models", [])}
return hist
# ---------------------------------------------------------------- 输出
def fmt_int(n):
return f"{n:,}" if isinstance(n, (int, float)) else "-"
def write_markdown(date_str, rows, rankings, changes, total_models, total_free):
L = []
L.append(f"# OpenRouter 免费模型 Top{len(rows)} — {date_str}\n")
L.append(f"> 抓取时间:{datetime.now(CST).strftime('%Y-%m-%d %H:%M:%S')} (UTC+8) "
f"平台模型总数:{total_models} 免费模型总数:{total_free}\n")
if changes:
up = [c for c in changes if c["delta"] is not None and c["delta"] < 0] down = [c for c in changes if c["delta"] is not None and c["delta"] > 0]
new = [c for c in changes if c["delta"] is None]
bits = []
if new:
bits.append("新进榜 " + ", ".join(f"`{c['id']}`" for c in new))
if up:
bits.append("上升 " + ", ".join(f"`{c['id']}`(+{-c['delta']})" for c in up[:5]))
if down:
bits.append("下降 " + ", ".join(f"`{c['id']}`(-{c['delta']})" for c in down[:5]))
L.append("**较昨日变化:** " + ";".join(bits) + "\n")
else:
L.append("**较昨日变化:** 首次抓取,无对比基准。\n")
L.append("| 排名 | 模型名称 | 模型 ID | 上下文 | 上线日期 | 工具调用 | 视觉 | 官方榜 token | 上榜维度 | 较昨日 |")
L.append("|---:|---|---|---:|:---:|:---:|:---:|---:|---|:---:|")
chg_map = {c["id"]: c for c in changes}
for r in rows:
c = chg_map.get(r["id"])
if c is None or c["delta"] is None:
arrow = "新进榜"
elif c["delta"] < 0: arrow = f"↑ {-c['delta']}" elif c["delta"] > 0:
arrow = f"↓ {c['delta']}"
else:
arrow = "—"
tok = fmt_int(r["official_tokens"]) if r["official_tokens"] else "未上榜"
dims = ", ".join(r.get("ranked_dims") or []) or "—"
ctx = fmt_int(r["context_length"])
L.append(f"| {r['rank']} | {r['name']} | `{r['id']}` | {ctx} | {r['created_date']} | "
f"{'✅' if r['supports_tools'] else '—'} | {'✅' if r['supports_vision'] else '—'} | {tok} | {dims} | {arrow} |")
L.append(f"\n## 全部免费模型(按综合分,共 {total_free} 个)\n")
L.append("| # | 模型 ID | 上下文 | 上线日期 | 分数 |")
L.append("|---:|---|---:|:---:|---:|")
for r in rows:
L.append(f"| {r['rank']} | `{r['id']}` | {fmt_int(r['context_length'])} | {r['created_date']} | {r['score']} |")
L.append("\n## 数据来源说明\n")
L.append(f"- 免费模型清单:`{MODELS_URL}`(判定 pricing.prompt == 0 且 completion == 0)")
L.append("- 官方榜单接口(返回全球 Top10 时间序列,用于补充真实 token 量):")
for ep, info in rankings.items():
L.append(f" - `{RANK_URL.format(ep)}` → 数据周期 {info.get('bucket')}"
+ (f",⚠️ {info['error']}" if info.get("error") else ""))
L.append("\n> 注:OpenRouter 官方榜单仅公布全球 Top10,多数免费模型不在榜内。"
"本榜单的「排名」由模型上线时间与上下文长度综合生成,"
"「官方榜 token」列显示的是该模型真实进入官方榜时的 token 量(未上榜则为 0)。\n")
path = os.path.join(REPORTS, f"{date_str}.md")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(L))
return path
def write_csv(date_str, rows):
path = os.path.join(DATA, f"{date_str}.csv")
cols = ["rank", "id", "name", "context_length", "created_date", "score",
"official_tokens", "supports_tools", "supports_vision", "reasoning", "is_free_suffix"]
with open(path, "w", encoding="utf-8-sig", newline="") as f:
w = csv.DictWriter(f, fieldnames=cols, extrasaction="ignore")
w.writeheader()
for r in rows:
w.writerow(r)
return path
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--top", type=int, default=10, help="榜单长度,默认 10")
args = ap.parse_args()
os.makedirs(REPORTS, exist_ok=True)
os.makedirs(DATA, exist_ok=True)
date_str = datetime.now(CST).strftime("%Y-%m-%d")
print(f"[1/4] 抓取模型清单 {MODELS_URL}")
all_models, free = fetch_free_models()
total_models = len(all_models)
print(f" 模型总数 {total_models},免费模型 {len(free)}")
print(f"[2/4] 抓取官方榜单 {len(RANK_ENDPOINTS)} 个维度")
rankings = fetch_rankings()
print("[3/4] 计算排名")
ranked = score_models(free, rankings)
top = ranked[: args.top]
hist = load_history()
prev = hist.get(date_str) or (sorted(hist)[-1] if hist else None)
prev_map = hist.get(prev, {}) if prev else {}
changes = []
for r in top:
old = prev_map.get(r["id"])
changes.append({
"id": r["id"],
"prev_rank": old,
"delta": (old - r["rank"]) if old else None,
})
print("[4/4] 输出报告")
md = write_markdown(date_str, top, rankings, changes, total_models, len(free))
csv_path = write_csv(date_str, ranked)
with open(os.path.join(DATA, f"{date_str}.json"), "w", encoding="utf-8") as f:
json.dump({
"date": date_str,
"fetched_at": datetime.now(CST).isoformat(),
"total_models": total_models,
"free_count": len(free),
"models": ranked,
"rankings_meta": {k: {"bucket": v.get("bucket"), "error": v.get("error")} for k, v in rankings.items()},
}, f, ensure_ascii=False, indent=2)
with open(HISTORY, "a", encoding="utf-8") as f:
f.write(json.dumps({
"date": date_str,
"total_free": len(free),
"models": [{"id": r["id"], "rank": r["rank"], "score": r["score"]} for r in ranked],
}, ensure_ascii=False) + "\n")
print("\n=== OpenRouter 免费模型 Top%d (%s) ===" % (len(top), date_str))
for r in top:
tok = fmt_int(r["official_tokens"]) if r["official_tokens"] else "—"
print(f"{r['rank']:>2}. {r['id']:<52} ctx={fmt_int(r['context_length']):>10} tokens={tok:>14}")
print(f"\n报告: {md}\nCSV : {csv_path}")
if __name__ == "__main__":
main()
学习资料见知识星球。
以上就是今天要分享的技巧,你学会了吗?若有什么问题,欢迎在下方留言。
快来试试吧,小琥 my21ke007。获取 1000个免费 Excel模板福利!
更多技巧, www.excelbook.cn
欢迎 加入 零售创新 知识星球,知识星球主要以数据分析、报告分享、数据工具讨论为主;
1、价值上万元的专业的PPT报告模板。
2、专业案例分析和解读笔记。
3、实用的Excel、Word、PPT技巧。
4、VIP讨论群,共享资源。
5、优惠的会员商品。
6、一次付费只需129元,即可下载本站文章涉及的文件和软件。

