djhx-blogger 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of djhx-blogger might be problematic. Click here for more details.

File without changes
@@ -0,0 +1,4 @@
1
+ from .cli import app
2
+
3
+ if __name__ == '__main__':
4
+ app()
djhx_blogger/cli.py ADDED
@@ -0,0 +1,47 @@
1
+ from pathlib import Path
2
+
3
+ import typer
4
+
5
+ from .deploy import compress_dir, deploy_blog
6
+ from .gen import generate_blog
7
+ from .log_config import log_init
8
+ log_init()
9
+
10
+ app = typer.Typer()
11
+
12
+
13
+ @app.command()
14
+ def run(
15
+ origin: Path = typer.Argument(..., exists=True, readable=True, help="原始博客内容目录(必填)"),
16
+ target: Path = typer.Option(
17
+ Path.cwd(),
18
+ "--target", "-t",
19
+ help="生成的静态博客输出目录,默认为当前目录。",
20
+ ),
21
+ server: str = typer.Option(
22
+ None,
23
+ "--server", "-s",
24
+ help="目标服务器(域名或 IP 地址),可选。",
25
+ ),
26
+ server_target: str = typer.Option(
27
+ None,
28
+ "--server-target", "-T",
29
+ help="目标服务器的部署路径"
30
+ ),
31
+ deploy: bool = typer.Option(
32
+ False,
33
+ "--deploy/--no-deploy",
34
+ help="是否将静态博客部署到远程服务器。",
35
+ ),
36
+ ):
37
+ typer.echo(f"原始目录: {origin}")
38
+ typer.echo(f"输出目录: {target}")
39
+ typer.echo(f"目标服务器: {server or '(未指定)'}")
40
+ typer.echo(f"目标服务器部署地址: {server_target}")
41
+ typer.echo(f"是否部署: {'是' if deploy else '否'}")
42
+
43
+ root_node = generate_blog(str(origin))
44
+
45
+ if deploy and server and server_target:
46
+ tar_path = compress_dir(root_node.destination_path)
47
+ deploy_blog(server, tar_path, server_target)
djhx_blogger/deploy.py ADDED
@@ -0,0 +1,65 @@
1
+ import tarfile
2
+ from getpass import getpass
3
+ from pathlib import Path
4
+
5
+ from fabric import Connection, Config
6
+
7
+ from .log_config import app_logger
8
+
9
+ logger = app_logger
10
+
11
+
12
+ def compress_dir(blog_path: Path) -> Path:
13
+ """
14
+ 将指定目录压缩为 public.tar.gz
15
+ """
16
+ logger.info(f'压缩目录: {blog_path}')
17
+ output_tar = blog_path.parent / 'public.tar.gz'
18
+
19
+ with tarfile.open(output_tar, "w:gz") as tar:
20
+ tar.add(str(blog_path), arcname="public")
21
+
22
+ logger.info(f'压缩完成: {output_tar}')
23
+ return output_tar
24
+
25
+
26
+ def deploy_blog(server_name: str, local_tar_path: Path, remote_web_root: str):
27
+ """
28
+ 将 tar.gz 文件部署到远程服务器
29
+ """
30
+ logger.info(f'开始部署 -> 服务器: {server_name},文件: {local_tar_path}')
31
+
32
+ sudo_pass = getpass("[sudo]: ")
33
+ config = Config(overrides={'sudo': {'password': sudo_pass}})
34
+ c = Connection(host=server_name, user='koril', config=config, connect_kwargs={'password': sudo_pass})
35
+
36
+ remote_home_path = f'/home/{c.user}'
37
+ remote_tar_path = f'{remote_home_path}/{local_tar_path.name}'
38
+ remote_target_path = f'{remote_web_root}blog'
39
+
40
+ try:
41
+ # 上传
42
+ c.put(str(local_tar_path), remote=remote_home_path)
43
+ logger.info('上传完成')
44
+
45
+ # 删除旧备份
46
+ c.sudo(f'rm -rf {remote_web_root}blog.bak')
47
+ logger.info('旧 blog.bak 删除')
48
+
49
+ # 备份 blog
50
+ c.sudo(f'mv {remote_target_path} {remote_target_path}.bak')
51
+ logger.info('blog -> blog.bak')
52
+
53
+ # 移动 tar.gz 并解压
54
+ c.sudo(f'mv {remote_tar_path} {remote_web_root}')
55
+ c.sudo(f'tar -xzf {remote_web_root}{local_tar_path.name} -C {remote_web_root}')
56
+ logger.info('解压完成')
57
+
58
+ # 清理
59
+ c.sudo(f'rm {remote_web_root}{local_tar_path.name}')
60
+ c.sudo(f'mv {remote_web_root}public {remote_target_path}')
61
+ logger.info('部署完成')
62
+
63
+ except Exception as e:
64
+ logger.exception(f"部署失败")
65
+ raise
djhx_blogger/gen.py ADDED
@@ -0,0 +1,367 @@
1
+ import shutil
2
+ import time
3
+ from collections import deque, OrderedDict
4
+ from importlib import resources
5
+ from pathlib import Path
6
+
7
+ import markdown
8
+ from bs4 import BeautifulSoup
9
+ from jinja2 import Template
10
+
11
+ from .log_config import app_logger
12
+
13
+ logger = app_logger
14
+
15
+ ignore_item = ['.git', 'LICENSE']
16
+
17
+ def load_template(name: str) -> str:
18
+ """读取 static/template/ 下的模板文件"""
19
+ file_path = resources.files("djhx_blogger.static.template").joinpath(name)
20
+ return file_path.read_text(encoding="utf-8")
21
+
22
+
23
+ class Node:
24
+ cache_map = {}
25
+
26
+ def __init__(self, source_path, destination_path, node_type):
27
+ # 该节点的源目录路径
28
+ self.source_path = source_path
29
+ # 该节点生成的结果目录路径
30
+ self.destination_path = destination_path
31
+ # 子节点
32
+ self.children = []
33
+ # 节点类型:
34
+ # 1. category 包含多个子目录
35
+ # 2. article 包含一个 index.md 文件和 images 目录
36
+ # 3. leaf index.md 或者 images 目录
37
+ self.node_type = node_type
38
+ # 描述分类或者文章的元信息(比如:文章的标题,简介和日期)
39
+ self.metadata = None
40
+
41
+ Node.cache_map[source_path] = self
42
+
43
+ def __str__(self):
44
+ return f'path={self.source_path}'
45
+
46
+
47
+ def walk_dir(dir_path_str: str, destination_blog_dir_name: str) -> Node:
48
+ """
49
+ 遍历目录,构造树结构
50
+ :param dir_path_str: 存放博客 md 文件的目录的字符串
51
+ :param destination_blog_dir_name: 生成博客目录的名称
52
+ :return: 树结构的根节点
53
+ """
54
+
55
+ start = int(time.time() * 1000)
56
+ q = deque()
57
+ dir_path = Path(dir_path_str)
58
+ q.append(dir_path)
59
+
60
+ # 生成目录的根路径
61
+ destination_root_dir = dir_path.parent.joinpath(destination_blog_dir_name)
62
+ logger.info(f'源路经: {dir_path}, 目标路径: {destination_root_dir}')
63
+
64
+ root = None
65
+
66
+ # 层次遍历
67
+ while q:
68
+ item = q.popleft()
69
+ if item.name in ignore_item:
70
+ logger.info(f'略过: {item.name}')
71
+ continue
72
+ if Path.is_dir(item):
73
+ [q.append(e) for e in item.iterdir()]
74
+
75
+ # node 类型判定
76
+ node_type = 'leaf'
77
+ if Path.is_dir(item):
78
+ node_type = 'category'
79
+ # 如果目录包含 index.md 则是文章目录节点
80
+ for e in item.iterdir():
81
+ if e.name == 'index.md':
82
+ node_type = 'article'
83
+ break
84
+
85
+ if not root:
86
+ root = Node(item, destination_root_dir, node_type)
87
+ else:
88
+ cur_node = Node.cache_map[item.parent]
89
+ # 计算相对路径
90
+ relative_path = item.relative_to(dir_path)
91
+ # 构造目标路径
92
+ destination_path = destination_root_dir / relative_path
93
+ if destination_path.name == 'index.md':
94
+ destination_path = destination_path.parent / Path('index.html')
95
+ n = Node(item, destination_path, node_type)
96
+ cur_node.children.append(n)
97
+ end = int(time.time() * 1000)
98
+ logger.info(f'构造树耗时: {end - start} ms')
99
+
100
+ return root
101
+
102
+
103
+ def md_to_html(md_file_path: Path) -> str:
104
+ """
105
+ markdown -> html
106
+ :param md_file_path: markdown 文件的路径对象
107
+ :return: html str
108
+ """
109
+
110
+ def remove_metadata(content: str) -> str:
111
+ """
112
+ 删除文章开头的 YAML 元信息
113
+ :param content: markdown 内容
114
+ """
115
+ lines = content.splitlines()
116
+ if lines and lines[0] == '---':
117
+ for i in range(1, len(lines)):
118
+ if lines[i] == '---':
119
+ return '\n'.join(lines[i + 1:])
120
+ return md_content
121
+
122
+ with open(md_file_path, mode='r', encoding='utf-8') as md_file:
123
+ md_content = md_file.read()
124
+ md_content = remove_metadata(md_content)
125
+ return markdown.markdown(
126
+ md_content,
127
+ extensions=[
128
+ 'markdown.extensions.toc',
129
+ 'markdown.extensions.tables',
130
+ 'markdown.extensions.sane_lists',
131
+ 'markdown.extensions.fenced_code'
132
+ ]
133
+ )
134
+
135
+
136
+ def gen_article_index(md_file_path: Path, article_name):
137
+
138
+ bs1 = BeautifulSoup(load_template('article.html'), "html.parser")
139
+ bs2 = BeautifulSoup(md_to_html(md_file_path), "html.parser")
140
+
141
+ article_metadata = read_metadata(md_file_path)
142
+
143
+ article_tag = bs1.find('article')
144
+ # 添加 h1 标题
145
+ h1_tag = bs1.new_tag('h1')
146
+ h1_tag.string = article_name
147
+ article_tag.insert(0, h1_tag)
148
+
149
+ # 添加日期信息
150
+ time_tag = bs1.new_tag('time', datetime=article_metadata["date"])
151
+ time_tag.string = '时间: ' + article_metadata["date"]
152
+
153
+ # 添加摘要信息
154
+ summary_tag = bs1.new_tag('p')
155
+ summary_tag.string = '摘要: ' + article_metadata["summary"]
156
+
157
+ # 包裹元信息
158
+ meta_wrapper = bs1.new_tag('div', **{"class": "article-meta"})
159
+ meta_wrapper.append(time_tag)
160
+ meta_wrapper.append(bs1.new_tag('br'))
161
+ meta_wrapper.append(summary_tag)
162
+
163
+ # 插入到 h1 之后
164
+ h1_tag.insert_after(meta_wrapper)
165
+
166
+ # 添加标题和正文之间的换行符
167
+ article_tag.append(bs1.new_tag('hr'))
168
+ # 添加正文内容
169
+ article_tag.append(bs2)
170
+ # 修改页面标题
171
+ bs1.find('title').string = f'文章 | {article_name}'
172
+
173
+ return bs1.prettify()
174
+
175
+
176
+ def gen_category_index(categories: list, category_name) -> str:
177
+ template = Template(load_template('category.html'))
178
+ html = template.render(categories=categories, category_name=category_name)
179
+ return html
180
+
181
+
182
+ def sort_categories(item):
183
+ """
184
+ 对 categories 排序,type = category 排在所有 type = article 前
185
+ category 按照 name 字典顺序 a-z 排序
186
+ article 按照 metadata 的 date 字段(格式:2024-02-03T14:44:42+08:00)降序排列。
187
+ :param item:
188
+ :return:
189
+ """
190
+ from datetime import datetime
191
+ if item['type'] == 'category':
192
+ # 分类优先,按 name 排序
193
+ return 0, item['name'].lower()
194
+ elif item['type'] == 'article':
195
+ # 文章按日期降序排序,优先级次于 category
196
+ # 将日期解析为 datetime 对象,若无日期则排在最后
197
+ date = item['metadata'].get('date')
198
+ parsed_date = datetime.fromisoformat(date) if date else datetime(year=1970, month=1, day=1)
199
+ return 1, -parsed_date.timestamp()
200
+
201
+
202
+ def gen_blog_dir(root: Node):
203
+ """
204
+ 根据目录树构造博客目录
205
+ :param root: 树结构根节点
206
+ :return:
207
+ """
208
+
209
+ start = int(time.time() * 1000)
210
+
211
+ q = deque()
212
+ q.append(root)
213
+
214
+ # 清理之前生成的 root destination
215
+ if Path.exists(root.destination_path):
216
+ logger.info(f'存在目标目录: {root.destination_path},进行删除')
217
+ shutil.rmtree(root.destination_path)
218
+
219
+ while q:
220
+ node = q.popleft()
221
+ [q.append(child) for child in node.children]
222
+
223
+ # 对三种不同类型的节点分别进行处理
224
+
225
+ if node.node_type == 'category' and node.source_path.name != 'images':
226
+ Path.mkdir(node.destination_path, parents=True, exist_ok=True)
227
+ category_index = node.destination_path / Path('index.html')
228
+ categories = []
229
+ for child in node.children:
230
+ if child:
231
+ if child.node_type == 'article':
232
+ child.metadata = read_metadata(child.source_path / Path('index.md'))
233
+ relative_path = child.destination_path.name / Path('index.html')
234
+ categories.append({
235
+ 'type': child.node_type,
236
+ 'name': child.destination_path.name,
237
+ 'href': relative_path,
238
+ 'metadata': child.metadata,
239
+ })
240
+ categories.sort(key=sort_categories)
241
+ with open(category_index, mode='w', encoding='utf-8') as f:
242
+ f.write(gen_category_index(categories, node.source_path.name))
243
+
244
+ if node.node_type == 'category' and node.source_path.name == 'images':
245
+ Path.mkdir(node.destination_path, parents=True, exist_ok=True)
246
+
247
+ if node.node_type == 'article':
248
+ Path.mkdir(node.destination_path, parents=True, exist_ok=True)
249
+
250
+ if node.node_type == 'leaf':
251
+ Path.mkdir(node.destination_path.parent, parents=True, exist_ok=True)
252
+ if node.source_path.name == 'index.md':
253
+ with open(node.destination_path, mode='w', encoding='utf-8') as f:
254
+ f.write(gen_article_index(node.source_path, node.source_path.parent.name))
255
+ else:
256
+ shutil.copy(node.source_path, node.destination_path)
257
+
258
+ end = int(time.time() * 1000)
259
+ logger.info(f'生成目标目录耗时: {end - start} ms')
260
+
261
+
262
+ def gen_blog_archive(blog_dir_str, public_name, root: Node):
263
+ """
264
+ 生成博客 archive 页面
265
+ 按照年份分栏,日期排序,展示所有的博客文章
266
+ """
267
+
268
+ root_node_path = root.destination_path
269
+ blog_dir = Path(blog_dir_str)
270
+
271
+ q = deque()
272
+ q.append(root)
273
+ articles = []
274
+ while q:
275
+ node = q.popleft()
276
+ [q.append(child) for child in node.children]
277
+ if node.node_type == 'article':
278
+ articles.append(node)
279
+
280
+ archives = OrderedDict()
281
+ # 先将所有文章按日期降序排列
282
+ articles_sorted = sorted(articles, key=lambda a: a.metadata['date'], reverse=True)
283
+
284
+ for article in articles_sorted:
285
+ article_name = article.source_path.name
286
+ full_path = article.destination_path / Path('index.html')
287
+ base_path = blog_dir.with_name(public_name)
288
+ url = full_path.relative_to(base_path)
289
+
290
+ article_datetime = article.metadata.get('date')
291
+ article_year = article_datetime[:4]
292
+ article_date = article_datetime[:10]
293
+ if article_year not in archives:
294
+ archives[article_year] = {
295
+ 'articles': [],
296
+ 'total': 0,
297
+ }
298
+
299
+ archives[article_year]['articles'].append({
300
+ 'date': article_date,
301
+ 'title': article_name,
302
+ 'url': url
303
+ })
304
+ archives[article_year]['total'] += 1
305
+
306
+
307
+ template = Template(load_template('archive.html'))
308
+ html = template.render(archives=archives)
309
+
310
+ root_node_path.joinpath('archive.html').write_text(data=html, encoding='utf-8')
311
+
312
+
313
+ def cp_resource(dir_path_str: str):
314
+ """将包内 static 资源复制到目标目录下的 public/"""
315
+ dir_path = Path(dir_path_str)
316
+ public_dir = dir_path.parent / "public"
317
+
318
+ # 1. 复制 css/
319
+ css_src = str(resources.files("djhx_blogger.static").joinpath("css"))
320
+ css_dst = public_dir / "css"
321
+ shutil.copytree(css_src, css_dst, dirs_exist_ok=True)
322
+
323
+ # 2. 复制 images/
324
+ images_src = str(resources.files("djhx_blogger.static").joinpath("images"))
325
+ images_dst = public_dir / "images"
326
+ shutil.copytree(images_src, images_dst, dirs_exist_ok=True)
327
+
328
+
329
+ def read_metadata(md_file_path):
330
+ import re
331
+ with open(md_file_path, 'r', encoding='utf-8') as file:
332
+ content = file.read()
333
+
334
+ # 正则提取元数据
335
+ match = re.match(r'^---\n([\s\S]*?)\n---\n', content)
336
+ if match:
337
+ metadata = match.group(1)
338
+ return parse_metadata(metadata)
339
+ return {}
340
+
341
+
342
+ def parse_metadata(metadata):
343
+ """
344
+ 将元数据解析为字典
345
+ title, date, summary
346
+ """
347
+ meta_dict = {}
348
+ for line in metadata.split('\n'):
349
+ if ':' in line:
350
+ key, value = map(str.strip, line.split(':', 1))
351
+ meta_dict[key] = value
352
+ return meta_dict
353
+
354
+
355
+ def generate_blog(blog_dir: str):
356
+ start = time.time()
357
+ public_name = 'public'
358
+
359
+ logger.info("开始生成博客文件结构...")
360
+ root_node = walk_dir(blog_dir, public_name)
361
+ gen_blog_dir(root_node)
362
+ gen_blog_archive(blog_dir, public_name, root_node)
363
+ cp_resource(str(blog_dir))
364
+
365
+ end = time.time()
366
+ logger.info(f'生成静态博客 {blog_dir}, 任务完成, 总耗时: {int((end-start)*1000)} ms')
367
+ return root_node
@@ -0,0 +1,38 @@
1
+ import logging.config
2
+
3
+ log_name = 'djhx_blogger'
4
+
5
+ log_config_dict = {
6
+ 'version': 1,
7
+ 'disable_existing_loggers': False,
8
+ 'formatters': {
9
+ 'default': {
10
+ 'format': '[%(asctime)s] - %(levelname)-8s :: %(message)s',
11
+ }
12
+ },
13
+ 'handlers': {
14
+ 'console_handler': {
15
+ 'class': 'logging.StreamHandler',
16
+ 'stream': 'ext://sys.stdout',
17
+ 'formatter': 'default',
18
+ 'level': 'DEBUG',
19
+ },
20
+ },
21
+ 'loggers': {
22
+ log_name: {
23
+ 'handlers': ['console_handler'],
24
+ 'level': 'DEBUG',
25
+ 'propagate': False,
26
+ }
27
+ },
28
+ 'root': {
29
+ 'handlers': ['console_handler'],
30
+ 'level': 'WARNING',
31
+ }
32
+ }
33
+
34
+ def log_init():
35
+ logging.config.dictConfig(log_config_dict)
36
+
37
+
38
+ app_logger = logging.getLogger(log_name)
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: djhx-blogger
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.13
6
+ License-File: LICENSE
7
+ Requires-Dist: beautifulsoup4>=4.14.2
8
+ Requires-Dist: fabric>=3.2.2
9
+ Requires-Dist: jinja2>=3.1.6
10
+ Requires-Dist: markdown>=3.9
11
+ Requires-Dist: typer>=0.20.0
12
+ Dynamic: license-file
@@ -0,0 +1,11 @@
1
+ djhx_blogger/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ djhx_blogger/__main__.py,sha256=MwMXMO3kuvG-luTG4lnLwjEnqnm20lI-amV9sETColo,61
3
+ djhx_blogger/cli.py,sha256=v5VmlVI5sZEFca1C5VEmi7u9GMpvNBVd3NBLZSfKpks,1445
4
+ djhx_blogger/deploy.py,sha256=tHJrOHaW8vQqN-YfpRLpjZOaUDXrWk_NVXnvPM014Hc,2056
5
+ djhx_blogger/gen.py,sha256=AhShduRqJWYqdBPYVXnsSQ0v4Z9QWhtRI78olNIkHl4,12409
6
+ djhx_blogger/log_config.py,sha256=aEvShHNahBO52w5I6WU4U2yTMAO7qIezcH2K1vSnbVo,871
7
+ djhx_blogger-0.1.0.dist-info/licenses/LICENSE,sha256=Whbb1w0-YAwWeAth-B6_jXSPWx9Fum73B0R-Z_lzUjA,1085
8
+ djhx_blogger-0.1.0.dist-info/METADATA,sha256=j0rHWln9Zz5bKDABfig0ceqROGGAxjO1Y3nqgFVIpHk,325
9
+ djhx_blogger-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
10
+ djhx_blogger-0.1.0.dist-info/top_level.txt,sha256=FZNu1SEldZAx_j_NmZoOxLha4G8-0KndmlFiPjPfJIk,13
11
+ djhx_blogger-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Koril33
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ djhx_blogger