onecrawler 0.1.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.
Files changed (39) hide show
  1. onecrawler/__init__.py +7 -0
  2. onecrawler/browser.py +93 -0
  3. onecrawler/crawler/__init__.py +0 -0
  4. onecrawler/crawler/base.py +54 -0
  5. onecrawler/crawler/link/__init__.py +0 -0
  6. onecrawler/crawler/link/classifier.py +116 -0
  7. onecrawler/crawler/link/deep.py +235 -0
  8. onecrawler/crawler/link/engine.py +80 -0
  9. onecrawler/crawler/link/helper.py +124 -0
  10. onecrawler/crawler/link/shallow.py +90 -0
  11. onecrawler/crawler/scraper/__init__.py +0 -0
  12. onecrawler/crawler/scraper/engine.py +90 -0
  13. onecrawler/crawler/scraper/genai/__init__.py +0 -0
  14. onecrawler/crawler/scraper/genai/executor.py +39 -0
  15. onecrawler/crawler/scraper/genai/graph.py +61 -0
  16. onecrawler/crawler/scraper/genai/model.py +44 -0
  17. onecrawler/crawler/scraper/genai/prompt.py +22 -0
  18. onecrawler/crawler/scraper/genai/state.py +9 -0
  19. onecrawler/crawler/scraper/heuristic/script.py +50 -0
  20. onecrawler/map/__init__.py +0 -0
  21. onecrawler/map/helper.py +63 -0
  22. onecrawler/map/sitemap.py +563 -0
  23. onecrawler/proxy/__init__.py +0 -0
  24. onecrawler/proxy/pool.py +28 -0
  25. onecrawler/settings/__init__.py +4 -0
  26. onecrawler/settings/browser.py +73 -0
  27. onecrawler/settings/crawler.py +83 -0
  28. onecrawler/settings/genai.py +17 -0
  29. onecrawler/settings/proxy.py +43 -0
  30. onecrawler/settings/simulation.py +25 -0
  31. onecrawler/utils/__init__.py +0 -0
  32. onecrawler/utils/decorator.py +24 -0
  33. onecrawler/utils/stats.py +0 -0
  34. onecrawler/utils/writter.py +6 -0
  35. onecrawler-0.1.0.dist-info/METADATA +22 -0
  36. onecrawler-0.1.0.dist-info/RECORD +39 -0
  37. onecrawler-0.1.0.dist-info/WHEEL +5 -0
  38. onecrawler-0.1.0.dist-info/licenses/LICENSE +21 -0
  39. onecrawler-0.1.0.dist-info/top_level.txt +1 -0
