# ----------------------------------------------------------------------------- # Fabri-Ka — Agrégateur de produits québécois # Auteur : Simon-Pierre Boucher — contact@spboucher.ai # connectors/votresite.py : connecteur Votresite.ca (plateforme québécoise). # Les sites Votresite.ca sont des Drupal 8 gérés (thème owebo-votresite) avec # une boutique OpenCart montée dans un sous-répertoire (/boutique/ ou # /produits/, parfois autre). Rendu 100 % serveur, aucune API JSON : # - inventaire : /sitemap.xml (extension OpenCart, pas toujours # activée) sinon crawl des catégories /fr + pagination # ?limit=100&page=N ; # - fiche : URL …/-p[c…]/ — prix courant dans

…$

, # prix barré (spécial) dans , # « Disponibilité: » / « Modèle: » en
  • , description div#tab-description, # galerie . Les meta twitter:data1/2 servent de # secours mais affichent le prix RÉGULIER même en solde. # Dédup par ID produit (le même p apparaît sous plusieurs chemins de # catégorie, ex. -p361c37c45c44). # ----------------------------------------------------------------------------- from __future__ import annotations import concurrent.futures as cf import re from html import unescape from ..schema import Product, parse_price from .base import BaseConnector # …/slug-p361/ ou …/slug-p361c37c45/ (produit) ; …/slug-c116c139/ (catégorie) PROD_URL_RE = re.compile(r"/([^/]+)-p(\d+)(?:c\d+)*/?(?:\?.*)?$") CAT_URL_RE = re.compile(r"-c\d+(?:c\d+)*/?$") LOC_RE = re.compile(r"\s*(?:)?\s*", re.I | re.S) HREF_RE = re.compile(r'href="([^"]+)"') SKIP_RE = re.compile(r"(/account|/cart|/checkout|/wishlist|/compare|/login|" r"/register|route=|mailto:|tel:|javascript:)", re.I) # marqueurs OpenCart d'une boutique Votresite valide _OC_MARKERS = ("image/cache", "route=product", 'class="thumbnails"', "list-unstyled") CANDIDATE_MOUNTS = ["/boutique", "/produits"] def _clean(s: str | None) -> str: return unescape(re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", s or ""))).strip() def _enc(url: str) -> str: """Les chemins d'images OpenCart contiennent espaces et accents bruts.""" return url.replace(" ", "%20") class VotresiteConnector(BaseConnector): platform = "votresite" request_delay = 0.2 max_products = 2000 max_listing_pages = 250 # garde-fou du crawl catégories # ------------------------------------------------------------------ mount def _try_mount(self, mount: str) -> bool: try: r = self.get(f"{self.base}{mount}/fr") except Exception: return False html = r.text or "" return (r.status_code == 200 and any(m in html for m in _OC_MARKERS) and (PROD_URL_RE.search(html) is not None or re.search(r'-[pc]\d+[c\d]*/"', html) is not None or "-c" in html)) def _discover_mount(self) -> str: ep = (self.store.get("catalog_endpoint") or "").strip().rstrip("/") candidates: list[str] = [] if ep and ep != "__generic__": candidates.append(ep if ep.startswith("/") else f"/{ep}") # page d'accueil Drupal : le lien vers la boutique révèle le montage try: html = self.get(self.base + "/").text for m in re.finditer( r'href="https?://[^"/]+(/[a-z0-9_-]+)(?:/fr)?/?"', html, re.I): p = m.group(1) if re.search(r"boutique|produit|magasin|commander|achat", p, re.I): candidates.append(p) for m in re.finditer( r'href="https?://[^"/]+(/[a-z0-9_-]+)/fr/[^"]*-[pc]\d', html): candidates.append(m.group(1)) except Exception: pass candidates += CANDIDATE_MOUNTS seen: set[str] = set() for c in candidates: c = c.rstrip("/") if not c or c in seen: continue seen.add(c) if self._try_mount(c): return c raise RuntimeError("boutique Votresite (OpenCart) introuvable " f"(montages essayés : {sorted(seen)})") # -------------------------------------------------------------- inventaire def _sitemap_urls(self, mount: str) -> dict[str, str]: """{pid: url produit} depuis /sitemap.xml (souvent absent).""" out: dict[str, str] = {} try: r = self.get(f"{self.base}{mount}/sitemap.xml") except Exception: return out xml = r.text or "" if r.status_code != 200 or " dict[str, str]: """Crawl des pages catégories : {pid: url produit}.""" root = f"{self.base}{mount}" prods: dict[str, str] = {} seen: set[str] = set() queue: list[str] = [f"{root}/fr"] fetched = 0 while queue and fetched < self.max_listing_pages: url = queue.pop(0) key = url.rstrip("/") if key in seen: continue seen.add(key) try: html = self.get(url).text or "" except Exception: continue fetched += 1 for href in HREF_RE.findall(html): href = unescape(href).split("#")[0] if not href.startswith(root) or SKIP_RE.search(href): continue pm = PROD_URL_RE.search(href.split("?")[0]) if pm: prods.setdefault(pm.group(2), href.split("?")[0]) continue path = href.split("?")[0] if CAT_URL_RE.search(path): # 100 produits par page de liste (défaut : 15) if path.rstrip("/") + "/?limit=100" not in seen: queue.append(path.rstrip("/") + "/?limit=100") elif "page=" in href and "limit=" in href: queue.append(href) # pagination déjà paramétrée if len(prods) >= self.max_products: break return prods # ------------------------------------------------------------------ fiche def _parse_product(self, pid: str, url: str, html: str) -> Product | None: # le 1er

    est souvent le logo du site (un lien) ; le titre du # produit est le dernier

    SANS ancre (ex. lemieldabee.ca) title, h1_pos = None, -1 h1s = list(re.finditer(r"]*>(.*?)

    ", html, re.S)) plain = [m for m in h1s if "
    ]+property="og:title"[^>]+content="([^"]+)"', html) title = _clean(m.group(1)) if m else None if chosen: h1_pos = chosen.start() if not title: return None # zone d'info produit : entre le

    et le formulaire d'achat if h1_pos < 0: h1_pos = 0 zone_end = html.find('
    h1_pos else h1_pos + 6000] compare = None m = re.search(r"line-through[^>]*>\s*([^<]*\d[^<]*)]*>\s*([^<]*\d[^<]*\$[^<]*)

    ", zone) if m: price = parse_price(m.group(1)) if price is None: # secours : meta twitter (prix régulier) m = re.search(r'twitter:data1"\s+content="([^"]+)"', html) if m: price = parse_price(m.group(1)) if compare is not None and price is not None and compare <= price: compare = None available = None m = (re.search(r"Disponibilité\s*:\s*]*>?\s*([^<]+)<", zone) or re.search(r'twitter:data2"\s+content="([^"]+)"', html)) dispo = _clean(m.group(1)) if m else "" if dispo: low = dispo.lower() if "rupture" in low or "épuis" in low or "epuis" in low: available = False elif "stock" in low or "commande" in low or "jour" in low: available = True det: dict = {} m = re.search(r"Modèle\s*:\s*([^<]+)<", zone) if m and _clean(m.group(1)): det["model"] = _clean(m.group(1)) if dispo: det["availability_text"] = dispo # options (listes déroulantes OpenCart) opts = [] for sel in re.finditer( r']*control-label[^>]*>(.*?)\s*]*' r'name="option\[\d+\]"[^>]*>(.*?)', html, re.S): name = _clean(sel.group(1)) values = [_clean(v) for v in re.findall(r"]*>(.*?)", sel.group(2), re.S)] values = [v for v in values if v and "choisir" not in v.lower()] if name and values: opts.append({"name": name, "values": values[:30]}) if opts: det["options"] = opts # description : div#tab-description jusqu'au prochain onglet/bloc desc = "" start = html.find('id="tab-description"') if start != -1: start = html.find(">", start) + 1 cuts = [html.find(marker, start) for marker in ('id="tab-', 'class="related', " start] desc = html[start: min(cuts) if cuts else start + 20000] if not _clean(desc): m = (re.search(r']+property="og:description"[^>]+content="([^"]+)"', html) or re.search(r']+name="description"[^>]+content="([^"]+)"', html)) desc = m.group(1) if m else "" images: list[str] = [] for m in re.finditer(r']+class="thumbnail"[^>]+href="([^"]+)"', html): u = _enc(unescape(m.group(1))) if u.startswith("http") and u not in images: images.append(u) m = re.search(r']+property="og:image"[^>]+content="([^"]+)"', html) if m: u = _enc(unescape(m.group(1))) if u.startswith("http") and u not in images: images.append(u) # catégories du fil d'Ariane (sans l'accueil ni le produit lui-même) cats: list[str] = [] m = re.search(r'', html, re.S) if m: entries = re.findall(r"]*>(.*?)
    ", m.group(1), re.S) cats = [_clean(e) for e in entries if _clean(e) and "fa-home" not in e and _clean(e) != title] return Product( store_id=self.store_id, external_id=pid, url=url, title=title, description=desc, price=price, price_max=price, compare_at_price=compare, images=images, product_type=cats[-1] if cats else "", tags=cats, available=available, details=det, ) # ------------------------------------------------------------------ fetch def fetch(self) -> list[Product]: mount = self._discover_mount() urls = self._sitemap_urls(mount) if len(urls) < 3: # sitemap absent ou quasi vide -> crawl crawled = self._crawl_urls(mount) for pid, u in crawled.items(): urls.setdefault(pid, u) items = list(urls.items())[: self.max_products] out: list[Product] = [] def work(item: tuple[str, str]) -> Product | None: pid, u = item try: html = self.get(u).text or "" except Exception: return None if len(html) < 500: return None return self._parse_product(pid, u, html) with cf.ThreadPoolExecutor(6) as ex: for rec in ex.map(work, items): if rec: out.append(rec) return out