记录读过的书、看过的电影,是很多人坚持多年的习惯。豆瓣作为国内主流的书影音平台,积累了丰富的个人数据。本文将介绍如何利用豆瓣 RSS + Python 脚本 + Hexo 数据文件,打造一个自动同步的书影音记录页面。
效果展示
最终效果如下:页面支持电影/书籍 Tab 切换,卡片展示封面、标题、评分和评语,响应式布局适配移动端,并支持暗黑模式。
功能亮点:
- 自动从豆瓣 RSS 同步标记内容
- 封面图片本地化,解决豆瓣防盗链问题
- 支持评分(1-5 星)和评语展示
- 响应式网格布局 + 暗黑模式适配
- GitHub Actions 定时自动同步
需求分析
获取豆瓣数据有几种方案:
| 方案 |
优点 |
缺点 |
| 豆瓣 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 定时任务
核心问题
实现过程中需要解决几个问题:
- 豆瓣防盗链:外链图片无法直接显示,需要下载到本地
- 数据去重:增量更新,避免重复同步
- 评分解析:RSS 中评分是中文(力荐/推荐等),需映射为数字
前端页面开发
Hexo 数据文件机制
Hexo 支持在 source/_data/ 目录下放置 YAML 或 JSON 文件,通过 site.data 在模板中访问。这种方式非常适合管理结构化数据。
创建两个数据文件:
1 2 3 4 5 6 7 8 9 10 11 12
| - title: 肖申克的救赎 cover: /images/douban/xxx.jpg date: 2025-11-25 rating: 5 comment: 希望是美好的事物
- 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
| 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; }
.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; }
|
数据同步脚本
使用 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))
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 = 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}"
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: - 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 等平台
- 增加年度统计、评分分布图表
- 瀑布流布局、筛选排序功能
相关链接: