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 = `
+
+

+
+
+ ${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);
});