Python爬虫实战:ThreadPoolExecutor多线程采集书籍信息与图片下载
完整代码在最后
Python 爬虫和多线程,使用 BooksToScrape 网站作为练习项目,实现:
- 获取所有书籍详情页链接
- 获取图片链接
- 多线程采集书籍信息
- 保存 CSV 数据
- 多线程下载图片
项目不大,但在开发过程中踩到了不少坑
本文记录整个开发过程中的经验、问题以及解决方案。
项目目标
实现以下功能:
text
获取列表页
↓
提取书籍详情页链接
↓
提取图片链接
↓
线程池采集详情页
↓
保存CSV
↓
线程池下载图片
项目使用技术
python
requests
BeautifulSoup
ThreadPoolExecutor
csv
os
urllib.parse.urljoin
第一个坑:标签选择错误
最开始写的是:
python
articles = soup.find_all(
"div",
class_="product_pod"
)
结果:
python
print(len(articles))
# 输出
0
检查网页结构后发现:
html
<article class="product_pod">
正确写法:
python
articles = soup.find_all(
"article",
class_="product_pod"
)
写爬虫时不要想当然,一定要检查网页真实结构
第二个坑:图片地址获取错误
最开始使用:
python
img["href"]
结果报错:
python
KeyError: 'href'
因为:
html
<img src="media/cache/...jpg">
图片标签使用的是:
html
src
而不是:
html
href
正确写法:
python
img_src = img["src"]
经验:
text
a标签一般使用href
img标签一般使用src
第三个坑:线程池没有真正并发
刚学习线程池时写法如下:
python
for book_url in book_urls:
future = pool.submit(
save_books,
book_url
)
writer.writerow(
future.result()
)
看起来使用了线程池:
python
ThreadPoolExecutor
实际上:
text
提交任务
↓
等待结果
↓
提交下一个任务
效果接近单线程。
正确写法:
先提交所有任务:
python
futures = []
for book_url in book_urls:
future = pool.submit(
save_books,
book_url
)
futures.append(future)
再统一获取结果:
python
for future in futures:
writer.writerow(
future.result()
)
这样线程池才能真正发挥作用。
第四个坑:Future对象不是结果
最开始理解错误:
python
future = pool.submit(
save_books,
book_url
)
print(future)
输出:
python
<Future at 0x123456 state=running>
发现拿到的不是书籍信息。
原因:
python
submit()
返回的是:
python
Future对象
它表示:
text
未来某个时间的结果
真正获取结果:
python
future.result()
第五个坑:文件提前关闭
最开始写法:
python
with open("books.csv","w") as f:
writer = csv.writer(f)
writer.writerow(data)
结果:
python
ValueError:
I/O operation on closed file
原因:
python
with
结束后文件自动关闭。
必须保证:
python
writer.writerow()
在 with 代码块内部执行。
第六个坑:线程写CSV
最开始想让多个线程直接写 CSV。
后来发现容易出现:
text
数据错乱
缺失
覆盖
正确思路:
text
线程负责采集
↓
主线程统一写文件
即:
python
return data
最后:
python
writer.writerows(data_list)
第七个坑:下载图片没有返回值
下载函数:
python
def download(url):
...
没有:
python
return
因此:
python
future.result()
返回:
python
None
但这并不代表 Future 没用。
Future还有两个重要作用:
text
等待任务结束
捕获异常
例如:
python
future.result()
可以检查:
python
requests.exceptions.Timeout
等异常。
ThreadPoolExecutor常用知识点
创建线程池
python
from concurrent.futures import ThreadPoolExecutor
with ThreadPoolExecutor(
max_workers=10
) as pool:
...
提交任务
python
future = pool.submit(
func,
arg1,
arg2
)
等价于:
python
func(arg1, arg2)
获取结果
python
result = future.result()
等待所有任务完成
python
for future in futures:
future.result()
捕获异常
python
try:
result = future.result()
except Exception as e:
print(e)
本次项目结构
text
get_list()
↓
book_urls
img_urls
↓
ThreadPoolExecutor
↓
save_books()
↓
CSV
-------------------
ThreadPoolExecutor
↓
download()
↓
images
完整代码
python
import os
import requests
from bs4 import BeautifulSoup
from concurrent.futures import ThreadPoolExecutor
from urllib.parse import urljoin
import csv
url = "https://books.toscrape.com/"
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive"
}
'''
一次性获取所有书籍信息,下载图片,并且保存数据
多线程实现
'''
book_urls = []
img_urls = []
#获取所有书籍详情页地址
def get_list():
current_url = url
num = 0
while True:
res = requests.get(current_url,headers=headers,timeout=10)
soup = BeautifulSoup(res.text,"html.parser")
articles = soup.find_all("article",class_="product_pod")
for article in articles:
num+=1
print(f"找到第{num}条数据")
book_href = article.find("div",class_="image_container").find("a")["href"]
img_src = article.find("div",class_="image_container").find("img")["src"]
book_url = urljoin(current_url,book_href)
img_url = urljoin(current_url,img_src)
book_urls.append(book_url)
img_urls.append(img_url)
break #爬取第一页测试即可
next_li = soup.find("li",class_="next")
if next_li is None:
break
next_url = urljoin(current_url,next_li.find("a")["href"])
current_url = next_url
#保存数据
def save_books(book_url):
res = requests.get(book_url,headers=headers,timeout=10)
soup = BeautifulSoup(res.text,"html.parser")
title = soup.find("div",class_="col-sm-6 product_main").find("h1").text.strip()
price = soup.find("p",class_="price_color").text.strip()
instock = soup.find("p",class_="instock availability").text.strip()
return [title,price,instock]
#下载图片
def download(img_url,i):
makedir = "download"
os.makedirs(makedir,exist_ok=True)
res = requests.get(img_url,headers=headers,timeout=10)
filename = os.path.join(makedir,f"image_{i}.png")
with open(filename,"wb") as f:
f.write(res.content)
if __name__ == "__main__":
get_list()
print(f"共找到 {len(book_urls)} 本书,开始爬取...")
with ThreadPoolExecutor(max_workers=10) as pool:
# 1. 先提交所有任务(真正并行)
futures = [pool.submit(save_books, book_url) for book_url in book_urls]
# 2. 再统一写入 CSV(避免边爬边写时异常中断)
with open("books.csv", "w", newline="", encoding="utf-8-sig") as f: # 用 utf-8-sig 防止 Windows 乱码
writer = csv.writer(f)
writer.writerow(["书名", "价格", "库存"])
for future in futures:
try:
row = future.result(timeout=10) # 增加超时保护
writer.writerow(row)
except Exception as e:
print(f"爬取书籍信息失败: {e}")
writer.writerow(["爬取失败", "N/A", "N/A"])
# 3. 下载图片(和上面使用同一个 pool)
print("开始下载图片...")
download_futures = []
for i, img_url in enumerate(img_urls, start=1):
fut = pool.submit(download, img_url, i)
download_futures.append(fut)
# 等待图片下载完成
for fut in download_futures:
try:
fut.result(timeout=30)
except Exception as e:
print(f"下载图片失败: {e}")
print("全部完成!")