刚接触GitHub Trending的时候,我就有个执念:这么勤快的榜单,凭什么要我一个个点开页面人工统计。每天刷来刷去,时间浪费不少,真正记住的项目却没几个。后来我干脆搭了一套小工具,用Playwright定时抓取趋势页面,洗干净数据后丢进FastAPI暴露成接口,内网谁要数据直接调API,几秒钟拿到一份结构化的今日热榜。这篇文章就把这套体系的完整落地过程拆开讲,包括为什么选Playwright而不写一堆静态解析逻辑、缓存与后台刷新怎么设计、API怎么封装才抗造,以及部署后踩过的几个典型坑。
如果你是Python有一定基础、想学爬虫进阶或后端接口设计,又恰好对GitHub生态感兴趣,这篇文章应该能帮你少走不少弯路。整套方案不需要复杂集群,一台普通服务器就能跑,核心就两个库加一个Python服务。
1. 需求拆解与技术选型思路
1.1 这个项目到底在解决什么问题
很多人以为抓GitHub趋势就是把页面下载下来再解析,实际动手才发现坑远比想象的多。Trending页面包含每日、每周、每月的排名切换,内容不是简简单单的一段静态HTML,它里面混有动态渲染的脚本逻辑。用传统的Requests直接把HTML拉回来,你会得到一堆JS引用和空壳结构,真正有用的仓库列表还得靠浏览器执行完脚本后才能看到。
更麻烦的是数据时效性。趋势榜单是按时间段变化的,今天的热门项目明天可能就掉下去,如果每次用户查询都实时去抓,慢不说,还会频繁请求目标站点,很容易触发限流机制。所以这个项目表面上是爬虫,核心却落在“如何提供稳定、及时、不打扰源站的服务”上。拆开来看需求其实有四层:
- 能正确渲染并抓取Trending页面数据。
- 能对抓到的数据做结构化清洗,去掉杂质。
- 能缓存结果,不反复请求GitHub。
- 能通过HTTP接口对外提供查询。
基于这四层,技术选型自然就清楚了:浏览器自动化交给Playwright,服务层交给FastAPI。
1.2 为什么用Playwright而不是Requests
我早期做爬虫也喜欢纯Requests加大正则,觉得轻巧又可控。但GitHub趋势页面的实际表现是:页面结构与脚本执行强相关,尤其是切换语言标签和周期时,部分数据会由前端脚本动态注入。如果只用Requests,可能得额外维护一套模拟请求参数的逻辑,去猜数据埋在哪个接口里,一旦页面改版就要跟着返工。
Playwright的核心价值在于它启动了一个真实浏览器内核,页面怎么渲染它就怎么执行。用户看到什么,代码就能抓到什么。这样就把“猜结构”变成“看结果”,我只需要关心DOM结构是否稳定。和Selenium比,Playwright的优势也很明显:
- 原生支持asyncio,和FastAPI的异步模型完美配对。
- 自动管理浏览器上下文,内存隔离更好。
- 内置等待机制,不用手动sleep。
- 支持Chromium、Firefox、WebKit多内核切换。
当然浏览器自动化的缺点是重。启动一个浏览器进程比发一次HTTP请求贵得多。所以我的策略是用它做“低频采集”,而不是每个请求都开浏览器。
1.3 为什么用FastAPI做服务层
FastAPI这几年在Python后端里很受欢迎,除了开发效率高,还有一个隐藏优势:它的异步编程模型和Playwright同一个路子。我在爬虫模块里用async写,API接口也用async写,两者可以共用一个事件循环,不需要开线程池硬拼接。
此外FastAPI自带请求参数校验和自动生成交互式文档。给前端、给同事对接接口时,直接发一个/docs地址过去就行,不用额外写接口文档。这对小型工具服务来说非常省事。再加上基于Pydantic的响应模型,返回给调用方的数据结构有约束,不容易出现字段拼写错误。
1.4 高可用的两层含义
“高可用”不是指永远不挂,而是指在有限的资源下尽量保证服务稳定。这套系统里,高可用体现在两个层面。第一层是对源站友好:通过合理频率、缓存复用、异常重试来降低对GitHub的请求压力,避免被限流。第二层是对调用方稳定:API接口不要因为一次抓取失败就整个崩溃,而是把上次成功的数据继续返回,并标记数据时间长度,让调用方知道当前数据是实时的还是缓存的。
这两个目标都指向同一个核心设计:缓存加异步刷新。下面展开讲具体怎么落地。
2. 核心模块设计:数据、缓存、解析与接口
2.1 先想清楚要返回什么
写代码前先定义数据结构,是最能避免后期返工的做法。GitHub趋势页面的每个仓库卡片,信息密度其实很高,但实际用到的基础字段就那几个。我在项目里这样设计返回字段:
repository:仓库全名,例如owner/name。description:项目描述,没有就返回空字符串。language:主要编程语言。stars_total:当前总Star数。stars_today:当前周期内新增Star数。forks:Fork数。url:仓库完整链接。since:当前数据代表的时间窗口,daily、weekly或monthly。
since这个字段值得多说一句。同一个仓库在daily和weekly榜上表现完全不同,调用方需要知道这份数据是多少时间范围内统计出来的,否则容易产生误解。无论是后续做数据归档还是生成趋势报告,这个字段都很有价值。
2.2 缓存不是可选项而是必须
第一版我偷懒,每次接口被调用就触发一次爬虫。结果就是接口响应慢、GitHub请求频繁,还偶尔被安全校验拦截。后来彻底改成缓存优先。缓存逻辑不复杂,走的是经典的“读缓存、写缓存、过期刷新”三步,但有几个细节值得注意。
缓存键是language + since,比如python-daily、all-weekly。键值对存储的是一个包含“仓库列表”和“抓取时间”的结构。为什么要把抓取时间一起存?因为调用方经常问“这数据是什么时候的”,直接查缓存的更新时间和列表一起返回,省得多做一次查询。
TTL(有效期)我设为300秒,也就是5分钟。这个值不是随便拍的:GitHub趋势在最热门时段变化较快,但也没快到每秒钟都变;5分钟足以保证数据新鲜度,又不会把请求频率拉得太高。
2.3 Playwright的浏览器上下文管理
Playwright最容易被忽略的概念就是Browser与BrowserContext的区别。Browser是浏览器实例,Context相当于浏览器里的独立会话。每一次抓取任务,我都新建一个Context,用完就关掉。这样即使某次页面内残留了奇怪的状态,也不会污染下一次抓取。
Browser本身保持单例,应用启动时创建,服务停止时关闭。这样避免了反复启动浏览器进程的开销。再配合viewport、locale、timezone_id这些参数,让整个浏览器会话更像一个真实用户,而不是默认的自动化窗口。
等待策略是整个抓取成败的关键。默认的page.goto只保证页面接收完成,不保证内容渲染完毕。正确做法是使用page.wait_for_selector等待仓库列表的某个稳定元素出现,确认渲染完成后再开始解析。我给等待设置超时时间,默认30秒,超过就抛异常,由上层重试逻辑兜底。
2.4 解析Trending页面的几个实用选择器
GitHub Trending页面的DOM结构虽然偶有调整,但核心的仓库行一般保持在某个稳定容器内。我在解析时用CSS选择器定位仓库卡片,然后逐个提取信息。
注意不要依赖article下面的深层嵌套结构,因为article标签本身相对稳定,而内部的标签层级一旦调整就废了。解析出标题和链接后,用字符串处理拆出作者的仓库名。这里有个小技巧:把href属性拿下来之后,可以用urljoin拼接完整地址,免去自己拼字符串出错的麻烦。
密集采集时,可以给每个解析任务加一点随机延时,错开请求时间,避免同一秒内连续访问。这个习惯对任何爬虫项目都适用,不是针对某个特定平台,而是基本的礼貌协议。
2.5 API接口设计
接口设计遵循少而精的原则。整个服务就提供一个主接口:
GET /api/trending
参数有三个:
language:可选,按语言过滤,默认所有语言。since:可选,daily、weekly、monthly,默认daily。limit:可选,返回数量上限,默认30,最大值50。
返回结构统一为:
{ "code": 0, "message": "success", "data": [ ... ], "update_time": "2024-01-01T12:00:00Z" }code和message是固定的状态外层,data是仓库列表,update_time是缓存刷新时间。接口本身不做实时抓取,只从缓存里拿数据。这是整套设计里最重要的一条原则:API服务绝不直接触达目标站点,所有抓取动作只在后台刷新任务里发生。
这样设计还有一个附带好处:即使上游抓取失败或超时,接口依然能返回最近一次成功的数据。调用方看到update_time比较旧,就知道数据可能不是实时的,但服务本身没有中断。
3. 完整实现:一步一步把服务跑起来
3.1 项目目录与依赖
我建议把爬虫和接口拆开,不要全塞在一个文件里。项目结构如下:
. ├── app │ ├── __init__.py │ ├── main.py │ ├── crawler.py │ ├── cache.py │ └── models.py ├── requirements.txt └── README.md依赖文件放在requirements.txt里:
playwright==1.44.0 fastapi==0.111.0 uvicorn[standard]==0.30.0 pydantic==2.7.0安装后还需要执行一条Playwright自己的命令,把浏览器内核下载下来:
pip install -r requirements.txt playwright install chromium注意在Linux服务器上部署时,光下载Chromium还不行,系统自带的动态库往往缺失。这时执行playwright install-deps补全运行库,通常就能解决大部分启动报错。
3.2 爬虫核心:Crawler类的实现
先定义数据结构模型,用Pydantic约束字段:
# app/models.py from pydantic import BaseModel from typing import Optional class RepoItem(BaseModel): repository: str description: str language: str stars_total: int stars_today: int forks: int url: str since: str然后编写抓取类。核心方法里用async with管理上下文生命周期,确保每次抓取结束,Context被正确关闭。
# app/crawler.py from playwright.async_api import async_playwright from urllib.parse import urljoin import random import re from app.models import RepoItem class GithubTrendingCrawler: BASE_URL = "https://github.com/trending" def __init__(self): self._playwright = None self._browser = None async def start(self): self._playwright = await async_playwright().start() self._browser = await self._playwright.chromium.launch( headless=True, args=["--no-sandbox", "--disable-blink-features=AutomationControlled"] ) async def stop(self): if self._browser: await self._browser.close() if self._playwright: await self._playwright.stop() async def fetch_trending(self, since="daily", language=""): if self._browser is None: await self.start() context = await self._browser.new_context( viewport={"width": 1280, "height": 720}, user_agent="Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/120.0.0.0 Safari/537.36", locale="en-US", timezone_id="UTC", ) try: page = await context.new_page() url = self.BASE_URL params = [] if since: params.append(f"since={since}") if language: params.append(f"spoken_language_code={language}") if params: url += "?" + "&".join(params) await page.goto(url, wait_until="domcontentloaded", timeout=30000) await page.wait_for_selector("article.Box-row", timeout=30000) items = await page.eval_on_selector_all( "article.Box-row", """els => els.map(el => { const titleEl = el.querySelector('h2 a'); const descEl = el.querySelector('p'); const langEl = el.querySelector('[itemprop="programmingLanguage"]'); const metaEls = el.querySelectorAll('a.Link--muted'); const starsTotal = metaEls[0] ? metaEls[0].innerText.trim().replace(',', '') : '0'; const forks = metaEls[1] ? metaEls[1].innerText.trim().replace(',', '') : '0'; const starsToday = el.innerText.match(/(\\d+)\\s+stars?\\s+today/); return { title: titleEl ? titleEl.innerText.trim() : '', href: titleEl ? titleEl.getAttribute('href') : '', desc: descEl ? descEl.innerText.trim() : '', lang: langEl ? langEl.innerText.trim() : '', starsTotal: starsTotal, forks: forks, starsToday: starsToday ? starsToday[1] : '0' }; })""" ) result = [] for item in items: if not item["title"]: continue full_name = item["title"].replace("\\n", "").strip() result.append( RepoItem( repository=full_name, description=item["desc"], language=item["lang"], stars_total=int(item["starsTotal"]), stars_today=int(item["starsToday"]), forks=int(item["forks"]), url=urljoin(self.BASE_URL, item["href"]), since=since, ) ) return result finally: await context.close() async def close(self): await self.stop()这段代码有几个地方需要特别解释。
--disable-blink-features=AutomationControlled是启动参数,让Chrome对navigator.webdriver的可探测性有所降低。这不是为了对抗合法安全机制,而是为了避免目标站点对自动化浏览器产生不必要的误判,毕竟我们访问的是一个公开页面。
page.eval_on_selector_all把解析逻辑直接写到浏览器里执行,比在Python端写一长串循环更高效。注意转义处理,内层模板字符串用了双引号和转义,确保页面返回的是干净的数组。
starsToday用正则匹配,匹配的是页面元素里的“x stars today”文字。这个正则虽然简单,但精度够用。解析完以后,再用RepoItem做一次强校验,任何字段类型不对都会在这一层暴露,不会把脏数据带到缓存里。
3.3 缓存与后台定时刷新
缓存很简单,用字典加时间戳就能实现,不需要引入Redis。关键点是要有一个后台循环,定时把数据往缓存里塞。
# app/cache.py import time from threading import Lock class TTLCache: def __init__(self, ttl=300): self._store = {} self._lock = Lock() self.ttl = ttl def set(self, key, value): with self._lock: self._store[key] = { "data": value, "ts": time.time() } def get(self, key): with self._lock: item = self._store.get(key) if not item: return None if time.time() - item["ts"] > self.ttl: return None return item["data"] def get_update_time(self, key): with self._lock: item = self._store.get(key) if not item: return None return item["ts"]后台刷新用FastAPI的lifespan机制来管理。服务启动时,先把爬虫和缓存准备好,然后用asyncio.create_task启动一个无限循环,每隔几分钟抓一次数据。这个循环可以用一个事件来控制,服务关闭时取消任务。
# app/main.py from contextlib import asynccontextmanager from fastapi import FastAPI, Query import asyncio from app.crawler import GithubTrendingCrawler from app.cache import TTLCache cache = TTLCache(ttl=300) crawler = GithubTrendingCrawler() refresh_task = None SINCE_OPTIONS = ["daily", "weekly", "monthly"] LANGUAGES = ["", "python", "javascript", "go", "rust", "java", "c++", "typescript"] async def refresh_loop(): while True: try: for since in SINCE_OPTIONS: for language in LANGUAGES: key = f"{language or 'all'}-{since}" if cache.get(key) is None: data = await crawler.fetch_trending(since=since, language=language) cache.set(key, data) print(f"[refresh] {key} updated") await asyncio.sleep(cache.ttl) except asyncio.CancelledError: break except Exception: # 单独一次抓取失败不影响整体循环 await asyncio.sleep(30) @asynccontextmanager async def lifespan(app: FastAPI): await crawler.start() global refresh_task refresh_task = asyncio.create_task(refresh_loop()) yield if refresh_task: refresh_task.cancel() await crawler.close() app = FastAPI(title="GitHub Trending API", lifespan=lifespan) @app.get("/api/trending") async def get_trending( language: str = Query(default="", description="语言,为空表示全部"), since: str = Query(default="daily", description="daily/weekly/monthly"), limit: int = Query(default=30, ge=1, le=50), ): if since not in SINCE_OPTIONS: return {"code": 1, "message": "since参数不合法", "data": [], "update_time": None} key = f"{language or 'all'}-{since}" data = cache.get(key) if data is None: return {"code": 2, "message": "缓存未就绪,请稍后重试", "data": [], "update_time": None} return { "code": 0, "message": "success", "data": data[:limit], "update_time": cache.get_update_time(key), }这里的后台刷新循环我做了一个优化:只有当缓存里没有某个键值对时才去抓取。这样设计的好处是,如果某个时间段某个语言的热榜很少被调用,就不会频繁请求GitHub。而一旦有用户第一次查询该语言,触发抓取并缓存后,后续所有请求都走缓存。
为什么不一次性把所有语言都抓完?因为语言组合太多,全抓下来要小几十次请求,性价比太低。按需拉取是最经济的方式。
3.4 接口验证与文档
启动服务只需要一行命令:
uvicorn app.main:app --host 0.0.0.0 --port 8000启动后浏览器打开http://localhost:8000/docs就能看到FastAPI自动生成的接口文档。试着调用一次:
curl "http://localhost:8000/api/trending?language=python&since=daily&limit=5"第一次调用时缓存可能还没准备好,接口会返回缓存未就绪的状态码。等后台刷新任务跑一轮后,再次调用就能拿到数据。这个体验设计是刻意为之:宁可告知数据未就绪,也不让接口阻塞一段时间等爬虫完成。
4. 常见问题与排查技巧实录
4.1 抓取结果为空或超时
这是最常遇到的问题。页面还没加载完,脚本就开始解析DOM,自然抓不到任何内容。解决思路是检查wait_for_selector的超时时间是否够长,以及选择器是否还能精确定位到仓库卡片。
我在调试时发现,有时GitHub会弹出“验证浏览器”的中间页,导致article.Box-row根本不存在。这种情况如果遇到,第一反应不是加代理或者硬绕,而是要降低抓取频率,冷却一段时间再试。正常节奏下,GitHub对低频公开页面访问是相当宽松的。
网络不稳定也会导致超时。建议在服务器上抓取,并且设置合理的timeout值,不要无限等下去。我习惯把goto和wait_for_selector都设为30秒,超过就进入重试逻辑。
4.2 浏览器内存持续增长
服务跑几天后内存占用越来越高,通常有两个原因。第一是Context没有关闭,创建过的页面残留了大量JS堆内存。第二是并发抓取任务太多,同一时间开了好几个Context。
解决办法一是确保context.close()一定会在finally块里执行,二是用asyncio.Semaphore限制同时进行抓取的任务数。在后台刷新循环里设成每次只跑一个抓取任务就够了,没必要并发。
我在代码里用了一个小技巧:fetch_trending内部用async with browser.new_context()包裹,这样即使中途异常退出,Context也会被自动关闭。建议大家也这么写。
4.3 部署到Linux服务器后浏览器启动失败
在干净服务器上跑Playwright,最常见的坑就是缺系统依赖。光是playwright install chromium还不够,很多系统库没装上,浏览器进程起不来。执行一次:
playwright install-deps然后用普通用户运行服务,不要用root。如果遇到Sandbox相关的报错,就在启动参数里加--no-sandbox。在Docker部署时,官方Playwright镜像已经预装了大部分依赖,推荐直接用它作为基础镜像,省去自己折腾系统库的麻烦。
4.4 接口返回数据与实际页面不一致
出现这个问题的原因一般是页面结构微调了,某个字段的解析逻辑失效。定期观察解析结果,发现某个仓库的starsToday一直为0,就要去看看页面里对应元素的文本格式是否变了。
我自己的排查习惯是把原始抓取结果打印到日志里,保留最近一次解析失败的HTML快照。这样页面改版时,直接对比快照就能快速定位哪里出了问题。
4.5 常见问题速查表
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
| 抓取结果为空 | 页面未加载完成或选择器失效 | 加长等待超时,检查选择器 |
| 请求超时 | 网络不稳定 | 设置合理timeout,增加重试 |
| 内存持续增长 | Context未关闭或并发过多 | 确保Context自动关闭,限制并发数 |
| Linux启动失败 | 系统动态库缺失 | 执行playwright install-deps |
| 接口返回旧数据 | 缓存未刷新或TTL过长 | 降低TTL,或手动触发刷新 |
| 某些字段解析为0 | 页面结构调整 | 对比页面快照,更新解析逻辑 |
5. 从爬到练:这组设计还能怎么延伸
这里想多说一个很多教程不会提的体会:爬虫项目最值钱的部分往往是“工程化改造”,而不是抓取本身。
比如这套服务目前用的是内存缓存,重启就丢。如果想让数据跨重启保留,可以加一个SQLite落盘,每次抓完先写库再缓存。再比如想统计历史趋势,把每次快照按时间戳存下来,就能画出一个仓库的Star增长曲线。GitHub官方虽然也有API,但有其自身限制和速率配额;自己抓Trending做分析,反而灵活很多。
再往下走,还可以把这个服务包装成一个小型的“技术热榜聚合中心”,不只爬GitHub,再对接技术资讯平台的数据源,统一输出成一个标准格式,给内部其他系统调用。数据源多了之后,缓存设计、调度策略和接口抽象能力就真正派上用场了。
我在实际调这套服务时,最大的感受是“别贪快”。爬虫不是越快越好,稳定和可持续才是第一位的。定时慢慢抓、缓存兜底、接口解耦,这套三角结构无论放在什么爬虫项目上都成立。
如果你只是想抓一次数据,那确实不用搞这么复杂,脚本跑一遍就完事。但如果你和我一样,需要长期、稳定、自动地观察开源趋势,把爬虫当服务来搭,才是真正一劳永逸的方向。