diff --git a/CHANGELOG.md b/CHANGELOG.md index 905adc884d..7a448d5bac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ All notable changes to this maintained LibreTV fork are documented here. +## 1.2.11 - 2026-05-12 + +### Fixed + +- Search results now require title relevance so sources that ignore keywords cannot flood unrelated adult content into normal searches. +- The `成人视频` home tag now loads latest entries from selected adult sources instead of querying Douban as a normal tag. +- Normal Douban home categories now filter adult-looking cards and keep adult content isolated to the dedicated adult tag. + ## 1.2.10 - 2026-05-12 ### Changed diff --git a/VERSION.txt b/VERSION.txt index 86e201dbfe..963335c2a3 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -202605120057 +202605120120 diff --git a/js/app.js b/js/app.js index cf1070f5da..0c6356789c 100644 --- a/js/app.js +++ b/js/app.js @@ -15,7 +15,17 @@ const YELLOW_CONTENT_KEYWORDS = [ '伦理片', '福利', '里番动漫', '门事件', '萝莉少女', '制服诱惑', '国产传媒', 'cosplay', '黑丝诱惑', '无码', '日本无码', '有码', '日本有码', 'swag', '网红主播', '美女主播', '国产自拍', '色情片', '同性片', '福利视频', '福利片', - '成人', '情色', '女优', '人妻', '巨乳', '私拍', '大尺度', '麻豆', '抖阴' + '成人', '情色', '女优', '人妻', '巨乳', '私拍', '大尺度', '麻豆', '抖阴', + '热舞', '裸聊', '自慰', '做爱', '性交', '口交', '约炮', '无码流出', + '番号', '爆乳', '翘臀', '嫩模', '嫩妹', '美乳', '白丝', '丝袜', '写真' +]; + +const SEARCH_TITLE_FIELDS = [ + 'vod_name', + 'vod_sub', + 'vod_en', + 'title', + 'name' ]; function isYellowContentFilterEnabled() { @@ -37,19 +47,68 @@ function getSearchableApiIds(apiIds = selectedAPIs) { return ids.filter(apiId => !isAdultSourceId(apiId)); } +function getSelectedAdultApiIds(apiIds = selectedAPIs) { + return Array.from(apiIds || []).filter(apiId => isAdultSourceId(apiId)); +} + +function shouldShowAdultRecommendTag() { + return !isYellowContentFilterEnabled() && getSelectedAdultApiIds().length > 0; +} + function normalizeFilterText(value) { return String(value || '').toLowerCase(); } +function normalizeSearchText(value) { + return String(value || '').toLowerCase().replace(/\s+/g, ''); +} + +function getSearchTitleTexts(item) { + return SEARCH_TITLE_FIELDS + .map(field => normalizeSearchText(item?.[field])) + .filter(Boolean); +} + +function doesResultMatchQuery(item, query) { + const compactQuery = normalizeSearchText(query); + if (!compactQuery) return true; + + const titleTexts = getSearchTitleTexts(item); + if (titleTexts.some(text => text.includes(compactQuery))) { + return true; + } + + const queryTerms = String(query || '') + .toLowerCase() + .split(/\s+/) + .map(normalizeSearchText) + .filter(Boolean); + + return queryTerms.length > 1 && queryTerms.every(term => + titleTexts.some(text => text.includes(term)) + ); +} + +function filterResultsByQuery(results, query) { + const list = Array.isArray(results) ? results : []; + return list.filter(item => doesResultMatchQuery(item, query)); +} + function matchesYellowContent(item) { if (isAdultSourceId(item?.source_code)) return true; const haystack = [ item?.vod_name, + item?.vod_sub, + item?.vod_en, + item?.title, + item?.name, item?.type_name, item?.vod_class, item?.vod_remarks, item?.vod_content, + item?.desc, + item?.description, item?.source_name ].map(normalizeFilterText).join(' '); @@ -422,6 +481,10 @@ function updateSelectedAPIs() { // 更新显示选中的API数量 updateSelectedApiCount(); + + if (typeof window.refreshDoubanAdultTagState === 'function') { + window.refreshDoubanAdultTagState(); + } } // 更新选中的API数量显示 @@ -617,6 +680,11 @@ function setupEventListeners() { // 添加成人API列表 addAdultAPI(); } + + checkAdultAPIsSelected(); + if (typeof window.refreshDoubanAdultTagState === 'function') { + window.refreshDoubanAdultTagState(); + } }); } @@ -1373,6 +1441,10 @@ function saveStringAsFile(content, fileName) { } window.getSearchableApiIds = getSearchableApiIds; +window.getSelectedAdultApiIds = getSelectedAdultApiIds; +window.shouldShowAdultRecommendTag = shouldShowAdultRecommendTag; +window.doesResultMatchQuery = doesResultMatchQuery; +window.filterResultsByQuery = filterResultsByQuery; window.filterYellowContentResults = filterYellowContentResults; window.matchesYellowContent = matchesYellowContent; window.getResultTypeLabel = getResultTypeLabel; diff --git a/js/config.js b/js/config.js index 47371d9c46..7d2cd0e749 100644 --- a/js/config.js +++ b/js/config.js @@ -17,7 +17,7 @@ const SITE_CONFIG = { url: '', description: '免费在线视频搜索与观看平台', logo: 'image/logo.png', - version: '1.2.10' + version: '1.2.11' }; const DEFAULT_SELECTED_APIS = ['ysgc', 'jszy', 'wujin', 'maoyan']; diff --git a/js/douban.js b/js/douban.js index 17fe7ecb85..12b3bf1932 100644 --- a/js/douban.js +++ b/js/douban.js @@ -1,6 +1,7 @@ // 豆瓣热门电影电视剧推荐功能 // 豆瓣标签列表 - 修改为默认标签 +const ADULT_RECOMMEND_TAG = '成人视频'; let defaultMovieTags = ['热门', '最新', '经典', '豆瓣高分', '冷门佳片', '华语', '欧美', '韩国', '日本', '动作', '喜剧', '日综', '爱情', '科幻', '悬疑', '恐怖', '治愈']; let defaultTvTags = ['热门', '美剧', '英剧', '韩剧', '日剧', '国产剧', '港剧', '日本动画', '综艺', '纪录片']; @@ -8,38 +9,64 @@ let defaultTvTags = ['热门', '美剧', '英剧', '韩剧', '日剧', '国产 let movieTags = []; let tvTags = []; +function sanitizeDoubanTags(tags, fallbackTags = []) { + const sourceTags = Array.isArray(tags) ? tags : fallbackTags; + const seen = new Set(); + + return sourceTags + .map(tag => String(tag || '').trim()) + .filter(tag => tag && tag !== ADULT_RECOMMEND_TAG) + .filter(tag => { + const key = tag.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + // 加载用户标签 function loadUserTags() { try { + let shouldResaveTags = false; // 尝试从本地存储加载用户保存的标签 const savedMovieTags = localStorage.getItem('userMovieTags'); const savedTvTags = localStorage.getItem('userTvTags'); // 如果本地存储中有标签数据,则使用它 if (savedMovieTags) { - movieTags = JSON.parse(savedMovieTags); + const parsedMovieTags = JSON.parse(savedMovieTags); + movieTags = sanitizeDoubanTags(parsedMovieTags, defaultMovieTags); + shouldResaveTags = shouldResaveTags || !Array.isArray(parsedMovieTags) || movieTags.length !== parsedMovieTags.length; } else { // 否则使用默认标签 - movieTags = [...defaultMovieTags]; + movieTags = sanitizeDoubanTags(defaultMovieTags); } if (savedTvTags) { - tvTags = JSON.parse(savedTvTags); + const parsedTvTags = JSON.parse(savedTvTags); + tvTags = sanitizeDoubanTags(parsedTvTags, defaultTvTags); + shouldResaveTags = shouldResaveTags || !Array.isArray(parsedTvTags) || tvTags.length !== parsedTvTags.length; } else { // 否则使用默认标签 - tvTags = [...defaultTvTags]; + tvTags = sanitizeDoubanTags(defaultTvTags); + } + + if (shouldResaveTags) { + saveUserTags(); } } catch (e) { console.error('加载标签失败:', e); // 初始化为默认值,防止错误 - movieTags = [...defaultMovieTags]; - tvTags = [...defaultTvTags]; + movieTags = sanitizeDoubanTags(defaultMovieTags); + tvTags = sanitizeDoubanTags(defaultTvTags); } } // 保存用户标签 function saveUserTags() { try { + movieTags = sanitizeDoubanTags(movieTags, defaultMovieTags); + tvTags = sanitizeDoubanTags(tvTags, defaultTvTags); localStorage.setItem('userMovieTags', JSON.stringify(movieTags)); localStorage.setItem('userTvTags', JSON.stringify(tvTags)); } catch (e) { @@ -53,6 +80,48 @@ let doubanCurrentTag = '热门'; let doubanPageStart = 0; const doubanPageSize = 16; // 一次显示的项目数量 +function isAdultRecommendTagAvailable() { + return typeof window.shouldShowAdultRecommendTag === 'function' + && window.shouldShowAdultRecommendTag(); +} + +function getCurrentDoubanBaseTags() { + const currentTags = doubanMovieTvCurrentSwitch === 'movie' ? movieTags : tvTags; + return sanitizeDoubanTags(currentTags); +} + +function getVisibleDoubanTags() { + const visibleTags = getCurrentDoubanBaseTags(); + if (isAdultRecommendTagAvailable()) { + visibleTags.push(ADULT_RECOMMEND_TAG); + } + return visibleTags; +} + +function ensureValidDoubanTag() { + if (doubanCurrentTag === ADULT_RECOMMEND_TAG && !isAdultRecommendTagAvailable()) { + doubanCurrentTag = '热门'; + doubanPageStart = 0; + return true; + } + return false; +} + +function refreshDoubanAdultTagState() { + const tagChanged = ensureValidDoubanTag(); + renderDoubanTags(); + + const doubanArea = document.getElementById('doubanArea'); + const shouldReload = tagChanged || doubanCurrentTag === ADULT_RECOMMEND_TAG; + if ( + shouldReload + && localStorage.getItem('doubanEnabled') === 'true' + && (!doubanArea || !doubanArea.classList.contains('hidden')) + ) { + renderRecommend(doubanCurrentTag, doubanPageSize, doubanPageStart); + } +} + // 初始化豆瓣功能 function initDouban() { // 设置豆瓣开关的初始状态 @@ -320,9 +389,11 @@ function renderDoubanMovieTvSwitch() { function renderDoubanTags(tags) { const tagContainer = document.getElementById('douban-tags'); if (!tagContainer) return; - + + ensureValidDoubanTag(); + // 确定当前应该使用的标签列表 - const currentTags = doubanMovieTvCurrentSwitch === 'movie' ? movieTags : tvTags; + const currentTags = getVisibleDoubanTags(); // 清空标签容器 tagContainer.innerHTML = ''; @@ -386,7 +457,7 @@ function fetchDoubanTags() { const movieTagsTarget = `https://movie.douban.com/j/search_tags?type=movie` fetchDoubanData(movieTagsTarget) .then(data => { - movieTags = data.tags; + movieTags = sanitizeDoubanTags(data.tags, defaultMovieTags); if (doubanMovieTvCurrentSwitch === 'movie') { renderDoubanTags(movieTags); } @@ -397,7 +468,7 @@ function fetchDoubanTags() { const tvTagsTarget = `https://movie.douban.com/j/search_tags?type=tv` fetchDoubanData(tvTagsTarget) .then(data => { - tvTags = data.tags; + tvTags = sanitizeDoubanTags(data.tags, defaultTvTags); if (doubanMovieTvCurrentSwitch === 'tv') { renderDoubanTags(tvTags); } @@ -423,11 +494,15 @@ function renderRecommend(tag, pageLimit, pageStart) { container.classList.add("relative"); container.insertAdjacentHTML('beforeend', loadingOverlayHTML); + + if (tag === ADULT_RECOMMEND_TAG) { + return renderAdultRecommend(pageLimit, pageStart); + } const target = `https://movie.douban.com/j/search_subjects?type=${doubanMovieTvCurrentSwitch}&tag=${tag}&sort=recommend&page_limit=${pageLimit}&page_start=${pageStart}`; // 使用通用请求函数 - fetchDoubanData(target) + return fetchDoubanData(target) .then(data => { renderDoubanCards(data, container); }) @@ -442,6 +517,111 @@ function renderRecommend(tag, pageLimit, pageStart) { }); } +function escapeInlineJsString(value) { + return String(value || '') + .replace(/\\/g, '\\\\') + .replace(/'/g, "\\'") + .replace(/\r?\n/g, ' '); +} + +function getAdultRecommendPage(pageLimit, pageStart) { + const limit = Number.parseInt(pageLimit, 10) || doubanPageSize; + const start = Number.parseInt(pageStart, 10) || 0; + return Math.max(1, Math.floor(start / limit) + 1); +} + +async function renderAdultRecommend(pageLimit, pageStart) { + const container = document.getElementById("douban-results"); + if (!container) return; + + const adultApiIds = typeof window.getSelectedAdultApiIds === 'function' + ? window.getSelectedAdultApiIds() + : []; + + if (!isAdultRecommendTagAvailable() || adultApiIds.length === 0) { + renderDoubanCards({ subjects: [] }, container); + return; + } + + try { + const page = getAdultRecommendPage(pageLimit, pageStart); + const batches = await Promise.all(adultApiIds.map(async apiId => { + if (typeof fetchLatestByAPI !== 'function') return []; + return fetchLatestByAPI(apiId, page); + })); + + const adultResults = batches + .flat() + .filter(item => typeof matchesYellowContent !== 'function' || matchesYellowContent(item)) + .slice(0, pageLimit); + + renderAdultCards(adultResults, container); + } catch (error) { + console.error("获取成人视频数据失败:", error); + container.innerHTML = ` +
+
❌ 获取成人视频数据失败,请稍后重试
+
+ `; + } +} + +function renderAdultCards(items, container) { + const fragment = document.createDocumentFragment(); + + if (!items || items.length === 0) { + const emptyEl = document.createElement("div"); + emptyEl.className = "col-span-full text-center py-8"; + emptyEl.innerHTML = ` +
❌ 暂无数据,请尝试其他分类或刷新
+ `; + fragment.appendChild(emptyEl); + } else { + items.forEach(item => { + const card = document.createElement("div"); + card.className = "bg-[#111] hover:bg-[#222] transition-all duration-300 rounded-lg overflow-hidden flex flex-col transform hover:scale-105 shadow-md hover:shadow-lg"; + + const rawTitle = item.vod_name || '未知视频'; + const safeTitle = escapeHtmlAttr(rawTitle); + const safeTitleJs = escapeInlineJsString(rawTitle); + const safeIdJs = escapeInlineJsString(item.vod_id || ''); + const safeSourceCodeJs = escapeInlineJsString(item.source_code || ''); + const sourceName = escapeHtmlAttr(item.source_name || ''); + const originalCoverUrl = normalizeImageUrl(item.vod_pic, item.api_url || API_SITES[item.source_code]?.api || ''); + const safeCoverUrl = escapeHtmlAttr(originalCoverUrl); + + card.innerHTML = ` +
+ ${safeTitle} +
+
+ ${ADULT_RECOMMEND_TAG} +
+ ${sourceName ? ` +
+ ${sourceName} +
` : ''} +
+
+ +
+ `; + + fragment.appendChild(card); + }); + } + + container.innerHTML = ""; + container.appendChild(fragment); +} + async function fetchDoubanData(url) { // 添加超时控制 const controller = new AbortController(); @@ -504,9 +684,12 @@ async function fetchDoubanData(url) { function renderDoubanCards(data, container) { // 创建文档片段以提高性能 const fragment = document.createDocumentFragment(); + const subjects = Array.isArray(data.subjects) + ? data.subjects.filter(item => typeof matchesYellowContent !== 'function' || !matchesYellowContent(item)) + : []; // 如果没有数据 - if (!data.subjects || data.subjects.length === 0) { + if (subjects.length === 0) { const emptyEl = document.createElement("div"); emptyEl.className = "col-span-full text-center py-8"; emptyEl.innerHTML = ` @@ -515,7 +698,7 @@ function renderDoubanCards(data, container) { fragment.appendChild(emptyEl); } else { // 循环创建每个影视卡片 - data.subjects.forEach(item => { + subjects.forEach(item => { const card = document.createElement("div"); card.className = "bg-[#111] hover:bg-[#222] transition-all duration-300 rounded-lg overflow-hidden flex flex-col transform hover:scale-105 shadow-md hover:shadow-lg"; @@ -576,6 +759,9 @@ function resetToHome() { // 加载豆瓣首页内容 document.addEventListener('DOMContentLoaded', initDouban); +window.ADULT_RECOMMEND_TAG = ADULT_RECOMMEND_TAG; +window.refreshDoubanAdultTagState = refreshDoubanAdultTagState; + // 显示标签管理模态框 function showTagManageModal() { // 确保模态框在页面上只有一个实例 @@ -696,6 +882,11 @@ function addTag(tag) { .replace(//g, '>') .replace(/"/g, '"'); + + if (safeTag === ADULT_RECOMMEND_TAG) { + showToast('成人视频为系统标签,无需手动添加', 'info'); + return; + } // 确定当前使用的是电影还是电视剧标签 const isMovie = doubanMovieTvCurrentSwitch === 'movie'; @@ -770,9 +961,9 @@ function resetTagsToDefault() { // 重置为默认标签 if (isMovie) { - movieTags = [...defaultMovieTags]; + movieTags = sanitizeDoubanTags(defaultMovieTags); } else { - tvTags = [...defaultTvTags]; + tvTags = sanitizeDoubanTags(defaultTvTags); } // 设置当前标签为热门 diff --git a/js/search.js b/js/search.js index 928e1c244a..a3c639b47e 100644 --- a/js/search.js +++ b/js/search.js @@ -1,60 +1,84 @@ -async function searchByAPIAndKeyWord(apiId, query) { - try { - let apiUrl, apiName, apiBaseUrl; - - // 处理自定义API - if (apiId.startsWith('custom_')) { - const customIndex = apiId.replace('custom_', ''); - const customApi = getCustomApiInfo(customIndex); - if (!customApi) return []; - - apiBaseUrl = customApi.url; - apiUrl = apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query); - apiName = customApi.name; - } else { - // 内置API - if (!API_SITES[apiId]) return []; - apiBaseUrl = API_SITES[apiId].api; - apiUrl = apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query); - apiName = API_SITES[apiId].name; - } +function getApiSearchContext(apiId) { + if (apiId.startsWith('custom_')) { + const customIndex = apiId.replace('custom_', ''); + const customApi = getCustomApiInfo(customIndex); + if (!customApi) return null; - const withSourceInfo = item => ({ - ...item, - vod_pic: normalizeImageUrl(item.vod_pic, apiBaseUrl), - source_name: apiName, - source_code: apiId, - api_url: apiId.startsWith('custom_') ? getCustomApiInfo(apiId.replace('custom_', ''))?.url : undefined - }); - - // 添加超时处理 - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 15000); - - // 添加鉴权参数到代理URL - const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? + return { + apiBaseUrl: customApi.url, + apiName: customApi.name, + apiUrl: customApi.url, + isCustom: true + }; + } + + if (!API_SITES[apiId]) return null; + + return { + apiBaseUrl: API_SITES[apiId].api, + apiName: API_SITES[apiId].name, + apiUrl: undefined, + isCustom: false + }; +} + +function withSourceInfo(item, apiId, context) { + return { + ...item, + vod_pic: normalizeImageUrl(item.vod_pic, context.apiBaseUrl), + source_name: context.apiName, + source_code: apiId, + api_url: context.apiUrl + }; +} + +async function fetchVodApiJson(apiUrl, headers = API_CONFIG.search.headers) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 15000); + + try { + const proxiedUrl = await window.ProxyAuth?.addAuthToProxyUrl ? await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(apiUrl)) : PROXY_URL + encodeURIComponent(apiUrl); - + const response = await fetch(proxiedUrl, { - headers: API_CONFIG.search.headers, + headers, signal: controller.signal }); - - clearTimeout(timeoutId); - + if (!response.ok) { - return []; + return null; } - - const data = await response.json(); + + return await response.json(); + } finally { + clearTimeout(timeoutId); + } +} + +function normalizeApiList(data, apiId, context) { + if (!data || !Array.isArray(data.list)) return []; + return data.list.map(item => withSourceInfo(item, apiId, context)); +} + +async function searchByAPIAndKeyWord(apiId, query) { + try { + const context = getApiSearchContext(apiId); + if (!context) return []; + + const apiUrl = context.apiBaseUrl + API_CONFIG.search.path + encodeURIComponent(query); + const data = await fetchVodApiJson(apiUrl); if (!data || !data.list || !Array.isArray(data.list) || data.list.length === 0) { return []; } // 处理第一页结果 - const results = data.list.map(withSourceInfo); + const firstPageResults = normalizeApiList(data, apiId, context); + const results = filterResultsByQuery(firstPageResults, query); + if (firstPageResults.length > 0 && results.length === 0) { + return []; + } // 获取总页数 const pageCount = data.pagecount || 1; @@ -67,36 +91,19 @@ async function searchByAPIAndKeyWord(apiId, query) { for (let page = 2; page <= pagesToFetch + 1; page++) { // 构建分页URL - const pageUrl = apiBaseUrl + API_CONFIG.search.pagePath + const pageUrl = context.apiBaseUrl + API_CONFIG.search.pagePath .replace('{query}', encodeURIComponent(query)) .replace('{page}', page); // 创建获取额外页的Promise const pagePromise = (async () => { try { - const pageController = new AbortController(); - const pageTimeoutId = setTimeout(() => pageController.abort(), 15000); - - // 添加鉴权参数到代理URL - const proxiedPageUrl = await window.ProxyAuth?.addAuthToProxyUrl ? - await window.ProxyAuth.addAuthToProxyUrl(PROXY_URL + encodeURIComponent(pageUrl)) : - PROXY_URL + encodeURIComponent(pageUrl); - - const pageResponse = await fetch(proxiedPageUrl, { - headers: API_CONFIG.search.headers, - signal: pageController.signal - }); - - clearTimeout(pageTimeoutId); - - if (!pageResponse.ok) return []; - - const pageData = await pageResponse.json(); + const pageData = await fetchVodApiJson(pageUrl); if (!pageData || !pageData.list || !Array.isArray(pageData.list)) return []; // 处理当前页结果 - return pageData.list.map(withSourceInfo); + return filterResultsByQuery(normalizeApiList(pageData, apiId, context), query); } catch (error) { console.warn(`API ${apiId} 第${page}页搜索失败:`, error); return []; @@ -123,3 +130,20 @@ async function searchByAPIAndKeyWord(apiId, query) { return []; } } + +async function fetchLatestByAPI(apiId, page = 1) { + try { + const context = getApiSearchContext(apiId); + if (!context) return []; + + const safePage = Math.max(1, Number.parseInt(page, 10) || 1); + const pageUrl = `${context.apiBaseUrl}?ac=videolist&pg=${safePage}`; + const data = await fetchVodApiJson(pageUrl); + return normalizeApiList(data, apiId, context); + } catch (error) { + console.warn(`API ${apiId} 最新列表获取失败:`, error); + return []; + } +} + +window.fetchLatestByAPI = fetchLatestByAPI; diff --git a/package-lock.json b/package-lock.json index 5e9bed80dd..ea5d6f77a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "libretv", - "version": "1.2.10", + "version": "1.2.11", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "libretv", - "version": "1.2.10", + "version": "1.2.11", "license": "Apache-2.0", "dependencies": { "axios": "^1.9.0", diff --git a/package.json b/package.json index 29696e4290..213b7c72ce 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "libretv", "type": "module", - "version": "1.2.10", + "version": "1.2.11", "private": true, "description": "免费在线视频搜索与观看平台", "author": "bestZwei", diff --git a/test/libretv-defaults.test.mjs b/test/libretv-defaults.test.mjs index 2ee032bee0..8bdd6bc485 100644 --- a/test/libretv-defaults.test.mjs +++ b/test/libretv-defaults.test.mjs @@ -22,6 +22,39 @@ async function loadBrowserConfig() { return sandbox.window; } +function createElementStub(tagName = 'div') { + return { + tagName, + children: [], + className: '', + innerHTML: '', + textContent: '', + onclick: null, + dataset: {}, + style: {}, + classList: { + add() {}, + remove() {}, + contains() { + return false; + } + }, + appendChild(child) { + if (child?.isFragment) { + this.children.push(...child.children); + } else { + this.children.push(child); + } + return child; + }, + insertAdjacentHTML(_position, html) { + this.innerHTML += html; + }, + addEventListener() {}, + remove() {} + }; +} + test('verified customer API sources are available and invalid placeholders are removed', async () => { const window = await loadBrowserConfig(); @@ -565,6 +598,216 @@ test('yellow content filter skips adult sources and adult-looking search results ); }); +test('search results require title relevance and ignored sources do not paginate', async () => { + const storage = new Map([ + ['yellowFilterEnabled', 'false'], + ['selectedAPIs', JSON.stringify(['siwa'])] + ]); + const requestedUrls = []; + const sandbox = { + console, + URL, + window: {}, + localStorage: { + getItem(key) { + return storage.get(key) || null; + }, + setItem(key, value) { + storage.set(key, value); + } + }, + document: { + addEventListener() {}, + getElementById() { + return null; + }, + querySelectorAll() { + return []; + } + }, + fetch: async url => { + requestedUrls.push(decodeURIComponent(String(url))); + return { + ok: true, + async json() { + return { + pagecount: 50, + list: [ + { vod_id: 'adult-1', vod_name: '美女主播私拍', type_name: '国产自拍' }, + { vod_id: 'adult-2', vod_name: '热舞屁股', type_name: '美女主播' } + ] + }; + } + }; + }, + setTimeout, + clearTimeout, + AbortController + }; + vm.createContext(sandbox); + vm.runInContext(await readProjectFile('js/config.js'), sandbox); + vm.runInContext(await readProjectFile('js/customer_site.js'), sandbox); + vm.runInContext(await readProjectFile('js/app.js'), sandbox); + vm.runInContext(await readProjectFile('js/search.js'), sandbox); + + assert.equal(typeof sandbox.window.filterResultsByQuery, 'function'); + assert.deepEqual( + Array.from(sandbox.window.filterResultsByQuery([ + { vod_name: '世界的主人', source_code: 'ysgc' }, + { vod_name: '美女主播私拍', source_code: 'siwa' }, + { vod_name: '别名', vod_sub: '世界的主人', source_code: 'ysgc' } + ], '世界的主人')).map(item => item.vod_id || item.vod_name), + ['世界的主人', '别名'] + ); + + const results = await sandbox.searchByAPIAndKeyWord('siwa', '世界的主人'); + + assert.deepEqual(Array.from(results), []); + assert.equal(requestedUrls.filter(url => url.includes('siwazyw.tv')).length, 1); + assert.equal(requestedUrls.some(url => url.includes('pg=2')), false); +}); + +test('adult recommendation tag uses selected adult sources instead of Douban', async () => { + const storage = new Map([ + ['yellowFilterEnabled', 'false'], + ['selectedAPIs', JSON.stringify(['ysgc', 'siwa'])], + ['userMovieTags', JSON.stringify(['热门', '成人视频', '动作'])] + ]); + const elements = new Map([ + ['douban-results', createElementStub('div')], + ['douban-tags', createElementStub('div')] + ]); + const requestedUrls = []; + const sandbox = { + console, + URL, + window: {}, + localStorage: { + getItem(key) { + return storage.get(key) || null; + }, + setItem(key, value) { + storage.set(key, value); + } + }, + document: { + addEventListener() {}, + getElementById(id) { + return elements.get(id) || null; + }, + createElement: createElementStub, + createDocumentFragment() { + return { + isFragment: true, + children: [], + appendChild(child) { + this.children.push(child); + return child; + } + }; + }, + querySelectorAll() { + return []; + } + }, + fetch: async url => { + const decodedUrl = decodeURIComponent(String(url)); + requestedUrls.push(decodedUrl); + return { + ok: true, + async json() { + return { + pagecount: 1, + list: [ + { vod_id: 'adult-latest-1', vod_name: '国产自拍最新', type_name: '国产自拍' } + ], + subjects: [] + }; + } + }; + }, + showDetails() {}, + setTimeout, + clearTimeout, + AbortController + }; + vm.createContext(sandbox); + vm.runInContext(await readProjectFile('js/config.js'), sandbox); + vm.runInContext(await readProjectFile('js/customer_site.js'), sandbox); + vm.runInContext(await readProjectFile('js/app.js'), sandbox); + vm.runInContext(await readProjectFile('js/search.js'), sandbox); + vm.runInContext(await readProjectFile('js/source-health.js'), sandbox); + vm.runInContext(await readProjectFile('js/douban.js'), sandbox); + + assert.equal(typeof sandbox.fetchLatestByAPI, 'function'); + const latest = await sandbox.fetchLatestByAPI('siwa', 2); + assert.deepEqual(Array.from(latest).map(item => item.source_code), ['siwa']); + + const renderPromise = sandbox.renderRecommend('成人视频', 16, 0); + if (renderPromise && typeof renderPromise.then === 'function') { + await renderPromise; + } + await new Promise(resolve => setTimeout(resolve, 0)); + + assert.equal(requestedUrls.some(url => url.includes('movie.douban.com')), false); + assert.equal(requestedUrls.some(url => url.includes('siwazyw.tv') && url.includes('ac=videolist') && url.includes('pg=')), true); + assert.equal(requestedUrls.some(url => url.includes('wd=')), false); +}); + +test('normal douban cards filter adult-looking subjects from regular tags', async () => { + const container = createElementStub('div'); + const sandbox = { + console, + URL, + window: {}, + localStorage: { + getItem() { + return null; + }, + setItem() {} + }, + document: { + addEventListener() {}, + getElementById() { + return null; + }, + createElement: createElementStub, + createDocumentFragment() { + return { + isFragment: true, + children: [], + appendChild(child) { + this.children.push(child); + return child; + } + }; + }, + querySelectorAll() { + return []; + } + }, + setTimeout, + clearTimeout, + AbortController + }; + vm.createContext(sandbox); + vm.runInContext(await readProjectFile('js/config.js'), sandbox); + vm.runInContext(await readProjectFile('js/customer_site.js'), sandbox); + vm.runInContext(await readProjectFile('js/app.js'), sandbox); + vm.runInContext(await readProjectFile('js/douban.js'), sandbox); + + sandbox.renderDoubanCards({ + subjects: [ + { title: '美女主播私拍', rate: '7.0', cover: '', url: 'https://movie.douban.com/subject/adult' }, + { title: '世界的主人', rate: '7.5', cover: '', url: 'https://movie.douban.com/subject/normal' } + ] + }, container); + + assert.equal(container.children.length, 1); + assert.match(container.children[0].innerHTML, /世界的主人/); + assert.doesNotMatch(container.children[0].innerHTML, /美女主播/); +}); + test('playback errors are classified and offer one-click source switching', async () => { const playerErrors = await readProjectFile('js/player-errors.js'); const player = await readProjectFile('js/player.js'); @@ -665,12 +908,12 @@ test('release metadata is bumped for this update', async () => { const changelog = await readProjectFile('CHANGELOG.md'); - assert.equal(packageJson.version, '1.2.10'); - assert.equal(lockJson.version, '1.2.10'); - assert.equal(lockJson.packages[''].version, '1.2.10'); - assert.match(config, /version:\s*'1\.2\.10'/); - assert.match(changelog, /1\.2\.10/); - assert.match(changelog, /检测源|Source health checks/); + assert.equal(packageJson.version, '1.2.11'); + assert.equal(lockJson.version, '1.2.11'); + assert.equal(lockJson.packages[''].version, '1.2.11'); + assert.match(config, /version:\s*'1\.2\.11'/); + assert.match(changelog, /1\.2\.11/); + assert.match(changelog, /成人视频|adult tag/); assert.match(versionTxt, /^\d{12}$/); assert.ok(Number(versionTxt) > 202508060117); });