博客记录书影音——Hugo 的 Data Templates - 自写主题
2021年6月25日 - 4453 字

如你所见,本站在侧边栏里新增了“记录”板块,包含我读过的书、我看过的电影. 本文做一个技术总结.

动机

我本来用来记录书、电影、音乐、游戏的平台是豆瓣,但把自己的数据交到别的平台上是很危险的一件事,已经有不少友邻出现了帐号被封禁后所有的书影音游记录全部消失的悲剧事件;第二,豆瓣的发言要经由中国大陆相关部门的审查,这使得在评价一部文艺作品时也需要注意措辞,有的时候恐怕不能把心中想的东西原原本本地记录下来;第三,豆瓣在文艺作品的条目上也受到中国大陆相关部门的审查,很多书影音在豆瓣上根本不能存在,比如我看过的《颐和园》、《自助洗衣店》等作品就无法在豆瓣上标注,这对于记录自己的文艺生活来说是致命的灾难.

分析豆瓣这个平台,其作用有二:一是记录自己的文艺生活,二是可以在上面浏览其他人的记录,参考它上面的评分,有一定的社交属性. 豆瓣之所以能长期存活,是因为第二点;反过来想,如果我们能够放弃第二点,那么豆瓣就不是无可替代的,自己拿一个 excel 表格就能做记录,想放到网上可以选 Notion 或者 Airtable 之类,这样的实践已经有很多.

而我自己,已经有自己的博客了,把书影音记录整合进这个博客是非常自然的一个选择. 这里有三个问题需要解决:

  • 怎么在博客中展示这些数据?需要什么格式的文件,数据结构应该是什么样的?
  • 怎么把豆瓣中的以往数据拿下来,并且存成我们想要的格式?
  • 未来的记录流程是怎么样的,使用这个博客能像使用豆瓣那样流畅、自然吗?

怎么在博客中展示数据

基本结构

我使用的博客生成器是 Hugo,而 Hugo 有一个 Data Templates,这正是完成这件事的最佳方案!

根据文档,我们可以把数据存成 yaml、toml、json,甚至是 csv 都可以. 我觉得 yaml 和 json 都是很好的选择,前者格式简洁,适合人类读写,后续添加记录的时候应该会省事很多;后者则应用非常广泛,相应的工具比较成熟,写代码处理 json 数据会非常方便. 我选择的是 json 格式.

根据文档中的例子,具体的数据结构是非常灵活的,想怎么安排就怎么安排,我根据自己想要展示的内容,做成了列表的形式,列表里面的每个元素都是一个字典:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# data/book.json
[
  {
    "title": "第一本书的书名",
    "author": "书的作者",
    "public_year": "书的出版年份",
    "press": "出版社",
    "link": "豆瓣链接",
    "isbn": "书的 ISBN 编号",
    "mark_date": "我是哪一天读完的此书",
    "my_score": "我给的星星数",
    "my_comment": "我对此书的评价"
  },
  {
    "title": "第二本书的书名",
    "author": "书的作者",
    "public_year": "书的出版年份",
    "press": "出版社",
    "link": "豆瓣链接",
    "isbn": "书的 ISBN 编号",
    "mark_date": "我是哪一天读完的此书",
    "my_score": "我给的星星数",
    "my_comment": "我对此书的评价"
  }
]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
# data/film.json
[
  {
    "title": "第一部电影的电影中文名",
    "ori_name": "电影的原名",
    "public_year": "上映年份",
    "produce_country": "发行地区",
    "film_type": "电影的类型",
    "director": "导演",
    "link": "豆瓣链接",
    "imdb_id": "IMDB 编号",
    "mark_date": "我是哪一天看完的此电影",
    "my_score": "我给的星星数",
    "my_comment": "我对此电影的评价"
  },
  {
    "title": "第二部电影的电影中文名",
    "ori_name": "电影的原名",
    "public_year": "上映年份",
    "produce_country": "发行地区",
    "film_type": "电影的类型",
    "director": "导演",
    "link": "豆瓣链接",
    "imdb_id": "IMDB 编号",
    "mark_date": "我是哪一天看完的此电影",
    "my_score": "我给的星星数",
    "my_comment": "我对此电影的评价"
  }
]

