diff --git a/README.md b/README.md index 95f8b8c0f..f617700cf 100644 --- a/README.md +++ b/README.md @@ -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": "火影忍者" }` 删除收藏及对应搜索缓存。 @@ -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分钟) diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index 7320ce57a..2830ffe90 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -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}\)/); @@ -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) { @@ -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) { @@ -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}`); @@ -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 = { @@ -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; } // 如果指定了平台偏好,则继续循环查找是否有得分更高的源(最小杂质匹配) diff --git a/danmu_api/apis/favorite-api.js b/danmu_api/apis/favorite-api.js index 66c0850cd..bfc4a2465 100644 --- a/danmu_api/apis/favorite-api.js +++ b/danmu_api/apis/favorite-api.js @@ -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() }); diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 6117fa13f..e99f63384 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.20.7', + VERSION: '1.20.8', MAX_LOGS: 1000, // 日志存储,最多保存 1000 行 MAX_RECORDS: 100, // 请求记录最大数量 diff --git a/danmu_api/sources/hongguo.js b/danmu_api/sources/hongguo.js index 9cb4f412c..a13dd500e 100644 --- a/danmu_api/sources/hongguo.js +++ b/danmu_api/sources/hongguo.js @@ -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; @@ -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(); @@ -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: { diff --git a/danmu_api/ui/js/apitest.js b/danmu_api/ui/js/apitest.js index 4bba61917..eaf3981d0 100644 --- a/danmu_api/ui/js/apitest.js +++ b/danmu_api/ui/js/apitest.js @@ -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() { @@ -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); @@ -832,6 +846,10 @@ async function favoriteManualSearch() { resetManualFavoriteButton(); return; } + if (!favoriteState.favoriteSupported) { + customAlert(favoriteState.favoriteSupportMessage || '当前云部署未配置 Redis,收藏功能不可用'); + return; + } if (inputKeyword !== danmuTestState.favoriteSearchKeyword) { resetManualFavoriteButton(); return; @@ -960,6 +978,14 @@ function renderFavoriteListView() { return; } + if (!favoriteState.favoriteSupported) { + if (status) status.textContent = '收藏不可用 · 未配置 Redis'; + list.innerHTML = '
收藏后的剧集会永久缓存,后续匹配可秒级返回缓存结果;对于《火影忍者》《名侦探柯南》等集数较多的剧集尤其有用,无需每次重新搜索。可在“手动匹配测试”界面搜索剧集后,点击“收藏”按钮添加搜索结果收藏。只缓存剧集搜索结果,不缓存弹幕。
+收藏后的剧集会永久缓存,后续匹配可秒级返回缓存结果;对于《火影忍者》《名侦探柯南》等集数较多的剧集尤其有用,无需每次重新搜索。可在“手动匹配测试”界面搜索剧集后,点击“收藏”按钮添加搜索结果收藏。只缓存剧集搜索结果,不缓存弹幕。Vercel、Netlify、Cloudflare 等云平台需配置 UPSTASH_REDIS_REST_URL 和 UPSTASH_REDIS_REST_TOKEN 才能使用收藏。