self-healing-scraper 0.1.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.
Files changed (32) hide show
  1. self_healing_scraper-0.1.0/.github/workflows/ci.yml +18 -0
  2. self_healing_scraper-0.1.0/.gitignore +19 -0
  3. self_healing_scraper-0.1.0/.pre-commit-config.yaml +24 -0
  4. self_healing_scraper-0.1.0/.python-version +1 -0
  5. self_healing_scraper-0.1.0/PKG-INFO +12 -0
  6. self_healing_scraper-0.1.0/README.md +82 -0
  7. self_healing_scraper-0.1.0/pyproject.toml +69 -0
  8. self_healing_scraper-0.1.0/src/self_healing_scraper/__init__.py +22 -0
  9. self_healing_scraper-0.1.0/src/self_healing_scraper/agent/__init__.py +1 -0
  10. self_healing_scraper-0.1.0/src/self_healing_scraper/agent/create_parser.py +69 -0
  11. self_healing_scraper-0.1.0/src/self_healing_scraper/agent/llm.py +77 -0
  12. self_healing_scraper-0.1.0/src/self_healing_scraper/agent/normalize.py +59 -0
  13. self_healing_scraper-0.1.0/src/self_healing_scraper/agent/repair_parser.py +61 -0
  14. self_healing_scraper-0.1.0/src/self_healing_scraper/asyncio_compat.py +41 -0
  15. self_healing_scraper-0.1.0/src/self_healing_scraper/domain.py +37 -0
  16. self_healing_scraper-0.1.0/src/self_healing_scraper/fetch/__init__.py +3 -0
  17. self_healing_scraper-0.1.0/src/self_healing_scraper/fetch/crawler.py +84 -0
  18. self_healing_scraper-0.1.0/src/self_healing_scraper/models.py +88 -0
  19. self_healing_scraper-0.1.0/src/self_healing_scraper/runtime/__init__.py +4 -0
  20. self_healing_scraper-0.1.0/src/self_healing_scraper/runtime/executor.py +131 -0
  21. self_healing_scraper-0.1.0/src/self_healing_scraper/runtime/validators.py +280 -0
  22. self_healing_scraper-0.1.0/src/self_healing_scraper/scrape.py +179 -0
  23. self_healing_scraper-0.1.0/src/self_healing_scraper/settings.py +29 -0
  24. self_healing_scraper-0.1.0/src/self_healing_scraper/store.py +221 -0
  25. self_healing_scraper-0.1.0/tests/conftest.py +119 -0
  26. self_healing_scraper-0.1.0/tests/test_executor.py +38 -0
  27. self_healing_scraper-0.1.0/tests/test_memory_store.py +43 -0
  28. self_healing_scraper-0.1.0/tests/test_normalize.py +33 -0
  29. self_healing_scraper-0.1.0/tests/test_registry.py +40 -0
  30. self_healing_scraper-0.1.0/tests/test_scrape_loop.py +83 -0
  31. self_healing_scraper-0.1.0/tests/test_validators.py +42 -0
  32. self_healing_scraper-0.1.0/uv.lock +2859 -0
