# -*- coding: utf-8 -*-
"""참조 법령/기준 목록 md — §5 '원문 md 색인' 절만 갱신 (AUTO:INDEX 마커 스플라이스).
⚠ 잔여 코드 안내 (2026-08-15): 아래에는 구 버전(문서 전체 생성)의 §1~§4 등록표·KS 표
조립 코드가 그대로 남아 실행되지만, 저장 단계에서 §5 블록만 추출하므로 산출물에는
쓰이지 않는다 (등록표는 04 문서에서 수동 관리로 전환됨). 기능 문제 없음 — 정리는 선택.
"""
import re, json
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 ""
from collections import defaultdict, OrderedDict
BASE = Path(str(ROOT_DIR / "임도기술교본"))
# 운영본 위치 = knowledge 루트 (구경로 original/0. 참조 법령·기준 목록.md — 2026-08-15 정정)
OUTMD = Path(str(ROOT_DIR.parent / "04_참조_법령기준_목록.md"))
SC = Path(str(DATA_DIR))
def norm(s):
s = s.replace("
", "").replace("ㆍ", "·")
return re.sub(r"[\s\u00b7\u2027]", "", s)
ENTRIES = [
(
"법률",
"산림청",
"산림자원의 조성 및 관리에 관한 법률",
"약칭 산림자원법",
[r"산림자원의조성및관리에관한법률(?!시행)", r"산림자원법(?!시행)"],
),
(
"법률",
"산림청",
"산림자원의 조성 및 관리에 관한 법률 시행령",
"",
[r"산림자원의조성및관리에관한법률시행령", r"산림자원법시행령"],
),
(
"법률",
"산림청",
"산림자원의 조성 및 관리에 관한 법률 시행규칙",
"별표1 타당성평가·별표2 시설기준 포함",
[r"산림자원의조성및관리에관한법률시행규칙", r"산림자원법시행규칙"],
),
(
"법률",
"산림청",
"산림기술 진흥 및 관리에 관한 법률",
"약칭 산림기술법",
[r"산림기술진흥및관리에관한법률(?!시행)", r"산림기술법(?!시행)"],
),
(
"법률",
"산림청",
"산림기술 진흥 및 관리에 관한 법률 시행령",
"",
[r"산림기술진흥및관리에관한법률시행령", r"산림기술법시행령"],
),
("법률", "산림청", "산림보호법", "", [r"산림보호법"]),
("법률", "산림청", "산지관리법", "", [r"산지관리법"]),
("법률", "환경부", "자연환경보전법", "시행령·시행규칙 포함", [r"자연환경보전법"]),
("법률", "행정안전부", "자연재해대책법", "시행령·시행규칙 포함", [r"자연재해대책법"]),
("법률", "환경부", "환경영향평가법", "시행령·시행규칙 포함", [r"환경영향평가법"]),
(
"법률",
"산업통상자원부",
"국가균형발전특별법",
"시행령·시행규칙 포함",
[r"국가균형발전특별법"],
),
(
"법률",
"기획재정부",
"보조금 관리에 관한 법률",
"시행령·시행규칙 포함",
[r"보조금관리에관한법률", r"보조금에관한법률"],
),
(
"법률",
"기획재정부",
"국가를 당사자로 하는 계약에 관한 법률",
"약칭 국가계약법",
[r"국가를당사자로하는계약에관한법률(?!시행)", r"국가계약법(?!시행|률)", r"국가계약법률"],
),
(
"법률",
"기획재정부",
"국가를 당사자로 하는 계약에 관한 법률 시행령",
"",
[r"국가를당사자로하는계약에관한법률시행령", r"국가계약법시행령"],
),
(
"법률",
"행정안전부",
"지방자치단체를 당사자로 하는 계약에 관한 법률",
"약칭 지방계약법",
[r"지방자치단체를당사자로하는계약에관한법률(?!시행)", r"지방계약법(?!시행)"],
),
(
"법률",
"행정안전부",
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
"",
[r"지방자치단체를당사자로하는계약에관한법률시행령", r"지방계약법시행령"],
),
("법률", "국토교통부", "도로법", "", [r"(? cand.count(")"):
cand += ")"
if len(cand) > len(ks_title.get(code, "")):
ks_title[code] = cand
hits = defaultdict(lambda: OrderedDict())
for p in files:
rel = str(p.relative_to(BASE)).replace("\\", "/")
lines = p.read_text(encoding="utf-8").split("\n")
for i, ln in enumerate(lines, 1):
n = norm(re.sub(r"^#{1,6}\s+", "", ln))
if not n:
continue
for cat, org, name, note, pats in ENTRIES:
if any(re.search(pt, n) for pt in pats):
hits[name].setdefault(rel, []).append(i)
NUM = re.compile(r"^([0-9][0-9\-]*)\.?\s")
def fmtloc(d):
out = []
for rel, ii in d.items():
chap, fn = rel.split("/", 1)
cno = NUM.match(chap).group(1)
fno = NUM.match(fn).group(1)
links = [f"[{cno}/{fno}:{n}](<임도기술교본/{rel}#L{n}>)" for n in ii[:5]]
if len(ii) > 5:
links.append(f"…(+{len(ii) - 5})")
out.append(" ".join(links))
return "
".join(out)
def loc(name):
d = hits.get(name)
return fmtloc(d) if d else "—"
def cnt(d):
return sum(len(v) for v in d.values())
CAT_TITLE = OrderedDict(
[
("법률", "1. 법률·시행령·시행규칙"),
("행정규칙", "2. 행정규칙 (훈령·예규·고시·지침)"),
("표준시방서", "3. 표준시방서"),
]
)
SRC = json.load(open(SC / "srcmap.json", encoding="utf-8"))
GOT = json.load(open(SC / "collected_map.json", encoding="utf-8"))
KSMAP = json.load(open(SC / "ks_map.json", encoding="utf-8"))
KCSC = json.load(open(SC / "kcsc_map.json", encoding="utf-8"))
def ks_code_of(nm):
m = re.match(r"^(KS [A-Z] \d{4})", nm)
return m.group(1) if m else None
def _entry_target(folder_rel):
"""폴더의 _index.md(개별 리스트)로 연결. 없으면 현행_*.md, 그것도 없으면 폴더."""
d = ROOT_DIR / folder_rel
if (d / "_index.md").exists():
return f"{folder_rel}/_index.md"
cur = sorted(d.glob("현행_*.md"))
if cur:
return f"{folder_rel}/{cur[-1].name}"
return f"{folder_rel}/"
def linked(name):
g = GOT.get(name)
if g:
return f"[{name}](<{_entry_target(g['path'])}>)"
if name in KCSC:
return f"[{name}](<{_entry_target('표준시방서/' + KCSC[name][0])}>)"
if name in HWP_ONLY:
return f"[{name}](<{_entry_target('표준시방서/' + HWP_ONLY[name])}>)"
return name
HWP_ONLY = {"임도시설공사 표준시방서": "임도시설공사 표준시방서"}
def status(name):
if GOT.get(name) or name in KCSC or name in HWP_ONLY:
return "■"
return "□"
def org_cell(name, org):
# 실제로 내려받아 확인된 출처만 표기. 미수집 건은 출처를 적지 않는다.
if GOT.get(name):
src = "국가법령정보센터" + ("·국가건설기준센터" if name in KCSC else "")
return f"{org}
{src}"
if name in KCSC:
return f"{org}
국가건설기준센터"
return org
def got_note(name):
g = GOT.get(name)
if not g:
if name in KCSC:
return f"KCS본문 {KCSC[name][1]}건 (국가건설기준센터)"
if name in HWP_ONLY:
return "원본 HWP(2016.12) 확보 · 본문은 교본 15.4와 동일"
return ""
if g.get("별표참조"):
return f"시행규칙 {g['별표참조']} PDF로 확보"
v = [x.split(":")[0] for x in g.get("판본", [])]
bits = []
if "교본시점" in v:
bits.append("현행+교본시점")
else:
bits.append("현행만")
if g.get("별표"):
bits.append(f"별표{g['별표']}")
if g.get("개정"):
bits.append(f"교본후 개정{g['개정']}회")
if name in KCSC:
bits.append(f"KCS본문 {KCSC[name][1]}건")
return " · ".join(bits)
DL = {
"법률": "**다운로드**: [국가법령정보센터](https://www.law.go.kr) — 36건 전건 수집 완료.",
"행정규칙": "**다운로드**: [국가법령정보센터](https://www.law.go.kr) — 21건 중 15건 수집 완료. 나머지 6건은 법령정보센터 검색 결과 없음(다른 확보처 미확인).",
"표준시방서": "**다운로드**: 고시문 [국가법령정보센터](https://www.law.go.kr) + KCS 본문 [국가건설기준센터](https://kcsc.re.kr) OpenApi. KCS 4종(콘크리트·도로·공통·비탈면) 본문 83개 코드 수집. 임도시설공사(산림청) 미수집.",
}
def dates(name):
d = SRC.get(name)
if not d:
return "—", "—"
return (d.get("시행") or "—"), (d.get("개정") or "—")
def note_of(name, base):
d = SRC.get(name) or {}
parts = [x for x in (base, d.get("추가", "")) if x]
return " / ".join(parts)
import unicodedata
LINKRE = re.compile(r"\[([^\]]*)\]\(<[^>]*>\)")
def _w(t):
return sum(2 if unicodedata.east_asian_width(c) in "WF" else 1 for c in t)
def dw(t):
t = LINKRE.sub(lambda m: m.group(1), t)
return max(_w(x) for x in t.split("
")) if t else 0
def pad(t, w, align="l"):
n = max(0, w - dw(t))
if align == "r":
return " " * n + t
if align == "c":
return " " * (n // 2) + t + " " * (n - n // 2)
return t + " " * n
COLS = [
("No.", 3, "r"),
("명칭", 56, "l"),
("시행일", 10, "c"),
("개정일", 10, "c"),
("소관", 20, "l"),
("비고", 56, "l"),
("인용", 4, "r"),
("확보", 4, "c"),
("교본 내 위치", 0, "l"),
]
def row(vals):
out = []
for (h, w, a), v in zip(COLS, vals):
v = str(v)
out.append(pad(v, w, a) if w else v)
return "| " + " | ".join(out) + " |"
HDR = row([c[0] for c in COLS])
SEP = (
"|"
+ "|".join(
(":" + "-" * max(1, w) + ":")
if a == "c"
else ("-" * max(1, w + 1) + ":")
if a == "r"
else ("-" * max(1, w + 2))
if w
else "---"
for h, w, a in COLS
)
+ "|"
)
L = []
L.append("# 0. 참조 법령·기준 목록")
L.append("")
L.append(f"> 출처: `임도기술교본/` 전 문서({len(files)}개 md, `원문/` 제외) 전수 스캔")
L.append(
"> 위치 표기: `폴더no/파일no:줄번호` 링크 — 클릭 시 교본 해당 줄로 이동 (파일당 5개까지, 초과분은 `…(+n)`)"
)
L.append("> `인용` 열은 교본 내 언급 횟수(줄 단위 집계)")
L.append("> `소관` 열 둘째 줄은 실제로 내려받은 출처 (미수집 건은 출처 미표기 — 확인되지 않음)")
L.append(
"> `확보` ■ = 원문 수집 완료 (명칭 클릭 시 폴더로 이동) / ◐ = 메타데이터만(원문 DRM) / □ = 미수집"
)
L.append(
"> `시행일`/`개정일`은 **현행본** 기준 (국가법령정보센터 조회, 2026-08 확인). 교본이 인용한 2019년 판본과 다르면 `비고`에 표기"
)
L.append("> `확보` 대상은 현행본. 교본 대조가 필요하면 연혁본을 함께 받을 것")
L.append(
"> 4개 표 모두 동일한 9열 구조 · 동일 열 폭 (`No. / 명칭 / 시행일 / 개정일 / 소관 / 비고 / 인용 / 확보 / 교본 내 위치`)"
)
L.append("")
total = 0
for cat, title in CAT_TITLE.items():
rows = [e for e in ENTRIES if e[0] == cat and e[2] in hits]
L.append(f"## {title}")
L.append("")
L.append(DL[cat])
L.append("")
L.append(HDR)
L.append(SEP)
for k, (c, org, name, note, pats) in enumerate(rows, 1):
total += 1
ef, am = dates(name)
nt = " / ".join(x for x in (note_of(name, note), got_note(name)) if x)
L.append(
row(
[
k,
linked(name),
ef,
am,
org_cell(name, org),
nt,
cnt(hits[name]),
status(name),
loc(name),
]
)
)
L.append("")
n_ks_active = sum(1 for c in ks_hits if KSMAP.get(c, {}).get("상태") != "폐지")
n_ks_repeal = sum(1 for c in ks_hits if KSMAP.get(c, {}).get("상태") == "폐지")
L.append("## 4. 한국산업표준 (KS)")
L.append("")
L.append(
f"**메타데이터**: [e나라 표준인증](https://www.standard.go.kr) — 45건 전건 조회 완료(표준명·제·개정일·적용범위·개정이력). 명칭 클릭 시 `KS/` 폴더로 이동."
)
L.append("**원문**: MarkAny DRM 뷰어 열람 전용이라 파일 다운로드 경로 없음. **전건 원문 미수집.**")
L.append(
f"**상태**: 현행 {n_ks_active} · 폐지 {n_ks_repeal} (교본 인용 규격 중 폐지분은 교본 갱신 시 반영 필요)."
)
L.append("")
L.append(
"> `확보` ◐ = 메타데이터만 수집(원문 DRM). `시행일`=제정일 / `개정일`=최종개정확인일. 규격명은 e나라 공식 표준명."
)
L.append("")
L.append(HDR)
L.append(SEP)
for k, code in enumerate(sorted(ks_hits, key=lambda c: (c.split()[1], c.split()[2])), 1):
total += 1
km = KSMAP.get(code, {})
t = km.get("표준명") or ks_title.get(code, "")
nm = f"{code} {t}".strip()
linked_nm = f"[{nm}]()" if km else nm
ef = re.sub(r"-", ".", km.get("제정일", "")) or "—"
am = re.sub(r"-", ".", km.get("최종개정확인일", "")) or "—"
st = km.get("상태", "")
note = "**폐지**" if st == "폐지" else ("" if t else "교본은 규격번호만 인용")
mark = "◐" if km else "□"
L.append(
row(
[
k,
linked_nm,
ef,
am,
"산업통상자원부",
note,
cnt(ks_hits[code]),
mark,
fmtloc(ks_hits[code]),
]
)
)
L.append("")
miss = [e[2] for e in ENTRIES if e[2] not in hits]
if miss:
L.append("## 미확인 (스캔 무매칭)")
L.append("")
for m in miss:
L.append(f"- {m}")
L.append("")
# ── 5. 원문 md 색인 (법령 본문 + 별표 전체) ──
# ORIG = 원문 코퍼스 루트(…/original). ⚠ OUTMD.parent 사용 금지 — OUTMD가 knowledge
# 루트로 이동(2026-08-15)한 뒤 OUTMD.parent는 원문 폴더가 아니다 (빈 색인 생성 버그 원인).
ORIG = ROOT_DIR
CATDIR = [("법률", "법률"), ("행정규칙", "행정규칙"), ("표준시방서", "표준시방서")]
L.append("## 5. 원문 md 색인")
L.append("")
L.append("> 수집한 원문 전체의 마크다운 변환본. 법령 본문(현행·교본시점) + 별표/서식 전건.")
L.append("> 별표 md는 같은 폴더의 PDF를 변환한 것으로, 표는 마크다운 표로 복원됨.")
L.append("")
n_law = n_byl = n_att = n_kcs = 0
n_zip = [0]
for cat, d in CATDIR:
base = ORIG / d
if not base.is_dir():
continue
L.append(f"### {cat}")
L.append("")
for folder in sorted([x for x in base.iterdir() if x.is_dir()], key=lambda x: x.name):
rel = f"original/{d}/{folder.name}" # 링크 기준 = knowledge 루트(OUTMD 위치)
docs = []
for f in sorted(folder.glob("*.md")):
label = {"_meta": "메타", "CHANGELOG": "변경이력"}.get(f.stem, f.stem.replace("_", " "))
docs.append(f"[{label}](<{rel}/{f.name}>)")
if f.stem not in ("_meta", "CHANGELOG"):
n_law += 1
idx_link = f"[📑 개별 목록](<{rel}/_index.md>) " if (folder / "_index.md").exists() else ""
L.append(
f"- **{folder.name}** — {idx_link}"
+ (" · ".join(docs) if docs else "_(원문 폴더 없음, 하위 자료만)_")
)
byl = sorted((folder / "별표").glob("*.md")) if (folder / "별표").is_dir() else []
if byl:
n_byl += len(byl)
links = []
for f in byl:
nm = f.stem
num, _, rest = nm.partition("_")
links.append(f"[{num}](<{rel}/별표/{f.name}>)")
L.append(f" - 별표·서식 {len(byl)}건: " + " · ".join(links))
att = sorted((folder / "첨부").glob("*.md")) if (folder / "첨부").is_dir() else []
if att:
n_att += len(att)
L.append(
f" - 첨부 본문 {len(att)}건: "
+ " · ".join(f"[{f.stem[:24]}](<{rel}/첨부/{f.name}>)" for f in att)
)
# 첨부 zip 해제 폴더([압축] …)
zdirs = sorted((folder / "첨부").glob("[[]압축[]]*")) if (folder / "첨부").is_dir() else []
for zd in zdirs:
zmds = [p for p in zd.rglob("*.md") if p.name != "_목록.md"]
n_zip[0] += len(zmds)
idx = zd / "_목록.md"
link = f"[_목록](<{rel}/첨부/{zd.name}/_목록.md>)" if idx.exists() else zd.name
L.append(f" - 압축해제 「{zd.name[4:].strip()}」 {len(zmds)}건: {link}")
kcs = sorted((folder / "KCS").glob("*.md")) if (folder / "KCS").is_dir() else []
kcs = [f for f in kcs if f.stem != "_목록"]
if kcs:
n_kcs += len(kcs)
L.append(
f" - KCS 본문 {len(kcs)}건: [_목록](<{rel}/KCS/_목록.md>) · "
+ " · ".join(f"[{f.stem.split('_')[0]}](<{rel}/KCS/{f.name}>)" for f in kcs[:8])
+ (f" …외 {len(kcs) - 8}건" if len(kcs) > 8 else "")
)
L.append("")
L.append("### KS (메타데이터)")
L.append("")
ksdir = sorted((ORIG / "KS").glob("*.md")) if (ORIG / "KS").is_dir() else []
if ksdir:
L.append("- " + " · ".join(f"[{f.stem}]()" for f in ksdir))
L.append("")
L.append(
f"> 법령 본문 md {n_law} · 별표/서식 md {n_byl} · 첨부 본문 md {n_att} · 압축해제 md {n_zip[0]} · KCS 본문 md {n_kcs} · KS 메타 md {len(ksdir)}"
)
L.append("")
L.append("---")
L.append("")
L.append(f"- 총 {total}건 (법률 36 / 행정규칙 21 / 표준시방서 6 / KS 45)")
L.append(f"- 스캔 대상: `임도기술교본/` 하위 md {len(files)}개 (`원문/` 제외)")
L.append(
"- 스캔 기준: 정식명칭 및 약칭(산림자원법, 국가계약법, 지방계약법, 임도규정 등) 정규식 매칭, 공백·`ㆍ`·`
` 정규화 후 대조"
)
L.append("- 교본 기준 시점: 2019년 (고시·요율은 당시 개정본 기준. 원문 수집 시 현행본 병기 필요)")
L.append("- 시행일/개정일 조회: 국가법령정보센터 Open API (`law.go.kr/DRF`), 2026-08 기준 현행본")
# ── 쓰기: 전체 덮어쓰기 금지 — '원문 md 색인' 절만 마커 사이에 스플라이스 ──
# 배경(2026-08-15): 04 목록의 등록표(§1~§4)는 수기 추가 행(사방 고시·조달수수료 등)이
# 축적된 수동 관리 문서가 됐다. 전체 재생성하면 수기 행이 소실되므로, 이 스크립트는
# 자동화 가치가 큰 '원문 md 색인' 절만 AUTO 마커 블록 안에서 재생성한다.
MARK_BEGIN = ""
MARK_END = ""
idx_start = L.index("## 5. 원문 md 색인")
idx_end = next(i for i, ln in enumerate(L) if ln.startswith("> 법령 본문 md "))
auto_block = "\n".join([MARK_BEGIN] + L[idx_start : idx_end + 1] + [MARK_END])
if not OUTMD.exists():
raise SystemExit(f"오류: {OUTMD} 없음 — 이 스크립트는 색인 절만 갱신하며 신규 생성하지 않는다.")
cur = OUTMD.read_text(encoding="utf-8")
if MARK_BEGIN not in cur or MARK_END not in cur:
raise SystemExit(
"오류: AUTO:INDEX 마커 없음 — 04 문서에 마커 블록을 먼저 넣어야 한다 "
"('## 5. 원문 md 색인' 절을 MARK_BEGIN/END로 감싼다)."
)
pre = cur.split(MARK_BEGIN)[0]
post = cur.split(MARK_END, 1)[1]
OUTMD.write_text(pre + auto_block + post, encoding="utf-8")
print(f"색인 절 갱신 완료: {OUTMD.name} (등록표 §1~§4·큐레이션 절은 수동 관리 — 보존됨)")