LCid.cc:利用GitHub Actions每日抓取LeetCode题目信息并联动Heroku自动更新后端服务

前言

这段时间一直在LeetCode上刷题准备面试找工作,经常在网上跟人交流,大家聊题目的时候经常倾向于只说个题号,但只有题号想要快速进入LeetCode对应的题目页面却是个麻烦事儿,需要先进入LeetCode的题目列表页面,输入题号,按回车,点击题目标题,最终才能进入题目页面,属实有点麻烦。

search-lc-id.gif

LC的题目页URL只包含题目名称(例如:https://leetcode.com/problems/lru-cache/ ),要是能支持直接在URL里输入题号就能跳转到题目页面(类似:https://leetcode.com/problems/146 )这样岂不是方便许多?既然官方不支持,不如自己试试做一个跳转站,在URL里输入题号,回车,就能直接跳转到LC题目页面。

经过足足8小时折腾,这个跳转站做好了,代码开源在GitHub:https://github.com/bunnyxt/lcid ,系统部署在LCid.cc上。

lcid-homepage.jpg

LCid支持以下访问:

lcid-redirection.gif

技术方面,LCid基于Python urllib3实现爬虫,利用GitHub Actions触发定时任务每日更新题目信息文件,基于Python Flask构建简单的后端跳转服务,打包部署到Heroku平台。每天GitHub Actions启动定时更新题目信息完成后,会将爬取到的最新的题目信息文件problems_all.json通过提交commit的形式添加回GitHub仓库,此操作会触发Heroku使用最新的题目信息文件重新构建与部署后端程序,以此更新后端服务使用的题目信息。

本文将从以下几个部分介绍LCid:

  1. API寻找与爬虫编写
  2. 简单后端服务搭建
  3. GitHub Actions定时任务设置与Heroku部署

API寻找与爬虫编写

API寻找与验证

首先,为了获取全部题目信息,我们需要寻找LeetCode的API。打开Chrome,新开一个无痕模式窗口,按下F12,切换到Network选项卡,筛选只查看Fetch/XHR,访问https://leetcode.com/problemset/all/ ,仔细观察右侧的请求,可以发现一个返回了题目信息的请求,

lc-all-problems-api-response.png

从URLhttps://leetcode.com/graphql可以看出,这是一个GraphQL接口。前端通过向该接口发送POST请求,将需要获取的内容与格式放在请求体内,后端即会返回请求的数据。更多关于GraphQL的知识,推荐前往https://graphql.org 学习。这里,为了模拟该请求,我们切换到Headers选项卡,滚动到最下方查看Request Payload

lc-all-problems-api-request.png

右键,选择Copy object,复制出来,格式化整理后如下。注意,实际写JSON的时候字符串是不允许这样直接回车换行的,这里为了美观这样展示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
{
    "query": "
        query problemsetQuestionList(
            $categorySlug: String, 
            $limit: Int, 
            $skip: Int, 
            $filters: QuestionListFilterInput
        ) {
            problemsetQuestionList: questionList(
                categorySlug: $categorySlug
                limit: $limit
                skip: $skip
                filters: $filters
            ) {
                total: totalNum
                questions: data {
                    acRate
                    difficulty
                    freqBar
                    frontendQuestionId: questionFrontendId
                    isFavor
                    paidOnly: isPaidOnly
                    status
                    title
                    titleSlug
                    topicTags {
                        name
                        id
                        slug
                    }
                    hasSolution
                    hasVideoSolution
                }
            }
        }
    ",
    "variables": {
        "categorySlug": "",
        "skip": 0,
        "limit": 50,
        "filters": {}
    },
    "operationName": "problemsetQuestionList"
}

为了验证该接口的可用性,我们使用Postman,将以上请求信息添加到Body中,发送请求。可以看到,该接口成功返回前50道题目的信息。

lc-all-problems-api-postman-test.png

这里我们关注两处参数:skiplimit。很显然可以猜到,这两个参数类似SQL中的offsetlimit,用来筛选返回部分题目信息。那么,我们是否可以通过设置limit为当前的题目总数,来获取所有的题目信息呢?已知当前题目总数是2066,我们修改limit的值为2066,再次发送请求,稍等片刻,竟然全部2066道题的信息都返回了。看来这个接口并没有配置最大的返回题目道数限制。

lc-all-problems-api-postman-2066.png

爬虫代码编写

至此,我们便可开始编写爬虫代码了。基于urllib3,很快便完成了fetch_problems函数,该函数接收一个参数limit,对应上述提到的请求中的limit参数。该函数返回本次请求接口返回的JSON字符串序列化后的字典对象。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
def fetch_problems(limit=50):
http = urllib3.PoolManager()
data = {
'query': 'query problemsetQuestionList($categorySlug:String,$limit:Int,$skip:Int,$filters:QuestionListFilterInput){problemsetQuestionList:questionList(categorySlug:$categorySlug limit:$limit skip:$skip filters:$filters){total:totalNum questions:data{acRate difficulty freqBar frontendQuestionId:questionFrontendId isFavor paidOnly:isPaidOnly status title titleSlug topicTags{name id slug}hasSolution hasVideoSolution}}}',
'variables': {
'categorySlug': '',
'skip': 0,
'limit': limit,
'filters': {},
},
}
encoded_data = json.dumps(data).encode('utf-8')
r = http.request(
'POST',
'https://leetcode.com/graphql/',
body=encoded_data,
headers={
'Content-Type': 'application/json',
},
)
if r.status != 200:
raise RuntimeError('Fail to fetch problems! status: %d, data: %s' % (r.status, r.data))
response_content = json.loads(r.data)
return response_content

主函数中的逻辑也很直观,分两次调用该函数,第一次请求获取当前题目总数,第二次调用将总数作为参数传入,这样即可获得所有题目的信息。最后,将获得的全部题目信息保存到problems_all.json中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def main():
print('Now try get LeetCode problems total count...')
response_content = fetch_problems()
total_count = response_content['data']['problemsetQuestionList']['total']
print('Found %d problems in total.' % total_count)

print('Now try fetch all %d LeetCode problems...' % total_count)
response_content = fetch_problems(total_count)
questions_all = {q['frontendQuestionId']: q for q in
response_content['data']['problemsetQuestionList']['questions']}
print('All %d problems fetched.' % total_count)

with open('problems_all.json', 'w') as f:
f.write(json.dumps(questions_all))
print('All %d problems info saved into problems_all.json file.' % total_count)

至此,看上去爬虫部分就结束了。实际上,该版本脚本确实很多时候都是可以正常运行的。但是,我本人在调试的时候遇到过问题:LeetCode服务器报错,具体错误信息我没有保存,大意是缺少csrftoken信息。经过再次排查,发现浏览器中的请求头的Cookie中包含一项csrftoken=xxxxxx,而该Cookie是最一开始发送GET请求至https://leetcode.com/problemset/all/时返回头中的Set-Cookie项设置的。因此,在调用fetch_problems函数之前,还需要发送一次GET请求至https://leetcode.com/problemset/all/,获取其返回头中的Set-Cookie中的csrftoken,之后在请求https://leetcode.com/graphql/时在cookie中添加csrftoken即可。完整代码详见https://github.com/bunnyxt/lcid

简单后端服务搭建

有了所有题目的数据,那么我们就可以根据题目id找到题目的全小写带连字符标题(例如lru-cache),即可将其拼接到https://leetcode.com/problems/之后构成真正的题目URL了。这里我们使用Flask构建简单的后端服务,代码保存在app.py中。

跳转入口与信息API

首先,我们将保存到本地的题目信息读取到内存中,用字典形式保存。

1
2
3
4
5
6
import json

# load problems
with open('problems_all.json', 'r') as f:
problems_all_json = f.read()
problems_all = json.loads(problems_all_json)

在构建入口前,我们先构造一个Flask应用实例(名为app),并设置其静态资源文件夹为当前文件夹。另外,为了方便其他Web应用调用LCid的接口,这里使用flask_cors使其支持CORS。

1
2
3
4
5
from flask import Flask
from flask_cors import CORS

app = Flask(__name__, static_url_path='', static_folder='')
CORS(app)

基于Flask构建后端入口非常容易,只需要声明一个函数,加上装饰器即可。例如,下面的函数为/<problem_id>入口创建对应的服务,检索是否存在id为<problem_id>的题目信息,提取其全小写带连字符标题(例如lru-cache),拼接成完整的URL,并使用Flask中的redirect方法完成跳转。如果不存在该题,则只返回一行提示文字信息,不执行跳转。

1
2
3
4
5
6
7
8
from flask import redirect

@app.route('/<problem_id>')
def go_redirect(problem_id):
problem_info = problems_all.get(problem_id, None)
if not problem_info:
return 'Fail to redirect to leetcode problem %s page.' % problem_id
return redirect('https://leetcode.com/problems/%s/' % problem_info['titleSlug'])

同理,/cn/<problem_id>入口逻辑完全相同,只不过拼接时LeetCode主域名用的是leetcode-cn.com,跳转到中国站。

1
2
3
4
5
6
@app.route('/cn/<problem_id>')
def go_redirect_cn(problem_id):
problem_info = problems_all.get(problem_id, None)
if not problem_info:
return 'Fail to redirect to leetcode-cn problem %s page.' % problem_id
return redirect('https://leetcode-cn.com/problems/%s/' % problem_info['titleSlug'])

/cn/<problem_id>入口则直接返回题目的信息,将字典序列化为JSON字符串,直接返回。

1
2
3
4
5
6
@app.route('/info/<problem_id>')
def info(problem_id):
problem_info = problems_all.get(problem_id, None)
if not problem_info:
return '{"code":404,"message":"Fail to get info of leetcode problem %s."}' % problem_id, 404
return json.dumps(problems_all[problem_id])

静态资源与根目录

为了方便用户使用,LCid还做了个简单的主页,保存在index.html,当访问项目根目录(即/入口)时返回给客户端index.html文件。这样的需求可以通过app.send_static_file()方法实现。

1
2
3
@app.route('/')
def root():
return app.send_static_file('index.html')

同理,favicon.ico网站图标文件也需要以同样的方式返回。

1
2
3
@app.route('/favicon.ico')
def favicon():
return app.send_static_file('favicon.ico')

所有的入口编写完毕,此时还需要一个WSGI server以供生产环境部署。这里我们使用waitress作为WSGI server

1
2
3
4
5
import sys
from waitress import serve

if __name__ == "__main__":
serve(app, host="0.0.0.0", port=sys.argv[1])

此时,控制台输入python app.py <port>即可在本机<port>端口启动此后端服务。访问localhost:<port>即可看见网站首页。至此,后端服务搭建完成。

GitHub Actions定时任务设置与Heroku部署

Heroku项目连接GitHub仓库

首先先说Heroku部署。Heroku是一个云服务平台,可以将自己的Web程序托管到远程机器上运行,每个月有一定数量的免费时长提供,足够小型项目与实验项目使用。前往https://dashboard.heroku.com/apps 进入个人项目总控制台,点击右上角的按钮新建一个项目,填写项目名称之后,该项目就创建完成了。

heroku-create-app.png

将自己的项目部署到Heroku上有很多种方法,包括功能非常强大的Heroku CLI命令行工具。由于我们的项目在GitHub上开源,因此我们选择将上一步创建的Heroku项目与GitHub仓库链接,并开启自动部署功能,这样每当我们的GitHub仓库的main分支有更新(例如commit),Heroku都会自动拉取仓库中最新的代码,根据该代码构建最新的后端服务。

heroku-deploy-with-github.png

对了,我们还得告诉Heroku如何启动我们的Web应用。在项目的根目录下创建Procfile文件,内容如下。

1
web: python app.py $PORT

这样,Heroku就知道如何启动我们的项目了。此时,访问https://<app-id>.herokuapp.com,即可看到项目已经成功运行。

GitHub Actions定时任务详解

因为LeetCode题目信息是会不定期更新的,而我们的后端程序一旦启动就只会用启动那一时刻目录中的problems_all.json文件作为题目信息库,因此需要一种方式,定期执行python fetch_problems_all.py,获得更新的problems_all.json文件,以commit的形式推送到仓库中,以此触发Heroku的自动部署,使用最新的problems_all.json文件重新启动服务,实现更新。

一番分析下来,GitHub Actions就完美的符合我们的需求。简单来说,我们可以通过GitHub Actions,编写一些基于仓库代码的行为操作(运行),并声明这些操作的执行时机(例如每当有新commit时触发,或者一个定时器定时触发)。在GitHub仓库主页中选择Actions分支,可以看到一系列官方推荐的简单的模板。当然,我们也可以点击set up a workflow yourself,自己编写我们想要的行为。

github-actions-setup.png

这里,我们的需求是,每天某个固定时刻,执行python fetch_problems_all.py脚本,并将更新的problems_all.json文件,以commit的形式推送回仓库。首先看下完整的配置代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# This is a basic workflow to help you get started with Actions

name: CI

# Controls when the workflow will run
on:
# Triggers the workflow with schedule
schedule:
# Runs at 00:00 UTC every day
- cron: '0 0 * * *'

# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "update-problems-all"
update-problems-all:
# The type of runner that the job will run on
runs-on: ubuntu-latest

# Steps represent a sequence of tasks that will be executed as part of the job
steps:
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2

# Setup Python
- name: Setup Python
uses: actions/setup-python@v2.2.2

# Pip install
- name: Python Pip Install
uses: logikal-code/pip-install@v1.0.0

# Runs fetch_problems_all.py
- name: Run fetch_problems_all.py
run: python fetch_problems_all.py

# Commit problems_all.json
- name: Commit problems_all.json
uses: stefanzweifel/git-auto-commit-action@v4.12.0
with:
# Commit message
commit_message: 'ci: update problems_all.json'
# File pattern used for `git add`. For example `src/*.js`
file_pattern: problems_all.json

大多数内容都是基于默认模板,依葫芦画瓢修改,注释写得很详细,这里再详细说明一下。

1
2
3
4
5
6
7
8
9
10
11
12
# This is a basic workflow to help you get started with Actions

name: CI
# Controls when the workflow will run
on:
# Triggers the workflow with schedule
schedule:
# Runs at 00:00 UTC every day
- cron: '0 0 * * *'

# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

name: CI声明了整个流程的名称。on后面的部分声明了接下来的行为在什么时候被触发,这里声明了两种方式,即在每天UTC零点时触发,以及允许手动触发。0 0 * * *UNIX cron格式字符串,具体格式详见此处

1
2
3
4
5
6
7
8
9
# A workflow run is made up of one or more jobs that can run sequentially or in parallel
jobs:
# This workflow contains a single job called "update-problems-all"
update-problems-all:
# The type of runner that the job will run on
runs-on: ubuntu-latest

# Steps represent a sequence of tasks that will be executed as part of the job
steps:

jobs部分正式声明任务,这里声明了一个叫update-problems-all的任务,该任务运行在ubuntu-latest平台上。之后steps指定了一系列步骤,我们依次了解一下。

1
2
# Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it
- uses: actions/checkout@v2

每一条step-开头,uses: actions/checkout@v2表示引用别人已经定义好的步骤,此步骤检查是否在GitHub工作区内,以保证以下步骤可以执行。

1
2
3
# Setup Python
- name: Setup Python
uses: actions/setup-python@v2.2.2

可以给步骤自定义名称name: Setup Python。同理,这里uses: actions/setup-python@v2.2.2是引用了一段配置Python运行环境的行为,安装最新版本的Python

1
2
3
# Pip install
- name: Python Pip Install
uses: logikal-code/pip-install@v1.0.0

此步骤uses: logikal-code/pip-install@v1.0.0实际内容是执行pip install -r requirements.txt安装依赖。至此,程序运行环境搭建完毕。

1
2
3
# Runs fetch_problems_all.py
- name: Run fetch_problems_all.py
run: python fetch_problems_all.py

这一步没有uses:了,而是run: python fetch_problems_all.py,相当于直接在命令行运行python fetch_problems_all.py指令。这一步执行完之后,当前目录下的problems_all.json文件即是最新抓下来的题目信息文件。

1
2
3
4
5
6
7
8
# Commit problems_all.json
- name: Commit problems_all.json
uses: stefanzweifel/git-auto-commit-action@v4.12.0
with:
# Commit message
commit_message: 'ci: update problems_all.json'
# File pattern used for `git add`. For example `src/*.js`
file_pattern: problems_all.json

最后一步,将当前目录下的problems_all.json文件通过commit的形式提交回仓库。同样,这里直接uses: stefanzweifel/git-auto-commit-action@v4.12.0使用别人已经写好了的步骤,并使用with:声明了两个参数,分别是commit_message: 'ci: update problems_all.json'制定了commit message以及file_pattern: problems_all.json即只将problems_all.json文件的改动提交。

大功告成!这样,GitHub Actions在每天UTC零点时就会自动执行以上脚本,抓取最新的题目信息,保存到problems_all.json文件中,并重新提交回仓库。

github-actions-execute-history.png

这样的更新也会触发Heroku的自动构建与部署,实现信息更新。

heroku-activity-feed.png

后记

LCid项目是我在经历了太多次「只知道题号不知道题目内容」之后的摸鱼产物,从新建文件夹到完成整个部署,总共编码时间大约在8小时左右,其中有用到我熟悉的urllib3Flask,也有全新的GitHub ActionsHeroku的尝试。其实完成后系统还接入了Cloudflare,不过与系统本身关系不大,再加上确实都是比较普通的配置,这里就不细说了。

最后,如果这个小项目以及这篇文章的分享对你有所帮助,希望你能给这个项目https://github.com/bunnyxt/lcid 点个Star,十分感谢。欢迎交流讨论。