crawlerflow 0.2.0__tar.gz → 0.4.0__tar.gz
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-0.2.0 → crawlerflow-0.4.0}/.gitignore +3 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/CHANGELOG.md +31 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/PKG-INFO +1 -1
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/__init__.py +1 -1
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/browser/base.py +7 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/browser/pydoll.py +62 -1
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/engine/context.py +2 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/steps/utility.py +142 -2
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/http-requests.md +31 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/plugins.md +50 -0
- crawlerflow-0.4.0/examples/plugins/capsolver/README.md +66 -0
- crawlerflow-0.4.0/examples/plugins/capsolver/pyproject.toml +21 -0
- crawlerflow-0.4.0/examples/plugins/capsolver/src/crawlerflow_capsolver/__init__.py +503 -0
- crawlerflow-0.4.0/examples/plugins/capsolver/workflow.yaml +28 -0
- crawlerflow-0.4.0/examples/plugins/capsolver-webshare/cloudflare-challenge-http.yaml +68 -0
- crawlerflow-0.4.0/examples/plugins/capsolver-webshare/cloudflare-challenge.yaml +74 -0
- crawlerflow-0.4.0/examples/plugins/capsolver-webshare/workflow.yaml +54 -0
- crawlerflow-0.4.0/examples/plugins/webshare/README.md +63 -0
- crawlerflow-0.4.0/examples/plugins/webshare/pyproject.toml +21 -0
- crawlerflow-0.4.0/examples/plugins/webshare/src/crawlerflow_webshare/__init__.py +454 -0
- crawlerflow-0.4.0/examples/plugins/webshare/workflow.yaml +28 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/pyproject.toml +1 -1
- crawlerflow-0.4.0/tests/test_capsolver_plugin.py +96 -0
- crawlerflow-0.4.0/tests/test_combined_plugin_workflow.py +55 -0
- crawlerflow-0.4.0/tests/test_json_filter.py +105 -0
- crawlerflow-0.4.0/tests/test_proxy_retry.py +141 -0
- crawlerflow-0.4.0/tests/test_webshare_plugin.py +127 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/LICENSE +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/README.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/__main__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/browser/__init__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/browser/factory.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/cli/__init__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/cli/app.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/engine/__init__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/engine/executor.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/engine/registry.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/engine/runner.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/events/__init__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/events/bus.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/events/console_logger.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/events/json_logger.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/events/models.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/expressions/__init__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/expressions/conditions.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/expressions/engine.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/plugins/__init__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/plugins/base.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/plugins/manager.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/py.typed +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/steps/__init__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/steps/browser.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/steps/control.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/workflow/__init__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/workflow/loader.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/crawlerflow/workflow/models.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/adr/0001-core-boundaries.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/architecture.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/control-flow.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/getting-started.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/html-output.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/index.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/pydoll.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/retries-and-logging.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/docs/runtime-variables.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/examples/basic.yaml +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/examples/control-flow.yaml +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/examples/loops-macros.yaml +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/examples/plugins/example/README.md +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/examples/plugins/example/pyproject.toml +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/examples/plugins/example/src/crawlerflow_example_plugin/__init__.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/examples/plugins/example/workflow.yaml +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/examples/pydoll.yaml +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/examples/retry-logging.yaml +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_browser_steps.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_cli.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_conditions.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_control_flow.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_example_plugin.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_expressions.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_loops_and_macros.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_plugins.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_pydoll_adapter.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_retry_and_logging.py +0 -0
- {crawlerflow-0.2.0 → crawlerflow-0.4.0}/tests/test_workflow.py +0 -0
|
@@ -5,6 +5,35 @@ All notable changes to this project are documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and this project
|
|
6
6
|
adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-09-20
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- `filter_json` step for filtering JSON arrays and objects with declarative conditions and
|
|
13
|
+
selecting only the fields needed for output.
|
|
14
|
+
|
|
15
|
+
## [0.3.0] - 2026-09-17
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- Workflow-wide proxy support. `WorkflowContext` carries a `proxy_url` that survives parallel loop
|
|
20
|
+
forks, and the built-in `http_request`, `resolve_location_url`, `enrich_html_links_http`, and
|
|
21
|
+
`enrich_json_map_locations` steps route their requests through it.
|
|
22
|
+
- `BrowserAdapter.configure_proxy()` extension hook. Adapters that cannot proxy raise a clear
|
|
23
|
+
error, keeping the contract explicit.
|
|
24
|
+
- Pydoll proxy support for HTTP, HTTPS, and SOCKS5 URLs, including authenticated proxies through a
|
|
25
|
+
generated extension. The temporary extension directory is removed when the adapter closes.
|
|
26
|
+
- CapSolver reference plugin that solves captcha tasks and exposes the solution to later steps.
|
|
27
|
+
- Webshare reference plugin that selects proxies and publishes the active proxy to the workflow.
|
|
28
|
+
- Combined CapSolver and Webshare example workflows covering browser and HTTP-only Cloudflare
|
|
29
|
+
challenge flows.
|
|
30
|
+
- Plugin documentation covering proxy-aware plugins and the new adapter hook.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Disabled Jekyll processing for the published documentation site so workflow expression examples
|
|
35
|
+
are no longer parsed as Liquid templates.
|
|
36
|
+
|
|
8
37
|
## [0.2.0] - 2026-08-18
|
|
9
38
|
|
|
10
39
|
### Changed
|
|
@@ -36,5 +65,7 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
|
36
65
|
- Plugin API with typed settings, lifecycle hooks, steps, filters, and subscribers.
|
|
37
66
|
- `run`, `validate`, `list-steps`, `list-plugins`, and `doctor` CLI commands.
|
|
38
67
|
|
|
68
|
+
[0.4.0]: https://github.com/mehmetemineker/crawlerflow/releases/tag/v0.4.0
|
|
69
|
+
[0.3.0]: https://github.com/mehmetemineker/crawlerflow/releases/tag/v0.3.0
|
|
39
70
|
[0.2.0]: https://github.com/mehmetemineker/crawlerflow/releases/tag/v0.2.0
|
|
40
71
|
[0.1.0]: https://github.com/mehmetemineker/crawlerflow/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: crawlerflow
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Declarative YAML workflow engine for browser automation and web scraping
|
|
5
5
|
Project-URL: Homepage, https://github.com/mehmetemineker/crawlerflow
|
|
6
6
|
Project-URL: Repository, https://github.com/mehmetemineker/crawlerflow
|
|
@@ -66,6 +66,13 @@ class BrowserAdapter(ABC):
|
|
|
66
66
|
@abstractmethod
|
|
67
67
|
async def screenshot(self, path: Path) -> Path: ...
|
|
68
68
|
|
|
69
|
+
def configure_proxy(self, proxy_url: str) -> None:
|
|
70
|
+
"""Configure a proxy before the browser session starts, if supported."""
|
|
71
|
+
|
|
72
|
+
raise RuntimeError(
|
|
73
|
+
f"Browser adapter {type(self).__name__} does not support proxy configuration"
|
|
74
|
+
)
|
|
75
|
+
|
|
69
76
|
async def close(self) -> None:
|
|
70
77
|
"""Release browser resources when an adapter owns them."""
|
|
71
78
|
|
|
@@ -6,11 +6,14 @@ import asyncio
|
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
8
|
import math
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
9
11
|
import time
|
|
10
12
|
from collections.abc import Mapping
|
|
11
|
-
from dataclasses import dataclass
|
|
13
|
+
from dataclasses import dataclass, replace
|
|
12
14
|
from pathlib import Path
|
|
13
15
|
from typing import Any
|
|
16
|
+
from urllib.parse import unquote, urlsplit
|
|
14
17
|
|
|
15
18
|
from crawlerflow.browser.base import BrowserAdapter, BrowserResponse
|
|
16
19
|
|
|
@@ -40,6 +43,7 @@ class PydollBrowserConfig:
|
|
|
40
43
|
default_wait_timeout: float = 10
|
|
41
44
|
network_idle_period: float = 0.5
|
|
42
45
|
download_directory: Path | None = None
|
|
46
|
+
proxy_url: str | None = None
|
|
43
47
|
|
|
44
48
|
|
|
45
49
|
class PydollBrowserAdapter(BrowserAdapter):
|
|
@@ -61,6 +65,7 @@ class PydollBrowserAdapter(BrowserAdapter):
|
|
|
61
65
|
self._network_callback_ids: list[int] = []
|
|
62
66
|
self._inflight_requests: set[str] = set()
|
|
63
67
|
self._last_network_activity = time.monotonic()
|
|
68
|
+
self._proxy_extension_dir: Path | None = None
|
|
64
69
|
|
|
65
70
|
async def goto(self, url: str) -> None:
|
|
66
71
|
tab = await self._get_tab()
|
|
@@ -174,6 +179,18 @@ class PydollBrowserAdapter(BrowserAdapter):
|
|
|
174
179
|
await tab.take_screenshot(path)
|
|
175
180
|
return path
|
|
176
181
|
|
|
182
|
+
def configure_proxy(self, proxy_url: str) -> None:
|
|
183
|
+
"""Set an HTTP(S)/SOCKS5 proxy before Chromium is launched."""
|
|
184
|
+
|
|
185
|
+
if self._browser is not None or self._tab is not None:
|
|
186
|
+
raise PydollAdapterError("Proxy must be configured before the browser starts")
|
|
187
|
+
parsed = urlsplit(proxy_url)
|
|
188
|
+
if parsed.scheme not in {"http", "https", "socks5"} or not parsed.hostname:
|
|
189
|
+
raise PydollAdapterError("Proxy URL must include an HTTP(S) or SOCKS5 host")
|
|
190
|
+
if parsed.port is None or not 1 <= parsed.port <= 65535:
|
|
191
|
+
raise PydollAdapterError("Proxy URL must include a valid port")
|
|
192
|
+
self.config = replace(self.config, proxy_url=proxy_url)
|
|
193
|
+
|
|
177
194
|
async def close(self) -> None:
|
|
178
195
|
if self._tab is not None:
|
|
179
196
|
for callback_id in self._network_callback_ids:
|
|
@@ -191,6 +208,9 @@ class PydollBrowserAdapter(BrowserAdapter):
|
|
|
191
208
|
self._browser = None
|
|
192
209
|
self._network_tracking_enabled = False
|
|
193
210
|
self._inflight_requests.clear()
|
|
211
|
+
if self._proxy_extension_dir is not None:
|
|
212
|
+
shutil.rmtree(self._proxy_extension_dir, ignore_errors=True)
|
|
213
|
+
self._proxy_extension_dir = None
|
|
194
214
|
|
|
195
215
|
async def _get_tab(self) -> Any:
|
|
196
216
|
if self._tab is None:
|
|
@@ -220,10 +240,51 @@ class PydollBrowserAdapter(BrowserAdapter):
|
|
|
220
240
|
options.set_default_download_directory(str(self.config.download_directory))
|
|
221
241
|
for argument in self.config.arguments:
|
|
222
242
|
options.add_argument(argument)
|
|
243
|
+
if self.config.proxy_url is not None:
|
|
244
|
+
self._configure_proxy_options(options, self.config.proxy_url)
|
|
223
245
|
|
|
224
246
|
self._browser = Chrome(options=options)
|
|
225
247
|
self._tab = await self._browser.start()
|
|
226
248
|
|
|
249
|
+
def _configure_proxy_options(self, options: Any, proxy_url: str) -> None:
|
|
250
|
+
parsed = urlsplit(proxy_url)
|
|
251
|
+
host = parsed.hostname
|
|
252
|
+
port = parsed.port
|
|
253
|
+
if host is None or port is None:
|
|
254
|
+
raise PydollAdapterError("Proxy URL must include a host and port")
|
|
255
|
+
options.add_argument(f"--proxy-server={parsed.scheme}://{host}:{port}")
|
|
256
|
+
if parsed.username is not None or parsed.password is not None:
|
|
257
|
+
if parsed.username is None or parsed.password is None:
|
|
258
|
+
raise PydollAdapterError("Proxy URL must include both username and password")
|
|
259
|
+
self._proxy_extension_dir = self._create_proxy_auth_extension(
|
|
260
|
+
unquote(parsed.username),
|
|
261
|
+
unquote(parsed.password),
|
|
262
|
+
)
|
|
263
|
+
options.add_argument(f"--load-extension={self._proxy_extension_dir}")
|
|
264
|
+
|
|
265
|
+
@staticmethod
|
|
266
|
+
def _create_proxy_auth_extension(username: str, password: str) -> Path:
|
|
267
|
+
extension_dir = Path(tempfile.mkdtemp(prefix="crawlerflow-proxy-auth-"))
|
|
268
|
+
manifest = {
|
|
269
|
+
"manifest_version": 2,
|
|
270
|
+
"name": "Crawlerflow proxy authentication",
|
|
271
|
+
"version": "1.0",
|
|
272
|
+
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"],
|
|
273
|
+
"background": {"scripts": ["background.js"]},
|
|
274
|
+
}
|
|
275
|
+
background = (
|
|
276
|
+
"chrome.webRequest.onAuthRequired.addListener("
|
|
277
|
+
"function(details) { return {authCredentials: {username: "
|
|
278
|
+
f"{json.dumps(username)}, password: {json.dumps(password)}}}; }}, "
|
|
279
|
+
"{urls: ['<all_urls>']}, ['blocking']);"
|
|
280
|
+
)
|
|
281
|
+
(extension_dir / "manifest.json").write_text(
|
|
282
|
+
json.dumps(manifest),
|
|
283
|
+
encoding="utf-8",
|
|
284
|
+
)
|
|
285
|
+
(extension_dir / "background.js").write_text(background, encoding="utf-8")
|
|
286
|
+
return extension_dir
|
|
287
|
+
|
|
227
288
|
async def _query(self, selector: str, timeout_seconds: float | None = None) -> Any:
|
|
228
289
|
tab = await self._get_tab()
|
|
229
290
|
timeout = timeout_seconds or self.config.default_wait_timeout
|
|
@@ -22,6 +22,7 @@ class WorkflowContext:
|
|
|
22
22
|
workflow_name: str
|
|
23
23
|
base_path: Path
|
|
24
24
|
browser: BrowserAdapter | None = None
|
|
25
|
+
proxy_url: str | None = None
|
|
25
26
|
variables: dict[str, Any] = field(default_factory=dict)
|
|
26
27
|
outputs: dict[str, Any] = field(default_factory=dict)
|
|
27
28
|
cookies: dict[str, str] = field(default_factory=dict)
|
|
@@ -51,6 +52,7 @@ class WorkflowContext:
|
|
|
51
52
|
workflow_name=self.workflow_name,
|
|
52
53
|
base_path=self.base_path,
|
|
53
54
|
browser=self.browser,
|
|
55
|
+
proxy_url=self.proxy_url,
|
|
54
56
|
variables=dict(self.variables),
|
|
55
57
|
outputs=dict(self.outputs),
|
|
56
58
|
cookies=dict(self.cookies),
|
|
@@ -19,6 +19,7 @@ from crawlerflow.browser import BrowserResponse
|
|
|
19
19
|
from crawlerflow.engine.context import WorkflowContext
|
|
20
20
|
from crawlerflow.engine.registry import BaseStep, step
|
|
21
21
|
from crawlerflow.events import EventName
|
|
22
|
+
from crawlerflow.expressions.conditions import ConditionOperator
|
|
22
23
|
|
|
23
24
|
console = Console()
|
|
24
25
|
|
|
@@ -86,6 +87,137 @@ class SaveJsonStep(BaseStep[SaveJsonConfig]):
|
|
|
86
87
|
)
|
|
87
88
|
|
|
88
89
|
|
|
90
|
+
class JsonFilterRule(BaseModel):
|
|
91
|
+
model_config = ConfigDict(extra="forbid")
|
|
92
|
+
|
|
93
|
+
path: str = Field(min_length=1)
|
|
94
|
+
operator: ConditionOperator
|
|
95
|
+
value: Any = None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class FilterJsonConfig(BaseModel):
|
|
99
|
+
model_config = ConfigDict(extra="forbid")
|
|
100
|
+
|
|
101
|
+
data: Any
|
|
102
|
+
items_path: str | None = Field(default=None, min_length=1)
|
|
103
|
+
select: dict[str, str] | None = None
|
|
104
|
+
where: list[JsonFilterRule] = Field(default_factory=list)
|
|
105
|
+
save_as: str = Field(pattern=r"^[A-Za-z_]\w*$")
|
|
106
|
+
|
|
107
|
+
@field_validator("items_path")
|
|
108
|
+
@classmethod
|
|
109
|
+
def validate_items_path(cls, value: str | None) -> str | None:
|
|
110
|
+
if value is not None and any(not part.strip() for part in value.split(".")):
|
|
111
|
+
raise ValueError("filter_json items_path contains an empty segment")
|
|
112
|
+
return value
|
|
113
|
+
|
|
114
|
+
@field_validator("select")
|
|
115
|
+
@classmethod
|
|
116
|
+
def validate_select_paths(cls, value: dict[str, str] | None) -> dict[str, str] | None:
|
|
117
|
+
if value is None:
|
|
118
|
+
return None
|
|
119
|
+
for output_path, source_path in value.items():
|
|
120
|
+
if not output_path.strip() or any(
|
|
121
|
+
not part.strip() for part in output_path.split(".")
|
|
122
|
+
):
|
|
123
|
+
raise ValueError("filter_json select contains an invalid output path")
|
|
124
|
+
if not source_path.strip() or any(
|
|
125
|
+
not part.strip() for part in source_path.split(".")
|
|
126
|
+
):
|
|
127
|
+
raise ValueError("filter_json select contains an invalid source path")
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@step("filter_json")
|
|
132
|
+
class FilterJsonStep(BaseStep[FilterJsonConfig]):
|
|
133
|
+
"""Filter JSON objects and arrays before saving or passing them onward."""
|
|
134
|
+
|
|
135
|
+
config_model = FilterJsonConfig
|
|
136
|
+
_missing = object()
|
|
137
|
+
|
|
138
|
+
async def execute(self, context: WorkflowContext) -> Any:
|
|
139
|
+
filtered_data = deepcopy(self.config.data)
|
|
140
|
+
if self.config.items_path is None:
|
|
141
|
+
result = self._filter_root(context, filtered_data)
|
|
142
|
+
else:
|
|
143
|
+
target = self._lookup(filtered_data, self.config.items_path)
|
|
144
|
+
if not isinstance(target, list):
|
|
145
|
+
raise ValueError(
|
|
146
|
+
f"filter_json items_path must point to a list: {self.config.items_path}"
|
|
147
|
+
)
|
|
148
|
+
self._set_path(
|
|
149
|
+
filtered_data,
|
|
150
|
+
self.config.items_path,
|
|
151
|
+
self._filter_items(context, target),
|
|
152
|
+
)
|
|
153
|
+
result = filtered_data
|
|
154
|
+
|
|
155
|
+
context.outputs[self.config.save_as] = result
|
|
156
|
+
return result
|
|
157
|
+
|
|
158
|
+
def _filter_root(self, context: WorkflowContext, data: Any) -> Any:
|
|
159
|
+
if isinstance(data, list):
|
|
160
|
+
return self._filter_items(context, data)
|
|
161
|
+
if isinstance(data, dict):
|
|
162
|
+
if not self._matches(context, data):
|
|
163
|
+
return None
|
|
164
|
+
return self._select(data)
|
|
165
|
+
raise ValueError("filter_json data must be a JSON object or array")
|
|
166
|
+
|
|
167
|
+
def _filter_items(self, context: WorkflowContext, items: list[Any]) -> list[Any]:
|
|
168
|
+
result = []
|
|
169
|
+
for item in items:
|
|
170
|
+
if not isinstance(item, dict):
|
|
171
|
+
if self.config.where or self.config.select is not None:
|
|
172
|
+
raise ValueError("filter_json where/select requires object items")
|
|
173
|
+
result.append(item)
|
|
174
|
+
continue
|
|
175
|
+
if self._matches(context, item):
|
|
176
|
+
result.append(self._select(item))
|
|
177
|
+
return result
|
|
178
|
+
|
|
179
|
+
def _matches(self, context: WorkflowContext, item: dict[str, Any]) -> bool:
|
|
180
|
+
for rule in self.config.where:
|
|
181
|
+
value = self._lookup(item, rule.path)
|
|
182
|
+
if value is self._missing:
|
|
183
|
+
return False
|
|
184
|
+
if not context.condition_engine.evaluate(
|
|
185
|
+
{"left": value, "operator": rule.operator, "right": rule.value}
|
|
186
|
+
):
|
|
187
|
+
return False
|
|
188
|
+
return True
|
|
189
|
+
|
|
190
|
+
def _select(self, item: dict[str, Any]) -> dict[str, Any]:
|
|
191
|
+
if self.config.select is None:
|
|
192
|
+
return item
|
|
193
|
+
result: dict[str, Any] = {}
|
|
194
|
+
for output_path, source_path in self.config.select.items():
|
|
195
|
+
value = self._lookup(item, source_path)
|
|
196
|
+
if value is not self._missing:
|
|
197
|
+
self._set_path(result, output_path, deepcopy(value))
|
|
198
|
+
return result
|
|
199
|
+
|
|
200
|
+
@classmethod
|
|
201
|
+
def _lookup(cls, value: Any, path: str) -> Any:
|
|
202
|
+
current = value
|
|
203
|
+
for part in path.split("."):
|
|
204
|
+
if not isinstance(current, dict) or part not in current:
|
|
205
|
+
return cls._missing
|
|
206
|
+
current = current[part]
|
|
207
|
+
return current
|
|
208
|
+
|
|
209
|
+
@classmethod
|
|
210
|
+
def _set_path(cls, value: dict[str, Any], path: str, replacement: Any) -> None:
|
|
211
|
+
parts = path.split(".")
|
|
212
|
+
current = value
|
|
213
|
+
for part in parts[:-1]:
|
|
214
|
+
child = current.get(part)
|
|
215
|
+
if not isinstance(child, dict):
|
|
216
|
+
raise ValueError(f"filter_json path is not an object: {path}")
|
|
217
|
+
current = child
|
|
218
|
+
current[parts[-1]] = replacement
|
|
219
|
+
|
|
220
|
+
|
|
89
221
|
class ExtractJavascriptArrayConfig(BaseModel):
|
|
90
222
|
model_config = ConfigDict(extra="forbid")
|
|
91
223
|
content: str
|
|
@@ -939,7 +1071,10 @@ class EnrichHtmlLinksHttpStep(BaseStep[EnrichHtmlLinksHttpConfig]):
|
|
|
939
1071
|
cache: dict[str, str | None] = {}
|
|
940
1072
|
insertions: dict[int, list[tuple[str, str]]] = {}
|
|
941
1073
|
last_finished_at: float | None = None
|
|
942
|
-
async with httpx.AsyncClient(
|
|
1074
|
+
async with httpx.AsyncClient(
|
|
1075
|
+
timeout=self.config.timeout,
|
|
1076
|
+
proxy=context.proxy_url,
|
|
1077
|
+
) as client:
|
|
943
1078
|
for target_index, url in collector.links:
|
|
944
1079
|
if url not in cache:
|
|
945
1080
|
if last_finished_at is not None and self.config.delay > 0:
|
|
@@ -1092,6 +1227,7 @@ class ResolveLocationUrlStep(BaseStep[ResolveLocationUrlConfig]):
|
|
|
1092
1227
|
timeout=self.config.timeout,
|
|
1093
1228
|
follow_redirects=True,
|
|
1094
1229
|
max_redirects=self.config.max_redirects,
|
|
1230
|
+
proxy=context.proxy_url,
|
|
1095
1231
|
) as client:
|
|
1096
1232
|
response = await client.request(
|
|
1097
1233
|
"GET",
|
|
@@ -1307,6 +1443,7 @@ class EnrichJsonMapLocationsStep(BaseStep[EnrichJsonMapLocationsConfig]):
|
|
|
1307
1443
|
timeout=self.config.timeout,
|
|
1308
1444
|
follow_redirects=True,
|
|
1309
1445
|
max_redirects=self.config.max_redirects,
|
|
1446
|
+
proxy=context.proxy_url,
|
|
1310
1447
|
) as client:
|
|
1311
1448
|
|
|
1312
1449
|
async def resolve(url: str) -> tuple[str, dict[str, Any] | None]:
|
|
@@ -1435,7 +1572,10 @@ class HttpRequestStep(BaseStep[HttpRequestConfig]):
|
|
|
1435
1572
|
{"transport": "http", "method": method, "url": self.config.url},
|
|
1436
1573
|
)
|
|
1437
1574
|
try:
|
|
1438
|
-
async with httpx.AsyncClient(
|
|
1575
|
+
async with httpx.AsyncClient(
|
|
1576
|
+
timeout=self.config.timeout,
|
|
1577
|
+
proxy=context.proxy_url,
|
|
1578
|
+
) as client:
|
|
1439
1579
|
response = await client.request(
|
|
1440
1580
|
method,
|
|
1441
1581
|
self.config.url,
|
|
@@ -78,6 +78,37 @@ The original `map` field is retained. Successful objects receive numeric `latitu
|
|
|
78
78
|
field name to also store the resolved URL. `on_url_error` accepts `fail` or `continue`; continued
|
|
79
79
|
errors are available in `json_location_enrichment_errors` workflow storage.
|
|
80
80
|
|
|
81
|
+
## Filter JSON content
|
|
82
|
+
|
|
83
|
+
Use `filter_json` to filter an array with one or more `where` rules and optionally project only
|
|
84
|
+
selected fields. `items_path` points to a nested array; without it, the input itself can be an array
|
|
85
|
+
or a single object. Rules are combined with `AND` and use the same operators as the `if` step.
|
|
86
|
+
|
|
87
|
+
```yaml
|
|
88
|
+
- filter_json:
|
|
89
|
+
data: "{{response.body}}"
|
|
90
|
+
items_path: data.items
|
|
91
|
+
where:
|
|
92
|
+
- path: status
|
|
93
|
+
operator: eq
|
|
94
|
+
value: active
|
|
95
|
+
- path: score
|
|
96
|
+
operator: gte
|
|
97
|
+
value: 50
|
|
98
|
+
select:
|
|
99
|
+
id: id
|
|
100
|
+
name: profile.name
|
|
101
|
+
save_as: filtered_items
|
|
102
|
+
|
|
103
|
+
- save_json:
|
|
104
|
+
path: output/items.json
|
|
105
|
+
data: "{{filtered_items}}"
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
`select` maps output field paths to source field paths. Missing selected fields are omitted. Supported
|
|
109
|
+
operators are `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `contains`, `not_contains`, `in`, `not_in`,
|
|
110
|
+
`truthy`, and `falsy`.
|
|
111
|
+
|
|
81
112
|
## Extract a JavaScript array
|
|
82
113
|
|
|
83
114
|
`extract_javascript_array` reads an assigned array from HTML or JavaScript text without starting a
|
|
@@ -120,6 +120,56 @@ python -m crawlerflow list-plugins
|
|
|
120
120
|
|
|
121
121
|
The command displays each plugin name, import target, and owning Python distribution.
|
|
122
122
|
|
|
123
|
+
## Webshare proxy plugin
|
|
124
|
+
|
|
125
|
+
The repository also contains a separately installable `crawlerflow-webshare` plugin. It reads the
|
|
126
|
+
Webshare proxy list, selects one valid proxy randomly during workflow startup, and keeps that proxy
|
|
127
|
+
for the workflow's browser and direct HTTP requests:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
python -m pip install -e . -e examples/plugins/webshare
|
|
131
|
+
export WEBSHARE_API_KEY="your-webshare-api-key"
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
```yaml
|
|
135
|
+
plugins:
|
|
136
|
+
- name: webshare
|
|
137
|
+
settings:
|
|
138
|
+
mode: direct
|
|
139
|
+
country_codes: [US]
|
|
140
|
+
valid_only: true
|
|
141
|
+
proxy_retry:
|
|
142
|
+
enabled: true
|
|
143
|
+
attempts_per_proxy: 3
|
|
144
|
+
max_proxies: 5
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
See the complete example in
|
|
148
|
+
[`examples/plugins/webshare/workflow.yaml`](https://github.com/mehmetemineker/crawlerflow/blob/main/examples/plugins/webshare/workflow.yaml).
|
|
149
|
+
|
|
150
|
+
For an HTTP-only variant without Pydoll, use
|
|
151
|
+
[`cloudflare-challenge-http.yaml`](https://github.com/mehmetemineker/crawlerflow/blob/main/examples/plugins/capsolver-webshare/cloudflare-challenge-http.yaml).
|
|
152
|
+
It fetches the challenge HTML and sends the returned `cf_clearance` cookie with a second direct
|
|
153
|
+
HTTP request through the same proxy.
|
|
154
|
+
|
|
155
|
+
## Cloudflare Challenge example
|
|
156
|
+
|
|
157
|
+
The combined CapSolver and Webshare example uses CapSolver's `AntiCloudflareTask`. It opens the
|
|
158
|
+
target with a fixed user agent, passes the same selected Webshare proxy to CapSolver, applies the
|
|
159
|
+
returned clearance cookies, and reloads the target page:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
python -m pip install -e . -e examples/plugins/capsolver -e examples/plugins/webshare
|
|
163
|
+
export CAPSOLVER_API_KEY="your-capsolver-api-key"
|
|
164
|
+
export WEBSHARE_API_KEY="your-webshare-api-key"
|
|
165
|
+
python -m crawlerflow run examples/plugins/capsolver-webshare/cloudflare-challenge.yaml
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Replace the authorized target URL before running. Because the example enables
|
|
169
|
+
`include_credentials: true` to pass the proxy to CapSolver, protect the generated result file;
|
|
170
|
+
it can contain a `cf_clearance` cookie. See
|
|
171
|
+
[`cloudflare-challenge.yaml`](https://github.com/mehmetemineker/crawlerflow/blob/main/examples/plugins/capsolver-webshare/cloudflare-challenge.yaml).
|
|
172
|
+
|
|
123
173
|
## Example package
|
|
124
174
|
|
|
125
175
|
`examples/plugins/example` is a separately installable reference package. It registers a custom
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# Crawlerflow CapSolver plugin
|
|
2
|
+
|
|
3
|
+
This separately installable plugin exposes CapSolver's generic task API to Crawlerflow. The task
|
|
4
|
+
object is passed through without a task-type-specific schema, so all CapSolver task types can be
|
|
5
|
+
used by setting the documented `task.type` and parameters. New CapSolver task types do not require
|
|
6
|
+
a plugin release.
|
|
7
|
+
|
|
8
|
+
Install it from the repository root:
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
python -m pip install -e . -e examples/plugins/capsolver
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Set the API key outside the workflow file:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
export CAPSOLVER_API_KEY="your-capsolver-api-key"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
The complete example is available at [`workflow.yaml`](workflow.yaml). It uses an authorized
|
|
21
|
+
reCAPTCHA v3 test target and writes the raw CapSolver response to `output/capsolver-result.json`.
|
|
22
|
+
|
|
23
|
+
Enable the plugin in YAML:
|
|
24
|
+
|
|
25
|
+
```yaml
|
|
26
|
+
version: 1
|
|
27
|
+
|
|
28
|
+
workflow:
|
|
29
|
+
name: capsolver-example
|
|
30
|
+
|
|
31
|
+
plugins:
|
|
32
|
+
- capsolver
|
|
33
|
+
|
|
34
|
+
steps:
|
|
35
|
+
- capsolver_solve:
|
|
36
|
+
task:
|
|
37
|
+
type: ImageToTextTask
|
|
38
|
+
body: BASE64_IMAGE_DATA
|
|
39
|
+
save_as: solution
|
|
40
|
+
|
|
41
|
+
- save_json:
|
|
42
|
+
path: output/capsolver-result.json
|
|
43
|
+
data: "{{solution}}"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Steps
|
|
47
|
+
|
|
48
|
+
| Step | API operation |
|
|
49
|
+
| --- | --- |
|
|
50
|
+
| `capsolver_create_task` | `createTask` |
|
|
51
|
+
| `capsolver_get_task_result` | `getTaskResult` |
|
|
52
|
+
| `capsolver_solve` | `createTask` plus polling `getTaskResult` |
|
|
53
|
+
| `capsolver_get_token` | `getToken` |
|
|
54
|
+
| `capsolver_get_balance` | `getBalance` |
|
|
55
|
+
| `capsolver_get_state` | CapSolver's documented service-state operation |
|
|
56
|
+
| `capsolver_request` | Any documented CapSolver API endpoint |
|
|
57
|
+
|
|
58
|
+
`capsolver_solve` handles both synchronous results, such as recognition tasks, and asynchronous
|
|
59
|
+
token tasks. Polling defaults to three seconds and is capped at 120 attempts, matching the
|
|
60
|
+
documented `getTaskResult` limit. Plugin settings can override `base_url`, `app_id`, `timeout`,
|
|
61
|
+
`poll_interval`, and `max_poll_attempts`; step-level `app_id` and `callback_url` take precedence.
|
|
62
|
+
|
|
63
|
+
The plugin returns CapSolver responses unchanged, including the task-specific `solution` object.
|
|
64
|
+
The generic `task` and `capsolver_request` payloads preserve forward compatibility with all current
|
|
65
|
+
and future task types and API fields. It does not inject solutions into a browser page; browser/page
|
|
66
|
+
integration remains the responsibility of the consuming workflow and target-site authorization.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crawlerflow-capsolver"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CapSolver plugin for Crawlerflow"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"crawlerflow>=0.2.0",
|
|
13
|
+
"httpx>=0.27",
|
|
14
|
+
"pydantic>=2.8",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
[project.entry-points."crawlerflow.plugins"]
|
|
18
|
+
capsolver = "crawlerflow_capsolver:CapSolverPlugin"
|
|
19
|
+
|
|
20
|
+
[tool.hatch.build.targets.wheel]
|
|
21
|
+
packages = ["src/crawlerflow_capsolver"]
|