hexo-butterfly主题添加影评书评页面。
1. 说明
在25年1月份,我的博客站点新增了一个影评书评页面,这个页面是基于之前添加的装备页面的修改而来的。
有关添加装备页面的教程,可以参考这篇博客:【Hexo】hexo-butterfly主题添加装备展示页面 | 慕雪的寒舍
后续我在这个装备页面的基础上,让GPT帮忙修改了一下前端代码,实现了一个影评和书评的画廊视图页面。

本文将给出这个页面的css和pug文件,以及如何使用github action自动从我们的书评和影评文章来创建对应的yaml配置,用于最终生成这个页面。
2. 主题修改
本站的butterfly主题停留在古早的4.9.5版本,此项修改可能对最新版的butterfly主题无效!
更新:本站已经更新至5.3.5版本,本文有效。
另外,修改hexo主题的前提是使用GIT方式来安装hexo主题(主题文件在themes文件夹内),如果你使用npm方式安装主题,换了一个环境或者npm更新主题版本之后你的主题修改就丢失了。
2.1. 前端文件修改
这部分修改和添加装备页面需要做的修改一致。
修改blog/themes/butterfly/layout/page.pug文件,在case page.type的判断语句中新增一个rating的判断,添加在default之前即可。
| 12
 
 | when 'rating'include includes/page/rating.pug
 
 | 
随后再创建一个blog/themes/butterfly/layout/includes/page/rating.pug文件,写入如下内容
| 12
 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
 
 | #ratingif 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的含义是关闭侧边栏。
| 12
 3
 4
 5
 6
 7
 
 | ---title: 影评 · 书评
 date: 2025-01-04 16:45:12
 aside: false
 type: rating
 ---
 
 
 | 
再在source/rating/目录下创建一个rating.css文件,写入如下内容
| 12
 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的整数)
| 12
 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.webp
 link: /
 
 | 
到这里,就应该搞定了,可以在本地hexo s然后访问/rating路径看看是否有新增的页面了。

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让它根据你的需要来微调代码。
| 12
 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 yamlimport sys
 import re
 import os
 
 class Config:
 
 MOVIE_FILE_PATH = "Notes/CODE/blog/rating/movies"
 BOOK_FILE_PATH = "Notes/CODE/blog/rating/books"
 
 DEFAULT_COVER = '/img/bg/op32.jpg'
 
 POST_LINK_PREFIX = '/posts/'
 
 class Rating:
 """评价列表类"""
 MOVIE = '电影世界'
 MOVIE_DESC = '光影交织,大梦一场'
 BOOK = '书籍海洋'
 BOOK_DESC = '行万里路,读万卷书'
 
 
 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,'/')
 
 
 @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()
 
 
 match = re.match(r'---\n(.*?)\n---\n', content, re.DOTALL)
 
 if match:
 front_matter = match.group(1)
 return yaml.safe_load(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)
 
 
 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列表"""
 
 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))
 
 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
 
 
 def generate_rating_yaml():
 
 movie_ratings = generate_rating_list(Config.MOVIE_FILE_PATH)
 
 book_ratings = generate_rating_list(Config.BOOK_FILE_PATH)
 
 
 classes = [
 Rating.create_class(Rating.MOVIE, Rating.MOVIE_DESC, movie_ratings),
 Rating.create_class(Rating.BOOK, Rating.BOOK_DESC, book_ratings),
 ]
 
 
 return classes
 
 
 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]
 
 
 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库
| 12
 3
 4
 5
 6
 7
 8
 9
 10
 11
 
 | - name: Set up Python 3.10
 uses: actions/setup-python@v4
 with:
 python-version: '3.10'
 
 
 - name: Install pip dependencies
 run: |
 python -m pip install --upgrade pip
 pip install pyyaml
 
 | 
然后再在push之前添加一个脚本调用就可以了。脚本提供的参数是目标yaml文件的路径。
| 12
 3
 4
 
 | - name: Generate rating.yml from raw markdown
 run: |
 python Data/python_scripts/gen_rating.py HexoBlog/source/_data/rating.yml
 
 | 
最终的完整action文件如下。这个yaml文件其他部分的内容的作用参考上面贴出来的博客。
| 12
 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/**'
 
 jobs:
 sync:
 runs-on: ubuntu-latest
 
 steps:
 
 - name: Checkout obsidian repository
 uses: actions/checkout@v3
 
 
 - name: Set up Python 3.10
 uses: actions/setup-python@v4
 with:
 python-version: '3.10'
 
 
 - name: Install pip dependencies
 run: |
 python -m pip install --upgrade pip
 pip install pyyaml
 
 
 - 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
 
 
 - name: Checkout HexoBlog repository
 run: |
 git clone git@github.com:musnows/Hexo-Blog.git HexoBlog
 
 
 - name: Sync files from CODE to _posts
 run: |
 rsync -av --delete Notes/CODE/ HexoBlog/source/_posts/
 
 
 - name: Generate rating.yml from raw markdown
 run: |
 python Data/python_scripts/gen_rating.py HexoBlog/source/_data/rating.yml
 
 
 - 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文件。
