博客园文章导入Hexo

获取文章

在博客园后台可以选择备份下载所有文章。

很久以前可以以 md 形式导出(现在不行了),而我那个时候导出过,所以我就有所有原始md文件

然后再以 xml 形式导出,这是为了补充我们前面的yml。


接下来我们把xml和md进行合并:

首先保证目录结构是这样子的:

1
2
3
4
5
6
7
8
9
D:\cnblog\
├─ cnblogs_backup.xml # 博客园后台导出的官方备份文件
├─ md_original/ # 你之前下载好的所有MD原稿(带子目录分类也没关系)
│ ├─ 算法/
│ │ └─ DP/
│ │ └─ 背包问题九讲.md
│ └─ 日常随笔/
│ └─ 我的2024总结.md
└─ (脚本放这里)

然后我们新建Python脚本。

注意xml中非法字符、不兼容格式,同时每篇文章后面有自带作者的固定后缀,这些都需要我们在脚本中处理:

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
import os
import re
import difflib
import xml.etree.ElementTree as ET
from datetime import datetime

# ========== 配置区 ==========
XML_PATH = "cnblogs_backup.xml"
MD_SOURCE_DIR = "md_original"
OUTPUT_DIR = "hexo_posts"
# ========================

def is_valid_xml_char(c):
code = ord(c)
return (
code == 0x9 or code == 0xA or code == 0xD or
0x20 <= code <= 0xD7FF or
0xE000 <= code <= 0xFFFD or
0x10000 <= code <= 0x10FFFF
)

def clean_xml_text(raw_text):
"""清洗XML非法字符和字符实体"""
def replace_dec_entity(match):
code = int(match.group(1))
try:
char = chr(code)
return char if is_valid_xml_char(char) else ""
except:
return ""
raw_text = re.sub(r'&#(\d+);', replace_dec_entity, raw_text)

def replace_hex_entity(match):
code = int(match.group(1), 16)
try:
char = chr(code)
return char if is_valid_xml_char(char) else ""
except:
return ""
raw_text = re.sub(r'&#x([0-9a-fA-F]+);', replace_hex_entity, raw_text)

return "".join([c for c in raw_text if is_valid_xml_char(c)])

def clean_title(title):
"""统一标题清洗:去除博客园后缀、特殊字符,用于匹配"""
# 1. 移除末尾固定后缀 -zhangtingxi
title = re.sub(r'-zhangtingxi\s*$', '', title, flags=re.IGNORECASE)
# 2. 移除开头所有 [xxx] 标记(草稿、置顶等)
title = re.sub(r'^\[[^\]]+\]\s*', '', title)
# 3. 全角转半角、统一小写
title = title.lower()
# 4. 只保留中文、字母、数字,剔除所有标点符号、空格、特殊字符
return re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9]', '', title)

def parse_pub_date(date_str):
"""兼容Atom/RSS多种时间格式"""
formats = [
"%a, %d %b %Y %H:%M:%S %Z",
"%Y-%m-%dT%H:%M:%SZ",
"%Y-%m-%dT%H:%M:%S+08:00",
"%Y-%m-%d %H:%M:%S"
]
for fmt in formats:
try:
dt = datetime.strptime(date_str, fmt)
return dt.strftime("%Y-%m-%d %H:%M:%S")
except:
continue
return date_str

def parse_xml_meta(xml_path):
"""解析Atom格式XML,提取元数据"""
with open(xml_path, "r", encoding="utf-8") as f:
raw_text = f.read()
clean_text = clean_xml_text(raw_text)
root = ET.fromstring(clean_text)

# 查找所有文章节点(忽略命名空间)
items = []
for node in root.iter():
tag = node.tag.split('}')[-1]
if tag in ("item", "entry"):
items.append(node)

if not items:
raise Exception("未找到任何文章节点")

meta_map = {}
for item in items:
# 提取节点文本(忽略命名空间)
def get_text(tag_name):
for child in item:
if child.tag.split('}')[-1] == tag_name:
return (child.text or "").strip()
return ""

