天天看點

[Python 爬蟲] 使用 Scrapy 爬取新浪微網誌使用者資訊(三) —— 資料的持久化——使用MongoDB存儲爬取的資料

上一篇:[Python 爬蟲] 使用 Scrapy 爬取新浪微網誌使用者資訊(二) —— 編寫一個基本的 Spider 爬取微網誌使用者資訊

在上一篇部落格中,我們已經建立了一個爬蟲應用,并簡單實作了爬取一位微網誌使用者的基本資訊。這一篇部落格就将介紹怎樣橫向和縱向地擴充爬蟲,讓爬蟲程式循環地爬取使用者資訊,然後将爬取的使用者資訊,儲存到 MongoDB。

擴充爬取範圍

1. 完善爬取使用者的資料

其實上一篇部落格還遺留了部分問題,我們隻爬取了使用者首頁的資訊(使用者Id、微網誌數、關注數、粉絲數),還沒有爬取使用者資料中的資訊,包括使用者昵稱、認證資訊、簡介、認證、性别、地區等,這一節我們就來實作這部分邏輯。

我們上一篇實作使用者基本資訊的爬取是在 base_info_parse() 方法中實作的,我們再定義一個 detail_info_parse() 方法來實作使用者資料的爬取。我們在使用者首頁點選“資料”就可以跳轉到使用者資料頁面(https://weibo.cn/1809054937/info),是以我們可以在 base_info_parse() 方法中擷取使用者資料的 url,當然,仔細觀察不難看出所有使用者的資料頁面 url 都是形如 https://weibo.cn/{user_id}/info 的,是以我們也可以用解析的 user_id 直接組裝出 url,這裡采用自己組裝 url 的方法,然後構造一個新的請求。在 base_info_parse() 方法末尾建構新的爬蟲 Request,另外,由于我們得到一位使用者的完整資訊,是需要将使用者基本資訊和詳細資訊組裝到一起的,是以我們希望将 base_info_parse() 提取的資訊也傳遞到 detail_info_parse() 方法中去,我們可以采用 meta 這個參數,将提取的資訊傳遞下去,代碼如下:

yield scrapy.Request(url='https://weibo.cn/%s/info' % user_id, callback=self.detail_info_parse,
                             headers=self.headers, cookies=self.cookies, meta={'item': load.load_item()})
           

detail_info_parse() 的完整代碼如下:

def detail_info_parse(self, response):
        """
        使用者資料解析函數\n
        :param response:
        :return:
        """
        # 擷取上一個函數的解析結果
        item = response.meta['item']
        user_id = item.get('user_id')
        # 利用上一個函數的解析結果構造加載器(Loader)
        load = ItemLoader(item=item, response=response)
        selector = scrapy.Selector(response)
        # 如果 user_id 為空,在使用者資料頁面,再次提取 user_id
        if not user_id:
            ids = selector.xpath('//a[contains(@href,"uid")]/@href').re('uid=(\d{10})')
            ids = list(set(ids))
            user_id = ids[0]
            load.add_value('user_id', user_id)
        nick_name, gender, district, birthday, brief_intro, identify, head_img = '', '', '', '', '', '', ''
        for info in selector.xpath('//div[@class="c"][3]/text()'):
            # 提取個人資料
            nick_name = info.re(u'昵稱:(.*)')[0] if info.re(u'昵稱:(.*)') else nick_name
            identify = info.re(u'認證:(.*)')[0] if info.re(u'認證:(.*)') else identify
            gender = info.re(u'性别:(.*)')[0] if info.re(u'性别:(.*)') else gender
            district = info.re(u'地區:(.*)')[0] if info.re(u'地區:(.*)') else district
            birthday = info.re(u'生日:(.*)')[0] if info.re(u'生日:(.*)') else birthday
            brief_intro = info.re(u'簡介:(.*)')[0] if info.re(u'簡介:(.*)') else brief_intro
        # 根據使用者填寫的地區資訊拆分成 省份 和 城市
        province, city = '', ''
        if district:
            extract = district.split(' ')
            province = extract[0] if extract else ''
            city = extract[1] if extract and len(extract) > 1 else ''
        # 合并使用者基本資訊和詳細資料
        load.add_value('province', province)
        load.add_value('city', city)
        load.add_xpath('head_img', '//div[@class="c"]/img[@alt="頭像"]/@src')
        load.add_value('username', nick_name)
        load.add_value('identify', identify)
        load.add_value('gender', gender)
        load.add_value('district', district)
        load.add_value('birthday', birthday)
        load.add_value('brief_intro', brief_intro)
        yield load.load_item()
           

2. 縱向擴充爬取:遞歸爬取使用者的粉絲和關注

目前我們已經基本實作爬取一位微網誌使用者的資訊,要實作爬取多使用者資訊雖然可以在 start_urls 裡面構造多個 url 來實作爬取多位使用者,但是這樣做顯然是不現實的,我們注意到微網誌使用者都有自己的粉絲和關注,我們可以通過爬取指定微網誌使用者的關注和粉絲來擴充爬取,是以我們就需要解析使用者的關注和粉絲頁面來提取資料了。每一個使用者的關注頁面 url 都是: https://weibo.cn/{user_id}/follow ,粉絲頁面 url 都是:https://weibo.cn/{user_id}/fans ,是以我們就可以通過 user_id 來組裝出使用者的粉絲頁面和關注頁面:代碼如下:

# 使用者關注頁 url
follows_url = 'https://weibo.cn/%s/follow' % user_id
# 使用者粉絲頁 url
fans_url = 'https://weibo.cn/%s/fans' % user_id
           

通過分析粉絲和關注頁面,其實兩者的頁面結構是一樣的,是以我們可以用一個方法來分别解析兩個頁面,代碼如下:

def follow_fans_parse(self, response):
        """
        擷取關注使用者/粉絲使用者\n
        :param response:
        :return:
        """
        user_id = response.meta.get('user_id')
        if not user_id:
            user_id = re.compile('https://weibo.cn/(\d{10})/.*').findall(response.url)
            user_id = user_id[0] if user_id else ''
        selector = scrapy.Selector(response)
        # 判斷使用者數是否超過配置的最大使用者數
        type_str = '關注' if str(response.url).find('follow') > 0 else '粉絲'
        self.logger.info('開始構造 [%s] %s爬取請求...' % (user_id, type_str))
        # 解析頁面中所有的 URL,并提取 使用者 id
        accounts = selector.xpath('//a[starts-with(@href,"https://weibo.cn/u/")]/@href').re(
            u'https://weibo.cn/u/(\d{10})')
        # 去重
        accounts = list(set(accounts))
        # 使用使用者 id 構造個人資料、使用者首頁、關注清單以及粉絲清單的 URL
        urls = []
        [urls.extend(('https://weibo.cn/u/%s' % acc, 'https://weibo.cn/%s/fans' % acc,
                      'https://weibo.cn/%s/follow' % acc)) for acc in accounts]
           

在上面代碼中,我們隻是解析了使用者關注/粉絲頁面的 user_id ,還并沒有爬取他們的資訊,現在我們來進一步完善程式,使其形成一個閉環,繼續爬取使用者關注和粉絲的資訊以及他們的粉絲和關注的資訊。可以看到,我們構造了三種 url,分别是使用者首頁、關注清單以及粉絲清單的 url,其中關注清單 url 和粉絲清單 url,可以遞歸調用 follow_fans_parse() 方法,而使用者首頁可以調用 base_info_parse() 方法,代碼如下:

# 使用生成的 URL 構造 request
for url in urls:
   if str(url).find('follow') > 0 or str(url).find('fan') > 0:
       yield scrapy.Request(url=url, callback=self.follow_fans_parse, headers=self.headers,cookies=self.cookies, meta={'user_id': user_id})
   else:
       yield scrapy.Request(url=url, callback=self.base_info_parse, headers=self.headers, cookies=self.cookies)
           

3. 橫向擴充爬取:添加分頁爬取

現在我們隻是實作了爬取一頁的關注/粉絲,在頁面中,我們看到對于使用者關注和粉絲都是有分頁的,每一頁隻展示 10 位使用者,是以我們添加分頁的實作,代碼如下:

# 下一頁
nextLink = selector.xpath('//div[@class="pa"]/form/div/a/@href').extract()
if nextLink:
   url = 'https://weibo.cn' + nextLink[0]
   self.logger.info('[%s] %s下一頁:%s' % (user_id, type_str, url))
   yield scrapy.Request(url=url, callback=self.follow_fans_parse, headers=self.headers, cookies=self.cookies, meta={'user_id': user_id})
else:
   self.logger.info(u'[%s] %s已爬取完畢!' % (user_id, type_str))
           

最後别忘了在 base_info_parse() 方法中構造 follow_fans_parse() 的請求:

for url in (follows_url, fans_url):
    yield scrapy.Request(url=url, callback=self.follow_fans_parse, headers=self.headers,
                                 cookies=self.cookies, meta={'user_id': user_id})
           

目前,sina_user.py 的完整代碼如下(已隐去 cookies的值):

# -*- coding: utf-8 -*-
import scrapy, time, re
from scrapy.loader import ItemLoader
from sina_scrapy.items import SinaUserItem


class SinaUserSpider(scrapy.Spider):
    # 爬蟲的名字,唯一辨別
    name = 'sina_user'
    # 允許爬取的域名範圍
    allowed_domains = ['weibo.cn']
    # 爬蟲的起始頁面url
    start_urls = ['https://weibo.cn/u/1809054937']

    def __init__(self):
        self.headers = {
            'Referer': 'https://weibo.cn/u/1809054937',
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/75.0.3770.100 Safari/537.36'
        }

        self.cookies = {
            'SCF': 'XXXXXXXXXXXXXXXXXXXXXXXXXX',
            'SUB': 'XXXXXXXXXXXXXXXXXXXXXXXXXX',
            'SUHB': 'XXXXXXXXXXXXXXXXXXXXXXXXX',
            '_T_WM': XXXXXXXXXXXXXXXXXXXXXXXXX
        }

    def start_requests(self):
        """
        構造最初 request 函數\n
        :return:
        """
        for url in self.start_urls:
            yield scrapy.Request(url=url, callback=self.base_info_parse, headers=self.headers, cookies=self.cookies)

    def base_info_parse(self, response):
        """
        微網誌使用者基本資訊解析函數\n
        :param response:
        :return:
        """
        # 加載器(Loader)
        load = ItemLoader(item=SinaUserItem(), response=response)
        selector = scrapy.Selector(response)
        # 解析微網誌使用者 id
        re_url = selector.xpath('///a[contains(@href,"uid")]/@href').re('uid=(\d{10})')
        user_id = re_url[0] if re_url else ''
        load.add_value('user_id', user_id)

        follows_url = 'https://weibo.cn/%s/follow' % user_id
        fans_url = 'https://weibo.cn/%s/fans' % user_id
        for url in (follows_url, fans_url):
            yield scrapy.Request(url=url, callback=self.follow_fans_parse, headers=self.headers,
                                 cookies=self.cookies, meta={'user_id': user_id})

        # 微網誌數
        webo_num_re = selector.xpath('//div[@class="tip2"]').re(u'微網誌\[(\d+)\]')
        webo_num = int(webo_num_re[0]) if webo_num_re else 0
        load.add_value('webo_num', webo_num)
        # 關注人數
        follow_num_re = selector.xpath('//div[@class="tip2"]').re(u'關注\[(\d+)\]')
        follow_num = int(follow_num_re[0]) if follow_num_re else 0
        load.add_value('follow_num', follow_num)
        # 粉絲人數
        fans_num_re = selector.xpath('//div[@class="tip2"]').re(u'粉絲\[(\d+)\]')
        fans_num = int(fans_num_re[0]) if fans_num_re else 0
        load.add_value('fans_num', fans_num)
        # 記錄爬取時間
        load.add_value('crawl_time', time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(time.time())))

        yield scrapy.Request(url='https://weibo.cn/%s/info' % user_id, callback=self.detail_info_parse,
                             headers=self.headers, cookies=self.cookies, meta={'item': load.load_item()})

    def detail_info_parse(self, response):
        """
        使用者資料解析函數\n
        :param response:
        :return:
        """
        # 擷取上一個函數的解析結果
        item = response.meta['item']
        user_id = item.get('user_id')
        # 利用上一個函數的解析結果構造加載器(Loader)
        load = ItemLoader(item=item, response=response)
        selector = scrapy.Selector(response)
        # 如果 user_id 為空,在使用者資料頁面,再次提取 user_id
        if not user_id:
            ids = selector.xpath('//a[contains(@href,"uid")]/@href').re('uid=(\d{10})')
            ids = list(set(ids))
            user_id = ids[0]
            load.add_value('user_id', user_id)
        nick_name, gender, district, birthday, brief_intro, identify, head_img = '', '', '', '', '', '', ''
        for info in selector.xpath('//div[@class="c"][3]/text()'):
            # 提取個人資料
            nick_name = info.re(u'昵稱:(.*)')[0] if info.re(u'昵稱:(.*)') else nick_name
            identify = info.re(u'認證:(.*)')[0] if info.re(u'認證:(.*)') else identify
            gender = info.re(u'性别:(.*)')[0] if info.re(u'性别:(.*)') else gender
            district = info.re(u'地區:(.*)')[0] if info.re(u'地區:(.*)') else district
            birthday = info.re(u'生日:(.*)')[0] if info.re(u'生日:(.*)') else birthday
            brief_intro = info.re(u'簡介:(.*)')[0] if info.re(u'簡介:(.*)') else brief_intro
        # 根據使用者填寫的地區資訊拆分成 省份 和 城市
        province, city = '', ''
        if district:
            extract = district.split(' ')
            province = extract[0] if extract else ''
            city = extract[1] if extract and len(extract) > 1 else ''
        # 合并使用者基本資訊和詳細資料
        load.add_value('province', province)
        load.add_value('city', city)
        load.add_xpath('head_img', '//div[@class="c"]/img[@alt="頭像"]/@src')
        load.add_value('username', nick_name)
        load.add_value('identify', identify)
        load.add_value('gender', gender)
        load.add_value('district', district)
        load.add_value('birthday', birthday)
        load.add_value('brief_intro', brief_intro)
        yield load.load_item()

    def follow_fans_parse(self, response):
        """
        擷取關注使用者/粉絲使用者\n
        :param response:
        :return:
        """
        user_id = response.meta.get('user_id')
        if not user_id:
            user_id = re.compile('https://weibo.cn/(\d{10})/.*').findall(response.url)
            user_id = user_id[0] if user_id else ''
        selector = scrapy.Selector(response)
        # 判斷使用者數是否超過配置的最大使用者數
        type_str = '關注' if str(response.url).find('follow') > 0 else '粉絲'
        self.logger.info('開始構造 [%s] %s爬取請求...' % (user_id, type_str))
        # 解析頁面中所有的 URL,并提取 使用者 id
        accounts = selector.xpath('//a[starts-with(@href,"https://weibo.cn/u/")]/@href').re(
            u'https://weibo.cn/u/(\d{10})')
        # 去重
        accounts = list(set(accounts))
        # 使用使用者 id 構造個人資料、使用者首頁、關注清單以及粉絲清單的 URL
        urls = []
        [urls.extend(('https://weibo.cn/u/%s' % acc, 'https://weibo.cn/%s/fans' % acc,
                      'https://weibo.cn/%s/follow' % acc)) for acc in accounts]

        # 使用生成的 URL 構造 request
        for url in urls:
            if str(url).find('follow') > 0 or str(url).find('fan') > 0:
                yield scrapy.Request(url=url, callback=self.follow_fans_parse, headers=self.headers,
                                     cookies=self.cookies, meta={'user_id': user_id})
            else:
                yield scrapy.Request(url=url, callback=self.base_info_parse, headers=self.headers, cookies=self.cookies)

        # 下一頁
        nextLink = selector.xpath('//div[@class="pa"]/form/div/a/@href').extract()
        if nextLink:
            url = 'https://weibo.cn' + nextLink[0]
            self.logger.info('[%s] %s下一頁:%s' % (user_id, type_str, url))
            yield scrapy.Request(url=url, callback=self.follow_fans_parse, headers=self.headers, cookies=self.cookies,
                                 meta={'user_id': user_id})
        else:
            self.logger.info(u'[%s] %s已爬取完畢!' % (user_id, type_str))
           

