拒绝第三方依赖:用纯静态架构,把 900 条豆瓣记录搬进个人博客

想把豆瓣的观影记录同步到自己的静态博客上展示,折腾了一整圈后发现网上的很多老方案基本都失效了。

记录一下这次抓取豆瓣全量观影记录的实际过程、踩坑点以及拿到的结构化数据。这篇先解决“历史数据导出”的问题,后续博客组件的渲染和部署会接着写。

截屏2026-08-20 15.59.32

为什么放弃现成轮子

在动手写脚本之前,把社区里常见的方案都试了一遍,结果基本都不太理想:

方案现状与问题结论
豆伴 / 豆坟官方早就不维护了,社区的 MV3 修复版在当前豆瓣风控下极易报错或漏抓放弃
豆瓣官方数据导出依据个人信息保护法下载的数据包,里面全是账号基础信息,根本没有书影音标记明细没用
NeoDB本身是泛文化记录平台,导入依赖现成的数据归档文件,无法直接帮你爬豆瓣只能作下游
油猴脚本导出浏览器前端注入翻页抓取,数据量大(几百条以上)时极易卡死或断流不太稳定

最省事、最可控的方案依然是:**本地写一个单文件 Python 脚本,带上登录态 Cookie 直接抓取分页,导出为 movies.json**


抓取完整的私有短评、评分与标记时间,必须携带登录态请求头。

1. 参数提取

  • 打开电脑浏览器登录豆瓣,进入「个人主页」后按 F12 打开开发者工具,切到 Network(网络) 标签。
  • 刷新页面,点击第一个请求(通常是你的用户 ID 或 movie.douban.com)。
  • Request Headers 中提取所需字段:
参数说明示例
USER_ID个人主页链接 /people/ 后面的一串数字或自定义英文83316315
COOKIE请求头中的完整 cookie 字符串bid=...; dbcl2="83316315:xxxx"; ck=...;
截屏2026-08-19 11.33.30

脚本编写与抓取

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 分钟,无中断无报错。

截屏2026-08-19 12.43.50

踩坑与注意点

  1. **终端报 zsh: command not found: pip** macOS 自带的 Python 环境默认不链接 pip 别名,改用 pip3python3 -m pip 即可。
  2. **Cookie 字符串双引号引发 SyntaxError** 豆瓣 Cookie 里的 dbcl2="83316315:..." 字段自带英文双引号。在 Python 代码中如果直接用双引号 "..." 赋值会直接报语法错误,必须使用 Python 的三引号 '''...''' 包裹。
  3. 抓取频次与防封 豆瓣对单 IP 访问频率非常敏感。单线程请求 + time.sleep(3) 是兼顾速度与安全性的临界值,千万不要把休眠去掉开并发,否则几十页后必吃 403。
  4. 海报防盗链(前端展示前置提醒) 抓取到的豆瓣图片链接直接放在博客页面上会被豆瓣图床拦截(返回 403)。后续在前端渲染时必须在 <img> 上增加 referrerpolicy="no-referrer",或者走免费图片 CDN 代理(如 wsrv.nl)。

总结

导出的 movies.json 结构非常规整,单条数据包含了 ID、中英标题、星级、标记时间、个人短评和高清海报链接。

文件体积很小(800+ 条记录约 200KB),后续不需要依赖任何数据库或第三方服务中转,直接丢进静态博客工程的静态数据目录即可完成本地构建。下篇记录怎么在前端把这些数据渲染成海报墙。

渲染思路:为什么不用现成的主题?

数据拿到本地存成 JSON 后,接下来的问题是怎么把这 900 多部影视和书籍展示出来。

目前的静态博客生态里,展示豆瓣记录通常有两种做法:

  1. 用现成的框架插件(比如 Hexo / Hugo 的各种 Douban 插件),缺点是高度耦合,且 UI 风格基本定死,想改个间距都得翻半天源码。
  2. 重度依赖前端框架(如 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)即可。

核心逻辑实现细节
分页状态维护 currentPagepageSize(我设为了 60)两个变量
数据切片filtered.slice((currentPage - 1) * pageSize, currentPage * pageSize)
UI 交互底部放一个极简的 [上一页] 当前页/总页数 [下一页]
体验优化点击“下一页”时,用 window.scrollTo({ top: 0, behavior: 'smooth' }) 自动滑回顶部

这样既保证了 DOM 树足够小,又给了浏览者明确的进度预期。

3. 搜索与过滤:砍掉伪需求,实现“零延迟”检索

一开始我在控制面板里塞进了“分类(影/书)”、“状态(看过/想看)”、“星级(1-5星)”以及搜索框。后来发现第一个卡片位置被塞得满满当当,极其臃肿。

取舍与优化

  • 砍掉星级过滤:这是一个典型的“伪需求”,除了自己测试,平时根本不会用到“只看 3 星电影”这种功能。果断删除相关 DOM 和 JS,让控制卡片恢复“透气感”。
  • 全局秒搜:搜索框绑定 input 事件,只要敲击键盘,立刻忽略当前的状态限制,对 JSON 数据里的 titlecomment 字段进行全局跨类别过滤。因为是纯前端内存运算,没有任何网络延迟,体验真的是“秒出结果”。

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 文件,这个私人赛博档案馆就能一直稳定运转下去。