auto: 2026-09-02 16:48 (EOMSANGDON-HOME)
This commit is contained in:
@@ -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/."""
|
||||
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""
|
||||
|
||||
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""))
|
||||
out.append((r, f""))
|
||||
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()
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user