1
0
Files
Rihards 35accde959 Nolikumi-as-Code 0.2.0: atsauces uz tiesību aktiem (Cite)
Katrs nolikuma punkts saņem Cite katram tiesību aktam, uz kuru likumi.lv lapā ir saite punkta tekstā,
un „Izdoti saskaņā ar …” — atsauces pēc LegalBasis. Grozījumu norādes un paššaites netiek iekļautas.
363 atsauces 94 nolikumos (301 likumi, 61 MK noteikumi, 1 rīkojums). Shēma paplašināta saderīgi,
datu līgumā objekts Cite → cac_bronze.nol_cite. tools/cites.py. validate: 0 kļūdu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016igpMJBtapmG6vHE4vSF9q
2026-10-24 08:43:46 +00:00

191 lines
7.8 KiB
Python
Executable File

"""cites.py — atsauces uz tiesību aktiem nolikumā (Cite) no likumi.lv lapas saitēm.
Katrs nolikuma punkts (un „Izdoti saskaņā ar …”) saņem <Cite> katram tiesību aktam, uz kuru likumi.lv lapā ir saite
šī punkta tekstā. Neņem: saites uz grozījumu noteikumiem (redakcijas piezīmes, „svītrots ar …”), saites uz pašu nolikumu.
python3 tools/cites.py <kešs> [<veidi.csv>]
<kešs>/<likumi_id>.html — nolikuma lapa; trūkstošās lapas (nolikumi un citētie akti) tiek lejupielādētas.
<veidi.csv> (nav obligāts) — jau zināmi citēto aktu nosaukumi un veidi (kolonnas likumi_id, nosaukums, veids, statuss),
lai nav jālejupielādē katra citētā akta lapa.
Ja atsauces failā nemainās, fails netiek pārrakstīts; ja mainās — pievieno DataVersion ierakstu.
"""
import copy, csv, datetime, html as H, os, re, sys, time, urllib.request
import yaml
import lxml.html
from lxml import etree
NS = "urn:pppa:vpk:nolikums:0.1"
Q = "{%s}" % NS
UA = {"User-Agent": "Mozilla/5.0 (nolikumi-as-code/0.2 cites)"}
SUP = str.maketrans("0123456789", "⁰¹²³⁴⁵⁶⁷⁸⁹")
DATE = re.compile(r"^\d{2}\.\d{2}\.\d{4}\.?$")
NUM = re.compile(r"^(\d+(?:\.\d+)*)\.?([⁰¹²³⁴⁵⁶⁷⁸⁹]*)\.?\s") # 4.22.² / 4.22² → 4.22²
LINK = re.compile(r"^(?:https?://likumi\.lv)?/ta/id/(\d+)(?:-([^#?]*))?(?:#(\w+))?")
def get(lid, cache):
p = os.path.join(cache, f"{lid}.html")
if not os.path.exists(p) or os.path.getsize(p) == 0:
time.sleep(1)
with urllib.request.urlopen(urllib.request.Request(f"https://likumi.lv/ta/id/{lid}", headers=UA), timeout=60) as r:
open(p, "wb").write(r.read())
return open(p, encoding="utf-8").read()
def pase(page):
i = page.find("pase-container")
t = " ".join(H.unescape(re.sub(r"<[^>]+>", " ", re.sub(r"(?s)<(script|style)\b.*?</\1>", " ", page[i:i + 20000]))).split())
f = {}
for k in ["Nosaukums", "Statuss", "Veids"]:
m = re.search(re.escape(k) + r":\s*(.*?)\s+(?=Statuss:|Izdevējs:|Veids:|Pieņemts:|Stājas spēkā:|Tēma:|Zaudē spēku:|Publicēts:|Numurs:)", t)
if m:
f[k] = m.group(1).strip()
if "Statuss" in f:
w = f["Statuss"].split(); h = len(w) // 2
if len(w) % 2 == 0 and [x.lower() for x in w[:h]] == [x.lower() for x in w[h:]]:
f["Statuss"] = " ".join(w[:h])
return f
def anchor_unit(a):
"""'p16' → pants 16; 'p16_1' → 16¹; 'piel2' → pielikums 2; 'n3' → nodaļa 3."""
if not a:
return None, None
m = re.fullmatch(r"p(\d+)(?:_(\d+))?", a)
if m:
return "article", m.group(1) + (m.group(2) or "").translate(SUP)
m = re.fullmatch(r"piel(\d+)", a)
if m:
return "annex", m.group(1)
m = re.fullmatch(r"n(\d+)", a)
if m:
return "chapter", m.group(1)
return None, None
def links_in(p, self_id):
"""Saites rindkopā p: [(likumi_id, enkurs, saites teksts)], bez grozījumu un paššaitēm."""
out = []
for a in p.iter("a"):
m = LINK.match(a.get("href") or "")
if not m:
continue
lid, slug, anc = m.group(1), m.group(2) or "", m.group(3)
txt = " ".join(a.text_content().split())
if lid == str(self_id) or slug.startswith("grozij") or DATE.match(txt) or not txt:
continue
if out and out[-1][0] == lid and (not out[-1][1] or not anc):
# viena atsauce, vairākas saites: "Valsts pārvaldes iekārtas likuma" + "16.panta"; "Latvijas" + "vides … likumā"
out[-1] = (lid, out[-1][1] or anc, out[-1][2] + " " + txt)
continue
out.append((lid, anc, txt))
return out
def html_cites(page, self_id):
"""→ (legal_basis_cites, {punkta n: [cites]})"""
r = lxml.html.fromstring(page)
body = r.xpath("//div[contains(concat(' ', normalize-space(@class), ' '), ' TV207 ')]")[0].getparent()
basis, by_n = [], {}
for el in body:
if not isinstance(el.tag, str):
continue
c = ((el.get("class") or "").split() or [""])[0]
if c == "TV900":
basis += links_in(el, self_id)
continue
if c != "TV213":
continue
top = next((x.get("name")[1:] for x in el.iter("a") if re.fullmatch(r"p\d+", x.get("name") or "")), None)
cur = top
for p in el.iter("p"):
if "labojumu_pamats" in (p.get("class") or ""):
continue
q = copy.deepcopy(p)
for sp in q.iter("sup"):
sp.text = (sp.text or "").translate(SUP)
t = " ".join(q.text_content().split())
m = NUM.match(t)
if m:
cur = m.group(1) + m.group(2)
ls = links_in(p, self_id)
if ls and cur:
by_n.setdefault(cur, []).extend(ls)
return basis, by_n
def cite_el(lid, anc, txt, acts):
a = acts.get(lid, {})
e = etree.Element(Q + "Cite", likumiId=lid)
if a.get("veids"): e.set("type", a["veids"])
if a.get("nosaukums"): e.set("title", a["nosaukums"])
if a.get("statuss"): e.set("status", a["statuss"])
unit, n = anchor_unit(anc)
if unit: e.set(unit, n)
e.text = txt
return e
def sig(root):
return [(x.getparent().get("id"), x.get("likumiId"), x.get("article"), x.text) for x in root.iter(Q + "Cite")]
def place(parent, cites, acts):
"""Cite pēc Text/Note/LegalBasis, pirms apakšpunktiem / nodaļām."""
for x in parent.findall(Q + "Cite"):
parent.remove(x)
kids = list(parent)
pos = next((k for k, x in enumerate(kids) if x.tag in (Q + "Point", Q + "Chapter")), len(kids))
for k, (lid, anc, txt) in enumerate(cites):
parent.insert(pos + k, cite_el(lid, anc, txt, acts))
def main(cache, kinds=None):
os.makedirs(cache, exist_ok=True)
cat = yaml.safe_load(open("nolikumi.yaml", encoding="utf-8"))["nolikumi"]
acts = {}
if kinds:
for r in csv.DictReader(open(kinds, encoding="utf-8")):
acts[r["likumi_id"]] = {"nosaukums": r.get("nosaukums"), "veids": r.get("veids"), "statuss": r.get("statuss")}
today = datetime.date.today().isoformat()
tot = {"files": 0, "changed": 0, "cites": 0, "unmatched": 0}
for e in cat:
page = get(e["likumi_id"], cache)
basis, by_n = html_cites(page, e["likumi_id"])
for lid in {x[0] for x in basis} | {x[0] for v in by_n.values() for x in v}:
if lid not in acts or not acts[lid].get("veids"):
p = pase(get(lid, cache))
acts[lid] = {"nosaukums": p.get("Nosaukums"), "veids": p.get("Veids"), "statuss": p.get("Statuss")}
path = e["file"]
tree = etree.parse(path)
root = tree.getroot()
before = sig(root)
if root.find(Q + "LegalBasis") is not None:
place(root, basis, acts)
pts = {p.get("n"): p for p in root.iter(Q + "Point")}
for p in pts.values():
place(p, [], acts)
for n, cs in by_n.items():
if n in pts:
place(pts[n], cs, acts)
else:
tot["unmatched"] += 1
print(f" {e['org']}: punkts {n} nav XML ({len(cs)} saites)")
after = sig(root)
tot["files"] += 1; tot["cites"] += len(after)
if after != before:
nums = [int(d.get("number")) for d in root.findall(Q + "DataVersion")]
dv = etree.SubElement(root, Q + "DataVersion", number=str(max(nums) + 1), date=today)
etree.SubElement(dv, Q + "Change").text = ("Atsauces uz tiesību aktiem (Cite) pēc likumi.lv lapas saitēm: "
f"{len(after)}.")
etree.indent(root, space=" ")
data = etree.tostring(root, xml_declaration=True, encoding="UTF-8")
open(path, "wb").write(data if data.endswith(b"\n") else data + b"\n")
tot["changed"] += 1
print(tot)
if __name__ == "__main__":
main(*sys.argv[1:])