早上打开手机,先刷一眼 GitHub 趋势页,看看今天哪些仓库火了,这已经成了我每天开工前的习惯。但问题在于,GitHub 趋势页的排序逻辑多变,热榜上经常出现看不懂的语言、冷门领域,或者一些点进去才发现早就见过的老项目。刷了几年之后,我干脆写了个小服务来干这件事,每天定时把趋势仓库抓下来、按自己的规则过滤排序,再推到群里和邮件里。这套东西就是今天想聊的“今日GitHub趋势速递”。
这东西的定位很纯粹:不用打开浏览器、不用被推荐算法带偏,每天固定时间主动把“值得看的仓库”送到你手上。它不是一个复杂的平台,也没有高深的技术,但如果你每天依赖 GitHub 热门仓库获取学习素材、寻找灵感、或者只是不想错过圈子里的热点,这个服务能帮你省掉大量碎片时间。适合的人群也很广:独立开发者、技术团队负责人、关注开源动态的产品经理,甚至刚入门想找练手项目的学生。
下面我会把整套方案从需求拆解、技术选型到实现细节、部署运维全部过一遍,把我踩过的坑和调过的参数都摆出来讲清楚。
1. 项目概述:趋势速递到底在解决什么问题
1.1 核心需求解析
先聊一下需求本身。GitHub 官方趋势页面每天都会更新,但作为信息源,它有三个让我不舒服的地方。
第一个问题是时间敏感度不够。官方的“今日趋势”并不是严格按 24 小时滚动的,很多仓库一旦上榜会连续挂两三天,排在前面的大多是大厂或者明星项目,真正的小众黑马很容易被挤到后面甚至根本看不见。
第二个问题是信息密度低。趋势页只显示仓库名、描述、星标数、今日新增星标和语言,但没告诉你这个仓库为什么火、它的核心亮点是什么、适合什么场景使用。我得一个个点进去看 README,一个早上就这么没了。
第三个问题是入选标准不可控。默认的 GitHub 趋势页有语言过滤、日期范围过滤,但很多人其实不知道结合这些过滤条件能挖出完全不同的榜单。如果只是被动地看默认页面,等于把自己锁在一个信息茧房里。
所以当时我对这个项目的预期非常具体:每天定时去抓原始趋势数据,按自己的规则做二次筛选(比如排除掉已经见过很多次的知名项目、排除掉文档都没有的占坑仓库、按开发语言重新分组),然后生成一个带推荐语和简要分析的速递报告,推送到我常用的聊天工具里。
1.2 目标读者与适用场景
这套东西做出来之后,我首先自己用了两周,然后陆续有些朋友也接入进去。从反馈来看,使用场景主要分成三类。
第一类是个人开发者,他们把趋势速递当作每日技术早报,用来发现新的开源工具、看别人的项目怎么写 README、分析热门项目用了什么技术栈。
第二类是技术团队负责人,他们会把速递内容同步到一个内部的频道,用于技术选型调研和团队信息同步。有段时间我们在评估 API 网关方案,靠的就是持续几天观察 GitHub 趋势上出现的网关项目,然后逐个做定向分析。
第三类是开源爱好者,他们更关注冷门领域的黑马项目,希望看到榜单里那些星标不多但趋势很猛的新仓库。这类项目往往代表了一个小圈子的近期热点,提前关注可以占住先手。
所以这个项目的关键词是“自动采集”、“趋势筛选”、“定时推送”、“多端触达”。每一个词背后都有对应的技术选型和实现细节。
2. 技术选型与方案设计
2.1 语言与框架的取舍逻辑
抓取 GitHub 趋势页这件事,最简单粗暴的方案是写个 Python 脚本用 requests 拉 HTML,再用 BeautifulSoup 解析。但真正做下来你会发现,这个方案的脆弱性超出想象。
GitHub 的页面结构不是一成不变的,class 名偶尔会调整,DOM 层级也会变,一套解析规则可能今天还能用、明天就报废。而且 GitHub 对未登录的匿名请求有比较严的限流策略,频率稍微高一点就会返回 429。
我在选型时走了另一条路:直接用 GitHub 官方 REST API,再加一层趋势判断逻辑。具体来说,用 GitHub Search API 按仓库的创建时间和星标增长情况做过滤,配合官方 Trending 页面的数据做交叉验证。
语言的选型上我最后用了 Python。相比 Node.js 或者 Go,Python 在这类场景有三个优势:requests 和 BeautifulSoup 生态成熟、写脚本迭代速度快、后续如果要接数据分析或者机器学习模型也很方便。我自己用的是 Python 3.10 搭配 FastAPI 做了个轻量服务,其实如果不是为了那个简单的 Web 展示页,用纯脚本+定时任务就够了。
2.2 数据源的获取策略
我把数据获取分成两路。
一路是官方 Trending 页面的 HTML 抓取,虽然解析规则会变,但它有一个别的地方拿不到的好处:它直接反映了 GitHub 官方认为“今天最火”的仓库。官方趋势页的背后逻辑不公开,但它大概率结合了收藏数、fork 速度、访问量、Star 的绝对增量等多个信号,单靠 API 是模拟不出来的。
第二路是Search API 拉取近期创建仓库的热度数据,这路数据主要负责补充官方趋势页的盲区。因为有些小众项目并不在官方趋势榜上,但它们在特定语言或者特定主题的搜索排序里上升很快,这种仓库值得单独拎出来做观察。
这里顺便说一下抓取频率的设计。官方趋势页按天更新,我设置每 6 小时跑一次,一天四次足够捕捉到当天上榜的仓库变化。Search API 因为搜索结果变化快,我设置每 4 小时跑一次。整体的请求量控制在每天几百次以内,远低于 GitHub API 的限流阈值(标准认证每小时 5000 次请求),所以到现在没有遇到过封禁问题。
2.3 整体架构与模块划分
这个项目拆成四个模块:
- 采集模块:负责获取 HTML 数据和 API 数据,做去重和归一化。
- 筛选模块:按规则打分、过滤、分组,生成每日趋势榜单。
- 推送模块:把榜单内容渲染为 Markdown,推送到多个渠道。
- 可视化模块:生成简单的 Web 页面,方便随时查看历史趋势记录。
四个模块独立部署,彼此之间通过 SQLite 数据库共享数据。之所以不用 MySQL 或者 PostgreSQL,是因为这个服务的并发量极低——每天就几次写入、几十次查询,SQLite 单文件部署方便,备份也简单。后面如果数据量真的大了,再迁到 PostgreSQL 也不迟,但至少现阶段 SQLite 完全够用。
3. 核心功能实现与细节拆解
3.1 采集模块:HTML 解析与 API 补全
HTML 抓取这一步,我用了最稳妥的方式:先请求页面,拿到 HTML 后用 BeautifulSoup 定位仓库列表的容器。GitHub 趋势页的仓库列表结构相对固定,每个仓库条目都在一个<article>标签内,标题在h2标签里,描述在<p>标签里。用select方法按 CSS 选择器提取,中间隔几层 class 变化也不怕,只要锚定article这个标签就行。
import requests from bs4 import BeautifulSoup headers = { "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/120.0 Safari/537.36" } def fetch_trending(language="", since="daily"): url = "https://github.com/trending" + (f"/{language}" if language else "") params = {"since": since} resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() soup = BeautifulSoup(resp.text, "html.parser") repos = [] for article in soup.select("article.Box-row"): h2 = article.select_one("h2 a") desc = article.select_one("p") stars = article.select("a[href$='/stargazers']") repo_name = h2.text.strip().replace("\n", "").replace(" ", "") repo_desc = desc.text.strip() if desc else "" star_count = 0 if stars: star_text = stars[0].text.strip() star_count = parse_star_text(star_text) repos.append({ "name": repo_name, "desc": repo_desc, "stars": star_count, }) return repos def parse_star_text(text): # "1,234" -> 1234, "12.5k" -> 12500 text = text.replace(",", "") if text.endswith("k"): return int(float(text[:-1]) * 1000) return int(text)这里有个容易忽略的细节:article下的a[href$='/stargazers']可能会匹配到多个元素,因为一个仓库条目里既有 Star 数也有 Fork 数,它们的链接后缀不一样,要精确匹配stargazers。
API 补全这部分,我用的是 GitHub Search API 里按created:>过滤近期仓库,排序用stars,再加一个language:参数按语言分流。例如想找最近一周创建、涨星最快的 Python 仓库:
curl -H "Accept: application/vnd.github+json" \ "https://api.github.com/search/repositories?q=created:>2024-01-01+language:python&sort=stars&order=desc&per_page=50"注意 Search API 的per_page上限是 100,如果只取一个语言的 top50,一次请求就够了。这些数据会缓存在本地,后续筛选模块会结合 HTML 的趋势数据和 API 的搜索数据一起处理。
3.2 筛选模块:热度评分与黑名单机制
抓回来的原始数据不能直接用,因为官方趋势页的排序不完全符合我的口味。我设计了一套简单的打分规则,核心指标有三个:今日新增星标数、总星标数、仓库创建时间。
计算公式是这样的:
score = 新增星标数 * 2 + min(总星标数 / 1000, 50) + 创建天数衰减因子创建天数衰减因子是这么算的:如果仓库创建超过 30 天,每多一天扣 0.1 分;如果创建在 7 天以内,额外加 10 分。这样做的目的是让新项目有更高的曝光机会,避免榜单被那些老牌热门项目长期霸占。
我用一个score_repo(repo)函数来实现:
from datetime import datetime, timezone def score_repo(repo): today_stars = repo.get("today_stars", 0) total_stars = repo.get("stars", 0) created_at = repo.get("created_at") created_days = (datetime.now(timezone.utc) - created_at).days score = today_stars * 2 + min(total_stars / 1000, 50) if created_days < 7: score += 10 elif created_days > 30: score -= (created_days - 30) * 0.1 return round(score, 2)除了打分,黑名单机制也必不可少。我把那些“看到烦”的仓库加进黑名单,比如某些头部大厂的 SDK 仓库、每隔几天就被推到趋势顶部的知名项目,还有 README 都没写清楚的空壳仓库。这个名单我用 JSON 文件维护,运行时可动态更新,不需要重启服务。
3.3 推送模块:多渠道触达的渲染与适配
筛选完的榜单最终要以可读性强的形式推出去。我这里选择了三个渠道:钉钉机器人、企业微信机器人、邮件。三个渠道的技术难度都不高,但渲染格式略有不同。
钉钉和企业微信都支持 Markdown 消息,直接拼一个 Markdown 字符串发过去就行。邮件的部分稍微麻烦一点,因为很多邮箱客户端对 Markdown 不支持,我要先把内容转成 HTML。
推送模块的核心是构建一个统一的render_report(repos)函数,把仓库列表渲染成带标题、描述、链接、语言标签的文本。不管推到哪个渠道,主体格式保持一致,只是包装方式不同。
def render_report(repos): lines = ["## GitHub 今日趋势速递", ""] for idx, repo in enumerate(repos, start=1): lines.append(f"### {idx}. {repo['name']}") lines.append(f"- 语言:{repo.get('lang', '未知')}") lines.append(f"- 今日 Star:{repo.get('today_stars', 0)}") lines.append(f"- 描述:{repo.get('desc', '暂无')[:120]}") lines.append(f"- 地址:{repo.get('url', '')}") lines.append("") return "\n".join(lines)钉钉机器人推送需要加一个关键字或者加签验证,企业微信机器人则是直接发 Webhook 地址。如果你用的是自定义渠道,比如 Discord 或者 Telegram,接一个 HTTP 调用也能搞定,原理一模一样。
3.4 定时调度与幂等处理
这个服务要跑起来,调度是必不可少的。我用的是 APScheduler,原因很简单:它在 Python 里配置简单、支持 cron 表达式、还能持久化任务状态。每天上午 9 点跑一次趋势采集,下午 3 点跑一次 API 补全,晚上 8 点做最终汇总推送。
这里有一个很重要的细节:幂等处理。定时任务在运行过程中可能因为网络问题失败重试,如果重试时不做判断,同一批数据可能会被重复写入、重复推送。我在数据库里为仓库加了一个唯一索引,字段组合是仓库名 + 采集日期,写入时用INSERT OR IGNORE,能挡住重复数据。推送模块则用任务 ID 做去重,每个任务生成一个 UUID,一分钟之内的重复请求直接跳过。
4. 部署流程与日常运维
4.1 快速上手的部署步骤
部署环境我推荐用一台 1 核 1G 的小机器就够了,这个服务的资源消耗非常低。如果你只有一台平时跑其他服务的服务器,用 Docker 把它塞进去最省事。
先给项目写一个简单的 Dockerfile:
FROM python:3.10-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt COPY . . CMD ["python", "main.py"]然后构建镜像并跑起来:
docker build -t trending-digest . docker run -d --name trending-digest \ -v /data/trending.db:/app/data/trending.db \ -e PUSH_URL="https://oapi.dingtalk.com/robot/send?access_token=xxx" \ -e MAIL_ENABLED="true" \ --restart always \ trending-digest环境变量那一块是重点。把所有敏感配置(Webhook 地址、邮箱密码、数据库路径)全部用环境变量注入,不要写死在代码里,尤其是聊天机器人的 Webhook,一旦泄露后果不好收拾。
如果你不想上 Docker,直接在服务器上跑也行:
nohup python main.py > app.log 2>&1 &然后把进程托管给 systemd 或者 supervisor,再加一个定时任务做重启兜底,省心程度和 Docker 相差不大。
4.2 数据存储与历史记录查询
前面提到我用 SQLite 做数据持久化,看下建表语句:
CREATE TABLE IF NOT EXISTS repos ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, lang TEXT, desc TEXT, stars INTEGER, today_stars INTEGER, score REAL, created_at DATETIME, collected_date TEXT NOT NULL, UNIQUE(name, collected_date) );每天跑完任务,数据就落进这张表。想查某天榜单非常简单:
SELECT * FROM repos WHERE collected_date = '2024-01-15' ORDER BY score DESC LIMIT 20;用 SQLite 的好处是备份容易,直接拷文件就行。我每天凌晨会压缩一份数据库文件存到另一块磁盘上,保留最近 30 天的备份,基本杜绝了误操作导致的历史数据丢失。
4.3 日志与异常监控
定时任务最怕“悄悄死掉”。我的做法是两层监控:第一层是服务自带日志,用 Python 的logging模块同时输出到控制台和文件;第二层是健康检查接口,Web 模块里加了一个/healthz路由,返回 JSON 状态码,然后用外部监控服务每 5 分钟探一次。
from fastapi import FastAPI from fastapi.responses import JSONResponse app = FastAPI() @app.get("/healthz") def health_check(): return JSONResponse({"status": "ok", "time": datetime.now().isoformat()})如果连续三次健康检查失败,监控服务会给我发报警。这个机制帮我抓过一次故障:有段时间 GitHub 改版,我的 HTML 解析规则失效,采集模块抛异常,但因为主进程没退出,健康检查一直是正常的,直到监控加了“最近一次成功入库的数据时间”这个指标才暴露出来。所以健康检查不能只看进程活着,一定要看数据有没有真正更新。
5. 常见问题与避坑指南
5.1 请求被限流怎么办
很多人一上来就直接用 requests 高频抓 GitHub,结果很快收到 403 或者 429。GitHub 的限流策略对不同接口不一样,REST API 的限流是按每小时计算的,未认证的请求一小时只有 60 次,认证之后是 5000 次。HTML 页面的限流虽然没有明确数字,但明显更敏感,短时间高频请求很容易被临时封 IP。
我的应对方案是:第一,所有 API 请求都带上 OAuth token;第二,抓取节奏控制在最小必要频率;第三,设置随机延迟,避免请求像脉冲一样规律性发出。代码里我加了一个小函数:
import time import random def polite_request(url, headers, retries=3): for i in range(retries): try: time.sleep(random.uniform(1.5, 3.5)) resp = requests.get(url, headers=headers, timeout=15) if resp.status_code == 429: wait_time = int(resp.headers.get("Retry-After", 60)) time.sleep(wait_time + 5) continue resp.raise_for_status() return resp except requests.RequestException: if i == retries - 1: raise time.sleep(5 * (i + 1))5.2 HTML 解析规则失效
GitHub 的前端改版虽然不频繁,但一旦改了,解析代码就直接废掉。最稳妥的做法是减少对具体 class 的依赖。我的经验是锚定语义化标签,比如article、h2、p这些,而不是某个具体样式类。如果哪天连article都换了,那就只能等代码更新后重新抓一次。
还要注意 GitHub 页面可能返回空列表。如果请求成功但解析出的仓库数为 0,这时候宁可不推送也不要推一个空报告,否则用户会困惑。我在采集模块里加了判断:如果仓库数量为 0,抛一个自定义异常,调度任务就跳过本次推送。
5.3 消息推送的重复与丢失
推送渠道偶尔会出问题,最典型的是 Webhook 地址失效或者被平台风控。钉钉机器人的安全设置必须配置加签或者关键字,否则外网随便什么人往你的群里发消息就麻烦了。企业微信机器人相对宽松一点,但也会有频率限制,短时间大量消息会被截断。
我的做法是在推送模块里加了一个发送队列,每条消息发送失败会重试三次,三次都不成功就写入本地失败记录文件,并在下一次任务里带上失败记录重新尝试推送。这样做避免了一部分消息丢失的问题,但不是所有渠道都支持重试(比如邮件重发会产生垃圾邮件),所以这个功能做成可配置的开关。
5.4 趋势榜单的“虚假繁荣”问题
有时候你会看到某个仓库今天涨了很多星,点进去发现是空壳项目或者钓鱼仓库。GitHub 上刷星的情况虽然不多,但确实存在。我的筛选模块里加了一个启发式规则:如果仓库的 Star 数量很高但 README 只有一个标题,或者仓库被 fork 的代码量和 Star 数量严重不匹配,打分会降低。
这条规则的具体实现是:把 README 的长度作为特征值,小于 100 字符的直接标记为“低内容”。再配合一个简单的语言关键词过滤(比如 Description 为空、项目名以 qq 群号开头的),就能挡掉很大一部分垃圾项目。
总结:这套速递服务的真正价值
做这个项目的过程中我一直在想一个问题:GitHub 趋势页本身就在那里,为什么还要费劲搞一个二次加工的服务?后来想明白了,信息过载时代,趋势只有被过滤和解释之后才真正对人有用。官方榜单给的是一堆仓库名,我们想要的是一个能拍板说“今天这几个值得关注”的结论。这就是“今日GitHub趋势速递”这个项目的核心价值。
从技术上来说,整个项目并不复杂,但靠近业务侧的取舍、数据清洗和推送策略才是真正的护城河。把同样一套代码给不同的人用,会因为每个人维护的黑名单、评分偏好、推送渠道不同,产出完全不同的速递效果。这也让工具本身有了个性化的温度。
最后分享一个小技巧:评分公式里的参数别定死,跑一周之后看看实际推出来的榜单质量,再把权重调一调。比如我发现某些语言的项目涨星普遍偏慢,把阈值调低之后,小众语言那边的冷门好项目才开始浮出水面。这种持续调优的过程,反而是做这个项目最有趣的部分。