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.
- ghost_api/__init__.py +3 -0
- ghost_api/__main__.py +5 -0
- ghost_api/adopt.py +173 -0
- ghost_api/agent/__init__.py +0 -0
- ghost_api/agent/orchestrator.py +413 -0
- ghost_api/agent/prompts.py +149 -0
- ghost_api/agent/tools.py +84 -0
- ghost_api/analysis/__init__.py +0 -0
- ghost_api/analysis/analyzer.py +130 -0
- ghost_api/analysis/detectors.py +188 -0
- ghost_api/analysis/exposure.py +80 -0
- ghost_api/analysis/graphql.py +67 -0
- ghost_api/analysis/models.py +64 -0
- ghost_api/analysis/schema.py +109 -0
- ghost_api/analysis/url_grouping.py +217 -0
- ghost_api/api.py +264 -0
- ghost_api/audit.py +78 -0
- ghost_api/browser/__init__.py +0 -0
- ghost_api/browser/auth_state.py +172 -0
- ghost_api/browser/driver.py +273 -0
- ghost_api/browser/egress.py +183 -0
- ghost_api/browser/gate.py +127 -0
- ghost_api/browser/install.py +28 -0
- ghost_api/browser/snapshot.py +221 -0
- ghost_api/capture/__init__.py +0 -0
- ghost_api/capture/filters.py +255 -0
- ghost_api/capture/har.py +148 -0
- ghost_api/capture/models.py +41 -0
- ghost_api/capture/personal.py +188 -0
- ghost_api/capture/recorder.py +181 -0
- ghost_api/cli/__init__.py +44 -0
- ghost_api/cli/_common.py +219 -0
- ghost_api/cli/client.py +487 -0
- ghost_api/cli/data.py +492 -0
- ghost_api/cli/explore.py +379 -0
- ghost_api/cli/manage.py +437 -0
- ghost_api/config.py +132 -0
- ghost_api/drift.py +344 -0
- ghost_api/drive.py +211 -0
- ghost_api/errors.py +106 -0
- ghost_api/events.py +213 -0
- ghost_api/exitcodes.py +21 -0
- ghost_api/export/__init__.py +71 -0
- ghost_api/export/confidence.py +45 -0
- ghost_api/export/html.py +41 -0
- ghost_api/export/openapi.py +201 -0
- ghost_api/export/report.py +227 -0
- ghost_api/export/site_data.py +196 -0
- ghost_api/export/tabular.py +88 -0
- ghost_api/export/viewer/report.html +286 -0
- ghost_api/generator/__init__.py +221 -0
- ghost_api/generator/cassettes.py +80 -0
- ghost_api/generator/models_gen.py +144 -0
- ghost_api/generator/naming.py +174 -0
- ghost_api/generator/package.py +197 -0
- ghost_api/generator/python_gen.py +172 -0
- ghost_api/generator/spec.py +486 -0
- ghost_api/generator/templates/client.py.j2 +518 -0
- ghost_api/generator/templates/client.test.ts.j2 +106 -0
- ghost_api/generator/templates/client.ts.j2 +339 -0
- ghost_api/generator/templates/models.py.j2 +45 -0
- ghost_api/generator/templates/package_readme.md.j2 +111 -0
- ghost_api/generator/templates/pyproject.toml.j2 +22 -0
- ghost_api/generator/templates/test_client.py.j2 +105 -0
- ghost_api/generator/ts_gen.py +232 -0
- ghost_api/integrations/__init__.py +0 -0
- ghost_api/integrations/claude_code/commands/ghost-data.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-diff.md +10 -0
- ghost_api/integrations/claude_code/commands/ghost-explore.md +10 -0
- ghost_api/integrations/claude_code/skills/ghost-api/SKILL.md +46 -0
- ghost_api/integrations/setup.py +84 -0
- ghost_api/llm/__init__.py +0 -0
- ghost_api/llm/doctor.py +60 -0
- ghost_api/llm/gateway.py +476 -0
- ghost_api/llm/providers.py +124 -0
- ghost_api/memory/__init__.py +0 -0
- ghost_api/memory/knowledge.py +157 -0
- ghost_api/memory/store.py +883 -0
- ghost_api/net.py +235 -0
- ghost_api/netguard.py +203 -0
- ghost_api/pipeline.py +745 -0
- ghost_api/probe/__init__.py +0 -0
- ghost_api/probe/prober.py +311 -0
- ghost_api/probe/replay.py +313 -0
- ghost_api/project.py +186 -0
- ghost_api/py.typed +0 -0
- ghost_api/runtime.py +397 -0
- ghost_api/serve/__init__.py +101 -0
- ghost_api/serve/explorer_mcp.py +305 -0
- ghost_api/serve/mcp.py +121 -0
- ghost_api/serve/mock.py +130 -0
- ghost_api/serve/rest.py +132 -0
- ghost_api/site.py +84 -0
- ghost_api/specdiff.py +122 -0
- ghost_api/ui/__init__.py +0 -0
- ghost_api/ui/keys.py +134 -0
- ghost_api/ui/repl.py +453 -0
- ghost_api/ui/terminal.py +573 -0
- ghost_api/verify/__init__.py +0 -0
- ghost_api/verify/classify.py +84 -0
- ghost_api/verify/repair.py +40 -0
- ghost_api/verify/runner.py +110 -0
- ghost_api/verify/verifier.py +291 -0
- ghost_api/watch.py +313 -0
- ghost_api/web/__init__.py +1 -0
- ghost_api/web/server.py +690 -0
- ghost_api/web/static/assets/Endpoints-zPnjlcM8.js +46 -0
- ghost_api/web/static/assets/Run-D85zWHey.js +91 -0
- ghost_api/web/static/assets/Settings-D0aWHFAt.js +11 -0
- ghost_api/web/static/assets/Site-a88shEmP.js +71 -0
- ghost_api/web/static/assets/Watches-CqZWIa9G.js +16 -0
- ghost_api/web/static/assets/circle-x-BcTE_1tr.js +11 -0
- ghost_api/web/static/assets/index-Ds2e5B9f.js +141 -0
- ghost_api/web/static/assets/index-xQpyIBbG.css +1 -0
- ghost_api/web/static/assets/minus-C2xqP1eQ.js +6 -0
- ghost_api/web/static/assets/refresh-cw-CxdR_8Oi.js +6 -0
- ghost_api/web/static/favicon.svg +1 -0
- ghost_api/web/static/index.html +26 -0
- ghost_api/workflows.py +203 -0
- ghost_api_cli-0.2.0.dist-info/METADATA +1019 -0
- ghost_api_cli-0.2.0.dist-info/RECORD +125 -0
- ghost_api_cli-0.2.0.dist-info/WHEEL +5 -0
- ghost_api_cli-0.2.0.dist-info/entry_points.txt +2 -0
- ghost_api_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
- 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
|