LeetCode题库爬虫:爬取所有题目与题解,搭建离线刷题系统

简介: 本文详解如何绕过LeetCode反爬机制(Cloudflare、GraphQL限流等),通过API直连+隧道代理方案,高效爬取题目与题解,构建本地离线刷题系统,支持JSON/SQLite存储、Web服务及多语言代码运行,兼顾效率、稳定与合规。

引言

对于每一位备战技术面试的程序员来说,LeetCode几乎是绕不开的“磨刀石”。然而,在线刷题存在诸多痛点:网络不稳定时无法访问、网页编辑器功能有限无法断点调试、反复复制粘贴测试用例效率低下。于是,一个自然而然的念头浮现了:能不能把LeetCode的所有题目和题解都爬下来,搭建一套属于自己的离线刷题系统?

这个想法虽然美好,但实现起来却面临着不小的挑战。LeetCode主站添加了Cloudflare反爬虫机制,传统的静态爬虫脚本已无法正常获取题目数据。与此同时,平台频繁更新其内部路由、选择器和页面结构,静态解析方案极易失效。

本文将从零开始,系统介绍如何爬取LeetCode的题目与题解数据,并搭建一套完整的离线刷题系统。

一、理解LeetCode的数据架构与反爬体系

1.1 数据的“藏身之处”:GraphQL API

现代Web应用普遍采用前后端分离架构,LeetCode也不例外。页面上的题目列表、题目详情、题解等内容并非直接写在HTML中,而是通过JavaScript异步请求后端API获取后动态渲染。

LeetCode的核心数据接口是GraphQL API,地址为 https://leetcode.com/graphql/。通过这个接口,可以获取题目列表、题目详情、用户信息、提交记录等各类数据。

与传统的RESTful API不同,GraphQL允许客户端精确指定需要哪些字段,一次请求即可获取完整的数据结构,避免了多次往返请求的低效。这也是LeetCode选择GraphQL作为数据接口的重要原因。

1.2 公开API与受限接口

LeetCode提供了一些无需认证即可访问的公开接口。例如,https://leetcode.com/api/problems/all/ 可以获取所有题目的元数据列表。但这类公开接口通常只返回题目的基本信息——ID、标题、难度、付费标识等——不包含完整的题目描述、示例、题解等详细内容。

要获取题目的完整信息,需要通过GraphQL接口发起更复杂的查询。部分接口(如获取用户提交记录、个人解题进度等)则需要携带登录态Cookie才能访问。

1.3 反爬防线:从Cloudflare到行为分析

LeetCode构建了多层反爬防线:

第一层:Cloudflare防护。LeetCode主站部署了Cloudflare反爬虫机制。初次访问时会经历浏览器挑战验证,自动化工具如果无法通过这一关,连页面都加载不了。

第二层:API请求拦截。LeetCode有严格的anti-bot机制,会主动拦截自动化的API请求。传统的基于requests库的直接调用方式,很容易被识别并返回403错误。

第三层:动态前端架构。LeetCode的前端基于动态JavaScript框架构建,平台频繁更新其内部路由、选择器和页面结构。昨天还能用的CSS选择器,今天可能就失效了。

第四层:频率限制。LeetCode的搜索和API接口有速率限制。短时间内发出大量请求,会触发限流机制,导致IP被临时封禁。

面对如此严密的防护体系,简单粗暴的爬虫方案显然行不通。

二、可行技术路线分析

基于对LeetCode反爬体系的理解,目前主流的采集方案有以下几条路线。

2.1 路线一:浏览器自动化方案

核心思路:使用Playwright或Selenium驱动真实浏览器,模拟真人操作——打开页面、等待渲染、提取数据。浏览器自动化工具能够完整执行JavaScript,绕过Cloudflare的挑战验证,获取到完整的渲染后页面内容。

代表项目有使用Playwright和AgentQL从LeetCode的学习计划页面提取题目数据,以及使用Selenium爬取所有题目并生成HTML/EPUB文件。

优点

  • 无需逆向API,浏览器自动处理JS执行和Cloudflare挑战
  • 行为接近真实用户,反爬检测难度高
  • 可处理登录、翻页等复杂交互

缺点

  • 效率较低,每个页面需要加载完整资源
  • 资源消耗大(内存、CPU)
  • 页面结构变化时需要更新选择器

2.2 路线二:GraphQL API直接调用

核心思路:通过分析LeetCode前端发起的GraphQL请求,逆向出查询语句和参数格式,然后直接用代码调用API获取数据。

这种方式不需要加载页面,直接与后端通信,效率和资源消耗都远优于浏览器自动化。社区已有多个相关项目,如leetcode-query提供了高度可定制的GraphQL API,以及基于TypeScript的@leetnotion/leetcode-api

优点

  • 效率极高,直接获取结构化JSON数据
  • 资源消耗低
  • 适合大规模批量采集

