PyQt5和Scrapy整合解决方案
整体思路:在PyQt5窗口类中开一个进程运行Scrapy代码,并使用Queue进行通信。
项目文件下载
后台回复"整合"获取项目文件:
实现爬虫功能
首先是Scrapy,笔者使用以下命令创建项目:
scrapy startproject books
cd books
scrapy genspider book books.toscrape.com
该项目对books.toscrape.com网站上的书籍信息作了简单的爬取,以下是爬虫文件book.py的内容:
# -*- coding: utf-8 -*-
import scrapy
from books.items import BooksItem
class BookSpider(scrapy.Spider):
name = 'book'
allowed_domains = ['books.toscrape.com']
# start_urls = ['http://books.toscrape.com/']
def start_requests(self):
print('开始爬取')
for page_num in range(1, 51):
url = 'http://books.toscrape.com/catalogue/page-%d.html' % page_num
yield scrapy.Request(url)
def parse(self, response):
for book in response.xpath('//article[@class="product_pod"]'):
# 初始化Item
items = BooksItem()
# 书本标题
items['title'] = book.xpath('./h3/a/@title').extract_first()
# 书本价格
items['price'] = book.xpath('./div/p[@class="price_color"]/text()').extract_first()
# 书本评级
review = book.xpath('./p[1]/@class').extract_first()
items['review'] = review.split(' ')[-1]
print(f"{items['title']}\n{items['price']}\n{items['review']}\n")
yield items
def close(spider, reason):
print('爬取结束')
以下是items.py内容:
# -*- coding: utf-8 -*-
# Define here the models for your scraped items
#
# See documentation in:
# https://docs.scrapy.org/en/latest/topics/items.html
import scrapy
class BooksItem(scrapy.Item):
# define the fields for your item here like:
# name = scrapy.Field()
title = scrapy.Field()
price = scrapy.Field()
review = scrapy.Field()
为使讲解尽可能简洁,其余文件保持不变。
完成界面布局
接着我们在books项目文件夹中新建一个gui.py,并用PyQt5来编写一个GUI界面:
代码如下:
import sys
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QLineEdit, QPushButton, \
QTextBrowser, QComboBox, QHBoxLayout, QVBoxLayout
class Demo(QWidget):
def __init__(self):
super(Demo, self).__init__()
self.setWindowTitle('PyQt5与Scrapy')
self.ua_line = QLineEdit(self) # 输入USER_AGENT
self.obey_combo = QComboBox(self) # 选择ROBOTSTXT_OBEY
self.obey_combo.addItems(['是', '否'])
self.log_browser = QTextBrowser(self) # 日志输出框
self.crawl_btn = QPushButton('开始爬取', self) # 开始爬取按钮
# 布局
self.h_layout = QHBoxLayout()
self.v_layout = QVBoxLayout()
self.h_layout.addWidget(QLabel('输入User-Agent'))
self.h_layout.addWidget(self.ua_line)
self.h_layout.addWidget(QLabel('是否遵循Robot协议'))
self.h_layout.addWidget(self.obey_combo)
self.v_layout.addLayout(self.h_layout)
self.v_layout.addWidget(QLabel('日志输出框'))
self.v_layout.addWidget(self.log_browser)
self.v_layout.addWidget(self.crawl_btn)
self.setLayout(self.v_layout)
if __name__ == '__main__':
app = QApplication(sys.argv)
demo = Demo()
demo.show()
sys.exit(app.exec_())
界面显示如下:
用户可以输入User-Agent以及选择是否遵循爬虫协议,这些都会应用到BookSpider上。而日志输出框则会显示BookSpider中打印的内容。
整合PyQt5和Scrapy代码
我们先完成“开始爬取”按钮的功能,当用户按钮该按钮后,爬虫启动。首先把信号和槽进行连接:
...
self.crawl_btn = QPushButton('开始爬取', self) # 开始爬取按钮
self.crawl_btn.clicked.connect(self.crawl_slot)
...
槽函数crawl_slot实现如下:
def crawl_slot(self):
if self.crawl_btn.text() == '开始爬取':
self.log_browser.clear()
self.crawl_btn.setText('停止爬取')
ua = self.ua_line.text().strip()
is_obey = True if self.obey_combo.currentText() == '是' else False
self.p = Process(target=..., args=(...))
self.p.start()
else:
self.crawl_btn.setText('开始爬取')
self.p.terminate()
在槽函数中我们通过判断按钮文本来启动或停止进程。如果按钮文本为“开始爬取”,则清空日志输出框、改变按钮文本、获取用户输入的User-Agent以及选择项,接着创建一个进程并启动。如果文本为“停止爬取”,则直接终止进程。注意这里要在文件开头导入相应的类:
from multiprocessing import Process
现在我们要考虑的是进程实例化时的target参数和args参数。我们知道启动Scrapy爬虫的方式有两种:一种是通过命令行,另一种是脚本。笔者建议使用后者。
而在脚本启动方面,Scrapy提供了两种方式,一种是使用CrawlerProcess,另一种是CrawlerRunner。可以查看Scrapy文档。
# CrawlerProcess
import scrapy
from scrapy.crawler import CrawlerProcess
class MySpider(scrapy.Spider):
# Your spider definition
...
process = CrawlerProcess(settings={
'FEED_FORMAT': 'json',
'FEED_URI': 'items.json'
})
process.crawl(MySpider)
process.start() # the script will block here until the crawling is finished
# CrawlerRunner
from twisted.internet import reactor
import scrapy
from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
class MySpider(scrapy.Spider):
# Your spider definition
...
# configure_logging可有可无,读者可自己运行代码了解该函数用处。
# 在CrawlerProcess中,该函数默认使用。
configure_logging({'LOG_FORMAT': '%(levelname)s: %(message)s'})
runner = CrawlerRunner()
d = runner.crawl(MySpider)
d.addBoth(lambda _: reactor.stop())
reactor.run() # the script will block here until the crawling is finished
经笔者测试,两种方法都能用于整合。
我们接下来要做的就是在gui.py中写一个crawl函数(注意不能作为任何PyQt5相关类的成员函数,否则运行闪退):
def crawl(Q, ua, is_obey):
# CrawlerProcess
process = CrawlerProcess(settings={
'USER_AGENT': ua,
'ROBOTSTXT_OBEY': is_obey
})
process.crawl(BookSpider, Q=Q)
process.start()
# CrawlerRunner
"""runner = CrawlerRunner(settings={
'USER_AGENT': ua,
'ROBOTSTXT_OBEY': is_obey
})
d = runner.crawl(BookSpider, Q=Q)
d.addBoth(lambda _: reactor.stop())
reactor.run()"""
在crawl函数中,笔者选择CrawlerProcess来启动Scrapy代码。我们看到该函数一共有三个参数:Q,ua和is_obey。
ua和is_obey这里就不讲了。Q用于进程间通信,我们会通过runner.crawl函数将其传给BookSpider,此时需要对BookSpider类作两处修改:
1. 增加一个类变量Q:
class BookSpider(scrapy.Spider):
name = 'book'
allowed_domains = ['books.toscrape.com']
# start_urls = ['http://books.toscrape.com/']
Q = None
...
2. 将print改为Q.put():
self.Q.put('开始爬取')
self.Q.put(f"{items['title']}\n{items['price']}\n{items['review']}\n")
spider.Q.put('爬取结束')
现在我们来完善Demo窗口类。首先实例化一个Q成员变量:
self.Q = Manager().Queue()
记得导入Manager类:
from multiprocessing import Process, Manager
然后完成进程实例化代码:
self.p = Process(target=crawl, args=(self.Q, ua, is_obey))
此时我们运行代码,点击按钮,发现BookSpider正常运行:
但是发现一个问题,日志输出框中并没有显示Q队列中的消息。此时想到我们应该用一个进程或者线程来持续读取Q队列中的消息并将其显示到日志输出框上。笔者这里创建了一个LogThread线程类:
class LogThread(QThread):
def __init__(self, gui):
super(LogThread, self).__init__()
self.gui = gui
def run(self):
while True:
if not self.gui.Q.empty():
self.gui.log_browser.append(self.gui.Q.get())
# 确保滑动条到底
cursor = self.gui.log_browser.textCursor()
pos = len(self.gui.log_browser.toPlainText())
cursor.setPosition(pos)
self.gui.log_browser.setTextCursor(cursor)
if '爬取结束' in self.gui.log_browser.toPlainText():
self.gui.crawl_btn.setText('开始爬取')
break
# 睡眠10毫秒,否则太快会导致闪退或者显示乱码
self.msleep(10)
可以看到在run函数中有一个while循环,在循环中我们首先判断队列是否为空,如果不是,则取出队列中的一条消息并显示到日志框上。最后如果日志框上出现“爬取结束”字样,则修改按钮文本并退出循环。注意这里一定要进行睡眠,否则QTextBrowser无法很好的显示队列内容,而且常常会一下子显示一大段队列消息从而导致闪退。
最后我们再在Demo窗口类中实例化好线程类,并修改下按钮槽函数即可:
...
self.log_thread = LogThread(self)
def crawl_slot(self):
if self.crawl_btn.text() == '开始爬取':
...
self.log_thread.start()
else:
...
self.log_thread.terminate()
现在运行发现日志输出框有内容了:
gui.py全部代码如下:
import sys
from PyQt5.QtCore import QThread
from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QLineEdit, QPushButton, \
QTextBrowser, QComboBox, QHBoxLayout, QVBoxLayout
from books.spiders.book import BookSpider
from twisted.internet import reactor
from scrapy.crawler import CrawlerRunner
from multiprocessing import Process, Manager
from scrapy.crawler import CrawlerProcess
def crawl(Q, ua, is_obey):
# CrawlerProcess
process = CrawlerProcess(settings={
'USER_AGENT': ua,
'ROBOTSTXT_OBEY': is_obey
})
process.crawl(BookSpider, Q=Q)
process.start()
# CrawlerRunner
"""runner = CrawlerRunner(settings={
'USER_AGENT': ua,
'ROBOTSTXT_OBEY': is_obey
})
d = runner.crawl(BookSpider, Q=Q)
d.addBoth(lambda _: reactor.stop())
reactor.run()"""
class Demo(QWidget):
def __init__(self):
super(Demo, self).__init__()
self.setWindowTitle('PyQt5与Scrapy')
self.ua_line = QLineEdit(self) # 输入USER_AGENT
self.obey_combo = QComboBox(self) # 选择ROBOTSTXT_OBEY
self.obey_combo.addItems(['是', '否'])
self.log_browser = QTextBrowser(self) # 日志输出框
self.crawl_btn = QPushButton('开始爬取', self) # 开始爬取按钮
self.crawl_btn.clicked.connect(self.crawl_slot)
# 布局
self.h_layout = QHBoxLayout()
self.v_layout = QVBoxLayout()
self.h_layout.addWidget(QLabel('输入User-Agent'))
self.h_layout.addWidget(self.ua_line)
self.h_layout.addWidget(QLabel('是否遵循Robot协议'))
self.h_layout.addWidget(self.obey_combo)
self.v_layout.addLayout(self.h_layout)
self.v_layout.addWidget(QLabel('日志输出框'))
self.v_layout.addWidget(self.log_browser)
self.v_layout.addWidget(self.crawl_btn)
self.setLayout(self.v_layout)
self.Q = Manager().Queue()
self.log_thread = LogThread(self)
def crawl_slot(self):
if self.crawl_btn.text() == '开始爬取':
self.log_browser.clear()
self.crawl_btn.setText('停止爬取')
ua = self.ua_line.text().strip()
is_obey = True if self.obey_combo.currentText() == '是' else False
self.p = Process(target=crawl, args=(self.Q, ua, is_obey))
self.p.start()
self.log_thread.start()
else:
self.crawl_btn.setText('开始爬取')
self.p.terminate()
self.log_thread.terminate()
def closeEvent(self, event):
self.p.terminate()
self.log_thread.terminate()
class LogThread(QThread):
def __init__(self, gui):
super(LogThread, self).__init__()
self.gui = gui
def run(self):
while True:
if not self.gui.Q.empty():
self.gui.log_browser.append(self.gui.Q.get())
# 确保滑动条到底
cursor = self.gui.log_browser.textCursor()
pos = len(self.gui.log_browser.toPlainText())
cursor.setPosition(pos)
self.gui.log_browser.setTextCursor(cursor)
if '爬取结束' in self.gui.log_browser.toPlainText():
self.gui.crawl_btn.setText('开始爬取')
break
# 睡眠10毫秒,否则太快会导致闪退或者显示乱码
self.msleep(10)
if __name__ == '__main__':
app = QApplication(sys.argv)
demo = Demo()
demo.show()
sys.exit(app.exec_())
注意事项
在Mac系统上,请加上'HTTPPROXY_ENABLED': False这个配置,否则无法运行:
def crawl(Q, ua, is_obey):
# CrawlerProcess
process = CrawlerProcess(settings={
'USER_AGENT': ua,
'ROBOTSTXT_OBEY': is_obey,
# 'HTTPPROXY_ENABLED': False
# 在Mac系统上,请加上'HTTPPROXY_ENABLED': False
})
process.crawl(BookSpider, Q=Q)
process.start()
# CrawlerRunner
"""runner = CrawlerRunner(settings={
'USER_AGENT': ua,
'ROBOTSTXT_OBEY': is_obey
# 'HTTPPROXY_ENABLED': False
# 在Mac系统上,请加上'HTTPPROXY_ENABLED': False
})
d = runner.crawl(BookSpider, Q=Q)
d.addBoth(lambda _: reactor.stop())
reactor.run()"""
打包
请看笔者的《PyInstaller打包实战指南》:https://blog.csdn.net/La_vie_est_belle/article/details/96321995
欢迎关注我的微信公众号,发现更多有趣内容: