"""지역 글 → 시도 · 시군구 두 칸(요구 842 · PLAN 830-11) — 표 = `_행정구역.json` · 시험 = test_830_11_material_sido. `split(글)` → (시도, 시군구, 조합 글 | None) - 없음(-) = (-, -) · 전국 = (전국, 전체) · 시도만 = (시도, 전체) · 「전지역」 은 전체 - 시군구만 = 표로 시도를 찾음 · 시도 + 시군구 = 둘 다 · 여러 시군구 · 시도 = 쉼표(적힌 차례) - 읍면 = 「시군구(읍면)」 꼴 · 「… 제외」 = 시군구 칸 끝에 「 제외」 - 옛 이름 · 긴 이름 · 「…지역」 = 지금 짧은 이름 · 조합 이름(「…(협)」) = 셋째(비고 몫) - 30 자 잘린 끝 낱말 = 같은 시도 안 앞글자가 하나뿐인 시군구로 채움 - 못 풀면 ValueError(빌더가 멈춤). """ from __future__ import annotations import json import re from functools import lru_cache from pathlib import Path DB = Path(__file__).resolve().parents[1] / "_행정구역.json" NONE = "-" NATION = "전국" WHOLE = "전체" EXCEPT = "제외" _UNION = re.compile(r",?\s*[가-힣]+\(협\)") _CUT = ",.()및 " def short(name: str) -> str: """짧은 이름 — 끝 시 · 군 · 구를 뗌(남는 글이 두 자 넘을 때만) · 「송파구」 → 송파 · 「중구」 그대로.""" return name[:-1] if len(name) > 2 and name[-1] in "시군구" else name @lru_cache(maxsize=1) def table() -> dict: """{시도: [시군구 짧은 이름]} · 낱말 사전 {글: [뜻]} · 옛 이름 글 묶음.""" db = json.loads(DB.read_text(encoding="utf-8")) sido = {s["이름"]: [short(g) for g in s["시군구"]] for s in db["시도"]} words: dict[str, list[tuple]] = {} # 뜻 끝 「옛」 = 옛 이름으로 풂 def put(text: str, meaning: tuple) -> None: words.setdefault(text, []) if meaning not in words[text]: words[text].append(meaning) for s in db["시도"]: for text in (s["이름"], s["정식"], s["정식"].removesuffix("도")): put(text, ("시도", s["이름"])) for g in s["시군구"]: for text in {g, short(g)}: put(text, ("시군구", s["이름"], short(g))) put(NATION, ("시도", NATION)) for text in ("전지역", WHOLE): put(text, ("전체",)) put(EXCEPT, ("제외",)) for kind, book in (("옛", db["옛이름"]), ("별", db["별칭"])): for src, dst in book.items(): to, src_name = dst.split(), src.split()[-1] mark = ("옛",) if kind == "옛" and short(src_name) != short(to[-1]) else () meaning = ("시도", to[0]) if len(to) == 1 else ("시군구", to[0], short(to[1])) for text in {src_name, short(src_name)}: put(text, meaning + mark) return {"시도": sido, "낱말": words} def _tokens(text: str) -> list[tuple]: """(종류, 뜻 | 글) 줄 — 사전 낱말은 가장 긴 것 · 시군구 뒤 괄호 · 띄움 글 = 읍면(그대로 글).""" words = table()["낱말"] out, i, n = [], 0, len(text) longest = max(map(len, words)) while i < n: ch = text[i] if ch in "()": out.append((ch, None)) i += 1 continue if ch in ",.및 ": out.append(("띄움" if ch == " " else "끊음", None)) i += 1 continue hit = next( ( text[i : i + k] for k in range(min(longest, n - i), 0, -1) if text[i : i + k] in words ), None, ) if hit and text[i + len(hit) :].startswith("지역"): # 충남지역 · 제주지역 out.append(("낱말", hit, words[hit])) i += len(hit) + 2 continue if hit: out.append(("낱말", hit, words[hit])) i += len(hit) if words[hit][0][0] == "시군구" and i < n: emd = _emd(text, i) if emd: out.append(("읍면", emd[0])) i = emd[1] continue j = i while j < n and text[j] not in _CUT: j += 1 out.append(("글", text[i:j])) i = j return out def _emd(text: str, i: int) -> tuple[str, int] | None: """시군구 바로 뒤 읍면 글 — 「(미원면 제외)」 · 「 내면」 → (글, 다음 자리) · 아니면 None.""" words = table()["낱말"] if text[i] == "(": depth, j = 0, i while j < len(text): depth += {"(": 1, ")": -1}.get(text[j], 0) if depth == 0: break j += 1 return text[i + 1 : j], j + 1 if text[i] == " ": j = i + 1 while j < len(text) and text[j] not in ",)": j += 1 word = text[i + 1 : j].strip() if word and word not in words and not any(w in words for w in word.split()): return word, j return None def split(text) -> tuple[str, str, str | None]: """지역 글 → (시도, 시군구, 조합 글 | None) — 맨 위 설명.""" return _parse(text)[:3] def is_old(text) -> bool: """지역 글을 옛 이름(개편 전 이름)으로 풀었나 — 보고 셈용.""" return _parse(text)[3] def _parse(text) -> tuple[str, str, str | None, bool]: raw = str(text or "").strip() if raw in ("", NONE): return NONE, NONE, None, False unions = [u.lstrip(", ") for u in _UNION.findall(raw)] body = _UNION.sub("", raw).strip(", ") sidos: list[str] = [] towns: list[tuple[str, str]] = [] # (시도, 짧은 이름(읍면)) cur, cut, old, i = None, False, False, 0 tokens = _tokens(body) while i < len(tokens): tok = tokens[i] if tok[0] == "낱말": meanings = tok[2] kinds = {m[0] for m in meanings} if kinds == {"전체"}: pass elif kinds == {"제외"}: cut = True elif "시도" in kinds and ( cur is None or "시군구" not in kinds or not _inside(tokens, i) ): got = next(m for m in meanings if m[0] == "시도") cur, old = got[1], old or got[-1] == "옛" if cur not in sidos: sidos.append(cur) else: got = _town(meanings, cur, sidos, raw) towns.append(got[1:3]) old = old or got[-1] == "옛" elif tok[0] == "읍면" and towns: s, g = towns[-1] towns[-1] = (s, f"{g}({tok[1]})") elif tok[0] == "글": if i != len(tokens) - 1 or body.count("(") <= body.count(")"): raise ValueError(f"지역 글 못 풂 — 「{tok[1]}」 ({raw})") towns.append(_complete(tok[1], cur, sidos, raw)) i += 1 for s, _ in towns: if s not in sidos: sidos.append(s) if not sidos: raise ValueError(f"지역 글 못 풂 — 시도 없음 ({raw})") names = list(dict.fromkeys(g for _, g in towns)) sgg = ",".join(names) + (f" {EXCEPT}" if cut else "") if names else WHOLE sido = ",".join(sidos) return sido, (WHOLE if sido == NATION else sgg), " · ".join(unions) or None, old def _inside(tokens: list[tuple], i: int) -> bool: """i 번 낱말이 괄호 안인지.""" depth = 0 for t in tokens[:i]: depth += {"(": 1, ")": -1}.get(t[0], 0) return depth > 0 def _town(meanings: list[tuple], cur, sidos: list[str], raw: str) -> tuple: """시군구 낱말 → 뜻(「시군구」, 시도, 짧은 이름[, 「옛」]) — 지금 시도 › 적힌 시도 › 전국에서 하나뿐인 것.""" towns = [m for m in meanings if m[0] == "시군구"] for pool in ([cur], sidos, None): got = [m for m in towns if pool is None or m[1] in pool] if len({(m[1], m[2]) for m in got}) == 1: return got[0] raise ValueError(f"시군구 소속 못 정함 — {[m[1:] for m in towns]} ({raw})") def _complete(head: str, cur, sidos: list[str], raw: str) -> tuple[str, str]: """잘린 끝 낱말 → 앞글자가 하나뿐인 시군구(지금 시도 › 적힌 시도 › 전국).""" sido = table()["시도"] for pool in ([cur] if cur else [], sidos, list(sido)): got = [(s, g) for s in pool for g in sido.get(s, []) if g.startswith(head)] if len(got) == 1: return got[0] raise ValueError(f"잘린 지역 글 못 풂 — 「{head}」 ({raw})") def valid_sido(value) -> bool: """시도 칸 값 — 「-」 · 「전국」 · 시도 짧은 이름(쉼표로 여럿).""" if value in (NONE, NATION): return True parts = str(value or "").split(",") return all(p in table()["시도"] for p in parts) and len(set(parts)) == len(parts)