-
Notifications
You must be signed in to change notification settings - Fork 29
Expand file tree
/
Copy pathbuild.py
More file actions
1294 lines (1015 loc) · 38.6 KB
/
Copy pathbuild.py
File metadata and controls
1294 lines (1015 loc) · 38.6 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
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env -S uv run
# /// script
# requires-python = ">=3.10"
# ///
"""
Tufted Blog Template 构建脚本
这是一个跨平台的构建脚本,用于将 Typst (.typ) 文件编译为 HTML 和 PDF,
并复制静态资源到输出目录。
支持增量编译:只重新编译修改后的文件,加快构建速度。
用法:
uv run build.py build # 完整构建 (HTML + PDF + 资源)
uv run build.py html # 仅构建 HTML 文件
uv run build.py pdf # 仅构建 PDF 文件
uv run build.py assets # 仅复制静态资源
uv run build.py clean # 清理生成的文件
uv run build.py preview # 启动本地预览服务器(默认端口 8000)
uv run build.py preview -p 3000 # 使用自定义端口
uv run build.py --help # 显示帮助信息
增量编译选项:
--force, -f # 强制完整重建,忽略增量检查
预览服务器选项:
--port, -p PORT # 指定服务器端口号(默认: 8000)
也可以直接使用 Python 运行:
python build.py build
python build.py build --force
python build.py preview -p 3000
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import threading
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from html.parser import HTMLParser
from pathlib import Path
from typing import Literal
# ============================================================================
# 配置
# ============================================================================
CONTENT_DIR = Path("content") # 源文件目录
SITE_DIR = Path("_site") # 输出目录
ASSETS_DIR = Path("assets") # 静态资源目录
CONFIG_FILE = Path("config.typ") # 全局配置文件
MATHML_MIN_TYPST_VERSION = (0, 15, 0)
@dataclass
class BuildStats:
"""构建统计信息"""
success: int = 0
skipped: int = 0
failed: int = 0
def format_summary(self) -> str:
"""格式化统计摘要"""
parts = []
if self.success > 0:
parts.append(f"编译: {self.success}")
if self.skipped > 0:
parts.append(f"跳过: {self.skipped}")
if self.failed > 0:
parts.append(f"失败: {self.failed}")
return ", ".join(parts) if parts else "无文件需要处理"
@property
def has_failures(self) -> bool:
"""是否存在失败"""
return self.failed > 0
class HTMLMetadataParser(HTMLParser):
"""
从 HTML 文件中提取元数据的解析器。
解析以下元数据:
- lang: 从 <html lang="..."> 属性获取
- title: 从 <title> 标签获取
- description: 从 <meta name="description" content="..."> 获取
- link: 从 <link rel="canonical" href="..."> 获取
- date: 从 <meta name="date" content="..."> 获取
"""
def __init__(self):
super().__init__()
self.metadata = {"title": ""}
self._in_title = False
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]):
attrs_dict = {k: v for k, v in attrs if v}
match tag:
case "html":
self.metadata["lang"] = attrs_dict.get("lang", "")
case "title":
self._in_title = True
case "meta":
name = attrs_dict.get("name", "")
if name in {"description", "date"}:
self.metadata[name] = attrs_dict.get("content", "")
case "link":
if attrs_dict.get("rel") == "canonical":
self.metadata["link"] = attrs_dict.get("href", "")
def handle_endtag(self, tag: str):
if tag == "title":
self._in_title = False
def handle_data(self, data: str):
if self._in_title:
self.metadata["title"] += data
def get_typst_version() -> tuple[int, ...] | None:
"""
获取当前 Typst CLI 的语义化版本号。
返回:
tuple[int, int, int] | None: 版本号,获取失败时返回 None
"""
try:
result = subprocess.run(
["typst", "--version"],
capture_output=True,
text=True,
encoding="utf-8",
)
except (FileNotFoundError, OSError):
return None
if result.returncode != 0:
return None
match = re.search(r"typst (\d+)\.(\d+)\.(\d+)", result.stdout)
if match is None:
return None
return tuple(int(component) for component in match.groups())
def warn_if_typst_version_is_outdated() -> None:
"""
对低于 MathML 支持基线的 Typst 版本输出提示。
"""
version = get_typst_version()
if version is None or version >= MATHML_MIN_TYPST_VERSION:
return
current = ".".join(str(component) for component in version)
required = ".".join(str(component) for component in MATHML_MIN_TYPST_VERSION)
print(
f" ⚠️ 检测到 Typst {current}。HTML 导出的原生 MathML 公式支持需要 Typst {required}+,建议升级 Typst 版本。"
)
# ============================================================================
# 增量编译辅助函数
# ============================================================================
def get_file_mtime(path: Path) -> float:
"""
获取文件的修改时间戳。
参数:
path: 文件路径
返回:
float: 修改时间戳,文件不存在返回 0
"""
try:
return path.stat().st_mtime
except (OSError, FileNotFoundError):
return 0.0
def is_dep_file(path: Path) -> bool:
"""
判断一个文件是否被追踪为依赖)。
content/ 下的普通页面文件不被视为模板文件,因为它们是独立的页面,
不应该相互依赖。
参数:
path: 文件路径
返回:
bool: 是否是依赖文件
"""
try:
resolved_path = path.resolve()
project_root = Path(__file__).parent.resolve()
content_dir = (project_root / CONTENT_DIR).resolve()
# config.typ 是依赖文件
if resolved_path == (project_root / CONFIG_FILE).resolve():
return True
# 检查是否在 content/ 目录下
try:
relative_to_content = resolved_path.relative_to(content_dir)
# content/_* 目录下的文件视为依赖文件
parts = relative_to_content.parts
if len(parts) > 0 and parts[0].startswith("_"):
return True
# content/ 下的其他文件不是依赖文件
return False
except ValueError:
# 不在 content/ 目录下,视为依赖文件(如 config.typ)
return True
except Exception:
return True
def find_typ_dependencies(typ_file: Path) -> set[Path]:
"""
解析 .typ 文件中的依赖(通过 #import 和 #include 导入的文件)。
只追踪 .typ 文件的依赖,忽略 content/ 下的普通页面文件。
其他资源文件(如 .md, .bib, 图片等)通过 copy_content_assets 处理。
参数:
typ_file: .typ 文件路径
返回:
set[Path]: 依赖的 .typ 文件路径集合
"""
dependencies: set[Path] = set()
try:
content = typ_file.read_text(encoding="utf-8")
except Exception:
return dependencies
# 获取文件所在目录,用于解析相对路径
base_dir = typ_file.parent
patterns = [
r'#import\s+"([^"]+)"',
r"#import\s+'([^']+)'",
r'#include\s+"([^"]+)"',
r"#include\s+'([^']+)'",
]
for pattern in patterns:
for match in re.finditer(pattern, content):
dep_path_str = match.group(1)
# 跳过包导入(如 @preview/xxx)
if dep_path_str.startswith("@"):
continue
# 解析相对路径
if dep_path_str.startswith("/"):
# 相对于项目根目录的路径
dep_path = Path(dep_path_str.lstrip("/"))
else:
# 相对于当前文件的路径
dep_path = base_dir / dep_path_str
# 规范化路径,只追踪 .typ 文件
try:
dep_path = dep_path.resolve()
if dep_path.exists() and dep_path.suffix == ".typ" and is_dep_file(dep_path):
dependencies.add(dep_path)
except Exception:
pass
return dependencies
def get_all_dependencies(typ_file: Path, visited: set[Path] | None = None) -> set[Path]:
"""
递归获取 .typ 文件的所有依赖(包括传递依赖)。
参数:
typ_file: .typ 文件路径
visited: 已访问的文件集合(用于避免循环依赖)
返回:
set[Path]: 所有依赖文件路径集合
"""
if visited is None:
visited = set()
# 避免循环依赖
abs_path = typ_file.resolve()
if abs_path in visited:
return set()
visited.add(abs_path)
all_deps: set[Path] = set()
direct_deps = find_typ_dependencies(typ_file)
for dep in direct_deps:
all_deps.add(dep)
# 只对 .typ 文件递归查找依赖
if dep.suffix == ".typ":
all_deps.update(get_all_dependencies(dep, visited))
return all_deps
def needs_rebuild(source: Path, target: Path, extra_deps: list[Path] | None = None) -> bool:
"""
判断是否需要重新构建。
当以下任一条件满足时需要重建:
1. 目标文件不存在
2. 源文件比目标文件新
3. 任何额外依赖文件比目标文件新
4. 源文件的任何导入依赖比目标文件新
5. 源文件同目录下的任何非 .typ 文件比目标文件新(如 .md, .bib, 图片等)
参数:
source: 源文件路径
target: 目标文件路径
extra_deps: 额外的依赖文件列表(如 config.typ)
返回:
bool: 是否需要重新构建
"""
# 目标不存在,需要构建
if not target.exists():
return True
target_mtime = get_file_mtime(target)
# 源文件更新了
if get_file_mtime(source) > target_mtime:
return True
# 检查额外依赖
if extra_deps:
for dep in extra_deps:
if dep.exists() and get_file_mtime(dep) > target_mtime:
return True
# 检查源文件的导入依赖
for dep in get_all_dependencies(source):
if get_file_mtime(dep) > target_mtime:
return True
# 检查源文件同目录下的非 .typ 资源文件(如 .md, .bib, 图片等)
# 只检查同一目录,不递归子目录,避免过度重编译
source_dir = source.parent
for item in source_dir.iterdir():
if item.is_file() and item.suffix != ".typ":
if get_file_mtime(item) > target_mtime:
return True
return False
def find_common_dependencies() -> list[Path]:
"""
查找所有文件的公共依赖(如 config.typ)。
返回:
list[Path]: 公共依赖文件路径列表
"""
common_deps = []
# config.typ 是全局配置,修改后所有页面都需要重建
if CONFIG_FILE.exists():
common_deps.append(CONFIG_FILE)
# 可以在这里添加其他公共依赖
# 例如:查找 content/_* 目录下的模板文件
if CONTENT_DIR.exists():
for item in CONTENT_DIR.iterdir():
if item.is_dir() and item.name.startswith("_"):
for typ_file in item.rglob("*.typ"):
common_deps.append(typ_file)
return common_deps
# ============================================================================
# 辅助函数
# ============================================================================
def find_typ_files() -> list[Path]:
"""
查找 content/ 目录下所有 .typ 文件,排除路径中包含以下划线开头的目录的文件。
返回:
list[Path]: .typ 文件路径列表
"""
typ_files = []
for typ_file in CONTENT_DIR.rglob("*.typ"):
# 检查路径中是否有以下划线开头的目录
parts = typ_file.relative_to(CONTENT_DIR).parts
if not any(part.startswith("_") for part in parts):
typ_files.append(typ_file)
return typ_files
def get_file_output_path(typ_file: Path, type: Literal["pdf", "html"]) -> Path:
"""
获取 .typ 文件的输出路径。
参数:
typ_file: .typ 文件路径 (相对于 content/)
返回:
Path: 文件输出路径 (在 _site/ 目录下)
"""
relative_path = typ_file.relative_to(CONTENT_DIR)
return SITE_DIR / relative_path.with_suffix(f".{type}")
def run_typst_command(args: list[str]) -> bool:
"""
运行 typst 命令。
参数:
args: typst 命令参数列表
返回:
bool: 命令是否成功执行
"""
try:
result = subprocess.run(["typst"] + args, capture_output=True, text=True, encoding="utf-8")
if result.returncode != 0:
print(f" ❌ Typst 错误: {result.stderr.strip()}")
return False
return True
except FileNotFoundError:
print(" ❌ 错误: 未找到 typst 命令。请确保已安装 Typst 并添加到 PATH 环境变量中。")
print(" 📝 安装说明: https://typst.app/open-source/#download")
return False
except Exception as e:
print(f" ❌ 执行 typst 命令时出错: {e}")
return False
# ============================================================================
# 构建命令
# ============================================================================
def _compile_files(
files: list[Path],
force: bool,
common_deps: list[Path],
get_output_path_func,
build_args_func,
) -> BuildStats:
"""
通用文件编译函数,减少重复代码。
参数:
files: 要编译的文件列表
force: 是否强制重建
common_deps: 公共依赖列表
get_output_path_func: 获取输出路径的函数
build_args_func: 构建编译参数的函数
返回:
BuildStats: 构建统计信息
"""
stats = BuildStats()
for typ_file in files:
output_path = get_output_path_func(typ_file)
# 增量编译检查
if not force and not needs_rebuild(typ_file, output_path, common_deps):
stats.skipped += 1
continue
output_path.parent.mkdir(parents=True, exist_ok=True)
# 构建编译参数
args = build_args_func(typ_file, output_path)
if run_typst_command(args):
stats.success += 1
else:
print(f" ❌ {typ_file} 编译失败")
stats.failed += 1
return stats
def build_html(force: bool = False) -> bool:
"""
编译所有 .typ 文件为 HTML(文件名中包含 PDF 的除外)。
参数:
force: 是否强制重建所有文件
"""
SITE_DIR.mkdir(parents=True, exist_ok=True)
typ_files = find_typ_files()
# 排除标记为 PDF 的文件
html_files = [f for f in typ_files if "pdf" not in f.stem.lower()]
if not html_files:
print(" ⚠️ 未找到任何 HTML 文件。")
return True
print("正在构建 HTML 文件...")
# 获取公共依赖
common_deps = find_common_dependencies()
def build_html_args(typ_file: Path, output_path: Path) -> list[str]:
"""构建 HTML 编译参数"""
try:
rel_path = typ_file.relative_to(CONTENT_DIR)
if rel_path.name == "index.typ":
# index.typ uses the parent directory name as the path
# content/Blog/index.typ -> "Blog"
# content/index.typ -> "" (Homepage)
page_path = rel_path.parent.as_posix()
if page_path == ".":
page_path = ""
else:
# Common files use the filename as the path
# content/about.typ -> "about"
page_path = rel_path.with_suffix("").as_posix()
except ValueError:
page_path = ""
return [
"compile",
"--root",
".",
"--font-path",
str(ASSETS_DIR),
"--features",
"html",
"--format",
"html",
"--input",
f"page-path={page_path}",
str(typ_file),
str(output_path),
]
stats = _compile_files(
html_files,
force,
common_deps,
lambda typ_file: get_file_output_path(typ_file, "html"),
build_html_args,
)
print(f"✅ HTML 构建完成。{stats.format_summary()}")
return not stats.has_failures
def build_pdf(force: bool = False) -> bool:
"""
编译文件名包含 "PDF" 的 .typ 文件为 PDF。
参数:
force: 是否强制重建所有文件
"""
SITE_DIR.mkdir(parents=True, exist_ok=True)
typ_files = find_typ_files()
pdf_files = [f for f in typ_files if "pdf" in f.stem.lower()]
if not pdf_files:
return True
print("正在构建 PDF 文件...")
# 获取公共依赖
common_deps = find_common_dependencies()
def build_pdf_args(typ_file: Path, output_path: Path) -> list[str]:
"""构建 PDF 编译参数"""
return [
"compile",
"--root",
".",
"--font-path",
str(ASSETS_DIR),
str(typ_file),
str(output_path),
]
stats = _compile_files(
pdf_files,
force,
common_deps,
lambda typ_file: get_file_output_path(typ_file, "pdf"),
build_pdf_args,
)
print(f"✅ PDF 构建完成。{stats.format_summary()}")
return not stats.has_failures
def copy_assets() -> bool:
"""
复制静态资源到输出目录。
"""
if not ASSETS_DIR.exists():
print(f" ⚠ 静态资源目录 {ASSETS_DIR} 不存在。")
return True
SITE_DIR.mkdir(parents=True, exist_ok=True)
target_dir = SITE_DIR / "assets"
try:
if target_dir.exists():
shutil.rmtree(target_dir)
shutil.copytree(ASSETS_DIR, target_dir)
return True
except Exception as e:
print(f" ❌ 复制静态资源失败: {e}")
return False
def copy_content_assets(force: bool = False) -> bool:
"""
复制 content 目录下的非 .typ 文件(如图片)到输出目录。
支持增量复制:只复制修改过的文件。
参数:
force: 是否强制复制所有文件
"""
SITE_DIR.mkdir(parents=True, exist_ok=True)
if not CONTENT_DIR.exists():
print(f" ⚠ 内容目录 {CONTENT_DIR} 不存在,跳过。")
return True
try:
copy_count = 0
skip_count = 0
for item in CONTENT_DIR.rglob("*"):
# 跳过目录和 .typ 文件
if item.is_dir() or item.suffix == ".typ":
continue
# 跳过以下划线开头的路径
relative_path = item.relative_to(CONTENT_DIR)
if any(part.startswith("_") for part in relative_path.parts):
continue
# 计算目标路径
target_path = SITE_DIR / relative_path
# 增量复制检查
if not force and target_path.exists():
if get_file_mtime(item) <= get_file_mtime(target_path):
skip_count += 1
continue
# 创建目标目录
target_path.parent.mkdir(parents=True, exist_ok=True)
# 复制文件
shutil.copy2(item, target_path)
copy_count += 1
return True
except Exception as e:
print(f" ❌ 复制内容资源文件失败: {e}")
return False
def clean() -> bool:
"""
清理生成的文件。
"""
print("正在清理生成的文件...")
if not SITE_DIR.exists():
print(f" 输出目录 {SITE_DIR} 不存在,无需清理。")
return True
try:
# 删除 _site 目录下的所有内容
for item in SITE_DIR.iterdir():
if item.is_dir():
shutil.rmtree(item)
else:
item.unlink()
print(f" ✅ 已清理 {SITE_DIR}/ 目录。")
return True
except Exception as e:
print(f" ❌ 清理失败: {e}")
return False
def preview(port: int = 8000, open_browser_flag: bool = True) -> bool:
"""
启动本地预览服务器。
首先尝试使用 uvx livereload(支持实时刷新),
如果失败则回退到 Python 内置的 http.server。
参数:
port: 服务器端口号,默认为 8000
open_browser_flag: 是否自动打开浏览器,默认为 True
"""
import webbrowser
if not SITE_DIR.exists():
print(f" ⚠ 输出目录 {SITE_DIR} 不存在,请先运行 build 命令。")
return False
print("正在启动本地预览服务器(按 Ctrl+C 停止)...")
print()
if open_browser_flag:
def open_browser():
time.sleep(1.5) # 等待服务器启动
url = f"http://localhost:{port}"
print(f" 🚀 正在打开浏览器: {url}")
webbrowser.open(url)
# 在后台线程中打开浏览器
threading.Thread(target=open_browser, daemon=True).start()
# 首先尝试 uvx livereload
try:
result = subprocess.run(
["uvx", "livereload", str(SITE_DIR), "-p", str(port)],
check=False,
)
return result.returncode == 0
except FileNotFoundError:
print(" 未找到 uv,尝试 Python http.server...")
except KeyboardInterrupt:
print("\n服务器已停止。")
return True
# 回退到 Python http.server
try:
print("使用 Python 内置 http.server...")
result = subprocess.run(
[sys.executable, "-m", "http.server", str(port), "--directory", str(SITE_DIR)],
check=False,
)
return result.returncode == 0
except KeyboardInterrupt:
print("\n服务器已停止。")
return True
except Exception as e:
print(f" ❌ 启动服务器失败: {e}")
return False
def parse_html_metadata(html_path: Path) -> dict[str, str]:
"""
解析 HTML 文件并返回元数据解析器实例。
参数:
html_path (Path): HTML 文件路径
返回:
HTMLMetadataParser: 包含解析结果的解析器实例
"""
parser = HTMLMetadataParser()
parser.feed(html_path.read_text(encoding="utf-8"))
return parser.metadata
def get_site_url() -> str | None:
"""
从生成的首页 HTML 文件中解析站点 URL。
功能:
从 _site/index.html 的 <link rel="canonical" href="..."> 提取 site-url。
返回:
str: 站点的根 URL(如 "https://example.com"),末尾不带斜杠。
如果未配置或解析失败则返回 None。
"""
index_html = SITE_DIR / "index.html"
parser = parse_html_metadata(index_html)
if parser.get("link"):
return parser["link"].rstrip("/")
return None
def get_feed_dirs() -> set[str]:
"""
从 config.typ 配置文件中解析 RSS Feed 订阅源的配置信息。
功能:
解析 config.typ 中的 feed 配置块,提取目录列表。
返回:
set[str]: 要包含的文章目录列表,默认为空集合
"""
if not CONFIG_FILE.exists():
return set()
try:
content = CONFIG_FILE.read_text(encoding="utf-8")
# 移除注释
content = re.sub(r"//.*", "", content)
content = re.sub(r"/\*[\s\S]*?\*/", "", content)
match = re.search(r"feed-dir\s*:\s*\((.*?)\)", content, re.DOTALL)
if match:
return set(
c.strip("/") for c in re.findall(r'"([^"]*)"', match.group(1)) if c and c.strip("/")
)
except Exception as e:
print(f"⚠️ 解析 feed-dir 失败: {e}")
return set()
def extract_post_metadata(index_html: Path) -> tuple[str, str, str, datetime | None]:
"""
从生成的 HTML 文件中提取文章的元数据信息。
功能:
提取文章元数据:
1. 标题 (title): 从 <title> 标签提取
2. 描述 (description): 从 <meta name="description"> 提取
3. 链接 (link): 从 <link rel="canonical" href="..."> 提取
4. 日期 (date): 依次尝试从以下来源获取:
- HTML 中的 <meta name="date" content="...">
- 文件夹名中的 YYYY-MM-DD 格式日期
参数:
index_html (Path): 文章的 index.html 文件路径
返回:
tuple[str, str, str, datetime | None]: 包含四个元素的元组:
- str: 文章标题
- str: 文章描述(可能为空字符串)
- str: 文章链接(完整 URL)
- datetime | None: 文章日期(带 UTC 时区),无法获取时为 None
"""
parser = parse_html_metadata(index_html)
title = parser["title"].strip()
description = parser.get("description", "").strip()
link = parser.get("link", "")
date_obj = None
# 尝试从 <meta name="date"> 解析日期
if parser.get("date"):
try:
date_obj = datetime.strptime(parser["date"].split("T")[0], "%Y-%m-%d")
date_obj = date_obj.replace(tzinfo=timezone.utc)
except Exception:
pass
# 如果没找到日期,尝试从文件夹名提取 (YYYY-MM-DD)
if not date_obj:
date_match = re.search(r"(\d{4}-\d{2}-\d{2})", index_html.parent.name)
if date_match:
try:
date_obj = datetime.strptime(date_match.group(1), "%Y-%m-%d")
date_obj = date_obj.replace(tzinfo=timezone.utc)
except ValueError:
pass
return title, description, link, date_obj
def collect_posts(dirs: set[str], site_url: str) -> list[dict]:
"""
从指定的目录中收集所有文章的元数据。
功能:
遍历 _site 目录下指定目录中的所有子目录,提取每个文章的元数据信息。
只处理目录(每个目录代表一篇文章),跳过普通文件。
如果无法确定文章日期,则跳过该文章并输出警告。
参数:
dirs (set[str]): 要扫描的目录名称集合(如 {"Blog", "Docs"})
site_url (str): 站点的根 URL(如 "https://example.com")
返回:
list[dict]: 文章数据字典列表,每个字典包含以下键:
- title (str): 文章标题
- description (str): 文章描述
- dir (str): 文章所属分类(即目录名)
- link (str): 文章的完整 URL
- date (datetime): 文章日期对象(带时区)
"""
posts = []
for d in dirs:
dir_path = SITE_DIR / d
for item in dir_path.iterdir():
if not item.is_dir():
continue
index_html = item / "index.html"
if not index_html.exists():
continue
title, description, link, date_obj = extract_post_metadata(index_html)
if not date_obj:
print(f"⚠️ 无法确定文章 '{item.name}' 的日期,已跳过。")
continue
posts.append(
{
"title": title,
"description": description,
"dir": d,
"link": link,
"date": date_obj,
}
)
return posts
def build_rss_xml(posts: list[dict], config: dict) -> str:
"""
构建符合 RSS 2.0 规范的 XML 内容字符串。
功能:
使用 Python 标准库 xml.etree.ElementTree 根据文章数据和站点配置生成完整的 RSS Feed XML。
支持条件输出 description 标签(仅在有描述时输出)。
参数:
posts (list[dict]): 文章数据列表,每个字典应包含:
- title: 标题
- description: 描述(可选)
- link: 文章链接
- date: datetime 对象
- dir: 分类名称 (即路径名)
config (dict): 站点配置字典,应包含:
- site_url: 站点根 URL
- site_title: 站点标题
- site_description: 站点描述
- lang: 语言代码(如 "zh", "en")
返回:
str: 完整的 RSS 2.0 XML 字符串,包含 XML 声明和所有必要的命名空间。
"""
import xml.etree.ElementTree as ET
from email.utils import format_datetime
# 注册 atom 命名空间前缀
ATOM_NS = "http://www.w3.org/2005/Atom"
ET.register_namespace("atom", ATOM_NS)
# 创建 RSS 根元素(命名空间声明由 register_namespace 自动处理)
rss = ET.Element("rss", version="2.0")
# Channel 元数据
channel = ET.SubElement(rss, "channel")
ET.SubElement(channel, "title").text = config["site_title"]
ET.SubElement(channel, "link").text = config["site_url"]
ET.SubElement(channel, "description").text = config["site_description"]
ET.SubElement(channel, "language").text = config["lang"]
ET.SubElement(channel, "lastBuildDate").text = format_datetime(datetime.now(timezone.utc))
# 添加 atom:link 自链接
atom_link = ET.SubElement(channel, f"{{{ATOM_NS}}}link")
atom_link.set("href", f"{config['site_url']}/feed.xml")
atom_link.set("rel", "self")
atom_link.set("type", "application/rss+xml")
# 添加文章条目
for post in posts:
item = ET.SubElement(channel, "item")
ET.SubElement(item, "title").text = post["title"]
ET.SubElement(item, "link").text = post["link"]
ET.SubElement(item, "guid", isPermaLink="true").text = post["link"]
ET.SubElement(item, "pubDate").text = format_datetime(post["date"])
ET.SubElement(item, "category").text = post["dir"]