Initial release: stealth browser (Camoufox + captcha WebUI)
This commit is contained in:
commit
0fe375cfaa
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
data/
|
||||
__pycache__/
|
||||
*.log
|
||||
.env
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
FROM python:3.11-slim
|
||||
|
||||
# Playwright/Firefox runtime deps (GTK, X11, fonts, dbus)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
libgtk-3-0 libdbus-glib-1-2 libx11-xcb1 libxt6 libasound2 \
|
||||
libxcomposite1 libxdamage1 libxrandr2 libgbm1 libpango-1.0-0 \
|
||||
libcairo2 libnss3 libxss1 fonts-liberation ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# Pre-download the Camoufox browser so first request is fast
|
||||
RUN python -m camoufox fetch
|
||||
|
||||
COPY app.py .
|
||||
COPY static/ static/
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
79
README.md
Normal file
79
README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Stealth Browser (Camoufox) for pi
|
||||
|
||||
Bypasses bot detection / fingerprint blocking that the built-in `web_scrape`
|
||||
tool hits (Cloudflare, DataDome, 403s, captcha walls). Includes a **manual
|
||||
CAPTCHA-solving WebUI** and **admin log panel**.
|
||||
|
||||
**Deployed:** http://192.168.0.65:8010 (login: admin / admin — change it)
|
||||
**Health:** http://192.168.0.65:8010/health
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pi (any machine) --HTTP--> stealth-browser container (192.168.0.65:8010)
|
||||
web_fetch_stealth tool Camoufox patched Firefox, headless
|
||||
│
|
||||
├─ captcha? → WebUI challenge (login admin/admin)
|
||||
└─ logs → /api/logs (admin panel tab)
|
||||
```
|
||||
|
||||
pi extension `~/.pi/agent/extensions/stealth-tools.ts` registers
|
||||
`web_fetch_stealth`, pointing at the server by default. Override with
|
||||
`STEALTH_BROWSER_URL`, add basic auth with `STEALTH_BROWSER_USER` /
|
||||
`STEALTH_BROWSER_PASS` (server uses `ADMIN_USER` / `ADMIN_PASS`).
|
||||
|
||||
## WebUI (admin + captcha solving)
|
||||
|
||||
- **Challenges tab** — live queue; when a fetch hits a captcha the job pauses
|
||||
and appears with ⚠. Click *Open & solve*: live screenshot, click-to-interact,
|
||||
type into focused field, reload/back/scroll, goto URL, then **✔ Done —
|
||||
extract page** (or ✖ cancel).
|
||||
- **Logs tab** — every fetch: start/captcha/action/done/failed/cancelled with
|
||||
status, duration, error. Stored in `./data/logs.jsonl`.
|
||||
|
||||
## Redeploy after changes (on Docker host, 192.168.0.65)
|
||||
|
||||
```bash
|
||||
cd ~/stealth-browser # synced from /blackbox/.../stealth-browser
|
||||
docker compose up -d --build
|
||||
curl localhost:8010/health
|
||||
```
|
||||
|
||||
## Proxy (important)
|
||||
|
||||
Fingerprint evasion alone gets blocked on datacenter IPs. For Cloudflare/
|
||||
high-security sites, set a rotating residential proxy:
|
||||
|
||||
```yaml
|
||||
environment:
|
||||
- PROXY=http://user:pass@residential-proxy.example:port
|
||||
```
|
||||
|
||||
## API
|
||||
|
||||
- `GET /health` — liveness
|
||||
- `POST /fetch` — `{"url": "...", "extract": "text|html", "selector": "css?", "pause": bool}`
|
||||
Blocks until done (or captcha solved via WebUI). Returns
|
||||
`{status, title, url, text, outcome, job_id}`.
|
||||
- `GET /api/jobs` — job queue
|
||||
- `GET /api/jobs/{id}` — job + result text
|
||||
- `GET /api/jobs/{id}/screenshot` — live JPEG
|
||||
- `POST /api/jobs/{id}/action` — `click|x,y|img_w`, `type`, `key`, `scroll`, `goto`, `reload`, `back`
|
||||
- `POST /api/jobs/{id}/continue|cancel`
|
||||
- `GET /api/logs` — last 200 log entries
|
||||
|
||||
## Limits
|
||||
|
||||
- Defeats fingerprint bot detection. Does NOT defeat:
|
||||
- IP reputation blocking (needs the residential proxy above)
|
||||
- CAPTCHAs it can't flag — detection looks for known captcha iframes
|
||||
(Cloudflare, hCaptcha, reCAPTCHA, Turnstile, Arkose); pass `"pause": true`
|
||||
to always pause for manual review.
|
||||
- `MAX_CONCURRENT=3` browser instances; each fetch boots a fresh browser (~2-5s).
|
||||
|
||||
## Deploy notes / gotchas fixed
|
||||
|
||||
- Port 8000 was taken by Nextcloud → service runs on host port **8010**.
|
||||
- Camoufox needs `headless=True` explicitly (defaults to headful → DISPLAY error).
|
||||
- Docker blocks Firefox's user-namespace sandbox → `MOZ_DISABLE_*_SANDBOX=1` env.
|
||||
- GTK/X11/font system packages required in image.
|
||||
335
app.py
Normal file
335
app.py
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from fastapi import Depends, FastAPI, HTTPException, Request
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from fastapi.security import HTTPBasic, HTTPBasicCredentials
|
||||
from pydantic import BaseModel
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
|
||||
from camoufox.async_api import AsyncCamoufox
|
||||
|
||||
app = FastAPI(title="Stealth Browser API", version="1.1")
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_DIR = os.getenv("DATA_DIR", "/data")
|
||||
LOG_FILE = os.path.join(DATA_DIR, "logs.jsonl")
|
||||
|
||||
PROXY = os.getenv("PROXY", "")
|
||||
ADMIN_USER = os.getenv("ADMIN_USER", "admin")
|
||||
ADMIN_PASS = os.getenv("ADMIN_PASS", "") # empty = no auth (LAN mode)
|
||||
CAPTCHA_WAIT_MS = int(os.getenv("CAPTCHA_WAIT_MS", "4000"))
|
||||
SOLVE_TIMEOUT_S = int(os.getenv("SOLVE_TIMEOUT_S", "900"))
|
||||
|
||||
CAPTCHA_SELECTORS = (
|
||||
'iframe[src*="challenges.cloudflare.com"],'
|
||||
'iframe[src*="hcaptcha.com"],'
|
||||
'iframe[src*="recaptcha"],'
|
||||
'iframe[src*="arkoselabs"],'
|
||||
'iframe[src*="funcaptcha"],'
|
||||
'iframe[src*="turnstile"],'
|
||||
'div[style*="captcha"]'
|
||||
)
|
||||
MAX_CONCURRENT = int(os.getenv("MAX_CONCURRENT", "3"))
|
||||
_semaphore = asyncio.Semaphore(MAX_CONCURRENT)
|
||||
|
||||
# ---------------------------------------------------------------- auth
|
||||
security = HTTPBasic(auto_error=False)
|
||||
|
||||
|
||||
def require_auth(creds: HTTPBasicCredentials | None = Depends(security)):
|
||||
if not ADMIN_PASS:
|
||||
return None # open mode
|
||||
if not creds or not secrets.compare_digest(creds.username, ADMIN_USER) or not secrets.compare_digest(creds.password, ADMIN_PASS):
|
||||
raise HTTPException(401, "Invalid or missing credentials")
|
||||
return creds
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- browser helper
|
||||
def browser_kwargs() -> dict:
|
||||
kwargs = {"headless": True}
|
||||
if PROXY:
|
||||
kwargs["proxy"] = {"server": PROXY}
|
||||
return kwargs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- logging
|
||||
_log_lock = asyncio.Lock()
|
||||
|
||||
|
||||
async def log(entry: dict):
|
||||
os.makedirs(DATA_DIR, exist_ok=True)
|
||||
entry["ts"] = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||
async with _log_lock:
|
||||
with open(LOG_FILE, "a") as f:
|
||||
f.write(json.dumps(entry) + "\n")
|
||||
|
||||
|
||||
async def read_logs(limit: int = 200):
|
||||
if not os.path.exists(LOG_FILE):
|
||||
return []
|
||||
lines = []
|
||||
async with _log_lock:
|
||||
with open(LOG_FILE) as f:
|
||||
lines = f.readlines()
|
||||
out = []
|
||||
for line in lines[-limit:]:
|
||||
try:
|
||||
out.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return list(reversed(out))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- jobs
|
||||
class Job:
|
||||
def __init__(self, url: str, extract: str, selector: str | None, pause: bool):
|
||||
self.id = uuid.uuid4().hex[:8]
|
||||
self.url = url
|
||||
self.extract = extract
|
||||
self.selector = selector
|
||||
self.pause = pause
|
||||
self.created = time.time()
|
||||
self.status = "queued" # queued|loading|captcha|solving|done|failed|cancelled|timeout
|
||||
self.outcome = None
|
||||
self.error = None
|
||||
self.title = None
|
||||
self.status_code = None
|
||||
self.final_url = None
|
||||
self.text = None
|
||||
self.viewport = {"width": 1280, "height": 720}
|
||||
self.page = None
|
||||
self.solved = asyncio.Event()
|
||||
self.cancelled = False
|
||||
self.action_lock = asyncio.Lock()
|
||||
self.finished = asyncio.Event()
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id, "url": self.url, "status": self.status,
|
||||
"outcome": self.outcome, "created": self.created,
|
||||
"title": self.title, "status_code": self.status_code,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
|
||||
jobs: dict[str, Job] = {}
|
||||
|
||||
|
||||
class FetchRequest(BaseModel):
|
||||
url: str
|
||||
extract: str = "text" # text | html
|
||||
selector: str | None = None
|
||||
pause: bool = False # always open admin WebUI for manual check
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- fetch flow
|
||||
async def detect_captcha(page) -> bool:
|
||||
try:
|
||||
await page.wait_for_selector(CAPTCHA_SELECTORS, state="attached", timeout=CAPTCHA_WAIT_MS)
|
||||
return True
|
||||
except PlaywrightTimeoutError:
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def run_fetch(job: Job):
|
||||
t0 = time.time()
|
||||
async with _semaphore:
|
||||
try:
|
||||
async with AsyncCamoufox(**browser_kwargs()) as browser:
|
||||
page = await browser.new_page()
|
||||
job.page = page
|
||||
job.status = "loading"
|
||||
await log({"job": job.id, "url": job.url, "event": "load_start"})
|
||||
resp = await page.goto(job.url, wait_until="domcontentloaded", timeout=45_000)
|
||||
job.status_code = resp.status if resp else None
|
||||
job.final_url = page.url
|
||||
job.title = await page.title()
|
||||
|
||||
has_captcha = job.pause or await detect_captcha(page)
|
||||
if has_captcha and not job.cancelled:
|
||||
job.status = "captcha"
|
||||
await log({"job": job.id, "url": job.url, "event": "captcha_detected"})
|
||||
try:
|
||||
await asyncio.wait_for(job.solved.wait(), timeout=SOLVE_TIMEOUT_S)
|
||||
except asyncio.TimeoutError:
|
||||
job.status = "timeout"
|
||||
job.outcome = "captcha_timeout"
|
||||
job.error = "No human solved the captcha in time"
|
||||
await log({"job": job.id, "url": job.url, "event": "captcha_timeout"})
|
||||
return
|
||||
if job.cancelled:
|
||||
return
|
||||
|
||||
if job.cancelled:
|
||||
return
|
||||
job.status = "solving"
|
||||
try:
|
||||
await page.wait_for_load_state("networkidle", timeout=15_000)
|
||||
except Exception:
|
||||
pass
|
||||
job.title = await page.title()
|
||||
if job.selector:
|
||||
el = page.locator(job.selector)
|
||||
job.text = await el.first.inner_text() if await el.count() else ""
|
||||
elif job.extract == "html":
|
||||
job.text = await page.content()
|
||||
else:
|
||||
job.text = await page.inner_text("body")
|
||||
job.status = "done"
|
||||
job.outcome = "captcha_solved" if has_captcha else "ok"
|
||||
await log({
|
||||
"job": job.id, "url": job.url, "event": "done",
|
||||
"outcome": job.outcome, "status_code": job.status_code,
|
||||
"duration_ms": int((time.time() - t0) * 1000), "title": job.title,
|
||||
})
|
||||
except Exception as e:
|
||||
job.status = "failed"
|
||||
job.error = str(e)
|
||||
job.outcome = "failed"
|
||||
await log({
|
||||
"job": job.id, "url": job.url, "event": "failed",
|
||||
"error": str(e)[:500], "duration_ms": int((time.time() - t0) * 1000),
|
||||
})
|
||||
finally:
|
||||
job.page = None
|
||||
if job.outcome == "cancelled":
|
||||
await log({"job": job.id, "url": job.url, "event": "cancelled", "duration_ms": int((time.time() - t0) * 1000)})
|
||||
job.finished.set()
|
||||
|
||||
|
||||
@app.post("/fetch", dependencies=[Depends(require_auth)])
|
||||
async def fetch(req: FetchRequest):
|
||||
if not req.url.startswith(("http://", "https://")):
|
||||
raise HTTPException(400, "url must start with http(s)://")
|
||||
job = Job(req.url, req.extract, req.selector, req.pause)
|
||||
jobs[job.id] = job
|
||||
asyncio.create_task(run_fetch(job))
|
||||
await job.finished.wait()
|
||||
if job.status in ("done",):
|
||||
return {"status": job.status_code, "title": job.title, "url": job.final_url, "text": (job.text or "")[:200_000], "outcome": job.outcome, "job_id": job.id}
|
||||
raise HTTPException(502, f"{job.outcome or job.status}: {job.error or 'unknown error'}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- admin / WebUI endpoints
|
||||
class Action(BaseModel):
|
||||
type: str # click|type|key|scroll|goto|reload|back
|
||||
x: float | None = None
|
||||
y: float | None = None
|
||||
img_w: float | None = None
|
||||
text: str | None = None
|
||||
key: str | None = None
|
||||
delta: int | None = None
|
||||
url: str | None = None
|
||||
|
||||
|
||||
@app.get("/", include_in_schema=False)
|
||||
async def index():
|
||||
return FileResponse(os.path.join(BASE_DIR, "static", "index.html"))
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"ok": True, "version": app.version, "active": sum(1 for j in jobs.values() if j.status in ("loading", "captcha", "solving"))}
|
||||
|
||||
|
||||
@app.get("/api/jobs", dependencies=[Depends(require_auth)])
|
||||
async def list_jobs():
|
||||
return [j.to_dict() for j in jobs.values()][-50:][::-1]
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}", dependencies=[Depends(require_auth)])
|
||||
async def job_detail(job_id: str):
|
||||
job = jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404, "job not found")
|
||||
d = job.to_dict()
|
||||
d["text"] = (job.text or "")[:50_000]
|
||||
return d
|
||||
|
||||
|
||||
@app.get("/api/jobs/{job_id}/screenshot", dependencies=[Depends(require_auth)])
|
||||
async def screenshot(job_id: str):
|
||||
job = jobs.get(job_id)
|
||||
if not job or job.page is None:
|
||||
raise HTTPException(404, "no live page for this job")
|
||||
png = await job.page.screenshot(type="jpeg", quality=60)
|
||||
return Response(content=png, media_type="image/jpeg")
|
||||
|
||||
|
||||
async def run_action(job: Job, action: Action):
|
||||
page = job.page
|
||||
if page is None:
|
||||
raise HTTPException(404, "page closed")
|
||||
a = action
|
||||
if a.type == "click":
|
||||
vw, vh = job.viewport["width"], job.viewport["height"]
|
||||
img_w = a.img_w or vw
|
||||
x = a.x * vw / img_w
|
||||
y = a.y * vh / img_w # same scale
|
||||
await page.mouse.click(x, y)
|
||||
elif a.type == "type":
|
||||
await page.keyboard.type(a.text or "", delay=25)
|
||||
elif a.type == "key":
|
||||
await page.keyboard.press(a.key or "Enter")
|
||||
elif a.type == "scroll":
|
||||
await page.mouse.wheel(0, a.delta or 300)
|
||||
elif a.type == "goto":
|
||||
await page.goto(a.url, wait_until="domcontentloaded", timeout=45_000)
|
||||
elif a.type == "reload":
|
||||
await page.reload(wait_until="domcontentloaded")
|
||||
elif a.type == "back":
|
||||
await page.go_back(wait_until="domcontentloaded")
|
||||
else:
|
||||
raise HTTPException(400, f"unknown action {a.type}")
|
||||
await log({"job": job.id, "event": "action", "type": a.type})
|
||||
return job.to_dict()
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/action", dependencies=[Depends(require_auth)])
|
||||
async def do_action(job_id: str, action: Action):
|
||||
job = jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404)
|
||||
async with job.action_lock:
|
||||
return await run_action(job, action)
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/continue", dependencies=[Depends(require_auth)])
|
||||
async def continue_job(job_id: str):
|
||||
job = jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404)
|
||||
job.status = "solving"
|
||||
job.solved.set()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.post("/api/jobs/{job_id}/cancel", dependencies=[Depends(require_auth)])
|
||||
async def cancel_job(job_id: str):
|
||||
job = jobs.get(job_id)
|
||||
if not job:
|
||||
raise HTTPException(404)
|
||||
job.cancelled = True
|
||||
job.outcome = "cancelled"
|
||||
job.status = "cancelled"
|
||||
job.solved.set()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@app.get("/api/logs", dependencies=[Depends(require_auth)])
|
||||
async def get_logs():
|
||||
return await read_logs()
|
||||
|
||||
|
||||
# periodic log of health to admin logs
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
await log({"event": "server_start", "version": app.version, "proxy": bool(PROXY)})
|
||||
print(f"[startup] auth={'on' if ADMIN_PASS else 'OFF (LAN open)'} captcha_wait={CAPTCHA_WAIT_MS}ms solve_timeout={SOLVE_TIMEOUT_S}s", flush=True)
|
||||
25
docker-compose.yml
Normal file
25
docker-compose.yml
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
services:
|
||||
stealth-browser:
|
||||
build: .
|
||||
container_name: stealth-browser
|
||||
ports:
|
||||
- "8010:8000"
|
||||
environment:
|
||||
# Optional rotating residential proxy. Without it, datacenter IPs still
|
||||
# get blocked by Cloudflare/RiskIQ. Format: http://user:pass@host:port
|
||||
- PROXY=
|
||||
# Admin WebUI login (change these!). Empty ADMIN_PASS = no login (LAN open).
|
||||
- ADMIN_USER=admin
|
||||
- ADMIN_PASS=admin
|
||||
- CAPTCHA_WAIT_MS=4000 # how long to look for a captcha after load
|
||||
- SOLVE_TIMEOUT_S=900 # how long a challenge stays open before timing out
|
||||
- MAX_CONCURRENT=3
|
||||
# Docker can't give firefox its user-namespace sandbox
|
||||
- MOZ_DISABLE_CONTENT_SANDBOX=1
|
||||
- MOZ_DISABLE_GMP_SANDBOX=1
|
||||
- MOZ_DISABLE_RDD_SANDBOX=1
|
||||
volumes:
|
||||
- ./data:/data # admin logs (logs.jsonl)
|
||||
restart: unless-stopped
|
||||
# Keep browser memory reasonable and allow many tabs
|
||||
shm_size: "1gb"
|
||||
3
requirements.txt
Normal file
3
requirements.txt
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
camoufox[geoip]>=0.4.15
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
227
static/index.html
Normal file
227
static/index.html
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Stealth Browser Admin</title>
|
||||
<style>
|
||||
:root{--bg:#0f1115;--panel:#171a21;--panel2:#1d212b;--border:#2a2f3a;--fg:#e6e9ef;--dim:#8a91a0;--acc:#4f8cff;--ok:#37c269;--warn:#e5a13a;--err:#e5484d}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,Segoe UI,sans-serif;padding:16px}
|
||||
h1{font-size:18px;margin-bottom:4px} h1 span{color:var(--dim);font-weight:400;font-size:13px}
|
||||
.tabs{display:flex;gap:8px;margin:14px 0;border-bottom:1px solid var(--border);padding-bottom:8px}
|
||||
.tab{background:var(--panel);border:1px solid var(--border);color:var(--dim);padding:6px 14px;border-radius:6px;cursor:pointer}
|
||||
.tab.active{background:var(--acc);border-color:var(--acc);color:#fff}
|
||||
.section{display:none}.section.active{display:block}
|
||||
.card{background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:10px}
|
||||
.card.pending{border-color:var(--warn)}
|
||||
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap}
|
||||
.url{flex:1;min-width:200px;font-family:ui-monospace,monospace;font-size:12px;color:var(--dim);word-break:break-all}
|
||||
.badge{font-size:11px;padding:2px 8px;border-radius:10px;background:var(--panel2);color:var(--dim);border:1px solid var(--border)}
|
||||
.badge.captcha{background:#3a2c14;color:var(--warn);border-color:#5c4520}
|
||||
.badge.ok{background:#14281c;color:var(--ok);border-color:#1f4d2e}
|
||||
.badge.fail{background:#33181a;color:var(--err);border-color:#5c2024}
|
||||
button{background:var(--panel2);border:1px solid var(--border);color:var(--fg);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:13px}
|
||||
button:hover{border-color:var(--acc)}
|
||||
button.primary{background:var(--acc);border-color:var(--acc);color:#fff}
|
||||
button.danger{background:#3a1d1f;border-color:#5c2024;color:var(--err)}
|
||||
button:disabled{opacity:.4;cursor:not-allowed}
|
||||
input{background:var(--panel2);border:1px solid var(--border);color:var(--fg);padding:6px 8px;border-radius:6px;font-size:13px}
|
||||
input:focus{outline:none;border-color:var(--acc)}
|
||||
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:10px 0}
|
||||
.probe{border:1px solid var(--border);border-radius:8px;overflow:hidden;background:#000;max-height:70vh;overflow-y:auto;position:relative;cursor:crosshair}
|
||||
.probe img{display:block;width:100%;height:auto}
|
||||
#crosshair{position:absolute;border:1px solid var(--err);width:4px;height:4px;border-radius:50%;pointer-events:none;display:none}
|
||||
.result{white-space:pre-wrap;max-height:300px;overflow-y:auto;background:var(--panel2);border:1px solid var(--border);border-radius:6px;padding:10px;font-family:ui-monospace,monospace;font-size:12px;color:var(--fg);margin-top:10px}
|
||||
table{width:100%;border-collapse:collapse;font-size:12px}
|
||||
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--border);vertical-align:top}
|
||||
th{color:var(--dim);font-weight:600}
|
||||
.dim{color:var(--dim)}
|
||||
.login{position:fixed;inset:0;background:var(--bg);display:none;align-items:center;justify-content:center;z-index:10}
|
||||
.login .box{background:var(--panel);border:1px solid var(--border);border-radius:10px;padding:24px;width:320px}
|
||||
.login h2{font-size:16px;margin-bottom:12px}
|
||||
.login input{width:100%;margin-bottom:10px}
|
||||
.err{color:var(--err);font-size:12px;margin-top:8px;display:none}
|
||||
.hint{font-size:12px;color:var(--dim);margin-top:12px}
|
||||
a{color:var(--acc)}
|
||||
.view{display:none;margin-top:12px}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>🛡️ Stealth Browser Admin <span id="hdr">—</span></h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" data-t="challenges">Challenges <span id="cnt" class="dim"></span></button>
|
||||
<button class="tab" data-t="logs">Logs</button>
|
||||
</div>
|
||||
|
||||
<div id="sec-challenges" class="section active">
|
||||
<div id="pending"></div>
|
||||
<div id="done"></div>
|
||||
</div>
|
||||
|
||||
<div id="sec-logs" class="section">
|
||||
<table><thead><tr><th>Time</th><th>Job</th><th>Event</th><th>URL / Detail</th><th>Dur</th></tr></thead>
|
||||
<tbody id="logrows"><tr><td colspan="5" class="dim">loading…</td></tr></tbody></table>
|
||||
</div>
|
||||
|
||||
<div id="view" class="view">
|
||||
<div class="card" id="viewhead"></div>
|
||||
<div class="toolbar">
|
||||
<button id="b-click" class="primary">Click</button>
|
||||
<button id="b-reload">Reload</button>
|
||||
<button id="b-back">Back</button>
|
||||
<button id="b-up">↑ Scroll</button>
|
||||
<button id="b-down">↓ Scroll</button>
|
||||
<input id="in-url" style="flex:1" placeholder="goto URL…">
|
||||
<button id="b-goto">Go</button>
|
||||
<input id="in-type" style="flex:1" placeholder="type text into focused field…">
|
||||
<button id="b-type">Type</button>
|
||||
<button id="b-enter">Enter</button>
|
||||
<button id="b-continue" class="primary">✔ Done — extract page</button>
|
||||
<button id="b-cancel" class="danger">✖ Cancel job</button>
|
||||
</div>
|
||||
<div class="probe"><img id="shot" alt=""><div id="crosshair"></div></div>
|
||||
<div id="stage" class="dim hint"></div>
|
||||
<div id="result" class="result" style="display:none"></div>
|
||||
</div>
|
||||
|
||||
<div class="login" id="login"><div class="box">
|
||||
<h2>Login</h2>
|
||||
<input id="li-user" placeholder="username" autocomplete="username">
|
||||
<input id="li-pass" type="password" placeholder="password" autocomplete="current-password">
|
||||
<button id="li-go" class="primary" style="width:100%">Sign in</button>
|
||||
<div class="err" id="li-err">Wrong credentials</div>
|
||||
<div class="hint">Stored in this browser only. Set ADMIN_USER/ADMIN_PASS on the server to require login.</div>
|
||||
</div></div>
|
||||
|
||||
<script>
|
||||
const $=s=>document.querySelector(s);
|
||||
let auth=null, jobId=null, pollTimer=null;
|
||||
|
||||
function hdrs(){const h={}; if(auth) h['Authorization']='Basic '+auth; return h;}
|
||||
async function api(url,opts={}){const r=await fetch(url,{...opts,headers:{...(hdrs()||{}),...(opts.headers||{})}}); if(r.status===401){showLogin();throw new Error('auth');} return r;}
|
||||
function showLogin(){document.getElementById('login').style.display='flex';}
|
||||
function hideLogin(){document.getElementById('login').style.display='none';}
|
||||
function b64(s){return btoa(unescape(encodeURIComponent(s)));}
|
||||
|
||||
function startLogin(){const u=$('#li-user').value,p=$('#li-pass').value; if(!u&&!p){hideLogin();auth=null;refresh();return;} auth=b64(u+':'+p); api('/api/jobs').then(()=>{hideLogin();refresh();}).catch(()=>{$('#li-err').style.display='block';auth=null;});}
|
||||
$('#li-go').onclick=startLogin;
|
||||
$('#li-pass').addEventListener('keydown',e=>{if(e.key==='Enter')startLogin();});
|
||||
|
||||
function badge(j){const m={captcha:'captcha',done:j.outcome==='ok'?'ok':'captcha',ok:'ok',failed:'fail',cancelled:'',timeout:'fail'}; const c=m[j.status.replace('solving','')]; return `<span class="badge ${c||''}">${j.status}${j.outcome&&j.outcome!=='ok'?' · '+j.outcome:''}</span>`;}
|
||||
|
||||
function renderPending(items){
|
||||
const p=items.filter(j=>j.status==='captcha');
|
||||
$('#cnt').textContent=p.length?`(${p.length})`:'';
|
||||
$('#pending').innerHTML=p.map(j=>`
|
||||
<div class="card pending"><div class="row">
|
||||
<span class="badge captcha">⚠ captcha</span>
|
||||
<span class="url">${j.url}</span>
|
||||
<button data-open="${j.id}" class="primary">Open & solve</button>
|
||||
<button data-cancel2="${j.id}" class="danger">Cancel</button>
|
||||
</div></div>`).join('') || '<div class="card"><span class="dim">No pending challenges — fetching normally.</span></div>';
|
||||
document.querySelectorAll('[data-open]').forEach(b=>b.onclick=()=>openView(b.dataset.open));
|
||||
document.querySelectorAll('[data-cancel2]').forEach(b=>b.onclick=async()=>{await api('/api/jobs/'+b.dataset.cancel2+'/cancel',{method:'POST'});refresh();});
|
||||
}
|
||||
|
||||
function renderDone(items){
|
||||
const d=items.filter(j=>['done','failed','cancelled','timeout'].includes(j.status)).slice(0,15);
|
||||
$('#done').innerHTML=d.map(j=>`
|
||||
<div class="card"><div class="row">
|
||||
<span>${badge(j)}</span>
|
||||
<span class="url">${j.url}</span>
|
||||
${j.title?`<span class="dim">${j.title}</span>`:''}
|
||||
<a href="javascript:void 0" data-res="${j.id}">result</a>
|
||||
</div></div>`).join('')||'';
|
||||
document.querySelectorAll('[data-res]').forEach(a=>a.onclick=async()=>{const r=await api('/api/jobs/'+a.dataset.res);openView(a.dataset.res,true);});
|
||||
}
|
||||
|
||||
function renderJobs(items){renderPending(items);renderDone(items);}
|
||||
|
||||
document.querySelectorAll('.tab').forEach(b=>b.onclick=()=>{
|
||||
document.querySelectorAll('.tab').forEach(x=>x.classList.remove('active'));
|
||||
document.querySelectorAll('.section').forEach(x=>x.classList.remove('active'));
|
||||
b.classList.add('active'); $('#sec-'+b.dataset.t).classList.add('active');
|
||||
});
|
||||
|
||||
async function refresh(){
|
||||
try{
|
||||
const jr=await api('/api/jobs');
|
||||
const jobs=await jr.json();
|
||||
renderJobs(jobs);
|
||||
const h=jobs.length?'('+jobs.length+' jobs)':'—';
|
||||
$('#hdr').textContent=h;
|
||||
}catch(e){ if(e.message!=='auth') console.error(e); }
|
||||
}
|
||||
|
||||
async function refreshLogs(){
|
||||
try{
|
||||
const r=await api('/api/logs'); const logs=await r.json();
|
||||
$('#logrows').innerHTML=logs.map(l=>`<tr>
|
||||
<td class="dim">${l.ts}</td><td>${l.job||''}</td><td>${l.event}</td>
|
||||
<td class="url">${l.url||(l.error?l.error.slice(0,120):'')}</td>
|
||||
<td>${l.duration_ms!=null?l.duration_ms+'ms':''}</td></tr>`).join('') ||
|
||||
'<tr><td colspan="5" class="dim">no logs yet</td></tr>';
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
let shotTimer=null, cursorEl=null;
|
||||
function openView(id, alreadyDone=false){
|
||||
jobId=id; $('#view').style.display='block';
|
||||
$('#result').style.display='none'; $('#stage').textContent='Loading…';
|
||||
const jobs=document.querySelectorAll('#pending .card');
|
||||
const probe=document.querySelector('.probe');
|
||||
const img=$('#shot');
|
||||
img.onclick=async e=>{
|
||||
const rect=img.getBoundingClientRect();
|
||||
const x=(e.clientX-rect.left)/rect.width*img.naturalWidth;
|
||||
const y=(e.clientY-rect.top)/rect.height*img.naturalHeight;
|
||||
await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'click',x,y,img_w:img.naturalWidth})});
|
||||
shot();
|
||||
};
|
||||
cursorEl=cursorEl||document.getElementById('crosshair');
|
||||
probe.addEventListener('mousemove',e=>{if(!cursorEl)return;const r=img.getBoundingClientRect();cursorEl.style.display='block';cursorEl.style.left=(e.clientX-r.left-2)+'px';cursorEl.style.top=(e.clientY-r.top-2)+'px';});
|
||||
const shot=async()=>{
|
||||
try{
|
||||
const r=await api('/api/jobs/'+jobId+'/screenshot');
|
||||
const blob=await r.blob(); img.src=URL.createObjectURL(blob);
|
||||
$('#stage').textContent=(new Date()).toLocaleTimeString()+' · live view (click to interact)';
|
||||
}catch(e){ $('#stage').textContent='job closed or done'; }
|
||||
};
|
||||
shot(); shotTimer=setInterval(shot,2000);
|
||||
pollDetail();
|
||||
}
|
||||
|
||||
async function pollDetail(){
|
||||
const t=setInterval(async()=>{
|
||||
if(!jobId){clearInterval(t);return;}
|
||||
try{
|
||||
const r=await api('/api/jobs/'+jobId); const j=await r.json();
|
||||
if(['done','failed','cancelled','timeout'].includes(j.status)){
|
||||
clearInterval(t); clearInterval(shotTimer);
|
||||
$('#stage').textContent='';
|
||||
$('#result').style.display='block';
|
||||
$('#result').textContent=`[${j.status}${j.outcome?' · '+j.outcome:''}] ${j.url}\n——\n${(j.text||j.error||'').slice(0,15000)}`;
|
||||
refresh();
|
||||
}
|
||||
}catch(e){}
|
||||
},1500);
|
||||
}
|
||||
|
||||
$('#b-continue').onclick=async()=>{await api('/api/jobs/'+jobId+'/continue',{method:'POST'});};
|
||||
$('#b-cancel').onclick=async()=>{await api('/api/jobs/'+jobId+'/cancel',{method:'POST'});refresh();};
|
||||
$('#b-type').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'type',text:$('#in-type').value})});};
|
||||
$('#b-enter').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'key',key:'Enter'})});};
|
||||
$('#b-reload').onclick=r1('reload'); $('#b-back').onclick=r1('back');
|
||||
function r1(type){return async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type})});};}
|
||||
$('#b-up').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'scroll',delta:-400})});};
|
||||
$('#b-down').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'scroll',delta:400})});};
|
||||
$('#b-goto').onclick=async()=>{await api('/api/jobs/'+jobId+'/action',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'goto',url:$('#in-url').value})});};
|
||||
|
||||
setInterval(()=>{refresh();const t=document.querySelector('.tab.active');if(t&&t.dataset.t==='logs')refreshLogs();},2000);
|
||||
(async function init(){try{const r=await api('/api/jobs');if(r.ok){hideLogin();refresh();} }catch(e){showLogin();}} )();
|
||||
refreshLogs();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in a new issue