From 0a5410f0a16e46a8fa47a49a48973edb12bef7b7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Fri, 24 Jul 2026 15:33:58 +0000 Subject: [PATCH] Sync all projects --- FGBLH/Web鱼壳海豚.json | 48 + FGBLH/Web鱼壳海豚py.json | 42 + FGBLH/Web鱼壳海豚无18加.json | 36 + FGBLH/ok海豚.json | 42 + FGBLH/ok海豚py.json | 42 + FGBLH/ok海豚无18加.json | 38 +- FGBLH/py/DJ呦呦.py | 491 ++++++ FGBLH/py/stripchat.py | 841 ++++++++-- FGBLH/py/多瑙影院.py | 512 +++++++ FGBLH/py/太乙影视.py | 1042 +++++++++++++ FGBLH/py/奇点影视.py | 569 +++++++ FGBLH/py/影视大全.py | 106 ++ FGBLH/py/枝枝影视.py | 540 +++++++ FGBLH/py/柯南影视.py | 651 ++++++++ FGBLH/py/草榴视频区.py | 495 ++++++ cluntop_tvbox/lib/iptv.m3u | 2206 ++++++++++++++------------- cluntop_tvbox/lib/iptv.txt | 2191 +++++++++++++------------- cluntop_tvbox/lib/iptv_test.m3u | 884 +++++------ cluntop_tvbox/lib/iptv_test.txt | 876 +++++------ jaychouqq/tvbox/file.json | 2 +- jaychouqq/tvbox/file1.json | 2 +- jaychouqq/tvbox/file2.json | 2 +- jaychouqq/tvbox/fileysc.json | 1 + jaychouqq/tvbox/jaychouqq | 2 +- jaychouqq/yingshi/py9/草榴视频区.py | 495 ++++++ tvbox/lib/iptv.m3u | 2206 ++++++++++++++------------- tvbox/lib/iptv.txt | 2191 +++++++++++++------------- tvbox/lib/iptv_test.m3u | 884 +++++------ tvbox/lib/iptv_test.txt | 876 +++++------ yjl/ysm.json | 4 +- 30 files changed, 12076 insertions(+), 6241 deletions(-) create mode 100644 FGBLH/py/DJ呦呦.py create mode 100644 FGBLH/py/多瑙影院.py create mode 100644 FGBLH/py/太乙影视.py create mode 100644 FGBLH/py/奇点影视.py create mode 100644 FGBLH/py/影视大全.py create mode 100644 FGBLH/py/枝枝影视.py create mode 100644 FGBLH/py/柯南影视.py create mode 100644 FGBLH/py/草榴视频区.py create mode 100644 jaychouqq/tvbox/fileysc.json create mode 100644 jaychouqq/yingshi/py9/草榴视频区.py diff --git a/FGBLH/Web鱼壳海豚.json b/FGBLH/Web鱼壳海豚.json index 64fb0738..72f4c05d 100644 --- a/FGBLH/Web鱼壳海豚.json +++ b/FGBLH/Web鱼壳海豚.json @@ -164,6 +164,30 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麒麟影视.py" }, + { + "key": "qdvm", + "name": "🐬奇点影视.py[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/奇点影视.py" + }, + { + "key": "zzvm", + "name": "🐬枝枝影视.py(关梯)[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/枝枝影视.py" + }, + { + "key": "knvm", + "name": "🐬柯南影视.py(关梯)[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/柯南影视.py" + }, + { + "key": "teys", + "name": "🐬太乙影视.py(关梯)[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/太乙影视.py" + }, { "key": "wkvm", "name": "🐬悟空影视.py(关梯)[追剧]", @@ -231,6 +255,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/努努影院.py" }, + { + "key": "dnyy", + "name": "🐬多瑙影院.py[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/多瑙影院.py" + }, { "key": "nvm", "name": "🐬泥视频.py[追剧]", @@ -786,6 +816,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/悦听吧.py" }, + { + "key": "275ts", + "name": "🐬275听书.py[听书]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/275听书.py" + }, { "key": "kwms", "name": "🐬酷我音乐.py[音乐]", @@ -803,6 +839,12 @@ "jar": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/so.jar", "filterable": 1 }, + { + "key": "djyy", + "name": "🐬DJ呦呦.py[音乐]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/DJ呦呦.py" + }, { "key": "shjb", "name": "🐬色播聚合.py|🔞[成人直播]", @@ -983,6 +1025,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/萝莉AV.py" }, + { + "key": "clspq", + "name": "🐬草榴视频区.py|🔞[成人]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/草榴视频区.py" + }, { "key": "123av", "name": "🐬123AV.py|🔞[成人]", diff --git a/FGBLH/Web鱼壳海豚py.json b/FGBLH/Web鱼壳海豚py.json index 984cae35..c8c1ecbc 100644 --- a/FGBLH/Web鱼壳海豚py.json +++ b/FGBLH/Web鱼壳海豚py.json @@ -101,6 +101,30 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麒麟影视.py" }, + { + "key": "qdvm", + "name": "🐬奇点影视.py[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/奇点影视.py" + }, + { + "key": "zzvm", + "name": "🐬枝枝影视.py(关梯)[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/枝枝影视.py" + }, + { + "key": "knvm", + "name": "🐬柯南影视.py(关梯)[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/柯南影视.py" + }, + { + "key": "teys", + "name": "🐬太乙影视.py(关梯)[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/太乙影视.py" + }, { "key": "wkvm", "name": "🐬悟空影视.py(关梯)[追剧]", @@ -161,6 +185,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/努努影院.py" }, + { + "key": "dnyy", + "name": "🐬多瑙影院.py[追剧]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/多瑙影院.py" + }, { "key": "nvm", "name": "🐬泥视频.py[追剧]", @@ -389,6 +419,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/酷我音乐.py" }, + { + "key": "djyy", + "name": "🐬DJ呦呦.py[音乐]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/DJ呦呦.py" + }, { "key": "shjb", "name": "🐬色播聚合.py|🔞[成人直播]", @@ -569,6 +605,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/萝莉AV.py" }, + { + "key": "clspq", + "name": "🐬草榴视频区.py|🔞[成人]", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/草榴视频区.py" + }, { "key": "123av", "name": "🐬123AV.py|🔞[成人]", diff --git a/FGBLH/Web鱼壳海豚无18加.json b/FGBLH/Web鱼壳海豚无18加.json index ef63b3fe..303d63a9 100644 --- a/FGBLH/Web鱼壳海豚无18加.json +++ b/FGBLH/Web鱼壳海豚无18加.json @@ -164,6 +164,30 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麒麟影视.py" }, + { + "key": "qdvm", + "name": "🐬奇点影视.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/奇点影视.py" + }, + { + "key": "zzvm", + "name": "🐬枝枝影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/枝枝影视.py" + }, + { + "key": "knvm", + "name": "🐬柯南影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/柯南影视.py" + }, + { + "key": "teys", + "name": "🐬太乙影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/太乙影视.py" + }, { "key": "wkvm", "name": "🐬悟空影视.py(关梯)", @@ -224,6 +248,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/努努影院.py" }, + { + "key": "dnyy", + "name": "🐬多瑙影院.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/多瑙影院.py" + }, { "key": "nvm", "name": "🐬泥视频.py", @@ -757,6 +787,12 @@ "jar": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/so.jar", "filterable": 1 }, + { + "key": "djyy", + "name": "🐬DJ呦呦.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/DJ呦呦.py" + }, { "key": "豆瓣", "name": "豆瓣|首页", diff --git a/FGBLH/ok海豚.json b/FGBLH/ok海豚.json index 7d242f15..6a547b4d 100644 --- a/FGBLH/ok海豚.json +++ b/FGBLH/ok海豚.json @@ -142,6 +142,30 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麒麟影视.py" }, + { + "key": "qdvm", + "name": "🐬奇点影视.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/奇点影视.py" + }, + { + "key": "zzvm", + "name": "🐬枝枝影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/枝枝影视.py" + }, + { + "key": "knvm", + "name": "🐬柯南影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/柯南影视.py" + }, + { + "key": "teys", + "name": "🐬太乙影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/太乙影视.py" + }, { "key": "wkvm", "name": "🐬悟空影视.py(关梯)", @@ -202,6 +226,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/努努影院.py" }, + { + "key": "dnyy", + "name": "🐬多瑙影院.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/多瑙影院.py" + }, { "key": "nvm", "name": "🐬泥视频.py", @@ -742,6 +772,12 @@ "jar": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/so.jar", "filterable": 1 }, + { + "key": "djyy", + "name": "🐬DJ呦呦.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/DJ呦呦.py" + }, { "key": "shjb", "name": "🐬色播聚合.py|🔞", @@ -928,6 +964,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/萝莉AV.py" }, + { + "key": "clspq", + "name": "🐬草榴视频区.py|🔞", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/草榴视频区.py" + }, { "key": "123av", "name": "🐬123AV.py|🔞", diff --git a/FGBLH/ok海豚py.json b/FGBLH/ok海豚py.json index 4b215f48..333bbdcd 100644 --- a/FGBLH/ok海豚py.json +++ b/FGBLH/ok海豚py.json @@ -87,6 +87,30 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麒麟影视.py" }, + { + "key": "qdvm", + "name": "🐬奇点影视.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/奇点影视.py" + }, + { + "key": "zzvm", + "name": "🐬枝枝影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/枝枝影视.py" + }, + { + "key": "knvm", + "name": "🐬柯南影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/柯南影视.py" + }, + { + "key": "teys", + "name": "🐬太乙影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/太乙影视.py" + }, { "key": "wkvm", "name": "🐬悟空影视.py(关梯)", @@ -160,6 +184,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/努努影院.py" }, + { + "key": "dnyy", + "name": "🐬多瑙影院.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/多瑙影院.py" + }, { "key": "nvm", "name": "🐬泥视频.py", @@ -406,6 +436,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/酷我音乐.py" }, + { + "key": "djyy", + "name": "🐬DJ呦呦.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/DJ呦呦.py" + }, { "key": "shjb", "name": "🐬色播聚合.py|🔞", @@ -592,6 +628,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/萝莉AV.py" }, + { + "key": "clspq", + "name": "🐬草榴视频区.py|🔞", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/草榴视频区.py" + }, { "key": "123av", "name": "🐬123AV.py|🔞", diff --git a/FGBLH/ok海豚无18加.json b/FGBLH/ok海豚无18加.json index f07620b6..404ff7af 100644 --- a/FGBLH/ok海豚无18加.json +++ b/FGBLH/ok海豚无18加.json @@ -142,6 +142,30 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/麒麟影视.py" }, + { + "key": "qdvm", + "name": "🐬奇点影视.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/奇点影视.py" + }, + { + "key": "zzvm", + "name": "🐬枝枝影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/枝枝影视.py" + }, + { + "key": "knvm", + "name": "🐬柯南影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/柯南影视.py" + }, + { + "key": "teys", + "name": "🐬太乙影视.py(关梯)", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/太乙影视.py" + }, { "key": "wkvm", "name": "🐬悟空影视.py(关梯)", @@ -202,6 +226,12 @@ "type": 3, "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/努努影院.py" }, + { + "key": "dnyy", + "name": "🐬多瑙影院.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/多瑙影院.py" + }, { "key": "nvm", "name": "🐬泥视频.py", @@ -696,7 +726,13 @@ "ext": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/Music1.json", "jar": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/jar_js/so.jar", "filterable": 1 - } + }, + { + "key": "djyy", + "name": "🐬DJ呦呦.py", + "type": 3, + "api": "https://ghfast.top/https://raw.githubusercontent.com/FGBLH/HKL/refs/heads/main/py/DJ呦呦.py" + } ], "parses": [ { diff --git a/FGBLH/py/DJ呦呦.py b/FGBLH/py/DJ呦呦.py new file mode 100644 index 00000000..1e1b8991 --- /dev/null +++ b/FGBLH/py/DJ呦呦.py @@ -0,0 +1,491 @@ +# -*- coding: utf-8 -*- +# 兼容 OK影视/影视仓 +# 其它影视壳自测 + +from base.spider import Spider +import requests +import re +from bs4 import BeautifulSoup +from urllib.parse import quote + + +class Spider(Spider): + def getName(self): + return "DJ呦呦" + + def init(self, extend=""): + pass + + def isVideoFormat(self, url): + return False + + def manualVideoCheck(self): + return False + + def homeContent(self, filter): + result = {} + result['filters'] = {} + cateId = [ + {"type_name": "最近更新-专辑", "type_id": "ablum_i1"}, + {"type_name": "最新加入-专辑", "type_id": "ablum_i2"}, + {"type_name": "热门DJ-专辑", "type_id": "ablum_i3"}, + {"type_name": "独家舞曲", "type_id": "exclusive_115"}, + {"type_name": "迪高串烧", "type_id": "djlist_1"}, + {"type_name": "慢摇串烧", "type_id": "djlist_2"}, + {"type_name": "慢歌串烧", "type_id": "djlist_3"}, + {"type_name": "中文Remix", "type_id": "djlist_4"}, + {"type_name": "外文Remix", "type_id": "djlist_5"}, + {"type_name": "中文DISCO", "type_id": "djlist_9"}, + {"type_name": "外文DISCO", "type_id": "djlist_10"} + ] + result['class'] = cateId + return result + + def homeVideoContent(self): + return self.categoryContent("ablum_i1", 1, False, {}) + + # ---------- 分类列表 ---------- + + def categoryContent(self, tid, pg, filter, extend): + result = { + 'list': [], + 'page': pg, + 'pagecount': 9999, + 'limit': 30, + 'total': 999999 + } + path, cid = self._split_type(tid) + + try: + if path == 'ablum': + videos = self._category_ablum(cid, pg) + elif path == 'exclusive': + videos = self._category_songlist('exclusive', cid, pg) + elif path == 'djlist': + videos = self._category_songlist('djlist', cid, pg) + else: + videos = [] + + result['list'] = videos + except Exception: + pass + + return result + + def _split_type(self, tid): + # 格式: ablum_i1, exclusive_115, djlist_1 + if '_' in tid: + path, cid = tid.split('_', 1) + return path, cid + return 'ablum', tid + + def _category_ablum(self, cid, pg): + url = f"https://www.djuu.com/ablum/{cid}_{pg}.html" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://www.djuu.com/ablum/' + } + r = requests.get(url, headers=headers, timeout=10) + r.encoding = 'utf-8' + soup = BeautifulSoup(r.text, 'html.parser') + + videos = [] + container = soup.select_one('.djshow_contentlist') + if container: + for table in container.find_all('table', recursive=False): + rows = table.find_all('tr') + if not rows: + continue + + first_row = rows[0] + img_a = first_row.find('a', href=re.compile(r'/ablum/\d+\.html')) + if not img_a: + continue + + img = img_a.find('img') + pic = (img.get('src') or '') if img else '' + ablum_url = img_a.get('href', '') + m = re.search(r'/ablum/(\d+)\.html', ablum_url) + if not m: + continue + + name_a = table.find('a', class_='djshow_contentlist_name') + name = name_a.get_text(strip=True) if name_a else '未知DJ' + + msgs = table.find_all('td', class_='djshow_contentlist_msg') + area = msgs[0].get_text(strip=True) if len(msgs) > 0 else '' + hot = msgs[2].get_text(strip=True) if len(msgs) > 2 else '' + remarks = f"{area} | 热度:{hot}" if area and hot else (area or hot) + + videos.append({ + "vod_id": m.group(1), + "vod_name": name, + "vod_pic": pic, + "vod_remarks": remarks + }) + return videos + + def _category_songlist(self, path, cid, pg): + url = f"https://www.djuu.com/{path}/{cid}_{pg}.html" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': f'https://www.djuu.com/{path}/' + } + r = requests.get(url, headers=headers, timeout=10) + r.encoding = 'utf-8' + soup = BeautifulSoup(r.text, 'html.parser') + + videos = [] + seen = set() + for div in soup.find_all('div', class_='isgood_list'): + img_a = div.find('a', href=re.compile(r'/play/\d+\.html')) + title_a = div.find('p', class_='t1') + if title_a: + title_a = title_a.find('a', href=re.compile(r'/play/\d+\.html')) + if not img_a or not title_a: + continue + + m = re.search(r'/play/(\d+)\.html', img_a.get('href', '')) + if not m: + continue + sid = m.group(1) + if sid in seen: + continue + seen.add(sid) + + name = title_a.get('title') or title_a.get_text(strip=True) + img = img_a.find('img') + pic = (img.get('src') or '') if img else '' + # 兼容懒加载 + if not pic: + pic = (img.get('data-src') or '') if img else '' + + # 时长/大小作为备注 + spans = div.find_all('span') + info = ' | '.join([s.get_text(strip=True) for s in spans[:3]]) + + videos.append({ + "vod_id": f"song_{sid}", + "vod_name": name, + "vod_pic": pic, + "vod_remarks": info + }) + return videos + + # ---------- 详情 ---------- + + def detailContent(self, ids): + rid = ids[0] if isinstance(ids, (list, tuple)) else ids + result = {} + + try: + if isinstance(rid, str) and rid.startswith('song_'): + sid = rid.replace('song_', '', 1) + vod = self._song_detail(sid) + else: + vod = self._ablum_detail(rid) + result['list'] = [vod] + except Exception as e: + result['list'] = [{ + "vod_id": rid, + "vod_name": "加载失败", + "vod_content": f"加载失败: {str(e)}", + "vod_remarks": "加载失败", + "vod_actor": "未知", + "vod_play_from": "DJ呦呦", + "vod_play_url": "", + "vod_pic": "" + }] + + return result + + def _ablum_detail(self, rid): + vod_name, pic, content = self._ablum_info(rid) + songs = self._ablum_songs(rid) + + play_arr = [] + for song in songs: + name = re.sub(r'[$#]', '', song.get('name', '')).strip() + sid = song.get('id', '') + if name and sid: + play_arr.append(f"{name}${sid}") + + return { + "vod_id": rid, + "vod_name": vod_name, + "vod_pic": pic, + "vod_content": content if content else "暂无简介", + "vod_remarks": f"歌曲 : {len(songs)}首", + "vod_actor": vod_name, + "vod_play_from": "DJ呦呦", + "vod_play_url": "#".join(play_arr) + } + + def _song_detail(self, sid): + # 单曲详情:直接进入播放页取信息 + url = f"https://www.djuu.com/play/{sid}.html" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://www.djuu.com/' + } + r = requests.get(url, headers=headers, timeout=10) + r.encoding = 'utf-8' + soup = BeautifulSoup(r.text, 'html.parser') + + # 歌曲名 + name = '' + h1 = soup.find('h1') + if h1: + name = h1.get_text(strip=True) + if not name: + m = re.search(r"var music = \{[^}]*name:\s*'([^']+)'", r.text) + name = m.group(1) if m else '未知歌曲' + + # 封面:优先取播放器区域 / 旋转封面 / 模糊背景,避免取到广告图 + pic = self._extract_play_pic(soup) + + # 简介:取播放页信息 + content = '' + info = soup.select_one('.djshow_djmsg_content') or soup.select_one('.play_info') + if info: + content = info.get_text('\n', strip=True) + + return { + "vod_id": f"song_{sid}", + "vod_name": name, + "vod_pic": pic, + "vod_content": content if content else "暂无简介", + "vod_remarks": "单曲", + "vod_actor": name, + "vod_play_from": "DJ呦呦", + "vod_play_url": f"{name}${sid}" + } + + def _ablum_info(self, rid): + url = f"https://www.djuu.com/ablum/{rid}.html" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://www.djuu.com/ablum/' + } + r = requests.get(url, headers=headers, timeout=10) + r.encoding = 'utf-8' + soup = BeautifulSoup(r.text, 'html.parser') + + h1 = soup.find('h1') + vod_name = h1.get_text(strip=True) if h1 else '未知DJ' + vod_name = re.sub(r'<[^>]+>', '', vod_name) + + pic = '' + for img in soup.find_all('img', src=re.compile(r'img\.djuu\.com')): + src = img.get('src') or '' + pic = src or pic + if 'ablum' in pic or 'dj_album' in pic: + break + + content = '' + info_div = soup.select_one('.djshow_djmsg_content') + if info_div: + content = info_div.get_text('\n', strip=True) + + return vod_name, pic, content + + def _ablum_songs(self, rid, max_pages=10, max_songs=300): + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': f'https://www.djuu.com/ablum/{rid}.html' + } + + songs = [] + for pg in range(1, max_pages + 1): + url = f"https://www.djuu.com/ablum/{rid}_1_{pg}.html" + try: + r = requests.get(url, headers=headers, timeout=10) + r.encoding = 'utf-8' + soup = BeautifulSoup(r.text, 'html.parser') + + page_songs = [] + seen = set(s['id'] for s in songs) + for a in soup.find_all('a', href=re.compile(r'/play/(\d+)\.html')): + sid = re.search(r'/play/(\d+)\.html', a.get('href', '')) + if not sid: + continue + song_id = sid.group(1) + if song_id in seen: + continue + title = a.get('title') or a.get_text(strip=True) + if title: + page_songs.append({'id': song_id, 'name': title}) + seen.add(song_id) + + if not page_songs: + break + + songs.extend(page_songs) + if len(songs) >= max_songs: + songs = songs[:max_songs] + break + + except Exception: + continue + + return songs + + # ---------- 播放 ---------- + + def _extract_play_pic(self, soup): + """从播放页提取歌曲封面,优先排除广告图""" + pic = '' + for selector in ['#mcover', 'img.blur', '.play_detail img', '.play_p2 img', '.play_ct img']: + el = soup.select_one(selector) + if el and el.name == 'img': + pic = el.get('src') or '' + elif el: + img = el.find('img') + pic = (img.get('src') or '') if img else '' + if pic: + break + # fallback:取包含 cover 且非 advert 的第一张图 + if not pic: + for img in soup.find_all('img', src=re.compile(r'img\.djuu\.com')): + src = img.get('src') or '' + if 'cover' in src and 'advert' not in src: + pic = src + break + # 最后兜底 + if not pic: + first = soup.find('img', src=re.compile(r'img\.djuu\.com')) + pic = (first.get('src') or '') if first else '' + return pic + + def playerContent(self, flag, id, vipFlags): + result = {} + rid = id + if isinstance(rid, str) and rid.startswith('song_'): + rid = rid.replace('song_', '', 1) + + url = f"https://www.djuu.com/play/{rid}.html" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://www.djuu.com/' + } + + try: + r = requests.get(url, headers=headers, timeout=10) + r.encoding = 'utf-8' + html = r.text + soup = BeautifulSoup(html, 'html.parser') + + # 歌曲名 + name = '' + h1 = soup.find('h1') + if h1: + name = h1.get_text(strip=True) + if not name: + m = re.search(r"var music = \{[^}]*name:\s*'([^']+)'", html) + name = m.group(1) if m else '' + + # 封面 + pic = self._extract_play_pic(soup) + + m = re.search(r"var music = \{[^}]*file:\s*'([^']+)'", html) + if m: + file_path = m.group(1) + if file_path.startswith('http'): + play_url = file_path + else: + play_url = f"https://mp4.djuu.com/{file_path}.m4a" + result["parse"] = 0 + result["jx"] = 0 + result["playUrl"] = "" + result["url"] = play_url + result["header"] = { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", + "Referer": "https://www.djuu.com/" + } + result["pic"] = pic + result["name"] = name + return result + + except Exception: + pass + + result["parse"] = 0 + result["jx"] = 0 + result["playUrl"] = "" + result["url"] = "" + result["header"] = {} + result["pic"] = "" + result["name"] = "" + return result + + # ---------- 搜索 ---------- + + def searchContent(self, key, quick, pg=1): + result = { + 'list': [], + 'page': pg, + 'pagecount': 9999, + 'limit': 30, + 'total': 999999 + } + wd = quote(key) + url = f"https://www.djuu.com/search?musicname={wd}&page={pg}" + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + 'Referer': 'https://www.djuu.com/' + } + + try: + r = requests.get(url, headers=headers, timeout=10) + r.encoding = 'utf-8' + soup = BeautifulSoup(r.text, 'html.parser') + + videos = [] + seen = set() + # 搜索结果为表格行,每行一个 isgood_list + for tr in soup.find_all('tr', class_='sbg'): + a = tr.find('a', href=re.compile(r'/play/\d+\.html')) + if not a: + continue + m = re.search(r'/play/(\d+)\.html', a.get('href', '')) + if not m: + continue + song_id = m.group(1) + if song_id in seen: + continue + seen.add(song_id) + + title = a.get('title') or a.get_text(strip=True) + if not title: + continue + + pic = '' + img = tr.find('img') + if img: + pic = img.get('src') or '' + if not pic: + pic = img.get('data-src') or '' + + remarks = '' + spans = tr.find_all('span', class_='sc_2') + if spans: + remarks = ' | '.join([s.get_text(strip=True) for s in spans]) + + videos.append({ + "vod_id": f"song_{song_id}", + "vod_name": title, + "vod_pic": pic, + "vod_remarks": remarks + }) + + result['list'] = videos + except Exception: + pass + + return result + + def searchContentPage(self, key, quick, pg): + return self.searchContent(key, quick, pg) + + def localProxy(self, param): + return {} diff --git a/FGBLH/py/stripchat.py b/FGBLH/py/stripchat.py index 34d9c8a1..a69dd2b4 100644 --- a/FGBLH/py/stripchat.py +++ b/FGBLH/py/stripchat.py @@ -1,132 +1,753 @@ # coding=utf-8 -# !/usr/bin/python +#!/usr/bin/python +import base64 +from datetime import datetime, timedelta +from functools import lru_cache +import json +import random +import re import sys +import threading +import time +from urllib.parse import quote, urlparse + import requests -sys.path.append('..') +from urllib3.util.retry import Retry + from base.spider import Spider +sys.path.append("..") + + class Spider(Spider): - def init(self, extend="{}"): - self.host='https://zh.stripol.com/' - self.headers = { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:142.0) Gecko/20100101 Firefox/142.0' - } + def init(self, extend="{}"): + # 1. 创建 Session + self.create_session_with_retry() - def getName(self): - pass + # 2. 备用域名列表 + self.dynamic_urls = [ + " https://zh.stripol.com/", + "https://zh.pikpedcams.com/", + "https://zh.virtualtaboo.live/", + ] - def isVideoFormat(self, url): - pass + # 3. 基础 Header 及属性初始化 + self.Doppiocdn = "doppiocdn.org" + user_agent = ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:153.0) Gecko/20100101" + " Firefox/153.0" + ) + self.headers = { + "User-Agent": user_agent, + "Accept-Language": "zh,en;q=0.5", + } - def manualVideoCheck(self): - pass + # 默认选第一个,后续请求会轮询切换 + self.host = self.dynamic_urls[0] + self._update_headers_for_host(self.host) - def destroy(self): - pass + # 4. 其他配置及弹幕锁 + self.stripchat_preferredVideoCodec = "H264" # 可选H264或AV1 + self.stripchat_key = "YzWScuyQRGAGcxx1KIJmiQ7BY9Vi35ftwLqUOVO8uoo=" + self.stripchat_pkey = "Fq6m2TO2ZeBkRPm9" + self.stripchat_play = "0 0" + self.danmu_cache = {} + self.danmu_threads = {} + self.danmu_lock = threading.Lock() - def homeContent(self, filter): - result = {} - classes = [{'type_name': '女主播', 'type_id': 'girls'}, {'type_name': '情侣', 'type_id': 'couples'}, {'type_name': '男主播', 'type_id': 'men'}, {'type_name': '跨性别', 'type_id': 'trans'}] - filters = {} - value = [{'n': '中国', 'v': 'tagLanguageChinese'}, {'n': '亚洲', 'v': 'ethnicityAsian'}, {'n': '白人', 'v': 'ethnicityWhite'}, {'n': '拉丁', 'v': 'ethnicityLatino'}, {'n': '混血', 'v': 'ethnicityMultiracial'}, {'n': '印度', 'v': 'ethnicityIndian'}, {'n': '阿拉伯', 'v': 'ethnicityMiddleEastern'}, {'n': '黑人', 'v': 'ethnicityEbony'}] - value_gay = [{'n': '情侣', 'v': 'sexGayCouples'}, {'n': '直男', 'v': 'orientationStraight'}] - for tid in ['girls', 'couples', 'men', 'trans']: - c_value = value[:] - if tid == 'men': - c_value += value_gay - filters[tid] = [{'key': 'tag', 'value': c_value}] - result['class'] = classes - result['filters'] = filters - return result + def _update_headers_for_host(self, host_url): + """根据当前使用的 Host 刷新请求头""" + self.host = host_url + self.headers["Origin"] = host_url + self.headers["Referer"] = f"{host_url}/" + self.json_headers = { + **self.headers, + "Accept": "application/json, text/plain, */*", + } - def homeVideoContent(self): - pass + def _request_with_failover(self, path, timeout=(3, 5)): + """核心逻辑:逐个域名尝试请求列表/详情,直到成功为止""" + # 优先使用上一次成功的域名,若不在第一位则将其调整到前面 + urls_to_try = list(self.dynamic_urls) + if self.host in urls_to_try: + urls_to_try.remove(self.host) + urls_to_try.insert(0, self.host) + + last_error = None + + for domain in urls_to_try: + clean_domain = domain.strip().rstrip('/') + full_url = ( + f'{clean_domain}{path}' if path.startswith('/') else f'/{path}' + ) + + # 临时构造对应域名的 Header + headers = { + **self.headers, + 'Origin': clean_domain, + 'Referer': f'{clean_domain}/', + 'Accept': 'application/json, text/plain, */*', + } + + try: + response = self.session.get(full_url, headers=headers, timeout=timeout) + if response.status_code == 200: + data = response.json() + # 校验是否拿到了正确的 json 数据 + if isinstance(data, dict): + # 成功!更新全局 Host 状态 + if self.host != clean_domain: + self.log(f'[HOST] 切换可用域名为: {clean_domain}') + self._update_headers_for_host(clean_domain) + return data + else: + self.log( + f'[HOST] 域名 {clean_domain} 请求失败,状态码: {response.status_code},尝试下一个...' + ) + except Exception as e: + self.log(f'[HOST] 域名 {clean_domain} 访问异常: {e},尝试下一个...') + last_error = e + continue + + self.log(f'[HOST] 所有域名均访问失败! 最后一次错误: {last_error}') + return {} + + def getName(self): + return 'StripChat' + + def isVideoFormat(self, url): + pass + + def manualVideoCheck(self): + pass + + def destroy(self): + pass + + def homeVideoContent(self): + pass + + def datetime_utc8(self, strTime, outFormat): + return ( + datetime.strptime(strTime, '%Y-%m-%dT%H:%M:%SZ') + timedelta(hours=8) + ).strftime(outFormat) + + def homeContent(self, filter): + CLASSES = [ + {'type_name': '女主播', 'type_id': 'girls'}, + {'type_name': '情侣', 'type_id': 'couples'}, + {'type_name': '男主播', 'type_id': 'men'}, + {'type_name': '跨性别', 'type_id': 'trans'}, + ] + VALUE = [ + {'n': '新主播', 'v': 'autoTagNew'}, + {'n': '推荐', 'v': 'recommended'}, + {'v': 'fuckMachine', 'n': '炮机'}, + {'n': '青年', 'v': 'ageTeen'}, + {'n': 'VR', 'v': 'autoTagVr'}, + {'n': '亚洲人', 'v': 'ethnicityAsian'}, + {'n': '🇨🇳中国', 'v': 'tagLanguageChinese'}, + {'n': '🇯🇵日本', 'v': 'tagLanguageJapanese'}, + {'n': '🇰🇷韩国', 'v': 'tagLanguageKorean'}, + {'n': '🇻🇳越南', 'v': 'tagLanguageVietnamese'}, + {'v': 'tagLanguageUkrainian', 'n': '🇺🇦乌克兰'}, + {'v': 'tagLanguageRussianSpeaking', 'n': '🇷🇺俄罗斯'}, + {'v': 'tagLanguageUSModels', 'n': '🇺🇸美国'}, + {'v': 'tagLanguageColombian', 'n': '🇨🇴哥伦比亚'}, + {'v': 'tagLanguageGermanSpeaking', 'n': '🇩🇪德国'}, + {'v': 'tagLanguageFrench', 'n': '🇫🇷法国'}, + {'v': 'tagLanguageUKModels', 'n': '🇬🇧英国'}, + {'v': 'tagLanguageCanadian', 'n': '🇨🇦加拿大'}, + {'v': 'tagLanguageMexican', 'n': '🇲🇽墨西哥'}, + {'v': 'ethnicityIndian', 'n': '🇮🇳印度'}, + {'v': 'tagLanguageVenezuelan', 'n': '🇻🇪委内瑞拉'}, + {'v': 'tagLanguageRomanian', 'n': '🇷🇴罗马尼亚'}, + {'v': 'tagLanguageAfrican', 'n': '🌍非洲'}, + {'v': 'tagLanguageSpanishSpeaking', 'n': '🇪🇸西班牙'}, + {'v': 'ethnicityMiddleEastern', 'n': '🇸🇦🇦🇪阿拉伯'}, + {'v': 'tagLanguageKenyan', 'n': '🇰🇪肯尼亚'}, + {'v': 'tagLanguageSouthAfrican', 'n': '🇿🇦南非'}, + {'v': 'tagLanguageBrazilian', 'n': '🇧🇷巴西'}, + {'v': 'tagLanguageThai', 'n': '🇹🇭泰国'}, + {'v': 'tagLanguageItalian', 'n': '🇮🇹意大利'}, + {'n': '亚洲', 'v': 'ethnicityAsian'}, + {'n': '白人', 'v': 'ethnicityWhite'}, + {'n': '拉丁', 'v': 'ethnicityLatino'}, + {'n': '混血', 'v': 'ethnicityMultiracial'}, + {'n': '印度', 'v': 'ethnicityIndian'}, + {'n': '阿拉伯', 'v': 'ethnicityMiddleEastern'}, + {'n': '黑人', 'v': 'ethnicityEbony'}, + {'n': '✨新主播', 'v': 'autoTagNew'}, + {'n': 'VR直播', 'v': 'autoTagVr'}, + {'n': '18+', 'v': 'ageTeen'}, + {'n': '鲜嫩青年22+', 'v': 'ageYoung'}, + {'n': '学生', 'v': 'subcultureStudent'}, + {'n': '口交', 'v': 'doBlowjob'}, + {'n': '深喉', 'v': 'doDeepThroat'}, + {'n': '恋足', 'v': 'doFootFetish'}, + {'n': '互动玩具', 'v': 'autoTagInteractiveToy'}, + {'n': '自慰', 'v': 'doMasturbation'}, + {'n': '肛交', 'v': 'doAnal'}, + {'n': '潮吹', 'v': 'doSquirt'}, + {'n': '狗式', 'v': 'doDoggyStyle'}, + {'n': 'Cosplay', 'v': 'doCosplay'}, + {'n': 'RolePlay', 'v': 'doRolePlay'}, + ] + VALUE_MEN = [ + {'n': '情侣', 'v': 'sexGayCouples'}, + {'n': '直男', 'v': 'orientationStraight'}, + ] + TIDS = ('girls', 'couples', 'men', 'trans') + filters = { + tid: [{'key': 'tag', 'value': VALUE_MEN + VALUE if tid == 'men' else VALUE}] + for tid in TIDS + } + return {'class': CLASSES, 'filters': filters} + + def _parse_status_remark(self, is_live, status, viewers=0): + if not is_live or status == 'off': + status_text = '⚫ 已下播' + elif status == 'public': + status_text = '🔴 直播中' + else: + status_text = '收费房' + + return f'👤 {viewers}人 | {status_text}' if viewers else status_text + + def categoryContent(self, tid, pg, filter, extend): + try: + pg_str = str(pg) + page_num = int(pg_str) + + # 1. 搜索场景逻辑 + if tid.startswith('search '): + _, tag, key = tid.split(maxsplit=2) + path = f'/api/front/v4/models/search/group/username?query={key}&limit=900&primaryTag={tag}' + rsp = self._request_with_failover(path) - def categoryContent(self, tid, pg, filter, extend): - limit = 60 - offset = limit * (int(pg) - 1) - domain = f"{self.host}api/front/models?improveTs=false&removeShows=false&limit={limit}&offset={offset}&primaryTag={tid}&sortBy=viewersRating&rcmGrp=A&rbCnGr=true&prxCnGr=false&nic=false" - if 'tag' in extend: - domain += "&filterGroupTags=%5B%5B%22" + extend['tag'] + "%22%5D%5D" - rsp = requests.get(domain, headers=self.headers).json() - vodList = rsp['models'] videos = [] - for vod in vodList: - id = str(vod['id']) - title = str(vod['username']).strip() - stamp = vod['snapshotTimestamp'] - videos.append({ - "vod_id": title, - "vod_name": title, - "vod_pic": f"https://img.doppiocdn.net/thumbs/{stamp}/{id}", - "vod_remarks": "收费表演中" if vod['groupShowType'] else "" - }) - total = int(rsp['filteredCount']) - result = {} - result['list'] = videos - result['page'] = pg - result['pagecount'] = (total + limit - 1) // limit - result['limit'] = limit - result['total'] = total - return result + for u in rsp.get('models', []): + if not u.get('isLive'): + continue + viewers = u.get('viewersCount', 0) + is_live = u.get('isLive', False) + status = u.get('status', 'off') - def detailContent(self, array): - username = array[0] - domain = f"{self.host}api/front/v2/models/username/{username}/cam" - rsp = requests.get(domain, headers=self.headers).json() - info = rsp['cam'] - user = rsp['user']['user'] - id = str(user['id']) - vod = { - "vod_id": id, - "vod_name": str(info['topic']).strip(), - "vod_pic": str(user['avatarUrl']), - "vod_director": username, - "vod_area": str(user['country']), - 'vod_play_from': '直播线路', - 'vod_play_url': f"{id}${id}" + remark = self._parse_status_remark(is_live, status, viewers) + videos.append({ + 'vod_id': str(u['username']), + 'vod_name': ( + f"{self.country_code_to_flag(str(u.get('country', '')))}{u['username']}" + ), + 'vod_pic': ( + f"https://img.{self.Doppiocdn}/snapshot/{u['id']}/{u.get('snapshotTimestamp', '')}" + ), + 'style': {'type': 'rect', 'ratio': 1.78}, + 'vod_remarks': remark, + }) + + return { + 'list': videos, + 'page': pg_str, + 'pagecount': '1', + 'limit': '900', + 'total': str(len(videos)), } - result = { - 'list': [ - vod - ] - } - return result - def searchContent(self, key, quick, pg="1"): - pass + # 2. 普通分类列表场景逻辑 (已移除 host_card 保证追加) + limit = 60 + offset = limit * (page_num - 1) + path = f'/api/front/models?improveTs=false&removeShows=false&limit={limit}&offset={offset}&primaryTag={tid}&sortBy=stripRanking&rcmGrp=A&rbCnGr=true&prxCnGr=false&nic=false' + if 'tag' in extend and extend['tag']: + path += f'&filterGroupTags=[["{extend["tag"]}"]]' - def playerContent(self, flag, id, vipFlags): - domain = f"https://edge-hls.growcdnssedge.com/hls/{id}/master/{id}_auto.m3u8?playlistType=lowLatency" - rsp = requests.get(domain, headers=self.headers).text + # 使用轮询切域名获取列表数据 + rsp = self._request_with_failover(path) + + videos = [] + for v in rsp.get('models', []): + is_live = v.get('isLive', False) + status = v.get('status', 'public') + viewers = v.get('viewersCount', 0) + + remark = self._parse_status_remark(is_live, status, viewers) + + videos.append({ + 'vod_id': str(v['username']), + 'vod_name': ( + f"{self.country_code_to_flag(str(v.get('country', '')))}{v['username']}" + ), + 'vod_pic': ( + f"https://img.{self.Doppiocdn}/snapshot/{v['id']}/{v.get('snapshotTimestamp', '')}" + ), + 'vod_remarks': remark, + }) + + total = int(rsp.get('filteredCount', 0)) + pagecount = (total + limit - 1) // limit if total > 0 else 1 + + return { + 'list': videos, + 'page': pg_str, + 'pagecount': str(pagecount), + 'limit': str(limit), + 'total': str(total), + } + except Exception as e: + self.log(f'获取分类内容失败: {e}') + return { + 'list': [], + 'page': str(pg), + 'pagecount': '1', + 'limit': '60', + 'total': '0', + } + + def detailContent(self, array): + username = array[0] + + try: + path = f'/api/front/v2/models/username/{username}/cam' + # 使用轮询切域名获取详情数据 + rsp = self._request_with_failover(path) + + info = rsp.get('cam', {}) + user = rsp.get('user', {}).get('user', {}) + uid, isLive = str(user.get('id', '')), user.get('isLive', False) + + oldName = self.stripchat_play.rsplit(' ', 1)[-1] + if username != oldName: + timestp = int(time.time()) + self.stripchat_play = f'0 {timestp} {username}' + flag = self.country_code_to_flag(str(user.get('country', '')).strip()) + + remark = '🔴 直播中' if isLive else '⚫ 已下播' + show = info.get('show') or info.get('groupShowAnnouncement') + if show: + startAt = show.get('createdAt') or show.get('startAt') + if startAt: + remark = ( + f"🎫 购票表演始于 {self.datetime_utc8(startAt, '%m月%d日 %H:%M')}" + ) + + director = f'{flag}{username}' + desc = self.get_danmaku_desc(uid) + + vod_play_from = '高清线路$$$标清线路二$$$标清线路三' + vod_play_url = ( + f'主线路${uid}$$$备用线路$lemon_{uid}$$$备用线路三$sacf_{uid}' + ) + + return { + 'list': [{ + 'vod_id': username, + 'vod_name': str(info.get('topic', ''))[:80], + 'vod_pic': str(user.get('avatarUrl', '')), + 'vod_director': director, + 'vod_content': desc, + 'vod_remarks': remark, + 'vod_play_from': vod_play_from, + 'vod_play_url': vod_play_url, + }] + } + except Exception as e: + self.log(f'获取详情失败: {e}') + return {'list': []} + + def searchContent(self, key, quick, pg='1'): + if int(pg) > 1: + return {} + return { + 'list': [ + { + 'vod_id': f'search {t["type_id"]} {key}', + 'vod_name': t['type_name'], + 'vod_tag': 'folder', + } + for t in self.homeContent(False).get('class', []) + ] + } + + def playerContent(self, flag, id, vipFlags): + urls = [] + try: + sid = id.split('_')[-1] + self.start_danmu(sid) + + # 统一使用动态 self.host 配置 Origin 和 Referer + headers = { + 'User-Agent': self.headers.get('User-Agent'), + 'Origin': self.host, + 'Referer': f'{self.host}/', + } + + # --- 线路2: stripchat.global --- + if id.startswith('lemon'): + rsp = self.session_get( + f'https://edge-hls.growcdnssedge.com/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency' + ).text lines = rsp.strip().split('\n') - psch = '' - pkey = '' - url = [] for i, line in enumerate(lines): - if line.startswith('#EXT-X-MOUFLON:'): - parts = line.split(':') - if len(parts) >= 4: - psch = parts[2] - pkey = parts[3] - if '#EXT-X-STREAM-INF' in line: - name_start = line.find('NAME="') + 6 - name_end = line.find('"', name_start) - qn = line[name_start:name_end] - # URL在下一行 - url_base = lines[i + 1] - # 组合最终的URL,并加上psch和pkey参数 - full_url = f"{url_base}&psch={psch}&pkey={pkey}" - # 将画质和URL添加到列表中 - url.append(qn) - url.append(full_url) - result = {} - result["url"] = url - result["parse"] = '0' - result["contentType"] = '' - result["header"] = self.headers - return result + if '#EXT-X-STREAM-INF' in line: + qn_start = line.find('NAME="') + 6 + qn = line[qn_start : line.find('"', qn_start)] + url = lines[i + 1] + urls.extend([qn, url]) - def localProxy(self, param): - pass + # --- 线路3: StripOl --- + elif id.startswith('sacf'): + rsp = self.session_get( + f'https://edge-hls.sacfedge.com/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency' + ).text + lines = rsp.strip().split('\n') + psch, pkey = 'v2', self.stripchat_pkey + for i, line in enumerate(lines): + if '#EXT-X-STREAM-INF' in line: + qn_start = line.find('NAME="') + 6 + qn = line[qn_start : line.find('"', qn_start)] + full_url = f'{lines[i+1]}&psch={psch}&pkey={pkey}&preferredVideoCodec={self.stripchat_preferredVideoCodec}' + urls.extend([qn, f'{self.getProxyUrl()}&url={quote(full_url)}']) + + # --- 线路1: StripChat (主线路) --- + else: + rsp = self.session_get( + f'https://edge-hls.{self.Doppiocdn}/hls/{sid}/master/{sid}_auto.m3u8?playlistType=lowLatency' + ).text + lines = rsp.strip().split('\n') + psch, pkey = 'v2', self.stripchat_pkey + for i, line in enumerate(lines): + if '#EXT-X-STREAM-INF' in line: + qn_start = line.find('NAME="') + 6 + qn = line[qn_start : line.find('"', qn_start)] + full_url = f'{lines[i+1]}&psch={psch}&pkey={pkey}&preferredVideoCodec={self.stripchat_preferredVideoCodec}' + urls.extend([qn, f'{self.getProxyUrl()}&url={quote(full_url)}']) + + return {'url': urls, 'parse': '0', 'position': '0', 'header': headers} + except Exception as e: + self.log(f'播放失败 {id}: {e}') + return {'url': urls, 'parse': 0} + + def update_vod(self, username): + try: + content_data = self.detailContent([username]).get('list')[0] + payload = {'json': json.dumps(content_data, ensure_ascii=False)} + self.post('http://127.0.0.1:9978/action?do=refresh&type=vod', data=payload) + except Exception as e: + self.log(f'刷新详情失败: {e}') + + def localProxy(self, param): + url, type = param['url'], param.get('type', '') + if type == 'media': + data = self.session_get(url, timeout=(5, 15)) + return [200, 'video/mp4', data.content]#更改这个可以让羊壳正常播放video/mp4和application/octet-stream + rsp = self.session_get(url) + oldCode, oldtmp, username = self.stripchat_play.rsplit(' ') + timestp = int(time.time()) + is_time_up = (timestp - 10) > int(oldtmp) + is_code_changed = int(oldCode) != 0 and rsp.status_code != int(oldCode) + if is_time_up or is_code_changed: + self.stripchat_play = f'{rsp.status_code} {timestp} {username}' + self.log('计划更新') + self.update_vod(username) + if is_code_changed: + self.log('code变更') + self.post('http://127.0.0.1:9978/action?do=refresh&type=player') + return [404, 'text/plain', ''] + if rsp.status_code == 403: + rsp = self.session_get( + re.sub(r'(_\d+p\d*)?\.m3u8', '_160p_blurred.m3u8', url) + ) + if rsp.status_code != 200: + return [404, 'text/plain', ''] + data = ( + self.process_m3u8(rsp.text) + if '#EXT-X-MOUFLON:URI:' in rsp.text + else rsp.text + ) + return [200, 'application/vnd.apple.mpegur', data] + + URL_PATTERN = re.compile( + r'https://media-hls\.doppiocdn\.\w+/b-hls-\d+/media\.mp4' + ) + MAP_URI_PATTERN = re.compile(r'URI=["\']?(https?://[^\s"\'<>]+)["\']?') + MOUFLON_TAIL_PATTERN = re.compile(r'(_part\d+)?\.mp4$') + + def process_m3u8(self, content): + lines = content.strip().split('\n') + for i, line in enumerate(lines): + if line.startswith('#EXT-X-MOUFLON:URI:') and 'media.mp4' in lines[i + 1]: + mouflon = line.split(':', 2)[2].strip() + encrypted = self.MOUFLON_TAIL_PATTERN.sub('', mouflon).rsplit('_', 2)[1] + new_url = mouflon.replace( + encrypted, self._decode(encrypted[::-1], self.stripchat_key) + ) + proxy_url = f'{self.getProxyUrl()}&type=media&url={quote(new_url)}' + lines[i + 1] = self.URL_PATTERN.sub(proxy_url, lines[i + 1]) + elif line.startswith('#EXT-X-MAP:URI'): + match = self.MAP_URI_PATTERN.search(line) + if match: + original_url = match.group(1) + proxy_url = ( + f'{self.getProxyUrl()}&type=media&url={quote(original_url)}' + ) + lines[i] = line.replace(original_url, proxy_url) + return '\n'.join(lines) + + def country_code_to_flag(self, code): + return ( + ''.join( + chr(ord(c.upper()) - ord('A') + 0x1F1E6) + for c in code + if len(code) == 2 and code.isalpha() + ) + if len(code) == 2 and code.isalpha() + else code + ) + + @staticmethod + @lru_cache(maxsize=20) + def _decode(encrypted_b64: str, key_b64: str) -> str: + encrypted_b64 += '=' * (4 - len(encrypted_b64) % 4) + key_bytes = base64.b64decode(key_b64) + encrypted = base64.b64decode(encrypted_b64) + decrypted = bytearray(len(encrypted)) + for i in range(len(encrypted)): + decrypted[i] = encrypted[i] ^ (key_bytes[i % len(key_bytes)] & 0xFF) + return decrypted.decode('utf-8') + + def create_session_with_retry(self): + self.session = requests.Session() + retry = Retry( + total=2, + backoff_factor=0.2, + status_forcelist=[408, 429, 500, 502, 503, 504], + raise_on_status=False, + ) + adapter = requests.adapters.HTTPAdapter( + max_retries=retry, pool_connections=20, pool_maxsize=50, pool_block=False + ) + self.session.mount('http://', adapter) + self.session.mount('https://', adapter) + + def session_get(self, url, headers=None, stream=False, timeout=(3, 5)): + return self.session.get( + url, + headers=self.headers if headers is None else headers, + timeout=timeout, + stream=stream, + allow_redirects=True, + ) + + def start_danmu(self, room_id): + try: + entry = self.danmu_threads.get(room_id) + if entry: + t, _ = entry + if t.is_alive(): + self.log(f'弹幕线程已存在: {room_id}') + return + for rid, (ot, stop_evt) in list(self.danmu_threads.items()): + if rid == room_id: + continue + if ot.is_alive(): + self.log(f'正在关闭其他房间弹幕线程: {rid}') + stop_evt.set() + ot.join(timeout=1.0) + del self.danmu_threads[rid] + stop_event = threading.Event() + t = threading.Thread( + target=self._danmu_poll_worker, + args=(room_id, stop_event), + daemon=True, + ) + self.danmu_threads[room_id] = (t, stop_event) + t.start() + self.log(f'弹幕线程启动: {room_id}') + except Exception as e: + self.log(f'弹幕线程启动失败: {e}') + + def _danmu_poll_worker(self, room_id, stop_event): + while True: + if self.base_url: + try: + r = self.fetch(f'{self.base_url}/media', timeout=1).json() + if not r.get('state', False): + stop_event.set() + except: + stop_event.set() + if stop_event.is_set(): + break + try: + self.fetch_chat_once(room_id) + if stop_event.wait(5): + break + except Exception as e: + self.log(f'弹幕轮询异常 {room_id}: {e}') + if stop_event.wait(10): + break + self.log(f'弹幕线程结束: {room_id}') + + def fetch_chat_once(self, room_id): + path = f'/api/front/v2/models/{room_id}/chat?source=regular&uniq={int(time.time()*1000)}' + data = self._request_with_failover(path) + + arr = data.get('messages') if isinstance(data, dict) else [] + if not isinstance(arr, list): + return 0 + newId, newMsg = 0, [] + with self.danmu_lock: + cache = self.danmu_cache.get(room_id, {}) + oldId = cache.get('id', 0) + cacheMsg = cache.get('msg', []) + for raw in reversed(arr): + id = int(raw.get('id', 0)) + if oldId and id <= oldId: + break + if not newId: + newId = id + item = self.normalize_chat_message(raw) + if not item: + continue + newMsg.append(item) + if newId: + if newMsg: + cacheMsg = newMsg + cacheMsg + cacheMsg = cacheMsg[:30] + self.danmu_cache[room_id] = {'id': newId, 'msg': cacheMsg} + if oldId: + for m in reversed(newMsg): + self.send_live_danmaku(m) + time.sleep(0.15) + + def replace_emoji(self, text: str) -> str: + emoji_map = { + ':heart:': '❤️', + ':dancing:': '💃', + ':thumbsup:': '👍', + ':flower:': '🌹', + ':lol:': '😄', + ':flirt:': '😉', + ':devil:': '😈', + ':hideeyes:': '🙈', + ':ask:': '❓', + ':inlove:': '😍', + ':tongue:': '😛', + ':cry:': '😭', + ':fire:': '🔥', + ':asking:': '🤔', + ':wink:': '😉', + ':ok:': '👌', + ':shy:': '😳', + ':angry:': '😡', + ':facepalm:': '🤦‍♂️', + ':ass:': '🍑', + } + for code, emoji_char in emoji_map.items(): + text = text.replace(code, emoji_char) + return text + + def normalize_chat_message(self, msg): + try: + if not isinstance(msg, dict): + return None + details = msg.get('details') or {} + text = ( + msg.get('text') + or msg.get('message') + or msg.get('content') + or msg.get('body') + or '' + ) + if not text and isinstance(details, dict): + text = ( + details.get('body') + or details.get('message') + or details.get('text') + or '' + ) + if isinstance(text, dict): + text = text.get('text') or text.get('body') or '' + tp = msg.get('type') or '' + if not text and tp == 'tip': + amount = ( + details.get('amount') or details.get('tokens') or '' + if isinstance(details, dict) + else '' + ) + text = f'打赏 {amount} tk' if amount else '打赏' + if not text and tp == 'lovense': + text = 'Lovense互动' + ud = msg.get('userData') or msg.get('user') or msg.get('sender') or {} + user = '' + if isinstance(ud, dict): + user = ud.get('username') or ud.get('name') or ud.get('login') or '' + elif isinstance(ud, str): + user = ud + if not user: + user = msg.get('username') or msg.get('userName') or '' + text, user = str(text).strip(), str(user).strip() + if not text: + return None + return { + 'time': msg.get('createdAt'), + 'user': user[:32], + 'text': self.replace_emoji(text)[:120], + } + except Exception as e: + self.log(f'弹幕解析失败: {e}') + return None + + def send_live_danmaku(self, item): + try: + text = str(item.get('text', '')).strip() + user = str(item.get('user', '')).strip() + show = (f'{user}: {text}' if user else text)[:80] + ok = self.call_local_action( + f'do=danmaku&text={quote(show)}', f'实时弹幕发送: {show}' + ) + if not ok: + self.log('实时弹幕 action 未确认') + except Exception as e: + self.log(f'实时弹幕发送失败: {e}') + + def get_danmaku_desc(self, room_id): + cache = self.danmu_cache.get(room_id, {}) + cacheMsg = cache.get('msg', []) + msg = [] + for item in cacheMsg: + t = self.datetime_utc8(item.get('time'), '%H:%M') + text = str(item.get('text', '')).strip() + user = str(item.get('user', '')).strip() + show = f'{t} {user}: {text}' if user else f'{t} {text}' + msg.append(show) + return '\n'.join(msg) + + def get_action_bases(self): + bases = [] + try: + p = urlparse(self.getProxyUrl()) + if p.scheme and p.netloc: + bases.append(f'{p.scheme}://{p.netloc}') + except Exception: + pass + for b in ['http://127.0.0.1:9978', 'http://127.0.0.1:9979']: + if b not in bases: + bases.append(b) + return bases + + base_url = '' + + def call_local_action(self, query, log_name): + for base in [self.base_url] if self.base_url else self.get_action_bases(): + try: + url = f'{base}/action?{query}' + r = self.fetch(url, timeout=1) + if r.text.strip() == 'OK': + self.base_url = base + self.log(log_name) + return True + except: + continue + self.log(f'失败: {log_name}') + return False diff --git a/FGBLH/py/多瑙影院.py b/FGBLH/py/多瑙影院.py new file mode 100644 index 00000000..2215ad46 --- /dev/null +++ b/FGBLH/py/多瑙影院.py @@ -0,0 +1,512 @@ +# -*- coding: utf-8 -*- +# 多瑙影院 dnvod.org +# 兼容 FongMi/TV 与 WebHomeTV/PeekPro 的 Python Spider + +import sys +import re +import json +import time +from html import unescape +from urllib.parse import urlencode, quote, urljoin + +try: + from concurrent.futures import ThreadPoolExecutor, as_completed +except Exception: + ThreadPoolExecutor = None + as_completed = None + +sys.path.append('..') + +try: + from base.spider import Spider as BaseSpider +except ImportError: + import requests as rq + + class BaseSpider: + def fetch(self, url, headers=None, **kw): + kw.pop('timeout', None) + r = rq.get(url, headers=headers, timeout=30, **kw) + r.encoding = 'utf-8' + return r + + +class Spider(BaseSpider): + + def getName(self): + return '多瑙影院' + + def init(self, extend=''): + self.host = 'https://dnvod.org' + if isinstance(extend, str) and extend.startswith('http'): + self.host = extend.rstrip('/') + self._home_cache = [] + self._home_cache_time = 0 + self.header = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36', + 'Referer': self.host + '/', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + } + self._type_map = { + 'tv': '电视剧', + 'movie': '电影', + 'show': '综艺', + 'anime': '动漫', + 'doc': '纪录片', + } + + # ---------- 基础工具 ---------- + def _txt(self, url, referer=None, timeout=30): + headers = dict(self.header) + if referer: + headers['Referer'] = referer + try: + rsp = self.fetch(url, headers=headers, timeout=timeout) + try: + rsp.encoding = 'utf-8' + except Exception: + pass + return rsp.text + except Exception: + return '' + + def _json(self, url, referer=None, timeout=20): + headers = dict(self.header) + headers['X-Requested-With'] = 'XMLHttpRequest' + if referer: + headers['Referer'] = referer + try: + rsp = self.fetch(url, headers=headers, timeout=timeout) + if hasattr(rsp, 'json'): + return rsp.json() + return json.loads(rsp.text) + except Exception: + return {} + + def _url(self, path): + if not path: + return '' + if path.startswith('//'): + return 'https:' + path + return urljoin(self.host + '/', path) + + def _clean(self, text): + if not text: + return '' + text = re.sub(r'(?is)|', '', text) + text = re.sub(r'(?is)', ' ', text) + text = re.sub(r'(?is)<.*?>', '', text) + text = unescape(text).replace('\xa0', ' ') + return re.sub(r'\s+', ' ', text).strip() + + def _match(self, pattern, text, default='', flags=re.S): + m = re.search(pattern, text or '', flags) + return self._clean(m.group(1)) if m else default + + def _split_id(self, vod_id): + if isinstance(vod_id, list): + vod_id = vod_id[0] + vod_id = str(vod_id or '') + if '$' in vod_id: + cate, vid = vod_id.split('$', 1) + else: + m = re.search(r'/(tv|movie|show|anime|doc)/detail/(\d+)', vod_id) + if m: + cate, vid = m.group(1), m.group(2) + else: + cate, vid = 'movie', vod_id + return cate, vid + + def _source_name(self, src_site): + src_site = (src_site or '').lower() + table = { + 'xlzy': 'XL', + 'jyzy': 'JY', + 'mdzy': 'MD', + 'hnzy': 'HN', + 'gszy': 'GS', + 'jszy': 'JS', + 'yhzy': 'YH', + } + return table.get(src_site, src_site.replace('zy', '').upper() or '默认') + + def _episode_to_api(self, play_path): + # /play/202642024-ep40 -> 202642024, ep40 + # /play/202642181-m -> 202642181, m + play_path = str(play_path or '').split('#')[0].strip('/') + play_path = play_path.replace('play/', '') + if '-' in play_path: + vid, ep = play_path.split('-', 1) + else: + vid, ep = play_path, '' + return vid, ep + + def _normalize_episodes(self, episodes): + """ + 修正 dnvod 选集页常见问题: + 1. 页面经常倒序显示:40、39、38... + 2. 页面只给一部分集数,缺失的集数接口会 404,不能硬补假集。 + 3. 有些页面中间会漏几集,只按源站实际存在的链接做正序排序。 + """ + if not episodes: + return [] + + nums = [] + for title, path in episodes: + m = re.search(r'play/\d+-ep(\d+)', path) + if m: + try: + nums.append(int(m.group(1))) + except Exception: + pass + + # 电影或只有少量非数字选集时,保持页面原有顺序;如果是数字集数则做正序排序。 + def sort_key(item): + m = re.search(r'-ep(\d+)', item[1]) + return int(m.group(1)) if m else 999999 + + if nums: + return sorted(episodes, key=sort_key) + return episodes + + def _play_api_url(self, play_path): + vid, ep = self._episode_to_api(play_path) + return self.host + '/vod_plays/{}/{}'.format(vid, ep) + + def _resolve_m3u8_child(self, m3u8_url, referer=''): + """部分 Exo 对主 m3u8 跳转不稳定,优先解析到子 m3u8。""" + try: + text = self._txt(m3u8_url, referer=referer or self.host + '/', timeout=15) + if not text or '#EXTM3U' not in text: + return m3u8_url + lines = [x.strip() for x in text.splitlines() if x.strip()] + for i, line in enumerate(lines): + if line.startswith('#EXT-X-STREAM-INF'): + for nxt in lines[i + 1:]: + if nxt and not nxt.startswith('#'): + return urljoin(m3u8_url, nxt) + return m3u8_url + except Exception: + return m3u8_url + + # ---------- 首页 ---------- + def homeContent(self, filter): + classes = [ + {'type_id': 'movie', 'type_name': '电影'}, + {'type_id': 'tv', 'type_name': '电视剧'}, + {'type_id': 'show', 'type_name': '综艺'}, + {'type_id': 'anime', 'type_name': '动漫'}, + {'type_id': 'doc', 'type_name': '纪录片'}, + ] + result = {'class': classes} + if filter: + result['filters'] = { + 'movie': self._filters('movie'), + 'tv': self._filters('tv'), + 'show': self._filters('show'), + 'anime': self._filters('anime'), + 'doc': self._filters('doc'), + } + return result + + def _filters(self, cate): + common_region = [ + {'n': '全部', 'v': ''}, + {'n': '大陆', 'v': 'cn'}, + {'n': '港台', 'v': 'hk_tw'}, + {'n': '日韩', 'v': 'jp_kr'}, + {'n': '欧美', 'v': 'west'}, + {'n': '东南亚', 'v': 'sea'}, + {'n': '其他', 'v': 'other'}, + ] + anime_region = [ + {'n': '全部', 'v': ''}, + {'n': '日本', 'v': 'jp'}, + {'n': '大陆', 'v': 'cn'}, + {'n': '欧美', 'v': 'west'}, + ] + years = [ + {'n': '全部', 'v': ''}, {'n': '2026', 'v': '2026'}, {'n': '2025', 'v': '2025'}, + {'n': '2024', 'v': '2024'}, {'n': '2023', 'v': '2023'}, {'n': '2022', 'v': '2022'}, + {'n': '2021', 'v': '2021'}, {'n': '2020', 'v': '2020'}, + {'n': '2010年代', 'v': 'range__2010_2019'}, {'n': '2000年代', 'v': 'range__2000_2009'}, + {'n': '更早', 'v': 'lt__2000'}, + ] + movie_genres = [ + ('全部', ''), ('喜剧', 'xi-ju'), ('爱情', 'ai-qing'), ('动作', 'dong-zuo'), + ('犯罪', 'fan-zui'), ('科幻', 'ke-huan'), ('奇幻', 'qi-huan'), ('冒险', 'mao-xian'), + ('灾难', 'zai-nan'), ('惊悚', 'jing-song'), ('剧情', 'ju-qing'), ('战争', 'zhan-zheng'), + ('歌舞', 'ge-wu'), ('经典', 'jing-dian'), ('悬疑', 'xuan-yi'), + ] + show_genres = [ + ('全部', ''), ('真人秀', 'zhen-ren-xiu'), ('搞笑', 'gao-xiao'), ('选秀', 'xuan-xiu'), + ('脱口秀', 'tuo-kou-xiu'), ('音乐', 'yin-le'), ('晚会', 'wan-hui'), ('美食', 'mei-shi'), + ('访谈', 'fang-tan'), + ] + anime_genres = [ + ('全部', ''), ('热血', 're-xue'), ('动作', 'dong-zuo'), ('战争', 'zhan-zheng'), + ('青春', 'qing-chun'), ('治愈', 'zhi-yu'), ('运动', 'yun-dong'), ('科幻', 'ke-huan'), + ('魔幻', 'mo-huan'), ('冒险', 'mao-xian'), ('推理', 'tui-li'), ('搞笑', 'gao-xiao'), + ('校园', 'xiao-yuan'), ('百合', 'bai-he'), + ] + filters = [] + if cate in ('movie', 'show', 'anime'): + genres = movie_genres if cate == 'movie' else show_genres if cate == 'show' else anime_genres + filters.append({'key': 'genre', 'name': '分类', 'value': [{'n': n, 'v': v} for n, v in genres]}) + filters.append({'key': 'region', 'name': '地区', 'value': anime_region if cate == 'anime' else common_region}) + filters.append({'key': 'year', 'name': '年代', 'value': years}) + return filters + + def homeVideoContent(self): + now = int(time.time()) + if self._home_cache and now - self._home_cache_time < 300: + return {'list': self._home_cache[:72]} + + urls = [ + self.host + '/movie/list/', + self.host + '/tv/list/', + self.host + '/show/list/', + self.host + '/anime/list/', + self.host + '/doc/list/', + ] + videos, seen = [], set() + + def load(url): + return self._parse_cards(self._txt(url, timeout=12)) + + try: + if ThreadPoolExecutor and as_completed: + pool = ThreadPoolExecutor(max_workers=5) + futures = [pool.submit(load, u) for u in urls] + try: + for fu in as_completed(futures, timeout=18): + for v in fu.result() or []: + vid = v.get('vod_id') + if vid and vid not in seen: + seen.add(vid) + videos.append(v) + if len(videos) >= 72: + break + if len(videos) >= 72: + break + finally: + try: + pool.shutdown(wait=False) + except Exception: + pass + else: + for u in urls: + for v in load(u): + vid = v.get('vod_id') + if vid and vid not in seen: + seen.add(vid) + videos.append(v) + if len(videos) >= 72: + break + except Exception: + pass + + if not videos: + videos = self._parse_cards(self._txt(self.host + '/', timeout=20)) + + self._home_cache = videos[:72] + self._home_cache_time = now + return {'list': self._home_cache} + + # ---------- 列表解析 ---------- + def _parse_cards(self, html): + if not html: + return [] + videos, seen = [], set() + pattern = re.compile(r'href=["\']/(movie|tv|show|anime|doc)/detail/(\d+)["\']', re.I) + matches = list(pattern.finditer(html)) + for m in matches: + cate, vid = m.group(1), m.group(2) + key = cate + '$' + vid + if key in seen: + continue + seen.add(key) + pos = m.start() + window = html[pos:pos + 1800] + back = html[max(0, pos - 600):pos + 800] + title = self._match(r']+class=["\'][^"\']*text-left\s+text-truncate\s+text-dark[^"\']*["\'][^>]*>(.*?)', window) + if not title: + title = self._match(r'href=["\']/%s/detail/%s["\'][^>]*>(.*?)' % (cate, vid), window) + pic = self._match(r']+src=["\']([^"\']+)["\']', window) + if not pic: + pic = self._match(r']+src=["\']([^"\']+)["\']', back) + lines = [self._clean(x) for x in re.findall(r']+class=["\'][^"\']*small\s+text-truncate[^"\']*["\'][^>]*>(.*?)', window, re.S)] + remarks = '' + for x in lines: + if x and ('人气' in x or '第' in x or 'HD' in x or '4K' in x or 'TC' in x or '正片' in x): + remarks = x + break + if not remarks and lines: + remarks = lines[0] + if title and len(title) < 80: + videos.append({ + 'vod_id': key, + 'vod_name': title, + 'vod_pic': self._url(pic), + 'vod_remarks': remarks, + }) + return videos + + # ---------- 分类 ---------- + def categoryContent(self, tid, pg, filter, extend): + tid = tid or 'movie' + pg = str(pg or '1') + params = {} + extend = extend or {} + for k in ('genre', 'region', 'year'): + v = extend.get(k) if isinstance(extend, dict) else '' + if v: + params[k] = v + if pg != '1': + params['page'] = pg + url = self.host + '/{}/list/'.format(tid) + if params: + url += '?' + urlencode(params) + html = self._txt(url, timeout=25) + videos = self._parse_cards(html) + pagecount = int(pg) + 1 if videos else int(pg) + return { + 'list': videos, + 'page': int(pg), + 'pagecount': pagecount, + 'limit': len(videos) or 48, + 'total': pagecount * (len(videos) or 48), + } + + # ---------- 搜索 ---------- + def searchContent(self, key, quick, pg='1'): + params = {'q': key or ''} + if str(pg or '1') != '1': + params['page'] = str(pg) + url = self.host + '/search?' + urlencode(params) + html = self._txt(url, timeout=25) + return {'list': self._parse_cards(html)} + + def searchContentPage(self, key, quick, pg): + return self.searchContent(key, quick, pg) + + # ---------- 详情 ---------- + def detailContent(self, ids): + cate, vid = self._split_id(ids) + detail_url = self.host + '/{}/detail/{}'.format(cate, vid) + html = self._txt(detail_url, timeout=25) + + name = self._match(r']*class=["\'][^"\']*title[^"\']*["\'][^>]*>(.*?)', html) + if not name: + name = self._match(r'(.*?)在线', html) + pic = self._match(r'<img[^>]+alt=["\']%s["\'][^>]+src=["\']([^"\']+)["\']' % re.escape(name), html) + if not pic: + pic = '/vod-img/{}.jpg'.format(vid) + + type_name = self._match(r'分类:\s*(.*?)</div>', html) + year = self._match(r'年份:\s*(.*?)</div>', html) + area = self._match(r'区域:\s*(.*?)</div>', html) + lang = self._match(r'语言:\s*(.*?)</div>', html) + director = self._match(r'导演:\s*(.*?)</div>', html) + actor = self._match(r'主演:</span>(.*?)<br>', html) + desc = self._match(r'<small[^>]+class=["\']text-secondary["\'][^>]*>(.*?)</small>', html) + + episodes = [] + for href, title in re.findall(r'<a[^>]+class=["\'][^"\']*ep-btn[^"\']*["\'][^>]+href=["\'](/play/[^"\']+)["\'][^>]*>(.*?)</a>', html, re.S): + title = self._clean(title) or '播放' + play_path = href.split('#')[0].strip('/') + if play_path and (title, play_path) not in episodes: + episodes.append((title, play_path)) + episodes = self._normalize_episodes(episodes) + + play_from = ['多瑙优选'] + play_urls = ['#'.join(['{}${}'.format(n, p) for n, p in episodes])] + + # 用最新一集探测线路,生成 XL/JY/MD 等可切换线路。 + # 不能用第一集,因为源站有些剧前几集接口不存在,会导致线路探测失败。 + if episodes: + probe_ep = episodes[-1][1] + api = self._play_api_url(probe_ep) + data = self._json(api, referer=self.host + '/' + probe_ep, timeout=15) + lines = data.get('video_plays') or [] + if lines: + play_from, play_urls = [], [] + for idx, item in enumerate(lines): + line_name = self._source_name(item.get('src_site')) + if line_name in play_from: + line_name = line_name + str(idx + 1) + play_from.append(line_name) + play_urls.append('#'.join(['{}${}@@{}'.format(n, p, idx) for n, p in episodes])) + + if not play_urls or not play_urls[0]: + play_from = ['多瑙'] + play_urls = ['暂无播放$'] + + vod = { + 'vod_id': cate + '$' + vid, + 'vod_name': name, + 'vod_pic': self._url(pic), + 'type_name': type_name, + 'vod_year': year, + 'vod_area': area, + 'vod_lang': lang, + 'vod_actor': actor, + 'vod_director': director, + 'vod_content': desc, + 'vod_play_from': '$$$'.join(play_from), + 'vod_play_url': '$$$'.join(play_urls), + } + return {'list': [vod]} + + # ---------- 播放 ---------- + def playerContent(self, flag, id, vipFlags): + # id: play/202642024-ep40@@线路序号 + play_id = str(id or '') + line_idx = None + if '@@' in play_id: + play_id, idx = play_id.rsplit('@@', 1) + try: + line_idx = int(idx) + except Exception: + line_idx = None + + api = self._play_api_url(play_id) + data = self._json(api, referer=self.host + '/' + play_id, timeout=20) + plays = data.get('video_plays') or [] + + url = '' + if line_idx is not None and 0 <= line_idx < len(plays): + url = plays[line_idx].get('play_data') or '' + if not url and plays: + # 默认优先 XL/JY/MD,这几类通常对 Exo/MPV 更稳定。 + priority = ['xlzy', 'jyzy', 'mdzy', 'hnzy', 'gszy', 'jszy', 'yhzy'] + for p in priority: + for item in plays: + if (item.get('src_site') or '').lower() == p and item.get('play_data'): + url = item.get('play_data') + break + if url: + break + if not url: + url = plays[0].get('play_data') or '' + + if url and url.startswith('//'): + url = 'https:' + url + if '.m3u8' in url: + url = self._resolve_m3u8_child(url, referer=self.host + '/' + play_id) + + return { + 'parse': 0, + 'playUrl': '', + 'url': url, + 'header': { + 'User-Agent': self.header['User-Agent'], + 'Referer': self.host + '/', + }, + 'format': 'application/x-mpegURL' if '.m3u8' in url else '', + 'contentType': 'application/x-mpegURL' if '.m3u8' in url else '', + } + + # ---------- 本地代理 ---------- + def localProxy(self, params): + return [404, 'text/plain', {}, b'not found'] diff --git a/FGBLH/py/太乙影视.py b/FGBLH/py/太乙影视.py new file mode 100644 index 00000000..046af2af --- /dev/null +++ b/FGBLH/py/太乙影视.py @@ -0,0 +1,1042 @@ +# -*- coding: utf-8 -*- +""" +太乙电影 — 兼容 FongMi/TV (T3) 与 WebHomeTV/PeekPro (T4) 双壳子 +修复: 分页加载 + 播放解析 +站点: https://ww98.taiee.xyz/ +版本: 3.1.0 +""" +import sys +import json +import re +import time +import base64 +from urllib.parse import urljoin, quote, unquote + +sys.path.append('..') + +# ===== 兼容导入 ===== +try: + from base.spider import Spider +except ImportError: + import requests as rq + class Spider: + def fetch(self, url, headers=None, **kw): + kw.pop('timeout', None) + r = rq.get(url, headers=headers, timeout=15, **kw) + r.encoding = 'utf-8' + return r + + +class Spider(Spider): + """太乙电影 Spider — 修复分页和播放""" + + def getName(self): + return "太乙电影" + + def init(self, extend=""): + if isinstance(extend, list): + self.extend = '' + else: + self.extend = extend or '' + + self.host = "https://ww98.taiee.xyz" + self.api_base = self.host + "/api.php/provide/vod/" + + # 完整请求头 + self.header = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8', + 'Accept-Encoding': 'gzip, deflate, br', + 'Referer': self.host + '/', + 'Connection': 'keep-alive', + 'Upgrade-Insecure-Requests': '1', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Sec-Fetch-User': '?1', + 'Cache-Control': 'max-age=0', + } + + self.api_header = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 13; SM-G998B) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36', + 'Accept': 'application/json, text/javascript, */*; q=0.01', + 'Accept-Language': 'zh-CN,zh;q=0.9', + 'X-Requested-With': 'XMLHttpRequest', + 'Referer': self.host + '/', + } + + self._home_cache = [] + self._home_cache_time = 0 + self._class_list = [] + self._use_api = None + # 分页缓存 + self._page_cache = {} + self._api_total_info = None + + # ========== 网络请求封装 ========== + def _fetch_json(self, url, timeout=30): + try: + rsp = self.fetch(url, headers=self.api_header, timeout=timeout) + try: + rsp.encoding = "utf-8" + except Exception: + pass + text = rsp.text + if text.startswith('(') and text.endswith(')'): + text = text[1:-1] + return json.loads(text) + except Exception: + return None + + def _fetch_html(self, url, referer=None, timeout=30): + headers = dict(self.header) + if referer: + headers["Referer"] = referer + try: + rsp = self.fetch(url, headers=headers, timeout=timeout) + try: + rsp.encoding = "utf-8" + except Exception: + pass + text = rsp.text + if '520' in text[:1000] and 'cloudflare' in text[:1000].lower(): + return "" + return text + except Exception: + return "" + + def _match(self, pattern, text, flags=0): + m = re.search(pattern, text, flags) + return m.group(1) if m else "" + + def _url(self, path): + if not path: + return "" + if path.startswith("http"): + return path + return urljoin(self.host, path) + + def _is_direct_media(self, url): + url = (url or "").lower() + return ".m3u8" in url or ".mp4" in url or ".flv" in url or ".mkv" in url + + def _is_official_source(self, url): + url = (url or "").lower() + keys = ( + "mgtv.com", "youku.com", "iqiyi.com", "qiyi.com", + "v.qq.com", "qq.com", "bilibili.com", "le.com", + "sohu.com", "pptv.com", "1905.com", + ) + return any(k in url for k in keys) and not self._is_direct_media(url) + + def _aes_cbc_decrypt_text(self, cipher_text): + try: + from Crypto.Cipher import AES + key = cipher_text[-32:-16].encode("utf-8") + iv = cipher_text[-16:].encode("utf-8") + data = base64.b64decode(cipher_text[:-32]) + raw = AES.new(key, AES.MODE_CBC, iv).decrypt(data) + pad = raw[-1] + if 0 < pad <= 16: + raw = raw[:-pad] + return raw.decode("utf-8", "ignore") + except Exception: + return "" + + def _decode_bfq_result(self, result): + if not result: + return {} + text = self._aes_cbc_decrypt_text(result) + if not text: + return {} + try: + return json.loads(text) + except Exception: + return {} + + def _resolve_official_to_media(self, src_url): + if not src_url or not self._is_official_source(src_url): + return "" + try: + page_url = "https://bfq.txnp.cn/player?url=" + quote(src_url, safe="") + headers = dict(self.header) + headers["Referer"] = "https://bfq.txnp.cn/excessive?url=" + quote(src_url, safe="") + html = self._fetch_html(page_url, referer=headers["Referer"], timeout=20) + result = self._match(r'let\s+result\s*=\s*"([^"]+)"', html, re.S) + data = self._decode_bfq_result(result) + video = ((data.get("video_info") or {}).get("video") or {}) + media = (video.get("url") or "").replace("\\/", "/") + if media and self._is_direct_media(media): + if ".m3u8" in media: + media = self._resolve_m3u8_child(media, referer=page_url) + return media + except Exception: + pass + return "" + + def _resolve_m3u8_child(self, m3u8_url, referer=None): + """ + 有些解析返回的是 master m3u8,部分壳子/播放器兼容性差。 + 这里优先取第一个清晰度子 m3u8;普通 m3u8 原样返回。 + """ + if not m3u8_url or ".m3u8" not in m3u8_url.lower(): + return m3u8_url + try: + headers = dict(self.header) + if referer: + headers["Referer"] = referer + rsp = self.fetch(m3u8_url, headers=headers, timeout=15) + try: + rsp.encoding = "utf-8" + except Exception: + pass + text = rsp.text or "" + if "#EXT-X-STREAM-INF" not in text: + return m3u8_url + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + if ".m3u8" in line.lower(): + return urljoin(m3u8_url, line) + except Exception: + pass + return m3u8_url + + # ========== 探测API ========== + def _detect_api(self): + if self._use_api is not None: + return self._use_api + + urls = [ + self.api_base + "?ac=list", + self.host + "/api.php/provide/vod/?ac=list", + self.host + "/api/provide/vod/?ac=list", + ] + for url in urls: + data = self._fetch_json(url, timeout=10) + if data and (data.get("class") or data.get("list")): + self._use_api = True + self.api_base = url.replace("?ac=list", "") + return True + + self._use_api = False + return False + + # ========== 首页 ========== + def homeContent(self, filter): + classes = [] + + if self._detect_api(): + data = self._fetch_json(self.api_base + "?ac=list", timeout=15) + if data and data.get("class"): + for c in data["class"]: + classes.append({ + "type_id": str(c.get("type_id", "")), + "type_name": c.get("type_name", "") + }) + + if not classes: + html = self._fetch_html(self.host + "/", timeout=15) + if html: + nav_items = re.findall(r'<a[^>]+href=["\']([^"\']*(?:type|vodshow|movie|tv)[^"\']*)["\'][^>]*>([^<]+)</a>', html, re.S) + seen = set() + for href, name in nav_items: + name = re.sub(r'<[^>]+>', '', name).strip() + tid = self._extract_tid_from_url(href) + if tid and tid not in seen and name: + seen.add(tid) + classes.append({"type_id": tid, "type_name": name}) + + if not classes: + classes = [ + {"type_id": "1", "type_name": "电影"}, + {"type_id": "2", "type_name": "电视剧"}, + {"type_id": "3", "type_name": "综艺"}, + {"type_id": "4", "type_name": "动漫"}, + ] + + cleaned = [] + seen_ids = set() + for c in classes: + tid = str(c.get("type_id", "")) + name = str(c.get("type_name", "")).strip() + if not tid or not name: + continue + if name in ("精选", "推荐", "首页"): + continue + if tid in seen_ids: + continue + seen_ids.add(tid) + cleaned.append({"type_id": tid, "type_name": name}) + classes = cleaned + + if self._use_api: + classes = [c for c in classes if str(c.get("type_id", "")) != "0" and c.get("type_name") != "全部"] + classes = [{"type_id": "0", "type_name": "全部"}] + classes + + self._class_list = classes + result = {"class": classes} + + if filter: + result["filters"] = self._build_filters() + + return result + + def _build_filters(self): + filters = {} + year_values = [ + {"n": "全部", "v": ""}, + {"n": "2026", "v": "2026"}, {"n": "2025", "v": "2025"}, + {"n": "2024", "v": "2024"}, {"n": "2023", "v": "2023"}, + {"n": "2022", "v": "2022"}, {"n": "2021", "v": "2021"}, + {"n": "2020", "v": "2020"}, {"n": "2019", "v": "2019"}, + ] + by_values = [ + {"n": "时间", "v": "time"}, {"n": "人气", "v": "hits"}, {"n": "评分", "v": "score"}, + ] + for c in self._class_list: + tid = c.get("type_id", "") + if tid: + filters[tid] = [ + {"key": "year", "name": "年份", "value": year_values}, + {"key": "by", "name": "排序", "value": by_values}, + ] + return filters + + def _extract_tid_from_url(self, url): + m = re.search(r'[type|vodshow|t]=(\d+)', url) + if m: + return m.group(1) + m = re.search(r'/(\d+)\.html', url) + if m: + return m.group(1) + return None + + def homeVideoContent(self): + now = int(time.time()) + if self._home_cache and now - self._home_cache_time < 300: + return {"list": self._home_cache[:72]} + + videos = [] + seen = set() + classes = self._class_list + if not classes: + classes = [ + {"type_id": "1", "type_name": "电影"}, + {"type_id": "2", "type_name": "电视剧"}, + {"type_id": "3", "type_name": "综艺"}, + {"type_id": "4", "type_name": "动漫"}, + ] + + # API获取 + if self._detect_api(): + for c in classes[:4]: + tid = c.get("type_id", "") + if not tid: + continue + url = self.api_base + "?ac=videolist&t=" + tid + "&pg=1&pagesize=24" + data = self._fetch_json(url, timeout=12) + if data and data.get("list"): + for item in data["list"]: + vid = str(item.get("vod_id", "")) + if vid and vid not in seen: + seen.add(vid) + videos.append(self._format_api_vod(item)) + if len(videos) >= 72: + break + if len(videos) >= 72: + break + + # HTML获取 + if not videos: + try: + from concurrent.futures import ThreadPoolExecutor, as_completed + def load(tid): + return self._load_html_list(tid, "1") + pool = ThreadPoolExecutor(max_workers=4) + futures = [pool.submit(load, c.get("type_id", "")) for c in classes[:4]] + try: + for fu in as_completed(futures, timeout=18): + for v in fu.result() or []: + vid = v.get("vod_id") + if vid and vid not in seen: + seen.add(vid) + videos.append(v) + if len(videos) >= 72: + break + finally: + pool.shutdown(wait=False) + except Exception: + pass + + self._home_cache = videos[:72] + self._home_cache_time = now + return {"list": self._home_cache} + + # ========== 分类列表 — 三层fallback ========== + def categoryContent(self, tid, pg, filter, extend): + try: + ext_key = json.dumps(extend or {}, ensure_ascii=False, sort_keys=True) + except Exception: + ext_key = "" + cache_key = str(tid) + "_" + str(pg) + "_" + ext_key + if cache_key in self._page_cache: + cached = self._page_cache[cache_key] + if int(time.time()) - cached.get("_time", 0) < 60: + return cached + + result = self._try_api_category(tid, pg, extend) + if result is not None: + self._page_cache[cache_key] = result + return result + + result = self._try_html_category(tid, pg, extend) + if result and result.get("list"): + self._page_cache[cache_key] = result + return result + + result = self._try_common_category(tid, pg) + if result and result.get("list"): + self._page_cache[cache_key] = result + return result + + return {"list": [], "page": pg, "pagecount": 999, "limit": 20, "total": 9999} + + def _get_api_total_info(self): + if self._api_total_info: + return self._api_total_info + info = {"pagecount": 999, "total": 9999, "limit": 20} + try: + data = self._fetch_json(self.api_base + "?ac=videolist&pg=1&pagesize=20", timeout=15) + if data: + info["pagecount"] = int(data.get("pagecount") or 999) + info["total"] = int(data.get("total") or 9999) + info["limit"] = int(data.get("limit") or 20) + except Exception: + pass + self._api_total_info = info + return info + + def _try_api_category(self, tid, pg, extend): + if not self._detect_api(): + return None + + tid = str(tid or "") + pg = str(pg or "1") + is_all = tid in ("0", "all", "全部", "") + params = "?ac=videolist&pg=" + pg + "&pagesize=20" + if not is_all: + params += "&t=" + tid + if extend: + year = extend.get("year", "") + by = extend.get("by", "") + if year: + params += "&year=" + year + if by: + params += "&by=" + by + + data = self._fetch_json(self.api_base + params, timeout=30) + if not data: + return None + + videos = [] + if data.get("list"): + for item in data["list"]: + videos.append(self._format_api_vod(item)) + + page = int(data.get("page") or pg) + total = int(data.get("total") or 0) + limit = int(data.get("limit") or 20) + api_pagecount = int(data.get("pagecount") or 0) + + if total > 0 and limit > 0: + pagecount = (total + limit - 1) // limit + else: + # 如果API没返回total,根据是否有内容来判断 + pagecount = 999 if len(videos) >= limit else page + if api_pagecount > 0: + pagecount = max(pagecount, api_pagecount) + + total_info = self._get_api_total_info() + if is_all: + pagecount = max(pagecount, total_info.get("pagecount", 999)) + total = max(total, total_info.get("total", 9999)) + + return { + "list": videos, + "page": str(page), + "pagecount": pagecount, + "limit": limit, + "total": total if total > 0 else 9999, + } + + def _try_html_category(self, tid, pg, extend): + urls = [] + cate = extend.get("cate", "") if extend else "" + year = extend.get("year", "") if extend else "" + + if cate or year: + urls.append(self.host + "/vodshow/" + tid + "-" + cate + "--" + year + "------" + pg + ".html") + urls.append(self.host + "/vodshow/" + tid + "-" + cate + "--------" + pg + ".html") + urls.append(self.host + "/vodshow/" + tid + "-----------" + pg + ".html") + urls.append(self.host + "/type/" + tid + "-" + pg + ".html") + urls.append(self.host + "/type/" + tid + ".html") + urls.append(self.host + "/vodtype/" + tid + "-" + pg + ".html") + + for url in urls: + html = self._fetch_html(url, timeout=30) + if html: + videos = self._parse_html_list(html) + if videos: + # 关键修复: 估算总页数,让壳子可以无限翻页 + total_match = self._match(r'共\s*(\d+)\s*条', html) or self._match(r'total\s*[:=]\s*(\d+)', html) + if total_match and total_match.isdigit(): + total = int(total_match) + pagecount = (total + 23) // 24 + else: + # 没拿到总数,给一个很大的数让壳子可以一直翻 + total = 9999 + pagecount = 999 + + return { + "list": videos, + "page": pg, + "pagecount": pagecount, + "limit": 24, + "total": total, + } + return None + + def _try_common_category(self, tid, pg): + return None + + def _load_html_list(self, tid, pg): + urls = [ + self.host + "/vodshow/" + tid + "-----------" + pg + ".html", + self.host + "/type/" + tid + "-" + pg + ".html", + self.host + "/type/" + tid + ".html", + ] + for url in urls: + html = self._fetch_html(url, timeout=12) + if html: + videos = self._parse_html_list(html) + if videos: + return videos + return [] + + # ========== 详情页 ========== + def detailContent(self, ids): + if isinstance(ids, str): + ids = [ids] + vod_id = ids[0] + + vod = self._try_api_detail(vod_id) + if vod: + return {"list": [vod]} + + vod = self._try_html_detail(vod_id) + if vod: + return {"list": [vod]} + + return {"list": []} + + def _try_api_detail(self, vod_id): + if not self._detect_api(): + return None + + data = self._fetch_json(self.api_base + "?ac=detail&ids=" + str(vod_id), timeout=30) + if data and data.get("list") and len(data["list"]) > 0: + return self._format_api_detail(data["list"][0]) + return None + + def _try_html_detail(self, vod_id): + urls = [ + self.host + "/detail/" + str(vod_id) + ".html", + self.host + "/voddetail/" + str(vod_id) + ".html", + self.host + "/vod/" + str(vod_id) + ".html", + ] + + for url in urls: + html = self._fetch_html(url, timeout=30) + if html: + return self._parse_html_detail(html, vod_id, url) + return None + + # ========== 搜索 ========== + def searchContent(self, key, quick, pg="1"): + if self._detect_api(): + url = self.api_base + "?ac=videolist&wd=" + quote(key) + "&pg=" + pg + "&pagesize=24" + data = self._fetch_json(url, timeout=30) + if data and data.get("list"): + videos = [] + for item in data["list"]: + videos.append(self._format_api_vod(item)) + return {"list": videos} + + urls = [ + self.host + "/vodsearch/" + quote(key) + "----------" + pg + ".html", + self.host + "/search.html?wd=" + quote(key) + "&page=" + pg, + self.host + "/search?wd=" + quote(key) + "&page=" + pg, + ] + for url in urls: + html = self._fetch_html(url, timeout=30) + if html: + videos = self._parse_html_list(html) + if videos: + return {"list": videos} + + return {"list": []} + + # ========== 播放解析 — 关键修复 ========== + def playerContent(self, flag, id, vipFlags): + """ + 关键修复: + 1. id 可能是完整URL,也可能是相对路径 + 2. 需要处理苹果CMS的播放链接格式 + 3. 需要处理加密和iframe嵌套 + """ + # 处理id + if not id: + return {"parse": 1, "playUrl": "", "url": ""} + + url = id if str(id).startswith("http") else self._url(id) + + # 如果已经是直链 + if ".m3u8" in url: + return { + "parse": 0, + "playUrl": "", + "url": url, + "header": { + "User-Agent": self.header["User-Agent"], + "Referer": self.host + "/", + }, + "format": "application/x-mpegURL", + "contentType": "application/x-mpegURL", + } + + if ".mp4" in url or ".mkv" in url or ".flv" in url: + return { + "parse": 0, + "playUrl": "", + "url": url, + "header": { + "User-Agent": self.header["User-Agent"], + "Referer": self.host + "/", + }, + } + + if self._is_official_source(url): + resolved = self._resolve_official_to_media(url) + if resolved: + return { + "parse": 0, + "playUrl": "", + "url": resolved, + "header": { + "User-Agent": self.header["User-Agent"], + "Referer": "https://bfq.txnp.cn/", + }, + "format": "application/x-mpegURL" if ".m3u8" in resolved else "", + "contentType": "application/x-mpegURL" if ".m3u8" in resolved else "", + } + + # 获取播放页HTML + html = self._fetch_html(url, referer=self.host + "/", timeout=30) + if not html: + # 如果获取失败,尝试直接返回URL让壳子嗅探 + return { + "parse": 1, + "playUrl": "", + "url": url, + "header": { + "User-Agent": self.header["User-Agent"], + "Referer": self.host + "/", + }, + } + + real = "" + + # 1. player_aaaa JSON (苹果CMS标准) + m = re.search(r'var\s+player_[a-zA-Z0-9_]+\s*=\s*(\{.*?\})\s*</script>', html, re.S) + if m: + try: + data = json.loads(m.group(1)) + real = data.get("url", "") or "" + encrypt = str(data.get("encrypt", "0")) + if encrypt == "1" and real: + try: + real = unquote(real) + except Exception: + pass + elif encrypt == "2" and real: + try: + real = unquote(base64.b64decode(real).decode("utf-8")) + except Exception: + pass + # 处理from字段(播放来源) + from_src = data.get("from", "") + if from_src: + # 有些站点from字段表示播放器类型 + pass + except Exception: + # JSON解析失败,尝试正则提取url + real = self._match(r'"url"\s*:\s*"([^"]+)"', m.group(1)) + + # 2. 其他player变量格式 + if not real: + m2 = re.search(r'var\s+player\s*=\s*["\']([^"\']+)["\']', html, re.S) + if m2: + real = m2.group(1) + if real.startswith("//"): + real = "https:" + real + + # 3. iframe嵌套 + if not real: + iframe = re.search(r'<iframe[^>]+src=["\']([^"\']+)["\']', html, re.S) + if iframe: + iframe_url = self._url(iframe.group(1)) + iframe_html = self._fetch_html(iframe_url, referer=url, timeout=30) + if iframe_html: + # 在iframe内容中找播放地址 + real = self._match(r'"url"\s*:\s*"([^"]+)"', iframe_html) + if not real: + real = self._match(r'["\'](https?://[^"\']+\.(?:m3u8|mp4)[^"\']*)["\']', iframe_html, re.I) + if not real: + real = self._match(r'src=["\']([^"\']+\.(?:m3u8|mp4))["\']', iframe_html, re.I) + + # 4. 全局匹配m3u8/mp4直链 + if not real: + m3 = re.search(r'["\'](https?://[^"\']+\.(?:m3u8|mp4)[^"\']*)["\']', html, re.I) + if m3: + real = m3.group(1) + + # 5. 匹配data-url或data-src + if not real: + m4 = re.search(r'data-(?:url|src)=["\']([^"\']+)["\']', html, re.S) + if m4: + real = m4.group(1) + if real.startswith("//"): + real = "https:" + real + + # 6. 匹配video标签src + if not real: + m5 = re.search(r'<video[^>]+src=["\']([^"\']+)["\']', html, re.S) + if m5: + real = m5.group(1) + + # 处理获取到的地址 + if real: + real = real.replace("\\/", "/") + if real.startswith("//"): + real = "https:" + real + + if self._is_official_source(real): + resolved = self._resolve_official_to_media(real) + if resolved: + real = resolved + + # 如果是m3u8,解析子m3u8(Exo兼容) + if ".m3u8" in real: + real = self._resolve_m3u8_child(real, referer=url) + + return { + "parse": 0 if self._is_direct_media(real) else 1, + "playUrl": "", + "url": real, + "header": { + "User-Agent": self.header["User-Agent"], + "Referer": url, + }, + "format": "application/x-mpegURL" if ".m3u8" in real else "", + "contentType": "application/x-mpegURL" if ".m3u8" in real else "", + } + + # 都没找到,返回原始URL让壳子自行嗅探 + return { + "parse": 1, + "playUrl": "", + "url": url, + "header": { + "User-Agent": self.header["User-Agent"], + "Referer": url, + }, + } + + # ========== HTML解析 ========== + def _parse_html_list(self, html): + videos = [] + if not html or len(html) < 500: + return videos + + # 模式1: li结构 + items = re.findall( + r'<li[^>]*>.*?<a[^>]+href=["\']([^"\']*(?:detail|vod|play)[^"\']*)["\'][^>]*>.*?' + r'<img[^>]+(?:data-original|src)=["\']([^"\']+)["\'][^>]*>.*?' + r'(?:<[^>]*class=["\'][^"\']*(?:title|name)[^"\']*["\'][^>]*>(.*?)</[^>]*>)?' + r'(?:.*?<[^>]*class=["\'][^"\']*(?:remarks?|note|status|text)[^"\']*["\'][^>]*>(.*?)</[^>]*>)?)?' + r'.*?</li>', + html, re.S + ) + for item in items: + href, pic = item[0], item[1] + title = item[2] if len(item) > 2 else "" + remarks = item[3] if len(item) > 3 else "" + title = re.sub(r'<[^>]+>', '', title).strip() if title else "" + remarks = re.sub(r'<[^>]+>', '', remarks).strip() if remarks else "" + vid = self._extract_vid(href) + if vid: + videos.append({ + "vod_id": str(vid), + "vod_name": title, + "vod_pic": self._url(pic), + "vod_remarks": remarks, + }) + + # 模式2: div卡片 + if not videos: + cards = re.findall( + r'<div[^>]*class=["\'][^"\']*(?:item|card|vod|video|pic|list)[^"\']*["\'][^>]*>.*?' + r'<a[^>]+href=["\']([^"\']*(?:detail|vod|play)[^"\']*)["\'][^>]*>.*?' + r'<img[^>]+(?:data-original|src)=["\']([^"\']+)["\'][^>]*>.*?' + r'(?:<[^>]*class=["\'][^"\']*(?:title|name)[^"\']*["\'][^>]*>(.*?)</[^>]*>)?' + r'(?:.*?<[^>]*class=["\'][^"\']*(?:remarks?|note|status|text)[^"\']*["\'][^>]*>(.*?)</[^>]*>)?)?' + r'.*?</div>', + html, re.S + ) + for card in cards: + href, pic = card[0], card[1] + title = card[2] if len(card) > 2 else "" + remarks = card[3] if len(card) > 3 else "" + title = re.sub(r'<[^>]+>', '', title).strip() if title else "" + remarks = re.sub(r'<[^>]+>', '', remarks).strip() if remarks else "" + vid = self._extract_vid(href) + if vid: + videos.append({ + "vod_id": str(vid), + "vod_name": title, + "vod_pic": self._url(pic), + "vod_remarks": remarks, + }) + + # 模式3: 宽松a+img + if not videos: + links = re.findall( + r'<a[^>]+href=["\']([^"\']*(?:detail|vod|play)[^"\']*)["\'][^>]*>.*?' + r'<img[^>]+(?:data-original|src)=["\']([^"\']+)["\'][^>]*>.*?</a>', + html, re.S + ) + for href, pic in links: + vid = self._extract_vid(href) + if vid: + videos.append({ + "vod_id": str(vid), + "vod_name": "", + "vod_pic": self._url(pic), + "vod_remarks": "", + }) + + return videos + + def _parse_html_detail(self, html, vod_id, url): + vod_name = self._extract_title(html) + vod_pic = self._extract_pic(html) + type_name = self._extract_field(html, [r'类型[::]\s*([^<\n]+)', r'class=["\']type["\'][^>]*>([^<]+)']) + vod_year = self._extract_field(html, [r'年份[::]\s*([^<\n]+)', r'class=["\']year["\'][^>]*>([^<]+)']) + vod_area = self._extract_field(html, [r'地区[::]\s*([^<\n]+)', r'class=["\']area["\'][^>]*>([^<]+)']) + vod_remarks = self._extract_field(html, [r'状态[::]\s*([^<\n]+)', r'class=["\']remarks?["\'][^>]*>([^<]+)', r'class=["\']note["\'][^>]*>([^<]+)']) + vod_actor = self._extract_field(html, [r'主演[::]\s*([^<\n]+)', r'class=["\']actor["\'][^>]*>([^<]+)']) + vod_director = self._extract_field(html, [r'导演[::]\s*([^<\n]+)', r'class=["\']director["\'][^>]*>([^<]+)']) + vod_content = self._extract_content(html) + + play_from_list, play_url_list = self._extract_playlist(html, url) + + return { + "vod_id": str(vod_id), + "vod_name": vod_name or "未知影片", + "vod_pic": vod_pic, + "type_name": type_name or "", + "vod_year": vod_year or "", + "vod_area": vod_area or "", + "vod_remarks": vod_remarks or "", + "vod_actor": vod_actor or "", + "vod_director": vod_director or "", + "vod_content": vod_content or "暂无简介", + "vod_play_from": "$$$".join(play_from_list) if play_from_list else "默认线路", + "vod_play_url": "$$$".join(play_url_list) if play_url_list else "正片$" + url, + } + + def _extract_vid(self, href): + if not href: + return None + patterns = [r'/(\d+)\.html', r'id=(\d+)', r'/(\d+)/?$', r'/(\d+)\.htm'] + for p in patterns: + m = re.search(p, href) + if m: + return m.group(1) + return href + + def _extract_title(self, html): + patterns = [ + r'<h1[^>]*>(.*?)</h1>', r'<h2[^>]*>(.*?)</h2>', + r'class=["\']title["\'][^>]*>(.*?)</[^>]*>', + r'class=["\']name["\'][^>]*>(.*?)</[^>]*>', + r'<title>(.*?)', + ] + for p in patterns: + title = self._match(p, html) + if title: + title = re.sub(r'<[^>]+>', '', title).strip() + if title and title != "太乙电影": + return title + return "" + + def _extract_pic(self, html): + patterns = [ + r']+class=["\'][^"\']*(?:poster|pic|thumb)[^"\']*["\'][^>]+src=["\']([^"\']+)["\']', + r']+src=["\']([^"\']+)["\'][^>]+class=["\'][^"\']*(?:poster|pic|thumb)[^"\']*["\']', + r'poster["\']?\s*[:=]\s*["\']([^"\']+)', + r']+src=["\']([^"\']+)["\'][^>]*class=["\'][^"\']*vod[^"\']*["\']', + ] + for p in patterns: + pic = self._match(p, html) + if pic: + return self._url(pic) + return "" + + def _extract_field(self, html, patterns): + for p in patterns: + val = self._match(p, html) + if val: + val = re.sub(r'<[^>]+>', '', val).strip() + if val: + return val + return "" + + def _extract_content(self, html): + patterns = [ + r'简介[::]\s*]*>\s*]*>(.*?)

', + r'class=["\']desc["\'][^>]*>(.*?)]*>', + r'class=["\']content["\'][^>]*>(.*?)]*>', + r'class=["\']summary["\'][^>]*>(.*?)]*>', + r'剧情[::]\s*<[^>]*>(.*?)]*>', + ] + for p in patterns: + content = self._match(p, html) + if content: + content = re.sub(r'<[^>]+>', '', content).strip() + if content: + return content + return "" + + def _extract_playlist(self, html, referer_url): + play_from_list = [] + play_url_list = [] + + # 模式1: player_list div + blocks = re.findall(r']*class=["\'][^"\']*player_list[^"\']*["\'][^>]*>(.*?)', html, re.S) + if blocks: + for block in blocks: + source = self._match(r']*>(.*?)', block) or self._match(r']*>(.*?)', block) or "默认线路" + source = re.sub(r'<[^>]+>', '', source).strip() + play_from_list.append(source) + eps = re.findall(r']+href=["\']([^"\']+)["\'][^>]*>(.*?)', block, re.S) + ep_strs = [] + for ep_url, ep_name in eps: + ep_name = re.sub(r'<[^>]+>', '', ep_name).strip() + ep_url = self._url(ep_url) + ep_strs.append(ep_name + "$" + ep_url) + play_url_list.append("#".join(ep_strs)) + + # 模式2: ul/li + if not play_from_list: + lists = re.findall(r']*class=["\'][^"\']*(?:play|episode|list)[^"\']*["\'][^>]*>(.*?)', html, re.S) + for lst in lists: + play_from_list.append("默认线路") + eps = re.findall(r']+href=["\']([^"\']+)["\'][^>]*>(.*?)', lst, re.S) + ep_strs = [] + for ep_url, ep_name in eps: + ep_name = re.sub(r'<[^>]+>', '', ep_name).strip() + ep_url = self._url(ep_url) + ep_strs.append(ep_name + "$" + ep_url) + play_url_list.append("#".join(ep_strs)) + + # 模式3: 通用a标签 + if not play_from_list: + eps = re.findall(r']+href=["\']([^"\']*(?:play|vodplay)[^"\']*)["\'][^>]*>(.*?)', html, re.S) + if eps: + play_from_list.append("默认线路") + ep_strs = [] + for ep_url, ep_name in eps: + ep_name = re.sub(r'<[^>]+>', '', ep_name).strip() + ep_url = self._url(ep_url) + ep_strs.append(ep_name + "$" + ep_url) + play_url_list.append("#".join(ep_strs)) + + if not play_from_list: + play_from_list.append("默认线路") + play_url_list.append("正片$" + referer_url) + + return play_from_list, play_url_list + + # ========== API格式化 ========== + def _format_api_vod(self, item): + return { + "vod_id": str(item.get("vod_id", "")), + "vod_name": item.get("vod_name", ""), + "vod_pic": item.get("vod_pic", ""), + "type_name": item.get("type_name", ""), + "vod_year": item.get("vod_year", ""), + "vod_area": item.get("vod_area", ""), + "vod_remarks": item.get("vod_remarks", item.get("vod_serial", "")), + "vod_actor": item.get("vod_actor", ""), + "vod_director": item.get("vod_director", ""), + } + + def _format_api_detail(self, item): + play_from = item.get("vod_play_from", "") + play_url = item.get("vod_play_url", "") + if not play_from: + play_from = "默认线路" + if not play_url: + play_url = "" + return { + "vod_id": str(item.get("vod_id", "")), + "vod_name": item.get("vod_name", ""), + "vod_pic": item.get("vod_pic", ""), + "type_name": item.get("type_name", ""), + "vod_year": item.get("vod_year", ""), + "vod_area": item.get("vod_area", ""), + "vod_remarks": item.get("vod_remarks", item.get("vod_serial", "")), + "vod_actor": item.get("vod_actor", ""), + "vod_director": item.get("vod_director", ""), + "vod_content": item.get("vod_content", ""), + "vod_play_from": play_from, + "vod_play_url": play_url, + } + + # ========== m3u8子解析 ========== + def _resolve_m3u8_child(self, m3u8_url, referer=""): + text = self._fetch_html(m3u8_url, referer=referer or self.host + "/", timeout=20) + if not text or "#EXTM3U" not in text: + return m3u8_url + lines = [x.strip() for x in text.splitlines() if x.strip()] + for i, line in enumerate(lines): + if line.startswith("#EXT-X-STREAM-INF"): + for nxt in lines[i + 1:]: + if nxt and not nxt.startswith("#"): + return urljoin(m3u8_url, nxt) + return m3u8_url + + # ========== 本地代理 ========== + def localProxy(self, param): + return [200, "video/MP2T", b"", ""] + + # ========== 清理 ========== + def destroy(self): + pass + + def close(self): + self.destroy() diff --git a/FGBLH/py/奇点影视.py b/FGBLH/py/奇点影视.py new file mode 100644 index 00000000..6e757290 --- /dev/null +++ b/FGBLH/py/奇点影视.py @@ -0,0 +1,569 @@ +# -*- coding: utf-8 -*- +# 奇点影视 qdys2.cc / qdys1.cc +# 兼容 FongMi/TV 与 WebHomeTV/PeekPro 的 Python Spider + +import sys +import re +import json +import base64 +import time +from html import unescape +from urllib.parse import quote, unquote, urljoin + +try: + from concurrent.futures import ThreadPoolExecutor, as_completed +except Exception: + ThreadPoolExecutor = None + as_completed = None + +sys.path.append('..') + +try: + from base.spider import Spider as BaseSpider +except ImportError: + import requests as rq + + class BaseSpider: + def fetch(self, url, headers=None, **kw): + kw.pop('timeout', None) + r = rq.get(url, headers=headers, timeout=30, **kw) + r.encoding = 'utf-8' + return r + + +class Spider(BaseSpider): + + def getName(self): + return '奇点影视' + + def init(self, extend=''): + self.host = 'https://www.qdys2.cc' + self.backup_host = 'https://www.qdys1.cc' + if isinstance(extend, str) and extend.startswith('http'): + self.host = extend.rstrip('/') + self._home_cache = [] + self._home_cache_time = 0 + self.header = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36', + 'Referer': self.host + '/', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9', + } + + # ---------- 基础工具 ---------- + def _clean(self, text): + if not text: + return '' + text = re.sub(r'(?is)|', '', text) + text = re.sub(r'(?is)', ' ', text) + text = re.sub(r'(?is)<.*?>', '', text) + text = unescape(text).replace('\xa0', ' ') + return re.sub(r'\s+', ' ', text).strip() + + def _match(self, pattern, text, default='', flags=re.S): + m = re.search(pattern, text or '', flags) + return self._clean(m.group(1)) if m else default + + def _url(self, path, host=None): + host = host or self.host + if not path: + return '' + path = path.strip() + if path.startswith('//'): + return 'https:' + path + if path.startswith('http'): + return path + return urljoin(host + '/', path) + + def _headers(self, referer=None): + h = dict(self.header) + h['Referer'] = referer or (self.host + '/') + return h + + def _swap_host(self, url): + if not isinstance(url, str): + return url + if self.host in url: + return url.replace(self.host, self.backup_host) + if self.backup_host in url: + return url.replace(self.backup_host, self.host) + return url + + def _txt(self, url, referer=None, timeout=30): + url = self._url(url) + for u in (url, self._swap_host(url)): + try: + headers = self._headers(referer or u) + rsp = self.fetch(u, headers=headers, timeout=timeout) + try: + rsp.encoding = 'utf-8' + except Exception: + pass + text = rsp.text or '' + # qdys1/2 偶尔有广告页或短错误页,短内容就试备用域名。 + if len(text) > 2000 and '页面不存在' not in text and 'Cloudflare' not in text: + if self.backup_host in u: + text = text.replace(self.backup_host, self.host) + return text + except Exception: + pass + return '' + + def _json_player(self, html): + if not html: + return {} + m = re.search(r'var\s+player_aaaa\s*=\s*(\{.*?\})\s*', html, re.S) + if not m: + m = re.search(r'player_aaaa\s*=\s*(\{.*?\});', html, re.S) + if not m: + return {} + try: + return json.loads(m.group(1)) + except Exception: + try: + return json.loads(m.group(1).replace('\\/', '/')) + except Exception: + return {} + + def _decode_play_url(self, data): + url = data.get('url') or '' + enc = str(data.get('encrypt', '0')) + try: + if enc == '1': + url = unquote(url) + elif enc == '2': + url = unquote(base64.b64decode(url).decode('utf-8')) + except Exception: + pass + return (url or '').replace('\\/', '/') + + def _is_direct_media(self, url): + url = (url or '').lower() + if not url: + return False + if '.m3u8' in url or '.mp4' in url or '.flv' in url: + return True + return False + + def _is_external_sniff_line(self, source_name, data, url): + text = '{} {} {}'.format(source_name or '', data.get('from') or '', url or '').lower() + external_keys = [ + 'tx', 'qq', 'v.qq.com', 'yk', 'youku', 'v.youku.com', + 'qy', 'qiyi', 'iqiyi', 'www.iqiyi.com', 'mgtv', 'bilibili', + ] + return any(k in text for k in external_keys) and not self._is_direct_media(url) + + def _probe_source_url(self, vid, sid, nid): + path = '/play/{}-{}-{}.html'.format(vid, sid, nid) + play_url = self.host + path + html = self._txt(play_url, referer=self.host + '/', timeout=15) + data = self._json_player(html) + url = self._decode_play_url(data) + return data, url + + def _resolve_sniff_to_media(self, url): + if not url or self._is_direct_media(url): + return url + lower = url.lower() + if not any(k in lower for k in ('v.youku.com', 'youku', 'iqiyi.com', 'qiyi.com', 'v.qq.com', 'mgtv.com', 'bilibili.com')): + return '' + try: + page_url = 'https://svip.qlplayer.cyou/?url=' + quote(url, safe='') + page_headers = { + 'User-Agent': self.header['User-Agent'], + 'Referer': self.host + '/', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'zh-CN,zh;q=0.9', + } + rsp = self.fetch(page_url, headers=page_headers, timeout=15) + try: + rsp.encoding = 'utf-8' + except Exception: + pass + html = rsp.text or '' + token = '' + for pattern in ( + r'apiToken\s*:\s*["\']([^"\']+)["\']', + r'["\']apiToken["\']\s*:\s*["\']([^"\']+)["\']', + r'apiToken\s*=\s*["\']([^"\']+)["\']', + ): + m = re.search(pattern, html) + if m: + token = m.group(1) + break + if not token: + return '' + + api = 'https://svip.qlplayer.cyou/api/resolve.php?token=' + quote(token, safe='') + api_headers = { + 'User-Agent': self.header['User-Agent'], + 'Referer': page_url, + 'Origin': 'https://svip.qlplayer.cyou', + 'Accept': '*/*', + 'X-Requested-With': 'mark.via', + } + res = self.fetch(api, headers=api_headers, timeout=15) + try: + res.encoding = 'utf-8' + except Exception: + pass + data = json.loads(res.text or '{}') + media = (data.get('url') or '').replace('\\/', '/') + if media and self._is_direct_media(media): + if '.m3u8' in media: + media = self._resolve_m3u8_child(media, referer=page_url) + return media + except Exception: + pass + return '' + + def _resolve_m3u8_child(self, m3u8_url, referer=''): + try: + text = self._txt(m3u8_url, referer=referer or self.host + '/', timeout=15) + if not text or '#EXTM3U' not in text: + return m3u8_url + lines = [x.strip() for x in text.splitlines() if x.strip()] + for i, line in enumerate(lines): + if line.startswith('#EXT-X-STREAM-INF'): + for nxt in lines[i + 1:]: + if nxt and not nxt.startswith('#'): + return urljoin(m3u8_url, nxt) + return m3u8_url + except Exception: + return m3u8_url + + # ---------- 首页 ---------- + def homeContent(self, filter): + classes = [ + {'type_id': '1', 'type_name': '电影'}, + {'type_id': '2', 'type_name': '电视剧'}, + {'type_id': '3', 'type_name': '综艺'}, + {'type_id': '4', 'type_name': '动漫'}, + {'type_id': '7', 'type_name': '纪录片'}, + {'type_id': '39', 'type_name': '短剧'}, + ] + return {'class': classes} + + def homeVideoContent(self): + now = int(time.time()) + if self._home_cache and now - self._home_cache_time < 300: + return {'list': self._home_cache} + + urls = [self.host + '/list/{}.html'.format(t) for t in ('1', '2', '3', '4', '7', '39')] + videos, seen = [], set() + + def load(url): + return self._parse_cards(self._txt(url, timeout=12)) + + try: + if ThreadPoolExecutor and as_completed: + pool = ThreadPoolExecutor(max_workers=6) + futures = [pool.submit(load, u) for u in urls] + try: + for fu in as_completed(futures, timeout=20): + for v in fu.result() or []: + vid = v.get('vod_id') + if vid and vid not in seen: + seen.add(vid) + videos.append(v) + if len(videos) >= 72: + break + if len(videos) >= 72: + break + finally: + try: + pool.shutdown(wait=False) + except Exception: + pass + else: + for u in urls: + for v in load(u): + vid = v.get('vod_id') + if vid and vid not in seen: + seen.add(vid) + videos.append(v) + if len(videos) >= 72: + break + except Exception: + pass + + if not videos: + videos = self._parse_cards(self._txt(self.host + '/', timeout=20)) + self._home_cache = videos[:72] + self._home_cache_time = now + return {'list': self._home_cache} + + # ---------- 列表解析 ---------- + def _parse_cards(self, html): + if not html: + return [] + videos, seen = [], set() + + # 优先解析标准卡片,能拿到封面和备注。 + blocks = re.findall(r']+class=["\'][^"\']*vod-item[^"\']*["\'][^>]*>(.*?)\s*\s*', html, re.S) + if not blocks: + blocks = re.findall(r'(]+href=["\']/html/\d+\.html["\'][\s\S]{0,900?)', html, re.S) + + for block in blocks: + m = re.search(r'href=["\']/html/(\d+)\.html["\']', block) + if not m: + continue + vid = m.group(1) + if vid in seen: + continue + seen.add(vid) + name = self._match(r'alt=["\']([^"\']+)["\']', block) + if not name: + name = self._match(r']+class=["\'][^"\']*vod-title[^"\']*["\'][^>]*>(.*?)', block) + if not name: + name = self._match(r']+class=["\'][^"\']*hot-name[^"\']*["\'][^>]*>(.*?)', block) + pic = self._match(r'data-original=["\']([^"\']+)["\']', block) + if not pic: + pic = self._match(r']+src=["\']([^"\']+)["\']', block) + remarks = self._match(r']+class=["\'][^"\']*poster-remarks[^"\']*["\'][^>]*>(.*?)', block) + if not remarks: + remarks = self._match(r']+class=["\'][^"\']*pic-text[^"\']*["\'][^>]*>(.*?)', block) + if name and '广告' not in name and '/go/ad/' not in block: + videos.append({ + 'vod_id': vid, + 'vod_name': name, + 'vod_pic': self._url(pic), + 'vod_remarks': remarks, + }) + + # 兜底:WebFetch 结构或聚合页中可能只有文字链接。 + if not videos: + for href, text in re.findall(r']+href=["\']/html/(\d+)\.html["\'][^>]*>(.*?)', html, re.S): + name = self._clean(text) + if not name or href in seen or len(name) > 60: + continue + seen.add(href) + videos.append({ + 'vod_id': href, + 'vod_name': name, + 'vod_pic': self.host + '/static/upload/localized/placeholder.webp', + 'vod_remarks': '', + }) + return videos + + # ---------- 分类 ---------- + def categoryContent(self, tid, pg, filter, extend): + pg = str(pg or '1') + tid = str(tid or '1') + if pg == '1': + url = self.host + '/list/{}.html'.format(tid) + else: + url = self.host + '/list/{}-{}.html'.format(tid, pg) + html = self._txt(url, timeout=25) + videos = self._parse_cards(html) + pagecount = int(pg) + 1 if videos else int(pg) + return { + 'list': videos, + 'page': int(pg), + 'pagecount': pagecount, + 'limit': len(videos) or 30, + 'total': pagecount * (len(videos) or 30), + } + + # ---------- 搜索 ---------- + def searchContent(self, key, quick, pg='1'): + key = key or '' + # 站点搜索路径是 /search/关键词-------------.html + url = self.host + '/search/{}-------------.html'.format(quote(key)) + if str(pg or '1') != '1': + # 常见分页形式,若站点没有分页也会返回第一页或空列表。 + url = self.host + '/search/{}-------------{}---.html'.format(quote(key), pg) + html = self._txt(url, timeout=25) + videos = [] + seen = set() + + # 搜索结果标题在 h3 里,页面侧边还有热榜,必须优先只取 h3 结果,避免混入热榜。 + for m in re.finditer(r']*>\s*]+href=["\']/(?:html/(\d+)\.html|play/(\d+)-\d+-\d+\.html)["\'][^>]*>(.*?)\s*', html, re.S): + vid = m.group(1) or m.group(2) + if not vid or vid in seen: + continue + block = html[max(0, m.start() - 500):m.end() + 800] + name = self._clean(m.group(3)) + if not name: + name = self._match(r'alt=["\']([^"\']+)["\']', block) + pic = self._match(r'data-original=["\']([^"\']+)["\']', block) or self._match(r']+src=["\']([^"\']+)["\']', block) + remarks = self._match(r']+class=["\'][^"\']*pic-text[^"\']*["\'][^>]*>(.*?)', block) + if name and '/go/ad/' not in block: + seen.add(vid) + videos.append({ + 'vod_id': vid, + 'vod_name': name, + 'vod_pic': self._url(pic), + 'vod_remarks': remarks, + }) + if not videos: + videos = self._parse_cards(html) + return {'list': videos} + + def searchContentPage(self, key, quick, pg): + return self.searchContent(key, quick, pg) + + # ---------- 详情 ---------- + def detailContent(self, ids): + vid = ids[0] if isinstance(ids, list) else ids + vid = str(vid or '').strip() + vid = re.sub(r'\D', '', vid) + detail_url = self.host + '/html/{}.html'.format(vid) + html = self._txt(detail_url, timeout=25) + + name = self._match(r']+class=["\'][^"\']*knowledge-title[^"\']*["\'][^>]*>(.*?)', html) + if not name: + name = self._match(r']+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']', html) + pic = self._match(r']+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']', html) + if not pic: + pic = self._match(r'data-original=["\']([^"\']+)["\'][^>]+alt=["\']%s["\']' % re.escape(name), html) + + sub = self._match(r']+class=["\'][^"\']*knowledge-sub[^"\']*["\'][^>]*>(.*?)', html) + parts = [x.strip() for x in re.split(r'[·/]', sub) if x.strip()] + type_name = parts[0] if len(parts) > 0 else '' + year = parts[1] if len(parts) > 1 else '' + area = parts[2] if len(parts) > 2 else '' + lang = parts[3] if len(parts) > 3 else '' + remarks = parts[4] if len(parts) > 4 else '' + + director = self._match(r'\s*导演\s*\s*(.*?)', html) + actor = self._match(r'\s*主演\s*\s*(.*?)', html) + content = self._match(r'

\s*%s剧情介绍\s*

\s*]*>(.*?)

' % re.escape(name), html) + if not content: + content = self._match(r']+name=["\']description["\'][^>]+content=["\']([^"\']+)["\']', html) + + # 详情页不含完整选集,进入第一集播放页解析线路和集数。 + first_play = self._match(r'href=["\'](/play/%s-\d+-\d+\.html)["\']' % vid, html) + if not first_play: + first_play = '/play/{}-1-1.html'.format(vid) + play_html = self._txt(self.host + first_play, referer=detail_url, timeout=25) + + sources = [] + # 只从真正的“播放源”按钮读取线路,避免把“正片、1、2、3...”误识别成线路。 + source_links = re.findall( + r']+class=["\'][^"\']*source-flat[^"\']*["\'][^>]+href=["\'](/play/%s-(\d+)-\d+\.html)["\'][^>]*>(.*?)' % vid, + play_html, + re.S + ) + if not source_links: + source_links = re.findall(r']+href=["\'](/play/%s-(\d+)-\d+\.html)["\'][^>]*>(.*?)' % vid, play_html, re.S) + for href, sid, title in source_links: + title = self._clean(title) + # 线路名必须像 TX线路、4K高清、线路① 这类;纯数字/正片/集数都不是线路。 + if not title or '第' in title or title in ('正片', 'HD'): + continue + if re.fullmatch(r'\d+', title): + continue + if not (('线路' in title) or ('高清' in title) or ('4K' in title.upper())): + continue + if (sid, title) not in sources: + sources.append((sid, title)) + # 先按名字粗排;后面探测后再按直连稳定性重排。 + def source_rank(item): + name = item[1] + if '4K' in name or '高清' in name: + return 0 + if '①' in name or '1' in name: + return 1 + if '②' in name or '2' in name: + return 2 + if '⑨' in name or '9' in name: + return 9 + return 5 + sources = sorted(sources, key=source_rank) + + eps = [] + for href, nid_text, title in re.findall(r']+href=["\'](/play/%s-\d+-(\d+)\.html)["\'][^>]*>(.*?)' % vid, play_html, re.S): + title = self._clean(title) + if not title: + continue + # 播放页选集有的显示“第1集”,有的只显示“1/2/3”,线路按钮则是“YK线路/QY线路”。 + if ('线路' in title) or ('高清' in title) or ('4K' in title.upper()): + continue + if not (('第' in title) or re.fullmatch(r'\d+', title) or title in ('正片', 'HD')): + continue + nid = int(nid_text) + if re.fullmatch(r'\d+', title): + title = '第{}集'.format(title) + item = (nid, title) + if item not in eps: + eps.append(item) + eps = sorted(eps, key=lambda x: x[0]) + + if not sources: + sources = [('1', '默认')] + if not eps: + eps = [(1, '正片')] + + play_from, play_urls = [], [] + for sid, sname in sources: + play_from.append(sname) + play_urls.append('#'.join(['{}${}-{}-{}'.format(title, vid, sid, nid) for nid, title in eps])) + + vod = { + 'vod_id': vid, + 'vod_name': name, + 'vod_pic': self._url(pic), + 'type_name': type_name, + 'vod_year': year, + 'vod_area': area, + 'vod_lang': lang, + 'vod_remarks': remarks, + 'vod_actor': actor, + 'vod_director': director, + 'vod_content': content, + 'vod_play_from': '$$$'.join(play_from), + 'vod_play_url': '$$$'.join(play_urls), + } + return {'list': [vod]} + + # ---------- 播放 ---------- + def playerContent(self, flag, id, vipFlags): + play_id = str(id or '').strip() + if play_id == '__NO_DIRECT__': + return { + 'parse': 0, + 'playUrl': '', + 'url': '', + 'header': {'User-Agent': self.header['User-Agent'], 'Referer': self.host + '/'}, + } + if play_id.startswith('/play/'): + path = play_id + elif play_id.startswith('play/'): + path = '/' + play_id + else: + path = '/play/{}.html'.format(play_id) + if not path.endswith('.html'): + path += '.html' + + play_url = self.host + path + html = self._txt(play_url, referer=self.host + '/', timeout=25) + data = self._json_player(html) + url = self._decode_play_url(data) + + # 部分线路是优酷/爱奇艺/腾讯等外站地址,先按浏览器抓包流程尝试换成真实 m3u8。 + parse = 0 + if url and not self._is_direct_media(url): + resolved = self._resolve_sniff_to_media(url) + if resolved: + url = resolved + else: + parse = 1 + if url and '.m3u8' in url: + url = self._resolve_m3u8_child(url, referer=play_url) + + return { + 'parse': parse, + 'playUrl': '', + 'url': url or play_url, + 'header': { + 'User-Agent': self.header['User-Agent'], + 'Referer': self.host + '/', + }, + 'format': 'application/x-mpegURL' if '.m3u8' in (url or '') else '', + 'contentType': 'application/x-mpegURL' if '.m3u8' in (url or '') else '', + } + + def localProxy(self, params): + return [404, 'text/plain', {}, b'not found'] diff --git a/FGBLH/py/影视大全.py b/FGBLH/py/影视大全.py new file mode 100644 index 00000000..c8cb150d --- /dev/null +++ b/FGBLH/py/影视大全.py @@ -0,0 +1,106 @@ +import sys, re, json, requests +from urllib.parse import quote +from lxml import etree +from base.spider import Spider + +class Spider(Spider): + siteUrl = "https://ys2046.lat" + headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://ys2046.lat/"} + + def init(self, extend=""): + self.cateManual = [ + {"name": "电影", "tid": "1", "type": "dianying", "filters": {"class": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["喜剧","爱情","动作","恐怖","科幻","剧情","犯罪","奇幻","战争","悬疑","动画","文艺","纪录","传记","歌舞","古装","历史","惊悚","伦理"]], "area": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["大陆","香港","台湾","美国","韩国","日本","泰国","新加坡","马来西亚","印度","英国","法国","加拿大","西班牙","俄罗斯","其它"]], "year": [{"key": "全部", "value": ""}] + [{"key": str(y), "value": str(y)} for y in range(2026, 2004, -1)]}}, + {"name": "连续剧", "tid": "2", "type": "lianxuju", "filters": {"class": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["喜剧","爱情","动作","恐怖","科幻","剧情","犯罪","奇幻","战争","悬疑","动画","文艺","纪录","传记","歌舞","古装","历史","惊悚","伦理"]], "area": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["大陆","香港","台湾","美国","韩国","日本","泰国","新加坡","马来西亚","印度","英国","法国","加拿大","西班牙","俄罗斯","其它"]], "year": [{"key": "全部", "value": ""}] + [{"key": str(y), "value": str(y)} for y in range(2026, 2004, -1)]}}, + {"name": "综艺", "tid": "3", "type": "zongyi", "filters": {"class": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["脱口秀","真人秀","搞笑","访谈","生活","音乐","美食","游戏","旅游","时尚","益智","职场","晚会","纪录"]], "area": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["大陆","香港","台湾","美国","韩国","日本","英国","其他"]], "year": [{"key": "全部", "value": ""}] + [{"key": str(y), "value": str(y)} for y in range(2026, 2010, -1)]}}, + {"name": "动漫", "tid": "4", "type": "dongman", "filters": {"class": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["热血","搞笑","科幻","剧情","冒险","奇幻","战斗","校园","恋爱","治愈","悬疑","推理","机战","运动","美食","历史","少儿"]], "area": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["大陆","日本","美国","韩国","法国","英国","其他"]], "year": [{"key": "全部", "value": ""}] + [{"key": str(y), "value": str(y)} for y in range(2026, 2000, -1)]}}, + {"name": "短剧", "tid": "5", "type": "duanju", "filters": {"class": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["喜剧","爱情","动作","剧情","悬疑","奇幻","古装","都市","逆袭"]], "area": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["大陆","其他"]], "year": [{"key": "全部", "value": ""}] + [{"key": str(y), "value": str(y)} for y in range(2026, 2020, -1)]}}, + {"name": "伦理", "tid": "6", "type": "lunli", "filters": {"class": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["剧情","爱情","惊悚"]], "area": [{"key": "全部", "value": ""}] + [{"key": k, "value": k} for k in ["大陆","香港","台湾","美国","法国","日本","韩国"]], "year": [{"key": "全部", "value": ""}] + [{"key": str(y), "value": str(y)} for y in range(2026, 2000, -1)]}}, + ] + + def _get(self, url): + try: return requests.get(url, headers=self.headers, timeout=10).text + except: return "" + + def _parse_list(self, html): + tree = etree.HTML(html) + return [{"vod_id": m.group(1) if (m := re.search(r'/detail/[\w-]+-(\d+)\.html', a.get("href", ""))) else a.get("href", ""), + "vod_name": t.text.strip() if t.text else "", + "vod_pic": a.get("data-original") or a.get("data-src") or a.get("src") or "", + "vod_remarks": r[0].strip() if (r := li.xpath('.//span[contains(@class,"fed-list-remarks")]/text()')) else ""} + for li in tree.xpath('//li[contains(@class,"fed-list-item")]') + for a in li.xpath('.//a[contains(@class,"fed-list-pics")]') + for t in li.xpath('.//a[contains(@class,"fed-list-title")]') + if a.get("href") and (a.get("data-original") or a.get("data-src") or a.get("src")) and t.text] + + def homeContent(self, filter): + return {"class": [{"type_id": c["tid"], "type_name": c["name"]} for c in self.cateManual], + "filters": {c["tid"]: c["filters"] for c in self.cateManual}} if filter else {"class": [{"type_id": c["tid"], "type_name": c["name"]} for c in self.cateManual]} + + def homeVideoContent(self): + return {"list": self._parse_list(self._get(self.siteUrl))} + + def categoryContent(self, tid, pg, filter, extend): + pg = int(pg) + cls = extend.get("class", "") if extend else "" + area = extend.get("area", "") if extend else "" + year = extend.get("year", "") if extend else "" + p = f"-{pg}" if pg > 1 else "" + return {"page": pg, "pagecount": 9999, "limit": 9999, "total": 9999, "list": self._parse_list(self._get(f"{self.siteUrl}/show/{tid}-{cls}--{area}-{year}{p}.html"))} + + def detailContent(self, ids): + vid = ids[0] if isinstance(ids, list) else ids + cate = next((c for c in self.cateManual if len(html := self._get(f"{self.siteUrl}/detail/{c['type']}-{vid}.html")) > 500), None) + if not cate: return {} + tree = etree.HTML(html) + name = n[0].strip() if (n := tree.xpath('//h1/a/text()')) else "" + img = i[0] if (i := tree.xpath('//div[contains(@class,"fed-deta-info")]//img/@data-original | //div[contains(@class,"fed-deta")]//img/@data-src | //a[contains(@class,"fed-list-pics")]/@data-original')) else "" + + play_btns = tree.xpath('//a[contains(@href,"/play/")]') + sources = [] + seen_sid = set() + for a in play_btns: + if m := re.search(r'/play/[\w-]+-(\d+)-(\d+)\.html', a.get("href", "")): + sid = m.group(1) + if sid not in seen_sid: + seen_sid.add(sid) + sources.append({"name": a.text.strip() if a.text else f"线路{sid}", "sid": sid}) + + source_names = tree.xpath('//div[contains(@class,"fed-drop-btns")]//a') + if source_names and len(source_names) == len(sources): + for i, sn in enumerate(source_names): + if sn.text and sn.text.strip(): sources[i]["name"] = sn.text.strip() + + parts = [] + for src in sources: + sid = src["sid"] + play_tree = etree.HTML(self._get(f"{self.siteUrl}/play/{cate['type']}-{vid}-{sid}-1.html")) + ep_list = [] + seen_nid = set() + for ep in play_tree.xpath(f'//a[contains(@href,"-{sid}-")]'): + if em := re.search(rf'/play/[\w-]+-{sid}-(\d+)\.html', ep.get("href", "")): + nid = em.group(1) + if nid not in seen_nid: + seen_nid.add(nid) + ept = ep.text.strip() if ep.text else f"第{nid}集" + ep_list.append(f"{ept}${vid}_{sid}_{nid}_{cate['type']}") + parts.append("#".join(ep_list) if ep_list else f"第1集${vid}_{sid}_1_{cate['type']}") + + return {"vod_id": vid, "vod_name": name, "vod_pic": img, "type_name": cate["name"], + "vod_year": "", "vod_area": "", "vod_remarks": "", "vod_actor": "", "vod_director": "", "vod_content": "", + "vod_play_from": "$$$".join([s["name"] for s in sources]), "vod_play_url": "$$$".join(parts)} + + def searchContent(self, key, quick, pg=1): + pg = int(pg) + return self._parse_list(self._get(f"{self.siteUrl}/search/{quote(key)}---{pg}.html")) + + def playerContent(self, flag, id, vipFlags): + parts = id.split("_") + if len(parts) == 4: + vid, sid, nid, vtype = parts + return {"parse": 1, "url": f"{self.siteUrl}/play/{vtype}-{vid}-{sid}-{nid}.html"} + if len(parts) == 3: + vid, sid, nid = parts + for c in self.cateManual: + url = f"{self.siteUrl}/play/{c['type']}-{vid}-{sid}-{nid}.html" + if len(self._get(url)) > 500: return {"parse": 1, "url": url} + return {"parse": 1, "url": id if id.startswith("http") else self.siteUrl + id} \ No newline at end of file diff --git a/FGBLH/py/枝枝影视.py b/FGBLH/py/枝枝影视.py new file mode 100644 index 00000000..a51afe15 --- /dev/null +++ b/FGBLH/py/枝枝影视.py @@ -0,0 +1,540 @@ +# -*- coding: utf-8 -*- +# 枝枝影视 zzoc.cc +# 兼容 FongMi/TV 与 WebHomeTV/PeekPro 的 Python Spider + +import sys +import re +import json +import base64 +import time +from html import unescape +from urllib.parse import quote, unquote, urljoin + +try: + from concurrent.futures import ThreadPoolExecutor, as_completed +except Exception: + ThreadPoolExecutor = None + as_completed = None + +try: + import requests as rq +except Exception: + rq = None + +sys.path.append('..') + +try: + from base.spider import Spider as BaseSpider +except ImportError: + import requests as rq + + class BaseSpider: + def fetch(self, url, headers=None, **kw): + kw.pop('timeout', None) + r = rq.get(url, headers=headers, timeout=30, **kw) + r.encoding = 'utf-8' + return r + + +class Spider(BaseSpider): + + def getName(self): + return '枝枝影视' + + def init(self, extend=''): + self.host = 'https://zzoc.cc' + if isinstance(extend, str) and extend.startswith('http'): + self.host = extend.rstrip('/') + self._home_cache = [] + self._home_cache_time = 0 + self.header = { + 'User-Agent': 'Mozilla/5.0 (Linux; Android 13) AppleWebKit/537.36 ' + '(KHTML, like Gecko) Chrome/120.0 Mobile Safari/537.36', + 'Referer': self.host + '/', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + } + + # ---------- 基础工具 ---------- + def _txt(self, url, referer=None, timeout=30): + headers = dict(self.header) + if referer: + headers['Referer'] = referer + try: + rsp = self.fetch(url, headers=headers, timeout=timeout) + try: + rsp.encoding = 'utf-8' + except Exception: + pass + return rsp.text + except Exception: + return '' + + def _url(self, path): + return urljoin(self.host + '/', path) + + def _proxy_url(self, media_url, referer=''): + if not hasattr(self, 'getProxyUrl'): + return media_url + try: + base = self.getProxyUrl() + return base + '&url=' + quote(media_url, safe='') + '&referer=' + quote(referer or self.host + '/', safe='') + except Exception: + return media_url + + def _clean(self, text): + if not text: + return '' + text = re.sub(r'(?is)|', '', text) + text = re.sub(r'(?is)', ' ', text) + text = re.sub(r'(?is)<.*?>', '', text) + text = unescape(text) + text = text.replace('\xa0', ' ') + return re.sub(r'\s+', ' ', text).strip() + + def _match(self, pattern, text, default='', flags=re.S): + m = re.search(pattern, text, flags) + return self._clean(m.group(1)) if m else default + + def _abs_pic(self, pic): + if not pic: + return '' + if pic.startswith('//'): + return 'https:' + pic + return self._url(pic) + + def _parse_cards(self, html): + if not html: + return [] + videos = [] + parts = html.split('
') + for part in parts[1:]: + m = re.search(r'href=["\'](/voddetail/(\d+)\.html)["\']', part) + if not m: + continue + vod_id = m.group(2) + name = self._match(r'alt=["\']([^"\']+)["\']', part) + if not name: + name = self._match(r'
(.*?)
', part) + pic = self._match(r']+src=["\']([^"\']+)["\']', part) + if 'load.gif' in pic: + pic = self._match(r'