人人视频

This commit is contained in:
Harold
2026-04-20 13:31:30 +08:00
parent c9c98e0b4c
commit 9a085af835
2 changed files with 610 additions and 0 deletions
+273
View File
@@ -0,0 +1,273 @@
import base64
import json
import unittest
from importlib.machinery import SourceFileLoader
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
ROOT = Path(__file__).resolve().parents[1]
MODULE = SourceFileLoader("rrmj_spider", str(ROOT / "人人视频.py")).load_module()
Spider = MODULE.Spider
def encrypt_ecb_payload(payload):
cipher = AES.new(MODULE.AES_KEY.encode("utf-8"), AES.MODE_ECB)
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
return base64.b64encode(cipher.encrypt(pad(body, AES.block_size))).decode("utf-8")
def encrypt_cbc_payload(value, new_sign):
key = new_sign[4:20].encode("utf-8")
cipher = AES.new(key, AES.MODE_CBC, MODULE.AES_IV.encode("utf-8"))
return base64.b64encode(cipher.encrypt(pad(value.encode("utf-8"), AES.block_size))).decode("utf-8")
class TestRenRenShiPinSpider(unittest.TestCase):
def setUp(self):
Spider._instance = None
self.spider = Spider()
self.spider.init()
def test_module_uses_pycryptodome_and_hmac(self):
source = (ROOT / "人人视频.py").read_text(encoding="utf-8")
self.assertIn("from Crypto.Cipher import AES", source)
self.assertIn("from Crypto.Util.Padding import pad, unpad", source)
self.assertIn("import hmac", source)
def test_home_video_content_returns_empty_list(self):
self.assertEqual(self.spider.homeVideoContent(), {"list": []})
@patch("rrmj_spider.time.time", return_value=1713523200.123)
def test_create_headers_signs_sorted_params(self, mock_time):
headers = self.spider._create_headers(
"/m-station/drama/drama_filter_search",
{
"sort": "hot",
"dramaType": "TV",
"year": "",
},
method="POST",
)
self.assertEqual(headers["t"], "1713523200123")
self.assertEqual(headers["aliId"], MODULE.KY_ID)
self.assertEqual(
headers["x-ca-sign"],
"wmstQtQaO9LiPx+xbWpi7LncPdyFnf6eVrVTNgoDDBU=",
)
@patch.object(Spider, "fetch")
def test_request_api_decrypts_ecb_response(self, mock_fetch):
mock_fetch.return_value = SimpleNamespace(
status_code=200,
text=encrypt_ecb_payload({"code": "0000", "data": {"ok": True}}),
)
result = self.spider._request_api("/m-station/drama/get_drama_filter")
self.assertEqual(result, {"code": "0000", "data": {"ok": True}})
self.assertEqual(
mock_fetch.call_args.args[0],
"https://api.rrmj.plus/m-station/drama/get_drama_filter",
)
@patch.object(Spider, "_request_api")
def test_home_content_maps_expected_classes_and_filters(self, mock_request_api):
mock_request_api.return_value = {
"code": "0000",
"data": [
{
"filterType": "sort",
"dramaFilterItemList": [
{"displayName": "最热", "value": "hot"},
{"displayName": "最新", "value": "new"},
],
},
{
"filterType": "dramaType",
"dramaFilterItemList": [
{"displayName": "全部", "value": ""},
{"displayName": "电视剧", "value": "TV"},
{"displayName": "电影", "value": "MOVIE"},
{"displayName": "综艺", "value": "VARIETY"},
{"displayName": "纪录片", "value": "DOCUMENTARY"},
{"displayName": "动漫", "value": "COMIC"},
],
},
{
"filterType": "area",
"dramaFilterItemList": [
{"displayName": "全部", "value": ""},
{"displayName": "内地", "value": "中国大陆"},
],
},
{
"filterType": "plotType",
"dramaFilterItemList": [
{"displayName": "全部", "value": ""},
{"displayName": "悬疑", "value": "悬疑"},
],
},
{
"filterType": "year",
"dramaFilterItemList": [
{"displayName": "全部", "value": ""},
{"displayName": "2026", "value": "2026"},
],
},
],
}
result = self.spider.homeContent(False)
self.assertEqual(
result["class"],
[
{"type_id": "TV", "type_name": "剧集"},
{"type_id": "MOVIE", "type_name": "电影"},
{"type_id": "VARIETY", "type_name": "综艺"},
{"type_id": "COMIC", "type_name": "动漫"},
],
)
self.assertEqual([item["key"] for item in result["filters"]["TV"]], ["area", "class", "year", "by"])
self.assertEqual(result["filters"]["TV"][0]["value"][1], {"n": "内地", "v": "中国大陆"})
self.assertEqual(result["filters"]["TV"][1]["value"][1], {"n": "悬疑", "v": "悬疑"})
self.assertEqual(result["filters"]["TV"][3]["value"][1], {"n": "最新", "v": "new"})
@patch.object(Spider, "_request_api")
def test_category_content_posts_expected_payload_and_omits_pagecount(self, mock_request_api):
mock_request_api.return_value = {
"code": "0000",
"data": [
{
"dramaId": 56433,
"title": "神与律师事务所",
"coverUrl": "https://img.example/poster.jpg",
"year": "2026",
"subtitle": "柳演锡x李絮x金景南",
}
],
}
result = self.spider.categoryContent(
"TV",
"2",
False,
{"area": "韩国", "year": "2026", "class": "悬疑", "by": "new"},
)
self.assertEqual(
mock_request_api.call_args.args,
(
"/m-station/drama/drama_filter_search",
{
"area": "韩国",
"sort": "new",
"year": "2026",
"dramaType": "TV",
"plotType": "悬疑",
"contentLabel": "",
"page": 2,
"rows": 30,
},
),
)
self.assertEqual(result["page"], 2)
self.assertEqual(result["list"][0]["vod_id"], "56433")
self.assertEqual(result["list"][0]["vod_remarks"], "2026 柳演锡x李絮x金景南")
self.assertNotIn("pagecount", result)
@patch.object(Spider, "_request_api")
def test_detail_content_maps_metadata_and_episode_ids(self, mock_request_api):
mock_request_api.return_value = {
"code": "0000",
"data": {
"dramaInfo": {
"dramaId": 56433,
"title": "神与律师事务所",
"cover": "https://img.example/detail.jpg",
"description": "一段剧情简介",
"year": 2026,
"area": "韩国",
"plotType": "剧情/悬疑",
"subtitle": "柳演锡x李絮x金景南",
"playStatus": "更新至12集",
},
"episodeList": [
{"id": 380772, "episodeNo": 1, "title": ""},
{"id": 380810, "episodeNo": 2, "title": "特别篇"},
],
},
}
result = self.spider.detailContent(["56433"])
vod = result["list"][0]
self.assertEqual(vod["vod_id"], "56433")
self.assertEqual(vod["vod_name"], "神与律师事务所")
self.assertEqual(vod["vod_pic"], "https://img.example/detail.jpg")
self.assertEqual(vod["vod_content"], "一段剧情简介")
self.assertEqual(vod["vod_year"], "2026")
self.assertEqual(vod["vod_area"], "韩国")
self.assertEqual(vod["type_name"], "剧情/悬疑")
self.assertEqual(vod["vod_actor"], "柳演锡x李絮x金景南")
self.assertEqual(vod["vod_play_from"], "人人专线")
self.assertEqual(vod["vod_play_url"], "第1集$56433@380772#特别篇$56433@380810")
@patch.object(Spider, "fetch")
@patch.object(Spider, "_request_api")
def test_player_content_decrypts_play_url_and_uses_redirect_location(self, mock_request_api, mock_fetch):
new_sign = "ngXN+eiB3og7T+Zf3iojv6zn6GFDjCi+9COuBBGHO6Y="
encrypted_url = encrypt_cbc_payload("https://video.example/decrypted.mp4", new_sign)
mock_request_api.return_value = {
"code": "0000",
"data": {
"m3u8": {"url": encrypted_url},
"newSign": new_sign,
},
}
mock_fetch.return_value = SimpleNamespace(status_code=302, headers={"location": "https://video.example/final.mp4"})
result = self.spider.playerContent("人人专线", "56433@380772", {})
self.assertEqual(result["parse"], 0)
self.assertEqual(result["jx"], 0)
self.assertEqual(result["url"], "https://video.example/final.mp4")
self.assertEqual(result["header"]["Referer"], MODULE.HOST)
@patch.object(Spider, "_request_api")
def test_search_content_prefers_precise_results_and_omits_pagecount(self, mock_request_api):
mock_request_api.return_value = {
"code": "0000",
"data": {
"seasonList": [
{
"id": "56433",
"title": "神与律师事务所",
"cover": "https://img.example/cover.jpg",
"year": "2026",
"classify": "电视剧",
}
],
"fuzzySeasonList": [
{
"id": "56694",
"title": "山田轰律师事务所",
"cover": "https://img.example/fuzzy.jpg",
"year": "2026",
"classify": "电视剧",
}
],
},
}
result = self.spider.searchContent("律师事务所", False, "1")
self.assertEqual(
result["list"],
[
{
"vod_id": "56433",
"vod_name": "神与律师事务所",
"vod_pic": "https://img.example/cover.jpg",
"vod_remarks": "2026 电视剧",
}
],
)
self.assertNotIn("pagecount", result)
if __name__ == "__main__":
unittest.main()
+337
View File
@@ -0,0 +1,337 @@
# coding=utf-8
import base64
import hashlib
import hmac
import json
import sys
import time
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from base.spider import Spider as BaseSpider
sys.path.append("..")
HOST = "https://m.yichengwlkj.com"
API_URL = "https://api.rrmj.plus"
UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/143.0.0.0 Safari/537.36 Edg/143.0.0.0"
)
KY_ID = "BA21A0F5-7C57-41BA-8665-B7164A131832"
APP_SECRET = "ES513W0B1CsdUrR13Qk5EgDAKPeeKZY"
AES_KEY = "3b744389882a4067"
AES_IV = "b1da7878016e4e2b"
class Spider(BaseSpider):
def __init__(self):
self.name = "人人视频"
self.class_name_map = {
"TV": "剧集",
"MOVIE": "电影",
"VARIETY": "综艺",
"COMIC": "动漫",
}
def init(self, extend=""):
return None
def getName(self):
return self.name
def homeVideoContent(self):
return {"list": []}
def _stringify(self, value):
return "" if value is None else str(value)
def _build_api_url(self, path):
return f"{API_URL}{str(path or '').strip()}"
def _join_values(self, *values):
return " ".join([self._stringify(value).strip() for value in values if self._stringify(value).strip()]).strip()
def _generate_signature(self, text):
digest = hmac.new(APP_SECRET.encode("utf-8"), text.encode("utf-8"), hashlib.sha256).digest()
return base64.b64encode(digest).decode("utf-8")
def _create_headers(self, url_path, params=None, method="GET"):
payload = {
self._stringify(key): self._stringify(value)
for key, value in dict(params or {}).items()
}
timestamp = str(int(time.time() * 1000))
sorted_params = "&".join([f"{key}={payload[key]}" for key in sorted(payload)])
full_url = f"{url_path}?{sorted_params}" if sorted_params else url_path
string_to_sign = "\n".join(
[
method.upper(),
f"aliId:{KY_ID}",
"ct:web_pc",
"cv:1.0.0",
f"t:{timestamp}",
full_url,
]
)
headers = {
"Accept": "application/json, text/plain, */*",
"User-Agent": UA,
"Origin": HOST,
"Referer": HOST,
"aliId": KY_ID,
"clientType": "web_pc",
"clientVersion": "1.0.0",
"ct": "web_pc",
"cv": "1.0.0",
"deviceId": KY_ID,
"t": timestamp,
"token": "",
"uet": "9",
"umid": KY_ID,
"x-ca-sign": self._generate_signature(string_to_sign),
}
if method.upper() == "POST":
headers["Content-Type"] = "application/json;charset=UTF-8"
return headers
def _decrypt_aes_ecb(self, encrypted_data):
text = self._stringify(encrypted_data).strip()
if not text:
return None
if text.startswith("{") or text.startswith("["):
try:
return json.loads(text)
except Exception:
return None
try:
clean = "".join(text.split())
cipher = AES.new(AES_KEY.encode("utf-8"), AES.MODE_ECB)
raw = base64.b64decode(clean)
decoded = unpad(cipher.decrypt(raw), AES.block_size).decode("utf-8")
return json.loads(decoded)
except Exception:
return None
def _decrypt_aes_cbc(self, encrypted_text, new_sign):
text = self._stringify(encrypted_text).strip()
sign = self._stringify(new_sign).strip()
if not text or len(sign) < 20:
return ""
try:
key = sign[4:20].encode("utf-8")
cipher = AES.new(key, AES.MODE_CBC, AES_IV.encode("utf-8"))
decoded = unpad(cipher.decrypt(base64.b64decode(text)), AES.block_size).decode("utf-8")
return decoded
except Exception:
return ""
def _request_api(self, url_path, params=None, method="GET"):
payload = dict(params or {})
headers = self._create_headers(url_path, payload, method=method)
try:
if method.upper() == "POST":
response = self.post(
self._build_api_url(url_path),
headers=headers,
json=payload,
timeout=10,
verify=False,
)
else:
response = self.fetch(
self._build_api_url(url_path),
params=payload,
headers=headers,
timeout=10,
verify=False,
)
except Exception:
return None
if getattr(response, "status_code", 0) != 200:
return None
return self._decrypt_aes_ecb(getattr(response, "text", ""))
def _build_filter_values(self, items):
values = []
for item in items or []:
name = self._stringify(item.get("displayName"))
value = self._stringify(item.get("value"))
if not name:
continue
values.append({"n": name, "v": value})
return values
def _page_result(self, items, pg):
page = int(pg)
return {"page": page, "limit": len(items), "total": len(items), "list": items}
def homeContent(self, filter):
payload = self._request_api("/m-station/drama/get_drama_filter") or {}
groups = {
self._stringify(item.get("filterType")): list(item.get("dramaFilterItemList") or [])
for item in payload.get("data", [])
}
classes = []
filters = {}
for item in groups.get("dramaType", []):
type_id = self._stringify(item.get("value"))
if type_id not in self.class_name_map:
continue
classes.append({"type_id": type_id, "type_name": self.class_name_map[type_id]})
filters[type_id] = [
{"key": "area", "name": "地区", "value": self._build_filter_values(groups.get("area", []))},
{"key": "class", "name": "类型", "value": self._build_filter_values(groups.get("plotType", []))},
{"key": "year", "name": "年份", "value": self._build_filter_values(groups.get("year", []))},
{"key": "by", "name": "排序", "value": self._build_filter_values(groups.get("sort", []))},
]
return {"class": classes, "filters": filters}
def categoryContent(self, tid, pg, filter, extend):
params = {
"area": self._stringify((extend or {}).get("area")),
"sort": self._stringify((extend or {}).get("by") or "hot"),
"year": self._stringify((extend or {}).get("year")),
"dramaType": self._stringify(tid),
"plotType": self._stringify((extend or {}).get("class")),
"contentLabel": "",
"page": int(pg),
"rows": 30,
}
payload = self._request_api("/m-station/drama/drama_filter_search", params, method="POST") or {}
items = []
for vod in payload.get("data", []) or []:
items.append(
{
"vod_id": self._stringify(vod.get("dramaId") or vod.get("id")),
"vod_name": self._stringify(vod.get("title") or vod.get("name")),
"vod_pic": self._stringify(vod.get("coverUrl") or vod.get("cover")),
"vod_remarks": self._join_values(vod.get("year"), vod.get("subtitle")),
}
)
return self._page_result(items, pg)
def _episode_name(self, episode):
title = self._stringify(episode.get("title")).strip()
if title:
return title
text = self._stringify(episode.get("text")).strip()
if text:
return text
number = self._stringify(episode.get("episodeNo")).strip()
return f"{number}" if number else "正片"
def detailContent(self, ids):
items = []
for raw_id in ids or []:
drama_id = self._stringify(raw_id).strip()
if not drama_id:
continue
payload = self._request_api(
"/m-station/drama/page",
{
"hsdrOpen": "0",
"isAgeLimit": "0",
"dramaId": drama_id,
"quality": "AI4K",
"hevcOpen": "0",
"tria4k": "1",
},
) or {}
data = payload.get("data") or {}
info = data.get("dramaInfo") or {}
play_urls = []
for episode in data.get("episodeList", []) or []:
episode_id = self._stringify(episode.get("sid") or episode.get("id")).strip()
if not episode_id:
continue
play_urls.append(f"{self._episode_name(episode)}${drama_id}@{episode_id}")
items.append(
{
"vod_id": drama_id,
"vod_name": self._stringify(info.get("title")),
"vod_pic": self._stringify(info.get("coverUrl") or info.get("cover") or info.get("cover3Url")),
"vod_remarks": self._stringify(info.get("playStatus")),
"vod_year": self._stringify(info.get("year")),
"vod_area": self._stringify(info.get("area")),
"vod_actor": self._stringify(info.get("actor") or info.get("subtitle")),
"vod_director": self._stringify(info.get("director")),
"vod_content": self._stringify(
info.get("introduction") or info.get("description") or info.get("subtitle")
),
"type_name": self._stringify(info.get("plotType") or self.class_name_map.get(info.get("dramaType"), "")),
"vod_play_from": "人人专线" if play_urls else "",
"vod_play_url": "#".join(play_urls),
}
)
return {"list": items}
def playerContent(self, flag, id, vipFlags):
raw = self._stringify(id).strip()
if "@" not in raw:
return {"parse": 1, "playUrl": "", "url": raw, "jx": 1}
drama_id, episode_sid = raw.split("@", 1)
payload = self._request_api(
"/m-station/drama/play",
{
"dramaId": drama_id,
"episodeSid": episode_sid,
"hevcOpen": "0",
"hsdrOpen": "0",
"quality": "AI4K",
"tria4k": "1",
},
) or {}
data = payload.get("data") or {}
encrypted_url = self._stringify((data.get("m3u8") or {}).get("url") or (data.get("tria4kPlayInfo") or {}).get("url"))
final_url = self._decrypt_aes_cbc(encrypted_url, data.get("newSign"))
if not final_url:
return {"parse": 1, "playUrl": "", "url": "", "jx": 1}
try:
response = self.fetch(
final_url,
headers={"User-Agent": UA},
timeout=10,
verify=False,
allow_redirects=False,
)
final_url = self._stringify(getattr(response, "headers", {}).get("location") or final_url)
except Exception:
pass
return {
"parse": 0,
"playUrl": "",
"url": final_url,
"jx": 0,
"header": {"User-Agent": UA, "Referer": HOST},
}
def searchContent(self, key, quick, pg="1"):
keyword = self._stringify(key).strip()
if not keyword:
return self._page_result([], pg)
payload = self._request_api(
"/search/comprehensive/precise-mixed",
{"keywords": keyword, "size": "20", "searchAfter": ""},
) or {}
data = payload.get("data") or {}
source = list(data.get("seasonList") or []) or list(data.get("fuzzySeasonList") or [])
items = []
seen = set()
for vod in source:
vod_id = self._stringify(vod.get("id") or vod.get("dramaId"))
if not vod_id or vod_id in seen:
continue
seen.add(vod_id)
items.append(
{
"vod_id": vod_id,
"vod_name": self._stringify(vod.get("title") or vod.get("name")),
"vod_pic": self._stringify(vod.get("cover") or vod.get("coverUrl")),
"vod_remarks": self._join_values(vod.get("year"), vod.get("classify") or vod.get("cat")),
}
)
return self._page_result(items, pg)