#!/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"