feat: 支持netlify一键部署并修复一些bug (#58)

* 修复如果不冷启动一直使用animes缓存中旧数据的问题

* 修复有对接redis的场景下手动解析链接只返回第一次请求的链接的问题

* 手动搜索场景通过获取播放链接title作为集标题显示

* 增加netlify一键部署

* 修改readme
This commit is contained in:
huangxd- authored and GitHub committed 2025-10-18 11:49:51 +08:00
1 parent 58d6292948
commit 534761c7db
4 files changed
+156 -49

No files matched your search

+33 -4
View File
@@ -37,7 +37,7 @@ LogVar 弹幕 API 服务器
- `POST /api/v2/comment/by-url`:通过视频URL直接获取弹幕。
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
- **日志记录**:捕获 `console.log`(info 级别)和 `console.error`(error 级别),JSON 内容格式化输出。
- **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Cloudflare 一键部署和 Docker 一键启动。
- **部署支持**:支持本地运行、Docker 容器化、Vercel 一键部署、Netlify 一键部署、Cloudflare 一键部署和 Docker 一键启动。
- **手动选择记忆**:支持记住之前搜索title时手动选择的anime,并在后续的match自动匹配时优选该anime【实验性】。
- **手动搜索支持输入播放链接获取弹幕**:支持手动搜索的播放器输入爱优腾芒哔播放链接可获取弹幕,如`senplayer`。
@@ -161,6 +161,20 @@ LogVar 弹幕 API 服务器
> hk有可能访问不了360或其他源,可以尝试切其他region,如新加坡等
- vercel在国内被墙,请配合代理使用或绑定自定义域名
## 部署到 Netlify 【推荐】
### 一键部署
点击以下按钮即可将项目快速部署到 Netlify:
<a href="https://app.netlify.com/start/deploy?repository=https://github.com/huangxd-/danmu_api"><img src="https://www.netlify.com/img/deploy/button.svg"></a>
> 默认访问domain:https://{你的部署项目名}.netlify.app
- **设置环境变量**:部署后,在 Netlify 仪表板中:
1. 点击Project configuration。
2. 在“Environment variables”部分点击 “Add a variable” 添加 `TOKEN` 变量,输入你的 API 令牌值。
3. 保存更改并重新部署。
## 部署到 腾讯云 edgeone pages
### 一键部署
@@ -198,7 +212,7 @@ LogVar 弹幕 API 服务器
### 手动部署
创建一个worker,将`danmu_api/worker.js`里的代码直接拷贝到你创建的`worker.js`里,然后点击部署。
> cf部署可能不稳定,推荐用vercel部署。
> cf部署可能不稳定,推荐用vercel/netlify部署。
## API食用指南
支持 forward/senplayer/hills/小幻/yamby/eplayerx/afusekt 等支持弹幕API的播放器。
@@ -239,7 +253,7 @@ LogVar 弹幕 API 服务器
| EPISODE_TITLE_FILTER | 【可选】剧集标题正则过滤,按正则关键字对剧集或综艺的集标题进行过滤,适用于过滤一些预告或综艺非正式集,只支持match自动匹配,默认值如下 |
| BLOCKED_WORDS | 【可选】弹幕屏蔽词列表,默认为空,示例如下 |
| GROUP_MINUTE | 【可选】合并去重分钟数,表示按n分钟分组后对弹幕合并去重,默认为1,最大值为30,0表示不去重 |
| PROXY_URL | 【可选】代理地址,示例: `http://127.0.0.1:7897` ,目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,所以除vercel/cloudflare之外默认不开启bahamut源,开启请先在SOURCE_ORDER环境变量中添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址;vercel/cf中理应都自然能联通,不用填写 |
| PROXY_URL | 【可选】代理地址,示例: `http://127.0.0.1:7897` ,目前只对巴哈姆特生效(注意:如果巴哈姆特请求不通,会拖慢搜索返回速度,所以除vercel/netlify/cloudflare之外默认不开启bahamut源,开启请先在SOURCE_ORDER环境变量中添加`bahamut`)如果你使用docker部署并且访问不了bahamut源,请配置代理地址;vercel/netlify/cf中理应都自然能联通,不用填写 |
| RATE_LIMIT_MAX_REQUESTS | 【可选】限流配置:1分钟内同一IP最大请求次数,默认为`3`,设置为`0`表示不限流 |
| UPSTASH_REDIS_REST_URL | 【可选】update redis url,需配合UPSTASH_REDIS_REST_TOKEN使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
| UPSTASH_REDIS_REST_TOKEN | 【可选】update redis token,需配合UPSTASH_REDIS_REST_URL使用,用于持久化存储,不会因为冷启动而丢失过去的查询信息(在cf/eo/claw上配置后应该能更稳定点,也能解决小幻掉匹配的问题,但会稍微影响请求速度),获取方法请参考:`https://cloud.tencent.cn/developer/article/2424508` |
@@ -275,6 +289,16 @@ LogVar 弹幕 API 服务器
/(全体成员|报到|报道|来啦|签到|刷|打卡|我在|来了|考古|爱了|挖坟|留念|你好|回来|哦哦|重温|复习|重刷|再看|在看|前排|沙发|有人看|板凳|末排|我老婆|我老公|撅了|后排|周目|重看|包养|DVD|同上|同样|我也是|俺也|算我|爱豆|我家爱豆|我家哥哥|加我|三连|币|新人|入坑|补剧|冲了|硬了|看完|舔屏|万人|牛逼|煞笔|傻逼|卧槽|tm|啊这|哇哦)/ # 屏蔽常见互动、报到或口语化弹幕词汇
```
## 采集源及对应平台列表
| 采集源 | 平台列表 |
| ----------- | ----------- |
| 360 | qiyi, bilibili1, imgo, youku, qq |
| vod | qiyi, bilibili1, imgo, youku, qq |
| tencent | tencent |
| renren | renren |
| hanjutv | hanjutv |
| bahamut | bahamut |
## 项目结构
```
danmu_api/
@@ -287,13 +311,18 @@ danmu_api/
│ ├── server.js # 本地node启动脚本
│ ├── worker.js # 主 API 服务器代码
│ ├── worker.test.js # 测试文件
├── netlify/
│ └── functions/
│ └── api.js # netlify 中间处理逻辑
├── node-functions/
│ ├── [[...path]]..js # edgeone pages 所有路由跳转指向index
│ └── index.js # edgeone pages 中间处理逻辑
├── .env.example
├── .gitignore
├── Dockerfile
├── edgeone.json # edgeone pages 配置文件
├── LICENSE
├── netlify.toml # netlify 配置文件
├── package.json
├── README.md
├── vercel.json # vercel 配置文件
@@ -309,7 +338,7 @@ danmu_api/
- cloudflare貌似被哔风控了。
- 如果想更换兜底第三方弹幕服务器,请添加环境变量`OTHER_SERVER`,示例`https://api.danmu.icu`。
- 如果想更换vod站点,请添加环境变量`VOD_SERVERS`,示例`vod@https://www.caiji.cyou,vod2@https://zy.xmm.hk`(支持多个服务器并发查询)。
- 推荐vercel部署,cloudflare/edgeone/claw不稳定,当然最稳定还是自己本地docker部署最佳。
- 推荐vercel/netlify部署,cloudflare/edgeone/claw不稳定,当然最稳定还是自己本地docker部署最佳。
- /api/v2/comment接口默认限流:1分钟内同一IP只能请求3次,可通过环境变量`RATE_LIMIT_MAX_REQUESTS`调整(设置为0表示不限流)。
### 关联项目
+102 -45
View File
@@ -110,7 +110,7 @@ let sourceOrderArr = [];
function resolveSourceOrder(env, deployPlatform) {
// 获取环境变量中的 SOURCE_ORDER 配置
let sourceOrder = DEFAULT_SOURCE_ORDER;
if (deployPlatform === "cloudflare" || deployPlatform === "vercel") {
if (deployPlatform === "cloudflare" || deployPlatform === "vercel" || deployPlatform === "netlify") {
sourceOrder += ",bahamut";
}
@@ -349,9 +349,6 @@ function addAnime(anime) {
return false;
}
// 创建 anime 的副本以避免修改原始对象
const animeCopy = { ...anime, links: [] }; // 初始化 links 为空数组
// 遍历 links,调用 addEpisode,并收集返回的对象
const newLinks = [];
anime.links.forEach(link => {
@@ -365,18 +362,29 @@ function addAnime(anime) {
}
});
// 替换 animeCopy 的 links
animeCopy.links = newLinks;
// 创建新的 anime 副本
const animeCopy = { ...anime, links: newLinks };
// 添加到 animes
// 检查是否已存在相同 animeId 的 anime
const existingAnimeIndex = animes.findIndex(a => a.animeId === anime.animeId);
if (existingAnimeIndex !== -1) {
// 如果存在,先删除旧的
animes.splice(existingAnimeIndex, 1);
log("info", `Removed old anime at index: ${existingAnimeIndex}`);
}
// 将新的添加到数组末尾(最新位置)
animes.push(animeCopy);
log("info", `Added anime: ${JSON.stringify(animeCopy)}`);
log("info", `Added anime to latest position: ${anime.animeId}`);
// 检查是否超过 MAX_ANIMES,超过则删除最早的
if (animes.length > MAX_ANIMES) {
removeEarliestAnime();
}
console.log("animes: ", animes);
return true;
}
@@ -644,6 +652,43 @@ async function httpPost(url, body, options = {}) {
}
}
async function getPageTitle(url) {
try {
// 使用 httpGet 获取网页内容
const response = await httpGet(url, {
headers: {
'User-Agent': 'Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) AppleWebKit/605.1.15'
}
});
// response.data 包含 HTML 内容
const html = response.data;
// 方法1: 使用正则表达式提取 <title> 标签
const titleMatch = html.match(/<title[^>]*>(.*?)<\/title>/i);
if (titleMatch && titleMatch[1]) {
// 解码 HTML 实体(如 &nbsp; &amp; 等)
const title = titleMatch[1]
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.trim();
return title;
}
// 如果没找到 title 标签
return url;
} catch (error) {
log("error", `获取标题失败: ${error.message}`);
return url;
}
}
// =====================
// upstash redis 读写请求 (先简单实现,不加锁)
// =====================
@@ -4013,11 +4058,7 @@ async function handleVodAnimes(animesVod, curAnimes, key) {
};
curAnimes.push(transformedAnime);
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
if (!exists) {
const transformedAnimeCopy = {...transformedAnime, links: links};
addAnime(transformedAnimeCopy);
}
addAnime({...transformedAnime, links: links});
if (animes.length > MAX_ANIMES) removeEarliestAnime();
}
}));
@@ -4081,11 +4122,7 @@ async function handle360Animes(animes360, curAnimes) {
};
curAnimes.push(transformedAnime);
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
if (!exists) {
const transformedAnimeCopy = {...transformedAnime, links: links};
addAnime(transformedAnimeCopy);
}
addAnime({...transformedAnime, links: links});
if (animes.length > MAX_ANIMES) removeEarliestAnime();
}
}));
@@ -4124,11 +4161,7 @@ async function handleRenrenAnimes(animesRenren, queryTitle, curAnimes) {
curAnimes.push(transformedAnime);
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
if (!exists) {
const transformedAnimeCopy = {...transformedAnime, links: links};
addAnime(transformedAnimeCopy);
}
addAnime({...transformedAnime, links: links});
if (animes.length > MAX_ANIMES) removeEarliestAnime();
}
@@ -4177,11 +4210,7 @@ async function handleHanjutvAnimes(animesHanjutv, queryTitle, curAnimes) {
curAnimes.push(transformedAnime);
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
if (!exists) {
const transformedAnimeCopy = {...transformedAnime, links: links};
addAnime(transformedAnimeCopy);
}
addAnime({...transformedAnime, links: links});
if (animes.length > MAX_ANIMES) removeEarliestAnime();
}
@@ -4225,11 +4254,7 @@ async function handleBahamutAnimes(animesBahamut, queryTitle, curAnimes) {
curAnimes.push(transformedAnime);
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
if (!exists) {
const transformedAnimeCopy = {...transformedAnime, links: links};
addAnime(transformedAnimeCopy);
}
addAnime({...transformedAnime, links: links});
if (animes.length > MAX_ANIMES) removeEarliestAnime();
}
@@ -4277,11 +4302,7 @@ async function handleTencentAnimes(animesTencent, queryTitle, curAnimes) {
curAnimes.push(transformedAnime);
const exists = animes.some(existingAnime => existingAnime.animeId.toString() === transformedAnime.animeId.toString());
if (!exists) {
const transformedAnimeCopy = {...transformedAnime, links: links};
addAnime(transformedAnimeCopy);
}
addAnime({...transformedAnime, links: links});
if (animes.length > MAX_ANIMES) removeEarliestAnime();
}
@@ -4314,7 +4335,7 @@ async function searchAnime(url) {
"isFavorited": true
};
let platform = "all";
let platform = "unknown";
if (queryTitle.includes(".qq.com")) {
platform = "qq";
} else if (queryTitle.includes(".iqiyi.com")) {
@@ -4327,19 +4348,22 @@ async function searchAnime(url) {
platform = "bilibili1";
}
const pageTitle = await getPageTitle(queryTitle);
const links = [{
"name": "手动解析链接弹幕",
"url": queryTitle,
"title": `【${platform}】 #${queryTitle}#`
"title": `【${platform}】 #${pageTitle}#`
}];
curAnimes.push(tmpAnime);
const exists = animes.some(existingAnime => existingAnime.animeId === tmpAnime.animeId);
if (!exists) {
const transformedAnimeCopy = {...tmpAnime, links: links};
addAnime(transformedAnimeCopy);
}
addAnime({...tmpAnime, links: links});
if (animes.length > MAX_ANIMES) removeEarliestAnime();
// 如果有新的anime获取到,则更新redis
if (redisValid && curAnimes.length !== 0) {
await updateCaches();
}
return jsonResponse({
errorCode: 0,
success: true,
@@ -5193,6 +5217,39 @@ export async function vercelHandler(req, res) {
res.send(text);
}
// --- Netlify 入口 ---
export async function netlifyHandler(event, context) {
// 获取客户端 IP
const clientIp = event.headers['x-nf-client-connection-ip'] ||
event.headers['x-forwarded-for'] ||
context.ip ||
'unknown';
// 构造标准 Request 对象
const url = event.rawUrl || `https://${event.headers.host}${event.path}`;
const request = new Request(url, {
method: event.httpMethod,
headers: new Headers(event.headers),
body: event.body ? event.body : undefined,
});
// 调用核心处理函数
const response = await handleRequest(request, process.env, "netlify", clientIp);
// 转换为 Netlify 响应格式
const headers = {};
response.headers.forEach((value, key) => {
headers[key] = value;
});
return {
statusCode: response.status,
headers,
body: await response.text(),
};
}
// 为了测试导出 handleRequest
export { handleRequest, searchAnime, searchEpisodes, matchAnime, getBangumi, getComment, getCommentByUrl, fetchTencentVideo, fetchIqiyi,
fetchMangoTV, fetchBilibili, fetchYouku, fetchOtherServer, httpGet, httpPost, hanjutvSearch, getHanjutvEpisodes,
+18
View File
@@ -0,0 +1,18 @@
[build]
command = "mkdir -p netlify/functions"
publish = "."
[functions]
node_bundler = "esbuild"
external_node_modules = []
[[redirects]]
from = "/api/*"
to = "/.netlify/functions/api/:splat"
status = 200
force = true
[[redirects]]
from = "/*"
to = "/.netlify/functions/api"
status = 200
+3
View File
@@ -0,0 +1,3 @@
import { netlifyHandler } from '../../danmu_api/worker.js';
export const handler = netlifyHandler;