# 提取节点属性
def get_attr(tag_name, attr_name):
for child in item:
if child.tag.split('}')[-1] == tag_name:
return child.get(attr_name, "").strip()
return ""

raw_title = get_text("title")
link = get_attr("link", "href") or get_text("link")
pub_date = get_text("published") or get_text("updated") or get_text("pubDate")
date = parse_pub_date(pub_date)

# 提取分类与标签(Atom格式在term属性中)
categories_raw = []
for child in item:
if child.tag.split('}')[-1] == "category":
term = child.get("term", "").strip()
if term:
categories_raw.append(term)

# 分离分类层级(带/)和普通标签
cat_paths = [c for c in categories_raw if "/" in c]
tags = [c for c in categories_raw if "/" not in c]

if cat_paths:
categories = [p.strip() for p in cat_paths[-1].split("/") if p.strip()]
else:
categories = [categories_raw[0]] if categories_raw else ["未分类"]

# 清洗后的标题作为匹配键
key = clean_title(raw_title)
# 最终标题去掉后缀,用于Hexo显示
final_title = re.sub(r'-zhangtingxi\s*$', '', raw_title, flags=re.IGNORECASE)

meta_map[key] = {
"title": final_title,
"date": date,
"link": link,
"tags": tags,
"categories": categories
}

print(f"✅ 从XML中解析到 {len(meta_map)} 篇文章元数据")
# 打印3个清洗后的样例,方便核对
print("📌 XML清洗后标题样例:")
for i, k in enumerate(list(meta_map.keys())[:3]):
print(f" {i+1}. {meta_map[k]['title']} → 匹配键: {k}")
return meta_map

def generate_hexo_post(md_path, meta, output_dir):
"""生成Hexo标准文章"""
with open(md_path, "r", encoding="utf-8") as f:
content = f.read()

fm = ["---"]
fm.append(f'title: "{meta["title"]}"')
fm.append(f"date: {meta['date']}")

fm.append("tags:")
if meta["tags"]:
for tag in meta["tags"]:
fm.append(f" - {tag}")
else:
fm.append(" - []")

fm.append("categories:")
for cat in meta["categories"]:
fm.append(f" - {cat}")

fm.append("---")
fm.append("")

notice = f"**本文搬运自本人博客园博客,若图片加载不出来,可到原文查看:[{meta['link']}]({meta['link']})**\n\n"
final_content = "\n".join(fm) + notice + content

filename = os.path.basename(md_path)
out_path = os.path.join(output_dir, filename)
os.makedirs(output_dir, exist_ok=True)
with open(out_path, "w", encoding="utf-8") as f:
f.write(final_content)

def main():
meta_map = parse_xml_meta(XML_PATH)

# 遍历所有MD文件
md_files = []
for root, dirs, files in os.walk(MD_SOURCE_DIR):
for file in files:
if file.endswith(".md"):
md_files.append(os.path.join(root, file))

print(f"\n🔍 找到 {len(md_files)} 个本地MD文件")
print("📌 本地文件名清洗后样例:")
for i, path in enumerate(md_files[:3]):
name = os.path.splitext(os.path.basename(path))[0]
print(f" {i+1}. {name} → 匹配键: {clean_title(name)}")

success_count = 0
failed_files = []
all_meta_keys = list(meta_map.keys())

for md_path in md_files:
filename = os.path.basename(md_path)
title_raw = os.path.splitext(filename)[0]
title_key = clean_title(title_raw)

# 1. 精准匹配
meta = meta_map.get(title_key)

# 2. 精准失败,用模糊匹配兜底(相似度≥85%)
if not meta:
matches = difflib.get_close_matches(title_key, all_meta_keys, n=1, cutoff=0.85)
if matches:
meta = meta_map[matches[0]]

if not meta:
failed_files.append(filename)
continue

generate_hexo_post(md_path, meta, OUTPUT_DIR)
success_count += 1

print(f"\n🎉 转换完成!成功 {success_count} 篇,匹配失败 {len(failed_files)} 篇")
print(f"输出目录:{os.path.abspath(OUTPUT_DIR)}")

