非同步最佳化與資料入庫:頂點小說爬蟲進階實戰

存子發表於2024-07-07

頂點小說進階

建議

這篇頂點小說進階包括(資料入庫、非同步爬蟲)

看之前可以先看我之前釋出的文章(從零開始學習Python爬蟲:頂點小說全網爬取實戰)

入庫

# 入庫
def save_to_mysql(db_name, table_name, table_column_str, table_info_str):
    db = pymysql.connect(user='host', password='Lun18532104295', db=db_name)
    cursor = db.cursor()
    sql = f'insert into {table_name}({table_column_str}) values({table_info_str})'
    cursor.execute(sql)
    db.commit()

完善主執行緒

if __name__ == '__main__':
    # 獲取小說分類url
    type_lists = get_type()
    # 分類url預設為第一頁
    for first_page_url in type_lists:
        # 獲取帶分類的url的前半截
        type_url = first_page_url.split('1')[0]
        # 獲取此分類下最大頁
        max_page = get_max_page(first_page_url)
        # 生成此分類下每一頁url
        for every_page in range(1, int(max_page[0]) + 1):
            every_page_url = f"{type_url}{every_page}/"
            # 獲取小說列表頁資訊
            book_info_lists = get_book_info(every_page_url)
            # 獲取章節列表url
            for book_info in book_info_lists:
                print(f"爬取小說:{book_info[1]}...")
                # 入庫小說資訊
                save_to_mysql('xiaoshuo', 'books', 'book_id, book_name, new_chapter, author, update_time, font_num, summary', ''.join(book_info))

                book_id = book_info[0]
                chapter_urls = get_chapter_urls(f"https://www.cdbxs.com/booklist/b/{book_id}/1")
                for chapter_url in chapter_urls:
                    # print(chapter_url)
                    chapter_info = get_chapter_info(chapter_url)
                    # 入庫小說章節資訊
                    print(chapter_info)
                    save_to_mysql('xiaoshuo', 'chapters', 'chapter_name,chapter_content', ''.join(chapter_info))

                    # print(f"title:{chapter_info[0]}")
                    # print(f"content:{chapter_info[1]}")

非同步爬蟲

介紹

爬蟲是IO密集型任務。比如我們使用requests庫來爬取某個站點的話,發出一個請求之後,程式必須要等待網站返回響應之後才能接著執行,而在等待響應的過程中,整個爬蟲程式是一直等待的,實際上沒有做任何的事情。
非同步是提高程式執行效率的一種有效方法。

基本概念

  • 阻塞
    • 阻塞狀態指程式未得到所需計算資源時被掛起的狀態。程式在等待某個操作完成期間,自身無法繼續處理其他的事情,則稱該程式在操作上是阻塞的。
    • 常見的阻塞形式有:網路I/O阻塞、磁碟I/O阻塞、使用者輸入阻塞等,阻塞是無處不在的,包括CPU切換上下文時,所有的程序都無法真正處理事情,他們會被阻塞。如果是多核CPU則正在執行上下文切換操作的核不可被利用。
  • 非阻塞
    • 程式在等待某操作過程中,自身不被阻塞,可以繼續處理其他的事情,則稱該程式在該操作上是非阻塞的。非阻塞並不是在任何程式級別、任何情況下都可以存在的,僅當程式封裝的級別可以囊括獨立的子程式單位時,它才可能存在非阻塞狀態。
    • 非阻塞的存在是因為阻塞存在,正因為某個操作阻塞導致的耗時與效率低下,我們才要把它變成非阻塞的
  • 同步
    • 不同程式單位為了完成某個任務,在執行過程中需靠某種通訊方式以協調一致,我們稱這些程式單位是同步執行的。
    • 例如購物系統中更新商品庫存,需要用“行鎖”作為通訊訊號,讓不同的更新請求強制排隊順序執行,那更新庫存的操作是同步的。簡而言之,同步意味著有序。
  • 非同步
    • 為完成某個任務,不同程式單元之間過程中無需通訊協調,也能完成任務的方式,不相關的程式單元之間可以是非同步的。
    • 例如,爬蟲下載網頁。排程程式呼叫下載程式後,即可排程其他任務,而無需與該下載任務保持通訊以協調行為。不同網頁的下載、儲存等操作都是無關的,也無需相互通訊協調。這些非同步操作的完成時刻並不確定。簡而言之,非同步意味著無序。
  • 多程序
    • 多程序就是利用CPU的多核優勢,在同一時間並行地執行多個任務,可以大大提高執行效率。
  • 協程(Coroutine)
    • 又稱微執行緒、纖程,協程是一種使用者態地輕量級執行緒。
    • 協程擁有自己的暫存器上下文和棧。協程排程切換時,將暫存器上下文和棧儲存到其他地方,在切回來地時候,回覆先前儲存的暫存器上下文和棧。因此協程能保留上一次呼叫的狀態,即所有區域性狀態的一個特定組合,每次過程重入時,就相當於進入上一次呼叫的狀態。
    • 協程本質上是一個單程序,協程相對於多程序來說,無需執行緒上下文切換的開銷,無需原子操作鎖定及同步的開銷。我們可以使用協程來實現非同步操作。比如在網路爬蟲場景下,我們發出一個請求之後,需要等待一定的時間才能得到響應,但其實在這個等待過程中,程式可以幹許多其他的事情,等到響應得到之後才切換回來繼續處理,這樣可以充分利用CPU和其他資源,這就是協程的優勢。

