基于Playwright与FastAPI构建高可用GitHub趋势爬虫API服务

1. 项目概述:为什么我们需要一个“高可用”的GitHub趋势爬虫?

每次想看看GitHub上最近有什么新玩意儿火起来了,你是不是也习惯性地打开GitHub Trending页面?手动翻看确实直观,但如果你想定时追踪、分析数据趋势,或者想把它集成到自己的仪表盘、日报系统里,手动操作就太原始了。市面上虽然有一些现成的API或爬虫脚本,但往往要么不稳定(网站结构一变就挂),要么功能单一(只能爬今天的数据),要么就是性能堪忧(一跑就把自己IP给封了)。

所以,这个项目的核心目标,就是构建一个稳定、灵活、易于集成的GitHub趋势项目信息获取服务。我们不满足于写一个“一次性”的脚本,而是要打造一个生产级的解决方案。它需要具备几个关键特性:高可靠性,能应对GitHub页面的细微改动和反爬机制;服务化,通过标准的API接口提供数据,方便其他应用调用;可维护性,代码结构清晰,易于扩展和部署。

为了实现这个目标,技术选型上我们放弃了传统的requests+BeautifulSoup组合,也绕过了对动态页面处理略显笨重的Selenium,而是选择了更现代的Playwright。它是一个由微软开源的浏览器自动化测试框架,但用来做爬虫简直是“降维打击”——它支持无头浏览器,能完美执行JavaScript,模拟真人操作,并且速度飞快。后端API服务则选用FastAPI,这个Python异步Web框架以高性能和直观的API文档自动生成而闻名,非常适合快速构建数据接口。最终,我们将得到一个爬虫核心模块和一个包裹它的API服务,你可以轻松地通过HTTP请求获取格式化好的GitHub趋势数据,甚至可以部署到服务器上,供团队内部使用。

2. 核心工具链深度解析:Playwright与FastAPI为何是绝配?

2.1 Playwright:不只是自动化测试工具

很多人第一次听说Playwright,都是在测试领域。但如果你只把它当成Selenium的替代品,那就太小看它了。在爬虫场景下,Playwright有几个碾压级的优势。

首先,它原生支持多浏览器引擎(Chromium, Firefox, WebKit)。这意味着你可以选择最合适的浏览器来执行任务,甚至可以在同一脚本中切换。对于GitHub这种主流网站,使用Chromium通常就能获得最好的兼容性和性能。其次,它的自动等待机制是爬虫开发者的福音。传统爬虫需要自己写time.sleep或者显式等待元素出现,既不稳定又低效。Playwright的page.wait_for_selectorpage.wait_for_load_state等方法,能智能地等待页面元素或状态,大大减少了因网络波动或页面加载慢导致的爬取失败。

最关键的是,Playwright处理动态内容的能力。GitHub Trending页面虽然看似静态,但它的项目语言颜色标签、部分交互元素都是动态生成的。用requests直接抓取HTML,你会丢失这些信息。Playwright启动一个真实的浏览器环境,JavaScript执行完毕后,你拿到的是完整的、渲染好的DOM树,抓取数据准确无误。

此外,Playwright的网络拦截(Route)和请求模拟功能,允许我们优化爬取过程。比如,我们可以拦截并阻止页面加载图片、CSS等非必要资源,显著提升爬取速度。也可以轻松地修改请求头,模拟更真实的浏览器指纹。

2.2 FastAPI:为数据接口而生的现代框架

爬虫爬到了数据,怎么提供出去?写个简单的Flask应用?可以,但FastAPI能做得更优雅、更高效。FastAPI基于Python的异步asyncio库和类型提示构建,这正好与Playwright的异步API珠联璧合。

性能优势:FastAPI的异步特性意味着它能够高效地处理大量并发请求。当我们的爬虫API被频繁调用时,异步处理可以避免线程阻塞,用更少的资源服务更多的请求。这与Playwright的异步操作模式(async/await)完美契合,整个数据流从爬取到响应都可以在一个高效的异步管道中完成。

