2025-11-24-Mon-T-博客文章同步脚本

背景:几年前我通过hexo博客框架自己搭了一套自己的博客,托管在github io服务器上。最近我在博客园也开通了博客,用作backup。

如果手动地同步github io上的文章到博客园,工作量有点大,并且github io文章中引用的图片大部分也在自定义的github图床上,如果github图床出问题,博客园文章也会受影响。

因此我打算将github io上的文章全量同步至博客园,并且将文章中的图片也上传至博客园,同时替换图片引用。

博客园账号

开通博客园博客高级功能后, 在博客园-->设置-->博客设置-->页脚HTML代码-->其他设置 开通允许MetaWeblog博客客户端访问。复制登录名和访问令牌。将其保存在config.json文件中, 例如:

1
2
3
4
5
6
7
8
{
"url": "https://rpc.cnblogs.com/metaweblog/fei" , // metaweblog访问地址,需要修改为自己账号对应的地址
"username": "fei",
"password": "访问令牌",
"hexo_root_path": "D:/Users/fei/gitrepo/myblog/myblog", // hexo博客根目录
"bash_path": "D:\\Program Files\\Git\\bin\\bash.exe", // windows githash 程序目录
"hexo_path": "/d/Users/fei/gitrepo/myblog/node_modules/.bin/hexo" // hexo命令目录
}

图片上传与文章同步脚本 - python

将hexo中的文章同步至博客园,再将hexo 重新部署。这样再通过typora进行脚本执行时,就可以完成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
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
import xmlrpc.client
import ssl
import os
import json
import sys
import re # 导入 re 模块用于正则表达式操作
import requests # 需要安装 requests 库:pip install requests
import subprocess # 【新增】导入 subprocess 模块用于执行系统命令
import shlex # 【新增】用于安全地引用字符串
import time # 【新增】导入 time 模块用于休眠

# 设置标准输出编码为 UTF-8,以支持 Emoji 和 Unicode 字符
if sys.platform.startswith('win'):
sys.stdout.reconfigure(encoding='utf-8')


ssl._create_default_https_context = ssl._create_unverified_context

rootPath = os.path.abspath(os.path.dirname(__file__))
with open(os.path.join(rootPath,"config.json"),"rb") as f:
config = json.loads(f.read())

# 用于匹配所有图片引用的正则表达式
# Group 1: Alt text
# Group 2: 图片路径或URL
ALL_IMAGE_PATTERN = re.compile(r'!\[(.*?)\]\((.*?)\)')


POST_DELAY_SECONDS = 3.5 # 推荐 3 到 4 秒,确保每分钟发文少于 20 篇


# ----------------------------------------------------------------------------------
# 【新增】获取最新文章列表并查找目标文章 ID 的函数
# ----------------------------------------------------------------------------------
def getExistingPostId(title):
"""
通过检查最新文章列表,查找给定标题的文章 ID。

注意:MetaWeblog API 只能获取最近的 N 篇文章 (例如 20 篇)。
如果文章非常旧,可能无法找到。N 的值通常由博客平台决定。
"""
print(f"🔄 尝试在最近的文章中查找标题为 '{title}' 的文章...")

# 博客园 MetaWeblog API URL
url = config["url"]
username = config['username']
password = config['password']

# 获取最近文章的篇数,例如获取最近 50 篇
NUMBER_OF_POSTS_TO_CHECK = 50

proxy = xmlrpc.client.ServerProxy(url)
try:
# 调用 getRecentPosts 方法
recent_posts = proxy.metaWeblog.getRecentPosts(
'', username, password, NUMBER_OF_POSTS_TO_CHECK
)

for post in recent_posts:
# post 字典中的 'title' 键存储文章标题
if post['title'] == title:
print(f"✅ 找到已存在的文章!Post ID: {post['postid']}")
# post['postid'] 存储文章的唯一标识符
return post['postid']

print("➡️ 未找到同名文章,将创建新文章。")
return None

except Exception as e:
print(f"❌ 查找现有文章时出错: {e}")
return None

