记录读过的书、看过的电影,是很多人坚持多年的习惯。豆瓣作为国内主流的书影音平台,积累了丰富的个人数据。本文将介绍如何利用豆瓣 RSS + Python 脚本 + Hexo 数据文件,打造一个自动同步的书影音记录页面。

效果展示

最终效果如下:页面支持电影/书籍 Tab 切换,卡片展示封面、标题、评分和评语,响应式布局适配移动端,并支持暗黑模式。

功能亮点:

  • 自动从豆瓣 RSS 同步标记内容
  • 封面图片本地化,解决豆瓣防盗链问题
  • 支持评分(1-5 星)和评语展示
  • 响应式网格布局 + 暗黑模式适配
  • GitHub Actions 定时自动同步

需求分析

为什么选择豆瓣 RSS

获取豆瓣数据有几种方案:

方案 优点 缺点
豆瓣 API 数据结构化、功能完整 需申请 Key、有配额限制、API 已停止开放
爬虫 数据全面 易被封禁、维护成本高
RSS 开放协议、无需认证、稳定可靠 数据字段有限

豆瓣 RSS 是最简单可靠的方案。每个用户都有公开的 RSS 地址,格式为:

1
2
https://www.douban.com/feed/people/{用户ID}/interests?cat=movie
https://www.douban.com/feed/people/{用户ID}/interests?cat=book

整体架构

数据流程如下:

1
豆瓣 RSS → Python 脚本解析 → YAML 数据文件 → Hexo 渲染 → 静态页面

技术栈:

  • 前端:Hexo 数据文件 + Butterfly 主题 + 自定义 CSS
  • 后端:Python(feedparser、BeautifulSoup、PyYAML)
  • 自动化:GitHub Actions 定时任务

核心问题

实现过程中需要解决几个问题:

  1. 豆瓣防盗链:外链图片无法直接显示,需要下载到本地
  2. 数据去重:增量更新,避免重复同步
  3. 评分解析:RSS 中评分是中文(力荐/推荐等),需映射为数字

前端页面开发

Hexo 数据文件机制

Hexo 支持在 source/_data/ 目录下放置 YAML 或 JSON 文件,通过 site.data 在模板中访问。这种方式非常适合管理结构化数据。

创建两个数据文件:

1
2
3
4
5
6
7
8
9
10
11
12
# source/_data/movies.yml
- title: 肖申克的救赎
cover: /images/douban/xxx.jpg
date: 2025-11-25
rating: 5
comment: 希望是美好的事物

# source/_data/books.yml
- title: 被讨厌的勇气
cover: /images/douban/xxx.jpg
date: 2026-04-26
rating: 4

字段说明:

字段 类型 必填 说明
title string 标题
cover string 封面图片路径
date string 标记日期
rating number 评分 1-5
comment string 短评

Butterfly 主题适配

Butterfly 主题通过 page.type 支持自定义页面类型。在 page.pug 中添加 media 类型:

1
2
3
case page.type
when 'media'
include includes/page/media.pug

创建 source/media/index.md

1
2
3
4
5
6
7
---
title: 书影音
type: media
date: 2024-01-01 00:00:00
---

记录我看过的电影和读过的书籍。

模板文件 themes/butterfly/layout/includes/page/media.pug 核心逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// Tab 切换
.media-tabs
button.media-tab.active(data-tab="movies") 电影
button.media-tab(data-tab="books") 书籍

// 电影列表
.media-content.movies.active
.media-grid
- const movies = site.data.movies || []
- const sortedMovies = movies.sort((a, b) => new Date(b.date) - new Date(a.date))
each item in sortedMovies
.media-card
.media-card-cover
img(src=url_for(item.cover) alt=item.title)
.media-card-info
h3.media-card-title= item.title
.media-card-rating
- for (let i = 0; i < 5; i++)
span.star(class=i < item.rating ? 'filled' : '') ★
if item.comment
p.media-card-comment= item.comment

CSS 样式设计

样式文件 source/css/media.css,在配置中引入:

1
2
3
4
# _config.butterfly.yml
inject:
head:
- <link rel="stylesheet" href="/css/media.css">

关键样式:

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
/* 卡片网格布局 */
.media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 20px;
}

/* 封面图 2:3 比例 */
.media-card-cover {
aspect-ratio: 2 / 3;
overflow: hidden;
}

.media-card:hover .media-card-cover img {
transform: scale(1.05);
}

/* 评分星星 */
.media-card-rating .star {
color: #ddd;
}

.media-card-rating .star.filled {
color: #f5a623;
}

/* 暗黑模式 */
[data-theme="dark"] .media-card {
background: #1f1f1f;
}

数据同步脚本

RSS 解析流程

使用 feedparser 解析 RSS,通过链接域名区分电影和书籍:

1
2
3
4
5
6
7
8
9
10
11
12
import feedparser

RSS_URLS = {
"movie": f"https://www.douban.com/feed/people/{DOUBAN_ID}/interests?cat=movie",
"book": f"https://www.douban.com/feed/people/{DOUBAN_ID}/interests?cat=book",
}

feed = feedparser.parse(RSS_URLS["movie"])
for entry in feed.entries:
link = entry.get("link", "")
if "movie.douban.com" not in link:
continue # 跳过非电影条目

RSS 条目标题格式为"看过肖申克的救赎"或"想看xxx",通过正则提取状态和标题:

1
2
3
4
5
6
import re

