前言
最近逛到一个图片站 xiuno.store/BeautyGallery.htm,页面做得很简洁——三个栏目(黑丝、白丝、JK),竖向瀑布流布局,滚动到底部就自动加载更多图片。
乍一看似乎无从下手"全部保存",但研究了一下它的加载机制后,发现其实很简单。这篇文章就分享一下整个分析过程和实现原理,适合爬虫新手参考。

一、先观察:页面是怎么展示图片的
打开网页,第一眼看到的是三个分类标签和一排图片。乍看之下,图片好像"无穷无尽"——你怎么滚都还有。
这种"滚动加载"的模式在现代网站非常常见,术语叫 瀑布流(Waterfall / Masonry Layout)。它的特点是:
- 图片按高度自适应排列,不强制裁剪
- 不分页,而是滚动到接近底部时自动请求新图
- 用户感觉"永远刷不完"
光看 UI 是猜不出数据来源的,得去翻代码。
二、拆解:找到图片的"源头"
2.1 用浏览器开发者工具看 DOM
按下 F12 打开开发者工具,用元素选择器点中任意一张图片,可以看到这样的结构:
<div class="mobile-cats">
<div class="mobile-grid">
<div class="mobile-cat active"
data-api="https://v2.xxapi.cn/api/heisi?return=302"
data-name="黑丝">黑丝</div>
<div class="mobile-cat"
data-api="https://v2.xxapi.cn/api/baisi?return=302"
data-name="白丝">白丝</div>
<div class="mobile-cat"
data-api="https://v2.xxapi.cn/api/jk?return=302"
data-name="JK">JK</div>
</div>
</div>关键发现:每个分类标签上都有一个 data-api 属性,直接暴露了图片接口地址!
| 分类 | API 地址 |
|---|---|
| 黑丝 | https://v2.xxapi.cn/api/heisi?return=302 |
| 白丝 | https://v2.xxapi.cn/api/baisi?return=302 |
| JK | https://v2.xxapi.cn/api/jk?return=302 |
这就是图片的"源头"。
2.2 看瀑布流的 JavaScript 逻辑
继续翻 <script> 标签,找到了核心逻辑(简化版):
const grid = document.getElementById('grid');
let currentApi = '';
let loadingLock = false;
// 点击分类切换
document.querySelectorAll('.mobile-cat').forEach(el => {
el.onclick = () => {
currentApi = el.dataset.api;
grid.innerHTML = '';
loadFirst();
};
});
// 首次加载 8 张
async function loadFirst() {
for (let i = 0; i < 8; i++) renderItem(getUrl());
}
// 滚动加载更多,每次 4 张
async function loadMore() {
for (let i = 0; i < 4; i++) renderItem(getUrl());
}
// 关键:每次拼一个随机参数
function getUrl() {
return currentApi +
(currentApi.includes('?') ? '&' : '?') +
'v=' + Math.random().toString(36).slice(2, 10);
}
// 渲染一张图
function renderItem(url) {
const card = document.createElement('div');
card.className = 'wall-card';
const img = new Image();
img.src = url;
card.appendChild(img);
grid.appendChild(card);
}
// 监听滚动
scrollWrap.addEventListener('scroll', () => {
if (scrollWrap.scrollTop + scrollWrap.clientHeight + 400
>= scrollWrap.scrollHeight) {
loadMore();
}
});读到这里,整个机制就清晰了:
三、原理:随机图片 API 是怎么工作的
这个网站用的不是传统的"图片列表分页接口",而是 随机图片 API。
3.1 一次请求的完整流程
浏览器 → GET https://v2.xxapi.cn/api/heisi?return=302&v=abc12345
服务器 → 302 重定向到某张实际图片
浏览器 → GET https://images.xxapi.cn/images/heisi/image_38_34604b7b.jpg
服务器 → 返回图片二进制数据注意 URL 后面的 v=abc12345——这是一个随机字符串。它的作用不是参数过滤,而是欺骗缓存,让浏览器以为这是一个新请求,从而绕过浏览器缓存,每次都拿到不同的随机图。
3.2 为什么"永远刷不完"
因为 API 每次都从图片池里随机抽一张给你,所以你滚一辈子也滚不完。即使池子里只有 1000 张图,你刷 2000 次也至少有一半是重复的。
这就给了我们一个重要的启示:如果想下载"所有"图片,只要持续请求,直到连续多次都拿到重复图,就可以认为池子已经被掏空了。
四、实现:批量下载脚本
4.1 核心思路
1. 请求 API → 跟随 302 重定向 → 拿到真实图片地址
2. 从地址中提取文件名,判断是否已下载过
3. 如果是新图 → 下载保存
4. 如果是重复 → 累加计数器
5. 连续重复 N 次 → 认为池子耗尽,停止4.2 Python 实现
完整脚本如下(Python 3):
import os
import time
import random
import string
import urllib.request
BASE_OUTPUT_DIR = r"你的项目路径"
CATEGORIES = [
("heisi", "https://v2.xxapi.cn/api/heisi?return=302"),
("baisi", "https://v2.xxapi.cn/api/baisi?return=302"),
("jk", "https://v2.xxapi.cn/api/jk?return=302"),
]
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")
MAX_CONSECUTIVE_DUPES = 80 # 连续 80 次重复就停
MAX_TOTAL_ATTEMPTS = 3000 # 单类最多尝试 3000 次
MIN_FILE_SIZE = 2000 # 小于 2KB 的当垃圾丢掉
def random_str(n=8):
"""生成随机字符串,模拟前端那个 v 参数"""
return ''.join(random.choices(
string.ascii_lowercase + string.digits, k=n))
def download_and_get_filename(api_url_with_param):
"""
请求 API,urllib 会自动跟随 302 重定向,
最终 resp.url 就是真实图片地址,
resp.read() 就是图片二进制内容。
"""
try:
req = urllib.request.Request(
api_url_with_param,
headers={'User-Agent': USER_AGENT}
)
with urllib.request.urlopen(req, timeout=30) as resp:
final_url = resp.url
data = resp.read()
filename = os.path.basename(final_url.split('?')[0])
return filename, data
except Exception:
return None, None
def download_category(name, api_url):
cat_dir = os.path.join(BASE_OUTPUT_DIR, name)
os.makedirs(cat_dir, exist_ok=True)
# 启动时先扫描已有文件,支持断点续传
downloaded = set()
for f in os.listdir(cat_dir):
fp = os.path.join(cat_dir, f)
if os.path.isfile(fp) and os.path.getsize(fp) > MIN_FILE_SIZE:
downloaded.add(f)
success_count = len(downloaded)
attempt = 0
consecutive_dupes = 0
print(f"\n=== [{name}] 已有 {success_count} 张,"
f"连续重复 {MAX_CONSECUTIVE_DUPES} 次后停止 ===")
while (consecutive_dupes < MAX_CONSECUTIVE_DUPES
and attempt < MAX_TOTAL_ATTEMPTS):
attempt += 1
request_url = f"{api_url}&v={random_str(10)}"
filename, data = download_and_get_filename(request_url)
if not filename or not data:
time.sleep(0.3)
continue
# 用文件名做去重判定
if filename in downloaded:
consecutive_dupes += 1
continue
consecutive_dupes = 0 # 一旦遇到新图,计数器归零
if len(data) < MIN_FILE_SIZE:
continue
file_path = os.path.join(cat_dir, filename)
with open(file_path, 'wb') as f:
f.write(data)
downloaded.add(filename)
success_count += 1
print(f" 第 {attempt} 次: 第 {success_count} 张 - "
f"{filename} ({len(data)/1024:.1f} KB)")
time.sleep(0.05) # 礼貌性延迟,别给服务器太大压力
print(f"\n=== [{name}] 完成: 共 {success_count} 张 ===")
return success_count
def main():
os.makedirs(BASE_OUTPUT_DIR, exist_ok=True)
total = 0
for name, api_url in CATEGORIES:
total += download_category(name, api_url)
print(f"\n全部完成!共下载 {total} 张")
if __name__ == "__main__":
main()五、关键技巧解析
5.1 为什么不直接请求图片地址
因为图片池有多大、每张图叫什么名字,这些都是未知的。我们只有一个"抽签机"——每次抽一张。
所以只能通过反复抽签 + 去重的方式逼近全集。这就像盲盒里摸球:你不知道有多少种颜色,但只要连续摸很久都没摸到新颜色,就可以认为摸全了。
5.2 用文件名做去重的依据
每张图的真实地址长这样:
https://images.xxapi.cn/images/heisi/image_38_34604b7b.jpg
https://images.xxapi.cn/images/heisi/ABcdEFghIJKlMNop1234.jpg文件名是唯一的,所以直接用 os.path.basename() 提取文件名,扔进一个 set() 里就能去重。
5.3 连续重复 N 次才停止
为什么不"遇到一次重复就停"?因为随机性,你可能在第 10 次就抽到第 1 次抽过的图,但这不代表池子小。
更稳妥的策略是:连续 N 次都没抽到新图,才认为池子已经掏空。N 越大越保险,我设的是 80。
5.4 自动跟随 302 重定向
Python 的 urllib.request.urlopen() 默认会跟随 HTTP 重定向,所以一行代码就完成了"请求 API → 拿到真实图"的全过程。如果用 PowerShell,就需要手动处理 Headers.Location,麻烦得多。
5.5 断点续传
脚本启动时会先扫描目标文件夹已有的图片,加到 downloaded 集合里。这样即使中途断了再运行,也不会重新下载已完成的图。
六、最终成果
跑了一段时间后,三个分类全部下载完成:
| 分类 | 图片数 | 占用空间 |
|---|---|---|
| 黑丝 | 975 张 | 147.5 MB |
| 白丝 | 548 张 | 379.3 MB |
| JK | 371 张 | 56.8 MB |
| 合计 | 1894 张 | 583.6 MB |
其中黑丝池子最大(接近 1000 张),白丝和 JK 都是因为连续 80 次重复触发了停止条件,可以确认池子已耗尽。
七、总结
这次爬取的几个要点:
- 先看 DOM,再读 JS:现代网站的数据源几乎都能在前端代码里找到线索,
data-*属性是最常见的暴露点。 - 识别 API 类型:是分页接口还是随机接口,决定了爬取策略。随机接口要用"去重 + 连续重复停止"的方式。
- 善用文件名去重:比 hash 快得多,对随机图片 API 来说足够可靠。
- 加入礼貌延迟:
time.sleep(0.05)看似微不足道,但能避免给对方服务器造成压力,也能降低被封 IP 的风险。 - 断点续传很重要:大池子爬一半断了,没续传功能会想哭。
希望这篇小白文对你有帮助。完整脚本已放在文末,需要自取。
免责声明:本文仅作技术学习交流,爬取前请确认目标网站的 robots.txt 和相关条款,尊重版权,合理使用。
暂无评论