協程

asyncio

  • event_loop:事件迴圈池,相當於一個無限迴圈,我們可以把一些函式註冊到這個事件池中,當滿足條件發生的時候,就會呼叫對應的處理方法。
  • coroutine:協程物件,我們可以將協程物件註冊到事件迴圈池中,它會被事件迴圈池呼叫。我們可以用async關鍵字來定義一個方法,那這個方法在呼叫時不會立即被執行,而是返回一個協程物件。
  • task:任務,對協程物件的進一步封裝,包含了任務的各個狀態。
  • await:用來掛起阻塞方法的執行
import asyncio


async def execute(x):
    print(f"Number: {x}")

# 建立協程物件
coroutine = execute(10)
# 封裝成任務(可省略,將協程物件放入事件迴圈池後自動封裝為任務)
task = asyncio.ensure_future(coroutine)
print(task)
# 建立事件迴圈池
loop = asyncio.get_event_loop()
# 註冊任務,開始執行
loop.run_until_complete(task)
print(task)
task物件的繫結回撥操作

可以為某個task繫結一個回撥方法

# 為task物件繫結回撥函式
async def call_on():
    status = requests.get("https://www.baidu.com")
    return status

def call_back(task):
    print(f"status: {task.result()}")

# 建立協程物件
coroutine1 = call_on()
# 將協程物件封裝為任務
task1 = asyncio.ensure_future(coroutine1)
# 建立事件迴圈池
loop = asyncio.get_event_loop()
# 為task物件繫結回撥函式
task1.add_done_callback(call_back)
# 註冊任務
loop.run_until_complete(task1)

非同步爬蟲實現(基於協程)

await後面的物件
  • 一個原生的coroutine物件
  • 一個返回coroutine物件的生成器
  • 一個包含await方法的物件返回的一個迭代器
aiohttp

aiohttp是一個支援非同步請求的庫,利用它和asyncio配合我們可以方便地實現非同步請求操作。下面以訪問部落格裡面的文章,並返回response.text()為例,實現非同步爬蟲。

import time
import requests
import aiohttp
from lxml import etree
import asyncio
import logging

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s : %(message)s')
url = 'https://blog.csdn.net/nav/ai'
start_time = time.time()


# 獲取部落格裡文章連結
def get_urls():
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0'
    }
    source = requests.get(url=url, headers=headers).text
    urls = etree.HTML(source).xpath(
        "//div[@class='content']/a/@href")
    return urls


