Python網路爬蟲3 – 生產者消費者模型爬取某金融網站資料

litreily發表於2019-02-28

部落格首發於www.litreily.top

應一位金融圈的朋友所託,幫忙寫個爬蟲,幫他爬取中國期貨行業協議網站中所有金融機構的從業人員資訊。網站資料的獲取本身比較簡單,但是為了學習一些新的爬蟲方法和技巧,即本文要講述的生產者消費者模型,我又學習了一下Python中佇列庫queue及執行緒庫Thread的使用方法。

生產者消費者模型

生產者消費者模型非常簡單,相信大部分程式設計師都知道,就是一方作為生產者不斷提供資源,另一方作為消費者不斷消費資源。簡單點說,就好比餐館的廚師和顧客,廚師作為生產者不斷製作美味的食物,而顧客作為消費者不斷食用廚師提供的食物。此外,生產者與消費者之間可以是一對一、一對多、多對一和多對多的關係。

那麼這個模型和爬蟲有什麼關係呢?其實,爬蟲可以認為是一個生產者,它不斷從網站爬取資料,爬取到的資料就是食物;而所得資料需要消費者進行資料清洗,把有用的資料吸收掉,把無用的資料丟棄。

在實踐過程中,爬蟲爬取和資料清洗分別對應一個Thread,兩個執行緒之間通過順序佇列queue傳遞資料,資料傳遞過程就好比餐館服務員從廚房把食物送到顧客餐桌上的過程。爬取執行緒負責爬取網站資料,並將原始資料存入佇列,清洗執行緒從佇列中按入隊順序讀取原始資料並提取出有效資料。

以上便是對生產者消費者模型的簡單介紹了,下面針對本次爬取任務予以詳細說明。

分析站點

http://www.cfachina.org/cfainfo/organbaseinfoServlet?all=personinfo

home page

我們要爬取的資料是主頁顯示的表格中所有期貨公司的從業人員資訊,每個公司對應一個機構編號(G01001~G01198)。從上圖可以看到有主頁有分頁,共8頁。以G01001方正中期期貨公司為例,點選該公司名稱跳轉至對應網頁如下:

personinfo

從網址及網頁內容可以提取出以下資訊:

  1. 網址
  • http://www.cfachina.org/cfainfo/organbaseinfoOneServlet?organid=+G01001+&currentPage=1&pageSize=20&selectType=personinfo
    • organid: 機構編號,+G01001+ ~ +G01198+
    • currentPage: 該機構從業人員資訊當前頁面編號
    • pageSize: 每個頁面顯示的人員個數,預設20
    • selectType: 固定為personinfo
  1. 機構名稱mechanism_name,在每頁表格上方可以看到當前機構名稱
  2. 從業人員資訊,即每頁的表格內容,也是我們要爬取的物件
  3. 該機構從業人員資訊總頁數page_cnt

我們最終爬取的資料可以按機構名稱儲存到對應的txt檔案或excel檔案中。

獲取機構名稱

get mechanism name

獲取到某機構的任意從業資訊頁面後,使用BeautifulSoup可快速提取機構名稱。

mechanism_name = soup.find(``, {`class`:`gst_title`}).find_all(`a`)[2].get_text()
複製程式碼

那麼有人可能會問,既然主頁表格都已經包含了所有機構的編號和名稱,為何還要多此一舉的再獲取一次呢?這是因為,我壓根就不想爬主頁的那些表格,直接根據機構編號的遞增規律生成對應的網址即可,所以獲取機構名稱的任務就放在了爬取每個機構首個資訊頁面之後。

獲取機構資訊對應的網頁數量

get count of page

每個機構的資料量是不等的,幸好每個頁面都包含了當前頁面數及總頁面數。使用以下程式碼即可獲取頁碼數。

url_re = re.compile(`#currentPage.*+.*+`(d+)``)
page_cnt = url_re.search(html).group(1)
複製程式碼

從每個機構首頁獲取頁碼數後,便可for迴圈修改網址引數中的currentPage,逐頁獲取機構資訊。

獲取當前頁面從業人員資訊

