GitHub trending 没有官方 API,只能抓 HTML;Hugging Face 有 /api/models,可以 sort=likes&direction=-1;Reddit 的 r/MachineLearning/hot.json 直接给 JSON。三个源的数据形状完全不一样:一个是「仓库 + star」,一个是「模型 + like」,一个是「帖子 + upvote」。
CodePulse 要做的事很窄:把三种热度塞进同一张 projects 表,前端三个 Tab、详情页、推荐页读同一套 API。窄的好处是不用抽象出「平台适配器」那一层,归一化全部落在 backend/app/services/crawler.py:三个类各一个 fetch_trending(),都返回 List[Dict],字段固定七个------title、description、url、stars、forks、language、source。
顺便交代一个当时就否掉的方向:用 GitHub 的搜索 API 按 stars 倒序拿仓库。那样确实有结构化字段、有分页、有 ETag,但拿到的是「历史上星最多的仓库」,跟「今天在涨的仓库」不是一回事,按星数倒序排,榜单会被几个历史积累最厚的项目长期占住。trending 页的语义是单位时间内的增量,这个信息只在那个 HTML 页面里,所以 GitHub 这条路只能抓页面。HF 和 Reddit 反过来,它们的热度接口本身就是按「此刻」排序的,hot.json 和 sort=likes 都不用自己算增量。
三个源的抓取频率也不一样,不是随便填的:HF 的 like 数变化最慢,1800 秒一次足够;GitHub trending 的榜单一天会翻好几次,取 3600 秒;Reddit 的 hot 排序带时间衰减,7200 秒一次反而更稳,抓太勤容易撞 429。
一张表吃三个源
| 源 | 端点 | 热度映射 | 单次条数 | 额外处理 |
|---|---|---|---|---|
| GitHub | https://github.com/trending |
stars、forks 原样 |
1(桩数据) | 带浏览器 UA |
| Hugging Face | https://huggingface.co/api/models |
likes → stars,forks=0 |
20 | limit=20&sort=likes&direction=-1 |
https://www.reddit.com/r/MachineLearning/hot.json |
score → stars,forks=0 |
10 | selftext 截 500 字符 |
落库是 projects 表:url VARCHAR(500) UNIQUE NOT NULL,trend_score DECIMAL(5,2),tags JSONB DEFAULT '[]'。调度写在 backend/app/worker.py 的 beat_schedule 里,GitHub 3600 秒、HF 1800 秒、Reddit 7200 秒。HF 那段最省事,因为字段本来就规整:
python
response = await client.get(
HuggingFaceCrawler.BASE_URL,
params={"limit": 20, "sort": "likes", "direction": "-1"},
)
if response.status_code != 200:
return []
models = response.json()
return [
{
"title": model.get("modelId", ""),
"description": model.get("description", ""),
"url": f"https://huggingface.co/{model.get('modelId', '')}",
"stars": model.get("likes", 0),
"forks": 0,
"language": "Python",
"source": "huggingface",
}
for model in models[:20]
]
language 写死 "Python" 是我偷懒:HF 的模型卡里没有可靠的语言字段,我干脆让这一列有个值。代价是按语言筛选时,HF 的条目会全落到 Python 下面。
去重:URL 是唯一键,但没用 UPSERT
去重没有单独模块,就是 update_projects() 里的一次 filter:
python
for source, items in results.items():
for item in items:
existing = self.db.query(Project).filter(Project.url == item["url"]).first()
trend_score = self.calculate_trend_score(item["stars"], item["forks"], source)
if existing:
existing.stars = item["stars"]
existing.forks = item["forks"]
existing.trend_score = trend_score
else:
self.db.add(Project(
title=item["title"], description=item["description"],
source=source, url=item["url"], stars=item["stars"],
forks=item["forks"], language=item.get("language"),
tags=[], trend_score=trend_score,
))
updated += 1
try:
self.db.commit()
except Exception as exc:
self.db.rollback()
raise RuntimeError(f"Failed to commit database changes: {exc}") from exc
选 URL 当唯一键,是因为三源的地址都稳定:HF 是 huggingface.co/{modelId},Reddit 是 permalink 拼出来的帖子地址,GitHub 是仓库地址。同一项目在不同源上 URL 不同,所以「跨源合并同一项目」这个版本没做------GitHub 上的 llama 和 HF 上的 llama 是两行。这是故意的取舍,不是漏掉。
另外两个候选键我都试过设想:用 title 不行,Reddit 的帖子标题是自然语言,改个标点就是新记录,重复率会高得没法看;用 (source, title) 稍好一点,但 HF 的 modelId 才真正唯一,标题反而可能被作者改。最后只有 URL 同时满足「源内唯一」和「跨轮次稳定」这两条。代价是去重粒度被 URL 的形状绑死了------哪天 HF 换 URL 格式,历史记录会整体复制一份,得手动迁移。
坑一是逐行 SELECT 加结尾单点 commit。8 小时抓一次时看不出问题,间隔压到分钟级就是每轮几十次 SELECT ... WHERE url = ?。更麻烦的是那个唯一的 commit():任何一行失败都回滚整批,而 updated += 1 在 commit 之前就加了,返回的计数跟着失真。要修就把查重和写入合成一条语句,交给唯一索引裁决:
sql
INSERT INTO projects (title, description, source, url, stars, forks, language, tags, trend_score)
VALUES (%s, %s, %s, %s, %s, %s, %s, '[]', %s)
ON CONFLICT (url) DO UPDATE
SET stars = EXCLUDED.stars,
forks = EXCLUDED.forks,
trend_score = EXCLUDED.trend_score,
updated_at = NOW();
url UNIQUE 约束已经在了,ON CONFLICT (url) 才成立;SQLAlchemy 那边对应 pg_insert(...).on_conflict_do_update。
打分:一个除法、两个魔数,和 DECIMAL(5,2) 的天花板
python
def calculate_trend_score(self, stars: int, forks: int, source: str) -> float:
base_score = stars + (forks * 2)
multipliers = {"github": 1.0, "huggingface": 1.2, "reddit": 0.8}
return round(base_score * multipliers.get(source, 1.0) / 100, 2)
测试锁住的取值是 calculate_trend_score(1000, 100, "github") == 12.0、"huggingface" == 14.4。fork 权重 2、乘数 1.0/1.2/0.8 都是手调的,没有归一化依据。当时的想法是:Fork 比 Star 贵,一个人点星只要一秒,fork 至少意味着他 clone 了,所以给 2 倍;HF 的 like 门槛比 GitHub 的 star 高(要先登录、还得找到那个模型页),所以给 1.2;Reddit 一个 upvote 最廉价,给 0.8。
| 输入 | 计算 | trend_score |
|---|---|---|
| GitHub 1000 星 / 100 fork | (1000 + 200) × 1.0 / 100 |
12.0 |
| HF 1000 like | (1000 + 0) × 1.2 / 100 |
12.0 |
| Reddit 500 分 | (500 + 0) × 0.8 / 100 |
4.0 |
| GitHub 10000 星 | 10000 × 1.0 / 100 |
100.0 |
| GitHub 400000 星 | 400000 × 1.0 / 100 |
4000.00,超列精度 |
坑二就在最后一行。DECIMAL(5,2) 表示总共 5 位有效数字、其中 2 位小数,最大值 999.99;stars + 2*forks 到 99999 时正好卡在 999.99,再加一就超出列精度,十万星量级的仓库直接抛 numeric field overflow,被上面那个 try/except 包成 RuntimeError,然后整批回滚,三个源一起白抓。更难受的是测试全绿:backend/tests/test_api.py 用的是 sqlite:///./test.db,SQLite 不校验 DECIMAL 精度,往里塞 4000.00 一句怨言都没有。这就是「本地跑得通、上线才炸」的典型。
修法两条,任选:列改成 DECIMAL(12,2);或者把分值做对数压缩,让一万星和十万星不再差一个数量级。我倾向后者,因为详情页直接把 trend_score 显示出来,四位数看着就不像个「分数」。
AI 摘要:剥围栏、截 README、三层兜底
摘要是一次 messages.create,模型 claude-3-5-haiku-20241022,max_tokens=1000,prompt 里塞 README 前 5000 字符加 description/language/stars 三个元数据,要求返回 summary、key_features、use_cases 的 JSON。因为没用 tool use 或结构化输出,模型经常在 JSON 外面套一层 markdown 代码围栏,得先用正则剥掉:
python
try:
message = AIService._client().messages.create(
model="claude-3-5-haiku-20241022",
max_tokens=1000,
messages=[{"role": "user", "content": prompt}],
)
content = message.content[0].text
content = re.sub(r"```json\s*", "", content)
content = re.sub(r"```\s*", "", content).strip()
return json.loads(content)
except anthropic.APIError as exc:
logger.error(f"Anthropic API error in summarize_project: {exc}")
return {"summary": metadata.get("description", "No summary available"),
"key_features": [], "use_cases": []}
except json.JSONDecodeError as exc:
logger.error(f"Failed to parse AI response as JSON: {exc}")
return {"summary": metadata.get("description", "No summary available"),
"key_features": [], "use_cases": []}
兜底三层返回同一个结构,保证前端 project.ai_summary ? ... : null 那块不会崩掉。
坑三在 except 的排列上。answer_question() 里写成了:
python
except anthropic.APIError as exc:
return "I cannot reach the AI service right now. Please try again later."
except anthropic.AuthenticationError as exc:
return "AI service authentication failed. Please check API configuration."
except anthropic.RateLimitError as exc:
return "AI service rate limit exceeded. Please try again later."
后两个分支永远执行不到:AuthenticationError 和 RateLimitError 都继承自 APIError,Python 按顺序匹配,第一个就接住了。现象是 key 配错了、或者限流了,前端收到的永远是那句「连不上 AI」,我照着这句话查了半天网络,最后发现是环境变量没进容器。改法只有一条:窄异常在前,APIError 兜底放最后。
还得说清楚一件事:仓库里 summarize_project 目前没有调用点。/api/chat 走的只有 answer_question,ai_summaries 表和 Project.ai_summary 关系都建好了,但没有任何代码往里写,详情页那块 AI 摘要区块实际不会渲染。它是写完没接线的半成品。
选 claude-3-5-haiku 而不是更大的模型,理由是摘要这个任务的输入是 README、输出是一段固定结构的 JSON,不需要推理,只需要归纳;max_tokens=1000 也够,三个字段塞不满。README 截 5000 字符是另一层成本控制:不少仓库的 README 带大量徽章和示例图,原文几万字符,截断之后 token 花在项目描述上的比例更高。这里我没做的事是缓存------设计文档里写了「摘要缓存 30 天」,代码里没有对应实现,同一仓库重复调用会重复付费。
边界与代价
- worker 起了,beat 没起。
docker-compose.yml里 worker 的命令是celery -A app.worker worker --loglevel=info,没有beat,beat_schedule里那三个 3600/1800/7200 秒的条目不会被触发。 - 三个定时任务只抓不落库。
fetch_github_trending拿到结果后只return {"fetched": len(result)},没有db.add;真正写库的只有fetch_all_trending()→update_projects(),而它不在beat_schedule里。所以现在要么手动 dispatch,要么库里一直是空的。 - GitHub 抓取是桩。
GitHubCrawler.fetch_trending()确实发了GET https://github.com/trending,UA 是Mozilla/5.0 (compatible; CodePulse/1.0),但await client.get(...)的返回值被直接丢掉,函数返回一行硬编码的example/awesome-project(stars=1500)。GitHub 那个 Tab 的数据不是抓来的;而且这一行会以https://github.com/example/awesome-project为 URL 真的进库。 - trending 接口靠
updated_at兜时间窗。/api/projects/trending的过滤条件是updated_at >= utcnow() - 1 day,抓取一停三个数组就都是空的,接口照样返回 200,前端看到空列表而不是错误。 - 「推荐」是三源合并后按分数排序取 20。
RecommendPage.tsx里就是[...data.github, ...data.huggingface, ...data.reddit].sort((a, b) => b.trend_score - a.trend_score).slice(0, 20),没有个性化;user_interactions表建了但没人读。 - 同步 SQLAlchemy 跑在
async def里。list_projects、get_trending都是async def配同步Session,查询期间占着事件循环。连接池是pool_size=10, max_overflow=20, pool_pre_ping=True, pool_recycle=3600,单机够用,但不是能横向扩的写法。 tags恒为空。update_projects()里硬编码tags=[],AIService.extract_tags()只在tests/test_services.py里被调用过。/health吞掉数据库故障。SELECT 1失败只把database字段写成"disconnected",HTTP 状态还是 200,而docker-compose的 healthcheck 打的就是这个端点,所以数据库挂了容器也不会被判不健康。- 设计文档比代码大一圈。
docs/design/2026-05-07-github-hot-tracker-design.md里列了/api/auth/register、/api/auth/login、/api/recommendations和services/recommendation.py,代码树里没有对应的auth.py和recommendation.py;users、user_interactions两张表和JWT_SECRET、JWT_EXPIRATION配置都在,但没有一条读写路径。推荐页没有走独立接口,是直接复用/api/projects/trending。
小结
三个源塞一张表,最贵的不是抓取,是「什么算同一个东西」和「什么算热」这两个定义:URL 唯一键决定了跨源项目永远是两行,stars + 2*forks 除以 100 决定了量纲不同的平台被强行拉到同一根横轴上,而 DECIMAL(5,2) 这种看着很安全的字段会在大仓库面前直接变成 500。这三个问题里,前两个是设计选择,可以用文档说清楚;第三个纯粹是字段长度没算过,属于必须改的那类。
真要我按优先级重排一遍:先给 ON CONFLICT 把落库改成幂等,再把 trend_score 换成浮点或对数刻度,然后补上 beat 和真实的 GitHub 解析,最后才轮到接 summarize_project。顺序反了的话,AI 摘要会为一个只有一行桩数据的 GitHub Tab 生成漂亮的总结,而那条数据的 URL 是 example/awesome-project。
把这些边界写下来,比再加一个平台适配器有用。