避坑指南:Python爬取百度图片时常见的5个错误及解决方法

最近在帮一个做设计的朋友批量找素材,他需要根据不同的主题词收集大量图片。我第一个想到的自然是百度图片,毕竟资源库够大。于是,我顺手写了个Python脚本,本以为分分钟搞定,结果却踩了一连串的坑。从请求被拒到图片乱码,再到莫名其妙地只下载了几十张就停了,整个过程简直像在玩扫雷。如果你也正打算用Python从百度图片抓取点东西,那我这一路踩过来的坑,或许能帮你省下不少折腾的时间。这篇文章,我就把这些常见的“雷区”和我的“排雷”经验,系统地梳理给你。

1. 请求被拒:破解反爬机制的头道关卡

刚开始写爬虫,最容易碰到的就是请求直接被拒绝,返回一堆错误码,或者干脆没有数据。百度图片虽然不像一些电商网站那样有极其复杂的验证,但其基础的反爬措施也足够让新手头疼一阵子。

首先,一个最容易被忽视的细节是请求头(Headers)的模拟。 很多教程会告诉你加个 User-Agent 就行,但实际远不止如此。百度的服务器会检查请求的完整性。如果你只用了一个简单的、来自 requests 库的默认 User-Agent,它很可能被识别为爬虫。

一个更真实的请求头应该包含多个字段。下面是我调整后稳定可用的一个示例:

headers = {
    'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
    'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
    'Accept-Encoding': 'gzip, deflate, br',
    'Referer': 'https://image.baidu.com/',
    'Connection': 'keep-alive',
    'Upgrade-Insecure-Requests': '1'
}

提示:Referer 字段非常重要,它告诉服务器请求是从哪个页面发起的。对于图片搜索这类场景,将其设置为百度图片的首页或搜索页域名,能显著提高请求的通过率。

其次,请求频率控制是另一个关键。即使你的请求头伪装得很好,如果以机器般的速度连续发送请求,IP地址也很快会被暂时限制。一个简单有效的策略是在请求之间加入随机延时。

import time
import random

def request_with_delay(url, headers):
    response = requests.get(url, headers=headers)
    # 在每次请求后,随机等待1到3秒
    time.sleep(random.uniform(1, 3))
    return response

对于需要大量抓取的情况,仅仅延时可能不够。你可以考虑以下进阶策略:

策略 描述 适用场景
IP代理池 使用多个代理IP轮换发送请求,避免单一IP被封。 大规模、长时间抓取任务。
会话保持 使用 requests.Session() 对象,它可以自动管理cookies,使一系列请求看起来更像一个完整的浏览器会话。 需要维持登录状态或应对基于会话的反爬。
降低并发 即使使用多线程/异步,也要严格控制并发数量,例如同时只进行3-5个请求。 任何并发爬取场景。

最后,留意一下百度图片接口URL的构造。它的参数(如 pn, rn, gsm 等)可能会变化,且某些参数值(如 gsm)看起来像随机数或加密值。如果直接复制别人代码里的固定URL,可能很快失效。最稳妥的方式是实时打开浏览器开发者工具(F12),在“网络”(Network)选项卡中筛选XHR或Fetch请求,找到最新的图片数据接口,观察其URL规律。 通常,pn 参数代表偏移量(第几张开始),rn 代表返回数量。

2. 数据解析陷阱:JSON格式与正则的抉择

成功拿到接口返回的数据后,下一步就是解析出图片的真实URL。这里常见的坑有两个:一是误判了数据格式,二是使用了脆弱的解析方法。

百度图片的搜索接口(/search/acjson)返回的数据通常是 JSONP 或纯 JSON 格式。早期可能是JSONP(即数据包裹在一个函数调用里),现在更常见的是纯JSON。你需要先确认你拿到的是什么。

  • 如果是JSONP:响应内容可能像 callbackFunction({...data...})。你需要先剥离外层的函数调用,再解析内部的JSON对象。
  • 如果是纯JSON:直接使用 response.json() 方法即可。

一个健壮的解析流程应该是这样的:

import requests
import json

url = ‘你找到的接口URL‘
response = requests.get(url, headers=headers)
content = response.text