get personinfo

針對如上圖所示的一個特定資訊頁時,人員資訊被存放於一個表中,除了固定的表頭資訊外,人員資訊均被包含在一個帶有idtr標籤中,所以使用BeautifulSoup可以很容易提取出頁面內所有人員資訊。

soup.find_all(`tr`, id=True)
複製程式碼

確定爬取方案

一般的想法當然是逐頁爬取主頁資訊,然後獲取每頁所有機構對應的網頁連結,進而繼續爬取每個機構資訊。

但是由於該網站的機構資訊網址具有明顯的規律,我們根據每個機構的編號便可直接得到每個機構每個資訊頁面的網址。所以具體爬取方案如下:

  1. 將所有機構編號網址存入佇列url_queue
  2. 新建生產者執行緒SpiderThread完成抓取任務
  • 迴圈從佇列url_queue中讀取一個編號,生成機構首頁網址,使用requests抓取之
  • 從抓取結果中獲取頁碼數量,若為0,則返回該執行緒第1步
  • 迴圈爬取當前機構剩餘頁面
  • 將頁面資訊存入佇列html_queue
  1. 新建消費者執行緒DatamineThread完成資料清洗任務
  • 迴圈從佇列html_queue中讀取一組頁面資訊
  • 使用BeautifulSoup提取頁面中的從業人員資訊
  • 將資訊以二維陣列形式儲存,最後交由資料儲存類Storage存入本地檔案

程式碼實現

生成者SpiderThread

爬蟲執行緒先從佇列獲取一個機構編號,生成機構首頁網址並進行爬取,接著判斷機構頁面數量是否為0,如若不為0則繼續獲取機構名稱,並根據頁面數迴圈爬取剩餘頁面,將原始html資料以如下dict格式存入佇列html_queue:

{
    `name`: mechanismId_mechanismName,
    `num`: currentPage,
    `content`: html
}
複製程式碼

爬蟲產生的資料佇列html_queue將由資料清洗執行緒進行處理,下面是爬蟲執行緒的主程式,整個執行緒程式碼請看後面的原始碼

def run(self):
    while True:
        mechanism_id = `G0` + self.url_queue.get()

        # the first page`s url
        url = self.__get_url(mechanism_id, 1)
        html = self.grab(url)

        page_cnt = self.url_re.search(html.text).group(1)
        if page_cnt == `0`:
            self.url_queue.task_done()
            continue
        
        soup = BeautifulSoup(html.text, `html.parser`)
        mechanism_name = soup.find(``, {`class`:`gst_title`}).find_all(`a`)[2].get_text()
        print(`
Grab Thread: get %s - %s with %s pages
` % (mechanism_id, mechanism_name, page_cnt))

        # put data into html_queue
        self.html_queue.put({`name`:`%s_%s` % (mechanism_id, mechanism_name), `num`:1, `content`:html})
        for i in range(2, int(page_cnt) + 1):
            url = self.__get_url(mechanism_id, i)
            html = self.grab(url)
            self.html_queue.put({`name`:`%s_%s` % (mechanism_id, mechanism_name), `num`:i, `content`:html})
        
        self.url_queue.task_done()
複製程式碼

消費者DatamineThread

資料清洗執行緒比較簡單,就是從生產者提供的資料佇列html_queue逐一提取html資料,然後從html資料中提取從業人員資訊,以二維陣列形式儲存,最後交由儲存模組Storage完成資料儲存工作。

class DatamineThread(Thread):
    """Parse data from html"""
    def __init__(self, html_queue, filetype):
        Thread.__init__(self)
        self.html_queue = html_queue
        self.filetype = filetype

    def __datamine(self, data):
        ```Get data from html content```
        soup = BeautifulSoup(data[`content`].text, `html.parser`)
        infos = []
        for info in soup.find_all(`tr`, id=True):
            items = []
            for item in info.find_all(`td`):
                items.append(item.get_text())
            infos.append(items)
        return infos
        
    def run(self):
        while True:
            data = self.html_queue.get()
            print(`Datamine Thread: get %s_%d` % (data[`name`], data[`num`]))

            store = Storage(data[`name`], self.filetype)
            store.save(self.__datamine(data))
            self.html_queue.task_done()
