gitlink-cli/scripts/research/hotspot.py

544 lines
19 KiB
Python
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.

"""hotspot.py — 科研热点追踪(全栈重构版)。
输入一组科研关键词,从 GitLink 平台按关键词搜索相关仓库,对每个仓库:
- 拉取 repo_infostars / forks / 更新时间 / 贡献者)
- 拉取 issue + pr 列表(活跃讨论按评论数排序)
- 从 description + readme 抽取主题标签
- 聚合学者/团队贡献网络
输出hotspot.json结构化数据+ report.md中文简报
取数与算法分离collect() 在线取数compute_*() 纯函数离线可测。
用法:
# 分类精选源GitLink 官方 pinned自带 visits—— 推荐用于缩小范围
python hotspot.py --category 深度学习 --limit 30 --out ./out
python hotspot.py --category 32 --limit 20 --days 90 # 近 90 天
# 关键词源(原逻辑)
python hotspot.py --keywords "deep learning,机器学习" --limit 12 --out ./out
"""
from __future__ import annotations
import argparse
import json
import os
import sys
import time
from collections import Counter, defaultdict
from typing import Any
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import collect as c
import topics as T
# ---------------------------------------------------------------------------
# 小工具
# ---------------------------------------------------------------------------
def _now_ts() -> float:
return time.time()
def _days_ago(ts: float) -> int:
"""粗略计算距今多少天(非精确日历差,但足以排序)。"""
return max(0, int((_now_ts() - ts) / 86400))
# ---------------------------------------------------------------------------
# 热度评分(纯函数,离线可测)
# ---------------------------------------------------------------------------
def compute_trending_score(stars: int, forks: int, updated_days_ago: int, visits: int = 0) -> int:
"""仓库热度综合评分。
公式stars + forks × 2 + visits//10GitLink 官方访问量)+ 近期更新加成。
visits 来自 `explore +pinned` 的官方字段;关键词源无该字段时为 0向后兼容
满分无上限,用于仓库间横向排序。
"""
base = stars + forks * 2 + visits // 10
if updated_days_ago <= 7:
recency = 30
elif updated_days_ago <= 30:
recency = 20
elif updated_days_ago <= 90:
recency = 10
elif updated_days_ago <= 180:
recency = 5
else:
recency = 0
return base + recency
def compute_velocity(stars: int, updated_days_ago: int) -> float:
"""日均星标增速(近似值)。"""
if updated_days_ago <= 0:
updated_days_ago = 1
return round(stars / max(updated_days_ago, 1), 2)
# ---------------------------------------------------------------------------
# 在线取数
# ---------------------------------------------------------------------------
def collect(keywords: list[str] | None = None, repos_limit: int = 12,
category: str | None = None, limit: int | None = None,
days: int = 0) -> dict:
"""在线取数:候选源(分类精选 OR 关键词搜索)→ 去重 → 截断 → 逐仓详情。
- ``category`` 模式:走 ``explore +pinned --category <域>``,取 GitLink 官方精选
项目(自带 visits/praises_count/forked_count/topics/language/author
- 关键词模式:多关键词 ``search +repos``(原逻辑)。
其余逐仓 enrichmentrepo_info/issues/prs/contributors/readme/languages两种源共用。
返回原始数据字典,供 compute() 消费。
"""
seen: dict[str, dict] = {}
def _add(proj: dict, kw: str = ""):
full = c.repo_fullname(proj)
if not full:
return
if full not in seen:
seen[full] = {
"fullname": full,
"matched_keywords": [],
"project": proj,
"info": None,
"issues": [],
"prs": [],
"contributors": [],
"readme": "",
"languages": {},
}
if kw and kw not in seen[full]["matched_keywords"]:
seen[full]["matched_keywords"].append(kw)
# Step 1: 候选源
if category:
n = limit or repos_limit
for proj in c.pinned(category, limit=n):
_add(proj)
else:
for kw in (keywords or []):
for proj in c.search_repos(kw, limit=repos_limit):
_add(proj, kw)
repos = list(seen.values())
# 按 praises + visits//10 粗排,截断到 limit 再深度取数(节省 API
def _heat_sort_key(r: dict) -> int:
p = r["project"]
return -(c.as_int(p.get("praises_count") or p.get("stars_count") or 0)
+ c.as_int(p.get("visits") or 0) // 10)
cap = limit or repos_limit
repos.sort(key=_heat_sort_key)
repos = repos[:cap]
# days 过滤(按 last_update_time 近 N 天0 = 不过滤;过滤后为空则保留原列表)
if days and days > 0:
cutoff = _now_ts() - days * 86400
fresh = [r for r in repos
if _parse_ts(r["project"].get("last_update_time") or "") >= cutoff]
if fresh:
repos = fresh
# Step 2: 逐仓库取详情(两源共用)
for r in repos:
full = r["fullname"]
parts = full.split("/", 1)
if len(parts) != 2:
continue
owner, repo = parts[0], parts[1]
info = c.repo_info(owner, repo)
r["info"] = info if isinstance(info, dict) else {}
r["issues"] = c.issues(owner, repo, state="open", max_pages=3, page_size=30)
r["prs"] = c.prs(owner, repo, state="open", max_pages=3, page_size=30)
r["contributors"] = c.contributors(owner, repo)
r["readme"] = (c.readme(owner, repo) or "")[:4000]
r["languages"] = c.languages(owner, repo)
return {
"keywords": keywords or [],
"category": category or "",
"repos": repos,
"repos_limit": cap,
}
# ---------------------------------------------------------------------------
# 离线计算(纯函数,不联网)
# ---------------------------------------------------------------------------
def compute(raw: dict) -> dict:
"""从 collect() 的原始数据计算出所有热点指标。
输入结构见 collect() 返回值;输出为标准 hotspot.json 结构。
"""
repos_raw = raw.get("repos") or []
# ------ trending_repos ------
trending: list[dict] = []
now = _now_ts()
for r in repos_raw:
info = r.get("info") or {}
proj = r.get("project") or {}
stars = c.as_int(
info.get("watchers_count")
or info.get("praises_count")
or proj.get("praises_count")
or 0
)
forks = c.as_int(
info.get("forked_count")
or proj.get("forked_count")
or 0
)
# 解析更新时间
update_str = (
info.get("full_last_update_time")
or info.get("last_update_time")
or proj.get("full_last_update_time")
or proj.get("last_update_time")
or ""
)
update_ts = _parse_ts(update_str)
days = _days_ago(update_ts)
visits = c.as_int(proj.get("visits") or info.get("visits") or 0)
score = compute_trending_score(stars, forks, days, visits=visits)
velocity = compute_velocity(stars, days)
language = ""
lang_obj = info.get("language") or proj.get("language")
if isinstance(lang_obj, dict):
language = lang_obj.get("name") or ""
elif isinstance(lang_obj, str):
language = lang_obj
trending.append({
"repo": r["fullname"],
"description": (
info.get("description")
or proj.get("description")
or ""
),
"language": language,
"stars": stars,
"forks": forks,
"visits": visits,
"score": score,
"velocity": velocity,
"matched_keywords": r.get("matched_keywords", []),
"updated": _fmt_ts(update_ts),
"contributors_count": c.as_int(info.get("contributor_users_count") or 0),
"releases_count": c.as_int(info.get("version_releases_count") or 0),
})
trending.sort(key=lambda x: -x["score"])
# ------ active_discussions ------
discussions: list[dict] = []
for r in repos_raw:
full = r["fullname"]
# issues
for iss in r.get("issues") or []:
comments = c.as_int(iss.get("comment_count") or iss.get("comments") or 0)
if comments > 0:
discussions.append({
"type": "issue",
"repo": full,
"title": iss.get("title") or "(无标题)",
"number": iss.get("index") or iss.get("number") or "",
"state": iss.get("state") or iss.get("status") or "open",
"comments": comments,
})
# PRs
for pr in r.get("prs") or []:
comments = c.as_int(pr.get("comment_count") or pr.get("comments") or 0)
if comments > 0:
discussions.append({
"type": "pr",
"repo": full,
"title": pr.get("title") or "(无标题)",
"number": pr.get("index") or pr.get("number") or "",
"state": pr.get("state") or pr.get("status") or "open",
"comments": comments,
})
discussions.sort(key=lambda x: -x["comments"])
# ------ topic_heat ------
# 对所有仓库的 description + readme 跑 topic_counter
texts = []
for r in repos_raw:
info = r.get("info") or {}
proj = r.get("project") or {}
desc = info.get("description") or proj.get("description") or ""
texts.append(desc)
readme = r.get("readme") or ""
if readme:
texts.append(readme)
heat = T.topic_counter(texts)
topic_heat = [
{"topic": topic, "count": count}
for topic, count in heat.most_common(10)
]
# ------ core_scholars / core_teams ------
scholar_repo_count: Counter = Counter()
# 记录每个 scholar 关联的仓库名
scholar_repos: dict[str, list[str]] = defaultdict(list)
for r in repos_raw:
full = r["fullname"]
for contrib in r.get("contributors") or []:
login = c.login_of(contrib)
if not login or _is_bot(login):
continue
scholar_repo_count[login] += 1
if full not in scholar_repos[login]:
scholar_repos[login].append(full)
core_scholars = [
{
"login": login,
"repo_count": count,
"repos": scholar_repos.get(login, []),
}
for login, count in scholar_repo_count.most_common(12)
]
# 核心团队:按 owner仓库第一段聚合
org_repo: Counter = Counter()
for r in repos_raw:
full = r["fullname"]
org = full.split("/")[0] if "/" in full else full
org_repo[org] += 1
core_teams = [
{"login": org, "repo_count": count}
for org, count in org_repo.most_common(8)
]
# ------ meta ------
total_issues = sum(len(r.get("issues") or []) for r in repos_raw)
total_prs = sum(len(r.get("prs") or []) for r in repos_raw)
return {
"scenario": "hotspot",
"keywords": raw.get("keywords") or [],
"category": raw.get("category") or "",
"trending_repos": trending,
"active_discussions": discussions,
"topic_heat": topic_heat,
"core_scholars": core_scholars,
"core_teams": core_teams,
"meta": {
"repo_count": len(repos_raw),
"issue_count": total_issues,
"pr_count": total_prs,
"scholar_count": len(scholar_repo_count),
"discussion_count": len(discussions),
"topic_count": len(topic_heat),
},
}
# ---------------------------------------------------------------------------
# 时间工具
# ---------------------------------------------------------------------------
def _parse_ts(v: Any) -> float:
"""把 GitLink 时间戳/字符串尽量解析为 Unix 浮点秒。"""
if v is None:
return 0.0
if isinstance(v, (int, float)):
if v > 1_000_000_000_000:
return v / 1000.0
return float(v)
s = str(v).strip()
if not s:
return 0.0
# ISO 8601 格式
for fmt in ("%Y-%m-%dT%H:%M:%S", "%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%SZ", "%Y-%m-%d"):
try:
return time.mktime(time.strptime(s[:19] if len(s) >= 19 else s, fmt))
except ValueError:
continue
return 0.0
def _fmt_ts(ts: float) -> str:
"""Unix 浮点秒 → 'YYYY-MM-DD' 字符串。"""
if ts <= 0:
return ""
try:
return time.strftime("%Y-%m-%d", time.localtime(ts))
except (ValueError, OSError):
return ""
_BOT_HINTS = ("bot", "i-robot", "dependabot", "renovate", "semantic-release-bot")
def _is_bot(login: str) -> bool:
low = login.lower()
return any(h in low for h in _BOT_HINTS)
# ---------------------------------------------------------------------------
# 报告生成
# ---------------------------------------------------------------------------
def _report_md(output: dict) -> str:
"""从热点 JSON 产出中文简报 Markdown。"""
kw = ", ".join(output.get("keywords") or [])
category = output.get("category") or ""
meta = output.get("meta") or {}
source_line = f"> 领域分类:**{category}**GitLink 官方精选)" if category else f"> 关键词:{kw}"
lines = [
f"# 🔬 科研热点追踪报告",
f"",
source_line,
f"> 扫描时间:{_fmt_ts(_now_ts())}",
f"> 覆盖仓库:{meta.get('repo_count', 0)}"
f"· 讨论 {meta.get('discussion_count', 0)}"
f"· 主题 {meta.get('topic_count', 0)}"
f"· 学者 {meta.get('scholar_count', 0)}",
f"",
]
# 飙升项目 top 5
trending = output.get("trending_repos") or []
if trending:
lines.append("## 🔥 飙升项目 Top 5")
lines.append("")
lines.append("| # | 仓库 | 语言 | 👁 访问 | ★ Star | ⑂ Fork | 热度 | 更新 |")
lines.append("|---|------|------|---------|--------|--------|------|------|")
for i, r in enumerate(trending[:5], 1):
lines.append(
f"| {i} | `{r['repo']}` | {r['language'] or ''} | "
f"{r.get('visits', 0)} | {r['stars']} | {r['forks']} | {r['score']} | {r['updated']} |"
)
lines.append("")
# 活跃讨论 top 5
discussions = output.get("active_discussions") or []
if discussions:
lines.append("## 💬 活跃讨论 Top 5")
lines.append("")
for i, d in enumerate(discussions[:5], 1):
tp = "🐛 Issue" if d["type"] == "issue" else "🔀 PR"
lines.append(
f"{i}. {tp} [{d['repo']}] {d['title']} "
f"(#{d['number']} · {d['comments']} 💬)"
)
lines.append("")
# 热门主题
topic_heat = output.get("topic_heat") or []
if topic_heat:
lines.append("## 📊 热门主题")
lines.append("")
for t in topic_heat:
bar = "" * min(t["count"], 20)
lines.append(f"- **{t['topic']}** — {t['count']} 个仓库 {bar}")
lines.append("")
# 核心学者
scholars = output.get("core_scholars") or []
if scholars:
lines.append("## 👥 核心学者")
lines.append("")
for s in scholars[:5]:
lines.append(f"- **{s['login']}** — 关联 {s['repo_count']} 个仓库")
lines.append("")
# 核心团队
teams = output.get("core_teams") or []
if teams:
lines.append("## 🏛 活跃组织/团队")
lines.append("")
for t in teams:
lines.append(f"- **{t['login']}** — {t['repo_count']} 个仓库")
lines.append("")
lines.append("---")
lines.append("*由 gitlink-research-hotspot 自动生成*")
return "\n".join(lines)
# ---------------------------------------------------------------------------
# CLI 入口
# ---------------------------------------------------------------------------
def main() -> None:
ap = argparse.ArgumentParser(
description="科研热点追踪 — 分类精选(GitLink 官方) / 关键词 双数据源"
)
src = ap.add_mutually_exclusive_group()
src.add_argument(
"--category", "-c", default="",
help="GitLink 领域分类(中文名或 id如 深度学习 / 32—— 官方精选源",
)
src.add_argument(
"--keywords", "-k", default="",
help="搜索关键词,逗号分隔(关键词源)",
)
ap.add_argument("--limit", type=int, default=20, help="最大分析仓库数(默认 20")
ap.add_argument("--repos-limit", type=int, default=12, help="(兼容旧参数,等价 --limit)")
ap.add_argument("--days", type=int, default=0,
help="只取近 N 天更新的仓库0=不过滤;仅 --category 源生效)")
ap.add_argument("--out", "-o", default="", help="输出目录(不传则仅打印 JSON 到 stdout")
args = ap.parse_args()
category = args.category.strip()
kw_list = [k.strip() for k in args.keywords.split(",") if k.strip()] if args.keywords else []
if not category and not kw_list:
print(json.dumps({"ok": False, "error": "need --category OR --keywords"},
ensure_ascii=False))
sys.exit(1)
cap = args.limit or args.repos_limit
# 取数
if category:
sys.stderr.write(f"[hotspot] 分类精选: {category} 上限: {cap}{args.days or ''}\n")
else:
sys.stderr.write(f"[hotspot] 关键词: {kw_list} 上限: {cap}\n")
sys.stderr.flush()
raw = collect(keywords=kw_list or None, repos_limit=cap,
category=category or None, limit=cap, days=args.days)
# 计算
sys.stderr.write(f"[hotspot] 仓库: {len(raw['repos'])} 计算热点…\n")
sys.stderr.flush()
output = compute(raw)
json_text = json.dumps(output, ensure_ascii=False, indent=2)
if args.out:
os.makedirs(args.out, exist_ok=True)
json_path = os.path.join(args.out, "hotspot.json")
with open(json_path, "w", encoding="utf-8") as f:
f.write(json_text)
report_path = os.path.join(args.out, "report.md")
with open(report_path, "w", encoding="utf-8") as f:
f.write(_report_md(output))
sys.stderr.write(
f"[hotspot] ✓ 完成 "
f"仓库={output['meta']['repo_count']} "
f"讨论={output['meta']['discussion_count']} "
f"主题={output['meta']['topic_count']} "
f"学者={output['meta']['scholar_count']}\n"
)
sys.stderr.write(f"[hotspot] 产物: {json_path}, {report_path}\n")
sys.stderr.flush()
else:
print(json_text)
if __name__ == "__main__":
main()