This repository has been archived on 2026-07-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
shira-hermes/scripts/fetch_national_insurance_law.py
T
chaim 83a3f63a37 feat(kb): bulk-fetch תקנות הביטוח הלאומי from Wikisource
Extends scripts/fetch_national_insurance_law.py with a --regulations mode
that pulls all canonical (non-redirect) תקנות הביטוח הלאומי pages from the
MediaWiki API and writes <slug>.txt + <slug>.txt.meta.json pairs ready for
the existing scan-inbox cron.

Used to seed the live insurance KB with 92 regulations (~1,500 chunks,
~22MB Postgres) on dev. Wikisource's redirect graph dedups spelling
variants (קיצבה/קצבה, etc.) for free.

Bumps version to 0.2.0.

Refs Task Master #3

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:43:31 +00:00

350 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fetch חוק הביטוח הלאומי and its תקנות from Hebrew Wikisource and convert to
plain text with the headers the shira-hermes chunker expects (חלק / פרק /
סימן / סעיף).
Modes:
(default) fetch the parent law (חוק הביטוח הלאומי) → /tmp/national_insurance_law.txt
--regulations fetch all תקנות הביטוח הלאומי canonical pages (~92, redirects skipped)
→ /tmp/regulations/<slug>.txt + <slug>.txt.meta.json
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import time
import urllib.request
import urllib.parse
WIKI_PAGE = "חוק_הביטוח_הלאומי"
API = "https://he.wikisource.org/w/api.php"
REGULATIONS_PREFIX = "תקנות הביטוח הלאומי"
def fetch_wikitext(page: str) -> str:
url = f"{API}?action=parse&page={urllib.parse.quote(page)}&prop=wikitext&format=json"
req = urllib.request.Request(url, headers={"User-Agent": "shira-kb/1.0"})
with urllib.request.urlopen(req) as r:
data = json.load(r)
return data["parse"]["wikitext"]["*"]
# ---- template handlers ----------------------------------------------------
# {{ח:קטע2|סימולא|שם החלק}} → "חלק <number> — <title>"
# Wikisource numbers parts/chapters in their own way; sometimes the number is
# embedded in the title. We emit the raw text of the second param and let the
# chunker pick up the prefix from there.
def _section_header(prefix: str, args: list[str]) -> str:
args = _positional_args(args)
if not args:
return ""
# The title is usually the last positional arg; for קטע templates the
# title already contains its own prefix ("פרק א׳: ...") so we don't
# add our own. Otherwise prepend the given prefix.
title = (args[-1] if args[-1].strip() else (args[0] if args else "")).strip()
if not title:
return ""
if prefix:
return f"\n\n{prefix} {title}\n\n"
return f"\n\n{title}\n\n"
# Templates that produce a statute heading line.
# Note: {{ח:קטע2}} / {{ח:קטע3}} don't directly encode the level name —
# the actual level is embedded in the *title* argument (e.g. "פרק א׳: ...").
# So we emit the title verbatim and let the chunker's existing regexes
# pick it up (which they do, for פרק / סימן prefixes).
_HEADER_TEMPLATES = {
"ח:קטע2": "",
"ח:קטע3": "",
"ח:קטע4": "",
}
def _positional_args(args: list[str]) -> list[str]:
"""Filter out named arguments (e.g. 'אחר=[פרק א׳]') from a template arg list."""
return [a for a in args if "=" not in a.split("\n", 1)[0][:40]]
def _render_section(args: list[str]) -> str:
"""{{ח:סעיף|number|title|anchor?}} → "סעיף <number>. <title>" """
args = _positional_args(args)
if not args:
return ""
number = args[0].strip()
title = (args[1] if len(args) > 1 else "").strip()
if title:
return f"\n\nסעיף {number}. {title}\n\n"
return f"\n\nסעיף {number}.\n\n"
def _render_subsection(args: list[str]) -> str:
"""{{ח:ת|א|text}} → "(א) text" on its own line"""
args = _positional_args(args)
if not args:
return ""
letter = args[0].strip()
body = (args[1] if len(args) > 1 else "").strip()
return f"\n({letter}) {body}"
def _render_linkish(args: list[str]) -> str:
"""{{ח:פנימי|anchor|label}} / {{ח:חיצוני|url|label}} / {{ח:תיבה|...}} →
just the label text, dropping the link itself."""
if not args:
return ""
# label is typically the second arg; if only one, return that.
return args[1].strip() if len(args) > 1 else args[0].strip()
def _drop(args: list[str]) -> str:
return ""
# Map template name → handler. Unknown templates are silently stripped.
_HANDLERS = {
"ח:סעיף": _render_section,
"ח:סעיף*": _render_section,
"ח:ת": _render_subsection,
"ח:תת": _render_subsection,
"ח:תתת": _render_subsection,
"ח:תתתת": _render_subsection,
"ח:תתתתת": _render_subsection,
"ח:תתתתתת": _render_subsection,
"ח:פנימי": _render_linkish,
"ח:חיצוני": _render_linkish,
"ח:תיבה": _render_linkish,
# No body — drop silently
"ח:התחלה": _drop,
"ח:סוף": _drop,
"ח:כותרת": _drop,
"ח:מאגר": _drop,
"ח:פתיח-התחלה": _drop,
"ח:מפריד": _drop,
"ח:סוגר": _drop,
"ח:מבוא": _drop,
"ח:חתימות": _drop,
"ח:הערה": _drop, # footnotes — not needed for RAG
}
def _parse_template_args(raw: str) -> list[str]:
"""Split a template body by '|' at depth 0 (respecting nested templates)."""
args: list[str] = []
buf: list[str] = []
depth = 0
i = 0
while i < len(raw):
c = raw[i]
if c == "{" and i + 1 < len(raw) and raw[i + 1] == "{":
depth += 1
buf.append("{{")
i += 2
continue
if c == "}" and i + 1 < len(raw) and raw[i + 1] == "}":
depth -= 1
buf.append("}}")
i += 2
continue
if c == "[" and i + 1 < len(raw) and raw[i + 1] == "[":
depth += 1
buf.append("[[")
i += 2
continue
if c == "]" and i + 1 < len(raw) and raw[i + 1] == "]":
depth -= 1
buf.append("]]")
i += 2
continue
if c == "|" and depth == 0:
args.append("".join(buf))
buf = []
i += 1
continue
buf.append(c)
i += 1
args.append("".join(buf))
return args
def _expand_templates(text: str) -> str:
"""Recursively resolve {{ח:...}} templates, depth-first."""
out: list[str] = []
i = 0
while i < len(text):
if text[i : i + 2] == "{{":
# find matching }}
depth = 1
j = i + 2
while j < len(text) and depth > 0:
if text[j : j + 2] == "{{":
depth += 1
j += 2
elif text[j : j + 2] == "}}":
depth -= 1
j += 2
else:
j += 1
body = text[i + 2 : j - 2] # inside {{ ... }}
# recurse into body first
expanded_body = _expand_templates(body)
args = _parse_template_args(expanded_body)
name = args[0].strip() if args else ""
if name in _HEADER_TEMPLATES:
out.append(_section_header(_HEADER_TEMPLATES[name], args[1:]))
elif name in _HANDLERS:
out.append(_HANDLERS[name](args[1:]))
else:
# Unknown template — keep the last arg if it looks like text.
if len(args) > 1:
candidate = args[-1].strip()
if candidate and len(candidate) < 200:
out.append(candidate)
i = j
else:
out.append(text[i])
i += 1
return "".join(out)
def _cleanup(text: str) -> str:
# Drop wiki tables/images we don't care about.
text = re.sub(r"\[\[קובץ:[^\]]*\]\]", "", text)
text = re.sub(r"\[\[(?:[^\]|]*\|)?([^\]]*)\]\]", r"\1", text) # [[link|label]] → label
# Drop HTML-ish refs (footnotes, comments).
text = re.sub(r"<ref[^>]*>.*?</ref>", "", text, flags=re.DOTALL)
text = re.sub(r"<!--.*?-->", "", text, flags=re.DOTALL)
text = re.sub(r"</?[a-zA-Z][^>]*>", "", text)
# Collapse 3+ newlines to 2; strip trailing spaces.
text = re.sub(r"[ \t]+\n", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
# Drop bold/italic wiki markup.
text = text.replace("'''", "").replace("''", "")
return text.strip()
def fetch_regulation_titles() -> list[str]:
"""Query MediaWiki API for canonical (non-redirect) תקנות הביטוח הלאומי pages.
Wikisource keeps duplicate-spelling pages (e.g. קיצבה/קצבה) as redirects to
the modern canonical title. Filtering apfilterredir=nonredirects gives us
the deduped set without having to maintain a hand-curated list.
"""
titles: list[str] = []
apcontinue: str | None = None
while True:
params = {
"action": "query",
"list": "allpages",
"apprefix": REGULATIONS_PREFIX,
"apnamespace": "0",
"apfilterredir": "nonredirects",
"aplimit": "500",
"format": "json",
}
if apcontinue:
params["apcontinue"] = apcontinue
url = API + "?" + urllib.parse.urlencode(params)
req = urllib.request.Request(url, headers={"User-Agent": "shira-kb/1.0"})
with urllib.request.urlopen(req) as r:
data = json.load(r)
titles.extend(p["title"] for p in data["query"]["allpages"])
cont = data.get("continue", {}).get("apcontinue")
if not cont:
break
apcontinue = cont
return titles
_SLUG_DROP = re.compile(r"[()\[\]{}'\"־–—]") # parens, brackets, quotes, hyphens
_SLUG_WS = re.compile(r"[\s\-]+")
def _slug(title: str) -> str:
"""Convert a Hebrew title into a safe filename — Hebrew letters preserved.
Strips parens/brackets/quotes (problematic in shells and S3 console URLs),
collapses whitespace and hyphens to a single hyphen.
"""
s = _SLUG_DROP.sub(" ", title)
s = _SLUG_WS.sub("-", s.strip())
return s
def fetch_one_regulation(title: str, out_dir: str) -> dict:
"""Fetch a single regulation, expand templates, clean, write text + sidecar."""
wt = fetch_wikitext(title)
expanded = _expand_templates(wt)
clean = _cleanup(expanded)
slug = _slug(title)
txt_path = os.path.join(out_dir, f"{slug}.txt")
meta_path = txt_path + ".meta.json"
with open(txt_path, "w", encoding="utf-8") as f:
f.write(clean)
sidecar = {
"title": title,
"identifier": title,
"source_url": f"https://he.wikisource.org/wiki/{urllib.parse.quote(title.replace(' ', '_'))}",
}
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(sidecar, f, ensure_ascii=False, indent=2)
return {"title": title, "slug": slug, "raw_chars": len(wt), "clean_chars": len(clean)}
def fetch_all_regulations(out_dir: str) -> int:
"""Fetch every canonical תקנות הביטוח הלאומי page into out_dir."""
os.makedirs(out_dir, exist_ok=True)
titles = fetch_regulation_titles()
print(f"[regulations] {len(titles)} canonical pages", file=sys.stderr)
total_clean = 0
for i, title in enumerate(titles, 1):
try:
r = fetch_one_regulation(title, out_dir)
total_clean += r["clean_chars"]
print(
f"[{i:3d}/{len(titles)}] {r['slug']:.<70s} "
f"raw={r['raw_chars']:>7,} clean={r['clean_chars']:>7,}",
file=sys.stderr,
)
except Exception as e:
print(f"[{i:3d}/{len(titles)}] FAILED {title}: {e}", file=sys.stderr)
# Be polite to Wikisource — 50ms between requests = ~20 req/s, well under any limit.
time.sleep(0.05)
print(f"[done] total clean text: {total_clean:,} chars across {len(titles)} regulations",
file=sys.stderr)
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--regulations", action="store_true",
help="fetch all תקנות (default: fetch the parent law only)")
parser.add_argument("out", nargs="?", default=None,
help="output path (default: /tmp/national_insurance_law.txt for law, /tmp/regulations/ for --regulations)")
args = parser.parse_args()
if args.regulations:
out_dir = args.out or "/tmp/regulations"
return fetch_all_regulations(out_dir)
out_path = args.out or "/tmp/national_insurance_law.txt"
wt = fetch_wikitext(WIKI_PAGE)
print(f"[fetch] raw wikitext: {len(wt):,} chars", file=sys.stderr)
expanded = _expand_templates(wt)
print(f"[expand] after templates: {len(expanded):,} chars", file=sys.stderr)
clean = _cleanup(expanded)
print(f"[cleanup] final: {len(clean):,} chars", file=sys.stderr)
with open(out_path, "w", encoding="utf-8") as f:
f.write(clean)
print(f"[write] {out_path}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())