Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-27 14:39:52 +00:00
parent 4e4e437247
commit f53091d694
46 changed files with 31149 additions and 23124 deletions
@@ -0,0 +1,537 @@
var WidgetMetadata = {
id: "jable_capyplayer",
title: "Jable",
description: "Jable CapyPlayer 规范版,支持热门、最新、中文字幕、女优、玩法、剧情、搜索与播放解析。",
author: "Minis",
version: "3.0.3",
site: "https://jable.tv",
icon: "https://jable.tv/favicon.ico",
modules: [
{
id: "hot",
title: "热门",
type: "media_list",
functionName: "loadPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "列表地址",
type: "constant",
value: "https://jable.tv/hot/?mode=async&function=get_block&block_id=list_videos_common_videos_list"
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "video_viewed_today",
enumOptions: [
{ title: "今日热门", value: "video_viewed_today" },
{ title: "本周热门", value: "video_viewed_week" },
{ title: "本月热门", value: "video_viewed_month" },
{ title: "所有时间", value: "video_viewed" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "latest",
title: "最新",
type: "media_list",
functionName: "loadPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "列表地址",
type: "constant",
value: "https://jable.tv/new-release/?mode=async&function=get_block&block_id=list_videos_common_videos_list"
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "latest-updates",
enumOptions: [
{ title: "最新发布", value: "latest-updates" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "subtitle",
title: "中文",
type: "media_list",
functionName: "loadPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "列表地址",
type: "constant",
value: "https://jable.tv/categories/chinese-subtitle/?mode=async&function=get_block&block_id=list_videos_common_videos_list"
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "post_date",
enumOptions: [
{ title: "最近更新", value: "post_date" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "actress",
title: "女优",
type: "media_list",
functionName: "loadPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "选择女优",
type: "enum",
value: "https://jable.tv/s1/models/yua-mikami/?mode=async&function=get_block&block_id=list_videos_common_videos_list",
enumOptions: [
{ title: "三上悠亚", value: "https://jable.tv/s1/models/yua-mikami/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "枫可怜", value: "https://jable.tv/models/86b2f23f95cc485af79fe847c5b9de8d/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "小野夕子", value: "https://jable.tv/models/2958338aa4f78c0afb071e2b8a6b5f1b/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "大槻响", value: "https://jable.tv/models/hibiki-otsuki/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "藤森里穗", value: "https://jable.tv/models/riho-fujimori/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "JULIA", value: "https://jable.tv/models/julia/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "明里紬", value: "https://jable.tv/models/tsumugi-akari/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "桃乃木香奈", value: "https://jable.tv/models/momonogi-kana/?mode=async&function=get_block&block_id=list_videos_common_videos_list" }
]
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "post_date",
enumOptions: [
{ title: "最近更新", value: "post_date" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "playstyle",
title: "玩法",
type: "media_list",
functionName: "loadTagPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "玩法标签",
type: "enum",
value: "https://jable.tv/tags/outdoor/",
enumOptions: [
{ title: "露出", value: "https://jable.tv/tags/outdoor/" },
{ title: "集團進犯", value: "https://jable.tv/tags/gang-intrusion/" },
{ title: "進犯", value: "https://jable.tv/tags/intrusion/" },
{ title: "調教", value: "https://jable.tv/tags/tune/" },
{ title: "綑綁", value: "https://jable.tv/tags/bondage/" },
{ title: "瞬間插入", value: "https://jable.tv/tags/quickie/" },
{ title: "痴漢", value: "https://jable.tv/tags/chikan/" },
{ title: "痴女", value: "https://jable.tv/tags/chizyo/" },
{ title: "男M", value: "https://jable.tv/tags/masochism-guy/" },
{ title: "泥醉", value: "https://jable.tv/tags/crapulence/" },
{ title: "泡姬", value: "https://jable.tv/tags/soapland/" },
{ title: "母乳", value: "https://jable.tv/tags/breast-milk/" },
{ title: "放尿", value: "https://jable.tv/tags/piss/" },
{ title: "按摩", value: "https://jable.tv/tags/massage/" },
{ title: "多P", value: "https://jable.tv/tags/groupsex/" },
{ title: "刑具", value: "https://jable.tv/tags/grip/" },
{ title: "凌辱", value: "https://jable.tv/tags/insult/" },
{ title: "一日十回", value: "https://jable.tv/tags/10-times-a-day/" },
{ title: "3P", value: "https://jable.tv/tags/3p/" }
]
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "post_date",
enumOptions: [
{ title: "最近更新", value: "post_date" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "story",
title: "劇情",
type: "media_list",
functionName: "loadTagPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "劇情标签",
type: "enum",
value: "https://jable.tv/tags/black/",
enumOptions: [
{ title: "黑人", value: "https://jable.tv/tags/black/" },
{ title: "醜男", value: "https://jable.tv/tags/ugly-man/" },
{ title: "誘惑", value: "https://jable.tv/tags/temptation/" },
{ title: "親屬", value: "https://jable.tv/tags/kinship/" },
{ title: "童貞", value: "https://jable.tv/tags/virginity/" },
{ title: "時間停止", value: "https://jable.tv/tags/time-stop/" },
{ title: "復仇", value: "https://jable.tv/tags/avenge/" },
{ title: "年齡差", value: "https://jable.tv/tags/age-difference/" },
{ title: "巨漢", value: "https://jable.tv/tags/giant/" },
{ title: "媚藥", value: "https://jable.tv/tags/love-potion/" },
{ title: "夫目前犯", value: "https://jable.tv/tags/sex-beside-husband/" },
{ title: "出軌", value: "https://jable.tv/tags/affair/" },
{ title: "催眠", value: "https://jable.tv/tags/hypnosis/" },
{ title: "偷拍", value: "https://jable.tv/tags/private-cam/" },
{ title: "下雨天", value: "https://jable.tv/tags/rainy-day/" },
{ title: "NTR", value: "https://jable.tv/tags/ntr/" },
{ title: "風俗娘", value: "https://jable.tv/tags/club-hostess-and-sex-worker/" },
{ title: "醫生", value: "https://jable.tv/tags/doctor/" },
{ title: "逃犯", value: "https://jable.tv/tags/fugitive/" },
{ title: "護士", value: "https://jable.tv/tags/nurse/" },
{ title: "老師", value: "https://jable.tv/tags/teacher/" },
{ title: "空姐", value: "https://jable.tv/tags/flight-attendant/" },
{ title: "球隊經理", value: "https://jable.tv/tags/team-manager/" },
{ title: "未亡人", value: "https://jable.tv/tags/widow/" },
{ title: "搜查官", value: "https://jable.tv/tags/detective/" },
{ title: "情侶", value: "https://jable.tv/tags/couple/" },
{ title: "家政婦", value: "https://jable.tv/tags/housewife/" },
{ title: "家庭教師", value: "https://jable.tv/tags/private-teacher/" },
{ title: "偶像", value: "https://jable.tv/tags/idol/" },
{ title: "人妻", value: "https://jable.tv/tags/wife/" },
{ title: "主播", value: "https://jable.tv/tags/female-anchor/" },
{ title: "OL", value: "https://jable.tv/tags/ol/" },
{ title: "魔鏡號", value: "https://jable.tv/tags/magic-mirror/" },
{ title: "電車", value: "https://jable.tv/tags/tram/" },
{ title: "處女", value: "https://jable.tv/tags/first-night/" },
{ title: "監獄", value: "https://jable.tv/tags/prison/" },
{ title: "溫泉", value: "https://jable.tv/tags/hot-spring/" },
{ title: "洗浴場", value: "https://jable.tv/tags/bathing-place/" },
{ title: "泳池", value: "https://jable.tv/tags/swimming-pool/" },
{ title: "汽車", value: "https://jable.tv/tags/car/" },
{ title: "廁所", value: "https://jable.tv/tags/toilet/" },
{ title: "學校", value: "https://jable.tv/tags/school/" },
{ title: "圖書館", value: "https://jable.tv/tags/library/" },
{ title: "健身房", value: "https://jable.tv/tags/gym-room/" },
{ title: "便利店", value: "https://jable.tv/tags/store/" },
{ title: "錄像", value: "https://jable.tv/tags/video-recording/" },
{ title: "處女作/引退作", value: "https://jable.tv/tags/debut-retires/" },
{ title: "綜藝", value: "https://jable.tv/tags/variety-show/" },
{ title: "節日主題", value: "https://jable.tv/tags/festival/" },
{ title: "感謝祭", value: "https://jable.tv/tags/thanksgiving/" },
{ title: "4小時以上", value: "https://jable.tv/tags/more-than-4-hours/" }
]
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "post_date",
enumOptions: [
{ title: "最近更新", value: "post_date" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
// {
// id: "search_module",
// title: "网站搜索",
// type: "media_list",
// functionName: "searchVideos",
// cacheDuration: 300,
// timeoutSeconds: 30,
// params: [
// { name: "keyword", title: "关键词", type: "string", value: "" },
// {
// name: "sort_by",
// title: "排序",
// type: "enum",
// value: "post_date",
// enumOptions: [
// { title: "最多观看", value: "video_viewed" },
// { title: "近期最佳", value: "post_date_and_popularity" },
// { title: "最近更新", value: "post_date" },
// { title: "最多收藏", value: "most_favourited" }
// ]
// },
// { name: "from", title: "页码", type: "page", value: "1" }
// ]
// }
],
search: {
title: "搜索",
functionName: "searchVideos",
params: [
{ name: "keyword", title: "关键词", type: "string", value: "" },
{ name: "page", title: "页码", type: "page", value: "1" }
]
}
};
var JABLE_HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Referer": "https://jable.tv/"
};
function ensureArray(v) {
return Array.isArray(v) ? v : [];
}
function safeText(v) {
return String(v || "").replace(/\s+/g, " ").trim();
}
function absoluteUrl(url) {
if (!url) return "";
if (url.indexOf("http://") === 0 || url.indexOf("https://") === 0) return url;
return new URL(url, "https://jable.tv").toString();
}
async function fetchHtmlByBrowser(url) {
if (typeof fetch !== "function") return "";
try {
var response = await fetch(url, {
method: "GET",
credentials: "include",
cache: "no-store"
});
if (!response || !response.ok) return "";
return await response.text();
} catch (e) {
return "";
}
}
async function fetchHtml(url) {
var browserHtml = await fetchHtmlByBrowser(url);
if (browserHtml) return browserHtml;
var response = await Widget.http.get(url, { headers: JABLE_HEADERS, timeout: 30000 });
return String(response.data || "");
}
function mapJableItem(item) {
return {
id: String(item.id || item.link || item.title),
title: safeText(item.title || ""),
posterUrl: absoluteUrl(item.cover || ""),
backdropUrl: absoluteUrl(item.cover || ""),
description: item.duration ? ("时长: " + item.duration) : "",
mediaType: "movie",
link: absoluteUrl(item.link || "")
};
}
function parseJableList(htmlContent) {
var docId = Widget.dom.parse(htmlContent);
try {
var itemNodes = Widget.dom.select(docId, ".video-img-box");
var results = [];
var seen = {};
for (var i = 0; i < itemNodes.length; i++) {
var node = itemNodes[i];
var linkNode = Widget.dom.select(node, ".title a")[0];
if (!linkNode) continue;
var href = absoluteUrl((linkNode.attributes && linkNode.attributes.href) || "");
if (!href || seen[href]) continue;
seen[href] = true;
var imgNode = Widget.dom.select(node, "img")[0];
var durationNode = Widget.dom.select(node, ".absolute-bottom-right .label")[0];
results.push(mapJableItem({
id: href,
title: linkNode.text || "",
link: href,
cover: imgNode ? ((imgNode.attributes && (imgNode.attributes["data-src"] || imgNode.attributes.src || imgNode.attributes["data-original"])) || "") : "",
duration: durationNode ? (durationNode.text || "") : ""
}));
}
return results;
} finally {
Widget.dom.remove(docId);
}
}
async function loadPage(params) {
params = params || {};
var url = String(params.url || "");
if (!url) return [];
if (params.sort_by) url += "&sort_by=" + encodeURIComponent(params.sort_by);
if (params.from) url += "&from=" + encodeURIComponent(params.from);
try {
var html = await fetchHtml(url);
return ensureArray(parseJableList(html));
} catch (e) {
console.error("jable loadPage error", e && e.message ? e.message : e);
return [];
}
}
async function loadTagPage(params) {
params = params || {};
var url = String(params.url || "");
if (!url) return [];
if (params.sort_by) {
if (url.indexOf("?") >= 0) url += "&sort_by=" + encodeURIComponent(params.sort_by);
else url += "?sort_by=" + encodeURIComponent(params.sort_by);
}
if (params.from && String(params.from) !== "1") {
if (url.indexOf("?") >= 0) url += "&from=" + encodeURIComponent(params.from);
else url += "?from=" + encodeURIComponent(params.from);
}
try {
var html = await fetchHtml(url);
return ensureArray(parseJableList(html));
} catch (e) {
console.error("jable loadTagPage error", e && e.message ? e.message : e);
return [];
}
}
async function searchVideos(params) {
params = params || {};
var keyword = encodeURIComponent(params.keyword || "");
if (!keyword) return [];
var url = "https://jable.tv/search/" + keyword + "/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&q=" + keyword;
if (params.sort_by) url += "&sort_by=" + encodeURIComponent(params.sort_by);
if (params.from) url += "&from=" + encodeURIComponent(params.from);
try {
var html = await fetchHtml(url);
return ensureArray(parseJableList(html));
} catch (e) {
console.error("jable searchVideos error", e && e.message ? e.message : e);
return [];
}
}
async function loadDetail(link) {
try {
var html = await fetchHtmlByBrowser(link);
if (!html) {
var response = await Widget.http.get(link, { headers: JABLE_HEADERS, timeout: 30000 });
html = String(response.data || "");
}
var hlsUrl = "";
var key = "var hlsUrl = '";
var idx = html.indexOf(key);
if (idx >= 0) {
var start = idx + key.length;
var end = html.indexOf("'", start);
if (end > start) hlsUrl = html.slice(start, end);
}
if (!hlsUrl) {
key = 'var hlsUrl = "';
idx = html.indexOf(key);
if (idx >= 0) {
start = idx + key.length;
end = html.indexOf('"', start);
if (end > start) hlsUrl = html.slice(start, end);
}
}
if (!hlsUrl) {
var marker = '.m3u8';
var m3u8Index = html.indexOf(marker);
if (m3u8Index > 0) {
var begin = html.lastIndexOf('https://', m3u8Index);
if (begin < 0) begin = html.lastIndexOf('http://', m3u8Index);
if (begin >= 0) hlsUrl = html.slice(begin, m3u8Index + marker.length);
}
}
if (!hlsUrl) {
var sourceKey = '<source';
var sourceIdx = html.indexOf(sourceKey);
while (sourceIdx >= 0) {
var srcIdx = html.indexOf('src="', sourceIdx);
if (srcIdx < 0) srcIdx = html.indexOf("src='", sourceIdx);
if (srcIdx >= 0) {
var quote = html[srcIdx + 4];
var srcStart = srcIdx + 5;
var srcEnd = html.indexOf(quote, srcStart);
var candidate = srcEnd > srcStart ? html.slice(srcStart, srcEnd) : '';
if (candidate.indexOf('.m3u8') >= 0) {
hlsUrl = candidate;
break;
}
}
sourceIdx = html.indexOf(sourceKey, sourceIdx + sourceKey.length);
}
}
if (!hlsUrl) {
return { title: "无法获取有效的播放地址" };
}
hlsUrl = hlsUrl.split('\\/').join('/');
var docId = Widget.dom.parse(html);
try {
var title = "";
var metaTitle = Widget.dom.select(docId, 'meta[property="og:title"]')[0];
if (metaTitle && metaTitle.attributes && metaTitle.attributes.content) title = metaTitle.attributes.content;
if (!title) {
var titleNode = Widget.dom.select(docId, "title")[0];
title = titleNode ? safeText(titleNode.text) : "Jable";
}
var poster = "";
var metaImage = Widget.dom.select(docId, 'meta[property="og:image"]')[0];
if (metaImage && metaImage.attributes && metaImage.attributes.content) poster = absoluteUrl(metaImage.attributes.content);
return {
title: title || "Jable",
posterUrl: poster,
backdropUrl: poster,
videoUrl: hlsUrl,
playerType: "ijk",
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Referer": link,
"Origin": "https://jable.tv"
},
customHeaders: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Referer": link,
"Origin": "https://jable.tv"
}
};
} finally {
Widget.dom.remove(docId);
}
} catch (e) {
console.error("jable loadDetail error", e && e.message ? e.message : e);
return { title: "请求错误" };
}
}
+537
View File
@@ -0,0 +1,537 @@
var WidgetMetadata = {
id: "jable_capyplayer",
title: "Jable",
description: "Jable CapyPlayer 规范版,支持热门、最新、中文字幕、女优、玩法、剧情、搜索与播放解析。",
author: "Minis",
version: "3.0.3",
site: "https://jable.tv",
icon: "https://jable.tv/favicon.ico",
modules: [
{
id: "hot",
title: "热门",
type: "media_list",
functionName: "loadPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "列表地址",
type: "constant",
value: "https://jable.tv/hot/?mode=async&function=get_block&block_id=list_videos_common_videos_list"
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "video_viewed_today",
enumOptions: [
{ title: "今日热门", value: "video_viewed_today" },
{ title: "本周热门", value: "video_viewed_week" },
{ title: "本月热门", value: "video_viewed_month" },
{ title: "所有时间", value: "video_viewed" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "latest",
title: "最新",
type: "media_list",
functionName: "loadPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "列表地址",
type: "constant",
value: "https://jable.tv/new-release/?mode=async&function=get_block&block_id=list_videos_common_videos_list"
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "latest-updates",
enumOptions: [
{ title: "最新发布", value: "latest-updates" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "subtitle",
title: "中文",
type: "media_list",
functionName: "loadPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "列表地址",
type: "constant",
value: "https://jable.tv/categories/chinese-subtitle/?mode=async&function=get_block&block_id=list_videos_common_videos_list"
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "post_date",
enumOptions: [
{ title: "最近更新", value: "post_date" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "actress",
title: "女优",
type: "media_list",
functionName: "loadPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "选择女优",
type: "enum",
value: "https://jable.tv/s1/models/yua-mikami/?mode=async&function=get_block&block_id=list_videos_common_videos_list",
enumOptions: [
{ title: "三上悠亚", value: "https://jable.tv/s1/models/yua-mikami/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "枫可怜", value: "https://jable.tv/models/86b2f23f95cc485af79fe847c5b9de8d/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "小野夕子", value: "https://jable.tv/models/2958338aa4f78c0afb071e2b8a6b5f1b/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "大槻响", value: "https://jable.tv/models/hibiki-otsuki/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "藤森里穗", value: "https://jable.tv/models/riho-fujimori/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "JULIA", value: "https://jable.tv/models/julia/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "明里紬", value: "https://jable.tv/models/tsumugi-akari/?mode=async&function=get_block&block_id=list_videos_common_videos_list" },
{ title: "桃乃木香奈", value: "https://jable.tv/models/momonogi-kana/?mode=async&function=get_block&block_id=list_videos_common_videos_list" }
]
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "post_date",
enumOptions: [
{ title: "最近更新", value: "post_date" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "playstyle",
title: "玩法",
type: "media_list",
functionName: "loadTagPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "玩法标签",
type: "enum",
value: "https://jable.tv/tags/outdoor/",
enumOptions: [
{ title: "露出", value: "https://jable.tv/tags/outdoor/" },
{ title: "集團進犯", value: "https://jable.tv/tags/gang-intrusion/" },
{ title: "進犯", value: "https://jable.tv/tags/intrusion/" },
{ title: "調教", value: "https://jable.tv/tags/tune/" },
{ title: "綑綁", value: "https://jable.tv/tags/bondage/" },
{ title: "瞬間插入", value: "https://jable.tv/tags/quickie/" },
{ title: "痴漢", value: "https://jable.tv/tags/chikan/" },
{ title: "痴女", value: "https://jable.tv/tags/chizyo/" },
{ title: "男M", value: "https://jable.tv/tags/masochism-guy/" },
{ title: "泥醉", value: "https://jable.tv/tags/crapulence/" },
{ title: "泡姬", value: "https://jable.tv/tags/soapland/" },
{ title: "母乳", value: "https://jable.tv/tags/breast-milk/" },
{ title: "放尿", value: "https://jable.tv/tags/piss/" },
{ title: "按摩", value: "https://jable.tv/tags/massage/" },
{ title: "多P", value: "https://jable.tv/tags/groupsex/" },
{ title: "刑具", value: "https://jable.tv/tags/grip/" },
{ title: "凌辱", value: "https://jable.tv/tags/insult/" },
{ title: "一日十回", value: "https://jable.tv/tags/10-times-a-day/" },
{ title: "3P", value: "https://jable.tv/tags/3p/" }
]
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "post_date",
enumOptions: [
{ title: "最近更新", value: "post_date" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
{
id: "story",
title: "劇情",
type: "media_list",
functionName: "loadTagPage",
cacheDuration: 3600,
timeoutSeconds: 30,
params: [
{
name: "url",
title: "劇情标签",
type: "enum",
value: "https://jable.tv/tags/black/",
enumOptions: [
{ title: "黑人", value: "https://jable.tv/tags/black/" },
{ title: "醜男", value: "https://jable.tv/tags/ugly-man/" },
{ title: "誘惑", value: "https://jable.tv/tags/temptation/" },
{ title: "親屬", value: "https://jable.tv/tags/kinship/" },
{ title: "童貞", value: "https://jable.tv/tags/virginity/" },
{ title: "時間停止", value: "https://jable.tv/tags/time-stop/" },
{ title: "復仇", value: "https://jable.tv/tags/avenge/" },
{ title: "年齡差", value: "https://jable.tv/tags/age-difference/" },
{ title: "巨漢", value: "https://jable.tv/tags/giant/" },
{ title: "媚藥", value: "https://jable.tv/tags/love-potion/" },
{ title: "夫目前犯", value: "https://jable.tv/tags/sex-beside-husband/" },
{ title: "出軌", value: "https://jable.tv/tags/affair/" },
{ title: "催眠", value: "https://jable.tv/tags/hypnosis/" },
{ title: "偷拍", value: "https://jable.tv/tags/private-cam/" },
{ title: "下雨天", value: "https://jable.tv/tags/rainy-day/" },
{ title: "NTR", value: "https://jable.tv/tags/ntr/" },
{ title: "風俗娘", value: "https://jable.tv/tags/club-hostess-and-sex-worker/" },
{ title: "醫生", value: "https://jable.tv/tags/doctor/" },
{ title: "逃犯", value: "https://jable.tv/tags/fugitive/" },
{ title: "護士", value: "https://jable.tv/tags/nurse/" },
{ title: "老師", value: "https://jable.tv/tags/teacher/" },
{ title: "空姐", value: "https://jable.tv/tags/flight-attendant/" },
{ title: "球隊經理", value: "https://jable.tv/tags/team-manager/" },
{ title: "未亡人", value: "https://jable.tv/tags/widow/" },
{ title: "搜查官", value: "https://jable.tv/tags/detective/" },
{ title: "情侶", value: "https://jable.tv/tags/couple/" },
{ title: "家政婦", value: "https://jable.tv/tags/housewife/" },
{ title: "家庭教師", value: "https://jable.tv/tags/private-teacher/" },
{ title: "偶像", value: "https://jable.tv/tags/idol/" },
{ title: "人妻", value: "https://jable.tv/tags/wife/" },
{ title: "主播", value: "https://jable.tv/tags/female-anchor/" },
{ title: "OL", value: "https://jable.tv/tags/ol/" },
{ title: "魔鏡號", value: "https://jable.tv/tags/magic-mirror/" },
{ title: "電車", value: "https://jable.tv/tags/tram/" },
{ title: "處女", value: "https://jable.tv/tags/first-night/" },
{ title: "監獄", value: "https://jable.tv/tags/prison/" },
{ title: "溫泉", value: "https://jable.tv/tags/hot-spring/" },
{ title: "洗浴場", value: "https://jable.tv/tags/bathing-place/" },
{ title: "泳池", value: "https://jable.tv/tags/swimming-pool/" },
{ title: "汽車", value: "https://jable.tv/tags/car/" },
{ title: "廁所", value: "https://jable.tv/tags/toilet/" },
{ title: "學校", value: "https://jable.tv/tags/school/" },
{ title: "圖書館", value: "https://jable.tv/tags/library/" },
{ title: "健身房", value: "https://jable.tv/tags/gym-room/" },
{ title: "便利店", value: "https://jable.tv/tags/store/" },
{ title: "錄像", value: "https://jable.tv/tags/video-recording/" },
{ title: "處女作/引退作", value: "https://jable.tv/tags/debut-retires/" },
{ title: "綜藝", value: "https://jable.tv/tags/variety-show/" },
{ title: "節日主題", value: "https://jable.tv/tags/festival/" },
{ title: "感謝祭", value: "https://jable.tv/tags/thanksgiving/" },
{ title: "4小時以上", value: "https://jable.tv/tags/more-than-4-hours/" }
]
},
{
name: "sort_by",
title: "排序",
type: "enum",
value: "post_date",
enumOptions: [
{ title: "最近更新", value: "post_date" },
{ title: "最多观看", value: "video_viewed" },
{ title: "最多收藏", value: "most_favourited" }
]
},
{ name: "from", title: "页码", type: "page", value: "1" }
]
},
// {
// id: "search_module",
// title: "网站搜索",
// type: "media_list",
// functionName: "searchVideos",
// cacheDuration: 300,
// timeoutSeconds: 30,
// params: [
// { name: "keyword", title: "关键词", type: "string", value: "" },
// {
// name: "sort_by",
// title: "排序",
// type: "enum",
// value: "post_date",
// enumOptions: [
// { title: "最多观看", value: "video_viewed" },
// { title: "近期最佳", value: "post_date_and_popularity" },
// { title: "最近更新", value: "post_date" },
// { title: "最多收藏", value: "most_favourited" }
// ]
// },
// { name: "from", title: "页码", type: "page", value: "1" }
// ]
// }
],
search: {
title: "搜索",
functionName: "searchVideos",
params: [
{ name: "keyword", title: "关键词", type: "string", value: "" },
{ name: "page", title: "页码", type: "page", value: "1" }
]
}
};
var JABLE_HEADERS = {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Referer": "https://jable.tv/"
};
function ensureArray(v) {
return Array.isArray(v) ? v : [];
}
function safeText(v) {
return String(v || "").replace(/\s+/g, " ").trim();
}
function absoluteUrl(url) {
if (!url) return "";
if (url.indexOf("http://") === 0 || url.indexOf("https://") === 0) return url;
return new URL(url, "https://jable.tv").toString();
}
async function fetchHtmlByBrowser(url) {
if (typeof fetch !== "function") return "";
try {
var response = await fetch(url, {
method: "GET",
credentials: "include",
cache: "no-store"
});
if (!response || !response.ok) return "";
return await response.text();
} catch (e) {
return "";
}
}
async function fetchHtml(url) {
var browserHtml = await fetchHtmlByBrowser(url);
if (browserHtml) return browserHtml;
var response = await Widget.http.get(url, { headers: JABLE_HEADERS, timeout: 30000 });
return String(response.data || "");
}
function mapJableItem(item) {
return {
id: String(item.id || item.link || item.title),
title: safeText(item.title || ""),
posterUrl: absoluteUrl(item.cover || ""),
backdropUrl: absoluteUrl(item.cover || ""),
description: item.duration ? ("时长: " + item.duration) : "",
mediaType: "movie",
link: absoluteUrl(item.link || "")
};
}
function parseJableList(htmlContent) {
var docId = Widget.dom.parse(htmlContent);
try {
var itemNodes = Widget.dom.select(docId, ".video-img-box");
var results = [];
var seen = {};
for (var i = 0; i < itemNodes.length; i++) {
var node = itemNodes[i];
var linkNode = Widget.dom.select(node, ".title a")[0];
if (!linkNode) continue;
var href = absoluteUrl((linkNode.attributes && linkNode.attributes.href) || "");
if (!href || seen[href]) continue;
seen[href] = true;
var imgNode = Widget.dom.select(node, "img")[0];
var durationNode = Widget.dom.select(node, ".absolute-bottom-right .label")[0];
results.push(mapJableItem({
id: href,
title: linkNode.text || "",
link: href,
cover: imgNode ? ((imgNode.attributes && (imgNode.attributes["data-src"] || imgNode.attributes.src || imgNode.attributes["data-original"])) || "") : "",
duration: durationNode ? (durationNode.text || "") : ""
}));
}
return results;
} finally {
Widget.dom.remove(docId);
}
}
async function loadPage(params) {
params = params || {};
var url = String(params.url || "");
if (!url) return [];
if (params.sort_by) url += "&sort_by=" + encodeURIComponent(params.sort_by);
if (params.from) url += "&from=" + encodeURIComponent(params.from);
try {
var html = await fetchHtml(url);
return ensureArray(parseJableList(html));
} catch (e) {
console.error("jable loadPage error", e && e.message ? e.message : e);
return [];
}
}
async function loadTagPage(params) {
params = params || {};
var url = String(params.url || "");
if (!url) return [];
if (params.sort_by) {
if (url.indexOf("?") >= 0) url += "&sort_by=" + encodeURIComponent(params.sort_by);
else url += "?sort_by=" + encodeURIComponent(params.sort_by);
}
if (params.from && String(params.from) !== "1") {
if (url.indexOf("?") >= 0) url += "&from=" + encodeURIComponent(params.from);
else url += "?from=" + encodeURIComponent(params.from);
}
try {
var html = await fetchHtml(url);
return ensureArray(parseJableList(html));
} catch (e) {
console.error("jable loadTagPage error", e && e.message ? e.message : e);
return [];
}
}
async function searchVideos(params) {
params = params || {};
var keyword = encodeURIComponent(params.keyword || "");
if (!keyword) return [];
var url = "https://jable.tv/search/" + keyword + "/?mode=async&function=get_block&block_id=list_videos_videos_list_search_result&q=" + keyword;
if (params.sort_by) url += "&sort_by=" + encodeURIComponent(params.sort_by);
if (params.from) url += "&from=" + encodeURIComponent(params.from);
try {
var html = await fetchHtml(url);
return ensureArray(parseJableList(html));
} catch (e) {
console.error("jable searchVideos error", e && e.message ? e.message : e);
return [];
}
}
async function loadDetail(link) {
try {
var html = await fetchHtmlByBrowser(link);
if (!html) {
var response = await Widget.http.get(link, { headers: JABLE_HEADERS, timeout: 30000 });
html = String(response.data || "");
}
var hlsUrl = "";
var key = "var hlsUrl = '";
var idx = html.indexOf(key);
if (idx >= 0) {
var start = idx + key.length;
var end = html.indexOf("'", start);
if (end > start) hlsUrl = html.slice(start, end);
}
if (!hlsUrl) {
key = 'var hlsUrl = "';
idx = html.indexOf(key);
if (idx >= 0) {
start = idx + key.length;
end = html.indexOf('"', start);
if (end > start) hlsUrl = html.slice(start, end);
}
}
if (!hlsUrl) {
var marker = '.m3u8';
var m3u8Index = html.indexOf(marker);
if (m3u8Index > 0) {
var begin = html.lastIndexOf('https://', m3u8Index);
if (begin < 0) begin = html.lastIndexOf('http://', m3u8Index);
if (begin >= 0) hlsUrl = html.slice(begin, m3u8Index + marker.length);
}
}
if (!hlsUrl) {
var sourceKey = '<source';
var sourceIdx = html.indexOf(sourceKey);
while (sourceIdx >= 0) {
var srcIdx = html.indexOf('src="', sourceIdx);
if (srcIdx < 0) srcIdx = html.indexOf("src='", sourceIdx);
if (srcIdx >= 0) {
var quote = html[srcIdx + 4];
var srcStart = srcIdx + 5;
var srcEnd = html.indexOf(quote, srcStart);
var candidate = srcEnd > srcStart ? html.slice(srcStart, srcEnd) : '';
if (candidate.indexOf('.m3u8') >= 0) {
hlsUrl = candidate;
break;
}
}
sourceIdx = html.indexOf(sourceKey, sourceIdx + sourceKey.length);
}
}
if (!hlsUrl) {
return { title: "无法获取有效的播放地址" };
}
hlsUrl = hlsUrl.split('\\/').join('/');
var docId = Widget.dom.parse(html);
try {
var title = "";
var metaTitle = Widget.dom.select(docId, 'meta[property="og:title"]')[0];
if (metaTitle && metaTitle.attributes && metaTitle.attributes.content) title = metaTitle.attributes.content;
if (!title) {
var titleNode = Widget.dom.select(docId, "title")[0];
title = titleNode ? safeText(titleNode.text) : "Jable";
}
var poster = "";
var metaImage = Widget.dom.select(docId, 'meta[property="og:image"]')[0];
if (metaImage && metaImage.attributes && metaImage.attributes.content) poster = absoluteUrl(metaImage.attributes.content);
return {
title: title || "Jable",
posterUrl: poster,
backdropUrl: poster,
videoUrl: hlsUrl,
playerType: "ijk",
headers: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Referer": link,
"Origin": "https://jable.tv"
},
customHeaders: {
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
"Referer": link,
"Origin": "https://jable.tv"
}
};
} finally {
Widget.dom.remove(docId);
}
} catch (e) {
console.error("jable loadDetail error", e && e.message ? e.message : e);
return { title: "请求错误" };
}
}
+1599
View File
@@ -0,0 +1,1599 @@
const MISSAV_COOKIE = "";
var WidgetMetadata = {
id: "missav",
title: "MissAV",
description: "获取 MissAV 推荐",
version: "2.0.4",
requiredVersion: "0.0.2",
detailCacheDuration: 300,
site: "https://missav.ws",
icon: "https://missav.ws/favicon.ico",
modules: [
// {
// title: "搜索影片",
// description: "搜索 MissAV 影片内容",
// requiresWebView: true,
// functionName: "searchVideos",
// cacheDuration: 1800,
// params: [
// {
// name: "keyword",
// title: "搜索关键词",
// type: "input",
// description: "输入搜索关键词(演员名、番号、标题等)",
// value: ""
// },
// {
// name: "sort_by",
// title: "排序",
// type: "enumeration",
// description: "排序方式",
// value: "released_at",
// enumOptions: [
// { title: "发行日期", value: "released_at" },
// { title: "最近更新", value: "published_at" },
// { title: "收藏数", value: "saved" },
// { title: "今日浏览数", value: "today_views" },
// { title: "本周浏览数", value: "weekly_views" },
// { title: "本月浏览数", value: "monthly_views" },
// { title: "总浏览数", value: "views" }
// ]
// },
// { name: "page", title: "页码", type: "page", description: "页码", value: "1" }
// ]
// },
{
title: "今日热门",
description: "今日热门影片",
requiresWebView: true,
functionName: "loadTodayHot",
cacheDuration: 1800,
params: [
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "本周热门",
description: "本周热门影片",
requiresWebView: true,
functionName: "loadWeeklyHot",
cacheDuration: 1800,
params: [
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "本月热门",
description: "本月热门影片",
requiresWebView: true,
functionName: "loadMonthlyHot",
cacheDuration: 1800,
params: [
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "新作上市",
description: "新作上市影片",
requiresWebView: true,
functionName: "loadNewRelease",
cacheDuration: 1800,
params: [
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "中文字幕",
description: "中文字幕影片",
requiresWebView: true,
functionName: "loadChineseSubtitle",
cacheDuration: 1800,
params: [
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "无码影片库",
description: "无码影片各分类",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择分类",
type: "enumeration",
description: "选择分类",
enumOptions: [
{ title: "无码流出", value: "https://missav.ws/dm621/cn/uncensored-leak" },
{ title: "FC2", value: "https://missav.ws/dm99/cn/fc2" },
{ title: "HEYZO", value: "https://missav.ws/dm319995/cn/heyzo" },
{ title: "东京热", value: "https://missav.ws/dm29/cn/tokyohot" },
{ title: "Caribbeancom", value: "https://missav.ws/dm1271239/cn/caribbeancom" },
{ title: "Gachinco", value: "https://missav.ws/dm135/cn/gachinco" },
{ title: "XXX-AV", value: "https://missav.ws/dm29/cn/xxxav" },
{ title: "人妻斩", value: "https://missav.ws/dm24/cn/marriedslash" },
{ title: "顽皮 4610", value: "https://missav.ws/dm19/cn/naughty4610" },
{ title: "顽皮 0930", value: "https://missav.ws/dm22/cn/naughty0930" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "亚洲AV专区",
description: "亚洲AV各分类",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择分类",
type: "enumeration",
description: "选择分类",
enumOptions: [
{ title: "麻豆传媒", value: "https://missav.ws/dm34/cn/madou" },
{ title: "韩国直播", value: "https://missav.ws/cn/klive" },
{ title: "中国直播", value: "https://missav.ws/cn/clive" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "影片质量类",
description: "影片质量类 - 12个类型,748,863部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "高清 (248,852部)", value: "https://missav.ws/dm95/cn/genres/%E9%AB%98%E6%B8%85" },
{ title: "独家 (220,805部)", value: "https://missav.ws/dm136/cn/genres/%E7%8B%AC%E5%AE%B6" },
{ title: "单体作品 (185,259部)", value: "https://missav.ws/dm118/cn/genres/%E5%8D%95%E4%BD%93%E4%BD%9C%E5%93%81" },
{ title: "薄格 (93,610部)", value: "https://missav.ws/dm95/cn/genres/%E8%96%84%E6%A0%BC" },
{ title: "全高清 (FHD) (11928部)", value: "https://missav.ws/cn/genres/%E5%85%A8%E9%AB%98%E6%B8%85%20(FHD)" },
{ title: "低成本影片 (70部)", value: "https://missav.ws/cn/genres/%E4%BD%8E%E6%88%90%E6%9C%AC%E5%BD%B1%E7%89%87" },
{ title: "套装商品 (44部)", value: "https://missav.ws/cn/genres/%E5%A5%97%E8%A3%85%E5%95%86%E5%93%81" },
{ title: "限时特卖 (37部)", value: "https://missav.ws/cn/genres/%E9%99%90%E6%97%B6%E7%89%B9%E5%8D%96" },
{ title: "高清 (HD) (36部)", value: "https://missav.ws/cn/genres/%E9%AB%98%E6%B8%85%20%28HD%29" },
{ title: "协力作品 (32部)", value: "https://missav.ws/cn/genres/%E5%8D%8F%E5%8A%9B%E4%BD%9C%E5%93%81" },
{ title: "单一作品 (13部)", value: "https://missav.ws/cn/genres/%E5%8D%95%E4%B8%80%E4%BD%9C%E5%93%81" },
{ title: "仅限分发 (12部)", value: "https://missav.ws/cn/genres/%E4%BB%85%E9%99%90%E5%88%86%E5%8F%91" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "角色与身份",
description: "角色与身份 - 23个类型,609,543部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "人妻 (123,405部)", value: "https://missav.ws/dm67/cn/genres/%E4%BA%BA%E5%A6%BB" },
{ title: "熟女 (111,004部)", value: "https://missav.ws/dm107/cn/genres/%E7%86%9F%E5%A5%B3" },
{ title: "素人 (97,868部)", value: "https://missav.ws/dm95/cn/genres/%E7%B4%A0%E4%BA%BA" },
{ title: "美少女 (89,506部)", value: "https://missav.ws/dm93/cn/genres/%E7%BE%8E%E5%B0%91%E5%A5%B3" },
{ title: "痴女 (71,969部)", value: "https://missav.ws/dm68/cn/genres/%E7%97%B4%E5%A5%B3" },
{ title: "女高中生 (62,542部)", value: "https://missav.ws/dm61/cn/genres/%E5%A5%B3%E9%AB%98%E4%B8%AD%E7%94%9F" },
{ title: "秘书 (997部)", value: "https://missav.ws/dm63/cn/genres/秘书" },
{ title: "美丽的成熟女人 (135部)", value: "https://missav.ws/cn/genres/美丽的成熟女人" },
{ title: "妈妈朋友 (98部)", value: "https://missav.ws/cn/genres/%E5%A6%88%E5%A6%88%E6%9C%8B%E5%8F%8B" },
{ title: "M女人 (77部)", value: "https://missav.ws/dm1/cn/genres/M%E5%A5%B3%E4%BA%BA" },
{ title: "成熟的女人 (32部)", value: "https://missav.ws/cn/genres/%E6%88%90%E7%86%9F%E7%9A%84%E5%A5%B3%E4%BA%BA" },
{ title: "家庭主妇 (32部)", value: "https://missav.ws/cn/genres/%E5%AE%B6%E5%BA%AD%E4%B8%BB%E5%A6%87" },
{ title: "成熟女人 / 已婚女人 (29部)", value: "https://missav.ws/cn/genres/%E6%88%90%E7%86%9F%E5%A5%B3%E4%BA%BA%20/%20%E5%B7%B2%E5%A9%9A%E5%A5%B3%E4%BA%BA" },
{ title: "其他学生 (21部)", value: "https://missav.ws/cn/genres/%E5%85%B6%E4%BB%96%E5%AD%A6%E7%94%9F" },
{ title: "大小姐 (19部)", value: "https://missav.ws/dm69/cn/genres/%E5%A4%A7%E5%B0%8F%E5%A7%90" },
{ title: "公主 (18部)", value: "https://missav.ws/cn/genres/%E5%85%AC%E4%B8%BB" },
{ title: "美丽的女孩 (12部)", value: "https://missav.ws/dm89/cn/genres/%E7%BE%8E%E4%B8%BD%E7%9A%84%E5%A5%B3%E5%AD%A9" },
{ title: "新娘 / 年轻的妻子 (10部)", value: "https://missav.ws/cn/genres/%E6%96%B0%E5%A8%98%20/%20%E5%B9%B4%E8%BD%BB%E7%9A%84%E5%A6%BB%E5%AD%90" },
{ title: "养女 (10部)", value: "https://missav.ws/dm1/cn/genres/%E5%85%BB%E5%A5%B3" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "性行为类型",
description: "性行为类型 - 19个类型,759,620部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "中出 (198,292部)", value: "https://missav.ws/dm127/cn/genres/%E4%B8%AD%E5%87%BA" },
{ title: "口交 (95,026部)", value: "https://missav.ws/dm93/cn/genres/%E5%8F%A3%E4%BA%A4" },
{ title: "骑乘 (86,850部)", value: "https://missav.ws/dm82/cn/genres/%E9%AA%91%E4%B9%98" },
{ title: "潮吹 (73,825部)", value: "https://missav.ws/dm71/cn/genres/%E6%BD%AE%E5%90%B9" },
{ title: "乳交 (68,569部)", value: "https://missav.ws/dm67/cn/genres/%E4%B9%B3%E4%BA%A4" },
{ title: "颜射 (63,513部)", value: "https://missav.ws/dm59/cn/genres/%E9%A2%9C%E5%B0%84" },
{ title: "自慰 (60,648部)", value: "https://missav.ws/dm59/cn/genres/%E8%87%AA%E6%85%B0" },
{ title: "手淫 (58,635部)", value: "https://missav.ws/dm57/cn/genres/%E6%89%8B%E6%B7%AB" },
{ title: "内射精 (57部)", value: "https://missav.ws/dm77/cn/genres/%E5%86%85%E5%B0%84%E7%B2%BE" },
{ title: "极致高潮 (88部)", value: "https://missav.ws/dm19/cn/genres/%E6%9E%81%E8%87%B4%E9%AB%98%E6%BD%AE" },
{ title: "3P (26部)", value: "https://missav.ws/dm45/cn/genres/3P" },
{ title: "多人 (26部)", value: "https://missav.ws/cn/genres/%E5%A4%9A%E4%BA%BA" },
{ title: "狗狗式 (19部)", value: "https://missav.ws/cn/genres/%E7%8B%97%E7%8B%97%E5%BC%8F" },
{ title: "撒尿 (17部)", value: "https://missav.ws/cn/genres/%E6%92%92%E5%B0%BF" },
{ title: "盐吹 (16部)", value: "https://missav.ws/cn/genres/%E7%9B%90%E5%90%B9" },
{ title: "撒尿 (14部)", value: "https://missav.ws/dm12/cn/genres/%E6%92%92%E5%B0%BF" },
{ title: "3P / 4P (11部)", value: "https://missav.ws/cn/genres/3P%20/%204P" },
{ title: "洗澡 (26部)", value: "https://missav.ws/cn/genres/%E6%B4%97%E6%BE%A1" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "情节与主题",
description: "情节与主题 - 15个类型,363,926部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "企划 (67,686部)", value: "https://missav.ws/dm67/cn/genres/%E4%BC%81%E5%88%92" },
{ title: "乱伦 (56,481部)", value: "https://missav.ws/dm55/cn/genres/%E4%B9%B1%E4%BC%A6" },
{ title: "NTR (51,273部)", value: "https://missav.ws/dm51/cn/genres/NTR" },
{ title: "搭讪 (48,965部)", value: "https://missav.ws/dm48/cn/genres/%E6%90%AD%E8%AE%AA" },
{ title: "淫乱 (47,821部)", value: "https://missav.ws/dm47/cn/genres/%E6%B7%AB%E4%B9%B1" },
{ title: "剧情 (46,573部)", value: "https://missav.ws/dm46/cn/genres/%E5%89%A7%E6%83%85" },
{ title: "羞辱 (44,892部)", value: "https://missav.ws/dm44/cn/genres/%E7%BE%9E%E8%BE%B1" },
{ title: "妻子的出轨 / NTR / 戴绿帽子 (74部)", value: "https://missav.ws/cn/genres/%E5%A6%BB%E5%AD%90%E7%9A%84%E5%87%BA%E8%BD%A8%20/%20NTR%20/%20%E6%88%B4%E7%BB%BF%E5%B8%BD%E5%AD%90" },
{ title: "戴绿帽子 (39部)", value: "https://missav.ws/cn/genres/%E6%88%B4%E7%BB%BF%E5%B8%BD%E5%AD%90" },
{ title: "告白体验 (30部)", value: "https://missav.ws/cn/genres/%E5%91%8A%E7%99%BD%E4%BD%93%E9%AA%8C" },
{ title: "外遇妻子 / NTR / 戴绿帽子 (17部)", value: "https://missav.ws/cn/genres/%E5%A4%96%E9%81%87%E5%A6%BB%E5%AD%90%20/%20NTR%20/%20%E6%88%B4%E7%BB%BF%E5%B8%BD%E5%AD%90" },
{ title: "交往 (13部)", value: "https://missav.ws/cn/genres/%E4%BA%A4%E5%BE%80" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "特殊玩法类",
description: "特殊玩法 - 9个类型,85,102部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "多人运动 (53,962部)", value: "https://missav.ws/dm53/cn/genres/%E5%A4%9A%E4%BA%BA%E8%BF%90%E5%8A%A8" },
{ title: "拘束 (41,628部)", value: "https://missav.ws/dm41/cn/genres/%E6%8B%98%E6%9D%9F" },
{ title: "脏话 (63部)", value: "https://missav.ws/cn/genres/%E8%84%8F%E8%AF%9D" },
{ title: "催眠洗脑 (62部)", value: "https://missav.ws/cn/genres/%E5%82%AC%E7%9C%A0%E6%B4%97%E8%84%91" },
{ title: "口球 (51部)", value: "https://missav.ws/cn/genres/%E5%8F%A3%E7%90%83" },
{ title: "放置Play (31部)", value: "https://missav.ws/cn/genres/%E6%94%BE%E7%BD%AEPlay" },
{ title: "奴隶 (26部)", value: "https://missav.ws/dm6/cn/genres/%E5%A5%B4%E9%9A%B6" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "身材特征类",
description: "身材特征 - 14个类型,234,821部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "巨乳 (165,810部)", value: "https://missav.ws/dm112/cn/genres/%E5%B7%A8%E4%B9%B3" },
{ title: "苗条 (34,968部)", value: "https://missav.ws/dm34/cn/genres/%E8%8B%97%E6%9D%A1" },
{ title: "美乳 (33,527部)", value: "https://missav.ws/dm33/cn/genres/%E7%BE%8E%E4%B9%B3" },
{ title: "D罩杯 (79部)", value: "https://missav.ws/cn/genres/D%E7%BD%A9%E6%9D%AF" },
{ title: "背部 (73部)", value: "https://missav.ws/dm355/cn/genres/%E8%83%8C%E9%83%A8" },
{ title: "美丽的屁股 (60部)", value: "https://missav.ws/cn/genres/%E7%BE%8E%E4%B8%BD%E7%9A%84%E5%B1%81%E8%82%A1" },
{ title: "E罩杯以上的Judai(青少年) (55部)", value: "https://missav.ws/cn/genres/E%E7%BD%A9%E6%9D%AF%E4%BB%A5%E4%B8%8A%E7%9A%84Judai%EF%BC%88%E9%9D%92%E5%B0%91%E5%B9%B4%EF%BC%89" },
{ title: "甜屁股 (54部)", value: "https://missav.ws/cn/genres/%E7%94%9C%E5%B1%81%E8%82%A1" },
{ title: "美尻 (46部)", value: "https://missav.ws/cn/genres/%E7%BE%8E%E5%B0%BB" },
{ title: "性感的腿 (42部)", value: "https://missav.ws/cn/genres/%E6%80%A7%E6%84%9F%E7%9A%84%E8%85%BF" },
{ title: "大乳房 (31部)", value: "https://missav.ws/cn/genres/%E5%A4%A7%E4%B9%B3%E6%88%BF" },
{ title: "白皙的皮肤 (16部)", value: "https://missav.ws/cn/genres/%E7%99%BD%E7%9A%99%E7%9A%84%E7%9A%AE%E8%82%A4" },
{ title: "小乳房 (16部)", value: "https://missav.ws/cn/genres/%E5%B0%8F%E4%B9%B3%E6%88%BF" },
{ title: "皮肤黑 (44部)", value: "https://missav.ws/cn/genres/%E7%9A%AE%E8%82%A4%E9%BB%91" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "职业角色类",
description: "职业角色 - 8个类型,372部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "接待员 (97部)", value: "https://missav.ws/cn/genres/%E6%8E%A5%E5%BE%85%E5%91%98" },
{ title: "女导游 (92部)", value: "https://missav.ws/dm2/cn/genres/%E5%A5%B3%E5%AF%BC%E6%B8%B8" },
{ title: "啦啦队 (69部)", value: "https://missav.ws/cn/genres/%E5%95%A6%E5%95%A6%E9%98%9F" },
{ title: "空中小姐 CA (50部)", value: "https://missav.ws/cn/genres/%E7%A9%BA%E4%B8%AD%E5%B0%8F%E5%A7%90%20CA" },
{ title: "台湾模特儿 (20部)", value: "https://missav.ws/cn/genres/%E5%8F%B0%E6%B9%BE%E6%A8%A1%E7%89%B9%E5%84%BF" },
{ title: "迷你裙女警 (20部)", value: "https://missav.ws/dm1/cn/genres/%E8%BF%B7%E4%BD%A0%E8%A3%99%E5%A5%B3%E8%AD%A6" },
{ title: "色情明星 (14部)", value: "https://missav.ws/cn/genres/%E8%89%B2%E6%83%85%E6%98%8E%E6%98%9F" },
{ title: "演员 (10部)", value: "https://missav.ws/cn/genres/%E6%BC%94%E5%91%98" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "拍摄方式类",
description: "拍摄方式 - 6个类型,78,894部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "自拍 (39,847部)", value: "https://missav.ws/dm39/cn/genres/%E8%87%AA%E6%8B%8D" },
{ title: "偷拍 (38,924部)", value: "https://missav.ws/dm38/cn/genres/%E5%81%B7%E6%8B%8D" },
{ title: "第一次拍摄 (48部)", value: "https://missav.ws/cn/genres/%E7%AC%AC%E4%B8%80%E6%AC%A1%E6%8B%8D%E6%91%84" },
{ title: "主观性 (16部)", value: "https://missav.ws/cn/genres/%E4%B8%BB%E8%A7%82%E6%80%A7" },
{ title: "记录 (15部)", value: "https://missav.ws/cn/genres/%E8%AE%B0%E5%BD%95" },
{ title: "按摩 (15部)", value: "https://missav.ws/dm6/cn/genres/%E6%8C%89%E6%91%A9" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "时长合集类",
description: "时长与合集 - 3个类型,73,839部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "4小时以上 (37,685部)", value: "https://missav.ws/dm37/cn/genres/4%E5%B0%8F%E6%97%B6%E4%BB%A5%E4%B8%8A" },
{ title: "合集 (36,142部)", value: "https://missav.ws/dm36/cn/genres/%E5%90%88%E9%9B%86" },
{ title: "超过工作时间 4 小时 (12部)", value: "https://missav.ws/cn/genres/%E8%B6%85%E8%BF%87%E5%B7%A5%E4%BD%9C%E6%97%B6%E9%97%B4%204%20%E5%B0%8F%E6%97%B6" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "服装造型类",
description: "服装与造型 - 13个类型,657部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "裙子单声道 (75部)", value: "https://missav.ws/cn/genres/%E8%A3%99%E5%AD%90%E5%8D%95%E5%A3%B0%E9%81%93" },
{ title: "浴衣 (72部)", value: "https://missav.ws/dm1/cn/genres/%E6%B5%B4%E8%A1%A3" },
{ title: "中长发 (69部)", value: "https://missav.ws/dm1/cn/genres/%E4%B8%AD%E9%95%BF%E5%8F%91" },
{ title: "连裤袜的事 (67部)", value: "https://missav.ws/cn/genres/%E8%BF%9E%E8%A3%A4%E8%A2%9C%E7%9A%84%E4%BA%8B" },
{ title: "面具 (85部)", value: "https://missav.ws/cn/genres/%E9%9D%A2%E5%85%B7" },
{ title: "靴子 (44部)", value: "https://missav.ws/cn/genres/%E9%9D%B4%E5%AD%90" },
{ title: "卷发 (37部)", value: "https://missav.ws/cn/genres/%E5%8D%B7%E5%8F%91" },
{ title: "高跟鞋 (36部)", value: "https://missav.ws/cn/genres/%E9%AB%98%E8%B7%9F%E9%9E%8B" },
{ title: "围裙 (31部)", value: "https://missav.ws/dm25/cn/genres/%E5%9B%B4%E8%A3%99" },
{ title: "金发 (51部)", value: "https://missav.ws/cn/genres/%E9%87%91%E5%8F%91" },
{ title: "啡发 (76部)", value: "https://missav.ws/cn/genres/%E5%95%A1%E5%8F%91" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
},
{
title: "特殊题材类",
description: "特殊题材 - 25个类型,901部影片",
requiresWebView: true,
functionName: "loadPage",
cacheDuration: 1800,
params: [
{
name: "url",
title: "选择类型",
type: "enumeration",
description: "选择具体类型",
enumOptions: [
{ title: "SF (95部)", value: "https://missav.ws/cn/genres/SF" },
{ title: "洛丽塔 (83部)", value: "https://missav.ws/cn/genres/%E6%B4%9B%E4%B8%BD%E5%A1%94" },
{ title: "御宅 (82部)", value: "https://missav.ws/cn/genres/%E5%BE%A1%E5%AE%85" },
{ title: "魔法少女 (75部)", value: "https://missav.ws/cn/genres/%E9%AD%94%E6%B3%95%E5%B0%91%E5%A5%B3" },
{ title: "游戏现实版 (39部)", value: "https://missav.ws/cn/genres/%E6%B8%B8%E6%88%8F%E7%8E%B0%E5%AE%9E%E7%89%88" },
{ title: "3D (38部)", value: "https://missav.ws/dm24/cn/genres/3D" },
{ title: "AI生成的作品 (37部)", value: "https://missav.ws/cn/genres/AI%E7%94%9F%E6%88%90%E7%9A%84%E4%BD%9C%E5%93%81" },
{ title: "动漫人物 (35部)", value: "https://missav.ws/cn/genres/%E5%8A%A8%E6%BC%AB%E4%BA%BA%E7%89%A9" },
{ title: "虚拟现实 (35部)", value: "https://missav.ws/cn/genres/%E8%99%9A%E6%8B%9F%E7%8E%B0%E5%AE%9E" },
{ title: "动画 (14部)", value: "https://missav.ws/cn/genres/%E5%8A%A8%E7%94%BB" },
{ title: "偶像 (13部)", value: "https://missav.ws/cn/genres/%E5%81%B6%E5%83%8F" },
{ title: "透过偶像 (32部)", value: "https://missav.ws/cn/genres/%E9%80%8F%E8%BF%87%E5%81%B6%E5%83%8F" }
]
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
}
],
search: {
title: "搜索",
functionName: "searchVideos",
params: [
{
name: "keyword",
title: "搜索关键词",
type: "input",
description: "输入搜索关键词(演员名、番号、标题等)",
value: ""
},
{
name: "sort_by",
title: "排序",
type: "enumeration",
description: "排序方式",
value: "released_at",
enumOptions: [
{ title: "发行日期", value: "released_at" },
{ title: "最近更新", value: "published_at" },
{ title: "收藏数", value: "saved" },
{ title: "今日浏览数", value: "today_views" },
{ title: "本周浏览数", value: "weekly_views" },
{ title: "本月浏览数", value: "monthly_views" },
{ title: "总浏览数", value: "views" }
]
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
}
};
const MISSAV_SITE = "https://missav.ws";
const MISSAV_UA = "Mozilla/5.0 (iPhone; CPU iPhone OS 18_2 like Mac OS X) AppleWebKit/604.1.14 (KHTML, like Gecko)";
const MISSAV_BROWSER_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36 Edg/149.0.0.0";
async function searchVideos(params = {}) {
const keyword = params.keyword ? params.keyword.trim() : '';
const page = parseInt(params.page) || 1;
const sortBy = params.sort_by;
if (!keyword) {
return [createPlaceholderItem("请输入搜索关键词")];
}
const isVideoCode = /^[A-Za-z]+-?\d+$/i.test(keyword);
const encodedKeyword = encodeURIComponent(keyword);
let url = `${MISSAV_SITE}/cn/search/${encodedKeyword}`;
let hasParams = false;
if (sortBy) {
url += `?sort=${sortBy}`;
hasParams = true;
}
if (page > 1) {
url += hasParams ? `&page=${page}` : `?page=${page}`;
}
const searchResults = await fetchVideoList(url, params);
if (isOnlyPlaceholderItem(searchResults)) {
return searchResults;
}
if (!searchResults.length) {
return [createPlaceholderItem(`未找到与「${keyword}」相关的内容`)];
}
if (isVideoCode && searchResults.length > 0) {
const normalizedKeyword = keyword.toUpperCase().replace(/-/g, '');
const filteredResults = searchResults.filter(video => {
const videoCode = extractVideoCodeFromTitle(video.title) || extractVideoCodeFromDescription(video.description);
if (videoCode) {
const normalizedVideoCode = videoCode.toUpperCase().replace(/-/g, '');
return normalizedVideoCode === normalizedKeyword;
}
return false;
});
if (filteredResults.length === 0) {
return [createPlaceholderItem(`未找到与「${keyword}」相关的内容`)];
}
return filteredResults;
}
return searchResults;
}
async function loadTodayHot(params = {}) {
const page = parseInt(params.page) || 1;
let url = `${MISSAV_SITE}/dm242/cn/today-hot?sort=today_views`;
if (page > 1) {
url += `&page=${page}`;
}
return await fetchVideoList(url, params);
}
async function loadWeeklyHot(params = {}) {
const page = parseInt(params.page) || 1;
let url = `${MISSAV_SITE}/dm168/cn/weekly-hot?sort=weekly_views`;
if (page > 1) {
url += `&page=${page}`;
}
return await fetchVideoList(url, params);
}
async function loadMonthlyHot(params = {}) {
const page = parseInt(params.page) || 1;
let url = `${MISSAV_SITE}/dm207/cn/monthly-hot?sort=monthly_views`;
if (page > 1) {
url += `&page=${page}`;
}
return await fetchVideoList(url, params);
}
async function loadNewRelease(params = {}) {
const page = parseInt(params.page) || 1;
let url = `${MISSAV_SITE}/dm509/cn/release?sort=released_at`;
if (page > 1) {
url += `&page=${page}`;
}
return await fetchVideoList(url, params);
}
async function loadChineseSubtitle(params = {}) {
const page = parseInt(params.page) || 1;
const sortBy = params.sort_by || "released_at";
let url = `${MISSAV_SITE}/dm265/cn/chinese-subtitle`;
let hasParams = false;
if (sortBy) {
url += `?sort=${sortBy}`;
hasParams = true;
}
if (page > 1) {
url += hasParams ? `&page=${page}` : `?page=${page}`;
}
return await fetchVideoList(url, params);
}
async function loadPage(params = {}) {
const baseUrl = params.url;
const page = parseInt(params.page) || 1;
const sortBy = params.sort_by;
let url = baseUrl;
let hasParams = false;
if (sortBy) {
url += `?sort=${sortBy}`;
hasParams = true;
}
if (page > 1) {
url += hasParams ? `&page=${page}` : `?page=${page}`;
}
return await fetchVideoList(url, params);
}
function withCookie(headers, params) {
headers = Object.assign({}, headers || {});
const cookie = String((params && params.cookie) || MISSAV_COOKIE || "").trim();
if (cookie) headers["Cookie"] = cookie;
return headers;
}
function getCookie(params) {
return String((params && params.cookie) || MISSAV_COOKIE || "").trim();
}
function hasCookieName(cookie, name) {
return new RegExp("(?:^|;\\s*)" + name + "=").test(cookie);
}
function hasClearanceCookie(params) {
return hasCookieName(getCookie(params), "cf_clearance");
}
function looksLikePartialCloudflareCookie(params) {
const cookie = getCookie(params);
return !!cookie && hasCookieName(cookie, "_cfuvid") && !hasClearanceCookie(params);
}
function sleep(ms) {
if (typeof setTimeout !== "function") return Promise.resolve();
return new Promise(resolve => setTimeout(resolve, ms));
}
function isBlockedHtml(data) {
const text = String(data || "");
return !text ||
text.includes("Just a moment") ||
text.includes("cf-browser-verification") ||
text.includes("Attention Required") ||
text.includes("Cloudflare");
}
function getHttpStatus(error) {
if (!error) return 0;
const response = error.response || error.Response || {};
return error.status || error.statusCode || response.status || response.statusCode || 0;
}
function isRetryableMissavError(error) {
const status = getHttpStatus(error);
const text = String((error && (error.message || (error.toString && error.toString()))) || "");
return status === 403 ||
status === 429 ||
text.includes("status code of 403") ||
text.includes("status code of 429");
}
function blockedMessage(error, params) {
const status = getHttpStatus(error);
if ((status === 403 || String(error || "").includes("403")) && looksLikePartialCloudflareCookie(params)) {
return "访问失败:CK 可能不完整,建议提取包含 cf_clearance 的整段 Cookie";
}
if (status === 429) {
return "访问过于频繁,请稍后再试";
}
return "访问失败,可能已被风控";
}
function getMissavRequestOptions(headers) {
return {
headers: headers || {},
timeout: 8000,
allow_redirects: true,
useBrowserCookie: true,
attachBrowserCookie: true,
useBrowserFallback: true,
browserFallback: true,
allowBrowserFallback: true
};
}
function getMissavResponseHtml(response) {
if (!response) return "";
if (typeof response.data === "string") return response.data;
if (typeof response.html === "string") return response.html;
if (typeof response.body === "string") return response.body;
return "";
}
function normalizeMissavResponse(response) {
const html = getMissavResponseHtml(response);
if (!html || isBlockedHtml(html)) return null;
if (response && response.data === html) return response;
return Object.assign({}, response || {}, { data: html });
}
async function requestMissavPage(url, params, referer) {
const requestUrl = normalizeMissavUrl(url);
const response = await Widget.http.get(
requestUrl,
getMissavRequestOptions(withCookie(getPageHeaders(referer), params))
);
const normalized = normalizeMissavResponse(response);
if (normalized) return normalized;
throw new Error("blocked or empty response");
}
async function requestMissavListPage(url, params, referer) {
const requestUrl = normalizeMissavUrl(url);
const uaList = getCookie(params) ? [MISSAV_BROWSER_UA, MISSAV_UA] : [MISSAV_UA, MISSAV_BROWSER_UA];
let lastError = null;
for (const ua of uaList) {
try {
const response = await Widget.http.get(
requestUrl,
getMissavRequestOptions(withCookie(getPageHeaders(referer, ua), params))
);
const normalized = normalizeMissavResponse(response);
if (normalized) return normalized;
lastError = new Error("blocked or empty response");
} catch (error) {
lastError = error;
}
}
throw lastError || new Error("request failed");
}
function normalizeMissavUrl(url) {
return String(url || "").trim().replace(/^https?:\/\/missav\.ai/i, MISSAV_SITE);
}
function stripMissavDmPrefix(url) {
const current = normalizeMissavUrl(url);
return current.replace(/^https?:\/\/missav\.ws\/dm\d+\//i, MISSAV_SITE + "/");
}
function decodeUrlOnce(url) {
try {
return decodeURI(url);
} catch (e) {
return url;
}
}
function uniqueUrls(urls) {
const seen = {};
return urls.filter(url => {
if (!url || seen[url]) return false;
seen[url] = true;
return true;
});
}
function getListRequestUrls(url) {
const canonical = stripMissavDmPrefix(url);
return uniqueUrls([
canonical,
decodeUrlOnce(canonical)
]);
}
async function fetchVideoList(url, params) {
let lastError = null;
const urls = getListRequestUrls(url);
for (const requestUrl of urls) {
try {
const response = await requestMissavListPage(requestUrl, params, MISSAV_SITE + "/");
return parseVideoList(response.data, params);
} catch (error) {
lastError = error;
if (!isRetryableMissavError(error)) throw error;
}
}
try {
if (lastError) throw lastError;
const response = await requestMissavListPage(url, params, MISSAV_SITE + "/");
return parseVideoList(response.data, params);
} catch (error) {
return [createPlaceholderItem(blockedMessage(error, params))];
}
}
function createPlaceholderItem(message = "已被风控,请稍后重试") {
const isSearchEmpty = String(message || "").includes("未找到与");
const description = isSearchEmpty
? `${message}\n请检查关键词是否拼写正确或更换关键词`
: `${message}\n请填写cookie、检查网络连接或稍后再试`;
return {
id: "content-placeholder",
type: "placeholder",
title: "🚫 " + message,
backdropPath: "https://via.placeholder.com/400x225/FF6B6B/FFFFFF?text=%E5%B7%B2%E8%A2%AB%E9%A3%8E%E6%8E%A7",
mediaType: "placeholder",
duration: 0,
durationText: "⚠️ 访问受限",
previewUrl: "",
videoUrl: "",
link: "",
description: description,
playerType: "none"
};
}
function isOnlyPlaceholderItem(items) {
return Array.isArray(items) &&
items.length === 1 &&
items[0] &&
items[0].type === "placeholder";
}
function parseVideoList(html, params) {
const $ = Widget.html.load(html);
const videos = [];
const cookie = String((params && params.cookie) || MISSAV_COOKIE || "").trim();
$('a[href]').each((index, element) => {
const $link = $(element);
const href = $link.attr('href') || '';
const $img = $link.find('img').first();
if ($img.length && href.match(/\/cn\/[a-zA-Z0-9\-]+(-uncensored-leak)?$/)) {
const imgSrc = $img.attr('data-src') || $img.attr('src');
if (imgSrc) {
let title = $link.attr('title') || $img.attr('alt') || '';
if (!title) {
const $parent = $link.closest('div');
title = $parent.find('h1, h2, h3, .title, [class*="title"]').first().text().trim();
}
if (!title) {
title = $link.text().trim();
}
const videoId = extractVideoId(href);
const fullVideoUrl = href.startsWith('http') ? href : `${MISSAV_SITE}${href}`;
const coverUrl = normalizeImageUrl(imgSrc) || `https://fourhoi.com/${videoId}/cover-t.jpg`;
let videoCode = videoId.toUpperCase().replace('-CHINESE-SUBTITLE', '').replace('-UNCENSORED-LEAK', '');
if (title && !title.match(/[A-Z]+-\d+/)) {
title = `${videoCode} ${title}`;
} else if (!title) {
title = videoCode;
}
videos.push({
id: fullVideoUrl,
type: "movie",
title: title || `${videoCode}`,
backdropPath: coverUrl,
posterPath: coverUrl,
mediaType: "movie",
duration: 0,
durationText: "",
previewUrl: "",
videoUrl: "",
url: "",
playUrl: "",
link: fullVideoUrl,
detailUrl: fullVideoUrl,
cookie: cookie,
description: `番号: ${videoCode}`,
playerType: "none"
});
}
}
});
return videos;
}
function extractVideoId(url) {
const matches = url.match(/\/cn\/([a-zA-Z0-9\-]+)(-uncensored-leak)?$/);
return matches ? matches[1] : url.split('/').pop();
}
function normalizeImageUrl(url) {
url = String(url || "").trim();
if (!url) return "";
if (url.startsWith("//")) return "https:" + url;
if (/^https?:\/\//i.test(url)) return url;
if (url.startsWith("/")) return MISSAV_SITE + url;
return url;
}
function extractVideoCodeFromTitle(title) {
if (!title) return null;
const match = title.match(/^([A-Za-z]+-?\d+)/i);
return match ? match[1] : null;
}
function extractVideoCodeFromDescription(description) {
if (!description) return null;
const match = description.match(/番号:\s*([A-Za-z]+-?\d+)/i);
return match ? match[1] : null;
}
function getPageHeaders(referer, ua) {
const headers = {
"User-Agent": ua || MISSAV_UA,
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
"Referer": referer || MISSAV_SITE + "/"
};
if ((ua || MISSAV_UA) === MISSAV_BROWSER_UA) {
headers["Sec-Ch-Ua"] = "\"Microsoft Edge\";v=\"149\", \"Chromium\";v=\"149\", \"Not(A:Brand\";v=\"24\"";
headers["Sec-Ch-Ua-Mobile"] = "?0";
headers["Sec-Ch-Ua-Platform"] = "\"Windows\"";
headers["Sec-Fetch-Dest"] = "document";
headers["Sec-Fetch-Mode"] = "navigate";
headers["Sec-Fetch-Site"] = "same-origin";
headers["Upgrade-Insecure-Requests"] = "1";
}
return headers;
}
function getPlayHeaders(referer) {
return {
"User-Agent": MISSAV_UA,
"Referer": referer || MISSAV_SITE + "/",
"Origin": MISSAV_SITE
};
}
function extractMissavUuid(html) {
html = String(html || "");
const nineyuMatch = html.match(/nineyu\.com\\?\/([a-f0-9-]{36})\\?\/seek\\?\/_0\.jpg/i);
if (nineyuMatch && nineyuMatch[1]) return nineyuMatch[1];
const surritMatch = html.match(/https?:\\?\/\\?\/surrit\.com\\?\/([a-f0-9-]{36})\\?\//i);
if (surritMatch && surritMatch[1]) return surritMatch[1];
const uuidMatches = html.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/ig);
return uuidMatches && uuidMatches.length ? uuidMatches[0] : "";
}
function parseHlsAttributeList(attributeText) {
const attributes = {};
const text = String(attributeText || "");
let token = "";
let inQuotes = false;
for (let i = 0; i <= text.length; i++) {
const char = i < text.length ? text[i] : ",";
if (char === '"' && text[i - 1] !== "\\") {
inQuotes = !inQuotes;
token += char;
continue;
}
if (char === "," && !inQuotes) {
const part = token.trim();
token = "";
if (!part) continue;
const equalsIndex = part.indexOf("=");
if (equalsIndex <= 0) continue;
const key = part.slice(0, equalsIndex).trim().toUpperCase();
let value = part.slice(equalsIndex + 1).trim();
if (value.length >= 2 && value[0] === '"' && value[value.length - 1] === '"') {
value = value.slice(1, -1).replace(/\\"/g, '"');
}
attributes[key] = value;
continue;
}
token += char;
}
return attributes;
}
function parsePositiveInteger(value) {
const number = parseInt(String(value || ""), 10);
return isFinite(number) && number > 0 ? number : 0;
}
function parseHlsResolution(resolutionText, uri) {
let width = 0;
let height = 0;
const declared = String(resolutionText || "").match(/(\d{2,5})\s*x\s*(\d{2,5})/i);
if (declared) {
width = parsePositiveInteger(declared[1]);
height = parsePositiveInteger(declared[2]);
}
const pathText = String(uri || "");
if (!width || !height) {
const pathResolution = pathText.match(/(?:^|[\/_\-.])(\d{2,5})x(\d{2,5})(?:[\/_\-.]|$)/i);
if (pathResolution) {
width = parsePositiveInteger(pathResolution[1]);
height = parsePositiveInteger(pathResolution[2]);
}
}
if (!height) {
const heightOnly = pathText.match(/(?:^|[\/_\-.])(\d{3,4})p(?:[\/_\-.]|$)/i);
if (heightOnly) {
height = parsePositiveInteger(heightOnly[1]);
width = height ? Math.round(height * 16 / 9) : 0;
}
}
return {
width: width,
height: height,
pixels: width > 0 && height > 0 ? width * height : 0
};
}
function resolveHlsUri(uri, playlistUrl) {
const value = String(uri || "").trim();
const base = String(playlistUrl || "").trim();
if (!value) return "";
if (/^https?:\/\//i.test(value)) return value;
if (/^\/\//.test(value)) return "https:" + value;
try {
if (typeof URL !== "undefined") {
return new URL(value, base).toString();
}
} catch (e) {
// Fall through to the small URL resolver below.
}
const baseWithoutQuery = base.replace(/[?#].*$/, "");
const originMatch = baseWithoutQuery.match(/^(https?:\/\/[^/]+)/i);
if (value[0] === "/" && originMatch) {
return originMatch[1] + value;
}
const slashIndex = baseWithoutQuery.lastIndexOf("/");
const directory = slashIndex >= 0 ? baseWithoutQuery.slice(0, slashIndex + 1) : baseWithoutQuery + "/";
return directory + value.replace(/^\.\//, "");
}
function parseMasterVariants(masterText, playlistUrl) {
const lines = String(masterText || "").split(/\r?\n/);
const variants = [];
for (let i = 0; i < lines.length; i++) {
const line = String(lines[i] || "").trim();
if (!/^#EXT-X-STREAM-INF\s*:/i.test(line)) continue;
const attributes = parseHlsAttributeList(line.slice(line.indexOf(":") + 1));
let uri = "";
let uriIndex = i + 1;
while (uriIndex < lines.length) {
const candidate = String(lines[uriIndex] || "").trim();
if (!candidate) {
uriIndex++;
continue;
}
if (candidate[0] === "#") {
uriIndex++;
continue;
}
uri = candidate;
break;
}
if (!uri) continue;
const resolution = parseHlsResolution(attributes.RESOLUTION, uri);
const averageBandwidth = parsePositiveInteger(attributes["AVERAGE-BANDWIDTH"]);
const peakBandwidth = parsePositiveInteger(attributes.BANDWIDTH);
variants.push({
url: resolveHlsUri(uri, playlistUrl),
uri: uri,
codecs: String(attributes.CODECS || ""),
resolution: String(attributes.RESOLUTION || ""),
width: resolution.width,
height: resolution.height,
pixels: resolution.pixels,
bandwidth: averageBandwidth || peakBandwidth,
peakBandwidth: peakBandwidth,
frameRate: parseFloat(String(attributes["FRAME-RATE"] || "0")) || 0,
videoRange: String(attributes["VIDEO-RANGE"] || ""),
order: variants.length
});
i = uriIndex;
}
return variants;
}
function rankHighestQualityVariant(masterText, playlistUrl) {
const variants = parseMasterVariants(masterText, playlistUrl);
if (!variants.length) return null;
variants.sort((left, right) => {
const qualityFields = ["pixels", "height", "width", "bandwidth", "peakBandwidth", "frameRate", "order"];
for (const field of qualityFields) {
const difference = Number(right[field] || 0) - Number(left[field] || 0);
if (difference) return difference;
}
return 0;
});
return variants[0];
}
function choosePlayerTypeForVariant(variant) {
const codecs = String((variant && variant.codecs) || "").toLowerCase();
const hasAvc = /(^|[,\s])(avc1|avc3|h264)(?:\.|[,\s]|$)/i.test(codecs);
const hasMpvPreferredCodec = /(^|[,\s])(hvc1|hev1|hevc|h265|av01|av1|vp09|vp9|dvhe|dvh1)(?:\.|[,\s]|$)/i.test(codecs);
if (hasAvc && !hasMpvPreferredCodec) {
return "ijk";
}
return "system";
}
function pickBestM3u8(masterText, uuid) {
const playlistUrl = `https://surrit.com/${uuid}/playlist.m3u8`;
const selected = rankHighestQualityVariant(masterText, playlistUrl);
return selected && selected.url ? selected.url : playlistUrl;
}
async function resolveMissavPlayback(html, detailUrl) {
const uuid = extractMissavUuid(html);
if (!uuid) {
return {
url: "",
playerType: "none",
codecs: "",
resolution: "",
bandwidth: 0
};
}
const playlistUrl = `https://surrit.com/${uuid}/playlist.m3u8`;
try {
const response = await Widget.http.get(playlistUrl, {
headers: getPlayHeaders(detailUrl)
});
const masterText = (response && response.data) || "";
const selected = rankHighestQualityVariant(masterText, playlistUrl);
if (!selected || !selected.url) {
return {
url: playlistUrl,
playerType: "system",
codecs: "",
resolution: "",
bandwidth: 0
};
}
const playerType = choosePlayerTypeForVariant(selected);
const resolution = selected.resolution || (selected.width && selected.height ? `${selected.width}x${selected.height}` : "");
console.log(`[MissAV] highest quality selected: ${resolution || "unknown"}, engine hint: ${playerType === "ijk" ? "MDK" : "Auto/MPV"}`);
return {
url: selected.url,
playerType: playerType,
codecs: selected.codecs,
resolution: resolution,
bandwidth: selected.bandwidth
};
} catch (e) {
console.warn("[MissAV] master playlist inspection failed; keeping the master URL with Auto engine selection");
return {
url: playlistUrl,
playerType: "system",
codecs: "",
resolution: "",
bandwidth: 0
};
}
}
async function resolveMissavVideoUrl(html, detailUrl) {
const playback = await resolveMissavPlayback(html, detailUrl);
return playback.url || "";
}
async function loadDetail(link) {
try {
let originalLink = link;
let coverFromList = "";
let cookieFromList = "";
if (link && typeof link === "object") {
originalLink = link.detailUrl || link.link || link.id || link.url || "";
coverFromList = link.backdropPath || link.posterPath || "";
cookieFromList = link.cookie || MISSAV_COOKIE || "";
}
link = normalizeMissavUrl(String(originalLink || ""));
const response = await requestMissavPage(link, { cookie: cookieFromList }, MISSAV_SITE + "/");
const videoId = extractVideoId(link);
const videoCode = videoId.toUpperCase().replace('-CHINESE-SUBTITLE', '').replace('-UNCENSORED-LEAK', '');
const fallbackCover = normalizeImageUrl(coverFromList) || `https://fourhoi.com/${videoId}/cover-t.jpg`;
if (!response || !response.data || response.data.includes('Just a moment')) {
return {
id: link,
type: "detail",
videoUrl: "",
url: "",
playUrl: "",
title: `${videoCode}`,
description: `番号: ${videoCode}\n播放页被风控,未解析到 m3u8`,
posterPath: fallbackCover,
backdropPath: fallbackCover,
mediaType: "movie",
duration: 0,
durationText: "",
previewUrl: "",
playerType: "none",
link: link
};
}
const $ = Widget.html.load(response.data);
let title = $('meta[property="og:title"]').attr('content') || '';
if (!title) {
title = $('h1').first().text().trim();
}
if (!title) {
title = $('title').text().replace(/\s*-\s*MissAV.*$/i, '').trim();
}
const releaseDate = String(
$('time').first().attr('datetime') ||
$('time').first().text() ||
''
).trim() || undefined;
let officialDescription = $('meta[property="og:description"]').attr('content') || $('meta[name="description"]').attr('content') || '';
officialDescription = String(officialDescription || '').replace(/\s+/g, ' ').trim();
if (!officialDescription) {
officialDescription = $('div.mt-4').first().text().replace(/\s+/g, ' ').trim();
}
if (!officialDescription) {
officialDescription = title || `${videoCode}`;
}
let playback = await resolveMissavPlayback(response.data, link);
let videoUrl = playback.url || "";
$('script').each((index, element) => {
if (videoUrl) return false;
const scriptContent = $(element).html() || "";
if (scriptContent.includes('surrit.com') && scriptContent.includes('.m3u8')) {
const surritMatches = scriptContent.match(/https:\/\/surrit\.com\/[a-f0-9\-]+\/[^"'\s]*\.m3u8/g);
if (surritMatches && surritMatches.length > 0) {
videoUrl = surritMatches[0];
return false;
}
}
if (!videoUrl && scriptContent.includes('eval(function')) {
const uuidMatches = scriptContent.match(/[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}/g);
if (uuidMatches && uuidMatches.length > 0) {
videoUrl = `https://surrit.com/${uuidMatches[0]}/playlist.m3u8`;
}
}
});
if (videoUrl && !playback.url) {
playback = {
url: videoUrl,
playerType: "system",
codecs: "",
resolution: "",
bandwidth: 0
};
}
const playHeaders = videoUrl ? getPlayHeaders(link) : undefined;
return {
id: link,
type: "detail",
videoUrl: videoUrl || "",
url: videoUrl || "",
playUrl: videoUrl || "",
title: title || `${videoCode}`,
description: videoUrl ? officialDescription : `${officialDescription}\n未解析到 m3u8,请检查 CK 或稍后重试`,
releaseDate: releaseDate,
posterPath: fallbackCover,
backdropPath: fallbackCover,
mediaType: "movie",
duration: 0,
durationText: "",
previewUrl: "",
playerType: videoUrl ? (playback.playerType || "system") : "none",
link: link,
customHeaders: playHeaders,
headers: playHeaders
};
} catch (error) {
const videoId = extractVideoId(link);
const videoCode = videoId.toUpperCase().replace('-CHINESE-SUBTITLE', '').replace('-UNCENSORED-LEAK', '');
const fallbackCover = `https://fourhoi.com/${videoId}/cover-t.jpg`;
return {
id: link,
type: "detail",
videoUrl: "",
url: "",
playUrl: "",
title: `${videoCode}`,
description: `番号: ${videoCode}\n详情页请求失败,未解析到 m3u8`,
releaseDate: undefined,
posterPath: fallbackCover,
backdropPath: fallbackCover,
mediaType: "movie",
duration: 0,
durationText: "",
previewUrl: "",
playerType: "none",
link: link
};
}
}
@@ -0,0 +1,2157 @@
const SITE = "https://vip.wwgz.cn:5200";
const PLAYER_SITE = "https://api.nmvod.me:520";
const UA =
"Mozilla/5.0 (iPhone; CPU iPhone OS 18_7 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.1 Mobile/15E148 Safari/604.1";
const PLAY_API = PLAYER_SITE + "/player/?url=";
const PLAY_HEADERS = {
"User-Agent": UA,
"Referer": PLAYER_SITE + "/"
};
const CACHE_TTL = 5 * 60 * 1000;
var __cache = {};
var WidgetMetadata = {
id: "nongmin_vod_capyplayer",
title: "农民影视",
description: "农民影视分类浏览与搜索播放源",
version: "1.3.19",
requiredVersion: "0.0.1",
modules: [
{
id: "movies",
title: "电影",
description: "农民影视电影分类",
functionName: "loadCategory",
type: "media_list",
cacheDuration: 120,
timeoutSeconds: 15,
retryCount: 0,
params: [
{ name: "categoryId", title: "分类", type: "constant", value: "1" },
{
name: "type",
title: "类型",
type: "enum",
enumOptions: [
{ title: "全部", value: "1" },
{ title: "动作片", value: "5" },
{ title: "喜剧片", value: "6" },
{ title: "爱情片", value: "7" },
{ title: "科幻片", value: "8" },
{ title: "恐怖片", value: "9" },
{ title: "剧情片", value: "10" },
{ title: "战争片", value: "11" },
{ title: "惊悚片", value: "12" },
{ title: "奇幻片", value: "13" }
],
value: "1"
},
{
name: "area",
title: "地区",
type: "enum",
enumOptions: [
{ title: "全部", value: "" },
{ title: "大陆", value: "大陆" },
{ title: "香港", value: "香港" },
{ title: "台湾", value: "台湾" },
{ title: "美国", value: "美国" },
{ title: "日本", value: "日本" },
{ title: "韩国", value: "韩国" },
{ title: "印度", value: "印度" },
{ title: "泰国", value: "泰国" },
{ title: "英国", value: "英国" },
{ title: "法国", value: "法国" },
{ title: "加拿大", value: "加拿大" },
{ title: "西班牙", value: "西班牙" },
{ title: "俄罗斯", value: "俄罗斯" },
{ title: "其他", value: "其他" }
],
value: ""
},
{
name: "year",
title: "年份",
type: "enum",
enumOptions: [
{ title: "全部", value: "0" },
{ title: "2026", value: "2026" },
{ title: "2025", value: "2025" },
{ title: "2024", value: "2024" },
{ title: "2023", value: "2023" },
{ title: "2022", value: "2022" },
{ title: "2021", value: "2021" },
{ title: "2020", value: "2020" },
{ title: "2019", value: "2019" },
{ title: "2018", value: "2018" },
{ title: "2017", value: "2017" },
{ title: "2016", value: "2016" },
{ title: "2015", value: "2015" },
{ title: "2014", value: "2014" },
{ title: "2013", value: "2013" },
{ title: "2012", value: "2012" },
{ title: "2011", value: "2011" },
{ title: "2010", value: "2010" },
{ title: "2009~2000", value: "2009~2000" }
],
value: "0"
},
{
name: "sort",
title: "排序",
type: "enum",
enumOptions: [
{ title: "时间", value: "time" },
{ title: "人气", value: "hits" },
{ title: "评分", value: "score" }
],
value: "time"
},
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "series",
title: "连续剧",
description: "农民影视连续剧分类",
functionName: "loadCategory",
type: "media_list",
cacheDuration: 120,
timeoutSeconds: 15,
retryCount: 0,
params: [
{ name: "categoryId", title: "分类", type: "constant", value: "2" },
{
name: "type",
title: "类型",
type: "enum",
enumOptions: [
{ title: "全部", value: "2" },
{ title: "国产剧", value: "12" },
{ title: "港台泰", value: "13" },
{ title: "日韩剧", value: "14" },
{ title: "欧美剧", value: "15" }
],
value: "2"
},
{
name: "area",
title: "地区",
type: "enum",
enumOptions: [
{ title: "全部", value: "" },
{ title: "大陆", value: "大陆" },
{ title: "香港", value: "香港" },
{ title: "台湾", value: "台湾" },
{ title: "美国", value: "美国" },
{ title: "日本", value: "日本" },
{ title: "韩国", value: "韩国" },
{ title: "印度", value: "印度" },
{ title: "泰国", value: "泰国" },
{ title: "英国", value: "英国" },
{ title: "法国", value: "法国" },
{ title: "加拿大", value: "加拿大" },
{ title: "西班牙", value: "西班牙" },
{ title: "俄罗斯", value: "俄罗斯" },
{ title: "其他", value: "其他" }
],
value: ""
},
{
name: "year",
title: "年份",
type: "enum",
enumOptions: [
{ title: "全部", value: "0" },
{ title: "2026", value: "2026" },
{ title: "2025", value: "2025" },
{ title: "2024", value: "2024" },
{ title: "2023", value: "2023" },
{ title: "2022", value: "2022" },
{ title: "2021", value: "2021" },
{ title: "2020", value: "2020" },
{ title: "2019", value: "2019" },
{ title: "2018", value: "2018" },
{ title: "2017", value: "2017" },
{ title: "2016", value: "2016" },
{ title: "2015", value: "2015" },
{ title: "2014", value: "2014" },
{ title: "2013", value: "2013" },
{ title: "2012", value: "2012" },
{ title: "2011", value: "2011" },
{ title: "2010", value: "2010" }
],
value: "0"
},
{
name: "sort",
title: "排序",
type: "enum",
enumOptions: [
{ title: "时间", value: "time" },
{ title: "人气", value: "hits" },
{ title: "评分", value: "score" }
],
value: "time"
},
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "variety",
title: "综艺",
description: "农民影视综艺分类",
functionName: "loadCategory",
type: "media_list",
cacheDuration: 120,
timeoutSeconds: 15,
retryCount: 0,
params: [
{ name: "categoryId", title: "分类", type: "constant", value: "3" },
{
name: "area",
title: "地区",
type: "enum",
enumOptions: [
{ title: "全部", value: "" },
{ title: "大陆", value: "大陆" },
{ title: "香港", value: "香港" },
{ title: "台湾", value: "台湾" },
{ title: "美国", value: "美国" },
{ title: "日本", value: "日本" },
{ title: "韩国", value: "韩国" },
{ title: "印度", value: "印度" },
{ title: "泰国", value: "泰国" },
{ title: "英国", value: "英国" },
{ title: "法国", value: "法国" },
{ title: "加拿大", value: "加拿大" },
{ title: "西班牙", value: "西班牙" },
{ title: "俄罗斯", value: "俄罗斯" },
{ title: "其他", value: "其他" }
],
value: ""
},
{
name: "year",
title: "年份",
type: "enum",
enumOptions: [
{ title: "全部", value: "0" },
{ title: "2026", value: "2026" },
{ title: "2025", value: "2025" },
{ title: "2024", value: "2024" },
{ title: "2023", value: "2023" },
{ title: "2022", value: "2022" },
{ title: "2021", value: "2021" },
{ title: "2020", value: "2020" },
{ title: "2019", value: "2019" },
{ title: "2018", value: "2018" },
{ title: "2017", value: "2017" },
{ title: "2016", value: "2016" },
{ title: "2015", value: "2015" },
{ title: "2014", value: "2014" },
{ title: "2013", value: "2013" },
{ title: "2012", value: "2012" },
{ title: "2011", value: "2011" },
{ title: "2010", value: "2010" }
],
value: "0"
},
{
name: "sort",
title: "排序",
type: "enum",
enumOptions: [
{ title: "时间", value: "time" },
{ title: "人气", value: "hits" },
{ title: "评分", value: "score" }
],
value: "time"
},
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "anime",
title: "动漫",
description: "农民影视动漫分类",
functionName: "loadCategory",
type: "media_list",
cacheDuration: 120,
timeoutSeconds: 15,
retryCount: 0,
params: [
{ name: "categoryId", title: "分类", type: "constant", value: "4" },
{
name: "area",
title: "地区",
type: "enum",
enumOptions: [
{ title: "全部", value: "" },
{ title: "大陆", value: "大陆" },
{ title: "香港", value: "香港" },
{ title: "台湾", value: "台湾" },
{ title: "美国", value: "美国" },
{ title: "日本", value: "日本" },
{ title: "韩国", value: "韩国" },
{ title: "印度", value: "印度" },
{ title: "泰国", value: "泰国" },
{ title: "英国", value: "英国" },
{ title: "法国", value: "法国" },
{ title: "加拿大", value: "加拿大" },
{ title: "西班牙", value: "西班牙" },
{ title: "俄罗斯", value: "俄罗斯" },
{ title: "其他", value: "其他" }
],
value: ""
},
{
name: "year",
title: "年份",
type: "enum",
enumOptions: [
{ title: "全部", value: "0" },
{ title: "2026", value: "2026" },
{ title: "2025", value: "2025" },
{ title: "2024", value: "2024" },
{ title: "2023", value: "2023" },
{ title: "2022", value: "2022" },
{ title: "2021", value: "2021" },
{ title: "2020", value: "2020" },
{ title: "2019", value: "2019" },
{ title: "2018", value: "2018" },
{ title: "2017", value: "2017" },
{ title: "2016", value: "2016" },
{ title: "2015", value: "2015" },
{ title: "2014", value: "2014" },
{ title: "2013", value: "2013" },
{ title: "2012", value: "2012" },
{ title: "2011", value: "2011" },
{ title: "2010", value: "2010" }
],
value: "0"
},
{
name: "sort",
title: "排序",
type: "enum",
enumOptions: [
{ title: "时间", value: "time" },
{ title: "人气", value: "hits" },
{ title: "评分", value: "score" }
],
value: "time"
},
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "short_drama",
title: "短剧",
description: "农民影视短剧分类",
functionName: "loadCategory",
type: "media_list",
cacheDuration: 120,
timeoutSeconds: 15,
retryCount: 0,
params: [
{ name: "categoryId", title: "分类", type: "constant", value: "26" },
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "loadResource",
title: "农民影视播放源",
description: "根据当前影片返回农民影视播放源",
functionName: "loadResource",
type: "stream",
params: []
}
],
search: {
title: "搜索",
functionName: "searchVideos",
params: [
{
name: "keyword",
title: "搜索关键词",
type: "input",
description: "输入影片名称",
value: ""
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
}
};
const NONGMIN_CATEGORY_IDS = {
movies: "1",
series: "2",
variety: "3",
anime: "4",
short_drama: "26"
};
function categoryValue(value, fallback) {
const text = String(value == null ? "" : value).trim();
return text || fallback;
}
function categoryUrl(params) {
params = params || {};
const categoryId = categoryValue(params.categoryId, "1");
const listId = categoryValue(params.type, categoryId);
const page = Math.max(1, toInt(params.page, 1));
const year = categoryValue(params.year, "0");
const sort = categoryValue(params.sort, "time");
const area = String(params.area == null ? "" : params.area);
return SITE + "/vod-list-id-" + encodeURIComponent(listId)
+ "-pg-" + page
+ "-order--by-" + encodeURIComponent(sort)
+ "-class-0"
+ "-year-" + encodeURIComponent(year)
+ "-letter--area-" + encodeURIComponent(area)
+ "-lang-.html";
}
async function loadCategory(params) {
const url = categoryUrl(params);
console.log("[nongmin] category request: " + url);
const res = await httpGet(url);
const cards = parseSearchResults((res && res.data) || "");
console.log("[nongmin] category parsed items=" + cards.length);
return cards.map(function (item) {
const mediaType = String(params && params.categoryId) === "1" ? "movie" : "tv";
return {
id: item.url,
type: mediaType,
title: item.rawTitle,
name: item.rawTitle,
posterPath: item.cover || "",
description: item.remark || "",
mediaType: mediaType,
link: item.url,
detailUrl: item.url
};
});
}
async function loadDetail(link) {
let originalLink = link;
let itemTitle = "";
if (link && typeof link === "object") {
originalLink = link.detailUrl || link.link || link.id || link.url || "";
itemTitle = String(link.title || link.name || "").trim();
}
const url = String(originalLink || "").trim();
if (!url) return {};
const playlist = await loadPlaylist(url);
const profiles = await buildNongminPlaybackProfiles(playlist || [], url);
if (!profiles.length) return {};
const maxTrackCount = profiles.reduce(function (count, profile) {
return Math.max(count, profile && profile.group && profile.group.tracks ? profile.group.tracks.length : 0);
}, 0);
const isSeries = maxTrackCount > 1 || profiles.some(function (profile) {
return looksLikeSeriesGroup(profile.group);
});
if (isSeries) {
const episodeItems = await resolveNongminSeriesEpisodes(url, profiles);
if (!episodeItems.length) return {};
return {
id: url,
type: "detail",
title: itemTitle,
mediaType: "tv",
seasonCount: 1,
episodeCount: episodeItems.length,
currentSeason: 1,
currentEpisode: 1,
currentSeasonId: url + "#s1",
currentEpisodeId: episodeItems[0].id,
currentEpisodeName: episodeItems[0].title,
seasons: [
{
id: url + "#s1",
seasonNumber: 1,
title: "第1季",
episodeCount: episodeItems.length,
episodes: episodeItems
}
],
episodeItems: episodeItems,
episodes: episodeItems,
episode_items: episodeItems,
seasonItems: [
{
id: url + "#s1",
seasonNumber: 1,
title: "第1季",
episodeCount: episodeItems.length,
episodes: episodeItems
}
],
season_items: [
{
id: url + "#s1",
seasonNumber: 1,
title: "第1季",
episodeCount: episodeItems.length,
episodes: episodeItems
}
],
link: url
};
}
const movie = await resolveNongminMoviePlan(url, profiles);
if (!movie || !movie.url) return {};
return {
id: url,
type: "detail",
title: itemTitle,
mediaType: "movie",
videoUrl: movie.url,
video_url: movie.url,
url: movie.url,
playUrl: movie.url,
playerType: movie.playerType || "system",
sourceName: movie.sourceName || "",
quality: formatNongminQuality(movie),
link: url
};
}
async function searchVideos(params) {
params = params || {};
const keyword = String(params.keyword || params.text || params.title || "").trim();
const page = Math.max(1, toInt(params.page, 1));
if (!keyword || page > 1) return [];
const results = await searchSite(keyword);
const typedResults = await Promise.all(results.map(async function (item) {
let mediaType = "movie";
let episodeCount = 0;
try {
const playlist = await loadPlaylist(item.url);
const group = pickBestGroup(playlist || []);
if (looksLikeSeriesGroup(group)) {
mediaType = "tv";
episodeCount = group.tracks.length;
}
} catch (error) {
console.log("[nongmin-search] classify error=" + String(error && error.message || error));
}
return {
item: item,
mediaType: mediaType,
episodeCount: episodeCount
};
}));
return typedResults.map(function (entry) {
const item = entry.item;
const mediaType = entry.mediaType;
const result = {
id: item.url,
type: mediaType,
title: item.rawTitle,
name: item.rawTitle,
posterPath: item.cover || "",
description: item.remark || "",
mediaType: mediaType,
seasonCount: mediaType === "tv" ? 1 : undefined,
episodeCount: mediaType === "tv" ? entry.episodeCount : undefined,
currentSeason: mediaType === "tv" ? 1 : undefined,
currentEpisode: mediaType === "tv" ? 1 : undefined,
link: item.url,
detailUrl: item.url
};
return result;
});
}
function toInt(v, d) {
const n = parseInt(v, 10);
return isNaN(n) ? d || 0 : n;
}
function nowMs() {
return typeof Date.now === "function" ? Date.now() : new Date().getTime();
}
function makeCacheKey(parts) {
return parts.map(p => String(p || "")).join("||");
}
function cacheGet(key) {
const item = __cache[key];
if (!item) return undefined;
if (nowMs() - item.time > CACHE_TTL) {
delete __cache[key];
return undefined;
}
return item.value;
}
function cacheSet(key, value) {
__cache[key] = { time: nowMs(), value: value };
return value;
}
function pad2(n) {
n = parseInt(n, 10);
if (isNaN(n)) return "";
return n < 10 ? "0" + n : String(n);
}
function buildHeaders(url, extra) {
return Object.assign({ "User-Agent": UA }, extra || {});
}
async function httpGet(url, extra) {
const headers = buildHeaders(url, extra);
if (typeof fetch === "function") {
console.log("[nongmin] GET transport=fetch");
const response = await fetch(url, {
method: "GET",
headers: headers,
credentials: "include",
cache: "no-store"
});
const data = typeof response.text === "function"
? await response.text()
: String(response.data || "");
console.log("[nongmin] GET fetch status=" + response.status + " bytes=" + data.length);
if (response.ok === false) {
throw new Error("http get " + response.status + ": " + url);
}
return { data: data, status: response.status };
}
console.log("[nongmin] GET transport=Widget.http");
const response = await Widget.http.get(url, { headers: headers, timeout: 15000 });
console.log("[nongmin] GET Widget.http bytes=" + String((response && response.data) || "").length);
return response;
}
async function httpPost(url, body, extra) {
const headers = buildHeaders(
url,
Object.assign({ "Content-Type": "application/x-www-form-urlencoded" }, extra || {})
);
if (typeof fetch === "function") {
const response = await fetch(url, {
method: "POST",
headers: headers,
body: body,
credentials: "include",
cache: "no-store"
});
const data = typeof response.text === "function"
? await response.text()
: String(response.data || "");
if (response.ok === false) {
throw new Error("http post " + response.status + ": " + url);
}
return { data: data, status: response.status };
}
return await Widget.http.post(url, body, { headers: headers, timeout: 15000 });
}
function isBadHref(url) {
url = String(url || "").trim();
if (!url) return true;
if (url === "#") return true;
if (/^javascript:/i.test(url)) return true;
if (/^void/i.test(url)) return true;
return false;
}
function absoluteUrl(url) {
url = String(url || "").trim();
if (isBadHref(url)) return "";
if (/^https?:\/\//i.test(url)) return url;
if (!url.startsWith("/")) url = "/" + url;
return SITE + url;
}
function htmlDecode(text) {
return String(text || "")
.replace(/&amp;/g, "&")
.replace(/&quot;/g, '"')
.replace(/&#34;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&apos;/g, "'")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&nbsp;/g, " ");
}
function stripTags(text) {
return String(text || "")
.replace(/<script[\s\S]*?<\/script>/gi, "")
.replace(/<style[\s\S]*?<\/style>/gi, "")
.replace(/<[^>]+>/g, "")
.replace(/\s+/g, " ")
.trim();
}
function normalizeName(text) {
return String(text || "")
.replace(/\s+/g, "")
.replace(/[:·・,.。!?\-—_'"“”()()\[\]【】]/g, "")
.toLowerCase();
}
function stripTitleMeta(text) {
return String(text || "")
.replace(/[\(][^\)]*[\)]/g, "")
.replace(/第[0-9一二两三四五六七八九十百零〇]+季/g, "")
.replace(/season\s*\d+/ig, "")
.replace(/\bs\d{1,2}\b/ig, "")
.trim();
}
const CN_NUM_MAP = {
"零": 0,
"": 0,
"一": 1,
"二": 2,
"两": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9
};
function cnToNum(s) {
s = String(s || "").trim();
if (!s) return 0;
if (/^\d+$/.test(s)) return parseInt(s, 10);
if (s === "十") return 10;
let total = 0;
s = s.replace(/^[零〇]+/, "");
if (!s) return total;
if (s.indexOf("百") >= 0) {
const arr = s.split("百");
const h = CN_NUM_MAP[arr[0]] || 1;
total += h * 100;
s = (arr[1] || "").replace(/^[零〇]+/, "");
}
if (s.indexOf("十") >= 0) {
const arr = s.split("十");
const t = arr[0] ? CN_NUM_MAP[arr[0]] || 0 : 1;
const u = arr[1] ? CN_NUM_MAP[arr[1]] || 0 : 0;
total += t * 10 + u;
return total;
}
if (s && CN_NUM_MAP[s] != null) return total + CN_NUM_MAP[s];
return total;
}
function extractEpisodeNumber(text) {
const s = String(text || "");
let m = s.match(/第\s*([0-9一二两三四五六七八九十百零〇]+)\s*[集话期]/);
if (m) return cnToNum(m[1]);
m = s.match(/(?:EP|E|episode)\s*0*(\d{1,4})/i);
if (m) return parseInt(m[1], 10);
m = s.match(/(?:^|[^\d])0*(\d{1,4})(?:$|[^\d])/);
if (m) return parseInt(m[1], 10);
return 0;
}
function getWantedEpisode(params) {
params = params || {};
let n = 0;
// 最优先:明确集数字段
n =
toInt(params.episode, 0) ||
toInt(params.episodeNumber, 0) ||
toInt(params.episodeNo, 0) ||
toInt(params.episodeNum, 0) ||
toInt(params.ep, 0) ||
toInt(params.epNumber, 0) ||
toInt(params.number, 0) ||
toInt(params.currentEpisode, 0);
if (n > 0) return n;
// episodeIndex 通常是 0 基:第 5 集 = 4
if (params.episodeIndex !== undefined && params.episodeIndex !== null) {
const idx = toInt(params.episodeIndex, -1);
if (idx >= 0) return idx + 1;
}
if (params.epIndex !== undefined && params.epIndex !== null) {
const idx = toInt(params.epIndex, -1);
if (idx >= 0) return idx + 1;
}
// 从文字字段提取
n =
extractEpisodeNumber(params.episodeName) ||
extractEpisodeNumber(params.episodeTitle) ||
extractEpisodeNumber(params.name) ||
extractEpisodeNumber(params.subtitle);
if (n > 0) return n;
return 0;
}
function parseSearchResults(html) {
html = String(html || "");
// Category pages contain navigation <li> elements before the actual card list.
const listMarker = html.indexOf("globalPicList");
if (listMarker >= 0) {
const listStart = html.indexOf("<ul", listMarker);
const listEnd = html.indexOf("</ul>", listStart);
if (listStart >= 0 && listEnd > listStart) {
html = html.slice(listStart, listEnd + 5);
}
}
const out = [];
const liReg = /<li[\s\S]*?<\/li>/gi;
let li;
while ((li = liReg.exec(html))) {
const item = li[0];
let href = "";
let title = "";
let cover = "";
let remark = "";
let m;
m = item.match(/<a[^>]+href=["']([^"']+)["'][^>]*>/i);
if (m) href = htmlDecode(m[1]);
m = item.match(/<[^>]+class=["'][^"']*sTit[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/i);
if (m) title = stripTags(htmlDecode(m[1]));
if (!title) {
m = item.match(/title=["']([^"']+)["']/i);
if (m) title = htmlDecode(m[1]).trim();
}
if (!title) {
m = item.match(/alt=["']([^"']+)["']/i);
if (m) title = htmlDecode(m[1]).trim();
}
m = item.match(/data-src=["']([^"']+)["']/i);
if (!m) m = item.match(/src=["']([^"']+)["']/i);
if (m) cover = htmlDecode(m[1]);
m = item.match(/<[^>]+class=["'][^"']*sStyle[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/i);
if (!m) m = item.match(/<[^>]+class=["'][^"']*sDes[^"']*["'][^>]*>([\s\S]*?)<\/[^>]+>/i);
if (m) remark = stripTags(htmlDecode(m[1]));
const abs = absoluteUrl(href);
if (abs && title) {
out.push({
url: abs,
rawTitle: title,
cover: absoluteUrl(cover),
remark: remark
});
}
}
if (!out.length) {
const re = /<a[^>]+href=["']([^"']+)["'][^>]*title=["']([^"']+)["'][^>]*>/ig;
let m;
while ((m = re.exec(html))) {
const href = htmlDecode(m[1]);
const title = htmlDecode(m[2]).trim();
const abs = absoluteUrl(href);
if (abs && title && /vod|detail/i.test(abs)) {
out.push({
url: abs,
rawTitle: title,
cover: "",
remark: ""
});
}
}
}
return out;
}
function parseMapResults(html) {
html = String(html || "");
const out = [];
const seen = {};
const re = /<a[^>]+href=["']([^"']*vod-detail-id-\d+\.html)["'][^>]*>([\s\S]*?)<\/a>/gi;
let m;
while ((m = re.exec(html))) {
const url = absoluteUrl(htmlDecode(m[1]));
const title = stripTags(htmlDecode(m[2])).trim();
if (!url || !title || seen[url]) continue;
seen[url] = true;
out.push({
url: url,
rawTitle: title,
cover: "",
remark: ""
});
}
return out;
}
async function searchSite(keyword) {
const cacheKey = makeCacheKey(["search", keyword]);
const cached = cacheGet(cacheKey);
if (cached !== undefined) return cached;
try {
const url = SITE + "/index.php?m=vod-search";
const body = "wd=" + encodeURIComponent(keyword);
const res = await httpPost(url, body);
const results = parseSearchResults((res && res.data) || "");
if (results.length) return cacheSet(cacheKey, results);
} catch (e) {}
const mapCacheKey = "vod-map-all";
let all = cacheGet(mapCacheKey);
if (all === undefined) {
const mapRes = await httpGet(SITE + "/vod-map.html");
all = cacheSet(mapCacheKey, parseMapResults((mapRes && mapRes.data) || ""));
}
const wantBaseNorm = normalizeName(stripTitleMeta(keyword));
const results = (all || []).filter(item => scoreResult(item, wantBaseNorm) >= 0);
return cacheSet(cacheKey, results);
}
function scoreResult(item, wantBaseNorm) {
const rawBase = stripTitleMeta(item.rawTitle);
const baseNorm = normalizeName(rawBase);
if (!baseNorm || !wantBaseNorm) return -1;
if (baseNorm === wantBaseNorm) return 320;
if (baseNorm.indexOf(wantBaseNorm) >= 0) return 180;
if (wantBaseNorm.indexOf(baseNorm) >= 0) return 150;
return -1;
}
function pickBestResult(results, wantBaseNorm) {
let best = null;
let bestScore = -Infinity;
for (const item of results) {
const sc = scoreResult(item, wantBaseNorm);
if (sc > bestScore) {
bestScore = sc;
best = item;
}
}
return bestScore >= 0 ? best : null;
}
function parsePlayPageUrls(html) {
html = String(html || "");
const urls = [];
const re = /<a[^>]+href=["']([^"']+)["'][^>]*>/ig;
let m;
while ((m = re.exec(html))) {
const href = htmlDecode(m[1]).trim();
if (isBadHref(href)) continue;
if (!/vod[-_]?play|play/i.test(href)) continue;
const abs = absoluteUrl(href);
if (abs && urls.indexOf(abs) < 0) {
urls.push(abs);
}
}
return urls;
}
function parseMacVars(html) {
const text = String(html || "");
const fromMatch =
text.match(/mac_from\s*=\s*'([^']*)'/) ||
text.match(/mac_from\s*=\s*"([^"]*)"/);
const urlMatch =
text.match(/mac_url\s*=\s*'([^']+)'/) ||
text.match(/mac_url\s*=\s*"([^"]+)"/);
if (!fromMatch || !urlMatch) return [];
const fromList = fromMatch[1].split("$$$");
const urlList = urlMatch[1].split("$$$");
const groups = [];
for (let i = 0; i < fromList.length; i++) {
const sourceName = fromList[i] || "默认线路";
const eps = String(urlList[i] || "").split("#").filter(Boolean);
const tracks = [];
for (let j = 0; j < eps.length; j++) {
const parts = eps[j].split("$");
const name = htmlDecode(parts[0] || "第" + (j + 1) + "集");
const rawUrl = htmlDecode(parts[1] || "");
if (!rawUrl || isBadHref(rawUrl)) continue;
const ep = extractEpisodeNumber(name) || j + 1;
tracks.push({
name: name,
episode: ep,
index: j + 1,
source: sourceName,
url: rawUrl
});
}
if (tracks.length) {
groups.push({
title: sourceName,
tracks: tracks
});
}
}
return groups;
}
async function loadPlaylist(detailUrl) {
const cacheKey = makeCacheKey(["playlist", detailUrl]);
const cached = cacheGet(cacheKey);
if (cached !== undefined) return cached;
const detailRes = await httpGet(detailUrl);
const detailHtml = (detailRes && detailRes.data) || "";
let groups = parseMacVars(detailHtml);
if (groups && groups.length) return cacheSet(cacheKey, groups);
const playUrls = parsePlayPageUrls(detailHtml);
if (!playUrls.length) return [];
const maxTry = Math.min(playUrls.length, 5);
for (let i = 0; i < maxTry; i++) {
try {
const epRes = await httpGet(playUrls[i]);
const epHtml = (epRes && epRes.data) || "";
groups = parseMacVars(epHtml);
if (groups && groups.length) {
return cacheSet(cacheKey, groups);
}
} catch (e) {}
}
return [];
}
function pickBestGroup(groups) {
if (!groups || !groups.length) return null;
let best = groups[0];
let bestCount = best.tracks ? best.tracks.length : 0;
for (let i = 1; i < groups.length; i++) {
const count = groups[i].tracks ? groups[i].tracks.length : 0;
if (count > bestCount) {
best = groups[i];
bestCount = count;
}
}
return best;
}
function looksLikeSeriesGroup(group) {
return !!(
group &&
group.tracks &&
group.tracks.length > 1 &&
group.tracks.some(function (track) {
return /第[0-9一二两三四五六七八九十百零〇]+集|S\d{1,2}E\d{1,3}/i.test(String(track.name || ""));
})
);
}
async function resolveEpisodeItems(detailUrl, tracks) {
const list = Array.isArray(tracks) ? tracks : [];
const resolved = await Promise.all(list.map(async function (track, index) {
if (!track || !track.url) return null;
const videoUrl = await resolveDirectUrl(track.url);
if (!videoUrl) return null;
const parsedEpisode = extractEpisodeNumber(track.name);
const episodeNumber = toInt(track.episode, 0) || parsedEpisode || index + 1;
const episodeTitle = track.name || track.episode || ("第" + pad2(episodeNumber) + "集");
return {
id: detailUrl + "#e" + episodeNumber,
seasonNumber: 1,
episodeNumber: episodeNumber,
episode: episodeNumber,
title: episodeTitle,
name: episodeTitle,
videoUrl: videoUrl,
video_url: videoUrl,
url: videoUrl
};
}));
return resolved.filter(Boolean);
}
function pickTrack(group, wantEpisode, isMovie) {
if (!group || !group.tracks || !group.tracks.length) return null;
const tracks = group.tracks;
if (isMovie) return tracks[0];
if (wantEpisode > 0) {
let tr = tracks.find(t => toInt(t.episode, -1) === wantEpisode);
if (tr) return tr;
tr = tracks.find(t => toInt(t.index, -1) === wantEpisode);
if (tr) return tr;
tr = tracks.find(t => extractEpisodeNumber(t.name) === wantEpisode);
if (tr) return tr;
}
return tracks[0];
}
function normalizePlayUrl(url) {
url = String(url || "").trim();
if (!url) return "";
url = htmlDecode(url)
.replace(/\\\//g, "/")
.replace(/\\\\/g, "\\");
try {
if (/^https?%3A%2F%2F/i.test(url)) {
url = decodeURIComponent(url);
}
} catch (e) {}
if (url.startsWith("//")) {
url = "https:" + url;
}
if (!/^https?:\/\//i.test(url)) {
return "";
}
if (isBadHref(url)) return "";
return url;
}
function parsePlayerUrl(html) {
const text = String(html || "");
let m;
m = text.match(/var\s+config\s*=\s*(\{[\s\S]*?\})\s*[,;]?/);
if (m && m[1]) {
const configString = m[1];
const urlMatch =
configString.match(/["']url["']\s*:\s*["']([^"']+)["']/) ||
configString.match(/url\s*:\s*["']([^"']+)["']/);
if (urlMatch && urlMatch[1]) {
const u = normalizePlayUrl(urlMatch[1]);
if (u) return u;
}
}
m = text.match(/https?:\\?\/\\?\/[^"'<>]+?\.m3u8[^"'<>]*/i);
if (m && m[0]) {
const u = normalizePlayUrl(m[0]);
if (u) return u;
}
m =
text.match(/["']url["']\s*:\s*["']([^"']+)["']/) ||
text.match(/url\s*:\s*["']([^"']+)["']/);
if (m && m[1]) {
const u = normalizePlayUrl(m[1]);
if (u) return u;
}
return "";
}
const NONGMIN_HLS_TIMEOUT_MS = 3200;
const NONGMIN_SEGMENT_TIMEOUT_MS = 2800;
const NONGMIN_EPISODE_TIMEOUT_MS = 2200;
const NONGMIN_EPISODE_BUDGET_MS = 8000;
const NONGMIN_PROBE_CONCURRENCY = 6;
async function mapNongminWithLimit(items, limit, iterator) {
const source = Array.isArray(items) ? items : [];
const results = new Array(source.length);
let cursor = 0;
const workerCount = Math.max(1, Math.min(toInt(limit, 1), source.length || 1));
async function worker() {
while (true) {
const index = cursor++;
if (index >= source.length) return;
results[index] = await iterator(source[index], index);
}
}
const workers = [];
for (let i = 0; i < workerCount; i++) workers.push(worker());
await Promise.all(workers);
return results;
}
function positiveNongminNumber(value) {
const number = parseInt(String(value || ""), 10);
return isFinite(number) && number > 0 ? number : 0;
}
function parseNongminHlsAttributes(attributeText) {
const attributes = {};
const source = String(attributeText || "");
let token = "";
let quoted = false;
for (let i = 0; i <= source.length; i++) {
const ch = i < source.length ? source.charAt(i) : ",";
if (ch === '"' && source.charAt(i - 1) !== "\\") {
quoted = !quoted;
token += ch;
continue;
}
if (ch === "," && !quoted) {
const part = token.trim();
token = "";
if (!part) continue;
const equalsIndex = part.indexOf("=");
if (equalsIndex <= 0) continue;
const key = part.slice(0, equalsIndex).trim().toUpperCase();
let value = part.slice(equalsIndex + 1).trim();
if (value.length >= 2 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') {
value = value.slice(1, -1).replace(/\\"/g, '"');
}
attributes[key] = value;
continue;
}
token += ch;
}
return attributes;
}
function parseNongminHlsResolution(value, uri) {
let width = 0;
let height = 0;
const declared = String(value || "").match(/(\d{2,5})\s*x\s*(\d{2,5})/i);
if (declared) {
width = positiveNongminNumber(declared[1]);
height = positiveNongminNumber(declared[2]);
}
const pathText = String(uri || "");
if (!width || !height) {
const pathResolution = pathText.match(/(?:^|[\/_\-.])(\d{2,5})x(\d{2,5})(?:[\/_\-.]|$)/i);
if (pathResolution) {
width = positiveNongminNumber(pathResolution[1]);
height = positiveNongminNumber(pathResolution[2]);
}
}
if (!height) {
const heightOnly = pathText.match(/(?:^|[\/_\-.])(\d{3,4})p(?:[\/_\-.]|$)/i);
if (heightOnly) {
height = positiveNongminNumber(heightOnly[1]);
width = height ? Math.round(height * 16 / 9) : 0;
}
}
return {
width: width,
height: height,
pixels: width && height ? width * height : 0
};
}
function resolveNongminHlsUrl(uri, playlistUrl) {
const value = String(uri || "").trim();
const base = String(playlistUrl || "").trim();
if (!value) return "";
if (/^https?:\/\//i.test(value)) return normalizePlayUrl(value);
if (/^\/\//.test(value)) return normalizePlayUrl("https:" + value);
try {
if (typeof URL !== "undefined") return normalizePlayUrl(new URL(value, base).toString());
} catch (e) {}
const cleanBase = base.replace(/[?#].*$/, "");
const originMatch = cleanBase.match(/^(https?:\/\/[^/]+)/i);
if (value.charAt(0) === "/" && originMatch) return normalizePlayUrl(originMatch[1] + value);
const slashIndex = cleanBase.lastIndexOf("/");
const directory = slashIndex >= 0 ? cleanBase.slice(0, slashIndex + 1) : cleanBase + "/";
return normalizePlayUrl(directory + value.replace(/^\.\//, ""));
}
function parseNongminMasterVariants(masterText, playlistUrl) {
const lines = String(masterText || "").split(/\r?\n/);
const variants = [];
for (let i = 0; i < lines.length; i++) {
const line = String(lines[i] || "").trim();
if (!/^#EXT-X-STREAM-INF\s*:/i.test(line)) continue;
const attributes = parseNongminHlsAttributes(line.slice(line.indexOf(":") + 1));
let uri = "";
let uriIndex = i + 1;
while (uriIndex < lines.length) {
const candidate = String(lines[uriIndex] || "").trim();
if (!candidate || candidate.charAt(0) === "#") {
uriIndex++;
continue;
}
uri = candidate;
break;
}
if (!uri) continue;
const resolution = parseNongminHlsResolution(attributes.RESOLUTION, uri);
const averageBandwidth = positiveNongminNumber(attributes["AVERAGE-BANDWIDTH"]);
const peakBandwidth = positiveNongminNumber(attributes.BANDWIDTH);
variants.push({
url: resolveNongminHlsUrl(uri, playlistUrl),
uri: uri,
codecs: String(attributes.CODECS || ""),
resolution: String(attributes.RESOLUTION || ""),
width: resolution.width,
height: resolution.height,
pixels: resolution.pixels,
bandwidth: averageBandwidth || peakBandwidth,
peakBandwidth: peakBandwidth,
frameRate: parseFloat(String(attributes["FRAME-RATE"] || "0")) || 0,
order: variants.length
});
i = uriIndex;
}
return variants;
}
function compareNongminQuality(left, right) {
left = left || {};
right = right || {};
const fields = ["verified", "pixels", "height", "width", "bandwidth", "peakBandwidth", "frameRate"];
for (let i = 0; i < fields.length; i++) {
const field = fields[i];
const leftValue = field === "verified" ? (left[field] ? 1 : 0) : Number(left[field] || 0);
const rightValue = field === "verified" ? (right[field] ? 1 : 0) : Number(right[field] || 0);
const difference = rightValue - leftValue;
if (difference) return difference;
}
return 0;
}
function selectNongminHighestVariant(masterText, playlistUrl) {
const variants = parseNongminMasterVariants(masterText, playlistUrl);
if (!variants.length) return null;
variants.sort(function (left, right) {
const quality = compareNongminQuality(
Object.assign({ verified: true }, left),
Object.assign({ verified: true }, right)
);
if (quality) return quality;
return left.order - right.order;
});
return variants[0];
}
function classifyNongminCodec(codecs) {
const value = String(codecs || "").toLowerCase();
if (/(^|[,\s])(hvc1|hev1|hevc|h265|av01|av1|vp09|vp9|dvhe|dvh1)(?:\.|[,\s]|$)/i.test(value)) {
return "modern";
}
if (/(^|[,\s])(avc1|avc3|h264)(?:\.|[,\s]|$)/i.test(value)) {
return "avc";
}
return "unknown";
}
function chooseNongminPlayerType(codecFamily) {
return codecFamily === "avc" ? "ijk" : "system";
}
function firstNongminMediaSegmentUrl(playlist, playlistUrl) {
const lines = String(playlist || "").split(/\r?\n/);
let expectSegment = false;
for (let i = 0; i < lines.length; i++) {
const line = String(lines[i] || "").trim();
if (/^#EXTINF:/i.test(line)) {
expectSegment = true;
continue;
}
if (!line || line.charAt(0) === "#") continue;
if (expectSegment) return resolveNongminHlsUrl(line, playlistUrl);
expectSegment = false;
}
return "";
}
function nongminResponseBytes(data) {
if (data == null) return [];
if (Array.isArray(data)) return data;
if (typeof Uint8Array !== "undefined" && data instanceof Uint8Array) return data;
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) return new Uint8Array(data);
if (data && data.buffer && typeof Uint8Array !== "undefined") {
try {
return new Uint8Array(data.buffer, data.byteOffset || 0, data.byteLength || data.length);
} catch (e) {}
}
const source = String(data || "");
const bytes = [];
const limit = Math.min(source.length, 131072);
for (let i = 0; i < limit; i++) bytes.push(source.charCodeAt(i) & 255);
return bytes;
}
function inspectNongminTsCodec(bytes) {
bytes = nongminResponseBytes(bytes);
let syncOffset = -1;
for (let offset = 0; offset < 188 && offset + 376 < bytes.length; offset++) {
if (bytes[offset] === 71 && bytes[offset + 188] === 71 && bytes[offset + 376] === 71) {
syncOffset = offset;
break;
}
}
if (syncOffset < 0) return { family: "unknown", streamTypes: [] };
let pmtPid = -1;
const streamTypes = [];
for (let packet = syncOffset; packet + 188 <= bytes.length; packet += 188) {
if (bytes[packet] !== 71) continue;
const payloadStart = (bytes[packet + 1] & 64) !== 0;
const pid = ((bytes[packet + 1] & 31) << 8) | bytes[packet + 2];
const adaptation = (bytes[packet + 3] >> 4) & 3;
if (adaptation === 0 || adaptation === 2) continue;
let cursor = packet + 4;
if (adaptation === 3) cursor += 1 + bytes[cursor];
if (cursor >= packet + 188) continue;
if (payloadStart) cursor += 1 + bytes[cursor];
if (cursor + 12 >= packet + 188) continue;
if (pid === 0 && bytes[cursor] === 0) {
const patLength = ((bytes[cursor + 1] & 15) << 8) | bytes[cursor + 2];
const patEnd = Math.min(cursor + 3 + patLength - 4, packet + 188);
for (let pat = cursor + 8; pat + 3 < patEnd; pat += 4) {
const program = (bytes[pat] << 8) | bytes[pat + 1];
if (program) {
pmtPid = ((bytes[pat + 2] & 31) << 8) | bytes[pat + 3];
break;
}
}
} else if (pid === pmtPid && bytes[cursor] === 2) {
const pmtLength = ((bytes[cursor + 1] & 15) << 8) | bytes[cursor + 2];
const programInfoLength = ((bytes[cursor + 10] & 15) << 8) | bytes[cursor + 11];
const pmtEnd = Math.min(cursor + 3 + pmtLength - 4, packet + 188);
for (let stream = cursor + 12 + programInfoLength; stream + 4 < pmtEnd;) {
const streamType = bytes[stream];
const infoLength = ((bytes[stream + 3] & 15) << 8) | bytes[stream + 4];
if (streamTypes.indexOf(streamType) < 0) streamTypes.push(streamType);
stream += 5 + infoLength;
}
if (streamTypes.length) break;
}
}
if (streamTypes.indexOf(36) >= 0) return { family: "modern", streamTypes: streamTypes };
if (streamTypes.indexOf(27) >= 0) return { family: "avc", streamTypes: streamTypes };
return { family: "unknown", streamTypes: streamTypes };
}
function decodeNongminResponseText(data) {
if (typeof data === "string") return data;
const bytes = nongminResponseBytes(data);
if (!bytes.length) return String(data || "");
if (typeof TextDecoder !== "undefined") {
try {
return new TextDecoder("utf-8").decode(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
} catch (e) {}
}
let text = "";
for (let i = 0; i < bytes.length; i++) text += String.fromCharCode(bytes[i]);
return text;
}
function getNongminPlaybackHeaders(extra) {
return Object.assign({
"User-Agent": UA,
"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/mp2t,video/*,*/*",
"Referer": PLAYER_SITE + "/"
}, extra || {});
}
async function boundedNongminPlaybackGet(url, headers, timeoutMs, binary) {
const timeout = Math.max(800, Math.min(Number(timeoutMs || NONGMIN_HLS_TIMEOUT_MS), 5000));
if (typeof Widget !== "undefined" && Widget.http && typeof Widget.http.get === "function") {
const response = await Widget.http.get(url, {
headers: headers || {},
allow_redirects: true,
timeout: timeout
});
const status = Number(response && response.status || 0);
if (!response || response.ok === false || status >= 400) {
throw new Error("playback HTTP " + status + ": " + url);
}
return response;
}
if (typeof fetch !== "function") throw new Error("No playback HTTP transport");
let timer = null;
const request = (async function () {
const response = await fetch(url, {
method: "GET",
headers: headers || {},
cache: "no-store"
});
if (response.ok === false) throw new Error("playback HTTP " + response.status + ": " + url);
let data;
if (binary && typeof response.arrayBuffer === "function") {
data = new Uint8Array(await response.arrayBuffer());
} else {
data = typeof response.text === "function" ? await response.text() : String(response.data || "");
}
return {
data: data,
status: response.status,
ok: response.ok,
url: response.url || url
};
})();
if (typeof setTimeout !== "function" || typeof Promise.race !== "function") return await request;
try {
return await Promise.race([
request,
new Promise(function (_, reject) {
timer = setTimeout(function () {
reject(new Error("playback timeout after " + timeout + "ms: " + url));
}, timeout);
})
]);
} finally {
if (timer && typeof clearTimeout === "function") clearTimeout(timer);
}
}
async function fetchNongminHlsText(url, timeoutMs) {
const response = await boundedNongminPlaybackGet(
url,
getNongminPlaybackHeaders(),
timeoutMs,
false
);
const body = decodeNongminResponseText(response && response.data);
if (!/^\s*#EXTM3U/i.test(body)) throw new Error("not an HLS playlist: " + url);
return {
body: body,
url: String(response && (response.url || response.finalUrl || response.requestUrl) || url)
};
}
async function probeNongminSegmentCodec(segmentUrl, timeoutMs) {
const response = await boundedNongminPlaybackGet(
segmentUrl,
getNongminPlaybackHeaders({ "Range": "bytes=0-65535" }),
Math.min(Number(timeoutMs || NONGMIN_SEGMENT_TIMEOUT_MS), NONGMIN_SEGMENT_TIMEOUT_MS),
true
);
return inspectNongminTsCodec(response && response.data);
}
async function inspectNongminHlsPlan(directUrl, probeCodec, timeoutMs) {
const timeout = Math.max(900, Math.min(Number(timeoutMs || NONGMIN_HLS_TIMEOUT_MS), NONGMIN_HLS_TIMEOUT_MS));
let current = await fetchNongminHlsText(directUrl, timeout);
let finalUrl = current.url || directUrl;
let mediaBody = current.body;
let mediaUrl = finalUrl;
let codecs = "";
let codecFamily = "unknown";
const quality = {
width: 0,
height: 0,
pixels: 0,
bandwidth: 0,
peakBandwidth: 0,
frameRate: 0,
resolution: ""
};
for (let depth = 0; depth < 3; depth++) {
const selected = selectNongminHighestVariant(current.body, current.url || finalUrl);
if (!selected || !selected.url) {
mediaBody = current.body;
mediaUrl = current.url || finalUrl;
break;
}
finalUrl = selected.url;
if (selected.width) quality.width = selected.width;
if (selected.height) quality.height = selected.height;
if (selected.pixels) quality.pixels = selected.pixels;
if (selected.bandwidth) quality.bandwidth = selected.bandwidth;
if (selected.peakBandwidth) quality.peakBandwidth = selected.peakBandwidth;
if (selected.frameRate) quality.frameRate = selected.frameRate;
if (selected.resolution) quality.resolution = selected.resolution;
const selectedFamily = classifyNongminCodec(selected.codecs);
if (selected.codecs) codecs = selected.codecs;
if (selectedFamily !== "unknown") codecFamily = selectedFamily;
current = await fetchNongminHlsText(finalUrl, timeout);
mediaBody = current.body;
mediaUrl = current.url || finalUrl;
}
let streamTypes = [];
if (codecFamily === "unknown" && probeCodec) {
const segmentUrl = firstNongminMediaSegmentUrl(mediaBody, mediaUrl);
if (segmentUrl) {
const codecProbe = await probeNongminSegmentCodec(segmentUrl, timeout);
codecFamily = codecProbe.family;
streamTypes = codecProbe.streamTypes || [];
}
}
return {
url: finalUrl,
verified: true,
playerType: chooseNongminPlayerType(codecFamily),
codecFamily: codecFamily,
codecs: codecs,
streamTypes: streamTypes,
width: quality.width,
height: quality.height,
pixels: quality.pixels,
bandwidth: quality.bandwidth,
peakBandwidth: quality.peakBandwidth,
frameRate: quality.frameRate,
resolution: quality.resolution
};
}
function applyNongminProfileFallback(plan, profile) {
const result = Object.assign({}, plan || {});
const profilePlan = profile && profile.plan ? profile.plan : null;
if ((!result.codecFamily || result.codecFamily === "unknown") && profilePlan && profilePlan.codecFamily) {
result.codecFamily = profilePlan.codecFamily;
result.codecs = result.codecs || profilePlan.codecs || "";
result.streamTypes = result.streamTypes && result.streamTypes.length
? result.streamTypes
: (profilePlan.streamTypes || []);
}
const qualityFields = ["width", "height", "pixels", "bandwidth", "peakBandwidth", "frameRate", "resolution"];
for (let i = 0; i < qualityFields.length; i++) {
const field = qualityFields[i];
if (!result[field] && profilePlan && profilePlan[field]) result[field] = profilePlan[field];
}
result.playerType = chooseNongminPlayerType(result.codecFamily || "unknown");
result.sourceName = result.sourceName || (profile && profile.group && profile.group.title) || "";
return result;
}
async function resolveNongminTrackPlan(rawUrl, detailUrl, profile, probeCodec, timeoutMs) {
const directUrl = await resolveDirectUrl(rawUrl);
if (!directUrl) return null;
const fullKey = makeCacheKey(["hls-plan-full", directUrl]);
const basicKey = makeCacheKey(["hls-plan-basic", directUrl]);
const fullCached = cacheGet(fullKey);
if (fullCached !== undefined) return applyNongminProfileFallback(fullCached, profile);
if (!probeCodec) {
const basicCached = cacheGet(basicKey);
if (basicCached !== undefined) return applyNongminProfileFallback(basicCached, profile);
}
if (Number(timeoutMs || 0) <= 0) {
return applyNongminProfileFallback({
url: directUrl,
verified: false,
playerType: "system",
codecFamily: "unknown",
codecs: "",
streamTypes: [],
width: 0,
height: 0,
pixels: 0,
bandwidth: 0,
peakBandwidth: 0,
frameRate: 0,
resolution: ""
}, profile);
}
try {
const plan = await inspectNongminHlsPlan(directUrl, !!probeCodec, timeoutMs);
plan.sourceName = profile && profile.group ? profile.group.title : "";
cacheSet(basicKey, plan);
if (probeCodec) cacheSet(fullKey, plan);
return applyNongminProfileFallback(plan, profile);
} catch (error) {
console.log("[nongmin-playback] inspect failed source=" +
String(profile && profile.group && profile.group.title || "") +
" error=" + String(error && error.message || error));
return applyNongminProfileFallback({
url: directUrl,
verified: false,
playerType: "system",
codecFamily: "unknown",
codecs: "",
streamTypes: [],
width: 0,
height: 0,
pixels: 0,
bandwidth: 0,
peakBandwidth: 0,
frameRate: 0,
resolution: ""
}, profile);
}
}
async function buildNongminPlaybackProfiles(groups, detailUrl) {
const list = (Array.isArray(groups) ? groups : []).filter(function (group) {
return group && Array.isArray(group.tracks) && group.tracks.length;
});
const profiles = await mapNongminWithLimit(list, 3, async function (group, index) {
const sampleTrack = group.tracks[0];
const profile = {
index: index,
group: group,
trackCount: group.tracks.length,
plan: null
};
profile.plan = await resolveNongminTrackPlan(
sampleTrack && sampleTrack.url,
detailUrl,
profile,
true,
NONGMIN_HLS_TIMEOUT_MS
);
return profile;
});
return profiles.filter(function (profile) {
return profile && profile.plan && profile.plan.url;
}).sort(function (left, right) {
const quality = compareNongminQuality(left.plan, right.plan);
if (quality) return quality;
const countDifference = Number(right.trackCount || 0) - Number(left.trackCount || 0);
if (countDifference) return countDifference;
return Number(left.index || 0) - Number(right.index || 0);
});
}
function nongminTrackEpisodeNumber(track, index) {
return toInt(track && track.episode, 0) ||
extractEpisodeNumber(track && track.name) ||
toInt(track && track.index, 0) ||
index + 1;
}
function findNongminEpisodeTrack(profile, episodeNumber, referenceIndex) {
if (!profile || !profile.group || !profile.group.tracks) return null;
const tracks = profile.group.tracks;
let track = tracks.find(function (item, index) {
return nongminTrackEpisodeNumber(item, index) === episodeNumber;
});
if (track) return track;
track = tracks.find(function (item) {
return toInt(item && item.index, 0) === episodeNumber;
});
if (track) return track;
return referenceIndex >= 0 && referenceIndex < tracks.length ? tracks[referenceIndex] : null;
}
function getNongminReferenceProfile(profiles) {
let best = null;
for (let i = 0; i < profiles.length; i++) {
const profile = profiles[i];
if (!best || Number(profile.trackCount || 0) > Number(best.trackCount || 0)) best = profile;
}
return best;
}
function formatNongminQuality(plan) {
plan = plan || {};
if (plan.resolution) return String(plan.resolution);
if (plan.width && plan.height) return String(plan.width) + "x" + String(plan.height);
if (plan.height) return String(plan.height) + "p";
if (plan.bandwidth) return Math.round(Number(plan.bandwidth) / 1000) + "kbps";
return "未知";
}
function nongminEngineLabel(plan) {
return plan && plan.playerType === "ijk" ? "MDK" : "Auto";
}
function buildNongminEpisodeItem(detailUrl, track, index, plan) {
const episodeNumber = nongminTrackEpisodeNumber(track, index);
const episodeTitle = track && (track.name || track.episode) || ("第" + pad2(episodeNumber) + "集");
return {
id: detailUrl + "#e" + episodeNumber + "-" + (index + 1),
seasonNumber: 1,
episodeNumber: episodeNumber,
episode: episodeNumber,
title: episodeTitle,
name: episodeTitle,
videoUrl: plan.url,
video_url: plan.url,
url: plan.url,
playUrl: plan.url,
playerType: plan.playerType || "system",
sourceName: plan.sourceName || "",
quality: formatNongminQuality(plan),
codecFamily: plan.codecFamily || "unknown"
};
}
async function resolveNongminSeriesEpisodes(detailUrl, profiles) {
const reference = getNongminReferenceProfile(profiles);
const referenceTracks = reference && reference.group && reference.group.tracks
? reference.group.tracks
: [];
const deadline = nowMs() + NONGMIN_EPISODE_BUDGET_MS;
const episodes = await mapNongminWithLimit(
referenceTracks,
NONGMIN_PROBE_CONCURRENCY,
async function (referenceTrack, referenceIndex) {
const episodeNumber = nongminTrackEpisodeNumber(referenceTrack, referenceIndex);
const candidates = [];
for (let i = 0; i < profiles.length; i++) {
const profile = profiles[i];
const track = findNongminEpisodeTrack(profile, episodeNumber, referenceIndex);
if (track && track.url) candidates.push({ profile: profile, track: track });
}
let fallback = null;
for (let i = 0; i < candidates.length; i++) {
const candidate = candidates[i];
const remaining = deadline - nowMs();
const timeout = remaining > 500
? Math.max(900, Math.min(NONGMIN_EPISODE_TIMEOUT_MS, remaining))
: 0;
const plan = await resolveNongminTrackPlan(
candidate.track.url,
detailUrl,
candidate.profile,
false,
timeout
);
if (!plan || !plan.url) continue;
const item = buildNongminEpisodeItem(detailUrl, candidate.track, referenceIndex, plan);
if (!fallback) fallback = item;
if (plan.verified) return item;
if (nowMs() >= deadline) return fallback;
}
return fallback;
}
);
return episodes.filter(Boolean);
}
async function resolveNongminMoviePlan(detailUrl, profiles) {
let fallback = null;
for (let i = 0; i < profiles.length; i++) {
const profile = profiles[i];
const track = profile && profile.group && profile.group.tracks && profile.group.tracks[0];
if (!track || !track.url) continue;
const plan = profile.plan || await resolveNongminTrackPlan(
track.url,
detailUrl,
profile,
true,
NONGMIN_HLS_TIMEOUT_MS
);
if (!plan || !plan.url) continue;
if (!fallback) fallback = plan;
if (plan.verified) return plan;
}
return fallback;
}
async function resolveDirectUrl(rawUrl) {
rawUrl = String(rawUrl || "").trim();
if (!rawUrl || isBadHref(rawUrl)) return "";
const cacheKey = makeCacheKey(["direct", rawUrl]);
const cached = cacheGet(cacheKey);
if (cached !== undefined) return cached;
const normalizedDirect = normalizePlayUrl(rawUrl);
if (normalizedDirect && /\.m3u8(?:[?#]|$)/i.test(normalizedDirect)) {
return cacheSet(cacheKey, normalizedDirect);
}
const playerUrl = PLAY_API + encodeURIComponent(rawUrl);
const res = await httpGet(playerUrl, {
"User-Agent": UA,
"Referer": SITE + "/",
"sec-fetch-site": "cross-site",
"sec-fetch-mode": "navigate",
"sec-fetch-dest": "iframe"
});
const html = (res && res.data) || "";
const directUrl = parsePlayerUrl(html);
return directUrl ? cacheSet(cacheKey, directUrl) : "";
}
async function loadResource(params) {
params = params || {};
const rawSeries = String(params.seriesName || params.title || "").trim();
const rawEpisodeName = String(params.episodeName || params.name || "").trim();
const baseTitle = stripTitleMeta(rawSeries) || rawSeries || rawEpisodeName;
if (!baseTitle) return [];
console.log(
"[nongmin-resource] start title=" + baseTitle +
" type=" + String(params.type || "") +
" keys=" + Object.keys(params).join(",")
);
try {
const wantBaseNorm = normalizeName(baseTitle);
const wantEpisode = getWantedEpisode(params);
let results = await searchSite(baseTitle);
if (!results.length && rawSeries && rawSeries !== baseTitle) {
results = await searchSite(rawSeries);
}
console.log("[nongmin-resource] search-count=" + results.length);
if (!results.length) return [];
const best = pickBestResult(results, wantBaseNorm);
if (!best || !best.url) return [];
const playlist = await loadPlaylist(best.url);
console.log("[nongmin-resource] group-count=" + (playlist ? playlist.length : 0));
if (!playlist || !playlist.length) return [];
const profiles = await buildNongminPlaybackProfiles(playlist, best.url);
if (!profiles.length) return [];
const maxTrackCount = profiles.reduce(function (count, profile) {
return Math.max(count, profile.trackCount || 0);
}, 0);
const playlistLooksSeries = maxTrackCount > 1 || profiles.some(function (profile) {
return looksLikeSeriesGroup(profile.group);
});
const resources = [];
const seen = {};
function addResource(name, description, plan) {
if (!plan || !plan.url || seen[plan.url]) return;
seen[plan.url] = true;
resources.push({
name: name,
description: description,
url: plan.url,
playerType: plan.playerType || "system"
});
}
if (!playlistLooksSeries) {
for (let i = 0; i < profiles.length; i++) {
const profile = profiles[i];
const track = profile.group.tracks[0];
const plan = profile.plan || await resolveNongminTrackPlan(
track.url,
best.url,
profile,
true,
NONGMIN_HLS_TIMEOUT_MS
);
addResource(
"农民影视 " + (profile.group.title || ("线路" + (i + 1))),
[
"匹配:" + best.rawTitle,
"线路:" + (profile.group.title || "默认线路"),
"画质:" + formatNongminQuality(plan),
"引擎:" + nongminEngineLabel(plan)
].join("\n"),
plan
);
}
} else if (wantEpisode > 0) {
for (let i = 0; i < profiles.length; i++) {
const profile = profiles[i];
const track = findNongminEpisodeTrack(profile, wantEpisode, Math.max(0, wantEpisode - 1));
if (!track || !track.url) continue;
const plan = await resolveNongminTrackPlan(
track.url,
best.url,
profile,
false,
NONGMIN_EPISODE_TIMEOUT_MS
);
addResource(
"农民影视 E" + pad2(wantEpisode) + " " + (profile.group.title || ("线路" + (i + 1))),
[
"匹配:" + best.rawTitle,
"线路:" + (profile.group.title || "默认线路"),
"集数:" + (track.name || ("E" + pad2(wantEpisode))),
"画质:" + formatNongminQuality(plan),
"引擎:" + nongminEngineLabel(plan)
].join("\n"),
plan
);
}
} else {
const episodes = await resolveNongminSeriesEpisodes(best.url, profiles);
for (let i = 0; i < episodes.length; i++) {
const episode = episodes[i];
const ep = toInt(episode.episodeNumber, i + 1);
addResource(
"农民影视 E" + pad2(ep) + " " + (episode.sourceName || "最佳线路"),
[
"匹配:" + best.rawTitle,
"集数:" + (episode.title || ("E" + pad2(ep))),
"线路:" + (episode.sourceName || "最佳线路"),
"画质:" + (episode.quality || "未知"),
"引擎:" + (episode.playerType === "ijk" ? "MDK" : "Auto")
].join("\n"),
{
url: episode.videoUrl,
playerType: episode.playerType
}
);
}
}
console.log("[nongmin-resource] direct-count=" + resources.length);
return resources;
} catch (error) {
console.log("[nongmin-resource] error=" + String(error && error.message || error));
return [];
}
}
@@ -0,0 +1,1369 @@
function rouOption(title, value) {
return { title: title, value: value || title };
}
var ROU_SORT_OPTIONS = [
rouOption("最新发布", "createdAt"),
rouOption("最多播放", "viewCount"),
rouOption("最多点赞", "likeCount"),
rouOption("最长时长", "duration"),
rouOption("最近更新", "updatedAt")
];
var ROU_CNAV_OPTIONS = [
rouOption("全部 国产AV", "國產AV"),
rouOption("糖心Vlog"),
rouOption("蜜桃影像傳媒"),
rouOption("香蕉視頻傳媒"),
rouOption("星空無限傳媒"),
rouOption("天美傳媒"),
rouOption("精東影業"),
rouOption("杏吧傳媒"),
rouOption("91製片廠"),
rouOption("皇家華人"),
rouOption("起點傳媒"),
rouOption("大象傳媒"),
rouOption("果凍傳媒"),
rouOption("蘿莉社"),
rouOption("ED Mosaic"),
rouOption("兔子先生"),
rouOption("扣扣傳媒"),
rouOption("SA國際傳媒"),
rouOption("愛神傳媒"),
rouOption("性視界傳媒"),
rouOption("PsychopornTW"),
rouOption("拍攝花絮"),
rouOption("抖陰"),
rouOption("91茄子"),
rouOption("絕對領域傳媒"),
rouOption("烏托邦傳媒"),
rouOption("紅斯燈影像"),
rouOption("草莓視頻"),
rouOption("渡邊傳媒"),
rouOption("葫蘆影業"),
rouOption("樂播傳媒"),
rouOption("Pussy Hunter"),
rouOption("麻麻傳媒"),
rouOption("三只狼傳媒"),
rouOption("萝莉原创"),
rouOption("辣椒原創"),
rouOption("MisAV"),
rouOption("SWAG@daisybaby"),
rouOption("冠希傳媒"),
rouOption("微密圈傳媒"),
rouOption("愛妃傳媒"),
rouOption("天美影院"),
rouOption("西瓜影視"),
rouOption("肉肉傳媒"),
rouOption("烏鴉傳媒"),
rouOption("日出文化"),
rouOption("鯨魚傳媒"),
rouOption("國產AV劇情"),
rouOption("SWAG@cartiernn"),
rouOption("TWAV"),
rouOption("Mini傳媒"),
rouOption("桃花源"),
rouOption("叮叮映畫"),
rouOption("蜜桃視頻"),
rouOption("O-STAR"),
rouOption("開心鬼傳媒"),
rouOption("葵心娛樂"),
rouOption("愛污傳媒")
];
var ROU_MADOU_OPTIONS = [
rouOption("全部 麻豆传媒", "麻豆傳媒"),
rouOption("愛豆傳媒"),
rouOption("MD"),
rouOption("MDX"),
rouOption("麻豆US"),
rouOption("MSD"),
rouOption("MCY"),
rouOption("MKY"),
rouOption("MPG"),
rouOption("FLIXKO"),
rouOption("貓爪影像"),
rouOption("國產麻豆AV節目"),
rouOption("麻豆女神微愛視頻"),
rouOption("麻豆番外"),
rouOption("麻豆三十天特別企劃"),
rouOption("麻豆導演系列"),
rouOption("情趣K歌房"),
rouOption("MDWP"),
rouOption("突襲女優家"),
rouOption("麻豆女優"),
rouOption("麻豆達人秀"),
rouOption("澀會"),
rouOption("MDS"),
rouOption("MDSR"),
rouOption("麻豆女神微愛影片"),
rouOption("MDL"),
rouOption("MAN"),
rouOption("MSM"),
rouOption("MDHT"),
rouOption("MDAG"),
rouOption("MS"),
rouOption("MSG"),
rouOption("MDJ"),
rouOption("MDM"),
rouOption("MXJ"),
rouOption("MDD"),
rouOption("MLT")
];
var ROU_ONLYFANS_OPTIONS = [
rouOption("全部 OnlyFans", "OnlyFans"),
rouOption("fansly"),
rouOption("tangbo_hu"),
rouOption("HongKongDoll"),
rouOption("BunnyMiffy"),
rouOption("Nana_Taipei"),
rouOption("qiobnxingcai"),
rouOption("suchanghub"),
rouOption("ssrpeach"),
rouOption("nicolove.cc"),
rouOption("Miuzxc"),
rouOption("kitty2002102"),
rouOption("kittyxkum"),
rouOption("yui_xin_tw"),
rouOption("juneliu"),
rouOption("YuZuKitty"),
rouOption("jeenzen"),
rouOption("monmon_tw"),
rouOption("applecptv"),
rouOption("andmlove"),
rouOption("Loliiiiipop99"),
rouOption("daintywilder"),
rouOption("ZZZ666"),
rouOption("aixiaixi"),
rouOption("ChiChibae"),
rouOption("blazeconjure3"),
rouOption("moremore618"),
rouOption("bdollairi"),
rouOption("olive_emmm"),
rouOption("chocoletmilkk"),
rouOption("SLRabbit"),
rouOption("Xreindeers"),
rouOption("Carla Grace")
];
var ROU_TANHUA_OPTIONS = [
rouOption("全部 探花", "探花"),
rouOption("91沈先生"),
rouOption("探花精選400"),
rouOption("小寶尋花"),
rouOption("91lisa"),
rouOption("調教小景甜"),
rouOption("午夜尋花"),
rouOption("91鳳鳴鳥唱"),
rouOption("大神精選"),
rouOption("AVOVE直播"),
rouOption("91貓先生"),
rouOption("千人斬探花"),
rouOption("全國探花"),
rouOption("91Fans"),
rouOption("七天探花"),
rouOption("9總全國探花"),
rouOption("91大神@LovELolita7"),
rouOption("18歲母狗無限高潮"),
rouOption("鴨哥探花"),
rouOption("錘子探花"),
rouOption("探花合集"),
rouOption("91不見星空"),
rouOption("早期東莞ISO桑拿系列"),
rouOption("91康先生"),
rouOption("肉オナホ"),
rouOption("91大神唐伯虎"),
rouOption("韋小寶"),
rouOption("91風流哥全集"),
rouOption("91蜜桃的合集"),
rouOption("換妻探花"),
rouOption("小陳頭星選"),
rouOption("91大神括約肌大叔"),
rouOption("情侶自拍"),
rouOption("探花精選"),
rouOption("91呆哥"),
rouOption("mmmn753"),
rouOption("楊導撩妹"),
rouOption("歌廳探花陳先生"),
rouOption("91美女涵菱"),
rouOption("太子探花"),
rouOption("小馬尋花"),
rouOption("91唐哥"),
rouOption("jimmybiiig"),
rouOption("91天堂原創"),
rouOption("小飛探花"),
rouOption("王子哥專啪學生妹"),
rouOption("文軒探花"),
rouOption("偉哥尋歡"),
rouOption("大草莓寶貝"),
rouOption("探花女下海直播"),
rouOption("91天堂系列"),
rouOption("91大神胖Kyo"),
rouOption("攝影師果哥出品"),
rouOption("莞式選妃"),
rouOption("catman"),
rouOption("90w粉"),
rouOption("探花大神"),
rouOption("91原創達人@多乙丶"),
rouOption("91大黃鴨"),
rouOption("小東全國尋妹"),
rouOption("91Dr哥"),
rouOption("大熊探花"),
rouOption("91約妹達人"),
rouOption("91大神揚風"),
rouOption("91愛絲小仙女思妍"),
rouOption("探花郎李尋歡"),
rouOption("91新晉大神sweattt"),
rouOption("91新人GD超模(現改名69DD"),
rouOption("91大神jinx"),
rouOption("91sex哥"),
rouOption("175車模"),
rouOption("東莞探花"),
rouOption("嫖嫖sex探花"),
rouOption("秀人網模特")
];
var ROU_SELFIE_OPTIONS = [
rouOption("全部 自拍流出", "自拍流出"),
rouOption("中國"),
rouOption("台灣"),
rouOption("twitter"),
rouOption("主播"),
rouOption("韓國"),
rouOption("絲襪"),
rouOption("多P"),
rouOption("素人"),
rouOption("偷拍"),
rouOption("情侶自拍"),
rouOption("91Fans"),
rouOption("91天堂原創"),
rouOption("大神精選"),
rouOption("糖心Vlog")
];
var WidgetMetadata = {
id: "rou_video",
title: "肉视频",
description: "rou.video 分类、排序、搜索与播放解析组件",
author: "Evil",
version: "1.2.5",
site: "https://rou.video",
icon: "https://rou.video/favicon.ico",
modules: [
// {
// id: "search",
// title: "站内搜索",
// type: "media_list",
// functionName: "searchVideos",
// cacheDuration: 300,
// timeoutSeconds: 30,
// params: [
// {
// name: "keyword",
// title: "搜索关键词",
// type: "input",
// description: "输入搜索关键词(标题、标签、演员/创作者等)",
// value: ""
// },
// { name: "page", title: "页码", type: "page", description: "页码", value: "1" }
// ]
// },
{
id: "home_sections",
title: "首页精选",
type: "media_list",
functionName: "loadSectionVideos",
cacheDuration: 1800,
timeoutSeconds: 30,
params: [
{
name: "section",
title: "分区",
type: "enum",
value: "latestVideos",
enumOptions: [
{ title: "最新更新", value: "latestVideos" },
{ title: "热门 91", value: "hot91" },
{ title: "热门 自拍流出", value: "hotSelfie" },
{ title: "热门 国产AV视频", value: "hotCNAV" },
{ title: "日榜 91", value: "dailyHot91" },
{ title: "日榜 自拍流出", value: "dailyHotSelfie" },
{ title: "日榜 国产AV视频", value: "dailyHotCNAV" },
{ title: "日榜 OnlyFans", value: "dailyOnlyFans" },
{ title: "日榜 日本区", value: "dailyJV" }
]
}
]
},
{
id: "cnav",
title: "国产AV",
type: "media_list",
functionName: "loadTagVideos",
cacheDuration: 1800,
timeoutSeconds: 30,
params: [
{ name: "tag", title: "子模块", type: "enum", value: "國產AV", enumOptions: ROU_CNAV_OPTIONS },
{ name: "order", title: "排序", type: "enum", value: "createdAt", enumOptions: ROU_SORT_OPTIONS },
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "madou",
title: "麻豆传媒",
type: "media_list",
functionName: "loadTagVideos",
cacheDuration: 1800,
timeoutSeconds: 30,
params: [
{ name: "tag", title: "子模块", type: "enum", value: "麻豆傳媒", enumOptions: ROU_MADOU_OPTIONS },
{ name: "order", title: "排序", type: "enum", value: "createdAt", enumOptions: ROU_SORT_OPTIONS },
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "onlyfans",
title: "OnlyFans",
type: "media_list",
functionName: "loadTagVideos",
cacheDuration: 1800,
timeoutSeconds: 30,
params: [
{ name: "tag", title: "子模块", type: "enum", value: "OnlyFans", enumOptions: ROU_ONLYFANS_OPTIONS },
{ name: "order", title: "排序", type: "enum", value: "createdAt", enumOptions: ROU_SORT_OPTIONS },
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "tanhua",
title: "探花",
type: "media_list",
functionName: "loadTagVideos",
cacheDuration: 1800,
timeoutSeconds: 30,
params: [
{ name: "tag", title: "子模块", type: "enum", value: "探花", enumOptions: ROU_TANHUA_OPTIONS },
{ name: "order", title: "排序", type: "enum", value: "createdAt", enumOptions: ROU_SORT_OPTIONS },
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "selfie",
title: "自拍流出",
type: "media_list",
functionName: "loadTagVideos",
cacheDuration: 1800,
timeoutSeconds: 30,
params: [
{ name: "tag", title: "子模块", type: "enum", value: "自拍流出", enumOptions: ROU_SELFIE_OPTIONS },
{ name: "order", title: "排序", type: "enum", value: "createdAt", enumOptions: ROU_SORT_OPTIONS },
{ name: "page", title: "页码", type: "page", value: "1" }
]
},
{
id: "loadResource",
title: "肉视频播放源",
description: "根据当前影片返回 MDK 标准化与 MPV 原始播放线路",
functionName: "loadResource",
type: "stream",
cacheDuration: 0,
timeoutSeconds: 45,
retryCount: 0,
params: []
}
],
search: {
title: "搜索",
functionName: "searchVideos",
params: [
{
name: "keyword",
title: "搜索关键词",
type: "input",
description: "输入搜索关键词(标题、标签、演员/创作者等)",
value: ""
},
{ name: "page", title: "页码", type: "page", description: "页码", value: "1" }
]
}
};
var ROU_SITE = "https://rou.video";
var ROU_IMAGE_PROXY = "https://external-content.duckduckgo.com/iu/";
var ROU_RESOURCE_LINKS = {};
var ROU_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
"Referer": ROU_SITE + "/"
};
function ensureArray(v) {
return Array.isArray(v) ? v : [];
}
function text(v) {
return String(v == null ? "" : v).replace(/\s+/g, " ").trim();
}
function stripRouLineSuffix(value) {
return text(value).replace(/\s*·\s*(?:MDK\s*标准化线路|MPV\s*原始线路)\s*$/i, "").trim();
}
function normalizeRouMatchTitle(value) {
return stripRouLineSuffix(value)
.toLowerCase()
.replace(/\s+/g, "")
.replace(/[·•:,,。!?"'“”‘’()()\[\]【】{}<>《》_\-.\/\\|~]/g, "");
}
function rouTitleHash(value) {
value = String(value || "");
var hash = 2166136261;
for (var i = 0; i < value.length; i++) {
hash ^= value.charCodeAt(i);
hash = Math.imul ? Math.imul(hash, 16777619) : ((hash * 16777619) | 0);
}
return (hash >>> 0).toString(16);
}
function rouResourceStorageKey(title) {
var normalized = normalizeRouMatchTitle(title);
return normalized ? ("rou.resource.link." + rouTitleHash(normalized) + "." + normalized.length) : "";
}
function rememberRouResourceLink(title, link) {
var key = rouResourceStorageKey(title);
link = text(link);
if (!key || !/^https?:\/\//i.test(link)) return;
ROU_RESOURCE_LINKS[key] = link;
try {
if (Widget.storage && typeof Widget.storage.set === "function") {
Widget.storage.set(key, link);
}
} catch (e) {
console.log("[rou.video] resource link cache write failed: " + (e && e.message ? e.message : e));
}
}
function getRememberedRouResourceLink(title) {
var key = rouResourceStorageKey(title);
if (!key) return "";
if (ROU_RESOURCE_LINKS[key]) return ROU_RESOURCE_LINKS[key];
try {
if (Widget.storage && typeof Widget.storage.get === "function") {
var stored = text(Widget.storage.get(key, ""));
if (/^https?:\/\//i.test(stored)) {
ROU_RESOURCE_LINKS[key] = stored;
return stored;
}
}
} catch (e) {
console.log("[rou.video] resource link cache read failed: " + (e && e.message ? e.message : e));
}
return "";
}
function utf8BytesToString(bytes) {
var result = "";
var i = 0;
while (i < bytes.length) {
var first = bytes[i++] & 255;
var codePoint = first;
if (first >= 240 && i + 2 < bytes.length) {
codePoint = ((first & 7) << 18) |
((bytes[i++] & 63) << 12) |
((bytes[i++] & 63) << 6) |
(bytes[i++] & 63);
} else if (first >= 224 && i + 1 < bytes.length) {
codePoint = ((first & 15) << 12) |
((bytes[i++] & 63) << 6) |
(bytes[i++] & 63);
} else if (first >= 192 && i < bytes.length) {
codePoint = ((first & 31) << 6) | (bytes[i++] & 63);
}
if (codePoint > 65535) {
codePoint -= 65536;
result += String.fromCharCode(55296 + (codePoint >> 10));
result += String.fromCharCode(56320 + (codePoint & 1023));
} else {
result += String.fromCharCode(codePoint);
}
}
return result;
}
function responseBodyToString(data) {
if (typeof data === "string") return data;
if (data == null) return "";
var bytes = null;
if (Array.isArray(data)) {
bytes = data;
} else if (typeof Uint8Array !== "undefined" && data instanceof Uint8Array) {
bytes = data;
} else if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) {
bytes = new Uint8Array(data);
} else if (data && data.buffer && typeof Uint8Array !== "undefined") {
try {
bytes = new Uint8Array(data.buffer, data.byteOffset || 0, data.byteLength || data.length);
} catch (e) {
bytes = null;
}
}
return bytes ? utf8BytesToString(bytes) : String(data);
}
function proxyCoverUrl(url) {
url = absoluteUrl(url);
if (!/^https?:\/\//i.test(url)) return url;
if (url.indexOf(ROU_IMAGE_PROXY) === 0) return url;
return ROU_IMAGE_PROXY + "?u=" + encodeURIComponent(url) + "&f=1&nofb=1";
}
function absoluteUrl(url) {
url = text(url);
if (!url) return "";
if (url.indexOf("//") === 0) return "https:" + url;
if (/^https?:\/\//i.test(url)) return url;
if (url.charAt(0) === "/") return ROU_SITE + url;
return ROU_SITE + "/" + url;
}
function formatDuration(seconds) {
seconds = Number(seconds || 0);
if (!isFinite(seconds) || seconds <= 0) return "";
seconds = Math.round(seconds);
var h = Math.floor(seconds / 3600);
var m = Math.floor((seconds % 3600) / 60);
var s = seconds % 60;
function pad(n) { return n < 10 ? "0" + n : String(n); }
return h > 0 ? (h + ":" + pad(m) + ":" + pad(s)) : (pad(m) + ":" + pad(s));
}
function formatCount(value) {
var n = Number(value || 0);
if (!isFinite(n) || n <= 0) return "";
if (n >= 100000000) return (n / 100000000).toFixed(1).replace(/\.0$/, "") + "亿";
if (n >= 10000) return (n / 10000).toFixed(1).replace(/\.0$/, "") + "万";
return String(Math.round(n));
}
function createPlaceholderItem(message) {
return {
id: "placeholder",
type: "placeholder",
title: "提示",
description: text(message || "暂无内容"),
posterPath: "",
backdropPath: "",
mediaType: "movie",
duration: 0,
durationText: "",
previewUrl: "",
videoUrl: "",
url: "",
playUrl: "",
link: "",
playerType: "none"
};
}
function base64ToBytes(input) {
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var clean = String(input || "").replace(/[^A-Za-z0-9+/=]/g, "");
var bytes = [];
var buffer = 0;
var bits = 0;
for (var i = 0; i < clean.length; i++) {
var c = clean.charAt(i);
if (c === "=") break;
var value = chars.indexOf(c);
if (value < 0) continue;
buffer = (buffer << 6) | value;
bits += 6;
if (bits >= 8) {
bits -= 8;
bytes.push((buffer >> bits) & 255);
}
}
return bytes;
}
function decodeRouVideoPayload(ev) {
if (!ev || !ev.d) return {};
var key = Number(ev.k || 0);
var bytes = base64ToBytes(ev.d);
var text = "";
for (var i = 0; i < bytes.length; i++) {
text += String.fromCharCode((bytes[i] - key + 256) & 255);
}
try {
return JSON.parse(text);
} catch (e) {
return {};
}
}
function extractNextData(html) {
var match = String(html || "").match(/<script id="__NEXT_DATA__" type="application\/json">([\s\S]*?)<\/script>/i);
if (!match) return {};
try {
var raw = JSON.parse(match[1]);
return (raw && raw.props && raw.props.pageProps) ? raw.props.pageProps : {};
} catch (e) {
return {};
}
}
async function requestTextWithRetry(url, headers, timeout, validator, label, maxAttempts) {
var lastError = null;
var attempts = Math.max(1, parseInt(maxAttempts, 10) || 2);
for (var attempt = 1; attempt <= attempts; attempt++) {
try {
var resp = await Widget.http.get(url, {
headers: headers || {},
allow_redirects: true,
timeout: timeout
});
var status = Number(resp && resp.status || 0);
var body = responseBodyToString(resp && resp.data);
if (!resp || resp.ok === false || status >= 400) {
throw new Error((label || "request") + " HTTP " + status);
}
if (!body || (validator && !validator(body))) {
throw new Error((label || "request") + " response validation failed");
}
return body;
} catch (e) {
lastError = e;
console.log("[rou.video] " + (label || "request") + " attempt " + attempt + " failed: " + (e && e.message ? e.message : e));
}
}
throw lastError || new Error((label || "request") + " failed");
}
function fetchHtmlWithOptions(url, timeout, attempts, label) {
return requestTextWithRetry(url, ROU_HEADERS, timeout || 8000, function (body) {
return body.indexOf("__NEXT_DATA__") >= 0;
}, label || "html", attempts || 2);
}
function fetchHtml(url) {
return fetchHtmlWithOptions(url, 8000, 2, "html");
}
function getPlayHeaders(referer) {
return {
"User-Agent": ROU_HEADERS["User-Agent"],
"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,video/*,*/*",
"Referer": referer || ROU_SITE + "/",
"Origin": ROU_SITE
};
}
function normalizeRouVideoUrl(url) {
url = text(url);
if (!url) return "";
return url.replace(/\/index\.jpg(?=([?#]|$))/i, "/index.m3u8");
}
function parseRouHlsAttributeList(attributeText) {
var attributes = {};
var source = String(attributeText || "");
var token = "";
var quoted = false;
for (var i = 0; i <= source.length; i++) {
var ch = i < source.length ? source.charAt(i) : ",";
if (ch === '"' && source.charAt(i - 1) !== "\\") {
quoted = !quoted;
token += ch;
continue;
}
if (ch === "," && !quoted) {
var part = token.trim();
token = "";
if (!part) continue;
var equalsIndex = part.indexOf("=");
if (equalsIndex <= 0) continue;
var key = part.slice(0, equalsIndex).trim().toUpperCase();
var value = part.slice(equalsIndex + 1).trim();
if (value.length >= 2 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') {
value = value.slice(1, -1).replace(/\\"/g, '"');
}
attributes[key] = value;
continue;
}
token += ch;
}
return attributes;
}
function positiveRouNumber(value) {
var result = parseInt(String(value || ""), 10);
return isFinite(result) && result > 0 ? result : 0;
}
function parseRouHlsResolution(value, uri) {
var width = 0;
var height = 0;
var declared = String(value || "").match(/(\d{2,5})\s*x\s*(\d{2,5})/i);
if (declared) {
width = positiveRouNumber(declared[1]);
height = positiveRouNumber(declared[2]);
}
var pathText = String(uri || "");
if (!width || !height) {
var pathResolution = pathText.match(/(?:^|[\/_\-.])(\d{2,5})x(\d{2,5})(?:[\/_\-.]|$)/i);
if (pathResolution) {
width = positiveRouNumber(pathResolution[1]);
height = positiveRouNumber(pathResolution[2]);
}
}
if (!height) {
var heightOnly = pathText.match(/(?:^|[\/_\-.])(\d{3,4})p(?:[\/_\-.]|$)/i);
if (heightOnly) {
height = positiveRouNumber(heightOnly[1]);
width = height ? Math.round(height * 16 / 9) : 0;
}
}
return { width: width, height: height, pixels: width && height ? width * height : 0 };
}
function resolveRouHlsUrl(uri, playlistUrl) {
var value = text(uri);
var base = text(playlistUrl);
if (!value) return "";
if (/^https?:\/\//i.test(value)) return normalizeRouVideoUrl(value);
if (/^\/\//.test(value)) return normalizeRouVideoUrl("https:" + value);
try {
if (typeof URL !== "undefined") return normalizeRouVideoUrl(new URL(value, base).toString());
} catch (e) {}
var cleanBase = base.replace(/[?#].*$/, "");
var originMatch = cleanBase.match(/^(https?:\/\/[^/]+)/i);
if (value.charAt(0) === "/" && originMatch) return normalizeRouVideoUrl(originMatch[1] + value);
var slashIndex = cleanBase.lastIndexOf("/");
var directory = slashIndex >= 0 ? cleanBase.slice(0, slashIndex + 1) : cleanBase + "/";
return normalizeRouVideoUrl(directory + value.replace(/^\.\//, ""));
}
function parseRouMasterVariants(masterText, playlistUrl) {
var lines = String(masterText || "").split(/\r?\n/);
var variants = [];
for (var i = 0; i < lines.length; i++) {
var line = String(lines[i] || "").trim();
if (!/^#EXT-X-STREAM-INF\s*:/i.test(line)) continue;
var attrs = parseRouHlsAttributeList(line.slice(line.indexOf(":") + 1));
var uri = "";
var uriIndex = i + 1;
while (uriIndex < lines.length) {
var candidate = String(lines[uriIndex] || "").trim();
if (!candidate || candidate.charAt(0) === "#") {
uriIndex++;
continue;
}
uri = candidate;
break;
}
if (!uri) continue;
var resolution = parseRouHlsResolution(attrs.RESOLUTION, uri);
var averageBandwidth = positiveRouNumber(attrs["AVERAGE-BANDWIDTH"]);
var peakBandwidth = positiveRouNumber(attrs.BANDWIDTH);
variants.push({
url: resolveRouHlsUrl(uri, playlistUrl),
uri: uri,
codecs: String(attrs.CODECS || ""),
resolution: String(attrs.RESOLUTION || ""),
width: resolution.width,
height: resolution.height,
pixels: resolution.pixels,
bandwidth: averageBandwidth || peakBandwidth,
peakBandwidth: peakBandwidth,
frameRate: parseFloat(String(attrs["FRAME-RATE"] || "0")) || 0,
order: variants.length
});
i = uriIndex;
}
return variants;
}
function selectRouHighestQualityVariant(masterText, playlistUrl) {
var variants = parseRouMasterVariants(masterText, playlistUrl);
if (!variants.length) return null;
variants.sort(function (left, right) {
var fields = ["pixels", "height", "width", "bandwidth", "peakBandwidth", "frameRate", "order"];
for (var i = 0; i < fields.length; i++) {
var difference = Number(right[fields[i]] || 0) - Number(left[fields[i]] || 0);
if (difference) return difference;
}
return 0;
});
return variants[0];
}
function classifyRouCodecString(codecs) {
var value = String(codecs || "").toLowerCase();
if (/(^|[,\s])(hvc1|hev1|hevc|h265|av01|av1|vp09|vp9|dvhe|dvh1)(?:\.|[,\s]|$)/i.test(value)) return "modern";
if (/(^|[,\s])(avc1|avc3|h264)(?:\.|[,\s]|$)/i.test(value)) return "avc";
return "unknown";
}
function firstRouMediaSegmentUrl(playlist, playlistUrl) {
var lines = String(playlist || "").split(/\r?\n/);
var expectSegment = false;
for (var i = 0; i < lines.length; i++) {
var line = String(lines[i] || "").trim();
if (/^#EXTINF:/i.test(line)) {
expectSegment = true;
continue;
}
if (!line || line.charAt(0) === "#") continue;
if (expectSegment) return resolveRouHlsUrl(line, playlistUrl);
expectSegment = false;
}
return "";
}
function responseBodyToBytes(data) {
if (data == null) return [];
if (Array.isArray(data)) return data;
if (typeof Uint8Array !== "undefined" && data instanceof Uint8Array) return data;
if (typeof ArrayBuffer !== "undefined" && data instanceof ArrayBuffer) return new Uint8Array(data);
if (data && data.buffer && typeof Uint8Array !== "undefined") {
try {
return new Uint8Array(data.buffer, data.byteOffset || 0, data.byteLength || data.length);
} catch (e) {}
}
var source = String(data || "");
var bytes = [];
var limit = Math.min(source.length, 131072);
for (var i = 0; i < limit; i++) bytes.push(source.charCodeAt(i) & 255);
return bytes;
}
function inspectRouTsCodec(bytes) {
bytes = responseBodyToBytes(bytes);
var syncOffset = -1;
for (var offset = 0; offset < 188 && offset + 376 < bytes.length; offset++) {
if (bytes[offset] === 71 && bytes[offset + 188] === 71 && bytes[offset + 376] === 71) {
syncOffset = offset;
break;
}
}
if (syncOffset < 0) return { family: "unknown", streamTypes: [] };
var pmtPid = -1;
var streamTypes = [];
for (var packet = syncOffset; packet + 188 <= bytes.length; packet += 188) {
if (bytes[packet] !== 71) continue;
var payloadStart = (bytes[packet + 1] & 64) !== 0;
var pid = ((bytes[packet + 1] & 31) << 8) | bytes[packet + 2];
var adaptation = (bytes[packet + 3] >> 4) & 3;
if (adaptation === 0 || adaptation === 2) continue;
var cursor = packet + 4;
if (adaptation === 3) cursor += 1 + bytes[cursor];
if (cursor >= packet + 188) continue;
if (payloadStart) cursor += 1 + bytes[cursor];
if (cursor + 12 >= packet + 188) continue;
if (pid === 0 && bytes[cursor] === 0) {
var patLength = ((bytes[cursor + 1] & 15) << 8) | bytes[cursor + 2];
var patEnd = Math.min(cursor + 3 + patLength - 4, packet + 188);
for (var pat = cursor + 8; pat + 3 < patEnd; pat += 4) {
var program = (bytes[pat] << 8) | bytes[pat + 1];
if (program) {
pmtPid = ((bytes[pat + 2] & 31) << 8) | bytes[pat + 3];
break;
}
}
} else if (pid === pmtPid && bytes[cursor] === 2) {
var pmtLength = ((bytes[cursor + 1] & 15) << 8) | bytes[cursor + 2];
var programInfoLength = ((bytes[cursor + 10] & 15) << 8) | bytes[cursor + 11];
var pmtEnd = Math.min(cursor + 3 + pmtLength - 4, packet + 188);
for (var stream = cursor + 12 + programInfoLength; stream + 4 < pmtEnd;) {
var streamType = bytes[stream];
var infoLength = ((bytes[stream + 3] & 15) << 8) | bytes[stream + 4];
if (streamTypes.indexOf(streamType) < 0) streamTypes.push(streamType);
stream += 5 + infoLength;
}
if (streamTypes.length) break;
}
}
if (streamTypes.indexOf(36) >= 0) return { family: "modern", streamTypes: streamTypes };
if (streamTypes.indexOf(27) >= 0) return { family: "avc", streamTypes: streamTypes };
return { family: "unknown", streamTypes: streamTypes };
}
async function probeRouSegmentCodec(segmentUrl, referer, timeout) {
var headers = getPlayHeaders(referer);
headers.Range = "bytes=0-65535";
var response = await Widget.http.get(segmentUrl, {
headers: headers,
allow_redirects: true,
timeout: Math.max(2000, Math.min(Number(timeout || 3500), 4500))
});
var status = Number(response && response.status || 0);
if (!response || response.ok === false || status >= 400) throw new Error("segment probe HTTP " + status);
return inspectRouTsCodec(response.data);
}
function stringToUtf8Bytes(input) {
input = String(input || "");
var bytes = [];
for (var i = 0; i < input.length; i++) {
var codePoint = input.charCodeAt(i);
if (codePoint >= 55296 && codePoint <= 56319 && i + 1 < input.length) {
var next = input.charCodeAt(i + 1);
if (next >= 56320 && next <= 57343) {
codePoint = 65536 + ((codePoint - 55296) << 10) + (next - 56320);
i++;
}
}
if (codePoint <= 127) {
bytes.push(codePoint);
} else if (codePoint <= 2047) {
bytes.push(192 | (codePoint >> 6));
bytes.push(128 | (codePoint & 63));
} else if (codePoint <= 65535) {
bytes.push(224 | (codePoint >> 12));
bytes.push(128 | ((codePoint >> 6) & 63));
bytes.push(128 | (codePoint & 63));
} else {
bytes.push(240 | (codePoint >> 18));
bytes.push(128 | ((codePoint >> 12) & 63));
bytes.push(128 | ((codePoint >> 6) & 63));
bytes.push(128 | (codePoint & 63));
}
}
return bytes;
}
function bytesToBase64(bytes) {
var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
var result = "";
for (var i = 0; i < bytes.length; i += 3) {
var a = bytes[i] & 255;
var hasB = i + 1 < bytes.length;
var hasC = i + 2 < bytes.length;
var b = hasB ? (bytes[i + 1] & 255) : 0;
var c = hasC ? (bytes[i + 2] & 255) : 0;
var value = (a << 16) | (b << 8) | c;
result += chars.charAt((value >> 18) & 63);
result += chars.charAt((value >> 12) & 63);
result += hasB ? chars.charAt((value >> 6) & 63) : "=";
result += hasC ? chars.charAt(value & 63) : "=";
}
return result;
}
function rewriteRouPlaylistForMdk(playlist) {
var lines = String(playlist || "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n").split("\n");
var expectSegment = false;
var rewritten = 0;
for (var i = 0; i < lines.length; i++) {
var trimmed = lines[i].trim();
if (/^#EXTINF:/i.test(trimmed)) {
expectSegment = true;
continue;
}
if (!trimmed || trimmed.charAt(0) === "#") continue;
if (expectSegment && /^https?:\/\//i.test(trimmed) && /\.jpg(?=([?#]|$))/i.test(trimmed)) {
lines[i] = lines[i].replace(/\.jpg(?=([?#]|$))/i, ".ts");
rewritten++;
}
expectSegment = false;
}
if (!/^\s*#EXTM3U/i.test(lines.join("\n")) || rewritten < 1) return null;
return { body: lines.join("\n"), segmentCount: rewritten };
}
function createMdkPlaylistDataUrl(playlist) {
var normalized = rewriteRouPlaylistForMdk(playlist);
if (!normalized) return null;
if (normalized.body.length > 800000) throw new Error("playlist is too large for inline transport");
return {
url: "data:application/vnd.apple.mpegurl;base64," + bytesToBase64(stringToUtf8Bytes(normalized.body)),
segmentCount: normalized.segmentCount
};
}
async function buildMdkPlaylistDataUrl(videoUrl, referer, timeout, attempts) {
var playlist = await requestTextWithRetry(videoUrl, getPlayHeaders(referer), timeout || 6000, function (body) {
return /^\s*#EXTM3U/i.test(body);
}, "playlist", attempts || 2);
var result = createMdkPlaylistDataUrl(playlist);
if (!result) throw new Error("playlist has no disguised MPEG-TS segments");
return result;
}
async function resolveRouPlaybackPlan(videoUrl, referer, timeout, attempts) {
var originalUrl = normalizeRouVideoUrl(videoUrl);
var playlistTimeout = Math.max(3000, Math.min(Number(timeout || 5000), 5500));
var playlistAttempts = Math.max(1, Math.min(parseInt(attempts, 10) || 1, 2));
var initialPlaylist = await requestTextWithRetry(originalUrl, getPlayHeaders(referer), playlistTimeout, function (body) {
return /^\s*#EXTM3U/i.test(body);
}, "playlist-inspection", playlistAttempts);
var selected = selectRouHighestQualityVariant(initialPlaylist, originalUrl);
var rawUrl = selected && selected.url ? selected.url : originalUrl;
var mediaPlaylist = selected ? "" : initialPlaylist;
if (selected) {
try {
mediaPlaylist = await requestTextWithRetry(rawUrl, getPlayHeaders(referer), playlistTimeout, function (body) {
return /^\s*#EXTM3U/i.test(body) && !/#EXT-X-STREAM-INF/i.test(body);
}, "selected-playlist", 1);
} catch (selectedError) {
console.log("[rou.video] selected playlist inspection failed: " + (selectedError && selectedError.message ? selectedError.message : selectedError));
}
}
var codecFamily = classifyRouCodecString(selected && selected.codecs);
var streamTypes = [];
var segmentUrl = mediaPlaylist ? firstRouMediaSegmentUrl(mediaPlaylist, rawUrl) : "";
if (codecFamily === "unknown" && segmentUrl) {
try {
var codecProbe = await probeRouSegmentCodec(segmentUrl, referer, Math.min(4000, playlistTimeout));
codecFamily = codecProbe.family;
streamTypes = codecProbe.streamTypes;
} catch (probeError) {
console.log("[rou.video] segment codec probe failed: " + (probeError && probeError.message ? probeError.message : probeError));
}
}
var mdkPlaylist = codecFamily === "modern" || !mediaPlaylist ? null : createMdkPlaylistDataUrl(mediaPlaylist);
var rawPlayerType = codecFamily === "avc" && mediaPlaylist && !mdkPlaylist ? "ijk" : "system";
var resolution = selected && (selected.resolution || (selected.width && selected.height ? selected.width + "x" + selected.height : ""));
console.log("[rou.video] selected quality=" + (resolution || "single") + " codec=" + codecFamily + " engine=" + (mdkPlaylist && codecFamily === "avc" ? "MDK" : rawPlayerType));
return {
rawUrl: rawUrl,
rawPlayerType: rawPlayerType,
codecFamily: codecFamily,
codecs: selected ? selected.codecs : "",
resolution: resolution || "",
bandwidth: selected ? selected.bandwidth : 0,
streamTypes: streamTypes,
mdkPlaylist: mdkPlaylist
};
}
function buildVideoItem(item) {
item = item || {};
var id = text(item.id || item.vid || item.nameZh || item.name);
var title = text(item.nameZh || item.name || item.title || id);
var cover = proxyCoverUrl(item.coverImageUrl || "");
var tags = ensureArray(item.tagsZh && item.tagsZh.length ? item.tagsZh : item.tags);
var durationText = formatDuration(item.duration);
var detailUrl = item.id ? (ROU_SITE + "/v/" + encodeURIComponent(item.id)) : absoluteUrl(item.ref || "");
var desc = [];
if (item.vid) desc.push("编号: " + text(item.vid));
if (durationText) desc.push("时长: " + durationText);
if (item.viewCount != null) desc.push("播放: " + formatCount(item.viewCount));
if (tags.length) desc.push("标签: " + tags.slice(0, 4).join(" / "));
if (item.ref) desc.push("来源: " + text(item.ref));
return {
id: id || detailUrl || title,
type: "movie",
title: title || id || "肉视频",
description: desc.join("\n"),
posterPath: cover,
backdropPath: cover,
mediaType: "movie",
duration: Math.round(Number(item.duration || 0)) || 0,
durationText: durationText,
previewUrl: "",
videoUrl: "",
url: "",
playUrl: "",
link: detailUrl,
detailUrl: detailUrl,
playerType: "none"
};
}
function mapList(items) {
return ensureArray(items).map(buildVideoItem);
}
async function loadHomeData() {
var html = await fetchHtml(ROU_SITE + "/home");
return extractNextData(html);
}
async function loadSectionVideos(params) {
params = params || {};
var section = text(params.section || "latestVideos");
var data = await loadHomeData();
var list = ensureArray(data[section]);
if (!list.length) {
return [createPlaceholderItem("未找到该分区内容")];
}
return mapList(list);
}
function buildTagUrl(tag, order, page) {
tag = text(tag || "自拍流出");
order = text(order || "createdAt");
page = parseInt(page, 10) || 1;
var url = ROU_SITE + "/t/" + encodeURIComponent(tag) + "?order=" + encodeURIComponent(order);
url += "&page=" + encodeURIComponent(page);
return url;
}
async function loadTagVideos(params) {
params = params || {};
var tag = text(params.tag || params.category || "自拍流出");
var order = text(params.order || params.sort || "createdAt");
var page = parseInt(params.page, 10) || 1;
var html = await fetchHtml(buildTagUrl(tag, order, page));
var data = extractNextData(html);
var list = ensureArray(data.videos);
if (!list.length) {
return [createPlaceholderItem("未找到「" + tag + "」相关内容")];
}
return mapList(list);
}
async function searchVideos(params) {
params = params || {};
var keyword = text(params.keyword || params.query || params.q || "");
var page = parseInt(params.page, 10) || 1;
if (!keyword) return [createPlaceholderItem("请输入搜索关键词")];
var url = ROU_SITE + "/search?q=" + encodeURIComponent(keyword) + "&t=&sort=&page=" + encodeURIComponent(page);
var html = await fetchHtml(url);
var data = extractNextData(html);
var list = ensureArray(data.videos);
if (!list.length) {
return [createPlaceholderItem("未找到与「" + keyword + "」相关的内容")];
}
return mapList(list);
}
async function loadDetail(link) {
try {
var requestProfile = arguments.length > 1 && arguments[1] ? arguments[1] : {};
var detailLink = link;
if (detailLink && typeof detailLink === "object") {
detailLink = detailLink.detailUrl || detailLink.link || detailLink.id || "";
}
detailLink = text(detailLink);
if (!detailLink) return createPlaceholderItem("\u7f3a\u5c11\u8be6\u60c5\u94fe\u63a5");
var html = await fetchHtmlWithOptions(
detailLink,
Number(requestProfile.htmlTimeout || 8000),
Number(requestProfile.htmlAttempts || 2),
"detail"
);
var data = extractNextData(html);
var video = data.video || {};
var item = buildVideoItem(video);
rememberRouResourceLink(item.title, detailLink);
var payload = decodeRouVideoPayload(data.ev);
var videoUrl = normalizeRouVideoUrl(payload.videoUrl || "");
if (!videoUrl) throw new Error("video URL not found");
var detailBase = {
id: item.id,
type: "detail",
title: item.title,
description: item.description || "\u6682\u65e0\u7b80\u4ecb",
posterPath: item.posterPath,
backdropPath: item.backdropPath,
mediaType: "movie",
duration: item.duration,
durationText: item.durationText,
previewUrl: text(payload.thumbVTTUrl || ""),
link: "",
detailUrl: ""
};
try {
var playback = await resolveRouPlaybackPlan(
videoUrl,
detailLink,
Number(requestProfile.playlistTimeout || 6000),
Number(requestProfile.playlistAttempts || 2)
);
videoUrl = playback.rawUrl || videoUrl;
var playHeaders = getPlayHeaders(detailLink);
var rawLine = Object.assign({}, detailBase, {
id: item.id + ":raw",
title: item.title,
description: (item.description || "\u6682\u65e0\u7b80\u4ecb") + "\n\u7ebf\u8def: Auto/MPV \u539f\u59cb HLS",
videoUrl: videoUrl,
url: videoUrl,
playUrl: videoUrl,
playerType: playback.rawPlayerType || "system",
customHeaders: playHeaders,
headers: playHeaders
});
var mdkLine = playback.mdkPlaylist ? Object.assign({}, detailBase, {
id: item.id + ":mdk",
title: item.title,
description: (item.description || "\u6682\u65e0\u7b80\u4ecb") + "\n\u7ebf\u8def: MDK \u6807\u51c6\u5316\uff08" + playback.mdkPlaylist.segmentCount + " \u4e2a TS \u5206\u7247\uff09",
videoUrl: playback.mdkPlaylist.url,
url: playback.mdkPlaylist.url,
playUrl: playback.mdkPlaylist.url,
playerType: "ijk",
customHeaders: playHeaders,
headers: playHeaders
}) : null;
if (mdkLine && playback.codecFamily === "avc") return [mdkLine, rawLine];
if (mdkLine && playback.codecFamily === "unknown") return [rawLine, mdkLine];
return [rawLine];
} catch (playlistError) {
console.log("[rou.video] playback inspection failed: " + (playlistError && playlistError.message ? playlistError.message : playlistError));
var fallbackHeaders = getPlayHeaders(detailLink);
return Object.assign({}, detailBase, {
videoUrl: videoUrl,
url: videoUrl,
playUrl: videoUrl,
playerType: "system",
customHeaders: fallbackHeaders,
headers: fallbackHeaders
});
}
} catch (e) {
console.error("[rou.video] loadDetail failed", e && e.message ? e.message : e);
return {
id: text(link) || "detail",
type: "detail",
title: "\u8089\u89c6\u9891",
description: "\u8be6\u60c5\u9875\u89e3\u6790\u5931\u8d25",
posterPath: "",
backdropPath: "",
mediaType: "movie",
duration: 0,
durationText: "",
previewUrl: "",
videoUrl: "",
url: "",
playUrl: "",
playerType: "none",
link: ""
};
}
}
function scoreRouResourceCandidate(item, targetTitle) {
var candidate = normalizeRouMatchTitle(item && item.title);
var target = normalizeRouMatchTitle(targetTitle);
if (!candidate || !target) return 0;
if (candidate === target) return 100000;
if (candidate.indexOf(target) >= 0 || target.indexOf(candidate) >= 0) {
return 50000 - Math.abs(candidate.length - target.length);
}
return 0;
}
async function findRouResourceItem(title) {
title = stripRouLineSuffix(title);
if (!title) return null;
var remembered = getRememberedRouResourceLink(title);
if (remembered) {
return { title: title, link: remembered, detailUrl: remembered };
}
var url = ROU_SITE + "/search?q=" + encodeURIComponent(title) + "&t=&sort=&page=1";
var html = await requestTextWithRetry(url, ROU_HEADERS, 6000, function (body) {
return body.indexOf("__NEXT_DATA__") >= 0;
}, "resource-search", 1);
var data = extractNextData(html);
var candidates = mapList(ensureArray(data.videos)).filter(function (item) {
return item && item.link;
});
var best = null;
var bestScore = 0;
for (var i = 0; i < candidates.length; i++) {
var score = scoreRouResourceCandidate(candidates[i], title);
if (score > bestScore) {
bestScore = score;
best = candidates[i];
}
}
if (bestScore >= 50000 && best) {
rememberRouResourceLink(best.title || title, best.link);
return best;
}
return null;
}
async function loadResource(params) {
params = params || {};
var rawTitle = params.seriesName || params.title || params.name || params.episodeName || "";
var title = stripRouLineSuffix(rawTitle);
if (!title) return [];
try {
var matched = await findRouResourceItem(title);
if (!matched || !matched.link) return [];
var detail = await loadDetail(matched.link, {
htmlTimeout: 6000,
htmlAttempts: 1,
playlistTimeout: 5000,
playlistAttempts: 2
});
var lines = Array.isArray(detail)
? detail
: (detail && Array.isArray(detail.playSources) && detail.playSources.length
? detail.playSources
: [detail]);
var resources = [];
for (var i = 0; i < lines.length; i++) {
var line = lines[i] || {};
var url = String(line.videoUrl || line.url || "");
if (!url) continue;
var isMdk = /:mdk$/i.test(String(line.id || "")) || /MDK\s*标准化线路/i.test(String(line.title || ""));
var name = isMdk ? "肉视频 MDK 标准化" : "肉视频 MPV 原始";
resources.push({
name: name,
description: "匹配: " + (matched.title || title) + "\n线路: " + (isMdk ? "标准 TS 清单" : "原始伪装 HLS"),
url: url
});
}
return resources;
} catch (e) {
console.error("[rou.video] loadResource failed", e && e.message ? e.message : e);
return [];
}
}
+118
View File
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
import re,json,requests
from urllib.parse import quote,urljoin
try:
from base.spider import Spider as BaseSpider
except Exception:
class BaseSpider:
def __init__(self):
return None
class Spider(BaseSpider):
def __init__(self):
self.host='https://www.adultporna-av107.com'
self.session=requests.Session()
self.headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36','Referer':self.host+'/zzzz','Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'}
self.classes=[('每日最新','/topic/'),('国产视频','/t/163/'),('网曝黑料','/t/232/'),('主播大秀','/t/236/'),('AV解说','/t/233/'),('国产自拍','/t/48/'),('抖阴视频','/t/231/'),('国模私拍','/t/45/'),('空姐模特','/t/67/'),('91制片厂','/t/131/'),('糖心VLOG','/t/128/'),('日本有码','/label/sortjp/'),('国产传媒','/label/sortcnseries/'),('番号专区','/label/sortseries/')]
def init(self,extend=''):
return None
def getName(self):
return 'AdultPorna'
def isVideoFormat(self,url):
return str(url).split('?')[0].lower().endswith(('.m3u8','.mp4','.flv','.avi','.mkv','.mov'))
def manualVideoCheck(self):
return True
def destroy(self):
return None
def _get(self,url):
u=url if str(url).startswith('http') else urljoin(self.host,url)
r=self.session.get(u,headers=self.headers,timeout=15,verify=False)
r.encoding=r.apparent_encoding or 'utf-8'
return r.text
def _url(self,u):
return urljoin(self.host,u.replace('\\/','/')) if u else ''
def _txt(self,s):
return re.sub(r'\s+',' ',re.sub(r'<[^>]+>',' ',s or '')).strip()
def _pic(self,s):
m=re.search(r'<img[^>]+(?:data-original|data-src|data-lazyload|src)=["\']([^"\']+)["\']',s,re.I)
return self._url(m.group(1)) if m else ''
def _parse_list(self,html):
arr=[];seen=set()
blocks=re.findall(r'<a[^>]+href=["\'](/voddetail/(\d+)/?)["\'][^>]*>.*?</a>',html,re.I|re.S)
for href,vid in blocks:
if vid in seen: continue
i=html.find(href);chunk=html[max(0,i-800):i+2500]
tm=re.search(r'<a[^>]+href=["\']'+re.escape(href)+r'["\'][^>]*(?:title=["\']([^"\']+)["\'])?',chunk,re.I|re.S)
am=re.search(r'<img[^>]+alt=["\']([^"\']+)["\']',chunk,re.I)
name=(tm.group(1) if tm and tm.group(1) else '') or (am.group(1) if am else '') or vid
sm=re.search(r'<small[^>]*>(.*?)</small>',chunk,re.I|re.S)
arr.append({'vod_id':vid,'vod_name':self._txt(name),'vod_pic':self._pic(chunk),'vod_remarks':self._txt(sm.group(1)) if sm else ''})
seen.add(vid)
return arr
def homeContent(self,filter=False):
html=self._get('/zzzz')
return {'class':[{'type_id':i,'type_name':n} for n,i in self.classes],'list':self._parse_list(html),'filters':{}}
def homeVideoContent(self):
return {'list':self._parse_list(self._get('/zzzz'))}
def categoryContent(self,tid,pg,filter,extend):
tid=str(tid);pg=str(pg or '1')
path=tid if tid.startswith('/') else '/t/'+tid.strip('/')+'/'
if pg!='1': path=path.rstrip('/')+'/page/'+pg+'/'
html=self._get(path)
data=self._parse_list(html)
return {'list':data,'page':int(pg),'pagecount':999 if data else int(pg),'limit':24,'total':999999}
def detailContent(self,ids):
vid=str(ids[0]).strip('/').split('/')[-1]
html=self._get('/voddetail/'+vid+'/')
name='';pic='';content=''
for p in [r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']',r'<h1[^>]*>(.*?)</h1>',r'title=["\']([^"\']+)["\']']:
m=re.search(p,html,re.I|re.S)
if m and self._txt(m.group(1)): name=self._txt(m.group(1));break
m=re.search(r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']',html,re.I)
pic=self._url(m.group(1)) if m else self._pic(html)
em=re.search(r'(?:剧情|简介|介绍|详情)[^<]*</[^>]+>\s*<[^>]+>(.*?)</',html,re.I|re.S)
content=self._txt(em.group(1)) if em else name
plays=[]
for href in re.findall(r'href=["\'](/v/'+re.escape(vid)+r'/?[^"\']*)["\']',html,re.I):
if href not in plays: plays.append(href)
if not plays: plays=['/v/'+vid+'/']
vod={'vod_id':vid,'vod_name':name or vid,'vod_pic':pic,'type_name':'','vod_year':'','vod_area':'','vod_remarks':'','vod_actor':'','vod_director':'','vod_content':content,'vod_play_from':'播放','vod_play_url':'#'.join(['%d集$%s'%(i+1,self._url(u)) for i,u in enumerate(plays)])}
return {'list':[vod]}
def searchContent(self,key,quick,pg='1'):
html=self._get('/s/?wd='+quote(str(key)))
data=self._parse_list(html)
return {'list':data,'page':int(pg or 1),'pagecount':1,'limit':24,'total':len(data)}
def playerContent(self,flag,id,vipFlags):
pid=str(id)
url=pid if pid.startswith('http') else self._url('/v/'+pid.strip('/').split('/')[-1]+'/')
if self.isVideoFormat(url): return {'parse':0,'playUrl':'','url':url,'header':self.headers}
html=self._get(url)
m=re.search(r'player_aaaa\s*=\s*(\{.*?\})\s*<',html,re.I|re.S) or re.search(r'player_aaaa\s*=\s*(\{.*?\})',html,re.I|re.S)
play=''
if m:
try:
play=json.loads(m.group(1).replace('\\/','/')).get('url','')
except json.JSONDecodeError:
mm=re.search(r'["\']url["\']\s*:\s*["\']([^"\']+)["\']',m.group(1));play=mm.group(1).replace('\\/','/') if mm else ''
if not play:
mm=re.search(r'(https?:\\?/\\?/[^"\']+?\.(?:m3u8|mp4)[^"\']*)',html,re.I);play=mm.group(1).replace('\\/','/') if mm else url
return {'parse':0 if self.isVideoFormat(play) else 1,'playUrl':'','url':play,'header':self.headers}
def localProxy(self,param):
return [404,'text/plain','']
+118
View File
@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
import re,json,requests
from urllib.parse import quote,urljoin
try:
from base.spider import Spider as BaseSpider
except Exception:
class BaseSpider:
def __init__(self):
return None
class Spider(BaseSpider):
def __init__(self):
self.host='https://www.adultporna-av107.com'
self.session=requests.Session()
self.headers={'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36','Referer':self.host+'/zzzz','Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'}
self.classes=[('每日最新','/topic/'),('国产视频','/t/163/'),('网曝黑料','/t/232/'),('主播大秀','/t/236/'),('AV解说','/t/233/'),('国产自拍','/t/48/'),('抖阴视频','/t/231/'),('国模私拍','/t/45/'),('空姐模特','/t/67/'),('91制片厂','/t/131/'),('糖心VLOG','/t/128/'),('日本有码','/label/sortjp/'),('国产传媒','/label/sortcnseries/'),('番号专区','/label/sortseries/')]
def init(self,extend=''):
return None
def getName(self):
return 'AdultPorna'
def isVideoFormat(self,url):
return str(url).split('?')[0].lower().endswith(('.m3u8','.mp4','.flv','.avi','.mkv','.mov'))
def manualVideoCheck(self):
return True
def destroy(self):
return None
def _get(self,url):
u=url if str(url).startswith('http') else urljoin(self.host,url)
r=self.session.get(u,headers=self.headers,timeout=15,verify=False)
r.encoding=r.apparent_encoding or 'utf-8'
return r.text
def _url(self,u):
return urljoin(self.host,u.replace('\\/','/')) if u else ''
def _txt(self,s):
return re.sub(r'\s+',' ',re.sub(r'<[^>]+>',' ',s or '')).strip()
def _pic(self,s):
m=re.search(r'<img[^>]+(?:data-original|data-src|data-lazyload|src)=["\']([^"\']+)["\']',s,re.I)
return self._url(m.group(1)) if m else ''
def _parse_list(self,html):
arr=[];seen=set()
blocks=re.findall(r'<a[^>]+href=["\'](/voddetail/(\d+)/?)["\'][^>]*>.*?</a>',html,re.I|re.S)
for href,vid in blocks:
if vid in seen: continue
i=html.find(href);chunk=html[max(0,i-800):i+2500]
tm=re.search(r'<a[^>]+href=["\']'+re.escape(href)+r'["\'][^>]*(?:title=["\']([^"\']+)["\'])?',chunk,re.I|re.S)
am=re.search(r'<img[^>]+alt=["\']([^"\']+)["\']',chunk,re.I)
name=(tm.group(1) if tm and tm.group(1) else '') or (am.group(1) if am else '') or vid
sm=re.search(r'<small[^>]*>(.*?)</small>',chunk,re.I|re.S)
arr.append({'vod_id':vid,'vod_name':self._txt(name),'vod_pic':self._pic(chunk),'vod_remarks':self._txt(sm.group(1)) if sm else ''})
seen.add(vid)
return arr
def homeContent(self,filter=False):
html=self._get('/zzzz')
return {'class':[{'type_id':i,'type_name':n} for n,i in self.classes],'list':self._parse_list(html),'filters':{}}
def homeVideoContent(self):
return {'list':self._parse_list(self._get('/zzzz'))}
def categoryContent(self,tid,pg,filter,extend):
tid=str(tid);pg=str(pg or '1')
path=tid if tid.startswith('/') else '/t/'+tid.strip('/')+'/'
if pg!='1': path=path.rstrip('/')+'/page/'+pg+'/'
html=self._get(path)
data=self._parse_list(html)
return {'list':data,'page':int(pg),'pagecount':999 if data else int(pg),'limit':24,'total':999999}
def detailContent(self,ids):
vid=str(ids[0]).strip('/').split('/')[-1]
html=self._get('/voddetail/'+vid+'/')
name='';pic='';content=''
for p in [r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']',r'<h1[^>]*>(.*?)</h1>',r'title=["\']([^"\']+)["\']']:
m=re.search(p,html,re.I|re.S)
if m and self._txt(m.group(1)): name=self._txt(m.group(1));break
m=re.search(r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']',html,re.I)
pic=self._url(m.group(1)) if m else self._pic(html)
em=re.search(r'(?:剧情|简介|介绍|详情)[^<]*</[^>]+>\s*<[^>]+>(.*?)</',html,re.I|re.S)
content=self._txt(em.group(1)) if em else name
plays=[]
for href in re.findall(r'href=["\'](/v/'+re.escape(vid)+r'/?[^"\']*)["\']',html,re.I):
if href not in plays: plays.append(href)
if not plays: plays=['/v/'+vid+'/']
vod={'vod_id':vid,'vod_name':name or vid,'vod_pic':pic,'type_name':'','vod_year':'','vod_area':'','vod_remarks':'','vod_actor':'','vod_director':'','vod_content':content,'vod_play_from':'播放','vod_play_url':'#'.join(['%d集$%s'%(i+1,self._url(u)) for i,u in enumerate(plays)])}
return {'list':[vod]}
def searchContent(self,key,quick,pg='1'):
html=self._get('/s/?wd='+quote(str(key)))
data=self._parse_list(html)
return {'list':data,'page':int(pg or 1),'pagecount':1,'limit':24,'total':len(data)}
def playerContent(self,flag,id,vipFlags):
pid=str(id)
url=pid if pid.startswith('http') else self._url('/v/'+pid.strip('/').split('/')[-1]+'/')
if self.isVideoFormat(url): return {'parse':0,'playUrl':'','url':url,'header':self.headers}
html=self._get(url)
m=re.search(r'player_aaaa\s*=\s*(\{.*?\})\s*<',html,re.I|re.S) or re.search(r'player_aaaa\s*=\s*(\{.*?\})',html,re.I|re.S)
play=''
if m:
try:
play=json.loads(m.group(1).replace('\\/','/')).get('url','')
except json.JSONDecodeError:
mm=re.search(r'["\']url["\']\s*:\s*["\']([^"\']+)["\']',m.group(1));play=mm.group(1).replace('\\/','/') if mm else ''
if not play:
mm=re.search(r'(https?:\\?/\\?/[^"\']+?\.(?:m3u8|mp4)[^"\']*)',html,re.I);play=mm.group(1).replace('\\/','/') if mm else url
return {'parse':0 if self.isVideoFormat(play) else 1,'playUrl':'','url':play,'header':self.headers}
def localProxy(self,param):
return [404,'text/plain','']
+196
View File
@@ -0,0 +1,196 @@
# -*- coding: utf-8 -*-
import re
import urllib.parse
import requests
try:
from base.spider import Spider as BaseSpider
except ImportError:
class BaseSpider:
pass
class Spider(BaseSpider):
BASE_URL = "https://www.motv.app"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36",
"Referer": "https://www.motv.app/",
"Accept": "*/*"
}
CATES = [
{"type_name": "日本有碼", "type_id": "20"},
{"type_name": "日本無碼", "type_id": "50"},
{"type_name": "歐美风情", "type_id": "25"},
{"type_name": "國產原創", "type_id": "41"},
{"type_name": "動畫", "type_id": "29"},
{"type_name": "水果AV", "type_id": "35"},
{"type_name": "色情情燴", "type_id": "30"},
{"type_name": "經典四級", "type_id": "47"},
{"type_name": "鹹濕電台", "type_id": "169"}
]
def __init__(self):
self.session = requests.Session()
self.session.headers.update(self.HEADERS)
def getName(self):
return "MOTV"
def init(self, extend=""):
pass
def isVideoFormat(self, url):
return False
def manualVideoCheck(self):
return False
def homeContent(self, filter):
return {"class": self.CATES, "filters": {}, "list": self._ajax_list("20", 1), "parse": 0, "jx": 0}
def homeVideoContent(self):
return {"list": self._ajax_list("20", 1)}
def categoryContent(self, tid, pg, filter, extend):
pg = int(pg or 1)
data = self._ajax_json(tid, pg)
return {
"list": self._json_list(data),
"page": pg,
"pagecount": self._to_int(data.get("pagecount") or data.get("page_count") or data.get("last_page"), 999),
"limit": self._to_int(data.get("limit"), 20),
"total": self._to_int(data.get("total"), 9999)
}
def detailContent(self, ids):
vid = ids[0] if isinstance(ids, list) else ids
html = self._get(urllib.parse.urljoin(self.BASE_URL, "/voddetail/%s/" % vid))
name = self._match(html, r"<h1[^>]*>(.*?)</h1>") or self._match(html, r"vod_name\s*=\s*['\"]([^'\"]+)")
pic = self._match(html, r"vod_pic\s*=\s*['\"]([^'\"]+)")
desc = self._clean(self._match(html, r'<div[^>]+class="[^"]*(?:content|detail|desc|sketch)[^"]*"[^>]*>(.*?)</div>'))
play_urls = self._play_urls(html, vid)
vod = {
"vod_id": vid,
"vod_name": self._clean(name) or vid,
"vod_pic": urllib.parse.urljoin(self.BASE_URL, pic or ""),
"type_name": "",
"vod_year": "",
"vod_area": "",
"vod_remarks": "",
"vod_actor": "",
"vod_director": "",
"vod_content": desc,
"vod_play_from": "MOTV",
"vod_play_url": play_urls
}
return {"list": [vod], "parse": 0, "jx": 0}
def searchContent(self, key, quick, pg="1"):
wd = urllib.parse.quote(key)
url = "%s/index.php/ajax/data?mid=1&wd=%s&page=%s&limit=20" % (self.BASE_URL, wd, pg)
return {"list": self._json_list(self._json(url))}
def playerContent(self, flag, id, vipFlags):
play_url = urllib.parse.urljoin(self.BASE_URL, id or "")
page = self._get(play_url)
watch = self._player_url(page)
if watch:
watch_html = self._get(watch, {"Referer": play_url, "Origin": self.BASE_URL, "Accept": "*/*"})
m3u8 = self._match(watch_html, r"var\s+src\s*=\s*['\"]([^'\"]+\.m3u8[^'\"]*)")
if not m3u8:
m3u8 = self._match(watch_html, r"['\"]([^'\"]+\.m3u8[^'\"]*)")
if m3u8:
real = urllib.parse.urljoin(watch, m3u8.replace("\\/", "/"))
u = urllib.parse.urlparse(watch)
return {"parse": 0, "playUrl": "", "url": real, "jx": 0, "header": {"User-Agent": self.HEADERS["User-Agent"], "Referer": watch, "Origin": u.scheme + "://" + u.netloc}}
return {"parse": 0, "playUrl": "", "url": watch, "jx": 0, "header": {"User-Agent": self.HEADERS["User-Agent"], "Referer": play_url, "Origin": self.BASE_URL}}
return {"parse": 1, "playUrl": "", "url": play_url, "jx": 0, "header": {"User-Agent": self.HEADERS["User-Agent"], "Referer": self.BASE_URL + "/"}}
def localProxy(self, param):
return None
def _ajax_json(self, tid, pg):
tid = str(tid or "20")
pg = str(pg or "1")
url = "%s/index.php/ajax/data?mid=1&tid=%s&page=%s&limit=20" % (self.BASE_URL, urllib.parse.quote(tid), urllib.parse.quote(pg))
return self._json(url, {"Referer": "%s/vodtype/%s/" % (self.BASE_URL, tid)})
def _ajax_list(self, tid, pg):
return self._json_list(self._ajax_json(tid, pg))
def _json_list(self, data):
arr = data.get("list") or data.get("data") or []
if isinstance(arr, dict):
arr = arr.get("list") or arr.get("data") or []
result = []
seen = set()
for item in arr:
vid = str(item.get("vod_id") or item.get("id") or item.get("vodid") or "")
name = item.get("vod_name") or item.get("name") or item.get("title") or ""
pic = item.get("vod_pic") or item.get("pic") or item.get("cover") or item.get("vod_pic_thumb") or ""
remarks = item.get("vod_remarks") or item.get("remarks") or item.get("note") or item.get("vod_time") or ""
if vid and name and vid not in seen:
seen.add(vid)
result.append({"vod_id": vid, "vod_name": self._clean(name), "vod_pic": urllib.parse.urljoin(self.BASE_URL, pic), "vod_remarks": self._clean(remarks)})
return result
def _play_urls(self, html, vid):
rows = re.findall(r'href=["\']([^"\']*/vodplay/%s-\d+-\d+/?)["\'][^>]*>(.*?)<' % re.escape(str(vid)), html, re.S)
if not rows:
return "第1集$%s/vodplay/%s-1-1/" % (self.BASE_URL, vid)
result = []
seen = set()
for url, name in rows:
full = urllib.parse.urljoin(self.BASE_URL, url)
if full not in seen:
seen.add(full)
result.append("%s$%s" % (self._clean(name) or ("%s" % len(result + [1])), full))
return "#".join(result) or "第1集$%s/vodplay/%s-1-1/" % (self.BASE_URL, vid)
def _player_url(self, html):
raw = self._match(html, r"player_aaaa\s*=\s*(\{.*?\})")
if not raw:
raw = self._match(html, r"player_data\s*=\s*(\{.*?\})")
if raw:
url = self._match(raw, r'"url"\s*:\s*"([^"]+)"') or self._match(raw, r"'url'\s*:\s*'([^']+)'")
if url:
return url.replace("\\/", "/")
iframe = self._match(html, r'<iframe[^>]+src=["\']([^"\']+)')
return urllib.parse.urljoin(self.BASE_URL, iframe) if iframe else ""
def _json(self, url, headers=None):
try:
h = self.HEADERS.copy()
if headers:
h.update(headers)
r = self.session.get(url, headers=h, timeout=15)
r.encoding = "utf-8"
return r.json()
except Exception:
return {}
def _get(self, url, headers=None):
try:
h = self.HEADERS.copy()
if headers:
h.update(headers)
r = self.session.get(url, headers=h, timeout=15)
r.encoding = "utf-8"
return r.text
except Exception:
return ""
def _match(self, text, pattern):
m = re.search(pattern, text or "", re.S)
return m.group(1).strip() if m else ""
def _clean(self, text):
text = re.sub(r"<[^>]+>", " ", text or "")
text = text.replace("&nbsp;", " ").replace("&amp;", "&").replace("&quot;", '"').replace("&#39;", "'")
return re.sub(r"\s+", " ", text).strip()
def _to_int(self, value, default=0):
try:
return int(value)
except Exception:
return default
@@ -0,0 +1,1023 @@
# -*- coding: utf-8 -*-
# //@name:豆瓣导航
# //@id:douban_meta_wish
# //@version:2
import json
import math
import re
import threading
import time
from collections import OrderedDict
from concurrent.futures import ThreadPoolExecutor, as_completed
from urllib.parse import quote, urlencode
import requests
from lxml import html
from requests.adapters import HTTPAdapter
from base.spider import Spider as BaseSpider
class Spider(BaseSpider):
name = "豆瓣导航"
host = "https://m.douban.com"
backend_parse = False
category_mode = False
categoryMode = False
API = "https://m.douban.com/rexxar/api/v2"
MOVIE = "https://movie.douban.com"
ACTION_PREFIX = "douban-wish:add:"
ERROR_PREFIX = "douban-error:"
FILTER_CACHE_KEY = "douban_meta_wish_filters_v4_navigation"
CATEGORIES = (
("hotmovie", "热门电影"),
("hottv", "热门剧集"),
("hotzy", "热门综艺"),
("movielist", "电影榜单"),
("tvlist", "电视榜单"),
("moviefilter", "电影筛选"),
("tvfilter", "电视筛选"),
("anime", "动漫"),
("wishlist", "豆瓣想看"),
)
MOVIE_LISTS = (
("实时热门电影", "movie_real_time_hotest"),
("一周口碑电影榜", "movie_weekly_best"),
("豆瓣电影Top250", "top250"),
)
TV_LISTS = (
("实时热门剧集", "tv_real_time_hotest"),
("华语口碑剧集榜", "tv_chinese_best_weekly"),
("全球口碑剧集榜", "tv_global_best_weekly"),
("国内口碑综艺榜", "show_chinese_best_weekly"),
("国外口碑综艺榜", "show_global_best_weekly"),
)
AREAS = ("中国大陆", "中国香港", "中国台湾", "美国", "英国", "日本", "韩国", "法国", "德国", "印度", "泰国")
MOVIE_TYPES = ("剧情", "喜剧", "动作", "爱情", "科幻", "动画", "悬疑", "犯罪", "惊悚", "恐怖", "纪录片", "短片")
TV_TYPES = ("电视剧", "综艺")
SERIES_TYPES = ("国产剧", "港剧", "台剧", "日剧", "韩剧", "美剧", "英剧")
SHOW_TYPES = ("真人秀", "脱口秀", "音乐", "喜剧", "旅行", "竞技")
TV_GENRES = ("剧情", "喜剧", "爱情", "悬疑", "动画", "武侠", "古装", "家庭", "犯罪", "科幻", "恐怖", "历史", "战争", "动作", "冒险", "传记", "奇幻", "惊悚", "灾难", "歌舞", "音乐")
TAGS = ("经典", "热门", "高分", "青春", "家庭", "治愈", "女性", "成长", "历史", "战争", "奇幻", "冒险", "推理", "人性", "真实事件改编")
PLATFORMS = ("Netflix", "HBO", "Disney+", "BBC", "NHK", "TVB", "爱奇艺", "腾讯视频", "优酷", "芒果TV")
SORTS = (("综合排序", "T"), ("近期热度", "U"), ("首映/首播时间", "R"), ("高分优先", "S"))
ANIME_SORTS = (("热度", "U"), ("更新时间", "R"), ("评分", "S"))
ANIME_REGIONS = {
"cn": "中国大陆",
"jp": "日本",
"kr": "韩国",
"us": "美国",
}
LEGACY_ANIME_REGIONS = {
"anime_cn": "中国大陆",
"anime_jp": "日本",
"anime_kr": "韩国",
"anime_us": "美国",
}
ANIME_GENRES = ("热血", "冒险", "奇幻", "科幻", "校园", "治愈", "搞笑", "恋爱", "悬疑", "运动", "音乐", "历史", "机战", "推理")
ANIME_FORMATS = ("TV动画", "剧场版", "OVA", "网络动画", "动画短片")
def __init__(self):
try:
super().__init__()
except Exception:
pass
self.timeout = 15
self.cache_ttl = 180
self.list_cache_ttl = 120
self.collection_cache_ttl = 300
self.detail_cache_ttl = 86400
self.wishlist_cache_ttl = 20
self.top250_cache_ttl = 21600
self.stale_ttl = 86400
self.cache_max_entries = 256
self.failure_ttl = 60
self.filter_cache_ttl = 21600
self.dynamic_filters = True
self.persistent_filter_cache = True
self.image_headers = True
self.verify_tls = True
self.trust_env = True
self.proxy = ""
self.cookie = ""
self.ck = ""
self.user_id = ""
self.user_agent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
)
self._session = None
self._cache = OrderedDict()
self._failures = {}
self._cache_lock = threading.RLock()
self._filters = None
self._filters_at = 0
self._reset_session()
def getName(self):
return self.name
def init(self, extend=""):
config = self._parse_config(extend)
self.timeout = self._bounded_int(config.get("timeout"), 15, 5, 45)
self.cache_ttl = self._bounded_int(config.get("cache_ttl"), 180, 0, 3600)
self.list_cache_ttl = self._bounded_int(config.get("list_cache_ttl"), 120, 10, 1800)
self.collection_cache_ttl = self._bounded_int(config.get("collection_cache_ttl"), 300, 30, 3600)
self.detail_cache_ttl = self._bounded_int(config.get("detail_cache_ttl"), 86400, 300, 604800)
self.wishlist_cache_ttl = self._bounded_int(config.get("wishlist_cache_ttl"), 20, 5, 300)
self.top250_cache_ttl = self._bounded_int(config.get("top250_cache_ttl"), 21600, 300, 86400)
self.stale_ttl = self._bounded_int(config.get("stale_ttl"), 86400, 300, 604800)
self.cache_max_entries = self._bounded_int(config.get("cache_max_entries"), 256, 32, 1024)
self.failure_ttl = self._bounded_int(config.get("failure_ttl"), 60, 10, 600)
self.filter_cache_ttl = self._bounded_int(config.get("filter_cache_ttl"), 21600, 300, 86400)
self.dynamic_filters = self._bool_value(config.get("dynamic_filters"), True)
self.persistent_filter_cache = self._bool_value(config.get("persistent_filter_cache"), True)
self.image_headers = self._bool_value(config.get("image_headers"), True)
self.verify_tls = self._bool_value(config.get("verify_tls"), True)
self.trust_env = self._bool_value(config.get("trust_env"), True)
self.proxy = str(config.get("proxy") or "").strip()
self.cookie = str(config.get("cookie") or "").strip()
self.user_id = str(config.get("user_id") or "").strip().strip("/")
self.ck = str(config.get("ck") or self._cookie_value(self.cookie, "ck") or "").strip()
ua = str(config.get("user_agent") or "").strip()
if ua:
self.user_agent = ua
with self._cache_lock:
self._cache.clear()
self._failures.clear()
self._filters = None
self._filters_at = 0
self._reset_session()
if not self.user_id and self.cookie:
self._resolve_user_id()
def destroy(self):
if self._session is not None:
try:
self._session.close()
except Exception:
pass
def homeContent(self, filter=False):
result = {"class": [{"type_id": key, "type_name": name} for key, name in self.CATEGORIES]}
if filter:
result["filters"] = self._get_filters()
return result
def homeVideoContent(self):
try:
params = {"start": 0, "count": 30, "updated_at": "", "items_only": 1, "for_mobile": 1}
data = self._get_json(self.API + "/subject_collection/subject_real_time_hotest/items", params=params, ttl=self.list_cache_ttl)
items = self._parse_collection_items(data)
if items:
return {"list": items}
raise RuntimeError("实时热门列表为空")
except Exception as primary:
try:
fallback = self._category_media("movie", 1, {"sort": "U"})
if fallback.get("list"):
return {"list": fallback["list"]}
except Exception:
pass
return {"list": [self._error_card("首页载入失败", primary)]}
def categoryContent(self, tid, pg, filter=False, extend=None):
page = self._positive_int(pg, 1)
ext = self._parse_extend(extend)
try:
if tid == "hotmovie":
return self._category_media("movie", page, ext)
if tid == "hottv":
return self._category_media("tv", page, ext)
if tid == "hotzy":
return self._category_media("show", page, ext)
if tid == "movielist":
return self._category_movie_list(page, self._value(ext, "1", "movie_real_time_hotest"), ext)
if tid == "tvlist":
return self._category_collection(page, self._value(ext, "1", "tv_real_time_hotest"), ext)
if tid == "moviefilter":
return self._category_recommend("movie", page, ext)
if tid == "tvfilter":
return self._category_recommend("tv", page, ext)
if tid == "anime":
region_key = self._value(ext, "region", "cn")
return self._category_anime(self.ANIME_REGIONS.get(region_key, "中国大陆"), page, ext)
if tid in self.LEGACY_ANIME_REGIONS:
return self._category_anime(self.LEGACY_ANIME_REGIONS[tid], page, ext)
if tid == "wishlist":
return self._category_wishlist(page)
return self._page_result([], page, page, 0, 20)
except Exception as exc:
return self._page_result([self._error_card("分类载入失败", exc)], page, page, 1, 20)
def detailContent(self, ids):
subject_id = self._first_id(ids)
if subject_id.startswith("atvp_detail:"):
subject_id = subject_id[len("atvp_detail:"):]
if subject_id.startswith(self.ERROR_PREFIX):
text = subject_id[len(self.ERROR_PREFIX):]
return {"list": [{"vod_id": subject_id, "vod_name": "豆瓣错误", "vod_content": text}]}
subject_id = self._subject_id(subject_id)
if not subject_id:
return {"list": []}
try:
data = self._get_json(self.API + "/subject/" + subject_id, params={"for_mobile": 1}, ttl=self.detail_cache_ttl)
rating = self._rating(data)
title = str(data.get("title") or "")
original = str(data.get("original_title") or "")
names = title if not original or original == title else title + " / " + original
content = str(data.get("intro") or data.get("card_subtitle") or "").strip()
honors = self._names(data.get("honor_infos"), "title", 3)
if honors:
content = (content + "\n\n榜单:" + honors).strip()
vod = {
"vod_id": subject_id,
"vod_name": names,
"vod_pic": self._image(self._pic(data, large=True)),
"type_name": ", ".join(data.get("genres") or []),
"vod_year": str(data.get("year") or ""),
"vod_area": ", ".join(data.get("countries") or []),
"vod_remarks": self._detail_remark(data, rating),
"vod_actor": self._names(data.get("actors"), "name", 12),
"vod_director": self._names(data.get("directors"), "name", 6),
"vod_content": content,
"vod_play_from": "",
"vod_play_url": "",
}
return {"list": [vod]}
except Exception as exc:
return {"list": [self._error_card("详情载入失败", exc, subject_id)]}
def searchContent(self, key, quick=False, pg="1"):
return {"list": []}
def playerContent(self, flag, id, vipFlags=None):
return {
"parse": 0,
"jx": 0,
"playUrl": "",
"url": "",
"header": {},
"msg": "豆瓣仅提供影视资料,请使用详情页的全局搜索查找播放源",
}
def localProxy(self, param):
return [404, "text/plain; charset=utf-8", "not found"]
def action(self, action):
value = str(action or "")
if not value.startswith(self.ACTION_PREFIX):
return json.dumps({"msg": "不支持的豆瓣操作"}, ensure_ascii=False)
subject_id = self._subject_id(value[len(self.ACTION_PREFIX):])
if not subject_id:
return json.dumps({"msg": "豆瓣条目编号无效"}, ensure_ascii=False)
if not self.cookie or not self.ck:
return json.dumps({"msg": "未配置豆瓣 Cookie/ck,无法加入想看"}, ensure_ascii=False)
try:
url = self.MOVIE + "/j/subject/%s/interest" % subject_id
headers = {
"Referer": self.MOVIE + "/subject/%s/" % subject_id,
"X-Requested-With": "XMLHttpRequest",
}
data = {"interest": "wish", "ck": self.ck, "tags": "", "comment": "", "privacy": "public"}
response = self._session.post(url, headers=headers, data=data, timeout=self.timeout, verify=self.verify_tls)
payload = self._json_response(response)
if response.status_code == 200 and str(payload.get("r", "0")) == "0":
self._drop_cache_prefix("wishlist:")
return json.dumps({"msg": "已加入豆瓣想看"}, ensure_ascii=False)
if response.status_code in (401, 403) or str(payload.get("code", "")) == "403":
message = "豆瓣登录已失效,请更新 Cookie/ck"
else:
message = str(payload.get("msg") or payload.get("error") or "豆瓣未确认收藏成功")
return json.dumps({"msg": message}, ensure_ascii=False)
except Exception as exc:
return json.dumps({"msg": "加入想看失败:%s" % self._short_error(exc)}, ensure_ascii=False)
def _category_search_subjects(self, kind, page, tag, ext):
limit = 50
params = {"type": kind, "tag": tag or "热门", "page_limit": limit, "page_start": (page - 1) * limit}
data = self._get_json(self.MOVIE + "/j/search_subjects", params=params, ttl=self.list_cache_ttl)
items = []
for raw in data.get("subjects") or []:
items.append(self._subject_card(raw, ext))
pagecount = page + 1 if len(items) >= limit else page
return self._page_result(items, page, pagecount, pagecount * limit, limit)
def _category_media(self, media, page, ext):
limit = 20
sort = self._value(ext, "sort", "U")
if sort not in {item[1] for item in self.ANIME_SORTS}:
sort = "U"
area = self._value(ext, "area", "")
year = self._value(ext, "year", "")
genre = self._value(ext, "type" if media == "movie" else "genre", "")
platform = self._value(ext, "platform", "")
tag = self._value(ext, "tag", "")
if media == "movie":
endpoint = "movie"
selected = {"类型": genre, "地区": area}
expected_type = "movie"
tags = [genre, area, year, tag]
else:
endpoint = "tv"
form = "电视剧" if media == "tv" else "综艺"
selected = {"类型": genre, "形式": form, "地区": area}
expected_type = "tv"
tags = [form, genre, area, year, platform, tag]
params = {
"refresh": 0,
"start": (page - 1) * limit,
"count": limit,
"selected_categories": json.dumps(selected, ensure_ascii=False, separators=(",", ":")),
"uncollect": "false",
"sort": sort,
"tags": ",".join([item for item in tags if item]),
}
data = self._get_json(self.API + "/%s/recommend" % endpoint, params=params, ttl=self.list_cache_ttl)
items = []
for raw in data.get("items") or []:
raw_type = str(raw.get("type") or "")
if raw_type and raw_type != expected_type:
continue
items.append(self._collection_card(raw, ext))
total = self._positive_int(data.get("total"), 0)
pagecount = int(math.ceil(float(total) / limit)) if total else page + (1 if len(items) >= limit else 0)
return self._page_result(items, page, max(page, pagecount), total or pagecount * limit, limit)
def _category_hot_show(self, page, scope, ext):
if page > 1:
return self._page_result([], page, 1, 0, 50)
params = {"start": 0, "count": 50, "updated_at": "", "items_only": 1, "for_mobile": 1}
data = self._get_json(self.API + "/subject_collection/show_hot/items", params=params, ttl=self.list_cache_ttl)
items = []
for raw in data.get("subject_collection_items") or []:
subtitle = str(raw.get("card_subtitle") or "")
if scope == "zy_cn" and "中国" not in subtitle:
continue
if scope == "zy_other" and "中国" in subtitle:
continue
items.append(self._collection_card(raw, ext))
return self._page_result(items, 1, 1, len(items), 50)
def _category_movie_list(self, page, collection, ext):
if collection == "top250":
return self._category_top250(page, ext)
return self._category_collection(page, collection, ext)
def _category_collection(self, page, collection, ext):
limit = 50
params = {"start": (page - 1) * limit, "count": limit, "updated_at": "", "items_only": 1, "for_mobile": 1}
data = self._get_json(self.API + "/subject_collection/%s/items" % quote(collection, safe=""), params=params, ttl=self.collection_cache_ttl)
items = [self._collection_card(raw, ext) for raw in data.get("subject_collection_items") or []]
total = self._positive_int(data.get("total"), len(items))
pagecount = max(page, int(math.ceil(float(total) / limit))) if total else page
return self._page_result(items, page, pagecount, total, limit)
def _category_top250(self, page, ext):
limit = 25
if page > 10:
return self._page_result([], page, 10, 250, limit)
text = self._get_text(self.MOVIE + "/top250", params={"start": (page - 1) * limit}, ttl=self.top250_cache_ttl)
doc = html.fromstring(text)
items = []
nodes = doc.xpath("//div[contains(@class,'article')]//ol[contains(@class,'grid_view')]/li")
for node in nodes:
href = self._xpath_text(node, ".//div[contains(@class,'pic')]/a/@href")
subject_id = self._subject_id(href)
if not subject_id:
continue
title = self._xpath_text(node, ".//div[contains(@class,'hd')]//span[contains(@class,'title')][1]")
pic = self._xpath_text(node, ".//div[contains(@class,'pic')]//img/@src")
score = self._xpath_text(node, ".//span[contains(@class,'rating_num')]")
card = {"vod_id": subject_id, "vod_name": title, "vod_pic": self._image(pic), "vod_remarks": (score + "") if score else "Top250"}
items.append(card)
return self._page_result(items, page, 10, 250, limit)
def _category_recommend(self, kind, page, ext):
limit = 20
if kind == "movie":
type_value = self._value(ext, "1", "")
area = self._value(ext, "2", "")
tags = [type_value, area, self._value(ext, "3", ""), self._value(ext, "4", "")]
selected = {"类型": type_value, "地区": area}
sort = self._value(ext, "5", "U")
else:
form = self._value(ext, "1", "")
series = self._value(ext, "2", "")
show = self._value(ext, "3", "")
area = self._value(ext, "4", "")
subtype = series if form == "电视剧" else show if form == "综艺" else ""
tags = [subtype or form, area, self._value(ext, "5", ""), self._value(ext, "6", ""), self._value(ext, "7", "")]
selected = {"类型": subtype, "形式": form, "地区": area}
sort = self._value(ext, "8", "U")
params = {
"refresh": 0,
"start": (page - 1) * limit,
"count": limit,
"selected_categories": json.dumps(selected, ensure_ascii=False, separators=(",", ":")),
"uncollect": "false",
"sort": sort,
"tags": ",".join([item for item in tags if item]),
}
data = self._get_json(self.API + "/%s/recommend" % kind, params=params, ttl=self.list_cache_ttl)
items = []
for raw in data.get("items") or []:
if raw.get("type") and raw.get("type") != kind:
continue
items.append(self._collection_card(raw, ext))
total = self._positive_int(data.get("total"), 0)
pagecount = int(math.ceil(float(total) / limit)) if total else page + (1 if len(items) >= limit else 0)
return self._page_result(items, page, max(page, pagecount), total or pagecount * limit, limit)
def _category_wishlist(self, page):
user_id = self.user_id or self._resolve_user_id()
if not user_id:
message = "请在 ext 中配置 user_id;写回想看还需 cookieck 可从 Cookie 自动读取"
return self._page_result([self._error_card("豆瓣想看未配置", message)], page, page, 1, 15)
start = (page - 1) * 15
url = self.MOVIE + "/people/%s/wish" % quote(user_id, safe="")
cache_key = "wishlist:%s:%s" % (user_id, page)
text = self._get_text(url, params={"start": start, "sort": "time", "rating": "all", "filter": "all", "mode": "grid"}, custom_key=cache_key, ttl=self.wishlist_cache_ttl)
doc = html.fromstring(text)
items = []
nodes = doc.xpath("//div[contains(concat(' ',normalize-space(@class),' '),' grid-view ')]//div[contains(concat(' ',normalize-space(@class),' '),' item ')]")
for node in nodes:
href = self._xpath_text(node, ".//li[contains(@class,'title')]/a/@href")
subject_id = self._subject_id(href)
if not subject_id:
continue
title = self._xpath_text(node, ".//li[contains(@class,'title')]//em") or self._xpath_text(node, ".//img/@alt")
pic = self._xpath_text(node, ".//img/@src")
date = self._xpath_text(node, ".//span[contains(@class,'date')]")
intro = self._xpath_text(node, ".//li[contains(@class,'intro')]")
remark = date or (intro[:24] if intro else "想看")
items.append({"vod_id": subject_id, "vod_name": title, "vod_pic": self._image(pic), "vod_remarks": remark})
title_text = self._xpath_text(doc, "//title")
match = re.search(r"\((\d+)\)", title_text)
total = int(match.group(1)) if match else start + len(items)
pagecount = max(page, int(math.ceil(float(total) / 15))) if total else page
return self._page_result(items, page, pagecount, total, 15)
def _category_anime(self, region, page, ext):
kind = self._value(ext, "kind", "tv")
if kind not in ("tv", "movie"):
kind = "tv"
sort = self._value(ext, "sort", "U")
if sort not in {item[1] for item in self.ANIME_SORTS}:
sort = "U"
year = self._value(ext, "year", "")
genre = self._value(ext, "genre", "")
format_tag = self._value(ext, "format", "")
limit = 20
if kind == "movie":
selected = {"类型": "动画", "地区": region}
else:
selected = {"类型": "动画", "形式": "电视剧", "地区": region}
tags = ["动画", region, year, genre, format_tag]
params = {
"refresh": 0,
"start": (page - 1) * limit,
"count": limit,
"selected_categories": json.dumps(selected, ensure_ascii=False, separators=(",", ":")),
"uncollect": "false",
"sort": sort,
"tags": ",".join([item for item in tags if item]),
}
data = self._get_json(self.API + "/%s/recommend" % kind, params=params, ttl=self.list_cache_ttl)
items = []
for raw in data.get("items") or []:
raw_type = str(raw.get("type") or "")
if raw_type and raw_type != kind:
continue
items.append(self._collection_card(raw, ext))
total = self._positive_int(data.get("total"), 0)
pagecount = int(math.ceil(float(total) / limit)) if total else page + (1 if len(items) >= limit else 0)
return self._page_result(items, page, max(page, pagecount), total or pagecount * limit, limit)
def _parse_collection_items(self, data):
return [self._collection_card(raw, {}) for raw in data.get("subject_collection_items") or []]
def _subject_card(self, raw, ext):
score = str(raw.get("rate") or "").strip()
card = {
"vod_id": str(raw.get("id") or ""),
"vod_name": str(raw.get("title") or ""),
"vod_pic": self._image(str(raw.get("cover") or "")),
"vod_remarks": (score + "") if score and score != "0" else "暂无评分",
}
return card
def _collection_card(self, raw, ext):
rating = self._rating(raw)
honor = self._names(raw.get("honor_infos"), "title", 1)
remark = (rating + "") if rating else "暂无评分"
if honor:
remark += " " + honor
card = {
"vod_id": str(raw.get("id") or ""),
"vod_name": str(raw.get("title") or ""),
"vod_pic": self._image(self._pic(raw)),
"vod_remarks": remark,
}
return card
def _get_filters(self):
now = time.time()
if self._filters is not None and now - self._filters_at < self.filter_cache_ttl:
return self._filters
persisted = self._load_persistent_filters()
if persisted is not None:
self._filters = persisted
self._filters_at = now
return persisted
filters = self._base_filters()
if self.dynamic_filters:
self._merge_dynamic_filters(filters)
self._filters = filters
self._filters_at = now
self._save_persistent_filters(filters)
return filters
def _load_persistent_filters(self):
if not self.persistent_filter_cache:
return None
getter = getattr(self, "getCache", None)
if not callable(getter):
return None
try:
value = getter(self.FILTER_CACHE_KEY)
if not isinstance(value, dict):
return None
filters = value.get("filters")
if not isinstance(filters, dict):
return None
required = {item[0] for item in self.CATEGORIES}
if not required.issubset(set(filters)):
return None
return filters
except Exception:
return None
def _save_persistent_filters(self, filters):
if not self.persistent_filter_cache:
return
setter = getattr(self, "setCache", None)
if not callable(setter):
return
try:
setter(self.FILTER_CACHE_KEY, {
"expiresAt": int(time.time() + self.filter_cache_ttl),
"filters": filters,
})
except Exception:
pass
def _base_filters(self):
years = [str(year) for year in range(time.localtime().tm_year, 1979, -1)]
hot_movie = [
self._filter("sort", "排序", self.ANIME_SORTS),
self._filter("type", "类型", [("全部类型", "")] + [(v, v) for v in self.MOVIE_TYPES]),
self._filter("area", "地区", [("全部地区", "")] + [(v, v) for v in self.AREAS]),
self._filter("year", "年代", [("全部年代", "")] + [(v, v) for v in years]),
self._filter("tag", "标签", [("全部标签", "")] + [(v, v) for v in self.TAGS]),
]
hot_tv = [
self._filter("sort", "排序", self.ANIME_SORTS),
self._filter("genre", "题材", [("全部题材", "")] + [(v, v) for v in self.TV_GENRES]),
self._filter("area", "地区", [("全部地区", "")] + [(v, v) for v in self.AREAS]),
self._filter("year", "年代", [("全部年代", "")] + [(v, v) for v in years]),
self._filter("platform", "平台", [("全部平台", "")] + [(v, v) for v in self.PLATFORMS]),
self._filter("tag", "标签", [("全部标签", "")] + [(v, v) for v in self.TAGS]),
]
hot_show = [
self._filter("sort", "排序", self.ANIME_SORTS),
self._filter("genre", "类型", [("全部类型", "")] + [(v, v) for v in self.SHOW_TYPES]),
self._filter("area", "地区", [("全部地区", "")] + [(v, v) for v in self.AREAS]),
self._filter("year", "年代", [("全部年代", "")] + [(v, v) for v in years]),
self._filter("platform", "平台", [("全部平台", "")] + [(v, v) for v in self.PLATFORMS]),
]
return {
"hotmovie": hot_movie,
"hottv": hot_tv,
"hotzy": hot_show,
"movielist": [self._filter("1", "榜单", self.MOVIE_LISTS)],
"tvlist": [self._filter("1", "榜单", self.TV_LISTS)],
"moviefilter": [
self._filter("5", "排序", self.SORTS),
self._filter("1", "类型", [("全部类型", "")] + [(v, v) for v in self.MOVIE_TYPES]),
self._filter("2", "地区", [("全部地区", "")] + [(v, v) for v in self.AREAS]),
self._filter("3", "年代", [("全部年代", "")] + [(v, v) for v in years]),
self._filter("4", "标签", [("全部标签", "")] + [(v, v) for v in self.TAGS]),
],
"tvfilter": [
self._filter("8", "排序", self.SORTS),
self._filter("1", "类型", [("全部类型", "")] + [(v, v) for v in self.TV_TYPES]),
self._filter("2", "电视剧", [("全部剧集", "")] + [(v, v) for v in self.SERIES_TYPES]),
self._filter("3", "综艺", [("全部综艺", "")] + [(v, v) for v in self.SHOW_TYPES]),
self._filter("4", "地区", [("全部地区", "")] + [(v, v) for v in self.AREAS]),
self._filter("5", "年代", [("全部年代", "")] + [(v, v) for v in years]),
self._filter("6", "平台", [("全部平台", "")] + [(v, v) for v in self.PLATFORMS]),
self._filter("7", "标签", [("全部标签", "")] + [(v, v) for v in self.TAGS]),
],
"anime": self._anime_filters(years),
"wishlist": [],
}
def _anime_filters(self, years):
return [
self._filter("sort", "排序", self.ANIME_SORTS),
self._filter("region", "地区", (("国漫", "cn"), ("日漫", "jp"), ("韩漫", "kr"), ("美漫", "us"))),
self._filter("kind", "内容", (("动画剧集", "tv"), ("动画电影", "movie"))),
self._filter("year", "年代", [("全部年代", "")] + [(v, v) for v in years]),
self._filter("genre", "题材", [("全部题材", "")] + [(v, v) for v in self.ANIME_GENRES]),
self._filter("format", "形式", [("全部形式", "")] + [(v, v) for v in self.ANIME_FORMATS]),
]
def _merge_dynamic_filters(self, filters):
jobs = {
"movie_meta": (self.API + "/movie/recommend", {}),
"tv_meta": (self.API + "/tv/recommend", {}),
}
results = {}
try:
with ThreadPoolExecutor(max_workers=4) as executor:
futures = {executor.submit(self._get_json, url, params, self.filter_cache_ttl): key for key, (url, params) in jobs.items()}
for future in as_completed(futures):
try:
results[futures[future]] = future.result()
except Exception:
pass
except Exception:
return
self._merge_recommend_meta(filters.get("moviefilter"), results.get("movie_meta"), False)
self._merge_recommend_meta(filters.get("tvfilter"), results.get("tv_meta"), True)
def _merge_recommend_meta(self, target, data, is_tv):
if not target or not isinstance(data, dict):
return
categories = data.get("recommend_categories") or []
try:
if is_tv:
type_data = categories[0].get("data") or []
if type_data:
tags = type_data[0].get("tags") or []
values = []
for item in tags[1:]:
name = str(item).replace("全部剧集", "电视剧").replace("全部综艺", "综艺")
values.append((name, name))
if values:
self._set_filter_values(target, "1", [("全部类型", "")] + values)
if len(categories) > 1:
areas = categories[1].get("data") or []
values = [(str(v.get("text")), str(v.get("text"))) for v in areas[1:] if v.get("text")]
if values:
self._set_filter_values(target, "4", [("全部地区", "")] + values)
else:
for index in (0, 1):
values = []
for item in (categories[index].get("data") or [])[1:]:
if item.get("text"):
values.append((str(item["text"]), str(item["text"])))
if values:
self._set_filter_values(target, "1" if index == 0 else "2", [("全部" + ("类型" if index == 0 else "地区"), "")] + values)
sorts = [(str(v.get("text")), str(v.get("name"))) for v in data.get("sorts") or [] if v.get("text") and v.get("name")]
if sorts:
self._set_filter_values(target, "8" if is_tv else "5", sorts)
except Exception:
return
def _set_filter_values(self, filters, key, pairs):
for item in filters or []:
if str(item.get("key")) == str(key):
item["value"] = self._values(pairs)
return
def _get_json(self, url, params=None, ttl=None):
key = "json:" + url + "?" + urlencode(sorted((params or {}).items()), doseq=True)
ttl = self.cache_ttl if ttl is None else ttl
cached = self._cache_get(key, ttl)
if cached is not None:
return cached
stale = self._cache_get(key, self.stale_ttl, allow_expired=True)
if self._has_cached_failure(key) and stale is not None:
return stale
self._raise_cached_failure(key)
try:
response = self._session.get(url, params=params, timeout=self.timeout, verify=self.verify_tls)
payload = self._json_response(response)
if response.status_code != 200:
raise RuntimeError("HTTP %s" % response.status_code)
self._cache_set(key, payload)
self._failures.pop(key, None)
return payload
except Exception as exc:
self._remember_failure(key, exc)
if stale is not None:
return stale
raise
def _get_text(self, url, params=None, custom_key="", ttl=None):
key = custom_key or ("text:" + url + "?" + urlencode(sorted((params or {}).items()), doseq=True))
ttl = self.cache_ttl if ttl is None else ttl
cached = self._cache_get(key, ttl)
if cached is not None:
return cached
stale = self._cache_get(key, self.stale_ttl, allow_expired=True)
if self._has_cached_failure(key) and stale is not None:
return stale
self._raise_cached_failure(key)
try:
response = self._session.get(url, params=params, timeout=self.timeout, verify=self.verify_tls)
if response.status_code != 200:
raise RuntimeError("HTTP %s" % response.status_code)
text = response.text
if len(text) < 500:
raise RuntimeError("页面内容异常短")
self._cache_set(key, text)
self._failures.pop(key, None)
return text
except Exception as exc:
self._remember_failure(key, exc)
if stale is not None:
return stale
raise
def _json_response(self, response):
try:
value = response.json()
return value if isinstance(value, dict) else {"data": value}
except Exception:
if response.status_code != 200:
return {"error": "HTTP %s" % response.status_code}
raise RuntimeError("上游返回了非 JSON 内容")
def _reset_session(self):
if self._session is not None:
try:
self._session.close()
except Exception:
pass
session = requests.Session()
session.trust_env = self.trust_env
session.headers.update({"User-Agent": self.user_agent, "Referer": self.host + "/", "Accept": "application/json,text/html;q=0.9,*/*;q=0.8"})
if self.cookie:
session.headers["Cookie"] = self.cookie
if self.proxy:
session.proxies.update({"http": self.proxy, "https": self.proxy})
try:
from requests.packages.urllib3.util.retry import Retry
retry = Retry(total=2, connect=2, read=2, status=2, backoff_factor=0.35, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=frozenset(("GET",)))
adapter = HTTPAdapter(max_retries=retry, pool_connections=8, pool_maxsize=8)
except TypeError:
adapter = HTTPAdapter(max_retries=2, pool_connections=8, pool_maxsize=8)
session.mount("http://", adapter)
session.mount("https://", adapter)
self._session = session
def _resolve_user_id(self):
if self.user_id:
return self.user_id
if not self.cookie:
return ""
try:
response = self._session.get("https://www.douban.com/mine/", timeout=self.timeout, verify=self.verify_tls, allow_redirects=True)
match = re.search(r"/people/([^/?#]+)/?", response.url)
if not match:
match = re.search(r"https?://www\.douban\.com/people/([^/?#]+)/?", response.text)
if match:
self.user_id = match.group(1)
except Exception:
pass
return self.user_id
def _parse_config(self, extend):
value = extend
if isinstance(value, str):
text = value.strip()
if not text:
return {}
try:
value = json.loads(text)
except Exception:
return {}
if not isinstance(value, dict):
return {}
data = value.get("data")
if data:
nested = self._parse_config(data)
merged = dict(value)
merged.update(nested)
return merged
return dict(value)
def _parse_extend(self, extend):
if isinstance(extend, dict):
return extend
if isinstance(extend, str):
text = extend.strip()
if not text:
return {}
try:
value = json.loads(text)
return value if isinstance(value, dict) else {}
except Exception:
return {}
return {}
def _cache_get(self, key, ttl, allow_expired=False):
with self._cache_lock:
item = self._cache.get(key)
if not item:
return None
created, value = item
age = time.time() - created
if age > ttl:
if not allow_expired and age > self.stale_ttl:
self._cache.pop(key, None)
return value if allow_expired and age <= self.stale_ttl else None
self._cache.move_to_end(key)
return value
def _cache_set(self, key, value):
if self.cache_ttl <= 0:
return
with self._cache_lock:
self._cache[key] = (time.time(), value)
self._cache.move_to_end(key)
while len(self._cache) > self.cache_max_entries:
self._cache.popitem(last=False)
def _drop_cache_prefix(self, prefix):
with self._cache_lock:
for key in list(self._cache):
if key.startswith(prefix):
self._cache.pop(key, None)
def _remember_failure(self, key, exc):
self._failures[key] = (time.time(), self._short_error(exc))
def _raise_cached_failure(self, key):
item = self._failures.get(key)
if not item:
return
created, message = item
if time.time() - created <= self.failure_ttl:
raise RuntimeError(message)
self._failures.pop(key, None)
def _has_cached_failure(self, key):
item = self._failures.get(key)
if not item:
return False
if time.time() - item[0] <= self.failure_ttl:
return True
self._failures.pop(key, None)
return False
def _page_result(self, items, page, pagecount, total, limit):
return {"list": items, "page": page, "pagecount": max(page, pagecount), "limit": limit, "total": total}
def _error_card(self, title, exc, subject_id=""):
message = self._short_error(exc)
identity = self.ERROR_PREFIX + quote(message[:180], safe="")
return {"vod_id": subject_id or identity, "vod_name": title, "vod_pic": "", "vod_remarks": message, "vod_content": message}
def _detail_remark(self, data, rating):
parts = []
if rating:
parts.append(rating + "")
parts.extend([str(v) for v in data.get("durations") or []][:1])
episode_count = self._positive_int(data.get("episodes_count"), 0)
if episode_count:
parts.append("%s" % episode_count)
return " / ".join(parts)
def _image(self, url):
value = str(url or "").strip()
if not value or not self.image_headers or "doubanio.com" not in value:
return value
return value + "@Referer=https://m.douban.com/@User-Agent=" + self.user_agent
@staticmethod
def _pic(data, large=False):
pic = data.get("pic") or {}
if isinstance(pic, dict):
return str(pic.get("large" if large else "normal") or pic.get("normal") or pic.get("large") or "")
return str(data.get("cover_url") or data.get("cover") or "")
@staticmethod
def _rating(data):
rating = data.get("rating")
if isinstance(rating, dict):
value = rating.get("value")
else:
value = data.get("rate")
if value in (None, "", 0, "0"):
return ""
return str(value)
@staticmethod
def _names(items, key, limit):
values = []
for item in items or []:
value = item.get(key) if isinstance(item, dict) else item
if value:
values.append(str(value))
if len(values) >= limit:
break
return ", ".join(values)
@staticmethod
def _xpath_text(node, xpath):
try:
values = node.xpath(xpath)
if not values:
return ""
value = values[0]
if hasattr(value, "text_content"):
value = value.text_content()
return " ".join(str(value).split())
except Exception:
return ""
@staticmethod
def _subject_id(value):
match = re.search(r"(?:subject/)?(\d{3,})", str(value or ""))
return match.group(1) if match else ""
def _first_id(self, ids):
value = ids
if isinstance(value, str):
text = value.strip()
if text.startswith("["):
try:
value = json.loads(text)
except Exception:
value = text
if isinstance(value, (list, tuple)):
return str(value[0]) if value else ""
return str(value or "")
@staticmethod
def _cookie_value(cookie, name):
match = re.search(r"(?:^|;\s*)%s=([^;]*)" % re.escape(name), str(cookie or ""))
return match.group(1) if match else ""
@staticmethod
def _value(data, key, default=""):
if not isinstance(data, dict):
return default
value = data.get(key)
if value is None and str(key).isdigit():
value = data.get(int(key))
return default if value is None else str(value)
@staticmethod
def _filter(key, name, pairs):
return {"key": key, "name": name, "value": Spider._values(pairs)}
@staticmethod
def _values(pairs):
result = []
seen = set()
for name, value in pairs:
marker = str(value)
if marker in seen:
continue
seen.add(marker)
result.append({"n": str(name), "v": marker})
return result
@staticmethod
def _positive_int(value, default):
try:
result = int(value)
return result if result > 0 else default
except Exception:
return default
@staticmethod
def _bounded_int(value, default, minimum, maximum):
try:
result = int(value)
except Exception:
return default
return max(minimum, min(maximum, result))
@staticmethod
def _bool_value(value, default):
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "on")
@staticmethod
def _short_error(exc):
text = str(exc or "未知错误").strip().replace("\r", " ").replace("\n", " ")
return text[:220] or "未知错误"
+110 -546
View File
@@ -1,565 +1,129 @@
# -*- coding: utf-8 -*-
# //@name:Hanime1
# //@id:hanime1
# //@version:2
# //wab201 学习研究用
# 官网: https://hanime1.me
import base64
import json
import re
import time
from urllib.parse import parse_qs, urljoin, urlsplit
import sys,re,json
from urllib.parse import quote
sys.path.append('..')
try:
from base.spider import Spider as _B
except ImportError:
class _B:
def fetch(self,u,h=None,**k):
import requests as rq;k.pop('timeout',None)
r=rq.get(u,headers=h,timeout=15,**k);r.encoding='utf-8';return r
import requests
from lxml import html
import requests as R
H=bytes([104, 116, 116, 112, 115, 58, 47, 47, 104, 97, 110, 105, 109, 101, 49, 46, 99, 111, 109]).decode()
U="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
from base.spider import Spider
class Spider(_B):
def init(self,e=""):
self._s=R.Session()
self._s.headers.update({"User-Agent":U,"Accept-Language":"zh-TW,zh;q=0.9"})
def getName(self):return"Hanime1"
def isVideoFormat(self,u):return".mp4"in u
def manualVideoCheck(self):return False
class Spider(Spider):
HOST = "https://hanime1.me"
PLAY_PREFIX = "hanime1://play/"
CATEGORIES = (
("all", "全部", ""),
("hentai", "裏番", "裏番"),
("short", "泡麵番", "泡麵番"),
("motion", "Motion Anime", "Motion Anime"),
("cg3d", "3DCG", "3DCG"),
("d25", "2.5D", "2.5D"),
("d2", "2D動畫", "2D動畫"),
("ai", "AI生成", "AI生成"),
("mmd", "MMD", "MMD"),
("cosplay", "Cosplay", "Cosplay"),
)
SORTS = (
"最新上市",
"最新上傳",
"本日排行",
"本週排行",
"本月排行",
"觀看次數",
"讚好比例",
"時長最長",
"他們在看",
)
DURATIONS = (
"",
"1 分鐘 +",
"5 分鐘 +",
"10 分鐘 +",
"20 分鐘 +",
"30 分鐘 +",
"60 分鐘 +",
"0 - 10 分鐘",
"0 - 20 分鐘",
)
VIDEO_RE = re.compile(r"\.(?:mp4|m3u8|mkv|webm)(?:$|[?#])", re.I)
def _get(self,u,timeout=15):
if not u.startswith("http"):u=H+u
try:
r=self._s.get(u,timeout=timeout);r.raise_for_status()
r.encoding='utf-8';return r.text
except Exception as e:print(f"[H1]{u[:60]}->{e}");return""
def __init__(self):
self.name = "Hanime1"
self.host = self.HOST
self.timeout = 20
self.retries = 2
self.preferred_quality = 0
self.trust_env = True
self.cookie = ""
self.user_agent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/138.0.0.0 Safari/537.36"
)
self.backend_parse = False
self.category_mode = False
self.categoryMode = False
self.session = None
def _cards(self,h):
v=[]
for m in re.finditer(
r'<div title="([^"]*)"[^>]*class="video-item-container[^"]*">'
r'.*?<a href="([^"]+)"[^>]*class="video-link"'
r'.*?<img[^>]*src="([^"]+)"',
h,re.DOTALL):
vid=re.search(r'v=(\d+)',m.group(2))
v.append({
"vod_id":m.group(2) if m.group(2).startswith("http")else H+m.group(2),
"vod_name":m.group(1),
"vod_pic":m.group(3),
"vod_remarks":"",
})
return v
def getName(self):
return self.name
def _tags(self):
"""动态抓取搜索页全部标签"""
h=self._get(H+"/search")
tags=re.findall(r'<input name="tags\[\]" type="checkbox" value="([^"]+)"',h)
return list(dict.fromkeys(tags)) # 去重保序
def init(self, extend=""):
config = self._config(extend)
self.host = self._host(config.get("host") or self.HOST)
self.timeout = self._bounded_int(config.get("timeout"), 20, 8, 45)
self.retries = self._bounded_int(config.get("retries"), 2, 0, 4)
self.preferred_quality = self._bounded_int(
config.get("preferred_quality"), 0, 0, 4320
)
self.trust_env = self._bool(config.get("trust_env"), True)
self.cookie = str(
config.get("cookie") or config.get("cf_cookie") or ""
).strip()
self.user_agent = str(config.get("user_agent") or self.user_agent).strip()
self._reset_session()
def destroy(self):
if self.session is not None:
try:
self.session.close()
except Exception:
pass
self.session = None
def isVideoFormat(self, url):
return bool(self.VIDEO_RE.search(str(url or "")))
def manualVideoCheck(self):
return False
def homeContent(self, filter):
classes = [
{"type_id": type_id, "type_name": type_name}
for type_id, type_name, _ in self.CATEGORIES
]
filters = {}
sort_values = [{"n": value, "v": value} for value in self.SORTS]
duration_values = [
{"n": value or "全部", "v": value} for value in self.DURATIONS
]
for type_id, _, _ in self.CATEGORIES:
filters[type_id] = [
{"key": "sort", "name": "排序", "value": sort_values},
{"key": "duration", "name": "時長", "value": duration_values},
]
return {"class": classes, "filters": filters}
def homeContent(self,filter=False):
cs=[{"type_id":"__all__","type_name":"全部影片"}]
for t in self._tags():
cs.append({"type_id":t,"type_name":t})
return{"class":cs}
def homeVideoContent(self):
result = self.categoryContent("all", "1", False, {"sort": "最新上傳"})
return {"list": result.get("list", [])}
h=self._get(H)
return{"list":self._cards(h)}
def categoryContent(self, tid, pg, filter, extend):
page = self._page(pg)
genre = self._category_genre(tid)
options = self._config(extend)
params = {
"sort": str(options.get("sort") or "最新上傳"),
"page": page,
}
duration = str(options.get("duration") or "").strip()
if genre:
params["genre"] = genre
if duration:
params["duration"] = duration
def categoryContent(self,tid,pg=1,filter=False,extend=None):
try:
source, _ = self._request_text("/search", params=params)
return self._parse_listing(source, page)
except Exception as exc:
return self._empty_page(page, "分類讀取失敗: %s" % exc)
pn=max(int(str(pg)),1)
if tid=="__all__":
url=f"{H}/search?page={pn}"
elif tid.startswith("http"):
url=tid+f"&page={pn}"
else:
url=f"{H}/search?tags%5B%5D={quote(tid)}&page={pn}"
h=self._get(url)
if not h:return{"list":[],"page":pg,"pagecount":1}
pages=re.findall(r'page=(\d+)',h)
pc=max(int(p)for p in pages)if pages else pn
v=self._cards(h)
return{"list":v,"page":pn,"pagecount":pc,"limit":len(v),"total":pc*len(v)if v else 0}
except Exception as e:print(f"[H1]cat:{e}");return{"list":[],"page":pg,"pagecount":1}
def searchContent(self, key, quick, pg="1"):
page = self._page(pg)
keyword = self._clean(key)
if not keyword:
return self._empty_page(page)
try:
source, _ = self._request_text(
"/search", params={"query": keyword, "page": page}
)
return self._parse_listing(source, page)
except Exception as exc:
return self._empty_page(page, "搜尋失敗: %s" % exc)
def detailContent(self, ids):
raw_id = ids[0] if isinstance(ids, (list, tuple)) and ids else ids
video_id = self._video_id(raw_id)
if not video_id:
return {"list": []}
try:
source, page_url = self._request_text(
"/watch", params={"v": video_id}
)
return {"list": [self._parse_detail(source, video_id, page_url)]}
except Exception as exc:
message = "詳情讀取失敗: %s" % exc
return {
"list": [
{
"vod_id": video_id,
"vod_name": "Hanime1 %s" % video_id,
"vod_remarks": message,
"vod_content": message,
"vod_play_from": "Hanime1直鏈",
"vod_play_url": "重試$%s%s/0" % (self.PLAY_PREFIX, video_id),
}
]
}
def playerContent(self, flag, id, vipFlags):
video_id, requested_quality = self._play_id(id)
if not video_id:
return self._player_error("無法識別播放 ID")
try:
source, page_url = self._request_text(
"/watch", params={"v": video_id}
)
sources = self._parse_sources(self._document(source))
selected = self._select_source(sources, requested_quality)
if not selected:
return self._player_error("播放頁沒有可用的直鏈")
return {
"parse": 0,
"jx": 0,
"playUrl": "",
"url": selected[1],
"header": {
"User-Agent": self.user_agent,
"Referer": page_url,
"Origin": self.host,
},
}
except Exception as exc:
return self._player_error("播放解析失敗: %s" % exc)
def _reset_session(self):
self.destroy()
self.session = requests.Session()
self.session.trust_env = self.trust_env
self.session.headers.update(
{
"User-Agent": self.user_agent,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8"
),
"Accept-Language": "zh-TW,zh;q=0.9,en;q=0.7",
"Cache-Control": "no-cache",
}
)
if self.cookie:
self.session.headers["Cookie"] = self.cookie.removeprefix("Cookie:").strip()
def _request_text(self, path, params=None):
if self.session is None:
self._reset_session()
url = urljoin(self.host + "/", str(path or "").lstrip("/"))
last_error = None
for attempt in range(self.retries + 1):
try:
response = self.session.get(
url,
params=params,
headers={"Referer": self.host + "/"},
timeout=self.timeout,
allow_redirects=True,
)
source = self._decode_response(response)
if self._is_challenge(source) or response.status_code in (403, 429, 503):
raise RuntimeError(
"Cloudflare 驗證未通過;請在同一出口的瀏覽器完成驗證,"
"再於 Extend 填入 Cookie 和相同 User-Agent"
)
response.raise_for_status()
return source, response.url
except (requests.RequestException, RuntimeError) as exc:
last_error = exc
if attempt < self.retries:
time.sleep(0.35 * (attempt + 1))
raise RuntimeError(str(last_error or "請求失敗"))
def _parse_listing(self, source, page):
doc = self._document(source)
cards = []
seen = set()
nodes = doc.xpath(
"//a[contains(@href,'/watch') and "
".//div[contains(concat(' ',normalize-space(@class),' '),' search-videos ')]]"
)
if not nodes:
nodes = doc.xpath("//a[contains(@class,'video-link') and contains(@href,'/watch')]")
for node in nodes:
video_id = self._video_id(node.get("href"))
if not video_id or video_id in seen:
continue
seen.add(video_id)
title = self._clean(
node.xpath("string(.//div[contains(@class,'home-rows-videos-title')][1])")
or node.xpath("string(.//div[contains(@class,'title')][1])")
or node.get("title")
)
image = node.xpath("string(.//img[1]/@src)")
duration = self._clean(
node.xpath("string(.//div[contains(@class,'duration')][1])")
)
cards.append(
{
"vod_id": video_id,
"vod_name": title or "Hanime1 %s" % video_id,
"vod_pic": urljoin(self.host + "/", image),
"vod_remarks": duration,
}
)
page_numbers = []
for text_value in doc.xpath("//ul[contains(@class,'pagination')]//li//text()"):
value = self._clean(text_value)
if value.isdigit():
page_numbers.append(int(value))
pagecount = max([page] + page_numbers)
limit = len(cards)
return {
"list": cards,
"page": page,
"pagecount": pagecount,
"limit": limit,
"total": pagecount * limit if limit else 0,
}
def _parse_detail(self, source, video_id, page_url):
doc = self._document(source)
title = self._clean(
doc.xpath("string(//meta[@property='og:title']/@content)")
or doc.xpath("string(//h3[@id='shareBtn-title'])")
or doc.xpath("string(//title)")
)
title = re.sub(r"\s*-\s*Hanime1\.me\s*$", "", title, flags=re.I)
cover = doc.xpath("string(//meta[@property='og:image']/@content)")
if not cover:
cover = doc.xpath("string(//video[@id='player']/@poster)")
content = self._clean(doc.xpath("string(//meta[@name='description']/@content)"))
actor = self._clean(doc.xpath("string(//a[@id='video-artist-name'])"))
genre = self._clean(
doc.xpath("string((//a[contains(@href,'genre=')])[last()])")
)
tag_values = []
for node in doc.xpath("//div[contains(@class,'single-video-tag')]/a"):
value = re.sub(r"\s*\(\d+\)\s*$", "", self._clean(node.text_content()))
if value and value not in tag_values:
tag_values.append(value)
date_match = re.search(
r"\b(20\d{2})-\d{2}-\d{2}\b", self._clean(doc.text_content())
)
sources = self._parse_sources(doc)
play_items = []
for quality, _ in sources:
label = "%sP" % quality if quality else "自動"
play_items.append(
"%s$%s%s/%s" % (label, self.PLAY_PREFIX, video_id, quality or 0)
)
if not play_items:
play_items.append("自動$%s%s/0" % (self.PLAY_PREFIX, video_id))
return {
"vod_id": video_id,
"vod_name": title or "Hanime1 %s" % video_id,
"vod_pic": urljoin(page_url, cover),
"type_name": genre,
"vod_year": date_match.group(1) if date_match else "",
"vod_actor": actor,
"vod_content": content,
"vod_tag": ",".join(tag_values[:24]),
"vod_play_from": "Hanime1直鏈",
"vod_play_url": "#".join(play_items),
}
def _parse_sources(self, doc):
values = {}
for node in doc.xpath("//video//source[@src]"):
url = urljoin(self.host + "/", node.get("src"))
quality = self._quality(node.get("size"), url)
if url.startswith(("http://", "https://")):
values[quality] = url
if not values:
for node in doc.xpath("//link[@rel='preload' and @as='video']/@href"):
url = urljoin(self.host + "/", node)
values[self._quality("", url)] = url
return sorted(values.items(), key=lambda item: item[0], reverse=True)
def _document(self, source):
if isinstance(source, (bytes, bytearray)):
text = self._decode_bytes(bytes(source))
else:
text = str(source or "")
if "\x00" in text[:256]:
try:
text = self._decode_bytes(text.encode("latin-1"))
except (UnicodeEncodeError, UnicodeDecodeError):
text = text.replace("\x00", "")
parser = html.HTMLParser(encoding="utf-8", recover=True)
return html.fromstring(text.encode("utf-8"), parser=parser)
def _decode_response(self, response):
return self._decode_bytes(response.content, response.encoding)
@staticmethod
def _decode_bytes(raw, declared_encoding=""):
if not raw:
return ""
signatures = (
(b"\xff\xfe\x00\x00", "utf-32-le"),
(b"\x00\x00\xfe\xff", "utf-32-be"),
(b"\xff\xfe", "utf-16-le"),
(b"\xfe\xff", "utf-16-be"),
)
for signature, encoding in signatures:
if raw.startswith(signature):
return raw.decode(encoding).lstrip("\ufeff")
sample = raw[:512]
if len(sample) >= 16:
groups = len(sample) // 4
le32_zeros = sum(
sample[index] == 0
for index in range(1, groups * 4)
if index % 4 in (1, 2, 3)
)
be32_zeros = sum(
sample[index] == 0
for index in range(0, groups * 4)
if index % 4 in (0, 1, 2)
)
if le32_zeros >= groups * 2:
return raw.decode("utf-32-le")
if be32_zeros >= groups * 2:
return raw.decode("utf-32-be")
if raw[:4] == b"<\x00\x00\x00":
return raw.decode("utf-32-le")
if raw[:4] == b"\x00\x00\x00<":
return raw.decode("utf-32-be")
if raw[:2] == b"<\x00":
return raw.decode("utf-16-le")
if raw[:2] == b"\x00<":
return raw.decode("utf-16-be")
candidates = [str(declared_encoding or "").strip(), "utf-8-sig", "utf-8"]
for encoding in candidates:
if not encoding:
continue
try:
return raw.decode(encoding)
except (LookupError, UnicodeDecodeError):
continue
return raw.decode("utf-8", errors="replace")
def _select_source(self, sources, requested_quality):
def detailContent(self,ids):
url=ids[0]if ids[0].startswith("http")else H+ids[0]
h=self._get(url)
if not h:return{"list":[]}
tm=re.search(r'<title>(.*?)</title>',h)
title=tm.group(1).split("&nbsp;-&nbsp;")[0].strip()if tm else""
pm=re.search(r'poster="([^"]+)"',h)
pic=pm.group(1)if pm else""
sources=re.findall(r'<source src="([^"]+)"[^>]*size="([^"]+)"',h)
pf=[];pu=[]
for src,sz in sources:
pf.append(f"{sz}p")
pu.append(f"{sz}p${src}")
if not sources:
return None
target = requested_quality or self.preferred_quality
if target:
for item in sources:
if item[0] == target:
return item
return min(sources, key=lambda item: abs(item[0] - target))
return sources[0]
mp4s=re.findall(r'https?://[^\s"<>]+\.mp4[^\s"<>]*',h)
for i,mp in enumerate(mp4s):
pf.append(f"线路{i+1}")
pu.append(f"线路{i+1}${mp}")
return{"list":[{"vod_id":url,"vod_name":title,"vod_pic":pic,
"type_name":"","vod_year":"","vod_area":"","vod_remarks":"",
"vod_actor":"","vod_director":"","vod_content":"",
"vod_play_from":"$$$".join(pf),"vod_play_url":"$$$".join(pu)}]}
def _play_id(self, value):
text = str(value or "").strip()
match = re.match(r"^hanime1://play/(\d+)/(\d+)$", text)
if match:
return match.group(1), int(match.group(2))
video_id = self._video_id(text)
return (video_id, 0) if video_id else ("", 0)
def playerContent(self,flag,id,vipFlags=None):
if id and".mp4"in id:
return{"url":id,"header":json.dumps({"User-Agent":U,"Referer":H+"/"})}
d=self.detailContent([id])
if d and d.get("list"):
us=d["list"][0].get("vod_play_url","").split("$$$")
if us:
f=us[0];url=f.split("$",1)[1]if"$"in f else f
return{"url":url,"header":json.dumps({"User-Agent":U,"Referer":H+"/"})}
return{"url":""}
def _video_id(self, value):
text = str(value or "").strip()
if text.startswith("atvp_detail:"):
text = text[len("atvp_detail:") :].strip()
if text.isdigit():
return text
match = re.search(r"hanime1://(?:video|play)/(\d+)", text)
if match:
return match.group(1)
def searchContent(self,key,quick=False,pg=1):
try:
values = parse_qs(urlsplit(text).query).get("v") or []
if values and str(values[0]).isdigit():
return str(values[0])
except Exception:
pass
match = re.search(r"(?:[?&]v=|/watch/)(\d+)", text)
return match.group(1) if match else ""
pn=max(int(str(pg)),1)
h=self._get(f"{H}/search?search={quote(key)}&page={pn}")
if not h:return{"list":[]}
v=self._cards(h)
pages=re.findall(r'page=(\d+)',h)
pc=max(int(p)for p in pages)if pages else pn
return{"list":v,"page":pn,"pagecount":pc,"limit":len(v),"total":pc*len(v)if v else 0}
except Exception as e:print(f"[H1]search:{e}");return{"list":[]}
def _category_genre(self, tid):
value = str(tid or "all")
for type_id, _, genre in self.CATEGORIES:
if value == type_id or value == genre:
return genre
return ""
def _quality(self, value, url):
match = re.search(r"(\d{3,4})", str(value or ""))
if not match:
match = re.search(r"[-_](\d{3,4})p(?:\.|[/?])", str(url or ""), re.I)
return int(match.group(1)) if match else 0
def _is_challenge(self, source):
text = str(source or "")[:80000].lower()
signals = (
"<title>just a moment...</title>",
"id=\"challenge-form\"",
"cf-browser-verification",
"cf-chl-captcha",
"attention required! | cloudflare",
)
return any(signal in text for signal in signals)
def _player_error(self, message):
text = self._clean(message) or "播放失敗"
return {
"parse": 0,
"jx": 0,
"playUrl": "",
"url": "",
"header": {},
"msg": text,
"error": text,
"content": text,
}
def _empty_page(self, page, message=""):
result = {
"list": [],
"page": page,
"pagecount": page,
"limit": 0,
"total": 0,
}
if message:
result["msg"] = message
return result
def _config(self, extend):
if isinstance(extend, dict):
return extend
text = str(extend or "").strip()
if not text:
return {}
candidates = [text]
try:
candidates.append(
base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)).decode("utf-8")
)
except Exception:
pass
for candidate in candidates:
try:
value = json.loads(candidate)
if isinstance(value, dict):
return value
except Exception:
continue
return {"cookie": text} if "=" in text else {}
@staticmethod
def _host(value):
text = str(value or "").strip().rstrip("/")
return text if text.startswith(("http://", "https://")) else Spider.HOST
@staticmethod
def _clean(value):
return " ".join(str(value or "").replace("\xa0", " ").split())
@staticmethod
def _page(value):
try:
return max(1, int(value))
except Exception:
return 1
@staticmethod
def _bounded_int(value, default, low, high):
try:
return min(high, max(low, int(value)))
except Exception:
return default
@staticmethod
def _bool(value, default):
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in ("1", "true", "yes", "on")
def localProxy(self,param):pass
+565
View File
@@ -0,0 +1,565 @@
# -*- coding: utf-8 -*-
# //@name:Hanime1
# //@id:hanime1
# //@version:2
# //wab201 学习研究用
import base64
import json
import re
import time
from urllib.parse import parse_qs, urljoin, urlsplit
import requests
from lxml import html
from base.spider import Spider
class Spider(Spider):
HOST = "https://hanime1.me"
PLAY_PREFIX = "hanime1://play/"
CATEGORIES = (
("all", "全部", ""),
("hentai", "裏番", "裏番"),
("short", "泡麵番", "泡麵番"),
("motion", "Motion Anime", "Motion Anime"),
("cg3d", "3DCG", "3DCG"),
("d25", "2.5D", "2.5D"),
("d2", "2D動畫", "2D動畫"),
("ai", "AI生成", "AI生成"),
("mmd", "MMD", "MMD"),
("cosplay", "Cosplay", "Cosplay"),
)
SORTS = (
"最新上市",
"最新上傳",
"本日排行",
"本週排行",
"本月排行",
"觀看次數",
"讚好比例",
"時長最長",
"他們在看",
)
DURATIONS = (
"",
"1 分鐘 +",
"5 分鐘 +",
"10 分鐘 +",
"20 分鐘 +",
"30 分鐘 +",
"60 分鐘 +",
"0 - 10 分鐘",
"0 - 20 分鐘",
)
VIDEO_RE = re.compile(r"\.(?:mp4|m3u8|mkv|webm)(?:$|[?#])", re.I)
def __init__(self):
self.name = "Hanime1"
self.host = self.HOST
self.timeout = 20
self.retries = 2
self.preferred_quality = 0
self.trust_env = True
self.cookie = ""
self.user_agent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/138.0.0.0 Safari/537.36"
)
self.backend_parse = False
self.category_mode = False
self.categoryMode = False
self.session = None
def getName(self):
return self.name
def init(self, extend=""):
config = self._config(extend)
self.host = self._host(config.get("host") or self.HOST)
self.timeout = self._bounded_int(config.get("timeout"), 20, 8, 45)
self.retries = self._bounded_int(config.get("retries"), 2, 0, 4)
self.preferred_quality = self._bounded_int(
config.get("preferred_quality"), 0, 0, 4320
)
self.trust_env = self._bool(config.get("trust_env"), True)
self.cookie = str(
config.get("cookie") or config.get("cf_cookie") or ""
).strip()
self.user_agent = str(config.get("user_agent") or self.user_agent).strip()
self._reset_session()
def destroy(self):
if self.session is not None:
try:
self.session.close()
except Exception:
pass
self.session = None
def isVideoFormat(self, url):
return bool(self.VIDEO_RE.search(str(url or "")))
def manualVideoCheck(self):
return False
def homeContent(self, filter):
classes = [
{"type_id": type_id, "type_name": type_name}
for type_id, type_name, _ in self.CATEGORIES
]
filters = {}
sort_values = [{"n": value, "v": value} for value in self.SORTS]
duration_values = [
{"n": value or "全部", "v": value} for value in self.DURATIONS
]
for type_id, _, _ in self.CATEGORIES:
filters[type_id] = [
{"key": "sort", "name": "排序", "value": sort_values},
{"key": "duration", "name": "時長", "value": duration_values},
]
return {"class": classes, "filters": filters}
def homeVideoContent(self):
result = self.categoryContent("all", "1", False, {"sort": "最新上傳"})
return {"list": result.get("list", [])}
def categoryContent(self, tid, pg, filter, extend):
page = self._page(pg)
genre = self._category_genre(tid)
options = self._config(extend)
params = {
"sort": str(options.get("sort") or "最新上傳"),
"page": page,
}
duration = str(options.get("duration") or "").strip()
if genre:
params["genre"] = genre
if duration:
params["duration"] = duration
try:
source, _ = self._request_text("/search", params=params)
return self._parse_listing(source, page)
except Exception as exc:
return self._empty_page(page, "分類讀取失敗: %s" % exc)
def searchContent(self, key, quick, pg="1"):
page = self._page(pg)
keyword = self._clean(key)
if not keyword:
return self._empty_page(page)
try:
source, _ = self._request_text(
"/search", params={"query": keyword, "page": page}
)
return self._parse_listing(source, page)
except Exception as exc:
return self._empty_page(page, "搜尋失敗: %s" % exc)
def detailContent(self, ids):
raw_id = ids[0] if isinstance(ids, (list, tuple)) and ids else ids
video_id = self._video_id(raw_id)
if not video_id:
return {"list": []}
try:
source, page_url = self._request_text(
"/watch", params={"v": video_id}
)
return {"list": [self._parse_detail(source, video_id, page_url)]}
except Exception as exc:
message = "詳情讀取失敗: %s" % exc
return {
"list": [
{
"vod_id": video_id,
"vod_name": "Hanime1 %s" % video_id,
"vod_remarks": message,
"vod_content": message,
"vod_play_from": "Hanime1直鏈",
"vod_play_url": "重試$%s%s/0" % (self.PLAY_PREFIX, video_id),
}
]
}
def playerContent(self, flag, id, vipFlags):
video_id, requested_quality = self._play_id(id)
if not video_id:
return self._player_error("無法識別播放 ID")
try:
source, page_url = self._request_text(
"/watch", params={"v": video_id}
)
sources = self._parse_sources(self._document(source))
selected = self._select_source(sources, requested_quality)
if not selected:
return self._player_error("播放頁沒有可用的直鏈")
return {
"parse": 0,
"jx": 0,
"playUrl": "",
"url": selected[1],
"header": {
"User-Agent": self.user_agent,
"Referer": page_url,
"Origin": self.host,
},
}
except Exception as exc:
return self._player_error("播放解析失敗: %s" % exc)
def _reset_session(self):
self.destroy()
self.session = requests.Session()
self.session.trust_env = self.trust_env
self.session.headers.update(
{
"User-Agent": self.user_agent,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8"
),
"Accept-Language": "zh-TW,zh;q=0.9,en;q=0.7",
"Cache-Control": "no-cache",
}
)
if self.cookie:
self.session.headers["Cookie"] = self.cookie.removeprefix("Cookie:").strip()
def _request_text(self, path, params=None):
if self.session is None:
self._reset_session()
url = urljoin(self.host + "/", str(path or "").lstrip("/"))
last_error = None
for attempt in range(self.retries + 1):
try:
response = self.session.get(
url,
params=params,
headers={"Referer": self.host + "/"},
timeout=self.timeout,
allow_redirects=True,
)
source = self._decode_response(response)
if self._is_challenge(source) or response.status_code in (403, 429, 503):
raise RuntimeError(
"Cloudflare 驗證未通過;請在同一出口的瀏覽器完成驗證,"
"再於 Extend 填入 Cookie 和相同 User-Agent"
)
response.raise_for_status()
return source, response.url
except (requests.RequestException, RuntimeError) as exc:
last_error = exc
if attempt < self.retries:
time.sleep(0.35 * (attempt + 1))
raise RuntimeError(str(last_error or "請求失敗"))
def _parse_listing(self, source, page):
doc = self._document(source)
cards = []
seen = set()
nodes = doc.xpath(
"//a[contains(@href,'/watch') and "
".//div[contains(concat(' ',normalize-space(@class),' '),' search-videos ')]]"
)
if not nodes:
nodes = doc.xpath("//a[contains(@class,'video-link') and contains(@href,'/watch')]")
for node in nodes:
video_id = self._video_id(node.get("href"))
if not video_id or video_id in seen:
continue
seen.add(video_id)
title = self._clean(
node.xpath("string(.//div[contains(@class,'home-rows-videos-title')][1])")
or node.xpath("string(.//div[contains(@class,'title')][1])")
or node.get("title")
)
image = node.xpath("string(.//img[1]/@src)")
duration = self._clean(
node.xpath("string(.//div[contains(@class,'duration')][1])")
)
cards.append(
{
"vod_id": video_id,
"vod_name": title or "Hanime1 %s" % video_id,
"vod_pic": urljoin(self.host + "/", image),
"vod_remarks": duration,
}
)
page_numbers = []
for text_value in doc.xpath("//ul[contains(@class,'pagination')]//li//text()"):
value = self._clean(text_value)
if value.isdigit():
page_numbers.append(int(value))
pagecount = max([page] + page_numbers)
limit = len(cards)
return {
"list": cards,
"page": page,
"pagecount": pagecount,
"limit": limit,
"total": pagecount * limit if limit else 0,
}
def _parse_detail(self, source, video_id, page_url):
doc = self._document(source)
title = self._clean(
doc.xpath("string(//meta[@property='og:title']/@content)")
or doc.xpath("string(//h3[@id='shareBtn-title'])")
or doc.xpath("string(//title)")
)
title = re.sub(r"\s*-\s*Hanime1\.me\s*$", "", title, flags=re.I)
cover = doc.xpath("string(//meta[@property='og:image']/@content)")
if not cover:
cover = doc.xpath("string(//video[@id='player']/@poster)")
content = self._clean(doc.xpath("string(//meta[@name='description']/@content)"))
actor = self._clean(doc.xpath("string(//a[@id='video-artist-name'])"))
genre = self._clean(
doc.xpath("string((//a[contains(@href,'genre=')])[last()])")
)
tag_values = []
for node in doc.xpath("//div[contains(@class,'single-video-tag')]/a"):
value = re.sub(r"\s*\(\d+\)\s*$", "", self._clean(node.text_content()))
if value and value not in tag_values:
tag_values.append(value)
date_match = re.search(
r"\b(20\d{2})-\d{2}-\d{2}\b", self._clean(doc.text_content())
)
sources = self._parse_sources(doc)
play_items = []
for quality, _ in sources:
label = "%sP" % quality if quality else "自動"
play_items.append(
"%s$%s%s/%s" % (label, self.PLAY_PREFIX, video_id, quality or 0)
)
if not play_items:
play_items.append("自動$%s%s/0" % (self.PLAY_PREFIX, video_id))
return {
"vod_id": video_id,
"vod_name": title or "Hanime1 %s" % video_id,
"vod_pic": urljoin(page_url, cover),
"type_name": genre,
"vod_year": date_match.group(1) if date_match else "",
"vod_actor": actor,
"vod_content": content,
"vod_tag": ",".join(tag_values[:24]),
"vod_play_from": "Hanime1直鏈",
"vod_play_url": "#".join(play_items),
}
def _parse_sources(self, doc):
values = {}
for node in doc.xpath("//video//source[@src]"):
url = urljoin(self.host + "/", node.get("src"))
quality = self._quality(node.get("size"), url)
if url.startswith(("http://", "https://")):
values[quality] = url
if not values:
for node in doc.xpath("//link[@rel='preload' and @as='video']/@href"):
url = urljoin(self.host + "/", node)
values[self._quality("", url)] = url
return sorted(values.items(), key=lambda item: item[0], reverse=True)
def _document(self, source):
if isinstance(source, (bytes, bytearray)):
text = self._decode_bytes(bytes(source))
else:
text = str(source or "")
if "\x00" in text[:256]:
try:
text = self._decode_bytes(text.encode("latin-1"))
except (UnicodeEncodeError, UnicodeDecodeError):
text = text.replace("\x00", "")
parser = html.HTMLParser(encoding="utf-8", recover=True)
return html.fromstring(text.encode("utf-8"), parser=parser)
def _decode_response(self, response):
return self._decode_bytes(response.content, response.encoding)
@staticmethod
def _decode_bytes(raw, declared_encoding=""):
if not raw:
return ""
signatures = (
(b"\xff\xfe\x00\x00", "utf-32-le"),
(b"\x00\x00\xfe\xff", "utf-32-be"),
(b"\xff\xfe", "utf-16-le"),
(b"\xfe\xff", "utf-16-be"),
)
for signature, encoding in signatures:
if raw.startswith(signature):
return raw.decode(encoding).lstrip("\ufeff")
sample = raw[:512]
if len(sample) >= 16:
groups = len(sample) // 4
le32_zeros = sum(
sample[index] == 0
for index in range(1, groups * 4)
if index % 4 in (1, 2, 3)
)
be32_zeros = sum(
sample[index] == 0
for index in range(0, groups * 4)
if index % 4 in (0, 1, 2)
)
if le32_zeros >= groups * 2:
return raw.decode("utf-32-le")
if be32_zeros >= groups * 2:
return raw.decode("utf-32-be")
if raw[:4] == b"<\x00\x00\x00":
return raw.decode("utf-32-le")
if raw[:4] == b"\x00\x00\x00<":
return raw.decode("utf-32-be")
if raw[:2] == b"<\x00":
return raw.decode("utf-16-le")
if raw[:2] == b"\x00<":
return raw.decode("utf-16-be")
candidates = [str(declared_encoding or "").strip(), "utf-8-sig", "utf-8"]
for encoding in candidates:
if not encoding:
continue
try:
return raw.decode(encoding)
except (LookupError, UnicodeDecodeError):
continue
return raw.decode("utf-8", errors="replace")
def _select_source(self, sources, requested_quality):
if not sources:
return None
target = requested_quality or self.preferred_quality
if target:
for item in sources:
if item[0] == target:
return item
return min(sources, key=lambda item: abs(item[0] - target))
return sources[0]
def _play_id(self, value):
text = str(value or "").strip()
match = re.match(r"^hanime1://play/(\d+)/(\d+)$", text)
if match:
return match.group(1), int(match.group(2))
video_id = self._video_id(text)
return (video_id, 0) if video_id else ("", 0)
def _video_id(self, value):
text = str(value or "").strip()
if text.startswith("atvp_detail:"):
text = text[len("atvp_detail:") :].strip()
if text.isdigit():
return text
match = re.search(r"hanime1://(?:video|play)/(\d+)", text)
if match:
return match.group(1)
try:
values = parse_qs(urlsplit(text).query).get("v") or []
if values and str(values[0]).isdigit():
return str(values[0])
except Exception:
pass
match = re.search(r"(?:[?&]v=|/watch/)(\d+)", text)
return match.group(1) if match else ""
def _category_genre(self, tid):
value = str(tid or "all")
for type_id, _, genre in self.CATEGORIES:
if value == type_id or value == genre:
return genre
return ""
def _quality(self, value, url):
match = re.search(r"(\d{3,4})", str(value or ""))
if not match:
match = re.search(r"[-_](\d{3,4})p(?:\.|[/?])", str(url or ""), re.I)
return int(match.group(1)) if match else 0
def _is_challenge(self, source):
text = str(source or "")[:80000].lower()
signals = (
"<title>just a moment...</title>",
"id=\"challenge-form\"",
"cf-browser-verification",
"cf-chl-captcha",
"attention required! | cloudflare",
)
return any(signal in text for signal in signals)
def _player_error(self, message):
text = self._clean(message) or "播放失敗"
return {
"parse": 0,
"jx": 0,
"playUrl": "",
"url": "",
"header": {},
"msg": text,
"error": text,
"content": text,
}
def _empty_page(self, page, message=""):
result = {
"list": [],
"page": page,
"pagecount": page,
"limit": 0,
"total": 0,
}
if message:
result["msg"] = message
return result
def _config(self, extend):
if isinstance(extend, dict):
return extend
text = str(extend or "").strip()
if not text:
return {}
candidates = [text]
try:
candidates.append(
base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)).decode("utf-8")
)
except Exception:
pass
for candidate in candidates:
try:
value = json.loads(candidate)
if isinstance(value, dict):
return value
except Exception:
continue
return {"cookie": text} if "=" in text else {}
@staticmethod
def _host(value):
text = str(value or "").strip().rstrip("/")
return text if text.startswith(("http://", "https://")) else Spider.HOST
@staticmethod
def _clean(value):
return " ".join(str(value or "").replace("\xa0", " ").split())
@staticmethod
def _page(value):
try:
return max(1, int(value))
except Exception:
return 1
@staticmethod
def _bounded_int(value, default, low, high):
try:
return min(high, max(low, int(value)))
except Exception:
return default
@staticmethod
def _bool(value, default):
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in ("1", "true", "yes", "on")
+620
View File
@@ -0,0 +1,620 @@
# -*- coding: utf-8 -*-
# //@name:TMDB导航
# //@id:tmdb_navigation
# //@version:1
import json
import re
import threading
import time
from collections import OrderedDict
from urllib.parse import quote, urlencode
import requests
from requests.adapters import HTTPAdapter
from base.spider import Spider as BaseSpider
class Spider(BaseSpider):
name = "TMDB导航"
host = "https://www.themoviedb.org"
backend_parse = False
category_mode = False
categoryMode = False
API = "https://api.themoviedb.org/3"
IMAGE = "https://image.tmdb.org/t/p/w500"
NOTICE_PREFIX = "tmdb-nav:notice:"
CATEGORIES = (
("trending", "本周趋势"),
("movie_discover", "电影"),
("tv_discover", "剧集"),
("anime", "动漫"),
("movie_popular", "热门电影"),
("movie_now", "正在上映"),
("movie_upcoming", "即将上映"),
("movie_top", "高分电影"),
("tv_popular", "热门剧集"),
("tv_airing", "今日播出"),
("tv_on_air", "正在播出"),
("tv_top", "高分剧集"),
)
REGION_GROUPS = (
("全部地区", ""),
("日韩", "JP|KR"),
("欧美", "US|GB|FR|DE|ES|IT|CA|AU"),
("国产", "CN|HK|TW"),
("东南亚", "TH|SG|MY|PH|ID|VN"),
)
REGIONS = (
("全部地区", ""), ("中国大陆", "CN"), ("中国香港", "HK"),
("中国台湾", "TW"), ("日本", "JP"), ("韩国", "KR"),
("美国", "US"), ("英国", "GB"), ("法国", "FR"),
("德国", "DE"), ("西班牙", "ES"), ("意大利", "IT"),
("加拿大", "CA"), ("澳大利亚", "AU"), ("泰国", "TH"),
("新加坡", "SG"), ("马来西亚", "MY"), ("菲律宾", "PH"),
("印度尼西亚", "ID"), ("越南", "VN"), ("印度", "IN"),
("俄罗斯", "RU"), ("巴西", "BR"), ("墨西哥", "MX"),
("土耳其", "TR"),
)
ANIME_REGIONS = (("国漫", "CN"), ("日漫", "JP"), ("韩漫", "KR"), ("美漫", "US"))
MOVIE_GENRES = (
("全部类型", ""), ("动作", "28"), ("冒险", "12"), ("动画", "16"),
("喜剧", "35"), ("犯罪", "80"), ("纪录片", "99"), ("剧情", "18"),
("家庭", "10751"), ("奇幻", "14"), ("历史", "36"), ("恐怖", "27"),
("音乐", "10402"), ("悬疑", "9648"), ("爱情", "10749"),
("科幻", "878"), ("惊悚", "53"), ("战争", "10752"), ("西部", "37"),
)
TV_GENRES = (
("全部类型", ""), ("动作冒险", "10759"), ("动画", "16"), ("喜剧", "35"),
("犯罪", "80"), ("纪录片", "99"), ("剧情", "18"), ("家庭", "10751"),
("儿童", "10762"), ("悬疑", "9648"), ("真人秀", "10764"),
("科幻奇幻", "10765"), ("肥皂剧", "10766"), ("脱口秀", "10767"),
("战争政治", "10768"), ("西部", "37"),
)
MOVIE_SORTS = (
("热度", "popularity.desc"), ("更新时间", "primary_release_date.desc"),
("评分", "vote_average.desc"), ("票房", "revenue.desc"),
)
TV_SORTS = (
("热度", "popularity.desc"), ("更新时间", "first_air_date.desc"),
("评分", "vote_average.desc"),
)
LANGUAGES = (
("全部语言", ""), ("中文", "zh"), ("日语", "ja"), ("韩语", "ko"),
("英语", "en"), ("法语", "fr"), ("德语", "de"), ("西班牙语", "es"),
("印地语", "hi"), ("泰语", "th"),
)
RATINGS = (("不限评分", ""), ("6分以上", "6"), ("7分以上", "7"), ("8分以上", "8"), ("9分以上", "9"))
VOTE_COUNTS = (("不限人数", ""), ("20票以上", "20"), ("50票以上", "50"), ("100票以上", "100"), ("500票以上", "500"))
RUNTIMES = (("不限片长", ""), ("90分钟内", "short"), ("90-120分钟", "medium"), ("120分钟以上", "long"))
def __init__(self):
self.api_base = self.API
self.image_base = self.IMAGE
self.access_token = ""
self.api_key = ""
self.language = "zh-CN"
self.region = "CN"
self.timeout = 15
self.list_cache_ttl = 300
self.detail_cache_ttl = 21600
self.stale_ttl = 86400
self.cache_max_entries = 256
self.verify_tls = True
self.trust_env = True
self.user_agent = "TMDB-Navigation-Spider/1.0"
self._cache = OrderedDict()
self._cache_lock = threading.RLock()
self._session = None
self._reset_session()
def getName(self):
return self.name
def init(self, extend=""):
config = self._parse_config(extend)
self.access_token = self._first(config, "access_token", "accessToken", "readAccessToken", "bearerToken", "token")
self.api_key = self._first(config, "api_key", "apiKey", "apikey", "tmdbApiKey", "key")
self.api_base = self._https_base(config.get("api_base") or config.get("apiBase"), self.API)
self.image_base = self._https_base(config.get("image_base") or config.get("imageBase"), self.IMAGE)
self.language = str(config.get("language") or "zh-CN").strip() or "zh-CN"
self.region = str(config.get("region") or "CN").strip().upper() or "CN"
self.timeout = self._bounded_int(config.get("timeout"), 15, 5, 45)
self.list_cache_ttl = self._bounded_int(config.get("list_cache_ttl"), 300, 30, 3600)
self.detail_cache_ttl = self._bounded_int(config.get("detail_cache_ttl"), 21600, 300, 604800)
self.stale_ttl = self._bounded_int(config.get("stale_ttl"), 86400, 300, 604800)
self.cache_max_entries = self._bounded_int(config.get("cache_max_entries"), 256, 32, 1024)
self.verify_tls = self._bool_value(config.get("verify_tls"), True)
self.trust_env = self._bool_value(config.get("trust_env"), True)
ua = str(config.get("user_agent") or "").strip()
if ua:
self.user_agent = ua
with self._cache_lock:
self._cache.clear()
self._reset_session()
def destroy(self):
if self._session is not None:
try:
self._session.close()
except Exception:
pass
def homeContent(self, filter=False):
result = {"class": [{"type_id": key, "type_name": name} for key, name in self.CATEGORIES]}
if filter:
result["filters"] = self._filters()
return result
def homeVideoContent(self):
try:
data = self._api("/trending/all/day", {"page": 1}, self.list_cache_ttl)
return {"list": self._cards(data.get("results"), "")}
except Exception as exc:
return {"list": [self._notice_card("TMDB 导航未就绪", exc)]}
def categoryContent(self, tid, pg, filter=False, extend=None):
page = self._positive_int(pg, 1)
ext = self._parse_extend(extend)
try:
if tid == "trending":
media = self._value(ext, "media", "all")
if media not in ("all", "movie", "tv"):
media = "all"
window = self._value(ext, "window", "week")
if window not in ("day", "week"):
window = "week"
return self._list_endpoint("/trending/%s/%s" % (media, window), page, media if media != "all" else "")
fixed = {
"movie_popular": ("/movie/popular", "movie"),
"movie_now": ("/movie/now_playing", "movie"),
"movie_upcoming": ("/movie/upcoming", "movie"),
"movie_top": ("/movie/top_rated", "movie"),
"tv_popular": ("/tv/popular", "tv"),
"tv_airing": ("/tv/airing_today", "tv"),
"tv_on_air": ("/tv/on_the_air", "tv"),
"tv_top": ("/tv/top_rated", "tv"),
}
if tid in fixed:
path, media_type = fixed[tid]
params = {"region": self._value(ext, "region", self.region)} if media_type == "movie" else {}
return self._list_endpoint(path, page, media_type, params)
if tid == "movie_discover":
return self._discover("movie", page, ext)
if tid == "tv_discover":
return self._discover("tv", page, ext)
if tid == "anime":
return self._anime(page, ext)
return self._page_result([], page, page, 0)
except Exception as exc:
return self._page_result([self._notice_card("TMDB 分类载入失败", exc)], page, page, 1)
def detailContent(self, ids):
media_type, tmdb_id = self._decode_id(self._first_id(ids))
if not media_type or not tmdb_id:
return {"list": []}
try:
data = self._api("/%s/%s" % (media_type, tmdb_id), {"append_to_response": "credits"}, self.detail_cache_ttl)
title = self._title(data)
original = str(data.get("original_title") or data.get("original_name") or "").strip()
display = title if not original or original == title else title + " / " + original
credits = data.get("credits") or {}
cast = [str(item.get("name")) for item in (credits.get("cast") or [])[:12] if item.get("name")]
crew = credits.get("crew") or []
directors = [str(item.get("name")) for item in crew if item.get("job") in ("Director", "Creator") and item.get("name")][:6]
genres = [str(item.get("name")) for item in data.get("genres") or [] if item.get("name")]
countries = [str(item.get("name")) for item in data.get("production_countries") or [] if item.get("name")]
if media_type == "tv" and not countries:
countries = [str(v) for v in data.get("origin_country") or []]
date = str(data.get("release_date") or data.get("first_air_date") or "")
remarks = self._remark(data, media_type)
vod = {
"vod_id": self._encode_id(media_type, tmdb_id),
"vod_name": display,
"vod_pic": self._image(data.get("poster_path") or data.get("backdrop_path")),
"type_name": ", ".join(genres),
"vod_year": date[:4],
"vod_area": ", ".join(countries),
"vod_remarks": remarks,
"vod_actor": ", ".join(cast),
"vod_director": ", ".join(directors),
"vod_content": str(data.get("overview") or "").strip(),
"vod_play_from": "",
"vod_play_url": "",
}
return {"list": [vod]}
except Exception as exc:
return {"list": [self._notice_card("TMDB 详情载入失败", exc)]}
def searchContent(self, key, quick=False, pg="1"):
return {"list": []}
def playerContent(self, flag, id, vipFlags=None):
return {
"parse": 0,
"jx": 0,
"playUrl": "",
"url": "",
"header": {},
"msg": "TMDB 仅提供影视资料,请使用全局搜索查找播放源",
}
def localProxy(self, param):
return [404, "text/plain; charset=utf-8", "not found"]
def action(self, action):
value = str(action or "")
if value.startswith(self.NOTICE_PREFIX):
return json.dumps({"msg": value[len(self.NOTICE_PREFIX):]}, ensure_ascii=False)
return json.dumps({"msg": "不支持的 TMDB 导航操作"}, ensure_ascii=False)
def _list_endpoint(self, path, page, media_type, extra=None):
params = {"page": page}
params.update(extra or {})
data = self._api(path, params, self.list_cache_ttl)
return self._page_from_api(data, page, media_type)
def _discover(self, media_type, page, ext):
sort_default = "popularity.desc"
params = {
"page": page,
"sort_by": self._value(ext, "sort", sort_default),
"with_genres": self._value(ext, "genre", ""),
"include_adult": "false",
"include_video": "false",
}
country = self._value(ext, "country", "")
region = country or self._value(ext, "area", "")
year = self._value(ext, "year", "")
language = self._value(ext, "language", "")
rating = self._value(ext, "rating", "")
vote_count = self._value(ext, "votes", "")
runtime = self._value(ext, "runtime", "")
if media_type == "movie":
if region:
params["with_origin_country"] = region
if country:
params["region"] = country
if year:
params["primary_release_year"] = year
else:
if region:
params["with_origin_country"] = region
if year:
params["first_air_date_year"] = year
if language:
params["with_original_language"] = language
if rating:
params["vote_average.gte"] = rating
if vote_count:
params["vote_count.gte"] = vote_count
elif params["sort_by"] == "vote_average.desc":
params["vote_count.gte"] = 50
if runtime == "short":
params["with_runtime.lte"] = 90
elif runtime == "medium":
params["with_runtime.gte"] = 90
params["with_runtime.lte"] = 120
elif runtime == "long":
params["with_runtime.gte"] = 120
data = self._api("/discover/" + media_type, params, self.list_cache_ttl)
return self._page_from_api(data, page, media_type)
def _anime(self, page, ext):
media_type = self._value(ext, "kind", "tv")
if media_type not in ("movie", "tv"):
media_type = "tv"
region = self._value(ext, "region", "JP")
sort = self._value(ext, "sort", "popularity.desc")
year = self._value(ext, "year", "")
params = {
"page": page,
"sort_by": sort,
"with_genres": "16",
"with_origin_country": region,
"include_adult": "false",
}
if media_type == "movie":
params["region"] = region
if year:
params["primary_release_year"] = year
elif year:
params["first_air_date_year"] = year
if sort == "vote_average.desc":
params["vote_count.gte"] = 20
data = self._api("/discover/" + media_type, params, self.list_cache_ttl)
return self._page_from_api(data, page, media_type)
def _page_from_api(self, data, page, media_type):
items = self._cards(data.get("results"), media_type)
pagecount = self._positive_int(data.get("total_pages"), page)
total = self._positive_int(data.get("total_results"), len(items))
return self._page_result(items, page, min(500, max(page, pagecount)), total)
def _cards(self, items, forced_type):
result = []
for raw in items or []:
media_type = forced_type or str(raw.get("media_type") or "")
if media_type not in ("movie", "tv"):
media_type = "movie" if raw.get("title") or raw.get("release_date") else "tv"
title = self._title(raw)
tmdb_id = self._positive_int(raw.get("id"), 0)
if not title or not tmdb_id:
continue
result.append({
"vod_id": self._encode_id(media_type, tmdb_id),
"vod_name": title,
"vod_pic": self._image(raw.get("poster_path") or raw.get("backdrop_path")),
"vod_remarks": self._remark(raw, media_type),
})
return result
def _filters(self):
years = [("全部年代", "")] + [(str(year), str(year)) for year in range(time.localtime().tm_year + 1, 1979, -1)]
return {
"trending": [
self._filter("media", "内容", (("全部", "all"), ("电影", "movie"), ("剧集", "tv"))),
self._filter("window", "周期", (("今日", "day"), ("本周", "week"))),
],
"movie_popular": [self._filter("region", "地区", self.REGIONS)],
"movie_now": [self._filter("region", "地区", self.REGIONS)],
"movie_upcoming": [self._filter("region", "地区", self.REGIONS)],
"movie_top": [self._filter("region", "地区", self.REGIONS)],
"tv_popular": [], "tv_airing": [], "tv_on_air": [], "tv_top": [],
"movie_discover": [
self._filter("area", "大区", self.REGION_GROUPS),
self._filter("country", "国家/地区", self.REGIONS),
self._filter("sort", "排序", self.MOVIE_SORTS),
self._filter("genre", "类型", self.MOVIE_GENRES),
self._filter("year", "年代", years),
self._filter("language", "原始语言", self.LANGUAGES),
self._filter("rating", "最低评分", self.RATINGS),
self._filter("votes", "评分人数", self.VOTE_COUNTS),
self._filter("runtime", "片长", self.RUNTIMES),
],
"tv_discover": [
self._filter("area", "大区", self.REGION_GROUPS),
self._filter("country", "国家/地区", self.REGIONS),
self._filter("sort", "排序", self.TV_SORTS),
self._filter("genre", "类型", self.TV_GENRES),
self._filter("year", "年代", years),
self._filter("language", "原始语言", self.LANGUAGES),
self._filter("rating", "最低评分", self.RATINGS),
self._filter("votes", "评分人数", self.VOTE_COUNTS),
self._filter("runtime", "单集片长", self.RUNTIMES),
],
"anime": [
self._filter("sort", "排序", self.TV_SORTS),
self._filter("region", "地区", self.ANIME_REGIONS),
self._filter("kind", "内容", (("动画剧集", "tv"), ("动画电影", "movie"))),
self._filter("year", "年代", years),
],
}
def _api(self, path, params=None, ttl=None):
if not self.access_token and not self.api_key:
raise RuntimeError("请在插件 Extend 配置个人 TMDB access_token 或 api_key")
query = dict(params or {})
query.setdefault("language", self.language)
if not self.access_token:
query["api_key"] = self.api_key
cache_query = {key: value for key, value in query.items() if key != "api_key"}
key = "json:" + path + "?" + urlencode(sorted(cache_query.items()), doseq=True)
ttl = self.list_cache_ttl if ttl is None else ttl
cached = self._cache_get(key, ttl)
if cached is not None:
return cached
stale = self._cache_get(key, self.stale_ttl, allow_expired=True)
try:
response = self._session.get(self.api_base + path, params=query, timeout=self.timeout, verify=self.verify_tls)
try:
data = response.json()
except Exception:
raise RuntimeError("TMDB 返回了非 JSON 内容")
if response.status_code in (401, 403):
raise RuntimeError("TMDB API 凭据无效或无权访问")
if response.status_code == 429:
raise RuntimeError("TMDB API 请求过于频繁,请稍后刷新")
if response.status_code != 200:
raise RuntimeError(str(data.get("status_message") or "TMDB HTTP %s" % response.status_code))
if not isinstance(data, dict):
raise RuntimeError("TMDB API 响应结构异常")
self._cache_set(key, data)
return data
except Exception:
if stale is not None:
return stale
raise
def _reset_session(self):
if self._session is not None:
try:
self._session.close()
except Exception:
pass
session = requests.Session()
session.trust_env = self.trust_env
session.headers.update({"Accept": "application/json", "User-Agent": self.user_agent})
if self.access_token:
session.headers["Authorization"] = "Bearer " + self.access_token
try:
from requests.packages.urllib3.util.retry import Retry
retry = Retry(total=2, connect=2, read=2, status=2, backoff_factor=0.35, status_forcelist=(429, 500, 502, 503, 504), allowed_methods=frozenset(("GET",)))
adapter = HTTPAdapter(max_retries=retry, pool_connections=8, pool_maxsize=8)
except TypeError:
adapter = HTTPAdapter(max_retries=2, pool_connections=8, pool_maxsize=8)
session.mount("https://", adapter)
self._session = session
def _cache_get(self, key, ttl, allow_expired=False):
with self._cache_lock:
item = self._cache.get(key)
if not item:
return None
created, value = item
age = time.time() - created
if age > ttl:
return value if allow_expired and age <= self.stale_ttl else None
self._cache.move_to_end(key)
return value
def _cache_set(self, key, value):
with self._cache_lock:
self._cache[key] = (time.time(), value)
self._cache.move_to_end(key)
while len(self._cache) > self.cache_max_entries:
self._cache.popitem(last=False)
def _notice_card(self, title, exc):
message = self._short_error(exc)
return {
"vod_id": "tmdb-notice",
"vod_name": title,
"vod_pic": "",
"vod_remarks": message,
"vod_content": message,
"action": self.NOTICE_PREFIX + message,
}
def _image(self, path):
value = str(path or "").strip()
if not value:
return ""
if value.startswith("http://") or value.startswith("https://"):
return value
return self.image_base.rstrip("/") + "/" + value.lstrip("/")
def _remark(self, data, media_type):
parts = []
vote = data.get("vote_average")
try:
score = float(vote or 0)
except Exception:
score = 0
if score > 0:
parts.append(("%.1f" % score).rstrip("0").rstrip(".") + "")
date = str(data.get("release_date") or data.get("first_air_date") or "")
if date[:4].isdigit():
parts.append(date[:4])
parts.append("电影" if media_type == "movie" else "剧集")
return " / ".join(parts)
@staticmethod
def _title(data):
return str(data.get("title") or data.get("name") or data.get("original_title") or data.get("original_name") or "").strip()
@staticmethod
def _encode_id(media_type, tmdb_id):
return "tmdb:%s:%s" % (media_type, tmdb_id)
@staticmethod
def _decode_id(value):
match = re.match(r"^tmdb:(movie|tv):(\d+)$", str(value or ""))
return (match.group(1), match.group(2)) if match else ("", "")
def _first_id(self, ids):
value = ids
if isinstance(value, str):
text = value.strip()
if text.startswith("["):
try:
value = json.loads(text)
except Exception:
value = text
if isinstance(value, (list, tuple)):
return str(value[0]) if value else ""
return str(value or "")
@staticmethod
def _parse_config(extend):
value = extend
if isinstance(value, str):
text = value.strip()
if not text:
return {}
try:
value = json.loads(text)
except Exception:
return {}
if not isinstance(value, dict):
return {}
data = value.get("data")
if data:
nested = Spider._parse_config(data)
merged = dict(value)
merged.update(nested)
return merged
return dict(value)
@staticmethod
def _parse_extend(extend):
if isinstance(extend, dict):
return extend
if isinstance(extend, str):
try:
value = json.loads(extend.strip() or "{}")
return value if isinstance(value, dict) else {}
except Exception:
return {}
return {}
@staticmethod
def _first(data, *keys):
for key in keys:
value = str(data.get(key) or "").strip()
if value:
return value
return ""
@staticmethod
def _value(data, key, default=""):
if not isinstance(data, dict):
return default
value = data.get(key)
return default if value is None else str(value)
@staticmethod
def _filter(key, name, pairs):
return {"key": key, "name": name, "value": [{"n": str(label), "v": str(value)} for label, value in pairs]}
@staticmethod
def _page_result(items, page, pagecount, total):
return {"list": items, "page": page, "pagecount": max(page, pagecount), "limit": 20, "total": total}
@staticmethod
def _positive_int(value, default):
try:
result = int(value)
return result if result > 0 else default
except Exception:
return default
@staticmethod
def _bounded_int(value, default, minimum, maximum):
try:
result = int(value)
except Exception:
return default
return max(minimum, min(maximum, result))
@staticmethod
def _bool_value(value, default):
if value is None:
return default
if isinstance(value, bool):
return value
return str(value).strip().lower() in ("1", "true", "yes", "on")
@staticmethod
def _https_base(value, default):
text = str(value or default).strip().rstrip("/")
return text if text.startswith("https://") else default
@staticmethod
def _short_error(exc):
text = str(exc or "未知错误").strip().replace("\r", " ").replace("\n", " ")
return text[:220] or "未知错误"
+172
View File
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import re
import requests
from urllib.parse import quote, urljoin
try:
from bs4 import BeautifulSoup
except Exception:
BeautifulSoup = None
try:
from base.spider import Spider as BaseSpider
except Exception:
BaseSpider = object
class Spider(BaseSpider):
BASE_URL = 'https://www.yasetube.com'
HEADERS = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36','Accept':'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8','Accept-Language':'zh-CN,zh;q=0.9,en;q=0.8','Referer':'https://www.yasetube.com/'}
CATS = {'nvce':'女厕偷拍','fc2-ppv':'FC2 PPV','me':'Mesubuta系列','milf':'MILF人妻无码','dalu':'自拍偷拍','madou':'品牌传媒'}
def __init__(self):
self.session = requests.Session()
self.session.headers.update(self.HEADERS)
def getName(self):
return '亚色影库'
def init(self, extend=''):
return None
def isVideoFormat(self, url):
return any(x in url.lower() for x in ['.m3u8','.mp4','.flv','.mkv','.avi','.ts'])
def manualVideoCheck(self):
return True
def destroy(self):
return None
def _get(self, url):
url = url if str(url).startswith('http') else urljoin(self.BASE_URL, url)
try:
r = self.session.get(url, timeout=12, verify=False, allow_redirects=True)
if not r.encoding or r.encoding.lower() == 'iso-8859-1':
r.encoding = 'utf-8'
return r.text
except requests.RequestException:
return ''
def _soup(self, html):
return BeautifulSoup(html, 'html.parser') if BeautifulSoup else None
def _txt(self, s):
return re.sub(r'\s+', ' ', s or '').strip()
def _abs(self, u):
if not u:
return ''
if u.startswith('//'):
return 'https:' + u
return urljoin(self.BASE_URL, u)
def _meta(self, html, name):
m = re.search(r'<meta[^>]+(?:property|name)=["\']%s["\'][^>]+content=["\']([^"\']+)' % re.escape(name), html, re.I)
return self._txt(m.group(1)) if m else ''
def _id(self, url):
url = self._abs(url).split('?')[0].rstrip('/')
return url.replace(self.BASE_URL + '/', '')
def _parse_list(self, html):
arr = []
if BeautifulSoup and html:
soup = self._soup(html)
for a in soup.select('article.loop-video.thumb-block a[href]'):
href = self._abs(a.get('href'))
if '/video/' not in href:
continue
img = a.select_one('img')
title = self._txt(a.get('title') or (img.get('alt') if img else '') or (a.select_one('header.entry-header span').get_text(' ', strip=True) if a.select_one('header.entry-header span') else ''))
pic = self._abs((img.get('data-src') or img.get('src') or img.get('data-original')) if img else '')
remark = self._txt(' '.join([x.get_text(' ', strip=True) for x in a.select('span.hd-video,span.views,span.duration')]))
if title and href:
arr.append({'vod_id':self._id(href),'vod_name':title,'vod_pic':pic,'vod_remarks':remark})
if not arr:
for it in re.findall(r'<article[^>]+loop-video[\s\S]*?</article>', html, re.I):
h = re.search(r'<a[^>]+href=["\']([^"\']+)["\'][^>]*title=["\']([^"\']+)', it, re.I)
p = re.search(r'<img[^>]+(?:data-src|src)=["\']([^"\']+)', it, re.I)
if h:
arr.append({'vod_id':self._id(h.group(1)),'vod_name':self._txt(h.group(2)),'vod_pic':self._abs(p.group(1)) if p else '','vod_remarks':'HD' if 'hd-video' in it else ''})
seen, out = set(), []
for v in arr:
if v['vod_id'] not in seen:
seen.add(v['vod_id'])
out.append(v)
return out
def _cats(self):
html = self._get('/categories')
classes = []
if BeautifulSoup and html:
soup = self._soup(html)
for a in soup.select('a[href*="/video/category/"]'):
href = a.get('href') or ''
slug = href.rstrip('/').split('/')[-1]
name = self._txt(a.get('title') or a.get_text(' ', strip=True))
if slug and name and slug not in [x['type_id'] for x in classes]:
classes.append({'type_id':slug,'type_name':name})
if not classes:
classes = [{'type_id':k,'type_name':v} for k,v in self.CATS.items()]
return classes[:30]
def homeContent(self, filter=False):
return {'class':self._cats(),'filters':{},'list':self.homeVideoContent()['list']}
def homeVideoContent(self):
return {'list':self._parse_list(self._get('/'))[:20]}
def categoryContent(self, tid, pg, filter, ext):
pg = str(pg or '1')
if tid in ['latest','home','']:
url = '/' if pg == '1' else '/page/%s/' % pg
else:
url = '/video/category/%s/' % tid if pg == '1' else '/video/category/%s/page/%s/' % (tid, pg)
return {'page':int(pg),'pagecount':999,'limit':20,'total':999,'list':self._parse_list(self._get(url))}
def detailContent(self, ids):
vid = ids[0] if isinstance(ids, list) else ids
url = vid if str(vid).startswith('http') else self.BASE_URL + '/' + str(vid).lstrip('/')
html = self._get(url)
name = self._meta(html, 'og:title')
pic = self._meta(html, 'og:image')
desc = self._meta(html, 'og:description')
if BeautifulSoup and html:
soup = self._soup(html)
h = soup.select_one('h1.entry-title')
if h:
name = self._txt(h.get_text(' ', strip=True)) or name
im = soup.select_one('meta[itemprop="thumbnailUrl"]')
if im and im.get('content'):
pic = im.get('content') or pic
ds = soup.select_one('meta[itemprop="description"]')
if ds and ds.get('content'):
desc = ds.get('content') or desc
play = self._find_play(html) or url
vod = {'vod_id':vid,'vod_name':name or str(vid),'vod_pic':self._abs(pic),'type_name':'','vod_year':'','vod_area':'','vod_actor':'','vod_director':'','vod_content':desc or name or '','vod_play_from':'嗅探','vod_play_url':'正片$%s' % play}
return {'list':[vod]}
def searchContent(self, key, quick, pg='1'):
pg = str(pg or '1')
kw = quote(key)
url = '/?s=%s' % kw if pg == '1' else '/page/%s/?s=%s' % (pg, kw)
return {'page':int(pg),'pagecount':999,'limit':20,'total':999,'list':self._parse_list(self._get(url))}
def playerContent(self, flag, id, vipFlags):
u = self._abs(id)
if self.isVideoFormat(u):
return {'parse':0,'playUrl':'','url':u,'header':self.HEADERS}
html = self._get(u)
play = self._find_play(html)
if play and self.isVideoFormat(play):
return {'parse':0,'playUrl':'','url':play,'header':self.HEADERS}
return {'parse':1,'playUrl':'','url':u,'header':self.HEADERS}
def _find_play(self, html):
if not html:
return ''
pats = [r'<source[^>]+src=["\']([^"\']+)',r'<video[^>]+src=["\']([^"\']+)',r'(https?:\\?/\\?/[^"\'<>]+?\.(?:m3u8|mp4)(?:\?[^"\'<>]*)?)',r'file["\']?\s*[:=]\s*["\']([^"\']+)',r'url["\']?\s*[:=]\s*["\']([^"\']+\.(?:m3u8|mp4)[^"\']*)']
for p in pats:
m = re.search(p, html, re.I)
if m:
return self._abs(m.group(1).replace('\\/','/'))
return ''
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
import sys, requests, re, json, base64
from bs4 import BeautifulSoup
from urllib.parse import quote
class Spider():
def __init__(self):
self.host = "https://dottaia.lol"
self.session = requests.Session()
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36',
'Referer': self.host,
}
def getDependence(self):
return []
def init(self, extend=""):
try: self.session.get(self.host, headers=self.headers, timeout=5)
except: pass
def homeContent(self, filter):
result = {'class': [], 'list': [], 'filters': {}}
try:
res = self.session.get(self.host + "/cn", headers=self.headers)
res.encoding = 'utf-8'
soup = BeautifulSoup(res.text, 'html.parser')
seen = set()
for a in soup.select('a[href*="/cn/tag/"]'):
href = a.get('href', '')
name = a.text.strip()
m = re.search(r'/cn/tag/(.+)', href)
if m and name and name not in seen:
seen.add(name)
result['class'].append({"type_name": name, "type_id": quote(m.group(1))})
result['list'] = self._parse_list(soup)
except: pass
return result
def homeVideoContent(self):
return self.homeContent(False)
def _parse_list(self, soup):
items = []
seen = set()
for card in soup.select('.movie-card'):
try:
a = card.find('a', href=re.compile(r'/cn/movie/'))
if not a: continue
href = a.get('href', '')
vid = href.split('/movie/')[-1] if '/movie/' in href else href
if not vid or vid in seen: continue
seen.add(vid)
img = card.find('img')
pic = img.get('data-src') or img.get('src') or '' if img else ''
if pic and not pic.startswith('http'): pic = self.host + pic
name = ''
h5 = card.find('h5')
if h5:
name = h5.get('data-full-title') or h5.text.strip()
if not name:
name = a.get('title', '')
items.append({"vod_id": vid, "vod_name": name, "vod_pic": pic})
except: continue
return items
def categoryContent(self, tid, pg, filter, extend):
url = f"{self.host}/cn/tag/{tid}"
if pg and int(pg) > 1: url += f"?page={pg}"
try:
res = self.session.get(url, headers=self.headers)
res.encoding = 'utf-8'
soup = BeautifulSoup(res.text, 'html.parser')
v_list = self._parse_list(soup)
return {"list": v_list, "page": int(pg), "pagecount": 99, "limit": len(v_list), "total": 999}
except:
return {"list": [], "page": int(pg)}
def detailContent(self, ids):
result = {"list": []}
for vid in ids:
try:
url = f"{self.host}/cn/movie/{vid}"
res = self.session.get(url, headers=self.headers)
res.encoding = 'utf-8'
soup = BeautifulSoup(res.text, 'html.parser')
name = soup.select_one('h1')
name = name.text.strip() if name else soup.title.text.split('-')[0].strip() if soup.title else vid
pic = ""
ld = soup.select_one('script[type="application/ld+json"]')
if ld:
try:
d = json.loads(ld.string)
pic = d.get('thumbnailUrl', '')
except: pass
if not pic:
img = soup.select_one('img[uk-cover]')
if img: pic = img.get('src', '')
from_list, url_list = [], []
ls = soup.select_one('select.line-select')
opts = ls.find_all('option') if ls else []
if not opts:
opts = soup.select('option')
for opt in opts:
val = opt.get('value', '')
txt = opt.text.strip()
if val == 'no' or txt in ('English', '简体中文', '繁體中文', '720P'): continue
if not val: continue
from_list.append(txt)
url_list.append(f"高清${vid}|{val}")
if not from_list:
from_list.append("默认")
url_list.append(f"高清${vid}")
result["list"].append({
"vod_id": vid,
"vod_name": name,
"vod_pic": pic,
"vod_play_from": "$$$".join(from_list),
"vod_play_url": "$$$".join(["#".join(url_list)])
})
except: continue
return result
def searchContent(self, key, quick, pg=1):
url = f"{self.host}/cn/search?q={quote(key)}"
if pg and int(pg) > 1: url += f"&page={pg}"
try:
res = self.session.get(url, headers=self.headers)
res.encoding = 'utf-8'
soup = BeautifulSoup(res.text, 'html.parser')
return {"list": self._parse_list(soup)}
except: return {"list": []}
def playerContent(self, flag, id, vipFlags):
parts = id.split("|")
vid = parts[0]
url = f"{self.host}/cn/movie/{vid}"
try:
self.headers['Referer'] = url
res = self.session.get(url, headers=self.headers)
html = res.text
m3u8 = re.search(r'(https?://[^\s"\']+\.m3u8[^\s"\']*)', html)
if m3u8:
return {"parse": 0, "url": m3u8.group(1).replace('\\/', '/'), "header": dict(self.headers)}
match = re.search(r'player_aaaa\s*=\s*(\{.*?\})', html)
if match:
v_url = json.loads(match.group(1)).get('url', '')
if not any(x in v_url for x in ['.m3u8', '.mp4', 'http']) and len(v_url) > 20:
v_url = base64.b64decode(v_url).decode('utf-8')
return {"parse": 0 if '.m3u8' in v_url.lower() or '.mp4' in v_url.lower() else 1, "url": v_url, "header": dict(self.headers)}
except: pass
return {"parse": 1, "url": url, "header": dict(self.headers)}
+89
View File
@@ -0,0 +1,89 @@
import requests
import json
import re
from urllib.parse import urlparse, parse_qs
class DouyinCollectionSpider:
def __init__(self):
# 请求头,替换成你浏览器抓包的Cookie
self.headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Referer": "https://www.douyin.com/",
"Cookie": "这里粘贴你浏览器登录抖音后的完整Cookie"
}
self.session = requests.Session()
self.session.headers.update(self.headers)
def get_collection_id(self, collection_url: str) -> str:
"""从合集链接提取合集ID"""
# 格式示例:https://www.douyin.com/collection/7566889922334444141/
if "collection/" in collection_url:
match = re.search(r"collection/(\d+)", collection_url)
if match:
return match.group(1)
# 短链接解析(可选,需额外重定向处理)
parsed = urlparse(collection_url)
params = parse_qs(parsed.query)
if "itemIds" in params:
return params["itemIds"][0]
raise Exception("合集链接解析失败,请检查链接格式")
def fetch_collection_all_videos(self, collection_id: str):
"""获取合集全部视频列表"""
base_api = "https://www.douyin.com/aweme/v1/collection/video/list/"
all_videos = []
max_cursor = 0
has_more = True
while has_more:
params = {
"collection_id": collection_id,
"max_cursor": max_cursor,
"count": 20, # 单次拉取数量
"version_code": "170000"
}
resp = self.session.get(base_api, params=params, timeout=15)
if resp.status_code != 200:
print(f"接口请求失败,状态码:{resp.status_code}")
break
data = resp.json()
video_list = data.get("aweme_list", [])
if not video_list:
break
# 解析单条视频信息
for aweme in video_list:
video_info = {
"视频ID": aweme.get("aweme_id"),
"标题": aweme.get("desc"),
"作者昵称": aweme.get("author", {}).get("nickname"),
"作者UID": aweme.get("author", {}).get("uid"),
"发布时间": aweme.get("create_time"),
"封面图": aweme.get("video", {}).get("cover", {}).get("url_list", [])[0] if aweme.get("video") else "",
"无水印播放地址": aweme.get("video", {}).get("play_addr", {}).get("url_list", [])[0] if aweme.get("video") else "",
"点赞数": aweme.get("statistics", {}).get("digg_count"),
"评论数": aweme.get("statistics", {}).get("comment_count"),
"分享数": aweme.get("statistics", {}).get("share_count")
}
all_videos.append(video_info)
# 分页判断
has_more = data.get("has_more", 0) == 1
max_cursor = data.get("max_cursor", 0)
print(f"已获取 {len(all_videos)} 条视频,继续加载下一页...")
return all_videos
def save_to_json(self, video_list, save_name="抖音合集数据.json"):
"""保存合集数据到本地JSON文件"""
with open(save_name, "w", encoding="utf-8") as f:
json.dump(video_list, f, ensure_ascii=False, indent=2)
print(f"数据已保存至 {save_name},共 {len(video_list)} 条视频")
if __name__ == "__main__":
spider = DouyinCollectionSpider()
# 替换为你的抖音合集链接
collection_url = "https://www.douyin.com/collection/合集数字ID/"
try:
cid = spider.get_collection_id(collection_url)
video_data = spider.fetch_collection_all_videos(cid)
spider.save_to_json(video_data)
except Exception as e:
print(f"运行异常:{str(e)}")
+386
View File
@@ -0,0 +1,386 @@
# -*- coding: utf-8 -*-
import sys
import re
import json
import base64
import threading
import requests
import urllib3
import time
import random
from http.server import HTTPServer, BaseHTTPRequestHandler
from socketserver import ThreadingMixIn
from urllib.parse import unquote, quote, urljoin, urlparse
urllib3.disable_warnings()
sys.path.append('..')
from base.spider import Spider as BaseSpider
# ===== 本地代理(用于图片防盗链)=====
_proxy_port = 0
_proxy_started = False
_proxy_session = requests.Session()
_proxy_session.verify = False
_proxy_headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Referer': 'https://zsrqab03.zsrenqi.xyz/',
}
class _ThreadedHTTPServer(ThreadingMixIn, HTTPServer):
daemon_threads = True
class _ProxyHandler(BaseHTTPRequestHandler):
def do_GET(self):
try:
real_url = unquote(self.path[1:])
if not real_url or not real_url.startswith('http'):
self.send_response(404); self.end_headers(); return
r = _proxy_session.get(real_url, headers=_proxy_headers, timeout=20, verify=False)
ct = r.headers.get('Content-Type', 'image/jpeg')
self.send_response(200)
self.send_header('Content-Type', ct)
self.send_header('Content-Length', len(r.content))
self.send_header('Access-Control-Allow-Origin', '*')
self.end_headers()
self.wfile.write(r.content)
except BrokenPipeError: pass
except Exception:
self.send_response(404); self.end_headers()
def log_message(self, format, *args): pass
def _find_free_port():
import socket
sk = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sk.bind(('127.0.0.1', 0))
port = sk.getsockname()[1]
sk.close()
return port
def _start_proxy():
global _proxy_port, _proxy_started
if _proxy_started: return
_proxy_port = _find_free_port()
server = _ThreadedHTTPServer(('127.0.0.1', _proxy_port), _ProxyHandler)
threading.Thread(target=server.serve_forever, daemon=True).start()
_proxy_started = True
# ===== Spider =====
class Spider(BaseSpider):
session = requests.Session()
host = 'https://zsrqab03.zsrenqi.xyz'
def __init__(self):
super().__init__()
self._categories_cache = None
self._debug = True
def _log(self, msg):
if self._debug:
print(f'[zsrenqi] {msg}')
def getName(self):
return '真实人妻'
def isVideoFormat(self, url):
if not url or not isinstance(url, str):
return False
return url.startswith('http') and any(ext in url for ext in ['.m3u8', '.mp4', '.ts', '.flv', '.mkv'])
def manualVideoCheck(self):
return False
def destroy(self):
pass
def localProxy(self, param):
return [404, 'text/plain', '']
def init(self, extend=''):
self.session.verify = False
self.session.headers.update(self._get_headers())
_start_proxy()
text = self._fetch(self.host)
if text:
self._load_categories(text)
def _get_headers(self, referer=None):
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Referer': referer or self.host + '/',
}
return headers
def _proxy_url(self, url):
if not url:
return ''
if url.startswith('http://127.0.0.1'):
return url
return f'http://127.0.0.1:{_proxy_port}/{quote(url, safe="")}'
def _fetch(self, url, referer=None, retries=3):
for i in range(retries):
try:
if referer is None:
referer = self.host + '/'
headers = self._get_headers(referer)
if i > 0:
time.sleep(random.uniform(0.5, 1.5))
r = self.session.get(url, headers=headers, timeout=30, verify=False)
r.encoding = 'utf-8'
if r.status_code == 200:
return r.text
elif r.status_code in [403, 429, 503]:
self._log(f'请求被拦截 [{r.status_code}],重试 {i+1}/{retries}')
else:
return ''
except Exception as e:
self._log(f'请求异常 [{e}],重试 {i+1}/{retries}')
return ''
# ===== 分类加载(修改:移除指定分类)=====
def _load_categories(self, text):
if not text:
return
cats = []
seen = set()
cats.append({'type_id': 'home', 'type_name': '首页'})
seen.add('home')
# ---------- 要移除的分类(视频一区、视频五区、热搜视频) ----------
remove_list = ['视频一区', '视频五区', '热搜视频']
# -----------------------------------------------------------------
pattern = r'<div class="row-item[^"]*">\s*<div class="row-item-title[^"]*">\s*<a href="(/index\.php/vod/type/id/(\d+)\.html)"[^>]*>([^<]+)</a>'
for href, tid, name in re.findall(pattern, text, re.S):
if tid in seen:
continue
if name.strip() in remove_list: # 跳过这些分类
self._log(f'移除分类: {name}')
continue
seen.add(tid)
cats.append({'type_id': tid, 'type_name': name.strip()})
# 备用提取(仅在分类数量不足时启用,避免遗漏)
if len(cats) <= 2:
alt_pattern = r'<a[^>]+href="(/index\.php/vod/type/id/(\d+)\.html)"[^>]*>([^<]+)</a>'
for href, tid, name in re.findall(alt_pattern, text, re.S):
if tid in seen:
continue
if name.strip() in remove_list:
continue
if 'https' in href or 'http' in href:
continue # 跳过外部链接
seen.add(tid)
cats.append({'type_id': tid, 'type_name': name.strip()})
self._categories_cache = cats
self._log(f'加载分类: {len(cats)}')
# ===== 列表解析 =====
def _parse_items(self, html):
items = []
pattern = r'<li\s+class="content-item">\s*<a[^>]+href="(/index\.php/vod/detail/id/(\d+)\.html)"[^>]*>.*?<img[^>]+data-original="([^"]+)"[^>]*>.*?</a>\s*<div\s+class="title">\s*<h5[^>]*>\s*<a[^>]*>([^<]+)</a>'
for href, vid, pic, title in re.findall(pattern, html, re.S):
items.append({
'vod_id': vid,
'vod_name': title.strip(),
'vod_pic': self._proxy_url(pic) if pic.startswith('http') else pic,
'vod_remarks': '',
})
return items
def _get_list(self, tid, page):
if page == 1:
url = f'{self.host}/index.php/vod/type/id/{tid}.html'
else:
url = f'{self.host}/index.php/vod/type/id/{tid}.html?page={page}'
html = self._fetch(url, referer=f'{self.host}/index.php/vod/type/id/{tid}.html')
if not html and page > 1:
url = f'{self.host}/index.php/vod/type/id/{tid}/page/{page}.html'
html = self._fetch(url, referer=f'{self.host}/index.php/vod/type/id/{tid}.html')
if not html:
return []
return self._parse_items(html)
# ===== 详情解析(提取线路播放页)=====
def _fetch_detail(self, vid):
url = f'{self.host}/index.php/vod/detail/id/{vid}.html'
self._log(f'获取详情: {url}')
html = self._fetch(url, referer=self.host)
if not html:
return None
return self._parse_detail(html, vid, url)
def _parse_detail(self, html, vid, base_url):
# 标题
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', html, re.S)
if m:
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
if not title:
m = re.search(r'<title>([^<]+)</title>', html)
if m:
raw_title = m.group(1).strip()
title = re.split(r'\s*[-|]\s*', raw_title)[0].strip()
if not title:
title = vid
# 封面
cover = ''
m = re.search(r'<meta[^>]*property="og:image"[^>]*content="([^"]+)"', html)
if m:
cover = m.group(1)
if not cover:
m = re.search(r'<img[^>]+class="[^"]*cover[^"]*"[^>]+src="([^"]+)"', html, re.S)
if m:
cover = m.group(1)
if not cover:
m = re.search(r'data-original="([^"]+)"', html)
if m:
cover = m.group(1)
# 提取所有线路播放页链接(如 /index.php/vod/play/id/xxx/sid/1/nid/1.html
play_links = []
pattern = r'href=["\'](/index\.php/vod/play/id/\d+/sid/\d+/nid/\d+\.html)["\']'
for link in set(re.findall(pattern, html)):
full = urljoin(base_url, link)
if full.startswith('http'):
play_links.append(full)
# 若没有,尝试构造默认线路
if not play_links:
play_links.append(f'{self.host}/index.php/vod/play/id/{vid}/sid/1/nid/1.html')
# 组装播放串(线路名以“线路一、线路二”命名)
sources = []
urls = []
for idx, play_url in enumerate(play_links, 1):
label = f'线路{idx}'
sources.append(label)
urls.append(f'{label}${play_url}')
play_from = '$$$'.join(sources)
play_url = '#'.join(urls)
return {
'vod_id': vid,
'vod_name': title,
'vod_pic': self._proxy_url(cover) if cover else '',
'vod_play_from': play_from,
'vod_play_url': play_url,
'vod_content': title,
}
# ===== 首页、分类、详情、搜索 =====
def homeContent(self, filter):
try:
text = self._fetch(self.host)
if text and self._categories_cache is None:
self._load_categories(text)
cats = self._categories_cache or []
home_list = self._parse_items(text) if text else []
return {
'class': cats,
'filters': {},
'type': '影视',
'list': home_list,
'page': 1,
'pagecount': 1,
'limit': len(home_list),
'total': len(home_list)
}
except Exception as e:
self._log(f'homeContent 异常: {e}')
return {'class': [], 'filters': {}, 'type': '影视', 'list': [], 'page': 1, 'pagecount': 1, 'limit': 0, 'total': 0}
def homeVideoContent(self):
return {'list': []}
def categoryContent(self, tid, pg, filter, extend):
try:
if tid.startswith('search_'):
keyword = tid[7:]
return self.searchContent(keyword, False, pg)
if tid == 'home':
html = self._fetch(self.host)
items = self._parse_items(html) if html else []
return {'list': items, 'page': 1, 'pagecount': 1, 'limit': len(items), 'total': len(items)}
else:
page = int(pg) if pg else 1
items = self._get_list(tid, page)
total_page = page + 1
if page == 1:
first_html = self._fetch(f'{self.host}/index.php/vod/type/id/{tid}.html')
if first_html:
pages = re.findall(r'/page/(\d+)\.html', first_html)
if not pages:
pages = re.findall(r'[?&]page=(\d+)', first_html)
if pages:
total_page = max(int(p) for p in pages)
return {'list': items, 'page': page, 'pagecount': total_page, 'limit': len(items), 'total': total_page * len(items)}
except Exception as e:
self._log(f'categoryContent 异常: {e}')
return {'list': [], 'page': 1, 'pagecount': 1, 'limit': 0, 'total': 0}
def detailContent(self, ids):
try:
vid = str(ids[0] if isinstance(ids, list) else ids)
detail = self._fetch_detail(vid)
if not detail:
detail = {'vod_id': vid, 'vod_name': vid, 'vod_pic': '', 'vod_play_from': '', 'vod_play_url': '无$'}
return {'list': [detail]}
except Exception as e:
self._log(f'detailContent 异常: {e}')
return {'list': []}
# ===== 播放器(返回 parse=1 让 TVBox 解析播放页)=====
def playerContent(self, flag, id, vipFlags=None):
try:
if not id:
return {'parse': 0, 'url': '', 'header': {}}
# 如果 id 是数字,则取第一个线路播放页
if id.isdigit():
detail = self._fetch_detail(id)
if detail and detail.get('vod_play_url'):
first_line = detail['vod_play_url'].split('#')[0]
if '$' in first_line:
play_url = first_line.split('$', 1)[1]
else:
play_url = first_line
id = play_url
# 如果 id 是播放页(以 .html 结尾),则让 TVBox 解析
if id.endswith('.html') and id.startswith('http'):
return {
'parse': 1,
'url': id,
'header': {
'Referer': self.host,
'User-Agent': 'Mozilla/5.0',
}
}
# 否则直接播放(可能是视频地址)
referer = self.host
if id.startswith('http'):
parsed = urlparse(id)
if parsed.netloc:
referer = f'{parsed.scheme}://{parsed.netloc}/'
return {
'parse': 0,
'url': id,
'header': {
'Referer': referer,
'User-Agent': 'Mozilla/5.0',
}
}
except Exception as e:
self._log(f'playerContent 异常: {e}')
return {'parse': 0, 'url': '', 'header': {}}
def searchContent(self, key, quick, pg='1'):
try:
page = int(pg) if pg else 1
url = f'{self.host}/index.php/vod/search.html?wd={quote(key)}&page={page}'
html = self._fetch(url, referer=self.host)
items = self._parse_items(html) if html else []
return {'list': items, 'page': page, 'pagecount': page + 1, 'limit': len(items), 'total': page * len(items)}
except Exception as e:
self._log(f'searchContent 异常: {e}')
return {'list': [], 'page': 1, 'pagecount': 1, 'limit': 0, 'total': 0}
+410 -30
View File
@@ -1,46 +1,426 @@
# -*- coding: utf-8 -*-
"""
TVBox 本地 Py/Js 爬虫聚合源
==============================
扫描 /py/ 和 /js/ 目录下的爬虫文件,聚合展示。
"""
import os
import sys
import json
import base64
from base.spider import Spider
PY_DIR = "/storage/emulated/0/VodPlus/小百合/py"
def build_sites():
sites = []
self_path = os.path.abspath(__file__) # 当前脚本的绝对路径
class Spider(Spider):
# ==========================================================================
# 📂 【配置区】
# ==========================================================================
PY_DIR = "/storage/emulated/0/TV/海豚/py"
JS_DIR = "/storage/emulated/0/TV/海豚/js"
JAR_DIR = "/storage/emulated/0/TV/海豚/jar"
SAVE_PATH = "/storage/emulated/0/TV/海豚/自动接口.json"
LOGO_PATH = "/https://img.freepik.com/free-vector/cute-dolphin-swimming-cartoon-vector-icon-illustration-animal-nature-icon-isolated-flat-vector_138676-12582.jpg?semt=ais_hybrid&w=740&q=80"
# 递归遍历 PY_DIR 下所有子目录
for root, dirs, files in os.walk(PY_DIR):
for f in sorted(files):
if not f.endswith(".py"):
# 🔒 锁定在 sites 第 0、1 位的配置,无论扫描结果如何始终存在
_LOCKED_SITES = [
{
"key": "自动加载",
"name": "自动加载",
"type": 3,
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/自动加载.py"
},
{
"name": "弹幕",
"key": "弹幕豆瓣",
"type": 3,
"api": "csp_SecureDanmu",
"searchable": 1,
"jar": "https://ghfast.top/https://raw.githubusercontent.com/goodcommunication/mydm/main/danmu-spider-native.jar",
"ext": {
"apiUrls": [
"https://danmu.iyo.us.ci/theft-dastardly-prognosis-hula-agenda2-dropkick|公益源",
"https://logo.saodu.work:8888/87654321|公益源1",
"https://dm.ljiaovm.com/luosen|公益源2"
],
"titleMappingsUrl": "https://ghfast.top/https://raw.githubusercontent.com/goodcommunication/mydm/main/yins.json",
"filter": "./lib/douban.json"
}
}
]
_LOCKED_KEYS = {"FishConfig", "Local"}
# ==========================================================================
def __init__(self):
super().__init__()
self.inited = False
self.cache = {"categories": [], "file_index": {}}
def getName(self):
return "本地Py/Js聚合源"
def init(self, extend):
if self.inited:
return
self._scan_all()
self._save_config_json()
self.inited = True
# ==========================================================================
# 🔍 【扫描核心】手动递归,不依赖 os.walk
# ==========================================================================
def _scan_dir(self, base_dir, ext_list):
"""手动递归扫描目录,返回 [(full_path, file_name_no_ext, ext), ...]"""
results = []
if not base_dir:
return results
if not os.path.exists(base_dir):
try:
os.makedirs(base_dir, exist_ok=True)
except Exception:
return results
if not os.path.isdir(base_dir):
return results
try:
entries = os.listdir(base_dir)
except Exception:
return results
for entry in sorted(entries):
full_path = os.path.join(base_dir, entry)
if entry.startswith("."):
continue
full_path = os.path.join(root, f)
if os.path.abspath(full_path) == self_path:
continue # 排除自身
file_name = f[:-3] # 去掉 .py 后缀
rel_path = os.path.relpath(full_path, PY_DIR)
folder_part = os.path.dirname(rel_path)
if os.path.isdir(full_path):
sub_results = self._scan_dir(full_path, ext_list)
results.extend(sub_results)
elif os.path.isfile(full_path):
lower_name = entry.lower()
matched_ext = None
for ext in ext_list:
if lower_name.endswith(ext):
matched_ext = ext
break
if matched_ext:
name_no_ext = entry[: -len(matched_ext)]
results.append((full_path, name_no_ext, matched_ext))
if folder_part:
# 子文件夹里的 py 文件 → 不显示文件夹名
name_part = file_name
else:
# py 文件夹里直接有的 py 文件 → 显示文件夹名在前
folder_name = os.path.basename(PY_DIR)
name_part = folder_name + '' + file_name
return results
def _scan_all(self):
"""扫描 py 和 js 两个目录"""
sites = []
self_path = os.path.abspath(__file__) if hasattr(__file__, '__file__') else ""
# ---- 扫描 .py 文件 ----
py_files = self._scan_dir(self.PY_DIR, [".py"])
for full_path, name, ext in py_files:
if self_path and os.path.abspath(full_path) == self_path:
continue
display_name = f"【PY】{name}"
tid = base64.b64encode(
("PY|" + full_path).encode("utf-8")
).decode("utf-8")
sites.append({
"key": f"py_{name_part}",
"name": name_part,
"type_id": tid,
"type_name": display_name,
"_path": full_path,
"_ext": "py",
"_dir": self.PY_DIR,
"_sk": (0, name),
})
self.cache["file_index"][tid] = {
"path": full_path,
"ext": "py",
"dir": self.PY_DIR,
}
# ---- 扫描 .js 文件 ----
js_files = self._scan_dir(self.JS_DIR, [".js"])
for full_path, name, ext in js_files:
display_name = f"【JS】{name}"
tid = base64.b64encode(
("JS|" + full_path).encode("utf-8")
).decode("utf-8")
sites.append({
"type_id": tid,
"type_name": display_name,
"_path": full_path,
"_ext": "js",
"_dir": self.JS_DIR,
"_sk": (1, name),
})
self.cache["file_index"][tid] = {
"path": full_path,
"ext": "js",
"dir": self.JS_DIR,
}
sites.sort(key=lambda x: x["_sk"])
self.cache["categories"] = [
{"type_id": s["type_id"], "type_name": s["type_name"]}
for s in sites
]
def _build_api(self, file_info):
"""拼接 api 相对路径"""
f_path = file_info["path"]
base_dir = file_info["dir"]
try:
rel = os.path.relpath(f_path, base_dir)
except ValueError:
rel = os.path.basename(f_path)
dir_name = os.path.basename(base_dir)
return "./" + dir_name + "/" + rel
# ==========================================================================
# 🆕 【jar 扫描】扫描 jar 目录下所有 .jar 文件,拼接 spider 值
# ==========================================================================
def _build_spider_value(self):
"""扫描 jar 目录,返回用分号拼接的所有 jar 相对路径"""
jar_dir = self.JAR_DIR
if not jar_dir or not os.path.isdir(jar_dir):
return ""
jar_files = []
save_dir = os.path.dirname(self.SAVE_PATH)
try:
entries = sorted(os.listdir(jar_dir))
except Exception:
return ""
for entry in entries:
if entry.startswith("."):
continue
if entry.lower().endswith(".jar") and os.path.isfile(os.path.join(jar_dir, entry)):
abs_jar = os.path.join(jar_dir, entry)
try:
rel = os.path.relpath(abs_jar, save_dir)
except ValueError:
rel = "jar/" + entry
rel = "./" + rel.replace("\\", "/")
if not rel.startswith("./"):
rel = "./" + rel.lstrip("./")
jar_files.append(rel)
return ";".join(jar_files)
def _save_config_json(self):
"""保存 TVBox config.json"""
config = {
"logo": self.LOGO_PATH,
"spider": self._build_spider_value(),
"sites": []
}
for cat in self.cache["categories"]:
file_info = self.cache["file_index"].get(cat["type_id"])
if not file_info:
continue
f_path = file_info["path"]
ext = file_info["ext"]
f_base = os.path.basename(f_path)
if "." in f_base:
f_base = f_base.rsplit(".", 1)[0]
config["sites"].append({
"key": f_base + "_" + ext,
"name": f_base,
"type": 3,
"searchable": 1,
"quickSearch": 1,
"api": f"file://{full_path}"
"filterable": 1,
"api": self._build_api(file_info),
})
return sites
CONFIG = {
"spider": "",
"sites": build_sites()
}
# ============================================================
# 🔒 【锁定逻辑】移除冲突项 → 锁定项强制置顶
# ============================================================
filtered = [
s for s in config["sites"]
if s.get("key") not in self._LOCKED_KEYS
]
config["sites"] = list(self._LOCKED_SITES) + filtered
# ============================================================
save_dir = os.path.dirname(self.SAVE_PATH)
if save_dir and not os.path.exists(save_dir):
try:
os.makedirs(save_dir, exist_ok=True)
except Exception:
pass
try:
with open(self.SAVE_PATH, "w", encoding="utf-8") as fp:
json.dump(config, fp, ensure_ascii=False, indent=2)
except Exception:
pass
# ==========================================================================
# 🔧 辅助
# ==========================================================================
def _get_file_info(self, tid):
return self.cache["file_index"].get(tid)
def _count_str(self):
py_count = 0
js_count = 0
for info in self.cache["file_index"].values():
if info["ext"] == "py":
py_count += 1
elif info["ext"] == "js":
js_count += 1
return f"共扫描到 {py_count} 个PY文件, {js_count} 个JS文件"
def _count_jar_str(self):
"""统计 jar 文件数量"""
if not os.path.isdir(self.JAR_DIR):
return "jar 目录不存在"
count = 0
for f in os.listdir(self.JAR_DIR):
if f.lower().endswith(".jar") and os.path.isfile(os.path.join(self.JAR_DIR, f)):
count += 1
return f"共扫描到 {count} 个JAR文件"
# ==========================================================================
# 📺 【TVBox 标准接口】
# ==========================================================================
def homeContent(self, filter):
return {"class": self.cache["categories"]}
def homeVod(self):
info = self._count_str() + " | " + self._count_jar_str()
return {"list": [{
"vod_id": "__debug__",
"vod_name": info,
"vod_pic": "",
"vod_remarks": "统计",
}]}
def categoryContent(self, tid, pg, filter, ext):
if str(pg) != "1":
return {"list": []}
file_info = self._get_file_info(tid)
if not file_info:
return {"list": []}
f_path = file_info["path"]
if not os.path.exists(f_path):
return {"list": []}
f_base = os.path.basename(f_path)
if "." in f_base:
f_base = f_base.rsplit(".", 1)[0]
ext_name = file_info["ext"]
tag = "PY" if ext_name == "py" else "JS"
v_id = base64.b64encode(
(tag + "|" + f_path).encode("utf-8")
).decode("utf-8")
return {"list": [{
"vod_id": v_id,
"vod_name": f_base,
"vod_pic": "",
"vod_remarks": "[" + ext_name.upper() + "]",
}]}
def detailContent(self, array):
try:
v_id_raw = str(array[0])
if v_id_raw == "__debug__":
info = self._count_str() + "\n" + self._count_jar_str()
detail = "PY目录: " + self.PY_DIR + "\n"
detail += "JS目录: " + self.JS_DIR + "\n"
detail += "JAR目录: " + self.JAR_DIR + "\n"
detail += "配置文件: " + self.SAVE_PATH + "\n\n"
detail += info + "\n\n"
detail += "spider 值:\n " + self._build_spider_value() + "\n\n"
detail += "已扫描文件列表:\n"
for tid, finfo in self.cache["file_index"].items():
detail += " [" + finfo["ext"].upper() + "] " + finfo["path"] + "\n"
return {"list": [{
"vod_name": "扫描调试信息",
"vod_pic": "",
"vod_play_from": "信息",
"vod_play_url": "",
"vod_content": detail,
}]}
v_id_padded = v_id_raw + "=" * ((4 - len(v_id_raw) % 4) % 4)
raw = base64.b64decode(v_id_padded).decode("utf-8", errors="ignore")
if "|" in raw:
tag, f_path = raw.split("|", 1)
else:
tag, f_path = "PY", raw
if not os.path.exists(f_path):
return {"list": [{"vod_name": "文件不存在", "vod_content": "路径: " + f_path}]}
f_base = os.path.basename(f_path)
if "." in f_base:
f_base = f_base.rsplit(".", 1)[0]
ext_name = f_path.rsplit(".", 1)[-1] if "." in f_path else "unknown"
file_info = self.cache["file_index"].get(v_id_raw)
if file_info:
api = self._build_api(file_info)
else:
api = f_path
site_info = {
"key": f_base + "_" + ext_name,
"name": f_base,
"type": 3,
"searchable": 1,
"quickSearch": 1,
"filterable": 1,
"api": api,
}
info_text = json.dumps(site_info, ensure_ascii=False, indent=2)
self._save_config_json()
return {"list": [{
"vod_name": "[" + ext_name.upper() + "] " + f_base,
"vod_pic": "",
"vod_play_from": "配置信息",
"vod_play_url": "查看配置$" + f_path,
"vod_content": (
"配置已自动保存到: " + self.SAVE_PATH + "\n\n"
"站点类型: " + ext_name.upper() + "\n\n"
"站点配置:\n" + info_text + "\n\n"
"文件路径: " + f_path
),
}]}
except Exception as e:
return {"list": [{"vod_name": "解析错误", "vod_content": str(e)}]}
def searchContent(self, key, quick):
res = []
for tid, file_info in self.cache["file_index"].items():
f_path = file_info["path"]
ext_name = file_info["ext"]
f_base = os.path.basename(f_path)
if "." in f_base:
f_base = f_base.rsplit(".", 1)[0]
if key.lower() in f_base.lower():
tag = "PY" if ext_name == "py" else "JS"
v_id = base64.b64encode(
(tag + "|" + f_path).encode("utf-8")
).decode("utf-8")
res.append({
"vod_id": v_id,
"vod_name": "[" + ext_name.upper() + "] " + f_base,
"vod_pic": "",
"vod_remarks": self._build_api(file_info),
})
return {"list": res}
def playerContent(self, flag, id, vipFlags):
url = id.split("$")[-1] if "$" in id else id
return {"url": url, "header": {}, "parse": 0}
def destroy(self):
return "destroy"
+1 -1
View File
@@ -10,7 +10,7 @@
{"name":"🥘18+👿","url":"https://gitee.com/sji820/des/raw/master/0.jpg&&&http://api.btstu.cn/sjbz/"},
{"name":"🥘jackTV👿","url":"https://php.946985.filegear-sg.me/jackTV.m3u&&&http://api.btstu.cn/sjbz/"},
{"name":"🥘易发👿","url":"https://down.nigx.cn/raw.githubusercontent.com/fafa002/yf2025/refs/heads/main/yiyifafa.txt&&&http://api.btstu.cn/sjbz/"},
{"name":"🥘Jsnzkpg👿","url":"https://gh-proxy.org/https://raw.githubusercontent.com/Jsnzkpg/Jsnzkpg/Jsnzkpg/Jsnzkpg1.m3u&&&http://api.btstu.cn/sjbz/"},
{"name":"🥘Jsnzkpg👿","url":"https://tv.tmxk.kdns.fr/ex64181y&&&http://api.btstu.cn/sjbz/"},
{"name":"🥘韩国18👿","url":"https://gh-proxy.com/https://raw.githubusercontent.com/wliqi495-create/jaychouqq/refs/heads/main/hgll1.m3u&&&http://api.btstu.cn/sjbz/"},
{"name":"🥘韩国女团","url":"https://gist.githubusercontent.com/yuanwangokk-1/a661dd0e88466aa22f2a7c1fb375635e/raw/8708d9947be1c64c52bb1610a30aaa1b595848c9/韩国女团直播.m3u&&&http://api.btstu.cn/sjbz/"}