從零開始完成一個分佈式爬蟲項目

寫在最前面:

學習scrapy也有一段時間了,準備寫一個項目鞏固鞏固;也快要畢業了,畢業設計題目還沒想好;索性先拿這個項目練練手。

ps:源碼在文章末尾,有興趣的自行下載


廢話不多說,直接上任務

  1. 爬取網站 :房天下
  2. 爬取內容:各個省市所有的新房、二手房的信息
  3. 爬取策略:分佈式爬取(會先從單機開始,之後再改成分佈式)
  4. 存儲位置:存儲在 MongoDB上(有時間,會考慮使用集羣)
  5. 數據分析:對爬取下的數據進行分析,如哪個省、市平均房價等等(有時間可以做做)
  6. 數據可視化:使用pyecharts或者自帶的matplotlib(有時間做做)
  7. 待定…

ps: 這一套下來,我覺得做一個畢設應該沒問題。


一.分析網站

  • 想想要獲取所有的省市的房子信息,前提是要知道所有的省市具體名稱。幸運的是,還真的有這個頁面。


在更多城市那, 點擊進去,是一個完整的省市信息。如下圖:

ok,沒毛病了。爬取這個頁面所有的城市的鏈接,第一級鏈接就搞到手了。

還沒完,接着分析:

以福建省福州市爲例子,進入第一級鏈接。觀察URL發現,新房的URL爲:http://fz.newhouse.fang.com/house/s/,二手房的URL爲http://fz.esf.fang.com/;發現,貌似有點意思。再換另一個城市,如廈門的新房和二手房URL分別是:

http://xm.newhouse.fang.com/house/s/http://xm.esf.fang.com/,這下,規律就出來了吧。不過,有些坑需要注意一下,北京,它的新房和二手房URL並不是 http://bj.esf.fang.com/,這樣的,URL竟然是這樣的:http://newhouse.fang.com/house/s/https://esf.fang.com/,畢竟它是首都嘛,特殊一點沒毛病。

ok,頁面分析差不多了,現在看看,需要獲得到哪些字段。

根據自己需求來獲取,使用xpath進行解析。

給個參考:

class NewHouseItem(scrapy.Item):
    # 省份
    province = scrapy.Field()
    # 城市
    city = scrapy.Field()
    # 小區名字
    name = scrapy.Field()
    # 價格
    price = scrapy.Field()
    # 幾居
    rooms = scrapy.Field()
    # 地址
    address = scrapy.Field()
    # 行政區
    district = scrapy.Field()
    # 是否在售
    is_sale = scrapy.Field()
    # 詳情頁面 url
    orgin_url = scrapy.Field()
class OldHouseItem(scrapy.Item):
    # 省份
    province = scrapy.Field()
    # 城市
    city = scrapy.Field()
    # 小區名字
    name = scrapy.Field()
    # 幾室幾廳
    rooms = scrapy.Field()
    # 幾層
    floor = scrapy.Field()
    # 朝向
    toward = scrapy.Field()
    # 年代
    year = scrapy.Field()
    # 地址
    address = scrapy.Field()
    # 建築面積
    area = scrapy.Field()
    # 單價
    unit_price = scrapy.Field()
    # 總價
    total_price = scrapy.Field()

難得最近勤快些,我就重新走一遍流程吧。

一. 創建項目

  • 一步步來吧,先創建單機版本的

    scrapy startproject FangTianXinSingle
    
  • Pycharm打開,看到以下目錄結構

是不是感覺有點不對勁?別慌,多出來的utils和log是我手動創建的 😃,utils主要是工具類,之後會使用到IP代理池,一些接口會放在那,log也就是日誌目錄。

別急着創建爬蟲,先改改settings.py中的配置文件吧。

  • ROBOTSTXT_OBEY = False (不解釋)
  • DOWNLOAD_DELAY = 5(防止IP被封了)
  • COOKIES_ENABLED = False (推薦不使用cookie)
  • 至於USER-AGENT可設可不設,因爲下面會寫一個UA中間件
  • 其它的先不改

二. 創建爬蟲

