Astro 博客集成豆瓣书影音展示功能完整教程 -Retypeset

9 min

Retypeset Theme

前言

很多博主都希望在自己的博客上展示豆瓣的读书、观影记录,但豆瓣官方 API 早已关闭,第三方服务如 mouban.mythsman.com 也相继停止运营。本文将介绍一套完整的解决方案,帮助你在 Astro 博客中实现豆瓣书影音展示功能。

实现原理

整体方案分为三个部分:

  1. 数据获取:通过豆瓣 RSS Feed 或自部署 API 获取用户的书影音数据
  2. 数据存储:将数据保存为 JSON 文件,构建时静态渲染
  3. 图片代理:解决豆瓣图片防盗链导致的 403 问题

为什么选择静态数据?

相比实时调用 API,静态数据方案有以下优势:

  • 稳定性高:不依赖第三方服务的可用性
  • 加载速度快:数据在构建时已经准备好
  • 无 API 限制:不受请求频率限制
  • 可离线编辑:可以手动补充或修改数据

项目结构

├── scripts/
│   └── fetch-douban.ts      # 数据获取脚本
├── src/
│   ├── config.ts            # 配置文件(豆瓣用户 ID)
│   ├── data/
│   │   ├── douban-books.json    # 书籍数据
│   │   └── douban-movies.json   # 电影数据
│   └── pages/
│       └── [...lang]/
│           └── other/
│               └── douban.astro # 展示页面
└── .github/
    └── workflows/
        └── fetch-douban.yml # 自动更新工作流

第一步:配置豆瓣用户 ID

src/config.ts 中添加豆瓣配置:

export const themeConfig: ThemeConfig = {
  // ... 其他配置
  
  douban: {
    // 豆瓣用户 ID
    // 可在豆瓣个人主页 URL 中找到
    // 如 https://www.douban.com/people/245847465/
    userId: '245847465',
  },
}

第二步:创建数据获取脚本

创建 scripts/fetch-douban.ts

/**
 * 豆瓣数据获取脚本
 * 
 * 支持两种数据源:
 * 1. 豆瓣 RSS Feed (默认,无需配置)
 * 2. 自定义 API (需要自行部署)
 */

import * as fs from 'node:fs'
import * as path from 'node:path'

// 配置区域
const API_BASE_URL = '' // 自定义 API 地址(可选)
const CONFIG_PATH = path.join(process.cwd(), 'src/config.ts')
const DATA_DIR = path.join(process.cwd(), 'src/data')

// 类型定义
interface DoubanItem {
  id: string
  title: string
  cover: string
  rating?: number
  myRating?: number
  status: 'done' | 'doing' | 'wish'
  comment?: string
  date?: string
  author?: string
  director?: string
  year?: string
  link?: string
  type: 'book' | 'movie'
}

// 从 config.ts 提取 userId
function getUserId(): string {
  const configContent = fs.readFileSync(CONFIG_PATH, 'utf-8')
  const match = configContent.match(/userId:\s*['"]([^'"]+)['"]/)
  return match ? match[1] : ''
}

// 解析状态
function parseStatus(title: string): 'done' | 'doing' | 'wish' {
  if (title.startsWith('看过') || title.startsWith('读过'))
    return 'done'
  if (title.startsWith('在看') || title.startsWith('在读'))
    return 'doing'
  return 'wish'
}

// 从 RSS Feed 获取数据
async function fetchFromRSS(userId: string): Promise<DoubanItem[]> {
  const rssUrl = `https://www.douban.com/feed/people/${userId}/interests`
  
  const response = await fetch(rssUrl, {
    headers: {
      'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)',
    },
  })

  const xml = await response.text()
  const items: DoubanItem[] = []

  // 解析 XML
  const itemMatches = xml.matchAll(/<item>([\s\S]*?)<\/item>/g)

  for (const match of itemMatches) {
    const itemXml = match[1]
    const titleMatch = itemXml.match(/<title>(.*?)<\/title>/)
    const linkMatch = itemXml.match(/<link>(.*?)<\/link>/)
    const imgMatch = itemXml.match(/<img src="https://tpic2024.en.icu/Astro2026/public/content/posts/2025-12-15-astro-douban-integration/([^"]+)"/)
    const pubDateMatch = itemXml.match(/<pubDate>(.*?)<\/pubDate>/)

    if (!titleMatch || !linkMatch) continue

    const fullTitle = titleMatch[1]
    const link = linkMatch[1]
    const idMatch = link.match(/subject\/(\d+)/)

    items.push({
      id: idMatch ? idMatch[1] : '',
      title: fullTitle.replace(/^(想看|在看|看过|想读|在读|读过)/, '').trim(),
      cover: imgMatch ? imgMatch[1].replace('/s/', '/m/') : '',
      status: parseStatus(fullTitle),
      type: link.includes('book.douban.com') ? 'book' : 'movie',
      link,
      date: pubDateMatch 
        ? new Date(pubDateMatch[1]).toISOString().split('T')[0] 
        : undefined,
    })
  }

  return items
}

