v0.3: dashboard local mode, openbridge tab reuse, tab activation fix
- dashboard.html: local dashboard.json mode (10s poll) alongside artifact db mode; clickable cards behind an http(s)-only safeUrl filter; meta charset; mode-aware freshness line - openbridge.py: localhost shim exposing open_link so dashboard clicks reuse the platform's warm tab instead of spawning one per click - cdp.py: Tab.activate(); get_or_open_tab activates reused tabs — a backgrounded tab never finishes rendering, which made YouTube's login check read a signed-in account as signed out - README/MANIFEST: corrected CDP port (9336, was documented as 9222), removed the false claim that the server writes dashboard.json, documented the reboot/VPS requirements - .gitignore: __pycache__/, *.pyc, state.json, Archived/; removed stale tracked pycache and state.json from index
This commit is contained in:
+134
@@ -0,0 +1,134 @@
|
||||
"""openbridge — a localhost shim so the dashboard can reuse warm tabs.
|
||||
|
||||
The dashboard is a static page served off a plain file server; it cannot speak
|
||||
MCP. This exposes exactly one capability of the socialmag server — `open_link` —
|
||||
over 127.0.0.1, so clicking a card navigates the platform's existing tab instead
|
||||
of spawning a new one on every click.
|
||||
|
||||
It does not reimplement open_link. It imports `tool_open_link` and calls it, so
|
||||
validation (unknown platform, missing url, non-http(s) scheme) and tab matching
|
||||
stay in one place: change server.py and this follows.
|
||||
|
||||
Run:
|
||||
python3 -m socialmag.openbridge # 127.0.0.1:8322
|
||||
python3 -m socialmag.openbridge --port N
|
||||
|
||||
Endpoints:
|
||||
GET /health -> {"ok": true, "platforms": [...]}
|
||||
GET /open?platform=X&url=Y -> the open_link result, or {"error": ...}
|
||||
|
||||
The dashboard treats an unreachable bridge as "just open a new tab" — it falls
|
||||
back to the anchor's normal behaviour, so the page works with this not running.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
import threading
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
|
||||
from .queue import PostQueue
|
||||
from .server import PLATFORM_DOMAINS, tool_open_link
|
||||
|
||||
# The dashboard's clicks are human-paced and must feel instant. The posting
|
||||
# queue's 3-6s inter-job spacing exists to look human while publishing; applied
|
||||
# to a click it would just look broken.
|
||||
CLICK_DELAY = (0.0, 0.0)
|
||||
|
||||
|
||||
class _Loop:
|
||||
"""One asyncio loop on a background thread, with a started PostQueue.
|
||||
|
||||
Clicks still go through a queue: two fast clicks must not open two CDP
|
||||
connections racing for the same tab.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.loop = asyncio.new_event_loop()
|
||||
self.queue = PostQueue(inter_platform_delay=CLICK_DELAY)
|
||||
self._ready = threading.Event()
|
||||
self.thread = threading.Thread(target=self._run, name="openbridge", daemon=True)
|
||||
self.thread.start()
|
||||
if not self._ready.wait(timeout=10):
|
||||
raise RuntimeError("openbridge: event loop failed to start within 10s")
|
||||
|
||||
def _run(self):
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.loop.run_until_complete(self.queue.start())
|
||||
self.loop.call_soon(self._ready.set)
|
||||
self.loop.run_forever()
|
||||
|
||||
def call(self, coro, timeout=120):
|
||||
return asyncio.run_coroutine_threadsafe(coro, self.loop).result(timeout)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "socialmag-openbridge"
|
||||
bridge = None # set in serve()
|
||||
|
||||
def _send(self, code, payload):
|
||||
body = json.dumps(payload).encode("utf-8")
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
# The dashboard is served from a different port, so this is cross-origin.
|
||||
# The listener is bound to 127.0.0.1, so the reachable surface is local.
|
||||
self.send_header("Access-Control-Allow-Origin", "*")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self):
|
||||
parts = urllib.parse.urlsplit(self.path)
|
||||
query = urllib.parse.parse_qs(parts.query)
|
||||
|
||||
if parts.path == "/health":
|
||||
return self._send(200, {"ok": True, "platforms": sorted(PLATFORM_DOMAINS)})
|
||||
|
||||
if parts.path != "/open":
|
||||
return self._send(404, {"error": f"no such endpoint: {parts.path}"})
|
||||
|
||||
platform = (query.get("platform") or [None])[0]
|
||||
url = (query.get("url") or [None])[0]
|
||||
try:
|
||||
result = self.bridge.call(
|
||||
tool_open_link({"platform": platform, "url": url}, self.bridge.queue)
|
||||
)
|
||||
except ValueError as e:
|
||||
# open_link's own validation — a bad request, not a broken bridge.
|
||||
return self._send(400, {"error": str(e)})
|
||||
except Exception as e:
|
||||
return self._send(502, {"error": f"{type(e).__name__}: {e}"})
|
||||
return self._send(200, result)
|
||||
|
||||
def log_message(self, fmt, *args):
|
||||
sys.stderr.write("[openbridge] %s\n" % (fmt % args))
|
||||
|
||||
|
||||
def serve(port):
|
||||
Handler.bridge = _Loop()
|
||||
# No port fallback on purpose: if 8322 is taken, something else is already
|
||||
# answering there and silently moving would point the dashboard at nothing.
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", port), Handler)
|
||||
print(
|
||||
f"[openbridge] listening on http://127.0.0.1:{port} "
|
||||
f"(/health, /open?platform=&url=)",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("[openbridge] stopping", file=sys.stderr)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(description=__doc__)
|
||||
ap.add_argument("--port", type=int, default=8322)
|
||||
serve(ap.parse_args().port)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user