3832 字
19 分钟
– 次浏览
– 位访客
把 Bing 每日壁纸变成博客 Banner:265 行零依赖 Worker 与一条三级兜底链
AI 摘要
正在生成摘要…

Fuwari 的首页 banner 支持填一个图片地址,官方文档默认让你放一张本地图。但本地图的代价是:这张图再也不会变。想每天换一张风景,就得每天手动下载 + 压缩 + 提交 + 部署。

Bing 每天会换一张壁纸,图质量稳定、带摄影师署名、API 公开无需注册。拿它当 banner 图源是很自然的想法——而直接的实现路径全都走不通,最后落地的是一个 265 行的零依赖 Cloudflare Worker。

这篇只讲干货:接口真实长什么样、踩到的每个坑、以及为什么中间那一层不是”多余的封装”而是必需品。

浏览器 → 自建 Worker → Bing CDN 的请求链路,以及 Bing 不可用时的三级兜底链


一、为什么中间必须站一个 Worker#

先看三个”直接就能用”的思路,全部实测过:

思路实测结果
前端 fetch Bing 元数据 API 拿图地址❌ 被 CORS 拦死——该 API 响应里没有 Access-Control-Allow-Origin
第三方直链接口(bing.img.run、api.dujin.org 等老教程推荐的)❌ 实测已挂,域名不解析或不响应
把图下载到 public/ 每天提交一次✅ 能用,但就不是”自动”了,等于上了一个每日闹钟

第一条是决定性的。实测一下就很清楚:

Terminal window
curl -sI 'https://cn.bing.com/HPImageArchive.aspx?format=js&idx=0&n=1&mkt=zh-CN' | grep -i access-control
# (无输出)

浏览器发起的跨域 fetch 拿不到响应体,no-cors 模式下又读不出内容。这个接口天生只能由服务端调用——不是”可以加个代理更优雅”,而是”不加代理根本拿不到”。

所以链路变成了:

浏览器 ──<img src>──> Worker(302)──> cn.bing.com 的图片 CDN
└─ 边缘缓存着调一次元数据 API

这里有个值得单独说的设计:Worker 只做 302,不搬运图片字节。

  • 图片流量直接由浏览器连 Bing CDN,Worker 完全不参与传输;
  • Worker 一天真正”干活”的次数约等于零(元数据被边缘缓存住,cacheTtl: 1800);
  • 免费额度(10 万请求/天)对这个用法来说是天文数字。

如果改成 Worker 拉图再回吐(fetch 图片然后 Response 出来),带宽会全部记在 Worker 账上,收益是零。302 是这里唯一正确的姿态。


二、Bing 的接口真实长什么样#

format=js 时的响应(2026-09-19 实测截取):

{
"images": [
{
"startdate": "20260918",
"urlbase": "/th?id=OHR.AlphornBavaria_ZH-CN5896237112",
"copyright": "阿尔卑斯长号演奏者,巴伐利亚州,德国 (© U. J. Alexander/Shutterstock)",
"copyrightlink": "https://www.bing.com/search?q=..."
}
]
}

真正要用的只有三个字段:

字段用途
urlbase图片地址的”主干”,补上尺寸档位才是完整 URL
copyright署名文案,已含摄影师与图库
copyrightlinkBing 上这张图的介绍页,拿来做照片来源链接

2.1 拼地址:尺寸档位与那 30% 的体积差#

urlbase 拼图片地址的实际形态是:

https://cn.bing.com/th?id=OHR.AlphornBavaria_ZH-CN5896237112_1920x1080.jpg&pid=hp&w=1920&h=1080
└───── 档位 ─────┘ └── 尺寸参数 ──┘

两处尺寸必须写一样,但它们不是一回事:

  • _1920x1080 是档位,位图是 Bing 预压好的固定产物,白名单之外的档位直接 404;
  • &w=1920&h=1080&pid=hp 是让 Bing 按这个尺寸重新压一遍的参数。

把 &pid=hp&w=&h= 去掉,同一张图会大一圈:

地址形态实测体积
_1920x1080.jpg&pid=hp&w=1920&h=1080231.2 KB
_1920x1080.jpg(无尺寸参数)327.9 KB

少了这串参数,banner 就白多下约 96 KB(−30%)。这是最容易漏、也最值钱的一行。

档位白名单实测(同一天、同一张图):

档位结果体积
_UHD✅ 200218.8 KB
_1920x1080✅ 200231.2 KB
_1366x768✅ 200157.0 KB
_1280x720✅ 200132.5 KB
_1024x768✅ 200124.7 KB
_800x600✅ 20084.2 KB
_400x240✅ 20032.3 KB
_1920x1200 / _1600x900 / 不写档位❌ 404—

