Notion 官方 API 自动备份到 GitHub:GitHub Actions 完整方案
如果你以前折腾过 Notion 备份,大概率踩过两种坑:一种是靠 token_v2 这类非官方方式,隔一阵就过期;另一种是导出看起来成功了,结果只拿到一个主页面,子页面、子数据库、附件和文件名全都不太像你想要的样子。
我最后定下来的做法,是直接走 Notion 官方 API,再把导出流程交给 GitHub Actions。这样至少先把三件麻烦事压住了:授权不再靠 Cookie,定时任务不靠本地机器开着,导出的 Markdown 还能继续补一层递归和重命名逻辑。
如果你正在找这些问题的方案,比如 Notion 自动备份到 GitHub、Notion GitHub Actions 备份、Notion 官方 API 导出 Markdown,这篇就是围绕这几个搜索意图写的。
我为什么不再继续用非官方 Token 方案
非官方方案也不是完全不能用,问题在于它总带着一点“不知道哪天会失效”的不确定性。你今天能跑,明天可能就开始 401、403,或者因为异地 IP、Cookie 失效、登录状态变化突然断掉。
而官方 API 至少把最核心的一层稳定性给你了:
- Token 是 Internal Integration,不是临时抓来的 Cookie
- 权限模型清楚,页面能不能读,取决于你有没有把集成加进页面
- GitHub Actions 定时跑起来以后,不需要你自己守着本地机器
真正要补的,其实不是“怎么调用 API”,而是怎么把官方 API、导出工具和递归逻辑拼成一个能长期跑的备份链路。
这套方案最终长什么样
我更喜欢先把结构看明白,再写命令。整条链路其实不复杂:
Notion 顶层页面
↓
Internal Integration 授权
↓
GitHub Actions 定时触发
↓
Python 调 notion-client + notion2md
↓
递归遍历子页面 / 子数据库
↓
导出 Markdown + 下载附件
↓
自动解压 zip
↓
把文件名从 block id 改成真实标题
↓
提交到 GitHub 仓库
外层看是 GitHub Actions 在跑,真正的灵魂其实是中间那段 Python 脚本。因为 GitHub Actions 只是调度器,Notion 官方 SDK 只是 API 客户端,notion2md 也主要解决“单页导出 Markdown”这一步。你真正想要的“递归抓全站”和“导出后像人能看懂的目录结构”,还得自己补。
先把官方授权打通,不然后面脚本再长都白搭
第一步是去 Notion 开发者后台建一个 Internal Integration:
- 打开 Notion Integrations
- 新建集成时,类型选
Internal - 至少给它
Read content - 创建后拿到以
secret_开头的 Token
这里只拿到 Token 还不够。Notion 的权限控制很明确,集成默认并不能读你所有页面,你还得把它手动加到目标页面里。
这个动作在网页端做:
- 打开你准备作为备份入口的顶层页面
- 点右上角
... - 找到
Add connections - 把刚刚创建的集成加进去
这一步没做对,后面最常见的报错就是:
Could not find block with ID- 查得到页面 ID,但 API 死活读不到内容
还有一个细节也别忘了:你需要保存这个顶层页面的 page_id。通常就是 URL 里那串 32 位 ID,去掉横线也能用。
GitHub 这边其实只要准备两个 Secrets
我不建议把 Token 和页面 ID 写死进工作流文件,直接放 GitHub Secrets 就行。
要准备的变量就两个:
NOTION_INTEGRATION_TOKENNOTION_PAGE_ID
然后在仓库里建:
.github/workflows/backup.yml
工作流外壳并不花哨,核心就是:
- 检出仓库
- 准备 Python
- 安装
notion-client和notion2md - 跑你自己的导出脚本
- 如果内容有变化就提交回仓库
像这样就够了:
name: "Notion Official API Backup"
on:
schedule:
- cron: "0 0 * * *"
workflow_dispatch:
jobs:
backup:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
- name: Install dependencies
run: pip install notion2md notion-client
- name: Run export
env:
NOTION_TOKEN: ${{ secrets.NOTION_INTEGRATION_TOKEN }}
NOTION_PAGE_ID: ${{ secrets.NOTION_PAGE_ID }}
run: python export_script.py
- name: Commit changes
run: |
git config --local user.email "[email protected]"
git config --local user.name "GitHub Action Backup"
git add .
if git diff --staged --quiet; then
echo "No content changes"
else
git commit -m "Auto backup: $(date +'%Y-%m-%d %H:%M:%S')"
git push
fi
如果你只是想先把流程跑通,到这里其实已经够用了。后面真的决定这套方案好不好用的,是导出脚本本身。
真正关键的是递归导出,不是工作流外壳
直接调用官方 API 的问题是,它不会自动替你做“站点级递归导出”。很多人第一次跑完都会以为出问题了,其实不是没导出,而是只导出了你指定的那个页面。
所以我会额外包一层 Python 逻辑,至少处理这四件事:
- 把主页面导出成 Markdown
- 遍历所有子页面
- 如果碰到子数据库,继续把数据库里的页面抓出来
- 导出后自动把 block id 文件名改成真实标题
下面这段是我觉得真正值得保留的核心骨架:
import os
import zipfile
from notion_client import Client
from notion2md.exporter.block import MarkdownExporter
token = os.environ["NOTION_TOKEN"]
root_id = os.environ["NOTION_PAGE_ID"].replace("-", "")
notion = Client(auth=token)
def sanitize_title(title: str) -> str:
safe = "".join(
c for c in title
if c.isalnum() or c in (" ", "-", "_", "(", ")", "【", "】", "、", ".")
).rstrip()
return safe or "untitled"
def export_recursive(block_id: str, output_dir: str, title: str):
block_id = block_id.replace("-", "")
safe_title = sanitize_title(title)
os.makedirs(output_dir, exist_ok=True)
MarkdownExporter(block_id=block_id, output_path=output_dir, download=True).export()
zip_path = os.path.join(output_dir, f"{block_id}.zip")
if os.path.exists(zip_path):
with zipfile.ZipFile(zip_path, "r") as zip_ref:
zip_ref.extractall(output_dir)
os.remove(zip_path)
md_path = os.path.join(output_dir, f"{block_id}.md")
new_md_path = os.path.join(output_dir, f"{safe_title}.md")
if os.path.exists(md_path):
if os.path.exists(new_md_path):
os.remove(new_md_path)
os.rename(md_path, new_md_path)
results = []
next_cursor = None
while True:
res = notion.blocks.children.list(block_id=block_id, start_cursor=next_cursor)
results.extend(res.get("results", []))
next_cursor = res.get("next_cursor")
if not next_cursor:
break
child_dir = os.path.join(output_dir, safe_title)
for block in results:
if block["type"] == "child_page":
export_recursive(block["id"], child_dir, block["child_page"]["title"])
elif block["type"] == "child_database":
db_dir = os.path.join(child_dir, sanitize_title(block["child_database"]["title"]))
pages = notion.databases.query(database_id=block["id"]).get("results", [])
for page in pages:
page_title = "untitled"
for _, prop in page["properties"].items():
if prop["type"] == "title" and prop["title"]:
page_title = prop["title"][0]["plain_text"]
break
export_recursive(page["id"], db_dir, page_title)
这个版本的重点不是“代码多优雅”,而是它终于把几个很烦的实际问题一次性压住了:
- 官方 API 不递归的问题
- 导出带 zip 附件的问题
- 文件名全是 block id 的问题
- 子数据库页面默认不会一起落盘的问题
这套方案为什么比 token_v2 更值得长期留着
我觉得最关键的不是“官方方案听起来更正规”,而是它真的更适合长期无人值守。
你把这件事拆开看就很明显:
token_v2方案更像是借用浏览器登录状态- Internal Integration 更像是明确授权一个服务账号去读内容
前者能快速验证,但长期维护时总有一点悬着。后者前期多配一步权限,后面反而更省心。
再加上 GitHub Actions 本来就很适合这类“每天跑一次、失败了留日志、成功了自动提交”的后台任务,所以这套组合放在一起就很自然。
最容易翻车的地方,不在 API 调用本身
真要说排错,我会先看下面这几项:
- Secrets 里的 Token 多了空格,或者压根不是最新的
secret_... - 集成虽然建好了,但没有被加进目标页面
- 页面 ID 取错,拿成了别的块或者别的数据库
- 导出脚本能跑,但根目录、重命名逻辑或者 zip 解压逻辑没处理好
还有一个经常被忽略的点:Notion API 有速率限制,页面多、附件多、数据库层级深的时候,跑得慢不一定是挂了。很多时候就是在慢慢遍历和下载。
所以如果你看到 GitHub Actions 挂了好几分钟,还在 Run,先别急着判死刑,先看日志是不是还在继续往下滚。
如果我自己要长期维护,会盯这几件事
这类备份脚本一旦跑起来,后面最重要的就不是“它能不能导出一次”,而是“它有没有在变化后继续稳定产出”。
我自己会重点看:
- 仓库里最近几天有没有正常提交
- 新建子页面后,递归导出有没有把它带出来
- 数据库里的新条目有没有跟着更新
- 重命名逻辑在中英文标题、特殊符号场景下会不会出错
如果你后面继续把这套链路往下打磨,我建议先做两件小事:
- 失败时把关键报错写进 Actions 日志里,别只返回退出码
- 备份仓库按日期或目录结构管理好,不然后面会越来越乱
这篇方案适合谁,不适合谁
适合:
- 想把 Notion 当知识库长期存档的人
- 不想再靠临时 Cookie 抓导出的人
- 已经在用 GitHub,希望顺手把备份也纳入仓库历史的人
不太适合:
- 只想偶尔手工导出一两次的人
- 完全不想碰 GitHub Actions 和 Python 脚本的人
- 对附件、数据库、递归结构完全没有要求的人
如果你只是偶尔导出一次,那官方网页导出也够了。真正值得上这套自动化的,通常是笔记数量已经开始变多,而且你已经不想再手工点导出了。