# ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/generic.py : connecteur universel par sitemap + extraction du # balisage produit rendu côté serveur (JSON-LD schema.org Product, microdata, # Open Graph product, blobs PrestaShop/Magento). Couvre PrestaShop, Magento, # BigCommerce, WordPress non-Woo et sites ecommerce sur mesure. Scrapfly en # secours pour l'anti-bot. Rendu client-only (Ecwid) non couvert ; Square # Online est couvert par connectors/square.py (vague 2). # ----------------------------------------------------------------------------- from __future__ import annotations import concurrent.futures as cf import json import re from html import unescape from urllib.parse import urlparse from ..schema import Product, parse_price from .base import BaseConnector PRODUCT_URL_RE = re.compile( r"(/produits?/|/product/|/products/|/boutique/|/shop/|/store/|/p/|/item/" r"|/\d+-[a-z0-9]|/achat/|/produit-|-p\d+\.html|\.html$)", re.I) NON_PRODUCT_RE = re.compile( r"(/blog|/blogue|/category|/categorie|/tag/|/page/|/compte|/account|/cart|" r"/panier|/checkout|/contact|/a-propos|/about|/cms|/content/|/faq|/policies|" r"/politique|sitemap|\.(?:jpg|png|pdf|css|js)$)", re.I) LOC_RE = re.compile(r"\s*(?:)?\s*", re.I | re.S) IP_HOST_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}(?::\d+)?$") def _clean(s): return unescape(re.sub(r"<[^>]+>", " ", re.sub(r"\s+", " ", s or ""))).strip() def _walk_jsonld(node, out): if isinstance(node, list): for x in node: _walk_jsonld(x, out) elif isinstance(node, dict): t = node.get("@type") types = t if isinstance(t, list) else [t] if any(str(x).endswith("Product") for x in types if x): out.append(node) for v in node.values(): if isinstance(v, (list, dict)): _walk_jsonld(v, out) def _jsonld_images(img) -> list[str]: """Normalise le champ image JSON-LD (str | dict | liste mixte) en URLs.""" out: list[str] = [] items = img if isinstance(img, list) else [img] for x in items: if isinstance(x, dict): x = x.get("url") or x.get("contentUrl") if isinstance(x, str) and x.startswith("http") and x not in out: out.append(x) return out[:10] def extract_product(url, html): """Retourne un dict {title, price, price_max, image, images, description, currency, available, brand, sku, gtin, rating, review_count} ou None.""" title = price = price_max = image = desc = None brand = sku = gtin = rating = review_count = None images: list[str] = [] currency = "CAD" available = None # 1) JSON-LD Product for block in re.findall(r']*type="application/ld\+json"[^>]*>(.*?)', html, re.S | re.I): try: data = json.loads(block.strip()) except Exception: continue prods = [] _walk_jsonld(data, prods) for p in prods: if not isinstance(p, dict): continue offers = p.get("offers") or {} if isinstance(offers, list): offers = next((o for o in offers if isinstance(o, dict)), {}) if not isinstance(offers, dict): offers = {} spec = offers.get("priceSpecification") or {} if isinstance(spec, list): spec = next((s for s in spec if isinstance(s, dict)), {}) if not isinstance(spec, dict): spec = {} pr = parse_price(offers.get("price") or offers.get("lowPrice") or spec.get("price")) if pr: title = title or _clean(p.get("name")) price = price or pr price_max = price_max or parse_price(offers.get("highPrice")) currency = offers.get("priceCurrency") or currency if not images: images = _jsonld_images(p.get("image")) image = image or (images[0] if images else None) desc = desc or _clean(p.get("description")) av = str(offers.get("availability") or "") available = ("InStock" in av) if av else available b = p.get("brand") if isinstance(b, dict): b = b.get("name") if isinstance(b, str) and b.strip(): brand = brand or _clean(b) if p.get("sku"): sku = sku or str(p["sku"])[:80] for gk in ("gtin13", "gtin", "gtin12", "gtin8", "mpn"): if p.get(gk): gtin = gtin or str(p[gk])[:40] break ar = p.get("aggregateRating") or {} if isinstance(ar, dict) and ar.get("ratingValue"): try: rating = rating or float(ar["ratingValue"]) rc = ar.get("reviewCount") or ar.get("ratingCount") if rc: review_count = review_count or int(float(rc)) except (TypeError, ValueError): pass # 2) Open Graph product / meta if not price: m = re.search(r']+(?:og:price:amount|product:price:amount)"[^>]*content="([^"]+)"', html, re.I) \ or re.search(r']+content="([^"]+)"[^>]*(?:og:price:amount|product:price:amount)"', html, re.I) if m: price = parse_price(m.group(1)) # 3) microdata itemprop=price if not price: m = re.search(r'itemprop="price"[^>]*content="([^"]+)"', html, re.I) \ or re.search(r'content="([^"]+)"[^>]*itemprop="price"', html, re.I) if m: price = parse_price(m.group(1)) if not title: m = re.search(r']+property="og:title"[^>]*content="([^"]+)"', html, re.I) title = _clean(m.group(1)) if m else None if not title: m = re.search(r"]*>(.*?)", html, re.S | re.I) title = _clean(m.group(1)) if m else None if not image: m = re.search(r']+property="og:image"[^>]*content="([^"]+)"', html, re.I) image = m.group(1) if m else None if image and image not in images: images.insert(0, image) if not desc: m = re.search(r']+(?:name|property)="(?:description|og:description)"[^>]*content="([^"]+)"', html, re.I) desc = _clean(m.group(1)) if m else None if not brand: m = re.search(r']+property="(?:og:brand|product:brand)"[^>]*content="([^"]+)"', html, re.I) brand = _clean(m.group(1)) if m else None if not (title and price): return None return {"title": title, "price": price, "price_max": price_max, "image": image, "images": images or ([image] if image else []), "description": desc, "currency": currency, "available": available, "brand": brand, "sku": sku, "gtin": gtin, "rating": rating, "review_count": review_count} class GenericConnector(BaseConnector): platform = "generic" request_delay = 0.2 max_products = 800 use_scrapfly = True # False pendant la détection (vitesse) def _rehost(self, u): # certains CMS mal configurés (ex. boreale.com, Craft CMS, 2026-09-08) # émettent des sur l'IP brute du serveur → cert TLS invalide. # On ramène ces URLs sur le domaine de la boutique, qui sert les # mêmes chemins (sitemaps enfants et pages produit vérifiés 200). # Variante 2026-09-22 (boreale.com encore) : émis sur un domaine # parasite (sitemaps.instead.beer) — « sitemap » dans l'hôte faisait en # plus exclure chaque URL produit via NON_PRODUCT_RE. Réécriture OPT-IN # par fiche registre (rehost_hosts), jamais globale : d'autres boutiques # génériques pointent légitimement vers un domaine tiers (CDN, miroir). p = urlparse(u) bad_hosts = {h.lower() for h in (self.store.get("rehost_hosts") or [])} if IP_HOST_RE.match(p.netloc) or p.netloc.lower() in bad_hosts: return f"{self.base}{p.path}" + (f"?{p.query}" if p.query else "") return u def _sitemap_products(self): from . import scrapfly seen, out = set(), [] roots = [f"{self.base}/sitemap.xml", f"{self.base}/sitemap_index.xml", f"{self.base}/wp-sitemap.xml", f"{self.base}/1_fr_0_sitemap.xml", f"{self.base}/sitemap/sitemap-index.xml", f"{self.base}/media/sitemap.xml", f"{self.base}/pub/media/sitemap.xml", f"{self.base}/sitemap1.xml", f"{self.base}/en/sitemap.xml", f"{self.base}/fr/sitemap.xml"] queue, depth_left = list(roots), 3 fetched_roots = 0 while queue and fetched_roots < 60: u = queue.pop(0) if u in seen: continue seen.add(u) try: r = self.session.get(u, timeout=self.timeout) xml = r.text if r.status_code == 200 else "" except Exception: xml = "" if not xml and self.use_scrapfly and scrapfly.available() and u == roots[0]: try: _, xml = scrapfly.scrapfly_get(u) except Exception: xml = "" if not xml: continue fetched_roots += 1 locs = [self._rehost(l.strip()) for l in LOC_RE.findall(xml)] child_maps = [l for l in locs if l.endswith(".xml") or "sitemap" in l.lower()] if child_maps and depth_left > 0: queue = child_maps + queue depth_left -= 0 for l in locs: if l.endswith(".xml"): continue if PRODUCT_URL_RE.search(l) and not NON_PRODUCT_RE.search(l): out.append(l) if len(out) >= self.max_products * 2: break # sites bilingues (ex. maisondherbes.com, refonte Next.js fr/en) : le # sitemap liste chaque produit en double sous un préfixe de langue # (/en/…). Exclusion OPT-IN par fiche registre (skip_url_prefixes), # jamais globale : d'autres boutiques génériques ont leur catalogue # légitimement sous /en/ (ex. pierresdailleurs.ca, 771 produits). skip = tuple(self.store.get("skip_url_prefixes") or []) if skip: out = [u for u in out if not urlparse(u).path.startswith(skip)] # dédup en gardant l'ordre return list(dict.fromkeys(out))[: self.max_products] def _fetch_html(self, url): from . import scrapfly try: r = self.session.get(url, timeout=self.timeout) if r.status_code == 200 and len(r.text) > 500: return r.text except Exception: pass if self.use_scrapfly and scrapfly.available(): try: st, content = scrapfly.scrapfly_get(url) if st == 200: return content except Exception: pass return "" def fetch(self) -> list[Product]: urls = self._sitemap_products() if not urls: return [] out: list[Product] = [] base_host = urlparse(self.base).netloc.lower().replace("www.", "") def work(u): html = self._fetch_html(u) if not html: return None info = extract_product(u, html) if not info: return None det: dict = {} if info.get("sku"): det["sku"] = info["sku"] if info.get("gtin"): det["gtin"] = info["gtin"] if info.get("rating"): det["average_rating"] = info["rating"] if info.get("review_count"): det["review_count"] = info["review_count"] return Product( store_id=self.store_id, external_id=u.rstrip("/").split("/")[-1][:80] or u, url=u, title=info["title"], description=info.get("description") or "", price=info["price"], price_max=info.get("price_max") or info["price"], currency=info.get("currency") or "CAD", images=info.get("images") or ([info["image"]] if info.get("image") else []), vendor=info.get("brand") or "", available=info.get("available"), details=det) with cf.ThreadPoolExecutor(6) as ex: for rec in ex.map(work, urls): if rec: out.append(rec) return out def probe_generic(domain, session, sample=6): """Teste si une boutique est récoltable en générique. Retourne (ok, n_urls, n_hits).""" store = {"id": domain, "url": f"https://{domain}"} conn = GenericConnector(store) conn.session = session urls = conn._sitemap_products() if not urls: return False, 0, 0 hits = 0 for u in urls[:sample]: html = conn._fetch_html(u) if html and extract_product(u, html): hits += 1 return (hits >= max(2, sample // 2)), len(urls), hits