crawlerflow 0.1.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.
@@ -0,0 +1,9 @@
1
+ """CrawlerFlow public package."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from crawlerflow.engine.runner import WorkflowRunner
6
+
7
+ __all__ = ["WorkflowRunner"]
8
+ __version__ = "0.1.0"
9
+
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from crawlerflow.cli.app import app
4
+
5
+ app()
6
+
@@ -0,0 +1,8 @@
1
+ """Browser adapter contracts and implementations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from crawlerflow.browser.base import BrowserAdapter, BrowserResponse
6
+ from crawlerflow.browser.factory import create_browser_adapter
7
+
8
+ __all__ = ["BrowserAdapter", "BrowserResponse", "create_browser_adapter"]
@@ -0,0 +1,72 @@
1
+ """Browser abstraction used by workflow steps."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(slots=True, frozen=True)
12
+ class BrowserResponse:
13
+ """Serializable response returned by browser-session requests."""
14
+
15
+ status_code: int
16
+ headers: dict[str, str] = field(default_factory=dict)
17
+ body: str | bytes | None = None
18
+
19
+
20
+ class BrowserAdapter(ABC):
21
+ """Backend-neutral asynchronous browser contract."""
22
+
23
+ @abstractmethod
24
+ async def goto(self, url: str) -> None: ...
25
+
26
+ @abstractmethod
27
+ async def click(self, selector: str) -> None: ...
28
+
29
+ @abstractmethod
30
+ async def fill(self, selector: str, value: str) -> None: ...
31
+
32
+ @abstractmethod
33
+ async def select(self, selector: str, value: str) -> None: ...
34
+
35
+ @abstractmethod
36
+ async def wait(self, selector: str, timeout_seconds: float | None = None) -> None: ...
37
+
38
+ @abstractmethod
39
+ async def wait_network(self, timeout_seconds: float | None = None) -> None: ...
40
+
41
+ @abstractmethod
42
+ async def html(self) -> str: ...
43
+
44
+ @abstractmethod
45
+ async def evaluate(self, script: str) -> Any: ...
46
+
47
+ @abstractmethod
48
+ async def cookies(self) -> dict[str, str]: ...
49
+
50
+ @abstractmethod
51
+ async def set_cookies(self, cookies: dict[str, str]) -> None: ...
52
+
53
+ @abstractmethod
54
+ async def request(
55
+ self,
56
+ method: str,
57
+ url: str,
58
+ *,
59
+ headers: dict[str, str] | None = None,
60
+ data: Any = None,
61
+ ) -> BrowserResponse: ...
62
+
63
+ @abstractmethod
64
+ async def download(self, url: str, path: Path) -> Path: ...
65
+
66
+ @abstractmethod
67
+ async def screenshot(self, path: Path) -> Path: ...
68
+
69
+ async def close(self) -> None:
70
+ """Release browser resources when an adapter owns them."""
71
+
72
+ return None
@@ -0,0 +1,40 @@
1
+ """Create browser adapters from workflow configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from crawlerflow.browser.base import BrowserAdapter
8
+ from crawlerflow.workflow.models import BrowserSettings
9
+
10
+
11
+ def create_browser_adapter(
12
+ settings: BrowserSettings,
13
+ *,
14
+ base_path: Path,
15
+ ) -> BrowserAdapter | None:
16
+ """Create the configured browser adapter, if browser execution is enabled."""
17
+
18
+ if settings.engine is None:
19
+ return None
20
+ if settings.engine == "pydoll":
21
+ from crawlerflow.browser.pydoll import PydollBrowserAdapter, PydollBrowserConfig
22
+
23
+ binary_location = settings.binary_location
24
+ if binary_location is not None and not binary_location.is_absolute():
25
+ binary_location = base_path / binary_location
26
+ download_directory = settings.download_directory
27
+ if download_directory is not None and not download_directory.is_absolute():
28
+ download_directory = base_path / download_directory
29
+ return PydollBrowserAdapter(
30
+ PydollBrowserConfig(
31
+ headless=settings.headless,
32
+ binary_location=binary_location,
33
+ arguments=tuple(settings.arguments),
34
+ start_timeout=settings.start_timeout,
35
+ default_wait_timeout=settings.default_wait_timeout,
36
+ network_idle_period=settings.network_idle_period,
37
+ download_directory=download_directory,
38
+ )
39
+ )
40
+ raise ValueError(f"Unsupported browser engine: {settings.engine}")
@@ -0,0 +1,311 @@
1
+ """Pydoll implementation of the browser adapter contract."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import math
9
+ import time
10
+ from collections.abc import Mapping
11
+ from dataclasses import dataclass
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from crawlerflow.browser.base import BrowserAdapter, BrowserResponse
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class PydollAdapterError(RuntimeError):
21
+ """Base error raised by the Pydoll adapter."""
22
+
23
+
24
+ class PydollNotInstalledError(PydollAdapterError):
25
+ """Raised when the optional Pydoll dependency is unavailable."""
26
+
27
+
28
+ class NetworkIdleTimeout(PydollAdapterError, TimeoutError):
29
+ """Raised when browser network activity does not become idle in time."""
30
+
31
+
32
+ @dataclass(slots=True, frozen=True)
33
+ class PydollBrowserConfig:
34
+ """Runtime options used to launch a Pydoll Chromium browser."""
35
+
36
+ headless: bool = True
37
+ binary_location: Path | None = None
38
+ arguments: tuple[str, ...] = ()
39
+ start_timeout: int = 10
40
+ default_wait_timeout: float = 10
41
+ network_idle_period: float = 0.5
42
+ download_directory: Path | None = None
43
+
44
+
45
+ class PydollBrowserAdapter(BrowserAdapter):
46
+ """Drive Chromium through Pydoll while exposing backend-neutral operations."""
47
+
48
+ def __init__(
49
+ self,
50
+ config: PydollBrowserConfig | None = None,
51
+ *,
52
+ browser: Any = None,
53
+ tab: Any = None,
54
+ ) -> None:
55
+ self.config = config or PydollBrowserConfig()
56
+ self._browser = browser
57
+ self._tab = tab
58
+ self._owns_browser = browser is None
59
+ self._start_lock = asyncio.Lock()
60
+ self._network_tracking_enabled = False
61
+ self._network_callback_ids: list[int] = []
62
+ self._inflight_requests: set[str] = set()
63
+ self._last_network_activity = time.monotonic()
64
+
65
+ async def goto(self, url: str) -> None:
66
+ tab = await self._get_tab()
67
+ await tab.go_to(url)
68
+
69
+ async def click(self, selector: str) -> None:
70
+ element = await self._query(selector)
71
+ await element.click()
72
+
73
+ async def fill(self, selector: str, value: str) -> None:
74
+ element = await self._query(selector)
75
+ await element.clear()
76
+ await element.insert_text(value)
77
+
78
+ async def select(self, selector: str, value: str) -> None:
79
+ element = await self._query(selector)
80
+ escaped_value = self._json_string(value)
81
+ result = await element.execute_script(
82
+ "if (!(this instanceof HTMLSelectElement)) { "
83
+ "throw new Error('Selected element is not a <select>'); "
84
+ "} "
85
+ f"this.value = {escaped_value}; "
86
+ "this.dispatchEvent(new Event('input', { bubbles: true })); "
87
+ "this.dispatchEvent(new Event('change', { bubbles: true })); "
88
+ "return this.value;",
89
+ return_by_value=True,
90
+ )
91
+ selected_value = self._unwrap_script_result(result)
92
+ if selected_value != value:
93
+ raise PydollAdapterError(
94
+ f"Option value '{value}' was not found for selector '{selector}'"
95
+ )
96
+
97
+ async def wait(self, selector: str, timeout_seconds: float | None = None) -> None:
98
+ await self._query(selector, timeout_seconds=timeout_seconds)
99
+
100
+ async def wait_network(self, timeout_seconds: float | None = None) -> None:
101
+ await self._get_tab()
102
+ timeout = timeout_seconds or self.config.default_wait_timeout
103
+ deadline = time.monotonic() + timeout
104
+
105
+ while True:
106
+ now = time.monotonic()
107
+ idle_for = now - self._last_network_activity
108
+ if not self._inflight_requests and idle_for >= self.config.network_idle_period:
109
+ return
110
+ if now >= deadline:
111
+ raise NetworkIdleTimeout(
112
+ f"Network did not become idle within {timeout:g} seconds "
113
+ f"({len(self._inflight_requests)} request(s) still active)"
114
+ )
115
+ await asyncio.sleep(min(0.05, deadline - now))
116
+
117
+ async def html(self) -> str:
118
+ tab = await self._get_tab()
119
+ return await tab.page_source
120
+
121
+ async def evaluate(self, script: str) -> Any:
122
+ tab = await self._get_tab()
123
+ result = await tab.execute_script(
124
+ script,
125
+ return_by_value=True,
126
+ await_promise=True,
127
+ )
128
+ return self._unwrap_script_result(result)
129
+
130
+ async def cookies(self) -> dict[str, str]:
131
+ tab = await self._get_tab()
132
+ cookies = await tab.get_cookies()
133
+ return {str(cookie["name"]): str(cookie["value"]) for cookie in cookies}
134
+
135
+ async def set_cookies(self, cookies: dict[str, str]) -> None:
136
+ tab = await self._get_tab()
137
+ await tab.set_cookies(
138
+ [{"name": name, "value": value} for name, value in cookies.items()]
139
+ )
140
+
141
+ async def request(
142
+ self,
143
+ method: str,
144
+ url: str,
145
+ *,
146
+ headers: dict[str, str] | None = None,
147
+ data: Any = None,
148
+ ) -> BrowserResponse:
149
+ tab = await self._get_tab()
150
+ pydoll_headers = self._headers_to_entries(headers)
151
+ response = await tab.request.request(
152
+ method.upper(),
153
+ url,
154
+ headers=pydoll_headers or None,
155
+ data=data,
156
+ )
157
+ return BrowserResponse(
158
+ status_code=response.status_code,
159
+ headers=self._entries_to_headers(response.headers),
160
+ body=response.text,
161
+ )
162
+
163
+ async def download(self, url: str, path: Path) -> Path:
164
+ tab = await self._get_tab()
165
+ response = await tab.request.get(url)
166
+ response.raise_for_status()
167
+ path.parent.mkdir(parents=True, exist_ok=True)
168
+ path.write_bytes(response.content)
169
+ return path
170
+
171
+ async def screenshot(self, path: Path) -> Path:
172
+ tab = await self._get_tab()
173
+ path.parent.mkdir(parents=True, exist_ok=True)
174
+ await tab.take_screenshot(path)
175
+ return path
176
+
177
+ async def close(self) -> None:
178
+ if self._tab is not None:
179
+ for callback_id in self._network_callback_ids:
180
+ try:
181
+ await self._tab.remove_callback(callback_id)
182
+ except Exception:
183
+ logger.debug("Could not remove Pydoll callback", exc_info=True)
184
+ self._network_callback_ids.clear()
185
+ if self._browser is not None and self._owns_browser:
186
+ try:
187
+ await self._browser.stop()
188
+ except Exception:
189
+ logger.debug("Could not stop Pydoll browser", exc_info=True)
190
+ self._tab = None
191
+ self._browser = None
192
+ self._network_tracking_enabled = False
193
+ self._inflight_requests.clear()
194
+
195
+ async def _get_tab(self) -> Any:
196
+ if self._tab is None:
197
+ async with self._start_lock:
198
+ if self._tab is None:
199
+ await self._start()
200
+ await self._enable_network_tracking()
201
+ return self._tab
202
+
203
+ async def _start(self) -> None:
204
+ if self._browser is None:
205
+ try:
206
+ from pydoll.browser.chromium import Chrome
207
+ from pydoll.browser.options import ChromiumOptions
208
+ except ImportError as error:
209
+ raise PydollNotInstalledError(
210
+ "Pydoll is not installed; install CrawlerFlow with the 'browser' extra"
211
+ ) from error
212
+
213
+ options = ChromiumOptions()
214
+ options.headless = self.config.headless
215
+ options.start_timeout = self.config.start_timeout
216
+ if self.config.binary_location is not None:
217
+ options.binary_location = str(self.config.binary_location)
218
+ if self.config.download_directory is not None:
219
+ self.config.download_directory.mkdir(parents=True, exist_ok=True)
220
+ options.set_default_download_directory(str(self.config.download_directory))
221
+ for argument in self.config.arguments:
222
+ options.add_argument(argument)
223
+
224
+ self._browser = Chrome(options=options)
225
+ self._tab = await self._browser.start()
226
+
227
+ async def _query(self, selector: str, timeout_seconds: float | None = None) -> Any:
228
+ tab = await self._get_tab()
229
+ timeout = timeout_seconds or self.config.default_wait_timeout
230
+ return await tab.query(selector, timeout=max(1, math.ceil(timeout)), raise_exc=True)
231
+
232
+ async def _enable_network_tracking(self) -> None:
233
+ if self._network_tracking_enabled:
234
+ return
235
+ try:
236
+ from pydoll.protocol.network.events import NetworkEvent
237
+ except ImportError as error:
238
+ raise PydollNotInstalledError(
239
+ "Pydoll is not installed; install CrawlerFlow with the 'browser' extra"
240
+ ) from error
241
+
242
+ await self._tab.enable_network_events()
243
+ self._network_callback_ids = [
244
+ await self._tab.on(NetworkEvent.REQUEST_WILL_BE_SENT, self._on_request_started),
245
+ await self._tab.on(NetworkEvent.LOADING_FINISHED, self._on_request_finished),
246
+ await self._tab.on(NetworkEvent.LOADING_FAILED, self._on_request_finished),
247
+ ]
248
+ self._network_tracking_enabled = True
249
+ self._last_network_activity = time.monotonic()
250
+
251
+ async def _on_request_started(self, event: Mapping[str, Any]) -> None:
252
+ request_id = self._request_id(event)
253
+ if request_id is not None:
254
+ self._inflight_requests.add(request_id)
255
+ self._last_network_activity = time.monotonic()
256
+
257
+ async def _on_request_finished(self, event: Mapping[str, Any]) -> None:
258
+ request_id = self._request_id(event)
259
+ if request_id is not None:
260
+ self._inflight_requests.discard(request_id)
261
+ self._last_network_activity = time.monotonic()
262
+
263
+ @staticmethod
264
+ def _request_id(event: Mapping[str, Any]) -> str | None:
265
+ parameters = event.get("params", event)
266
+ if not isinstance(parameters, Mapping):
267
+ return None
268
+ request_id = parameters.get("requestId")
269
+ return str(request_id) if request_id is not None else None
270
+
271
+ @staticmethod
272
+ def _unwrap_script_result(response: Mapping[str, Any]) -> Any:
273
+ payload = response.get("result", response)
274
+ if not isinstance(payload, Mapping):
275
+ return payload
276
+ exception = payload.get("exceptionDetails")
277
+ if isinstance(exception, Mapping):
278
+ message = exception.get("text", "JavaScript execution failed")
279
+ remote_exception = exception.get("exception")
280
+ if isinstance(remote_exception, Mapping):
281
+ message = remote_exception.get("description", message)
282
+ raise PydollAdapterError(str(message))
283
+
284
+ remote_object = payload.get("result", payload)
285
+ if not isinstance(remote_object, Mapping):
286
+ return remote_object
287
+ if "value" in remote_object:
288
+ return remote_object["value"]
289
+ if remote_object.get("type") == "undefined":
290
+ return None
291
+ return remote_object.get("unserializableValue", remote_object.get("description"))
292
+
293
+ @staticmethod
294
+ def _headers_to_entries(headers: dict[str, str] | None) -> list[dict[str, str]]:
295
+ if headers is None:
296
+ return []
297
+ return [{"name": name, "value": value} for name, value in headers.items()]
298
+
299
+ @staticmethod
300
+ def _entries_to_headers(entries: Any) -> dict[str, str]:
301
+ if isinstance(entries, Mapping):
302
+ return {str(name): str(value) for name, value in entries.items()}
303
+ return {
304
+ str(entry["name"]): str(entry["value"])
305
+ for entry in entries or []
306
+ if "name" in entry and "value" in entry
307
+ }
308
+
309
+ @staticmethod
310
+ def _json_string(value: str) -> str:
311
+ return json.dumps(value)
@@ -0,0 +1,4 @@
1
+ """CrawlerFlow command-line interface."""
2
+
3
+ from __future__ import annotations
4
+
crawlerflow/cli/app.py ADDED
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import importlib.util
5
+ import sys
6
+ from enum import StrEnum
7
+ from pathlib import Path
8
+ from typing import Annotated
9
+
10
+ import typer
11
+ from pydantic import ValidationError
12
+ from rich.console import Console
13
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TaskProgressColumn, TextColumn
14
+ from rich.table import Table
15
+
16
+ from crawlerflow import __version__
17
+ from crawlerflow.engine.executor import WorkflowExecutionError
18
+ from crawlerflow.engine.runner import WorkflowRunner
19
+ from crawlerflow.plugins import discover_plugins
20
+ from crawlerflow.workflow.loader import WorkflowLoadError
21
+
22
+ app = typer.Typer(help="Declarative browser automation workflow engine.", no_args_is_help=True)
23
+ console = Console()
24
+ workflow_errors = (WorkflowLoadError, WorkflowExecutionError, ValidationError, ValueError)
25
+
26
+
27
+ class RunMode(StrEnum):
28
+ SYNC = "sync"
29
+ ASYNC = "async"
30
+
31
+
32
+ @app.command()
33
+ def run(
34
+ workflows: Annotated[
35
+ list[Path],
36
+ typer.Argument(
37
+ exists=True,
38
+ file_okay=True,
39
+ dir_okay=True,
40
+ readable=True,
41
+ help="Workflow YAML files or directories containing YAML files.",
42
+ ),
43
+ ],
44
+ mode: Annotated[
45
+ RunMode,
46
+ typer.Option("--mode", "-m", help="Workflow execution mode."),
47
+ ] = RunMode.SYNC,
48
+ show_progress: Annotated[
49
+ bool,
50
+ typer.Option("--progress", help="Show a live workflow progress bar."),
51
+ ] = False,
52
+ concurrency: Annotated[
53
+ int | None,
54
+ typer.Option(
55
+ "--concurrency",
56
+ "-c",
57
+ min=1,
58
+ help="Maximum workflows running at once in async mode.",
59
+ ),
60
+ ] = None,
61
+ ) -> None:
62
+ """Run one or more YAML workflows sequentially or in parallel."""
63
+
64
+ workflow_paths = _expand_workflow_paths(workflows)
65
+ if not workflow_paths:
66
+ console.print("[red]No workflow YAML files found.[/red]")
67
+ raise typer.Exit(1)
68
+
69
+ if mode is RunMode.ASYNC:
70
+ _run_async_workflows(
71
+ workflow_paths,
72
+ show_progress=show_progress,
73
+ concurrency=concurrency,
74
+ )
75
+ return
76
+
77
+ if concurrency is not None:
78
+ console.print("[red]--concurrency requires --mode async.[/red]")
79
+ raise typer.Exit(1)
80
+ _run_sync_workflows(workflow_paths, show_progress=show_progress)
81
+
82
+
83
+ def _expand_workflow_paths(paths: list[Path]) -> list[Path]:
84
+ workflows: list[Path] = []
85
+ seen: set[Path] = set()
86
+ for path in paths:
87
+ candidates = (
88
+ sorted(
89
+ (
90
+ child
91
+ for child in path.iterdir()
92
+ if child.is_file() and child.suffix.lower() in {".yaml", ".yml"}
93
+ ),
94
+ key=lambda child: (child.name.casefold(), child.name),
95
+ )
96
+ if path.is_dir()
97
+ else [path]
98
+ )
99
+ for candidate in candidates:
100
+ identity = candidate.resolve()
101
+ if identity not in seen:
102
+ seen.add(identity)
103
+ workflows.append(candidate)
104
+ return workflows
105
+
106
+
107
+ def _run_sync_workflows(workflows: list[Path], *, show_progress: bool = False) -> None:
108
+ with _create_workflow_progress(show_progress) as progress:
109
+ progress_task = progress.add_task("Workflows", total=len(workflows))
110
+ for workflow in workflows:
111
+ try:
112
+ context = asyncio.run(WorkflowRunner().run(workflow))
113
+ except workflow_errors as error:
114
+ progress.advance(progress_task)
115
+ console.print(f"[red]Workflow failed:[/red] {workflow}: {error}")
116
+ raise typer.Exit(1) from error
117
+ progress.advance(progress_task)
118
+ console.print(f"[green]Completed:[/green] {context.workflow_name}")
119
+
120
+
121
+ def _run_async_workflows(
122
+ workflows: list[Path],
123
+ *,
124
+ show_progress: bool = False,
125
+ concurrency: int | None = None,
126
+ ) -> None:
127
+ async def run_one(
128
+ workflow: Path,
129
+ semaphore: asyncio.Semaphore | None,
130
+ ) -> tuple[Path, object]:
131
+ try:
132
+ if semaphore is None:
133
+ result = await WorkflowRunner().run(workflow)
134
+ else:
135
+ async with semaphore:
136
+ result = await WorkflowRunner().run(workflow)
137
+ return workflow, result
138
+ except BaseException as error:
139
+ return workflow, error
140
+
141
+ async def run_all(
142
+ progress: Progress,
143
+ progress_task: int,
144
+ ) -> tuple[bool, BaseException | None]:
145
+ failed = False
146
+ unexpected_error: BaseException | None = None
147
+ semaphore = asyncio.Semaphore(concurrency) if concurrency is not None else None
148
+ tasks = [
149
+ asyncio.create_task(run_one(workflow, semaphore)) for workflow in workflows
150
+ ]
151
+ for completed in asyncio.as_completed(tasks):
152
+ workflow, result = await completed
153
+ progress.advance(progress_task)
154
+ if isinstance(result, workflow_errors):
155
+ failed = True
156
+ console.print(f"[red]Workflow failed:[/red] {workflow}: {result}")
157
+ elif isinstance(result, BaseException):
158
+ unexpected_error = unexpected_error or result
159
+ else:
160
+ console.print(f"[green]Completed:[/green] {result.workflow_name}")
161
+ return failed, unexpected_error
162
+
163
+ with _create_workflow_progress(show_progress) as progress:
164
+ progress_task = progress.add_task("Workflows", total=len(workflows))
165
+ failed, unexpected_error = asyncio.run(run_all(progress, progress_task))
166
+ if unexpected_error is not None:
167
+ raise unexpected_error
168
+ if failed:
169
+ raise typer.Exit(1)
170
+
171
+
172
+ def _create_workflow_progress(enabled: bool) -> Progress:
173
+ return Progress(
174
+ TextColumn("[progress.description]{task.description}"),
175
+ BarColumn(),
176
+ TaskProgressColumn(),
177
+ MofNCompleteColumn(),
178
+ console=console,
179
+ disable=not enabled,
180
+ refresh_per_second=10,
181
+ )
182
+
183
+
184
+ @app.command()
185
+ def validate(
186
+ workflow: Annotated[
187
+ Path,
188
+ typer.Argument(exists=True, dir_okay=False, readable=True),
189
+ ],
190
+ ) -> None:
191
+ """Validate workflow structure and step configurations."""
192
+
193
+ try:
194
+ document = WorkflowRunner().load(workflow)
195
+ except (WorkflowLoadError, ValidationError, ValueError) as error:
196
+ console.print(f"[red]Invalid workflow:[/red] {error}")
197
+ raise typer.Exit(1) from error
198
+ console.print(f"[green]Valid workflow:[/green] {document.workflow.name}")
199
+
200
+
201
+ @app.command("list-steps")
202
+ def list_steps() -> None:
203
+ """List all registered workflow steps."""
204
+
205
+ runner = WorkflowRunner()
206
+ table = Table("Step")
207
+ for name in runner.registry.names():
208
+ table.add_row(name)
209
+ console.print(table)
210
+
211
+
212
+ @app.command("list-plugins")
213
+ def list_plugins() -> None:
214
+ """List installed CrawlerFlow plugin entry points without loading them."""
215
+
216
+ plugins = discover_plugins()
217
+ if not plugins:
218
+ console.print("[yellow]No plugins installed.[/yellow]")
219
+ return
220
+
221
+ table = Table("Plugin", "Target", "Distribution", box=None, pad_edge=False)
222
+ for plugin in plugins:
223
+ table.add_row(plugin.name, plugin.target, plugin.distribution or "-")
224
+ console.print(table)
225
+
226
+
227
+ @app.command()
228
+ def doctor() -> None:
229
+ """Display runtime and package diagnostics."""
230
+
231
+ table = Table("Check", "Value")
232
+ table.add_row("CrawlerFlow", __version__)
233
+ table.add_row("Python", sys.version.split()[0])
234
+ table.add_row("Runtime", "OK" if sys.version_info >= (3, 12) else "Python 3.12+ required")
235
+ pydoll_status = "Installed" if importlib.util.find_spec("pydoll") else "Not installed"
236
+ table.add_row("Pydoll adapter", pydoll_status)
237
+ console.print(table)