这里的顺序是按照标记日期新的在前、旧的在后的顺序排列的.

安排好数据结构,就可以写模板了. 像我这个站,需要两个单独的网页来展示,所以就写在文章模板 layouts/_default/single.html 里. 为了方便后期维护,我们把书影音记录页面单拆一个 partial 出来:

1
2
3
4
<!-- layouts/_default/single.html -->

<!-- 书影音展示区域 -->
{{ partial "record.html" . }}

为了确定哪些页面需要渲染这一部分,我们使用 identifier 做个判断:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
<!-- layouts/partials/record.html -->

{{ if eq (.Param "menu.main.identifier") "book" }}
<!-- 下面是读过的书的记录 -->
    {{ range $.Site.Data.book }}
        {{ .title }}
    {{ end }}

{{ else if eq (.Param "menu.main.identifier") "film" }}
<!-- 下面是看过的电影的记录 -->
    {{ range $.Site.Data.film }}
        {{ .title }}
    {{ end }}

{{ end }}

目录结构如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
$ tree
.
├── config.yaml
├── content
│   ├── about
│   ├── _index.md
│   └── record
│       ├── book.md
│       ├── film.md
│       └── _index.md
├── data
│   ├── book.json
└   └── film.json

其中:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- content/record/_index.md -->

---
menu:
  main:
    identifier: record
    name: 记录
    weight: 3
---

记录分区,包含我所有读过的书、看过的电影,作为豆瓣的替代品.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- content/record/book.md -->

---
title: 读过的书
date: 2010-01-01
menu:
  main:
    identifier: book
    parent: record
    name: 读过的书
    weight: 1
---

这些是我读过的书.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- content/record/film.md -->

---
title: 看过的电影
date: 2010-01-01
menu:
  main:
    identifier: film
    parent: record
    name: 看过的电影
    weight: 2
---

这些是我看过的电影和电视剧.

这样就搞定了,只要在 layouts/partials/record.html 里再好好把数据的展示格式调整一下就没问题了.

处理多语言问题

不过我在这里遇到了另一个问题,那就是我的网站是多语言的网站. 多语言的意思就是这个网站的每一个页面都应该做多语言的翻译,就比如一篇文章,在源文件里是写在 title.md 里的,但为了多语言,则应该给它翻译成 title.en.mdtitle.fr.md 分别用作英语的页面和法语的页面,Hugo 也能非常自动地把这个问题处理好,只要你这样写了,它就会自动帮你生成多语言的页面. 类比到 data 的问题上,照理说也应如此,比如 book.json 我给它翻译出两份 book.en.jsonbook.fr.json 应该就可以了,但 Hugo 并没有支持这种做法.

于是我就想,要不就把数据存成 book_zh.jsonbook_en.jsonbook_fr.json 这种格式,然后在 layouts/partials/record.html 里做个类似于 {{ range $.Site.Data.film_(.Language.Lang) }}的东西就差不多了吧,但我试了半天也查了半天文档,发现 Hugo 好像并不支持这种变量里面套变量(variable variables)的做法.

正在我左右为难之际,我突然灵光一现!我想起了在一般的编程语言比如 python 中是如何处理这种变量套变量的,那就是使用字典

1
2
3
a = "hello"
dict = {a: "world"}
print(a, dict[a])

对于 golang 来说就是 map,而用在我们这里的 Hugo data templates,我们应该注意到:data 文件夹的每一层都是一个 map 啊!这下问题就可以简单地解决了.

文件结构做点调整:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
$ tree
.
├── config.yaml
├── content
│   ├── about
│   ├── _index.md
│   └── record
│       ├── book.en.md
│       ├── book.md
│       ├── film.en.md
│       ├── film.md
│       ├── _index.en.md
│       └── _index.md
├── data
│   ├── en
│   │   ├── book.json
│   │   └── film.json
│   └── zh
│       ├── book.json
└       └── film.json

