notion写文章通过Github Action自动同步到hexo博客上

端午节,没有出去玩,在家也无聊,就去外面找个咖啡厅坐着,折腾一下之前纠结了很久的事情,就是用文档软件作为hexo的后端,实现在软件上发文同步到博客。

我不太懂代码,网上好多教程,但是我都看不懂,所幸现在的ai功能强大,所以大部分代码都是ai来写的。(你遇到问题之后可以把代码丢给ai,让ai帮你修改成你能使用的版本)

这篇文章就是我第一篇正式用notion来写的,感觉还可以,确实挺不错的。

一、Notion上的设置

1.注册notion的 integrations

我把名字设置成hexo 注册完之后进入设置 把 ntn_开头的复制下来 这是NOTION_TOKEN

2.设置notion的数据库

数据库的表上设置以下项目 左边是名称 右边是类型

img2025-06-02-001.png

设置完之后就是上面这样。

接下来 进入到这个表格(数据库)里点击右上角三个点 connect到刚刚设置的hexo integrations

在右上角share里确保页面都是公开的,然后把链接复制下来

https://shelled-lycra-402.notion.site/205dec0a530780ceb816f49ea993f628?v=205dec0a5307805bba60000c35598268&source=copy_link

第一个就是数据库id 把数据库ID复制下来(这里很容易搞错)是32位的

205dec0xxxxxxx

到这里我们获取到两个东西

1.NOTION_TOKEN: ntn_xxxxxxx

2.DATABASE_ID:205decxxxxxx

3.写文章

在这个数据库下面新建一列 按照对应的输入文章标题、日期、分类等等。然后title下面文章的名字点进去,就可以编辑里面的内容了,编辑完之后点publised下面的复选框,变成勾之后就可以被抓取了。

img2025-06-02-002.png

二、接下来就是hexo目录下的的设置

1.新建sync.js文件夹到hexo根目录下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
const { Client } = require('@notionhq/client');
const fs = require('fs');
const path = require('path');
const https = require('https');
const { mkdirp } = require('mkdirp'); // 需要安装: npm install mkdirp

// 用于生成安全文件名的辅助函数
function generateSafeFileName(name) {
// 允许中文、数字、字母和基本标点符号,其他字符替换为连字符
return name.replace(/[^a-zA-Z0-9\u4e00-\u9fa5._-]/g, '-');
}

// 格式化日期为 YYYY-MM-DD 格式
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}

const notion = new Client({
auth: process.env.NOTION_TOKEN,
notionVersion: '2022-06-28'
});

// 记录每篇文章的图片序号
const imageCounter = new Map();

