screenshot_map.py 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167
  1. #!/usr/bin/env python3
  2. """Headless screenshot + marker-alignment verifier for a served Mapbox GL JS page.
  3. Usage: screenshot_map.py [OPTIONS] <URL> <OUT.png>
  4. Input: URL of a *served* map page (http://…, not file://); PNG output path.
  5. Optional --expect LNG LAT to project a coordinate to its on-canvas pixel.
  6. Output: stdout = data only — a human result line, or the --json envelope (§4).
  7. Stderr: progress, warnings, console-error dumps, stack-trace-free diagnostics.
  8. Exit: 0 ok (map ready, no console errors)
  9. 2 usage (bad/missing args)
  10. 5 precondition (playwright not installed / browser missing)
  11. 7 unavailable (map never signalled ready within --timeout)
  12. 10 domain signal (page/console errors were captured) — caller branches on this
  13. Examples:
  14. screenshot_map.py http://localhost:8777/preview/index.html out.png
  15. screenshot_map.py http://localhost:8777/index.html out.png --expect 146.9 -36.1
  16. screenshot_map.py http://localhost:8777/index.html out.png --json | jq '.data'
  17. First run only: uv run --with playwright python -m playwright install chromium
  18. Why served (not file://): a page that fetches GeoJSON/photos at runtime needs an HTTP
  19. origin and a same-origin canvas (else createImageBitmap taints). Serve one with:
  20. python -m http.server 8777 --directory <site-dir>
  21. """
  22. from __future__ import annotations
  23. import argparse
  24. import json
  25. import sys
  26. # Semantic exit codes (SKILL-RESOURCE-PROTOCOL §5).
  27. EX_OK, EX_USAGE, EX_PRECONDITION, EX_UNAVAILABLE, EX_DOMAIN = 0, 2, 5, 7, 10
  28. READY_JS = """
  29. () => {
  30. try {
  31. if (window.__mapReady === true) return true;
  32. const m = window.map;
  33. if (!m) return false;
  34. if (typeof m.loaded === 'function' && m.loaded()) return true;
  35. if (typeof m.isStyleLoaded === 'function' && m.isStyleLoaded()) return true;
  36. return false;
  37. } catch (e) { return false; }
  38. }
  39. """
  40. PROJECT_JS = """
  41. ([lng, lat]) => {
  42. const m = window.map;
  43. if (!m) return null;
  44. const r = m.getCanvas().getBoundingClientRect();
  45. const p = m.project([lng, lat]);
  46. return { canvas: {x: p.x, y: p.y},
  47. page: {x: r.left + p.x, y: r.top + p.y},
  48. size: {w: r.width, h: r.height} };
  49. }
  50. """
  51. SCHEMA = "claude-mods.mapbox-ops.screenshot_map/v1"
  52. def emit_json(data: dict, code: int) -> int:
  53. """Print the §4 success/error envelope to stdout and return the exit code."""
  54. if code in (EX_OK, EX_DOMAIN):
  55. print(json.dumps({"data": data, "meta": {"schema": SCHEMA, "exit": code}}))
  56. else:
  57. print(json.dumps({"error": {"code": data.get("code", "ERROR"),
  58. "message": data.get("message", ""),
  59. "details": data}}))
  60. return code
  61. def main() -> int:
  62. ap = argparse.ArgumentParser(
  63. prog="screenshot_map.py", add_help=True,
  64. description="Headless screenshot + marker-alignment verifier for a served Mapbox GL JS page.")
  65. ap.add_argument("url", help="served map page URL (http://…, not file://)")
  66. ap.add_argument("out", help="screenshot output path (.png)")
  67. ap.add_argument("--expect", nargs=2, type=float, metavar=("LNG", "LAT"),
  68. help="project this lng/lat and report its pixel")
  69. ap.add_argument("--width", type=int, default=1280)
  70. ap.add_argument("--height", type=int, default=800)
  71. ap.add_argument("--timeout", type=int, default=20000, help="readiness timeout (ms)")
  72. ap.add_argument("--json", action="store_true", help="emit the structured §4 envelope")
  73. args = ap.parse_args()
  74. as_json = args.json
  75. if not args.url.startswith(("http://", "https://")):
  76. msg = "URL must be http(s):// (the page must be served, not file://)"
  77. print(f"error: {msg}", file=sys.stderr)
  78. return emit_json({"code": "USAGE", "message": msg}, EX_USAGE) if as_json else EX_USAGE
  79. try:
  80. from playwright.sync_api import sync_playwright
  81. except ImportError:
  82. msg = ("playwright not installed — run: uv run --with playwright "
  83. "python -m playwright install chromium")
  84. print(f"error: {msg}", file=sys.stderr)
  85. return emit_json({"code": "PRECONDITION", "message": msg}, EX_PRECONDITION) if as_json else EX_PRECONDITION
  86. errors: list[str] = []
  87. ready = False
  88. projection: dict | None = None
  89. with sync_playwright() as p:
  90. try:
  91. browser = p.chromium.launch()
  92. except Exception as e: # browser binary not installed
  93. msg = f"chromium launch failed — run: python -m playwright install chromium ({e})"
  94. print(f"error: {msg}", file=sys.stderr)
  95. return emit_json({"code": "PRECONDITION", "message": msg}, EX_PRECONDITION) if as_json else EX_PRECONDITION
  96. page = browser.new_page(viewport={"width": args.width, "height": args.height},
  97. device_scale_factor=2)
  98. page.on("console", lambda m: errors.append(m.text) if m.type == "error" else None)
  99. page.on("pageerror", lambda e: errors.append(str(e)))
  100. page.goto(args.url, wait_until="networkidle")
  101. try:
  102. page.wait_for_function(READY_JS, timeout=args.timeout)
  103. ready = True
  104. except Exception:
  105. print(f"warn: map not ready within {args.timeout}ms "
  106. "(set window.__mapReady=true at end of init() for an exact signal)",
  107. file=sys.stderr)
  108. page.screenshot(path=args.out, full_page=False)
  109. print(f"screenshot → {args.out}", file=sys.stderr) # status → stderr, not data
  110. if args.expect:
  111. projection = page.evaluate(PROJECT_JS, args.expect)
  112. if not projection:
  113. print("warn: window.map not found (expose it: `window.map = map`)", file=sys.stderr)
  114. browser.close()
  115. # Result assembly
  116. data = {"out": args.out, "ready": ready, "errorCount": len(errors), "errors": errors[:20]}
  117. if projection:
  118. cx, cy = projection["canvas"]["x"], projection["canvas"]["y"]
  119. w, h = projection["size"]["w"], projection["size"]["h"]
  120. projection["inside"] = bool(0 <= cx <= w and 0 <= cy <= h)
  121. data["projection"] = projection
  122. # Decide exit code: console/page errors are the domain signal (10); never-ready is 7.
  123. code = EX_DOMAIN if errors else (EX_UNAVAILABLE if not ready else EX_OK)
  124. if as_json:
  125. return emit_json(data, code)
  126. # plain-text data product on stdout
  127. line = f"ready={ready} errors={len(errors)} out={args.out}"
  128. if projection:
  129. line += (f" project={tuple(args.expect)}→canvas("
  130. f"{projection['canvas']['x']:.0f},{projection['canvas']['y']:.0f}) "
  131. f"{'inside' if projection['inside'] else 'OUTSIDE'}")
  132. print(line)
  133. if errors:
  134. print(f"\n{len(errors)} console/page error(s):", file=sys.stderr)
  135. for e in errors[:20]:
  136. print(" - " + e, file=sys.stderr)
  137. return code
  138. if __name__ == "__main__":
  139. raise SystemExit(main())