#!/usr/bin/env python3
"""lj_core — textul ÎN VIGOARE al unui act de pe legislatie.just.ro + extragere articol.
Standalone: doar biblioteca standard Python 3.6+. Poate fi copiat și rulat oriunde
(inclusiv pe calculatorul utilizatorului), din orice director.
python3 lj_core.py 215925 --articol 485^1
python3 lj_core.py https://legislatie.just.ro/Public/DetaliiDocument/215925 --grep "concediu"
python3 lj_core.py 215925 --info # doar forma/data, fără text
Coduri de ieșire: 0 ok | 3 rețea: HTTPS către portal blocat din acest mediu |
4 articol negăsit | 5 pagina nu conține text de act | 6 act inexistent
"""
import argparse, json, os, re, ssl, sys, tempfile, time
import urllib.error, urllib.request
from html.parser import HTMLParser
VERSION = "6.1.0"
HOST = "legislatie.just.ro"
UA = "Mozilla/5.0 (legislatie-skill)"
CACHE_DIR = os.environ.get("LEGISLATIE_CACHE") or os.path.join(tempfile.gettempdir(), "legislatie_cache")
CACHE_TTL = 6 * 3600 # consolidările apar de câteva ori pe lună; 6h e sigur
class RouteBlocked(Exception):
"""HTTPS către portal nu merge din mediul curent (proxy, IP de datacenter, fără rețea)."""
def utf8_console():
"""Diacriticele nu trebuie să blocheze consolele Windows (cp1252/cp852)."""
for st in (sys.stdout, sys.stderr):
try:
st.reconfigure(encoding="utf-8", errors="replace")
except Exception: # noqa: BLE001 (Python < 3.7 sau stream înlocuit)
pass
def _cert_error(e):
r = getattr(e, "reason", e)
return isinstance(r, ssl.SSLError) and "CERTIFICATE_VERIFY_FAILED" in str(r)
def urlopen(req, timeout=90):
"""urllib.urlopen (respectă HTTP(S)_PROXY). Dacă magazinul de certificate lipsește
(frecvent pe Python instalat de pe python.org pe macOS), reîncearcă cu certifi."""
try:
return urllib.request.urlopen(req, timeout=timeout)
except urllib.error.URLError as e:
if not _cert_error(e):
raise
try:
import certifi # noqa: WPS433
except ImportError:
raise RouteBlocked("certificatele SSL de sistem lipsesc: rulează «Install Certificates.command» "
"(macOS, python.org) sau `pip install certifi`")
ctx = ssl.create_default_context(cafile=certifi.where())
return urllib.request.urlopen(req, timeout=timeout, context=ctx)
def fetch_html(path, timeout=90):
url = path if path.startswith("http") else "https://%s%s" % (HOST, path)
url = url.replace("http://", "https://", 1)
req = urllib.request.Request(url, headers={"User-Agent": UA, "Accept": "text/html"})
try:
with urlopen(req, timeout=timeout) as r:
return r.read().decode("utf-8", errors="replace")
except urllib.error.HTTPError as e:
if e.code in (403, 407, 429, 502, 503, 504): # proxy / filtrare / portal supraîncărcat
raise RouteBlocked("HTTP %s pentru %s" % (e.code, url))
raise LookupError("Portalul a răspuns HTTP %s pentru %s (id greșit?)" % (e.code, url))
except (urllib.error.URLError, OSError) as e: # reset, timeout, DNS, proxy
raise RouteBlocked("%s: %s" % (type(e).__name__, getattr(e, "reason", e)))
class _H2T(HTMLParser):
SKIP = {"script", "style", "head", "noscript"}
BLOCK = {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6",
"section", "article", "ul", "ol", "table"}
def __init__(self):
HTMLParser.__init__(self, convert_charrefs=True)
self.parts, self.skip = [], []
def handle_starttag(self, tag, attrs):
if tag in self.SKIP:
self.skip.append(tag)
elif not self.skip:
self.parts.append("\n" if tag in self.BLOCK else " ")
def handle_endtag(self, tag):
if self.skip and self.skip[-1] == tag:
self.skip.pop()
elif not self.skip:
self.parts.append("\n" if tag in self.BLOCK else " ")
def handle_data(self, d):
if not self.skip:
self.parts.append(d)
def html_to_text(raw):
p = _H2T()
p.feed(raw)
return clean_text("".join(p.parts))
def clean_text(text):
"""Normalizează spațiile pe linii și elimină marcajele de listă (și pentru textul din API)."""
out = []
for ln in text.split("\n"):
ln = re.sub(r"[ \t\xa0]+", " ", ln).strip()
if ln and ln not in {"+", "-", "•", "·"}:
out.append(ln)
return "\n".join(out)
def _d(s): # "12.09.2026" -> (2026, 9, 12)
p = s.split(".")
return (int(p[2]), int(p[1]), int(p[0])) if len(p) == 3 else (0, 0, 0)
def parse_istoric(raw):
"""Lista consolidărilor din blocul istoric_fa: [{'data','id'}]; id=None = forma afișată."""
m = re.search(r"id=['\"]istoric_fa['\"].*?", raw, re.S)
if not m:
return []
out = []
for attrs, data in re.findall(r"]*)>\s*([\d]{2}\.[\d]{2}\.[\d]{4})\s*", m.group(0)):
h = re.search(r"DetaliiDocument(?:Afis)?/(\d+)", attrs)
out.append({"data": data, "id": h.group(1) if h else None})
return out
def _has_body(text):
return len(text) > 1500 and re.search(r"(?m)^(?:Articolul|Art\.)\s+\S", text) is not None
def doc_id_of(act):
m = re.search(r"(\d+)\s*$", str(act).strip().rstrip("/"))
if not m:
raise ValueError("Nu recunosc id-ul actului în: %r" % act)
return m.group(1)
def _load(page_id):
"""(raw, text, id_urmat): DetaliiDocument/; dacă pagina e doar „ambalaj” (ex. Legea
53/2003 -> Codul muncii, id 309240), urmează link-ul din forma consolidată afișată."""
raw = fetch_html("/Public/DetaliiDocument/%s" % page_id)
text, followed, seen = html_to_text(raw), None, {str(page_id)}
for _ in range(2):
if _has_body(text):
break
i = raw.find("div_Formaconsolidata")
ln = re.search(r"DetaliiDocument(?:Afis)?/(\d+)", raw[i:i + 20000]) if i >= 0 else None
if not ln or ln.group(1) in seen:
break
followed = ln.group(1)
seen.add(followed)
raw = fetch_html("/Public/DetaliiDocument/%s" % followed)
text = html_to_text(raw)
if not _has_body(text):
alt = html_to_text(fetch_html("/Public/DetaliiDocumentAfis/%s" % (followed or page_id)))
if _has_body(alt):
text = alt
return raw, text, followed
def get_in_force(act, no_cache=False, forma_baza=False, exact=False):
"""Returnează dict: id, url, forma, data_consolidare, id_consolidare, text, cache."""
doc_id = doc_id_of(act)
key = "%s%s" % (doc_id, "_baza" if forma_baza else ("_exact" if exact else ""))
meta_p, txt_p = (os.path.join(CACHE_DIR, key + ext) for ext in (".json", ".txt"))
if not no_cache and os.path.exists(meta_p) and os.path.exists(txt_p) \
and time.time() - os.path.getmtime(meta_p) < CACHE_TTL:
with open(meta_p, encoding="utf-8") as f:
meta = json.load(f)
with open(txt_p, encoding="utf-8") as f:
meta["text"] = f.read()
meta["cache"] = txt_p
return meta
meta = {"id": doc_id, "url": "https://%s/Public/DetaliiDocument/%s" % (HOST, doc_id),
"forma": None, "data_consolidare": None, "id_consolidare": None}
if forma_baza:
text = html_to_text(fetch_html("/Public/DetaliiDocumentAfis/%s" % doc_id))
meta["forma"] = "publicată inițial (cerută explicit)"
else:
# DetaliiDocument/ servește ULTIMA consolidare (DetaliiDocumentAfis/ = forma
# publicată inițial). Apoi verificăm cu istoricul că forma afișată e chiar cea mai nouă.
raw, text, fid = _load(doc_id)
hist = parse_istoric(raw)
shown = [h["data"] for h in hist if h["id"] is None]
linked = [h for h in hist if h["id"]]
newest = max(linked, key=lambda h: _d(h["data"])) if linked else None
shown_d = shown[0] if shown else None
if not exact and newest and (not _has_body(text) or not shown_d or _d(newest["data"]) > _d(shown_d)):
raw, text, fid2 = _load(newest["id"])
meta.update(data_consolidare=newest["data"], id_consolidare=fid2 or newest["id"])
else:
meta.update(data_consolidare=shown_d, id_consolidare=fid)
if meta["data_consolidare"]:
meta["forma"] = "consolidată — Consolidarea din %s" % meta["data_consolidare"]
else:
meta["forma"] = ("publicată — portalul nu listează consolidări (nemodificat, "
"sau consolidarea încă nepublicată: verifică actele modificatoare recente)")
if not _has_body(text):
text = html_to_text(fetch_html("/Public/DetaliiDocumentAfis/%s" % doc_id))
head = text[:6000]
ab = re.search(r"[^\n]{0,120}\ba fost abrogat[ăa]?\b[^\n]{0,160}", head)
meta["avertisment"] = ("Posibil abrogat — verifică: " + ab.group(0).strip()) if ab else None
os.makedirs(CACHE_DIR, exist_ok=True)
with open(txt_p, "w", encoding="utf-8") as f:
f.write(text)
with open(meta_p, "w", encoding="utf-8") as f:
json.dump(meta, f, ensure_ascii=False)
meta["text"], meta["cache"] = text, txt_p
return meta
def _norm_ref(ref):
ref = re.sub(r"^\s*(?:art\.?|articolul)\s*", "", ref.strip(), flags=re.I)
sup = str.maketrans("⁰¹²³⁴⁵⁶⁷⁸⁹", "0123456789")
ref = re.sub(r"\s*\^\s*\{?\s*(\d+)\s*\}?", r"^\1", ref)
m = re.match(r"^(\d+)([⁰¹²³⁴⁵⁶⁷⁸⁹]+)$", ref)
return m.group(1) + "^" + m.group(2).translate(sup) if m else ref
def extract_articol(text, ref):
"""Textul articolului (cu Notele și deciziile de sub el). None dacă nu există."""
ref = _norm_ref(ref)
text = text if "\t" not in text[:5000] else clean_text(text)
lead = "Articolul" if len(re.findall(r"(?m)^Articolul\s", text)) >= \
len(re.findall(r"(?m)^Art\.\s", text)) else r"Art\."
if re.fullmatch(r"[IVXLCDM]+", ref, re.I):
num, stop = re.escape(ref.upper()), r"(?![IVXLCDM\^])"
elif ref.lower() == "unic":
num, stop = "unic", r"\b"
else:
num, stop = re.escape(ref), r"(?![\d^])"
start = re.compile(r"(?m)^%s\s+%s%s" % (lead, num, stop))
nxt = re.compile(r"(?m)^%s\s+(?:\d+(?:\^\d+)?|[IVXLCDM]+|unic)(?![\w^])" % lead)
best = None
for m in start.finditer(text): # prima apariție e de obicei cuprinsul: luăm corpul cel mai lung
n = nxt.search(text, m.end())
end = n.start() if n else len(text)
if best is None or end - m.start() > best[1] - best[0]:
best = (m.start(), end)
return text[best[0]:best[1]].strip() if best else None
def grep(text, pattern, context=1, limit=40):
lines, rx, out = text.split("\n"), re.compile(pattern, re.I), []
for i, ln in enumerate(lines):
if rx.search(ln):
out.append("\n".join("%6d %s" % (j + 1, lines[j][:600])
for j in range(max(0, i - context), min(len(lines), i + context + 1))))
if len(out) >= limit:
out.append("… (limită %d potriviri)" % limit)
break
return "\n--\n".join(out)
def header(meta):
s = ["ACT: id %s — %s" % (meta["id"], meta["url"]), "FORMA: " + meta["forma"]]
if meta.get("id_consolidare"):
s.append(" (id consolidare %s)" % meta["id_consolidare"])
if meta.get("avertisment"):
s.append("ATENȚIE: " + meta["avertisment"])
if meta.get("cache"):
s.append("TEXT: %s caractere -> %s" % (format(len(meta["text"]), ","), meta["cache"]))
return "\n".join(s)
def main(argv=None):
utf8_console()
ap = argparse.ArgumentParser(description="Text în vigoare + articol, legislatie.just.ro")
ap.add_argument("act", nargs="?", help="id (215925) sau link DetaliiDocument/")
ap.add_argument("--articol", help="ex: 485^1, 12, XLIX, 'Art. 5', unic")
ap.add_argument("--grep", help="regex căutat în text (linii cu context)")
ap.add_argument("--context", type=int, default=1)
ap.add_argument("--info", action="store_true", help="doar forma/data, fără text")
ap.add_argument("--json", action="store_true")
ap.add_argument("--no-cache", action="store_true")
ap.add_argument("--forma-baza", action="store_true", help="forma publicată inițial")
ap.add_argument("--exact", action="store_true",
help="exact consolidarea cu acest id (istoric), fără salt la cea mai nouă")
ap.add_argument("--version", action="store_true")
a = ap.parse_args(argv)
if a.version:
print("lj_core", VERSION)
return 0
if not a.act:
ap.print_help()
return 1
try:
meta = get_in_force(a.act, no_cache=a.no_cache, forma_baza=a.forma_baza, exact=a.exact)
except RouteBlocked as e:
print("ROUTE_BLOCKED: HTTPS către %s nu merge din acest mediu (%s).\n"
"Treci la ruta următoare din SKILL.md (calculatorul utilizatorului / browser)." % (HOST, e),
file=sys.stderr)
return 3
except LookupError as e:
print(str(e), file=sys.stderr)
return 6
if not _has_body(meta["text"]):
print(header(meta) + "\n[!] Pagina nu conține textul actului.", file=sys.stderr)
return 5
if a.json:
print(json.dumps({k: v for k, v in meta.items() if k != "text"}, ensure_ascii=False, indent=1))
else:
print(header(meta))
if a.articol:
art = extract_articol(meta["text"], a.articol)
if art is None:
print("[!] Articolul %s nu apare în text." % a.articol, file=sys.stderr)
return 4
print("\n--- Articolul %s (%s caractere) ---\n%s" % (_norm_ref(a.articol), format(len(art), ","), art))
elif a.grep:
print("\n" + (grep(meta["text"], a.grep, a.context) or "(nicio potrivire)"))
return 0
if __name__ == "__main__":
sys.exit(main())