
![](https://imnerd.org/assets/2022/doumark.png)


> 2023-07-12 更新：[《关于豆瓣图片无法直接使用的说明》](https://github.com/lizheming/doumark-action/issues/8)

## 简介

[doumark-action](https://github.com/lizheming/doumark-action) 是我前段时间造的一个轮子。它是一款 GitHub Action，支持在 GitHub 中同步你的豆瓣书影音数据到本地的文件或者 Notion 中。我利用它，定时同步我的豆瓣观影数据到我的博客仓库中，并利用 Hugo 读取文件数据渲染成页面，[观影](https://imnerd.org/douban.html) 是最终的效果。

## 使用

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

```yml
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](https://github.com/lizheming/doumark-action) 同步豆瓣账号 `lizheming` 的 `movie` 类型数据到 `./douban` 文件夹下，并保存为 `csv` 格式文件；最后一步则是当 `./douban` 文件夹下有更新则调用插件提交修改。

## Notion

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

1. 我们可以在 [My Integrations](https://www.notion.so/my-integrations) 里创建机器人得到 `NOTION_TOKEN`。
2. [电影](https://lizheming.notion.site/d8a363df3ca84ca89ef52208ad874e3b) | [阅读](https://lizheming.notion.site/488c17fd89fb424591f68f7cfb029020) | [音乐](https://lizheming.notion.site/d80ca60213c54ab99c4376caec0be9d7) 基于这三个模板点击右上角的 <kbd>Duplicate</kbd> 按钮渲染复制页面。
3. 复制后的页面右上角选择右上角的 <kbd>Share</kbd> - <kbd>Invite</kbd> 将第一步创建的机器人加入，这样机器人就有权限更新你的页面数据。

```yml
# .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` 需要为 `notion`，`dir` 为 Notion 页面 ID，Notion 页面 URL 第一个随机字符即为页面的 ID。

## 渲染

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

```html
{{$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="https://dou.img.lithub.cc/movie/{{ index (findRE `\d+` (index $movie 5)) 0 }}.jpg" 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 年看过的喜剧片了！

```js
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 就好了。

```js
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》](/douban-movie-track-for-chrome.html)。

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

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

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

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

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

于是乎「[doumark-action]((https://github.com/lizheming/doumark-action))」这个项目就诞生了！
