Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ LogVar 弹幕 API 服务器
- `GET /api/logs`:获取最近的日志(最多 500 行,格式为 `[时间戳] 级别: 消息`)。
- `GET /api/cache/animes`:获取最近的 animes 缓存。
- `POST /api/v2/favorite/add`:新增收藏。手动匹配测试使用 `{ "keyword": "火影忍者" }` 保存搜索关键词及整组搜索结果;同时兼容 `{ "fileName": "火影忍者 S01E01" }`。
- `GET /api/v2/favorite/list`:获取收藏摘要列表,包含收藏关键词、来源、总集数、首条搜索结果图片、收藏时间及最近刷新时间。
- `GET /api/v2/favorite/list`:获取收藏摘要列表,包含收藏关键词、来源、总集数、首条搜索结果图片、收藏时间及最近刷新时间;响应中的 `favoriteSupported` 表示当前部署是否具备持久化收藏能力
- `POST /api/v2/favorite/refresh`:使用 `{ "keyword": "火影忍者" }` 强制重新搜索并更新收藏缓存。
- `POST /api/v2/favorite/schedule`:设置或关闭收藏的定时刷新(仅 Node/Docker 部署可用)。设置使用 `{ "keyword": "火影忍者", "schedule": { "frequency": "daily", "time": "03:00" } }`;每周模式需额外传 `"weekday": 1-7`(周一至周日),例如 `{ "frequency": "weekly", "time": "03:00", "weekday": 1 }`。关闭使用 `{ "keyword": "火影忍者", "schedule": null }`。固定按北京时间(`Asia/Shanghai`)执行,serverless 平台返回 `501`。
- `POST /api/v2/favorite/remove`:使用 `{ "keyword": "火影忍者" }` 删除收藏及对应搜索缓存。
Expand All @@ -84,7 +84,7 @@ LogVar 弹幕 API 服务器
- 支持定时刷新收藏:在“收藏”标签页点击“定时刷新”按钮,选择每天或每周(1-7 对应周一至周日)与执行时间,固定按北京时间(`Asia/Shanghai`)运行;已配置的条目按钮会显示类似“每天 03:00”“周一 03:00”,条目下方显示下次执行时间和最近状态。
- 定时刷新失败会保留旧缓存并在 10 分钟后自动重试一次,仍失败则等待下一个正常周期,不再继续重试;服务停机错过执行时间时,重启后只补执行一次并重新计算下一周期。
- 定时刷新计划随收藏一起保存在 `.cache/favoritesCache` 或 Redis 中,Node/Docker 重启后如需保留请挂载 `.cache` 目录或配置 Upstash Redis;纯内存收藏及计划会随进程重启丢失。Vercel、Cloudflare、Netlify、EdgeOne、Hugging Face 等 serverless 平台不启动调度器,按钮会禁用并提示“仅支持 Node/Docker 部署”。
- Node/Docker 部署会写入 `.cache/favoritesCache` 永久保存,请挂载 `.cache` 目录;serverless 未配置 Redis 时仅保存在当前实例内存中,配置 Redis 后可跨冷启动和实例恢复。
- Node/Docker 部署会写入 `.cache/favoritesCache` 永久保存,请挂载 `.cache` 目录;serverless 平台必须配置 Redis 才启用收藏按钮,否则界面会置灰并提示配置 `UPSTASH_REDIS_REST_URL`、`UPSTASH_REDIS_REST_TOKEN`。配置 Redis 后可跨冷启动和实例恢复。
- **智能缓存管理**:支持内存缓存搜索结果和弹幕数据,避免短期内重复的不必要API请求。包括:
- 搜索结果缓存(可通过 `SEARCH_CACHE_MINUTES` 配置,默认1分钟)
- 弹幕缓存(可通过 `COMMENT_CACHE_MINUTES` 配置,默认5分钟)
Expand Down
57 changes: 54 additions & 3 deletions danmu_api/apis/dandan-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,29 @@ function matchYear(anime, queryYear) {
return animeYear === queryYear;
}

// 在带有 SxxExx 的匹配请求中,电影通常也只有一个“第 1 集”链接,
// 会错误地抢在同名电视剧之前命中。优先使用详情中的有效集数,声明集数作为补充。
function isMovieMatchCandidate(anime) {
const type = `${anime?.type || ''} ${anime?.typeDescription || ''}`;
return /(电影|剧场版|movie|film)/i.test(type);
}

function getMatchEpisodeCount(anime, bangumiData) {
// 部分源(如 360)会把同一部电影的多个平台链接都放进 links,
// 不能把平台链接数当成电影集数。
if (isMovieMatchCandidate(anime)) return 1;
const episodes = bangumiData?.bangumi?.episodes;
const filtered = Array.isArray(episodes)
? filterSameEpisodeTitle(episodes.filter(ep => !globals.episodeTitleFilter.test(ep.episodeTitle)))
: [];
const declaredCount = Number(anime?.episodeCount) || 0;
return Math.max(filtered.length, declaredCount);
}

