2023-07-12 更新:《关于豆瓣图片无法直接使用的说明》
简介
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 同步豆瓣账号 lizheming
的 movie
类型数据到 ./douban
文件夹下,并保存为 csv
格式文件;最后一步则是当 ./douban
文件夹下有更新则调用插件提交修改。
Notion
如果是要同步到 Notion 中会稍微复杂一点。需要先准备好 Notion Token 并初始化好页面。
- 我们可以在 My Integrations 里创建机器人得到
NOTION_TOKEN
。 - 电影 | 阅读 | 音乐 基于这三个模板点击右上角的 Duplicate 按钮渲染复制页面。
- 复制后的页面右上角选择右上角的 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
需要为 notion
,dir
为 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="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 年看过的喜剧片了!
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」这个项目就诞生了!
多年前用过你的 egg.js,在 issues 区和你交流过,今天因为写博客,对比评论系统,又重遇了。
@lyd123qw2008: 应该是 ThinkJS 吧 [哭笑不得] 评论系统也是用 ThinkJS 写的,可以试试呀,有问题可以提 issue~
用 Action 同步数据到 Notion,这样会不会有封号风险?https://www.v2ex.com/t/817831
@Henry: 我一直使用的是 CSV 存仓库的方式,所以不是特别确定。如果担心这个风险问题,也可以使用 Drone CI 来跑任务 https://plugins.drone.io/plugins/doumark
单条数据也直接调用本地 data 咯,发现书籍有介绍 intro ,电影 intro 没有,能设置抓取不?
@林木木: 电影我之前看是没有 intro 内容的,你可以改成 json 类型存储,可以看到所有的原始接口数据。
是否因为 2023 年的缘故,前端显示有问题
@林木木: 你是说你的阅读页面数据错乱导致展示有问题吧?这个是最近发现的一个 Bug,使用的 CSV 库存储成 CSV 的时候列顺序不是固定的,我之前的模板指定了列数据,所以列错乱导致生成的页面有问题。目前我已经按照文章中模板指定的列顺序强制指定了 CSV 存储的顺序了,现在没有这个问题。你的阅读页面应该是改了模板所以可能不正常,之前我看是你的电影页面不正常所以排查了一下。
有 2 个小问题请教一下,这个保存时,可以同时保存为
json
和csv
两种格式吗;还有支持电影和电视剧分开吗?@Charles Chin: 1. 目前代码是不支持的,需要的话可以先输出 json 然后自己再写个 action 生成 csv,这样改造比较简单
2. 电影和电视剧在接口数据里是可以区分开的,如果需要的话可以自己对 json 数据再处理一下也行
感谢工具,已经完成了读过的书影音同步,另外想问下可以用这个抓取想读的书籍嘛
@TheWanderingAllison: 可以的,改下这个 status 字段的值就可以了,https://github.com/lizheming/drone-doumark/blob/master/src/douban.js#L12。不过我没这个诉求,你有需要的话可以 PR 一个呀~
@怡红公子: 改了之后如何做成镜像呢
前来感谢一下,已经利用您的工具实现了Ghost博客上的豆瓣清单
CRON居然可以用在Github Actions上,
@非科学の河童: 可以的,你甚至可以在 GitHub Action 上起一个 HTTP 服务跑起来都可以(逃……
竟然只能用 github 账号评论 😂
@THYUU: 嗯,因为之前被匿名评论攻击过,所以限制了下只允许 GitHub 登录了
学习了。。
参考你这个,把饭否的也抓过来了。。
@冰剑: 冰剑大佬666,Hexo 我还没搞过呢,等你写文章分享呀嘻嘻
我更好奇 老哥这个cover图是自己设计的吗 真好看
@SHUAXIN: 是呀,Google 搜图找的灵感,然后缝合怪一下~ 每次写文章做封面图都令人头秃 😦
效果太赞啦!已完美折腾~
@林木木: 谢谢木木老师赞美~
这个很顶。
不过可以做一个分页截断,电影太多直接卡死。
@Charles Chin: 我这还行吧,做了懒加载其实还好?太长的话可以考虑渲染成 JSON 到页面之后前端做伪分页。