File size: 14,416 Bytes
83892b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
"""

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 <title> tag contains things like:

        "LEGE 53 28/06/2003 - Portal Legislativ"

        "CODUL CIVIL din 17 iulie 2009 - Portal Legislativ"



    We strip the " - Portal Legislativ" suffix and return the rest.

    If the title tag is missing for some reason, we fall back to "Unknown".

    """
    # Primary: use the <title> tag
    title_tag = soup.find("title")
    if title_tag:
        title = title_tag.get_text(strip=True)
        title = title.replace(" - Portal Legislativ", "").strip()
        if title:
            return title

    # Fallback: look for the biggest heading on the page
    for tag in ["h1", "h2", "h3"]:
        heading = soup.find(tag)
        if heading:
            text = heading.get_text(strip=True)
            if len(text) > 5:  # ignore empty or trivial headings
                return text

    return "Unknown"


# ── Step 3: Extract readable text ─────────────────────────────────────────────

def extract_raw_text(soup: BeautifulSoup) -> str:
    """

    Strip all HTML and return the plain text content of the law.



    We remove:

      - <script> and <style> tags (JavaScript and CSS — not law text)

      - <nav> tags (site navigation menus)

      - <footer> and <header> tags (site chrome)

      - <form> tags (search boxes, login forms)

      - Tags with class names that suggest UI elements, not content



    Then we use BeautifulSoup's get_text() to extract the remaining text,

    using newlines as separators so article structure is preserved.

    """
    # Remove all non-content tags completely
    for tag in soup(["script", "style", "nav", "footer", "header", "form"]):
        tag.decompose()

    # Also remove common UI element classes the portal uses
    for tag in soup.find_all(class_=re.compile(
        r"(menu|navbar|breadcrumb|pagination|sidebar|cookie|banner|btn|button)",
        re.IGNORECASE
    )):
        tag.decompose()

    # Find the main content area
    # The portal wraps the law text in a <div> — we try to find it
    # If we can't, we fall back to the full <body>
    content = (
        soup.find("div", class_=re.compile(r"(content|document|text|lege)", re.IGNORECASE))
        or soup.find("body")
    )

    if not content:
        return ""

    # get_text with separator="\n" means each HTML block element
    # becomes a separate line — this preserves article structure
    text = content.get_text(separator="\n")

    # Collapse 3+ consecutive newlines into just 2 (one blank line)
    # This keeps paragraph separation but removes excessive whitespace
    text = re.sub(r"\n{3,}", "\n\n", text)

    return text.strip()


# ── Step 4: Split text into articles ──────────────────────────────────────────

def split_into_articles(raw_text: str) -> list[dict]:
    """

    Cut the full law text into individual articles.



    Romanian laws use these article header formats:

        "Articolul 1"         — standard numbered article

        "Articolul 142"       — larger numbered article

        "Articolul UNIC"      — law with only one article

        "+ Articolul 5"       — the site sometimes prepends a "+" character

        "Art. 5"              — some older laws use abbreviation

        "ART. 5"              — uppercase abbreviation



    The strategy:

      1. Find every article header using a regex

      2. Each header's text ends where the NEXT header begins

      3. Return a list of {"number": ..., "text": ...} dicts



    Edge cases handled:

      - No articles found → return the whole text as a single chunk

      - Empty article body (abrogated articles) → skip them

      - Roman numerals → handled up to common values (I through XXX)

    """
    # This regex matches article headers ONLY when they appear as standalone headers,
    # NOT when they appear inline inside body text like "prevederile art. 29 din Legea".
    #
    # The key insight: real article headers on this portal are ALWAYS:
    #   1. At the start of a line (^ with MULTILINE)
    #   2. Optionally preceded by a "+" character (the portal's expand widget)
    #   3. Followed by a number/identifier, then a newline or end of string
    #      — they are NOT followed by more inline text like "din Legea nr. X"
    #
    # So "art. 29 din Legea nr. 47/1992" is NOT a header — it has text after it.
    # But "+ Articolul 29\n" IS a header — it's standalone on its own line.
    #
    # The negative lookahead (?!\s+din\b|\s+alin\b|\s+lit\b) prevents matching
    # inline law cross-references like "art. 29 din Legea" or "art. 5 alin. (1)".
    article_pattern = re.compile(
        r"^[\+\s]*"                           # optional leading + or whitespace
        r"((?:Articolul|ARTICOLUL)\s+"        # must use full word "Articolul"
        r"(\d+(?:\^\d+)?|UNIC|[IVXLC]{1,6}))"  # number, UNIC, or roman numeral
        r"(?!\s+(?:din\b|alin\b|lit\b|pct\b|teza\b))",  # NOT an inline ref
        re.MULTILINE,
    )

    matches = list(article_pattern.finditer(raw_text))

    # Edge case: law has no article markers (rare, but happens with very old laws)
    if not matches:
        return [{"number": "Articolul 1", "text": raw_text.strip()}]

    articles = []
    for i, match in enumerate(matches):
        # group(1) = full header text e.g. "Articolul 29"
        header = match.group(1).strip()

        # The article text starts right after the header
        text_start = match.end()

        # The article text ends where the next article header begins
        # For the last article, it extends to the end of the document
        text_end = matches[i + 1].start() if i + 1 < len(matches) else len(raw_text)

        body = raw_text[text_start:text_end].strip()

        # Skip completely empty articles (these are abrogated/repealed ones
        # where the site only shows the header with nothing after it)
        if not body:
            continue

        # Also skip articles that are ONLY the word "Abrogat" or similar
        body_single_line = " ".join(body.split())
        if re.match(r"^[\(\[\*\s]*[Aa]brogat[\)\]\*\s\.]*$", body_single_line):
            continue

        articles.append({
            "number": header,
            "text":   body,
        })

    return articles


# ── Main entry point ──────────────────────────────────────────────────────────

def scrape_law(law_id: int) -> dict | None:
    """

    Full pipeline for one law ID:

      1. Download the HTML page (tries both URL patterns automatically)

      2. Extract the title

      3. Extract the raw plain text

      4. Split into individual articles

      5. Return a structured dict



    Returns None if the page couldn't be fetched or was empty.

    """
    print(f"\n  → Fetching ID {law_id}...")

    soup, url = fetch_law_page(law_id)
    if soup is None:
        return None

    raw_text = extract_raw_text(soup)

    if not raw_text or len(raw_text) < 200:
        print(f"  ✗ Skipping ID {law_id} — page looks empty after text extraction.")
        return None

    title    = extract_title(soup)
    articles = split_into_articles(raw_text)

    print(f"  ✓ '{title}' — {len(articles)} articles found.")
    print(f"    URL: {url}")

    return {
        "id":            law_id,
        "title":         title,
        "url":           url,
        "article_count": len(articles),
        "articles":      articles,
        "raw_text":      raw_text,
    }


# ── Quick test ────────────────────────────────────────────────────────────────
# Run this file directly to test a single law:
#   python html_scraper.py
if __name__ == "__main__":
    # Codul Muncii (republicat 2011) — verified ID from legislatie.just.ro
    result = scrape_law(128646)

    if result:
        print(f"\n{'='*60}")
        print(f"Title:    {result['title']}")
        print(f"URL:      {result['url']}")
        print(f"Articles: {result['article_count']}")
        print(f"\nFirst 3 articles:")
        for art in result["articles"][:3]:
            print(f"\n  [{art['number']}]")
            print(f"  {art['text'][:200]}...")
    else:
        print("Failed to scrape. Check your internet connection.")