Files
Aislo/resources/knowledge/original/_pipeline/collect_law.py
T
eomsangdonandClaude Fable 5 6df07f60a1 fix(knowledge): 검토내역 8건 반영 — 상한 제거·행정규칙 부칙 복원·회귀검사 신설
타 AI 검토내역(tmp/기술문서DB_검토내역_260814.md) 전수 검증 후 동의분 적용.

파이프라인:
- fix_fences.py units[:20] / collect_law.py 별표 limit=80·변경이력 [:40] 상한 제거
- gen_md.py 기준목록 출력을 운영본(04_참조_법령기준_목록.md) 경로로 정정
- README 구경로(docs/raw/law) 3곳 현행화, ruff format 일괄 적용
- verify_structure.py 신설: XML↔md 부칙·목·별표·CHANGELOG 행수 회귀검사

신규 발견 결함 2건 정정 (검토내역에 없던 것):
- 행정규칙 XML은 부칙내용이 부칙 직속(부칙단위 래퍼 없음) — 변환기가 미처리해
  행정규칙 md 22건에 부칙 전무. patch_law_md 확장 후 부칙 138건 복원
- patch_law_md 부칙 dedup 키가 괄호형 타법개정 헤더 미매칭 — 재실행 시
  기존 부칙 1,724건 중복 추가되는 비멱등 결함. 정규식 보강

재수집 (law.go.kr):
- 대기환경보전법 시행규칙 별표 PDF 78건 추가 확보 (80→158, 전수)
- CHANGELOG 절단 3건 재생성 (고용보험법 시행령 42·국가계약법 시행령 41·
  대기환경 45회 — API 연혁과 전건 일치 확인)

기술문서·데이터:
- _최종검증로그: 55문서 스냅샷 범위 명시 (현재 65문서, 신설 10건 미검증 주지)
- 제비율.md frontmatter에 조달청 제비율(2026.04.13)·국민연금법 출처 보강
- 원가_입력변수_사전: 장비대금 지급보증을 공사종류별 표로 정정, csv→json 표기
- labor_const JSON: 원문 노임 118건 전건 일치 확인 후 해시만 재동기화
  (원문 변경분 = 신뢰도 플래그 컬럼뿐, 단가 무변)

검증: verify_structure 결함 0건, patch_law_md 재실행 +0 (멱등),
원가 JSON 해시 56건 중 잔여 불일치 1건(coef_2026 — 사용자 협의 대상)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 07:47:36 +09:00

