Repository navigation
Expand file tree
/
Copy pathPublishBlog.py
More file actions
646 lines (519 loc) · 21.1 KB
/
Copy pathPublishBlog.py
File metadata and controls
646 lines (519 loc) · 21.1 KB
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
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
"""
PublishBlog.py - 一键发布/删除博客文章
用法:
python PublishBlog.py --all 自动检测并发布所有未发布的文章
python PublishBlog.py AI 发布指定文章
python PublishBlog.py AI Antigravity 发布多篇文章
python PublishBlog.py --delete 红楼梦人物关系图 删除指定文章
python PublishBlog.py --delete AI Antigravity 同时删除多篇文章
功能:
1. 读取 content/post/<name>.md 中的 front matter 和正文
2. 在 blog/<name>/ 下生成 index.html 博客页面
3. 自动更新根目录 index.html 的文章列表
4. 执行 git add / commit / push 完成发布
5. --delete 模式:删除博客页面目录并从首页列表中移除条目
"""
import os
import re
import sys
import shutil
import uuid
import subprocess
from datetime import datetime
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
os.chdir(SCRIPT_DIR)
# ============================================================
# 博客页面 HTML 模板
# ============================================================
BLOG_PAGE_TEMPLATE = r'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="generator" content="Hugo 0.59.1" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="author" content="DingZQ" />
<meta property="og:url" content="https://www.dingzhiqiang.com/blog/{slug}/" />
<link rel="canonical" href="https://www.dingzhiqiang.com/blog/{slug}/" /><script type="application/ld+json">
{{
"@context" : "http://schema.org",
"@type" : "BlogPosting",
"mainEntityOfPage": {{
"@type": "WebPage",
"@id": "https:\/\/www.dingzhiqiang.com\/"
}},
"articleSection" : "post",
"name" : "{title}",
"headline" : "{title}",
"description" : "{description}",
"inLanguage" : "en-US",
"author" : "DingZQ",
"creator" : "DingZQ",
"publisher": "DingZQ",
"accountablePerson" : "DingZQ",
"copyrightHolder" : "DingZQ",
"copyrightYear" : "{year}",
"datePublished": "{date_full}",
"dateModified" : "{date_full}",
"url" : "https:\/\/www.dingzhiqiang.com\/blog\/{slug}\/",
"keywords" : [ ]
}}
</script>
<title>{title} - Zhiqiang's Blog</title>
<meta property="og:title" content="{title} - Zhiqiang's Blog" />
<meta property="og:type" content="article" />
<meta name="description" content="{description}" />
<link rel="stylesheet" href="/css/flexboxgrid-6.3.1.min.css" />
<link rel="stylesheet"
href="/css/github-markdown.min.css" />
<link rel="stylesheet" href="/css/highlight/tomorrow.min.css" />
<link rel="stylesheet" href="/css/index.css">
<link href="/index.xml" rel="alternate" type="application/rss+xml" title="Zhiqiang's Blog">
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&family=Noto+Sans+SC:wght@300;400;500&display=swap" rel="stylesheet">
<script src="/js/fx.js" defer></script>
</head>
<body>
<article class="post " id="article">
<div class="row">
<div class="col-xs-12 col-sm-10 col-md-8 col-sm-offset-1 col-md-offset-2 col-lg-6 col-lg-offset-3">
<div class="site-header">
<header>
<div class="signatures site-title">
<a href="/">无敌的丁苏</a>
</div>
</header>
<div class="row end-xs">
</div>
<div class="header-line"></div>
</div>
<header class="post-header">
<h1 class="post-title">{title}</h1>
<div class="row post-desc">
<div class="col-xs-6">
<time class="post-date" datetime="{date_full}">
{date_display}
</time>
</div>
<div class="col-xs-6">
<div class="post-author">
<a target="_blank" href="https://www.dingzhiqiang.com/">@DingZQ</a>
</div>
</div>
</div>
</header>
<div class="post-content markdown-body">
{content_html}
</div>
<div style="height: 50px;"></div>
<div class="site-footer">
</div>
</div>
</div>
</article>
<script src="/js/highlight.pack.js"></script>
<script>
hljs.initHighlightingOnLoad();
</script>
<div class="lightbox-overlay" id="lightbox-overlay" onclick="this.classList.remove('active')">
<img id="lightbox-img" src="" alt="" />
</div>
<script>
document.querySelectorAll('.post-content img').forEach(function(img) {{
img.addEventListener('click', function() {{
var overlay = document.getElementById('lightbox-overlay');
document.getElementById('lightbox-img').src = this.src;
overlay.classList.add('active');
}});
}});
</script>
</body>
</html>
'''
# ============================================================
# index.html 中的文章条目模板
# ============================================================
POST_ENTRY_TEMPLATE = '''
<div class="row post-line">
<div class="posts-date col-xs-2">
<time datetime="{date_full}">{date_short}</time>
</div>
<div class="posts-title col-xs-10">
<a href="/blog/{slug}/">{title}</a>
</div>
</div>
'''
# ============================================================
# 解析 Markdown 文件
# ============================================================
def parse_markdown(filepath):
"""解析 markdown 文件,返回 front matter 字典和正文内容"""
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
fm_match = re.match(r'^---\s*\n(.*?)\n---\s*\n(.*)', content, re.DOTALL)
if not fm_match:
print(f" 错误: 无法解析 {filepath} 的 front matter")
return None, None
fm_text = fm_match.group(1)
body = fm_match.group(2).strip()
meta = {}
for line in fm_text.strip().split('\n'):
if ':' in line:
key, val = line.split(':', 1)
meta[key.strip()] = val.strip().strip('"').strip("'")
return meta, body
def markdown_to_html(md_text):
"""简易 Markdown 转 HTML"""
lines = md_text.split('\n')
html_parts = []
in_code_block = False
code_lang = ''
code_lines = []
for line in lines:
if line.startswith('```'):
if not in_code_block:
in_code_block = True
code_lang = line[3:].strip()
code_lines = []
else:
in_code_block = False
code_content = '\n'.join(code_lines)
if code_lang:
html_parts.append(f'<pre><code class="{code_lang}">{code_content}</code></pre>')
else:
html_parts.append(f'<pre><code>{code_content}</code></pre>')
continue
if in_code_block:
code_lines.append(line)
continue
if line.startswith('######'):
html_parts.append(f'<h6>{line[6:].strip()}</h6>')
elif line.startswith('#####'):
html_parts.append(f'<h5>{line[5:].strip()}</h5>')
elif line.startswith('####'):
html_parts.append(f'<h4>{line[4:].strip()}</h4>')
elif line.startswith('###'):
html_parts.append(f'<h3>{line[3:].strip()}</h3>')
elif line.startswith('##'):
html_parts.append(f'<h2>{line[2:].strip()}</h2>')
elif line.startswith('#'):
html_parts.append(f'<h1>{line[1:].strip()}</h1>')
elif line.strip() == '':
continue
elif line.startswith('- '):
html_parts.append(f'<li>{line[2:].strip()}</li>')
elif line.startswith('!['):
img_match = re.match(r'!\[(.*?)\]\((.*?)\)', line)
if img_match:
alt, src = img_match.group(1), img_match.group(2)
html_parts.append(f'<img src="{src}" alt="{alt}" />')
else:
html_parts.append(f'<p>{line}</p>')
else:
line = re.sub(r'\[(.*?)\]\((.*?)\)', r'<a href="\2">\1</a>', line)
line = re.sub(r'`([^`]+)`', r'<code>\1</code>', line)
line = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', line)
line = re.sub(r'\*(.*?)\*', r'<em>\1</em>', line)
html_parts.append(f'<p>{line}</p>')
return '\n '.join(html_parts)
def parse_date(date_str):
"""解析日期字符串,返回 datetime 对象"""
date_str = re.sub(r'([+-]\d{2}):(\d{2})$', r'\1\2', date_str)
try:
return datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S%z')
except ValueError:
try:
return datetime.strptime(date_str[:19], '%Y-%m-%dT%H:%M:%S')
except ValueError:
return datetime.now()
def update_index_html(slug, title, dt):
"""更新 index.html,将新文章添加到对应年份的 section 中"""
index_path = os.path.join(SCRIPT_DIR, 'index.html')
with open(index_path, 'r', encoding='utf-8') as f:
html = f.read()
year = str(dt.year)
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
date_short = f"{months[dt.month - 1]} {dt.day:02d}"
date_full = dt.strftime('%Y-%m-%d %H:%M:%S') + ' CST'
if f'href="/blog/{slug}/"' in html:
print(f" 文章 {slug} 已在 index.html 中,跳过更新列表")
return
new_entry = POST_ENTRY_TEMPLATE.format(
date_full=date_full,
date_short=date_short,
slug=slug,
title=title
)
year_pattern = f'<h1 class="site-date-catalog">{year}</h1>'
if year_pattern in html:
insert_pos = html.index(year_pattern) + len(year_pattern)
while insert_pos < len(html) and html[insert_pos] in '\r\n \t':
insert_pos += 1
html = html[:insert_pos] + new_entry + html[insert_pos:]
else:
new_section = f''' <section>
<h1 class="site-date-catalog">{year}</h1>
{new_entry}
</section>
'''
marker = '<div id="posts-list">\r\n'
if marker not in html:
marker = '<div id="posts-list">\n'
if marker in html:
insert_pos = html.index(marker) + len(marker)
html = html[:insert_pos] + new_section + html[insert_pos:]
else:
print(" 警告: 未找到 posts-list 标记,无法更新 index.html")
return
with open(index_path, 'w', encoding='utf-8') as f:
f.write(html)
print(f" 已更新 index.html 列表")
def create_blog_page(slug, title, dt, content_html, description):
"""创建博客文章 HTML 页面"""
blog_dir = os.path.join(SCRIPT_DIR, 'blog', slug)
os.makedirs(blog_dir, exist_ok=True)
months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
date_display = f"{dt.day:02d} {months[dt.month - 1]} {dt.year}"
date_full = dt.strftime('%Y-%m-%d %H:%M:%S') + ' +0800 CST'
page_html = BLOG_PAGE_TEMPLATE.format(
slug=slug,
title=title,
description=description,
year=dt.year,
date_full=date_full,
date_display=date_display,
content_html=content_html
)
output_path = os.path.join(blog_dir, 'index.html')
with open(output_path, 'w', encoding='utf-8') as f:
f.write(page_html)
print(f" 已生成 blog/{slug}/index.html")
def git_publish(post_names, action='Publish'):
"""执行 git add, commit, push"""
print("\n正在提交到 GitHub...")
subprocess.run(['git', 'add', '.'], cwd=SCRIPT_DIR)
msg = f'{action} post: {", ".join(post_names)}'
subprocess.run(['git', 'commit', '-m', msg], cwd=SCRIPT_DIR)
result = subprocess.run(['git', 'push'], cwd=SCRIPT_DIR, capture_output=True, text=True)
if result.returncode == 0:
print(" 已推送到 GitHub!稍等片刻,博客即可对外可见。")
else:
print(f" Git push 输出: {result.stdout}{result.stderr}")
print(" 请检查网络连接或 SSH 密钥配置。")
def delete_blog_dir(slug):
"""删除 blog/<slug>/ 目录"""
blog_dir = os.path.join(SCRIPT_DIR, 'blog', slug)
if os.path.isdir(blog_dir):
shutil.rmtree(blog_dir)
print(f" 已删除目录 blog/{slug}/")
return True
else:
print(f" 警告: blog/{slug}/ 目录不存在,跳过")
return False
def remove_from_index(slug):
"""从 index.html 首页列表中移除对应的文章条目"""
index_path = os.path.join(SCRIPT_DIR, 'index.html')
with open(index_path, 'r', encoding='utf-8') as f:
html = f.read()
href_marker = f'href="/blog/{slug}/"'
if href_marker not in html:
print(f" 警告: index.html 中未找到 {slug} 的条目,跳过")
return False
# 匹配包含该 href 的整个 <div class="row post-line">...</div> 块
# 支持两种缩进风格(带前导空格 或 顶格)
pattern = r'[ \t]*<div class="row post-line">.*?</div>\s*</div>\s*</div>\r?\n?'
def contains_slug(m):
return href_marker in m.group(0)
new_html = html
for m in re.finditer(pattern, html, re.DOTALL):
if href_marker in m.group(0):
new_html = html[:m.start()] + html[m.end():]
break
if new_html == html:
# fallback: 删除含该 href 的整行所在的 post-line 块
print(f" 警告: 正则未能匹配,尝试行级删除")
lines = html.splitlines(keepends=True)
# 找到含 href 的行号
target_line = None
for i, line in enumerate(lines):
if href_marker in line:
target_line = i
break
if target_line is None:
return False
# 向上找 <div class="row post-line">
start = target_line
for i in range(target_line, max(target_line - 10, -1), -1):
if 'row post-line' in lines[i]:
start = i
break
# 向下找闭合的 </div></div>
depth = 0
end = target_line
for i in range(start, min(start + 15, len(lines))):
depth += lines[i].count('<div')
depth -= lines[i].count('</div>')
end = i
if depth <= 0:
break
new_html = ''.join(lines[:start]) + ''.join(lines[end + 1:])
# 清理因删除条目而产生的空 <section>(只包含年份标题没有条目)
new_html = re.sub(
r'<section>\s*<h1 class="site-date-catalog">\d+</h1>\s*</section>\r?\n?',
'', new_html
)
with open(index_path, 'w', encoding='utf-8') as f:
f.write(new_html)
print(f" 已从 index.html 移除 {slug} 的条目")
return True
def delete_post(post_name):
"""删除已发布的博客文章(博客目录 + index.html 条目)"""
slug = post_name.lower()
print(f"\n========== 删除文章: {post_name} ==========")
dir_ok = delete_blog_dir(slug)
idx_ok = remove_from_index(slug)
return dir_ok or idx_ok
def find_unpublished_posts():
"""扫描 content/post/ 目录,找出尚未在 blog/ 目录中生成页面的文章"""
post_dir = os.path.join(SCRIPT_DIR, 'content', 'post')
if not os.path.isdir(post_dir):
print("错误: content/post/ 目录不存在")
return []
unpublished = []
for filename in os.listdir(post_dir):
if not filename.endswith('.md'):
continue
post_name = filename[:-3] # 去掉 .md 后缀
slug = post_name.lower()
blog_page = os.path.join(SCRIPT_DIR, 'blog', slug, 'index.html')
if not os.path.exists(blog_page):
unpublished.append(post_name)
else:
# 检查 md 文件是否比 html 文件更新
md_path = os.path.join(post_dir, filename)
if os.path.getmtime(md_path) > os.path.getmtime(blog_page):
unpublished.append(post_name)
return unpublished
def process_images(post_name, md_path):
"""处理文章中的图片:将 content/post/ 下的本地图片重命名并复制到 images/ 目录,更新 md 引用"""
post_dir = os.path.dirname(md_path)
images_dir = os.path.join(SCRIPT_DIR, 'images')
os.makedirs(images_dir, exist_ok=True)
with open(md_path, 'r', encoding='utf-8') as f:
content = f.read()
IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.svg')
modified = False
# 匹配 markdown 图片语法 
def replace_image(match):
nonlocal modified
alt = match.group(1)
img_path = match.group(2)
# 跳过已经是 /images/ 开头的或者 http 开头的远程图片
if img_path.startswith('/images/') or img_path.startswith('http'):
return match.group(0)
# 获取图片文件的完整路径
full_img_path = os.path.join(post_dir, img_path)
if not os.path.exists(full_img_path):
print(f" 警告: 图片文件不存在 {full_img_path},跳过")
return match.group(0)
# 生成随机文件名,保留原始扩展名
ext = os.path.splitext(img_path)[1].lower()
if ext not in IMAGE_EXTS:
return match.group(0)
random_name = uuid.uuid4().hex[:12] + ext
dest_path = os.path.join(images_dir, random_name)
# 复制图片到 images/ 目录
shutil.copy2(full_img_path, dest_path)
print(f" 图片: {img_path} -> /images/{random_name}")
# 删除原始图片文件
os.remove(full_img_path)
modified = True
return f''
new_content = re.sub(r'!\[(.*?)\]\((.*?)\)', replace_image, content)
if modified:
with open(md_path, 'w', encoding='utf-8') as f:
f.write(new_content)
print(f" 已更新 {post_name}.md 中的图片引用")
def publish_post(post_name):
"""发布单篇文章"""
md_path = os.path.join(SCRIPT_DIR, 'content', 'post', f'{post_name}.md')
if not os.path.exists(md_path):
print(f" 错误: 找不到文件 {md_path}")
return False
print(f"\n========== 发布文章: {post_name} ==========")
# 1. 处理图片:重命名并复制到 images/ 目录
process_images(post_name, md_path)
# 2. 解析 Markdown
meta, body = parse_markdown(md_path)
if meta is None:
return False
title = meta.get('title', post_name)
date_str = meta.get('date', '')
draft = meta.get('draft', 'false').lower()
if draft == 'true':
print(f" 注意: {post_name} 标记为草稿,自动改为 draft: false")
with open(md_path, 'r', encoding='utf-8') as f:
md_content = f.read()
md_content = md_content.replace('draft: true', 'draft: false')
with open(md_path, 'w', encoding='utf-8') as f:
f.write(md_content)
dt = parse_date(date_str) if date_str else datetime.now()
slug = post_name.lower()
# 3. 转换 Markdown 为 HTML
content_html = markdown_to_html(body)
# 取正文第一行作为 description
first_line = body.split('\n')[0].strip() if body else title
description = first_line[:200]
# 4. 生成博客页面
create_blog_page(slug, title, dt, content_html, description)
# 5. 更新 index.html 列表
update_index_html(slug, title, dt)
return True
def main():
args = sys.argv[1:]
# --delete 模式
if args and args[0] == '--delete':
post_names = args[1:]
if not post_names:
print("用法: python PublishBlog.py --delete <文章名> [文章名...]")
return
deleted = []
for name in post_names:
if delete_post(name):
deleted.append(name)
if deleted:
git_publish(deleted, action='Delete')
print(f"\n✅ 成功删除 {len(deleted)} 篇文章!")
else:
print("\n没有文章被成功删除。")
return
# --all 模式: 自动检测未发布的文章
if len(args) == 1 and args[0] == '--all':
post_names = find_unpublished_posts()
if not post_names:
print("没有检测到需要发布的新文章。")
print("(所有 content/post/*.md 都已经有对应的 blog 页面且未被修改)")
return
print(f"检测到 {len(post_names)} 篇需要发布的文章: {', '.join(post_names)}")
elif args:
post_names = args
else:
# 默认也是 --all 模式
post_names = find_unpublished_posts()
if not post_names:
print("没有检测到需要发布的新文章。")
print("(所有 content/post/*.md 都已经有对应的 blog 页面且未被修改)")
return
print(f"检测到 {len(post_names)} 篇需要发布的文章: {', '.join(post_names)}")
published = []
for name in post_names:
if publish_post(name):
published.append(name)
if published:
git_publish(published)
print(f"\n✅ 成功发布 {len(published)} 篇文章!")
else:
print("\n没有文章被成功发布。")
if __name__ == '__main__':
main()