ghost-api-cli 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. ghost_api/__init__.py +3 -0
  2. ghost_api/__main__.py +5 -0
  3. ghost_api/adopt.py +173 -0
  4. ghost_api/agent/__init__.py +0 -0
  5. ghost_api/agent/orchestrator.py +413 -0
  6. ghost_api/agent/prompts.py +149 -0
  7. ghost_api/agent/tools.py +84 -0
  8. ghost_api/analysis/__init__.py +0 -0
  9. ghost_api/analysis/analyzer.py +130 -0
  10. ghost_api/analysis/detectors.py +188 -0
  11. ghost_api/analysis/exposure.py +80 -0
  12. ghost_api/analysis/graphql.py +67 -0
  13. ghost_api/analysis/models.py +64 -0
  14. ghost_api/analysis/schema.py +109 -0
  15. ghost_api/analysis/url_grouping.py +217 -0
  16. ghost_api/api.py +264 -0
  17. ghost_api/audit.py +78 -0
  18. ghost_api/browser/__init__.py +0 -0
  19. ghost_api/browser/auth_state.py +172 -0
  20. ghost_api/browser/driver.py +273 -0
  21. ghost_api/browser/egress.py +183 -0
  22. ghost_api/browser/gate.py +127 -0
  23. ghost_api/browser/install.py +28 -0
  24. ghost_api/browser/snapshot.py +221 -0
  25. ghost_api/capture/__init__.py +0 -0
  26. ghost_api/capture/filters.py +255 -0
  27. ghost_api/capture/har.py +148 -0
  28. ghost_api/capture/models.py +41 -0
  29. ghost_api/capture/personal.py +188 -0
  30. ghost_api/capture/recorder.py +181 -0
  31. ghost_api/cli/__init__.py +44 -0
  32. ghost_api/cli/_common.py +219 -0
  33. ghost_api/cli/client.py +487 -0
  34. ghost_api/cli/data.py +492 -0
  35. ghost_api/cli/explore.py +379 -0
  36. ghost_api/cli/manage.py +437 -0
  37. ghost_api/config.py +132 -0
  38. ghost_api/drift.py +344 -0
  39. ghost_api/drive.py +211 -0
  40. ghost_api/errors.py +106 -0
  41. ghost_api/events.py +213 -0
  42. ghost_api/exitcodes.py +21 -0
  43. ghost_api/export/__init__.py +71 -0
  44. ghost_api/export/confidence.py +45 -0
  45. ghost_api/export/html.py +41 -0
  46. ghost_api/export/openapi.py +201 -0
  47. ghost_api/export/report.py +227 -0
  48. ghost_api/export/site_data.py +196 -0
  49. ghost_api/export/tabular.py +88 -0
  50. ghost_api/export/viewer/report.html +286 -0
  51. ghost_api/generator/__init__.py +221 -0
  52. ghost_api/generator/cassettes.py +80 -0
  53. ghost_api/generator/models_gen.py +144 -0
  54. ghost_api/generator/naming.py +174 -0
  55. ghost_api/generator/package.py +197 -0
  56. ghost_api/generator/python_gen.py +172 -0
  57. ghost_api/generator/spec.py +486 -0
  58. ghost_api/generator/templates/client.py.j2 +518 -0
  59. ghost_api/generator/templates/client.test.ts.j2 +106 -0
  60. ghost_api/generator/templates/client.ts.j2 +339 -0
  61. ghost_api/generator/templates/models.py.j2 +45 -0
  62. ghost_api/generator/templates/package_readme.md.j2 +111 -0
  63. ghost_api/generator/templates/pyproject.toml.j2 +22 -0
  64. ghost_api/generator/templates/test_client.py.j2 +105 -0
  65. ghost_api/generator/ts_gen.py +232 -0
  66. ghost_api/integrations/__init__.py +0 -0
  67. ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
  68. ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
  69. ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
  70. ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
  71. ghost_api/integrations/setup.py +84 -0
  72. ghost_api/llm/__init__.py +0 -0
  73. ghost_api/llm/doctor.py +60 -0
  74. ghost_api/llm/gateway.py +476 -0
  75. ghost_api/llm/providers.py +124 -0
  76. ghost_api/memory/__init__.py +0 -0
  77. ghost_api/memory/knowledge.py +157 -0
  78. ghost_api/memory/store.py +883 -0
  79. ghost_api/net.py +235 -0
  80. ghost_api/netguard.py +203 -0
  81. ghost_api/pipeline.py +745 -0
  82. ghost_api/probe/__init__.py +0 -0
  83. ghost_api/probe/prober.py +311 -0
  84. ghost_api/probe/replay.py +313 -0
  85. ghost_api/project.py +186 -0
  86. ghost_api/py.typed +0 -0
  87. ghost_api/runtime.py +397 -0
  88. ghost_api/serve/__init__.py +101 -0
  89. ghost_api/serve/explorer_mcp.py +305 -0
  90. ghost_api/serve/mcp.py +121 -0
  91. ghost_api/serve/mock.py +130 -0
  92. ghost_api/serve/rest.py +132 -0
  93. ghost_api/site.py +84 -0
  94. ghost_api/specdiff.py +122 -0
  95. ghost_api/ui/__init__.py +0 -0
  96. ghost_api/ui/keys.py +134 -0
  97. ghost_api/ui/repl.py +453 -0
  98. ghost_api/ui/terminal.py +573 -0
  99. ghost_api/verify/__init__.py +0 -0
  100. ghost_api/verify/classify.py +84 -0
  101. ghost_api/verify/repair.py +40 -0
  102. ghost_api/verify/runner.py +110 -0
  103. ghost_api/verify/verifier.py +291 -0
  104. ghost_api/watch.py +313 -0
  105. ghost_api/web/__init__.py +1 -0
  106. ghost_api/web/server.py +690 -0
  107. ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
  108. ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
  109. ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
  110. ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
  111. ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
  112. ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
  113. ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
  114. ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
  115. ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
  116. ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
  117. ghost_api/web/static/favicon.svg +1 -0
  118. ghost_api/web/static/index.html +26 -0
  119. ghost_api/workflows.py +203 -0
  120. ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
  121. ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
  122. ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
  123. ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
  124. ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
  125. ghost_api_cli-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,273 @@
