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
ghost_api/pipeline.py
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
"""The Ghost API workflow, with no user interface.
|
|
2
|
+
|
|
3
|
+
Everything here emits events (`ghost_api/events.py`) instead of printing, so the terminal,
|
|
4
|
+
the web dashboard and the database all see the same run. Every event is also saved in
|
|
5
|
+
the `run_events` table, so any run can be replayed later.
|
|
6
|
+
|
|
7
|
+
Workflow: robots.txt check -> explore (agent or plain visit) -> test endpoints directly ->
|
|
8
|
+
generate the client -> verify it against the live API (repairing failures) -> remember
|
|
9
|
+
lessons -> write report.md and openapi.yaml.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import time
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
from urllib.parse import urlsplit
|
|
21
|
+
|
|
22
|
+
from ghost_api import drift
|
|
23
|
+
from ghost_api.agent.orchestrator import AgentResult, Explorer, StepRecord
|
|
24
|
+
from ghost_api.analysis.analyzer import analyze, endpoint_keys
|
|
25
|
+
from ghost_api.analysis.detectors import is_bot_challenge
|
|
26
|
+
from ghost_api.analysis.exposure import exposures, summary_line
|
|
27
|
+
from ghost_api.analysis.models import Endpoint
|
|
28
|
+
from ghost_api.audit import AuditLog
|
|
29
|
+
from ghost_api.browser import auth_state
|
|
30
|
+
from ghost_api.browser.driver import BrowserSession
|
|
31
|
+
from ghost_api.browser.gate import RequestGate
|
|
32
|
+
from ghost_api.capture.models import CapturedRequest
|
|
33
|
+
from ghost_api.capture.recorder import TrafficRecorder
|
|
34
|
+
from ghost_api.errors import error_code, friendly_error
|
|
35
|
+
from ghost_api.events import (
|
|
36
|
+
AgentStep,
|
|
37
|
+
ClientGenerated,
|
|
38
|
+
EndpointsUpdated,
|
|
39
|
+
Event,
|
|
40
|
+
EventBus,
|
|
41
|
+
HypothesisTested,
|
|
42
|
+
NetworkSummary,
|
|
43
|
+
Notice,
|
|
44
|
+
Phase,
|
|
45
|
+
PhaseChanged,
|
|
46
|
+
ProbeFinished,
|
|
47
|
+
RequestCaptured,
|
|
48
|
+
RunFinished,
|
|
49
|
+
RunStarted,
|
|
50
|
+
Screenshot,
|
|
51
|
+
UsageUpdated,
|
|
52
|
+
VerifyRound,
|
|
53
|
+
)
|
|
54
|
+
from ghost_api.export import write_exports
|
|
55
|
+
from ghost_api.export.site_data import display_label, endpoint_summary
|
|
56
|
+
from ghost_api.generator import GenerationResult, NothingToGenerate, generate_from, plan_for
|
|
57
|
+
from ghost_api.generator.python_gen import GenerationError
|
|
58
|
+
from ghost_api.generator.ts_gen import camel, ts_type
|
|
59
|
+
from ghost_api.llm.gateway import LLMGateway
|
|
60
|
+
from ghost_api.memory.knowledge import (
|
|
61
|
+
DomainMemory,
|
|
62
|
+
discovery_lessons,
|
|
63
|
+
rebuild_raw,
|
|
64
|
+
write_lessons,
|
|
65
|
+
)
|
|
66
|
+
from ghost_api.memory.store import Store
|
|
67
|
+
from ghost_api.net import DenyPaths, MultiHostRobotsPolicy, RateLimiter, RobotsPolicy, fetch_robots
|
|
68
|
+
from ghost_api.netguard import NetworkPolicy
|
|
69
|
+
from ghost_api.probe.prober import Prober, ProbeReport
|
|
70
|
+
from ghost_api.probe.replay import Replayer, is_replayable
|
|
71
|
+
from ghost_api.project import site_policy
|
|
72
|
+
from ghost_api.site import site_key
|
|
73
|
+
from ghost_api.verify.verifier import Round, Verifier
|
|
74
|
+
|
|
75
|
+
# With no agent, the page is just loaded and scrolled to the bottom this many times.
|
|
76
|
+
AUTO_SCROLLS = 5
|
|
77
|
+
# Agent steps allowed when verification sends the agent back to the site.
|
|
78
|
+
REPLAN_STEPS = 6
|
|
79
|
+
# Only runs with at least this many agent steps are worth an LLM call for lessons.
|
|
80
|
+
MIN_STEPS_FOR_LESSONS = 2
|
|
81
|
+
# Runs that ended this way are not probed or turned into a client.
|
|
82
|
+
STOPPED = ("interrupted", "bot-protection", "failed")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class RunOptions:
|
|
87
|
+
lang: str = "python"
|
|
88
|
+
headed: bool = False
|
|
89
|
+
max_steps: int = 40
|
|
90
|
+
max_minutes: float = 15.0
|
|
91
|
+
max_probes: int = 60
|
|
92
|
+
no_probe: bool = False
|
|
93
|
+
no_generate: bool = False
|
|
94
|
+
no_verify: bool = False
|
|
95
|
+
out: Path = Path("output")
|
|
96
|
+
third_party_robots: bool = False # also check other sites' robots.txt (browser calls)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def screenshots_dir(db_path: Path, run_id: int) -> Path:
|
|
100
|
+
"""Where a run's browser screenshots go: next to the database, never in the output."""
|
|
101
|
+
return db_path.parent / f"{db_path.stem}_files" / "runs" / str(run_id)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# --- stage 0: robots.txt ---------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def robots_policy(
|
|
108
|
+
url: str, ignore: bool, limiter: RateLimiter, bus: EventBus
|
|
109
|
+
) -> tuple[RobotsPolicy, bool]:
|
|
110
|
+
"""The site's robots.txt rules, and whether the start page may be visited.
|
|
111
|
+
|
|
112
|
+
Returns a ``MultiHostRobotsPolicy`` that lazily fetches robots.txt for any
|
|
113
|
+
new subdomain the agent navigates to (Issue 3).
|
|
114
|
+
"""
|
|
115
|
+
host = urlsplit(url).hostname or ""
|
|
116
|
+
policy, _ = site_policy(site_key(url) or host)
|
|
117
|
+
robots: RobotsPolicy
|
|
118
|
+
robots, allowed = _robots_rules(url, host, ignore, limiter, bus)
|
|
119
|
+
if policy.deny_paths:
|
|
120
|
+
bus.emit(Notice(f"Project rules: {len(policy.deny_paths)} path(s) are never requested "
|
|
121
|
+
f"({', '.join(policy.deny_paths[:4])})")) # fmt: skip
|
|
122
|
+
robots = DenyPaths(robots, policy.deny_paths)
|
|
123
|
+
if not robots.allows(url):
|
|
124
|
+
bus.emit(Notice(f"Stopped: {url} is denied by the project rules", "error"))
|
|
125
|
+
return robots, False
|
|
126
|
+
return robots, allowed
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _robots_rules(
|
|
130
|
+
url: str, host: str, ignore: bool, limiter: RateLimiter, bus: EventBus
|
|
131
|
+
) -> tuple[MultiHostRobotsPolicy, bool]:
|
|
132
|
+
if ignore:
|
|
133
|
+
bus.emit(Notice("robots.txt check skipped (--ignore-robots)", "warning"))
|
|
134
|
+
return (
|
|
135
|
+
MultiHostRobotsPolicy(
|
|
136
|
+
RobotsPolicy.allow_all(host),
|
|
137
|
+
limiter,
|
|
138
|
+
lambda u, _l: RobotsPolicy.allow_all(urlsplit(u).hostname or ""),
|
|
139
|
+
),
|
|
140
|
+
True,
|
|
141
|
+
)
|
|
142
|
+
robots = fetch_robots(url, limiter)
|
|
143
|
+
verdict = robots.verdict(url)
|
|
144
|
+
if not verdict.allowed:
|
|
145
|
+
bus.emit(
|
|
146
|
+
Notice(
|
|
147
|
+
f"Stopped: {verdict.reason} for {url}",
|
|
148
|
+
"error",
|
|
149
|
+
"Use --ignore-robots only if you have permission to access this site.",
|
|
150
|
+
)
|
|
151
|
+
)
|
|
152
|
+
return MultiHostRobotsPolicy(robots, limiter), False
|
|
153
|
+
bus.emit(Notice(f"robots.txt: {verdict.reason}", "success"))
|
|
154
|
+
if verdict.crawl_delay:
|
|
155
|
+
limiter.slow_down_to(verdict.crawl_delay)
|
|
156
|
+
bus.emit(Notice(f"Honoring the site's Crawl-delay of {verdict.crawl_delay}s"))
|
|
157
|
+
return MultiHostRobotsPolicy(robots, limiter), True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# --- the session -----------------------------------------------------------------------------
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@dataclass
|
|
164
|
+
class Session:
|
|
165
|
+
"""Everything one run or refresh shares."""
|
|
166
|
+
|
|
167
|
+
store: Store
|
|
168
|
+
bus: EventBus
|
|
169
|
+
run_id: int
|
|
170
|
+
host: str # the server: first-party vs third-party, robots.txt
|
|
171
|
+
start_url: str
|
|
172
|
+
goal: str
|
|
173
|
+
headed: bool
|
|
174
|
+
robots: RobotsPolicy
|
|
175
|
+
recorder: TrafficRecorder
|
|
176
|
+
replayer: Replayer
|
|
177
|
+
gateway: LLMGateway | None
|
|
178
|
+
screens: Path | None = None
|
|
179
|
+
stop: Callable[[], bool] | None = None # e.g. the dashboard's Stop button
|
|
180
|
+
memory: str = ""
|
|
181
|
+
steps: list[StepRecord] = field(default_factory=list)
|
|
182
|
+
site: str = "" # memory and output folder: host or host/section (ghost_api/site.py)
|
|
183
|
+
network: NetworkPolicy | None = None # where the browser may connect (netguard.py)
|
|
184
|
+
login: dict[str, Any] | None = None # a saved login for the site (auth_state.py)
|
|
185
|
+
third_party_robots: bool = False # RunOptions.third_party_robots
|
|
186
|
+
audit: AuditLog | None = None # every request sent or blocked (audit.py)
|
|
187
|
+
_blocked: set[str] = field(default_factory=set)
|
|
188
|
+
|
|
189
|
+
def emit(self, event: Event) -> None:
|
|
190
|
+
self.bus.emit(event)
|
|
191
|
+
|
|
192
|
+
def _on_block(self, message: str) -> None:
|
|
193
|
+
"""A request the network rules stopped: said once in the run log (and audited)."""
|
|
194
|
+
if self.audit is not None:
|
|
195
|
+
self.audit.blocked(message)
|
|
196
|
+
if message not in self._blocked:
|
|
197
|
+
self._blocked.add(message)
|
|
198
|
+
self.emit(Notice(message, "warning"))
|
|
199
|
+
|
|
200
|
+
def browser(self) -> BrowserSession:
|
|
201
|
+
"""A browser whose every request follows the run's rules: no private addresses
|
|
202
|
+
(unless the target itself is local), robots.txt and the rate limit."""
|
|
203
|
+
gate = RequestGate(
|
|
204
|
+
self.host,
|
|
205
|
+
self.robots,
|
|
206
|
+
self.replayer.limiter,
|
|
207
|
+
self._on_block,
|
|
208
|
+
third_party_robots=self.third_party_robots,
|
|
209
|
+
)
|
|
210
|
+
return BrowserSession(
|
|
211
|
+
self.start_url,
|
|
212
|
+
self.recorder,
|
|
213
|
+
headed=self.headed,
|
|
214
|
+
policy=self.network,
|
|
215
|
+
gate=gate,
|
|
216
|
+
on_block=self._on_block,
|
|
217
|
+
storage_state=self.login,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def phase(self, phase: Phase) -> None:
|
|
221
|
+
self.emit(PhaseChanged(phase))
|
|
222
|
+
|
|
223
|
+
def on_step(self, rec: StepRecord) -> None:
|
|
224
|
+
self.steps.append(rec)
|
|
225
|
+
self.store.add_step(
|
|
226
|
+
self.run_id,
|
|
227
|
+
rec.step,
|
|
228
|
+
rec.thought,
|
|
229
|
+
rec.action,
|
|
230
|
+
rec.args,
|
|
231
|
+
rec.result,
|
|
232
|
+
len(rec.new_endpoints),
|
|
233
|
+
)
|
|
234
|
+
self.emit(
|
|
235
|
+
AgentStep(
|
|
236
|
+
step=rec.step,
|
|
237
|
+
action=rec.action,
|
|
238
|
+
args=dict(rec.args),
|
|
239
|
+
thought=rec.thought,
|
|
240
|
+
result=rec.result,
|
|
241
|
+
new_endpoints=list(rec.new_endpoints),
|
|
242
|
+
requests=rec.requests,
|
|
243
|
+
screenshot=rec.screenshot,
|
|
244
|
+
box=rec.box,
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
if self.gateway is not None:
|
|
248
|
+
self.emit(usage_event(self.gateway))
|
|
249
|
+
|
|
250
|
+
def _shooter(self, browser: BrowserSession) -> Callable[[str], str | None] | None:
|
|
251
|
+
if self.screens is None:
|
|
252
|
+
return None
|
|
253
|
+
folder = self.screens
|
|
254
|
+
|
|
255
|
+
def shoot(name: str) -> str | None:
|
|
256
|
+
file = f"{name}.jpg"
|
|
257
|
+
if not browser.screenshot(folder / file):
|
|
258
|
+
return None
|
|
259
|
+
# Step screenshots travel in their AgentStep event; announce the others (the
|
|
260
|
+
# page as first loaded), or a run that finishes at step 1 shows no picture.
|
|
261
|
+
if not name.startswith("step-"):
|
|
262
|
+
self.emit(Screenshot(file, browser.url))
|
|
263
|
+
return file
|
|
264
|
+
|
|
265
|
+
return shoot
|
|
266
|
+
|
|
267
|
+
def explore(
|
|
268
|
+
self,
|
|
269
|
+
goal: str,
|
|
270
|
+
max_steps: int,
|
|
271
|
+
max_minutes: float,
|
|
272
|
+
ask_user: Callable[[str], str] | None = None,
|
|
273
|
+
) -> AgentResult:
|
|
274
|
+
"""Open the browser and let the agent work toward `goal`."""
|
|
275
|
+
assert self.gateway is not None
|
|
276
|
+
with self.browser() as browser:
|
|
277
|
+
return Explorer(
|
|
278
|
+
browser=browser,
|
|
279
|
+
llm=self.gateway,
|
|
280
|
+
captured=lambda: self.recorder.captured,
|
|
281
|
+
goal=goal,
|
|
282
|
+
start_url=self.start_url,
|
|
283
|
+
target_host=self.host,
|
|
284
|
+
max_steps=max_steps,
|
|
285
|
+
max_minutes=max_minutes,
|
|
286
|
+
robots=self.robots,
|
|
287
|
+
on_step=self.on_step,
|
|
288
|
+
replayer=self.replayer,
|
|
289
|
+
raw_requests=lambda: self.recorder.raw,
|
|
290
|
+
add_sample=self.recorder.add,
|
|
291
|
+
ask_user=ask_user,
|
|
292
|
+
memory=self.memory,
|
|
293
|
+
screenshot=self._shooter(browser),
|
|
294
|
+
should_stop=self.stop,
|
|
295
|
+
).run()
|
|
296
|
+
|
|
297
|
+
def visit(self, scrolls: int) -> str | None:
|
|
298
|
+
"""Load the start page and scroll, without the agent (no LLM). Returns the page
|
|
299
|
+
title when it is a bot-protection page (nothing more is done there), else None."""
|
|
300
|
+
with self.browser() as browser:
|
|
301
|
+
browser.goto(self.start_url)
|
|
302
|
+
snapshot = browser.snapshot(self.host, self.robots)
|
|
303
|
+
if is_bot_challenge(snapshot.title, snapshot.text):
|
|
304
|
+
return snapshot.title # never try to get past bot protection (NFR-19)
|
|
305
|
+
for _ in range(scrolls):
|
|
306
|
+
if self.stop is not None and self.stop():
|
|
307
|
+
break
|
|
308
|
+
browser.scroll("bottom")
|
|
309
|
+
shoot = self._shooter(browser)
|
|
310
|
+
if shoot:
|
|
311
|
+
shoot("visit")
|
|
312
|
+
return None
|
|
313
|
+
|
|
314
|
+
def replan(self, sub_goal: str) -> None:
|
|
315
|
+
"""Verification sends the agent back with a focused sub-goal (e.g. fresh session)."""
|
|
316
|
+
if self.gateway is None:
|
|
317
|
+
return
|
|
318
|
+
self.emit(Notice(f"Replanning: {sub_goal}"))
|
|
319
|
+
with contextlib.suppress(Exception):
|
|
320
|
+
self.explore(sub_goal, REPLAN_STEPS, 3)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def open_session(
|
|
324
|
+
store: Store,
|
|
325
|
+
bus: EventBus,
|
|
326
|
+
host: str,
|
|
327
|
+
start_url: str,
|
|
328
|
+
goal: str,
|
|
329
|
+
headed: bool,
|
|
330
|
+
limiter: RateLimiter,
|
|
331
|
+
robots: RobotsPolicy,
|
|
332
|
+
gateway: LLMGateway | None,
|
|
333
|
+
max_probes: int,
|
|
334
|
+
screens_root: Path | None = None,
|
|
335
|
+
mode: str = "run",
|
|
336
|
+
lang: str = "python",
|
|
337
|
+
site: str | None = None,
|
|
338
|
+
) -> Session:
|
|
339
|
+
"""Create the run in the database and start recording its events.
|
|
340
|
+
|
|
341
|
+
`site` defaults to the start URL's site; a refresh passes the name its memory is under."""
|
|
342
|
+
site = site or site_key(start_url) or host
|
|
343
|
+
run_id = store.create_run(site, start_url, goal)
|
|
344
|
+
bus.subscribe(lambda event: store.add_event(run_id, event.to_dict()))
|
|
345
|
+
if gateway is not None:
|
|
346
|
+
gateway.store = store # cache LLM answers in the same database
|
|
347
|
+
|
|
348
|
+
def on_capture(req: CapturedRequest) -> None:
|
|
349
|
+
store.add_request(run_id, req)
|
|
350
|
+
bus.emit(RequestCaptured(req.method, req.url, req.status, req.third_party))
|
|
351
|
+
|
|
352
|
+
recorder = TrafficRecorder(host, on_capture=on_capture)
|
|
353
|
+
network = NetworkPolicy.for_target(start_url)
|
|
354
|
+
replayer = Replayer(host, limiter, robots, max_requests=max_probes, policy=network)
|
|
355
|
+
screens = screenshots_dir(screens_root, run_id) if screens_root is not None else None
|
|
356
|
+
session = Session(
|
|
357
|
+
store,
|
|
358
|
+
bus,
|
|
359
|
+
run_id,
|
|
360
|
+
host,
|
|
361
|
+
start_url,
|
|
362
|
+
goal,
|
|
363
|
+
headed,
|
|
364
|
+
robots,
|
|
365
|
+
recorder,
|
|
366
|
+
replayer,
|
|
367
|
+
gateway,
|
|
368
|
+
screens,
|
|
369
|
+
site=site,
|
|
370
|
+
network=network,
|
|
371
|
+
login=_saved_login(store, site, bus),
|
|
372
|
+
audit=AuditLog(store.path, site, run_id),
|
|
373
|
+
)
|
|
374
|
+
audit = session.audit
|
|
375
|
+
assert audit is not None
|
|
376
|
+
replayer.on_sent = lambda method, url, status, decision: audit.record(
|
|
377
|
+
"direct", method, url, status, decision
|
|
378
|
+
)
|
|
379
|
+
bus.emit(
|
|
380
|
+
RunStarted(run_id, site, start_url, goal, "refresh" if mode == "refresh" else "run", lang)
|
|
381
|
+
)
|
|
382
|
+
if network.allow_private:
|
|
383
|
+
bus.emit(Notice("Local target: this computer and the local network may be reached"))
|
|
384
|
+
return session
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def network_summary(s: Session) -> NetworkSummary:
|
|
388
|
+
third_party = sorted({urlsplit(c.url).hostname or "" for c in s.recorder.captured
|
|
389
|
+
if c.third_party} - {""}) # fmt: skip
|
|
390
|
+
return NetworkSummary(
|
|
391
|
+
target=s.host,
|
|
392
|
+
robots=s.robots.reason,
|
|
393
|
+
rate_limit=round(1 / s.replayer.limiter.interval, 2) if s.replayer.limiter.interval else 0,
|
|
394
|
+
local_target=bool(s.network and s.network.allow_private),
|
|
395
|
+
saved_login=s.login is not None,
|
|
396
|
+
api_calls_seen=len(s.recorder.captured),
|
|
397
|
+
direct_calls=s.replayer.requests_made,
|
|
398
|
+
third_party_hosts=third_party,
|
|
399
|
+
blocked=sorted(s._blocked),
|
|
400
|
+
llm_calls=s.gateway.usage.answered if s.gateway is not None else 0,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _saved_login(store: Store, site: str, bus: EventBus) -> dict[str, Any] | None:
|
|
405
|
+
"""The site's saved login (ghost-api login), announced in the run log."""
|
|
406
|
+
try:
|
|
407
|
+
found = auth_state.load(store.path, site)
|
|
408
|
+
except auth_state.LoginError as exc:
|
|
409
|
+
bus.emit(Notice(str(exc), "warning"))
|
|
410
|
+
return None
|
|
411
|
+
if found is None:
|
|
412
|
+
return None
|
|
413
|
+
state, meta = found
|
|
414
|
+
days = auth_state.age_days(meta)
|
|
415
|
+
note = f"Using your saved login for {site} (saved {days:.0f} day(s) ago)"
|
|
416
|
+
bus.emit(Notice(note, "warning" if days > 14 else "info",
|
|
417
|
+
"If pages show you logged out, run ghost-api login again." if days > 14
|
|
418
|
+
else "")) # fmt: skip
|
|
419
|
+
return state
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# --- run and refresh -------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def explore_site(
|
|
426
|
+
s: Session,
|
|
427
|
+
opts: RunOptions,
|
|
428
|
+
use_agent: bool,
|
|
429
|
+
ask_user: Callable[[str], str] | None = None,
|
|
430
|
+
) -> tuple[str, str]:
|
|
431
|
+
"""Workflow stage 1: explore. Returns (status, reason)."""
|
|
432
|
+
s.phase("explore")
|
|
433
|
+
try:
|
|
434
|
+
if not use_agent:
|
|
435
|
+
challenge = s.visit(AUTO_SCROLLS)
|
|
436
|
+
if challenge is not None:
|
|
437
|
+
return "bot-protection", f"bot-protection page detected: {challenge!r}"
|
|
438
|
+
if s.stop is not None and s.stop():
|
|
439
|
+
return "interrupted", "stopped by user"
|
|
440
|
+
return "captured", "--no-agent: scrolled the page"
|
|
441
|
+
result = s.explore(s.goal, opts.max_steps, opts.max_minutes, ask_user=ask_user)
|
|
442
|
+
return result.status, result.reason
|
|
443
|
+
except KeyboardInterrupt:
|
|
444
|
+
return "interrupted", "stopped by user"
|
|
445
|
+
except Exception as exc:
|
|
446
|
+
s.emit(Notice(friendly_error(exc), "error", f"error code: {error_code(exc)}"))
|
|
447
|
+
return "failed", friendly_error(exc)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def refresh_site(s: Session, mem: DomainMemory, max_steps: int) -> tuple[str, str]:
|
|
451
|
+
"""Re-check known endpoints with as little work as possible. Returns (status, reason)."""
|
|
452
|
+
status = "refreshed"
|
|
453
|
+
s.phase("explore")
|
|
454
|
+
try:
|
|
455
|
+
# 1. Stored samples that can be replayed as they are: no browser, no LLM.
|
|
456
|
+
preload(s, mem)
|
|
457
|
+
missing = missing_endpoints(s, mem)
|
|
458
|
+
s.emit(
|
|
459
|
+
Notice(
|
|
460
|
+
f"1. {len(mem.endpoints) - len(missing)} of {len(mem.endpoints)} endpoint(s) can "
|
|
461
|
+
"be re-checked from stored samples (no browser, no LLM)"
|
|
462
|
+
)
|
|
463
|
+
)
|
|
464
|
+
# 2. One plain visit for fresh traffic (fresh keys, cookies): still no LLM.
|
|
465
|
+
if missing:
|
|
466
|
+
labels = ", ".join(e.label for e in missing)
|
|
467
|
+
s.emit(Notice(f"2. Visiting the site once for fresh traffic: {labels}"))
|
|
468
|
+
s.visit(1)
|
|
469
|
+
missing = missing_endpoints(s, mem)
|
|
470
|
+
# 3. Only what's still missing needs the agent.
|
|
471
|
+
if missing and s.gateway is not None:
|
|
472
|
+
labels = ", ".join(e.label for e in missing)
|
|
473
|
+
s.emit(Notice(f"3. The agent looks for: {labels}"))
|
|
474
|
+
goal = f"{mem.goal}. Make the site send these requests again: {labels}"
|
|
475
|
+
result = s.explore(goal, max_steps, 5)
|
|
476
|
+
if result.status in ("bot-protection", "interrupted"):
|
|
477
|
+
status = result.status
|
|
478
|
+
missing = missing_endpoints(s, mem)
|
|
479
|
+
except KeyboardInterrupt:
|
|
480
|
+
return "interrupted", "stopped by user"
|
|
481
|
+
except Exception as exc:
|
|
482
|
+
s.emit(Notice(friendly_error(exc), "error", f"error code: {error_code(exc)}"))
|
|
483
|
+
return status, friendly_error(exc)
|
|
484
|
+
if missing:
|
|
485
|
+
names = ", ".join(e.label for e in missing)
|
|
486
|
+
if status == "refreshed" and s.gateway is not None:
|
|
487
|
+
# Looked for with every tool we have and still not seen: the site dropped them.
|
|
488
|
+
# They stay in the history but leave the client, report and runtime.
|
|
489
|
+
s.store.mark_gone(mem.domain, [e.key for e in missing], s.run_id)
|
|
490
|
+
return status, f"{len(missing)} known endpoint(s) not seen again: {names}"
|
|
491
|
+
return status, "all known endpoints re-captured"
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def preload(s: Session, mem: DomainMemory, per_endpoint: int = 5) -> None:
|
|
495
|
+
"""Load each known endpoint's latest stored samples, rebuilt for replay where possible.
|
|
496
|
+
|
|
497
|
+
They go straight into the recorder's lists (not re-saved: they're already stored).
|
|
498
|
+
"""
|
|
499
|
+
stored = s.store.requests_for_domain(mem.domain)
|
|
500
|
+
known = {e.key: e for e in mem.endpoints}
|
|
501
|
+
picked: dict[str, list[CapturedRequest]] = {}
|
|
502
|
+
for key, req in zip(endpoint_keys(stored), stored, strict=False):
|
|
503
|
+
if key in known:
|
|
504
|
+
picked.setdefault(key, []).append(req)
|
|
505
|
+
for key, reqs in picked.items():
|
|
506
|
+
for req in reqs[-per_endpoint:]:
|
|
507
|
+
s.recorder.captured.append(req)
|
|
508
|
+
s.recorder.raw.append(rebuild_raw(req, known[key].required_headers))
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def missing_endpoints(s: Session, mem: DomainMemory) -> list[Endpoint]:
|
|
512
|
+
"""Known endpoints with no sample that can be replayed right now."""
|
|
513
|
+
ready = {
|
|
514
|
+
key
|
|
515
|
+
for key, raw in zip(endpoint_keys(s.recorder.captured), s.recorder.raw, strict=False)
|
|
516
|
+
if raw is not None and is_replayable(raw)
|
|
517
|
+
}
|
|
518
|
+
return [e for e in mem.endpoints if e.key not in ready]
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
# --- stages 2-8: test, generate, verify, learn, report ---------------------------------------
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
@dataclass
|
|
525
|
+
class FinishResult:
|
|
526
|
+
endpoints: list[Endpoint]
|
|
527
|
+
generated: GenerationResult | None
|
|
528
|
+
generation_error: str
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def finish(s: Session, status: str, reason: str, started: float, opts: RunOptions) -> FinishResult:
|
|
532
|
+
store, site = s.store, s.site
|
|
533
|
+
reports: dict[str, ProbeReport] = {}
|
|
534
|
+
if not opts.no_probe and status not in STOPPED:
|
|
535
|
+
s.phase("test")
|
|
536
|
+
reports = probe_all(s)
|
|
537
|
+
|
|
538
|
+
endpoints = analyze(s.recorder.captured)
|
|
539
|
+
store.save_endpoints(site, s.run_id, endpoints)
|
|
540
|
+
for ep in endpoints:
|
|
541
|
+
report = reports.get(ep.key)
|
|
542
|
+
if report is not None:
|
|
543
|
+
apply_probe(ep, report)
|
|
544
|
+
store.save_probe_report(site, s.run_id, ep, report)
|
|
545
|
+
s.emit(EndpointsUpdated([endpoint_summary(e, site) for e in endpoints]))
|
|
546
|
+
exposed = exposures(endpoints, s.recorder.captured)
|
|
547
|
+
if exposed:
|
|
548
|
+
s.emit(
|
|
549
|
+
Notice(
|
|
550
|
+
f"Personal data: {summary_line(exposed)}. Ghost API hid these values.",
|
|
551
|
+
"warning",
|
|
552
|
+
"\n".join(f"{e.label}: {', '.join(e.kinds)}" for e in exposed),
|
|
553
|
+
)
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
generated: GenerationResult | None = None
|
|
557
|
+
generation_error = ""
|
|
558
|
+
if not opts.no_generate and endpoints and status not in ("interrupted", "bot-protection"):
|
|
559
|
+
s.phase("generate")
|
|
560
|
+
plan = plan_for(store, s.gateway, site, endpoints)
|
|
561
|
+
try:
|
|
562
|
+
if opts.no_verify:
|
|
563
|
+
generated = generate_from(
|
|
564
|
+
site, s.start_url, endpoints, s.recorder.captured, opts.out, plan, opts.lang,
|
|
565
|
+
store=store,
|
|
566
|
+
) # fmt: skip
|
|
567
|
+
else:
|
|
568
|
+
s.phase("verify")
|
|
569
|
+
s.emit(Notice("Verifying the generated client against the live API..."))
|
|
570
|
+
verifier = Verifier(
|
|
571
|
+
site,
|
|
572
|
+
s.start_url,
|
|
573
|
+
endpoints,
|
|
574
|
+
s.recorder.captured,
|
|
575
|
+
opts.out,
|
|
576
|
+
raw=s.recorder.raw,
|
|
577
|
+
plan=plan,
|
|
578
|
+
replayer=s.replayer,
|
|
579
|
+
explore=s.replan if s.gateway is not None else None,
|
|
580
|
+
on_round=lambda r: s.emit(round_event(r)),
|
|
581
|
+
add_sample=s.recorder.add,
|
|
582
|
+
lang=opts.lang,
|
|
583
|
+
store=store,
|
|
584
|
+
)
|
|
585
|
+
verification = verifier.run()
|
|
586
|
+
generated = verification.result
|
|
587
|
+
endpoints = verifier.endpoints
|
|
588
|
+
store.save_endpoints(site, s.run_id, endpoints) # e.g. relaxed schemas
|
|
589
|
+
for ep in endpoints:
|
|
590
|
+
state, note = verification.statuses.get(ep.key, ("unverified", ""))
|
|
591
|
+
ep.status, ep.status_note = state, note
|
|
592
|
+
store.set_endpoint_status(site, ep, state, note)
|
|
593
|
+
s.emit(EndpointsUpdated([endpoint_summary(e, site) for e in endpoints]))
|
|
594
|
+
except (NothingToGenerate, GenerationError) as exc:
|
|
595
|
+
generation_error = str(exc)
|
|
596
|
+
s.emit(Notice(f"No client generated: {exc}", "warning"))
|
|
597
|
+
s.replayer.close()
|
|
598
|
+
if generated is not None:
|
|
599
|
+
version = drift.record(store, site, generated.spec, s.run_id)
|
|
600
|
+
generated.version = version
|
|
601
|
+
breaking = any(c.severity == "breaking" for c in version.changes)
|
|
602
|
+
level: Literal["info", "warning"] = "warning" if breaking else "info"
|
|
603
|
+
s.emit(
|
|
604
|
+
Notice(
|
|
605
|
+
f"Client version: {version.summary()}",
|
|
606
|
+
level,
|
|
607
|
+
"\n".join(c.line() for c in version.changes[:12]) if version.changes else "",
|
|
608
|
+
)
|
|
609
|
+
)
|
|
610
|
+
s.emit(client_event(generated))
|
|
611
|
+
|
|
612
|
+
# Workflow stage 8: remember what was learned.
|
|
613
|
+
s.phase("learn")
|
|
614
|
+
auto = discovery_lessons([(r.action, r.args, r.new_endpoints) for r in s.steps])
|
|
615
|
+
learned = store.add_lessons(site, s.run_id, auto, "auto")
|
|
616
|
+
if s.gateway is not None and len(s.steps) >= MIN_STEPS_FOR_LESSONS:
|
|
617
|
+
texts = write_lessons(
|
|
618
|
+
s.gateway, s.goal, status, [(r.action, r.args, r.result) for r in s.steps], endpoints
|
|
619
|
+
)
|
|
620
|
+
learned += store.add_lessons(site, s.run_id, texts, "llm")
|
|
621
|
+
store.finish_run(s.run_id, status)
|
|
622
|
+
if s.gateway is not None:
|
|
623
|
+
store.record_usage(s.run_id, s.gateway.usage.answered, s.gateway.usage.tokens)
|
|
624
|
+
s.emit(usage_event(s.gateway))
|
|
625
|
+
s.gateway.close()
|
|
626
|
+
s.emit(network_summary(s))
|
|
627
|
+
exports = write_exports(store, site, s.run_id, opts.out, generated.spec if generated else None)
|
|
628
|
+
|
|
629
|
+
s.emit(
|
|
630
|
+
RunFinished(
|
|
631
|
+
status=status,
|
|
632
|
+
reason=reason,
|
|
633
|
+
seconds=round(time.monotonic() - started, 1),
|
|
634
|
+
requests=len(s.recorder.captured),
|
|
635
|
+
endpoints=len(endpoints),
|
|
636
|
+
report=str(exports.report),
|
|
637
|
+
openapi=str(exports.openapi),
|
|
638
|
+
report_html=str(exports.html or ""),
|
|
639
|
+
lessons_learned=learned,
|
|
640
|
+
filtered=dict(s.recorder.dropped),
|
|
641
|
+
generation_error=generation_error,
|
|
642
|
+
exposure=summary_line(exposed) if exposed else "",
|
|
643
|
+
)
|
|
644
|
+
)
|
|
645
|
+
s.phase("done")
|
|
646
|
+
return FinishResult(endpoints, generated, generation_error)
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def probe_all(s: Session) -> dict[str, ProbeReport]:
|
|
650
|
+
"""Workflow stage 4: test every endpoint with direct calls."""
|
|
651
|
+
recorder = s.recorder
|
|
652
|
+
endpoints = analyze(recorder.captured)
|
|
653
|
+
if not endpoints:
|
|
654
|
+
return {}
|
|
655
|
+
s.emit(Notice(f"Testing {len(endpoints)} endpoint(s) with direct calls..."))
|
|
656
|
+
keys = endpoint_keys(recorder.captured)
|
|
657
|
+
raws = list(recorder.raw)
|
|
658
|
+
prober = Prober(s.replayer, on_sample=recorder.add)
|
|
659
|
+
reports: dict[str, ProbeReport] = {}
|
|
660
|
+
for ep in endpoints:
|
|
661
|
+
samples = [r for k, r in zip(keys, raws, strict=False) if k == ep.key and r]
|
|
662
|
+
report = prober.probe(ep, samples)
|
|
663
|
+
reports[ep.key] = report
|
|
664
|
+
label = display_label(ep, s.host)
|
|
665
|
+
for h in report.hypotheses:
|
|
666
|
+
s.emit(HypothesisTested(label, h.text, h.result, h.evidence))
|
|
667
|
+
if report.note:
|
|
668
|
+
s.emit(Notice(f"{label}: {report.note}"))
|
|
669
|
+
s.emit(ProbeFinished(len(endpoints), s.replayer.requests_made))
|
|
670
|
+
return reports
|
|
671
|
+
|
|
672
|
+
|
|
673
|
+
def apply_probe(ep: Endpoint, report: ProbeReport) -> None:
|
|
674
|
+
ep.replayable = report.replayable
|
|
675
|
+
ep.auth_type = report.auth_type
|
|
676
|
+
ep.pagination = report.pagination
|
|
677
|
+
ep.required_headers = report.required_headers
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
# --- event payloads --------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def round_event(r: Round) -> VerifyRound:
|
|
684
|
+
counts = {k: sum(o.outcome == k for o in r.outcomes) for k in ("passed", "failed", "skipped")}
|
|
685
|
+
failures = []
|
|
686
|
+
for o in r.outcomes:
|
|
687
|
+
if o.outcome == "failed":
|
|
688
|
+
first = next((ln for ln in o.message.splitlines() if ln.strip()), "")
|
|
689
|
+
failures.append({"name": o.name, "message": first[:200]})
|
|
690
|
+
repairs = [{"method": x.method, "kind": x.kind, "action": x.action} for x in r.repairs]
|
|
691
|
+
return VerifyRound(
|
|
692
|
+
r.number, counts["passed"], counts["failed"], counts["skipped"], failures, repairs
|
|
693
|
+
)
|
|
694
|
+
|
|
695
|
+
|
|
696
|
+
def client_event(result: GenerationResult) -> ClientGenerated:
|
|
697
|
+
spec, client = result.spec, result.client
|
|
698
|
+
typescript = client.client_path.suffix == ".ts"
|
|
699
|
+
methods = []
|
|
700
|
+
for m in spec.methods:
|
|
701
|
+
if typescript:
|
|
702
|
+
name = camel(m.name)
|
|
703
|
+
args = [camel(p.name) for p in m.required]
|
|
704
|
+
iterator = f"iter{name[:1].upper()}{name[1:]}" if m.page_param else None
|
|
705
|
+
returns = ts_type(m.response_type)
|
|
706
|
+
else:
|
|
707
|
+
name, args = m.name, [p.name for p in m.required]
|
|
708
|
+
iterator = f"iter_{m.name}" if m.page_param else None
|
|
709
|
+
returns = m.response_type
|
|
710
|
+
methods.append(
|
|
711
|
+
{
|
|
712
|
+
"name": name,
|
|
713
|
+
"args": args,
|
|
714
|
+
"returns": returns,
|
|
715
|
+
"iterator": iterator,
|
|
716
|
+
"summary": m.summary,
|
|
717
|
+
"endpoint": m.endpoint_key,
|
|
718
|
+
}
|
|
719
|
+
)
|
|
720
|
+
files = list(
|
|
721
|
+
dict.fromkeys(p.name for p in (client.client_path, client.models_path, client.tests_path))
|
|
722
|
+
)
|
|
723
|
+
return ClientGenerated(
|
|
724
|
+
class_name=spec.class_name,
|
|
725
|
+
lang="typescript" if typescript else "python",
|
|
726
|
+
named_by_llm=result.named_by_llm,
|
|
727
|
+
directory=str(client.directory),
|
|
728
|
+
methods=methods,
|
|
729
|
+
credentials=[{"arg": c.arg, "env": c.env} for c in spec.credentials],
|
|
730
|
+
skipped=list(spec.skipped),
|
|
731
|
+
files=files,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
def usage_event(gateway: LLMGateway) -> UsageUpdated:
|
|
736
|
+
u = gateway.usage
|
|
737
|
+
return UsageUpdated(
|
|
738
|
+
answered=u.answered,
|
|
739
|
+
tokens=u.tokens,
|
|
740
|
+
cache_hits=u.cache_hits,
|
|
741
|
+
retries=u.retries,
|
|
742
|
+
fallbacks=u.fallbacks,
|
|
743
|
+
by_provider=dict(u.by_provider),
|
|
744
|
+
problems=[f"{n}x {p}" for p, n in u.problems.most_common(5)],
|
|
745
|
+
)
|