页面的 markdown 文件:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
<!-- content/record/_index.en.md -->

---
menu:
  main:
    identifier: record
    name: RECORD
    weight: 3
---

Record section. Including the books I have read and the films I have watched, as an alternative to douban.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- content/record/book.en.md -->

---
title: Books read
date: 2010-01-01
menu:
  main:
    identifier: book
    parent: record
    name: Books Read
    weight: 1
---

Here are the books I have read.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
<!-- content/record/film.en.md -->

---
title: Films watched
date: 2010-01-01
menu:
  main:
    identifier: film
    parent: record
    name: Films Watched
    weight: 2
---

Here are the films I have watched.

模板:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<!-- layouts/partials/record.html -->

{{ $curr_page := . }}
{{ range $key, $value := $.Site.Data }}

    {{ if eq $key $curr_page.Language.Lang }}

        {{ if eq ($curr_page.Param "menu.main.identifier") "book" }}
        <!-- 下面是读过的书的记录 -->
            {{ range $value.book }}
                {{ .title }}
            {{ end }}
        {{ end }}

        {{ else if eq ($curr_page.Param "menu.main.identifier") "film" }}
        <!-- 下面是看过的电影的记录 -->
            {{ range $value.film }}
                {{ .title }}
            {{ end }}
        {{ end }}

    {{ end }}

{{ end }}

调整格式丰富内容以后再用 i18n 函数把内容翻译成多语言即可.

怎么把豆瓣的数据存成相应格式

首先使用豆伴备份工具备份豆瓣数据,下载下来得到一个 xlsx 文件. 然后我们可以使用 python 的 openpyxl 包把里面的数据取出来弄成我们想要的格式. 但是这个工具备份下来的数据没有原名以及 IMDB 编号,我又找到了一个好心人制作的免费 api:douban-imdb-api. 把这些东西集合在一起,我写了这么几行代码:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
from openpyxl import load_workbook
import json
import requests

wb = load_workbook('douban.xlsx')

watch_done = wb['看过']
read_done = wb['读过']

wb.close()

watch_json = []
read_json = []

for i in range(2, read_done.max_row + 1):
    title = read_done['A%d'%(i)].value
    des = read_done['B%d'%(i)].value.split('/')
    try:
        author = des[0].strip()
        public_year = des[1].strip()
        press = des[2].strip()
    except:
        author = "null"
        public_year = "null"
        press = "null"
        print(i)
        print("读过出现问题")
    link = read_done['D%d'%(i)].value
    mark_date = read_done['E%d'%(i)].value[0:10]
    my_score = read_done['F%d'%(i)].value
    my_comment = read_done['H%d'%(i)].value

    book_obj = {
            'title': title,
            'author': author,
            'public_year': public_year,
            'press': press,
            'link': link,
            'mark_date': mark_date,
            'my_score': '★' * my_score,
            'my_comment': my_comment
            }

    read_json.append(book_obj)

for i in range(2, watch_done.max_row + 1):
    title = watch_done['A%d'%(i)].value
    des = watch_done['B%d'%(i)].value.split('/')
    try:
        public_year = des[0].strip()
        produce_country = des[1].strip()
        film_type = des[2].strip()
        director = des[3].strip()
    except:
        public_year = "null"
        produce_country = "null"
        film_type = "null"
        director = "null"
        print(i)
        print("看过出现问题")
    link = watch_done['D%d'%(i)].value
    link_split = link.split('/')
    douban_id = link_split[-2]
    try:
        film_ex = requests.get('https://movie.querydata.org/api?id=' + douban_id).json()
        imdb_id = film_ex['imdbId']
        ori_name = film_ex['originalName']
    except:
        print('')
        print(i)
        print('出现异常')
        print('')
        imdb_id = 'null'
        ori_name = 'null'
    else:
        print(i)
        print('成功')
    mark_date = watch_done['E%d'%(i)].value[0:10]
    my_score = watch_done['F%d'%(i)].value
    my_comment = watch_done['H%d'%(i)].value

    film_obj = {
            'title': title,
            'ori_name': ori_name,
            'public_year': public_year,
            'produce_country': produce_country,
            'film_type': film_type,
            'director': director,
            'link': link,
            'imdb_id': imdb_id,
            'mark_date': mark_date,
            'my_score': '★' * my_score,
            'my_comment': my_comment
            }

    watch_json.append(film_obj)

