| import asyncio |
| import logging |
| from typing import Set |
| from urllib.parse import urlparse |
| from .config import CrawlerConfig |
| from .anti_blocking import AntiBlockingManager |
| from .state import CrawlerState |
| from .client import RobustHTTPClient |
| from .parser import PageParser |
|
|
| logger = logging.getLogger("crawler.engine") |
|
|
| class CrawlEngine: |
| def __init__(self, config: CrawlerConfig, db_path: str = "crawler_state.db"): |
| self.config = config |
| self.state = CrawlerState(db_path) |
| self.anti_blocking = AntiBlockingManager() |
| self.client = RobustHTTPClient(config, self.anti_blocking) |
| self.parser = PageParser(config.domain, config.scheme) |
| self.semaphore = asyncio.Semaphore(config.concurrency_limit) |
| |
| |
| self.pages_scraped_count = 0 |
| |
| |
| self.state.reset_running() |
| self.state.add_to_queue(config.base_url, depth=0) |
|
|
| async def _process_url(self, url: str, depth: int): |
| """Worker task processing a single URL: get, parse, store, enqueue new links.""" |
| async with self.semaphore: |
| |
| if depth > self.config.max_depth: |
| self.state.mark_visited(url, depth, error="Exceeded max crawl depth") |
| return |
|
|
| |
| if self.config.max_pages and self.pages_scraped_count >= self.config.max_pages: |
| logger.info(f"Crawl limit reached ({self.config.max_pages} pages). Skipping {url}") |
| |
| self.state.mark_failed(url, depth, "Skipped due to page limit") |
| return |
|
|
| try: |
| |
| response = await self.client.get(url) |
| |
| |
| content_type = response.headers.get("content-type", "").lower() |
| if "text/html" not in content_type: |
| self.state.mark_visited(url, depth, status_code=response.status_code, error="Not HTML content") |
| return |
| |
| |
| html_content = response.text |
| parsed_data = self.parser.parse(html_content, url) |
| |
| |
| self.state.save_scraped_data(url, parsed_data) |
| self.pages_scraped_count += 1 |
| logger.info(f"Successfully scraped [{response.status_code}]: {url} ({parsed_data['title']})") |
| |
| |
| if depth < self.config.max_depth: |
| new_links = self.parser.extract_links(html_content, url) |
| links_to_add = [(link, depth + 1) for link in new_links] |
| added = self.state.add_multiple_to_queue(links_to_add) |
| if added > 0: |
| logger.info(f"Queued {added} new internal links found on {url}") |
|
|
| |
| self.state.mark_visited(url, depth, status_code=response.status_code) |
|
|
| except Exception as e: |
| logger.error(f"Error crawling {url}: {str(e)}") |
| self.state.mark_failed(url, depth, error=str(e)) |
|
|
| async def run(self): |
| """Starts the main async crawling loop, coordinating workers.""" |
| logger.info(f"Starting crawler for domain: {self.config.domain}") |
| logger.info(f"Concurrency limit: {self.config.concurrency_limit}, delay range: {self.config.delay_range}") |
| |
| active_tasks = set() |
| |
| while True: |
| |
| if self.config.max_pages and self.pages_scraped_count >= self.config.max_pages: |
| logger.info("Configured max pages scrape limit reached. Terminating crawl.") |
| break |
|
|
| |
| free_slots = self.config.concurrency_limit - len(active_tasks) |
| for _ in range(free_slots): |
| next_item = self.state.get_next_url() |
| if next_item: |
| url, depth = next_item |
| task = asyncio.create_task(self._process_url(url, depth)) |
| active_tasks.add(task) |
| |
| task.add_done_callback(active_tasks.discard) |
| else: |
| break |
|
|
| |
| if not active_tasks: |
| |
| next_item = self.state.get_next_url() |
| if not next_item: |
| logger.info("Crawl complete. No more pending links in the queue.") |
| break |
| else: |
| |
| continue |
|
|
| |
| await asyncio.sleep(0.1) |
|
|
| |
| await self.client.close() |
| logger.info("Crawler run finished.") |
| |
| |
| stats = self.state.get_stats() |
| print("\n--- Crawl Statistics ---") |
| print(f"Scraped & Saved: {stats['scraped']} pages") |
| print(f"Visited (Success): {stats['successful']} pages") |
| print(f"Visited (Failed): {stats['failed']} pages") |
| print(f"Pending in Queue: {stats['pending']} pages") |
| print("------------------------") |
|
|