先進入spiders目錄中,cd spiders
控制檯執行 scrapy genspider soufang fang.com

如圖:

  • 首先,把 start_urls 給改了,換成之前分析的那個url http://www.fang.com/SoufunFamily.htm

  • 其次,別忘記我們的需求,要獲得所有的 城市的鏈接。分析頁面,使用xpath,解析出所有的鏈接。

    通過檢查發現,其所有的城市是放在一個 id=c02 的div容器中,這下好辦了直接上代碼。懶癌犯了,就不一一分析xpath語法了。

# -*- coding: utf-8 -*-
import scrapy

class SoufangSpider(scrapy.Spider):
    name = 'soufang'
    allowed_domains = ['fang.com']
    start_urls = ['https://www.fang.com/SoufunFamily.htm']

    def parse(self, response):
        trs = response.xpath("//div[@id='c02']//tr")
        province = None
        for tr in trs:
            tds = tr.xpath(".//td[not(@class)]")
            province_td = tds[0].xpath(".//text()").get().strip()
            if province_td:
                province = province_td
            city_links = tds[1].xpath(".//a")
            # 不需要爬取國外的
            if province == '其它':
                continue
            for city_link in city_links:
                city_name = city_link.xpath(".//text()").get()
                city_url = city_link.xpath(".//@href").get()
                scheme, domain = city_url.split("//")
                # 北京的新房和二手房鏈接需要特別處理
                if 'bj.' in domain:
                    newHouseLink = 'http://newhouse.fang.com/house/s'
                    oldHouseLink = 'http://esf.fang.com/'
                else:
                    newHouseLink = scheme + "//" + "newhouse." + domain + "house/s"
                    oldHouseLink = scheme + "//" + "esf." + domain
                print(newHouseLink, oldHouseLink)

執行該爬蟲,看看成效:

scrapy crawl soufang

如果你能打印出以下內容,恭喜你
在這裏插入圖片描述

三. 對這些鏈接再次訪問之前,要做一些反爬蟲策略了。

由於剛剛只對該網站的一個頁面進行爬取,做不做反爬蟲無所謂。但是接下去需要對網站這麼網址進行爬取,不做點什麼好像對不起這個網站

策略1:UA中間件

在目錄下創建UserAgentMiddleware.py文件,我一般不習不在scrapy提供的middlewares.py文件中寫


# author:dayin
# Date:2019/12/18 0018

from fake_useragent import UserAgent


class UserAgentMiddleware(object):
    ua = UserAgent()

    def process_request(self, request, spider):
        request.headers['User-Agent'] = self.ua.random

別忘記了!要去settings文件中 downloadmiddleware中加入你創建的中間件!

DOWNLOADER_MIDDLEWARES = {
   'FangTianXiaSingle.UserAgentMiddleware.UserAgentMiddleware': 543,
}

策略2:使用IP代理池

關於IP代理池可以說的有很多。。我簡略的說一下

  • 考慮到 經濟基礎 (我是個窮學生),我還是老老實實的使用一些免費的代理IP吧。

  • github上有很多開源的免費IP代理,我找了一個挺不錯的,分享給大家。

    代理IP池

  • 關於代理IP配置

    根據他給的文檔即可,寫的很詳細。裏面可以使用redis和ssdb數據庫,我使用的是redis數據庫。

直接貼出我的代碼:

在 utils中創建 getProxyIP.py文件

# author:dayin
# Date:2019/12/18 0016
import requests
def get_proxy():
    return 'http://' + requests.get("http://192.168.43.115:5555/get").json()['proxy']
def delete_proxy(proxy):
    if proxy:
        requests.get("http://192.168.43.115:5555/delete/?proxy={}".format(proxy))
if __name__ == '__main__':
    print(get_proxy())

這裏解釋一下,我沒有使用該代理IP池自帶的API接口,我是自己創建一個輕量的Flask框架。根據自己的需求,自定製一個api

工具有了,接下來就創建 代理中間件

在目錄下,創建 ProxyMiddleWare.py文件

# author:dayin
# Date:2019/12/18 0018

