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之前即可。

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

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

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的含义是关闭侧边栏。

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

再在source/rating/目录下创建一个rating.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的整数)

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让它根据你的需要来微调代码。

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安装了这个库后,使用如下命令调用脚本

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库

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文件的路径。

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文件其他部分的内容的作用参考上面贴出来的博客。

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