1. 项目起因与整体设计思路
说实话,写这个Python脚本的念头来得挺突然。某个周末我想换一批新壁纸,结果翻了半小时网页,看到顺眼的还得一张张右键另存为,存完还得手动跳过那些分辨率不达标的“伪高清”图,折腾半天也就攒了七八张。当时我就在想,这种重复劳动为什么不丢给脚本去干?虽然市面上确实有不少现成的壁纸下载工具,但用别人的工具总有几个绕不开的问题:一是不知道它会不会偷偷往你电脑里塞东西,二是很多工具对站点结构写得很死,网站一改版就废,三是想自定义分辨率、想按标签筛选的时候,根本没地方给你调。
自己做就不一样,代码攥在自己手里,想怎么改怎么改,站点挂了你能修,需求变了你能扩。而且用Python写这种脚本,技术门槛不算高,requests加BeautifulSoup就能搞定大部分工作,顺带还能把爬虫、解析、异常处理这些东西一起练了,属于典型的一举多得。这篇文章就把我这个脚本从想法到落地的完整过程拆开来讲,代码不复杂,但里面的坑不少,我把踩过的都标记出来,你照着写一遍,基本就能改成一个能自定义站点、能断点续传、能自动筛分辨率的顺手工具。
先说这脚本解决了什么问题。它做三件事:访问壁纸站点,解析页面里的图片链接,把高清图下载到本地文件夹。听起来简单,但真正动手才发现,关键不在下载这个动作上,而在“怎么从一堆HTML标签里精准找出你想要的那张图”。页面里有缩略图、有广告图、有懒加载的占位符,你要是直接抓img标签,大概率存下来一堆小尺寸缩略图,放大全是马赛克。所以这个脚本的核心逻辑是:先找到壁纸对应的详情页,再从详情页里提取原图链接。
整个项目结构我拆成了五块:目标URL配置、请求头的伪装、页面内容的解析、原图地址的提取、以及下载过程和进度的处理。每一个模块单独看都不难,拼起来就是一个很实用的自动化小工具。这周的“壁纸自由”我就靠它实现了,顺手把过程记录成文,给同样有“换壁纸洁癖”的朋友一份可以直接抄作业的参考。
2. 环境准备与依赖选型
2.1 Python环境怎么准备
写脚本前先把环境弄利索。我默认你电脑上已经装了Python 3.8以上的版本,没装的话,去Python官网下载安装包,安装的时候记得勾选“Add Python to PATH”,这个勾不选的话,后面在命令行里敲python会提示找不到命令。装完打开终端(Windows是CMD或PowerShell,macOS或Linux直接用自带的终端),敲一下 python --version,能正常弹出版本号就说明环境OK。
这个脚本用到的第三方库不多,核心是两个:requests和beautifulsoup4。requests负责网络请求,BeautifulSoup负责解析HTML。还有一个lxml库,它本身不直接调用,但BeautifulSoup指定lxml作为解析器的时候需要它,解析速度比系统默认的html.parser快不少,建议一起装上。安装命令就一行:
pip install requests beautifulsoup4 lxml如果你机器上装过其他版本的Python,pip可能指向老版本,这时候可以用 pip3 或者 python -m pip 来确保安装到当前环境里。
2.2 为什么要选BeautifulSoup而不是正则
刚开始写爬虫的人容易有个误区,觉得正则表达式万能,看到HTML就想用正则硬抠。我第一版脚本也是这么写的,从
到图片地址之间写了一大串匹配规则,结果网站改版之后,只要标签结构变一点,整个脚本立刻抓瞎。后来换成BeautifulSoup,才意识到自己绕了多大一个弯。
BeautifulSoup做的事情其实很直观:它把整个HTML变成一棵对象树,你可以像翻目录一样,通过id、class、标签名找到你要的那个节点。代码可读性高一大截,而且对HTML结构的容错性比正则强得多。比如页面里这张壁纸的原图链接在img标签的src属性里,但缩略图也在img标签里,单靠正则去区分两者非常痛苦,用BeautifulSoup就能先定位到详情页的特定标签块,再从这个块里找img,准确性完全不同。当然正则也不是完全没用,后面提取URL里的具体参数时我还是会用re库做二次处理,两个配合着来,各干各擅长的活。
2.3 依赖库安装失败的常见原因
pip安装卡住是新手最常见的坑。我这边的经验是,先看报错信息里的关键词。如果提示timeout或者Connection reset,多半是网络问题,解决方案是临时换用国内镜像源,用法是加一个 -i 参数:
pip install requests -i https://pypi.tuna.tsinghua.edu.cn/simple如果提示Unable to install或者找不到匹配版本,那就排除一下是不是Python版本太老。lxml这个库对Python版本要求比较敏感,Python 3.7以下的机器装新版本lxml可能会失败,建议要么升级Python,要么装旧版lxml,比如 pip install lxml==4.9.2。
反正这个脚本的环境依赖总体是轻量的,没有那些动辄几百MB的机器学习库,装起来不会太折腾。
3. 核心逻辑拆解:从网页结构到原图地址
3.1 用开发者工具摸清壁纸页面的结构
写解析代码之前,最重要的一步不是上Python,而是打开浏览器,按F12进开发者工具,把目标壁纸网页的结构看明白。这一步做扎实了,后面写代码就是顺水推舟的事。
我拿一个典型的壁纸站来示范。页面布局一般是这样:首页或者分类页会展示一系列壁纸卡片,每张卡片是一个缩略图,点击之后进入壁纸详情页,详情页里才是真正的高清原图。所以我的思路分为两层:第一层,从列表页拿到每一张壁纸的详情页链接;第二层,从详情页拎出原图地址。用开发者工具的“查看元素”功能,鼠标悬停到壁纸缩略图上右键检查,你能看到类似这样的结构:
<a href="/wallpaper/12345.html" class="preview"> <img src="/thumbs/thumb_12345.jpg" alt="Mountain Landscape"> </a>这里第一个链接就是详情页地址,img的src是缩略图,千万别存这个。记住这个结构特征,后面用BeautifulSoup的时候,要根据a标签里的href属性来抓详情页入口。
接着点进详情页,继续用F12检查下载按钮或者大图区域。多数壁纸站的原图地址会放在类似 img id="wallpaper" 或者 div class="download-box" 里面,原图链接通常带有 high、full、original 之类的关键词。有些站点还会把图片地址写在页面源码的JavaScript变量里,这种情况直接从HTML标签里找不到,要改用正则去匹配那个JS变量的赋值语句。如果遇到这种结构,代码里我会加一个正则分支来兜底,实战中很多壁纸站都是这个套路。
3.2 请求头伪装与频率控制里的小讲究
爬壁纸站最忌讳的就是用requests裸奔式地请求。很多站点的反爬策略很直接,看到请求头里没有浏览器特征,直接给你返回403。我在脚本里设置了User-Agent,指的是把自己伪装成Chrome浏览器的请求,不然服务器一看到Python的默认UA就直接拒了。更讲究一点的站点还会校验Referer字段,也就是“你从哪个页面跳过来的”,如果原图链接请求没有带当前壁纸详情页的Referer,即便IP没被封,图片接口也可能返回403。这个坑我踩过一次,当时还很纳闷为什么浏览器能打开的图片,脚本下载回来却是html格式的错误页面,排查了半天才反应过来是Referer的问题。
另一个要点是请求频率。即使UA伪装了,如果你开着循环一口气把人家整个分类页全部下载完,几十个请求在几秒内砸过去,IP很容易被临时封禁。我在脚本里用time.sleep做了间隔控制,每下载一张图睡1到2秒,批量跑的时候运气好可以做到不被封。如果你要下载的量特别大,间隔可以再放长到3秒。老实说,壁纸类站点一般对爬虫不算严格,但因为我的脚本里既有列表页请求又有详情页请求,双重请求叠加,不加间隔的话风险会高不少。
3.3 页面编码与中文乱码的处理技巧
解析页面时还有一个很隐蔽的问题:编码。很多壁纸站是中文站,页面的meta标签里写着charset=utf-8,但实际返回的内容可能在传输过程中用了其他编码;或者页面本身是GBK编码,你用requests直接拿text属性,中文标题全变成“锟斤拷”之类的乱码。排查方式非常简单,遇到响应内容里中文显示异常,就手动检查一下requests的apparent_encoding属性:
resp = requests.get(url, headers=headers) print(resp.apparent_encoding)拿到结果之后,要么在请求时显式指定编码,要么直接用resp.content拿到字节流,再手动解码。我在脚本里做了编码自动识别,优先使用apparent_encoding,如果识别结果是空或者ISO-8859-1这类,就默认强制用utf-8。千万别偷懒省略这一步,否则你保存图片时用中文标题重命名,生成一堆乱码文件名会让你抓狂。
4. 完整脚本实现:一步步从请求写到下载
4.1 第一步:封装请求与基础解析函数
现在进入代码环节。我先把整个脚本的基础框架搭出来,包含导入库、请求函数和解析函数三块。因为实际项目里会有多个不同类型的请求,所以我把“发请求拿HTML”封装成了一个独立函数,默认带上伪装请求头,顺便做了异常处理和编码识别,这样后面无论是抓列表页还是抓详情页,一行代码就能复用。
import os import re import time import requests from bs4 import BeautifulSoup 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-Language": "zh-CN,zh;q=0.9,en;q=0.8", "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8", } def fetch_html(url): try: resp = requests.get(url, headers=HEADERS, timeout=10) if resp.status_code == 403: print(f"被拒绝访问: {url}") return None resp.raise_for_status() if resp.apparent_encoding: resp.encoding = resp.apparent_encoding return resp.text except requests.RequestException as e: print(f"请求失败 {url}: {e}") return None这段代码里有几个细节说一下。timeout=10 的意思是单个请求最多等10秒,超过直接报超时异常,避免某个坏链接让整个脚本卡死在原地。普通请求库虽然也可以不设timeout,但脚本跑到一半卡几十秒没响应,那种体验是真的难受,加上它等于给程序装上了一个底线保护。
UA这里我用的是一串比较新的Chrome版本号,实测下来大部分站点对它的识别度很高。有的站还看Accept和Accept-Language,我也顺手补上了。如果你要爬的目标站点比较严格,可以把这套请求头直接复制进代码里,不亏。
4.2 第二步:从列表页批量提取详情页链接
接下来是从列表页提取所有详情页链接。这里我用BeautifulSoup找到所有包着缩略图的a标签,再从中筛选出符合壁纸详情页特征的链接。
def parse_list_page(html, base_url): if html is None: return [] soup = BeautifulSoup(html, "lxml") detail_urls = [] for a in soup.find_all("a", href=True): href = a["href"] if re.search(r"/wallpaper/.+?\.html", href): if href.startswith("http"): full_url = href else: full_url = base_url + href detail_urls.append(full_url) return detail_urls为什么筛选规则用“url里包含/wallpaper/且以html结尾”而不是直接找class?因为不同站点的class命名千奇百怪,有的叫preview,有的叫pic-link,与其依靠前端样式名,不如依靠URL特征。URL结构在大多数内容站点里是语义化的,壁纸详情页的链接一般有规律可循。这一层抓准了,列表页解析就成功了一大半。
注意这里做了一个相对链接转绝对链接的操作。壁纸站里的链接很多是写成 /wallpaper/12345.html 这种不带域名的形式,直接拿它去请求是废的,必须拼上站点的根域名,所以我传入了base_url参数。这种细节写的时候看着不起眼,但缺了它,后面抓详情页的时候99%的链接都会请求失败。
4.3 第三步:从详情页提取原图地址
详情页的关键动作有两个:第一是找原图标签,第二是兜底用正则匹配JS变量。
def parse_detail_page(html): if html is None: return None soup = BeautifulSoup(html, "lxml") img = soup.find("img", id="wallpaper") if img and img.get("src"): return img["src"] img = soup.find("img", class_="download-box") if img and img.get("src"): return img["src"] pattern = re.compile(r'(?:downloadUrl|imageUrl|wallpaperUrl)\s*=\s*["\']([^"\']+)["\']') match = pattern.search(html) if match: return match.group(1) return None这里的优先级设计是:先找标签方案,再走正则兜底。大量壁纸站为了防盗链,原图地址是JavaScript动态生成的,你肉眼在网页源码里看不见img标签里有src,但在script标签里能看到类似 downloadUrl = "http://..." 这样的赋值。我见过的一次情况是,页面里原图的id不仅相同,src被拆成了两段字符串,要在正则里做拼接,不过大部分站点只要用上面的正则就够用。
如果两种方式都找不到,返回None,下载模块碰到None会跳过并记录日志,不会导致整个脚本崩溃。这是写爬虫的一个原则:宁可单条数据失败,也不能让整个流程中断。
4.4 第四步:下载图片并保存到本地文件夹
有了原图链接,下载就相对简单了。我用了stream模式来拉图片数据流,然后分块写入文件,这样做的好处是即使图片很大,也不会一次性吃满内存。文件命名我优先用页面的标题清洗后生成,如果用时间戳命名的话,文件名全是数字,后续筛图就很难受了。
def download_image(img_url, save_dir, filename): if not os.path.exists(save_dir): os.makedirs(save_dir) try: resp = requests.get(img_url, headers=HEADERS, stream=True, timeout=15) if resp.status_code == 403: print("图片下载被拒绝,可能缺Referer") return False resp.raise_for_status() file_path = os.path.join(save_dir, filename) with open(file_path, "wb") as f: for chunk in resp.iter_content(chunk_size=8192): if chunk: f.write(chunk) print(f"下载完成: {filename}") return True except requests.RequestException as e: print(f"图片下载失败: {img_url}, 错误: {e}") return False有个细节:下载图片时Referer字段在这里非常重要。我前文提过这个坑,实际逻辑也要配合上。你可以给download_image单独加一个headers参数,把当前详情页的URL填进Referer,很多站点的防盗链就过了。代码里简单起见我沿用HEADERS,但如果你遇到的站点下载403,首选排查方向就是加Referer。
chunk_size设成8192字节,也就是8KB,是实践中比较稳妥的一个值。设太小,读写频次太高,效率慢;设太大,内存占用高,如果对方服务器支持不稳定,大块读取还容易断流。8KB不激进也不保守,下载一张10MB左右的壁纸耗时完全可以接受。
4.5 第五步:多页循环与断点续传
最后是主控制流程。我加了一个翻页循环,可以从某一页开始一直抓。同时做了简单的断点续传,也就是下载前先检查本地是否已存在同名文件,存在就跳过。这个设计很实用,脚本跑到一半因为网络原因中断,重新执行的时候不用从头下载已经完成的图片,省事不少。
def main(): save_dir = "wallpapers" base_url = "https://example.com" # 替换成你要爬的站点 for page in range(1, 6): print(f"正在处理第 {page} 页...") page_url = f"{base_url}/wallpaper/page/{page}" html = fetch_html(page_url) detail_urls = parse_list_page(html, base_url) for detail_url in detail_urls: detail_html = fetch_html(detail_url) if detail_html is None: continue img_url = parse_detail_page(detail_html) if img_url and img_url.startswith("http"): filename = img_url.split("/")[-1].split("?")[0] if not filename.endswith((".jpg", ".png", ".jpeg", ".webp")): filename += ".jpg" full_path = os.path.join(save_dir, filename) if os.path.exists(full_path): print(f"文件已存在,跳过: {filename}") continue download_image(img_url, save_dir, filename) time.sleep(2) time.sleep(3) if __name__ == "__main__": main()main函数里我把页码范围写死成1到5,你可以按需改。防封策略上,单张图片之间间隔2秒,翻页之间间隔3秒,这个节奏是我试过多次之后选的平衡点——够礼貌,也不会慢到让人失去耐心。文件名处理这里注意了一下:很多图片链接后面会带参数,比如?sign=xxx,如果不截断,存下来的文件名会带一长串无用字符。用split("?")[0]只保留路径部分,后面再按后缀名补全扩展名,避免下载下来的文件没有格式。
5. 实操心得与常见问题排查
5.1 抓不到原图地址的几种情况
这个脚本我迭代了三轮才稳定下来,中途遇到的最大类问题就是“详情页里找不到原图”。我把排查思路整理成一个速查表,你遇到同样情况可以按顺序试:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 解析结果全是缩略图链接 | 提取到列表页的缩略图而不是详情页原图 | 检查详情页结构,定位真正的原图标签 |
| 详情页原图为空 | 原图地址藏在JS变量中 | 用正则匹配downloadUrl/imageUrl变量 |
| 图片下载返回403 | 防盗链校验,缺Referer | 在下载请求头中加入当前详情页URL作为Referer |
| 下载的文件打开后是HTML | 图片地址被重定向到了错误页面 | 检查img_url是否被拼错,打印看实际内容 |
| 中文文件名乱码 | 页面编码识别错误 | 用resp.apparent_encoding手动指定编码 |
如果你要爬的站点结构比较特殊,一个稳妥的调试思路是:先打印详情页HTML中的img标签列表,肉眼扫一遍看看原图在哪个位置。这个方法虽然土,但在结构复杂的站点面前,比闷头调代码和盲猜省时间得多。
5.2 被反爬之后怎么办
爬壁纸站一般不至于触发太严厉的反爬,但如果你频率太高,还是会碰到IP临时封禁的情况。我在第一轮测试时因为没加sleep,连续快速请求了几十个页面,紧接着所有请求都返回403了,换成浏览器访问正常,但脚本里就是403,基本可以确认被临时限流了。解决办法是停手等几分钟,别再频繁请求;同时把代码里的间隔加大。另外你还可以考虑给代码里加入随机延时:
import random time.sleep(random.uniform(1.5, 3.5))随机延时比固定延时更自然,机器特征弱一些,触发反爬的概率理论上也会降一点。不要把间隔设成0,也别设成1秒内,别小看这两三秒的空歇,这是爬虫能和站点“和平共处”的基础。
5.3 图片重复与文件名冲突
很多壁纸站会存在同一张图片被分到多个分类下,我在循环抓取几个分类页的时候,发现文件名冲突概率很高,后下载的图会覆盖掉之前下载的同名图。断点续传虽然能跳过已存在文件,但如果第一轮没下载成功,二次跑的时候还是会重名覆盖。更稳妥的做法是,检查本地文件名时,如果已存在且大小不一样,就自动在后面加序号。这个逻辑可以加在download_image里,比对同路径下带后缀的同名文件。
不过话说回来,普通用途的脚本其实不用搞得太复杂。我后来简化成“重名直接跳过”,因为壁纸这种资源本来就不是非此不可,跳过一两张重复的,不愿意为了“完整”去增加太多代码复杂度。
5.4 图片下载不全或文件损坏
流式下载偶尔会遇到文件不完整的问题。原因是连接中途断了,但requests没有抛异常,你已经把部分字节写进文件,程序还傻乎乎提示下载成功。我在脚本里加了一层校验:下载完成后,检查文件大小是否大于某个阈值(比如100KB),如果太小就直接删除并重试一次。壁纸图片一般不会低于几百KB,低于100KB的基本可以判定为异常文件。
if os.path.getsize(file_path) < 100 * 1024: print(f"文件异常,重新下载: {filename}") os.remove(file_path) time.sleep(2) download_image(img_url, save_dir, filename)这一层校验在批量下载场景下很关键。如果脚本一次性跑上千张图,中间只要断几回,最后留给你一批半截文件,你肉眼根本分不清哪些是完好的哪些是坏的,只能重新全量跑一遍,时间成本翻倍。有校验重试机制后,整体成功率能提升到99%以上。
5.5 通过命令行参数让脚本更灵活
脚本写好之后,每次改页码范围都要去动代码,太不灵活。我后来顺手加了一个argparse命令行参数解析,用起来方便很多。比如想抓3到10页,不再改代码,而是直接执行:
python wallpaper_downloader.py --page-from 3 --page-to 10import argparse def get_args(): parser = argparse.ArgumentParser(description="壁纸批量下载脚本") parser.add_argument("--page-from", type=int, default=1, help="起始页码") parser.add_argument("--page-to", type=int, default=5, help="结束页码") return parser.parse_args()这个改动成本很低,但带来的体验提升非常明显。命令行参数也是“脚本应该具备的基本尊严”,不然每次需求微调都要打开代码编辑器,跟搬砖没什么区别。
6. 扩展思路:让脚本从“能用”到“好用”
写完基础版本之后,我发现这个脚本的扩展空间比自己预想的大得多。如果你愿意多花一点时间,下面的几个方向都可以直接升级。我当时实测了多线程方案,效果最明显,也踩了最快的一个坑:程序突然崩溃,网页请求成功但图片总是下到一半断掉。后来我才注意到,线程池默认最大工作数设得过高,目标站点扛不住,主动断开了连接。把池子大小改成5,时间上缩短了明显的一截。多线程虽然香,但频率控制的纪律不能松。
定时任务集成其实也不复杂。我个人的习惯是每周日设置一个定时任务跑一次脚本,自动把本周的新壁纸全拉下来,周一上班打开电脑就觉得桌面焕然一新。用Windows的任务计划程序或者macOS的launchd,把python命令按指定时间跑起来就行,再配合命令行参数的页码区间,等于给自己做了一个低配版壁纸聚合器。
还有一个思路是结合图像识别筛选风格。比如不喜欢粉色系的壁纸,可以在下载完成后用PIL把图片主色调算出来,不符合预期就直接删掉。不过这个属于后处理,会增加不少代码量,核心下载流程其实不长。建议先跑通基础版,再按需加料,一步到位反而容易处处碰bug。
我在实际使用过程中的体会是:这种小项目,最大的收获不是那几十上百张壁纸,而是把“靠眼睛盯页面找图”的思路,彻底转换成了“用逻辑描述规则”的工程思维。从那以后,再遇到任何重复性的下载场景,哪怕不是壁纸,我也会下意识地想一下:这东西能不能用脚本处理?思路一旦打开,能自动化的活儿其实比你想象中多得多。比如PDF批量转图片、表情包归类整理、素材站素材更新检测,只要你愿意改改解析规则,这套代码骨架都能复用。这就是写自动化脚本最值钱的部分。