给苹果cms实现一个高可用的豆瓣热榜接口

机器人
摘要
Mxchild
生成中...

一、引言

豆瓣官方没有公开的「热榜」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实现一个高可用的豆瓣热榜接口 - 迅维博客网

三、实现功能

1. 11 个 RESTful 接口,覆盖电影 / 剧集 / 动漫 / 综艺四个大类 8 个细分榜单

主入口 /api.php/doubanhot 接四个参数(type、tag、limit、refresh)支持任意组合;又额外封装了 10 个快捷入口让前端少敲几个字符:

快捷接口对应 type+tag用途
GET /api.php/doubanhot自定义通用入口,参数自定
GET /api.php/doubanhot/moviemovie + 热门电影热门榜
GET /api.php/doubanhot/tvtv + 热门剧集热门榜
GET /api.php/doubanhot/animetv + 动漫动漫榜
GET /api.php/doubanhot/varietytv + 综艺综艺榜
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_top250movie + TOP250电影 TOP250 前 50
GET /api.php/doubanhot/chinese_dramatv + 国产剧国产剧榜
GET /api.php/doubanhot/us_dramatv + 美剧美剧榜
GET /api.php/doubanhot/korean_dramatv + 韩剧韩剧榜
GET /api.php/doubanhot/japanese_dramatv + 日剧日剧榜
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 直接 403
  • User-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_subtitleitem_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 直接 403
  • User-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 + 写一个一行跳转方法就能扩展新榜单——维护成本极低。

代码下载

© 版权声明
THE END
喜欢就支持一下吧
点赞12 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容