Notion 图片反代加速

August 11, 2026 · View on GitHub

Notion 图片默认会先访问 www.notion.so,再跳到 Notion 临时生成的图片地址。这个过程能用,但速度和缓存都不太稳定。

更省事的办法是:给图片单独准备一个 Cloudflare 域名,例如:

https://cdn.example.com

然后让 NotionNext 把 Notion 图片都改成这个域名:

NEXT_PUBLIC_NOTION_HOST=https://cdn.example.com

改完以后,文章里的图片会从:

https://www.notion.so/image/...

变成:

https://cdn.example.com/image/...

这样图片会先经过你的 Cloudflare Worker,再由 Worker 去 Notion 拿图,并同时缓存到 Cloudflare 边缘和访客浏览器。Notion 附件地址包含图片标识,替换图片后通常会生成新地址,因此可以安全使用长期缓存。

不会代码怎么办

可以直接把本文交给 AI Agent 执行,例如 Codex、Cursor、Claude Code、GitHub Copilot Agent。

你可以这样说:

请按这篇文档帮我完成 NotionNext 的 Notion 图片反代配置。
我的图片 CDN 域名是:cdn.example.com
请帮我创建或修改 Cloudflare Worker,并把 NotionNext 的 NEXT_PUBLIC_NOTION_HOST 配好。

如果你想自己手动操作,也可以按下面的步骤做。主流程不需要你会写代码,只需要会复制、粘贴、保存、重新部署。

适合谁

适合:

  • 博客图片主要上传在 Notion。
  • 不想压缩图片。
  • 不想手动搬图片到 R2、OSS、七牛云。
  • 想尽快让图片走自己的 Cloudflare 缓存。
  • 同时使用 yarn startyarn export

不适合:

  • 完全不使用 Cloudflare。
  • 想把所有图片永久保存到自己的对象存储。
  • 想做复杂的图片处理、缩略图、鉴权、防盗链。

小白手动流程

下面按 Cloudflare 网页后台操作,不要求你在电脑上安装命令行工具。

1. 准备图片域名

假设你的主域名是:

example.com

建议图片域名用:

cdn.example.com

也可以用:

img.example.com
assets.example.com

注意:

  • 主域名必须已经接入 Cloudflare。
  • 不要提前在 DNS 里手动添加 cdn.example.com
  • 这个域名只给 Notion 图片用,不要再拿来放博客首页。

2. 打开 Cloudflare Worker

进入 Cloudflare 后:

左侧菜单 -> Workers & Pages -> Create

选择:

Create Worker

Worker 名称可以填:

notion-image-proxy

创建后进入 Worker 编辑页面。

3. 粘贴 Worker 代码

在 Worker 页面点击:

Edit code

删除默认代码,把下面代码完整粘贴进去。此代码与仓库中的 cloudflare/notion-image-proxy/worker.mjs 保持一致:

const NOTION_ORIGIN = 'https://www.notion.so'
const IMMUTABLE_TTL_SECONDS = 60 * 60 * 24 * 365
const USER_AGENT =
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36'

export default {
  async fetch(request) {
    const url = new URL(request.url)

    if (!isAllowedPath(url.pathname)) {
      return new Response('Not found', { status: 404 })
    }

    if (request.method !== 'GET' && request.method !== 'HEAD') {
      return new Response('Method not allowed', { status: 405 })
    }

    const cache = caches.default
    const cacheKey = new Request(request.url, { method: 'GET' })
    const cached = await cache.match(cacheKey)
    if (cached) {
      const hitHeaders = new Headers(cached.headers)
      setCacheHeaders(hitHeaders)
      setValidatorHeaders(hitHeaders)
      hitHeaders.set('X-Notion-Image-Proxy-Cache', 'HIT')
      if (isNotModified(request, hitHeaders)) {
        return notModifiedResponse(hitHeaders)
      }
      return new Response(request.method === 'HEAD' ? null : cached.body, {
        status: cached.status,
        statusText: cached.statusText,
        headers: hitHeaders
      })
    }

    const upstreamUrl = new URL(url.pathname + url.search, NOTION_ORIGIN)
    const response = await fetch(upstreamUrl, {
      method: 'GET',
      redirect: 'follow',
      cf: {
        cacheEverything: true,
        cacheTtl: IMMUTABLE_TTL_SECONDS,
        cacheKey: request.url
      },
      headers: {
        'User-Agent': USER_AGENT,
        Accept:
          'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'
      }
    })

    const headers = new Headers(response.headers)
    setCacheHeaders(headers)
    setValidatorHeaders(headers)
    headers.set('X-Notion-Image-Proxy', '1')
    headers.set('X-Notion-Image-Proxy-Cache', 'MISS')
    headers.delete('set-cookie')
    headers.delete('content-security-policy')
    headers.delete('content-security-policy-report-only')
    headers.delete('vary')

    const proxied = new Response(response.body, {
      status: response.status,
      statusText: response.statusText,
      headers
    })
    if (response.ok) {
      await cache.put(cacheKey, proxied.clone())
    }

    if (isNotModified(request, headers)) {
      return notModifiedResponse(headers)
    }

    return request.method === 'HEAD' ? new Response(null, proxied) : proxied
  }
}

