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.
- self_healing_scraper-0.1.0/.github/workflows/ci.yml +18 -0
- self_healing_scraper-0.1.0/.gitignore +19 -0
- self_healing_scraper-0.1.0/.pre-commit-config.yaml +24 -0
- self_healing_scraper-0.1.0/.python-version +1 -0
- self_healing_scraper-0.1.0/PKG-INFO +12 -0
- self_healing_scraper-0.1.0/README.md +82 -0
- self_healing_scraper-0.1.0/pyproject.toml +69 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/__init__.py +22 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/agent/__init__.py +1 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/agent/create_parser.py +69 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/agent/llm.py +77 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/agent/normalize.py +59 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/agent/repair_parser.py +61 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/asyncio_compat.py +41 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/domain.py +37 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/fetch/__init__.py +3 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/fetch/crawler.py +84 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/models.py +88 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/runtime/__init__.py +4 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/runtime/executor.py +131 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/runtime/validators.py +280 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/scrape.py +179 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/settings.py +29 -0
- self_healing_scraper-0.1.0/src/self_healing_scraper/store.py +221 -0
- self_healing_scraper-0.1.0/tests/conftest.py +119 -0
- self_healing_scraper-0.1.0/tests/test_executor.py +38 -0
- self_healing_scraper-0.1.0/tests/test_memory_store.py +43 -0
- self_healing_scraper-0.1.0/tests/test_normalize.py +33 -0
- self_healing_scraper-0.1.0/tests/test_registry.py +40 -0
- self_healing_scraper-0.1.0/tests/test_scrape_loop.py +83 -0
- self_healing_scraper-0.1.0/tests/test_validators.py +42 -0
- 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,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
|