""" html_scraper.py ─────────────── Downloads a law page from legislatie.just.ro and splits it into articles. URL pattern: https://legislatie.just.ro/Public/DetaliiDocument/{id} What this file does, step by step: 1. fetch_law_page() — downloads the HTML, handles errors & rate limits 2. extract_title() — pulls the law title out of the page 3. extract_raw_text() — strips all HTML tags, keeps only readable text 4. split_into_articles() — finds "Articolul X" headers and cuts the text there Install: pip install requests beautifulsoup4 """ import re import time import random import requests from bs4 import BeautifulSoup # ── Constants ───────────────────────────────────────────────────────────────── # The portal has TWO different URL patterns — we try both automatically. # DetaliiDocument works for most (A) and (R) versions. # DetaliiDocumentAfis works for older/original versions and some republications. URL_PATTERNS = [ "https://legislatie.just.ro/Public/DetaliiDocument/{}", "https://legislatie.just.ro/Public/DetaliiDocumentAfis/{}", ] BASE_URL = URL_PATTERNS[0] # kept for backward compatibility with scrape_law() # We use ONE persistent session for the entire scraping run. # This is important because: # - It reuses the TCP connection (faster, lower overhead on their server) # - It automatically handles cookies (looks more like a real browser) # - Servers are far less likely to block a session that behaves like a browser SESSION = requests.Session() SESSION.headers.update({ # Identify ourselves as a modern Chrome browser on Windows "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" ), # Tell the server we accept HTML and compressed responses "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "Accept-Language": "ro-RO,ro;q=0.9,en-US;q=0.8,en;q=0.7", "Accept-Encoding": "gzip, deflate, br", # This is the key anti-block header: # It tells the server we "came from" the portal homepage, # which is what a real user clicking a search result would look like. "Referer": "https://legislatie.just.ro/", "Connection": "keep-alive", }) # ── Step 1: Download the page ───────────────────────────────────────────────── def _fetch_single_url(url: str, retries: int = 3) -> requests.Response | None: """ Try to GET one URL with retries and rate-limit handling. Returns the Response object on success, None on permanent failure. """ for attempt in range(retries): try: resp = SESSION.get(url, timeout=20) if resp.status_code == 429: wait = 60 * (attempt + 1) print(f"\n ⚠ Rate limited (429). Waiting {wait}s...") time.sleep(wait) continue if resp.status_code == 403: return None # access denied — caller will try alternate URL resp.raise_for_status() resp.encoding = resp.apparent_encoding return resp except requests.exceptions.Timeout: time.sleep(2 ** attempt) except requests.exceptions.ConnectionError: time.sleep(2 ** attempt) except requests.exceptions.HTTPError: time.sleep(2 ** attempt) return None def fetch_law_page(law_id: int) -> tuple[BeautifulSoup, str] | tuple[None, None]: """ Download the HTML page for one law, trying both URL patterns automatically. The portal uses two different URL patterns: - DetaliiDocument/{id} — works for most actualizat (A) versions - DetaliiDocumentAfis/{id} — works for republicat (R) and older versions We try DetaliiDocument first. If it returns a page with < 500 chars of actual text content, we assume it's a redirect/error and try the Afis URL. Returns (BeautifulSoup, url_used) on success, or (None, None) on failure. """ for url_pattern in URL_PATTERNS: url = url_pattern.format(law_id) resp = _fetch_single_url(url) if resp is None: continue # try next pattern soup = BeautifulSoup(resp.text, "html.parser") # Quick sanity check: extract text and see if there's real content. # An error/redirect page has very little text (< 500 chars). # A real law page has thousands of chars. body = soup.find("body") text_preview = body.get_text() if body else "" if len(text_preview.strip()) >= 500: return soup, url # success — found a real page # This URL returned an empty/redirect page — try the next pattern print(f"\n ↻ {url_pattern.split('/')[5]} returned empty page, " f"trying alternate URL pattern...") print(f"\n ✗ Both URL patterns failed for ID {law_id}. Skipping.") return None, None # ── Step 2: Extract the title ───────────────────────────────────────────────── def extract_title(soup: BeautifulSoup) -> str: """ Pull the law's title from the HTML. The page