hexo-butterfly 主题添加影评书评页面。

1. 说明

在 25 年 1 月份,我的博客站点新增了一个影评书评页面,这个页面是基于之前添加的装备页面的修改而来的。

有关添加装备页面的教程,可以参考这篇博客:【Hexo】hexo-butterfly 主题添加装备展示页面 | 慕雪的寒舍

后续我在这个装备页面的基础上,让 GPT 帮忙修改了一下前端代码,实现了一个影评和书评的画廊视图页面。

image.png

本文将给出这个页面的 css 和 pug 文件,以及如何使用 github action 自动从我们的书评和影评文章来创建对应的 yaml 配置,用于最终生成这个页面。

2. 主题修改

本站的 butterfly 主题停留在古早的 4.9.5 版本,此项修改可能对最新版的 butterfly 主题无效!

另外,修改 hexo 主题的前提是使用 GIT 方式来安装 hexo 主题(主题文件在 themes 文件夹内),如果你使用 npm 方式安装主题,换了一个环境或者 npm 更新主题版本之后你的主题修改就丢失了。

2.1. 前端文件修改

这部分修改和添加装备页面需要做的修改一致。

修改 blog/themes/butterfly/layout/page.pug 文件,在 case page.type 的判断语句中新增一个 rating 的判断,添加在 default 之前即可。

plaintext
1
2
when 'rating'
include includes/page/rating.pug

随后再创建一个 blog/themes/butterfly/layout/includes/page/rating.pug 文件,写入如下内容

plaintext
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
#rating
if site.data.rating
each i in site.data.rating
.rating-item
h2.rating-item-title= i.class_name
.rating-item-description= i.description
.rating-item-content
each item, index in i.rating_list
.rating-item-content-item
.rating-item-content-item-cover
img.rating-item-content-item-image(data-lazy-src=url_for(item.image) onerror=`this.onerror=null;this.src='` + url_for(theme.error_img.flink) + `'` alt=item.name)
.rating-item-content-item-info
.rating-item-content-item-name= item.name
.rating-item-content-item-specification
// 定义评分渲染函数
- const renderRating = (rating) => {
- const fullStar = '★';
- const emptyStar = '☆';
- const maxRating = 5;
- let stars = '';
- for (let i = 0; i < maxRating; i++) {
- stars += i < rating ? fullStar : emptyStar;
- }
- // 格式化评分数字(保留一位小数)
- const formattedRating = Number(rating).toFixed(1);
- return `评分:${formattedRating} ${stars}`;
- }
| #{renderRating(item.specification)}
.rating-item-content-item-description= item.description
.rating-item-content-item-toolbar
if item.link.includes('https://') || item.link.includes('http://')
a.rating-item-content-item-link(href= item.link, target='_blank') 详情
else
a.rating-item-content-item-link(href= item.link, target='_blank') 查看文章

随后在 source 文件夹下创建一个 rating 文件夹,在该文件夹内创建一个 index.md 文件,写入如下内容。其中 title 可以根据你的喜好修改,aside: false 的含义是关闭侧边栏。

markdown
1
2
3
4
5
6
7
---
title: 影评 · 书评
date: 2025-01-04 16:45:12
aside: false
type: rating
---

再在 source/rating/ 目录下创建一个 rating.css 文件,写入如下内容

css
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
.rating-item-content {
display: flex;
flex-direction: row;
flex-wrap: wrap;
margin: 0 -8px;
}

.rating-item-content-item {
width: calc(25% - 12px);
border-radius: 12px;
border: 2px solid #979797;
overflow: hidden;
margin: 8px 6px;
background: var(--heo-card-bg);
box-shadow: var(--heo-shadow-border);
min-height: 400px;
position: relative;
}

@media screen and (max-width: 1200px) {
.rating-item-content-item {
width: calc(50% - 12px);
}
}

@media screen and (max-width: 768px) {
.rating-item-content-item {
width: 100%;
}
}

.rating-item-content-item-info {
padding: 8px 16px 16px 16px;
margin-top: 12px;
}

.rating-item-content-item-name {
font-size: 18px;
font-weight: bold;
line-height: 1;
margin-bottom: 8px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: fit-content;
}

