博客私密化

摘要

双仓库方案

新建一个private仓库存源码,备份之后把原项目(public仓库)关联到私有仓库

1
2
git init
git remote add origin https://github.com/Mintind/private.git

然后通过github action配置,让private仓库push时,自动push一部分内容到public仓库,public仓库再部署成网页。这样public仓库只会保留必要的内容,不会把配置文件、脚本、markdown原文件等都放上去

在private仓库的_config.yml设置部署选项。Hexo 官方的一键部署机制就是通过 _config.ymldeploy 设置部署器;hexo-deployer-git 会把生成后的站点内容放到部署目录并 push 到目标 Git 仓库。这一步是配置 hexo deploy

1
2
3
4
5
6
7
# Deployment
## Docs: https://hexo.io/docs/one-command-deployment
deploy:
type: git
repository: git@github.com:Mintind/Mintind.github.io.git
# repository: https://github.com/Mintind/Mintind.github.io.git
branch: main

.github中添加workflows,deploy.yml,用于在main分支有push时自动checkout、准备私钥和运行hexo部署。这一步是配置github action

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
name: Hexo Deploy

on:
push:
branches:
- main # 当你向 main 分支 push 时触发

jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout source
uses: actions/checkout@v4
with:
submodules: true # 如果你用了子模块主题
fetch-depth: 0 # 要读取完整 Git 历史

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '24' # 建议使用稳定版本

- name: Install dependencies
run: npm install

- name: Setup SSH
run: |
mkdir -p ~/.ssh/
echo "${{ secrets.HEXO_DEPLOY_PRI }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
ls -l ~/.ssh/id_rsa # 调试
ssh-keyscan github.com >> ~/.ssh/known_hosts

- name: Deploy
run: |
git config --global user.name "Mintind"
git config --global user.email ""
npx hexo clean
npx hexo generate
npx hexo deploy

设置.gitignore:

1
2
3
4
5
6
7
8
.DS_Store
Thumbs.db
db.json
*.log
node_modules/
public/
.deploy*/
_multiconfig.yml

设置给action用的凭证。因为 GitHub Actions 是在private仓库运行,却需要向public仓库写入。我用的ssh key。生成一对公私钥之后,在public仓库设置-deploy key,添加公钥;私钥放private仓库。push时就会通过私钥来验证private仓库确实有写入权限。

push之后在github页面的action中可以点击查看日志,如果有错误或者失败可以用来核查

image-20260813200914635

整体结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
本地 Hexo 项目

│ git push

Private:Mintind/mintind-hexo-blog

│ GitHub Actions

├─ checkout 源码
├─ npm install
├─ hexo clean
├─ hexo generate
└─ hexo deploy

│ SSH deploy key

Public:Mintind/Mintind.github.io


GitHub Pages


https://mintind.github.io

在不同设备之间的迁移

这个方案的好处,换新设备之后或者有另外的设备需要加笔记的时候,可以直接git clone完整的private仓库,加入笔记之后push

还是那三件套

1
2
3
git add .
git commit -m "update"
git push origin main

修改更新时间机制

博客的更新时间默认机制是mtime,也就是使用Markdown 文件在当前文件系统上的 modification time。在双仓库方案里public仓库的文件都是在push时全部重新生成的,所以会导致每次push所有博客的更新时间都变成刚刚。但我希望这个时间保持为我本地文件最后一次编辑的时间

打算用pre-hook方案,pre-hook就是在commit前执行的hook,用这个hook将每个博客文件的updated time改为文件编辑时间

回退方案:date 会退回文章发布日期,在其他手段失效的时候的fallback。hexo配置文件里改:

1
updated_option: 'date'

添加脚本

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
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
const fs = require('fs');
const { execFileSync, spawnSync } = require('child_process');

const POST_DIRS = [
'source/_posts',
'source/_drafts'
];

function git(args) {
return execFileSync('git', args, {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe']
});
}

function splitNull(text) {
return text.split('\0').filter(Boolean);
}

function isMarkdown(file) {
return /\.md$/i.test(file);
}

function parseFrontMatter(content) {
const match = content.match(
/^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/
);

if (!match) return null;

return {
opening: match[1],
body: match[2],
closing: match[3],
rest: content.slice(match[0].length)
};
}