两个可以直接抄的结论:

  1. 别猜档位。1600x900 和 1920x1200 看着很合理,实际 404。404 在 <img> 里不会报错,只会静默变成一张破图——比白屏更难发现。
  2. 体积不随分辨率单调。_UHD 反而比 _1920x1080 小(218.8 vs 231.2 KB),说明这些档位是不同批次压出来的固定产物。所以别按”分辨率越大越重”去推理,要按实测选。banner 是全宽大图,1920x1080 足够;窄屏或移动端为主可以降到 _1366x768,直接省掉三分之一。

2.2 署名要洗一遍#

copyright 的值带着 Bing 自己的中文前缀:

阿尔卑斯长号演奏者,巴伐利亚州,德国 (© U. J. Alexander/Shutterstock)

直接显示没问题,但如果想只留后面的部分,用一条正则剥掉「图文:」这类前缀即可:

// 匹配开头的任意非中文冒号内容 + 冒号
function cleanCredit(raw?: string): string {
return str(raw).replace(/^[^::]*[::]\s*/, "");
}

顺手把”取不到就返回空”处理掉,前端的兜底文案(config.ts 里那一行)会顶上。


三、三级兜底链:Banner 不能白板#

banner 是首屏第一眼。它挂了,整站看起来就像没做完。所以这里的兜底不是”锦上添花”,而是必须项:

级触发条件返回
1Bing API 正常302 → 当天壁纸
2Bing 失败,且自备兜底图探活通过302 → 自备兜底图
3兜底图也探不通(或没配)200 → 内置 SVG 渐变占位图(约 400 B)

3.1 为什么第 2 级必须”先探活”#

最容易写错的一步:拿到 FALLBACK_URL 就直接 302 过去。问题是兜底图本身也是外部依赖——它托管在图床上,而图床会挂。

图床挂掉时的表现是”302 到一个死链”,浏览器上就是一张破图。兜底图挂掉造成的破图,比没有兜底更糟,因为它伪装成了有兜底。

正确做法是探活:

async function isReachable(url: string): Promise<boolean> {
try {
const res = await fetch(url, { method: "HEAD", cf: { cacheTtl: 300, cacheEverything: true } });
return res.ok;
} catch {
return false;
}
}

HEAD 不传输图片字节,cacheTtl: 300 让探活结果在边缘缓存 5 分钟——只在兜底路径上发生,正常路径零开销。

3.2 第 3 级为什么是内联 SVG#

最后一道保险不能有任何外部依赖。所以它不是一张图,而是 Worker 里的一段字符串:

function placeholderSvg(): string {
return '<svg xmlns="http://www.w3.org/2000/svg" width="1920" height="1080" viewBox="0 0 1920 1080">'
+ '<defs><linearGradient id="g" x1="0" y1="0" x2="1" y2="1">'
+ '<stop offset="0" stop-color="#2a3f5f"/><stop offset="0.55" stop-color="#1b2a41"/>'
+ '<stop offset="1" stop-color="#0f1a2b"/></linearGradient></defs>'
+ '<rect width="1920" height="1080" fill="url(#g)"/></svg>';
}

约 400 字节,纯渐变、不含文字(不依赖任何字体,也不会有中文乱码问题)。这段代码只要 Worker 还在跑,它就一定渲染得出来——这才是”永不白板”的物理保证。

实测确认:五种故障模式——HTTP 500、DNS 失败、返回非 JSON、images 为空、缺 urlbase——走的都是同一条兜底链,因为 fetchTodayImage() 把所有异常都吞掉并返回 null,由调用方统一决定去向。


四、/fallback:把”等它挂”变成一条命令#

三级兜底链有个尴尬:平时永远测不到。第 2、3 级只有在 Bing 真挂的那天才会触发,而你想在出事前确认它可用时,只能干等。

所以专门开了一个端点:

GET /fallback → 不管 Bing 死活,直接命中兜底链的第 2/3 级

它带两个响应头说明自己走的是哪一级:

X-Fallback: custom # 命中了自备兜底图
X-Fallback-Reason: manual # 手动触发(自动触发时是 bing-unavailable)
X-Fallback: svg # 自备图也没探通,退回内置 SVG

现在验证兜底链是一条命令的事:

Terminal window
node -e "const r=await fetch('https://bing.142588.xyz/fallback',{redirect:'manual'});console.log(r.status, r.headers.get('x-fallback'), r.headers.get('location'))"

这个设计的价值在于:可测的兜底才算兜底。 一段”只会在故障时执行、且从没被执行过”的代码,是没被验证过的代码。


