fix(register): align live sentinel flow with successful HAR
This commit is contained in:
251
src/sentinel_solver.py
Normal file
251
src/sentinel_solver.py
Normal file
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.resources
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
|
||||
SENTINEL_ORIGIN = "https://sentinel.openai.com"
|
||||
SENTINEL_BOOTSTRAP_URL = f"{SENTINEL_ORIGIN}/backend-api/sentinel/sdk.js"
|
||||
SDK_VERSION_RE = re.compile(r"script\.src\s*=\s*'https://sentinel\.openai\.com/sentinel/([^']+)/sdk\.js'")
|
||||
|
||||
|
||||
class SentinelSolveError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SentinelSolver:
|
||||
def __init__(
|
||||
self,
|
||||
http,
|
||||
*,
|
||||
node_binary: str = "node",
|
||||
runner_path: str | Path | None = None,
|
||||
timeout_seconds: float = 30.0,
|
||||
) -> None:
|
||||
self.http = http
|
||||
self.node_binary = node_binary
|
||||
self.runner_path = Path(runner_path) if runner_path else self._default_runner_path()
|
||||
self.timeout_seconds = timeout_seconds
|
||||
self._flow_cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def build_token(self, flow: str) -> str:
|
||||
cached = self._prepare_flow(flow)
|
||||
chat_req = cached["chat_req"]
|
||||
env = cached["env"]
|
||||
proof = cached["prepare_proof"]
|
||||
turnstile = chat_req.get("turnstile") if isinstance(chat_req, dict) else None
|
||||
dx = turnstile.get("dx") if isinstance(turnstile, dict) else None
|
||||
encoded_turnstile = None
|
||||
if dx:
|
||||
encoded_turnstile = self._run_node("turnstile", {**env, "p": proof, "dx": dx})["t"]
|
||||
if not isinstance(encoded_turnstile, str) or not encoded_turnstile:
|
||||
raise SentinelSolveError(
|
||||
f"sentinel turnstile runner returned invalid result: {encoded_turnstile!r}"
|
||||
)
|
||||
enforcement_proof = self._run_node("enforcement", {**env, "chat_req": chat_req})["p"]
|
||||
if not isinstance(enforcement_proof, str) or not enforcement_proof:
|
||||
raise SentinelSolveError(
|
||||
f"sentinel enforcement runner returned invalid proof: {enforcement_proof!r}"
|
||||
)
|
||||
|
||||
token: dict[str, Any] = {
|
||||
"p": enforcement_proof,
|
||||
"t": encoded_turnstile,
|
||||
"c": chat_req.get("token") if isinstance(chat_req, dict) else None,
|
||||
"flow": flow,
|
||||
}
|
||||
did = self._cookie_value("oai-did") or getattr(self.http, "device_id", "")
|
||||
if did:
|
||||
token["id"] = did
|
||||
return json.dumps(token, separators=(",", ":"))
|
||||
|
||||
def build_session_observer_token(self, flow: str) -> str:
|
||||
cached = self._prepare_flow(flow)
|
||||
chat_req = cached["chat_req"]
|
||||
so_payload = chat_req.get("so") if isinstance(chat_req, dict) else None
|
||||
snapshot_dx = so_payload.get("snapshot_dx") if isinstance(so_payload, dict) else None
|
||||
if not snapshot_dx:
|
||||
raise SentinelSolveError(f"sentinel session observer snapshot missing for flow {flow!r}")
|
||||
|
||||
so_value = self._run_node(
|
||||
"session-observer",
|
||||
{
|
||||
**cached["env"],
|
||||
"flow": flow,
|
||||
"c": chat_req.get("token") if isinstance(chat_req, dict) else None,
|
||||
"snapshot_dx": snapshot_dx,
|
||||
"chat_req": chat_req,
|
||||
},
|
||||
)["so"]
|
||||
if not isinstance(so_value, str) or not so_value:
|
||||
raise SentinelSolveError(
|
||||
f"sentinel session observer runner returned invalid payload: {so_value!r}"
|
||||
)
|
||||
|
||||
token: dict[str, Any] = {
|
||||
"so": so_value,
|
||||
"c": chat_req.get("token") if isinstance(chat_req, dict) else None,
|
||||
"flow": flow,
|
||||
}
|
||||
did = self._cookie_value("oai-did") or getattr(self.http, "device_id", "")
|
||||
if did:
|
||||
token["id"] = did
|
||||
return json.dumps(token, separators=(",", ":"))
|
||||
|
||||
def _fetch_sdk_version(self) -> str:
|
||||
response = self.http.request("GET", SENTINEL_BOOTSTRAP_URL)
|
||||
text = getattr(response, "text", "") or ""
|
||||
match = SDK_VERSION_RE.search(text)
|
||||
if not match:
|
||||
raise SentinelSolveError("failed to parse active sentinel SDK version")
|
||||
return match.group(1)
|
||||
|
||||
def _fetch_req_payload(self, flow: str, proof: str, sdk_version: str) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {"p": proof, "flow": flow}
|
||||
did = self._cookie_value("oai-did") or getattr(self.http, "device_id", "")
|
||||
if did:
|
||||
payload["id"] = did
|
||||
|
||||
frame_url = f"{SENTINEL_ORIGIN}/backend-api/sentinel/frame.html?sv={quote(sdk_version)}"
|
||||
self._warm_frame(frame_url)
|
||||
response = self.http.request(
|
||||
"POST",
|
||||
f"{SENTINEL_ORIGIN}/backend-api/sentinel/req",
|
||||
data=json.dumps(payload),
|
||||
headers={
|
||||
"Content-Type": "text/plain;charset=UTF-8",
|
||||
"Accept": "*/*",
|
||||
"Origin": SENTINEL_ORIGIN,
|
||||
"Referer": frame_url,
|
||||
},
|
||||
)
|
||||
if getattr(response, "status_code", 0) != 200:
|
||||
raise SentinelSolveError(
|
||||
f"sentinel req failed: {getattr(response, 'status_code', 'unknown')} {self._response_excerpt(response)}"
|
||||
)
|
||||
try:
|
||||
data = response.json()
|
||||
except Exception as error: # pragma: no cover - defensive
|
||||
raise SentinelSolveError(f"sentinel req returned non-JSON response: {error}") from error
|
||||
if not isinstance(data, dict):
|
||||
raise SentinelSolveError(f"sentinel req returned invalid payload: {data!r}")
|
||||
return data
|
||||
|
||||
def _prepare_flow(self, flow: str) -> dict[str, Any]:
|
||||
cached = self._flow_cache.get(flow)
|
||||
if cached:
|
||||
return cached
|
||||
|
||||
version = self._fetch_sdk_version()
|
||||
env = self._node_environment(version)
|
||||
proof = self._run_node("prepare", env)["p"]
|
||||
if not isinstance(proof, str) or not proof:
|
||||
raise SentinelSolveError(f"sentinel prepare returned invalid proof: {proof!r}")
|
||||
|
||||
chat_req = self._fetch_req_payload(flow, proof, version)
|
||||
cached = {
|
||||
"sdk_version": version,
|
||||
"env": env,
|
||||
"prepare_proof": proof,
|
||||
"chat_req": chat_req,
|
||||
}
|
||||
self._flow_cache[flow] = cached
|
||||
return cached
|
||||
|
||||
def _node_environment(self, sdk_version: str) -> dict[str, Any]:
|
||||
sdk_url = f"{SENTINEL_ORIGIN}/sentinel/{sdk_version}/sdk.js"
|
||||
accept_language = self._session_header("Accept-Language")
|
||||
return {
|
||||
"sdk_version": sdk_version,
|
||||
"sdk_url": sdk_url,
|
||||
"sdk_bootstrap_url": SENTINEL_BOOTSTRAP_URL,
|
||||
"sdk_version_url": sdk_url,
|
||||
"location_search_prepare": f"?sv={sdk_version}",
|
||||
"location_search_enforcement": "",
|
||||
"user_agent": self._session_header("User-Agent"),
|
||||
"accept_language": accept_language,
|
||||
"hardware_concurrency": 8,
|
||||
"screen_width": 1920,
|
||||
"screen_height": 1080,
|
||||
"js_heap_size_limit": 4294967296,
|
||||
}
|
||||
|
||||
def _run_node(self, mode: str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
command = [self.node_binary, str(self.runner_path), mode]
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
input=json.dumps(payload),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
timeout=self.timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except FileNotFoundError as error:
|
||||
raise SentinelSolveError(f"Node.js executable not found: {self.node_binary}") from error
|
||||
except subprocess.TimeoutExpired as error:
|
||||
raise SentinelSolveError(f"sentinel {mode} timed out") from error
|
||||
|
||||
if completed.returncode != 0:
|
||||
stderr = (completed.stderr or "").strip()
|
||||
stdout = (completed.stdout or "").strip()
|
||||
detail = stderr or stdout or f"exit code {completed.returncode}"
|
||||
raise SentinelSolveError(f"sentinel {mode} failed: {detail}")
|
||||
|
||||
stdout = (completed.stdout or "").strip()
|
||||
if not stdout:
|
||||
raise SentinelSolveError(f"sentinel {mode} returned empty stdout")
|
||||
try:
|
||||
data = json.loads(stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise SentinelSolveError(f"sentinel {mode} returned invalid JSON: {stdout!r}") from error
|
||||
if not isinstance(data, dict):
|
||||
raise SentinelSolveError(f"sentinel {mode} returned invalid payload: {data!r}")
|
||||
return data
|
||||
|
||||
def _cookie_value(self, name: str) -> str:
|
||||
session = getattr(self.http, "session", None)
|
||||
cookies = getattr(session, "cookies", None)
|
||||
jar = getattr(cookies, "jar", None)
|
||||
if not jar:
|
||||
return ""
|
||||
for cookie in jar:
|
||||
if getattr(cookie, "name", "") == name:
|
||||
return getattr(cookie, "value", "")
|
||||
return ""
|
||||
|
||||
def _session_header(self, name: str) -> str:
|
||||
session = getattr(self.http, "session", None)
|
||||
headers = getattr(session, "headers", {}) or {}
|
||||
return headers.get(name, "")
|
||||
|
||||
def _warm_frame(self, frame_url: str) -> None:
|
||||
response = self.http.request(
|
||||
"GET",
|
||||
frame_url,
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"Referer": SENTINEL_BOOTSTRAP_URL,
|
||||
},
|
||||
)
|
||||
if getattr(response, "status_code", 0) not in {200, 204}:
|
||||
raise SentinelSolveError(
|
||||
f"sentinel frame warmup failed: {getattr(response, 'status_code', 'unknown')} {self._response_excerpt(response)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _response_excerpt(response, limit: int = 300) -> str:
|
||||
text = getattr(response, "text", "") or ""
|
||||
return text.replace("\n", " ").replace("\r", " ")[:limit]
|
||||
|
||||
@staticmethod
|
||||
def _default_runner_path() -> Path:
|
||||
try:
|
||||
return Path(importlib.resources.files(__package__).joinpath("sentinel_runner.js"))
|
||||
except Exception:
|
||||
return Path(__file__).with_name("sentinel_runner.js")
|
||||
Reference in New Issue
Block a user