豆瓣书影音同步 GitHub Action

Featured Image

简介

doumark-action 是我前段时间造的一个轮子。它是一款 GitHub Action,支持在 GitHub 中同步你的豆瓣书影音数据到本地的文件或者 Notion 中。我利用它,定时同步我的豆瓣观影数据到我的博客仓库中,并利用 Hugo 读取文件数据渲染成页面,观影 是最终的效果。

使用

使用其实很简单,在你的博客仓库中新建 .github/workflows/douban.yml 文件,以观影为例添加如下内容。它实现了每小时自动抓取你的豆瓣观影记录并更新到文件中,如果发现文件有更新则触发 commit 提交。

name: douban
on: 
  schedule:
  - cron: "30 * * * *"

jobs:
  douban:
    name: Douban mark data sync
    runs-on: ubuntu-latest
    steps:
    - name: Checkout
      uses: actions/checkout@v2

    - name: movie
      uses: lizheming/doumark-action@master
      with:
        id: lizheming
        type: movie
        format: csv
        dir: ./douban

    - name: Commit
      uses: EndBug/add-and-commit@v8
      with:
        message: 'chore: update douban data'
        add: './douban'

该 workflow 总共分为三步,第一步初始化 Git 仓库;第二步调用 doumark-action 同步豆瓣账号 lizhemingmovie 类型数据到 ./douban 文件夹下,并保存为 csv 格式文件;最后一步则是当 ./douban 文件夹下有更新则调用插件提交修改。

Notion

如果是要同步到 Notion 中会稍微复杂一点。需要先准备好 Notion Token 并初始化好页面。

  1. 我们可以在 My Integrations 里创建机器人得到 NOTION_TOKEN
  2. 电影 | 阅读 | 音乐 基于这三个模板点击右上角的 Duplicate 按钮渲染复制页面。
  3. 复制后的页面右上角选择右上角的 Share - Invite 将第一步创建的机器人加入,这样机器人就有权限更新你的页面数据。
# .github/workflows/douban.yml
name: douban
on: 
  schedule:
  - cron: "30 * * * *"

jobs:
  douban:
    name: Douban mark data sync
    runs-on: ubuntu-latest
    steps:
    - name: movie
      uses: lizheming/doumark-action@master
      with:
        id: lizheming
        type: movie
        format: notion
        dir: xxxx
        notion_token: ${{ secrets.notion_token }}

其中 format 需要为 notiondir 为 Notion 页面 ID,Notion 页面 URL 第一个随机字符即为页面的 ID。

渲染

数据已经有了,剩下的就是我们需要读取该数据源的数据,并渲染出页面。除了数据渲染之外,我还给自己增加了筛选查找的需求,所以我在头部还渲染了一些筛选项。

