knowledge(노임): 제조노임 옛 회차 2020.7 ~ 2026.1 원문 11회 수집 · 회차별 완전 md 화 · 미공표 16 직종 추적표

- 원문 = 중소기업중앙회 「중소제조업 직종별 임금조사 보고서」 2020 상반기 ~ 2025 하반기 (PDF 10 · HWP 1 · 2022 상반기 HWP 병행 · 직종명 대조 HWP 2) — 적용일 폴더별 보관
- 회차마다 전체본 md + 장별 md 4개 · 글자층 쪽별 글자 대조 · 조사노임표 129~130 직종 PDF 단어 좌표 재독 대조 · 회차 사이 같은 조사월 값 대조(숫자 다른 칸 0) · 수식 · 입력화면 그림 쪽 그림 판독
- 조사되지 않은(*) 직종 적용 규정 = 12 회차 원문 어디에도 없음 (기호 뜻만) — 미공표직종_추적.md 1절에 문장 그대로
- 미공표직종_추적.md = 지금 판 값 없는 16 직종 · 마지막 공표값 · 전체 평균 이음 · 산정값(원 미만 처리 없음, 참고 계산)
- 직종코드_변천_대조표.md = 2024.10 적용 회차 코드 재정렬(74 직종) 포함 회차별 코드
- 도구 = _pipeline/kbiz_report_md.py · kbiz_verify.py · kbiz_track_unpublished.py
- _meta.md 에 공표 기관 · 문서 이름 · 조사 기준월 · 공표일 · 적용일 · 받은 주소 · 받은 날 · 승인번호
- 마스터는 건드리지 않음

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1MKKZKpUHTPKb513FneU8
This commit is contained in:
2026-09-20 18:06:13 +09:00
co-authored by Claude Sonnet 5
parent ce38094e91
commit ebc8f470e0
77 changed files with 34995 additions and 0 deletions
@@ -0,0 +1,569 @@
# -*- coding: utf-8 -*-
"""중소제조업 직종별 임금조사 보고서(PDF) → 전체본 md + 장별 md 4개.
사용: python kbiz_report_md.py <회차 폴더> [...]
회차 폴더 = 원가계산/노임단가_제조업_중소기업중앙회/<적용일>/ (PDF 한 개 · 「…보고서.pdf」)
- 글은 PDF 글자층의 시각 줄 그대로(줄바꿈 보존). 표는 pymupdf find_tables 칸 그대로 — 병합 칸은 빈 칸.
- 가로 2쪽 모아찍기(가로 841×595) PDF 는 좌우 반쪽으로 잘라 한 쪽씩 읽음(글자층 그대로 · 원문 PDF 는 안 건드림).
- 쪽마다 인쇄 쪽 번호(「- N -」)를 머리로 붙임.
- 검증: 쪽마다 원문 글자층 글자 집합(공백·쪽번호 뺀)과 만들어진 md 글자 집합을 대조(check_md).
"""
import re
import sys
from collections import Counter
from pathlib import Path
import pymupdf as fitz
PAGE_NO = re.compile(r"^-\s*(\d+)\s*-$")
CH = [
("1. 조사개요", None),
("2. 조사결과 요약", re.compile(r"Ⅱ\.\s*조사결과")),
(
"3. 직종별 조사노임",
re.compile(r"제조부문\s*생산직\s*직종별\s*평균\s*조사노임\(일급\)\s*\(단위"),
),
("4. 직종코드 및 직종명 해설", re.compile(r"【붙임\s*2】")),
]
ROUNDS = { # 적용일: (조사 회차, 공표일, 게시글 seq, 조사기준월)
"2020-07-01": ("2020년 상반기", "2020-06-30", 148064, "2020-03"),
"2021-01-01": ("2020년 하반기", "2020-11-30", 149024, "2020-08"),
"2021-07-01": ("2021년 상반기", "2021-06-25", 150311, "2021-03"),
"2022-01-01": ("2021년 하반기", "2021-11-26", 151206, "2021-08"),
"2022-07-01": ("2022년 상반기", "2022-06-17", 152435, "2022-03"),
"2023-01-01": ("2022년 하반기", "2022-11-24", 153306, "2022-08"),
"2023-07-01": ("2023년 상반기", "2023-06-26", 154773, "2023-03"),
"2024-01-01": ("2023년 하반기", "2023-11-27", 155993, "2023-08"),
"2024-10-01": ("2024년", "2024-09-27", 158518, "2024-06"),
"2025-07-01": ("2025년 상반기", "2025-06-27", 160550, "2025-03"),
"2026-01-01": ("2025년 하반기", "2025-11-28", 161708, "2025-08"),
}
def pages_of(pdf_path):
"""(쪽 객체 목록, 문서) — 모아찍기면 반쪽으로 잘라 새 문서로."""
src = fitz.open(pdf_path)
if src[0].rect.width <= src[0].rect.height:
return src, src, False
out = fitz.open()
for i, p in enumerate(src):
w, h = p.rect.width, p.rect.height
for half in (0, 1):
np_ = out.new_page(width=w / 2, height=h)
np_.show_pdf_page(
np_.rect, src, i, clip=fitz.Rect(half * w / 2, 0, (half + 1) * w / 2, h)
)
return out, out, True
def _center(r):
return fitz.Point((r.x0 + r.x1) / 2, (r.y0 + r.y1) / 2)
PUA = re.compile("[" + chr(0xE000) + "-" + chr(0xF8FF) + "]+")
EQ, SEAL = "[수식]", "[도장기호]"
def clean_pua(txt):
"""글자층의 사용자 정의 글꼴 기호(수식 조각 · 도장 기호)는 자리표시로 바꿈 — 내용은 쪽 그림 판독 메모로 옮김."""
txt = txt.replace(chr(0xF000), SEAL)
return PUA.sub(EQ, txt)
def raw_lines(page):
out = []
for blk in page.get_text("dict").get("blocks", []):
if blk.get("type") != 0:
continue
for ln in blk.get("lines", []):
txt = clean_pua("".join(sp.get("text", "") for sp in ln.get("spans", [])))
if txt.strip():
out.append((fitz.Rect(ln["bbox"]), txt))
return out
def cell_text(c):
"""칸 글: 줄바꿈은 공백으로(세로로 한 글자씩 쌓인 칸은 붙임)."""
if c is None:
return ""
ps = [p.strip() for p in str(c).split(chr(10)) if p.strip()]
if len(ps) > 1 and all(len(p) <= 2 for p in ps):
return "".join(ps)
return " ".join(ps)
def esc(s):
return s.replace("|", "\\|")
def table_md(rows):
w = max(len(r) for r in rows)
rows = [r + [""] * (w - len(r)) for r in rows]
keep = [j for j in range(w) if any(r[j] for r in rows)] # 통째로 빈 열(선 잡음)은 뺌
rows = [[r[j] for j in keep] for r in rows]
w = len(keep)
o = ["| " + " | ".join(esc(c) for c in rows[0]) + " |", "|" + "---|" * w]
for r in rows[1:]:
o.append("| " + " | ".join(esc(c) for c in r) + " |")
return o
def page_elems(page):
"""세로 순서대로 ('text', y, x, 줄) / ('table', y, x, md줄들, 글조각들)."""
try:
tabs = list(page.find_tables().tables)
except Exception:
tabs = []
lines = raw_lines(page)
final = []
for t in tabs:
try:
rows = [[cell_text(c) for c in r] for r in t.extract()]
except Exception:
continue
rows = [r for r in rows if any(r)]
if len(rows) < 2 or max(len(r) for r in rows) < 2:
continue
b = fitz.Rect(t.bbox)
inside = "".join(re.sub(r"\s", "", txt) for r, txt in lines if b.contains(_center(r)))
got = "".join(re.sub(r"\s", "", c) for r in rows for c in r)
if Counter(inside) != Counter(got):
continue # 칸 글이 원문과 다르면 표 취급 취소 → 글 줄로
final.append((b, rows))
boxes = [b for b, _ in final]
items = []
for r, txt in lines:
if any(b.contains(_center(r)) for b in boxes):
continue
items.append(("text", r.y0, r.x0, txt.rstrip()))
for b, rows in final:
items.append(("table", b.y0, b.x0, table_md(rows), [c for r in rows for c in r]))
items.sort(key=lambda x: (round(x[1] / 2.5), x[2]))
return items
NOTE = "> [쪽 그림 판독] "
INPUT_TABLE = [
"| 직종코드 | 직종명 | 총인원 소계 | 남 | 여 | 월 정상 근로시간(①) | 인원당 월 근로시간 | 총지급액(②+③) | 기본급(②) | 통상적수당(③) | 평균일급((②+③)/①×8시간) |",
"|---|---|---|---|---|---|---|---|---|---|---|",
"| 30 | 종이및합성수지인쇄기계조작원 | 2 | 2 | | 416 | 208.0 | 9,650,000 | 6,400,000 | 3,250,000 | 185,577 |",
"| 129 | 작업반장 | 5 | 5 | | 1,040 | 208.0 | 15,550,000 | 11,000,000 | 4,550,000 | 119,615 |",
"| 106 | 제품검사및조정원 | 4 | | 4 | 832 | 208.0 | 8,357,000 | 6,670,000 | 1,687,000 | 80,356 |",
"| 94 | 폐수처리장치조작원 | 1 | 1 | | 208 | 208.0 | 2,800,000 | 2,800,000 | | 107,692 |",
"| 128 | 안전관리사 | 1 | 1 | | 208 | 208.0 | 2,800,000 | 2,800,000 | | 107,692 |",
"| 125 | 내부특수차운전원 | 1 | 1 | | 208 | 208.0 | 3,200,000 | 3,200,000 | | 123,077 |",
"| 126 | 외부특수차운전원 | 1 | 1 | | 208 | 208.0 | 3,400,000 | 3,400,000 | | 130,769 |",
"| 127 | 단순노무종사원 | 6 | 2 | 4 | 1,248 | 208.0 | 12,535,500 | 10,005,000 | 2,530,500 | 80,356 |",
"| 124 | 제품하역적재원 | 10 | 10 | | 2,080 | 208.0 | 20,892,500 | 16,675,000 | 4,217,500 | 80,356 |",
"| | | | | | | | | | | |",
]
SAMPLE_TABLE = [
"| 업 종 | 직종코드 및 직종명 | 2022년 8월 조사노임 | 변동계수 | 2022년 3월 조사노임 | 2021년 8월 조사노임 |",
"|---|---|---|---|---|---|",
"| 세척 | 27.세척원 | 92,276 | 21.5 | 89,554 | 85,656 |",
"| 출판·인쇄 | 28.전자조판원 | **89,509 | 10.7 | **90,988 | **90,628 |",
"| | 29.편집교정사 | 129,906 | 33.8 | 129,906 | * |",
"| | 30.종이및합성수지인쇄기계조작원 | 112,227 | 9.4 | 112,137 | 112,478 |",
"| | 31.인쇄기능원 | 110,092 | 15.6 | 109,328 | 108,312 |",
"| | 32.연포장재접합원 | * | * | * | * |",
]
CV = "변동계수 CV(ȳ) = √var(ŷ) ÷ ȳ × 100"
def page_notes(text, label, has_fig):
"""글자층에 없는 수식·그림 내용을 쪽 그림을 보고 옮긴 메모(줄들). label = 적용일."""
old = label <= "2022-07-01"
out = []
if "표본규모 산출공식" in text:
if old:
out += [
NOTE + "수식 — 글자층에 수식 글자가 없어 쪽 그림을 보고 옮김: "
"n = (C ÷ C_ȳ)² ÷ [1 + (1 ÷ N) · (C ÷ C_ȳ)²]",
">",
"> 여기서 n = 업종ㆍ매출액규모별 표본수 · C = S/Ȳ : 모집단 변동계수(평균 급여액) · "
"C_ȳ : 목표오차 · N : 업종ㆍ종사자규모별 모집단 업체수",
]
else:
out += [
NOTE + "수식 — 글자층에 수식 글자가 없어 쪽 그림을 보고 옮김: "
"n_hi = (N_hi · S_hi)² ÷ [D² + (N_hi · S_hi²)] , 단 D = X̂ · E ÷ Z",
">",
"> 여기서 n_hi : 업종ㆍ매출액규모별 표본수 · N_hi : 업종ㆍ매출액규모별 모집단수 · "
"S_hi : 업종ㆍ매출액규모별 평균 급여액의 표준편차 · X̂ : 모총계(평균 급여액) · E : 허용오차 · "
"h = 1, …, H : 업종분류를 나타내는 첨자 · i = 1, …, 5 : 매출액 규모를 나타내는 첨자",
]
if "설계가중치" in text and "표본추출" in text:
head = NOTE + "수식 — 글자층에 수식 글자가 없어 쪽 그림을 보고 옮김:"
if old:
out += [
head,
"> - 설계가중치 : w_h⁰ = N_h ÷ n_h (h : 층을 나타내는 첨자(h = 1, 2, …, H) · N_h : h층의 모집단 기업체 수 · n_h : h층의 표본 기업체 수)",
"> - 평균임금의 추정치 : ȳ = [Σ(h=1..H) Σ(i=1..n_h) w_hi · y_hi] ÷ [Σ(h=1..H) Σ(i=1..n_h) w_hi] (w_hi : h층의 i번째 표본업체의 가중치 · y_hi : h층의 i번째 표본업체로부터 얻은 측정값)",
"> - ȳ의 분산추정치 : var(ȳ) = Σ(h=1..H) [n_h(1 − f_n) ÷ (n_h − 1)] · Σ(i=1..n_h) (e_hi − ē_h.)² (e_hi = w_hi(y_hi − ȳ) ÷ w.. , ē_h. = [Σ(i=1..n_h) e_hi] ÷ n_h , w.. = Σ(h=1..H) Σ(i=1..n_h) w_hi)",
"> - ȳ의 상대표준오차(CV) : CV^(ȳ) = √var(ŷ) ÷ ȳ × 100",
]
else:
wnote = " (다만, w_hi = 1)" if label >= "2024-01-01" else ""
out += [
head,
"> - 설계가중치 : w_h¹ = N_h ÷ n_h (h : 층을 나타내는 첨자(h = 1, 2, …, H) · N_h : h층의 모집단 기업체 수 · n_h : h층의 표본 기업체 수)",
"> - 평균임금의 추정치 : ȳ = [Σ(h=1..H) Σ(i=1..n_h) w_hi · y_hi] ÷ [Σ(h=1..H) Σ(i=1..n_h) w_hi · x_hi] "
f"(w_hi : h층의 i번째 표본기업체의 가중치{wnote} · y_hi : h층의 i번째 표본기업체의 직종별 측정값(임금) · x_hi : h층의 i번째 표본기업체의 직종별 종사자수)",
"> - ȳ의 분산추정치 : var(ȳ) = Σ(h=1..H) [n_h(1 − f_n) ÷ (n_h − 1)] · Σ(i=1..n_h) e²_hi (e_hi = w_hi(y_hi − ȳ · x_hi) ÷ w.. , w.. = Σ(h=1..H) Σ(i=1..n_h) w_hi)",
"> - ȳ의 상대표준오차(CV) : CV^(ȳ) = √var(ŷ) ÷ ȳ × 100",
]
if re.search(r"변동계수\(" + PUA.pattern, text):
out += [NOTE + "수식 — 「변동계수(」 뒤 수식은 글자층에 없어 쪽 그림을 보고 옮김: " + CV]
if has_fig and "입력사례" in text:
out += [
NOTE
+ "입력화면 그림(글자층 없음) — 【예시. 비금속광물 제조업체의 생산직 직종 입력현황】 「3. 입력화면」 표를 옮김. "
"안내 글: 「※ 직종코드 삭제방법 : 조회화면에서 직종선택을 해제하거나 입력화면에서 직종코드를 지우면 됩니다.」 · [직종 추가] 단추 · (단위:명,원,시간)",
"",
]
out += INPUT_TABLE
if has_fig and "특수기호 표기 사례" in text:
out += [
NOTE
+ "「< 특수기호 표기 사례 >」 그림(글자층 없음) — 표를 옮김 (그림 아래쪽은 32번 줄에서 잘려 있음):",
"",
]
out += SAMPLE_TABLE
return out
def render_page(page, idx, label):
"""→ (md 줄들, 인쇄 쪽 번호|None)."""
items = page_elems(page)
pn = None
body = []
for it in items:
if it[0] == "text":
m = PAGE_NO.match(it[3].strip())
if m:
pn = int(m.group(1))
continue
if it[3].strip() == EQ and body and body[-1][0] == "text" and body[-1][3].strip() == EQ:
continue # 수식 자리 줄 연속은 하나로
body.append(it)
if pn is not None:
head = f"### 쪽 {pn}"
else:
head = "### 표지" if idx == 0 else f"### 쪽 번호 없음 (PDF {idx + 1}째 쪽)"
out = [head, ""]
for it in body:
if it[0] == "text":
out.append(it[3].strip() + " ")
else:
out.append("")
out += it[3]
out.append("")
has_fig = any(
w > 250 and h > 100 and w < 700
for w, h in (
(i["bbox"][2] - i["bbox"][0], i["bbox"][3] - i["bbox"][1])
for i in page.get_image_info()
)
)
notes = page_notes(page.get_text(), label, has_fig)
if notes:
out.append("")
out += notes
out.append("")
return out, pn, len(notes)
def norm_chars(s):
return re.sub(r"\s", "", s)
def check_md(doc, pages_md):
"""쪽마다 원문 글자층(공백·쪽 번호 뺀) 글자 집합 = md 글자 집합(표 표시·머리 뺀)인지."""
bad = []
for i, md in enumerate(pages_md):
raw = "\n".join(ln for ln in doc[i].get_text().split("\n") if not PAGE_NO.match(ln.strip()))
raw = PUA.sub("", raw)
skip = set(INPUT_TABLE) | set(SAMPLE_TABLE)
body = "\n".join(ln for ln in md[2:] if not ln.startswith(">") and ln not in skip)
body = body.replace(EQ, "").replace(SEAL, "")
body = body.replace("\\|", "\x00")
body = "\n".join(
"" if re.fullmatch(r"\|(?:---\|)+", ln.strip()) else ln for ln in body.split("\n")
)
body = body.replace("|", "").replace("<br>", "").replace("\x00", "|")
a, b = Counter(norm_chars(raw)), Counter(norm_chars(body))
if a != b:
bad.append((i + 1, dict(a - b), dict(b - a)))
return bad
def convert(folder):
folder = Path(folder)
rnd = ROUNDS[folder.name]
meta = {"공표": rnd[1], "조사": rnd[0]}
pdf = next(p for p in folder.glob("*.pdf"))
doc, _, twoup = pages_of(pdf)
pages, pns = [], []
for i in range(len(doc)):
o, pn, nn = render_page(doc[i], i, folder.name)
pages.append(o)
pns.append(pn)
bad = check_md(doc, pages)
texts = [doc[i].get_text() for i in range(len(doc))]
st = [0]
for name, rx in CH[1:]:
hits = [i for i, t in enumerate(texts) if rx.search(t) and i > st[-1]]
if not hits:
raise SystemExit(f"{folder.name}: 장 시작을 못 찾음 — {name}")
st.append(hits[0])
st.append(len(doc))
stem = pdf.stem
hdr = (
f"> 원문: {pdf.name} (중소기업중앙회, 공표 {meta['공표']}, 적용 {folder.name})\n"
"> 변환: kbiz_report_md.py — PDF 글자층을 시각 줄 그대로 · 표는 칸 그대로(병합 칸은 빈 칸). "
"`*` = 표본 부족 미공표, `**` = 응답업체 5개 미만 · 원문 각주 참조. "
"쪽 표제 옆 「1111」 · 「IIII」 같은 겹친 숫자는 원문 표제 그림 글자.\n\n"
)
title = f"{meta['조사']} 중소제조업 직종별 임금조사 보고서 — {folder.name} 적용"
full = (
f"# {title}\n\n> 원본: `{pdf.name}` ({len(doc)}쪽{' · 가로 모아찍기를 좌우 반쪽으로 나눔' if twoup else ''})\n\n"
+ "\n".join("\n".join(p) for p in pages)
+ "\n"
)
(folder / f"{stem}.md").write_text(full, encoding="utf-8")
for k, (name, _) in enumerate(CH):
a, b = st[k], st[k + 1]
body = "\n".join("\n".join(pages[i]) for i in range(a, b))
(folder / f"{name}.md").write_text(
f"# {name.split('. ', 1)[1]}\n\n{hdr}{body}\n", encoding="utf-8"
)
return bad, st, pns
# ───────────────────────── HWP (pyhwp) ─────────────────────────
HWP_CTRL = {
"TableControl",
"Control",
"GShapeObjectControl",
"ShapeComponent",
"TableBody",
"TableCell",
"TableCaption",
"TextboxParagraphList",
"FooterParagraphList",
"HeaderParagraphList",
}
def hwp_xml(path):
"""pyhwp(hwp5proc xml)로 HWP5 → XML 트리. pyhwp 는 환경변수 PYHWP_PATH 폴더에 설치(pip install pyhwp --target ...)."""
import os
import subprocess
import xml.etree.ElementTree as ET
env = dict(os.environ)
if os.environ.get("PYHWP_PATH"):
env["PYTHONPATH"] = os.environ["PYHWP_PATH"]
code = (
"import sys;from hwp5.hwp5proc import main;sys.argv=['hwp5proc','xml',sys.argv[1]];main()"
)
r = subprocess.run(
[sys.executable, "-c", code, str(path)], env=env, capture_output=True, check=True
)
root = ET.fromstring(r.stdout)
for e, sc in zip(root.iter("EqEdit"), hwp_eq_scripts(path)):
e.set("script", sc)
return root
def hwp_eq_scripts(path):
"""HWP 본문 레코드의 수식(EQEDIT 0x58) 대본 — pyhwp XML 에는 비어 있음. 문서 순서."""
import struct
import zlib
import olefile
ole = olefile.OleFileIO(str(path))
comp = bool(struct.unpack("<I", ole.openstream("FileHeader").read()[36:40])[0] & 1)
out = []
names = sorted(
(x for x in ole.listdir() if x[0] == "BodyText"), key=lambda n: int(re.sub(r"\D", "", n[1]))
)
for n in names:
data = ole.openstream(n).read()
if comp:
data = zlib.decompress(data, -15)
i = 0
while i < len(data):
h = struct.unpack_from("<I", data, i)[0]
tag, size = h & 0x3FF, h >> 20
i += 4
if size == 0xFFF:
size = struct.unpack_from("<I", data, i)[0]
i += 4
pl = data[i : i + size]
i += size
if tag == 0x58:
ln = struct.unpack_from("<H", pl, 4)[0]
out.append(pl[6 : 6 + ln * 2].decode("utf-16le"))
return out
def hwp_own(p):
"""문단 자기 글(표·상자 안 글은 뺌)."""
out = []
def rec(e):
for c in e:
if c.tag == "Text":
out.append(c.text or "")
elif c.tag == "ControlChar" and c.get("name") == "TAB":
out.append(" ")
elif c.tag in HWP_CTRL:
if c.tag in ("Control", "GShapeObjectControl", "ShapeComponent"):
for q in c.iter("EqEdit"):
out.append("[수식: " + " ".join(q.get("script", "").split()) + "]")
continue
elif c.tag == "EqEdit":
out.append("[수식: " + " ".join(c.get("script", "").split()) + "]")
else:
rec(c)
rec(p)
return "".join(out)
def hwp_cell_text(cell):
ps = [hwp_own(p).strip() for p in cell.findall("Paragraph")]
ps = [x for x in ps if x]
for p in cell.iter("Paragraph"): # 칸 안 표·상자의 글
pass
return " ".join(ps)
def hwp_table_rows(tc):
tb = tc.find("TableBody")
cells = []
for row in tb.findall("TableRow"):
for c in row.findall("TableCell"):
cells.append(
(
int(c.get("row")),
int(c.get("col")),
int(c.get("rowspan")),
int(c.get("colspan")),
hwp_cell_text(c),
)
)
nr = max(r + rs for r, c, rs, cs, t in cells)
nc = max(c + cs for r, c, rs, cs, t in cells)
grid = [[""] * nc for _ in range(nr)]
for r, c, rs, cs, t in cells:
grid[r][c] = t
return grid
def hwp_blocks(root):
"""본문 순서대로 [('text', 줄) | ('table', 행렬) | ('page', None)]."""
out = []
for cs in root.iter("ColumnSet"):
for p in cs:
if p.tag != "Paragraph":
continue
if p.get("new-page") == "1":
out.append(("page", None))
t = hwp_own(p)
if t.strip():
out.append(("text", t.rstrip()))
for box in p.iter("TextboxParagraphList"):
for bp in box.findall("Paragraph"):
bt = hwp_own(bp)
if bt.strip():
out.append(("text", bt.rstrip()))
for tc in p.iter("TableControl"):
grid = hwp_table_rows(tc)
if grid and any(any(r) for r in grid):
out.append(("table", grid))
return out
def hwp_md_lines(blocks):
"""블록 → md 줄들. 한 줄짜리 표는 글 줄로, 여러 줄 표는 md 표로."""
out = []
for kind, v in blocks:
if kind == "page":
continue
if kind == "text":
out.append(v.strip() + " ")
continue
rows = [r for r in v if any(r)]
if len(rows) == 1:
out.append(" ".join(c for c in rows[0] if c) + " ")
elif max(len(r) for r in rows) == 1:
out += [r[0] + " " for r in rows]
else:
out.append("")
out += table_md(rows)
out.append("")
return out
def convert_hwp(folder, write=True):
"""HWP 보고서 → 전체본 md + 장별 md 4개. HWP 에는 인쇄 쪽 구분이 없어 쪽 표기 없음."""
folder = Path(folder)
rnd = ROUNDS[folder.name]
meta = {"공표": rnd[1], "조사": rnd[0]}
hwp = next(p for p in folder.glob("*_중소기업중앙회.hwp") if "이전_직종명" not in p.name)
blocks = hwp_blocks(hwp_xml(hwp))
i2 = next(
i
for i, b in enumerate(blocks)
if b[0] == "text" and re.match(r"Ⅱ\.\s*조사결과", b[1].strip())
)
i3 = next(
i
for i, b in enumerate(blocks)
if i > i2 and b[0] == "table" and len(b[1]) == 1 and b[1][0][0].strip() == "4"
)
i4 = next(
i
for i, b in enumerate(blocks)
if i > i3
and b[0] == "text"
and re.match(r"【붙임\s*2】", b[1].strip())
and "\t" not in b[1]
)
st = [0, i2, i3, i4, len(blocks)]
stem = hwp.stem
hdr = (
f"> 원문: {hwp.name} (중소기업중앙회, 공표 {meta['공표']}, 적용 {folder.name})\n"
"> 변환: kbiz_report_md.py — HWP 본문·표 칸을 pyhwp 로 직접 읽음(표 병합 칸은 빈 칸) · 수식 개체는 "
"HWP 수식 대본을 「[수식: …]」 로 옮김. HWP 에는 인쇄 쪽 구분이 없어 쪽 표기 없음 · 원문 PDF 없음"
"(쪽 그림 대조 불가) — 표 값은 다른 회차 PDF 와 기계 대조(kbiz_verify.py). "
"`*` = 표본 부족 미공표, `**` = 응답업체 5개 미만 · 원문 각주 참조.\n\n"
)
title = f"{meta['조사']} 중소제조업 직종별 임금조사 보고서 — {folder.name} 적용"
full = f"# {title}\n\n> 원본: `{hwp.name}` (HWP)\n\n" + "\n".join(hwp_md_lines(blocks)) + "\n"
parts = {}
for k, (name, _) in enumerate(CH):
head = f"# {name.split('. ', 1)[1]}\n\n{hdr}"
parts[name] = head + "\n".join(hwp_md_lines(blocks[st[k] : st[k + 1]])) + "\n"
if write:
(folder / f"{stem}.md").write_text(full, encoding="utf-8")
for name, body in parts.items():
(folder / f"{name}.md").write_text(body, encoding="utf-8")
return st, len(blocks)
if __name__ == "__main__":
for f in sys.argv[1:]:
if list(Path(f).glob("*.pdf")):
bad, st, pns = convert(f)
print(f, "글자대조", "이상없음" if not bad else bad, "장 시작(0기준 PDF쪽)", st[:-1])
else:
st, n = convert_hwp(f)
print(f, "HWP 블록", n, "장 시작(블록)", st[:-1])
@@ -0,0 +1,220 @@
# -*- coding: utf-8 -*-
"""제조노임 미공표 직종 추적표 · 직종코드 변천표 생성.
입력: 노임단가_제조업_중소기업중앙회/<적용일>/ 회차 md 의 「3. 직종별 조사노임」 표(값은 md 에서만 읽음) + 기본 폴더(2026-07-01).
출력: 노임단가_제조업_중소기업중앙회/미공표직종_추적.md · 직종코드_변천_대조표.md
사용: python kbiz_track_unpublished.py
- 미공표 직종 = 가장 최근 회차(2026-07-01 적용)에서 조사노임 칸이 「*」 인 직종.
- 산정값 = 마지막 공표값 × (최근 조사월 전체평균 ÷ 그 공표 조사월 전체평균) — 공표 조사월 단계별 증가율을 이어 곱한 값과 같음.
"""
import re
from pathlib import Path
from kbiz_verify import BASE, load_rounds
TARGET_ROUND = "2026-07-01"
MONTH_KEY = lambda s: (int(s.split(".")[0]), int(s.split(".")[1])) # noqa: E731
def num(s):
return int(s.replace("*", "").replace(",", ""))
def series(rs):
"""(직종명 → {조사월: 값문자}) · 조사월 순서 · 조사월 → 전체평균 문자 · 조사월 → (전체평균을 실은 회차들)."""
val, avg, avg_src = {}, {}, {}
own = {}
for lab, r in rs.items():
for name, (code, vs) in r["rows"].items():
for k, (m, v) in enumerate(zip(r["months"], vs)):
if k == 0:
own[(name, m)] = v # 그 조사월을 처음 낸 회차의 값(표기 그대로)
val.setdefault(name, {}).setdefault(m, v)
a = [x for x in (r["avg"] or []) if x != "-"]
for m, x in zip(r["months"], a):
avg.setdefault(m, x)
avg_src.setdefault(m, []).append((lab, x))
for (name, m), v in own.items():
val[name][m] = v
months = sorted({m for n in val for m in val[n]}, key=MONTH_KEY)
return val, months, avg, avg_src
def mlabel(m):
y, mm = m.split(".")
return f"{y}.{int(mm)}"
def build_track(rs):
val, months, avg, avg_src = series(rs)
last = rs[TARGET_ROUND]
latest_month = last["months"][0]
un = [n for n, (c, v) in last["rows"].items() if v[0].replace("*", "").strip() in ("", "-")]
un.sort(key=lambda n: last["rows"][n][0])
lines = []
A = lines.append
A("# 제조노임 미공표 직종 추적 — 마지막 공표값 · 전체 평균 증가율 이음 (참고 계산)\n")
A(
"> 지식DB 근거 문서 — 값을 확정하지 않음. 원 미만 처리 · 어느 시기 값으로 이을지는 사용자 협의로 정함. "
"마스터(`resources/master_data`)에는 반영하지 않음.\n"
"> 생성: `original/_pipeline/kbiz_track_unpublished.py` (회차 md 에서 값을 읽음)."
)
A("\n## 1. 원문 규칙\n")
A(
"- **중소기업중앙회 「중소제조업 직종별 임금조사 보고서」 에는 조사되지 않은(`*`) 직종의 노임을 어떻게 적용하라는 규정이 없음.** "
"2020 상반기 ~ 2026 상반기 12 회차 원문(`<적용일>/2. 조사결과 요약.md` 「조사노임 적용시 유의사항」 · `3. 직종별 조사노임.md` 표 밑 「주」)을 모두 찾아 확인함."
)
A("- 원문에 있는 것은 기호 뜻 뿐 (문장 그대로):")
A(
" - 2020.7 ~ 2023.7 적용 회차 「조사노임 적용시 유의사항」: 「‘*’ 표시는 조사업체가 3개 미만이거나 표본수가 적고 변동계수가 커 신뢰성을 확보하기 어려운 일부 직종으로 이번 조사결과 공표에서는 제외하며, ‘**’ 표시는 5개 미만인 직종으로 활용시 주의 필요함」"
)
A(
" - 2024.1 ~ 2026.7 적용 회차 「특수기호의 정의」: 「‘*’ 표시는 조사업체가 3개 미만이거나 표본수가 적은 경우, 또는 변동계수가 커 신뢰성을 확보하기 어려운 직종으로 ‘공표 제외’」 · 「‘**’ 표시는 응답기업 5개 미만인 직종으로 활용시 주의가 필요함」"
)
A(
" - 조사노임 표 밑 「주2」 (전 회차): 「‘-’표시는 조사되지 않음, ‘*’표시는 조사업체가 3개 미만이거나 표본수가 적고 변동계수가 커지는 등 신뢰성을 확인하기 어려운 직종, ‘**’표시는 5개 미만을 나타내는 것으로 활용시 주의 필요함」"
)
A(
" - 「주3」: 「(5~7)식품가공원, (20~21)일반화학원, (23~25)배합원, (125~126)특수차운전원의 통합단가는 2019년까지만 발표하고 2020년부터는 발표하지 아니함」"
)
A(
"- 건설업 임금실태조사(대한건설협회) 임금적용요령 4-라 의 「전후 시기 값 × 전체 평균 증가율」 같은 규정은 이 보고서에 **없음**. "
"아래 산정값은 건설 쪽 방식을 같은 모양으로 대입해 본 **참고 계산**이며 원문 근거가 없음 — 채택 여부는 사용자 협의."
)
A(
"- 산정식(마지막 공표 조사월 L → "
f"{mlabel(latest_month)}): **산정값 = L 의 공표값 × Π(조사월마다 전체 평균 ÷ 앞 조사월 평균)** = "
f"{mlabel(latest_month)} 전체 평균 ÷ L 전체 평균 (가운데 항이 지워짐). 「원 미만 처리」 규정 없음 → 처리 없이 소수 둘째 자리까지 적음."
)
A(
"- 「전체 평균 조사노임」 = 「전체평균」 줄(조사노임표 맨 끝) · 조사결과 요약 「전체 조사직종(129개)의 평균 조사노임(일급)」 과 같음. "
"원문 주: 각 직종별 평균은 단순평균값, 전체 조사직종의 평균은 가중평균."
)
A("\n## 2. 조사월별 전체 조사직종 평균 조사노임 (원문 인용)\n")
A("| 조사월 | 전체 평균(원) | 실린 회차(적용일) | 회차 사이 일치 |")
A("|---|---|---|---|")
for m in months:
srcs = avg_src.get(m, [])
same = len({x for _, x in srcs}) == 1
A(
f"| {mlabel(m)} | {avg[m]} | {' · '.join(l for l, _ in srcs)} | {'같음' if same else '다름 ' + str(srcs)} |"
)
A(
"\n- 표는 각 회차 「3. 직종별 조사노임」 표 맨 끝 「전체평균」 줄(회차마다 조사월 3 칸). "
"같은 조사월이 여러 회차에 실려 있어 회차 사이 일치를 확인함. 직종수: 2020.3 ~ 2020.8 조사는 130 직종, 2021.3 조사부터 129 직종(90.자동차정비원 제외) — 이음 계열이 바뀌는 자리에서도 회차 사이 같은 조사월 평균은 같은 값으로 실려 있음."
)
A(f"\n## 3. {len(un)} 직종 공표 이력 (원문 md 인용 · `*` = 미공표 · `-` = 조사되지 않음)\n")
hdr = "| 직종 (최근 판 코드) | " + " | ".join(mlabel(m) for m in months) + " |"
A(hdr)
A("|---|" + "---|" * len(months))
for n in un:
code = last["rows"][n][0]
A(f"| {code} {n} | " + " | ".join(val[n].get(m, "") for m in months) + " |")
A(
"\n- 값 출처: 각 조사월을 처음 낸 회차 폴더의 「3. 직종별 조사노임.md」 (표기 `**` 는 그 회차 표기 그대로). 직종 코드는 2024.10 적용 회차에서 재정렬됨 — 「직종코드_변천_대조표.md」 참조. "
"같은 조사월 값이 여러 회차에 실려 있으면 숫자는 모두 같음(`**` 표기만 회차에 따라 다를 수 있음) — 회차 사이 대조로 확인함."
)
A("\n## 4. 이음 단계 (조사월 전체 평균 ÷ 앞 조사월 평균)\n")
A("| 앞 → 뒤 | 분자(뒤) | 분모(앞) | 증가율 |")
A("|---|---|---|---|")
step = {}
for a, b in zip(months, months[1:]):
r = num(avg[b]) / num(avg[a])
step[b] = r
A(f"| {mlabel(a)} → {mlabel(b)} | {avg[b]} | {avg[a]} | {r:.6f} |")
A(
"\n- 조사월 간격: 반기(3월 · 8월 조사) → 2024년은 연 1회(2024.6) → 2025.3 부터 다시 반기. "
"원문 연혁: 「2024. 5 작성주기 변경(연2회 → 연1회)」 · 「2025. 1 작성주기 변경(연1회 → 연2회)」. "
"이음은 실린 조사월끼리 곱함(2023.8 → 2024.6 은 두 조사월 평균의 비)."
)
A(f"\n## 5. {mlabel(latest_month)} 조사(2026-07-01 적용) 산정값\n")
A("| 직종 | 마지막 공표 조사월 | 그 값(원) | 이어 곱한 증가율 | 산정값(처리 없음) | 비고 |")
A("|---|---|---|---|---|---|")
rows = []
for n in un:
code = last["rows"][n][0]
pub = [(m, val[n][m]) for m in months if val[n].get(m, "*") not in ("*", "-", "")]
m, v = pub[-1]
mult = 1.0
for k in months[months.index(m) + 1 :]:
mult *= step[k]
est = num(v) * mult
ratio = num(avg[latest_month]) / num(avg[m])
assert abs(mult - ratio) < 1e-9
note = "값에 `**`(5개 미만) 표기" if "**" in v else ""
rows.append((code, n, m, v, mult, est, note))
A(f"| {code} {n} | {mlabel(m)} | {v} | {mult:.6f} | {est:,.2f} | {note} |")
A(
"\n- 「마지막 공표」 는 `**`(응답업체 5개 미만 · 활용시 주의) 표기가 붙은 값도 공표값으로 보아 셈 — `*` · `-` 만 값 없음."
"\n- 「그 값」 은 마지막으로 값이 실린 조사월의 공표 표기 그대로(`**` 포함). 산정값은 `**` 를 뗀 숫자에 곱함."
"\n- 마지막 공표값이 2020 이전인 직종은 없음 — 전 직종의 마지막 공표값이 2020.8 이후 회차에서 나와 2019 이전 회차는 받지 않음."
"\n- 최근 조사월 전체 평균 "
f"{avg[latest_month]}원 = 「조사결과 요약」 의 전체 조사직종(129개) 평균 조사노임."
)
return "\n".join(lines) + "\n", rows, un, months, val
def build_codes(rs):
labs = list(rs)
names = {}
for lab in labs:
for n, (c, v) in rs[lab]["rows"].items():
names.setdefault(n, {})[lab] = c
o = []
A = o.append
A("# 직종코드 변천·대조표 — 회차별 직종코드 (조사노임표 원문 그대로)\n")
A(
"> 지식DB 근거 문서. 생성: `original/_pipeline/kbiz_track_unpublished.py` — 각 회차 「3. 직종별 조사노임」 표의 「직종코드 및 직종명」 칸에서 읽음."
)
A(
"\n## 1. 원문에 실린 대조표 (각 회차 `3. 직종별 조사노임.md` 안 「5. 이전 직종코드·직종명으로 새 직종명 찾기」)\n"
)
A(
"- 모든 회차(2020.7 ~ 2026.7 적용)가 「기존 직종코드 및 직종명(~2018)」 → 「새로운 직종코드 및 직종명(2019~)」 표(124 줄)를 싣고 있음. "
"2020.7 ~ 2024.1 적용 회차 8 개는 표가 같고, 2024.10 · 2025.7 · 2026.1 적용 회차 3 개는 「새로운 직종코드」 칸이 재정렬 뒤 코드로 바뀐 판(두 판의 다른 줄 144 · 뒤 3 회차끼리는 같음 — 회차 사이 대조로 확인). "
"2020.7 · 2021.1 적용 회차 게시글에는 같은 표의 HWP(`이전_직종명으로_새_직종명_찾기.hwp` → 같은 이름 md) 가 첨부돼 있음."
)
A(
"- 이 HWP 첨부의 「34.유리절단및재단원」 줄은 「조사보류」 로 적혀 있고 PDF 본문 표는 「130.유리절단및재단원」 — 원문 그대로 둘 다 옮김."
)
A("\n## 2. 회차 사이 직종코드가 바뀐 자리\n")
prev = None
for lab in labs:
if prev:
ch = [
n
for n in rs[lab]["rows"]
if n in rs[prev]["rows"] and rs[lab]["rows"][n][0] != rs[prev]["rows"][n][0]
]
drop = [n for n in rs[prev]["rows"] if n not in rs[lab]["rows"]]
add = [n for n in rs[lab]["rows"] if n not in rs[prev]["rows"]]
if ch or drop or add:
A(
f"- {prev} → {lab}: 코드 바뀐 직종 {len(ch)} · 빠진 직종 {len(drop)}{(' (' + ', '.join(f'{rs[prev]["rows"][n][0]}.{n}' for n in drop) + ')') if drop else ''} · 새 직종 {len(add)}"
)
prev = lab
A(
"- 코드가 바뀐 것은 2024.1 → 2024.10 적용 회차(원문 연혁 「2024. 5 … 조사직종 재정렬」) 한 번 뿐. 재정렬 전 코드 → 뒤 코드는 2024.10 적용 회차부터 1 절의 표에 반영돼 있고, 아래 3절은 회차별 조사노임표에서 이름으로 맞춘 대조."
)
A("\n## 3. 직종명 × 회차별 코드\n")
A("| 직종명 | " + " | ".join(labs) + " |")
A("|---|" + "---|" * len(labs))
key = lambda n: rs[labs[-1]]["rows"].get(n, rs[labs[0]]["rows"].get(n, (9999,)))[0] # noqa: E731
for n in sorted(names, key=key):
A(f"| {n} | " + " | ".join(str(names[n].get(l, "")) for l in labs) + " |")
A(
"\n- 빈 칸 = 그 회차 조사노임표에 없음(90.자동차정비원은 2021.7 적용 회차부터 조사 대상에서 제외 — 원문 각주 「2년 연속 무응답으로 2021년 상반기 조사부터 조사 대상에서 제외」). "
"2024.1 적용 회차까지 130.유리절단및재단원 · 2024.10 적용 회차부터 17."
)
return "\n".join(o) + "\n"
if __name__ == "__main__":
rs = load_rounds()
md, rows, un, months, val = build_track(rs)
(BASE / "미공표직종_추적.md").write_text(md, encoding="utf-8")
(BASE / "직종코드_변천_대조표.md").write_text(build_codes(rs), encoding="utf-8")
print("미공표", len(un), "직종 · 조사월", len(months))
for r in rows:
print(r[0], r[1], r[2], r[3], f"{r[4]:.6f}", f"{r[5]:,.2f}")
@@ -0,0 +1,172 @@
# -*- coding: utf-8 -*-
"""중소제조업 임금조사 회차 md 표 검증 — 「3. 직종별 조사노임.md」 값을 PDF 글자층과 따로 읽어 대조 + 회차 사이 대조.
사용: python kbiz_verify.py (원가계산/노임단가_제조업_중소기업중앙회/<적용일>/ 전부)
- 표 값: md 의 직종 줄(코드 · 이름 · 값 칸들) = PDF 단어 좌표로 다시 읽은 줄(코드 · 이름 · 값들) 인지.
- 회차 사이: 같은 조사월 값(직종별)이 다른 회차 보고서 칸에도 실려 있으면 같은지.
"""
import re
import sys
from collections import defaultdict
from pathlib import Path
import pymupdf as fitz
sys.path.insert(0, str(Path(__file__).resolve().parent))
from kbiz_report_md import pages_of # noqa: E402
BASE = Path(__file__).resolve().parent.parent / "원가계산" / "노임단가_제조업_중소기업중앙회"
CODE = re.compile(r"^(\d{1,3})\.")
VAL = re.compile(r"^[ ]*\*{0,2}[\d,]+(?:\.\d+)?\**[ ]*$|^[ ]*\*{1,2}[ ]*$|^[ ]*-[ ]*$|^ $")
def md_rows(md_path):
"""조사노임표(머리에 「업 종」 · 「조사노임」) 의 직종 줄 [(코드, 이름, [값 칸들])]."""
rows, blocks, cur = [], [], []
for ln in Path(md_path).read_text(encoding="utf-8").split(chr(10)):
if ln.startswith("|"):
cur.append(ln)
elif cur:
blocks.append(cur)
cur = []
if cur:
blocks.append(cur)
for blk in blocks:
head = " ".join(blk[:2])
if not ("업 종" in head and "조사노임" in head):
continue
for ln in blk[2:]:
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
for i, c in enumerate(cells):
m = CODE.match(c)
if m and i <= 2:
rows.append((int(m.group(1)), c, cells[i + 1 :]))
break
return rows
def pdf_rows(pdf):
doc, _, _ = pages_of(pdf)
out = []
for p in doc:
ws = [(w[0], w[1], w[2], w[3], w[4]) for w in p.get_text("words")]
for x0, y0, x1, y1, w in ws:
if not (CODE.match(w) and x0 < 300):
continue
yc = (y0 + y1) / 2
toks = sorted(
(t for t in ws if abs((t[1] + t[3]) / 2 - yc) < 5 and t[0] >= x0),
key=lambda t: t[0],
)
name, j = toks[0][4], 1
while j < len(toks) and not VAL.match(toks[j][4]):
name += toks[j][4]
j += 1
vals = [t[4] for t in toks[j:]]
if vals and all(VAL.match(v) for v in vals):
out.append((int(CODE.match(name).group(1)), name, vals))
return out
def wage_pages_rows(rows):
"""값이 3~5칸인 줄만(=조사노임표 · 다른 표의 코드 줄 제외)."""
return [(c, n, v) for c, n, v in rows if all(VAL.match(x) or x == "" for x in v)]
def check_round(folder):
pdf = next(folder.glob("*.pdf"))
a = md_rows(folder / "3. 직종별 조사노임.md")
b = pdf_rows(pdf)
clean = lambda v: [x.replace(" ", "").strip() for x in v if x.replace(" ", "").strip()]
fa = [(c, n.replace(" ", ""), clean(v)) for c, n, v in a]
fb = [(c, n.replace(" ", ""), clean(v)) for c, n, v in b]
# 조사노임표는 쪽 18~ 이므로 md 표 줄 수 = 직종수. PDF 쪽 전체에서 잡힌 줄 중 표 줄만 남기려면 md 코드+이름으로 맞춤
keyb = {(c, n): v for c, n, v in fb}
bad = []
for c, n, v in fa:
pv = keyb.get((c, n))
if pv is None:
bad.append(("PDF 에 없음", c, n))
elif pv != v:
bad.append(("값 다름", c, n, v, pv))
return len(fa), bad
MONTH = re.compile(r"(\d{4})\s*[년.]\s*(\d{1,2})\s*월")
def load_rounds():
"""{적용일: {'months': [조사월 3개], 'rows': {직종명: (코드, [값 3개])}, 'avg': [전체평균 3개]}} — 2026-07-01 은 기본 폴더."""
res = {}
dirs = [
(d.name, d) for d in sorted(BASE.iterdir()) if d.is_dir() and re.match(r"\d{4}-", d.name)
]
dirs.append(("2026-07-01", BASE))
for label, d in dirs:
md = d / "3. 직종별 조사노임.md"
lines = md.read_text(encoding="utf-8").split(chr(10))
months = None
blocks, cur = [], []
for ln in lines:
if ln.startswith("|"):
cur.append(ln)
elif cur:
blocks.append(cur)
cur = []
if cur:
blocks.append(cur)
rows, avg = {}, None
for blk in blocks:
head = " ".join(blk[:3])
if not ("업 종" in head and "조사노임" in head):
continue
hc = [c.strip() for c in blk[0].strip().strip("|").split("|")]
ms = [MONTH.search(c) for c in hc]
ms = [f"{m.group(1)}.{int(m.group(2))}" for m in ms if m]
months = months or ms
for ln in blk[2:]:
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
if cells and cells[0].startswith("전체평균"):
avg = [c for c in cells[1:] if c]
for i, c in enumerate(cells):
m = CODE.match(c)
if m and i <= 2:
v = cells[i + 1 :]
# 값 칸: 조사노임 · 변동계수 · 앞 조사 · 앞앞 조사 → 변동계수 뺌
if len(v) == 4:
v = [v[0], v[2], v[3]]
rows[c.split(".", 1)[1].replace(" ", "")] = (int(m.group(1)), v)
break
res[label] = {"months": months, "rows": rows, "avg": avg}
return res
def cross_check():
rs = load_rounds()
seen = {}
diffs = []
for label, r in rs.items():
for name, (code, vals) in r["rows"].items():
for mth, v in zip(r["months"], vals):
k = (name, mth)
if k in seen and seen[k][1] != v:
diffs.append((name, mth, seen[k], (label, v)))
seen.setdefault(k, (label, v))
return rs, diffs
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "cross":
rs, diffs = cross_check()
for label, r in rs.items():
print(label, r["months"], "직종", len(r["rows"]), "전체평균", r["avg"])
print("회차 사이 다른 칸", len(diffs))
for d in diffs[:60]:
print(" ", d)
sys.exit()
for d in sorted(p for p in BASE.iterdir() if p.is_dir() and re.match(r"\d{4}-", p.name)):
if not list(d.glob("*.pdf")):
continue
n, bad = check_round(d)
print(d.name, "직종 줄", n, "이상", len(bad), bad[:4])