crawler4j-sdk 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.
@@ -0,0 +1,58 @@
1
+ """Crawler4j SDK - 任务脚本开发工具包。
2
+
3
+ 本包聚合导出模块开发所需的稳定契约与基类,供标准模块直接依赖。
4
+ 稳定契约类型由 `crawler4j-contracts` 提供,SDK 负责补充 TaskScript、
5
+ TaskFlow 与 ModuleAssembler 等开发入口。
6
+ """
7
+
8
+ from crawler4j_sdk._version import get_version
9
+ from crawler4j_sdk.base import TaskScript
10
+ from crawler4j_sdk.workflow import TaskFlow
11
+ from crawler4j_sdk.assembler import ModuleAssembler
12
+ from crawler4j_sdk.env_selector import EnvSelectorInfo, env_selector
13
+ from crawler4j_contracts import (
14
+ BBox,
15
+ ClickCaptchaDebugInfo,
16
+ ClickCaptchaMatchResult,
17
+ ClickCaptchaOrderedTarget,
18
+ EnvCandidate,
19
+ EnvAction,
20
+ ImageInput,
21
+ Point,
22
+ SliderCaptchaDebugInfo,
23
+ SliderCaptchaMatchResult,
24
+ TaskContext,
25
+ TaskResult,
26
+ TaskSignal,
27
+ TaskSignalAction,
28
+ ToolSpec,
29
+ ToolsCapability,
30
+ )
31
+
32
+ __version__ = get_version()
33
+
34
+ # 稳定导出列表(同 MAJOR 版本内冻结)
35
+ __all__ = [
36
+ # 核心契约类型
37
+ "TaskScript",
38
+ "TaskFlow",
39
+ "ModuleAssembler",
40
+ "env_selector",
41
+ "EnvSelectorInfo",
42
+ "TaskContext",
43
+ "TaskResult",
44
+ "TaskSignal",
45
+ "TaskSignalAction",
46
+ "EnvAction",
47
+ "ImageInput",
48
+ "BBox",
49
+ "Point",
50
+ "ToolSpec",
51
+ "ToolsCapability",
52
+ "SliderCaptchaDebugInfo",
53
+ "SliderCaptchaMatchResult",
54
+ "ClickCaptchaDebugInfo",
55
+ "ClickCaptchaOrderedTarget",
56
+ "ClickCaptchaMatchResult",
57
+ "EnvCandidate",
58
+ ]
@@ -0,0 +1,48 @@
1
+ """Version helpers backed by package metadata and pyproject.toml."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import tomllib
7
+ from importlib import metadata
8
+ from pathlib import Path
9
+
10
+
11
+ PACKAGE_NAME = "crawler4j-sdk"
12
+ PACKAGE_ROOT = Path(__file__).resolve().parents[1]
13
+ PYPROJECT_PATH = PACKAGE_ROOT / "pyproject.toml"
14
+ BASE_VERSION_RE = re.compile(r"^v?(?P<major>0|[1-9]\d*)\.(?P<minor>0|[1-9]\d*)\.(?P<patch>0|[1-9]\d*)")
15
+
16
+
17
+ def _load_version_from_metadata() -> str | None:
18
+ try:
19
+ return metadata.version(PACKAGE_NAME)
20
+ except metadata.PackageNotFoundError:
21
+ return None
22
+
23
+
24
+ def _load_version_from_pyproject() -> str:
25
+ with PYPROJECT_PATH.open("rb") as f:
26
+ pyproject = tomllib.load(f)
27
+ return str(pyproject["project"]["version"])
28
+
29
+
30
+ def get_version() -> str:
31
+ """Resolve the package version from build metadata or the source pyproject."""
32
+ return _load_version_from_metadata() or _load_version_from_pyproject()
33
+
34
+
35
+ def get_base_version(version: str | None = None) -> str:
36
+ """Extract major.minor.patch from the declared version string."""
37
+ resolved = version or get_version()
38
+ match = BASE_VERSION_RE.match(resolved.strip())
39
+ if not match:
40
+ raise ValueError(f"Unsupported version format: {resolved}")
41
+ return f"{match.group('major')}.{match.group('minor')}.{match.group('patch')}"
42
+
43
+
44
+ def get_compatible_dependency_spec() -> str:
45
+ """Build the default scaffold dependency range for the current SDK version."""
46
+ base_version = get_base_version()
47
+ major = int(base_version.split(".", 1)[0])
48
+ return f"{PACKAGE_NAME}>={base_version},<{major + 1}.0.0"
@@ -0,0 +1,216 @@
1
+ """Module Assembler for Crawler4j modules.
2
+
3
+ This module provides the ModuleAssembler class which handles automatic discovery
4
+ of tasks and workflows within a module, and provides a standardized entry point.
5
+ """
6
+
7
+ import importlib
8
+ import inspect
9
+ import logging
10
+ from pathlib import Path
11
+ from pkgutil import iter_modules
12
+ from typing import Any, Callable, Dict, Optional, Type
13
+
14
+ import yaml
15
+
16
+ from crawler4j_sdk.base import TaskScript
17
+ from crawler4j_sdk.context import TaskContext
18
+ from crawler4j_sdk.env_selector import EnvSelectorInfo, get_env_selector_info, invoke_env_selector
19
+ from crawler4j_sdk.result import TaskResult
20
+ from crawler4j_sdk.workflow import TaskFlow
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ class ModuleAssembler:
26
+ """Handles discovery and execution of module components."""
27
+
28
+ def __init__(self, package_root: Path, module_name: str, default_workflow: str = ""):
29
+ self.package_root = package_root
30
+ self.module_name = module_name
31
+ self.default_workflow = default_workflow
32
+ self.task_scripts: Dict[str, Type[TaskScript]] = {}
33
+ self.workflows: Dict[str, Type[TaskFlow]] = {}
34
+ self.discovery_errors: Dict[str, str] = {}
35
+
36
+ # Hooks that can be overridden by module_runtime.py
37
+ self.hooks: Dict[str, Callable] = {}
38
+ self.env_selectors: Dict[str, Callable] = {}
39
+ self.env_selector_infos: Dict[str, EnvSelectorInfo] = {}
40
+
41
+ self._load_manifest()
42
+ self._discover()
43
+ self._load_runtime_extensions()
44
+
45
+ def _load_manifest(self):
46
+ """Load default_workflow from module.yaml if not set."""
47
+ manifest_path = self.package_root / "module.yaml"
48
+ if manifest_path.exists():
49
+ try:
50
+ with open(manifest_path, "r", encoding="utf-8") as f:
51
+ manifest = yaml.safe_load(f)
52
+ if manifest and not self.default_workflow:
53
+ workflows = manifest.get("workflows", [])
54
+ if workflows and isinstance(workflows, list):
55
+ self.default_workflow = workflows[0].get("name", "")
56
+ except Exception as e:
57
+ logger.warning(f"Failed to load manifest at {manifest_path}: {e}")
58
+
59
+ def _load_registry(self, subpackage: str, base_cls: type) -> Dict[str, type]:
60
+ registry: Dict[str, type] = {}
61
+ package_dir = self.package_root / subpackage
62
+ if not package_dir.exists():
63
+ return registry
64
+
65
+ full_package_name = f"{self.module_name}.{subpackage}"
66
+ for module_info in iter_modules([str(package_dir)]):
67
+ if module_info.name.startswith("_"):
68
+ continue
69
+ import_target = f"{full_package_name}.{module_info.name}"
70
+ try:
71
+ module = importlib.import_module(import_target)
72
+ for attr_name in dir(module):
73
+ candidate = getattr(module, attr_name)
74
+ if (
75
+ inspect.isclass(candidate)
76
+ and issubclass(candidate, base_cls)
77
+ and candidate is not base_cls
78
+ ):
79
+ key = getattr(candidate, "name", "") or module_info.name
80
+ registry[key] = candidate
81
+ except Exception as exc:
82
+ error_message = (
83
+ f"Failed to import {import_target} during {subpackage} discovery: "
84
+ f"{exc.__class__.__name__}: {exc}"
85
+ )
86
+ self.discovery_errors[module_info.name] = error_message
87
+ logger.exception(error_message)
88
+ continue
89
+ return registry
90
+
91
+ def _discover(self):
92
+ """Automatically discover tasks and workflows."""
93
+ self.task_scripts = self._load_registry("tasks", TaskScript)
94
+ self.workflows = self._load_registry("workflows", TaskFlow)
95
+
96
+ def _load_runtime_extensions(self):
97
+ """Load optional module_runtime.py extensions."""
98
+ runtime_path = self.package_root / "module_runtime.py"
99
+ if runtime_path.exists():
100
+ try:
101
+ runtime_module = importlib.import_module(f"{self.module_name}.module_runtime")
102
+
103
+ # Load hooks if present
104
+ hook_names = [
105
+ "prepare_env",
106
+ "init_env",
107
+ "before_run",
108
+ "on_success",
109
+ "on_failure",
110
+ "on_timeout",
111
+ "on_cleanup",
112
+ ]
113
+ for name in hook_names:
114
+ if hasattr(runtime_module, name):
115
+ self.hooks[name] = getattr(runtime_module, name)
116
+
117
+ # Allow overriding default_workflow
118
+ if hasattr(runtime_module, "DEFAULT_WORKFLOW"):
119
+ self.default_workflow = getattr(runtime_module, "DEFAULT_WORKFLOW")
120
+
121
+ # Allow manual registration/override of tasks/workflows
122
+ if hasattr(runtime_module, "TASK_SCRIPTS"):
123
+ self.task_scripts.update(getattr(runtime_module, "TASK_SCRIPTS"))
124
+ if hasattr(runtime_module, "WORKFLOWS"):
125
+ self.workflows.update(getattr(runtime_module, "WORKFLOWS"))
126
+
127
+ for attr_name in dir(runtime_module):
128
+ candidate = getattr(runtime_module, attr_name)
129
+ info = get_env_selector_info(candidate)
130
+ if not info:
131
+ continue
132
+ self.env_selectors[info.name] = candidate
133
+ self.env_selector_infos[info.name] = info
134
+
135
+ except Exception as e:
136
+ logger.exception(f"Failed to load runtime extensions: {e}")
137
+
138
+ @staticmethod
139
+ def _normalize_result_payload(result: object) -> TaskResult:
140
+ if isinstance(result, TaskResult):
141
+ return result
142
+ if result is None:
143
+ return TaskResult.ok()
144
+ if isinstance(result, dict):
145
+ return TaskResult.ok(data=result)
146
+ return TaskResult.ok(data={"value": result})
147
+
148
+ async def _run_task_script(self, script_cls: Type[TaskScript], ctx: TaskContext) -> TaskResult:
149
+ script = script_cls()
150
+ result = await script.execute(ctx)
151
+ return self._normalize_result_payload(result)
152
+
153
+ async def _run_task_flow(self, flow_cls: Type[TaskFlow], ctx: TaskContext) -> TaskResult:
154
+ flow = flow_cls()
155
+ result = await flow.run(ctx)
156
+ if result is None:
157
+ return TaskResult.ok(data=dict(ctx.state))
158
+ return self._normalize_result_payload(result)
159
+
160
+ async def _subtask_executor(self, task_name: str, ctx: TaskContext) -> TaskResult:
161
+ if task_name not in self.task_scripts:
162
+ raise ValueError(f"Unknown subtask: {task_name}")
163
+ return await self._run_task_script(self.task_scripts[task_name], ctx)
164
+
165
+ @staticmethod
166
+ def _resolve_workflow_name(context: TaskContext, default_workflow: str) -> str:
167
+ """Resolve workflow selection from runtime only."""
168
+ runtime = getattr(context, "runtime", None)
169
+ if isinstance(runtime, dict):
170
+ workflow_name = runtime.get("workflow")
171
+ if isinstance(workflow_name, str) and workflow_name.strip():
172
+ return workflow_name
173
+ return default_workflow
174
+
175
+ async def run(self, context: TaskContext) -> TaskResult:
176
+ """Module execution entry point."""
177
+ workflow_name = self._resolve_workflow_name(context, self.default_workflow)
178
+
179
+ if workflow_name in self.workflows:
180
+ context._subtask_executor = self._subtask_executor
181
+ return await self._run_task_flow(self.workflows[workflow_name], context)
182
+
183
+ if workflow_name in self.task_scripts:
184
+ return await self._run_task_script(self.task_scripts[workflow_name], context)
185
+
186
+ discovery_error = self.discovery_errors.get(workflow_name)
187
+ if discovery_error:
188
+ raise ValueError(f"Workflow or task not found: {workflow_name}. {discovery_error}")
189
+
190
+ raise ValueError(f"Workflow or task not found: {workflow_name}")
191
+
192
+ def get_hook(self, name: str) -> Optional[Callable]:
193
+ """Get a registered hook by name."""
194
+ return self.hooks.get(name)
195
+
196
+ def get_env_selector(self, name: str) -> Optional[Callable]:
197
+ """Get a registered env selector callback by name."""
198
+ return self.env_selectors.get(name)
199
+
200
+ def list_env_selectors(self) -> list[EnvSelectorInfo]:
201
+ """List registered env selectors in stable name order."""
202
+ return [
203
+ self.env_selector_infos[name]
204
+ for name in sorted(self.env_selector_infos)
205
+ ]
206
+
207
+ async def run_env_selector(
208
+ self,
209
+ name: str,
210
+ context: TaskContext,
211
+ candidates: list[Any],
212
+ ) -> Any:
213
+ selector = self.get_env_selector(name)
214
+ if not selector:
215
+ raise ValueError(f"Env selector not found: {name}")
216
+ return await invoke_env_selector(selector, context, candidates)
crawler4j_sdk/base.py ADDED
@@ -0,0 +1,90 @@
1
+ """TaskScript 基类定义。
2
+
3
+ 本模块定义了 Crawler4j SDK 的核心契约之一:TaskScript(原子任务基类)。
4
+ 所有任务脚本必须继承此类并实现 execute 方法。
5
+
6
+ 稳定契约 (Stable API - 同 MAJOR 版本内冻结):
7
+ - 类属性: name, display_name, description, default_config
8
+ - 方法: execute
9
+
10
+ 参考规格: docs/02-requirements/reference-srs/06-sdk/06-1-taskscript.md
11
+ """
12
+
13
+ from abc import ABC, abstractmethod
14
+ from typing import TYPE_CHECKING, Any
15
+
16
+ if TYPE_CHECKING:
17
+ from crawler4j_sdk.context import TaskContext
18
+ from crawler4j_sdk.result import TaskResult
19
+
20
+
21
+ class TaskScript(ABC):
22
+ """原子任务基类。
23
+
24
+ TaskScript 是最小可执行单元:给定上下文与配置,执行一次业务动作并返回结构化结果。
25
+
26
+ 类属性 (Stable):
27
+ name: 任务唯一标识符 (MUST),用于调度、引用和追溯。
28
+ display_name: 面向 UI/日志的可读名称 (SHOULD)。
29
+ description: 简要说明 (SHOULD)。
30
+ default_config: 默认配置字典 (MAY),运行时会与外部配置深合并。
31
+
32
+ 并发语义:
33
+ - 单个 TaskScript 实例不要求线程安全
34
+ - 框架不得并发调用同一实例的 execute
35
+ - 每次执行应使用新实例,避免隐式共享状态
36
+
37
+ 示例:
38
+ >>> from crawler4j_sdk import TaskScript, TaskContext, TaskResult
39
+ >>>
40
+ >>> class LoginTask(TaskScript):
41
+ ... name = "login"
42
+ ... display_name = "用户登录"
43
+ ... description = "执行网站登录流程"
44
+ ... default_config = {"timeout": 30}
45
+ ...
46
+ ... async def execute(self, ctx: TaskContext) -> TaskResult:
47
+ ... await ctx.page.goto("https://example.com/login")
48
+ ... # ... 登录逻辑
49
+ ... return TaskResult.ok(message="登录成功")
50
+ """
51
+
52
+ # === 类属性 (Stable) ===
53
+
54
+ name: str = ""
55
+ """任务唯一标识符 (MUST)。用于调度、引用和追溯,应保持稳定。"""
56
+
57
+ display_name: str = ""
58
+ """面向 UI/日志的可读名称 (SHOULD)。"""
59
+
60
+ description: str = ""
61
+ """任务简要说明 (SHOULD)。"""
62
+
63
+ default_config: dict[str, Any] = {}
64
+ """默认配置字典 (MAY)。运行时会与外部配置深合并,外部配置优先。"""
65
+
66
+ # === 主执行方法 (Stable) ===
67
+
68
+ @abstractmethod
69
+ async def execute(self, ctx: "TaskContext") -> "TaskResult":
70
+ """执行任务的主方法 (MUST 实现)。
71
+
72
+ 这是 TaskScript 的核心方法,子类必须实现。
73
+ 方法应完成一次完整的业务动作并返回结构化结果。
74
+
75
+ Args:
76
+ ctx: 任务执行上下文,提供 page、config、runtime、logger、tools 等能力。
77
+ 脚本应只通过 ctx 获取框架能力,不得直接耦合 Core 内部对象。
78
+
79
+ Returns:
80
+ TaskResult: 执行结果。成功使用 TaskResult.ok(),失败使用 TaskResult.fail()。
81
+
82
+ Raises:
83
+ Exception: 允许抛出异常,由宿主 ATM 统一处理并映射到失败生命周期。
84
+
85
+ Note:
86
+ - 业务失败(可预期)建议返回 TaskResult.fail()
87
+ - 不可预期异常可直接抛出,由运行时处理
88
+ - 应避免长时间阻塞调用,使用 async I/O
89
+ """
90
+ raise NotImplementedError
@@ -0,0 +1,13 @@
1
+ """CLI 命令模块。"""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def main() -> int:
7
+ """惰性导入 CLI 入口,避免 `python -m ...commands` 时重复预加载。"""
8
+ from crawler4j_sdk.cli.commands import main as commands_main
9
+
10
+ return commands_main()
11
+
12
+
13
+ __all__ = ["main"]