.rating-item-content-item-specification {
font-size: 12px;
color: var(--heo-secondtext);
line-height: 1;
margin-bottom: 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}

.rating-item-content-item-description {
line-height: 20px;
color: var(--heo-secondtext);
height: 60px;
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
font-size: 14px;
}

a.rating-item-content-item-link {
font-size: 12px;
background: var(--heo-gray-op);
padding: 4px 8px;
border-radius: 8px;
cursor: pointer;
}

a.rating-item-content-item-link:hover {
background: var(--heo-main);
color: var(--heo-white);
}

h2.rating-item-title {
line-height: 1;
}

.rating-item-description {
line-height: 1;
margin: 4px 0 8px 0;
color: var(--heo-secondtext);
}

.rating-item-content-item-cover {
width: 100%;
height: 200px;
background: var(--heo-secondbg);
display: flex;
justify-content: center;
}

img.rating-item-content-item-image {
object-fit: cover;
height: 100%;
}

div#rating {
margin-top: 26px;
}

.rating-item-content-item-toolbar {
display: flex;
justify-content: space-between;
position: absolute;
bottom: 12px;
left: 0;
width: 100%;
padding: 0 16px;
}

a.bber-reply {
cursor: pointer;
}

到这里,主题的修改就完成了。

2.2. 评分配置文件

接下来要做的是新增一个对应的 yaml 配置文件,在 source/_data/ 下新增一个 rating.yml,评分项目的格式如下,包含了书评影评的名字、描述、文章链接、封面、评分(1 到 5 的整数)

yaml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- class_name: 电影世界
description: 光影交织,大梦一场
rating_list:
- name: 桑尼的优势
specification: 5
description: 制作精良,剧情在线,神级反转,好看!
image: https://img.musnow.top/i/2025/01/a35d66e1ea8ffae6bb5ff248d1f53c63.png
link: /posts/1438650502/
- class_name: 书籍海洋
description: 行万里路,读万卷书
rating_list:
- name: 占位符
specification: 4
description: 占位符
image: /img/bg/op32.jpg
link: /

到这里,就应该搞定了,可以在本地 hexo s 然后访问 /rating 路径看看是否有新增的页面了。

image.png

3. Github Action 自动化配置

现在页面已经创建好了,但我不想每次写个影评之后都要自己去修改 rating.yml 文件,那样太过麻烦,所以写了一个 python 脚本,来自动化生成这个文件。

3.1. 新增的 front-matter

既然需要 python 脚本来处理,那么第一步就是把 yaml 文件里面的内容写到 markdown 文件的 front-matter 里面。这里新增了下面几个字段

字段含义说明
rating_name 书籍或电影的名字如果缺少此字段,则会使用 title
rating_desc 书籍或电影的简述如果缺少此字段,则使用 description
rating_point 评分(1 到 5 的整数)默认为 0
rating_cover 书籍或电影的封面如果缺少此字段,则使用 cover;若没有 cover,则使用提前配置好的默认封面

书评和影评文章的 link 会根据 front-matter 里面的 abbrlink 来生成。

3.2. Python 处理脚本

下面给出半 GPT 写的完整的 Python 脚本,顶部 Config 是需要配置的条目,分别是书评和影评的 md 文件路径、当没有设置 rating_cover 时使用的默认封面、abbrlink 的前缀

其中 POST_LINK_PREFIX 参数是 abbrlink 前缀,在我的站点中,文章的链接都是 /posts/<abbrlink>/,abbrlink 的前缀就是 /posts/。这个参数根据你自己站点内 abbrlink 插件的配置来修改这个配置项。

剩下的代码,如果你不知道它们是怎么工作的,请不要修改。你可以丢给 GPT 让它根据你的需要来微调代码。

python
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
import yaml
import sys
import re
import os

class Config:
# 书籍、影评md文件路径
MOVIE_FILE_PATH = "Notes/CODE/blog/rating/movies"
BOOK_FILE_PATH = "Notes/CODE/blog/rating/books"
# 默认封面
DEFAULT_COVER = '/img/bg/op32.jpg'
# abbrlink前缀
POST_LINK_PREFIX = '/posts/'

class Rating:
"""评价列表类"""
MOVIE = '电影世界'
MOVIE_DESC = '光影交织,大梦一场'
BOOK = '书籍海洋'
BOOK_DESC = '行万里路,读万卷书'

