Sync all projects
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,589 @@
|
||||
/**
|
||||
* title: "喵物次元",
|
||||
* logo: "https://www.mwcy.net/favicon.ico",
|
||||
* more: {
|
||||
* sourceTag: "动漫"
|
||||
* }
|
||||
*/
|
||||
import { Crypto, load, _ } from 'assets://js/lib/cat.js';
|
||||
|
||||
const HOST = 'https://www.mwcy.net';
|
||||
const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
||||
|
||||
let siteKey = "", siteType = "", sourceKey = "", ext = "";
|
||||
|
||||
function init(cfg) {
|
||||
siteKey = cfg.skey;
|
||||
siteType = cfg.stype;
|
||||
sourceKey = cfg.sourceKey;
|
||||
ext = cfg.ext;
|
||||
// 如果ext传入则覆盖HOST(保持兼容)
|
||||
if (ext && ext.indexOf('http') == 0) HOST = ext;
|
||||
}
|
||||
|
||||
// ==================== 辅助函数 ====================
|
||||
function fixUrl(url) {
|
||||
if (!url) return '';
|
||||
url = url.trim();
|
||||
if (url.startsWith('//')) return 'https:' + url;
|
||||
if (url.startsWith('/')) return HOST + url;
|
||||
return url;
|
||||
}
|
||||
|
||||
function cleanText(text) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function isVideoFormat(url) {
|
||||
if (!url) return false;
|
||||
return /\.(m3u8|mp4|mkv|flv|avi|mov|wmv|webm)(\?.*)?$/i.test(url);
|
||||
}
|
||||
|
||||
// ==================== 1. 首页内容与筛选配置 ====================
|
||||
function home(filter) {
|
||||
// 固定分类(6个)
|
||||
const classes = [
|
||||
{ type_id: "1", type_name: "番剧" },
|
||||
{ type_id: "22", type_name: "连载新番" },
|
||||
{ type_id: "24", type_name: "国漫" },
|
||||
{ type_id: "2", type_name: "剧场" },
|
||||
{ type_id: "25", type_name: "欧美动漫" },
|
||||
{ type_id: "26", type_name: "4K专区" }
|
||||
];
|
||||
|
||||
// ---- 公共筛选选项 ----
|
||||
// 年份:当前年份往前30年 + 更早
|
||||
const yearList = (() => {
|
||||
const years = [{ n: "全部", v: "" }];
|
||||
const currentYear = new Date().getFullYear();
|
||||
for (let y = currentYear; y >= currentYear - 30; y--) {
|
||||
years.push({ n: String(y), v: String(y) });
|
||||
}
|
||||
years.push({ n: "更早", v: "更早" });
|
||||
return years;
|
||||
})();
|
||||
|
||||
// 字母
|
||||
const letterList = (() => {
|
||||
const letters = [{ n: "全部", v: "" }];
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('');
|
||||
chars.forEach(c => letters.push({ n: c, v: c }));
|
||||
letters.push({ n: "0-9", v: "0-9" });
|
||||
return letters;
|
||||
})();
|
||||
|
||||
// 排序
|
||||
const orderList = [
|
||||
{ n: "最新", v: "time" },
|
||||
{ n: "最热", v: "hits" },
|
||||
{ n: "评分", v: "score" }
|
||||
];
|
||||
|
||||
// 地区(用于剧场、欧美动漫)
|
||||
const areaList = [
|
||||
{ n: "全部", v: "" },
|
||||
{ n: "大陆", v: "大陆" },
|
||||
{ n: "香港", v: "香港" },
|
||||
{ n: "台湾", v: "台湾" },
|
||||
{ n: "美国", v: "美国" },
|
||||
{ n: "法国", v: "法国" },
|
||||
{ n: "英国", v: "英国" },
|
||||
{ n: "日本", v: "日本" },
|
||||
{ n: "韩国", v: "韩国" },
|
||||
{ n: "德国", v: "德国" },
|
||||
{ n: "泰国", v: "泰国" },
|
||||
{ n: "印度", v: "印度" },
|
||||
{ n: "意大利", v: "意大利" },
|
||||
{ n: "西班牙", v: "西班牙" },
|
||||
{ n: "加拿大", v: "加拿大" },
|
||||
{ n: "其他", v: "其他" }
|
||||
];
|
||||
|
||||
// ---- 按分类配置筛选器 ----
|
||||
const filters = {
|
||||
"1": [ // 番剧
|
||||
{ key: "year", name: "年份", value: yearList },
|
||||
{ key: "letter", name: "字母", value: letterList },
|
||||
{ key: "order", name: "排序", value: orderList }
|
||||
],
|
||||
"22": [ // 连载新番
|
||||
{ key: "year", name: "年份", value: yearList },
|
||||
{ key: "letter", name: "字母", value: letterList },
|
||||
{ key: "order", name: "排序", value: orderList }
|
||||
],
|
||||
"24": [ // 国漫
|
||||
{ key: "year", name: "年份", value: yearList },
|
||||
{ key: "letter", name: "字母", value: letterList },
|
||||
{ key: "order", name: "排序", value: orderList }
|
||||
],
|
||||
"2": [ // 剧场
|
||||
{ key: "area", name: "地区", value: areaList },
|
||||
{ key: "year", name: "年份", value: yearList },
|
||||
{ key: "letter", name: "字母", value: letterList },
|
||||
{ key: "order", name: "排序", value: orderList }
|
||||
],
|
||||
"25": [ // 欧美动漫
|
||||
{ key: "area", name: "地区", value: areaList },
|
||||
{ key: "year", name: "年份", value: yearList },
|
||||
{ key: "letter", name: "字母", value: letterList },
|
||||
{ key: "order", name: "排序", value: orderList }
|
||||
],
|
||||
"26": [ // 4K专区
|
||||
{ key: "letter", name: "字母", value: letterList },
|
||||
{ key: "order", name: "排序", value: orderList }
|
||||
]
|
||||
};
|
||||
|
||||
return JSON.stringify({ class: classes, filters: filters });
|
||||
}
|
||||
|
||||
// ==================== 2. 首页推荐视频 ====================
|
||||
async function homeVod() {
|
||||
try {
|
||||
const res = await req(HOST, { headers: { 'User-Agent': UA } });
|
||||
const $ = load(res.content);
|
||||
|
||||
// 定位“十月新番”区域
|
||||
let section = null;
|
||||
$('.box-width.wow.fadeInUp .title .title-h').each((i, el) => {
|
||||
if ($(el).text().trim() === '十月新番') {
|
||||
section = $(el).closest('.box-width').find('.public-r');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (!section) {
|
||||
section = $('.public-list-box.public-pic-b').parent();
|
||||
}
|
||||
|
||||
const items = section ? section.find('.public-list-box.public-pic-b') : $('.public-list-box.public-pic-b');
|
||||
const videos = [];
|
||||
const seen = new Set();
|
||||
|
||||
items.each((i, el) => {
|
||||
const $el = $(el);
|
||||
const $link = $el.find('a.public-list-exp');
|
||||
const href = $link.attr('href');
|
||||
if (!href || !href.startsWith('/bangumi/')) return;
|
||||
const title = $el.find('.time-title').text().trim() || $link.attr('title') || '';
|
||||
const pic = $link.find('img').attr('data-src') || $link.find('img').attr('src') || '';
|
||||
const remarks = $el.find('.public-list-prb').text().trim() || '';
|
||||
if (title && href) {
|
||||
const vod_id = href.startsWith('http') ? href : HOST + href;
|
||||
if (!seen.has(vod_id)) {
|
||||
seen.add(vod_id);
|
||||
videos.push({ vod_id, vod_name: title, vod_pic: pic, vod_remarks: remarks });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({ list: videos });
|
||||
} catch (e) {
|
||||
console.log('homeVod error:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 3. 分类内容爬取 ====================
|
||||
async function category(tid, pg, filter, extend) {
|
||||
if (pg <= 0) pg = 1;
|
||||
extend = extend || {};
|
||||
const area = extend.area || '';
|
||||
const year = extend.year || '';
|
||||
const letter = extend.letter || '';
|
||||
const order = extend.order || '';
|
||||
|
||||
// 构建URL
|
||||
let url = `${HOST}/show/${tid}`;
|
||||
const parts = [];
|
||||
if (area) parts.push(`area/${encodeURIComponent(area)}`);
|
||||
if (order) parts.push(`by/${encodeURIComponent(order)}`);
|
||||
if (letter) parts.push(`letter/${encodeURIComponent(letter)}`);
|
||||
if (year) parts.push(`year/${encodeURIComponent(year)}`);
|
||||
if (pg > 1) parts.push(`page/${pg}`);
|
||||
|
||||
if (parts.length > 0) {
|
||||
url += '/' + parts.join('/') + '.html';
|
||||
} else {
|
||||
url += (pg === 1 ? '.html' : `/page/${pg}.html`);
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
const res = await req(url, { headers: { 'User-Agent': UA } });
|
||||
const $ = load(res.content);
|
||||
|
||||
// 解析视频列表(多级兜底)
|
||||
let items = $('.public-list-box.public-pic-b');
|
||||
if (!items.length) items = $('.public-list-div').parent();
|
||||
|
||||
const videos = [];
|
||||
const seen = new Set();
|
||||
|
||||
items.each((i, el) => {
|
||||
const $el = $(el);
|
||||
const $link = $el.find('a.public-list-exp');
|
||||
const href = $link.attr('href');
|
||||
if (!href) return;
|
||||
|
||||
let vod_id = href;
|
||||
if (!href.startsWith('http')) vod_id = HOST + href;
|
||||
// 如果是 /play/ 链接,转换为 /bangumi/
|
||||
if (href.startsWith('/play/')) {
|
||||
const match = href.match(/^\/play\/([^-]+)/);
|
||||
if (match) {
|
||||
vod_id = HOST + `/bangumi/${match[1]}.html`;
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
} else if (!href.startsWith('/bangumi/')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const title = $el.find('.time-title').text().trim() || $link.attr('title') || '';
|
||||
const pic = $link.find('img').attr('data-src') || $link.find('img').attr('src') || '';
|
||||
const remarks = $el.find('.public-list-prb').text().trim() || '';
|
||||
if (title && vod_id && !seen.has(vod_id)) {
|
||||
seen.add(vod_id);
|
||||
videos.push({
|
||||
vod_id,
|
||||
vod_name: title,
|
||||
vod_pic: fixUrl(pic),
|
||||
vod_remarks: remarks
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 提取总页数
|
||||
let pagecount = 1;
|
||||
const pageTip = $('.page-tip').text().trim();
|
||||
if (pageTip) {
|
||||
const match = pageTip.match(/当前\d+\/(\d+)页/);
|
||||
if (match) pagecount = parseInt(match[2]) || 1;
|
||||
}
|
||||
if (pagecount === 1) {
|
||||
const lastPage = $('.page-link').last().attr('href');
|
||||
if (lastPage) {
|
||||
const m = lastPage.match(/page\/(\d+)\.html/);
|
||||
if (m) pagecount = parseInt(m[1]) || 1;
|
||||
}
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
list: videos,
|
||||
page: pg,
|
||||
pagecount: pagecount,
|
||||
limit: 20,
|
||||
total: videos.length
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('category error:', e);
|
||||
return JSON.stringify({ list: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 4. 搜索功能 ====================
|
||||
async function search(wd) {
|
||||
try {
|
||||
const encoded = encodeURIComponent(wd);
|
||||
const url = `${HOST}/search/wd/${encoded}.html`;
|
||||
|
||||
const res = await req(url, { headers: { 'User-Agent': UA } });
|
||||
const $ = load(res.content);
|
||||
|
||||
// 搜索页结果使用 .vod-detail.search-list
|
||||
let items = $('.vod-detail.search-list');
|
||||
if (!items.length) items = $('.vod-detail');
|
||||
|
||||
const videos = [];
|
||||
const seen = new Set();
|
||||
|
||||
items.each((i, el) => {
|
||||
const $el = $(el);
|
||||
// 标题和链接
|
||||
let title = '';
|
||||
let vod_id = '';
|
||||
const titleEl = $el.find('h3.slide-info-title');
|
||||
if (titleEl.length) title = titleEl.text().trim();
|
||||
|
||||
const linkEl = $el.find('a[target="_blank"]');
|
||||
if (linkEl.length) {
|
||||
const href = linkEl.attr('href');
|
||||
if (href) {
|
||||
if (href.startsWith('/bangumi/')) {
|
||||
vod_id = HOST + href;
|
||||
} else if (href.startsWith('/play/')) {
|
||||
const match = href.match(/^\/play\/([^-]+)/);
|
||||
if (match) vod_id = HOST + `/bangumi/${match[1]}.html`;
|
||||
}
|
||||
}
|
||||
if (!title) title = linkEl.text().trim();
|
||||
}
|
||||
if (!title) {
|
||||
// 从其他位置找
|
||||
const altTitle = $el.find('.slide-info-title').text().trim();
|
||||
if (altTitle) title = altTitle;
|
||||
}
|
||||
|
||||
const pic = $el.find('.detail-pic img').attr('data-src') || $el.find('.detail-pic img').attr('src') || '';
|
||||
const remarks = $el.find('.slide-info-remarks').first().text().trim() || '';
|
||||
|
||||
if (title && vod_id && !seen.has(vod_id)) {
|
||||
seen.add(vod_id);
|
||||
videos.push({
|
||||
vod_id,
|
||||
vod_name: title,
|
||||
vod_pic: fixUrl(pic),
|
||||
vod_remarks: remarks
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 总页数
|
||||
let pagecount = 1;
|
||||
const pageTip = $('.page-tip').text().trim();
|
||||
if (pageTip) {
|
||||
const match = pageTip.match(/当前\d+\/(\d+)页/);
|
||||
if (match) pagecount = parseInt(match[2]) || 1;
|
||||
}
|
||||
return JSON.stringify({
|
||||
list: videos,
|
||||
page: 1,
|
||||
pagecount: pagecount,
|
||||
limit: 20,
|
||||
total: videos.length
|
||||
});
|
||||
} catch (e) {
|
||||
console.log('search error:', e);
|
||||
return JSON.stringify({ list: [] });
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 5. 详情页解析 ====================
|
||||
async function detail(id) {
|
||||
try {
|
||||
const url = id.startsWith('http') ? id : HOST + id;
|
||||
const res = await req(url, { headers: { 'User-Agent': UA } });
|
||||
const $ = load(res.content);
|
||||
|
||||
// 标题
|
||||
let vod_name = $('h3.slide-info-title').text().trim();
|
||||
if (!vod_name) vod_name = $('.player-title-link').text().trim();
|
||||
if (!vod_name) vod_name = $('title').text().replace(/^.*? - /, '').replace(/ - .*$/, '');
|
||||
|
||||
// 封面
|
||||
let vod_pic = $('.detail-pic img').attr('data-src') || $('.detail-pic img').attr('src') || '';
|
||||
if (!vod_pic) vod_pic = $('.vod-detail .detail-pic img').attr('data-src') || '';
|
||||
|
||||
// 简介
|
||||
let vod_content = $('#height_limit').text().trim() || $('.vod-news .text').first().text().trim() || '';
|
||||
|
||||
// 元数据:年份、地区、类型
|
||||
let vod_year = '', vod_area = '';
|
||||
$('.slide-info .slide-info-remarks a').each((i, el) => {
|
||||
const text = $(el).text().trim();
|
||||
if (/^\d{4}$/.test(text)) vod_year = text;
|
||||
else if (['日本','大陆','香港','台湾','美国','英国','韩国','法国','德国','泰国','印度','意大利','西班牙','加拿大','其他'].includes(text)) {
|
||||
vod_area = text;
|
||||
}
|
||||
});
|
||||
// 类型
|
||||
// ---- 提取演员和导演 ----
|
||||
let vod_actor = '', vod_director = '', type_name = '';
|
||||
|
||||
// 方式1:从 .slide-info.partition 中提取
|
||||
$('.slide-info.partition').each((i, el) => {
|
||||
const $el = $(el);
|
||||
|
||||
// 类型
|
||||
const typeStrong = $el.find('strong:contains("类型")');
|
||||
if (typeStrong.length) {
|
||||
const typeLinks = typeStrong.nextAll('a').map((j, a) => $(a).text().trim()).get();
|
||||
if (typeLinks.length) type_name = typeLinks.join(',');
|
||||
}
|
||||
// 导演
|
||||
const dirStrong = $el.find('strong:contains("导演")');
|
||||
if (dirStrong.length) {
|
||||
const dirLinks = dirStrong.nextAll('a').map((j, a) => $(a).text().trim()).get();
|
||||
if (dirLinks.length) vod_director = dirLinks.join(',');
|
||||
}
|
||||
// 演员
|
||||
const actorStrong = $el.find('strong:contains("演员")');
|
||||
if (actorStrong.length) {
|
||||
const actorLinks = actorStrong.nextAll('a').map((j, a) => $(a).text().trim()).get();
|
||||
if (actorLinks.length) vod_actor = actorLinks.join(',');
|
||||
}
|
||||
});
|
||||
|
||||
// ---- 播放源与剧集 ----
|
||||
const playFrom = [];
|
||||
const playUrls = [];
|
||||
|
||||
// 获取线路名称
|
||||
const sourceNames = [];
|
||||
$('.anthology-tab a').each((i, el) => {
|
||||
let name = $(el).text().trim();
|
||||
name = name.replace(/<i[^>]*>.*?<\/i>/, '').replace(/ /g, '').replace(/<span[^>]*>.*?<\/span>/, '').trim();
|
||||
if (name) sourceNames.push(name);
|
||||
});
|
||||
if (!sourceNames.length) {
|
||||
$('.vod-playerUrl').each((i, el) => {
|
||||
let name = $(el).text().trim();
|
||||
name = name.replace(/<i[^>]*>.*?<\/i>/, '').replace(/<span[^>]*>.*?<\/span>/, '').trim();
|
||||
if (name) sourceNames.push(name);
|
||||
});
|
||||
}
|
||||
|
||||
const boxes = $('.anthology-list-box');
|
||||
if (boxes.length && sourceNames.length) {
|
||||
boxes.each((idx, box) => {
|
||||
const name = sourceNames[idx] || ('线路' + (idx+1));
|
||||
const episodes = [];
|
||||
$(box).find('ul.anthology-list-play li a').each((j, ep) => {
|
||||
const $ep = $(ep);
|
||||
let epName = $ep.find('span').text().trim() || $ep.text().trim();
|
||||
let href = $ep.attr('href');
|
||||
if (epName && href) {
|
||||
href = fixUrl(href);
|
||||
episodes.push(epName + '$' + href);
|
||||
}
|
||||
});
|
||||
if (episodes.length) {
|
||||
playFrom.push(name);
|
||||
playUrls.push(episodes.join('#'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (!playFrom.length) {
|
||||
const singleBox = $('.anthology-list-play');
|
||||
if (singleBox.length) {
|
||||
const episodes = [];
|
||||
singleBox.find('li a').each((j, ep) => {
|
||||
const $ep = $(ep);
|
||||
let epName = $ep.find('span').text().trim() || $ep.text().trim();
|
||||
let href = $ep.attr('href');
|
||||
if (epName && href) {
|
||||
href = fixUrl(href);
|
||||
episodes.push(epName + '$' + href);
|
||||
}
|
||||
});
|
||||
if (episodes.length) {
|
||||
playFrom.push('默认线路');
|
||||
playUrls.push(episodes.join('#'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const vod = {
|
||||
vod_id: id,
|
||||
vod_name,
|
||||
vod_pic: fixUrl(vod_pic),
|
||||
type_name,
|
||||
vod_actor: vod_actor,
|
||||
vod_director: vod_director,
|
||||
vod_year,
|
||||
vod_area,
|
||||
vod_remarks: '',
|
||||
vod_content,
|
||||
vod_play_from: playFrom.join('$$$'),
|
||||
vod_play_url: playUrls.join('$$$')
|
||||
};
|
||||
|
||||
return JSON.stringify({ list: [vod] });
|
||||
} catch (e) {
|
||||
console.log('detail error:', e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 6. 播放链接解析 ====================
|
||||
async function play(flag, id, flags) {
|
||||
try {
|
||||
const playUrl = id.startsWith('http') ? id : HOST + id;
|
||||
const res = await req(playUrl, { headers: { 'User-Agent': UA } });
|
||||
const html = res.content;
|
||||
|
||||
const match = html.match(/player_.*?=([^]*?)</);
|
||||
if (!match) return JSON.stringify({ parse: 1, url: playUrl });
|
||||
|
||||
const config = JSON.parse(match[1]);
|
||||
let videoUrl = (config.url || '').trim();
|
||||
if (!videoUrl) return JSON.stringify({ parse: 1, url: playUrl });
|
||||
|
||||
const directVideoPattern = /\.(m3u8|mp4|mkv|flv|avi|mov|wmv|webm)(\?.*)?$/i;
|
||||
if (directVideoPattern.test(videoUrl)) {
|
||||
console.log('videoUrl:', videoUrl);
|
||||
return JSON.stringify({ parse: 0, url: videoUrl });
|
||||
}
|
||||
|
||||
const tryParse = async (apiPath) => {
|
||||
try {
|
||||
const apiUrl = `https://player.catw.moe${apiPath}${encodeURIComponent(videoUrl)}&_t=${Date.now()}`;
|
||||
const res = await req(apiUrl, { headers: { 'User-Agent': UA } });
|
||||
const html = res.content;
|
||||
// 提取 uid
|
||||
const uidMatch = html.match(/"uid"\s*:\s*"([^"]+)"/);
|
||||
const uid = uidMatch ? uidMatch[1] : null;
|
||||
console.log('uid:', uid);
|
||||
// 提取 url (ConFig 根层级那个长字符串)
|
||||
const urlMatch = html.match(/"url"\s*:\s*"([^"]+)"/);
|
||||
const url = urlMatch ? urlMatch[1] : null;
|
||||
console.log('url:', url);
|
||||
if (!uid || !url) {
|
||||
console.log('[喵物次元] ConFig 缺少 uid 或 url');
|
||||
return null;
|
||||
}
|
||||
|
||||
const realUrl = decryptEcUrl(url, uid);
|
||||
if (realUrl) {
|
||||
return { url: realUrl, ua: UA };
|
||||
}
|
||||
return null;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
let parsed = await tryParse('/player/ec.php?code=qw&if=1&url=');
|
||||
if (!parsed) parsed = await tryParse('/art.php?url=');
|
||||
|
||||
if (parsed) {
|
||||
return JSON.stringify({
|
||||
parse: 0,
|
||||
url: parsed.url,
|
||||
header: { 'User-Agent': parsed.ua }
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({ parse: 1, url: playUrl });
|
||||
} catch (e) {
|
||||
return JSON.stringify({ parse: 1, url: id });
|
||||
}
|
||||
}
|
||||
|
||||
function decryptEcUrl(encryptedBase64, uid) {
|
||||
try {
|
||||
const aesKey = '2890' + uid + 'tB959C';
|
||||
const aesIv = '2F131BE91247866E';
|
||||
// aesX(算法, 加密?false=解密, 数据, 输入是Base64?, key, iv, 输出是Base64?)
|
||||
const realUrl = aesX('AES/CBC/PKCS7', false, encryptedBase64, true, aesKey, aesIv, false);
|
||||
console.log(realUrl)
|
||||
return realUrl;
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 导出 ====================
|
||||
export function __jsEvalReturn() {
|
||||
return {
|
||||
init,
|
||||
home,
|
||||
homeVod,
|
||||
category,
|
||||
detail,
|
||||
play,
|
||||
search
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
@header({
|
||||
searchable: 1,
|
||||
filterable: 1,
|
||||
quickSearch: 1,
|
||||
title: '听了么[听]',
|
||||
lang: 'cat'
|
||||
})
|
||||
*/
|
||||
|
||||
function home() {
|
||||
return JSON.stringify({
|
||||
'class': [
|
||||
{'type_id': 'hot', 'type_name': '热门歌单'},
|
||||
{'type_id': 'new', 'type_name': '新歌推荐'}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
async function homeVod() {
|
||||
let url = 'http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmPlayList?loginUid=0&loginSid=0&appUid=76039576&rn=30&order=hot&pn=1';
|
||||
let res = JSON.parse((await req(url)).content);
|
||||
let data = res.data?.data || res.data || [];
|
||||
let d = data.map(it => ({
|
||||
vod_name: it.name || it.title || '未命名歌单',
|
||||
vod_id: (it.id || it.pid || '').toString(),
|
||||
vod_pic: it.img || it.pic || it.cover || 'https://p1.music.126.net/SUeqMM8HOIpHv9Nhl9qt9w==/109951165647004069.jpg',
|
||||
vod_remarks: it.info || it.uname || it.userName || '',
|
||||
type_name: 'hot'
|
||||
}));
|
||||
return JSON.stringify({list: d});
|
||||
}
|
||||
|
||||
async function category(tid, pg) {
|
||||
let api = `http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmPlayList?loginUid=0&loginSid=0&appUid=76039576&rn=30&order=${tid}&pn=${pg}&_=${Date.now()}`;
|
||||
let res = JSON.parse((await req(api)).content);
|
||||
let data = res.data?.data || res.data || [];
|
||||
let arr = data.map(it => ({
|
||||
vod_name: it.name || it.title || '未命名歌单',
|
||||
vod_id: (it.id || it.pid || '').toString(),
|
||||
vod_pic: it.img || it.pic || it.cover || 'https://p1.music.126.net/SUeqMM8HOIpHv9Nhl9qt9w==/109951165647004069.jpg',
|
||||
vod_remarks: it.info || it.uname || it.userName || '',
|
||||
type_name: tid
|
||||
}));
|
||||
return JSON.stringify({list: arr, page: +pg, pagecount: 999, limit: 30, total: 999});
|
||||
}
|
||||
|
||||
async function detail(vod_url) {
|
||||
let api = `http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid=${vod_url.trim()}&pn=0&rn=200&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.1.1.2_BCS2&newver=1`;
|
||||
console.log(`✅[api]: ${api}`);
|
||||
let d = JSON.parse((await req(api)).content);
|
||||
console.log(`✅[d的结果: ]${JSON.stringify(d, null, 4)}`);
|
||||
let list = d.musiclist || [];
|
||||
|
||||
let playArr = [];
|
||||
let artistPicArr = [];
|
||||
|
||||
list.forEach(it => {
|
||||
let rid = (it.id || '').toString();
|
||||
let song = (it.name || it.SONGNAME || it.displaysongname || '').toString();
|
||||
let artist = (it.artist || it.ARTIST || it.FARTIST || it.displayartistname || '').toString();
|
||||
let albumpic = it.albumpic || '';
|
||||
let artistPic = it.artistPic || '';
|
||||
let displayName = artist ? `${song} [${artist}]` : song;
|
||||
|
||||
if (rid) {
|
||||
playArr.push(`${displayName}$${rid}&&${albumpic}&&${artistPic}`);
|
||||
artistPicArr.push(artistPic);
|
||||
}
|
||||
});
|
||||
|
||||
return JSON.stringify({
|
||||
list: [{
|
||||
vod_id: vod_url,
|
||||
vod_name: d.name || d.title || '酷我歌单',
|
||||
vod_pic: d.pic || d.img,
|
||||
vod_content: d.info || d.desc || '',
|
||||
vod_play_from: '酷我歌单',
|
||||
vod_play_pic: artistPicArr.join('#'),
|
||||
vod_play_pic_ratio: 1.5,
|
||||
vod_play_url: playArr.join('#')
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
async function play(flag, id) {
|
||||
// 解析格式:显示名称$歌曲ID&&专辑图片&&歌手图片
|
||||
let parts = id.split('&&');
|
||||
|
||||
// 第一部分可能包含显示名称$歌曲ID
|
||||
let firstPart = parts[0] || '';
|
||||
let firstParts = firstPart.split('$');
|
||||
let songId = firstParts.length > 1 ? firstParts[1] : firstParts[0];
|
||||
|
||||
let albumPic = parts[1] || '';
|
||||
let artistPic = parts[2] || '';
|
||||
|
||||
// 优先使用专辑图片,没有则使用歌手图片
|
||||
let picUrl = albumPic || artistPic;
|
||||
|
||||
if (/\.(m3u8|mp4|m4a|mp3|aac)(\?|$)/i.test(songId)) {
|
||||
return JSON.stringify({
|
||||
parse: 0,
|
||||
jx: 0,
|
||||
url: songId,
|
||||
pic: picUrl
|
||||
});
|
||||
}
|
||||
|
||||
async function getUrl(rid, br) {
|
||||
let api = `http://nmobi.kuwo.cn/mobi.s?f=web&user=0&source=kwplayerhd_ar_4.3.0.8_tianbao_T1A_qirui.apk&type=convert_url_with_sign&rid=${rid}&br=${br}`;
|
||||
let j = JSON.parse((await req(api)).content);
|
||||
return j?.data?.url?.trim() || '';
|
||||
}
|
||||
|
||||
let url = await getUrl(songId, '320kmp3') || await getUrl(songId, '128kmp3');
|
||||
let lrc = await getLyric(songId);
|
||||
|
||||
return JSON.stringify({
|
||||
parse: 0,
|
||||
jx: 0,
|
||||
url: url,
|
||||
pic: picUrl,
|
||||
cover: albumPic,
|
||||
lrc: lrc
|
||||
});
|
||||
}
|
||||
|
||||
async function getLyric(rid) {
|
||||
let url = `http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId=${rid}`;
|
||||
let res = (await req(url)).content;
|
||||
let json = JSON.parse(res);
|
||||
let lrclist = json?.data?.lrclist;
|
||||
if (!lrclist) return '';
|
||||
|
||||
return lrclist.map(item => {
|
||||
let time = +item.time;
|
||||
let min = Math.floor(time / 60).toString().padStart(2, '0');
|
||||
let sec = Math.floor(time % 60).toString().padStart(2, '0');
|
||||
let ms = Math.floor((time % 1) * 100).toString().padStart(2, '0');
|
||||
return `[${min}:${sec}.${ms}]${item.lineLyric}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
async function search(wd, quick) {
|
||||
let searchUrl = `https://search.kuwo.cn/r.s?client=kt&all=${encodeURIComponent(wd)}&pn=0&rn=20&vipver=1&ft=music&encoding=utf8&rformat=json&mobi=1`;
|
||||
|
||||
let res = (await req(searchUrl)).content;
|
||||
let json = JSON.parse(res);
|
||||
let d = [];
|
||||
|
||||
if (json.abslist) {
|
||||
json.abslist.forEach(it => {
|
||||
if (it.MUSICRID) {
|
||||
let musicId = it.MUSICRID.replace('MUSIC_', '');
|
||||
let picUrl = it.hts_MVPIC || '';
|
||||
|
||||
d.push({
|
||||
vod_name: (it.NAME || '未知歌曲') + (it.ARTIST ? ' - ' + it.ARTIST : ''),
|
||||
vod_id: musicId,
|
||||
vod_pic: picUrl,
|
||||
vod_remarks: '酷我音乐',
|
||||
type_name: 'search'
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return JSON.stringify({list: d});
|
||||
}
|
||||
|
||||
export function __jsEvalReturn() {
|
||||
return {home, homeVod, category, detail, play, search};
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
/*
|
||||
@header({
|
||||
searchable: 1,
|
||||
filterable: 0,
|
||||
quickSearch: 1,
|
||||
title: '百度短剧',
|
||||
lang: 'cat'
|
||||
})
|
||||
*/
|
||||
import { Crypto as CryptoJS } from 'assets://js/lib/cat.js';
|
||||
|
||||
let key = '百度短剧';
|
||||
let siteName = '';
|
||||
let siteKey = '';
|
||||
let siteType = 0;
|
||||
let shuaCache = [];
|
||||
|
||||
let UA = "Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.91 Mobile Safari/537.36";
|
||||
let clarity_order = {'蓝光': 1, '超清': 2, '标清': 3};
|
||||
|
||||
// ==================== URL配置集中管理 ====================
|
||||
let rule = {
|
||||
host: 'https://mbd.baidu.com',
|
||||
detailHost: 'https://sv.baidu.com',
|
||||
listUrl: '/feedapi/v1/videoserver/playlets/list?service=bdbox',
|
||||
searchUrl: '/feedapi/v1/videoserver/playlets/search?service=bdbox',
|
||||
detailUrl: '/haokan/ui-video/playlet/rec/detail?log=vhk&tn=1020970b&ctn=1008350n&blur=1',
|
||||
playUrl: '/appui/api?cmd=video/relate&log=vhk&tn=1020970b&ctn=1008350n&blur=1',
|
||||
};
|
||||
|
||||
function init(cfg) {
|
||||
siteName = (cfg.skey?.split('_')[1] || cfg.skey) || (cfg.key?.split('_')[1] || cfg.key) || '未知';
|
||||
siteKey = cfg.skey;
|
||||
siteType = cfg.stype;
|
||||
}
|
||||
|
||||
function home(filter) {
|
||||
let he = ["全部", "新剧", "限时免费", "精选", "独播"];
|
||||
let ticailist = [
|
||||
"神医", "连续剧", "都市", "现代言情", "异能", "逆袭", "甜宠", "总裁", "萌宝", "战神", "宫斗宅斗", "神豪",
|
||||
"虐恋", "闪婚", "玄幻", "穿越重生", "年代", "家庭伦理", "古代言情", "武侠武打", "赘婿", "单元剧", "青春校园",
|
||||
"历史架空", "王妃", "鉴宝", "科幻", "军旅战争", "种田"
|
||||
];
|
||||
|
||||
let classes = he.map(name => ({
|
||||
type_id: name,
|
||||
type_name: name
|
||||
}));
|
||||
|
||||
classes = classes.concat(ticailist.map(name => ({
|
||||
type_id: name === "全部" ? "全部题材" : name,
|
||||
type_name: name
|
||||
})));
|
||||
|
||||
return JSON.stringify({
|
||||
class: classes,
|
||||
filters: {}
|
||||
});
|
||||
}
|
||||
|
||||
async function homeVod() {
|
||||
const categoryResult = await category('新剧', 1, {}, {});
|
||||
const categoryList = JSON.parse(categoryResult).list;
|
||||
|
||||
return JSON.stringify({
|
||||
list: [
|
||||
{
|
||||
vod_id: 'shua',
|
||||
vod_name: '发现精彩',
|
||||
vod_pic: 'https://t8.baidu.com/it/u=615012979,225344800&fm=193'
|
||||
},
|
||||
...categoryList
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并请求函数 - 统一处理 data 和 body,支持 form-urlencoded 和 JSON
|
||||
*/
|
||||
async function request(url, options = {}) {
|
||||
try {
|
||||
console.log(`【${siteName}】${options.method || 'GET'} ${url.split('?')[0]}`);
|
||||
|
||||
// 准备基础配置
|
||||
let requestConfig = {
|
||||
method: options.method || 'GET',
|
||||
headers: { "User-Agent": UA, ...options.headers }
|
||||
};
|
||||
|
||||
// 获取内容类型
|
||||
let contentType = requestConfig.headers['Content-Type'] || '';
|
||||
|
||||
// 辅助函数:将对象转换为字符串
|
||||
function stringifyData(data, format) {
|
||||
if (format.includes('json')) {
|
||||
return JSON.stringify(data);
|
||||
} else {
|
||||
// 默认 form-urlencoded
|
||||
const parts = [];
|
||||
for (let key in data) {
|
||||
let value = data[key];
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
value = JSON.stringify(value);
|
||||
}
|
||||
parts.push(encodeURIComponent(key) + '=' + encodeURIComponent(value));
|
||||
}
|
||||
return parts.join('&');
|
||||
}
|
||||
}
|
||||
|
||||
// 处理数据 - 无论 data 还是 body,统一处理
|
||||
let requestData = options.data || options.body;
|
||||
|
||||
if (requestData) {
|
||||
if (typeof requestData === 'string') {
|
||||
// 已经是字符串,直接使用
|
||||
requestConfig.body = requestData;
|
||||
} else if (typeof requestData === 'object') {
|
||||
// 对象,根据内容类型转换
|
||||
if (!contentType) {
|
||||
// 没有指定内容类型,默认 form-urlencoded
|
||||
contentType = 'application/x-www-form-urlencoded';
|
||||
requestConfig.headers['Content-Type'] = contentType;
|
||||
}
|
||||
requestConfig.body = stringifyData(requestData, contentType);
|
||||
}
|
||||
}
|
||||
|
||||
const res = await req(url, requestConfig);
|
||||
return res.content || '';
|
||||
} catch (e) {
|
||||
console.log(`【${siteName}】请求失败: ${e.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
async function category(tid, pg, filter, extend) {
|
||||
pg = pg <= 0 ? 1 : pg;
|
||||
let sub = ["新剧", "限时免费", "精选", "独播"].includes(tid) ? tid : "新剧";
|
||||
let tcsub = tid === "全部" || tid === "全部题材" ? "" : tid;
|
||||
|
||||
let t = Math.floor(Date.now() / 1000);
|
||||
let version = await md5(t + "v2");
|
||||
|
||||
// 直接传对象
|
||||
let postData = {
|
||||
'data': {
|
||||
"data": {
|
||||
"extRequest": { "flow_tabid": "13" },
|
||||
"from": "feed",
|
||||
"page": "channel_video_landing",
|
||||
"pd": "feed",
|
||||
"refreshIndex": pg,
|
||||
"cursor": "",
|
||||
"theme": "",
|
||||
"timestamp": t,
|
||||
"version": version,
|
||||
"themes": [
|
||||
{ "kind": "综合", "names": [sub] },
|
||||
{ "kind": "题材", "names": [tcsub] }
|
||||
]
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let html = await request(`${rule.host}${rule.listUrl}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Connection": "Keep-Alive",
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
data: postData // 可以用 data
|
||||
});
|
||||
|
||||
let res = JSON.parse(html);
|
||||
let items = res.data.items;
|
||||
|
||||
let videos = items.map(it => ({
|
||||
vod_id: it.collId,
|
||||
vod_name: it.title,
|
||||
vod_pic: it.img,
|
||||
vod_remarks: it.updateStatus,
|
||||
vod_content: it.description
|
||||
}));
|
||||
|
||||
return JSON.stringify({
|
||||
page: pg,
|
||||
pagecount: pg + 1,
|
||||
limit: 20,
|
||||
total: items.length * (pg + 1),
|
||||
list: videos
|
||||
});
|
||||
}
|
||||
|
||||
async function detail(id) {
|
||||
if (id === 'shua') {
|
||||
return JSON.stringify({
|
||||
list: [{
|
||||
vod_id: 'shua',
|
||||
vod_name: '发现精彩',
|
||||
vod_pic: 'https://t8.baidu.com/it/u=615012979,225344800&fm=193',
|
||||
vod_play_from: '百度短剧',
|
||||
vod_play_url: '刷刷看$shua',
|
||||
vod_tag: '[SHUA][JUMP][V]'
|
||||
}]
|
||||
});
|
||||
}
|
||||
|
||||
// 也可以用 body
|
||||
let html = await request(`${rule.detailHost}${rule.detailUrl}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: { // body 传对象也会自动处理
|
||||
playlet_id: id,
|
||||
vid: "undefined"
|
||||
}
|
||||
});
|
||||
|
||||
let res = JSON.parse(html);
|
||||
let dthtml = res.data;
|
||||
let vids = dthtml.vid_list;
|
||||
let playArr = vids.map((vid, index) => `第${index + 1}集$${vid}`);
|
||||
|
||||
const vod = {
|
||||
vod_id: id,
|
||||
vod_name: dthtml.playlet_title,
|
||||
vod_pic: dthtml.playlet_poster,
|
||||
vod_content: dthtml.description,
|
||||
vod_remarks: `共${vids.length}集 热度值:${dthtml.hot_value} 集数:${dthtml.episodes_num}`,
|
||||
vod_director: dthtml.tag_text,
|
||||
vod_year: dthtml.create_time,
|
||||
vod_play_from: "百度短剧",
|
||||
vod_play_url: playArr.join('#')
|
||||
};
|
||||
|
||||
return JSON.stringify({ list: [vod] });
|
||||
}
|
||||
|
||||
async function play(flag, id, flags) {
|
||||
if (id == 'shua') {
|
||||
if (shuaCache.length == 0) {
|
||||
const randomPage = getRnd(1, 20);
|
||||
const categories = ["新剧", "限时免费", "精选", "独播"];
|
||||
const randomCate = categories[Math.floor(Math.random() * categories.length)];
|
||||
|
||||
const categoryResult = await category(randomCate, randomPage, {}, {});
|
||||
const res = JSON.parse(categoryResult);
|
||||
const videos = [];
|
||||
|
||||
for (const it of res.list.slice(0, 10)) {
|
||||
const detailResult = await detail(it.vod_id);
|
||||
const detailObj = JSON.parse(detailResult);
|
||||
const vod = detailObj.list[0];
|
||||
|
||||
const match = vod.vod_remarks.match(/(\d+)/);
|
||||
const episodeCount = match[1];
|
||||
|
||||
videos.push({
|
||||
parse: 0,
|
||||
url: it.vod_id,
|
||||
shuaTitle: vod.vod_name,
|
||||
shuaDes: '共' + episodeCount + '集 | ' + vod.vod_content.replace(/\s/g, ''),
|
||||
shuaActions: { play: it.vod_id },
|
||||
errorPlayNext: true
|
||||
});
|
||||
}
|
||||
shuaCache.push(...videos);
|
||||
}
|
||||
|
||||
const cache = shuaCache.shift();
|
||||
const detailResult = await detail(cache.url);
|
||||
const detailObj = JSON.parse(detailResult);
|
||||
const vod = detailObj.list[0];
|
||||
|
||||
const playUrls = vod.vod_play_url.split('#');
|
||||
const firstEpisode = playUrls[0];
|
||||
const vid = firstEpisode.split('$')[1];
|
||||
|
||||
const playHtml = await request(`${rule.detailHost}${rule.playUrl}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
data: { // 用 data 或 body 都可以
|
||||
method: "post",
|
||||
vid: vid
|
||||
}
|
||||
});
|
||||
|
||||
const playRes = JSON.parse(playHtml);
|
||||
const playJson = playRes["video/relate"].data.cur_video;
|
||||
const urls = [];
|
||||
|
||||
for (const item of playJson.clarityUrl) {
|
||||
urls.push({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
order: clarity_order[item.title] || 999
|
||||
});
|
||||
}
|
||||
urls.sort(function (a, b) { return a.order - b.order; });
|
||||
cache.url = urls[0].url;
|
||||
|
||||
return JSON.stringify(cache);
|
||||
}
|
||||
|
||||
const html = await request(`${rule.detailHost}${rule.playUrl}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: { // body 传对象
|
||||
method: "post",
|
||||
vid: id
|
||||
}
|
||||
});
|
||||
|
||||
const res = JSON.parse(html);
|
||||
const json = res["video/relate"].data.cur_video;
|
||||
const urls = [];
|
||||
|
||||
for (const item of json.clarityUrl) {
|
||||
urls.push({
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
order: clarity_order[item.title] || 999
|
||||
});
|
||||
}
|
||||
urls.sort(function (a, b) { return a.order - b.order; });
|
||||
|
||||
const flat = [];
|
||||
for (const item of urls) {
|
||||
flat.push(item.title);
|
||||
flat.push(item.url);
|
||||
}
|
||||
|
||||
return JSON.stringify({
|
||||
parse: 0,
|
||||
url: flat,
|
||||
header: {
|
||||
'User-Agent': UA,
|
||||
'Referer': rule.host
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function search(wd, quick, pg) {
|
||||
pg = pg <= 0 ? 1 : pg;
|
||||
|
||||
let postData = {
|
||||
'data': {
|
||||
"data": {
|
||||
"query": wd,
|
||||
"page": pg,
|
||||
"attribute": ["title"],
|
||||
"fe_page_type": "search",
|
||||
"extra": {
|
||||
"tab_id": "216",
|
||||
"flow_tabid": "13",
|
||||
"shortplay_source": "feed",
|
||||
"from": "feed",
|
||||
"tab_type": "搜索",
|
||||
"sub_template": "playlet_search_result"
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let html = await request(`${rule.host}${rule.searchUrl}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
"Connection": "Keep-Alive",
|
||||
"Accept-Encoding": "gzip",
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
data: postData // 用 data
|
||||
});
|
||||
|
||||
let res = JSON.parse(html);
|
||||
let items = res.data.itemList;
|
||||
|
||||
let videos = items.map(it => ({
|
||||
vod_id: it.nid.split("_")[1],
|
||||
vod_name: it.title,
|
||||
vod_pic: it.img,
|
||||
vod_remarks: it.collNum + '集',
|
||||
vod_content: it.description
|
||||
}));
|
||||
|
||||
return JSON.stringify({
|
||||
page: pg,
|
||||
pagecount: pg + 1,
|
||||
limit: 20,
|
||||
total: items.length * (pg + 1),
|
||||
list: videos
|
||||
});
|
||||
}
|
||||
|
||||
function getRnd(min, max, hexNum, isUpper) {
|
||||
var r = parseInt(Math.random() * (max - min + 1) + min, 10);
|
||||
if (hexNum) {
|
||||
r = isUpper ? r.toString(hexNum).toUpperCase() : r.toString(hexNum);
|
||||
}
|
||||
return r;
|
||||
}
|
||||
|
||||
async function md5(str) {
|
||||
return CryptoJS.MD5(str).toString(CryptoJS.enc.Hex).toLowerCase();
|
||||
}
|
||||
|
||||
async function action(action, value) {
|
||||
if (action === 'shuaPlay') {
|
||||
return JSON.stringify({
|
||||
action: {
|
||||
actionId: '__detail__',
|
||||
ids: value,
|
||||
keep: true
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function __jsEvalReturn() {
|
||||
return {
|
||||
init: init,
|
||||
home: home,
|
||||
homeVod: homeVod,
|
||||
category: category,
|
||||
detail: detail,
|
||||
play: play,
|
||||
search: search,
|
||||
action: action
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,883 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
2048核基地 爬虫 - 修复版 + 去广告
|
||||
修复:发布页Cookie验证、域名自动获取、多域名备用、art列表/详情、分隔符编码
|
||||
新增:m3u8 广告清洗(无AES),屏蔽图片/小说分类
|
||||
"""
|
||||
import sys
|
||||
import re
|
||||
import json
|
||||
import html
|
||||
import requests
|
||||
import urllib3
|
||||
import time
|
||||
import random
|
||||
from urllib.parse import quote, urljoin, unquote, urlparse
|
||||
|
||||
urllib3.disable_warnings()
|
||||
sys.path.append('..')
|
||||
from base.spider import Spider as BaseSpider
|
||||
|
||||
|
||||
class Spider(BaseSpider):
|
||||
# ========== 多域名配置 ==========
|
||||
# hosts[0] 是主域名,失效时自动从发布页获取更新
|
||||
hosts = ['https://s7t8u9v0.luanlunba15.cc']
|
||||
host = hosts[0]
|
||||
|
||||
# 发布页配置(用于自动获取最新域名)
|
||||
PUBLISH_PAGES = [
|
||||
'https://www.luanlunba.cc',
|
||||
'https://s7t8u9v0.luanlunba13.cc',
|
||||
'https://s7t8u9v0.luanlunba14.cc',
|
||||
]
|
||||
|
||||
session = requests.Session()
|
||||
_debug = True
|
||||
_categories = []
|
||||
|
||||
def _log(self, msg):
|
||||
if self._debug:
|
||||
print(f'[luanlunba] {msg}')
|
||||
|
||||
def getName(self):
|
||||
return '2048核基地'
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
return url and ('.m3u8' in url or '.mp4' in url or '.ts' in url)
|
||||
|
||||
def manualVideoCheck(self):
|
||||
return False
|
||||
|
||||
def destroy(self):
|
||||
if hasattr(self, 'session'):
|
||||
try:
|
||||
self.session.close()
|
||||
except:
|
||||
pass
|
||||
self.session = None
|
||||
|
||||
# ---------- 本地代理:支持图片代理和 m3u8 清洗 ----------
|
||||
def localProxy(self, param):
|
||||
EMPTY_GIF = b'\x47\x49\x46\x38\x39\x61\x01\x00\x01\x00\x80\x00\x00\xff\xff\xff\x00\x00\x00!\xf9\x04\x01\x00\x00\x00\x00,\x00\x00\x00\x00\x01\x00\x01\x00\x00\x02\x02D\x01\x00;'
|
||||
# 如果请求包含 do=m3u8 则进行 m3u8 广告清洗
|
||||
if 'do=m3u8' in param:
|
||||
try:
|
||||
# 解析参数
|
||||
params = dict(p.split('=', 1) for p in param.split('&') if '=' in p)
|
||||
url = unquote(params.get('url', ''))
|
||||
referer = unquote(params.get('referer', self.host))
|
||||
if not url:
|
||||
return [404, "text/plain", "missing url"]
|
||||
# 下载原始 m3u8
|
||||
raw = self._get_m3u8_content(url, referer)
|
||||
if not raw:
|
||||
return [404, "text/plain", "m3u8 download failed"]
|
||||
# 清洗广告
|
||||
cleaned = self._clean_m3u8(raw, url, referer)
|
||||
return [200, "application/vnd.apple.mpegurl", cleaned]
|
||||
except Exception as e:
|
||||
self._log(f'm3u8 清洗异常: {e}')
|
||||
return [404, "text/plain", "proxy error"]
|
||||
# 否则走原有的图片代理逻辑
|
||||
if not param or not param.startswith('http'):
|
||||
return [200, 'image/gif', EMPTY_GIF]
|
||||
try:
|
||||
r = self.session.get(param, headers={
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
'Referer': self.host + '/'
|
||||
}, timeout=(10, 15))
|
||||
r.raise_for_status()
|
||||
content_type = r.headers.get('Content-Type', 'application/octet-stream')
|
||||
return [200, content_type, r.content]
|
||||
except:
|
||||
return [200, 'image/gif', EMPTY_GIF]
|
||||
|
||||
def _get_headers(self, referer=None):
|
||||
return {
|
||||
'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,en;q=0.8',
|
||||
'Referer': referer or self.host + '/'
|
||||
}
|
||||
|
||||
def _fetch(self, url, referer=None, retries=3):
|
||||
for attempt in range(retries):
|
||||
try:
|
||||
if attempt > 0:
|
||||
time.sleep(random.uniform(0.5, 1.5))
|
||||
r = self.session.get(url, headers=self._get_headers(referer), timeout=(10, 20), verify=False)
|
||||
# 不再强制 UTF-8;优先使用响应头/meta声明,否则自动探测
|
||||
if not r.encoding or r.encoding.lower() in ('iso-8859-1', 'latin-1'):
|
||||
r.encoding = r.apparent_encoding
|
||||
if r.status_code == 200:
|
||||
return r.text
|
||||
else:
|
||||
self._log(f'请求失败 [{r.status_code}] {url}')
|
||||
return ''
|
||||
except Exception as e:
|
||||
self._log(f'请求异常 {e},重试 {attempt+1}')
|
||||
continue
|
||||
return ''
|
||||
|
||||
# ========== 【核心】域名自动更新(支持Cookie验证+AJAX接口) ==========
|
||||
def _update_host(self):
|
||||
"""从发布页获取最新可用域名,支持多发布页、Cookie验证、AJAX接口"""
|
||||
for pub in self.PUBLISH_PAGES:
|
||||
try:
|
||||
# Step 1: 获取Cookie验证页
|
||||
r1 = self.session.get(pub + '/', headers=self._get_headers(), timeout=10, verify=False)
|
||||
cookie_match = re.search(r'document\.cookie\s*=\s*"([^"]+)"', r1.text)
|
||||
|
||||
if cookie_match:
|
||||
# 解析并设置Cookie
|
||||
cookie_str = cookie_match.group(1)
|
||||
parts = cookie_str.split(';')
|
||||
for part in parts:
|
||||
part = part.strip()
|
||||
if '=' in part and 'path' not in part and 'max-age' not in part:
|
||||
key, val = part.split('=', 1)
|
||||
self.session.cookies.set(key.strip(), val.strip())
|
||||
self._log(f'发布页 {pub} Cookie已设置')
|
||||
|
||||
# Step 2: 请求AJAX接口获取域名列表
|
||||
ajax_url = pub + '/xuexi/data.php'
|
||||
ajax_headers = self._get_headers(pub + '/')
|
||||
ajax_headers['X-Requested-With'] = 'XMLHttpRequest'
|
||||
|
||||
r2 = self.session.get(ajax_url, headers=ajax_headers, timeout=10, verify=False)
|
||||
if not r2.encoding or r2.encoding.lower() in ('iso-8859-1', 'latin-1'):
|
||||
r2.encoding = r2.apparent_encoding
|
||||
|
||||
try:
|
||||
data = r2.json()
|
||||
urls = data.get('urls', [])
|
||||
self._log(f'发布页 {pub} 返回 {len(urls)} 个域名')
|
||||
except:
|
||||
# 如果JSON解析失败,尝试从HTML提取
|
||||
urls = re.findall(r'(https?://[a-z0-9]+\.luanlunba\d*\.\w+)', r2.text)
|
||||
self._log(f'发布页 {pub} JSON失败,从HTML提取到 {len(urls)} 个域名')
|
||||
|
||||
# Step 3: 验证每个域名可用性
|
||||
for url in urls:
|
||||
url = url.strip('/')
|
||||
if not url.startswith('http'):
|
||||
continue
|
||||
try:
|
||||
test = self.session.get(url + '/', headers=self._get_headers(), timeout=8, verify=False)
|
||||
if test.status_code == 200 and len(test.text) > 1000:
|
||||
# 进一步验证:检查是否有分类结构
|
||||
if 'vodtype' in test.text or 'arttype' in test.text or 'voddetail' in test.text:
|
||||
self._log(f'验证可用域名: {url}')
|
||||
self.host = url
|
||||
self.hosts = [url] + [h for h in self.hosts if h != url]
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
|
||||
except Exception as e:
|
||||
self._log(f'发布页 {pub} 获取失败: {e}')
|
||||
continue
|
||||
|
||||
# 所有发布页失败,尝试备用hosts列表
|
||||
for h in self.hosts:
|
||||
try:
|
||||
test = self.session.get(h + '/', headers=self._get_headers(), timeout=8, verify=False)
|
||||
if test.status_code == 200 and len(test.text) > 1000:
|
||||
self.host = h
|
||||
self._log(f'使用备用域名: {h}')
|
||||
return True
|
||||
except:
|
||||
continue
|
||||
|
||||
self._log('所有域名获取方式均失败')
|
||||
return False
|
||||
|
||||
def _parse_categories(self, html):
|
||||
cats = []
|
||||
menu_match = re.search(r'<div[^>]+class="menu\s+clearfix"[^>]*>(.*?)</div>\s*</div>', html, re.S)
|
||||
menu_text = menu_match.group(1) if menu_match else html
|
||||
links = re.findall(r'<a\s+[^>]*href="([^"]*)"[^>]*>(.*?)</a>', menu_text, re.S)
|
||||
for href, text in links:
|
||||
m = re.search(r'/(vodtype|arttype)/(\d+)\.html', href)
|
||||
if not m:
|
||||
continue
|
||||
type_prefix, tid = m.groups()
|
||||
name = self._clean_text(text)
|
||||
if not name or len(name) > 15:
|
||||
continue
|
||||
if name in ('首页', '搜索', '全部', '更多', '排行', '留言', '帮助', '返回首页', '发布页', '传送门'):
|
||||
continue
|
||||
# 【新增】屏蔽图片/小说分类(arttype)
|
||||
if type_prefix == 'arttype':
|
||||
continue
|
||||
cats.append({
|
||||
'type_id': tid,
|
||||
'type_name': name,
|
||||
'type': 'vod' if type_prefix == 'vodtype' else 'art'
|
||||
})
|
||||
return self._dedup(cats)
|
||||
|
||||
def _dedup(self, cats):
|
||||
seen = set()
|
||||
unique = []
|
||||
for c in cats:
|
||||
tid = c['type_id']
|
||||
if tid not in seen:
|
||||
seen.add(tid)
|
||||
unique.append(c)
|
||||
return unique
|
||||
|
||||
def init(self, extend=''):
|
||||
self._log('正在初始化...')
|
||||
if hasattr(self, 'session'):
|
||||
try:
|
||||
self.session.close()
|
||||
except:
|
||||
pass
|
||||
self.session = requests.Session()
|
||||
|
||||
# 尝试更新域名
|
||||
if not self._update_host():
|
||||
self._log('域名更新失败,使用默认域名')
|
||||
|
||||
# 获取分类
|
||||
html = self._fetch(self.host + '/')
|
||||
if html:
|
||||
cats = self._parse_categories(html)
|
||||
if cats:
|
||||
self._categories = cats
|
||||
self._log(f'分类获取成功: {len(cats)} 个')
|
||||
return
|
||||
|
||||
# 备用
|
||||
html = self._fetch(self.host + '/vodtype/1.html')
|
||||
if html:
|
||||
cats = self._parse_categories(html)
|
||||
if cats:
|
||||
self._categories = cats
|
||||
self._log(f'备用页分类获取成功: {len(cats)} 个')
|
||||
return
|
||||
|
||||
# 硬编码兜底(仅保留视频分类)
|
||||
self._categories = [
|
||||
{'type_id': '1', 'type_name': '劲爆推荐', 'type': 'vod'},
|
||||
{'type_id': '2', 'type_name': '精品爆料', 'type': 'vod'},
|
||||
{'type_id': '58', 'type_name': '网曝黑料', 'type': 'vod'},
|
||||
{'type_id': '3', 'type_name': '特色仓库', 'type': 'vod'},
|
||||
{'type_id': '69', 'type_name': '精品资源', 'type': 'vod'},
|
||||
{'type_id': '78', 'type_name': '热播片库', 'type': 'vod'},
|
||||
# 已删除 '5': '激情图区' 和 '38': '情色小说'
|
||||
]
|
||||
self._log('使用硬编码分类(仅视频)')
|
||||
|
||||
# ========== 视频列表解析 ==========
|
||||
def _clean_text(self, text):
|
||||
if not text:
|
||||
return ''
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = html.unescape(text)
|
||||
return text.strip()
|
||||
|
||||
def _parse_video_list(self, html):
|
||||
items = []
|
||||
dl_pattern = r'<dl>\s*<dt[^>]*>.*?<a[^>]*href="/voddetail/(\d+)\.html"[^>]*>.*?<img[^>]*data-original="([^"]*)"[^>]*>.*?</a>.*?</dt>\s*<dd>\s*<a[^>]*href="/voddetail/\d+\.html"[^>]*>(.*?)</a>\s*</dd>\s*</dl>'
|
||||
for m in re.finditer(dl_pattern, html, re.S):
|
||||
vid, img, title_block = m.groups()
|
||||
if not img.startswith('http'):
|
||||
img = urljoin(self.host, img)
|
||||
title = self._clean_text(title_block)
|
||||
items.append({
|
||||
'vod_id': vid,
|
||||
'vod_name': title if title else '未知标题',
|
||||
'vod_pic': img,
|
||||
'vod_remarks': '',
|
||||
})
|
||||
return items
|
||||
|
||||
# ========== 【修复】图片/小说列表解析(保留方法,不会被调用) ==========
|
||||
def _parse_art_list(self, html):
|
||||
"""解析图片/小说(arttype)列表页,兼容多种 HTML 结构"""
|
||||
items = []
|
||||
if not html:
|
||||
return items
|
||||
|
||||
# 模式1: <dl> 传统结构
|
||||
pattern1 = r'<dl>\s*<dt[^>]*>.*?<a[^>]*href="/artdetail/(\d+)\.html"[^>]*>.*?<img[^>]*(?:data-original|src|data-src)="([^"]*)"[^>]*>.*?</a>.*?</dt>\s*<dd>\s*<a[^>]*href="/artdetail/\d+\.html"[^>]*>(.*?)</a>\s*</dd>\s*</dl>'
|
||||
for m in re.finditer(pattern1, html, re.S):
|
||||
vid, img, title_block = m.groups()
|
||||
if not img.startswith('http'):
|
||||
img = urljoin(self.host, img)
|
||||
title = self._clean_text(title_block)
|
||||
items.append({
|
||||
'vod_id': vid,
|
||||
'vod_name': title if title else '未知标题',
|
||||
'vod_pic': img,
|
||||
'vod_remarks': '',
|
||||
})
|
||||
|
||||
# 模式2: <a href="/artdetail/123.html"> 内部有 <img> 和文字标题
|
||||
if not items:
|
||||
pattern2 = r'<a[^>]*href="/artdetail/(\d+)\.html"[^>]*>(.*?)</a>'
|
||||
for m in re.finditer(pattern2, html, re.S):
|
||||
vid, block = m.groups()
|
||||
img_match = re.search(r'<img[^>]*(?:data-original|src|data-src|original)="([^"]+)"', block)
|
||||
img = img_match.group(1) if img_match else ''
|
||||
if img and not img.startswith('http'):
|
||||
img = urljoin(self.host, img)
|
||||
title = ''
|
||||
alt_match = re.search(r'<img[^>]*alt="([^"]*)"', block)
|
||||
if alt_match:
|
||||
title = self._clean_text(alt_match.group(1))
|
||||
if not title:
|
||||
title = self._clean_text(block)
|
||||
items.append({
|
||||
'vod_id': vid,
|
||||
'vod_name': title if title else '未知标题',
|
||||
'vod_pic': img,
|
||||
'vod_remarks': '',
|
||||
})
|
||||
|
||||
# 模式3: 更宽松的 div/li 结构
|
||||
if not items:
|
||||
pattern3 = r'<(?:div|li)[^>]*>\s*<a[^>]*href="/artdetail/(\d+)\.html"[^>]*>.*?<img[^>]*(?:data-original|src|data-src|original)="([^"]*)"[^>]*>.*?</a>\s*<(?:h3|h4|p|div|span)[^>]*>(.*?)</(?:h3|h4|p|div|span)>\s*</(?:div|li)>'
|
||||
for m in re.finditer(pattern3, html, re.S):
|
||||
vid, img, title_block = m.groups()
|
||||
if not img.startswith('http'):
|
||||
img = urljoin(self.host, img)
|
||||
title = self._clean_text(title_block)
|
||||
items.append({
|
||||
'vod_id': vid,
|
||||
'vod_name': title if title else '未知标题',
|
||||
'vod_pic': img,
|
||||
'vod_remarks': '',
|
||||
})
|
||||
|
||||
self._log(f'art列表解析到 {len(items)} 条')
|
||||
return items
|
||||
|
||||
def homeContent(self, filter=False):
|
||||
try:
|
||||
if not self._categories:
|
||||
self.init()
|
||||
# 仅保留视频分类(type == 'vod')
|
||||
video_cats = [c for c in self._categories if c.get('type') == 'vod']
|
||||
html = self._fetch(self.host + '/')
|
||||
items = self._parse_video_list(html) if html else []
|
||||
return {'class': video_cats, 'list': items[:20]}
|
||||
except Exception as e:
|
||||
self._log(f'homeContent 异常: {e}')
|
||||
return {'class': [], 'list': []}
|
||||
|
||||
def homeVideoContent(self):
|
||||
html = self._fetch(self.host + '/')
|
||||
items = self._parse_video_list(html) if html else []
|
||||
return {'list': items[:20]}
|
||||
|
||||
def categoryContent(self, tid, pg, filter=False, extend=''):
|
||||
try:
|
||||
page = int(pg) if pg else 1
|
||||
# 检查是否为图片/小说分类(通过 _categories 判断)
|
||||
for c in self._categories:
|
||||
if str(c['type_id']) == str(tid):
|
||||
if c.get('type') != 'vod':
|
||||
# 图片/小说分类不再提供内容,返回空
|
||||
return {'list': [], 'page': page, 'pagecount': 1}
|
||||
break
|
||||
# 视频分类正常加载
|
||||
url = f'{self.host}/vodtype/{tid}-{page}.html' if page > 1 else f'{self.host}/vodtype/{tid}.html'
|
||||
html = self._fetch(url)
|
||||
items = self._parse_video_list(html) if html else []
|
||||
total_pages = page
|
||||
if html:
|
||||
page_links = re.findall(r'/vodtype/{}[-_](\d+)\.html'.format(tid), html)
|
||||
if page_links:
|
||||
total_pages = max(int(p) for p in page_links)
|
||||
else:
|
||||
total_pages = page + 1
|
||||
return {'list': items, 'page': page, 'pagecount': max(total_pages, page)}
|
||||
except Exception as e:
|
||||
self._log(f'categoryContent 异常: {e}')
|
||||
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1}
|
||||
|
||||
# ========== 播放地址提取 ==========
|
||||
def _extract_m3u8(self, html):
|
||||
urls = []
|
||||
if not html:
|
||||
return urls
|
||||
player_match = re.search(r'var\s+player_aaaa\s*=\s*({.*?});', html, re.S)
|
||||
if player_match:
|
||||
try:
|
||||
data = json.loads(player_match.group(1))
|
||||
raw = data.get('url', '')
|
||||
if raw:
|
||||
decoded = unquote(raw)
|
||||
if decoded.startswith('http'):
|
||||
urls.append(decoded)
|
||||
except:
|
||||
pass
|
||||
direct = re.findall(r'(https?://[^\s"\'<>]+\.m3u8[^\s"\'<>]*)', html)
|
||||
urls.extend(direct)
|
||||
if not urls:
|
||||
scripts = re.findall(r'<script[^>]*>(.*?)</script>', html, re.S)
|
||||
for scr in scripts:
|
||||
json_urls = re.findall(r'''["\']url["\']\s*:\s*["\']([^"\']+\.m3u8[^"\']*)["\']''', scr)
|
||||
urls.extend(json_urls)
|
||||
seen = set()
|
||||
clean = []
|
||||
for u in urls:
|
||||
if u.startswith('http') and u not in seen:
|
||||
seen.add(u)
|
||||
clean.append(u)
|
||||
return clean
|
||||
|
||||
def detailContent(self, ids):
|
||||
try:
|
||||
vid = str(ids[0] if isinstance(ids, list) else ids)
|
||||
html = self._fetch(f'{self.host}/voddetail/{vid}.html')
|
||||
if html:
|
||||
return self._video_detail(vid, html)
|
||||
# 如果视频详情页无内容,不再尝试图片/小说详情(因分类已屏蔽)
|
||||
return {'list': [{'vod_id': vid, 'vod_name': '未知影片', 'vod_play_from': '错误', 'vod_play_url': ''}]}
|
||||
except Exception as e:
|
||||
self._log(f'detailContent 异常: {e}')
|
||||
return {'list': [{'vod_id': vid, 'vod_name': '错误', 'vod_play_from': '错误', 'vod_play_url': ''}]}
|
||||
|
||||
# ========== 【修复】视频详情 - 分隔符不编码 ==========
|
||||
def _video_detail(self, vid, html):
|
||||
title = ''
|
||||
cover = ''
|
||||
m = re.search(r'<h1[^>]*>(.*?)</h1>', html, re.S)
|
||||
if m:
|
||||
title = self._clean_text(m.group(1))
|
||||
if not title:
|
||||
m = re.search(r'<title>(.*?)</title>', html)
|
||||
if m:
|
||||
title = self._clean_text(m.group(1))
|
||||
m = re.search(r'<img[^>]*data-original="([^"]*)"[^>]*>', html)
|
||||
if m:
|
||||
cover = m.group(1)
|
||||
if not cover:
|
||||
m = re.search(r'<meta[^>]+property="og:image"[^>]+content="([^"]+)"', html)
|
||||
if m:
|
||||
cover = m.group(1)
|
||||
if cover and not cover.startswith('http'):
|
||||
cover = urljoin(self.host, cover)
|
||||
|
||||
# 更灵活的播放按钮匹配
|
||||
buttons = re.findall(
|
||||
r'<div[^>]+class="item"[^>]*>\s*<a[^>]+href="(/vodplay/' + vid + r'[-_]\d+[-_]\d+\.html)"[^>]*>(.*?)</a>',
|
||||
html, re.S
|
||||
)
|
||||
if not buttons:
|
||||
buttons = re.findall(
|
||||
r'href="(/vodplay/' + vid + r'[^"]*)"[^>]*>(.*?)</a>',
|
||||
html, re.S
|
||||
)
|
||||
if not buttons:
|
||||
buttons = [(f'/vodplay/{vid}-1-1.html', '立即播放')]
|
||||
self._log(f'未匹配到播放按钮,使用默认: {buttons[0][0]}')
|
||||
|
||||
line_map = {}
|
||||
cache = {}
|
||||
|
||||
for href, btn_name in buttons:
|
||||
btn_name = self._clean_text(btn_name) or '播放'
|
||||
play_url = urljoin(self.host, href)
|
||||
|
||||
if href not in cache:
|
||||
play_html = self._fetch(play_url)
|
||||
m3u8_list = self._extract_m3u8(play_html) if play_html else []
|
||||
cache[href] = m3u8_list
|
||||
self._log(f'播放页 {href} 提取到 {len(m3u8_list)} 个地址')
|
||||
else:
|
||||
m3u8_list = cache[href]
|
||||
|
||||
if m3u8_list:
|
||||
for i, m3u8 in enumerate(m3u8_list):
|
||||
name = btn_name if i == 0 else f'{btn_name}_{i+1}'
|
||||
if btn_name not in line_map:
|
||||
line_map[btn_name] = []
|
||||
# 使用代理清洗链接(后续 playerContent 会处理)
|
||||
line_map[btn_name].append((name, m3u8))
|
||||
else:
|
||||
if btn_name not in line_map:
|
||||
line_map[btn_name] = []
|
||||
line_map[btn_name].append((btn_name, play_url))
|
||||
self._log(f'播放页 {href} 未提取到 m3u8,回退到播放页 URL')
|
||||
|
||||
if not line_map:
|
||||
return {'list': [{'vod_id': vid, 'vod_name': title, 'vod_pic': cover,
|
||||
'vod_play_from': '错误', 'vod_play_url': '未找到播放地址'}]}
|
||||
|
||||
# TVBox 格式:$ # $$$ 绝对不能编码
|
||||
from_lines = []
|
||||
url_lines = []
|
||||
for line_name, episodes in line_map.items():
|
||||
from_lines.append(line_name)
|
||||
ep_str = '#'.join([f'{ep_name}${ep_url}' for ep_name, ep_url in episodes])
|
||||
url_lines.append(ep_str)
|
||||
|
||||
vod_play_from = '#'.join(from_lines)
|
||||
vod_play_url = '$$$'.join(url_lines)
|
||||
|
||||
self._log(f'vod_play_from: {vod_play_from}')
|
||||
self._log(f'vod_play_url: {vod_play_url[:200]}...')
|
||||
|
||||
return {'list': [{'vod_id': vid, 'vod_name': title, 'vod_pic': cover,
|
||||
'vod_play_from': vod_play_from, 'vod_play_url': vod_play_url}]}
|
||||
|
||||
# ========== 播放器:集成 m3u8 广告清洗代理 ==========
|
||||
def playerContent(self, flag, id, vipFlags=None):
|
||||
if id.startswith('http') and ('.m3u8' in id or '.mp4' in id or '.ts' in id):
|
||||
# 如果是 m3u8,替换为本地代理清洗链接
|
||||
if '.m3u8' in id:
|
||||
proxy_url = self._proxy_m3u8_url(id, self.host)
|
||||
return {'parse': 0, 'url': proxy_url, 'header': {'Referer': self.host, 'User-Agent': 'Mozilla/5.0'}}
|
||||
else:
|
||||
return {'parse': 0, 'url': id, 'header': {'Referer': self.host, 'User-Agent': 'Mozilla/5.0'}}
|
||||
# 非直链,交由解析接口处理
|
||||
return {'parse': 1, 'url': id, 'header': {'Referer': self.host, 'User-Agent': 'Mozilla/5.0'}}
|
||||
|
||||
# ===================== m3u8 广告清洗相关方法(移植自 qinav) =====================
|
||||
def _proxy_m3u8_url(self, url, referer=''):
|
||||
"""生成走本地代理的清洗链接"""
|
||||
try:
|
||||
# 尝试使用基类提供的代理基础路径
|
||||
base = self.getProxyUrl()
|
||||
if '?' not in base:
|
||||
base += '?do=py'
|
||||
return base + '&do=m3u8&url=' + quote(url, safe='') + '&referer=' + quote(referer or self.host, safe='')
|
||||
except:
|
||||
pass
|
||||
# 降级:直接返回原始 URL(不做清洗)
|
||||
return url
|
||||
|
||||
def _get_m3u8_content(self, url, referer):
|
||||
try:
|
||||
headers = self.session.headers.copy()
|
||||
headers['Referer'] = referer
|
||||
resp = requests.get(url, headers=headers, timeout=15)
|
||||
if resp.status_code == 200:
|
||||
if not resp.encoding or resp.encoding.lower() in ('iso-8859-1', 'latin-1'):
|
||||
resp.encoding = resp.apparent_encoding
|
||||
return resp.text
|
||||
except Exception as e:
|
||||
self._log(f'下载 m3u8 失败: {e}')
|
||||
return None
|
||||
|
||||
def _clean_m3u8(self, m3u8_text, m3u8_url='', referer='', skip_seconds=25):
|
||||
"""清洗 m3u8:去除广告分片,保留 KEY/MAP/DISCONTINUITY,URI 绝对化"""
|
||||
text = (m3u8_text or '').replace('\r', '')
|
||||
if '#EXT-X-STREAM-INF' in text:
|
||||
# master m3u8,将子 m3u8 的 URL 也替换为代理链接
|
||||
out = []
|
||||
for raw in text.splitlines():
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line.startswith('#'):
|
||||
out.append(line)
|
||||
else:
|
||||
abs_url = urljoin(m3u8_url, line)
|
||||
if '.m3u8' in line.lower():
|
||||
out.append(self._proxy_m3u8_url(abs_url, referer))
|
||||
else:
|
||||
out.append(abs_url)
|
||||
return '\n'.join(out) + '\n'
|
||||
|
||||
header, segments, tail, media_sequence, target_duration = self._parse_m3u8_segments(text)
|
||||
if not segments:
|
||||
return text
|
||||
|
||||
marker = self._main_path_marker(m3u8_url)
|
||||
stat = {}
|
||||
for seg in segments:
|
||||
key = self._segment_host_key(seg['uri'], m3u8_url)
|
||||
stat[key] = stat.get(key, 0.0) + float(seg.get('dur') or 0)
|
||||
main_key = max(stat.items(), key=lambda x: x[1])[0] if stat else ('', '')
|
||||
total_dur = sum(stat.values()) or 0
|
||||
main_dur = stat.get(main_key, 0)
|
||||
|
||||
cleaned = []
|
||||
removed = 0
|
||||
for idx, seg in enumerate(segments):
|
||||
key = self._segment_host_key(seg['uri'], m3u8_url)
|
||||
is_front = idx < 12
|
||||
abs_uri = urljoin(m3u8_url, seg.get('uri', ''))
|
||||
is_ad = self._is_ad_segment(seg['uri'], seg.get('dur'), seg.get('tags'))
|
||||
if marker and marker not in urlparse(abs_uri).path.lower():
|
||||
is_ad = True
|
||||
tags_text = '\n'.join(seg.get('tags') or []).upper()
|
||||
if is_front and 'METHOD=NONE' in tags_text and marker and marker not in urlparse(abs_uri).path.lower():
|
||||
is_ad = True
|
||||
if (not is_ad) and is_front and total_dur > 0 and main_dur >= total_dur * 0.6:
|
||||
if key != main_key and stat.get(key, 0) <= 90:
|
||||
is_ad = True
|
||||
if is_ad:
|
||||
removed += 1
|
||||
continue
|
||||
seg['_idx'] = idx
|
||||
cleaned.append(seg)
|
||||
|
||||
# 若未检测到广告,尝试按累积秒数跳过前置广告段
|
||||
if removed == 0 and len(segments) > 4:
|
||||
acc = 0.0
|
||||
cut = 0
|
||||
for idx, seg in enumerate(segments[:12]):
|
||||
key = self._segment_host_key(seg['uri'], m3u8_url)
|
||||
if key == main_key and acc >= 3:
|
||||
break
|
||||
acc += float(seg.get('dur') or target_duration or 3)
|
||||
cut = idx + 1
|
||||
if acc >= skip_seconds:
|
||||
break
|
||||
if cut > 0 and cut < len(segments):
|
||||
first_key = self._segment_host_key(segments[0]['uri'], m3u8_url)
|
||||
if first_key != main_key:
|
||||
cleaned = segments[cut:]
|
||||
removed = cut
|
||||
|
||||
if not cleaned:
|
||||
cleaned = segments
|
||||
removed = 0
|
||||
|
||||
new_lines = []
|
||||
has_m3u = False
|
||||
for line in header:
|
||||
if line.startswith('#EXTM3U'): has_m3u = True
|
||||
if line.startswith('#EXT-X-MEDIA-SEQUENCE') or line.startswith('#EXT-X-START'):
|
||||
continue
|
||||
if line.startswith('#EXT-X-KEY') and 'METHOD=NONE' in line.upper() and removed > 0:
|
||||
continue
|
||||
new_lines.append(line)
|
||||
if not has_m3u:
|
||||
new_lines.insert(0, '#EXTM3U')
|
||||
first_idx = cleaned[0].get('_idx', removed) if cleaned else removed
|
||||
new_lines.append(f'#EXT-X-MEDIA-SEQUENCE:{media_sequence + first_idx}')
|
||||
for seg in cleaned:
|
||||
for tag in seg.get('tags') or []:
|
||||
if tag.startswith('#EXT-X-KEY') or tag.startswith('#EXT-X-MAP'):
|
||||
def _fix_uri(m):
|
||||
return 'URI="' + urljoin(m3u8_url, m.group(1)) + '"'
|
||||
tag = re.sub(r'URI="([^"]+)"', _fix_uri, tag)
|
||||
new_lines.append(tag)
|
||||
new_lines.append(urljoin(m3u8_url, seg.get('uri', '')))
|
||||
if tail:
|
||||
for line in tail:
|
||||
if line.startswith('#EXT-X-ENDLIST'):
|
||||
new_lines.append(line)
|
||||
elif '#EXT-X-ENDLIST' in text:
|
||||
new_lines.append('#EXT-X-ENDLIST')
|
||||
self._log(f'm3u8清洗: 原{len(segments)}片 → 删除{removed}片广告,保留{len(cleaned)}片')
|
||||
return '\n'.join(new_lines) + '\n'
|
||||
|
||||
def _parse_m3u8_segments(self, text):
|
||||
lines = [x.strip() for x in (text or '').replace('\r', '').split('\n') if x.strip()]
|
||||
header, segments, tail = [], [], []
|
||||
pending_tags = []
|
||||
media_sequence = 0
|
||||
target_duration = 0
|
||||
started = False
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
if line.startswith('#EXT-X-MEDIA-SEQUENCE'):
|
||||
try:
|
||||
media_sequence = int(line.split(':', 1)[1])
|
||||
except:
|
||||
pass
|
||||
if not started:
|
||||
header.append(line)
|
||||
else:
|
||||
pending_tags.append(line)
|
||||
elif line.startswith('#EXT-X-TARGETDURATION'):
|
||||
try:
|
||||
target_duration = float(line.split(':', 1)[1])
|
||||
except:
|
||||
pass
|
||||
if not started:
|
||||
header.append(line)
|
||||
else:
|
||||
pending_tags.append(line)
|
||||
elif line.startswith('#EXTINF'):
|
||||
started = True
|
||||
dur = target_duration or 3.0
|
||||
m = re.search(r'#EXTINF:\s*([\d.]+)', line)
|
||||
if m:
|
||||
try:
|
||||
dur = float(m.group(1))
|
||||
except:
|
||||
pass
|
||||
tags = pending_tags + [line]
|
||||
pending_tags = []
|
||||
uri = ''
|
||||
j = i + 1
|
||||
while j < len(lines):
|
||||
if lines[j].startswith('#'):
|
||||
tags.append(lines[j])
|
||||
j += 1
|
||||
continue
|
||||
uri = lines[j]
|
||||
break
|
||||
if uri:
|
||||
segments.append({'tags': tags, 'uri': uri, 'dur': dur})
|
||||
i = j
|
||||
else:
|
||||
tail.extend(tags)
|
||||
elif line.startswith('#EXT-X-ENDLIST'):
|
||||
tail.append(line)
|
||||
elif line.startswith('#'):
|
||||
if started:
|
||||
pending_tags.append(line)
|
||||
else:
|
||||
header.append(line)
|
||||
else:
|
||||
started = True
|
||||
dur = target_duration or 3.0
|
||||
segments.append({'tags': pending_tags, 'uri': line, 'dur': dur})
|
||||
pending_tags = []
|
||||
i += 1
|
||||
return header, segments, tail, media_sequence, target_duration
|
||||
|
||||
def _is_ad_segment(self, uri, dur=0, prev_tags=None):
|
||||
u = (uri or '').strip().lower()
|
||||
if not u:
|
||||
return False
|
||||
ad_words = [
|
||||
'ad', 'ads', 'advert', 'advertise', 'advertisement', 'sponsor',
|
||||
'pre', 'preroll', '片头', '广告', '/gg/', '_gg', 'gg_', '/adv/',
|
||||
'/ad/', '/ads/', 'banner', 'promo', 'commercial'
|
||||
]
|
||||
if any(w in u for w in ad_words):
|
||||
return True
|
||||
try:
|
||||
if 0 < float(dur) <= 1.2:
|
||||
return True
|
||||
except:
|
||||
pass
|
||||
return False
|
||||
|
||||
def _segment_host_key(self, uri, base_url):
|
||||
try:
|
||||
full = urljoin(base_url, uri)
|
||||
p = urlparse(full)
|
||||
path = re.sub(r'/[^/]*$', '/', p.path or '/')
|
||||
return (p.netloc.lower(), path.lower())
|
||||
except:
|
||||
return ('', '')
|
||||
|
||||
def _main_path_marker(self, m3u8_url):
|
||||
try:
|
||||
p = urlparse(m3u8_url).path
|
||||
m = re.search(r'(/\d{8}/[^/]+/\d+kb/hls/)', p)
|
||||
if m:
|
||||
return m.group(1).lower()
|
||||
m = re.search(r'(/\d{8}/[^/]+/)', p)
|
||||
if m:
|
||||
return m.group(1).lower()
|
||||
except:
|
||||
pass
|
||||
return ''
|
||||
|
||||
# ========== 图片/小说详情(保留,但不再主动调用) ==========
|
||||
def _art_detail(self, vid, html):
|
||||
title = ''
|
||||
m = re.search(r'<h1[^>]*>(.*?)</h1>', html, re.S)
|
||||
if m:
|
||||
title = self._clean_text(m.group(1))
|
||||
if not title:
|
||||
m = re.search(r'<title>(.*?)</title>', html)
|
||||
if m:
|
||||
title = self._clean_text(m.group(1))
|
||||
|
||||
# 图片提取(增强)
|
||||
imgs = []
|
||||
for attr in ['data-original', 'src', 'data-src', 'original', 'data-url']:
|
||||
found = re.findall(rf'<img[^>]*{attr}="([^"]+)"', html)
|
||||
imgs.extend(found)
|
||||
|
||||
real_imgs = []
|
||||
for img in imgs:
|
||||
lower = img.lower()
|
||||
if any(k in lower for k in ['logo', 'loading', 'ad.', 'icon', 'avatar', 'thumb', 'blank', 'default']):
|
||||
continue
|
||||
if img.startswith('//'):
|
||||
img = 'https:' + img
|
||||
if not img.startswith('http'):
|
||||
img = urljoin(self.host, img)
|
||||
if img not in real_imgs:
|
||||
real_imgs.append(img)
|
||||
|
||||
if real_imgs:
|
||||
pics = '&&'.join(real_imgs)
|
||||
play_url = f'查看$pics://{pics}'
|
||||
vod_play_from = '图片'
|
||||
self._log(f'图片详情提取到 {len(real_imgs)} 张图片')
|
||||
return {'list': [{'vod_id': vid, 'vod_name': title, 'vod_pic': real_imgs[0] if real_imgs else '',
|
||||
'vod_play_from': vod_play_from, 'vod_play_url': play_url}]}
|
||||
|
||||
# 小说提取(增强)
|
||||
content = ''
|
||||
content_patterns = [
|
||||
r'<div[^>]+class="[^"]*content[^"]*"[^>]*>(.*?)</div>',
|
||||
r'<div[^>]+class="[^"]*article[^"]*"[^>]*>(.*?)</div>',
|
||||
r'<div[^>]+class="[^"]*post[^"]*"[^>]*>(.*?)</div>',
|
||||
r'<div[^>]+class="[^"]*text[^"]*"[^>]*>(.*?)</div>',
|
||||
r'<div[^>]+class="[^"]*novel[^"]*"[^>]*>(.*?)</div>',
|
||||
r'<div[^>]+id="content"[^>]*>(.*?)</div>',
|
||||
r'<div[^>]+id="article"[^>]*>(.*?)</div>',
|
||||
r'<article[^>]*>(.*?)</article>',
|
||||
r'<div[^>]+class="[^"]*main[^"]*"[^>]*>(.*?)</div>',
|
||||
]
|
||||
|
||||
for pattern in content_patterns:
|
||||
m = re.search(pattern, html, re.S)
|
||||
if m:
|
||||
raw = m.group(1)
|
||||
raw = re.sub(r'<br\s*/?>', '\n', raw)
|
||||
raw = re.sub(r'</p>', '\n', raw)
|
||||
raw = re.sub(r'<p>', '', raw)
|
||||
content = re.sub(r'<[^>]+>', '', raw)
|
||||
content = re.sub(r' ', ' ', content)
|
||||
content = re.sub(r'&', '&', content)
|
||||
content = re.sub(r'<', '<', content)
|
||||
content = re.sub(r'>', '>', content)
|
||||
content = re.sub(r'"', '"', content)
|
||||
content = re.sub(r'&#\d+;', '', content)
|
||||
content = re.sub(r'[ \t]*\n[ \t]*', '\n', content)
|
||||
content = re.sub(r'\n{3,}', '\n\n', content)
|
||||
content = content.strip()
|
||||
if len(content) > 50:
|
||||
break
|
||||
|
||||
if len(content) < 50:
|
||||
paragraphs = re.findall(r'<p[^>]*>(.*?)</p>', html, re.S)
|
||||
texts = []
|
||||
for p in paragraphs:
|
||||
txt = re.sub(r'<[^>]+>', '', p).strip()
|
||||
if len(txt) > 10:
|
||||
texts.append(txt)
|
||||
if texts:
|
||||
content = '\n\n'.join(texts)
|
||||
|
||||
if content and len(content) > 20:
|
||||
novel_json = json.dumps({'title': title, 'content': content[:8000]}, ensure_ascii=False)
|
||||
play_url = f'阅读$novel://{novel_json}'
|
||||
vod_play_from = '小说'
|
||||
self._log(f'小说详情提取到 {len(content)} 字内容')
|
||||
return {'list': [{'vod_id': vid, 'vod_name': title, 'vod_pic': '',
|
||||
'vod_play_from': vod_play_from, 'vod_play_url': play_url}]}
|
||||
|
||||
return {'list': [{'vod_id': vid, 'vod_name': title, 'vod_play_from': '错误', 'vod_play_url': '内容无法解析'}]}
|
||||
|
||||
def searchContent(self, key, quick, pg='1'):
|
||||
try:
|
||||
page = int(pg) if pg else 1
|
||||
url = f'{self.host}/vodsearch/-------------.html?wd={quote(key)}&page={page}'
|
||||
html = self._fetch(url)
|
||||
items = self._parse_video_list(html) if html else []
|
||||
return {'list': items, 'page': page, 'pagecount': page + 1}
|
||||
except Exception as e:
|
||||
self._log(f'searchContent 异常: {e}')
|
||||
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1}
|
||||
@@ -0,0 +1,2151 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# //@name:Catemby多播放
|
||||
# //@id:catemby_multi
|
||||
# //@version:8
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from datetime import datetime
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
|
||||
import requests
|
||||
|
||||
try:
|
||||
from com.github.catvod import Proxy as CatVodProxy
|
||||
except Exception:
|
||||
CatVodProxy = None
|
||||
|
||||
from base.spider import Spider as BaseSpider
|
||||
|
||||
|
||||
class WafBlockedError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class Spider(BaseSpider):
|
||||
name = "Catemby多播放"
|
||||
backend_parse = False
|
||||
category_mode = False
|
||||
categoryMode = False
|
||||
|
||||
API_BASE = "https://jdforrepam.com/api"
|
||||
SOURCE_BASE = "https://catembylegacy.fastcdn.dpdns.org"
|
||||
SOURCE_ORIGIN = SOURCE_BASE + "/"
|
||||
SIGNATURE_TOKEN = "lpw6vgqzsp"
|
||||
SIGNATURE_SALT = (
|
||||
"71cf27bb3c0bcdf207b64abecddc970098c7421ee7203b9cdae54478478a199e7"
|
||||
"d5a6e1a57691123c1a931c057842fb73ba3b3c83bcd69c17ccf174081e3d8aa"
|
||||
)
|
||||
|
||||
PLAY_PREFIX = "catemby-play:"
|
||||
DEFAULT_PIC = SOURCE_BASE + "/favicon.ico"
|
||||
CATEGORY_SPECS = (
|
||||
("censored", "有码", "0"),
|
||||
("uncensored", "无码", "1"),
|
||||
("western", "欧美", "2"),
|
||||
("fc2", "FC2", "3"),
|
||||
)
|
||||
TYPE_BY_CATEGORY = {
|
||||
item[0]: item[2] for item in CATEGORY_SPECS if item[2] is not None
|
||||
}
|
||||
PERIODS = (
|
||||
("日榜", "daily"),
|
||||
("周榜", "weekly"),
|
||||
("月榜", "monthly"),
|
||||
)
|
||||
RESOURCE_FILTERS = (
|
||||
("全部可用", "all"),
|
||||
("可播放", "can_play"),
|
||||
("含磁链", "magnets"),
|
||||
("含字幕", "subtitle"),
|
||||
)
|
||||
SORTS = (
|
||||
("热度", "watched_count"),
|
||||
("最新", "release"),
|
||||
("评分", "score"),
|
||||
("想看", "want_watch_count"),
|
||||
("磁链", "magnets_count"),
|
||||
)
|
||||
VIDEO_EXTS = (
|
||||
".mp4",
|
||||
".mkv",
|
||||
".avi",
|
||||
".mov",
|
||||
".wmv",
|
||||
".flv",
|
||||
".ts",
|
||||
".m2ts",
|
||||
".webm",
|
||||
".mpg",
|
||||
".mpeg",
|
||||
".m4v",
|
||||
)
|
||||
CHALLENGE_MARKERS = (
|
||||
"just a moment",
|
||||
"/cdn-cgi/challenge-platform",
|
||||
"_cf_chl_opt",
|
||||
"cf-turnstile",
|
||||
"attention required",
|
||||
)
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
super().__init__()
|
||||
except Exception:
|
||||
pass
|
||||
self.timeout = 20
|
||||
self.speed_probe_timeout = 3
|
||||
self.speed_probe = True
|
||||
self.dynamic_tags = True
|
||||
self.strict_direct_cards = True
|
||||
self.direct_probe_limit = 12
|
||||
self.min_direct_minutes = 30
|
||||
self.full_probe_cache_ttl = 1800
|
||||
self.show_unplayable = False
|
||||
self.native_magnet_fallback = True
|
||||
self.verify_tls = True
|
||||
self.trust_env = True
|
||||
self.proxy = ""
|
||||
self.list_cache_ttl = 120
|
||||
self.detail_cache_ttl = 21600
|
||||
self.tag_cache_ttl = 21600
|
||||
self.resolver_cache_ttl = 300
|
||||
self.health_cache_ttl = 900
|
||||
self.image_cache_ttl = 1800
|
||||
self.max_variants = 12
|
||||
self.max_magnets = 50
|
||||
self.max_json_bytes = 2 * 1024 * 1024
|
||||
self.max_image_bytes = 4 * 1024 * 1024
|
||||
self.max_playlist_bytes = 1024 * 1024
|
||||
self.user_agent = (
|
||||
"Mozilla/5.0 (Linux; Android 10; TV) AppleWebKit/537.36 "
|
||||
"Chrome/120.0 Safari/537.36"
|
||||
)
|
||||
self.alist_api = ""
|
||||
self.alist_token = ""
|
||||
self.alist_api_key = ""
|
||||
self.alist_source = "catemby"
|
||||
self.proxy_site_key = "catemby"
|
||||
self.alist_timeout = 120
|
||||
self._session = None
|
||||
self._cache = {}
|
||||
self._health = {}
|
||||
self._playlist_cache = {}
|
||||
self._image_cache = {}
|
||||
self._media_meta_cache = {}
|
||||
self._lock = threading.RLock()
|
||||
self._reset_session()
|
||||
|
||||
def getName(self):
|
||||
return self.name
|
||||
|
||||
def init(self, extend=""):
|
||||
config = self._parse_dict(extend)
|
||||
self.timeout = self._bounded_int(config.get("timeout"), self.timeout, 5, 45)
|
||||
self.speed_probe_timeout = self._bounded_int(
|
||||
config.get("speed_probe_timeout"), self.speed_probe_timeout, 1, 8
|
||||
)
|
||||
self.speed_probe = self._bool(config.get("speed_probe"), self.speed_probe)
|
||||
self.dynamic_tags = self._bool(
|
||||
config.get("dynamic_tags"), self.dynamic_tags
|
||||
)
|
||||
self.strict_direct_cards = self._bool(
|
||||
config.get("strict_direct_cards"), self.strict_direct_cards
|
||||
)
|
||||
self.direct_probe_limit = self._bounded_int(
|
||||
config.get("direct_probe_limit"), self.direct_probe_limit, 4, 24
|
||||
)
|
||||
self.min_direct_minutes = self._bounded_int(
|
||||
config.get("min_direct_minutes"), self.min_direct_minutes, 1, 240
|
||||
)
|
||||
self.full_probe_cache_ttl = self._bounded_int(
|
||||
config.get("full_probe_cache_ttl"),
|
||||
self.full_probe_cache_ttl,
|
||||
60,
|
||||
86400,
|
||||
)
|
||||
self.show_unplayable = self._bool(
|
||||
config.get("show_unplayable"), self.show_unplayable
|
||||
)
|
||||
self.native_magnet_fallback = self._bool(
|
||||
config.get("native_magnet_fallback"), self.native_magnet_fallback
|
||||
)
|
||||
self.verify_tls = self._bool(config.get("verify_tls"), self.verify_tls)
|
||||
self.trust_env = self._bool(config.get("trust_env"), self.trust_env)
|
||||
self.list_cache_ttl = self._bounded_int(
|
||||
config.get("list_cache_ttl"), self.list_cache_ttl, 0, 1800
|
||||
)
|
||||
self.detail_cache_ttl = self._bounded_int(
|
||||
config.get("detail_cache_ttl"), self.detail_cache_ttl, 0, 86400
|
||||
)
|
||||
self.tag_cache_ttl = self._bounded_int(
|
||||
config.get("tag_cache_ttl"), self.tag_cache_ttl, 0, 86400
|
||||
)
|
||||
self.resolver_cache_ttl = self._bounded_int(
|
||||
config.get("resolver_cache_ttl"), self.resolver_cache_ttl, 0, 1800
|
||||
)
|
||||
self.health_cache_ttl = self._bounded_int(
|
||||
config.get("health_cache_ttl"), self.health_cache_ttl, 30, 7200
|
||||
)
|
||||
self.max_variants = self._bounded_int(
|
||||
config.get("max_variants"), self.max_variants, 1, 20
|
||||
)
|
||||
self.max_magnets = self._bounded_int(
|
||||
config.get("max_magnets"), self.max_magnets, 1, 100
|
||||
)
|
||||
self.proxy = str(config.get("proxy") or "").strip()
|
||||
user_agent = str(config.get("user_agent") or "").strip()
|
||||
if user_agent:
|
||||
self.user_agent = user_agent
|
||||
self.alist_api = str(
|
||||
config.get("alist_tvbox_api") or config.get("offline_api") or ""
|
||||
).strip().rstrip("/")
|
||||
self.alist_token = str(
|
||||
config.get("alist_tvbox_token") or config.get("offline_token") or ""
|
||||
).strip()
|
||||
self.alist_api_key = str(
|
||||
config.get("alist_tvbox_api_key") or config.get("offline_api_key") or ""
|
||||
).strip()
|
||||
self.alist_source = str(
|
||||
config.get("alist_tvbox_source") or self.alist_source
|
||||
).strip() or "catemby"
|
||||
self.proxy_site_key = str(
|
||||
config.get("proxy_site_key") or self.proxy_site_key
|
||||
).strip() or "catemby"
|
||||
self.alist_timeout = self._bounded_int(
|
||||
config.get("alist_tvbox_timeout"), self.alist_timeout, 15, 300
|
||||
)
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._health.clear()
|
||||
self._playlist_cache.clear()
|
||||
self._image_cache.clear()
|
||||
self._media_meta_cache.clear()
|
||||
self._reset_session()
|
||||
|
||||
def destroy(self):
|
||||
if self._session is not None:
|
||||
try:
|
||||
self._session.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._session = None
|
||||
with self._lock:
|
||||
self._cache.clear()
|
||||
self._health.clear()
|
||||
self._playlist_cache.clear()
|
||||
self._image_cache.clear()
|
||||
self._media_meta_cache.clear()
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
text = str(url or "").lower()
|
||||
return bool(
|
||||
re.search(r"\.(?:m3u8|mp4|mkv|webm)(?:$|[?#])", text)
|
||||
or "kind=hls" in text
|
||||
)
|
||||
|
||||
def manualVideoCheck(self):
|
||||
return False
|
||||
|
||||
def localProxy(self, param):
|
||||
data = param if isinstance(param, dict) else self._parse_dict(param)
|
||||
kind = str(data.get("kind") or "").strip().lower()
|
||||
token = str(data.get("token") or "").strip()
|
||||
try:
|
||||
if kind == "image":
|
||||
source_url = self._unpack_text(token)
|
||||
decoded, mime = self._decoded_image(source_url)
|
||||
return [
|
||||
200,
|
||||
mime,
|
||||
decoded,
|
||||
{
|
||||
"Cache-Control": "public, max-age=1800",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"Content-Length": str(len(decoded)),
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
]
|
||||
if kind == "hls":
|
||||
cached = self._playlist_cache_get(token)
|
||||
if cached is None:
|
||||
return [404, "text/plain; charset=utf-8", b"playlist expired"]
|
||||
return [
|
||||
200,
|
||||
"application/vnd.apple.mpegurl",
|
||||
cached,
|
||||
{
|
||||
"Cache-Control": "no-store",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
]
|
||||
except Exception as exc:
|
||||
return [
|
||||
502,
|
||||
"text/plain; charset=utf-8",
|
||||
("proxy error: %s" % exc).encode("utf-8", errors="replace"),
|
||||
]
|
||||
return [404, "text/plain; charset=utf-8", b"not found"]
|
||||
|
||||
def homeContent(self, filter):
|
||||
classes = [
|
||||
{"type_id": item[0], "type_name": item[1]}
|
||||
for item in self.CATEGORY_SPECS
|
||||
]
|
||||
filters = {}
|
||||
tag_map = self._load_all_tags() if self.dynamic_tags else {}
|
||||
for type_id, _, content_type in self.CATEGORY_SPECS:
|
||||
if content_type is None:
|
||||
continue
|
||||
rows = [self._filter("sort", "排序", self.SORTS)]
|
||||
tags = tag_map.get(content_type) or []
|
||||
if tags:
|
||||
values = [("全部", "")]
|
||||
for group in tags:
|
||||
group_name = self._clean_text(
|
||||
group.get("category") or group.get("category_id")
|
||||
)
|
||||
for tag in group.get("tags") or []:
|
||||
tag_id = self._safe_filter_value(tag.get("id"))
|
||||
tag_name = self._clean_text(tag.get("name") or tag_id)
|
||||
if tag_id and tag_name:
|
||||
values.append((group_name + "·" + tag_name, tag_id))
|
||||
if len(values) > 1:
|
||||
rows.append(self._filter("tag", "标签", values[:240]))
|
||||
filters[type_id] = rows
|
||||
return {"class": classes, "filters": filters}
|
||||
|
||||
def homeVideoContent(self):
|
||||
result = self.categoryContent("censored", "1", False, {})
|
||||
return {"list": result.get("list", []), "msg": result.get("msg", "")}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
page = self._page(pg)
|
||||
type_id = str(tid or "").strip()
|
||||
selected = self._parse_dict(extend)
|
||||
try:
|
||||
content_type = self.TYPE_BY_CATEGORY.get(type_id)
|
||||
if content_type is None:
|
||||
return self._empty_page(page, "未知分类")
|
||||
sort_by = self._choice(
|
||||
selected.get("sort"), self.SORTS, "watched_count"
|
||||
)
|
||||
tag_id = self._safe_filter_value(selected.get("tag"))
|
||||
filter_by = (
|
||||
content_type + ":t:" + tag_id + "::::"
|
||||
if tag_id
|
||||
else content_type + ":t:::::"
|
||||
)
|
||||
data = self._api(
|
||||
"/v1/movies/tags",
|
||||
{
|
||||
"filter_by": filter_by,
|
||||
"sort_by": sort_by,
|
||||
"order_by": "desc",
|
||||
"page": page,
|
||||
"limit": 24,
|
||||
},
|
||||
self.list_cache_ttl,
|
||||
)
|
||||
return self._page_result(
|
||||
data.get("movies") or [], page, 24, True, self.strict_direct_cards
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._empty_page(page, "分类读取失败: %s" % exc)
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
keyword = self._clean_text(key)
|
||||
page = self._page(pg)
|
||||
if not keyword:
|
||||
return self._empty_page(page)
|
||||
try:
|
||||
data = self._api(
|
||||
"/v2/search",
|
||||
{"q": keyword, "page": page, "type": "movie", "limit": 24},
|
||||
self.list_cache_ttl,
|
||||
)
|
||||
return self._page_result(
|
||||
data.get("movies") or [], page, 24, True, self.strict_direct_cards
|
||||
)
|
||||
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
|
||||
movie_id = self._normalize_detail_id(raw_id)
|
||||
if not movie_id:
|
||||
return {"list": []}
|
||||
try:
|
||||
detail = self._api(
|
||||
"/v4/movies/" + quote(movie_id, safe=""),
|
||||
{},
|
||||
self.detail_cache_ttl,
|
||||
)
|
||||
movie = detail.get("movie") or {}
|
||||
if not movie:
|
||||
raise RuntimeError("详情响应缺少 movie")
|
||||
except Exception as exc:
|
||||
return {"list": [self._detail_error(movie_id, str(exc))]}
|
||||
|
||||
magnets = []
|
||||
magnet_error = ""
|
||||
try:
|
||||
magnet_data = self._api(
|
||||
"/v1/movies/%s/magnets" % quote(movie_id, safe=""),
|
||||
{},
|
||||
self.detail_cache_ttl,
|
||||
)
|
||||
magnets = self._sort_magnets(magnet_data.get("magnets") or [])
|
||||
except Exception as exc:
|
||||
magnet_error = self._clean_text(exc)
|
||||
|
||||
variants = []
|
||||
resolver_error = ""
|
||||
number = self._clean_text(
|
||||
movie.get("number") or movie.get("number_letter") or movie_id
|
||||
)
|
||||
if movie.get("can_play") and number:
|
||||
try:
|
||||
variants = self._full_direct_variants(
|
||||
self._resolve_variants(number), movie.get("duration")
|
||||
)
|
||||
except Exception as exc:
|
||||
resolver_error = self._clean_text(exc)
|
||||
|
||||
vod = self._build_detail_vod(
|
||||
movie_id, movie, variants, magnets, resolver_error, magnet_error
|
||||
)
|
||||
return {"list": [vod]}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
payload = self._unpack_play_id(id)
|
||||
if not payload:
|
||||
return self._player_error("invalid_play_id", "无法识别播放 ID")
|
||||
kind = str(payload.get("kind") or "")
|
||||
try:
|
||||
if kind in ("auto", "variant"):
|
||||
code = self._clean_text(payload.get("code"))
|
||||
if not code:
|
||||
return self._player_error("missing_code", "播放 ID 缺少番号")
|
||||
variants = self._full_direct_variants(
|
||||
self._resolve_variants(code, fresh=True),
|
||||
payload.get("declared_duration"),
|
||||
)
|
||||
if not variants:
|
||||
return self._player_error("resolver_empty", "解析器未返回播放变体")
|
||||
if kind == "auto":
|
||||
mode = str(payload.get("mode") or "quality")
|
||||
if self.speed_probe:
|
||||
self._measure_variants(variants)
|
||||
ordered = self._sort_variants(variants, mode)
|
||||
healthy = [
|
||||
item
|
||||
for item in ordered
|
||||
if (self._health_for_variant(item) or {}).get("ok")
|
||||
]
|
||||
unknown = [
|
||||
item
|
||||
for item in ordered
|
||||
if self._health_for_variant(item) is None
|
||||
]
|
||||
candidates = healthy or unknown or ordered
|
||||
selected = candidates[0] if candidates else None
|
||||
else:
|
||||
selected = self._find_variant(variants, payload)
|
||||
if selected and self.speed_probe:
|
||||
self._measure_variants(variants)
|
||||
health = self._health_for_variant(selected)
|
||||
if health and not health.get("ok"):
|
||||
mode = str(payload.get("mode") or "quality")
|
||||
ordered = self._sort_variants(variants, mode)
|
||||
healthy = [
|
||||
item
|
||||
for item in ordered
|
||||
if (self._health_for_variant(item) or {}).get("ok")
|
||||
]
|
||||
if healthy:
|
||||
selected = healthy[0]
|
||||
if not selected:
|
||||
return self._player_error("variant_missing", "目标播放变体已失效")
|
||||
return self._variant_player(selected)
|
||||
if kind == "preview":
|
||||
return self._player_error("preview_rejected", "预览视频已被完整版门禁过滤")
|
||||
if kind == "push":
|
||||
url = str(payload.get("url") or "").strip()
|
||||
if not self._is_public_http_url(url):
|
||||
return self._player_error("push_rejected", "分享地址无效")
|
||||
return {
|
||||
"parse": 0,
|
||||
"jx": 0,
|
||||
"playUrl": "",
|
||||
"url": "push://" + url,
|
||||
"header": {},
|
||||
}
|
||||
if kind == "magnet":
|
||||
magnet = self._normalize_magnet(payload.get("magnet"))
|
||||
if not magnet:
|
||||
return self._player_error("magnet_invalid", "磁力哈希无效")
|
||||
return self._magnet_player(magnet)
|
||||
if kind == "error":
|
||||
return self._player_error(
|
||||
str(payload.get("code") or "no_resources"),
|
||||
payload.get("message") or "源站暂无可播放资源",
|
||||
)
|
||||
except WafBlockedError as exc:
|
||||
return self._player_error("blocked_by_waf", str(exc))
|
||||
except Exception as exc:
|
||||
return self._player_error("playback_failed", str(exc))
|
||||
return self._player_error("unsupported_play_kind", "不支持的播放方式")
|
||||
|
||||
def _build_detail_vod(
|
||||
self, movie_id, movie, variants, magnets, resolver_error, magnet_error
|
||||
):
|
||||
number = self._clean_text(
|
||||
movie.get("number") or movie.get("number_letter") or movie_id
|
||||
)
|
||||
title = self._clean_text(
|
||||
movie.get("title") or movie.get("origin_title") or number
|
||||
)
|
||||
display_title = (number + " " + title).strip()
|
||||
pic = self._image_proxy_url(
|
||||
movie.get("cover_url")
|
||||
or movie.get("thumb_url")
|
||||
or self._first_preview_image(movie)
|
||||
or ""
|
||||
)
|
||||
groups = []
|
||||
content = []
|
||||
declared_duration = self._number(movie.get("duration"))
|
||||
|
||||
if variants:
|
||||
smart_items = [
|
||||
(
|
||||
"画质自动",
|
||||
self._pack_play_id(
|
||||
{
|
||||
"kind": "auto",
|
||||
"code": number,
|
||||
"mode": "quality",
|
||||
"declared_duration": declared_duration,
|
||||
}
|
||||
),
|
||||
),
|
||||
(
|
||||
"极速自动",
|
||||
self._pack_play_id(
|
||||
{
|
||||
"kind": "auto",
|
||||
"code": number,
|
||||
"mode": "speed",
|
||||
"declared_duration": declared_duration,
|
||||
}
|
||||
),
|
||||
),
|
||||
]
|
||||
groups.append(("智能线路", smart_items))
|
||||
groups.append(
|
||||
(
|
||||
"画质优先",
|
||||
self._variant_entries(
|
||||
self._sort_variants(variants, "quality"),
|
||||
number,
|
||||
"quality",
|
||||
declared_duration,
|
||||
),
|
||||
)
|
||||
)
|
||||
groups.append(
|
||||
(
|
||||
"极速优先",
|
||||
self._variant_entries(
|
||||
self._sort_variants(variants, "speed"),
|
||||
number,
|
||||
"speed",
|
||||
declared_duration,
|
||||
),
|
||||
)
|
||||
)
|
||||
push_items = []
|
||||
magnet_items = []
|
||||
for item in magnets[: self.max_magnets]:
|
||||
label = self._magnet_label(item)
|
||||
magnet = self._normalize_magnet(item.get("hash") or item.get("magnet"))
|
||||
if magnet:
|
||||
magnet_items.append(
|
||||
(
|
||||
label,
|
||||
self._pack_play_id(
|
||||
{"kind": "magnet", "magnet": magnet, "title": label}
|
||||
),
|
||||
)
|
||||
)
|
||||
push_url = str(item.get("pikpak_url") or "").strip()
|
||||
if self._is_public_http_url(push_url):
|
||||
push_items.append(
|
||||
(
|
||||
label,
|
||||
self._pack_play_id({"kind": "push", "url": push_url}),
|
||||
)
|
||||
)
|
||||
if push_items:
|
||||
groups.append(("PikPak分享", push_items))
|
||||
if magnet_items:
|
||||
groups.append(("磁力完整版", magnet_items))
|
||||
|
||||
if not groups:
|
||||
no_resource_message = (
|
||||
"源站当前没有直连、磁力或预览资源;请使用客户端全局搜索番号 %s"
|
||||
% number
|
||||
)
|
||||
groups.append(
|
||||
(
|
||||
"资源状态",
|
||||
[
|
||||
(
|
||||
"暂无资源 · 搜索 " + number,
|
||||
self._pack_play_id(
|
||||
{
|
||||
"kind": "error",
|
||||
"code": "no_resources",
|
||||
"message": no_resource_message,
|
||||
}
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
)
|
||||
content.append(no_resource_message)
|
||||
|
||||
play_from = []
|
||||
play_url = []
|
||||
for group_name, entries in groups:
|
||||
valid = [(self._safe_play_name(n), value) for n, value in entries if value]
|
||||
if not valid:
|
||||
continue
|
||||
play_from.append(group_name)
|
||||
play_url.append("#".join("%s$%s" % item for item in valid))
|
||||
|
||||
summary = self._clean_text(movie.get("summary"))
|
||||
if summary:
|
||||
content.append(summary)
|
||||
maker = self._clean_text(movie.get("maker_name"))
|
||||
director = self._clean_text(movie.get("director_name"))
|
||||
series = self._clean_text(movie.get("series_name"))
|
||||
metadata = " · ".join(item for item in (maker, director, series) if item)
|
||||
if metadata:
|
||||
content.append(metadata)
|
||||
if resolver_error:
|
||||
content.append("直连解析暂不可用: " + resolver_error)
|
||||
if magnet_error:
|
||||
content.append("磁力列表暂不可用: " + magnet_error)
|
||||
tags = [
|
||||
self._clean_text(item.get("name") if isinstance(item, dict) else item)
|
||||
for item in movie.get("tags") or []
|
||||
]
|
||||
actors = [
|
||||
self._clean_text(item.get("name") if isinstance(item, dict) else item)
|
||||
for item in movie.get("actors") or []
|
||||
]
|
||||
duration = self._duration(movie.get("duration"))
|
||||
score = self._number(movie.get("score"))
|
||||
remarks = []
|
||||
if variants:
|
||||
remarks.append("完整版直连")
|
||||
if magnets:
|
||||
remarks.append("磁力%d" % len(magnets))
|
||||
if movie.get("has_cnsub") or self._number(movie.get("play_subtitle")) > 0:
|
||||
remarks.append("中字")
|
||||
return {
|
||||
"vod_id": movie_id,
|
||||
"vod_name": display_title,
|
||||
"vod_pic": pic or self.DEFAULT_PIC,
|
||||
"vod_remarks": " · ".join(remarks) or number,
|
||||
"vod_content": "\n".join(content),
|
||||
"vod_actor": ", ".join(item for item in actors if item),
|
||||
"vod_class": ", ".join(item for item in tags if item),
|
||||
"vod_director": director,
|
||||
"vod_year": str(movie.get("release_date") or "")[:4],
|
||||
"vod_area": self._area_name(movie.get("type")),
|
||||
"vod_duration": duration,
|
||||
"vod_score": score,
|
||||
"vod_play_from": "$$$".join(play_from),
|
||||
"vod_play_url": "$$$".join(play_url),
|
||||
}
|
||||
|
||||
def _variant_entries(self, variants, code, mode, declared_duration=0):
|
||||
entries = []
|
||||
for variant in variants[: self.max_variants]:
|
||||
payload = {
|
||||
"kind": "variant",
|
||||
"code": code,
|
||||
"fingerprint": variant.get("fingerprint"),
|
||||
"index": variant.get("index"),
|
||||
"variant": variant.get("variant"),
|
||||
"transport": variant.get("transport"),
|
||||
"mode": mode,
|
||||
"declared_duration": self._number(declared_duration),
|
||||
}
|
||||
entries.append((self._variant_label(variant), self._pack_play_id(payload)))
|
||||
return entries
|
||||
|
||||
def _resolve_variants(self, code, fresh=False, isolated=False):
|
||||
cache_key = "resolver:" + code
|
||||
cached = self._cache_get(cache_key)
|
||||
if not fresh and cached is not None:
|
||||
return cached
|
||||
errors = []
|
||||
client = self._new_session() if isolated else None
|
||||
for resolver_code in self._resolver_code_candidates(code):
|
||||
url = (
|
||||
self.SOURCE_BASE
|
||||
+ "/api/v/resolve?code="
|
||||
+ quote(resolver_code, safe="")
|
||||
+ "&lang=zh"
|
||||
)
|
||||
try:
|
||||
body = self._request_json_url(
|
||||
url,
|
||||
{
|
||||
"Accept": "application/json",
|
||||
"Referer": self.SOURCE_ORIGIN,
|
||||
"User-Agent": self.user_agent,
|
||||
},
|
||||
self.max_json_bytes,
|
||||
session=client,
|
||||
)
|
||||
except Exception as exc:
|
||||
errors.append("%s: %s" % (resolver_code, self._clean_text(exc)))
|
||||
continue
|
||||
raw_variants = body.get("variants")
|
||||
if raw_variants is None and isinstance(body.get("data"), dict):
|
||||
raw_variants = body["data"].get("variants")
|
||||
if raw_variants is None and isinstance(body.get("result"), dict):
|
||||
raw_variants = body["result"].get("variants")
|
||||
variants = []
|
||||
for index, item in enumerate(raw_variants or []):
|
||||
normalized = self._normalize_variant(item, index)
|
||||
if normalized:
|
||||
normalized["resolver_code"] = resolver_code
|
||||
variants.append(normalized)
|
||||
variants = variants[: self.max_variants]
|
||||
if variants:
|
||||
self._cache_set(cache_key, variants, self.resolver_cache_ttl)
|
||||
if client is not None:
|
||||
client.close()
|
||||
return variants
|
||||
errors.append(resolver_code + ": empty_variants")
|
||||
|
||||
usable = self._usable_cached_variants(cached)
|
||||
if client is not None:
|
||||
client.close()
|
||||
if usable:
|
||||
return usable
|
||||
if errors:
|
||||
raise RuntimeError("解析器候选均失败: " + " | ".join(errors))
|
||||
return []
|
||||
|
||||
def _full_direct_variants(self, variants, declared_duration=0):
|
||||
candidates = [
|
||||
item
|
||||
for item in variants or []
|
||||
if item.get("transport") == "progressive"
|
||||
and self._is_public_http_url(item.get("url"))
|
||||
]
|
||||
if not candidates:
|
||||
return []
|
||||
accepted = []
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(candidates))) as executor:
|
||||
jobs = {
|
||||
executor.submit(self._probe_full_progressive, item): item
|
||||
for item in candidates
|
||||
}
|
||||
for future in as_completed(jobs):
|
||||
item = jobs[future]
|
||||
try:
|
||||
meta = future.result()
|
||||
except Exception:
|
||||
continue
|
||||
if not meta.get("ok"):
|
||||
continue
|
||||
enriched = dict(item)
|
||||
enriched["duration_seconds"] = meta.get("duration_seconds")
|
||||
enriched["bytes_total"] = meta.get("bytes_total")
|
||||
enriched["full_probe"] = meta
|
||||
accepted.append(enriched)
|
||||
order = {item.get("fingerprint"): index for index, item in enumerate(candidates)}
|
||||
accepted.sort(key=lambda item: order.get(item.get("fingerprint"), 9999))
|
||||
return accepted
|
||||
|
||||
def _probe_full_progressive(self, variant):
|
||||
url = str(variant.get("url") or "").strip()
|
||||
if not self._is_public_http_url(url):
|
||||
return {"ok": False, "reason": "invalid_url"}
|
||||
cache_key = hashlib.sha256(url.encode("utf-8")).hexdigest()
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
cached = self._media_meta_cache.get(cache_key)
|
||||
if cached and now - cached[0] <= self.full_probe_cache_ttl:
|
||||
return dict(cached[1])
|
||||
|
||||
client = self._new_session()
|
||||
response = None
|
||||
result = {"ok": False, "reason": "probe_failed"}
|
||||
try:
|
||||
headers = self._media_headers(variant.get("page_url"))
|
||||
head_headers = dict(headers)
|
||||
head_headers["Range"] = "bytes=0-131071"
|
||||
response = client.get(
|
||||
url,
|
||||
headers=head_headers,
|
||||
timeout=(self.speed_probe_timeout, max(self.speed_probe_timeout, 8)),
|
||||
allow_redirects=True,
|
||||
verify=self.verify_tls,
|
||||
stream=True,
|
||||
)
|
||||
final_url = str(response.url or url)
|
||||
if not self._is_public_http_url(final_url):
|
||||
raise RuntimeError("媒体跳转到非公网地址")
|
||||
content_type = str(response.headers.get("Content-Type") or "").lower()
|
||||
head = self._read_prefix(response, 131072)
|
||||
status = int(response.status_code)
|
||||
total = self._response_total_bytes(response.headers, len(head))
|
||||
duration = self._mp4_duration_seconds(head)
|
||||
has_ftyp = b"ftyp" in head[:64]
|
||||
if (
|
||||
duration is None
|
||||
and total > 1048576
|
||||
and 200 <= status < 400
|
||||
and "image/" not in content_type
|
||||
):
|
||||
response.close()
|
||||
response = None
|
||||
tail_headers = dict(headers)
|
||||
tail_headers["Range"] = "bytes=%d-%d" % (
|
||||
max(0, total - 1048576),
|
||||
total - 1,
|
||||
)
|
||||
response = client.get(
|
||||
final_url,
|
||||
headers=tail_headers,
|
||||
timeout=(self.speed_probe_timeout, max(self.speed_probe_timeout, 8)),
|
||||
allow_redirects=True,
|
||||
verify=self.verify_tls,
|
||||
stream=True,
|
||||
)
|
||||
tail_url = str(response.url or final_url)
|
||||
if not self._is_public_http_url(tail_url):
|
||||
raise RuntimeError("媒体尾部跳转到非公网地址")
|
||||
duration = self._mp4_duration_seconds(
|
||||
self._read_prefix(response, 1048576)
|
||||
)
|
||||
minimum = float(self.min_direct_minutes * 60)
|
||||
result = {
|
||||
"ok": bool(
|
||||
200 <= status < 400
|
||||
and has_ftyp
|
||||
and not content_type.startswith(("image/", "text/html"))
|
||||
and duration is not None
|
||||
and duration >= minimum
|
||||
),
|
||||
"status": status,
|
||||
"content_type": content_type,
|
||||
"bytes_total": total,
|
||||
"duration_seconds": duration,
|
||||
"duration_minutes": round(duration / 60.0, 2) if duration else 0,
|
||||
"minimum_minutes": self.min_direct_minutes,
|
||||
"has_ftyp": has_ftyp,
|
||||
}
|
||||
if duration is None:
|
||||
result["reason"] = "duration_unknown"
|
||||
elif duration < minimum:
|
||||
result["reason"] = "preview_too_short"
|
||||
elif not has_ftyp:
|
||||
result["reason"] = "not_mp4"
|
||||
except Exception as exc:
|
||||
result = {"ok": False, "reason": self._clean_text(exc) or "probe_failed"}
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
client.close()
|
||||
with self._lock:
|
||||
self._media_meta_cache[cache_key] = (time.time(), dict(result))
|
||||
self._trim_timed_cache(self._media_meta_cache, 128)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _read_prefix(response, maximum):
|
||||
body = bytearray()
|
||||
for chunk in response.iter_content(16384):
|
||||
if not chunk:
|
||||
continue
|
||||
remaining = maximum - len(body)
|
||||
if remaining <= 0:
|
||||
break
|
||||
body.extend(chunk[:remaining])
|
||||
if len(body) >= maximum:
|
||||
break
|
||||
return bytes(body)
|
||||
|
||||
@staticmethod
|
||||
def _response_total_bytes(headers, fallback=0):
|
||||
content_range = str(headers.get("Content-Range") or "")
|
||||
match = re.search(r"/([0-9]+)$", content_range)
|
||||
if match:
|
||||
return int(match.group(1))
|
||||
try:
|
||||
return int(headers.get("Content-Length") or fallback or 0)
|
||||
except (TypeError, ValueError):
|
||||
return int(fallback or 0)
|
||||
|
||||
@staticmethod
|
||||
def _mp4_duration_seconds(raw):
|
||||
body = raw or b""
|
||||
offset = 0
|
||||
while True:
|
||||
marker = body.find(b"mvhd", offset)
|
||||
if marker < 0:
|
||||
return None
|
||||
start = marker + 4
|
||||
if start + 20 > len(body):
|
||||
return None
|
||||
version = body[start]
|
||||
try:
|
||||
if version == 0:
|
||||
timescale = struct.unpack(">I", body[start + 12 : start + 16])[0]
|
||||
duration = struct.unpack(">I", body[start + 16 : start + 20])[0]
|
||||
elif version == 1 and start + 32 <= len(body):
|
||||
timescale = struct.unpack(">I", body[start + 20 : start + 24])[0]
|
||||
duration = struct.unpack(">Q", body[start + 24 : start + 32])[0]
|
||||
else:
|
||||
offset = marker + 4
|
||||
continue
|
||||
except struct.error:
|
||||
return None
|
||||
if timescale and duration:
|
||||
return float(duration) / float(timescale)
|
||||
offset = marker + 4
|
||||
|
||||
@staticmethod
|
||||
def _resolver_code_candidates(code):
|
||||
value = re.sub(r"\s+", "", str(code or "")).upper()
|
||||
candidates = []
|
||||
match = re.match(r"^FC2[-_]?([0-9]{5,})$", value)
|
||||
if match:
|
||||
candidates.append("FC2PPV-" + match.group(1))
|
||||
candidates.append(str(code or "").strip())
|
||||
result = []
|
||||
for item in candidates:
|
||||
if item and item not in result:
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _usable_cached_variants(cached):
|
||||
now = time.time() + 5
|
||||
return [
|
||||
item
|
||||
for item in cached or []
|
||||
if not item.get("expires_at") or item.get("expires_at") > now
|
||||
]
|
||||
|
||||
def _normalize_variant(self, raw, index):
|
||||
if not isinstance(raw, dict):
|
||||
return None
|
||||
url = str(
|
||||
raw.get("sourceUrl")
|
||||
or raw.get("source_url")
|
||||
or raw.get("playUrl")
|
||||
or raw.get("url")
|
||||
or ""
|
||||
).strip()
|
||||
is_data_hls = url.lower().startswith(
|
||||
"data:application/vnd.apple.mpegurl"
|
||||
)
|
||||
if not is_data_hls and not self._is_public_http_url(url):
|
||||
return None
|
||||
source_type = self._clean_text(raw.get("sourceType")).lower()
|
||||
variant_name = self._clean_text(raw.get("variant")).lower()
|
||||
quality = self._clean_text(raw.get("quality"))
|
||||
label = self._clean_text(raw.get("label") or variant_name or "线路")
|
||||
transport = "hls" if is_data_hls or "mpegurl" in source_type else "progressive"
|
||||
container = "mp4" if "mp4" in source_type or re.search(r"\.mp4(?:$|[?#])", url, re.I) else ("hls" if transport == "hls" else "unknown")
|
||||
height = self._quality_height(quality + " " + label)
|
||||
bitrate = self._number(raw.get("bitrate"))
|
||||
expires_at = self._expiry_epoch(raw.get("expiresAt") or raw.get("expires_at"))
|
||||
if expires_at and expires_at <= time.time() + 5:
|
||||
return None
|
||||
fingerprint_source = "|".join(
|
||||
(variant_name, label, source_type, quality, str(index))
|
||||
)
|
||||
fingerprint = hashlib.sha256(
|
||||
fingerprint_source.encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
return {
|
||||
"url": url,
|
||||
"source_type": source_type,
|
||||
"variant": variant_name,
|
||||
"quality": quality,
|
||||
"label": label,
|
||||
"transport": transport,
|
||||
"container": container,
|
||||
"height": height,
|
||||
"bitrate": bitrate,
|
||||
"expires_at": expires_at,
|
||||
"page_url": str(raw.get("pageUrl") or raw.get("page_url") or ""),
|
||||
"index": index,
|
||||
"fingerprint": fingerprint,
|
||||
}
|
||||
|
||||
def _sort_variants(self, variants, mode):
|
||||
if mode == "speed":
|
||||
return sorted(variants, key=self._speed_sort_key)
|
||||
return sorted(variants, key=self._quality_sort_key)
|
||||
|
||||
def _quality_sort_key(self, item):
|
||||
name = str(item.get("variant") or "").lower()
|
||||
original = 2 if name == "original" else (1 if "original" in name else 0)
|
||||
transport = 1 if item.get("container") == "mp4" else 0
|
||||
return (
|
||||
-original,
|
||||
-int(item.get("height") or 0),
|
||||
-int(item.get("bitrate") or 0),
|
||||
-transport,
|
||||
int(item.get("index") or 0),
|
||||
)
|
||||
|
||||
def _speed_sort_key(self, item):
|
||||
health = self._health_for_variant(item)
|
||||
state = 0 if health and health.get("ok") else (1 if not health else 2)
|
||||
rtt = int(health.get("rtt_ms") or 999999) if health else 999999
|
||||
transport = 0 if item.get("container") == "mp4" else 1
|
||||
return (
|
||||
state,
|
||||
rtt,
|
||||
transport,
|
||||
int(item.get("index") or 0),
|
||||
)
|
||||
|
||||
def _measure_variants(self, variants):
|
||||
targets = []
|
||||
seen = set()
|
||||
for item in variants:
|
||||
target = self._probe_target(item)
|
||||
if not target:
|
||||
continue
|
||||
key = self._health_key(item, target)
|
||||
if key in seen or self._health_get(key) is not None:
|
||||
continue
|
||||
seen.add(key)
|
||||
targets.append((item, target, key))
|
||||
if len(targets) >= 4:
|
||||
break
|
||||
if not targets:
|
||||
return
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(targets))) as executor:
|
||||
jobs = {
|
||||
executor.submit(self._probe_media_target, item, target): key
|
||||
for item, target, key in targets
|
||||
}
|
||||
for future in as_completed(jobs):
|
||||
key = jobs[future]
|
||||
try:
|
||||
result = future.result()
|
||||
except Exception:
|
||||
result = {
|
||||
"ok": False,
|
||||
"rtt_ms": 999999,
|
||||
"checked_at": time.time(),
|
||||
}
|
||||
self._health_set(key, result)
|
||||
|
||||
def _probe_media_target(self, item, target):
|
||||
started = time.monotonic()
|
||||
result = {"ok": False, "rtt_ms": 999999, "checked_at": time.time()}
|
||||
client = self._new_session()
|
||||
response = None
|
||||
try:
|
||||
headers = self._media_headers(item.get("page_url"))
|
||||
if item.get("transport") == "hls":
|
||||
range_headers = dict(headers)
|
||||
range_headers["Range"] = "bytes=0-1503"
|
||||
response = client.get(
|
||||
target,
|
||||
headers=range_headers,
|
||||
timeout=(self.speed_probe_timeout, self.speed_probe_timeout),
|
||||
allow_redirects=True,
|
||||
verify=self.verify_tls,
|
||||
stream=True,
|
||||
)
|
||||
final_url = str(response.url or target)
|
||||
if not self._is_public_http_url(final_url):
|
||||
raise RuntimeError("HLS 分片探测跳转到非公网地址")
|
||||
prefix = b""
|
||||
for chunk in response.iter_content(512):
|
||||
if chunk:
|
||||
prefix += chunk
|
||||
if len(prefix) >= 1504:
|
||||
prefix = prefix[:1504]
|
||||
break
|
||||
content_type = str(response.headers.get("Content-Type") or "")
|
||||
segment_kind = self._hls_segment_kind(prefix, content_type)
|
||||
result.update(
|
||||
{
|
||||
"method": "GET_RANGE_HLS",
|
||||
"rtt_ms": int((time.monotonic() - started) * 1000),
|
||||
"ok": 200 <= response.status_code < 400 and bool(segment_kind),
|
||||
"status": int(response.status_code),
|
||||
"content_type": content_type,
|
||||
"segment_kind": segment_kind,
|
||||
}
|
||||
)
|
||||
return result
|
||||
response = client.head(
|
||||
target,
|
||||
headers=headers,
|
||||
timeout=(self.speed_probe_timeout, self.speed_probe_timeout),
|
||||
allow_redirects=True,
|
||||
verify=self.verify_tls,
|
||||
)
|
||||
if response.status_code in (400, 403, 405, 501):
|
||||
response.close()
|
||||
response = None
|
||||
range_headers = dict(headers)
|
||||
range_headers["Range"] = "bytes=0-0"
|
||||
response = client.get(
|
||||
target,
|
||||
headers=range_headers,
|
||||
timeout=(self.speed_probe_timeout, self.speed_probe_timeout),
|
||||
allow_redirects=True,
|
||||
verify=self.verify_tls,
|
||||
stream=True,
|
||||
)
|
||||
result["method"] = "GET_RANGE"
|
||||
else:
|
||||
result["method"] = "HEAD"
|
||||
final_url = str(response.url or target)
|
||||
if not self._is_public_http_url(final_url):
|
||||
raise RuntimeError("媒体探测跳转到非公网地址")
|
||||
result["rtt_ms"] = int((time.monotonic() - started) * 1000)
|
||||
result["ok"] = 200 <= response.status_code < 400
|
||||
result["status"] = int(response.status_code)
|
||||
result["accept_ranges"] = str(
|
||||
response.headers.get("Accept-Ranges") or ""
|
||||
)
|
||||
except Exception:
|
||||
result["rtt_ms"] = int((time.monotonic() - started) * 1000)
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
client.close()
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _hls_segment_kind(raw, content_type=""):
|
||||
body = raw or b""
|
||||
mime = str(content_type or "").lower()
|
||||
if not body or mime.startswith("image/"):
|
||||
return ""
|
||||
if body.startswith((b"\x89PNG\r\n\x1a\n", b"\xff\xd8\xff", b"GIF87a", b"GIF89a")):
|
||||
return ""
|
||||
maximum = min(188, len(body))
|
||||
for offset in range(maximum):
|
||||
if offset + 376 < len(body):
|
||||
if (
|
||||
body[offset] == 0x47
|
||||
and body[offset + 188] == 0x47
|
||||
and body[offset + 376] == 0x47
|
||||
):
|
||||
return "mpeg-ts"
|
||||
if any(marker in body[:64] for marker in (b"ftyp", b"styp", b"moof")):
|
||||
return "fmp4"
|
||||
return ""
|
||||
|
||||
def _probe_target(self, variant):
|
||||
url = str(variant.get("url") or "")
|
||||
if self._is_public_http_url(url):
|
||||
return url
|
||||
if variant.get("transport") == "hls":
|
||||
try:
|
||||
playlist = self._decode_data_playlist(url).decode("utf-8")
|
||||
for line in playlist.splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#") and self._is_public_http_url(line):
|
||||
return line
|
||||
except Exception:
|
||||
return ""
|
||||
return ""
|
||||
|
||||
def _health_for_variant(self, variant):
|
||||
target = self._probe_target(variant)
|
||||
if not target:
|
||||
return None
|
||||
return self._health_get(self._health_key(variant, target))
|
||||
|
||||
def _health_key(self, variant, target):
|
||||
return "%s:%s" % (
|
||||
variant.get("transport") or "unknown",
|
||||
(urlsplit(target).hostname or "").lower(),
|
||||
)
|
||||
|
||||
def _find_variant(self, variants, payload):
|
||||
fingerprint = str(payload.get("fingerprint") or "")
|
||||
for item in variants:
|
||||
if fingerprint and item.get("fingerprint") == fingerprint:
|
||||
return item
|
||||
variant_name = str(payload.get("variant") or "")
|
||||
transport = str(payload.get("transport") or "")
|
||||
if variant_name:
|
||||
for item in variants:
|
||||
if item.get("variant") != variant_name:
|
||||
continue
|
||||
if transport and item.get("transport") != transport:
|
||||
continue
|
||||
return item
|
||||
index = self._bounded_int(payload.get("index"), -1, -1, 1000)
|
||||
for item in variants:
|
||||
if item.get("index") == index:
|
||||
return item
|
||||
return None
|
||||
|
||||
def _variant_player(self, variant):
|
||||
url = str(variant.get("url") or "")
|
||||
referer = str(variant.get("page_url") or self.SOURCE_ORIGIN)
|
||||
if variant.get("transport") == "hls" and url.lower().startswith("data:"):
|
||||
playlist = self._decode_data_playlist(url)
|
||||
token = hashlib.sha256(playlist).hexdigest()[:24]
|
||||
with self._lock:
|
||||
self._playlist_cache[token] = (time.time(), playlist)
|
||||
self._trim_timed_cache(self._playlist_cache, 16)
|
||||
proxy_url = self._local_proxy_url("hls", token)
|
||||
return self._direct_player(proxy_url, "m3u8", referer)
|
||||
if not self._is_public_http_url(url):
|
||||
return self._player_error("media_rejected", "媒体地址无效")
|
||||
media_type = "m3u8" if variant.get("transport") == "hls" else "mp4"
|
||||
return self._direct_player(url, media_type, referer)
|
||||
|
||||
def _direct_player(self, url, media_type, referer):
|
||||
result = {
|
||||
"parse": 0,
|
||||
"jx": 0,
|
||||
"playUrl": "",
|
||||
"url": url,
|
||||
"header": self._media_headers(referer),
|
||||
"type": media_type,
|
||||
}
|
||||
if media_type == "m3u8":
|
||||
result["format"] = "application/x-mpegURL"
|
||||
return result
|
||||
|
||||
def _media_headers(self, referer=""):
|
||||
value = str(referer or self.SOURCE_ORIGIN).strip()
|
||||
if not self._is_public_http_url(value):
|
||||
value = self.SOURCE_ORIGIN
|
||||
return {
|
||||
"User-Agent": self.user_agent,
|
||||
"Referer": value,
|
||||
"Origin": self._origin(value),
|
||||
}
|
||||
|
||||
def _sort_magnets(self, raw_items):
|
||||
items = []
|
||||
seen = set()
|
||||
for index, raw in enumerate(raw_items or []):
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
magnet = self._normalize_magnet(raw.get("hash") or raw.get("magnet"))
|
||||
btih = self._extract_btih(magnet)
|
||||
if not btih or btih in seen:
|
||||
continue
|
||||
seen.add(btih)
|
||||
item = dict(raw)
|
||||
name = self._clean_text(item.get("name"))
|
||||
item["magnet"] = magnet
|
||||
item["is_subtitle"] = bool(item.get("cnsub")) or self._has_subtitle(name)
|
||||
item["is_hd"] = bool(item.get("hd")) or self._has_hd(name)
|
||||
item["size_value"] = self._number(item.get("size"))
|
||||
item["date_value"] = self._date_value(item.get("created_at"))
|
||||
item["files_value"] = int(self._number(item.get("files_count")))
|
||||
item["source_index"] = index
|
||||
items.append(item)
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda item: (
|
||||
0 if item.get("is_subtitle") else 1,
|
||||
0 if item.get("is_hd") else 1,
|
||||
-float(item.get("size_value") or 0),
|
||||
-int(item.get("date_value") or 0),
|
||||
-int(item.get("files_value") or 0),
|
||||
int(item.get("source_index") or 0),
|
||||
),
|
||||
)
|
||||
|
||||
def _magnet_label(self, item):
|
||||
flags = []
|
||||
if item.get("is_subtitle"):
|
||||
flags.append("中字")
|
||||
if item.get("is_hd"):
|
||||
flags.append("HD")
|
||||
size = self._format_source_size(item.get("size_value"))
|
||||
date = self._clean_text(item.get("created_at"))
|
||||
files = int(item.get("files_value") or 0)
|
||||
meta = [item for item in (size, date, "%d文件" % files if files else "") if item]
|
||||
name = self._clean_text(item.get("name")) or "磁力资源"
|
||||
prefix = " ".join("[%s]" % item for item in flags)
|
||||
return " | ".join(item for item in (prefix, " · ".join(meta), name) if item)
|
||||
|
||||
def _magnet_player(self, magnet):
|
||||
if not self.alist_api or not self.alist_token:
|
||||
if self.native_magnet_fallback:
|
||||
return {
|
||||
"parse": 0,
|
||||
"jx": 0,
|
||||
"playUrl": "",
|
||||
"url": "push://" + magnet,
|
||||
"header": {},
|
||||
}
|
||||
return self._player_error(
|
||||
"magnet_offline_not_configured",
|
||||
"磁力已收录;客户端原生磁力兜底已关闭,且未配置 AList 离线服务",
|
||||
)
|
||||
endpoint = self.alist_api + "/offline_download/" + quote(
|
||||
self.alist_token, safe=""
|
||||
)
|
||||
headers = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if self.alist_api_key:
|
||||
headers["X-API-KEY"] = self.alist_api_key
|
||||
response = self._session.post(
|
||||
endpoint,
|
||||
params={"ac": "gui"},
|
||||
json={"url": magnet, "type": "magnet", "source": self.alist_source},
|
||||
headers=headers,
|
||||
timeout=(10, self.alist_timeout),
|
||||
verify=self.verify_tls,
|
||||
)
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
body = {}
|
||||
if response.status_code >= 400:
|
||||
detail = self._clean_text(
|
||||
body.get("detail") or body.get("message") or response.text
|
||||
)
|
||||
return self._player_error("offline_http_%d" % response.status_code, detail)
|
||||
items = self._offline_video_items(body)
|
||||
if not items:
|
||||
detail = self._clean_text(
|
||||
body.get("detail") or body.get("message") or "离线任务尚未返回视频文件"
|
||||
)
|
||||
return self._player_error("offline_pending", detail)
|
||||
chosen = items[0]
|
||||
return {
|
||||
"parse": 0,
|
||||
"jx": 0,
|
||||
"playUrl": "",
|
||||
"url": chosen.get("url") or "",
|
||||
"header": chosen.get("header") if isinstance(chosen.get("header"), dict) else {},
|
||||
}
|
||||
|
||||
def _offline_video_items(self, body):
|
||||
items = []
|
||||
seen = set()
|
||||
for group in body.get("list") or []:
|
||||
if not isinstance(group, dict):
|
||||
continue
|
||||
for raw in group.get("items") or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
url = str(raw.get("url") or "").strip()
|
||||
if not self._is_public_http_url(url) or url in seen:
|
||||
continue
|
||||
name = self._clean_text(
|
||||
raw.get("title") or raw.get("name") or raw.get("path")
|
||||
)
|
||||
low = name.lower()
|
||||
if name and not any(ext in low for ext in self.VIDEO_EXTS):
|
||||
continue
|
||||
seen.add(url)
|
||||
bad = bool(
|
||||
re.search(
|
||||
r"sample|preview|trailer|广告|廣告|预告|預告|样片|樣片|试看|試看",
|
||||
name,
|
||||
re.I,
|
||||
)
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"url": url,
|
||||
"name": name,
|
||||
"size": self._number(raw.get("size")),
|
||||
"subtitle": self._has_subtitle(name),
|
||||
"bad": bad,
|
||||
"header": raw.get("header") or {},
|
||||
}
|
||||
)
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda item: (
|
||||
0 if item.get("subtitle") else 1,
|
||||
1 if item.get("bad") else 0,
|
||||
-float(item.get("size") or 0),
|
||||
item.get("name") or "",
|
||||
),
|
||||
)
|
||||
|
||||
def _api(self, path, query, ttl, isolated=False):
|
||||
url = self.API_BASE.rstrip("/") + "/" + str(path or "").lstrip("/")
|
||||
pairs = []
|
||||
for key in sorted((query or {}).keys()):
|
||||
value = query.get(key)
|
||||
if value is None or value == "":
|
||||
continue
|
||||
pairs.append("%s=%s" % (quote(str(key), safe=""), quote(str(value), safe="")))
|
||||
if pairs:
|
||||
url += "?" + "&".join(pairs)
|
||||
cache_key = "api:" + url
|
||||
cached = self._cache_get(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"jdsignature": self._signature(),
|
||||
"Referer": self.SOURCE_ORIGIN,
|
||||
}
|
||||
client = self._new_session() if isolated else self._session
|
||||
try:
|
||||
body = self._request_json_url(
|
||||
url, headers, self.max_json_bytes, session=client
|
||||
)
|
||||
finally:
|
||||
if isolated:
|
||||
client.close()
|
||||
if body.get("success") != 1:
|
||||
raise RuntimeError(self._clean_text(body.get("message")) or "API 返回失败")
|
||||
data = body.get("data") or {}
|
||||
self._cache_set(cache_key, data, ttl)
|
||||
return data
|
||||
|
||||
def _request_json_url(self, url, headers, max_bytes, session=None):
|
||||
if not self._is_public_http_url(url):
|
||||
raise RuntimeError("已阻止非公网请求")
|
||||
client = session or self._session
|
||||
last_error = None
|
||||
for attempt in range(2):
|
||||
response = None
|
||||
try:
|
||||
response = client.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=(min(self.timeout, 10), self.timeout),
|
||||
allow_redirects=True,
|
||||
verify=self.verify_tls,
|
||||
stream=True,
|
||||
)
|
||||
final_url = str(response.url or url)
|
||||
if not self._is_public_http_url(final_url):
|
||||
raise RuntimeError("已阻止外域私网跳转")
|
||||
raw = self._read_bounded(response, max_bytes)
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
if self._looks_like_challenge(response.status_code, text):
|
||||
raise WafBlockedError(
|
||||
"Cloudflare 挑战需要可见浏览器或站点授权接口"
|
||||
)
|
||||
if response.status_code == 429:
|
||||
raise RuntimeError("rate_limited")
|
||||
if response.status_code >= 500 and attempt == 0:
|
||||
time.sleep(0.2)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
parsed = json.loads(text)
|
||||
if not isinstance(parsed, dict):
|
||||
raise RuntimeError("JSON 顶层不是对象")
|
||||
return parsed
|
||||
except WafBlockedError:
|
||||
raise
|
||||
except (requests.RequestException, ValueError, RuntimeError) as exc:
|
||||
last_error = exc
|
||||
if attempt == 0 and "rate_limited" not in str(exc):
|
||||
time.sleep(0.15)
|
||||
continue
|
||||
finally:
|
||||
if response is not None:
|
||||
response.close()
|
||||
raise RuntimeError("网络请求失败: %s" % last_error)
|
||||
|
||||
def _load_all_tags(self):
|
||||
cached = self._cache_get("all-tags")
|
||||
if cached is not None:
|
||||
return cached
|
||||
result = {}
|
||||
with ThreadPoolExecutor(max_workers=4) as executor:
|
||||
jobs = {
|
||||
executor.submit(
|
||||
self._api, "/v1/tags", {"type": content_type}, self.tag_cache_ttl, True
|
||||
): content_type
|
||||
for content_type in ("0", "1", "2", "3")
|
||||
}
|
||||
for future in as_completed(jobs):
|
||||
content_type = jobs[future]
|
||||
try:
|
||||
result[content_type] = future.result().get("tags") or []
|
||||
except Exception:
|
||||
result[content_type] = []
|
||||
self._cache_set("all-tags", result, self.tag_cache_ttl)
|
||||
return result
|
||||
|
||||
def _page_result(
|
||||
self, raw_movies, page, expected_limit, pageable, direct_check=False
|
||||
):
|
||||
items = []
|
||||
seen = set()
|
||||
raw_movies = raw_movies or []
|
||||
source_count = len(raw_movies)
|
||||
if direct_check:
|
||||
raw_movies = self._filter_direct_movies(raw_movies)
|
||||
for raw in raw_movies:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
movie_id = self._clean_text(raw.get("id"))
|
||||
if not movie_id or movie_id in seen:
|
||||
continue
|
||||
if not self.show_unplayable and not self._has_declared_resource(raw):
|
||||
continue
|
||||
seen.add(movie_id)
|
||||
items.append(self._movie_card(raw))
|
||||
pagecount = (
|
||||
page + 1
|
||||
if pageable and source_count >= expected_limit
|
||||
else page
|
||||
)
|
||||
if not pageable:
|
||||
pagecount = 1
|
||||
page = 1
|
||||
limit = expected_limit or len(items) or 1
|
||||
return {
|
||||
"list": items,
|
||||
"page": page,
|
||||
"pagecount": pagecount,
|
||||
"limit": limit,
|
||||
"total": pagecount * limit,
|
||||
}
|
||||
|
||||
def _filter_direct_movies(self, raw_movies):
|
||||
rows = [item for item in raw_movies if isinstance(item, dict)]
|
||||
if not rows:
|
||||
return []
|
||||
accepted = {}
|
||||
candidates = []
|
||||
for item in rows:
|
||||
movie_id = self._clean_text(item.get("id"))
|
||||
if not movie_id:
|
||||
continue
|
||||
if self._number(item.get("magnets_count")) > 0:
|
||||
kept = dict(item)
|
||||
kept["_resource_gate"] = "magnet"
|
||||
accepted[movie_id] = kept
|
||||
elif len(candidates) < self.direct_probe_limit:
|
||||
candidates.append(item)
|
||||
if not candidates:
|
||||
return [accepted[mid] for mid in [self._clean_text(x.get("id")) for x in rows] if mid in accepted]
|
||||
with ThreadPoolExecutor(max_workers=min(4, len(candidates))) as executor:
|
||||
jobs = {
|
||||
executor.submit(self._raw_has_progressive_direct, item): self._clean_text(
|
||||
item.get("id")
|
||||
)
|
||||
for item in candidates
|
||||
}
|
||||
for future in as_completed(jobs):
|
||||
movie_id = jobs[future]
|
||||
try:
|
||||
if movie_id and future.result():
|
||||
kept = next(
|
||||
item
|
||||
for item in candidates
|
||||
if self._clean_text(item.get("id")) == movie_id
|
||||
)
|
||||
kept = dict(kept)
|
||||
kept["_resource_gate"] = "full_direct"
|
||||
kept["_full_direct_verified"] = True
|
||||
accepted[movie_id] = kept
|
||||
except Exception:
|
||||
pass
|
||||
return [
|
||||
accepted[mid]
|
||||
for mid in [self._clean_text(item.get("id")) for item in rows]
|
||||
if mid in accepted
|
||||
]
|
||||
|
||||
def _raw_has_progressive_direct(self, raw):
|
||||
if not raw.get("can_play"):
|
||||
return False
|
||||
code = self._clean_text(
|
||||
raw.get("number") or raw.get("number_letter") or raw.get("id")
|
||||
)
|
||||
if not code:
|
||||
return False
|
||||
variants = self._resolve_variants(code, isolated=True)
|
||||
return bool(self._full_direct_variants(variants, raw.get("duration")))
|
||||
|
||||
def _movie_card(self, raw):
|
||||
movie_id = self._clean_text(raw.get("id"))
|
||||
number = self._clean_text(
|
||||
raw.get("number") or raw.get("number_letter") or movie_id
|
||||
)
|
||||
title = self._clean_text(
|
||||
raw.get("title") or raw.get("origin_title") or number
|
||||
)
|
||||
remarks = []
|
||||
if raw.get("_full_direct_verified"):
|
||||
remarks.append("完整版直连")
|
||||
magnets = int(self._number(raw.get("magnets_count")))
|
||||
if magnets:
|
||||
remarks.append("磁力%d" % magnets)
|
||||
if raw.get("has_cnsub") or self._number(raw.get("play_subtitle")) > 0:
|
||||
remarks.append("中字")
|
||||
if not self._has_declared_resource(raw):
|
||||
remarks.append("无资源")
|
||||
score = self._number(raw.get("score"))
|
||||
if score:
|
||||
remarks.append("%.1f分" % score)
|
||||
return {
|
||||
"vod_id": movie_id,
|
||||
"vod_name": (number + " " + title).strip(),
|
||||
"vod_pic": self._image_proxy_url(
|
||||
raw.get("thumb_url")
|
||||
or raw.get("cover_url")
|
||||
or self._first_preview_image(raw)
|
||||
or ""
|
||||
)
|
||||
or self.DEFAULT_PIC,
|
||||
"vod_remarks": " · ".join(remarks) or self._duration(raw.get("duration")),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _has_declared_resource(raw):
|
||||
if not isinstance(raw, dict):
|
||||
return False
|
||||
magnets_count = Spider._number(raw.get("magnets_count"))
|
||||
return bool(
|
||||
raw.get("can_play")
|
||||
or raw.get("has_preview_video")
|
||||
or raw.get("preview_video_url")
|
||||
or raw.get("play_sources")
|
||||
or magnets_count > 0
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _first_preview_image(raw):
|
||||
if not isinstance(raw, dict):
|
||||
return ""
|
||||
for item in raw.get("preview_images") or []:
|
||||
if isinstance(item, dict):
|
||||
value = item.get("large_url") or item.get("thumb_url") or item.get("url")
|
||||
else:
|
||||
value = item
|
||||
value = str(value or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _decoded_image(self, url):
|
||||
if not self._is_public_http_url(url):
|
||||
raise RuntimeError("图片地址无效")
|
||||
key = hashlib.sha256(url.encode("utf-8")).hexdigest()
|
||||
with self._lock:
|
||||
cached = self._image_cache.get(key)
|
||||
if cached and time.time() - cached[0] <= self.image_cache_ttl:
|
||||
return cached[1], cached[2]
|
||||
response = self._session.get(
|
||||
url,
|
||||
headers={
|
||||
"Accept": "image/avif,image/webp,image/apng,image/*,*/*;q=0.8",
|
||||
"Referer": self.SOURCE_ORIGIN,
|
||||
"User-Agent": self.user_agent,
|
||||
},
|
||||
timeout=(min(self.timeout, 10), self.timeout),
|
||||
allow_redirects=True,
|
||||
verify=self.verify_tls,
|
||||
stream=True,
|
||||
)
|
||||
try:
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise RuntimeError("图片 HTTP %d" % response.status_code)
|
||||
raw = self._read_bounded(response, self.max_image_bytes)
|
||||
finally:
|
||||
response.close()
|
||||
decoded, mime = self._decode_image_bytes(raw)
|
||||
with self._lock:
|
||||
self._image_cache[key] = (time.time(), decoded, mime)
|
||||
self._trim_timed_cache(self._image_cache, 32)
|
||||
return decoded, mime
|
||||
|
||||
def _decode_image_bytes(self, raw):
|
||||
mime = self._image_mime(raw)
|
||||
if mime:
|
||||
return raw, mime
|
||||
candidates = []
|
||||
if raw:
|
||||
key = raw[0]
|
||||
candidates.append(bytes(value ^ key for value in raw[1:]))
|
||||
for skip in (0, 1, 2):
|
||||
if len(raw) > skip:
|
||||
candidates.append(bytes(value ^ 0x7F for value in raw[skip:]))
|
||||
for candidate in candidates:
|
||||
mime = self._image_mime(candidate)
|
||||
if mime:
|
||||
return candidate, mime
|
||||
raise RuntimeError("未知图片编码")
|
||||
|
||||
@staticmethod
|
||||
def _image_mime(raw):
|
||||
if raw.startswith(b"\xff\xd8\xff"):
|
||||
return "image/jpeg"
|
||||
if raw.startswith(b"\x89PNG\r\n\x1a\n"):
|
||||
return "image/png"
|
||||
if raw.startswith((b"GIF87a", b"GIF89a")):
|
||||
return "image/gif"
|
||||
if raw.startswith(b"BM"):
|
||||
return "image/bmp"
|
||||
if len(raw) >= 12 and raw[:4] == b"RIFF" and raw[8:12] == b"WEBP":
|
||||
return "image/webp"
|
||||
return ""
|
||||
|
||||
def _decode_data_playlist(self, value):
|
||||
text = str(value or "")
|
||||
comma = text.find(",")
|
||||
if comma <= 0:
|
||||
raise RuntimeError("HLS data URI 缺少 payload")
|
||||
meta = text[:comma].lower()
|
||||
payload = text[comma + 1 :]
|
||||
if len(payload) > self.max_playlist_bytes * 2:
|
||||
raise RuntimeError("HLS data URI 超过上限")
|
||||
if ";base64" in meta:
|
||||
raw = base64.b64decode(payload)
|
||||
else:
|
||||
raw = unquote(payload).encode("utf-8")
|
||||
if len(raw) > self.max_playlist_bytes:
|
||||
raise RuntimeError("HLS 播放列表超过上限")
|
||||
source = raw.decode("utf-8", errors="strict")
|
||||
if not source.lstrip().startswith("#EXTM3U"):
|
||||
raise RuntimeError("HLS 播放列表缺少 EXTM3U")
|
||||
for line in source.splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#"):
|
||||
continue
|
||||
if not self._is_public_http_url(line):
|
||||
raise RuntimeError("HLS 含非公网或相对分片地址")
|
||||
return source.encode("utf-8")
|
||||
|
||||
def _image_proxy_url(self, url):
|
||||
value = str(url or "").strip()
|
||||
if not self._is_public_http_url(value):
|
||||
return ""
|
||||
return self._local_proxy_url("image", self._pack_text(value))
|
||||
|
||||
def _local_proxy_url(self, kind, token):
|
||||
site_key = quote(
|
||||
str(getattr(self, "siteKey", "") or self.proxy_site_key or "catemby"),
|
||||
safe="",
|
||||
)
|
||||
base = self._proxy_base_url()
|
||||
separator = "&" if "?" in base else "?"
|
||||
return "%s%ssiteKey=%s&kind=%s&token=%s" % (
|
||||
base,
|
||||
separator,
|
||||
site_key,
|
||||
quote(kind, safe=""),
|
||||
quote(token, safe=""),
|
||||
)
|
||||
|
||||
def _proxy_base_url(self):
|
||||
inherited = getattr(super(), "getProxyUrl", None)
|
||||
if callable(inherited):
|
||||
try:
|
||||
value = str(inherited(True) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
except Exception:
|
||||
pass
|
||||
if CatVodProxy is not None:
|
||||
return str(CatVodProxy.getUrl(True)) + "?do=py"
|
||||
return "http://127.0.0.1:9978/proxy?do=py"
|
||||
|
||||
def _new_session(self):
|
||||
session = requests.Session()
|
||||
session.trust_env = self.trust_env
|
||||
session.headers.update(
|
||||
{
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,zh-TW;q=0.8,en;q=0.5",
|
||||
}
|
||||
)
|
||||
if self.proxy:
|
||||
session.proxies.update({"http": self.proxy, "https": self.proxy})
|
||||
return session
|
||||
|
||||
def _reset_session(self):
|
||||
if self._session is not None:
|
||||
try:
|
||||
self._session.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._session = self._new_session()
|
||||
|
||||
@staticmethod
|
||||
def _signature_static(token, salt):
|
||||
timestamp = str(int(time.time()))
|
||||
digest = hashlib.md5((timestamp + salt).encode("utf-8")).hexdigest()
|
||||
return timestamp + "." + token + "." + digest
|
||||
|
||||
def _signature(self):
|
||||
return self._signature_static(self.SIGNATURE_TOKEN, self.SIGNATURE_SALT)
|
||||
|
||||
@staticmethod
|
||||
def _read_bounded(response, maximum):
|
||||
content_length = response.headers.get("Content-Length")
|
||||
if content_length:
|
||||
try:
|
||||
if int(content_length) > maximum:
|
||||
raise RuntimeError("响应体超过上限")
|
||||
except ValueError:
|
||||
pass
|
||||
chunks = []
|
||||
total = 0
|
||||
for chunk in response.iter_content(65536):
|
||||
if not chunk:
|
||||
continue
|
||||
total += len(chunk)
|
||||
if total > maximum:
|
||||
raise RuntimeError("响应体超过上限")
|
||||
chunks.append(chunk)
|
||||
return b"".join(chunks)
|
||||
|
||||
def _looks_like_challenge(self, status, text):
|
||||
if int(status or 0) not in (403, 429, 503):
|
||||
return False
|
||||
lower = str(text or "").lower()
|
||||
return any(marker in lower for marker in self.CHALLENGE_MARKERS)
|
||||
|
||||
def _cache_get(self, key):
|
||||
with self._lock:
|
||||
item = self._cache.get(key)
|
||||
if not item:
|
||||
return None
|
||||
if item[0] < time.time():
|
||||
self._cache.pop(key, None)
|
||||
return None
|
||||
return item[1]
|
||||
|
||||
def _cache_set(self, key, value, ttl):
|
||||
if ttl <= 0:
|
||||
return
|
||||
with self._lock:
|
||||
self._cache[key] = (time.time() + ttl, value, time.time())
|
||||
if len(self._cache) > 128:
|
||||
oldest = min(self._cache, key=lambda item: self._cache[item][2])
|
||||
self._cache.pop(oldest, None)
|
||||
|
||||
def _health_get(self, key):
|
||||
with self._lock:
|
||||
item = self._health.get(key)
|
||||
if not item or time.time() - item[0] > self.health_cache_ttl:
|
||||
self._health.pop(key, None)
|
||||
return None
|
||||
return item[1]
|
||||
|
||||
def _health_set(self, key, value):
|
||||
with self._lock:
|
||||
self._health[key] = (time.time(), value)
|
||||
self._trim_timed_cache(self._health, 24)
|
||||
|
||||
def _playlist_cache_get(self, token):
|
||||
with self._lock:
|
||||
item = self._playlist_cache.get(token)
|
||||
proxy_ttl = max(30, self.resolver_cache_ttl)
|
||||
if not item or time.time() - item[0] > proxy_ttl:
|
||||
self._playlist_cache.pop(token, None)
|
||||
return None
|
||||
return item[1]
|
||||
|
||||
@staticmethod
|
||||
def _trim_timed_cache(cache, maximum):
|
||||
while len(cache) > maximum:
|
||||
oldest = min(cache, key=lambda key: cache[key][0])
|
||||
cache.pop(oldest, None)
|
||||
|
||||
@staticmethod
|
||||
def _pack_text(value):
|
||||
raw = str(value or "").encode("utf-8")
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
@staticmethod
|
||||
def _unpack_text(value):
|
||||
token = str(value or "").strip()
|
||||
token += "=" * (-len(token) % 4)
|
||||
try:
|
||||
return base64.urlsafe_b64decode(token.encode("ascii")).decode("utf-8")
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _pack_play_id(self, payload):
|
||||
raw = json.dumps(
|
||||
payload or {}, ensure_ascii=False, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
return self.PLAY_PREFIX + base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
|
||||
def _unpack_play_id(self, value):
|
||||
text = str(value or "").strip()
|
||||
if not text.startswith(self.PLAY_PREFIX):
|
||||
return {}
|
||||
token = text[len(self.PLAY_PREFIX) :]
|
||||
token += "=" * (-len(token) % 4)
|
||||
try:
|
||||
data = json.loads(base64.urlsafe_b64decode(token).decode("utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _filter(key, name, values):
|
||||
return {
|
||||
"key": key,
|
||||
"name": name,
|
||||
"value": [{"n": item[0], "v": item[1]} for item in values],
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_dict(value):
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if not value:
|
||||
return {}
|
||||
try:
|
||||
parsed = json.loads(str(value))
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _choice(value, options, default):
|
||||
allowed = {item[1] for item in options}
|
||||
text = str(value or "").strip()
|
||||
return text if text in allowed else default
|
||||
|
||||
@staticmethod
|
||||
def _safe_filter_value(value):
|
||||
text = str(value or "").strip()
|
||||
return text if re.match(r"^[A-Za-z0-9._:-]{1,80}$", text) else ""
|
||||
|
||||
@staticmethod
|
||||
def _safe_play_name(value, limit=120):
|
||||
text = re.sub(r"\s+", " ", str(value or ""))
|
||||
text = text.replace("#", " ").replace("$", " ").strip()
|
||||
return text[:limit] or "播放"
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value):
|
||||
return re.sub(r"\s+", " ", str(value or "")).strip()
|
||||
|
||||
@staticmethod
|
||||
def _number(value):
|
||||
try:
|
||||
return float(value or 0)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _bounded_int(value, default, minimum, maximum):
|
||||
try:
|
||||
number = int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return max(minimum, min(maximum, number))
|
||||
|
||||
@staticmethod
|
||||
def _bool(value, default=False):
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return str(value).strip().lower() not in ("0", "false", "no", "off", "")
|
||||
|
||||
@staticmethod
|
||||
def _page(value):
|
||||
try:
|
||||
return max(1, int(value))
|
||||
except (TypeError, ValueError):
|
||||
return 1
|
||||
|
||||
def _normalize_detail_id(self, value):
|
||||
text = str(value or "").strip()
|
||||
if text.startswith("atvp_detail:"):
|
||||
text = text[len("atvp_detail:") :]
|
||||
return text if re.match(r"^[A-Za-z0-9._:-]{1,120}$", text) else ""
|
||||
|
||||
@staticmethod
|
||||
def _quality_height(value):
|
||||
numbers = [int(item) for item in re.findall(r"(?<!\d)(2160|1440|1080|720|540|480)(?:p)?", str(value or ""), re.I)]
|
||||
return max(numbers) if numbers else 0
|
||||
|
||||
@staticmethod
|
||||
def _expiry_epoch(value):
|
||||
if value is None or value == "":
|
||||
return 0
|
||||
try:
|
||||
number = float(value)
|
||||
if number > 100000000000:
|
||||
number /= 1000.0
|
||||
return number
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
text = str(value).strip().replace("Z", "+00:00")
|
||||
try:
|
||||
return datetime.fromisoformat(text).timestamp()
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _date_value(value):
|
||||
digits = re.sub(r"\D", "", str(value or ""))[:14]
|
||||
try:
|
||||
return int(digits.ljust(14, "0"))
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
@staticmethod
|
||||
def _has_subtitle(value):
|
||||
text = re.sub(r"\s+", "", str(value or ""))
|
||||
return bool(re.search(r"中文字幕|简体中文|繁体中文|中字|字幕|CHS|CHT|SUB", text, re.I))
|
||||
|
||||
@staticmethod
|
||||
def _has_hd(value):
|
||||
return bool(re.search(r"(?:^|[^A-Z0-9])(HD|FHD|UHD|4K|2160P|1080P|720P)(?:[^A-Z0-9]|$)", str(value or ""), re.I))
|
||||
|
||||
@staticmethod
|
||||
def _extract_btih(value):
|
||||
text = str(value or "")
|
||||
match = re.search(r"btih:([A-F0-9]{40}|[A-Z2-7]{32})", text, re.I)
|
||||
if match:
|
||||
return match.group(1).upper()
|
||||
if re.match(r"^(?:[A-F0-9]{40}|[A-Z2-7]{32})$", text.strip(), re.I):
|
||||
return text.strip().upper()
|
||||
return ""
|
||||
|
||||
def _normalize_magnet(self, value):
|
||||
btih = self._extract_btih(value)
|
||||
return "magnet:?xt=urn:btih:" + btih if btih else ""
|
||||
|
||||
@staticmethod
|
||||
def _format_source_size(value):
|
||||
size_mb = float(value or 0)
|
||||
if size_mb <= 0:
|
||||
return ""
|
||||
if size_mb >= 1024:
|
||||
return "%.2fGB" % (size_mb / 1024.0)
|
||||
return "%dMB" % int(size_mb)
|
||||
|
||||
@staticmethod
|
||||
def _duration(value):
|
||||
try:
|
||||
number = float(value or 0)
|
||||
return "%d分钟" % int(round(number)) if number > 0 else ""
|
||||
except (TypeError, ValueError):
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _area_name(value):
|
||||
return {"0": "日本", "1": "日本", "2": "欧美", "3": "FC2"}.get(str(value), "")
|
||||
|
||||
def _variant_label(self, item):
|
||||
parts = []
|
||||
if item.get("height"):
|
||||
parts.append("%dP" % int(item["height"]))
|
||||
variant = str(item.get("variant") or "").lower()
|
||||
if "original" in variant:
|
||||
parts.append("原版")
|
||||
elif "reducing_mosaic" in variant:
|
||||
parts.append("处理版")
|
||||
parts.append("HLS" if item.get("transport") == "hls" else "MP4")
|
||||
duration_seconds = self._number(item.get("duration_seconds"))
|
||||
if duration_seconds:
|
||||
parts.append("%d分钟" % int(round(duration_seconds / 60.0)))
|
||||
health = self._health_for_variant(item)
|
||||
if health and health.get("ok"):
|
||||
parts.append("%dms" % int(health.get("rtt_ms") or 0))
|
||||
label = self._clean_text(item.get("label"))
|
||||
if label and label not in parts:
|
||||
parts.append(label)
|
||||
return " ".join(item for item in parts if item)
|
||||
|
||||
@staticmethod
|
||||
def _origin(url):
|
||||
parsed = urlsplit(str(url or ""))
|
||||
if not parsed.scheme or not parsed.hostname:
|
||||
return ""
|
||||
port = ":%d" % parsed.port if parsed.port else ""
|
||||
return "%s://%s%s" % (parsed.scheme, parsed.hostname, port)
|
||||
|
||||
@staticmethod
|
||||
def _is_public_http_url(value):
|
||||
try:
|
||||
parsed = urlsplit(str(value or ""))
|
||||
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
||||
return False
|
||||
host = parsed.hostname.strip("[]")
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
return not (
|
||||
address.is_private
|
||||
or address.is_loopback
|
||||
or address.is_link_local
|
||||
or address.is_multicast
|
||||
or address.is_unspecified
|
||||
)
|
||||
except ValueError:
|
||||
return host.lower() != "localhost"
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def _empty_page(self, page, message=""):
|
||||
result = {
|
||||
"list": [],
|
||||
"page": page,
|
||||
"pagecount": page,
|
||||
"limit": 24,
|
||||
"total": 0,
|
||||
}
|
||||
if message:
|
||||
result["msg"] = self._clean_text(message)
|
||||
return result
|
||||
|
||||
def _detail_error(self, movie_id, message):
|
||||
text = self._clean_text(message) or "详情读取失败"
|
||||
error_id = self._pack_play_id({"kind": "error", "message": text})
|
||||
return {
|
||||
"vod_id": movie_id or "error",
|
||||
"vod_name": "详情读取失败",
|
||||
"vod_pic": self.DEFAULT_PIC,
|
||||
"vod_content": text,
|
||||
"vod_play_from": "错误",
|
||||
"vod_play_url": "查看错误$" + error_id,
|
||||
}
|
||||
|
||||
def _player_error(self, code, message):
|
||||
text = self._clean_text(message) or "播放失败"
|
||||
return {
|
||||
"parse": 0,
|
||||
"jx": 0,
|
||||
"playUrl": "",
|
||||
"url": "",
|
||||
"header": {},
|
||||
"code": code,
|
||||
"msg": text,
|
||||
"content": text,
|
||||
"error": text,
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# by @嗷呜
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
import requests
|
||||
import base64
|
||||
from base64 import b64encode, b64decode
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from Crypto.PublicKey import RSA
|
||||
from Crypto.Cipher import PKCS1_v1_5
|
||||
|
||||
sys.path.append('..')
|
||||
from base.spider import Spider
|
||||
|
||||
class Spider(Spider):
|
||||
|
||||
def init(self, extend=""):
|
||||
did = self.getdid()
|
||||
self.headers.update({'deviceId': did})
|
||||
token = self.gettk()
|
||||
self.headers.update({'token': token})
|
||||
|
||||
def getName(self):
|
||||
pass
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
pass
|
||||
|
||||
def manualVideoCheck(self):
|
||||
pass
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
|
||||
# 1. 修改为主机域名
|
||||
host = 'http://qkys.qukanwh.com'
|
||||
|
||||
# 2. 同步原脚本的配置请求头
|
||||
headers = {
|
||||
'HOST': 'qkys.qukanwh.com',
|
||||
'User-Agent': 'okhttp/4.12.0',
|
||||
'client': 'app',
|
||||
'deviceType': 'Android',
|
||||
'Referer': ''
|
||||
}
|
||||
|
||||
# 3. 导入原脚本中的 RSA 密钥对与配置
|
||||
publicKey_str = "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCoYt0BP77U+DM08BiI/QbSRIfxijXo85BTPqIM1Ow8BNwhLETzRIZ+dEwdWDbydG/PspgBAfRpGaYVdJYtvaC2JnoO8+Ik6qMWojfEJxSFLa0Pb0A892tun4gsxoEMjcreZ+YGyaBxAfqX0BSMfdrOgIYaZQjYrw9TRLlUT31QoQIDAQAB\n-----END PUBLIC KEY-----"
|
||||
privateKey_str = "-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCquQQ5r6+yJI8CDFkXRp8vUsdD45ov8EP12ooLs56ca2DQXaSNGS9910bAPVA9chkp0mKIvKqjAsHz5Tl9EeNPblarGEeJUIxpxZtiSqNTpvtiD/TjhpzuHYic7RAfQ/h7p/ypE8ymU42pYjsB5t26Mv6XgkLV+jzrSf73HlCuS0iMyLmt6zz3Mw9izM13EpB8iFLtfbbYymycKTx4RAmPQLwhNGex/AlUIYxXP4R2yyaa4W6mEtc6aME2QuzJFxPgP3HJ9NBx/LWVn4skxWjZ7zg+VRQRHnjyVaSLu3Z5gN5ITWCyE32qaHJa6WBahZj5jWhRyAG1bQ+xKJa8lBL5AgMBAAECggEAUwv9SjJ0PSwbhNuM2w23kcWquROWhYtTA91zGY4esehqB/IFgb2mpIh8Gje5OKqwIu/8jpd4SiOlRYdUF8sD0DfUYRZGdj2AkFNX6tBz8tVfo6wvbB6naA1lzzBij1L5JO3qsjS3cJFkb+kg2yP66AC2Z+0tpfk8eRhdtshAZwfcd1DEGt1uAvYL1eaUK9HRvpt9lPeGcHERDl2hBd4uyaF0K1O+zF9y59nYbTySWPxRZq3sFEE85xRMlstD7YZi7W2gKvMFRD4/FKmrZ3m7aKJRITtyKOyyPcYmepNv3Qv7kk59Pg38n2WWQ0Ra/bCH3E48YNCnQvZMpitkTfJhoQKBgQDbnROOYTP8OTJ6f/qhoGjxeO3x1VOaOp8l0x7b0SCfoqNGS0Cyiqj72BmJtPMPqSTjn6MmNzqbg1KOdhXyzNozs+i5ccW1M56j96mr5I/Z0FpE3oyIHNfDDBlf9M8YQqEF9oYxniYYft9oapO7cRQkHER6qpvnHTavwlv4m78CXwKBgQDHAjs2YlpKDdI1lcbZJCc7TwtH+Pd2bUki8YXafWNcPhITQHbOZjr310eK1QJC6GJncjkOqbX7yv3ivvTO35FZTQhuA1xEG1P00FG8bE0tHYPIwQHi9y0eA5cieMdo8E6XYria1mw/3fqSQEsfZyJlR32JQIoGAipM8iO1X2nZpwKBgDkMFIhnt5lNQk+P7wsNIDWZtDWdtJnboHuy29E+Abt2A/O+mI/IdRz2hau/1WO8DFkUnszOi+rZshhPlGP90rCbi1igtTrcrdjp/KkqNjPea5R4OwkgdOu1uOG0NheXNzzVTQaWjk7Opjn5dWa7eP/oV+GFb/oZHJuLYVizHGsBAoGADA7rjZEKDYCm4w5PPSr+oY5ZjaPdQrS+gLqHtMRyN82fBMGcMUdqfUfzEstzVqCEDeaS5HuOBlK3bXzKkppjUTjksN3NQmcxgBz7RuJ9DqXCLXDcb2cwuafYCYOt+YLOEEgwDVm+t2P44dG5e46hO+fICH/7nP+WlpD5buz4GfMCgYB57r3g/6hi9WUDnfc7ZAzWMqR0EhJVYKYy+KFEtdIPzhkkIHq5RASe88E9kzoGoZFdb3tIjvGZWcHerirrqWkMsuQtP/Qi0zjieid5tAPj+r4kbiCVTw0E0jnmPBzGInQi7lpeTTKnG1fbyS5lBS+WmHfIuzpECgCkxhaT+LJJkg==\n-----END PRIVATE KEY-----"
|
||||
|
||||
# RSA 公钥加密实现
|
||||
def rsa_encrypt(self, text):
|
||||
try:
|
||||
key = RSA.import_key(self.publicKey_str)
|
||||
cipher = PKCS1_v1_5.new(key)
|
||||
cipher_text = cipher.encrypt(text.encode('utf-8'))
|
||||
return b64encode(cipher_text).decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"RSA加密失败: {e}")
|
||||
return ""
|
||||
|
||||
# RSA 私钥解密实现
|
||||
def rsa_decrypt(self, text):
|
||||
try:
|
||||
key = RSA.import_key(self.privateKey_str)
|
||||
cipher = PKCS1_v1_5.new(key)
|
||||
raw_bytes = b64decode(text.encode('utf-8'))
|
||||
|
||||
decrypted = b""
|
||||
offset = 0
|
||||
while offset < len(raw_bytes):
|
||||
chunk = raw_bytes[offset:offset + 256]
|
||||
decrypted += cipher.decrypt(chunk, None)
|
||||
offset += 256
|
||||
return decrypted.decode('utf-8')
|
||||
except Exception as e:
|
||||
print(f"RSA解密失败: {e}")
|
||||
return ""
|
||||
|
||||
def homeContent(self, filter):
|
||||
data = self.post(f"{self.host}/api/v1/app/screen/screenType", headers=self.headers).json()
|
||||
result = {}
|
||||
cate = {
|
||||
"类型": "type",
|
||||
"地区": "area",
|
||||
"年份": "year"
|
||||
}
|
||||
sort = {
|
||||
'key': 'sort',
|
||||
'name': '排序',
|
||||
'value': [{'n': '最新', 'v': 'NEWEST'}, {'n': '热门', 'v': 'HOT'}, {'n': '收藏', 'v': 'COLLECT'}]
|
||||
}
|
||||
classes = []
|
||||
filters = {}
|
||||
for k in data.get('data', []):
|
||||
classes.append({
|
||||
'type_name': k['name'],
|
||||
'type_id': str(k['id'])
|
||||
})
|
||||
filters[str(k['id'])] = []
|
||||
for v in k.get('children', []):
|
||||
if v['name'] in cate:
|
||||
filters[str(k['id'])].append({
|
||||
'name': v['name'],
|
||||
'key': cate[v['name']],
|
||||
'value': [{'n': i['name'], 'v': i['name']} for i in v.get('children', [])]
|
||||
})
|
||||
filters[str(k['id'])].append(sort)
|
||||
result['class'] = classes
|
||||
result['filters'] = filters
|
||||
return result
|
||||
|
||||
def homeVideoContent(self):
|
||||
jdata = {
|
||||
"condition": {
|
||||
"sreecnTypeEnum": "NEWEST"
|
||||
},
|
||||
"pageNum": 1,
|
||||
"pageSize": 40
|
||||
}
|
||||
data = self.post(f"{self.host}/api/v1/app/screen/screenMovie", headers=self.headers, json=jdata).json()
|
||||
return {'list': self.getlist(data.get('data', {}).get('records', []))}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
# 保持最纯粹的条件字段,移除任何空字符串占位
|
||||
condition = {
|
||||
'sreecnTypeEnum': 'NEWEST',
|
||||
'typeId': int(tid) if str(tid).isdigit() else tid
|
||||
}
|
||||
|
||||
if extend:
|
||||
if 'sort' in extend:
|
||||
condition['sreecnTypeEnum'] = extend.pop('sort')
|
||||
condition.update(extend)
|
||||
|
||||
jdata = {
|
||||
'condition': condition,
|
||||
'pageNum': int(pg),
|
||||
'pageSize': 40,
|
||||
}
|
||||
|
||||
try:
|
||||
data = self.post(f"{self.host}/api/v1/app/screen/screenMovie", headers=self.headers, json=jdata).json()
|
||||
result = {}
|
||||
if data and data.get('data') and 'records' in data['data']:
|
||||
result['list'] = self.getlist(data['data']['records'])
|
||||
else:
|
||||
result['list'] = []
|
||||
result['page'] = pg
|
||||
result['pagecount'] = 9999
|
||||
result['limit'] = 40
|
||||
result['total'] = 999999
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"分类获取错误: {e}")
|
||||
return {'list': [], 'page': pg}
|
||||
|
||||
def detailContent(self, ids):
|
||||
ids = ids[0].split('@@')
|
||||
jdata = {"id": int(ids[0]), "typeId": ids[-1]}
|
||||
v = self.post(f"{self.host}/api/v1/app/play/movieDesc", headers=self.headers, json=jdata).json()
|
||||
v = v.get('data', {})
|
||||
vod = {
|
||||
'type_name': v.get('typeId', ''),
|
||||
'vod_year': v.get('year', ''),
|
||||
'vod_area': v.get('area', ''),
|
||||
'vod_actor': v.get('star', ''),
|
||||
'vod_director': v.get('director', ''),
|
||||
'vod_content': v.get('introduce', ''),
|
||||
'vod_play_from': '',
|
||||
'vod_play_url': ''
|
||||
}
|
||||
|
||||
play_params = {
|
||||
"id": int(ids[0]),
|
||||
"source": 0,
|
||||
"typeId": ids[-1]
|
||||
}
|
||||
encrypt_payload = {"key": self.rsa_encrypt(json.dumps(play_params))}
|
||||
|
||||
c_res = self.post(f"{self.host}/api/v1/app/play/movieDetails", headers=self.headers, json=encrypt_payload).json()
|
||||
decrypted_play_str = self.rsa_decrypt(c_res.get('data', ''))
|
||||
if not decrypted_play_str:
|
||||
return {'list': [vod]}
|
||||
|
||||
decrypted_play_data = json.loads(decrypted_play_str)
|
||||
l = decrypted_play_data.get('moviePlayerList', [])
|
||||
if not l:
|
||||
return {'list': [vod]}
|
||||
|
||||
n = {str(i['id']): i['moviePlayerName'] for i in l}
|
||||
|
||||
m = play_params.copy()
|
||||
m.update({'playerId': l[0]['id']})
|
||||
|
||||
first_source_payload = {"key": self.rsa_encrypt(json.dumps(m))}
|
||||
first_res = self.post(f"{self.host}/api/v1/app/play/movieDetails", headers=self.headers, json=first_source_payload).json()
|
||||
|
||||
decrypted_first_str = self.rsa_decrypt(first_res.get('data', ''))
|
||||
if decrypted_first_str:
|
||||
decrypted_first_episode = json.loads(decrypted_first_str)
|
||||
pd = self.getv(m, decrypted_first_episode.get('episodeList', []))
|
||||
else:
|
||||
pd = {}
|
||||
|
||||
if len(l) > 1:
|
||||
with ThreadPoolExecutor(max_workers=len(l)-1) as executor:
|
||||
future_to_player = {executor.submit(self.getd, play_params, player): player for player in l[1:]}
|
||||
for future in future_to_player:
|
||||
try:
|
||||
o, p = future.result()
|
||||
if p:
|
||||
pd.update(self.getv(o, p))
|
||||
except Exception as e:
|
||||
print(f"多线路请求失败: {e}")
|
||||
w, e = [], []
|
||||
for i, x in pd.items():
|
||||
if x:
|
||||
w.append(n.get(i, '未知线路'))
|
||||
e.append(x)
|
||||
vod['vod_play_from'] = '$$$'.join(w)
|
||||
vod['vod_play_url'] = '$$$'.join(e)
|
||||
return {'list': [vod]}
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
jdata = {
|
||||
"condition": {
|
||||
"value": str(key)
|
||||
},
|
||||
"pageNum": int(pg),
|
||||
"pageSize": 40
|
||||
}
|
||||
try:
|
||||
data = self.post(f"{self.host}/api/v1/app/search/searchMovie", headers=self.headers, json=jdata).json()
|
||||
return {'list': self.getlist(data.get('data', {}).get('records', [])), 'page': pg}
|
||||
except Exception as e:
|
||||
print(f"搜索请求失败: {e}")
|
||||
return {'list': [], 'page': pg}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
raw_id_str = self.d64(id)
|
||||
if not raw_id_str:
|
||||
return {'parse': 0, 'url': ''}
|
||||
jdata = json.loads(raw_id_str)
|
||||
encrypt_payload = {"key": self.rsa_encrypt(json.dumps(jdata))}
|
||||
data = self.post(f"{self.host}/api/v1/app/play/movieDetails", headers=self.headers, json=encrypt_payload).json()
|
||||
|
||||
try:
|
||||
decrypted_url_data = json.loads(self.rsa_decrypt(data.get('data', '')))
|
||||
playerUrl = decrypted_url_data.get('url', '')
|
||||
if not playerUrl:
|
||||
return {'parse': 0, 'url': ''}
|
||||
|
||||
params = {'playerUrl': playerUrl, 'playerId': jdata['playerId']}
|
||||
pd = self.fetch(f"{self.host}/api/v1/app/play/analysisMovieUrl", headers=self.headers, params=params).json()
|
||||
url, p = pd.get('data', ''), 0
|
||||
except Exception as e:
|
||||
print(f"解析流媒体直链失败: {e}")
|
||||
url, p = "", 0
|
||||
return {'parse': p, 'url': url, 'header': {'User-Agent': 'okhttp/4.12.0'}}
|
||||
|
||||
def localProxy(self, param):
|
||||
pass
|
||||
|
||||
def liveContent(self, url):
|
||||
pass
|
||||
|
||||
def gettk(self):
|
||||
self.headers.update({'deviceId': self.getdid()})
|
||||
try:
|
||||
data = self.fetch(f"{self.host}/api/v1/app/user/visitorInfo", headers=self.headers).json()
|
||||
return data.get('data', {}).get('token', '')
|
||||
except:
|
||||
return ""
|
||||
|
||||
def getdid(self):
|
||||
did = self.getCache('ldid')
|
||||
if not did:
|
||||
hex_chars = '0123456789abcdef'
|
||||
did = ''.join(random.choice(hex_chars) for _ in range(16))
|
||||
self.setCache('ldid', did)
|
||||
return did
|
||||
|
||||
def getd(self, jdata, player):
|
||||
x = jdata.copy()
|
||||
x.update({'playerId': player['id']})
|
||||
encrypt_payload = {"key": self.rsa_encrypt(json.dumps(x))}
|
||||
response = self.post(f"{self.host}/api/v1/app/play/movieDetails", headers=self.headers, json=encrypt_payload).json()
|
||||
decrypted_str = self.rsa_decrypt(response.get('data', ''))
|
||||
if decrypted_str:
|
||||
decrypted_episode = json.loads(decrypted_str)
|
||||
return x, decrypted_episode.get('episodeList', [])
|
||||
return x, []
|
||||
|
||||
def getv(self, d, c):
|
||||
f = {str(d['playerId']): ''}
|
||||
g = []
|
||||
for i in c:
|
||||
j = d.copy()
|
||||
j.update({'episodeId': i['id']})
|
||||
g.append(f"{i['episode']}${self.e64(json.dumps(j))}")
|
||||
f[str(d['playerId'])] = '#'.join(g)
|
||||
return f
|
||||
|
||||
def getlist(self, data):
|
||||
videos = []
|
||||
for i in data:
|
||||
if not i.get('id'):
|
||||
continue
|
||||
videos.append({
|
||||
'vod_id': f"{i['id']}@@{i.get('typeId', '')}",
|
||||
'vod_name': i.get('name', ''),
|
||||
'vod_pic': i.get('cover', ''),
|
||||
'vod_year': i.get('year', ''),
|
||||
'vod_remarks': i.get('totalEpisode', '')
|
||||
})
|
||||
return videos
|
||||
|
||||
def e64(self, text):
|
||||
try:
|
||||
return b64encode(text.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return ""
|
||||
|
||||
def d64(self, encoded_text):
|
||||
try:
|
||||
return b64decode(encoded_text.encode('utf-8')).decode('utf-8')
|
||||
except:
|
||||
return ""
|
||||
# 播放
|
||||
_original = Spider.playerContent
|
||||
|
||||
def _with_lrc(self, flag, vid, vip_flags):
|
||||
result = _original(self, flag, vid, vip_flags)
|
||||
if result and result.get('url'):
|
||||
try:
|
||||
r = requests.get('WzAwOjAwLjAwXTRr5o6l5Y+j5rqQ56CB5YiG5LqrUVHkuqTmtYHnvqQ6MjEyNzA2OTM0ClswMDowMC4wMF3kuI3lv5jliJ3lv4PvvIzmsLjkuYXlhY3otLnkuJPkurrnu7TmiqQKWzAwOjAzLjAwXeabtOWkmui1hOa6kOWFseS6q+etieS9oOaOoue0ojogaHR0cHM6Ly9memwueG8uamUKWzAwOjA2LjAwXeKAiwpbMDM6MDMuMDBd4oCLClswMzowNi4wMF3mupDnoIHliIbkuqtRUeS6pOa1gee+pDoyMTI3MDY5MzQKWzAzOjA5LjAwXei1hOa6kOWFseS6qzogaHR0cHM6Ly9memwueG8uamUKWzAzOjEyLjAwXeWkh+eUqOWcsOWdgDogaHR0cHM6Ly9memwucmYuZ2QKWzAzOjE1LjAwXeavj+aXpeabtOaWsOacgOaWsOi1hOa6kApbMDM6MTguMDBd6K+35Yu/55So5LqO5ZWG5Lia55So6YCU', timeout=5)
|
||||
result["lrc"] = base64.b64decode(r.text).decode('utf-8')
|
||||
except Exception as e:
|
||||
print("加载异常:", e)
|
||||
return result
|
||||
Spider.playerContent = _with_lrc
|
||||
@@ -0,0 +1,177 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# FongMi/TVBox Python Spider - 泥视频 nivod.vip
|
||||
import re, json, html, base64
|
||||
from urllib.parse import urljoin, quote, unquote
|
||||
|
||||
try:
|
||||
from base.spider import Spider as BaseSpider
|
||||
except Exception:
|
||||
class BaseSpider(object):
|
||||
def fetch(self, url, headers=None, timeout=15, **kwargs):
|
||||
import requests
|
||||
return requests.get(url, headers=headers, timeout=timeout, verify=False)
|
||||
|
||||
class Spider(BaseSpider):
|
||||
def __init__(self):
|
||||
self.host = 'https://www.nivod.vip'
|
||||
self.headers = {'User-Agent':'Mozilla/5.0 (Linux; Android 12) AppleWebKit/537.36 Chrome/120 Mobile Safari/537.36','Referer':self.host + '/'}
|
||||
self.classes = [{'type_id':'1','type_name':'电影'},{'type_id':'2','type_name':'剧集'},{'type_id':'3','type_name':'综艺'},{'type_id':'4','type_name':'动漫'},{'type_id':'new','type_name':'今日更新'},{'type_id':'hot','type_name':'热榜'}]
|
||||
|
||||
def getName(self): return '泥视频'
|
||||
def getDependence(self): return []
|
||||
def init(self, extend=''): pass
|
||||
def isVideoFormat(self, url): return bool(re.search(r'\.(m3u8|mp4)(\?|$)', str(url), re.I))
|
||||
def manualVideoCheck(self): return True
|
||||
def action(self, action): return None
|
||||
def destroy(self): pass
|
||||
def liveContent(self, url): return {'list': []}
|
||||
def localProxy(self, param): return [404, 'text/plain', 'Not Found']
|
||||
|
||||
def log(self, msg):
|
||||
try: print('[泥视频] ' + str(msg))
|
||||
except Exception: pass
|
||||
|
||||
def getHtml(self, url, referer=None):
|
||||
if not url.startswith('http'): url = urljoin(self.host, url)
|
||||
h = dict(self.headers)
|
||||
if referer: h['Referer'] = referer
|
||||
try:
|
||||
r = self.fetch(url, headers=h, timeout=15)
|
||||
if hasattr(r, 'content'):
|
||||
enc = getattr(r, 'encoding', None) or 'utf-8'
|
||||
return r.content.decode(enc, 'ignore')
|
||||
return getattr(r, 'text', '') or ''
|
||||
except Exception as e:
|
||||
self.log('请求失败 %s %s' % (url, e)); return ''
|
||||
|
||||
def clean(self, s):
|
||||
s = html.unescape(str(s or ''))
|
||||
s = re.sub(r'<script[\s\S]*?</script>|<style[\s\S]*?</style>', ' ', s, flags=re.I)
|
||||
s = re.sub(r'<[^>]+>', ' ', s)
|
||||
return re.sub(r'\s+', ' ', s).strip()
|
||||
|
||||
def fix(self, u):
|
||||
if not u: return ''
|
||||
u = html.unescape(u).replace('\\/', '/')
|
||||
return urljoin(self.host, u.strip())
|
||||
|
||||
def homeContent(self, filter):
|
||||
return {'class': self.classes, 'filters': self.makeFilters() if filter else {}}
|
||||
|
||||
def makeFilters(self):
|
||||
years = [{'n':'全部','v':''}] + [{'n':str(y),'v':str(y)} for y in range(2026, 2010, -1)]
|
||||
areas = [{'n':'全部','v':''}] + [{'n':x,'v':x} for x in ['大陆','香港','台湾','日本','韩国','欧美','英国','泰国','其它']]
|
||||
langs = [{'n':'全部','v':''}] + [{'n':x,'v':x} for x in ['国语','英语','粤语','韩语','日语','西班牙语','法语','德语','泰语','其它']]
|
||||
bys = [{'n':'添加时间','v':'time_add'},{'n':'更新时间','v':'time_update'},{'n':'人气排序','v':'hits'},{'n':'评分排序','v':'score'}]
|
||||
letters = [{'n':'全部','v':''}] + [{'n':c,'v':c} for c in list('ABCDEFGHIJKLMNOPQRSTUVWXYZ')] + [{'n':'0-9','v':'0-9'}]
|
||||
common = [{'key':'area','name':'地区','value':areas},{'key':'year','name':'年份','value':years},{'key':'lang','name':'语言','value':langs},{'key':'letter','name':'字母','value':letters},{'key':'by','name':'排序','value':bys}]
|
||||
fs = {c['type_id']: list(common) for c in self.classes if c['type_id'] not in ['new','hot']}
|
||||
fs['1'] = [{'key':'class','name':'类型','value':[{'n':'全部','v':''}]+[{'n':n,'v':v} for n,v in [('动作片','6'),('喜剧片','7'),('爱情片','8'),('科幻片','9'),('奇幻片','10'),('恐怖片','11'),('剧情片','12'),('战争片','20'),('纪录片','21'),('动画片','26'),('悬疑片','22'),('冒险片','23'),('犯罪片','24')]]}] + common
|
||||
return fs
|
||||
|
||||
def homeVideoContent(self):
|
||||
return {'list': self.parseList(self.getHtml(self.host + '/'))[:30]}
|
||||
|
||||
def buildCategoryUrl(self, tid, pg, extend):
|
||||
pg = str(pg or '1'); ext = extend or {}
|
||||
if tid == 'new': return self.host + '/label/new/'
|
||||
if tid == 'hot': return self.host + '/label/hot/'
|
||||
cid = ext.get('class') or tid
|
||||
if ext:
|
||||
area = quote(str(ext.get('area','')), safe='')
|
||||
by = quote(str(ext.get('by','')), safe='')
|
||||
lang = quote(str(ext.get('lang','')), safe='')
|
||||
letter = quote(str(ext.get('letter','')), safe='')
|
||||
year = quote(str(ext.get('year','')), safe='')
|
||||
p = '' if pg == '1' else pg
|
||||
return self.host + '/k/%s-%s-%s--%s-%s---%s---%s/' % (cid, area, by, lang, letter, p, year)
|
||||
if pg == '1': return self.host + '/t/%s/' % tid
|
||||
return self.host + '/t/%s-%s/' % (tid, pg)
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
url = self.buildCategoryUrl(tid, pg, extend or {})
|
||||
vods = self.parseList(self.getHtml(url, self.host + '/'))
|
||||
return {'list': vods, 'page': int(pg or 1), 'pagecount': 999999 if vods else int(pg or 1), 'limit': len(vods), 'total': 999999 if vods else 0}
|
||||
|
||||
def parseList(self, txt):
|
||||
vods, seen = [], set()
|
||||
blocks = re.findall(r'<a\b(?=[^>]*class=["\'][^"\']*module-(?:poster-)?item[^"\']*["\'])([\s\S]*?)</a>', txt or '', re.I)
|
||||
if not blocks:
|
||||
blocks = re.findall(r'(<div\b[^>]*class=["\'][^"\']*module-card-item[^"\']*module-item[^"\']*["\'][\s\S]*?)(?=<div\b[^>]*class=["\'][^"\']*module-card-item\s+module-item|</div>\s*</div>\s*</div>)', txt or '', re.I)
|
||||
if not blocks:
|
||||
blocks = re.findall(r'<a\b([^>]+href=["\'][^"\']*/nivod/\d+/?["\'][\s\S]*?)</a>', txt or '', re.I)
|
||||
for b in blocks:
|
||||
try:
|
||||
hm = re.search(r'href=["\']([^"\']*/nivod/(\d+)/?)["\']', b, re.I)
|
||||
if not hm: continue
|
||||
vid = self.fix(hm.group(1))
|
||||
if vid in seen: continue
|
||||
seen.add(vid)
|
||||
tm = re.search(r'title=["\']([^"\']+)["\']', b, re.I) or re.search(r'class=["\'][^"\']*module-(?:poster|card)-item-title[^"\']*["\'][^>]*>[\s\S]*?<a[^>]*>([\s\S]*?)</a>', b, re.I) or re.search(r'class=["\'][^"\']*module-(?:poster|card)-item-title[^"\']*["\'][^>]*>([\s\S]*?)</div>', b, re.I)
|
||||
title = self.clean(tm.group(1)) if tm else ''
|
||||
pm = re.search(r'(?:data-original|data-src)=["\']([^"\']+)["\']', b, re.I) or re.search(r'<img[^>]+src=["\']((?!/loading\.png)[^"\']+)["\']', b, re.I)
|
||||
rm = re.search(r'class=["\'][^"\']*module-item-note[^"\']*["\'][^>]*>([\s\S]*?)</div>', b, re.I)
|
||||
if title:
|
||||
vods.append({'vod_id':vid,'vod_name':title,'vod_pic':self.fix(pm.group(1)) if pm else '', 'vod_remarks':self.clean(rm.group(1)) if rm else ''})
|
||||
except Exception as e:
|
||||
self.log('列表单条失败 %s' % e)
|
||||
return vods
|
||||
|
||||
def detailContent(self, ids):
|
||||
url = ids[0]
|
||||
txt = self.getHtml(url, self.host + '/')
|
||||
mt = re.search(r'<h1[^>]*>([\s\S]*?)</h1>', txt, re.I) or re.search(r'<title>(.*?)详情介绍', txt, re.S) or re.search(r'title=["\']立刻播放([^"\']+)', txt)
|
||||
title = self.clean(mt.group(1)) if mt else ''
|
||||
picm = re.search(r'(?:data-original|data-src)=["\']([^"\']+)["\'][^>]+alt=["\']%s' % re.escape(title), txt, re.I) or re.search(r'class=["\'][^"\']*module-item-pic[^"\']*["\'][\s\S]*?(?:data-original|data-src|src)=["\']([^"\']+)', txt, re.I)
|
||||
cm = re.search(r'module-info-introduction-content["\'][^>]*>([\s\S]*?)</div>', txt, re.I)
|
||||
content = self.clean(cm.group(1)) if cm else ''
|
||||
def item(name):
|
||||
m = re.search(r'<span[^>]*>%s[::]</span>\s*<div[^>]*>([\s\S]*?)</div>' % name, txt, re.I)
|
||||
return self.clean(m.group(1)) if m else ''
|
||||
names = [self.clean(x) for x in re.findall(r'<div[^>]+class=["\'][^"\']*module-tab-item[^"\']*tab-item[^"\']*["\'][^>]*>\s*<span>(.*?)</span>', txt, re.I)]
|
||||
groups = re.findall(r'<div[^>]+class=["\'][^"\']*module-play-list[^"\']*["\'][^>]*>([\s\S]*?)</div>\s*</div>\s*</div>', txt, re.I)
|
||||
play_from, play_url = [], []
|
||||
for i,g in enumerate(groups):
|
||||
eps=[]
|
||||
for a in re.findall(r'<a\b([^>]+class=["\'][^"\']*module-play-list-link[^"\']*["\'][^>]*)>([\s\S]*?)</a>', g, re.I):
|
||||
hm = re.search(r'href=["\']([^"\']+) ["\']', a[0]+' ', re.I) or re.search(r'href=["\']([^"\']+)["\']', a[0], re.I)
|
||||
if hm:
|
||||
name = self.clean(a[1]) or ('第%d集' % (len(eps)+1))
|
||||
eps.append(name + '$' + self.fix(hm.group(1)))
|
||||
if eps:
|
||||
play_from.append(names[i] if i < len(names) and names[i] else '线路%d'%(i+1)); play_url.append('#'.join(eps))
|
||||
if not play_url:
|
||||
eps=[]
|
||||
for h,n in re.findall(r'href=["\']([^"\']*/niplay/\d+-\d+-\d+/)["\'][^>]*>([\s\S]*?)</a>', txt, re.I):
|
||||
eps.append((self.clean(n) or '播放') + '$' + self.fix(h))
|
||||
if eps: play_from, play_url = ['默认'], ['#'.join(list(dict.fromkeys(eps)))]
|
||||
vod = {'vod_id':url,'vod_name':title,'vod_pic':self.fix(picm.group(1)) if picm else '', 'type_name':item('类型') or item('分类'), 'vod_year':item('上映')[:4], 'vod_area':item('地区'), 'vod_remarks':item('更新'), 'vod_actor':item('主演'), 'vod_director':item('导演'), 'vod_content':content, 'vod_play_from':'$$$'.join(play_from), 'vod_play_url':'$$$'.join(play_url)}
|
||||
return {'list':[vod]}
|
||||
|
||||
def searchContent(self, key, quick, pg='1'):
|
||||
url = self.host + '/s/%s-------------/' % quote(key)
|
||||
return {'list': self.parseList(self.getHtml(url, self.host + '/')), 'page': int(pg or 1), 'pagecount': 1, 'limit': 20, 'total': 0}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
if self.isVideoFormat(id): return {'parse':0, 'url':id, 'header':self.headers}
|
||||
txt = self.getHtml(id, self.host + '/')
|
||||
data = None
|
||||
m = re.search(r'var\s+player_[a-zA-Z0-9_]+\s*=\s*(\{[\s\S]*?\})\s*</script>', txt, re.I)
|
||||
if m:
|
||||
try: data = json.loads(m.group(1))
|
||||
except Exception: data = None
|
||||
url = data.get('url','') if isinstance(data, dict) else ''
|
||||
enc = str(data.get('encrypt','0')) if isinstance(data, dict) else '0'
|
||||
try:
|
||||
if enc == '1': url = unquote(url)
|
||||
elif enc == '2': url = unquote(base64.b64decode(url).decode('utf-8','ignore'))
|
||||
except Exception: pass
|
||||
url = self.fix(url)
|
||||
if not self.isVideoFormat(url):
|
||||
mm = re.search(r'(https?:\\?/\\?/[^"\']+?\.(?:m3u8|mp4)[^"\']*)', txt, re.I)
|
||||
url = self.fix(mm.group(1)) if mm else url
|
||||
if self.isVideoFormat(url):
|
||||
return {'parse':0, 'url':url, 'header':{'User-Agent':self.headers['User-Agent'], 'Referer':id}}
|
||||
return {'parse':1, 'url':id, 'header':self.headers}
|
||||
|
||||
spider = Spider()
|
||||
Reference in New Issue
Block a user