Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016679RwmHsuTFfxt26wP6rk
156 lines
7.8 KiB
Python
156 lines
7.8 KiB
Python
"""TAP portāla (tapportals.mk.gov.lv) lapu nolasīšana: Ministru kabineta sēdes darba kārtība un dokumentu teksts.
|
|
|
|
TAP nepublicē sēžu darba kārtību un lēmumu tekstu mašīnlasāmā formā. Darba kārtība ir HTML lapā /meetings/cabinet_ministers/<id>,
|
|
dokumenta teksts — HTML lapā /structuralizer/data/nodes/<id>/preview, kur katrs numurētais punkts ir atsevišķs elements.
|
|
Šis modulis no šīm lapām nolasa tikai to, kas tajās ir: neko nepapildina un neizdomā.
|
|
"""
|
|
import re
|
|
import urllib.request
|
|
|
|
from lxml import html as H
|
|
|
|
BASE = "https://tapportals.mk.gov.lv"
|
|
UA = {"User-Agent": "Mozilla/5.0 (compatible; valsts-pirmkods/0.1; +https://processgit.org/Valsts-Pirmkods/MK-Lemumi-as-Code)"}
|
|
# darba kārtības dokumenti, kas ir lēmums (ne normatīvā akta teksts vai ziņojums)
|
|
DECISION_DOCS = ("MK sēdes protokollēmuma projekts", "MK sēdes protokollēmums", "Rīkojuma projekts", "Rīkojuma (grozījumu) projekts",
|
|
"Rīkojums")
|
|
FLAGS = {"iekļauts papildus": "addedLate", "jautājums tiks skatīts A sadaļā": "movedToA",
|
|
"jautājuma izskatīšana atlikta": "postponed"}
|
|
|
|
|
|
def get(url, accept=None):
|
|
h = dict(UA)
|
|
if accept:
|
|
h["Accept"] = accept
|
|
return urllib.request.urlopen(urllib.request.Request(url, headers=h), timeout=60).read()
|
|
|
|
|
|
def norm(s):
|
|
return re.sub(r"\s+", " ", (s or "").replace("\xa0", " ")).strip()
|
|
|
|
|
|
def text(e):
|
|
return norm(e.text_content()) if e is not None else ""
|
|
|
|
|
|
# ------------------------------------------------------------------ sēdes darba kārtība
|
|
def parse_meeting(raw):
|
|
"""-> {'title', 'sections': [{'n', 'title', 'items': [...]}]}; item: n, tap, restricted, flags, title, presenter,
|
|
invited, status, legal_act (TAP uuid), documents [(label, href)], approvals [label], participation."""
|
|
doc = H.fromstring(raw)
|
|
title = text((doc.xpath("//h1") or [None])[0])
|
|
sections, cur = [], None
|
|
rows = doc.xpath('//div[contains(@class,"meeting__section-row") or '
|
|
'contains(concat(" ", normalize-space(@class), " "), " meeting-question ")]')
|
|
for r in rows:
|
|
if "meeting__section-row" in r.get("class"):
|
|
m = re.match(r"(\d+)\.\s*(.*)", text(r))
|
|
cur = {"n": m.group(1) if m else "", "title": m.group(2) if m else text(r), "items": []}
|
|
sections.append(cur)
|
|
continue
|
|
c = {x.get("data-column-header-name"): x for x in r.xpath("./div[@data-column-header-name]")}
|
|
if "Nr.p.k." not in c:
|
|
continue
|
|
pid = c.get("Projekta ID")
|
|
a = pid.xpath(".//a") if pid is not None else []
|
|
q = c["Jautājums"]
|
|
for s in q.xpath('.//div[contains(@class,"document-summary")]'):
|
|
s.getparent().remove(s)
|
|
qtext, flags = text(q), []
|
|
while True: # "(iekļauts papildus) (jautājums tiks skatīts A sadaļā) Informatīvais ziņojums …"
|
|
m = re.match(r"\(([^)]*)\)\s*", qtext)
|
|
if not m or m.group(1) not in FLAGS:
|
|
break
|
|
flags.append(FLAGS[m.group(1)])
|
|
qtext = qtext[m.end():]
|
|
tgt = r.xpath(".//span[@data-collapse-target]")
|
|
docs, approvals, participation = [], [], ""
|
|
if tgt:
|
|
rec = doc.xpath(f'//div[@data-collapse-recipient="{tgt[0].get("data-collapse-target")}"]')
|
|
if rec:
|
|
for sec in rec[0].xpath('.//div[contains(@class,"meeting-question__additional-information-section")]'):
|
|
head = text((sec.xpath('.//div[contains(@class,"additional-information-title")]') or [None])[0])
|
|
body = sec.xpath("./div[@data-collapse-recipient]")
|
|
if head == "Dokumenti":
|
|
docs = [(text(x), x.get("href")) for x in sec.xpath(".//a[@href]")]
|
|
elif head == "Saskaņojumi":
|
|
approvals = [text(x) for x in sec.xpath(".//a")]
|
|
elif head == "Sabiedrības līdzdalība" and body:
|
|
participation = text(body[0])
|
|
pid_text = text(pid)
|
|
cur["items"].append({
|
|
"n": text(c["Nr.p.k."]), "tap": pid_text.replace("(IP)", "").strip(), "restricted": "(IP)" in pid_text,
|
|
"legal_act": (re.search(r"/legal_acts/([0-9a-f-]{36})", a[0].get("href")) or [None, None])[1] if a else None,
|
|
"flags": flags, "title": qtext, "presenter": text(c.get("Ziņo")), "invited": text(c.get("Uzaicināti")),
|
|
"status": text(c.get("Statuss")), "documents": docs, "approvals": approvals, "participation": participation})
|
|
return {"title": title, "sections": sections}
|
|
|
|
|
|
# ------------------------------------------------------------------ dokumenta teksts (structuralizer)
|
|
def parse_node(raw):
|
|
"""-> {'heading': [rindas virs virsraksta], 'header': TAP ID, 'title', 'points': [...], 'annexes': [...], 'signatures': [...]}.
|
|
Punkts: {'n', 'text', 'quotes': [jaunā redakcija pēdiņās, ja punktā ir vairāki teksta lauki], 'children': [...]}.
|
|
Nenumurēts teksta lauks ir punkts ar n=""."""
|
|
doc = H.fromstring(raw)
|
|
tree = doc.xpath('//div[contains(@class,"structuralizer-tree--preview")]')
|
|
if not tree:
|
|
raise ValueError("nav dokumenta teksta (structuralizer-tree)")
|
|
tree = tree[0]
|
|
for x in tree.xpath('.//div[contains(@class,"node-preview__actions")]'):
|
|
x.getparent().remove(x)
|
|
hdr = tree.xpath('.//div[contains(@class,"structuralizer-tree__header")]')
|
|
header_id = norm(hdr[0].text) if hdr else ""
|
|
title = text((tree.xpath('.//div[contains(@class,"structuralizer-tree__header")]//div[contains(@class,"structuralizer-field--preview")]') or [None])[0])
|
|
heading = []
|
|
if hdr:
|
|
for w in hdr[0].itersiblings(preceding=True):
|
|
heading[:0] = [text(g) for g in w.xpath('./div/div | .//div[contains(@class,"structuralizer-tree__content-wrapper")]/div/div')
|
|
if text(g)]
|
|
signatures = [text(x) for x in tree.xpath('.//div[contains(@class,"signature-list")]/div') if text(x)]
|
|
|
|
def is_wrapper(e):
|
|
return e.tag == "div" and e.get("data-numbered-field-wrapper") == "true"
|
|
|
|
def fields(w):
|
|
return [text(f) for f in w.xpath('./div[contains(@class,"numbered-field")]/div[@data-numbered-field-preview-field="true"]')]
|
|
|
|
def own_number(w):
|
|
n = w.xpath('./div[contains(@class,"numbered-field")]/span[contains(@class,"numbered-field__number")]')
|
|
return text(n[0]) if n else ""
|
|
|
|
def skip(ch):
|
|
c = ch.get("class") or ""
|
|
return ("structuralizer__annex-wrapper" in c or "signature-list" in c or "speaker-field-group" in c
|
|
or ch.get("data-numbered-field-preview-field") == "true" or "numbered-field__number" in c)
|
|
|
|
def children(e):
|
|
out = []
|
|
for ch in e.iterchildren():
|
|
if not isinstance(ch.tag, str) or skip(ch):
|
|
continue
|
|
if is_wrapper(ch):
|
|
f = fields(ch)
|
|
out.append({"n": own_number(ch), "text": f[0] if f else "", "quotes": f[1:], "children": children(ch)})
|
|
elif "structuralizer-field--preview" in (ch.get("class") or ""):
|
|
t = text(ch)
|
|
if t:
|
|
out.append({"n": "", "text": t, "quotes": [], "children": []})
|
|
else:
|
|
out.extend(children(ch))
|
|
return out
|
|
|
|
points = []
|
|
if hdr:
|
|
for w in hdr[0].itersiblings():
|
|
points.extend(children(w))
|
|
annexes = [{"n": text((a.xpath('.//*[contains(@class,"annex-number")]') or [None])[0]), "text": text(a)}
|
|
for a in tree.xpath('.//div[contains(@class,"structuralizer__annex-wrapper")]')]
|
|
return {"heading": heading, "header": header_id, "title": title, "points": [p for p in points if p["text"] or p["children"]],
|
|
"annexes": annexes, "signatures": signatures}
|
|
|
|
|
|
def flatten(points, depth=0):
|
|
for p in points:
|
|
yield depth, p
|
|
yield from flatten(p["children"], depth + 1)
|