複製程式碼

資料儲存Storage

我寫了兩類檔案格式的儲存函式,write_txt, write_excel,分別對應txt,excel檔案。實際儲存時由呼叫方確定檔案格式。

def save(self, data):
    {
        `.txt`: self.write_txt,
        `.xls`: self.write_excel
    }.get(self.filetype)(data)
複製程式碼

存入txt檔案

存入txt檔案是比較簡單的,就是以附加(a)形式開啟檔案,寫入資料,關閉檔案。其中,檔名稱由呼叫方提供。寫入資料時,每個人員資訊佔用一行,以製表符 分隔。

def write_txt(self, data):
    ```Write data to txt file```
    fid = open(self.path, `a`, encoding=`utf-8`)

    # insert the header of table
    if not os.path.getsize(self.path):
        fid.write(`	`.join(self.table_header) + `
`)
    
    for info in data:
        fid.write(`	`.join(info) + `
`)
    fid.close()
複製程式碼

存入Excel檔案

存入Excel檔案還是比較繁瑣的,由於經驗不多,選用的是xlwt, xlrdxlutils庫。說實話,這3個庫真心不大好用,勉強完成任務而已。為什麼這麼說,且看:

  1. 修改檔案麻煩:xlwt只能寫,xlrd只能讀,需要xlutilscopy函式將xlrd讀取的資料複製到記憶體,再用xlwt修改
  2. 只支援.xls檔案:.xlsx經讀寫也會變成.xls格式
  3. 表格樣式易變:只要重新寫入檔案,表格樣式必然重置

所以後續我肯定會再學學其它的excel庫,當然,當前解決方案暫時還用這三個。程式碼如下:

def write_excel(self, data):
    ```write data to excel file```
    if not os.path.exists(self.path):
        header_style = xlwt.easyxf(`font:name 楷體, color-index black, bold on`)
        wb = xlwt.Workbook(encoding=`utf-8`)
        ws = wb.add_sheet(`Data`)

        # insert the header of table
        for i in range(len(self.table_header)):
            ws.write(0, i, self.table_header[i], header_style)
    else:
        rb = open_workbook(self.path)
        wb = copy(rb)
        ws = wb.get_sheet(0)
    
    # write data
    offset = len(ws.rows)
    for i in range(0, len(data)):
        for j in range(0, len(data[0])):
            ws.write(offset + i, j, data[i][j])

    # When use xlutils.copy.copy function to copy data from exist .xls file,
    # it will loss the origin style, so we need overwrite the width of column,
    # maybe there some other good solution, but I have not found yet.
    for i in range(len(self.table_header)):
        ws.col(i).width = 256 * (10, 10, 15, 20, 50, 20, 15)[i]

    # save to file
    while True:
        try:
            wb.save(self.path)
            break
        except PermissionError as e:
            print(`{0} error: {1}`.format(self.path, e.strerror))
            time.sleep(5)
        finally:
            pass
複製程式碼

說明:

  1. 一個檔案對應一個機構的資料,需要多次讀取和寫入,所以需要計算檔案寫入時的行數偏移量offset,即當前檔案已包含資料的行數
  2. 當被寫入檔案被人為開啟時,會出現PermissionError異常,可以在捕獲該異常然後提示錯誤資訊,並定時等待直到檔案被關閉。

main

主函式用於建立和啟動生產者執行緒和消費者執行緒,同時為生產者執行緒提供機構編號佇列。

url_queue = queue.Queue()
html_queue = queue.Queue()

def main():
    for i in range(1001, 1199):
        url_queue.put(str(i))

    # create and start a spider thread
    st = SpiderThread(url_queue, html_queue)
    st.setDaemon(True)
    st.start()

    # create and start a datamine thread
    dt = DatamineThread(html_queue, `.xls`)
    dt.setDaemon(True)
    dt.start()

    # wait on the queue until everything has been processed
    url_queue.join()
    html_queue.join()
