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.
- crawlerflow/__init__.py +9 -0
- crawlerflow/__main__.py +6 -0
- crawlerflow/browser/__init__.py +8 -0
- crawlerflow/browser/base.py +72 -0
- crawlerflow/browser/factory.py +40 -0
- crawlerflow/browser/pydoll.py +311 -0
- crawlerflow/cli/__init__.py +4 -0
- crawlerflow/cli/app.py +237 -0
- crawlerflow/engine/__init__.py +9 -0
- crawlerflow/engine/context.py +164 -0
- crawlerflow/engine/executor.py +273 -0
- crawlerflow/engine/registry.py +107 -0
- crawlerflow/engine/runner.py +170 -0
- crawlerflow/events/__init__.py +17 -0
- crawlerflow/events/bus.py +38 -0
- crawlerflow/events/console_logger.py +56 -0
- crawlerflow/events/json_logger.py +53 -0
- crawlerflow/events/models.py +31 -0
- crawlerflow/expressions/__init__.py +21 -0
- crawlerflow/expressions/conditions.py +83 -0
- crawlerflow/expressions/engine.py +191 -0
- crawlerflow/plugins/__init__.py +35 -0
- crawlerflow/plugins/base.py +37 -0
- crawlerflow/plugins/manager.py +212 -0
- crawlerflow/py.typed +0 -0
- crawlerflow/steps/__init__.py +20 -0
- crawlerflow/steps/browser.py +572 -0
- crawlerflow/steps/control.py +706 -0
- crawlerflow/steps/utility.py +1487 -0
- crawlerflow/workflow/__init__.py +9 -0
- crawlerflow/workflow/loader.py +34 -0
- crawlerflow/workflow/models.py +171 -0
- crawlerflow-0.1.0.dist-info/METADATA +131 -0
- crawlerflow-0.1.0.dist-info/RECORD +37 -0
- crawlerflow-0.1.0.dist-info/WHEEL +4 -0
- crawlerflow-0.1.0.dist-info/entry_points.txt +2 -0
- crawlerflow-0.1.0.dist-info/licenses/LICENSE +21 -0
crawlerflow/__init__.py
ADDED
crawlerflow/__main__.py
ADDED
|
@@ -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)
|
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)
|