五、Worker 端的四个取舍#

整份实现 265 行、零依赖——wrangler deploy 上去的就是自己那份 index.ts。几个刻意的选择:

① 不引框架。 四个端点、一个 switch 就够了。上 Hono 会让”部署的产物”和”能读懂的代码”之间多隔一层。

② 边缘缓存元数据。 调 Bing 那次 fetch 带 cacheTtl: 1800,只有到期的第一次请求会真正回源。一天 48 次,不是”每个访客一次”。

③ CORS 只给 /meta。 只有它需要被前端 fetch(换署名),图片路径是 302 跳转,根本不需要 CORS 头。ALLOWED_ORIGINS 走白名单而不是 *。

④ 跳转本身也缓存。 302 带 Cache-Control: public, max-age=3600,浏览器和中间层一小时才回来问一次。

端点作用
GET / GET /today GET /bing302 → 当天壁纸(三个别名,用哪个都行)
GET /fallback手动命中兜底链,用于验证
GET /meta当天壁纸元数据 JSON(标题 / 署名 / 原文链接 / 原图)
GET /health健康检查,返回市场、尺寸、当前兜底模式

六、前端三步#

后端通了,前端只剩三件事——但每件都有一个”不做就静默失效”的坑。

6.1 banner.src 必须是 http(s) 绝对地址#

src/config.ts
banner: {
enable: true,
src: "https://bing.142588.xyz/today",
credit: { enable: true, text: "Bing 每日壁纸", url: "https://www.bing.com" },
}

这一步决定了”每天自动换”能不能成立。

Fuwari 的 ImageWrapper.astro 对 http(s):// 开头的地址走普通 <img> 直出,Astro 不会在构建时把它下载固化(只有本地相对路径才会走 <Image> 优化)。所以 src 指向一个动态 302 是安全的:每次访问由浏览器现场解析,图片天然天天不同,不需要重新构建。

反过来,如果写成本地相对路径 /banner.jpg,它会在构建时被优化成一张固定图片——“每天自动换”就彻底没了。

6.2 署名是动态的#

静态配置里只能写死一句兜底文案。真正的摄影师署名要去 /meta 换回来。这段脚本放在布局里,首次加载跑一次即可——banner 在 Swup 容器之外,切页不会重建。

src/layouts/MainGridLayout.astro
const bannerMetaUrl = siteConfig.banner.src.replace(/\/today\/?$/, "/meta");

脚本 fetch(bannerMetaUrl) 拿到 data.credit 就写进 #banner-credit-text,拿到 data.creditUrl 就改 #banner-credit 的 href。

⚠️ 注意 config.ts 里的 credit.text / credit.url 是兜底值,别删:/meta 请求失败、或 Bing 挂掉走兜底图时,页面上显示的就是它俩。

6.3 两条 preconnect#

链路里有两个域名要握手:取图服务、以及它 302 之后的 Bing CDN。两条预连接省掉两次完整握手:

{enableBanner && bannerOrigin && (
<>
<link rel="preconnect" href={bannerOrigin} />
<link rel="preconnect" href="https://cn.bing.com" />
</>
)}

第二条是最容易忘的——redirect 的目标域名也要 preconnect,否则 DNS + TLS 全挤在 302 之后才发生。


七、部署:两个非写不可的细节#

Terminal window
cd workers/bing-banner
env -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_ACCOUNT_ID npx -y wrangler deploy

① 必须清掉那两个环境变量。 本机环境里它们存在但无效,不清掉会直接报认证错误 10000。这个坑的本站记录在另一篇文章里也出现过——凡是 wrangler deploy 报 10000,先 env -u 再跑。

② workers.dev 域名必须放弃。 它在国内被 DNS 污染,直连不通。配置里用 custom_domain: true 挂自有域名:

"routes": [
{ "pattern": "bing.142588.xyz", "custom_domain": true }
]

custom_domain: true 会让 wrangler 自动创建 DNS 记录与证书,不用手动去控制台点。


八、为什么没用 Unsplash#

Unsplash 看起来是更”正规”的选择:图片许可干净(Unsplash License,可商用)。但实测下来,做 banner 图源它反而更麻烦:

对比项Bing 每日壁纸Unsplash
随机图接口官方 API,无需注册、无需 keysource.unsplash.com 实测 503(已停止服务)
正式 APIcn.bing.com/HPImageArchive.aspx,直接 GETapi.unsplash.com/photos/random 实测 401,必须注册应用拿 Access Key
调用配额无(本方案靠边缘缓存,一天几十次)Demo 版 50 请求/小时
图片体积(1920×1080)231.2 KB(带尺寸参数)249.5 KB(jpg q80)/ 242.4 KB(webp q80)
更新节奏每天全站同一张 → 缓存友好,图不变每次请求可以不同 → 每次都要重新下载
许可版权属摄影师 / Shutterstock,非商业使用属灰色地带Unsplash License,可商用
署名接口直接给摄影师与图库名规范要求显著标注,需自己实现

