feat: add gimy spider

This commit is contained in:
Harold
2026-04-19 10:58:39 +08:00
parent 5265dcc8f9
commit 80916ae626
2 changed files with 662 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
import unittest
import base64
from importlib.machinery import SourceFileLoader
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
MODULE = SourceFileLoader("gimy_spider", str(ROOT / "剧迷.py")).load_module()
Spider = MODULE.Spider
class TestGimySpider(unittest.TestCase):
def setUp(self):
Spider._instance = None
self.spider = Spider()
self.spider.init()
def test_home_content_exposes_expected_categories_and_filters(self):
content = self.spider.homeContent(False)
class_ids = [item["type_id"] for item in content["class"]]
self.assertEqual(class_ids, ["1", "2", "4", "29", "34", "13"])
self.assertEqual(content["filters"]["2"][0]["key"], "by")
self.assertEqual(content["filters"]["2"][0]["init"], "time")
def test_parse_cards_extracts_vod_id_title_cover_and_remarks(self):
html = """
<div class="module-item">
<a href="/detail/123.html" title="鬥羅大陸" data-original="/cover.jpg"></a>
<div class="module-item-note">更新至第3集</div>
</div>
"""
items = self.spider._extract_cards(html)
self.assertEqual(
items,
[
{
"vod_id": "123",
"vod_name": "斗罗大陆",
"vod_pic": "https://gimyai.tw/cover.jpg",
"vod_remarks": "更新至第3集",
}
],
)
def test_build_category_url_uses_default_sort_for_page1(self):
self.assertEqual(
self.spider._build_category_url("2", "1", {}),
"https://gimyai.tw/genre/2.html",
)
def test_build_category_url_appends_page_and_sort_when_needed(self):
self.assertEqual(
self.spider._build_category_url("2", "3", {"by": "hits"}),
"https://gimyai.tw/genre/2.html?page=3&by=hits",
)
@patch.object(Spider, "_request_html")
def test_home_video_content_limits_to_24_items(self, mock_request_html):
cards = []
for index in range(1, 27):
cards.append(
f'<div class="module-item"><a href="/detail/{index}.html" title="影片{index}" data-original="/{index}.jpg"></a></div>'
)
mock_request_html.return_value = "".join(cards)
result = self.spider.homeVideoContent()
self.assertEqual(len(result["list"]), 24)
self.assertEqual(result["list"][0]["vod_id"], "1")
@patch.object(Spider, "_request_html")
def test_category_content_builds_page_result(self, mock_request_html):
mock_request_html.return_value = """
<div class="module-item">
<a href="/detail/456.html" title="分类影片" data-original="/cate.jpg"></a>
<div class="module-item-note">HD</div>
</div>
"""
result = self.spider.categoryContent("2", "2", False, {"by": "hits"})
self.assertEqual(mock_request_html.call_args.args[0], "https://gimyai.tw/genre/2.html?page=2&by=hits")
self.assertEqual(result["page"], 2)
self.assertEqual(result["pagecount"], 2)
self.assertEqual(result["list"][0]["vod_name"], "分类影片")
def test_parse_detail_page_extracts_meta_and_playlists(self):
html = """
<meta property="og:image" content="/poster.jpg" />
<h1 class="text-overflow">機動戰士鋼彈</h1>
<ul>
<li><span>狀態:</span>更新至第2集</li>
<li><span>類別:</span>動畫</li>
<li><span>年代:</span>2026</li>
<li><span>國家/地區:</span>日本</li>
<li><span>導演:</span><a>导演甲</a></li>
<li><span>主演:</span><a>演员甲</a><a>演员乙</a></li>
</ul>
<div class="details-content">一段劇情簡介</div>
<div id="playTab">
<a href="#con_playlist_1">Gimy</a>
<a href="#con_playlist_2">雲播</a>
</div>
<div id="con_playlist_1">
<a href="/play/100-1-1.html">第1集</a>
<a href="/play/100-1-2.html">第2集</a>
</div>
<div id="con_playlist_2">
<a href="/play/100-2-1.html">第1集</a>
</div>
"""
result = self.spider._parse_detail_page(html, "100")
vod = result["list"][0]
self.assertEqual(vod["vod_id"], "100")
self.assertEqual(vod["vod_name"], "机动战士钢弹")
self.assertEqual(vod["vod_pic"], "https://gimyai.tw/poster.jpg")
self.assertEqual(vod["vod_content"], "一段剧情简介")
self.assertEqual(vod["vod_remarks"], "更新至第2集")
self.assertEqual(vod["type_name"], "动画")
self.assertEqual(vod["vod_year"], "2026")
self.assertEqual(vod["vod_area"], "日本")
self.assertEqual(vod["vod_actor"], "演员甲,演员乙")
self.assertEqual(vod["vod_director"], "导演甲")
self.assertEqual(vod["vod_play_from"], "Gimy$$$云播")
self.assertEqual(vod["vod_play_url"], "第1集$100-1-1#第2集$100-1-2$$$第1集$100-2-1")
@patch.object(Spider, "_request_html")
def test_detail_content_accepts_numeric_id(self, mock_request_html):
mock_request_html.return_value = """
<h1 class="text-overflow">详情影片</h1>
<div id="playTab"><a href="#con_playlist_1">Gimy</a></div>
<div id="con_playlist_1"><a href="/play/300-1-1.html">正片</a></div>
"""
result = self.spider.detailContent(["300"])
self.assertEqual(mock_request_html.call_args.args[0], "https://gimyai.tw/detail/300.html")
self.assertEqual(result["list"][0]["vod_id"], "300")
def test_normalize_search_keyword_removes_noise_and_converts_traditional(self):
self.assertEqual(self.spider._normalize_search_keyword("鬥羅大陸 線上看"), "斗罗大陆线上看")
def test_refine_search_results_prefers_exact_match_after_simplification(self):
result = self.spider._refine_search_results(
[
{"vod_name": "鬥羅大陸", "vod_id": "1"},
{"vod_name": "斗罗大陆2绝世唐门", "vod_id": "2"},
{"vod_name": "海贼王", "vod_id": "3"},
],
"斗罗大陆",
)
self.assertEqual([item["vod_id"] for item in result], ["1", "2"])
@patch.object(Spider, "_request_html")
def test_search_content_filters_raw_results(self, mock_request_html):
mock_request_html.return_value = """
<div class="box-main-content">
<div class="module-item">
<a href="/detail/11.html" title="鬥羅大陸" data-original="/a.jpg"></a>
</div>
<div class="module-item">
<a href="/detail/12.html" title="海賊王" data-original="/b.jpg"></a>
</div>
</div>
"""
result = self.spider.searchContent("斗罗大陆", False, "1")
self.assertEqual([item["vod_id"] for item in result["list"]], ["11"])
self.assertEqual(result["page"], 1)
self.assertEqual(result["pagecount"], 1)
def test_extract_player_data_reads_json_block(self):
html = '<script>var player_data = {"url":"https://video.example/direct.m3u8","encrypt":"0","from":"gimy"};</script>'
data = self.spider._extract_player_data(html)
self.assertEqual(data["url"], "https://video.example/direct.m3u8")
def test_decode_play_url_supports_encrypt_0_1_2(self):
self.assertEqual(
self.spider._decode_play_url({"url": "https://video.example/raw.m3u8", "encrypt": "0"}),
"https://video.example/raw.m3u8",
)
self.assertEqual(
self.spider._decode_play_url({"url": "https%3A%2F%2Fvideo.example%2Fescape.m3u8", "encrypt": "1"}),
"https://video.example/escape.m3u8",
)
encoded = base64.b64encode("https://video.example/base64.m3u8".encode("utf-8")).decode("utf-8")
self.assertEqual(
self.spider._decode_play_url({"url": encoded, "encrypt": "2"}),
"https://video.example/base64.m3u8",
)
@patch.object(Spider, "_request_html")
def test_player_content_returns_direct_media_url(self, mock_request_html):
mock_request_html.return_value = """
<script>var player_data = {"url":"https://video.example/final.m3u8","encrypt":"0","from":"gimy"};</script>
"""
result = self.spider.playerContent("Gimy", "100-1-1", {})
self.assertEqual(result["parse"], 0)
self.assertEqual(result["url"], "https://video.example/final.m3u8")
self.assertEqual(result["header"]["Referer"], "https://gimyai.tw/play/100-1-1.html")
@patch.object(Spider, "_request_html")
def test_player_content_uses_parse_php_when_available(self, mock_request_html):
mock_request_html.side_effect = [
'<script>var player_data = {"url":"https://middle.example/embed?id=1","encrypt":"0","from":"JD4K"};</script>',
'{"url":"https://video.example/parsed.m3u8"}',
]
result = self.spider.playerContent("Gimy", "100-1-1", {})
self.assertEqual(result["parse"], 0)
self.assertEqual(result["url"], "https://video.example/parsed.m3u8")
self.assertEqual(result["header"]["Origin"], "https://play.gimyai.tw")
@patch.object(Spider, "_request_html")
def test_player_content_falls_back_to_raw_url_when_parse_php_returns_empty(self, mock_request_html):
mock_request_html.side_effect = [
'<script>var player_data = {"url":"https://middle.example/embed?id=1","encrypt":"0","from":"NSYS"};</script>',
'{}',
]
result = self.spider.playerContent("Gimy", "100-1-1", {})
self.assertEqual(result["parse"], 1)
self.assertEqual(result["jx"], 1)
self.assertEqual(result["url"], "https://middle.example/embed?id=1")
@patch.object(Spider, "_request_html")
def test_player_content_falls_back_to_play_page_when_player_data_missing(self, mock_request_html):
mock_request_html.return_value = "<html><body>empty</body></html>"
result = self.spider.playerContent("Gimy", "100-1-1", {})
self.assertEqual(result["parse"], 1)
self.assertEqual(result["jx"], 1)
self.assertEqual(result["url"], "https://gimyai.tw/play/100-1-1.html")
if __name__ == "__main__":
unittest.main()
+434
View File
@@ -0,0 +1,434 @@
# coding=utf-8
import base64
import json
import re
import sys
from urllib.parse import quote, unquote
from base.spider import Spider as BaseSpider
sys.path.append("..")
class Spider(BaseSpider):
def __init__(self):
self.name = "Gimy剧迷"
self.host = "https://gimyai.tw"
self.headers = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/146.0.0.0 Safari/537.36"
),
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "https://gimyai.tw/",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
}
self.categories = [
{"type_id": "1", "type_name": "电影"},
{"type_id": "2", "type_name": "电视剧"},
{"type_id": "4", "type_name": "动漫"},
{"type_id": "29", "type_name": "综艺"},
{"type_id": "34", "type_name": "短剧"},
{"type_id": "13", "type_name": "陆剧"},
]
filter_value = [
{"n": "最新", "v": "time"},
{"n": "人气", "v": "hits"},
{"n": "评分", "v": "score"},
]
self.filters = {
key: [{"key": "by", "name": "排序", "init": "time", "value": filter_value}]
for key in ["1", "2", "4", "29", "34", "13"]
}
self.filter_def = {key: {"by": "time"} for key in self.filters}
self.t2s_map = {
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "",
"": "线",
"": "",
}
def init(self, extend=""):
return None
def getName(self):
return self.name
def homeContent(self, filter):
return {"class": self.categories, "filters": self.filters}
def _build_url(self, href):
raw = str(href or "").strip()
if not raw:
return ""
if raw.startswith(("http://", "https://")):
return raw
if raw.startswith("//"):
return "https:" + raw
return self.host + "/" + raw.lstrip("/")
def _normalize_text(self, text):
value = re.sub(r"<[^>]+>", " ", str(text or ""))
return re.sub(r"\s+", " ", value.replace("&nbsp;", " ")).strip()
def _to_display_text(self, text):
clean = self._normalize_text(text)
for trad, simp in self.t2s_map.items():
clean = clean.replace(trad, simp)
return clean
def _extract_cards(self, html):
root = self.html(html)
if root is None:
return []
items = []
seen = set()
for anchor in root.xpath("//a[contains(@href,'/detail/')]"):
href = (anchor.xpath("./@href") or [""])[0].strip()
matched = re.search(r"/detail/(\d+)\.html", href)
if not matched:
continue
vod_id = matched.group(1)
if vod_id in seen:
continue
title = (
(anchor.xpath("./@title") or [""])[0].strip()
or (anchor.xpath(".//img/@alt") or [""])[0].strip()
or "".join(anchor.xpath(".//text()")).strip()
)
pic = (
(anchor.xpath("./@data-original") or [""])[0].strip()
or (anchor.xpath("./@data-src") or [""])[0].strip()
or (anchor.xpath(".//img/@src") or [""])[0].strip()
)
parent = anchor.getparent()
remarks = ""
if parent is not None:
remarks = "".join(
parent.xpath(".//*[contains(@class,'note') or contains(@class,'text')]//text()")
).strip()
seen.add(vod_id)
items.append(
{
"vod_id": vod_id,
"vod_name": self._to_display_text(title),
"vod_pic": self._build_url(pic),
"vod_remarks": self._to_display_text(remarks),
}
)
return items
def _build_category_url(self, tid, pg, extend):
page = int(pg)
values = dict(self.filter_def.get(str(tid), {"by": "time"}))
if isinstance(extend, dict):
values.update(extend)
by = str(values.get("by", "time"))
url = f"{self.host}/genre/{tid}.html"
if page > 1 or by != "time":
url += f"?page={page}&by={by}"
return url
def _request_html(self, path_or_url, referer=None):
target = path_or_url if str(path_or_url).startswith("http") else self._build_url(path_or_url)
headers = dict(self.headers)
headers["Referer"] = referer or self.headers["Referer"]
response = self.fetch(target, headers=headers, timeout=10)
if response.status_code != 200:
return ""
return response.text or ""
def homeVideoContent(self):
html = self._request_html(self.host)
return {"list": self._extract_cards(html)[:24]}
def categoryContent(self, tid, pg, filter, extend):
url = self._build_category_url(str(tid), str(pg), extend if isinstance(extend, dict) else {})
items = self._extract_cards(self._request_html(url))
page = int(pg)
pagecount = page + 1 if len(items) >= 20 else page
return {
"list": items,
"page": page,
"pagecount": pagecount,
"limit": 20,
"total": page * 20 + len(items),
}
def _pick_info(self, html, label):
matched = re.search(rf"<span[^>]*>{label}[:]</span>(.*?)</li>", str(html or ""), re.S)
return self._to_display_text(matched.group(1) if matched else "")
def _parse_play_sources(self, html):
root = self.html(html)
if root is None:
return []
groups = []
tabs = []
for tab in root.xpath("//*[@id='playTab']//a[starts-with(@href,'#con_playlist_')]"):
tabs.append(
{
"id": (tab.xpath("./@href") or [""])[0].replace("#", ""),
"name": self._to_display_text("".join(tab.xpath(".//text()"))),
}
)
for tab in tabs:
episodes = []
for anchor in root.xpath(f"//*[@id='{tab['id']}']//a[contains(@href,'/play/')]"):
href = (anchor.xpath("./@href") or [""])[0].strip()
matched = re.search(r"/play/(\d+-\d+-\d+)\.html", href)
if not matched:
continue
title = self._to_display_text("".join(anchor.xpath(".//text()"))) or "正片"
episodes.append(f"{title}${matched.group(1)}")
if episodes:
groups.append({"from": tab["name"], "urls": "#".join(episodes)})
return groups
def _parse_detail_page(self, html, vod_id):
root = self.html(html)
if root is None:
return {"list": []}
title = "".join(
root.xpath("//h1[contains(@class,'text-overflow')][1]//text()") or root.xpath("//h1[1]//text()")
).strip()
poster = ((root.xpath("//meta[@property='og:image']/@content") or [""])[0]).strip()
content = "".join(
root.xpath("//*[contains(@class,'details-content') or contains(@class,'detail-sketch')][1]//text()")
).strip()
actors = [self._to_display_text(text) for text in root.xpath("//li[contains(.,'主演')]//a/text()")]
directors = [
self._to_display_text(text)
for text in root.xpath("//li[contains(.,'導演') or contains(.,'导演')]//a/text()")
]
play_groups = self._parse_play_sources(html)
return {
"list": [
{
"vod_id": str(vod_id),
"vod_name": self._to_display_text(title),
"vod_pic": self._build_url(poster),
"vod_content": self._to_display_text(content),
"vod_remarks": self._pick_info(html, "狀態") or self._pick_info(html, "状态"),
"type_name": self._pick_info(html, "類別") or self._pick_info(html, "类别"),
"vod_year": self._pick_info(html, "年代") or self._pick_info(html, "年份"),
"vod_area": self._pick_info(html, "國家/地區") or self._pick_info(html, "国家/地区"),
"vod_actor": ",".join([item for item in actors if item]),
"vod_director": ",".join([item for item in directors if item]),
"vod_play_from": "$$$".join([item["from"] for item in play_groups]),
"vod_play_url": "$$$".join([item["urls"] for item in play_groups]),
}
]
}
def detailContent(self, ids):
raw = ids[0]
url = raw if str(raw).startswith("http") else f"{self.host}/detail/{raw}.html"
html = self._request_html(url)
matched = re.search(r"/detail/(\d+)\.html", url)
vod_id = matched.group(1) if matched else str(raw)
return self._parse_detail_page(html, vod_id)
def _normalize_search_keyword(self, text):
value = self._to_display_text(text).lower()
return re.sub(r"[\s\-_—–·•:,.。!?!?'\"“”‘’()()\[\]【】{}]", "", value)
def _build_search_tokens(self, keyword):
base = self._normalize_search_keyword(keyword)
if not base:
return []
tokens = [base]
simplified = re.sub(
r"线上看|線上看|全集|連續劇|连续剧|電視劇|电视剧|動漫|动漫|電影|电影|綜藝|综艺",
"",
base,
).strip()
if simplified and simplified not in tokens:
tokens.append(simplified)
stripped = re.sub(r"\d+[集季部篇]?$", "", simplified)
stripped = re.sub(r"\d+$", "", stripped)
if stripped and stripped not in tokens:
tokens.append(stripped)
return sorted([item for item in tokens if item], key=len, reverse=True)
def _score_search_result(self, vod_name, keyword):
name = self._normalize_search_keyword(vod_name)
best = 0
for token in self._build_search_tokens(keyword):
if name == token:
best = max(best, 1000 + len(token))
elif name.startswith(token):
best = max(best, 800 + len(token))
elif token in name:
best = max(best, 600 + len(token))
elif len(name) >= 2 and name in token:
best = max(best, 450 + len(name))
return best
def _refine_search_results(self, items, keyword):
scored = []
for item in items:
score = self._score_search_result(item.get("vod_name", ""), keyword)
if score > 0:
scored.append((score, item))
if scored:
scored.sort(key=lambda pair: (-pair[0], pair[1].get("vod_name", "")))
return [item for _, item in scored]
loose_key = self._normalize_search_keyword(keyword)
return [
item
for item in items
if loose_key and loose_key in self._normalize_search_keyword(item.get("vod_name", ""))
]
def searchContent(self, key, quick, pg="1"):
url = f"{self.host}/find/-------------.html?wd={quote(str(key))}&page={pg}"
html = self._request_html(url)
raw_items = self._extract_cards(html)
items = self._refine_search_results(raw_items, key)
page = int(pg)
pagecount = page + 1 if len(raw_items) >= 20 else page
return {
"list": items,
"page": page,
"pagecount": pagecount,
"limit": len(items),
"total": len(items),
}
def _extract_player_data(self, html):
matched = re.search(r"var\s+player_data\s*=\s*(\{[\s\S]*?\})\s*;?\s*</script>", str(html or ""), re.S)
if not matched:
return {}
try:
return json.loads(matched.group(1))
except Exception:
return {}
def _decode_play_url(self, player_data):
raw = str(player_data.get("url", "")).strip()
encrypt = str(player_data.get("encrypt", "0")).strip()
if not raw:
return ""
if encrypt == "1":
return unquote(raw)
if encrypt == "2":
try:
decoded = base64.b64decode(raw).decode("utf-8")
return unquote(decoded)
except Exception:
return ""
return raw
def _is_direct_media_url(self, url):
return bool(re.match(r"^https?://.*\.(m3u8|mp4|flv|m4s)(\?.*)?$", str(url or ""), re.I))
def _build_parse_context(self, raw_url, play_from, play_page_url):
base = "https://play.gimyai.tw/v/"
referer = f"https://play.gimyai.tw/v/?url={quote(raw_url)}"
if play_from in ["JD4K", "JD2K", "JDHG", "JDQM"]:
base = "https://play.gimyai.tw/d/"
referer = (
f"https://play.gimyai.tw/d/?url={quote(raw_url)}"
f"&jctype={play_from}&next={quote(play_page_url)}"
)
elif play_from == "NSYS":
base = "https://play.gimyai.tw/n/"
referer = (
f"https://play.gimyai.tw/n/?url={quote(raw_url)}"
f"&jctype={play_from}&next={quote(play_page_url)}"
)
return {
"parse_url": f"{base}parse.php?url={quote(raw_url)}&_t=1",
"referer": referer,
"origin": "https://play.gimyai.tw",
}
def playerContent(self, flag, id, vipFlags):
play_id = str(id)
play_page_url = play_id if play_id.startswith("http") else f"{self.host}/play/{play_id}.html"
page_headers = {
"User-Agent": self.headers["User-Agent"],
"Referer": play_page_url,
"Origin": self.host,
}
html = self._request_html(play_page_url, referer=play_page_url)
player_data = self._extract_player_data(html)
raw_url = self._decode_play_url(player_data)
if self._is_direct_media_url(raw_url):
return {
"parse": 0,
"jx": 0,
"url": raw_url,
"header": {"User-Agent": self.headers["User-Agent"], "Referer": play_page_url},
}
if raw_url:
context = self._build_parse_context(raw_url, str(player_data.get("from", "")).strip(), play_page_url)
parse_text = self._request_html(context["parse_url"], referer=context["referer"])
try:
parse_json = json.loads(parse_text or "{}")
except Exception:
parse_json = {}
media_url = str(
parse_json.get("url") or parse_json.get("video") or parse_json.get("playurl") or ""
).strip()
if media_url:
return {
"parse": 0,
"jx": 0,
"url": media_url,
"header": {
"User-Agent": self.headers["User-Agent"],
"Referer": context["referer"],
"Origin": context["origin"],
},
}
if raw_url.startswith("http"):
return {
"parse": 1,
"jx": 1,
"url": raw_url,
"header": {
"User-Agent": self.headers["User-Agent"],
"Referer": context["referer"],
},
}
return {"parse": 1, "jx": 1, "url": play_page_url, "header": page_headers}