效率工具

把 Notion 当作博客后台:在 Notion 里写文章、把状态改成“已发布”

把 Notion 当作博客后台:在 Notion 里写文章、把状态改成“已发布”,GitHub Actions 每小时调用 Notion API 把文章转换成静态 HTML 网站,并自动部署到 Cloudflare Pages。写作体验是 Notion,网站是自己的。

BINBHO · 图文教程

把 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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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, /&lt;script&gt;/);
  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,最后让工作流按时自动发布。