WordPress 本地随机图片接口、Danbooru 同步与 Cloudflare 限流

博客首页原本直接使用外部随机图片接口作为 Banner 图片源。这种方式配置简单,但实际用下来有几个问题:图片质量和横竖比例不可控,第三方接口的图库规模和内容筛选也不完全符合需求;同一次页面加载的多个 Banner 还可能随机到同一张图。后续又希望点击 Banner 后能看到作者、角色、原始来源等信息,这时单纯依赖一个外部随机图片 URL 已经不够用了。

最后采用的结构是:服务器定期从 Danbooru 获取符合条件的明日方舟二创图片,转换成 WebP 保存在本地;WordPress 只通过一个通用的 random.php 从本地图库随机取图;detail.php 根据当前 Banner 对应关系读取 metadata.json 展示图片信息;Cloudflare 再对随机接口做速率限制,避免 PHP 被高频请求。

图片来源和本地图库

一开始测试过几个现成来源。外部随机图接口虽然支持 landscape 一类参数,但实际仍会出现竖图或主体位置不适合 Banner 的情况。Lolicon API 的图片质量不错,也能拿到比较完整的作品信息,不过即使设置 r18=0,仍然会出现不少不适合直接放在公开博客首页的擦边内容。这里容易把“非 R18”和“适合公开首页”混为一谈,前者只能排除明确的成人内容,并不能代替自己的内容筛选。

也尝试过从 PRTS、明日方舟官方 Bilibili 动态以及 GitHub 上的游戏资源仓库获取图片。实际分别遇到了接口 403、412,或者资源本身属于游戏拆包素材而不是需要的宣传图、二创图。最后确定只维护二创图库,数据源改成 Danbooru。

服务器访问 Danbooru 通过现有 HTTP 代理:

PROXY = "http://192.168.10.1:7893"

PROXIES = {
    "http": PROXY,
    "https": PROXY,
}

基础检索条件是:

SEARCH_TAGS = "arknights rating:g"

rating:g 只取 Danbooru 的 General 分级,但脚本仍然增加自己的过滤条件。当前主要限制包括最小宽度、横图比例、AI 标签和一组不适合首页展示的标签:

MIN_WIDTH = 1200
MIN_RATIO = 1.4

BLOCKED_TAGS = {
    "underwear",
    "panties",
    "bra",
    "lingerie",
    "ass",
    "cleavage",
    "bikini",
    "nude",
    "topless",
    "bottomless",
    "nipples",
    "upskirt",
    ...
}

同时排除:

ai-generated
ai-assisted

这里没有把首页三个 Banner 的实际比例硬编码进同步脚本。桌面端和窄屏下三个 Banner 的比例本身就会变化,主题还会使用类似 cover 的方式处理图片。如果分别为栏目 1、2、3 建三套固定比例规则,会把一个通用随机图库接口变成只服务当前主题布局的专用接口。因此同步端只保证图片足够宽、整体偏横向,最终裁切仍交给前端主题。

本地目录最终保持为:

/opt/wordpress/images/
└── arknights/
    ├── danbooru_<ID>.webp
    └── metadata.json

/opt/wordpress/random/
├── random.php
└── detail.php

/opt/wordpress/scripts/
├── sync_arknights_fanart.py
└── arknights_fanart_state.json

WordPress 容器中把图库和随机接口目录单独只读挂载到 Web 根目录:

volumes:
  - ./data/html:/var/www/html
  - /opt/wordpress/images:/var/www/html/images:ro
  - /opt/wordpress/random:/var/www/html/random:ro

这种情况下宿主机 data/html 中能看到 images、random 对应的挂载点是正常现象,并不是又复制了一套文件。

随机接口、Banner 去重和详情页

随机接口最初只需要完成一件事:扫描指定图库,随机选择一张图片,然后通过 302 跳转到真正的静态文件。

访问方式类似:

https://<域名>/random/random.php?type=arknights

PHP 返回:

302 Location: /images/arknights/danbooru_<ID>.webp

这样随机逻辑由 PHP 执行,而真正的图片仍然是普通静态资源。随机接口设置 no-store,避免随机结果被缓存成固定图片;静态 WebP 则可以继续由 Cloudflare 正常缓存。

首页需要同时放三张 Banner 后出现了另一个问题:

slot=1
slot=2
slot=3

虽然三个 URL 不同,但如果 PHP 每次仍然独立随机,同一次页面加载完全可能抽中同一张图片。slot 因此不能只是一个用于区分 URL 的无效参数,而要参与当前这一轮随机状态。

最终接口允许任意正整数 slot,并不限制为 1、2、3:

/random/random.php?type=arknights&slot=1
/random/random.php?type=arknights&slot=2
/random/random.php?type=arknights&slot=3

服务器根据访问者的 IP、User-Agent 和图库类型生成当前客户端的状态键:

$clientKey = hash(
    'sha256',
    $clientIp . '|' . $userAgent . '|' . $type
);

状态保存在容器内:

/tmp/random-gallery/

其中记录:

{
  "slots": {
    "1": "danbooru_<ID1>.webp",
    "2": "danbooru_<ID2>.webp",
    "3": "danbooru_<ID3>.webp"
  },
  "used": [
    "danbooru_<ID1>.webp",
    "danbooru_<ID2>.webp",
    "danbooru_<ID3>.webp"
  ]
}

有 slot 时,从候选集合中排除当前轮已经使用的文件;没有 slot 时仍然保持普通随机图接口的行为。这样 slot 只是随机接口的一个通用去重能力,没有把 PHP 写死成“三栏 Banner 接口”。

图片点击链接使用对应的详情页:

/random/detail.php?type=arknights&slot=1

这里曾经出现过:

Image information unavailable. Please return to the homepage and refresh.

原因不是 metadata 丢了,而是接口状态机制改过以后,random.php 已经把 slot → 图片文件名 写进 /tmp/random-gallery,旧版 detail.php 还在读取 Cookie。随机接口和详情页实际上使用了两套状态来源,所以 Banner 能显示,点击后却找不到对应图片。

修正后 detail.php 使用与 random.php 完全相同的客户端键和 slot,先从 /tmp/random-gallery 找到文件名,再读取:

/var/www/html/images/arknights/metadata.json

详情页展示作者标签、角色、尺寸、原始来源和 Danbooru 页面。

这里还发现一个来源字段的问题。Danbooru 的 source 有时保存的是:

https://i.pximg.net/img-original/.../<作品ID>_p0.png

这个地址是 Pixiv 图片 CDN,不是作品页面。直接让访客点击很容易得到 403,也不适合作为“原始来源”展示。因此同步时把 Pixiv CDN 地址转换为:

https://www.pixiv.net/artworks/<作品ID>

同时保留原始值:

{
  "source": "https://www.pixiv.net/artworks/<作品ID>",
  "source_raw": "https://i.pximg.net/img-original/.../<作品ID>_p0.png",
  "pixiv_id": "<作品ID>"
}

source 用于详情页点击,source_raw 只做数据追溯。Danbooru 的 artist_tags 也一并保存,但它属于 Danbooru 的作者索引标签;真正追溯作品时仍然应该结合原始来源。Danbooru 上传者、审核者可以作为索引站内部信息保存,但不能当作图片作者。

同步游标和失败重试

同步脚本不是每次都从第一页重新找 10 张图。这样运行一段时间后会不断扫描已经下载过的内容,图库越大,无效请求越多。

状态文件最终除了 downloaded,还记录几个游标:

{
  "downloaded": [],
  "pending": [],
  "latest_seen_id": 0,
  "history_before_id": 0,
  "history_exhausted": false,
  "failed_counts": {},
  "permanently_failed": []
}

Danbooru 支持按 ID 顺序翻页。同步脚本用 a<ID> 检查上次运行后出现的新帖子,用 b<ID> 向更早的历史内容继续扫描。

运行逻辑是先检查新帖,新帖数量不足时再继续向历史回填。本次找到目标数量后保存 history_before_id,下一次继续从这个位置往前,不需要重新扫描已经处理过的整段历史。

当符合当前条件的历史图片全部扫描完后:

"history_exhausted": true

之后定时运行只检查新出现的帖子。如果以后修改筛选条件,希望把以前被过滤掉的历史内容重新检查,可以执行:

python3 /opt/wordpress/scripts/sync_arknights_fanart.py --reset-history

这个操作只重置历史游标,不清空已经下载的 ID,因此已有图片仍然不会重复下载。

实际同步过程中还遇到了另一类错误:

下载:danbooru_<ID>.webp | 1920x1080 | 作者标签:<作者标签>
待处理帖子下载失败,保留下次重试:<ID> ->
cannot identify image file '/opt/wordpress/images/arknights/danbooru_<ID>.webp.download'

另一个历史帖子也出现相同错误:

历史帖子下载失败,下次从此处重试:<ID> ->
cannot identify image file '/opt/wordpress/images/arknights/danbooru_<ID>.webp.download'

这里的 pending 不是未筛选图片,而是“已经满足条件、准备下载,但本次没有成功完成”的重试队列。失败的帖子不会被加入 downloaded,下一次运行会优先再次尝试。

问题在于,如果历史游标为了保证不漏图,一直停在一个永远无法被 Pillow 解码的帖子上,后面的历史内容也无法继续扫描。于是同步脚本增加了三层检查。

首先只接受静态图片格式:

SUPPORTED_FILE_EXTS = {
    "jpg",
    "jpeg",
    "png",
    "webp",
}

下载响应还会检查 Content-Type:

SUPPORTED_CONTENT_TYPES = {
    "image/jpeg",
    "image/png",
    "image/webp",
}