# RatingItem类
class Item:
def __init__(self, name, specification, description, image, link):
self.name = name
self.specification = specification
self.description = description
self.image = image
self.link = link

def to_dict(self):
"""将 Rating 对象转换为字典格式"""
return {
'name': self.name,
'specification': self.specification,
'description': self.description,
'image': self.image,
'link': self.link
}

@staticmethod
def default():
"""获取到一个默认的item"""
return Rating.Item('占位符',0,'占位符',Config.DEFAULT_COVER,'/')

# 创建class内容的函数
@staticmethod
def create_class(class_name, description, rating_list):
return {
'class_name': class_name,
'description': description,
'rating_list': rating_list
}

class MarkdownParser:
"""md文件处理"""
@staticmethod
def extract_front_matter(file_path):
"""
提取 Markdown 文件中的 front-matter 内容。
假设 front-matter 是以 '---' 包围的 YAML 格式内容。
"""
with open(file_path, 'r', encoding='utf-8') as file:
content = file.read()

# 使用正则表达式匹配 front-matter
match = re.match(r'---\n(.*?)\n---\n', content, re.DOTALL)

if match:
front_matter = match.group(1)
return yaml.safe_load(front_matter) # 使用 yaml 解析 front-matter
else:
return None

@staticmethod
def extract_front_matter_from_dir(dir_path):
"""
遍历指定目录及其子目录下的所有 .md 文件,提取它们的 front-matter 内容,并将所有内容添加到列表中。
"""
front_matter_list = []

# 遍历目录中的所有文件和子目录
for root, dirs, files in os.walk(dir_path):
for filename in files:
file_path = os.path.join(root, filename)

# 只处理 .md 文件
if filename.endswith('.md'):
front_matter = MarkdownParser.extract_front_matter(file_path)
if front_matter:
front_matter_list.append(front_matter)

return front_matter_list

def generate_rating_list(file_path:str):
"""遍历目录下的所有md文件,构建rating列表"""
# 遍历md文件
front_matter_list = MarkdownParser.extract_front_matter_from_dir(file_path)
# 目录下没有有效文件
if not front_matter_list:
print(f"Err: no md file in {file_path}")
return [Rating.Item.default().to_dict()]

rating_list = []
for fm in front_matter_list:
# 电影名字
name = fm.get('rating_name', fm.get('title', None))
# 评价
desc = fm.get('rating_desc', fm.get('description', None))
# md文件里面没有电影名字和描述,直接跳过这个md文件
if not name and not desc:
continue
# 获取评分
rating_point = abs(fm.get('rating_point', 0))
if rating_point > 5:
rating_point = 5
# 封面
cover = fm.get('rating_cover', fm.get('cover', Config.DEFAULT_COVER))
link = Config.POST_LINK_PREFIX + str(fm.get('abbrlink','')) + '/'
item = Rating.Item(name, rating_point, desc, cover, link)
rating_list.append(item.to_dict())

# 如果列表为空,添加默认的占位符
if not rating_list:
print(f"Err: no validate md file in {file_path}")
rating_list.append(Rating.Item.default().to_dict())

return rating_list

# 生成整个YAML结构
def generate_rating_yaml():
# 电影世界的rating list
movie_ratings = generate_rating_list(Config.MOVIE_FILE_PATH)
# 书籍海洋的rating list
book_ratings = generate_rating_list(Config.BOOK_FILE_PATH)

# 创建每个class的字典
classes = [
Rating.create_class(Rating.MOVIE, Rating.MOVIE_DESC, movie_ratings),
Rating.create_class(Rating.BOOK, Rating.BOOK_DESC, book_ratings),
]

# 返回整个YAML数据结构
return classes

# 保存数据到YAML文件
def save_yaml(data, file_path):
with open(file_path, 'w', encoding='utf-8') as file:
yaml.dump(data, file, allow_unicode=True, default_flow_style=False)

# 主函数
if __name__ == '__main__':
# 检查命令行参数是否传入文件路径
if len(sys.argv) < 2:
print("Input Err: Please provide the YAML file path.")
sys.exit(1) # 退出程序并返回错误代码

