最近不是把博客迁移至了Fuwari吗?作为纯静态的博客,设置个合理的缓存是非常重要的,但是我既不想每次更新都手动清理缓存,也不想等待自然过期,这就会丧失实时性,因此在和 ai 讨论过后,我得到了一个合适的方法!
思路
众所周知,Github action是个可以在每次提交时都自动执行某些代码的东西。 那么,我们只需要写一个可以在每一次提交时,自动比对实际编译出来的时候,都有哪些文件被改变了,然后通过Cloudflare的api自动清除改变内容了的url即可!
准备工作
- Cloudflare API Token (清除缓存权限) 如图:

- 绑定在博客的域名的Zone ID (Zone ID获取方式如图)

- Github actions
部署细则
- 前往 https://github.com/[你的用户名]/[你的博客仓库]/settings/secrets/actions 并设置 Repository secrets
- 将你在准备中获得的Cloudflare api token填入secret中,名称为CLOUDFLARE_API_TOKEN
- 将你的Zone ID也填入secret中,名称为CLOUDFLARE_ZONE_ID
- 设置好的示例如图:

- 前往你的 action 页面,点击 New workflow
- 接下来你会看到一个 Choose a workflow界面,在这里点击 set up a workflow your self

- 接下来,在编辑器内填入以下代码(注意!要更改所有的 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- 点击 Commit changes
- 再次点击 Commit changes
- 提交一个新文件试试效果吧!
总结
通过GitHub action 我们可以实现全自动清理Cloudflare的缓存,保证了我们博客的实时性,也方便了我们查看对于博客的更改!
PS:还在修bug嘻嘻 修完了,可以用了