拒绝第三方依赖:用纯静态架构,把 900 条豆瓣记录搬进个人博客
想把豆瓣的多年观影与阅读记录同步到自己的个人博客上展示,折腾了一整圈后发现:网上的大多数现成方案要么早已失效,要么过度依赖臃肿的第三方运行时与不稳定的外部代理。
本文将完整复盘一套**“100% 本地化掌控、零外部运行时依赖、全自动增量同步”的纯静态书影音归档架构(Zen Architecture)**。从历史数据的冷启动导出、Node.js RSS 每日自动化增量抓取、海报本地化防盗链破解,到 Apple 级毛玻璃前端微交互设计、GitHub Actions 定时同步与 PWA 离线容灾,实现全链路的闭环。

1. 引言与架构全景:为什么追求 100% 本地掌控?
在决定自己手搓整套方案之前,我深度调研并测试了社区中现存的主流实现,但它们都在不同维度上存在致命短板:
1.1 社区现存方案痛点与技术选型
| 方案类别 | 代表工具 / 途径 | 现状与核心痛点 | 结论 |
|---|---|---|---|
| 浏览器插件导出 | 豆伴 / 豆坟 | 原版年久失修;社区 MV3 维护版在豆瓣严苛风控下极易漏抓、截断或报 403 | 放弃 |
| 官方合规导出 | 豆瓣个人信息数据包 | 依据法规下载的归档仅包含基础账号信息,完全不含书影音的标记明细与个人短评 | 毫无价值 |
| 第三方文化平台 | NeoDB / Notion 挂载 | 依赖第三方服务器托管;存在 API 速率限制、图床防盗链失效与平台迁移成本 | 只能作下游 |
| 前端动态 SPA / API 代理 | React / Vue 挂件 + Serverless | 为展示静态列表引入庞大 Runtime 与外部 API 请求;海报易被豆瓣防盗链 418 拦截 | 违背极简初衷 |
破局思路:与其在脆弱的第三方服务上修修补补,不如彻底贯彻 “数据在本地、资产在本地、计算在构建期” 的纯静态哲学。
1.2 现代静态书影音架构全景图
整个系统被拆解为清晰的五大层级流水线,数据单向流转,架构坚固且零耦合:
1.3 核心设计原则一览
- 双引擎协同:以 Python 脚本完成近千条历史数据的单次“冷启动”全量归档;以 Node.js RSS 引擎实现日常构建时的无感知“增量更新”。
- 海报绝对本地化:从根源解决豆瓣 CDN 的
418 I'm a teapot与403 Forbidden防盗链机制,海报 100% 存放在仓库与本地文件系统。 - 无框架原生前端:零 React/Vue,使用纯粹的原生 Vanilla JS + CSS 变量,带来亚毫秒级加载与极佳的交互质感。
- 全自动化免运维:借助 GitHub Actions 定时触发构建并自动将增量数据写回 Git 仓库,实现无人值守的持续演进。
2. 数据层设计:冷启动全量归档与日常自动化增量
数据获取面临的最大难题是:如何兼顾历史大批量抓取的完整性与日常自动化增量的低成本?
我们采用“冷热分离”策略:冷启动用 Python 脚本单次爬取沉淀为本地基石;日常更新则完全托管给 Node.js 静态构建流水线。
2.1 冷启动:Python 单次全量导出与避坑指南
冷启动需要导出全部历史的星级评分、标记时间、个人短评与高清海报链接。
核心坑点与防御策略
- Cookie 字符串双引号引发
SyntaxError: 豆瓣 Cookie 内部包含dbcl2="83316315:..."等带英文双引号的键值对。在 Python 中直接用普通双引号赋值会导致语法解析崩溃,必须使用三单引号'''...'''包裹。 - 片名爬取陷阱(繁体、原名与英文混杂): 海报
<img>标签的alt属性对于外语片经常存储的是原名(如《星际穿越》存储为 Interstellar,甚至部分为韩文/法文)。正解是解析.info .title a中的文本,并按斜杠/切割,只取第一部分的规范简体中文译名。 - 单 IP 频率控制防 403 封锁: 豆瓣对单 IP 翻页爬取非常敏感。必须保持单线程线性请求,且每页抓取后强制休眠 3 秒(
time.sleep(3))。实测抓取 55 页共 800+ 条记录耗时约 3 分钟,全程零报错。 - 高清海报 URL 规格重写: 列表页默认返回的小图为
/s_ratio_poster/,将其字符串替换为/l_ratio_poster/即可获取未经高压缩的高清海报。
冷启动全量导出脚本 (export_movies.py)
import json
import re
import time
import requests
from bs4 import BeautifulSoup
# ==================== 配置项 ====================
USER_ID = "haiwuhan" # 你的豆瓣 ID
COOKIE = '''bid=xxx; dbcl2="83316315:xxxx"; ck=xxxx;''' # 浏览器网络面板提取的 Cookie
# ===============================================
HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Cookie": COOKIE,
"Referer": "https://movie.douban.com/",
}
def extract_movie_id(url):
match = re.search(r"subject/(\d+)/?", url)
return match.group(1) if match else ""
def get_watched_movies(user_id):
movies = []
start = 0
page = 1
print(f"=== 开始抓取豆瓣用户 [{user_id}] 观影记录 ===")
while True:
url = f"https://movie.douban.com/people/{user_id}/collect?start={start}&sort=time&rating=all&filter=all&mode=grid"
print(f"[*] 正在抓取第 {page} 页 (Offset: {start})...")
try:
resp = requests.get(url, headers=HEADERS, timeout=10)
if resp.status_code == 403:
print("[!] 触发豆瓣 403 拦截,请检查 Cookie 有效性或增大等待间隔。")
break
if resp.status_code != 200:
print(f"[!] 请求异常,HTTP 状态码: {resp.status_code}")
break
soup = BeautifulSoup(resp.text, "html.parser")
items = soup.select(".grid-view .item")
if not items:
print("[✓] 没有更多分页数据,全量抓取完成。")
break
for item in items:
# 1. 规范提取简体中文标题
title_elem = item.select_one(".info .title a")
raw_title = title_elem.text if title_elem else ""
clean_title = " ".join(raw_title.split()).split("/")[0].strip()
link = title_elem["href"] if title_elem else ""
movie_id = extract_movie_id(link)
# 2. 封面图替换为高清大图规格
pic_elem = item.select_one(".pic img")
cover = pic_elem["src"] if pic_elem else ""
if "/s_ratio_poster/" in cover:
cover = cover.replace("/s_ratio_poster/", "/l_ratio_poster/")
# 3. 标记日期 (YYYY-MM-DD)
date_elem = item.select_one(".info .date")
date = date_elem.text.strip() if date_elem else ""
# 4. 星级评分 (1-5 星)
rating = 0
rating_elem = item.select_one('.info [class*="rating"]')
if rating_elem:
for cls in rating_elem.get("class", []):
if cls.startswith("rating") and len(cls) >= 7 and cls[6].isdigit():
rating = int(cls[6])
# 5. 个人短评
comment_elem = item.select_one(".info .comment")
comment = comment_elem.text.strip() if comment_elem else ""
movies.append({
"id": movie_id,
"title": clean_title,
"type": "movie",
"status": "collect",
"rating": rating,
"date": date,
"comment": comment,
"cover": cover,
"link": link
})
print(f"[+] 第 {page} 页解析完毕,已累计归档 {len(movies)} 部作品。")
start += 15
page += 1
time.sleep(3) # 严格限速,确保安全
except Exception as e:
print(f"[!] 抓取出错: {e}")
break
return movies
if __name__ == "__main__":
data = get_watched_movies(USER_ID)
with open("data/movies.json", "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"\n✅ 全部完成!共导出 {len(data)} 条观影数据,保存在 data/movies.json")
2.2 日常增量:Node.js RSS 监听与双轨合并引擎
全量历史导入后,后续日常打卡不可能每次都手动跑 Python 脚本。我们利用豆瓣官方提供的用户公开 RSS 源(https://www.douban.com/feed/people/{USER_ID}/interests),在 Node.js 构建期完成增量同步。
在 core/douban.js 中,核心增量算法如下:
- 多状态与多品类解析: 通过正则
/^(想看|在看|看过|想读|在读|读过)\s+(.*)$/智能解构用户的标记动作,并根据链接域名自动分流到movieMap或bookMap。 - 幂等合并与防覆盖机制: 以豆瓣
subject_id作为唯一主键(Map Key)。如果本地历史已有该记录,只做非空增量字段补全,保留已有的本地海报路径与历史清洗数据,确保数据绝对稳定。 - ⚠️ 豆瓣 RSS 20条滑动窗口机制与同步须知:
- 抓取物理上限:豆瓣官方 RSS 订阅流只对外输出用户最近的 20 条动态。
- 日常打卡(1~5 条/天):只要触发构建,新增内容与状态变更(如将“想看/在看”标记为“看过”)都会 100% 自动增量合并。
- 大批量标记注意:如果单次短时间内密集标记了超过 20 部条目,更早前未构建同步的条目会被新的标记挤出这 20 条窗口。因此在集中批量标记时,建议每标记 10~15 部顺手执行一次构建同步,确保所有记录无缝沉淀进本地大底库。
// core/douban.js 核心 RSS 增量解析逻辑
const titleMatch = rawTitle.match(/^(想看|在看|看过|想读|在读|读过)\s+(.*)$/);
if (titleMatch) {
const action = titleMatch[1];
const title = titleMatch[2].trim();
let type = link.includes('movie.douban.com') ? 'movie' : 'book';
let status = ['想看', '想读'].includes(action) ? 'wish' :
['在看', '在读'].includes(action) ? 'do' : 'collect';
const targetMap = type === 'movie' ? movieMap : bookMap;
const existing = targetMap.get(String(id)) || {};
targetMap.set(String(id), {
id: String(id),
title: title || existing.title,
type,
status,
cover: coverImageUrl || existing.cover,
local_cover: existing.local_cover || '',
rating: ratingScore || existing.rating || 0,
date: createdTime || existing.date || '',
comment: commentText || existing.comment || '',
link: link || existing.link
});
}
2.3 体验补全:全网官方评分与发行年份增量填充
在豆瓣 RSS 订阅流中,“想看”和“在看”的项目由于用户尚未打分,数据源中是没有个人评分和上映年份字段的。如果在海报墙上空着,视觉层级会非常不协调。
增量元数据抓取方案: 针对 status === 'wish' 或 status === 'do' 且未拉取过全网元数据的条目,构建脚本会通过移动端 H5 页面(https://m.douban.com/{type}/subject/{id}/)抓取豆瓣全网评分(转换为 5 星制)及发行年份。
// 平摊抓取开销:每次构建最多补充 10 个,增量沉淀
const MAX_FETCH_PER_BUILD = 10;
let fetchCount = 0;
for (const item of allMedia) {
if (['wish', 'do'].includes(item.status) && !item.year_fetched) {
if (fetchCount >= MAX_FETCH_PER_BUILD) break;
const res = await fetch(`https://m.douban.com/${item.type}/subject/${item.id}/`, {
headers: { 'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_6...)' }
});
if (res.ok) {
const html = await res.text();
// 正则提取官方评分与上映年份
const ratingMatch = html.match(/ratingValue"\s*content="([0-9.]+)"/);
if (ratingMatch) item.rating = parseFloat(ratingMatch[1]) / 2;
const yearMatch = html.match(/class="sub-meta"[^>]*>([\s\S]*?)<\/div>/);
if (yearMatch) item.year = (yearMatch[1].match(/(19|20)\d{2}/) || [])[0];
}
item.year_fetched = true;
fetchCount++;
await new Promise(r => setTimeout(r, 2000 + Math.random() * 1500)); // 随机休眠
}
}
3. 资产与安全防线:彻底解决豆瓣 CDN 防盗链
直接在网页 <img> 标签中引用豆瓣图床链接(img*.doubanio.com)时,浏览器发出的请求会携带博客站点的 Referer,豆瓣 CDN 会直接触发拦截并返回 HTTP 418 I'm a teapot 或 403 Forbidden。
3.1 为什么拒绝第三方代理服务?
部分博主习惯在前端使用免费的图片 CDN 代理(如 wsrv.nl)。这种方案存在明显的架构隐患:
- 单点故障风险:公共反代服务随时可能宕机、限速或被墙;
- 加载延迟增大:每次访客打开页面都需要第三方反代服务器实时回源抓图;
- 违背静态归档初衷:一旦源头图床删除图片,历史资产将永久丢失。
3.2 海报双级本地化缓存与分批并发下载
在 core/douban.js 中,我们设计了海报下载器与本地持久化管线:
// core/douban.js 海报本地化下载核心逻辑
async function downloadPoster(item) {
const id = String(item.id);
const isBook = item.type === 'book';
const cacheDir = isBook ? BOOK_COVERS_CACHE_DIR : POSTERS_CACHE_DIR;
const buildDir = isBook ? BOOK_COVERS_BUILD_DIR : POSTERS_BUILD_DIR;
const localUrl = `${isBook ? '/images/book_covers/' : '/images/posters/'}${id}.jpg`;
const cacheFile = path.join(cacheDir, `${id}.jpg`);
const buildFile = path.join(buildDir, `${id}.jpg`);
// 1. 本地持久化缓存命中:直接复制到构建输出目录,零网络请求
try {
await fs.access(cacheFile);
await fs.copyFile(cacheFile, buildFile);
return localUrl;
} catch (e) {}
// 2. 缓存未命中:携带合法 Referer 请求头下载至本地
if (item.cover && item.cover.startsWith('http')) {
try {
const res = await fetch(item.cover, {
headers: {
'Referer': isBook ? 'https://book.douban.com/' : 'https://movie.douban.com/',
'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)...'
}
});
if (res.ok) {
const buffer = Buffer.from(await res.arrayBuffer());
await fs.writeFile(cacheFile, buffer); // 写入长期存储 (Git 跟踪)
await fs.writeFile(buildFile, buffer); // 写入本次构建目录
return localUrl;
}
} catch (err) {
console.warn(`海报下载失败 (${id}): ${err.message}`);
}
}
return item.local_cover || item.cover;
}
并发控制:在构建执行时,采用 BATCH_SIZE = 5 的批次并发调度,既保证了成百上千张海报同步的高效吞吐,又不会因突发过高并发被豆瓣阻断。
4. UI 质感与交互系统:去框架化的纯粹体验
在前端展现上,我们完全舍弃了任何前端框架,直接用原生 CSS + Vanilla JS 打造极具科技感与设计质感的 Apple 级海报墙。
4.1 布局突破:让画廊在视口中“满屏铺开”
传统博客通常有着严格的正文最大宽度限制(如 max-width: 800px),这会让近千张海报挤在屏幕中央,留白过多且极其压抑。
我们重构了 .movies-container 布局,采用 CSS Grid 的 repeat(auto-fill, minmax(160px, 1fr)) 配合 gap: 16px,让海报墙根据用户的显示器宽度自适应铺满整屏,带来沉浸式的视觉张力。
4.2 Apple 风格悬浮胶囊栏:毛玻璃过滤与滚动智能感知
在页面底部,我们设计了一个常驻悬浮的 Apple Glassmorphism Filter Bar:

- 质感表达:采用
backdrop-filter: saturate(180%) blur(20px),配合浅色下的高透白与暗色模式下的 Alpha 半透明边框(rgba(255, 255, 255, 0.1)),通透且不油腻。 - 状态动态自适应:切换“影视”与“书籍”标签时,右侧状态栏自动动态切换为「看过 / 在看 / 想看」或「读过 / 在读 / 想读」。
- 滚动意图感知:向下滚动时悬浮栏平滑滑出隐藏;一旦向上滚动或停顿,悬浮栏即刻优雅浮现。
/* 毛玻璃悬浮控制器核心样式 */
.glass-filter-bar {
position: fixed;
bottom: 32px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 12px;
padding: 8px 16px;
border-radius: 30px;
background: rgba(255, 255, 255, 0.4);
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.4s ease;
z-index: 100;
}
html.dark-theme .glass-filter-bar {
background: rgba(30, 30, 30, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
}
.glass-filter-bar.hidden {
transform: translateX(-50%) translateY(100px);
opacity: 0;
pointer-events: none;
}
4.3 动态光影:Spotlight Hover 聚光灯边缘光效
为了让卡片在悬浮时拥有如同物理硬件般的高级感,我们在网格容器上挂载了鼠标移动监听,利用 CSS 变量 --mouse-x 与 --mouse-y 动态驱动卡片的径向渐变伪元素。
// 鼠标聚光灯动态追踪
els.grid.addEventListener('mousemove', (e) => {
const card = e.target.closest('.movie-card');
if (!card) return;
const rect = card.getBoundingClientRect();
card.style.setProperty('--mouse-x', `${e.clientX - rect.left}px`);
card.style.setProperty('--mouse-y', `${e.clientY - rect.top}px`);
});
/* 聚光灯径向发光层 */
.movie-card::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid transparent;
pointer-events: none;
background: radial-gradient(
circle at var(--mouse-x, 50%) var(--mouse-y, 50%),
rgba(255, 255, 255, 0.15) 0%,
transparent 280px
);
opacity: 0;
transition: opacity 0.3s ease;
}
.movie-card:hover::after {
opacity: 1;
border-color: rgba(212, 175, 55, 0.6); /* 尊贵金/极光绿边缘流光 */
}
4.4 禅意排版:字体排版双轨制
遵循博客全局的 Typography Isolation(排版双轨制) 设计护栏:
- 标题与说明:使用系统级沉浸式衬线体(
var(--mg-f-serif)),传递东方的文学与电影呼吸感。 - 评分星级、观影时间与版权:强制使用等宽字体(
var(--mg-f-mono))。纯粹的数据感与复古的衬线标题形成强烈的视觉碰撞。
4.5 极速检索与流式加载
- 零延迟全局秒搜:当用户在搜索框输入关键词时,JS 控制器直接在已加载的内存对象中对
title和comment做快速正则过滤,键入即出结果,无任何网络延迟。 - IntersectionObserver 高性能流式渲染: 一次性把 900+ 个 DOM 节点渲染出来会导致低配设备卡顿。我们采用单页 60 项的分页切片,配合
IntersectionObserver监听底部#loadMoreAnchor锚点实现流式增量灌入,兼具极速的首屏解析与无缝的漫游浏览体验。
5. 持续交付与离线可用:GitHub Actions 与 PWA 闭环
完成了本地构建与前端界面后,最后一公里是将全流程固化为无需人工干预的自动化运维体系。
5.1 GitHub Actions 每日自动化调度 (deploy.yml)
我们配置了 GitHub Actions 工作流:
- 定时调度:每天 UTC 00:00(北京时间上午 08:00)自动唤醒;
- 执行构建:运行
npm run build,拉取豆瓣 RSS 最新动态,自动下载新海报并重新渲染 HTML; - 数据自动回源与触发部署:检测
data/目录若有新增的海报或 JSON 变更,由机器人自动 commit 并推送回仓库。此处无需添加[skip ci](因为 GitHub Actions 默认机制本就不会响应GITHUB_TOKEN触发的新流水线,而去除[skip ci]才能保证 Cloudflare Pages / Workers 监听到 Git push 并即时触发全站构建发布)。
# .github/workflows/deploy.yml
name: Auto Sync Douban & Deploy Blog
on:
schedule:
- cron: '0 0 * * *' # 每天北京时间早上 8:00 自动执行
workflow_dispatch: # 支持手动一键触发
push:
branches:
- main
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 配置 Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'npm'
- name: 安装依赖与静态构建
run: |
npm ci
npm run build
- name: 自动提交更新后的豆瓣数据回仓库
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "github-actions[bot]@users.noreply.github.com"
if [ -n "$(git status --porcelain data/)" ]; then
git add data/
git commit -m "chore: auto sync douban media data"
git push
else
echo "无新增数据需要提交"
fi
5.2 PWA Service Worker 的离线容灾策略
为了保障极致的访问速度与无网环境下的可读性,在 service-worker.js 中将静态数据文件划入 Network-First(网络优先,离线降级) 策略:
// theme/public/pwa/service-worker.js
// 动态数据文件优先从网络获取最新记录,离线状态下无缝读取 Cache 历史快照
if (isHtmlPage || url.pathname === '/search.json' || url.pathname.startsWith('/data/')) {
event.respondWith(networkFirst(request));
}
访客每次访问都能瞬间拉取到最新的影视数据;而在断网或飞行模式下,PWA 会自动从 Service Worker 缓存中吐出完整的本地海报墙,实现 100% 可用的高容灾架构。
6. 总结与 Zen 哲学思考
这一套方案折腾下来,整个书影音模块的代码干净得令人愉悦:
- 零外部 API 依赖:不依赖 Notion、NeoDB 或第三方反代中转;
- 零客户端重型框架:没有 React / Vue 虚拟 DOM 的内存开销;
- 全生命周期自治:从数据抓取、海报存储、页面生成到自动化交付,每一行代码都处于绝对掌控之中。
把个人的数字生活印记真正保存在自己的硬盘与 Git 仓库里,随着每一次静默构建慢慢沉淀——这或许就是独立博客最迷人的极客浪漫。