51工具盒子

依楼听风雨
笑看云卷云舒,淡观潮起潮落

NeoDB API 创建观影页面

前言

#

几个月之前在 长毛象联邦宇宙 里问过 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 脚本下载:

注意替换 QuhZZpr8bE711111111111X2OPaSRKUAccess 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
赞(2)
未经允许不得转载:工具盒子 » NeoDB API 创建观影页面