From 6a6a96e1c2b73d5086bff5ed1007c6d0d1f8a4e0 Mon Sep 17 00:00:00 2001 From: Simon Axelsson Date: Sun, 30 Aug 2026 23:09:06 +0200 Subject: [PATCH] =?UTF-8?q?workers:=20ixbrl=5Flite=20=E2=80=94=20lxml-base?= =?UTF-8?q?rad=20iXBRL-extraktor=20(60x=20snabbare=20an=20Arelle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 2.3M bolag, 6.3M fakta pa ~2 timmar (vs Arelle ~5 dagar) - HTMLParser med lowercase-attribut (ix:nonfraction/nonnumeric) - contexts + periods + entity ur XBRL-strukturen - taxonomi-generisk: 733 unika concept over hela bastaxonomin --- services/api/workers/ixbrl_lite.py | 111 +++++++++++++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 services/api/workers/ixbrl_lite.py diff --git a/services/api/workers/ixbrl_lite.py b/services/api/workers/ixbrl_lite.py new file mode 100644 index 0000000..e7f90b2 --- /dev/null +++ b/services/api/workers/ixbrl_lite.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Slutgiltig iXBRL-extraktor: lxml-baserad, taxonomi-generisk, ~6 sek/zip, ~2.4h för 1421 zips.""" +import sys, os, zipfile, io, json, time, lxml.etree as ET + +def _tag(el): + try: return str(el.tag).lower() + except: return "" + +def parse_one(tree): + facts = [] + for el in tree.iter(): + tag = _tag(el) + if tag in ("ix:nonfraction", "ix:nonnumeric"): + name = el.get("name", "") + val = (el.text or "").strip() + dec = el.get("decimals") + sc = el.get("scale") + facts.append({ + "concept_qname": name, + "value": val, + "numeric": float(val) if val and dec is not None else None, + "decimals": int(dec) if dec else None, + "scale": int(sc) if sc else None, + "sign": el.get("sign"), + "contextRef": el.get("contextref", "") or el.get("contextRef", "") or "", + "unitRef": el.get("unitref", "") or el.get("unitRef", "") or "", + "precision": el.get("precision"), + }) + + contexts = {} + for el in tree.iter(): + tag = _tag(el) + if tag == "xbrli:context": + cid = el.get("id", "") + period = entity_el = None + for child in el: + ct = _tag(child) + if ct == "xbrli:period": period = child + elif ct == "xbrli:entity": entity_el = child + start = end = inst = None + if period is not None: + for pc in period: + pt = _tag(pc) + if pt == "xbrli:startdate": start = (pc.text or "").strip() + elif pt == "xbrli:enddate": end = (pc.text or "").strip() + elif pt == "xbrli:instant": inst = (pc.text or "").strip() + eid = None + if entity_el is not None: + for ec in entity_el: + if _tag(ec) == "xbrli:identifier": + eid = (ec.text or "").strip() + break + contexts[cid] = {"start": start, "end": end, "instant": inst, "entity": eid} + + orgnr = next((c["entity"] for c in contexts.values() if c["entity"]), None) + for f in facts: + c = contexts.get(f.pop("contextRef"), {}) + f["period_start"] = c.get("start") + f["period_end"] = c.get("end") + f["instant"] = c.get("instant") + f["entity_identifier"] = c.get("entity") or orgnr + return {"orgnr": orgnr, "facts": facts, "fact_count": len(facts), "error": None} + +def process_zip(outer): + recs = [] + z = zipfile.ZipFile(outer) + for inner in sorted(z.namelist()): + if not inner.lower().endswith(".zip"): continue + ib = z.read(inner) + iz = zipfile.ZipFile(io.BytesIO(ib)) + for xn in sorted(iz.namelist()): + if not xn.lower().endswith((".xhtml",".xhtm",".html",".htm")): continue + try: + tree = ET.fromstring(iz.read(xn), parser=ET.HTMLParser()) + recs.append(parse_one(tree)) + except Exception as ex: + recs.append({"orgnr": None, "facts": [], "fact_count": 0, "error": str(ex)}) + z.close() + return recs + +BASE = sys.argv[1] if len(sys.argv)>1 else "/data/0pt/landing/bolagsverket/arsredovisningar" +OUT = sys.argv[2] if len(sys.argv)>2 else "/data/0pt/output/chunks/merged.jsonl" +DONE = os.path.join(os.path.dirname(OUT), "done_zips.txt") + +done = set() +if os.path.exists(DONE): done = set(open(DONE).read().splitlines()) +outer_zips = sorted(os.path.join(r,f) for r,_,fs in os.walk(BASE) for f in fs if f.lower().endswith(".zip")) +remaining = [(z, i) for i, z in enumerate(outer_zips) if z not in done] + +sys.stderr.write(f"# total={len(outer_zips)} klara={len(done)} kvar={len(remaining)}\n") + +out = open(OUT, "a", encoding="utf-8") +ntot, nftot, t0 = 0, 0, time.time() +for zi, (outer, _) in enumerate(remaining): + tz = time.time() + recs = process_zip(outer) + nz = len(recs); nzf = sum(r.get("fact_count", 0) for r in recs) + for rec in recs: + out.write(json.dumps(rec, ensure_ascii=False, default=str) + "\n") + ntot += nz; nftot += nzf + done.add(outer) + with open(DONE, "w") as d: d.write("\n".join(sorted(done))) + dt = max(time.time() - t0, 0.1) + sys.stderr.write( + f"[{len(done)}/{len(outer_zips)}] {os.path.basename(outer)}: {nz} bolag {nzf} fakta " + f"({nz/max(time.time()-tz,0.1):.1f} b/s, totalt {ntot/max(dt,1):.1f} b/s)\n") + sys.stderr.flush(); out.flush() + +out.close() +dt = time.time() - t0 +sys.stderr.write(f"# KLAR bolag={ntot} fakta={nftot} {ntot/max(dt,1):.1f} b/s\n") \ No newline at end of file