文件写入临时路径后,Pillow 会真正执行一次解码,而不只是根据扩展名判断:

with Image.open(raw_temp) as source_img:
    source_img.load()

如果仍然失败,则记录失败次数:

MAX_RETRIES = 3

同一个合法候选连续失败三次后加入 permanently_failed,历史游标继续向后扫描,不再因为单个异常文件长期卡死。如果以后确认是临时网络或上游问题,希望重新尝试这些记录,可以执行:

python3 /opt/wordpress/scripts/sync_arknights_fanart.py --clear-failures

一次实际同步中,日志已经能区分候选检查、过滤、成功下载、临时失败和待处理状态:

========== 同步完成 ==========
检查新帖:4
检查历史:94
过滤掉:88
新帖入队:0
本次新增:9
失败:2
待处理新帖:1
历史下载:49
历史扫描状态:继续于 ID < <ID>

这里 新帖入队:0 和 待处理新帖:1 并不冲突,后者可以是上一次运行遗留下来的失败任务。

本地图片压缩和接口限流

最初转换 WebP 时使用:

WEBP_QUALITY = 90

而且保留原始分辨率。对于 4096×2291、4093×2686 这样的图片,即使已经转成 WebP,文件仍然可能超过 1 MB,个别甚至达到几 MB。首页 Banner 实际显示宽度远低于 4K,这部分像素没有必要长期保留在本地文件中。

后续新下载图片改成:

MAX_OUTPUT_WIDTH = 2560
WEBP_QUALITY = 82
WEBP_METHOD = 6

超过 2560 像素时只做等比例缩小,不裁剪,也不放大尺寸较小的图片。

这里有一个数据层和文件层需要分清。Danbooru 返回的:

{
  "width": 4096,
  "height": 2291,
  "original_file_ext": "jpg"
}

描述的是原始作品,不能因为本地压缩成 2560 像素就覆盖掉。因此 metadata 改为额外记录本地处理结果:

{
  "width": 4096,
  "height": 2291,
  "original_file_ext": "jpg",

  "local_file": "danbooru_<ID>.webp",
  "local_format": "webp",
  "local_width": 2560,
  "local_height": 1432,
  "local_quality": 82
}

原始作者、来源、尺寸、MD5 等信息继续保留,本地 local_* 只描述实际用于网站展示的文件。

旧图库随后用单独脚本批量处理。第一版批处理直接把所有 WebP 都重新编码成 quality=82,很快就发现即使尺寸完全没变化:

处理前:2560x1286 | 137.1 KB
处理后:2560x1286 | 124.0 KB
节省:13.0 KB

文件还是会变小。原因并不是缩放,而是原来的 WebP 又进行了一次有损编码。对于本来只有一两百 KB 的图片,节省十几 KB 没有太大意义,还引入额外画质损失。

旧图处理规则因此收紧为:

宽度 > 2560
    等比例缩到 2560,再按 quality=82 保存

宽度 <= 2560 且文件 > 1 MB
    不改尺寸,只重新压缩

宽度 <= 2560 且文件 <= 1 MB
    完全跳过

另外,如果重新编码后的文件反而更大,则保留原文件,不覆盖。

随机接口本身还有另一个资源消耗点。每次请求:

/random/random.php

都会真正执行 PHP,再返回 302。即使静态图片最终能由 CDN 缓存,高频刷这个接口仍然会持续打到随机逻辑,因此在 Cloudflare 增加了一条 Rate Limiting Rule:

URI Path:
  /random/random.php

特征:
  IP

阈值:
  12 requests / 10 seconds

动作:
  Block

持续:
  10 seconds

Windows PowerShell 用下面的命令直接测试:

1..20 | ForEach-Object {
    curl.exe -s -o NUL -w "%{http_code}n" "https://<域名>/random/random.php?test=$_"
}

实际返回中先出现正常的:

302
302
302
302
302
302

随后开始出现:

429
429
429

后续又能看到部分 302 和 429 交替。这里不用期待严格从第 13 个请求开始永久全部变成 429,速率限制按时间窗口统计,请求执行速度也会影响窗口中的计数。关键验证结果是高频请求已经能够在 Cloudflare 边缘被 429 拦截,而正常访问仍然得到随机接口预期的 302。

这条规则保护的是 PHP 随机接口,不等于静态图片防盗链。别人如果直接拿到:

/images/arknights/danbooru_<ID>.webp

仍然是在访问公开静态资源。随机接口的限流解决的是高频执行 PHP 和随机逻辑的问题,两者不是同一种防护。

最终图库的职责也比较清楚:Danbooru 提供索引、标签和来源信息,同步脚本负责筛选、去重、历史游标、失败重试和本地转换;metadata.json 保留作品原始信息和本地文件状态;random.php 只负责通用随机与同轮去重;detail.php 根据当前 slot 映射展示来源信息;Cloudflare 则挡住明显超出正常页面加载频率的接口请求。