缺点

  • 需要一定的逆向分析能力
  • API可能会更新
  • 需要处理认证和频率限制

2.3 路线三:混合方案

核心思路:先用浏览器自动化获取必要的认证信息(如Cookie、CSRF Token),然后切换到API直连方式进行批量采集。

LeetCode的csrftoken需要通过浏览器获取,拿到之后可以配合Cookie进行后续的API调用。这种方式结合了两种路线的优势——认证环节用浏览器保证成功率,采集环节用API保证效率。

2.4 选型建议

对于搭建离线刷题系统的需求,推荐采用“API直连为主、浏览器辅助认证”的混合方案。具体来说:

  • 首次运行时,使用Playwright打开浏览器完成登录(如需),获取Cookie和CSRF Token
  • 后续批量采集使用requests直接调用GraphQL API
  • 配合合理的请求间隔和代理IP,避免触发频率限制

三、爬虫实战:从API到结构化数据

3.1 环境准备

pip install requests beautifulsoup4 pandas playwright
playwright install chromium

3.2 获取题目列表

LeetCode提供了一个公开的API端点,无需认证即可获取所有题目的元数据:

import requests
import json

def fetch_problem_list():
   """获取所有题目的元数据列表"""
   url = "https://leetcode.com/api/problems/all/"
   headers = {
       "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0"
   }
   response = requests.get(url, headers=headers)
   if response.status_code == 200:
       data = response.json()
       return data.get("stat_status_pairs", [])
   return []

# 使用示例
problems = fetch_problem_list()
print(f"共获取 {len(problems)} 道题目")

返回的数据包含每个题目的ID、标题、难度、付费标识、通过率等信息。但请注意,这个接口只返回元数据,不包含题目的详细描述和示例。

3.3 通过GraphQL获取题目详情

要获取完整的题目描述、示例、约束条件等详细信息,需要通过GraphQL接口发起查询。

def fetch_problem_detail(title_slug):
   """通过GraphQL获取单道题目的详细信息"""
   url = "https://leetcode.com/graphql"
   
   # GraphQL查询语句
   query = """
   query getQuestionDetail($titleSlug: String!) {
       question(titleSlug: $titleSlug) {
           questionId
           title
           titleSlug
           difficulty
           content
           exampleTestcases
           hints
           solution {
               id
               content
               canSeeDetail
           }
           codeSnippets {
               lang
               code
           }
           topicTags {
               name
               slug
           }
       }
   }
   """

   
   variables = {"titleSlug": title_slug}
   payload = {"query": query, "variables": variables}
   
   headers = {
       "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
       "Content-Type": "application/json"
   }
   
   response = requests.post(url, json=payload, headers=headers)
   if response.status_code == 200:
       return response.json().get("data", {}).get("question")
   return None

# 使用示例
detail = fetch_problem_detail("two-sum")
if detail:
   print(f"题目: {detail.get('title')}")
   print(f"难度: {detail.get('difficulty')}")

3.4 完整爬虫实现

将以上功能整合,并加入错误处理、延迟控制和进度显示:

import requests
import json
import time
import random
from typing import List, Dict, Optional

