拒绝第三方依赖:用纯静态架构,把 900 条豆瓣记录搬进个人博客
想把豆瓣的观影记录同步到自己的静态博客上展示,折腾了一整圈后发现网上的很多老方案基本都失效了。
记录一下这次抓取豆瓣全量观影记录的实际过程、踩坑点以及拿到的结构化数据。这篇先解决“历史数据导出”的问题,后续博客组件的渲染和部署会接着写。

为什么放弃现成轮子
在动手写脚本之前,把社区里常见的方案都试了一遍,结果基本都不太理想:
| 方案 | 现状与问题 | 结论 |
|---|---|---|
| 豆伴 / 豆坟 | 官方早就不维护了,社区的 MV3 修复版在当前豆瓣风控下极易报错或漏抓 | 放弃 |
| 豆瓣官方数据导出 | 依据个人信息保护法下载的数据包,里面全是账号基础信息,根本没有书影音标记明细 | 没用 |
| NeoDB | 本身是泛文化记录平台,导入依赖现成的数据归档文件,无法直接帮你爬豆瓣 | 只能作下游 |
| 油猴脚本导出 | 浏览器前端注入翻页抓取,数据量大(几百条以上)时极易卡死或断流 | 不太稳定 |
最省事、最可控的方案依然是:**本地写一个单文件 Python 脚本,带上登录态 Cookie 直接抓取分页,导出为 movies.json**。
准备工作:获取 ID 与 Cookie
抓取完整的私有短评、评分与标记时间,必须携带登录态请求头。
1. 参数提取
- 打开电脑浏览器登录豆瓣,进入「个人主页」后按
F12打开开发者工具,切到 Network(网络) 标签。 - 刷新页面,点击第一个请求(通常是你的用户 ID 或
movie.douban.com)。 - 在 Request Headers 中提取所需字段:
| 参数 | 说明 | 示例 |
|---|---|---|
USER_ID | 个人主页链接 /people/ 后面的一串数字或自定义英文 | 83316315 |
COOKIE | 请求头中的完整 cookie 字符串 | bid=...; dbcl2="83316315:xxxx"; ck=...; |

