[ka6] fix connecteur auxjardins.ca: catalogue entièrement masqué (catalog_visibility: hidden, boutique saisonnière) — la liste Store API retourne [] mais les fiches unitaires /products/<slug> répondent; nouveau fallback WooCommerceConnector: slugs découverts via le sitemap produits (Yoast product-sitemap.xml / natif wp-sitemap.xml) puis fiche par fiche via la Store API (0→353 produits, mapping refactorisé en _parse_item partagé)
1 changed file +130 −70
modified
fabrika/connectors/woocommerce.py
+130 −70
@@ -119,6 +119,130 @@ class WooCommerceConnector(BaseConnector): | ||
| 119 | 119 | pass |
| 120 | 120 | return out |
| 121 | 121 | |
| 122 | + def _parse_item(self, it: dict) -> Product: | |
| 123 | + """Mappe un produit Store API (liste ou fiche unitaire) vers Product.""" | |
| 124 | + prices = it.get("prices") or {} | |
| 125 | + unit = 10 ** int(prices.get("currency_minor_unit", 2)) | |
| 126 | + def money(v): | |
| 127 | + try: | |
| 128 | + return int(v) / unit if v not in (None, "") else None | |
| 129 | + except (TypeError, ValueError): | |
| 130 | + return None | |
| 131 | + price = money(prices.get("price")) | |
| 132 | + pr = prices.get("price_range") or {} | |
| 133 | + pmin, pmax = money(pr.get("min_amount")), money(pr.get("max_amount")) | |
| 134 | + cats = [c.get("name", "") for c in (it.get("categories") or [])] | |
| 135 | + brands = [b.get("name", "") for b in (it.get("brands") or []) if b.get("name")] | |
| 136 | + det: dict = {} | |
| 137 | + try: | |
| 138 | + if float(it.get("average_rating") or 0) > 0: | |
| 139 | + det["average_rating"] = float(it["average_rating"]) | |
| 140 | + except (TypeError, ValueError): | |
| 141 | + pass | |
| 142 | + if it.get("review_count"): | |
| 143 | + det["review_count"] = int(it["review_count"]) | |
| 144 | + if it.get("sku"): | |
| 145 | + det["sku"] = it["sku"] | |
| 146 | + if it.get("weight"): | |
| 147 | + det["weight"] = it["weight"] | |
| 148 | + det["formatted_weight"] = it.get("formatted_weight") or "" | |
| 149 | + dims = it.get("dimensions") or {} | |
| 150 | + if any(dims.get(k) for k in ("length", "width", "height")): | |
| 151 | + det["dimensions"] = dims | |
| 152 | + det["formatted_dimensions"] = it.get("formatted_dimensions") or "" | |
| 153 | + # certains thèmes renvoient attributes/terms comme simples | |
| 154 | + # chaînes (constaté sur norseco.com) — tolérer les deux formes | |
| 155 | + def _nm(x): | |
| 156 | + return x.get("name", "") if isinstance(x, dict) else str(x) | |
| 157 | + attributes = [] | |
| 158 | + for a in (it.get("attributes") or []): | |
| 159 | + terms = a.get("terms") or [] if isinstance(a, dict) else [] | |
| 160 | + attributes.append({"name": _nm(a), | |
| 161 | + "terms": [_nm(t) for t in terms]}) | |
| 162 | + if attributes: | |
| 163 | + det["attributes"] = attributes | |
| 164 | + if it.get("low_stock_remaining"): | |
| 165 | + det["low_stock_remaining"] = it["low_stock_remaining"] | |
| 166 | + if it.get("is_on_backorder"): | |
| 167 | + det["is_on_backorder"] = True | |
| 168 | + if it.get("variations"): | |
| 169 | + det["variations"] = len(it["variations"]) | |
| 170 | + # description : courte + longue concaténées (l'ancienne règle | |
| 171 | + # « courte OU longue » jetait la description riche sur 37 % des fiches) | |
| 172 | + short = (it.get("short_description") or "").strip() | |
| 173 | + long_ = (it.get("description") or "").strip() | |
| 174 | + desc = short if long_ in ("", short) else (long_ if not short else f"{short} {long_}") | |
| 175 | + return Product( | |
| 176 | + store_id=self.store_id, | |
| 177 | + external_id=str(it["id"]), | |
| 178 | + url=it.get("permalink", ""), | |
| 179 | + title=it.get("name", ""), | |
| 180 | + description=desc, | |
| 181 | + price=pmin or price, | |
| 182 | + price_max=pmax or price, | |
| 183 | + compare_at_price=money(prices.get("regular_price")) | |
| 184 | + if prices.get("sale_price") and prices.get("sale_price") != prices.get("regular_price") | |
| 185 | + else None, | |
| 186 | + currency=prices.get("currency_code", "CAD"), | |
| 187 | + images=[im.get("src", "") for im in (it.get("images") or [])], | |
| 188 | + product_type=", ".join(cats), | |
| 189 | + tags=[t.get("name", "") for t in (it.get("tags") or [])], | |
| 190 | + vendor=brands[0] if brands else "", | |
| 191 | + available=bool(it.get("is_in_stock", True)), | |
| 192 | + details=det, | |
| 193 | + ) | |
| 194 | + | |
| 195 | + def _sitemap_slug_fallback(self) -> list[Product]: | |
| 196 | + """Fallback quand la liste Store API est vide mais que des produits existent. | |
| 197 | + | |
| 198 | + Certaines boutiques (ex. auxjardins.ca, saisonnière) masquent tout leur | |
| 199 | + catalogue (catalog_visibility: hidden) : la liste /products retourne [] | |
| 200 | + mais les fiches unitaires /products/<slug> répondent. On découvre les | |
| 201 | + slugs via le sitemap produits (Yoast product-sitemap.xml ou natif | |
| 202 | + wp-sitemap.xml) et on aspire chaque fiche via la Store API. | |
| 203 | + """ | |
| 204 | + import xml.etree.ElementTree as ET | |
| 205 | + | |
| 206 | + def locs(url: str) -> list[str]: | |
| 207 | + try: | |
| 208 | + r = self.get(url) | |
| 209 | + root = ET.fromstring(r.content) | |
| 210 | + return [e.text.strip() for e in root.iter() | |
| 211 | + if e.tag.endswith("loc") and e.text] | |
| 212 | + except Exception: | |
| 213 | + return [] | |
| 214 | + | |
| 215 | + product_urls: list[str] = [] | |
| 216 | + # Yoast : product-sitemap.xml (parfois paginé product-sitemap2.xml — | |
| 217 | + # couvert car listé dans sitemap_index.xml) | |
| 218 | + candidates = [f"{self.base}/product-sitemap.xml"] | |
| 219 | + for index in (f"{self.base}/sitemap_index.xml", f"{self.base}/wp-sitemap.xml"): | |
| 220 | + candidates += [u for u in locs(index) | |
| 221 | + if re.search(r"(?:^|/)(?:product-sitemap|wp-sitemap-posts-product-)[^/]*\.xml$", u)] | |
| 222 | + seen: set[str] = set() | |
| 223 | + for sm in candidates: | |
| 224 | + if sm in seen: | |
| 225 | + continue | |
| 226 | + seen.add(sm) | |
| 227 | + product_urls += [u for u in locs(sm) | |
| 228 | + if re.search(r"/(?:produit|product)/[^/]+/?$", u)] | |
| 229 | + slugs = [] | |
| 230 | + slug_seen: set[str] = set() | |
| 231 | + for u in product_urls: | |
| 232 | + slug = u.rstrip("/").split("/")[-1] | |
| 233 | + if slug and slug not in slug_seen: | |
| 234 | + slug_seen.add(slug) | |
| 235 | + slugs.append(slug) | |
| 236 | + out: list[Product] = [] | |
| 237 | + for slug in slugs: | |
| 238 | + try: | |
| 239 | + it = self._get_items(f"{self.base}/wp-json/wc/store/v1/products/{slug}") | |
| 240 | + if isinstance(it, dict) and it.get("id"): | |
| 241 | + out.append(self._parse_item(it)) | |
| 242 | + except Exception: | |
| 243 | + continue | |
| 244 | + return out | |
| 245 | + | |
| 122 | 246 | def fetch(self) -> list[Product]: |
| 123 | 247 | out: list[Product] = [] |
| 124 | 248 | page = 1 |
@@ -133,76 +257,7 @@ class WooCommerceConnector(BaseConnector): | ||
| 133 | 257 | if not isinstance(items, list) or not items: |
| 134 | 258 | break |
| 135 | 259 | for it in items: |
| 136 | − prices = it.get("prices") or {} | |
| 137 | − unit = 10 ** int(prices.get("currency_minor_unit", 2)) | |
| 138 | − def money(v): | |
| 139 | − try: | |
| 140 | − return int(v) / unit if v not in (None, "") else None | |
| 141 | − except (TypeError, ValueError): | |
| 142 | − return None | |
| 143 | − price = money(prices.get("price")) | |
| 144 | − pr = prices.get("price_range") or {} | |
| 145 | − pmin, pmax = money(pr.get("min_amount")), money(pr.get("max_amount")) | |
| 146 | − cats = [c.get("name", "") for c in (it.get("categories") or [])] | |
| 147 | − brands = [b.get("name", "") for b in (it.get("brands") or []) if b.get("name")] | |
| 148 | − det: dict = {} | |
| 149 | − try: | |
| 150 | − if float(it.get("average_rating") or 0) > 0: | |
| 151 | − det["average_rating"] = float(it["average_rating"]) | |
| 152 | − except (TypeError, ValueError): | |
| 153 | − pass | |
| 154 | − if it.get("review_count"): | |
| 155 | − det["review_count"] = int(it["review_count"]) | |
| 156 | − if it.get("sku"): | |
| 157 | − det["sku"] = it["sku"] | |
| 158 | − if it.get("weight"): | |
| 159 | − det["weight"] = it["weight"] | |
| 160 | − det["formatted_weight"] = it.get("formatted_weight") or "" | |
| 161 | − dims = it.get("dimensions") or {} | |
| 162 | − if any(dims.get(k) for k in ("length", "width", "height")): | |
| 163 | − det["dimensions"] = dims | |
| 164 | − det["formatted_dimensions"] = it.get("formatted_dimensions") or "" | |
| 165 | − # certains thèmes renvoient attributes/terms comme simples | |
| 166 | − # chaînes (constaté sur norseco.com) — tolérer les deux formes | |
| 167 | − def _nm(x): | |
| 168 | − return x.get("name", "") if isinstance(x, dict) else str(x) | |
| 169 | − attributes = [] | |
| 170 | − for a in (it.get("attributes") or []): | |
| 171 | − terms = a.get("terms") or [] if isinstance(a, dict) else [] | |
| 172 | − attributes.append({"name": _nm(a), | |
| 173 | − "terms": [_nm(t) for t in terms]}) | |
| 174 | − if attributes: | |
| 175 | − det["attributes"] = attributes | |
| 176 | − if it.get("low_stock_remaining"): | |
| 177 | − det["low_stock_remaining"] = it["low_stock_remaining"] | |
| 178 | − if it.get("is_on_backorder"): | |
| 179 | − det["is_on_backorder"] = True | |
| 180 | − if it.get("variations"): | |
| 181 | − det["variations"] = len(it["variations"]) | |
| 182 | − # description : courte + longue concaténées (l'ancienne règle | |
| 183 | − # « courte OU longue » jetait la description riche sur 37 % des fiches) | |
| 184 | − short = (it.get("short_description") or "").strip() | |
| 185 | − long_ = (it.get("description") or "").strip() | |
| 186 | − desc = short if long_ in ("", short) else (long_ if not short else f"{short} {long_}") | |
| 187 | − out.append(Product( | |
| 188 | − store_id=self.store_id, | |
| 189 | − external_id=str(it["id"]), | |
| 190 | − url=it.get("permalink", ""), | |
| 191 | − title=it.get("name", ""), | |
| 192 | − description=desc, | |
| 193 | − price=pmin or price, | |
| 194 | − price_max=pmax or price, | |
| 195 | − compare_at_price=money(prices.get("regular_price")) | |
| 196 | − if prices.get("sale_price") and prices.get("sale_price") != prices.get("regular_price") | |
| 197 | − else None, | |
| 198 | − currency=prices.get("currency_code", "CAD"), | |
| 199 | − images=[im.get("src", "") for im in (it.get("images") or [])], | |
| 200 | − product_type=", ".join(cats), | |
| 201 | − tags=[t.get("name", "") for t in (it.get("tags") or [])], | |
| 202 | − vendor=brands[0] if brands else "", | |
| 203 | − available=bool(it.get("is_in_stock", True)), | |
| 204 | − details=det, | |
| 205 | − )) | |
| 260 | + out.append(self._parse_item(it)) | |
| 206 | 261 | if len(items) < 100: |
| 207 | 262 | break |
| 208 | 263 | page += 1 |
@@ -214,4 +269,9 @@ class WooCommerceConnector(BaseConnector): | ||
| 214 | 269 | return html_out |
| 215 | 270 | raise first_page_exc |
| 216 | 271 | |
| 272 | + if not out and first_page_exc is None: | |
| 273 | + # Liste vide mais API saine → catalogue peut-être masqué | |
| 274 | + # (catalog_visibility) : tenter le sitemap produits + fiches par slug | |
| 275 | + out = self._sitemap_slug_fallback() | |
| 276 | + | |
| 217 | 277 | return out |
| 218 | 278 | |