class LeetCodeScraper:
   def __init__(self, use_proxy: bool = False):
       self.session = requests.Session()
       self.session.headers.update({
           "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/120.0.0.0",
           "Content-Type": "application/json"
       })
       
       # 站大爷隧道代理配置
       self.use_proxy = use_proxy
       if use_proxy:
           self.proxies = {
               "http": "http://隧道代理地址:端口",
               "https": "https://隧道代理地址:端口"
           }
       else:
           self.proxies = None
       
       self.graphql_url = "https://leetcode.com/graphql"
   
   def fetch_problem_list(self) -> List[Dict]:
       """获取所有题目列表"""
       url = "https://leetcode.com/api/problems/all/"
       try:
           resp = self.session.get(url, proxies=self.proxies, timeout=15)
           if resp.status_code == 200:
               data = resp.json()
               return data.get("stat_status_pairs", [])
       except Exception as e:
           print(f"获取题目列表失败: {e}")
       return []
   
   def fetch_problem_detail(self, title_slug: str, retry: int = 3) -> Optional[Dict]:
       """获取单道题目的详细信息"""
       query = """
       query getQuestionDetail($titleSlug: String!) {
           question(titleSlug: $titleSlug) {
               questionId
               title
               titleSlug
               difficulty
               content
               exampleTestcases
               hints
               solution { id content canSeeDetail }
               codeSnippets { lang code }
               topicTags { name slug }
           }
       }
       """

       payload = {"query": query, "variables": {"titleSlug": title_slug}}
       
       for attempt in range(retry):
           try:
               resp = self.session.post(
                   self.graphql_url,
                   json=payload,
                   proxies=self.proxies,
                   timeout=15
               )
               if resp.status_code == 200:
                   data = resp.json()
                   return data.get("data", {}).get("question")
               elif resp.status_code == 403:
                   print(f"请求被拒绝(403),第{attempt+1}次重试...")
                   time.sleep(5 * (attempt + 1))
           except Exception as e:
               print(f"请求异常: {e}")
               time.sleep(3 * (attempt + 1))
       return None
   
   def scrape_all_problems(self, max_problems: int = 0) -> List[Dict]:
       """爬取所有题目(可限制数量)"""
       print("正在获取题目列表...")
       problem_list = self.fetch_problem_list()
       if not problem_list:
           print("获取题目列表失败")
           return []
       
       # 过滤付费题目(只爬取免费题目)
       free_problems = [p for p in problem_list if not p.get("paid_only", False)]
       print(f"共有 {len(free_problems)} 道免费题目")
       
       if max_problems > 0:
           free_problems = free_problems[:max_problems]
       
       results = []
       total = len(free_problems)
       
       for idx, problem in enumerate(free_problems, 1):
           stat = problem.get("stat", {})
           title_slug = stat.get("question__title_slug")
           
           if not title_slug:
               continue
           
           print(f"[{idx}/{total}] 正在爬取: {stat.get('question__title')}")
           
           detail = self.fetch_problem_detail(title_slug)
           if detail:
               results.append(detail)
           
           # 随机延迟,避免触发频率限制
           delay = random.uniform(1.5, 3.5)
           time.sleep(delay)
       
       print(f"爬取完成,共获取 {len(results)} 道题目")
       return results
   
   def save_to_json(self, data: List[Dict], filename: str = "leetcode_problems.json"):
       """保存数据到JSON文件"""
       with open(filename, "w", encoding="utf-8") as f:
           json.dump(data, f, ensure_ascii=False, indent=2)
       print(f"数据已保存到 {filename}")


# 使用示例
if __name__ == "__main__":
   scraper = LeetCodeScraper(use_proxy=True)
   problems = scraper.scrape_all_problems(max_problems=10)  # 先测试10道
   scraper.save_to_json(problems)

四、构建离线刷题系统

4.1 数据存储设计

爬取到的数据需要以合理的结构存储,方便离线查询和检索。推荐采用以下方案:

存储层级 格式 用途
原始数据 JSON 完整的API响应数据
结构化数据 SQLite 高效查询、索引、关联
展示数据 Markdown/HTML 人类可读的题目展示

社区已有现成的工具可以实现这一目标。LeetScrape是一个Python包,可以快速下载并保存LeetCode题目到本地,包含主题、难度和多种语言的代码模板。它提供了命令行和Python API两种使用方式。

4.2 离线刷题工具链

目前社区已有多个成熟的离线刷题工具:

LeetScrape:轻量级Python包,可获取题目列表、题目详情(包含测试用例、约束条件、提示)以及任意编程语言的代码模板。

Leetgo:用Go语言编写的命令行工具,通过LeetCode的GraphQL API通信,能拉取题目描述和测试用例,并生成包含完整输入输出逻辑的本地代码骨架。它支持竞赛模式,可以定时拉取题目并一键提交。通过leetgo.yaml配置文件,用户可以自定义生成的文件名格式和代码结构。

leetcode-local-cli:面向力扣中文站的轻量本地刷题CLI工具,复用浏览器登录态获取题目,生成单文件solution.py,支持本地测试和远程提交。

4.3 从零搭建简易离线刷题系统

如果希望自己动手搭建,可以按照以下步骤:

第一步:数据采集。使用前文实现的爬虫,将所有免费题目的数据爬取下来,保存为JSON或存入SQLite数据库。

第二步:本地Web服务。使用Flask或FastAPI搭建一个本地Web服务,提供题目浏览、搜索、按难度/标签筛选等功能。

第三步:本地代码运行环境。为每种编程语言配置本地运行环境,支持在本地编写代码并运行测试用例。

第四步:进度追踪。在本地数据库中记录每道题的完成状态、提交次数、笔记等。

五、隧道代理:突破IP封锁的关键

5.1 为什么需要代理?

即使你完美配置了User-Agent和请求延迟,当采集规模从几十道扩展到上千道题目时,IP封禁依然不可避免。LeetCode对API请求有严格的频率限制,同一个IP在短时间内发出大量请求,必然触发限流机制。

传统的解决方案是自行维护代理IP池,但这种方法存在两个核心痛点:

  1. IP质量参差不齐:免费代理资源已被大量滥用,可用性低
  2. 手动维护繁琐:需要持续检测IP有效性,被封后手动更换

5.2 隧道代理的工作原理

隧道代理是传统代理的升级方案。你不需要手动维护IP池,只需配置好接入信息。每次发送请求时,隧道代理会自动把流量引导至不同的出口IP——相当于给爬虫配备了一条专属的“IP安全隧道”。

