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

本文将给出这个页面的 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
路径看看是否有新增的页面了。

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: 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 库
1 2 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 文件的路径。
1 2 3 4
| - 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/**'
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 文件。
