auto: 2026-09-02 16:48 (EOMSANGDON-HOME)

This commit is contained in:
2026-09-02 16:48:22 +09:00
parent b4c7a7eedb
commit 397a135073
192 changed files with 6816 additions and 6014 deletions
@@ -1,69 +1,86 @@
# -*- coding: utf-8 -*-
"""lawapi.json에서 정확명 매칭 행을 뽑아 시행일/개정일 맵 생성 + 수동 보정."""
import 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)
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()
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)
if m:
return m.group(1)
return ""
OUT = Path(str(DATA_DIR))
api = json.load(open(OUT / "lawapi.json", encoding="utf-8"))
# 목록상 명칭 -> API 조회명 (다르면 매핑)
Q = {
"산림자원의 조성 및 관리에 관한 법률": "산림자원의 조성 및 관리에 관한 법률",
"산림자원의 조성 및 관리에 관한 법률 시행령": "산림자원의 조성 및 관리에 관한 법률 시행령",
"산림자원의 조성 및 관리에 관한 법률 시행규칙": "산림자원의 조성 및 관리에 관한 법률 시행규칙",
"산림기술 진흥 및 관리에 관한 법률": "산림기술 진흥 및 관리에 관한 법률",
"산림기술 진흥 및 관리에 관한 법률 시행령": "산림기술 진흥 및 관리에 관한 법률 시행령",
"산림보호법": "산림보호법", "산지관리법": "산지관리법",
"자연환경보전법": "자연환경보전법", "자연재해대책법": "자연재해대책법",
"환경영향평가법": "환경영향평가법",
"보조금 관리에 관한 법률": "보조금 관리에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률": "국가를 당사자로 하는 계약에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률 시행령": "국가를 당사자로 하는 계약에 관한 법률 시행령",
"지방자치단체를 당사자로 하는 계약에 관한 법률": "지방자치단체를 당사자로 하는 계약에 관한 법률",
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령": "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
"도로법": "도로법", "농어촌도로정비법": "농어촌도로 정비법",
"국토의 계획 및 이용에 관한 법률": "국토의 계획 및 이용에 관한 법률",
"도로명주소법": "도로명주소법", "도로명주소법 시행령": "도로명주소법 시행령",
"측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률",
"산업안전보건법": "산업안전보건법", "산업재해보상보험법": "산업재해보상보험법",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령",
"고용보험법 시행령": "고용보험법 시행령",
"국민건강보험법": "국민건강보험법", "국민연금법": "국민연금법",
"노인장기요양보험법": "노인장기요양보험법", "노인장기요양보험법 시행령": "노인장기요양보험법 시행령",
"부가가치세법": "부가가치세법", "근로기준법": "근로기준법",
"산업표준화법": "산업표준화법", "전자서명법": "전자서명법",
"공동주택관리법": "공동주택관리법",
"임도설치 및 관리 등에 관한 규정": "임도설치 및 관리 등에 관한 규정",
"훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령·예규 등의 발령 및 관리에 관한 규정",
"사업종류별 산재보험료율 고시": "사업종류별 산재보험료율",
"건설업 산업안전보건관리비 계상 및 사용기준": "건설업 산업안전보건관리비 계상 및 사용기준",
"(국토교통부) 사회보험의 보험료 적용기준": "사회보험의 보험료 적용기준",
"엔지니어링사업대가의 기준": "엔지니어링사업대가의 기준",
"산림자원의 조성 및 관리에 관한 법률": "산림자원의 조성 및 관리에 관한 법률",
"산림자원의 조성 및 관리에 관한 법률 시행령": "산림자원의 조성 및 관리에 관한 법률 시행령",
"산림자원의 조성 및 관리에 관한 법률 시행규칙": "산림자원의 조성 및 관리에 관한 법률 시행규칙",
"산림기술 진흥 및 관리에 관한 법률": "산림기술 진흥 및 관리에 관한 법률",
"산림기술 진흥 및 관리에 관한 법률 시행령": "산림기술 진흥 및 관리에 관한 법률 시행령",
"산림보호법": "산림보호법",
"산지관리법": "산지관리법",
"자연환경보전법": "자연환경보전법",
"자연재해대책법": "자연재해대책법",
"환경영향평가법": "환경영향평가법",
"보조금 관리에 관한 법률": "보조금 관리에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률": "국가를 당사자로 하는 계약에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률 시행령": "국가를 당사자로 하는 계약에 관한 법률 시행령",
"지방자치단체를 당사자로 하는 계약에 관한 법률": "지방자치단체를 당사자로 하는 계약에 관한 법률",
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령": "지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
"도로법": "도로법",
"농어촌도로정비법": "농어촌도로 정비법",
"국토의 계획 및 이용에 관한 법률": "국토의 계획 및 이용에 관한 법률",
"도로명주소법": "도로명주소법",
"도로명주소법 시행령": "도로명주소법 시행령",
"측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률",
"산업안전보건법": "산업안전보건법",
"산업재해보상보험법": "산업재해보상보험법",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령": "고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령",
"고용보험법 시행령": "고용보험법 시행령",
"국민건강보험법": "국민건강보험법",
"국민연금법": "국민연금법",
"노인장기요양보험법": "노인장기요양보험법",
"노인장기요양보험법 시행령": "노인장기요양보험법 시행령",
"부가가치세법": "부가가치세법",
"근로기준법": "근로기준법",
"산업표준화법": "산업표준화법",
"전자서명법": "전자서명법",
"공동주택관리법": "공동주택관리법",
"임도설치 및 관리 등에 관한 규정": "임도설치 및 관리 등에 관한 규정",
"훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령·예규 등의 발령 및 관리에 관한 규정",
"사업종류별 산재보험료율 고시": "사업종류별 산재보험료율",
"건설업 산업안전보건관리비 계상 및 사용기준": "건설업 산업안전보건관리비 계상 및 사용기준",
"(국토교통부) 사회보험의 보험료 적용기준": "사회보험의 보험료 적용기준",
"엔지니어링사업대가의 기준": "엔지니어링사업대가의 기준",
}
# 조회명이 목록명과 다른 경우 실제 API 반환명 지정
EXACT = {
"농어촌도로정비법": "농어촌도로 정비법",
"측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률",
"훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령ㆍ예규 등의 발령 및 관리에 관한 규정",
"사업종류별 산재보험료율 고시": "2026년도 사업종류별 산재보험료율",
"(국토교통부) 사회보험의 보험료 적용기준": "(국토교통부) 사회보험의 보험료 적용기준",
"농어촌도로정비법": "농어촌도로 정비법",
"측량ㆍ수로조사 및 지적에 관한 법률": "공간정보의 구축 및 관리 등에 관한 법률",
"훈령ㆍ예규 등의 발령 및 관리에 관한 규정": "훈령ㆍ예규 등의 발령 및 관리에 관한 규정",
"사업종류별 산재보험료율 고시": "2026년도 사업종류별 산재보험료율",
"(국토교통부) 사회보험의 보험료 적용기준": "(국토교통부) 사회보험의 보험료 적용기준",
}
SRC_LAW = "국가법령정보센터"
@@ -71,61 +88,226 @@ m = {}
for name, q in Q.items():
rows = api.get(q, {}).get("rows", [])
want = EXACT.get(name, name)
hit = next((r for r in rows if r["명"] == want), None) or next((r for r in rows if r["명"].startswith(want)), None)
hit = next((r for r in rows if r["명"] == want), None) or next(
(r for r in rows if r["명"].startswith(want)), None
)
if not hit:
print("!! 미매칭", name, "|", [r["명"] for r in rows][:3])
continue
m[name] = {"시행": hit["시행"], "개정": hit["공포"], "구분": hit["제개정"],
"호수": hit["번호"], "종류": hit["종류"], "출처": SRC_LAW, "실명": hit["명"]}
m[name] = {
"시행": hit["시행"],
"개정": hit["공포"],
"구분": hit["제개정"],
"호수": hit["번호"],
"종류": hit["종류"],
"출처": SRC_LAW,
"실명": hit["명"],
}
# ── 2·3차 조회 결과 및 별표·비수록 항목 수동 등록 ──
MANUAL = {
"국가균형발전특별법": ("2026.07.01", "2026.03.05", "일부개정", "21447", "법률", SRC_LAW,
"폐지·승계 → 「지방자치분권 및 균형성장에 관한 특별법」"),
"수치지도 작성 작업규칙": ("2015.06.04", "2015.06.04", "일부개정", "00209", "국토교통부령", SRC_LAW, ""),
"재난구호 및 재난복구 비용 부담기준 등에 관한 규정": ("2025.11.28", "2025.11.27", "일부개정", "35875", "대통령령", SRC_LAW,
"「자연재난 구호 및 복구 비용…규정」으로 분리 (사회재난분 별도 35876)"),
"임도 품셈 적용기준 / 임도표준품셈": ("2026.01.01", "2025.11.26", "전부개정", "2025-82", "고시", SRC_LAW,
"현 「산림사업 표준품셈」(산림청 고시)"),
"건설공사 감독자 업무 지침": ("2026.07.08", "2026.07.08", "일부개정", "2026-360", "고시", SRC_LAW,
"현 「건설공사 사업관리방식 검토기준 및 업무수행지침」"),
"공사장의 비산분진 발생원 시설관리기준": ("2026.07.15", "2026.07.15", "일부개정", "00049", "환경부령", SRC_LAW,
"「대기환경보전법 시행규칙」 별표에 수록"),
"국가지점번호판 규격 등 고시": ("2024.07.05", "2024.07.05", "제정", "2024-56", "고시", SRC_LAW,
"「국가지점번호의 표기 및 국가지점번호판의 설치 확인에 관한 업무 위탁 고시」"),
"국가지점번호 부여기준 및 방법": ("2012.12.18", "2012.12.12", "제정", "2012-55", "고시", SRC_LAW,
"「국가지점번호 기준점 고시」"),
"입찰유의서(계약예규)": ("2025.12.31", "2025.12.31", "일부개정", "", "계약예규", SRC_LAW,
"「(계약예규) 공사입찰유의서」"),
"콘크리트 표준시방서": ("2025.01.05", "2024.12.30", "일부개정", "2025-879", "고시", "국가건설기준센터",
"KCS 14 20 00 (국토교통부 고시로도 수록)"),
"도로공사 표준시방서": ("2023.01.12", "2023.01.06", "일부개정", "2023-907", "고시", "국가건설기준센터",
"KCS 44 00 00"),
"토목공사 표준시방서": ("2023.01.25", "2023.01.19", "일부개정", "2023-48", "고시", "국가건설기준센터",
"현 KCS 10 00 00 공통공사 표준시방서로 재편"),
"건설공사 표준시방서": ("2018.08.09", "2018.08.03", "제정", "2018-468", "고시", "국가건설기준센터",
"건설기준 코드(KDS/KCS) 체계로 통합"),
"건설공사 비탈면 표준시방서": ("", "", "", "", "", "국가건설기준센터",
"KCS 11 70 00 비탈면 (법령정보센터 미수록)"),
"임도시설공사 표준시방서": ("", "", "", "", "", "산림청",
"법령정보센터·건설기준센터 모두 미수록"),
"산림관리기반시설의 설계 및 시설기준": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW,
"「산림자원법 시행규칙」 별표2에 수록"),
"산림관리기반시설의 범위 및 기준": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW,
"「산림자원법 시행규칙」 별표1에 수록"),
"산림관리기반시설의 타당성평가 항목별 기준 및 방법": ("2026.02.01", "2026.02.01", "", "", "별표", SRC_LAW,
"「산림자원법 시행규칙」 별표1의2에 수록"),
"지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침": ("", "", "", "", "지침", "산림청",
"법령정보센터 미수록"),
"중기운용관리예규 / 차량관리예규": ("", "", "", "", "예규", "산림청", "법령정보센터 미수록"),
"예산편성기준": ("", "", "", "", "지침", "기획재정부",
"「예산안 편성 및 기금운용계획안 작성지침」 — 법령정보센터 미수록, 기재부 연간 배포"),
"토목공사원가계산 제비율 적용기준": ("", "", "", "", "기준", "조달청",
"법령정보센터 미수록, 조달청 연간 발표"),
"국가균형발전특별법": (
"2026.07.01",
"2026.03.05",
"일부개정",
"21447",
"법률",
SRC_LAW,
"폐지·승계 → 「지방자치분권 및 균형성장에 관한 특별법」",
),
"수치지도 작성 작업규칙": (
"2015.06.04",
"2015.06.04",
"일부개정",
"00209",
"국토교통부령",
SRC_LAW,
"",
),
"재난구호 및 재난복구 비용 부담기준 등에 관한 규정": (
"2025.11.28",
"2025.11.27",
"일부개정",
"35875",
"대통령령",
SRC_LAW,
"「자연재난 구호 및 복구 비용…규정」으로 분리 (사회재난분 별도 35876)",
),
"임도 품셈 적용기준 / 임도표준품셈": (
"2026.01.01",
"2025.11.26",
"전부개정",
"2025-82",
"고시",
SRC_LAW,
"현 「산림사업 표준품셈」(산림청 고시)",
),
"건설공사 감독자 업무 지침": (
"2026.07.08",
"2026.07.08",
"일부개정",
"2026-360",
"고시",
SRC_LAW,
"현 「건설공사 사업관리방식 검토기준 및 업무수행지침」",
),
"공사장의 비산분진 발생원 시설관리기준": (
"2026.07.15",
"2026.07.15",
"일부개정",
"00049",
"환경부령",
SRC_LAW,
"「대기환경보전법 시행규칙」 별표에 수록",
),
"국가지점번호판 규격 등 고시": (
"2024.07.05",
"2024.07.05",
"제정",
"2024-56",
"고시",
SRC_LAW,
"「국가지점번호의 표기 및 국가지점번호판의 설치 확인에 관한 업무 위탁 고시」",
),
"국가지점번호 부여기준 및 방법": (
"2012.12.18",
"2012.12.12",
"제정",
"2012-55",
"고시",
SRC_LAW,
"「국가지점번호 기준점 고시」",
),
"입찰유의서(계약예규)": (
"2025.12.31",
"2025.12.31",
"일부개정",
"",
"계약예규",
SRC_LAW,
"「(계약예규) 공사입찰유의서」",
),
"콘크리트 표준시방서": (
"2025.01.05",
"2024.12.30",
"일부개정",
"2025-879",
"고시",
"국가건설기준센터",
"KCS 14 20 00 (국토교통부 고시로도 수록)",
),
"도로공사 표준시방서": (
"2023.01.12",
"2023.01.06",
"일부개정",
"2023-907",
"고시",
"국가건설기준센터",
"KCS 44 00 00",
),
"토목공사 표준시방서": (
"2023.01.25",
"2023.01.19",
"일부개정",
"2023-48",
"고시",
"국가건설기준센터",
"현 KCS 10 00 00 공통공사 표준시방서로 재편",
),
"건설공사 표준시방서": (
"2018.08.09",
"2018.08.03",
"제정",
"2018-468",
"고시",
"국가건설기준센터",
"건설기준 코드(KDS/KCS) 체계로 통합",
),
"건설공사 비탈면 표준시방서": (
"",
"",
"",
"",
"",
"국가건설기준센터",
"KCS 11 70 00 비탈면 (법령정보센터 미수록)",
),
"임도시설공사 표준시방서": (
"",
"",
"",
"",
"",
"산림청",
"법령정보센터·건설기준센터 모두 미수록",
),
"산림관리기반시설의 설계 및 시설기준": (
"2026.02.01",
"2026.02.01",
"",
"",
"별표",
SRC_LAW,
"「산림자원법 시행규칙」 별표2에 수록",
),
"산림관리기반시설의 범위 및 기준": (
"2026.02.01",
"2026.02.01",
"",
"",
"별표",
SRC_LAW,
"「산림자원법 시행규칙」 별표1에 수록",
),
"산림관리기반시설의 타당성평가 항목별 기준 및 방법": (
"2026.02.01",
"2026.02.01",
"",
"",
"별표",
SRC_LAW,
"「산림자원법 시행규칙」 별표1의2에 수록",
),
"지방산림청과 자연휴양림관리소와의 자연휴양림업무 처리지침": (
"",
"",
"",
"",
"지침",
"산림청",
"법령정보센터 미수록",
),
"중기운용관리예규 / 차량관리예규": ("", "", "", "", "예규", "산림청", "법령정보센터 미수록"),
"예산편성기준": (
"",
"",
"",
"",
"지침",
"기획재정부",
"「예산안 편성 및 기금운용계획안 작성지침」 — 법령정보센터 미수록, 기재부 연간 배포",
),
"토목공사원가계산 제비율 적용기준": (
"",
"",
"",
"",
"기준",
"조달청",
"법령정보센터 미수록, 조달청 연간 발표",
),
}
for k, v in MANUAL.items():
m[k] = {"시행": v[0], "개정": v[1], "구분": v[2], "호수": v[3],
"종류": v[4], "출처": v[5], "실명": "", "추가": v[6]}
m[k] = {
"시행": v[0],
"개정": v[1],
"구분": v[2],
"호수": v[3],
"종류": v[4],
"출처": v[5],
"실명": "",
"추가": v[6],
}
json.dump(m, open(OUT / "srcmap.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
print(f"매핑 {len(m)}건 생성")
@@ -1,30 +1,42 @@
# -*- coding: utf-8 -*-
"""국가법령정보센터 Open API로 목록 항목의 보유 여부·시행일·개정일 조회."""
import json, re, time, urllib.parse, urllib.request
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)
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()
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)
if m:
return m.group(1)
return ""
import xml.etree.ElementTree as ET
OUT = Path(str(DATA_DIR))
def api(target, query, display=5):
url = ("https://www.law.go.kr/DRF/lawSearch.do?OC=umsangdon&type=XML"
f"&target={target}&display={display}&query=" + urllib.parse.quote(query))
url = (
"https://www.law.go.kr/DRF/lawSearch.do?OC=umsangdon&type=XML"
f"&target={target}&display={display}&query=" + urllib.parse.quote(query)
)
for _ in range(3):
try:
with urllib.request.urlopen(url, timeout=25) as r:
@@ -33,6 +45,7 @@ def api(target, query, display=5):
time.sleep(1.2)
return None
def txt(node, *names):
for n in names:
el = node.find(n)
@@ -40,51 +53,75 @@ def txt(node, *names):
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 ""
LAWS = [
"산림자원의 조성 및 관리에 관한 법률", "산림자원의 조성 및 관리에 관한 법률 시행령",
"산림자원의 조성 및 관리에 관한 법률 시행규칙", "산림기술 진흥 및 관리에 관한 법률",
"산림기술 진흥 및 관리에 관한 법률 시행령", "산림보호법", "산지관리법",
"자연환경보전법", "자연재해대책법", "환경영향평가법", "국가균형발전특별법",
"보조금 관리에 관한 법률", "국가를 당사자로 하는 계약에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률 시행령",
"지방자치단체를 당사자로 하는 계약에 관한 법률",
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
"도로법", "농어촌도로 정비법", "국토의 계획 및 이용에 관한 법률",
"도로명주소법", "도로명주소법 시행령", "공간정보의 구축 및 관리 등에 관한 법률",
"산업안전보건법", "산업재해보상보험법",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령",
"고용보험법 시행령", "국민건강보험법", "국민연금법", "노인장기요양보험법",
"노인장기요양보험법 시행령", "부가가치세법", "근로기준법", "산업표준화법",
"전자서명법", "공동주택관리법",
"산림자원의 조성 및 관리에 관한 법률",
"산림자원의 조성 및 관리에 관한 법률 시행령",
"산림자원의 조성 및 관리에 관한 법률 시행규칙",
"산림기술 진흥 및 관리에 관한 법률",
"산림기술 진흥 및 관리에 관한 법률 시행령",
"산림보호법",
"산지관리법",
"자연환경보전법",
"자연재해대책법",
"환경영향평가법",
"국가균형발전특별법",
"보조금 관리에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률",
"국가를 당사자로 하는 계약에 관한 법률 시행령",
"지방자치단체를 당사자로 하는 계약에 관한 법률",
"지방자치단체를 당사자로 하는 계약에 관한 법률 시행령",
"도로법",
"농어촌도로 정비법",
"국토의 계획 및 이용에 관한 법률",
"도로명주소법",
"도로명주소법 시행령",
"공간정보의 구축 및 관리 등에 관한 법률",
"산업안전보건법",
"산업재해보상보험법",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률",
"고용보험 및 산업재해보상보험의 보험료징수 등에 관한 법률 시행령",
"고용보험법 시행령",
"국민건강보험법",
"국민연금법",
"노인장기요양보험법",
"노인장기요양보험법 시행령",
"부가가치세법",
"근로기준법",
"산업표준화법",
"전자서명법",
"공동주택관리법",
]
RULES = [
"임도설치 및 관리 등에 관한 규정",
"산림관리기반시설의 설계 및 시설기준",
"산림관리기반시설의 범위 및 기준",
"산림관리기반시설의 타당성평가",
"임도 품셈",
"자연휴양림업무 처리지침",
"중기운용관리",
"훈령·예규 등의 발령 및 관리에 관한 규정",
"국가지점번호",
"재난구호 및 재난복구 비용 부담기준 등에 관한 규정",
"사업종류별 산재보험료율",
"건설업 산업안전보건관리비 계상 및 사용기준",
"사회보험의 보험료 적용기준",
"엔지니어링사업대가의 기준",
"예산안 편성",
"토목공사원가계산 제비율",
"입찰유의서",
"수치지도 작성 작업규칙",
"비산분진 발생원 시설관리기준",
"건설공사 감독자 업무 지침",
"콘크리트 표준시방서", "도로공사 표준시방서", "임도시설공사 표준시방서",
"임도설치 및 관리 등에 관한 규정",
"산림관리기반시설의 설계 및 시설기준",
"산림관리기반시설의 범위 및 기준",
"산림관리기반시설의 타당성평가",
"임도 품셈",
"자연휴양림업무 처리지침",
"중기운용관리",
"훈령·예규 등의 발령 및 관리에 관한 규정",
"국가지점번호",
"재난구호 및 재난복구 비용 부담기준 등에 관한 규정",
"사업종류별 산재보험료율",
"건설업 산업안전보건관리비 계상 및 사용기준",
"사회보험의 보험료 적용기준",
"엔지니어링사업대가의 기준",
"예산안 편성",
"토목공사원가계산 제비율",
"입찰유의서",
"수치지도 작성 작업규칙",
"비산분진 발생원 시설관리기준",
"건설공사 감독자 업무 지침",
"콘크리트 표준시방서",
"도로공사 표준시방서",
"임도시설공사 표준시방서",
]
res = {}
@@ -94,18 +131,23 @@ for grp, target, items in (("법령", "law", LAWS), ("행정규칙", "admrul", R
rows = []
if root is not None:
for node in root.findall("law") + root.findall("admrul"):
rows.append({
"명": txt(node, "법령명한글", "행정규칙명"),
"약칭": txt(node, "법령약칭명"),
"종류": txt(node, "법령구분명", "행정규칙종류"),
"부처": txt(node, "소관부처명", "소관부처명"),
"공포": ymd(txt(node, "공포일자", "발령일자")),
"시행": ymd(txt(node, "시행일자")),
"제개정": txt(node, "제개정구분명", "제개정구분코드"),
"번호": txt(node, "공포번호", "발령번호"),
})
rows.append(
{
"명": txt(node, "법령명한글", "행정규칙명"),
"약칭": txt(node, "법령약칭명"),
"종류": txt(node, "법령구분명", "행정규칙종류"),
"부처": txt(node, "소관부처명", "소관부처명"),
"공포": ymd(txt(node, "공포일자", "발령일자")),
"시행": ymd(txt(node, "시행일자")),
"제개정": txt(node, "제개정구분명", "제개정구분코드"),
"번호": txt(node, "공포번호", "발령번호"),
}
)
res[q] = {"grp": grp, "target": target, "rows": rows}
print(f"[{grp}] {q} -> {len(rows)}건" + (f" | {rows[0]['명']} 시행 {rows[0]['시행']}" if rows else ""))
print(
f"[{grp}] {q} -> {len(rows)}건"
+ (f" | {rows[0]['명']} 시행 {rows[0]['시행']}" if rows else "")
)
time.sleep(0.35)
json.dump(res, open(OUT / "lawapi.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
@@ -19,6 +19,7 @@
값형(4~6)은 원본 스냅샷을 폴더에 보존하고, 프로그램용 데이터셋은 여기서 별도 추출해 구성한다
(2026-08-14 사용자 결정). 표준시장단가는 수집 제외 (100억 미만 공사 미적용·품셈 방식과 별도 트랙).
"""
import csv
import json
import os
@@ -36,18 +37,30 @@ UA = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
# ── 반기 갱신 대상 URL (2026 상반기/2026년판 기준) ──
DOC_SOURCES = [
# (폴더, 파일명, URL, referer)
("노임단가_건설업_대한건설협회", "2026상반기_건설업_임금실태조사_대한건설협회.pdf",
"https://www.cak.or.kr/download.do?uuid=a0e92670-b6b7-4764-a15f-40c34febeaa0.pdf",
"https://www.cak.or.kr/lay1/S1T16C41/sublink.do"),
("노임단가_제조업_중소기업중앙회", "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.pdf",
None, # kbiz는 view 페이지에서 download.do 링크 추출 필요 — VIEW_URL 사용
"https://www.kbiz.or.kr/ko/contents/bbs/view.do?mnSeq=325&seq=163372"),
("건설공사_표준품셈", "2026년_건설공사_표준품셈.pdf",
"https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=1",
"https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1"),
("건설공사_표준품셈", "2026년_건설공사_표준품셈_개정사항.pdf",
"https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=2",
"https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1"),
(
"노임단가_건설업_대한건설협회",
"2026상반기_건설업_임금실태조사_대한건설협회.pdf",
"https://www.cak.or.kr/download.do?uuid=a0e92670-b6b7-4764-a15f-40c34febeaa0.pdf",
"https://www.cak.or.kr/lay1/S1T16C41/sublink.do",
),
(
"노임단가_제조업_중소기업중앙회",
"2026상반기_중소제조업_직종별_임금조사_중소기업중앙회.pdf",
None, # kbiz는 view 페이지에서 download.do 링크 추출 필요 — VIEW_URL 사용
"https://www.kbiz.or.kr/ko/contents/bbs/view.do?mnSeq=325&seq=163372",
),
(
"건설공사_표준품셈",
"2026년_건설공사_표준품셈.pdf",
"https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=1",
"https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1",
),
(
"건설공사_표준품셈",
"2026년_건설공사_표준품셈_개정사항.pdf",
"https://www.kseis.co.kr/bbs/data/dataFileDown.do?bbs_seq=64699193400142&file_no=2",
"https://www.kseis.co.kr/bbs/data/dataDetail.do?bbs_seq=64699193400142&pgno=1",
),
]
G2B_OPS = [
@@ -93,7 +106,11 @@ def collect_docs():
continue
url = "https://www.kbiz.or.kr" + links[0].replace("&", "&")
data = fetch(url, referer)
if not data[:4] == b"%PDF" and not data[:4] == b"PK\x03\x04" and not data[:8].startswith(b"\xd0\xcf\x11\xe0"):
if (
not data[:4] == b"%PDF"
and not data[:4] == b"PK\x03\x04"
and not data[:8].startswith(b"\xd0\xcf\x11\xe0")
):
print(f"FAIL {fname}: PDF/HWP 아님 ({data[:8].hex()}) — URL 갱신 필요")
continue
dest.write_bytes(data)
@@ -109,13 +126,22 @@ def collect_values():
ecos = read_key(r"ECOS[^`]*\n- 인증키: `([^`]+)`", "ECOS")
year = today[:4]
rows = []
for code, name in [("0000001", "원/미국달러"), ("0000002", "원/일본엔100"),
("0000003", "원/유로"), ("0000012", "원/영국파운드"), ("0000053", "원/위안")]:
url = (f"https://ecos.bok.or.kr/api/StatisticSearch/{ecos}/json/kr/1/400/"
f"731Y001/D/{year}0101/{today.replace('-', '')}/{code}")
for code, name in [
("0000001", "원/미국달러"),
("0000002", "원/일본엔100"),
("0000003", "원/유로"),
("0000012", "원/영국파운드"),
("0000053", "원/위안"),
]:
url = (
f"https://ecos.bok.or.kr/api/StatisticSearch/{ecos}/json/kr/1/400/"
f"731Y001/D/{year}0101/{today.replace('-', '')}/{code}"
)
j = json.loads(fetch(url, timeout=60).decode("utf-8", "replace"))
for r in j.get("StatisticSearch", {}).get("row", []):
rows.append({"통화": name, "항목코드": code, "일자": r["TIME"], "환율": r["DATA_VALUE"]})
rows.append(
{"통화": name, "항목코드": code, "일자": r["TIME"], "환율": r["DATA_VALUE"]}
)
out = BASE / "환율_한국은행ECOS" / f"환율_일별_{year}0101_{today}.csv"
out.parent.mkdir(exist_ok=True)
with open(out, "w", newline="", encoding="utf-8-sig") as f:
@@ -127,11 +153,18 @@ def collect_values():
# 유가: 오늘 전국 평균 + 유종별 최근 7일
opinet = read_key(r"오피넷[^`]*`([^`]+)`", "오피넷")
data = {"수집일": today}
j = json.loads(fetch(f"https://www.opinet.co.kr/api/avgAllPrice.do?out=json&code={opinet}", timeout=60))
j = json.loads(
fetch(f"https://www.opinet.co.kr/api/avgAllPrice.do?out=json&code={opinet}", timeout=60)
)
data["전국평균"] = j.get("RESULT", {}).get("OIL", [])
data["최근7일"] = {}
for prod in ["B027", "D047"]: # 휘발유, 자동차용경유
j = json.loads(fetch(f"https://www.opinet.co.kr/api/avgRecentPrice.do?out=json&code={opinet}&prodcd={prod}", timeout=60))
j = json.loads(
fetch(
f"https://www.opinet.co.kr/api/avgRecentPrice.do?out=json&code={opinet}&prodcd={prod}",
timeout=60,
)
)
data["최근7일"][prod] = j.get("RESULT", {}).get("OIL", [])
out = BASE / "유가_오피넷" / f"유가_전국평균_{today}.json"
out.parent.mkdir(exist_ok=True)
@@ -146,8 +179,10 @@ def collect_snapshot(outdir=None):
for op, label in G2B_OPS:
page, got, total = 1, 0, 1
while got < total:
url = (f"http://apis.data.go.kr/1230000/ao/PriceInfoService/{op}"
f"?serviceKey={key}&pageNo={page}&numOfRows=999&type=json")
url = (
f"http://apis.data.go.kr/1230000/ao/PriceInfoService/{op}"
f"?serviceKey={key}&pageNo={page}&numOfRows=999&type=json"
)
j = json.loads(fetch(url, timeout=60).decode("utf-8", "replace"))
body = j.get("response", {}).get("body", {})
total = int(body.get("totalCount") or 0)
@@ -173,7 +208,8 @@ def collect_snapshot(outdir=None):
w.writeheader()
w.writerows(rows)
(outp / f"나라장터_시설공통자재_{today}.json").write_text(
json.dumps(rows, ensure_ascii=False), encoding="utf-8")
json.dumps(rows, ensure_ascii=False), encoding="utf-8"
)
print(f"saved {out.name} ({len(rows):,} rows, +json)")
@@ -4,6 +4,7 @@
- zip 내 CP949(EUC-KR) 파일명 mojibake를 복원해 `첨부/[zip]<이름>/` 에 해제
- 내부 HWP/HWPX → md 변환(hwpx_text.to_md / hwp5_to_md)
"""
import re, sys, zipfile
from pathlib import Path
@@ -12,6 +13,7 @@ import hwpx_text
ROOT = Path(__file__).resolve().parent.parent
def fixname(n):
"""zip 엔트리명 CP437 mojibake → CP949 복원."""
try:
@@ -19,9 +21,11 @@ def fixname(n):
except Exception:
return n
def safe_part(s):
return re.sub(r'[:*?"<>|]', "_", s).strip()
def extract_one(zip_path):
zf = zipfile.ZipFile(zip_path)
dest = zip_path.parent / ("[압축] " + zip_path.stem)
@@ -38,6 +42,7 @@ def extract_one(zip_path):
n += 1
return dest, n
def convert_dir(folder):
ok = fail = 0
for f in sorted(folder.rglob("*")):
@@ -46,9 +51,9 @@ def convert_dir(folder):
md = f.with_suffix(".md")
try:
b = f.read_bytes()[:4]
if b[:2] == b"PK": # HWPX
if b[:2] == b"PK": # HWPX
text = hwpx_text.to_md(f)
elif b.hex() == "d0cf11e0": # 구형 HWP
elif b.hex() == "d0cf11e0": # 구형 HWP
text = hwpx_text.hwp5_to_md(f)
else:
fail += 1
@@ -60,6 +65,7 @@ def convert_dir(folder):
fail += 1
return ok, fail
if __name__ == "__main__":
zips = [Path(a) for a in sys.argv[1:]] or list(ROOT.rglob("첨부/*.zip"))
for z in zips:
@@ -4,28 +4,34 @@
법령 조문의 <img> 안에 있던 박스 드로잉 표가 이미지 로컬화 후 텍스트로 남는데,
│로 열을 구분하므로 md 표로 복원한다. 각 표에는 대응 ![그림]도 이미 있다.
"""
import re, sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
BORDER = set("┌┬┐├┼┤└┴┘─━┏┳┓┣╋┫┗┻┛│┃ \t")
VBAR = "│┃|"
QP = re.compile(r"^\s*>+\s?") # 인용블록 접두 '> '
QP = re.compile(r"^\s*>+\s?") # 인용블록 접두 '> '
def unq(l):
return QP.sub("", l)
def is_border(l):
s = unq(l).strip()
return bool(s) and all(c in BORDER for c in s) and any(c in "─━┼┬┴┌┐└┘├┤" for c in s)
def is_data(l):
return any(c in "│┃" for c in unq(l))
def split_cells(l):
s = unq(l).strip().strip("│┃")
return [c.strip() for c in re.split(r"[│┃]", s)]
def convert_block(lines):
rows = [split_cells(l) for l in lines if is_data(l)]
rows = [r for r in rows if any(c for c in r)]
@@ -36,12 +42,12 @@ def convert_block(lines):
return None
rows = [r + [""] * (w - len(r)) for r in rows]
esc = lambda c: c.replace("|", "\\|")
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |",
"|" + "|".join(["---"] * w) + "|"]
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "|".join(["---"] * w) + "|"]
for r in rows[1:]:
out.append("| " + " | ".join(esc(c) for c in r) + " |")
return out
def fix(md_path):
lines = md_path.read_text(encoding="utf-8").split("\n")
out = []
@@ -51,14 +57,20 @@ def fix(md_path):
while i < n:
if lines[i].lstrip().startswith("```"):
infence = not infence
out.append(lines[i]); i += 1
out.append(lines[i])
i += 1
continue
# 박스표 블록 시작: border 또는 data(│ 포함) 연속 (펜스 밖에서만)
if not infence and (is_border(lines[i]) or (is_data(lines[i]) and not lines[i].lstrip().startswith("|"))):
if not infence and (
is_border(lines[i]) or (is_data(lines[i]) and not lines[i].lstrip().startswith("|"))
):
j = i
block = []
while j < n and (is_border(lines[j]) or (is_data(lines[j]) and not lines[j].lstrip().startswith("|"))):
block.append(lines[j]); j += 1
while j < n and (
is_border(lines[j]) or (is_data(lines[j]) and not lines[j].lstrip().startswith("|"))
):
block.append(lines[j])
j += 1
data_rows = [b for b in block if is_data(b)]
md = convert_block(block)
# 열이 일정한 진짜 표만 md 표로. 아니면(수식 등) 코드펜스로 정렬 보존.
@@ -75,11 +87,13 @@ def fix(md_path):
changed += 1
i = j
continue
out.append(lines[i]); i += 1
out.append(lines[i])
i += 1
if changed:
md_path.write_text("\n".join(out), encoding="utf-8")
return changed
if __name__ == "__main__":
total = 0
for md in ROOT.rglob("*.md"):
@@ -4,17 +4,25 @@
법령 XML 조문·개정문에 인라인으로 박힌 수식·그림 이미지 처리.
이미지는 각 명칭 폴더의 pic/ 에 저장하고 md에서 ../pic 상대참조.
"""
import re, sys, time, urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
UA = {"User-Agent": "Mozilla/5.0"}
# src="URL" 형식과 id="flSeq" 형식(내부 이미지) 모두 처리
IMG = re.compile(r'<img\b([^>]*?)/?>')
IMG = re.compile(r"<img\b([^>]*?)/?>")
SRC = re.compile(r'src="([^"]+)"')
IID = re.compile(r'id="(\d+)"')
EXT = {b"\x89PNG": ".png", b"\xff\xd8\xff": ".jpg", b"GIF8": ".gif",
b"BM": ".bmp", b"II*\x00": ".tif", b"MM\x00*": ".tif"}
EXT = {
b"\x89PNG": ".png",
b"\xff\xd8\xff": ".jpg",
b"GIF8": ".gif",
b"BM": ".bmp",
b"II*\x00": ".tif",
b"MM\x00*": ".tif",
}
def sniff(b):
for sig, ext in EXT.items():
@@ -22,6 +30,7 @@ def sniff(b):
return ext
return ".png"
def download(url):
u = url.replace("http://", "https://")
for _ in range(3):
@@ -34,6 +43,7 @@ def download(url):
time.sleep(1.5)
return None
def fix(md_path):
"""md 파일: <img> → pic/ 저장 + ![그림](../pic/..) 참조. 폴더는 명칭 폴더의 pic/."""
text = md_path.read_text(encoding="utf-8")
@@ -43,6 +53,7 @@ def fix(md_path):
folder = md_path.parent
picdir = folder / "pic"
seq = [0]
def repl(m):
attrs = m.group(1)
ms = SRC.search(attrs)
@@ -61,16 +72,22 @@ def fix(md_path):
fn = f"{md_path.stem}_img{seq[0]}{sniff(blob)}"
(picdir / fn).write_bytes(blob)
return f"![그림](<pic/{fn}>)"
new = IMG.sub(repl, text)
# 부칙 등에서 여는 태그와 분리돼 남은 고아 닫는 태그 제거(단독 줄/인용줄 포함)
new = re.sub(r'^>?\s*</img>\s*$', ">", new, flags=re.M)
new = re.sub(r"^>?\s*</img>\s*$", ">", new, flags=re.M)
new = new.replace("</img>", "")
if new != text:
md_path.write_text(new, encoding="utf-8")
return seq[0]
if __name__ == "__main__":
mds = list(ROOT.rglob("현행_*.md")) + list(ROOT.rglob("교본시점_*.md")) + list(ROOT.rglob("CHANGELOG.md"))
mds = (
list(ROOT.rglob("현행_*.md"))
+ list(ROOT.rglob("교본시점_*.md"))
+ list(ROOT.rglob("CHANGELOG.md"))
)
mds = [m for m in mds if "임도기술교본" not in str(m) and "_pipeline" not in str(m)]
tot = 0
for md in sorted(mds):
@@ -7,6 +7,7 @@ PDF 표(정상)는 그대로 두고, 공백이 붙어버린 프로즈 줄만 HWP
- 별표: 같은 폴더 현행 XML의 별표서식파일링크(HWP)로 원본을 받아 hwp5 추출
- 첨부: 같은 폴더의 .hwp/.hwpx 원본을 사용
"""
import re, sys, time, urllib.request
import xml.etree.ElementTree as ET
from pathlib import Path
@@ -18,11 +19,13 @@ import hwp5_text
BASEURL = "https://www.law.go.kr"
UA = {"User-Agent": "Mozilla/5.0"}
def nsp(s):
# 매칭 키: 공백·특수문자(사설글리프·불릿·문장부호) 제거 → 한글/영숫자만.
# HWP와 PDF 추출의 글자 차이(ㅇ·ㆍ·U+F09E 등)를 흡수한다.
return re.sub(r"[^가-힣0-9A-Za-z]", "", s)
def spaced_index(hwp_path):
"""HWP 전체를 하나의 띄어쓰기 문자열로 잇고, 무공백↔원문 위치 맵을 만든다.
@@ -52,6 +55,7 @@ def respace(body, spaced, spaced_nsp, pos):
# 원문 줄바꿈(문단경계)은 공백으로
return re.sub(r"\s+", " ", seg).strip()
def download(link, dest):
url = link if link.startswith("http") else BASEURL + link
for _ in range(3):
@@ -65,6 +69,7 @@ def download(link, dest):
time.sleep(1.5)
return False
def byl_link_map(folder):
"""현행 XML → {별표 stem 접두: HWP링크}. md 파일명과 매칭용."""
xmls = sorted(folder.parent.glob("현행_*.xml"))
@@ -83,16 +88,23 @@ def byl_link_map(folder):
kind = (b.findtext("별표구분") or "별표").strip()
# 파일명 접두(별표/서식/별지)와 XML 구분을 맞춘다
pre = "별표" if kind == "별표" else ("별지" if kind == "별지" else "서식")
key = f"{pre}{num}{('의'+g) if g else ''}"
key = f"{pre}{num}{('의' + g) if g else ''}"
link = b.findtext("별표서식파일링크")
if link:
out[key] = link
return out
def restore_line(line, spaced, spaced_nsp, pos):
"""프로즈 줄이면 띄어쓰기 버전으로 교체. 표 행(|)은 건드리지 않는다."""
st = line.strip()
if not st or st.startswith("|") or st.startswith("#") or st.startswith(">") or st.startswith("!["):
if (
not st
or st.startswith("|")
or st.startswith("#")
or st.startswith(">")
or st.startswith("![")
):
return line
m = re.match(r"^(\s*(?:[-*]\s+|[가-힣]\.\s*|\(\d+\)\s*|\d+\.\s*)?)(.*)$", line)
prefix, body = m.group(1), m.group(2)
@@ -105,9 +117,10 @@ def restore_line(line, spaced, spaced_nsp, pos):
return prefix + sp
return line
def fix_file(md, hwp_dir_download=True):
text = md.read_text(encoding="utf-8")
folder = md.parent # .../별표 또는 .../첨부
folder = md.parent # .../별표 또는 .../첨부
stem = md.stem
# 소스 HWP 확보
@@ -126,7 +139,7 @@ def fix_file(md, hwp_dir_download=True):
if not hwp:
return None
if hwp.suffix == ".hwpx":
return None # hwpx는 별도(hwpx_text)로 이미 처리
return None # hwpx는 별도(hwpx_text)로 이미 처리
spaced, pos = spaced_index(hwp)
if not spaced:
return None
@@ -137,5 +150,3 @@ def fix_file(md, hwp_dir_download=True):
md.write_text("\n".join(new), encoding="utf-8")
return sum(1 for a, b in zip(lines, new) if a != b)
return 0
@@ -1,31 +1,41 @@
# -*- coding: utf-8 -*-
"""고시·훈령 본문이 껍데기인 경우 실제 내용이 담긴 첨부파일/별표 원본(HWP)을 내려받는다."""
import re, time, 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)
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()
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)
if m:
return m.group(1)
return ""
ROOT = Path(str(ROOT_DIR))
UA = {"User-Agent": "Mozilla/5.0"}
def safe(s):
return re.sub(r'[\\/:*?"<>|\n\r]', "_", s).strip().rstrip(".")
def get(url, tries=3):
url = url.strip().replace("http://law.go.kr", "https://www.law.go.kr")
if url.startswith("/"):
@@ -40,6 +50,7 @@ def get(url, tries=3):
return None
time.sleep(2)
tot_att = tot_hwp = 0
for xml in sorted(ROOT.rglob("현행_*.xml")):
folder = xml.parent
@@ -62,7 +73,7 @@ for xml in sorted(ROOT.rglob("현행_*.xml")):
d.mkdir(parents=True, exist_ok=True)
p.write_bytes(blob)
tot_att += 1
print(f" 첨부 {len(blob)//1024:6d}KB {folder.name[:34]} / {nm[:44]}", flush=True)
print(f" 첨부 {len(blob) // 1024:6d}KB {folder.name[:34]} / {nm[:44]}", flush=True)
time.sleep(0.3)
# ── 2) 별표 PDF가 안내문뿐인 경우 HWP 원본 확보 ──
@@ -75,17 +86,18 @@ for xml in sorted(ROOT.rglob("현행_*.xml")):
continue
num = (b.findtext("별표번호") or "0").lstrip("0") or "0"
g = (b.findtext("별표가지번호") or "").lstrip("0")
stem = safe(f"{b.findtext('별표구분')}{num}{('의'+g) if g else ''}_{title[:48]}")
stem = safe(f"{b.findtext('별표구분')}{num}{('의' + g) if g else ''}_{title[:48]}")
pdf = folder / "별표" / f"{stem}.pdf"
if not pdf.exists():
continue
try:
import pymupdf
t = "".join(pg.get_text() for pg in pymupdf.open(pdf))
except Exception:
continue
if len(re.sub(r"\s", "", t)) >= 120 and "자세한 내용은" not in t:
continue # 정상 PDF
continue # 정상 PDF
hlk = b.findtext("별표서식파일링크")
hnm = b.findtext("별표HWP파일명") or f"{stem}.hwp"
if not hlk:
@@ -100,7 +112,7 @@ for xml in sorted(ROOT.rglob("현행_*.xml")):
continue
hp.write_bytes(blob)
tot_hwp += 1
print(f" HWP {len(blob)//1024:6d}KB {folder.name[:34]} / {stem[:44]}", flush=True)
print(f" HWP {len(blob) // 1024:6d}KB {folder.name[:34]} / {stem[:44]}", flush=True)
time.sleep(0.3)
print(f"\n첨부파일 {tot_att}건 / 안내문 별표의 HWP 원본 {tot_hwp}건")
@@ -5,31 +5,40 @@ CodeList로 전체 코드를 받고, 대상 KCS 코드의 CodeViewer 본문을
표준시방서/<명칭>/KCS/<코드>_<이름>.md 로 저장한다.
API Key는 keyfile(_kcsc_key.txt)에서 읽는다.
"""
import json, re, sys, time, html, urllib.parse, urllib.request
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)
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()
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)
if m:
return m.group(1)
return ""
ROOT = Path(str(ROOT_DIR / "표준시방서"))
SC = Path(str(DATA_DIR))
KEY = _load_key("KCSC_KEY") # .secrets.local.md 또는 환경변수 KCSC_KEY
KEY = _load_key("KCSC_KEY") # .secrets.local.md 또는 환경변수 KCSC_KEY
BASE = "https://kcsc.re.kr/OpenApi"
UA = {"User-Agent": "Mozilla/5.0"}
def api(path):
url = f"{BASE}/{path}{'&' if '?' in path else '?'}key={KEY}"
for k in range(3):
@@ -42,9 +51,11 @@ def api(path):
return None
time.sleep(2)
def safe(s):
return re.sub(r'[\\/:*?"<>|\n\r]', "_", s).strip().rstrip(".")
# ── HTML → Markdown ──
def cell_text(td):
t = re.sub(r"<br\s*/?>", " ", td)
@@ -53,6 +64,7 @@ def cell_text(td):
t = html.unescape(t)
return re.sub(r"\s+", " ", t).strip()
def table_to_md(tbl):
cap = ""
mcap = re.search(r"<caption[^>]*>(.*?)</caption>", tbl, re.S)
@@ -78,6 +90,7 @@ def table_to_md(tbl):
out.append("| " + " | ".join(esc(c) for c in r) + " |")
return "\n".join(out)
def content_to_md(c):
if not c:
return ""
@@ -86,7 +99,7 @@ def content_to_md(c):
parts = []
pos = 0
for m in re.finditer(r"<table.*?</table>", c, re.S):
pre = c[pos:m.start()]
pre = c[pos : m.start()]
pt = re.sub(r"<[^>]+>", "", pre)
pt = html.unescape(re.sub(r"\s+", " ", pt)).strip()
if pt:
@@ -105,9 +118,12 @@ def content_to_md(c):
t = re.sub(r"<[^>]+>", "", t)
return html.unescape(t).strip()
def viewer_to_md(doc):
out = [f"# KCS {doc['code']} {doc['name']}", ""]
out.append(f"> 버전 {doc.get('version','')} | 수정 {(doc.get('updateDate') or '')[:10]} | fullCode {doc.get('fullCode','')}")
out.append(
f"> 버전 {doc.get('version', '')} | 수정 {(doc.get('updateDate') or '')[:10]} | fullCode {doc.get('fullCode', '')}"
)
out.append(f"> 출처: https://kcsc.re.kr/OpenApi/CodeViewer/{doc['codeType']}/{doc['code']}")
out.append("")
last_head = ""
@@ -144,6 +160,7 @@ def viewer_to_md(doc):
md.append(l)
return "\n".join(md).strip() + "\n"
TARGETS = {
"콘크리트 표준시방서 (KCS 14 20 00)": ["1420"],
"도로공사 표준시방서 (KCS 44 00 00)": ["44"],
@@ -151,23 +168,33 @@ TARGETS = {
"건설공사 비탈면 표준시방서 (KCS 11 70 00)": ["117", "114030"],
}
def run():
codelist = api("CodeList")
if not codelist:
print("CodeList 실패"); return
(SC / "kcsc_codelist.json").write_text(json.dumps(codelist, ensure_ascii=False), encoding="utf-8")
print("CodeList 실패")
return
(SC / "kcsc_codelist.json").write_text(
json.dumps(codelist, ensure_ascii=False), encoding="utf-8"
)
kcs = [x for x in codelist if x["codeType"] == "KCS"]
print(f"CodeList {len(codelist)}건 (KCS {len(kcs)})")
summary = []
for folder_name, prefixes in TARGETS.items():
codes = sorted({x["code"]: x for x in kcs
if any(x["code"].startswith(p) for p in prefixes)}.items())
codes = sorted(
{x["code"]: x for x in kcs if any(x["code"].startswith(p) for p in prefixes)}.items()
)
folder = ROOT / safe(folder_name) / "KCS"
folder.mkdir(parents=True, exist_ok=True)
idx = [f"# {folder_name} — KCS 코드 목록", "",
f"> 국가건설기준센터 OpenApi 수집. 총 {len(codes)}개 코드.", "",
"| 코드 | 이름 | 버전 | md |", "|---|---|---|---|"]
idx = [
f"# {folder_name} — KCS 코드 목록",
"",
f"> 국가건설기준센터 OpenApi 수집. 총 {len(codes)}개 코드.",
"",
"| 코드 | 이름 | 버전 | md |",
"|---|---|---|---|",
]
ok = 0
for code, meta in codes:
doc = api(f"CodeViewer/KCS/{code}")
@@ -177,9 +204,9 @@ def run():
d = doc[0]
fn = safe(f"{code}_{d['name']}") + ".md"
(folder / fn).write_text(viewer_to_md(d), encoding="utf-8")
idx.append(f"| KCS {code} | {d['name']} | {d.get('version','')} | [{fn}](<{fn}>) |")
idx.append(f"| KCS {code} | {d['name']} | {d.get('version', '')} | [{fn}](<{fn}>) |")
ok += 1
print(f" KCS {code} {d['name'][:30]} ({len(d.get('list',[]))}절)", flush=True)
print(f" KCS {code} {d['name'][:30]} ({len(d.get('list', []))}절)", flush=True)
time.sleep(0.4)
(folder / "_목록.md").write_text("\n".join(idx) + "\n", encoding="utf-8")
summary.append((folder_name, len(codes), ok))
@@ -189,5 +216,6 @@ def run():
for n, t, o in summary:
print(f" {o}/{t} {n}")
if __name__ == "__main__":
run()
@@ -1,29 +1,38 @@
# -*- coding: utf-8 -*-
"""e나라 표준인증에서 KS 표준 메타데이터·개정이력 수집 (원문은 DRM 열람 전용이라 미수집)."""
import json, re, time, urllib.parse, urllib.request
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)
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()
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)
if m:
return m.group(1)
return ""
ROOT = Path(str(ROOT_DIR / "KS"))
SC = Path(str(DATA_DIR))
BASE = "https://www.standard.go.kr/KSCI/standardIntro/getStandardSearchView.do"
UA = {"User-Agent": "Mozilla/5.0"}
def fetch(ks):
url = f"{BASE}?menuId=919&topMenuId=502&upperMenuId=503&ksNo={urllib.parse.quote(ks)}"
for k in range(3):
@@ -36,6 +45,7 @@ def fetch(ks):
return None
time.sleep(2)
def flatten(h):
h = re.sub(r"<script.*?</script>", "", h, flags=re.S)
h = re.sub(r"<style.*?</style>", "", h, flags=re.S)
@@ -46,14 +56,16 @@ def flatten(h):
t = re.sub(r"(\|\s*)+", "|", t)
return t
def field(t, key, stop=("|",)):
m = re.search(r"\|" + re.escape(key) + r"\|+([^|]*)", t)
return m.group(1).strip() if m else ""
def parse(ks, h):
t = flatten(h)
i = t.find("|기본정보|")
seg = t[i:i + 4000] if i > 0 else t
seg = t[i : i + 4000] if i > 0 else t
d = {
"표준번호": field(seg, "표준번호") or ks,
"표준명": field(seg, "표준명(한글)"),
@@ -71,13 +83,26 @@ def parse(ks, h):
hist = []
j = t.find("표준 이력사항")
if j > 0:
seg2 = t[j:j + 6000]
for m in re.finditer(r"\|변경일자\|([0-9\-]{8,10})\s*\|구분\|([^|]*)\|고시번호\|([^|]*)", seg2):
hist.append({"일자": m.group(1).strip(), "구분": m.group(2).strip(), "고시번호": m.group(3).strip()})
seg2 = t[j : j + 6000]
for m in re.finditer(
r"\|변경일자\|([0-9\-]{8,10})\s*\|구분\|([^|]*)\|고시번호\|([^|]*)", seg2
):
hist.append(
{
"일자": m.group(1).strip(),
"구분": m.group(2).strip(),
"고시번호": m.group(3).strip(),
}
)
d["이력"] = hist
d["상태"] = "폐지" if any(x["구분"] == "폐지" for x in hist) else ("현행" if d["표준명"] else "확인필요")
d["상태"] = (
"폐지"
if any(x["구분"] == "폐지" for x in hist)
else ("현행" if d["표준명"] else "확인필요")
)
return d
CODES = json.load(open(SC / "ks_codes.json", encoding="utf-8"))
ROOT.mkdir(parents=True, exist_ok=True)
res = []
@@ -90,7 +115,10 @@ for i, ks in enumerate(CODES, 1):
d = parse(ks, h)
d["조회번호"] = key
res.append(d)
print(f"[{i}/{len(CODES)}] {ks} :: {d['상태']} | {d['표준명'][:34]} | 개정 {d['최종개정확인일']} | 이력 {len(d['이력'])}", flush=True)
print(
f"[{i}/{len(CODES)}] {ks} :: {d['상태']} | {d['표준명'][:34]} | 개정 {d['최종개정확인일']} | 이력 {len(d['이력'])}",
flush=True,
)
time.sleep(0.6)
json.dump(res, open(SC / "ks_meta.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
@@ -1,20 +1,28 @@
# -*- coding: utf-8 -*-
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)
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()
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)
if m:
return m.group(1)
return ""
"""HWP5(OLE) 본문 텍스트 추출 — 순수 파이썬.
BodyText/Section* 스트림을 (필요시 raw-deflate 해제) 레코드 파싱해
@@ -25,10 +33,11 @@ import olefile
HWPTAG_BEGIN = 0x10
HWPTAG_PARA_HEADER = HWPTAG_BEGIN + 50 # 0x42
HWPTAG_PARA_TEXT = HWPTAG_BEGIN + 51 # 0x43
HWPTAG_PARA_TEXT = HWPTAG_BEGIN + 51 # 0x43
HWPTAG_CTRL_HEADER = HWPTAG_BEGIN + 55 # 0x47
HWPTAG_LIST_HEADER = HWPTAG_BEGIN + 56 # 0x48
HWPTAG_TABLE = HWPTAG_BEGIN + 61 # 0x4d
HWPTAG_TABLE = HWPTAG_BEGIN + 61 # 0x4d
def is_compressed(ole):
with ole.openstream("FileHeader") as f:
@@ -37,35 +46,38 @@ def is_compressed(ole):
flags = struct.unpack("<I", data[36:40])[0]
return bool(flags & 1)
def records(buf):
i, n = 0, len(buf)
while i + 4 <= n:
header = struct.unpack("<I", buf[i:i+4])[0]
header = struct.unpack("<I", buf[i : i + 4])[0]
i += 4
tag = header & 0x3FF
level = (header >> 10) & 0x3FF
size = (header >> 20) & 0xFFF
if size == 0xFFF:
size = struct.unpack("<I", buf[i:i+4])[0]
size = struct.unpack("<I", buf[i : i + 4])[0]
i += 4
yield tag, level, buf[i:i+size]
yield tag, level, buf[i : i + size]
i += size
# 인라인 확장 제어문자(뒤에 14 WCHAR = 28바이트 추가로 따라옴)
EXT_CTRL = {1, 2, 3, 4, 5, 6, 7, 8, 9, 11, 12, 14, 15, 16, 17, 18, 21, 22, 23}
# 인라인 문자 제어(그 자체로 1 WCHAR)
INLINE = {0, 10, 13, 24, 25, 26, 27, 28, 29, 30, 31}
def para_text(data):
out = []
i, n = 0, len(data)
while i + 1 < n:
code = struct.unpack("<H", data[i:i+2])[0]
code = struct.unpack("<H", data[i : i + 2])[0]
if code in EXT_CTRL:
i += 16 * 2 # 제어문자 1 + 확장 14 + 종료 1 = 16 WCHAR
continue
if code in INLINE:
if code == 13: # 문단 구분? (para text엔 없음)
if code == 13: # 문단 구분? (para text엔 없음)
out.append("\n")
i += 2
continue
@@ -73,6 +85,7 @@ def para_text(data):
i += 2
return "".join(out)
def extract(path):
"""PARA_TEXT 문단만 평문 리스트로(하위호환)."""
ole = olefile.OleFileIO(path)
@@ -89,19 +102,21 @@ def extract(path):
ole.close()
return paras
def _table_md(cells, ncols):
"""셀 텍스트 리스트를 nCols 기준으로 md 표로."""
cells = [re.sub(r"\s+", " ", (c or "").replace("|", "/")).strip() for c in cells]
if ncols < 1 or len(cells) < ncols * 2:
# 표라기엔 빈약 → 줄 텍스트
return None
rows = [cells[i:i + ncols] for i in range(0, len(cells), ncols)]
rows = [cells[i : i + ncols] for i in range(0, len(cells), ncols)]
rows = [r + [""] * (ncols - len(r)) for r in rows]
out = ["| " + " | ".join(rows[0]) + " |", "|" + "|".join(["---"] * ncols) + "|"]
for r in rows[1:]:
out.append("| " + " | ".join(r) + " |")
return "\n".join(out)
def extract_items(path):
"""(kind, val) 아이템 리스트. kind='text'|'table'. 표를 복원한다."""
ole = olefile.OleFileIO(path)
@@ -160,6 +175,7 @@ def extract_items(path):
ole.close()
return items
if __name__ == "__main__":
paras = extract(sys.argv[1])
text = "\n".join(p for p in paras if p)
@@ -6,15 +6,18 @@ HWP→PDF 변환에서 공백이 소실된 첨부 md를 이 원본에서 재생
- 단일셀 표(글상자) → 인용블록(줄바꿈 보존)
- 번호체계(제N장 / N-N-N. / 1. / 가. / (1) / ①) → 헤딩·중첩 리스트
"""
import re, sys, zipfile
from pathlib import Path
import xml.etree.ElementTree as ET
NS = "{http://www.hancom.co.kr/hwpml/2011/paragraph}"
def _local(tag):
return tag.split("}")[-1]
def para_text(p):
buf = []
for el in p.iter():
@@ -27,6 +30,7 @@ def para_text(p):
buf.append("\n")
return "".join(buf)
def cell_paras(tc):
parts = []
for sub in tc.iter(f"{NS}p"):
@@ -35,9 +39,11 @@ def cell_paras(tc):
parts.append(s)
return parts
def cell_text(tc):
return " ".join(cell_paras(tc)).replace("|", "/")
def table_md(tbl):
"""(kind, value) 반환. kind = 'table' | 'box' | 'text'."""
rows = [tr.findall(f"{NS}tc") for tr in tbl.findall(f"{NS}tr")]
@@ -57,6 +63,7 @@ def table_md(tbl):
out.append("| " + " | ".join(c.replace("\n", "<br>") for c in r) + " |")
return ("table", "\n".join(out))
def walk(container, out):
for child in container:
if _local(child.tag) != "p":
@@ -68,6 +75,7 @@ def walk(container, out):
else:
out.append(("text", para_text(child)))
def extract(path):
z = zipfile.ZipFile(path)
secs = sorted(n for n in z.namelist() if re.search(r"Contents/section\d+\.xml$", n))
@@ -76,18 +84,20 @@ def extract(path):
walk(ET.fromstring(z.read(sec)), out)
return out
# ── 계층 마커: (정규식, 종류) — 리스트 깊이는 등장 순서 스택으로 결정 ──
CHAP = re.compile(r"^제\d+\s*장(\s|$)")
SECN = re.compile(r"^\d+-\d+(-\d+)?\.?(\s|$)") # 품셈 절/항 번호 (1-2, 1-2-3.)
SECN = re.compile(r"^\d+-\d+(-\d+)?\.?(\s|$)") # 품셈 절/항 번호 (1-2, 1-2-3.)
JO = re.compile(r"^제\d+조(의\d+)?\s*\(")
MARKERS = [
("num", re.compile(r"^(\d{1,2}\.)\s*(.*)$")),
("kor", re.compile(r"^([가-힣]\.)\s*(.*)$")),
("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")),
("circ", re.compile(r"^([①-⑳])\s*(.*)$")),
("dash", re.compile(r"^([-∙·○])\s+(.*)$")),
("num", re.compile(r"^(\d{1,2}\.)\s*(.*)$")),
("kor", re.compile(r"^([가-힣]\.)\s*(.*)$")),
("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")),
("circ", re.compile(r"^([①-⑳])\s*(.*)$")),
("dash", re.compile(r"^([-∙·○])\s+(.*)$")),
]
def marker(s):
for k, rx in MARKERS:
m = rx.match(s)
@@ -95,10 +105,11 @@ def marker(s):
return k, m.group(1), m.group(2)
return None, "", s
def structure(items, header):
"""(kind, val) 아이템 리스트 → 구조화 md 라인. hwpx/hwp5 공용."""
lines = list(header)
stack = [] # 리스트 마커 종류 스택
stack = [] # 리스트 마커 종류 스택
for kind, val in items:
if kind == "table":
lines += ["", val, ""]
@@ -117,21 +128,26 @@ def structure(items, header):
continue
# 헤딩류
if CHAP.match(st):
lines += ["", f"## {st}", ""]; stack = []; continue
lines += ["", f"## {st}", ""]
stack = []
continue
if SECN.match(st):
lines += ["", f"### {st}", ""]; stack = []; continue
lines += ["", f"### {st}", ""]
stack = []
continue
if JO.match(st):
m = re.match(r"^(제\d+조(?:의\d+)?\s*\([^)]*\))\s*(.*)$", st, re.S)
lines += ["", f"### {m.group(1)}", ""]
if m.group(2).strip():
lines.append(m.group(2).strip())
stack = []; continue
stack = []
continue
# 리스트 마커
k, mk, rest = marker(st)
if k:
if k in stack:
depth = stack.index(k)
del stack[depth + 1:]
del stack[depth + 1 :]
else:
stack.append(k)
depth = len(stack) - 1
@@ -159,19 +175,23 @@ def structure(items, header):
out = out.encode("utf-8", "ignore").decode("utf-8")
return out
def to_md(path):
items = extract(path)
header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWPX 재추출)", ""]
return structure(items, header)
def hwp5_to_md(path, header=None):
"""구형 HWP5(OLE) → 구조화 md. 표를 복원(extract_items)해 계층·표 보존."""
import hwp5_text
items = hwp5_text.extract_items(str(path))
if header is None:
header = [f"# {Path(path).stem}", "", f"> 원본: `{Path(path).name}` (HWP 재추출)", ""]
return structure(items, header)
if __name__ == "__main__":
for f in sys.argv[1:]:
p = Path(f)
@@ -4,6 +4,7 @@
법률/행정규칙/표준시방서/<명칭>/ 하위의 본문·별표·첨부·압축해제·KCS md를 전부 링크.
최상위 `0. 참조 법령·기준 목록.md` 의 명칭이 이 _index.md 로 연결된다(gen_md).
"""
import re, sys
from pathlib import Path
@@ -11,9 +12,11 @@ ROOT = Path(__file__).resolve().parent.parent
CATS = ["법률", "행정규칙", "표준시방서"]
SKIP = {"_index.md", "_목록.md"}
def rel(p, base):
return p.relative_to(base).as_posix()
def build(folder):
name = folder.name
lines = [f"# {name} — 문서 목록", ""]
@@ -24,7 +27,9 @@ def build(folder):
lines.append("## 본문")
lines.append("")
for p in top:
label = {"_meta": "메타정보", "CHANGELOG": "변경이력"}.get(p.stem, p.stem.replace("_", " "))
label = {"_meta": "메타정보", "CHANGELOG": "변경이력"}.get(
p.stem, p.stem.replace("_", " ")
)
lines.append(f"- [{label}](<{p.name}>)")
lines.append("")
@@ -70,7 +75,9 @@ def build(folder):
if grp:
lines.append(f" - **{grp}**")
for p in tree[grp]:
lines.append(f" - [{p.stem}](<첨부/{zd.name}/{p.relative_to(zd).as_posix()}>)")
lines.append(
f" - [{p.stem}](<첨부/{zd.name}/{p.relative_to(zd).as_posix()}>)"
)
else:
for p in tree[grp]:
lines.append(f" - [{p.stem}](<첨부/{zd.name}/{p.name}>)")
@@ -86,12 +93,21 @@ def build(folder):
lines.append(f"- [{p.stem}](<KCS/{p.name}>)")
lines.append("")
total = len(top) + len(byl) + len(att) + len(kcs) + sum(
len([p for p in zd.rglob("*.md") if p.name not in SKIP]) for zd in zdirs)
lines.insert(1, f"> 총 {total}건 (본문 {len(top)} · 별표 {len(byl)} · 첨부 {len(att)} · 압축 {total-len(top)-len(byl)-len(att)-len(kcs)} · KCS {len(kcs)})")
total = (
len(top)
+ len(byl)
+ len(att)
+ len(kcs)
+ sum(len([p for p in zd.rglob("*.md") if p.name not in SKIP]) for zd in zdirs)
)
lines.insert(
1,
f"> 총 {total}건 (본문 {len(top)} · 별표 {len(byl)} · 첨부 {len(att)} · 압축 {total - len(top) - len(byl) - len(att) - len(kcs)} · KCS {len(kcs)})",
)
(folder / "_index.md").write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8")
return total
if __name__ == "__main__":
n = 0
for cat in CATS:
@@ -4,6 +4,7 @@
각 이미지: 미리보기 링크 · 크기 · 형식 · 소속(명칭) · 참조 md 링크.
사용자가 표로 옮길 이미지를 직접 판단한다.
"""
import re
from pathlib import Path
from PIL import Image
@@ -11,6 +12,7 @@ from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "_이미지 목록(표 변환 검토용).md"
def _cand(p):
try:
w, h = Image.open(p).size
@@ -18,6 +20,7 @@ def _cand(p):
except Exception:
return False
def build():
# 1) 이미지 → 참조 md 역매핑
ref = {}
@@ -26,8 +29,11 @@ def build():
continue
t = m.read_text(encoding="utf-8")
# 경로에 괄호가 있어도 <...> 안이면 확장자까지 잡는다
for mm in re.finditer(r'!\[[^\]]*\]\(<([^>]+\.(?:png|jpg|jpeg|gif|bmp))>\)'
r'|!\[[^\]]*\]\(([^)\s]+\.(?:png|jpg|jpeg|gif|bmp))\)', t):
for mm in re.finditer(
r"!\[[^\]]*\]\(<([^>]+\.(?:png|jpg|jpeg|gif|bmp))>\)"
r"|!\[[^\]]*\]\(([^)\s]+\.(?:png|jpg|jpeg|gif|bmp))\)",
t,
):
path = mm.group(1) or mm.group(2)
img = (m.parent / path).resolve()
ref.setdefault(str(img), []).append(m)
@@ -37,7 +43,7 @@ def build():
groups = {}
for p in imgs:
rel = p.relative_to(ROOT)
parts = rel.parts # 분류/명칭/pic/파일 또는 분류/명칭/첨부/[압축]…/pic/…
parts = rel.parts # 분류/명칭/pic/파일 또는 분류/명칭/첨부/[압축]…/pic/…
cat = parts[0]
name = parts[1] if len(parts) > 2 else "(기타)"
groups.setdefault((cat, name), []).append(p)
@@ -53,31 +59,49 @@ def build():
seen, uniq = set(), []
for m in ref.get(str(p.resolve()), []):
if m not in seen:
seen.add(m); uniq.append(m)
rlinks = " · ".join(f"[{m.stem[:18]}](<{m.relative_to(OUT.parent).as_posix()}>)"
for m in uniq[:2]) if uniq else "_미참조_"
seen.add(m)
uniq.append(m)
rlinks = (
" · ".join(
f"[{m.stem[:18]}](<{m.relative_to(OUT.parent).as_posix()}>)" for m in uniq[:2]
)
if uniq
else "_미참조_"
)
return f"| ☐ | {idx} | [{p.name[:40]}](<{rel}>) | {size} | {fmt} | {rlinks} |"
cand = [p for p in imgs if _cand(p)]
rest = [p for p in imgs if not _cand(p)]
L = ["# 이미지 목록 — 표 변환 검토용", "",
f"> pic/ 이미지 전건 **{len(imgs)}개**. 각 이미지를 열어 **표로 옮길지** `☐` 열에 체크(→ `☑`)한다.",
"> 체크한 이미지를 알려주면 md 표로 옮기고 이미지는 대조용으로 병기한다.", "",
f"## ★ 표 후보 (가로형 {len(cand)}개) — 우선 검토", "",
"> 셀 경계가 뚜렷한 가로형. 표일 가능성 높음(단, 수식·표시·도형 섞여 있으니 실제로 열어 확인).", "",
"| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |",
"|:-:|---:|---|---|---|---|"]
L = [
"# 이미지 목록 — 표 변환 검토용",
"",
f"> pic/ 이미지 전건 **{len(imgs)}개**. 각 이미지를 열어 **표로 옮길지** `☐` 열에 체크(→ `☑`)한다.",
"> 체크한 이미지를 알려주면 md 표로 옮기고 이미지는 대조용으로 병기한다.",
"",
f"## ★ 표 후보 (가로형 {len(cand)}개) — 우선 검토",
"",
"> 셀 경계가 뚜렷한 가로형. 표일 가능성 높음(단, 수식·표시·도형 섞여 있으니 실제로 열어 확인).",
"",
"| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |",
"|:-:|---:|---|---|---|---|",
]
for i, p in enumerate(sorted(cand, key=lambda x: -Image.open(x).size[0]), 1):
L.append(row(i, p))
L += ["", f"## 그 외 이미지 ({len(rest)}개)", "",
"> 대부분 로고·점·수식·표시·도형. 표 가능성 낮으나 필요시 검토.", "",
"| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |",
"|:-:|---:|---|---|---|---|"]
L += [
"",
f"## 그 외 이미지 ({len(rest)}개)",
"",
"> 대부분 로고·점·수식·표시·도형. 표 가능성 낮으나 필요시 검토.",
"",
"| 반영 | # | 이미지 | 크기 | 형식 | 참조 문서 |",
"|:-:|---:|---|---|---|---|",
]
for i, p in enumerate(sorted(rest), len(cand) + 1):
L.append(row(i, p))
OUT.write_text("\n".join(L) + "\n", encoding="utf-8")
return len(imgs)
if __name__ == "__main__":
n = build()
print(f"이미지 목록 {n}개 → {OUT.name}")
@@ -1,14 +1,20 @@
# -*- coding: utf-8 -*-
"""[압축] 폴더마다 _목록.md 생성 — 내부 HWP→md 파일 트리 색인."""
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
def build(folder):
mds = sorted(p for p in folder.rglob("*.md") if p.name != "_목록.md")
lines = [f"# {folder.name} — 압축 해제 문서 목록", "",
f"> 원본 zip을 해제해 HWP를 md로 변환. 총 {len(mds)}건.", ""]
lines = [
f"# {folder.name} — 압축 해제 문서 목록",
"",
f"> 원본 zip을 해제해 HWP를 md로 변환. 총 {len(mds)}건.",
"",
]
# 하위 폴더 구조 반영
tree = {}
for m in mds:
@@ -27,6 +33,7 @@ def build(folder):
(folder / "_목록.md").write_text("\n".join(lines), encoding="utf-8")
return len(mds)
if __name__ == "__main__":
n = 0
for folder in ROOT.rglob("[[]압축[]]*"):
@@ -5,42 +5,52 @@
- 본문은 법령 번호체계(Ⅰ./1./가./(1)/(가)/1)/가)/①) 기준으로 중첩 리스트화
- PDF 줄바꿈은 꼬리 공백을 신뢰해 그대로 이어붙임 (한글 어절 분리 방지)
"""
import re, sys, 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)
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()
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)
if m:
return m.group(1)
return ""
import pymupdf
ROOT = Path(str(ROOT_DIR))
# ── 마커 정의 (우선순위 순, 같은 종류끼리 같은 깊이) ──
MARKERS = [
("roman", re.compile(r"^([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\.)\s*(.*)$")),
("num", re.compile(r"^(\d{1,2}\.)\s+(.*)$")),
("kor", re.compile(r"^([가-힣]\.)\s+(.*)$")),
("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")),
("pkor", re.compile(r"^(\([가-힣]\))\s*(.*)$")),
("numb", re.compile(r"^(\d{1,2}\))\s*(.*)$")),
("korb", re.compile(r"^([가-힣]\))\s*(.*)$")),
("roman", re.compile(r"^([ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]+\.)\s*(.*)$")),
("num", re.compile(r"^(\d{1,2}\.)\s+(.*)$")),
("kor", re.compile(r"^([가-힣]\.)\s+(.*)$")),
("pnum", re.compile(r"^(\(\d{1,2}\))\s*(.*)$")),
("pkor", re.compile(r"^(\([가-힣]\))\s*(.*)$")),
("numb", re.compile(r"^(\d{1,2}\))\s*(.*)$")),
("korb", re.compile(r"^([가-힣]\))\s*(.*)$")),
("circle", re.compile(r"^([①-⑳])\s*(.*)$")),
("dash", re.compile(r"^([-‐–ㆍ·○□])\s+(.*)$")),
("dash", re.compile(r"^([-‐–ㆍ·○□])\s+(.*)$")),
]
HEAD = re.compile(r"^■\s*(.+?)\s*\[(별표|별지)\s*([^\]]*)\]\s*(<[^>]*>)?\s*$")
def match_marker(s):
for kind, rx in MARKERS:
m = rx.match(s)
@@ -48,6 +58,7 @@ def match_marker(s):
return kind, m.group(1), m.group(2)
return None, "", s
# ── 페이지 → (요소 리스트) ──
def _lines(page):
out = []
@@ -60,13 +71,17 @@ def _lines(page):
out.append((pymupdf.Rect(ln["bbox"]), txt))
return out
def _nk(s):
return re.sub(r"[^가-힣0-9A-Za-z%]", "", s)
def safe(s):
return re.sub(r'[\\/:*?"<>|\s]+', "_", s).strip("_")
MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘·구분선 등)
MIN_IMG = 40 # 이 픽셀보다 작은 이미지는 무시(안내문 아이콘·구분선 등)
def _images(page, pno, doc, picdir, stem):
"""페이지 이미지를 pic/에 저장하고 (rect, ref) 리스트 반환."""
@@ -84,16 +99,17 @@ def _images(page, pno, doc, picdir, stem):
continue
idx += 1
picdir.mkdir(parents=True, exist_ok=True)
fn = f"{stem}_p{pno+1}_{idx}.png"
fn = f"{stem}_p{pno + 1}_{idx}.png"
try:
if px.n - px.alpha >= 4: # CMYK 등 → RGB
if px.n - px.alpha >= 4: # CMYK 등 → RGB
px = pymupdf.Pixmap(pymupdf.csRGB, px)
px.save(str(picdir / fn))
except Exception:
continue
out.append((r, f"![그림 {pno+1}-{idx}](<../pic/{fn}>)"))
out.append((r, f"![그림 {pno + 1}-{idx}](<../pic/{fn}>)"))
return out
def page_elements(page, pno=0, doc=None, picdir=None, stem=""):
"""세로 순서대로 ('text', y, x, 문자열) / ('table', y, x, md) / ('image', y, x, ref) 반환.
@@ -112,15 +128,18 @@ def page_elements(page, pno=0, doc=None, picdir=None, stem=""):
if not md:
continue
b = pymupdf.Rect(t.bbox)
inside = "".join(_nk(txt) for r, txt in lines
if b.contains(pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)))
inside = "".join(
_nk(txt)
for r, txt in lines
if b.contains(pymupdf.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2))
)
if not inside:
continue
got = _nk(md)
hit = sum(1 for ch in set(inside) if ch in got)
cov = len(_nk(md)) / len(inside) if inside else 0
if cov < 0.90 or hit < len(set(inside)) * 0.95:
continue # 표 변환이 원문을 다 못 담음 → 텍스트로 유지
continue # 표 변환이 원문을 다 못 담음 → 텍스트로 유지
final.append((b, md))
boxes = [b for b, _ in final]
@@ -138,6 +157,7 @@ def page_elements(page, pno=0, doc=None, picdir=None, stem=""):
items.sort(key=lambda x: (round(x[1], 1), x[2]))
return items
def table_md(t):
try:
rows = t.extract()
@@ -152,8 +172,12 @@ def table_md(t):
if len(cnts) == 1 and cnts and max(cnts) > 1:
n = max(cnts)
for i in range(n):
split.append([(p[i].strip() if len(p) == n else (r[j] if i == 0 else ""))
for j, p in enumerate(parts)])
split.append(
[
(p[i].strip() if len(p) == n else (r[j] if i == 0 else ""))
for j, p in enumerate(parts)
]
)
else:
split.append(r)
rows = [[re.sub(r"\s+", " ", c).strip() for c in r] for r in split]
@@ -163,19 +187,19 @@ def table_md(t):
w = max(len(r) for r in rows)
rows = [r + [""] * (w - len(r)) for r in rows]
esc = lambda c: c.replace("|", "\\|").replace("\n", "<br>")
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |",
"|" + "|".join(["---"] * w) + "|"]
out = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "|".join(["---"] * w) + "|"]
for r in rows[1:]:
out.append("| " + " | ".join(esc(c) for c in r) + " |")
return "\n".join(out)
# ── 변환 본체 ──
def convert(pdf_path):
doc = pymupdf.open(pdf_path)
header, title = "", ""
body = [] # (depth, marker, text) | ("TABLE", md)
stack = [] # 마커 종류 스택 (index = depth)
cur = None # 현재 항목 dict
body = [] # (depth, marker, text) | ("TABLE", md)
stack = [] # 마커 종류 스택 (index = depth)
cur = None # 현재 항목 dict
first_lines = []
def flush():
@@ -207,7 +231,7 @@ def convert(pdf_path):
# PyMuPDF는 구조적 줄의 들여쓰기를 텍스트 안에 넣고 x0=좌측여백으로 둔다.
# x0가 좌측여백보다 큰 줄 = 앞줄에서 넘어온 줄바꿈 조각.
wrap = x0 > base_x + 2.0
raw = el[3] # 꼬리 공백 보존 (한글 줄바꿈 이어붙이기 판단용)
raw = el[3] # 꼬리 공백 보존 (한글 줄바꿈 이어붙이기 판단용)
s = raw.strip()
if not s:
continue
@@ -225,23 +249,28 @@ def convert(pdf_path):
if kind:
if kind in stack:
depth = stack.index(kind)
del stack[depth + 1:]
del stack[depth + 1 :]
else:
stack.append(kind)
depth = len(stack) - 1
flush()
cur = {"kind": "item", "depth": depth, "marker": mk,
"head": rest.rstrip("\n"), "body": ""}
cur = {
"kind": "item",
"depth": depth,
"marker": mk,
"head": rest.rstrip("\n"),
"body": "",
}
else:
if cur is None:
cur = {"kind": "item", "depth": 0, "marker": "", "head": "", "body": ""}
piece = raw.lstrip().rstrip("\n") # 앞 들여쓰기만 제거, 꼬리 공백 유지
if wrap: # 좌측 여백까지 붙은 줄 = 앞줄의 이어짐
piece = raw.lstrip().rstrip("\n") # 앞 들여쓰기만 제거, 꼬리 공백 유지
if wrap: # 좌측 여백까지 붙은 줄 = 앞줄의 이어짐
if cur["body"]:
cur["body"] += piece
else:
cur["head"] += piece
else: # 들여쓴 줄 = 새 본문 문단
else: # 들여쓴 줄 = 새 본문 문단
if cur["body"]:
cur["body"] += "\n\n" + piece
else:
@@ -291,10 +320,13 @@ def convert(pdf_path):
md.append(l)
return "\n".join(md).strip() + "\n"
if __name__ == "__main__":
targets = sys.argv[1:]
if not targets:
targets = [str(p) for p in ROOT.rglob("별표/*.pdf")] + [str(p) for p in ROOT.rglob("첨부/*.pdf")]
targets = [str(p) for p in ROOT.rglob("별표/*.pdf")] + [
str(p) for p in ROOT.rglob("첨부/*.pdf")
]
ok = fail = 0
for f in targets:
p = Path(f)
@@ -5,6 +5,7 @@
소스 대조: 별표·첨부 md ↔ 같은 이름 PDF, 현행 본문 md ↔ 같은 이름 XML.
결과를 qc_report.json 으로 저장하고 카테고리별 요약 출력.
"""
import json, re
from pathlib import Path
import pymupdf
@@ -12,9 +13,11 @@ import pymupdf
ROOT = Path(__file__).resolve().parent.parent
OUT = Path(__file__).resolve().parent / "data"
def norm(s):
return re.sub(r"[^가-힣0-9A-Za-z%㎞㎡㎥℃]", "", s)
def strip_fenced(text):
"""``` 코드펜스 안을 빈 줄로 치환(위치 보존)."""
out, infence = [], False
@@ -26,6 +29,7 @@ def strip_fenced(text):
out.append("" if infence else l)
return chr(10).join(out)
def ncols(line):
s = line.strip()
if s.startswith("|"):
@@ -34,6 +38,7 @@ def ncols(line):
s = s[:-1]
return len(s.split("|"))
def check_tables(text):
"""마크다운 표 유효성. (문제 리스트) 반환."""
issues = []
@@ -49,16 +54,19 @@ def check_tables(text):
block.append(lines[i])
i += 1
head = ncols(block[0])
if len(block) < 2 or not set(block[1].replace("|", "").replace(" ", "").replace(":", "")) <= set("-"):
issues.append(f"L{start+1} 구분선 없음/이상")
if len(block) < 2 or not set(
block[1].replace("|", "").replace(" ", "").replace(":", "")
) <= set("-"):
issues.append(f"L{start + 1} 구분선 없음/이상")
continue
for j, b in enumerate(block):
if j == 1:
continue
if ncols(b) != head:
issues.append(f"L{start+j+1} 열수 {ncols(b)}≠{head}")
issues.append(f"L{start + j + 1} 열수 {ncols(b)}≠{head}")
return issues
def check_space(text):
"""프로즈(표·펜스 제외)의 한글 12자 이상 연속 비율."""
prose = [l for l in strip_fenced(text).split(chr(10)) if not l.lstrip().startswith("|")]
@@ -69,24 +77,33 @@ def check_space(text):
runs = re.findall(r"[가-힣]{12,}", t)
return round(sum(len(x) for x in runs) / kor, 3)
def check_linebreak(text):
"""줄바꿈 결함: 표 앞 빈 줄 없음, 헤딩 직후 표 붙음."""
issues = []
lines = strip_fenced(text).split("\n")
for i in range(1, len(lines)):
s = lines[i].strip()
prev = lines[i-1].strip()
prev = lines[i - 1].strip()
# 표 시작인데 앞 줄이 텍스트(표/빈줄/헤딩 아님)
if s.startswith("|") and prev and not prev.startswith("|") and not prev.startswith("#") and not prev.startswith(">"):
issues.append(f"L{i+1} 표 앞 빈 줄 없음")
if (
s.startswith("|")
and prev
and not prev.startswith("|")
and not prev.startswith("#")
and not prev.startswith(">")
):
issues.append(f"L{i + 1} 표 앞 빈 줄 없음")
return issues[:5]
def pdf_stats(pdf):
d = pymupdf.open(pdf)
txt = "\n".join(p.get_text() for p in d)
imgs = sum(len(p.get_images()) for p in d)
return txt, imgs
def run():
report = []
targets = []
@@ -120,7 +137,7 @@ def run():
ptxt, pimgs = pdf_stats(pdf)
pn, mn = norm(ptxt), norm(text)
if pn and len(mn) / len(pn) < 0.98:
rec["issues"]["내용누락"] = f"{len(pn)}→{len(mn)} ({len(mn)/len(pn):.2f})"
rec["issues"]["내용누락"] = f"{len(pn)}→{len(mn)} ({len(mn) / len(pn):.2f})"
mimg = text.count("〔그림〕") + text.count("![")
if pimgs > 0 and mimg == 0:
rec["issues"]["사진누락"] = f"PDF 이미지 {pimgs}개 / md 0"
@@ -130,7 +147,9 @@ def run():
if rec["issues"]:
report.append(rec)
json.dump(report, open(OUT / "qc_report.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1)
json.dump(
report, open(OUT / "qc_report.json", "w", encoding="utf-8"), ensure_ascii=False, indent=1
)
# 요약
cat = {}
@@ -142,8 +161,11 @@ def run():
print("\n=== 심각(내용누락·사진누락·테이블) 상위 ===")
sev = [r for r in report if set(r["issues"]) & {"내용누락", "사진누락", "테이블"}]
for r in sev[:30]:
ks = ", ".join(f"{k}={v if not isinstance(v,list) else len(v)}" for k, v in r["issues"].items())
ks = ", ".join(
f"{k}={v if not isinstance(v, list) else len(v)}" for k, v in r["issues"].items()
)
print(f" {r['file'][-64:]} [{ks}]")
if __name__ == "__main__":
run()
@@ -13,6 +13,7 @@
- 품셈: 부문 표제 라인·목차 구역은 자동 탐지하지만 결과 요약(장 수)이 목차와 일치하는지 확인
- 공통: 원문 PDF 프로즈는 어절 공백이 붙는 특성 있음(값·표는 정상) — W5 공백 기준 예외로 기록
"""
import re
import sys
from pathlib import Path
@@ -25,8 +26,13 @@ BASE = Path(__file__).resolve().parent.parent / "원가계산"
# ────────────────────────── 건협 노임 ──────────────────────────
CAK_DIR = "노임단가_건설업_대한건설협회"
CAK_STEM = "2026상반기_건설업_임금실태조사_대한건설협회"
PAGE_TABLE = (9, 13) # 0-based: 원문 p.10~13 = 개별직종 노임단가 표
CAK_CH = [("1. 조사개요", 1), ("2. 임금적용요령", 5), ("3. 개별직종 노임단가", 9), ("4. 직종해설", 13)]
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 |"
@@ -51,8 +57,10 @@ def parse_cak_table():
if code not in result and len(slots) == 4 and name:
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)]
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"
@@ -72,27 +80,36 @@ def split_cak():
if starts[0][1] is None:
starts[0] = (starts[0][0], 0)
assert all(s[1] is not None for s in starts), starts
hdr = (f"> 원문: {CAK_STEM}.pdf (대한건설협회, 공표 2025-12-31, 적용 2026-01-01)\n"
"> 변환: pdf2md + 표 정밀 파싱. ⚠ 본문 프로즈는 원문 PDF 특성상 어절 공백이 붙어 있음 — 값·표는 정상.\n\n")
hdr = (
f"> 원문: {CAK_STEM}.pdf (대한건설협회, 공표 2025-12-31, 적용 2026-01-01)\n"
"> 변환: pdf2md + 표 정밀 파싱. ⚠ 본문 프로즈는 원문 PDF 특성상 어절 공백이 붙어 있음 — 값·표는 정상.\n\n"
)
for i, (fname, st) in enumerate(starts):
en = starts[i + 1][1] if i + 1 < len(starts) else len(lines)
body = "\n".join(lines[st:en]).strip()
if "개별직종" in fname:
body = ("## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n"
f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n"
"> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 PDF 참조.\n"
"> **신뢰도** 열 = 원문이 직종번호 앞에 붙이는 기호 — `*` 조사현장 5개 미만(적용 시 유의), "
"`**` 미조사(임금적용요령 Ⅱ 참조). 빈칸 = 정상 공표.\n\n" + table)
body = (
"## Ⅲ. 개별직종 노임단가 (1일 8시간 기준, 원)\n\n"
f"> PDF 좌표·스트림 정밀 파싱으로 재구성 — {cnt}개 직종 전수, 최근 4개 공표일 병기.\n"
"> `-` = 해당 공표일 미공표(표본 부족·신설 등). 원문 각주는 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")
f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8"
)
print("wrote", fname)
# ────────────────────────── 중기중앙회 노임 ──────────────────────────
KBIZ_DIR = "노임단가_제조업_중소기업중앙회"
KBIZ_STEM = "2026상반기_중소제조업_직종별_임금조사_중소기업중앙회"
KBIZ_CH = [("1. 조사개요", 1), ("2. 조사결과 요약", 12), ("3. 직종별 조사노임", 18),
("4. 직종코드 및 직종명 해설", 30)]
KBIZ_CH = [
("1. 조사개요", 1),
("2. 조사결과 요약", 12),
("3. 직종별 조사노임", 18),
("4. 직종코드 및 직종명 해설", 30),
]
def split_kbiz():
@@ -102,13 +119,16 @@ def split_kbiz():
if starts[0][1] is None:
starts[0] = (starts[0][0], 0)
assert all(s[1] is not None for s in starts), starts
hdr = (f"> 원문: {KBIZ_STEM}.pdf (중소기업중앙회, 공표 2026-06-30, 적용 2026-07-01)\n"
"> 변환: pdf2md. 표·수치 정상. `*` = 표본 부족 미공표, `**` = 원문 각주 참조.\n\n")
hdr = (
f"> 원문: {KBIZ_STEM}.pdf (중소기업중앙회, 공표 2026-06-30, 적용 2026-07-01)\n"
"> 변환: pdf2md. 표·수치 정상. `*` = 표본 부족 미공표, `**` = 원문 각주 참조.\n\n"
)
for i, (fname, st) in enumerate(starts):
en = starts[i + 1][1] if i + 1 < len(starts) else len(lines)
body = "\n".join(lines[st:en]).strip()
(BASE / KBIZ_DIR / f"{fname}.md").write_text(
f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8")
f"# {fname.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8"
)
print("wrote", fname)
@@ -121,8 +141,13 @@ def split_pumsem():
lines = (BASE / PUM_DIR / f"{PUM_STEM}.md").read_text(encoding="utf-8").splitlines()
# 부문 표제 위치 자동 탐지 (짧은 단독 라인)
sec_names = [("01_공통부문", "공통부문"), ("02_토목부문", "토목부문"), ("03_건축부문", "건축부문"),
("04_기계설비부문", "기계설비부문"), ("05_유지관리부문", "유지관리부문")]
sec_names = [
("01_공통부문", "공통부문"),
("02_토목부문", "토목부문"),
("03_건축부문", "건축부문"),
("04_기계설비부문", "기계설비부문"),
("05_유지관리부문", "유지관리부문"),
]
hits = {}
for i, l in enumerate(lines):
s = re.sub(r"[\s#>\-·ㆍ]", "", l)
@@ -170,11 +195,14 @@ def split_pumsem():
outdir.mkdir(exist_ok=True)
for j, (n, nm, st) in enumerate(starts):
en = starts[j + 1][2] if j + 1 < len(starts) else b
hdr = (f"# {sec[3:]} 제{n}장 {nm}\n\n"
f"> 원문: {PUM_STEM}.pdf (국토교통부 공고, 2026년 적용) — pdf2md 변환\n"
"> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\n")
hdr = (
f"# {sec[3:]} 제{n}장 {nm}\n\n"
f"> 원문: {PUM_STEM}.pdf (국토교통부 공고, 2026년 적용) — pdf2md 변환\n"
"> ⚠ 장 경계는 표제 탐지 기준 — 앞뒤 1페이지 내외 겹침 가능. 수치 검증 시 원본 PDF 대조.\n\n"
)
(outdir / f"제{n}장_{nm}.md").write_text(
hdr + "\n".join(lines[st:en]).strip() + "\n", encoding="utf-8")
hdr + "\n".join(lines[st:en]).strip() + "\n", encoding="utf-8"
)
print(sec, f"{len(starts)}/{len(names)} 장")
@@ -1,33 +1,44 @@
# -*- coding: utf-8 -*-
"""PDF → md 변환 손실 검증: 정규화 문자 커버리지 + 줄 단위 누락 확인."""
import re
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)
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()
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)
if m:
return m.group(1)
return ""
import pymupdf
ROOT = Path(str(ROOT_DIR))
def norm(s):
return re.sub(r"[^가-힣0-9A-Za-z%㎞㎡㎥℃]", "", s)
bad, miss_lines, empty = [], [], []
tot = 0
for pdf in sorted(list(ROOT.rglob("별표/*.pdf"))+list(ROOT.rglob("첨부/*.pdf"))):
for pdf in sorted(list(ROOT.rglob("별표/*.pdf")) + list(ROOT.rglob("첨부/*.pdf"))):
md = pdf.with_suffix(".md")
if not md.exists():
bad.append((pdf.name, "md 없음", 0, 0))
@@ -47,7 +47,14 @@ def sheet_map(z):
for name, rid in RE_SHEET.findall(wb):
tgt = rels.get(rid)
if tgt:
out.append((name, "xl/" + tgt.lstrip("/").replace("worksheets/", "worksheets/") if not tgt.startswith("xl/") else tgt))
out.append(
(
name,
"xl/" + tgt.lstrip("/").replace("worksheets/", "worksheets/")
if not tgt.startswith("xl/")
else tgt,
)
)
return out
@@ -13,7 +13,9 @@ ROOT = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")
KNOW = os.path.normpath(os.path.join(ROOT, "knowledge"))
# 이 스크립트는 resources/knowledge/original/원가계산/STmate/_scripts/ 에 위치
# → knowledge 루트 = ../../../..
KNOW = os.path.normpath(os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", ".."))
KNOW = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", "..")
)
sys.stdout.reconfigure(encoding="utf-8")
@@ -42,9 +42,7 @@ def records(path, table):
def mode_key(recs, width):
"""열별 최빈 바이트 = 키스트림 후보 (공백 암호문 가정)"""
return bytes(
Counter(r[j] for r in recs).most_common(1)[0][0] for j in range(width)
)
return bytes(Counter(r[j] for r in recs).most_common(1)[0][0] for j in range(width))
def probe(table, clip=None):
@@ -62,9 +60,7 @@ def probe(table, clip=None):
for name, key, nrec in keys:
n = min(len(base_key), len(key))
same = sum(1 for a, b in zip(base_key[:n], key[:n]) if a == b)
print(
f" {name[:44]:46s} rec={nrec:4d} 일치 {same}/{n} ({round(same / n * 100)}%)"
)
print(f" {name[:44]:46s} rec={nrec:4d} 일치 {same}/{n} ({round(same / n * 100)}%)")
print()