Initial release: stealth browser (Camoufox + captcha WebUI)

This commit is contained in:
dev6tools 2026-08-23 15:04:56 -04:00
commit 0fe375cfaa
7 changed files with 695 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
data/
__pycache__/
*.log
.env

22
Dockerfile Normal file
View 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
View 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
View 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
View 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
View file

@ -0,0 +1,3 @@
camoufox[geoip]>=0.4.15
fastapi>=0.115
uvicorn[standard]>=0.30

227
static/index.html Normal file
View 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>