公司动态

Python爬虫实战:构建Wallheaven壁纸自动下载工具

📅 2026/8/17 2:28:45
Python爬虫实战:构建Wallheaven壁纸自动下载工具
1. 从需求到实现为什么选择Wallheaven作为壁纸源作为一个常年和屏幕打交道的人我对桌面壁纸有种近乎偏执的追求。一张好的壁纸不仅仅是视觉上的点缀更是工作灵感和情绪的调节器。我试过很多壁纸网站从国内的各种图库到国外的Unsplash、Pexels但最终让我长期驻留的还是Wallheaven。原因很简单它的内容生态太独特了。这里聚集了大量由艺术家和爱好者上传的高质量、高分辨率壁纸尤其是二次元、概念艺术、极简抽象和科幻赛博朋克风格的作品其丰富度和审美水准在其他平台很难找到。但问题也随之而来。Wallheaven的网页端虽然体验不错但每次想批量下载心仪的壁纸或者想根据自己设定的关键词、分辨率、宽高比自动更新壁纸库时手动操作就显得异常繁琐。你可能需要一页页翻找一张张点击下载效率极低。更别提有时候看到某个画师的一系列作品想全部收藏下来手动操作几乎是个不可能完成的任务。这就是自动化脚本的价值所在——它能把我们从重复、机械的劳动中解放出来让我们专注于“选择”和“欣赏”本身。所以今天我想分享的就是如何构建一个稳定、高效且具有一定智能的Wallheaven壁纸爬虫。这不仅仅是一个“下载图片”的脚本它涉及到对网站结构的逆向工程、请求模拟、反爬策略应对、本地文件管理以及错误恢复机制。我会从最基础的思路讲起一步步拆解核心难点并分享我在实际开发中踩过的坑和总结的经验。无论你是Python新手想练手还是有一定经验的开发者想完善自己的工具链相信都能从中获得启发。2. 逆向工程理解Wallheaven的页面结构与数据流在动手写代码之前我们必须像侦探一样先搞清楚目标网站是如何工作的。直接莽撞地请求页面然后解析HTML往往效率低下且脆弱。我们的目标是找到最稳定、最高效的数据获取方式。2.1 页面导航与URL规律首先我们打开Wallheaven的搜索页面。比如我们搜索“cyberpunk”URL大概是这样的https://wallhaven.cc/search?qcyberpunk。观察这个URL我们可以发现几个关键的查询参数q: 搜索关键词。categories: 分类general, anime, people通常以类似110的二进制位表示。purity: 内容纯度sfw, sketchy, nsfw同样以二进制位表示。sorting: 排序方式date_added, relevance, random, views, favorites, toplist。order: 排序顺序desc, asc。page: 页码。这意味着我们可以通过程序化地构造URL来模拟翻页和筛选这是爬虫的基础。例如要获取“cyberpunk”主题、仅限“general”和“anime”分类、安全内容、按最新排序、第5页的结果我们可以构造URLhttps://wallhaven.cc/search?qcyberpunkcategories110purity100sortingdate_addedorderdescpage5。2.2 核心数据藏在哪HTML解析 vs. 潜在API接下来是最关键的一步数据在哪最直观的想法是下载页面HTML然后用BeautifulSoup或lxml去解析出图片的预览图链接和详情页链接。这当然可行Wallheaven的列表页结构相对规整图片信息包裹在section标签下的figure元素中每个figure有一个>pip install requests3.2 页面解析库BeautifulSoup4从HTML中提取数据BeautifulSoup4 (bs4) 是不二之选。它支持多种解析器如 lxml, html.parser能让我们像操作DOM树一样使用标签名、属性、CSS选择器来定位元素API非常友好。安装命令pip install beautifulsoup4 # 为了提高解析速度建议同时安装lxml解析器 pip install lxml3.3 其他辅助工具正则表达式 (re): Python内置模块。虽然BeautifulSoup是主力但在处理一些复杂的字符串匹配和提取时比如从一段脚本文本中提取JSON数据正则表达式依然是利器。os / pathlib: 用于本地目录的创建、文件路径的拼接确保下载的图片能有序保存。time / random: 用于在请求之间添加随机延迟模拟人类操作避免触发反爬机制。logging: 用于记录程序运行日志方便排查错误。爬虫运行时间可能很长没有日志就像在黑暗中摸索。一个基础的爬虫项目结构可以这样组织wallheaven_crawler/ ├── main.py # 主程序入口 ├── crawler.py # 核心爬取逻辑类 ├── utils.py # 工具函数如请求头生成、延迟函数 ├── config.py # 配置文件搜索关键词、保存路径等 ├── logs/ # 日志目录 └── wallpapers/ # 图片保存目录按日期或关键词分类4. 爬虫实战分步拆解与代码实现现在让我们把理论付诸实践一步步构建爬虫。我会先给出关键代码片段然后解释其背后的逻辑和注意事项。4.1 第一步模拟浏览器请求获取列表页HTML任何网站都会检查请求头Headers其中User-Agent是标识客户端身份的关键。如果我们使用Python Requests的默认UA很容易被识别为爬虫。因此第一步是伪装成一个真实的浏览器。import requests from bs4 import BeautifulSoup import time import random import logging logging.basicConfig(levellogging.INFO, format%(asctime)s - %(levelname)s - %(message)s) logger logging.getLogger(__name__) def get_headers(): 生成随机的用户代理头模拟不同浏览器访问 user_agents [ Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36, Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.1.1 Safari/605.1.15, Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36 ] return { User-Agent: random.choice(user_agents), Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: en-US,en;q0.5, Referer: https://www.google.com/, # 模拟从谷歌搜索跳转而来 DNT: 1, # Do Not Track Connection: keep-alive, Upgrade-Insecure-Requests: 1, } def fetch_search_page(keyword, page1, categories111, purity100): 获取指定关键词和页码的搜索列表页HTML base_url https://wallhaven.cc/search params { q: keyword, categories: categories, # 例如111表示全选 purity: purity, # 例如100表示仅SFW sorting: date_added, # 按最新排序 order: desc, page: page } headers get_headers() try: # 添加随机延迟避免请求过快 time.sleep(random.uniform(1, 3)) response requests.get(base_url, paramsparams, headersheaders, timeout10) response.raise_for_status() # 如果状态码不是200抛出HTTPError异常 response.encoding utf-8 logger.info(f成功获取搜索页: {keyword} - 第{page}页) return response.text except requests.exceptions.RequestException as e: logger.error(f获取搜索页失败: {e}, URL: {response.url if response in locals() else Unknown}) return None关键点解析随机User-Agent准备一个列表每次请求随机选择一个降低被单一特征识别的风险。完整的请求头除了UA还添加了Accept,Referer等字段使请求看起来更“自然”。随机延迟time.sleep(random.uniform(1, 3))在每次请求前等待1到3秒。这是最基本的礼貌爬虫原则也是对目标网站服务器的尊重能有效避免IP被暂时封锁。异常处理使用try...except捕获网络请求可能出现的超时、连接错误、HTTP错误等并通过日志记录保证程序在遇到个别错误时不会整体崩溃。4.2 第二步解析列表页提取壁纸ID与预览信息拿到HTML后我们用BeautifulSoup来解析它提取出我们需要的壁纸ID和详情页链接。def parse_wallpaper_list(html_content): 从列表页HTML中解析出壁纸ID和详情页链接 if not html_content: return [] soup BeautifulSoup(html_content, lxml) wallpaper_items [] # 根据Wallheaven的页面结构壁纸项通常位于 section 标签下的 figure 标签中 # 使用更精确的选择器找到所有具有data-wallpaper-id属性的figure标签 figure_elements soup.select(figure[data-wallpaper-id]) for figure in figure_elements: wallpaper_id figure.get(data-wallpaper-id) if not wallpaper_id: continue # 详情页链接 detail_url fhttps://wallhaven.cc/w/{wallpaper_id} # 可以尝试获取预览图地址缩略图用于预览或校验 preview_elem figure.select_one(img.lazyload) preview_url preview_elem.get(data-src) if preview_elem else None # 如果data-src没有则尝试src if not preview_url and preview_elem: preview_url preview_elem.get(src) wallpaper_items.append({ id: wallpaper_id, detail_url: detail_url, preview_url: preview_url }) logger.info(f从当前页面解析到 {len(wallpaper_items)} 个壁纸项目) return wallpaper_items关键点解析选择器策略figure[data-wallpaper-id]是一个属性选择器它直接定位到拥有>def fetch_wallpaper_detail(detail_url): 访问详情页提取原图下载地址和元信息 headers get_headers() try: time.sleep(random.uniform(2, 4)) # 详情页访问间隔可以稍长 response requests.get(detail_url, headersheaders, timeout15) response.raise_for_status() response.encoding utf-8 detail_html response.text except requests.exceptions.RequestException as e: logger.error(f获取详情页失败: {e}, URL: {detail_url}) return None soup BeautifulSoup(detail_html, lxml) # 方案一尝试通过#wallpaper图片元素的src属性获取 wallpaper_img soup.select_one(#wallpaper) image_url wallpaper_img.get(src) if wallpaper_img else None # 方案二如果方案一失败可能是旧版页面结构或脚本加载尝试在页面脚本中查找 if not image_url: # 查找包含图片URL的脚本标签 for script in soup.find_all(script): if script.string and wallpaper in script.string and src in script.string: # 使用正则表达式从脚本文本中匹配图片URL模式 import re # 匹配类似 https://w.wallhaven.cc/full/xx/wallhaven-xxxxxx.jpg 的格式 pattern rhttps://w\.wallhaven\.cc/full/[a-z0-9]{2}/wallhaven-[a-z0-9]{6}\.(jpg|png|webp) matches re.search(pattern, script.string) if matches: image_url matches.group(0) break if not image_url: logger.warning(f无法从详情页提取原图URL: {detail_url}) # 可以尝试记录下页面HTML片段用于调试 # with open(fdebug_{detail_url.split(/)[-1]}.html, w, encodingutf-8) as f: # f.write(detail_html[:5000]) return None # 同时可以提取一些元信息如分辨率、标签等 resolution None resolution_elem soup.select_one(dl.sidebar-section dd:nth-of-type(3)) # 根据实际页面结构调整选择器 if resolution_elem: resolution resolution_elem.text.strip() tags [] tags_container soup.select(ul.tags li a) for tag in tags_container: tags.append(tag.text.strip()) return { original_url: image_url, detail_url: detail_url, resolution: resolution, tags: tags }关键点解析主次方案结合首选通过CSS选择器#wallpaper定位图片元素。这是最直接的方式。如果失败可能因为页面结构微调或图片由JavaScript动态加载则启动备用方案扫描页面中的所有script标签使用正则表达式匹配已知的图片URL模式。这种“主定位正则兜底”的策略大大增强了爬虫的鲁棒性。更长的延迟访问详情页的间隔2-4秒比列表页1-3秒稍长。因为详情页的访问对服务器负载更重且我们可能需要连续访问多个详情页保持礼貌的访问间隔至关重要。元信息提取除了原图URL我们还尝试提取了分辨率和标签。分辨率可以用于后续筛选例如只下载4K壁纸标签可以用于本地图片库的分类管理。这些信息不是下载所必需的但能极大提升爬取数据的价值。错误处理与调试如果最终都没找到图片URL记录警告日志。被注释掉的调试代码保存HTML片段在开发阶段非常有用可以帮助你分析页面结构到底发生了什么变化。4.4 第四步下载并保存图片到本地获取到原图URL后最后一步就是下载并保存。这里需要注意文件命名和目录组织。import os from urllib.parse import urlparse def download_image(image_info, save_dir./wallpapers, keywordgeneral): 根据图片信息下载并保存图片 if not image_info or original_url not in image_info: logger.error(无效的图片信息无法下载) return False image_url image_info[original_url] # 从URL中提取文件名 parsed_url urlparse(image_url) filename os.path.basename(parsed_url.path) # 例如 wallhaven-7p39gy.png # 按关键词创建子目录方便管理 keyword_dir os.path.join(save_dir, keyword.replace( , _)) os.makedirs(keyword_dir, exist_okTrue) filepath os.path.join(keyword_dir, filename) # 检查文件是否已存在避免重复下载 if os.path.exists(filepath): logger.info(f文件已存在跳过下载: {filepath}) return True headers get_headers() # 下载图片需要Referer指向详情页有些网站会校验 headers[Referer] image_info.get(detail_url, https://wallhaven.cc/) try: time.sleep(random.uniform(1, 2)) # 下载前也稍作等待 response requests.get(image_url, headersheaders, timeout30, streamTrue) # 使用流式下载大文件 response.raise_for_status() # 流式写入文件避免大文件占用过多内存 with open(filepath, wb) as f: for chunk in response.iter_content(chunk_size8192): if chunk: f.write(chunk) logger.info(f图片下载成功: {filename} - {filepath}) # 可选保存元信息到单独的JSON文件或数据库 # save_metadata(image_info, filepath) return True except requests.exceptions.RequestException as e: logger.error(f图片下载失败: {e}, URL: {image_url}) # 如果下载失败删除可能已创建的不完整文件 if os.path.exists(filepath): os.remove(filepath) return False except IOError as e: logger.error(f文件写入失败: {e}, Path: {filepath}) return False关键点解析文件命名与目录组织直接从图片URL中提取文件名如wallhaven-7p39gy.png这通常是唯一的。我们按照搜索关键词创建子目录这样不同主题的壁纸就不会混在一起。重复下载检查在下载前检查本地是否已存在同名文件。这是一个好习惯既能节省时间和流量也能避免因程序中断重启导致的重复工作。流式下载对于可能很大的图片文件使用streamTrue参数和response.iter_content()进行流式下载。这样不会一次性将整个文件加载到内存中对于内存受限的环境非常友好。Referer头有些图片服务器会检查Referer头以确保请求是从自己的网站页面发起的。我们将Referer设置为壁纸的详情页URL模拟从网页点击下载的行为。完善的异常处理与清理下载过程中任何错误网络、写入都会被捕获并记录。如果下载失败我们会尝试删除可能已创建的不完整文件保持本地文件系统的整洁。5. 工程化与稳健性提升让爬虫更可靠一个能跑通的脚本和一个能在生产环境长期稳定运行的爬虫之间隔着许多工程细节。下面我们来解决这些问题。5.1 应对反爬机制IP限制与请求频率Wallheaven虽然没有特别严厉的反爬但毫无节制的请求依然可能导致IP被暂时限制访问。除了我们已经使用的随机延迟和随机UA还可以考虑以下策略使用代理IP池如果你的爬取量非常大可以考虑使用付费或免费的代理IP服务在请求失败或收到特定状态码如429 Too Many Requests时自动切换IP。更智能的延迟不要使用固定延迟。可以在一个基础延迟上增加随机扰动甚至模拟人类的浏览模式——连续翻几页后休息更长时间。请求重试机制对于非永久性错误如网络波动、连接超时可以实现一个带指数退避的重试逻辑。def robust_request(url, headers, max_retries3): 一个带有重试机制的请求函数 for attempt in range(max_retries): try: delay random.uniform(1, 3) * (attempt 1) # 重试延迟递增 time.sleep(delay) response requests.get(url, headersheaders, timeout10) response.raise_for_status() return response except requests.exceptions.Timeout: logger.warning(f请求超时第{attempt1}次重试: {url}) except requests.exceptions.ConnectionError: logger.warning(f连接错误第{attempt1}次重试: {url}) except requests.exceptions.HTTPError as e: if e.response.status_code 429: # 触发频率限制 wait_time int(e.response.headers.get(Retry-After, 30)) logger.warning(f触发频率限制等待{wait_time}秒后重试) time.sleep(wait_time) continue else: raise e # 其他HTTP错误直接抛出 logger.error(f请求失败已达最大重试次数: {url}) return None5.2 断点续传与状态持久化爬虫可能会因为网络问题、程序异常或人为中断而停止。我们不想每次从头开始。实现断点续传的关键是持久化爬取状态。一个简单有效的方法是将已成功处理的壁纸ID记录到一个文件如JSON或文本文件或数据库中。每次启动爬虫时先加载这个记录在解析列表页后过滤掉已经处理过的ID。import json STATE_FILE crawler_state.json def load_processed_ids(): 加载已处理的壁纸ID集合 if os.path.exists(STATE_FILE): with open(STATE_FILE, r, encodingutf-8) as f: try: data json.load(f) return set(data.get(processed_ids, [])) except json.JSONDecodeError: return set() return set() def save_processed_ids(processed_ids): 保存已处理的壁纸ID集合 state_data {processed_ids: list(processed_ids)} with open(STATE_FILE, w, encodingutf-8) as f: json.dump(state_data, f, ensure_asciiFalse, indent2) # 在主循环中使用 def main_crawl_loop(keyword, max_pages5): processed_ids load_processed_ids() newly_processed set() for page in range(1, max_pages 1): html fetch_search_page(keyword, page) if not html: break items parse_wallpaper_list(html) if not items: logger.info(f第{page}页没有解析到数据可能已到末页) break for item in items: wallpaper_id item[id] if wallpaper_id in processed_ids: logger.debug(f跳过已处理ID: {wallpaper_id}) continue # 处理这个壁纸获取详情、下载 detail_info fetch_wallpaper_detail(item[detail_url]) if detail_info and download_image(detail_info, keywordkeyword): newly_processed.add(wallpaper_id) # 每成功处理一个可以即时保存状态更安全 processed_ids.add(wallpaper_id) save_processed_ids(processed_ids) # 处理间隔 time.sleep(random.uniform(3, 5)) logger.info(f本轮爬取完成新增 {len(newly_processed)} 个壁纸。)5.3 日志记录与错误监控清晰的日志是调试和监控爬虫健康状况的眼睛。我们之前已经使用了Python内置的logging模块。建议将日志同时输出到控制台和文件并区分不同的日志级别INFO, WARNING, ERROR。def setup_logging(): logger logging.getLogger(wallheaven_crawler) logger.setLevel(logging.INFO) # 控制台处理器 console_handler logging.StreamHandler() console_format logging.Formatter(%(asctime)s - %(name)s - %(levelname)s - %(message)s) console_handler.setFormatter(console_format) logger.addHandler(console_handler) # 文件处理器按日期滚动 from logging.handlers import TimedRotatingFileHandler file_handler TimedRotatingFileHandler( filenamelogs/crawler.log, whenmidnight, interval1, backupCount7, encodingutf-8 ) file_format logging.Formatter(%(asctime)s - %(levelname)s - %(message)s) file_handler.setFormatter(file_format) logger.addHandler(file_handler) return logger这样你可以在logs/目录下看到按天分割的日志文件方便追溯问题。6. 进阶玩法从脚本到工具基础功能实现后我们可以考虑如何让它变得更强大、更好用。6.1 多关键词与条件筛选批量爬取我们可以将配置外部化通过一个配置文件或命令行参数来指定多个搜索关键词、分辨率筛选、分类和纯度设置实现批量自动化任务。# config.yaml (示例) tasks: - keyword: cyberpunk 2077 categories: 110 # general anime purity: 100 # SFW only sorting: favorites max_pages: 3 min_resolution: 1920x1080 - keyword: landscape categories: 111 purity: 100 sorting: toplist max_pages: 5主程序读取这个配置文件依次为每个任务执行爬取流程并根据min_resolution等条件在下载前进行过滤。6.2 分辨率筛选与图片去重有时我们只想要特定分辨率如4K的壁纸。可以在fetch_wallpaper_detail函数中提取分辨率信息格式如1920x1080然后在下载前进行判断。更复杂一点的需求是内容去重。Wallheaven上不同ID的壁纸可能是同一张图的不同版本或重新上传。一个简单的内容去重方法是计算下载图片的MD5或感知哈希pHash将哈希值存入数据库。每次下载新图片前计算其哈希如果已存在则视为重复可以跳过或记录关联关系。这需要引入图像处理库如PIL/Pillow和哈希计算库如imagehash。6.3 定时任务与自动化更新如果你希望壁纸库能自动更新可以将爬虫脚本部署到服务器并使用系统的定时任务工具如Linux的cronWindows的任务计划程序来定期执行。例如在Linux上可以编辑crontab# 每天凌晨3点运行爬虫并将日志输出到指定文件 0 3 * * * /usr/bin/python3 /path/to/your/wallheaven_crawler/main.py /path/to/logs/cron.log 21在脚本内部可以设计为只爬取“最新”sorting: date_added的几页内容这样每次运行都能获取到网站新增的符合你口味的壁纸。6.4 构建简单的图形界面或Web服务如果你想让不懂命令行的朋友也能使用可以考虑用tkinter、PyQt做一个简单的桌面GUI或者用Flask、FastAPI搭建一个轻量的Web服务。界面可以提供输入关键词、选择分类、设置保存路径、开始/停止爬取等按钮并将日志实时显示在界面上。这会将你的脚本从一个开发者工具变成一个真正的产品。7. 法律、道德与最佳实践在享受技术带来的便利时我们必须清醒地认识到边界在哪里。尊重robots.txt访问https://wallheaven.cc/robots.txt查看网站是否允许爬虫访问/search等路径。即使没有明确禁止也应遵守其指引。遵守服务条款仔细阅读Wallheaven的服务条款明确其对自动化数据抓取的态度。你的使用行为不应违反这些条款。控制访问频率这是最重要的道德准则。我们的代码中已经加入了随机延迟务必确保你的爬虫不会对Wallheaven的服务器造成显著负担。不要开启过高并发避免在短时间内发起海量请求。合理使用数据爬取的数据应用于个人欣赏和学习目的。不要将大量壁纸重新打包分发或用于任何商业用途这很可能侵犯上传者的版权。标识你的爬虫在请求头中可以考虑设置一个自定义的From或X-Requested-By头包含你的联系邮箱例如X-Requested-By: my-wallpaper-collector (contactexample.com)。这是一种负责任的体现如果网站管理员认为你的爬虫行为有问题他们可以联系你而不是直接封禁IP。技术是一把双刃剑。一个编写良好、行为克制的爬虫可以成为我们获取美好事物的助手而一个贪婪、粗暴的爬虫则可能损害网站生态甚至触犯法律。希望我们在动手实现功能的同时也能时刻牢记这份责任。