"""Trek katalog XML'ini bir kez parse edip hash index halinde tutar. Tum lookup'lar O(1) — re-parse yok. Background refresh ve fetch lock'u var.""" from __future__ import annotations import asyncio import logging import re import threading import time import requests import urllib3 from config import TREK_XML_URL, TREK_XML_TIMEOUT, CACHE_TTL_TREK_XML urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) logger = logging.getLogger(__name__) # Turkce -> ASCII normalizasyon _TR_MAP = { "İ": "i", "I": "i", "ı": "i", "Ğ": "g", "ğ": "g", "Ü": "u", "ü": "u", "Ş": "s", "ş": "s", "Ö": "o", "ö": "o", "Ç": "c", "ç": "c", } def normalize(s: str) -> str: if not s: return "" for tr, en in _TR_MAP.items(): s = s.replace(tr, en) return s.lower() # Pre-compiled regex'ler (modul yuklemesinde bir kez) _ITEM_RE = re.compile(r"(.*?)", re.DOTALL) _VARIANT_LABEL_SEP = re.compile(r"\s*[-/]\s*") _TOKEN_RE = re.compile(r"[a-z0-9]+") _SIZE_PAT = re.compile( r"^(?:XX?S|XS|S|M|L|XL|XXL|XXXL|\d{2}(?:\.\d)?(?:\s*CM)?)$", re.I ) _FIELD_RES = { "rootlabel": re.compile(r""), "label": re.compile(r""), "productLink": re.compile(r""), "stockCode": re.compile(r""), "isOptionOfAProduct": re.compile(r"(\d+)"), "rootProductStockCode": re.compile( r"" ), "priceTaxWithCur": re.compile(r"([\d.]+)"), } def _parse_item(it: str) -> dict: """Tek bir blogunu dict'e cevir + arama icin token cache'i ekle. Resim alanlari (picture1..8) parse edilmiyor — browser sayfayi kendisi gosteriyor.""" def grab(name: str) -> str: m = _FIELD_RES[name].search(it) return m.group(1).strip() if m else "" rootlabel = grab("rootlabel") var_label = grab("label") link = grab("productLink") sku = grab("stockCode") iv_m = _FIELD_RES["isOptionOfAProduct"].search(it) is_variant = bool(iv_m and iv_m.group(1) == "1") root_sku_raw = grab("rootProductStockCode") root_sku = root_sku_raw if root_sku_raw and root_sku_raw != "0" else None price_str = grab("priceTaxWithCur") price: float | None = None if price_str: try: p = float(price_str) if p > 0: price = p except (TypeError, ValueError): pass color: str | None = None size: str | None = None if is_variant and var_label: parts = [p.strip() for p in _VARIANT_LABEL_SEP.split(var_label) if p.strip()] for p in parts: if _SIZE_PAT.match(p) and not size: size = p.upper() elif not color: color = p.upper() label_norm = normalize(rootlabel) tokens = [t for t in _TOKEN_RE.findall(label_norm) if len(t) >= 1] return { "name": rootlabel, "link": link, "sku": sku, "color": color, "size": size, "price": price, "is_variant": is_variant, "root_sku": root_sku, "_tokens": tokens, "_label_norm": label_norm, } def public_view(p: dict | None) -> dict | None: """Internal field'lari (_tokens, _label_norm) cikar — client'a gonderilebilir.""" if not p: return None return {k: v for k, v in p.items() if not k.startswith("_")} class ProductIndex: """Thread-safe parse-once index. Re-parse sadece XML degisirse.""" def __init__(self) -> None: self._lock = threading.Lock() self._fetch_lock = threading.Lock() self._xml_data: bytes | None = None self._xml_time: float = 0 self._xml_id: int | None = None self.products: list[dict] = [] self.by_link: dict[str, dict] = {} self.by_sku: dict[str, dict] = {} self.variants_by_root: dict[str, list[dict]] = {} self.main_count: int = 0 # ---------- XML fetch (lock'lu, thundering herd onleyici) ---------- def _fetch_xml(self) -> bytes | None: with self._fetch_lock: now = time.time() # Lock alindiginda baska bir thread fetch yapmis olabilir if self._xml_data and (now - self._xml_time < CACHE_TTL_TREK_XML): return self._xml_data try: r = requests.get(TREK_XML_URL, verify=False, timeout=TREK_XML_TIMEOUT) if r.status_code == 200 and r.content: self._xml_data = r.content self._xml_time = now logger.info(f"[index] fetched Trek XML ({len(r.content)} bytes)") return r.content except Exception: logger.exception("[index] Trek XML fetch hatasi") # Eski (stale) data varsa onu kullan return self._xml_data # ---------- Index build ---------- def _build(self, xml_bytes: bytes) -> None: text = xml_bytes.decode("utf-8", errors="replace") products: list[dict] = [] by_link: dict[str, dict] = {} by_sku: dict[str, dict] = {} variants_by_root: dict[str, list[dict]] = {} main_count = 0 for it in _ITEM_RE.findall(text): p = _parse_item(it) products.append(p) if p["link"]: by_link[p["link"]] = p if p["sku"]: by_sku[p["sku"]] = p if not p["is_variant"]: main_count += 1 else: if p["root_sku"]: variants_by_root.setdefault(p["root_sku"], []).append(p) with self._lock: self.products = products self.by_link = by_link self.by_sku = by_sku self.variants_by_root = variants_by_root self.main_count = main_count self._xml_id = id(xml_bytes) logger.info( f"[index] built: {len(products)} items, {main_count} main, " f"{sum(len(v) for v in variants_by_root.values())} variants" ) def ensure(self) -> bool: """XML cache'ini guncelle ve index'i (gerekirse) yeniden build et. True donerse data hazir. Sync, thread-safe.""" xml = self._fetch_xml() if not xml: return False if id(xml) != self._xml_id: self._build(xml) return True # ---------- Public lookups (sync, hizli) ---------- def find_by_link(self, link: str) -> dict | None: """productLink ile eslesen urun. Varyant ise ana urune cikar.""" if not link: return None with self._lock: p = self.by_link.get(link.strip()) if not p: return None if p["is_variant"] and p["root_sku"]: main = self.by_sku.get(p["root_sku"]) if main and not main["is_variant"]: return public_view(main) return public_view(p) def variants_of(self, main_link: str) -> list[dict]: """Verilen ana urunun tum varyantlari.""" if not main_link: return [] with self._lock: main = self.by_link.get(main_link.strip()) if not main or main["is_variant"] or not main["sku"]: return [] return [public_view(v) for v in self.variants_by_root.get(main["sku"], [])] def snapshot(self) -> list[dict]: """Tum urunlerin internal-field'li listesi (matcher icin).""" with self._lock: return list(self.products) # Singleton _index = ProductIndex() def get_index() -> ProductIndex: return _index async def background_refresh_loop(interval: int): """Periodic XML refresh in thread executor.""" while True: try: await asyncio.to_thread(_index.ensure) except Exception: logger.exception("background_refresh_loop hatasi") await asyncio.sleep(interval)