个人书影音档案项目完整实现与踩坑记录

2026年8月16日16:47:32 发表评论 阅读(104)

个人书影音档案项目完整实现与踩坑记录

豆瓣上面记录了我的读书和观影数据,但这些数据分散在豆瓣页面中,展示方式也比较固定。之前用的第三方API实现了数据展示的功能,但是现在该API下线了。那么我自己来实现一下吧,把自己的书影音记录同步出来,存放在自己的服务器上,并在个人网站中以统一的方式展示。

需求概览

  • 书籍:在读、想读、读过
  • 电影:在看、想看、看过
  • 每页展示 20 条数据
  • 页面滚动到底部后通过 Axios 自动加载下一页
  • 文字数据存储在 MySQL
  • 封面图片上传到七牛云对象存储
  • 对外提供 JSON API,供个人网站或其他客户端调用

数据量目前只有约 200 条,因此不需要复杂的搜索集群或分布式存储。MySQL 加七牛云就够了,而且便于备份、迁移和长期维护。

实现思路

划分为四个部分:

  1. 从豆瓣获取个人书影音记录
  2. 将结构化数据保存到 MySQL
  3. 将封面上传到七牛云
  4. 通过 Express API 和前端页面展示数据

整体架构如下:

flowchart LR
    A["豆瓣个人账号"] --> B["Node.js 同步程序"]
    B --> C["MySQL"]
    B --> D["七牛云对象存储"]
    C --> E["Express API"]
    D --> E
    E --> F["Nginx /dbApi 反向代理"]
    F --> G["个人网站"]
    G --> H["Axios 滚动分页"]

项目使用的主要技术:

  • Node.js
  • Express
  • Axios
  • Cheerio
  • MySQL
  • 七牛云 Kodo
  • Docker Compose
  • Nginx

数据分类与状态设计

类型 状态 含义
book do 在读
book wish 想读
book collect 读过
movie do 在看
movie wish 想看
movie collect 看过

豆瓣移动端接口使用的状态名称和项目内部状态并不相同,因此需要进行一次映射:

const API_STATUSES = {
  do: "doing",
  wish: "mark",
  collect: "done"
};

这样,项目内部始终使用统一的 do、wish、collect,只有请求豆瓣时才转换为豆瓣接口需要的值。

同步程序依次处理六个分类:

const CATEGORIES = [
  { mediaType: "book", status: "do", label: "在读" },
  { mediaType: "book", status: "wish", label: "想读" },
  { mediaType: "book", status: "collect", label: "读过" },
  { mediaType: "movie", status: "do", label: "在看" },
  { mediaType: "movie", status: "wish", label: "想看" },
  { mediaType: "movie", status: "collect", label: "看过" }
];

MySQL 数据设计

核心数据存放在 douban_items 表中,主要字段包括:

  • douban_id:豆瓣条目 ID
  • media_type:书籍或电影
  • status:当前状态
  • title:标题
  • creators:作者、导演或演员
  • meta:出版社、上映年份等描述
  • note:个人短评
  • user_rating:个人评分
  • douban_rating:豆瓣评分
  • douban_rating_count:豆瓣评分人数
  • mark_date:标记日期
  • subject_url:豆瓣详情页
  • original_cover_url:豆瓣原始封面
  • cover_key:七牛云文件 Key
  • sort_order:列表顺序
  • is_active:本次同步后是否仍然有效
  • sync_batch:同步批次

该项目通过豆瓣条目 ID 和媒体类型建立唯一约束。再次同步同一个条目时使用 ON DUPLICATE KEY UPDATE 更新,而不是重复插入。

每个分类同步完成后,会把本批次没有出现的旧记录标记为非活动状态:

UPDATE douban_items
SET is_active = 0
WHERE media_type = ?
  AND status = ?
  AND sync_batch <> ?;

API 只返回 is_active = 1 的记录。

封面上传与展示

同步数据时,程序会读取豆瓣返回的封面地址,下载图片并上传到七牛云。数据库不保存图片二进制,只保存七牛云文件 Key。 API 返回数据时,将 Key 转换为可访问的 coverUrl:

{
  "doubanId": "123456",
  "title": "示例电影",
  "status": "collect",
  "subjectUrl": "https://movie.douban.com/subject/123456/",
  "coverUrl": "https://img.example.com/douban/movie/123456.jpg"
}

分页接口与滚动加载

数据接口格式为:

GET /api/items?type=movie&status=collect&page=1

每页固定返回 20 条:

const pageSize = 20;
const offset = (page - 1) * pageSize;

数据库实际查询 21 条:

LIMIT 21 OFFSET ?

如果查询结果超出 20 条,就表示还有下一页:

const hasMore = rows.length > pageSize;
const items = rows.slice(0, pageSize);

响应结构:

{
  "page": 1,
  "pageSize": 20,
  "hasMore": true,
  "items": []
}

页面滚动到底部附近时发起下一页请求。请求期间将加载标记设置为 false,防止滚动事件重复发送多个请求;请求失败后重新开放加载状态,允许用户继续滚动重试。

部署过程中遇到的问题

CentOS 7 无法运行新版本 Node.js

服务器最初通过 PM2 启动项目,日志出现:

ReferenceError: ReadableStream is not defined

原因是服务器 Node.js 版本过旧,而新版本 Express、Cheerio 和 Undici 需要更高版本的 Node.js。

单独降级 undici 不能彻底解决,因为 Express、Cheerio 和 pnpm 也存在运行时版本要求。

