Files
tvboxzt/py/两个BT.py
T
Harold bbdbb0a471 两个BT
新韩剧网
2026-04-24 19:21:45 +08:00

574 lines
23 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.
# coding=utf-8
import base64
import hashlib
import json
import os
import re
import subprocess
import sys
import tempfile
from urllib.parse import quote, urlencode, urljoin
from lxml import html as lxml_html
from base.spider import Spider as BaseSpider
sys.path.append("..")
class Spider(BaseSpider):
def __init__(self):
self.name = "两个BT"
self.host = "https://www.bttwoo.com"
self._wasm_asset_cache = {}
self.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-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": self.host + "/",
}
self.classes = [
{"type_id": "zgjun", "type_name": "国产剧"},
{"type_id": "meiju", "type_name": "美剧"},
{"type_id": "jpsrtv", "type_name": "日韩剧"},
{"type_id": "movie_bt_tags/xiju", "type_name": "喜剧"},
{"type_id": "movie_bt_tags/aiqing", "type_name": "爱情"},
{"type_id": "movie_bt_tags/adt", "type_name": "冒险"},
{"type_id": "movie_bt_tags/at", "type_name": "动作"},
{"type_id": "movie_bt_tags/donghua", "type_name": "动画"},
{"type_id": "movie_bt_tags/qihuan", "type_name": "奇幻"},
{"type_id": "movie_bt_tags/xuanni", "type_name": "悬疑"},
{"type_id": "movie_bt_tags/kehuan", "type_name": "科幻"},
{"type_id": "movie_bt_tags/juqing", "type_name": "剧情"},
{"type_id": "movie_bt_tags/kongbu", "type_name": "恐怖"},
{"type_id": "gf", "type_name": "高分电影"},
]
def init(self, extend=""):
return None
def getName(self):
return self.name
def homeContent(self, filter):
return {"class": self.classes}
def homeVideoContent(self):
return {"list": self._extract_cards(self._request_html(self.host))}
def categoryContent(self, tid, pg, filter, extend):
page = max(1, self._to_int(pg, 1))
items = self._extract_cards(self._request_html(self._build_category_url(tid, page)))
return {"page": page, "limit": len(items), "total": len(items), "list": items}
def searchContent(self, key, quick, pg="1"):
page = max(1, self._to_int(pg, 1))
keyword = self._clean_text(key)
if not keyword:
return {"page": page, "limit": 0, "total": 0, "list": []}
url = self.host + f"/search?q={quote(keyword)}"
if page > 1:
url += f"&page={page}"
items = self._extract_cards(self._request_html(url), keyword=keyword)
return {"page": page, "limit": len(items), "total": len(items), "list": items}
def detailContent(self, ids):
vod_id = self._normalize_vod_id(ids[0] if isinstance(ids, list) and ids else ids)
if not vod_id:
return {"list": []}
html = self._request_html(self._build_detail_url(vod_id))
detail = self._parse_detail(html, vod_id)
return {"list": [detail]} if detail else {"list": []}
def playerContent(self, flag, id, vipFlags):
play_id = str(id or "").strip()
if self._is_media_url(play_id):
return self._build_player_result(play_id, self.host + "/")
meta = self._decode_play_id(play_id)
pid = meta.get("pid") or play_id
play_path = self._extract_play_path(pid)
if str(pid or "").startswith(("http://", "https://")):
play_page_url = str(pid)
elif play_path:
play_page_url = self._abs_url(play_path)
else:
play_page_url = self.host + f"/v_play/{pid}.html"
html = self._request_html(play_page_url, referer=self.host + "/")
media_url = self._extract_media_url(html)
if media_url:
return self._build_player_result(media_url, play_page_url)
iframe_url = self._extract_iframe_url(html)
if iframe_url:
iframe_html = self._request_html(iframe_url, referer=play_page_url)
iframe_media_url = self._extract_media_url(iframe_html)
if iframe_media_url:
return self._build_player_result(iframe_media_url, iframe_url)
if play_path:
self._cache_wasm_assets(html)
dataid = self._extract_play_dataid(html, play_page_url)
secret_key = play_path.rsplit("/", 1)[-1]
userlink = self._extract_userlink(html) or "0"
api_url = self._build_wasm_play_api_url(dataid, secret_key, "1080", userlink)
api_data = self._request_json(api_url, referer=play_page_url)
media_url = self._extract_media_from_play_api(api_data)
if media_url:
return self._build_player_result(media_url, play_page_url)
return self._build_parse_result(play_page_url, play_page_url)
def _request_html(self, url, referer=None):
headers = dict(self.headers)
headers["Referer"] = referer or self.headers["Referer"]
try:
response = self.fetch(url, headers=headers, timeout=10, verify=False)
except Exception:
return ""
if response.status_code != 200:
return ""
return str(response.text or "")
def _build_category_url(self, tid, page):
path = str(tid or "").strip().lstrip("/")
filters = {
"zgjun": {"classify": "2", "tvclasses": "20"},
"meiju": {"classify": "2", "tvclasses": "21"},
"jpsrtv": {"classify": "2", "tvclasses": "22"},
"movie_bt_tags/xiju": {"classify": "1", "types": "5"},
"movie_bt_tags/aiqing": {"classify": "1", "types": "6"},
"movie_bt_tags/adt": {"classify": "1", "types": "18"},
"movie_bt_tags/at": {"classify": "1", "types": "10"},
"movie_bt_tags/donghua": {"classify": "1", "types": "11"},
"movie_bt_tags/qihuan": {"classify": "1", "types": "12"},
"movie_bt_tags/xuanni": {"classify": "1", "types": "2"},
"movie_bt_tags/kehuan": {"classify": "1", "types": "14"},
"movie_bt_tags/juqing": {"classify": "1", "types": "1"},
"movie_bt_tags/kongbu": {"classify": "1", "types": "3"},
"gf": {"classify": "1", "sort_by": "score", "order": "desc"},
}.get(path)
if filters:
params = dict(filters)
if page > 1:
params["page"] = str(page)
return self.host + "/filter?" + urlencode(params)
url = self.host + "/" + path
if page > 1:
url += f"?page={page}"
return url
def _extract_cards(self, html, keyword=None):
root = self._parse_html(html)
if root is None:
return []
results = []
seen = set()
nodes = root.xpath(
"//*[contains(concat(' ', normalize-space(@class), ' '), ' movie-card ')]"
"|//li[.//a[contains(@href,'/movie/')]]"
)
for node in nodes:
href = (
self._first_attr(node, ".//a[contains(@href,'/play/')][1]", "href")
or self._first_attr(node, ".//a[contains(@href,'/movie/')][1]", "href")
)
vod_id = self._extract_card_id(href)
title = (
self._first_text(node, ".//h3//a[1]")
or self._first_text(node, ".//h3[1]")
or self._clean_text(self._first_attr(node, ".//a[@title][1]", "title"))
or self._clean_text(self._first_attr(node, ".//img[@alt][1]", "alt"))
or self._first_text(node, ".//*[contains(@class,'title')][1]")
or self._first_text(node, ".//*[contains(@class,'name')][1]")
)
if not vod_id or not title or vod_id in seen:
continue
if keyword and not self._is_relevant_search_result(title, keyword):
continue
pic = (
self._first_attr(node, ".//img[@data-original][1]", "data-original")
or self._first_attr(node, ".//img[@data-src][1]", "data-src")
or self._first_attr(node, ".//img[@src][1]", "src")
)
remarks = (
self._first_text(node, ".//*[contains(@class,'rating')][1]")
or self._first_text(node, ".//*[contains(@class,'status')][1]")
or self._first_text(node, ".//span[contains(text(),'集')][1]")
or self._first_text(node, ".//span[contains(text(),'HD')][1]")
or self._first_text(node, ".//span[contains(text(),'4k')][1]")
)
seen.add(vod_id)
results.append(
{
"vod_id": vod_id,
"vod_name": title,
"vod_pic": self._abs_url(pic),
"vod_remarks": remarks,
}
)
return results
def _is_relevant_search_result(self, title, keyword):
title_text = self._clean_text(title).lower()
keyword_text = self._clean_text(keyword).lower()
if not title_text or not keyword_text:
return False
if keyword_text in title_text:
return True
if len(keyword_text) <= 2:
return False
key_chars = set(keyword_text.replace(" ", ""))
title_chars = set(title_text.replace(" ", ""))
if not key_chars:
return False
return len(key_chars & title_chars) / float(len(key_chars)) >= 0.6
def _extract_vod_id(self, href):
matched = re.search(r"/movie/(\d+)\.html", str(href or "").strip())
return matched.group(1) if matched else ""
def _extract_play_path(self, href):
raw = str(href or "").strip()
if "/play/" not in raw:
return ""
path = raw[raw.find("/play/") :]
return path.split("?", 1)[0].split("#", 1)[0]
def _extract_card_id(self, href):
return self._extract_play_path(href) or self._extract_vod_id(href)
def _extract_play_pid(self, href):
matched = re.search(r"/v_play/([^.]+)\.html", str(href or "").strip())
return matched.group(1) if matched else ""
def _normalize_vod_id(self, value):
raw = str(value or "").strip()
return self._extract_play_path(raw) or raw
def _build_detail_url(self, vod_id):
raw = str(vod_id or "").strip()
if not raw:
return ""
if raw.startswith(("http://", "https://")):
return raw
play_path = self._extract_play_path(raw)
if play_path:
return self._abs_url(play_path)
if raw.startswith("/movie/"):
return self._abs_url(raw)
return self.host + f"/movie/{raw}.html"
def _parse_detail(self, html, vod_id):
root = self._parse_html(html)
if root is None:
return None
vod_name = (
self._first_text(root, "//*[contains(@class,'movie-poster')]//h1[1]")
or self._first_text(root, "//h1[1]")
or self._first_text(root, "//h2[1]")
or self._extract_title_text(html)
)
vod_pic = (
self._first_attr(root, "//meta[@property='og:image'][1]", "content")
or self._first_attr(root, "//*[contains(@class,'movie-poster')]//img[1]", "src")
or self._first_attr(root, "//img[contains(@class,'poster')][1]", "src")
or self._first_attr(root, "//*[contains(@class,'poster')]//img[1]", "src")
or self._first_attr(root, "//img[1]", "src")
)
vod_content = (
self._first_attr(root, "//meta[@name='description'][1]", "content")
or self._first_text(root, "//*[contains(text(),'剧情简介')]/following::p[1]")
or self._first_text(root, "//*[contains(@class,'intro')][1]")
or self._first_text(root, "//*[contains(@class,'description')][1]")
or self._first_text(root, "//*[contains(@class,'desc')][1]")
)
vod_actor = self._extract_labeled_value(root, "主演") or self._extract_meta_text(root, "主演")
vod_director = self._extract_labeled_value(root, "导演") or self._extract_meta_text(root, "导演")
episodes = []
seen = set()
episode_nodes = root.xpath(
"//*[@x-data[contains(.,'episodeManager')]]//a[contains(@href,'/play/')]"
"|//*[contains(@class,'episode-link') and contains(@href,'/play/')]"
"|//a[contains(@href,'/v_play/')]"
)
for index, node in enumerate(episode_nodes):
href = str(node.get("href") or "").strip()
pid = self._extract_play_path(href) or self._extract_play_pid(href)
name = self._clean_text(node.text_content()) or self._clean_text(node.get("data-episode")) or f"第{index + 1}集"
if not pid or pid in seen:
continue
seen.add(pid)
episodes.append(f"{name}${self._encode_play_id(pid, vod_id, name)}")
if not episodes:
return None
return {
"vod_id": vod_id,
"vod_name": vod_name or "未知标题",
"vod_pic": self._abs_url(vod_pic),
"vod_content": vod_content,
"vod_actor": vod_actor,
"vod_director": vod_director,
"vod_play_from": "两个BT",
"vod_play_url": "#".join(episodes),
}
def _encode_play_id(self, pid, sid, name):
raw = json.dumps(
{"pid": str(pid or ""), "sid": str(sid or ""), "name": str(name or "")},
ensure_ascii=False,
separators=(",", ":"),
)
return base64.b64encode(raw.encode("utf-8")).decode("utf-8")
def _decode_play_id(self, value):
try:
raw = base64.b64decode(str(value or "").encode("utf-8")).decode("utf-8")
data = json.loads(raw)
except Exception:
return {"pid": "", "sid": "", "name": ""}
return {
"pid": str(data.get("pid") or ""),
"sid": str(data.get("sid") or ""),
"name": str(data.get("name") or ""),
}
def _extract_title_text(self, html):
matched = re.search(r"<title>(.*?)</title>", str(html or ""), re.I | re.S)
return self._clean_text(matched.group(1)) if matched else ""
def _extract_meta_text(self, root, label):
text = self._first_text(root, f"//*[contains(text(),'{label}')][1]")
return re.sub(rf"^{label}[:]?", "", text).strip()
def _extract_labeled_value(self, root, label):
if root is None:
return ""
for node in root.xpath(
f"//*[normalize-space(text())='{label}' or contains(text(),'{label}') or contains(text(),'{label}:')]"
):
text = self._clean_text(node.text_content())
inline = re.sub(rf"^{label}[:]?", "", text).strip()
if inline and inline != text:
return inline
sibling = node.getnext()
if sibling is not None:
sibling_text = self._clean_text(sibling.text_content())
if sibling_text:
return sibling_text
return ""
def _extract_userlink(self, html):
matched = re.search(r"userlink:'([^']+)'", str(html or ""))
return str(matched.group(1) or "").strip() if matched else ""
def _extract_play_dataid(self, html, play_page_url):
root = self._parse_html(html)
if root is None:
return ""
play_path = self._extract_play_path(play_page_url)
if play_path:
for node in root.xpath(f"//a[@dataid and contains(@href,'{play_path}')]"):
dataid = str(node.get("dataid") or "").strip()
if dataid:
return dataid
return self._first_attr(root, "//a[@dataid][1]", "dataid")
def _cache_wasm_assets(self, html):
js_rel = self._first_attr(self._parse_html(html), "//*[@id='wasm-cfg'][1]", "data-js")
wasm_rel = self._first_attr(self._parse_html(html), "//*[@id='wasm-cfg'][1]", "data-bg")
if not js_rel or not wasm_rel:
return
cache_key = f"{js_rel}|{wasm_rel}"
cached = self._wasm_asset_cache.get(cache_key)
if cached and os.path.exists(cached.get("js", "")) and os.path.exists(cached.get("wasm", "")):
self._wasm_asset_cache["active"] = cached
return
tmp_dir = tempfile.gettempdir()
js_path = os.path.join(tmp_dir, f"lianggebt_{hashlib.md5(js_rel.encode('utf-8')).hexdigest()}.mjs")
wasm_path = os.path.join(tmp_dir, f"lianggebt_{hashlib.md5(wasm_rel.encode('utf-8')).hexdigest()}.wasm")
if not os.path.exists(js_path):
response = self.fetch(self._abs_url(js_rel), headers=self.headers, timeout=15, verify=False)
if response.status_code == 200:
with open(js_path, "w", encoding="utf-8") as handle:
handle.write(response.text or "")
if not os.path.exists(wasm_path):
response = self.fetch(self._abs_url(wasm_rel), headers=self.headers, timeout=15, verify=False)
if response.status_code == 200:
with open(wasm_path, "wb") as handle:
handle.write(response.content or b"")
active = {"js": js_path, "wasm": wasm_path}
self._wasm_asset_cache[cache_key] = active
self._wasm_asset_cache["active"] = active
def _build_wasm_play_api_url(self, dataid, secret_key, quality, userlink):
active = self._wasm_asset_cache.get("active") or {}
js_path = active.get("js", "")
wasm_path = active.get("wasm", "")
if not dataid or not secret_key or not js_path or not wasm_path:
return ""
if not os.path.exists(js_path) or not os.path.exists(wasm_path):
return ""
script = (
"import { pathToFileURL } from 'node:url';"
"const mod = await import(pathToFileURL(process.argv[1]).href);"
"await mod.default({module_or_path: await (await import('node:fs/promises')).readFile(process.argv[2])});"
"console.log(mod.build_play_url(process.argv[3], process.argv[4], process.argv[5], process.argv[6]));"
)
try:
result = subprocess.run(
[
"node",
"--input-type=module",
"-e",
script,
js_path,
wasm_path,
str(dataid),
str(secret_key),
str(quality or "1080"),
str(userlink or "0"),
],
capture_output=True,
text=True,
timeout=20,
check=False,
)
except Exception:
return ""
if result.returncode != 0:
return ""
return self._abs_url((result.stdout or "").strip())
def _request_json(self, url, referer=None):
target = str(url or "").strip()
if not target:
return {}
headers = dict(self.headers)
headers["Referer"] = referer or self.headers["Referer"]
headers["Accept"] = "application/json,text/plain,*/*"
try:
response = self.fetch(target, headers=headers, timeout=15, verify=False)
except Exception:
return {}
if response.status_code != 200:
return {}
try:
return json.loads(response.text or "{}")
except Exception:
return {}
def _extract_media_from_play_api(self, data):
payload = ((data or {}).get("data") or {}) if isinstance(data, dict) else {}
quality_urls = payload.get("quality_urls") or []
current_quality = self._to_int(payload.get("current_quality"), 0)
ordered = []
if 0 <= current_quality < len(quality_urls):
ordered.append(quality_urls[current_quality])
ordered.extend([item for index, item in enumerate(quality_urls) if index != current_quality])
for item in ordered:
url = str((item or {}).get("url") or "").strip()
if url and url != "1":
return self._abs_url(url)
return ""
def _extract_media_url(self, html):
body = str(html or "")
patterns = [
r'(https?://[^"\'\s<>]+\.(?:m3u8|mp4|flv|avi|mkv|ts)(?:\?[^"\'\s<>]*)?)',
r'"url"\s*:\s*"([^"]+\.(?:m3u8|mp4|flv|avi|mkv|ts)[^"]*)"',
r"'url'\s*:\s*'([^']+\.(?:m3u8|mp4|flv|avi|mkv|ts)[^']*)'",
]
for pattern in patterns:
matched = re.search(pattern, body, re.I)
if matched:
value = matched.group(1) if matched.groups() else matched.group(0)
return self._abs_url(value)
return ""
def _is_media_url(self, value):
return bool(re.search(r"\.(?:m3u8|mp4|flv|avi|mkv|ts)(?:\?|#|$)", str(value or ""), re.I))
def _build_player_result(self, url, referer):
return {
"parse": 0,
"jx": 0,
"playUrl": "",
"url": str(url or ""),
"header": {
"User-Agent": self.headers["User-Agent"],
"Referer": str(referer or self.host + "/"),
"Origin": self.host,
},
}
def _build_parse_result(self, url, referer):
return {
"parse": 1,
"jx": 1,
"playUrl": "",
"url": str(url or ""),
"header": {
"User-Agent": self.headers["User-Agent"],
"Referer": str(referer or self.host + "/"),
"Origin": self.host,
},
}
def _extract_iframe_url(self, html):
matched = re.search(r'<iframe[^>]+src=["\']([^"\']+)["\']', str(html or ""), re.I)
return self._abs_url(matched.group(1)) if matched else ""
def _abs_url(self, value):
raw = str(value or "").strip()
if not raw:
return ""
if raw.startswith(("http://", "https://")):
return raw
if raw.startswith("//"):
return "https:" + raw
return urljoin(self.host + "/", raw.lstrip("/"))
def _parse_html(self, text):
body = str(text or "").strip()
if not body:
return None
try:
return lxml_html.fromstring(body)
except Exception:
return None
def _clean_text(self, text):
return re.sub(r"\s+", " ", str(text or "").replace("\xa0", " ")).strip()
def _first_attr(self, node, xpath, attr):
if node is None:
return ""
for item in node.xpath(xpath):
value = str(item.get(attr) or "").strip()
if value:
return value
return ""
def _first_text(self, node, xpath):
if node is None:
return ""
for item in node.xpath(xpath):
text = self._clean_text(item.text_content())
if text:
return text
return ""
def _to_int(self, value, default=0):
try:
return int(str(value))
except Exception:
return default