创建爬虫
在新建好项目后,用PyCharm或其他IDE打开该项目。进入该项目文件夹,使用genspider
命令新建一个爬虫:
cd scrapy_project
scrapy genspider QuestionSetSpider leetcode.com
其中QuestionSetSpider是爬虫的名字,leetcode.com是我们打算爬取的网站的域名。
新建好爬虫之后可以看到在项目的spiders文件夹下新增了一个名为 QuestionSetSpider.py的文件,这就是我们刚才新建的爬虫文件。这个爬虫文件会自动生成以下代码
# -*- coding: utf-8 -*-
import scrapy
class QuestionSetSpider(scrapy.Spider):
name = 'QuestionSetSpider'
allowed_domains = ['leetcode.com']
start_urls = ['http://leetcode.com/']
def parse(self, response):
pass
- QuestionSetSpider类继承自scrapy.Spider,也就是scrapy框架中所有爬虫的基类;
- self.name属性是该爬虫的名字,在该爬虫文件的外部可以通过这个属性获取当前爬虫;
- self.allowed_domains是当前爬虫文件可以访问的域名列表,如果在爬取页面时进入了一个该域名以外的url会抛出错误;
- self.start_urls是一个url列表,基类中定义了start_requests函数,它会遍历self.start_urls,并对每一个url调用scrapy.Request(url, dont_filter=True),为了实现爬取题目的需求,我们需要重写self.start_urls函数
获取题目详细信息
分析
LeetCode使用了GraphQL进行数据的查询和传输,大部分页面都是通过JS渲染生成的动态页面,所以无法直接从页面上获取标签,即使使用提供JavaScript渲染服务的库(例如Splash)也无法获取全部的数据,所以只能通过发送请求来获取数据。
为了爬取题目的详细信息,我们首先要从题目列表进入每个题目对应的链接。
首先打开leetcode的problem列表,按F12打开Chrome的开发者工具,进入Network标签栏,勾选上Preserve log,刷新该页面。
可以看到,网页向 https://leetcode.com/api/problems/all/ 发送了一个名为"all/“的GET类型的Request,这就是获取所有题目链接和相关信息的请求。如果此时已经安装了Toggle JavaScript插件,我们可以直接右键点击“Open in new tab”,查看该请求返回的Response。
更方便的方法是使用postman向服务器发送一个相同的Request,并将其保存下来,这样如果我们下次需要查看相应的Response的时候就不需要再使用开发者工具了。
返回的Response是一个json对象,其中的"stat_status_pairs"键所对应的值是所有包含题目信息的list,而列表中的[“stat”][“question__title_slug”]就是题目所在的页面。以Largest Perimeter Triangle为例,将其title_slug拼接到https://leetcode.com/problems/ 后,进入页面https://leetcode.com/problems/largest-perimeter-triangle/ 。同样地,打开开发者工具,刷新页面,可以看到服务器返回了很多项graphql的查询数据,通过查看Request Payload可以找到其中operationName为"questionData"的一项,这就是当前题目的详细信息。
将Payload复制粘贴到postman的Body中,在Headers中设置Content-Type为application/json,发送请求,可以看到返回的是一个json对象,包含了该题目所对应的所有信息。
接下来我们就可以对该题目的信息进行处理了。
实现
为了获取题目列表的json对象,我们需要先重写start_requests函数。
def start_requests(self):
self.Login() # 用户登录,后续会用到
questionset_url = "https://leetcode.com/api/problems/all/"
yield scrapy.Request(url=questionset_url, callback=self.ParseQuestionSet)
Request是scrapy的一个类对象,功能类似于requests库中的get函数,可以让scrapy框架中的Downloader向url发送一个get请求,并将获取的response交给指定的爬虫文件中的回调函数进行相应的处理,其构造函数如下
class Request(object_ref):
def __init__(self, url, callback=None, method='GET', headers=None, body=None, cookies=None, meta=None, encoding='utf-8', priority=0, dont_filter=False, errback=None, flags=None):
...
在获取到json对象之后,可以通过遍历"stat_status_pairs"键所对应的列表,并取出[“stat”][“question__title_slug”]的值,得到题目的title_slug。此时我们不再需要进行打开题目相关页面的操作,直接向GraphQL发送查询详细信息的request即可。
我们可以从postman直接获取到发送请求相关的代码。因为每个题目的title_slug不同,我们可以将Payload中titleSlug后的字段改为一个不会重复的独特的字符串,在每一次获取到新的title_slug之后用replace函数替换它,发送新的请求,然后再将其替换回独特的字符串。
准备好Payload和Headers之后,我们可以使用FormRequest发送POST请求向GraphQL查询数据。FormRequest是scrapy的一个类对象,功能类似于requests库中的post函数,让scrapy框架中的Downloader向url发送一个post请求,并将获取的response交给指定的爬虫文件中的回调函数进行相应的处理。此处在发送POST请求之后response被交给ParseQuestionData函数进行处理。
question_payload = "{\n \"operationName\": \"questionData\",\n \"variables\": {\n \"titleSlug\": \"QuestionName\"\n },\n \"query\": \"query questionData($titleSlug: String!) {\\n question(titleSlug: $titleSlug) {\\n questionId\\n questionFrontendId\\n boundTopicId\\n title\\n titleSlug\\n content\\n translatedTitle\\n translatedContent\\n isPaidOnly\\n difficulty\\n likes\\n dislikes\\n isLiked\\n similarQuestions\\n contributors {\\n username\\n profileUrl\\n avatarUrl\\n __typename\\n }\\n langToValidPlayground\\n topicTags {\\n name\\n slug\\n translatedName\\n __typename\\n }\\n companyTagStats\\n codeSnippets {\\n lang\\n langSlug\\n code\\n __typename\\n }\\n stats\\n hints\\n solution {\\n id\\n canSeeDetail\\n __typename\\n }\\n status\\n sampleTestCase\\n metaData\\n judgerAvailable\\n judgeType\\n mysqlSchemas\\n enableRunCode\\n enableTestMode\\n envInfo\\n __typename\\n }\\n}\\n\"\n}\n"
graphql_url = "https://leetcode.com/graphql"
def ParseQuestionSet(self, response):
headers = {
"user_agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36'",
"content-type": "application/json" # necessary
}
questionSet = json.loads(response.text)
questionSet = questionSet["stat_status_pairs"]
for question in questionSet:
title_slug = question["stat"]["question__title_slug"]
self.question_payload = self.question_payload.replace("QuestionName", title_slug)
yield scrapy.FormRequest(url=self.graphql_url, callback=self.ParseQuestionData,
headers=headers, body=self.question_payload)
self.question_payload = self.question_payload.replace(title_slug, "QuestionName")
现在数据已经获取到了,我们需要在items.py文件中定义一个类用来存储题目的详细信息。items.py文件中的类继承自scrapy.Item类,是提供给scrapy框架中的组件Item Pipeline进行处理的统一的的数据结构。
import scrapy
class QuestionDataItem(scrapy.Item):
# define the fields for your item here like:
# name = scrapy.Field()
id = scrapy.Field()
title = scrapy.Field()
content = scrapy.Field()
submission_list = scrapy.Field()
topics = scrapy.Field()
difficulty = scrapy.Field()
ac_rate = scrapy.Field()
likes = scrapy.Field()
dislikes = scrapy.Field()
slug = scrapy.Field()
定义了QuestionDataItem类之后可以进入ParseQuestionData函数开始对题目详细信息的提取,我们可以根据需求提取出题目的id,title,content,topics,difficulty等信息,用一个QuestionDataItem对象来存储这些数据,然后进行yield questionDataItem操作,将这个对象交给Item Pipeline进行处理。
def ParseQuestionData(self, response):
questionData = json.loads(response.text)["data"]["question"]
questionDataItem = QuestionDataItem()
questionDataItem["id"] = questionData["questionFrontendId"]
questionDataItem["title"] = questionData["title"]
questionDataItem["content"] = questionData["content"]
topics = []
for topic in questionData["topicTags"]:
topics.append(topic["name"])
if len(topics) == 0:
topics.append("None")
questionDataItem["topics"] = topics
questionDataItem["difficulty"] = questionData["difficulty"]
stats = json.loads(questionData["stats"])
questionDataItem["ac_rate"] = stats["acRate"]
questionDataItem["likes"] = questionData["likes"]
questionDataItem["dislikes"] = questionData["dislikes"]
questionDataItem["slug"] = questionData["titleSlug"]
submission_list = self.GetSubmissionList(questionDataItem["slug"])
questionDataItem["submission_list"] = submission_list
yield questionDataItem
至此题目信息的爬取就完成了。
参考资料
Scrapy官方文档
Postman官方文档