Hugo系列(九):添加NeoDB书影音页面

Hugo系列(九):添加NeoDB书影音页面

通过 Neodb API 获取书影音数据,并展示在 Hugo 页面。

前言

之前通过 doumark-action 项目获取了豆瓣观影数据,并存入博客仓库,然后展示在页面,由于豆瓣关闭了 API,因此获取到的封面其实是项目作者反代后的地址,然而最近图片全部挂掉,后面尝试将条目封面换成 NeoDB 的封面(详情见 Hugo系列(五):添加豆瓣观影页面),但是替换会使得部署时间大幅提高,所以直接一步到位,通过 doumark-action 同步到 NeoDB,然后使用 GitHub Action 下载数据,最后通过模板展示。

Note

文章参考了:NeoDB API 创建观影页面

豆瓣同步数据到 NeoDB

豆瓣同步到 NeoDB 的方法很简单,参考 doumark-action 文档

NeoDB 数据下载

NeoDB 的数据下载需要申请 API,详情可以参考下面的这篇文章和官方文档:

申请到 API 后在博客仓库创建一个工作流,路径为 ~/.github/workflows/neodb.yml

 1name: Sync NeoDB Data
 2
 3on:
 4  schedule:
 5    - cron: "0 17 * * *"
 6  workflow_dispatch:
 7
 8env:
 9  NEODB_DATA_PATH: assets/data/neodb
10
11jobs:
12  sync_neodb:
13    name: Sync NeoDB Data
14    runs-on: ubuntu-latest
15    steps:
16    - name: Checkout
17      uses: actions/checkout@v3
18
19    - name: Setup Environment
20      run: |
21        # 检查是否安装了 jq
22        if ! command -v jq &> /dev/null; then
23          echo "jq is not installed. Installing..."
24          sudo apt-get update
25          sudo apt-get install -y jq
26        else
27          echo "jq is already installed."
28        fi
29        # 创建数据目录
30        mkdir -p ${{ env.NEODB_DATA_PATH }}        
31
32    - name: Get Local and Remote Count
33      run: |
34        # 获取本地 count
35        if [ -f "${{ env.NEODB_DATA_PATH }}/media.json" ]; then
36          LOCAL_COUNT=$(jq -r '.count // 0' ${{ env.NEODB_DATA_PATH }}/media.json)
37        else
38          LOCAL_COUNT=0
39        fi
40        echo "LOCAL_COUNT=$LOCAL_COUNT" >> $GITHUB_ENV
41
42        # 获取远程 count
43        RESPONSE=$(curl -s -X 'GET' \
44          'https://neodb.social/api/me/shelf/complete?page=1' \
45          -H 'accept: application/json' \
46          -H "Authorization: Bearer ${{ secrets.NEODB_ACCESS_TOKEN }}")
47
48        REMOTE_COUNT=$(echo "$RESPONSE" | jq -r '.count')
49        REMOTE_PAGES=$(echo "$RESPONSE" | jq -r '.pages')
50
51        echo "REMOTE_COUNT=$REMOTE_COUNT" >> $GITHUB_ENV
52        echo "REMOTE_PAGES=$REMOTE_PAGES" >> $GITHUB_ENV        
53
54    - name: Sync NeoDB Data
55      if: env.LOCAL_COUNT != env.REMOTE_COUNT
56      run: |
57        mkdir -p ${{ env.NEODB_DATA_PATH }}/temp_data
58
59        for ((i=1; i<=${{ env.REMOTE_PAGES }}; i++)); do
60          url="https://neodb.social/api/me/shelf/complete?page=$i"
61          filename="${{ env.NEODB_DATA_PATH }}/temp_data/page$i.json"
62          curl -X 'GET' "$url" \
63            -H 'accept: application/json' \
64            -H "Authorization: Bearer ${{ secrets.NEODB_ACCESS_TOKEN }}" > "$filename"
65        done
66
67        # 合并所有数据到一个文件
68        jq -c -s '{
69          data: map(.data[]) | unique | sort_by(.created_time) | reverse,
70          pages: .[0].pages,
71          count: .[0].count
72        }' ${{ env.NEODB_DATA_PATH }}/temp_data/*.json > "${{ env.NEODB_DATA_PATH }}/media.json"
73
74        # 清理临时文件
75        rm -rf ${{ env.NEODB_DATA_PATH }}/temp_data        
76
77    - name: Git Add and Commit
78      if: env.LOCAL_COUNT != env.REMOTE_COUNT
79      uses: EndBug/add-and-commit@v9
80      with:
81        message: 'chore(data): update neodb data'
82        add: '${{ env.NEODB_DATA_PATH }}'