脚本编写与抓取
1. 安装依赖
macOS 系统下默认可能没有配置 pip 软链接,建议直接使用 pip3 安装解析库:
pip3 install requests beautifulsoup4
2. 导出脚本 export_movies.py
在抓取逻辑中做了几处细节优化:
- 海报大图替换:将封面 URL 中的
/s_ratio_poster/替换为/l_ratio_poster/获取高清海报。 - 标题清洗:过滤豆瓣网页 DOM 中多余的换行与空格,规范中英文标题。
- 频率限制:每页抓取后强制休眠 3 秒,避免触发 IP 级别的 403 封禁。
import json
import re
import time
import requests
from bs4 import BeautifulSoup
# ==================== 配置项 ====================
USER_ID = "835"
# 注意:Cookie 字符串内部含有双引号,外层必须使用三引号包裹
COOKIE = '''bid=jRegckXI; dbcl2="83315:gefmOoc"; ck=rAfd; push_noty_num=0; push_doumail_num=0; frodotk_db="d07029fd94221507df8bc6666c6"'''
# ===============================================
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"[!] 请求异常,状态码: {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 ""
title = " ".join(raw_title.split())
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 = None
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. 标签
tags_elem = item.select_one(".info .tags")
tags = tags_elem.text.replace("标签:", "").strip() if tags_elem else ""
# 6. 短评
comment_elem = item.select_one(".info .comment")
comment = comment_elem.text.strip() if comment_elem else ""
movies.append({
"id": movie_id,
"title": title,
"rating": rating,
"date": date,
"comment": comment,
"tags": tags,
"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)
output_file = "movies.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=2)
print(f"\n=== 全部完成!共导出 {len(data)} 条观影数据,保存在 ./{output_file} ===")
3. 执行抓取
终端直接运行:
python3 export_movies.py
实际测试抓取 55 页共 804 条观影记录,全程用时约 3 分钟,无中断无报错。

踩坑与注意点
- **终端报
zsh: command not found: pip**macOS 自带的 Python 环境默认不链接pip别名,改用pip3或python3 -m pip即可。 - **Cookie 字符串双引号引发
SyntaxError**豆瓣 Cookie 里的dbcl2="83316315:..."字段自带英文双引号。在 Python 代码中如果直接用双引号"..."赋值会直接报语法错误,必须使用 Python 的三引号'''...'''包裹。 - 抓取频次与防封 豆瓣对单 IP 访问频率非常敏感。单线程请求 +
time.sleep(3)是兼顾速度与安全性的临界值,千万不要把休眠去掉开并发,否则几十页后必吃 403。 - 海报防盗链(前端展示前置提醒) 抓取到的豆瓣图片链接直接放在博客页面上会被豆瓣图床拦截(返回 403)。后续在前端渲染时必须在
<img>上增加referrerpolicy="no-referrer",或者走免费图片 CDN 代理(如wsrv.nl)。
总结
导出的 movies.json 结构非常规整,单条数据包含了 ID、中英标题、星级、标记时间、个人短评和高清海报链接。
文件体积很小(800+ 条记录约 200KB),后续不需要依赖任何数据库或第三方服务中转,直接丢进静态博客工程的静态数据目录即可完成本地构建。下篇记录怎么在前端把这些数据渲染成海报墙。
渲染思路:为什么不用现成的主题?
数据拿到本地存成 JSON 后,接下来的问题是怎么把这 900 多部影视和书籍展示出来。
目前的静态博客生态里,展示豆瓣记录通常有两种做法:
- 用现成的框架插件(比如 Hexo / Hugo 的各种 Douban 插件),缺点是高度耦合,且 UI 风格基本定死,想改个间距都得翻半天源码。
- 重度依赖前端框架(如 React / Vue 写的 SPA 页面),缺点是太重了。为了展示一个纯静态的书影音列表,引入一大堆构建工具和 Runtime,完全违背了极简静态站点的初衷。
这次我选择彻底的纯静态路线(Zen Architecture):在服务端(或者构建脚本里)直接读取 JSON,配合原生 HTML/CSS 和一点点 Vanilla JS(原生 JavaScript)来做交互。核心诉求只有两个:够纯粹、加载够快。
核心痛点与解决手段
在实际渲染和调试的过程中,遇到了几个影响体验的硬伤,折腾了一番才调教到满意的状态。
1. 突破宽度限制:让瀑布流“满屏铺开”
最开始我套用了博客默认的页面容器(通常有 max-width: 960px 限制),结果 900 多张海报挤在中间,两边留白太多,视觉上非常局促。
解决办法: 干脆打破传统博客居中的执念,移除容器的最大宽度限制。把 movies-container 改为 width: 100%,配合 CSS Grid 的 auto-fill 属性,让海报像画廊一样根据你的屏幕宽度自适应排满。配合极致压缩的卡片内边距(Padding 调整为 10px 10px 8px 10px),整体视觉张力瞬间就出来了。
2. 拒绝无尽滚动:手写纯前端分页
900 多条记录如果一次性通过 DOM 渲染出来,低配电脑的浏览器绝对会卡死;如果是做“无限下拉加载”(Infinite Scroll),又会产生没完没了的滑动焦虑。
解决办法:纯前端分页。 既然数据已经在内存里了,直接用 JS 切片(slice)即可。
| 核心逻辑 | 实现细节 |
|---|---|
| 分页状态 | 维护 currentPage 和 pageSize(我设为了 60)两个变量 |
| 数据切片 | filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize) |
| UI 交互 | 底部放一个极简的 [上一页] 当前页/总页数 [下一页] |
| 体验优化 | 点击“下一页”时,用 window.scrollTo({ top: 0, behavior: 'smooth' }) 自动滑回顶部 |
这样既保证了 DOM 树足够小,又给了浏览者明确的进度预期。
3. 搜索与过滤:砍掉伪需求,实现“零延迟”检索
一开始我在控制面板里塞进了“分类(影/书)”、“状态(看过/想看)”、“星级(1-5星)”以及搜索框。后来发现第一个卡片位置被塞得满满当当,极其臃肿。
取舍与优化:
- 砍掉星级过滤:这是一个典型的“伪需求”,除了自己测试,平时根本不会用到“只看 3 星电影”这种功能。果断删除相关 DOM 和 JS,让控制卡片恢复“透气感”。
- 全局秒搜:搜索框绑定
input事件,只要敲击键盘,立刻忽略当前的状态限制,对 JSON 数据里的title和comment字段进行全局跨类别过滤。因为是纯前端内存运算,没有任何网络延迟,体验真的是“秒出结果”。
4. 审美升级:干掉笨重的“药丸按钮”
默认的分类过滤按钮是带有背景色的圆角药丸(Pill Button),不仅占地方,还有一种廉价的后台管理系统既视感。
我把它重构成了一种更具质感的“纯文本链接”交互:
/* 核心状态样式 */
.media-nav-btn {
background: transparent;
color: var(--mg-t-m);
transition: color 0.2s ease;
}
.media-nav-btn.active {
color: var(--mg-t-p);
}
/* 用 SVG 遮罩实现高级的勾选图标 */
.media-nav-btn.active::after {
content: "";
display: inline-block;
width: 16px;
height: 16px;
background: var(--mg-t-p);
-webkit-mask: url("data:image/svg+xml,...") no-repeat center / contain;
}
现在的效果是:平时只是低调的灰色文字,选中时颜色变深,并在右侧浮现出一个精致的 Checkmark 勾选图标,克制且高级。
意外踩坑:为什么抓出来的片名是英文或繁体?
在页面成功渲染后,我发现了一个非常刺眼的问题:《星际穿越》变成了 Constellation,《龙之家族》变成了 House of the Dragon Season 3,甚至还有韩文。
复盘排查: 回头检查前文提到的爬虫脚本,发现问题出在这行代码: title = img_tag.get('alt', '') 豆瓣网页的逻辑非常奇葩:海报图片 <img> 的 alt 属性,对于很多外语片存放的是原名或港台译名,而不是大家熟悉的简体中文名。
修复方案: 必须去解析列表右侧信息区里的 <em> 标签文本。真正的简体译名藏在这里,并且格式往往是 简体中文名 / 原名。
修改数据提取逻辑如下:
# 正确的片名提取姿势
info_div = item.find('div', class_='info')
if info_div:
title_li = info_div.find('li', class_='title')
if title_li and title_li.find('a'):
raw_text = title_li.find('a').text.strip()
# 用斜杠切割,只取第一部分的简体中文
title = raw_text.split('/')[0].strip()
如果你也遇到了名字中英混杂的问题,可以直接按这个逻辑写一个小脚本把已有的 movies.json 里的标题重洗一遍,不需要重新爬图片和评论。
另外还有一个细节:在前端写搜索或切换分类过滤的逻辑时,一定要在触发回调里加上 currentPage = 1。否则如果你原本在第 5 页,一搜一个冷门电影(结果只有 1 页),页面直接越界白屏。
结语
一番折腾下来,整个书影音页面连一个无用的 CSS 类名都没留。没有任何沉重的第三方依赖库,所有的控制权和数据都 100% 掌握在自己手里。
这种“面向纯静态”的架构虽然在初期需要手搓一些基础逻辑,但换来的是极致的页面加载速度和零维护成本的后续体验。以后只需要偶尔跑一次脚本更新 JSON 文件,这个私人赛博档案馆就能一直稳定运转下去。