前言
几个月之前在 长毛象联邦宇宙 里问过 NeoDB 官方有没有 API,得到肯定回答后,我就着手计划把观影页面的 API 搬到 NeoDB 了。前几天豆瓣的图片挂掉之后,加快了这一进程。
感谢豆瓣以前提供的无偿服务。不过这也印证了 SaaS 服务不可信 的观点。
有很多吐槽,但是算了,直接开始写备忘录。
我没有使用通过 API 获取动态数据的方式,而是把数据都下载到本地。静态化后性能会更好。
1. 注册 NeoDB 账号
注册 NeoDB 账号前,需要注册一个 Mastodon 长毛象宇宙的账号,有很多实例可以注册。然后用 Mastodon 账号就可以登录 NeoDB 了。最新的 NeoDB 似乎已经可以绑定邮箱登录了。
注册 Mastodon 和 NeoDB 这些都是小事情,暂时略过,默认任何人都会了。
比如我就注册在 mastodon.social ,我以前还自建过 Mastodon,不过没必要。
2. 生成 NeoDB 的 Token
参考:《 NeoDB 获取 Access Token 》一文。
3. 标记影音
3.1 在 NeoDB 标记:
3.2 在 NeoDB 数据 设置里导入其他平台标记的数据:
4. 下载 NeoDB 数据
因为 NeoDB 限制分页,需要按页数下载,不能一次下载所有数据。
就写了个 Shell Script 脚本下载:
注意替换 QuhZZpr8bE711111111111X2OPaSRKU
即 Access Token
。
#! /bin/sh
curl -X 'GET' 'https://neodb.social/api/me/shelf/complete?category=movie&page=1' \
-H 'accept: application/json' \
-H 'Authorization: Bearer QuhZZpr8bE711111111111X2OPaSRKU' > movie1.json
curl -X 'GET' 'https://neodb.social/api/me/shelf/complete?category=tv&page=1' \
-H 'accept: application/json' \
-H 'Authorization: Bearer QuhZZpr8bE711111111111X2OPaSRKU' > tv1.json
pages=$(jq '.pages' movie1.json)
tv_pages=$(jq '.pages' tv1.json)
# 下载 Movie 分类
# 循环下载文件,因为 page 1 已经下载过了,从 2 开始
for ((i=2; i<=$pages; i++)); do
url="https://neodb.social/api/me/shelf/complete?category=movie&page=$i"
filename="movie$i.json"
# 下载文件并保存为对应的文件名
curl -X 'GET' "$url" \
-H 'accept: application/json' \
-H 'Authorization: Bearer QuhZZpr8bE711111111111X2OPaSRKU' > "$filename"
done
# 下载 TV 分类
for ((i=2; i<=$tv_pages; i++)); do
tv_url="https://neodb.social/api/me/shelf/complete?category=tv&page=$i"
tv_filename="tv$i.json"
curl -X 'GET' "$tv_url" \
-H 'accept: application/json' \
-H 'Authorization: Bearer ${{ secrets.NEODB_ACCESS_TOKEN }}' > "$tv_filename"
done
# 把所有数据合并成一个文件
jq -c -s '{data: map(.data[]) | unique | sort_by(.created_time) | reverse, pages: map(.pages)[0], count: map(.count)[0]}' *.json > movie.json
然后就会得到一个包含所有标记数据的文件------movie.json
。
把 movie.json
文件复制到目录 data/neodb/movie.json
。
5. 新建 movie.html 模板
在 Hugo 根目录或者主题目录 layouts/_default
新建一个 movie.html 模板。
如果不知道模板长什么样,可以复制正在使用的主题下其他 Page 在用的模板,然后改下名字。
核心代码:
<!-- 其他代码 -->
<!-- 引入 Style 。注意路径,放在 static 目录-->
<link rel="stylesheet" href="/movie.css">
<!-- 获取本地 Json 数据 -->
{{ $movies := getJSON "data/neodb/movie.json" }}
<div class="yourContent">
<div class="sort-by-items">
<a href="javascript:void 0;" class="sort-by-item active" data-order="time"><i
class="fas fa-sort-amount-down"></i> 观影时间排序</a>
<a href="javascript:void 0;" class="sort-by-item" data-order="rating"><i
class="fas fa-sort-numeric-down-alt"></i> 评分排序</a>
<a href="javascript:void 0;" class="sort-by-item" data-order="count"><i class="fas fa-sort-alpha-down-alt"></i>
评分人数排序</a>
</div>
<div class="movie">
{{ range $movies.data }}
{{ $title := .item.display_title }}
{{ $rating := .item.rating }}
{{ $movie_url := .item.url }}
{{ $cover := .item.cover_image_url }}
{{ $cover_name := path.Base $cover }}
{{ $cate_movie := "movie" }}
{{ $cate_tv := "tv" }}
<div class="movies sorting" data-marked="{{ .created_time }}" data-year='{{ dateFormat "2006-01-02 15:04:05" .created_time }}' data-star="{{ .rating_grade }}" data-rating="{{ .item.rating }}" data-count="{{ .item.rating_count }}">
<div class="cover">
<div class="cover__container">
{{ range .item.external_resources }}
{{ if (in .url "douban") }}
<a href="{{ .url }}" target="_blank" rel="noreferrer noopener nofollow"><img alt="{{ $title }}" class="lazy" loading="lazy" data-src="/assets/images/posts/neodb/{{ $cover_name }}"></a>
{{ end }}
{{ end }}
</div>
</div>
<div class="title">
{{ $hasDouban := false }}
{{ range .item.external_resources }}
{{ if (in .url "douban") }}
{{ $hasDouban = true }}
<a href="{{ .url }}" target="_blank" rel="noreferrer noopener nofollow">
{{ $title }}
</a>
{{ end }}
{{ end }}
{{ if not $hasDouban }}
<a href="https://neodb.social{{ $movie_url }}" target="_blank" rel="noreferrer noopener nofollow">
{{ $title }}
</a>
{{ end }}
</div>
<div class="rating">
{{ range $star := (seq 0 2 8) }}
{{ if gt $rating $star }}
<span class="rating_star">
<svg viewBox="0 0 24 24" width="24" height="24" class="stars">
<path fill="none" d="M0 0h24v24H0z"></path>
<path fill="currentColor" d="M12 18.26l-7.053 3.948 1.575-7.928L.587 8.792l8.027-.952L12 .5l3.386 7.34 8.027.952-5.935 5.488 1.575 7.928z">
</path>
</svg>
</span>
{{ else }}
<span class="rating_star">
<svg viewBox="0 0 24 24" width="24" height="24" class="stars white">
<path fill="none" d="M0 0h24v24H0z"></path>
<path fill="currentcolor" d="M12 18.26l-7.053 3.948 1.575-7.928L.587 8.792l8.027-.952L12 .5l3.386 7.34 8.027.952-5.935 5.488 1.575 7.928z"></path>
</svg>
</span>
{{ end }}
<span class="rating_star">{{ $rating }}</span>
<div class="rating_count hidden">
<span>
<svg viewBox="0 0 24 24" width="24" height="24" class="stars">
<path fill="none" d="M0 0h24v24H0z"></path>
<path fill="currentColor" d="M12 18.26l-7.053 3.948 1.575-7.928L.587 8.792l8.027-.952L12 .5l3.386 7.34 8.027.952-5.935 5.488 1.575 7.928z">
</path>
</svg>
</span>
<span><a href="https://neodb.social{{ $movie_url }}" target="_blank" rel="noreferrer noopener nofollow">{{ .item.rating_count }} {{ T `movie_count_text` }}</a></span>
</div>
<div class="referrer">
{{ if eq .item.category $cate_movie }}
<i class="fas fa-film fa-fw"></i>
{{ else if eq .item.category $cate_tv }}
<i class="fas fa-tv fa-xs"></i>
{{ end }}
<span class="neodb">
<a href="https://neodb.social{{ $movie_url }}" target="_blank" rel="noreferrer noopener nofollow">
<img src="/assets/images/movie/neodbsocial.jpg" loading="lazy" alt="NeoDB">
</a>
</span>
{{ range .item.external_resources }}
{{ $parsedURL := urls.Parse .url }}
{{ $host := $parsedURL.Hostname }}
{{ $title := .title }}
<span class="external-resource">
<a href="{{ .url }}" target="_blank" rel="noreferrer noopener nofollow">
<img src="/assets/images/movie/{{ $host }}.png" loading="lazy" alt="{{ $title }}">
</a>
</span>
{{ end }}
<!-- <span class="rottentomatoes">
<a href="http://www.google.com/search?hl=en&q={{ $title }}+rotten+tomatoes&btnI=I" target="_blank" rel="noreferrer noopener nofollow">
<img src="/assets/images/movie/www.rottentomatoes.com.png" loading="lazy" alt="NeoDB">
</a>
</span> -->
</div>
</div>
{{ end }}
</div>
</div>
</article>
<script type="text/javascript" src="/assets/lazyload.iife.min.js?v=17.8.3"></script>
<script type="text/javascript" src="/assets/movie.min.js?v=2023.07.11"></script>
<script>
var lazyLoadInstance = new LazyLoad({
// Your custom settings go here
});
</script>
<!-- 其他代码 -->
6. CSS 样式
这是一些必要的 CSS,只会影响到观影页面,没有侵入性。
可把 CSS 放入 Hugo 的 static
目录
.movie {
display: grid;
width: 100%;
gap: 10px;
margin-top: 1rem;
}
@media (min-width: 1000px) {
.movie {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}
@media only screen and (max-width: 1000px) {
.movie {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
}
@media only screen and (max-width: 680px) {
.movie {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 359px) {
.movie {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.movie .cover {
position: relative;
border-radius: 0.25rem;
width: 100%;
height: 100%;
}
.movie .cover .cover__container {
position: relative;
border-radius: 0.25rem;
background-image: linear-gradient(to bottom, #ddd, #f5f5f5);
overflow: hidden;
padding-top: 177.78%; /* 9:16 竖屏宽高比的容器 */
padding-top: 133.33%; /* 3:4 宽高比的容器 */
padding-top: 150%; /* 豆瓣常见的宽高比的容器 */
}
.movie .cover .cover__container img {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
cursor: pointer;
-o-object-fit: cover;
object-fit: cover;
transition: all 0.6s ease;
}
.movie .cover .cover__container img:hover {
transform: scale(1.1);
}
.movie .movies {
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
margin: 0;
margin-bottom: 2rem;
}
.movie .title {
margin-top: 0.25rem;
width: 100%;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-bottom: 0.5rem;
}
.movie .title,
.movie .rating,
.movie .referrer {
margin-right: auto;
}
.movie .rating {
display: flex;
-webkit-box-align: center;
align-items: center;
font-size: 0.875rem;
}
.movie .rating span:last-child {
margin-right: 0.5rem;
}
.movie .rating .stars {
margin-right: 1px;
width: 0.875rem;
height: 0.875rem;
color: #fccd59;
}
.movie .rating .stars.white {
color: #eee;
}
.movie .movies .referrer {
display: flex;
justify-content: center;
align-items: center;
margin-top: 0.5rem;
gap: 5px;
}
.movie .movies .referrer img {
width: 1rem;
height: 1rem;
opacity: 0.7;
}
.movie .movies .referrer img:hover {
opacity: 0.95;
transition: all 0.6s ease;
}
.sort-by-items {
text-align: left;
}
.rating_count {
display: flex;
text-align: left;
justify-content: flex-start;
align-items: center;
white-space: nowrap;
overflow: hidden;
}
.rating_star.hidden,
.rating_count.hidden {
display: none;
}
.sort-by-item.active {
background: rgba(85,85,85,.1);
}
.sort-by-item {
padding: 0 5px;
}
7. JS 代码
其实可以不需要 JS。所有数据都通过脚本和 Hugo 程序处理好了。这一段 JS 主要是用于排序。
function search(e) {
// 隐藏所有 .sorting 元素
document.querySelectorAll('.sorting').forEach(item => item.classList.add('hide'));
// 移除之前处于活动状态的 .dvtjjf 元素
document.querySelector(`.dvtjjf.active[data-search="${e.target.dataset.search}"]`)?.classList.remove('active');
if (e.target.dataset.value) {
// 将当前点击的 .dvtjjf 元素设为活动状态
e.target.classList.add('active');
}
// 构建属性选择器数组
const searchItems = document.querySelectorAll('.dvtjjf.active');
const attributes = Array.from(searchItems, searchItem => {
const property = `data-${searchItem.dataset.search}`;
const logic = searchItem.dataset.method === 'contain' ? '*' : '^';
const value = searchItem.dataset.method === 'contain' ? `${searchItem.dataset.value}` : searchItem.dataset.value;
return `[${property}${logic}='${value}']`;
});
// 构建选择器字符串
const selector = `.sorting${attributes.join('')}`;
// 显示匹配选择器的元素
document.querySelectorAll(selector).forEach(item => item.classList.remove('hide'));
}
window.addEventListener('click', function (e) {
if (e.target.classList.contains('sc-gtsrHT')) {
e.preventDefault();
search(e);
}
});
function sort(e) {
const sortBy = e.target.dataset.order;
const style = document.createElement('style');
style.classList.add('sort-order-style');
// 移除之前的排序样式
document.querySelector('style.sort-order-style')?.remove();
// 移除之前处于活动状态的 .sort-by-item 元素
document.querySelector('.sort-by-item.active')?.classList.remove('active');
// 将当前点击的 .sort-by-item 元素设为活动状态
e.target.classList.add('active');
if (sortBy === 'rating') {
const movies = Array.from(document.querySelectorAll('.sorting'));
// 根据评分进行排序
movies.sort((movieA, movieB) => {
const ratingA = parseFloat(movieA.dataset.rating) || 0;
const ratingB = parseFloat(movieB.dataset.rating) || 0;
if (ratingA === ratingB) {
return 0;
}
return ratingA > ratingB ? -1 : 1;
});
// 生成排序样式表
const stylesheet = movies.map((movie, idx) => `.sorting[data-rating="${movie.dataset.rating}"] { order: ${idx}; }`).join('\r\n');
style.innerHTML = stylesheet;
document.body.appendChild(style);
} else if (sortBy === 'count') {
const movies = Array.from(document.querySelectorAll('.sorting'));
// 根据评分人数进行排序
movies.sort((movieA, movieB) => {
const countA = parseInt(movieA.dataset.count) || 0;
const countB = parseInt(movieB.dataset.count) || 0;
if (countA === countB) {
return 0;
}
return countA > countB ? -1 : 1;
});
// 生成排序样式表
const stylesheet = movies.map((movie, idx) => `.sorting[data-count="${movie.dataset.count}"] { order: ${idx}; }`).join('\r\n');
style.innerHTML = stylesheet;
document.body.appendChild(style);
}
}
window.addEventListener('click', function (e) {
if (e.target.classList.contains('sort-by-item')) {
e.preventDefault();
sort(e);
}
});
8. 附加 GitHub Actions
GitHub Actions 处理 Json 数据的好处是不用每次都手动下载更新,而且 Access Token 可以保存在 GitHub 仓库的 Secrets Setting 里。
然后填入前面步骤得到的 Access Token
Name *
:NEODB_ACCESS_TOKEN
Secret *
:QuhZZpr111111111111111110X2OPaSRKU
:secret:
下面是具体的 GitHub Actions neodb.yml 代码。不需要用到的步骤直接删除即可。
# .github/workflows/douban.yml
name: Sync NeoDB Data
on:
schedule:
- cron: "0 17 * * *"
# watch:
# types: [started]
workflow_dispatch:
jobs:
douban:
name: Sync NeoDB Data
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
# 检查是否安装了 JQ
- name: Check JQ
run: |
if ! command -v jq &> /dev/null; then
echo "jq is not installed. Installing..."
sudo apt-get update
sudo apt-get install -y jq
else
echo "jq is already installed."
fi
# 把当前目录保存到环境变量中
echo "WORK_DIR=$(pwd)" >> $GITHUB_ENV
# 获取本地现有文件的标记数
- name: Get Current Count
run: |
CURRENT_COUNT() {
jq '.count' data/neodb/movie.json
}
echo "CURRENT_COUNT=$(CURRENT_COUNT)" >> $GITHUB_ENV
- name: Get NeoDB JSON and Count
run: |
curl -X 'GET' \
'https://neodb.social/api/me/shelf/complete?category=movie&page=1' \
-H 'accept: application/json' \
-H 'Authorization: Bearer ${{ secrets.NEODB_ACCESS_TOKEN }}' > movie1.json
# 获取 NeoDB 上电影的标记数
MOVIE_COUNT() {
jq '.count' movie1.json
}
echo "MOVIE_COUNT=$(MOVIE_COUNT)" >> $GITHUB_ENV
curl -X 'GET' \
'https://neodb.social/api/me/shelf/complete?category=tv&page=1' \
-H 'accept: application/json' \
-H 'Authorization: Bearer ${{ secrets.NEODB_ACCESS_TOKEN }}' > tv1.json
# 获取 NeoDB 上电视剧的标记数
TV_COUNT() {
jq '.count' tv1.json
}
REMOTE_COUNT=$(($(MOVIE_COUNT) + $(TV_COUNT)))
echo "REMOTE_COUNT=$REMOTE_COUNT" >> $GITHUB_ENV
# 对比本地的标记数和远程标记数,相等就跳过,不相等就下载新数据
- name: Count Compare
run: |
if [ "${{ env.REMOTE_COUNT }}" = "${{ env.CURRENT_COUNT }}" ]; then
echo "Variables are equal. Skipping the next steps."
exit 0
else
echo "Variables are not equal. Running the next steps."
fi
# 下载所有数据
- name: Get All NeoDB Count
if: ${{ env.REMOTE_COUNT != env.CURRENT_COUNT }}
run: |
#从 json 中提取 pages 字段的值
pages=$(jq '.pages' movie1.json)
tv_pages=$(jq '.pages' tv1.json)
# 个人使用,新建 WorkDIR ,排除 vercel.json 和 package.json 等
mkdir neodb
cd neodb
# 下载 Movie 分类
for ((i=1; i<=$pages; i++)); do
url="https://neodb.social/api/me/shelf/complete?category=movie&page=$i"
filename="movie$i.json"
# 下载文件并保存为对应的文件名
curl -X 'GET' "$url" \
-H 'accept: application/json' \
-H 'Authorization: Bearer ${{ secrets.NEODB_ACCESS_TOKEN }}' > "$filename"
done
# 下载 TV 分类
for ((i=1; i<=$tv_pages; i++)); do
tv_url="https://neodb.social/api/me/shelf/complete?category=tv&page=$i"
tv_filename="tv$i.json"
curl -X 'GET' "$tv_url" \
-H 'accept: application/json' \
-H 'Authorization: Bearer ${{ secrets.NEODB_ACCESS_TOKEN }}' > "$tv_filename"
done
# 把所有数据合并成一个文件
jq -c -s '{data: map(.data[]) | unique | sort_by(.created_time) | reverse, pages: map(.pages)[0], count: map(.count)[0]}' *.json > movie.json
# 更新 NeoDB 数据
cp -f movie.json ${{ env.WORK_DIR }}/data/neodb/
- name: Download NeoDB Cover
run: |
# 检查 movie 目录是否存在,如果不存在则创建
if [ ! -d "movie" ]; then
mkdir movie
fi
# 读取本地的 movie.json 文件内容
json=$(cat data/neodb/movie.json)
# 提取图片 URL
image_urls=$(echo "$json" | jq -r '.data[].item.cover_image_url')
# 遍历图片 URL 并下载图片
for url in $image_urls; do
filename=$(basename "$url")
filepath="data/neodb/cover/$filename"
# 检查文件是否已存在
if [ -f "$filepath" ]; then
echo "Skipping $filename - File already exists"
else
# 使用 curl 命令下载图片
curl -o "$filepath" "$url"
echo "Downloaded $filename"
echo "REMOTE_COUNT=''" >> $GITHUB_ENV
fi
done
# 把修改后的数据提交到 GitHub 仓库
- name: Git Add and Commit
if: ${{ env.REMOTE_COUNT != env.CURRENT_COUNT }}
uses: EndBug/add-and-commit@v9
with:
message: 'chore(data): update neodb data'
add: './data/neodb'
# 调用另外的 GitHub Actions 构建 Hugo
- name: Build Hugo and Deploy
if: ${{ env.REMOTE_COUNT != env.CURRENT_COUNT }}
uses: peter-evans/repository-dispatch@v2
with:
event-type: "Build Hugo and Deploy"
# 把海报上传到腾讯云
- name: Upload Cover to Tencent COS
if: ${{ env.REMOTE_COUNT != env.CURRENT_COUNT }}
uses: zkqiang/tencent-cos-action@v0.1.0
with:
args: upload -rs ./data/neodb/cover/ /images/neodb/
secret_id: ${{ secrets.SECRET_COS_ID }}
secret_key: ${{ secrets.SECRET_COS_KEY }}
bucket: ${{ secrets.COS_CDN_BUCKET }}
region: ap-shanghai