1
+ """Control a real Chromium browser with Playwright (FR-7).
2
+
3
+ Every action ends with `settle()`, which waits for the network to go quiet and then
4
+ flushes the traffic recorder so response bodies are read before the next action.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import contextlib
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+ from types import TracebackType
13
+ from typing import Any
14
+ from urllib.parse import urlsplit
15
+
16
+ from playwright.sync_api import Browser, BrowserContext, Page, Playwright, sync_playwright
17
+ from playwright.sync_api import Error as PlaywrightError
18
+
19
+ from ghost_api.browser.egress import EgressProxy
20
+ from ghost_api.browser.gate import RequestGate
21
+ from ghost_api.browser.snapshot import (
22
+ SNAPSHOT_ARGS,
23
+ SNAPSHOT_JS,
24
+ PageSnapshot,
25
+ build_snapshot,
26
+ )
27
+ from ghost_api.capture.models import CapturedRequest
28
+ from ghost_api.capture.recorder import TrafficRecorder
29
+ from ghost_api.config import tool_user_agent
30
+ from ghost_api.net import RobotsPolicy
31
+ from ghost_api.netguard import NetworkPolicy
32
+
33
+ NAV_TIMEOUT_MS = 30_000
34
+ SETTLE_TIMEOUT_MS = 8_000
35
+ QUIET_MS = 600
36
+ POLL_MS = 100
37
+ ACTION_TIMEOUT_MS = 5_000
38
+
39
+
40
+ class ActionError(Exception):
41
+ """An agent action could not be performed; the message is shown to the agent."""
42
+
43
+
44
+ class BrowserSession:
45
+ def __init__(
46
+ self,
47
+ start_url: str,
48
+ recorder: TrafficRecorder,
49
+ headed: bool = False,
50
+ policy: NetworkPolicy | None = None,
51
+ gate: RequestGate | None = None,
52
+ on_block: Callable[[str], None] | None = None,
53
+ storage_state: dict[str, Any] | None = None,
54
+ ) -> None:
55
+ """`policy`: where the browser may connect (default: decided from `start_url`).
56
+ `gate`: robots.txt and rate limits for the browser's own requests."""
57
+ self.start_url = start_url
58
+ self.recorder = recorder
59
+ self.headed = headed
60
+ self.policy = policy or NetworkPolicy.for_target(start_url)
61
+ self.gate = gate
62
+ self.on_block = on_block
63
+ self.storage_state = storage_state # a saved login (auth_state.py)
64
+ self._proxy: EgressProxy | None = None
65
+ self._pw: Playwright | None = None
66
+ self._browser: Browser | None = None
67
+ self._context: BrowserContext | None = None
68
+ self._page: Page | None = None
69
+ self._inflight = 0
70
+ self.popup_urls: list[str] = []
71
+
72
+ @property
73
+ def page(self) -> Page:
74
+ if self._page is None:
75
+ raise RuntimeError("BrowserSession is not open")
76
+ return self._page
77
+
78
+ @property
79
+ def host(self) -> str:
80
+ return urlsplit(self.start_url).hostname or ""
81
+
82
+ def open(self) -> BrowserSession:
83
+ launch: dict[str, Any] = {"headless": not self.headed}
84
+ if not self.policy.allow_private:
85
+ # Every connection goes through the egress proxy, loopback included (Chromium
86
+ # skips the proxy for localhost unless told "<-loopback>").
87
+ self._proxy = EgressProxy(self.policy, self._blocked).start()
88
+ launch["proxy"] = {"server": self._proxy.url, "bypass": "<-loopback>"}
89
+ self._pw = sync_playwright().start()
90
+ self._browser = self._pw.chromium.launch(**launch)
91
+ # Keep the real browser user-agent (so sites behave normally) and add an honest
92
+ # identifier for this tool (NFR-20).
93
+ probe = self._browser.new_page()
94
+ base_ua = probe.evaluate("navigator.userAgent")
95
+ probe.close()
96
+ self._context = self._browser.new_context(
97
+ user_agent=f"{base_ua} {tool_user_agent()}",
98
+ storage_state=self.storage_state, # type: ignore[arg-type]
99
+ )
100
+ if self.gate is not None:
101
+ self._context.route("**/*", self.gate.handle)
102
+ self._page = self._context.new_page()
103
+ # Registered after the main tab exists, so only real popups are caught.
104
+ self._context.on("page", self._on_popup)
105
+ self._page.set_default_navigation_timeout(NAV_TIMEOUT_MS)
106
+ self._page.on("request", lambda _: self._track(+1))
107
+ self._page.on("requestfinished", lambda _: self._track(-1))
108
+ self._page.on("requestfailed", lambda _: self._track(-1))
109
+ self.recorder.attach(self._page)
110
+ return self
111
+
112
+ def close(self) -> None:
113
+ for closer in (self._context, self._browser):
114
+ if closer is not None:
115
+ with contextlib.suppress(Exception): # best-effort cleanup
116
+ closer.close()
117
+ if self._pw is not None:
118
+ self._pw.stop()
119
+ if self._proxy is not None:
120
+ self._proxy.stop()
121
+ self._page = self._context = self._browser = self._pw = None
122
+ self._proxy = None
123
+
124
+ def export_state(self) -> dict[str, Any]:
125
+ """Cookies and site storage now (after the user logged in), for auth_state.save."""
126
+ if self._context is None:
127
+ raise RuntimeError("BrowserSession is not open")
128
+ return dict(self._context.storage_state())
129
+
130
+ def _blocked(self, message: str) -> None:
131
+ self.recorder.dropped["blocked-network"] += 1
132
+ if self.on_block is not None:
133
+ self.on_block(message)
134
+
135
+ def __enter__(self) -> BrowserSession:
136
+ return self.open()
137
+
138
+ def __exit__(
139
+ self,
140
+ exc_type: type[BaseException] | None,
141
+ exc: BaseException | None,
142
+ tb: TracebackType | None,
143
+ ) -> None:
144
+ self.close()
145
+
146
+ # --- actions ---
147
+
148
+ def settle(self) -> list[CapturedRequest]:
149
+ """Wait until no requests have been in flight for QUIET_MS, then collect traffic.
150
+
151
+ Playwright's "networkidle" load state only applies to the initial page load, so
152
+ requests triggered later (by a scroll or click) are tracked here instead. Sites
153
+ with long-polling never go quiet; SETTLE_TIMEOUT_MS caps the wait.
154
+ """
155
+ waited = quiet = 0
156
+ while waited < SETTLE_TIMEOUT_MS:
157
+ self.page.wait_for_timeout(POLL_MS) # lets Playwright deliver events
158
+ waited += POLL_MS
159
+ quiet = quiet + POLL_MS if self._inflight == 0 else 0
160
+ if quiet >= QUIET_MS:
161
+ break
162
+ return self.recorder.flush()
163
+
164
+ def _track(self, delta: int) -> None:
165
+ self._inflight = max(0, self._inflight + delta)
166
+
167
+ def _on_popup(self, popup: Page) -> None:
168
+ """Keep everything in one tab: remember where a popup was going, then close it."""
169
+ if popup is self._page:
170
+ return
171
+ with contextlib.suppress(PlaywrightError):
172
+ popup.wait_for_load_state("domcontentloaded", timeout=ACTION_TIMEOUT_MS)
173
+ self.popup_urls.append(popup.url)
174
+ popup.close()
175
+
176
+ @property
177
+ def url(self) -> str:
178
+ return self.page.url
179
+
180
+ def snapshot(self, target_host: str, robots: RobotsPolicy | None = None) -> PageSnapshot:
181
+ raw = self.page.evaluate(SNAPSHOT_JS, SNAPSHOT_ARGS)
182
+ return build_snapshot(raw, target_host, robots)
183
+
184
+ def screenshot(self, path: Path) -> bool:
185
+ """Save a small JPEG of what the browser shows (for the dashboard's live view)."""
186
+ try:
187
+ path.parent.mkdir(parents=True, exist_ok=True)
188
+ self.page.screenshot(path=str(path), type="jpeg", quality=55)
189
+ except PlaywrightError:
190
+ return False
191
+ return True
192
+
193
+ def element_box(self, element_id: int) -> dict[str, float] | None:
194
+ """Where an element is on the page (viewport pixels), before acting on it."""
195
+ try:
196
+ box = self.page.locator(f'[data-ghost-id="{element_id}"]').first.bounding_box(
197
+ timeout=1_000
198
+ )
199
+ except PlaywrightError:
200
+ return None
201
+ if box is None:
202
+ return None
203
+ viewport = self.page.viewport_size or {"width": 1280, "height": 720}
204
+ return {
205
+ "x": box["x"],
206
+ "y": box["y"],
207
+ "width": box["width"],
208
+ "height": box["height"],
209
+ "viewport_width": viewport["width"],
210
+ "viewport_height": viewport["height"],
211
+ }
212
+
213
+ def _element(self, element_id: int): # type: ignore[no-untyped-def]
214
+ loc = self.page.locator(f'[data-ghost-id="{element_id}"]').first
215
+ if loc.count() == 0:
216
+ raise ActionError(f"element [{element_id}] is no longer on the page")
217
+ return loc
218
+
219
+ def _after_action(self) -> list[CapturedRequest]:
220
+ # A click may start a navigation; give the new document a moment to load.
221
+ with contextlib.suppress(PlaywrightError):
222
+ self.page.wait_for_load_state("domcontentloaded", timeout=ACTION_TIMEOUT_MS)
223
+ return self.settle()
224
+
225
+ def click(self, element_id: int) -> list[CapturedRequest]:
226
+ loc = self._element(element_id)
227
+ try:
228
+ loc.click(timeout=ACTION_TIMEOUT_MS)
229
+ except PlaywrightError:
230
+ # Covered by an overlay or animating: fall back to a DOM click.
231
+ try:
232
+ loc.evaluate("el => el.click()")
233
+ except PlaywrightError as exc:
234
+ raise ActionError(f"could not click [{element_id}]: {_first_line(exc)}") from exc
235
+ return self._after_action()
236
+
237
+ def type_text(self, element_id: int, text: str, submit: bool) -> list[CapturedRequest]:
238
+ loc = self._element(element_id)
239
+ try:
240
+ loc.fill(text, timeout=ACTION_TIMEOUT_MS)
241
+ if submit:
242
+ loc.press("Enter", timeout=ACTION_TIMEOUT_MS)
243
+ except PlaywrightError as exc:
244
+ raise ActionError(f"could not type into [{element_id}]: {_first_line(exc)}") from exc
245
+ return self._after_action()
246
+
247
+ def go_back(self) -> list[CapturedRequest]:
248
+ try:
249
+ self.page.go_back(wait_until="domcontentloaded")
250
+ except PlaywrightError as exc:
251
+ raise ActionError(f"could not go back: {_first_line(exc)}") from exc
252
+ return self.settle()
253
+
254
+ def goto(self, url: str) -> list[CapturedRequest]:
255
+ try:
256
+ self.page.goto(url, wait_until="domcontentloaded")
257
+ except PlaywrightError as exc:
258
+ raise ActionError(f"could not open {url}: {_first_line(exc)}") from exc
259
+ return self.settle()
260
+
261
+ def scroll(self, direction: str = "down") -> list[CapturedRequest]:
262
+ if direction == "bottom":
263
+ self.page.evaluate("window.scrollTo(0, document.documentElement.scrollHeight)")
264
+ else:
265
+ delta = -1 if direction == "up" else 1
266
+ viewport = self.page.viewport_size
267
+ height = viewport["height"] if viewport else 800
268
+ self.page.mouse.wheel(0, delta * height)
269
+ return self.settle()
270
+
271
+
272
+ def _first_line(exc: Exception) -> str:
273
+ return str(exc).strip().splitlines()[0][:160] if str(exc).strip() else type(exc).__name__
@@ -0,0 +1,183 @@
1
+ """A tiny local proxy that every browser connection goes through (the network boundary).
2
+
3
+ Playwright's request interception does not see redirect hops: when a page or an API call
4
+ is redirected, the browser follows it on its own. A proxy sees every connection the
5
+ browser opens — redirects, images, scripts, websockets — so each destination is checked
6
+ with `NetworkPolicy` before anything is sent to it. The proxy then connects to the very
7
+ address that passed the check (no second DNS lookup to switch to a private address).
8
+
9
+ HTTPS goes through `CONNECT host:port` tunnels: the proxy sees the host, not the path or
10
+ the content (paths and robots.txt are checked by the route gate in `gate.py`). Plain HTTP
11
+ requests are forwarded one per connection.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import contextlib
17
+ import select
18
+ import socket
19
+ import socketserver
20
+ import threading
21
+ from collections.abc import Callable
22
+ from urllib.parse import urlsplit
23
+
24
+ from ghost_api.netguard import NetworkPolicy, PolicyDenied
25
+
26
+ HEAD_LIMIT = 64 * 1024
27
+ CONNECT_TIMEOUT_S = 15
28
+ IDLE_TIMEOUT_S = 120
29
+
30
+
31
+ class EgressProxy:
32
+ def __init__(
33
+ self, policy: NetworkPolicy, on_block: Callable[[str], None] | None = None
34
+ ) -> None:
35
+ self.policy = policy
36
+ self.on_block = on_block
37
+ self.blocked: list[str] = []
38
+ self._lock = threading.Lock()
39
+ proxy = self
40
+
41
+ class Handler(socketserver.BaseRequestHandler):
42
+ def handle(self) -> None:
43
+ proxy._handle(self.request)
44
+
45
+ class Server(socketserver.ThreadingTCPServer):
46
+ daemon_threads = True
47
+ allow_reuse_address = True
48
+
49
+ self._server = Server(("127.0.0.1", 0), Handler)
50
+ self._thread: threading.Thread | None = None
51
+
52
+ @property
53
+ def url(self) -> str:
54
+ return f"http://127.0.0.1:{self._server.server_address[1]}"
55
+
56
+ def start(self) -> EgressProxy:
57
+ self._thread = threading.Thread(target=self._server.serve_forever, daemon=True)
58
+ self._thread.start()
59
+ return self
60
+
61
+ def stop(self) -> None:
62
+ self._server.shutdown()
63
+ self._server.server_close()
64
+
65
+ def __enter__(self) -> EgressProxy:
66
+ return self.start()
67
+
68
+ def __exit__(self, *exc: object) -> None:
69
+ self.stop()
70
+
71
+ # --- one client connection ---
72
+
73
+ def _handle(self, client: socket.socket) -> None:
74
+ client.settimeout(CONNECT_TIMEOUT_S)
75
+ try:
76
+ head = _read_head(client)
77
+ except OSError:
78
+ return
79
+ if not head:
80
+ return
81
+ line, _, rest = head.partition(b"\r\n")
82
+ try:
83
+ method, target, version = line.decode("latin-1").split(" ", 2)
84
+ except ValueError:
85
+ _reply(client, 400, "Bad Request")
86
+ return
87
+ if method.upper() == "CONNECT":
88
+ host, _, port_text = target.rpartition(":")
89
+ port = int(port_text) if port_text.isdigit() else 443
90
+ upstream = self._connect(client, host, port)
91
+ if upstream is None:
92
+ return
93
+ _reply(client, 200, "Connection Established")
94
+ _pipe(client, upstream)
95
+ return
96
+ parts = urlsplit(target)
97
+ if parts.scheme != "http" or not parts.hostname:
98
+ _reply(client, 400, "Bad Request")
99
+ return
100
+ upstream = self._connect(client, parts.hostname, parts.port or 80)
101
+ if upstream is None:
102
+ return
103
+ path = parts.path or "/"
104
+ if parts.query:
105
+ path += f"?{parts.query}"
106
+ headers, _, body = rest.partition(b"\r\n\r\n")
107
+ kept = [
108
+ h
109
+ for h in headers.split(b"\r\n")
110
+ if h and not h.lower().startswith((b"proxy-connection:", b"connection:"))
111
+ ]
112
+ request = f"{method} {path} {version}\r\n".encode("latin-1")
113
+ request += b"\r\n".join([*kept, b"Connection: close"]) + b"\r\n\r\n" + body
114
+ upstream.sendall(request)
115
+ _pipe(client, upstream)
116
+
117
+ def _connect(self, client: socket.socket, host: str, port: int) -> socket.socket | None:
118
+ try:
119
+ addresses = self.policy.resolve(host, port)
120
+ except PolicyDenied as exc:
121
+ self._block(str(exc))
122
+ _reply(client, 403, "Blocked by Ghost API")
123
+ return None
124
+ for address in addresses:
125
+ try:
126
+ return socket.create_connection((address, port), timeout=CONNECT_TIMEOUT_S)
127
+ except OSError:
128
+ continue
129
+ _reply(client, 502, "Bad Gateway")
130
+ return None
131
+
132
+ def _block(self, message: str) -> None:
133
+ with self._lock:
134
+ self.blocked.append(message)
135
+ if self.on_block is not None:
136
+ with contextlib.suppress(Exception):
137
+ self.on_block(message)
138
+
139
+
140
+ def _read_head(sock: socket.socket) -> bytes:
141
+ data = b""
142
+ while b"\r\n\r\n" not in data and len(data) < HEAD_LIMIT:
143
+ chunk = sock.recv(4096)
144
+ if not chunk:
145
+ break
146
+ data += chunk
147
+ return data
148
+
149
+
150
+ def _reply(client: socket.socket, status: int, reason: str) -> None:
151
+ """Answer the browser; any answer but "200 Connection Established" ends the connection."""
152
+ with contextlib.suppress(OSError):
153
+ if status == 200:
154
+ client.sendall(f"HTTP/1.1 200 {reason}\r\n\r\n".encode("latin-1"))
155
+ return
156
+ client.sendall(
157
+ f"HTTP/1.1 {status} {reason}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
158
+ .encode("latin-1")
159
+ ) # fmt: skip
160
+ client.close()
161
+
162
+
163
+ def _pipe(a: socket.socket, b: socket.socket) -> None:
164
+ """Copy bytes both ways until either side closes or the connection idles out."""
165
+ sockets = [a, b]
166
+ try:
167
+ for s in sockets:
168
+ s.settimeout(None)
169
+ while True:
170
+ readable, _, broken = select.select(sockets, [], sockets, IDLE_TIMEOUT_S)
171
+ if broken or not readable:
172
+ return
173
+ for source in readable:
174
+ data = source.recv(65536)
175
+ if not data:
176
+ return
177
+ (b if source is a else a).sendall(data)
178
+ except OSError:
179
+ return
180
+ finally:
181
+ for s in sockets:
182
+ with contextlib.suppress(OSError):
183
+ s.close()
@@ -0,0 +1,127 @@
1
+ """Rules for requests the browser makes, applied before they are sent.
2
+
3
+ - Page loads (navigations) anywhere must be allowed by that host's robots.txt.
4
+ - The site's own API calls (fetch / XHR to the site or its subdomains) must be allowed
5
+ by robots.txt and are spaced out per host by the run's rate limit, like Ghost API's
6
+ direct requests. The page's own images, scripts and styles load as in any browser.
7
+ - Third-party calls (analytics, maps, CDNs...) are not checked against robots.txt: the
8
+ page makes them, not Ghost API, and fetching every third party's robots.txt would add
9
+ traffic. They still go through the egress proxy (no private addresses).
10
+
11
+ Every decision to block is reported once per host and path through `on_block`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import time
17
+ from collections.abc import Callable
18
+ from typing import Protocol
19
+ from urllib.parse import urlsplit
20
+
21
+ from ghost_api.capture.filters import is_third_party
22
+ from ghost_api.net import RateLimiter, RobotsPolicy
23
+
24
+ API_TYPES = {"fetch", "xhr", "eventsource"}
25
+ MAX_WAIT_S = 10.0
26
+
27
+
28
+ class _Frame(Protocol):
29
+ @property
30
+ def parent_frame(self) -> object | None: ...
31
+
32
+
33
+ class _Request(Protocol):
34
+ @property
35
+ def url(self) -> str: ...
36
+
37
+ @property
38
+ def resource_type(self) -> str: ...
39
+
40
+ @property
41
+ def frame(self) -> _Frame: ...
42
+
43
+ def is_navigation_request(self) -> bool: ...
44
+
45
+
46
+ class _Route(Protocol):
47
+ @property
48
+ def request(self) -> _Request: ...
49
+
50
+ def continue_(self) -> None: ...
51
+
52
+ def abort(self, error_code: str | None = None) -> None: ...
53
+
54
+
55
+ class RequestGate:
56
+ def __init__(
57
+ self,
58
+ target_host: str,
59
+ robots: RobotsPolicy | None,
60
+ limiter: RateLimiter | None,
61
+ on_block: Callable[[str], None] | None = None,
62
+ clock: Callable[[], float] = time.monotonic,
63
+ sleep: Callable[[float], None] = time.sleep,
64
+ third_party_robots: bool = False,
65
+ ) -> None:
66
+ """`third_party_robots`: also check the page's API calls to other sites (search
67
+ services, maps...) against those sites' own robots.txt."""
68
+ self.third_party_robots = third_party_robots
69
+ self.target_host = target_host
70
+ self.robots = robots
71
+ self.limiter = limiter
72
+ self.on_block = on_block
73
+ self._clock = clock
74
+ self._sleep = sleep
75
+ self._last: dict[str, float] = {}
76
+ self._reported: set[str] = set()
77
+ self.blocked = 0
78
+ self.delayed = 0
79
+
80
+ def decide(self, url: str, resource_type: str, navigation: bool) -> str | None:
81
+ """Why the request must not be sent, or None (after any rate-limit wait)."""
82
+ parts = urlsplit(url)
83
+ if parts.scheme not in ("http", "https"):
84
+ return None # data:, blob: ... never reach the network
85
+ first_party = not is_third_party(url, self.target_host)
86
+ api_call = first_party and resource_type in API_TYPES
87
+ if (navigation or api_call) and self.robots is not None and not self.robots.allows(url):
88
+ return "disallowed by robots.txt"
89
+ third_party_api = not first_party and resource_type in API_TYPES
90
+ checked = third_party_api and self.third_party_robots and self.robots is not None
91
+ if checked and self.robots is not None and not self.robots.allows(url):
92
+ return "disallowed by that site's robots.txt"
93
+ if api_call or (navigation and first_party):
94
+ self._space_out((parts.hostname or "").lower())
95
+ return None
96
+
97
+ def handle(self, route: _Route) -> None:
98
+ request = route.request
99
+ try: # only the main page's own loads, not embedded frames (ads, videos...)
100
+ navigation = request.is_navigation_request() and request.frame.parent_frame is None
101
+ except Exception: # noqa: BLE001 - a service worker or detached frame
102
+ navigation = False
103
+ reason = self.decide(request.url, request.resource_type, navigation)
104
+ if reason is None:
105
+ route.continue_()
106
+ return
107
+ self.blocked += 1
108
+ self._report(request.url, reason)
109
+ route.abort("blockedbyclient")
110
+
111
+ def _space_out(self, host: str) -> None:
112
+ if self.limiter is None:
113
+ return
114
+ now = self._clock()
115
+ wait = min(self._last.get(host, float("-inf")) + self.limiter.interval - now, MAX_WAIT_S)
116
+ if wait > 0:
117
+ self.delayed += 1
118
+ self._sleep(wait)
119
+ self._last[host] = self._clock()
120
+
121
+ def _report(self, url: str, reason: str) -> None:
122
+ parts = urlsplit(url)
123
+ key = f"{parts.hostname}{parts.path}"
124
+ if key in self._reported or self.on_block is None:
125
+ return
126
+ self._reported.add(key)
127
+ self.on_block(f"Blocked {parts.hostname}{parts.path[:60]}: {reason}")
@@ -0,0 +1,28 @@
1
+ """Is the browser Ghost API drives installed, and install it if not."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+
10
+ def chromium_path() -> Path | None:
11
+ """Where Playwright's Chromium is, or None when it isn't installed."""
12
+ from playwright.sync_api import Error as PlaywrightError
13
+ from playwright.sync_api import sync_playwright
14
+
15
+ try:
16
+ with sync_playwright() as pw:
17
+ path = Path(pw.chromium.executable_path)
18
+ except PlaywrightError:
19
+ return None
20
+ return path if path.exists() else None
21
+
22
+
23
+ def install_chromium() -> bool:
24
+ """Download Chromium for Playwright (about 150 MB, once). True when it worked."""
25
+ proc = subprocess.run(
26
+ [sys.executable, "-m", "playwright", "install", "chromium"], check=False, timeout=1800
27
+ )
28
+ return proc.returncode == 0 and chromium_path() is not None