# ----------------------------------------------------------------------------------
# 【修改】uploadArticle 函数,实现创建或更新逻辑
# ----------------------------------------------------------------------------------
def uploadArticle(articles):
for article in articles:
# ... (读取文件内容和图片处理逻辑保持不变) ...
with open(article,"r",encoding="utf8") as f:
original_data = f.read()

# data_to_post = original_data
data_to_post = original_data

# 1. 获取当前MD文件所在目录,用于解析本地图片的相对路径
md_dir = os.path.dirname(article)

# 2. 查找所有的图片引用
# 使用 finditer 可以找到所有匹配项及其在字符串中的位置
matches = list(ALL_IMAGE_PATTERN.finditer(data_to_post))

# 使用字典来存储已上传图片的映射,避免重复上传相同的图片
uploaded_map = {}

# 3. 逐个处理图片
for match in matches:
alt_text = match.group(1)
source_path = match.group(2) # 原始的路径/URL
original_full_match = match.group(0) # 完整的 ![alt](path) 字符串

# 如果图片路径已经在映射中,则直接使用已上传的URL
if source_path in uploaded_map:
new_url = uploaded_map[source_path]
print(f"➡️ 图片已处理: {source_path},使用缓存URL: {new_url}")
else:
# 确定图片在本地文件系统中的绝对路径或保持网络URL
if source_path.startswith('http'):
upload_target = source_path
else:
# 相对路径转绝对路径
upload_target = os.path.normpath(os.path.join(md_dir, source_path))

# 4. 调用上传函数
new_url = uploadImage(upload_target)

if new_url:
uploaded_map[source_path] = new_url # 存入缓存
else:
print(f"⚠️ 图片 {source_path} 上传失败,将保留原地址。")
continue

# 5. 替换文章内容中的链接
# 构建新的引用字符串
new_image_reference = f"![{alt_text}]({new_url})"
# 替换内容,注意我们是在 data_to_post 中替换
data_to_post = data_to_post.replace(original_full_match, new_image_reference, 1)

# 假设图片处理成功完成, data_to_post 现在包含博客园的图片 URL

# 获取标题 (作为查找和发布的依据)
title = os.path.basename(article)[:-3]



# 1. 检查文章是否已存在
post_id = getExistingPostId(title)

# 2. 构造文章数据
post = dict(
# dateCreated 仅对 newPost 有意义,editPost 不需要
description = data_to_post,
title = title,
categories = ['[Markdown]'],
)

proxy = xmlrpc.client.ServerProxy(config["url"])
userName = config["url"].split("/")[-1]

if post_id:
# 3. 文章已存在:执行更新 (editPost)
try:
# metaWeblog.editPost(postid, username, password, struct post, bool publish)
success = proxy.metaWeblog.editPost(
post_id, config['username'], config['password'], post, True
)
if success:
print(f"🎉 文章更新成功 (Post ID: {post_id})")
else:
raise Exception("API 返回 False")

# 构造博客园文章链接
article_url = f"https://www.cnblogs.com/{userName}/p/{post_id}.html"

except Exception as e:
print(f"❌ 文章更新失败 (editPost): {e}")
continue # 处理下一个文件
else:
# 4. 文章不存在:执行创建 (newPost)
try:
post['dateCreated'] = xmlrpc.client.DateTime() # 确保新文章有日期
# metaWeblog.newPost(blogid, username, password, struct post, bool publish)
new_post_id = proxy.metaWeblog.newPost(
'', config['username'], config['password'], post, True
)
print(f"🎉 文章创建成功!新 Post ID: {new_post_id}")

# 构造博客园文章链接
article_url = f"https://www.cnblogs.com/{userName}/p/{new_post_id}.html"

except Exception as e:
print(f"❌ 文章创建失败 (newPost): {e}")
# 如果创建失败,可能需要更长的等待时间再尝试下一个,或者直接跳过
print(f"⚠️ 遇到频率限制,暂停 {POST_DELAY_SECONDS * 2} 秒...")
time.sleep(POST_DELAY_SECONDS * 2)
continue # 处理下一个文件

print(f"✅ 文章发布/更新成功: {article_url}")
# 5. 【关键】在处理完一篇文章后,添加强制延迟
print(f"⏸️ 暂停 {POST_DELAY_SECONDS} 秒,以避免频率限制...")
time.sleep(POST_DELAY_SECONDS)