// 主函数
async function main() {
  const userId = getUserId()
  if (!userId) {
    console.error('未找到豆瓣用户 ID')
    process.exit(1)
  }

  console.log(`获取豆瓣数据,用户 ID: ${userId}`)

  // 确保数据目录存在
  if (!fs.existsSync(DATA_DIR)) {
    fs.mkdirSync(DATA_DIR, { recursive: true })
  }

  const items = await fetchFromRSS(userId)
  
  // 分离电影和书籍
  const movies = items.filter(item => item.type === 'movie')
  const books = items.filter(item => item.type === 'book')

  // 保存数据
  fs.writeFileSync(
    path.join(DATA_DIR, 'douban-movies.json'),
    JSON.stringify(movies, null, 2)
  )
  fs.writeFileSync(
    path.join(DATA_DIR, 'douban-books.json'),
    JSON.stringify(books, null, 2)
  )

  console.log(`完成!电影 ${movies.length} 条,书籍 ${books.length} 条`)
}

main()

package.json 中添加脚本命令:

{
  "scripts": {
    "fetch-douban": "tsx scripts/fetch-douban.ts"
  }
}

运行 pnpm fetch-douban 即可获取数据。

第三步:解决图片防盗链问题

豆瓣图片有防盗链机制,直接引用会返回 403 错误。我们的脚本采用本地存储方案,在获取数据时同时下载封面图片到 public/douban/ 目录。

本地存储方案(推荐)

脚本会自动:

  1. 下载豆瓣封面图片到 public/douban/ 目录
  2. 将数据中的 cover 字段更新为本地路径(如 /douban/1292052.webp
  3. 页面直接使用本地图片,完全避免 403 问题

优势:

  • 完全避免 403:图片存储在本地,不受豆瓣限制
  • 加载速度快:本地图片加载更快
  • 离线可用:即使豆瓣服务不可用,图片仍然正常显示
  • 版本控制:图片随代码一起管理

备用方案

如果不想下载图片到本地,也可以使用以下方案:

方案一:图片代理服务

function proxyImage(url: string): string {
  return `https://wsrv.nl/?url=${encodeURIComponent(url)}`
}

方案二:referrerpolicy

<img referrerpolicy="no-referrer" src={item.cover} />

第四步:创建展示页面

创建 src/pages/[...lang]/other/douban.astro

---
import Layout from '@/layouts/Layout.astro'

// 加载数据
let books = []
let movies = []

try {
  const booksModule = await import('@/data/douban-books.json')
  books = booksModule.default || []
} catch {}

try {
  const moviesModule = await import('@/data/douban-movies.json')
  movies = moviesModule.default || []
} catch {}

// 图片代理
function proxyImage(url) {
  if (!url) return ''
  return `https://wsrv.nl/?url=${encodeURIComponent(url)}`
}
---

<Layout>
  <h1>书影音</h1>
  
  <section>
    <h2>电影 ({movies.length})</h2>
    <div class="grid">
      {movies.map((item) => (
        <a href={item.link} target="_blank">
          <img src={proxyImage(item.cover)} alt={item.title} />
          <h3>{item.title}</h3>
        </a>
      ))}
    </div>
  </section>

  <section>
    <h2>书籍 ({books.length})</h2>
    <div class="grid">
      {books.map((item) => (
        <a href={item.link} target="_blank">
          <img src={proxyImage(item.cover)} alt={item.title} />
          <h3>{item.title}</h3>
        </a>
      ))}
    </div>
  </section>
</Layout>

<style>
  .grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
    gap: 1rem;
  }
