Files
tvbox_collect/jaychouqq/yingshi/py9/hanime2.py
T
2026-07-27 14:39:52 +00:00

566 lines
20 KiB
Python

# -*- coding: utf-8 -*-
# //@name:Hanime1
# //@id:hanime1
# //@version:2
# //wab201 学习研究用
import base64
import json
import re
import time
from urllib.parse import parse_qs, urljoin, urlsplit
import requests
from lxml import html
from base.spider import Spider
class Spider(Spider):
HOST = "https://hanime1.me"
PLAY_PREFIX = "hanime1://play/"
CATEGORIES = (
("all", "全部", ""),
("hentai", "裏番", "裏番"),
("short", "泡麵番", "泡麵番"),
("motion", "Motion Anime", "Motion Anime"),
("cg3d", "3DCG", "3DCG"),
("d25", "2.5D", "2.5D"),
("d2", "2D動畫", "2D動畫"),
("ai", "AI生成", "AI生成"),
("mmd", "MMD", "MMD"),
("cosplay", "Cosplay", "Cosplay"),
)
SORTS = (
"最新上市",
"最新上傳",
"本日排行",
"本週排行",
"本月排行",
"觀看次數",
"讚好比例",
"時長最長",
"他們在看",
)
DURATIONS = (
"",
"1 分鐘 +",
"5 分鐘 +",
"10 分鐘 +",
"20 分鐘 +",
"30 分鐘 +",
"60 分鐘 +",
"0 - 10 分鐘",
"0 - 20 分鐘",
)
VIDEO_RE = re.compile(r"\.(?:mp4|m3u8|mkv|webm)(?:$|[?#])", re.I)
def __init__(self):
self.name = "Hanime1"
self.host = self.HOST
self.timeout = 20
self.retries = 2
self.preferred_quality = 0
self.trust_env = True
self.cookie = ""
self.user_agent = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/138.0.0.0 Safari/537.36"
)
self.backend_parse = False
self.category_mode = False
self.categoryMode = False
self.session = None
def getName(self):
return self.name
def init(self, extend=""):
config = self._config(extend)
self.host = self._host(config.get("host") or self.HOST)
self.timeout = self._bounded_int(config.get("timeout"), 20, 8, 45)
self.retries = self._bounded_int(config.get("retries"), 2, 0, 4)
self.preferred_quality = self._bounded_int(
config.get("preferred_quality"), 0, 0, 4320
)
self.trust_env = self._bool(config.get("trust_env"), True)
self.cookie = str(
config.get("cookie") or config.get("cf_cookie") or ""
).strip()
self.user_agent = str(config.get("user_agent") or self.user_agent).strip()
self._reset_session()
def destroy(self):
if self.session is not None:
try:
self.session.close()
except Exception:
pass
self.session = None
def isVideoFormat(self, url):
return bool(self.VIDEO_RE.search(str(url or "")))
def manualVideoCheck(self):
return False
def homeContent(self, filter):
classes = [
{"type_id": type_id, "type_name": type_name}
for type_id, type_name, _ in self.CATEGORIES
]
filters = {}
sort_values = [{"n": value, "v": value} for value in self.SORTS]
duration_values = [
{"n": value or "全部", "v": value} for value in self.DURATIONS
]
for type_id, _, _ in self.CATEGORIES:
filters[type_id] = [
{"key": "sort", "name": "排序", "value": sort_values},
{"key": "duration", "name": "時長", "value": duration_values},
]
return {"class": classes, "filters": filters}
def homeVideoContent(self):
result = self.categoryContent("all", "1", False, {"sort": "最新上傳"})
return {"list": result.get("list", [])}
def categoryContent(self, tid, pg, filter, extend):
page = self._page(pg)
genre = self._category_genre(tid)
options = self._config(extend)
params = {
"sort": str(options.get("sort") or "最新上傳"),
"page": page,
}
duration = str(options.get("duration") or "").strip()
if genre:
params["genre"] = genre
if duration:
params["duration"] = duration
try:
source, _ = self._request_text("/search", params=params)
return self._parse_listing(source, page)
except Exception as exc:
return self._empty_page(page, "分類讀取失敗: %s" % exc)
def searchContent(self, key, quick, pg="1"):
page = self._page(pg)
keyword = self._clean(key)
if not keyword:
return self._empty_page(page)
try:
source, _ = self._request_text(
"/search", params={"query": keyword, "page": page}
)
return self._parse_listing(source, page)
except Exception as exc:
return self._empty_page(page, "搜尋失敗: %s" % exc)
def detailContent(self, ids):
raw_id = ids[0] if isinstance(ids, (list, tuple)) and ids else ids
video_id = self._video_id(raw_id)
if not video_id:
return {"list": []}
try:
source, page_url = self._request_text(
"/watch", params={"v": video_id}
)
return {"list": [self._parse_detail(source, video_id, page_url)]}
except Exception as exc:
message = "詳情讀取失敗: %s" % exc
return {
"list": [
{
"vod_id": video_id,
"vod_name": "Hanime1 %s" % video_id,
"vod_remarks": message,
"vod_content": message,
"vod_play_from": "Hanime1直鏈",
"vod_play_url": "重試$%s%s/0" % (self.PLAY_PREFIX, video_id),
}
]
}
def playerContent(self, flag, id, vipFlags):
video_id, requested_quality = self._play_id(id)
if not video_id:
return self._player_error("無法識別播放 ID")
try:
source, page_url = self._request_text(
"/watch", params={"v": video_id}
)
sources = self._parse_sources(self._document(source))
selected = self._select_source(sources, requested_quality)
if not selected:
return self._player_error("播放頁沒有可用的直鏈")
return {
"parse": 0,
"jx": 0,
"playUrl": "",
"url": selected[1],
"header": {
"User-Agent": self.user_agent,
"Referer": page_url,
"Origin": self.host,
},
}
except Exception as exc:
return self._player_error("播放解析失敗: %s" % exc)
def _reset_session(self):
self.destroy()
self.session = requests.Session()
self.session.trust_env = self.trust_env
self.session.headers.update(
{
"User-Agent": self.user_agent,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8"
),
"Accept-Language": "zh-TW,zh;q=0.9,en;q=0.7",
"Cache-Control": "no-cache",
}
)
if self.cookie:
self.session.headers["Cookie"] = self.cookie.removeprefix("Cookie:").strip()
def _request_text(self, path, params=None):
if self.session is None:
self._reset_session()
url = urljoin(self.host + "/", str(path or "").lstrip("/"))
last_error = None
for attempt in range(self.retries + 1):
try:
response = self.session.get(
url,
params=params,
headers={"Referer": self.host + "/"},
timeout=self.timeout,
allow_redirects=True,
)
source = self._decode_response(response)
if self._is_challenge(source) or response.status_code in (403, 429, 503):
raise RuntimeError(
"Cloudflare 驗證未通過;請在同一出口的瀏覽器完成驗證,"
"再於 Extend 填入 Cookie 和相同 User-Agent"
)
response.raise_for_status()
return source, response.url
except (requests.RequestException, RuntimeError) as exc:
last_error = exc
if attempt < self.retries:
time.sleep(0.35 * (attempt + 1))
raise RuntimeError(str(last_error or "請求失敗"))
def _parse_listing(self, source, page):
doc = self._document(source)
cards = []
seen = set()
nodes = doc.xpath(
"//a[contains(@href,'/watch') and "
".//div[contains(concat(' ',normalize-space(@class),' '),' search-videos ')]]"
)
if not nodes:
nodes = doc.xpath("//a[contains(@class,'video-link') and contains(@href,'/watch')]")
for node in nodes:
video_id = self._video_id(node.get("href"))
if not video_id or video_id in seen:
continue
seen.add(video_id)
title = self._clean(
node.xpath("string(.//div[contains(@class,'home-rows-videos-title')][1])")
or node.xpath("string(.//div[contains(@class,'title')][1])")
or node.get("title")
)
image = node.xpath("string(.//img[1]/@src)")
duration = self._clean(
node.xpath("string(.//div[contains(@class,'duration')][1])")
)
cards.append(
{
"vod_id": video_id,
"vod_name": title or "Hanime1 %s" % video_id,
"vod_pic": urljoin(self.host + "/", image),
"vod_remarks": duration,
}
)
page_numbers = []
for text_value in doc.xpath("//ul[contains(@class,'pagination')]//li//text()"):
value = self._clean(text_value)
if value.isdigit():
page_numbers.append(int(value))
pagecount = max([page] + page_numbers)
limit = len(cards)
return {
"list": cards,
"page": page,
"pagecount": pagecount,
"limit": limit,
"total": pagecount * limit if limit else 0,
}
def _parse_detail(self, source, video_id, page_url):
doc = self._document(source)
title = self._clean(
doc.xpath("string(//meta[@property='og:title']/@content)")
or doc.xpath("string(//h3[@id='shareBtn-title'])")
or doc.xpath("string(//title)")
)
title = re.sub(r"\s*-\s*Hanime1\.me\s*$", "", title, flags=re.I)
cover = doc.xpath("string(//meta[@property='og:image']/@content)")
if not cover:
cover = doc.xpath("string(//video[@id='player']/@poster)")
content = self._clean(doc.xpath("string(//meta[@name='description']/@content)"))
actor = self._clean(doc.xpath("string(//a[@id='video-artist-name'])"))
genre = self._clean(
doc.xpath("string((//a[contains(@href,'genre=')])[last()])")
)
tag_values = []
for node in doc.xpath("//div[contains(@class,'single-video-tag')]/a"):
value = re.sub(r"\s*\(\d+\)\s*$", "", self._clean(node.text_content()))
if value and value not in tag_values:
tag_values.append(value)
date_match = re.search(
r"\b(20\d{2})-\d{2}-\d{2}\b", self._clean(doc.text_content())
)
sources = self._parse_sources(doc)
play_items = []
for quality, _ in sources:
label = "%sP" % quality if quality else "自動"
play_items.append(
"%s$%s%s/%s" % (label, self.PLAY_PREFIX, video_id, quality or 0)
)
if not play_items:
play_items.append("自動$%s%s/0" % (self.PLAY_PREFIX, video_id))
return {
"vod_id": video_id,
"vod_name": title or "Hanime1 %s" % video_id,
"vod_pic": urljoin(page_url, cover),
"type_name": genre,
"vod_year": date_match.group(1) if date_match else "",
"vod_actor": actor,
"vod_content": content,
"vod_tag": ",".join(tag_values[:24]),
"vod_play_from": "Hanime1直鏈",
"vod_play_url": "#".join(play_items),
}
def _parse_sources(self, doc):
values = {}
for node in doc.xpath("//video//source[@src]"):
url = urljoin(self.host + "/", node.get("src"))
quality = self._quality(node.get("size"), url)
if url.startswith(("http://", "https://")):
values[quality] = url
if not values:
for node in doc.xpath("//link[@rel='preload' and @as='video']/@href"):
url = urljoin(self.host + "/", node)
values[self._quality("", url)] = url
return sorted(values.items(), key=lambda item: item[0], reverse=True)
def _document(self, source):
if isinstance(source, (bytes, bytearray)):
text = self._decode_bytes(bytes(source))
else:
text = str(source or "")
if "\x00" in text[:256]:
try:
text = self._decode_bytes(text.encode("latin-1"))
except (UnicodeEncodeError, UnicodeDecodeError):
text = text.replace("\x00", "")
parser = html.HTMLParser(encoding="utf-8", recover=True)
return html.fromstring(text.encode("utf-8"), parser=parser)
def _decode_response(self, response):
return self._decode_bytes(response.content, response.encoding)
@staticmethod
def _decode_bytes(raw, declared_encoding=""):
if not raw:
return ""
signatures = (
(b"\xff\xfe\x00\x00", "utf-32-le"),
(b"\x00\x00\xfe\xff", "utf-32-be"),
(b"\xff\xfe", "utf-16-le"),
(b"\xfe\xff", "utf-16-be"),
)
for signature, encoding in signatures:
if raw.startswith(signature):
return raw.decode(encoding).lstrip("\ufeff")
sample = raw[:512]
if len(sample) >= 16:
groups = len(sample) // 4
le32_zeros = sum(
sample[index] == 0
for index in range(1, groups * 4)
if index % 4 in (1, 2, 3)
)
be32_zeros = sum(
sample[index] == 0
for index in range(0, groups * 4)
if index % 4 in (0, 1, 2)
)
if le32_zeros >= groups * 2:
return raw.decode("utf-32-le")
if be32_zeros >= groups * 2:
return raw.decode("utf-32-be")
if raw[:4] == b"<\x00\x00\x00":
return raw.decode("utf-32-le")
if raw[:4] == b"\x00\x00\x00<":
return raw.decode("utf-32-be")
if raw[:2] == b"<\x00":
return raw.decode("utf-16-le")
if raw[:2] == b"\x00<":
return raw.decode("utf-16-be")
candidates = [str(declared_encoding or "").strip(), "utf-8-sig", "utf-8"]
for encoding in candidates:
if not encoding:
continue
try:
return raw.decode(encoding)
except (LookupError, UnicodeDecodeError):
continue
return raw.decode("utf-8", errors="replace")
def _select_source(self, sources, requested_quality):
if not sources:
return None
target = requested_quality or self.preferred_quality
if target:
for item in sources:
if item[0] == target:
return item
return min(sources, key=lambda item: abs(item[0] - target))
return sources[0]
def _play_id(self, value):
text = str(value or "").strip()
match = re.match(r"^hanime1://play/(\d+)/(\d+)$", text)
if match:
return match.group(1), int(match.group(2))
video_id = self._video_id(text)
return (video_id, 0) if video_id else ("", 0)
def _video_id(self, value):
text = str(value or "").strip()
if text.startswith("atvp_detail:"):
text = text[len("atvp_detail:") :].strip()
if text.isdigit():
return text
match = re.search(r"hanime1://(?:video|play)/(\d+)", text)
if match:
return match.group(1)
try:
values = parse_qs(urlsplit(text).query).get("v") or []
if values and str(values[0]).isdigit():
return str(values[0])
except Exception:
pass
match = re.search(r"(?:[?&]v=|/watch/)(\d+)", text)
return match.group(1) if match else ""
def _category_genre(self, tid):
value = str(tid or "all")
for type_id, _, genre in self.CATEGORIES:
if value == type_id or value == genre:
return genre
return ""
def _quality(self, value, url):
match = re.search(r"(\d{3,4})", str(value or ""))
if not match:
match = re.search(r"[-_](\d{3,4})p(?:\.|[/?])", str(url or ""), re.I)
return int(match.group(1)) if match else 0
def _is_challenge(self, source):
text = str(source or "")[:80000].lower()
signals = (
"<title>just a moment...</title>",
"id=\"challenge-form\"",
"cf-browser-verification",
"cf-chl-captcha",
"attention required! | cloudflare",
)
return any(signal in text for signal in signals)
def _player_error(self, message):
text = self._clean(message) or "播放失敗"
return {
"parse": 0,
"jx": 0,
"playUrl": "",
"url": "",
"header": {},
"msg": text,
"error": text,
"content": text,
}
def _empty_page(self, page, message=""):
result = {
"list": [],
"page": page,
"pagecount": page,
"limit": 0,
"total": 0,
}
if message:
result["msg"] = message
return result
def _config(self, extend):
if isinstance(extend, dict):
return extend
text = str(extend or "").strip()
if not text:
return {}
candidates = [text]
try:
candidates.append(
base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)).decode("utf-8")
)
except Exception:
pass
for candidate in candidates:
try:
value = json.loads(candidate)
if isinstance(value, dict):
return value
except Exception:
continue
return {"cookie": text} if "=" in text else {}
@staticmethod
def _host(value):
text = str(value or "").strip().rstrip("/")
return text if text.startswith(("http://", "https://")) else Spider.HOST
@staticmethod
def _clean(value):
return " ".join(str(value or "").replace("\xa0", " ").split())
@staticmethod
def _page(value):
try:
return max(1, int(value))
except Exception:
return 1
@staticmethod
def _bounded_int(value, default, low, high):
try:
return min(high, max(low, int(value)))
except Exception:
return default
@staticmethod
def _bool(value, default):
if isinstance(value, bool):
return value
if value is None:
return default
return str(value).strip().lower() in ("1", "true", "yes", "on")