SPB Git forge

spb/fabri-ka

Public

Agrégateur de produits québécois — www.fabri-ka.com

217commits 1branches 0releases
66.1 MBsize
maindefault branch
6 h agolast push
Python 38.4% HTML 30.3% TypeScript 17.2% CSS 11% JavaScript 3.2%

[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é)

Simon-Pierre Boucher committed 1 mo ago (Aug 23, 2026) parent a7280ba

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