一 抓取需求
# 抓取需求
start_url = 'http://ks.wangxiao.cn/'
抓取 首页 各分类下 ---> 各种考试 ---> 考点练习里 各科目的 练习题
eg:
工程类 ---> 一级建造师 ---> 建设工程经济 --->章节
# 存储需求: 文件存储 题目中可能包含图片 md格式比较合适
按照分类 依次 存为文件夹目录,最后题目中 章节 最底层的小节名 为文件名,存储为markdown格式的文件
eg:
工程类
- 一级建造师
- 建设工程经济
- 第1篇工程经济
- 第1章资金时间价值计算及应用
- ...
-4.利率.md
-4.利率--img
-1.jpg
# 图片存放位置:文件名 同级的 '文件名--img' 文件夹里
二 基础分析
2.1 首页分析
# 首页分析
1.从首页的源码中,能够直接获取到 各分类 和 考试、以及第二页的 链接
eg: 工程类、 一级建造师、一级建造师的链接(但默认是到 模拟考试的 http://ks.wangxiao.cn/TestPaper/list?sign=jz1)
2.链接分析
需要的是考点练习,而不是模拟考试,但用不着 非得从页面获取,直接根据链接规律自己替换
模拟考试:http://ks.wangxiao.cn/TestPaper/list?sign=jz1
考点练习:http://ks.wangxiao.cn/exampoint/list?sign=jz1
第二个路径 由 TestPaper,替换为 exampoint 就行
2.2 章节存储分析
### 0 前提
按照 篇章/章节/小节/考点,为文件路径 进行文件存储
### 1 分析
eg: http://ks.wangxiao.cn/exampoint/list?sign=jz1 下考题网页结构
根据不同考试的不同科目,有可能出现 多种层级结构
# 情况一:
篇章名 ul[@class="chapter-item"]
章节名 ul[@class="section-item"]
小节名 ul[@class="section-item"]
考点 ul[@class="section-point-item"]
# 情况二:
篇章名 ul[@class="chapter-item"]
章节名 ul[@class="section-item"]
考点 ul[@class="section-point-item"]
# 情况三:
篇章名 ul[@class="chapter-item"]
考点 ul[@class="section-point-item"]
# 情况四:
篇章名 ul[@class="chapter-item"] # 有些篇章 直接就是考点
### 2 总结:
若是按照正向的顺序 一层一层 向下查找,中间的section-item 不知道多少层
而且 for循环 迭代的 嵌套层数 也特别麻烦,每层的名字都需要获取到,直到最底层。
故反向查找 就很比较轻松一些,借助 xpath的语法 ancestor(n.祖先) 获取祖先节点
# ancestor 默认获取所有的祖先节点,返回的是包含各个父节点正向顺序的 列表
[根节点, ..., 父父父节点,父父节点,父节点]
# ancestor::标签名 一直找到标签名为祖先节点为止
[标签名, ..., 父父父节点,父父节点,父节点]
### 3 解决
1.先 获取 最底层考点,依次获取 考点名、以及发送答题(第三层页面)的请求 等等
point = .xpath(".//ul[@class="section-point-item"]")
2.根据最底层考点,向上 获取祖先节点,获取篇章名等
ancestor_ul = point.xpath("./ancestor::ul")
或
ancestor_ul = point.xpath('./ancestor::ul[@class="section-item" or class="chapter-item"]')
2.3 题库json分析
# 前提:在最底层考点层时,有“开始答题”,发送新的异步请求,返回的题库json数据
# question_list_url:https://ks.wangxiao.cn/practice/listQuestions
# 核心:其他题型(单选、多选、简述 n个题目 + n个答案) 和 材料题(1个背景材料 + n个题目 + n个答案)
# json结构解析:
{
"Data":[
{
"questions":[ # 其他题型,题目和答案 都在该questions中
{
"content":"题干",
"options":["题选项"],
"textAnalysis":"答案解析"
},
],
"materials":null
},
{
"questions":null,
"materials":[ # 材料题,题目和答案 都在该materials中
{
"material":{"content":"背景材料" ...},
"questions":[
{
"content":"题干",
"options":["题选项"],
"textAnalysis":"答案解析"
},
]
}
]
}
]
...
}
# 总结:
1.直接遍历data 判断 "questions" 是否为空,从而区分 材料题 和 其他题型
2.材料题 获取题目、选项和答案,逻辑 和其他题型一样,可以封装成一个解析函数
三 实现代码
3.1 spiders/ks.py
import json
import scrapy
import os
class KsSpider(scrapy.Spider):
name = 'ks'
allowed_domains = ['wangxiao.cn']
start_urls = ['http://ks.wangxiao.cn/']
def parse(self, response, **kwargs):
li_list = response.xpath('//ul[@class="first-title"]/li')
for li in li_list:
# 一级类目 eg:工程类
first_title = li.xpath('./p/span/text()').extract_first()
a_list = li.xpath('./div/a')
for a in a_list:
# 二级类目 eg: 一级建造师
second_title = a.xpath('./text()').extract_first()
href = a.xpath('./@href').extract_first()
# 第二页链接 模拟考试 ,替换成 考点练习
href = response.urljoin(href).replace('TestPaper', 'exampoint')
# yield scrapy.Request(
# url=href,
# callback=self.parse_second,
# meta={
# 'first_title': first_title,
# 'second_title': second_title
# }
# )
# 测试: 减少请求,固定单一网页url
yield scrapy.Request(
url='http://ks.wangxiao.cn/exampoint/list?sign=jz1',
callback=self.parse_second,
meta={
'first_title': '工程类',
'second_title': '一级建造师'
}
)
return # 直接返回,就不会yield新请求
def parse_second(self, response):
first_title = response.meta.get('first_title')
second_title = response.meta.get('second_title')
a_list = response.xpath('//div[@class="filter-content"]/div[2]/a')
for a in a_list:
third_title = a.xpath('./text()').extract_first()
href = response.urljoin(a.xpath('./@href').extract_first()) # 科目
yield scrapy.Request(
url=href,
callback=self.parse_third,
meta={
'first_title': first_title,
'second_title': second_title,
'third_title': third_title
}
)
return # 测试
def parse_third(self, response):
first_title = response.meta.get('first_title')
second_title = response.meta.get('second_title')
third_title = response.meta.get('third_title')
chapters = response.xpath('.//ul[@class="chapter-item"]')
for chapter in chapters:
point_uls = chapter.xpath('.//ul[@class="section-point-item"]')
if not point_uls:
# 没有最底层,直接是"chapter-item",就开始答题
file_name = ''.join(chapter.xpath('./li[1]//text()').extract()).strip().replace(' ', '')
file_path = os.path.join(first_title, second_title, third_title)
top = chapter.xpath('./li[2]/text()').extract_first().split('/')[-1]
sign = chapter.xpath('./li[3]/span/@data_sign').extract_first()
subsign = chapter.xpath('./li[3]/span/@data_subsign').extract_first()
list_question_url ='https://ks.wangxiao.cn/practice/listQuestions'
data = {
'examPointType': "",
'practiceType': "2",
'questionType': "",
'sign': sign,
'subsign': subsign,
'top': top
}
yield scrapy.Request(
url=list_question_url,
method='POST',
body=json.dumps(data),
headers={
'Content-Type': 'application/json; charset=UTF-8'
},
callback=self.parse_fourth,
meta={
'file_path': file_path,
'file_name': file_name
}
)
for point in point_uls:
# 1.在最底层节点,向上获取 中间层的 篇章名,拼接目录
item_list = point.xpath('./ancestor::ul')
title_list = []
for item in item_list:
title = ''.join(item.xpath('./li[1]//text()').extract()).strip().replace(' ', '')
title_list.append(title)
file_path = os.path.join(first_title, second_title, third_title, *title_list)
# 2.在最底层节点,获取本层的 考点名, 构成文件名
file_name = ''.join(point.xpath('./li[1]//text()').extract()).strip().replace(' ', '')
# 3.发生新请求 题目url,开始答题 获取请求需要的参数
top = point.xpath('./li[2]/text()').extract_first().split('/')[-1]
sign = point.xpath('./li[3]/span/@data_sign').extract_first()
subsign = point.xpath('./li[3]/span/@data_subsign').extract_first()
list_question_url ='https://ks.wangxiao.cn/practice/listQuestions'
data = {
'examPointType': "",
'practiceType': "2",
'questionType': "",
'sign': sign,
'subsign': subsign,
'top': top
}
yield scrapy.Request(
url=list_question_url,
method='POST',
body=json.dumps(data),
# 注意:发送json格式数据,请求头要带这个 指明传输的数据格式类型
headers={
'Content-Type': 'application/json; charset=UTF-8'
},
callback=self.parse_fourth,
meta={
'file_path': file_path,
'file_name': file_name
}
)
def parse_fourth(self, response):
file_path = response.meta.get('file_path')
file_name = response.meta.get('file_name')
data = response.json().get('Data')
for item in data:
questions = item.get('questions')
if questions: # 其他题型
for question in questions:
question_info = self.parse_questions(question)
yield {
'file_path': file_path,
'file_name': file_name,
'question_info': question_info
}
else: # 材料题
materials = item.get('materials')
for mater in materials:
material = mater.get('material')
content = "背景材料:" + '\n' + material.get('content') + '\n' # 背景材料
questions = mater.get('questions')
question_list = []
for question in questions:
question_content = self.parse_questions(question)
question_list.append(question_content)
question_info = content + '\n'.join(question_list)
yield {
'file_path': file_path,
'file_name': file_name,
'question_info': question_info
}
def parse_questions(self, question):
content = "问题内容:" + '\n' + question.get("content") # 题干
options = question.get("options") # 选项
options_list = []
answer_list = []
for opt in options:
opt_name = opt.get('name')
opt_content = opt.get("content")
is_right = opt.get('isRight')
if is_right:
answer_list.append(opt_name)
options_list.append(opt_name + '.' + opt_content)
analysis = "参考解析:" + '\n' + question.get("textAnalysis") # 答案解析
analysis = "参考答案:" + ','.join(answer_list) + '\n' + analysis # 拼接 答案和答案解析
content = content + '\n' + '\n'.join(options_list) + '\n' + analysis + '\n' # 将题干 选项 答案及解析 全拼在一起
return content
3.2 pipeline.py
from itemadapter import ItemAdapter
from scrapy.pipelines.images import ImagesPipeline
import os
from lxml import etree
from scrapy import Request
class WangxiaoPipeline:
def process_item(self, item, spider):
file_path = item.get('file_path')
file_name = item.get('file_name')
question_info = item.get('question_info')
if not os.path.exists(file_path):
os.makedirs(file_path)
with open(os.path.join(file_path, file_name + '.md'), mode='a', encoding='utf-8') as f:
f.write(question_info)
print(question_info)
return item
class WangxiaoImagePipeline(ImagesPipeline):
def get_media_requests(self, item, info):
# 下载 题中的图片到本地
question_info = item.get('question_info')
tree = etree.HTML(question_info)
img_url_list = tree.xpath('//img/@src')
for img_url in img_url_list:
yield Request(
url=img_url,
meta={
'img_url': img_url,
'file_path': item.get('file_path'),
'file_name': item.get('file_name')
}
)
def file_path(self, request, response=None, info=None, *, item=None):
# 图片存放位置:文件名 同级的 '文件名--img' 文件夹里
img_url = request.meta.get('img_url')
file_path = request.meta.get('file_path')
file_name = request.meta.get('file_name')
return os.path.join(file_path, file_name + '--img', img_url.split('/')[-1])
def item_completed(self, results, item, info):
if results: # 如果有图片下载. 才往里走
for status, pic_info in results:
if status:
# 将md文件中的 图片 由网络地址,替换成本地图片地址
# 网络地址 pic_info中有 pic_info.get('url')
http_url = pic_info.get('url')
# 本地地址 pic_info.get('path') ,再操作为 相对地址
local_url = os.path.join(*pic_info.get('path').split('\\')[-2:])
item['question_info'] = item.get('question_info').replace(http_url, local_url)
return item
3.3 setings.py
BOT_NAME = 'wangxiao'
SPIDER_MODULES = ['wangxiao.spiders']
NEWSPIDER_MODULE = 'wangxiao.spiders'
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36'
ROBOTSTXT_OBEY = False
LOG_LEVEL = 'WARNING'
DOWNLOAD_DELAY = 3
COOKIES_ENABLED = False
DEFAULT_REQUEST_HEADERS = {
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'en',
'Cookie':'账号cookie'
}
ITEM_PIPELINES = {
'wangxiao.pipelines.WangxiaoImagePipeline': 300,
'wangxiao.pipelines.WangxiaoPipeline': 301,
}
# 下载图片的总路径
IMAGES_STORE = './'