说实话我是被手动换壁纸这件事烦到不行才开始写这个脚本的。每次想给桌面换个新鲜感都得打开浏览器、翻分类、挨个预览右键另存为保存最后再手动挪到对应文件夹。一套操作下来十几分钟是常有的事而且下次想找同一风格的图又得重新翻一遍。所以就有了这篇博客的标题编写一个 Python 脚本自动下载壁纸。可能有人觉得这种脚本网上现成的一大堆直接抄不就行了但现实是网上很多脚本要么是针对某个具体站点写死的要么是一跑就报错的老代码真正能让你理解原理、按需改动、稳定跑起来的并不多。这篇文章会从需求拆解开始讲清楚每一步为什么这么做给出完整可改的代码再把我实际运行中踩过的坑一并列出来。适合刚学 Python 没多久、但已经能看懂基础语法想拿个小项目练手的朋友也适合已经写过简单爬虫、想补全工程细节的人。1. 手动换壁纸的痛点以及脚本要拆成哪几件事1.1 我为什么决定写这个脚本我的诉求其实很朴素每天或每周能自动给我拉一批新壁纸按风景、动漫、极简这种分类放好我只需要进去挑一张顺眼的就行。听起来简单但手动做起来你就会发现整个流程里其实藏着好几个重复劳动点第一是找得知道哪个站更新了好看的图第二是选要预览缩略图、点开大图第三是存保存时文件名经常是一串乱码还得自己重命名第四是管不同类型的图混在一起时间一长目录就乱成一锅粥。这些问题单独看都不大但叠加在一起就构成了一个非常典型的自动化场景。与其每次手动重复不如写一个脚本把这四件事一次跑完。而且这个需求非常适合练手它不涉及复杂的登录和交互主体就是发请求、解析页面、下载文件这三板斧但对 HTTP 请求、HTML 解析、文件 IO、异常处理都有要求学会之后能直接迁移到很多类似的批量抓取资源场景。1.2 把下载壁纸拆成四个可编程的环节写代码之前最重要的不是急着敲键盘而是把需求拆清楚。我把它拆成了四个环节获取图片列表页面的 HTML 内容。从 HTML 里解析出每张壁纸的高清大图地址。逐个请求图片地址把二进制内容写入本地文件。对文件做命名、去重、分类归档并记录哪些已经下载过。这四个环节各自独立任何一个出问题都不会影响其他环节的判断。比如解析失败了我可以单独调试解析逻辑下载失败了我可以只重试下载。这种高内聚低耦合的拆法对一个小脚本来说可能显得有点讲究但等你后面想加功能、想扩展站点时就会发现当初这十几分钟的拆分时间花得特别值。2. 技术选型requests BeautifulSoup 为什么够用2.1 几种常见方案的对比确定需求后第二步是选工具。我见过不少新手一上来就想上 Scrapy 或者 Selenium其实没必要。我把自己考虑过的方案列个表格方便你对比方案优点缺点适合场景requests BeautifulSoup轻量、上手快、依赖少不处理 JavaScript 渲染普通静态页面为主Scrapy高并发、框架完整、自带去重学习曲线陡配置多大规模爬取、长期项目Selenium / Playwright能跑 JS 渲染页面开销大、速度慢、维护难必须模拟真实点击aiohttp 异步解析并发下载快代码复杂度高、调试麻烦单次要下几百张图我这次的目标站点是典型的静态列表页图片地址直接写在 HTML 里根本不需要浏览器渲染。所以requests加BeautifulSoup就是最省事、最稳的组合。Scrapy 很强但为了下载几十张壁纸搭一个完整的 Scrapy 工程属于杀鸡用牛刀Selenium 更不用说开个浏览器去滚动页面性能开销全是浪费。选型判断口诀页面能右键查看源代码看到图片地址就用 requests BeautifulSoup看不到再考虑渲染类工具。2.2 环境准备与依赖安装我推荐用虚拟环境管理依赖避免把系统 Python 环境搞得一团糟。命令如下mkdir wallpaper-spider cd wallpaper-spider python -m venv venv source venv/bin/activate # Windows 下用 venv\Scripts\activate pip install requests beautifulsoup4 lxml schedule这里面的依赖逐个说一下用途requests发 HTTP 请求拿页面和图片。beautifulsoup4解析 HTML提取图片地址。lxmlBeautifulSoup 的解析引擎比纯 Python 的 html.parser 快很多对复杂页面容错也更好。schedule后面做定时任务用的这个看个人需求。我在 Python 3.10 和 3.12 上都跑过这套代码都没问题。如果你用的是更老的版本至少保证 3.8 以上主要是语法兼容性的问题。2.3 为什么没上异步并发写第一版的时候我确实纠结过要不要用 asyncio 并发下载因为页面上一页可能就有几十张原图如果一张一张串行下载可能会慢得让人怀疑人生。但后来想清楚了一个事实对壁纸这种几百 KB 到几 MB 的图片来说串行下载加 0.5 到 1 秒延时一分钟也就下完几十张了完全够用。更重要的是异步并发会带来一连串附加问题连接池管理、失败重试逻辑复杂度上升、被站点识别为恶意请求的风险变大。对一个自己用的、频率不高的脚本来说稳定和可控远比速度重要。所以我最终选择了串行加延时的方式只有当你需要一次性抓取几百上千张图的时候才值得引入并发。3. 核心实现从页面解析到图片落盘接下来进入正题。我以一个虚构的壁纸站https://wallpaper.example.com/landscape为例它的结构是列表页里每个图片项是一个div.pic-list里面有个img标签缩略图地址在src属性里高清大图地址在>import requests BASE_URL https://wallpaper.example.com/landscape 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9, image/avif,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://wallpaper.example.com/, } SESSION requests.Session() SESSION.headers.update(HEADERS) def fetch_page(url): resp SESSION.get(url, timeout10) resp.raise_for_status() return resp.text这里有两个容易被忽略的关键点。第一是resp.raise_for_status()如果返回了 404 或 500它会直接抛异常而不是让你拿到一个内容完全不对的页面继续解析这能帮你及早发现 URL 写错或页面改版的问题。第二是timeout10必须要有否则某个请求卡住时脚本会无限期挂在那整个任务都停摆。3.2 第二步用 CSS 选择器提取图片地址拿到 HTML 之后就该 BeautifulSoup 上场了。解析的核心是观察页面结构然后用最精准的方式定位元素。from urllib.parse import urljoin from bs4 import BeautifulSoup def parse_image_urls(html): soup BeautifulSoup(html, lxml) urls [] for img in soup.select(div.pic-list img): # 优先取>def download_image(image_url, save_path): resp SESSION.get(image_url, timeout15, streamTrue) resp.raise_for_status() with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk)块大小选 8192 字节8KB是常见做法磁盘 IO 和内存占用之间比较均衡。太小的块会频繁写入太大会一次占用较多内存。除非你的文件特别大否则这个值不用改。3.4 完整脚本示例把上面的功能拼起来再加上基本的去重和文件名生成就是一个能用的完整脚本了import hashlib import os import time import random from urllib.parse import urlparse, urljoin import requests from bs4 import BeautifulSoup BASE_URL https://wallpaper.example.com/landscape SAVE_DIR wallpapers/landscape DOWNLOAD_LOG downloaded.txt 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, Accept: text/html,application/xhtmlxml,application/xml;q0.9,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9,en;q0.8, Referer: https://wallpaper.example.com/, } SESSION requests.Session() SESSION.headers.update(HEADERS) def fetch_page(url): resp SESSION.get(url, timeout10) resp.raise_for_status() return resp.text def parse_image_urls(html): soup BeautifulSoup(html, lxml) urls [] for img in soup.select(div.pic-list img): image_url img.get(data-src) or img.get(src) if image_url: full_url urljoin(BASE_URL, image_url.strip()) if full_url not in urls: urls.append(full_url) return urls def make_filename(image_url): ext os.path.splitext(urlparse(image_url).path)[1] if ext not in (.jpg, .jpeg, .png, .webp, .gif): ext .jpg name hashlib.md5(image_url.encode()).hexdigest()[:16] return name ext def download_image(image_url, save_path): resp SESSION.get(image_url, timeout15, streamTrue) resp.raise_for_status() with open(save_path, wb) as f: for chunk in resp.iter_content(chunk_size8192): if chunk: f.write(chunk) def load_downloaded(): if not os.path.exists(DOWNLOAD_LOG): return set() with open(DOWNLOAD_LOG, r, encodingutf-8) as f: return {line.strip() for line in f if line.strip()} def record_downloaded(image_url): with open(DOWNLOAD_LOG, a, encodingutf-8) as f: f.write(image_url \n) def main(): os.makedirs(SAVE_DIR, exist_okTrue) downloaded load_downloaded() html fetch_page(BASE_URL) image_urls parse_image_urls(html) print(f页面解析完成共 {len(image_urls)} 张图片) count 0 for image_url in image_urls: if image_url in downloaded: print(f跳过已下载: {image_url}) continue filename make_filename(image_url) save_path os.path.join(SAVE_DIR, filename) try: download_image(image_url, save_path) record_downloaded(image_url) count 1 print(f[{count}] 已保存: {filename}) except Exception as e: print(f下载失败 {image_url}: {e}) # 随机延时 0.5 到 1.5 秒避免请求过于集中 time.sleep(random.uniform(0.5, 1.5)) print(f本次新增下载 {count} 张完成。) if __name__ __main__: main()这个脚本已经能完成解析 - 下载 - 记录的闭环。运行一次后下次再跑会自动跳过已下载的图片实现最简单的增量更新。你会发现去重逻辑其实就是靠一个文本文件当数据库简单粗暴但非常有效。4. 反爬与请求头伪装实测中必须处理的细节4.1 服务器会识别什么很多人第一次写这种脚本时吭哧吭哧把解析和下载写好了一跑发现要么返回 403要么直接被封 IP人直接懵了。其实服务器判断你是不是真人主要看这么几样东西请求头是否像一个真实浏览器请求频率是否像一个人类Cookie / Session 是否符合正常访问路径是否存在像脚本的异常行为特征。最常见的翻车点就是User-Agent。requests默认的 UA 是python-requests/x.y.z伪装程度约等于零不少站点见一个拦一个。所以我第一件事就是把它改成当前主流 Chrome 版本的 UA 字符串。除此之外Referer也是很多壁纸站做防盗链的关键字段。图片服务器会校验请求是从哪个页面跳转过来的如果Referer不对直接返回 403。这也是为什么我的 headers 里单独带了Referer并且在代码里由同一个 Session 统一管理。4.2 请求头怎么配才算完整我把常用的请求头整理成一个参考表格实战中你可以按需取舍请求头作用实测翻车点User-Agent标识浏览器类型与版本漏掉或被识别为 python-requestsReferer标识请求来源页面图片防盗链校验失败Accept声明能接受的响应类型不设置可能拿到非预期格式Accept-Language声明接受的语言影响某些页面返回内容Cookie维持登录/访问状态需要登录的站点必填要注意的是请求头不是越多越好刻意堆一堆浏览器里的奇怪字段反而可能暴露。一般核心就是User-Agent、Referer、Accept、Accept-Language这四样够用就行。4.3 延时与重试逻辑请求频率是最容易被忽略但又最致命的一环。很多站点虽然不校验请求头但会对 IP 的请求频率做统计一分钟发几百个请求基本等于在脸上写着我是爬虫。我采用的策略是每次下载后随机延时 0.5 到 1.5 秒用random.uniform而非固定值。固定值有一个问题容易被频率检测精确匹配到规律。随机延时虽然让总时间稍微变长但换来的是请求模式更接近真人。另外单次请求失败不一定要直接放弃。我建议加上简单的重试逻辑比如对超时或 5xx 错误重试两次每次重试前等待更长时间。下面是一个可以参考的重试封装def get_with_retry(url, retries3): for i in range(retries): try: resp SESSION.get(url, timeout10) resp.raise_for_status() return resp except Exception as e: print(f第 {i 1} 次请求失败: {e}) if i retries - 1: time.sleep(3 i * 2) return None还有一点要特别说明不要对同一站点高频跑脚本。壁纸类站点的服务器负载通常不高你一分钟上百个请求相当于对这个站点做了一次小型攻击。我的建议是每天最多跑一次或者每周跑两三次这也是为什么我在题目里说的自动下载更适合配一个低频的定时任务。5. 文件管理命名、去重、分类归档5.1 命名规则为什么要用 Hash一开始我用的是图片原始文件名比如DSC02345.jpg、bg_20240101_1920x1080.jpg。但跑了几天就发现问题了不同页面可能存在同名文件覆盖保存后前面的图就丢了还有的原始文件名包含特殊字符在 Windows 路径里直接报错。后来我改成用 URL 的 MD5 值作为文件名。为什么选 MD5 而不是别的因为make_filename只需要一个确定性映射同一张图片无论什么时候跑、跑多少次生成的名称都不会变这天然支持了去重。文件名取前 16 位十六进制字符碰撞概率对这个量级的数据来说完全不用担心。同时我从 URL 路径后缀里提取扩展名保证文件格式正确。如果你希望文件名能一眼看出内容可以把主题关键词也拼进去比如landscape_20240101_a1b2c3d4.jpg。核心是唯一性优先可读性其次。5.2 去重一个 txt 文件就够去重的实现我前面已经在脚本里展示了核心就是一个downloaded.txt每次下载成功就往里追加一行 URL。下次跑时先把它读进一个set判断当前图片 URL 是否在里面是就跳过。这里有个实现细节值得注意记录的是下载成功的 URL而不是待下载的 URL。为什么要这样做因为你如果先把 URL 写进文件再下载下载失败了那这个记录就是脏数据下次还会跳过这张实际没成功的图。先下载、成功后记录顺序不能反。对于几十万量级的 URLtxt 文件的性能也够。如果哪天量大了可以换成 SQLite 存储但在我的场景下完全没必要引入数据库。5.3 分类归档与目录结构壁纸这种资源不分类就是灾难。我建议按主题建目录脚本让用户以命令行参数传入分类名或者写死在配置里。我使用的结构是这样的wallpapers/ ├── landscape/ │ ├── a1b2c3d4e5f6a7b8.jpg │ └── ... ├── anime/ │ └── ... └── minimal/ └── ...实现上很简单os.makedirs(SAVE_DIR, exist_okTrue)会自动创建缺失目录exist_okTrue避免已存在时抛异常。后续如果同一个分类里还想按分辨率细分比如landscape/4k、landscape/1080p也可以先解析图片 URL 里的尺寸参数再决定放进哪一层。6. 定时运行与增量更新让脚本变成自动的6.1 用 schedule 库实现定时任务脚本本身写好了但手动执行和写脚本的初衷冲突——我还是得自己记得运行它。所以要让脚本自动真正跑起来定时任务才行。方法有三种操作系统的计划任务Windows 任务计划程序、Linux 的 crontab、云平台定时触发、或者直接在脚本里用schedule库。我选择的是最后一种因为整个任务都收敛在一个脚本里跨平台也好移植。import schedule def job(): print(开始更新壁纸...) main() print(壁纸更新完毕。) # 每天早上 7 点自动执行 schedule.every().day.at(07:00).do(job) while True: schedule.run_pending() time.sleep(60)这个while True会挂住进程所以我把这个逻辑单独放到run_scheduler.py里核心下载脚本保持独立。这样即使定时任务出问题我随时可以手动跑核心脚本两者互不干扰。如果你的电脑不会常开用系统级计划任务更合适。Linux 下就是一行 crontab0 7 * * * cd /path/to/wallpaper-spider venv/bin/python main.py run.log 21Windows 任务计划程序里设置触发器为每天 07:00操作里填python main.py工作目录填项目路径效果一样。6.2 增量更新每天只下载新图由于我前面实现了downloaded.txt记录机制脚本天然具备增量能力。每天定时跑一次时页面里大部分是老图脚本会直接跳过只下载新增的那几张。这不仅节省流量和磁盘空间也降低了对站点造成的压力。但增量逻辑有个要注意的地方如果站点把图片 URL 改了但内容没变你的记录会把它当作全新图片又下载一次。我的处理方式是接受这个误差因为壁纸站通常不会频繁变更资源地址。如果你非常在意可以改成下载后计算文件 MD5再和本地库比对但这就有点过度设计了。6.3 日志输出与运行通知定时任务跑在后台出错了你未必知道。我建议脚本里加两个基础能力日志文件和控制台输出。把print改成同时写入日志文件是最简单的办法import logging logging.basicConfig( filenamewallpaper.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s, )然后把关键的print换成logging.info出错的地方用logging.exception。这样第二天早上我可以只花十秒钟看一眼日志确认昨晚的更新是成功还是失败。有条件的话还可以在任务成功后调用系统通知接口把下载数量推送到手机不过我目前觉得看日志就够了。7. 我实测踩过的坑以及几个扩展方向7.1 三个最值得说的问题第一个坑是图片地址用了懒加载。很多现代壁纸站在图片进入可视区域前src属性是空的真实地址放在>