omk-crawl 2.0.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.
- omk_crawl/__init__.py +16 -0
- omk_crawl/cli.py +137 -0
- omk_crawl/detect.py +189 -0
- omk_crawl/pipeline.py +80 -0
- omk_crawl/py.typed +0 -0
- omk_crawl/result.py +73 -0
- omk_crawl/router.py +380 -0
- omk_crawl/tools/__init__.py +46 -0
- omk_crawl/tools/autoscraper_tool.py +71 -0
- omk_crawl/tools/base.py +60 -0
- omk_crawl/tools/browser_use_tool.py +60 -0
- omk_crawl/tools/crawl4ai_tool.py +85 -0
- omk_crawl/tools/curl_cffi_tool.py +50 -0
- omk_crawl/tools/markitdown_tool.py +37 -0
- omk_crawl/tools/scrapling_tool.py +48 -0
- omk_crawl-2.0.0.dist-info/METADATA +361 -0
- omk_crawl-2.0.0.dist-info/RECORD +21 -0
- omk_crawl-2.0.0.dist-info/WHEEL +4 -0
- omk_crawl-2.0.0.dist-info/entry_points.txt +2 -0
- omk_crawl-2.0.0.dist-info/licenses/LICENSE.txt +75 -0
- omk_crawl-2.0.0.dist-info/licenses/NOTICE.md +109 -0
omk_crawl/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""omk-crawl — Smart web crawling toolbox.
|
|
2
|
+
|
|
3
|
+
Auto-escalating router across 6 adapters:
|
|
4
|
+
curl_cffi → crawl4ai → scrapling → browser-use (+ autoscraper, markitdown)
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from omk_crawl import crawl
|
|
8
|
+
result = crawl("https://example.com")
|
|
9
|
+
print(result.markdown)
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from omk_crawl.result import CrawlResult, CrawlStatus
|
|
13
|
+
from omk_crawl.router import SmartRouter, crawl, crawl_async
|
|
14
|
+
|
|
15
|
+
__all__ = ["CrawlResult", "CrawlStatus", "SmartRouter", "crawl", "crawl_async"]
|
|
16
|
+
__version__ = "2.0.0"
|
omk_crawl/cli.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""CLI — omk-crawl <url> [options]
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
omk-crawl https://example.com # auto-escalate
|
|
5
|
+
omk-crawl https://example.com --tool curl_cffi # force tool
|
|
6
|
+
omk-crawl https://example.com -o out.md # save markdown
|
|
7
|
+
omk-crawl https://example.com --json # JSON output
|
|
8
|
+
omk-crawl https://example.com -v # verbose escalation
|
|
9
|
+
omk-crawl --diagnose https://example.com # dry-run: what would we try?
|
|
10
|
+
omk-crawl --tools # list installed tools
|
|
11
|
+
omk-crawl report.pdf # file → markdown (markitdown)
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from omk_crawl.detect import available_tools, missing_tools
|
|
22
|
+
from omk_crawl.result import CrawlResult
|
|
23
|
+
from omk_crawl.router import SmartRouter, crawl
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _print_result(r: CrawlResult, *, as_json: bool = False, output: str | None = None) -> None:
|
|
27
|
+
if as_json:
|
|
28
|
+
data = {
|
|
29
|
+
"url": r.url,
|
|
30
|
+
"status": r.status.value,
|
|
31
|
+
"status_code": r.status_code,
|
|
32
|
+
"tool": r.tool,
|
|
33
|
+
"elapsed_ms": round(r.elapsed_ms, 1),
|
|
34
|
+
"content_length": len(r.content) if r.content else 0,
|
|
35
|
+
"markdown": r.markdown,
|
|
36
|
+
"fit_markdown": r.fit_markdown,
|
|
37
|
+
"extracted": r.extracted,
|
|
38
|
+
"error": r.error,
|
|
39
|
+
"metadata": r.metadata,
|
|
40
|
+
}
|
|
41
|
+
text = json.dumps(data, ensure_ascii=False, indent=2)
|
|
42
|
+
else:
|
|
43
|
+
text = r.content or r.error or "(empty)"
|
|
44
|
+
|
|
45
|
+
if output:
|
|
46
|
+
Path(output).write_text(text, encoding="utf-8")
|
|
47
|
+
print(f"Saved {len(text)} chars → {output}")
|
|
48
|
+
else:
|
|
49
|
+
print(text)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def main(argv: list[str] | None = None) -> None:
|
|
53
|
+
parser = argparse.ArgumentParser(
|
|
54
|
+
prog="omk-crawl",
|
|
55
|
+
description="Smart web crawling toolbox — 6 adapters, auto-escalating router",
|
|
56
|
+
epilog=(
|
|
57
|
+
"This product uses Crawl4AI"
|
|
58
|
+
" (https://github.com/unclecode/crawl4ai) for web data extraction."
|
|
59
|
+
),
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument("url", nargs="?", help="URL or file path to crawl/convert")
|
|
62
|
+
parser.add_argument("--tool", "-t", help="Force a specific tool (skip auto-escalation)")
|
|
63
|
+
parser.add_argument("--output", "-o", help="Save output to file")
|
|
64
|
+
parser.add_argument("--json", "-j", action="store_true", help="JSON output")
|
|
65
|
+
parser.add_argument("--verbose", "-v", action="store_true", help="Verbose escalation log")
|
|
66
|
+
parser.add_argument(
|
|
67
|
+
"--no-robots", action="store_true",
|
|
68
|
+
help="Skip robots.txt check (use responsibly)",
|
|
69
|
+
)
|
|
70
|
+
parser.add_argument(
|
|
71
|
+
"--min-delay", type=float, default=0.5,
|
|
72
|
+
help="Minimum seconds between requests to same domain (default: 0.5)",
|
|
73
|
+
)
|
|
74
|
+
parser.add_argument(
|
|
75
|
+
"--diagnose", action="store_true",
|
|
76
|
+
help="Dry-run: show what tools would be tried",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument("--tools", action="store_true", help="List installed/missing tools")
|
|
79
|
+
parser.add_argument("--version", action="version", version="omk-crawl 2.0.0")
|
|
80
|
+
|
|
81
|
+
args = parser.parse_args(argv)
|
|
82
|
+
|
|
83
|
+
if args.tools:
|
|
84
|
+
avail = available_tools()
|
|
85
|
+
miss = missing_tools()
|
|
86
|
+
print("Installed tools:")
|
|
87
|
+
for t in avail:
|
|
88
|
+
print(f" ✅ {t}")
|
|
89
|
+
if miss:
|
|
90
|
+
print("\nMissing tools:")
|
|
91
|
+
for t in miss:
|
|
92
|
+
print(f" ❌ {t}")
|
|
93
|
+
print("\nInstall all: pip install omk-crawl[all]")
|
|
94
|
+
return
|
|
95
|
+
|
|
96
|
+
if not args.url:
|
|
97
|
+
parser.print_help()
|
|
98
|
+
sys.exit(1)
|
|
99
|
+
|
|
100
|
+
if args.diagnose:
|
|
101
|
+
router = SmartRouter(verbose=True)
|
|
102
|
+
info = router.diagnose(args.url)
|
|
103
|
+
print(json.dumps(info, indent=2))
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
# File path → markitdown
|
|
107
|
+
if Path(args.url).is_file():
|
|
108
|
+
from omk_crawl.tools.markitdown_tool import MarkitdownTool
|
|
109
|
+
|
|
110
|
+
tool = MarkitdownTool()
|
|
111
|
+
r = tool.fetch(args.url)
|
|
112
|
+
_print_result(r, as_json=args.json, output=args.output)
|
|
113
|
+
sys.exit(0 if r.ok else 1)
|
|
114
|
+
|
|
115
|
+
# Web crawl with auto-escalation
|
|
116
|
+
if args.verbose:
|
|
117
|
+
import logging
|
|
118
|
+
|
|
119
|
+
logging.basicConfig(
|
|
120
|
+
level=logging.INFO,
|
|
121
|
+
format=" [omk-crawl] %(message)s",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
r = crawl(
|
|
125
|
+
args.url, tool=args.tool, verbose=args.verbose,
|
|
126
|
+
respect_robots=not args.no_robots, min_delay=args.min_delay,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
if args.verbose:
|
|
130
|
+
print(f"\n--- {r.summary()} ---\n", file=sys.stderr)
|
|
131
|
+
|
|
132
|
+
_print_result(r, as_json=args.json, output=args.output)
|
|
133
|
+
sys.exit(0 if r.ok else 1)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
if __name__ == "__main__":
|
|
137
|
+
main()
|
omk_crawl/detect.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
"""Detection heuristics — what's blocking us, what do we need?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.util
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from enum import Flag, auto
|
|
8
|
+
|
|
9
|
+
from omk_crawl.result import CrawlStatus
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BlockType(Flag):
|
|
13
|
+
"""Detected blocking mechanism."""
|
|
14
|
+
|
|
15
|
+
NONE = 0
|
|
16
|
+
TLS_FINGERPRINT = auto() # JA3/HTTP2 fingerprint rejection
|
|
17
|
+
JS_REQUIRED = auto() # empty body / noscript / JS framework
|
|
18
|
+
CLOUDFLARE = auto() # CF challenge / Turnstile
|
|
19
|
+
WAF = auto() # generic WAF (403 + challenge page)
|
|
20
|
+
RATE_LIMIT = auto() # 429
|
|
21
|
+
AUTH_REQUIRED = auto() # 401 / login redirect
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class Detection:
|
|
26
|
+
"""Result of analyzing a response."""
|
|
27
|
+
|
|
28
|
+
block: BlockType = BlockType.NONE
|
|
29
|
+
status_code: int | None = None
|
|
30
|
+
needs_browser: bool = False
|
|
31
|
+
needs_stealth: bool = False
|
|
32
|
+
needs_llm_agent: bool = False
|
|
33
|
+
confidence: float = 0.0
|
|
34
|
+
detail: str = ""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# --- Tool availability ---
|
|
38
|
+
|
|
39
|
+
_TOOL_MODULES: dict[str, str] = {
|
|
40
|
+
"curl_cffi": "curl_cffi",
|
|
41
|
+
"crawl4ai": "crawl4ai",
|
|
42
|
+
"scrapling": "scrapling",
|
|
43
|
+
"scrapy": "scrapy",
|
|
44
|
+
"crawlee": "crawlee",
|
|
45
|
+
"browser_use": "browser_use",
|
|
46
|
+
"autoscraper": "autoscraper",
|
|
47
|
+
"markitdown": "markitdown",
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tool_available(name: str) -> bool:
|
|
52
|
+
"""Check if a tool's Python module is importable."""
|
|
53
|
+
mod = _TOOL_MODULES.get(name, name)
|
|
54
|
+
return importlib.util.find_spec(mod) is not None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def available_tools() -> list[str]:
|
|
58
|
+
"""List installed tools."""
|
|
59
|
+
return [name for name in _TOOL_MODULES if tool_available(name)]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def missing_tools() -> list[str]:
|
|
63
|
+
"""List tools that need `pip install`."""
|
|
64
|
+
return [name for name in _TOOL_MODULES if not tool_available(name)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# --- Response analysis ---
|
|
68
|
+
|
|
69
|
+
_CF_MARKERS = ("cf-browser-verification", "cf_chl_opt", "turnstile", "challenge-platform")
|
|
70
|
+
_JS_MARKERS = (
|
|
71
|
+
"<noscript>", 'id="__next"', 'id="root"', 'id="app"', "ng-app", "data-reactroot",
|
|
72
|
+
)
|
|
73
|
+
_WAF_MARKERS = ("access denied", "blocked", "captcha", "are you a robot", "unusual traffic")
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def detect_block(html: str | None, status_code: int | None) -> Detection:
|
|
77
|
+
"""Analyze response HTML + status to detect blocking type."""
|
|
78
|
+
d = Detection(status_code=status_code)
|
|
79
|
+
|
|
80
|
+
if status_code == 429:
|
|
81
|
+
d.block = BlockType.RATE_LIMIT
|
|
82
|
+
d.confidence = 0.9
|
|
83
|
+
d.detail = "Rate limited (429)"
|
|
84
|
+
return d
|
|
85
|
+
|
|
86
|
+
if status_code == 401:
|
|
87
|
+
d.block = BlockType.AUTH_REQUIRED
|
|
88
|
+
d.confidence = 0.9
|
|
89
|
+
d.detail = "Authentication required (401)"
|
|
90
|
+
return d
|
|
91
|
+
|
|
92
|
+
if not html:
|
|
93
|
+
if status_code == 403:
|
|
94
|
+
d.block = BlockType.TLS_FINGERPRINT | BlockType.WAF
|
|
95
|
+
d.confidence = 0.7
|
|
96
|
+
d.detail = "403 with empty body — likely TLS fingerprint or WAF"
|
|
97
|
+
return d
|
|
98
|
+
|
|
99
|
+
lower = html.lower()
|
|
100
|
+
|
|
101
|
+
# Cloudflare
|
|
102
|
+
if any(m in lower for m in _CF_MARKERS):
|
|
103
|
+
d.block |= BlockType.CLOUDFLARE
|
|
104
|
+
d.needs_stealth = True
|
|
105
|
+
d.confidence = 0.9
|
|
106
|
+
d.detail = "Cloudflare challenge detected"
|
|
107
|
+
|
|
108
|
+
# JS-required
|
|
109
|
+
if any(m in lower for m in _JS_MARKERS) and len(html) < 5000:
|
|
110
|
+
d.block |= BlockType.JS_REQUIRED
|
|
111
|
+
d.needs_browser = True
|
|
112
|
+
d.confidence = max(d.confidence, 0.8)
|
|
113
|
+
d.detail = "JS framework shell detected (needs rendering)"
|
|
114
|
+
|
|
115
|
+
# Generic WAF
|
|
116
|
+
if status_code == 403 and any(m in lower for m in _WAF_MARKERS):
|
|
117
|
+
d.block |= BlockType.WAF
|
|
118
|
+
d.confidence = max(d.confidence, 0.7)
|
|
119
|
+
d.detail = "WAF challenge page"
|
|
120
|
+
|
|
121
|
+
# TLS fingerprint: 403 + very short/empty response
|
|
122
|
+
if status_code == 403 and len(html) < 500 and not d.block:
|
|
123
|
+
d.block = BlockType.TLS_FINGERPRINT
|
|
124
|
+
d.confidence = 0.6
|
|
125
|
+
d.detail = "Short 403 — possible TLS fingerprint block"
|
|
126
|
+
|
|
127
|
+
if not d.block:
|
|
128
|
+
d.block = BlockType.NONE
|
|
129
|
+
d.confidence = 1.0
|
|
130
|
+
d.detail = "No blocking detected"
|
|
131
|
+
|
|
132
|
+
return d
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def detection_to_status(det: Detection) -> CrawlStatus:
|
|
136
|
+
"""Map a Detection to the appropriate CrawlStatus.
|
|
137
|
+
|
|
138
|
+
Priority: block signals > HTTP client/server errors > OK.
|
|
139
|
+
Block signals (CF, WAF, TLS, JS) take precedence over raw HTTP codes
|
|
140
|
+
because anti-bot systems commonly use 403/503 for challenge pages.
|
|
141
|
+
"""
|
|
142
|
+
# 1. Blocking detection FIRST — anti-bot challenges use 403/503
|
|
143
|
+
if BlockType.TLS_FINGERPRINT in det.block:
|
|
144
|
+
return CrawlStatus.TLS_BLOCKED
|
|
145
|
+
if BlockType.JS_REQUIRED in det.block and det.needs_browser:
|
|
146
|
+
return CrawlStatus.JS_REQUIRED
|
|
147
|
+
if det.block.value != 0:
|
|
148
|
+
return CrawlStatus.BLOCKED
|
|
149
|
+
|
|
150
|
+
# 2. HTTP-level errors with NO blocking signal — genuine errors
|
|
151
|
+
sc = det.status_code
|
|
152
|
+
if sc is not None and sc >= 400:
|
|
153
|
+
if sc in (404, 405, 410):
|
|
154
|
+
return CrawlStatus.ERROR # client error — not a block
|
|
155
|
+
if sc == 401:
|
|
156
|
+
return CrawlStatus.ERROR # auth — no point escalating
|
|
157
|
+
if sc >= 500:
|
|
158
|
+
return CrawlStatus.ERROR # server error — not a block
|
|
159
|
+
return CrawlStatus.ERROR # other 4xx without markers
|
|
160
|
+
|
|
161
|
+
return CrawlStatus.OK
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# --- robots.txt ---
|
|
165
|
+
|
|
166
|
+
def check_robots_txt(url: str, user_agent: str = "*") -> bool:
|
|
167
|
+
"""Check if a URL is allowed by the site's robots.txt.
|
|
168
|
+
|
|
169
|
+
Returns True if crawling is allowed (or robots.txt is unavailable).
|
|
170
|
+
Returns False if the URL is explicitly disallowed.
|
|
171
|
+
|
|
172
|
+
This is a best-effort check — it fetches and caches robots.txt
|
|
173
|
+
per domain. Network errors are treated as 'allowed' (fail-open).
|
|
174
|
+
"""
|
|
175
|
+
import urllib.request # noqa: F401 — needed by RobotFileParser internally
|
|
176
|
+
from urllib.parse import urlparse
|
|
177
|
+
from urllib.robotparser import RobotFileParser
|
|
178
|
+
|
|
179
|
+
parsed = urlparse(url)
|
|
180
|
+
robots_url = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
|
|
181
|
+
|
|
182
|
+
rp = RobotFileParser()
|
|
183
|
+
try:
|
|
184
|
+
rp.set_url(robots_url)
|
|
185
|
+
rp.read()
|
|
186
|
+
except Exception:
|
|
187
|
+
return True # fail-open: can't read robots.txt → allow
|
|
188
|
+
|
|
189
|
+
return rp.can_fetch(user_agent, url)
|
omk_crawl/pipeline.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""Pipeline — compose fetch → extract → convert steps."""
|
|
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 omk_crawl.result import CrawlResult, CrawlStatus
|
|
10
|
+
from omk_crawl.router import SmartRouter
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Pipeline:
|
|
15
|
+
"""Composable crawl pipeline.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
p = Pipeline()
|
|
19
|
+
p.fetch() # auto-escalating fetch
|
|
20
|
+
p.extract_css("div.product", { # CSS extraction
|
|
21
|
+
"title": "h2", "price": ".price"
|
|
22
|
+
})
|
|
23
|
+
p.to_markdown() # ensure markdown output
|
|
24
|
+
result = p.run("https://example.com")
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
steps: list[Callable[[CrawlResult], CrawlResult]] = field(default_factory=list)
|
|
28
|
+
router: SmartRouter = field(default_factory=SmartRouter)
|
|
29
|
+
|
|
30
|
+
def fetch(self, tool: str | None = None, **kwargs: Any) -> Pipeline:
|
|
31
|
+
"""Step 1: fetch with auto-escalation (or specific tool)."""
|
|
32
|
+
def _fetch(r: CrawlResult) -> CrawlResult:
|
|
33
|
+
if tool:
|
|
34
|
+
self.router.tools = [tool]
|
|
35
|
+
return self.router.crawl(r.url, **kwargs)
|
|
36
|
+
self.steps.append(_fetch)
|
|
37
|
+
return self
|
|
38
|
+
|
|
39
|
+
def extract_css(self, base: str, fields: dict[str, str]) -> Pipeline:
|
|
40
|
+
"""Step 2: CSS extraction on HTML (requires selectolax)."""
|
|
41
|
+
def _extract(r: CrawlResult) -> CrawlResult:
|
|
42
|
+
if not r.html:
|
|
43
|
+
r.error = "No HTML to extract from"
|
|
44
|
+
return r
|
|
45
|
+
try:
|
|
46
|
+
from selectolax.parser import HTMLParser
|
|
47
|
+
|
|
48
|
+
tree = HTMLParser(r.html)
|
|
49
|
+
rows = []
|
|
50
|
+
for node in tree.css(base):
|
|
51
|
+
row = {}
|
|
52
|
+
for name, sel in fields.items():
|
|
53
|
+
child = node.css_first(sel)
|
|
54
|
+
row[name] = child.text(strip=True) if child else None
|
|
55
|
+
rows.append(row)
|
|
56
|
+
r.extracted = rows
|
|
57
|
+
r.metadata["extract_count"] = len(rows)
|
|
58
|
+
except ImportError:
|
|
59
|
+
# selectolax not installed — no extraction possible
|
|
60
|
+
r.metadata["extract_note"] = "pip install selectolax for CSS extraction"
|
|
61
|
+
return r
|
|
62
|
+
self.steps.append(_extract)
|
|
63
|
+
return self
|
|
64
|
+
|
|
65
|
+
def to_markdown(self) -> Pipeline:
|
|
66
|
+
"""Step 3: ensure markdown output (delegates to SmartRouter._ensure_markdown)."""
|
|
67
|
+
def _convert(r: CrawlResult) -> CrawlResult:
|
|
68
|
+
SmartRouter._ensure_markdown(r)
|
|
69
|
+
return r
|
|
70
|
+
self.steps.append(_convert)
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def run(self, url: str) -> CrawlResult:
|
|
74
|
+
"""Execute the pipeline."""
|
|
75
|
+
result = CrawlResult(url=url, status=CrawlStatus.OK)
|
|
76
|
+
for step in self.steps:
|
|
77
|
+
result = step(result)
|
|
78
|
+
if result.status is CrawlStatus.ERROR:
|
|
79
|
+
break
|
|
80
|
+
return result
|
omk_crawl/py.typed
ADDED
|
File without changes
|
omk_crawl/result.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Unified crawl result across all tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from enum import Enum
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class CrawlStatus(Enum):
|
|
12
|
+
"""Outcome of a crawl attempt."""
|
|
13
|
+
|
|
14
|
+
OK = "ok"
|
|
15
|
+
BLOCKED = "blocked" # 403 / WAF / anti-bot
|
|
16
|
+
TLS_BLOCKED = "tls_blocked" # fingerprint-based rejection
|
|
17
|
+
JS_REQUIRED = "js_required" # empty body, needs rendering
|
|
18
|
+
ERROR = "error"
|
|
19
|
+
TOOL_MISSING = "tool_missing"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class CrawlResult:
|
|
24
|
+
"""Tool-agnostic crawl output."""
|
|
25
|
+
|
|
26
|
+
url: str
|
|
27
|
+
status: CrawlStatus
|
|
28
|
+
status_code: int | None = None
|
|
29
|
+
html: str | None = None
|
|
30
|
+
markdown: str | None = None
|
|
31
|
+
fit_markdown: str | None = None
|
|
32
|
+
extracted: list[dict[str, Any]] | None = None
|
|
33
|
+
tool: str = ""
|
|
34
|
+
elapsed_ms: float = 0.0
|
|
35
|
+
error: str | None = None
|
|
36
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
37
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def ok(self) -> bool:
|
|
41
|
+
return self.status is CrawlStatus.OK
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def blocked(self) -> bool:
|
|
45
|
+
return self.status in (CrawlStatus.BLOCKED, CrawlStatus.TLS_BLOCKED)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def content(self) -> str | None:
|
|
49
|
+
"""Best available text content."""
|
|
50
|
+
return self.fit_markdown or self.markdown or self.html
|
|
51
|
+
|
|
52
|
+
def summary(self) -> str:
|
|
53
|
+
parts = [f"[{self.status.value}] {self.url}"]
|
|
54
|
+
if self.tool:
|
|
55
|
+
parts.append(f"via {self.tool}")
|
|
56
|
+
if self.status_code:
|
|
57
|
+
parts.append(f"HTTP {self.status_code}")
|
|
58
|
+
parts.append(f"{self.elapsed_ms:.0f}ms")
|
|
59
|
+
if self.content:
|
|
60
|
+
parts.append(f"{len(self.content)} chars")
|
|
61
|
+
if self.error:
|
|
62
|
+
parts.append(f"err={self.error}")
|
|
63
|
+
return " | ".join(parts)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _timer() -> tuple[float, Any]:
|
|
67
|
+
"""Return (start_time, stop_fn). Call stop_fn() to get elapsed ms."""
|
|
68
|
+
t0 = time.perf_counter()
|
|
69
|
+
|
|
70
|
+
def stop() -> float:
|
|
71
|
+
return (time.perf_counter() - t0) * 1000
|
|
72
|
+
|
|
73
|
+
return t0, stop
|