# 确保在函数定义外部定义了 deployHexo 或将其注释掉,以防报错

# 将 Windows 绝对路径 hexo_root_path 转换为 Git Bash 兼容的路径
def to_bash_path(win_path):
"""
将 Windows 绝对路径转换为 Git Bash/MinGW 路径。
例如: C:/Users/name/ -> /c/Users/name/
"""
if not win_path:
return win_path

# 统一斜杠方向
normalized_path = win_path.replace('\\', '/')

# 检查是否以驱动器字母开头 (如 D:/ 或 C:/)
if re.match(r'^[A-Za-z]:/', normalized_path):
# 转换为 /d/Users/fei...
drive = normalized_path[0].lower()
rest_of_path = normalized_path[2:]
return f'/{drive}{rest_of_path}'

return normalized_path # 如果不是驱动器路径,则保持不变

# 定义 Hexo 命令的执行函数
def deployHexo():
"""
执行 Hexo 部署命令 (hexo clean, hexo g, hexo d),使用 Bash -c 选项进行安全封装。
"""
hexo_root_path_win = config.get("hexo_root_path")
bash_executable_path = config.get("bash_path")
HEXO_EXE_PATH = config.get("hexo_path")

# ... (路径检查代码保持不变) ...

# 1. 将 Windows 路径转换为 Bash 可识别的路径
hexo_root_path_bash = to_bash_path(hexo_root_path_win)


# 2. 构造所有命令,使用 Bash 风格的 && 串联
# /d/Users/fei/gitrepo/myblog/node_modules/.bin/hexo
commands_to_run = " && ".join([
f"{HEXO_EXE_PATH} clean",
f"{HEXO_EXE_PATH} generate",
f"{HEXO_EXE_PATH} deploy"
])

# 3. 构造要传给 Bash -c 的内部命令。
# 我们使用 Bash 语法来封装整个命令。
internal_command = f'cd {shlex.quote(hexo_root_path_bash)} && {commands_to_run}'

# 4. 构造最终的执行命令:调用 Bash,并用 -c 传递 internal_command
# 这里的关键是:我们不再依赖 shell=True 和 executable 的复杂结合。
# 我们直接构造一个命令列表 (list of strings) 让 subprocess 执行 Bash。

# 确保 Bash 路径被安全引用 (Windows路径中的空格问题)
quoted_bash_path = shlex.quote(bash_executable_path)

# 构造命令列表: [Bash可执行文件, -c, "内部命令"]
cmd_list = [
quoted_bash_path.strip("'\""), # 移除shlex可能添加的引号,确保Windows能找到文件
"-c",
internal_command # 内部命令包含 cd 和 hexo 串联
]

print("\n--- 🚀 开始执行 Hexo 部署流程 (使用 Bash 列表模式) ---")
print(f"Bash Command List: {cmd_list}")

try:
# 关键修改:
# 1. 传入命令列表 (cmd_list),而不是单个命令字符串
# 2. 移除 shell=True 和 executable=... 参数
# 这样做能最大限度地避免 Windows Shell 的解析干扰
result = subprocess.run(
cmd_list,
check=True,
capture_output=True,
text=True,
encoding='utf-8'
)
print("✅ Hexo 部署命令全部成功完成。")

except subprocess.CalledProcessError as e:
print(f"❌ Bash 命令执行失败,请检查 Hexo 错误。")
print(f"Stdout:\n{e.stdout}")
print(f"Stderr:\n{e.stderr}")
print("--- 终止 Hexo 部署流程 ---")
return
except FileNotFoundError:
print(f"❌ 错误: 找不到 Bash 可执行文件: {quoted_bash_path}")
print("--- 终止 Hexo 部署流程 ---")
return
except Exception as e:
print(f"❌ 发生未知错误:{e}")
print("--- 终止 Hexo 部署流程 ---")
return

