feat(scripts): add Firefox driver (ff.py) via Playwright; disable claude-in-chrome
Add .claude/scripts/ff.py, a Firefox browser driver built on Playwright and the Firefox sibling of the existing cdp.py Chrome driver. It runs a small background daemon holding one Playwright Firefox page on a persistent profile, controlled over localhost:9333, with subcommands launch/status/nav/shot/click/ type/eval/console/network/stop. Verified end-to-end (real screenshot, network and console capture). This is now the preferred browser-automation path because Mike dislikes Chrome and the claude-in-chrome extension (that connector was disabled in ~/.claude.json this session - not a repo change). Add memory reference_ff_firefox_driver.md documenting the driver and an index line in MEMORY.md. The MEMORY.md change also unavoidably includes a pre-existing adjacent index line for reference_antigravity_agy_not_headless.md, so that memory file is bundled in to keep the index consistent. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
|||||||
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
|
- [Syncro API — Invoice Verification Pattern](syncro_invoice_verification_pattern.md) — /invoices?customer_id=X returns no ticket linkage; query /invoices/{number} for ticket_id. Compare by ticket ID, not number.
|
||||||
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval; features→roadmap, bugs→bug list.
|
- [Approval Workflow: Tools vs Projects](approval-workflow-tools-vs-projects.md) — Tools (remediation, scripts): Howard/Claude with approval. Projects (GuruRMM): Mike approval; features→roadmap, bugs→bug list.
|
||||||
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
|
- [CDP Chrome driver](reference_cdp_chrome_driver.md) — Drive Chrome via DevTools Protocol (.claude/scripts/cdp.py): visible window + screenshots-to-disk so Gemini/Grok can SEE the live site. Use localhost not 127.0.0.1; dedicated profile. Antigravity-style.
|
||||||
|
- [Firefox driver (ff.py)](reference_ff_firefox_driver.md) — PREFERRED browser driver. Drive Firefox via Playwright (.claude/scripts/ff.py): daemon on :9333, persistent profile, nav/shot/click/type/eval/console/network. Mike dislikes Chrome; claude-in-chrome connector disabled 2026-06-06.
|
||||||
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
|
- [Community Forum (Flarum)](reference_community_forum.md) — Flarum forum at community.azcomputerguru.com, API access, database, posting workflow.
|
||||||
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
|
- [Radio Show Website](reference_radio_website.md) — Astro static site at radio.azcomputerguru.com on IX server.
|
||||||
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
|
- [IX Server Access](reference_ix_server_access.md) — `ix.azcomputerguru.com` / 172.16.3.10. Reachable when Tailscale is on (no VPN). SSH currently uses sshpass with root password; key auth from GURU-5070 not configured yet (was CachyOS, now Win11 — verify).
|
||||||
@@ -44,6 +45,7 @@
|
|||||||
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
|
- [/tmp path mismatch on Windows](feedback_tmp_path_windows.md) — Write tool and Git Bash resolve `/tmp` to DIFFERENT real dirs. Use heredoc or workspace path for JSON payloads handed to curl.
|
||||||
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
|
- [Windows bash command mapping](feedback_windows_bash_mapping.md) — `bash` often resolves to WSL stub instead of Git/MSYS bash required by the harness. Fix by prepending `C:\Program Files\Git\bin` (and usr\bin) to PATH, or source `.claude/scripts/ensure-git-bash.ps1`. Profile has the logic; use plain `bash .claude/scripts/...` after remap. See the helper and this memory file for details.
|
||||||
- [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable.
|
- [Git must authenticate non-interactively](feedback_git_noninteractive_auth.md) — Mike's gripe with Git for Windows is the constant password prompts (GCM) that hang automation, NOT the tool itself. D:\ClaudeTools is set to `credential.helper=store` primed with the azcomputerguru Gitea API token (host 172.16.3.20:3000); always set `GIT_TERMINAL_PROMPT=0`. Any never-prompts solution is acceptable.
|
||||||
|
- [Antigravity agy.exe is not a headless CLI](reference_antigravity_agy_not_headless.md) — the `agy` skill's real backend is `@google/gemini-cli`, not the Antigravity `agy.exe` (IDE agent, no stdout, hangs). Don't reinstall agy.exe expecting headless output. Mike has a paid Gemini account, so stay on gemini-cli past the June 18 free-tier sunset (prefer `GEMINI_API_KEY`).
|
||||||
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
|
- [SQL instance role — verify by connections, not name](feedback_sql_instance_role_by_connection.md) — Standard installed under default `SQLEXPRESS` instance name is real. Prove role with `sys.dm_exec_sessions` + `Get-NetTCPConnection -OwningProcess` before recommending stop/uninstall.
|
||||||
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
|
- [Clear-RecycleBin fails silently as SYSTEM](feedback_clear_recyclebin_system_context.md) — RMM-dispatched cleanup scripts cannot use `Clear-RecycleBin -Force`; the cmdlet uses Shell COM and silently no-ops without an interactive desktop. Enumerate `C:\$Recycle.Bin\<SID>\*` directly.
|
||||||
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
|
- [Graph CA policy reads are eventually consistent](feedback_graph_ca_policy_eventual_consistency.md) — After PATCHing a CA policy (204), wait ~5s before GET-verifying; immediate reads can be stale.
|
||||||
|
|||||||
12
.claude/memory/reference_antigravity_agy_not_headless.md
Normal file
12
.claude/memory/reference_antigravity_agy_not_headless.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
name: reference_antigravity_agy_not_headless
|
||||||
|
description: Antigravity CLI agy.exe is the IDE embedded agent (no stdout, SQLite store) — NOT a headless CLI. The agy skill uses @google/gemini-cli, not agy.exe. Don't reinstall agy.exe expecting a headless tool.
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
The `agy.exe` installed by Google's Antigravity CLI (`%LOCALAPPDATA%\agy\bin\agy.exe`, installer `https://antigravity.google/cli/install.ps1`) is the IDE's embedded agent, **NOT a usable headless CLI** on this fleet. Even v1.0.6's advertised `-p/--print` produces ZERO stdout and hangs when invoked non-interactively from the Bash/PowerShell tool harness — it writes only to a SQLite conversation store. First found 2026-06-05 (`session-logs/2026-06-05-mike-gururmm-platform-day.md` line 35); **re-confirmed 2026-06-06** after the GURU-5070 reinstall (reinstalled agy.exe and walked straight back into the same no-output/hang symptom).
|
||||||
|
|
||||||
|
The `agy` SKILL (despite the name) routes to the official **`@google/gemini-cli`** (`gemini`, npm global) — that IS the real headless second-opinion tool (Google OAuth, no API key), resolved via `identity.json .gemini.binary`. Grok (`ask-grok.sh`) is the other working second model. Both were verified returning `OK` on 2026-06-06.
|
||||||
|
|
||||||
|
**June 18 sunset — likely a non-issue for ACG.** Google is sunsetting gemini-cli's free/unpaid OAuth quota on **2026-06-18**, but Mike has a **paid Gemini account**, so the plan is to **stay on gemini-cli** (do NOT migrate to Antigravity). The bulletproof form is to auth gemini-cli with a paid **Gemini API key** (`GEMINI_API_KEY`) rather than the free OAuth quota — that path is unaffected by the OAuth-CLI sunset regardless of how the consumer tiers shake out, and is more stable for headless use. (Sources disagree on whether paid Pro/Ultra OAuth is also cut, so the API-key path is the safe bet.) **Do NOT reinstall agy.exe expecting it to work headless.** Related: [[feedback_agy_review_not_readonly]].
|
||||||
37
.claude/memory/reference_ff_firefox_driver.md
Normal file
37
.claude/memory/reference_ff_firefox_driver.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: reference_ff_firefox_driver
|
||||||
|
description: Drive Firefox via Playwright (.claude/scripts/ff.py) — Mike's preferred browser; replaces the disliked claude-in-chrome extension
|
||||||
|
metadata:
|
||||||
|
type: reference
|
||||||
|
---
|
||||||
|
|
||||||
|
`.claude/scripts/ff.py` drives **Firefox** over Playwright — the Firefox sibling of
|
||||||
|
[[reference_cdp_chrome_driver]]. Mike dislikes Chrome and the `claude-in-chrome` MCP
|
||||||
|
extension, so when he asks to "look at a website / interact / collect the logs", use this,
|
||||||
|
not Chrome. (The Chrome connector was disabled 2026-06-06: keys `claudeInChromeDefaultEnabled`,
|
||||||
|
`cachedChromeExtensionInstalled` set false and `chromeExtension` pairing removed in
|
||||||
|
`~/.claude.json`; backup at `~/.claude.json.bak-prechrome`. Re-toggle in the connectors UI if it
|
||||||
|
reappears.)
|
||||||
|
|
||||||
|
**Why a daemon, not stateless like cdp.py:** Firefox dropped most CDP support, so cdp.py's
|
||||||
|
"new WS per command" trick doesn't port. `ff.py launch` spawns a background daemon holding ONE
|
||||||
|
Playwright Firefox page on a **persistent profile** (`~/.claude/ff-profile`, logins survive);
|
||||||
|
every other subcommand is a thin HTTP client to it on `localhost:9333` (env `FF_PORT`). The page
|
||||||
|
persists between calls (nav now, shot later) and the daemon accumulates console + network logs.
|
||||||
|
|
||||||
|
**Commands:** `launch [url] [--headless]` · `status` · `nav <url>` · `shot <out.png>` (real PNG to
|
||||||
|
disk → feed to `agy image-analyze`/Grok) · `click <x> <y>` · `type <text>` · `key <Key>` ·
|
||||||
|
`eval <js>` · `console [--clear]` · `network [--clear]` · `stop`. Default headed (visible) so Mike
|
||||||
|
can log into authenticated apps once; Claude still must NOT type passwords.
|
||||||
|
|
||||||
|
**Gotchas (both bit during build, 2026-06-06):**
|
||||||
|
- **`py` honors a script's shebang.** ff.py's `#!/usr/bin/env python` makes `py ff.py` resolve
|
||||||
|
`python` via PATH → **Python 3.12**, while bare `py -c` uses the default **3.14**. Playwright is
|
||||||
|
installed in BOTH now (`<py312>\python.exe -m pip install playwright` + `... -m playwright install
|
||||||
|
firefox`), so it's interpreter-agnostic. If `ModuleNotFoundError: playwright` recurs after a
|
||||||
|
Python upgrade, install playwright into whatever `py .claude/scripts/ff.py status` actually runs.
|
||||||
|
- The detached daemon's stdio is redirected to `~/.claude/ff-daemon.log` (NOT inherited) — otherwise
|
||||||
|
`launch` never returns control and startup crashes are invisible. Check that log if `launch` hangs.
|
||||||
|
|
||||||
|
Verified end-to-end 2026-06-06: launch→status→eval→shot (26KB real render of example.com)→network
|
||||||
|
(200 captured)→console (caught an injected log). See [[reference_cdp_chrome_driver]].
|
||||||
279
.claude/scripts/ff.py
Normal file
279
.claude/scripts/ff.py
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
ff.py - drive Firefox over Playwright, the Firefox sibling of cdp.py.
|
||||||
|
|
||||||
|
Firefox dropped most of its CDP support, so the stateless "new connection per
|
||||||
|
command" trick cdp.py uses against Chrome's debug port doesn't port cleanly.
|
||||||
|
Instead `launch` spawns a small background daemon that holds ONE Playwright
|
||||||
|
Firefox page (on a persistent profile, so logins survive); every other
|
||||||
|
subcommand is a thin HTTP client to that daemon. The page persists between
|
||||||
|
calls (nav now, shot later) and the daemon accumulates console + network logs
|
||||||
|
for retrieval -- the "collect the logs" use case.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
py ff.py launch [url] [--headless] # start the background Firefox daemon
|
||||||
|
py ff.py status # daemon health + current url/title
|
||||||
|
py ff.py nav <url> # navigate the page
|
||||||
|
py ff.py shot <out.png> # screenshot the page to a PNG file
|
||||||
|
py ff.py click <x> <y> # left-click at viewport coords
|
||||||
|
py ff.py type <text> # insert text into the focused element
|
||||||
|
py ff.py key <Key> # press a key (Enter/Tab/Escape/...)
|
||||||
|
py ff.py eval <js> # page.evaluate(js), prints JSON result
|
||||||
|
py ff.py console [--clear] # dump collected console messages (JSON)
|
||||||
|
py ff.py network [--clear] # dump collected network requests (JSON)
|
||||||
|
py ff.py stop # shut the daemon down
|
||||||
|
|
||||||
|
Env: FF_PORT (control port, default 9333)
|
||||||
|
FF_PROFILE (default %USERPROFILE%\\.claude\\ff-profile)
|
||||||
|
"""
|
||||||
|
import sys, os, json, time, subprocess, urllib.request, urllib.error
|
||||||
|
|
||||||
|
PORT = int(os.environ.get("FF_PORT", "9333"))
|
||||||
|
BASE = f"http://localhost:{PORT}"
|
||||||
|
PROFILE = os.environ.get("FF_PROFILE", os.path.join(os.path.expanduser("~"), ".claude", "ff-profile"))
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# client side (the CLI you actually type)
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def _req(path, method="GET", body=None, timeout=30):
|
||||||
|
data = json.dumps(body).encode() if body is not None else None
|
||||||
|
r = urllib.request.Request(BASE + path, data=data, method=method,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(r, timeout=timeout) as resp:
|
||||||
|
raw = resp.read().decode()
|
||||||
|
return json.loads(raw) if raw else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _alive():
|
||||||
|
try:
|
||||||
|
_req("/status", timeout=2)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_launch(args):
|
||||||
|
headless = "--headless" in args
|
||||||
|
url = next((a for a in args if not a.startswith("--")), None)
|
||||||
|
if _alive():
|
||||||
|
print(f"[ff] daemon already running on {BASE}")
|
||||||
|
if url:
|
||||||
|
_req("/nav", "POST", {"url": _fix(url)})
|
||||||
|
print(f"[ff] navigated -> {_fix(url)}")
|
||||||
|
return
|
||||||
|
os.makedirs(PROFILE, exist_ok=True)
|
||||||
|
flags = subprocess.CREATE_NEW_PROCESS_GROUP | 0x00000008 # DETACHED_PROCESS
|
||||||
|
env = dict(os.environ, FF_DAEMON="1", FF_HEADLESS="1" if headless else "0",
|
||||||
|
FF_START_URL=_fix(url) if url else "about:blank")
|
||||||
|
# Redirect the detached child's stdio to a logfile -- otherwise it inherits
|
||||||
|
# the parent's stdout pipe (caller never gets control back) and any startup
|
||||||
|
# crash is invisible.
|
||||||
|
log = open(os.path.join(os.path.dirname(PROFILE), "ff-daemon.log"), "w")
|
||||||
|
subprocess.Popen([sys.executable, os.path.abspath(__file__), "_serve"],
|
||||||
|
env=env, creationflags=flags, close_fds=True,
|
||||||
|
stdin=subprocess.DEVNULL, stdout=log, stderr=log)
|
||||||
|
for _ in range(60):
|
||||||
|
if _alive():
|
||||||
|
print(f"[ff] daemon up on {BASE} (headless={headless}) profile={PROFILE}")
|
||||||
|
if url:
|
||||||
|
print(f"[ff] start url -> {_fix(url)}")
|
||||||
|
return
|
||||||
|
time.sleep(0.5)
|
||||||
|
raise SystemExit("[ff] daemon failed to start (check that 'py -m playwright install firefox' ran)")
|
||||||
|
|
||||||
|
|
||||||
|
def _fix(url):
|
||||||
|
if url and "://" not in url and url != "about:blank":
|
||||||
|
return "https://" + url
|
||||||
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
def _need(args, n, what):
|
||||||
|
if len(args) < n:
|
||||||
|
raise SystemExit(f"[ff] {what}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(a):
|
||||||
|
print(json.dumps(_req("/status"), indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_nav(a):
|
||||||
|
_need(a, 1, "usage: ff.py nav <url>")
|
||||||
|
_req("/nav", "POST", {"url": _fix(a[0])})
|
||||||
|
print(f"[ff] navigated -> {_fix(a[0])}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_shot(a):
|
||||||
|
_need(a, 1, "usage: ff.py shot <out.png>")
|
||||||
|
out = os.path.abspath(a[0])
|
||||||
|
_req("/shot", "POST", {"path": out})
|
||||||
|
print(f"[ff] screenshot -> {out} ({os.path.getsize(out)} bytes)")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_click(a):
|
||||||
|
_need(a, 2, "usage: ff.py click <x> <y>")
|
||||||
|
_req("/click", "POST", {"x": float(a[0]), "y": float(a[1])})
|
||||||
|
print(f"[ff] click ({a[0]},{a[1]})")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_type(a):
|
||||||
|
_need(a, 1, "usage: ff.py type <text>")
|
||||||
|
_req("/type", "POST", {"text": a[0]})
|
||||||
|
print(f"[ff] typed {len(a[0])} chars")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_key(a):
|
||||||
|
_need(a, 1, "usage: ff.py key <Key>")
|
||||||
|
_req("/key", "POST", {"key": a[0]})
|
||||||
|
print(f"[ff] key {a[0]}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_eval(a):
|
||||||
|
_need(a, 1, "usage: ff.py eval <js>")
|
||||||
|
print(json.dumps(_req("/eval", "POST", {"js": a[0]}).get("value"), indent=2, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_console(a):
|
||||||
|
res = _req("/console" + ("?clear=1" if "--clear" in a else ""))
|
||||||
|
print(json.dumps(res.get("messages", []), indent=2, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_network(a):
|
||||||
|
res = _req("/network" + ("?clear=1" if "--clear" in a else ""))
|
||||||
|
print(json.dumps(res.get("requests", []), indent=2, default=str))
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_stop(a):
|
||||||
|
if not _alive():
|
||||||
|
print("[ff] daemon not running")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
_req("/stop", "POST", {}, timeout=5)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
print("[ff] daemon stopped")
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# daemon side (py ff.py _serve) -- holds the live Firefox page
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def serve():
|
||||||
|
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||||
|
from urllib.parse import urlparse, parse_qs
|
||||||
|
import threading
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
headless = os.environ.get("FF_HEADLESS") == "1"
|
||||||
|
start_url = os.environ.get("FF_START_URL", "about:blank")
|
||||||
|
|
||||||
|
pw = sync_playwright().start()
|
||||||
|
ctx = pw.firefox.launch_persistent_context(PROFILE, headless=headless,
|
||||||
|
viewport={"width": 1280, "height": 800})
|
||||||
|
page = ctx.pages[0] if ctx.pages else ctx.new_page()
|
||||||
|
|
||||||
|
console_log, network_log = [], []
|
||||||
|
page.on("console", lambda m: console_log.append(
|
||||||
|
{"type": m.type, "text": m.text, "location": m.location}))
|
||||||
|
page.on("response", lambda r: network_log.append(
|
||||||
|
{"status": r.status, "method": r.request.method, "url": r.url,
|
||||||
|
"type": r.request.resource_type}))
|
||||||
|
page.on("pageerror", lambda e: console_log.append(
|
||||||
|
{"type": "pageerror", "text": str(e), "location": {}}))
|
||||||
|
if start_url and start_url != "about:blank":
|
||||||
|
try:
|
||||||
|
page.goto(start_url, wait_until="load", timeout=30000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class H(BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, *a): # silence
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _reply(self, obj, code=200):
|
||||||
|
b = json.dumps(obj, default=str).encode()
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", "application/json")
|
||||||
|
self.send_header("Content-Length", str(len(b)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(b)
|
||||||
|
|
||||||
|
def _body(self):
|
||||||
|
n = int(self.headers.get("Content-Length", 0))
|
||||||
|
return json.loads(self.rfile.read(n)) if n else {}
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
u = urlparse(self.path)
|
||||||
|
q = parse_qs(u.query)
|
||||||
|
try:
|
||||||
|
if u.path == "/status":
|
||||||
|
self._reply({"ok": True, "url": page.url, "title": page.title(),
|
||||||
|
"headless": headless, "console": len(console_log),
|
||||||
|
"network": len(network_log)})
|
||||||
|
elif u.path == "/console":
|
||||||
|
msgs = list(console_log)
|
||||||
|
if q.get("clear"):
|
||||||
|
console_log.clear()
|
||||||
|
self._reply({"messages": msgs})
|
||||||
|
elif u.path == "/network":
|
||||||
|
reqs = list(network_log)
|
||||||
|
if q.get("clear"):
|
||||||
|
network_log.clear()
|
||||||
|
self._reply({"requests": reqs})
|
||||||
|
else:
|
||||||
|
self._reply({"error": "not found"}, 404)
|
||||||
|
except Exception as e:
|
||||||
|
self._reply({"error": str(e)}, 500)
|
||||||
|
|
||||||
|
def do_POST(self):
|
||||||
|
u = urlparse(self.path)
|
||||||
|
try:
|
||||||
|
b = self._body()
|
||||||
|
if u.path == "/nav":
|
||||||
|
page.goto(b["url"], wait_until="load", timeout=30000)
|
||||||
|
self._reply({"ok": True, "url": page.url})
|
||||||
|
elif u.path == "/shot":
|
||||||
|
page.screenshot(path=b["path"], full_page=b.get("full", False))
|
||||||
|
self._reply({"ok": True})
|
||||||
|
elif u.path == "/click":
|
||||||
|
page.mouse.click(b["x"], b["y"])
|
||||||
|
self._reply({"ok": True})
|
||||||
|
elif u.path == "/type":
|
||||||
|
page.keyboard.insert_text(b["text"])
|
||||||
|
self._reply({"ok": True})
|
||||||
|
elif u.path == "/key":
|
||||||
|
page.keyboard.press(b["key"])
|
||||||
|
self._reply({"ok": True})
|
||||||
|
elif u.path == "/eval":
|
||||||
|
self._reply({"value": page.evaluate(b["js"])})
|
||||||
|
elif u.path == "/stop":
|
||||||
|
self._reply({"ok": True})
|
||||||
|
threading.Thread(target=httpd.shutdown, daemon=True).start()
|
||||||
|
else:
|
||||||
|
self._reply({"error": "not found"}, 404)
|
||||||
|
except Exception as e:
|
||||||
|
self._reply({"error": str(e)}, 500)
|
||||||
|
|
||||||
|
httpd = HTTPServer(("127.0.0.1", PORT), H)
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
ctx.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
pw.stop()
|
||||||
|
|
||||||
|
|
||||||
|
CMDS = {"launch": cmd_launch, "status": cmd_status, "nav": cmd_nav, "shot": cmd_shot,
|
||||||
|
"click": cmd_click, "type": cmd_type, "key": cmd_key, "eval": cmd_eval,
|
||||||
|
"console": cmd_console, "network": cmd_network, "stop": cmd_stop}
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) >= 2 and sys.argv[1] == "_serve":
|
||||||
|
serve()
|
||||||
|
elif len(sys.argv) < 2 or sys.argv[1] not in CMDS:
|
||||||
|
print(__doc__)
|
||||||
|
raise SystemExit(1)
|
||||||
|
else:
|
||||||
|
CMDS[sys.argv[1]](sys.argv[2:])
|
||||||
Reference in New Issue
Block a user