然后在仓库 settings - Secrets and variables - ActionsRepository secrets 点击 New repository secret,创建一个名称为 NEODB_ACCESS_TOKEN 的密钥,值为获取到的 NeoDB API

这个工作流会检查本地 ~/assets/data/neodb/media.json 中条目的数量和 NeoDB 获取到标记为完成的数量是否一致,如果不一致,则在 assets/data/neodb 下载数据并保存为 media.json,如果一致则跳过。

Note

Hugo 会在 0.136 版本舍弃 get.CSV 函数,而 resource.Get 获取全局数据默认路径为 assets 文件夹,因此上述代码中 NEODB_DATA_PATH 是在 assets 中。

添加书影音页面

新建 ~/layouts\_default\media.html 并写入代码:

  1{{- define "title" -}}
  2  {{- title .Title -}}
  3  {{- if .Site.Params.withSiteTitle }} {{ .Site.Params.titleDelimiter }} {{ .Site.Title }}{{- end -}}
  4{{- end -}}
  5
  6{{- define "content" -}}
  7  {{- $title := title .Title -}}
  8  {{- $params := partial "function/params.html" -}}
  9  {{- $toc := .Scratch.Get "toc" -}}
 10  {{- $tocEmpty := eq .TableOfContents `<nav id="TableOfContents"></nav>` -}}
 11  {{/*  样式引入  */}}
 12  <link rel="stylesheet" href="/movie.css">
 13  <div class="media-page">
 14    <header>
 15      <h1>{{ .Title }}</h1>
 16    </header>
 17
 18    <div class="media-filters">
 19      <span class="category-item active" data-category="all">全部</span>
 20      <span class="category-item" data-category="movie">电影</span>
 21      <span class="category-item" data-category="tv">电视剧</span>
 22      <span class="category-item" data-category="book">书籍</span>
 23      <span class="category-item" data-category="music">音乐</span>
 24      <span class="category-item" data-category="game">游戏</span>
 25      <span class="category-item" data-category="podcast">播客</span>
 26    </div>
 27
 28    <div class="media-grid">
 29      {{ $mediaResource := resources.Get "data/neodb/media.json" }}
 30      {{ $media := $mediaResource | transform.Unmarshal }}
 31      {{ range $media.data }}
 32        {{ $title := .item.title }}
 33        {{ $localizedTitles := .item.localized_title }}
 34        {{ $orig_title := (index ($localizedTitles) 1).text }}
 35        {{ $category := .item.category }}
 36        {{ $cover := .item.cover_image_url }}
 37        {{ $rating := float .item.rating }}
 38        {{ $rating_percent := mul $rating 10 }}
 39        {{ $rating_count := .item.rating_count }}
 40        {{ $id := .item.id }}
 41        {{ $brief := .item.brief }}
 42        {{ $comment_text := .item.comment_text }}
 43        {{ $type := .item.type }}
 44
 45        <div class="media-card m-cate-{{ $category }}">
 46          <div class="media-cover">
 47            <img class="lazyload" data-src="{{ $cover }}" alt="{{ $title }}">
 48          </div>
 49          <div class="media-overlay">
 50            <div class="media-overlay-content">
 51              <div class="media-localized-title">
 52                <h2 class="media-title">
 53                    <a href="{{ $id }}">{{ $title }}</a>
 54                </h2>
 55                <span class="media-orig-title">{{ $orig_title }}</span>
 56              </div>
 57              <div class="media-rating">
 58                <div class="stars">
 59                  <div class="star-bg"></div>
 60                  <div class="star-fill" style="width: {{ $rating_percent }}%"></div>
 61                </div>
 62              </div>
 63                <div class="media-rating-info">
 64                    <span class="rating-num">{{ $rating }} 分</span>
 65                    <span class="rating-count">{{ $rating_count }} 人点评</span>
 66                </div>
 67              </div>
 68              <div class="media-brief">
 69                {{ if $comment_text }}
 70                  <p>短评:{{ $comment_text }}</p>
 71                {{ else }}
 72                  <p>简介:{{ $brief }}</p>
 73                {{ end }}
 74              </div>
 75              <div class="media-type">
 76                {{ $type }}
 77              </div>
 78            </div>
 79          </div>
 80      {{ end }}
 81    </div>
 82  </div>
 83
 84
 85  {{/*  图片懒加载  */}}
 86  <script src='{{ "/js/lazyload.iife.min.js" | relURL }}'></script>
 87  <script>
 88    document.addEventListener("DOMContentLoaded", function() {
 89      new LazyLoad({
 90        elements_selector: ".lazyload"
 91      });
 92    });
 93  </script>
 94
 95  {{/*  类别过滤  */}}
 96  <script>
 97    const buttons = document.querySelectorAll('.category-item');
 98    const cards = document.querySelectorAll('.media-card');
 99
