Stuart Lau bio photo

Je pense, donc je suis.

Email

LinkedIn

Instagram

RedNote

豆瓣广播系统设计与实现

目录

前言

本文介绍豆瓣广播(Broadcast)数据系统的设计与实现,包括数据采集、存储、API设计和前端展示的完整流程。通过该系统,我们将分散在不同年份的豆瓣广播数据整合到统一的页面中展示。

系统架构概览

整体架构设计

┌─────────────────────────────────────────────────────────────────────┐
│                         用户访问层                                    │
│              (浏览器 → GitHub Pages → 静态页面)                       │
├─────────────────────────────────────────────────────────────────────┤
│                         API接口层                                     │
│              (Jekyll Layouts → Data Files → JSON)                   │
├─────────────────────────────────────────────────────────────────────┤
│                         数据处理层                                    │
│         (Python脚本 → JSON清洗 → 按年份分组 → 统计汇总)              │
├─────────────────────────────────────────────────────────────────────┤
│                         数据源层                                      │
│        (豆瓣网页 → 手动导出 → 清洗处理 → 结构化存储)                  │
└─────────────────────────────────────────────────────────────────────┘

数据流转图

flowchart LR
    subgraph 原始数据
        A[豆瓣网页] --> B[手动复制/导出]
    end
    
    subgraph 数据处理
        B --> C[Python清洗脚本]
        C --> D[JSON格式化]
        D --> E[按年份分组]
    end
    
    subgraph 数据存储
        E --> F[_data/douban/]
        F --> G[2021.json]
        F --> H[2022.json]
        F --> I[2023.json]
        F --> J[2024.json]
        F --> K[2025.json]
        F --> L[2026.json]
    end
    
    subgraph 前端展示
        M[douban.html] --> N[年份选择器]
        M --> O[Feed列表]
        M --> P[评论集成]
    end
    
    G --> M
    H --> M
    I --> M
    J --> M
    K --> M
    L --> M

页面展示效果

📸 截图位置:请在此处插入豆瓣广播页面截图

Douban Broadcast Page

如图所示,页面包含:

  • 左侧用户头像和统计信息
  • 中间Feed流展示区
  • 右侧年份导航和热门年份提示

数据结构设计

广播数据模型

# _data/douban/2026.json 示例
- time: "2026-01-12 20:30:45"
  content: "分享一段日常生活记录"
  images:
    - "https://img9.doubanio.com/view/photo/s/public/p1.jpg"
    - "https://img9.doubanio.com/view/photo/s/public/p2.jpg"
  my_rating: 5          # 可选,1-5分
  video_url: ""         # 可选,视频链接
  interact_info:        # 互动数据
    liked_count: 42
    comment_count: 5
    forward_count: 3

数据字段说明

字段 类型 说明
time String 发布时间,格式:YYYY-MM-DD HH:mm:ss
content String 广播文本内容
images Array 图片URL列表,支持多图
my_rating Integer 个人评分(1-5),仅限读书/电影/游戏标记
video_url String 视频链接(可选)
interact_info Object 互动统计数据

年份汇总统计

// _data/douban_summaries.json
{
  "2026": {
    "count": 156,
    "with_images": 89,
    "with_videos": 12,
    "avg_rating": 4.2,
    "top_topics": ["旅行", "美食", "日常生活"]
  },
  "2025": {
    "count": 203,
    "with_images": 134,
    "with_videos": 18,
    "avg_rating": 4.0,
    "top_topics": ["育儿", "技术", "旅行"]
  }
}

前端实现

页面布局结构

<div class="douban-container">
    <!-- 左侧边栏 -->
    <aside class="douban-sidebar">
        <div class="user-info">
            <img src="/images/douban_avatar.jpg" class="avatar" />
            <h2>Stuart Lau</h2>
            <p class="nickname">@stuartlau</p>
        </div>
        <div class="stats">
            <div class="stat-item">
                <span class="stat-value">1200+</span>
                <span class="stat-label">广播</span>
            </div>
            <div class="stat-item">
                <span class="stat-value">50+</span>
                <span class="stat-label">书籍</span>
            </div>
        </div>
    </aside>
    
    <!-- 主内容区 -->
    <main class="douban-main">
        <!-- 年份选择器 -->
        <nav class="year-tabs">
            <a href="#2026" class="year-tab active">2026</a>
            <a href="#2025" class="year-tab">2025</a>
            ...
        </nav>
        
        <!-- Feed列表 -->
        <div class="feed-list">
            <!-- 广播条目 -->
        </div>
    </main>
</div>

广播条目组件

┌────────────────────────────────────────────────────────────┐
│  用户头像    @stuartlau        3Day 3h                     │
├────────────────────────────────────────────────────────────┤
│                                                             │
│  这是广播的文字内容,可能很长,可能包含多行                 │
│                                                             │
│  ┌────┐ ┌────┐ ┌────┐                                     │
│  │图1 │ │图2 │ │图3 │  图片网格展示                        │
│  └────┘ └────┘ └────┘                                     │
│                                                             │
├────────────────────────────────────────────────────────────┤
│  💬 ...  🔖 ...  ❤️ ...           点击展开评论             │
└────────────────────────────────────────────────────────────┘

相对时间格式化