# 非同步請求部落格裡文章連結(aiohttp)
async def request_page(url):
    logging.info(f'scraping {url}')
    async with aiohttp.ClientSession() as session:
        # 發起請求
        response = await session.get(url)
        return await response.text()


# main函式
def main():
    # 獲取部落格裡文章連結
    urls = get_urls()
    # 建立任務列表
    tasks = [asyncio.ensure_future(request_page(url)) for url in urls]
    # 建立事件迴圈池
    loop = asyncio.get_event_loop()
    # 處理協程物件列表(不可少,用於併發執行多個可等待物件(比如協程),並將它們的結果收集到一個列表中。)
    results = asyncio.gather(*tasks)
    # 註冊任務,開始執行
    loop.run_until_complete(results)


if __name__ == '__main__':
    main()
    end_time = time.time()
    logging.info(f"total time {end_time - start_time} seconds")

Question1:text和content啥區別?

Answer1:

響應物件中,有兩個常用的屬性:text 和 content。

text:

text 屬性返回的是響應內容的字串形式,通常是根據 HTTP 響應的內容推測出的字元編碼來解碼的文字。例如,如果伺服器返回的是 HTML 內容,那麼text 屬性會返回解碼後的 HTML 文字內容。
content:

content 屬性返回的是響應內容的位元組形式,即原始的未解碼資料。這個屬性通常用於獲取非文字型別的內容,比如圖片、音訊、影片等檔案。對於文字內容,你也可以透過 response.content.decode('utf-8') 等方法將其轉換為字串形式。
區別總結如下:

text: 返回解碼後的文字內容,適用於文字型別的響應資料,如 HTML、JSON 等。
content: 返回原始的位元組形式的響應內容,適用於任何型別的響應資料,包括文字和非文字。

Question2:為什麼用aiohttp來配合asyncio而不是用requests?

aiohttp為非同步請求庫,requests為同步請求庫。
要實現爬蟲非同步請求,需要用到await來將請求掛起,而await後不能跟同步請求,需要跟非同步請求。

執行緒

非同步爬蟲實現(基於執行緒)

import time
import requests
from lxml import etree
import logging
from concurrent.futures import ThreadPoolExecutor

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s : %(message)s')
url = 'https://blog.csdn.net/nav/ai'
start_time = time.time()


# 獲取部落格裡文章連結
def get_urls():
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0'
    }
    source = requests.get(url=url, headers=headers).text
    urls = etree.HTML(source).xpath(
        "//div[@class='content']/a/@href")
    return urls


# 請求部落格裡文章連結
def request_page(url):
    logging.info(f'scraping {url}')
    headers = {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0'
    }
    return requests.get(url=url, headers=headers).text


# main函式
def main():
    # 獲取部落格裡文章連結
    urls = get_urls()
    # 建立執行緒池
    with ThreadPoolExecutor(max_workers=6) as executor:
        executor.map(request_page, urls)


if __name__ == '__main__':
    main()
    end_time = time.time()
    logging.info(f"total time {end_time - start_time} seconds")

程序、執行緒、協程的關係與區別

  1. 程序:程序是計算機中的程式關於某資料集合上的一次執行活動,是系統進行資源分配和排程的基本單位,是作業系統結構的基礎

  2. 執行緒(Thread):執行緒有時被稱為輕量級程序,是程式執行流的最小單元。一個標準的執行緒由執行緒ID、當前指令指標(PC)、暫存器集合和堆疊組成。

  3. 協程:協程是一種比執行緒更加輕量級的一種函式。正如一個程序可以擁有多個執行緒一樣,一個執行緒可以擁有多個協程。協程不是被作業系統核心所管理的,而是完全由程式所控制的,即在使用者態執行。 這樣帶來的好處是:效能有大幅度的提升,因為不會像執行緒切換那樣消耗資源。

  4. 聯絡

    •   協程既不是程序也不是執行緒,協程僅是一個特殊的函式。協程、程序和執行緒不是一個維度的。
        一個程序可以包含多個執行緒,一個執行緒可以包含多個協程。雖然一個執行緒內的多個協程可以切換但是這多個協程是序列執行的,某個時刻只能有一個協程在執行,沒法利用CPU的多核能力。
        執行緒與程序一樣,也存在上下文切換問題。
        程序的切換者是作業系統,切換時機是根據作業系統自己的切換策略來決定的,使用者是無感的。程序的切換內容包括頁全域性目錄、核心棧和硬體上下文,切換內容被儲存在記憶體中。 程序切換過程採用的是“從使用者態到核心態再到使用者態”的方式,切換效率低。
        執行緒的切換者是作業系統,切換時機是根據作業系統自己的切換策略來決定的,使用者是無感的。執行緒的切換內容包括核心棧和硬體上下文。執行緒切換內容被儲存在核心棧中。執行緒切換過程採用的是“從使用者態到核心態再到使用者態”的方式,切換效率中等。
        協程的切換者是使用者(程式設計者或應用程式),切換時機是使用者自己的程式來決定的。協程的切換內容是硬體上下文,切換記憶體被儲存在用自己的變數(使用者棧或堆)中。協程的切換過程只有使用者態(即沒有陷入核心態),因此切換效率高。
      