現在,我們的程式已經基本實作了爬取微網誌使用者資訊的功能(目前沒有限制爬取速度,是以在爬取部分使用者後,微網誌伺服器會響應 418,這是微網誌反爬的一種政策,目前隻能通過降低爬取的頻率來避免出現 418,這個問題會在後面的部落格介紹)

實作資料的持久化

還記得在 第一篇部落格 [Python 爬蟲] 使用 Scrapy 爬取新浪微網誌使用者資訊(一) —— 建立爬蟲項目 中分析 Scrapy 的整體架構時介紹到,Spider 爬取的資料,會交給 Item Pipeline 處理。在上面的代碼中,detail_info_parse() 方法的最後一行代碼:

yield load.load_item()
           

通過這行代碼,Spider 就生成了一個 Item,并将這個 Item 傳回給了 Item Pipeline 處理。我們在 Item Pipeline 裡面可以将我們爬取的資料存入到 MongoDB 中去。

首先我們在 settings.py 裡面定義我們 MongoDB 的連接配接資訊,代碼如下:

# MONGODB 主機名
MONGODB_HOST = "127.0.0.1"
# MONGODB 端口号
MONGODB_PORT = 27017
# 資料庫名稱
MONGODB_DBNAME = "crawl"
# 存放資料的集合名稱
MONGODB_COLLECTION = "sina_userinfo"
           

