타 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>
478 lines
18 KiB
Python
478 lines
18 KiB
Python
# -*- 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
|
||
)
|