# 設置IP代理池 中間件
from FangTianXiaSingle.utils.getProxyIP import *


class ProxyMiddleWare(object):

    def process_request(self, request, spider):
        try:
            proxy = get_proxy()
            request.meta['proxy'] = proxy
        except:
            # 有異常,使用本機IP進行爬取
            print('代理池裏沒有IP了....只能使用自己的啦')

    def process_exception(self, request, spider, exception):
        print('----' * 100)
        print("這個代理IP超時,把它刪了吧,換下一個...")
        delete_proxy(request.meta.get('proxy'))
        request.meta['proxy'] = get_proxy()
        print('----' * 100)
        return request.replace(dont_filter=True)

這樣就基本搞定了IP代理池了,少年,你可以放肆的去爬了。

PS,免費的代理畢竟不穩定,經常會連接不上。心累,沒錢買好用的代理

對了,記得去settings.py文件中加入該中間件!

DOWNLOADER_MIDDLEWARES = {
    'FangTianXiaSingle.ProxyMiddleWare.ProxyMiddleWare': 300,
    'FangTianXiaSingle.UserAgentMiddleware.UserAgentMiddleware': 543,
}

四. 既然已經有了反爬蟲策略,那麼接下去便是分別解析各個城市二手房和新房的鏈接了

具體頁面就不去分析了,給出代碼,供大家參考:

# -*- coding: utf-8 -*-
import scrapy
import re

from FangTianXiaSingle.items import OldHouseItem, NewHouseItem


class SoufangSpider(scrapy.Spider):
    name = 'soufang'
    allowed_domains = ['fang.com']
    start_urls = ['https://www.fang.com/SoufunFamily.htm']

    def parse(self, response):
        trs = response.xpath("//div[@id='c02']//tr")
        province = None
        for tr in trs:
            tds = tr.xpath(".//td[not(@class)]")
            province_td = tds[0].xpath(".//text()").get().strip()
            if province_td:
                province = province_td
            city_links = tds[1].xpath(".//a")
            # 不需要爬取國外的
            if province == '其它':
                continue
            for city_link in city_links:
                city_name = city_link.xpath(".//text()").get()
                city_url = city_link.xpath(".//@href").get()
                scheme, domain = city_url.split("//")
                # 北京的新房和二手房鏈接需要特別處理
                if 'bj.' in domain:
                    newHouseLink = 'http://newhouse.fang.com/house/s'
                    oldHouseLink = 'http://esf.fang.com/'
                else:
                    newHouseLink = scheme + "//" + "newhouse." + domain + "house/s"
                    oldHouseLink = scheme + "//" + "esf." + domain

                yield scrapy.Request(url=newHouseLink, callback=self.parse_newhouse,
                                     meta={'info': (province, city_name)})

                yield scrapy.Request(url=oldHouseLink, callback=self.parse_oldhouse,
                                     meta={'info': (province, city_name)})

    def parse_newhouse(self, response):
        province, city = response.meta.get("info")
        lis = response.xpath(".//div[contains(@class,'nl_con')]/ul//li")
        for li in lis:
            # 房子的名稱
            name = li.xpath(".//div[@class='nlcd_name']//text()").getall()
            if name:
                name = re.sub(r'[\s\n]', '', ''.join(name))
                # 價格
                price = li.xpath(".//div[@class='nhouse_price']//text()").getall()
                price = re.sub(r'[\s\n廣告]', '', ''.join(price))
                # 居式
                rooms = li.xpath(".//div[contains(@class,'house_type')]//text()").getall()
                rooms = re.sub('-', '一共', re.sub(r'[\s\n]', '', ''.join(rooms)))
                # 地址
                address = li.xpath('.//div[@class="address"]/a/@title').get()
                address = re.sub(r'\[.+\]', '', address)
                # 地區
                district = li.xpath(".//div[@class='address']//text()").getall()
                try:
                    district = re.search(r'(\[.+\])', ''.join(district)).group(1)
                except:
                    district = ''
                # 是否在售
                is_sale = li.xpath(".//div[contains(@class,'fangyuan')]/span/text()").get()
                # 房源的鏈接
                orgin_url = response.urljoin(li.xpath(".//div[@class='nlcd_name']/a/@href").get())

                items = NewHouseItem(province=province, city=city, name=name, price=price, rooms=rooms, address=address,
                                     district=district,
                                     is_sale=is_sale, orgin_url=orgin_url)
                yield items

        next_url = response.xpath("//div[@class='page']//a[@class='next']/@href").get()
        if next_url:
            next_url = response.urljoin(next_url)
            yield scrapy.Request(next_url, callback=self.parse_newhouse, meta={'info': (province, city)})

    def parse_oldhouse(self, response):
        province, city = response.meta.get("info")
        lis = response.xpath("//div[contains(@class,'shop_list')]//dl[@dataflag='bg']")

        for li in lis:
            try:
                name = li.xpath(".//p[@class='add_shop']/a/@title").get()
                address = li.xpath(".//p[@class='add_shop']//span/text()").get()
                house_info = li.xpath(".//p[@class='tel_shop']//text()").getall()
                house_info = ''.join(house_info).split('|')
                house_info = list(map(lambda x: re.sub(r'[\r\n\s]', '', x), house_info))
                rooms, areas, floor, toward, year, *ags = house_info
                unit_price = li.xpath(".//dd[@class='price_right']/span[not(@class)]/text()").get()
                total_price = li.xpath(".//dd[@class='price_right']/span[@class='red']//text()").getall()
                total_price = ''.join(total_price)
                item = OldHouseItem(province=province, city=city, name=name, address=address, rooms=rooms, floor=floor,
                                    toward=toward, year=year, area=areas, unit_price=unit_price,
                                    total_price=total_price)
                yield item
            except:
                continue

        next_url = response.xpath("//div[@class='page_al']//span/following-sibling::p//a[text()='下一頁']/@href").get()
        if next_url:
            yield scrapy.Request(response.urljoin(next_url), callback=self.parse_oldhouse,
                                 meta={'info': (province, city)})