然後在 Item Pipeline 中得到這些配置,用來初始化 MongoDB 連接配接,代碼如下:

from scrapy.conf import settings
from pymongo import MongoClient

host = settings.get('MONGODB_HOST')
port = settings.get('MONGODB_PORT')
dbname = settings.get('MONGODB_DBNAME')
collection_name = settings.get('MONGODB_COLLECTION')
db = MongoClient(host=host, port=port).get_database(dbname).get_collection(collection_name)
           

接下來我們要做的很簡單,隻需要把得到的 Item 儲存到 MongoDB 就可以了,我們定義一個 SaveUserInfoPipeline 類,然後定義一個 process_item() 方法,然後将 item 轉化成字典類型,儲存入庫就行了。代碼如下:

class SaveUserInfoPipeline(object):
    """
    儲存爬取的資料\n
    """

    def __init__(self):
        print('要儲存的 Collenction:%s' % collection_name)

    def process_item(self, item, spider):
        data = dict(item)
        print("最終入庫資料:%s" % item)
        # 記錄不存在則插入,否則更新資料
        db.update_one({'weibo_id': data.get('weibo_id')}, {"$set": data}, True)
        return item
           

最後一步,在 settings 啟用我們定義的 Item Pipeline,代碼如下:

ITEM_PIPELINES = {
    'sina_scrapy.pipelines.SaveUserInfoPipeline': 20,
}
           

