Python爬虫实战:ThreadPoolExecutor多线程采集书籍信息与图片下载

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("全部完成!")
相关推荐
川冰ICE17 小时前
JavaScript工程化②|Webpack5基础配置,打包你的第一个项目
开发语言·javascript·ecmascript
YHHLAI17 小时前
JavaScript 同步异步精讲:单线程、事件循环、Promise 执行机制
开发语言·javascript·ecmascript
资深流水灯工程师17 小时前
PySide6 + Qt Designer + PyCharm 完整开发流程
开发语言·qt·pycharm
阿旭超级学得完17 小时前
Linux基础指令 四(apt,vim,git,cgdb)
linux·服务器·开发语言·数据结构·c++·git·vim
Invictus_cl17 小时前
条纹圆形进度条(彩虹色)
开发语言·前端·javascript
Vallelonga17 小时前
Rust 中的枚举
开发语言·rust
兰令水17 小时前
leecodecode【状态机DP】【2026.6.9打卡-java版本】
java·开发语言·算法
郝亚军17 小时前
win11安装python3.12.7和pycharm
ide·python·pycharm
资深流水灯工程师17 小时前
PyCharm 虚拟环境完整配置指南(PySide6 开发专用)
ide·python·pycharm