TMDB Cloudflare Worker代理
This commit is contained in:
+287
@@ -0,0 +1,287 @@
|
||||
# TMDB Cloudflare Worker Proxy
|
||||
|
||||
一个基于 Cloudflare Worker 的 TMDB API 与图片代理服务。
|
||||
|
||||
支持:
|
||||
|
||||
* TMDB API 代理
|
||||
* TMDB 图片代理
|
||||
* 隐藏 API Key
|
||||
* Cloudflare Edge Cache
|
||||
* KV 二级缓存
|
||||
* 多 API Key 轮换
|
||||
* 自定义域名
|
||||
|
||||
---
|
||||
|
||||
# 文件说明
|
||||
|
||||
项目包含两个版本:
|
||||
|
||||
| 文件 | 说明 | 适合场景 |
|
||||
| -------- | --- | --------------------------- |
|
||||
| index.js | 轻量版 | 个人使用、小规模部署 |
|
||||
| tmdb.js | 生产版 | ATV-Player、影视TV、TvBox 等公开服务 |
|
||||
|
||||
---
|
||||
|
||||
## index.js(轻量版)
|
||||
|
||||
特点:
|
||||
|
||||
* 单文件
|
||||
* 无 KV 依赖
|
||||
* API 代理
|
||||
* 图片代理
|
||||
* Edge Cache
|
||||
* API Key 隐藏
|
||||
|
||||
适合:
|
||||
|
||||
* 自用
|
||||
* 测试
|
||||
* 小规模用户
|
||||
|
||||
部署时直接复制到 Cloudflare Worker 即可。
|
||||
|
||||
---
|
||||
|
||||
## tmdb.js(生产版)
|
||||
|
||||
特点:
|
||||
|
||||
* API 代理
|
||||
* 图片代理
|
||||
* API Key 隐藏
|
||||
* Cloudflare Edge Cache
|
||||
* KV 二级缓存
|
||||
* 多 API Key 自动轮换
|
||||
* IP 限流
|
||||
* CORS
|
||||
* 热门接口缓存
|
||||
|
||||
适合:
|
||||
|
||||
* ATV-Player
|
||||
* TvBox
|
||||
* 影视TV
|
||||
* 公共 API 服务
|
||||
|
||||
推荐使用 Wrangler 部署。
|
||||
|
||||
---
|
||||
|
||||
# 获取 TMDB API Key
|
||||
|
||||
注册:
|
||||
|
||||
https://www.themoviedb.org/
|
||||
|
||||
创建 API Key:
|
||||
|
||||
https://www.themoviedb.org/settings/api
|
||||
|
||||
---
|
||||
|
||||
# 部署方式
|
||||
|
||||
## 方案一:Dashboard(推荐新手)
|
||||
|
||||
### 创建 Worker
|
||||
|
||||
Cloudflare Dashboard
|
||||
|
||||
Workers & Pages
|
||||
|
||||
Create Worker
|
||||
|
||||
---
|
||||
|
||||
### 使用轻量版
|
||||
|
||||
复制:
|
||||
|
||||
```text
|
||||
index.js
|
||||
```
|
||||
|
||||
内容到 Worker。
|
||||
|
||||
---
|
||||
|
||||
### 添加环境变量
|
||||
|
||||
Settings
|
||||
|
||||
Variables
|
||||
|
||||
新增:
|
||||
|
||||
```text
|
||||
TMDB_KEYS
|
||||
```
|
||||
|
||||
值:
|
||||
|
||||
```text
|
||||
your_tmdb_api_key
|
||||
```
|
||||
|
||||
部署即可。
|
||||
|
||||
---
|
||||
|
||||
## 方案二:Wrangler(推荐生产环境)
|
||||
|
||||
### 安装
|
||||
|
||||
```bash
|
||||
npm install -g wrangler
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 登录
|
||||
|
||||
```bash
|
||||
wrangler login
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 创建 KV
|
||||
|
||||
```bash
|
||||
wrangler kv namespace create TMDB_CACHE
|
||||
```
|
||||
|
||||
记录返回的 Namespace ID。
|
||||
|
||||
---
|
||||
|
||||
### wrangler.toml
|
||||
|
||||
```toml
|
||||
name = "tmdb-proxy"
|
||||
main = "tmdb.js"
|
||||
compatibility_date = "2026-06-06"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "TMDB_CACHE"
|
||||
id = "YOUR_KV_NAMESPACE_ID"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 配置 API Key
|
||||
|
||||
```bash
|
||||
wrangler secret put TMDB_KEYS
|
||||
```
|
||||
|
||||
输入:
|
||||
|
||||
```text
|
||||
key1,key2,key3
|
||||
```
|
||||
|
||||
支持多个 Key。
|
||||
|
||||
---
|
||||
|
||||
### 本地运行
|
||||
|
||||
```bash
|
||||
wrangler dev
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 部署
|
||||
|
||||
```bash
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# API 示例
|
||||
|
||||
请求:
|
||||
|
||||
```text
|
||||
https://tmdb.example.com/3/movie/550
|
||||
```
|
||||
|
||||
实际访问:
|
||||
|
||||
```text
|
||||
https://api.themoviedb.org/3/movie/550
|
||||
```
|
||||
|
||||
并自动附加 API Key。
|
||||
|
||||
---
|
||||
|
||||
# 图片示例
|
||||
|
||||
请求:
|
||||
|
||||
```text
|
||||
https://tmdb.example.com/t/p/w500/abc.jpg
|
||||
```
|
||||
|
||||
实际访问:
|
||||
|
||||
```text
|
||||
https://image.tmdb.org/t/p/w500/abc.jpg
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# ATV-Player 配置
|
||||
|
||||
```text
|
||||
TMDB API URL:
|
||||
https://tmdb.example.com
|
||||
|
||||
TMDB Image URL:
|
||||
https://tmdb.example.com
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# 缓存策略
|
||||
|
||||
轻量版:
|
||||
|
||||
| 类型 | 缓存 |
|
||||
| --- | --- |
|
||||
| API | 1小时 |
|
||||
| 图片 | 30天 |
|
||||
|
||||
生产版:
|
||||
|
||||
| 类型 | 缓存 |
|
||||
| ---------- | ---- |
|
||||
| Edge Cache | 1小时 |
|
||||
| KV Cache | 24小时 |
|
||||
| 图片 | 30天 |
|
||||
|
||||
---
|
||||
|
||||
# 推荐选择
|
||||
|
||||
个人使用:
|
||||
|
||||
```text
|
||||
index.js
|
||||
```
|
||||
|
||||
公开服务:
|
||||
|
||||
```text
|
||||
tmdb.js
|
||||
```
|
||||
|
||||
如果预计用户超过 1000 人,推荐直接使用生产版。
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// 只允许 GET / HEAD 缓存
|
||||
const cacheable =
|
||||
request.method === "GET" ||
|
||||
request.method === "HEAD";
|
||||
|
||||
const cache = caches.default;
|
||||
|
||||
if (cacheable) {
|
||||
const cached = await cache.match(request);
|
||||
if (cached) {
|
||||
const headers = new Headers(cached.headers);
|
||||
headers.set("X-TMDB-Cache", "HIT");
|
||||
return new Response(cached.body, {
|
||||
status: cached.status,
|
||||
headers
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let targetUrl;
|
||||
let cacheTTL;
|
||||
|
||||
// ==========================
|
||||
// API
|
||||
// https://tmdb.xxx.com/3/movie/550
|
||||
// ==========================
|
||||
if (
|
||||
url.pathname.startsWith("/3/") ||
|
||||
url.pathname.startsWith("/4/")
|
||||
) {
|
||||
targetUrl =
|
||||
"https://api.themoviedb.org" +
|
||||
url.pathname +
|
||||
url.search;
|
||||
|
||||
cacheTTL = 3600;
|
||||
}
|
||||
|
||||
// ==========================
|
||||
// 图片
|
||||
// https://tmdb.xxx.com/t/p/w500/xxx.jpg
|
||||
// ==========================
|
||||
else if (
|
||||
url.pathname.startsWith("/t/p/")
|
||||
) {
|
||||
targetUrl =
|
||||
"https://image.tmdb.org" +
|
||||
url.pathname;
|
||||
|
||||
cacheTTL = 2592000; // 30天
|
||||
}
|
||||
|
||||
else {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
service: "TMDB Proxy",
|
||||
api: "/3/*",
|
||||
image: "/t/p/*"
|
||||
}),
|
||||
{
|
||||
headers: {
|
||||
"content-type": "application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const upstream = await fetch(targetUrl, {
|
||||
method: request.method,
|
||||
headers: request.headers
|
||||
});
|
||||
|
||||
const headers = new Headers(upstream.headers);
|
||||
|
||||
headers.set(
|
||||
"Cache-Control",
|
||||
`public, s-maxage=${cacheTTL}`
|
||||
);
|
||||
|
||||
headers.set(
|
||||
"Access-Control-Allow-Origin",
|
||||
"*"
|
||||
);
|
||||
|
||||
headers.set(
|
||||
"X-TMDB-Cache",
|
||||
"MISS"
|
||||
);
|
||||
|
||||
const response = new Response(
|
||||
upstream.body,
|
||||
{
|
||||
status: upstream.status,
|
||||
statusText: upstream.statusText,
|
||||
headers
|
||||
}
|
||||
);
|
||||
|
||||
if (
|
||||
cacheable &&
|
||||
upstream.ok
|
||||
) {
|
||||
ctx.waitUntil(
|
||||
cache.put(
|
||||
request,
|
||||
response.clone()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
+378
@@ -0,0 +1,378 @@
|
||||
const API_CACHE_TTL = 3600; // 1小时
|
||||
const IMAGE_CACHE_TTL = 2592000; // 30天
|
||||
const KV_CACHE_TTL = 86400; // 1天
|
||||
|
||||
const RATE_LIMIT = 300; // 每分钟
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
try {
|
||||
return await handle(request, env, ctx);
|
||||
} catch (e) {
|
||||
return json(
|
||||
{
|
||||
error: e.message,
|
||||
},
|
||||
500
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
async function handle(request, env, ctx) {
|
||||
const url = new URL(request.url);
|
||||
|
||||
// -------------------
|
||||
// CORS
|
||||
// -------------------
|
||||
|
||||
if (request.method === "OPTIONS") {
|
||||
return new Response(null, {
|
||||
headers: corsHeaders()
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------
|
||||
// Rate Limit
|
||||
// -------------------
|
||||
|
||||
const ip =
|
||||
request.headers.get("CF-Connecting-IP") ||
|
||||
"unknown";
|
||||
|
||||
const rlKey =
|
||||
`rl:${ip}:${Math.floor(Date.now()/60000)}`;
|
||||
|
||||
if (env.TMDB_CACHE) {
|
||||
const count =
|
||||
parseInt(
|
||||
await env.TMDB_CACHE.get(rlKey) || "0"
|
||||
);
|
||||
|
||||
if (count > RATE_LIMIT) {
|
||||
return json(
|
||||
{
|
||||
error: "rate limit exceeded"
|
||||
},
|
||||
429
|
||||
);
|
||||
}
|
||||
|
||||
ctx.waitUntil(
|
||||
env.TMDB_CACHE.put(
|
||||
rlKey,
|
||||
String(count + 1),
|
||||
{
|
||||
expirationTtl: 120
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------
|
||||
// Routing
|
||||
// -------------------
|
||||
|
||||
if (
|
||||
url.pathname.startsWith("/t/p/")
|
||||
) {
|
||||
return imageProxy(
|
||||
request,
|
||||
url,
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
return apiProxy(
|
||||
request,
|
||||
url,
|
||||
env,
|
||||
ctx
|
||||
);
|
||||
}
|
||||
|
||||
async function apiProxy(
|
||||
request,
|
||||
url,
|
||||
env,
|
||||
ctx
|
||||
) {
|
||||
|
||||
const cache = caches.default;
|
||||
|
||||
const cacheKey =
|
||||
new Request(
|
||||
request.url,
|
||||
request
|
||||
);
|
||||
|
||||
let cached =
|
||||
await cache.match(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return addCacheHeader(
|
||||
cached,
|
||||
"EDGE-HIT"
|
||||
);
|
||||
}
|
||||
|
||||
const kvKey =
|
||||
`api:${url.pathname}${url.search}`;
|
||||
|
||||
if (env.TMDB_CACHE) {
|
||||
const kvData =
|
||||
await env.TMDB_CACHE.get(
|
||||
kvKey
|
||||
);
|
||||
|
||||
if (kvData) {
|
||||
return new Response(
|
||||
kvData,
|
||||
{
|
||||
headers: {
|
||||
"content-type":
|
||||
"application/json",
|
||||
...corsHeaders(),
|
||||
"X-TMDB-Cache":
|
||||
"KV-HIT"
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const keys =
|
||||
(env.TMDB_KEYS || "")
|
||||
.split(",")
|
||||
.filter(Boolean);
|
||||
|
||||
if (!keys.length) {
|
||||
throw new Error(
|
||||
"TMDB_KEYS missing"
|
||||
);
|
||||
}
|
||||
|
||||
const key =
|
||||
keys[
|
||||
Math.floor(
|
||||
Date.now()/60000
|
||||
) % keys.length
|
||||
];
|
||||
|
||||
const upstreamUrl =
|
||||
new URL(
|
||||
"https://api.themoviedb.org" +
|
||||
url.pathname
|
||||
);
|
||||
|
||||
url.searchParams.forEach(
|
||||
(v, k) =>
|
||||
upstreamUrl.searchParams.set(
|
||||
k,
|
||||
v
|
||||
)
|
||||
);
|
||||
|
||||
upstreamUrl.searchParams.set(
|
||||
"api_key",
|
||||
key
|
||||
);
|
||||
|
||||
const upstream =
|
||||
await fetch(
|
||||
upstreamUrl.toString(),
|
||||
{
|
||||
headers: {
|
||||
accept:
|
||||
"application/json"
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
const text =
|
||||
await upstream.text();
|
||||
|
||||
const headers =
|
||||
new Headers();
|
||||
|
||||
headers.set(
|
||||
"content-type",
|
||||
"application/json"
|
||||
);
|
||||
|
||||
headers.set(
|
||||
"cache-control",
|
||||
`public,max-age=${API_CACHE_TTL}`
|
||||
);
|
||||
|
||||
Object.entries(
|
||||
corsHeaders()
|
||||
).forEach(
|
||||
([k, v]) =>
|
||||
headers.set(k, v)
|
||||
);
|
||||
|
||||
headers.set(
|
||||
"X-TMDB-Cache",
|
||||
"MISS"
|
||||
);
|
||||
|
||||
const response =
|
||||
new Response(
|
||||
text,
|
||||
{
|
||||
status:
|
||||
upstream.status,
|
||||
headers
|
||||
}
|
||||
);
|
||||
|
||||
if (upstream.ok) {
|
||||
|
||||
ctx.waitUntil(
|
||||
cache.put(
|
||||
cacheKey,
|
||||
response.clone()
|
||||
)
|
||||
);
|
||||
|
||||
if (env.TMDB_CACHE) {
|
||||
ctx.waitUntil(
|
||||
env.TMDB_CACHE.put(
|
||||
kvKey,
|
||||
text,
|
||||
{
|
||||
expirationTtl:
|
||||
KV_CACHE_TTL
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async function imageProxy(
|
||||
request,
|
||||
url,
|
||||
ctx
|
||||
) {
|
||||
|
||||
const cache =
|
||||
caches.default;
|
||||
|
||||
const cached =
|
||||
await cache.match(
|
||||
request
|
||||
);
|
||||
|
||||
if (cached) {
|
||||
return addCacheHeader(
|
||||
cached,
|
||||
"EDGE-HIT"
|
||||
);
|
||||
}
|
||||
|
||||
const upstream =
|
||||
await fetch(
|
||||
"https://image.tmdb.org" +
|
||||
url.pathname,
|
||||
{
|
||||
headers: request.headers
|
||||
}
|
||||
);
|
||||
|
||||
const headers =
|
||||
new Headers(
|
||||
upstream.headers
|
||||
);
|
||||
|
||||
headers.set(
|
||||
"cache-control",
|
||||
`public,max-age=${IMAGE_CACHE_TTL}`
|
||||
);
|
||||
|
||||
headers.set(
|
||||
"Access-Control-Allow-Origin",
|
||||
"*"
|
||||
);
|
||||
|
||||
headers.set(
|
||||
"X-TMDB-Cache",
|
||||
"MISS"
|
||||
);
|
||||
|
||||
const response =
|
||||
new Response(
|
||||
upstream.body,
|
||||
{
|
||||
status:
|
||||
upstream.status,
|
||||
headers
|
||||
}
|
||||
);
|
||||
|
||||
if (upstream.ok) {
|
||||
ctx.waitUntil(
|
||||
cache.put(
|
||||
request,
|
||||
response.clone()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
function addCacheHeader(
|
||||
response,
|
||||
value
|
||||
) {
|
||||
const headers =
|
||||
new Headers(
|
||||
response.headers
|
||||
);
|
||||
|
||||
headers.set(
|
||||
"X-TMDB-Cache",
|
||||
value
|
||||
);
|
||||
|
||||
return new Response(
|
||||
response.body,
|
||||
{
|
||||
status:
|
||||
response.status,
|
||||
headers
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function corsHeaders() {
|
||||
return {
|
||||
"Access-Control-Allow-Origin":
|
||||
"*",
|
||||
"Access-Control-Allow-Methods":
|
||||
"GET,HEAD,OPTIONS",
|
||||
"Access-Control-Allow-Headers":
|
||||
"*"
|
||||
};
|
||||
}
|
||||
|
||||
function json(
|
||||
data,
|
||||
status = 200
|
||||
) {
|
||||
return new Response(
|
||||
JSON.stringify(data),
|
||||
{
|
||||
status,
|
||||
headers: {
|
||||
"content-type":
|
||||
"application/json",
|
||||
...corsHeaders()
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
name = "tmdb-proxy"
|
||||
|
||||
[[kv_namespaces]]
|
||||
binding = "TMDB_CACHE"
|
||||
id = "YOUR_KV_ID"
|
||||
|
||||
[vars]
|
||||
TMDB_KEYS = "key1,key2,key3"
|
||||
Reference in New Issue
Block a user