頁面信息獲取並不難,只要掌握了xpath的語法規則,獲取頁面上的元素不是輕而易舉?

最後,別忘記了。要在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 NewHouseItem(scrapy.Item):
    # 省份
    province = scrapy.Field()
    # 城市
    city = scrapy.Field()
    # 小區名字
    name = scrapy.Field()
    # 價格
    price = scrapy.Field()
    # 幾居
    rooms = scrapy.Field()
    # 地址
    address = scrapy.Field()
    # 行政區
    district = scrapy.Field()
    # 是否在售
    is_sale = scrapy.Field()
    # 詳情頁面 url
    orgin_url = scrapy.Field()


class OldHouseItem(scrapy.Item):
    # 省份
    province = scrapy.Field()
    # 城市
    city = scrapy.Field()
    # 小區名字
    name = scrapy.Field()
    # 幾室幾廳
    rooms = scrapy.Field()
    # 幾層
    floor = scrapy.Field()
    # 朝向
    toward = scrapy.Field()
    # 年代
    year = scrapy.Field()
    # 地址
    address = scrapy.Field()
    # 建築面積
    area = scrapy.Field()
    # 單價
    unit_price = scrapy.Field()
    # 總價
    total_price = scrapy.Field()


五. 數據存儲,將數據存儲到MongoDB中

按照我的習慣,我不會在已經定義好的pipelines.py文件中創建,而是在目錄下創建一個MongoPipeline.py文件

對了,最好把MongoDB的配置文件放在settings.py中,便於管理

# settings.py文件
# 設置Mongodb配置信息
MONGO_URI = '192.168.43.115'
MONGO_DB = 'FangTianXiaSingle'

代碼如下:


# author:dayin
# Date:2019/12/18 0018

from pymongo import MongoClient

from FangTianXiaSingle.items import NewHouseItem, OldHouseItem