站大爷隧道代理为例,其核心优势包括:

  • 自动切换无感知:支持每请求自动切换IP,无需手动干预
  • 覆盖范围广泛:覆盖全国99%地域
  • 高可用性:连续7天24小时压力测试,连接成功率稳定在99.3%
  • 高并发支持:实测每秒150+请求,连续24小时成功率依然稳定在98%以上
  • 主备双隧道:持续更新IP池,保障采集流程顺畅
  • 300万级IP池:覆盖全国290+城市

5.3 在爬虫中集成隧道代理

在前文的爬虫代码中,我们已经预留了隧道代理的集成接口:

if use_proxy:
   self.proxies = {
       "http": "http://隧道代理地址:端口",
       "https": "https://隧道代理地址:端口"
   }

使用时只需将隧道代理地址端口替换为站大爷提供的实际地址即可。站大爷支持三种鉴权模式——白名单、用户名密码/白名单、用户名密码+白名单,可以根据自己的需求选择。

对于需要精细控制IP轮换的场景,站大爷隧道代理支持通过参数自定义IP更换频率:

# 每300秒更换一次IP
self.proxies = {
   "http": "http://隧道代理地址:端口?period=300",
   "https": "https://隧道代理地址:端口?period=300"
}

实际应用中,隧道代理可以将IP封禁率大幅降低,支撑大规模数据采集的稳定性。有实测数据显示,使用站大爷隧道代理连续7天采集某平台数据,平均成功率可达98.7%。

六、常见问题与避坑指南

6.1 Cloudflare挑战无法通过怎么办?

LeetCode主站部署了Cloudflare反爬虫机制。解决方案:

  • 使用Playwright等浏览器自动化工具,让真实浏览器完成挑战验证
  • 首次运行时手动在浏览器中完成验证,保存Cookie供后续使用
  • 注意:力扣中国站(leetcode-cn.com)不受Cloudflare影响,脚本依然可用

6.2 GraphQL请求返回403怎么办?

  • 检查是否携带了正确的User-Agent
  • 检查请求头是否完整(Accept、Content-Type等)
  • 增加请求间隔,使用随机延迟而非固定间隔
  • 使用站大爷隧道代理切换IP

6.3 爬取速度太慢怎么办?

  • 使用异步IO(aiohttp)并发请求
  • 只爬取免费题目,跳过付费题目
  • 分批次爬取,每次爬取一部分
  • 使用max_problems参数先测试少量题目

6.4 数据存储格式如何选择?

  • 小规模数据(<1000道):JSON格式即可,便于查看和修改
  • 中等规模数据:SQLite数据库,支持索引和复杂查询
  • 需要展示和阅读:生成Markdown或HTML文件

6.5 法律与合规提醒

  • LeetCode的题目数据版权归LeetCode官方所有
  • 请遵循LeetCode的使用条例
  • 仅供个人学习和研究使用,请勿用于商业用途
  • 控制请求频率,避免对LeetCode服务器造成过大压力

七、总结

本文从LeetCode的数据架构与反爬体系入手,系统介绍了爬取题目与题解数据、搭建离线刷题系统的完整方案。核心要点可以概括为:

环节 关键技术 产出
数据发现 GraphQL API分析 题目列表 + 题目详情接口
数据爬取 requests + GraphQL查询 结构化题目数据
数据存储 JSON / SQLite 离线题库
刷题系统 本地Web服务 + 代码运行环境 离线刷题工具
反爬应对 站大爷隧道代理 稳定的IP访问

技术选型速览:推荐“GraphQL API直连 + 站大爷隧道代理”的组合方案。LeetCode提供了结构化的GraphQL接口,直接调用API比浏览器自动化效率更高、资源消耗更低。配合隧道代理的自动IP切换,可以稳定支撑大规模数据采集。

LeetCode的题库是每位程序员备战技术面试的宝贵资源。通过合理的爬虫技术和数据管理方法,我们可以将这份资源转化为离线的、可随时访问的个人知识库——无论在飞机上、地铁里还是网络不稳定的环境下,都能随时刷题、随时进步。

最后需要提醒的是:数据采集行为应当遵循平台规则,控制请求频率以避免对目标服务器造成过载。请遵守LeetCode的使用条例及相关法律法规,仅将爬虫技术用于学习和研究目的。希望本文能帮助你在算法学习的道路上迈出坚实的一步。

目录
相关文章
人工智能 缓存 前端开发
9062 37
人工智能 JavaScript 开发工具
3728 9
开发工具 Swift git
1411 2
缓存 JavaScript Shell
1723 2
人工智能 JavaScript 测试技术
1279 0
Shell API 调度
943 3
人工智能 JavaScript 测试技术
489 4
人工智能 Java BI
529 0