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 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