- 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
135 lines
4.8 KiB
Python
135 lines
4.8 KiB
Python
"""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()
|