# 尝试判断是否为JSONP
if content.startswith(‘callback‘) or content.startswith(‘jQuery‘):
    # 找到第一个左括号和最后一个右括号的位置
    json_start = content.find(‘{‘)
    json_end = content.rfind(‘}‘) + 1
    json_str = content[json_start:json_end]
    data = json.loads(json_str)
else:
    # 尝试直接解析为JSON
    try:
        data = response.json()
    except json.JSONDecodeError:
        print(“响应不是有效的JSON格式,请检查URL和请求头。“)
        data = None

if data and ‘data‘ in data:
    for item in data[‘data‘]:
        if item and ‘thumbURL‘ in item: # 也可能是 ‘middleURL‘, ‘hoverURL‘等
            image_url = item[‘thumbURL‘]
            # 处理图片URL

强烈不建议在解析JSON结构稳定的数据时,首选正则表达式(regex)。 虽然正则很强大,但对于嵌套的、结构可能变化的JSON来说,正则表达式写的规则非常脆弱,页面结构或字段名稍有变动,解析就会失败。使用 json 库是更标准、更可靠的做法。

注意:即使使用JSON解析,也要注意数据可能为空或字段缺失的情况。务必在代码中添加判断(如上面的 if item and ‘thumbURL‘ in item:),避免因个别数据项异常导致整个程序崩溃。

3. 编码与存储乱象:从URL到本地文件的完整链路

当你千辛万苦提取出图片的URL,并准备下载时,新的问题可能出现在编码和文件存储环节。

首先是URL编码问题。 你的搜索关键词可能是中文,如“风景”。这个关键词需要被正确地编码后拼接到请求URL中。urllib.parse 库里的 quote 函数就是干这个的。

from urllib import parse

keyword = “自然风景“
encoded_keyword = parse.quote(keyword) # 输出:%E8%87%AA%E7%84%B6%E9%A3%8E%E6%99%AF

如果忘记编码,服务器可能无法识别你的请求,或者返回错误结果。

其次是图片存储时的文件名冲突和非法字符问题。 图片URL可能很长,或者包含一些不能作为文件名的字符(如 /, ?, : 等)。直接截取URL的一部分作为文件名,很容易导致重复或写入失败。

我的建议是,使用内容哈希或时间戳来生成唯一文件名,这样既避免了冲突,也屏蔽了非法字符。

import hashlib
import os

def save_image(image_content, save_dir, keyword):
    # 为图片内容生成MD5哈希值作为文件名
    file_hash = hashlib.md5(image_content).hexdigest()
    # 可以结合关键词组织目录
    filename = f“{file_hash}.jpg“
    filepath = os.path.join(save_dir, keyword, filename)

    # 确保目录存在
    os.makedirs(os.path.dirname(filepath), exist_ok=True)

    with open(filepath, ‘wb‘) as f:
        f.write(image_content)
    print(f“图片已保存:{filepath}“)

此外,在保存大量图片时,要考虑到文件系统对单个文件夹内文件数量的限制(虽然现在很少见),以及目录结构清晰的需求。可以按关键词、日期等建立子文件夹进行分类存储。

4. 数量不对与循环终止:深入理解分页逻辑

你有没有遇到过这种情况:脚本运行得很顺利,但最终下载的图片数量远少于搜索结果显示的总数,或者循环莫名其妙提前结束了?这多半是分页逻辑处理有误。

百度图片的接口通常是“懒加载”或分页加载的。关键参数是 pn(可以理解为 offset)和 rn(page size,通常是30)。假设你要爬取150张图片,rn=30,那么你需要循环请求 pn=0, 30, 60, 90, 120

