feat(master_data): 관급 단가 받기 — 조달청 종합쇼핑몰 API 원본 보관 · update_관급단가.py 받기·짝짓기 시작
- 받기: 레미콘 · 아스팔트콘크리트 · 봉강(철근) · H빔 · 파형강관 · 콘크리트관 — MAS · 일반단가 · 3자단가 (gz 원본 + _meta.md) - 짝짓기 도우미: 지명 묶음 · 강도 · 지역 고르기 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PAdA5ThqmtVSsbzjusk1cJ
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,17 @@
|
||||
# 관급 단가 원본 — 조달청 종합쇼핑몰 품목정보 서비스
|
||||
|
||||
받은 날 2026-09-20 · 수집 `scripts/update_관급단가.py fetch` · 조건 = inqryDiv=2 · prdctClsfcNoNm=<분류명> · 쪽당 999
|
||||
|
||||
| 파일 | 오퍼레이션 | 분류명 | 줄 수 |
|
||||
|---|---|---|---|
|
||||
| MASCntrct_레미콘.json.gz | getMASCntrctPrdctInfoList | 레미콘 | 26,384 (totalCount 26,384) |
|
||||
| ThptyUcntrct_레미콘.json.gz | getThptyUcntrctPrdctInfoList | 레미콘 | 128 (totalCount 128) |
|
||||
| MASCntrct_아스팔트콘크리트.json.gz | getMASCntrctPrdctInfoList | 아스팔트콘크리트 | 17,949 (totalCount 17,949) |
|
||||
| Ucntrct_아스팔트콘크리트.json.gz | getUcntrctPrdctInfoList | 아스팔트콘크리트 | 17 (totalCount 17) |
|
||||
| ThptyUcntrct_아스팔트콘크리트.json.gz | getThptyUcntrctPrdctInfoList | 아스팔트콘크리트 | 13 (totalCount 13) |
|
||||
| MASCntrct_봉강.json.gz | getMASCntrctPrdctInfoList | 봉강 | 1,039 (totalCount 1,039) |
|
||||
| MASCntrct_H빔.json.gz | getMASCntrctPrdctInfoList | H빔 | 181 (totalCount 181) |
|
||||
| MASCntrct_파형강관.json.gz | getMASCntrctPrdctInfoList | 파형강관 | 6,970 (totalCount 6,970) |
|
||||
| ThptyUcntrct_파형강관.json.gz | getThptyUcntrctPrdctInfoList | 파형강관 | 1,573 (totalCount 1,573) |
|
||||
| MASCntrct_콘크리트관.json.gz | getMASCntrctPrdctInfoList | 콘크리트관 | 504 (totalCount 504) |
|
||||
| Ucntrct_콘크리트관.json.gz | getUcntrctPrdctInfoList | 콘크리트관 | 10 (totalCount 10) |
|
||||
@@ -0,0 +1,140 @@
|
||||
"""관급 단가 갱신 — 조달청 종합쇼핑몰 API 에서 받아 자재품목(재료_시중물가) 「조달」 칸을 채움.
|
||||
|
||||
python update_관급단가.py fetch # 받기 → 원본 보관 (resources/knowledge/original/원가계산/자재단가/관급/<날짜>/)
|
||||
python update_관급단가.py fill [날짜] # 원본 → 사본에서 조달 칸을 지우고 다시 채움 · 다산소프트 값과 맞대기
|
||||
마스터 정본은 덮지 않음 — 사본(tmp/관급_사본.json)과 대조표(tmp/관급_대조.json)만 씀.
|
||||
"""
|
||||
|
||||
import gzip
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
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)
|
||||
|
||||
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, "콘크리트관"),
|
||||
]
|
||||
# 보관하는 칸 — 나머지(첨부·원산지·이미지·긴 설명)는 버림(원본이 수백 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}"
|
||||
return json.loads(cc.fetch(url, timeout=90).decode("utf-8", "replace"))["response"]
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
f = f"{op.removeprefix('get').removesuffix('PrdctInfoList')}_{name}.json.gz"
|
||||
(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
|
||||
|
||||
|
||||
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}"
|
||||
return None, "지역 못 찾음"
|
||||
if __name__ == "__main__":
|
||||
mode = sys.argv[1] if len(sys.argv) > 1 else "fetch"
|
||||
if mode == "fetch":
|
||||
fetch_all()
|
||||
Reference in New Issue
Block a user