後面的數字 20 是代表優先級(取值範圍是 1 ~ 999),目前隻有一個 Item Pipeline,是以任意指定一個就行。

使用 scrapy crawl sina_user 指令啟動爬蟲,現在我們已經實作了将爬取的使用者資訊儲存到 MongoDB,但是檢視 MongoDB 的資料可以發現,儲存的每一項都是一個清單形式,這并不是我們想要的。針對這個問題,我們可以修改 Items 裡面關于資料模型的定義,對于每一項資料都隻取第一個元素,代碼如下:

import scrapy
from scrapy.loader.processors import TakeFirst


class SinaUserItem(scrapy.Item):
    # 微網誌使用者唯一辨別
    user_id = scrapy.Field(output_processor=TakeFirst())
    # 使用者昵稱
    username = scrapy.Field(output_processor=TakeFirst())
    # 微網誌數量
    webo_num = scrapy.Field(output_processor=TakeFirst())
    # 關注人數
    follow_num = scrapy.Field(output_processor=TakeFirst())
    # 粉絲人數
    fans_num = scrapy.Field(output_processor=TakeFirst())
    # 性别
    gender = scrapy.Field(output_processor=TakeFirst())
    # 地區
    district = scrapy.Field(output_processor=TakeFirst())
    # 省份
    province = scrapy.Field(output_processor=TakeFirst())
    # 地市
    city = scrapy.Field(output_processor=TakeFirst())
    # 生日
    birthday = scrapy.Field(output_processor=TakeFirst())
    # 簡介
    brief_intro = scrapy.Field(output_processor=TakeFirst())
    # 認證
    identify = scrapy.Field(output_processor=TakeFirst())
    # 頭像 URL
    head_img = scrapy.Field(output_processor=TakeFirst())
    # 爬取時間
    crawl_time = scrapy.Field(output_processor=TakeFirst())
           

這樣我們就實作了我們預期的效果了。

總結

這一篇部落格,我們實作了從橫向和縱向擴充爬取使用者資訊并将使用者資訊儲存到 MongoDB。在下一節中,我們将對爬蟲的一些反爬技術進行介紹,例如添加 IP代理池、Cookies池以及随機選取 User-Agent 等。讓我們的爬蟲應用反爬機制更加健全。

下一篇:[Python 爬蟲] 使用 Scrapy 爬取新浪微網誌使用者資訊(四) —— 應對反爬技術(選取 User-Agent、添加 IP代理池以及Cookies池 )