Python爬虫实战:BooksToScrape 多线程爬取与图片下载

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("爬取完成")
    
相关推荐
LadenKiller1 小时前
期货多品种轮动标的池:天勤 query_quotes 筛品种写法
python·区块链
郑洁文1 小时前
基于Python+回归分析的电子产品需求数据分析与预测
python·数据分析·回归·电子产品需求数据·电子产品数据分析
凡人叶枫1 小时前
Effective C++ 条款15:在资源管理类中提供对原始资源的访问
linux·开发语言·c++·stm32·单片机
IT策士1 小时前
Redis 从入门到精通:Python 操作 Redis 进阶
数据库·redis·python
swordbob1 小时前
Spring Boot 2.0 改 CGLIB 后,接口实现是否有影响
java·开发语言·spring
AI人工智能+电脑小能手1 小时前
【大白话说Java面试题 第106题】【并发篇】第6题:synchronized 锁的锁对象可以是什么?
java·开发语言·面试
质造者1 小时前
Python 本地 RAG 实战 | Ollama+ChromaDB 实现 PDF 离线智能问答
开发语言·python·pdf·大模型·rag
骑士雄师1 小时前
18.1 星系案例:多智能体宇宙探索系统(学习langgraph 的存储知识)
windows·python·学习
slandarer1 小时前
MATLAB | 韦恩图的高阶版: UpSet图 更新升级啦!
开发语言·matlab