告别低效爬虫!7步实现异步协程爬取,数万小说轻松入库

132 阅读25分钟

顶点小说爬虫进阶:数据入库与异步爬虫实战指南

在互联网信息爆炸的时代,爬虫技术已成为获取和处理大规模数据的必备工具。本文将带您深入探索顶点小说爬虫的进阶技术,包括数据入库和异步爬虫实现,让您的爬虫效率提升数倍!

前言

本文是《从零开始学习Python爬虫:顶点小说全网爬取实战》的进阶篇,如果您还没有阅读前篇,建议先查看我之前发布的基础教程,以便更好地理解本文内容。

一、数据入库实战

对于大规模爬虫项目,将爬取的数据存储到数据库中是非常必要的。下面我们来看如何实现数据入库功能:

MySQL数据入库实现

 # 入库
 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密集型任务。使用传统同步爬虫时,程序在等待网站响应的过程中处于空闲状态,造成资源浪费。异步爬虫技术可以让程序在等待响应的同时处理其他任务,显著提高爬取效率。

异步编程核心概念

1. 阻塞与非阻塞

阻塞:程序在等待某个操作完成期间无法处理其他事情,处于挂起状态。常见的阻塞形式包括网络I/O阻塞、磁盘I/O阻塞及用户输入阻塞等。

非阻塞:程序在等待某操作过程中,能够继续处理其他事情。非阻塞状态只有在程序能够封装独立的子程序单位时才可能存在。

2. 同步与异步

同步:不同程序单位为完成任务需要协调一致,有序执行。如购物系统中更新商品库存时,不同的更新请求需要排队顺序执行。

异步:不同程序单元之间无需通信协调,也能完成任务。如爬虫下载不同网页,各下载任务之间无需相互协调,可以无序进行。

3. 多进程与协程

多进程:利用CPU多核优势,在同一时间并行执行多任务,提高执行效率。

协程:又称微线程或纤程,是一种用户态的轻量级线程。协程能保留上次调用状态,相比多进程无需上下文切换,执行效率更高。

协程实现异步爬虫

asyncio基础

asyncio是Python的异步编程库,提供了实现异步编程的基础设施:

  • event_loop:事件循环池,可注册函数并在满足条件时调用
  • coroutine:协程对象,可注册到事件循环池中被调用
  • 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对象绑定回调函数
 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)

aiohttp异步爬虫实战

aiohttp是支持异步请求的库,与asyncio配合可实现高效异步爬虫。下面以CSDN博客文章爬取为例:

 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")
text和content的区别
  • text:返回解码后的文本内容,适用于HTML、JSON等文本类型响应
  • content:返回原始的字节形式数据,适用于任何类型的响应数据,包括文本和非文本
为什么用aiohttp而非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. 线程:程序执行流的最小单元,由线程ID、当前指令指针、寄存器集合和堆栈组成

  3. 协程:比线程更轻量级的函数,一个线程可拥有多个协程,由程序控制而非操作系统内核管理

  4. 关键区别

    • 协程仅是特殊函数,与进程和线程不是一个维度的概念
    • 一个进程可包含多个线程,一个线程可包含多个协程
    • 线程内的多个协程可切换但串行执行,无法利用CPU多核能力
    • 进程切换内容包括页全局目录、内核栈和硬件上下文,切换效率低
    • 线程切换内容包括内核栈和硬件上下文,切换效率中等
    • 协程切换内容仅是硬件上下文,且只在用户态进行,切换效率高

五、异步MySQL与同步MySQL

MySQL操作为同步代码,在异步环境中无法直接调用,需要使用异步MySQL库:

 import asyncio
 import aiomysql
 import shortuuid
 import pymysql
 ​
 # 异步MySQL
 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()
 ​
 # 同步MySQL
 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()

六、顶点小说异步爬虫实战

基于前文基础,我们来实现完整的顶点小说异步爬虫系统:

异步优化思路

  1. 使用aiohttp和协程实现异步请求
  2. 对获取的源码进行xpath解析
  3. 使用aiomysql实现异步数据入库
  4. 采用面向对象方式,创建Spider类封装功能
  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}')

总结

通过本文,我们详细介绍了顶点小说爬虫的进阶技术,包括数据入库和异步爬虫实现。与传统同步爬虫相比,异步爬虫能够显著提高爬取效率,特别是在处理大量网页请求时更为明显。同时,我们也深入探讨了进程、线程和协程的区别与联系,以及异步MySQL的使用方法。

希望这篇教程能够帮助您更好地理解和应用Python爬虫技术,构建高效的数据采集系统。未来我们将继续分享更多爬虫相关的进阶知识,欢迎持续关注!

更多精彩内容

想了解更多Python爬虫和数据分析的精彩内容,欢迎关注公众号:码途有你,我们将定期推送高质量的编程教程和实战案例!