if failed_files:
with open("匹配失败清单.txt", "w", encoding="utf-8") as f:
f.write("以下文件未匹配到元数据,请手动核对:\n")
for name in failed_files:
f.write(name + "\n")
print("⚠️ 匹配失败的文件已写入「匹配失败清单.txt」")

if __name__ == "__main__":
main()

注意,最好手动删除私密文章

分类迁移

我们发现xml里面没有文章的分类,而且这个分类是前段js渲染的,requests 静态请求无法获取。

于是我们可以考虑用 分类列表页反映射法

我们先打开我们博客园的首页,把我们所有的复制下来,包含链接,丢到一份md里。

然后运行脚本!

先安装环境依赖:

1
pip install requests beautifulsoup4

然后运行:

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
import os
import re
import time
import requests
from bs4 import BeautifulSoup

# ====================== 配置 ======================
CAT_LIST_FILE = "cats.md"
SLEEP_SEC = 1.0
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0 Safari/537.36",
"Accept-Language": "zh-CN,zh;q=0.9"
}
# ===================================================

def parse_cat_md():
"""解析分类清单,提取分类名+分类页链接"""
if not os.path.exists(CAT_LIST_FILE):
raise FileNotFoundError(f"未找到分类清单文件 {CAT_LIST_FILE}")
with open(CAT_LIST_FILE, "r", encoding="utf-8") as f:
text = f.read()
pattern = r"- \[(.*?)\]\((https://www\.cnblogs\.com/zhangtingxi/category/\d+\.html)\)"
result = re.findall(pattern, text)
cat_info = [(name.strip(), url) for name, url in result]
print(f"✅ 成功读取 {len(cat_info)} 个分类\n")
return cat_info

def get_all_article_urls(cat_name, base_cat_url):
"""分页抓取单个分类下的所有文章链接"""
article_urls = set()
page = 1
while True:
page_url = f"{base_cat_url}?page={page}"
try:
resp = requests.get(page_url, headers=HEADERS, timeout=12)
resp.encoding = "utf-8"
soup = BeautifulSoup(resp.text, "html.parser")
a_tags = soup.select(".postTitle a, .entrylistPosttitle a")
if not a_tags:
break
for a in a_tags:
article_urls.add(a["href"].strip())
pager = soup.select_one(".pager")
if not pager or "下一页" not in pager.get_text():
break
page += 1
time.sleep(SLEEP_SEC)
except Exception as e:
print(f"❌ 分类【{cat_name}】第{page}页失败:{e}")
break
print(f"📂 {cat_name} → 共 {len(article_urls)} 篇文章")
return list(article_urls)

def build_url_cat_map(cat_info_list):
"""构建 文章URL → 分类列表 映射表"""
url_to_categories = {}
for cat_name, cat_url in cat_info_list:
art_urls = get_all_article_urls(cat_name, cat_url)
for art_url in art_urls:
if art_url not in url_to_categories:
url_to_categories[art_url] = []
url_to_categories[art_url].append(cat_name)
print(f"\n✅ 映射表构建完成,共收录 {len(url_to_categories)} 篇文章")
return url_to_categories

def build_cat_yaml(cat_list):
"""生成指定的多行 YAML 分类格式"""
if not cat_list:
return "categories: []\n"
lines = ["categories:"]
for name in cat_list:
lines.append(f"- {name}")
return "\n".join(lines) + "\n"

def fill_md_categories(url_map):
"""批量回填本地 md 文件的 categories 字段"""
md_files = [f for f in os.listdir(".") if f.endswith(".md") and f != CAT_LIST_FILE]
success_count = 0
print(f"\n开始处理本地 {len(md_files)} 篇Markdown文件\n")

# 兼容所有格式的 categories / tags 块:单行数组、多行无缩进、多行带缩进
cat_block_pattern = r"categories:\s*(?:\[.*?\]|(?:\n\s*- .+)+)\n?"
tag_block_pattern = r"(tags:\s*(?:\[.*?\]|(?:\n\s*- .+)+))"

