前几天遇到一个纯静态图库网站 https://ximi-img.hhqq.net/,想把左侧栏"本地图库"里的图片批量下载下来。研究了一下它的实现机制,发现这类网站的数据完全暴露在前端,提取起来非常方便。本文记录一下完整的分析过程和提取方案。
一、目标分析
1.1 网站概况
目标网站是一个基于纯静态前端的图库系统,名为 Ximi Gallery。界面包含左侧树形菜单和右侧图片展示区,支持平铺模式和瀑布流模式浏览,还带灯箱预览功能。
整体技术栈很简单:
- HTML + Tailwind CSS 构建界面
- 原生 JavaScript 处理交互逻辑
- Masonry.js 实现瀑布流布局
- ArtPlayer 处理视频播放
1.2 左侧栏结构
左侧栏的"本地图库"采用三级分类结构:
本地图库
├── 美图
│ ├── XXXX
│ ├── XXXX
│ ├── XXXX
│ ├── XXXX
│ ├── XXXX
│ ├── XXXX
│ └── XXXX
└── 古风
├── XXXXX
├── XXXX
├── XXXX
└── XXX图片数量不少。如果手动一张张右键保存,得累死。所以得研究一下它的图片是怎么加载的。
2026年啦,这是就需要我们著名的yes工程师上场啦。有请豆包同学来分析结构
二、网站机制剖析
2.1 资源加载策略
打开浏览器开发者工具,看 Network 面板,发现网站加载了这么几个关键文件:
| 文件 | 作用 |
|---|---|
index.html | 页面骨架 |
setting.js | 本地图库的图片路径配置 |
api.js | API调用的视频/图片URL配置 |
assets/js/man.js | 核心渲染逻辑 |
网站在 <head> 里有一个资源加载器,采用 本地优先 + CDN兜底 的策略:
const cssResources = [
{ local: './assets/css/tailwind.min.css',
remote: 'https://www.ximi.me/usr/demo/ximi-img/assets/css/tailwind.min.css' }
];本地加载失败就自动切换到远程 CDN,保证资源可用。这个设计挺聪明的,但对我们提取数据没什么影响。
2.2 配置文件结构
重点来了。打开 setting.js,所有图片的路径都明明白白写在里面:
window._galleryConfigs = window._galleryConfigs || [];
window._galleryConfigs.push({
"本地图库": {
"美图": {
"年年 - 琳妮特": {
"_images": [
"本地图库/美图/年年 - 琳妮特/1.jpg",
"本地图库/美图/年年 - 琳妮特/2.jpg",
"本地图库/美图/年年 - 琳妮特/3.jpg",
// ... 一直到 37.jpg
]
},
"年年 风": {
"_images": [
"本地图库/美图/年年 风/1.jpg",
// ...
]
}
}
}
});关键发现:这是一个嵌套对象结构,最内层的 _images 数组里存着所有图片的相对路径。路径格式是 分类/子分类/相册名/文件名.jpg。
2.3 路径编码逻辑
光有相对路径还不够,还得知道前端怎么把它变成完整URL的。翻开 man.js,找到了核心逻辑:
let encodedPath;
if (media.toLowerCase().startsWith('http')) {
// 网络链接直接用
encodedPath = media;
} else {
// 本地路径分段编码
encodedPath = media.replace(/\\/g, '/')
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
}为什么要分段编码?
因为路径里有中文(本地图库、美图、年年 - 琳妮特),还有空格和特殊字符。如果整段编码,/ 会被编码成 %2F,路径就废了。分段编码只编码每一段,保留 / 分隔符:
原始: 本地图库/美图/年年 - 琳妮特/1.jpg
编码: %E6%9C%AC%E5%9C%B0%E5%9B%BE%E5%BA%93/%E7%BE%8E%E5%9B%BE/%E5%B9%B4%E5%B9%B4%20-%20%E7%90%B3%E5%A6%AE%E7%89%B9/1.jpg完整URL就是:https://ximi-img.hhqq.net/ + 编码后的路径
2.4 渲染与懒加载
顺便看了一下 renderGallery 函数,网站的渲染逻辑也挺有意思:
function renderGallery(title, mediaArray) {
// 1. 分离图片和视频
const images = [], videos = [];
mediaArray.forEach(media => {
const ext = media.split('.').pop().toLowerCase();
if (videoExts.includes(ext)) videos.push(media);
else images.push(media);
});
// 2. 分块渲染,每30张一组
window.currentMediaChunks = [];
for (let i = 0; i < allMedia.length; i += 30) {
window.currentMediaChunks.push(allMedia.slice(i, i + 30));
}
// 3. 渲染第一屏 + 滚动监听加载下一屏
renderNextChunk();
}每30张图片分一块,滚动到底部自动加载下一批。这是典型的瀑布流懒加载模式,对用户体验很好,但也意味着图片URL都是可预测的。
三、提取脚本实现
分析清楚了,接下来写脚本。思路很简单:获取配置文件 → 解析路径 → 拼接URL → 下载。
3.1 完整代码
import requests
import re
import os
from urllib.parse import quote
BASE_URL = "https://ximi-img.hhqq.net/"
SETTING_URL = BASE_URL + "setting.js"
DOWNLOAD_DIR = "ximi_images"
def fetch_setting_js():
"""获取配置文件"""
print(f"正在获取配置文件: {SETTING_URL}")
try:
response = requests.get(SETTING_URL, timeout=30)
response.raise_for_status()
return response.text
except Exception as e:
print(f"获取配置文件失败: {e}")
return None
def parse_image_paths(js_content):
"""正则解析所有图片路径"""
print("正在解析图片路径...")
# 第一级:匹配 "_images": [...] 中的内容
pattern = r'"_images":\s*\[([^\]]+)\]'
matches = re.findall(pattern, js_content, re.DOTALL)
all_images = []
for match in matches:
# 第二级:匹配图片文件名
image_pattern = r'"([^"]+\.(?:jpg|jpeg|png|gif|webp))"'
images = re.findall(image_pattern, match)
all_images.extend(images)
print(f"共找到 {len(all_images)} 张图片")
return all_images
def build_full_urls(image_paths):
"""拼接完整URL,模拟前端的分段编码逻辑"""
urls = []
for path in image_paths:
encoded_path = path.replace("\\", "/").split("/")
encoded_path = "/".join(quote(segment) for segment in encoded_path)
full_url = BASE_URL + encoded_path
urls.append((full_url, path))
return urls
def download_image(url, save_path):
"""流式下载单张图片"""
try:
response = requests.get(url, timeout=30, stream=True)
response.raise_for_status()
os.makedirs(os.path.dirname(save_path), exist_ok=True)
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
return True
except Exception as e:
print(f"下载失败 {url}: {e}")
return False
def main():
# 1. 获取配置文件
js_content = fetch_setting_js()
if not js_content:
return
# 2. 解析图片路径
image_paths = parse_image_paths(js_content)
if not image_paths:
print("未找到图片路径")
return
# 3. 构建完整URL
url_pairs = build_full_urls(image_paths)
# 4. 批量下载
print(f"\n开始下载 {len(url_pairs)} 张图片...")
success_count = 0
for i, (url, path) in enumerate(url_pairs, 1):
save_path = os.path.join(DOWNLOAD_DIR, path)
print(f"[{i}/{len(url_pairs)}] 正在下载: {path}")
if download_image(url, save_path):
success_count += 1
print(f"\n下载完成!成功: {success_count}, 失败: {len(url_pairs) - success_count}")
print(f"图片保存到: {os.path.abspath(DOWNLOAD_DIR)}")
if __name__ == "__main__":
main()3.2 代码逐段解析
第一步:获取配置文件
response = requests.get(SETTING_URL, timeout=30)直接请求 https://ximi-img.hhqq.net/setting.js,拿到原始JavaScript文本。因为这个文件是静态资源,无需任何鉴权。
第二步:正则解析路径
这里用了两级正则匹配:
# 第一级:匹配 "_images": [...] 数组的内容
pattern = r'"_images":\s*\[([^\]]+)\]'
matches = re.findall(pattern, js_content, re.DOTALL)"_images":\s*\[匹配键名和左括号([^\]]+)捕获数组内的所有内容(非贪婪,直到右括号)re.DOTALL让.能匹配换行符
# 第二级:匹配图片文件名
image_pattern = r'"([^"]+\.(?:jpg|jpeg|png|gif|webp))"'
images = re.findall(image_pattern, match)只匹配以图片扩展名结尾的字符串,过滤掉其他内容。
第三步:构建完整URL
这一步必须复刻前端的编码逻辑,否则中文路径会404:
encoded_path = path.replace("\\", "/").split("/")
encoded_path = "/".join(quote(segment) for segment in encoded_path)urllib.parse.quote() 对每个路径段单独编码,保留 / 分隔符,和 man.js 里的 encodeURIComponent 逻辑完全一致。
第四步:流式下载
response = requests.get(url, timeout=30, stream=True)
with open(save_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)几个关键点:
stream=True流式下载,避免大图片一次性载入内存os.makedirs(..., exist_ok=True)自动创建多层目录,保持原始分类结构iter_content(chunk_size=8192)分块写入,8KB一块
四、运行结果
4.1 第一次运行(本地图库)
正在获取配置文件: https://ximi-img.hhqq.net/setting.js
正在解析图片路径...
本地图库共找到 306 张图片
开始下载 306 张图片...
[1/306] 本地图库/美图/年年 - 琳妮特/1.jpg
[2/306] 本地图库/美图/年年 - 琳妮特/2.jpg
...
[306/306] 本地图库/古风/捻发作须/1779037001_6a09f3491aa1b.jpg
下载完成!成功: 306, 失败: 0306张图片全部下载成功,按原始目录结构保存在本地。
4.2 第二次运行(API调用图片)
除了本地图库,我发现网站还有一个 api.js 配置文件,里面存储了"Api调用"分类的图片URL。结构类似:
window.gallerySettings.push({
"Api调用": {
"图片": {
"_images": [
"https://img.alicdn.com/imgextra/i2/1738635228/O1CN016QBWcl1oUTkc637Yd_!!1738635228.jpg",
// ...1122张
]
},
"壁纸": {
"_images": [
"https://img.alicdn.com/imgextra/i4/1738635228/O1CN01NM6Acs1oUTkbX80Fh_!!1738635228.jpg",
// ...251张
]
}
}
});与 setting.js 不同的是,api.js 里的图片URL都是完整的https链接(指向阿里云CDN),不需要再做路径编码。
修改脚本,增加对 api.js 的解析和下载:
def download_api_gallery():
"""下载api.js中的API调用图片"""
js_content = fetch_js_content(API_URL)
image_paths = parse_image_paths(js_content)
# api.js里的URL是完整链接,直接下载即可
for i, url in enumerate(image_paths, 1):
filename = get_filename_from_url(url, i) # 从URL提取文件名
save_path = os.path.join(DOWNLOAD_DIR, "Api调用", filename)
if download_image(url, save_path, allow_redirects=True):
success_count += 1运行结果:
============================================================
【步骤 1】下载本地图库 (setting.js)
============================================================
本地图库共找到 306 张图片
[1/306] 本地图库/美图/年年 - 琳妮特/1.jpg
...
[306/306] 本地图库/古风/捻发作须/1779037001_6a09f3491aa1b.jpg
============================================================
【步骤 2】下载API调用图片 (api.js)
============================================================
API调用共找到 1373 个图片URL
[1/1373] O1CN016QBWcl1oUTkc637Yd_!!1738635228.jpg
...
[1373/1373] O1CN01l5w6nU1oUTiFPRMpS_!!1738635228.jpg
============================================================
下载完成汇总
============================================================
本地图库: 成功 306 张, 失败 0 张
API调用: 成功 1373 张, 失败 0 张
总计: 成功 1679 张, 失败 0 张4.3 最终目录结构
ximi_images/
├── 本地图库/ # 来自 setting.js
│ ├── 美图/
│ │ ├── 年年 - 琳妮特/
│ │ │ ├── 1.jpg
│ │ │ └── ... (37张)
│ │ ├── 年年 风/
│ │ │ └── ... (41张)
│ │ ├── 桜桃喵 - 夜纱/
│ │ │ └── ... (44张)
│ │ └── ...
│ └── 古风/
│ ├── 金风玉露/
│ ├── 丐帮女侠/
│ ├── 湖畔闲思/
│ └── 捻发作须/
└── Api调用/ # 来自 api.js
├── O1CN016QBWcl1oUTkc637Yd_!!1738635228.jpg
├── O1CN01w1l5nt1oUTkc6RCkd_!!1738635228.jpg
└── ... (1373张)总共下载了 1679 张图片:
- 本地图库: 306张(来自 setting.js)
- API调用: 1373张(来自 api.js,包含图片和壁纸两个子分类)
五、踩坑记录
实现过程中踩了几个坑,记录一下:
坑1:中文路径编码
一开始直接拼接URL,结果中文路径全404。后来发现必须模拟前端的分段编码逻辑,对每个路径段单独 encodeURIComponent,保留 / 分隔符。
坑2:正则匹配多行
_images 数组跨多行,默认的正则 . 不匹配换行符。加上 re.DOTALL 标志后才正常工作。
坑3:目录创建
图片按 本地图库/美图/相册名/ 多层目录保存,必须用 os.makedirs(..., exist_ok=True) 递归创建目录,普通 os.mkdir 会报错。
坑4:DNS解析间歇性失败
下载 API 调用图片时,alicdn.com 偶尔出现 getaddrinfo failed 的DNS解析错误。解决办法是在脚本里加入文件存在检查,失败的文件下次运行会自动重试:
def download_image(url, save_path, allow_redirects=True):
# 如果文件已存在且非空,跳过下载
if os.path.exists(save_path) and os.path.getsize(save_path) > 0:
return True # 视为成功(已存在)
# ... 否则正常下载这样第一次运行失败的图片,第二次运行会自动重试,最终全部下载成功。
坑5:PowerShell输出缓冲
Python在PowerShell中运行时,print() 输出会被缓冲,导致看不到实时进度。解决办法:
- 使用
print(msg, flush=True)强制刷新 - 或者运行时加
-u参数:python -u script.py
六、原理总结
6.1 这类网站为什么容易被提取
这类纯静态图库网站有个致命弱点:所有数据都暴露在前端。
- 配置文件公开可读:
setting.js和api.js都是静态资源,任何人都能直接访问,无需登录鉴权。 - 路径明文存储:图片路径没有任何加密或混淆,直接写在JavaScript对象里。
- 编码逻辑可复刻:前端怎么编码URL,我们就怎么编码,100%还原。
- 多数据源暴露:本地图库(setting.js)和API调用(api.js)的图片URL都暴露在前端,一次就能全部提取。
6.2 提取通用思路
针对这类网站,提取流程可以归纳为:
分析网络请求 → 定位数据源 → 解析数据结构 → 复刻编码逻辑 → 批量请求下载具体到本案例:
| 步骤 | 操作 | 工具 |
|---|---|---|
| 1. 抓包分析 | 查看Network面板,找到配置文件 | 浏览器DevTools |
| 2. 定位数据源 | 发现 setting.js 和 api.js 存储所有路径 | 阅读源码 |
| 3. 理解编码逻辑 | man.js 里分段URI编码(仅本地路径需要) | 阅读源码 |
| 4. 编写解析脚本 | 正则匹配 + URL拼接 | Python |
| 5. 批量下载 | requests流式下载,支持重试 | Python |
6.3 两种数据源对比
| 特性 | setting.js (本地图库) | api.js (API调用) |
|---|---|---|
| 路径格式 | 相对路径 本地图库/美图/.../1.jpg | 完整URL https://img.alicdn.com/... |
| 需要编码 | 是,分段URI编码 | 否,直接使用 |
| 图片来源 | 网站自身服务器 | 阿里云CDN |
| 图片数量 | 306张 | 1373张 |
| 下载难度 | 需复刻前端编码逻辑 | 直接请求即可 |
七、扩展思考
7.1 如何防御
如果你是站点所有者,不想被这样批量提取,可以考虑:
- 接口鉴权:把配置文件改成需要鉴权的API接口,加Token验证。
- 路径混淆:图片路径不直接明文存储,改用动态生成的临时签名URL。
- 防盗链:服务器配置Referer校验,拒绝外部直接访问图片资源。
- 频率限制:对同一IP的请求频率做限制,防止爬虫。
7.2 法律边界
本文仅以技术学习为目的,记录静态网站的数据结构分析方法。实际操作时请注意:
- 尊重网站版权声明
- 遵守 robots.txt 协议
- 不对服务器造成过大压力
- 仅用于个人学习,不商用传播
结语
这个案例很好地说明了"前端无秘密"的道理。只要数据下发到客户端,就一定可以被提取。对于纯静态站点,所有逻辑和数据都暴露在浏览器里,分析清楚前端的处理逻辑,提取数据就是水到渠成的事。
本次实战共提取 1679张图片:
- 本地图库(setting.js):306张,需要复刻前端的分段URI编码逻辑
- API调用(api.js):1373张,直接是完整URL,下载更简单
脚本已开源,核心不到200行代码。希望这篇实战记录对大家理解前端数据流转和静态站点爬取有所帮助。
本文以技术分析为目的,示例网站为公开的演示站点。请在合法合规的前提下使用相关技术。
暂无评论