豆瓣广播Feed系统架构设计与实现
目录
项目背景
在个人博客中集成豆瓣广播(Status)功能,实现类似微博/朋友圈的动态展示。该项目历时数周,涉及数据同步、前端展示、评论系统集成等多个技术领域,最终实现了一个功能完善、用户体验优秀的Feed系统。
核心目标
- 数据同步: 自动从豆瓣抓取广播内容并本地化存储
- 视觉呈现: 仿豆瓣绿色气泡UI,支持图片、文字、时间戳
- 互动功能: 集成Giscus评论系统,实现每条广播独立评论区
- 性能优化: 懒加载、单实例管理,确保页面流畅
- 移动适配: 响应式设计,移动端弹窗体验
系统架构
整体架构图
graph TB
subgraph "数据层 Data Layer"
A[豆瓣API] -->|HTTP请求| B[Python同步脚本]
B -->|解析HTML/JSON| C[图片下载器]
B -->|写入| D[本地JSON文件]
C -->|存储| E[/images/douban/]
end
subgraph "构建层 Build Layer"
D -->|Jekyll读取| F[Liquid模板引擎]
E -->|静态资源| F
F -->|渲染| G[静态HTML页面]
end
subgraph "展示层 Presentation Layer"
G -->|加载| H[浏览器]
H -->|渲染| I[Feed UI组件]
I -->|懒加载| J[Giscus评论系统]
I -->|IntersectionObserver| K[统计数据API]
end
subgraph "第三方服务 External Services"
L[GitHub Discussions] -->|提供评论存储| J
K -->|查询| M[Giscus API]
M -->|返回count| I
end
style A fill:#f9f,stroke:#333,stroke-width:2px
style D fill:#bfb,stroke:#333,stroke-width:2px
style G fill:#bbf,stroke:#333,stroke-width:2px
style J fill:#fbb,stroke:#333,stroke-width:2px
数据流转图
sequenceDiagram
participant User
participant Browser
participant Jekyll
participant Python
participant Douban
participant Giscus
participant GitHub
Note over Python,Douban: 数据同步阶段
Python->>Douban: 请求用户广播列表
Douban-->>Python: 返回HTML/JSON
Python->>Python: 解析内容、提取图片URL
Python->>Douban: 下载图片
Douban-->>Python: 返回图片二进制
Python->>Python: 保存JSON + 图片到本地
Note over Jekyll,Browser: 页面构建阶段
Jekyll->>Jekyll: 读取JSON数据
Jekyll->>Jekyll: Liquid模板渲染
Jekyll-->>Browser: 返回静态HTML
Note over Browser,Giscus: 用户交互阶段
User->>Browser: 滚动页面
Browser->>Browser: IntersectionObserver触发
Browser->>Giscus: 请求评论统计
Giscus->>GitHub: 查询Discussion
GitHub-->>Giscus: 返回comments/reactions
Giscus-->>Browser: 返回统计数据
Browser->>Browser: 更新UI显示
User->>Browser: 点击评论图标
Browser->>Browser: 移动全局Giscus容器
Browser->>Giscus: 重载iframe(新term)
Giscus->>GitHub: 加载对应Discussion
GitHub-->>Giscus: 返回评论内容
Giscus-->>Browser: 渲染评论界面
核心功能模块
1. 数据同步模块
技术选型
- 语言: Python 3.x
- HTTP库: requests
- HTML解析: BeautifulSoup4 / lxml
- 并发: 多线程图片下载
同步流程
flowchart TD
Start([开始同步]) --> Auth{检查认证}
Auth -->|Cookie有效| FetchList[获取广播列表]
Auth -->|Cookie失效| Error1[抛出认证错误]
FetchList --> ParseHTML[解析HTML/JSON]
ParseHTML --> ExtractData[提取文本/时间/图片]
ExtractData --> HasImages{包含图片?}
HasImages -->|是| DownloadImg[并发下载图片]
HasImages -->|否| SaveJSON
DownloadImg --> CheckExist{图片已存在?}
CheckExist -->|是| Skip[跳过下载]
CheckExist -->|否| Download[下载并保存]
Download --> SaveJSON[保存JSON数据]
Skip --> SaveJSON
SaveJSON --> CheckNext{还有更多?}
CheckNext -->|是| FetchList
CheckNext -->|否| End([同束])
Error1 --> End
关键代码结构
class DoubanSyncClient:
def __init__(self, cookie, user_id):
self.session = requests.Session()
self.session.headers.update({'Cookie': cookie})
def fetch_statuses(self, year):
"""获取指定年份的所有广播"""
statuses = []
page = 0
while True:
data = self._fetch_page(year, page)
if not data:
break
statuses.extend(self._parse_statuses(data))
page += 1
return statuses
def download_images(self, statuses):
"""并发下载图片"""
with ThreadPoolExecutor(max_workers=5) as executor:
futures = []
for status in statuses:
for img_url in status.get('images', []):
future = executor.submit(
self._download_image, img_url
)
futures.append(future)
wait(futures)
2. UI展示模块
气泡设计
采用仿豆瓣的绿色气泡UI,包含:
- 头像: 左侧圆形头像
- 气泡: 带箭头指向头像的白色/绿色气泡
- 内容: 文字 + 图片缩略图网格
- 页脚: 时间戳 + 评论/点赞统计
CSS架构
graph LR
A[douban.css] --> B[基础布局]
A --> C[气泡样式]
A --> D[图片网格]
A --> E[响应式]
A --> F[暗黑模式]
A --> G[Giscus集成]
C --> C1[气泡背景]
C --> C2[箭头]
C --> C3[阴影]
E --> E1[PC端]
E --> E2[移动端弹窗]
F --> F1[颜色变量]
F --> F2[主题切换]
关键样式
/* 气泡容器 */
.status-bubble {
background: #fff;
border-radius: 8px;
padding: 12px 15px;
position: relative;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
/* 气泡箭头 */
.status-bubble::before {
content: '';
position: absolute;
left: -8px;
top: 20px;
width: 0;
height: 0;
border-style: solid;
border-width: 8px 8px 8px 0;
border-color: transparent #fff transparent transparent;
}
/* 图片网格 */
.status-images {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 4px;
margin-top: 10px;
}
/* 暗黑模式 */
[data-theme="dark"] .status-bubble {
background: #1e1e1e;
color: #e0e0e0;
}
3. Giscus评论集成
这是整个项目最复杂的部分,面临多个技术挑战。
架构演进
graph TB
subgraph "V1: 多实例方案 ❌"
A1[每条Feed] --> B1[独立Giscus容器]
B1 --> C1[加载client.js]
C1 --> D1[创建iframe]
D1 --> E1[问题: 只有第一个有效]
end
subgraph "V2: postMessage方案 ❌"
A2[单个Giscus实例] --> B2[监听点击事件]
B2 --> C2[postMessage更新配置]
C2 --> D2[问题: Giscus不响应]
end
subgraph "V3: iframe重载方案 ✅"
A3[全局Giscus容器] --> B3[DOM移动到目标Feed]
B3 --> C3[修改iframe.src参数]
C3 --> D3[iframe重新加载]
D3 --> E3[成功: 显示正确评论]
end
style E1 fill:#fbb
style D2 fill:#fbb
style E3 fill:#bfb
核心实现
// 全局单例管理
var globalGiscusContainer = null;
var giscusIframe = null;
var currentTerm = null;
function toggleGiscus(el) {
var wrapper = el.closest('.status-bubble')
.querySelector('.giscus-wrapper');
var term = wrapper.getAttribute('data-term');
// 初次加载
if (!globalGiscusContainer) {
initGlobalGiscus(wrapper, term);
return;
}
// 复用实例
moveGiscusToWrapper(wrapper);
updateGiscusTerm(term);
}
function updateGiscusTerm(newTerm) {
if (currentTerm === newTerm) return;
// 关键: 修改iframe src强制重载
var currentSrc = giscusIframe.src;
var newSrc = currentSrc.replace(
/term=[^&]*/,
'term=' + encodeURIComponent(newTerm)
);
giscusIframe.src = newSrc;
currentTerm = newTerm;
}
Term生成策略
为确保每条Feed有唯一标识,采用组合方案:
term = "douban-{时间戳}-{内容长度}"
示例: "douban-2026-01-10-13-22-114"
- ✅ 时间精确到分钟,基本唯一
- ✅ 内容长度作为哈希,处理同时刻多条
- ✅ 避免中文slugify失败问题
- ✅ 稳定不变,不受数据顺序影响
4. 懒加载优化
IntersectionObserver应用
sequenceDiagram
participant Page
participant Observer
participant API
participant UI
Page->>Observer: 注册.giscus-stats元素
Note over Observer: 监听元素进入视口
Observer->>Observer: 元素进入rootMargin
Observer->>API: fetchGiscusStats(term)
API->>API: GET /api/discussions?term=xxx
API-->>Observer: {totalCommentCount, reactionCount}
Observer->>UI: 更新💬和❤️数字
Observer->>Observer: unobserve(已加载元素)
实现代码
// 创建观察器
var statsObserver = new IntersectionObserver(
function(entries, observer) {
entries.forEach(function(entry) {
if (entry.isIntersecting) {
var statsEl = entry.target;
if (!statsEl.hasAttribute('data-fetched')) {
fetchGiscusStats(statsEl);
statsEl.setAttribute('data-fetched', 'true');
}
observer.unobserve(statsEl);
}
});
},
{ rootMargin: '50px' } // 提前50px开始加载
);
// 注册所有统计元素
document.querySelectorAll('.giscus-stats').forEach(function(el) {
statsObserver.observe(el);
});
5. 移动端适配
响应式策略
graph TD
A[检测设备] --> B{屏幕宽度}
B -->|> 768px| C[PC端布局]
B -->|<= 768px| D[移动端布局]
C --> C1[直接显示图片网格]
C --> C2[hover效果]
C --> C3[inline评论展开]
D --> D1[点击打开弹窗]
D --> D2[全屏查看内容]
D --> D3[滑动查看图片]
D --> D4[底部评论按钮]
弹窗实现
// 移动端弹窗
function showMobilePopup(index) {
var popup = document.getElementById('popup-' + index);
popup.classList.add('active');
document.body.style.overflow = 'hidden'; // 禁止背景滚动
}
// 图片全屏查看
function showFullImage(imgSrc) {
var overlay = document.createElement('div');
overlay.className = 'image-overlay';
overlay.innerHTML = '<img src="' + imgSrc + '">';
overlay.onclick = function() {
overlay.remove();
document.body.style.overflow = '';
};
document.body.appendChild(overlay);
}
技术挑战与解决方案
挑战1: Giscus多实例冲突
问题: Giscus的client.js在同一页面只能初始化一次,后续调用无法创建新iframe
尝试方案:
- ❌ 每个Feed独立加载 → 只有第一个成功
- ❌ 使用postMessage更新配置 → Giscus不响应
- ✅ 单实例+DOM移动+iframe重载 → 成功
最终方案:
- 创建全局唯一Giscus容器
- 点击时移动容器到目标Feed下方
- 修改iframe src的term参数触发重载
挑战2: 中文内容Slugify失败
问题: Jekyll的slugify过滤器会移除所有中文字符,导致term为空或重复
解决方案:
<!-- 错误方式 -->
{{ status.content | slugify }}
<!-- 结果: "" (空字符串) -->
<!-- 正确方式 -->
{{ status.time | replace:' ','-' | replace:':','-' }}-{{ status.content | size }}
<!-- 结果: "2026-01-10-13-22-114" -->
挑战3: API请求优化
问题: 数百条Feed同时请求Giscus API导致:
- 页面卡顿
- 可能触发GitHub API rate limit
解决方案:
- ✅ 使用IntersectionObserver懒加载
- ✅ 设置
rootMargin: '50px'提前加载 - ✅ 加载后立即
unobserve,避免重复请求 - ✅ 设置
data-fetched标记防止重复
效果对比:
优化前: 页面加载 → 立即发送200+请求 → 阻塞3-5秒
优化后: 滚动触发 → 按需加载5-10个 → 无感知
挑战4: 暗黑模式适配
问题: Giscus iframe有自己的主题,需要与网站主题同步
解决方案:
// 初始化时检测当前主题
var theme = document.documentElement
.getAttribute('data-theme') === 'dark'
? 'dark' : 'light';
script.setAttribute("data-theme", theme);
性能指标
构建性能
| 指标 | 优化前 | 优化后 | 改善 |
|---|---|---|---|
| Jekyll构建时间 | 10分钟 | 1分钟 | 90% ⬇️ |
| 数据文件大小 | 5MB | 150KB | 97% ⬇️ |
| 页面HTML大小 | 2MB | 800KB | 60% ⬇️ |
用户体验指标
- 首屏渲染: < 500ms
- 滚动流畅度: 60fps
- 评论加载: < 1s
- 移动端适配: 100%
- 暗黑模式: 完全支持
经验教训
✅ 成功经验
- 分层架构: 数据层、构建层、展示层分离,易于维护和扩展
- 性能优先: 懒加载、增量构建、按需请求,确保用户体验
- 渐进增强: 先实现核心功能,再逐步添加交互
- 充分测试: 每个功能点都经过PC/移动端/暗黑模式测试
⚠️ 需要改进
- 错误处理: API请求缺少retry机制和fallback
- 缓存策略: 可以添加localStorage缓存统计数据
- 图片优化: 未实施压缩和WebP转换
- 无障碍: 缺少ARIA标签和键盘导航
总结
本项目历时数周,从最初的简单需求到最终的完整系统,经历了多次架构调整和技术攻坚。核心挑战在于:
- Giscus集成: 解决多实例冲突,最终采用单实例+iframe重载方案
- 性能优化: 通过懒加载和增量构建将构建时间从10分钟降至1分钟
- 用户体验: 实现PC/移动端双端适配,支持暗黑模式
最终实现了一个功能完善、性能优秀、用户体验良好的Feed系统,为个人博客增添了动态内容展示能力。
技术栈总结
| 层级 | 技术选型 | 作用 |
|---|---|---|
| 数据采集 | Python + requests + BeautifulSoup | 豆瓣爬虫 |
| 数据存储 | JSON + 本地文件系统 | 结构化存储 |
| 静态生成 | Jekyll + Liquid | 模板渲染 |
| 前端框架 | 原生JavaScript | 交互逻辑 |
| 样式方案 | CSS + CSS Variables | 响应式+暗黑模式 |
| 评论系统 | Giscus + GitHub Discussions | 社交互动 |
| 性能优化 | IntersectionObserver + 懒加载 | 体验优化 |
关键指标
- 代码行数: ~2000行 (Python 500, JS 800, CSS 700)
- 数据量: 2021-2026共 1500+ 条广播
- 图片数量: 500+ 张
- 构建时间: 60秒
- 页面大小: 800KB
- 性能评分: Lighthouse 95+
项目地址: GitHub
在线演示: 豆瓣广播
作者: Stuart Lau
完成日期: 2026-01-10
RedNote