[ka2] fix connecteur solaris: variante ANGLAISE servie à l'URL française par le cache Cloudflare → parsing bilingue + rotation d'IP au retry. Au sync de 09:37 le garde-fou levait « aucune unité parsée alors que data-count=8 » sur les 3 tentatives (session proxy Oxylabs collante du fix 99c1a4b) : la page /appartements/ a été servie en ANGLAIS (gabarit identique, data-count=8, mais « 2 bedroom · 4½ 875 sq ft $2,140 a month Free now Photos and plan Visit ») → 0/8 pour ROW_RE. Constats live : le site a une version EN (/en/apartments/, hreflang) exactement à cette signature ; Cloudflare cache les deux variantes SANS Vary et sa clé de cache IGNORE la query string (cf-cache-status: HIT même sur ?lkb=<unique>) → le cache-buster du retry 8eff094 ne buste RIEN, et la session collante (même sortie/colo) revoyait la même variante empoisonnée aux 3 tentatives ; Accept-Language et géo de l'IP (testé Toronto vs Montréal) ne changent pas la réponse de façon déterministe. Fix 1 : parsing bilingue — ROW_EN_RE (ordre des champs inversé : chambres avant typologie, prix $2,140 avec virgule, sq ft, « a month ») en repli par ligne quand ROW_RE ne matche pas, avec renormalisation de la dispo en français canonique (_avail_fr : « Free now » → « Libre maintenant », « Free on Nov 1, 2026 » → « Libre le 1er novembre 2026 ») — parité vérifiée 8/8 FR↔EN sur les deux pages live (prix/typologie/chambres/pi²/dispo strictement identiques, zéro churn). Fix 2 : au retry, si la session est sur proxy, NOUVELLE session collante Oxylabs (_enable_residential_proxy re-appelé → autre IP/colo) — le vrai levier contre une variante en cache, le ?lkb= étant inopérant ; aide aussi contre les sorties résidentielles tarpitées (000 constatés ~1 essai sur 3 en test curl). Fixtures inchangées (page FR), pytest solaris 2 passed. Testé : fetch() venv → 8 unités (1 630–2 730 $, dispos FR), run.py sync solaris → found=8 ok (14,5 s), pm2 restart lou-ka-sync, site :8095 → 200.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 changed file +46 −6
modified
louka/connectors/solaris.py
+46 −6
@@ -39,8 +39,38 @@ ROW_RE = re.compile( | ||
| 39 | 39 | r"\s*([\d\s]*?)\s*pi²" |
| 40 | 40 | r"\s*([\d\s]+?)\s*\$\s*/\s*mois" |
| 41 | 41 | r"\s*(Libre.*?)\s*(?:Photos\s+et\s+plan|Plan)\b", re.S) |
| 42 | +# 2026-10-25 : Cloudflare sert PARFOIS la variante ANGLAISE à l'URL française | |
| 43 | +# (cache sans Vary, clé de cache qui IGNORE la query string — le cache-buster | |
| 44 | +# ?lkb= ne buste donc rien). Même data-count=8 mais « 2 bedroom · 4½ 875 sq ft | |
| 45 | +# $2,140 a month Free now Photos and plan » → 0/8 pour ROW_RE. On parse les | |
| 46 | +# deux langues (données identiques) et on renormalise la dispo en français. | |
| 47 | +ROW_EN_RE = re.compile( | |
| 48 | + r"(\d)\s*bedrooms?\s*·\s*(\d)\s*½" | |
| 49 | + r"\s*([\d,\s]*?)\s*sq\s*ft" | |
| 50 | + r"\s*\$\s*([\d,\s]+?)\s*(?:a|per)\s+month" | |
| 51 | + r"\s*(Free.*?)\s*(?:Photos\s+and\s+plan|Plan)\b", re.S) | |
| 42 | 52 | IMG_RE = re.compile(r'<img[^>]+src="([^"]+\.webp)"', re.I) |
| 43 | 53 | |
| 54 | +_EN_MONTHS = {"jan": "janvier", "feb": "février", "mar": "mars", | |
| 55 | + "apr": "avril", "may": "mai", "jun": "juin", "jul": "juillet", | |
| 56 | + "aug": "août", "sep": "septembre", "oct": "octobre", | |
| 57 | + "nov": "novembre", "dec": "décembre"} | |
| 58 | + | |
| 59 | + | |
| 60 | +def _avail_fr(avail: str) -> str: | |
| 61 | + """« Free now » / « Free on Nov 1, 2026 » → texte français canonique.""" | |
| 62 | + if re.match(r"free\s+now", avail, re.I): | |
| 63 | + return "Libre maintenant" | |
| 64 | + m = re.match(r"free\s+on\s+([A-Za-z]+)\.?\s+(\d{1,2}),?\s+(\d{4})", | |
| 65 | + avail, re.I) | |
| 66 | + if m: | |
| 67 | + month = _EN_MONTHS.get(m.group(1)[:3].lower()) | |
| 68 | + if month: | |
| 69 | + day = int(m.group(2)) | |
| 70 | + jour = "1er" if day == 1 else str(day) | |
| 71 | + return f"Libre le {jour} {month} {m.group(3)}" | |
| 72 | + return avail | |
| 73 | + | |
| 44 | 74 | |
| 45 | 75 | def _flat(fragment: str) -> str: |
| 46 | 76 | text = _html.unescape(fragment) |
@@ -96,6 +126,10 @@ class SolarisConnector(BaseConnector): | ||
| 96 | 126 | for attempt in range(3): |
| 97 | 127 | if attempt: |
| 98 | 128 | time.sleep(4 * attempt) |
| 129 | + if self.session.proxies: | |
| 130 | + # le cache CF ignore la query string → le vrai levier de | |
| 131 | + # retry est une NOUVELLE IP collante (autre sortie/colo). | |
| 132 | + self._enable_residential_proxy() | |
| 99 | 133 | url = PLANS_URL if attempt == 0 else \ |
| 100 | 134 | f"{PLANS_URL}?lkb={int(time.time())}" |
| 101 | 135 | try: |
@@ -131,19 +165,25 @@ class SolarisConnector(BaseConnector): | ||
| 131 | 165 | |
| 132 | 166 | listings: list[Listing] = [] |
| 133 | 167 | for num, body in UNIT_LI_RE.findall(section): |
| 134 | − row = ROW_RE.search(_flat(body)) | |
| 135 | − if not row: | |
| 136 | − continue | |
| 137 | − n, beds, sqft, amount, avail = row.groups() | |
| 168 | + text = _flat(body) | |
| 169 | + row = ROW_RE.search(text) | |
| 170 | + if row: | |
| 171 | + n, beds, sqft, amount, avail = row.groups() | |
| 172 | + else: | |
| 173 | + row = ROW_EN_RE.search(text) | |
| 174 | + if not row: | |
| 175 | + continue | |
| 176 | + beds, n, sqft, amount, avail = row.groups() | |
| 177 | + avail = _avail_fr(avail) | |
| 138 | 178 | try: |
| 139 | − price = float(re.sub(r"\s", "", amount)) | |
| 179 | + price = float(re.sub(r"[\s,]", "", amount)) | |
| 140 | 180 | except ValueError: |
| 141 | 181 | continue |
| 142 | 182 | if not 300 <= price <= 20000: |
| 143 | 183 | continue |
| 144 | 184 | unit_type = f"{n}½" |
| 145 | 185 | area = None |
| 146 | − sqft = re.sub(r"\s", "", sqft) | |
| 186 | + sqft = re.sub(r"[\s,]", "", sqft) | |
| 147 | 187 | if sqft.isdigit(): |
| 148 | 188 | area = float(sqft) |
| 149 | 189 | imgs = [u for u in plan_imgs |
| 150 | 190 | |