@@ -0,0 +1,18 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v5
14
+ with:
15
+ python-version: "3.12"
16
+ - run: uv sync --group dev
17
+ - run: uv run pre-commit run --all-files
18
+ - run: uv run pytest
@@ -0,0 +1,19 @@
1
+ .env
2
+ __pycache__/
3
+ .DS_Store
4
+ .venv/
5
+ .nimbella
6
+ .deployed
7
+ logs/
8
+
9
+ # uv / tooling
10
+ .ruff_cache/
11
+ .pytest_cache/
12
+ .mypy_cache/
13
+ .ty_cache/
14
+ .coverage
15
+ htmlcov/
16
+ dist/
17
+ build/
18
+ *.egg-info/
19
+ test_results/
@@ -0,0 +1,24 @@
1
+ exclude: ^(\.cursor/|\.venv/)
2
+
3
+ repos:
4
+ - repo: https://github.com/pre-commit/pre-commit-hooks
5
+ rev: v6.0.0
6
+ hooks:
7
+ - id: trailing-whitespace
8
+ - id: end-of-file-fixer
9
+ - id: check-yaml
10
+ - id: check-toml
11
+ - id: check-added-large-files
12
+ args: ["--maxkb=1024"]
13
+
14
+ - repo: https://github.com/astral-sh/ruff-pre-commit
15
+ rev: v0.16.0
16
+ hooks:
17
+ - id: ruff-check
18
+ args: [--fix]
19
+ - id: ruff-format
20
+
21
+ - repo: https://github.com/astral-sh/ty-pre-commit
22
+ rev: v0.0.63
23
+ hooks:
24
+ - id: ty
@@ -0,0 +1 @@
1
+ 3.12
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: self-healing-scraper
3
+ Version: 0.1.0
4
+ Summary: Generic self-healing declarative web scraper engine
5
+ Requires-Python: >=3.12
6
+ Requires-Dist: beautifulsoup4>=4.15.0
7
+ Requires-Dist: crawl4ai>=0.7.0
8
+ Requires-Dist: openai>=2.48.0
9
+ Requires-Dist: pydantic-settings>=2.14.2
10
+ Requires-Dist: pydantic<3,>=2.0.0
11
+ Requires-Dist: python-dotenv>=1.0.0
12
+ Requires-Dist: typing-extensions>=4.5.0
@@ -0,0 +1,82 @@
1
+ # self-healing-scraper
2
+
3
+ Generic self-healing declarative web scraper. Pass a URL and a domain config; the engine looks up a stored parser by URL regex, creates one with AI if missing, executes CSS extractors, validates items, and repairs the parser when checks fail.
4
+
5
+ Persistence is **not** included — inject a [`ParserStore`](src/self_healing_scraper/store.py) implementation (SQL, memory, etc.).
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ # From GitHub
11
+ uv add "self-healing-scraper @ git+https://github.com/SollalF/self-healing-scraper"
12
+
13
+ # Local path (development)
14
+ uv add --editable ../self-healing-scraper
15
+
16
+ # Browser deps used by Crawl4AI
17
+ uv run playwright install chromium
18
+ # or: uv run crawl4ai-setup
19
+ ```
20
+
21
+ ## Quick example
22
+
23
+ ```python
24
+ import asyncio
25
+ from self_healing_scraper import scrape_url, ScrapeDomain, DomainPrompts
26
+ from self_healing_scraper.store import InMemoryParserStore
27
+
28
+ domain = ScrapeDomain(
29
+ prompts=DomainPrompts(
30
+ create_system="...", # your domain prompts
31
+ create_user_template="...",
32
+ repair_system="...",
33
+ repair_user_template="...",
34
+ ),
35
+ default_required_fields=["title", "url"],
36
+ )
37
+
38
+ async def main() -> None:
39
+ store = InMemoryParserStore()
40
+ result = await scrape_url(
41
+ "https://example.com/list",
42
+ store=store,
43
+ domain=domain,
44
+ )
45
+ print(result.items)
46
+
47
+ asyncio.run(main())
48
+ ```
49
+
50
+ ## Configuration
51
+
52
+ | Env | Default | Purpose |
53
+ |-----|---------|---------|
54
+ | `LLM_API_KEY` | — | Required to create/repair parsers |
55
+ | `LLM_MODEL` | `gpt-4o` | Model for parser agent |
56
+ | `LLM_BASE_URL` | — | OpenAI-compatible API base |
57
+ | `MAX_REPAIR_ATTEMPTS` | `3` | Self-heal loop limit |
58
+ | `CRAWL_TIMEOUT_MS` | `30000` | Page load timeout |
59
+ | `PAGE_SAMPLE_CHARS` | `12000` | HTML sample size sent to the AI |
60
+
61
+ ## Layout
62
+
63
+ ```
64
+ src/self_healing_scraper/
65
+ scrape.py # heal loop
66
+ store.py # ParserStore Protocol + helpers
67
+ domain.py # ScrapeDomain / DomainPrompts
68
+ fetch/ # Crawl4AI wrapper
69
+ runtime/ # executor + core validators
70
+ agent/ # create / repair LLM
71
+ tests/
72
+ ```
73
+
74
+ ## Develop
75
+
76
+ ```bash
77
+ uv sync
78
+ uv run pre-commit install
79
+ uv run pytest
80
+ ```
81
+
82
+ CI runs `pre-commit` (ruff + ty + basic hooks) and pytest on every push/PR.
@@ -0,0 +1,69 @@
1
+ [project]
2
+ name = "self-healing-scraper"
3
+ version = "0.1.0"
4
+ description = "Generic self-healing declarative web scraper engine"
5
+ requires-python = ">=3.12"
6
+ dependencies = [
7
+ "beautifulsoup4>=4.15.0",
8
+ "crawl4ai>=0.7.0",
9
+ "openai>=2.48.0",
10
+ "pydantic>=2.0.0,<3",
11
+ "pydantic-settings>=2.14.2",
12
+ "python-dotenv>=1.0.0",
13
+ "typing-extensions>=4.5.0",
14
+ ]
15
+
16
+ [build-system]
17
+ requires = ["hatchling"]
18
+ build-backend = "hatchling.build"
19
+
20
+ [tool.hatch.build.targets.wheel]
21
+ packages = ["src/self_healing_scraper"]
22
+
23
+ [dependency-groups]
24
+ dev = [
25
+ "pre-commit>=4.6.1",
26
+ "pytest>=9.1.1",
27
+ "pytest-asyncio>=0.25.0",
28
+ "ruff>=0.16.0",
29
+ "ty>=0.0.63",
30
+ ]
31
+
32
+ [tool.uv]
33
+ package = true
34
+
35
+ [tool.ruff]
36
+ target-version = "py312"
37
+ line-length = 88
38
+ src = ["src"]
39
+
40
+ [tool.ruff.lint]
41
+ select = [
42
+ "E",
43
+ "F",
44
+ "I",
45
+ "UP",
46
+ "B",
47
+ "C4",
48
+ "SIM",
49
+ ]
50
+ ignore = [
51
+ "E501",
52
+ ]
53
+
54
+ [tool.ruff.lint.isort]
55
+ known-first-party = ["self_healing_scraper"]
56
+
57
+ [tool.ruff.format]
58
+ quote-style = "double"
59
+ indent-style = "space"
60
+
61
+ [tool.ty.environment]
62
+ root = ["./src"]
63
+ python-version = "3.12"
64
+
65
+ [tool.pytest.ini_options]
66
+ pythonpath = ["src"]
67
+ testpaths = ["tests"]
68
+ asyncio_mode = "auto"
69
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,22 @@
1
+ """Generic self-healing declarative web scraper engine."""
2
+
3
+ from self_healing_scraper.domain import DomainPrompts, ScrapeDomain
4
+ from self_healing_scraper.models import ScrapeResult
5
+ from self_healing_scraper.scrape import normalize_url, scrape_url, scrape_urls
6
+ from self_healing_scraper.store import (
7
+ InMemoryParserStore,
8
+ ParserStore,
9
+ best_parser_match,
10
+ )
11
+
12
+ __all__ = [
13
+ "DomainPrompts",
14
+ "InMemoryParserStore",
15
+ "ParserStore",
16
+ "ScrapeDomain",
17
+ "ScrapeResult",
18
+ "best_parser_match",
19
+ "normalize_url",
20
+ "scrape_url",
21
+ "scrape_urls",
22
+ ]
@@ -0,0 +1 @@
1
+ """LLM-assisted parser create / repair."""
@@ -0,0 +1,69 @@
1
+ """AI-assisted parser creation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from urllib.parse import urlparse
6
+
7
+ from self_healing_scraper.agent.llm import complete_json
8
+ from self_healing_scraper.agent.normalize import normalize_generated_payload
9
+ from self_healing_scraper.domain import ScrapeDomain
10
+ from self_healing_scraper.models import GeneratedParser, PageContent, PageKind
11
+ from self_healing_scraper.settings import Settings, get_settings
12
+
13
+
14
+ def _page_kind_hint(url: str) -> str:
15
+ path = urlparse(url).path.rstrip("/")
16
+ # Heuristic only — the model may override.
17
+ articleish = (
18
+ any(part.isdigit() and len(part) >= 4 for part in path.split("/") if part)
19
+ or path.count("/") >= 3
20
+ )
21
+ return PageKind.ARTICLE.value if articleish else PageKind.LISTING.value
22
+
23
+
24
+ def _html_sample(html: str, limit: int) -> str:
25
+ """Prefer the main content region so chrome/nav does not eat the sample budget."""
26
+ lowered = html.lower()
27
+ for marker in ("<main", 'role="main"', 'id="content"', 'class="content"'):
28
+ idx = lowered.find(marker)
29
+ if idx != -1:
30
+ start = max(0, idx - 200)
31
+ return html[start : start + limit]
32
+ # Fall back to the middle/end of the document where listings often live.
33
+ if len(html) > limit:
34
+ return html[-(limit):]
35
+ return html
36
+
37
+
38
+ async def create_parser(
39
+ page: PageContent,
40
+ domain: ScrapeDomain,
41
+ settings: Settings | None = None,
42
+ ) -> GeneratedParser:
43
+ cfg = settings or get_settings()
44
+ # Markdown from Crawl4AI is usually denser for listings than truncated HTML.
45
+ markdown = (page.markdown or "")[: cfg.page_sample_chars]
46
+ sample = _html_sample(page.html, min(cfg.page_sample_chars, 8000))
47
+ payload = await complete_json(
48
+ system=domain.prompts.create_system,
49
+ user=domain.prompts.create_user_template.format(
50
+ url=page.url,
51
+ page_kind_hint=_page_kind_hint(page.url),
52
+ html_sample=sample,
53
+ markdown_sample=markdown,
54
+ ),
55
+ settings=cfg,
56
+ )
57
+ return GeneratedParser.model_validate(
58
+ normalize_generated_payload(
59
+ payload,
60
+ known_checks=known_checks_for_domain(domain),
61
+ default_required_fields=domain.default_required_fields,
62
+ )
63
+ )
64
+
65
+
66
+ def known_checks_for_domain(domain: ScrapeDomain) -> frozenset[str]:
67
+ from self_healing_scraper.runtime.validators import CORE_KNOWN_CHECKS
68
+
69
+ return CORE_KNOWN_CHECKS | domain.known_checks | frozenset(domain.extra_validators)
@@ -0,0 +1,77 @@
1
+ """Thin OpenAI-compatible LLM client wrapper."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ from typing import Any
8
+
9
+ from self_healing_scraper.settings import Settings, get_settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/v1"
14
+ MOONSHOT_BASE_URL = "https://api.moonshot.ai/v1"
15
+
16
+
17
+ def resolve_llm_base_url(api_key: str, configured: str) -> str | None:
18
+ """Pick the right OpenAI-compatible base URL for the key type."""
19
+ key = api_key.strip()
20
+ base = configured.strip()
21
+ if key.startswith("sk-kimi-"):
22
+ # Kimi Code keys are rejected by api.moonshot.ai
23
+ if not base or "moonshot.ai" in base:
24
+ return KIMI_CODING_BASE_URL
25
+ return base
26
+ return base or None
27
+
28
+
29
+ def _default_headers(base_url: str | None) -> dict[str, str] | None:
30
+ if base_url and "api.kimi.com" in base_url:
31
+ # Coding endpoint gates access behind a recognized agent UA.
32
+ return {"User-Agent": "KimiCLI/1.0"}
33
+ return None
34
+
35
+
36
+ async def complete_json(
37
+ *,
38
+ system: str,
39
+ user: str,
40
+ settings: Settings | None = None,
41
+ ) -> dict[str, Any]:
42
+ cfg = settings or get_settings()
43
+ if not cfg.llm_api_key:
44
+ raise RuntimeError("LLM_API_KEY is required to create or repair parsers")
45
+
46
+ from openai import AsyncOpenAI
47
+
48
+ base_url = resolve_llm_base_url(cfg.llm_api_key, cfg.llm_base_url)
49
+ headers = _default_headers(base_url)
50
+ logger.info("LLM request model=%s base_url=%s", cfg.llm_model, base_url)
51
+
52
+ client = AsyncOpenAI(
53
+ api_key=cfg.llm_api_key,
54
+ base_url=base_url,
55
+ default_headers=headers,
56
+ )
57
+ create_kwargs: dict[str, Any] = {
58
+ "model": cfg.llm_model,
59
+ "response_format": {"type": "json_object"},
60
+ "messages": [
61
+ {"role": "system", "content": system},
62
+ {"role": "user", "content": user},
63
+ ],
64
+ }
65
+ # Some models (e.g. kimi-k3) only accept temperature=1.
66
+ if "kimi" in cfg.llm_model.lower():
67
+ create_kwargs["temperature"] = 1
68
+ else:
69
+ create_kwargs["temperature"] = 0.2
70
+
71
+ response = await client.chat.completions.create(**create_kwargs)
72
+ content = response.choices[0].message.content or "{}"
73
+ logger.debug("LLM raw response: %s", content[:500])
74
+ data = json.loads(content)
75
+ if not isinstance(data, dict):
76
+ raise ValueError("LLM did not return a JSON object")
77
+ return data
@@ -0,0 +1,59 @@
1
+ """Normalize LLM parser payloads before Pydantic validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from self_healing_scraper.runtime.validators import CORE_KNOWN_CHECKS
8
+
9
+
10
+ def normalize_generated_payload(
11
+ payload: dict[str, Any],
12
+ *,
13
+ known_checks: frozenset[str] | None = None,
14
+ default_required_fields: list[str] | None = None,
15
+ ) -> dict[str, Any]:
16
+ """Drop null/invalid field extractors and coerce loose shapes from the LLM."""
17
+ allowed = known_checks if known_checks is not None else CORE_KNOWN_CHECKS
18
+ required = default_required_fields or ["title", "url"]
19
+ data = dict(payload)
20
+ definition = data.get("definition")
21
+ if isinstance(definition, dict):
22
+ fields = definition.get("fields")
23
+ if isinstance(fields, dict):
24
+ cleaned: dict[str, Any] = {}
25
+ for name, extractor in fields.items():
26
+ if extractor is None:
27
+ continue
28
+ if isinstance(extractor, dict) and extractor.get("selector"):
29
+ cleaned[name] = {
30
+ "selector": extractor["selector"],
31
+ "attr": extractor.get("attr") or "text",
32
+ "many": bool(extractor.get("many", False)),
33
+ }
34
+ definition = {**definition, "fields": cleaned}
35
+ data["definition"] = definition
36
+
37
+ validations = data.get("validations")
38
+ if isinstance(validations, dict):
39
+ checks = validations.get("checks")
40
+ if isinstance(checks, list):
41
+ kept = [
42
+ c for c in checks if isinstance(c, dict) and c.get("type") in allowed
43
+ ]
44
+ # Ensure a minimal useful suite remains.
45
+ types = {c["type"] for c in kept}
46
+ if "min_count" not in types:
47
+ kept.insert(0, {"type": "min_count", "value": 1})
48
+ if "required_fields" not in types:
49
+ kept.append({"type": "required_fields", "fields": list(required)})
50
+ data["validations"] = {**validations, "checks": kept}
51
+ elif validations is None:
52
+ data["validations"] = {
53
+ "checks": [
54
+ {"type": "min_count", "value": 1},
55
+ {"type": "required_fields", "fields": list(required)},
56
+ ]
57
+ }
58
+
59
+ return data
@@ -0,0 +1,61 @@
1
+ """AI-assisted parser repair."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ from self_healing_scraper.agent.create_parser import known_checks_for_domain
8
+ from self_healing_scraper.agent.llm import complete_json
9
+ from self_healing_scraper.agent.normalize import normalize_generated_payload
10
+ from self_healing_scraper.domain import ScrapeDomain
11
+ from self_healing_scraper.models import (
12
+ GeneratedParser,
13
+ PageContent,
14
+ ParserDefinition,
15
+ ValidationResult,
16
+ ValidationSuite,
17
+ )
18
+ from self_healing_scraper.settings import Settings, get_settings
19
+
20
+
21
+ async def repair_parser(
22
+ *,
23
+ page: PageContent,
24
+ name: str,
25
+ url_pattern: str,
26
+ page_kind: str,
27
+ definition: ParserDefinition,
28
+ validations: ValidationSuite,
29
+ validation_result: ValidationResult,
30
+ domain: ScrapeDomain,
31
+ settings: Settings | None = None,
32
+ ) -> GeneratedParser:
33
+ cfg = settings or get_settings()
34
+ current = {
35
+ "name": name,
36
+ "url_pattern": url_pattern,
37
+ "page_kind": page_kind,
38
+ "definition": definition.model_dump(),
39
+ "validations": validations.model_dump(),
40
+ }
41
+ payload = await complete_json(
42
+ system=domain.prompts.repair_system,
43
+ user=domain.prompts.repair_user_template.format(
44
+ url=page.url,
45
+ current_parser=json.dumps(current, indent=2),
46
+ failures=validation_result.model_dump_json(indent=2),
47
+ html_sample=page.html[: cfg.page_sample_chars],
48
+ ),
49
+ settings=cfg,
50
+ )
51
+ # Preserve identity fields if the model omits them.
52
+ payload.setdefault("name", name)
53
+ payload.setdefault("url_pattern", url_pattern)
54
+ payload.setdefault("page_kind", page_kind)
55
+ return GeneratedParser.model_validate(
56
+ normalize_generated_payload(
57
+ payload,
58
+ known_checks=known_checks_for_domain(domain),
59
+ default_required_fields=domain.default_required_fields,
60
+ )
61
+ )
@@ -0,0 +1,41 @@
1
+ """Windows-compatible asyncio helpers for Playwright (and consumers using psycopg)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import sys
7
+ from collections.abc import Callable, Coroutine
8
+ from typing import cast
9
+
10
+
11
+ def configure_event_loop_policy() -> None:
12
+ """Use SelectorEventLoop on Windows so psycopg async works in the main loop."""
13
+ if sys.platform == "win32":
14
+ # Still required for psycopg async on Windows under Python 3.12.
15
+ asyncio.set_event_loop_policy( # ty: ignore[deprecated]
16
+ asyncio.WindowsSelectorEventLoopPolicy()
17
+ )
18
+
19
+
20
+ def run[T](coro: Coroutine[object, object, T]) -> T:
21
+ configure_event_loop_policy()
22
+ return asyncio.run(coro)
23
+
24
+
25
+ def _run_in_fresh_loop[T](coro: Coroutine[object, object, T]) -> T:
26
+ """Run a coroutine on a fresh loop (Proactor on Windows for Playwright)."""
27
+ if sys.platform == "win32":
28
+ loop = asyncio.ProactorEventLoop()
29
+ else:
30
+ loop = asyncio.new_event_loop()
31
+ try:
32
+ return loop.run_until_complete(coro)
33
+ finally:
34
+ loop.close()
35
+
36
+
37
+ async def run_playwright[T](factory: Callable[[], Coroutine[object, object, T]]) -> T:
38
+ """Run Playwright/Crawl4AI work on a loop that supports subprocesses."""
39
+ if sys.platform == "win32":
40
+ return cast(T, await asyncio.to_thread(_run_in_fresh_loop, factory()))
41
+ return await factory()
@@ -0,0 +1,37 @@
1
+ """Domain plug-in surface for prompts and extra validators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable
6
+ from dataclasses import dataclass, field
7
+ from typing import Any
8
+
9
+ from self_healing_scraper.models import (
10
+ PageContent,
11
+ ValidationCheck,
12
+ ValidationFailure,
13
+ )
14
+
15
+ CheckFn = Callable[
16
+ [ValidationCheck, list[dict[str, Any]], PageContent | None],
17
+ ValidationFailure | None,
18
+ ]
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class DomainPrompts:
23
+ create_system: str
24
+ create_user_template: str
25
+ repair_system: str
26
+ repair_user_template: str
27
+
28
+
29
+ @dataclass(frozen=True)
30
+ class ScrapeDomain:
31
+ """Specialization knobs for a scraping product (news, jobs, etc.)."""
32
+
33
+ prompts: DomainPrompts
34
+ default_required_fields: list[str] = field(default_factory=lambda: ["title", "url"])
35
+ known_checks: frozenset[str] = field(default_factory=frozenset)
36
+ extra_validators: dict[str, CheckFn] = field(default_factory=dict)
37
+ item_builder: Callable[[dict[str, Any]], Any] | None = None
@@ -0,0 +1,3 @@
1
+ from self_healing_scraper.fetch.crawler import fetch_page
2
+
3
+ __all__ = ["fetch_page"]