</style>

第五步:设置自动更新(可选)

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

name: Fetch Douban Data

on:
  schedule:
    - cron: '0 0 * * 1'  # 每周一运行
  workflow_dispatch:      # 支持手动触发

jobs:
  fetch-douban:
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v4
      
      - uses: pnpm/action-setup@v2
        with:
          version: 10
          
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'pnpm'
          
      - run: pnpm install --frozen-lockfile
      - run: pnpm fetch-douban
      
      - name: Commit changes
        run: |
          git config user.email "action@github.com"
          git config user.name "GitHub Action"
          git add src/data/douban-*.json
          git diff --staged --quiet || git commit -m "chore: update douban data"
          git push

数据格式说明

douban-movies.json

[
  {
    "id": "1292052",
    "title": "肖申克的救赎",
    "cover": "https://img2.doubanio.com/view/photo/s_ratio_poster/public/p480747492.jpg",
    "rating": 9.7,
    "myRating": 5,
    "status": "done",
    "director": "弗兰克·德拉邦特",
    "actors": "蒂姆·罗宾斯 / 摩根·弗里曼",
    "year": "1994",
    "date": "2024-02-10",
    "comment": "希望是个好东西",
    "link": "https://movie.douban.com/subject/1292052/"
  }
]

字段说明

字段类型说明
idstring豆瓣条目 ID
titlestring标题
coverstring封面图片 URL
ratingnumber豆瓣评分
myRatingnumber我的评分(1-5)
statusstring状态:done/doing/wish
directorstring导演(电影)
authorstring作者(书籍)
yearstring年份
datestring标记日期
commentstring短评
linkstring豆瓣链接

进阶:自部署 API

如果 RSS Feed 数据量不够,可以自己部署一个 Cloudflare Worker 来代理豆瓣数据。

创建 scripts/douban-worker.js 并部署到 Cloudflare Workers:

const DOUBAN_HEADERS = {
  'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X)',
  'Referer': 'https://m.douban.com/',
}

export default {
  async fetch(request) {
    const url = new URL(request.url)
    const pathMatch = url.pathname.match(/^\/(movies|books)\/(\d+)$/)
    
    if (!pathMatch) {
      return new Response(JSON.stringify({ error: 'Invalid path' }), {
        status: 400,
        headers: { 'Content-Type': 'application/json' },
      })
    }

    const [, category, userId] = pathMatch
    const type = url.searchParams.get('type') || 'done'
    
    const doubanUrl = category === 'movies'
      ? `https://m.douban.com/people/${userId}/movie/${type}`
      : `https://m.douban.com/people/${userId}/book/${type}`

    const response = await fetch(doubanUrl, { headers: DOUBAN_HEADERS })
    const html = await response.text()
    
    // 解析 HTML 并返回 JSON
    // ... 解析逻辑
    
    return new Response(JSON.stringify(data), {
      headers: {
        'Content-Type': 'application/json',
        'Access-Control-Allow-Origin': '*',
      },
    })
  },
}

常见问题

Q: RSS Feed 只能获取最近的数据?

A: 是的,豆瓣 RSS Feed 只返回最近约 10 条记录。如需完整数据,可以:

  1. 手动编辑 JSON 文件补充历史数据
  2. 自部署 API 获取完整列表

Q: 图片还是显示不出来?

A: 检查以下几点:

  1. 确认使用了图片代理或设置了 referrerpolicy
  2. 检查图片 URL 是否正确
  3. 尝试清除浏览器缓存

Q: 如何添加更多字段?

A: 直接编辑 JSON 文件即可,页面会自动读取新字段。

总结

通过本教程,你已经学会了:

  1. 使用 RSS Feed 获取豆瓣数据
  2. 解决图片防盗链问题
  3. 创建书影音展示页面
  4. 设置 GitHub Actions 自动更新

这套方案的优点是不依赖任何第三方服务,数据完全由自己掌控,稳定可靠。

如果你有任何问题,欢迎在评论区留言讨论!

评论 43