| 62 | "PLQ": r"(?:PLQ|Parti\s+lib[ée]ral|lib[ée]raux)", |
62 | "PLQ": r"(?:PLQ|Parti\s+lib[ée]ral|lib[ée]raux)", |
| 63 | "PQ": r"(?:PQ\b|Parti\s+[Qq]u[ée]b[ée]cois)", |
63 | "PQ": r"(?:PQ\b|Parti\s+[Qq]u[ée]b[ée]cois)", |
| 64 | "QS": r"(?:QS\b|Qu[ée]bec\s+[Ss]olidaire)", |
64 | "QS": r"(?:QS\b|Qu[ée]bec\s+[Ss]olidaire)", |
| 65 | − "PCQ": r"(?:PCQ|Parti\s+conservateur|conservateurs?\s+du\s+Qu[ée]bec)", |
65 | + "PCQ": r"(?:PCQ|Parti\s+conservateur|conservateurs?\b)", |
| 66 | } |
66 | } |
| 67 | FIELD_RE = re.compile( |
67 | FIELD_RE = re.compile( |
| 68 | − r"(?:du|from)\s+(\d{1,2})(?:er)?\s+(?:au|to)\s+(\d{1,2})(?:er)?\s+" |
68 | + r"(?:du|from|les)\s+(\d{1,2})(?:er)?\s+(?:([a-zéû]+)\s+)?(?:au|to|et)\s+" |
| 69 | − r"([a-zéû]+)\s+(\d{4})", re.I) |
69 | + r"(\d{1,2})(?:er)?\s+([a-zéû]+)(?:\s+(\d{4}))?", re.I) |
| 70 | MONTHS = {"janvier": 1, "février": 2, "mars": 3, "avril": 4, "mai": 5, |
70 | MONTHS = {"janvier": 1, "février": 2, "mars": 3, "avril": 4, "mai": 5, |
| 71 | "juin": 6, "juillet": 7, "août": 8, "aout": 8, "septembre": 9, |
71 | "juin": 6, "juillet": 7, "août": 8, "aout": 8, "septembre": 9, |
| 72 | "octobre": 10, "novembre": 11, "décembre": 12, "decembre": 12} |
72 | "octobre": 10, "novembre": 11, "décembre": 12, "decembre": 12} |
| 91 | field_end = None |
91 | field_end = None |
| 92 | m = FIELD_RE.search(md[:6000]) |
92 | m = FIELD_RE.search(md[:6000]) |
| 93 | if m: |
93 | if m: |
| 94 | − month = MONTHS.get(m.group(3).lower()) |
94 | + month = MONTHS.get(m.group(4).lower()) |
| |
95 | + year = int(m.group(5)) if m.group(5) else settings.election_date.year |
| 95 | if month: |
96 | if month: |
| 96 | try: |
97 | try: |
| 97 | − field_end = date(int(m.group(4)), month, int(m.group(2))) |
98 | + field_end = date(year, month, int(m.group(3))) |
| 98 | except ValueError: |
99 | except ValueError: |
| 99 | pass |
100 | pass |
| 100 | n = None |
101 | n = None |
| 222 | "en_révision": pending, "déjà_intégrés": integrated} |
223 | "en_révision": pending, "déjà_intégrés": integrated} |
| 223 | |
224 | |
| 224 | |
225 | |
| |
226 | +def extract_from_radar(db: Session, max_articles: int = 6) -> dict: |
| |
227 | + """Ferme la boucle du radar : les ARTICLES DE PRESSE détectés (poll-radar) |
| |
228 | + qui rapportent un sondage absent de la base sont scrapés et leurs toplines |
| |
229 | + extraites → sondage en FILE DE RÉVISION (excluded=True). Pendant la |
| |
230 | + campagne, la presse publie les chiffres 24-48 h avant Wikipédia.""" |
| |
231 | + from .firecrawl_watch import scrape_markdown, _enabled |
| |
232 | + if not _enabled(): |
| |
233 | + return {"skipped": "Firecrawl requis"} |
| |
234 | + pending = (db.query(Mo.WebSignal) |
| |
235 | + .filter(Mo.WebSignal.kind == "poll-radar", |
| |
236 | + Mo.WebSignal.status == "nouveau") |
| |
237 | + .order_by(Mo.WebSignal.detected_at.desc()) |
| |
238 | + .limit(max_articles).all()) |
| |
239 | + created, skipped = 0, 0 |
| |
240 | + for sig in pending: |
| |
241 | + md = scrape_markdown(sig.url) |
| |
242 | + if not md: |
| |
243 | + continue |
| |
244 | + _archive(sig.pollster or "presse", sig.url, md[:200000]) |
| |
245 | + ext = extract_toplines(md) |
| |
246 | + sig.extra = {**(sig.extra or {}), |
| |
247 | + "extraction": {**ext, "field_end": ext["field_end"].isoformat() |
| |
248 | + if ext["field_end"] else None}} |
| |
249 | + firm = sig.pollster or next( |
| |
250 | + (h for h in ("Léger", "Pallas Data", "Synopsis", "Mainstreet", |
| |
251 | + "SEGMA", "Liaison Strategies", "Angus Reid") |
| |
252 | + if h.split()[0].lower() in md[:3000].lower()), None) |
| |
253 | + if not firm or ext["confidence"] < 0.7 or not ext["field_end"]: |
| |
254 | + skipped += 1 |
| |
255 | + continue |
| |
256 | + from ..seed import get_or_create |
| |
257 | + pol, _ = get_or_create(db, Mo.Pollster, name=firm) |
| |
258 | + if (db.query(Mo.Poll).filter_by(pollster_id=pol.id, |
| |
259 | + field_end=ext["field_end"]).first()): |
| |
260 | + sig.status = "intégré" |
| |
261 | + continue |
| |
262 | + el = db.query(Mo.Election).filter_by(is_target=True).first() |
| |
263 | + total = sum(ext["shares"].values()) |
| |
264 | + poll = Mo.Poll( |
| |
265 | + election_id=el.id, pollster_id=pol.id, |
| |
266 | + field_start=ext["field_end"] - timedelta(days=3), |
| |
267 | + field_end=ext["field_end"], sample_size=ext["sample_size"], |
| |
268 | + mode=PRIMARY_SOURCES.get(firm, {}).get("mode", "unknown"), |
| |
269 | + source_name=f"Article de presse (radar) — extraction auto", |
| |
270 | + source_url=sig.url, excluded=True, |
| |
271 | + exclusion_reason=("révision requise — extrait d'un article de " |
| |
272 | + f"presse (confiance {ext['confidence']})")) |
| |
273 | + for party, v in ext["shares"].items(): |
| |
274 | + poll.results.append(Mo.PollResult( |
| |
275 | + party=party, raw_value=v, |
| |
276 | + normalized_value=round(v * 100 / total, 3))) |
| |
277 | + db.add(poll) |
| |
278 | + db.flush() |
| |
279 | + sig.status = "en_révision" |
| |
280 | + sig.extra = {**sig.extra, "poll_id": poll.id} |
| |
281 | + created += 1 |
| |
282 | + db.commit() |
| |
283 | + return {"articles_scannés": len(pending), "en_révision": created, |
| |
284 | + "sans_extraction_fiable": skipped} |
| |
285 | + |
| |
286 | + |
| |
287 | +def cross_validate_pending(db: Session, tol_pp: float = 1.5) -> dict: |
| |
288 | + """AUTO-VALIDATION PAR RECOUPEMENT — jamais silencieux, jamais une seule |
| |
289 | + source : un sondage en file de révision est validé (excluded=False) |
| |
290 | + UNIQUEMENT si une DEUXIÈME extraction indépendante (autre URL, même maison, |
| |
291 | + même fin de terrain ±2 j) rapporte les mêmes chiffres à ±tol_pp près. |
| |
292 | + La double provenance et la décision sont journalisées.""" |
| |
293 | + pending = (db.query(Mo.Poll) |
| |
294 | + .filter(Mo.Poll.excluded.is_(True), |
| |
295 | + Mo.Poll.exclusion_reason.like("révision requise%")).all()) |
| |
296 | + validated = 0 |
| |
297 | + for poll in pending: |
| |
298 | + shares = {r.party: r.raw_value for r in poll.results} |
| |
299 | + confirmations = [] |
| |
300 | + for sig in db.query(Mo.WebSignal).filter( |
| |
301 | + Mo.WebSignal.kind.in_(("poll-radar", "rapport-primaire"))).all(): |
| |
302 | + ext = (sig.extra or {}).get("extraction") or {} |
| |
303 | + if not ext.get("shares") or sig.url == poll.source_url: |
| |
304 | + continue |
| |
305 | + fe = ext.get("field_end") |
| |
306 | + if not fe or abs((date.fromisoformat(fe) - poll.field_end).days) > 2: |
| |
307 | + continue |
| |
308 | + diffs = [abs(ext["shares"].get(p, 0) - v) |
| |
309 | + for p, v in shares.items() if p in ext["shares"]] |
| |
310 | + if len(diffs) >= 4 and max(diffs) <= tol_pp: |
| |
311 | + confirmations.append(sig.url) |
| |
312 | + if confirmations: |
| |
313 | + poll.excluded = False |
| |
314 | + poll.exclusion_reason = None |
| |
315 | + poll.methodology_notes = ( |
| |
316 | + "Auto-validé par recoupement de sources indépendantes : " |
| |
317 | + f"{poll.source_url} + {confirmations[0]}") |
| |
318 | + db.add(Mo.PipelineLog(step="auto-validation-sondage", status="ok", |
| |
319 | + message=f"{poll.pollster.name} " |
| |
320 | + f"{poll.field_end} validé par " |
| |
321 | + f"{len(confirmations)} recoupement(s)")) |
| |
322 | + validated += 1 |
| |
323 | + db.commit() |
| |
324 | + return {"en_attente": len(pending) - validated, "auto_validés": validated} |
| |
325 | + |
| |
326 | + |
| 225 | def run_primary_watch(db: Session, max_firms: int | None = None) -> dict: |
327 | def run_primary_watch(db: Session, max_firms: int | None = None) -> dict: |
| 226 | """Cycle des connecteurs primaires — chaque firme isolée.""" |
328 | """Cycle des connecteurs primaires — chaque firme isolée.""" |
| 227 | report = {} |
329 | report = {} |
| 233 | except Exception as e: |
335 | except Exception as e: |
| 234 | db.rollback() |
336 | db.rollback() |
| 235 | report[firm] = f"échec: {e}" |
337 | report[firm] = f"échec: {e}" |
| |
338 | + try: |
| |
339 | + report["articles_radar"] = extract_from_radar(db) |
| |
340 | + except Exception as e: |
| |
341 | + db.rollback() |
| |
342 | + report["articles_radar"] = f"échec: {e}" |
| |
343 | + try: |
| |
344 | + report["recoupement"] = cross_validate_pending(db) |
| |
345 | + except Exception as e: |
| |
346 | + db.rollback() |
| |
347 | + report["recoupement"] = f"échec: {e}" |
| 236 | return report |
348 | return report |