async function syncNotionToHexo() {
try {
// 1. 获取文章列表
const response = await notion.databases.query({
database_id: process.env.NOTION_DATABASE_ID,
filter: {
property: 'Published',
checkbox: {
equals: true
}
}
});

// 2. 确保获取到数据
if (!response.results || response.results.length === 0) {
console.log('没有找到已发布的文章');
return;
}

// 3. 处理每篇文章
for (const post of response.results) {
try {
const title = post.properties.Title?.title[0]?.plain_text || 'Untitled';

// 获取文章日期,如果没有设置则使用当前日期
const dateObj = post.properties.Date?.date?.start
? new Date(post.properties.Date.date.start)
: new Date();

const formattedDate = formatDate(dateObj);
const dateString = dateObj.toISOString();

// 提取Tags和Categories
const tags = extractTags(post);
const categories = extractCategories(post);

// 使用新的安全文件名生成函数
const safeTitle = generateSafeFileName(title);

// 在文件名和文件夹名前都加上日期前缀
const fileName = `${formattedDate}-${safeTitle}.md`;
const postDirName = `${formattedDate}-${safeTitle}`; // 文件夹名与文件名一致
const postDir = path.join('source/_posts', postDirName);
const filePath = path.join('source/_posts', fileName);

// 重置当前文章的图片计数器
imageCounter.set(post.id, 1);

// 创建文章目录用于存放图片
await mkdirp(postDir);

// 获取文章内容
const blocks = await notion.blocks.children.list({
block_id: post.id
});

// 生成Markdown内容,使用原始标题
let content = `---
title: "${title}"
date: ${dateString}
tags: ${formatTagsOrCategories(tags)}
categories: ${formatTagsOrCategories(categories)}
---\n\n`;

// 处理所有块
for (const block of blocks.results) {
if (block.type === 'paragraph') {
content += block.paragraph.rich_text.map(t => t.plain_text).join('') + '\n\n';
} else if (block.type === 'heading_1') {
content += `# ${block.heading_1.rich_text.map(t => t.plain_text).join('')}\n\n`;
} else if (block.type === 'heading_2') {
content += `## ${block.heading_2.rich_text.map(t => t.plain_text).join('')}\n\n`;
} else if (block.type === 'heading_3') {
content += `### ${block.heading_3.rich_text.map(t => t.plain_text).join('')}\n\n`;
} else if (block.type === 'bulleted_list_item') {
content += `- ${block.bulleted_list_item.rich_text.map(t => t.plain_text).join('')}\n`;
} else if (block.type === 'numbered_list_item') {
content += `1. ${block.numbered_list_item.rich_text.map(t => t.plain_text).join('')}\n`;
} else if (block.type === 'image') {
// 处理图片
const imageUrl = getImageUrl(block);
if (imageUrl) {
// 获取当前图片序号并递增
const imageNum = imageCounter.get(post.id);
imageCounter.set(post.id, imageNum + 1);

// 生成新的图片文件名:imgYYYY-MM-DD-序号.jpg
const ext = path.extname(imageUrl.split('?')[0]) || '.jpg';
const imageName = `img${formattedDate}-${imageNum.toString().padStart(3, '0')}${ext}`;

// 下载图片到文章目录
await downloadImage(imageUrl, postDir, imageName);

// 使用HTML <img>标签引用图片,使用不带斜杠的相对路径
content += `<img src="${postDirName}/${imageName}" alt="${imageName}" />\n\n`;
}
}
// 代码块处理
else if (block.type === 'code') {
const codeBlock = block.code;
const language = codeBlock.language || '';
const codeContent = codeBlock.rich_text.map(t => t.plain_text).join('');

// 使用三个反引号包裹代码块,保持你习惯的格式
content += `\`\`\`${language}\n${codeContent}\n\`\`\`\n\n`;
}
// 可以继续添加更多块类型的处理
}

// 写入Markdown文件,指定UTF-8编码
fs.writeFileSync(filePath, content, 'utf8');
console.log(`成功生成: ${fileName} (目录: ${postDirName})`);

} catch (error) {
console.error(`处理文章时出错: ${error.message}`);
}
}
} catch (error) {
console.error(`同步失败: ${error.message}`);
process.exit(1);
}
}

// 获取图片URL
function getImageUrl(block) {
if (!block.image) return null;

// 处理不同来源的图片
if (block.image.type === 'external') {
return block.image.external.url;
} else if (block.image.type === 'file') {
return block.image.file.url;
}

return null;
}

// 下载图片到指定目录,使用指定文件名
async function downloadImage(url, dir, fileName) {
return new Promise((resolve, reject) => {
const filePath = path.join(dir, fileName);

const file = fs.createWriteStream(filePath);

https.get(url, response => {
if (response.statusCode !== 200) {
reject(new Error(`下载图片失败,状态码: ${response.statusCode}`));
return;
}

response.pipe(file);

file.on('finish', () => {
file.close();
resolve(fileName);
});
}).on('error', error => {
fs.unlink(filePath, () => {}); // 删除可能已创建的损坏文件
reject(error);
});
});
}

// 提取Tags的辅助函数
function extractTags(post) {
const tagProperty = post.properties.Tags;
if (!tagProperty || tagProperty.type !== 'multi_select') {
return [];
}
return tagProperty.multi_select.map(option => option.name);
}

// 提取Categories的辅助函数
function extractCategories(post) {
const categoryProperty = post.properties.Categories;
if (!categoryProperty || categoryProperty.type !== 'select') {
return [];
}
return categoryProperty.select ? [categoryProperty.select.name] : [];
}

// 格式化Tags或Categories为YAML数组格式
function formatTagsOrCategories(items) {
if (!items || items.length === 0) {
return '[]';
}
if (items.length === 1) {
return `[${items[0]}]`;
}
return '[' + items.map(item => `"${item}"`).join(', ') + ']';
}

// 执行同步
syncNotionToHexo();

把这个方到博客的目录下。

2.然后新建一个.env文件

1
2
3
NOTION_TOKEN=ntn_xxxx 替换成你的TOKEN
NOTION_DATABASE_ID=205dxxxxxxxx 替换成你的数据库ID
POST_PATH=source/_posts

然后在目录下执行

1
2
3
4
5
6
# 设置 Token 和数据库 ID
export NOTION_TOKEN=ntn_xxxx
export NOTION_DATABASE_ID=xxxxx

