Sync all projects
This commit is contained in:
+46
-10
@@ -160,6 +160,13 @@
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/片库.js",
|
||||
"changeable": 0
|
||||
},
|
||||
{
|
||||
"key": "枫叶影院",
|
||||
"name": "🐬枫叶影院.js(关梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/枫叶影院.js",
|
||||
"changeable": 0
|
||||
},
|
||||
{
|
||||
"key": "808yr",
|
||||
"name": "🐬808影院.py(关梯)[追剧]",
|
||||
@@ -220,6 +227,12 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/4K影视.py"
|
||||
},
|
||||
{
|
||||
"key": "蝶卡影视",
|
||||
"name": "🐬蝶卡影视.py(关梯)[追剧]",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/蝶卡影视.py"
|
||||
},
|
||||
{
|
||||
"key": "ddyy",
|
||||
"name": "🐬低端影视.py[追剧]",
|
||||
@@ -979,6 +992,18 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/采集聚合成人.py"
|
||||
},
|
||||
{
|
||||
"key": "BadNews",
|
||||
"name": "🐬BadNews.py|🔞[成人](挂梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/BadNews.py"
|
||||
},
|
||||
{
|
||||
"key": "麻豆社",
|
||||
"name": "🐬麻豆社.py|🔞[成人]",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麻豆社.py"
|
||||
},
|
||||
{
|
||||
"key": "prsd",
|
||||
"name": "🐬怦然心动.py|🔞[成人]",
|
||||
@@ -2032,7 +2057,7 @@
|
||||
},
|
||||
{
|
||||
"key": "哆啦新番社",
|
||||
"name": "哆啦|新番社",
|
||||
"name": "哆啦|新番社[动漫]",
|
||||
"type": 3,
|
||||
"api": "csp_XBPQ",
|
||||
"style": {
|
||||
@@ -2042,14 +2067,14 @@
|
||||
},
|
||||
{
|
||||
"key": "Anime1",
|
||||
"name": "A1|动漫",
|
||||
"name": "A1[动漫]",
|
||||
"type": 3,
|
||||
"api": "https://cnb.cool/fish2018/xs/-/git/raw/main/js/drpy2.min.js",
|
||||
"ext": "https://cnb.cool/fish2018/xs/-/git/raw/main/js/Anime1.js"
|
||||
},
|
||||
{
|
||||
"key": "漫国动漫",
|
||||
"name": "漫国丨动漫",
|
||||
"name": "漫国[动漫]",
|
||||
"type": 3,
|
||||
"searchable": 1,
|
||||
"api": "csp_AppSy",
|
||||
@@ -2062,7 +2087,7 @@
|
||||
},
|
||||
{
|
||||
"key": "方舟动漫",
|
||||
"name": "方舟丨动漫",
|
||||
"name": "方舟[动漫]",
|
||||
"type": 3,
|
||||
"searchable": 1,
|
||||
"api": "csp_AppGet",
|
||||
@@ -2074,7 +2099,7 @@
|
||||
},
|
||||
{
|
||||
"key": "曼波动漫",
|
||||
"name": "曼波|动漫",
|
||||
"name": "曼波[动漫]",
|
||||
"type": 3,
|
||||
"searchable": 1,
|
||||
"api": "csp_AppGet",
|
||||
@@ -2086,7 +2111,7 @@
|
||||
},
|
||||
{
|
||||
"key": "咕咕动漫",
|
||||
"name": "咕咕|动漫",
|
||||
"name": "咕咕[动漫]",
|
||||
"type": 3,
|
||||
"searchable": 1,
|
||||
"api": "csp_AppGet",
|
||||
@@ -2098,21 +2123,21 @@
|
||||
},
|
||||
{
|
||||
"key": "巴士动漫",
|
||||
"name": "巴士|动漫",
|
||||
"name": "巴士[动漫]",
|
||||
"type": 3,
|
||||
"api": "csp_XYQHiker",
|
||||
"ext": "https://cnb.cool/fish2018/xs/-/git/raw/main/XYQHiker/巴士动漫.json"
|
||||
},
|
||||
{
|
||||
"key": "路漫漫",
|
||||
"name": "路漫漫|动漫",
|
||||
"name": "路漫漫[动漫]",
|
||||
"type": 3,
|
||||
"api": "https://cnb.cool/fish2018/xs/-/git/raw/main/js/drpy2.min.js",
|
||||
"ext": "https://cnb.cool/fish2018/xs/-/git/raw/main/js/路漫漫.js"
|
||||
},
|
||||
{
|
||||
"key": "去看吧",
|
||||
"name": "去看吧|动漫",
|
||||
"name": "去看吧[动漫]",
|
||||
"type": 3,
|
||||
"api": "https://cnb.cool/fish2018/xs/-/git/raw/main/js/drpy2.min.js",
|
||||
"ext": "https://cnb.cool/fish2018/xs/-/git/raw/main/js/去看吧.js"
|
||||
@@ -2444,7 +2469,7 @@
|
||||
},
|
||||
{
|
||||
"key": "哔哩哔哩听书",
|
||||
"name": "哔哩|听书",
|
||||
"name": "哔哩[听书]",
|
||||
"type": 3,
|
||||
"api": "csp_Bili",
|
||||
"searchable": 0,
|
||||
@@ -2676,6 +2701,17 @@
|
||||
"type": 0,
|
||||
"ua": "okhttp",
|
||||
"url": "https://php.946985.filegear-sg.me/jackTV.m3u"
|
||||
},
|
||||
{
|
||||
"name": "🐬海豚佬🔞午夜剧场(挂梯)",
|
||||
"type": 0,
|
||||
"playerType": 2,
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png",
|
||||
"url": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/午夜剧场.txt",
|
||||
"header": {
|
||||
"X-Api-key": "8692bd33270440d4d2941b814b81a25a1eb84a7205532971714c4299c57449e0..0..1738210005..5cf2c4ad-043b-4234-83a2-e84ae917"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "🐬海豚佬xxx视频资源🔞",
|
||||
|
||||
@@ -143,6 +143,12 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/4K影视.py"
|
||||
},
|
||||
{
|
||||
"key": "蝶卡影视",
|
||||
"name": "🐬蝶卡影视.py(关梯)[追剧]",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/蝶卡影视.py"
|
||||
},
|
||||
{
|
||||
"key": "ddyy",
|
||||
"name": "🐬低端影视.py[追剧]",
|
||||
@@ -545,6 +551,18 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/采集聚合成人.py"
|
||||
},
|
||||
{
|
||||
"key": "BadNews",
|
||||
"name": "🐬BadNews.py|🔞[成人](挂梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/BadNews.py"
|
||||
},
|
||||
{
|
||||
"key": "麻豆社",
|
||||
"name": "🐬麻豆社.py|🔞[成人]",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麻豆社.py"
|
||||
},
|
||||
{
|
||||
"key": "prsd",
|
||||
"name": "🐬怦然心动.py|🔞[成人]",
|
||||
@@ -921,6 +939,17 @@
|
||||
"type": 0,
|
||||
"ua": "okhttp",
|
||||
"url": "https://php.946985.filegear-sg.me/jackTV.m3u"
|
||||
},
|
||||
{
|
||||
"name": "🐬海豚佬🔞午夜剧场(挂梯)",
|
||||
"type": 0,
|
||||
"playerType": 2,
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png",
|
||||
"url": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/午夜剧场.txt",
|
||||
"header": {
|
||||
"X-Api-key": "8692bd33270440d4d2941b814b81a25a1eb84a7205532971714c4299c57449e0..0..1738210005..5cf2c4ad-043b-4234-83a2-e84ae917"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "🐬海豚佬xxx视频资源🔞",
|
||||
|
||||
@@ -160,6 +160,13 @@
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/片库.js",
|
||||
"changeable": 0
|
||||
},
|
||||
{
|
||||
"key": "枫叶影院",
|
||||
"name": "🐬枫叶影院.js(关梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/枫叶影院.js",
|
||||
"changeable": 0
|
||||
},
|
||||
{
|
||||
"key": "808yr",
|
||||
"name": "🐬808影院.py(关梯)",
|
||||
@@ -220,6 +227,12 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/4K影视.py"
|
||||
},
|
||||
{
|
||||
"key": "蝶卡影视",
|
||||
"name": "🐬蝶卡影视.py(关梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/蝶卡影视.py"
|
||||
},
|
||||
{
|
||||
"key": "ddyy",
|
||||
"name": "🐬低端影视.py",
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
// 从壳子内置路径导入cheerio
|
||||
import cheerio from 'assets://js/lib/cheerio.min.js';
|
||||
|
||||
const TAG = "枫叶4K";
|
||||
let baseUrl = 'https://www.cd-zj.com';
|
||||
|
||||
|
||||
const mylog = (...args) => console.log(TAG, ...args);
|
||||
|
||||
// 1. 统一错误响应处理工具
|
||||
const backError = (err, type = 'list') => {
|
||||
const msg = err?.message || err || `${TAG}未知异常`;
|
||||
mylog("错误捕获 ->", msg);
|
||||
|
||||
if (type === 'play') {
|
||||
return JSON.stringify({ parse: 0, msg });
|
||||
} else if (type === 'home') {
|
||||
return JSON.stringify({ msg, class: [] });
|
||||
} else {
|
||||
return JSON.stringify({ msg, list: [], pagecount: 1 });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
function myjsonParse(target) {
|
||||
return typeof target === 'string' ? JSON.parse(target) : target || {}
|
||||
}
|
||||
const Headers = {
|
||||
"user-agent": 'Mozilla/5.0 (Linux; Android 15; Pixel 9) AppleWebKit/537.36 Chrome/150.0.0.0 Mobile',
|
||||
"Referer": baseUrl + "/",
|
||||
"Cookie": ""
|
||||
};
|
||||
async function myFetch(url, options = {}, needJsonParse = true) {
|
||||
try {
|
||||
let res = await req(url, {
|
||||
method: options?.method || "get",
|
||||
headers: Headers,
|
||||
...options
|
||||
})
|
||||
return needJsonParse ? myjsonParse(res?.content) : res?.content
|
||||
} catch (err) {
|
||||
mylog("myfetch err ", err)
|
||||
}
|
||||
}
|
||||
async function init(ext) { }
|
||||
|
||||
async function home() {
|
||||
try {
|
||||
return JSON.stringify({
|
||||
class: [
|
||||
{ type_id: "4", type_name: "动漫" },
|
||||
{ type_id: "2", type_name: "电视剧" },
|
||||
{ type_id: "1", type_name: "电影" },
|
||||
{ type_id: "/label/qq", type_name: "腾讯" },
|
||||
{ type_id: "/label/bli", type_name: "B站" },
|
||||
{ type_id: "/label/youku", type_name: "优酷" },
|
||||
{ type_id: "3", type_name: "综艺" },
|
||||
{ type_id: "5", type_name: "热门短剧" }
|
||||
]
|
||||
});
|
||||
} catch (err) {
|
||||
return backError(err, 'home');
|
||||
}
|
||||
}
|
||||
|
||||
async function homeVod() {
|
||||
return await category("", 1, false, {});
|
||||
}
|
||||
|
||||
async function category(tid, pg = 1, filter, extend = {}) {
|
||||
try {
|
||||
let page = parseInt(pg) || 1;
|
||||
|
||||
|
||||
// 1. 处理 VIP 精选等 HTML 页面分类
|
||||
if (!tid || tid?.startsWith("/label")) {
|
||||
const url = !tid ? baseUrl : `${baseUrl}${tid}/page/${page}.html`;
|
||||
mylog("label category url:", url);
|
||||
const res = await req(url, { headers: Headers });
|
||||
if (!res?.content) throw new Error("获取精选分类失败");
|
||||
return await parseList(res.content);
|
||||
}
|
||||
|
||||
|
||||
let params = [
|
||||
`mid=1`,
|
||||
`tid=${tid}`,
|
||||
`page=${page}`,
|
||||
`limit=20`,
|
||||
|
||||
];
|
||||
|
||||
const apiUrl = `${baseUrl}/index.php/ajax/data?${params.join('&')}`;
|
||||
mylog("ajax category url ->", apiUrl);
|
||||
|
||||
const data = await myFetch(apiUrl)
|
||||
if (!data) throw new Error("API 请求无响应");
|
||||
|
||||
|
||||
let list = [];
|
||||
if (Array.isArray(data?.list)) {
|
||||
list = data.list.map(it => {
|
||||
let vod_id = it.vod_id ? `/detail/${it.vod_id}.html` : '';
|
||||
if (!vod_id && it.detail_link) {
|
||||
const match = it.detail_link.match(/\/detail\/(\d+)\.html/);
|
||||
if (match) vod_id = `/detail/${match[1]}.html`;
|
||||
}
|
||||
return {
|
||||
vod_id: vod_id,
|
||||
vod_name: (it.vod_name || '').trim(),
|
||||
vod_pic: fixPic(it.vod_pic || ''),
|
||||
vod_remarks: (it.vod_remarks || '').trim(),
|
||||
vod_year: (it.vod_year || '').trim()
|
||||
};
|
||||
}).filter(it => it.vod_id);
|
||||
}
|
||||
|
||||
const pagecount = parseInt(data?.pagecount) || 1;
|
||||
const total = parseInt(data?.total) || list.length;
|
||||
|
||||
return JSON.stringify({
|
||||
list,
|
||||
page: page,
|
||||
pagecount: pagecount,
|
||||
limit: 20,
|
||||
total: total
|
||||
});
|
||||
} catch (err) {
|
||||
return backError(err, 'category');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function search(wd, quick, page = 1) {
|
||||
if (parseInt(page) >= 2) {
|
||||
return JSON.stringify({ list: [] });
|
||||
}
|
||||
|
||||
try {
|
||||
const cleanWd = decodeURIComponent(wd);
|
||||
const searchUrl = `${baseUrl}/index.php/ajax/suggest?mid=1&wd=${encodeURIComponent(cleanWd)}&limit=30`;
|
||||
mylog("ajax searchUrl:", searchUrl);
|
||||
|
||||
const data = await myFetch(searchUrl)
|
||||
if (!data) throw new Error("搜索请求未返回数据");
|
||||
|
||||
let list = [];
|
||||
|
||||
if (Array.isArray(data?.list)) {
|
||||
list = data.list.map(it => ({
|
||||
vod_id: `/detail/${it.id}.html`,
|
||||
vod_name: (it.name || '').trim(),
|
||||
vod_pic: fixPic(it.pic || ''),
|
||||
vod_remarks: (it.remarks || '').trim()
|
||||
})).filter(it => it.vod_id);
|
||||
}
|
||||
list = list.reverse()
|
||||
|
||||
return JSON.stringify({
|
||||
list,
|
||||
page: 1,
|
||||
});
|
||||
} catch (err) {
|
||||
return backError(err, 'search');
|
||||
}
|
||||
}
|
||||
|
||||
// 图片防盗链/相对路径修补
|
||||
function fixPic(u) {
|
||||
if (!u) return '';
|
||||
if (u.startsWith('//')) return 'https:' + u;
|
||||
return u.replace(/&/g, '&');
|
||||
}
|
||||
|
||||
// DOM 解析逻辑(用于标签/精选分类页面)
|
||||
async function parseList(html) {
|
||||
const $ = cheerio.load(html);
|
||||
const list = [];
|
||||
|
||||
$(".public-list-bj").each((_, el) => {
|
||||
const $el = $(el);
|
||||
const vod_id = $el.find("a.public-list-exp").attr("href");
|
||||
const vod_name = $el.find("a.public-list-exp").attr("title") || $(".thumb-content a").text().trim();
|
||||
const vod_pic = fixPic($el.find(".public-list-exp img").attr("data-src") || '');
|
||||
const vod_remarks = $el.find(".ft2").text().trim();
|
||||
|
||||
const text4k = $el.find('.public-list-exp .public-prt-g').text().trim();
|
||||
const updateTime = $el.find('.public-list-exp .public-prt').eq(1).text().trim();
|
||||
const vod_year = `${text4k ? `「${text4k}」` : ''} ${updateTime}`.trim();
|
||||
|
||||
list.push({ vod_id, vod_name: vod_name?.trim(), vod_pic, vod_remarks, vod_year });
|
||||
});
|
||||
|
||||
const pagecount = parseInt($('.page-tip').text().match(/\d+\/(\d+)页/)?.[1]) || 1;
|
||||
|
||||
return JSON.stringify({ list, pagecount });
|
||||
}
|
||||
|
||||
// 线路与剧集拼接辅助
|
||||
function buildVodPlayData(lines, playlists, shouldReverse = true) {
|
||||
|
||||
const processedPlaylists = playlists.map(eps => (shouldReverse ? [...eps].reverse() : eps).join('#'));
|
||||
return {
|
||||
vod_play_from: lines.filter(Boolean).join('$$$'),
|
||||
vod_play_url: processedPlaylists.join('$$$')
|
||||
};
|
||||
}
|
||||
|
||||
async function detail(vid) {
|
||||
try {
|
||||
const url = baseUrl + vid;
|
||||
const res = await req(url);
|
||||
if (!res?.content) throw new Error("获取详情页失败");
|
||||
|
||||
const $ = cheerio.load(res.content);
|
||||
|
||||
// 1. 播放列表解析 logic
|
||||
const lines = [], playlists = [], nameCounts = {};
|
||||
$('.swiper-slide').each((_, el) => {
|
||||
const rawName = $(el).clone().find('i, span').remove().end().text().trim();
|
||||
if (rawName) {
|
||||
nameCounts[rawName] = (nameCounts[rawName] || 0) + 1;
|
||||
lines.push(nameCounts[rawName] > 1 ? `${rawName}-${nameCounts[rawName]}` : rawName);
|
||||
}
|
||||
});
|
||||
$('.anthology-list-box').each((_, poolEl) => {
|
||||
const episodes = [];
|
||||
$(poolEl).find('a').each((_, epEl) => {
|
||||
const name = $(epEl).text().trim(), href = $(epEl).attr('href') || '';
|
||||
if (name && href) episodes.push(`${name}$${href}`);
|
||||
});
|
||||
playlists.push(episodes);
|
||||
});
|
||||
|
||||
// 辅助函数:快速提取包含特定关键词的标签文本
|
||||
const getInfoText = (key) => {
|
||||
const $box = $(`.detail-info .slide-info:contains("${key}")`).clone();
|
||||
$box.find('strong').remove(); // 移除 "导演:"、"主演:" 等前缀标签
|
||||
return $box.text().replace(/\s+/g, ' ').trim();
|
||||
};
|
||||
|
||||
// 2. 提取各项详细数据
|
||||
const vod_name = $('.slide-info-title').text().trim();
|
||||
|
||||
// 封面图(优先取 data-src,没有则取 src)
|
||||
const imgAttr = $('.detail-pic img').attr("data-src") || $('.detail-pic img').attr("src") || '';
|
||||
const vod_pic = fixPic(imgAttr);
|
||||
|
||||
// 导演
|
||||
const vod_director = getInfoText("导演");
|
||||
|
||||
// 主演/演员(匹配 HTML 中的 "主演")
|
||||
const vod_actor = getInfoText("主演");
|
||||
|
||||
// 连载 / 备注(优先提取 "连载",若无则提取 "更新")
|
||||
let vod_remarks = getInfoText("连载");
|
||||
if (!vod_remarks) {
|
||||
vod_remarks = getInfoText("更新");
|
||||
}
|
||||
|
||||
// 年份(从没有包含 strong 标签的 slide-info 中提取纯日期/年份)
|
||||
|
||||
|
||||
// 剧情简介
|
||||
const vod_content = $('#height_limit').text().trim() || $('.detail-info .slide-info-p').text().trim();
|
||||
|
||||
// 3. 构建播放数据
|
||||
const { vod_play_from, vod_play_url } = buildVodPlayData(lines, playlists, true);
|
||||
|
||||
return JSON.stringify({
|
||||
list: [{
|
||||
vod_id: vid,
|
||||
vod_name: vod_name,
|
||||
vod_pic: vod_pic,
|
||||
vod_director: vod_director,
|
||||
vod_actor: vod_actor,
|
||||
vod_remarks: vod_remarks,
|
||||
vod_content: vod_content,
|
||||
vod_play_from,
|
||||
vod_play_url
|
||||
}]
|
||||
});
|
||||
} catch (err) {
|
||||
return backError(err, 'detail');
|
||||
}
|
||||
}
|
||||
|
||||
const parseMap = {
|
||||
'JD': "https://fgsrg.hzqingshan.com",
|
||||
'co': "https://zzrs.mfdyvip.com",
|
||||
'knmb': "https://zzrs.mfdyvip.com",
|
||||
'YYNB': "https://zzrs.mfdyvip.com"
|
||||
};
|
||||
|
||||
async function parsePLayUrl(url) {
|
||||
try {
|
||||
const lineKey = url.split(/[-_]/)?.[0];
|
||||
const parseApiUrl = parseMap[lineKey] || parseList["JD"];
|
||||
if (!parseApiUrl) throw new Error(`未找到匹配的解析接口[${lineKey}]`);
|
||||
|
||||
const htmlRes = await req(`${parseApiUrl}/player/?url=${url}`, { headers: Headers });
|
||||
if (!htmlRes?.content) throw new Error("获取解析播放器页面失败");
|
||||
|
||||
const token = cheerio.load(htmlRes.content)('#player-data').attr('data-te');
|
||||
if (!token) throw new Error("未寻找到 token 数据");
|
||||
|
||||
const playDataRes = await req(`${parseApiUrl}/player/mplayer.php`, {
|
||||
method: 'POST', postType: 'form',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' },
|
||||
data: { url, token }
|
||||
});
|
||||
|
||||
if (!playDataRes?.content) throw new Error("二次解析接口请求失败");
|
||||
|
||||
let parsePlayUrl = JSON.parse(playDataRes.content).url;
|
||||
if (!parsePlayUrl) throw new Error("二次解析未获取到 URL");
|
||||
|
||||
return parsePlayUrl.startsWith('/playproxy.php') ? parseApiUrl + parsePlayUrl : parsePlayUrl;
|
||||
} catch (err) {
|
||||
mylog("parsePLayUrl 内部错误:", err.message);
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function play(flag, id) {
|
||||
try {
|
||||
const detailUrl = `${baseUrl}${id}`;
|
||||
mylog('detailUrl', detailUrl);
|
||||
|
||||
const res = await req(detailUrl);
|
||||
if (!res?.content) throw new Error("详情页网络请求失败");
|
||||
|
||||
const match = res.content.match(/var\s+player_aaaa[\s\S]*?"url"\s*:\s*"([^"]+)"/);
|
||||
const url = match ? match[1].replace(/\\/g, '') : '';
|
||||
|
||||
if (!url) throw new Error("页面中未匹配到视频 URL 变量");
|
||||
|
||||
if (url.startsWith('http') && (url.includes("m3u") || url.includes('.mp4'))) {
|
||||
mylog("直链播放", url);
|
||||
return JSON.stringify({ parse: 0, url });
|
||||
}
|
||||
|
||||
const playUrl = await parsePLayUrl(url);
|
||||
if (!playUrl) throw new Error("线路解析失败,请尝试切换播放线路");
|
||||
|
||||
return JSON.stringify({ parse: 0, url: playUrl });
|
||||
} catch (err) {
|
||||
return backError(err, 'play');
|
||||
}
|
||||
}
|
||||
|
||||
export default { init, home, homeVod, category, detail, search, play };
|
||||
@@ -93,6 +93,12 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/4K影视.py"
|
||||
},
|
||||
{
|
||||
"key": "蝶卡影视",
|
||||
"name": "🐬蝶卡影视.py(关梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/蝶卡影视.py"
|
||||
},
|
||||
{
|
||||
"key": "ddyy",
|
||||
"name": "🐬低端影视.py",
|
||||
@@ -138,6 +144,13 @@
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/片库.js",
|
||||
"changeable": 0
|
||||
},
|
||||
{
|
||||
"key": "枫叶影院",
|
||||
"name": "🐬枫叶影院.js(关梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/枫叶影院.js",
|
||||
"changeable": 0
|
||||
},
|
||||
{
|
||||
"key": "qqvm",
|
||||
"name": "🐬千千影视.py(关梯)(羊壳观看)",
|
||||
@@ -918,6 +931,24 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/蜜桃视频.py"
|
||||
},
|
||||
{
|
||||
"key": "樱花传媒",
|
||||
"name": "🐬樱花传媒.py|🔞",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/樱花传媒.py"
|
||||
},
|
||||
{
|
||||
"key": "BadNews",
|
||||
"name": "🐬BadNews.py|🔞(挂梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/BadNews.py"
|
||||
},
|
||||
{
|
||||
"key": "麻豆社",
|
||||
"name": "🐬麻豆社.py|🔞",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麻豆社.py"
|
||||
},
|
||||
{
|
||||
"key": "prsd",
|
||||
"name": "🐬怦然心动.py|🔞",
|
||||
@@ -1551,6 +1582,17 @@
|
||||
"type": 0,
|
||||
"ua": "okhttp",
|
||||
"url": "https://php.946985.filegear-sg.me/jackTV.m3u"
|
||||
},
|
||||
{
|
||||
"name": "🐬海豚佬🔞午夜剧场(挂梯)",
|
||||
"type": 0,
|
||||
"playerType": 2,
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png",
|
||||
"url": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/午夜剧场.txt",
|
||||
"header": {
|
||||
"X-Api-key": "8692bd33270440d4d2941b814b81a25a1eb84a7205532971714c4299c57449e0..0..1738210005..5cf2c4ad-043b-4234-83a2-e84ae917"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "🐬海豚佬xxx视频资源🔞",
|
||||
|
||||
@@ -142,6 +142,12 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/4K影视.py"
|
||||
},
|
||||
{
|
||||
"key": "蝶卡影视",
|
||||
"name": "🐬蝶卡影视.py(关梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/蝶卡影视.py"
|
||||
},
|
||||
{
|
||||
"key": "ddyy",
|
||||
"name": "🐬低端影视.py",
|
||||
@@ -574,6 +580,24 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/蜜桃视频.py"
|
||||
},
|
||||
{
|
||||
"key": "樱花传媒",
|
||||
"name": "🐬樱花传媒.py|🔞",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/樱花传媒.py"
|
||||
},
|
||||
{
|
||||
"key": "BadNews",
|
||||
"name": "🐬BadNews.py|🔞(挂梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/BadNews.py"
|
||||
},
|
||||
{
|
||||
"key": "麻豆社",
|
||||
"name": "🐬麻豆社.py|🔞",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麻豆社.py"
|
||||
},
|
||||
{
|
||||
"key": "prsd",
|
||||
"name": "🐬怦然心动.py|🔞",
|
||||
@@ -1082,6 +1106,17 @@
|
||||
"ua": "okhttp",
|
||||
"url": "https://php.946985.filegear-sg.me/jackTV.m3u"
|
||||
},
|
||||
{
|
||||
"name": "🐬海豚佬🔞午夜剧场(挂梯)",
|
||||
"type": 0,
|
||||
"playerType": 2,
|
||||
"epg": "https://epg.112114.xyz/?ch={name}&date={date}",
|
||||
"logo": "https://epg.112114.xyz/logo/{name}.png",
|
||||
"url": "https://gh-proxy.com/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/午夜剧场.txt",
|
||||
"header": {
|
||||
"X-Api-key": "8692bd33270440d4d2941b814b81a25a1eb84a7205532971714c4299c57449e0..0..1738210005..5cf2c4ad-043b-4234-83a2-e84ae917"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "🐬海豚佬xxx视频资源🔞",
|
||||
"type": 0,
|
||||
|
||||
@@ -93,6 +93,12 @@
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/4K影视.py"
|
||||
},
|
||||
{
|
||||
"key": "蝶卡影视",
|
||||
"name": "🐬蝶卡影视.py(关梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/蝶卡影视.py"
|
||||
},
|
||||
{
|
||||
"key": "ddyy",
|
||||
"name": "🐬低端影视.py",
|
||||
@@ -138,6 +144,13 @@
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/片库.js",
|
||||
"changeable": 0
|
||||
},
|
||||
{
|
||||
"key": "枫叶影院",
|
||||
"name": "🐬枫叶影院.js(关梯)",
|
||||
"type": 3,
|
||||
"api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/枫叶影院.js",
|
||||
"changeable": 0
|
||||
},
|
||||
{
|
||||
"key": "qqvm",
|
||||
"name": "🐬千千影视.py(关梯)(羊壳观看)",
|
||||
|
||||
@@ -0,0 +1,874 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# //@name:BadNews直播放
|
||||
# //@id:badnews_direct
|
||||
# //@version:7
|
||||
|
||||
import hashlib
|
||||
import html as html_lib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from urllib.parse import quote, unquote, urljoin, urlsplit
|
||||
|
||||
import requests
|
||||
from lxml import html
|
||||
|
||||
from base.spider import Spider as BaseSpider
|
||||
|
||||
try:
|
||||
from com.github.catvod import Proxy as CatVodProxy
|
||||
except Exception:
|
||||
CatVodProxy = None
|
||||
|
||||
|
||||
class Spider(BaseSpider):
|
||||
name = "BadNews直播放"
|
||||
host = "https://bad.news"
|
||||
backend_parse = False
|
||||
category_mode = False
|
||||
categoryMode = False
|
||||
|
||||
PLAY_PREFIX = "badnews-play:"
|
||||
ERROR_PREFIX = "badnews-error:"
|
||||
DEFAULT_PIC = "https://bad.news/favicon.ico"
|
||||
|
||||
CATEGORY_SPECS = (
|
||||
("hot", "热门视频", "entry", "/sort-hot"),
|
||||
("new", "最新视频", "entry", "/sort-new"),
|
||||
("short", "短视频", "entry", "/tag/porn"),
|
||||
("long", "长视频", "entry", "/tag/long-porn"),
|
||||
("dm", "H动漫", "dm", "/dm"),
|
||||
("dm_3d", "3D动画", "dm", "/dm/type/q-3D"),
|
||||
("dm_doujin", "同人作品", "dm", "/dm/type/q-同人"),
|
||||
("dm_cosplay", "Cosplay", "dm", "/dm/type/q-Cosplay"),
|
||||
("better", "精选视频", "entry", "/sort-better"),
|
||||
("score", "高分视频", "entry", "/sort-score"),
|
||||
)
|
||||
|
||||
BLOCKED_HOSTS = frozenset(
|
||||
{
|
||||
"script-center.bad.news",
|
||||
"portalfluently.com",
|
||||
"vivodemisrentas.net",
|
||||
"secretlygoatsarrangement.com",
|
||||
"ri1.xlfn.cc",
|
||||
"static.cloudflareinsights.com",
|
||||
"www.google-analytics.com",
|
||||
"www.googletagmanager.com",
|
||||
"www.statcounter.com",
|
||||
}
|
||||
)
|
||||
MEDIA_HOSTS = frozenset({"video.twimg.com", "static.bad.news"})
|
||||
CHALLENGE_MARKERS = (
|
||||
"just a moment",
|
||||
"/cdn-cgi/challenge-platform",
|
||||
"_cf_chl_opt",
|
||||
"cf-turnstile",
|
||||
"turnstile",
|
||||
)
|
||||
CONTENT_MARKERS = (
|
||||
'class="entry',
|
||||
"class='entry",
|
||||
"<article",
|
||||
"<video",
|
||||
"data-source=",
|
||||
"/dm/play/id-",
|
||||
)
|
||||
VIDEO_URL_RE = re.compile(r"\.(?:m3u8|mp4)(?:$|[?#])", re.I)
|
||||
PAGE_RE = re.compile(r"/page-(\d+)(?:$|[/?#])", re.I)
|
||||
TOPIC_ID_RE = re.compile(r"/t/(\d+)(?:$|[/?#])", re.I)
|
||||
DM_ID_RE = re.compile(r"/dm/play/id-(\d+)(?:$|[/?#])", re.I)
|
||||
|
||||
def __init__(self):
|
||||
try:
|
||||
super().__init__()
|
||||
except Exception:
|
||||
pass
|
||||
self.timeout = 15
|
||||
self.verify_tls = True
|
||||
self.trust_env = True
|
||||
self.proxy = ""
|
||||
self.cache_ttl = 30
|
||||
self.prefer_progressive_mp4 = True
|
||||
self.lock_hls_highest = True
|
||||
self.user_agent = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/131.0.0.0 Safari/537.36"
|
||||
)
|
||||
self._session = None
|
||||
self._cache = {}
|
||||
self._proxy_manifests = {}
|
||||
self._reset_session()
|
||||
|
||||
def getName(self):
|
||||
return self.name
|
||||
|
||||
def init(self, extend=""):
|
||||
config = self._parse_config(extend)
|
||||
configured_host = str(config.get("host") or self.host).strip().rstrip("/")
|
||||
if configured_host.startswith(("http://", "https://")):
|
||||
self.host = configured_host
|
||||
self.timeout = self._bounded_int(config.get("timeout"), self.timeout, 5, 45)
|
||||
self.cache_ttl = self._bounded_int(config.get("cache_ttl"), self.cache_ttl, 0, 300)
|
||||
self.verify_tls = self._bool_value(config.get("verify_tls"), self.verify_tls)
|
||||
self.trust_env = self._bool_value(config.get("trust_env"), self.trust_env)
|
||||
self.prefer_progressive_mp4 = self._bool_value(
|
||||
config.get("prefer_progressive_mp4", config.get("prefer_mp4")),
|
||||
self.prefer_progressive_mp4,
|
||||
)
|
||||
self.lock_hls_highest = self._bool_value(
|
||||
config.get("lock_hls_highest"), self.lock_hls_highest
|
||||
)
|
||||
self.proxy = str(config.get("proxy") or "").strip()
|
||||
configured_ua = str(config.get("user_agent") or "").strip()
|
||||
if configured_ua:
|
||||
self.user_agent = configured_ua
|
||||
self._cache.clear()
|
||||
self._proxy_manifests.clear()
|
||||
self._reset_session()
|
||||
|
||||
def destroy(self):
|
||||
if self._session is not None:
|
||||
try:
|
||||
self._session.close()
|
||||
except Exception:
|
||||
pass
|
||||
self._session = None
|
||||
self._cache.clear()
|
||||
self._proxy_manifests.clear()
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
return bool(self.VIDEO_URL_RE.search(str(url or "")))
|
||||
|
||||
def manualVideoCheck(self):
|
||||
return False
|
||||
|
||||
def localProxy(self, param):
|
||||
data = param if isinstance(param, dict) else self._parse_config(param)
|
||||
token = str(data.get("token") or "").strip()
|
||||
cached = self._proxy_manifests.get(token)
|
||||
if not cached or time.time() - cached[0] > 1800:
|
||||
return [404, "text/plain; charset=utf-8", b"manifest not found"]
|
||||
return [
|
||||
200,
|
||||
"application/vnd.apple.mpegurl",
|
||||
cached[1],
|
||||
{"Cache-Control": "no-store", "Access-Control-Allow-Origin": "*"},
|
||||
]
|
||||
|
||||
def homeContent(self, filter):
|
||||
return {
|
||||
"class": [
|
||||
{"type_id": type_id, "type_name": type_name}
|
||||
for type_id, type_name, _, _ in self.CATEGORY_SPECS
|
||||
],
|
||||
"filters": {},
|
||||
}
|
||||
|
||||
def homeVideoContent(self):
|
||||
result = self.categoryContent("new", "1", False, {})
|
||||
return {"list": result.get("list", [])}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
self._parse_config(extend)
|
||||
page = self._page_number(pg)
|
||||
spec = self._category_spec(tid)
|
||||
if spec is None:
|
||||
return self._empty_page(page, "未知分类")
|
||||
_, _, parser_kind, base_path = spec
|
||||
paths = [self._paged_path(base_path, page)]
|
||||
if str(tid) == "hot":
|
||||
paths.append("/" if page == 1 else "/page-%d" % page)
|
||||
|
||||
last_error = None
|
||||
for path in paths:
|
||||
try:
|
||||
source, page_url = self._request_text(path)
|
||||
if parser_kind == "dm":
|
||||
return self._parse_dm_page(source, page, page_url)
|
||||
return self._parse_entry_page(source, page, page_url)
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
print("[badnews-probe] category path=%s error=%s" % (path, exc))
|
||||
return self._empty_page(page, "分类读取失败: %s" % last_error)
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
keyword = self._clean_text(key)
|
||||
page = self._page_number(pg)
|
||||
if not keyword:
|
||||
return self._empty_page(page)
|
||||
|
||||
encoded = quote(keyword, safe="")
|
||||
main_path = "/search/q-%s/type-porn" % encoded
|
||||
dm_path = "/dm/search/q-%s" % encoded
|
||||
if page > 1:
|
||||
main_path += "/page-%d" % page
|
||||
dm_path += "/page-%d" % page
|
||||
|
||||
items = []
|
||||
pagecount = page
|
||||
errors = []
|
||||
for parser_kind, path in (("entry", main_path), ("dm", dm_path)):
|
||||
try:
|
||||
source, page_url = self._request_text(path)
|
||||
parsed = (
|
||||
self._parse_dm_page(source, page, page_url)
|
||||
if parser_kind == "dm"
|
||||
else self._parse_entry_page(source, page, page_url)
|
||||
)
|
||||
items.extend(parsed.get("list", []))
|
||||
pagecount = max(pagecount, self._page_number(parsed.get("pagecount")))
|
||||
except Exception as exc:
|
||||
errors.append(str(exc))
|
||||
|
||||
deduped = []
|
||||
seen = set()
|
||||
for item in items:
|
||||
vod_id = str(item.get("vod_id") or "")
|
||||
if not vod_id or vod_id in seen:
|
||||
continue
|
||||
seen.add(vod_id)
|
||||
deduped.append(item)
|
||||
return {
|
||||
"list": deduped,
|
||||
"page": page,
|
||||
"pagecount": pagecount,
|
||||
"limit": len(deduped),
|
||||
"total": pagecount * max(len(deduped), 1),
|
||||
"msg": "; ".join(errors) if errors and not deduped else "",
|
||||
}
|
||||
|
||||
def detailContent(self, ids):
|
||||
raw_id = ids[0] if isinstance(ids, (list, tuple)) and ids else ids
|
||||
value = str(raw_id or "").strip()
|
||||
if value.startswith("atvp_detail:"):
|
||||
value = value[len("atvp_detail:") :].strip()
|
||||
if value.startswith(self.PLAY_PREFIX):
|
||||
value = value[len(self.PLAY_PREFIX) :].strip()
|
||||
kind, item_id = self._split_vod_id(value)
|
||||
if not kind or not item_id:
|
||||
return {"list": []}
|
||||
|
||||
path = "/t/%s" % item_id if kind == "t" else "/dm/play/id-%s" % item_id
|
||||
try:
|
||||
source, page_url = self._request_text(path, fresh=(kind == "dm"))
|
||||
vod, _ = self._parse_detail_page(source, kind, item_id, page_url)
|
||||
return {"list": [vod]}
|
||||
except Exception as exc:
|
||||
return {"list": [self._detail_error(value, str(exc))]}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
value = str(id or "").strip()
|
||||
if value.startswith(self.ERROR_PREFIX):
|
||||
return self._player_error(unquote(value[len(self.ERROR_PREFIX) :]))
|
||||
if value.startswith(("http://", "https://")):
|
||||
if not self._is_allowed_media_url(value):
|
||||
return self._player_error("播放地址域名不在媒体白名单")
|
||||
return self._player_for_media(value, self._media_type(value))
|
||||
if not value.startswith(self.PLAY_PREFIX):
|
||||
return self._player_error("无法识别播放 ID")
|
||||
|
||||
kind, item_id = self._split_vod_id(value[len(self.PLAY_PREFIX) :])
|
||||
if not kind or not item_id:
|
||||
return self._player_error("播放 ID 不完整")
|
||||
path = "/t/%s" % item_id if kind == "t" else "/dm/play/id-%s" % item_id
|
||||
try:
|
||||
source, page_url = self._request_text(path, fresh=(kind == "dm"))
|
||||
_, media = self._parse_detail_page(source, kind, item_id, page_url)
|
||||
return self._player_for_media(
|
||||
media["url"], media["type"], media.get("source", "primary")
|
||||
)
|
||||
except Exception as exc:
|
||||
return self._player_error("播放地址刷新失败: %s" % exc)
|
||||
|
||||
def _reset_session(self):
|
||||
if self._session is not None:
|
||||
try:
|
||||
self._session.close()
|
||||
except Exception:
|
||||
pass
|
||||
session = requests.Session()
|
||||
session.trust_env = self.trust_env
|
||||
session.headers.update(
|
||||
{
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.5",
|
||||
"Cache-Control": "no-cache",
|
||||
}
|
||||
)
|
||||
if self.proxy:
|
||||
session.proxies.update({"http": self.proxy, "https": self.proxy})
|
||||
self._session = session
|
||||
|
||||
def _request_text(self, path, fresh=False):
|
||||
url = self._absolute_url(path)
|
||||
if not self._is_allowed_html_url(url):
|
||||
raise RuntimeError("已阻止非站点 HTML 请求")
|
||||
now = time.time()
|
||||
if not fresh and self.cache_ttl > 0:
|
||||
cached = self._cache.get(url)
|
||||
if cached and now - cached[0] <= self.cache_ttl:
|
||||
return cached[1], cached[2]
|
||||
|
||||
last_error = None
|
||||
for attempt in range(2):
|
||||
try:
|
||||
response = self._session.get(
|
||||
url,
|
||||
timeout=(min(self.timeout, 10), self.timeout),
|
||||
allow_redirects=True,
|
||||
verify=self.verify_tls,
|
||||
)
|
||||
final_url = str(response.url or url)
|
||||
if not self._is_allowed_html_url(final_url):
|
||||
raise RuntimeError("已阻止外域跳转: %s" % urlsplit(final_url).hostname)
|
||||
text = self._response_text(response)
|
||||
if self._looks_like_challenge(response.status_code, text):
|
||||
raise RuntimeError("blocked_by_waf: 页面返回挑战或验证码")
|
||||
if response.status_code == 429 and attempt == 0:
|
||||
retry_after = self._bounded_int(
|
||||
response.headers.get("Retry-After"), 1, 1, 3
|
||||
)
|
||||
time.sleep(retry_after)
|
||||
continue
|
||||
response.raise_for_status()
|
||||
if not fresh and self.cache_ttl > 0:
|
||||
self._cache[url] = (time.time(), text, final_url)
|
||||
return text, final_url
|
||||
except requests.RequestException as exc:
|
||||
last_error = exc
|
||||
if attempt == 0:
|
||||
time.sleep(0.4)
|
||||
continue
|
||||
break
|
||||
except RuntimeError:
|
||||
raise
|
||||
raise RuntimeError("网络请求失败: %s" % last_error)
|
||||
|
||||
def _parse_entry_page(self, source, page, page_url):
|
||||
tree = self._tree(source)
|
||||
items = []
|
||||
entries = tree.xpath(
|
||||
'//div[contains(concat(" ", normalize-space(@class), " "), " entry ")]'
|
||||
)
|
||||
for entry in entries:
|
||||
videos = entry.xpath('.//video[@data-source or @data-id]')
|
||||
if not videos:
|
||||
continue
|
||||
video = videos[0]
|
||||
item_id = self._digits(video.get("data-id"))
|
||||
if not item_id:
|
||||
item_id = self._first_matching_id(entry.xpath('.//a/@href'), self.TOPIC_ID_RE)
|
||||
if not item_id:
|
||||
continue
|
||||
title = self._first_text(
|
||||
entry.xpath(
|
||||
'.//h3[contains(concat(" ", normalize-space(@class), " "), " title ")]'
|
||||
'/a[contains(concat(" ", normalize-space(@class), " "), " title ")][1]'
|
||||
)
|
||||
)
|
||||
if not title:
|
||||
title = "视频 %s" % item_id
|
||||
pic = self._absolute_media_url(
|
||||
video.get("data-poster") or video.get("poster") or "", page_url
|
||||
)
|
||||
duration = self._clean_text(
|
||||
" ".join(entry.xpath('.//*[contains(@class,"ct-time")]//text()'))
|
||||
)
|
||||
tag = self._first_text(entry.xpath('.//h4[contains(@class,"label")]'))
|
||||
media_type = str(video.get("data-type") or "").upper()
|
||||
remarks = duration or tag or media_type
|
||||
items.append(
|
||||
{
|
||||
"vod_id": "t:%s" % item_id,
|
||||
"vod_name": title,
|
||||
"vod_pic": pic or self.DEFAULT_PIC,
|
||||
"vod_remarks": remarks,
|
||||
}
|
||||
)
|
||||
return self._page_result(items, tree, page, 25)
|
||||
|
||||
def _parse_dm_page(self, source, page, page_url):
|
||||
tree = self._tree(source)
|
||||
items = []
|
||||
articles = tree.xpath('//article[.//a[contains(@href,"/dm/play/id-")]]')
|
||||
for article in articles:
|
||||
title_links = article.xpath(
|
||||
'.//a[contains(concat(" ", normalize-space(@class), " "), " title ")][1]'
|
||||
)
|
||||
links = title_links or article.xpath('.//a[contains(@href,"/dm/play/id-")][1]')
|
||||
if not links:
|
||||
continue
|
||||
link = links[0]
|
||||
href = str(link.get("href") or "")
|
||||
item_id = self._first_matching_id([href], self.DM_ID_RE)
|
||||
if not item_id:
|
||||
continue
|
||||
title = self._clean_text(link.get("title") or link.text_content())
|
||||
images = article.xpath('.//img[1]')
|
||||
pic = ""
|
||||
if images:
|
||||
image = images[0]
|
||||
pic = self._absolute_media_url(
|
||||
image.get("data-echo")
|
||||
or image.get("data-src")
|
||||
or image.get("src")
|
||||
or "",
|
||||
page_url,
|
||||
)
|
||||
items.append(
|
||||
{
|
||||
"vod_id": "dm:%s" % item_id,
|
||||
"vod_name": title or "动漫 %s" % item_id,
|
||||
"vod_pic": pic or self.DEFAULT_PIC,
|
||||
"vod_remarks": "MP4",
|
||||
}
|
||||
)
|
||||
return self._page_result(items, tree, page, 30)
|
||||
|
||||
def _parse_detail_page(self, source, kind, item_id, page_url):
|
||||
tree = self._tree(source)
|
||||
if kind == "t":
|
||||
videos = tree.xpath('//video[@data-id="%s"]' % item_id)
|
||||
if not videos:
|
||||
videos = tree.xpath('//video[@data-source][1]')
|
||||
else:
|
||||
videos = tree.xpath('//video[@data-source][1]')
|
||||
if not videos:
|
||||
raise RuntimeError("详情页没有 video[data-source]")
|
||||
video = videos[0]
|
||||
media_url = self._absolute_media_url(
|
||||
video.get("data-source") or video.get("src") or "", page_url
|
||||
)
|
||||
if not self._is_allowed_media_url(media_url):
|
||||
raise RuntimeError("详情媒体域名不在白名单")
|
||||
detected_type = self._media_type(media_url)
|
||||
declared_type = str(video.get("data-type") or "").lower()
|
||||
media_type = (
|
||||
detected_type
|
||||
if detected_type in ("mp4", "m3u8")
|
||||
else declared_type
|
||||
)
|
||||
media_source = "primary-%s" % media_type
|
||||
if self.prefer_progressive_mp4 and media_type == "m3u8":
|
||||
for meta_property in ("og:video:secure_url", "og:video"):
|
||||
progressive_url = self._absolute_media_url(
|
||||
self._meta_content(tree, "property", meta_property), page_url
|
||||
)
|
||||
if (
|
||||
self._media_type(progressive_url) == "mp4"
|
||||
and self._is_allowed_media_url(progressive_url)
|
||||
):
|
||||
media_url = progressive_url
|
||||
media_type = "mp4"
|
||||
media_source = "target-og-progressive-mp4"
|
||||
break
|
||||
title = self._meta_content(tree, "property", "og:title")
|
||||
if not title:
|
||||
title = self._meta_content(tree, "name", "headline")
|
||||
if not title:
|
||||
headings = tree.xpath('//h1[1] | //h2[1]')
|
||||
title = self._first_text(headings)
|
||||
if not title:
|
||||
title = ("动漫 " if kind == "dm" else "视频 ") + item_id
|
||||
pic = self._meta_content(tree, "property", "og:image")
|
||||
if not pic:
|
||||
pic = video.get("data-poster") or video.get("poster") or ""
|
||||
pic = self._absolute_media_url(pic, page_url) or self.DEFAULT_PIC
|
||||
content = self._meta_content(tree, "name", "Description")
|
||||
if not content:
|
||||
content = self._meta_content(tree, "property", "og:description")
|
||||
vod_id = "%s:%s" % (kind, item_id)
|
||||
play_target = self.PLAY_PREFIX + vod_id
|
||||
vod = {
|
||||
"vod_id": vod_id,
|
||||
"vod_name": title,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": media_type.upper(),
|
||||
"vod_content": content,
|
||||
"vod_play_from": "BadNews动漫" if kind == "dm" else "BadNews直连",
|
||||
"vod_play_url": "播放$%s" % play_target,
|
||||
}
|
||||
return vod, {"url": media_url, "type": media_type, "source": media_source}
|
||||
|
||||
def _page_result(self, items, tree, page, default_limit):
|
||||
pagecount = page
|
||||
for href in tree.xpath('//a/@href'):
|
||||
match = self.PAGE_RE.search(str(href or ""))
|
||||
if match:
|
||||
pagecount = max(pagecount, self._page_number(match.group(1)))
|
||||
limit = len(items) or default_limit
|
||||
return {
|
||||
"list": items,
|
||||
"page": page,
|
||||
"pagecount": pagecount,
|
||||
"limit": limit,
|
||||
"total": pagecount * limit,
|
||||
}
|
||||
|
||||
def _player_result(self, media_url, media_type):
|
||||
result = {
|
||||
"parse": 0,
|
||||
"jx": 0,
|
||||
"playUrl": "",
|
||||
"url": media_url,
|
||||
"header": {"User-Agent": self.user_agent},
|
||||
"type": media_type,
|
||||
}
|
||||
if media_type == "m3u8":
|
||||
result["format"] = "application/x-mpegURL"
|
||||
return result
|
||||
|
||||
def _player_for_media(self, media_url, media_type, media_source="primary"):
|
||||
self._probe_log(
|
||||
"media_selected type=%s source=%s host=%s"
|
||||
% (media_type, media_source, urlsplit(media_url).hostname or "")
|
||||
)
|
||||
if media_type == "m3u8" and self.lock_hls_highest:
|
||||
try:
|
||||
locked_url = self._prepare_locked_hls(media_url)
|
||||
if locked_url:
|
||||
self._probe_log("hls_highest_locked source=%s" % media_source)
|
||||
return self._player_result(locked_url, "m3u8")
|
||||
except Exception as exc:
|
||||
self._probe_log("hls_lock_failed url=%s error=%s" % (media_url, exc))
|
||||
self._probe_log("hls_original_fallback source=%s" % media_source)
|
||||
return self._player_result(media_url, media_type)
|
||||
|
||||
def _prepare_locked_hls(self, master_url):
|
||||
source = self._request_media_text(master_url)
|
||||
manifest = self._highest_hls_manifest(source, master_url)
|
||||
if not manifest:
|
||||
return ""
|
||||
token = hashlib.sha256(
|
||||
(master_url + "\n" + manifest).encode("utf-8")
|
||||
).hexdigest()[:24]
|
||||
self._proxy_manifests[token] = (time.time(), manifest.encode("utf-8"))
|
||||
if len(self._proxy_manifests) > 16:
|
||||
oldest = min(self._proxy_manifests, key=lambda key: self._proxy_manifests[key][0])
|
||||
self._proxy_manifests.pop(oldest, None)
|
||||
site_key = quote(str(getattr(self, "siteKey", "") or "badnews"), safe="")
|
||||
return "%s?siteKey=%s&token=%s" % (
|
||||
self._proxy_base_url(),
|
||||
site_key,
|
||||
token,
|
||||
)
|
||||
|
||||
def _request_media_text(self, url):
|
||||
if not self._is_allowed_media_url(url) or self._media_type(url) != "m3u8":
|
||||
raise RuntimeError("媒体列表地址不在白名单")
|
||||
last_error = None
|
||||
for attempt in range(3):
|
||||
try:
|
||||
response = self._session.get(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": self.user_agent,
|
||||
"Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,*/*",
|
||||
},
|
||||
timeout=(min(self.timeout, 10), self.timeout),
|
||||
verify=self.verify_tls,
|
||||
allow_redirects=True,
|
||||
)
|
||||
if not 200 <= response.status_code < 300:
|
||||
raise RuntimeError("HLS HTTP %s" % response.status_code)
|
||||
source = response.content.decode("utf-8", errors="replace")
|
||||
if "#EXTM3U" not in source:
|
||||
raise RuntimeError("HLS 响应缺少 EXTM3U")
|
||||
return source
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
if attempt < 2:
|
||||
time.sleep(0.15 * (attempt + 1))
|
||||
raise RuntimeError("HLS 主列表读取失败: %s" % last_error)
|
||||
|
||||
def _highest_hls_manifest(self, source, master_url):
|
||||
lines = [line.strip() for line in str(source or "").splitlines() if line.strip()]
|
||||
streams = []
|
||||
media_lines = {}
|
||||
for index, line in enumerate(lines):
|
||||
if line.startswith("#EXT-X-MEDIA:"):
|
||||
attrs = self._hls_attrs(line.split(":", 1)[1])
|
||||
if attrs.get("TYPE") == "AUDIO" and attrs.get("GROUP-ID"):
|
||||
media_lines[attrs["GROUP-ID"]] = (line, attrs)
|
||||
elif line.startswith("#EXT-X-STREAM-INF:"):
|
||||
attrs = self._hls_attrs(line.split(":", 1)[1])
|
||||
uri = ""
|
||||
for candidate in lines[index + 1 :]:
|
||||
if candidate.startswith("#"):
|
||||
continue
|
||||
uri = candidate
|
||||
break
|
||||
if uri:
|
||||
resolution = attrs.get("RESOLUTION", "0x0").lower().split("x")
|
||||
try:
|
||||
pixels = int(resolution[0]) * int(resolution[1])
|
||||
except Exception:
|
||||
pixels = 0
|
||||
try:
|
||||
bandwidth = int(attrs.get("AVERAGE-BANDWIDTH") or attrs.get("BANDWIDTH") or 0)
|
||||
except Exception:
|
||||
bandwidth = 0
|
||||
streams.append((pixels, bandwidth, line, attrs, uri))
|
||||
if not streams:
|
||||
return ""
|
||||
_, _, stream_line, stream_attrs, stream_uri = max(
|
||||
streams, key=lambda item: (item[0], item[1])
|
||||
)
|
||||
audio_group = stream_attrs.get("AUDIO", "")
|
||||
output = ["#EXTM3U", "#EXT-X-VERSION:6", "#EXT-X-INDEPENDENT-SEGMENTS"]
|
||||
if audio_group in media_lines:
|
||||
audio_line, audio_attrs = media_lines[audio_group]
|
||||
audio_uri = audio_attrs.get("URI", "")
|
||||
if audio_uri:
|
||||
absolute_audio = urljoin(master_url, audio_uri)
|
||||
audio_line = re.sub(
|
||||
r'URI=(?:"[^"]*"|[^,]*)', 'URI="%s"' % absolute_audio, audio_line
|
||||
)
|
||||
output.append(audio_line)
|
||||
output.append(stream_line)
|
||||
output.append(urljoin(master_url, stream_uri))
|
||||
return "\n".join(output) + "\n"
|
||||
|
||||
@staticmethod
|
||||
def _hls_attrs(text):
|
||||
attrs = {}
|
||||
for match in re.finditer(r'([A-Z0-9-]+)=("[^"]*"|[^,]*)', str(text or "")):
|
||||
value = match.group(2).strip()
|
||||
if len(value) >= 2 and value[0] == value[-1] == '"':
|
||||
value = value[1:-1]
|
||||
attrs[match.group(1)] = value
|
||||
return attrs
|
||||
|
||||
@staticmethod
|
||||
def _proxy_base_url():
|
||||
if CatVodProxy is not None:
|
||||
return str(CatVodProxy.getUrl(True))
|
||||
return "http://127.0.0.1:9978/proxy"
|
||||
|
||||
def _player_error(self, message):
|
||||
text = self._clean_text(message) or "播放失败"
|
||||
return {
|
||||
"parse": 0,
|
||||
"jx": 0,
|
||||
"playUrl": "",
|
||||
"url": "",
|
||||
"header": {},
|
||||
"msg": text,
|
||||
"content": text,
|
||||
"error": text,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _probe_log(message):
|
||||
print("[badnews-probe] %s" % message)
|
||||
|
||||
def _detail_error(self, vod_id, message):
|
||||
text = self._clean_text(message) or "详情读取失败"
|
||||
return {
|
||||
"vod_id": vod_id or "error",
|
||||
"vod_name": "详情读取失败",
|
||||
"vod_pic": self.DEFAULT_PIC,
|
||||
"vod_content": text,
|
||||
"vod_play_from": "错误",
|
||||
"vod_play_url": "查看错误$%s%s" % (self.ERROR_PREFIX, quote(text, safe="")),
|
||||
}
|
||||
|
||||
def _category_spec(self, tid):
|
||||
value = str(tid or "hot").strip()
|
||||
for spec in self.CATEGORY_SPECS:
|
||||
if spec[0] == value:
|
||||
return spec
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _paged_path(base_path, page):
|
||||
if page <= 1:
|
||||
return base_path
|
||||
return base_path.rstrip("/") + "/page-%d" % page
|
||||
|
||||
def _absolute_url(self, path):
|
||||
return urljoin(self.host.rstrip("/") + "/", str(path or "").lstrip("/"))
|
||||
|
||||
@staticmethod
|
||||
def _absolute_media_url(value, page_url):
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return urljoin(page_url, text)
|
||||
|
||||
def _is_allowed_html_url(self, url):
|
||||
parsed = urlsplit(str(url or ""))
|
||||
host = (parsed.hostname or "").lower()
|
||||
configured_host = (urlsplit(self.host).hostname or "").lower()
|
||||
return (
|
||||
parsed.scheme in ("http", "https")
|
||||
and bool(host)
|
||||
and host == configured_host
|
||||
and host not in self.BLOCKED_HOSTS
|
||||
)
|
||||
|
||||
def _is_allowed_media_url(self, url):
|
||||
parsed = urlsplit(str(url or ""))
|
||||
host = (parsed.hostname or "").lower()
|
||||
return (
|
||||
parsed.scheme in ("http", "https")
|
||||
and host in self.MEDIA_HOSTS
|
||||
and host not in self.BLOCKED_HOSTS
|
||||
and self.isVideoFormat(url)
|
||||
)
|
||||
|
||||
def _looks_like_challenge(self, status_code, source):
|
||||
sample = str(source or "")[:200000].lower()
|
||||
status = int(status_code or 0)
|
||||
if 200 <= status < 300 and any(
|
||||
marker in sample for marker in self.CONTENT_MARKERS
|
||||
):
|
||||
return False
|
||||
if any(marker in sample for marker in self.CHALLENGE_MARKERS):
|
||||
return True
|
||||
return status in (403, 503) and "cloudflare" in sample
|
||||
|
||||
@staticmethod
|
||||
def _response_text(response):
|
||||
content = bytes(response.content or b"")
|
||||
declared = str(response.encoding or "").strip()
|
||||
normalized = declared.lower().replace("_", "-")
|
||||
if not normalized or normalized in {
|
||||
"iso-8859-1",
|
||||
"utf-32",
|
||||
"utf-32le",
|
||||
"utf-32be",
|
||||
"usc4 little endian",
|
||||
"usc4 big endian",
|
||||
}:
|
||||
chosen = "utf-8"
|
||||
else:
|
||||
chosen = declared
|
||||
try:
|
||||
text = content.decode(chosen, errors="replace")
|
||||
except (LookupError, UnicodeError):
|
||||
chosen = "utf-8"
|
||||
text = content.decode(chosen, errors="replace")
|
||||
if chosen.lower() != normalized:
|
||||
print(
|
||||
"[badnews-probe] encoding declared=%s chosen=%s bytes=%d content_type=%s"
|
||||
% (
|
||||
declared or "none",
|
||||
chosen,
|
||||
len(content),
|
||||
response.headers.get("Content-Type", ""),
|
||||
)
|
||||
)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _tree(source):
|
||||
if isinstance(source, bytes):
|
||||
payload = source
|
||||
else:
|
||||
payload = str(source or "<html></html>").encode("utf-8", errors="replace")
|
||||
parser = html.HTMLParser(encoding="utf-8", recover=True)
|
||||
return html.fromstring(payload, parser=parser)
|
||||
|
||||
def _meta_content(self, tree, attr_name, attr_value):
|
||||
nodes = tree.xpath(
|
||||
'//meta[translate(@%s,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="%s"]/@content'
|
||||
% (attr_name, attr_value.lower())
|
||||
)
|
||||
return self._clean_text(nodes[0]) if nodes else ""
|
||||
|
||||
def _first_text(self, nodes):
|
||||
for node in nodes or []:
|
||||
try:
|
||||
value = node.text_content()
|
||||
except Exception:
|
||||
value = str(node or "")
|
||||
value = self._clean_text(value)
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
def _split_vod_id(self, value):
|
||||
text = str(value or "").strip()
|
||||
if ":" not in text:
|
||||
return ("t", self._digits(text)) if self._digits(text) else ("", "")
|
||||
kind, item_id = text.split(":", 1)
|
||||
kind = kind.strip().lower()
|
||||
item_id = self._digits(item_id)
|
||||
if kind not in ("t", "dm") or not item_id:
|
||||
return "", ""
|
||||
return kind, item_id
|
||||
|
||||
@staticmethod
|
||||
def _first_matching_id(values, pattern):
|
||||
for value in values or []:
|
||||
match = pattern.search(str(value or ""))
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _digits(value):
|
||||
match = re.search(r"\d+", str(value or ""))
|
||||
return match.group(0) if match else ""
|
||||
|
||||
def _media_type(self, url):
|
||||
text = str(url or "").lower()
|
||||
if ".m3u8" in text:
|
||||
return "m3u8"
|
||||
if ".mp4" in text:
|
||||
return "mp4"
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _parse_config(extend):
|
||||
if isinstance(extend, dict):
|
||||
return dict(extend)
|
||||
text = str(extend or "").strip()
|
||||
if not text:
|
||||
return {}
|
||||
if text.startswith(("http://", "https://")):
|
||||
return {"host": text}
|
||||
try:
|
||||
value = json.loads(text)
|
||||
return value if isinstance(value, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def _bool_value(value, default=False):
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if value is None:
|
||||
return bool(default)
|
||||
return str(value).strip().lower() in ("1", "true", "yes", "on")
|
||||
|
||||
@staticmethod
|
||||
def _bounded_int(value, default, minimum=1, maximum=999999):
|
||||
try:
|
||||
number = int(value)
|
||||
except Exception:
|
||||
number = int(default)
|
||||
return max(minimum, min(maximum, number))
|
||||
|
||||
def _page_number(self, value):
|
||||
return self._bounded_int(value, 1, 1, 999999)
|
||||
|
||||
@staticmethod
|
||||
def _clean_text(value):
|
||||
text = html_lib.unescape(str(value or ""))
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
@staticmethod
|
||||
def _empty_page(page, message=""):
|
||||
return {
|
||||
"list": [],
|
||||
"page": page,
|
||||
"pagecount": page,
|
||||
"limit": 0,
|
||||
"total": 0,
|
||||
"msg": message,
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
import requests
|
||||
|
||||
try:
|
||||
from base.spider import Spider as BaseSpider
|
||||
except ImportError:
|
||||
class BaseSpider:
|
||||
pass
|
||||
|
||||
class Spider(BaseSpider):
|
||||
BASE_URL = "https://x3av.com"
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 10; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36",
|
||||
"Referer": "https://x3av.com/",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.siteUrl = self.BASE_URL
|
||||
self.extend = {}
|
||||
|
||||
def init(self, extend=""):
|
||||
if extend:
|
||||
try:
|
||||
self.extend = json.loads(extend) if isinstance(extend, str) else extend
|
||||
self.siteUrl = self.extend.get("siteUrl", self.BASE_URL).rstrip("/")
|
||||
except Exception:
|
||||
self.siteUrl = self.BASE_URL
|
||||
|
||||
def getName(self):
|
||||
return "樱花传媒"
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
return bool(re.search(r"\.(m3u8|mp4|flv|avi|mkv)(\?|$)", url or "", re.I))
|
||||
|
||||
def manualVideoCheck(self):
|
||||
return True
|
||||
|
||||
def homeContent(self, filter):
|
||||
classes = [
|
||||
{"type_id": "1", "type_name": "有码"},
|
||||
{"type_id": "2", "type_name": "无码"},
|
||||
{"type_id": "3", "type_name": "素人"},
|
||||
{"type_id": "4", "type_name": "中文字幕"}
|
||||
]
|
||||
filters = {}
|
||||
for i in [x["type_id"] for x in classes]:
|
||||
filters[i] = [
|
||||
{"key": "by", "name": "排序", "value": [
|
||||
{"n": "最新", "v": "time"},
|
||||
{"n": "热门", "v": "hits"},
|
||||
{"n": "评分", "v": "score"}
|
||||
]}
|
||||
]
|
||||
return {"class": classes, "filters": filters}
|
||||
|
||||
def homeVideoContent(self):
|
||||
html = self._get(self.siteUrl)
|
||||
return {"list": self._parse_list(html)}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
pg = str(pg or "1")
|
||||
by = (extend or {}).get("by", "")
|
||||
if by and by != "time":
|
||||
url = self.siteUrl + "/vshow/by/{}/id/{}/page/{}.html".format(by, tid, pg)
|
||||
else:
|
||||
url = self.siteUrl + "/category/{}.html".format(tid) if pg == "1" else self.siteUrl + "/category/{}/page/{}.html".format(tid, pg)
|
||||
html = self._get(url)
|
||||
videos = self._parse_list(html)
|
||||
return {"page": int(pg), "pagecount": int(pg) + 1 if videos else int(pg), "limit": 24, "total": 999999, "list": videos}
|
||||
|
||||
def detailContent(self, ids):
|
||||
vid = ids[0] if isinstance(ids, list) else ids
|
||||
url = self._full_url(vid)
|
||||
html = self._get(url)
|
||||
name = self._clean(self._match(html, r"<h1[^>]*>(.*?)</h1>") or self._match(html, r"<title[^>]*>(.*?)</title>") or "")
|
||||
pic = self._match(html, r'background\s*:\s*url\((.*?)\)') or self._match(html, r'<img[^>]+data-original=["\']([^"\']+)') or self._match(html, r'<img[^>]+data-src=["\']([^"\']+)') or self._match(html, r'<img[^>]+src=["\']([^"\']+)')
|
||||
desc = self._clean(self._match(html, r'<div[^>]+class=["\'][^"\']*(?:desc|content|video-info)[^"\']*["\'][^>]*>(.*?)</div>') or "")
|
||||
actor = self._clean(self._match(html, r"主演[:: \s]*([^<]+)") or "")
|
||||
remarks = self._clean(self._match(html, r"番号[:: \s]*([^<]+)") or "")
|
||||
play_items = []
|
||||
for m in re.finditer(r'<a[^>]+id=["\']playerserver["\'][^>]*>', html, re.I):
|
||||
tag = m.group(0)
|
||||
vodid = self._attr(tag, "data-vodid")
|
||||
sid = self._attr(tag, "data-sid") or "1"
|
||||
nid = self._attr(tag, "data-nid") or "1"
|
||||
title = self._clean(self._match(html[m.end():m.end()+200], r"([^<]+)</a>") or "播放{}".format(len(play_items) + 1))
|
||||
if vodid:
|
||||
play_items.append("{}${}|{}|{}".format(title, vodid, sid, nid))
|
||||
if not play_items:
|
||||
mid = self._match(url, r"/videos/(\d+)")
|
||||
if mid:
|
||||
play_items.append("播放${}|1|1".format(mid))
|
||||
vod = {
|
||||
"vod_id": vid,
|
||||
"vod_name": name,
|
||||
"vod_pic": self._real_pic(pic),
|
||||
"type_name": "",
|
||||
"vod_year": "",
|
||||
"vod_area": "",
|
||||
"vod_remarks": remarks,
|
||||
"vod_actor": actor,
|
||||
"vod_director": "",
|
||||
"vod_content": desc,
|
||||
"vod_play_from": "樱花传媒",
|
||||
"vod_play_url": "#".join(play_items)
|
||||
}
|
||||
return {"list": [vod]}
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
wd = urllib.parse.quote(key)
|
||||
url = self.siteUrl + "/search.html?wd={}".format(wd) if str(pg) == "1" else self.siteUrl + "/search.html?wd={}&page={}".format(wd, pg)
|
||||
html = self._get(url)
|
||||
return {"list": self._parse_list(html)}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
pp = str(id).split("|")
|
||||
if len(pp) < 3:
|
||||
return {"parse": 1, "playUrl": "", "url": id, "header": self.HEADERS}
|
||||
data = {"ids": pp[0], "flag": "player", "sid": pp[1], "nid": pp[2]}
|
||||
headers = dict(self.HEADERS)
|
||||
headers["X-Requested-With"] = "XMLHttpRequest"
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded; charset=UTF-8"
|
||||
try:
|
||||
res = requests.post(self.siteUrl + "/api.php/post/urlget/", data=data, headers=headers, timeout=15, verify=False)
|
||||
text = res.text
|
||||
except requests.RequestException:
|
||||
text = ""
|
||||
iframe = ""
|
||||
try:
|
||||
obj = json.loads(text)
|
||||
iframe = self._match(obj.get("player", ""), r'<iframe[^>]+src=["\']([^"\']+)')
|
||||
except Exception:
|
||||
iframe = self._match(text, r'<iframe[^>]+src=["\']([^"\']+)')
|
||||
iframe = self._full_url(iframe)
|
||||
real = self._extract_player_url(iframe)
|
||||
if real:
|
||||
return {"parse": 0, "playUrl": "", "url": real, "header": {"User-Agent": self.HEADERS["User-Agent"], "Referer": iframe}}
|
||||
return {"parse": 1, "playUrl": "", "url": iframe, "header": {"User-Agent": self.HEADERS["User-Agent"], "Referer": self.siteUrl + "/"}}
|
||||
|
||||
def localProxy(self, param):
|
||||
return [404, "text/plain", ""]
|
||||
|
||||
def _parse_list(self, html):
|
||||
html = re.sub(r"<!--[\s\S]*?-->", "", html or "")
|
||||
arr = []
|
||||
blocks = re.findall(r'<div[^>]+class=["\'][^"\']*video-elem[^"\']*["\'][\s\S]*?(?=<div[^>]+class=["\'][^"\']*video-elem|<ul[^>]+class=["\'][^"\']*pagination|</body>|$)', html, re.I)
|
||||
if not blocks:
|
||||
blocks = re.findall(r'<a[^>]+href=["\'][^"\']*/videos/\d+\.html[^"\']*["\'][\s\S]*?</a>', html, re.I)
|
||||
for block in blocks:
|
||||
href = self._match(block, r'href=["\']([^"\']*/videos/\d+\.html[^"\']*)')
|
||||
name = self._clean(self._match(block, r'<a[^>]+class=["\'][^"\']*title[^"\']*["\'][^>]*>(.*?)</a>') or self._match(block, r'title=["\']([^"\']+)') or self._match(block, r'alt=["\']([^"\']+)'))
|
||||
img_tag = self._match(block, r'(<img[\s\S]*?>)')
|
||||
pic = self._attr(img_tag, "data-original") or self._attr(img_tag, "data-src") or self._attr(img_tag, "src")
|
||||
remark = self._clean(self._match(block, r'<span[^>]+class=["\'][^"\']*(?:duration|remarks|time)[^"\']*["\'][^>]*>(.*?)</span>') or "")
|
||||
pic = self._real_pic(pic)
|
||||
if href and name:
|
||||
arr.append({"vod_id": self._full_url(href), "vod_name": name, "vod_pic": pic, "vod_remarks": remark})
|
||||
return arr
|
||||
|
||||
def _extract_player_url(self, iframe):
|
||||
if not iframe:
|
||||
return ""
|
||||
html = self._get(iframe, {"Referer": self.siteUrl + "/"})
|
||||
code = self._unpack(html) or html
|
||||
p = {}
|
||||
m = re.search(r"var\s+p\s*=\s*(\{.*?\})\s*;", code, re.S)
|
||||
if m:
|
||||
for k, v in re.findall(r'["\']?(hls\d+|mp4|file)["\']?\s*:\s*["\']([^"\']+)["\']', m.group(1), re.I):
|
||||
p[k.lower()] = v.replace("\\/", "/")
|
||||
url = p.get("hls2") or p.get("hls3") or p.get("hls4") or p.get("file") or p.get("mp4")
|
||||
if not url:
|
||||
sm = re.search(r'sources\s*:\s*\[\s*\{\s*file\s*:\s*(p\.(hls\d+|file|mp4)|["\']([^"\']+)["\'])', code, re.I)
|
||||
if sm:
|
||||
url = p.get((sm.group(2) or "").lower()) or sm.group(3) or ""
|
||||
if not url:
|
||||
urls = re.findall(r'https?://[^"\']+?\.(?:m3u8|mp4)(?:\?[^"\']*)?|/[A-Za-z0-9_./-]+/(?:master|index)\.(?:m3u8|mp4)(?:\?[^"\']*)?', code, re.I)
|
||||
for u in urls:
|
||||
if "jpg" not in u.lower() and "png" not in u.lower():
|
||||
url = u
|
||||
break
|
||||
return self._join(iframe, url)
|
||||
|
||||
def _unpack(self, html):
|
||||
m = re.search(r"eval\(function\(p,a,c,k,e,d\).*?\}\('(.+?)',(\d+),(\d+),'(.+?)'\.split\('\|'\)\)\)", html or "", re.S)
|
||||
if not m:
|
||||
return ""
|
||||
p, a, c, k = m.group(1), int(m.group(2)), int(m.group(3)), m.group(4).split("|")
|
||||
for i in range(c - 1, -1, -1):
|
||||
if i < len(k) and k[i]:
|
||||
p = re.sub(r"\b" + re.escape(self._base_n(i, a)) + r"\b", k[i], p)
|
||||
return p
|
||||
|
||||
def _get(self, url, headers=None):
|
||||
if not url:
|
||||
return ""
|
||||
h = dict(self.HEADERS)
|
||||
if headers:
|
||||
h.update(headers)
|
||||
try:
|
||||
r = requests.get(url, headers=h, timeout=15, verify=False)
|
||||
if r.encoding == "ISO-8859-1":
|
||||
r.encoding = r.apparent_encoding
|
||||
return r.text
|
||||
except requests.RequestException:
|
||||
return ""
|
||||
|
||||
def _full_url(self, url):
|
||||
url = (url or "").replace("&", "&").strip()
|
||||
if not url:
|
||||
return ""
|
||||
if url.startswith("//"):
|
||||
return "https:" + url
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
if url.startswith("/"):
|
||||
return self.siteUrl + url
|
||||
return self.siteUrl + "/" + url
|
||||
|
||||
def _join(self, base, url):
|
||||
url = (url or "").replace("\\/", "/").replace("&", "&").strip()
|
||||
if not url:
|
||||
return ""
|
||||
if url.startswith("//"):
|
||||
return "https:" + url
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
return urllib.parse.urljoin(base, url)
|
||||
|
||||
def _real_pic(self, url):
|
||||
url = self._full_url(url or "")
|
||||
url = url.replace("&", "&").strip()
|
||||
if not url or "noimage" in url.lower() or url.endswith("/"):
|
||||
return ""
|
||||
if "getimages.php" in url and "src=" in url:
|
||||
m = re.search(r"src=([^&]+)", url)
|
||||
if m:
|
||||
src = urllib.parse.unquote(m.group(1)).replace("&", "&")
|
||||
if src.startswith("http") and "noimage" not in src.lower():
|
||||
return src
|
||||
m = re.search(r"(https?://[^&'\"]+\.(?:jpg|jpeg|png|webp))", url, re.I)
|
||||
if m:
|
||||
return urllib.parse.unquote(m.group(1))
|
||||
return url
|
||||
|
||||
def _match(self, text, pattern):
|
||||
m = re.search(pattern, text or "", re.S | re.I)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
def _attr(self, tag, key):
|
||||
return self._match(tag, key + r'=["\']([^"\']+)')
|
||||
|
||||
def _clean(self, text):
|
||||
text = re.sub(r"<[^>]+>", " ", text or "")
|
||||
text = urllib.parse.unquote(text)
|
||||
text = text.replace(" ", " ").replace("&", "&").replace(""", '"').replace("'", "'")
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
def _base_n(self, num, base):
|
||||
chars = "0123456789abcdefghijklmnopqrstuvwxyz"
|
||||
if num == 0:
|
||||
return "0"
|
||||
s = ""
|
||||
while num:
|
||||
s = chars[num % base] + s
|
||||
num //= base
|
||||
return s
|
||||
@@ -0,0 +1,572 @@
|
||||
# coding: utf-8
|
||||
# 站点: 蝶卡影视网 (https://www.diekawang.com)
|
||||
|
||||
|
||||
import json
|
||||
import base64
|
||||
import re
|
||||
from urllib.parse import quote, urljoin, unquote
|
||||
|
||||
from base.spider import Spider as BaseSpider
|
||||
|
||||
|
||||
class Spider(BaseSpider):
|
||||
|
||||
def __init__(self):
|
||||
# __init__ 只做本地初始化,禁止网络请求,保证壳子首页秒出 class。
|
||||
self.extend = ""
|
||||
self.host = "https://www.diekawang.com"
|
||||
self.classes = [
|
||||
{"type_id": "1", "type_name": "电影"},
|
||||
{"type_id": "2", "type_name": "电视剧"},
|
||||
{"type_id": "3", "type_name": "综艺"},
|
||||
{"type_id": "4", "type_name": "动漫"},
|
||||
{"type_id": "457", "type_name": "短剧"},
|
||||
{"type_id": "462", "type_name": "体育"},
|
||||
]
|
||||
self.filters = {
|
||||
"1": [
|
||||
{"key": "cate", "name": "类型", "value": [
|
||||
{"n": "全部", "v": "0"},
|
||||
{"n": "动作片", "v": "9"},
|
||||
{"n": "喜剧片", "v": "10"},
|
||||
{"n": "爱情片", "v": "11"},
|
||||
{"n": "恐怖片", "v": "12"},
|
||||
{"n": "剧情片", "v": "13"},
|
||||
{"n": "科幻片", "v": "14"},
|
||||
{"n": "惊悚片", "v": "15"},
|
||||
{"n": "奇幻片", "v": "16"},
|
||||
{"n": "动画片", "v": "17"},
|
||||
{"n": "悬疑片", "v": "18"},
|
||||
{"n": "冒险片", "v": "19"},
|
||||
{"n": "纪录片", "v": "20"},
|
||||
{"n": "战争片", "v": "21"},
|
||||
{"n": "倫理片", "v": "460"},
|
||||
]},
|
||||
],
|
||||
"2": [
|
||||
{"key": "cate", "name": "类型", "value": [
|
||||
{"n": "全部", "v": "0"},
|
||||
{"n": "国产剧", "v": "22"},
|
||||
{"n": "香港剧", "v": "23"},
|
||||
{"n": "台湾剧", "v": "24"},
|
||||
{"n": "欧美剧", "v": "25"},
|
||||
{"n": "日本剧", "v": "26"},
|
||||
{"n": "韩国剧", "v": "27"},
|
||||
{"n": "泰国剧", "v": "28"},
|
||||
{"n": "海外剧", "v": "29"},
|
||||
]},
|
||||
],
|
||||
"3": [
|
||||
{"key": "cate", "name": "类型", "value": [
|
||||
{"n": "全部", "v": "0"},
|
||||
{"n": "大陆综艺", "v": "30"},
|
||||
{"n": "港台综艺", "v": "31"},
|
||||
{"n": "日韩综艺", "v": "32"},
|
||||
{"n": "欧美综艺", "v": "33"},
|
||||
{"n": "海外综艺", "v": "34"},
|
||||
]},
|
||||
],
|
||||
"4": [
|
||||
{"key": "cate", "name": "类型", "value": [
|
||||
{"n": "全部", "v": "0"},
|
||||
{"n": "国产动漫", "v": "35"},
|
||||
{"n": "日韩动漫", "v": "36"},
|
||||
{"n": "欧美动漫", "v": "37"},
|
||||
{"n": "海外动漫", "v": "38"},
|
||||
{"n": "港台动漫", "v": "459"},
|
||||
]},
|
||||
],
|
||||
"457": [
|
||||
{"key": "cate", "name": "类型", "value": [
|
||||
{"n": "全部", "v": "0"},
|
||||
{"n": "漫剧", "v": "540"},
|
||||
{"n": "玄幻", "v": "541"},
|
||||
{"n": "剧情", "v": "542"},
|
||||
{"n": "女性成长", "v": "543"},
|
||||
{"n": "权谋", "v": "544"},
|
||||
{"n": "豪门", "v": "545"},
|
||||
{"n": "齐幻", "v": "546"},
|
||||
{"n": "宫斗", "v": "547"},
|
||||
{"n": "脑洞", "v": "548"},
|
||||
{"n": "科幻", "v": "549"},
|
||||
{"n": "冒险", "v": "550"},
|
||||
{"n": "仙侠", "v": "551"},
|
||||
{"n": "喜剧", "v": "552"},
|
||||
{"n": "动作", "v": "553"},
|
||||
{"n": "悬疑", "v": "554"},
|
||||
{"n": "战神", "v": "555"},
|
||||
{"n": "刑侦", "v": "556"},
|
||||
{"n": "求生", "v": "557"},
|
||||
{"n": "商战", "v": "558"},
|
||||
{"n": "恐怖", "v": "559"},
|
||||
{"n": "武侠", "v": "560"},
|
||||
{"n": "爱情", "v": "561"},
|
||||
{"n": "AI漫剧", "v": "562"},
|
||||
]},
|
||||
],
|
||||
"462": [
|
||||
{"key": "cate", "name": "类型", "value": [
|
||||
{"n": "全部", "v": "0"},
|
||||
]},
|
||||
],
|
||||
}
|
||||
self.headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Linux; Android 14; 22127RK46C) AppleWebKit/537.36",
|
||||
"Referer": self.host + "/",
|
||||
}
|
||||
|
||||
def getName(self):
|
||||
return "蝶卡影视"
|
||||
|
||||
def getDependence(self):
|
||||
return []
|
||||
|
||||
def init(self, extend=""):
|
||||
self.extend = extend or ""
|
||||
|
||||
# ==================== 内部工具模块 ====================
|
||||
|
||||
def _cleanText(self, text):
|
||||
"""清洗 HTML 标签和空白字符"""
|
||||
if not text:
|
||||
return ""
|
||||
text = re.sub(r'<[^>]+>', '', text)
|
||||
text = re.sub(r' ', ' ', text)
|
||||
text = re.sub(r'\s+', ' ', text)
|
||||
return text.strip()
|
||||
|
||||
def _absUrl(self, url):
|
||||
"""补全相对 URL"""
|
||||
if not url:
|
||||
return ""
|
||||
if url.startswith("//"):
|
||||
return "https:" + url
|
||||
if url.startswith("http"):
|
||||
return url
|
||||
return urljoin(self.host, url)
|
||||
|
||||
def _decodeFile(self, file_str):
|
||||
"""解码播放文件 URL: 去掉3字符前缀 -> base64解码 -> URL反编码"""
|
||||
if not file_str or len(file_str) <= 3:
|
||||
return ""
|
||||
try:
|
||||
raw = file_str[3:]
|
||||
decoded = base64.b64decode(raw).decode('utf-8', 'ignore')
|
||||
return unquote(decoded)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _makePlayHeader(self, url):
|
||||
"""空防盗链头优先: 只保留 UA,避免 EXO 把错误 Referer/Origin 透传给第三方 CDN 分片"""
|
||||
return {"User-Agent": self.headers.get("User-Agent", "Mozilla/5.0")}
|
||||
|
||||
def _parseListCards(self, html):
|
||||
"""
|
||||
解析列表页卡片 (多级兜底)
|
||||
主选择器: div.moon-list-item > a.item.goLinklist
|
||||
语义锚点: href="/vod/player/0/{id}"
|
||||
兜底: 全页扫描 /vod/player/0/ 链接块
|
||||
"""
|
||||
result = []
|
||||
seen_ids = set()
|
||||
|
||||
# 主选择器: 匹配完整的卡片块
|
||||
# Pattern 1: 标准 moon-list-item 结构
|
||||
cards = re.findall(
|
||||
r'<div[^>]*class="[^"]*moon-list-item[^"]*"[^>]*>\s*'
|
||||
r'<a[^>]*href="/vod/player/0/(\d+)"[^>]*>(.*?)</a>',
|
||||
html, re.S
|
||||
)
|
||||
for vid, block in cards:
|
||||
if vid in seen_ids:
|
||||
continue
|
||||
# 提取标题: p.name 或 .item-title
|
||||
name = re.search(r'class="[^"]*name[^"]*item-title[^"]*"[^>]*>(.*?)</p>', block, re.S)
|
||||
if not name:
|
||||
name = re.search(r'class="[^"]*item-title[^"]*"[^>]*>(.*?)</p>', block, re.S)
|
||||
name = self._cleanText(name.group(1)) if name else ""
|
||||
if not name:
|
||||
continue
|
||||
# 提取图片: data-original > data-src > src
|
||||
pic = re.search(r'data-original="([^"]+)"', block)
|
||||
if not pic:
|
||||
pic = re.search(r'data-src="([^"]+)"', block)
|
||||
if not pic:
|
||||
pic = re.search(r'src="([^"]+)"', block)
|
||||
pic = self._absUrl(pic.group(1)) if pic else ""
|
||||
# 提取评分/备注: label.rate
|
||||
remark = re.search(r'class="[^"]*rate[^"]*"[^>]*>(.*?)</label>', block, re.S)
|
||||
remark = self._cleanText(remark.group(1)) if remark else ""
|
||||
# 打包轻量字段到 vod_id
|
||||
vod_id = vid + '|$|' + name + '|$|' + pic + '|$|' + remark
|
||||
result.append({
|
||||
"vod_id": vod_id,
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remark,
|
||||
})
|
||||
seen_ids.add(vid)
|
||||
|
||||
# 兜底: 如果主选择器没匹配到,全页扫描 player 链接
|
||||
if not result:
|
||||
blocks = re.findall(
|
||||
r'<a[^>]*href="/vod/player/0/(\d+)"[^>]*class="[^"]*(?:item|goLinklist)[^"]*"[^>]*>(.*?)</a>',
|
||||
html, re.S
|
||||
)
|
||||
for vid, block in blocks:
|
||||
if vid in seen_ids:
|
||||
continue
|
||||
name = re.search(r'<p[^>]*>(.*?)</p>', block, re.S)
|
||||
name = self._cleanText(name.group(1)) if name else ""
|
||||
if not name:
|
||||
continue
|
||||
pic = re.search(r'data-original="([^"]+)"', block)
|
||||
if not pic:
|
||||
pic = re.search(r'src="([^"]+)"', block)
|
||||
pic = self._absUrl(pic.group(1)) if pic else ""
|
||||
remark = re.search(r'class="[^"]*rate[^"]*"[^>]*>(.*?)</label>', block, re.S)
|
||||
remark = self._cleanText(remark.group(1)) if remark else ""
|
||||
vod_id = vid + '|$|' + name + '|$|' + pic + '|$|' + remark
|
||||
result.append({
|
||||
"vod_id": vod_id,
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remark,
|
||||
})
|
||||
seen_ids.add(vid)
|
||||
|
||||
return result
|
||||
|
||||
def _parseSearchCards(self, html):
|
||||
"""
|
||||
解析搜索结果卡片
|
||||
主选择器: a.moon-item.seachlist
|
||||
语义锚点: href="/vod/player/0/{id}"
|
||||
"""
|
||||
result = []
|
||||
seen_ids = set()
|
||||
|
||||
cards = re.findall(
|
||||
r'<a[^>]*href="/vod/player/0/(\d+)"[^>]*class="[^"]*seachlist[^"]*"[^>]*>(.*?)</a>',
|
||||
html, re.S
|
||||
)
|
||||
for vid, block in cards:
|
||||
if vid in seen_ids:
|
||||
continue
|
||||
# 标题: h2
|
||||
name = re.search(r'<h2[^>]*>(.*?)</h2>', block, re.S)
|
||||
name = self._cleanText(name.group(1)) if name else ""
|
||||
if not name:
|
||||
continue
|
||||
# 图片
|
||||
pic = re.search(r'data-src="([^"]+)"', block)
|
||||
if not pic:
|
||||
pic = re.search(r'src="([^"]+)"', block)
|
||||
pic = self._absUrl(pic.group(1)) if pic else ""
|
||||
# 信息: label-list 中的 span (年份/类型/地区)
|
||||
info_spans = re.findall(r'<span[^>]*>(.*?)</span>', block, re.S)
|
||||
info_parts = [self._cleanText(s) for s in info_spans if self._cleanText(s)]
|
||||
remark = " ".join(info_parts[:3]) if info_parts else ""
|
||||
vod_id = vid + '|$|' + name + '|$|' + pic + '|$|' + remark
|
||||
result.append({
|
||||
"vod_id": vod_id,
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remark,
|
||||
})
|
||||
seen_ids.add(vid)
|
||||
|
||||
return result
|
||||
|
||||
def _getPageCount(self, html, default=99):
|
||||
"""从"尾页"链接中提取总页数"""
|
||||
# 尾页链接格式: href="/vod/list/{last_page}/{type}/{sub_type}"
|
||||
last_page = re.search(r'href="/vod/list/(\d+)/\d+/\d+"[^>]*>[^<]*尾页', html)
|
||||
if last_page:
|
||||
try:
|
||||
return int(last_page.group(1))
|
||||
except ValueError:
|
||||
pass
|
||||
return default
|
||||
|
||||
def _isNoResultPage(self, html):
|
||||
"""检测无结果页 (排除 Vue 模板中的暂无数据占位符)"""
|
||||
no_result_patterns = [
|
||||
r'没有找到您想要的结果',
|
||||
r'没有找到.*结果',
|
||||
r'搜索无结果',
|
||||
r'暂无影片',
|
||||
r'没有搜到',
|
||||
r'无搜索结果',
|
||||
]
|
||||
for pattern in no_result_patterns:
|
||||
if re.search(pattern, html):
|
||||
return True
|
||||
return False
|
||||
|
||||
# ==================== 核心接口方法 ====================
|
||||
|
||||
def homeContent(self, filter):
|
||||
"""首页入口零网络,只返回本地 class/filters"""
|
||||
return {"class": self.classes, "filters": self.filters if filter else {}}
|
||||
|
||||
def getHomeContent(self, filter):
|
||||
return self.homeContent(filter)
|
||||
|
||||
def homeVideoContent(self):
|
||||
"""首页推荐数据"""
|
||||
try:
|
||||
url = f"{self.host}/vod/list/1/1/0"
|
||||
res = self.fetch(url, headers=self.headers)
|
||||
html = res.text
|
||||
return {"list": self._parseListCards(html)}
|
||||
except Exception:
|
||||
return {"list": []}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
"""
|
||||
分类列表: /vod/list/{page}/{parent_type}/{sub_type}
|
||||
动态消费 pg 和 extend.cate
|
||||
"""
|
||||
page = str(pg) if pg else "1"
|
||||
extend = extend or {}
|
||||
sub_type = extend.get("cate", "0") or "0"
|
||||
|
||||
url = f"{self.host}/vod/list/{page}/{tid}/{sub_type}"
|
||||
try:
|
||||
res = self.fetch(url, headers=self.headers)
|
||||
html = res.text
|
||||
|
||||
items = self._parseListCards(html)
|
||||
# 无结果检测: 仅在卡片为空时检查无结果提示
|
||||
if not items and self._isNoResultPage(html):
|
||||
return {
|
||||
"list": [],
|
||||
"page": int(page),
|
||||
"pagecount": 1,
|
||||
"limit": 10,
|
||||
"total": 0,
|
||||
}
|
||||
pagecount = self._getPageCount(html)
|
||||
return {
|
||||
"list": items,
|
||||
"page": int(page),
|
||||
"pagecount": pagecount,
|
||||
"limit": 10,
|
||||
"total": pagecount * 10,
|
||||
}
|
||||
except Exception:
|
||||
return {
|
||||
"list": [],
|
||||
"page": int(page),
|
||||
"pagecount": 1,
|
||||
"limit": 10,
|
||||
"total": 0,
|
||||
}
|
||||
|
||||
def detailContent(self, ids):
|
||||
"""
|
||||
详情页: /vod/player/0/{vod_id}
|
||||
提取 temLineList JSON 构建播放树
|
||||
提取 vod 元信息 (name, pic, year, area, actor, director, score)
|
||||
"""
|
||||
raw = str(ids[0])
|
||||
ps = raw.split('|$|')
|
||||
vod_id = ps[0]
|
||||
old_name = ps[1] if len(ps) > 1 else ''
|
||||
old_pic = ps[2] if len(ps) > 2 else ''
|
||||
old_remark = ps[3] if len(ps) > 3 else ''
|
||||
|
||||
url = f"{self.host}/vod/player/0/{vod_id}"
|
||||
try:
|
||||
res = self.fetch(url, headers=self.headers)
|
||||
html = res.text
|
||||
except Exception:
|
||||
# 网络失败时返回列表阶段缓存的字段
|
||||
return {"list": [{
|
||||
"vod_id": raw,
|
||||
"vod_name": old_name or "视频",
|
||||
"vod_pic": old_pic,
|
||||
"vod_remarks": old_remark,
|
||||
"vod_play_from": "播放",
|
||||
"vod_play_url": "播放$" + vod_id,
|
||||
}]}
|
||||
|
||||
# 提取 temLineList JSON
|
||||
vod_name = old_name
|
||||
vod_pic = old_pic
|
||||
vod_year = ""
|
||||
vod_area = ""
|
||||
vod_actor = ""
|
||||
vod_director = ""
|
||||
vod_score = ""
|
||||
vod_content = ""
|
||||
vod_remarks = old_remark
|
||||
|
||||
# 提取名称: H1 标签 > item变量 > 列表缓存
|
||||
h1_match = re.search(r'<h1[^>]*>(.*?)</h1>', html, re.S)
|
||||
if h1_match:
|
||||
vod_name = self._cleanText(h1_match.group(1)) or vod_name
|
||||
|
||||
# 提取图片: item变量 imgUrl > data-original
|
||||
img_match = re.search(r'imgUrl:\s*["\']([^"\']+)', html)
|
||||
if img_match:
|
||||
vod_pic = self._absUrl(img_match.group(1).replace('\\/', '/'))
|
||||
elif not vod_pic:
|
||||
img_match2 = re.search(r'data-original="([^"]+)"', html)
|
||||
if img_match2:
|
||||
vod_pic = self._absUrl(img_match2.group(1))
|
||||
|
||||
# 提取评分: label-list 中的 din-condensed
|
||||
score_match = re.search(r'class="[^"]*din-condensed[^"]*"[^>]*>(.*?)</span>', html, re.S)
|
||||
if score_match:
|
||||
vod_score = self._cleanText(score_match.group(1))
|
||||
|
||||
# 提取年份和地区: label-list 中的 span
|
||||
label_section = re.search(r'class="label-list"[^>]*>(.*?)</div>', html, re.S)
|
||||
if label_section:
|
||||
spans = re.findall(r'<span[^>]*>(.*?)</span>', label_section.group(1), re.S)
|
||||
span_texts = [self._cleanText(s) for s in spans if self._cleanText(s)]
|
||||
for txt in span_texts:
|
||||
if re.match(r'^\d{4}$', txt):
|
||||
vod_year = txt
|
||||
elif txt != vod_score:
|
||||
if not vod_area:
|
||||
vod_area = txt
|
||||
|
||||
# 提取导演: worker-name 标签后的内容
|
||||
director_section = re.search(
|
||||
r'class="worker-name"[^>]*>\s*导演\s*</a>\s*<div[^>]*>(.*?)</div>', html, re.S
|
||||
)
|
||||
if director_section:
|
||||
vod_director = self._cleanText(director_section.group(1))
|
||||
|
||||
# 提取演员: worker-name 标签后的内容
|
||||
actor_section = re.search(
|
||||
r'class="worker-name"[^>]*>\s*演员\s*</a>\s*<div[^>]*>(.*?)</div>', html, re.S
|
||||
)
|
||||
if actor_section:
|
||||
vod_actor = self._cleanText(actor_section.group(1))
|
||||
|
||||
# 从 meta keywords 提取演员 (兜底)
|
||||
if not vod_actor:
|
||||
meta_kw = re.search(r'<meta\s+name="keywords"\s+content="([^"]+)"', html)
|
||||
if meta_kw:
|
||||
kw_parts = meta_kw.group(1).split(',')
|
||||
# keywords 格式: 站名,剧名,类型,子类型,,演员列表
|
||||
if len(kw_parts) >= 6:
|
||||
vod_actor = kw_parts[5]
|
||||
|
||||
# 构建播放树
|
||||
play_from_list = []
|
||||
play_url_list = []
|
||||
|
||||
tem_line_match = re.search(r'temLineList\s*=\s*(\[.*?\])\s*;', html, re.S)
|
||||
if tem_line_match:
|
||||
try:
|
||||
line_data = json.loads(tem_line_match.group(1))
|
||||
# 按 tag 分组 (通常只有一组)
|
||||
lines = {}
|
||||
line_order = []
|
||||
for ep in line_data:
|
||||
tag = ep.get("tag", "播放") or "播放"
|
||||
if tag not in lines:
|
||||
lines[tag] = []
|
||||
line_order.append(tag)
|
||||
ep_name = ep.get("name", "") or ep.get("subTitle", "") or "播放"
|
||||
ep_file = ep.get("file", "")
|
||||
lines[tag].append(f"{ep_name}${ep_file}")
|
||||
|
||||
for tag in line_order:
|
||||
play_from_list.append(tag)
|
||||
play_url_list.append("#".join(lines[tag]))
|
||||
except (json.JSONDecodeError, Exception):
|
||||
pass
|
||||
|
||||
# 兜底: 如果没有提取到播放树,返回嗅探
|
||||
if not play_from_list:
|
||||
play_from_list.append("播放")
|
||||
play_url_list.append(f"播放${vod_id}")
|
||||
|
||||
vod = {
|
||||
"vod_id": raw,
|
||||
"vod_name": vod_name or "视频",
|
||||
"vod_pic": vod_pic,
|
||||
"vod_year": vod_year,
|
||||
"vod_area": vod_area,
|
||||
"vod_actor": vod_actor,
|
||||
"vod_director": vod_director,
|
||||
"vod_score": vod_score,
|
||||
"vod_remarks": vod_remarks or vod_score,
|
||||
"vod_content": vod_content or vod_remarks,
|
||||
"vod_play_from": "$$$".join(play_from_list),
|
||||
"vod_play_url": "$$$".join(play_url_list),
|
||||
}
|
||||
return {"list": [vod]}
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
"""
|
||||
搜索: /public/auto/search1.html?keyword={keyword}
|
||||
搜索无分页,全部结果在第一页返回
|
||||
"""
|
||||
if not key:
|
||||
return {"list": [], "page": 1}
|
||||
try:
|
||||
url = f"{self.host}/public/auto/search1.html?keyword={quote(key)}"
|
||||
res = self.fetch(url, headers=self.headers)
|
||||
html = res.text
|
||||
items = self._parseSearchCards(html)
|
||||
return {"list": items, "page": 1}
|
||||
except Exception:
|
||||
return {"list": [], "page": 1}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
"""
|
||||
播放解析: 解码 file 值获取 m3u8 直链
|
||||
file 格式: 3字符前缀 + base64(URL编码的m3u8地址)
|
||||
解码后返回 parse:0 直链
|
||||
"""
|
||||
# 如果 id 本身是 m3u8/mp4 直链
|
||||
if id.endswith((".m3u8", ".mp4")) or id.startswith("http"):
|
||||
return {
|
||||
"parse": 0,
|
||||
"url": id,
|
||||
"header": self._makePlayHeader(id),
|
||||
}
|
||||
|
||||
# 纯数字 ID 兜底: 嗅探
|
||||
if id.isdigit():
|
||||
return {
|
||||
"parse": 1,
|
||||
"url": f"{self.host}/vod/player/0/{id}",
|
||||
"header": self.headers,
|
||||
}
|
||||
|
||||
# 解码 file 值
|
||||
decoded_url = self._decodeFile(id)
|
||||
if decoded_url and decoded_url.startswith("http"):
|
||||
return {
|
||||
"parse": 0,
|
||||
"url": decoded_url,
|
||||
"header": self._makePlayHeader(decoded_url),
|
||||
}
|
||||
|
||||
# 解码失败,降级嗅探
|
||||
return {
|
||||
"parse": 1,
|
||||
"url": id,
|
||||
"header": self.headers,
|
||||
}
|
||||
|
||||
def localProxy(self, param):
|
||||
pass
|
||||
|
||||
def isVideoFormat(self, url):
|
||||
return bool(re.match(r'.*\.(m3u8|mp4)(\?.*)?$', url, re.I))
|
||||
|
||||
def manualVideoCheck(self):
|
||||
return False
|
||||
|
||||
def destroy(self):
|
||||
pass
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import re
|
||||
import urllib.parse
|
||||
import requests
|
||||
|
||||
try:
|
||||
from base.spider import Spider as BaseSpider
|
||||
except ImportError:
|
||||
class BaseSpider:
|
||||
pass
|
||||
|
||||
class Spider(BaseSpider):
|
||||
BASE_URL = "https://madou.club"
|
||||
DASH_URL = "https://dash.madou.club"
|
||||
HEADERS = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Referer": BASE_URL + "/",
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.name = "麻豆社"
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update(self.HEADERS)
|
||||
self._class_cache = None
|
||||
|
||||
def init(self, extend="{}"):
|
||||
return None
|
||||
|
||||
def getName(self):
|
||||
return self.name
|
||||
|
||||
def homeContent(self, filter):
|
||||
html = self._get(self.BASE_URL + "/")
|
||||
return {"class": self._classes(html), "filters": {}, "list": self._parse_list(html), "parse": 0, "jx": 0}
|
||||
|
||||
def homeVideoContent(self):
|
||||
return {"list": self._parse_list(self._get(self.BASE_URL + "/"))}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
page = self._to_int(pg, 1)
|
||||
base = tid if str(tid).startswith("http") else self.BASE_URL + "/category/" + str(tid).strip("/")
|
||||
url = base.rstrip("/") if page <= 1 else base.rstrip("/") + "/page/" + str(page)
|
||||
data = self._parse_list(self._get(url))
|
||||
return {"page": page, "pagecount": page if len(data) < 10 else page + 1, "limit": 20, "total": 99999, "list": data, "parse": 0, "jx": 0}
|
||||
|
||||
def detailContent(self, ids):
|
||||
result = {"list": [], "parse": 0, "jx": 0}
|
||||
if not ids:
|
||||
return result
|
||||
url = ids[0]
|
||||
html = self._get(url)
|
||||
name = self._clean(self._match(html, r'<h1[^>]*class=["\']article-title["\'][^>]*>(.*?)</h1>') or self._match(html, r'<title>(.*?)</title>').split("-")[0])
|
||||
pic = self._match(html, r'shareimage\s*:\s*["\']([^"\']+)') or self._match(html, r'<img[^>]+data-src=["\']([^"\']+)') or self._match(html, r'<img[^>]+src=["\']([^"\']+)')
|
||||
cate = self._clean(self._match(html, r'分类:\s*<a[^>]*>(.*?)</a>'))
|
||||
remarks = self._clean(self._match(html, r'观看\((.*?)\)'))
|
||||
tag_block = self._match(html, r'<div[^>]+class=["\']article-tags["\'][^>]*>(.*?)</div>')
|
||||
tags = ",".join([self._clean(x) for x in re.findall(r'<a[^>]*>(.*?)</a>', tag_block, re.S)])
|
||||
iframe = self._match(html, r'<iframe[^>]+src=["\']?([^"\'\s>]+)')
|
||||
play_id = urllib.parse.urljoin(self.BASE_URL, iframe or url)
|
||||
result["list"].append({"vod_id": url, "vod_name": name, "vod_pic": urllib.parse.urljoin(self.BASE_URL, pic), "type_name": cate, "vod_year": "", "vod_area": "", "vod_remarks": remarks, "vod_actor": tags, "vod_director": "", "vod_content": name, "vod_play_from": "DPlayer", "vod_play_url": name + "$" + play_id})
|
||||
return result
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
page = self._to_int(pg, 1)
|
||||
q = urllib.parse.quote(str(key))
|
||||
url = self.BASE_URL + "/?s=" + q if page <= 1 else self.BASE_URL + "/page/" + str(page) + "?s=" + q
|
||||
data = self._parse_list(self._get(url))
|
||||
return {"page": page, "pagecount": page if len(data) < 10 else page + 1, "limit": 20, "total": 99999, "list": data, "parse": 0, "jx": 0}
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
result = {"parse": 0, "playUrl": "", "url": id or "", "jx": 0, "header": {"User-Agent": self.HEADERS["User-Agent"], "Referer": self.BASE_URL + "/"}}
|
||||
if not id:
|
||||
return result
|
||||
play_page = id
|
||||
if "dash.madou.club/share/" not in play_page:
|
||||
html = self._get(play_page)
|
||||
play_page = urllib.parse.urljoin(self.BASE_URL, self._match(html, r'<iframe[^>]+src=["\']?([^"\'\s>]+)') or play_page)
|
||||
html = self._get(play_page, {"Referer": self.BASE_URL + "/"})
|
||||
token = self._match(html, r'var\s+token\s*=\s*["\']([^"\']*)')
|
||||
m3u8 = self._match(html, r'var\s+m3u8\s*=\s*["\']([^"\']+\.m3u8)["\']')
|
||||
if m3u8:
|
||||
url = urllib.parse.urljoin(self.DASH_URL, m3u8)
|
||||
result["url"] = url + (("&" if "?" in url else "?") + "token=" + token if token else "")
|
||||
result["header"] = {"User-Agent": self.HEADERS["User-Agent"], "Referer": play_page, "Origin": self.BASE_URL}
|
||||
return result
|
||||
|
||||
def _classes(self, html=None):
|
||||
if self._class_cache:
|
||||
return self._class_cache
|
||||
html = html or self._get(self.BASE_URL + "/")
|
||||
classes, seen = [], set()
|
||||
for href, name in re.findall(r'<a[^>]+href=["\'](https://madou\.club/category/[^"\']+)["\'][^>]*>(.*?)</a>', html, re.S):
|
||||
name = self._clean(name)
|
||||
key = href.rstrip("/")
|
||||
if key not in seen and name:
|
||||
seen.add(key)
|
||||
classes.append({"type_id": href, "type_name": name})
|
||||
self._class_cache = classes
|
||||
return classes
|
||||
|
||||
def _parse_list(self, html):
|
||||
data = []
|
||||
blocks = re.findall(r'<article\b.*?</article>', html, re.S) or re.findall(r'<li>.*?</li>', html, re.S)
|
||||
for item in blocks:
|
||||
href = self._match(item, r'<a[^>]+href=["\']([^"\']+\.html)["\']')
|
||||
name = self._clean(self._match(item, r'<h2[^>]*>.*?<a[^>]*>(.*?)</a>') or self._match(item, r'<a[^>]*>(?:<span.*?</span>)?\s*(.*?)</a>'))
|
||||
pic = self._match(item, r'<img[^>]+data-src=["\']([^"\']+)') or self._match(item, r'<img[^>]+src=["\']([^"\']+)')
|
||||
remarks = self._clean(self._match(item, r'<time[^>]*>(.*?)</time>') or self._match(item, r'观看\((.*?)\)'))
|
||||
if href and name:
|
||||
data.append({"vod_id": urllib.parse.urljoin(self.BASE_URL, href), "vod_name": name, "vod_pic": urllib.parse.urljoin(self.BASE_URL, pic), "vod_remarks": remarks})
|
||||
return data
|
||||
|
||||
def _get(self, url, headers=None):
|
||||
h = dict(self.HEADERS)
|
||||
if headers:
|
||||
h.update(headers)
|
||||
try:
|
||||
return self.session.get(url, headers=h, timeout=15, verify=False).text
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
def _match(self, text, pattern):
|
||||
m = re.search(pattern, text or "", re.S | re.I)
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
def _clean(self, text):
|
||||
text = re.sub(r'<.*?>', '', text or '')
|
||||
text = text.replace(' ', ' ').replace('&', '&').replace('&', '&').replace('"', '"')
|
||||
return re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
def _to_int(self, value, default=0):
|
||||
try:
|
||||
return int(value)
|
||||
except Exception:
|
||||
return default
|
||||
@@ -1,11 +1,11 @@
|
||||
午㊰劇場,#genre#
|
||||
午夜刷场,#genre#
|
||||
极限电影台,http://lc.aacalive.com:26789/i/ghjnvq5o/8c855fdf/index.m3u8
|
||||
🌲1️⃣台,http://lc.aacalive.com:26789/i/ghjnvq5o/8c85giea/index.m3u8
|
||||
🌲2️⃣台,http://lc.aacalive.com:26789/i/ghjnvq5o/8c85fsaf/index.m3u8
|
||||
🍌台,http://lc.aacalive.com:26789/i/ghjnvq5o/mdfdc123/index.m3u8
|
||||
🌈🎦,http://lc.aacalive.com:26789/i/ghjnvq5o/8c855d75/index.m3u8
|
||||
🌈🅴,http://lc.aacalive.com:26789/i/ghjnvq5o/8c855daa/index.m3u8
|
||||
驚艷台,http://lc.aacalive.com:26789/i/ghjnvq5o/sajdxxzc/index.m3u8
|
||||
🌲松视1️⃣台,http://lc.aacalive.com:26789/i/ghjnvq5o/8c85giea/index.m3u8
|
||||
🌲松视2️⃣台,http://lc.aacalive.com:26789/i/ghjnvq5o/8c85fsaf/index.m3u8
|
||||
🍌香焦台HD,http://lc.aacalive.com:26789/i/ghjnvq5o/mdfdc123/index.m3u8
|
||||
潘多啦完美,http://lc.aacalive.com:26789/i/ghjnvq5o/8c855d75/index.m3u8
|
||||
🌈🅴彩虹E,http://lc.aacalive.com:26789/i/ghjnvq5o/8c855daa/index.m3u8
|
||||
驚艷台HD,http://lc.aacalive.com:26789/i/ghjnvq5o/sajdxxzc/index.m3u8
|
||||
中字➊,http://lc.aacalive.com:26789/i/ghjnvq5o/8jainsbq/index.m3u8
|
||||
中字➋,http://lc.aacalive.com:26789/i/ghjnvq5o/v19a2133/index.m3u8
|
||||
中字➌,http://lc.aacalive.com:26789/i/ghjnvq5o/56ffe9b8/index.m3u8
|
||||
Reference in New Issue
Block a user