複製程式碼

從主函式可以看到,兩個佇列都呼叫了join函式,用於阻塞,直到對應佇列為空為止。要注意的是,佇列操作中,每個出隊操作queue.get()需要對應一個queue.task_done()操作,否則會出現佇列資料已全部處理完,但主執行緒仍在執行的情況。

至此,爬蟲的主要程式碼便講解完了,下面是完整原始碼。

原始碼

#!/usr/bin/python3
# -*-coding:utf-8-*-

import queue
from threading import Thread

import requests

import re
from bs4 import BeautifulSoup

import os
import platform

import xlwt
from xlrd import open_workbook
from xlutils.copy import copy

import time

# url format ↓
# http://www.cfachina.org/cfainfo/organbaseinfoOneServlet?organid=+G01001+&currentPage=1&pageSize=20&selectType=personinfo&all=undefined
# organid: +G01001+, +G01002+, +G01003+, ...
# currentPage: 1, 2, 3, ...
# pageSize: 20(default)
# 
# Algorithm design:
# 2 threads with 2 queues
# Thread-1, get first page url, then get page_num and mechanism_name from first page
# Thread-2, parse html file and get data from it, then output data to local file
# url_queue data -> `url`  # first url of each mechanism
# html_queue data -> {`name`:`mechanism_name`, `html`:data}

url_queue = queue.Queue()
html_queue = queue.Queue()


class SpiderThread(Thread):
    """Threaded Url Grab"""
    def __init__(self, url_queue, html_queue):
        Thread.__init__(self)
        self.url_queue = url_queue
        self.html_queue = html_queue
        self.page_size = 20
        self.url_re = re.compile(`#currentPage.*+.*+`(d+)``)
        self.headers = {`User-Agent`: `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36`}

    def __get_url(self, mechanism_id, current_page):
        return `http://www.cfachina.org/cfainfo/organbaseinfoOneServlet?organid=+%s+&currentPage=%d&pageSize=%d&selectType=personinfo&all=undefined` 
        % (mechanism_id, current_page, self.page_size)

    def grab(self, url):
        ```Grab html of url from web```
        while True:
            try:
                html = requests.get(url, headers=self.headers, timeout=20)
                if html.status_code == 200:
                    break
            except requests.exceptions.ConnectionError as e:
                print(url + ` Connection error, try again...`)
            except requests.exceptions.ReadTimeout as e:
                print(url + ` Read timeout, try again...`)
            except Exception as e:
                print(str(e))
            finally:
                pass
        return html
    
    def run(self):
        ```Grab all htmls of mechanism one by one
        Steps:
            1. grab first page of each mechanism from url_queue
            2. get number of pages and mechanism name from first page
            3. grab all html file of each mechanism
            4. push all html to html_queue
        ```
        while True:
            mechanism_id = `G0` + self.url_queue.get()

            # the first page`s url
            url = self.__get_url(mechanism_id, 1)
            html = self.grab(url)

            page_cnt = self.url_re.search(html.text).group(1)
            if page_cnt == `0`:
                self.url_queue.task_done()
                continue
            
            soup = BeautifulSoup(html.text, `html.parser`)
            mechanism_name = soup.find(``, {`class`:`gst_title`}).find_all(`a`)[2].get_text()
            print(`
Grab Thread: get %s - %s with %s pages
` % (mechanism_id, mechanism_name, page_cnt))

            # put data into html_queue
            self.html_queue.put({`name`:`%s_%s` % (mechanism_id, mechanism_name), `num`:1, `content`:html})
            for i in range(2, int(page_cnt) + 1):
                url = self.__get_url(mechanism_id, i)
                html = self.grab(url)
                self.html_queue.put({`name`:`%s_%s` % (mechanism_id, mechanism_name), `num`:i, `content`:html})
            
            self.url_queue.task_done()
    

