# -*- coding: utf-8 -*- # //@name:BadNews直播放 # //@id:badnews_direct # //@version:7 import hashlib import html as html_lib import json import re import time from urllib.parse import quote, unquote, urljoin, urlsplit import requests from lxml import html from base.spider import Spider as BaseSpider try: from com.github.catvod import Proxy as CatVodProxy except Exception: CatVodProxy = None class Spider(BaseSpider): name = "BadNews直播放" host = "https://bad.news" backend_parse = False category_mode = False categoryMode = False PLAY_PREFIX = "badnews-play:" ERROR_PREFIX = "badnews-error:" DEFAULT_PIC = "https://bad.news/favicon.ico" CATEGORY_SPECS = ( ("hot", "热门视频", "entry", "/sort-hot"), ("new", "最新视频", "entry", "/sort-new"), ("short", "短视频", "entry", "/tag/porn"), ("long", "长视频", "entry", "/tag/long-porn"), ("dm", "H动漫", "dm", "/dm"), ("dm_3d", "3D动画", "dm", "/dm/type/q-3D"), ("dm_doujin", "同人作品", "dm", "/dm/type/q-同人"), ("dm_cosplay", "Cosplay", "dm", "/dm/type/q-Cosplay"), ("better", "精选视频", "entry", "/sort-better"), ("score", "高分视频", "entry", "/sort-score"), ) BLOCKED_HOSTS = frozenset( { "script-center.bad.news", "portalfluently.com", "vivodemisrentas.net", "secretlygoatsarrangement.com", "ri1.xlfn.cc", "static.cloudflareinsights.com", "www.google-analytics.com", "www.googletagmanager.com", "www.statcounter.com", } ) MEDIA_HOSTS = frozenset({"video.twimg.com", "static.bad.news"}) CHALLENGE_MARKERS = ( "just a moment", "/cdn-cgi/challenge-platform", "_cf_chl_opt", "cf-turnstile", "turnstile", ) CONTENT_MARKERS = ( 'class="entry', "class='entry", " 1800: return [404, "text/plain; charset=utf-8", b"manifest not found"] return [ 200, "application/vnd.apple.mpegurl", cached[1], {"Cache-Control": "no-store", "Access-Control-Allow-Origin": "*"}, ] def homeContent(self, filter): return { "class": [ {"type_id": type_id, "type_name": type_name} for type_id, type_name, _, _ in self.CATEGORY_SPECS ], "filters": {}, } def homeVideoContent(self): result = self.categoryContent("new", "1", False, {}) return {"list": result.get("list", [])} def categoryContent(self, tid, pg, filter, extend): self._parse_config(extend) page = self._page_number(pg) spec = self._category_spec(tid) if spec is None: return self._empty_page(page, "未知分类") _, _, parser_kind, base_path = spec paths = [self._paged_path(base_path, page)] if str(tid) == "hot": paths.append("/" if page == 1 else "/page-%d" % page) last_error = None for path in paths: try: source, page_url = self._request_text(path) if parser_kind == "dm": return self._parse_dm_page(source, page, page_url) return self._parse_entry_page(source, page, page_url) except Exception as exc: last_error = exc print("[badnews-probe] category path=%s error=%s" % (path, exc)) return self._empty_page(page, "分类读取失败: %s" % last_error) def searchContent(self, key, quick, pg="1"): keyword = self._clean_text(key) page = self._page_number(pg) if not keyword: return self._empty_page(page) encoded = quote(keyword, safe="") main_path = "/search/q-%s/type-porn" % encoded dm_path = "/dm/search/q-%s" % encoded if page > 1: main_path += "/page-%d" % page dm_path += "/page-%d" % page items = [] pagecount = page errors = [] for parser_kind, path in (("entry", main_path), ("dm", dm_path)): try: source, page_url = self._request_text(path) parsed = ( self._parse_dm_page(source, page, page_url) if parser_kind == "dm" else self._parse_entry_page(source, page, page_url) ) items.extend(parsed.get("list", [])) pagecount = max(pagecount, self._page_number(parsed.get("pagecount"))) except Exception as exc: errors.append(str(exc)) deduped = [] seen = set() for item in items: vod_id = str(item.get("vod_id") or "") if not vod_id or vod_id in seen: continue seen.add(vod_id) deduped.append(item) return { "list": deduped, "page": page, "pagecount": pagecount, "limit": len(deduped), "total": pagecount * max(len(deduped), 1), "msg": "; ".join(errors) if errors and not deduped else "", } def detailContent(self, ids): raw_id = ids[0] if isinstance(ids, (list, tuple)) and ids else ids value = str(raw_id or "").strip() if value.startswith("atvp_detail:"): value = value[len("atvp_detail:") :].strip() if value.startswith(self.PLAY_PREFIX): value = value[len(self.PLAY_PREFIX) :].strip() kind, item_id = self._split_vod_id(value) if not kind or not item_id: return {"list": []} path = "/t/%s" % item_id if kind == "t" else "/dm/play/id-%s" % item_id try: source, page_url = self._request_text(path, fresh=(kind == "dm")) vod, _ = self._parse_detail_page(source, kind, item_id, page_url) return {"list": [vod]} except Exception as exc: return {"list": [self._detail_error(value, str(exc))]} def playerContent(self, flag, id, vipFlags): value = str(id or "").strip() if value.startswith(self.ERROR_PREFIX): return self._player_error(unquote(value[len(self.ERROR_PREFIX) :])) if value.startswith(("http://", "https://")): if not self._is_allowed_media_url(value): return self._player_error("播放地址域名不在媒体白名单") return self._player_for_media(value, self._media_type(value)) if not value.startswith(self.PLAY_PREFIX): return self._player_error("无法识别播放 ID") kind, item_id = self._split_vod_id(value[len(self.PLAY_PREFIX) :]) if not kind or not item_id: return self._player_error("播放 ID 不完整") path = "/t/%s" % item_id if kind == "t" else "/dm/play/id-%s" % item_id try: source, page_url = self._request_text(path, fresh=(kind == "dm")) _, media = self._parse_detail_page(source, kind, item_id, page_url) return self._player_for_media( media["url"], media["type"], media.get("source", "primary") ) except Exception as exc: return self._player_error("播放地址刷新失败: %s" % exc) def _reset_session(self): if self._session is not None: try: self._session.close() except Exception: pass session = requests.Session() session.trust_env = self.trust_env session.headers.update( { "User-Agent": self.user_agent, "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.5", "Cache-Control": "no-cache", } ) if self.proxy: session.proxies.update({"http": self.proxy, "https": self.proxy}) self._session = session def _request_text(self, path, fresh=False): url = self._absolute_url(path) if not self._is_allowed_html_url(url): raise RuntimeError("已阻止非站点 HTML 请求") now = time.time() if not fresh and self.cache_ttl > 0: cached = self._cache.get(url) if cached and now - cached[0] <= self.cache_ttl: return cached[1], cached[2] last_error = None for attempt in range(2): try: response = self._session.get( url, timeout=(min(self.timeout, 10), self.timeout), allow_redirects=True, verify=self.verify_tls, ) final_url = str(response.url or url) if not self._is_allowed_html_url(final_url): raise RuntimeError("已阻止外域跳转: %s" % urlsplit(final_url).hostname) text = self._response_text(response) if self._looks_like_challenge(response.status_code, text): raise RuntimeError("blocked_by_waf: 页面返回挑战或验证码") if response.status_code == 429 and attempt == 0: retry_after = self._bounded_int( response.headers.get("Retry-After"), 1, 1, 3 ) time.sleep(retry_after) continue response.raise_for_status() if not fresh and self.cache_ttl > 0: self._cache[url] = (time.time(), text, final_url) return text, final_url except requests.RequestException as exc: last_error = exc if attempt == 0: time.sleep(0.4) continue break except RuntimeError: raise raise RuntimeError("网络请求失败: %s" % last_error) def _parse_entry_page(self, source, page, page_url): tree = self._tree(source) items = [] entries = tree.xpath( '//div[contains(concat(" ", normalize-space(@class), " "), " entry ")]' ) for entry in entries: videos = entry.xpath('.//video[@data-source or @data-id]') if not videos: continue video = videos[0] item_id = self._digits(video.get("data-id")) if not item_id: item_id = self._first_matching_id(entry.xpath('.//a/@href'), self.TOPIC_ID_RE) if not item_id: continue title = self._first_text( entry.xpath( './/h3[contains(concat(" ", normalize-space(@class), " "), " title ")]' '/a[contains(concat(" ", normalize-space(@class), " "), " title ")][1]' ) ) if not title: title = "视频 %s" % item_id pic = self._absolute_media_url( video.get("data-poster") or video.get("poster") or "", page_url ) duration = self._clean_text( " ".join(entry.xpath('.//*[contains(@class,"ct-time")]//text()')) ) tag = self._first_text(entry.xpath('.//h4[contains(@class,"label")]')) media_type = str(video.get("data-type") or "").upper() remarks = duration or tag or media_type items.append( { "vod_id": "t:%s" % item_id, "vod_name": title, "vod_pic": pic or self.DEFAULT_PIC, "vod_remarks": remarks, } ) return self._page_result(items, tree, page, 25) def _parse_dm_page(self, source, page, page_url): tree = self._tree(source) items = [] articles = tree.xpath('//article[.//a[contains(@href,"/dm/play/id-")]]') for article in articles: title_links = article.xpath( './/a[contains(concat(" ", normalize-space(@class), " "), " title ")][1]' ) links = title_links or article.xpath('.//a[contains(@href,"/dm/play/id-")][1]') if not links: continue link = links[0] href = str(link.get("href") or "") item_id = self._first_matching_id([href], self.DM_ID_RE) if not item_id: continue title = self._clean_text(link.get("title") or link.text_content()) images = article.xpath('.//img[1]') pic = "" if images: image = images[0] pic = self._absolute_media_url( image.get("data-echo") or image.get("data-src") or image.get("src") or "", page_url, ) items.append( { "vod_id": "dm:%s" % item_id, "vod_name": title or "动漫 %s" % item_id, "vod_pic": pic or self.DEFAULT_PIC, "vod_remarks": "MP4", } ) return self._page_result(items, tree, page, 30) def _parse_detail_page(self, source, kind, item_id, page_url): tree = self._tree(source) if kind == "t": videos = tree.xpath('//video[@data-id="%s"]' % item_id) if not videos: videos = tree.xpath('//video[@data-source][1]') else: videos = tree.xpath('//video[@data-source][1]') if not videos: raise RuntimeError("详情页没有 video[data-source]") video = videos[0] media_url = self._absolute_media_url( video.get("data-source") or video.get("src") or "", page_url ) if not self._is_allowed_media_url(media_url): raise RuntimeError("详情媒体域名不在白名单") detected_type = self._media_type(media_url) declared_type = str(video.get("data-type") or "").lower() media_type = ( detected_type if detected_type in ("mp4", "m3u8") else declared_type ) media_source = "primary-%s" % media_type if self.prefer_progressive_mp4 and media_type == "m3u8": for meta_property in ("og:video:secure_url", "og:video"): progressive_url = self._absolute_media_url( self._meta_content(tree, "property", meta_property), page_url ) if ( self._media_type(progressive_url) == "mp4" and self._is_allowed_media_url(progressive_url) ): media_url = progressive_url media_type = "mp4" media_source = "target-og-progressive-mp4" break title = self._meta_content(tree, "property", "og:title") if not title: title = self._meta_content(tree, "name", "headline") if not title: headings = tree.xpath('//h1[1] | //h2[1]') title = self._first_text(headings) if not title: title = ("动漫 " if kind == "dm" else "视频 ") + item_id pic = self._meta_content(tree, "property", "og:image") if not pic: pic = video.get("data-poster") or video.get("poster") or "" pic = self._absolute_media_url(pic, page_url) or self.DEFAULT_PIC content = self._meta_content(tree, "name", "Description") if not content: content = self._meta_content(tree, "property", "og:description") vod_id = "%s:%s" % (kind, item_id) play_target = self.PLAY_PREFIX + vod_id vod = { "vod_id": vod_id, "vod_name": title, "vod_pic": pic, "vod_remarks": media_type.upper(), "vod_content": content, "vod_play_from": "BadNews动漫" if kind == "dm" else "BadNews直连", "vod_play_url": "播放$%s" % play_target, } return vod, {"url": media_url, "type": media_type, "source": media_source} def _page_result(self, items, tree, page, default_limit): pagecount = page for href in tree.xpath('//a/@href'): match = self.PAGE_RE.search(str(href or "")) if match: pagecount = max(pagecount, self._page_number(match.group(1))) limit = len(items) or default_limit return { "list": items, "page": page, "pagecount": pagecount, "limit": limit, "total": pagecount * limit, } def _player_result(self, media_url, media_type): result = { "parse": 0, "jx": 0, "playUrl": "", "url": media_url, "header": {"User-Agent": self.user_agent}, "type": media_type, } if media_type == "m3u8": result["format"] = "application/x-mpegURL" return result def _player_for_media(self, media_url, media_type, media_source="primary"): self._probe_log( "media_selected type=%s source=%s host=%s" % (media_type, media_source, urlsplit(media_url).hostname or "") ) if media_type == "m3u8" and self.lock_hls_highest: try: locked_url = self._prepare_locked_hls(media_url) if locked_url: self._probe_log("hls_highest_locked source=%s" % media_source) return self._player_result(locked_url, "m3u8") except Exception as exc: self._probe_log("hls_lock_failed url=%s error=%s" % (media_url, exc)) self._probe_log("hls_original_fallback source=%s" % media_source) return self._player_result(media_url, media_type) def _prepare_locked_hls(self, master_url): source = self._request_media_text(master_url) manifest = self._highest_hls_manifest(source, master_url) if not manifest: return "" token = hashlib.sha256( (master_url + "\n" + manifest).encode("utf-8") ).hexdigest()[:24] self._proxy_manifests[token] = (time.time(), manifest.encode("utf-8")) if len(self._proxy_manifests) > 16: oldest = min(self._proxy_manifests, key=lambda key: self._proxy_manifests[key][0]) self._proxy_manifests.pop(oldest, None) site_key = quote(str(getattr(self, "siteKey", "") or "badnews"), safe="") return "%s?siteKey=%s&token=%s" % ( self._proxy_base_url(), site_key, token, ) def _request_media_text(self, url): if not self._is_allowed_media_url(url) or self._media_type(url) != "m3u8": raise RuntimeError("媒体列表地址不在白名单") last_error = None for attempt in range(3): try: response = self._session.get( url, headers={ "User-Agent": self.user_agent, "Accept": "application/vnd.apple.mpegurl,application/x-mpegURL,*/*", }, timeout=(min(self.timeout, 10), self.timeout), verify=self.verify_tls, allow_redirects=True, ) if not 200 <= response.status_code < 300: raise RuntimeError("HLS HTTP %s" % response.status_code) source = response.content.decode("utf-8", errors="replace") if "#EXTM3U" not in source: raise RuntimeError("HLS 响应缺少 EXTM3U") return source except Exception as exc: last_error = exc if attempt < 2: time.sleep(0.15 * (attempt + 1)) raise RuntimeError("HLS 主列表读取失败: %s" % last_error) def _highest_hls_manifest(self, source, master_url): lines = [line.strip() for line in str(source or "").splitlines() if line.strip()] streams = [] media_lines = {} for index, line in enumerate(lines): if line.startswith("#EXT-X-MEDIA:"): attrs = self._hls_attrs(line.split(":", 1)[1]) if attrs.get("TYPE") == "AUDIO" and attrs.get("GROUP-ID"): media_lines[attrs["GROUP-ID"]] = (line, attrs) elif line.startswith("#EXT-X-STREAM-INF:"): attrs = self._hls_attrs(line.split(":", 1)[1]) uri = "" for candidate in lines[index + 1 :]: if candidate.startswith("#"): continue uri = candidate break if uri: resolution = attrs.get("RESOLUTION", "0x0").lower().split("x") try: pixels = int(resolution[0]) * int(resolution[1]) except Exception: pixels = 0 try: bandwidth = int(attrs.get("AVERAGE-BANDWIDTH") or attrs.get("BANDWIDTH") or 0) except Exception: bandwidth = 0 streams.append((pixels, bandwidth, line, attrs, uri)) if not streams: return "" _, _, stream_line, stream_attrs, stream_uri = max( streams, key=lambda item: (item[0], item[1]) ) audio_group = stream_attrs.get("AUDIO", "") output = ["#EXTM3U", "#EXT-X-VERSION:6", "#EXT-X-INDEPENDENT-SEGMENTS"] if audio_group in media_lines: audio_line, audio_attrs = media_lines[audio_group] audio_uri = audio_attrs.get("URI", "") if audio_uri: absolute_audio = urljoin(master_url, audio_uri) audio_line = re.sub( r'URI=(?:"[^"]*"|[^,]*)', 'URI="%s"' % absolute_audio, audio_line ) output.append(audio_line) output.append(stream_line) output.append(urljoin(master_url, stream_uri)) return "\n".join(output) + "\n" @staticmethod def _hls_attrs(text): attrs = {} for match in re.finditer(r'([A-Z0-9-]+)=("[^"]*"|[^,]*)', str(text or "")): value = match.group(2).strip() if len(value) >= 2 and value[0] == value[-1] == '"': value = value[1:-1] attrs[match.group(1)] = value return attrs @staticmethod def _proxy_base_url(): if CatVodProxy is not None: return str(CatVodProxy.getUrl(True)) return "http://127.0.0.1:9978/proxy" def _player_error(self, message): text = self._clean_text(message) or "播放失败" return { "parse": 0, "jx": 0, "playUrl": "", "url": "", "header": {}, "msg": text, "content": text, "error": text, } @staticmethod def _probe_log(message): print("[badnews-probe] %s" % message) def _detail_error(self, vod_id, message): text = self._clean_text(message) or "详情读取失败" return { "vod_id": vod_id or "error", "vod_name": "详情读取失败", "vod_pic": self.DEFAULT_PIC, "vod_content": text, "vod_play_from": "错误", "vod_play_url": "查看错误$%s%s" % (self.ERROR_PREFIX, quote(text, safe="")), } def _category_spec(self, tid): value = str(tid or "hot").strip() for spec in self.CATEGORY_SPECS: if spec[0] == value: return spec return None @staticmethod def _paged_path(base_path, page): if page <= 1: return base_path return base_path.rstrip("/") + "/page-%d" % page def _absolute_url(self, path): return urljoin(self.host.rstrip("/") + "/", str(path or "").lstrip("/")) @staticmethod def _absolute_media_url(value, page_url): text = str(value or "").strip() if not text: return "" return urljoin(page_url, text) def _is_allowed_html_url(self, url): parsed = urlsplit(str(url or "")) host = (parsed.hostname or "").lower() configured_host = (urlsplit(self.host).hostname or "").lower() return ( parsed.scheme in ("http", "https") and bool(host) and host == configured_host and host not in self.BLOCKED_HOSTS ) def _is_allowed_media_url(self, url): parsed = urlsplit(str(url or "")) host = (parsed.hostname or "").lower() return ( parsed.scheme in ("http", "https") and host in self.MEDIA_HOSTS and host not in self.BLOCKED_HOSTS and self.isVideoFormat(url) ) def _looks_like_challenge(self, status_code, source): sample = str(source or "")[:200000].lower() status = int(status_code or 0) if 200 <= status < 300 and any( marker in sample for marker in self.CONTENT_MARKERS ): return False if any(marker in sample for marker in self.CHALLENGE_MARKERS): return True return status in (403, 503) and "cloudflare" in sample @staticmethod def _response_text(response): content = bytes(response.content or b"") declared = str(response.encoding or "").strip() normalized = declared.lower().replace("_", "-") if not normalized or normalized in { "iso-8859-1", "utf-32", "utf-32le", "utf-32be", "usc4 little endian", "usc4 big endian", }: chosen = "utf-8" else: chosen = declared try: text = content.decode(chosen, errors="replace") except (LookupError, UnicodeError): chosen = "utf-8" text = content.decode(chosen, errors="replace") if chosen.lower() != normalized: print( "[badnews-probe] encoding declared=%s chosen=%s bytes=%d content_type=%s" % ( declared or "none", chosen, len(content), response.headers.get("Content-Type", ""), ) ) return text @staticmethod def _tree(source): if isinstance(source, bytes): payload = source else: payload = str(source or "").encode("utf-8", errors="replace") parser = html.HTMLParser(encoding="utf-8", recover=True) return html.fromstring(payload, parser=parser) def _meta_content(self, tree, attr_name, attr_value): nodes = tree.xpath( '//meta[translate(@%s,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz")="%s"]/@content' % (attr_name, attr_value.lower()) ) return self._clean_text(nodes[0]) if nodes else "" def _first_text(self, nodes): for node in nodes or []: try: value = node.text_content() except Exception: value = str(node or "") value = self._clean_text(value) if value: return value return "" def _split_vod_id(self, value): text = str(value or "").strip() if ":" not in text: return ("t", self._digits(text)) if self._digits(text) else ("", "") kind, item_id = text.split(":", 1) kind = kind.strip().lower() item_id = self._digits(item_id) if kind not in ("t", "dm") or not item_id: return "", "" return kind, item_id @staticmethod def _first_matching_id(values, pattern): for value in values or []: match = pattern.search(str(value or "")) if match: return match.group(1) return "" @staticmethod def _digits(value): match = re.search(r"\d+", str(value or "")) return match.group(0) if match else "" def _media_type(self, url): text = str(url or "").lower() if ".m3u8" in text: return "m3u8" if ".mp4" in text: return "mp4" return "" @staticmethod def _parse_config(extend): if isinstance(extend, dict): return dict(extend) text = str(extend or "").strip() if not text: return {} if text.startswith(("http://", "https://")): return {"host": text} try: value = json.loads(text) return value if isinstance(value, dict) else {} except Exception: return {} @staticmethod def _bool_value(value, default=False): if isinstance(value, bool): return value if value is None: return bool(default) return str(value).strip().lower() in ("1", "true", "yes", "on") @staticmethod def _bounded_int(value, default, minimum=1, maximum=999999): try: number = int(value) except Exception: number = int(default) return max(minimum, min(maximum, number)) def _page_number(self, value): return self._bounded_int(value, 1, 1, 999999) @staticmethod def _clean_text(value): text = html_lib.unescape(str(value or "")) return re.sub(r"\s+", " ", text).strip() @staticmethod def _empty_page(page, message=""): return { "list": [], "page": page, "pagecount": page, "limit": 0, "total": 0, "msg": message, }