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,221 @@
1
+ """A compact, LLM-friendly view of the current page (FR-8, NFR-6).
2
+
3
+ JavaScript in the page lists visible interactive elements and tags each with a
4
+ `data-ghost-id` attribute so the agent can refer to it by number. Python then removes
5
+ elements the agent must never use (FR-10, FR-11, NFR-21) and caps the list size.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from typing import Any
13
+ from urllib.parse import urlsplit
14
+
15
+ from ghost_api.capture.filters import is_same_site
16
+ from ghost_api.net import RobotsPolicy
17
+
18
+ MAX_ELEMENTS = 60
19
+ MAX_LABEL = 60
20
+ MAX_TEXT = 400
21
+
22
+ # Labels or URLs of actions that change server state, spend money or end the session.
23
+ DESTRUCTIVE_RE = re.compile(
24
+ r"\b(delete|remove|destroy|erase|buy|purchase|checkout|check out|pay|payment|place order|"
25
+ r"order now|add to (cart|bag|basket)|subscribe|unsubscribe|log ?out|sign ?out|logout|"
26
+ r"signout|deactivate|close account|cancel (my )?(account|subscription|order)|donate|"
27
+ r"report|block|unfollow|follow|upvote|downvote|vote|flag|hide|reply|comment|send|share|"
28
+ r"invite|transfer|withdraw|confirm)\b",
29
+ re.I,
30
+ )
31
+ # Input types the agent must not fill in.
32
+ FORBIDDEN_INPUT_TYPES = {"password", "file", "hidden"}
33
+
34
+ SNAPSHOT_JS = r"""
35
+ ({ MAX_LABEL, MAX_TEXT }) => {
36
+ document.querySelectorAll('[data-ghost-id]').forEach(e => e.removeAttribute('data-ghost-id'));
37
+ const selector = [
38
+ 'a[href]', 'button', 'input:not([type=hidden])', 'select', 'textarea', 'summary',
39
+ '[role=button]', '[role=link]', '[role=tab]', '[role=menuitem]', '[role=option]',
40
+ '[role=checkbox]', '[role=switch]', '[role=searchbox]', '[role=combobox]', '[onclick]',
41
+ '[contenteditable=true]'
42
+ ].join(',');
43
+ const clean = s => (s || '').replace(/\s+/g, ' ').trim();
44
+ const visible = el => {
45
+ const r = el.getBoundingClientRect();
46
+ if (r.width < 2 || r.height < 2) return false;
47
+ const st = getComputedStyle(el);
48
+ return st.visibility !== 'hidden' && st.display !== 'none' && Number(st.opacity) > 0.05;
49
+ };
50
+ const labelOf = el => {
51
+ const parts = [
52
+ el.getAttribute('aria-label'), el.innerText, el.getAttribute('placeholder'),
53
+ el.getAttribute('title'), el.getAttribute('alt'), el.getAttribute('name'),
54
+ el.tagName === 'INPUT' && ['submit', 'button'].includes(el.type) ? el.value : '',
55
+ el.querySelector && el.querySelector('img[alt]') ? el.querySelector('img[alt]').alt : ''
56
+ ];
57
+ for (const p of parts) { const c = clean(p); if (c) return c.slice(0, MAX_LABEL); }
58
+ return '';
59
+ };
60
+ const out = [];
61
+ let n = 0;
62
+ for (const el of document.querySelectorAll(selector)) {
63
+ if (!visible(el) || el.disabled) continue;
64
+ // Open links in this tab so the recorder sees their traffic.
65
+ if (el.tagName === 'A' && el.target) el.target = '_self';
66
+ const r = el.getBoundingClientRect();
67
+ n += 1;
68
+ el.setAttribute('data-ghost-id', String(n));
69
+ out.push({
70
+ id: n,
71
+ tag: el.tagName.toLowerCase(),
72
+ role: el.getAttribute('role') || '',
73
+ type: (el.getAttribute('type') || '').toLowerCase(),
74
+ label: labelOf(el),
75
+ href: el.tagName === 'A' ? el.href : '',
76
+ download: el.hasAttribute('download'),
77
+ inViewport: r.bottom > 0 && r.top < window.innerHeight,
78
+ top: r.top + window.scrollY
79
+ });
80
+ }
81
+ const doc = document.documentElement;
82
+ const maxScroll = doc.scrollHeight - window.innerHeight;
83
+ const scroll = maxScroll <= 5 ? 'no-scroll'
84
+ : window.scrollY <= 5 ? 'top'
85
+ : window.scrollY >= maxScroll - 5 ? 'bottom' : 'middle';
86
+ const main = document.querySelector('main, [role=main], #content, article') || document.body;
87
+ return {
88
+ url: location.href,
89
+ title: document.title,
90
+ scroll,
91
+ text: clean(main ? main.innerText : '').slice(0, MAX_TEXT),
92
+ elements: out
93
+ };
94
+ }
95
+ """
96
+ SNAPSHOT_ARGS = {"MAX_LABEL": MAX_LABEL, "MAX_TEXT": MAX_TEXT}
97
+
98
+
99
+ @dataclass
100
+ class Element:
101
+ id: int
102
+ kind: str # link, button, input:text, select, ...
103
+ label: str
104
+ href: str = ""
105
+ in_viewport: bool = False
106
+
107
+ def describe(self) -> str:
108
+ text = (
109
+ f'[{self.id}] {self.kind} "{self.label}"' if self.label else f"[{self.id}] {self.kind}"
110
+ )
111
+ if self.href:
112
+ text += f" -> {_short_href(self.href)}"
113
+ return text
114
+
115
+
116
+ @dataclass
117
+ class PageSnapshot:
118
+ url: str
119
+ title: str
120
+ scroll: str
121
+ text: str
122
+ elements: list[Element] = field(default_factory=list)
123
+ blocked: int = 0 # elements removed for safety
124
+ has_password_field: bool = False # likely a login form
125
+ hidden: int = 0 # elements left out to keep the prompt small
126
+
127
+ def element(self, element_id: int) -> Element | None:
128
+ return next((e for e in self.elements if e.id == element_id), None)
129
+
130
+
131
+ def kind_of(raw: dict[str, Any]) -> str:
132
+ tag = str(raw.get("tag") or "")
133
+ role = str(raw.get("role") or "")
134
+ typ = str(raw.get("type") or "")
135
+ if role:
136
+ return role
137
+ if tag == "a":
138
+ return "link"
139
+ if tag == "input":
140
+ return f"input:{typ or 'text'}"
141
+ if tag in ("textarea", "select", "summary", "button"):
142
+ return tag
143
+ return "clickable"
144
+
145
+
146
+ def block_reason(
147
+ raw: dict[str, Any], target_host: str, robots: RobotsPolicy | None = None
148
+ ) -> str | None:
149
+ """Why the agent must not use this element, or None if it's allowed."""
150
+ label = str(raw.get("label") or "")
151
+ href = str(raw.get("href") or "")
152
+ kind = kind_of(raw)
153
+ if raw.get("download"):
154
+ return "download"
155
+ if kind.startswith("input:") and kind.split(":", 1)[1] in FORBIDDEN_INPUT_TYPES:
156
+ return "sensitive input"
157
+ if DESTRUCTIVE_RE.search(label) or (href and DESTRUCTIVE_RE.search(urlsplit(href).path)):
158
+ return "destructive"
159
+ if href:
160
+ scheme = urlsplit(href).scheme
161
+ if scheme in ("mailto", "tel", "sms", "data", "blob"):
162
+ return f"{scheme} link"
163
+ if scheme in ("http", "https"):
164
+ if not is_same_site(href, target_host):
165
+ return "off-site"
166
+ if robots is not None and not robots.allows(href):
167
+ return "robots.txt"
168
+ return None
169
+
170
+
171
+ def build_snapshot(
172
+ raw: dict[str, Any], target_host: str, robots: RobotsPolicy | None = None
173
+ ) -> PageSnapshot:
174
+ """Turn the page script's output into a safe, size-capped snapshot."""
175
+ kept: list[Element] = []
176
+ seen: set[tuple[str, str, str]] = set()
177
+ blocked = 0
178
+ password = False
179
+ for item in raw.get("elements", []):
180
+ password = password or kind_of(item) == "input:password"
181
+ if block_reason(item, target_host, robots):
182
+ blocked += 1
183
+ continue
184
+ el = Element(
185
+ id=int(item["id"]),
186
+ kind=kind_of(item),
187
+ label=str(item.get("label") or ""),
188
+ href=str(item.get("href") or ""),
189
+ in_viewport=bool(item.get("inViewport")),
190
+ )
191
+ key = (el.kind, el.label, el.href)
192
+ if key in seen: # e.g. twenty identical "Read more" links
193
+ continue
194
+ seen.add(key)
195
+ kept.append(el)
196
+ # Elements on screen first; the rest in page order.
197
+ kept.sort(key=lambda e: not e.in_viewport)
198
+ shown = kept[:MAX_ELEMENTS]
199
+ return PageSnapshot(
200
+ url=str(raw.get("url", "")),
201
+ title=str(raw.get("title", ""))[:120],
202
+ scroll=str(raw.get("scroll", "")),
203
+ text=str(raw.get("text", "")),
204
+ elements=shown,
205
+ blocked=blocked,
206
+ hidden=len(kept) - len(shown),
207
+ has_password_field=password,
208
+ )
209
+
210
+
211
+ def _short_href(href: str) -> str:
212
+ parts = urlsplit(href)
213
+ if parts.scheme == "javascript":
214
+ return "(script)"
215
+ path = parts.path or "/"
216
+ text = (
217
+ path
218
+ + (f"?{parts.query}" if parts.query else "")
219
+ + (f"#{parts.fragment}" if parts.fragment else "")
220
+ )
221
+ return text if len(text) <= 70 else text[:67] + "..."
File without changes
@@ -0,0 +1,255 @@
1
+ """Decide which captured requests are API traffic, and strip secrets out of them.
2
+
3
+ Everything here is plain, deterministic code. Redaction runs before anything is stored,
4
+ logged or sent to an LLM (FR-16, NFR-13).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import re
11
+ from typing import Any
12
+ from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
13
+
14
+ REDACTED = "[REDACTED]"
15
+
16
+ # Resource types Playwright reports for script-initiated requests.
17
+ API_RESOURCE_TYPES = {"xhr", "fetch"}
18
+
19
+ # Third-party analytics / tracking / monitoring hosts. Matched as a domain suffix.
20
+ NOISE_DOMAINS = (
21
+ "google-analytics.com",
22
+ "analytics.google.com",
23
+ "googletagmanager.com",
24
+ "doubleclick.net",
25
+ "googlesyndication.com",
26
+ "googleadservices.com",
27
+ "facebook.net",
28
+ "facebook.com",
29
+ "connect.facebook.net",
30
+ "segment.io",
31
+ "segment.com",
32
+ "mixpanel.com",
33
+ "amplitude.com",
34
+ "hotjar.com",
35
+ "hotjar.io",
36
+ "clarity.ms",
37
+ "fullstory.com",
38
+ "sentry.io",
39
+ "nr-data.net",
40
+ "newrelic.com",
41
+ "datadoghq.com",
42
+ "browser-intake-datadoghq.com",
43
+ "intercom.io",
44
+ "launchdarkly.com",
45
+ "optimizely.com",
46
+ "bing.com",
47
+ "linkedin.com",
48
+ "tiktok.com",
49
+ "twitter.com",
50
+ "ads-twitter.com",
51
+ )
52
+
53
+ # Third-party infrastructure that returns JSON but is never the site's data API:
54
+ # CDNs, captchas, consent banners, payment widgets, video players, push services.
55
+ INFRA_DOMAINS = (
56
+ "gstatic.com", "fonts.googleapis.com", "recaptcha.net", "hcaptcha.com",
57
+ "challenges.cloudflare.com",
58
+ "cloudflareinsights.com", "jsdelivr.net", "unpkg.com", "cdnjs.cloudflare.com",
59
+ "stripe.com", "stripe.network", "paypal.com", "braintreegateway.com", "youtube.com",
60
+ "ytimg.com", "vimeo.com", "onesignal.com", "pusher.com", "cookielaw.org", "onetrust.com",
61
+ "cookiebot.com", "usercentrics.eu", "trustarc.com", "quantcast.com", "gravatar.com",
62
+ "wp.com", "zendesk.com", "hubspot.com", "hs-scripts.com", "typekit.net", "fontawesome.com",
63
+ ) # fmt: skip
64
+
65
+ # Subdomains that carry tracking or monitoring, even on the target's own domain.
66
+ NOISE_SUBDOMAINS = {
67
+ "telemetry", "analytics", "metrics", "stats", "tracking", "track", "log", "logs", "rum",
68
+ "beacon", "pixel", "collector", "sentry",
69
+ } # fmt: skip
70
+
71
+ # Second-level public suffixes, so "shop.example.co.uk" maps to "example.co.uk".
72
+ _TWO_PART_SUFFIXES = {
73
+ "co.uk", "org.uk", "ac.uk", "gov.uk", "com.au", "net.au", "org.au", "co.in", "net.in",
74
+ "org.in", "co.jp", "ne.jp", "com.br", "com.cn", "com.mx", "co.nz", "co.za", "com.sg",
75
+ } # fmt: skip
76
+
77
+ _SENSITIVE_WORDS = {
78
+ "token", "secret", "password", "passwd", "pwd", "apikey", "csrf", "xsrf", "session",
79
+ "sessionid", "sessid", "sid", "auth", "authorization", "signature", "sig", "cookie",
80
+ "jwt", "otp", "bearer", "credential", "credentials",
81
+ } # fmt: skip
82
+ # Run-together names like PHPSESSID, csrftoken, authtoken.
83
+ _SENSITIVE_SUFFIXES = ("sessid", "sessionid", "token", "secret", "password", "apikey")
84
+ # "key" alone is often a legitimate ID field, so only these combinations count.
85
+ _SENSITIVE_KEY_PREFIXES = {"api", "access", "private", "secret", "client", "auth", "signing"}
86
+
87
+ _IP_RE = re.compile(r"\d{1,3}(\.\d{1,3}){3}")
88
+
89
+ # Beacon-style endpoints that sites host on their own domain.
90
+ _FIRST_PARTY_NOISE_RE = re.compile(r"/(collect|beacon|telemetry|pixel|__track|rum)(/|$)", re.I)
91
+ # Health checks and liveness probes (first- or third-party): never a data API.
92
+ _HEALTH_RE = re.compile(
93
+ r"/(isalive|is_alive|health|healthz|healthcheck|ping|heartbeat|livez|readyz)/?$", re.I
94
+ )
95
+
96
+ _JWT_RE = re.compile(r"eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{4,}")
97
+ _BEARER_RE = re.compile(r"(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]{8,}")
98
+ _CAMEL_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
99
+
100
+
101
+ def site_of(host: str) -> str:
102
+ """Approximate the registrable domain: 'api.shop.example.com' -> 'example.com'."""
103
+ host = host.lower().rstrip(".").split(":")[0]
104
+ if _IP_RE.fullmatch(host):
105
+ return host
106
+ parts = host.split(".")
107
+ if len(parts) <= 2:
108
+ return host
109
+ last_two = ".".join(parts[-2:])
110
+ if last_two in _TWO_PART_SUFFIXES:
111
+ return ".".join(parts[-3:])
112
+ return last_two
113
+
114
+
115
+ def is_same_site(url: str, target_host: str) -> bool:
116
+ """True if the URL is on the target's domain or one of its subdomains (FR-10)."""
117
+ host = (urlsplit(url).hostname or "").lower()
118
+ return bool(host) and site_of(host) == site_of(target_host)
119
+
120
+
121
+ def is_noise_domain(url: str) -> bool:
122
+ host = (urlsplit(url).hostname or "").lower()
123
+ if host.split(".", 1)[0] in NOISE_SUBDOMAINS:
124
+ return True
125
+ return any(host == d or host.endswith("." + d) for d in NOISE_DOMAINS)
126
+
127
+
128
+ def is_infra_domain(url: str) -> bool:
129
+ host = (urlsplit(url).hostname or "").lower()
130
+ return any(host == d or host.endswith("." + d) for d in INFRA_DOMAINS)
131
+
132
+
133
+ def is_third_party(url: str, target_host: str) -> bool:
134
+ return not is_same_site(url, target_host)
135
+
136
+
137
+ def is_json_content_type(content_type: str | None) -> bool:
138
+ return bool(content_type) and "json" in content_type.lower() # type: ignore[union-attr]
139
+
140
+
141
+ # Some sites put a guard before their JSON so another site's <script> tag can't read it
142
+ # (Google: ")]}'"; older sites: "while(1);" or "for(;;);"). Browsers' own code strips it.
143
+ XSSI_PREFIXES = (")]}'", "while(1);", "for(;;);")
144
+
145
+
146
+ def strip_xssi(text: str) -> str:
147
+ """Remove an anti-hijacking prefix from a JSON response, if there is one."""
148
+ stripped = text.lstrip()
149
+ for prefix in XSSI_PREFIXES:
150
+ if stripped.startswith(prefix):
151
+ return stripped[len(prefix) :].lstrip(",").lstrip()
152
+ return text
153
+
154
+
155
+ def parse_response_json(text: str) -> Any:
156
+ """Parse a JSON response body, allowing an anti-hijacking prefix. Raises ValueError."""
157
+ return json.loads(strip_xssi(text))
158
+
159
+
160
+ def classify(resource_type: str, url: str, target_host: str) -> str | None:
161
+ """Return None if this request should be kept, otherwise the reason it was dropped.
162
+
163
+ Requests to other domains are kept (as third-party APIs, e.g. a site's search running
164
+ on Algolia) unless they are analytics or known infrastructure.
165
+ """
166
+ if resource_type not in API_RESOURCE_TYPES:
167
+ return f"resource:{resource_type}"
168
+ if is_noise_domain(url):
169
+ return "analytics"
170
+ if is_third_party(url, target_host) and is_infra_domain(url):
171
+ return "infrastructure"
172
+ if _HEALTH_RE.search(urlsplit(url).path):
173
+ return "health-check"
174
+ if _FIRST_PARTY_NOISE_RE.search(urlsplit(url).path):
175
+ return "analytics"
176
+ return None
177
+
178
+
179
+ # --- Redaction -----------------------------------------------------------------------------
180
+
181
+
182
+ def _name_tokens(name: str) -> list[str]:
183
+ name = _CAMEL_RE.sub("_", name)
184
+ return [t for t in re.split(r"[^a-z0-9]+", name.lower()) if t]
185
+
186
+
187
+ def is_sensitive_name(name: str) -> bool:
188
+ """True for header / parameter / field names that usually hold secrets.
189
+
190
+ Splits on separators and camelCase so 'accessToken' and 'X-Auth-Token' match,
191
+ but 'author' and 'key' do not.
192
+ """
193
+ tokens = _name_tokens(name)
194
+ joined = "".join(tokens)
195
+ if joined in _SENSITIVE_WORDS:
196
+ return True
197
+ for i, tok in enumerate(tokens):
198
+ if tok in _SENSITIVE_WORDS or tok.endswith(_SENSITIVE_SUFFIXES):
199
+ return True
200
+ if tok == "key" and i > 0 and tokens[i - 1] in _SENSITIVE_KEY_PREFIXES:
201
+ return True
202
+ return False
203
+
204
+
205
+ def redact_text(text: str) -> str:
206
+ """Mask secret-looking values (JWTs, bearer/basic credentials) inside free text."""
207
+ text = _JWT_RE.sub(REDACTED, text)
208
+ return _BEARER_RE.sub(lambda m: f"{m.group(1)} {REDACTED}", text)
209
+
210
+
211
+ def redact_headers(headers: dict[str, str]) -> dict[str, str]:
212
+ return {k: (REDACTED if is_sensitive_name(k) else redact_text(v)) for k, v in headers.items()}
213
+
214
+
215
+ def redact_url(url: str) -> str:
216
+ parts = urlsplit(url)
217
+ if not parts.query:
218
+ return url
219
+ pairs = parse_qsl(parts.query, keep_blank_values=True)
220
+ safe = [(k, REDACTED if is_sensitive_name(k) else redact_text(v)) for k, v in pairs]
221
+ return urlunsplit(parts._replace(query=urlencode(safe, safe="[]{}")))
222
+
223
+
224
+ def redact_json(value: Any) -> Any:
225
+ if isinstance(value, dict):
226
+ return {
227
+ k: (
228
+ REDACTED
229
+ if is_sensitive_name(k) and not isinstance(v, dict | list)
230
+ else redact_json(v)
231
+ )
232
+ for k, v in value.items()
233
+ }
234
+ if isinstance(value, list):
235
+ return [redact_json(v) for v in value]
236
+ if isinstance(value, str):
237
+ return redact_text(value)
238
+ return value
239
+
240
+
241
+ def redact_body(body: str | None, content_type: str | None) -> str | None:
242
+ """Redact a request or response body, keeping its format."""
243
+ if not body:
244
+ return body
245
+ ctype = (content_type or "").lower()
246
+ stripped = body.lstrip()
247
+ if "json" in ctype or stripped[:1] in ("{", "["):
248
+ try:
249
+ return json.dumps(redact_json(json.loads(body)), ensure_ascii=False)
250
+ except ValueError:
251
+ pass
252
+ if "x-www-form-urlencoded" in ctype:
253
+ pairs = parse_qsl(body, keep_blank_values=True)
254
+ return urlencode([(k, REDACTED if is_sensitive_name(k) else v) for k, v in pairs])
255
+ return redact_text(body)
@@ -0,0 +1,148 @@
1
+ """Read a HAR file (the browser's "Save all as HAR") into captured requests.
2
+
3
+ For sites Ghost API can't explore by itself: behind a login, a bot check you passed by
4
+ hand, or a flow too long for the agent. You browse; your browser records; Ghost API maps
5
+ the API from the recording, with no browser and no LLM of its own.
6
+
7
+ Every entry goes through the same filters as live capture (`recorder.build_capture`):
8
+ only JSON API calls are kept, cookies, tokens and keys are redacted, and personal data is
9
+ hidden before anything is stored. The unredacted originals stay in memory only, for
10
+ testing endpoints with direct calls. A HAR file itself holds your cookies and tokens in
11
+ plain text: delete it when you are done.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import base64
17
+ import binascii
18
+ import json
19
+ from collections import Counter
20
+ from collections.abc import Iterator
21
+ from dataclasses import dataclass, field
22
+ from pathlib import Path
23
+ from typing import Any
24
+ from urllib.parse import urlsplit
25
+
26
+ from ghost_api.capture import filters
27
+ from ghost_api.capture.models import CapturedRequest, RawRequest
28
+ from ghost_api.capture.recorder import build_capture
29
+
30
+ MAX_HAR_BYTES = 500_000_000
31
+
32
+
33
+ class HarError(Exception):
34
+ """The file is not a HAR file Ghost API can read."""
35
+
36
+
37
+ @dataclass
38
+ class HarImport:
39
+ start_url: str
40
+ target_host: str
41
+ captured: list[tuple[CapturedRequest, RawRequest]] = field(default_factory=list)
42
+ dropped: Counter[str] = field(default_factory=Counter)
43
+ entries: int = 0
44
+
45
+
46
+ def read_har(path: Path, start_url: str | None = None) -> HarImport:
47
+ if path.stat().st_size > MAX_HAR_BYTES:
48
+ raise HarError(f"{path.name} is larger than {MAX_HAR_BYTES // 1_000_000} MB")
49
+ try:
50
+ data = json.loads(path.read_text(encoding="utf-8-sig"))
51
+ entries = data["log"]["entries"]
52
+ except (OSError, ValueError, KeyError, TypeError) as exc:
53
+ raise HarError(f"{path.name} is not a HAR file ({exc})") from exc
54
+ if not isinstance(entries, list):
55
+ raise HarError(f"{path.name} has no entries")
56
+ start = start_url or _start_url(entries)
57
+ if not start:
58
+ raise HarError(f"{path.name} has no http(s) requests")
59
+ result = HarImport(start, (urlsplit(start).hostname or "").lower(), entries=len(entries))
60
+ for entry in entries:
61
+ try:
62
+ item = _entry(entry, result.target_host)
63
+ except (KeyError, TypeError, ValueError):
64
+ result.dropped["unreadable"] += 1
65
+ continue
66
+ if isinstance(item, str):
67
+ result.dropped[item] += 1
68
+ else:
69
+ result.captured.append(item)
70
+ return result
71
+
72
+
73
+ def _start_url(entries: list[Any]) -> str:
74
+ """The first page the browser loaded, or the origin of the first request."""
75
+ for entry in entries:
76
+ url = str(entry.get("request", {}).get("url", ""))
77
+ if entry.get("_resourceType") == "document" and url.startswith("http"):
78
+ return url
79
+ for entry in entries:
80
+ url = str(entry.get("request", {}).get("url", ""))
81
+ if url.startswith("http"):
82
+ parts = urlsplit(url)
83
+ return f"{parts.scheme}://{parts.netloc}/"
84
+ return ""
85
+
86
+
87
+ def _headers(items: list[dict[str, Any]]) -> dict[str, str]:
88
+ # HTTP/2 pseudo-headers (":authority") are not real headers.
89
+ return {str(h["name"]).lower(): str(h["value"]) for h in items if not h["name"].startswith(":")}
90
+
91
+
92
+ def _resource_type(entry: dict[str, Any], resp_headers: dict[str, str]) -> str:
93
+ """Chrome and Edge write `_resourceType`; Firefox doesn't, so JSON replies count as fetch."""
94
+ kind = entry.get("_resourceType")
95
+ if isinstance(kind, str) and kind:
96
+ return kind.lower()
97
+ if filters.is_json_content_type(resp_headers.get("content-type")):
98
+ return "fetch"
99
+ return "other"
100
+
101
+
102
+ def _entry(entry: dict[str, Any], target_host: str) -> tuple[CapturedRequest, RawRequest] | str:
103
+ request, response = entry["request"], entry["response"]
104
+ url = str(request["url"])
105
+ if not url.startswith(("http://", "https://")):
106
+ return "not-http"
107
+ req_headers = _headers(request.get("headers", []))
108
+ resp_headers = _headers(response.get("headers", []))
109
+ kind = _resource_type(entry, resp_headers)
110
+ reason = filters.classify(kind, url, target_host)
111
+ if reason:
112
+ return reason
113
+ status = int(response.get("status") or 0)
114
+ if 300 <= status < 400:
115
+ return "redirect"
116
+ if status == 0:
117
+ return "no-reply" # blocked, cancelled or failed in the browser
118
+ content = response.get("content", {})
119
+ text = content.get("text")
120
+ if text is None:
121
+ return "no-body" # saved without content ("Save all as HAR (sanitized)")
122
+ body = _decode(str(text), content.get("encoding"))
123
+ if body is None:
124
+ return "binary"
125
+ post = request.get("postData", {}).get("text")
126
+ post_bytes = str(post).encode("utf-8") if post is not None else None
127
+ if "content-type" not in resp_headers and content.get("mimeType"):
128
+ resp_headers["content-type"] = str(content["mimeType"])
129
+ return build_capture(
130
+ str(request["method"]).upper(), url, kind, status, req_headers, post_bytes,
131
+ resp_headers, body, target_host,
132
+ ) # fmt: skip
133
+
134
+
135
+ def _decode(text: str, encoding: Any) -> bytes | None:
136
+ if encoding == "base64":
137
+ try:
138
+ return base64.b64decode(text, validate=True)
139
+ except (binascii.Error, ValueError):
140
+ return None
141
+ return text.encode("utf-8")
142
+
143
+
144
+ def summary(result: HarImport) -> Iterator[str]:
145
+ yield f"{result.entries} entries, {len(result.captured)} API calls kept"
146
+ if result.dropped:
147
+ top = ", ".join(f"{k}: {v}" for k, v in result.dropped.most_common(6))
148
+ yield f"left out: {top}"
@@ -0,0 +1,41 @@
1
+ """Data captured from the browser's network traffic."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import UTC, datetime
7
+ from typing import Any
8
+
9
+
10
+ @dataclass
11
+ class CapturedRequest:
12
+ """One API request/response pair. All fields are already redacted."""
13
+
14
+ method: str
15
+ url: str
16
+ status: int
17
+ resource_type: str
18
+ req_headers: dict[str, str]
19
+ req_body: str | None
20
+ req_content_type: str | None
21
+ resp_content_type: str | None
22
+ resp_body: str | None
23
+ truncated: bool = False
24
+ third_party: bool = False # served from a different domain than the target site
25
+ timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
26
+ # Personal data hidden from resp_body before storing (capture/personal.py):
27
+ # [{"path", "kind", "severity", "example" (masked), "count"}].
28
+ personal: list[dict[str, Any]] = field(default_factory=list)
29
+
30
+
31
+ @dataclass
32
+ class RawRequest:
33
+ """The original, UNREDACTED request, kept in memory only so endpoints can be replayed.
34
+
35
+ Never stored, logged or sent to an LLM (NFR-13). repr() hides headers and body.
36
+ """
37
+
38
+ method: str
39
+ url: str = field(repr=False)
40
+ headers: dict[str, str] = field(repr=False)
41
+ body: str | None = field(default=None, repr=False)