修复AI匹配中单集电影抢占SxxExx电视剧的问题及红果弹幕获取 (#454)
* feat: 添加收藏功能可用性检测,未配置Redis时禁用UI操作 - 在收藏列表接口中返回 `favoriteSupported` 字段,根据部署平台及 Redis 配置动态判断 - 前端根据 `favoriteSupported` 禁用“收藏”按钮,并显示配置 Redis 的提示信息 - 更新 README 说明 serverless 平台需配置 Upstash Redis 才能启用收藏功能 - 避免无持久化存储的实例在冷启动或实例切换后丢失收藏数据导致用户误操作 * chore: 升级版本号至 v1.20.8 * fix: 修复AI匹配中单集电影抢占SxxExx电视剧的问题 - 新增 isMovieMatchCandidate、getMatchEpisodeCount 和 isSingleEpisodeMatchCandidate 函数,用于准确识别电影及单集候选。 - 在 matchAniAndEpByAi 中,若 AI 选中单集候选而存在多集电视剧候选,则回退到常规季集匹配,避免同名电影错误命中 S01E01。 - 在常规匹配循环中,跳过单集候选参与有季集参数的匹配,并调整评分逻辑,对多集候选加分、单集候选减分,提升多集电视剧优先级。 - 优化 break 条件,避免单集候选提前终止搜索,确保多集候选能被充分比较。 * feat(hongguo): 添加Web端HTML解析作为搜索和剧集获取的备用方式 - 新增parseWebSearchResults和parseWebSeriesDetail函数,从HTML响应中提取JSON数据 - search方法优先请求Web端/search页面,解析搜索结果列表 - getEpisodes方法优先请求Web端/detail页面,解析剧集列表 - 当Web请求失败时回退至原有API方式,提升数据获取的稳定性
This commit is contained in:
1 parent
1bdb2a879e
commit
12d62ad97f
7 files changed
+162
-9
No files matched your search
@@ -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分钟)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// 如果指定了平台偏好,则继续循环查找是否有得分更高的源(最小杂质匹配)
|
||||
|
||||
@@ -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()
|
||||
});
|
||||
|
||||
@@ -13,7 +13,7 @@ export const Globals = {
|
||||
accessedEnvVars: {},
|
||||
|
||||
// 静态常量
|
||||
VERSION: '1.20.7',
|
||||
VERSION: '1.20.8',
|
||||
MAX_LOGS: 1000, // 日志存储,最多保存 1000 行
|
||||
MAX_RECORDS: 100, // 请求记录最大数量
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 = '<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))
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;">
|
||||
|
||||
Reference in new issue
Block a user