# 1 🤔欲做其事,必先利其器
最近开始刷力扣,奈何力扣的那个编辑器(monaco editor)实在不好用,还是用起了常用的 vscode。每次开始一道题目之前不可避免地需要将题目要求和代码复制到本地,而这样的重复劳动十分令人生厌,于是开个项目,把之前写的统计文件数的命令行程序扩展成一个爬虫工具。
# 2 🧰工具和基础代码
由于个人主要使用 go 语言,因此爬虫也选择使用 go 来实现;如果你使用其他语言,也可以参考该实现的主要思路。该命令行工具使用 cobra 框架完成,但那并非要讨论的内容。让我们看看爬虫部分。
# 2.1 req
req 是 go 语言爬虫的最佳实践之一,其操作十分简单。在爬虫部分中,核心环节即是由 req 实现的 query 请求函数。让我们看看代码:
func query(bodyJsonString string) *req.Response {
res, err := req.C().R().
//csrf标头设置
SetHeader("x-csrftoken",
"**********************************************************").
SetHeader("referer", "https://leetcode.cn/problems").
SetBodyJsonString(bodyJsonString).
Post("https://leetcode.cn/graphql/")
checkErr(err)
return res
}
在该段代码中,使用 .C()
获取一个爬虫客户端,使用 .R()
获取请求,然后设置必要的 Header
和请求体,最后使用 Post
方法发起请求,得到一个可进行后续处理的 Response
结构体指针。详细内容下面会讲。
# 2.2 json 格式数据处理
当我们得到了请求到的数据,你会发现它是一个 json 格式的字符串。我们需要从该 json 格式数据中得到我们需要的信息,如题号、题目要求、题目代码等。在 go 中,一个很方便的实践是 gjson。而当我们想要修改 json 字符串时(你会发现我们确实需要),sjson 则是一个好的选择。在获取特定题目标题时,我使用了 gjson 以获取标题和难度等级。
func GetSlug(index int) (slug, diff string) {
res := query(slugPayload)
jsonString := res.String()
baseFindPath := fmt.Sprintf("data.allQuestionsBeta.#(questionId=%d)", index)
slugFindPath := baseFindPath + ".titleSlug"
diffFindPath := baseFindPath + ".difficulty"
slug = gjson.Get(jsonString, slugFindPath).String()
diff = gjson.Get(jsonString, diffFindPath).String()
return
}
# 3 🐛开始爬取数据
OK,到这里你已经知道了该使用什么工具,而我们的爬虫教程也终于可以正式开始了!
# 3.1 爬取特定题目信息
首先让我们先爬取特定题目的有用信息。这些信息可能包括题目的题号、难度、代码、需求和标题,为了方便起见,将它们加入一个结构体。
type Data struct {
Index int
Diff string
CodeR string
NeedR string
TitleR string
}
当你进入力扣并且打开一个具体的题目(这里以大家熟知的“两数之和”为例),按 F12
打开浏览器控制台,依次点击网络和 XHR
,你会发现很多 graphql/
,这些即是力扣的 api
请求信息。在仔细检查每项的数据内容后就能够发现记录着全部我们所需要信息的数据:
data: {,…}
question: {questionId: "1", questionFrontendId: "1", categoryTitle: "Algorithms", boundTopicId: 2,…}
接着我们就可以查看标头,记录请求地址(此处为 https://leetcode.cn/graphql/
)、crsftoken
和 referer
,并且设置爬虫的 Header
,并复制请求载荷信息作为请求体,以上面的地址发送 POST
请求:
func query(bodyJsonString string) *req.Response {
res, err := req.C().R().
//csrf标头设置
SetHeader("x-csrftoken",
"**********************************************************").
SetHeader("referer", "https://leetcode.cn/problems").
SetBodyJsonString(bodyJsonString).
Post("https://leetcode.cn/graphql/")
checkErr(err)
return res
}
其中请求体可以自定义请求项,只获取我们可能用得上的关键数据:
//请求体
{
"operationName": "questionData",
"variables": {
"titleSlug": "two-sum"
},
"query": "query questionData($titleSlug: String!) { question(titleSlug: $titleSlug) {\n questionId\n categoryTitle\n title\n titleSlug\n translatedTitle\n translatedContent\n isPaidOnly\n difficulty\n codeSnippets\n { lang\n langSlug\n code\n } } }"
}
这样我们就在 res
中获得了在浏览器中抓包得到的同样的 json 数据,你可以使用 res.String()
将它打印出来以进行验证;我们这里就不这样做了。然后使用 gjson
提取我们需要的数据:
func GetInter(slug, codeType string) (string, string, string) {
interPayload, err := sjson.Set(interPayload, "variables.titleSlug", slug)
checkErr(err)
res := query(interPayload)
jsonString := res.String()
codeTypeCode := model.CodeTypeMap[codeType]
code := fmt.Sprintf("data.question.codeSnippets.%v.code", codeTypeCode)
need := "data.question.translatedContent"
title := "data.question.translatedTitle"
codeR := gjson.Get(jsonString, code).String()
needR := gjson.Get(jsonString, need).String() //html
titleR := gjson.Get(jsonString, title).String()
return codeR, needR, titleR
}
gjson
的使用方法很简单:它的基本使用方式是 gjson.Get(json字符串,查找字符串)
,它能够通过 父元素名.子元素名
以获取子元素的值。至于如 #(questionId=1)
则是获取数组内属性 questionId
为 1 的元素。最后使用 .String()
方法将其转化为字符串。
由上面的代码,我们就获取了特定题目的代码,需求和标题。
但是我们如何爬取其他题目呢?我们发现涉及题目标题的请求信息只有 referer
和请求体的 titleSlug
,而修改 referer
似乎是不起作用的,那么真正区分不同题目的就只有 titleSlug
的值。只要利用 sjson
将其修改为其他题目的标题,我们确实拉取到了其他题目的信息。
# 爬取全部题目列表
至此我们已经能够根据标题取得我们需要的数据了,但每次传入标题的形式并不高效,更何况还是 slug 格式的标题。我们所需要的是根据题号爬取题目信息。
还是回到两数之和这个页面,如果你足够仔细的话,你可能早就发现了以下的 json 信息:
{data: {allQuestionsBeta: [{questionId: "1", __typename: "QuestionNode", status: "ac"},…]}}
该信息中维护了全部的题目信息,适当修改其请求体以获取 slug
格式标题,当然这也可包括其他数据:
{"operationName": "allQuestions", "query": "query allQuestions { allQuestionsBeta { ...questionSummaryFields __typename }}fragment questionSummaryFields on QuestionNode { title titleSlug translatedTitle questionId difficulty __typename}"}
细心的同学可能注意到,为何我们不在这里取得题目具体的信息,如code和need之类的呢?为何我们还需要维护一个请求专门爬取上面两个数据呢?这是因为我在修改上面请求体的过程中发现只要请求了以上任何一个数据就会报错,因此不得不这样做,虽然可能因此拖慢了程序的速度(毕竟要爬两次呢)。
于是我们就可以利用它取得题号对应的标题:
func GetSlug(index int) (slug, diff string) {
res := query(slugPayload)
jsonString := res.String()
baseFindPath := fmt.Sprintf("data.allQuestionsBeta.#(questionId=%d)", index)
slugFindPath := baseFindPath + ".titleSlug"
diffFindPath := baseFindPath + ".difficulty"
slug = gjson.Get(jsonString, slugFindPath).String()
diff = gjson.Get(jsonString, diffFindPath).String()
return
}
除开以上关键环节就只剩下一些文件操作(以及一些简单的ui),最后爬虫部分最终代码如下:
func Get(index int) {
t := timer.NewTimer()
t.Start()
config.ReadConfig()
codeType := viper.GetString("codeType")
slug, diff := query.GetSlug(index)
codeR, needR, titleR := query.GetInter(slug, codeType)
data := &model.Data{Index: index, Diff: diff, CodeR: codeR, NeedR: needR, TitleR: titleR}
codeDir := viper.GetString("codeDir")
var dir string
if viper.GetBool("moduled") {
diff := strings.ToLower(data.Diff)
dir = filepath.Join(codeDir, diff, strconv.Itoa(data.Index)+"."+data.TitleR)
} else {
dir = filepath.Join(codeDir, strconv.Itoa(data.Index)+"."+data.TitleR)
}
os.MkdirAll(dir, os.ModePerm)
codeFilename := "main." + codeType
codeFilePath := filepath.Join(dir, codeFilename)
needFilePath := filepath.Join(dir, "need.md")
if !isExist(codeFilePath) {
color.Cyan("请勿重复拉取文件")
return
} else {
cfile, err := os.Create(codeFilePath)
checkErr(err)
defer cfile.Close()
info := viper.GetString("append.prefix") + data.CodeR
cfile.WriteString(info)
}
if viper.GetBool("function.useNeed") {
if !isExist(needFilePath) {
color.Cyan("请勿重复拉取文件")
return
} else {
nfile, err := os.Create(needFilePath)
checkErr(err)
defer nfile.Close()
nfile.WriteString(needR)
}
}
t.Stop()
}