三个结论:

  • 稳定性上 Bing 完胜:不需要 key、不需要注册、不会因为配额或接口下线而挂(source.unsplash.com 的前车之鉴就在眼前)。
  • 体积上两者接近(231 KB vs 250 KB),所以”省流量”不是选 Bing 的理由。
  • 法律上 Unsplash 更干净。Bing 的图版权归摄影师和 Shutterstock,Bing 官方 tooltip 甚至写着”此图片不能下载用作壁纸”。个人博客非商业使用属于灰色地带,缓解方式是把署名显示出来(本项目 credit 就是干这个的),但这不是免责。

如果这个站将来要商业化,正确做法是换成 Unsplash / Pexels 这类明确授权的图源,或者干脆换成自绘的 banner。技术链路(Worker + 兜底链 + 302)可以原样复用,只改 FALLBACK_URL 和第 1 级的取图逻辑。

另外,兜底图本身就选了一张 Unsplash License 的图(山峦云海,1920×1080 / webp q82 / 259 KB,托管在自有图床)——万一哪天真要撤掉 Bing,第 2 级已经在跑一张合法图了。


九、踩坑清单#

现象原因处理
前端 fetch Bing API 报 CORS该接口无 Access-Control-Allow-Origin只能由服务端调,加中间层
banner 是破图尺寸档位不在白名单(如 1600x900),图片 404只用实测可用的档位,Worker 侧保证档位不来自用户输入
banner 加载慢、体积大漏了 &pid=hp&w=&h=拼地址时补上,同一张图省约 30%
署名一直是”Bing 每日壁纸”/meta 请求失败,或 bannerMetaUrl 推导错了检查 src 是否以 /today 结尾;credit.text 是兜底值不是 bug
切页后署名或 banner 失效脚本挂在 Swup 容器内被替换banner 与 credit 都应在 Swup 容器之外
部署报错 10000环境里有无效凭据env -u CLOUDFLARE_API_TOKEN -u CLOUDFLARE_ACCOUNT_ID 后再 deploy
workers.dev 打不开国内 DNS 污染挂自有域名,custom_domain: true
图片每天都一样banner.src 写成了本地相对路径,构建时被固化必须用 http(s) 绝对地址指向动态 302
想测兜底链但没机会兜底只在故障时触发用 GET /fallback 手动命中

十、验证#

整套逻辑可以完全离线验证,不需要部署、不需要起服务:

Terminal window
node .workbuddy/tools/verify-bing-banner.mjs # 71 项

它用打桩的方式覆盖了:四种端点路由、302 的 Location 拼接、&w=&h 参数是否带上、CORS 白名单命中与不命中、五种故障模式是否都落到兜底链、X-Fallback 头是否正确、内置 SVG 是否合法且无外部引用。

线上快速探活(含兜底链):

Terminal window
node -e "const r=await fetch('https://bing.142588.xyz/today',{redirect:'manual'});console.log(r.status, r.headers.get('location'))"

小结#

整件事的核心可以压缩成三句话:

  1. 中间层不是封装,是必需品——Bing 的元数据接口没有 CORS 头,浏览器端根本拿不到数据。
  2. Worker 只做 302,不搬字节——图片流量直连 Bing CDN,Worker 一天真正干活的次数屈指可数。
  3. 兜底必须可测——三级兜底链 + 一个专门用来手动命中它的 /fallback 端点。

最终成果:首页 banner 每天自动换图、显示摄影师署名、不需要重新构建、Bing 挂了也不会白板——代价是 265 行代码和一个自有子域名。


参考与致谢#

  • 接口:Bing HPImageArchive.aspx(本文所有档位与体积数据均实测自该接口当天返回的图片)
  • 兜底图:Unsplash License,源图 ID photo-1470071459604-3b5ec3a7fe05
  • Banner 组件:Fuwari 原生的 ImageWrapper.astro / siteConfig.banner
AI 参与程度
润色
完全
不使用
把 Bing 每日壁纸变成博客 Banner:265 行零依赖 Worker 与一条三级兜底链
https://blog.142588.xyz/posts/fuwari-bing-banner/
作者
Watch Your Back
发布于
2026-09-19
许可协议
CC BY-NC-SA 4.0

评论

加载中…