顶点小说爬虫进阶:数据入库与异步爬虫实战指南

在互联网信息爆炸的时代,爬虫技术已成为获取和处理大规模数据的必备工具。本文将带您深入探索顶点小说爬虫的进阶技术,包括数据入库和异步爬虫实现,让您的爬虫效率提升数倍!

前言

本文是《从零开始学习Python爬虫:顶点小说全网爬取实战》的进阶篇,如果您还没有阅读前篇,建议先查看我之前发布的基础教程,以便更好地理解本文内容。

一、数据入库实战

对于大规模爬虫项目,将爬取的数据存储到数据库中是非常必要的。下面我们来看如何实现数据入库功能:

MySQL数据入库实现

# 入库
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密集型任务。使用传统同步爬虫时,程序在等待网站响应的过程中处于空闲状态,造成资源浪费。异步爬虫技术可以让程序在等待响应的同时处理其他任务,显著提高爬取效率。

异步编程核心概念

1. 阻塞与非阻塞

阻塞:程序在等待某个操作完成期间无法处理其他事情,处于挂起状态。常见的阻塞形式包括网络I/O阻塞、磁盘I/O阻塞及用户输入阻塞等。

非阻塞:程序在等待某操作过程中,能够继续处理其他事情。非阻塞状态只有在程序能够封装独立的子程序单位时才可能存在。

2. 同步与异步

同步:不同程序单位为完成任务需要协调一致,有序执行。如购物系统中更新商品库存时,不同的更新请求需要排队顺序执行。

异步:不同程序单元之间无需通信协调,也能完成任务。如爬虫下载不同网页,各下载任务之间无需相互协调,可以无序进行。

3. 多进程与协程

多进程:利用CPU多核优势,在同一时间并行执行多任务,提高执行效率。

协程:又称微线程或纤程,是一种用户态的轻量级线程。协程能保留上次调用状态,相比多进程无需上下文切换,执行效率更高。

协程实现异步爬虫

asyncio基础

asyncio是Python的异步编程库,提供了实现异步编程的基础设施:

  • event_loop:事件循环池,可注册函数并在满足条件时调用
  • coroutine:协程对象,可注册到事件循环池中被调用
  • 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对象绑定回调函数
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)

aiohttp异步爬虫实战

aiohttp是支持异步请求的库,与asyncio配合可实现高效异步爬虫。下面以CSDN博客文章爬取为例:

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")
text和content的区别
  • text:返回解码后的文本内容,适用于HTML、JSON等文本类型响应
  • content:返回原始的字节形式数据,适用于任何类型的响应数据,包括文本和非文本
为什么用aiohttp而非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. 线程:程序执行流的最小单元,由线程ID、当前指令指针、寄存器集合和堆栈组成

  3. 协程:比线程更轻量级的函数,一个线程可拥有多个协程,由程序控制而非操作系统内核管理

  4. 关键区别

    • 协程仅是特殊函数,与进程和线程不是一个维度的概念
    • 一个进程可包含多个线程,一个线程可包含多个协程
    • 线程内的多个协程可切换但串行执行,无法利用CPU多核能力
    • 进程切换内容包括页全局目录、内核栈和硬件上下文,切换效率低
    • 线程切换内容包括内核栈和硬件上下文,切换效率中等
    • 协程切换内容仅是硬件上下文,且只在用户态进行,切换效率高

五、异步MySQL与同步MySQL

MySQL操作为同步代码,在异步环境中无法直接调用,需要使用异步MySQL库:

import asyncio
import aiomysql
import shortuuid
import pymysql

# 异步MySQL
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()

# 同步MySQL
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()

六、顶点小说异步爬虫实战

基于前文基础,我们来实现完整的顶点小说异步爬虫系统:

异步优化思路

  1. 使用aiohttp和协程实现异步请求
  2. 对获取的源码进行xpath解析
  3. 使用aiomysql实现异步数据入库
  4. 采用面向对象方式,创建Spider类封装功能
  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}')

总结

通过本文,我们详细介绍了顶点小说爬虫的进阶技术,包括数据入库和异步爬虫实现。与传统同步爬虫相比,异步爬虫能够显著提高爬取效率,特别是在处理大量网页请求时更为明显。同时,我们也深入探讨了进程、线程和协程的区别与联系,以及异步MySQL的使用方法。

希望这篇教程能够帮助您更好地理解和应用Python爬虫技术,构建高效的数据采集系统。未来我们将继续分享更多爬虫相关的进阶知识,欢迎持续关注!

更多精彩内容

想了解更多Python爬虫和数据分析的精彩内容,欢迎关注公众号:码途有你,我们将定期推送高质量的编程教程和实战案例!

公众号