class MongoPipeline(object):

    def __init__(self, mongo_uri, mongo_db):
        self.mongo_uri = mongo_uri
        self.mongo_db = mongo_db

    @classmethod
    def from_crawler(cls, crawler):
        return cls(
            mongo_uri=crawler.settings.get('MONGO_URI'),
            mongo_db=crawler.settings.get('MONGO_DB')
        )

    def open_spider(self, spider):
        self.client = MongoClient(self.mongo_uri)
        self.db = self.client[self.mongo_db]

    def process_item(self, item, spider):
        # 新房和二手房的數據分開存放
        if isinstance(item, NewHouseItem):
            self.db['newHouse'].insert(dict(item))
            print('[success] insert into the newHouse : ' + item.get('name') + ' to MongoDB')
        elif isinstance(item, OldHouseItem):
            self.db['oldHouse'].insert(dict(item))
            print('[success] insert into the oldHouse : ' + item.get('name') + ' to MongoDB')
        return item

    def close_spider(self, spider):
        self.client.close()

對咯,別忘記將它添加到settings.py文件中的ITEM_PIPELINES

ITEM_PIPELINES = {
    'FangTianXiaSingle.MongoPipeline.MongoPipeline': 300,
}

六. 關於優化

  • 優化1, 取消 重試中間件

    ‘scrapy.downloadermiddlewares.retry.RetryMiddleware’: None,’

  • 優化2,可以取消重定向中間件,自己寫一個。

  • 優化3,可以設置超時下載時間。

    DOWNLOAD_TIMEOUT = 5


ok,單機版本的就基本上完成了。讓我們看看效果

效果還行,幾秒鐘就爬了幾千條數據。不過,這和分佈式爬蟲比起來,還是個弟弟。

單機版的完整代碼,我已經上傳到 github上 點擊這裏

有興趣的可以去下載


接下來就是分佈式爬蟲了

這裏,我不介紹原理概念什麼的,相信有接觸過 scrapy分佈式爬蟲的都門兒清。

說起來,單機版的能夠成功爬取,那麼只需要修改幾個地方,就能輕鬆變成分佈式爬蟲,所以說啊,會寫單機版本的爬蟲纔是王道

想必大家都懂,scrapy實現分佈式爬蟲,是基於 scrapy-redis,so,直接切入主題。我把修改的幾個地方貼出來:

第一,在settings.py文件中增加 scrapy-redis配置

# 增加 Scrapy-redis配置

# 確保request存儲到redis中
SCHEDULER = "scrapy_redis.scheduler.Scheduler"

# 確保所有爬蟲共享相同的去重指紋
DUPEFILTER_CLASS = "scrapy_redis.dupefilter.RFPDupeFilter"

# 可以實現暫停和恢復的功能
SCHEDULER_PERSIST = True

REDIS_HOST = '192.168.43.115'
REDIS_PORT = 6379
REDIS_PARAMS = {'password': 'chendayin'}

第二,修改soufang.py文件

主要改兩個地方,第一個不在從scrapy.Spider中繼承了,而是繼承scrapy-redis中
RedisSpider,需要導入包:

from scrapy_redis.spiders import RedisSpider

第二個地方,去掉start_urls,增加 redis_key = ‘soufang’

完整代碼如下:

import scrapy
import re

from FangTianXiaSingle.items import OldHouseItem, NewHouseItem
from scrapy_redis.spiders import RedisSpider

class SoufangSpider(RedisSpider):
    name = 'soufang'
    allowed_domains = ['fang.com']
    # start_urls = ['https://www.fang.com/SoufunFamily.htm']
    redis_key = 'soufang'
	

 	...中間省略...和之前單機版一樣

第三,需要手動去redis數據庫中創建一個列表,值爲: https://www.fang.com/SoufunFamily.htm

lpush soufang https://www.fang.com/SoufunFamily.htm

只有這樣,爬蟲纔會啓動。否則一直阻塞

最後,你可以將項目拷貝到Linux服務器上,或者遠程服務器上。啓動它們,就完成了分佈式的爬取。


待完善

  • 重定向中間件編寫
  • MongoDB 集羣
  • 數據分析處理
  • 數據可視化

ok,項目差不多就到這裏了。有問題的,可以向我提問

項目源碼在github上 -------> 點這裏

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章