BF-Realtime / product_index.py
SamiKoen
Fiyat product_index'e tasindi (Trek temiz feed'e fiyat eklendi)
7349c7f
Raw
History Blame Contribute Delete
8.2 kB
"""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)