写这个脚本的起因特别简单:我电脑壁纸每隔几天就看腻了,手动去图站翻半天、右键另存为、再建个文件夹分类,重复了无数遍之后,我决定写一个Python脚本自动下载壁纸。当时的诉求就三条:每天能拉一批新图,优先高清大图,按日期存进指定目录,最好还能留日志让我知道今天下载了哪些。今天就把这个脚本从零到能跑完整拆一遍,包括中途踩过的坑和后来加的并发优化。
这篇文章适合三类人看:刚学完Python基础、想拿真实项目练手的新手;已经会写简单爬虫、但还没做过“批量下载+定时执行”场景的同学;以及纯粹懒得手动收壁纸、想一劳永逸的普通用户。我会用尽量通俗的说法讲清楚每一步为什么要这么做,代码可以直接复制改一改就上路。
1. 先想清楚:你要的是“能跑的脚本”还是“可持续维护的工具”
很多人在写自动下载脚本时,上来就打开编辑器拼命敲代码,结果写着写着发现:图片链接怎么抓不全、下载到一半报错、文件名重复把前面的图覆盖了。这些问题基本都是因为开始之前没有把设计思路理顺。写脚本和装修房子一样,水电管线不提前规划,后面返工的成本极高。
1.1 需求拆解:数据源、下载范围、存储规则
一个壁纸自动下载脚本,核心其实只有三个问题:
第一,图片从哪里来。常见的数据源有三类:图库网站提供的公开API,比如Lorem Picsum、Unsplash的Source接口;壁纸网站的分类列表页,比如某些摄影社区的热门板块;以及搜索引擎的图片结果页。三类数据的处理难度完全不同。API最省事,返回的是结构化JSON,直接拿字段就行;列表页需要从HTML里解析图片地址,稍微多写几步;搜索引擎图片页通常需要处理JS动态加载以及反爬校验,不建议新手一上来就啃。
第二,下载哪些图、下载多少。是只下某一分类的图,还是整个列表页前N张?分辨率要不要限制?比如我只想要1920x1080以上的壁纸,那在解析阶段就应该过滤掉尺寸不达标的图,而不是全下载完再看。最好支持命令行参数传入数量,以后用起来方便。
第三,存到哪里、怎么避免重复下载。我习惯把壁纸按日期分目录,比如C:\Users\Me\Pictures\Wallpapers\2024-11-01\,然后文件名用时间戳+图片ID组合。如果脚本每天跑一次,同一个链接第二次遇到时应当跳过,不然磁盘很快就会被重复文件塞满。
把这三个问题写在纸上,再开始动手,整个过程会顺畅很多。
1.2 技术选型:能少用库就少用库
Python能用来干这活的库非常多,但脚本不是工程,没必要重。我的选型原则是:标准库优先,缺什么补什么,越轻越好。
网络请求用requests而不是标准库urllib。原因很简单:requests的API更人性化,session管理、超时设置、流式下载都写得很顺手。urllib当然也能用,但代码写起来啰嗦,而且遇到重定向和超时处理时,需要自己手动处理很多边界情况。这不是说urllib不行,而是同样的效果,requests十行能写完,没必要自找麻烦。
页面解析用BeautifulSoup + lxml或纯正则。如果目标页面结构稳定,其实正则也能搞定,比如从一段HTML里提取所有.jpg结尾的链接,正则足够快。但如果有属性筛选、标签嵌套这类需求,BeautifulSoup的select方法写起来更清晰,可维护性也更好。我个人的习惯是“能用CSS选择器就不写正则”,因为选择器表达语义更直观。
并发下载用标准库concurrent.futures.ThreadPoolExecutor。 Python线程在IO密集型任务里表现不错,下载图片时大部分时间在等网络响应,线程切换的开销相对收益来说完全可以接受。如果你机器配置一般,4到6条线程足够跑满宽带,还不会把目标服务器打挂。
1.3 方案对比:脚本 vs 爬虫框架
有人会问,既然要抓图片,用Scrapy这种专业爬虫框架不是更好吗?答案是要看你把它放在哪个场景里。Scrapy的优势在于分布式采集、Pipeline处理、规则限速、去重都有现成组件,适合大规模或长期爬取项目。但如果只是每天从一两个图站拉几十张壁纸,用Scrapy等于开一辆满载的卡车去楼下便利店买瓶水——功能过剩,还要额外维护爬虫项目结构和配置文件。
Selenium同样有必要吗?除非目标页面完全靠JavaScript渲染,且没有API接口可用,否则我强烈不建议。Selenium要挂浏览器驱动,资源占用大,下载20张图要启动一个Chromium实例,完全是杀鸡用牛刀。我写过一次Selenium做壁纸下载,最后因为内存占用太高,被迫改回API方式。
结论很简单:能走API就走API,没有API就用requests+BeautifulSoup解析静态HTML,只有动态页面才考虑Selenium,而且优先看看有没有隐藏接口。
2. 核心细节:哪些地方最容易翻车
整体方案定了之后,真正的坑基本都集中在几个具体细节上。我把我自己踩过最多次的坑集中说一下,这些地方全部避开,脚本基本就能跑明白。
2.1 请求头与反爬的最小配置
几乎每个以乾杯为背景的爬虫都会遇到403 Forbidden,壁纸下载脚本也不例外。很多网站会检查请求头里的User-Agent和Referer,甚至会校验Cookie。你直接用浏览器能打开图片,脚本一跑就是403,原因通常就是requests默认的User-Agent太显眼,服务器一看就知道不是真人浏览器访问。
我一般会在脚本开头配置一个全局session对象,把必要的请求头固定好:
import requests HEADERS = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", "Referer": "https://example-wallpaper-site.com/", "Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", } session = requests.Session() session.headers.update(HEADERS)Referer的作用是告诉服务器“我是从哪个页面跳过来的”,一些有防盗链机制的网站检查到你Referer为空或来路不明,就会拒绝返回图片内容。把这两个字段搭好,绝大多数静态页面的403问题能直接解决。
不过话说回来,如果你用的数据源本身提供公开API,官方会建议你不要伪装成浏览器,也不用带Referer,直接调API即可,速度更快也更稳定。请求头只是个“最小配置”,不要过度设计。
2.2 图片URL提取的三种姿势
拿到页面之后,怎么把真正的图片地址拎出来,是脚本里最容易写错的地方。我总结了三种方式,按适用场景排个序。
第一种,API接口直接返回JSON。比如Lorem Picsum的接口返回就是下面这种结构:
[ {"id": 101, "author": "author", "width": 300, "height": 200, "url": "https://unsplash.com/photos/xxx", "download_url": "https://picsum.photos/id/101/300/200"}, ... ]这种最无脑,直接用json()解析后取download_url字段即可。但要注意一个细节:API返回的地址可能是按请求时参数生成的原图地址,你如果想下高清版本,需要自己改一下URL规则。比如Lorem Picsum的download_url默认是https://picsum.photos/id/{id}/{width}/{height},我想把它换成1080P版本,就把最后两个数字改成1920/1080,这就涉及字符串替换逻辑。
第二种,静态HTML里有CSS选择器可循。比如某个图库里大图都在.wallpaper-image img单元格里面,那么用BeautifulSoup提取特别简洁:
from bs4 import BeautifulSoup html = session.get(list_page_url).text soup = BeautifulSoup(html, "lxml") img_tags = soup.select(".wallpaper-item img") urls = [img.get("data-src") or img.get("src") for img in img_tags]注意这里我用了>import re pattern = r'https://[^"\'\s]+?\.(?:jpg|jpeg|png|webp)' urls = list(set(re.findall(pattern, html)))
正则的问题在于容易误匹配,比如把logo、图标也抓进来。所以我会在代码里额外加一层过滤条件:只保留包含wallpaper或1920这类关键字的URL,或者干脆在解析后检查文件大小和图片尺寸。
2.3 保存文件时的坑:路径、重名、扩展名
图片地址拿到手,接下来就是保存。这件看起来最简单的事,其实埋着三个雷。
第一个雷是路径非法字符。图片URL最后一段可能带中文、空格、尖括号、问号,这些在Linux里大多能忍受,在Windows里直接建立不了文件。我踩过最坑的是一个图片名字叫beach?lang=zh&size=2,Windows把它当成带问号的文件名,保存时直接报OSError。所以保存前必须清洗文件名:
import re def safe_filename(url: str) -> str: filename = url.split("/")[-1].split("?")[0] return re.sub(r'[\\/:*?"<>|\s]+', "_", filename)第二个雷是重名覆盖。同一个图站很可能出现两张同名图片,或者同一天下载的图片跟昨天的重名。解决办法是加时间戳或随机后缀。我用的是{YYYYMMDD}_{id}_{filename},其中id来自API字段,基本保证不重。
第三个雷是扩展名没有写在URL里。有些CDN地址形如https://example.com/image/abc123,没有后缀,你下载下来不知道它是jpg还是png。这种最好通过HTTP响应头里的Content-Type判断:
import mimetypes def guess_ext(content_type: str) -> str: return "." + content_type.split("/")[1]别小看这一步,我有一段时间下载了一堆无扩展名的文件,Windows根本没法自动关联看图软件,最后写了个遍历文件改后缀的脚本才救回来。
3. 一步一步写脚本:从单张下载到批量并发
前面的设计思路和避坑注意都讲完了,下面进入实战环节。我会按“先跑通再优化”的顺序,把一个最小可用脚本一步步补成想要的完整工具。
3.1 先写一个能跑的下载函数
不管数据源是怎样的,最终我们都要把图片二进制保存到本地。先写一个稳健的下载函数,后面的代码都复用它。
import os import time import requests def download_one(session: requests.Session, url: str, save_path: str) -> bool: retry_times = 3 for i in range(retry_times): try: with session.get(url, stream=True, timeout=(5, 30)) as resp: if resp.status_code != 200: print(f"状态码异常: {resp.status_code}, URL: {url}") return False content_type = resp.headers.get("Content-Type", "") if not content_type.startswith("image/"): print(f"非图片响应: {content_type}, URL: {url}") return False os.makedirs(os.path.dirname(save_path), exist_ok=True) with open(save_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) return True except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e: print(f"第 {i + 1} 次尝试失败: {e}") time.sleep(1.5) return False这里的几个细节都是经验换来。stream=True很关键,图片可能很大,如果不流式下载,requests会先把整个文件读进内存,几十兆的图同时下载时内存直接飙升。分块写盘配合chunk_size=8192,既稳定又不怎么占内存。超时参数(5, 30)中的5秒是连接超时、30秒是读取超时,避免某个坏链接把整个脚本卡死。
Content-Type检查是我后补的,因为有些网站会把404错误页面当作图片返回,状态码还是200,但内容其实是HTML错误页。检查内容类型之后,能少存不少垃圾文件。
3.2 解析列表页获取图片地址
我用一个公开且稳定的API来做示例:Lorem Picsum的列表接口。它不要求认证,返回JSON数组,非常适合初学者理解整个数据流。当然,如果你有自己的目标图站,只需要把解析部分换成2.2节里的任何一种方式。
import json import requests from bs4 import BeautifulSoup # 如果解析HTML才需要 def fetch_image_entries(api_url="https://picsum.photos/v2/list", limit=10): session = requests.Session() session.headers.update({ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)" }) resp = session.get(api_url, params={"page": 1, "limit": limit}, timeout=10) resp.raise_for_status() data = resp.json() entries = [] for item in data: # 把默认的下载地址替换成1920x1080 img_id = item["id"] download_url = f"https://picsum.photos/id/{img_id}/1920/1080" entries.append({ "id": img_id, "url": download_url, "author": item.get("author", "unknown"), }) return entries这里有几个处理值得说。params让我不用手动拼接问号字符串,代码阅读起来更清晰。把download_url的尺寸参数统一改成1920/1080,是因为默认返回宽度高度是按列表接口里的原始参数,并不一定适合当壁纸。拼接成新URL后,下载到的就是统一分辨率的壁纸。
如果你要解析HTML页面,完全可以用BeautifulSoup替换这个函数。两段代码的输出要保持同样的结构:一个包含id和url的字典列表。这样后续下载逻辑完全不用动。
3.3 用线程池把下载速度拉满
单张下载函数有了,列表也有了,接下来最没技术含量却最解压的步骤:批量下载。如果用一个for循环挨个下,几十张图可能要好几分钟,但用线程池并发下载,时间能缩短到原来的三分之一。
import concurrent.futures import os from datetime import datetime def download_batch(entries, download_dir): today = datetime.now().strftime("%Y-%m-%d") target_dir = os.path.join(download_dir, today) os.makedirs(target_dir, exist_ok=True) tasks = [] for entry in entries: filename = f"{entry['id']}_{entry['author']}_{entry['url'].split('/')[-1]}" filename = safe_filename(filename) save_path = os.path.join(target_dir, filename) tasks.append((entry["url"], save_path)) with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor: future_map = {} session = requests.Session() session.headers.update(HEADERS) for url, path in tasks: future = executor.submit(download_one, session, url, path) future_map[future] = (url, path) for future in concurrent.futures.as_completed(future_map): url, path = future_map[future] try: success = future.result() print(f"{'成功' if success else '失败'}: {url} -> {path}") except Exception as e: print(f"任务异常: {url}, 错误: {e}")线程池用同一把session会不会引发线程安全问题?实测下来,requests.Session在并发请求时是线程安全的,可以复用同一个session,这样连接池复用效果最好。每个任务调用download_one时传入同一个session,既能共享连接,又不会串数据。
并发数建议保持在4到8之间。太大容易把自己机器的文件句柄用完,也容易把目标服务器逼到限流。我的经验是:家用宽带加速比大约在4线程时收益最明显,8线程之后基本没有提升,反而可能触发对方防火墙。
3.4 加上定时与日志,做成常驻小工具
下载脚本只手动跑一次的话,价值有限。我更希望它每天早上自动执行,把新壁纸收好。这里有两个层面需要处理:定时触发和运行日志。
定时触发我用的是schedule库,写起来最轻量:
import schedule def job(): entries = fetch_image_entries(limit=10) download_batch(entries, download_dir="./wallpapers") print("本轮下载完成") schedule.every().day.at("07:30").do(job) while True: schedule.run_pending() time.sleep(60)schedule库伪装成每隔60秒醒一次看看有没有到点的定时任务。缺点是它只是一个纯Python轮询库,进程如果挂了,任务也就没了。所以更稳妥的做法是用系统自带的定时器:Windows任务计划程序里设置每天触发python your_script.py,Linux上写一条crontab,这样即使Python脚本因为某种原因退出,也只是当天少跑一次,不会影响整个系统。
日志也不是小事。我用标准库logging输出到文件,方便第二天早上检查下载情况。最少要记录:每个文件下载结果、失败原因、耗时。如果发现连续几天都是同一些链接失败,那就该考虑是不是数据源接口变了。
import logging logging.basicConfig( filename="wallpaper_downloader.log", level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s", )4. 实际运行中遇到的高频问题
脚本跑起来只是一半,另一半是应对随时可能出现的异常。下面这些问题是最近几个朋友跑类似脚本时反馈最多的,我整理成了一份速查思路,每个都附上我的排查顺序。
4.1 403与请求头:大多数问题的起点
症状:浏览器里直接打开图片地址能看到图,脚本下载却返回403 Forbidden。
排查顺序:先加User-Agent,不行再加Referer,再不行看看目标网站是否校验Cookie。逐个尝试,不要一次性全堆上去。最稳妥的方法还是找到网站的官方API,官方接口一般不会有过于激进的反爬策略。
我遇到过最隐蔽的情况是,网站图片在CDN层做了防盗链,精确到必须在URL上带一个动态token。这种情况就不要再硬扛了,要么走非官方接口绕过token,要么换一个数据源。写脚本是为了省时间,不是为了训练反爬技能。
表格区分一下几种常见状态码会更清楚:
| 状态码 | 含义 | 常见原因 | 处理思路 |
|---|---|---|---|
| 403 | Forbidden | UA被识别、防盗链 | 添加Headers、更换UA |
| 404 | Not Found | 链接失效、ID不存在 | 跳过该图,记录到日志 |
| 429 | Too Many Requests | 请求过频 | 降低并发数、增加间隔 |
| 503 | Service Unavailable | 服务器压力大 | 等待后重试 |
4.2 下载图片破损?检查流式写入
症状:下载后文件存在,但图片打不开,或者缩略图只有一半。
通常原因有两个。一是没有用流式写入,一次性读入整个响应后写盘,如果网络中断导致只写了一半,文件就废了。二是没有等待响应完全读完就关闭了上下文,我把download_one里with session.get(...) as resp:的上下文写对了,但之前用requests的普通写法:
resp = session.get(url) open(path, "wb").write(resp.content)这样在响应体很大的情况下,同样可能写到一半出错。换成3.1节的流式写法后基本不会再出现。
另外一个容易忽略的点是:图片服务器返回的Content-Length和你实际下载到的字节数不一致。我通常在写入完文件后,再做一个文件大小检查,如果最后一块chunk读出来为空但文件比预期小很多,就标记为失败并重试。
actual_size = os.path.getsize(save_path) expected_size = int(resp.headers.get("Content-Length", 0)) if expected_size and actual_size != expected_size: return False # 会被外层重试逻辑再次尝试4.3 并发下载时的超时与重试
并发环境里,控制超时比单文件更麻烦。某个线程卡在一个黑洞洞IP上30秒,其他线程全部完成后还得等它。所以download_one里我加了连接超时5秒和读取超时30秒,线程池的as_completed循环则保证每个任务完成或被淘汰后都能及时返回。
重试策略上我最多让它重试3次,每次间隔1.5秒。不要无脑重试太多次,因为如果目标服务器已经限流,你重试得越猛,反而越达不到目的。给一个更优雅的做法:如果遇到429状态码,读取响应头里的Retry-After字段,按服务器指定的秒数等待,再重试。这比固定间隔好得多。
if resp.status_code == 429: retry_after = float(resp.headers.get("Retry-After", "1")) time.sleep(retry_after)另外,线程池本身也建议设置一个全局任务超时,防止某个图片短时间内频繁重试,拖慢整个任务队列。可以在ThreadPoolExecutor外层套一个wait(futures, timeout=120)的控制逻辑,或者简单点,直接在download_one内部把单次请求时间控死就够了。
5. 让脚本更聪明:后续值得做的改动
基础版本跑通之后,你可以根据自己的姿势再往下扩展。这部分不是刚需,但每一步扩完都会让工具的舒服程度上一个台阶。
5.1 多来源聚合与去重
只从一个图站下,风格容易单调。后期可以引入多个来源,比如同时抓几个免费图库,把它们的图片URL汇总到同一个列表里。这时最重要的问题就是去重。同一个图片很可能出现在不同图站,或者同一个图站不同页面重复展示。
我用的去重方案比较简洁:用URL作为key存进一个set,下载前先判断集合里有没有这个链接。如果要做跨脚本的长久去重,可以把已下载图片的URL或图片内容的MD5哈希存到本地数据库/JSON文件里,每次启动时加载,下载完后更新。注意,正则提取出来URL时一定要先做排序、去重,否则同一个图被多个线程重复下载也只是时间问题。
5.2 自动识别最佳分辨率
默认下载1080P可能不够用。有些网站能提供从400到4K的不同尺寸URL规则,但用户很懒,不想手动挑。这时候可以用Python的PIL库对下载完的图片做一次检查,读取它的宽高,如果小于设定的阈值就直接删除,避免垃圾小图长期占用磁盘。
from PIL import Image def is_quality_ok(path, min_width=1920, min_height=1080): try: with Image.open(path) as img: w, h = img.size return w >= min_width and h >= min_height except Exception: return False把这个逻辑嵌在download_batch尾部,过滤完后剩余的图片就可以自动进入壁纸文件夹。代价是每张图片多耗零点几秒解析时间,在下载几十张图的场景里完全可接受。
5.3 与系统联动自动换壁纸
下载只是前半场,后半场是让操作系统自己摆上去。Windows、macOS、Linux各有不同方案,我这边只给一个Windows下的示例思路:
import ctypes def set_wallpaper(path): ctypes.windll.user32.SystemParametersInfoW(20, 0, path, 3)20号参数对应SPI_SETDESKWALLPAPER,最后那个3代表更新INI文件并通知系统刷新。配合定时任务,可以实现“每天自动下载新壁纸并立即更换”。macOS可以用osascript调用系统事件,Linux桌面环境通常有gsettings或专门的工具。这个联动我试过以后觉得非常爽,完全替掉了第三方壁纸软件。
不过要注意一点:壁纸文件分辨率如果和屏幕不匹配,画面可能被拉伸或留黑边。所以我在3.2节生成URL时就直接锁定1920x1080,保证和大部分屏幕比例一致。如果你的显示器是2K或4K,记得把URL规则里的分辨率参数改成自己的屏幕参数。
一点收梢的个人体会
写这个脚本最大的收获不是“我自动下载了一堆壁纸”,而是理清了“先定需求,再选技术,最后才动手”的过程。第一次写的时候,我雄心壮志地打算把所有功能一把梭做出来,结果代码写到一半自己都看不懂了。后来拆分成fetch -> parse -> download -> save四步,问题一下变得好调试得多。
如果你也想照着做,我的建议是:先不要急着上并发和定时,用最简单的for循环跑通5张图,确认数据源和解析规则没问题,再逐步叠加并发、日志、过滤和联动。这样每一步出的问题都能准确定位到具体模块,不会出现“下了3张图,其中2张是网页错误页、还有1张文件名像个乱码”这种一堆问题挤在一起的情况。
最后再分享一个小技巧:写这种工具类脚本,务必把自己手动访问URL看到的HTML和脚本获取到的HTML做一次对比,很多问题都是从“你以为的网页结构”和“实际返回的结构”不一致开始的。多打印点原始响应到文件里调试,肉眼一看就明白问题在哪了。等哪天脚本完全稳定,你每天早上打开电脑就能看到新壁纸,那时候就知道当初花半小时填的坑有多值。