Files
agent-radar/scripts/_common.py
T

216 lines
7.6 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.
"""agent-radar 共享基础库:GitHub API 封装 + YAML 精确回写。
设计要点
--------
1. GitHub API 优先使用 GITHUB_TOKEN,缺失时匿名降级(限额 60 次/小时)。
2. YAML 回写采用**行级精确替换**而不是 yaml.dump —— dump 会冲掉档案里的
所有注释和字段顺序,而档案是靠注释承载"为什么这么填"的。
"""
from __future__ import annotations
import json
import os
import re
import time
import urllib.error
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parent.parent
DATA = ROOT / "data"
PROJECTS = DATA / "projects"
SNAPSHOTS = DATA / "snapshots"
ARTICLES = ROOT / "articles"
SCHEMA = ROOT / "schema"
GH_API = "https://api.github.com"
TOKEN = os.environ.get("GITHUB_TOKEN", "").strip()
_last_call = 0.0
# ---------------------------------------------------------------- GitHub API
def gh_get(path: str, timeout: int = 25, retries: int = 2) -> dict | list | None:
"""GET GitHub API。失败返回 None(调用方负责降级)。
内置限速:匿名 1.5s 间隔、带 token 0.3s 间隔,避免触发 403 限流。
retries 是必要的:releases 这类接口响应体可达数百 KB,
偶发 IncompleteRead(连接提前关闭)。不加重试会静默丢掉版本节奏数据,
而"样本不足"和"真的没发版"在结果上无法区分。
"""
global _last_call
url = f"{GH_API}/{path.lstrip('/')}"
req = urllib.request.Request(
url,
headers={
"Accept": "application/vnd.github+json",
"User-Agent": "agent-radar",
**({"Authorization": f"Bearer {TOKEN}"} if TOKEN else {}),
},
)
last_err = None
for attempt in range(retries + 1):
wait = 0.3 if TOKEN else 1.5
gap = time.time() - _last_call
if gap < wait:
time.sleep(wait - gap)
_last_call = time.time()
try:
with urllib.request.urlopen(req, timeout=timeout) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
# 4xx 是确定性失败,重试无意义
if e.code == 403:
print(f" [warn] GitHub API 限流或禁止访问 (403): {path}")
elif e.code == 404:
print(f" [warn] 资源不存在 (404): {path}")
else:
print(f" [warn] HTTP {e.code}: {path}")
return None
except Exception as e: # IncompleteRead / 超时 / 连接重置
last_err = e
if attempt < retries:
time.sleep(1.5 * (attempt + 1))
continue
print(f" [warn] 请求失败(已重试 {retries} 次): {path} -> {last_err}")
return None
# ---------------------------------------------------------------- YAML 读写
def load_yaml(path) -> dict:
"""接受 str 或 Path —— 调用方传字符串时不应该炸。"""
p = Path(path)
return yaml.safe_load(p.read_text(encoding="utf-8")) or {}
def today() -> str:
return datetime.now(timezone.utc).astimezone().strftime("%Y-%m-%d")
def _fmt(v) -> str:
"""把 Python 值渲染成 YAML 标量文本。"""
if isinstance(v, bool):
return "true" if v else "false"
if v is None:
return '""'
if isinstance(v, (int, float)):
return str(v)
if isinstance(v, dict):
inner = ", ".join(f"{k}: {_fmt(x)}" for k, x in v.items())
return "{" + inner + "}"
if isinstance(v, list):
inner = ", ".join(_fmt(x) for x in v)
return "[" + inner + "]"
s = str(v)
# 含冒号空格、井号、引号或为空的字符串需要加引号
if s == "" or re.search(r'[:#\n"]|^\s|\s$', s) or re.match(r"^[-?*&!|>%@`]", s):
return json.dumps(s, ensure_ascii=False)
return s
def update_block(text: str, block_key: str, values: dict, indent: int | None = None) -> str:
"""在 YAML 文本里定位 `<block_key>:` 块,替换其中的键值。
只改块内已有键的值;块内没有而 values 里有的,追加到块末尾。
块外内容(含所有注释)原样保留。
indent 传 None 时**自动探测**第一个同名块 —— 推荐用这个。
硬编码缩进是很脆的:schema 一改层级,块的实际缩进就变了,
而失败表现只是"静默没写进去",很容易被忽略。
"""
lines = text.split("\n")
# `(\s*)` 而不是 `(\s+)`:顶层块的缩进是 0,用 `+` 会匹配不到
# (article: 就是顶层块,这个坑踩过一次)
pat = re.compile(rf"^(\s*){re.escape(block_key)}:\s*$")
start = None
base = None
for i, ln in enumerate(lines):
m = pat.match(ln)
if m and (indent is None or len(m.group(1)) == indent):
start, base = i, len(m.group(1))
break
if start is None:
raise ValueError(f"未找到 `{block_key}:` 块(indent={indent})")
# 块的结束:下一个缩进 <= base 的非空、非注释行
end = len(lines)
for j in range(start + 1, len(lines)):
ln = lines[j]
if not ln.strip() or ln.lstrip().startswith("#"):
continue
if len(ln) - len(ln.lstrip()) <= base:
end = j
break
child = base + 2
seen: set[str] = set()
for k in range(start + 1, end):
m = re.match(rf"^(\s{{{child},}})([A-Za-z_][\w-]*):\s*(.*)$", lines[k])
if not m:
continue
key = m.group(2)
if key in values:
lines[k] = f"{' ' * child}{key}: {_fmt(values[key])}"
seen.add(key)
missing = [f"{' ' * child}{k}: {_fmt(v)}" for k, v in values.items() if k not in seen]
if missing:
lines[end:end] = missing
return "\n".join(lines)
# ---------------------------------------------------------------- 快照
def snapshot_path(pid: str) -> Path:
return SNAPSHOTS / f"{pid}.jsonl"
def append_snapshot(pid: str, metrics: dict) -> None:
"""追加一行快照。同一天重复运行会覆盖当天那行(保持"一天一个点")。"""
SNAPSHOTS.mkdir(parents=True, exist_ok=True)
p = snapshot_path(pid)
day = metrics.get("data_date") or today()
rows = []
if p.exists():
rows = [r for r in p.read_text(encoding="utf-8").splitlines() if r.strip()]
rows = [r for r in rows if json.loads(r).get("data_date") != day]
rows.append(json.dumps(metrics, ensure_ascii=False))
p.write_text("\n".join(rows) + "\n", encoding="utf-8")
def last_snapshot(pid: str) -> dict | None:
p = snapshot_path(pid)
if not p.exists():
return None
rows = [r for r in p.read_text(encoding="utf-8").splitlines() if r.strip()]
return json.loads(rows[-1]) if rows else None
# ---------------------------------------------------------------- 许可证分级
def classify_license(spdx: str, license_name: str = "") -> dict:
"""按 schema/license-tiers.yaml 判定成色分级与镜像可见性。
返回 {level, redistributable, visibility}。
无法识别时返回 L3(保守策略:宁可私有,不可误公开)。
"""
tiers = load_yaml(SCHEMA / "license-tiers.yaml").get("tiers", [])
key = (spdx or "").strip()
for t in tiers:
match = [str(x) for x in t.get("match_spdx", []) if x is not None]
if key in match:
return {
"level": t["level"],
"redistributable": bool(t.get("redistributable")),
"visibility": t.get("visibility", "private"),
}
# 未命中:按 L3 处理(源码可得但许可不明 → 不公开)
return {"level": "L3", "redistributable": False, "visibility": "private"}