with open ('book.json', 'w') as rf:
    json.dump(read_json, rf, ensure_ascii=False, indent=2)

with open ('film.json', 'w') as wf:
    json.dump(watch_json, wf, ensure_ascii=False, indent=2)

运行这个代码之前应该先把表格按照标记时间新的在上、旧的在下的顺序重新排一下序,因为用豆伴工具备份下来的数据是按照第一次标记的顺序排列的. 比如,我在 2019 年给某一个电影标记了“想看”,2021 年的某一天我把它看了,标了“看过”,那么这条记录会出现在 2019 年的那个位置,尽管它的标记日期是 2021 年. 如果像我一样最开始没有注意到这一点,等数据都弄好了才发现,我又写了另一份代码帮忙排序(因为重新生成数据的话还得去调用人家的免费 api,一是我于心不忍,二是那太慢了!):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
import json
import operator

with open('book.json', 'r') as ori_book:
    book_ori = json.load(ori_book)

book_ori.sort(key = operator.itemgetter('mark_date'), reverse = True)

with open ('book_new.json', 'w') as rf:
    json.dump(book_ori, rf, ensure_ascii=False, indent=2)

with open('film.json', 'r') as ori_film:
    film_ori = json.load(ori_film)

film_ori.sort(key = operator.itemgetter('mark_date'), reverse = True)

with open ('film_new.json', 'w') as wf:
    json.dump(film_ori, wf, ensure_ascii=False, indent=2)

这样,我们的数据都搞定了,把它们放进 data/zh/ 里就好了,如果需要多语言的则自行翻译后放进相应语言的文件夹里.

怎么添加新的条目到我的博客中

受之前一篇文章的启发,这里有一个最棒的解决思路:豆瓣的标记是能生成 RSS 的,因此跑一个脚本,在自己帐号的标记书影音 RSS 发生变化时,自动把条目添加到 json 文件中. 而跑这个脚本最好的地方就是 GitHub Action,这样一来还同时解决了 push 到 GitHub repo 以及重新生成网站(很多托管网站像 Vercel、Netlify 都提供这样的功能,本质上就是他们托管商那里也安装了 Hugo 这个软件)的问题,应该是最简单高效,极其完美的解决方案了.

但我这个博客有个特殊之处在于我有一些文章是需要自己跑脚本加密的,必须得我自己生成 public 文件夹再推送出去,不能用托管商的 hugo 命令. 所以我想的是,干脆就放弃豆瓣,自己写一个脚本,需要添加新标记时就跑一下脚本,输入豆瓣 id 后脚本自动去把相关信息爬下来,我只需要打分和写评语就可以了. 现在这个脚本也写好了,甚至可以通过电影的 IMDB 编号或者书籍的 ISBN 编号来搜索项目. 豆瓣的搜索还真是不简单,我使用了这份代码,其原理见微信公众号“日常学 python”的文章【每周一爆】豆瓣读书搜索页的window.__DATA__的解密.

最后写好的代码见这里.

友情链接(该栏目下的其他文章)

  1. 第 1 期:Hugo 静态博客建站记 - 自写主题
  2. 第 2 期:博客记录书影音——Hugo 的 Data Templates - 自写主题(就是这篇!)
  3. 第 3 期:如何让静态网站的暗色主题换页不闪烁 - 自写主题
  4. 第 4 期:Hugo 如何让图片在暗色主题下反色 - 自写主题