Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-29 14:45:49 +00:00
parent 518ce5cbc7
commit bc4c572d85
50 changed files with 17420 additions and 10050 deletions
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
+589
View File
@@ -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(/&nbsp;/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
};
}
+174
View File
@@ -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};
}
+436
View File
@@ -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
};
}
+883
View File
@@ -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/DISCONTINUITYURI 绝对化"""
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'&nbsp;', ' ', content)
content = re.sub(r'&amp;', '&', content)
content = re.sub(r'&lt;', '<', content)
content = re.sub(r'&gt;', '>', content)
content = re.sub(r'&quot;', '"', 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}
+2151
View File
@@ -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,
}
+343
View File
@@ -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
+177
View File
@@ -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()