# coding=utf-8 import json import re import sys import time from hashlib import sha256 from urllib.parse import quote from Crypto.Cipher import AES from Crypto.Cipher import ChaCha20_Poly1305 from Crypto.Random import get_random_bytes from lxml import html as lxml_html from base.spider import Spider as BaseSpider sys.path.append("..") class Spider(BaseSpider): def __init__(self): self.name = "听友FM" self.host = "https://tingyou.fm" self.headers = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/146.0.0.0 Safari/537.36" ), "Referer": self.host + "/", "Origin": self.host, "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", } self.payload_key_hex = "ea9d9d4f9a983fe6f6382f29c7b46b8d6dc47abc6da36662e6ddff8c78902f65" self.payload_version = 1 self.classes = [ {"type_id": "46", "type_name": "有声小说"}, {"type_id": "11", "type_name": "武侠小说"}, {"type_id": "19", "type_name": "言情通俗"}, {"type_id": "21", "type_name": "相声小品"}, {"type_id": "14", "type_name": "恐怖惊悚"}, {"type_id": "17", "type_name": "官场商战"}, {"type_id": "15", "type_name": "历史军事"}, {"type_id": "9", "type_name": "百家讲坛"}, ] def init(self, extend=""): return None def getName(self): return self.name def _get_headers(self, extra=None): headers = dict(self.headers) if extra: headers.update(extra) return headers def _normalize_url(self, url): value = str(url or "").strip() if not value: return "" if value.startswith("http://") or value.startswith("https://"): return value if value.startswith("//"): return "https:" + value if value.startswith("/"): return self.host + value return value def _safe_text(self, node): if node is None: return "" if isinstance(node, str): return re.sub(r"\s+", " ", node).strip() return re.sub(r"\s+", " ", "".join(node.itertext())).strip() def _load_html(self, content): text = str(content or "").strip() or "" return lxml_html.fromstring(text) def _get_html(self, path): url = path if str(path).startswith("http") else self.host + (path if str(path).startswith("/") else "/" + str(path)) try: response = self.fetch(url, headers=self._get_headers(), timeout=10, verify=False) except Exception: return "" return response.text if getattr(response, "status_code", 0) == 200 else "" def _hex_to_bytes(self, hex_text): return bytes.fromhex(str(hex_text or "").strip()) def _bytes_to_hex(self, data): return bytes(data or b"").hex() def _encrypt_payload(self, plain_text): key = self._hex_to_bytes(self.payload_key_hex) iv = get_random_bytes(12) cipher = AES.new(key, AES.MODE_GCM, nonce=iv) encrypted, tag = cipher.encrypt_and_digest(str(plain_text or "").encode("utf-8")) return self._bytes_to_hex(bytes([self.payload_version]) + iv + encrypted + tag) def _decrypt_xchacha_body(self, key, nonce, body): cipher = ChaCha20_Poly1305.new(key=key, nonce=nonce) return cipher.decrypt_and_verify(body[:-16], body[-16:]) def _decrypt_aes_gcm_payload(self, raw, attempts): key = self._hex_to_bytes(self.payload_key_hex) last_error = None for iv_start, cipher_start in attempts: try: iv = raw[iv_start:iv_start + 12] body = raw[cipher_start:] encrypted = body[:-16] tag = body[-16:] cipher = AES.new(key, AES.MODE_GCM, nonce=iv) return cipher.decrypt_and_verify(encrypted, tag).decode("utf-8") except Exception as exc: last_error = exc if last_error: raise last_error raise ValueError("aes decrypt failed") def _decrypt_payload(self, hex_text): raw = self._hex_to_bytes(hex_text) if len(raw) < 2: raise ValueError("payload too short") version = raw[0] key = self._hex_to_bytes(self.payload_key_hex) if version == 1: if len(raw) >= 41: try: plain = self._decrypt_xchacha_body(key, raw[1:25], raw[25:]) return plain.decode("utf-8") except Exception: pass return self._decrypt_aes_gcm_payload(raw, [(1, 13), (0, 12), (2, 14)]) if version == 2: nonce = raw[1:25] body = raw[25:][::-1] plain = self._decrypt_xchacha_body(key, nonce, body) return bytes(plain).decode("utf-8") raise ValueError("unsupported payload version") def _make_dfp_cookie(self): today_hex = format(int(time.strftime("%Y%m%d")), "x") seed = f"{time.time()}|tingyou|{get_random_bytes(8).hex()}".encode("utf-8") return f"dfp=f-{today_hex}:f-{sha256(seed).hexdigest()}" def _decode_nuxt_value(self, table, node, seen=None): seen = seen or {} markers = { "ShallowReactive", "Reactive", "Ref", "EmptyRef", "Set", "Map", "Date", "RegExp", "BigInt", "null", "undefined", "NaN", "-0", "Infinity", "-Infinity", } if isinstance(node, int) and 0 <= node < len(table): if node in seen: return seen[node] raw = table[node] if isinstance(raw, list) and raw and isinstance(raw[0], str) and raw[0] in markers: marker = raw[0] if marker in ("ShallowReactive", "Reactive", "Ref"): value = self._decode_nuxt_value(table, raw[1] if len(raw) > 1 else None, seen) seen[node] = value return value if marker in ("EmptyRef", "null", "undefined", "NaN"): seen[node] = None return None if isinstance(raw, dict): seen[node] = {} for key, value in raw.items(): seen[node][key] = self._decode_nuxt_value(table, value, seen) return seen[node] if isinstance(raw, list): seen[node] = [] for item in raw: seen[node].append(self._decode_nuxt_value(table, item, seen)) return seen[node] return raw if isinstance(node, list): return [self._decode_nuxt_value(table, item, seen) for item in node] if isinstance(node, dict): return {key: self._decode_nuxt_value(table, value, seen) for key, value in node.items()} return node def _load_nuxt_root(self, html): match = re.search(r']*id=["\']__NUXT_DATA__["\'][^>]*>([\s\S]*?)', str(html or ""), re.I) if not match: return {} try: payload = json.loads(match.group(1)) except Exception: return {} if isinstance(payload, list) and len(payload) > 1: return self._decode_nuxt_value(payload, 1) return payload if isinstance(payload, dict) else {} def _pick_image(self, node): if node is None: return "" for name in ("src", "data-src", "data-lazy-src", "data-original", "data-url"): value = node.get(name, "") if value and not value.startswith("data:image"): return self._normalize_url(value) return "" def _parse_album_anchor(self, anchor, fallback_type_id="", fallback_type_name=""): href = anchor.get("href", "") match = re.search(r"/albums/(\d+)", href) if not match: return None album_id = match.group(1) images = anchor.xpath(".//img") image = images[0] if images else None title = "" if image is not None: title = image.get("alt", "").strip() if not title: first_p = anchor.xpath(".//p") title = self._safe_text(first_p[0]) if first_p else self._safe_text(anchor) text = self._safe_text(anchor) periods = re.search(r"(\d+)\s*期", text) status = re.search(r"(连载中|已完结)", text) person = re.search(r"播音[::]\s*([^\s·|]+)", text) or re.search(r"作者[::]\s*([^\s·|]+)", text) remarks = " · ".join( [value for value in [periods.group(1) + "期" if periods else "", status.group(1) if status else "", person.group(1) if person else ""] if value] ) return { "vod_id": album_id, "vod_name": title or ("专辑" + album_id), "vod_pic": self._pick_image(image), "vod_remarks": remarks, "type_id": str(fallback_type_id or ""), "type_name": fallback_type_name or "", } def _unique_by_id(self, items): seen = set() result = [] for item in items or []: vod_id = str((item or {}).get("vod_id", "")).strip() if not vod_id or vod_id in seen: continue seen.add(vod_id) result.append(item) return result def _category_name(self, tid): for item in self.classes: if item["type_id"] == str(tid): return item["type_name"] return "" def _parse_home_list(self, html, fallback_type_id="", fallback_type_name=""): document = self._load_html(html) items = [] for anchor in document.xpath("//a[contains(@href, '/albums/')]"): if not anchor.xpath(".//img"): continue item = self._parse_album_anchor(anchor, fallback_type_id, fallback_type_name) if item: items.append(item) return self._unique_by_id(items) def _parse_home_nuxt(self, html): root = self._load_nuxt_root(html) tabs = ((root.get("data") or {}).get("index-home-tabs") or {}) items = [] for bucket in tabs.values(): tab_items = bucket.get("items") if isinstance(bucket, dict) else None if not isinstance(tab_items, list): continue for item in tab_items: album_id = str((item or {}).get("id") or "").strip() if not album_id: continue status = "连载中" if str(item.get("status")) == "1" else "已完结" if str(item.get("status")) == "0" else "" remarks = " · ".join( [ value for value in [ f"{item.get('chapterTotal')}期" if item.get("chapterTotal") else "", status, str(item.get("teller") or item.get("author") or "").strip(), ] if value ] ) items.append( { "vod_id": album_id, "vod_name": str(item.get("title") or ("专辑" + album_id)), "vod_pic": self._normalize_url(item.get("cover") or item.get("cover_url") or ""), "vod_remarks": remarks, "type_id": "", "type_name": str(item.get("categoryName") or "").strip(), } ) return self._unique_by_id(items) def _map_nuxt_album_item(self, item, tid, type_name): data = item or {} album_id = str(data.get("id") or "") if not album_id: return None status = "连载中" if str(data.get("status")) == "1" else "已完结" if str(data.get("status")) == "0" else "" remarks = " · ".join( [ value for value in [ f"{data.get('count')}期" if data.get("count") else "", status, str(data.get("teller") or data.get("author") or "").strip(), ] if value ] ) return { "vod_id": album_id, "vod_name": str(data.get("title") or ("专辑" + album_id)), "vod_pic": self._normalize_url(data.get("cover_url") or ""), "vod_remarks": remarks, "type_id": str(tid or ""), "type_name": type_name or "", } def _parse_category_nuxt(self, html, tid): root = self._load_nuxt_root(html) data = (root.get("data") or {}).get(f"categoryAlbums-{tid}") or {} items = [self._map_nuxt_album_item(item, tid, self._category_name(tid)) for item in data.get("data", [])] return { "page": int(data.get("page") or 1), "pages": int(data.get("pages") or 1), "list": [item for item in items if item], } if items else None def _parse_search_nuxt(self, html): root = self._load_nuxt_root(html) data = root.get("data") or {} search_value = None for key, value in data.items(): if "search" in str(key).lower(): search_value = value break results = [] def walk(node): if isinstance(node, list): for item in node: walk(item) return if not isinstance(node, dict): return if node.get("id") and (node.get("title") or node.get("name")): pic = node.get("cover") or node.get("cover_url") or node.get("pic") or node.get("pic_url") if pic: results.append( { "vod_id": str(node.get("id")), "vod_name": str(node.get("title") or node.get("name") or ""), "vod_pic": self._normalize_url(pic), "vod_remarks": str(node.get("desc") or node.get("subtitle") or node.get("author") or node.get("teller") or "").strip(), } ) for value in node.values(): walk(value) walk(search_value) return self._unique_by_id(results) def _parse_detail_page(self, html, album_id): document = self._load_html(html) name = self._safe_text(next(iter(document.xpath("//section[contains(@class, 'album-pannel')]//*[contains(@class, 'album-intro')]//h1")), None)) if not name: name = document.xpath("string(//meta[@property='og:title']/@content)").strip() pic = "" images = document.xpath("//section[contains(@class, 'album-pannel')]//img") if images: pic = self._pick_image(images[0]) if not pic: pic = self._normalize_url(document.xpath("string(//meta[@property='og:image']/@content)").strip()) content = ( document.xpath("string(//meta[@name='description']/@content)").strip() or document.xpath("string(//meta[@property='og:description']/@content)").strip() or self._safe_text(next(iter(document.xpath("//*[contains(@class, 'album-desc') or contains(@class, 'desc') or contains(@class, 'intro')]")), None)) ) type_name = "" for node in document.xpath("//section[contains(@class, 'album-pannel')]//*[contains(@class, 'pods')]//span"): text = self._safe_text(node) if text.startswith("分类:"): type_name = text.split(":", 1)[1].strip() break root = self._load_nuxt_root(html) nuxt_data = root.get("data") or {} album_detail = nuxt_data.get(f"album-detail-{album_id}") or {} album_chapters = nuxt_data.get(f"album-chapters-{album_id}") or {} if not name: name = str(album_detail.get("title") or "").strip() if not pic: pic = self._normalize_url(album_detail.get("cover_url") or "") if not content: content = str(album_detail.get("synopsis") or "").strip() play_items = [] chapters = album_chapters.get("chapters") or [] if isinstance(chapters, list) and chapters: for index, item in enumerate(chapters, start=1): raw_index = item.get("index") chapter_idx = int(raw_index) if isinstance(raw_index, (int, str)) and str(raw_index).isdigit() else index title = str(item.get("title") or f"第{chapter_idx}集").strip() play_items.append(f"{title}${album_id}|{chapter_idx}") else: for index, item in enumerate(document.xpath("//ul[contains(@class, 'chapter-list')]/li[contains(@class, 'chapter-item')]"), start=1): num_text = self._safe_text(next(iter(item.xpath("./p")), None)) title = self._safe_text(next(iter(item.xpath(".//*[contains(@class, 'title')]")), None)) or f"第{index}集" chapter_idx = int(num_text) if str(num_text).isdigit() else index play_items.append(f"{title}${album_id}|{chapter_idx}") return { "vod_id": str(album_id), "vod_name": name or ("专辑" + str(album_id)), "vod_pic": pic, "vod_content": content, "type_name": type_name, "vod_play_from": "听友FM", "vod_play_url": "#".join(play_items), } def _normalize_api_result(self, data): value = data if isinstance(value, dict) and isinstance(value.get("payload"), str): plain = self._decrypt_payload(value["payload"]) try: return json.loads(plain) except Exception: return plain if isinstance(value, str) and re.fullmatch(r"[0-9a-fA-F]+", value or ""): plain = self._decrypt_payload(value) try: return json.loads(plain) except Exception: return plain return value def _api_post(self, path, body=None, extra_headers=None): url = path if str(path).startswith("http") else self.host + (path if str(path).startswith("/") else "/" + str(path)) payload = None if body is not None: payload = self._encrypt_payload(json.dumps(body, ensure_ascii=False, separators=(",", ":"))) headers = self._get_headers({"X-Payload-Version": str(self.payload_version)}) if payload is not None: headers["Content-Type"] = "text/plain" if extra_headers: headers.update(extra_headers) response = self.post(url, data=payload, headers=headers, timeout=10, verify=False) if getattr(response, "status_code", 0) >= 400: raise ValueError("api request failed") text = getattr(response, "text", "") or "" try: data = json.loads(text) except Exception: data = text return self._normalize_api_result(data) def _extract_play_url(self, value): candidates = [] def walk(node): if isinstance(node, str): if node.startswith("http://") or node.startswith("https://"): candidates.append(node) return if isinstance(node, list): for item in node: walk(item) return if not isinstance(node, dict): return for key, item in node.items(): if isinstance(item, str) and (key.lower() in ("url", "src", "play", "audio", "file", "link") or item.startswith("http")): candidates.append(item) walk(item) walk(value) for candidate in candidates: if candidate.startswith("http://") or candidate.startswith("https://"): return candidate return "" def _anonymous_auth(self): cookie = self._make_dfp_cookie() data = self._api_post("/api/me", None, extra_headers={"Accept": "application/json", "Cookie": cookie}) auth_token = str((data or {}).get("auth_token") or "").strip() return { "auth_token": auth_token, "cookie": cookie, "data": data or {}, } def homeContent(self, filter): html = self._get_html("/") document = self._load_html(html) classes = [] seen = set() for anchor in document.xpath("//a[contains(@href, '/categories/')]"): href = anchor.get("href", "") match = re.search(r"/categories/(\d+)", href) if not match: continue type_id = match.group(1) if type_id in seen: continue seen.add(type_id) classes.append({"type_id": type_id, "type_name": self._safe_text(anchor) or self._category_name(type_id)}) items = self._parse_home_nuxt(html) or self._parse_home_list(html) return {"class": classes or list(self.classes), "list": items[:20]} def homeVideoContent(self): return {"list": self.homeContent(False).get("list", [])} def categoryContent(self, tid, pg, filter, extend): page = int(pg or 1) html = self._get_html(f"/categories/{tid}?sort=comprehensive&page={page}") nuxt = self._parse_category_nuxt(html, tid) items = (nuxt or {}).get("list") or self._parse_home_list(html, tid, self._category_name(tid)) return {"page": (nuxt or {}).get("page", page), "limit": len(items), "total": len(items), "list": items} def detailContent(self, ids): result = {"list": []} for album_id in ids or []: html = self._get_html(f"/albums/{album_id}") vod = self._parse_detail_page(html, str(album_id)) if vod: result["list"].append(vod) return result def searchContent(self, key, quick, pg="1"): keyword = str(key or "").strip() if not keyword: return {"page": 1, "limit": 0, "total": 0, "list": []} html = self._get_html(f"/search?q={quote(keyword)}") items = self._parse_search_nuxt(html) if not items: items = self._parse_home_list(html) lowered = keyword.lower() filtered = [ item for item in self._unique_by_id(items) if lowered in f"{item.get('vod_name', '')} {item.get('vod_remarks', '')}".lower() ] return {"page": int(pg or 1), "limit": len(filtered), "total": len(filtered), "list": filtered} def playerContent(self, flag, id, vipFlags): album_id, chapter_idx = str(id or "").split("|", 1) fallback = f"{self.host}/audios/{album_id}/{chapter_idx}" try: auth = self._anonymous_auth() extra_headers = { "Accept": "application/json", "Cookie": auth.get("cookie", ""), } if auth.get("auth_token"): extra_headers["Authorization"] = f"Bearer {auth['auth_token']}" payload = self._api_post( "/api/play_token", {"album_id": int(album_id), "chapter_idx": int(chapter_idx)}, extra_headers=extra_headers, ) data = self._normalize_api_result(payload) url = self._extract_play_url(data) if url: return {"parse": 0, "url": url, "header": self._get_headers(extra_headers)} except Exception: pass return {"parse": 1, "url": fallback, "header": self._get_headers()}