# 运行脚本
node sync.js

就能看到从ntoion拉取的文章了,到这里.js就配置完成了

我这个.js的逻辑是 把文章拉取下来之后 会把文章的图片放到同名文件夹下。

1
2
/hello_world.md
/hello_world/img1.jpg

三、Github action设置

1.在.github/worlkflows/新建文件.sync.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
name: "Notion to Hexo Sync"

on:
schedule:
- cron: "0 * * * *" # 每小时运行
workflow_dispatch: # 允许手动触发

jobs:
deploy:
runs-on: ubuntu-latest
steps:
# 步骤1:检出代码
- name: Checkout
uses: actions/checkout@v3
with:
ref: "main" # 你的源码分支

# 步骤2:设置Node.js
- name: Setup Node
uses: actions/setup-node@v3
with:
node-version: "18"

# 步骤3:安装依赖
- name: Install Dependencies
run: |
npm install -g hexo-cli
npm install
npm install @notionhq/client
npm install mkdirp

# 步骤4:同步Notion
- name: Sync from Notion
run: node sync.js
env:
NOTION_TOKEN: ${{ secrets.NOTION_TOKEN }}
NOTION_DATABASE_ID: ${{ secrets.NOTION_DATABASE_ID }}

# 步骤5:生成静态文件
- name: Generate Site
run: |
hexo clean
hexo generate


# 步骤6:部署到GitHub Pages
- name: Deploy to Pages
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./public
publish_branch: pages # 你的发布分支

这里你需要确认仓库的分支,和发布的分支名字。

如果运行不成功的话 你可以直接从github网页端新建这个文件,把代码复制进去避免编码错误。

2.depoly设置

1.depoly涉及很多情况

  1. 在同一个仓库的不同分支,例如main分支用来存放源码,pages分支用来发布页面,这样的话就是用原来代码 配合GITHUB_TOKEN来发布。
  2. 源码和页面在两个不同的仓库,在hexo仓库存放源码,然后在另外一个仓库 xxx.github.io上发布我的页面,这样就适合直接用ssh来发布。

无论什么情况 你只需要把action的配置代码 sync.yml 复制给ai 把你的情况描述,跟着做就会成功的。

2.配置新的sshkey

ssh的配置hexo博客的时候都已经使用过了,而且涉及我自己的key所以就不放出来了,我遇到的坑就是ssh是已经使用过的 所以需要重新配置一个ssh

1
ssh-keygen -t ed25519 -C "your_email@example.com" -f github-deploy-key-new
  1. 将新公钥添加到目标仓库:
  2. 将新私钥添加到源仓库的 Secrets:
    3.根据你生成 SSH 密钥时的输出,公钥和私钥已分别保存到以下文件中:
  • 私钥:github-deploy-key-new(此文件包含你的私人密钥,请勿分享)
  • 公钥:github-deploy-key-new.pub(此文件包含你的公共密钥,可以安全地分享)

4.如何找到这些文件

在命令行中查看

如果你是在 Linux、macOS 或 Windows 的 WSL 环境中生成的密钥,可以使用以下命令查看:

1
2
3
4
5
# 查看私钥(注意:不要将此内容分享给他人)
cat github-deploy-key-new

# 查看公钥(可以安全分享
cat github-deploy-key-new.pub

在文件资源管理器中查看

  • Windows:通常位于你的用户目录(例如 C:\Users\你的用户名\github-deploy-key-new.pub)
  • Linux/macOS:通常位于你的主目录(例如 /home/你的用户名/github-deploy-key-new.pub)
    如果不会的话设置完以后在github action上手动拉取一次 之后报错的内容复制给ai让ai帮你。

3.启动github action

进入仓库之后点击actions

img2025-06-02-003.png

部署完之后应该就OK了我设置的代码是一小时抓去一次

我自己也不太懂代码 完全是跟着ai的提示完成的

四、我配置的时候遇到的坑

1.不会设置数据库的格式,问了ai好多次才明白

2.抓取的时候可以在本地测试sync.js的代码 成功了再上传到githubaction,可以省很多时间

3.deploy的时候用的ssh已经被使用了,但是我不知道,浪费了很多时间。

4.这个方式并不会把notion的文件保存到仓库里,而是每次都会重新抓取一次,我一开始以为是ai没把代码写好,后面才意识到,这样子更方便对文件进行管理,因为你想取消发布的文章可以把published的勾取消,而且每次对文章内容的修改都能同步到网页上,这样更加像后台。