三源热榜去重加 AI 摘要:URL 唯一键踩出的三个坑

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
Reddit 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。

把这些边界写下来,比再加一个平台适配器有用。

仓库:Cooperiano/codepulse

相关推荐
用户40199255686113 分钟前
多源异构 B2B 公开需求信号去重与企业实体证据链存储设计(64-bit SimHash 分桶 + Jaccard)
python
Yk酱2284027 分钟前
A2UI探索随笔-技术选型
前端
前端繁华如梦31 分钟前
# 从 73000 行压缩代码到 TypeScript 模块化:我把一个 Three.js FPS 游戏 1:1 复刻了一遍
前端
Penge66635 分钟前
React Context 详解:从 Props 传递到跨组件状态共享
前端
Penge66644 分钟前
React 事件处理:bind(this) 原理与实际开发常用写法
前端
aifannihao1 小时前
GitHub Actions 锁文件实测:6 个 uses 只锁住 2 个
python·ci/cd·标准库·供应链安全·github actions
ZhengEnCi1 小时前
小白用 AI 做官网完全指南:不用框架的六格清单,双击就能打开
前端·html·ai编程
2601_967980111 小时前
使用gensim处理文本(1)语料库与词向量空间
python·自然语言处理·gensim·词向量·文本分析
zhanlang02171 小时前
HTML 零基础入门到企业实战【第五章 5.2】图片路径精讲:绝对路径、相对路径、网络路径(新手必踩坑点)
前端·html