WordPress 本地随机图片接口、Danbooru 同步与 Cloudflare 限流
- 脚本开发
- 2026-08-19
- 181热度
- 0评论
博客首页原本直接使用外部随机图片接口作为 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 则挡住明显超出正常页面加载频率的接口请求。