100    buttons.forEach(button => {
101      button.addEventListener('click', (event) => {
102        const category = event.target.dataset.category;
103        buttons.forEach(btn => {
104          btn.classList.remove('active');
105        });
106        event.target.classList.add('active');
107
108        if (category === 'all') {
109          cards.forEach(card => {
110            card.classList.remove('hidden');
111          });
112        } else {
113          cards.forEach(card => {
114            if (card.classList.contains(`m-cate-${category}`)) {
115              card.classList.remove('hidden');
116            } else {
117              card.classList.add('hidden');
118            }
119          });
120        }
121      });
122    });
123  </script>
124{{ end }}
Tip

样式引入前的代码和最后的 {{ end }} 是本主题的模板,其他主题需要更换

~/static 文件夹下新建 media.css 文件,写入样式:

  1/* media.css */
  2.media-page {
  3  max-width: 1200px;
  4  margin: 0 auto;
  5  padding: 20px;
  6}
  7
  8.media-filters {
  9  margin-bottom: 20px;
 10  display: flex;
 11  flex-wrap: wrap;
 12}
 13
 14.category-item {
 15  cursor: pointer;
 16  margin-right: 10px;
 17  padding: 5px 10px;
 18  border-radius: 5px;
 19  background-color: #f3f4f6;
 20  color: #334155;
 21  font-size: 0.875rem;
 22  line-height: 1.25rem;
 23  margin-bottom: 0.5rem;
 24}
 25.category-item:hover {
 26  background-color: #e2e8f0;
 27}
 28
 29.category-item.active {
 30  background-color: #4b5563;
 31  color: #f3f4f6;
 32}
 33
 34.media-grid {
 35  display: grid;
 36  grid-template-columns: repeat(5, 1fr);
 37  gap: 10px;
 38}
 39
 40.media-card {
 41  position: relative;
 42  border-radius: 5px;
 43  overflow: hidden;
 44  transition: transform 0.5s ease, box-shadow 0.5s ease;
 45  background-color: rgba(255, 255, 255, 0.2); /* 半透明背景 */
 46  box-shadow: 0 4px 10px rgba(0, 0, 0, 0.1); /* 轻微阴影 */
 47}
 48
 49.media-card:hover {
 50  transform: scale(1.05) translateY(-8px); /* 悬浮时放大并上移 */
 51  box-shadow: 0 8px 20px rgba(0, 0, 0, 0.2); /* 增强悬浮时的阴影效果 */
 52}
 53
 54/* 媒体封面 */
 55.media-cover {
 56  position: relative;
 57  overflow: hidden;
 58  aspect-ratio: 2 / 3;
 59  transition: filter 0.5s ease; /* 过渡效果应用于模糊和亮度 */
 60}
 61
 62.media-cover img {
 63  width: 100%;
 64  height: 100%;
 65  object-fit: cover;
 66  transition: filter 0.5s ease; /* 添加图片模糊和亮度过渡 */
 67}
 68
 69.media-card:hover .media-cover img {
 70  filter: blur(5px) brightness(75%); /* 仅对封面图片应用模糊和亮度降低 */
 71}
 72
 73
 74.media-overlay {
 75  position: absolute;
 76  top: 0;
 77  left: 0;
 78  right: 0;
 79  bottom: 0;
 80  background-color: rgba(0, 0, 0, 0.7);
 81  color: #fff;
 82  opacity: 0;
 83  transition: opacity 0.3s ease;
 84  display: flex;
 85  flex-direction: column;
 86  justify-content: center;
 87  align-items: flex-start;
 88  text-align: left;
 89  padding: 10px;
 90}
 91
 92.media-card:hover .media-overlay {
 93  opacity: 1;
 94}
 95
 96.media-overlay-content {
 97  width: 100%;
 98}
 99