function getUpdated(content) {
const fm = parseFrontMatter(content);
if (!fm) return null;

const match = fm.body.match(/^updated\s*:\s*(.*?)\s*$/m);
if (!match) return null;

return match[1]
.trim()
.replace(/^['"]|['"]$/g, '');
}

function setUpdated(file, timestamp, preserveStat = null) {
const content = fs.readFileSync(file, 'utf8');
const fm = parseFrontMatter(content);

if (!fm) {
console.warn(`[skip] No front-matter: ${file}`);
return false;
}

const eol = fm.opening.includes('\r\n') ? '\r\n' : '\n';
const updatedLine = `updated: ${timestamp}`;

let newBody;

if (/^updated\s*:.*$/m.test(fm.body)) {
newBody = fm.body.replace(
/^updated\s*:.*$/m,
updatedLine
);
} else {
newBody = fm.body
? `${fm.body}${eol}${updatedLine}`
: updatedLine;
}

const newContent =
fm.opening +
newBody +
fm.closing +
fm.rest;

if (newContent === content) {
return false;
}

fs.writeFileSync(file, newContent, 'utf8');

// pre-commit 自己写 updated 也会改变文件 mtime。
// 因此本地模式需要把原始 mtime 恢复回去。
if (preserveStat) {
fs.utimesSync(
file,
preserveStat.atime,
preserveStat.mtime
);
}

return true;
}

function pad2(value) {
return String(value).padStart(2, '0');
}

function pad3(value) {
return String(value).padStart(3, '0');
}

/*
* 把文件 mtime 表示成带时区的 ISO 时间:
*
* 2026-08-13T20:21:35.123+08:00
*/
function formatLocalIso(date) {
const offsetMinutes = -date.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? '+' : '-';

const absOffset = Math.abs(offsetMinutes);
const offsetHour = Math.floor(absOffset / 60);
const offsetMinute = absOffset % 60;

return (
`${date.getFullYear()}-` +
`${pad2(date.getMonth() + 1)}-` +
`${pad2(date.getDate())}T` +
`${pad2(date.getHours())}:` +
`${pad2(date.getMinutes())}:` +
`${pad2(date.getSeconds())}.` +
`${pad3(date.getMilliseconds())}` +
`${sign}${pad2(offsetHour)}:${pad2(offsetMinute)}`
);
}

/*
* 本地模式
*
* git commit
* ↓
* 找出这次 staged 的 Markdown
* ↓
* 读取文件真实 mtime
* ↓
* 写 updated
* ↓
* 再次 git add
*/
function localMode() {
const files = splitNull(
git([
'diff',
'--cached',
'--name-only',
'--diff-filter=ACMR',
'-z',
'--',
...POST_DIRS
])
).filter(isMarkdown);

if (files.length === 0) {
return;
}

/*
* 先检查:
* 如果一个 staged 文件还有 unstaged 修改,
* 不自动 git add,避免把用户原本不想提交的修改偷偷提交进去。
*/
const unsafeFiles = [];

for (const file of files) {
if (!fs.existsSync(file)) continue;

const result = spawnSync(
'git',
['diff', '--quiet', '--', file],
{ stdio: 'ignore' }
);

if (result.status === 1) {
unsafeFiles.push(file);
} else if (result.status !== 0) {
console.error(`git diff failed: ${file}`);
process.exit(1);
}
}

if (unsafeFiles.length > 0) {
console.error(
'\nCommit aborted: these staged Markdown files also contain unstaged changes:'
);

for (const file of unsafeFiles) {
console.error(` ${file}`);
}

console.error(
'\nPlease git add them again, or handle the unstaged changes first.\n'
);

process.exit(1);
}

for (const file of files) {
if (!fs.existsSync(file)) continue;

// 必须在修改 front-matter 之前读取
const stat = fs.statSync(file);
const timestamp = formatLocalIso(stat.mtime);

const changed = setUpdated(
file,
timestamp,
stat
);

if (changed) {
git(['add', '--', file]);
}

console.log(
`[updated] ${file} -> ${timestamp}`
);
}
}

function readFileAtRevision(revision, file) {
try {
return git([
'show',
`${revision}:${file}`
]);
} catch {
return null;
}
}

/*
* GitHub Actions 模式
*
* 用于 GitHub 网页直接编辑。
*
* 网页编辑没有本地 filesystem mtime,
* 所以如果这次修改没有同时修改 updated,
* 就使用该文件最后一次 Git commit 时间作为兜底。
*/
function ciMode() {
const before = process.env.BEFORE_SHA;
const after =
process.env.AFTER_SHA || process.env.GITHUB_SHA || 'HEAD';

if (!before || !after) {
console.error(
'CI mode requires BEFORE_SHA and AFTER_SHA.'
);
process.exit(1);
}

let files;

if (/^0+$/.test(before)) {
files = splitNull(
git([
'show',
'--pretty=format:',
'--name-only',
'-z',
after,
'--',
...POST_DIRS
])
);
} else {
files = splitNull(
git([
'diff',
'--name-only',
'--diff-filter=ACMR',
'-z',
before,
after,
'--',
...POST_DIRS
])
);
}

files = files.filter(isMarkdown);

for (const file of files) {
if (!fs.existsSync(file)) continue;

const currentContent =
fs.readFileSync(file, 'utf8');

const currentUpdated =
getUpdated(currentContent);

const previousContent =
/^0+$/.test(before)
? null
: readFileAtRevision(before, file);

/*
* 新文件已经自带 updated:
* 大概率来自本地 pre-commit,保留。
*/
if (
previousContent === null &&
currentUpdated !== null
) {
console.log(
`[keep] ${file}: already contains updated`
);
continue;
}

const previousUpdated =
previousContent === null
? null
: getUpdated(previousContent);

/*
* 如果此次 commit 本身已经修改了 updated,
* 说明:
* - 本地 hook 已处理,或
* - 用户手动指定
*
* 都不要覆盖。
*/
if (
previousContent !== null &&
currentUpdated !== previousUpdated
) {
console.log(
`[keep] ${file}: updated changed in commit`
);
continue;
}

/*
* 到这里说明内容修改了,但 updated 没动。
* 典型场景就是 GitHub 网页编辑。
*/
const timestamp = git([
'log',
'-1',
'--format=%cI',
after,
'--',
file
]).trim();

if (!timestamp) {
console.warn(
`[skip] Cannot determine commit time: ${file}`
);
continue;
}

if (setUpdated(file, timestamp)) {
console.log(
`[CI fallback] ${file} -> ${timestamp}`
);
}
}
}

if (process.argv.includes('--ci')) {
ciMode();
} else {
localMode();
}

添加hook。新建一个文件夹,里面放hook,让它去执行改时间脚本:

1
2
3
#!/bin/sh

exec node scripts/update-post-times.js

让仓库去它所在的目录找hook。因为默认的hook路径是.git/hooks/ ,但是因为./git文件一般不会被push到仓库,就备份不了,为了能备份我们可以改个hook目录:

1
git config core.hooksPath .githooks

git add了之后,在 Git 索引里把这个文件标记成可执行文件。

1
git update-index --chmod=+x .githooks/pre-commit

如果换设备了还得执行git config core.hooksPath .githooks,可以配置成自动添加。在package.json的scripts里加上这句,如:

1
2
3
4
5
6
7
"scripts": {
"build": "hexo generate",
"clean": "hexo clean",
"deploy": "hexo deploy",
"server": "hexo server",
"setup-hooks": "git config core.hooksPath .githooks"
},

这样初始化会自动改hook目录。

兜底。如果是网页编译不会触发pre commit。在deploy.yml,安装依赖之前添加:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# GitHub 网页直接编辑时,本地 pre-commit 不会运行。
# 因此这里负责兜底。
- name: Sync post updated times
env:
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.sha }}
run: |
node scripts/update-post-times.js --ci

