1.
过年也没啥事干,继续捣鼓爬虫。开始是准备爬豆瓣电影的,豆瓣存在一些反爬机制,爬一会就爬不动了。当然后面是突破了这个限制,使用随机bid,设置cookie。据说会出现验证码,我爬了几万部电影也没有出现这个问题。初期的想法是使用代理ip,网络上的免费代理ip大都不靠谱,懒得捣鼓。
在豆瓣电影这个爬虫中,我其实是使用两个步骤来执行。第一部分是按照年标签查找电影,从1900到2017年,将每个电影链接存入到mongodb中,然后进行去重(手动)。
class DoubanMovies(scrapy.Spider):
name = "douban"
prefix = 'https://movie.douban.com/tag/'
start_urls = []
for i in range(1900,2018):
start_urls.append(prefix+str(i))
def parse(self,response):
names = response.xpath('//div[@class="pl2"]/a/text()').extract()
names = [name.strip('\n/ ') for name in names]
names = [name for name in names if len(name)>0] #去掉空名字
movie_urls = response.xpath('//div[@class="pl2"]/a/@href').extract()
hrefs = response.xpath('//div[@class="paginator"]/a/@href').extract()#获取分页链接
for href in hrefs:
yield scrapy.Request(href, callback=self.parse)
for i in range(len(names)):
yield {'name':names[i],'url':movie_urls[i]}
关于mongodb去重的问题,我使用的临时表。主要是我对mongodb确实不熟悉,而且我对JavaScript这样的语法着实不感冒。下面的代码很简单,每个电影链接就是https://movie.douban.com/subject/26685451/ ,我这里特地把这中间的数字提取出来,然后进行对比,这个肯定是唯一的。distinct会把获取的数据重复的进行合并,这在sql中也有这功能。
nums = movies.distinct("number")#我把链接中的数字提取了出来
m = db.movies1
for num in mums:
movie = movies.find_one({"number":num})
m.insert_one(movie)
moives.drop()#删除原来的数据表
m.rename('movie')#把新表命名
还有个问题就是针对douban的时间限制,需要使用DOWNLOAD_DELAY
设置时间间隔,当然我使用bid突破了这个限制。
下面是第二个爬虫的代码,这个代码就是访问每个电影页面提取相关数据,然后存到mongodb中。数据提取没什么难度,为了快速判断xpath有效,最好开一个scrapy shell进行测试。
class DoubanMovies(scrapy.Spider):
name = "doubansubject"
def start_requests(self):
MONGO_URI = 'mongodb://localhost/'
client = MongoClient(MONGO_URI)
db = client.douban
movie = db.movie
cursor = movie.find({})
urls = [c['url'] for c in cursor]
for url in urls:
bid = "".join(random.sample(string.ascii_letters + string.digits, 11))
yield scrapy.Request(url,callback=self.parse,cookies={"bid":bid})
def parse(self,response):
title = response.xpath('//span[@property="v:itemreviewed"]/text()').extract_first()
year = response.xpath('//span[@class="year"]/text()').extract_first()#(2016)
pattern_y = re.compile(r'[0-9]+')
year = pattern_y.findall(year)
if len(year)>0:
year = year[0]
else:
year = ""
directors = response.xpath('//a[@rel="v:directedBy"]/text()').extract()#导演?有没有可能有多个导演呢
'''
评分人数
'''
votes= response.xpath('//span[@property="v:votes"]/text()').extract_first()#评分人数
'''
分数
'''
score = response.xpath('//strong[@property="v:average"]/text()').extract_first()#抓取分数
#编剧不好找等会弄
'''
演员
'''
actors = response.xpath('//a[@rel="v:starring"]/text()').extract()#演员
genres = response.xpath('//span[@property="v:genre"]/text()').extract()#电影类型
html = response.body.decode('utf-8')
pattern_zp = re.compile(r'<span class="pl">制片国家/地区:</span>(.*)<br/>')
nations = pattern_zp.findall(html)
if len(nations)>0 :
nations = nations[0]
nations = nations.split('/')
nations = [n.strip() for n in nations]
'''
多个国家之间以/分开,前后可能出现空格也要删除
'''
pattern_bj = re.compile(r"<span ><span class='pl'>编剧</span>: <span class='attrs'>(.*)</span></span><br/>")
bj_as = pattern_bj.findall(html)
'''
bj_as 内容是
[<a>编剧</a>,<a></a>,<a></a>,<a></a>,]
需要进一步提取
'''
p = re.compile(r'>(.*)<')
bj = [p.findall(bj) for bj in bj_as]
'''
p.findall也会产生数组,需要去掉括号,只有有数据才能去掉
'''
bj = [b[0].strip() for b in bj if len(b)>0]#编剧的最终结果
'''
语言
<span class="pl">语言:</span> 英语 / 捷克语 / 乌克兰语 / 法语<br/>
'''
pattern_lang = re.compile(r'<span class="pl">语言:</span>(.*)<br/>')
langs = pattern_lang.findall(html)
if len(langs)>0:
langs = langs[0]
langs = langs.split('/')
langs = [l.strip() for l in langs]
runtime = response.xpath('//span[@property="v:runtime"]/@content').extract_first()
'''
上映日期也有多个
'''
releasedates = response.xpath('//span[@property="v:initialReleaseDate"]/text()').extract()
'''
标签
'''
tags = response.xpath('//div[@class="tags-body"]/a/text()').extract()
##这里不能用return
yield {"title":title,"year":year,"directors":directors,"score":score,"votes":votes,
"actors":actors,"genres":genres,"nations":nations,"bj":bj,"langs":langs,"runtime":runtime,
"releasedates":releasedates,"url":response.url
}
上面的代码确实能正常工作,但是有个缺点就是太慢,不到五万个页面就要几个小时,显然瓶颈在分析这一块。性能问题会在下面一个例子中讨论。
2
性能问题确实是个大问题,在满足能爬取的情况下,速度要优化。这几天抓取一个AV网站,没错AV网站的种子文件。先抓取文章列表,再抓取每个详细页面,访问种子下载页面,最后下载里面的种子文件。
- 方法一
这个代码很简单使用的是requests来下载文件,里面的下载功能代码就是从requests教程中拷贝出来的。
def process_item(self, item, spider):
try:
bt_urls = item['bt_urls']
if not os.path.exists(self.dir_path):
os.makedirs(self.dir_path)
'''
检查文件夹是否存在这段代码应该放到open_spider中去才是合适的,启动检查一下后面就不管了
'''
for url in bt_urls:
response = requests.get(url,stream=True)
attachment = response.headers['Content-Disposition']
pattern = re.compile(r'filename="(.+)"')
filename = pattern.findall(attachment)[0]
filepath = '%s/%s' % (self.dir_path,filename)
with open(filepath, 'wb') as handle:
#response = requests.get(image_url, stream=True)
for block in response.iter_content(1024):
if not block:
break
handle.write(block)
'''
整个代码肯定会严重影响爬虫的运行速度,是否考虑多进程方法
'''
except Exception as e:
print(e)
return item
bt_url种子的链接就放在bt_urls,由于是从下载页面返回的item,实际中最多只有一个链接。这个代码运行起来没什么问题,但是速度相当慢。scrapy使用的是异步网络框架,但是requests是实实在在的同步方法,单线程的情况下必然影响到整个系统的执行。必须要突破这个瓶颈,实际中要先考虑代码能正确运行再考虑其它方面。
- 方法二
既然在本线程中直接下载会造成线程阻塞,那开启一个新的进程如何。
class DownloadBittorrent2(object):
def __init__(self, dir_path):
self.dir_path = dir_path
# self.mongo_db = mongo_db
@classmethod
def from_crawler(cls, crawler):
return cls(
dir_path =crawler.settings.get('DIR_PATH'),
)
def open_spider(self, spider):
if not os.path.exists(self.dir_path):
os.makedirs(self.dir_path)
def close_spider(self, spider):
pass
def downloadprocess(self,url):
try:
response = requests.get(url,stream=True)
attachment = response.headers['Content-Disposition']
pattern = re.compile(r'filename="(.+)"')
filename = pattern.findall(str(attachment))[0]#这里attachment是bytes必须要转化
filepath = '%s/%s' % (self.dir_path,filename)
with open(filepath, 'wb') as handle:
#response = requests.get(image_url, stream=True)
for block in response.iter_content(1024):
if not block:
break
handle.write(block)
except Exception as e:
print(e)
def process_item(self, item, spider):
bt_urls = item['bt_urls']
if len(bt_urls)>0:#最多只有一个url
p = Process(target=self.downloadprocess,args=(bt_urls[0],))
p.start()
return item
这个代码也能正常工作,但是报错,直接导致服务器挂了。
HTTPConnectionPool(host='taohuabbs.info', port=80): Max retries exceeded with url: /forum.php?mod=attachment&aid=MjAxNzk 0fDA0OTkxZjM0fDE0ODU4NjY0OTZ8MHwxMzMzNDA= (Caused by NewConnectionError('<requests.packages.urllib3.connection.HTTPConne ction object at 0x00000000041F9048>: Failed to establish a new connection: [WinError 10060]
由于连接方在一段时间后没有正确答复或连接的主机没有反应,连接尝试失败。',))
这个可能跟设置的延迟有关系(我就没有设置延迟),反正就是把服务器弄死了。还有就是requests在这种异常情况下容错能力有问题。
- 方法三
既然scrapy自带了一个Filespipeline,那么是不是可以考虑用这个来下载呢!可以试试!
class DownloadBittorrent3(FilesPipeline):
def get_media_requests(self, item, info):
for file_url in item['bt_urls']:
yield scrapy.Request(file_url)
代码报错了,原因是文件名打不开。这个就涉及到如何命名下载文件名的问题。如果链接中带*.jpg这样类似的名字,程序不会有问题,如果不是会怎么样,链接中可能出现操作系统不允许在文件名中出现的字符,这就会报错。我对系统自带的这个pipeline了解甚少,就没有继续研究。
还有一点我希望文件名来自于服务器的反馈,对于下载文件服务器可能会把文件名发过来,这个就在headers的Content-Disposition
字段中。也就是是说我必须要先访问网络之后才能确定文件名。
- 方法四
前面我们都使用了pipeline来处理,实际上我们完全可以不用pipeline而直接在spider中处理。
def download(self,response):
'''
在爬取过程中发现有可能返回不是torrent文件,这时候要考虑容错性问题,虽然爬虫并不会挂掉
'''
attachment = response.headers['Content-Disposition']
pattern = re.compile(r'filename="(.+)"')
filename = pattern.findall(attachment.decode('utf-8'))[0]
filepath = '%s/%s' % (self.settings['DIR_PATH'],filename)
with open(filepath, 'wb') as handle:
handle.write(response.body)
这种方法性能不错,对比前面50/min速度,这个可以达到100/min。其实我们可以更快。
3
在实际的下载中,我们要充分利用scrapy的网络下载框架,这个性能好容错性高,而且也好排错。上面的10060错误,我估计放在http中可能就是503(服务器无法到达)。
前面的方法都在单线程中运作,虽然后面有多进程版的下载代码,由于没有scrapy稳定所以我考虑用多个爬虫来实现。如果启动两个scrapy爬虫,一个负责爬页面,一个负责下载,这样的效率应该会高不少。虽然前面的笔记中有提到相关代码,使用redis来实现分布式。当然在单机上称不上分布式,但是使用redis作为进程间通讯手段确实极好的,不管是多进程还是分布式都能非常高效的工作。github上有基于redis版本的scrapy,这里我的想法是第一个爬虫负责爬页面的属于一般爬虫(使用原版的scrapy),而第二个爬虫使用基于redis的爬虫。
- 1 scrapy-redis安装
pip install scrapy-redis
安装方法倒是很简单,但是这个代码比较旧了,版本是0.6.3,这个版本在python3.5上工作不正常(出错跟转码有关str,具体情况不懂),处理的办法就是把0.6.7的代码下载下来直接覆盖就可以了(反正我也看不懂代码,覆盖了能工作)。 - 2 配置
scrapy-redis的配置还是在settings中,参考文档
文档中有几个必须配置的参数:
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
后面还可以配置redis服务器端口号,还有redis服务器地址。
REDIS_START_URLS_BATCH_SIZE
上面的参数对代表每次redis从redis服务器中获取的链接数量,这个调高可能会增加性能。 - 3 页面爬虫
class BtSpiderEx(scrapy.Spider):
name = 'btspiderex'
start_urls = ['http://taohuabbs.info/forum-181-1.html']
def parse(self,response):
urls = response.xpath('//a[@onclick="atarget(this)"]/@href').extract()
for url in urls:
yield scrapy.Request(response.urljoin(url),callback=self.parsedetail)
page_urls = response.xpath('//div[@class="pg"]/a/@href').extract()
for url in page_urls:
yield scrapy.Request(response.urljoin(url),callback=self.parse)
def parsedetail(self,response):
hrefs = response.xpath('//p[@class="attnm"]/a/@href').extract()
for h in hrefs:
yield scrapy.Request(response.urljoin(h),callback=self.parsedown)
def parsedown(self,response):
'''
其实每次只能分析出一个bt链接
'''
bt_urls = response.xpath('//div[@style="padding-left:10px;"]/a/@href').extract()
yield {'bt_urls':bt_urls}
页面爬虫代码其实相对于前面的实现,变得更加简单,这里把将下载链接推送到redis服务器的任务交给pipeline。
class DownloadBittorrent(object):
def __init__(self, dir_path):
self.dir_path = dir_path
@classmethod
def from_crawler(cls, crawler):
return cls(
dir_path =crawler.settings.get('DIR_PATH'),
)
def open_spider(self, spider):
if not os.path.exists(self.dir_path):
os.makedirs(self.dir_path)
self.conn = redis.Redis(port=6666)
def close_spider(self,spdier):
pass
def process_item(self, item, spider):
bt_urls = item['bt_urls']
for url in bt_urls:
self.conn.lpush('redisspider:start_urls',url)
return item
open_spider
在爬虫启动的时候启动,这里就可以打开redis和建立下载文件夹。redisspider:start_urls
这个是redis队列名,缺省情况下scrapy-redis
爬虫的队列就是爬虫名+start_urls。
- 4 下载爬虫
下载爬虫只负责从redis获取链接然后下载。
from scrapy_redis.spiders import RedisSpider
import re
class DistributeSpider(RedisSpider):
name = 'redisspider'
def parse(self,response):
DIR_PATH = "D:/bt"
if 'Content-Disposition' in response.headers:
attachment = response.headers['Content-Disposition']
pattern = re.compile(r'filename="(.+)"')
filename = pattern.findall(attachment.decode('utf-8'))[0]
filepath = '%s/%s' % (DIR_PATH,filename)#DIR_PATH = "D:/bt"
with open(filepath, 'wb') as handle:
handle.write(response.body)
settings.py配置,只列出了主要参数,这里修改了默认端口号:
SCHEDULER = "scrapy_redis.scheduler.Scheduler"
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"
REDIS_PORT = 6666