Python爬虫实战:BooksToScrape 多线程爬取与图片下载
完整代码在最后,默认全部爬取,1000张图片和1000本书籍全部爬,有需求自行调整
BooksToScrape 网站,完成以下功能:
- 获取所有分页链接
- 多线程爬取所有书籍数据
- 保存 CSV 文件
- 多线程下载所有书籍封面
- 学习 ThreadPoolExecutor 的使用
- 理解 map() 与 submit() 的区别
- 解决 URL 拼接等常见坑
项目不复杂,适合有基础的新人练手
一、项目整体流程
最终实现的流程如下:
text
获取分页URL
↓
多线程爬取分页数据
↓
汇总所有书籍信息
↓
保存CSV
↓
多线程下载图片
对应代码结构:
python
get_urls()
↓
pool.map(get_books,url_list)
↓
all_books
↓
books.csv
↓
pool.submit(save_img,...)
这种结构已经具备了中小型爬虫项目的基本雏形。
二、获取分页链接
网站首页:
text
https://books.toscrape.com/
通过观察发现:
html
<li class="next">
<a href="catalogue/page-2.html">next</a>
</li>
因此采用:
python
while True:
不断寻找下一页。
核心代码:
python
next_li = soup.find("li", class_="next")
if next_li is None:
break
href = next_li.find("a")["href"]
current_url = urljoin(current_url, href)
三、第一个坑:URL拼接错误
这一点一定要注意,最开始写的是:
python
current_url = urljoin(url, href)
看起来没有问题。
第一页:
python
urljoin(
"https://books.toscrape.com/",
"catalogue/page-2.html"
)
结果:
text
https://books.toscrape.com/catalogue/page-2.html
正确。
但是到了第二页:
python
href = "page-3.html"
执行:
python
urljoin(
"https://books.toscrape.com/",
"page-3.html"
)
得到:
text
https://books.toscrape.com/page-3.html
错误。
正确地址应该是:
text
https://books.toscrape.com/catalogue/page-3.html
解决方案:
必须基于当前页拼接:
python
current_url = urljoin(current_url, href)
这个问题本质上是:
相对路径必须相对于当前页面,而不是首页。
这是爬虫中非常经典的坑。
四、多线程爬取分页数据
获取到所有分页 URL 后:
python
url_list
包含:
text
page1
page2
page3
...
page50
然后使用线程池:
python
from concurrent.futures import ThreadPoolExecutor
核心代码:
python
with ThreadPoolExecutor(max_workers=10) as pool:
results = pool.map(
get_books,
url_list
)
这里:
python
url1 → get_books(url1)
url2 → get_books(url2)
url3 → get_books(url3)
同时执行。
然后汇总结果:
python
all_books = []
for books in results:
all_books.extend(books)
最终得到:
python
[
[书名,价格,库存,图片URL],
[书名,价格,库存,图片URL],
...
]
五、ThreadPoolExecutor 核心知识
线程池:
python
ThreadPoolExecutor(max_workers=10)
表示:
text
同时最多运行10个线程
作用:
- 避免频繁创建线程
- 自动回收线程
- 提高并发效率
工作流程:
text
任务1
任务2
任务3
任务4
...
放入线程池:
text
线程1
线程2
线程3
线程4
...
线程执行完毕后自动接收新的任务。
六、map() 的使用
最开始使用:
python
pool.map(get_books, url_list)
map 的特点:
python
函数
+
可迭代对象
例如:
python
pool.map(
get_books,
url_list
)
等价于:
python
get_books(url1)
get_books(url2)
get_books(url3)
...
多个参数:
python
pool.map(
save_img,
urls,
indexes
)
等价于:
python
save_img(urls[0], indexes[0])
save_img(urls[1], indexes[1])
save_img(urls[2], indexes[2])
map 优点:
- 简洁
- 适合批量任务
- 返回结果方便
适合:
text
URL列表爬取
数据处理
批量计算
七、submit() 的使用
图片下载阶段使用:
python
pool.submit(
save_img,
img_url,
i
)
submit 会返回:
python
Future对象
例如:
python
future = pool.submit(...)
可以:
python
future.result()
future.done()
future.cancel()
submit 更灵活:
python
for url in urls:
pool.submit(
save_img,
url
)
每个任务独立管理。
八、map() 和 submit() 的区别
map
适合:
text
大量任务
同一个函数
统一处理
例如:
python
pool.map(
get_books,
url_list
)
非常自然。
优点:
text
代码简洁
自动收集结果
适合规则任务
submit
适合:
text
需要单独管理任务
需要异常处理
需要失败重试
需要日志记录
例如:
python
future = pool.submit(...)
优点:
text
灵活
可控
便于监控
实际开发推荐:
text
列表页爬取 → map
图片下载 → submit
一句话总结:
map 适合批量执行同一种任务,submit 适合需要单独控制和管理任务的场景;对于爬虫来说,列表页采集常用 map,而图片下载和复杂任务管理更适合 submit。
九、多线程下载图片
图片 URL 已经在:
python
all_books
里面。
例如:
python
[
[
title,
price,
stock,
img_url
]
]
下载代码:
python
with ThreadPoolExecutor(
max_workers=20
) as pool:
for i, book in enumerate(
all_books,
start=1
):
pool.submit(
save_img,
book[3],
i
)
实现:
text
20个线程同时下载图片
速度明显提升。
十、图片下载中的注意事项
实际开发最好不要写死,测试阶段直接写死也没关系:
python
image_1.png
因为网站图片可能是:
text
jpg
jpeg
webp
png
推荐:
python
ext = os.path.splitext(
urlparse(url).path
)[1]
自动获取扩展名。
增加异常处理:
python
try:
...
except Exception as e:
...
否则一张图片失败可能导致程序终止。
增加状态码检查:
python
res.raise_for_status()
避免:
text
403
404
500
被当成正常页面处理。
十一、完整代码
python
import requests
import csv
from urllib.parse import urljoin
from concurrent.futures import ThreadPoolExecutor
from bs4 import BeautifulSoup
import os
import time,random
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"
}
#本地代理,根据自己端口来
proxy = {
"http":"http://127.0.0.1:7892",
"https":"http://127.0.0.1:7892"
}
#获取所有分页链接
def get_urls():
current_url = url
url_list = [url] #第一页
num = 0
while True:
num+=1
print(f"正在爬取{num}页链接")
res = requests.get(current_url,headers=headers,proxies=proxy,timeout=10)
soup = BeautifulSoup(res.text,"html.parser")
next_li = soup.find("li",class_="next")
if next_li is None:
print("书籍页码爬取结束")
break
a_href = next_li.find("a")["href"]
current_url = urljoin(current_url,a_href)
url_list.append(current_url)
print(f"共获取{len(url_list)}条分页数据")
time.sleep(random.randint(1,3))
return url_list
#爬取分页内容
def get_books(url):
print(f"开始爬取:{url}")
books = []
res = requests.get(url,proxies=proxy,headers=headers,timeout=10)
soup = BeautifulSoup(res.text,"html.parser")
articles = soup.find_all("article",class_="product_pod")
for article in articles:
title = article.find("h3").find("a")["title"]
price = article.find("p",class_="price_color").text.strip()
instock = article.find("p",class_="instock availability").text.strip()
img = article.find("div",class_="image_container").find("img")["src"]
img_url = urljoin(url,img)
books.append([title,price,instock,img_url])
time.sleep(random.randint(1,3))
return books
def save_img(title,url):
makedir = "download"
os.makedirs(makedir,exist_ok=True)
res = requests.get(url,headers=headers,proxies=proxy,timeout=20)
#图片名称需要正则过滤特殊符号,自行处理
filename = os.path.join(makedir,f"{title}.png")
print(f"正在保存图片:{title}.png")
with open(filename,"wb") as f:
f.write(res.content)
time.sleep(random.randint(1,3))
#多线程,一次性保存csv
if __name__ == "__main__":
url_list = get_urls()
all_books = []
with ThreadPoolExecutor(max_workers=10) as pool:
results = pool.map(get_books,url_list)
for books in results:
all_books.extend(books)
for book in all_books:
title = book[0]
img_url = book[3]
pool.submit(save_img,title,img_url)
print(f"共获取{len(all_books)}条书籍数据")
with open("books.csv","w",newline="",encoding="utf-8-sig") as f:
writer = csv.writer(f)
writer.writerow(["书名","价格","库存","封面"])
writer.writerows(all_books)
print("爬取完成")