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.
- onecrawler/__init__.py +7 -0
- onecrawler/browser.py +93 -0
- onecrawler/crawler/__init__.py +0 -0
- onecrawler/crawler/base.py +54 -0
- onecrawler/crawler/link/__init__.py +0 -0
- onecrawler/crawler/link/classifier.py +116 -0
- onecrawler/crawler/link/deep.py +235 -0
- onecrawler/crawler/link/engine.py +80 -0
- onecrawler/crawler/link/helper.py +124 -0
- onecrawler/crawler/link/shallow.py +90 -0
- onecrawler/crawler/scraper/__init__.py +0 -0
- onecrawler/crawler/scraper/engine.py +90 -0
- onecrawler/crawler/scraper/genai/__init__.py +0 -0
- onecrawler/crawler/scraper/genai/executor.py +39 -0
- onecrawler/crawler/scraper/genai/graph.py +61 -0
- onecrawler/crawler/scraper/genai/model.py +44 -0
- onecrawler/crawler/scraper/genai/prompt.py +22 -0
- onecrawler/crawler/scraper/genai/state.py +9 -0
- onecrawler/crawler/scraper/heuristic/script.py +50 -0
- onecrawler/map/__init__.py +0 -0
- onecrawler/map/helper.py +63 -0
- onecrawler/map/sitemap.py +563 -0
- onecrawler/proxy/__init__.py +0 -0
- onecrawler/proxy/pool.py +28 -0
- onecrawler/settings/__init__.py +4 -0
- onecrawler/settings/browser.py +73 -0
- onecrawler/settings/crawler.py +83 -0
- onecrawler/settings/genai.py +17 -0
- onecrawler/settings/proxy.py +43 -0
- onecrawler/settings/simulation.py +25 -0
- onecrawler/utils/__init__.py +0 -0
- onecrawler/utils/decorator.py +24 -0
- onecrawler/utils/stats.py +0 -0
- onecrawler/utils/writter.py +6 -0
- onecrawler-0.1.0.dist-info/METADATA +22 -0
- onecrawler-0.1.0.dist-info/RECORD +39 -0
- onecrawler-0.1.0.dist-info/WHEEL +5 -0
- onecrawler-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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()
|