scripts/cf-image-cache-rule.sh
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env bash
# Add the /_next/image edge-cache rule to Cloudflare. Dry-run by default.
#
# ── Why this rule is not optional ─────────────────────────────────────────
#
# Next's image optimizer sets `Vary: Accept` on every /_next/image response,
# unconditionally, regardless of the `formats` config. Cloudflare's default
# behaviour for a Vary'd response is to SKIP THE CACHE and report BYPASS, so a
# plain "cache /_next/image*" rule achieves nothing. The older "Vary for
# Images" feature does not help either: it keys on a file extension in the
# path (this URL has none) and requires a Pro plan.
#
# What works is the `vary` setting on Cache Rules, which Cloudflare shipped on
# 2026-07-02. It works on the free plan and needs no extension. There is no
# dashboard control for it -- it is API-only, which is why this script exists.
#
# Without the rule, every size variant of every image is an origin request
# against a 512 MB container. See docs/AnimeGoClub-首页加载性能优化方案.md §12.6.
#
# ── Why a script and not a curl one-liner ─────────────────────────────────
#
# The Rulesets API replaces the whole rule list on PUT. The zone already has a
# cache rule for /anime/* (the edge cache that the ISR detail pages depend on,
# added after the 2026-06-05 crawler incident). A naive PUT would delete it.
# So: read the current rules, append ours, show the result, and only write on
# an explicit --apply.
#
# ── Usage ────────────────────────────────────────────────────────────────
#
# export CF_API_TOKEN=... # Zone → Cache Rules → Edit, on this zone
# ./scripts/cf-image-cache-rule.sh # dry run: show the diff
# ./scripts/cf-image-cache-rule.sh --apply # write it
# ./scripts/cf-image-cache-rule.sh --verify # probe the live edge afterwards
#
# Create the token at: Cloudflare dashboard → My Profile → API Tokens →
# Create Token → Custom token. Permissions: Zone / Cache Rules / Edit, plus
# Zone / Zone / Read so the script can resolve the zone id by name. Scope
# "Zone Resources" to this single zone -- the token needs nothing else.
set -euo pipefail
ZONE_NAME="${CF_ZONE_NAME:-animegoclub.com}"
RULE_DESC="Cache /_next/image variants at the edge, keyed on Accept"
API="https://api.cloudflare.com/client/v4"
MODE="${1:-dry-run}"
command -v jq >/dev/null || { echo "需要 jq: brew install jq" >&2; exit 1; }
[ -n "${CF_API_TOKEN:-}" ] || { echo "需要 CF_API_TOKEN 环境变量" >&2; exit 1; }
cf() { curl -sS -H "Authorization: Bearer $CF_API_TOKEN" -H "Content-Type: application/json" "$@"; }
die_on_error() {
local body="$1" what="$2"
if [ "$(jq -r '.success' <<<"$body")" != "true" ]; then
echo "✗ $what 失败:" >&2
jq -r '.errors[]? | " [\(.code)] \(.message)"' <<<"$body" >&2
exit 1
fi
}
# ── zone id ───────────────────────────────────────────────────────────────
ZONES=$(cf "$API/zones?name=$ZONE_NAME")
die_on_error "$ZONES" "查询 zone"
ZONE_ID=$(jq -r '.result[0].id // empty' <<<"$ZONES")
# Braces are not optional where a variable is followed by a non-ASCII byte:
# bash reads the first byte of the multi-byte character as part of the
# identifier, producing a name that was never assigned. Under `set -u` that is
# a hard exit, and the error names a variable that does not appear in the
# source, which is a confusing five minutes.
[ -n "$ZONE_ID" ] || { echo "✗ 找不到 zone ${ZONE_NAME}(token 权限是否包含 Zone:Read?)" >&2; exit 1; }
echo "zone $ZONE_NAME → $ZONE_ID"
# ── verify mode: probe the live edge, nothing else ────────────────────────
if [ "$MODE" = "--verify" ]; then
URL="https://$ZONE_NAME/_next/image?url=$(python3 -c "
import urllib.parse
print(urllib.parse.quote('https://s4.anilist.co/file/anilistcdn/media/anime/cover/large/bx182317-zzpOnAECrM2o.png', safe=''))")&w=384&q=85"
# This rule cannot be verified until the code that produces /_next/image
# responses is live. Before that deploy the route answers 400 ("url parameter
# is not allowed", from an empty remotePatterns), a 400 is not cacheable, and
# the probe below reports BYPASS -- which looks exactly like the rule having
# failed. Check the status first and say which of the two it is.
PROBE_STATUS=$(curl -sS -o /dev/null -w '%{http_code}' -H "Accept: image/avif,image/webp,*/*" "$URL")
if [ "$PROBE_STATUS" = "400" ]; then
echo
echo "⚠ /_next/image 当前返回 400 —— 优化器还没上线(remotePatterns 仍为空)。"
echo " 这条 CF 规则本身可能没问题,但在部署之前无法验证:400 不可缓存,"
echo " 探测只会得到 BYPASS。先合并并部署 PR,再回来跑 --verify。"
echo " 线上版本:$(curl -sS "https://$ZONE_NAME/version.json" 2>/dev/null || echo '(取不到)')"
exit 0
fi
echo
echo "同一个 URL、两种 Accept、各打两次。要看到的是:"
echo " · 第二次 cf-cache-status 是 HIT(不是 BYPASS / DYNAMIC)"
echo " · 两种 Accept 拿到不同的 content-type(image/avif vs image/webp)"
echo
for accept in "image/avif,image/webp,*/*" "image/webp,*/*"; do
echo "Accept: ${accept%%,*}"
for i in 1 2; do
printf " 第%d次 " "$i"
curl -sSI -H "Accept: $accept" "$URL" \
| grep -iE '^(cf-cache-status|content-type|vary):' \
| tr -d '\r' | paste -sd' ' -
done
done
exit 0
fi
# ── current cache-settings ruleset ────────────────────────────────────────
PHASE="$API/zones/$ZONE_ID/rulesets/phases/http_request_cache_settings/entrypoint"
EXISTING=$(cf "$PHASE" || true)
if [ "$(jq -r '.success' <<<"$EXISTING")" = "true" ]; then
RULESET_ID=$(jq -r '.result.id' <<<"$EXISTING")
CURRENT_RULES=$(jq '.result.rules // []' <<<"$EXISTING")
echo "已有 ruleset ${RULESET_ID},$(jq 'length' <<<"$CURRENT_RULES") 条规则:"
jq -r '.[] | " · \(.description // .expression)"' <<<"$CURRENT_RULES"
else
RULESET_ID=""
CURRENT_RULES='[]'
echo "该 zone 还没有 cache-settings ruleset,将新建。"
fi
# Idempotent: drop any previous copy of our own rule before appending.
KEPT=$(jq --arg d "$RULE_DESC" '[.[] | select(.description != $d)]' <<<"$CURRENT_RULES")
if [ "$(jq 'length' <<<"$KEPT")" != "$(jq 'length' <<<"$CURRENT_RULES")" ]; then
echo "(检测到本规则的旧版本,将替换而不是重复添加)"
fi
NEW_RULE=$(jq -n --arg d "$RULE_DESC" '{
description: $d,
expression: "(starts_with(http.request.uri.path, \"/_next/image\"))",
action: "set_cache_settings",
action_parameters: {
cache: true,
# Next already sends Cache-Control: public, max-age=2678400, must-revalidate
# from `minimumCacheTTL`. Let that be the single source of truth rather than
# setting a second number here that can drift from the config.
edge_ttl: { mode: "respect_origin" },
browser_ttl: { mode: "respect_origin" },
vary: {
# An unexpected Vary header from the origin should fail closed -- not
# cached -- rather than serve one visitor the format another negotiated.
default: { action: "bypass" },
headers: {
accept: { action: "normalize", media_types: ["image/avif", "image/webp"] }
}
}
}
}')
# Ours goes last: cache rules are evaluated in order and the /anime/* rule must
# keep matching first for the ISR HTML it was added to protect.
FINAL_RULES=$(jq -s '.[0] + [.[1]]' <(echo "$KEPT") <(echo "$NEW_RULE"))
echo
echo "将要写入的完整规则列表($(jq 'length' <<<"$FINAL_RULES") 条):"
jq . <<<"$FINAL_RULES"
if [ "$MODE" != "--apply" ]; then
echo
echo "── dry run,没有改动任何东西 ──"
echo "确认无误后跑: $0 --apply"
exit 0
fi
echo
echo "写入中…"
BODY=$(jq -n --argjson rules "$FINAL_RULES" '{rules: $rules}')
if [ -n "$RULESET_ID" ]; then
RESP=$(cf -X PUT "$API/zones/$ZONE_ID/rulesets/$RULESET_ID" --data "$BODY")
else
RESP=$(cf -X POST "$API/zones/$ZONE_ID/rulesets" --data "$(jq -n --argjson rules "$FINAL_RULES" '{
name: "default", kind: "zone", phase: "http_request_cache_settings", rules: $rules }')")
fi
die_on_error "$RESP" "写入 ruleset"
echo "✓ 已写入。现在的规则:"
jq -r '.result.rules[] | " · \(.description // .expression)"' <<<"$RESP"
echo
echo "边缘缓存要几分钟生效。之后跑: $0 --verify"