Spaces:
Running
Running
| """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"<item>(.*?)</item>", 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"<rootlabel><!\[CDATA\[(.*?)\]\]></rootlabel>"), | |
| "label": re.compile(r"<label><!\[CDATA\[(.*?)\]\]></label>"), | |
| "productLink": re.compile(r"<productLink><!\[CDATA\[(.*?)\]\]></productLink>"), | |
| "stockCode": re.compile(r"<stockCode><!\[CDATA\[(.*?)\]\]></stockCode>"), | |
| "isOptionOfAProduct": re.compile(r"<isOptionOfAProduct>(\d+)</isOptionOfAProduct>"), | |
| "rootProductStockCode": re.compile( | |
| r"<rootProductStockCode><!\[CDATA\[(.*?)\]\]></rootProductStockCode>" | |
| ), | |
| "priceTaxWithCur": re.compile(r"<priceTaxWithCur>([\d.]+)</priceTaxWithCur>"), | |
| } | |
| def _parse_item(it: str) -> dict: | |
| """Tek bir <item> 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) | |