onecrawler/__init__.py ADDED
@@ -0,0 +1,7 @@
1
+ from .crawler.link.classifier import LinkClassifierPipeline
2
+ from .crawler.link.engine import LinkExtractionEngine
3
+ from .crawler.scraper.engine import ScraperEngine
4
+ from .map.sitemap import SiteMap, SitemapStats, UniversalSiteMap
5
+ from .settings import *
6
+
7
+ __version__ = "0.1.0"
onecrawler/browser.py ADDED
@@ -0,0 +1,93 @@
1
+ from contextlib import suppress
2
+
3
+ from playwright.async_api import async_playwright
4
+
5
+ from .settings.browser import BrowserSettings
6
+
7
+
8
+ class GoogleChrome:
9
+ def __init__(self, settings: BrowserSettings):
10
+ self.settings = settings
11
+ self.playwright = None
12
+ self.browser = None
13
+ self.context = None
14
+ self._started = False
15
+ self._closed = False
16
+
17
+ async def start(self):
18
+ if self._started:
19
+ return
20
+
21
+ self.playwright = await async_playwright().start()
22
+
23
+ launch = self.settings.launch
24
+ context = self.settings.context
25
+
26
+ self.browser = await self.playwright.chromium.launch(
27
+ headless=launch.headless,
28
+ slow_mo=launch.slow_mo,
29
+ args=launch.args,
30
+ executable_path=launch.executable_path,
31
+ channel=launch.channel,
32
+ env=launch.env,
33
+ )
34
+
35
+ self.context = await self.browser.new_context(
36
+ viewport=context.viewport,
37
+ screen=context.screen,
38
+ no_viewport=context.no_viewport,
39
+ locale=context.locale,
40
+ timezone_id=context.timezone_id,
41
+ user_agent=context.user_agent,
42
+ java_script_enabled=context.java_script_enabled,
43
+ bypass_csp=context.bypass_csp,
44
+ ignore_https_errors=context.ignore_https_errors,
45
+ extra_http_headers=context.extra_http_headers,
46
+ offline=context.offline,
47
+ geolocation=context.geolocation,
48
+ permissions=context.permissions,
49
+ storage_state=context.storage_state,
50
+ base_url=context.base_url,
51
+ proxy=self.settings.proxy.as_playwright() if self.settings.proxy else None,
52
+ )
53
+
54
+ self._started = True
55
+ self._closed = False
56
+
57
+ async def new_page(self):
58
+ if not self._started:
59
+ await self.start()
60
+
61
+ page = await self.context.new_page()
62
+
63
+ runtime = self.settings.runtime
64
+ page.set_default_timeout(runtime.action_timeout)
65
+ page.set_default_navigation_timeout(runtime.navigation_timeout)
66
+
67
+ return page
68
+
69
+ async def close(self):
70
+ if self._closed:
71
+ return
72
+
73
+ self._closed = True
74
+
75
+ # Close context safely
76
+ if self.context:
77
+ with suppress(Exception):
78
+ await self.context.close()
79
+ self.context = None
80
+
81
+ # Close browser (YOU WERE MISSING THIS)
82
+ if self.browser:
83
+ with suppress(Exception):
84
+ await self.browser.close()
85
+ self.browser = None
86
+
87
+ # Stop playwright
88
+ if self.playwright:
89
+ with suppress(Exception):
90
+ await self.playwright.stop()
91
+ self.playwright = None
92
+
93
+ self._started = False
File without changes
@@ -0,0 +1,54 @@
1
+ import logging
2
+ from abc import ABC, abstractmethod
3
+ from typing import Optional, Type
4
+
5
+
6
+ class BaseEngine(ABC):
7
+ def __init__(self):
8
+ self._closed: bool = True
9
+ self.logger = logging.getLogger(self.__class__.__name__)
10
+
11
+ # ===== Context Manager =====
12
+ async def __aenter__(self):
13
+ self._closed = False
14
+ await self.start()
15
+ self.logger.debug("Engine started")
16
+ return self
17
+
18
+ async def __aexit__(
19
+ self,
20
+ exc_type: Optional[Type[BaseException]],
21
+ exc: Optional[BaseException],
22
+ tb,
23
+ ):
24
+ try:
25
+ await self.close()
26
+ finally:
27
+ self._closed = True
28
+ self.logger.debug("Engine closed")
29
+
30
+ # ===== Lifecycle Hooks =====
31
+ async def start(self):
32
+ """Override to initialize resources."""
33
+ pass
34
+
35
+ async def close(self):
36
+ """Override to cleanup resources."""
37
+ pass
38
+
39
+ # ===== REQUIRED API =====
40
+ @abstractmethod
41
+ async def run(self, *args, **kwargs):
42
+ """Main execution method for the engine."""
43
+ raise NotImplementedError
44
+
45
+ # ===== Safety =====
46
+ def _ensure_open(self):
47
+ if self._closed:
48
+ raise RuntimeError(
49
+ f"{self.__class__.__name__} is closed. Use 'async with'."
50
+ )
51
+
52
+ @property
53
+ def is_closed(self) -> bool:
54
+ return self._closed
File without changes
@@ -0,0 +1,116 @@
1
+ import asyncio
2
+ import warnings
3
+ from functools import lru_cache
4
+ from typing import List
5
+ from urllib.parse import unquote
6
+
7
+ CLASSIFIER_AVAILABLE = True
8
+ try:
9
+ import torch
10
+ from transformers import AutoModelForSequenceClassification, AutoTokenizer, pipeline
11
+ except ImportError:
12
+ CLASSIFIER_AVAILABLE = False
13
+
14
+
15
+ if CLASSIFIER_AVAILABLE:
16
+ repo_id = "SayedShaun/distilbert-link-type-classifier"
17
+
18
+ torch.set_grad_enabled(False)
19
+
20
+ model = AutoModelForSequenceClassification.from_pretrained(repo_id)
21
+ tokenizer = AutoTokenizer.from_pretrained(repo_id)
22
+
23
+ def get_classifier(device: str = "cpu"):
24
+ device_id = 0 if device == "cuda" else -1
25
+ return pipeline(
26
+ "text-classification",
27
+ model=model,
28
+ tokenizer=tokenizer,
29
+ device=device_id,
30
+ )
31
+
32
+ clf = get_classifier("cuda" if torch.cuda.is_available() else "cpu")
33
+
34
+
35
+ def cheap_filter(url: str) -> bool:
36
+ if not url:
37
+ return False
38
+
39
+ bad_patterns = (
40
+ "javascript:",
41
+ "mailto:",
42
+ "tel:",
43
+ "#",
44
+ )
45
+
46
+ return not any(p in url for p in bad_patterns)
47
+
48
+
49
+ @lru_cache(maxsize=10000)
50
+ def _cached_single_prediction(url: str) -> str:
51
+ result = clf(url, truncation=True, max_length=128)[0]
52
+ return result["label"]
53
+
54
+
55
+ class LinkClassifierPipeline:
56
+ def __init__(
57
+ self,
58
+ confidence_threshold: float = 0.8,
59
+ ):
60
+ self.threshold = confidence_threshold
61
+
62
+ if not CLASSIFIER_AVAILABLE:
63
+ warnings.warn(
64
+ "Classifier enabled but transformers/torch not installed. Disabling."
65
+ )
66
+ self.available = False
67
+ else:
68
+ self.available = True
69
+
70
+ async def classify_batch(self, urls: List[str]) -> List[bool]:
71
+ if not self.available:
72
+ return [True] * len(urls)
73
+
74
+ filtered_urls = []
75
+ index_map = []
76
+
77
+ for i, url in enumerate(urls):
78
+ if cheap_filter(url):
79
+ filtered_urls.append(unquote(url))
80
+ index_map.append(i)
81
+
82
+ results = [False] * len(urls)
83
+
84
+ if not filtered_urls:
85
+ return results
86
+
87
+ try:
88
+ predictions = await asyncio.to_thread(
89
+ lambda: clf(filtered_urls, truncation=True, max_length=128)
90
+ )
91
+ except Exception:
92
+ return [True] * len(urls)
93
+
94
+ for idx, pred in zip(index_map, predictions):
95
+ label = pred["label"]
96
+ score = pred["score"]
97
+
98
+ if label == "content" and score >= self.threshold:
99
+ results[idx] = True
100
+ else:
101
+ results[idx] = False
102
+
103
+ return results
104
+
105
+ async def is_valid(self, url: str) -> bool:
106
+ if not self.available:
107
+ return True
108
+
109
+ if not cheap_filter(url):
110
+ return False
111
+
112
+ try:
113
+ label = await asyncio.to_thread(_cached_single_prediction, unquote(url))
114
+ return label != "section"
115
+ except Exception:
116
+ return True
@@ -0,0 +1,235 @@
1
+ import asyncio
2
+ import logging
3
+ from collections import deque
4
+ from typing import Optional
5
+ from urllib.parse import urldefrag
6
+
7
+ from ...settings.simulation import HumanBehaviorSettings
8
+ from .helper import human_delay, human_mouse_move, human_scroll, wildcard_link_match
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class BFScheduler:
14
+ def __init__(self, base_url: str, max_queue_size: int = 5000):
15
+ self.queue = deque([base_url])
16
+ self.priority = deque()
17
+
18
+ self.visited = set()
19
+ self.in_queue = {base_url}
20
+
21
+ self.max_queue_size = max_queue_size
22
+ self.lock = asyncio.Lock()
23
+
24
+ async def has_next(self) -> bool:
25
+ async with self.lock:
26
+ return bool(self.queue or self.priority)
27
+
28
+ async def next(self) -> str | None:
29
+ async with self.lock:
30
+ if self.priority:
31
+ return self.priority.popleft()
32
+ if self.queue:
33
+ return self.queue.popleft()
34
+ return None
35
+
36
+ async def add(self, url: str, priority: bool = False):
37
+ async with self.lock:
38
+ if url in self.visited or url in self.in_queue:
39
+ return
40
+
41
+ if len(self.in_queue) >= self.max_queue_size:
42
+ return
43
+
44
+ self.in_queue.add(url)
45
+
46
+ if priority:
47
+ self.priority.append(url)
48
+ else:
49
+ self.queue.append(url)
50
+
51
+ def mark_visited(self, url: str):
52
+ self.visited.add(url)
53
+ self.in_queue.discard(url)
54
+
55
+
56
+ class BrowserPool:
57
+ def __init__(self, browser, size: int):
58
+ self.browser = browser
59
+ self.size = size
60
+ self.pages = asyncio.Queue(maxsize=size)
61
+ self._closed = False
62
+
63
+ async def init(self):
64
+ # NOTE: browser.start() must already be called before BrowserPool.init()
65
+ # We only create pages here — do NOT call self.browser.start() again.
66
+ for _ in range(self.size):
67
+ page = await self.browser.new_page()
68
+ await self.pages.put(page)
69
+
70
+ async def acquire(self):
71
+ return await self.pages.get()
72
+
73
+ async def release(self, page):
74
+ if not self._closed:
75
+ await self.pages.put(page)
76
+
77
+ async def close(self):
78
+ self._closed = True
79
+ while not self.pages.empty():
80
+ page = await self.pages.get()
81
+ await page.close()
82
+ # NOTE: do NOT close the browser here — the engine owns it
83
+
84
+
85
+ class LinkSpider:
86
+ def __init__(self, base_prefix: str):
87
+ self.base_prefix = base_prefix
88
+
89
+ async def parse(self, page):
90
+ raw = await page.eval_on_selector_all(
91
+ "a", "els => els.map(e => e.href).filter(Boolean)"
92
+ )
93
+ return [
94
+ urldefrag(link).url
95
+ for link in raw
96
+ if isinstance(link, str) and link.startswith(self.base_prefix)
97
+ ]
98
+
99
+
100
+ class BFSRuntime:
101
+ def __init__(
102
+ self,
103
+ scheduler,
104
+ pool,
105
+ spider,
106
+ base_prefix: str,
107
+ max_links: int,
108
+ human_behavior_settings: HumanBehaviorSettings,
109
+ include_pattern: Optional[list] = None,
110
+ enable_human_behaviors: bool = False,
111
+ concurrency: int = 5,
112
+ ):
113
+ self.scheduler = scheduler
114
+ self.pool = pool
115
+ self.spider = spider
116
+
117
+ self.base_prefix = base_prefix
118
+ self.max_links = max_links
119
+ self.include_pattern = include_pattern
120
+ self.enable_human_behaviors = enable_human_behaviors
121
+ self.human_behavior_settings = human_behavior_settings
122
+ self.concurrency = concurrency
123
+
124
+ self.stop_event = asyncio.Event()
125
+
126
+ self.results = []
127
+ self.results_set = set()
128
+ self.lock = asyncio.Lock()
129
+
130
+ # Track how many workers are actively processing a URL
131
+ self._active_workers = 0
132
+ self._active_lock = asyncio.Lock()
133
+
134
+ async def worker(self):
135
+ while not self.stop_event.is_set():
136
+ url = await self.scheduler.next()
137
+
138
+ if url is None:
139
+ # Queue is empty — check if any other worker is still active.
140
+ # If not, all work is done and we can exit.
141
+ async with self._active_lock:
142
+ if self._active_workers == 0:
143
+ self.stop_event.set()
144
+ return
145
+
146
+ # Another worker is still processing and may enqueue more URLs.
147
+ # Wait a bit and retry instead of busy-spinning.
148
+ await asyncio.sleep(0.2)
149
+ continue
150
+
151
+ if url in self.scheduler.visited:
152
+ continue
153
+
154
+ async with self._active_lock:
155
+ self._active_workers += 1
156
+
157
+ page = await self.pool.acquire()
158
+
159
+ try:
160
+ self.scheduler.mark_visited(url)
161
+
162
+ try:
163
+ await page.goto(url, wait_until="domcontentloaded")
164
+ except Exception as e:
165
+ logger.warning("Failed to load %s: %s", url, e)
166
+ return
167
+
168
+ if self.enable_human_behaviors:
169
+ await human_delay(
170
+ self.human_behavior_settings.min_delay,
171
+ self.human_behavior_settings.max_delay,
172
+ )
173
+ await human_scroll(
174
+ page, max_scrolls=self.human_behavior_settings.max_scrolls
175
+ )
176
+
177
+ links = await self.spider.parse(page)
178
+
179
+ if self.enable_human_behaviors:
180
+ await human_mouse_move(
181
+ page,
182
+ min_mouse_moves=self.human_behavior_settings.min_mouse_moves,
183
+ max_mouse_moves=self.human_behavior_settings.max_mouse_moves,
184
+ mouse_width=self.human_behavior_settings.mouse_width,
185
+ mouse_height=self.human_behavior_settings.mouse_height,
186
+ min_mouse_steps=self.human_behavior_settings.min_mouse_steps,
187
+ max_mouse_steps=self.human_behavior_settings.max_mouse_steps,
188
+ min_mouse_sleep=self.human_behavior_settings.min_mouse_sleep,
189
+ max_mouse_sleep=self.human_behavior_settings.max_mouse_sleep,
190
+ )
191
+
192
+ for link in links:
193
+ if self.stop_event.is_set():
194
+ break
195
+
196
+ if not link.startswith(self.base_prefix):
197
+ continue
198
+
199
+ if self.include_pattern:
200
+ if not wildcard_link_match(
201
+ link,
202
+ self.base_prefix,
203
+ self.include_pattern,
204
+ ):
205
+ continue
206
+
207
+ await self.scheduler.add(link)
208
+
209
+ async with self.lock:
210
+ if link in self.results_set:
211
+ continue
212
+
213
+ self.results_set.add(link)
214
+ self.results.append(link)
215
+
216
+ logger.info(
217
+ "Discovered %s/%s links; link=%s",
218
+ len(self.results),
219
+ self.max_links,
220
+ link,
221
+ )
222
+
223
+ if len(self.results) >= self.max_links:
224
+ self.stop_event.set()
225
+ return
226
+
227
+ finally:
228
+ await self.pool.release(page)
229
+ async with self._active_lock:
230
+ self._active_workers -= 1
231
+
232
+ async def run(self):
233
+ tasks = [asyncio.create_task(self.worker()) for _ in range(self.concurrency)]
234
+ await asyncio.gather(*tasks, return_exceptions=True)
235
+ return self.results
@@ -0,0 +1,80 @@
1
+ from urllib.parse import urlparse
2
+
3
+ from ...browser import GoogleChrome
4
+ from ..base import BaseEngine
5
+ from .deep import BFScheduler, BFSRuntime, BrowserPool, LinkSpider
6
+ from .shallow import extract_url_from_current_page
7
+
8
+
9
+ class LinkExtractionEngine(BaseEngine):
10
+ def __init__(self, settings):
11
+ super().__init__()
12
+
13
+ self.settings = settings
14
+
15
+ # future-ready placeholders
16
+ self.session = None
17
+
18
+ self.logger.info("LinkExtractionEngine initialized")
19
+
20
+ async def start(self):
21
+ self.browser = GoogleChrome(self.settings.browser_settings)
22
+ await self.browser.start()
23
+
24
+ async def close(self):
25
+ if hasattr(self, "browser") and self.browser:
26
+ await self.browser.close()
27
+
28
+ async def run(self, url: str) -> dict:
29
+ self._ensure_open()
30
+
31
+ strategy = self.settings.link_extraction_strategy
32
+ self.logger.info(f"Running link extraction on {url} with strategy: {strategy}")
33
+
34
+ if strategy == "shallow":
35
+ return await self._run_shallow(url)
36
+
37
+ if strategy == "deep":
38
+ return await self._run_deep(url)
39
+
40
+ raise ValueError(f"Unknown strategy: {strategy}")
41
+
42
+ async def _run_shallow(self, url: str) -> dict:
43
+ self._ensure_open()
44
+
45
+ return await extract_url_from_current_page(
46
+ url=url,
47
+ browser=self.browser,
48
+ include_link_patterns=self.settings.include_link_patterns,
49
+ link_classification=self.settings.link_classification,
50
+ max_links=self.settings.link_extraction_limit,
51
+ )
52
+
53
+ async def _run_deep(self, url: str) -> dict:
54
+ self._ensure_open()
55
+
56
+ parsed = urlparse(url)
57
+ base_prefix = f"{parsed.scheme}://{parsed.netloc}"
58
+
59
+ scheduler = BFScheduler(url)
60
+ spider = LinkSpider(base_prefix)
61
+ pool = BrowserPool(self.browser, self.settings.concurrency)
62
+
63
+ await pool.init()
64
+
65
+ runtime = BFSRuntime(
66
+ scheduler=scheduler,
67
+ pool=pool,
68
+ spider=spider,
69
+ base_prefix=base_prefix,
70
+ max_links=self.settings.link_extraction_limit,
71
+ include_pattern=self.settings.include_link_patterns,
72
+ enable_human_behaviors=self.settings.enable_human_behaviors,
73
+ human_behavior_settings=self.settings.human_behavior_settings,
74
+ concurrency=self.settings.concurrency,
75
+ )
76
+
77
+ try:
78
+ return await runtime.run()
79
+ finally:
80
+ await pool.close()