零成本 VIP 视频聚合站:Cloudflare Pages Functions 多源调度架构

这是什么
一个跑在 Cloudflare Pages 免费额度上的 VIP 视频聚合搜索。前端纯静态 HTML,后端是 Cloudflare Pages Functions(Workers 运行时)写的一组 API,并行请求 5 个 MacCMS 协议的第三方资源站,把同名影片的多个播放源合并,搜一次拿到所有可用线路。
没有服务器,没有数据库,也不用运维。Cloudflare Pages 的 Functions 每天送 10 万次调用,个人站用不完。
整体架构
五个设计
1. 熔断器:别让坏源拖死整个请求
5 个上游随时可能挂。每个都傻等到超时(10s),最坏一个搜索请求要等 50 秒。
规则很简单:某个源连续失败 3 次就标记熔断,之后 30 秒跳过它。窗口过了自动半开,放一次试探请求,成功就恢复,失败继续熔断。
// 熔断器状态机const breakerState = new Map(); // sourceId → { failures, tripped, tripUntil }
function isTripped(sourceId) { const state = breakerState.get(sourceId); if (!state) return false; // 还在熔断窗口内?跳过 if (state.tripped && Date.now() < state.tripUntil) return true; // 熔断窗口过了,自动重置,允许试探 if (state.tripped && Date.now() >= state.tripUntil) { state.tripped = false; state.failures = 0; } return false;}5 个源里挂掉 4 个,搜索仍然只花 1 个源的时间(1 秒内),不会被超时拖住。
2. 并行聚合:Promise.all 一次打完所有源
搜索不是挨个轮询,是一次并发出去,在内存里归并:
export async function searchVideos(keyword, page = 1) { // 并行请求 5 个源 const results = await Promise.all( sources.map((s) => fetchFromSource(s, { ac: 'videolist', pg: page, wd: keyword })) );
// 按 vod_name 归并,同一片子的多个源合并 const grouped = new Map(); for (const data of results) { for (const item of data.list) { const key = item.vod_name; if (!grouped.has(key)) { grouped.set(key, { ...item, _playSources: [] }); } // 把播放线路合到一起 grouped.get(key)._playSources.push(...parsePlaySources(item)); } } return [...grouped.values()];}能这么归并是因为 MacCMS 协议的返回字段是统一的,所有资源站都给 vod_name、vod_play_from、vod_play_url 这几个同结构字段,不需要给每个源单独写适配。
3. Cache API:跨请求的边缘缓存
Worker 里没有 Redis,也写不了本地文件。Cloudflare 给了 caches.default,一个跨请求、跨边缘节点共享的 HTTP 语义缓存。
async function getCache(key) { const cache = await caches.default; const res = await cache.match(new Request('https://cache.local/' + key)); if (!res) return null; const data = await res.json(); // 手动 TTL,Cloudflare Cache API 不会主动过期 if (Date.now() > data._expires) return null; return data;}
async function setCache(key, data, ttl = 300) { const cache = await caches.default; await cache.put( new Request('https://cache.local/' + key), new Response(JSON.stringify({ ...data, _expires: Date.now() + ttl * 1000 }), { headers: { 'Cache-Control': `max-age=${ttl}` }, }) );}https://cache.local/ URLCloudflare Cache API 的 key 必须是完整 URL。缓存的是内部数据,不是真实网页响应,所以虚构一个 URL 当 key 容器,不碰真实流量。
热门词(比如”蜘蛛侠”)第一次查完,之后 5 分钟内所有用户命中的都是同一份缓存,响应从几百毫秒掉到接近零。
4. 后端预判 URL 类型
上游返回的播放 URL 有好几种:官方平台页面、m3u8 直链、资源站自带播放器的分享页。前端得知道每种该怎么处理:
export function classifyPlayUrl(url) { // 官方平台 URL → 套第三方解析 API if (/v\.qq\.com|iqiyi\.com|youku\.com/.test(url)) return 'official';
// m3u8 直链 → hls.js 直接播 if (/\.m3u8(\?|$)/i.test(url)) return 'm3u8';
// 资源站分享页(如 U酷云播、金鹰云播) // → 页面自带播放器,直接 iframe 嵌入 if (/\/share\/[a-zA-Z0-9]{4,}/i.test(url)) { if (/ukzy|ukubf/i.test(host)) return 'shareDirect'; return 'share'; // 其他 share 可能需要二次解析 }
return 'unknown';}分类放在后端做。前端拿到的 _playSources 数组里,每条线路已经带好 urlType 标签,直接路由到对应播放器组件,前端不用再写一遍正则。
5. 无数据库唯一 ID:SHA-1 就行
5 个上游的 vod_id 会冲突,不同资源站可能拿同一个数字 ID 表示完全不同的片子。所以要一个全局唯一标识。
不建数据库映射表,直接 SHA-1(影片名 + 年份)取前 20 位十六进制:
export async function generateUniqueId(vodName, vodYear = '') { const input = `${vodName || ''}|${vodYear || ''}`; const buf = await crypto.subtle.digest('SHA-1', new TextEncoder().encode(input)); const hex = [...new Uint8Array(buf)] .slice(0, 10) .map((b) => b.toString(16).padStart(2, '0')) .join(''); return 'av_' + hex;}同一部片永远得到同一个 ID,不同片几乎不可能碰撞(2^80 的空间)。纯函数,不需要数据库、缓存表或 ID 分配服务。
为什么选 Cloudflare Pages Functions
| 维度 | Cloudflare Pages | Node.js + VPS |
|---|---|---|
| 服务器成本 | 0(每天 10 万次免费) | 至少 20 元/月 |
| 运维 | 0(Git push 自动部署) | 进程守护、PM2、日志 |
| 延迟 | <50ms(全球边缘节点直出) | 取决于 VPS 位置 |
| 缓存 | 内置 Cache API(跨边缘共享) | 自己搭 Redis |
| 运行时 | Web Standard API(fetch/AbortSignal) | Node 原生模块 |
| 限制 | CPU 50ms / 请求(免费版) | 无 |
50ms CPU 限制对这个项目够用。计算全是 Promise.all 网络 IO、JSON 解析和数组归并,真正占 CPU 的逻辑是微秒级。瓶颈在上游 API 的响应时间,不在 Worker。
局限和取舍
几个绕不开的短板:
- 上游 API 全挂就是全挂,熔断器只能降级,变不出数据
- CPU 50ms 限制:哪天聚合到 100 个源,JSON 解析可能超
- 没有持久化:Worker 重启后内存里的熔断器状态会丢,好在
caches.default是持久的,重试几次自己就重建了 - MacCMS 协议耦合:加非 MacCMS 的源要写适配层
对照表
| 设计 | 解决什么问题 | 关键代码 |
|---|---|---|
| 熔断器 | 坏源拖慢请求 | breakerState Map + 3 次失败熔断 30s |
| Promise.all 并行 | 聚合延迟最小化 | sources.map → fetch → merge |
| Cache API | 跨请求/跨节点缓存 | caches.default + 手动 _expires TTL |
| URL 分类 | 前端播放路由 | 后端正则预判,返回 urlType 标签 |
| SHA-1 唯一 ID | 无数据库去重 | crypto.subtle.digest 纯函数 |
后端不到 450 行,跑在 Cloudflare 免费额度上,撑得住每天几千次搜索。
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!














