跳到主要内容
MingTone滴小站 FIELD NODE / MingTone
--:--
MISSION BRIEF / article

已为本站支持自动清除cloudflare缓存

通过Github action实现了全自动清理Cloudflare缓存!

发布 2026-01-07 1756 字 9 分钟 未分类

最近不是把博客迁移至了Fuwari吗?作为纯静态的博客,设置个合理的缓存是非常重要的,但是我既不想每次更新都手动清理缓存,也不想等待自然过期,这就会丧失实时性,因此在和 ai 讨论过后,我得到了一个合适的方法!


思路#

众所周知,Github action是个可以在每次提交时都自动执行某些代码的东西。 那么,我们只需要写一个可以在每一次提交时,自动比对实际编译出来的时候,都有哪些文件被改变了,然后通过Cloudflare的api自动清除改变内容了的url即可!


准备工作#

  1. Cloudflare API Token (清除缓存权限) 如图:
  2. 绑定在博客的域名的Zone ID (Zone ID获取方式如图)
  3. Github actions

部署细则#

  1. 前往 https://github.com/[你的用户名]/[你的博客仓库]/settings/secrets/actions 并设置 Repository secrets
  2. 将你在准备中获得的Cloudflare api token填入secret中,名称为CLOUDFLARE_API_TOKEN
  3. 将你的Zone ID也填入secret中,名称为CLOUDFLARE_ZONE_ID
  4. 设置好的示例如图:
  5. 前往你的 action 页面,点击 New workflow
  6. 接下来你会看到一个 Choose a workflow界面,在这里点击 set up a workflow your self
  7. 接下来,在编辑器内填入以下代码(注意!要更改所有的 https://blog.furry.ist 为你自己的站点url,否则会不起作用!)
name: Purge Cloudflare Changed HTML Pages (fast)
on:
push:
branches: ["main"]
workflow_dispatch:
permissions:
contents: read
concurrency:
group: purge-cf-${{ github.ref }}
cancel-in-progress: true
jobs:
purge:
runs-on: ubuntu-latest
steps:
- name: Checkout (full history for diff)
uses: actions/checkout@v4
with:
fetch-depth: 0
# 0) 判断:这次提交是否可能影响 HTML(不影响就结束)
- name: Detect whether HTML can be affected
id: detect
env:
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.sha }}
run: |
python3 - <<'PY'
import os, subprocess, sys
before = os.environ.get("BEFORE_SHA", "").strip()
after = os.environ.get("AFTER_SHA", "").strip()
out_path = os.environ.get("GITHUB_OUTPUT", "").strip()
def write_output(k, v):
if not out_path:
return
with open(out_path, "a", encoding="utf-8") as f:
f.write(f"{k}={v}\n")
# 无法可靠 diff(第一次 push / force push / workflow_dispatch 等)时:保守认为会影响
can_diff = bool(before) and before != "0000000000000000000000000000000000000000"
if not can_diff:
print("⚠️ No reliable BEFORE_SHA; assume HTML affected.")
write_output("CHANGED_COUNT", 0)
write_output("SHOULD_RUN", "true")
write_output("REASON", "no-before-sha")
sys.exit(0)
# ⭐ 关键修复:关闭 quotepath,避免中文路径被 \"...\\345...\" 这种形式转义
out = subprocess.check_output(
["git", "-c", "core.quotepath=false", "diff", "--name-only", before, after],
text=True,
).strip()
changed = [x.strip() for x in out.splitlines() if x.strip()]
print("Changed files:")
for f in changed:
print(" -", f)
print(f"Total changed: {len(changed)}")
# 更稳:文章/内容常见是 md/mdx;模板/组件/配置改动也会影响 HTML
affect_prefixes = (
"src/pages/",
"src/content/",
"src/layouts/",
"src/components/",
"src/middleware/",
"content/",
"posts/",
"src/posts/",
)
affect_files_exact = (
"astro.config.mjs",
"astro.config.js",
"package.json",
"pnpm-lock.yaml",
"vite.config.js",
"vite.config.ts",
"tailwind.config.js",
"tailwind.config.cjs",
)
affect_exts = (".md", ".mdx", ".markdown", ".html", ".astro")
def affects_html(path: str) -> bool:
if path in affect_files_exact:
return True
if path.startswith(affect_prefixes):
return True
if path.endswith(affect_exts) and not path.startswith(".github/"):
return True
return False
should = any(affects_html(f) for f in changed)
print("Decision:", "RUN" if should else "SKIP")
write_output("CHANGED_COUNT", len(changed))
write_output("SHOULD_RUN", "true" if should else "false")
write_output("REASON", "html-affected" if should else "no-html-affect")
PY
- name: Debug detect outputs
run: |
echo "CHANGED_COUNT=${{ steps.detect.outputs.CHANGED_COUNT }}"
echo "SHOULD_RUN=${{ steps.detect.outputs.SHOULD_RUN }}"
echo "REASON=${{ steps.detect.outputs.REASON }}"
- name: Stop early if HTML not affected
if: steps.detect.outputs.SHOULD_RUN != 'true'
run: |
echo "✅ No HTML-affecting changes. Skip build & purge."
# 1) 只有需要时才继续
- name: Setup Node.js
if: steps.detect.outputs.SHOULD_RUN == 'true'
uses: actions/setup-node@v4
with:
node-version: "20"
# ✅ 不指定 version,避免与 package.json#packageManager 冲突
- name: Setup pnpm
if: steps.detect.outputs.SHOULD_RUN == 'true'
uses: pnpm/action-setup@v4
- name: Show pnpm version
if: steps.detect.outputs.SHOULD_RUN == 'true'
run: |
pnpm -v
node -v
# pnpm store 缓存
- name: Get pnpm store path
if: steps.detect.outputs.SHOULD_RUN == 'true'
id: pnpm-store
run: echo "STORE_PATH=$(pnpm store path --silent)" >> "$GITHUB_OUTPUT"
- name: Cache pnpm store
if: steps.detect.outputs.SHOULD_RUN == 'true'
uses: actions/cache@v4
with:
path: ${{ steps.pnpm-store.outputs.STORE_PATH }}
key: pnpm-store-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
pnpm-store-${{ runner.os }}-
- name: Install dependencies
if: steps.detect.outputs.SHOULD_RUN == 'true'
run: pnpm install --frozen-lockfile
# 2) 恢复上一次 dist(用于对比哪些 HTML 变了)
- name: Restore previous dist (for diff)
if: steps.detect.outputs.SHOULD_RUN == 'true'
uses: actions/cache@v4
with:
path: dist_prev
key: dist-prev-${{ runner.os }}-${{ github.ref_name }}-${{ github.sha }}
restore-keys: |
dist-prev-${{ runner.os }}-${{ github.ref_name }}-
dist-prev-${{ runner.os }}-
- name: List restored dist_prev
if: steps.detect.outputs.SHOULD_RUN == 'true'
run: |
echo "dist_prev exists?"
ls -la dist_prev || true
echo "dist_prev html count:"
(find dist_prev -type f -name "*.html" | wc -l) || true
# 3) Build 一次,产物放 dist_new
- name: Build (Astro)
if: steps.detect.outputs.SHOULD_RUN == 'true'
run: |
pnpm build
rm -rf dist_new
mv dist dist_new
echo "dist_new html count:"
find dist_new -type f -name "*.html" | wc -l
# 4) 生成 purge 列表:只 HTML;如果全站影响或没 prev,则 purge 全部 HTML
- name: Plan purge URLs (changed HTML only)
if: steps.detect.outputs.SHOULD_RUN == 'true'
env:
SITE_ORIGIN: https://blog.furry.ist
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.sha }}
run: |
python3 - <<'PY'
import os, json, pathlib, hashlib, subprocess
site = os.environ.get("SITE_ORIGIN", "https://blog.furry.ist").rstrip("/")
before = os.environ.get("BEFORE_SHA", "").strip()
after = os.environ.get("AFTER_SHA", "").strip()
dist_prev = pathlib.Path("dist_prev")
dist_new = pathlib.Path("dist_new")
def sha256_file(p: pathlib.Path) -> str:
h = hashlib.sha256()
with p.open("rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk)
return h.hexdigest()
def to_url(p: pathlib.Path, base: pathlib.Path) -> str:
rel = p.relative_to(base).as_posix()
if rel == "index.html":
return f"{site}/"
if rel.endswith("/index.html"):
route = rel[:-len("/index.html")]
return f"{site}/{route}/"
return f"{site}/{rel}"
if not dist_new.exists():
raise SystemExit("❌ dist_new not found. build failed?")
# “全站渲染影响”的改动:layout/component/config
global_affect_prefixes = (
"src/layouts/",
"src/components/",
"src/middleware/",
)
global_affect_files = (
"astro.config.mjs",
"astro.config.js",
"package.json",
"pnpm-lock.yaml",
"vite.config.js",
"vite.config.ts",
"tailwind.config.js",
"tailwind.config.cjs",
)
global_purge = False
can_diff = bool(before) and before != "0000000000000000000000000000000000000000"
if can_diff:
try:
out = subprocess.check_output(
["git", "-c", "core.quotepath=false", "diff", "--name-only", before, after],
text=True
).strip()
changed = [x.strip() for x in out.splitlines() if x.strip()]
for f in changed:
if f in global_affect_files or f.startswith(global_affect_prefixes):
global_purge = True
break
except Exception:
pass
new_html = sorted(dist_new.rglob("*.html"))
new_map = {p.relative_to(dist_new).as_posix(): p for p in new_html}
has_prev = dist_prev.exists() and any(dist_prev.iterdir())
if not has_prev or global_purge:
urls = [to_url(p, dist_new) for p in new_html]
plan = {
"mode": "all_html",
"count": len(urls),
"urls": urls,
"reason": "no-prev-dist" if not has_prev else "global-affect-changes",
}
else:
prev_html = list(dist_prev.rglob("*.html"))
prev_map = {p.relative_to(dist_prev).as_posix(): p for p in prev_html}
urls = []
# 新增/变更
for rel, p in new_map.items():
old = prev_map.get(rel)
if old is None or sha256_file(p) != sha256_file(old):
urls.append(to_url(p, dist_new))
# 删除:旧有新无
deleted = set(prev_map.keys()) - set(new_map.keys())
for rel in sorted(deleted):
if rel == "index.html":
urls.append(f"{site}/")
elif rel.endswith("/index.html"):
route = rel[:-len("/index.html")]
urls.append(f"{site}/{route}/")
else:
urls.append(f"{site}/{rel}")
# 去重
dedup, seen = [], set()
for u in urls:
if u not in seen:
seen.add(u)
dedup.append(u)
plan = {
"mode": "changed_only",
"count": len(dedup),
"urls": dedup,
"reason": "hash-diff-dist-html",
}
out = json.dumps(plan, ensure_ascii=False)
print("Purge plan:", out)
with open("plan.json", "w", encoding="utf-8") as f:
f.write(out)
PY
- name: Show plan.json
if: steps.detect.outputs.SHOULD_RUN == 'true'
run: |
echo "=== plan.json ==="
cat plan.json || true
# 5) 调用 Cloudflare Purge by URL(每批 30,带重试)
- name: Purge Cloudflare cache by URL (batched)
if: steps.detect.outputs.SHOULD_RUN == 'true'
env:
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
run: |
python3 - <<'PY'
import os, json, urllib.request, urllib.error, time, sys
zone_id = (os.environ.get("CLOUDFLARE_ZONE_ID") or "").strip()
token = (os.environ.get("CLOUDFLARE_API_TOKEN") or "").strip()
if not zone_id or not token:
raise SystemExit("❌ Missing CLOUDFLARE_ZONE_ID or CLOUDFLARE_API_TOKEN secrets.")
with open("plan.json", "r", encoding="utf-8") as f:
plan = json.load(f)
urls = plan.get("urls", []) or []
print(f"Plan mode={plan.get('mode')} reason={plan.get('reason')} urls={len(urls)}")
if not urls:
print("✅ No changed HTML pages to purge.")
sys.exit(0)
endpoint = f"https://api.cloudflare.com/client/v4/zones/{zone_id}/purge_cache"
def request_purge(batch):
body = json.dumps({"files": batch}).encode("utf-8")
req = urllib.request.Request(
endpoint,
data=body,
method="POST",
headers={
"Content-Type": "application/json",
"Authorization": f"Bearer {token}",
"User-Agent": "github-actions-purge-html/1.0",
},
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read().decode("utf-8"))
def purge_with_retry(batch, max_attempts=5):
delay = 1.0
last_err = None
for attempt in range(1, max_attempts + 1):
try:
data = request_purge(batch)
if data.get("success"):
return
raise RuntimeError("API success=false: " + json.dumps(data, ensure_ascii=False))
except urllib.error.HTTPError as e:
detail = e.read().decode("utf-8", errors="replace")
code = getattr(e, "code", None)
last_err = f"HTTP {code}: {detail}"
if code in (429, 500, 502, 503, 504):
print(f"⚠️ Attempt {attempt}/{max_attempts} failed ({code}), retry in {delay:.1f}s")
time.sleep(delay)
delay = min(delay * 2, 16)
continue
raise SystemExit(f"❌ Purge HTTP error (non-retryable): {code}\n{detail}")
except Exception as e:
last_err = str(e)
print(f"⚠️ Attempt {attempt}/{max_attempts} failed ({e}), retry in {delay:.1f}s")
time.sleep(delay)
delay = min(delay * 2, 16)
continue
raise SystemExit("❌ Purge failed after retries: " + (last_err or "unknown error"))
batch_size = 30
for i in range(0, len(urls), batch_size):
batch = urls[i:i + batch_size]
purge_with_retry(batch)
print(f"🧹 Purged {i + 1}-{i + len(batch)} / {len(urls)}")
print(f"🎉 Done. Purged {len(urls)} URLs.")
PY
# 6) 更新 dist 缓存:本次 dist_new 作为下次 dist_prev
- name: Save dist for next run (as dist_prev)
if: steps.detect.outputs.SHOULD_RUN == 'true'
run: |
rm -rf dist_prev
mv dist_new dist_prev
  1. 点击 Commit changes
  2. 再次点击 Commit changes
  3. 提交一个新文件试试效果吧!

总结#

通过GitHub action 我们可以实现全自动清理Cloudflare的缓存,保证了我们博客的实时性,也方便了我们查看对于博客的更改!

PS:还在修bug嘻嘻 修完了,可以用了

FEEDBACK 发现错误或想继续讨论?

可以直接联系维护者,或在 GitHub 查看并编辑对应 Markdown。

KEYBOARD 任务快捷键

Ctrl K 搜索,Alt ← 新一篇,Alt → 旧一篇。

LICENSE / CREATIVE COMMONS 已为本站支持自动清除cloudflare缓存

除另有说明外,本任务简报按下列许可协议共享。

作者MingTone
发布2026-01-07