function formatTime(timeStr) {
    const now = new Date();
    const statusDate = new Date(timeStr.replace(' ', 'T'));
    const diffMs = now - statusDate;
    const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
    const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
    const diffMinutes = Math.floor(diffMs / (1000 * 60));

    if (diffDays < 7 && diffDays >= 0) {
        if (diffDays > 0) {
            return `${diffDays}Day ${diffHours % 24}h`;
        } else if (diffHours > 0) {
            return `${diffHours}h ${diffMinutes % 60}m`;
        } else {
            return `${diffMinutes}m`;
        }
    } else {
        // 超过7天显示具体日期
        const year = statusDate.getFullYear();
        const month = String(statusDate.getMonth() + 1).padStart(2, '0');
        const day = String(statusDate.getDate()).padStart(2, '0');
        return `${year}-${month}-${day}`;
    }
}

年份筛选逻辑

// 使用data属性存储年份信息
document.querySelectorAll('.douban-status-item').forEach(item => {
    const itemYear = item.dataset.year;
    const targetYear = getCurrentYearFromHash();
    
    if (itemYear === targetYear || targetYear === 'all') {
        item.style.display = '';
    } else {
        item.style.display = 'none';
    }
});

// 简化:直接链接到不同页面
// /douban/2026.html, /douban/2025.html, ...

评论系统集成

Giscus集成

<!-- 使用Giscus实现GitHub Discussions评论 -->
<script 
    src="https://giscus.app/client.js"
    data-repo="stuartlau/stuartlau.github.io"
    data-repo-id="xxx"
    data-category="Announcements"
    data-category-id="xxx"
    data-mapping="pathname"
    data-strict="0"
    data-reactions-enabled="1"
    data-emit-metadata="0"
    data-input-position="bottom"
    data-theme="light"
    data-lang="zh-CN"
    crossorigin="anonymous"
    async>
</script>

评论动态加载

function toggleGiscus(element) {
    const wrapper = element.closest('.douban-status-item')
                       .nextElementSibling
                       .querySelector('.giscus-wrapper');
    
    if (wrapper.style.display === 'none') {
        wrapper.style.display = 'block';
        // Giscus自动加载
        if (typeof giscus !== 'undefined') {
            giscus.configure();
        }
    } else {
        wrapper.style.display = 'none';
    }
}

数据统计与可视化

年度数据统计

graph LR
    A[原始数据] --> B[数据清洗]
    B --> C[统计计算]
    C --> D[可视化展示]
    
    subgraph 统计维度
        E[总数统计]
        F[图片占比]
        G[评分分布]
        H[月份分布]
        I[互动排行]
    end
    
    C --> E
    C --> F
    C --> G
    C --> H
    C --> I
    
    D --> J[柱状图]
    D --> K[饼图]
    D --> L[时间线]

统计展示效果

📸 截图位置:请在此处插入统计卡片截图

Douban Stats

图片处理优化

图片网格布局

.status-images {
    display: flex;
    gap: 8px;
    margin-top: 12px;
}

.status-image-thumb {
    width: 120px;
    height: 120px;
    background-size: cover;
    background-position: center;
    border-radius: 8px;
    cursor: pointer;
    transition: transform 0.2s;
}

.status-image-thumb:hover {
    transform: scale(1.05);
}

灯箱效果

function openLightbox(imageUrl, allImages) {
    currentImages = allImages;
    currentImageIndex = currentImages.indexOf(imageUrl);
    
    const lb = document.getElementById('lightbox');
    document.getElementById('lightbox-img').src = imageUrl;
    lb.style.display = 'flex';
    document.body.style.overflow = 'hidden';
}

// 键盘导航
document.addEventListener('keydown', function(e) {
    const lb = document.getElementById('lightbox');
    if (lb.style.display === 'flex') {
        if (e.key === 'ArrowRight') nextLightboxImage();
        if (e.key === 'ArrowLeft') prevLightboxImage();
        if (e.key === 'Escape') closeLightbox();
    }
});

数据维护流程

添加新广播的流程

flowchart TD
    A[在豆瓣发布新广播] --> B[定期导出数据]
    B --> C[运行清洗脚本]
    C --> D[更新对应年份JSON]
    D --> E[本地验证]
    E --> F[提交代码]
    F --> G[自动部署]
    G --> H[线上生效]

清洗脚本示例

import json
import re
from datetime import datetime

def clean_douban_data(raw_text):
    """清洗豆瓣原始数据"""
    records = []
    
    for line in raw_text.strip().split('\n'):
        if not line.strip():
            continue
            
        # 解析时间
        time_match = re.search(r'(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})', line)
        if time_match:
            time_str = time_match.group(1)
            
            # 提取内容(去除时间和特殊字符)
            content = re.sub(r'\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]', '', line)
            content = content.strip()
            
            records.append({
                'time': time_str,
                'content': content,
                'images': extract_images(content),
                'interact_info': {
                    'liked_count': '0',
                    'comment_count': '0'
                }
            })
    
    return records

def group_by_year(records):
    """按年份分组"""
    grouped = {}
    for record in records:
        year = record['time'][:4]
        if year not in grouped:
            grouped[year] = []
        grouped[year].append(record)
    
    return grouped

总结

本文介绍了豆瓣广播数据系统的完整实现:

模块 技术方案
数据采集 手动导出 + Python清洗
数据存储 Jekyll Data Files(YAML/JSON)
页面生成 Jekyll Layouts + Liquid模板
交互功能 Vanilla JS(Tab切换、灯箱)
评论系统 Giscus(GitHub Discussions)
部署方式 GitHub Pages自动部署

该系统充分利用了静态站点的优势,实现了低运维成本的数据聚合展示。