Sync all projects
This commit is contained in:
@@ -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: "请求错误" };
|
||||
}
|
||||
}
|
||||
@@ -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: "请求错误" };
|
||||
}
|
||||
}
|
||||
@@ -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(/&/g, "&")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/ /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 [];
|
||||
}
|
||||
}
|
||||
@@ -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','']
|
||||
@@ -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','']
|
||||
@@ -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(" ", " ").replace("&", "&").replace(""", '"').replace("'", "'")
|
||||
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;写回想看还需 cookie,ck 可从 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
@@ -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(" - ")[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
|
||||
|
||||
@@ -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")
|
||||
@@ -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 "未知错误"
|
||||
@@ -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 ''
|
||||
@@ -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)}
|
||||
@@ -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)}")
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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/"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user