Files
service-finder/backend/app/workers/service/service_robot_0_hunter.py
2026-03-22 11:02:05 +00:00

174 lines
7.4 KiB
Python
Executable File

# /opt/docker/dev/service_finder/backend/app/workers/service/service_robot_0_hunter.py
import asyncio
import httpx
import logging
import os
import hashlib
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text, update
from app.db.session import AsyncSessionLocal
from app.models.marketplace.staged_data import ServiceStaging, DiscoveryParameter
# Naplózás beállítása a Sentinel monitorozáshoz
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(name)s: %(message)s')
logger = logging.getLogger("Robot-Continental-Scout-v1.3")
class ServiceHunter:
"""
Robot v1.3.1: Continental Scout (Grid Search Edition)
Felelőssége: Új szervizpontok felfedezése külső API-k alapján.
"""
PLACES_NEW_URL = "https://places.googleapis.com/v1/places:searchNearby"
GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
@classmethod
def _generate_fingerprint(cls, name: str, city: str, address: str) -> str:
"""
MD5 Ujjlenyomat generálása.
Ez biztosítja, hogy ha ugyanazt a helyet több rács-cellából is megtaláljuk,
ne jöjjön létre duplikált rekord.
"""
raw = f"{str(name).lower()}|{str(city).lower()}|{str(address).lower()[:10]}"
return hashlib.md5(raw.encode()).hexdigest()
@classmethod
async def _get_city_bounds(cls, city: str, country_code: str):
""" Nominatim API hívás a város befoglaló téglalapjának lekéréséhez. """
url = "https://nominatim.openstreetmap.org/search"
params = {"city": city, "country": country_code, "format": "json"}
headers = {"User-Agent": "ServiceFinder-Scout-v1.3/2.0 (contact@servicefinder.com)"}
async with httpx.AsyncClient(headers=headers, timeout=10) as client:
try:
resp = await client.get(url, params=params)
if resp.status_code == 200 and resp.json():
bbox = resp.json()[0].get("boundingbox") # [min_lat, max_lat, min_lon, max_lon]
return [float(x) for x in bbox]
except Exception as e:
logger.error(f"⚠️ Városhatár lekérdezési hiba ({city}): {e}")
return None
@classmethod
async def get_google_places(cls, lat: float, lon: float):
""" Google Places V1 (New) API hívás. """
if not cls.GOOGLE_API_KEY:
logger.error("❌ Google API Key hiányzik!")
return []
headers = {
"Content-Type": "application/json",
"X-Goog-Api-Key": cls.GOOGLE_API_KEY,
"X-Goog-FieldMask": "places.displayName,places.id,places.internationalPhoneNumber,places.websiteUri,places.formattedAddress,places.location"
}
# MB 2.0 szűrők: Csak releváns típusok
payload = {
"includedTypes": ["car_repair", "motorcycle_repair", "car_wash", "tire_shop"],
"maxResultCount": 20,
"locationRestriction": {
"circle": {
"center": {"latitude": lat, "longitude": lon},
"radius": 1200.0 # 1.2km sugarú körök a jó átfedéshez
}
}
}
async with httpx.AsyncClient(timeout=15) as client:
try:
resp = await client.post(cls.PLACES_NEW_URL, json=payload, headers=headers)
if resp.status_code == 200:
return resp.json().get("places", [])
logger.warning(f"Google API hiba: {resp.status_code} - {resp.text}")
except Exception as e:
logger.error(f"Google API hívás hiba: {e}")
return []
@classmethod
async def _save_to_staging(cls, db: AsyncSession, task, p_data: dict):
""" Adatmentés a staging táblába deduplikációval. """
name = p_data.get('displayName', {}).get('text')
addr = p_data.get('formattedAddress', '')
f_print = cls._generate_fingerprint(name, task.city, addr)
# Ellenőrzés, hogy létezik-e már (Ujjlenyomat alapján)
stmt = select(ServiceStaging).where(ServiceStaging.fingerprint == f_print)
existing = (await db.execute(stmt)).scalar_one_or_none()
if existing:
# Csak a bizalmi pontot és az utolsó észlelést frissítjük
existing.trust_score += 2
existing.updated_at = datetime.now(timezone.utc)
return
# Új rekord létrehozása
new_entry = ServiceStaging(
name=name,
source="google_scout_v1.3",
external_id=p_data.get('id'),
fingerprint=f_print,
city=task.city,
full_address=addr,
contact_phone=p_data.get('internationalPhoneNumber'),
website=p_data.get('websiteUri'),
raw_data=p_data,
status="pending",
trust_score=30 # Alapértelmezett bizalmi szint
)
db.add(new_entry)
@classmethod
async def run_grid_search(cls, db: AsyncSession, task: DiscoveryParameter):
""" A város koordináta-alapú bejárása. """
# DiscoveryParameter modellnek nincs country_code mezője, ezért alapértelmezett 'HU'-t használunk
bbox = await cls._get_city_bounds(task.city, 'HU')
if not bbox:
return
# Lépésközök meghatározása (kb. 1km = 0.01 fok)
lat_step = 0.012
lon_step = 0.018
curr_lat = bbox[0]
while curr_lat < bbox[1]:
curr_lon = bbox[2]
while curr_lon < bbox[3]:
logger.info(f"🛰️ Cella pásztázása: {curr_lat:.4f}, {curr_lon:.4f} ({task.city})")
places = await cls.get_google_places(curr_lat, curr_lon)
for p in places:
await cls._save_to_staging(db, task, p)
await db.commit() # Cellánként mentünk, hogy ne vesszen el a munka
curr_lon += lon_step
await asyncio.sleep(0.3) # Rate limit védelem
curr_lat += lat_step
@classmethod
async def run(cls):
""" A robot fő hurokfolyamata. """
logger.info("🤖 Continental Scout ONLINE - Grid Engine Indul...")
while True:
async with AsyncSessionLocal() as db:
try:
# Aktív keresési feladatok lekérése
stmt = select(DiscoveryParameter).where(DiscoveryParameter.is_active == True)
tasks = (await db.execute(stmt)).scalars().all()
for task in tasks:
# Csak akkor futtatjuk, ha már régen volt (pl. 30 naponta)
if not task.last_run_at or (datetime.now(timezone.utc) - task.last_run_at).days >= 30:
logger.info(f"🔎 Felderítés indítása: {task.city}")
await cls.run_grid_search(db, task)
task.last_run_at = datetime.now(timezone.utc)
await db.commit()
except Exception as e:
logger.error(f"💥 Kritikus hiba a Scout robotban: {e}")
await db.rollback()
# 6 óránként ellenőrizzük, van-e új feladat
await asyncio.sleep(21600)
if __name__ == "__main__":
asyncio.run(ServiceHunter.run())