Sync all projects

This commit is contained in:
github-actions[bot]
2026-07-26 14:40:30 +00:00
parent e6cee0f96e
commit 3ec7ec4d08
41 changed files with 11118 additions and 4513 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
+352
View File
@@ -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 };
+161
View File
@@ -0,0 +1,161 @@
# -*- 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://jable.sbs"
FALLBACK_URLS = ["https://jable.sbs", "https://jable.tv"]
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": BASE_URL + "/",
}
def __init__(self):
super().__init__()
self.name = "JableTV"
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 + "/latest-updates/")
return {"class": self._classes(), "filters": {}, "list": self._parse_list(html), "parse": 0, "jx": 0}
def homeVideoContent(self):
return {"list": self._parse_list(self._get(self.BASE_URL + "/latest-updates/"))}
def categoryContent(self, tid, pg, filter, extend):
page = self._to_int(pg, 1)
path = str(tid or "latest-updates").strip("/")
url = self.BASE_URL + "/" + path + "/" if page <= 1 else self.BASE_URL + "/" + path + "/" + str(page) + "/"
data = self._parse_list(self._get(url))
return {"page": page, "pagecount": page if len(data) < 10 else page + 1, "limit": 24, "total": 99999, "list": data, "parse": 0, "jx": 0}
def detailContent(self, ids):
result = {"list": [], "parse": 0, "jx": 0}
if not ids:
return result
url = self._fix_url(ids[0] if str(ids[0]).startswith("http") else self.BASE_URL + "/videos/" + str(ids[0]).strip("/") + "/")
html = self._get(url)
name = self._clean(self._match(html, r'<h4[^>]*>(.*?)</h4>') or self._match(html, r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)') or self._match(html, r'<title>(.*?)</title>').split("-")[0])
pic = self._match(html, r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)') or self._match(html, r'<video[^>]+poster=["\']([^"\']+)') or self._match(html, r'<img[^>]+(?:data-src|src)=["\']([^"\']+)')
tags = ",".join([self._clean(x) for x in re.findall(r'<a[^>]+href=["\'][^"\']*/tags/[^"\']+["\'][^>]*>(.*?)</a>', html, re.S)])
remarks = self._clean(" ".join(re.findall(r'<h6[^>]*>(.*?)</h6>', html, re.S)[:3]))
content = self._clean(self._match(html, r'<div[^>]+class=["\'][^"\']*(?:description|info|text)[^"\']*["\'][^>]*>(.*?)</div>') or remarks or name)
m3u8 = self._m3u8(html)
result["list"].append({"vod_id": url, "vod_name": name, "vod_pic": urllib.parse.urljoin(self.BASE_URL, pic), "type_name": tags, "vod_year": "", "vod_area": "", "vod_remarks": remarks, "vod_actor": tags, "vod_director": "", "vod_content": content, "vod_play_from": "Jable", "vod_play_url": "正片$" + (m3u8 or url)})
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 + "/search/" + q + "/" if page <= 1 else self.BASE_URL + "/search/" + q + "/" + str(page) + "/"
data = self._parse_list(self._get(url))
return {"page": page, "pagecount": page if len(data) < 10 else page + 1, "limit": 24, "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
if ".m3u8" in id or ".mp4" in id:
return result
play_page = self._fix_url(id if str(id).startswith("http") else self.BASE_URL + "/videos/" + str(id).strip("/") + "/")
html = self._get(play_page)
m3u8 = self._m3u8(html)
if m3u8:
result["url"] = m3u8
result["header"] = {"User-Agent": self.HEADERS["User-Agent"], "Referer": play_page, "Origin": self.BASE_URL}
else:
result["url"] = play_page
result["parse"] = 1
return result
def _classes(self, html=None):
if self._class_cache:
return self._class_cache
self._class_cache = [
{"type_id": "latest-updates", "type_name": "最近更新"},
{"type_id": "hot", "type_name": "热门影片"},
{"type_id": "new-release", "type_name": "全新上市"},
{"type_id": "tags/chinese-subtitle", "type_name": "中文字幕"},
{"type_id": "tags/drama", "type_name": "剧情"},
{"type_id": "tags/cosplay", "type_name": "角色扮演"},
]
return self._class_cache
def _parse_list(self, html):
data, seen = [], set()
cards = re.findall(r'(<div[^>]+class=["\'][^"\']*video-img-box[^"\']*["\'][\s\S]*?</h6>[\s\S]*?</div>\s*</div>)', html or "", re.S | re.I)
if not cards:
cards = re.findall(r'(<a[^>]+href=["\'][^"\']*/videos/[^"\']+["\'][\s\S]*?</a>)', html or "", re.S | re.I)
for item in cards:
href = self._match(item, r'href=["\']([^"\']*/videos/[^"\']+)["\']')
if not href:
continue
name = self._clean(self._match(item, r'<h6[^>]*class=["\'][^"\']*title[^"\']*["\'][^>]*>\s*<a[^>]*>(.*?)</a>') or self._match(item, r'title=["\']([^"\']+)') or self._match(item, r'alt=["\']([^"\']+)'))
pic = self._match(item, r'(?:data-src|data-original|data-lazy-src|data-lazyload)=["\']([^"\']+)') or self._match(item, r'<img[^>]+src=["\']([^"\']+)')
remarks = self._clean(self._match(item, r'<span[^>]+class=["\'][^"\']*(?:duration|label|badge)[^"\']*["\'][^>]*>(.*?)</span>') or self._match(item, r'(\d{1,2}:\d{2}(?::\d{2})?)'))
full = self._fix_url(urllib.parse.urljoin(self.BASE_URL, href))
if full not in seen and name and not re.fullmatch(r'\d{1,2}:\d{2}(?::\d{2})?', name):
seen.add(full)
data.append({"vod_id": full, "vod_name": name, "vod_pic": urllib.parse.urljoin(self.BASE_URL, pic), "vod_remarks": remarks})
return data
def _get(self, url, headers=None):
for real in self._candidate_urls(self._fix_url(url)):
h = dict(self.HEADERS)
h["Referer"] = self.BASE_URL + "/"
if headers:
h.update(headers)
try:
r = self.session.get(real, headers=h, timeout=15, verify=False)
r.encoding = "utf-8"
if r.status_code < 400 and "Just a moment" not in r.text and "cf-browser-verification" not in r.text:
return r.text
except Exception:
continue
return ""
def _candidate_urls(self, url):
urls = [url]
for host in self.FALLBACK_URLS:
p = urllib.parse.urlparse(url)
if p.netloc and host not in url:
urls.append(host + p.path + ("?" + p.query if p.query else ""))
return list(dict.fromkeys(urls))
def _fix_url(self, url):
return str(url or "").replace("https://jable.tv", self.BASE_URL).replace("http://jable.tv", self.BASE_URL).replace("https://www.jable.tv", self.BASE_URL)
def _m3u8(self, html):
return self._match(html, r'var\s+hlsUrl\s*=\s*["\']([^"\']+\.m3u8[^"\']*)') or self._match(html, r'["\'](https?://[^"\']+\.m3u8[^"\']*)["\']')
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('&nbsp;', ' ').replace('&amp;', '&').replace('&#038;', '&').replace('&quot;', '"')
return re.sub(r'\s+', ' ', text).strip()
def _to_int(self, value, default=0):
try:
return int(value)
except Exception:
return default
+147 -201
View File
@@ -1,215 +1,161 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
from base.spider import Spider
import requests, re, json
import re
import urllib.parse
import requests
class Spider(Spider):
def getName(self):
return "JableTV"
def init(self, extend=""):
self.name = "JableTV"
self.host = "https://jable.tv"
self.header = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
def destroy(self):
try:
from base.spider import Spider as BaseSpider
except ImportError:
class BaseSpider:
pass
def localProxy(self, param):
return [False, ""]
class Spider(BaseSpider):
BASE_URL = "https://jable.sbs"
FALLBACK_URLS = ["https://jable.sbs", "https://jable.tv"]
HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": BASE_URL + "/",
}
def fix_url(self, url):
if not url: return ""
if url.startswith("//"): return "https:" + url
if url.startswith("/"): return self.host + url
return url
def __init__(self):
super().__init__()
self.name = "JableTV"
self.session = requests.Session()
self.session.headers.update(self.HEADERS)
self._class_cache = None
def clean_text(self, text):
return re.sub(r'\s+', ' ', text).strip() if text else ""
def init(self, extend="{}"):
return None
def _fetch(self, url, method="GET", data=None):
try:
if method == "POST":
resp = requests.post(url, data=data, headers=self.header, timeout=15)
else:
resp = requests.get(url, headers=self.header, timeout=15)
resp.encoding = 'utf-8'
return resp.text
except Exception as e:
print(f"[{self.name}] request fail: {url} - {e}")
return ""
def _extract_video_list(self, html):
items = []
seen_ids = set()
img_urls = re.findall(r'data-src="(https://[^"]*assets-cdn\.jable\.tv[^"]*preview\.jpg[^"]*)"', html)
if not img_urls:
img_urls = re.findall(r'data-src="(https://[^"]*assets-cdn\.jable\.tv[^"]*320x180[^"]*)"', html)
if not img_urls:
img_urls = re.findall(r'data-src="(https://[^"]*assets-cdn\.jable\.tv[^"]*)"', html)
video_data = []
h6_matches = re.findall(r'<h6[^>]*class="[^"]*title[^"]*"[^>]*>(.*?)</h6>', html, re.DOTALL)
for h6c in h6_matches:
if '/videos/' not in h6c:
continue
m = re.search(r'href="(https?://[^"]*videos/[^"]+)"', h6c)
if not m:
m = re.search(r'href="(/videos/[^"]+)"', h6c)
if m:
link = m.group(1)
if link.startswith('/'):
link = self.host + link
title = re.sub(r'<[^>]+>', '', h6c).strip()
vid = re.search(r'/videos/([^/]+)', link)
vod_id = vid.group(1) if vid else ""
if vod_id and vod_id not in seen_ids:
seen_ids.add(vod_id)
video_data.append((vod_id, title, link))
if not video_data:
all_links = re.findall(r'<a[^>]*href="([^"]*videos/[^"]+)"[^>]*>(.*?)</a>', html, re.DOTALL)
for href, content in all_links:
title = re.sub(r'<[^>]+>', '', content).strip()
if not title:
continue
link = href if href.startswith('http') else self.host + href
vid = re.search(r'/videos/([^/]+)', link)
vod_id = vid.group(1) if vid else ""
if vod_id and vod_id not in seen_ids:
seen_ids.add(vod_id)
video_data.append((vod_id, title, link))
if not video_data:
hrefs = re.findall(r'href="(https?://[^"]*videos/[^"]+)"', html)
for href in hrefs:
vid = re.search(r'/videos/([^/]+)', href)
vod_id = vid.group(1) if vid else ""
if vod_id and vod_id not in seen_ids:
seen_ids.add(vod_id)
video_data.append((vod_id, vod_id.upper(), href))
for i, (vid, title, link) in enumerate(video_data):
pic = img_urls[i] if i < len(img_urls) else ""
items.append({"vod_name": self.clean_text(title), "vod_id": vid, "vod_pic": pic})
return items
def getName(self):
return self.name
def homeContent(self, filter):
print(f"[{self.name}] homeContent")
try:
html = self._fetch(self.host)
items = self._extract_video_list(html)
class_list = [
{"type_name": "最新更新", "type_id": "latest-updates"},
{"type_name": "所有热门", "type_id": "hot"},
{"type_name": "本月热门", "type_id": "hot-monthly"},
{"type_name": "本周热门", "type_id": "hot-weekly"},
{"type_name": "今日热门", "type_id": "hot-daily"},
{"type_name": "新片速递", "type_id": "new-release"},
{"type_name": "蓝光无码", "type_id": "uncensored"},
]
return {"class": class_list, "filters": {}, "list": items, "page": 1, "pagecount": 1, "limit": len(items), "total": len(items)}
except Exception as e:
print(f"[{self.name}] homeContent error: {e}")
return {"class": [], "filters": {}, "list": [], "page": 1, "pagecount": 1, "limit": 0, "total": 0}
html = self._get(self.BASE_URL + "/latest-updates/")
return {"class": self._classes(), "filters": {}, "list": self._parse_list(html), "parse": 0, "jx": 0}
def homeVideoContent(self):
return {"list": self._parse_list(self._get(self.BASE_URL + "/latest-updates/"))}
def categoryContent(self, tid, pg, filter, extend):
print(f"[{self.name}] category: {tid} page {pg}")
try:
pg = int(pg) if pg else 1
tmap = {"hot-monthly":"hot","hot-weekly":"hot","hot-daily":"hot"}
sort_map = {"hot":"video_viewed","hot-monthly":"video_viewed_month","hot-weekly":"video_viewed_week","hot-daily":"video_viewed_today"}
rpath = tmap.get(tid, tid)
params = f"?sort_by={sort_map[tid]}" if tid in sort_map else ""
url = f"{self.host}/{rpath}/{params}" if pg == 1 else f"{self.host}/{rpath}/{pg}/{params}"
html = self._fetch(url)
items = self._extract_video_list(html)
pagecount = 99
nums = re.findall(r'/(\d+)/"', html)
if nums:
ints = [int(x) for x in nums if x.isdigit() and 1 <= int(x) <= 200]
if ints:
pagecount = max(ints)
total = len(items) * pagecount
return {"list": items, "page": pg, "pagecount": pagecount, "limit": len(items), "total": total}
except Exception as e:
print(f"[{self.name}] category error: {e}")
return {"list": [], "page": pg, "pagecount": 1, "limit": 0, "total": 0}
page = self._to_int(pg, 1)
path = str(tid or "latest-updates").strip("/")
url = self.BASE_URL + "/" + path + "/" if page <= 1 else self.BASE_URL + "/" + path + "/" + str(page) + "/"
data = self._parse_list(self._get(url))
return {"page": page, "pagecount": page if len(data) < 10 else page + 1, "limit": 24, "total": 99999, "list": data, "parse": 0, "jx": 0}
def detailContent(self, ids):
vid = ids[0] if isinstance(ids, list) else ids
print(f"[{self.name}] detail: {vid}")
result = {"list": [], "parse": 0, "jx": 0}
if not ids:
return result
url = self._fix_url(ids[0] if str(ids[0]).startswith("http") else self.BASE_URL + "/videos/" + str(ids[0]).strip("/") + "/")
html = self._get(url)
name = self._clean(self._match(html, r'<h4[^>]*>(.*?)</h4>') or self._match(html, r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)') or self._match(html, r'<title>(.*?)</title>').split("-")[0])
pic = self._match(html, r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)') or self._match(html, r'<video[^>]+poster=["\']([^"\']+)') or self._match(html, r'<img[^>]+(?:data-src|src)=["\']([^"\']+)')
tags = ",".join([self._clean(x) for x in re.findall(r'<a[^>]+href=["\'][^"\']*/tags/[^"\']+["\'][^>]*>(.*?)</a>', html, re.S)])
remarks = self._clean(" ".join(re.findall(r'<h6[^>]*>(.*?)</h6>', html, re.S)[:3]))
content = self._clean(self._match(html, r'<div[^>]+class=["\'][^"\']*(?:description|info|text)[^"\']*["\'][^>]*>(.*?)</div>') or remarks or name)
m3u8 = self._m3u8(html)
result["list"].append({"vod_id": url, "vod_name": name, "vod_pic": urllib.parse.urljoin(self.BASE_URL, pic), "type_name": tags, "vod_year": "", "vod_area": "", "vod_remarks": remarks, "vod_actor": tags, "vod_director": "", "vod_content": content, "vod_play_from": "Jable", "vod_play_url": "正片$" + (m3u8 or url)})
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 + "/search/" + q + "/" if page <= 1 else self.BASE_URL + "/search/" + q + "/" + str(page) + "/"
data = self._parse_list(self._get(url))
return {"page": page, "pagecount": page if len(data) < 10 else page + 1, "limit": 24, "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
if ".m3u8" in id or ".mp4" in id:
return result
play_page = self._fix_url(id if str(id).startswith("http") else self.BASE_URL + "/videos/" + str(id).strip("/") + "/")
html = self._get(play_page)
m3u8 = self._m3u8(html)
if m3u8:
result["url"] = m3u8
result["header"] = {"User-Agent": self.HEADERS["User-Agent"], "Referer": play_page, "Origin": self.BASE_URL}
else:
result["url"] = play_page
result["parse"] = 1
return result
def _classes(self, html=None):
if self._class_cache:
return self._class_cache
self._class_cache = [
{"type_id": "latest-updates", "type_name": "最近更新"},
{"type_id": "hot", "type_name": "热门影片"},
{"type_id": "new-release", "type_name": "全新上市"},
{"type_id": "tags/chinese-subtitle", "type_name": "中文字幕"},
{"type_id": "tags/drama", "type_name": "剧情"},
{"type_id": "tags/cosplay", "type_name": "角色扮演"},
]
return self._class_cache
def _parse_list(self, html):
data, seen = [], set()
cards = re.findall(r'(<div[^>]+class=["\'][^"\']*video-img-box[^"\']*["\'][\s\S]*?</h6>[\s\S]*?</div>\s*</div>)', html or "", re.S | re.I)
if not cards:
cards = re.findall(r'(<a[^>]+href=["\'][^"\']*/videos/[^"\']+["\'][\s\S]*?</a>)', html or "", re.S | re.I)
for item in cards:
href = self._match(item, r'href=["\']([^"\']*/videos/[^"\']+)["\']')
if not href:
continue
name = self._clean(self._match(item, r'<h6[^>]*class=["\'][^"\']*title[^"\']*["\'][^>]*>\s*<a[^>]*>(.*?)</a>') or self._match(item, r'title=["\']([^"\']+)') or self._match(item, r'alt=["\']([^"\']+)'))
pic = self._match(item, r'(?:data-src|data-original|data-lazy-src|data-lazyload)=["\']([^"\']+)') or self._match(item, r'<img[^>]+src=["\']([^"\']+)')
remarks = self._clean(self._match(item, r'<span[^>]+class=["\'][^"\']*(?:duration|label|badge)[^"\']*["\'][^>]*>(.*?)</span>') or self._match(item, r'(\d{1,2}:\d{2}(?::\d{2})?)'))
full = self._fix_url(urllib.parse.urljoin(self.BASE_URL, href))
if full not in seen and name and not re.fullmatch(r'\d{1,2}:\d{2}(?::\d{2})?', name):
seen.add(full)
data.append({"vod_id": full, "vod_name": name, "vod_pic": urllib.parse.urljoin(self.BASE_URL, pic), "vod_remarks": remarks})
return data
def _get(self, url, headers=None):
for real in self._candidate_urls(self._fix_url(url)):
h = dict(self.HEADERS)
h["Referer"] = self.BASE_URL + "/"
if headers:
h.update(headers)
try:
r = self.session.get(real, headers=h, timeout=15, verify=False)
r.encoding = "utf-8"
if r.status_code < 400 and "Just a moment" not in r.text and "cf-browser-verification" not in r.text:
return r.text
except Exception:
continue
return ""
def _candidate_urls(self, url):
urls = [url]
for host in self.FALLBACK_URLS:
p = urllib.parse.urlparse(url)
if p.netloc and host not in url:
urls.append(host + p.path + ("?" + p.query if p.query else ""))
return list(dict.fromkeys(urls))
def _fix_url(self, url):
return str(url or "").replace("https://jable.tv", self.BASE_URL).replace("http://jable.tv", self.BASE_URL).replace("https://www.jable.tv", self.BASE_URL)
def _m3u8(self, html):
return self._match(html, r'var\s+hlsUrl\s*=\s*["\']([^"\']+\.m3u8[^"\']*)') or self._match(html, r'["\'](https?://[^"\']+\.m3u8[^"\']*)["\']')
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('&nbsp;', ' ').replace('&amp;', '&').replace('&#038;', '&').replace('&quot;', '"')
return re.sub(r'\s+', ' ', text).strip()
def _to_int(self, value, default=0):
try:
html = self._fetch(f"{self.host}/videos/{vid}/")
title = ""
m = re.search(r'<h1[^>]*>(.*?)</h1>', html)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
if not title:
m = re.search(r'<title>(.*?)</title>', html)
if m:
title = re.sub(r'\s*-\s*Jable\.TV.*', '', m.group(1), flags=re.IGNORECASE).strip()
if not title:
m = re.search(r'<h6[^>]*class="[^"]*title[^"]*"[^>]*>(.*?)</h6>', html, re.DOTALL)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
pic = ""
m = re.search(r'poster="([^"]+)"', html)
if m: pic = self.fix_url(m.group(1))
if not pic:
m = re.search(r'<meta[^>]*property="og:image"[^>]*content="([^"]+)"', html)
if m: pic = m.group(1)
if not pic:
m = re.search(r'data-src="([^"]*preview\.jpg[^"]*)"', html)
if m: pic = m.group(1)
hls_url = ""
m = re.search(r"var\s+hlsUrl\s*=\s*'([^']+)'", html)
if m: hls_url = m.group(1)
if not hls_url:
m = re.search(r'var\s+hlsUrl\s*=\s*"([^"]+)"', html)
if m: hls_url = m.group(1)
vod_play_url = f"默认线路${hls_url}" if hls_url else ""
vod_play_from = "默认线路" if hls_url else ""
print(f"[{self.name}] detail ok: {title[:30] if title else 'N/A'}, has_play={bool(hls_url)}")
return {"list": [{"vod_id": vid, "vod_name": self.clean_text(title), "vod_pic": pic, "vod_play_from": vod_play_from, "vod_play_url": vod_play_url}]}
except Exception as e:
print(f"[{self.name}] detail error: {e}")
return {"list": []}
def searchContent(self, key, quick, pg):
pg = int(pg) if pg else 1
print(f"[{self.name}] search: {key}")
try:
html = self._fetch(f"{self.host}/search/", method="POST", data={"searchword": key})
items = self._extract_video_list(html)
if not items:
html = self._fetch(f"{self.host}/search/{key}/")
items = self._extract_video_list(html)
print(f"[{self.name}] search results: {len(items)}")
return {"list": items, "page": pg, "pagecount": 1, "limit": len(items), "total": len(items)}
except Exception as e:
print(f"[{self.name}] search error: {e}")
return {"list": [], "page": pg, "pagecount": 1, "limit": 0, "total": 0}
def playerContent(self, flag, id, vip_flag):
print(f"[{self.name}] player: {flag}")
try:
if ".m3u8" in (id or "") or ".mp4" in (id or ""):
print(f"[{self.name}] direct: {id[:50] if id else 'N/A'}...")
return {"parse": 0, "url": id, "header": json.dumps(self.header)}
html = self._fetch(f"{self.host}/videos/{id}/")
hls_url = ""
m = re.search(r"var\s+hlsUrl\s*=\s*'([^']+)'", html)
if m: hls_url = m.group(1)
if not hls_url:
m = re.search(r'var\s+hlsUrl\s*=\s*"([^"]+)"', html)
if m: hls_url = m.group(1)
if hls_url:
print(f"[{self.name}] play: {hls_url[:50]}...")
return {"parse": 0, "url": hls_url, "header": json.dumps(self.header)}
print(f"[{self.name}] play url not found")
return {"parse": 0, "url": "", "header": ""}
except Exception as e:
print(f"[{self.name}] player error: {e}")
return {"parse": 0, "url": "", "header": ""}
return int(value)
except Exception:
return default
+215
View File
@@ -0,0 +1,215 @@
#!/usr/bin/python
# -*- coding: utf-8 -*-
from base.spider import Spider
import requests, re, json
class Spider(Spider):
def getName(self):
return "JableTV"
def init(self, extend=""):
self.name = "JableTV"
self.host = "https://jable.tv"
self.header = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
def destroy(self):
pass
def localProxy(self, param):
return [False, ""]
def fix_url(self, url):
if not url: return ""
if url.startswith("//"): return "https:" + url
if url.startswith("/"): return self.host + url
return url
def clean_text(self, text):
return re.sub(r'\s+', ' ', text).strip() if text else ""
def _fetch(self, url, method="GET", data=None):
try:
if method == "POST":
resp = requests.post(url, data=data, headers=self.header, timeout=15)
else:
resp = requests.get(url, headers=self.header, timeout=15)
resp.encoding = 'utf-8'
return resp.text
except Exception as e:
print(f"[{self.name}] request fail: {url} - {e}")
return ""
def _extract_video_list(self, html):
items = []
seen_ids = set()
img_urls = re.findall(r'data-src="(https://[^"]*assets-cdn\.jable\.tv[^"]*preview\.jpg[^"]*)"', html)
if not img_urls:
img_urls = re.findall(r'data-src="(https://[^"]*assets-cdn\.jable\.tv[^"]*320x180[^"]*)"', html)
if not img_urls:
img_urls = re.findall(r'data-src="(https://[^"]*assets-cdn\.jable\.tv[^"]*)"', html)
video_data = []
h6_matches = re.findall(r'<h6[^>]*class="[^"]*title[^"]*"[^>]*>(.*?)</h6>', html, re.DOTALL)
for h6c in h6_matches:
if '/videos/' not in h6c:
continue
m = re.search(r'href="(https?://[^"]*videos/[^"]+)"', h6c)
if not m:
m = re.search(r'href="(/videos/[^"]+)"', h6c)
if m:
link = m.group(1)
if link.startswith('/'):
link = self.host + link
title = re.sub(r'<[^>]+>', '', h6c).strip()
vid = re.search(r'/videos/([^/]+)', link)
vod_id = vid.group(1) if vid else ""
if vod_id and vod_id not in seen_ids:
seen_ids.add(vod_id)
video_data.append((vod_id, title, link))
if not video_data:
all_links = re.findall(r'<a[^>]*href="([^"]*videos/[^"]+)"[^>]*>(.*?)</a>', html, re.DOTALL)
for href, content in all_links:
title = re.sub(r'<[^>]+>', '', content).strip()
if not title:
continue
link = href if href.startswith('http') else self.host + href
vid = re.search(r'/videos/([^/]+)', link)
vod_id = vid.group(1) if vid else ""
if vod_id and vod_id not in seen_ids:
seen_ids.add(vod_id)
video_data.append((vod_id, title, link))
if not video_data:
hrefs = re.findall(r'href="(https?://[^"]*videos/[^"]+)"', html)
for href in hrefs:
vid = re.search(r'/videos/([^/]+)', href)
vod_id = vid.group(1) if vid else ""
if vod_id and vod_id not in seen_ids:
seen_ids.add(vod_id)
video_data.append((vod_id, vod_id.upper(), href))
for i, (vid, title, link) in enumerate(video_data):
pic = img_urls[i] if i < len(img_urls) else ""
items.append({"vod_name": self.clean_text(title), "vod_id": vid, "vod_pic": pic})
return items
def homeContent(self, filter):
print(f"[{self.name}] homeContent")
try:
html = self._fetch(self.host)
items = self._extract_video_list(html)
class_list = [
{"type_name": "最新更新", "type_id": "latest-updates"},
{"type_name": "所有热门", "type_id": "hot"},
{"type_name": "本月热门", "type_id": "hot-monthly"},
{"type_name": "本周热门", "type_id": "hot-weekly"},
{"type_name": "今日热门", "type_id": "hot-daily"},
{"type_name": "新片速递", "type_id": "new-release"},
{"type_name": "蓝光无码", "type_id": "uncensored"},
]
return {"class": class_list, "filters": {}, "list": items, "page": 1, "pagecount": 1, "limit": len(items), "total": len(items)}
except Exception as e:
print(f"[{self.name}] homeContent error: {e}")
return {"class": [], "filters": {}, "list": [], "page": 1, "pagecount": 1, "limit": 0, "total": 0}
def categoryContent(self, tid, pg, filter, extend):
print(f"[{self.name}] category: {tid} page {pg}")
try:
pg = int(pg) if pg else 1
tmap = {"hot-monthly":"hot","hot-weekly":"hot","hot-daily":"hot"}
sort_map = {"hot":"video_viewed","hot-monthly":"video_viewed_month","hot-weekly":"video_viewed_week","hot-daily":"video_viewed_today"}
rpath = tmap.get(tid, tid)
params = f"?sort_by={sort_map[tid]}" if tid in sort_map else ""
url = f"{self.host}/{rpath}/{params}" if pg == 1 else f"{self.host}/{rpath}/{pg}/{params}"
html = self._fetch(url)
items = self._extract_video_list(html)
pagecount = 99
nums = re.findall(r'/(\d+)/"', html)
if nums:
ints = [int(x) for x in nums if x.isdigit() and 1 <= int(x) <= 200]
if ints:
pagecount = max(ints)
total = len(items) * pagecount
return {"list": items, "page": pg, "pagecount": pagecount, "limit": len(items), "total": total}
except Exception as e:
print(f"[{self.name}] category error: {e}")
return {"list": [], "page": pg, "pagecount": 1, "limit": 0, "total": 0}
def detailContent(self, ids):
vid = ids[0] if isinstance(ids, list) else ids
print(f"[{self.name}] detail: {vid}")
try:
html = self._fetch(f"{self.host}/videos/{vid}/")
title = ""
m = re.search(r'<h1[^>]*>(.*?)</h1>', html)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
if not title:
m = re.search(r'<title>(.*?)</title>', html)
if m:
title = re.sub(r'\s*-\s*Jable\.TV.*', '', m.group(1), flags=re.IGNORECASE).strip()
if not title:
m = re.search(r'<h6[^>]*class="[^"]*title[^"]*"[^>]*>(.*?)</h6>', html, re.DOTALL)
if m: title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
pic = ""
m = re.search(r'poster="([^"]+)"', html)
if m: pic = self.fix_url(m.group(1))
if not pic:
m = re.search(r'<meta[^>]*property="og:image"[^>]*content="([^"]+)"', html)
if m: pic = m.group(1)
if not pic:
m = re.search(r'data-src="([^"]*preview\.jpg[^"]*)"', html)
if m: pic = m.group(1)
hls_url = ""
m = re.search(r"var\s+hlsUrl\s*=\s*'([^']+)'", html)
if m: hls_url = m.group(1)
if not hls_url:
m = re.search(r'var\s+hlsUrl\s*=\s*"([^"]+)"', html)
if m: hls_url = m.group(1)
vod_play_url = f"默认线路${hls_url}" if hls_url else ""
vod_play_from = "默认线路" if hls_url else ""
print(f"[{self.name}] detail ok: {title[:30] if title else 'N/A'}, has_play={bool(hls_url)}")
return {"list": [{"vod_id": vid, "vod_name": self.clean_text(title), "vod_pic": pic, "vod_play_from": vod_play_from, "vod_play_url": vod_play_url}]}
except Exception as e:
print(f"[{self.name}] detail error: {e}")
return {"list": []}
def searchContent(self, key, quick, pg):
pg = int(pg) if pg else 1
print(f"[{self.name}] search: {key}")
try:
html = self._fetch(f"{self.host}/search/", method="POST", data={"searchword": key})
items = self._extract_video_list(html)
if not items:
html = self._fetch(f"{self.host}/search/{key}/")
items = self._extract_video_list(html)
print(f"[{self.name}] search results: {len(items)}")
return {"list": items, "page": pg, "pagecount": 1, "limit": len(items), "total": len(items)}
except Exception as e:
print(f"[{self.name}] search error: {e}")
return {"list": [], "page": pg, "pagecount": 1, "limit": 0, "total": 0}
def playerContent(self, flag, id, vip_flag):
print(f"[{self.name}] player: {flag}")
try:
if ".m3u8" in (id or "") or ".mp4" in (id or ""):
print(f"[{self.name}] direct: {id[:50] if id else 'N/A'}...")
return {"parse": 0, "url": id, "header": json.dumps(self.header)}
html = self._fetch(f"{self.host}/videos/{id}/")
hls_url = ""
m = re.search(r"var\s+hlsUrl\s*=\s*'([^']+)'", html)
if m: hls_url = m.group(1)
if not hls_url:
m = re.search(r'var\s+hlsUrl\s*=\s*"([^"]+)"', html)
if m: hls_url = m.group(1)
if hls_url:
print(f"[{self.name}] play: {hls_url[:50]}...")
return {"parse": 0, "url": hls_url, "header": json.dumps(self.header)}
print(f"[{self.name}] play url not found")
return {"parse": 0, "url": "", "header": ""}
except Exception as e:
print(f"[{self.name}] player error: {e}")
return {"parse": 0, "url": "", "header": ""}
+388
View File
@@ -0,0 +1,388 @@
# coding=utf-8
"""
目标站: 4kvm 首页: https://www.4kvm.net
动态筛选、精准分集、去重列表
"""
import re
import sys
import json
import urllib.parse
from bs4 import BeautifulSoup
sys.path.append('..')
from base.spider import Spider
class Spider(Spider):
def init(self, extend=""):
self.site_url = "https://www.4kvm.top"
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': self.site_url,
'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'
}
self.categories = [
{"type_id": "1", "type_name": "电影"},
{"type_id": "2", "type_name": "电视剧"},
{"type_id": "3", "type_name": "动漫"}
]
self._filters_cache = None
# ================= 动态筛选解析 =================
def _fetch_filters_for_classify(self, tid):
"""请求 /filter?classify=tid,解析页面筛选区域,返回该分类的筛选列表"""
url = f"{self.site_url}/filter?classify={tid}"
resp = self.fetch(url, headers=self.headers)
if not resp:
return []
soup = BeautifulSoup(resp.text, 'html.parser')
filter_groups = []
containers = soup.select('main div.flex.flex-wrap.items-center.gap-3')
for container in containers:
links = container.select('a[href]')
if len(links) < 2:
continue
first_text = links[0].get_text(strip=True)
if not first_text.startswith('全部'):
continue
group_name = first_text.replace('全部', '', 1).strip()
# 从非全部的链接中提取参数键
param_key = None
for a in links[1:]:
href = a.get('href', '')
parsed = urllib.parse.urlparse(href)
qs = urllib.parse.parse_qs(parsed.query)
for k in qs:
if k not in ('classify', 'page'):
param_key = k
break
if param_key:
break
if not param_key:
continue
if param_key in ('sort_by', 'order'):
continue
options = []
for a in links:
text = a.get_text(strip=True)
href = a.get('href', '')
parsed = urllib.parse.urlparse(href)
qs = urllib.parse.parse_qs(parsed.query)
val = ''
if param_key in qs:
val = qs[param_key][0] if qs[param_key] else ''
if text.startswith('全部'):
val = ''
options.append({"n": text, "v": val})
if options:
filter_groups.append({
"key": param_key,
"name": group_name,
"value": options
})
return filter_groups
def _get_all_filters(self):
if self._filters_cache is not None:
return self._filters_cache
filters = {}
for cat in self.categories:
tid = cat["type_id"]
groups = self._fetch_filters_for_classify(tid)
if groups:
filters[tid] = groups
# 为没有筛选的分类复用电影分类的筛选
if "1" in filters:
if "3" not in filters:
filters["3"] = filters["1"]
if "4" not in filters:
filters["4"] = filters["1"]
self._filters_cache = filters
return filters
# ================= 核心业务方法 =================
def homeContent(self, filter):
url = self.site_url + "/"
resp = self.fetch(url, headers=self.headers)
video_list = []
if resp:
soup = BeautifulSoup(resp.text, 'html.parser')
# 使用唯一卡片容器
cards = soup.select('div[data-vod-id]')
for card in cards[:20]:
a = card.select_one('a.block[href^="/play/"]')
if not a:
continue
vod_id = card.get('data-vod-id', '').strip()
if not vod_id:
href = a.get('href', '')
vod_id = href.replace('/play/', '').strip()
if not vod_id:
continue
title_tag = card.select_one('h3.text-white') or card.select_one('h3')
vod_name = title_tag.get_text(strip=True) if title_tag else ''
if not vod_name:
continue
img = card.select_one('img[data-src]')
vod_pic = ''
if img:
src = img.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
remark_tag = card.select_one('.text-green-500, .text-yellow-400, span[class*="px-1.5"]')
vod_remarks = remark_tag.get_text(strip=True) if remark_tag else ''
video_list.append({
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": vod_remarks
})
return {"class": self.categories, "list": video_list, "filters": self._get_all_filters()}
def homeVideoContent(self):
return self.homeContent(False)
def categoryContent(self, tid, pg, filter, extend):
page = int(pg) if pg else 1
params = {"classify": tid}
if extend:
for k, v in extend.items():
if v and k != 'classify':
params[k] = v
if page > 1:
params['page'] = page
query = urllib.parse.urlencode(params)
url = f"{self.site_url}/filter?{query}"
resp = self.fetch(url, headers=self.headers)
if not resp:
return {"list": [], "page": page, "pagecount": 1, "limit": 24, "total": 0}
soup = BeautifulSoup(resp.text, 'html.parser')
video_list = []
cards = soup.select('div[data-vod-id]')
for card in cards:
a = card.select_one('a.block[href^="/play/"]')
if not a:
continue
vod_id = card.get('data-vod-id', '').strip()
if not vod_id:
href = a.get('href', '')
vod_id = href.replace('/play/', '').strip()
if not vod_id:
continue
title_tag = card.select_one('h3.text-white') or card.select_one('h3')
vod_name = title_tag.get_text(strip=True) if title_tag else ''
if not vod_name:
continue
img = card.select_one('img[data-src]')
vod_pic = ''
if img:
src = img.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
remark_tag = card.select_one('.text-green-500, .text-yellow-400, span[class*="px-1.5"]')
vod_remarks = remark_tag.get_text(strip=True) if remark_tag else ''
video_list.append({
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": vod_remarks
})
# 分页处理
pagecount = page
page_text = soup.find(string=re.compile(r'\s*\d+\s*页'))
if page_text:
nums = re.findall(r'\d+', page_text)
if nums:
pagecount = int(nums[-1])
else:
page_block = soup.select_one('.flex.justify-center')
if page_block:
page_links = page_block.select('a[href*="page="]')
for a in page_links:
text = a.get_text(strip=True)
if text.isdigit():
pagecount = max(pagecount, int(text))
return {
"list": video_list,
"page": page,
"pagecount": pagecount,
"limit": 24,
"total": len(video_list) * pagecount
}
def detailContent(self, ids):
if not ids:
return {"list": []}
vod_id = ids[0]
url = f"{self.site_url}/play/{vod_id}"
resp = self.fetch(url, headers=self.headers)
if not resp or resp.status_code != 200:
return {"list": []}
soup = BeautifulSoup(resp.text, 'html.parser')
# 标题
title_elem = soup.select_one('h1.text-xl') or soup.select_one('h1') or soup.select_one('h2')
vod_name = title_elem.get_text(strip=True) if title_elem else vod_id
# 图片
vod_pic = ''
img_elem = soup.select_one('img.w-full') or soup.select_one('img[src]')
if img_elem:
src = img_elem.get('src', '') or img_elem.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
# 导演、主演、简介
vod_director = ''
vod_actor = ''
vod_content = ''
info_block = soup.select_one('.rounded-lg div.grid') or soup.select_one('div.grid')
if info_block:
text = info_block.get_text(' ', strip=True)
dir_match = re.search(r'导演\s*([^主\n]+)', text)
if dir_match:
vod_director = dir_match.group(1).strip()
act_match = re.search(r'主演\s*([^剧\n]+)', text)
if act_match:
vod_actor = act_match.group(1).strip()
desc_match = re.search(r'剧情简介\s*(.+)', text, re.DOTALL)
if desc_match:
vod_content = desc_match.group(1).strip()
elif re.search(r'简介\s*(.+)', text, re.DOTALL):
vod_content = re.search(r'简介\s*(.+)', text, re.DOTALL).group(1).strip()
# ================= 分集解析 (基于 episodeManager) =================
play_from_list = []
play_url_list = []
episode_manager = soup.select_one('[x-data*="episodeManager"]')
if episode_manager:
xdata = episode_manager.get('x-data', '')
lines_raw = re.findall(r'\{[^}]*lineName\s*:\s*\'([^\']+)\'[^}]*episodeCount\s*:\s*(\d+)[^}]*\}', xdata)
lines_info = [{'lineName': name, 'episodeCount': int(count)} for name, count in lines_raw]
episode_links = episode_manager.select('a[data-episode]')
lines_eps = {}
for a in episode_links:
line = a.get('data-line', '1')
ep = a.get('data-episode', '')
href = a.get('href', '')
if not href or not ep:
continue
full_url = href if href.startswith('http') else self.site_url + href
lines_eps.setdefault(line, []).append((int(ep), full_url))
for line_key in sorted(lines_eps.keys()):
eps = sorted(lines_eps[line_key], key=lambda x: x[0])
line_name = f'线路{line_key}'
for info in lines_info:
line_name = info['lineName']
break # 目前只用第一个线路名
if not eps:
continue
episode_strs = [f"{ep[0]}集${ep[1]}" for ep in eps]
play_from_list.append(line_name)
play_url_list.append('#'.join(episode_strs))
# 回退:无分集则直接播放当前页
if not play_url_list:
play_from_list.append('播放')
play_url_list.append(f"播放${vod_id}")
vod_play_from = '$$$'.join(play_from_list)
vod_play_url = '$$$'.join(play_url_list)
result = [{
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_content": vod_content,
"vod_actor": vod_actor,
"vod_director": vod_director,
"vod_area": "",
"vod_year": "",
"vod_play_from": vod_play_from,
"vod_play_url": vod_play_url
}]
return {"list": result}
def searchContent(self, key, quick, pg="1"):
page = int(pg) if pg else 1
params = {"q": key}
if page > 1:
params['page'] = page
query = urllib.parse.urlencode(params)
url = f"{self.site_url}/search?{query}"
resp = self.fetch(url, headers=self.headers)
if not resp:
return {"list": [], "page": page, "pagecount": 1}
soup = BeautifulSoup(resp.text, 'html.parser')
video_list = []
cards = soup.select('div[data-vod-id]')
if not cards:
# 搜索页可能没有 data-vod-id,降级处理
for a in soup.select('a.block[href^="/play/"]'):
href = a.get('href', '')
vod_id = href.replace('/play/', '').strip()
if not vod_id:
continue
h3 = a.select_one('h3')
vod_name = h3.get_text(strip=True) if h3 else href
if not vod_name:
continue
img = a.select_one('img[data-src]')
vod_pic = ''
if img:
src = img.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
video_list.append({
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": ''
})
else:
for card in cards[:30]:
a = card.select_one('a.block[href^="/play/"]')
if not a:
continue
vod_id = card.get('data-vod-id', '').strip()
if not vod_id:
href = a.get('href', '')
vod_id = href.replace('/play/', '').strip()
if not vod_id:
continue
title_tag = card.select_one('h3.text-white') or card.select_one('h3')
vod_name = title_tag.get_text(strip=True) if title_tag else ''
if not vod_name:
continue
img = card.select_one('img[data-src]')
vod_pic = ''
if img:
src = img.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
remark_tag = card.select_one('.text-green-500, .text-yellow-400, span[class*="px-1.5"]')
vod_remarks = remark_tag.get_text(strip=True) if remark_tag else ''
video_list.append({
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": vod_remarks
})
return {"list": video_list, "page": page, "pagecount": 1}
def playerContent(self, flag, id, vipFlags):
if not id.startswith('http'):
url = f"{self.site_url}/play/{id}"
else:
url = id
return {"parse": 1, "url": url, "header": self.headers}
+388
View File
@@ -0,0 +1,388 @@
# coding=utf-8
"""
目标站: 4kvm 首页: https://www.4kvm.net
动态筛选、精准分集、去重列表
"""
import re
import sys
import json
import urllib.parse
from bs4 import BeautifulSoup
sys.path.append('..')
from base.spider import Spider
class Spider(Spider):
def init(self, extend=""):
self.site_url = "https://www.4kvm.top"
self.headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
'Referer': self.site_url,
'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'
}
self.categories = [
{"type_id": "1", "type_name": "电影"},
{"type_id": "2", "type_name": "电视剧"},
{"type_id": "3", "type_name": "动漫"}
]
self._filters_cache = None
# ================= 动态筛选解析 =================
def _fetch_filters_for_classify(self, tid):
"""请求 /filter?classify=tid,解析页面筛选区域,返回该分类的筛选列表"""
url = f"{self.site_url}/filter?classify={tid}"
resp = self.fetch(url, headers=self.headers)
if not resp:
return []
soup = BeautifulSoup(resp.text, 'html.parser')
filter_groups = []
containers = soup.select('main div.flex.flex-wrap.items-center.gap-3')
for container in containers:
links = container.select('a[href]')
if len(links) < 2:
continue
first_text = links[0].get_text(strip=True)
if not first_text.startswith('全部'):
continue
group_name = first_text.replace('全部', '', 1).strip()
# 从非全部的链接中提取参数键
param_key = None
for a in links[1:]:
href = a.get('href', '')
parsed = urllib.parse.urlparse(href)
qs = urllib.parse.parse_qs(parsed.query)
for k in qs:
if k not in ('classify', 'page'):
param_key = k
break
if param_key:
break
if not param_key:
continue
if param_key in ('sort_by', 'order'):
continue
options = []
for a in links:
text = a.get_text(strip=True)
href = a.get('href', '')
parsed = urllib.parse.urlparse(href)
qs = urllib.parse.parse_qs(parsed.query)
val = ''
if param_key in qs:
val = qs[param_key][0] if qs[param_key] else ''
if text.startswith('全部'):
val = ''
options.append({"n": text, "v": val})
if options:
filter_groups.append({
"key": param_key,
"name": group_name,
"value": options
})
return filter_groups
def _get_all_filters(self):
if self._filters_cache is not None:
return self._filters_cache
filters = {}
for cat in self.categories:
tid = cat["type_id"]
groups = self._fetch_filters_for_classify(tid)
if groups:
filters[tid] = groups
# 为没有筛选的分类复用电影分类的筛选
if "1" in filters:
if "3" not in filters:
filters["3"] = filters["1"]
if "4" not in filters:
filters["4"] = filters["1"]
self._filters_cache = filters
return filters
# ================= 核心业务方法 =================
def homeContent(self, filter):
url = self.site_url + "/"
resp = self.fetch(url, headers=self.headers)
video_list = []
if resp:
soup = BeautifulSoup(resp.text, 'html.parser')
# 使用唯一卡片容器
cards = soup.select('div[data-vod-id]')
for card in cards[:20]:
a = card.select_one('a.block[href^="/play/"]')
if not a:
continue
vod_id = card.get('data-vod-id', '').strip()
if not vod_id:
href = a.get('href', '')
vod_id = href.replace('/play/', '').strip()
if not vod_id:
continue
title_tag = card.select_one('h3.text-white') or card.select_one('h3')
vod_name = title_tag.get_text(strip=True) if title_tag else ''
if not vod_name:
continue
img = card.select_one('img[data-src]')
vod_pic = ''
if img:
src = img.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
remark_tag = card.select_one('.text-green-500, .text-yellow-400, span[class*="px-1.5"]')
vod_remarks = remark_tag.get_text(strip=True) if remark_tag else ''
video_list.append({
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": vod_remarks
})
return {"class": self.categories, "list": video_list, "filters": self._get_all_filters()}
def homeVideoContent(self):
return self.homeContent(False)
def categoryContent(self, tid, pg, filter, extend):
page = int(pg) if pg else 1
params = {"classify": tid}
if extend:
for k, v in extend.items():
if v and k != 'classify':
params[k] = v
if page > 1:
params['page'] = page
query = urllib.parse.urlencode(params)
url = f"{self.site_url}/filter?{query}"
resp = self.fetch(url, headers=self.headers)
if not resp:
return {"list": [], "page": page, "pagecount": 1, "limit": 24, "total": 0}
soup = BeautifulSoup(resp.text, 'html.parser')
video_list = []
cards = soup.select('div[data-vod-id]')
for card in cards:
a = card.select_one('a.block[href^="/play/"]')
if not a:
continue
vod_id = card.get('data-vod-id', '').strip()
if not vod_id:
href = a.get('href', '')
vod_id = href.replace('/play/', '').strip()
if not vod_id:
continue
title_tag = card.select_one('h3.text-white') or card.select_one('h3')
vod_name = title_tag.get_text(strip=True) if title_tag else ''
if not vod_name:
continue
img = card.select_one('img[data-src]')
vod_pic = ''
if img:
src = img.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
remark_tag = card.select_one('.text-green-500, .text-yellow-400, span[class*="px-1.5"]')
vod_remarks = remark_tag.get_text(strip=True) if remark_tag else ''
video_list.append({
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": vod_remarks
})
# 分页处理
pagecount = page
page_text = soup.find(string=re.compile(r'\s*\d+\s*页'))
if page_text:
nums = re.findall(r'\d+', page_text)
if nums:
pagecount = int(nums[-1])
else:
page_block = soup.select_one('.flex.justify-center')
if page_block:
page_links = page_block.select('a[href*="page="]')
for a in page_links:
text = a.get_text(strip=True)
if text.isdigit():
pagecount = max(pagecount, int(text))
return {
"list": video_list,
"page": page,
"pagecount": pagecount,
"limit": 24,
"total": len(video_list) * pagecount
}
def detailContent(self, ids):
if not ids:
return {"list": []}
vod_id = ids[0]
url = f"{self.site_url}/play/{vod_id}"
resp = self.fetch(url, headers=self.headers)
if not resp or resp.status_code != 200:
return {"list": []}
soup = BeautifulSoup(resp.text, 'html.parser')
# 标题
title_elem = soup.select_one('h1.text-xl') or soup.select_one('h1') or soup.select_one('h2')
vod_name = title_elem.get_text(strip=True) if title_elem else vod_id
# 图片
vod_pic = ''
img_elem = soup.select_one('img.w-full') or soup.select_one('img[src]')
if img_elem:
src = img_elem.get('src', '') or img_elem.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
# 导演、主演、简介
vod_director = ''
vod_actor = ''
vod_content = ''
info_block = soup.select_one('.rounded-lg div.grid') or soup.select_one('div.grid')
if info_block:
text = info_block.get_text(' ', strip=True)
dir_match = re.search(r'导演\s*([^主\n]+)', text)
if dir_match:
vod_director = dir_match.group(1).strip()
act_match = re.search(r'主演\s*([^剧\n]+)', text)
if act_match:
vod_actor = act_match.group(1).strip()
desc_match = re.search(r'剧情简介\s*(.+)', text, re.DOTALL)
if desc_match:
vod_content = desc_match.group(1).strip()
elif re.search(r'简介\s*(.+)', text, re.DOTALL):
vod_content = re.search(r'简介\s*(.+)', text, re.DOTALL).group(1).strip()
# ================= 分集解析 (基于 episodeManager) =================
play_from_list = []
play_url_list = []
episode_manager = soup.select_one('[x-data*="episodeManager"]')
if episode_manager:
xdata = episode_manager.get('x-data', '')
lines_raw = re.findall(r'\{[^}]*lineName\s*:\s*\'([^\']+)\'[^}]*episodeCount\s*:\s*(\d+)[^}]*\}', xdata)
lines_info = [{'lineName': name, 'episodeCount': int(count)} for name, count in lines_raw]
episode_links = episode_manager.select('a[data-episode]')
lines_eps = {}
for a in episode_links:
line = a.get('data-line', '1')
ep = a.get('data-episode', '')
href = a.get('href', '')
if not href or not ep:
continue
full_url = href if href.startswith('http') else self.site_url + href
lines_eps.setdefault(line, []).append((int(ep), full_url))
for line_key in sorted(lines_eps.keys()):
eps = sorted(lines_eps[line_key], key=lambda x: x[0])
line_name = f'线路{line_key}'
for info in lines_info:
line_name = info['lineName']
break # 目前只用第一个线路名
if not eps:
continue
episode_strs = [f"{ep[0]}集${ep[1]}" for ep in eps]
play_from_list.append(line_name)
play_url_list.append('#'.join(episode_strs))
# 回退:无分集则直接播放当前页
if not play_url_list:
play_from_list.append('播放')
play_url_list.append(f"播放${vod_id}")
vod_play_from = '$$$'.join(play_from_list)
vod_play_url = '$$$'.join(play_url_list)
result = [{
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_content": vod_content,
"vod_actor": vod_actor,
"vod_director": vod_director,
"vod_area": "",
"vod_year": "",
"vod_play_from": vod_play_from,
"vod_play_url": vod_play_url
}]
return {"list": result}
def searchContent(self, key, quick, pg="1"):
page = int(pg) if pg else 1
params = {"q": key}
if page > 1:
params['page'] = page
query = urllib.parse.urlencode(params)
url = f"{self.site_url}/search?{query}"
resp = self.fetch(url, headers=self.headers)
if not resp:
return {"list": [], "page": page, "pagecount": 1}
soup = BeautifulSoup(resp.text, 'html.parser')
video_list = []
cards = soup.select('div[data-vod-id]')
if not cards:
# 搜索页可能没有 data-vod-id,降级处理
for a in soup.select('a.block[href^="/play/"]'):
href = a.get('href', '')
vod_id = href.replace('/play/', '').strip()
if not vod_id:
continue
h3 = a.select_one('h3')
vod_name = h3.get_text(strip=True) if h3 else href
if not vod_name:
continue
img = a.select_one('img[data-src]')
vod_pic = ''
if img:
src = img.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
video_list.append({
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": ''
})
else:
for card in cards[:30]:
a = card.select_one('a.block[href^="/play/"]')
if not a:
continue
vod_id = card.get('data-vod-id', '').strip()
if not vod_id:
href = a.get('href', '')
vod_id = href.replace('/play/', '').strip()
if not vod_id:
continue
title_tag = card.select_one('h3.text-white') or card.select_one('h3')
vod_name = title_tag.get_text(strip=True) if title_tag else ''
if not vod_name:
continue
img = card.select_one('img[data-src]')
vod_pic = ''
if img:
src = img.get('data-src', '')
if src and not src.startswith('data:'):
vod_pic = src if src.startswith('http') else 'https:' + src
remark_tag = card.select_one('.text-green-500, .text-yellow-400, span[class*="px-1.5"]')
vod_remarks = remark_tag.get_text(strip=True) if remark_tag else ''
video_list.append({
"vod_id": vod_id,
"vod_name": vod_name,
"vod_pic": vod_pic,
"vod_remarks": vod_remarks
})
return {"list": video_list, "page": page, "pagecount": 1}
def playerContent(self, flag, id, vipFlags):
if not id.startswith('http'):
url = f"{self.site_url}/play/{id}"
else:
url = id
return {"parse": 1, "url": url, "header": self.headers}
+163
View File
@@ -0,0 +1,163 @@
# -*- coding: utf-8 -*-
# 8x8x官网: https://www.7xb38c.com/
import sys,re,json,base64
from urllib.parse import quote
sys.path.append('..')
try:
from base.spider import Spider as _Base
except ImportError:
class _Base:
def fetch(self,url,headers=None,**kw):
import requests as rq
kw.pop('timeout',None);r=rq.get(url,headers=headers,timeout=15,**kw)
r.encoding='utf-8';return r
try:
import curl_cffi.requests as cr
_HAS_CFFI=True
except ImportError:
_HAS_CFFI=False
import requests as cr
_H=[104,116,116,112,115,58,47,47,119,119,119,46,51,97,98,102,117,103,57,50,100,46,99,111,109]
H=bytes(_H).decode()
U="Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
CATS={1:"大陆",2:"日韩",3:"欧美",4:"动漫",5:"三级"}
TC=None
# ── 动态发现 body path ──
def _discover_body(session):
"""从 SPA 壳中提取 body 路径前缀, 失败返回默认值"""
try:
kw={"timeout":10}
if _HAS_CFFI:kw["impersonate"]="chrome120"
r=session.get(H+"/",**kw)
r.raise_for_status()
js_name=re.search(r'src=/assets/(app\.[a-f0-9]+\.js)',r.text)
if not js_name:return"/cou345w"
r2=session.get(H+"/assets/"+js_name.group(1),**kw)
r2.raise_for_status()
seg=re.search(r'atob\("([^"]+)"\)',r2.text)
if not seg:return"/cou345w"
return"/"+base64.b64decode(seg.group(1)).decode()
except Exception as e:
print(f"[8x8x] body discover failed: {e}, using default")
return"/cou345w"
class Spider(_Base):
def init(self,extend=""):
self._s=cr.Session()
self._s.headers.update({"User-Agent":U,"Accept-Language":"zh-CN,zh;q=0.9"})
self._s.verify=False
# ★ 动态发现 body path
self._bd=_discover_body(self._s)
print(f"[8x8x] body path: {self._bd}")
def getName(self):return"8x8x"
def isVideoFormat(self,u):return".m3u8"in u or".mp4"in u
def manualVideoCheck(self):return False
def _get(self,url,timeout=15):
if not url.startswith("http"):url=H+self._bd+url
try:
kw={"timeout":timeout}
if _HAS_CFFI:kw["impersonate"]="chrome120"
r=self._s.get(url,**kw);r.raise_for_status()
if hasattr(r,'encoding'):r.encoding='utf-8'
return r.text
except Exception as e:
print(f"[8x8x]GET {url[:60]} -> {e}")
return""
def _tags(self):
global TC
if TC is not None:return TC
h=self._get("/");g={}
if not h:TC=g;return g
for gm in re.finditer(r'<div class=tag-group data-group-id=\d+><span class=tag-group-label>([^<]+)</span>(.*?)</div>',h,re.DOTALL):
gn=gm.group(1);inner=gm.group(2)
ts=re.findall(r'<a href=(/tags/[^/]+/)\s[^>]*>([^<]+)</a>',inner)
if ts:g[gn]=ts
TC=g;return g
def _cards(self,h):
v=[]
for m in re.finditer(r'<a href=(/vd/(\d+)/)\s[^>]*>(.*?)</a>',h,re.DOTALL):
inner=m.group(3)
tm=re.search(r'<div class=card-title>(.*?)</div>',inner)
im=re.search(r'data-src=([^\s>]+)',inner)
v.append({"vod_id":m.group(1),"vod_name":tm.group(1)if tm else"N/A","vod_pic":im.group(1)if im else"","vod_remarks":""})
return v
def homeContent(self,filter=False):
t=self._tags();cs=[]
for cid,cn in sorted(CATS.items()):cs.append({"type_id":str(cid),"type_name":cn})
for gn in sorted(t.keys()):
for url,name in t[gn]:cs.append({"type_id":url,"type_name":f"[{gn}] {name}"})
return{"class":cs}
def homeVideoContent(self):
h=self._get("/")
return{"list":self._cards(h)[:30]if h else[]}
def categoryContent(self,tid,pg=1,filter=False,extend=None):
try:
pn=max(int(str(pg)),1)
base=tid.rstrip("/")if tid.startswith("/tags/")else f"/category/{int(tid)}"
h=self._get(f"{base}/page/{pn}/")
if not h:return{"list":[],"page":pg,"pagecount":1}
mp=re.search(r'data-max=(\d+)',h);pc=int(mp.group(1))if mp else pn
v=self._cards(h)
return{"list":v,"page":pn,"pagecount":pc,"limit":len(v),"total":pc*len(v)if v else 0}
except Exception as e:print(f"[8x8x]cat:{e}");return{"list":[],"page":pg,"pagecount":1}
def detailContent(self,ids):
vid=ids[0]
if not vid.startswith("/vd/"):vid=f"/vd/{vid.strip('/')}/"
h=self._get(vid)
if not h:return{"list":[]}
tm=re.search(r'<title>(.*?)</title>',h);title=tm.group(1).replace(" - 8x8x","")if tm else""
pm=re.search(r'data-poster=([^\s>]+)',h);pic=pm.group(1).strip('"').strip("'")if pm else""
mm=re.search(r'data-m3u8=([^\s>]+)',h)
if not mm:return{"list":[{"vod_id":ids[0],"vod_name":title,"vod_pic":pic}]}
m3u8_path=mm.group(1).strip('"').strip("'")
pf=[];pu=[]
for i,rn in enumerate(["data-route1","data-route2","data-route3"],1):
rm=re.search(rf'{rn}=([^\s>]+)',h)
if rm:
rt=rm.group(1).strip('"').strip("'")
full_m3u8=rt.rstrip("/")+"/"+m3u8_path.lstrip("/")
pf.append(f"线路{i}")
pu.append(f"线路{i}${full_m3u8}")
return{"list":[{"vod_id":ids[0],"vod_name":title,"vod_pic":pic,"type_name":"","vod_year":"","vod_area":"","vod_remarks":"","vod_actor":"","vod_director":"","vod_content":"","vod_play_from":"$$$".join(pf),"vod_play_url":"$$$".join(pu)}]}
def playerContent(self,flag,id,vipFlags=None):
if id and".m3u8"in id:return{"url":id,"header":json.dumps({"User-Agent":U,"Referer":H+"/"})}
d=self.detailContent([id])
if d and d.get("list"):
urls=d["list"][0].get("vod_play_url","").split("$$$")
if urls:
first=urls[0]
if"$"in first:first=first.split("$",1)[1]
return{"url":first,"header":json.dumps({"User-Agent":U,"Referer":H+"/"})}
return{"url":""}
def searchContent(self,key,quick=False,pg=1):
try:
pn=max(int(str(pg)),1)
url=f"{H}/api/search/video?keyword={quote(key)}&page={pn}"
kw={"timeout":15}
if _HAS_CFFI:kw["impersonate"]="chrome120"
r=self._s.get(url,**kw);r.raise_for_status()
data=r.json()
if data.get("code")!=0:return{"list":[]}
dl=data.get("data",{})
videos=[]
for item in dl.get("list",[]):
videos.append({"vod_id":f"/vd/{item['id']}/","vod_name":item.get("title",""),"vod_pic":item.get("litpic",""),"vod_remarks":item.get("typename","")})
return{"list":videos,"page":pn,"pagecount":dl.get("total_pages",pn),"limit":len(videos),"total":dl.get("total",0)}
except Exception as e:print(f"[8x8x]search:{e}");return{"list":[],"page":pg,"pagecount":1}
def localProxy(self,param):pass
+874
View File
@@ -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,
}
+874
View File
@@ -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,
}
+270
View File
@@ -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"主演[:&nbsp;\s]*([^<]+)") or "")
remarks = self._clean(self._match(html, r"番号[:&nbsp;\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("&amp;", "&").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("&amp;", "&").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("&amp;", "&").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("&amp;", "&")
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("&nbsp;", " ").replace("&amp;", "&").replace("&quot;", '"').replace("&#39;", "'")
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
+801
View File
@@ -0,0 +1,801 @@
# -*- coding: utf-8 -*-
# 适配站点: https://807.khp22.cc
# 分类:动态解析导航栏一级分类(无二级)
# 列表:标准卡片解析(<article class="excerpt excerpt-c5">),支持列表页加密解码
# 播放:直链 + iframe 二次解析 + m3u8 清洗(可选)
import sys
import re
import json
import base64
import requests
import urllib3
import time
import random
from urllib.parse import unquote, quote, urljoin, urlparse
urllib3.disable_warnings()
class Spider:
session = requests.Session()
host = 'https://807.khp22.cc'
_debug = True
_category_cache = None
def _log(self, msg):
if self._debug:
print(f'[807khp] {msg}')
def getName(self):
return '807khp'
def getDependence(self):
return []
def isVideoFormat(self, url):
if not url:
return False
return '.m3u8' in url or '.mp4' in url or '.ts' in url
def manualVideoCheck(self):
return False
def destroy(self):
pass
# ---------- 本地代理:清洗 m3u8(可选) ----------
def localProxy(self, param):
try:
if not isinstance(param, dict):
param = {}
ptype = param.get('type') or param.get('action') or param.get('do')
url = param.get('url', '')
if ptype != 'm3u8' or not url:
return [404, "text/plain", "not found"]
referer = param.get('referer', '') or self.host
if isinstance(url, list):
url = url[0]
if isinstance(referer, list):
referer = referer[0]
url = unquote(url)
referer = unquote(referer)
raw_m3u8 = self._get_m3u8_content(url, referer)
if not raw_m3u8:
return [404, "text/plain", "m3u8 download failed"]
cleaned = self._clean_m3u8(raw_m3u8, url, referer)
return [200, "application/vnd.apple.mpegurl", cleaned]
except Exception as e:
self._log(f'localProxy error: {e}')
return [404, "text/plain", "proxy error"]
# ---------- 初始化 ----------
def init(self, extend=''):
self.session.verify = False
self.session.headers.update(self._get_headers())
try:
self.session.get(self.host, timeout=10)
except:
pass
def _get_headers(self, referer=None):
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,image/webp,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9',
'Accept-Encoding': 'gzip, deflate',
'Connection': 'keep-alive',
'Referer': referer or self.host + '/',
}
return headers
def _fetch(self, url, referer=None, retries=3):
for i in range(retries):
try:
if referer is None:
referer = self.host + '/'
headers = self._get_headers(referer)
if i > 0:
time.sleep(random.uniform(1.5, 3.0))
else:
time.sleep(random.uniform(0.5, 1.5))
self._log(f'请求: {url}')
r = self.session.get(url, headers=headers, timeout=30, verify=False, allow_redirects=True)
r.encoding = 'utf-8'
if r.status_code == 200:
self._log(f'成功获取,内容长度: {len(r.text)}')
return r.text
else:
self._log(f'状态码 {r.status_code},重试 {i+1}/{retries}')
except Exception as e:
self._log(f'请求异常 [{e}],重试 {i+1}/{retries}')
self._log(f'所有重试失败: {url}')
return ''
@staticmethod
def _try_decode_b64(text):
"""尝试 Base64 解码,失败则返回原字符串"""
if not text:
return ''
try:
missing_padding = 4 - len(text) % 4
if missing_padding != 4:
text += "=" * missing_padding
decoded_bytes = base64.b64decode(text)
return decoded_bytes.decode('utf-8')
except Exception:
return text
# ==================== 分类相关(动态解析,无二级) ====================
def _fetch_category_data(self):
if self._category_cache is not None:
return self._category_cache
html = self._fetch(self.host)
if not html:
self._log('首页获取失败,使用硬编码备用')
return self._get_fallback_categories()
real_html = self._extract_real_html(html)
if not real_html:
self._log('解码首页失败,使用备用')
return self._get_fallback_categories()
categories = []
pattern = r'<a\s+href="(/type/(\d+)/hot/1\.html)"[^>]*>([^<]+)</a>'
for href, tid, name in re.findall(pattern, real_html):
name = name.strip()
if not name or name in ['首页', '我的收藏', '收藏', '']:
continue
if any(c['type_name'] == name for c in categories):
continue
categories.append({'type_id': tid, 'type_name': name})
if not categories:
self._log('解析分类为空,使用备用')
return self._get_fallback_categories()
self._category_cache = categories
self._log(f'动态解析到 {len(categories)} 个分类: {[c["type_name"] for c in categories]}')
return categories
def _get_fallback_categories(self):
return [
{'type_id': '1', 'type_name': '热门'},
{'type_id': '7', 'type_name': '日本'},
{'type_id': '6', 'type_name': '偷拍'},
{'type_id': '2', 'type_name': '国产精选'},
{'type_id': '4', 'type_name': '家庭'},
{'type_id': '3', 'type_name': '华语'},
{'type_id': '5', 'type_name': '动漫'},
{'type_id': '9', 'type_name': '欧美'},
{'type_id': '8', 'type_name': '黄网'},
]
def _extract_real_html(self, html):
"""解码首页/详情页的 html_b 加密"""
match = re.search(r'html_b\s*=\s*"([^"]+)"', html)
if match:
b64 = match.group(1)
try:
decoded = base64.b64decode(b64).decode('utf-8')
return decoded
except Exception as e:
self._log(f'解码 html_b 失败: {e}')
return ''
self._log('未找到 html_b,可能未加密')
return html
# ==================== 首页 ====================
def homeContent(self, filter=False):
try:
categories = self._fetch_category_data()
classes = []
for cat in categories:
classes.append({'type_id': cat['type_id'], 'type_name': cat['type_name']})
home_list = []
if categories:
first_cat = categories[0]
home_list = self._get_video_list(first_cat['type_id'], 1)
return {
'class': classes,
'filters': {},
'type': '影视',
'list': home_list,
'page': 1,
'pagecount': 1,
'limit': len(home_list),
'total': len(home_list)
}
except Exception as e:
self._log(f'homeContent 异常: {e}')
return {'class': [], 'filters': {}, 'type': '影视', 'list': [], 'page': 1, 'pagecount': 1, 'limit': 0, 'total': 0}
def homeVideoContent(self):
return {'list': []}
def categoryContent(self, tid, pg, filter=False, extend=None):
try:
page = int(pg) if pg else 1
items = self._get_video_list(tid, page)
total_page = page + 1
url = f'{self.host}/type/{tid}/hot/1.html'
html = self._fetch(url)
if html:
pages = re.findall(r'/type/\d+/hot/(\d+)\.html', html)
if pages:
total_page = max(int(p) for p in pages)
return {
'list': items,
'page': page,
'pagecount': total_page,
'limit': len(items),
'total': total_page * len(items) if items else 0,
'type_name': self._get_category_name(tid)
}
except Exception as e:
self._log(f'categoryContent 异常: {e}')
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1, 'limit': 0, 'total': 0}
def _get_category_name(self, tid):
for cat in self._fetch_category_data():
if cat['type_id'] == tid:
return cat['type_name']
return tid
# ==================== 列表解析(封面 bbb 解码) ====================
def _parse_list(self, html):
items = []
articles = re.findall(r'<article[^>]*class="[^"]*excerpt[^"]*"[^>]*>(.*?)</article>', html, re.S)
self._log(f'解析到 {len(articles)} 个卡片')
if not articles:
self._log(f'HTML预览: {html[:500]}')
return items
for art in articles:
a_match = re.search(r'<a\s+href="([^"]+)"', art)
if not a_match:
continue
href = a_match.group(1)
m = re.search(r'/(\d+)\.html', href)
if not m:
continue
vid = m.group(1)
# 标题
title = ''
title_match = re.search(r'<h2[^>]*>(.*?)</h2>', art, re.S)
if title_match:
inner = title_match.group(1)
a_title = re.search(r'<a[^>]*>(.*?)</a>', inner, re.S)
if a_title:
title = re.sub(r'<[^>]+>', '', a_title.group(1)).strip()
else:
title = re.sub(r'<[^>]+>', '', inner).strip()
# 封面提取(优先 data-src / bbb,并对 bbb 解码)
pic = ''
img_tag = re.search(r'<img[^>]+>', art, re.S)
if img_tag:
tag_str = img_tag.group(0)
for attr in ['data-src', 'data-original', 'bbb', 'src']:
attr_match = re.search(r'{}=["\']([^"\']+)["\']'.format(attr), tag_str)
if attr_match:
raw = attr_match.group(1)
if attr == 'bbb':
raw = self._try_decode_b64(raw)
if 'loading.gif' not in raw:
pic = raw
break
else:
pic = raw # 占位图暂时保留
if not pic or 'loading.gif' in pic:
bbb_match = re.search(r'bbb="([^"]+)"', art)
if bbb_match:
pic = self._try_decode_b64(bbb_match.group(1))
if pic and not pic.startswith('http'):
pic = urljoin(self.host, pic)
items.append({
'vod_id': vid,
'vod_name': title or '未知标题',
'vod_pic': pic,
'vod_remarks': ''
})
self._log(f'解析到 {len(items)} 个视频项,首项封面: {items[0]["vod_pic"] if items else ""}')
return items
def _get_video_list(self, tid, page):
url = f'{self.host}/type/{tid}/hot/{page}.html'
self._log(f'请求列表页: {url}')
raw_html = self._fetch(url, referer=f'{self.host}/type/{tid}/hot/1.html')
if not raw_html:
return []
real_html = self._extract_real_html(raw_html)
if real_html and real_html != raw_html:
html = real_html
else:
html = raw_html
return self._parse_list(html)
# ==================== 详情解析(解码HTML + 只保留媒体链接 + 外链解析) ====================
def _fetch_detail(self, vid):
url = f'{self.host}/{vid}.html'
self._log(f'尝试详情页: {url}')
raw_html = self._fetch(url, referer=self.host + '/')
if not raw_html:
alt_url = f'{self.host}/play/{vid}.html'
self._log(f'尝试备用路径: {alt_url}')
raw_html = self._fetch(alt_url, referer=self.host + '/')
if not raw_html:
return {'vod_id': vid, 'vod_name': vid, 'vod_pic': '', 'vod_play_from': '', 'vod_play_url': ''}
# 解码 html_b
html = self._extract_real_html(raw_html)
if not html:
html = raw_html
self._log('详情页HTML解码完成' if html != raw_html else '详情页未加密或解码失败')
detail = self._parse_detail(html, vid, url)
if not detail.get('vod_play_url'):
detail['vod_play_from'] = ''
detail['vod_play_url'] = ''
return detail
def _parse_detail(self, html, vid, base_url):
# 标题
title = ''
m = re.search(r'<h1[^>]*>(.*?)</h1>', html, re.S)
if m:
title = re.sub(r'<[^>]+>', '', m.group(1)).strip()
if not title:
m = re.search(r'<title>([^<]+)</title>', html)
if m:
title = m.group(1).strip()
# 封面(bbb 解码)
cover = ''
m = re.search(r'<meta[^>]*property="og:image"[^>]*content="([^"]+)"', html)
if m: cover = m.group(1)
if not cover:
m = re.search(r'<img[^>]*class="[^"]*(?:thumb|poster)[^"]*"[^>]*src="([^"]+)"', html)
if m: cover = m.group(1)
if not cover:
m = re.search(r'<img[^>]*data-(?:src|original)=["\']([^"\']+)["\']', html)
if m: cover = self._try_decode_b64(m.group(1))
if not cover:
m = re.search(r'<img[^>]+bbb=["\']([^"\']+)["\']', html)
if m: cover = self._try_decode_b64(m.group(1))
if not cover:
m = re.search(r'<img[^>]+src=["\']([^"\']+)["\']', html)
if m: cover = m.group(1)
if cover and not cover.startswith('http'):
cover = urljoin(base_url, cover)
# 收集所有可能的播放地址:直链 + iframe 外链
media_urls = [] # 直接播放的媒体链接
iframe_urls = [] # 需要二次解析的网页链接
seen_media = set()
seen_iframe = set()
def clean_url(u):
"""去除 URL 末尾等号和多余字符"""
if u:
u = u.strip().rstrip('=')
return u
# --- 策略1:页面内直接出现的媒体链接 ---
all_links = set(re.findall(r'(https?://[^\s"\'<>]+)', html))
for link in all_links:
link = clean_url(link)
if any(ext in link for ext in ['.m3u8', '.mp4', '.flv', '.ts']):
if link not in seen_media:
seen_media.add(link)
media_urls.append(link)
# --- 策略2iframe 深度解析 ---
iframe_srcs = set(re.findall(r'<iframe[^>]+(?:src|data-src)=["\']([^"\']+)["\']', html))
for src in iframe_srcs:
full = src if src.startswith('http') else urljoin(base_url, src)
self._log(f'发现 iframe: {full}')
# 尝试加载 iframe 内容
iframe_html = self._fetch(full, referer=base_url)
if iframe_html:
# 提取所有绝对链接并筛选媒体
iframe_links = set(re.findall(r'(https?://[^\s"\'<>]+)', iframe_html))
for link in iframe_links:
link = clean_url(link)
if any(ext in link for ext in ['.m3u8', '.mp4', '.flv', '.ts']):
if link not in seen_media:
seen_media.add(link)
media_urls.append(link)
# 提取脚本中的媒体链接
scripts = re.findall(r'<script[^>]*>(.*?)</script>', iframe_html, re.S)
all_script = '\n'.join(scripts)
script_links = set(re.findall(r'''['"](https?://[^'" ]+\.(?:m3u8|mp4)[^'" ]*)['"]''', all_script))
for link in script_links:
link = clean_url(link)
if any(ext in link for ext in ['.m3u8', '.mp4']) and link not in seen_media:
seen_media.add(link)
media_urls.append(link)
# 尝试 Base64 解码
b64_links = re.findall(r'["\']([A-Za-z0-9+/=]{20,})["\']', iframe_html)
for b64_str in b64_links:
decoded = self._try_decode_b64(b64_str)
if decoded.startswith('http') and any(ext in decoded for ext in ['.m3u8', '.mp4']):
link = clean_url(decoded)
if link not in seen_media:
seen_media.add(link)
media_urls.append(link)
else:
# iframe 加载失败,保存外链用于二次解析
if full not in seen_iframe:
seen_iframe.add(full)
iframe_urls.append(full)
# --- 策略3JS 变量/JSON 中的链接 ---
scripts = re.findall(r'<script[^>]*>(.*?)</script>', html, re.S)
all_js = '\n'.join(scripts)
js_patterns = [
r'''url\s*:\s*['"]([^'"]+)['"]''',
r'''file\s*:\s*['"]([^'"]+)['"]''',
r'''src\s*:\s*['"]([^'"]+)['"]''',
r'''video\s*:\s*['"]([^'"]+)['"]''',
r'''player_aaaa\s*=\s*['"]([^'"]+)['"]''',
r'''playurl\s*=\s*['"]([^'"]+)['"]''',
r'''["\'](https?://[^"\']+?\.(?:m3u8|mp4)[^"\']*?)["\']''',
]
for pat in js_patterns:
for match in re.finditer(pat, all_js, re.IGNORECASE):
val = match.group(1)
# 先尝试 Base64 解码
decoded = self._try_decode_b64(val)
if decoded.startswith('http') and any(ext in decoded for ext in ['.m3u8', '.mp4']):
link = clean_url(decoded)
if link not in seen_media:
seen_media.add(link)
media_urls.append(link)
elif val.startswith('http') and any(ext in val for ext in ['.m3u8', '.mp4']):
link = clean_url(val)
if link not in seen_media:
seen_media.add(link)
media_urls.append(link)
# --- 策略4HTML5 标签 ---
for media in set(re.findall(r'<(?:video|source)[^>]+src=["\']([^"\']+)["\']', html)):
link = clean_url(media)
if any(ext in link for ext in ['.m3u8', '.mp4', '.flv', '.ts']) and link not in seen_media:
seen_media.add(link)
media_urls.append(link)
# --- 策略5:自定义 data 属性解码 ---
for attr_val in re.findall(r'data-(?:url|video|src)=["\']([^"\']+)["\']', html):
decoded = self._try_decode_b64(attr_val)
if decoded.startswith('http') and any(ext in decoded for ext in ['.m3u8', '.mp4']):
link = clean_url(decoded)
if link not in seen_media:
seen_media.add(link)
media_urls.append(link)
# 构建最终播放列表
play_list = []
sources = []
if media_urls:
# 有直接媒体链接,全部标记为直链
for u in media_urls:
play_list.append(f'直链${u}')
sources.append('直链')
else:
# 没有媒体链接,使用 iframe 外链作为解析源
if iframe_urls:
for u in iframe_urls:
play_list.append(f'网页解析${u}')
sources.append('网页解析')
else:
# 完全没找到任何链接
self._log('未提取到任何播放地址')
return {
'vod_id': vid,
'vod_name': title or vid,
'vod_pic': cover or '',
'vod_play_from': '',
'vod_play_url': '',
'vod_content': title or '',
}
self._log(f'提取到 {len(play_list)} 个播放地址:')
for idx, p in enumerate(play_list):
self._log(f' [{idx}] {p[:120]}')
return {
'vod_id': vid,
'vod_name': title or vid,
'vod_pic': cover or '',
'vod_play_from': '$$$'.join(sources),
'vod_play_url': '#'.join(play_list),
'vod_content': title or '',
}
def detailContent(self, ids):
try:
vid = str(ids[0] if isinstance(ids, list) else ids)
detail = self._fetch_detail(vid)
return {'list': [detail]}
except Exception as e:
self._log(f'detailContent 异常: {e}')
return {'list': []}
# ==================== 播放器(区分直链和解析链接) ====================
def playerContent(self, flag, id, vipFlags=None):
try:
# 如果是网页解析链接(flag 包含“网页解析”),让 TVBox 用 WebView 解析
if flag and '网页解析' in flag:
full_url = id if id.startswith('http') else urljoin(self.host, id)
return {'parse': 1, 'url': full_url, 'header': ''}
# 处理直链媒体
if id.startswith('http'):
headers = {
'Referer': self.host + '/',
'Origin': self.host,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
if '.m3u8' in id:
# 可选:使用本地代理清洗广告
proxy_url = self._proxy_m3u8_url(id, self.host)
return {'parse': 0, 'url': proxy_url, 'header': json.dumps(headers)}
else:
return {'parse': 0, 'url': id, 'header': json.dumps(headers)}
elif id.startswith('/'):
full = urljoin(self.host, id)
return self.playerContent(flag, full, vipFlags)
else:
return {'parse': 0, 'url': '', 'header': {}}
except Exception as e:
self._log(f'playerContent 异常: {e}')
return {'parse': 0, 'url': '', 'header': {}}
# ==================== m3u8 清洗(保持不变) ====================
def _proxy_m3u8_url(self, url, referer=''):
try:
if hasattr(self, 'getProxyUrl'):
base = self.getProxyUrl()
if '?' not in base:
base += '?do=py'
return base + '&type=m3u8&url=' + quote(url, safe='') + '&referer=' + quote(referer or self.host, safe='')
except:
pass
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:
resp.encoding = 'utf-8'
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:去除广告分片"""
text = (m3u8_text or '').replace('\r', '')
if '#EXT-X-STREAM-INF' in text:
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', 'sponsor', 'pre', 'preroll', '片头', '广告', '/gg/', '_gg', 'gg_', '/adv/']
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 searchContent(self, key, quick=False, pg='1'):
try:
page = int(pg) if pg else 1
urls = [
f'{self.host}/search?keyword={quote(key)}&page={page}',
f'{self.host}/search.php?content={quote(key)}&page={page}',
]
items = []
for url in urls:
html = self._fetch(url, referer=self.host)
if html:
items = self._parse_list(html)
if items:
break
return {
'list': items,
'page': page,
'pagecount': page + 1,
'limit': len(items),
'total': page * len(items)
}
except Exception as e:
self._log(f'searchContent 异常: {e}')
return {'list': [], 'page': int(pg) if pg else 1, 'pagecount': 1, 'limit': 0, 'total': 0}
+162
View File
@@ -0,0 +1,162 @@
# -*- coding: utf-8 -*-
import re
import urllib.parse
import requests
try:
from base.spider import Spider as BaseSpider
except ImportError:
class BaseSpider:
def __init__(self):
return None
class Spider(BaseSpider):
BASE_URL = "https://maomi66.cc"
HEADERS = {
"User-Agent": "Mozilla/5.0 (Linux; Android 12; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Mobile Safari/537.36",
"Referer": "https://maomi66.cc/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
}
def __init__(self):
self.session = requests.Session()
self.session.headers.update(self.HEADERS)
self._class_cache = []
def getName(self):
return "猫咪AV"
def init(self, extend=""):
return None
def isVideoFormat(self, url):
return bool(re.search(r'\.(m3u8|mp4|flv|avi|mkv|mov)(\?|$)', url or '', re.I))
def manualVideoCheck(self):
return True
def homeContent(self, filter):
html = self._get(self.BASE_URL)
classes = self._classes(html)
return {"class": classes, "list": self._parse_list(html), "filters": {}, "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)
html = self._get(self.BASE_URL + "/list/%s-%s.html" % (tid, page))
data = self._parse_list(html)
return {"list": data, "page": page, "pagecount": page + 1 if data else page, "limit": len(data) or 20, "total": (page + 1) * (len(data) or 20)}
def detailContent(self, ids):
vid = ids[0] if isinstance(ids, list) and ids else str(ids)
url = vid if str(vid).startswith("http") else self.BASE_URL + "/video/%s.html" % vid
html = self._get(url)
title = self._clean(self._match(html, r'<h1[^>]*>(.*?)</h1>') or self._match(html, r'<h2[^>]*>(.*?)</h2>') or self._match(html, r'<title[^>]*>(.*?)</title>'))
if not title:
title = "视频%s" % re.sub(r'\D+', '', str(vid))
pic = self._fix(self._match(html, r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)') or self._match(html, r'<video[^>]+poster=["\']([^"\']+)') or self._match(html, r'(?:data-original|data-src|src)=["\']([^"\']+\.(?:jpg|jpeg|png|webp|gif)[^"\']*)'))
play = self._extract_play(html)
tags = []
for x in re.findall(r'<a[^>]+href=["\']/list/\d+-1\.html["\'][^>]*>(.*?)</a>', html, re.S):
t = self._clean(x)
if t and t not in tags:
tags.append(t)
content = self._clean(self._match(html, r'<div[^>]+class=["\'][^"\']*(?:des|intro|content|info)[^"\']*["\'][^>]*>(.*?)</div>')) or title
vod = {
"vod_id": str(vid).split("/")[-1].replace(".html", ""),
"vod_name": title,
"vod_pic": pic,
"type_name": "/".join(tags[:3]),
"vod_year": "",
"vod_area": "",
"vod_remarks": "",
"vod_actor": "",
"vod_director": "",
"vod_content": content,
"vod_play_from": "默认",
"vod_play_url": "播放$%s" % (play or url)
}
return {"list": [vod]}
def searchContent(self, key, quick, pg="1"):
q = urllib.parse.quote(str(key or ""))
page = self._to_int(pg, 1)
html = self._get(self.BASE_URL + "/search.php?content=%s&type=1&page=%s" % (q, page))
data = self._parse_list(html)
if not data:
html = self._get(self.BASE_URL + "/search.php?content=%s&type=1" % q)
data = self._parse_list(html)
return {"list": data, "page": page, "pagecount": page + 1 if data else page, "limit": len(data) or 20, "total": (page + 1) * (len(data) or 20)}
def playerContent(self, flag, id, vipFlags):
url = urllib.parse.unquote(str(id or ""))
if "/video/" in url or re.fullmatch(r'\d+', url):
page = url if url.startswith("http") else self.BASE_URL + "/video/%s.html" % url
play = self._extract_play(self._get(page))
url = play or page
return {"parse": 0, "playUrl": "", "url": self._fix(url), "header": self.HEADERS}
def _classes(self, html):
arr = []
for tid, name in re.findall(r'href=["\']/list/(\d+)-1\.html["\'][^>]*>(.*?)</a>', html or "", re.S):
name = self._clean(name)
if tid and name and not any(x["type_id"] == tid for x in arr):
arr.append({"type_id": tid, "type_name": name})
if not arr:
arr = [
{"type_id": "69829818", "type_name": "国产精品"},
{"type_id": "71188148", "type_name": "国产自拍"},
{"type_id": "43659662", "type_name": "日本精品"},
{"type_id": "37440125", "type_name": "欧美极品"},
{"type_id": "19211697", "type_name": "中文字幕"},
{"type_id": "77777777", "type_name": "动漫精品"}
]
self._class_cache = arr
return arr
def _parse_list(self, html):
out = []
blocks = re.findall(r'<li[\s\S]*?</li>', html or "", re.I)
if not blocks:
blocks = re.findall(r'<a[^>]+href=["\']/video/\d+\.html["\'][\s\S]*?</a>', html or "", re.I)
for item in blocks:
vid = self._match(item, r'href=["\'][^"\']*/video/(\d+)\.html["\']')
if not vid:
continue
name = self._clean(self._match(item, r'<h5[^>]*>\s*<a[^>]*>(.*?)</a>') or self._match(item, r'title=["\']([^"\']+)') or self._match(item, r'alt=["\']([^"\']+)'))
pic = self._fix(self._match(item, r'data-original=["\']([^"\']+)') or self._match(item, r'data-src=["\']([^"\']+)') or self._match(item, r'<img[^>]+src=["\']([^"\']+)'))
remark = self._clean(self._match(item, r'<span[^>]*>(.*?)</span>') or self._match(item, r'<em[^>]*>(.*?)</em>'))
if not name:
name = "视频%s" % vid
vod = {"vod_id": vid, "vod_name": name, "vod_pic": pic, "vod_remarks": remark}
if not any(x["vod_id"] == vid for x in out):
out.append(vod)
return out
def _extract_play(self, html):
play = self._match(html, r'hls\.loadSource\(["\']([^"\']+)["\']\)') or self._match(html, r'video\.src\s*=\s*["\']([^"\']+)["\']') or self._match(html, r'<source[^>]+src=["\']([^"\']+)["\']') or self._match(html, r'["\'](https?://[^"\']+play\.php\?[^"\']+)["\']') or self._match(html, r'["\'](/play\.php\?[^"\']+)["\']')
return self._fix(play)
def _get(self, url):
if not url:
return ""
url = self._fix(url)
headers = dict(self.HEADERS)
headers["Referer"] = self.BASE_URL + "/"
try:
r = self.session.get(url, headers=headers, timeout=12, verify=False)
if not r.encoding or r.encoding.lower() == "iso-8859-1":
r.encoding = r.apparent_encoding or "utf-8"
return r.text
except requests.RequestException:
return ""
def _match(self, text, pattern, default=""):
m = re.search(pattern, text or "", re.S | re.I)
if not m:
return default
return m.group(1) if m.lastindex else m.group(0)
def _clean(self, text):
text = re.sub(r'<script[\s\S]*?</script>|<style[\s\S]*?</style>', ' ', text or '', flags=re.I)
text = re.sub(r'<[^>]+>', ' ', text)
text = text.replace('&nbsp;', ' ').replace('&amp;amp;', '&').replace('&amp;', '&').replace('&#038;', '&').replace('&quot;', '"').replace('&#39;', "'").replace('&lt;', '<').replace('&gt;', '>')
return re.sub(r'\s+', ' ', text).strip()
def _fix(self, url):
url = (url or "").strip().replace("\\/", "/")
if not url:
return ""
if url.startswith("//"):
return "https:" + url
if url.startswith("/"):
return self.BASE_URL + url
return url
def _to_int(self, value, default=1):
try:
return int(value)
except Exception:
return default
+1315
View File
@@ -0,0 +1,1315 @@
# -*- coding: utf-8 -*-
"""
TVBox 本地 Py/Js/HTML/XBPQ 爬虫聚合源(增强版)
==================================================
在原有聚合功能基础上新增:
- 增量合并:保留手工站点,仅替换自动生成的站点
- 交互操作:扫描开关、重新扫描、清除自动站点、忽略/恢复源、撤销变更、分类开关
- 分页支持:分类列表和搜索结果支持分页浏览
- APP 重载:通过 WebHTV 本机管理接口自动重载并校验站点列表
- 子文件夹后缀:根据文件所在子文件夹名称自动添加后缀标识
"""
import os
import json
import base64
import hashlib
import shutil
import threading
import time
import urllib.parse
import urllib.request
from base.spider import Spider
class Spider(Spider):
# ==========================================================================
# 📂 【配置区】
# ==========================================================================
PY_DIR = "/storage/emulated/0/TV/小百合/py"
JS_DIR = "/storage/emulated/0/TV/小百合/js"
HTML_DIR = "/storage/emulated/0/TV/小百合/html"
XBPQ_DIR = "/storage/emulated/0/TV/小百合/XBPQ"
JAR_DIR = "/storage/emulated/0/TV/小百合/jar"
SAVE_PATH = "/storage/emulated/0/TV/小百合/自动接口.json"
LOGO_PATH = "/storage/emulated/0/TV/小百合/jar/头像.gif"
# 🆕 增强功能配置
SETTINGS_PATH = "/storage/emulated/0/TV/小百合/接口配置/扫描配置.json"
BACKUP_DIR = "/storage/emulated/0/TV/小百合/接口配置"
HTML_API = "csp_Nostr"
XBPQ_API = "csp_XBPQ"
# 🔒 锁定在 sites 第 0、1、2 位的配置,无论扫描结果如何始终存在
_LOCKED_SITES = [
{
"key": "FishConfig",
"name": "🍼┆设置┆中心[工具]",
"type": 3,
"api": "csp_FishConfig"
},
{
"key": "Local",
"name": "📁┆文件┆浏览[工具]",
"type": 3,
"api": "csp_Local",
"searchable": 0,
"changeable": 0,
"indexs": 0,
"style": {
"type": "list"
},
"ext": "https://6800.kstore.vip/share.json"
},
{
"key": "自动加载[工具]_py", # ✅ 修改:与 _LOCKED_KEYS 保持一致
"name": "自动加载[工具]",
"type": 3,
"searchable": 1,
"quickSearch": 1,
"filterable": 1,
"api": "./py/工具/自动加载.py"
}
]
_LOCKED_KEYS = {"FishConfig", "Local", "自动加载[工具]_py"}
# 🆕 增量合并前缀与分页大小
GENERATED_KEY_PREFIX = "local_auto_"
PAGE_SIZE = 60
# 🆕 APP 重载配置
AUTO_RELOAD_APP = True
APP_PORT_START = 9978
APP_PORT_END = 9998
APP_REQUEST_TIMEOUT = 0.35
# 🆕 交互操作常量
ACTION_RESCAN = "local_source_rescan"
ACTION_TOGGLE_SCAN = "local_source_toggle_scan"
ACTION_CLEAR_SITES = "local_source_clear_sites"
ACTION_TOGGLE_IGNORE_PFX = "local_source_toggle_ignore:"
ACTION_TOGGLE_TYPE_PFX = "local_source_toggle_type:"
ACTION_RESTORE_BACKUP = "local_source_restore_backup"
ACTION_DELETE_BACKUPS = "local_source_delete_backups"
ACTION_TOGGLE_APP_RELOAD = "local_source_toggle_app_reload"
STATUS_ID = "__local_source_status__"
RESCAN_ID = "__local_source_rescan__"
TOGGLE_SCAN_ID = "__local_source_toggle_scan__"
CLEAR_SITES_ID = "__local_source_clear_sites__"
RESTORE_BACKUP_ID = "__local_source_restore_backup__"
DELETE_BACKUPS_ID = "__local_source_delete_backups__"
# ==========================================================================
def __init__(self):
super().__init__()
self.lock = threading.RLock()
self.inited = False
# 扫描开关与分类开关
self.scan_enabled = True
self.type_enabled = {"PY": True, "JS": True, "HTML": True, "XBPQ": True}
# 忽略源集合(存储 identity,如 "PY|/path/to/file"
self.ignored_sources = set()
# APP 重载相关
self.auto_reload_app = self.AUTO_RELOAD_APP
self.app_server_ports = list(range(self.APP_PORT_START, self.APP_PORT_END + 1))
self.last_app_port = 0
# 扫描结果缓存(扩展版)
self.cache = {
"categories": [],
"file_index": {},
"sources": [],
"ignored": [],
"source_index": {},
"type_counts": {},
"ignored_counts": {},
}
# 扫描状态
self.status = {
"scan_time": "-",
"included": 0,
"ignored": 0,
"manual_sites": 0,
"generated_sites": 0,
"added": 0,
"updated": 0,
"removed": 0,
"unchanged": 0,
"write_state": "尚未扫描",
"written": False,
"app_reload_state": "-",
}
def getName(self):
return "本地Py/Js/HTML/XBPQ聚合源(增强版)"
def init(self, extend):
with self.lock:
if self.inited:
return
self._load_settings()
if self.scan_enabled:
self._scan_all()
self._save_config_json()
self.inited = True
# ==========================================================================
# ⚙ 【设置持久化】
# ==========================================================================
def _load_settings(self):
path = self.SETTINGS_PATH
if not os.path.isfile(path):
return
try:
with open(path, "r", encoding="utf-8") as fp:
data = json.load(fp)
if not isinstance(data, dict):
return
self.scan_enabled = bool(data.get("scan_enabled", True))
te = data.get("type_enabled", {})
if isinstance(te, dict):
for t in self.type_enabled:
if t in te:
self.type_enabled[t] = bool(te[t])
ignored = data.get("ignored_sources", [])
if isinstance(ignored, list):
self.ignored_sources = {str(s).strip() for s in ignored if str(s).strip()}
self.auto_reload_app = bool(data.get("auto_reload_app", self.AUTO_RELOAD_APP))
try:
port = int(data.get("last_app_port", 0) or 0)
self.last_app_port = port if self.APP_PORT_START <= port <= 65535 else 0
except Exception:
self.last_app_port = 0
except Exception:
pass
def _save_settings(self):
data = {
"scan_enabled": bool(self.scan_enabled),
"type_enabled": {t: bool(v) for t, v in self.type_enabled.items()},
"ignored_sources": sorted(self.ignored_sources),
"auto_reload_app": bool(self.auto_reload_app),
"last_app_port": int(self.last_app_port or 0),
}
self._atomic_write_json_file(self.SETTINGS_PATH, data)
# ==========================================================================
# 📡 【APP 重载】通过 WebHTV 本机管理接口重载配置
# ==========================================================================
def _reload_app_vod_config(self):
"""
尝试通过 WebHTV 本机管理接口重载当前点播配置。
返回 (ok: bool, detail: str)
"""
if not self.auto_reload_app:
return False, "App重载已关闭"
last_error = "未发现WebHTV服务"
# 优先尝试上次成功的端口
ports = []
if self.last_app_port:
ports.append(self.last_app_port)
ports.extend(p for p in self.app_server_ports if p not in ports)
for port in ports:
base = "http://127.0.0.1:{}".format(port)
try:
# 第一步:获取当前活跃的点播配置
payload = self._request_json(
base + "/manage/configs", self.APP_REQUEST_TIMEOUT
)
items = payload.get("items", []) if isinstance(payload, dict) else []
current = next(
(
item for item in items
if isinstance(item, dict)
and int(item.get("type", -1)) == 0
and bool(item.get("active", False))
),
None,
)
if not current or not str(current.get("url", "")).strip():
last_error = "WebHTV未返回点播接口"
continue
# 第二步:请求重载当前点播配置
query = urllib.parse.urlencode(
{"type": 0, "url": str(current["url"]).strip()}
)
self._request_json(
base + "/manage/config/use?" + query,
max(1.5, self.APP_REQUEST_TIMEOUT * 4),
)
# 第三步:等待并验证站点列表已更新
verified = False
for _ in range(5):
try:
sites_payload = self._request_json(
base + "/manage/proxy/suggest/sites",
max(0.5, self.APP_REQUEST_TIMEOUT),
)
sites = sites_payload.get("sites", []) if isinstance(sites_payload, dict) else []
if sites:
verified = True
break
except Exception:
pass
time.sleep(0.12)
self._remember_app_port(port)
if verified:
return True, "重载成功"
else:
return True, "已发送重载请求"
except Exception as exc:
last_error = str(exc)
return False, "未连接({})".format(last_error[:20] if last_error else "")
def _request_json(self, url, timeout):
"""发送 HTTP GET 请求并解析 JSON 响应"""
request = urllib.request.Request(
url,
headers={"Accept": "application/json", "Connection": "close"},
)
opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
with opener.open(request, timeout=timeout) as response:
status = getattr(response, "status", response.getcode())
raw = response.read()
if int(status) < 200 or int(status) >= 300:
raise ValueError("HTTP {}".format(status))
data = json.loads(raw.decode("utf-8"))
if not isinstance(data, dict):
raise ValueError("WebHTV返回格式无效")
return data
def _remember_app_port(self, port):
"""缓存上次成功连接的 App 端口"""
if self.last_app_port == int(port):
return
self.last_app_port = int(port)
try:
self._save_settings()
except Exception:
pass
# ==========================================================================
# 💾 【备份管理】
# ==========================================================================
def _has_backup(self):
return os.path.isfile(self._backup_path())
def _backup_path(self):
return os.path.join(self.BACKUP_DIR, "回滚备份.json")
def _create_backup(self):
if not os.path.isfile(self.SAVE_PATH):
return
try:
os.makedirs(self.BACKUP_DIR, exist_ok=True)
bp = self._backup_path()
tmp = bp + ".tmp"
shutil.copy2(self.SAVE_PATH, tmp)
os.replace(tmp, bp)
except Exception:
pass
def _restore_backup(self):
bp = self._backup_path()
if not os.path.isfile(bp):
return False, "暂无可恢复的备份"
try:
self._create_backup()
tmp = self.SAVE_PATH + ".tmp"
shutil.copy2(bp, tmp)
os.replace(tmp, self.SAVE_PATH)
return True, "已恢复到上次备份"
except Exception as e:
return False, "恢复失败: {}".format(e)
def _delete_backups(self):
bp = self._backup_path()
if os.path.isfile(bp):
try:
os.remove(bp)
return True
except Exception:
pass
return False
# ==========================================================================
# 🔍 【扫描核心】手动递归,不依赖 os.walk
# ==========================================================================
def _scan_dir(self, base_dir, ext_list):
"""手动递归扫描目录,返回 [(full_path, file_name_no_ext, ext), ...]"""
results = []
if not base_dir:
return results
if not os.path.exists(base_dir):
try:
os.makedirs(base_dir, exist_ok=True)
except Exception:
return results
if not os.path.isdir(base_dir):
return results
try:
entries = os.listdir(base_dir)
except Exception:
return results
for entry in sorted(entries):
full_path = os.path.join(base_dir, entry)
if entry.startswith("."):
continue
if os.path.isdir(full_path):
sub_results = self._scan_dir(full_path, ext_list)
results.extend(sub_results)
elif os.path.isfile(full_path):
lower_name = entry.lower()
matched_ext = None
for ext in ext_list:
if lower_name.endswith(ext):
matched_ext = ext
break
if matched_ext:
name_no_ext = entry[: -len(matched_ext)]
results.append((full_path, name_no_ext, matched_ext))
return results
def _get_sub_sfx(self, full_path, base_dir):
"""计算子文件夹后缀:取相对路径的第一层子目录名"""
try:
rel = os.path.relpath(full_path, base_dir)
rel_parts = rel.split(os.sep)
subfolder = rel_parts[0] if len(rel_parts) > 1 else ""
except (ValueError, IndexError):
subfolder = ""
if not subfolder:
return ""
# 如果子文件夹名本身已带 [ ] 则原样返回,否则自动包裹
if subfolder.startswith("[") and subfolder.endswith("]"):
return subfolder
return f"[{subfolder}]"
def _scan_all(self):
"""扫描 py、js、html 和 XBPQ 四个目录,区分正常源与忽略源"""
sources = []
ignored_sources = []
self_path = os.path.abspath(__file__) if hasattr(__file__, '__file__') else ""
scan_specs = [
(self.PY_DIR, [".py"], "PY", 0),
(self.JS_DIR, [".js"], "JS", 1),
(self.HTML_DIR, [".html"], "HTML", 2),
(self.XBPQ_DIR, [".py", ".js", ".json"], "XBPQ", 3),
]
for dir_path, ext_list, type_tag, order in scan_specs:
if not self.type_enabled.get(type_tag, True):
continue
files = self._scan_dir(dir_path, ext_list)
for full_path, name, ext in files:
if self_path and os.path.abspath(full_path) == self_path:
continue
identity = type_tag + "|" + full_path
tid = base64.b64encode(identity.encode("utf-8")).decode("utf-8")
# 计算子文件夹后缀
sub_sfx = self._get_sub_sfx(full_path, dir_path)
if type_tag == "HTML":
display_name = f"{name}[网页]{sub_sfx}"
elif type_tag == "XBPQ":
display_name = f"⁽ˣᵇᵖ⁾{name}{sub_sfx}"
else:
display_name = f"{type_tag}{name}{sub_sfx}"
source = {
"type_id": tid,
"type_name": display_name,
"identity": identity,
"_path": full_path,
"_ext": ext.lstrip(".") if type_tag == "XBPQ" else ext,
"_dir": dir_path,
"_type_tag": type_tag,
"_sk": (order, name),
"ignored": identity in self.ignored_sources,
"_sub_sfx": sub_sfx,
}
if source["ignored"]:
ignored_sources.append(source)
else:
sources.append(source)
self.cache["file_index"][tid] = {
"path": full_path,
"ext": source["_ext"],
"dir": dir_path,
"type_tag": type_tag,
"sub_sfx": sub_sfx,
}
sources.sort(key=lambda x: x["_sk"])
ignored_sources.sort(key=lambda x: x["_sk"])
self.cache["sources"] = sources
self.cache["ignored"] = ignored_sources
self.cache["source_index"] = {}
self.cache["type_counts"] = {}
self.cache["ignored_counts"] = {}
for s in sources + ignored_sources:
self.cache["source_index"][s["type_id"]] = s
tag = s["_type_tag"]
if s["ignored"]:
self.cache["ignored_counts"][tag] = self.cache["ignored_counts"].get(tag, 0) + 1
else:
self.cache["type_counts"][tag] = self.cache["type_counts"].get(tag, 0) + 1
self.cache["categories"] = [
{"type_id": s["type_id"], "type_name": s["type_name"]}
for s in sources
]
self.status["included"] = len(sources)
self.status["ignored"] = len(ignored_sources)
all_identities = {s["identity"] for s in sources + ignored_sources}
stale = {i for i in self.ignored_sources if i not in all_identities}
if stale:
self.ignored_sources -= stale
try:
self._save_settings()
except Exception:
pass
# ==========================================================================
# 🆕 【增量合并配置生成】
# ==========================================================================
def _build_api(self, file_info):
"""拼接 api 相对路径"""
f_path = file_info["path"]
base_dir = file_info["dir"]
try:
rel = os.path.relpath(f_path, base_dir)
except ValueError:
rel = os.path.basename(f_path)
dir_name = os.path.basename(base_dir)
return "./" + dir_name + "/" + rel
def _build_spider_value(self):
"""扫描 jar 目录,返回用分号拼接的所有 jar 相对路径"""
jar_dir = self.JAR_DIR
if not jar_dir or not os.path.isdir(jar_dir):
return ""
jar_files = []
save_dir = os.path.dirname(self.SAVE_PATH)
try:
entries = sorted(os.listdir(jar_dir))
except Exception:
return ""
for entry in entries:
if entry.startswith("."):
continue
if entry.lower().endswith(".jar") and os.path.isfile(os.path.join(jar_dir, entry)):
abs_jar = os.path.join(jar_dir, entry)
try:
rel = os.path.relpath(abs_jar, save_dir)
except ValueError:
rel = "jar/" + entry
rel = "./" + rel.replace("\\", "/")
if not rel.startswith("./"):
rel = "./" + rel.lstrip("./")
jar_files.append(rel)
return ";".join(jar_files)
def _get_locked_api_set(self):
"""提取锁定站点中已占用的文件路径集合,用于自动站点去重"""
locked = set()
for site in self._LOCKED_SITES:
for field in ("api", "homePage", "ext"):
val = str(site.get(field, "")).strip()
if val.startswith("./"):
locked.add(val)
return locked
def _is_generated_key(self, key):
"""判断 key 是否为自动生成的站点 key"""
return str(key).startswith(self.GENERATED_KEY_PREFIX)
def _load_existing_config(self):
"""加载现有配置文件"""
if not os.path.isfile(self.SAVE_PATH):
return None
try:
with open(self.SAVE_PATH, "r", encoding="utf-8") as fp:
data = json.load(fp)
return data if isinstance(data, dict) else None
except Exception:
return None
def _generate_auto_sites(self):
"""从当前扫描结果生成自动站点配置列表,排除已被锁定站点占用的文件"""
locked_paths = self._get_locked_api_set()
sites = []
for source in self.cache["sources"]:
file_info = self.cache["file_index"].get(source["type_id"])
if not file_info:
continue
f_path = file_info["path"]
type_tag = file_info.get("type_tag", "PY")
f_base = os.path.basename(f_path)
if "." in f_base:
f_base = f_base.rsplit(".", 1)[0]
api_path = self._build_api(file_info)
sub_sfx = file_info.get("sub_sfx", "")
# 跳过已被锁定站点占用的文件,避免重复
if api_path in locked_paths:
continue
key = self.GENERATED_KEY_PREFIX + type_tag.lower() + "_" + hashlib.sha256(
(type_tag + "|" + f_path).encode("utf-8")
).hexdigest()[:14]
if type_tag == "HTML":
sites.append({
"key": key,
"name": f"{f_base}[网页]{sub_sfx}",
"type": 3,
"api": self.HTML_API,
"homePage": api_path,
})
elif type_tag == "XBPQ":
sites.append({
"key": key,
"name": f"⁽ˣᵇᵖ⁾{f_base}{sub_sfx}",
"type": 3,
"api": self.XBPQ_API,
"ext": api_path,
})
else:
sites.append({
"key": key,
"name": f"{f_base}{sub_sfx}",
"type": 3,
"searchable": 1,
"quickSearch": 1,
"filterable": 1,
"api": api_path,
})
return sites
def _save_config_json(self):
"""增量合并:保留手工站点,仅替换自动生成的站点,写入后尝试重载 App"""
new_auto_sites = self._generate_auto_sites()
existing = self._load_existing_config()
manual_sites = []
old_auto_keys = set()
if existing and isinstance(existing.get("sites"), list):
for site in existing["sites"]:
if not isinstance(site, dict):
continue
k = site.get("key", "")
if k in self._LOCKED_KEYS:
continue
if self._is_generated_key(k):
old_auto_keys.add(k)
else:
manual_sites.append(site)
new_auto_keys = {s.get("key") for s in new_auto_sites}
self.status["added"] = len(new_auto_keys - old_auto_keys)
self.status["removed"] = len(old_auto_keys - new_auto_keys)
self.status["unchanged"] = len(old_auto_keys & new_auto_keys)
self.status["updated"] = 0
self.status["manual_sites"] = len(manual_sites)
self.status["generated_sites"] = len(new_auto_sites)
config = {
"logo": self.LOGO_PATH,
"spider": self._build_spider_value(),
"sites": list(self._LOCKED_SITES) + manual_sites + new_auto_sites,
}
new_content = json.dumps(config, ensure_ascii=False, indent=2)
if existing:
old_content = json.dumps(existing, ensure_ascii=False, indent=2)
if new_content == old_content:
self.status["write_state"] = "配置未变化"
self.status["written"] = True
self.status["scan_time"] = time.strftime("%Y-%m-%d %H:%M:%S")
self.status["app_reload_state"] = "配置未变化"
return
self._create_backup()
save_dir = os.path.dirname(self.SAVE_PATH)
if save_dir and not os.path.exists(save_dir):
try:
os.makedirs(save_dir, exist_ok=True)
except Exception:
pass
try:
tmp = self.SAVE_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as fp:
fp.write(new_content)
fp.flush()
os.fsync(fp.fileno())
os.replace(tmp, self.SAVE_PATH)
self.status["write_state"] = "已写入配置"
self.status["written"] = True
self.status["scan_time"] = time.strftime("%Y-%m-%d %H:%M:%S")
except Exception as e:
self.status["write_state"] = "写入失败: {}".format(e)
self.status["written"] = False
self.status["app_reload_state"] = "写入失败"
return
ok, detail = self._reload_app_vod_config()
self.status["app_reload_state"] = detail
def _clear_auto_sites(self):
"""从配置中移除自动站点,保留手工注入项和锁定项"""
existing = self._load_existing_config()
if not existing or not isinstance(existing.get("sites"), list):
return 0
old_count = len(existing["sites"])
kept = [s for s in existing["sites"]
if isinstance(s, dict) and not self._is_generated_key(s.get("key", ""))]
existing["sites"] = kept
removed = old_count - len(kept)
try:
self._create_backup()
content = json.dumps(existing, ensure_ascii=False, indent=2)
tmp = self.SAVE_PATH + ".tmp"
with open(tmp, "w", encoding="utf-8") as fp:
fp.write(content)
fp.flush()
os.fsync(fp.fileno())
os.replace(tmp, self.SAVE_PATH)
except Exception:
pass
return removed
# ==========================================================================
# 🔧 辅助方法
# ==========================================================================
def _get_file_info(self, tid):
return self.cache["file_index"].get(tid)
def _count_str(self):
c = self.cache["type_counts"]
return (
f"共扫描到 {c.get('PY', 0)} 个PY文件, {c.get('JS', 0)} 个JS文件, "
f"{c.get('HTML', 0)} 个HTML文件, {c.get('XBPQ', 0)} 个XBPQ文件"
)
def _count_jar_str(self):
"""统计 jar 文件数量"""
if not os.path.isdir(self.JAR_DIR):
return "jar 目录不存在"
count = sum(
1 for f in os.listdir(self.JAR_DIR)
if f.lower().endswith(".jar") and os.path.isfile(os.path.join(self.JAR_DIR, f))
)
return f"共扫描到 {count} 个JAR文件"
def _page_number(self, value):
try:
return max(1, int(value))
except Exception:
return 1
def _paged_result(self, items, page, make_vod):
"""通用分页结果生成,make_vod 为 item→vod 字典的转换函数"""
total = len(items)
page_size = max(1, self.PAGE_SIZE)
page_count = max(1, (total + page_size - 1) // page_size)
page = max(1, min(page, page_count))
start = (page - 1) * page_size
page_items = items[start: start + page_size]
return {
"page": page,
"pagecount": page_count,
"limit": page_size,
"total": total,
"list": [make_vod(item) for item in page_items],
}
def _source_to_vod(self, source):
"""将扫描源对象转换为 TVBox vod 条目"""
ignored = source.get("ignored", False)
return {
"vod_id": source["type_id"],
"vod_name": ("" if ignored else "") + source["type_name"],
"vod_pic": "",
"vod_remarks": (
f"{source['_type_tag']} · "
+ ("已忽略·点击恢复" if ignored else "点击忽略")
),
"action": self.ACTION_TOGGLE_IGNORE_PFX + source["type_id"],
}
def _atomic_write_json_file(self, path, data):
"""原子写入普通 JSON 文件"""
directory = os.path.dirname(path)
if directory and not os.path.isdir(directory):
os.makedirs(directory, exist_ok=True)
tmp = path + ".tmp"
try:
with open(tmp, "w", encoding="utf-8") as fp:
json.dump(data, fp, ensure_ascii=False, indent=2)
fp.flush()
os.fsync(fp.fileno())
os.replace(tmp, path)
except Exception:
try:
if os.path.exists(tmp):
os.remove(tmp)
except Exception:
pass
# ==========================================================================
# 📺 【TVBox 标准接口】
# ==========================================================================
def homeContent(self, filter):
classes = [
{"type_id": "all", "type_name": f"全部 ({len(self.cache['sources'])})"}
]
for tag in ("PY", "JS", "XBPQ", "HTML"):
count = self.cache["type_counts"].get(tag, 0)
if count:
classes.append({"type_id": "type:" + tag, "type_name": f"{tag} ({count})"})
if self.cache["ignored"]:
classes.append({"type_id": "ignored", "type_name": f"忽略 ({len(self.cache['ignored'])})"})
classes.append({"type_id": "scan_settings", "type_name": "扫描配置"})
if self._has_backup():
classes.append({"type_id": "backups", "type_name": "历史备份"})
return {"class": classes, "list": self._home_items()}
def homeVod(self):
info = self._count_str() + " | " + self._count_jar_str()
items = self._home_items()
items.insert(0, {
"vod_id": "__debug__",
"vod_name": info,
"vod_pic": "",
"vod_remarks": "统计",
})
return {"list": items}
def _home_items(self):
"""构建首页操作项"""
if not self.scan_enabled:
status_name = "自动扫描已关闭"
elif self.status["written"]:
status_name = "站点已合并"
else:
status_name = "站点未合并"
remarks = f"{self.status['included']}个源·{self.status['write_state']}"
app_state = self.status.get("app_reload_state", "")
if app_state and app_state != "-":
remarks += f"·App:{app_state}"
items = [
{
"vod_id": self.STATUS_ID,
"vod_name": status_name,
"vod_pic": "",
"vod_remarks": remarks,
},
{
"vod_id": self.TOGGLE_SCAN_ID,
"vod_name": "扫描开关:{}".format("" if self.scan_enabled else ""),
"vod_pic": "",
"vod_remarks": "点击切换",
"action": self.ACTION_TOGGLE_SCAN,
},
{
"vod_id": self.RESCAN_ID,
"vod_name": "一键扫描加载",
"vod_pic": "",
"vod_remarks": "扫描·写入·重载App",
"action": self.ACTION_RESCAN,
},
{
"vod_id": self.CLEAR_SITES_ID,
"vod_name": "清除自动站点",
"vod_pic": "",
"vod_remarks": "保留手工项",
"action": self.ACTION_CLEAR_SITES,
},
]
if self._has_backup():
items.append({
"vod_id": self.RESTORE_BACKUP_ID,
"vod_name": "撤销上次变更",
"vod_pic": "",
"vod_remarks": "恢复备份",
"action": self.ACTION_RESTORE_BACKUP,
})
items.append({
"vod_id": self.DELETE_BACKUPS_ID,
"vod_name": "删除历史备份",
"vod_pic": "",
"vod_remarks": "删除备份",
"action": self.ACTION_DELETE_BACKUPS,
})
return items
def categoryContent(self, tid, pg, filter, ext):
page = self._page_number(pg)
# ---- 全部源(分页)----
if tid == "all":
return self._paged_result(self.cache["sources"], page, self._source_to_vod)
# ---- 按类型筛选(分页)----
if str(tid).startswith("type:"):
source_type = str(tid).split(":", 1)[1].upper()
items = [s for s in self.cache["sources"] if s["_type_tag"] == source_type]
return self._paged_result(items, page, self._source_to_vod)
# ---- 忽略源(分页)----
if tid == "ignored":
return self._paged_result(self.cache["ignored"], page, self._source_to_vod)
# ---- 扫描配置 ----
if tid == "scan_settings":
items = self._scan_setting_items()
return self._paged_result(items, page, lambda s: s)
# ---- 历史备份 ----
if tid == "backups":
items = self._backup_items()
return self._paged_result(items, page, lambda s: s)
# ---- 原始逐文件行为(向后兼容)----
return self._category_content_single(tid)
def _category_content_single(self, tid):
"""原始的逐文件分类内容(单个条目)"""
file_info = self._get_file_info(tid)
if not file_info:
return {"list": []}
f_path = file_info["path"]
if not os.path.exists(f_path):
return {"list": []}
f_base = os.path.basename(f_path)
if "." in f_base:
f_base = f_base.rsplit(".", 1)[0]
ext_name = file_info["ext"]
type_tag = file_info.get("type_tag", "PY")
sub_sfx = file_info.get("sub_sfx", "")
v_id = base64.b64encode(
(type_tag + "|" + f_path).encode("utf-8")
).decode("utf-8")
if type_tag == "HTML":
vod_name = f"{f_base}[网页]{sub_sfx}"
vod_remarks = "[网页]"
elif type_tag == "XBPQ":
vod_name = f"⁽ˣᵇᵖ⁾{f_base}{sub_sfx}"
vod_remarks = "[XBPQ]"
else:
vod_name = f"{f_base}{sub_sfx}"
vod_remarks = "[" + ext_name.upper() + "]"
return {
"page": 1, "pagecount": 1, "limit": 1, "total": 1,
"list": [{
"vod_id": v_id,
"vod_name": vod_name,
"vod_pic": "",
"vod_remarks": vod_remarks,
}]
}
def _scan_setting_items(self):
"""扫描配置条目列表(分类开关 + App 重载开关)"""
items = []
type_labels = {"PY": "PY源", "JS": "JS源", "XBPQ": "XBPQ源", "HTML": "HTML源"}
for tag in ("PY", "JS", "XBPQ", "HTML"):
enabled = self.type_enabled.get(tag, True)
items.append({
"vod_id": f"setting_type_{tag.lower()}",
"vod_name": f"{'' if enabled else ''} {type_labels[tag]}",
"vod_pic": "",
"vod_remarks": "" if enabled else "",
"action": self.ACTION_TOGGLE_TYPE_PFX + tag,
})
# App 自动重载开关
reload_on = self.auto_reload_app
port_info = f"{self.APP_PORT_START}-{self.APP_PORT_END}"
last_port = str(self.last_app_port) if self.last_app_port else ""
remarks = ("" if reload_on else "") + f"·{port_info}"
if last_port:
remarks += f"·上次{last_port}"
items.append({
"vod_id": "setting_app_reload",
"vod_name": f"{'' if reload_on else ''} 重载App",
"vod_pic": "",
"vod_remarks": remarks,
"action": self.ACTION_TOGGLE_APP_RELOAD,
})
return items
def _backup_items(self):
"""历史备份条目列表"""
items = []
bp = self._backup_path()
if os.path.isfile(bp):
try:
modified = time.strftime(
"%Y-%m-%d %H:%M:%S",
time.localtime(os.path.getmtime(bp))
)
items.append({
"vod_id": "backup_latest",
"vod_name": f"恢复 {modified}",
"vod_pic": "",
"vod_remarks": "点击恢复此备份",
"action": self.ACTION_RESTORE_BACKUP,
})
except Exception:
pass
items.append({
"vod_id": "delete_backups",
"vod_name": "删除历史备份",
"vod_pic": "",
"vod_remarks": "删除所有备份",
"action": self.ACTION_DELETE_BACKUPS,
})
return items
def detailContent(self, array):
try:
v_id_raw = str(array[0]) if isinstance(array, (list, tuple)) and array else str(array or "")
# ---- 调试 / 状态信息 ----
if v_id_raw in ("__debug__", self.STATUS_ID):
return {"list": [self._status_detail()]}
# ---- 源文件详情(原始行为)----
v_id_padded = v_id_raw + "=" * ((4 - len(v_id_raw) % 4) % 4)
raw = base64.b64decode(v_id_padded).decode("utf-8", errors="ignore")
if "|" in raw:
type_tag, f_path = raw.split("|", 1)
else:
type_tag, f_path = "PY", raw
if not os.path.exists(f_path):
return {"list": [{"vod_name": "文件不存在", "vod_content": "路径: " + f_path}]}
f_base = os.path.basename(f_path)
if "." in f_base:
f_base = f_base.rsplit(".", 1)[0]
ext_name = f_path.rsplit(".", 1)[-1] if "." in f_path else "unknown"
file_info = self.cache["file_index"].get(v_id_raw)
api_path = self._build_api(file_info) if file_info else f_path
sub_sfx = file_info.get("sub_sfx", "") if file_info else ""
if type_tag == "HTML":
site_info = {
"key": f_base, "name": f"{f_base}[网页]{sub_sfx}", "type": 3,
"api": self.HTML_API, "homePage": api_path,
}
display_name = f"{f_base}[网页]{sub_sfx}"
elif type_tag == "XBPQ":
site_info = {
"key": f_base, "name": f"⁽ˣᵇᵖ⁾{f_base}{sub_sfx}", "type": 3,
"api": self.XBPQ_API, "ext": api_path,
}
display_name = f"⁽ˣᵇᵖ⁾{f_base}{sub_sfx}"
else:
site_info = {
"key": f_base + "_" + ext_name, "name": f"{f_base}{sub_sfx}", "type": 3,
"searchable": 1, "quickSearch": 1, "filterable": 1, "api": api_path,
}
display_name = "[" + ext_name.upper() + "] " + f_base + sub_sfx
info_text = json.dumps(site_info, ensure_ascii=False, indent=2)
return {"list": [{
"vod_name": display_name,
"vod_pic": "",
"vod_play_from": "配置信息",
"vod_play_url": "查看配置$" + f_path,
"vod_content": (
"配置文件: " + self.SAVE_PATH + "\n\n"
"站点类型: " + type_tag + " | 后缀: ." + ext_name + "\n\n"
"站点配置:\n" + info_text + "\n\n"
"文件路径: " + f_path
),
}]}
except Exception as e:
return {"list": [{"vod_name": "解析错误", "vod_content": str(e)}]}
def _status_detail(self):
"""生成详细扫描状态信息"""
c = self.cache["type_counts"]
ic = self.cache["ignored_counts"]
types_info = " ".join(
f"{t}:{c.get(t, 0)}/{ic.get(t, 0)}"
for t in ("PY", "JS", "XBPQ", "HTML")
)
content = (
"自动扫描: {scan_enabled}\n"
"扫描时间: {scan_time}\n"
"分类开关: {types}\n\n"
"有效源: {included}\n"
"忽略源: {ignored}\n\n"
"保留手工站点: {manual}\n"
"自动注入站点: {generated}\n"
"变更预览: +{added} -{removed} ={unchanged}\n"
"写入状态: {state}\n\n"
"App重载: {app_reload}\n"
"App自动重载: {auto_reload_enabled}\n"
"端口范围: {port_start}-{port_end}\n"
"上次连接: {last_port}\n\n"
"{py_info}\n"
"{jar_info}\n\n"
"配置文件: {save}\n"
"设置文件: {settings}\n"
"备份目录: {backup}\n\n"
"已扫描文件列表:\n"
"{file_list}"
).format(
scan_enabled="开启" if self.scan_enabled else "关闭",
scan_time=self.status["scan_time"],
types=types_info,
included=self.status["included"],
ignored=self.status["ignored"],
manual=self.status["manual_sites"],
generated=self.status["generated_sites"],
added=self.status["added"],
removed=self.status["removed"],
unchanged=self.status["unchanged"],
state=self.status["write_state"],
app_reload=self.status.get("app_reload_state", "-"),
auto_reload_enabled="开启" if self.auto_reload_app else "关闭",
port_start=self.APP_PORT_START,
port_end=self.APP_PORT_END,
last_port=self.last_app_port or "",
py_info=self._count_str(),
jar_info=self._count_jar_str(),
save=self.SAVE_PATH,
settings=self.SETTINGS_PATH,
backup=self.BACKUP_DIR,
file_list="\n".join(
f" [{fin.get('type_tag', fin['ext'].upper())}] {fin['path']}"
for fin in self.cache["file_index"].values()
) or "",
)
return {
"vod_id": self.STATUS_ID,
"vod_name": "扫描状态详情",
"vod_pic": "",
"vod_remarks": self.status["write_state"],
"vod_content": content,
}
def searchContent(self, key, quick, pg="1"):
"""搜索已扫描的源(支持分页)"""
page = self._page_number(pg)
keyword = str(key or "").strip().lower()
if not keyword:
return {"list": []}
items = [
s for s in self.cache["sources"]
if keyword in s["type_name"].lower()
or keyword in s["_type_tag"].lower()
or keyword in os.path.basename(s["_path"]).lower()
]
return self._paged_result(items, page, self._source_to_vod)
def playerContent(self, flag, id, vipFlags):
url = id.split("$")[-1] if "$" in id else id
return {"url": url, "header": {}, "parse": 0}
# ==========================================================================
# 🎮 【交互操作】action 方法
# ==========================================================================
def action(self, action):
action = str(action)
# ---- 忽略 / 恢复单个源 ----
if action.startswith(self.ACTION_TOGGLE_IGNORE_PFX):
tid = action[len(self.ACTION_TOGGLE_IGNORE_PFX):]
source = self.cache["source_index"].get(tid)
if not source:
return {"code": 0, "msg": "源不存在,请重新扫描"}
with self.lock:
identity = source["identity"]
now_ignored = identity not in self.ignored_sources
if now_ignored:
self.ignored_sources.add(identity)
else:
self.ignored_sources.discard(identity)
try:
self._save_settings()
if self.scan_enabled:
self._scan_all()
self._save_config_json()
return {
"code": 0,
"msg": f"已忽略:{source['type_name']}" if now_ignored
else f"已恢复:{source['type_name']}",
}
except Exception as exc:
if now_ignored:
self.ignored_sources.discard(identity)
else:
self.ignored_sources.add(identity)
return {"code": 0, "msg": "操作失败:{}".format(exc)}
# ---- 切换分类扫描开关 ----
if action.startswith(self.ACTION_TOGGLE_TYPE_PFX):
tag = action[len(self.ACTION_TOGGLE_TYPE_PFX):].upper()
if tag not in self.type_enabled:
return {"code": 0, "msg": "未知类型:{}".format(tag)}
with self.lock:
self.type_enabled[tag] = not self.type_enabled[tag]
try:
self._save_settings()
return {
"code": 0,
"msg": "{}扫描已{},重扫后生效".format(
tag, "" if self.type_enabled[tag] else ""
),
"list": self._scan_setting_items(),
}
except Exception as exc:
self.type_enabled[tag] = not self.type_enabled[tag]
return {"code": 0, "msg": "保存失败:{}".format(exc)}
# ---- 切换 App 自动重载开关 ----
if action == self.ACTION_TOGGLE_APP_RELOAD:
with self.lock:
self.auto_reload_app = not self.auto_reload_app
try:
self._save_settings()
return {
"code": 0,
"msg": "App自动重载已{}".format(
"开启" if self.auto_reload_app else "关闭"
),
"list": self._scan_setting_items(),
}
except Exception as exc:
self.auto_reload_app = not self.auto_reload_app
return {"code": 0, "msg": "保存失败:{}".format(exc)}
# ---- 切换总扫描开关 ----
if action == self.ACTION_TOGGLE_SCAN:
with self.lock:
prev = self.scan_enabled
self.scan_enabled = not self.scan_enabled
try:
self._save_settings()
if self.scan_enabled:
self._scan_all()
self._save_config_json()
msg = "扫描已开:{}个源,{}".format(
self.status["included"], self.status["write_state"]
)
else:
self.cache["categories"] = []
self.cache["sources"] = []
self.status["write_state"] = "自动扫描已关闭"
msg = "扫描已关,现有配置已保留"
return {"code": 0, "msg": msg}
except Exception as exc:
self.scan_enabled = prev
return {"code": 0, "msg": "操作失败:{}".format(exc)}
# ---- 一键扫描并加载(含 App 重载)----
if action == self.ACTION_RESCAN:
with self.lock:
if self.scan_enabled:
self._scan_all()
self._save_config_json()
app_state = self.status.get("app_reload_state", "")
msg = "已重扫:{}个源,{}".format(
self.status["included"], self.status["write_state"]
)
if app_state and app_state != "-":
msg += "App:{}".format(app_state)
else:
msg = "扫描已关,请先开启"
return {"code": 0, "msg": msg}
# ---- 一键清除自动站点(含 App 重载)----
if action == self.ACTION_CLEAR_SITES:
with self.lock:
removed = self._clear_auto_sites()
self.scan_enabled = False
self._save_settings()
self.cache = {
"categories": [], "file_index": {}, "sources": [], "ignored": [],
"source_index": {}, "type_counts": {}, "ignored_counts": {},
}
self.status["write_state"] = "已清除{}个自动站点".format(removed)
_, detail = self._reload_app_vod_config()
self.status["app_reload_state"] = detail
msg = "已清除{}个自动站点,手工项保留,扫描已关".format(removed)
if detail and "未连接" not in detail:
msg += "App:{}".format(detail)
return {"code": 0, "msg": msg}
# ---- 撤销上次变更(恢复备份 + App 重载)----
if action == self.ACTION_RESTORE_BACKUP:
with self.lock:
ok, msg = self._restore_backup()
if ok and self.scan_enabled:
self._scan_all()
self._save_config_json()
app_state = self.status.get("app_reload_state", "")
if app_state and app_state != "-":
msg += "App:{}".format(app_state)
elif ok:
_, detail = self._reload_app_vod_config()
if detail and "未连接" not in detail:
msg += "App:{}".format(detail)
return {"code": 0, "msg": msg}
# ---- 删除历史备份 ----
if action == self.ACTION_DELETE_BACKUPS:
with self.lock:
if self._delete_backups():
return {"code": 0, "msg": "已删除历史备份"}
return {"code": 0, "msg": "暂无可删除的备份"}
return {"code": 0, "msg": "未知操作:{}".format(action)}
# ==========================================================================
def destroy(self):
return "destroy"
+572
View File
@@ -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'&nbsp;', ' ', 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
View File
@@ -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('&nbsp;', ' ').replace('&amp;', '&').replace('&#038;', '&').replace('"', '"')
return re.sub(r'\s+', ' ', text).strip()
def _to_int(self, value, default=0):
try:
return int(value)
except Exception:
return default