function isAllowedPath(pathname) {
  return pathname.startsWith('/image/') || pathname.startsWith('/images/')
}

function setCacheHeaders(headers) {
  headers.set(
    'Cache-Control',
    `public, max-age=${IMMUTABLE_TTL_SECONDS}, s-maxage=${IMMUTABLE_TTL_SECONDS}, immutable`
  )
}

function setValidatorHeaders(headers) {
  if (headers.has('etag')) return

  const lastModified = Date.parse(headers.get('last-modified') || '')
  const contentLength = headers.get('content-length') || 'unknown'
  if (!Number.isNaN(lastModified)) {
    headers.set('ETag', `W/"${lastModified.toString(16)}-${contentLength}"`)
  }
}

function isNotModified(request, headers) {
  const etag = headers.get('etag')
  const ifNoneMatch = request.headers.get('if-none-match')
  if (etag && ifNoneMatch) {
    return ifNoneMatch
      .split(',')
      .map(value => value.trim())
      .some(value => value === '*' || weakEtag(value) === weakEtag(etag))
  }

  const lastModified = Date.parse(headers.get('last-modified') || '')
  const ifModifiedSince = Date.parse(
    request.headers.get('if-modified-since') || ''
  )
  return (
    !Number.isNaN(lastModified) &&
    !Number.isNaN(ifModifiedSince) &&
    lastModified <= ifModifiedSince
  )
}