非同步mysql(aiomysql)和同步mysql(pymsql)的使用

mysql操作為同步程式碼(也就是必須等待它的返回才會往下執行,如果一個SQL執行得比較久,那麼會直接卡死這個執行緒),在非同步環境中無法呼叫同步程式碼,所以需要呼叫非同步mysql進行入庫。

import asyncio
import aiomysql
# UUID是 通用唯一識別碼(Universally Unique Identifier)的縮寫.
import shortuuid
import pymysql


# 非同步
# aiomysql做資料庫連線的時候,需要這個loop物件
async def async_basic(loop):
    pool = await aiomysql.create_pool(
        host="127.0.0.1",
        port=3306,
        user='root',
        password='123456',
        db='test',
        loop=loop
    )
    async with pool.acquire() as conn:
        async with conn.cursor() as cursor:
            for x in range(10000):
                content = shortuuid.uuid()
                sql = f"insert into mybrank(brank) values('{content}')"
                # 執行sql語句
                await cursor.execute(sql)
            await conn.commit()

    # 關閉連線池
    pool.close()
    await pool.wait_closed()


# 同步
def sync_basic():
    conn = pymysql.connect(
        host='127.0.0.1',
        port=3306,
        user='root',
        password='123456',
        db='dvwa',
    )
    with conn.cursor() as cursor:
        for x in range(10000):
            content = shortuuid.uuid()
            sql = f"insert into guestbook(comment_id,comment,name) values(2,'asd','{content})"
            # 執行sql語句
            cursor.execute(sql)
        conn.commit()


if __name__ == '__main__':
    # 非同步: 數量大時用非同步
    loop = asyncio.get_event_loop()
    loop.run_until_complete(async_basic(loop))
    # 同步:sync_basic()
    sync_basic()

案例:頂點小說完善(基於aiohttp、aiomysql、協程)

建議:基於我上篇文章的頂點小說基礎,進行非同步最佳化。

非同步最佳化思路:

  1. aiohttp、協程:有一個scrape_api方法:非同步請求一個網址獲取頁面原始碼
  2. 獲取到原始碼正常xpath解析資料
  3. 基於aiomysql入庫:有個init_pool方法:初始化資料庫連線池,有個close_pool方法:關閉資料庫連線池;入庫程式碼改成非同步
  4. 整體用類Spider實現,有屬性session(非同步網路請求)、semaphore(設定協程數量)
  5. 對於爬取每本小說的所有章節的標題和正文,作為任務放到事件迴圈池中併發執行
import asyncio
import logging
import time
import requests
from lxml import etree
import aiohttp
import aiomysql
from aiohttp import ContentTypeError

CONCURRENCY = 4

logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s : %(message)s')


