"""Nebula H5 `$encode` signInput builder and local JS-VM bridge. 前端真实逻辑在 `main-CZ3ZSK5w.js`: 1. 从 cookie 只取白名单设备字段; 2. 加入 `sigCatVer=1` 和接口 query/body; 3. 按 `key=value` 字符串字典序拼接; 4. 调 KsGuard/Yoda VM 的 `$encode` 生成 68hex `__NS_sig3`。 这里保留稳定的 signInput 组装规则和本地 VM runner fallback。正常主流程 已经在 `core.h5_sig3` 里用纯 Python 复现 `$encode` 的摘要字段。 """ from __future__ import annotations import json import os import queue import subprocess import threading from dataclasses import dataclass from pathlib import Path from typing import Any H5_COOKIE_KEYS = ( "kpn", "kpf", "userId", "did", "c", "appver", "language", "mod", "did_tag", "egid", "oDid", "androidApiLevel", "newOc", "browseType", "socName", "ftt", "abi", "userRecoBit", "device_abi", "grant_browse_type", "iuid", "rdid", ) DEFAULT_H5_ENCODE_RUNNER = Path("core/h5_vendor_encode.mjs") DEFAULT_H5_ENCODE_SERVER = Path("core/h5_vendor_encode_server.mjs") @dataclass(frozen=True) class H5EncodeResult: sign_input: str sig3: str c_info: Any = None class H5JsBridgeEncoder: """长驻 Node `$encode` 进程,避免每次签名重复加载 VM chunk。""" def __init__( self, server: str | Path = DEFAULT_H5_ENCODE_SERVER, node_bin: str | None = None, timeout: int = 30, ) -> None: self.server = Path(server) self.node = node_bin or os.environ.get("NODE_BIN") or "node" self.timeout = timeout self._proc: subprocess.Popen[str] | None = None self._stdout_queue: queue.Queue[str] = queue.Queue() self._lock = threading.Lock() self._request_id = 0 def _start(self) -> None: if self._proc is not None and self._proc.poll() is None: return if not self.server.exists(): raise FileNotFoundError(f"H5 encode server not found: {self.server}") self._stdout_queue = queue.Queue() self._proc = subprocess.Popen( [self.node, str(self.server)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, encoding="utf-8", bufsize=1, ) def read_stdout() -> None: assert self._proc is not None assert self._proc.stdout is not None for line in self._proc.stdout: self._stdout_queue.put(line) thread = threading.Thread(target=read_stdout, name="H5JsBridgeEncoderStdout", daemon=True) thread.start() def close(self) -> None: proc = self._proc self._proc = None if proc is None: return try: if proc.stdin: proc.stdin.close() except Exception: pass try: if proc.stdout: proc.stdout.close() except Exception: pass try: proc.wait(timeout=1) except Exception: try: proc.kill() except Exception: pass def encode(self, sign_input: str) -> H5EncodeResult: with self._lock: self._start() assert self._proc is not None assert self._proc.stdin is not None self._request_id += 1 request_id = self._request_id payload = json.dumps( {"id": request_id, "signInput": sign_input}, ensure_ascii=False, separators=(",", ":"), ) try: self._proc.stdin.write(payload + "\n") self._proc.stdin.flush() except Exception: self.close() raise while True: try: line = self._stdout_queue.get(timeout=self.timeout) except queue.Empty as exc: self.close() raise TimeoutError("H5 encode server timed out") from exc try: data = json.loads(line) except json.JSONDecodeError: continue if data.get("id") not in {request_id, None}: continue if not data.get("ok", True): raise RuntimeError(f"H5 encode server failed: {data.get('error')}") sig3 = str(data.get("result") or "") if len(sig3) != 68: raise RuntimeError(f"H5 encode server returned invalid sig3: {sig3!r}") return H5EncodeResult(sign_input=sign_input, sig3=sig3, c_info=data.get("cInfo")) def _js_json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, separators=(",", ":")) def _h5_io(params: dict[str, Any]) -> str: parts: list[str] = [] for key, value in params.items(): if key.startswith("__NS"): continue if isinstance(value, (dict, list)): value = "" parts.append(f"{key}={value}") return "".join(sorted(parts)) def build_h5_sign_input( cookie: dict[str, str], query: dict[str, Any] | None = None, body: str | dict[str, Any] | list[Any] | None = None, method: str = "GET", request_type: str = "json", ) -> str: """按前端 `Mc()/io()` 规则构造 `$encode` 的 secPlain。""" selected = {key: cookie[key] for key in H5_COOKIE_KEYS if cookie.get(key)} params: dict[str, Any] = {"sigCatVer": 1, **selected, **(query or {})} method_lower = method.lower() request_type_lower = request_type.lower() if request_type_lower == "json": if method_lower in {"get", "options", "head"}: if isinstance(body, dict): params.update(body) return _h5_io(params) if body is None: body_text = "" elif isinstance(body, str): body_text = body else: body_text = _js_json(body) return _h5_io(params) + body_text if request_type_lower == "form" and isinstance(body, dict): params.update(body) return _h5_io(params) def encode_h5_sig3_with_runner( sign_input: str, runner: str | Path = DEFAULT_H5_ENCODE_RUNNER, node_bin: str | None = None, timeout: int = 30, ) -> H5EncodeResult: """调用本地 JS-VM runner 生成 H5 68hex `__NS_sig3`。""" runner_path = Path(runner) if not runner_path.exists(): raise FileNotFoundError(f"H5 encode runner not found: {runner_path}") node = node_bin or os.environ.get("NODE_BIN") or "node" proc = subprocess.run( [node, str(runner_path), "-"], input=sign_input, text=True, capture_output=True, timeout=timeout, check=False, ) if proc.returncode != 0: detail = (proc.stderr or proc.stdout or "").strip() raise RuntimeError(f"H5 encode runner failed: {detail}") data = json.loads(proc.stdout) sig3 = str(data.get("result") or "") if len(sig3) != 68: raise RuntimeError(f"H5 encode runner returned invalid sig3: {sig3!r}") return H5EncodeResult(sign_input=sign_input, sig3=sig3, c_info=data.get("cInfo")) def h5_sig3_for_request( cookie: dict[str, str], query: dict[str, Any] | None = None, body: str | dict[str, Any] | list[Any] | None = None, method: str = "GET", request_type: str = "json", runner: str | Path = DEFAULT_H5_ENCODE_RUNNER, node_bin: str | None = None, timeout: int = 30, encoder: H5JsBridgeEncoder | None = None, ) -> H5EncodeResult: sign_input = build_h5_sign_input( cookie, query=query, body=body, method=method, request_type=request_type, ) if encoder is not None: return encoder.encode(sign_input) return encode_h5_sig3_with_runner(sign_input, runner=runner, node_bin=node_bin, timeout=timeout) __all__ = [ "DEFAULT_H5_ENCODE_RUNNER", "DEFAULT_H5_ENCODE_SERVER", "H5_COOKIE_KEYS", "H5EncodeResult", "H5JsBridgeEncoder", "build_h5_sign_input", "encode_h5_sig3_with_runner", "h5_sig3_for_request", ]