Files
tvbox_collect/jaychouqq/yingshi/py3/spider_missavt.py
T
2026-06-29 03:41:14 +00:00

371 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import sys, re, json, requests, html as htmlmod, base64
from base.spider import Spider
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
requests.packages.urllib3.disable_warnings()
class Spider(Spider):
def getName(self):
return "MissAVt"
def init(self, extend=""):
super().init(extend)
self.domains = [
"https://both.hhfvdqsw.cc",
"https://missavt23.com",
"https://missavt.com",
]
self.site_url = self.domains[0]
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 + "/",
}
self.sess = requests.Session()
self.page_size = 24
self._categories = None
# AES 解密参数
self._aes_key = b'f5d965df75336270'
self._aes_iv = b'97b60394abc2fbe1'
# ── 域名切换 ──────────────────────────────────────────────
def _try_domains(self, path):
"""依次尝试多个域名,返回第一个成功的 HTML"""
import urllib.request, urllib.parse, ssl
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
encoded_path = urllib.parse.quote(path, safe='/:?&=#')
for domain in self.domains:
url = domain + encoded_path
req = urllib.request.Request(url, headers={
**self.headers,
"Referer": domain + "/",
"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",
})
try:
with urllib.request.urlopen(req, timeout=12, context=ctx) as resp:
if resp.status == 200:
html = resp.read().decode("utf-8", errors="replace")
if len(html) > 500:
self.site_url = domain
self.headers["Referer"] = domain + "/"
return html
except Exception:
continue
return ""
def _fetch(self, path):
import urllib.request, urllib.parse, ssl
encoded_path = urllib.parse.quote(path, safe='/:?&=#')
url = self.site_url + encoded_path
ctx = ssl.create_default_context()
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
req = urllib.request.Request(url, headers={
**self.headers,
"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",
})
try:
with urllib.request.urlopen(req, timeout=12, context=ctx) as resp:
return resp.read().decode("utf-8", errors="replace")
except Exception:
return self._try_domains(path)
def _decrypt_pic(self, pic_url):
"""解密 AES 加密的缩略图,返回 base64 data URL"""
if not pic_url:
return pic_url
try:
resp = self.sess.get(pic_url, headers=self.headers, timeout=10, verify=False)
if resp.status_code != 200:
return pic_url
encrypted = resp.content
if len(encrypted) < 16:
return pic_url
# AES-CBC 解密
cipher = AES.new(self._aes_key, AES.MODE_CBC, self._aes_iv)
decrypted = cipher.decrypt(encrypted)
decrypted = unpad(decrypted, AES.block_size)
# 转为 base64 data URL
b64 = base64.b64encode(decrypted).decode()
return "data:image/jpeg;base64," + b64
except Exception:
return pic_url
# ── 分类列表 ──────────────────────────────────────────────
def _get_categories(self):
if self._categories:
return self._categories
cats = [
{"type_name": "有码AV", "type_id": "censored"},
{"type_name": "中文字幕", "type_id": "chinese-subtitle"},
{"type_name": "人妻熟女", "type_id": "renqishunv"},
{"type_name": "制服诱惑", "type_id": "zhifuyouhuo"},
{"type_name": "调教SM", "type_id": "tiaojiaoSM"},
{"type_name": "家庭乱伦", "type_id": "jiatingluanlun"},
{"type_name": "国产传媒", "type_id": "domestic-media"},
{"type_name": "麻豆传媒", "type_id": "madou"},
{"type_name": "swag", "type_id": "swag"},
{"type_name": "糖心vlog", "type_id": "sweet-heart-vlog"},
{"type_name": "ed mosaic", "type_id": "ed-mosaic"},
{"type_name": "抖阴", "type_id": "douyin"},
{"type_name": "91制片厂", "type_id": "91-studio"},
{"type_name": "兔子先生", "type_id": "mr-rabbit"},
{"type_name": "杏吧探花", "type_id": "xingbatanhua"},
{"type_name": "无码流出", "type_id": "uncensored-leak"},
{"type_name": "FC2", "type_id": "fc2"},
{"type_name": "东京热", "type_id": "tokyohot"},
{"type_name": "人妻斩", "type_id": "marriedslash"},
{"type_name": "HEYZO", "type_id": "heyzo"},
{"type_name": "无码破解", "type_id": "reducing-mosaic"},
{"type_name": "10musume", "type_id": "10musume"},
{"type_name": "pacopacomama", "type_id": "pacopacomama"},
{"type_name": "xxx-av", "type_id": "xxx-av"},
{"type_name": "Caribbeancompr","type_id": "Caribbeancompr"},
{"type_name": "Caribbeancom", "type_id": "Caribbeancom"},
{"type_name": "一本道", "type_id": "1pondo"},
{"type_name": "素人", "type_id": "amateur"},
{"type_name": "SIRO", "type_id": "SIRO"},
{"type_name": "lulu", "type_id": "luxu"},
{"type_name": "gana", "type_id": "gana"},
{"type_name": "PRESTIGE PREMIUM","type_id": "PRESTIGE-PREMIUM"},
{"type_name": "S-CUTE", "type_id": "S-CUTE"},
{"type_name": "ARA", "type_id": "ARA"},
]
self._categories = cats
return cats
# ── HTML 解析 ─────────────────────────────────────────────
@staticmethod
def _parse_video_items(html_text):
"""从列表页提取视频(列表页 data-url 直接带 m3u8"""
results = []
# 按 video-item 块分割
blocks = re.split(r'<div\s+class="video-item">', html_text)
for block in blocks[1:]: # 跳过第一个(非视频内容)
# m3u8 URL from data-url on .poster div
m3u8 = ""
du = re.search(r'data-url="(https?://[^"]+\.m3u8[^"]*)"', block)
if du:
m3u8 = du.group(1)
# 封面图 + alt 标题
thumb = ""
alt_title = ""
img = re.search(r'<img[^>]*data-src="(https?://[^"]+)"[^>]*alt="([^"]*)"', block, re.DOTALL)
if not img:
img = re.search(r'<img[^>]*alt="([^"]*)"[^>]*data-src="(https?://[^"]+)"', block, re.DOTALL)
if img:
alt_title = img.group(1)
thumb = img.group(2)
if img and not thumb:
thumb = img.group(1)
alt_title = img.group(2)
# 番号/链接
link = re.search(r'href="/watch/([^/]+)/"', block)
vid = link.group(1) if link else ""
# 时长
duration = ""
dur = re.search(r'(\d+:\d+:\d+)', block)
if dur:
duration = dur.group(1)
if vid:
results.append({
"vod_id": vid,
"vod_pic": thumb,
"vod_remarks": duration,
"_m3u8": m3u8,
"_alt_title": alt_title,
})
# 标题提取:优先匹配 line-clamp 的 <a> 标签(分类页)
title_map = {}
for m in re.finditer(
r'<a\s[^>]*class="[^"]*line-clamp[^"]*"[^>]*href="/watch/([^/]+)/"[^>]*>(.*?)</a>',
html_text, re.DOTALL
):
vid = m.group(1)
title = htmlmod.unescape(re.sub(r'<[^>]+>', '', m.group(2)).strip())
if title:
title_map[vid] = title
# 合并标题:优先 line-clamp,其次 alt,最后 vid
for item in results:
vid = item["vod_id"]
if vid in title_map:
item["vod_name"] = title_map[vid]
elif item.get("_alt_title"):
item["vod_name"] = htmlmod.unescape(item["_alt_title"]).strip()
else:
item["vod_name"] = vid.upper()
return results
@staticmethod
def _parse_pagination(html_text):
"""提取分页信息,优先读 data-last-page 属性"""
# 优先:data-last-page="1072"
m = re.search(r'data-last-page="(\d+)"', html_text)
if m:
return int(m.group(1))
# fallback:从 href 推算
pages = re.findall(r'href="[^"]*/(\d+)/?"', html_text)
max_page = 1
for p in pages:
try:
n = int(p)
if 2 <= n <= 9999:
max_page = max(max_page, n)
except ValueError:
pass
next_match = re.search(r'rel="next"\s+href="[^"]*/(\d+)/?"', html_text)
if next_match:
max_page = max(max_page, int(next_match.group(1)) + 1)
return max_page
# ── TVBox 接口 ────────────────────────────────────────────
def homeContent(self, filter):
return {"class": self._get_categories(), "header": self.headers}
def categoryContent(self, tid, pg, filter, extend):
pg = int(pg) if str(pg).isdigit() else 1
path = f"/category/{tid}/" if pg == 1 else f"/category/{tid}/{pg}/"
# 首次用备用域名探测
html_text = self._try_domains(path) if pg == 1 else self._fetch(path)
if not html_text:
return {"list": [], "page": pg, "pagecount": 1, "limit": self.page_size, "total": 0}
items = self._parse_video_items(html_text)
total_page = self._parse_pagination(html_text)
video_list = []
for item in items:
video_list.append({
"vod_id": item["vod_id"],
"vod_name": item["vod_name"],
"vod_pic": self._decrypt_pic(item["vod_pic"]),
"vod_remarks": item["vod_remarks"],
})
return {
"list": video_list,
"page": pg,
"pagecount": total_page,
"limit": self.page_size,
"total": 9999,
"header": self.headers,
}
def detailContent(self, ids):
vid = ids[0] if ids else ""
if not vid:
return {"list": []}
m3u8 = ""
title = ""
thumb = ""
# 优先从 watch 页获取完整信息
html_text = self._fetch(f"/watch/{vid}/")
if html_text:
# 标题
t = re.search(r'<title>([^<]+)</title>', html_text)
if t:
full_title = htmlmod.unescape(t.group(1)).strip()
# 去掉站点后缀
title = re.split(r'\s*[-|]\s*MissAVt\s*$', full_title)[0].strip()
# 封面
og = re.search(r'property="og:image"\s+content="([^"]+)"', html_text)
if og:
thumb = og.group(1)
else:
tn = re.search(r'"thumbnailUrl"\s*:\s*"(https?://[^"]+)"', html_text)
if tn:
thumb = tn.group(1)
# m3u8(从 watch 页可能不直接有,需要 embed)
src = re.search(r'(https?://[^"\'"]+\.m3u8[^"\'"]*)', html_text)
if src:
m3u8 = src.group(1)
# 备用:从 embed 页拿 m3u8 和标题
if not m3u8:
embed_html = self._fetch(f"/embed/{vid}/")
if embed_html:
src = re.search(r'<source\s+src="(https?://[^"]+\.m3u8[^"]*)"', embed_html)
if src:
m3u8 = src.group(1)
if not title:
t = re.search(r'<title>([^<]+)</title>', embed_html)
if t:
full_title = htmlmod.unescape(t.group(1)).strip()
title = re.split(r'\s*[-|]\s*在线观看\s*$', full_title)[0].strip()
if not m3u8:
return {"list": []}
if not title:
title = vid.upper()
return {
"list": [{
"vod_id": vid,
"vod_name": title,
"vod_pic": thumb,
"vod_play_from": "MissAVt",
"vod_play_url": title + "$" + m3u8,
}],
"header": self.headers,
}
def searchContent(self, key, quick, pg=1):
pg = int(pg) if str(pg).isdigit() else 1
path = f"/search/{key}/" if pg == 1 else f"/search/{key}/{pg}/"
html_text = self._try_domains(path) if pg == 1 else self._fetch(path)
if not html_text:
return {"list": [], "page": pg, "pagecount": 1, "limit": self.page_size, "total": 0}
items = self._parse_video_items(html_text)
total_page = self._parse_pagination(html_text)
video_list = [{
"vod_id": item["vod_id"],
"vod_name": item["vod_name"],
"vod_pic": self._decrypt_pic(item["vod_pic"]),
"vod_remarks": item["vod_remarks"],
} for item in items]
return {
"list": video_list,
"page": pg,
"pagecount": total_page,
"limit": self.page_size,
"total": 9999,
"header": self.headers,
}
def playerContent(self, flag, id, vipFlags):
if not id:
return {"parse": 0, "url": "", "header": {}}
play_url = id.split("$")[1] if "$" in id else id
return {
"parse": 0,
"url": play_url,
"header": {
"User-Agent": self.headers["User-Agent"],
"Referer": self.site_url + "/",
},
}
def isVideoFormat(self, url):
return ".m3u8" in url or ".mp4" in url
def manualVideoCheck(self):
return False