- 创建项目
- 第一个小demo
- 在Linux环境(虚拟机)下使用Docker配置NoSQL
- 获取请求中的数据
- 不遵守robots协议
- scrapy整合Playwright
- 线程池
- 规则化爬虫
- 数据存储
- 分布式爬虫
- 爬虫管理和部署之使用Docker Compose
学习动机
我想写一个爬热点新闻的爬虫项目,最好能满足
- 规则化爬虫爬多个网站
- 结合docker和docker-copmpose和k8s进行分布式部署
- 具有代理池
- 充分使用Scrapy框架
- 可以结合一些NoSQL进行存储
- 作为一个服务方便调用(Flask)
创建项目
我是用conda创建的虚拟环境,这个自己去配去
创建环境conda create -n envName python=3.10
进入环境conda activate envName
安装scrapypip install scrapy
创建项目scrapy startproject newsCrawler
newsCrawler是我的项目名
跟着他的提示
cd newsCrawler
scrapy genspider example example.com
这个example是一个你的Spider类,他会作为example.py在spiders文件夹里,这个类设置的爬取的网站是example.com,这个可以自己再改的,所以无脑用就行
第一个小demo
爬取的是百度热搜
- 定义新闻数据实体,在items.py中
import scrapy class NewsItem(scrapy.Item): title = scrapy.Field() url = scrapy.Field() time = scrapy.Field()
- 修改spiders文件夹内的
exampleSpider.py
为baiduSpider.py
,修改允许爬取的域名和起始URL# 部分代码 class BaiduSpider(scrapy.Spider): name = 'baidu' allowed_domains = ['top.baidu.com'] start_urls = ['https://top.baidu.com/board?tab=realtime']
- 分析网站结构,编写解析网站元素得到目标数据的爬虫代码
# 这个代码和上面的是一块的,这个parse就是BaiduSpider的类方法 def parse(self, response): news_list = response.xpath('//*[@id="sanRoot"]/main/div[2]/div/div[2]/div[position()>=1]') for news in news_list: item = NewsItem() item['url'] = news.xpath('./div[2]/a/@href').get() item['title'] = news.xpath('./div[2]/a/div[1]/text()').get() yield item
- 在
settings.py
中配置以UTF-8
导出,不然中文会以unicode
字符显示。(似乎也可以在Spider中写编码或者用Pipeline中编码并导出,但我就是用的命令导出的,所以没考虑那两个)
这个配置随便找一行放上去即可,居左# 导出数据时以UTF-8编码导出 FEED_EXPORT_ENCODING='UTF-8'
- 编写启动类
其实我们这里是用python执行命令行代码,这样会方便很多
创建一个main.py
from scrapy.cmdline import execute if __name__ == '__main__': # 同在cmd中输入 scrapy crawl baidu -o baidu_news.json # 注意第一个baidu是被执行的spider的name # 如果不想打印日志,可以再加个'--nolog' execute(['scrapy', 'crawl', 'baidu','-o','baidu_news.json'])
在Linux环境(虚拟机)下使用Docker配置NoSQL和MQ
获取请求中的数据
https://news.qq.com/
在控制台中发现数据是从接口中获得的
请求网址:https://i.news.qq.com/trpc.qqnews_web.kv_srv.kv_srv_http_proxy/list?sub_srv_id=24hours&srv_id=pc&offset=0&limit=20&strategy=1&ext={%22pool%22:[%22top%22],%22is_filter%22:7,%22check_type%22:true}
请求方法:GET
携带数据:
貌似没有加密参数,即没有采取反爬,于是不准备模拟浏览器,而是直接爬取请求
import json
from urllib.parse import urlencode
import scrapy
from scrapy import Request
from newsCrawler.items import NewsItem
class TencentSpider(scrapy.Spider):
name = 'tencent'
allowed_domains = ['news.qq.com/']
base_url = 'https://i.news.qq.com/trpc.qqnews_web.kv_srv.kv_srv_http_proxy/list'
def start_requests(self):
query = {
'sub_srv_id': '24hours',
'srv_id': 'pc',
'offset': 0,
'limit': 20,
'strategy': 1,
'ext': json.dumps({
'pool': ['top'],
'is_filter': 7,
'check_type': True
})
}
str_query = urlencode(query)
url = self.base_url + "?" + str_query
yield Request(url=url,method='GET')
def parse(self, response):
json_data = json.loads(response.text)
news_list = json_data.get('data').get('list')
for news in news_list:
item = NewsItem()
item['title'] = news.get('title')
item['url'] = news.get('url')
yield item
不遵守robots协议
突然看到一个汇总了各个新闻网链接的网站
http://www.hao123.com/newswangzhi
结果爬不动,发现robots协议是这样的:
User-agent: Baiduspider
Allow: /
User-agent: Baiduspider-image
Allow: /
User-agent: Baiduspider-news
Allow: /
User-agent: Googlebot
Allow: /
...
User-agent: *
Disallow: /
意思就是除了上面这些,其他的都不能爬,所以要禁用robots协议
在settings.py中将该参数由True修改为False
# Obey robots.txt rules
ROBOTSTXT_OBEY = False
这个的代码就不放了,没啥含金量
robots协议是让搜索引擎判断这个页面是否允许被抓取的,所以我们自己的爬虫练习还是可以把他关掉的
scrapy整合Playwright
https://news.sina.com.cn/roll/#pageid=153&lid=2509&k=&num=50&page=1
这个新浪的滚动网站
通过观察发现也是一个用接口获得数据的,其实这个从他每一分钟就异步刷新一次就知道
但是下面这三个参数发现是随时间变化的,因为还没到能逆向的程度,所以直接选用模拟浏览器的操作进行爬取,选用的是Playwright
先写个demo
安装Playwright
注意第二行是为我们安装浏览器及驱动及配置
pip3 install playwright
playwright install
不得不说这个网站也太离谱了,返回结果居然不是一个json,花了半天弄出格式化的json字符串切片
下面只是一段测试代码,未接入scrapy
import json
from playwright.sync_api import sync_playwright
def on_response(response):
# 找到这个接口
if '/api/roll/get' in response.url and response.status == 200:
print(f'Statue {response.status}:{response.url}')
# 强行截取格式化的json
json_str = response.text()[46:-14]
# 字符串转json,注意字符串是loads,文件时load
json_data = json.loads(json_str)
news_list = json_data.get('result').get('data')
for news in news_list:
url = news.get('url')
title = news.get('title')
print(f'{url} {title}')
with sync_playwright() as p:
for browser_type in [p.chromium]:
# 以无头模式打开谷歌浏览器
browser = browser_type.launch(headless=True)
page = browser.new_page()
# 绑定监听response事件
page.on('response',on_response)
page.goto('https://news.sina.com.cn/roll/#pageid=153&lid=2509&k=&num=50&page=1')
# 等待网络请求结束、空闲下来
page.wait_for_load_state(state='networkidle')
browser.close()
接入Scrapy
我们直接导入崔庆才大大的Gerapy Playwright的包,这个包整合了Scrapy和Playwright
三行代码,轻松实现 Scrapy 对接新兴爬虫神器 Playwright!
GIthub项目地址
但是会有多个问题
-
This package does not work on Windows
,所以不能在windows上运行,会报NotImplementedError
,我索性在虚拟机上安装了anaconda并运行项目,步骤如下:- 安装
官网链接
参考博客:centos7 安装Anaconda3 亲测成功
注意安装过程中这个路径是anaconda的文件夹路径,这里是我自己手动输入的
- 导出项目依赖
pip install pipreqs
pipreqs ./ --encoding utf-8
- 把项目发到虚拟机上,使用conda创建虚拟环境Conda 创建和删除虚拟环境 ,进入虚拟环境
pip install -r requirements.txt
安装依赖,然后在虚拟环境下用命令行运行该Spider,然后进入第二个坑
- 安装
-
scrapy报错twisted.internet.error.ReactorAlreadyInstalledError: reactor already installed,报错见标题,方法见博客
-
出现gzip.BadGzipFile: Not a gzipped file (b’<!') 的解决办法。这个似乎是崔大大没弄好,跟响应头里有gzip有关,观察发现确实这个响应是这样的
仓库Issues里有人给了解决方案:去掉一个中间件,即
然后项目就能起来了import scrapy from gerapy_playwright import PlaywrightRequest from newsCrawler.items import NewsItem class SinaSpider(scrapy.Spider): name = 'sina' allowed_domains = ['news.sina.com.cn'] base_url = 'https://news.sina.com.cn/roll/#pageid=153&lid=2509&k=&num=50&page=1' def start_requests(self): # 大佬的包,解决了爬取页面的问题 yield PlaywrightRequest( self.base_url, wait_until='domcontentloaded', callback=self.parse, # 注意这里要设个等待时间,等ajax数据显示在网页上 sleep=0.5 ) def parse(self, response): # 第一层是一些ul news_1_list = response.xpath('/html/body/div[1]/div[1]/div[2]/div[3]/div[2]/div/ul[position()>=1]') for news_1 in news_1_list: # 第二层是一些li news_2_list = news_1.xpath('./li[position()>=1]') print(len(news_2_list)) # 每个li都是一个新闻 for news in news_2_list: item = NewsItem() item['title'] = news.xpath('./span[2]/a/text()').get() item['url'] = news.xpath('./span[2]/a/@href').get() yield item
代理池
怎么说呢,崔大大讲的大部分思想都能看懂,但是代码就看不懂了。。。
所以直接拿来用了!
仓库地址
安装并运行过程:(Docker-Compose版)
- 准备好Docker和Docker-Compose Linux下Docker安装几种NoSQL和MQ和乱七八糟的
- 把项目down下来
git clone https://github.com/Python3WebSpider/ProxyPool.git
cd ProxyPool
- 使用docker-compose运行
docker-compose up -d
注意这里可能会重试很多次,但是总还是会成功的,要等久一会,然后就是不断的命令行输出对代理的爬取、判活之类的消息了 - 尝试获取IP
http://IP:5555/random
仓库里也有个用requests获取代理池内代理的example,我都没想过这个。。。第一反应就是Flask,太傻了
规则化爬虫
这章的内容是真的多,一看吓死人,再一看稍微好一点
主要多了几个东西
ItemLoader
这个就是包装了你的自定义Item,同时它的子类可以灵活定义数据存入取出时的逻辑
拿爬宣讲家网举个涉及知识点较少、适合入门的例子:
NewsItem是一个Item
我们进入详情页,注意是详情页!!!class NewsItem(scrapy.Item): title = scrapy.Field() url = scrapy.Field() # 新闻媒体 media = scrapy.Field()
观察发现我们需要的内容,即标题和媒体,他们都只出现一次,所以我们定义一下在该页面的读取规则,因为待会用xpath之类的选择器读取的时候不能用extract_first这类的东西了,只能写selector
定义一个为该Item定制的Loaderfrom scrapy.loader import ItemLoader from itemloaders.processors import TakeFirst,Join,Compose class NewsItemLoader(ItemLoader): # 默认类中全部变量都只是该页面第一次匹配的结点的数据,且去除左右空格 default_output_processor = Compose(TakeFirst(),str.strip) # 也可以如下 # title_out = Compose(TakeFirst(),str.strip) # url_out = Compose(TakeFirst(), str.strip)
- 首先这里能定义in和out,即数据从页面提取并放入loader和从loader拿出到item中的两个阶段都能进行处理,我这里只处理了out
- 然后发现这里似乎是通过后缀来判断的,即是否为_in还是_out
- 我的第一行是配置的item中全部变量的规则,我们其实可以在下面对某个变量重新赋予规则,覆盖这个全局规则的
- 常用规则是:不进行处理
Identity
,匹配到的结果的第一个非空值TakeFirst
,将结果通过某种分隔符拼接Join
,组合多个函数Compose
、处理jsonSelectJmes
。还有一些东西可以看看关于Scrapy ItemLoader、MapCompose、Compose、input_processor与output_processor的一些理解
以上,进行完了详情页面的解析
CrawlSpider
这个类是Spider
类的子类,你暂时可以理解他比Spider多了个rules
元组,里面放了很多Rule
对象,他们包含了在列表页面找到新闻链接和翻页按钮的规则、找到链接后爬取完详情页html后的回调函数等内容。就是说我们现在是在进行新闻列表的解析,即在列表页面获取想要的新闻的链接,因为我们是要通过这些链接获取详情页html的嘛
3.LinkExtractor
上面那个没代码是因为他的Rule对象的配置的规则实际上是配在这个类里的。这个类参数都是一些选择器、域名黑白名单、后缀黑名单等内容
注意一下CrawlSpider
也是可以通过genspider
进行生成的,他有几个模板,默认模板的其实就是我们之前用的那个。我们这次选择
然后我们再稍微改下speaker.pyscrapy genspider -t crawl speaker www.71.cn
运行可出结果,但是我大意了,有些详情页面结构不一样的,不过无伤大雅from scrapy.linkextractors import LinkExtractor from scrapy.spiders import CrawlSpider, Rule from newsCrawler.items import NewsItem from newsCrawler.loaders import NewsItemLoader class SpeakerSpider(CrawlSpider): name = 'speaker' allowed_domains = ['www.71.cn'] start_urls = ['http://www.71.cn/'] rules = ( Rule(LinkExtractor(restrict_xpaths='/html/body/div[8]/div[1]/div[2]/div[2]/div/ul/li[position()>=1]/a',attrs='href'), callback='parse_detail'), # 因为只是简短demo我就没找有分页的网站了,分页就是如下,只会进行跳转而不会调用回调函数 # Rule(LinkExtractor(restrict_css='.next')) ) def parse_detail(self, response): # 包装item loader = NewsItemLoader(item=NewsItem(),response=response) loader.add_value('url',response.url) loader.add_xpath('title','//*[@id="main"]/div/div[2]/div[1]/div[1]/h1/text()') loader.add_xpath('media','//*[@id="main"]/div/div[2]/div[1]/div[1]/div[1]/span[2]/text()') yield loader.load_item()
真正规则化
为啥上面弄了什么loader、Rule这些东西啊,仔细看下,他们把爬取列表上的链接、爬取结点的选择器、结点的in-out规则都分开了,且都是对象或字符串的形式,而不是用extract_first之类的方法进行操作,我们完全可以把他们放进配置文件里头啊!我们定义一个通用Spider,它会获取要调用哪个配置文件,再使用这些配置进行爬取,这样就可以大大提高项目的可维护性了
具体代码我就不写了,因为我项目暂时不太大,然后有些地方不是单纯用配置文件抽取变量就能解决的,比如scrapy-playwrigh
t那边,所以只留个思想在这吧
数据存储
这个只涉及ItemPipeline
我这里也只演示存储进redis
- 首先是安装redis,这里继续参考Linux下Docker安装几种NoSQL和MQ和乱七八糟的
- 安装redis包以便操作redis
pip3 install redis
- 在settings.py中配置参数
REDIS_HOST = '192.168.192.129' REDIS_PORT = 6379 REDIS_DB_INDEX = 0 REDIS_PASSWORD ="root" ITEM_PIPELINES = { 'scrapyRedisDemo.pipelines.RedisPipeline': 300, }
- 编写pipeline
from redis.client import StrictRedis from redis.connection import ConnectionPool class RedisPipeline(object): def open_spider(self, spider): # 第一个参数是settings.py里的属性,第二个参数是获取不到值的时候的替代值 host = spider.settings.get("REDIS_HOST") port = spider.settings.get("REDIS_PORT") db_index = spider.settings.get("REDIS_DB_INDEX") db_psd = spider.settings.get("REDIS_PASSWORD") # 连接数据库 pool = ConnectionPool(host=host, port=port, db=db_index, password=db_psd) self.db_conn = StrictRedis(connection_pool=pool) def process_item(self, item, spider): self.db_conn.rpush("news", item['title']) return item def close_spider(self, spider): # 关闭连接 self.db_conn.connection_pool.disconnect()
- 检验是否放入
我是用redisinsight可视化工具查看的,但是好像图里有涉及国家政治方面的内容所以图挂了。不再贴了
分布式爬虫
这里使用的是scrapy-redis
包。当然也可以用消息队列,但我没用。
其实单机scrapy就内置了一个队列存放Request,并由调度器拿取Request,同时他还内置了去重、中断时记录上下文等功能。
但是实现分布式的话,肯定不能用内置的这些队列和功能,这些逻辑应该放到分布式中间件上
Scrapy-Redis解析
首先内置了三种集合:队列、栈、有序集合
然后实现了去重,即将item的hash值作为指纹,同时指纹用set去重存储,每次存入item前先查看是否存入指纹成功,成功则存入item,否则不存入
然后也实现了中断时记录上下文
Scpray-Redis的demo
爬取的是4399最新小游戏
#发现一个东西,记录一下
# 注意这里的操作,可以提取一个大标签下所有标签内部的文本,在爬内容或者正文时很重要
temp = response.xpath('........')
item['content'] = temp.xpath('string(.)').extract()[0]
# BT.py
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from scrapyRedisDemo.items import FilmItem
from scrapy.linkextractors import LinkExtractor
from scrapy.spiders import CrawlSpider, Rule
from scrapyRedisDemo.items import GameItem
class Spider4399(CrawlSpider):
name = '4399'
allowed_domains = ['www.4399.com']
start_urls = ['https://www.4399.com/flash/']
rules = (
Rule(LinkExtractor(restrict_xpaths='/html/body/div[8]/ul/li[position()>=1]/a',attrs='href'), callback='parse_detail'),
)
def parse_detail(self, response):
item = GameItem()
item['title'] = response.xpath('/html/body/div[7]/div[1]/div[1]/div[2]/div[1]/h1/a/text()').get()
item['content'] = response.xpath('/html/body/div[7]/div[1]/div[1]/div[2]/div[4]/div/font/text()').get()
item['category'] = response.xpath('/html/body/div[7]/div[1]/div[1]/div[2]/div[2]/a/text()').get()
item['url'] = response.url
yield item
# items.py
import scrapy
class GameItem(scrapy.Item):
title = scrapy.Field()
content = scrapy.Field()
category = scrapy.Field()
url = scrapy.Field()
重要的来了!!!!!配置Scrapy-Redis
settings.py中添加
# Redis连接参数
REDIS_HOST = '192.168.192.129'
REDIS_PORT = 6379
REDIS_PARAMS = {
'password': 'root'
}
# 将调度器的类和去重的类替换为Scrapy-Redis提供的
SCHEDULER = 'scrapy_redis.scheduler.Scheduler'
DUPEFILTER_CLASS='scrapy_redis.dupefilter.RFPDupeFilter'
# 调度队列(三选一,默认为优先队列)
SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.PriorityQueue'
# SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.FifoQueue'
# SCHEDULER_QUEUE_CLASS = 'scrapy_redis.queue.LifoQueue'
# 这里配置不持久化,即爬取完后不保存爬取队列和去重指纹集合,方便调试一些
SCHEDULER_PERSIST = False
# 配置itempipeline(将数据存入redis,很耗内存)
ITEM_PIPELINES = {'scrapy_redis.pipelines.RedisPipeline':300}
自定义去重逻辑为布隆过滤器
这里就不详细讲布隆过滤器了,但还是简单提一句:
知道位图bitmap不?比如五个人,每个人有及格和不及格两种情况,就可以用一个五位二进制数,如01100表示第二个和第三个人及格了,但是每多一个人就要多加一位,相对费内存,在数据量大时不太好
于是有一种方法:指定多个哈希函数,对要存入的数进行哈希,然后把每个哈希值对应的位的值变为1,这样容易冲突、误判,但是确实降低了内存消耗,而且原则是能查到的可能是误判,但是查不到的一定不存在
直接用了崔大大的包了
pip install scrapy-redis-bloomfilter
然后在settings.py中增加或修改如下配置
DUPEFILTER_CLASS = 'scrapy_redis_bloomfilter.dupefilter.RFPDupeFilter'
BLOOMFILTER_BIT = 20
此时发现redis中的指纹集合的后缀就是bloomfilter了
爬虫管理和部署
多机同时更新改动肯定是很麻烦的事,所以我们需要一个管理平台
基于Scrapyd(别看,乱写的,直接学Docker的)
提供了管理各Scrapy项目的命令
Scrapyd -Client
pip3 install scrapyd-cient
相比于单纯的Scrapyd提供了一系列更方便的API
然后我们就要对项目进行一些配置的修改了
对于项目里的scrapy.cfg,修改url为被部署的主机的url,让scrapyd能访问到,同时为该主机起个别名spyder-2
[deploy:spyder-1]
url = http://192.168.192.129:6800/
在项目里执行scrapyd-deploy vm-1
部署
可视化管理Gerapy
看到Gerapy我就知道崔大大又来推广自己的项目了(笑
这是一个基于Scrapyd、Django、Vue.js的分布式爬虫管理框架,提供图形化管理服务
- 安装
pip3 install gerapy
- 初始化
# 在当前目录生成gerapy文件夹 gerapy init # 初始化数据库 gerapy migrate # 生成管理员账号 gerapy initadmin # 默认在8000端口上启动服务 gerapy runserver
- 在服务器上进行访问,不知道为什么我不能远程访问这个网页。。。
http://localhost:8000
- 登录 账号密码都是
admin
- 添加主机的Scrapyd运行地址和端口,并在gerapy/project目录下存放Scrapy项目,Gerapy支持项目可视化编辑、可视化部署、启停、日志等服务
。。。。。。。。。。。。。。。。。。。。。我反正没搞懂咋弄的,直接学Docker+K8S的算了,通用一些。。。。。。。。。。。。。。。。。。。。。
基于Docker
使用Docker
- 在项目根目录导出项目依赖
pip install pipreqs pipreqs ./ --encoding utf-8
- 在项目根目录编写Dockerfile文件
# 使用了Docker基础镜像之python3.10 FROM python:3.10 # 指定工作目录 WORKDIR /spyder # 把依赖文件复制到工作目录下,即/spyder COPY requirements.txt . # 安装包 RUN pip install -r requirements.txt # 这里是故意把整个项目的复制放到后面的,具体原因太长了我就不写了 COPY . . # 容器启动时执行的命令 CMD ["scrapy","crawl","4399"]
- 修改配置项,让settings.py中的配置是从环境变量中获得
# REDIS_URL = 'redis://user:pass@hostname:9001' REDIS_URL = os.getenv('REDIS_URL')
- 进入项目根目录打包镜像,注意最右边是个点,表示当前目录。注意项目名必须全小写,不然报错
invalid reference format: repository name must be lowercase
用docker build -t 项目名 .
docker images
查看镜像是否创建成功 - 指定环境变量
在部署的服务器上找个位置创建一个.env
文件,针对你settings.py
中的环境变量编写,比如我的就是
注意不要有空格出现REDIS_URL='redis://:root@host.docker.internal:6379'
- 在.env的目录下运行
docker run --env-file .env 镜像名
- 推送至docker hub
在官网注册再登录,注意username
是跟你以后仓库地址有关的,比如仓库名叫demo,username叫lyy,那么仓库地址就是lyy/demo
用docker login
命令也可以登录
给本地镜像打标签
推送镜像到Docker Hubdocker tag 镜像名:版本 想放的仓库地址:版本
shell docker push 想放的仓库地址:版本
- 以后运行这个镜像
只需要
创建一个 .env
然后docker run --env-file .env 镜像名
使用Docker Compose(更方便)
这是一个用yaml配置服务的工具,比Docker命令方便多了
- 编写
docker-compose.yaml
version: "3" services: redis: # 使用已有的镜像进行构建 image: redis:alpine container_name: redis ports: - "6379" scrapyRedisDemo: build: "." image: "truedude/scrapyredisdemo" environment: REDIS_URL: 'redis://:root@192.168.192.129:6379' # 等redis起了才起这个容器 depends_on: - redis
- 打包为镜像
docker-compose build
- 运行镜像
docker-compose up
- 推送镜像
docker-compose push
K8S的使用
TODO