fix(pipeline): 법령 md 부칙 절단·목 미출력 결함 수정 + 보정 도구 신설
- collect_law.py: 부칙단위[:20] 상한 제거(법제처 XML은 오래된 순 → 최신 부칙 절단됨), 목이 항의 직속 자식으로 오는 구조 처리(문서 순서 순회), _meta에 수집일 필드 추가 - split_cost_docs.py: 노임 표 신뢰도 플래그(*/**) 캡처 → 별도 열로 복원 - patch_law_md.py 신설: 보관 XML 기준 누락분만 보충(전량 재생성은 후처리 파괴하므로 금지). README에 사유·절차 명문화
This commit is contained in:
@@ -16,6 +16,7 @@
|
||||
|---|---|---|
|
||||
| `check_law.py` | law.go.kr 현행본 시행일·개정일 조회 | W1 갱신 판별 |
|
||||
| `collect_law.py` | 법령·행정규칙 원문(현행+교본시점) + 별표 PDF 수집 | W2 |
|
||||
| `patch_law_md.py` | 보관 XML 기준으로 기존 법령 md의 **누락분만 보충**(부칙·목). 네트워크 미사용 | W2 보정 |
|
||||
| `get_attach.py` | 고시 첨부파일·안내문 별표의 HWP 원본 수집 | W3 |
|
||||
| `get_kcsc.py` | 국가건설기준센터 KCS 본문(OpenApi) 수집 | W2 |
|
||||
| `get_ks.py` | e나라 표준인증 KS 메타데이터·이력 수집(원문은 DRM) | W2 |
|
||||
@@ -56,3 +57,20 @@ python gen_md.py # 색인 재생성
|
||||
```
|
||||
|
||||
수집은 새로 받은 현행본을 `현행_새시행일.*` 로 **추가**한다(기존 판본 삭제 금지). 상세 규칙은 `docs/raw/law/README.md` W1~W6 참조.
|
||||
|
||||
## 변환 규칙이 바뀐 경우 (2026-08-14 감사 반영)
|
||||
|
||||
기존 법령 md 에는 수집 이후 **후처리**(ASCII 박스표 → 마크다운 표, 수식, 이미지)가
|
||||
적용돼 있다. `law_to_md()` 로 전체를 다시 렌더링하면 그 결과가 사라지므로
|
||||
**전량 재생성 금지**. 대신 누락분만 채우는 `patch_law_md.py` 를 쓴다.
|
||||
|
||||
```bash
|
||||
python patch_law_md.py --dry # 보충량 확인
|
||||
python patch_law_md.py # 저장
|
||||
```
|
||||
|
||||
과거 결함 2건(이 도구로 보정 완료 — 부칙 2,264건·목 2,114줄):
|
||||
- `부칙단위[:20]` 상한 → 법제처 XML 은 부칙이 **오래된 순**이라 최신 부칙(연도별 요율
|
||||
특례 등)이 통째로 절단됐다. 상한 제거함.
|
||||
- `목`(가·나·다…)이 `호`가 아니라 `항`의 **직속 자식**으로 오는 구조를 처리 못 해 누락.
|
||||
항의 자식을 문서 순서대로 순회하도록 수정함.
|
||||
|
||||
@@ -189,12 +189,19 @@ def law_to_md(root, 분류, 출처url, 실명=""):
|
||||
hv = clean(T(h, "항내용"))
|
||||
if hv:
|
||||
out.append(hv)
|
||||
for ho in h.findall("호"):
|
||||
ov = clean(T(ho, "호내용"))
|
||||
if ov:
|
||||
out.append(f" {ov}")
|
||||
for mo in ho.findall("목"):
|
||||
mv = clean(" ".join(x for x in (mo.itertext()) if x))
|
||||
# 목은 호의 자식이 아니라 항의 직속 자식으로 오는 경우가 있다
|
||||
# (법제처 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("")
|
||||
@@ -203,6 +210,10 @@ def law_to_md(root, 분류, 출처url, 실명=""):
|
||||
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("")
|
||||
|
||||
@@ -210,8 +221,10 @@ def law_to_md(root, 분류, 출처url, 실명=""):
|
||||
if 부칙 is not None:
|
||||
units = 부칙.findall("부칙단위")
|
||||
if units:
|
||||
# 법제처 XML은 부칙을 오래된 순으로 담는다 — 상한을 두면 최신 부칙
|
||||
# (연도별 요율 특례 등)이 잘려나가므로 전건 출력한다.
|
||||
out += ["", "## 부칙", ""]
|
||||
for b in units[:20]:
|
||||
for b in units:
|
||||
body = clean(T(b, "부칙내용"))
|
||||
# 인용블록: 각 줄 앞에 '> ', 원문 줄바꿈 보존(빈 줄은 '>')
|
||||
for ln in body.split("\n"):
|
||||
@@ -347,6 +360,8 @@ def collect(item):
|
||||
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 ""),
|
||||
"",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""보관된 법령 XML 기준으로 기존 md 의 누락분만 보충한다 (네트워크 미사용).
|
||||
|
||||
기존 md 에는 수동·스크립트 후처리(박스표 → 마크다운 표, 수식, 이미지)가
|
||||
적용돼 있어 전체 재렌더링은 그 결과를 파괴한다. 따라서 **추가만** 한다.
|
||||
|
||||
보충 대상 (2026-08-14 감사에서 확인된 변환 누락 2종):
|
||||
1. 부칙 절단 — 과거 collect_law.py 가 `부칙단위[:20]` 만 출력해 최신 부칙이
|
||||
통째로 빠졌다. md 에 없는 부칙단위를 뒤에 이어붙인다.
|
||||
2. 목(가·나·다…) 미출력 — 목이 호가 아니라 항의 직속 자식으로 오는 구조를
|
||||
처리하지 못해 누락됐다. 부모 호 줄 뒤에 들여쓰기 4칸으로 삽입한다.
|
||||
|
||||
사용:
|
||||
python patch_law_md.py --dry # 변경량만 출력
|
||||
python patch_law_md.py # 저장
|
||||
"""
|
||||
import re
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from collect_law import ROOT, T, clean
|
||||
|
||||
분류목록 = ("법률", "행정규칙")
|
||||
STEM = re.compile(r"^(현행|교본시점)_\d{8}$")
|
||||
부칙헤더 = re.compile(r"부칙\s*<[^>]*>")
|
||||
|
||||
|
||||
def 부칙키(text):
|
||||
"""'부칙 <제20903호,2025.4.2>' → 정규화 키."""
|
||||
m = 부칙헤더.search(text or "")
|
||||
return re.sub(r"\s+", "", m.group(0)) if m else None
|
||||
|
||||
|
||||
def 목텍스트(mo):
|
||||
return clean(T(mo, "목내용")) or clean(" ".join(x for x in mo.itertext() if x))
|
||||
|
||||
|
||||
def patch(md_path, xml_path):
|
||||
"""(부칙 추가수, 목 추가수, 목 앵커실패수)"""
|
||||
root = ET.parse(xml_path).getroot()
|
||||
text = md_path.read_text(encoding="utf-8")
|
||||
lines = text.splitlines()
|
||||
부칙추가, 목추가, 앵커실패 = 0, 0, 0
|
||||
|
||||
# ── 1) 목 삽입 (뒤에서부터 삽입해 인덱스 밀림 방지) ──
|
||||
조문 = root.find("조문")
|
||||
작업 = []
|
||||
if 조문 is not None:
|
||||
for j in 조문.findall("조문단위"):
|
||||
for h in j.findall("항"):
|
||||
앵커 = None # 직전 호 내용
|
||||
for kid in h:
|
||||
if kid.tag == "호":
|
||||
앵커 = clean(T(kid, "호내용"))
|
||||
for mo in kid.findall("목"):
|
||||
작업.append((앵커, 목텍스트(mo)))
|
||||
elif kid.tag == "목":
|
||||
작업.append((앵커, 목텍스트(kid)))
|
||||
for ho in j.findall("호"):
|
||||
앵커 = clean(T(ho, "호내용"))
|
||||
for mo in ho.findall("목"):
|
||||
작업.append((앵커, 목텍스트(mo)))
|
||||
|
||||
# 앵커별로 묶어 한 번에 삽입
|
||||
묶음 = {}
|
||||
for 앵커, mv in 작업:
|
||||
if not mv or mv in text: # 이미 있으면 건너뜀
|
||||
continue
|
||||
if not 앵커:
|
||||
앵커실패 += 1
|
||||
continue
|
||||
묶음.setdefault(앵커, []).append(mv)
|
||||
|
||||
for 앵커, 목들 in 묶음.items():
|
||||
idx = next((i for i, l in enumerate(lines) if l.strip() == 앵커.strip()), None)
|
||||
if idx is None:
|
||||
idx = next((i for i, l in enumerate(lines) if 앵커.strip() and 앵커.strip() in l), None)
|
||||
if idx is None:
|
||||
앵커실패 += len(목들)
|
||||
continue
|
||||
lines[idx + 1:idx + 1] = [f" {mv}" for mv in 목들]
|
||||
목추가 += len(목들)
|
||||
|
||||
# ── 2) 부칙 보충 ──
|
||||
부칙 = root.find("부칙")
|
||||
if 부칙 is not None:
|
||||
units = 부칙.findall("부칙단위")
|
||||
본문 = "\n".join(lines)
|
||||
기존키 = {re.sub(r"\s+", "", m) for m in 부칙헤더.findall(본문)}
|
||||
누락 = [b for b in units if (부칙키(T(b, "부칙내용")) or "") not in 기존키]
|
||||
if 누락:
|
||||
if "## 부칙" not in 본문:
|
||||
lines += ["", "## 부칙", ""]
|
||||
add = []
|
||||
for b in 누락:
|
||||
body = clean(T(b, "부칙내용"))
|
||||
if not body.strip():
|
||||
continue
|
||||
for ln in body.split("\n"):
|
||||
add.append(f"> {ln}" if ln.strip() else ">")
|
||||
add.append("")
|
||||
부칙추가 += 1
|
||||
# 첨부파일 절이 뒤에 있으면 그 앞에, 없으면 끝에
|
||||
pos = next((i for i, l in enumerate(lines) if l.startswith("## 첨부파일")), len(lines))
|
||||
lines[pos:pos] = add
|
||||
|
||||
if 부칙추가 or 목추가:
|
||||
md_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
return 부칙추가, 목추가, 앵커실패
|
||||
|
||||
|
||||
def main(dry=False):
|
||||
합계 = [0, 0, 0]
|
||||
파일 = 0
|
||||
for 분류 in 분류목록:
|
||||
base = ROOT / 분류
|
||||
if not base.is_dir():
|
||||
continue
|
||||
for folder in sorted(p for p in base.iterdir() if p.is_dir()):
|
||||
for xml_path in sorted(folder.glob("*.xml")):
|
||||
if not STEM.match(xml_path.stem):
|
||||
continue
|
||||
md_path = xml_path.with_suffix(".md")
|
||||
if not md_path.exists():
|
||||
continue
|
||||
if dry:
|
||||
orig = md_path.read_text(encoding="utf-8")
|
||||
b, m, f = patch(md_path, xml_path)
|
||||
if dry:
|
||||
md_path.write_text(orig, encoding="utf-8")
|
||||
if b or m or f:
|
||||
파일 += 1
|
||||
print(f" {분류}/{folder.name}/{md_path.name}: 부칙+{b} 목+{m}"
|
||||
+ (f" (앵커실패 {f})" if f else ""))
|
||||
합계[0] += b
|
||||
합계[1] += m
|
||||
합계[2] += f
|
||||
print(f"\n파일 {파일}건 / 부칙 +{합계[0]} · 목 +{합계[1]}"
|
||||
+ (f" · 앵커실패 {합계[2]}" if 합계[2] else "")
|
||||
+ (" (dry-run — 원복함)" if dry else " 저장 완료"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main(dry="--dry" in sys.argv)
|
||||
@@ -27,7 +27,7 @@ CAK_DIR = "노임단가_건설업_대한건설협회"
|
||||
CAK_STEM = "2026상반기_건설업_임금실태조사_대한건설협회"
|
||||
PAGE_TABLE = (9, 13) # 0-based: 원문 p.10~13 = 개별직종 노임단가 표
|
||||
CAK_CH = [("1. 조사개요", 1), ("2. 임금적용요령", 5), ("3. 개별직종 노임단가", 9), ("4. 직종해설", 13)]
|
||||
CAK_COLS = "| 직종코드 | 직종명 | 2026.1.1 | 2025.9.1 | 2025.1.1 | 2024.9.1 |"
|
||||
CAK_COLS = "| 직종코드 | 직종명 | 신뢰도 | 2026.1.1 | 2025.9.1 | 2025.1.1 | 2024.9.1 |"
|
||||
|
||||
|
||||
def parse_cak_table():
|
||||
@@ -41,13 +41,18 @@ def parse_cak_table():
|
||||
code, seg = parts[i], parts[i + 1]
|
||||
if not (1 <= int(code[1:]) <= 99):
|
||||
continue
|
||||
# 원문은 직종번호 앞에 신뢰도 기호를 붙인다: `*`=조사현장 5개 미만,
|
||||
# `**`=미조사. 값 채택 시 경고로 남겨야 하므로 반드시 보존한다.
|
||||
flag_m = re.search(r"(\*{1,2})\s*$", parts[i - 1])
|
||||
flag = flag_m.group(1) if flag_m else ""
|
||||
slots = re.findall(r"\d{1,3}(?:,\d{3})+|(?<![\d,])-(?![\d,])", seg)[:4]
|
||||
first = re.search(r"\d{1,3}(?:,\d{3})+|(?<![\d,])-(?![\d,])", seg)
|
||||
name = "".join(re.findall(r"[가-힣A-Za-z/]", seg[: first.start()] if first else seg))
|
||||
if code not in result and len(slots) == 4 and name:
|
||||
result[code] = (name, slots)
|
||||
rows = [CAK_COLS, "|---|---|---|---|---|---|"]
|
||||
rows += [f"| {c} | {result[c][0]} | {' | '.join(result[c][1])} |" for c in sorted(result)]
|
||||
result[code] = (name, slots, flag)
|
||||
rows = [CAK_COLS, "|---|---|---|---|---|---|---|"]
|
||||
rows += [f"| {c} | {result[c][0]} | {result[c][2]} | {' | '.join(result[c][1])} |"
|
||||
for c in sorted(result)]
|
||||
return len(result), "\n".join(rows) + "\n"
|
||||
|
||||
|
||||
@@ -75,7 +80,9 @@ def split_cak():
|
||||
if "개별직종" in fname:
|
||||
body = ("## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n"
|
||||
f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n"
|
||||
"> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n\n" + table)
|
||||
"> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n"
|
||||
"> **신뢰도** 열 = 원문이 직종번호 앞에 붙이는 기호 — `*` 조사현장 5개 미만(적용 시 유의), "
|
||||
"`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table)
|
||||
(BASE / CAK_DIR / f"{fname}.md").write_text(
|
||||
f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8")
|
||||
print("wrote", fname)
|
||||
|
||||
Reference in New Issue
Block a user