function weakEtag(value) {
  return value.replace(/^W\//, '')
}

function notModifiedResponse(headers) {
  const notModifiedHeaders = new Headers(headers)
  notModifiedHeaders.delete('content-length')
  notModifiedHeaders.delete('content-encoding')
  notModifiedHeaders.delete('content-range')
  return new Response(null, { status: 304, headers: notModifiedHeaders })
}

然后点击:

Save and deploy

4. 绑定自己的图片域名

回到 Worker 页面,找到:

Settings -> Domains & Routes

点击:

Add -> Custom Domain

填写你的图片域名:

cdn.example.com

保存。

这里最容易写错。

正确:

cdn.example.com

错误:

cdn.example.com/image/*

错误:

*.example.com

Cloudflare 这里绑定的是完整域名,不是图片路径。

5. 配置 NotionNext

只需要给 NotionNext 增加一个环境变量:

NEXT_PUBLIC_NOTION_HOST=https://cdn.example.com

不同平台的位置如下:

平台在哪里填
VercelProject Settings -> Environment Variables
Cloudflare PagesSettings -> Variables and Secrets
Docker / VPS.env 或容器环境变量
本地预览.env.local 或 PowerShell

填的时候注意:

  • 变量名必须是 NEXT_PUBLIC_NOTION_HOST
  • 值必须带 https://
  • 不要在结尾加 /

正确:

https://cdn.example.com

不推荐:

https://cdn.example.com/

6. 重新部署博客

配置环境变量后,一定要重新部署博客。

如果你用的是 Vercel 或 Cloudflare Pages:

重新触发一次 Deploy

如果你用的是服务器动态部署:

重启 NotionNext 服务

如果你用的是静态导出:

重新执行 yarn export,并重新上传导出的静态文件

只改环境变量但不重新部署,页面里的图片地址不会变。

命令行方式

如果你会使用命令行,也可以直接用仓库内置的 Worker 文件。

进入目录:

cd cloudflare/notion-image-proxy

复制配置:

Copy-Item wrangler.toml.example wrangler.toml

打开 wrangler.toml,把域名改成你的图片域名:

routes = [
  { pattern = "cdn.example.com", custom_domain = true }
]

部署:

npx wrangler deploy

如果你不懂这些命令,跳过这一节,使用上面的 Cloudflare 网页后台方式即可。

怎么判断成功

打开博客文章页,找一张 Notion 图片。

成功时,图片地址应该长这样:

https://cdn.example.com/image/...

不应该还是:

https://www.notion.so/image/...

如果还是 www.notion.so,通常是:

现象处理
变量名写错必须是 NEXT_PUBLIC_NOTION_HOST
少了 https://值必须是 https://cdn.example.com
改完没重新部署重新 Deploy 或重启服务
静态站没重新导出重新执行 yarn export
本地缓存影响临时设置 ENABLE_CACHE=false

怎么判断缓存命中

这里有两层不同的缓存:

  • 浏览器缓存:由 Cache-Control 控制。缓存新鲜时浏览器不会请求 Cloudflare。
  • Cloudflare 边缘缓存:由 CF-Cache-Status 判断,HIT 表示 Worker 不需要重新向 Notion 下载图片。

第一次打开一张新图片,Cloudflare 常见结果是:

CF-Cache-Status: MISS

这不是失败。MISS 的意思是:Cloudflare 第一次还没有缓存,所以先去 Notion 拿图。

同一张图第二次请求,理想结果是:

CF-Cache-Status: HIT

也可以看 Worker 自己加的响应头:

X-Notion-Image-Proxy: 1
X-Notion-Image-Proxy-Cache: HIT
Cache-Control: public, max-age=31536000, s-maxage=31536000, immutable
ETag: W/"..."

Chrome DevTools 里如果显示:

(memory cache)
(disk cache)

说明这次图片没有重新请求 Cloudflare,而是浏览器直接用了本地缓存。此时 Network 面板里显示的响应头可能只是第一次请求留下来的内容。

建议这样测:

  1. 打开 Chrome DevTools。
  2. 进入 Network。
  3. 确认没有勾选 Disable cache
  4. 正常进入文章,再离开并返回;图片应显示 (memory cache)(disk cache)

::: warning 不要用 Disable cache 测浏览器缓存 Disable cache、硬刷新以及请求头 Cache-Control: no-cache 都会主动要求重新验证资源。此时看到网络请求是正常的,不代表缓存失效。 :::

如果浏览器或扩展要求重新验证,Worker 会根据 ETagLast-Modified 返回:

304 Not Modified

304 没有图片响应体,不会重新传输整张图片。

如果你会用命令行,也可以连续请求同一个完整图片地址两次:

curl.exe -I "你的完整图片URL"
curl.exe -I "你的完整图片URL"

第二次看到 HIT 就说明 Cloudflare 边缘缓存正常。curl 每次都会发起网络请求,因此它不能证明浏览器本地缓存是否生效。

还可以复制第一次响应中的 Last-Modified,验证条件请求:

curl.exe -I -H "If-Modified-Since: Thu, 06 Aug 2026 11:27:52 GMT" "你的完整图片URL"

日期要替换成你实际看到的值,预期状态是 304 Not Modified

常见报错

访问 cdn 域名首页是 404

这是正常的。

这个 Worker 只处理:

/image/...
/images/...

直接打开:

https://cdn.example.com/

没有图片路径,所以会返回 404

部署成功但图片还是 notion.so

先检查环境变量:

NEXT_PUBLIC_NOTION_HOST=https://cdn.example.com

然后重新部署博客。

静态站尤其容易漏这一步。静态 HTML 是构建时生成的,所以必须重新 yarn export

Custom Domain 报错

如果看到:

Wildcard operators (*) are not allowed in Custom Domains
Paths are not allowed in Custom Domains

说明域名写成了路径或通配符。

把:

cdn.example.com/image/*

改成:

cdn.example.com

API Token 报错

如果你用命令行部署,并看到:

missing User->Memberships->Read
Authentication error [code: 10000]

说明 token 缺少 Wrangler 需要读取的用户成员权限。

解决办法:重新创建 token,并加上:

User -> Memberships -> Read

如果你使用 Cloudflare 网页后台手动创建 Worker,通常不会遇到这个 token 问题。

为什么不直接缓存 Notion 签名后的地址

Notion 图片大致是这个流程:

www.notion.so/image/...
-> Notion 生成临时签名地址
-> prod-files-secure.s3... 带 exp 和 sig 的地址
-> 图片内容

expsig 是临时签名,会变化。它们适合临时访问,不适合当作博客长期缓存地址。

这个 Worker 缓存的是你自己的稳定入口:

https://cdn.example.com/image/...

所以同一篇文章里的同一张图,可以更稳定地命中 Cloudflare 缓存。

yarn start 和 yarn export 都支持吗

支持。

yarn start 是动态部署。只要运行环境里有:

NEXT_PUBLIC_NOTION_HOST=https://cdn.example.com

页面生成时就会输出 CDN 图片地址。

yarn export 是静态导出。只要导出时已经配置这个变量,生成出来的 HTML 里也会是 CDN 图片地址。

区别只有一个:

部署方式改完变量后要做什么
yarn start重启服务
yarn export重新导出并上传

访问量很大够用吗

每次图片请求都会先经过 Worker。

粗略估算:

每天图片请求数 = 每天 PV x 每页平均图片数

例如:

10,000 PV/day x 20 张图 = 200,000 requests/day

如果你的站点访问量很大,建议直接使用 Workers Paid。先用 Worker 反代是门槛最低、见效最快的方案;等图片请求量持续很高,再考虑把热门图片迁移到 R2 或其他对象存储。

最小维护建议

  • CDN 域名只用于 Notion 图片。
  • 不要把 Cloudflare API Token 放到前端代码。
  • 不要把 token 提交到 GitHub。
  • 改 Worker 代码后,重新点一次 Save and deploy,或重新执行 npx wrangler deploy
  • 改 CDN 域名后,同步修改 NEXT_PUBLIC_NOTION_HOST,并重新部署博客。
  • 命令行部署时只提交 wrangler.toml.example;本地复制出的 wrangler.toml 已被 Git 忽略,避免把个人域名误提交到仓库。
  • 替换 Notion 图片通常会生成新附件地址;如果你在其他来源上原地覆盖了同一 URL 的内容,需要在 Cloudflare 中只清理对应 URL 的缓存。