一、引言
豆瓣官方没有公开的「热榜」API,但 https://movie.douban.com/j/search_subjects 这个接口在前端页面里其实是裸着的——它会按 type(movie / tv)+ tag(热门 / TOP250 / 国产剧 / 美剧 ……)+ page_start / page_limit 四个参数,返回一段 JSON,里面有每部影片的 id、title、cover、rate、url 等信息。正常浏览豆瓣页面就能从控制台看到这条请求。
问题在于:直接拿这个 URL 去前端 fetch,有两个硬伤——豆瓣会封 IP(短时间内高频调用会被临时拉黑或要求滑块验证)、响应慢(跨域 + 没有缓存的初次访问要 1~3 秒)。
所以我们要做的,是在服务端做一个带缓存 + 防击穿 + 防封禁的本地中转层:第一次请求打到豆瓣,把响应存进 ThinkPHP 文件缓存;后续 24 小时内所有请求都走缓存,零开销直达。同时整个站只暴露自己的 /api.php/doubanhot 入口,豆瓣源 URL 永远不出现在前端,源站 IP 也不暴露给客户端。
下面这篇文章,就是把我们这套实现完整拆给你看。代码全部沉淀在 Doubanhot.php,一个文件搞定所有热榜相关的接口。
二、效果预览
![图片[1] - 给苹果cms实现一个高可用的豆瓣热榜接口 - 迅维博客网](https://image-1301450403.cos.ap-nanjing.myqcloud.com/2026/07/27/zNPC7MOY.jpg)
三、实现功能
1. 11 个 RESTful 接口,覆盖电影 / 剧集 / 动漫 / 综艺四个大类 8 个细分榜单
主入口 /api.php/doubanhot 接四个参数(type、tag、limit、refresh)支持任意组合;又额外封装了 10 个快捷入口让前端少敲几个字符:
| 快捷接口 | 对应 type+tag | 用途 |
|---|---|---|
GET /api.php/doubanhot | 自定义 | 通用入口,参数自定 |
GET /api.php/doubanhot/movie | movie + 热门 | 电影热门榜 |
GET /api.php/doubanhot/tv | tv + 热门 | 剧集热门榜 |
GET /api.php/doubanhot/anime | tv + 动漫 | 动漫榜 |
GET /api.php/doubanhot/variety | tv + 综艺 | 综艺榜 |
GET /api.php/doubanhot/top10 | 当前 type+tag + limit=10 | 精简版 Top10 |
GET /api.php/doubanhot/top20 | 当前 type+tag + limit=20 | 默认版 Top20 |
GET /api.php/doubanhot/movie_top250 | movie + TOP250 | 电影 TOP250 前 50 |
GET /api.php/doubanhot/chinese_drama | tv + 国产剧 | 国产剧榜 |
GET /api.php/doubanhot/us_drama | tv + 美剧 | 美剧榜 |
GET /api.php/doubanhot/korean_drama | tv + 韩剧 | 韩剧榜 |
GET /api.php/doubanhot/japanese_drama | tv + 日剧 | 日剧榜 |
GET /api.php/doubanhot/categories | – | 元接口,返回所有可用分类 |
GET /api.php/doubanhot/refresh | – | 管理接口,清掉所有缓存 key |
2. 三层防护的缓存机制——TTL + 文件锁 + 降级返回
所有 (type, tag, limit) 组合的结果都进 ThinkPHP 文件缓存,TTL 固定 86400 秒(一天)。缓存失效瞬间并发打过来不会重复击穿豆瓣:第一个进程拿文件锁去抓数据,其余进程阻塞 500ms 等数据落缓存再读一次;要是等完还没拿到,直接返回空数组,让前端走「暂无数据」兜底,而不是排队把豆瓣打死。
3. TOP250 全量特殊处理——分页拉取 + 按评分重排
豆瓣 j/search_subjects 每页最多 50 条。limit=all 时切到 fetchTop250Data(),从 page_start=0 开始一页 50 条地抓,最多抓 50 条(也就是 1 页),每次请求间休眠 100ms 防止过快。抓完后按 rate 倒序重排,再重写 item_rank 字段,确保前端显示的排名就是 TOP250 的真实顺序。
4. 标准化输出结构——前端不用解析豆瓣原始字段
不管豆瓣返回什么字段,我们统一包装,前端不用关心豆瓣哪天改字段,原始整段 JSON 塞进 item_payload 字段一并返回,前端需要的话随时能展开。
5. 强制刷新兜底——refresh=1 走旁路清缓存
单接口支持 ?refresh=1 立即清掉指定 key 的缓存,下次访问会重新拉豆瓣。批量清空走 /api.php/doubanhot/refresh,遍历所有 (type, tag, limit) 组合,逐一 Cache::rm,返回成功清理的 key 数量。
四、完整处理流程
整个系统分四层:浏览器 → 标签页模板 → 本地 API 控制器 → 豆瓣源站。下面从外往里逐层拆解。
4.1 链路全貌
用户浏览器
│ ① GET /label/douban.html
▼
模板 douban.html(前端 JS 渲染)
│ ② $.getJSON('/api.php/doubanhot?type=movie&tag=热门&limit=20')
▼
控制器 Doubanhot::index()
│ ③ 先读 ThinkPHP 文件缓存
│ ├─ 命中 → 直接返回(cached=true)
│ └─ 未命中 → 拿文件锁
│ ├─ 抢到锁 → 调 fetchFromDouban() 抓豆瓣
│ │ └─ 写缓存 + 释放锁 + 返回
│ └─ 没抢到锁 → 等 500ms 再读缓存
│ ├─ 命中 → 返回(waited=true)
│ └─ 仍未命中 → 返回空数组(不重复打豆瓣)
▼
豆瓣源站 https://movie.douban.com/j/search_subjects
│ 返回 JSON { subjects: [...] }
▼
控制器 → normalizeData() → 写缓存 → 响应给前端
│
▼
前端 JS → DOM 渲染卡片列表
整个链路的关键设计点是:只在缓存失效的瞬间才有一次真实的豆瓣请求(带锁,独占),其余所有 PV 走的是本地文件系统里的 ThinkPHP 文件缓存。
4.2 缓存机制详解
Key 设计——douban_hot_{type}_{md5(tag)}_{all或limit}。tag 用 md5 是因为中文 tag 直接拼 key 在 Windows 文件系统下会有兼容问题,md5 后变成纯字母数字,ThinkPHP 文件缓存(按子目录分桶存)完全无障碍。limit=all 单独成 key(…all),避免和 limit=50 冲突。
TTL 86400——CACHE_TTL = 86400,也就是 1 天。Cache::set($cacheKey, $data, self::CACHE_TTL) 直接传时间戳给 ThinkPHP 的 Cache 类,到期自动失效。
文件锁防击穿——缓存在失效那一瞬间会被多进程同时发现为空。如果不做防护,N 个并发请求会同时去拉豆瓣,把源站打爆。我们用 flock(LOCK_EX | LOCK_NB) 非阻塞加锁:拿到锁的进程去抓数据 + 写缓存 + 解锁;没拿到的进程降级为 LOCK_SH 共享锁,等持有者写完缓存再读一次。还等不到就 usleep(500000) 睡 500ms 二次重读,再读不到直接返回空数组——这一步是主动降级,宁可前端显示「暂无数据」也不冒死重复拉豆瓣。
4.3 抓取层(豆瓣源对接)
抓豆瓣只用 file_get_contents + stream_context_create,没有 cURL 依赖。请求参数:
https://movie.douban.com/j/search_subjects
?type=movie # movie | tv
&tag=热门 # 中文 tag,URL 编码后传
&page_limit=20 # 单页条数
&page_start=0 # 偏移
请求头必须带两样:
Referer: https://movie.douban.com/——豆瓣会校验 Referer,空 Referer 直接 403User-Agent: Mozilla/5.0 ... Chrome/120.0——伪装成浏览器,空 UA 也会被拦
超时 10 秒。响应失败(file_get_contents === false)或 JSON 解析失败(缺 subjects 字段)一律返回 [],绝不抛异常到前端。
TOP250 分页——fetchTop250Data() 是 fetchFromDouban() 的特殊版本:循环请求 page_start += 50 直到拿够 totalLimit 条,每次循环 usleep(100000) 休眠 100ms 防止过快拉黑。最后 array_slice 截到目标条数,调 normalizeData 标准化,再按 rate 倒序重排一遍。
4.4 数据标准化
normalizeData() 把豆瓣原始字段映射成统一结构:
[
'provider_code' => 'douban', // 数据源标识
'item_key' => 'douban_{id}', // 全局唯一键
'item_rank' => 1, // 当前榜单里的排名
'item_title' => $title, // 片名
'item_subtitle' => $categoryLabel, // 分类标签「电影」「国产剧」等
'item_snippet' => '电影 · 30集 · 新', // 简介,三段拼
'item_url' => $url, // 豆瓣详情页
'item_cover' => $cover, // 海报 URL(豆瓣防盗链)
'item_score' => 7.5, // 评分(float)
'item_is_new' => true, // 是否新上映
'item_playable' => false, // 是否有在线资源
'item_episodes_info'=> '30集', // 集数信息
'item_payload' => '{...原始JSON...}', // 原始 payload 兜底
]
getCategoryLabel($type, $tag) 把 tag 映射成中文分类词——movie+热门→电影、movie+TOP250→电影、movie+即将上映→电影、tv+国产剧→国产剧、tv+美剧→美剧、tv+韩剧→韩剧、tv+日剧→日剧、tv+动漫→动漫、tv+综艺→综艺、tv+热门→剧集。这个分类词会作为 item_subtitle 和 item_snippet 的一部分出现在前端。
4.5 响应协议
所有接口统一返回 JSON:
{
"code": 200,
"msg": "success",
"data": [ ... 标准化后的数组 ... ],
"cached": true, // true=走缓存 false=实时抓
"waited": false, // true=等待过锁(仅未命中时)
"type": "movie",
"tag": "热门",
"limit": 20,
"total": 20
}
categories 接口比较特殊,返回的是分类元数据(供前端动态生成 tab 用),不是榜单数据:
{
"code": 200,
"data": {
"movie_tags": [{"value":"热门","label":"热门电影","desc":"..."}, ...],
"tv_tags": [{"value":"热门","label":"热门剧集","desc":"..."}, ...],
"anime_tags": [{"value":"动漫","label":"动漫","desc":"..."}],
"variety_tags":[{"value":"综艺","label":"综艺","desc":"..."}],
"limits": [10, 20, 30, 50]
}
}
refresh 接口返回的是 {“code”:200, “cleared_keys”: N},N 是实际清掉的缓存 key 数量。
五、具体实现与操作步骤
整个改造拢共分三步走完:写控制器、接模板、可选地接路由。文件位置和命名都对齐 ThinkPHP 5.x 的标准。
第一步:写控制器 Doubanhot.php
ThinkPHP 5.x 的 API 控制器都放在 application/api/controller/ 目录下,类名要和文件名一致(首字母大写)。
新建文件 application/api/controller/Doubanhot.php,把 class Doubanhot extends Controller 写好。
控制器可以分三段写:常量定义 + 主入口 index() + 快捷方法 + 抓取私有方法。
*关键是常量*,所有支持的 tag / type / TTL 全部抽成 const,后续要加新榜单只需加一行常量 + 一个快捷方法。
第二步:写模板 douban.html
** 文件位置 template/ds3/name/label/douban.html(标签页路由自动映射到这个路径)。
第三步:可选——批量清理入口 /api.php/doubanhot/refresh。
** 控制器里已经写了,遍历所有 (type, tag, limit) 组合调 Cache::rm 即可。如果你只想保留单接口的 ?refresh=1 旁路清理能力,这个批量接口可以不要。
关于豆瓣源站封禁的几个保命技巧(实战中踩过的坑):
Referer必须带https://movie.douban.com/,空 Referer 直接 403User-Agent必须伪装成常见浏览器,不要用 PHP 默认的 PHP/8.x- 不要高频请求。我们的缓存 TTL 是 86400 秒,正常情况下每天只触发一次真实豆瓣请求
- 不要抓太多。我们的 limit 上限是 50,TOP250 全量也是 50 条,对豆瓣压力可控
- 如果还是不放心,可以在控制器前再加一层 nginx limit_req 限流,每秒最多 5 次真实豆瓣请求
六、和系统默认方案 / 第三方插件的差异
对比直接用前端 fetch 调豆瓣源:
- 直接 fetch 会被豆瓣封 IP,且每次访问都跨域 + 1~3 秒延迟。我们这套方案纯本地文件缓存响应,命中后 50ms 以内
- 豆瓣源 URL 直接暴露在前端,等于把 IP 让竞争对手免费爬。我们把豆瓣 URL 锁死在服务端,前端只看到自己的
/api.php/doubanhot - 没有并发防护,N 个用户同时打开页面就是 N 个豆瓣请求。我们有文件锁,N 个并发请求只触发 1 次豆瓣源
对比通用爬虫插件(如某些 cms 论坛里流传的「豆瓣 api 抓取」代码):
- 那些代码普遍是「抓一次就返回一次」,没有缓存层,每个 PV 都打豆瓣
- 没有锁机制,缓存击穿时会瞬时打死源站
- 没有标准化输出层,前端要直接吃豆瓣原始字段,豆瓣哪天改字段前端就崩
- 没有 TOP250 全量能力,TOP250 最多拿前 20 条
对比直接 curl 命令行抓豆瓣:
- curl 是无状态工具,没法做缓存,要么写到文件自己管理过期,要么每次都重新抓
- curl 是命令行工具,没法承接前端 HTTP 请求
- 我们这个控制器本质就是「HTTP 入口 + 文件缓存 + 文件锁 + 抓取函数」四件套的组合,比 curl 命令多了 HTTP 接入和缓存层,比通用爬虫插件少了那些没用的扩展功能
整套实现的核心价值是:用 566 行单一控制器代码,换来「对外暴露自家 API、对内零成本命中、并发安全、TOP250 全量、标准化输出」五件能力,且后续运营只需在文件最上面加 const + 写一个一行跳转方法就能扩展新榜单——维护成本极低。













暂无评论内容