print("--- 🎉 Hexo 部署流程全部完成! ---")
def uploadImage(image_path_or_url):
"""
上传单个图片,无论是本地路径还是网络URL。

参数:
image_path_or_url: 本地图片路径或网络图片的URL。

返回:
成功上传后返回的博客园图片URL字符串;失败则返回 None。
"""
# 确定要上传的文件名和数据
if image_path_or_url.startswith('http'):
# **处理网络图片**:需要先下载图片
try:

response = requests.get(image_path_or_url, stream=True)
response.raise_for_status() # 检查请求是否成功

# 尝试从 URL 提取文件名和后缀
baseName = image_path_or_url.split('/')[-1].split('?')[0]
# 如果 URL 结尾没有明显文件名,则给一个默认名
if not baseName or '.' not in baseName:
baseName = f"remote_image_{os.urandom(4).hex()}.png"

imageData = response.content
suffix = baseName.split(".")[-1]
print(f"🌍 正在下载并上传网络图片: {image_path_or_url}")

except Exception as e:
print(f"❌ 错误:无法下载或处理网络图片 {image_path_or_url}: {e}")
return None
else:
# **处理本地图片**:使用原来的逻辑
if not os.path.exists(image_path_or_url):
print(f"⚠️ 警告:本地图片文件不存在,跳过上传:{image_path_or_url}")
return None

with open(image_path_or_url,"rb") as f:
imageData = f.read()

baseName = os.path.basename(image_path_or_url)
suffix = baseName.split(".")[-1]
print(f"🖼️ 正在上传本地图片: {image_path_or_url}")


file = dict(
bits = imageData,
name = baseName,
type = f"image/{suffix}"
)
try:
proxy = xmlrpc.client.ServerProxy(config["url"])
s = proxy.metaWeblog.newMediaObject('', config['username'], config['password'],file)
print(f"✨ 上传成功!新URL: {s['url']}")
return s["url"]
except Exception as e:
print(f"❌ 图片上传到博客园失败: {e}")
return None




if __name__ == '__main__':
# 请确保在运行前安装 requests 库:pip install requests
if len(sys.argv) <= 1:
print("请提供要上传的 Markdown 文件路径作为命令行参数。")
else:
uploadArticle(sys.argv[1:])
# 5. 自动执行 Hexo 部署 (如果已添加此功能)
deployHexo()

Typora设置导出命令

在Typora中设置自定义的文件导出命令:

1
`python D:\Users\fei\gitrepo\myblog\cnblogs_uploader\uploader.py ${currentPath}`

这样通过这个自定义导出,就可以完成两处的博客文章上传。如果有扩展,再修改脚本逻辑,即可实现多平台文章同步。

批量同步

由于前期积累了大量的文章未同步至博客园中,因此可以编写脚本批量将这些文章同步至博客园:

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
from uploader import uploadArticle # 导入刚刚编写上传文章方法。
import os
import glob
import sys
import time # 【新增】导入 time 模块用于休眠
# ... (其他 imports) ...

# -----------------------------------------------------------
# 配置 Markdown 文件所在的根文件夹
MARKDOWN_FOLDER_PATH = os.path.abspath("D:\\Users\\fei\\gitrepo\\myblog\\myblog\\source\\_posts")
# -----------------------------------------------------------


def run_batch_upload(folder_path):
"""
扫描指定文件夹,批量上传所有找到的 Markdown 文件。
"""

# 解决 Windows 编码输出问题(如果适用)
if sys.platform.startswith('win'):
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass

print(f"🔍 正在扫描文件夹: {folder_path} 查找 Markdown 文件...")

# 使用 glob 递归查找所有 .md 文件
# recursive=True 允许 glob 查找子文件夹
md_files = glob.glob(os.path.join(folder_path, '**', '*.md'), recursive=True)

if not md_files:
print("❌ 未找到任何 .md 文件,请检查 MARKDOWN_FOLDER_PATH 配置是否正确。")
else:
print(f"✅ 找到 {len(md_files)} 个 Markdown 文件,开始上传...")

# 调用导入的 uploadArticle 函数批量处理这些文件
uploadArticle(md_files)

print("\n\n🎉 所有任务处理完成!")


if __name__ == '__main__':
run_batch_upload(MARKDOWN_FOLDER_PATH)