-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathengine.py
More file actions
138 lines (117 loc) · 5.54 KB
/
Copy pathengine.py
File metadata and controls
138 lines (117 loc) · 5.54 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
import asyncio
import hashlib
import logging
import os
import os.path as osp
from typing import List, Optional
from urllib.parse import urlparse
import aiohttp
from aiohttp import ClientTimeout
from dotenv import load_dotenv
from pydantic import HttpUrl
from config import load_urls, settings
from models import JobStatus
from report.state_manager import state_manager
from scraper import WebScraper
load_dotenv(
dotenv_path=settings.model_config.get("env_file"), override=True, verbose=True
)
logger = logging.getLogger("WebScraper")
class ScraperEngine:
db_path_base = osp.join(osp.dirname(__file__), "dbs")
def __init__(self) -> None:
self.config = load_urls(settings.urls_path)
self.scrapers: List[WebScraper] = []
self._http_session: Optional[aiohttp.ClientSession] = None
self._semaphore = asyncio.Semaphore(value=settings.concurrent_scrapers)
logger.info(
f"ScraperEngine initialized. Concurrency limit: {settings.concurrent_scrapers}."
)
async def __aenter__(self):
"""Initializes the engine, making it ready to run."""
self._http_session = aiohttp.ClientSession(
timeout=ClientTimeout(total=settings.http_client_timeout_seconds),
headers={
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "application/json",
},
trust_env=True,
)
await self._initialize_scrapers()
logger.info(
"ScraperEngine entered context: HTTP session and scrapers are ready."
)
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Closes the HTTP session gracefully."""
if self._http_session and not self._http_session.closed:
await self._http_session.close()
logger.info("ScraperEngine exited context: HTTP session closed.")
def _get_db_file_path(self, url_str: HttpUrl) -> str:
parsed_url = urlparse(url_str.unicode_string())
hostname = parsed_url.hostname if parsed_url.hostname else "unknown_host"
sanitized_hostname = "".join(
c for c in hostname if c.isalnum() or c == "."
).replace(".", "_")
url_hash = hashlib.sha256(url_str.unicode_string().encode("utf-8")).hexdigest()[
:8
]
db_filename = f"db_{sanitized_hostname}_{url_hash}.sqlite3"
return osp.join(self.db_path_base, db_filename)
async def _initialize_scrapers(self):
"""Initializes scrapers and their job statuses concurrently."""
if self._http_session is None:
raise RuntimeError(
"HTTP session must be initialized before initializing scrapers."
)
os.makedirs(self.db_path_base, exist_ok=True)
self.scrapers = [
WebScraper(url, self._get_db_file_path(url), self._http_session)
for url in self.config.urls
]
init_tasks = [s.initialize_job_status() for s in self.scrapers]
if init_tasks:
await asyncio.gather(*init_tasks)
logger.info(f"Initialized job statuses for {len(self.scrapers)} scrapers.")
async def _run_scraper_safely(self, scraper: WebScraper):
"""A wrapper to run a single scraper within the semaphore."""
async with self._semaphore:
logger.debug(f"Acquired semaphore for {scraper.job_id}")
try:
return await scraper(show=False)
finally:
logger.debug(f"Released semaphore for {scraper.job_id}")
async def run_all(self):
"""
Fetches job statuses concurrently, filters out failed jobs,
and runs the active scrapers with a concurrency limit.
"""
if not self._http_session or self._http_session.closed:
logger.error(
"ScraperEngine is not ready. Use 'async with ScraperEngine()'."
)
return
logger.info("ScraperEngine: Starting all configured scrapers...")
# 1. Fetch all job statuses concurrently
status_tasks = [state_manager.get_job_status(s.job_id) for s in self.scrapers]
job_statuses = await asyncio.gather(*status_tasks)
# 2. Create a list of scraper tasks to run, filtering out failed ones
tasks_to_run = []
for scraper, status in zip(self.scrapers, job_statuses):
if status and status.status == JobStatus.PERMANENTLY_FAILED:
logger.warning(f"Skipping permanently failed scraper: {scraper.job_id}")
continue
tasks_to_run.append(self._run_scraper_safely(scraper))
# 3. Run the active scrapers concurrently with robust error handling
if tasks_to_run:
results = await asyncio.gather(*tasks_to_run, return_exceptions=True)
success_count = sum(1 for r in results if not isinstance(r, Exception))
failure_count = len(results) - success_count
logger.info(
f"ScraperEngine run finished. Completed: {success_count}/{failure_count + success_count}. Active Scrapers: ({failure_count + success_count}/{len(self.scrapers)})"
)
for result in results:
if isinstance(result, Exception):
logger.error(f"A scraper task failed: {result}", exc_info=False)
else:
logger.info("No active scrapers to run.")