前言

最近逛到一个图片站 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
JKhttps://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
JK371 张56.8 MB
合计1894 张583.6 MB

其中黑丝池子最大(接近 1000 张),白丝和 JK 都是因为连续 80 次重复触发了停止条件,可以确认池子已耗尽。


七、总结

这次爬取的几个要点:

  1. 先看 DOM,再读 JS:现代网站的数据源几乎都能在前端代码里找到线索,data-* 属性是最常见的暴露点。
  2. 识别 API 类型:是分页接口还是随机接口,决定了爬取策略。随机接口要用"去重 + 连续重复停止"的方式。
  3. 善用文件名去重:比 hash 快得多,对随机图片 API 来说足够可靠。
  4. 加入礼貌延迟time.sleep(0.05) 看似微不足道,但能避免给对方服务器造成压力,也能降低被封 IP 的风险。
  5. 断点续传很重要:大池子爬一半断了,没续传功能会想哭。

希望这篇小白文对你有帮助。完整脚本已放在文末,需要自取。


免责声明:本文仅作技术学习交流,爬取前请确认目标网站的 robots.txt 和相关条款,尊重版权,合理使用。