"""관급 단가 갱신 — 조달청 종합쇼핑몰 API 에서 받아 자재품목(재료_자재품목) 「관급」 열을 채움. 다시 돌리는 법 (한 줄) — 받기 + 정본 관급 열 갱신 + 바뀐 줄 목록: ./venv/Scripts/python.exe resources/master_data/scripts/update_관급단가.py update fetch 받기 → 원본 보관 (resources/knowledge/original/원가계산/자재단가/관급/<날짜>/ · gz + _meta.md) test [날짜] 관급 열을 지우고 다시 채워 옛 값(다산소프트)과 맞댐(±5% 안 = 맞음) — 정본 안 덮음 · tmp/관급_대조.json apply [날짜] 정본 관급 열(관급 · 비고)만 받은 값으로 바꿈 · tmp/관급_바뀐줄.json """ import gzip import json import re import sys import time import urllib.parse from datetime import date from pathlib import Path ROOT = Path(__file__).resolve().parents[3] PIPE = ROOT / "resources/knowledge/original/_pipeline" RAW = ROOT / "resources/knowledge/original/원가계산/자재단가/관급" sys.path.insert(0, str(PIPE)) import collect_cost_sources as cc # noqa: E402 (키 읽기 · fetch) import master_keys as mk # noqa: E402 (새 줄 키) import update_관급단가_기타 as misc # noqa: E402 (「그 밖」 갈래 짝짓기) import update_관급단가_느슨 as slow # noqa: E402 (엄격한 짝이 안 선 줄 · 느슨한 두 번째 짝) import update_관급단가_합침 as boost # noqa: E402 (보강 후보 합치기) BASE = "https://apis.data.go.kr/1230000/at/ShoppingMallPrdctInfoService/" MAS, UNIT, THPTY = ( "getMASCntrctPrdctInfoList", "getUcntrctPrdctInfoList", "getThptyUcntrctPrdctInfoList", ) # (오퍼레이션, 분류명) — 분류명은 조회 조건(inqryDiv=2 · prdctClsfcNoNm) JOBS = [ # 분류명은 부분 글자 조회 — 「철근」은 암거블록까지 섞여 오므로 「봉강」(철근또는강철봉) · H형강은 「H빔」 (MAS, "레미콘"), (THPTY, "레미콘"), (MAS, "아스팔트콘크리트"), (UNIT, "아스팔트콘크리트"), (THPTY, "아스팔트콘크리트"), (MAS, "봉강"), (MAS, "H빔"), (MAS, "파형강관"), (THPTY, "파형강관"), (MAS, "콘크리트관"), (UNIT, "콘크리트관"), # 2차 — 다산소프트 「그 밖」 관급 · 품셈재료 중 조달청에 있는 것 (MAS, "시멘트"), (MAS, "골재"), (MAS, "조경석"), (UNIT, "주철관"), (MAS, "파형강관이음관"), (THPTY, "파형강관이음관"), (MAS, "배수로"), (THPTY, "배수로"), (MAS, "블록"), (UNIT, "블록"), (THPTY, "블록"), (MAS, "돌망태"), (THPTY, "돌망태"), (MAS, "합성목재"), (MAS, "볼라드"), (MAS, "교량받침"), (THPTY, "교량받침"), (MAS, "용접철망"), (THPTY, "섬유"), (MAS, "폴리에틸렌"), (THPTY, "폴리에틸렌"), (MAS, "맨홀"), (MAS, "매트"), (THPTY, "매트"), (MAS, "낙석"), (THPTY, "그레이팅"), (MAS, "벽돌"), (THPTY, "시트"), (MAS, "비료"), (MAS, "압륜"), (THPTY, "부직포"), (MAS, "경질폴리염화비닐관"), (THPTY, "경질폴리염화비닐관"), (MAS, "플룸"), # 벤치플룸관 — 분류 「배수로」 조회에는 안 나옴 ] # 보관하는 칸 — 나머지(첨부·원산지·이미지·긴 설명)는 버림(원본이 수백 MB) KEEP = ( "cntrctMthdNm cntrctCorpNm prdctSpecNm cntrctPrceAmt prdctUnit prdctSplyRgnNm splyJrsdctRgnNm " "prdctDlvryCndtnNm vatAplDivNm prdctClsfcNoNm prdctClsfcNo dtilPrdctClsfcNo prdctIdntNo prdctMakrNm " "fctryLocplc dscntAmt dscntBgnDate dscntEndDate cntrctBgnDate cntrctEndDate rgstDt chgDt" ).split() def call(op: str, key: str, **kw) -> dict: q = "&".join(f"{k}={urllib.parse.quote(str(v))}" for k, v in kw.items()) url = f"{BASE}{op}?serviceKey={key}&type=json&{q}" for tries in range(5): # 큰 분류는 시간 초과가 잦음 — 다섯 번까지 try: return json.loads(cc.fetch(url, timeout=90).decode("utf-8", "replace"))["response"] except Exception as e: # 오류 글에 받은 주소(키 포함)가 실려도 키는 가림 if tries == 4: raise RuntimeError(f"{op} 호출 실패: {str(e).replace(key, '***')}") from None time.sleep(5) def fetch_all() -> Path: key = cc.read_g2b_key() out = RAW / date.today().isoformat() out.mkdir(parents=True, exist_ok=True) lines = [ "# 관급 단가 원본 — 조달청 종합쇼핑몰 품목정보 서비스", "", f"받은 날 {date.today().isoformat()} · 수집 `scripts/update_관급단가.py fetch` · 조건 = inqryDiv=2 · prdctClsfcNoNm=<분류명> · 쪽당 999", "", "| 파일 | 오퍼레이션 | 분류명 | 줄 수 |", "|---|---|---|---|", ] for op, name in JOBS: f = f"{op.removeprefix('get').removesuffix('PrdctInfoList')}_{name}.json.gz" if (out / f).exists(): # 같은 날 이미 받은 것은 건너뜀(이어받기) n = len(json.loads(gzip.decompress((out / f).read_bytes()))) lines.append(f"| {f} | {op} | {name} | {n:,} |") continue rows, page, total = [], 1, 1 while len(rows) < total: r = call(op, key, inqryDiv=2, prdctClsfcNoNm=name, numOfRows=999, pageNo=page) b = r["body"] total = int(b.get("totalCount") or 0) items = b.get("items") or [] if isinstance(items, dict): items = items.get("item", []) if not items: break rows += [{k: it.get(k) for k in KEEP} for it in items] page += 1 (out / f).write_bytes(gzip.compress(json.dumps(rows, ensure_ascii=False).encode("utf-8"))) lines.append(f"| {f} | {op} | {name} | {len(rows):,} (totalCount {total:,}) |") print(f, len(rows), total, flush=True) (out / "_meta.md").write_text("\n".join(lines) + "\n", encoding="utf-8") return out # ── 짝짓기 ────────────────────────────────────────────────────────────── def load(day: str) -> dict[str, list[dict]]: """받아 둔 원본 → {파일이름(.json.gz 뺀): 줄들}""" return { f.name.removesuffix(".json.gz"): json.loads(gzip.decompress(f.read_bytes())) for f in sorted((RAW / day).glob("*.json.gz")) } def latest() -> str: return max(p.name for p in RAW.iterdir() if p.is_dir()) ALIAS = { "경기": "경기도", "경남": "경상남도", "경북": "경상북도", "충남": "충청남도", "충북": "충청북도", "전남": "전라남도", "전북": "전라북도", "강원": "강원도", "제주": "제주특별자치도", "서울": "서울특별시", "부산": "부산광역시", "대구": "대구광역시", "인천": "인천광역시", "광주": "광주광역시", "대전": "대전광역시", "울산": "울산광역시", "세종": "세종특별자치시", } WORDS = re.compile(r"[가-힣]+") def places(text: str) -> frozenset: """지역 글 → 지명 낱말 묶음 — 약칭 풀고 「전지역」·「(협)」·조합 이름은 뺌.""" out = set() text = re.sub(r"전지역\([^)]*\)", "전지역", text or "") # 「전지역(구 나열)」 = 전지역 for w in WORDS.findall(text): if w in ("전지역", "지역", "협", "사업협동조합", "레미콘", "레미콘공업") or w.endswith( ("조합", "레미콘") ): continue w = ALIAS.get(w, w) if w.endswith(("광역시", "특별시", "특별자치시", "특별자치도")): w = w[:2] elif len(w) > 2 and w.endswith(("시", "군", "구", "도")) and w not in ("경기도", "강원도"): w = w[:-1] elif w in ("경기도", "강원도"): w = w[:2] out.add(w) return frozenset(out) def strength(text: str): """「25-18-080」·「25-18-80」 → (25, 18, 80) · 휨 강도 등은 글 그대로""" m = re.search(r"(\d+)-(\d+)-(\d+)", text or "") return tuple(int(x) for x in m.groups()) if m else None PROVINCE = frozenset(w for k, v in ALIAS.items() for w in places(k) | places(v)) def region_pick(cands: dict, want: frozenset): """cands = {지명묶음: 줄} — 같은 묶음 → 겹침 가장 큰 하나(동점이면 None).""" if want in cands: return cands[want], "지역 같음" best = sorted(((len(want & k) / len(want | k), k) for k in cands if want & k), reverse=True) if best and best[0][0] >= 0.5 and (len(best) == 1 or best[0][0] > best[1][0]): return cands[best[0][1]], f"지역 비슷함 {best[0][0]:.2f}" # 다산 묶음이 조달청 시군 줄 여럿을 합친 것 — 묶음 안 시군마다 조달청 줄이 있을 때만(값이 갈리면 pick 이 보류) towns = want - PROVINCE if len(towns) > 1: hit = [k for k in cands if k <= want and k - PROVINCE] if towns <= set().union(*hit) if hit else False: return [r for k in hit for r in cands[k]], f"지역 묶음 {len(hit)}줄" # 다산 묶음의 시군이 조달청 한 줄의 공급 지역 안에 모두 들어 있음 — 그 줄들의 최저가(값이 갈리면 pick 이 최저) sup = [k for k in cands if towns and towns <= k] if sup: return [r for k in sup for r in cands[k]], f"지역 포함 {len(sup)}줄" # 묶음 시군 가운데 일부만 조달청 줄이 있음 — 그 시군 줄만(같은 도 · 묶음 밖 시군이 낀 줄은 뺌) part = [k for k in cands if towns & k and k <= want | PROVINCE and k - PROVINCE <= want] if len(towns) > 1 and part: return [r for k in part for r in cands[k]], f"지역 일부 {len(part)}줄" return None, "지역 못 찾음" MASTER = ROOT / "resources/master_data/재료_자재품목.json" TMP = ROOT / "tmp" def newest(rows: list[dict]) -> dict: return max(rows, key=lambda r: (r["cntrctBgnDate"] or "", r["rgstDt"] or "")) def pick(rows: list[dict]): """같은 짝 후보가 여럿이면 값이 하나일 때 그 값 · 값이 갈리면 가장 낮은 값(제조사별 단가) — 사유를 함께 돌려줌.""" if not rows: return None, "후보 없음" vals = {int(r["cntrctPrceAmt"]) for r in rows} if len(vals) == 1: return newest(rows), "짝 하나" if len(rows) == 1 else f"같은 값 {len(rows)}건" low = min(vals) return newest( [r for r in rows if int(r["cntrctPrceAmt"]) == low] ), f"제조사별 값 {len(vals)}가지 중 최저" def _dia(t): m = re.search(r"[ΦD](\d+)", t or "") return int(m.group(1)) if m else None def _thick(t): m = re.search(r"[×xX*]t?(\d+(?:\.\d+)?)", t or "") return float(m.group(1)) if m else None def _size(t): m = re.search( r"(\d+(?:\.\d+)?)[×*x](\d+(?:\.\d+)?)[×*x](\d+(?:\.\d+)?)[×*x](\d+(?:\.\d+)?)", t or "" ) return tuple(float(x) for x in m.groups()) if m else None CORR_WORDS = ("PE양면피복", "PE내면피복", "아연도금", "유공관", "접속티", "커플링밴드", "직관") CORR_EXTRA = ("내부평활", "플랜지", "세라믹", "복합", "PE+PP", "PE/PP", "내식") class Matcher: """자재품목 줄 → 조달청 줄. 짝이 서면 (줄, 방식), 못 서면 (None, 까닭).""" def __init__(self, raw: dict): self.raw = raw self.rmc = {} for r in raw.get("MASCntrct_레미콘", []): if r["prdctMakrNm"] == "조합공통품목": self.rmc.setdefault(strength(r["prdctSpecNm"]), {}).setdefault( places(r["prdctSplyRgnNm"]), [] ).append(r) self.asc = {} for f in ( "MASCntrct_아스팔트콘크리트", "Ucntrct_아스팔트콘크리트", "ThptyUcntrct_아스팔트콘크리트", ): for r in raw.get(f, []): if "조합공통품목" in r["prdctSpecNm"]: k = ( r["prdctSpecNm"].startswith("순환"), re.sub(r"\s+", "", r["prdctSpecNm"].split("조합공통품목,")[1]), ) self.asc.setdefault(k, {}).setdefault(places(r["prdctSplyRgnNm"]), []).append(r) self.bar = raw.get("MASCntrct_봉강", []) self.hbeam = raw.get("MASCntrct_H빔", []) self.corr = raw.get("MASCntrct_파형강관", []) + raw.get("ThptyUcntrct_파형강관", []) self.hume = raw.get("MASCntrct_콘크리트관", []) + raw.get("Ucntrct_콘크리트관", []) @staticmethod def _region(table: dict, name: str): want = places(name.split(")-", 1)[1] if ")-" in name else name) cands, why = region_pick(table, want) if cands is None: # 구분 없이 붙은 시군(「진주사천남해…」)은 두 글자씩 끊어 다시 cut = frozenset( q for w in want for q in ([w[i : i + 2] for i in range(0, len(w), 2)] if len(w) >= 6 else [w]) ) if cut != want: cands, why = region_pick(table, cut) if cands is None or ( why.startswith(("지역 묶음", "지역 포함", "지역 일부")) and "제외" in name ): # 「~ 제외」 묶음은 짝짓지 않음 return None, why if cands is None else "지역 못 찾음" corp = re.findall( r"([가-힣]+)레미콘\(협\)", name ) # 이름에 조합이 붙은 줄(「제주…레미콘(협)」)은 그 조합 줄만 if corp and len(cands) > 1: cands = [r for r in cands if r["cntrctCorpNm"].startswith(corp[0])] or cands return cands, why def find(self, row: dict): r, why = self._find(row) if r is None: why = misc.absent(row["이름"], why) return r, why def _find(self, row: dict): n, spec = row["이름"], row["규격"] if n.startswith("레미콘(관급)-"): k = strength(spec) if k not in self.rmc: return None, "강도 없음(원문 규격이 조달청에 없음)" c, why = self._region(self.rmc[k], n) if c is None: return None, why r, how = pick(c) return r, f"{why} · {how}" if re.match(r"(순환)?아스콘\(관급\)-", n): k = ( n.startswith("순환"), re.sub( r"\s+", "", re.sub( r"^\s*[A-Z]\d-[HW]-[^,]*,", "", re.split(r"조합공통품목(?:\([^)]*\))?,", spec)[-1], ), ), # 「R3-H-BB2」 · 「A3-W-BB2」 = 앞의 관리 번호를 떼고 「BB-2, …」 ) if k not in self.asc: return None, "규격 없음(원문 규격이 조달청에 없음)" c, why = self._region(self.asc[k], n) if c is None: return None, why r, how = pick(c) return r, f"{why} · {how}" m = re.match(r"이형철근\((SD\d+)(,제주)?\)-관급", n) if m: d = re.search(r"HD=(\d+)", spec) zone = "제주" if m.group(2) else "전지역(제주제외)" cond = ( "생산공장상차도" if "생산공장" in spec else ("하치장상차도" if "하치장" in spec else None) ) c = ( [ r for r in self.bar if f"{m.group(1)}, D{d.group(1)}" in r["prdctSpecNm"] and r["prdctSplyRgnNm"].startswith(zone) and (cond is None or r["prdctDlvryCndtnNm"].replace(" ", "") == cond) ] if d else [] ) return pick(c) if n.startswith("H 형강(관급)"): sz = _size(spec) g = re.match(r"(SS\d+|SM\d+|SHN\d+)", spec) c = [ r for r in self.hbeam if sz and _size(r["prdctSpecNm"]) == sz and g and g.group(1) in r["prdctSpecNm"] and r["prdctSplyRgnNm"].startswith("전지역") ] return pick(c) if re.match(r"파형강관\(관급\)", n): dia, th = _dia(spec), _thick(spec) rs = re.search(r"(\d)RS", spec) if not (dia and th and rs): return None, "후보 없음" coat = [w for w in ("PE양면피복", "PE내면피복") if w in spec] c = [ # 먼저 「(nRS)」 꼴 — 이미 채운 값이 흔들리지 않게 · PE 피복 줄은 피복 글자가 맞아야 r for r in self.corr if _dia(r["prdctSpecNm"]) == dia and re.search(rf"t{th:g}mm", r["prdctSpecNm"]) and f"({rs.group(1)}RS)" in r["prdctSpecNm"] and all(re.search(rf"(? str: return next((k for k, pat in FAMILY if re.match(pat, name)), misc.label(name) or "그 밖") def procured(book: dict | None = None) -> dict[str, str]: """{자재품목 키: 조달청 원문번호} — 줄에 원문번호 칸이 없어 _키대장.json 으로 가름.""" book = book or mk.load_book() return { k: v["원문번호"] for k, v in book["키"].items() if v["파일"] == "재료_자재품목.json" and str(v["원문번호"]).startswith("조달청 ") } def info(r: dict) -> dict: return { "방식": r["cntrctMthdNm"], "품명": r["prdctSpecNm"], "지역": r["prdctSplyRgnNm"], "계약시작": r["cntrctBgnDate"], "부가세": r["vatAplDivNm"], "인도": r["prdctDlvryCndtnNm"], "단위": r["prdctUnit"], "제조사": r["cntrctCorpNm"] if r["prdctMakrNm"] == "조합공통품목" else r["prdctMakrNm"], } def refill(rows: list[dict], raw: dict) -> list[dict]: """조달 값(관급 열)이 있는 줄마다 조달청 값으로 다시 채운 결과.""" m, out, mine = Matcher(raw), [], procured() for x in rows: if x["키"] in mine: # 조달청에서 새로 들인 줄은 아래 `add_rows` 몫 continue if x["관급"] is None and not (x["비고"] or "").startswith( "관급:" ): # 지난번에 못 채운 줄도 다시 시도 continue r, why = m.find(x) if r is None: # 못 채운 줄은 규격 글자를 느슨하게 다시 봄 — 다산 옛 값 ±5% 안일 때만 채움 r, why = slow.find(x, m, why, pick, places, PROVINCE) out.append( { "키": x["키"], "이름": x["이름"], "규격": x["규격"], "옛값": x["관급"], "옛기간": x["비고"], "갈래": family(x["이름"]), "새값": int(r["cntrctPrceAmt"]) if r else None, "까닭": why, "조달청": info(r) if r else None, } ) return out def test(day: str | None = None) -> list[dict]: day = day or latest() res = refill(json.loads(MASTER.read_text(encoding="utf-8"))["줄"], load(day)) TMP.mkdir(exist_ok=True) (TMP / "관급_대조.json").write_text( json.dumps(res, ensure_ascii=False, indent=1), encoding="utf-8" ) tab: dict = {} for r in res: t = tab.setdefault( r["갈래"], {"전체": 0, "짝": 0, "맞음": 0, "다름": 0, "최저채움": 0, "못채움": 0, "까닭": {}}, ) t["전체"] += 1 if r["새값"] is None: t["못채움"] += 1 t["까닭"][r["까닭"]] = t["까닭"].get(r["까닭"], 0) + 1 else: t["짝"] += 1 ok = ( r["옛값"] and abs(r["새값"] / r["옛값"] - 1) <= 0.05 ) # 받는 시점 차이는 ±5% 안이면 맞음 t["맞음" if ok else "다름"] += 1 t["최저채움"] += "최저" in r["까닭"] for k, v in tab.items(): print( k, {a: b for a, b in v.items() if a != "까닭"}, dict(sorted(v["까닭"].items(), key=lambda kv: -kv[1])[:4]), ) return res METHOD = { "다수 공급자 계약": "다수공급자계약", "일반단가계약": "일반단가계약", "3자단가계약": "3자단가계약", } def decide(x: dict, r: dict, day: str) -> tuple[int | None, str]: """(새 관급 값, 새 비고) — 제조사별로 값이 갈리면 같은 규격의 가장 낮은 값 · 비고에 그 제조사.""" if r["새값"] is None: return None, f"관급: 조달청 값 못 채움 — {r['까닭']} ({day})" c = r["조달청"] loose = r["까닭"].startswith("느슨") # 느슨한 짝 — 제조사와 까닭을 함께 적음 who = f" {c['제조사']}" if loose else (f" {c['제조사']} 최저" if "최저" in r["까닭"] else "") who += " · 묶음 시군 일부만 조달청에 있음" if "지역 일부" in r["까닭"] else "" tail = f" · {r['까닭']}" if loose else "" return r["새값"], ( f"관급: 조달청 {METHOD.get(c['방식'], c['방식'])}{who} {day} · 계약 {c['계약시작']}~ · {c['지역']} · {c['인도']} · {c['부가세']}{tail}" ) def add_rows(lines: list[str], data: dict, raw: dict, day: str, changed: list) -> list[str]: """조달청에만 있는 품목 — 자재품목에 새 줄(관급만 · 물가지 네 열 null · 같은 품명·규격은 최저가). 이미 든 줄은 값·비고만 갈음.""" book, new = mk.load_book(), [] mine = procured(book) have = {mine[x["키"]]: x for x in data["줄"] if x["키"] in mine} for num, (name, spec, rows) in misc.groups(raw).items(): r, how = pick(rows) val, note = decide( None, {"새값": int(r["cntrctPrceAmt"]), "까닭": how, "조달청": info(r)}, day ) if num in have: for i, line in enumerate(lines): if f'"키": "{have[num]["키"]}"' in line: line = re.sub( r'"관급": (?:null|-?[\d.]+)', lambda _: f'"관급": {val}', line, count=1 ) lines[i] = re.sub( r'"비고": (?:null|"(?:[^"\\]|\\.)*")', lambda _: f'"비고": {json.dumps(note, ensure_ascii=False)}', line, count=1, ) break continue key = mk.issue(book, "MT", num, "재료_자재품목.json") row = { "키": key, "구분": None, "상세구분": None, "이름": name, "규격": spec, "단위": r["prdctUnit"], "물가자료": None, "유통물가": None, "물가정보": None, "거래가격": None, "관급": val, "출처": f"자재품목 조달청 {r['cntrctMthdNm']}", "비고": note, "면수": None, } # fmt: skip new.append( " { " + ", ".join(f'"{k}": {json.dumps(v, ensure_ascii=False)}' for k, v in row.items()) + " }" ) changed.append( { "키": key, "이름": name, "규격": spec, "갈래": "새 줄", "옛값": None, "새값": val, "비고": note, } ) if new: end = max(i for i, line in enumerate(lines) if line.strip() == "]") lines[end - 1] += "," lines[end:end] = [ln + "," for ln in new[:-1]] + new[-1:] mk.save_book(mk.finish(book)) return lines def add_boost(lines: list[str], data: dict, changed: list) -> list[str]: """보강 후보 중 새 줄로 더하는 품명(boost.NEW) — 이미 든 줄은 값·비고만 갈음.""" book = mk.load_book() at = {x["키"]: x for x in data["줄"]} dasan = {(x["이름"], x["규격"] or "") for x in data["줄"] if "조달청" not in x["출처"]} pos = { m.group(1): i for i, ln in enumerate(lines) if (m := re.match(r'\s*\{ "키": "([^"]+)"', ln)) } new = [] for row in boost.new_rows(book, mk.issue, dasan): if row["키"] in at: i = pos[row["키"]] lines[i] = re.sub( r'"관급": (?:null|-?[\d.]+)', lambda _: f'"관급": {row["관급"]}', lines[i], count=1 ) lines[i] = re.sub( r'"비고": (?:null|"(?:[^"\\]|\\.)*")', lambda _: f'"비고": {json.dumps(row["비고"], ensure_ascii=False)}', lines[i], count=1, ) continue new.append( " { " + ", ".join(f'"{k}": {json.dumps(v, ensure_ascii=False)}' for k, v in row.items()) + " }" ) changed.append({"키": row["키"], "이름": row["이름"], "규격": row["규격"], "갈래": "보강 새 줄", "옛값": None, "새값": row["관급"], "비고": row["비고"]}) # fmt: skip if new: end = max(i for i, line in enumerate(lines) if line.strip() == "]") lines[end - 1] += "," lines[end:end] = [ln + "," for ln in new[:-1]] + new[-1:] mk.save_book(mk.finish(book)) return lines def apply(day: str | None = None) -> None: """정본 관급 열 · 비고만 바꿈 — 다산소프트 값은 하나도 안 남김(못 채운 줄 = null).""" day = day or latest() text = MASTER.read_bytes().decode("utf-8") crlf = "\r\n" in text # 작업 폴더 줄바꿈(CRLF)을 그대로 지킴 text = text.replace("\r\n", "\n") data = json.loads(text) found = {r["키"]: r for r in refill(data["줄"], load(day))} changed, edits = [], {} for x in data["줄"]: r = found.get(x["키"]) if r is None: continue val, note = decide(x, r, day) changed.append( { "키": x["키"], "이름": x["이름"], "규격": x["규격"], "갈래": r["갈래"], "옛값": x["관급"], "새값": val, "비고": note, } ) edits[x["키"]] = (val, note) now = {x["키"]: edits[x["키"]][0] if x["키"] in edits else x["관급"] for x in data["줄"]} for k, (val, note) in boost.fills(data["줄"], now, places, PROVINCE).items(): x = next(y for y in data["줄"] if y["키"] == k) changed[:] = [c for c in changed if c["키"] != k] # 앞서 못 채움으로 적힌 줄을 갈음 changed.append({"키": k, "이름": x["이름"], "규격": x["규격"], "갈래": "보강 채움", "옛값": x["관급"], "새값": val, "비고": note}) # fmt: skip edits[k] = (val, note) # 글 자리에서 「관급」·「비고」 두 칸만 갈음 — 나머지 서식·칸은 그대로 lines = text.split("\n") put = lambda v: json.dumps(v, ensure_ascii=False) # noqa: E731 for i, line in enumerate(lines): m = re.match(r'\s*\{ "키": "([^"]+)"', line) if not m or m.group(1) not in edits: continue val, note = edits.pop(m.group(1)) line, n1 = re.subn( r'"관급": (?:null|-?[\d.]+)', lambda _: f'"관급": {put(val)}', line, count=1 ) line, n2 = re.subn( r'"비고": (?:null|"(?:[^"\\]|\\.)*")', lambda _: f'"비고": {put(note)}', line, count=1 ) assert n1 == n2 == 1, f"{m.group(1)} 줄 서식이 달라 멈춤" lines[i] = line assert not edits, f"정본에서 못 찾은 줄 {len(edits)}" lines = add_rows(lines, data, load(day), day, changed) lines = add_boost(lines, data, changed) out = "\n".join(lines) MASTER.write_bytes((out.replace("\n", "\r\n") if crlf else out).encode("utf-8")) TMP.mkdir(exist_ok=True) (TMP / "관급_바뀐줄.json").write_text( json.dumps(changed, ensure_ascii=False, indent=1), encoding="utf-8" ) ok = sum(c["새값"] is not None for c in changed) print( f"관급 열 {len(changed)} 줄 중 채움 {ok} · null {len(changed) - ok} — 목록 tmp/관급_바뀐줄.json" ) def update() -> None: day = fetch_all().name apply(day) if __name__ == "__main__": mode = sys.argv[1] if len(sys.argv) > 1 else "fetch" if mode == "fetch": fetch_all() elif mode == "test": test(*sys.argv[2:3]) elif mode == "apply": apply(*sys.argv[2:3]) elif mode == "update": update()