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
ghost_api/watch.py ADDED
@@ -0,0 +1,313 @@
1
+ """Watch & alerts: re-check an endpoint on a schedule and report what's new, changed or gone.
2
+
3
+ A watch is (site, operation, inputs). Each check fetches the pages through `SiteRuntime`,
4
+ keys every record by its ID-like field (or a content hash), compares it with the last
5
+ snapshot, stores the changes, and sends notifications. The first check only records a
6
+ baseline. Personal data stays hidden, as everywhere else.
7
+
8
+ Notification targets:
9
+ ntfy:TOPIC push to your phone with the free ntfy app (https://ntfy.sh). Topics are
10
+ public by name, so pick a long, unguessable one.
11
+ webhook:URL POST the changes as JSON (Discord, Slack, Zapier, your own server...).
12
+ desktop a desktop notification (needs `pip install plyer`).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import re
19
+ import time
20
+ from collections.abc import Callable
21
+ from dataclasses import asdict, dataclass, field
22
+ from datetime import UTC, datetime, timedelta
23
+ from typing import Any
24
+
25
+ import httpx
26
+
27
+ from ghost_api.analysis.detectors import item_fingerprints
28
+ from ghost_api.memory.store import Store
29
+ from ghost_api.runtime import OperationError, SiteRuntime, flatten
30
+
31
+ # Fields that change on every visit and would make everything look "changed".
32
+ _VOLATILE_RE = re.compile(
33
+ r"(^|[._])(updated|modified|fetched|crawled|last_seen|timestamp|time|ts|now|distance|"
34
+ r"rank|ranking|position|score|views?|view_count|seconds_ago|ago)($|[._])|"
35
+ r"(_at|At|_time|Time)$|_ranking|highlight",
36
+ re.I,
37
+ )
38
+ _TITLE_FIELDS = ("name", "title", "label", "headline", "venue.name", "text", "slug", "id")
39
+ MAX_CHANGES_LISTED = 8
40
+
41
+
42
+ @dataclass
43
+ class Change:
44
+ kind: str # "new", "changed" or "gone"
45
+ key: str
46
+ title: str
47
+ fields: dict[str, list[Any]] = field(default_factory=dict) # name -> [old, new]
48
+
49
+ def line(self) -> str:
50
+ if self.kind == "new":
51
+ return f"+ {self.title}"
52
+ if self.kind == "gone":
53
+ return f"- {self.title}"
54
+ diffs = ", ".join(f"{k}: {_short(o)} -> {_short(n)}" for k, (o, n) in self.fields.items())
55
+ return f"~ {self.title}: {diffs}"
56
+
57
+
58
+ @dataclass
59
+ class CheckResult:
60
+ watch: dict[str, Any]
61
+ rows: int = 0
62
+ baseline: bool = False
63
+ complete: bool = True # every page was read, so "gone" can be trusted
64
+ changes: list[Change] = field(default_factory=list)
65
+ error: str = ""
66
+ notified: list[str] = field(default_factory=list)
67
+
68
+ @property
69
+ def counts(self) -> dict[str, int]:
70
+ out = {"new": 0, "changed": 0, "gone": 0}
71
+ for c in self.changes:
72
+ out[c.kind] += 1
73
+ return out
74
+
75
+ def headline(self) -> str:
76
+ w = self.watch
77
+ where = f"{w['site']} · {w['operation']}"
78
+ if self.error:
79
+ return f"{where}: check failed ({self.error})"
80
+ if self.baseline:
81
+ return f"{where}: watching {self.rows} items (first check, nothing to compare yet)"
82
+ if not self.changes:
83
+ return f"{where}: no changes in {self.rows} items"
84
+ parts = [f"{n} {k}" for k, n in self.counts.items() if n]
85
+ return f"{where}: {', '.join(parts)}"
86
+
87
+ def text(self) -> str:
88
+ lines = [self.headline()]
89
+ lines += [c.line() for c in self.changes[:MAX_CHANGES_LISTED]]
90
+ if len(self.changes) > MAX_CHANGES_LISTED:
91
+ lines.append(f"... and {len(self.changes) - MAX_CHANGES_LISTED} more")
92
+ return "\n".join(lines)
93
+
94
+
95
+ # --- schedule -------------------------------------------------------------------------------
96
+
97
+
98
+ def parse_every(text: str) -> int:
99
+ """'30m' -> 30, '6h' -> 360, '1d' -> 1440, '90' -> 90 (minutes)."""
100
+ match = re.fullmatch(r"\s*(\d+)\s*([mhd]?)\s*", text.lower())
101
+ if not match:
102
+ raise ValueError(f"Not a duration: {text!r} (use e.g. 30m, 6h, 1d)")
103
+ minutes = int(match.group(1)) * {"": 1, "m": 1, "h": 60, "d": 1440}[match.group(2)]
104
+ if minutes < 5:
105
+ raise ValueError("Check at most every 5 minutes, to be gentle with the site")
106
+ return minutes
107
+
108
+
109
+ def is_due(watch: dict[str, Any], now: datetime | None = None) -> bool:
110
+ if not watch.get("last_check_at"):
111
+ return True
112
+ last = datetime.fromisoformat(watch["last_check_at"])
113
+ return (now or datetime.now(UTC)) >= last + timedelta(minutes=watch["every_minutes"])
114
+
115
+
116
+ def every_label(minutes: int) -> str:
117
+ if minutes % 1440 == 0:
118
+ return f"{minutes // 1440}d"
119
+ if minutes % 60 == 0:
120
+ return f"{minutes // 60}h"
121
+ return f"{minutes}m"
122
+
123
+
124
+ # --- one check ------------------------------------------------------------------------------
125
+
126
+
127
+ def check(
128
+ store: Store,
129
+ watch: dict[str, Any],
130
+ runtime: SiteRuntime | None = None,
131
+ notify: bool = True,
132
+ sender: Callable[[str, CheckResult], str] | None = None,
133
+ ) -> CheckResult:
134
+ """Fetch, compare with the last snapshot, store, notify. Never raises for site errors."""
135
+ result = CheckResult(watch)
136
+ owned = runtime is None
137
+ try:
138
+ rt = runtime or SiteRuntime(store, watch["site"])
139
+ try:
140
+ op = rt.operation(watch["operation"])
141
+ records: list[Any] = []
142
+ pages = 0
143
+ for page in rt.iterate(op, watch["params"], watch["max_pages"], 5000):
144
+ pages += 1
145
+ if not page.result.ok:
146
+ if not records:
147
+ result.error = page.result.error or f"HTTP {page.result.status}"
148
+ result.complete = False
149
+ break
150
+ records += page.records
151
+ if pages >= watch["max_pages"]:
152
+ result.complete = False # there may be more pages we didn't read
153
+ finally:
154
+ if owned:
155
+ rt.close()
156
+ except OperationError as exc:
157
+ result.error = str(exc)
158
+ except Exception as exc: # noqa: BLE001 - a watch must keep going on network errors
159
+ result.error = f"{type(exc).__name__}: {exc}"
160
+ if result.error:
161
+ store.mark_watch_checked(watch["id"], f"error: {result.error}")
162
+ return result
163
+
164
+ current = {
165
+ key: flatten(record)
166
+ for key, record in zip(item_fingerprints(records), records, strict=False)
167
+ }
168
+ result.rows = len(current)
169
+ previous = store.watch_items(watch["id"])
170
+ result.baseline = not previous
171
+ track = set(watch.get("track") or [])
172
+ if not result.baseline:
173
+ result.changes = diff(previous, current, track, result.complete)
174
+ gone = [c.key for c in result.changes if c.kind == "gone"]
175
+ status = "baseline" if result.baseline else f"{len(result.changes)} changes"
176
+ store.save_watch_check(watch["id"], current, gone, [asdict(c) for c in result.changes], status)
177
+ if notify and result.changes:
178
+ for target in watch.get("notify") or []:
179
+ outcome = (sender or send)(target, result)
180
+ result.notified.append(outcome)
181
+ return result
182
+
183
+
184
+ def diff(
185
+ previous: dict[str, dict[str, Any]],
186
+ current: dict[str, dict[str, Any]],
187
+ track: set[str],
188
+ complete: bool,
189
+ ) -> list[Change]:
190
+ changes = []
191
+ for key, row in current.items():
192
+ old = previous.get(key)
193
+ if old is None or old["gone"]:
194
+ changes.append(Change("new", key, title_of(row)))
195
+ continue
196
+ fields = {
197
+ name: [old["data"].get(name), row.get(name)]
198
+ for name in sorted(set(row) | set(old["data"]))
199
+ if _tracked(name, track) and _differs(old["data"].get(name), row.get(name))
200
+ }
201
+ if fields:
202
+ changes.append(Change("changed", key, title_of(row), fields))
203
+ if complete: # only then is "not seen" the same as "gone"
204
+ for key, old in previous.items():
205
+ if key not in current and not old["gone"]:
206
+ changes.append(Change("gone", key, title_of(old["data"])))
207
+ order = {"new": 0, "changed": 1, "gone": 2}
208
+ return sorted(changes, key=lambda c: (order[c.kind], c.title))
209
+
210
+
211
+ def title_of(row: dict[str, Any]) -> str:
212
+ for name in _TITLE_FIELDS:
213
+ value = row.get(name)
214
+ if value not in (None, ""):
215
+ return _short(value, 60)
216
+ return _short(next(iter(row.values()), "item"), 60)
217
+
218
+
219
+ def _tracked(name: str, track: set[str]) -> bool:
220
+ if track:
221
+ return name in track or any(name.endswith("." + t) for t in track)
222
+ return not _VOLATILE_RE.search(name) and not name.startswith("_")
223
+
224
+
225
+ def _differs(old: Any, new: Any) -> bool:
226
+ return str(old) != str(new)
227
+
228
+
229
+ def _short(value: Any, limit: int = 40) -> str:
230
+ text = str(value)
231
+ return text if len(text) <= limit else text[: limit - 1] + "…"
232
+
233
+
234
+ # --- notifications --------------------------------------------------------------------------
235
+
236
+
237
+ def send(target: str, result: CheckResult) -> str:
238
+ """Deliver one notification; returns what happened (never raises)."""
239
+ kind, _, where = target.partition(":")
240
+ try:
241
+ if kind == "ntfy" and where:
242
+ base = os.getenv("GHOST_API_NTFY_URL", "https://ntfy.sh").rstrip("/")
243
+ httpx.post(
244
+ f"{base}/{where}",
245
+ content=result.text().encode("utf-8"),
246
+ headers={"Title": "Ghost API: changes found", "Tags": "ghost"},
247
+ timeout=15,
248
+ ).raise_for_status()
249
+ elif kind == "webhook" and where:
250
+ payload = {
251
+ "text": result.text(), # Slack / generic
252
+ "content": result.text()[:1900], # Discord
253
+ "site": result.watch["site"],
254
+ "operation": result.watch["operation"],
255
+ "counts": result.counts,
256
+ "changes": [asdict(c) for c in result.changes],
257
+ }
258
+ httpx.post(where, json=payload, timeout=15).raise_for_status()
259
+ elif kind == "desktop":
260
+ return _desktop(result)
261
+ else:
262
+ return f"{target}: unknown target (use ntfy:TOPIC, webhook:URL or desktop)"
263
+ except httpx.HTTPError as exc:
264
+ return f"{kind}: failed ({type(exc).__name__})"
265
+ return f"{kind}: sent"
266
+
267
+
268
+ def _desktop(result: CheckResult) -> str:
269
+ try:
270
+ from plyer import (
271
+ notification, # type: ignore[import-not-found,import-untyped,unused-ignore]
272
+ )
273
+ except ImportError:
274
+ return "desktop: install it with `pip install plyer`"
275
+ try:
276
+ notification.notify(title="Ghost API", message=result.text()[:250], timeout=10)
277
+ except Exception as exc: # noqa: BLE001 - notification backends vary by system
278
+ return f"desktop: failed ({type(exc).__name__})"
279
+ return "desktop: sent"
280
+
281
+
282
+ # --- running many ---------------------------------------------------------------------------
283
+
284
+
285
+ def run_due(store: Store, only_due: bool = True) -> list[CheckResult]:
286
+ return [check(store, w) for w in store.watches() if not only_due or is_due(w)]
287
+
288
+
289
+ def loop(store: Store, on_result: Callable[[CheckResult], None], tick_s: float = 60) -> None:
290
+ """Check due watches forever (until Ctrl+C)."""
291
+ while True:
292
+ for result in run_due(store):
293
+ on_result(result)
294
+ time.sleep(tick_s)
295
+
296
+
297
+ def describe(watch: dict[str, Any]) -> str:
298
+ inputs = " ".join(f"{k}={v}" for k, v in watch["params"].items())
299
+ return f"{watch['site']} {watch['operation']} {inputs}".strip()
300
+
301
+
302
+ def to_json(result: CheckResult) -> dict[str, Any]:
303
+ return {
304
+ "watch_id": result.watch["id"],
305
+ "headline": result.headline(),
306
+ "rows": result.rows,
307
+ "baseline": result.baseline,
308
+ "complete": result.complete,
309
+ "error": result.error,
310
+ "counts": result.counts,
311
+ "changes": [asdict(c) for c in result.changes],
312
+ "notified": result.notified,
313
+ }
@@ -0,0 +1 @@
1
+ """Local web dashboard (Phase 3)."""