#!/usr/bin/env python3 """MK noteikumu HTML → sources/.txt un data/.xml (datu struktūra mk-noteikumi-0.1). python3 tools/convert.py [--org SS-IIII] Avots: * vestnesis.lv/ta/id/ — oficiālā publikācija (TextVersion basis="original"). Lapā ir divi formāti: vecākās publikācijas ar TV klasēm (TV206 galviņa, TV207 nosaukums, TV900 izdošanas pamatojums, TV212 nodaļa, TV213 punkts, TV216 paraksti, TV218 pielikuma galviņa, TV444 HTML) un jaunākās ar p.lik_noteik, p.lik_dat, h3, p.lik_izd, h4, p, p.lik_paraksts; * likumi.lv/ta/id/ — konsolidētā redakcija (basis="consolidated"): TV klases, punkti div.TV213[data-num] ar norādēm p.labojumu_pamats un tiesību akta pasi. HTML repozitorijā netiek glabāts: avots ir teksta datne, XML atbilst tai (tools/validate.py pārbauda). """ import copy import hashlib import os import re import sys from xml.sax.saxutils import escape, quoteattr import lxml.html ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) SUP = str.maketrans("0123456789", "⁰¹²³⁴⁵⁶⁷⁸⁹") SUPC = "⁰¹²³⁴⁵⁶⁷⁸⁹" MONTHS = {"janvār": 1, "februār": 2, "mart": 3, "aprīl": 4, "maij": 5, "jūnij": 6, "jūlij": 7, "august": 8, "septembr": 9, "oktobr": 10, "novembr": 11, "decembr": 12} # ----------------------------------------------------------------------------------------------------------- text helpers def norm(s): s = re.sub("[​‌‍⁠]", "", s or "") return re.sub(r"[ \t\r\f\v ]+", " ", (s or "").replace("­", "")).strip() def paras_of(el): """Visible text of an element split into paragraphs: digits → superscript characters; two consecutive
(or
followed by a new number) start a new paragraph; a single
is a space.""" e = copy.deepcopy(el) for s in e.iter("sup"): s.text = (s.text or "").translate(SUP).strip() s.tail = s.tail or "" for b in e.iter("br"): b.tail = "\ue000" + (b.tail or "") for im in e.iter("img"): # checkbox image in forms \u2192 \u2610 im.tail = ("\u2610 " if "KVADRATS" in (im.get("src") or "").upper() else "") + (im.tail or "") raw = e.text_content().replace("­", "") raw = re.sub(r"\s*\n\s*", " ", raw) # line breaks of the HTML source are spaces raw = re.sub(r"\ue000\s*\ue000", "\n", raw) out = [] for part in raw.split("\n"): part = norm(part.replace("\ue000", " ")) if part: out.append(part) return out def text_of(el): return " ".join(paras_of(el)) def lv_date(s): """"2023. gada 7. novembrī" / "2006.gada 5.decembrī" → 2023-11-07""" m = re.search(r"(\d{4})\.\s*gada\s*(\d{1,2})\.\s*([a-zāčēģīķļņšūž]+)", s or "") if not m: return None mon = next((v for k, v in MONTHS.items() if m.group(3).startswith(k)), None) return f"{m.group(1)}-{mon:02d}-{int(m.group(2)):02d}" if mon else None # ---------------------------------------------------------------------------------------------------------------- parse def events(page): """Ordered events: (kind, text[, extra]). kinds: head, title, basis, chapter, para, note, sig, annex, line, image.""" root = lxml.html.fromstring(page) box = root.xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' docContent ')]") if box: body = box[0] origin = "vestnesis" else: # likumi.lv t207 = root.xpath("//div[starts-with(@class, 'TV207')]")[0] body = t207.getparent() origin = "likumi" BLOCK = {"p", "h1", "h2", "h3", "h4", "h5", "div", "table"} INLINE_P = {"p", "font", "b", "strong", "i", "em", "span", "u", "center"} def blocks(parent): """Block elements in document order; inline wrappers (, ,
,
) that hold blocks are opened.""" for e in parent: if not isinstance(e.tag, str): continue tag = e.tag.lower() cls = e.get("class") or "" wrapper = tag in ("b", "i", "strong", "em", "center", "span", "font", "u") or \ (tag == "div" and not cls.startswith("TV") and "docContent" not in cls) if wrapper and any(isinstance(c.tag, str) and c.tag.lower() in BLOCK for c in e.iter() if c is not e): yield from blocks(e) elif tag in BLOCK or (wrapper and norm(e.text_content())): # stray inline text (old HTML) = a paragraph yield e def plain(el, ev): """Older publications without classes (2006 and earlier): head, date, title, legal basis, EU reference heading, signatures and the editorial note are told apart by position and wording. Returns True if the paragraph was used.""" t = text_of(el) if not t: return True started = any(e[0] in ("para", "chapter") for e in ev) if not started: if re.match(r"^Ministru kabineta noteikumi Nr", t): ev.append(("head", t)); return True if re.match(r"^Rīgā \d{4}\.", t) and ev and ev[-1][0] == "head": ev[-1] = ("head", ev[-1][1] + " " + t); return True if t.startswith("Izdoti saskaņā"): ev.append(("basis", t)); return True if not any(e[0] == "title" for e in ev) and split_num(t)[0] is None: ev.append(("title", t)); return True return False if re.match(r"^Informatīv[aā] atsauce", t): ev.append(("chapter", t)); return True if t.startswith("Redakcijas piebilde"): ev.append(("editorial", t)); return True if (el.get("align") or "").lower() == "right" and el.xpath(".//i") and split_num(t)[0] is None \ and sig_split(t)[0] != t and not re.search(r"pielikums", t): ev.append(("sig", t)); return True return False ev, after_sig = [], False for el in blocks(body): cls = (el.get("class") or "").split() c = cls[0] if cls else el.tag.lower() tv = re.match(r"TV(\d+)", c) code = tv.group(1) if tv else None if c == "lik_noteik" or code == "206": ev.append(("head", " ".join(paras_of(el)))) elif c == "lik_dat": ev[-1] = ("head", ev[-1][1] + " " + text_of(el)) if ev and ev[-1][0] == "head" else ("head", text_of(el)) elif el.tag.lower() == "h3" or code == "207": ev.append(("title", text_of(el))) elif c == "lik_izd" or (code == "900" and not any(e[0] == "chapter" for e in ev)): ev.append(("basis", text_of(el))) elif c == "lik_paraksts" or code in ("216", "217", "219"): for x in ([text_of(el)] if c == "lik_paraksts" else paras_of(el)): ev.append(("sig", x)) after_sig = True elif code == "218" or (after_sig and el.tag.lower() in ("p", "h3", "h4") and re.match(r"^\d+\.\s?pielikums", text_of(el))): ev.append(("annex", " ".join(paras_of(el)))) elif after_sig and ev and any(e[0] == "annex" for e in ev): imgs = el.xpath(".//img/@src") for src in imgs: if "KVADRATS" in src.upper(): # checkbox, kept in the text as ☐ continue ev.append(("image", src if src.startswith("http") else "https://www.vestnesis.lv" + re.sub("/+", "/", src))) rows = el.xpath(".//tr") if rows: for tr in rows: cells = [text_of(td) for td in tr.xpath("./td|./th")] if any(cells): ev.append(("line", " | ".join(cells))) elif code == "212" and text_of(el): ev.append(("line", text_of(el))) else: for x in paras_of(el): ev.append(("line", x)) elif el.tag.lower() == "h4" or code == "212": if text_of(el): ev.append(("chapter", text_of(el))) elif code == "213" and el.get("data-num"): # likumi.lv consolidated point for p in el.xpath("./p[contains(concat(' ', normalize-space(@class), ' '), ' TV213 ')]"): for x in paras_of(p): ev.append(("para", x)) for p in el.xpath(".//p[contains(@class,'labojumu_pamats')]"): if text_of(p): ev.append(("note", text_of(p))) elif el.tag.lower() in INLINE_P and not cls and plain(el, ev): after_sig = after_sig or bool(ev and ev[-1][0] == "sig") elif el.tag.lower() in INLINE_P or code == "213": for x in paras_of(el): ev.append(("para", x)) elif el.tag.lower() == "table" and not after_sig: for tr in el.xpath(".//tr"): cells = [text_of(td) for td in tr.xpath("./td|./th")] if any(cells): ev.append(("para", " | ".join(cells))) return origin, ev def split_num(text): """Printed point number → (number, rest). "3.1. x" → 3.1; "4.4.¹ x" / "4.4¹. x" → 4.4¹; "24.¹1. x" → 24¹.1; "1) x" → ("1)", x).""" m = re.match(r"^(\d+)\)\s*", text) if m: return m.group(1) + ")", text[m.end():] m = re.match(r"^(\d+)", text) if not m: return None, text segs, i = [m.group(1)], m.end() while True: rest = text[i:] sup = re.match(r"^\.?([" + SUPC + r"]+)", rest) if sup and not segs[-1].endswith(tuple(SUPC)): segs[-1] += sup.group(1) i += sup.end() nxt = re.match(r"^\.?\s?(\d+)(?=[." + SUPC + r"])", text[i:]) if nxt: segs.append(nxt.group(1)); i += nxt.end() continue nxt = re.match(r"^\.(\d+)(?=[." + SUPC + r"])", rest) if nxt: segs.append(nxt.group(1)); i += nxt.end() continue break end = re.match(r"^\.\s*", text[i:]) or (re.match(r"^\s+", text[i:]) if segs[-1][-1] in SUPC else None) if not end: return None, text rest = text[i + end.end():] if not rest or re.match(r"^(gada|panta|pant|punkt|apakšpunkt|daļ|nodaļ|pielikum|un |vai |–)", rest): # "2006. gada …" is not a point return None, text return ".".join(segs), rest def pid(num): """3.1 → p3.1; 4.2¹ → p4.2s1""" return "p" + re.sub("[" + SUPC + "]+", lambda m: "s" + m.group(0).translate(str.maketrans(SUPC, "0123456789")), num) def chapter_kind(title): t = (title or "").lower() if "pārejas" in t: return "transitional" if "noslēguma" in t: return "final" if "vispārīg" in t: return "general" return "substantive" def point_kind(text, chapter_k, has_children): t = text.lower() if t.startswith("(svītrots") or t == "svītrots.": return "deleted" if re.match(r"^atzīt par spēku zaudējuš", t): return "repeal" if re.search(r"^(noteikumi|šie noteikumi)( [^.]{0,40})? stājas spēkā", t) or re.match(r"^noteikumu \d", t) and "stājas spēkā" in t: return "in_force" if re.match(r"^(noteikumi|šie noteikumi) (nosaka|attiecas)", t): return "scope" if text.rstrip().endswith(":") or has_children: return "lead" if re.match(r"^[^–—-]{2,80} [–—-] ", text) and chapter_k == "general": return "definition" if chapter_k == "transitional": return "transitional" return "provision" def build_tree(ev, doc): chapters, top, eu, sigs, annexes = [], None, None, [], [] cur_ch, last_point, index = None, None, {} ch_seq = [0] mode = "body" def new_chapter(title, parent=None): nonlocal cur_ch m = re.match(r"^([IVXLC]+|\d+(?:\.\d+)*)\.\s*(.*)$", title) n, t = (m.group(1), m.group(2)) if m else (None, title) if parent is None: ch_seq[0] += 1 ch = {"id": f"{doc}.c{ch_seq[0]}", "n": n, "title": t, "notes": [], "items": [], "sub": 0} chapters.append(ch) else: parent["sub"] += 1 ch = {"id": f"{parent['id']}.{parent['sub']}", "n": n, "title": t, "notes": [], "items": [], "sub": 0} parent["items"].append(("chapter", ch)) cur_ch = ch return ch for e in ev: k, x = e[0], e[1] if k == "chapter": if re.match(r"^informatīv[aā] atsauce", x.lower()): eu = {"id": f"{doc}.eu", "title": x, "lead": None, "items": []} mode = "eu" continue mode = "body" m = re.match(r"^(\d+)\.(\d+)\.?\s", x) # 2.1. apakšnodaļa within chapter 2 if m and top is not None and top["n"] == m.group(1): new_chapter(x, parent=top) else: top = new_chapter(x) last_point = None elif k == "note": if last_point is not None: last_point["notes"].append(x) elif cur_ch is not None: cur_ch["notes"].append(x) elif k == "para" and mode == "eu": num, rest = split_num(x) if num and num.endswith(")"): eu["items"].append((num, x)) elif not eu["items"] and eu["lead"] is None and x.rstrip().endswith(":"): eu["lead"] = x elif not eu["items"] and re.search(r"Direktīv|Regul", x): # one act, written as one sentence eu["items"].append((None, x)) elif not eu["items"] and eu["lead"] is None: eu["lead"] = x elif eu["items"]: n0, t0 = eu["items"][-1] eu["items"][-1] = (n0, t0 + "\n" + x) elif k == "para": if cur_ch is None: top = new_chapter("") cur_ch["n"], cur_ch["title"] = None, None num, rest = split_num(x) if num is None: if last_point is not None: last_point["text"] += "\n" + x else: cur_ch["notes"].append(x) continue if num.endswith(")"): parent = last_point if last_point is not None and not last_point["n"].endswith(")") else (last_point or {}).get("parent") if parent is None: raise SystemExit(f"uzskaitījuma elements bez punkta: {x[:80]}") p = {"n": num, "id": f"{parent['id']}.i{num[:-1]}", "text": x, "notes": [], "children": [], "parent": parent} parent["children"].append(p) else: ident = f"{doc}.{pid(num)}" if ident in index: # "13.7. apakšpunktā minētie …" — a reference opening a paragraph, not a new point print(f" brīdinājums: {num} jau ir — rindkopa pievienota iepriekšējam punktam: {x[:60]}", file=sys.stderr) if last_point is not None: last_point["text"] += "\n" + x continue pnum = num.rsplit(".", 1)[0] if "." in num else None parent = index.get(f"{doc}.{pid(pnum)}") if pnum else None p = {"n": num, "id": ident, "text": x, "notes": [], "children": [], "parent": parent} index[ident] = p (parent["children"] if parent else cur_ch["items"]).append(p if parent else ("point", p)) p["chk"] = chapter_kind(cur_ch["title"]) if cur_ch["title"] else "none" last_point = p elif k == "sig": if annexes: annexes[-1]["sigs"].append(x) else: sigs.append(x) elif k == "annex": n = re.match(r"^(\d+)\.\s?pielikums", x) annexes.append({"id": f"{doc}.a{len(annexes) + 1}", "n": n.group(1) if n else None, "heading": x, "title": None, "lines": [], "images": [], "sigs": []}) elif k == "line" and annexes: a = annexes[-1] if a["title"] is None and not a["lines"] and not a["images"]: a["title"] = x else: a["lines"].append(x) elif k == "image" and annexes: annexes[-1]["images"].append(x) return chapters, eu, sigs, annexes # ----------------------------------------------------------------------------------------------------------------- write def a(k, v): return f" {k}={quoteattr(str(v))}" if v not in (None, "") else "" def el(tag, text, **at): return f"<{tag}{''.join(a(k, v) for k, v in at.items())}>{escape(text)}" if text else "" def sig_split(s): """"Ministru prezidents A.Kalvītis" / "Klimata un enerģētikas ministrs K. Melnis" → (role, name).""" m = re.match(r"^(.*?)\s+((?:[A-ZĀČĒĢĪĶĻŅŠŪŽ]\.\s?)+[A-ZĀČĒĢĪĶĻŅŠŪŽ][\wāčēģīķļņšūž-]+(?:\s[A-ZĀČĒĢĪĶĻŅŠŪŽ][\wāčēģīķļņšūž-]+)?)$", s) return (m.group(1), m.group(2)) if m else (s, s) def ministry_of(sigs): for s in sigs: role, _ = sig_split(s) m = re.match(r"^(.*?)\s+ministra (?:pienākumu izpildītāj|vietā)", role) or re.match(r"^(.*?)\s+ministr(s|e)$", role) if m and "prezident" not in role.lower(): return m.group(1) + " ministrija" return None def point_xml(p, lines, ind): text = p["text"] if not p["n"].endswith(")"): text = split_num(text.split("\n")[0])[1] + ("\n" + "\n".join(text.split("\n")[1:]) if "\n" in text else "") kind = "item" if p["n"].endswith(")") or (p["parent"] is not None and p["parent"]["text"].rstrip().endswith(":")) else \ point_kind(text, p.get("chk", "substantive"), bool(p["children"])) if kind == "item" and point_kind(text, "x", False) == "deleted": kind = "deleted" elif kind == "item" and p["parent"] is not None and re.search(r"termin", p["parent"]["text"].split("\n")[0].lower()): kind = "definition" lines.append(f"{ind}") lines.append(f"{ind} {el('Text', text)}") for n in p["notes"]: lines.append(f"{ind} {el('Note', n)}") for c in p["children"]: point_xml(c, lines, ind + " ") lines.append(f"{ind}") def chapter_xml(ch, lines, ind, kind_parent=None): kind = ("none" if not ch["title"] else chapter_kind(ch["title"])) if kind_parent is None else \ (chapter_kind(ch["title"]) if chapter_kind(ch["title"]) != "substantive" else kind_parent) lines.append(f"{ind}") if ch["title"]: lines.append(f"{ind} {el('Title', ch['title'])}") for n in ch["notes"]: lines.append(f"{ind} {el('Note', n)}") for t, item in ch["items"]: if t == "point": point_xml(item, lines, ind + " ") else: chapter_xml(item, lines, ind + " ", kind) lines.append(f"{ind}") def celex(text): m = re.search(r"(Direktīv|Regul)\w*\s+(?:\((ES|EK|EEK|Euratom)\)\s+)?(\d{4})/(\d+)(?:/(ES|EK|EEK))?", text) if not m: m2 = re.search(r"(Direktīv|Regul)\w*\s+(?:Nr\.\s*)?(\d+)/(\d{4})", text) if m2: return f"3{m2.group(3)}{'L' if m2.group(1) == 'Direktīv' else 'R'}{int(m2.group(2)):04d}" return None return f"3{m.group(3)}{'L' if m.group(1) == 'Direktīv' else 'R'}{int(m.group(4)):04d}" def build(page, src_id, retrieved, org=None): origin, ev = events(page) head = next((e[1] for e in ev if e[0] == "head"), "") title = next(e[1] for e in ev if e[0] == "title") basis = next((e[1] for e in ev if e[0] == "basis"), None) m = re.search(r"noteikumi Nr\.\s*(\d+)", head) number = int(m.group(1)) adopted = lv_date(head) year = adopted[:4] doc = f"lv-mkn-{year}-{number}" protocol = re.search(r"\((prot\.[^)]*)\)", head) pub = re.search(r"Publicēts oficiālajā (?:izdevumā|laikrakstā) "Latvijas Vēstnesis", (\d{1,2})\.(\d{1,2})\.(\d{4})\., Nr\. ([0-9A-Za-z]+)", page) \ or re.search(r'Publicēts oficiālajā (?:izdevumā|laikrakstā) "Latvijas Vēstnesis", (\d{1,2})\.(\d{1,2})\.(\d{4})\., Nr\. ([0-9A-Za-z]+)', page) if not pub: # likumi.lv passport: "Publicēts: Latvijas Vēstnesis, 89, 09.05.2024." m2 = re.search(r"Publicēts: ]*>Latvijas Vēstnesis, ([0-9A-Za-z]+), (\d{1,2})\.(\d{1,2})\.(\d{4})\.", page) if m2: class _Pub: # same groups as the vestnesis pattern: day, month, year, issue def __init__(self, m): self.g = (None, m.group(2), m.group(3), m.group(4), m.group(1)) def group(self, i): return self.g[i] pub = _Pub(m2) op = re.search(r"https://www\.vestnesis\.lv/op/(\d{4}/[0-9A-Za-z]+\.\d+)", page) pub_date = f"{pub.group(3)}-{int(pub.group(2)):02d}-{int(pub.group(1)):02d}" if pub else adopted chapters, eu, sigs, annexes = build_tree(ev, doc) # ------------------------------------------------------------------------------------------ text source (one line each) txt = [x for x in ([head, title, basis]) if x] for e in ev: if e[0] in ("chapter", "para", "note", "sig", "annex", "line", "editorial"): txt.append(e[1]) elif e[0] == "image": txt.append("[attēls] " + e[1]) body = "\n".join(txt) + "\n" os.makedirs(f"{ROOT}/sources", exist_ok=True) with open(f"{ROOT}/sources/{doc}.txt", "w", encoding="utf-8") as f: f.write(body) sha = hashlib.sha256(body.encode("utf-8")).hexdigest() url = f"https://www.vestnesis.lv/ta/id/{src_id}" if origin == "vestnesis" else f"https://likumi.lv/ta/id/{src_id}" ministry = ministry_of(sigs) L = ['', f'', " ", f" {el('Title', title)}", " Ministru kabinets", " Noteikumi", f" {number}", f" {adopted}"] if protocol: L.append(f" {el('Protocol', protocol.group(1))}") if ministry: L.append(f" {escape(ministry)}") if pub: L.append(f" " f"Latvijas Vēstnesis, {pub.group(3)}, {pub.group(4)}. nr.") # in force: the editorial note ("noteikumi stājas spēkā ar 2006.gada 29.aprīli") or the in-force point of the text force = next((lv_date(e[1]) for e in ev if e[0] == "editorial" and "stājas spēkā" in e[1]), None) if not force: force = next((lv_date(e[1]) for e in ev if e[0] == "para" and re.match(r"^\d+\.\s*(Šie )?[Nn]oteikumi stājas spēkā", e[1])), None) if force: L.append(f" {force}") L += [f" {src_id}", f" {el('Url', url)}", " ", f" ", " ", f" sources/{doc}.txt", f" {sha}", f" {retrieved}", f" {el('Url', url)}", " "] if basis: L.append(f" {el('LegalBasis', basis)}") for ch in chapters: chapter_xml(ch, L, " ") if eu: L.append(f" ") L.append(f" {el('Title', eu['title'])}") if eu["lead"]: L.append(f" {el('Lead', eu['lead'])}") for i, (n, t) in enumerate(eu["items"], 1): L.append(f" {el('Text', t)}") L.append(" ") for s in sigs: role, name = sig_split(s) L.append(f" {escape(name)}") for an in annexes: L.append(f" ") L.append(f" {el('Heading', an['heading'])}") if an["title"]: L.append(f" {el('Title', an['title'])}") for x in an["lines"]: L.append(f" {el('Line', x)}") for x in an["images"]: L.append(f" {el('Image', x)}") for s in an["sigs"]: role, name = sig_split(s) L.append(f" {escape(name)}") L.append(" ") L += [f' ', f" {el('Change', 'Pirmā versija: ' + ('oficiālā publikācija no vestnesis.lv, vēlākie grozījumi nav iestrādāti' if origin == 'vestnesis' else 'konsolidētā redakcija no likumi.lv'))}", " ", ""] os.makedirs(f"{ROOT}/data", exist_ok=True) with open(f"{ROOT}/data/{doc}.xml", "w", encoding="utf-8") as f: f.write("\n".join(L) + "\n") return doc if __name__ == "__main__": args = [x for x in sys.argv[1:] if not x.startswith("--")] org = sys.argv[sys.argv.index("--org") + 1] if "--org" in sys.argv else None if org: args.remove(org) page = open(args[0], encoding="utf-8", errors="replace").read() print(build(page, int(args[1]), args[2], org))