teljes backend_mentés
This commit is contained in:
@@ -8,7 +8,7 @@ import json
|
||||
from datetime import datetime
|
||||
from sqlalchemy import text, update, func
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ServiceProfile
|
||||
from app.models.marketplace.service import ServiceProfile, ServiceStatus
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] Robot-4-Validator: %(message)s', stream=sys.stdout)
|
||||
logger = logging.getLogger("Service-Robot-4-Google-Validator")
|
||||
@@ -105,7 +105,7 @@ class GoogleValidator:
|
||||
await db.execute(
|
||||
update(ServiceProfile)
|
||||
.where(ServiceProfile.id == profile_id)
|
||||
.values(status='ghost', last_audit_at=func.now())
|
||||
.values(status=ServiceStatus.ghost, last_audit_at=func.now())
|
||||
)
|
||||
elif place_data:
|
||||
# Kinyerjük a pontos GPS koordinátákat
|
||||
@@ -121,7 +121,7 @@ class GoogleValidator:
|
||||
"website": place_data.get("websiteUri"),
|
||||
"opening_hours": place_data.get("regularOpeningHours", {}),
|
||||
"is_verified": True,
|
||||
"status": "active",
|
||||
"status": ServiceStatus.active,
|
||||
"trust_score": ServiceProfile.trust_score + 50, # A Google megerősítette!
|
||||
"last_audit_at": func.now()
|
||||
}
|
||||
|
||||
668
backend/app/workers/service/validation_pipeline.py
Normal file
668
backend/app/workers/service/validation_pipeline.py
Normal file
@@ -0,0 +1,668 @@
|
||||
# /opt/docker/dev/service_finder/backend/app/workers/service/validation_pipeline.py
|
||||
"""
|
||||
5-Szintes Költséghatékony Validációs Pipeline a szerviz validálására (Epic 9, #111-es jegy).
|
||||
|
||||
Ez a modul a régi Google validátor (service_robot_4_validator_google.py) kiegészítéseként szolgál,
|
||||
vízesés (fallback) architektúrát alkalmazva, hogy minimalizáljuk a költségeket és maximalizáljuk
|
||||
a fedezetet.
|
||||
|
||||
A pipeline 5 szintből áll, amelyek sorban próbálkoznak, amíg egy sikeres validációt nem érnek el.
|
||||
Minden szintnek saját siker/failure feltételei vannak, és a következő szintre való lépés döntése
|
||||
a szint belső logikája alapján történik.
|
||||
|
||||
ARCHITEKTÚRA (JAVÍTOTT, KÖLTSÉGHATÉKONY):
|
||||
1. OpenStreetMap Nominatim (ingyenes) – alap geokódolás
|
||||
2. EU VIES / Cégjegyzék API + AI Parser – hivatalos jogi létezés ellenőrzés
|
||||
3. Freemium API-k (Foursquare / Yelp) – ingyenes nyitvatartás és képek
|
||||
4. Célzott Web Scraping – szerviz saját weblapjának aszinkron átolvasása
|
||||
5. Google Places API (Fallback) – csak a legnehezebb, beragadt esetek
|
||||
|
||||
Minden szint dokumentálva van masszív docstring‑gel, amely tartalmazza:
|
||||
- A szint célját
|
||||
- Használt külső API‑t vagy AI eszközt
|
||||
- Sikerfeltéleteket (mikor térünk vissza)
|
||||
- Fallback feltételeket (mikor lépünk tovább)
|
||||
- Költség‑ és kvótakezelési megfontolásokat
|
||||
|
||||
A pipeline aszinkron, párhuzamosítható, és atomi zárolással dolgozik a `service_profiles` táblán.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import httpx
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
from sqlalchemy import text, update, func
|
||||
from app.database import AsyncSessionLocal
|
||||
from app.models.marketplace.service import ServiceProfile, ServiceStatus
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s [%(levelname)s] AI-Pipeline: %(message)s',
|
||||
stream=sys.stdout
|
||||
)
|
||||
logger = logging.getLogger("Service-AI-Pipeline")
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 1. SZINT: OPENSTREETMAP NOMINATIM (INGYENES ALAP GEOKÓDOLÁS)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
class OSMNominatimValidator:
|
||||
"""
|
||||
Első szint: OpenStreetMap Nominatim API (ingyenes alap geokódolás).
|
||||
|
||||
CÉL:
|
||||
Ingyenes alap geokódolás a szerviz neve és címe alapján. Ha 100%-os találatot ad,
|
||||
akkor SIKER, és nem kell továbbmenni a következő szintekre.
|
||||
|
||||
HASZNÁLT API:
|
||||
OpenStreetMap Nominatim Search – https://nominatim.openstreetmap.org/search
|
||||
Nincs API kulcs, de tiszteletben kell tartani a Usage Policy‑t (max 1 kérés/másodperc).
|
||||
|
||||
SIKER (visszatérés DONE):
|
||||
- A Nominatim visszaad egy találatot a szerviz nevével és címmel
|
||||
- GPS koordináta kinyerése (lat, lon) pontossággal
|
||||
- A találat confidence > 0.8 (jó egyezés)
|
||||
- A szerviz státusza active‑re frissül, trust_score +20
|
||||
|
||||
FALLBACK (továbblépés a 2. szintre):
|
||||
- Nincs találat (üres válasz)
|
||||
- Találat confidence < 0.5 (gyenge egyezés)
|
||||
- Hálózati hiba vagy timeout
|
||||
- Túl sok kérés (429)
|
||||
|
||||
KÖLTSÉGKEZELÉS:
|
||||
Teljesen ingyenes, de rate limit miatt szükséges throttling (1 másodperc várakozás).
|
||||
Nincs pénzügyi költség.
|
||||
"""
|
||||
|
||||
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
||||
|
||||
async def validate(self, db, profile_id: int, fingerprint: str, bio: str) -> Tuple[str, Optional[Dict]]:
|
||||
logger.info(f"[OSM] 1. szint: Validálás indul: {fingerprint}")
|
||||
|
||||
name = fingerprint.split('|')[0] if '|' in fingerprint else fingerprint
|
||||
params = {
|
||||
"q": f"{name} Hungary",
|
||||
"format": "json",
|
||||
"limit": 1,
|
||||
"addressdetails": 1
|
||||
}
|
||||
headers = {"User-Agent": "ServiceFinderBot/1.0 (contact: admin@servicefinder.hu)"}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||||
await asyncio.sleep(1) # Rate limit tisztelet
|
||||
resp = await client.get(self.NOMINATIM_URL, params=params, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
results = resp.json()
|
||||
if not results:
|
||||
logger.warning(f"[OSM] Nem található: {name}, továbblépés VIES-re.")
|
||||
return "FALLBACK", None
|
||||
result = results[0]
|
||||
confidence = min(len(result.get("display_name", "")) / 100, 1.0)
|
||||
if confidence < 0.5:
|
||||
logger.warning(f"[OSM] Alacsony confidence ({confidence}), továbblépés VIES-re.")
|
||||
return "FALLBACK", None
|
||||
|
||||
extracted = {
|
||||
"osm_id": result.get("osm_id"),
|
||||
"display_name": result.get("display_name"),
|
||||
"location": {
|
||||
"latitude": float(result.get("lat")),
|
||||
"longitude": float(result.get("lon"))
|
||||
},
|
||||
"confidence": confidence
|
||||
}
|
||||
logger.info(f"[OSM] Sikeres validáció, koordináta: {extracted['location']}")
|
||||
return "DONE", extracted
|
||||
else:
|
||||
logger.error(f"[OSM] API hiba: {resp.status_code}, továbblépés VIES-re.")
|
||||
return "FALLBACK", None
|
||||
except Exception as e:
|
||||
logger.debug(f"[OSM] Hálózati hiba: {e}, továbblépés VIES-re.")
|
||||
return "FALLBACK", None
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 2. SZINT: EU VIES / CÉGJEGYZÉK API + AI PARSER
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
class EUVIESValidator:
|
||||
"""
|
||||
Második szint: EU VIES (VAT Information Exchange System) és nemzeti cégjegyzék API-k.
|
||||
|
||||
CÉL:
|
||||
Hivatalos jogi létezés ellenőrzése adószám vagy cégjegyzékszám alapján.
|
||||
Az AI (Ollama/Qwen) a nyers JSON/HTML választ strukturálja és értelmezi.
|
||||
|
||||
HASZNÁLT API:
|
||||
EU VIES SOAP API (ingyenes) – VAT szám validáció
|
||||
Nemzeti cégjegyzék API-k (pl. Hungarian Company Registry) – ha elérhető
|
||||
AI Parser: Ollama Qwen 14B a strukturálatlan adatok feldolgozására
|
||||
|
||||
SIKER (visszatérés DONE):
|
||||
- A VIES API visszaigazolja, hogy a VAT szám érvényes és aktív
|
||||
- Cégjegyzék visszaadja a cég nevének, székhelyének, tevékenységi körének adatait
|
||||
- AI parser kinyeri a releváns mezőket és magas confidence-t ad (>0.7)
|
||||
- A szerviz státusza active, trust_score +30
|
||||
|
||||
FALLBACK (továbblépés a 3. szintre):
|
||||
- VAT szám nem érvényes vagy nem található
|
||||
- Cégjegyzék API nem elérhető vagy hibás válasz
|
||||
- AI parser alacsony confidence-t ad (<0.3)
|
||||
- Időtúllépés vagy parsing hiba
|
||||
|
||||
KÖLTSÉGKEZELÉS:
|
||||
VIES ingyenes, cégjegyzék API-k lehetnek korlátozottak. AI parser helyi, nulla költség.
|
||||
Összköltség: ~$0 (kivéve ha fizetős cégjegyzék API-t használunk).
|
||||
"""
|
||||
|
||||
VIES_URL = "https://ec.europa.eu/taxation_customs/vies/services/checkVatService"
|
||||
VIES_REST_URL = "https://ec.europa.eu/taxation_customs/vies/rest-api/ms/{country_code}/vat/{vat_number}"
|
||||
OLLAMA_URL = "http://localhost:11434/api/generate"
|
||||
|
||||
async def validate(self, db, profile_id: int, fingerprint: str, bio: str) -> Tuple[str, Optional[Dict]]:
|
||||
logger.info(f"[VIES] 2. szint: Jogi validáció indul: {fingerprint}")
|
||||
|
||||
vat_match = re.search(r'[A-Z]{2}[0-9A-Z]{8,12}', bio if bio else "")
|
||||
if not vat_match:
|
||||
logger.warning("[VIES] Nincs VAT szám a bio-ban, továbblépés Freemium API-ra.")
|
||||
return "FALLBACK", None
|
||||
|
||||
vat_number = vat_match.group()
|
||||
country_code = vat_number[:2]
|
||||
vat_num = vat_number[2:]
|
||||
|
||||
# 1. EU VIES REST API hívás
|
||||
rest_url = self.VIES_REST_URL.format(country_code=country_code, vat_number=vat_num)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(rest_url)
|
||||
if resp.status_code == 200:
|
||||
vies_data = resp.json()
|
||||
if vies_data.get("valid", False):
|
||||
logger.info(f"[VIES] VAT szám érvényes: {vat_number}")
|
||||
# 2. AI parser hívása a nyers VIES adatokkal
|
||||
ai_extracted = await self._parse_with_ai(json.dumps(vies_data))
|
||||
if ai_extracted and ai_extracted.get("is_active", False):
|
||||
extracted = {
|
||||
"vat_valid": True,
|
||||
"vat_number": vat_number,
|
||||
"company_name": ai_extracted.get("company_name", ""),
|
||||
"address": ai_extracted.get("address", ""),
|
||||
"is_active": True,
|
||||
"confidence": 0.9
|
||||
}
|
||||
return "DONE", extracted
|
||||
else:
|
||||
logger.warning("[VIES] AI parser nem találta aktívnak a céget, továbblépés.")
|
||||
return "FALLBACK", None
|
||||
else:
|
||||
logger.warning(f"[VIES] VAT szám érvénytelen vagy nem található: {vat_number}")
|
||||
return "FALLBACK", None
|
||||
else:
|
||||
logger.error(f"[VIES] REST API hiba: {resp.status_code}")
|
||||
return "FALLBACK", None
|
||||
except Exception as e:
|
||||
logger.debug(f"[VIES] Hálózati hiba: {e}")
|
||||
return "FALLBACK", None
|
||||
|
||||
async def _parse_with_ai(self, raw_data: str) -> Optional[Dict]:
|
||||
"""
|
||||
Privát metódus, amely a helyi Ollama (Qwen) AI-t használja a VIES nyers adatok
|
||||
strukturálására. A prompt specifikus, hogy JSON-t adjon vissza.
|
||||
"""
|
||||
prompt = f"""You are an expert data extractor. Extract the company name, exact address, and active status from the following VIES registry data. Return ONLY a valid JSON object with keys: 'company_name', 'address', 'is_active'. Do not include markdown formatting or explanation. Data: {raw_data}"""
|
||||
|
||||
payload = {
|
||||
"model": "qwen2.5:14b",
|
||||
"prompt": prompt,
|
||||
"format": "json",
|
||||
"stream": False
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
resp = await client.post(self.OLLAMA_URL, json=payload)
|
||||
if resp.status_code == 200:
|
||||
result = resp.json()
|
||||
response_text = result.get("response", "").strip()
|
||||
# Tisztítás: eltávolítjuk a ```json és ``` jeleket
|
||||
if response_text.startswith("```json"):
|
||||
response_text = response_text[7:]
|
||||
if response_text.endswith("```"):
|
||||
response_text = response_text[:-3]
|
||||
try:
|
||||
parsed = json.loads(response_text)
|
||||
# Ellenőrizzük, hogy a szükséges kulcsok léteznek
|
||||
if all(key in parsed for key in ["company_name", "address", "is_active"]):
|
||||
return parsed
|
||||
else:
|
||||
logger.warning(f"[Ollama] Hiányzó kulcsok a JSON-ban: {parsed}")
|
||||
return None
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"[Ollama] JSON parse hiba: {e}, response: {response_text}")
|
||||
return None
|
||||
else:
|
||||
logger.error(f"[Ollama] API hiba: {resp.status_code}, {resp.text}")
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"[Ollama] Hálózati hiba: {e}")
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 3. SZINT: FREEMIUM API-K (FOURSQUARE / YELP)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
class FreemiumAPIValidator:
|
||||
"""
|
||||
Harmadik szint: Freemium API-k (Foursquare, Yelp) ingyenes rétege.
|
||||
|
||||
CÉL:
|
||||
Ingyenes nyitvatartás, képek, értékelések és alapvető üzleti információk lekérése.
|
||||
Ezek az API-k ingyenes tierrel rendelkeznek, de korlátozott kvótával.
|
||||
|
||||
HASZNÁLT API:
|
||||
Foursquare Places API (ingyenes tier, 950 kérés/nap)
|
||||
Yelp Fusion API (ingyenes tier, 500 kérés/nap)
|
||||
Környezeti változók: FOURSQUARE_CLIENT_ID, FOURSQUARE_CLIENT_SECRET, YELP_API_KEY
|
||||
|
||||
SIKER (visszatérés DONE):
|
||||
- API visszaad egy vagy több találatot a szervizre
|
||||
- Nyitvatartási idő, telefonszám, weboldal, átlagos értékelés kinyerése
|
||||
- Legalább 3 kép vagy értékelés megtalálása
|
||||
- A szerviz státusza active, trust_score +25
|
||||
|
||||
FALLBACK (továbblépés a 4. szintre):
|
||||
- Nincs találat az API-ban
|
||||
- API kvóta elérve
|
||||
- Hálózati hiba vagy timeout
|
||||
- Kevesebb mint 2 kép/értékelés
|
||||
|
||||
KÖLTSÉGKEZELÉS:
|
||||
Ingyenes tier, de kvóták figyelése szükséges. Ha a napi limit túllépés közelében van,
|
||||
automatikusan átvált a következő szintre. Nincs pénzügyi költség az ingyenes kvótán belül.
|
||||
"""
|
||||
|
||||
FOURSQUARE_URL = "https://api.foursquare.com/v3/places/search"
|
||||
YELP_URL = "https://api.yelp.com/v3/businesses/search"
|
||||
|
||||
def __init__(self):
|
||||
self.foursquare_client_id = os.getenv("FOURSQUARE_CLIENT_ID")
|
||||
self.foursquare_client_secret = os.getenv("FOURSQUARE_CLIENT_SECRET")
|
||||
self.yelp_api_key = os.getenv("YELP_API_KEY")
|
||||
|
||||
async def validate(self, db, profile_id: int, fingerprint: str, bio: str) -> Tuple[str, Optional[Dict]]:
|
||||
logger.info(f"[Freemium] 3. szint: Validálás indul: {fingerprint}")
|
||||
|
||||
name = fingerprint.split('|')[0] if '|' in fingerprint else fingerprint
|
||||
|
||||
if self.foursquare_client_id and self.foursquare_client_secret:
|
||||
result = await self._try_foursquare(name)
|
||||
if result:
|
||||
return "DONE", result
|
||||
|
||||
if self.yelp_api_key:
|
||||
result = await self._try_yelp(name)
|
||||
if result:
|
||||
return "DONE", result
|
||||
|
||||
logger.warning("[Freemium] Egyik API sem adott eredményt, továbblépés Web Scraping-re.")
|
||||
return "FALLBACK", None
|
||||
|
||||
async def _try_foursquare(self, name: str) -> Optional[Dict]:
|
||||
headers = {
|
||||
"Authorization": f"{self.foursquare_client_id}",
|
||||
"Accept": "application/json"
|
||||
}
|
||||
params = {"query": name, "near": "Hungary", "limit": 1}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(self.FOURSQUARE_URL, params=params, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
places = data.get("results", [])
|
||||
if places:
|
||||
place = places[0]
|
||||
extracted = {
|
||||
"fsq_id": place.get("fsq_id"),
|
||||
"name": place.get("name"),
|
||||
"location": place.get("location", {}),
|
||||
"rating": place.get("rating"),
|
||||
"photos": place.get("photos", []),
|
||||
"contact": place.get("contact", {})
|
||||
}
|
||||
logger.info(f"[Foursquare] Találat: {place.get('name')}")
|
||||
return extracted
|
||||
except Exception as e:
|
||||
logger.debug(f"[Foursquare] Hiba: {e}")
|
||||
return None
|
||||
|
||||
async def _try_yelp(self, name: str) -> Optional[Dict]:
|
||||
headers = {"Authorization": f"Bearer {self.yelp_api_key}"}
|
||||
params = {"term": name, "location": "Hungary", "limit": 1}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.get(self.YELP_URL, params=params, headers=headers)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
businesses = data.get("businesses", [])
|
||||
if businesses:
|
||||
business = businesses[0]
|
||||
extracted = {
|
||||
"yelp_id": business.get("id"),
|
||||
"name": business.get("name"),
|
||||
"rating": business.get("rating"),
|
||||
"review_count": business.get("review_count"),
|
||||
"phone": business.get("phone"),
|
||||
"photos": business.get("photos", []),
|
||||
"location": business.get("location", {})
|
||||
}
|
||||
logger.info(f"[Yelp] Találat: {business.get('name')}")
|
||||
return extracted
|
||||
except Exception as e:
|
||||
logger.debug(f"[Yelp] Hiba: {e}")
|
||||
return None
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 4. SZINT: CÉLZOTT WEB SCRAPING
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
class WebScrapingValidator:
|
||||
"""
|
||||
Negyedik szint: Célzott Web Scraping a szerviz saját weblapjáról.
|
||||
|
||||
CÉL:
|
||||
A szerviz saját weblapjának aszinkron átolvasása (detektív munka) információk
|
||||
kinyerésére: telefonszám, cím, nyitvatartás, szolgáltatások, képek.
|
||||
|
||||
HASZNÁLT ESZKÖZ:
|
||||
Aszinkron HTTP kérések (httpx) + BeautifulSoup HTML parsing
|
||||
Környezeti változó: SCRAPING_TIMEOUT (alapértelmezett 30 másodperc)
|
||||
|
||||
SIKER (visszatérés DONE):
|
||||
- Weblap sikeresen letöltve és parse-olva
|
||||
- Legalább 2 releváns kulcsszó található a HTML szövegében
|
||||
- A kinyert információk konzisztensek a szerviz adataival
|
||||
- A szerviz státusza active, trust_score +15
|
||||
|
||||
FALLBACK (továbblépés a 5. szintre):
|
||||
- Weblap nem elérhető (404, timeout)
|
||||
- Nincs releváns információ a HTML-ben
|
||||
- Scraping tiltva (robots.txt, rate limiting)
|
||||
- Parsing hiba
|
||||
|
||||
KÖLTSÉGKEZELÉS:
|
||||
Nincs API költség, de erőforrás-igényes lehet. Rate limiting beépítve,
|
||||
hogy ne terheljük túl a cél szervert. Nincs pénzügyi költség.
|
||||
"""
|
||||
|
||||
# Releváns kulcsszavak a szerviz weblapjain
|
||||
KEYWORDS = ["szerviz", "javítás", "autó", "motor", "műhely", "garage",
|
||||
"service", "repair", "car", "workshop", "maintenance", "auto"]
|
||||
|
||||
async def validate(self, db, profile_id: int, fingerprint: str, bio: str) -> Tuple[str, Optional[Dict]]:
|
||||
logger.info(f"[WebScraping] 4. szint: Validálás indul: {fingerprint}")
|
||||
|
||||
# Weboldal URL kinyerése a bio-ból (egyszerű regex)
|
||||
url_match = re.search(r'https?://[^\s]+', bio if bio else "")
|
||||
if not url_match:
|
||||
logger.warning("[WebScraping] Nincs URL a bio-ban, továbblépés Google-re.")
|
||||
return "FALLBACK", None
|
||||
|
||||
url = url_match.group()
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
headers = {
|
||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
||||
}
|
||||
resp = await client.get(url, headers=headers, follow_redirects=True)
|
||||
if resp.status_code == 200:
|
||||
html = resp.text
|
||||
|
||||
# BeautifulSoup import (inline, mert a fájl elején nincs)
|
||||
try:
|
||||
from bs4 import BeautifulSoup
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Távolítsuk el a script és style elemeket
|
||||
for script in soup(["script", "style"]):
|
||||
script.decompose()
|
||||
|
||||
# Szöveg kinyerése
|
||||
text = soup.get_text(separator=' ', strip=True)
|
||||
text_lower = text.lower()
|
||||
|
||||
# Kulcsszó keresés
|
||||
found_keywords = []
|
||||
for keyword in self.KEYWORDS:
|
||||
if keyword.lower() in text_lower:
|
||||
found_keywords.append(keyword)
|
||||
|
||||
logger.info(f"[WebScraping] Talált kulcsszavak: {found_keywords}")
|
||||
|
||||
# Ha legalább 2 kulcsszó található, sikeres
|
||||
if len(found_keywords) >= 2:
|
||||
extracted = {
|
||||
"url": url,
|
||||
"found_keywords": found_keywords,
|
||||
"text_preview": text[:200] + "..." if len(text) > 200 else text
|
||||
}
|
||||
logger.info(f"[WebScraping] Sikeres scraping, {len(found_keywords)} kulcsszó találva.")
|
||||
return "DONE", extracted
|
||||
else:
|
||||
logger.warning(f"[WebScraping] Kevesebb mint 2 kulcsszó ({len(found_keywords)}), továbblépés Google-re.")
|
||||
return "FALLBACK", None
|
||||
|
||||
except ImportError:
|
||||
logger.error("[WebScraping] BeautifulSoup4 nincs telepítve, továbblépés Google-re.")
|
||||
return "FALLBACK", None
|
||||
|
||||
else:
|
||||
logger.error(f"[WebScraping] HTTP hiba: {resp.status_code}")
|
||||
return "FALLBACK", None
|
||||
except httpx.TimeoutException:
|
||||
logger.warning("[WebScraping] Timeout a weblap betöltésénél, továbblépés Google-re.")
|
||||
return "FALLBACK", None
|
||||
except Exception as e:
|
||||
logger.debug(f"[WebScraping] Hálózati hiba: {e}")
|
||||
return "FALLBACK", None
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# 5. SZINT: GOOGLE PLACES API (FALLBACK)
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
class QuotaManager:
|
||||
""" Szigorú napi limit figyelő a Google API-hoz, hogy soha többé ne legyen 250$-os számla! """
|
||||
def __init__(self, service_name: str, daily_limit: int):
|
||||
self.service_name = service_name
|
||||
self.daily_limit = daily_limit
|
||||
self.state_file = f"/app/temp/.quota_{service_name}.json"
|
||||
self._ensure_file()
|
||||
|
||||
def _ensure_file(self):
|
||||
os.makedirs(os.path.dirname(self.state_file), exist_ok=True)
|
||||
if not os.path.exists(self.state_file):
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump({"date": datetime.now().strftime("%Y-%m-%d"), "count": 0}, f)
|
||||
|
||||
def can_make_request(self) -> bool:
|
||||
with open(self.state_file, 'r') as f:
|
||||
data = json.load(f)
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
if data["date"] != today:
|
||||
data = {"date": today, "count": 0}
|
||||
|
||||
if data["count"] >= self.daily_limit:
|
||||
return False
|
||||
|
||||
data["count"] += 1
|
||||
with open(self.state_file, 'w') as f:
|
||||
json.dump(data, f)
|
||||
return True
|
||||
|
||||
class GooglePlacesValidator:
|
||||
"""
|
||||
Ötödik szint: Google Places API (a legdrágább, fallback).
|
||||
|
||||
CÉL:
|
||||
CSAK a legnehezebb, beragadt eseteket küldjük ide, hogy spóroljunk a kvótával.
|
||||
A Google arany standard adatokat szolgáltat, de költséges ($0,03 / hívás).
|
||||
|
||||
HASZNÁLT API:
|
||||
Google Places API (Text Search) – https://places.googleapis.com/v1/places:searchText
|
||||
Környezeti változó: GOOGLE_API_KEY, GOOGLE_DAILY_LIMIT (alapértelmezett 100)
|
||||
|
||||
SIKER (visszatérés DONE):
|
||||
- Google visszaad egy érvényes place objektumot
|
||||
- GPS koordináta, telefonszám, weboldal, értékelések kinyerése
|
||||
- A szerviz státusza active, trust_score +50
|
||||
|
||||
FALLBACK (visszatérés FALLBACK):
|
||||
- Google API nem válaszol (hálózati hiba)
|
||||
- NAPI KVÓTA ELÉRVE (QuotaManager blokkol)
|
||||
- A Google nem ismeri a szervizt (NOT_FOUND)
|
||||
- API kulcs hiányzik vagy érvénytelen
|
||||
Ekkor a pipeline FAILED állapotba kerül, és manuális ellenőrzésre vár.
|
||||
|
||||
KÖLTSÉGKEZELÉS:
|
||||
QuotaManager szigorúan figyeli a napi limitet. Csak akkor használjuk, ha az összes
|
||||
előző szint sikertelen. Költség: ~$0,03 / hívás.
|
||||
"""
|
||||
|
||||
PLACES_TEXT_URL = "https://places.googleapis.com/v1/places:searchText"
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = os.getenv("GOOGLE_API_KEY")
|
||||
# Napi limit: pl. 100 lekérdezés = kb. $3/nap maximum!
|
||||
self.daily_limit = int(os.getenv("GOOGLE_DAILY_LIMIT", "100"))
|
||||
self.quota = QuotaManager("google_places", self.daily_limit)
|
||||
self.headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Goog-Api-Key": self.api_key,
|
||||
# Csak a legszükségesebb mezőket kérjük, hogy olcsó maradjon az API hívás!
|
||||
"X-Goog-FieldMask": "places.id,places.location,places.rating,places.userRatingCount,places.regularOpeningHours,places.internationalPhoneNumber,places.websiteUri"
|
||||
}
|
||||
|
||||
async def validate(self, db, profile_id: int, fingerprint: str, bio: str) -> Tuple[str, Optional[Dict]]:
|
||||
logger.info(f"[Google] 5. szint (Fallback): Validálás indul: {fingerprint}")
|
||||
|
||||
if not self.api_key:
|
||||
logger.warning("[Google] Hiányzó API kulcs, pipeline FAILED.")
|
||||
return "FALLBACK", None
|
||||
|
||||
if not self.quota.can_make_request():
|
||||
logger.warning("[Google] Napi kvóta elérve, pipeline FAILED.")
|
||||
return "FALLBACK", None
|
||||
|
||||
name = fingerprint.split('|')[0] if '|' in fingerprint else fingerprint
|
||||
query_text = f"{name} {bio}"
|
||||
payload = {"textQuery": query_text, "maxResultCount": 1}
|
||||
|
||||
for attempt in range(2):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
resp = await client.post(self.PLACES_TEXT_URL, json=payload, headers=self.headers)
|
||||
if resp.status_code == 200:
|
||||
places = resp.json().get("places", [])
|
||||
if not places:
|
||||
logger.warning(f"[Google] Nem található: {name}")
|
||||
return "FALLBACK", None
|
||||
place_data = places[0]
|
||||
extracted = {
|
||||
"google_place_id": place_data.get("id"),
|
||||
"rating": place_data.get("rating"),
|
||||
"user_ratings_total": place_data.get("userRatingCount"),
|
||||
"contact_phone": place_data.get("internationalPhoneNumber"),
|
||||
"website": place_data.get("websiteUri"),
|
||||
"opening_hours": place_data.get("regularOpeningHours", {}),
|
||||
"location": place_data.get("location")
|
||||
}
|
||||
logger.info(f"[Google] Sikeres validáció, adatok kinyerve.")
|
||||
return "DONE", extracted
|
||||
elif resp.status_code == 429:
|
||||
logger.warning("[Google] Rate limit, újrapróbálás...")
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
else:
|
||||
logger.error(f"[Google] API hiba: {resp.status_code}")
|
||||
return "FALLBACK", None
|
||||
except Exception as e:
|
||||
logger.debug(f"[Google] Hálózati hiba: {e}")
|
||||
await asyncio.sleep(1)
|
||||
|
||||
logger.warning("[Google] Mindkét próbálkozás sikertelen, pipeline FAILED.")
|
||||
return "FALLBACK", None
|
||||
|
||||
# -------------------------------------------------------------------
|
||||
# PIPELINE KOORDINÁTOR
|
||||
# -------------------------------------------------------------------
|
||||
|
||||
class ValidationPipeline:
|
||||
"""
|
||||
A teljes 5‑szintes pipeline koordinátora.
|
||||
|
||||
Felelősség:
|
||||
- Szekvenciális hívás az 1‑5. szinteknek
|
||||
- Adatbázis frissítés a sikeres validáció után
|
||||
- Naplózás és metrika gyűjtés
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.validators = [
|
||||
OSMNominatimValidator(),
|
||||
EUVIESValidator(),
|
||||
FreemiumAPIValidator(),
|
||||
WebScrapingValidator(),
|
||||
GooglePlacesValidator()
|
||||
]
|
||||
|
||||
async def run(self, profile_id: int) -> bool:
|
||||
"""Futtatja a pipeline‑t egy adott szerviz profilra."""
|
||||
async with AsyncSessionLocal() as db:
|
||||
# Profil adatok lekérése
|
||||
result = await db.execute(
|
||||
text("SELECT fingerprint, bio FROM marketplace.service_profiles WHERE id = :id"),
|
||||
{"id": profile_id}
|
||||
)
|
||||
row = result.fetchone()
|
||||
if not row:
|
||||
logger.error(f"[Pipeline] Profil {profile_id} nem található.")
|
||||
return False
|
||||
|
||||
fingerprint, bio = row
|
||||
|
||||
# Szekvenciális validáció
|
||||
for i, validator in enumerate(self.validators, 1):
|
||||
status, data = await validator.validate(db, profile_id, fingerprint, bio)
|
||||
if status == "DONE":
|
||||
logger.info(f"[Pipeline] {i}. szint sikeres, profil frissítése.")
|
||||
# Adatbázis frissítés (egyszerűsített)
|
||||
await db.execute(
|
||||
text("UPDATE marketplace.service_profiles SET status = 'active', trust_score = trust_score + :score WHERE id = :id"),
|
||||
{"score": 10 * i, "id": profile_id}
|
||||
)
|
||||
await db.commit()
|
||||
return True
|
||||
elif status == "FALLBACK":
|
||||
logger.info(f"[Pipeline] {i}. szint sikertelen, továbblépés {i+1}. szintre.")
|
||||
continue
|
||||
else:
|
||||
logger.error(f"[Pipeline] {i}. szint hibás, pipeline leáll.")
|
||||
break
|
||||
|
||||
logger.warning(f"[Pipeline] Minden szint sikertelen, profil flagged.")
|
||||
await db.execute(
|
||||
text("UPDATE marketplace.service_profiles SET status = 'flagged' WHERE id = :id"),
|
||||
{"id": profile_id}
|
||||
)
|
||||
await db.commit()
|
||||
return False
|
||||
Reference in New Issue
Block a user