最终选择 Docker 部署,让容器运行 Node.js 24,宿主机继续保持 CentOS 7。

Docker 镜像代理返回 502

构建镜像时出现:

dockerproxy.net: 502 Bad Gateway

这是 Docker 镜像代理故障,不是 Dockerfile 错误。

处理方法:

  • 检查 `/etc/docker/daemon.json`
  • 删除失效的 `registry-mirrors`
  • 或改用云厂商提供的专属镜像加速地址
  • 重启 Docker 后先单独拉取基础镜像

Docker 容器无法连接 MySQL

容器最初报错:

connect ECONNREFUSED 127.0.0.1:3306

关键点是:容器中的 127.0.0.1 指容器自身,不是 CentOS 宿主机。

由于 MySQL 安装在宿主机,最终使用:

MYSQL_HOST=host.docker.internal

并在 Compose 中增加:

extra_hosts:
  - "host.docker.internal:host-gateway"

随后又遇到:

Host '172.20.0.2' is not allowed to connect

这说明网络已经连通,但 MySQL 没有授权 Docker 网段。为应用账号增加对应网段权限后,连接恢复正常。

Docker 配置

项目使用的 Dockerfile 核心内容:

FROM node:24-bookworm-slim

WORKDIR /app

ENV NODE_ENV=production

RUN npm install --global pnpm@9.12.3

COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod

COPY --chown=node:node src ./src
COPY --chown=node:node public ./public
COPY --chown=node:node scripts ./scripts
COPY --chown=node:node sql ./sql

USER node

EXPOSE 3006

CMD ["node", "src/server.js"]

Compose 服务:

version: "3.8"

services:
  douban-api:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: douban-api
    restart: unless-stopped
    init: true
    read_only: true
    pids_limit: 128
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    tmpfs:
      - /tmp:size=64m,mode=1777
    env_file:
      - .env
    extra_hosts:
      - "host.docker.internal:host-gateway"
    ports:
      - "127.0.0.1:3006:3006"

服务只映射到宿主机 127.0.0.1,由 Nginx 对外提供访问,避免直接暴露 Node.js 端口。

最终实现的功能和效果

  • 同步豆瓣书籍的在读、想读、读过
  • 同步豆瓣电影的在看、想看、看过
  • 将文字、日期、短评、详情地址保存到 MySQL
  • 将封面上传到七牛云
  • 每页返回 20 条数据
  • 支持 hasMore 判断下一页
  • 页面滚动自动加载
  • 提供六种分类数量统计
  • 支持手动触发同步并查看同步状态
  • 使用 Docker 解决 CentOS 7 Node.js 版本限制
  • 使用 Nginx 统一对外暴露 `/dbApi/`
  • 使用 Docker 自动重启替代 PM2
  • 默认关闭公网同步接口,通过容器命令安全同步

常用运维命令

构建并启动:

docker compose build
docker compose up -d

查看状态:

docker compose ps
docker ps -a

查看日志:

docker compose logs --tail=100 douban-api
docker compose logs -f douban-api

执行同步:

docker compose exec douban-api pnpm sync

初始化数据库:

docker compose exec douban-api pnpm db:init

健康检查:

curl 'http://127.0.0.1:3006/api/health'

更新代码后重新部署:

docker compose build --no-cache
docker compose up -d --force-recreate

项目复盘

这个项目的代码量并不大,但完整链路涉及第三方数据源、数据库、对象存储、前端展示、运行时版本、Docker 网络、MySQL 权限和 Nginx 代理。真正耗时的部分并不是写出一个接口,而是保证每一层使用的是同一套数据、同一版代码和正确的运行环境。

这次实践中最重要的经验有以下几点:

  1. 状态值必须在抓取、数据库、接口和前端之间保持一致。
  2. 不要通过二元判断处理三个以上的状态。
  3. 接口参数缺失时应返回错误,不应悄悄使用默认值。
  4. Docker 容器里的 `127.0.0.1` 与宿主机不是同一个地址。
  5. 修改 `.env` 后需要重新创建容器。
  6. 修改源码后需要重新构建镜像。
  7. 数据库有数据不代表当前接口连接的就是这套数据库。
  8. 排查接口时,应依次检查请求参数、容器接口、数据库、代理接口。
  9. 敏感配置只放在 `.env`,不能写进镜像、文章或代码仓库。
  10. 具有写入能力的管理接口默认不应暴露到公网。

从最初的个人数据展示需求,到最终完成数据同步、封面存储、分页接口、Axios 展示和 Docker 部署,这个项目已经形成了一套相对完整的个人书影音归档方案。后续即使豆瓣页面结构发生变化,也只需要调整抓取层,不会影响数据库、API 和前端展示的整体架构。

封面图片版权与使用说明

本项目展示的图书、电影封面及相关名称、商标等内容,来自豆瓣条目页面或对应
作品的出版方、发行方及其他权利人。相关内容的著作权、商标权及其他合法权益归原
权利人所有。

戳我在线体验

 

weinxin
微信小程序
互联网开发,终身学习者,欢迎您的关注!
舍得

发表评论

不高兴 彩虹 吃瓜 丢翔 乖 滑稽 花心 惊哭 惊讶 挤眼 酷 伤心 帅吗? 礼物 玫瑰 怒 生气 喷 睡觉 太开心 小九九 啊
太阳 吐舌 委屈 笑眼 星星月亮 心碎 咦 阴险 疑问 真棒 偷笑 斜眼笑 震惊 略 哈欠 无奈哭 抠鼻 哼 期待 懒得理你 爱心 蜡烛