常见的错误是:

  1. 误判总数:接口返回的总数(displayNumtotalNum)可能不准确,或者包含了无关的条目。不要完全依赖这个数字来决定循环次数。
  2. 循环条件错误:使用 for i in range(total // rn) 这样的计算,如果总数不能被 rn 整除,最后一页的数据就会被遗漏。
  3. 接口终止信号:当 pn 超出实际范围时,接口可能返回空数据或状态码错误,但你的循环可能还在继续。

一个更健壮的分页循环逻辑应该这样写:

import requests

base_url = “https://image.baidu.com/search/acjson?tn=resultjson_com&word={}&pn={}&rn=30“
keyword = “猫“
encoded_keyword = parse.quote(keyword)
headers = { ... } # 你的请求头

pn = 0
rn = 30
image_count = 0
max_images = 200 # 设置一个最大抓取限制,防止无限循环

while image_count < max_images:
    url = base_url.format(encoded_keyword, pn)
    try:
        resp = requests.get(url, headers=headers, timeout=10)
        data = resp.json()
    except Exception as e:
        print(f“请求失败: {e}“)
        break # 或加入重试机制

    items = data.get(‘data‘, [])
    if not items:
        print(“没有更多数据,终止爬取。“)
        break

    # 过滤掉无效条目(有时最后一个条目是空的)
    valid_items = [item for item in items if item and item.get(‘thumbURL‘)]
    if not valid_items:
        print(“当前页无有效图片数据,终止爬取。“)
        break

    for item in valid_items:
        img_url = item[‘thumbURL‘]
        # 下载并保存图片
        # download_image(img_url, save_path)
        image_count += 1
        print(f“已下载第 {image_count} 张图片“)

    pn += rn # 翻到下一页
    time.sleep(random.uniform(1, 2)) # 礼貌性延时

这种“基于响应内容驱动”的循环,比单纯依赖一个预设的总数要可靠得多。

5. 效率瓶颈与资源管理:从能跑到跑得好

当你的爬虫能稳定运行后,下一个要面对的问题就是效率。同步请求、单线程下载几百上千张图片会非常慢。同时,不加以管理的网络请求和文件操作也可能耗尽系统资源。

提升效率最直接的方法是引入并发。 Python的 concurrent.futures 库中的 ThreadPoolExecutor 是一个简单易用的选择,它适合I/O密集型任务(如下载图片)。

from concurrent.futures import ThreadPoolExecutor, as_completed

def download_single_image(img_info):
    url, save_path = img_info
    try:
        content = requests.get(url, timeout=15).content
        with open(save_path, ‘wb‘) as f:
            f.write(content)
        return True, save_path
    except Exception as e:
        return False, f“{save_path}: {e}“

# 假设 image_url_list 是包含(url, filepath)元组的列表
image_url_list = [(url1, path1), (url2, path2), ...]

# 使用线程池,max_workers控制并发数,不宜过大(如5-10)
with ThreadPoolExecutor(max_workers=5) as executor:
    future_to_url = {executor.submit(download_single_image, img_info): img_info for img_info in image_url_list}
    for future in as_completed(future_to_url):
        success, result = future.result()
        if success:
            print(f“下载成功: {result}“)
        else:
            print(f“下载失败: {result}“)

提示:并发数(max_workers)不是越大越好。设置过高会加重本地网络和CPU的调度负担,也可能触发目标服务器的反爬机制。建议从较小的数字(如3)开始测试,逐步增加。

资源管理同样重要:

  • 设置超时:每个网络请求都应设置连接超时和读取超时,避免因某个请求卡住而阻塞整个程序。
    response = requests.get(url, headers=headers, timeout=(5, 15)) # (连接超时, 读取超时)
    
  • 异常处理:将下载、保存等可能失败的操作用 try...except 包裹,记录错误日志,而不是让整个程序崩溃。
  • 管理会话:在并发场景下,考虑为每个线程创建独立的 requests.Session,或者使用全局会话但注意线程安全。

最后,别忘了给你的爬虫加上一些运行状态反馈,比如进度条。这不仅能让你知道进展,也能在出现问题时帮你快速定位。可以使用 tqdm 库轻松实现。

pip install tqdm
from tqdm import tqdm

# 在循环或并发任务外包裹tqdm
for img_url in tqdm(image_urls, desc=“下载进度“):
    # 下载图片

爬虫写到最后,稳定性和可维护性往往比极限速度更重要。处理好异常,做好日志记录,设计好重试机制,你的爬虫才能长时间可靠地工作。我在最初版本因为一个连接超时没处理,导致脚本半夜就挂了,早上起来一看只下了几十张。加上完善的错误处理和日志后,它就能安稳地跑完上万张的任务了。

更多推荐