把 Notion 当作博客后台:在 Notion 里写文章、把状态改成“已发布”,GitHub Actions 每小时调用 Notion API 把文章转换成静态 HTML 网站,并自动部署到 Cloudflare Pages。写作体验是 Notion,网站是自己的。
先看清成品
成品是一个静态网站:首页按日期列出已发布文章,每篇文章一个页面,图片已下载到网站自己的目录里。本文代码已在本地实际测试:块转换正确合并列表、转义 HTML、过滤
链接;用模拟 Notion 接口的本地服务完整构建了一次站点,确认只查询“已发布”的文章、标题层级正确、Notion 上传的图片被下载到dist/img/,2 个测试全部通过。本文环境没有 Notion 和 Cloudflare 账号,读取真实数据和部署没有实际执行。
做完之后你会得到什么?
| 成果 | 说明 |
|---|---|
| Notion 写作后台 | 数据库里一行就是一篇文章 |
| 构建脚本 | 读取已发布文章,转换为 HTML |
| 图片本地化 | 解决 Notion 文件链接过期问题 |
| 自动部署 | GitHub Actions + Cloudflare Pages |
| 测试 | 不需要真实账号即可验证 |
需要准备哪些环境?
| 项目 | 要求 | 说明 |
|---|---|---|
| Notion 账号 | 免费计划即可 | 需要创建集成 |
| GitHub 账号 | 免费 | 运行构建 |
| Cloudflare 账号 | 免费计划即可 | 托管网站 |
| Node.js | 22 | 本地测试和 Wrangler 需要 |
| @notionhq/client | 5.x | 本文使用 5.27.1 |
第一步:怎样准备文章数据库?
新建数据库“博客文章”:
| 属性 | 类型 | 说明 |
|---|---|---|
| 标题 | 标题 | |
| Slug | 文本 | 网址名,如 hello,只用小写字母、数字和横线 |
| 发布日期 | 日期 | 用于排序 |
| 摘要 | 文本 | 首页显示 |
| 状态 | 状态 | 草稿、已发布 |
1. 创建内部集成
打开 Notion 的集成管理页(设置 → 连接 → 开发或管理集成,或直接访问 https://www.notion.so/profile/integrations),新建一个 内部集成,选择你的工作区。保存后复制 内部集成密钥(以 ntn_ 开头)。
2. 把数据库共享给集成
打开数据库所在页面,点右上角 ••• → 连接,搜索并添加刚才的集成。没有这一步,API 会返回 object_not_found。
3. 复制数据源 ID
从 2025 年 9 月的 API 版本开始,一个数据库可以包含多个数据源,读写数据要用 数据源 ID,而不是数据库 ID。打开数据库的 ••• → 管理数据源,在数据源的菜单里选择 复制数据源 ID。
注意:集成密钥能读写所有共享给它的页面。只共享需要的数据库,密钥只放在 GitHub Secrets 或本机环境变量里。
第二步:代码怎么写?
1. 项目结构
notion-blog/
├── .github/
│ └── workflows/
│ └── deploy.yml
├── render.mjs # Notion 块 → HTML
├── build.mjs # 读取数据,生成 dist/
├── build.test.mjs
└── package.json
2. package.json
{
"name": "notion-blog",
"private": true,
"type": "module",
"scripts": {
"build": "node build.mjs",
"test": "node --test"
},
"dependencies": {
"@notionhq/client": "^5.27.1"
}
}
3. render.mjs
// 把 Notion 块转换成 HTML。只处理博客常用的块类型,其余跳过并在控制台提示
const esc = (s) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
export function richText(rt = []) {
return rt
.map((t) => {
let s = esc(t.plain_text ?? t.text?.content ?? "");
const a = t.annotations ?? {};
if (a.code) s = `<code>${s}</code>`;
if (a.bold) s = `<strong>${s}</strong>`;
if (a.italic) s = `<em>${s}</em>`;
if (a.strikethrough) s = `<s>${s}</s>`;
const href = t.href ?? t.text?.link?.url;
if (href && /^https?:\/\//.test(href)) s = `<a href="${esc(href)}">${s}</a>`;
return s;
})
.join("");
}
export function blocksToHtml(blocks, { imageSrc = (b) => b.image?.external?.url ?? b.image?.file?.url } = {}) {
const out = [];
let list = null; // 连续的列表项要包在同一个 <ul>/<ol> 里
const closeList = () => { if (list) { out.push(`</${list}>`); list = null; } };
for (const b of blocks) {
const isList = b.type === "bulleted_list_item" || b.type === "numbered_list_item";
const tag = b.type === "bulleted_list_item" ? "ul" : "ol";
if (!isList || list !== tag) closeList();
const v = b[b.type] ?? {};
switch (b.type) {
case "paragraph": out.push(`<p>${richText(v.rich_text)}</p>`); break;
case "heading_1": out.push(`<h2>${richText(v.rich_text)}</h2>`); break; // 页面标题已占用 h1
case "heading_2": out.push(`<h3>${richText(v.rich_text)}</h3>`); break;
case "heading_3": out.push(`<h4>${richText(v.rich_text)}</h4>`); break;
case "bulleted_list_item":
case "numbered_list_item":
if (!list) { list = tag; out.push(`<${tag}>`); }
out.push(`<li>${richText(v.rich_text)}</li>`);
break;
case "quote": out.push(`<blockquote>${richText(v.rich_text)}</blockquote>`); break;
case "code": out.push(`<pre><code class="language-${esc(v.language ?? "")}">${richText(v.rich_text)}</code></pre>`); break;
case "divider": out.push("<hr>"); break;
case "to_do": out.push(`<p>${v.checked ? "☑" : "☐"} ${richText(v.rich_text)}</p>`); break;
case "image": {
const cap = richText(v.caption);
out.push(`<figure><img src="${esc(imageSrc(b))}" alt="${esc(cap.replace(/<[^>]+>/g, ""))}" loading="lazy">${cap ? `<figcaption>${cap}</figcaption>` : ""}</figure>`);
break;
}
default: console.warn(`跳过不支持的块类型:${b.type}`);
}
}
closeList();
return out.join("\n");
}
支持的块:段落、三级标题、无序和有序列表、引用、代码、分割线、待办、图片。表格、嵌入、子页面等会在构建日志里提示“跳过”,需要时自行补充。
4. build.mjs
// 从 Notion 数据源读取“已发布”的文章,生成静态网站到 dist/
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { createHash } from "node:crypto";
import { extname } from "node:path";
import { Client, iteratePaginatedAPI } from "@notionhq/client";
import { blocksToHtml, richText } from "./render.mjs";
const OUT = "dist";
const SITE = process.env.SITE_TITLE ?? "我的博客";
const page = (title, body) => `<!doctype html><html lang="zh-CN"><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title>
<style>body{font-family:system-ui,sans-serif;max-width:720px;margin:40px auto;padding:0 16px;line-height:1.75}
img{max-width:100%}pre{background:#f5f5f5;padding:12px;overflow:auto}a{color:#2563eb}</style>
<body><p><a href="/">${SITE}</a></p>${body}</body></html>`;
const prop = (p, name) => p.properties[name];
const text = (p, name) => (prop(p, name)?.rich_text ?? prop(p, name)?.title ?? []).map((t) => t.plain_text).join("");
export async function collect(notion, dataSourceId) {
const posts = [];
for await (const p of iteratePaginatedAPI(notion.dataSources.query, {
data_source_id: dataSourceId,
filter: { property: "状态", status: { equals: "已发布" } },
sorts: [{ property: "发布日期", direction: "descending" }],
})) {
const blocks = [];
for await (const b of iteratePaginatedAPI(notion.blocks.children.list, { block_id: p.id })) blocks.push(b);
posts.push({
id: p.id,
title: text(p, "标题"),
titleHtml: richText(prop(p, "标题").title),
slug: text(p, "Slug") || p.id.replaceAll("-", ""),
date: prop(p, "发布日期")?.date?.start ?? "",
summary: text(p, "摘要"),
blocks,
});
}
return posts;
}
// Notion 上传的图片地址大约一小时后失效,所以构建时下载到本地
async function localizeImages(post, fetchImpl = fetch) {
const map = new Map();
for (const b of post.blocks.filter((b) => b.type === "image" && b.image.type === "file")) {
const url = b.image.file.url;
const name = createHash("sha1").update(b.id).digest("hex").slice(0, 12) + (extname(new URL(url).pathname) || ".png");
const res = await fetchImpl(url);
if (!res.ok) throw new Error(`下载图片失败 ${res.status}:${b.id}`);
mkdirSync(`${OUT}/img`, { recursive: true });
writeFileSync(`${OUT}/img/${name}`, Buffer.from(await res.arrayBuffer()));
map.set(b.id, `/img/${name}`);
}
return (b) => map.get(b.id) ?? b.image?.external?.url;
}
export async function build(notion, dataSourceId, fetchImpl) {
rmSync(OUT, { recursive: true, force: true });
mkdirSync(`${OUT}/posts`, { recursive: true });
const posts = await collect(notion, dataSourceId);
for (const post of posts) {
const imageSrc = await localizeImages(post, fetchImpl);
const body = `<article><h1>${post.titleHtml}</h1><p><time>${post.date}</time></p>${blocksToHtml(post.blocks, { imageSrc })}</article>`;
writeFileSync(`${OUT}/posts/${post.slug}.html`, page(post.title, body));
}
const list = posts.map((p) => `<li><a href="/posts/${p.slug}.html">${p.titleHtml}</a> <small>${p.date}</small><br>${p.summary}</li>`).join("\n");
writeFileSync(`${OUT}/index.html`, page(SITE, `<h1>${SITE}</h1><ul>${list}</ul>`));
return posts.length;
}
if (import.meta.url === `file://${process.argv[1]}`) {
const notion = new Client({ auth: process.env.NOTION_TOKEN, notionVersion: "2026-03-11", baseUrl: process.env.NOTION_BASE_URL });
build(notion, process.env.NOTION_DATA_SOURCE_ID).then((n) => console.log(`生成 ${n} 篇文章到 ${OUT}/`));
}
| 关键点 | 原因 |
|---|---|
iteratePaginatedAPI |
文章和块都可能分页,SDK 帮你自动翻页 |
| 只查“已发布” | 草稿不会出现在网站上 |
下载 file 类型图片 |
Notion 托管文件的链接是临时的,约一小时后失效 |
external 图片保留原地址 |
外链图片不会过期 |
页面标题用 h1,正文标题降一级 |
每页只有一个 h1 |
第三步:怎样测试?
1. build.test.mjs
import { test } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { readFileSync, existsSync } from "node:fs";
import { Client } from "@notionhq/client";
import { blocksToHtml } from "./render.mjs";
import { build } from "./build.mjs";
const rt = (s, extra = {}) => [{ plain_text: s, annotations: {}, ...extra }];
test("列表合并、转义、链接和代码", () => {
const html = blocksToHtml([
{ type: "bulleted_list_item", bulleted_list_item: { rich_text: rt("一") } },
{ type: "bulleted_list_item", bulleted_list_item: { rich_text: rt("二") } },
{ type: "paragraph", paragraph: { rich_text: rt("<script>", { href: "x" }) } },
{ type: "code", code: { language: "bash", rich_text: rt("echo hi") } },
]);
assert.equal((html.match(/<ul>/g) ?? []).length, 1);
assert.match(html, /<script>/);
assert.doesNotMatch(html, //);
assert.match(html, /language-bash/);
});
test("从假 Notion 服务构建站点,并下载上传的图片", async () => {
const page = { object: "page", id: "aaaa-bbbb", properties: {
标题: { type: "title", title: rt("第一篇文章") }, Slug: { type: "rich_text", rich_text: rt("hello") },
发布日期: { type: "date", date: { start: "2026-10-20" } }, 摘要: { type: "rich_text", rich_text: rt("摘要文字") },
状态: { type: "status", status: { name: "已发布" } } } };
const blocks = [
{ object: "block", id: "b1", type: "heading_1", heading_1: { rich_text: rt("小标题") } },
{ object: "block", id: "b2", type: "image", image: { type: "file", file: { url: "http://img.local/x.png?sig=1" }, caption: [] } },
];
let queryBody;
const server = http.createServer(async (req, res) => {
let body = ""; for await (const c of req) body += c;
res.setHeader("content-type", "application/json");
if (req.url.startsWith("/v1/data_sources/ds1/query")) { queryBody = JSON.parse(body); return res.end(JSON.stringify({ object: "list", results: [page], has_more: false, next_cursor: null })); }
if (req.url.startsWith("/v1/blocks/aaaa-bbbb/children")) return res.end(JSON.stringify({ object: "list", results: blocks, has_more: false, next_cursor: null }));
res.statusCode = 404; res.end(JSON.stringify({ object: "error", status: 404, code: "object_not_found", message: req.url }));
});
await new Promise((r) => server.listen(0, r));
const notion = new Client({ auth: "t", notionVersion: "2026-03-11", baseUrl: `http://localhost:${server.address().port}` });
const fakeFetch = async () => new Response(new Uint8Array([137, 80, 78, 71]));
const n = await build(notion, "ds1", fakeFetch);
server.close();
assert.equal(n, 1);
assert.deepEqual(queryBody.filter, { property: "状态", status: { equals: "已发布" } });
const html = readFileSync("dist/posts/hello.html", "utf8");
assert.match(html, /<h2>小标题<\/h2>/);
assert.match(html, /src="\/img\/[0-9a-f]{12}\.png"/);
assert.ok(existsSync("dist/index.html"));
});
2. 运行
npm install
npm test
本文实际输出:
ok 1 - 列表合并、转义、链接和代码
ok 2 - 从假 Notion 服务构建站点,并下载上传的图片
# pass 2
# fail 0
测试结束后 dist/ 目录结构:
dist/
├── img/
│ └── 32f28ea03b1b.png
├── index.html
└── posts/
└── hello.html
3. 用真实数据构建
export NOTION_TOKEN=ntn_你的密钥
export NOTION_DATA_SOURCE_ID=你的数据源ID
npm run build
npx serve dist
在 Notion 里写一篇文章,状态设为“已发布”,构建后浏览器打开本地地址即可预览。
第四步:怎样自动部署?
1. 创建 Cloudflare Pages 项目
npx wrangler login
npx wrangler pages project create notion-blog --production-branch=main
这是 Direct Upload 类型的项目,由 GitHub Actions 上传构建结果。官方文档说明,Direct Upload 项目之后不能再切换成 Git 集成部署。
2. 工作流文件
name: 构建博客并部署到 Cloudflare Pages
on:
schedule:
- cron: "0 * * * *" # 每小时检查一次(UTC)
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run build
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_DATA_SOURCE_ID: ${{ secrets.NOTION_DATA_SOURCE_ID }}
SITE_TITLE: 我的博客
- run: npx wrangler pages deploy dist --project-name=notion-blog --branch=main
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
3. 配置 Secrets
| 名称 | 获取方式 |
|---|---|
NOTION_TOKEN |
Notion 集成密钥 |
NOTION_DATA_SOURCE_ID |
数据源 ID |
CLOUDFLARE_API_TOKEN |
Cloudflare 后台创建 API 令牌,权限选 Cloudflare Pages 编辑 |
CLOUDFLARE_ACCOUNT_ID |
Cloudflare 后台账户首页右侧 |
4. 检查结果
| 操作 | 期望 |
|---|---|
| 手动运行工作流 | 构建日志显示“生成 N 篇文章到 dist/” |
打开 notion-blog.pages.dev |
显示文章列表 |
| 在 Notion 把一篇改为草稿 | 下次构建后从网站消失 |
出问题时该怎么调?
1. 图片一段时间后显示不出来
说明用了 Notion 的临时文件地址。确认构建脚本跑过 localizeImages,网页里图片地址应是 /img/xxx.png。
2. 构建日志出现“跳过不支持的块类型:table”
本文没有实现表格。可以在 render.mjs 里加 table 分支:表格块的行是它的子块,需要再调用一次 blocks.children.list 取出。
3. 文章网址是一串 ID
文章没填 Slug,脚本用页面 ID 兜底。填写 Slug 后重新构建。
4. 状态筛选报错
Could not find property with name or id: 状态
属性名不一致,或者“状态”不是状态类型而是单选。单选类型要把筛选改为 select: { equals: "已发布" }。
常见问题怎么处理?
| 现象 | 原因 | 解决方法 |
|---|---|---|
| 首页为空 | 没有“已发布”的文章 | 检查状态值 |
| 改了文章网站没变 | 还没到下次构建 | 手动运行工作流 |
| 部署报 Authentication error | API 令牌权限不足 | 重新创建带 Pages 编辑权限的令牌 |
| 中文 Slug 网址难看 | 浏览器会编码 | Slug 用英文或拼音 |
| 构建很慢 | 文章多、块多 | 只在有变化时构建,或缓存块数据 |
| 429 限流 | 请求太密集 | SDK 会自动重试,必要时减少构建频率 |
项目资源
| 用途 | 地址 |
|---|---|
| Notion API 入门 | https://developers.notion.com/docs/getting-started |
| 数据源查询接口 | https://developers.notion.com/reference/query-a-data-source |
| 获取块子级接口 | https://developers.notion.com/reference/get-block-children |
| 文件对象说明 | https://developers.notion.com/reference/file-object |
| Notion JS SDK | https://github.com/makenotion/notion-sdk-js |
| Pages Direct Upload | https://developers.cloudflare.com/pages/get-started/direct-upload/ |
| Wrangler Pages 命令 | https://developers.cloudflare.com/workers/wrangler/commands/#pages |
| Cloudflare API 令牌 | https://developers.cloudflare.com/fundamentals/api/get-started/create-token/ |
建议先跑 npm test 看懂构建流程,再用真实密钥在本地构建一次,用 npx serve dist 预览。
本地预览满意后再创建 Pages 项目、配置 GitHub Secrets,最后让工作流按时自动发布。