
豆瓣上面记录了我的读书和观影数据,但这些数据分散在豆瓣页面中,展示方式也比较固定。之前用的第三方API实现了数据展示的功能,但是现在该API下线了。那么我自己来实现一下吧,把自己的书影音记录同步出来,存放在自己的服务器上,并在个人网站中以统一的方式展示。
需求概览
- 书籍:在读、想读、读过
- 电影:在看、想看、看过
- 每页展示 20 条数据
- 页面滚动到底部后通过 Axios 自动加载下一页
- 文字数据存储在 MySQL
- 封面图片上传到七牛云对象存储
- 对外提供 JSON API,供个人网站或其他客户端调用
数据量目前只有约 200 条,因此不需要复杂的搜索集群或分布式存储。MySQL 加七牛云就够了,而且便于备份、迁移和长期维护。
实现思路
划分为四个部分:
- 从豆瓣获取个人书影音记录
- 将结构化数据保存到 MySQL
- 将封面上传到七牛云
- 通过 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 代理。真正耗时的部分并不是写出一个接口,而是保证每一层使用的是同一套数据、同一版代码和正确的运行环境。
这次实践中最重要的经验有以下几点:
- 状态值必须在抓取、数据库、接口和前端之间保持一致。
- 不要通过二元判断处理三个以上的状态。
- 接口参数缺失时应返回错误,不应悄悄使用默认值。
- Docker 容器里的 `127.0.0.1` 与宿主机不是同一个地址。
- 修改 `.env` 后需要重新创建容器。
- 修改源码后需要重新构建镜像。
- 数据库有数据不代表当前接口连接的就是这套数据库。
- 排查接口时,应依次检查请求参数、容器接口、数据库、代理接口。
- 敏感配置只放在 `.env`,不能写进镜像、文章或代码仓库。
- 具有写入能力的管理接口默认不应暴露到公网。
从最初的个人数据展示需求,到最终完成数据同步、封面存储、分页接口、Axios 展示和 Docker 部署,这个项目已经形成了一套相对完整的个人书影音归档方案。后续即使豆瓣页面结构发生变化,也只需要调整抓取层,不会影响数据库、API 和前端展示的整体架构。
封面图片版权与使用说明
本项目展示的图书、电影封面及相关名称、商标等内容,来自豆瓣条目页面或对应
作品的出版方、发行方及其他权利人。相关内容的著作权、商标权及其他合法权益归原
权利人所有。

