669 lines
31 KiB
Python
669 lines
31 KiB
Python
# /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
|