100.media-localized-title h2 {
101  font-size: 1.25rem;
102  font-weight: 500;
103  margin: 0;
104}
105.media-localized-title a {
106  color: #f3f4f6;
107}
108.media-localized-title a:hover {
109  text-decoration: underline;
110  color: #ff9800;
111}
112
113.media-orig-title {
114  font-size: 0.875rem;
115  color: #f3f4f6;
116}
117
118.stars {
119  position: relative;
120  display: inline-block;
121  width: 80px; /* 星星的总宽度 */
122  height: 16px; /* 星星的高度 */
123}
124
125.star-bg, .star-fill {
126  position: absolute;
127  top: 0;
128  left: 0;
129  height: 100%;
130  background-size: 16px 16px; /* 星星的大小 */
131  background-repeat: repeat-x;
132}
133
134.star-bg {
135  width: 100%;
136  background-image: url(data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMzIiIGhlaWdodD0iMzIiPjxwYXRoIGQ9Ik05MDguMSAzNTMuMWwtMjUzLjktMzYuOUw1NDAuNyA4Ni4xYy0zLjEtNi4zLTguMi0xMS40LTE0LjUtMTQuNS0xNS44LTcuOC0zNS0xLjMtNDIuOSAxNC41TDM2OS44IDMxNi4ybC0yNTMuOSAzNi45Yy03IDEtMTMuNCA0LjMtMTguMyA5LjMtMTIuMyAxMi43LTEyLjEgMzIuOS42IDQ1LjNsMTgzLjcgMTc5LjEtNDMuNCAyNTIuOWMtMS4yIDYuOS0uMSAxNC4xIDMuMiAyMC4zIDguMiAxNS42IDI3LjYgMjEuNyA0My4yIDEzLjRMNTEyIDc1NGwyMjcuMSAxMTkuNGM2LjIgMy4zIDEzLjQgNC40IDIwLjMgMy4yIDE3LjQtMyAyOS4xLTE5LjUgMjYuMS0zNi45bC00My40LTI1Mi45IDE4My43LTE3OS4xYzUtNC45IDguMy0xMS4zIDkuMy0xOC4zIDIuNy0xNy41LTkuNS0zMy43LTI3LTM2LjN6TTY2NC44IDU2MS42bDM2LjEgMjEwLjNMNTEyIDY3Mi43IDMyMy4xIDc3MmwzNi4xLTIxMC4zLTE1Mi44LTE0OUw0MTcuNiAzODIgNTEyIDE5MC43IDYwNi40IDM4MmwyMTEuMiAzMC43LTE1Mi44IDE0OC45eiIgZmlsbD0iI2Y5OWIwMSIvPjwvc3ZnPg==);
137}
138
139.star-fill {
140  width: 100%;
141  background-image: url(data:image/svg+xml;base64,PHN2ZyBjbGFzcz0iaWNvbiIgdmlld0JveD0iMCAwIDEwMjQgMTAyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB3aWR0aD0iMzIiIGhlaWdodD0iMzIiPjxwYXRoIGQ9Ik05MDguMSAzNTMuMWwtMjUzLjktMzYuOUw1NDAuNyA4Ni4xYy0zLjEtNi4zLTguMi0xMS40LTE0LjUtMTQuNS0xNS44LTcuOC0zNS0xLjMtNDIuOSAxNC41TDM2OS44IDMxNi4ybC0yNTMuOSAzNi45Yy03IDEtMTMuNCA0LjMtMTguMyA5LjMtMTIuMyAxMi43LTEyLjEgMzIuOS42IDQ1LjNsMTgzLjcgMTc5LjEtNDMuNCAyNTIuOWMtMS4yIDYuOS0uMSAxNC4xIDMuMiAyMC4zIDguMiAxNS42IDI3LjYgMjEuNyA0My4yIDEzLjRMNTEyIDc1NGwyMjcuMSAxMTkuNGM2LjIgMy4zIDEzLjQgNC40IDIwLjMgMy4yIDE3LjQtMyAyOS4xLTE5LjUgMjYuMS0zNi45bC00My40LTI1Mi45IDE4My43LTE3OS4xYzUtNC45IDguMy0xMS4zIDkuMy0xOC4zIDIuNy0xNy41LTkuNS0zMy43LTI3LTM2LjN6IiBmaWxsPSIjZjk5YjAxIi8+PC9zdmc+);
142}
143
144.media-rating-info span {
145  font-size: 0.875rem;
146  margin-top: 2px;
147  background-color: #334155;
148  border-radius: 3px;
149  padding: 3px;
150}
151
152.media-brief p {
153  font-size: 14px;
154  margin: 10px 0;
155  overflow: hidden;
156  text-overflow: ellipsis;
157  display: -webkit-box;
158  -webkit-line-clamp: 3;
159  -webkit-box-orient: vertical;
160}
161
162.media-type {
163  position: absolute;
164  top: 0;
165  right: 0;
166  background-color: #ff9800;
167  color: #fff;
168  padding: 5px 10px;
169  border-radius: 0 0 0 5px;
170  opacity: 0;
171  transition: opacity 0.3s ease;
172}
173
174.media-card:hover .media-type {
175  opacity: 1;
176}
177
178.hidden {
179  display: none;
180}
181
182@media (max-width: 600px) {
183  .media-grid {
184    grid-template-columns: repeat(2, 1fr);
185    gap: 8px; /* 减小卡片间距 */
186  }
187
188  .media-card:hover {
189    transform: scale(1.03) translateY(-4px); /* 减小悬浮效果 */
190  }
191
192  .media-overlay {
193    padding: 6px; /* 减小内边距 */
194  }
195
196  .media-localized-title h2 {
197    font-size: 1rem; /* 减小标题字体大小 */
198  }
199
200  .media-orig-title {
201    font-size: 0.75rem; /* 减小原标题字体大小 */
202  }
203
204  .star {
205    font-size: 1rem; /* 减小星星大小 */
206  }
207
208  .media-rating-info span {
209    font-size: 0.75rem; /* 减小评分信息字体大小 */
210    padding: 2px; /* 减小评分信息内边距 */
211  }
212
213  .media-brief p {
214    font-size: 12px; /* 减小简介字体大小 */
215    margin: 6px 0; /* 减小上下间距 */
216    -webkit-line-clamp: 2; /* 减少显示行数 */
217  }
218
219  .media-type {
220    padding: 3px 6px; /* 减小类型标签内边距 */
221    font-size: 0.75rem; /* 减小类型标签字体大小 */
222  }
223
224  .category-item {
225    padding: 3px 6px; /* 减小分类按钮内边距 */
226    font-size: 0.75rem; /* 减小分类按钮字体大小 */
227    margin-right: 6px; /* 减小分类按钮右侧间距 */
228    margin-bottom: 0.3rem; /* 减小分类按钮下方间距 */
229  }
230}

最后在 ~/static/js/ 文件夹下放入 lazyload.iife.min.js 代码文件,可以点击此链接下载保存,上述代码中已经包括了样式文件和懒加载文件的引入。

完成后新建 ~/content/media.md 文件,写入信息:

1---
2title: 书影音记录
3layout: "media"
4---

最后在 Hugo 站点配置中添加菜单即可。

参考

Hugo系列(九):添加NeoDB书影音页面

https://blog.grew.cc/posts/neodb-media/

作者

Tom

创建时间

2024-10-10

最后更新时间

2024-10-10

许可协议

CC BY 4.0