{{$movies := getCSV "," "douban/movie.csv" }}
{{$scratch := newScratch}}
{{$scratch.Add "genres" slice}}
{{range $idx, $movie := $movies}}
  {{if ne $idx 0}}
    {{$scratch.Set "genres" (union ($scratch.Get "genres") (split (index $movie 7) ","))}}
  {{end}}
{{end}}
<div class="sc-ksluID gFnzgG">
  <!--分类筛选-->
  <div class="sc-bdnxRM jvCTkj">
    <a href="javascript:void 0;" class="sc-gtsrHT kEoOHR" data-search="genres" data-method="contain" data-value="">全部</a>
    {{range $genre := $scratch.Get "genres"}}
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="genres" data-method="contain" data-value="{{$genre}}">{{$genre}}</a>
    {{end}}
  </div>

  
  <!--时间筛选-->
  <div class="sc-bdnxRM jvCTkj">
    <a href="javascript:void 0;" class="sc-gtsrHT kEoOHR" data-search="year" data-method="equal" data-value="">全部</a>
    {{range $year := (seq 2022 -1 2009)}}
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="year" data-method="equal" data-value="{{$year}}">{{$year}}</a>
    {{end}}
  </div>
  
  <!--评分筛选-->
  <div class="sc-bdnxRM jvCTkj">
    <a href="javascript:void 0;" class="sc-gtsrHT kEoOHR" data-search="star" data-method="equal" data-value="">全部</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="5">五星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="4">四星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="3">三星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="2">二星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="1">一星</a>
    <a href="javascript:void 0;" class="sc-gtsrHT dvtjjf" data-search="star" data-method="equal" data-value="0">零星</a>
  </div>

  <!--排序规则-->
  <div class="sc-bdnxRM jvCTkj sort-by">
    <a href="javascript:void 0;" class="sort-by-item active" data-order="time">
      观影时间排序
    </a>
    <a href="javascript:void 0;" class="sort-by-item" data-order="rating">
      网友评分排序
    </a>
  </div>

  <!-影片列表-->
  <div class="sc-dIsUp fIuTG">
    {{range $idx, $movie := $movies}}
    <!--排除第一行表头-->
    {{if ne $idx 0 }}
    <div 
      class="sc-gKAaRy dfdORB" 
      data-year="{{index $movie 9}}" 
      data-star="{{index $movie 8}}"
      data-rating="{{index $movie 6}}"
      data-genres="{{index $movie 7}}"  
    >
      <a href="{{index $movie 5}}" target="_blank">
        <div class="sc-hKFxyN HPRth">
          <div class="lazyload-wrapper ">
            <img class="lazy" data-src="{{index $movie 3}}" referrer-policy="no-referrer" loading="lazy" alt="{{index $movie 1}}" width="150" height="220">
          </div>
        </div>
        <div class="sc-iCoGMd kMthTr">{{index $movie 1}}</div>
        <div class="sc-fujyAs eysHZq">
          <span class="sc-jSFjdj jcTaHb">
            {{range $star := (seq 0 2 8)}}
            <svg viewBox="0 0 24 24" width="24" height="24" class="sc-dlnjwi {{if gt (index $movie 6) $star}}lhtmRw{{else}}gaztka{{end}}">
              <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>
            {{end}}
          </span>
          <span class="sc-pNWdM iibjPt">{{index $movie 6}}</span>
        </div>
      </a>
    </div>
    {{end}}
    {{end}}
  </div>  
</div>

整体的布局我使用了 Flex 布局,增加了图片懒加载。

搜索

使用 CSS 的属性选择器,可以非常简单的实现搜索的功能。事先将数据通过属性挂载在 DIV 上,通过 [data-year^=2022][data-genres*=喜剧] 就可以查询到 2022 年看过的喜剧片了!

function search(e) {
  // 隐藏全部电影
  document.querySelectorAll('.dfdORB').forEach(item => item.classList.add('hide'));
  // 移除当前筛选项之前的选项
  document.querySelector(`.dvtjjf.active[data-search="${e.target.dataset.search}"]`)?.classList.remove('active');
  // 如果选择的是非全部选项,则高亮该选项
  if(e.target.dataset.value) {
    e.target.classList.add('active');
  }

  // 找到所有筛选项的值
  const searchItems = document.querySelectorAll('.dvtjjf.active');
  // 根据筛选值拼接 CSS 选择器,JSON 数据类型的需要使用 *=,其它的需要使用 ^=
  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 = `.dfdORB${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);
  }
});

排序

由于我使用了 Flex 布局,所以排序这个实行实际上是可以通过 Flex 的 order 属性来实现的。这样做的好处就是我不需要真的去修改 DOM 结构,只需要生成或者删除 CSS 就好了。

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();
  document.querySelector('.sort-by-item.active')?.classList.remove('active');
  e.target.classList.add('active');
  if(sortBy === 'rating') {
    const movies = Array.from(document.querySelectorAll('.dfdORB'));
    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) => `.dfdORB[data-rating="${movie.dataset.rating}"] { 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);
  }
});

起因

很早以前我就养成了看完电影就要上豆瓣上标记一下的习惯,并在每年年末的时候统计一下。为了满足自己的需求,很早之前我写过一款 Chrome 插件,用于统计豆瓣电影记录,具体可以看这篇文章《豆瓣电影统计插件For Chrome》

在后来无意间知道了牧风老师开发的布克牧为,用户同步豆瓣记录数据并支持在第三方网站中挂件展示。所以我为我的博客增加了观影页面,用来展示我看过的电影。后来,每当我和朋友聊电影,想要推荐之前看过的电影给他们的时候,它也成为了重要的查找入口。