478 lines
18 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""국가법령정보센터 원문 수집기.
항목별로 현행본 + 교본시점(2019-12-31 기준) 연혁본을 받아
<분류>/<명칭>/ 에 XML · Markdown · 별표 PDF · _meta.md · CHANGELOG.md 로 저장.
"""
import json, re, sys, time, urllib.parse, urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
import os as _os
from pathlib import Path as _P
# 이 스크립트는 original/_pipeline/ 에 위치. 코퍼스 루트 = 상위 폴더.
ROOT_DIR = _P(__file__).resolve().parent.parent # ...\original
DATA_DIR = _P(__file__).resolve().parent / "data" # 생성 데이터(JSON)
# API 키: 커밋 금지 파일(law/.secrets.local.md) 또는 환경변수에서 읽는다.
def _load_key(name):
v = _os.environ.get(name)
if v:
return v.strip()
sec = ROOT_DIR.parent / ".secrets.local.md"
if sec.exists():
import re as _re
for pat in (r"KCSC[\s\S]*?`([A-Za-z0-9]{20,})`", r"인증키[:\s]*`?([A-Za-z0-9]{30,})`?"):
m = _re.search(pat, sec.read_text(encoding="utf-8"))
if m:
return m.group(1)
return ""
ROOT = Path(str(ROOT_DIR))
SC = Path(str(DATA_DIR))
BASEURL = "https://www.law.go.kr"
OC = _os.environ.get("LAW_OC", "umsangdon") # law.go.kr 인증 ID
교본기준일 = "20191231"
# ───────────────────────── 통신 ─────────────────────────
def fetch(url, binary=False, tries=3):
for k in range(tries):
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=40) as r:
return r.read()
except Exception as e:
if k == tries - 1:
print(f" ! 실패 {url[:90]} :: {str(e)[:60]}")
return None
time.sleep(1.5)
def search(target, query, nw=None, display=100):
url = (
f"{BASEURL}/DRF/lawSearch.do?OC={OC}&type=XML&target={target}"
f"&display={display}&query={urllib.parse.quote(query)}"
)
if nw:
url += f"&nw={nw}"
b = fetch(url)
try:
return ET.fromstring(b)
except Exception:
return None
def service(target, key, kind="MST"):
url = f"{BASEURL}/DRF/lawService.do?OC={OC}&type=XML&target={target}&{kind}={key}"
b = fetch(url)
try:
return ET.fromstring(b), b
except Exception:
return None, None
def T(node, *names):
for n in names:
el = node.find(n)
if el is not None and el.text:
return el.text.strip()
return ""
def ymd(s):
s = re.sub(r"\D", "", s or "")
return f"{s[:4]}.{s[4:6]}.{s[6:8]}" if len(s) == 8 else ""
def safe(s):
return re.sub(r'[\\/:*?"<>|]', "_", s).strip().rstrip(".")
# ─────────────────── XML → Markdown 변환 ───────────────────
JOMUN = re.compile(r"^(제\d+조(?:의\d+)?)\s*(?:\(([^)]*)\))?\s*(.*)$", re.S)
def split_jo(text):
"""조문 텍스트를 (헤딩, 본문)으로 분리."""
m = JOMUN.match(text.strip())
if not m:
return "", text
num, title, rest = m.group(1), m.group(2), m.group(3)
head = f"{num}({title})" if title else num
return head, rest.strip()
def clean(t):
t = (t or "").replace("\u00a0", " ")
t = re.sub(r"[ \t]+", " ", t)
return t.strip()
def fence_ascii_tables(text):
"""소스의 ASCII 아트표(+---+ / |...|)를 코드펜스로 감싸 원형 보존.
셀이 여러 줄로 감겨 md 표 변환이 불안정한 대비표 등에 적용.
"""
lines = text.split(chr(10))
out = []
i = 0
infence = False
def is_tbl(l):
s = l.strip()
return bool(re.match(r"^\+[-=+]{3,}", s)) or (
s.startswith("|") and ("+" in s or s.count("|") >= 2)
)
while i < len(lines):
if lines[i].lstrip().startswith("```"):
infence = not infence
out.append(lines[i])
i += 1
continue
if not infence and is_tbl(lines[i]):
j = i
block = []
while j < len(lines):
if is_tbl(lines[j]) or lines[j].strip() == "":
block.append(lines[j])
j += 1
else:
break
# 뒤쪽 빈 줄 되돌림
while block and block[-1].strip() == "":
block.pop()
j -= 1
if any(re.match(r"^\s*\+[-=+]{3,}", b) for b in block):
out += ["", "```text"] + [b.rstrip() for b in block] + ["```", ""]
else:
out += block
i = j
else:
out.append(lines[i])
i += 1
return chr(10).join(out)
def law_to_md(root, 분류, 출처url, 실명=""):
info = root.find("기본정보") or root.find("행정규칙기본정보")
name = T(info, "법령명_한글", "행정규칙명") or 실명
out = [f"# {name}", ""]
meta = [
("시행일", ymd(T(info, "시행일자"))),
("공포일", ymd(T(info, "공포일자", "발령일자"))),
("공포번호", T(info, "공포번호", "발령번호")),
("종류", T(info, "법종구분", "행정규칙종류")),
("제개정", T(info, "제개정구분명")),
("소관", T(info, "소관부처", "소관부처명")),
]
out.append("> " + " | ".join(f"{k} {v}" for k, v in meta if v))
out.append(f"> 출처: {출처url}")
out.append("")
flat = root.findall("조문내용")
if flat:
for e in flat:
body = (e.text or "").replace(" ", " ").rstrip()
if not body.strip():
continue
lines = [l.rstrip() for l in body.split(chr(10))]
first = lines[0].strip()
if re.match(r"^제\d+[장절편관]", first):
out += ["", f"## {first}", ""]
rest = lines[1:]
elif re.match(r"^제\d+조", first):
head, body0 = split_jo(first)
out += [f"### {head}", ""]
rest = ([body0] if body0 else []) + lines[1:]
else:
rest = lines
for l in rest:
if l.strip():
out.append(l)
out.append("")
조문 = root.find("조문")
if 조문 is not None:
for j in 조문.findall("조문단위"):
내용 = clean(T(j, "조문내용"))
if T(j, "조문여부") == "전문":
if 내용:
out += ["", f"## {내용}", ""]
continue
if 내용:
head, body0 = split_jo(내용)
if head:
out += [f"### {head}", ""]
if body0:
out += [body0, ""]
else:
out += [f"### {내용}", ""]
for h in j.findall("항"):
hv = clean(T(h, "항내용"))
if hv:
out.append(hv)
# 목은 호의 자식이 아니라 항의 직속 자식으로 오는 경우가 있다
# (법제처 XML 관행). 문서 순서대로 순회해야 호-목 대응이 유지된다.
for kid in h:
if kid.tag == "호":
ov = clean(T(kid, "호내용"))
if ov:
out.append(f" {ov}")
for mo in kid.findall("목"):
mv = clean(T(mo, "목내용")) or clean(
" ".join(x for x in mo.itertext() if x)
)
if mv:
out.append(f" {mv}")
elif kid.tag == "목":
mv = clean(T(kid, "목내용")) or clean(
" ".join(x for x in kid.itertext() if x)
)
if mv:
out.append(f" {mv}")
out.append("")
# 항 없이 호만 있는 조문
for ho in j.findall("호"):
ov = clean(T(ho, "호내용"))
if ov:
out.append(f" {ov}")
for mo in ho.findall("목"):
mv = clean(T(mo, "목내용")) or clean(" ".join(x for x in mo.itertext() if x))
if mv:
out.append(f" {mv}")
if j.findall("호") and not j.findall("항"):
out.append("")
부칙 = root.find("부칙")
if 부칙 is not None:
# 법령(law)은 부칙단위 래퍼, 행정규칙(admrul)은 부칙내용이 부칙 직속 —
# 부칙단위만 처리하면 행정규칙 md 에 부칙이 통째로 빠진다 (2026-08-15 결함 정정).
units = 부칙.findall("부칙단위") or 부칙.findall("부칙내용")
if units:
# 법제처 XML은 부칙을 오래된 순으로 담는다 — 상한을 두면 최신 부칙
# (연도별 요율 특례 등)이 잘려나가므로 전건 출력한다.
out += ["", "## 부칙", ""]
for b in units:
body = (
clean(T(b, "부칙내용")) if b.tag == "부칙단위" else clean("".join(b.itertext()))
)
# 인용블록: 각 줄 앞에 '> ', 원문 줄바꿈 보존(빈 줄은 '>')
for ln in body.split("\n"):
out.append(f"> {ln}" if ln.strip() else ">")
out.append("")
att = root.find("첨부파일")
if att is not None and len(att):
names = [x.text.strip() for x in att.findall("첨부파일명") if x.text]
if names:
out += ["", "## 첨부파일", ""] + [f"- {n}" for n in names] + [""]
별표 = root.find("별표")
if 별표 is not None and len(별표):
out += ["", "## 별표·서식 목록", "", "| 구분 | 번호 | 제목 | PDF |", "|---|---|---|---|"]
for b in 별표.findall("별표단위"):
n = T(b, "별표번호").lstrip("0") or "-"
g = T(b, "별표가지번호").lstrip("0")
title = re.sub(r"\s+", " ", clean(T(b, "별표제목")).replace("\n", " ")).replace(
"|", ""
)
out.append(f"| {T(b, '별표구분')} | {n}{('의' + g) if g else ''} | {title} | `별표/` |")
out.append("")
return fence_ascii_tables("\n".join(out)) + "\n"
# ─────────────────── 별표 PDF 다운로드 ───────────────────
# limit=None: 상한 없음 — 80건 상한으로 대기환경보전법 시행규칙 별표 158건 중
# 78건이 누락된 결함 재발 방지 (2026-08-15). 수동 축소 실행 시에만 limit 지정.
def download_별표(root, folder, limit=None):
별표 = root.find("별표")
if 별표 is None or not len(별표):
return 0
d = folder / "별표"
n = 0
for b in 별표.findall("별표단위"):
title = clean(T(b, "별표제목"))
if title.startswith("삭제"):
continue
link = T(b, "별표서식PDF파일링크")
if not link:
continue
num = T(b, "별표번호").lstrip("0") or "0"
g = T(b, "별표가지번호").lstrip("0")
fn = safe(f"{T(b, '별표구분')}{num}{('의' + g) if g else ''}_{title[:48]}") + ".pdf"
d.mkdir(parents=True, exist_ok=True)
p = d / fn
if p.exists():
n += 1
continue
blob = fetch(BASEURL + link, binary=True)
if blob and blob[:4] == b"%PDF":
p.write_bytes(blob)
n += 1
time.sleep(0.25)
if limit is not None and n >= limit:
break
return n
# ─────────────────── 항목 1건 수집 ───────────────────
def collect(item):
분류폴더, target, 목록명, 조회명 = item["dir"], item["target"], item["name"], item["query"]
folder = ROOT / 분류폴더 / safe(목록명)
folder.mkdir(parents=True, exist_ok=True)
# 1) 현행본
r = search(target, 조회명)
if r is None:
return {"명칭": 목록명, "상태": "검색실패"}
nodes = r.findall("law") + r.findall("admrul")
want = item.get("exact", 조회명)
cur = next((n for n in nodes if T(n, "법령명한글", "행정규칙명") == want), None) or next(
(n for n in nodes if T(n, "법령명한글", "행정규칙명").startswith(want)), None
)
if cur is None:
return {
"명칭": 목록명,
"상태": "현행본 없음",
"후보": [T(n, "법령명한글", "행정규칙명") for n in nodes[:3]],
}
실명 = T(cur, "법령명한글", "행정규칙명")
cur_key = T(cur, "법령일련번호", "행정규칙일련번호")
kind = "MST" if target == "law" else "ID"
cur_id = cur_key if target == "law" else T(cur, "행정규칙ID")
# 2) 연혁 목록 → 교본시점 판본
hist = []
if target == "law":
h = search("eflaw", 조회명, display=100)
if h is not None:
for n in h.findall("law"):
if T(n, "법령명한글") != 실명:
continue
hist.append(
{
"일련": T(n, "법령일련번호"),
"시행": T(n, "시행일자"),
"구분": T(n, "현행연혁코드"),
}
)
else:
h = search(target, 조회명, nw=2, display=100)
if h is not None:
for n in h.findall("admrul"):
hist.append(
{
"일련": T(n, "행정규칙일련번호"),
"시행": T(n, "시행일자"),
"발령": T(n, "발령일자"),
"구분": T(n, "현행연혁구분"),
}
)
past = sorted(
[x for x in hist if x["시행"] and x["시행"] <= 교본기준일], key=lambda x: x["시행"]
)
past = past[-1] if past else None
saved = []
versions = [("현행", cur_key, T(cur, "시행일자"))]
if past and past["일련"] != cur_key:
versions.append(("교본시점", past["일련"], past["시행"]))
root_cur = None
for tag, key, eff in versions:
if target == "law":
root, blob = service("law", key, "MST")
else:
root, blob = service("admrul", key, "ID")
if root is None:
root, blob = service("admrul", key, "LID")
if root is None:
saved.append(f"{tag}:실패")
continue
stem = f"{tag}_{eff}"
(folder / f"{stem}.xml").write_bytes(blob)
url = f"{BASEURL}/DRF/lawService.do?OC={OC}&type=HTML&target={target}&{'MST' if target == 'law' else 'ID'}={key}"
(folder / f"{stem}.md").write_text(law_to_md(root, 분류폴더, url, 실명), encoding="utf-8")
saved.append(f"{tag}:{eff}")
if tag == "현행":
root_cur = root
time.sleep(0.3)
n별표 = download_별표(root_cur, folder) if root_cur is not None else 0
# 3) _meta.md / CHANGELOG.md
between = [x for x in hist if x["시행"] and 교본기준일 < x["시행"] <= T(cur, "시행일자")]
between.sort(key=lambda x: x["시행"], reverse=True)
meta = [
f"# {목록명}",
"",
f"- 정식명칭: {실명}",
f"- 목록상 명칭: {목록명}" + (" ← **명칭 상이**" if 실명 != 목록명 else ""),
f"- 식별자: `{'법령ID' if target == 'law' else '행정규칙ID'}={cur_id}` (불변)",
f"- 소관: {T(cur, '소관부처명')}",
f"- 종류: {T(cur, '법령구분명', '행정규칙종류')}",
f"- 현행 시행일: {ymd(T(cur, '시행일자'))} / 공포 {ymd(T(cur, '공포일자', '발령일자'))}{T(cur, '공포번호', '발령번호')}호",
f"- 교본시점 판본: {ymd(past['시행']) if past else '없음(교본 이후 제정)'}",
f"- 출처: {BASEURL}/DRF/lawService.do?target={target}&{'MST' if target == 'law' else 'ID'}={cur_key}",
# 수집일이 없으면 갱신 지연 여부를 파일만으로 판정할 수 없다 (2026-08-14 감사).
f"- 수집일: {time.strftime('%Y-%m-%d')}",
f"- 수집 파일: {', '.join(saved)}" + (f" / 별표 PDF {n별표}건" if n별표 else ""),
"",
]
(folder / "_meta.md").write_text("\n".join(meta), encoding="utf-8")
ch = [
f"# {목록명} — 변경이력",
"",
f"기준: 교본시점({ymd(교본기준일)}) → 현행({ymd(T(cur, '시행일자'))})",
"",
f"**교본 이후 개정 {len(between)}회**",
"",
]
if between:
ch += ["| 시행일 | 구분 |", "|---|---|"]
# 상한 없음 — [:40] 절단으로 개정 41회 이상 문서(고용보험법 시행령 42회 등)
# 이력이 잘리던 결함 재발 방지 (2026-08-15). 헤더 개정횟수와 표 행수 일치 보장.
for x in between:
ch.append(f"| {ymd(x['시행'])} | {x.get('구분', '')} |")
ch.append("")
if root_cur is not None:
for tag in ("제개정이유", "개정문"):
e = root_cur.find(tag)
if e is not None:
s = re.sub(r"\s+", " ", "".join(e.itertext())).strip()
if s:
ch += [f"## 최근 개정 — {tag}", "", f"> {s}", ""]
(folder / "CHANGELOG.md").write_text("\n".join(ch), encoding="utf-8")
return {
"명칭": 목록명,
"실명": 실명,
"상태": "OK",
"판본": saved,
"별표": n별표,
"개정횟수": len(between),
}
if __name__ == "__main__":
items = json.load(open(SC / sys.argv[1], encoding="utf-8"))
res = []
for i, it in enumerate(items, 1):
r = collect(it)
res.append(r)
print(
f"[{i}/{len(items)}] {r['명칭']} :: {r['상태']} {r.get('판본', '')} 별표{r.get('별표', 0)} 개정{r.get('개정횟수', '')}",
flush=True,
)
time.sleep(0.3)
json.dump(
res, open(SC / "collect_result.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1
)