aiohttp异步爬取实战
案例介绍
爬取一个数据量较大的软件,链接为https://spa5.scrape.center,页面如下图所示:
这是一个图书网站,整个网站包含数千本图书信息,网站数据是 JavaScript 渲染而得的,数据可以通过 Ajax 接口获取,并且接口没有设置反爬措施和加密参数。且网站数据量多,更适合异步爬取。
完成目标:
- 使用 aiohttp 爬取全站的图书数据;
- 将数据通过异步的方式保存到 MongoDB 中。
准备工作
实现MonogDB异步存储,离不开异步实现的 MongoDB 存储库 motor,其安装命令为:
pip3 install motor
页面分析
案例站点是列表页加详情页的结构,加载方式都是 Ajax ,分析到如下信息:
- 列表页的 Ajax 请求接口格式 https://spa5.scrape.center/api/book/?limit=18&offset={offset}。其中 limit 的值为每页包含多少本书;offset 的值为每一页的偏移量,计算公式为 offse t。limit*(page - 1),如第一页的 offset 值为0,第2页 offset 的值为18,依此类推。
- 在列表页Ajax接口返回的数据里,results字段包含当前页里18本图书的信息,其中每本书的数据里包含一个 id 字段,这个 id 就是图书本身的 ID ,可以用来进一步请求详情页。
- 详情页的Ajax请求接口格式为 https://spa5.scrape.center/api/book/{id} 。其中的 id 即为详情页对应图书的 ID ,可以从列表页 Ajax 接口的返回结果中获取此内容。
实现思路
一个完善的异步爬虫应该能够充分利用资源进行全速爬取,其实现思路是维护一个动态变化的爬取队列,每产生一个新的 task ,就将其放入爬取队列中,有专门的爬虫消费者从此队列中获取 task 并执行,能做到在最大并发量的前提下充分利用等待时间进行额外的爬取处理。
将爬取逻辑拆分成两部分,第一部分爬取列表页,第二部分为爬取详情页。因为异步爬虫的关键点在于并发执行,所以可以将爬取拆分为如下两个阶段。
- 第一阶段是异步爬取所有列表页,我们可以将所有列表页的爬取任务集合在一起,并将其声明为由task组成的列表,进行异步爬取。
- 第二阶段则是拿到上一步列表页的所有内容并解析,将所有图书的id信息组合为所有详情页的爬取任务集合,并将其声明为 task 组成的列表,进行异步爬取,同时爬取结果也以异步方式存储到 MongoDB 里面。
基本配置
首先,先配置一些基本的变量并引入一些必需的库,代码如下:
import asyncio import aiohttp import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s:%(message)s') INDEX_URL = 'https://spa5.scrape.center/api/book/?limit=18&offset={offset}' DETAIL_URL = 'https://spa5.scrape.center/api/book/{id}' PAGE_SIZE = 18 PAGE_NUMBER = 100 CONCURRENCY = 5
导入asyncio、aiohttp、logging这3个库,然后定义了 logging 的基本配置。接着定义了 URL 、爬取页码数量 PAGE_NUMBER 、并发量 CONCURRENCY 等信息。
爬取列表页面
第一阶段来爬取列表页,还是和之前一样,先定义一个通用的爬取方法,代码如下:
semaphore = asyncio.Semaphore(CONCURRENCY) session = None async def scrape_api(url): async with semaphore: try: logging.info('scraping %s', url) async with session.get(url) as response: return await response.json() except aiohttp.ClientError: logging.error('抓取时出错 %s', url, exc_info=True)
这里声明一个信号量,用来控制最大并发量。接着定义 scrape_api 方法,接受一个参数 ur l,该方法首先使用 async with 语句引入信号量作为上下文,接着调用session的get方法请求这个url,然后返回响应的 JSON 格式的结果。另外,这里还进行了异常处理,捕获了 ClientError ,如果出现错误,就会输出异常信息。
然后,爬取列表页,实现代码如下:
async def scrape_index(page): url = INDEX_URL.format(offset=PAGE_SIZE * (page-1)) return await scrape_api(url)
这里定义了 scrape_index 方法用于爬取列表页,它接受一个参数 page 。随后构造一个列表页的 URL ,将其传给 scrape_api 调用之后本身会返回一个协程对象。另外,由于 scrape_api 的返回结果就是 JSON 格式,因此这个结果已经是想要爬取的信息,不需要再额外解析了。
接下来定义main方法,将上面的方法串联起来调用,实现如下:
import json
async def main(): global session session = aiohttp.ClientSession() scrape_index_tasks = [asyncio.ensure_future(scrape_index(page)) for page in range(1, PAGE_NUMBER + 1))] results = await asyncio.gather(*scrape_index_tasks) logging.info('results %s', json.dumps(results, ensure_ascii=False, indent=2)) if __name__ == '__main__': asyncio.get_event_loop().run_until_complete(main())
这里首先声明了 session 对象,即最初声明的全局变量。这样的话,就不需要在各个方法里面都传递 session 了,实现起来比较简单。
接着定义了 scrape_index_tasks ,这就是用于爬取列表页的所有 task 组成的列表。然后调用 asyncio 的 gather 方法,并将 task 列表传入其参数,将结果赋值为 results ,它是由所有 task 返回结果组成的列表。
最后,调用 main 方法,使用事件循环启动该 main 方法对应的协程即可。
运行结果如下:
2024-06-11 01:39:49,032 - INFO:scraping https://spa5.scrape.center/api/book/?limit=18&offset=0
2024-06-11 01:39:49,146 - INFO:scraping https://spa5.scrape.center/api/book/?limit=18&offset=18
2024-06-11 01:39:49,146 - INFO:scraping https://spa5.scrape.center/api/book/?limit=18&offset=36
2024-06-11 01:39:49,146 - INFO:scraping https://spa5.scrape.center/api/book/?limit=18&offset=54
2024-06-11 01:39:49,147 - INFO:scraping https://spa5.scrape.center/api/book/?limit=18&offset=72
2024-06-11 01:39:49,604 - INFO:scraping https://spa5.scrape.center/api/book/?limit=18&offset=90
2024-06-11 01:39:50,574 - INFO:scraping https://spa5.scrape.center/api/book/?
可以看到开始异步爬取,并发量由我们控制,目前为5,在网站能承受的情况下,可以进一步提高这个数字,爬取速度会进一步加快。
最后,results 就是爬取所有列表页得到的结果,接着就可以使用它进行第二步爬取。
爬取详情页
第二阶段爬取详情页并保存数据。每个详情页对应一本书,每本书都需要一个 ID 作为唯一标识,而这个 ID 又正好在 results 里面,所以需将所有详情页的 ID 获取出来。
在 main 方法里增加 results 的解析代码,如下:
ids = [] for index_data in results: if not index_data: continue for item in index_data.get('results'): ids.append(item.get('id'))
这样 ids 就是所有书的id了,然后用所有的 id 构造所有详情页对应的 task ,进行异步爬取即可。
这里再定义两个方法,用于爬取详情页和保存数据,实现如下:
from motor.motor_asyncio import AsyncIOMotorClient MONGO_CONNECTION_STRING = 'mongodb://localhost:27017' MONGO_DB_NAME = 'books' MONGO_COLLECTION_NAME = 'books' async def save_data(self, data): logging.info('saving data %s', data) if data: return await collection.update_one({ 'id': data.get('id') }, { '$set': data }, upsert=True) async def scrape_detail(self, id): url = DETAIL_URL.format(id=id) data = await self.scrape_api(url) await self.save_data(data)
这里定义了 scrape_detail 方法用于爬取详情页数据,并调用 save_data 方法保存数据。save_data 方法可以将数据保存到 MongoDB 里面。
这里用到了支持异步的 MongoDB 存储库 motor 。motor 的连接声明和 pymongo 是类似的,保存数据的调用方法也基本一致,不过整个都换成了异步方法。
接着在main方法里增加对 scrape_detail 方法的调用即可爬取详情页,实现如下:
scrape_detail_tasks = [asyncio.ensure_future(scrape_detail(id)) for id in ids] await asyncio.wait(scrape_detail_tasks) await session.close()
运行结果:
2024-07-01 01:55:18,829 - INFO: scraping https://spa5.scrape.center/api/book/?limit=18&offset=0 results [{'count': 9040, 'results': [{'id': '7952978', 'name': 'Wonder', 'authors': ['R. J. Palacio'], 'cover': 'https://cdn.scrape.center/book/s27252687.jpg', 'score': '8.8'}, {'id': '7916054', 'name': '清白家风', 'authors': ['\n 董桥', '海豚简装'], 'cover': 'https://cdn.scrape.center/book/s27250764.jpg', 'score': '7.5'}, {'id': '7698729', 'name': '法老的宠妃 终结篇(上下册)', 'authors': ['\n 悠世', '法老的宠妃'], 'cover': 'https://cdn.scrape.center/book/s7027218.jpg', 'score': '7.2'}, {'id': '7658805', 'name': '士为知己(全二册)', 'authors': ['蓝色狮'], 'cover': 'https://cdn.scrape.center/book/s8866404.jpg', 'score': '7.7'}, {'id': '7564736', 'name': '那些年,我们一起追的女孩', 'authors': ['\n 九把刀', '九把刀作品集·现代版'], 'cover': 'https://cdn.scrape.center/book/s7049425.jpg', 'score': '8.2'}, {'id': '7440370', 'name': '非我倾城(全三册)', 'authors': ['\n 墨舞碧歌'], 'cover': 'https://cdn.scrape.center/book/s8916163.jpg', 'score': '7.8'}, {'id': '7163250', 'name': '明朝那些事儿', 'authors': ['\n 当年明月'], 'cover': 'https://cdn.scrape.center/book/s29399938.jpg', 'score': '9.2'}, {'id': '7154825', 'name': '我和你的笑忘书', 'authors': ['夏七夕'], 'cover': 'https://cdn.scrape.center/book/s7028274.jpg', 'score': '6.4'}, {'id': '7154690', 'name': '王小波全集 第一卷', 'authors': ['\n 王小波', '王小波全集(凤凰壹力2012版)'], 'cover': 'https://cdn.scrape.center/book/s11315662.jpg', 'score': '8.3'}, {'id': '7154675', 'name': '怦然心动', 'authors': ['\n [美]\n 文德琳·范·德拉安南'], 'cover': 'https://cdn.scrape.center/book/s7026741.jpg', 'score': '8.8'}, {'id': '7153409', 'name': '龙枪编年史(全3册)', 'authors': ['[美] 玛格丽特·魏丝', '[美] 崔西·西克曼'], 'cover': 'https://cdn.scrape.center/book/s9100391.jpg', 'score': '8.8'}, {'id': '7067986', 'name': '龙枪传奇(全三册)', 'authors': ['玛格丽特·魏丝 崔西·西克曼'], 'cover': 'https://cdn.scrape.center/book/s10423583.jpg', 'score': '9.1'}, {'id': '7067983', 'name': '黎明之街', 'authors': ['\n [日]\n 东野圭吾', '新经典文化', '新经典文库·东野圭吾作品'], 'cover': 'https://cdn.scrape.center/book/s8871926.jpg', 'score': '7.2'}, {'id': '7067149', 'name': '认知心理学及其启示', 'authors': ['约翰•R•安德森'], 'cover': 'https://cdn.scrape.center/book/s7059106.jpg', 'score': '8.8'}, {'id': '7065529', 'name': '银河帝国2:基地与帝国', 'authors': ['\n [美]\n 艾萨克·阿西莫夫', '读客文化', '银河帝国'], 'cover': 'https://cdn.scrape.center/book/s9117163.jpg', 'score': '9.0'}, {'id': '7065521', 'name': '银河帝国:基地', 'authors': ['\n [美]\n 艾萨克·阿西莫夫', '读客文化', '银河帝国'], 'cover': 'https://cdn.scrape.center/book/s8973055.jpg', 'score': '9.0'}, {'id': '7063979', 'name': '小学教材全解-四年级语文下', 'authors': ['\n 薛金星'], 'cover': 'https://cdn.scrape.center/book/s24506696.jpg', 'score': '7.3'}, {'id': '7062629', 'name': '越界言论(第3卷)', 'authors': ['许子东'], 'cover': 'https://cdn.scrape.center/book/s22696330.jpg', 'score': '8.3'}]}] 2024-07-01 01:55:20,906 - INFO: scraping https://spa5.scrape.center/api/book/7952978 2024-07-01 01:55:20,906 - INFO: scraping https://spa5.scrape.center/api/book/7916054 2024-07-01 01:55:20,907 - INFO: scraping https://spa5.scrape.center/api/book/7698729 2024-07-01 01:55:20,908 - INFO: scraping https://spa5.scrape.center/api/book/7658805 2024-07-01 01:55:20,908 - INFO: scraping https://spa5.scrape.center/api/book/7564736 2024-07-01 01:55:22,241 - INFO: saving data {'id': '7952978', 'comments': [{'id': '664438560', 'content': "把人变善良的书 :-3 “Kinder than is necessary. Because it's not enough to be kind. One should be kinder than needed.”"}, {'id': '896144243', 'content': 'August Summer Jack Via'}, {'id': '1315110121', 'content': '需要偶尔读一些像wonder这样的故事,暂时从现实生活里躲开一会儿。'}, {'id': '920203463', 'content': '很温暖的书,非常好读。'}, {'id': '969905317', 'content': '哭掉一整盒纸巾!'}, {'id': '2300338204', 'content': '简俗易懂,每个人的心理活动都很真实,但也很好的贴合形象,我最爱Olivia和Nate。'}, {'id': '2288246818', 'content': 'Always try to be a little kinder than necessary.'}, {'id': '2283172522', 'content': '一路流畅读下来,爽的不行\n而且书毕竟比电影充实得多'}, {'id': '2266562543', 'content': '❤️Kinder than is necessary. \n善良不仅仅是人类与生俱来的品质\n选择善良更是人类的伟大之处'}, {'id': '2255357424', 'content': 'Via部分一开始我就启动了哭泣模式。虽然比电影好看,但明天要再刷一遍电影😭'}], 'name': 'Wonder', 'authors': ['R. J. Palacio'], 'translators': [], 'publisher': 'Knopf Books for Young Readers', 'tags': ['英文原版', '治愈系', '小说', '励志', '英语', '美国', '外国文学', '校园'], 'url': 'https://book.douban.com/subject/7952978/', 'isbn': '9780375969027', 'cover': 'https://cdn.scrape.center/book/s27252687.jpg', 'page_number': 320, 'price': 'USD 18.99', 'score': '8.8', 'introduction': 'I won\'t describe what I look like. Whatever you\'re thinking, it\'s probably worse. August Pullman was born with a facial deformity that, up until now, has prevented him from going to a mainstream school. Starting 5th grade at Beecher Prep, he wants nothing more than to be treated as an ordinary kid—but his new classmates can’t get past Auggie’s extraordinary face. WONDER , now a #1 New York Times bestseller and included on the Texas Bluebonnet Award master list, begins from Auggie’s point of view, but soon switches to include his classmates, his sister, her boyfriend, and others. These perspectives converge in a portrait of one community’s struggle with empathy, compassion, and acceptance. "Wonder is the best kids\' book of the year," said Emily Bazelon, senior editor at Slate.com and author of Sticks and Stones: Defeating the Culture of Bullying and Rediscovering the Power of Character and Empathy . In a world where bullying among young people is an epidemic, this is a refreshing new narrative full of heart and hope. R.J. Palacio has called her debut novel “a meditation on kindness” —indeed, every reader will come away with a greater appreciation for the simple courage of friendship. Auggie is a hero to root for, a diamond in the rough who proves that you can’t blend in when you were born to stand out. Join the conversation: #thewonderofwonder From the Hardcover edition.', 'catalog': None, 'published_at': '2012-02-13T16:00:00Z', 'updated_at': '2020-03-21T16:55:24.424804Z'}
至此,就使用aiohttp完成了对图书网站的异步爬取。