""" Scraper — Giuseppe Dellamotta / investinglive.com Cible : articles "Rate hikes by year-end" publiés périodiquement (~mensuel) Auteur : Claude (Anthropic) — usage personnel Usage : python investinglive_scraper.py # scrape les N derniers articles détectés python investinglive_scraper.py --url # scrape un article spécifique python investinglive_scraper.py --output json # sortie JSON (défaut : tableau console) python investinglive_scraper.py --output json --save expectations.json Stratégie de découverte d'articles : 1. Page auteur Giuseppe Dellamotta 2. Catégorie CentralBanks 3. Google Search comme fallback Les articles contenant "Rate hikes by year-end" dans le corps sont sélectionnés. """ import requests from bs4 import BeautifulSoup import re import json import argparse import time import sys from datetime import datetime from urllib.parse import urljoin # ── Configuration ────────────────────────────────────────────────────────────── BASE_URL = "https://investinglive.com" AUTHOR_URL = f"{BASE_URL}/author/giuseppe-dellamotta/" CATEGORY_URL = f"{BASE_URL}/CentralBanks" KEYWORD = "Rate hikes by year-end" # mot-clé discriminant MAX_ARTICLES = 10 # nb max d'articles à scanner pour trouver les N derniers # Headers navigateur réaliste (Chrome 124, Windows) HEADERS = { "User-Agent": ( "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "AppleWebKit/537.36 (KHTML, like Gecko) " "Chrome/124.0.0.0 Safari/537.36" ), "Accept": ( "text/html,application/xhtml+xml,application/xml;" "q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8" ), "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Encoding": "gzip, deflate, br", "Connection": "keep-alive", "Upgrade-Insecure-Requests": "1", "Sec-Fetch-Dest": "document", "Sec-Fetch-Mode": "navigate", "Sec-Fetch-Site": "none", "Sec-Fetch-User": "?1", } REQUEST_DELAY = 1.5 # secondes entre requêtes (politesse) # ── Banques centrales reconnues ───────────────────────────────────────────────── CB_NAMES = { "fed": "Fed (USD)", "ecb": "ECB (EUR)", "boe": "BoE (GBP)", "boj": "BoJ (JPY)", "snb": "SNB (CHF)", "boc": "BoC (CAD)", "rba": "RBA (AUD)", "rbnz": "RBNZ (NZD)", } def normalize_cb(raw: str) -> str: key = raw.lower().strip(" *:") for k, v in CB_NAMES.items(): if k in key: return v return raw.strip(" *:") # ── HTTP helpers ──────────────────────────────────────────────────────────────── _session = requests.Session() _session.headers.update(HEADERS) def get_page(url: str, retries: int = 3) -> BeautifulSoup | None: """Fetch une page HTML et retourne un BeautifulSoup. None si échec.""" for attempt in range(retries): try: if attempt > 0: time.sleep(REQUEST_DELAY * (attempt + 1)) resp = _session.get(url, timeout=20) resp.raise_for_status() return BeautifulSoup(resp.text, "html.parser") except requests.HTTPError as e: print(f" [HTTP {e.response.status_code}] {url}", file=sys.stderr) if e.response.status_code in (403, 404): return None # inutile de retenter except requests.RequestException as e: print(f" [Réseau] {e}", file=sys.stderr) return None # ── Extraction de la date depuis l'URL ───────────────────────────────────────── def date_from_url(url: str) -> str: """Extrait YYYY-MM-DD depuis le slug YYYYMMDD en fin d'URL.""" m = re.search(r"(\d{8})/?$", url) if m: d = m.group(1) return f"{d[:4]}-{d[4:6]}-{d[6:8]}" return "?" # ── Parsing du bloc de données ────────────────────────────────────────────────── # Pattern : **RBNZ:** 76 bps (70% probability of no change at the next meeting) _ENTRY_RE = re.compile( r"\*?\*?\s*([A-Za-z/]+)\s*:+\s*\*?\*?\s*" r"(\d+)\s*bps\s*" r"\((\d+)%\s+probability\s+of\s+([^)]+)\)", re.IGNORECASE, ) def parse_rate_entries(text: str) -> list[dict]: return [ { "cb": normalize_cb(m.group(1)), "bps": int(m.group(2)), "prob_pct": int(m.group(3)), "prob_desc": m.group(4).strip(), } for m in _ENTRY_RE.finditer(text) ] def extract_rate_data(soup: BeautifulSoup, url: str) -> dict | None: """ Extrait les blocs 'Rate cuts by year-end' et 'Rate hikes by year-end' depuis le HTML d'un article. Retourne None si le keyword n'est pas trouvé. """ # Titre h1 = soup.find("h1") title = h1.get_text(strip=True) if h1 else "N/A" # Texte complet de l'article article = ( soup.find("article") or soup.find("div", class_=re.compile(r"article|content|entry|post", re.I)) or soup.body ) text = article.get_text(separator="\n") if article else soup.get_text(separator="\n") # Vérifier la présence du keyword if KEYWORD.lower() not in text.lower(): return None # Parser les sections result = { "url": url, "title": title, "date": date_from_url(url), "scraped_at": datetime.utcnow().isoformat() + "Z", "rate_cuts": [], "rate_hikes": [], } lines = [l.strip() for l in text.splitlines() if l.strip()] current_section = None for line in lines: ll = line.lower() if "rate cuts by year-end" in ll: current_section = "cuts" continue if "rate hikes by year-end" in ll: current_section = "hikes" continue if current_section and len(line) < 250: entries = parse_rate_entries(line) for e in entries: if current_section == "cuts": e["direction"] = "cut" result["rate_cuts"].append(e) else: e["direction"] = "hike" result["rate_hikes"].append(e) # Stop si on sort manifestement du bloc (paragraphe long) if current_section and len(line) > 300: current_section = None return result # ── Découverte des articles ───────────────────────────────────────────────────── def discover_article_urls(limit: int = MAX_ARTICLES) -> list[str]: """ Découvre les URLs d'articles de Giuseppe Dellamotta depuis : 1. Sa page auteur 2. La catégorie CentralBanks Retourne une liste dédupliquée, triée du plus récent au plus ancien. """ found = [] # Source 1 : page auteur soup = get_page(AUTHOR_URL) if soup: for a in soup.find_all("a", href=True): href = a["href"] # Articles de la catégorie centralbank avec slug YYYYMMDD if "/centralbank/" in href and re.search(r"\d{8}/?$", href): full = urljoin(BASE_URL, href) if full not in found: found.append(full) time.sleep(REQUEST_DELAY) # Source 2 : catégorie (si page auteur insuffisante) if len(found) < limit: soup2 = get_page(CATEGORY_URL) if soup2: for a in soup2.find_all("a", href=True): href = a["href"] if "/centralbank/" in href and re.search(r"\d{8}/?$", href): full = urljoin(BASE_URL, href) if full not in found: found.append(full) time.sleep(REQUEST_DELAY) # Trier du plus récent (date dans l'URL) au plus ancien def url_date_key(url): m = re.search(r"(\d{8})/?$", url) return m.group(1) if m else "00000000" found.sort(key=url_date_key, reverse=True) return found[:limit] # ── Scraping principal ────────────────────────────────────────────────────────── def scrape_recent(n: int = 2, specific_url: str = None) -> list[dict]: """ Scrape les n derniers articles contenant KEYWORD. Si specific_url est fourni, scrape uniquement cet article. """ if specific_url: urls_to_check = [specific_url] else: print(f"[Découverte] Recherche des articles de Giuseppe Dellamotta...", file=sys.stderr) urls_to_check = discover_article_urls(limit=MAX_ARTICLES) print(f" {len(urls_to_check)} articles candidats trouvés.", file=sys.stderr) results = [] for url in urls_to_check: if len(results) >= n and not specific_url: break print(f" → Scanning: {url}", file=sys.stderr) soup = get_page(url) if not soup: continue data = extract_rate_data(soup, url) if data: print(f" ✅ '{KEYWORD}' trouvé — {len(data['rate_hikes'])} BC hike / {len(data['rate_cuts'])} BC cut", file=sys.stderr) results.append(data) else: print(f" ⏭ Keyword absent — article ignoré", file=sys.stderr) time.sleep(REQUEST_DELAY) return results # ── Formatage console ─────────────────────────────────────────────────────────── def format_table(results: list[dict]) -> str: lines = [] for r in results: lines.append("=" * 65) lines.append(f"📅 {r['date']} | {r['title'][:50]}") lines.append(f"🔗 {r['url']}") lines.append("") all_entries = [] for e in r["rate_cuts"]: all_entries.append((e["cb"], -e["bps"], e["prob_pct"], e["prob_desc"], "cut")) for e in r["rate_hikes"]: all_entries.append((e["cb"], e["bps"], e["prob_pct"], e["prob_desc"], "hike")) # Trier par |bps| décroissant all_entries.sort(key=lambda x: -abs(x[1])) lines.append(f" {'Banque Centrale':<16} {'Attentes fin d\'année':>14} Prochain meeting") lines.append(f" {'-'*16} {'-'*14} {'-'*30}") for cb, bps, prob, desc, direction in all_entries: arrow = "▲ +" if direction == "hike" else "▼ -" lines.append( f" {cb:<16} {arrow}{abs(bps):>3} bps {prob}% prob. of {desc}" ) lines.append("") lines.append("=" * 65) lines.append(f"Scraped at: {datetime.utcnow().strftime('%Y-%m-%d %H:%M UTC')}") return "\n".join(lines) # ── CLI ───────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser(description="Scraper rate expectations — investinglive.com") parser.add_argument("--url", help="URL spécifique à scraper (optionnel)") parser.add_argument("--n", type=int, default=2, help="Nombre de derniers articles (défaut: 2)") parser.add_argument("--output", choices=["table", "json"], default="table", help="Format de sortie") parser.add_argument("--save", help="Fichier de sauvegarde JSON (optionnel)") args = parser.parse_args() results = scrape_recent(n=args.n, specific_url=args.url) if not results: print("❌ Aucun article trouvé avec le keyword.", file=sys.stderr) sys.exit(1) if args.output == "json" or args.save: json_str = json.dumps(results, ensure_ascii=False, indent=2) if args.save: with open(args.save, "w", encoding="utf-8") as f: f.write(json_str) print(f"💾 Sauvegardé dans {args.save}", file=sys.stderr) if args.output == "json": print(json_str) else: print(format_table(results)) else: print(format_table(results)) if __name__ == "__main__": main()