feat(4.1-02): implement web scraper and spec extractor services for spare-parts search
This commit is contained in:
222
backend/services/spec_extractor.py
Normal file
222
backend/services/spec_extractor.py
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
"""
|
||||||
|
Specification extractor service for search results parsing.
|
||||||
|
|
||||||
|
Extracts product specifications (manufacturer, model, capacity, specs) from search
|
||||||
|
results and maps them to Item model fields for pre-population in onboarding UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, asdict
|
||||||
|
from typing import Optional, Dict, Any
|
||||||
|
import logging
|
||||||
|
|
||||||
|
log = logging.getLogger("ainventory")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ExtractedSpecs:
|
||||||
|
"""Extracted specifications from search results."""
|
||||||
|
|
||||||
|
manufacturer: Optional[str] = None
|
||||||
|
model: Optional[str] = None
|
||||||
|
capacity: Optional[str] = None # e.g., "16GB"
|
||||||
|
memory_type: Optional[str] = None # e.g., "DDR4"
|
||||||
|
speed: Optional[str] = None # e.g., "3200MHz"
|
||||||
|
latency: Optional[str] = None # e.g., "CAS 16"
|
||||||
|
storage_type: Optional[str] = None # e.g., "SSD", "HDD"
|
||||||
|
processor_brand: Optional[str] = None # e.g., "Intel"
|
||||||
|
processor_model: Optional[str] = None # e.g., "Core i7-12700K"
|
||||||
|
power_rating: Optional[str] = None # e.g., "850W"
|
||||||
|
description: str = "" # Full snippet/details from search
|
||||||
|
confidence: float = 0.0 # 0.0-1.0 score
|
||||||
|
|
||||||
|
def to_item_fields(self, category: str) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Map extracted specs to Item model fields.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
category: Item category (e.g., "Memory", "Storage", "Processor")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: type, description, notes
|
||||||
|
"""
|
||||||
|
type_str = ""
|
||||||
|
notes_parts = []
|
||||||
|
|
||||||
|
if category.lower() in ["memory", "ram"]:
|
||||||
|
if self.memory_type:
|
||||||
|
type_str = self.memory_type
|
||||||
|
if self.capacity:
|
||||||
|
notes_parts.append(f"Capacity: {self.capacity}")
|
||||||
|
if self.speed:
|
||||||
|
notes_parts.append(f"Speed: {self.speed}")
|
||||||
|
if self.latency:
|
||||||
|
notes_parts.append(f"Latency: {self.latency}")
|
||||||
|
|
||||||
|
elif category.lower() in ["storage", "ssd", "hdd"]:
|
||||||
|
if self.storage_type:
|
||||||
|
type_str = self.storage_type
|
||||||
|
if self.capacity:
|
||||||
|
notes_parts.append(f"Capacity: {self.capacity}")
|
||||||
|
if self.model:
|
||||||
|
notes_parts.append(f"Model: {self.model}")
|
||||||
|
|
||||||
|
elif category.lower() in ["processor", "cpu", "gpu"]:
|
||||||
|
if self.processor_brand:
|
||||||
|
type_str = self.processor_brand
|
||||||
|
if self.processor_model:
|
||||||
|
notes_parts.append(f"Model: {self.processor_model}")
|
||||||
|
|
||||||
|
elif category.lower() in ["power", "psu"]:
|
||||||
|
if self.power_rating:
|
||||||
|
type_str = self.power_rating
|
||||||
|
if self.model:
|
||||||
|
notes_parts.append(f"Model: {self.model}")
|
||||||
|
|
||||||
|
if self.manufacturer and self.manufacturer not in type_str:
|
||||||
|
notes_parts.insert(0, f"Manufacturer: {self.manufacturer}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": type_str or "Generic",
|
||||||
|
"description": self.description[:200] if self.description else "",
|
||||||
|
"notes": " | ".join(notes_parts) if notes_parts else ""
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def extract_specs_from_search(title: str, snippet: str, url: str = "") -> ExtractedSpecs:
|
||||||
|
"""
|
||||||
|
Extract specifications from a search result (title + snippet).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
title: Search result title
|
||||||
|
snippet: Search result snippet/description
|
||||||
|
url: Source URL (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ExtractedSpecs object with extracted fields and confidence score
|
||||||
|
"""
|
||||||
|
full_text = f"{title} {snippet}".upper()
|
||||||
|
specs = ExtractedSpecs(description=snippet or title)
|
||||||
|
confidence_score = 0.0
|
||||||
|
|
||||||
|
# Manufacturer extraction
|
||||||
|
manufacturers = ["KINGSTON", "SAMSUNG", "CORSAIR", "INTEL", "AMD", "NVIDIA",
|
||||||
|
"CRUCIAL", "ADATA", "WESTERN DIGITAL", "SEAGATE", "HP", "DELL",
|
||||||
|
"LENOVO", "ASUS", "GIGABYTE", "MSI", "EVGA", "SAPPHIRE"]
|
||||||
|
for mfg in manufacturers:
|
||||||
|
if mfg in full_text:
|
||||||
|
specs.manufacturer = mfg.title()
|
||||||
|
confidence_score += 15
|
||||||
|
break
|
||||||
|
|
||||||
|
# Memory type extraction (DDR3/4/5, SODIMM, DIMM)
|
||||||
|
memory_patterns = {
|
||||||
|
r"DDR5?(\s|-)?LP?": "DDR5",
|
||||||
|
r"DDR4\b": "DDR4",
|
||||||
|
r"DDR3\b": "DDR3",
|
||||||
|
r"SODIMM\b": "SODIMM",
|
||||||
|
r"DIMM\b": "DIMM",
|
||||||
|
}
|
||||||
|
for pattern, memory_type in memory_patterns.items():
|
||||||
|
if re.search(pattern, full_text):
|
||||||
|
specs.memory_type = memory_type
|
||||||
|
confidence_score += 20
|
||||||
|
break
|
||||||
|
|
||||||
|
# Capacity extraction (16GB, 512GB, 1TB, etc.)
|
||||||
|
capacity_match = re.search(r"(\d+(?:\.\d+)?)\s*(GB|TB|MB)", full_text)
|
||||||
|
if capacity_match:
|
||||||
|
specs.capacity = f"{capacity_match.group(1)}{capacity_match.group(2)}"
|
||||||
|
confidence_score += 25
|
||||||
|
|
||||||
|
# Speed extraction (3200MHz, 6400MT/s, etc.)
|
||||||
|
speed_match = re.search(r"(\d+)\s*(MHz|MT/S)", full_text)
|
||||||
|
if speed_match:
|
||||||
|
specs.speed = f"{speed_match.group(1)}{speed_match.group(2)}"
|
||||||
|
confidence_score += 10
|
||||||
|
|
||||||
|
# Latency extraction (CAS 16, CAS 18, etc.)
|
||||||
|
latency_match = re.search(r"CAS\s*(\d+)", full_text)
|
||||||
|
if latency_match:
|
||||||
|
specs.latency = f"CAS {latency_match.group(1)}"
|
||||||
|
confidence_score += 10
|
||||||
|
|
||||||
|
# Storage type extraction (SSD, HDD, NVMe, M.2)
|
||||||
|
storage_patterns = {
|
||||||
|
r"NVME\b|NVMe\b": "NVMe",
|
||||||
|
r"SSD\b": "SSD",
|
||||||
|
r"HDD\b|HARD DRIVE": "HDD",
|
||||||
|
r"M\.2\b": "M.2",
|
||||||
|
}
|
||||||
|
for pattern, storage_type in storage_patterns.items():
|
||||||
|
if re.search(pattern, full_text):
|
||||||
|
specs.storage_type = storage_type
|
||||||
|
confidence_score += 20
|
||||||
|
break
|
||||||
|
|
||||||
|
# Processor extraction
|
||||||
|
processor_patterns = {
|
||||||
|
r"INTEL\s+(CORE\s+)?(I[3579]|PENTIUM|CELERON|XEON)": "Intel",
|
||||||
|
r"AMD\s+(RYZEN|EPYC|FX)": "AMD",
|
||||||
|
r"NVIDIA\s+GEFORCE": "NVIDIA",
|
||||||
|
}
|
||||||
|
for pattern, brand in processor_patterns.items():
|
||||||
|
if re.search(pattern, full_text):
|
||||||
|
specs.processor_brand = brand
|
||||||
|
confidence_score += 15
|
||||||
|
break
|
||||||
|
|
||||||
|
# Model number extraction (alphanumeric patterns after brand)
|
||||||
|
if specs.manufacturer:
|
||||||
|
# Look for patterns like "Kingston KF466C40RS-16" or "Samsung 870 EVO"
|
||||||
|
model_match = re.search(rf"{specs.manufacturer.upper()}\s+([A-Z0-9\-]+)", full_text)
|
||||||
|
if model_match:
|
||||||
|
specs.model = model_match.group(1)
|
||||||
|
confidence_score += 20
|
||||||
|
|
||||||
|
# Power rating extraction (850W, 1000W, etc.)
|
||||||
|
power_match = re.search(r"(\d+)(\s*)W(?=\s|$|\D)", full_text)
|
||||||
|
if power_match:
|
||||||
|
specs.power_rating = f"{power_match.group(1)}W"
|
||||||
|
confidence_score += 15
|
||||||
|
|
||||||
|
# Normalize confidence to 0.0-1.0 range
|
||||||
|
specs.confidence = min(100.0, confidence_score) / 100.0
|
||||||
|
|
||||||
|
return specs
|
||||||
|
|
||||||
|
|
||||||
|
def extract_specs_from_multiple_results(
|
||||||
|
results: list[Dict[str, str]],
|
||||||
|
category: str
|
||||||
|
) -> Dict[str, str]:
|
||||||
|
"""
|
||||||
|
Extract specs from multiple search results and return best candidate fields.
|
||||||
|
|
||||||
|
Aggregates specifications across multiple results, preferring specs with
|
||||||
|
highest confidence and deduplication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: List of search result dicts with 'title', 'snippet', 'url'
|
||||||
|
category: Item category for field mapping
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with keys: type, description, notes
|
||||||
|
"""
|
||||||
|
if not results:
|
||||||
|
return {"type": "", "description": "", "notes": ""}
|
||||||
|
|
||||||
|
# Extract from all results and pick highest confidence
|
||||||
|
all_specs = []
|
||||||
|
for result in results:
|
||||||
|
specs = extract_specs_from_search(
|
||||||
|
result.get("title", ""),
|
||||||
|
result.get("snippet", ""),
|
||||||
|
result.get("url", "")
|
||||||
|
)
|
||||||
|
all_specs.append(specs)
|
||||||
|
|
||||||
|
# Pick spec set with highest confidence
|
||||||
|
best_specs = max(all_specs, key=lambda s: s.confidence) if all_specs else ExtractedSpecs()
|
||||||
|
|
||||||
|
return best_specs.to_item_fields(category)
|
||||||
213
backend/services/web_scraper.py
Normal file
213
backend/services/web_scraper.py
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
"""
|
||||||
|
Web scraping service for spare-parts search with rate limiting and fallback engines.
|
||||||
|
|
||||||
|
Provides HTTP request handling with User-Agent rotation and resilient search across
|
||||||
|
Google and Bing with graceful fallback and rate limiting (1 request per 5 seconds).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
import urllib.parse
|
||||||
|
import logging
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
import aiohttp
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
log = logging.getLogger("ainventory")
|
||||||
|
|
||||||
|
USER_AGENT_POOL = [
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (Chrome/120.0.0.0) Safari/537.36",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (Chrome/120.0.0.0) Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (Chrome/120.0.0.0) Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64; rv:121.0) Gecko/20100101 Firefox/121.0",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15",
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (Chrome/119.0.0.0) Safari/537.36 Edg/119.0.0.0",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (Chrome/119.0.0.0) Safari/537.36",
|
||||||
|
"Mozilla/5.0 (iPad; CPU OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1",
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1.2 Mobile/15E148 Safari/604.1",
|
||||||
|
"Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36 (Chrome/120.0.0.0) Mobile Safari/537.36",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class SearchRateLimiter:
|
||||||
|
"""
|
||||||
|
Token bucket rate limiter for search requests.
|
||||||
|
|
||||||
|
Ensures maximum request rate to avoid IP blocking.
|
||||||
|
Default: 1 request per 5 seconds (0.2 req/sec).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, requests_per_second: float = 0.2):
|
||||||
|
"""
|
||||||
|
Initialize rate limiter.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
requests_per_second: Rate limit (default 0.2 = 1 request per 5 seconds)
|
||||||
|
"""
|
||||||
|
self.capacity = 1.0
|
||||||
|
self.refill_rate = requests_per_second
|
||||||
|
self.tokens = 1.0
|
||||||
|
self.last_refill = time.time()
|
||||||
|
|
||||||
|
async def acquire(self):
|
||||||
|
"""
|
||||||
|
Block until rate quota is available (token bucket algorithm).
|
||||||
|
|
||||||
|
Uses time-based token refill without asyncio.sleep loops.
|
||||||
|
"""
|
||||||
|
while self.tokens < 1.0:
|
||||||
|
now = time.time()
|
||||||
|
elapsed = now - self.last_refill
|
||||||
|
self.tokens = min(
|
||||||
|
self.capacity,
|
||||||
|
self.tokens + elapsed * self.refill_rate
|
||||||
|
)
|
||||||
|
self.last_refill = now
|
||||||
|
|
||||||
|
if self.tokens < 1.0:
|
||||||
|
await asyncio.sleep(0.1)
|
||||||
|
|
||||||
|
self.tokens -= 1.0
|
||||||
|
|
||||||
|
|
||||||
|
async def search_google(query: str, timeout: int = 10) -> Optional[List[Dict[str, str]]]:
|
||||||
|
"""
|
||||||
|
Search Google for spare-parts information.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with 'title', 'url', 'snippet' keys, or None on error
|
||||||
|
|
||||||
|
Example:
|
||||||
|
>>> results = await search_google("Kingston DDR4 16GB RAM")
|
||||||
|
>>> results[0]['title'] # Product name
|
||||||
|
"""
|
||||||
|
url = f"https://www.google.com/search?q={urllib.parse.quote(query)}"
|
||||||
|
headers = {"User-Agent": random.choice(USER_AGENT_POOL)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)) as response:
|
||||||
|
if response.status in (429, 403):
|
||||||
|
log.warning(f"Google blocked request: {response.status}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
html = await response.text()
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Google search result container: div.g
|
||||||
|
for result_div in soup.find_all("div", class_="g")[:5]: # Top 5 results
|
||||||
|
title_elem = result_div.find("h3")
|
||||||
|
url_elem = result_div.find("a")
|
||||||
|
snippet_elem = result_div.find("span", class_="VwiC3b")
|
||||||
|
|
||||||
|
if title_elem and url_elem:
|
||||||
|
results.append({
|
||||||
|
"title": title_elem.get_text(),
|
||||||
|
"url": url_elem.get("href", ""),
|
||||||
|
"snippet": snippet_elem.get_text() if snippet_elem else ""
|
||||||
|
})
|
||||||
|
|
||||||
|
return results if results else None
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning(f"Google search timed out: {query}")
|
||||||
|
raise
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
log.warning(f"Google search failed: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Google search error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def search_bing(query: str, timeout: int = 10) -> Optional[List[Dict[str, str]]]:
|
||||||
|
"""
|
||||||
|
Search Bing for spare-parts information (fallback from Google).
|
||||||
|
|
||||||
|
More stable than Google with less blocking.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
query: Search query string
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts with 'title', 'url', 'snippet' keys, or None on error
|
||||||
|
"""
|
||||||
|
url = f"https://www.bing.com/search?q={urllib.parse.quote(query)}"
|
||||||
|
headers = {"User-Agent": random.choice(USER_AGENT_POOL)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)) as response:
|
||||||
|
if response.status in (429, 403):
|
||||||
|
log.warning(f"Bing blocked request: {response.status}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
html = await response.text()
|
||||||
|
soup = BeautifulSoup(html, "html.parser")
|
||||||
|
results = []
|
||||||
|
|
||||||
|
# Bing search result container: li.b_algo
|
||||||
|
for result_li in soup.find_all("li", class_="b_algo")[:5]: # Top 5 results
|
||||||
|
title_elem = result_li.find("h2")
|
||||||
|
url_elem = result_li.find("a")
|
||||||
|
snippet_elem = result_li.find("p")
|
||||||
|
|
||||||
|
if title_elem and url_elem:
|
||||||
|
results.append({
|
||||||
|
"title": title_elem.get_text(),
|
||||||
|
"url": url_elem.get("href", ""),
|
||||||
|
"snippet": snippet_elem.get_text() if snippet_elem else ""
|
||||||
|
})
|
||||||
|
|
||||||
|
return results if results else None
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning(f"Bing search timed out: {query}")
|
||||||
|
raise
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
log.warning(f"Bing search failed: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"Bing search error: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_and_parse_html(url: str, timeout: int = 10) -> Optional[str]:
|
||||||
|
"""
|
||||||
|
Fetch and parse HTML from an arbitrary URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Target URL
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HTML content string or None on error
|
||||||
|
"""
|
||||||
|
headers = {"User-Agent": random.choice(USER_AGENT_POOL)}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with aiohttp.ClientSession() as session:
|
||||||
|
async with session.get(url, headers=headers, timeout=aiohttp.ClientTimeout(total=timeout)) as response:
|
||||||
|
if response.status == 200:
|
||||||
|
return await response.text()
|
||||||
|
else:
|
||||||
|
log.warning(f"Failed to fetch {url}: {response.status}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
log.warning(f"HTML fetch timed out: {url}")
|
||||||
|
raise
|
||||||
|
except aiohttp.ClientError as e:
|
||||||
|
log.warning(f"HTML fetch failed: {e}")
|
||||||
|
return None
|
||||||
|
except Exception as e:
|
||||||
|
log.error(f"HTML fetch error: {e}")
|
||||||
|
return None
|
||||||
Reference in New Issue
Block a user