class Spider(object):
    def __init__(self):
        # 方便設定頭部資訊、代理IP、cookie資訊等
        self.session = None
        # 設定協程數量
        self.semaphore = asyncio.Semaphore(CONCURRENCY)
        # 限制協程的併發數:
        # 如果併發數沒有達到限制: 那麼async with semaphore會瞬間執行完成,進入裡面的正式程式碼中
        # 如果併發數已經達到了限制,那麼其他的協程物件會阻塞在asyn with semaphore這個地方,直到正在執行的某個協程物件完成了,退出了,才會放行一個新的協程物件去替換掉這個已經完成的協程物件

    # 初始化資料庫連線池
    async def init_pool(self):
        self.pool = await aiomysql.create_pool(
            host="127.0.0.1",
            port=3306,
            user="root",
            password="123456",
            db=f"dingdian",
            autocommit=True  # Ensure autocommit is set to True for aiomysql
        )
        # 在 aiomysql.create_pool 方法中,不需要顯式傳遞 loop 引數。aiomysql 會自動使用當前的事件迴圈(即預設的全域性事件迴圈)。

    # 關閉資料庫連線池
    async def close_pool(self):
        if self.pool:
            self.pool.close()
            await self.pool.wait_closed()

    # 獲取url原始碼
    async def scrape_api(self, url):
        # 設定協程數量
        async with self.semaphore:
            logging.info(f"scraping {url}")
            try:
                async with self.session.get(url) as response:
                    # 控制爬取(或請求)的速率,以避免對目標伺服器造成過多的負荷或請求頻率過高而被封禁或限制訪問。
                    await asyncio.sleep(1)
                    # 在非同步環境中,可能需要使用 response.content.read() 或 await response.text() 來獲取文字內容。
                    return await response.text()
            except ContentTypeError as e:  # aiohttp 的 ContentTypeError 異常: 請求內容型別錯誤 或者 響應內容型別錯誤
                # exc_info=True 引數將導致 logging 模組記錄完整的異常資訊,包括棧跟蹤,這對於除錯非常有用。
                logging.error(f'error occurred while scraping {url}', exc_info=True)

    # 獲取小說分類url
    async def get_type(self):
        url = "https://www.cdbxs.com/sort/"
        source = await self.scrape_api(url)
        href_lists = etree.HTML(source).xpath('//ul[@class="nav"]/li/a/@href')[2:-4]
        type_lists = []
        for href in href_lists:
            type_lists.append(f"{url}{href.split('/')[2]}/1/")
        # print(type_lists)
        return type_lists

    # 獲取最大頁
    async def get_max_page(self, first_page_url):
        source = await self.scrape_api(first_page_url)
        # print(source)
        max_page = etree.HTML(source).xpath('//a[13]/text()')
        return max_page

    # 獲取小說列表頁資訊
    async def get_book_info(self, every_page_url):
        source = await self.scrape_api(every_page_url)
        book_lists = []

        lis = etree.HTML(source).xpath("//ul[@class='txt-list txt-list-row5']/li")
        for li in lis:
            book_id_url = li.xpath("span[@class='s2']/a/@href")[0]
            book_id = book_id_url.split('/')[3]
            # 書名
            book_name = li.xpath("span[@class='s2']/a/text()")[0]
            # 最新章節
            new_chapter = li.xpath("span[@class='s3']/a/text()")[0]
            # 作者
            author = li.xpath("span[@class='s4']/text()")[0]
            # 更新時間
            update_time = li.xpath("span[@class='s5']/text()")[0]

            source = await self.scrape_api(f"https://www.cdbxs.com{book_id_url}")
            # 字數
            font_num = etree.HTML(source).xpath("//p[6]/span/text()")[0]
            # 摘要
            summary = etree.HTML(source).xpath("//div[@class='desc xs-hidden']/text()")[0]

            # 以元組新增至 book_lists
            # print((book_id, book_name, new_chapter, author, update_time, font_num, summary))
            book_lists.append((book_id, book_name, new_chapter, author, update_time, font_num, summary))
        return book_lists

    # 獲取章節urls
    async def get_chapter_urls(self, chapter_list_url):
        source = await self.scrape_api(chapter_list_url)
        # 章節url
        chapter_urls = map(lambda x: "https://www.cdbxs.com" + x, etree.HTML(source).xpath(
            "//div[@class='section-box'][2]/ul[@class='section-list fix']/li/a/@href | //div[@class='section-box'][1]/ul[@class='section-list fix']/li/a/@href"))

        return chapter_urls

    # 獲取章節詳情資訊
    async def get_chapter_info(self, chapter_url):
        source = await self.scrape_api(chapter_url)
        # 標題
        title = etree.HTML(source).xpath("//h1[@class='title']/text()")
        # 正文
        content = ''.join(etree.HTML(source).xpath("//div[@id='nb_content']/dd//text()"))
        if title:
            return f'\'{title[0]}\'', f'\'{content}\''
        else:
            return '', f'\'{content}\''

    # 入庫
    async def save_to_mysql(self, table_name, table_column_str, table_info_str):
        async with self.pool.acquire() as conn:
            async with conn.cursor() as cursor:
                sql = f'insert into {table_name}({table_column_str}) values{table_info_str}'
                # 執行SQL語句
                await cursor.execute(sql)
                await conn.commit()

    async def main(self):
        # headers
        headers = {
            "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36 Edg/126.0.0.0"
        }
        # 建立非同步請求需要的session(主要加header頭資訊以及代理,cookie等頭資訊)
        self.session = aiohttp.ClientSession(headers=headers)
        # 獲取小說分類url
        type_lists = await self.get_type()
        # 分類url預設為第一頁
        for first_page_url in type_lists:
            # 獲取帶分類的url的前半截
            type_url = first_page_url.split('1')[0]
            # 獲取此分類下最大頁
            max_page = await self.get_max_page(first_page_url)
            # 生成此分類下每一頁url
            for every_page in range(1, int(max_page[0]) + 1):
                every_page_url = f"{type_url}{every_page}/"
                # 獲取小說列表頁資訊
                book_info_lists = await self.get_book_info(every_page_url)
                # 獲取章節列表url
                for book_info in book_info_lists:
                    print(f"爬取小說:{book_info[1]}...")
                    # 初始化資料庫連線池
                    await self.init_pool()
                    # 入庫小說資訊
                    await self.save_to_mysql('books',
                                             'book_id, book_name, new_chapter, author, update_time, font_num, summary',
                                             book_info)

                    # 獲取章節urls
                    book_id = book_info[0]
                    chapter_urls = await self.get_chapter_urls(f"https://www.cdbxs.com/booklist/b/{book_id}/1")
                    # 生成scrape_detail任務列表
                    scrape_detail_tasks = [asyncio.ensure_future(self.get_chapter_info(chapter_url)) for chapter_url in
                                           chapter_urls]
                    # 併發執行任務,獲取結果
                    chapter_details = list(
                        await asyncio.gather(*scrape_detail_tasks))  # await asyncio.gather(*scrape_detail_tasks生成元組
                    # 入庫
                    # 1.新增book_id 到 chapter_detail
                    for i in range(len(chapter_details)):
                        chapter_detail = list(chapter_details[i])
                        chapter_detail.append(book_id)
                        chapter_detail = tuple(chapter_detail)
                        chapter_details[i] = chapter_detail
                    # 2.儲存至資料庫
                    [await self.save_to_mysql('chapters', 'chapter_name,chapter_content, bid',
                                              chapter_detail) for chapter_detail in chapter_details]
        # 關閉連線池
        self.close_pool()
        # 關閉連線
        await self.session.close()


if __name__ == '__main__':
    # 開始時間
    start_time = time.time()
    # 初始化Spider
    spider = Spider()
    # 建立事件迴圈池
    loop = asyncio.get_event_loop()
    # 註冊
    loop.run_until_complete(spider.main())
    # 結束時間
    end_time = time.time()
    logging.info(f'total time: {end_time - start_time}')

後續釋出爬蟲更多精緻內容(按某培訓機構爬蟲課程順序釋出,歡迎關注後續釋出)

更多精緻內容,關注公眾號:[CodeRealm]

相關文章