Download sentinel_solver/app.py from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 10.2 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/sentinel_solver/app.py
- Command line
-
hf download hf://spaces/zlcy/study/sentinel_solver/app.py
-
curl -L -o app.py https://huggingface.co/spaces/zlcy/study/resolve/main/sentinel_solver/app.py
10.2 kB
| """sentinel 求解服务:常驻一个无头 Chrome 的 chatgpt.com 页面,替主服务计算 Turnstile / SO token。 | |
| 2026-10-14 起 chatgpt.com 的对话接口要求 Turnstile token 必须在真实的 chatgpt.com 页面里算出, | |
| 纯 Python/Node 模拟的结果会被拒(403)。主服务不打包浏览器,改为通过 HTTP 调用本服务。 | |
| 环境变量: | |
| - SENTINEL_SOLVER_KEY:可选,设置后请求需带 `Authorization: Bearer <key>` | |
| - SENTINEL_SOLVER_PROXY:可选,浏览器出网代理(如 http://host:7890),应与主服务出口一致 | |
| - SENTINEL_SOLVER_USER_AGENT:可选,默认按内置 Chromium 版本生成 Linux Chrome UA | |
| - SENTINEL_SOLVER_TIMEZONE:可选,页面时区(如 America/Los_Angeles),应与出口 IP 所在地一致;默认跟随系统 | |
| - SENTINEL_SOLVER_LOCALE:可选,页面语言,默认 en-US;应与出口 IP 所在地一致 | |
| - SENTINEL_SOLVER_REFRESH_SECONDS:页面定期重开的间隔,默认 1800 | |
| - SENTINEL_SOLVER_HEADLESS:默认 true;调试时可设为 false | |
| - PORT:监听端口,默认 8080 | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import hmac | |
| import os | |
| import time | |
| from contextlib import asynccontextmanager | |
| from pathlib import Path | |
| import uvicorn | |
| from fastapi import FastAPI, Header, HTTPException | |
| from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright | |
| from playwright_stealth import Stealth | |
| from pydantic import BaseModel | |
| CHATGPT_URL = "https://chatgpt.com/" | |
| CHALLENGE_TITLES = ("请稍候", "Just a moment", "Attention Required") | |
| VM_SCRIPT = (Path(__file__).parent / "vm.js").read_text(encoding="utf-8") | |
| BASE_INIT_SCRIPT = """ | |
| Object.defineProperty(navigator, 'webdriver', { get: () => undefined }); | |
| window.chrome = window.chrome || { runtime: {} }; | |
| """ | |
| SOLVER_KEY = os.getenv("SENTINEL_SOLVER_KEY", "").strip() | |
| PROXY = os.getenv("SENTINEL_SOLVER_PROXY", "").strip() | |
| USER_AGENT = os.getenv("SENTINEL_SOLVER_USER_AGENT", "").strip() | |
| TIMEZONE = os.getenv("SENTINEL_SOLVER_TIMEZONE", "").strip() | |
| # 页面语言应与出口 IP 所在地一致;此前硬编码 zh-CN,若出口在海外会构成矛盾特征。 | |
| LOCALE = os.getenv("SENTINEL_SOLVER_LOCALE", "en-US").strip() or "en-US" | |
| REFRESH_SECONDS = max(60, int(os.getenv("SENTINEL_SOLVER_REFRESH_SECONDS", "1800"))) | |
| HEADLESS = os.getenv("SENTINEL_SOLVER_HEADLESS", "true").strip().lower() not in {"0", "false", "no"} | |
| READY_TIMEOUT_SECONDS = 60 | |
| # 优先使用指定的 Chrome 可执行文件(例如精确版本的 chrome-for-testing), | |
| # 否则回退到 Playwright 自带 channel="chromium"。默认指向下好的 153.0.8010.12。 | |
| CHROME_PATH = os.getenv("SENTINEL_SOLVER_CHROME_PATH", "").strip() or os.path.join( | |
| os.environ.get("LOCALAPPDATA", ""), | |
| "ms-playwright", | |
| "chrome-153.0.8010.12", | |
| "chrome-win64", | |
| "chrome.exe", | |
| ) | |
| class SolverPage: | |
| """维护一个已通过 Cloudflare 校验的 chatgpt.com 页面,按需重开。""" | |
| def __init__(self) -> None: | |
| self._playwright: Playwright | None = None | |
| self._browser: Browser | None = None | |
| self._context: BrowserContext | None = None | |
| self._page: Page | None = None | |
| self._opened_at = 0.0 | |
| self._lock = asyncio.Lock() | |
| self.user_agent = USER_AGENT | |
| self.last_error = "" | |
| async def start(self) -> None: | |
| self._playwright = await async_playwright().start() | |
| launch_kwargs = { | |
| "headless": HEADLESS, | |
| "args": ["--disable-blink-features=AutomationControlled", "--no-sandbox", "--disable-dev-shm-usage"], | |
| } | |
| # 优先用指定的 Chrome 可执行文件(精确版本的 chrome-for-testing,UA 与后端对齐); | |
| # 否则回退到 Playwright 自带 channel="chromium"(默认 headless shell 过不了 Cloudflare)。 | |
| if CHROME_PATH and os.path.isfile(CHROME_PATH): | |
| launch_kwargs["executable_path"] = CHROME_PATH | |
| print(f"[sentinel-solver] using chrome: {CHROME_PATH}", flush=True) | |
| else: | |
| launch_kwargs["channel"] = "chromium" | |
| print("[sentinel-solver] using channel=chromium", flush=True) | |
| if PROXY: | |
| launch_kwargs["proxy"] = {"server": PROXY} | |
| self._browser = await self._playwright.chromium.launch(**launch_kwargs) | |
| if not self.user_agent: | |
| major = self._browser.version.split(".")[0] | |
| self.user_agent = ( | |
| "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " | |
| f"(KHTML, like Gecko) Chrome/{major}.0.0.0 Safari/537.36" | |
| ) | |
| try: | |
| await self.page() | |
| except Exception as exc: # 启动时页面暂时打不开不致命,首个请求会重试 | |
| self.last_error = str(exc) | |
| print(f"[sentinel-solver] initial page open failed: {exc}", flush=True) | |
| async def stop(self) -> None: | |
| if self._context is not None: | |
| await self._context.close() | |
| if self._browser is not None: | |
| await self._browser.close() | |
| if self._playwright is not None: | |
| await self._playwright.stop() | |
| async def page(self) -> Page: | |
| async with self._lock: | |
| stale = self._page is None or time.monotonic() - self._opened_at > REFRESH_SECONDS | |
| if not stale and not await self._is_ready(self._page): | |
| stale = True | |
| if stale: | |
| await self._reopen() | |
| return self._page | |
| async def invalidate(self) -> None: | |
| async with self._lock: | |
| self._opened_at = 0.0 | |
| async def _reopen(self) -> None: | |
| if self._context is not None: | |
| try: | |
| await self._context.close() | |
| except Exception: | |
| pass | |
| self._page = None | |
| context = await self._browser.new_context( | |
| locale=LOCALE, | |
| viewport={"width": 1280, "height": 800}, | |
| user_agent=self.user_agent, | |
| **({"timezone_id": TIMEZONE} if TIMEZONE else {}), | |
| ) | |
| await context.add_init_script(BASE_INIT_SCRIPT) | |
| await context.add_init_script(VM_SCRIPT) | |
| await Stealth().apply_stealth_async(context) | |
| page = await context.new_page() | |
| await page.goto(CHATGPT_URL, wait_until="domcontentloaded", timeout=60000) | |
| deadline = time.monotonic() + READY_TIMEOUT_SECONDS | |
| while not await self._is_ready(page): | |
| if time.monotonic() > deadline: | |
| title = await page.title() | |
| await context.close() | |
| raise RuntimeError(f"chatgpt.com page not ready (title={title!r}), likely stuck on a Cloudflare challenge") | |
| await asyncio.sleep(1) | |
| self._context = context | |
| self._page = page | |
| self._opened_at = time.monotonic() | |
| self.last_error = "" | |
| print(f"[sentinel-solver] chatgpt.com page ready: {await page.title()!r}", flush=True) | |
| async def _is_ready(page: Page | None) -> bool: | |
| if page is None or page.is_closed(): | |
| return False | |
| try: | |
| title = await page.title() | |
| if any(marker in title for marker in CHALLENGE_TITLES): | |
| return False | |
| return await page.evaluate("location.hostname === 'chatgpt.com' && typeof window.__sentinelVM === 'object'") | |
| except Exception: | |
| return False | |
| async def evaluate(self, expression: str, arg: object) -> object: | |
| """在页面里执行;页面失效时重开一次再试。""" | |
| for attempt in range(2): | |
| page = await self.page() | |
| try: | |
| return await page.evaluate(expression, arg) | |
| except Exception as exc: | |
| self.last_error = str(exc) | |
| if attempt: | |
| raise | |
| await self.invalidate() | |
| raise RuntimeError("unreachable") | |
| def status(self) -> dict[str, object]: | |
| return { | |
| "ready": self._page is not None and not self._page.is_closed(), | |
| "page_age_seconds": int(time.monotonic() - self._opened_at) if self._opened_at else None, | |
| "browser_version": self._browser.version if self._browser else None, | |
| "user_agent": self.user_agent, | |
| "timezone": TIMEZONE or None, | |
| "last_error": self.last_error or None, | |
| } | |
| solver = SolverPage() | |
| async def lifespan(_: FastAPI): | |
| await solver.start() | |
| yield | |
| await solver.stop() | |
| app = FastAPI(title="chatgpt2api sentinel solver", lifespan=lifespan) | |
| class TurnstileRequest(BaseModel): | |
| dx: str | |
| p: str | |
| class SORequest(BaseModel): | |
| collector_dx: str | |
| snapshot_dx: str | |
| p: str | |
| def require_key(authorization: str | None) -> None: | |
| if not SOLVER_KEY: | |
| return | |
| supplied = (authorization or "").removeprefix("Bearer ").strip() | |
| if not hmac.compare_digest(supplied, SOLVER_KEY): | |
| raise HTTPException(status_code=401, detail="invalid solver key") | |
| async def health() -> dict[str, object]: | |
| return solver.status() | |
| async def solve_turnstile(body: TurnstileRequest, authorization: str | None = Header(default=None)) -> dict[str, str]: | |
| require_key(authorization) | |
| try: | |
| token = await solver.evaluate("([dx, p]) => window.__sentinelVM.turnstile(dx, p)", [body.dx, body.p]) | |
| except Exception as exc: | |
| raise HTTPException(status_code=503, detail=f"turnstile solve failed: {exc}") from exc | |
| return {"token": str(token or "")} | |
| async def solve_so(body: SORequest, authorization: str | None = Header(default=None)) -> dict[str, str]: | |
| require_key(authorization) | |
| try: | |
| token = await solver.evaluate( | |
| "([c, s, p]) => window.__sentinelVM.so(c, s, p)", [body.collector_dx, body.snapshot_dx, body.p] | |
| ) | |
| except Exception as exc: | |
| raise HTTPException(status_code=503, detail=f"so solve failed: {exc}") from exc | |
| return {"token": str(token or "")} | |
| if __name__ == "__main__": | |
| uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "8080")), access_log=False) | |