WordPress 随机图片 Banner 实践:从外部 API 到本地图库、去重、元数据与 Cloudflare 防刷
- 脚本开发
- 19小时前
- 7热度
- 0评论
这次最开始只是想给 WordPress 首页 Banner 接一个随机明日方舟图片接口。需求看起来很简单:页面每次刷新随机换图,最好三张 Banner 不重复,图片点进去还能看到作者和来源。
真正做下来以后,问题很快从“随机返回一张图片”扩展成了一整条链路:
Danbooru
↓
Python 定时抓取
↓
筛选 / 下载 / WebP 转换
↓
本地图库 + metadata.json
↓
random.php 随机选择
↓
302 到静态图片
↓
WordPress Banner
↓
detail.php 查看作者和来源
中间踩到的坑主要集中在四类:图片源本身不稳定、随机接口会重复、来源元数据不能直接拿来展示、同步脚本在坏图和网络异常下不够稳。另外,因为 random.php 每次请求都会真正执行 PHP,最后还需要在 Cloudflare 前面加一层限速。
当前部署链路是:
Cloudflare Tunnel
↓
宝塔 Nginx
↓
127.0.0.1:8080
↓
WordPress Docker
↓
MariaDB Docker
WordPress 本身继续放在:
/opt/wordpress/data/html
随机图库相关内容单独拆出来:
/opt/wordpress/images
/opt/wordpress/random
/opt/wordpress/scripts
其中 images 和 random 以只读方式挂载到 WordPress 容器,这样图库和随机接口不用混进 WordPress 核心目录里维护。
从外部随机图接口改成本地图库
最开始测试过几个现成图片源,但实际使用后问题比较明显。
有的随机图片接口虽然支持 landscape,实际还是会返回竖图,构图也未必适合 Banner;另一个图库虽然标记了非 R18,但实际图片仍然有不少明显不适合公开博客首页的内容。
后来又尝试过官方或半官方资源:
- PRTS 接口访问时遇到
403 - Bilibili 动态接口从服务器请求时遇到
412 - 游戏资源解包仓库主要是立绘、KV、素材,不是我想要的完整横版二创作品
最后还是选择了 Danbooru,查询条件保持比较严格:
arknights rating:g
但我后来也确认了一点:只靠站点本身的 rating:g 还不够。对于公开博客首页,我又额外做了一层标签过滤,包括泳装、内衣、明显暴露标签以及 AI 生成标签等。
因为服务器访问 Danbooru 需要走代理,Python 中统一配置:
PROXY = "http://192.168.10.1:7893"
PROXIES = {
"http": PROXY,
"https": PROXY,
}
图片本身还要求:
最低宽度:1200
最低宽高比:1.4
格式:jpg / jpeg / png / webp
rating:g
排除 AI
排除额外敏感标签
这样得到的图库虽然筛选率很高,但更符合首页 Banner 的用途。
random.php 只负责“随机”,不要把所有逻辑都塞进去
本地图库做好以后,random.php 的职责刻意保持简单。
请求:
https://<域名1>/random/random.php?type=arknights
PHP 从:
/var/www/html/images/arknights
选择符合条件的图片,然后返回 302:
random.php
↓
302 Location
↓
/images/arknights/danbooru_xxxxx.webp
这里用 302 而不是让 PHP 自己读取图片内容,是为了把随机逻辑和静态文件传输分开。
random.php 每次需要执行,但真正的大图片最后变成:
/images/arknights/xxxx.webp
浏览器和 Cloudflare 都可以按普通静态文件处理。
随机接口本身设置了:
Cache-Control: no-store
防止随机结果被缓存成固定图片。
slot 一开始只是“不同 URL”,并不能保证不重复
首页 Banner 有三个位置,我最开始用了:
/random/random.php?type=arknights&slot=1
/random/random.php?type=arknights&slot=2
/random/random.php?type=arknights&slot=3
最初的理解是,只要三个 URL 不一样,浏览器就不会复用同一个请求。
这个理解只解决了“URL 相同导致请求复用”的问题,却没有解决随机本身:
slot=1 → 随机到 A
slot=2 → 也可能随机到 A
slot=3 → 随机到 B
实际测试以后确实出现了同页重复。
所以后来才把 slot 真正变成一个状态键,而不是一个没有意义的 Query 参数。
现在同一客户端会按:
IP + User-Agent + type
计算一个状态文件,保存在容器:
/tmp/random-gallery
状态大致类似:
{
"slots": {
"1": "danbooru_xxxxx.webp",
"2": "danbooru_yyyyy.webp",
"3": "danbooru_zzzzz.webp"
},
"used": [
"danbooru_xxxxx.webp",
"danbooru_yyyyy.webp",
"danbooru_zzzzz.webp"
]
}
请求某个 slot 时,会排除这一轮已经使用过的图片。
slot 本身没有写死成 1、2、3,因此以后:
slot=10
slot=20
同样可以使用。
这里的 /tmp/random-gallery 只是临时运行状态,不是图库数据库。容器重启以后它消失也没关系,重新加载首页就会重新建立。
detail.php 为什么一开始一直提示找不到图片
为了让图片不只是“随机展示”,我还给 Banner 配了详情页,例如:
/random/detail.php?type=arknights&slot=3
详情页需要显示:
- 大图
- 作者标签
- 角色
- 原始尺寸
- 原作品来源
- Danbooru 页面
第一次测试时却直接出现:
Image information unavailable. Please return to the homepage and refresh.
我最开始怀疑是不是 metadata.json 没读到,后来重新检查才发现问题根本不在元数据。
旧版 detail.php 还在读 Cookie,而新版 random.php 已经改成了:
IP + User-Agent + type
↓
/tmp/random-gallery/*.json
也就是说:
random.php 写文件状态
detail.php 却还在找 Cookie
两边根本不是同一种状态机制。
把 detail.php 改成和 random.php 使用相同的客户端 Key 和状态目录以后,详情页才能通过:
type + slot
找到 Banner 当前对应的本地文件,再去:
metadata.json
读取作品信息。
这里也有一个容易忽略的点:/tmp/random-gallery 是 WordPress 容器内部的 /tmp,不是宿主机 /tmp。
所以真正排查状态文件应该从容器看,而不是直接在宿主机执行:
docker exec wordpress-web sh -c
'ls -lah /tmp/random-gallery && cat /tmp/random-gallery/*.json'
metadata 里为什么会出现 i.pximg.net
详情页跑通以后又出现了一个比较明显的问题:有些图片点击“原始来源”会跳到类似 Pixiv 图片 CDN 的地址,然后直接 403。
一开始看起来像是系统还在用 Pixiv CDN 当图床,实际不是。
同步脚本只是把 Danbooru 返回的:
"source": "https://<域名4>/img-original/.../148111829_p0.png"
原封不动保存进了 metadata.json。
这个地址是图片文件本身,不是作品页面,而且图片 CDN 本来就可能限制直接访问。
正确的数据关系应该是:
source
→ 给访客点击的作品页面
source_raw
→ Danbooru 原始返回值,仅用于追溯
pixiv_id
→ 能识别出来时额外记录
所以同步脚本后来加入了来源规范化。
例如原始来源:
https://<域名4>/.../148111829_p0.png
会转换成:
https://<域名3>/artworks/148111829
同时保留:
{
"source": "https://<域名3>/artworks/148111829",
"source_raw": "https://<域名4>/.../148111829_p0.png",
"pixiv_id": 148111829
}
这样详情页里的“原始来源”才真正指向作品,而不是一个容易 403 的图片 CDN。
已经下载过的图片也不需要重新下载,只修改已有 metadata.json 即可。
同步脚本里的 pending、历史扫描到底是什么
同步脚本后来加了状态文件:
/opt/wordpress/scripts/arknights_fanart_state.json
它主要保存几类状态:
downloaded
pending
latest_seen_id
history_before_id
history_exhausted
failed_counts
permanently_failed
刚开始看到这些状态时,会对“历史”有一点误解。
例如运行结果:
检查新帖:9
检查历史:63
过滤掉:62
新帖入队:2
本次新增:10
本次临时失败:0
本次永久跳过:0
网络中断:0
待处理新帖:0
永久失败记录:0
历史下载:80
历史扫描状态:继续于 ID < 11998797
这里的“历史帖子”不是本地历史记录,而是 Danbooru 上更早的帖子。
脚本现在的逻辑是:
先检查上次运行以后出现的新帖子
↓
找到符合条件的新图
↓
如果还没达到本次 TARGET_NEW
↓
继续向更早的 Danbooru 帖子扫描
↓
直到凑够本次目标或达到扫描上限
例如这次新帖只有 2 张合格,但目标是 10 张:
新帖合格 2 张
↓
还差 8 张
↓
向更早的帖子继续扫描
↓
检查 63 条历史帖子
↓
再找到 8 张
↓
本次新增达到 10
历史扫描状态:继续于 ID < 11998797 的意思是:
下次需要继续补旧图时,从 ID 小于
11998797的位置继续往前找。
不会每次都重新从第一页扫描。
而:
历史下载:80
实际上更准确应该理解成:
累计已下载:80
这些 Danbooru ID 会保存在 downloaded 中,用于去重。
如果旧帖子最终全部筛完
脚本继续向旧 ID 扫描,直到没有更早的结果,然后设置:
"history_exhausted": true
以后不会再从头重复扫描整段历史。
之后每次任务主要就是:
检查新出现的帖子
↓
有新的合格图 → 下载
没有 → 本次新增 0
如果以后我放宽筛选条件,希望重新检查以前被过滤掉的图片,可以重置历史游标,但保留已经下载的 ID:
python3 /opt/wordpress/scripts/sync_arknights_fanart.py --reset-history
这样不会重复下载已有图片。
一张坏图为什么会卡住整个历史扫描
同步过程中出现过这样的真实错误:
下载:danbooru_12014154.webp | 1920x1080 | 作者标签:dragondd
待处理帖子下载失败,保留下次重试:12014154 -> cannot identify image file '/opt/wordpress/images/arknights/danbooru_12014154.webp.download'
历史扫描也遇到过:
下载:danbooru_12003801.webp | 1920x658 | 作者标签:lonkulade
历史帖子下载失败,下次从此处重试:12003801 -> cannot identify image file '/opt/wordpress/images/arknights/danbooru_12003801.webp.download'
cannot identify image file 表示下载请求本身完成了,但 Pillow 无法把临时文件识别成有效静态图片。
问题在于旧版历史扫描为了“不漏图”,失败后会把游标停在当前 ID。
这本来是为了下次重试,但如果某个帖子永远处理不了,就会变成:
第一次 → 卡在这个 ID
第二次 → 又卡在这个 ID
第三次 → 还是这个 ID
后面更老的图片永远扫不到。
后来脚本补了三层判断。
第一层先看 Danbooru 的 file_ext:
SUPPORTED_FILE_EXTS = {
"jpg",
"jpeg",
"png",
"webp",
}
第二层检查 HTTP Content-Type,确认下载到的确实是支持的图片内容,而不是 HTML、错误页面或者其他媒体文件。
第三层才交给 Pillow 真正解码。
对于图片本身的问题,引入失败计数:
第 1 次失败 → 下次重试
第 2 次失败 → 下次重试
第 3 次失败 → permanently_failed
达到上限以后直接跳过,并继续推进历史游标。
这样一张坏图不会永久堵住整个图库扫描。
代理掉了不能算成“图片坏了”
把坏图重试机制加上以后,我又想到另一个问题:
如果不是图片有问题,而是代理挂了或者网络暂时不好,会不会连续三次失败以后把正常图片永久拉黑?
旧版确实存在这个风险。
所以后来又把“网络失败”和“内容失败”彻底分开。
现在 requests.Session() 使用网络层自动重试,主要覆盖:
连接失败
代理失败
超时
HTTP 429
HTTP 500
HTTP 502
HTTP 503
HTTP 504
请求会先自动尝试几次。
如果代理仍然不可用,则本轮同步安全停止:
网络/代理临时异常
↓
停止当前任务
↓
不增加 failed_counts
↓
不加入 permanently_failed
↓
保留 pending
↓
保留历史游标
↓
下次任务继续
脚本会输出类似:
========== 网络异常 ==========
代理或网络暂时不可用:...
本次任务安全停止,不会把网络故障记成图片永久失败。
已保存的下载记录、pending 和历史游标会保留,下次继续。
并使用非 0 退出码结束。
这样定时任务日志能够区分:
任务正常完成
和:
因为网络异常提前结束
最重要的是,不会因为代理临时断开,把正常图片错误加入永久失败列表。
WebP 转换后为什么还有 1~3 MB
图库跑起来以后又发现,有些 WebP 仍然很大。
当时同步脚本用的是:
WEBP_QUALITY = 90
而且虽然格式从 JPG/PNG 转成了 WebP,却完整保留了类似:
4093x2686
4096x2291
这种原始分辨率。
所以出现:
1 MB
2 MB
3 MB
并不奇怪。
首页 Banner 实际根本不需要保存这么大的本地图片,所以后来改成:
最大宽度:2560px
WebP 初始 quality:82
并且只等比例缩小,不裁剪。
这里我还特意把“原始作品数据”和“本地文件数据”分开。
例如原图:
4096x2291 JPG
本地最后可能是:
2560x1432 WebP
metadata.json 中仍保留:
{
"width": 4096,
"height": 2291,
"original_file_ext": "jpg"
}
而处理后的本地信息另外写:
{
"local_width": 2560,
"local_height": 1432,
"local_file_size": 430000,
"local_quality": 82
}
这样详情页展示的仍然是作品原始信息,而不是被本地缓存处理过的数据。
第一个旧图压缩脚本又犯了一个问题
为了处理以前已经下载的几十张图片,我另外写了批处理脚本。
第一次运行后出现:
[1] danbooru_12000276.webp
处理前:2560x1286 | 137.1 KB
处理后:2560x1286 | 124.0 KB
节省:13.0 KB
[2] danbooru_12000376.webp
处理前:2560x1280 | 173.4 KB
处理后:2560x1280 | 147.7 KB
节省:25.7 KB
尺寸完全没变化,文件却变小了。
原因其实很直接:这个脚本对所有 WebP 都重新执行了一次:
converted.save(
...,
format="WEBP",
quality=82
)
原文件可能本来就是 quality=90,所以即使不缩放,也发生了一次新的有损压缩。
对于一张本来只有 137 KB 的图片,为了省 13 KB 再损失一次画质完全没有必要。
所以旧图处理逻辑后来改成:
宽度 > 2560
→ 处理
文件 > 1 MiB
→ 处理
宽度 <= 2560 且文件 <= 1 MiB
→ 完全跳过,不重新编码
这才符合实际目标:只处理明显超出预期的图片。
新抓取的图片也必须同时满足 2560px 和 1 MiB
做到旧图限制以后,我又发现如果只限制宽度,新下载的复杂插画依然可能在 2560px 下超过 1 MiB。
所以最终同步脚本的本地输出条件改成了两个都必须满足:
宽度 <= 2560px
文件 <= 1 MiB
转换过程不是一上来就把尺寸压得很低,而是优先保留分辨率:
原图
↓
如果宽度 > 2560,先缩到 2560
↓
从 quality=82 开始编码
↓
如果仍 >1MiB,逐级降低 quality
↓
最低 quality 仍超限
↓
再把尺寸缩小约 10%
↓
重新尝试编码
最低还设置了输出宽度和质量边界,避免无限缩小。
也就是说:
2560x1440 / 700 KB
→ 直接保存
2560x1440 / 1.3 MB
→ 先降低 WebP quality
降低质量仍 1.1 MB
→ 再缩小一点尺寸
最终必须 <=1 MiB
本地图库因此有比较明确的性能上限,而 metadata.json 仍然保存原始数据,不会因为压图改变作品记录。
Cloudflare 限制 random.php 防止 PHP 被刷
本地随机接口正常以后,还有一个安全和资源问题。
静态图片 URL:
/images/arknights/xxxx.webp
可以被 Cloudflare 缓存,但:
/random/random.php
本身设置了 no-store,每一次请求都会真正执行 PHP、扫描候选并处理随机状态。
所以真正需要优先保护的是这个动态接口,而不是整个 WordPress。
最后直接在 Cloudflare 配置 Rate Limiting:
匹配路径:
/random/random.php
计数:
IP
阈值:
12 requests / 10 seconds
动作:
Block
持续:
10 seconds
这个阈值没有设得特别严。
首页正常一次会同时请求:
slot=1
slot=2
slot=3
也就是至少 3 次。
如果设置成:
5 次 / 10 秒
正常用户快速刷新两次就可能被误伤。
所以最后选择:
12 次 / 10 秒
允许正常连续刷新几次,但脚本高频调用会被挡在 Cloudflare。
一开始测试全是 302
最初同时运行多个测试请求时看到的还是:
302
302
302
...
看起来像 Rate Limit 完全没有生效。
重新确认 Cloudflare 当前配置所在的 Zone 和接口所属域名后,再执行:
1..20 | ForEach-Object {
curl.exe -s -o NUL -w "%{http_code}n" "https://<域名1>/random/random.php?test=$_"
}
实际结果变成:
302
302
302
302
302
302
429
429
429
302
429
302
429
429
429
302
302
302
429
429
302 是随机接口正常跳转到静态图片。
429 表示请求已经被 Cloudflare 的速率限制命中。
这里没有要求“从第 13 个开始以后全部永久 429”,因为限速窗口本身会滚动,前面的请求会不断离开当前统计周期,所以短时间测试中 302 和 429 交替出现并不异常。
这个强度最终比较符合需求:
正常访问
→ 基本无感
连续快速刷新
→ 有一定余量
脚本持续高频刷 random.php
→ Cloudflare 边缘直接 429
请求在 Cloudflare 就被拦掉,不需要穿过 Tunnel 再执行本地 PHP。
最终状态
现在这套随机图库已经不只是一个简单的 random.php。
当前链路实际承担了不同职责:
Danbooru
→ 提供帖子、标签和来源信息
sync_arknights_fanart.py
→ 检查新帖、继续历史回填、过滤、去重、下载、压缩、记录状态
metadata.json
→ 保存原始作品数据和本地文件数据
/images/
→ 提供处理后的静态 WebP
random.php
→ 只负责随机选择和 slot 去重
/tmp/random-gallery
→ 保存当前浏览器这一轮 slot 对应关系
detail.php
→ 根据 slot 找图片,再读取 metadata 展示作者和来源
Cloudflare Rate Limiting
→ 限制 random.php 高频动态请求
最终一次同步的实际结果已经能稳定出现:
========== 同步完成 ==========
检查新帖:9
检查历史:63
过滤掉:62
新帖入队:2
本次新增:10
本次临时失败:0
本次永久跳过:0
网络中断:0
待处理新帖:0
永久失败记录:0
历史下载:80
历史扫描状态:继续于 ID < 11998797
图库:/opt/wordpress/images/arknights
元数据:/opt/wordpress/images/arknights/metadata.json
这组状态说明当次运行中,新帖先被检查并加入队列,新帖不足目标数量后又继续从 Danbooru 更早的帖子中补图,最终新增 10 张;没有图片处理失败,也没有因为代理或网络问题中断,历史游标已经推进到新的位置。
回头看,这次最容易误判的地方其实不是代码本身,而是几个概念边界:
slot 不等于天然去重;source 不一定就是适合给访客点击的作品地址;“请求成功下载到文件”不代表文件一定是有效图片;图片处理失败和代理断线也不能使用同一套失败计数;转成 WebP 更不代表文件自然就会足够小。
把这些职责分开以后,整个随机图库才从“能显示图片”变成了一个可以长期自动运行、能追溯来源、不会轻易被坏数据卡死,也不会让动态 PHP 接口毫无限制暴露在公网的完整链路。