for md_name in md_files:
md_path = os.path.join(".", md_name)
with open(md_path, "r", encoding="utf-8") as f:
content = f.read()

match = re.search(r"original:\s*(https?://www\.cnblogs\.com/.+?\.html)", content)
if not match:
print(f"⚠️ {md_name} 无original链接,跳过")
continue

art_url = match.group(1).strip()
cats = url_map.get(art_url, [])
new_cat_block = build_cat_yaml(cats)

# 优先替换已有分类,没有则插入到 tags 后方
if re.search(cat_block_pattern, content):
content = re.sub(cat_block_pattern, new_cat_block, content)
else:
content = re.sub(tag_block_pattern, rf"\1\n{new_cat_block}", content)

with open(md_path, "w", encoding="utf-8") as f:
f.write(content)
success_count += 1
print(f"✅ {md_name} | {cats}")

print(f"\n🎉 全部处理完毕,成功回填 {success_count} 篇文章")

if __name__ == "__main__":
cat_info = parse_cat_md()
url_cat_map = build_url_cat_map(cat_info)
fill_md_categories(url_cat_map)

处理

我们发现现在的处理有点乱,我们希望实现以下功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
1. 删去title两端的双引号和一些/\等不能在yml中出现的符号
2. 删除original字段
3. 把所有categories移动到tag中,并删去末尾(1)这样的括号内含数字的内容
4. 移动完后,删去所有以OJ开头的tag
5. 对于数学-组合-快速幂此类tag,拆分成3个tag,即:
- 数学
- 组合
- 快速幂
(注意,所有tag始终以换行然后- 开头的形式存在)
6. 对所有tag进行去重
7. 把所有categories统一改成:
- 初中OI
- 202x-202x赛季(这个具体哪个赛季你根据date字段定,2020年9月-2021年6月是2020-2021赛季,2021年7月-2022年6月是2021-2022赛季,2022年7月往后是2022-2023赛季)
8. 删去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
#!/usr/bin/env python3
"""
批量修改当前目录下所有 .md 文件的 Hexo front matter。
请先安装 PyYAML: pip install pyyaml
"""

import os
import re
import glob
from datetime import datetime

try:
import yaml
except ImportError:
print("错误:需要 PyYAML 库。请执行 pip install pyyaml 后重试。")
exit(1)


def get_season(date_str):
"""根据日期字符串 '2021-12-16 10:39:00' 计算赛季。"""
try:
dt = datetime.strptime(date_str, '%Y-%m-%d %H:%M:%S')
except ValueError:
# 如果日期格式不匹配,返回默认
return '未知赛季'

y, m = dt.year, dt.month
if (y == 2020 and m >= 9) or (y == 2021 and m <= 6):
return '2020-2021赛季'
elif (y == 2021 and m >= 7) or (y == 2022 and m <= 6):
return '2021-2022赛季'
elif (y == 2022 and m >= 7) or y > 2022:
return '2022-2023赛季'
else:
# 早于 2020 年 9 月的日期统一设为 '早期赛季' 或按需调整
return '早期赛季'


def clean_title(title):
"""去掉两端双引号,并删除 \ 和 / 字符。"""
if not isinstance(title, str):
return title
t = title.strip()
if t.startswith('"') and t.endswith('"'):
t = t[1:-1]
# 删除所有反斜杠和斜杠
t = t.replace('\\', '').replace('/', '')
return t