class DatamineThread(Thread):
    """Parse data from html"""
    def __init__(self, html_queue, filetype):
        Thread.__init__(self)
        self.html_queue = html_queue
        self.filetype = filetype

    def __datamine(self, data):
        ```Get data from html content```
        soup = BeautifulSoup(data[`content`].text, `html.parser`)
        infos = []
        for info in soup.find_all(`tr`, id=True):
            items = []
            for item in info.find_all(`td`):
                items.append(item.get_text())
            infos.append(items)
        return infos
        
    def run(self):
        while True:
            data = self.html_queue.get()
            print(`Datamine Thread: get %s_%d` % (data[`name`], data[`num`]))

            store = Storage(data[`name`], self.filetype)
            store.save(self.__datamine(data))
            self.html_queue.task_done()


class Storage():
    def __init__(self, filename, filetype):
        self.filetype = filetype
        self.filename = filename + filetype
        self.table_header = (`姓名`, `性別`, `從業資格號`, `投資諮詢從業證照號`, `任職部門`, `職務`, `任現職時間`)
        self.path = self.__get_path()

    def __get_path(self):
        path = {
            `Windows`: `D:/litreily/Documents/python/cfachina`,
            `Linux`: `/mnt/d/litreily/Documents/python/cfachina`
        }.get(platform.system())

        if not os.path.isdir(path):
            os.makedirs(path)
        return `%s/%s` % (path, self.filename)
    
    def write_txt(self, data):
        ```Write data to txt file```
        fid = open(self.path, `a`, encoding=`utf-8`)

        # insert the header of table
        if not os.path.getsize(self.path):
            fid.write(`	`.join(self.table_header) + `
`)
        
        for info in data:
            fid.write(`	`.join(info) + `
`)
        fid.close()
    
    def write_excel(self, data):
        ```write data to excel file```
        if not os.path.exists(self.path):
            header_style = xlwt.easyxf(`font:name 楷體, color-index black, bold on`)
            wb = xlwt.Workbook(encoding=`utf-8`)
            ws = wb.add_sheet(`Data`)

            # insert the header of table
            for i in range(len(self.table_header)):
                ws.write(0, i, self.table_header[i], header_style)
        else:
            rb = open_workbook(self.path)
            wb = copy(rb)
            ws = wb.get_sheet(0)
        
        # write data
        offset = len(ws.rows)
        for i in range(0, len(data)):
            for j in range(0, len(data[0])):
                ws.write(offset + i, j, data[i][j])

        # When use xlutils.copy.copy function to copy data from exist .xls file,
        # it will loss the origin style, so we need overwrite the width of column,
        # maybe there some other good solution, but I have not found yet.
        for i in range(len(self.table_header)):
            ws.col(i).width = 256 * (10, 10, 15, 20, 50, 20, 15)[i]

        # save to file
        while True:
            try:
                wb.save(self.path)
                break
            except PermissionError as e:
                print(`{0} error: {1}`.format(self.path, e.strerror))
                time.sleep(5)
            finally:
                pass
    
    def save(self, data):
        ```Write data to local file.

        According filetype to choose function to save data, filetype can be `.txt` 
        or `.xls`, but `.txt` type is saved more faster then `.xls` type

        Args:
            data: a 2d-list array that need be save
        ```
        {
            `.txt`: self.write_txt,
            `.xls`: self.write_excel
        }.get(self.filetype)(data)


def main():
    for i in range(1001, 1199):
        url_queue.put(str(i))

    # create and start a spider thread
    st = SpiderThread(url_queue, html_queue)
    st.setDaemon(True)
    st.start()

    # create and start a datamine thread
    dt = DatamineThread(html_queue, `.xls`)
    dt.setDaemon(True)
    dt.start()

    # wait on the queue until everything has been processed
    url_queue.join()
    html_queue.join()


if __name__ == `__main__`:
    main()
複製程式碼

爬取測試

spider
save to txt
save to excel

寫在最後

  • 測試發現,寫入txt的速度明顯高於寫入excel的速度
  • 如果將頁面網址中的pageSize修改為1000或更大,則可以一次性獲取某機構的所有從業人員資訊,而不用逐頁爬取,效率可以大大提高。
  • 該爬蟲已託管至github Python-demos

相關文章