512 lines
18 KiB
JavaScript
512 lines
18 KiB
JavaScript
// @name HDmoli
|
||
// @version 1.0.6
|
||
// @downloadURL https://gh-proxy.org/https://github.com/Silent1566/OmniBox-Spider/raw/refs/heads/main/影视/采集/HDmoli.js
|
||
// @dependencies cheerio
|
||
|
||
const OmniBox = require("omnibox_sdk");
|
||
const runner = require("spider_runner");
|
||
const cheerio = require("cheerio");
|
||
|
||
const BASE_URL = (process.env.HDMOLI_HOST || "https://www.hdmoli.org").replace(/\/$/, "");
|
||
const USER_AGENT = process.env.HDMOLI_UA || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36";
|
||
const REQUEST_TIMEOUT = Number(process.env.HDMOLI_TIMEOUT || 20000);
|
||
|
||
const CLASS_LIST = [
|
||
{ type_id: "1", type_name: "电影", url: "/show/1-----------.html" },
|
||
{ type_id: "2", type_name: "剧集", url: "/show/2-----------.html" },
|
||
{ type_id: "4", type_name: "动画", url: "/show/4-----------.html" },
|
||
{ type_id: "3", type_name: "综艺", url: "/show/3-----------.html" },
|
||
{ type_id: "5", type_name: "纪录片", url: "/show/5-----------.html" },
|
||
{ type_id: "6", type_name: "动作", url: "/show/6-----------.html" },
|
||
{ type_id: "7", type_name: "喜剧", url: "/show/7-----------.html" },
|
||
{ type_id: "8", type_name: "爱情", url: "/show/8-----------.html" },
|
||
{ type_id: "9", type_name: "科幻", url: "/show/9-----------.html" },
|
||
{ type_id: "10", type_name: "剧情", url: "/show/10-----------.html" },
|
||
{ type_id: "11", type_name: "悬疑", url: "/show/11-----------.html" },
|
||
{ type_id: "12", type_name: "惊悚", url: "/show/12-----------.html" },
|
||
];
|
||
|
||
module.exports = { home, category, detail, search, play };
|
||
runner.run(module.exports);
|
||
|
||
async function request(url, extra = {}) {
|
||
const finalUrl = url.startsWith("http") ? url : `${BASE_URL}${url}`;
|
||
const headers = {
|
||
"User-Agent": USER_AGENT,
|
||
Referer: BASE_URL + "/",
|
||
...extra.headers,
|
||
};
|
||
|
||
await OmniBox.log("info", `[request] ${finalUrl}`);
|
||
const res = await OmniBox.request(finalUrl, {
|
||
method: extra.method || "GET",
|
||
headers,
|
||
timeout: REQUEST_TIMEOUT,
|
||
});
|
||
|
||
if (res.statusCode !== 200) {
|
||
throw new Error(`HTTP ${res.statusCode} @ ${finalUrl}`);
|
||
}
|
||
|
||
return res.body || "";
|
||
}
|
||
|
||
function getClassById(categoryId) {
|
||
return CLASS_LIST.find(item => item.type_id === String(categoryId));
|
||
}
|
||
|
||
function buildPageUrl(basePath, page = 1) {
|
||
if (!basePath) return "/";
|
||
if (page <= 1) return basePath;
|
||
return basePath.replace(/-{11}\.html$/, `--------${page}---.html`);
|
||
}
|
||
|
||
function normalizeText(text) {
|
||
return String(text || "").replace(/\s+/g, " ").trim();
|
||
}
|
||
|
||
function absoluteUrl(url) {
|
||
if (!url) return "";
|
||
if (/^https?:\/\//i.test(url)) return url;
|
||
if (url.startsWith("//")) return `https:${url}`;
|
||
return `${BASE_URL}${url.startsWith("/") ? "" : "/"}${url}`;
|
||
}
|
||
|
||
function extractIdFromPath(path) {
|
||
const match = String(path || "").match(/\/([a-z]+)\/index(\d+)\.html/i);
|
||
if (!match) return "";
|
||
return `${match[1]}:${match[2]}`;
|
||
}
|
||
|
||
function splitVideoId(videoId) {
|
||
const [prefix, id] = String(videoId || "").split(":");
|
||
return { prefix: prefix || "movie", id: id || "" };
|
||
}
|
||
|
||
function buildDetailUrlFromPlay(playUrl) {
|
||
const match = String(playUrl || "").match(/\/play\/(\d+)-(\d+)-(\d+)\.html/i);
|
||
if (!match) return BASE_URL + "/";
|
||
return `${BASE_URL}/movie/index${match[1]}.html`;
|
||
}
|
||
|
||
function mapListItem($, el) {
|
||
const box = $(el);
|
||
const linkEl = box.find("a.myui-vodlist__thumb").first();
|
||
const titleEl = box.find("h4.title a").first();
|
||
const href = linkEl.attr("href") || titleEl.attr("href") || "";
|
||
const pic = linkEl.find("img").attr("data-original") || linkEl.find("img").attr("src") || "";
|
||
const remarks = normalizeText(linkEl.find(".pic-text").first().text() || box.find(".pic-text").first().text());
|
||
const score = normalizeText(linkEl.find(".pic-tag-right").first().text());
|
||
const meta = normalizeText(box.find("p.text").first().text());
|
||
const parts = meta.split("/").map(s => normalizeText(s)).filter(Boolean);
|
||
|
||
return {
|
||
vod_id: extractIdFromPath(href),
|
||
vod_name: normalizeText(titleEl.text() || linkEl.attr("title")),
|
||
vod_pic: absoluteUrl(pic),
|
||
vod_remarks: remarks || score,
|
||
vod_year: parts[0] || "",
|
||
vod_area: parts[1] || "",
|
||
type_name: parts.slice(2).join("/") || "",
|
||
vod_douban_score: score.replace(/分$/, ""),
|
||
};
|
||
}
|
||
|
||
function parseListPage(html) {
|
||
const $ = cheerio.load(html, { decodeEntities: false });
|
||
const list = [];
|
||
// 针对该站点的 col- 布局和 myui-vodlist 混合结构进行匹配
|
||
$("ul.myui-vodlist li, ul.myui-vodlist__bd li, #searchList li, li[class*='col-']").each((_, el) => {
|
||
const box = $(el);
|
||
// 必须包含链接和标题,且不是广告或菜单项
|
||
if (box.find("a[href*='/movie/index']").length > 0 || box.find("a[href*='/juji/index']").length > 0) {
|
||
const item = mapListItem($, el);
|
||
if (item.vod_id && item.vod_name) list.push(item);
|
||
}
|
||
});
|
||
// 去重
|
||
const seen = new Set();
|
||
return list.filter(item => {
|
||
if (seen.has(item.vod_id)) return false;
|
||
seen.add(item.vod_id);
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function parsePageCount(html) {
|
||
const nums = new Set();
|
||
const regex = /--------(\d+)---\.html/g;
|
||
let match;
|
||
while ((match = regex.exec(html))) {
|
||
nums.add(Number(match[1]));
|
||
}
|
||
const maxPage = Math.max(1, ...nums);
|
||
return Number.isFinite(maxPage) ? maxPage : 1;
|
||
}
|
||
|
||
function decodeHexString(input) {
|
||
const hex = String(input || "").replace(/[^0-9a-f]/gi, "");
|
||
if (!hex || hex.length % 2 !== 0) return "";
|
||
try {
|
||
return Buffer.from(hex, "hex").toString("utf8");
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
function decodePlayerUrl(rawUrl, encrypt) {
|
||
let value = String(rawUrl || "").trim();
|
||
if (!value) return "";
|
||
|
||
if (encrypt === 1) {
|
||
try { value = unescape(value); } catch {}
|
||
try { value = decodeURIComponent(value); } catch {}
|
||
return value;
|
||
}
|
||
|
||
if (encrypt === 2) {
|
||
try {
|
||
value = Buffer.from(value, "base64").toString("utf8");
|
||
} catch {}
|
||
try { value = unescape(value); } catch {}
|
||
try { value = decodeURIComponent(value); } catch {}
|
||
return value;
|
||
}
|
||
|
||
if (encrypt === 3) {
|
||
const hexDecoded = decodeHexString(value);
|
||
if (hexDecoded) value = hexDecoded;
|
||
try { value = unescape(value); } catch {}
|
||
try { value = decodeURIComponent(value); } catch {}
|
||
return value;
|
||
}
|
||
|
||
return value;
|
||
}
|
||
|
||
function parsePlaySources($) {
|
||
const tabs = [];
|
||
$(".nav-tabs li a[href^='#playlist']").each((index, el) => {
|
||
const tab = $(el);
|
||
tabs.push({
|
||
target: tab.attr("href") || `#playlist${index + 1}`,
|
||
name: normalizeText(tab.text()) || `线路${index + 1}`,
|
||
});
|
||
});
|
||
|
||
if (!tabs.length) {
|
||
$("div[id^='playlist']").each((index, el) => {
|
||
tabs.push({ target: `#${$(el).attr("id")}`, name: `线路${index + 1}` });
|
||
});
|
||
}
|
||
|
||
return tabs.map(tab => {
|
||
const episodes = [];
|
||
$(tab.target).find(".myui-content__list a").each((_, a) => {
|
||
const node = $(a);
|
||
const href = node.attr("href") || "";
|
||
const name = normalizeText(node.text()) || "播放";
|
||
if (!href) return;
|
||
episodes.push({
|
||
name,
|
||
playId: JSON.stringify({
|
||
source: tab.name,
|
||
playUrl: absoluteUrl(href),
|
||
referer: buildDetailUrlFromPlay(absoluteUrl(href)),
|
||
}),
|
||
});
|
||
});
|
||
return { name: tab.name, episodes };
|
||
}).filter(item => item.episodes.length > 0);
|
||
}
|
||
|
||
function parseDownloadLinks($) {
|
||
const links = [];
|
||
$(".myui-panel_bd p.text-muted a[title]").each((_, el) => {
|
||
const node = $(el);
|
||
const href = node.attr("href") || "";
|
||
const title = normalizeText(node.closest("p").find("b").text()).replace(/[::]/g, "") || normalizeText(node.attr("title"));
|
||
if (/pan\.quark\.cn|pan\.baidu\.com|drive\.uc\.cn/i.test(href)) {
|
||
links.push({ name: title || "网盘", url: href });
|
||
}
|
||
});
|
||
return links;
|
||
}
|
||
|
||
function filterOutDriveSources(playSources = []) {
|
||
const driveKeywordRegex = /(夸克|百度|阿里|UC|网盘|迅雷盘|115|123云盘|天翼)/i;
|
||
return playSources.filter(source => {
|
||
const sourceName = String(source?.name || "");
|
||
if (driveKeywordRegex.test(sourceName)) return false;
|
||
const episodes = Array.isArray(source?.episodes) ? source.episodes : [];
|
||
const hasDriveEpisode = episodes.some(ep => {
|
||
const rawPlayId = String(ep?.playId || "");
|
||
return /pan\.quark\.cn|pan\.baidu\.com|drive\.uc\.cn/i.test(rawPlayId);
|
||
});
|
||
return !hasDriveEpisode;
|
||
});
|
||
}
|
||
|
||
async function home(params, context) {
|
||
try {
|
||
await OmniBox.log("info", `[home] from=${context?.from || "web"}`);
|
||
const html = await request("/");
|
||
const list = parseListPage(html).slice(0, 24);
|
||
await OmniBox.log("info", `[home] list=${list.length}`);
|
||
return { class: CLASS_LIST.map(({ type_id, type_name }) => ({ type_id, type_name })), list };
|
||
} catch (error) {
|
||
await OmniBox.log("error", `[home] ${error.message}`);
|
||
return { class: CLASS_LIST.map(({ type_id, type_name }) => ({ type_id, type_name })), list: [] };
|
||
}
|
||
}
|
||
|
||
async function category(params, context) {
|
||
try {
|
||
const categoryId = String(params?.categoryId || "1");
|
||
const page = Number(params?.page || 1) || 1;
|
||
const category = getClassById(categoryId);
|
||
if (!category) return { page, pagecount: 0, total: 0, list: [] };
|
||
|
||
const url = buildPageUrl(category.url, page);
|
||
await OmniBox.log("info", `[category] categoryId=${categoryId} page=${page} url=${url}`);
|
||
const html = await request(url);
|
||
const list = parseListPage(html);
|
||
const pagecount = Math.max(page, parsePageCount(html));
|
||
await OmniBox.log("info", `[category] list=${list.length} pagecount=${pagecount}`);
|
||
return {
|
||
page,
|
||
pagecount,
|
||
total: pagecount * Math.max(list.length, 24),
|
||
list,
|
||
};
|
||
} catch (error) {
|
||
await OmniBox.log("error", `[category] ${error.message}`);
|
||
return { page: Number(params?.page || 1) || 1, pagecount: 0, total: 0, list: [] };
|
||
}
|
||
}
|
||
|
||
async function detail(params, context) {
|
||
try {
|
||
const { prefix, id } = splitVideoId(params?.videoId);
|
||
if (!id) return { list: [] };
|
||
|
||
const detailUrl = `/${prefix}/index${id}.html`;
|
||
await OmniBox.log("info", `[detail] videoId=${params?.videoId} url=${detailUrl}`);
|
||
const html = await request(detailUrl);
|
||
const $ = cheerio.load(html, { decodeEntities: false });
|
||
|
||
const title = normalizeText($(".myui-content__detail h1.title").clone().children().remove().end().text() || $("title").text().replace(/\s*-\s*HDmoli.*$/, ""));
|
||
const remarks = normalizeText($(".myui-content__detail h1.title font").text());
|
||
const pic = absoluteUrl($(".myui-content__thumb img").attr("data-original") || $(".myui-content__thumb img").attr("src"));
|
||
const score = normalizeText($("#rating .branch").text()).replace(/分$/, "");
|
||
const content = normalizeText($(".myui-panel p.text-muted").first().text().replace(/^剧情简介:/, ""));
|
||
|
||
let vodArea = "";
|
||
let vodYear = "";
|
||
let vodActor = "";
|
||
let vodDirector = "";
|
||
let typeName = "";
|
||
|
||
$(".myui-content__detail p.data").each((_, el) => {
|
||
const text = normalizeText($(el).text());
|
||
if (text.startsWith("分类:")) typeName = text.replace(/^分类:/, "").split("地区:")[0].trim();
|
||
if (text.includes("地区:")) {
|
||
const m = text.match(/地区:([^\s]+)/);
|
||
if (m) vodArea = m[1].trim();
|
||
}
|
||
if (text.includes("年份:")) {
|
||
const m = text.match(/年份:([^\s]+)/);
|
||
if (m) vodYear = m[1].trim();
|
||
}
|
||
if (text.startsWith("演员:")) vodActor = text.replace(/^演员:/, "").trim();
|
||
if (text.startsWith("导演:")) vodDirector = text.replace(/^导演:/, "").trim();
|
||
});
|
||
|
||
let vod_play_sources = parsePlaySources($);
|
||
const downloadLinks = parseDownloadLinks($);
|
||
|
||
if (downloadLinks.length) {
|
||
await OmniBox.log("info", `[detail] hide drive links=${downloadLinks.length}`);
|
||
}
|
||
|
||
vod_play_sources = filterOutDriveSources(vod_play_sources);
|
||
|
||
await OmniBox.log("info", `[detail] sources=${vod_play_sources.length}`);
|
||
return {
|
||
list: [{
|
||
vod_id: `${prefix}:${id}`,
|
||
vod_name: title,
|
||
vod_pic: pic,
|
||
vod_content: content,
|
||
vod_actor: vodActor,
|
||
vod_director: vodDirector,
|
||
vod_area: vodArea,
|
||
vod_year: vodYear,
|
||
vod_remarks: remarks,
|
||
vod_douban_score: score,
|
||
type_name: typeName,
|
||
vod_play_sources,
|
||
}],
|
||
};
|
||
} catch (error) {
|
||
await OmniBox.log("error", `[detail] ${error.message}`);
|
||
return { list: [] };
|
||
}
|
||
}
|
||
|
||
async function search(params, context) {
|
||
try {
|
||
const keyword = String(params?.keyword || params?.wd || "").trim();
|
||
const page = Number(params?.page || 1) || 1;
|
||
if (!keyword) return { page: 1, pagecount: 0, total: 0, list: [] };
|
||
|
||
let url = `/search/-------------.html?wd=${encodeURIComponent(keyword)}`;
|
||
if (page > 1) {
|
||
url = `/search/----------${page}---.html?wd=${encodeURIComponent(keyword)}`;
|
||
}
|
||
await OmniBox.log("info", `[search] keyword=${keyword} page=${page} url=${url}`);
|
||
const html = await request(url);
|
||
const list = parseListPage(html);
|
||
const pagecount = Math.max(page, parsePageCount(html));
|
||
await OmniBox.log("info", `[search] list=${list.length} pagecount=${pagecount}`);
|
||
return {
|
||
page,
|
||
pagecount,
|
||
total: pagecount * Math.max(list.length, 20),
|
||
list,
|
||
};
|
||
} catch (error) {
|
||
await OmniBox.log("error", `[search] ${error.message}`);
|
||
return { page: Number(params?.page || 1) || 1, pagecount: 0, total: 0, list: [] };
|
||
}
|
||
}
|
||
|
||
async function play(params, context) {
|
||
try {
|
||
const raw = String(params?.playId || "");
|
||
if (!raw) throw new Error("playId 不能为空");
|
||
|
||
let payload;
|
||
try {
|
||
payload = JSON.parse(raw);
|
||
} catch {
|
||
payload = { directUrl: raw, source: params?.flag || "play" };
|
||
}
|
||
|
||
if (payload.directUrl) {
|
||
const directUrl = String(payload.directUrl || "").trim();
|
||
const sniff = !/\.(m3u8|mp4|flv)(\?|$)/i.test(directUrl);
|
||
await OmniBox.log("info", `[play] direct source=${payload.source} sniff=${sniff} url=${directUrl}`);
|
||
return {
|
||
urls: [{ name: payload.source || "播放", url: directUrl }],
|
||
flag: params?.flag || payload.source || "play",
|
||
header: {},
|
||
parse: sniff ? 1 : 0,
|
||
};
|
||
}
|
||
|
||
if (payload.sniffUrl) {
|
||
const sniffHeaders = {
|
||
Referer: payload.referer || BASE_URL + "/",
|
||
"User-Agent": USER_AGENT,
|
||
};
|
||
await OmniBox.log("info", `[play] sdk sniff source=${payload.source} sniffUrl=${payload.sniffUrl}`);
|
||
try {
|
||
const sniffed = await OmniBox.sniffVideo(payload.sniffUrl, sniffHeaders);
|
||
const sniffedUrl = String(sniffed?.url || "").trim();
|
||
if (sniffedUrl) {
|
||
await OmniBox.log("info", `[play] sdk sniff success url=${sniffedUrl}`);
|
||
return {
|
||
urls: [{ name: payload.source || "播放", url: sniffedUrl }],
|
||
flag: params?.flag || payload.source || "play",
|
||
header: sniffed?.header || {},
|
||
parse: 0,
|
||
};
|
||
}
|
||
} catch (sniffError) {
|
||
await OmniBox.log("warn", `[play] sdk sniff failed: ${sniffError.message}`);
|
||
}
|
||
|
||
return {
|
||
urls: [{ name: payload.source || "播放", url: payload.sniffUrl }],
|
||
flag: params?.flag || payload.source || "play",
|
||
header: sniffHeaders,
|
||
parse: 1,
|
||
};
|
||
}
|
||
|
||
if (!payload.playUrl) throw new Error("缺少 playUrl");
|
||
|
||
await OmniBox.log("info", `[play] source=${payload.source} playUrl=${payload.playUrl}`);
|
||
const html = await request(payload.playUrl, {
|
||
headers: {
|
||
Referer: payload.referer || BASE_URL + "/",
|
||
},
|
||
});
|
||
|
||
const match = html.match(/var\s+player_aaaa\s*=\s*(\{.*?\})\s*<\/script>/s);
|
||
if (!match) throw new Error("未找到 player_aaaa");
|
||
|
||
const player = JSON.parse(match[1]);
|
||
const encrypt = Number(player.encrypt || 0);
|
||
const finalUrl = decodePlayerUrl(player.url || "", encrypt);
|
||
|
||
if (!finalUrl) throw new Error("播放器未给出 url");
|
||
|
||
const isDirectMedia = /\.(m3u8|mp4|flv)(\?|$)/i.test(finalUrl);
|
||
const finalHeaders = {};
|
||
if (player.from && !isDirectMedia) {
|
||
finalHeaders.Referer = payload.playUrl;
|
||
finalHeaders["User-Agent"] = USER_AGENT;
|
||
}
|
||
|
||
await OmniBox.log("info", `[play] from=${player.from} encrypt=${encrypt} direct=${isDirectMedia} sniff=${!isDirectMedia} url=${finalUrl}`);
|
||
|
||
if (!isDirectMedia) {
|
||
try {
|
||
const sniffed = await OmniBox.sniffVideo(payload.playUrl, {
|
||
Referer: payload.referer || BASE_URL + "/",
|
||
"User-Agent": USER_AGENT,
|
||
});
|
||
const sniffedUrl = String(sniffed?.url || "").trim();
|
||
if (sniffedUrl) {
|
||
await OmniBox.log("info", `[play] sdk sniff success from player page url=${sniffedUrl}`);
|
||
return {
|
||
urls: [{ name: payload.source || player.from || "播放", url: sniffedUrl }],
|
||
flag: params?.flag || payload.source || player.from || "play",
|
||
header: sniffed?.header || {},
|
||
parse: 0,
|
||
};
|
||
}
|
||
} catch (sniffError) {
|
||
await OmniBox.log("warn", `[play] sdk sniff from player page failed: ${sniffError.message}`);
|
||
}
|
||
|
||
return {
|
||
urls: [{ name: payload.source || player.from || "播放", url: payload.playUrl }],
|
||
flag: params?.flag || payload.source || player.from || "play",
|
||
header: {
|
||
Referer: payload.referer || BASE_URL + "/",
|
||
"User-Agent": USER_AGENT,
|
||
},
|
||
parse: 1,
|
||
};
|
||
}
|
||
|
||
return {
|
||
urls: [{ name: payload.source || player.from || "播放", url: finalUrl }],
|
||
flag: params?.flag || payload.source || player.from || "play",
|
||
header: finalHeaders,
|
||
parse: 0,
|
||
};
|
||
} catch (error) {
|
||
await OmniBox.log("error", `[play] ${error.message}`);
|
||
return {
|
||
urls: [],
|
||
flag: params?.flag || "play",
|
||
header: {},
|
||
parse: 0,
|
||
};
|
||
}
|
||
}
|