# 获取命令行传入的文件路径
file_path = sys.argv[1]

# 生成YAML数据
data = generate_rating_yaml()

# 保存到文件
save_yaml(data, file_path)
print(f'Rating Yaml save to {file_path}')

脚本依赖于 pyyaml 库,使用 pip install pyyaml 安装了这个库后,使用如下命令调用脚本

bash
1
python3 test.py 目标YAML文件路径

最终会在给定的目标文件路径写入生成的 yaml 内容,比如 python3 test.py ./rating.yml 就会在当前目录创建一个 rating.yml 文件并写入生成的内容。

3.3. Github Action 文件

配置 Github Action 之前,请先参考【博客】使用 GithubAction 自动同步 obisidian 和 hexo 仓库 | 慕雪的寒舍一文配置 obsidian 和 hexo 仓库的自动同步。后续的 Action 配置是在这个配置的基础之上的。

首先需要添加两个 step,配置 python 环境并安装解析 yaml 文件需要的 pyyaml 库

yaml
1
2
3
4
5
6
7
8
9
10
11
# 设置 Python 环境
- name: Set up Python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.10' # 使用的 Python 版本,可以根据需求选择 3.x 或具体版本号

# 安装 pip 依赖
- name: Install pip dependencies
run: |
python -m pip install --upgrade pip
pip install pyyaml

然后再在 push 之前添加一个脚本调用就可以了。脚本提供的参数是目标 yaml 文件的路径。

yaml
1
2
3
4
# 处理影视和书评评分的yaml文件
- name: Generate rating.yml from raw markdown
run: |
python Data/python_scripts/gen_rating.py HexoBlog/source/_data/rating.yml

最终的完整 action 文件如下。这个 yaml 文件其他部分的内容的作用参考上面贴出来的博客。

yaml
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
name: Sync CODE to _posts

on:
push:
paths:
- 'Notes/CODE/**' # 监听 CODE 文件夹内的文件变化

jobs:
sync:
runs-on: ubuntu-latest

steps:
# 检出 Obsidian 仓库的代码
- name: Checkout obsidian repository
uses: actions/checkout@v3

# 设置 Python 环境
- name: Set up Python 3.10
uses: actions/setup-python@v4
with:
python-version: '3.10' # 使用的 Python 版本,可以根据需求选择 3.x 或具体版本号

# 安装 pip 依赖
- name: Install pip dependencies
run: |
python -m pip install --upgrade pip
pip install pyyaml

# 设置 Git 配置
- name: Set up Git
env:
ACTIONS_KEY: ${{ secrets.HEXO_PRI_KEY }}
run: |
mkdir -p ~/.ssh/
echo "$ACTIONS_KEY" > ~/.ssh/id_rsa
chmod 700 ~/.ssh
chmod 600 ~/.ssh/id_rsa
ssh-keyscan github.com >> ~/.ssh/known_hosts
git config --global user.name "musnows"
git config --global user.email "ezplayingd@126.com"
git config --global core.quotepath false
git config --global i18n.commitEncoding utf-8
git config --global i18n.logOutputEncoding utf-8

# 克隆 HexoBlog 仓库(私密仓库),使用 ssh 来进行认证
- name: Checkout HexoBlog repository
run: |
git clone git@github.com:musnows/Hexo-Blog.git HexoBlog

# 同步文件:将 A 仓库中的 CODE 文件夹内容复制到 HexoBlog 仓库的 _posts 文件夹
- name: Sync files from CODE to _posts
run: |
rsync -av --delete Notes/CODE/ HexoBlog/source/_posts/

# 处理影视和书评评分的yaml文件
- name: Generate rating.yml from raw markdown
run: |
python Data/python_scripts/gen_rating.py HexoBlog/source/_data/rating.yml

# 提交更改并推送到 HexoBlog 仓库
- name: Commit and push changes to HexoBlog repository
run: |
cd HexoBlog
git add .
git commit -m "Sync CODE to _posts at $(TZ='Asia/Shanghai' date '+%Y-%m-%d %H:%M:%S')"
git push origin hexo

3.4. 测试 Action

将脚本和更新后的 action 文件 push 到远端,触发 action 之后,就能看到结果了,成功根据 md 文件里面的配置生成出了 yaml 文件。

image.png