# -*- 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·큐레이션 절은 수동 관리 — 보존됨)")