菜鸟写Python:scrapy爬取知名问答网站 实战(3)
一、文章开始:
可能看到这篇文章的朋友,大多数都是受慕课网bobby讲师课程的影响,本人也有幸在朋友处了解过这个项目,但是似乎他代码中登录方式因为知乎的改版而不再适用,因为知乎的登录变得越来越复杂,但是要爬取知乎首页问答,不登录是无法爬取的。
这篇文章将分享我在启发下,利用Scrapy结合Selenium实现知乎登录+保存登录cookies+读取保存cookies爬取首页问答内容+处理API请求的Json数据+处理多个Item数据保存到MySql,一整套的完整实现,利用selenium神器得到cookies估计中简答粗暴的方式是永久不过期的,据说,bobby老师后面也是结合selenium登录的。
二、必须铭记于心的原理
scrapy爬取知乎问答网站这个项目的关键在于最开始登录,而登录无论我们采用何种方式,其关键在于cookies和session来保存我们的请求对话,毕竟我们爬虫模拟的就是浏览器请求数据。
所以我们要采用一种登录方式,然后拿到登录之后的cookies值,在后面的请求带上登录之后的cookies就可以在之后的请求中以登录状态请求了,加上scrapy请求会记录cookies,所以我们在只要在请求知乎首页带上一次cookies即可。
知道我们登录任务的关键在于记录登录后的cookies,那就我们想尽一切招来做这件时好了:
我目前使用过的保存cookies的方法:
1.urllib库request+cookielib(登录,保存cookies,读取cookies请求网页);
可以参照:Cookie登录爬取实战:Python 利用urllib库的cookie实现网站登录并抓取
2.直接复制浏览器的cookies,在代码中构建cookies值;
3.利用selenium模拟登录+保存cookies(本文采用第三中)
三、实现过程
3.1 利用selenium模拟浏览器登录(扫码方式)
3.2 将登录之后的cookies写入(保存成json文件)
3.3 在请求知乎首页前从json文件读取cookies构建dict类型的cookies
3.4 带上dict类型的cookies和headers请求知乎首页
3.5 爬取知乎首页问题和问题的回答
3.6 数据保存到MySql数据库中
当然,3.1-3.4 的实现过程也可以参照:菜鸟写Python实战:Scrapy完成知乎登录并保存cookies文件用于请求他页面(by Selenium),当然,本文将是一整个知乎问答的爬取过程代码
四、实现代码
环境:python3.6 + scrapy
3.1 登录和保存cookies
利用selenium模拟浏览器登录(扫码方式):selenium这个神器的真的太强大了,这里只要知道它是用来模拟浏览器即可。如何你想进一步了解,可以看这篇:菜鸟写Python-Selenium操作:Selenium登录豆瓣并获取cookies
def loginZhihu(self):
loginurl='https://www.zhihu.com/signin'
# 加载webdriver驱动,用于获取登录页面标签属性
driver=webdriver.Chrome()
driver.get(loginurl)
# 扫描二维码前,让程序休眠10s
time.sleep(10)
input("请点击并扫描页面二维码,手机确认登录后,回编辑器点击回车:")
# 获取登录后的cookies
cookies = driver.get_cookies()
driver.close()
# 保存cookies,之后请求从文件中读取cookies就可以省去每次都要登录一次的,也可以通过return返回,每次执行先运行登录方法
# 保存成本地json文件
jsonCookies=json.dumps(cookies)
with open('zhihuCookies.json','w') as f:
f.write(jsonCookies)
3.2 读取cookies和请求知乎首页
def start_requests(self):
# 首次要执行登录知乎,用来保存cookies,
# 之后的请求可以注释该方法,每次请求都做登录那真的很烦
self.loginZhihu()
# self.loginZhihu()
# 读取login保存的cookies值
with open('zhihuCookies.json','r',encoding='utf-8') as f:
listcookies=json.loads(f.read())
# 通过构建字典类型的cookies
cookies_dict = dict()
for cookie in listcookies:
cookies_dict[cookie['name']] = cookie['value']
# Tips 我们在提取cookies时,其实只要其中name和value即可,其他的像domain等可以不用
# yield发起知乎首页请求,带上cookies哦-->cookies=cookies_dict
yield scrapy.Request(url=self.start_urls[0],cookies=cookies_dict,headers=self.headers,dont_filter=True)
Tips: 我们scrapy框架初始请求时start_request()方式,然后在yield请求提交scrapy.Request()中,不写callback=会默认调用parse()方法,所以:
yield scrapy.Request(url=self.start_urls[0],cookies=cookies_dict,headers=self.headers)
等同于 yield scrapy.Request(url=self.start_urls[0],cookies=cookies_dict,headers=self.headers,callback=parse)
3.3 爬取知乎首页问答url,以便爬取每一问答内容
# 爬取知乎首页,这里我们要做的是讲所以是知乎问答的请求url筛选出来
def parse(self, response):
# 爬取知乎首页->内容TopstoryMain中的->所有urls
content_urls=response.css(".TopstoryMain a::attr(href)").extract()
url_list=[] # 保存全部url,并用来进一步提取是question的URl
for url in content_urls:
# 大部分的url都是不带主域名的,所以通过parse函数拼接一个完整的url
urls=parse.urljoin(response.url,url)
url_list.append(urls)
# 筛选所有urls中是知乎问答的urls(分析问答urls格式发现是:/question/xxxxxxxxx)
for url in url_list:
url=re.findall("(.*question/([\s\S]*?))(/|$)",url) # 正则表达式匹配问答url
if url:
# 如果提取到question的url则通过yield请求question解析方法进一步解析question
request_url=url[0][0] # 问答url
request_id=url[0][1] # 问答id
# 请求该问答详情页
yield scrapy.Request(url=request_url,headers=self.headers,callback=self.parse_detail_question,meta={"zhihu_id":request_id})
else: # 否则说明当前知乎页面中没有question,那么在此请求知乎首页,相当于刷新,直到出现question
yield scrapy.Request(url=response.url,headers=self.headers,callback=self.parse)
# 这两次的scrapy.Request()并没有带上cookies了,但实际它依然是登录后的状态,scrapy的作用
3.4 爬取一个具体的知乎问答内容
# 爬取并解析问答详细页面,同时请求该问答知乎用户的回答
def parse_detail_question(self,response):
start_answe_url= 'https://www.zhihu.com/api/v4/questions/{0}/answers?include=data%5B%2A%5D.is_normal%2Cadmin_closed_comment%2Creward_info%2Cis_collapsed%2Cannotation_action%2Cannotation_detail%2Ccollapse_reason%2Cis_sticky%2Ccollapsed_by%2Csuggest_edit%2Ccomment_count%2Ccan_comment%2Ccontent%2Ceditable_content%2Cvoteup_count%2Creshipment_settings%2Ccomment_permission%2Ccreated_time%2Cupdated_time%2Creview_info%2Crelevant_info%2Cquestion%2Cexcerpt%2Crelationship.is_authorized%2Cis_author%2Cvoting%2Cis_thanked%2Cis_nothelp%3Bdata%5B%2A%5D.mark_infos%5B%2A%5D.url%3Bdata%5B%2A%5D.author.follower_count%2Cbadge%5B%3F%28type%3Dbest_answerer%29%5D.topics%3Bdci_info&limit={1}&offset={2}&sort_by=default'
# 以下是知乎问答的每一个字段提取,通过css方法,这里就不详细注释了
time.sleep(1) # 进程休眠1s 防止请求太快,知乎给你掐了ip
zhihu_id=response.meta.get("zhihu_id","")
topics=response.css(".TopicLink>div>div::text").extract()
topics=",".join(topics)
url=response.url
title=response.css('.QuestionHeader-title::text').extract_first('')
# 只获取标签,不提取标签的文本,以便下一步做去标签处理
content=response.css('.QuestionHeader-detail span').extract_first("")
# from w3lib.html import remove_tags 去除标签,留下文本
content=remove_tags(content).strip()
answer_num=response.css(".List-headerText>span::text").extract_first("").strip()
# 匹配所有的数字,然后拼接成一起,因为大于三位数的回答数中有,隔开
answer_nums=re.findall('([\d])',answer_num)
answer_num=''
for answer in answer_nums:
answer_num+=answer
comment_num=response.css(".QuestionHeader-Comment").extract_first('')
if "评论" in comment_num: # 判断是否有评论,可能一些问答没有评论
comment_num=response.css(".QuestionHeader-Comment>button::text").extract_first('')
comment_num=re.findall('([\s\S]*?)条评论',comment_num)[0].strip()
watch_click=response.css(".NumberBoard-itemValue::text").extract()
if len(watch_click[0])>3: # 去除大于3位数多出来的逗号'1,234'
watch_user_num = watch_click[0]
watch_user_nums = re.findall('([\d])', watch_user_num)
watch_user_num = ''
for i in watch_user_nums:
watch_user_num += i
else :
watch_user_num=watch_click[0]
if len(watch_click[1]) > 3: # 去除大于3位数多出来的逗号'1,234'
click_num = watch_click[0]
click_nums = re.findall('([\d])', click_num)
click_num = ''
for i in click_nums:
click_num += i
else:
click_num = watch_click[1]
# 获取当前的时间,并把datetime转换成字符
crawl_time = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# 传给Item
questionItem=ZhihuQuestionItem()
questionItem['zhihu_id']=zhihu_id
questionItem['topics'] = topics
questionItem['url'] = url
questionItem['title'] = title
questionItem['content'] = content
questionItem['answer_num'] = answer_num
questionItem['comment_num'] = comment_num
questionItem['watch_user_num'] = watch_user_num
questionItem['click_num'] = click_num
questionItem['crawl_time'] = crawl_time
yield questionItem
# 请求知乎相应回答数据,根据分析出的api接口发起请求,得到是json数据
# start_answe_url.format(zhihu_id,20,0),请求url中有三个要传参数通过format()给
yield scrapy.Request(url=start_answe_url.format(zhihu_id,20,0),headers=self.headers,callback=self.parse_answer)
3.5 爬取相应知乎问答的用户回答(这是一个api接口,可以根据页面加载更多分析得到)
# 请求知乎问答的用户回答,api接口,返回是一个json数据
def parse_answer(self,response):
answer_data=json.loads(response.text)
is_end=answer_data['paging']['is_end'] # 判断是否还有下一页
for data in answer_data['data']: # 解析json中相应想要爬取的数据
zhihu_id=data['id']
url=data['url']
question_id=data['question']['id']
author_id=data['author']['id'] if 'id' in data['author'] else None
content = data['content']
praise_num = data['voteup_count']
comments_num = data['comment_count']
# 将获取时间格式为1530878188数字串转换成时间格式
create_time = datetime.datetime.fromtimestamp(data['created_time'])
update_time = datetime.datetime.fromtimestamp(data['updated_time'])
crawl_time= datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') #同样转成str
# 保存Item
answerItem = ZhihuAnswerItem()
answerItem['zhihu_id'] = zhihu_id
answerItem['url'] = url
answerItem['question_id'] = question_id
answerItem['author_id'] = author_id
answerItem['content'] = content
answerItem['praise_num'] = praise_num
answerItem['comments_num'] = comments_num
answerItem['update_time'] = update_time
answerItem['create_time'] = create_time
answerItem['crawl_time'] = crawl_time
yield answerItem
if not is_end: # 如果不是下一页,则带上next_url回调该方法
next_url=answer_data['paging']['next']
yield scrapy.Request(url=next_url,headers=self.headers,callback=self.parse_answer)
根据api请求返回的json数据:
3.6 将爬取的数据库保存到MySQL数据库中
scrapy库的执行过程是,将数据提交给itme,然后传给pipeline进行其他操作,如保存文件,转存到mysql数据库。
所以,这一步的核心在于在pipeline保存数据到mysql数据库中,但是这一步的关键在于我们有两个非同时yield出去的item,在pipeline中,我们无法同时处理这个两个item,即使你写两个pipeline分别处理,虽然可以入库,但是依然完美(虽然我干过,体验很不好),其实bobby老师在课程中给了一种非常好的方法,即将tem不同的处理操作,在各自的item中处理,在pipeline中只做相同的事。
讲了一堆自己啰嗦不清晰的后,还是直接来说下转存mysql的操作,可能会好理解一些。
第一步:在Item中,新增初入数据库的方法,这里只是构建sql语句和准备sql要插入的参数,然后return回pipeline执行数据库操作(其实这个便是每个item的特殊操作,通过调用这个pipeline就可以根据相应请求对应的插入操作),下面只是问答item,回答item其实结构是一样的。
# 知乎问答的item
class ZhihuQuestionItem(scrapy.Item):
zhihu_id=scrapy.Field()
topics=scrapy.Field()
url=scrapy.Field()
title=scrapy.Field()
content=scrapy.Field()
answer_num=scrapy.Field()
comment_num=scrapy.Field()
watch_user_num=scrapy.Field()
click_num=scrapy.Field()
crawl_time=scrapy.Field()
# 这个是插入语句,1 构建insert语句, 2 准备insert要的参数
def get_sql(self):
sql="""
insert into question (zhihu_id,topics,url,title,content,answer_num,comment_num,watch_user_num,click_num,crawl_time)
values (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s)
on DUPLICATE key update content=values(content),answer_num=values(answer_num),comment_num=values(comment_num),
watch_user_num=values(watch_user_num),click_num=values(click_num)
"""
params=(self['zhihu_id'],self['topics'],self['url'],self['title'],self['content'],self['answer_num'],self['comment_num'],self['watch_user_num'],self['click_num'],self['crawl_time'])
# 并将insert语句和参数返回(即返回到了pipeline中)
return sql,params
第二步,在pipeline中新建连接mysql数据执行数据库操作的pipeline
# 处理不同Item进行异步执行数据库操作
class ProcessMysqlPipeline(object):
# 数据连接
def __init__(self):
params=dict(
host='127.0.0.1',
db='zhihu',
user='root',
passwd='upassword',
charset='utf8mb4',
cursorclass=MySQLdb.cursors.DictCursor,
use_unicode=True
)
self.dbpool=adbapi.ConnectionPool("MySQLdb",**params)
#sql操作
def do_sql(self,cursor,item):
# 获取sql语句和参数,这个就是调用相应item的get_sql(),这样就能实现不同item各自的插入
sql,params=item.get_sql()
# 执行数据库语句
cursor.execute(sql,params)
#处理错误
def handle_error(self,failure,item,spider):
print(failure)
# Pipeline发起执行语句
def process_item(self,item,spider):
# 使用twisted框架,将mysql操作变成异步
query=self.dbpool.runInteraction(self.do_sql,item)
query.addErrback(self.handle_error,spider,item)
return item
通过,上面处理就可以实现各自item执行数据插入,pipeline写完记得要到setting中配置一下,不然就等于白写了,不执行。
转载请注明出处哦。
上面的过程就可以实现知乎问答爬取了,代码基本如上,欢迎一起交流和学习,如果有错误欢迎指正呀,如果需要源码,欢迎加微信:第一行Python代码,一起学习python。