1. Python异步爬虫实战:aiohttp+parsel高效抓取小说网站
最近在做一个小说阅读分析工具,需要批量获取多个站点的完整小说内容。传统同步爬虫在抓取大量页面时效率太低,于是尝试用aiohttp+parsel搭建异步爬虫,实测抓取速度比requests快了8倍以上。下面分享这套方案的完整实现过程,包含你可能遇到的坑和解决方案。
1.1 为什么选择异步爬虫?
小说网站通常有以下几个特点:
- 章节页面数量庞大(一本小说可能有几百章)
- 单个页面体积较小(平均50-100KB)
- 服务器响应时间不稳定(某些站点有访问频率限制)
同步爬虫在这种场景下会频繁等待网络IO,CPU利用率往往不到10%。而异步爬虫可以在等待响应时处理其他任务,实测在相同时间内能完成更多请求。以某小说网站的200章测试为例:
| 爬虫类型 | 耗时(s) | CPU利用率 | 成功率 |
|---|---|---|---|
| requests | 182 | 12% | 100% |
| aiohttp | 23 | 78% | 100% |
1.2 技术选型考量
aiohttp vs requests+线程池:
- aiohttp是原生异步实现,比线程池更轻量(无需线程切换开销)
- 自带连接池和重试机制,对HTTP协议支持更完整
- 与asyncio生态无缝集成(如配合uvloop性能更好)
parsel的优势:
- 同时支持XPath和CSS选择器
- 内置正则表达式提取功能
- 对残缺HTML容错性更好(小说网站常有标签不闭合问题)
注意:如果目标网站有严格的反爬机制(如Cloudflare防护),建议先用浏览器测试能否正常访问,再决定是否采用此方案。
2. 环境准备与基础配置
2.1 开发环境搭建
推荐使用Python 3.8+版本,新建虚拟环境后安装依赖:
bash复制python -m pip install aiohttp parsel beautifulsoup4 fake-useragent
关键依赖说明:
fake-useragent:自动生成随机User-Agentbeautifulsoup4:备用解析器(部分网站可能需要)
2.2 基础爬虫框架
先搭建一个最小可运行的原型:
python复制import aiohttp
import asyncio
from parsel import Selector
async def fetch(session, url):
async with session.get(url) as response:
return await response.text()
async def parse(content):
selector = Selector(text=content)
title = selector.css("h1::text").get()
content = "".join(selector.css(".content p::text").getall())
return {"title": title, "content": content}
async def crawl(url):
async with aiohttp.ClientSession() as session:
html = await fetch(session, url)
return await parse(html)
# 测试单章抓取
if __name__ == "__main__":
test_url = "https://www.example.com/chapter/1"
result = asyncio.run(crawl(test_url))
print(result)
2.3 关键参数调优
在ClientSession中需要配置的重要参数:
python复制conn = aiohttp.TCPConnector(
limit=30, # 最大连接数
limit_per_host=5, # 单域名并发限制
enable_cleanup_closed=True, # 自动清理关闭的连接
force_close=True # 避免连接池耗尽
)
timeout = aiohttp.ClientTimeout(total=10) # 总超时时间
session = aiohttp.ClientSession(
connector=conn,
timeout=timeout,
headers={"User-Agent": fake_useragent.UserAgent().random}
)
实测经验:limit_per_host建议设置在3-5之间,过高可能触发网站反爬
3. 完整爬虫实现与优化
3.1 目录页解析策略
小说目录页通常有两种结构:
- 分页目录(如/page/1, /page/2)
- 无限滚动(通过API加载)
以分页目录为例的采集逻辑:
python复制async def get_chapter_list(session, book_url):
html = await fetch(session, book_url)
selector = Selector(text=html)
# 获取总页数
last_page = selector.css(".pagination li:last-child a::attr(href)").re(r"page/(\d+)")[0]
# 采集所有页的章节链接
all_links = []
for page in range(1, int(last_page)+1):
page_url = f"{book_url}/page/{page}"
html = await fetch(session, page_url)
selector = Selector(text=html)
links = selector.css(".chapter-list a::attr(href)").getall()
all_links.extend([urljoin(book_url, link) for link in links])
return all_links
3.2 内容抓取优化技巧
防反爬策略:
python复制# 在session配置中添加随机延迟
async def fetch_with_delay(session, url):
await asyncio.sleep(random.uniform(0.5, 1.5)) # 随机延迟
async with session.get(url) as response:
return await response.text()
# 使用代理IP池(需自行实现)
async def fetch_via_proxy(session, url, proxy):
async with session.get(url, proxy=proxy) as response:
return await response.text()
断点续爬实现:
python复制import pickle
from pathlib import Path
def save_progress(links, done):
data = {"pending": list(set(links) - set(done)), "done": done}
Path("progress.pkl").write_bytes(pickle.dumps(data))
def load_progress():
if Path("progress.pkl").exists():
return pickle.loads(Path("progress.pkl").read_bytes())
return None
3.3 异步任务调度
使用asyncio.Semaphore控制并发度:
python复制async def worker(sem, session, url, results):
async with sem:
try:
content = await fetch_with_delay(session, url)
data = await parse(content)
results.append(data)
except Exception as e:
print(f"Failed {url}: {str(e)}")
async def run_crawler(links):
sem = asyncio.Semaphore(5) # 并发数限制
results = []
async with aiohttp.ClientSession() as session:
tasks = [worker(sem, session, url, results) for url in links]
await asyncio.gather(*tasks)
return results
4. 常见问题与解决方案
4.1 编码问题处理
小说网站常见编码问题及解决方案:
python复制# 方法1:指定响应编码
async def fetch(session, url):
async with session.get(url) as response:
response.encoding = "gbk" # 常见中文编码
return await response.text()
# 方法2:使用chardet自动检测
import chardet
async def fetch(session, url):
async with session.get(url) as response:
content = await response.read()
encoding = chardet.detect(content)["encoding"]
return content.decode(encoding)
4.2 反爬绕过实战
应对策略对照表:
| 反爬类型 | 解决方案 | 实现示例 |
|---|---|---|
| User-Agent检查 | 随机生成User-Agent | fake-useragent库 |
| IP限制 | 代理IP轮换 | 在session中配置proxy参数 |
| 请求频率限制 | 添加随机延迟+限制单域名并发数 | asyncio.Semaphore控制 |
| 验证码 | 人工打码或第三方识别服务 | 需要额外集成 |
4.3 性能优化记录
实测优化效果对比:
| 优化措施 | 200章耗时(s) | 内存占用(MB) |
|---|---|---|
| 基线方案 | 38 | 120 |
| + 连接池调优 | 29 | 95 |
| + UVLoop加速 | 21 | 90 |
| + 二进制响应+异步存储 | 18 | 80 |
启用UVLoop的方法:
python复制import uvloop
asyncio.set_event_loop_policy(uvloop.EventLoopPolicy())
5. 数据存储与后处理
5.1 异步文件写入
使用aiofiles实现异步文件操作:
python复制import aiofiles
async def save_to_txt(data, path):
async with aiofiles.open(path, "w", encoding="utf-8") as f:
await f.write(f"## {data['title']}\n\n{data['content']}")
# 在worker中调用
async def worker(...):
...
await save_to_txt(data, f"output/{data['title']}.txt")
5.2 数据库存储方案
MySQL异步写入示例(需安装aiomysql):
python复制import aiomysql
async def save_to_mysql(data):
conn = await aiomysql.connect(
host="localhost", user="root",
password="", db="novels"
)
async with conn.cursor() as cur:
await cur.execute(
"INSERT INTO chapters (title, content) VALUES (%s, %s)",
(data["title"], data["content"])
)
await conn.commit()
conn.close()
5.3 内容清洗技巧
常见小说内容清洗逻辑:
python复制def clean_content(text):
# 去除广告文本
ad_keywords = ["最新章节", "请记住本书首发域名"]
for kw in ad_keywords:
text = text.replace(kw, "")
# 规范化空白字符
text = re.sub(r"\s+", " ", text).strip()
# 分段处理
paragraphs = [p for p in text.split(" ") if len(p) > 10]
return "\n".join(paragraphs)
这套方案已经稳定运行了三个月,累计抓取超过10万章小说内容。最关键的体会是:异步爬虫不是简单的把requests换成aiohttp,需要从网络IO、任务调度、错误处理等多个维度重新设计。特别是在处理大量任务时,合理的并发控制和错误重试机制能让成功率从70%提升到99%以上。
