短剧网

This commit is contained in:
Harold
2026-04-20 10:33:33 +08:00
parent 15873e87dc
commit 5efe589685
3 changed files with 286 additions and 36 deletions
+33 -27
View File
@@ -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__":
+15 -9
View File
@@ -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"):
+238
View File
@@ -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}