function isSingleEpisodeMatchCandidate(anime, bangumiData) {
return getMatchEpisodeCount(anime, bangumiData) <= 1;
}

export function matchSeason(anime, queryTitle, season) {
// 先从原始带括号的标题中分离出名称主体再对主体进行净化剥离非法字符
const match = anime.animeTitle.match(/^(.*?)\(\d{4}\)/);
Expand Down Expand Up @@ -1167,11 +1190,22 @@ async function matchAniAndEpByAi(season, episode, year, searchData, title, req,
return { resEpisode: null, resAnime: null };
}

// AI 有时会把同名电影选为首个候选;存在季集参数时交给常规匹配,
// 让多集电视剧候选按季集和集数优先级决策,避免单集电影抢占 S01E01。
const bangumiData = getBangumiDataForMatch(selectedAnime, detailStore);
if (!bangumiData?.success || !bangumiData?.bangumi?.episodes) {
return { resEpisode: null, resAnime: null };
}

const hasSeriesCandidate = searchData.animes.some(candidate => {
const candidateData = getBangumiDataForMatch(candidate, detailStore);
return !isMovieMatchCandidate(candidate) && getMatchEpisodeCount(candidate, candidateData) > 1;
});
if (season && episode && isSingleEpisodeMatchCandidate(selectedAnime, bangumiData) && hasSeriesCandidate) {
log('info', '[system] [match] AI selected a single-episode candidate while series candidates exist; falling back to season/episode matching');
return { resEpisode: null, resAnime: null };
}

let filteredEpisode = null;

if (season && episode) {
Expand Down Expand Up @@ -1398,6 +1432,10 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
};

const normalizedTitle = normalizeSpaces(title);
const hasMultiEpisodeCandidate = season && episode && searchData.animes.some(candidate => {
const candidateData = getBangumiDataForMatch(candidate, detailStore);
return getMatchEpisodeCount(candidate, candidateData) > 1;
});

// 遍历所有搜索结果,寻找最佳匹配
for (const anime of searchData.animes) {
Expand Down Expand Up @@ -1461,6 +1499,13 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
if (!bangumiData?.success || !bangumiData?.bangumi?.episodes) {
continue;
}

// S01E01 不能仅凭“第 1 集”命中电影;只要搜索结果中存在多集候选,
// 单集候选就不参与本轮季集匹配。这样不受平台顺序影响。
if (hasMultiEpisodeCandidate && isSingleEpisodeMatchCandidate(anime, bangumiData)) {
log('info', `[system] [match] Skip single-episode candidate for S${season}E${episode}: ${anime.animeTitle}`);
continue;
}

// 输出匹配分数及原始数据日志
log("info", "判断剧集", `Anime: ${anime.animeTitle}`);
Expand Down Expand Up @@ -1547,7 +1592,13 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
currentScore += 9999;
}

// 比较并更新最佳结果
// 比较并更新最佳结果。带季集时多集候选优先;单集候选仅作为兜底。
const isSingleEpisodeCandidate = season && episode && isSingleEpisodeMatchCandidate(anime, bangumiData);
if (isSingleEpisodeCandidate) {
currentScore -= 100;
} else if (season && episode) {
currentScore += 100;
}
// 逻辑:如果有更好的分数,或者之前没有匹配到任何结果,则更新
if (currentScore > bestRes.score) {
bestRes = {
Expand All @@ -1558,8 +1609,8 @@ async function matchAniAndEp(season, episode, year, searchData, title, req, plat
}

// 已命中最高优先级的手动优选,或不存在平台偏好且无待匹配的优选条目时立刻跳出查找
if (isPreferredAnime || (!platform && !preferAnimeId)) {
break;
if (isPreferredAnime || (!platform && !preferAnimeId && !isSingleEpisodeCandidate)) {
break;
}

// 如果指定了平台偏好,则继续循环查找是否有得分更高的源(最小杂质匹配)
Expand Down
7 changes: 7 additions & 0 deletions danmu_api/apis/favorite-api.js
Original file line number Diff line number Diff line change
Expand Up @@ -128,8 +128,15 @@ export async function handleFavoriteAdd(req, url) {
}

export function handleFavoriteList() {
// Node/Docker 可以使用本地文件缓存;无持久化存储的 serverless 实例
// 会在冷启动或实例切换后丢失收藏,因此不向前端开放收藏写入按钮。
const favoriteSupported = globals.deployPlatform === 'node' || globals.redisValid === true;
return jsonResponse({
success: true,
favoriteSupported,
favoriteSupportMessage: favoriteSupported
? ''
: '当前云部署未配置 Redis,收藏功能不可用。请配置 UPSTASH_REDIS_REST_URL 和 UPSTASH_REDIS_REST_TOKEN 后重新部署。',
scheduledRefreshSupported: globals.deployPlatform === 'node',
favorites: listFavorites()
});
Expand Down
2 changes: 1 addition & 1 deletion danmu_api/configs/globals.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const Globals = {
accessedEnvVars: {},

// 静态常量
VERSION: '1.20.7',
VERSION: '1.20.8',
MAX_LOGS: 1000, // 日志存储,最多保存 1000 行
MAX_RECORDS: 100, // 请求记录最大数量

Expand Down
64 changes: 64 additions & 0 deletions danmu_api/sources/hongguo.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,55 @@ const CLIENT_CONFIG = {
},
};

const WEB_ORIGIN = "https://hongguoduanju.com";

function parseWebSearchResults(html) {
const marker = '"searchList":';
const start = html.indexOf(marker);
if (start < 0) return [];
let i = start + marker.length;
while (i < html.length && /\s/.test(html[i])) i++;
if (html[i] !== "[") return [];
let depth = 0;
let quoted = false;
let escaped = false;
let end = i;
for (; end < html.length; end++) {
const ch = html[end];
if (quoted) {
if (escaped) escaped = false;
else if (ch === "\\") escaped = true;
else if (ch === '"') quoted = false;
continue;
}
if (ch === '"') quoted = true;
else if (ch === "[") depth++;
else if (ch === "]" && --depth === 0) break;
}
try { return JSON.parse(html.slice(i, end + 1)); } catch { return []; }
}

function parseWebSeriesDetail(html) {
const marker = '"seriesDetail":';
const start = html.indexOf(marker);
if (start < 0) return null;
let i = start + marker.length;
while (i < html.length && /\s/.test(html[i])) i++;
if (html[i] !== "{") return null;
let depth = 0;
let quoted = false;
let escaped = false;
let end = i;
for (; end < html.length; end++) {
const ch = html[end];
if (quoted) { if (escaped) escaped = false; else if (ch === "\\") escaped = true; else if (ch === '"') quoted = false; continue; }
if (ch === '"') quoted = true;
else if (ch === "{") depth++;
else if (ch === "}" && --depth === 0) break;
}
try { return JSON.parse(html.slice(i, end + 1)); } catch { return null; }
}

const COMMENT_SOURCE = 601;
const SERVER_CHANNEL = 1000;
const COMMENT_WINDOW_MS = 30_000;
Expand Down Expand Up @@ -755,6 +804,14 @@ export default class HongguoSource extends BaseSource {
}

async search(keyword) {
try {
const response = await httpGet(`${WEB_ORIGIN}/search/${encodeURIComponent(keyword)}`, { headers: { accept: "text/html" }, timeout: 30000 });
const items = parseWebSearchResults(typeof response.data === "string" ? response.data : "");
if (items.length) return items.map((item) => {
const data = item.video_data || {};
return { seriesId: String(data.series_id || item.keyword || ""), name: String(data.series_name || data.series_title || item.name || ""), episodeCount: Number(data.episode_cnt) || 0, score: "", year: extractYearFromTimestamp(data.create_time), imageUrl: extractImageUrl(data.series_cover) };
}).filter((item) => item.seriesId && item.name && item.episodeCount > 0).slice(0, MAX_SEARCH_ITEMS);
} catch (error) { log("warn", `[Hongguo] web search unavailable: ${error.message}`); }
try {
const results = [];
const seen = new Set();
Expand Down Expand Up @@ -798,6 +855,13 @@ export default class HongguoSource extends BaseSource {
}

async getEpisodes(seriesId) {
try {
const response = await httpGet(`${WEB_ORIGIN}/detail?series_id=${encodeURIComponent(seriesId)}`, { headers: { accept: "text/html" }, timeout: 30000 });
const detail = parseWebSeriesDetail(typeof response.data === "string" ? response.data : "");
if (detail && Array.isArray(detail.vid_list) && detail.vid_list.length) {
return { episodes: detail.vid_list.map((vid, index) => ({ index: index + 1, vid: String(vid), title: `第${index + 1}集`, duration: 0, commentCount: 0, imageUrl: "" })), year: extractYearFromTimestamp(detail.create_time), imageUrl: extractImageUrl(detail.series_cover) };
}
} catch (error) { log("warn", `[Hongguo] web detail unavailable: ${error.message}`); }
try {
const body = {
biz_param: {
Expand Down
35 changes: 33 additions & 2 deletions danmu_api/ui/js/apitest.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ let danmuTestState = {
isFavorite: false
};

let favoriteState = { items: [], loading: false, error: '', loaded: false, searchQuery: '', scheduledRefreshSupported: false };
let favoriteState = {
items: [], loading: false, error: '', loaded: false, searchQuery: '',
scheduledRefreshSupported: false, favoriteSupported: false,
favoriteSupportMessage: ''
};

// 初始化接口调试界面
function initApiTestInterface() {
Expand Down Expand Up @@ -789,13 +793,23 @@ function resetManualFavoriteButton() {
button.classList.add('btn-success');
button.disabled = true;
button.textContent = '收藏';
button.title = '请先完成手动搜索';
button.title = favoriteState.favoriteSupported
? '请先完成手动搜索'
: (favoriteState.favoriteSupportMessage || '当前部署未配置 Redis,收藏功能不可用');
}

function renderManualFavoriteButton() {
const button = document.getElementById('manual-favorite-btn');
if (!button || !danmuTestState.favoriteSearchKeyword) return;
const title = danmuTestState.favoriteAnimeTitle || danmuTestState.favoriteSearchKeyword;
if (!favoriteState.favoriteSupported) {
button.disabled = true;
button.classList.remove('btn-danger');
button.classList.add('btn-success');
button.textContent = '收藏(需 Redis)';
button.title = favoriteState.favoriteSupportMessage || '当前云部署未配置 Redis,收藏功能不可用';
return;
}
button.disabled = false;
button.classList.toggle('btn-success', !danmuTestState.isFavorite);
button.classList.toggle('btn-danger', danmuTestState.isFavorite);
Expand Down Expand Up @@ -832,6 +846,10 @@ async function favoriteManualSearch() {
resetManualFavoriteButton();
return;
}
if (!favoriteState.favoriteSupported) {
customAlert(favoriteState.favoriteSupportMessage || '当前云部署未配置 Redis,收藏功能不可用');
return;
}
if (inputKeyword !== danmuTestState.favoriteSearchKeyword) {
resetManualFavoriteButton();
return;
Expand Down Expand Up @@ -960,6 +978,14 @@ function renderFavoriteListView() {
return;
}

if (!favoriteState.favoriteSupported) {
if (status) status.textContent = '收藏不可用 · 未配置 Redis';
list.innerHTML = '<div class="preview-empty"><strong>当前部署未启用收藏</strong><span>' +
escapeHtml(favoriteState.favoriteSupportMessage || '云平台请配置 UPSTASH_REDIS_REST_URL 和 UPSTASH_REDIS_REST_TOKEN 后重新部署') +
'</span></div>';
return;
}

const normalizedQuery = favoriteState.searchQuery.toLocaleLowerCase();
const items = normalizedQuery
? favoriteState.items.filter(item => [item.keyword, item.animeTitle, item.source].join(' ').toLocaleLowerCase().includes(normalizedQuery))
Expand All @@ -984,6 +1010,11 @@ async function loadFavoriteList(shouldRender = true) {
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.message || 'HTTP ' + response.status);
favoriteState.items = Array.isArray(data.favorites) ? data.favorites : [];
// serverless 平台只有在 Redis 可用时才能跨冷启动持久化收藏。
// 兼容旧版本接口:缺少字段时按 Node 平台推断,避免误禁用本地部署。
favoriteState.favoriteSupported = data.favoriteSupported === true
|| (data.favoriteSupported === undefined && data.scheduledRefreshSupported === true);
favoriteState.favoriteSupportMessage = String(data.favoriteSupportMessage || '');
favoriteState.scheduledRefreshSupported = data.scheduledRefreshSupported === true;
favoriteState.loaded = true;
} catch (error) {
Expand Down
2 changes: 1 addition & 1 deletion danmu_api/ui/template.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export const HTML_TEMPLATE = /* html */ `
</div>

<div class="danmu-test-panel" id="favorite-panel">
<p style="color: #666; margin-bottom: 15px;">收藏后的剧集会永久缓存,后续匹配可秒级返回缓存结果;对于《火影忍者》《名侦探柯南》等集数较多的剧集尤其有用,无需每次重新搜索。可在“手动匹配测试”界面搜索剧集后,点击“收藏”按钮添加搜索结果收藏。只缓存剧集搜索结果,不缓存弹幕。</p>
<p style="color: #666; margin-bottom: 15px;">收藏后的剧集会永久缓存,后续匹配可秒级返回缓存结果;对于《火影忍者》《名侦探柯南》等集数较多的剧集尤其有用,无需每次重新搜索。可在“手动匹配测试”界面搜索剧集后,点击“收藏”按钮添加搜索结果收藏。只缓存剧集搜索结果,不缓存弹幕。Vercel、Netlify、Cloudflare 等云平台需配置 UPSTASH_REDIS_REST_URL 和 UPSTASH_REDIS_REST_TOKEN 才能使用收藏。</p>
<div class="form-group" style="margin-bottom: 15px;">
<label>搜索收藏</label>
<div style="display:flex;gap:10px;margin-top:5px;">
Expand Down
Loading