if ! git diff --quiet -- source; then
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"

git add -A -- source
git commit -m "chore: sync post updated time"
git push origin HEAD:main
fi

草稿 _drafts

hexo的草稿方案:

1
2
3
4
5
6
7
8
source/
├── _posts/
│ ├── 已发布文章.md
│ └── 已发布文章2.md

└── _drafts/
├── 写了一半.md
└── 随手记录.md

正常执行 hexo generate 时,draft 默认不会被生成到公开站点;只有使用 --draft,或者把 _config.yml 中的 render_drafts 打开,才会渲染草稿。

也可以在 _config.yml 里写死:

1
render_drafts: false

想连草稿一起预览可以:

1
npx hexo server --draft

正式发布:

可以手动移动到_posts,或者使用publish命令

1
npx hexo publish "my-article"

博客在线编辑

实际上是在点击编辑按钮时跳转到对应的github仓库里的markdown源码,进行编辑,可能会有点不太方便。不过对这个双仓库正好,把编辑链接指向private仓库,这样就能做成只有我能编辑的效果

直接在next配置文件里设置:

1
2
3
post_edit:
enable: true
url: https://github.com/Mintind/private/edit/main/source/

完成之后点击就到编辑页面了。如果没正确跳转检查一下是不是链接填的有问题

完全私密笔记

不打算发布的,这个可以放到source文件夹之外,不属于hexo的内容树。比如在根目录下新建一个文件夹存放。这个只会传到private仓库,不会发布