def process_file(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()

# 分离 front matter 和正文
if not content.startswith('---'):
print(f"跳过(无 front matter):{filepath}")
return

parts = content.split('---', 2)
if len(parts) < 3:
print(f"跳过(front matter 不完整):{filepath}")
return

front_matter_str = parts[1]
body = parts[2] # 包含开头的换行

# 解析 YAML
try:
data = yaml.safe_load(front_matter_str)
except yaml.YAMLError as e:
print(f"跳过(YAML 解析错误):{filepath},错误:{e}")
return

if not isinstance(data, dict):
print(f"跳过(front matter 不是字典):{filepath}")
return

# ---------- 修改数据 ----------
# 1. 处理 title
if 'title' in data:
data['title'] = clean_title(data['title'])

# 2. 删除 original
data.pop('original', None)

# 3. 准备 tags 列表(清理原 tags)
raw_tags = data.get('tags')
if raw_tags is None:
raw_tags = []
elif not isinstance(raw_tags, list):
raw_tags = [raw_tags]

# 只保留字符串 tag,忽略类似 [] 这样的嵌套列表
tags = [t for t in raw_tags if isinstance(t, str)]

# 将 categories 移入 tags(并清理括号数字)
raw_categories = data.get('categories')
if raw_categories is None:
raw_categories = []
elif not isinstance(raw_categories, list):
raw_categories = [raw_categories]

for cat in raw_categories:
if not isinstance(cat, str):
continue
# 去掉末尾括号及其中的数字,例如 (14)
cleaned = re.sub(r'\(\d+\)$', '', cat).strip()
if cleaned:
tags.append(cleaned)

# 4. 删除所有以 OJ 开头的 tag
tags = [t for t in tags if not t.startswith('OJ')]

# 5. 拆分包含 '-' 的 tag
new_tags = []
for tag in tags:
if '-' in tag:
# 按 '-' 拆分成多个子 tag,并去除空白
parts_tags = [p.strip() for p in tag.split('-') if p.strip()]
new_tags.extend(parts_tags)
else:
new_tags.append(tag)
tags = new_tags

# 6. 去重(保留顺序)
seen = set()
unique_tags = []
for t in tags:
if t not in seen:
seen.add(t)
unique_tags.append(t)
tags = unique_tags

data['tags'] = tags

# 7. 设置新的 categories
season = get_season(str(data.get('date', '')))
data['categories'] = ['初中OI', season]

# 如果 date 是 datetime 对象,转回字符串(通常 safe_load 会保留字符串,但以防万一)
if isinstance(data.get('date'), datetime):
data['date'] = data['date'].strftime('%Y-%m-%d %H:%M:%S')

# ---------- 重新生成 front matter ----------
# 使用 yaml.dump 保证格式
yaml_str = yaml.dump(data, allow_unicode=True, default_flow_style=False,
sort_keys=False, width=1000)

# 组装新文件内容
new_content = f'---\n{yaml_str}---{body}'

# 写回文件
with open(filepath, 'w', encoding='utf-8') as f:
f.write(new_content)

print(f"已处理:{filepath}")


def main():
md_files = glob.glob('*.md')
if not md_files:
print("当前目录下没有 .md 文件。")
return
for fp in md_files:
process_file(fp)
print("全部完成。")


if __name__ == '__main__':
main()

以及:

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
#!/usr/bin/env python3
"""
去除当前目录所有 .md 文件 front matter 中 date 值的首尾单引号。
例如:date: '2021-12-16 10:39:00' → date: 2021-12-16 10:39:00
"""

import os
import glob
import re

def remove_date_quotes(filepath):
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()

# 只处理 front matter 部分(第一个 --- 到第二个 --- 之间)
if not content.startswith('---'):
return False

parts = content.split('---', 2)
if len(parts) < 3:
return False

front = parts[1]
body = parts[2]

# 匹配 date 行并去掉首尾单引号(允许行前后有空格)
# 只处理值被单引号包裹的情况,且引号内不含单引号
new_front = re.sub(
r'^(date\s*:\s*)\'([^\']*)\'(\s*)$',
r'\1\2\3',
front,
flags=re.MULTILINE
)

if new_front == front:
return False # 没有改动

new_content = f'---{new_front}---{body}'
with open(filepath, 'w', encoding='utf-8') as f:
f.write(new_content)
return True


def main():
md_files = glob.glob('*.md')
count = 0
for fp in md_files:
if remove_date_quotes(fp):
print(f"已修改:{fp}")
count += 1
print(f"完成,共修改 {count} 个文件。")


if __name__ == '__main__':
main()