短剧网
This commit is contained in:
+33
-27
@@ -28,16 +28,16 @@ class TestLeTuSpider(unittest.TestCase):
|
||||
self.assertEqual(self.spider.homeVideoContent(), {"list": []})
|
||||
|
||||
def test_encode_and_decode_detail_and_play_ids(self):
|
||||
self.assertEqual(self.spider._encode_vod_id("/detail/demo.html"), "detail/demo")
|
||||
self.assertEqual(self.spider._decode_vod_id("detail/demo"), "https://www.letu.me/detail/demo.html")
|
||||
self.assertEqual(self.spider._encode_play_id("/play/123-1-1.html"), "play/123-1-1")
|
||||
self.assertEqual(self.spider._decode_play_id("play/123-1-1"), "https://www.letu.me/play/123-1-1.html")
|
||||
self.assertEqual(self.spider._encode_vod_id("/vods/305741.html"), "vods/305741")
|
||||
self.assertEqual(self.spider._decode_vod_id("vods/305741"), "https://www.letu.me/vods/305741.html")
|
||||
self.assertEqual(self.spider._encode_play_id("/vod/305741-1-1.html"), "vod/305741-1-1")
|
||||
self.assertEqual(self.spider._decode_play_id("vod/305741-1-1"), "https://www.letu.me/vod/305741-1-1.html")
|
||||
|
||||
def test_parse_cards_extracts_compact_vod_ids(self):
|
||||
html = """
|
||||
<div class="grid container_list">
|
||||
<div class="s6">
|
||||
<a href="/detail/demo.html" title="示例影片"></a>
|
||||
<a href="/vods/305741.html" title="示例影片"></a>
|
||||
<img class="large" data-src="/cover.jpg" />
|
||||
<div class="small-text">更新至1集</div>
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@ class TestLeTuSpider(unittest.TestCase):
|
||||
self.spider._parse_cards(html),
|
||||
[
|
||||
{
|
||||
"vod_id": "detail/demo",
|
||||
"vod_id": "vods/305741",
|
||||
"vod_name": "示例影片",
|
||||
"vod_pic": "https://www.letu.me/cover.jpg",
|
||||
"vod_remarks": "更新至1集",
|
||||
@@ -60,25 +60,31 @@ class TestLeTuSpider(unittest.TestCase):
|
||||
mock_request_html.return_value = """
|
||||
<div class="grid container_list">
|
||||
<div class="s6">
|
||||
<a href="/detail/cat-demo.html" title="分类片"></a>
|
||||
<a href="/vods/90001.html" title="分类片"></a>
|
||||
<img class="large" data-src="/cat.jpg" />
|
||||
<div class="small-text">HD</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
result = self.spider.categoryContent("2", "3", False, {})
|
||||
self.assertEqual(mock_request_html.call_args.args[0], "https://www.letu.me/type/2-3.html")
|
||||
self.assertEqual(result["page"], 3)
|
||||
result = self.spider.categoryContent("2", "5", False, {})
|
||||
self.assertEqual(mock_request_html.call_args.args[0], "https://www.letu.me/type/2-5.html")
|
||||
self.assertEqual(result["page"], 5)
|
||||
self.assertEqual(result["limit"], 1)
|
||||
self.assertNotIn("pagecount", result)
|
||||
self.assertEqual(result["list"][0]["vod_id"], "detail/cat-demo")
|
||||
self.assertEqual(result["list"][0]["vod_id"], "vods/90001")
|
||||
|
||||
@patch.object(Spider, "_request_html")
|
||||
def test_category_content_uses_plain_type_path_for_first_page(self, mock_request_html):
|
||||
mock_request_html.return_value = ""
|
||||
self.spider.categoryContent("1", "1", False, {})
|
||||
self.assertEqual(mock_request_html.call_args.args[0], "https://www.letu.me/type/1.html")
|
||||
|
||||
@patch.object(Spider, "_request_html")
|
||||
def test_search_content_uses_search_url_and_parses_cards(self, mock_request_html):
|
||||
mock_request_html.return_value = """
|
||||
<div class="result-list">
|
||||
<div class="result-item">
|
||||
<a href="/detail/search-demo.html">搜索影片</a>
|
||||
<a href="/vods/70001.html">搜索影片</a>
|
||||
<img class="large" data-src="/search.jpg" />
|
||||
<div class="small-text">全集</div>
|
||||
</div>
|
||||
@@ -89,7 +95,7 @@ class TestLeTuSpider(unittest.TestCase):
|
||||
mock_request_html.call_args.args[0],
|
||||
"https://www.letu.me/vodsearch/-------------.html?wd=%E7%B9%81%E8%8A%B1",
|
||||
)
|
||||
self.assertEqual(result["list"][0]["vod_id"], "detail/search-demo")
|
||||
self.assertEqual(result["list"][0]["vod_id"], "vods/70001")
|
||||
self.assertNotIn("pagecount", result)
|
||||
|
||||
def test_parse_detail_page_extracts_metadata_and_playlists(self):
|
||||
@@ -108,16 +114,16 @@ class TestLeTuSpider(unittest.TestCase):
|
||||
<a>线路B</a>
|
||||
</div>
|
||||
<div class="playno">
|
||||
<a href="/play/123-1-1.html">第1集</a>
|
||||
<a href="/play/123-1-2.html">第2集</a>
|
||||
<a href="/vod/305741-1-1.html">第1集</a>
|
||||
<a href="/vod/305741-1-2.html">第2集</a>
|
||||
</div>
|
||||
<div class="playno">
|
||||
<a href="/play/123-2-1.html">正片</a>
|
||||
<a href="/vod/305741-2-1.html">正片</a>
|
||||
</div>
|
||||
"""
|
||||
result = self.spider._parse_detail_page(html, "detail/demo")
|
||||
result = self.spider._parse_detail_page(html, "vods/305741")
|
||||
vod = result["list"][0]
|
||||
self.assertEqual(vod["vod_id"], "detail/demo")
|
||||
self.assertEqual(vod["vod_id"], "vods/305741")
|
||||
self.assertEqual(vod["vod_name"], "详情标题")
|
||||
self.assertEqual(vod["vod_pic"], "https://www.letu.me/poster.jpg")
|
||||
self.assertEqual(vod["type_name"], "电影")
|
||||
@@ -128,19 +134,19 @@ class TestLeTuSpider(unittest.TestCase):
|
||||
self.assertEqual(vod["vod_play_from"], "线路A$$$线路B")
|
||||
self.assertEqual(
|
||||
vod["vod_play_url"],
|
||||
"第1集$play/123-1-1#第2集$play/123-1-2$$$正片$play/123-2-1",
|
||||
"第1集$vod/305741-1-1#第2集$vod/305741-1-2$$$正片$vod/305741-2-1",
|
||||
)
|
||||
|
||||
@patch.object(Spider, "_request_html")
|
||||
def test_detail_content_decodes_compact_vod_id(self, mock_request_html):
|
||||
mock_request_html.return_value = "<h1>详情标题</h1>"
|
||||
self.spider.detailContent(["detail/demo"])
|
||||
self.assertEqual(mock_request_html.call_args.args[0], "https://www.letu.me/detail/demo.html")
|
||||
self.spider.detailContent(["vods/305741"])
|
||||
self.assertEqual(mock_request_html.call_args.args[0], "https://www.letu.me/vods/305741.html")
|
||||
|
||||
@patch.object(Spider, "_request_html")
|
||||
def test_player_content_returns_direct_json_url(self, mock_request_html):
|
||||
mock_request_html.return_value = '{"code":200,"url":"https://video.example/direct.m3u8"}'
|
||||
result = self.spider.playerContent("线路A", "play/123-1-1", {})
|
||||
result = self.spider.playerContent("线路A", "vod/305741-1-1", {})
|
||||
self.assertEqual(result["parse"], 0)
|
||||
self.assertEqual(result["jx"], 0)
|
||||
self.assertEqual(result["url"], "https://video.example/direct.m3u8")
|
||||
@@ -149,7 +155,7 @@ class TestLeTuSpider(unittest.TestCase):
|
||||
def test_player_content_decodes_rose_base64_url(self, mock_request_html):
|
||||
encoded = quote(base64.b64encode(b"https://video.example/rose.m3u8").decode("utf-8"))
|
||||
mock_request_html.return_value = '{"code":200,"url":"rose_' + encoded + '"}'
|
||||
result = self.spider.playerContent("线路A", "play/123-1-1", {})
|
||||
result = self.spider.playerContent("线路A", "vod/305741-1-1", {})
|
||||
self.assertEqual(result["parse"], 0)
|
||||
self.assertEqual(result["url"], "https://video.example/rose.m3u8")
|
||||
|
||||
@@ -164,18 +170,18 @@ class TestLeTuSpider(unittest.TestCase):
|
||||
'<script>var player_aaaa={"url":"https%3A%2F%2Fvideo.example%2Fenc1.m3u8","encrypt":"1"};</script>',
|
||||
'<script>var player_aaaa={"url":"' + encoded + '","encrypt":"2"};</script>',
|
||||
]
|
||||
encrypt_1 = self.spider.playerContent("线路A", "play/123-1-1", {})
|
||||
encrypt_2 = self.spider.playerContent("线路A", "play/123-1-2", {})
|
||||
encrypt_1 = self.spider.playerContent("线路A", "vod/305741-1-1", {})
|
||||
encrypt_2 = self.spider.playerContent("线路A", "vod/305741-1-2", {})
|
||||
self.assertEqual(encrypt_1["url"], "https://video.example/enc1.m3u8")
|
||||
self.assertEqual(encrypt_2["url"], "https://video.example/enc2.m3u8")
|
||||
|
||||
@patch.object(Spider, "_request_html")
|
||||
def test_player_content_falls_back_to_system_parse(self, mock_request_html):
|
||||
mock_request_html.return_value = "<html></html>"
|
||||
result = self.spider.playerContent("线路A", "play/123-1-1", {})
|
||||
result = self.spider.playerContent("线路A", "vod/305741-1-1", {})
|
||||
self.assertEqual(result["parse"], 1)
|
||||
self.assertEqual(result["jx"], 1)
|
||||
self.assertEqual(result["url"], "https://www.letu.me/play/123-1-1.html")
|
||||
self.assertEqual(result["url"], "https://www.letu.me/vod/305741-1-1.html")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -46,20 +46,20 @@ class Spider(BaseSpider):
|
||||
return urljoin(self.host + "/", str(path or "").strip())
|
||||
|
||||
def _encode_vod_id(self, href):
|
||||
matched = re.search(r"/detail/([^/?#]+)\.html", self._build_url(href))
|
||||
return f"detail/{matched.group(1)}" if matched else ""
|
||||
matched = re.search(r"/(vods|vod|detail)/([^/?#]+)\.html", self._build_url(href))
|
||||
return f"{matched.group(1)}/{matched.group(2)}" if matched else ""
|
||||
|
||||
def _decode_vod_id(self, vod_id):
|
||||
matched = re.search(r"^detail/([^/?#]+)$", str(vod_id or "").strip())
|
||||
return self._build_url(f"/detail/{matched.group(1)}.html") if matched else ""
|
||||
matched = re.search(r"^(vods|vod|detail)/([^/?#]+)$", str(vod_id or "").strip())
|
||||
return self._build_url(f"/{matched.group(1)}/{matched.group(2)}.html") if matched else ""
|
||||
|
||||
def _encode_play_id(self, href):
|
||||
matched = re.search(r"/play/([^/?#]+)\.html", self._build_url(href))
|
||||
return f"play/{matched.group(1)}" if matched else ""
|
||||
matched = re.search(r"/(vod|vodplay|play)/([^/?#]+)\.html", self._build_url(href))
|
||||
return f"{matched.group(1)}/{matched.group(2)}" if matched else ""
|
||||
|
||||
def _decode_play_id(self, play_id):
|
||||
matched = re.search(r"^play/([^/?#]+)$", str(play_id or "").strip())
|
||||
return self._build_url(f"/play/{matched.group(1)}.html") if matched else ""
|
||||
matched = re.search(r"^(vod|vodplay|play)/([^/?#]+)$", str(play_id or "").strip())
|
||||
return self._build_url(f"/{matched.group(1)}/{matched.group(2)}.html") if matched else ""
|
||||
|
||||
def _clean_text(self, text):
|
||||
return re.sub(r"\s+", " ", str(text or "")).strip()
|
||||
@@ -104,8 +104,14 @@ class Spider(BaseSpider):
|
||||
page = int(pg)
|
||||
return {"page": page, "limit": len(items), "total": len(items), "list": items}
|
||||
|
||||
def _build_category_url(self, tid, pg):
|
||||
page = int(pg)
|
||||
if page <= 1:
|
||||
return self._build_url(f"/type/{tid}.html")
|
||||
return self._build_url(f"/type/{tid}-{page}.html")
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
html = self._request_html(self._build_url(f"/type/{tid}-{int(pg)}.html"))
|
||||
html = self._request_html(self._build_category_url(tid, pg))
|
||||
return self._page_result(self._parse_cards(html), pg)
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
# coding=utf-8
|
||||
import re
|
||||
import sys
|
||||
from urllib.parse import quote
|
||||
|
||||
from base.spider import Spider as BaseSpider
|
||||
|
||||
sys.path.append("..")
|
||||
|
||||
|
||||
class Spider(BaseSpider):
|
||||
def __init__(self):
|
||||
self.name = "短剧网"
|
||||
self.host = "https://sm3.cc"
|
||||
self.headers = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X) "
|
||||
"AppleWebKit/605.1.15 (KHTML, like Gecko) Mobile/15E148"
|
||||
),
|
||||
"Referer": self.host + "/",
|
||||
}
|
||||
self.classes = [
|
||||
{"type_id": "1", "type_name": "短剧大全"},
|
||||
{"type_id": "2", "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": []}
|
||||
|
||||
def _stringify(self, value):
|
||||
return "" if value is None else str(value)
|
||||
|
||||
def _build_url(self, path):
|
||||
raw = self._stringify(path).strip()
|
||||
if not raw:
|
||||
return ""
|
||||
if raw.startswith(("http://", "https://")):
|
||||
return raw
|
||||
if raw.startswith("//"):
|
||||
return "https:" + raw
|
||||
if raw.startswith("/"):
|
||||
return self.host + raw
|
||||
return self.host + "/" + raw
|
||||
|
||||
def _clean_text(self, text):
|
||||
return re.sub(r"\s+", " ", str(text or "").replace("\xa0", " ")).strip()
|
||||
|
||||
def _request_html(self, path_or_url):
|
||||
target = path_or_url if str(path_or_url).startswith("http") else self._build_url(path_or_url)
|
||||
response = self.fetch(target, headers=dict(self.headers), timeout=10, verify=False)
|
||||
if response.status_code != 200:
|
||||
return ""
|
||||
return response.text or ""
|
||||
|
||||
def _parse_cards(self, html):
|
||||
root = self.html(html)
|
||||
if root is None:
|
||||
return []
|
||||
items = []
|
||||
seen = set()
|
||||
for card in root.xpath("//li[contains(@class,'col-6')]"):
|
||||
anchors = card.xpath(".//h3[contains(@class,'f-14')]//a[@href]")
|
||||
if not anchors:
|
||||
continue
|
||||
anchor = anchors[0]
|
||||
name = self._clean_text("".join(anchor.xpath(".//text()")))
|
||||
href = self._stringify((anchor.xpath("./@href") or [""])[0]).strip()
|
||||
title_attr = self._stringify((anchor.xpath("./@title") or [""])[0]).strip()
|
||||
if not name or not href:
|
||||
continue
|
||||
url = self._build_url(href)
|
||||
if url in seen:
|
||||
continue
|
||||
seen.add(url)
|
||||
pic = self._build_url(
|
||||
(card.xpath(".//img[contains(@class,'lazy')]/@data-original") or [""])[0]
|
||||
)
|
||||
remarks = ""
|
||||
if title_attr:
|
||||
m = re.search(r"((.+?))", title_attr)
|
||||
if m:
|
||||
remarks = m.group(1)
|
||||
items.append(
|
||||
{
|
||||
"vod_id": url,
|
||||
"vod_name": name,
|
||||
"vod_pic": pic,
|
||||
"vod_remarks": remarks,
|
||||
}
|
||||
)
|
||||
return items
|
||||
|
||||
def _page_result(self, items, pg):
|
||||
page = int(pg)
|
||||
return {
|
||||
"list": items,
|
||||
"page": page,
|
||||
"pagecount": page + 1 if items else page,
|
||||
"limit": len(items) or 20,
|
||||
"total": (page + 1 if items else page) * (len(items) or 20),
|
||||
}
|
||||
|
||||
def categoryContent(self, tid, pg, filter, extend):
|
||||
page = int(pg)
|
||||
url = f"{self.host}/?cate={tid}&page={page}"
|
||||
items = self._parse_cards(self._request_html(url))
|
||||
return self._page_result(items, pg)
|
||||
|
||||
def searchContent(self, key, quick, pg="1"):
|
||||
page = int(pg)
|
||||
keyword = self._clean_text(key)
|
||||
if not keyword:
|
||||
return self._page_result([], pg)
|
||||
url = f"{self.host}/search.php?q={quote(keyword)}&page={page}"
|
||||
items = self._parse_cards(self._request_html(url))
|
||||
return self._page_result(items, pg)
|
||||
|
||||
def _is_netdisk_url(self, value):
|
||||
text = self._stringify(value).strip().lower()
|
||||
return any(
|
||||
token in text
|
||||
for token in [
|
||||
"drive.uc.cn",
|
||||
"pan.quark.cn",
|
||||
"pan.baidu.com",
|
||||
"pan.xunlei.com",
|
||||
"alipan.com",
|
||||
"aliyundrive.com",
|
||||
]
|
||||
)
|
||||
|
||||
def _normalize_disk_name_from_url(self, value):
|
||||
text = self._stringify(value).strip().lower()
|
||||
if "pan.baidu.com" in text:
|
||||
return "baidu"
|
||||
if "pan.quark.cn" in text:
|
||||
return "quark"
|
||||
if "drive.uc.cn" in text:
|
||||
return "uc"
|
||||
if "alipan.com" in text or "aliyundrive.com" in text:
|
||||
return "aliyun"
|
||||
if "pan.xunlei.com" in text:
|
||||
return "xunlei"
|
||||
return ""
|
||||
|
||||
def _disk_priority(self, name):
|
||||
order = {"baidu": 1, "quark": 2, "uc": 3, "aliyun": 4, "xunlei": 5}
|
||||
return order.get(name, 999)
|
||||
|
||||
def _extract_share_links(self, html):
|
||||
root = self.html(html)
|
||||
if root is None:
|
||||
return []
|
||||
links = []
|
||||
seen = set()
|
||||
for anchor in root.xpath("//*[contains(@class,'content')]//a[@href]"):
|
||||
href = self._build_url((anchor.xpath("./@href") or [""])[0])
|
||||
if not href or href in seen:
|
||||
continue
|
||||
seen.add(href)
|
||||
links.append(href)
|
||||
return links
|
||||
|
||||
def _build_play_from_links(self, share_links):
|
||||
if not share_links:
|
||||
return {"vod_play_from": "短剧网", "vod_play_url": ""}
|
||||
grouped = {}
|
||||
order_seen = []
|
||||
for link in share_links:
|
||||
disk_name = self._normalize_disk_name_from_url(link)
|
||||
if not disk_name:
|
||||
continue
|
||||
if disk_name not in grouped:
|
||||
grouped[disk_name] = []
|
||||
order_seen.append(disk_name)
|
||||
title = disk_name
|
||||
urls_in_group = {item.split("$", 1)[1] for item in grouped[disk_name]}
|
||||
if link not in urls_in_group:
|
||||
grouped[disk_name].append(f"{title}${link}")
|
||||
if not grouped:
|
||||
fallback = "#".join(
|
||||
f"{i + 1}$push://{url}" for i, url in enumerate(share_links)
|
||||
)
|
||||
return {"vod_play_from": "短剧网", "vod_play_url": fallback}
|
||||
names = sorted(order_seen, key=self._disk_priority)
|
||||
return {
|
||||
"vod_play_from": "$$$".join(names),
|
||||
"vod_play_url": "$$$".join("#".join(grouped[n]) for n in names),
|
||||
}
|
||||
|
||||
def detailContent(self, ids):
|
||||
result = {"list": []}
|
||||
for raw_id in ids:
|
||||
url = self._build_url(raw_id)
|
||||
if not url:
|
||||
continue
|
||||
html = self._request_html(url)
|
||||
root = self.html(html)
|
||||
if root is None:
|
||||
continue
|
||||
title = self._clean_text("".join(root.xpath("//h1[1]//text()")))
|
||||
if not title:
|
||||
title_text = self._clean_text("".join(root.xpath("//title[1]//text()")))
|
||||
title = title_text.split("(")[0].strip() or "短剧"
|
||||
pic = self._build_url(
|
||||
(root.xpath("(//img[contains(@class,'lazy')]/@data-original)[1]") or [""])[0]
|
||||
or (root.xpath("(//img/@src)[1]") or [""])[0]
|
||||
)
|
||||
share_links = self._extract_share_links(html)
|
||||
play_data = self._build_play_from_links(share_links)
|
||||
result["list"].append(
|
||||
{
|
||||
"vod_id": url,
|
||||
"vod_name": title,
|
||||
"vod_pic": pic,
|
||||
"vod_content": "此为推送网盘规则",
|
||||
"vod_play_from": play_data["vod_play_from"],
|
||||
"vod_play_url": play_data["vod_play_url"],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def playerContent(self, flag, id, vipFlags):
|
||||
raw = self._stringify(id).strip()
|
||||
if raw.startswith("push://"):
|
||||
return {"parse": 0, "url": raw[7:]}
|
||||
if self._is_netdisk_url(raw):
|
||||
return {"parse": 0, "url": raw}
|
||||
return {"parse": 0, "url": raw}
|
||||
Reference in New Issue
Block a user