布克牧为的第三方挂件样式很好看,但筛选功能偏弱,仅支持分类的筛选。对于我有搜索和统计的需求其实没办法很好的满足。再加之最近布克牧为时长不出数据,变的不太稳定,导致我又有了重新造轮子的想法。

自从博客切换成 Hugo 之后,我对 SSG(Server Side Generate) 就非常的痴迷,连评论都是使用 SSG 的方式渲染到页面上的,具体可以查看我之前写的这篇文章《静态博客如何高性能插入评论》。于是关于这次的功能理所当然我也想使用类似的方式。

所以最开始我是写了个独立的服务,该服务会定时抓取数据并更新到数据库中,同时提供了 API 用于获取数据。在博客中则去调用该接口获取到数据后渲染页面。后来因为需要找一个第三方定义任务服务,用于定时触发抓取任务接口。更新数据后还需要调用博客的构建触发器,同时又觉得每次构建的时候都需要花时间去请求一次接口有点浪费,就一直在思考有没有其它的方式。

其实 Hugo 除了支持 JSON 接口的数据读取之外,也支持本地 CSV 文件的数据读取。直接读取从库中的表格文件获取到数据能减少不必要的网络请求,而表格文件更新的时候会自动触发 Git 操作从何触发博客的构建任务。所以最终就想到了 GitHub Action 的方案,通过免费的 GitHub Action 触发 CSV 文件的更新,最终触发构建更新。

于是乎「doumark-action」这个项目就诞生了!

Avatar
怡红公子 擅长前端和 Node.js 服务端方向。热爱开源时常在 Github 上活跃,也是博客爱好者,喜欢将所学内容总结成文章分享给他人。

12 评论

非科学の河童 Firefox99.0 Windows 10
2022-04-18T09:49:22.875Z 回复

CRON居然可以用在Github Actions上,

怡红公子 Chrome100.0 Mac OS 10.15.7
2022-04-18T10:19:06.851Z 回复

@非科学の河童: 可以的,你甚至可以在 GitHub Action 上起一个 HTTP 服务跑起来都可以(逃……

THYUU Edge100.0 Windows 10
2022-04-14T06:42:32.037Z 回复

竟然只能用 github 账号评论 😂

怡红公子 Chrome100.0 Mac OS 10.15.7
2022-04-14T07:25:59.594Z 回复

@THYUU: 嗯,因为之前被匿名评论攻击过,所以限制了下只允许 GitHub 登录了

冰剑 Chrome86.0 Windows 10
2022-04-10T12:35:05.038Z 回复

学习了。。
参考你这个,把饭否的也抓过来了。。

怡红公子 Chrome100.0 Mac OS 10.15.7
2022-04-10T13:01:09.264Z 回复

@冰剑: 冰剑大佬666,Hexo 我还没搞过呢,等你写文章分享呀嘻嘻

SHUAXIN Chrome99.0 Mac OS 10.15.7
2022-03-25T01:38:34.190Z 回复

我更好奇 老哥这个cover图是自己设计的吗 真好看

怡红公子 Chrome99.0 Mac OS 10.15.7
2022-03-25T01:40:32.683Z 回复

@SHUAXIN: 是呀,Google 搜图找的灵感,然后缝合怪一下~ 每次写文章做封面图都令人头秃 😦

林木木 Edge99.0 Mac OS 10.15.7
2022-03-22T14:34:09.510Z 回复

效果太赞啦!已完美折腾~

怡红公子 Chrome99.0 Mac OS 10.15.7
2022-03-23T14:37:51.331Z 回复

@林木木: 谢谢木木老师赞美~

Charles Chin Chrome99.0 Windows 10
2022-03-22T14:26:44.784Z 回复

这个很顶。
不过可以做一个分页截断,电影太多直接卡死。

怡红公子 Chrome99.0 Mac OS 10.15.7
2022-03-23T13:24:10.910Z 回复

@Charles Chin: 我这还行吧,做了懒加载其实还好?太长的话可以考虑渲染成 JSON 到页面之后前端做伪分页。