样本 - openwork (different-ai/openwork) 23.2k★ 能力分发层,DCO + evals + worlds - orca (stablyai/orca) 58.8k★ 并行编排外壳,5000 issue 实为团队自开任务板 - maka (apache/maka) 4.4k★ 执行记录层,Apache 孵化但无正式 release 档案 - 三份五维档案填完,risk.claim_vs_reality 均已核实到证据 - ipollowork 象限坐标从占位 9/9/9/9 校准为 8/7/7/7(与新样本同一标尺) 文章 - 新增横评文《四个 Agent 工作台的路线分野:把「多引擎中立」拆开看》(emlog id=99) - 总纲文回填四项目矩阵 + 双视图象限图 (emlog id=98) 镜像 - gitinbox/maka 已推送(public,source-only 快照 12 MB) - openwork(291MB) / orca(671MB) 受 50KB/s 带宽约束,档案标 mode: pending 规则 - license-tiers.yaml 新增「目录切分许可」降级流程(OpenWork 判例: 排除 ee/ 后为纯 MIT → public,完整镜像 → private) - registry.yaml openwork 可见性改 public + mirror_excludes: ["ee/"] 脚本修复 - _common.py 新增 upsert_block:手工整块覆盖 ecosystem 删掉 metrics 时能自愈 - mirror_sync.sh:SOURCE_ONLY 未初始化(set -u 下偶发炸); 浅仓推送被服务端拒绝 → 自动 --unshallow,失败降级快照; 量体积前先 repack(此前新 commit 全是 loose object,恒打印 0 MB) - build_matrix.py:成熟度长文本移出表格(新增 maturity_level 短标签); 象限图标签靠右时翻转到左侧防溢出;SVG 加 max-width 防窄屏溢出; 文章索引改为自动生成(原先手抄,横评文发了索引里却没有) - publish_emlog.py:ID 回写按「有无同名档案」分流, 00-* 跨项目文章落到 articles/<id>.meta.yaml(此前 ID 无处可写)
195 lines
6.5 KiB
Python
Executable File
195 lines
6.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""抓取被追踪项目的 GitHub 指标,回写档案并累积时间序列快照。
|
||
|
||
用法
|
||
----
|
||
python3 scripts/fetch_stats.py # 刷新 registry.yaml 里的全部项目
|
||
python3 scripts/fetch_stats.py --id ipollowork # 只刷新一个
|
||
python3 scripts/fetch_stats.py --force # 忽略当日缓存强制重抓
|
||
|
||
行为
|
||
----
|
||
· 优先读环境变量 GITHUB_TOKEN;没有则匿名(限额 60 次/小时),限速间隔自动放宽。
|
||
· 同一项目一天只抓一次(快照文件里已有当天记录就跳过),--force 可覆盖。
|
||
· 抓取失败时沿用上次快照数值,并把 metrics.stale 置为 true,
|
||
这样文章里的数字始终有出处,且能被识别出"这是旧数据"。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import re
|
||
import sys
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
|
||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||
from _common import ( # noqa: E402
|
||
DATA,
|
||
PROJECTS,
|
||
append_snapshot,
|
||
classify_license,
|
||
gh_get,
|
||
last_snapshot,
|
||
load_yaml,
|
||
today,
|
||
update_block,
|
||
upsert_block,
|
||
)
|
||
|
||
|
||
def human_size(kb: int) -> str:
|
||
mb = kb / 1024
|
||
return f"{mb:.0f} MB" if mb >= 1 else f"{kb} KB"
|
||
|
||
|
||
def calc_cadence(releases: list) -> str:
|
||
"""从最近若干 release 的发布时间间隔估算版本节奏。"""
|
||
dates = []
|
||
for r in releases:
|
||
d = (r.get("published_at") or "")[:10]
|
||
if re.match(r"^\d{4}-\d{2}-\d{2}$", d):
|
||
dates.append(datetime.strptime(d, "%Y-%m-%d"))
|
||
if len(dates) < 2:
|
||
return "样本不足"
|
||
dates.sort(reverse=True)
|
||
spans = [(dates[i] - dates[i + 1]).days for i in range(len(dates) - 1)]
|
||
spans = [s for s in spans if s >= 0]
|
||
if not spans:
|
||
return "样本不足"
|
||
avg = sum(spans) / len(spans)
|
||
if avg < 1:
|
||
return "一天多版"
|
||
if avg < 2:
|
||
return "约 1 天/版本"
|
||
if avg < 8:
|
||
return f"约 {avg:.0f} 天/版本"
|
||
if avg < 40:
|
||
return f"约 {avg / 7:.1f} 周/版本"
|
||
return f"约 {avg / 30:.1f} 月/版本"
|
||
|
||
|
||
def fetch_one(pid: str, repo: str, force: bool) -> bool:
|
||
print(f"\n[{pid}] {repo}")
|
||
|
||
# ---- 当日缓存 ----
|
||
if not force:
|
||
prev = last_snapshot(pid)
|
||
if prev and prev.get("data_date") == today():
|
||
print(f" 今日已抓取过({today()}),跳过。用 --force 强制刷新。")
|
||
return True
|
||
|
||
# ---- 主仓库信息 ----
|
||
info = gh_get(f"repos/{repo}")
|
||
if not info:
|
||
print(" 主信息抓取失败,尝试沿用上次快照 …")
|
||
return fallback(pid)
|
||
|
||
lic = info.get("license") or {}
|
||
spdx = lic.get("spdx_id") or "NOASSERTION"
|
||
if spdx == "NOASSERTION" and not lic.get("key"):
|
||
spdx = ""
|
||
|
||
langs_raw = gh_get(f"repos/{repo}/languages") or {}
|
||
total = sum(langs_raw.values()) or 1
|
||
langs = {k: round(v / total * 100, 1) for k, v in sorted(langs_raw.items(), key=lambda x: -x[1])[:6]}
|
||
|
||
rels = gh_get(f"repos/{repo}/releases?per_page=8") or []
|
||
latest = ""
|
||
if rels:
|
||
latest = rels[0].get("tag_name") or ""
|
||
|
||
metrics = {
|
||
"stars": info.get("stargazers_count", 0),
|
||
"forks": info.get("forks_count", 0),
|
||
"open_issues": info.get("open_issues_count", 0),
|
||
"subscribers": info.get("subscribers_count", 0),
|
||
"created_at": (info.get("created_at") or "")[:10],
|
||
"pushed_at": (info.get("pushed_at") or "")[:10],
|
||
"size_kb": info.get("size", 0),
|
||
"languages": langs,
|
||
"latest_release": latest,
|
||
"release_cadence": calc_cadence(rels),
|
||
"data_date": today(),
|
||
"stale": False,
|
||
}
|
||
|
||
print(f" ★ {metrics['stars']} fork {metrics['forks']} issues {metrics['open_issues']}")
|
||
print(f" 最新 {latest} 节奏 {metrics['release_cadence']} 体积 {human_size(metrics['size_kb'])}")
|
||
print(f" 语言 {langs}")
|
||
print(f" 许可证 spdx={spdx or '(无)'}")
|
||
|
||
# ---- 快照 + 回写 ----
|
||
append_snapshot(pid, metrics)
|
||
|
||
ypath = PROJECTS / f"{pid}.yaml"
|
||
if ypath.exists():
|
||
text = ypath.read_text(encoding="utf-8")
|
||
try:
|
||
# 用 upsert 而非 update:手工补完档案时若整块覆盖了 ecosystem,
|
||
# metrics 子块会消失,upsert 能把它重建回来。
|
||
text = upsert_block(text, "metrics", metrics, parent="ecosystem")
|
||
except ValueError as e:
|
||
print(f" [warn] 回写 metrics 失败:{e}")
|
||
# 许可证若尚未定级,顺手补上
|
||
if "openness: ~" in text or "openness: null" in text or 'openness: ""' in text:
|
||
cls = classify_license(spdx, lic.get("name", ""))
|
||
text = update_block(text, "license", {
|
||
"spdx": spdx or "NOASSERTION",
|
||
"name": lic.get("name") or "自定义许可",
|
||
"openness": cls["level"],
|
||
"redistributable": cls["redistributable"],
|
||
})
|
||
print(f" 自动定级:{cls['level']} → 镜像可见性 {cls['visibility']}")
|
||
ypath.write_text(text, encoding="utf-8")
|
||
else:
|
||
print(f" [warn] 档案不存在:{ypath},跳过回写")
|
||
|
||
return True
|
||
|
||
|
||
def fallback(pid: str) -> bool:
|
||
"""抓取失败:沿用上次快照并把 stale 标为 true。"""
|
||
prev = last_snapshot(pid)
|
||
ypath = PROJECTS / f"{pid}.yaml"
|
||
if not prev or not ypath.exists():
|
||
print(" 无历史快照可用,本次放弃。")
|
||
return False
|
||
prev = dict(prev)
|
||
prev["stale"] = True
|
||
text = ypath.read_text(encoding="utf-8")
|
||
try:
|
||
text = update_block(text, "metrics", prev)
|
||
ypath.write_text(text, encoding="utf-8")
|
||
print(f" 已沿用 {prev.get('data_date')} 的快照并标记 stale=true")
|
||
except ValueError as e:
|
||
print(f" [warn] 回写失败:{e}")
|
||
return False
|
||
return True
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser()
|
||
ap.add_argument("--id", help="只刷新指定项目 id")
|
||
ap.add_argument("--force", action="store_true", help="忽略当日缓存")
|
||
args = ap.parse_args()
|
||
|
||
reg = load_yaml(DATA / "registry.yaml")
|
||
items = reg.get("projects", [])
|
||
if not items:
|
||
print("registry.yaml 里没有登记任何项目。")
|
||
return 1
|
||
|
||
targets = [p for p in items if not args.id or p.get("id") == args.id]
|
||
if not targets:
|
||
print(f"未找到 id={args.id}")
|
||
return 1
|
||
|
||
ok = sum(fetch_one(p["id"], p["repo"], args.force) for p in targets)
|
||
print(f"\n完成 {ok}/{len(targets)}")
|
||
return 0 if ok == len(targets) else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|