开发体验:FastAPI利用Python类型提示,提供了无与伦比的开发体验和代码可靠性。你定义好请求和响应的数据模型(使用Pydantic),FastAPI会自动进行数据验证、序列化,并生成交互式的API文档(Swagger UI和ReDoc)。这意味着,你写完爬虫和数据模型,一个功能完整、文档齐全的API就基本完成了,前后端协作非常顺畅。

依赖注入系统:FastAPI强大的依赖注入系统,让我们能轻松管理爬虫的核心实例。比如,我们可以创建一个全局的Playwright浏览器实例,通过依赖注入的方式提供给每个API端点使用,避免重复启动浏览器的开销,实现连接池的效果。

2.3 技术栈协同工作流

整个系统的工作流非常清晰:

  1. 用户向FastAPI服务发起一个HTTP请求(例如GET /trending?lang=python&since=daily)。
  2. FastAPI接收请求,解析参数,并调用注入的“爬虫服务”依赖项。
  3. 爬虫服务内部使用Playwright,启动或复用浏览器,导航到对应的GitHub Trending URL(如https://github.com/trending/python?since=daily)。
  4. Playwright模拟浏览器行为,加载并渲染完整页面,然后通过选择器定位到仓库名、星数、fork数、描述等元素。
  5. 爬虫服务将从页面提取的原始数据,清洗、转换成结构化的Python对象(如Pydantic模型)。
  6. FastAPI将这个对象序列化为JSON,并返回给用户。

这个流程中,Playwright负责“攻”(获取数据),FastAPI负责“守”(提供数据),两者通过异步编程模型紧密结合,构建出一个响应迅速、稳定可靠的服务。

3. 项目实战:从零搭建爬虫核心模块

3.1 环境准备与依赖安装

首先,确保你的Python版本在3.8以上。然后,我们使用uvpip来管理依赖。uv是一个用Rust写的极速Python包安装器和解析器,速度远超pip,强烈推荐。

# 使用uv初始化项目并安装核心依赖 uv init github-trending-api cd github-trending-api uv add playwright fastapi uvicorn httpx pydantic # 安装Playwright所需的浏览器(这里安装Chromium) uv run playwright install chromium

这里解释一下依赖:

  • playwright: 主库,用于浏览器自动化。
  • fastapi: Web框架。
  • uvicorn: ASGI服务器,用于运行FastAPI应用。
  • httpx: 可选的异步HTTP客户端,可用于健康检查或调用其他API。
  • pydantic: 数据验证和设置管理,FastAPI的核心依赖之一。

注意playwright install chromium这一步可能会下载几百MB的浏览器二进制文件,请确保网络通畅。你也可以通过环境变量PLAYWRIGHT_DOWNLOAD_HOST配置镜像源来加速下载。

3.2 设计数据模型(Pydantic)

在写爬虫逻辑之前,我们先定义好数据的“形状”。这能让我们的代码更清晰,并且FastAPI能自动利用这些模型生成API文档。

# schemas.py from typing import Optional, List from pydantic import BaseModel, HttpUrl class Repository(BaseModel): """单个GitHub仓库的模型""" rank: int # 排名 name: str # 仓库全名,如 “owner/repo” url: HttpUrl # 仓库主页URL description: Optional[str] = None # 描述,可能为空 language: Optional[str] = None # 主要编程语言 language_color: Optional[str] = None # GitHub上该语言对应的颜色代码 stars: int # 星标总数 forks: int # Fork总数 stars_today: int # 今日新增星标数 built_by: List[str] = [] # 主要贡献者头像列表(URL) class TrendingResponse(BaseModel): """API响应模型""" since: str # 时间范围:daily, weekly, monthly language: Optional[str] = None # 编程语言筛选 repos: List[Repository] # 仓库列表

使用HttpUrl类型,Pydantic会自动验证字符串是否为有效的URL。Optional字段表示该字段可能为None。定义好模型后,后续的爬虫代码目标就是填充这个Repository对象。

3.3 编写Playwright爬虫核心类

接下来是重头戏:爬虫类。我们将采用面向对象的设计,封装所有与Playwright交互的细节。

# crawler.py import asyncio from typing import Optional, List from playwright.async_api import async_playwright, Browser, Page from schemas import Repository, TrendingResponse import logging logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) class GitHubTrendingCrawler: def __init__(self, headless: bool = True): """ 初始化爬虫 :param headless: 是否使用无头模式。生产环境建议为True。 """ self.headless = headless self.browser: Optional[Browser] = None self.playwright_instance = None async def start(self): """启动Playwright和浏览器实例。建议在应用启动时调用一次。""" self.playwright_instance = await async_playwright().start() # 使用Chromium,可配置代理或其他启动参数 self.browser = await self.playwright_instance.chromium.launch( headless=self.headless, args=['--disable-blink-features=AutomationControlled'] # 隐藏自动化特征 ) logger.info("Playwright浏览器实例已启动") async def stop(self): """关闭浏览器和Playwright。在应用关闭时调用。""" if self.browser: await self.browser.close() if self.playwright_instance: await self.playwright_instance.stop() logger.info("Playwright资源已释放") async def fetch_trending(self, language: str = "", since: str = "daily") -> TrendingResponse: """ 获取GitHub趋势数据 :param language: 编程语言,如'python', 'javascript'。空字符串表示所有语言。 :param since: 时间范围,'daily', 'weekly', 'monthly'。 :return: TrendingResponse对象 """ if not self.browser: raise RuntimeError("爬虫未启动,请先调用start()方法") # 构造URL base_url = "https://github.com/trending" url = f"{base_url}/{language}?since={since}" if language else f"{base_url}?since={since}" page: Page = await self.browser.new_page() # 关键步骤1:设置合理的请求头和视口,模拟普通浏览器 await page.set_viewport_size({"width": 1920, "height": 1080}) await page.set_extra_http_headers({ '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', 'Accept-Language': 'en-US,en;q=0.9', }) # 关键步骤2:拦截并阻止不必要的资源加载,大幅提升速度 await page.route("**/*.{png,jpg,jpeg,gif,css,woff,woff2}", lambda route: route.abort()) logger.info(f"正在爬取: {url}") try: # 导航到页面,等待主要内容加载完成 await page.goto(url, wait_until="networkidle") # 等待网络基本空闲 # 显式等待趋势列表容器出现,这是更稳健的做法 await page.wait_for_selector('article.Box-row', timeout=10000) # 关键步骤3:执行页面内JavaScript,获取语言颜色(如果需要) # GitHub的语言颜色是通过CSS变量定义的,我们可以用JS提取 language_color_map = await page.evaluate("""() => { const map = {}; const styleSheets = document.styleSheets; for (let sheet of styleSheets) { try { const rules = sheet.cssRules || sheet.rules; for (let rule of rules) { if (rule.selectorText && rule.selectorText.startsWith('.language-color-')) { const lang = rule.selectorText.split('-').pop(); map[lang] = rule.style.backgroundColor; } } } catch(e) {} } return map; }""") # 提取仓库列表 repo_elements = await page.query_selector_all('article.Box-row') repos = [] for index, repo_element in enumerate(repo_elements): try: repo = await self._parse_repo_element(repo_element, index + 1, language_color_map) repos.append(repo) except Exception as e: logger.warning(f"解析第{index+1}个仓库时出错: {e}") continue # 跳过解析失败的单个仓库,不影响整体 logger.info(f"成功爬取到 {len(repos)} 个仓库") return TrendingResponse(since=since, language=language if language else None, repos=repos) except Exception as e: logger.error(f"爬取过程发生错误: {e}") raise # 将异常向上抛,由API层处理 finally: await page.close() # 确保页面被关闭,释放资源 async def _parse_repo_element(self, element, rank: int, color_map: dict) -> Repository: """解析单个仓库元素,内部方法""" # 使用Playwright的ElementHandle方法提取数据,比纯文本解析更可靠 name_elem = await element.query_selector('h2 a') # 获取href属性并补全为完整URL relative_url = await name_elem.get_attribute('href') repo_url = f"https://github.com{relative_url}" repo_name = (await name_elem.text_content()).strip().replace('\n', '').replace(' ', '') # 描述可能不存在 desc_elem = await element.query_selector('p') description = (await desc_elem.text_content()).strip() if desc_elem else None # 提取编程语言和颜色 lang_elem = await element.query_selector('[itemprop="programmingLanguage"]') language = (await lang_elem.text_content()).strip() if lang_elem else None language_color = color_map.get(language.lower()) if language else None # 提取星标、Fork、今日新增星标数 - 这里需要处理复杂的文本 star_link = await element.query_selector('a[href$="/stargazers"]') fork_link = await element.query_selector('a[href$="/forks"]') stars_today_span = await element.query_selector('span.d-inline-block.float-sm-right') # 辅助函数:从文本中提取数字 def extract_number(text): if not text: return 0 import re # 处理“1.2k”这样的格式 num_text = text.strip().replace(',', '') if 'k' in num_text.lower(): return int(float(num_text.lower().replace('k', '')) * 1000) match = re.search(r'(\d+)', num_text) return int(match.group(1)) if match else 0 stars_text = await star_link.text_content() if star_link else "0" forks_text = await fork_link.text_content() if fork_link else "0" stars_today_text = await stars_today_span.text_content() if stars_today_span else "0" stars = extract_number(stars_text) forks = extract_number(forks_text) stars_today = extract_number(stars_today_text) # 提取贡献者头像(可选) avatar_links = await element.query_selector_all('a[data-hovercard-type="user"] img.avatar') built_by = [await avatar.get_attribute('src') for avatar in avatar_links] return Repository( rank=rank, name=repo_name, url=repo_url, description=description, language=language, language_color=language_color, stars=stars, forks=forks, stars_today=stars_today, built_by=built_by )

这个GitHubTrendingCrawler类封装了完整的生命周期和爬取逻辑。start()stop()用于管理浏览器实例,避免为每个请求都启动/关闭浏览器,这是实现高性能的关键。fetch_trending是核心方法,它处理URL构建、页面导航、资源拦截、数据提取和解析的全过程。

实操心得:在page.goto中使用wait_until="networkidle"是个不错的默认选择,但它有时会等待过久。对于GitHub Trending这种页面,可以尝试wait_until="domcontentloaded"然后结合page.wait_for_selector等待特定元素,通常能更快地开始解析。另外,通过page.route拦截图片、字体等资源,在我的测试中能将页面加载时间减少60%以上,对提升爬虫效率至关重要。

4. 构建FastAPI API服务与异步集成

有了爬虫核心,现在我们需要用FastAPI给它包上一层HTTP外衣。

4.1 创建FastAPI应用与依赖注入

# main.py from contextlib import asynccontextmanager from fastapi import FastAPI, Depends, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from typing import Optional import asyncio from crawler import GitHubTrendingCrawler from schemas import TrendingResponse # 全局爬虫实例 _crawler: Optional[GitHubTrendingCrawler] = None @asynccontextmanager async def lifespan(app: FastAPI): """管理应用生命周期:启动时初始化爬虫,关闭时清理资源""" global _crawler # 启动 _crawler = GitHubTrendingCrawler(headless=True) # 生产环境用无头模式 await _crawler.start() logger.info("GitHub趋势爬虫服务已启动") yield # 关闭 if _crawler: await _crawler.stop() logger.info("GitHub趋势爬虫服务已关闭") app = FastAPI( title="GitHub Trending API Service", description="一个高可用的GitHub趋势项目爬虫与API服务,基于Playwright和FastAPI构建。", version="1.0.0", lifespan=lifespan # 使用 lifespan 上下文管理器 ) # 添加CORS中间件,方便前端调用 app.add_middleware( CORSMiddleware, allow_origins=["*"], # 生产环境应指定具体域名 allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) def get_crawler() -> GitHubTrendingCrawler: """依赖注入函数,提供爬虫实例""" if _crawler is None: raise HTTPException(status_code=503, detail="爬虫服务未就绪") return _crawler @app.get("/", tags=["Root"]) async def root(): """服务根路径,返回基础信息""" return { "service": "GitHub Trending API", "status": "running", "docs": "/docs", "endpoints": { "trending": "/trending", "trending_with_lang": "/trending/{language}" } } @app.get("/trending", response_model=TrendingResponse, tags=["Trending"]) async def get_trending( language: Optional[str] = Query(None, description="筛选编程语言,例如:python, javascript, go"), since: str = Query("daily", description="时间范围:daily(每日), weekly(每周), monthly(每月)"), crawler: GitHubTrendingCrawler = Depends(get_crawler) ): """ 获取GitHub趋势仓库列表。 - **language**: 按编程语言筛选。留空或省略则返回所有语言。 - **since**: 趋势的时间范围。 """ # 参数验证 if since not in ["daily", "weekly", "monthly"]: raise HTTPException(status_code=400, detail="参数'since'必须是 daily, weekly 或 monthly") # 处理语言参数:API中None表示不筛选,但爬虫需要空字符串 lang_for_crawl = language if language else "" try: # 调用爬虫核心功能 result = await crawler.fetch_trending(language=lang_for_crawl, since=since) return result except Exception as e: # 记录详细错误日志,但返回给用户的信息要友好 logger.error(f"API调用爬虫失败: {e}", exc_info=True) raise HTTPException(status_code=500, detail="获取趋势数据失败,请稍后重试或检查服务状态") @app.get("/health", tags=["Health"]) async def health_check(crawler: GitHubTrendingCrawler = Depends(get_crawler)): """健康检查端点,用于监控服务状态""" try: # 尝试快速访问GitHub首页,检查网络和浏览器状态 page = await crawler.browser.new_page() await page.goto("https://github.com", wait_until="domcontentloaded", timeout=10000) title = await page.title() await page.close() return {"status": "healthy", "github_accessible": True, "page_title": title} except Exception as e: logger.error(f"健康检查失败: {e}") return {"status": "unhealthy", "error": str(e)}, 503

这个main.py文件构建了完整的API服务。关键点在于lifespan上下文管理器,它确保了爬虫浏览器实例在FastAPI应用启动时被创建,并在应用关闭时被正确清理,这是一种资源管理的推荐模式。get_crawler依赖函数使得我们可以在路由函数中方便地获取到爬虫实例。

API设计上,我们提供了两个主要端点:根路径/用于服务发现,/trending是核心数据获取接口,/health用于健康检查(这在部署后非常重要)。所有响应都遵循我们之前定义的TrendingResponse模型,FastAPI会自动将其转换为JSON并验证数据。

4.2 运行与测试API服务

使用Uvicorn运行这个应用:

uvicorn main:app --host 0.0.0.0 --port 8000 --reload

打开浏览器,访问http://localhost:8000/docs,你会看到FastAPI自动生成的交互式Swagger UI文档。你可以直接在页面上尝试调用/trending接口,选择参数,点击“Execute”,就能看到实时的API响应和格式化后的数据。

测试示例

  • GET /trending?since=weekly:获取本周所有语言的热门仓库。
  • GET /trending?language=python&since=daily:获取今日Python语言的热门仓库。

返回的数据会是结构清晰的JSON,包含了排名、仓库名、URL、描述、语言、星标数等所有信息,完全可以直接被前端或其他服务消费。

5. 部署与高可用性增强策略

一个能在本地跑的服务还不够,我们需要考虑如何将它部署到服务器,并确保其稳定、可靠地运行。

5.1 使用Docker容器化部署

Docker能解决环境一致性问题,是部署的首选。我们需要编写Dockerfiledocker-compose.yml

# Dockerfile FROM python:3.11-slim WORKDIR /app # 安装系统依赖,包括Playwright所需的库 RUN apt-get update && apt-get install -y \ wget \ gnupg \ libnss3 \ libatk-bridge2.0-0 \ libdrm2 \ libxkbcommon0 \ libgbm1 \ libasound2 \ libpangocairo-1.0-0 \ libx11-xcb1 \ libxcomposite1 \ libxcursor1 \ libxdamage1 \ libxi6 \ libxtst6 \ && rm -rf /var/lib/apt/lists/* # 使用uv安装Python依赖(更快更高效) COPY pyproject.toml uv.lock ./ RUN pip install uv && uv pip install --system -r pyproject.toml # 安装Playwright的Chromium浏览器 RUN playwright install chromium --with-deps # 复制应用代码 COPY . . # 暴露端口 EXPOSE 8000 # 启动命令 CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

对应的docker-compose.yml可以方便地定义服务:

# docker-compose.yml version: '3.8' services: github-trending-api: build: . container_name: github-trending-api ports: - "8000:8000" restart: unless-stopped # 容器意外退出时自动重启 # 可以在这里添加环境变量,如设置代理等 # environment: # - HTTP_PROXY=http://your-proxy:port # - HTTPS_PROXY=http://your-proxy:port # 挂载卷,如果需要持久化日志或缓存 # volumes: # - ./logs:/app/logs

使用docker-compose up -d即可在后台启动服务。restart: unless-stopped策略提供了基础的高可用性,当容器因未知原因崩溃时会自动重启。

5.2 使用Nginx作为反向代理

在生产环境,我们通常不会让Uvicorn直接对外服务。使用Nginx作为反向代理,可以提供负载均衡、SSL/TLS终止、静态文件服务、缓存和更好的安全性。

# nginx.conf 部分配置 server { listen 80; server_name your-domain.com; # 你的域名 location / { proxy_pass http://localhost:8000; # 转发到FastAPI服务 proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # 以下两行对WebSocket或长时间连接可能有帮助 proxy_read_timeout 300s; proxy_connect_timeout 75s; } # 如果你申请了SSL证书,可以添加下面这个server块,并重定向HTTP到HTTPS # listen 443 ssl http2; # ssl_certificate /path/to/your/cert.pem; # ssl_certificate_key /path/to/your/key.pem; # ... 其他SSL配置 } # 可选的:添加一个上游块,如果你部署了多个实例做负载均衡 # upstream fastapi_backend { # server 127.0.0.1:8000; # server 127.0.0.1:8001; # }

5.3 实现缓存与限流机制

直接每次请求都去爬GitHub,不仅慢,而且对GitHub服务器不友好,容易触发反爬。我们必须实现缓存。

内存缓存(简单方案):对于小型或个人服务,可以使用lru_cachecachetools库在内存中缓存结果。

# 在main.py中增加缓存 from functools import lru_cache from datetime import datetime, timedelta class CacheItem: def __init__(self, data, expiry): self.data = data self.expiry = expiry class SimpleCache: def __init__(self): self._cache = {} def get(self, key): item = self._cache.get(key) if item and datetime.now() < item.expiry: return item.data else: self._cache.pop(key, None) # 过期删除 return None def set(self, key, data, ttl_seconds=300): # 默认缓存5分钟 expiry = datetime.now() + timedelta(seconds=ttl_seconds) self._cache[key] = CacheItem(data, expiry) # 在FastAPI应用中初始化缓存 cache = SimpleCache() # 修改 /trending 端点 @app.get("/trending", response_model=TrendingResponse) async def get_trending( language: Optional[str] = Query(None), since: str = Query("daily"), crawler: GitHubTrendingCrawler = Depends(get_crawler) ): cache_key = f"trending:{language or 'all'}:{since}" cached_data = cache.get(cache_key) if cached_data: logger.info(f"缓存命中: {cache_key}") return cached_data # ... 原有的参数验证和爬取逻辑 ... result = await crawler.fetch_trending(language=lang_for_crawl, since=since) # 存入缓存,根据时间范围设置不同的TTL ttl = 300 if since == "daily" else 1800 # daily缓存5分钟,weekly/monthly缓存30分钟 cache.set(cache_key, result, ttl_seconds=ttl) return result

更高级的缓存:对于生产环境,建议使用RedisMemcached这样的外部缓存服务,它们支持分布式、数据持久化和更复杂的过期策略。

限流:为了防止API被滥用,可以使用slowapifastapi-limiter等中间件为API添加速率限制。

# 使用 slowapi 示例 from slowapi import Limiter, _rate_limit_exceeded_handler from slowapi.util import get_remote_address from slowapi.errors import RateLimitExceeded limiter = Limiter(key_func=get_remote_address) app.state.limiter = limiter app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) @app.get("/trending") @limiter.limit("10/minute") # 每个IP每分钟10次 async def get_trending(...): # ...

5.4 监控与日志

一个健壮的服务离不开监控和日志。我们可以将Python的日志输出配置为JSON格式,并集成像Sentry这样的错误监控平台。

# logging_config.py import json import logging from pythonjsonlogger import jsonlogger logger = logging.getLogger() logHandler = logging.StreamHandler() formatter = jsonlogger.JsonFormatter('%(asctime)s %(name)s %(levelname)s %(message)s') logHandler.setFormatter(formatter) logger.addHandler(logHandler) logger.setLevel(logging.INFO) # 在main.py中导入此配置

对于部署在云上的服务,可以利用云平台提供的监控(如AWS CloudWatch, Google Cloud Logging)或自建ELK(Elasticsearch, Logstash, Kibana)栈来收集和分析日志。

6. 常见问题排查与优化技巧实录

在实际开发和运行中,你肯定会遇到各种问题。这里记录了一些典型问题的排查思路和解决技巧。

6.1 Playwright爬取失败或超时

问题现象page.goto超时,或者页面元素无法找到。

  • 可能原因1:网络问题或GitHub访问慢。解决方案:增加超时时间,或在page.goto中使用timeout参数(例如timeout=60000)。考虑为Docker容器配置网络代理。
  • 可能原因2:GitHub页面结构发生变化。这是爬虫最常遇到的问题。解决方案:定期检查并更新CSS选择器。我们的选择器article.Box-rowh2 a是相对稳定的,但GitHub也可能改版。建议将选择器字符串定义为配置常量,便于统一修改。可以编写一个简单的测试脚本,定期运行,验证选择器是否有效。
  • 可能原因3:被检测为自动化脚本。虽然Playwright已经尽力隐藏,但高级反爬系统仍可能识别。解决方案:尝试使用playwright.chromium.launch时添加更多启动参数来模拟真人浏览器,例如args=['--disable-blink-features=AutomationControlled', '--start-maximized']。也可以随机化User-Agent和视口大小。

6.2 数据解析不准确或为空

问题现象:能打开页面,但repo_elements列表为空,或某些字段提取不到。

  • 排查步骤
    1. 手动检查页面:用浏览器打开相同的GitHub Trending URL,检查元素是否存在。按F12打开开发者工具,尝试用document.querySelectorAll('article.Box-row')验证选择器。
    2. 启用Playwright调试:在爬取时截屏或保存HTML,这能帮你看到Playwright实际获取到的页面内容。
    await page.screenshot(path='debug.png', full_page=True) html = await page.content() with open('debug.html', 'w', encoding='utf-8') as f: f.write(html)
    1. 检查等待逻辑page.wait_for_selector可能在你指定的元素出现之前就返回了(如果元素是动态插入的)。可以尝试更保守的等待,比如await page.wait_for_timeout(2000)后再抓取,或者使用page.wait_for_function等待特定条件。
    2. 处理动态内容:如果语言颜色等信息是通过JS动态加载的,确保在页面完全渲染后再执行提取逻辑。page.evaluate是在页面上下文中执行的,确保你访问的DOM元素已经存在。

6.3 API服务性能瓶颈

问题现象:并发请求时响应变慢,或者服务器负载过高。

  • 瓶颈分析
    • 爬虫本身是瓶颈:每次API调用都可能触发一次完整的浏览器页面加载和渲染,即使有缓存,缓存失效后的第一次请求也很慢。优化方案:实现一个后台定时任务(例如使用APSchedulerCelery),定期(如每5分钟)爬取热门数据并更新缓存。这样API请求几乎总是命中缓存,响应速度极快。
    • 浏览器实例单点:我们的设计是单浏览器实例。虽然Playwright支持多上下文(Context),但单个浏览器实例处理大量并发页面也可能有压力。优化方案:可以创建多个浏览器上下文(browser.new_context()),甚至启动多个浏览器进程,用连接池的方式管理。但这会显著增加内存消耗,需要权衡。
    • 缓存未命中风暴:如果缓存同时过期,大量请求会穿透到爬虫,导致瞬间高负载。优化方案:使用“缓存预热”或“缓存续期”策略。在缓存过期前,后台任务就提前更新数据。或者使用互斥锁(如asyncio.Lock),确保对于同一个缓存键,只有一个请求能执行爬取,其他请求等待该结果。

6.4 部署相关问题

Docker构建失败:最常见的是playwright install下载浏览器超时或失败。

  • 解决:使用国内镜像加速。可以在Dockerfile中设置环境变量:
    ENV PLAYWRIGHT_DOWNLOAD_HOST=https://npmmirror.com/mirrors/playwright RUN playwright install chromium --with-deps
    或者,在构建前将浏览器二进制包预先下载好,通过COPY指令放入镜像,避免在线安装。

服务无故重启或停止:可能是内存不足。Playwright的Chromium实例会消耗一定内存。

  • 监控:使用docker statshtop查看容器内存使用情况。
  • 调整:在docker-compose.yml中为服务设置内存限制和保留值,并确保宿主机有足够资源。
    services: github-trending-api: # ... deploy: resources: limits: memory: 1G # 内存限制 reservations: memory: 512M # 内存保留

6.5 伦理与合规性提醒

虽然我们构建了一个功能强大的爬虫,但必须负责任地使用它。

  • 尊重robots.txt:检查https://github.com/robots.txt。GitHub通常对爬虫比较友好,但明确禁止了对某些路径(如搜索API的滥用式访问)的爬取。我们的爬虫只访问公开的Trending页面,且频率很低(通过缓存控制),这通常是可接受的。
  • 设置合理的请求间隔:即使在缓存失效后重新爬取,也应避免在短时间内高频访问同一页面。我们的缓存机制(5-30分钟)已经起到了很好的间隔作用。
  • 标识你的爬虫:在User-Agent中可以考虑加入一个标识,例如MyGitHubTrendingBot/1.0 (+https://my-api.com),以示友好。虽然我们的示例中使用了普通浏览器的UA,但对于公开API服务,使用一个独特的、非误导性的UA是更好的实践。
  • 关注服务条款:定期查看GitHub的服务条款,确保你的使用方式没有违反规定。

构建这个项目的过程,远不止是学会用Playwright和FastAPI写代码。它涉及到了从需求分析、工具选型、核心开发、服务封装,到部署运维、性能优化和伦理考量的一整套工程化思维。当你把这个服务跑起来,并通过一个简单的GET请求就能拿到结构化的GitHub趋势数据时,你会感受到这种自动化、服务化思维带来的巨大效率提升。这个项目骨架具有很强的扩展性,你可以很容易地修改爬虫部分,去适配其他动态网站,或者为FastAPI服务添加更复杂的业务逻辑、用户认证和数据分析功能。