status_match = re.match(r"^(看过|读过|在看|在读|想看|想读)(.+)$", title_raw)
if status_match:
status = status_match.group(1) # 看过/读过
title = status_match.group(2) # 肖申克的救赎

只同步"看过"和"读过"的条目,跳过"想看"等状态。

HTML 解析与数据提取

RSS 的 description 字段是 HTML 格式,包含封面、评分、评语:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from bs4 import BeautifulSoup

soup = BeautifulSoup(description, "html.parser")

# 提取封面图
img_tag = soup.find("img")
cover_url = img_tag.get("src") if img_tag else DEFAULT_COVER

# 提取评分
rating_p = soup.find("p", string=re.compile(r"推荐:"))
if rating_p:
rating_match = re.search(r"推荐:\s*(力荐|推荐|还行|较差|很差)", rating_p.get_text())
rating = RATING_MAP.get(rating_match.group(1)) # 映射为 1-5

# 提取评语
comment_p = soup.find("p", string=re.compile(r"备注:"))
if comment_p:
comment = re.search(r"备注:\s*(.+)", comment_p.get_text()).group(1)

评分映射表:

1
2
3
4
5
6
7
RATING_MAP = {
"力荐": 5,
"推荐": 4,
"还行": 3,
"较差": 2,
"很差": 1,
}

图片本地化

豆瓣图片有防盗链,外链无法显示。解决方案是下载到本地:

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
import urllib.request
import hashlib
from pathlib import Path

def download_image(url: str, title: str) -> str:
# 使用 URL hash 作为文件名,避免重复下载
url_hash = hashlib.md5(url.encode()).hexdigest()[:10]
ext = url.split('.')[-1].split('?')[0] or 'jpg'
filename = f"{url_hash}.{ext}"
local_path = Path("source/images/douban") / filename

if local_path.exists():
return f"/images/douban/{filename}"

# 设置 Referer 绕过防盗链
req = urllib.request.Request(url, headers={
'User-Agent': 'Mozilla/5.0 ...',
'Referer': 'https://www.douban.com/'
})

with urllib.request.urlopen(req, timeout=10) as response:
with open(local_path, 'wb') as f:
f.write(response.read())

return f"/images/douban/{filename}"

YAML 数据管理

增量更新,基于标题去重:

1
2
3
4
5
6
7
8
9
10
import yaml

def merge_data(existing: List[Dict], new_items: List[Dict]) -> List[Dict]:
existing_titles = {item.get("title") for item in existing}
truly_new = [item for item in new_items if item.get("title") not in existing_titles]
return truly_new + existing # 新条目放前面

# 保存时保持可读格式
with open(file_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, allow_unicode=True, default_flow_style=False, sort_keys=False)

完整脚本已上传至 GitHub 仓库,依赖:

1
2
3
4
# requirements.txt
feedparser==6.0.10
PyYAML==6.0.1
beautifulsoup4==4.12.2

自动化集成

GitHub Actions 配置

创建 .github/workflows/douban-sync.yml

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
name: Douban Sync

on:
schedule:
# 每周一凌晨 3:00 (北京时间)
- cron: '0 19 * * 0'
workflow_dispatch: # 支持手动触发

permissions:
contents: write

jobs:
sync:
runs-on: ubuntu-latest

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Python
uses: actions/setup-python@v5
with:
python-version: '3.x'

- name: Install dependencies
run: pip install -r tools/requirements.txt

- name: Run sync script
id: sync
run: |
output=$(python tools/douban_sync.py)
echo "$output"
if echo "$output" | grep -q "HAS_NEW_CONTENT=True"; then
echo "has_new=true" >> $GITHUB_OUTPUT
fi

- name: Commit and push changes
if: steps.sync.outputs.has_new == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add source/_data/movies.yml source/_data/books.yml
git commit -m "chore: sync douban data [skip ci]"
git push

关键点:

  • cron: '0 19 * * 0' 表示每周日 UTC 19:00(北京时间周一凌晨 3 点)
  • 脚本输出 HAS_NEW_CONTENT=True 时才提交,避免空提交
  • [skip ci] 提交信息跳过 CI 触发,防止循环

本地调试

手动运行脚本验证:

1
2
cd /path/to/hexo
python tools/douban_sync.py

输出示例:

1
2
3
4
5
6
7
2026-04-26 13:45:21 - INFO - 开始同步 movie...
2026-04-26 13:45:21 - INFO - 解析到 2 个有效条目
2026-04-26 13:45:21 - INFO - 无新内容,跳过保存
2026-04-26 13:45:21 - INFO - 开始同步 book...
2026-04-26 13:45:23 - INFO - 新增条目: 被讨厌的勇气
2026-04-26 13:45:23 - INFO - 同步完成,新增 1 条记录
HAS_NEW_CONTENT=True

总结

本文介绍了如何利用豆瓣 RSS + Python + Hexo 打造自动同步的书影音记录页:

  • 前端:Hexo 数据文件机制 + Butterfly 主题自定义页面 + CSS 卡片布局
  • 后端:Python 解析 RSS、提取数据、下载图片、管理 YAML
  • 自动化:GitHub Actions 定时同步,无需手动维护

可扩展方向

  • 添加音乐、游戏分类
  • 接入 Goodreads、IMDb 等平台
  • 增加年度统计、评分分布图表
  • 瀑布流布局、筛选排序功能

相关链接