Enable official English address lookup for Korean postcode searches

The zipcode-search feature now uses the official ePost integrated search surface
for postcode plus English-address lookups, ships a runnable helper, and
locks the behavior with regression coverage plus aligned docs.

A narrow compatibility fallback was also added to the KIPRIS XML parser so
repository CI stays green on the current Python 3.14 environment where
pyexpat is unavailable.

Constraint: Must use official public ePost output instead of custom romanization rules
Constraint: Repository verification must pass under the current local Python 3.14 toolchain
Rejected: Implement our own Hangul-to-English address formatter | would diverge from the official postal rendering
Rejected: Leave the KIPRIS parser untouched | npm run ci currently fails in this environment without the XML fallback
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep zipcode-search tied to the official ePost integrated surface unless a new approved source is added
Tested: python3 -m unittest scripts.test_zipcode_search
Tested: node --test scripts/skill-docs.test.js
Tested: python3 scripts/zipcode_search.py '서울특별시 강남구 테헤란로 123'
Tested: npm run build
Tested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_patent_search
Tested: npm run ci
Not-tested: Live no-result and multi-result zipcode queries beyond the verified Teheran-ro example
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-10 10:34:25 +09:00
commit 5c95e9e742
10 changed files with 460 additions and 93 deletions

View file

@ -45,7 +45,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
| 근처 술집 조회 | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | 주소 키워드로 우편번호 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 우편번호 검색 | 주소 키워드로 우편번호 + 공식 영문주소 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | 다이소 매장별 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 마켓컬리 상품 조회 | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
| 올리브영 검색 | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |

View file

@ -1,40 +1,68 @@
# 우편번호 검색 가이드
# 우편번호 + 영문주소 검색 가이드
## 이 기능으로 할 수 있는 일
- 주소 키워드로 공식 우체국 우편번호 조회
- 같은 도로명/건물명 후보가 여러 개일 때 상위 결과 비교
- 같은 후보의 국문 도로명/지번 주소와 공식 영문 주소를 함께 비교
- 검색 결과가 없을 때 바로 재검색 키워드 조정
## 먼저 필요한 것
- 인터넷 연결
- `curl`
- 선택 사항: `python3`
- `python3`
## 입력값
- 주소 키워드
- 예: `세종대로 209`
- 예: `판교역로 235`
- 예: `서울특별시 강남구 테헤란로 123`
- 예: `역삼동 648-23`
## 기본 흐름
1. 비공식 지도/블로그 검색으로 우회하지 말고 우체국 공식 검색 페이지를 먼저 조회합니다.
1. 비공식 변환기나 블로그 표기로 우회하지 말고 우체국 공식 통합 검색 페이지를 먼저 조회합니다.
2. 주소 키워드를 `keyword` 파라미터로 넘겨 HTML 결과를 받습니다.
3. 결과에서 우편번호(`sch_zipcode`)와 표준 주소(`sch_address1`), 건물명(`sch_bdNm`)을 추출합니다.
3. 결과에서 `viewDetail(zip, roadAddress, englishAddress, jibunAddress, rowIndex)` 패턴을 추출합니다.
4. 후보가 여러 개면 상위 3~5개만 간단히 비교해 줍니다.
5. 전송 timeout/reset이 나면 `curl` 재시도 옵션을 유지한 채 한 번 더 돌리고, 그래도 실패하면 `세종대로 209` 같은 짧은 도로명 + 건물번호 → `서울 종로구 세종대로 209` 같은 시/군/구 포함 전체 주소 → 동/리 + 지번 순으로 재시도합니다.
5. 전송 timeout/reset이 나면 `curl` 재시도 옵션을 유지한 채 한 번 더 돌리고, 그래도 실패하면 `테헤란로 123` 같은 짧은 도로명 + 건물번호 → `서울 강남구 테헤란로 123` 같은 시/군/구 포함 전체 주소 → 동/리 + 지번 순으로 재시도합니다.
## 공식 endpoint
```text
https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm
```
검색 결과 표에는 `English/집배코드` 열이 있고, 실제 값은 `viewDetail(...)` 인자와 상세 행에 함께 들어 있습니다.
## 예시
```bash
python3 scripts/zipcode_search.py "서울특별시 강남구 테헤란로 123"
```
```json
{
"query": "서울특별시 강남구 테헤란로 123",
"results": [
{
"zip_code": "06133",
"road_address": "서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)",
"english_address": "123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA",
"jibun_address": "서울특별시 강남구 역삼동 648-23 (여삼빌딩)"
}
]
}
```
## raw HTML 추출 예시
```bash
python3 - <<'PY'
import html
import re
import subprocess
query = "세종대로 209"
query = "서울특별시 강남구 테헤란로 123"
cmd = [
"curl",
"--http1.1",
@ -53,29 +81,27 @@ cmd = [
"--get",
"--data-urlencode",
f"keyword={query}",
"https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp",
"https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm",
]
result = subprocess.run(
page = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
page = result.stdout
).stdout
matches = re.findall(
r'name="sch_zipcode"\s+value="([^"]+)".*?name="sch_address1"\s+value="([^"]+)".*?name="sch_bdNm"\s+value="([^"]*)"',
r"viewDetail\('([^']*)','([^']*)','([^']*)','([^']*)',\s*'[^']*'\)",
page,
re.S,
)
if not matches:
raise SystemExit("검색 결과가 없습니다.")
for zip_code, address, building in matches[:5]:
suffix = f" ({building})" if building else ""
print(f"{zip_code}\t{html.unescape(address)}{suffix}")
for zip_code, road_address, english_address, jibun_address in matches[:5]:
print(zip_code)
print(html.unescape(road_address))
print(html.unescape(english_address))
print(html.unescape(jibun_address))
print("---")
PY
```
@ -83,15 +109,14 @@ PY
- 쉘 래퍼나 에이전트 환경에서는 here-doc + Python one-liner보다 `mktemp` 같은 임시 파일에 HTML을 저장한 뒤 파싱하는 쪽이 더 안전합니다.
- 응답 일부만 빨리 보려고 `curl ... | head` 를 붙이면 다운스트림이 먼저 닫히면서 `curl: (23)` 이 보일 수 있습니다. 이때는 전체 응답을 임시 파일에 저장한 뒤 확인합니다.
- 재시도 순서는 보통 `세종대로 209` 같은 짧은 도로명 + 건물번호 → `서울 종로구 세종대로 209` 같은 전체 주소 → 동/리 + 지번 순이 가장 덜 헷갈립니다.
- 기본은 우체국 공식 영문 주소를 그대로 유지하고, 외부 서비스가 국가명 축약을 싫어할 때만 후처리를 따로 합니다.
## 프로토콜/클라이언트 제약
- 현재 ePost 엔드포인트는 로컬 기본 `urllib` 전송으로 붙으면 TLS/HTTP 협상 중 연결 reset이 날 수 있습니다.
- 현재 ePost 엔드포인트는 같은 curl 플래그여도 간헐적인 timeout/reset이 있을 수 있으므로 문서 기본 예시는 `--retry 3 --retry-all-errors --retry-delay 1`을 포함합니다.
- 현재 ePost 통합 검색 엔드포인트는 같은 curl 플래그여도 간헐적인 timeout/reset이 있을 수 있으므로 기본 예시는 `--retry 3 --retry-all-errors --retry-delay 1`을 포함합니다.
- 문서 기본 예시는 `curl --http1.1 --tls-max 1.2` 전송을 사용하고, Python은 응답 파싱/정리에만 사용합니다.
- 바깥쪽 Python `timeout`은 두지 않고 `curl` 자체 제한(`--max-time` + `--retry`)으로 전체 전송 시간을 제어합니다.
- 다른 클라이언트를 쓰더라도 최소한 HTTP/1.1 + TLS 1.2 경로에서 실제 응답을 먼저 확인한 뒤 정규식 추출을 붙입니다.
- 다른 클라이언트를 쓰더라도 최소한 HTTP/1.1 + TLS 1.2 경로에서 실제 응답을 먼저 확인한 뒤 `viewDetail(...)` 추출을 붙입니다.
## 주의할 점

View file

@ -110,6 +110,7 @@
- 한강홍수통제소 Open API 정책: https://www.hrfco.go.kr/web/openapi/policy.do
- 한강홍수통제소 API base: https://api.hrfco.go.kr
- 우체국 도로명주소 검색: https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp
- 우체국 통합 우편번호/영문주소 검색: https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm
- CJ대한통운 배송조회: https://www.cjlogistics.com/ko/tool/parcel/tracking
- CJ대한통운 배송상세 JSON: https://www.cjlogistics.com/ko/tool/parcel/tracking-detail
- 우체국 배송조회: https://service.epost.go.kr/trace.RetrieveRegiPrclDeliv.postal?sid1=

View file

@ -9,6 +9,7 @@ import urllib.parse
import urllib.request
import xml.etree.ElementTree as ET
from dataclasses import asdict, dataclass
from html.parser import HTMLParser
from typing import Callable
SERVICE_KEY_ENV_VAR = "KIPRIS_PLUS_API_KEY"
@ -75,6 +76,40 @@ class PatentDetail:
big_drawing: str | None
@dataclass
class XmlNode:
tag: str
children: list["XmlNode"]
text_chunks: list[str]
@property
def text(self) -> str:
return "".join(self.text_chunks)
class XmlNodeBuilder(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.root: XmlNode | None = None
self.stack: list[XmlNode] = []
def handle_starttag(self, tag: str, attrs) -> None: # type: ignore[override]
node = XmlNode(tag=tag, children=[], text_chunks=[])
if self.stack:
self.stack[-1].children.append(node)
else:
self.root = node
self.stack.append(node)
def handle_endtag(self, tag: str) -> None: # type: ignore[override]
if self.stack:
self.stack.pop()
def handle_data(self, data: str) -> None: # type: ignore[override]
if self.stack:
self.stack[-1].text_chunks.append(data)
def clean_text(value: str | None) -> str | None:
if value is None:
return None
@ -145,10 +180,45 @@ def fetch_xml(url: str, params: dict[str, str], timeout: int = DEFAULT_TIMEOUT)
raise RuntimeError(f"Failed to reach KIPRIS Plus API: {exc.reason}") from exc
def get_child_text(element: ET.Element | None, tag_name: str) -> str | None:
def normalize_tag(tag_name: str) -> str:
return tag_name.casefold()
def iter_children(element: ET.Element | XmlNode | None) -> list[ET.Element | XmlNode]:
if element is None:
return None
child = element.find(tag_name)
return []
if isinstance(element, XmlNode):
return element.children
return list(element)
def find_child(element: ET.Element | XmlNode | None, tag_name: str) -> ET.Element | XmlNode | None:
normalized_tag = normalize_tag(tag_name)
for child in iter_children(element):
if normalize_tag(child.tag) == normalized_tag:
return child
return None
def find_children(element: ET.Element | XmlNode | None, tag_name: str) -> list[ET.Element | XmlNode]:
normalized_tag = normalize_tag(tag_name)
return [child for child in iter_children(element) if normalize_tag(child.tag) == normalized_tag]
def parse_xml_with_fallback(xml_text: str) -> XmlNode:
parser = XmlNodeBuilder()
try:
parser.feed(xml_text)
parser.close()
except Exception as exc: # pragma: no cover - defensive fallback guard
raise RuntimeError(f"Failed to parse KIPRIS Plus XML response: {exc}") from exc
if parser.root is None:
raise RuntimeError("Failed to parse KIPRIS Plus XML response: empty document")
return parser.root
def get_child_text(element: ET.Element | XmlNode | None, tag_name: str) -> str | None:
child = find_child(element, tag_name)
return clean_text(child.text if child is not None else None)
@ -158,20 +228,21 @@ def parse_int(value: str | None) -> int | None:
return int(value)
def parse_xml_response(xml_text: str) -> ET.Element:
def parse_xml_response(xml_text: str) -> ET.Element | XmlNode:
try:
root = ET.fromstring(xml_text)
except ET.ParseError as exc:
raise RuntimeError(f"Failed to parse KIPRIS Plus XML response: {exc}") from exc
except (ET.ParseError, ImportError):
root = parse_xml_with_fallback(xml_text)
result_code = get_child_text(root.find("header"), "resultCode")
result_msg = get_child_text(root.find("header"), "resultMsg")
header = find_child(root, "header")
result_code = get_child_text(header, "resultCode")
result_msg = get_child_text(header, "resultMsg")
if result_code and result_code != "00":
raise RuntimeError(result_msg or f"KIPRIS Plus API error code {result_code}")
return root
def parse_patent_item(item: ET.Element) -> PatentSearchResult:
def parse_patent_item(item: ET.Element | XmlNode) -> PatentSearchResult:
application_number = get_child_text(item, "applicationNumber")
if not application_number:
raise RuntimeError("KIPRIS Plus response item is missing applicationNumber")
@ -198,9 +269,9 @@ def parse_patent_item(item: ET.Element) -> PatentSearchResult:
def parse_patent_search_response(xml_text: str, *, query: str) -> PatentSearchResponse:
root = parse_xml_response(xml_text)
body = root.find("body")
items_parent = body.find("items") if body is not None else None
item_elements = items_parent.findall("item") if items_parent is not None else []
body = find_child(root, "body")
items_parent = find_child(body, "items")
item_elements = find_children(items_parent, "item")
items = [parse_patent_item(item) for item in item_elements]
return PatentSearchResponse(
query=query,
@ -213,11 +284,11 @@ def parse_patent_search_response(xml_text: str, *, query: str) -> PatentSearchRe
def parse_patent_detail_response(xml_text: str) -> PatentDetail:
root = parse_xml_response(xml_text)
body = root.find("body")
item = body.find("item") if body is not None else None
body = find_child(root, "body")
item = find_child(body, "item")
if item is None and body is not None:
items_parent = body.find("items")
item = items_parent.find("item") if items_parent is not None else None
items_parent = find_child(body, "items")
item = find_child(items_parent, "item")
if item is None:
raise RuntimeError("KIPRIS Plus detail response did not include an item payload")

View file

@ -9,9 +9,9 @@
],
"scripts": {
"build": "npm run build --workspaces --if-present",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"test": "node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_mfds_food_safety scripts.test_zipcode_search && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run",
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
"version-packages": "changeset version",

View file

@ -528,40 +528,40 @@ test("repository docs advertise the zipcode-search skill across the documented s
assert.match(sources, /우체국 도로명주소 검색: https:\/\/parcel\.epost\.go\.kr\/parcel\/comm\/zipcode\/comm_newzipcd_list\.jsp/);
});
test("zipcode-search docs lock the official ePost extraction flow and reliable transport example", () => {
test("zipcode-search docs lock the official postcode plus English-address extraction flow", () => {
const skillPath = path.join(repoRoot, "zipcode-search", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected zipcode-search/SKILL.md to exist");
const skill = read(path.join("zipcode-search", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "zipcode-search.md"));
const readme = read("README.md");
const sources = read(path.join("docs", "sources.md"));
assert.match(skill, /^name: zipcode-search$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /parcel\.epost\.go\.kr\/parcel\/comm\/zipcode\/comm_newzipcd_list\.jsp/);
assert.match(doc, /sch_zipcode/);
assert.match(doc, /sch_address1/);
assert.match(doc, /sch_bdNm/);
assert.match(doc, /https:\/\/www\.epost\.kr\/search\.RetrieveIntegrationNewZipCdList\.comm/);
assert.match(doc, /viewDetail/);
assert.match(doc, /English\/집배코드/);
assert.match(doc, /Rep\. of KOREA/);
assert.match(doc, /curl --http1\.1 --tls-max 1\.2/);
assert.match(doc, /--max-time/);
assert.match(doc, /"--retry",\s+"3"/);
assert.match(doc, /--retry-all-errors/);
assert.match(doc, /"--retry-delay",\s+"1"/);
assert.match(doc, /영문 주소|영문주소/);
assert.match(doc, /python3 scripts\/zipcode_search\.py/);
assert.match(doc, /mktemp|임시 파일/);
assert.match(doc, /curl: \(23\)/);
assert.match(doc, /짧은 도로명 \+ 건물번호/);
assert.match(doc, /시\/군\/구 포함 전체 주소/);
assert.doesNotMatch(doc, /urllib\.request/);
assert.doesNotMatch(doc, /urlopen/);
}
assert.match(readme, /우편번호 \+ 공식 영문주소 조회/);
assert.match(sources, /우체국 통합 우편번호\/영문주소 검색: https:\/\/www\.epost\.kr\/search\.RetrieveIntegrationNewZipCdList\.comm/);
assert.match(skill, /검색 결과가 없으면/i);
assert.doesNotMatch(skill, /timeout\s*=/);
assert.doesNotMatch(featureDoc, /timeout\s*=/);
assert.match(skill, /`curl` 자체 제한/);
assert.match(featureDoc, /프로토콜\/클라이언트 제약/i);
assert.match(featureDoc, /`curl` 자체 제한/);
});
test("repository docs advertise the delivery-tracking skill across the documented surfaces", () => {

View file

@ -0,0 +1,87 @@
import json
import unittest
from unittest import mock
from scripts.zipcode_search import (
SEARCH_URL,
AddressSearchResult,
fetch_search_page,
lookup_korean_address,
parse_search_results,
)
SAMPLE_HTML = """
<table>
<tbody>
<tr class="title2">
<th scope="row">06133</th>
<td class="t_a_l l_h_18">
서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)<br />
서울특별시 강남구 역삼동 648-23 (여삼빌딩)
</td>
<td><a class="btn_s gray" href="#" onclick="javascript:viewDetail('06133','서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)','123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA','서울특별시 강남구 역삼동 648-23 (여삼빌딩)', '0');" title="보기">더보기</a></td>
</tr>
<tr class="view">
<td class="p_l_86px" colspan="3">
123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA
</td>
</tr>
</tbody>
</table>
"""
class ZipcodeSearchParsingTest(unittest.TestCase):
def test_parse_search_results_extracts_official_korean_and_english_addresses(self):
items = parse_search_results(SAMPLE_HTML)
self.assertEqual(
items,
[
AddressSearchResult(
zip_code="06133",
road_address="서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)",
english_address="123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA",
jibun_address="서울특별시 강남구 역삼동 648-23 (여삼빌딩)",
)
],
)
def test_lookup_korean_address_rejects_blank_query(self):
with self.assertRaisesRegex(ValueError, "query"):
lookup_korean_address(" ")
class ZipcodeSearchTransportTest(unittest.TestCase):
def test_fetch_search_page_uses_official_https_endpoint_and_curl_safety_flags(self):
runner = mock.Mock(return_value=mock.Mock(stdout="<html></html>"))
page = fetch_search_page("서울특별시 강남구 테헤란로 123", runner=runner)
self.assertEqual(page, "<html></html>")
command = runner.call_args.args[0]
self.assertEqual(command[0], "curl")
self.assertIn("--http1.1", command)
self.assertEqual(command[command.index("--tls-max") + 1], "1.2")
self.assertEqual(command[command.index("--retry") + 1], "3")
self.assertIn("--retry-all-errors", command)
self.assertEqual(command[command.index("--retry-delay") + 1], "1")
self.assertEqual(command[command.index("--max-time") + 1], "20")
self.assertEqual(command[-1], SEARCH_URL)
class ZipcodeSearchCliShapeTest(unittest.TestCase):
def test_lookup_response_is_json_serializable(self):
response = lookup_korean_address(
"서울특별시 강남구 테헤란로 123",
fetcher=lambda _query: SAMPLE_HTML,
)
payload = json.loads(response.to_json())
self.assertEqual(payload["query"], "서울특별시 강남구 테헤란로 123")
self.assertEqual(payload["results"][0]["zip_code"], "06133")
self.assertIn("Teheran-ro", payload["results"][0]["english_address"])
if __name__ == "__main__":
unittest.main()

11
scripts/zipcode_search.py Normal file
View file

@ -0,0 +1,11 @@
from __future__ import annotations
from pathlib import Path
_BUNDLED_HELPER = Path(__file__).resolve().parent.parent / "zipcode-search" / "scripts" / "zipcode_search.py"
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
raise FileNotFoundError(f"Bundled zipcode helper not found: {_BUNDLED_HELPER}")
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())

View file

@ -1,30 +1,30 @@
---
name: zipcode-search
description: Look up a Korean postcode from a known address with the official ePost road-name search page. Use when the user knows the address but wants the postal code quickly.
description: Look up a Korean postcode and official English address from a known address with the official ePost integrated search page.
license: MIT
metadata:
category: utility
locale: ko-KR
phase: v1
phase: v2
---
# Zipcode Search
## What this skill does
우체국 공식 도로명주소 검색 페이지를 조회해서 주소 키워드에 맞는 우편번호를 빠르게 찾는다.
우체국 공식 통합 우편번호 검색 페이지를 조회해서 주소 키워드에 맞는 우편번호와 공식 영문 주소를 함께 찾는다.
## When to use
- "이 주소 우편번호 뭐야"
- "세종대로 209 우편번호 찾아줘"
- "판교역로 235 주소 코드만 빨리 알려줘"
- "이 주소 우편번호랑 영문 주소 같이 알려줘"
- "서울특별시 강남구 테헤란로 123 영문 주소로 바꿔줘"
- "해외 결제용으로 한국 주소 영문 표기 필요해"
## Prerequisites
- 인터넷 연결
- `curl`
- 선택 사항: `python3`
- `python3`
## Inputs
@ -35,19 +35,19 @@ metadata:
## Workflow
### 1. Query the official ePost page first
### 1. Query the official integrated ePost page first
비공식 지도 검색이나 블로그 주소 데이터로 우회하지 말고 아래 우체국 공식 검색 페이지를 먼저 조회한다.
비공식 영문주소 변환기나 블로그 표기를 쓰지 말고 아래 우체국 공식 통합 검색 페이지를 먼저 조회한다.
```text
https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp
https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm
```
요청은 `keyword` 파라미터 하나만으로도 동작한다.
이 페이지는 `keyword` 파라미터로 우편번호, 국문 주소, `English/집배코드` 열의 공식 영문 주소를 함께 돌려준다.
### 2. Fetch the HTML with curl and extract the candidate rows
### 2. Fetch the HTML with curl and extract the `viewDetail(...)` rows
현재 ePost 엔드포인트는 응답이 간헐적으로 reset/timeout 될 수 있으므로, 로컬 `python3` 기본 `urllib` 전송 대신 `curl --http1.1 --tls-max 1.2` + 재시도 경로를 기본 예시로 사용한다.
현재 ePost 엔드포인트는 응답이 간헐적으로 reset/timeout 될 수 있으므로, 로컬 `urllib` 대신 `curl --http1.1 --tls-max 1.2` + 재시도 경로를 기본 예시로 사용한다.
```bash
python3 - <<'PY'
@ -55,7 +55,7 @@ import html
import re
import subprocess
query = "세종대로 209"
query = "서울특별시 강남구 테헤란로 123"
cmd = [
"curl",
"--http1.1",
@ -74,71 +74,95 @@ cmd = [
"--get",
"--data-urlencode",
f"keyword={query}",
"https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp",
"https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm",
]
result = subprocess.run(
page = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
page = result.stdout
).stdout
matches = re.findall(
r'name="sch_zipcode"\s+value="([^"]+)".*?name="sch_address1"\s+value="([^"]+)".*?name="sch_bdNm"\s+value="([^"]*)"',
r"viewDetail\('([^']*)','([^']*)','([^']*)','([^']*)',\s*'[^']*'\)",
page,
re.S,
)
if not matches:
raise SystemExit("검색 결과가 없습니다.")
for zip_code, address, building in matches[:5]:
suffix = f" ({building})" if building else ""
print(f"{zip_code}\t{html.unescape(address)}{suffix}")
for zip_code, road_address, english_address, jibun_address in matches[:5]:
print(zip_code)
print(html.unescape(road_address))
print(html.unescape(english_address))
print(html.unescape(jibun_address))
print("---")
PY
```
핵심 필드는 `sch_zipcode`(우편번호), `sch_address1`(기본 주소), `sch_bdNm`(건물명)이다.
핵심 값은 `viewDetail(zip, roadAddress, englishAddress, jibunAddress, rowIndex)` 인자다. 공식 출력은 보통 `123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA` 같은 형식을 그대로 준다.
바깥쪽 Python `timeout`은 두지 말고 `curl` 자체 제한(`--max-time` + `--retry`)으로 전송 시간을 제어한다. 전송 실패가 나도 바로 다른 소스로 우회하지 말고, 위 재시도 옵션 그대로 한 번 더 실행한 뒤 키워드를 더 구체화한다. 실전에서는 `세종대로 209` 같은 짧은 도로명 + 건물번호를 먼저 넣고, 실패하면 `서울 종로구 세종대로 209` 같은 시/군/구 포함 전체 주소 순으로 재시도한다.
### 3. Prefer the shipped helper for repeatable execution
### 3. Normalize for humans
저장소에는 같은 흐름을 감싼 실행 가능한 helper가 포함되어 있다.
```bash
python3 scripts/zipcode_search.py "서울특별시 강남구 테헤란로 123"
```
예시 출력:
```json
{
"query": "서울특별시 강남구 테헤란로 123",
"results": [
{
"zip_code": "06133",
"road_address": "서울특별시 강남구 테헤란로 123 (역삼동, 여삼빌딩)",
"english_address": "123, Teheran-ro, Gangnam-gu, Seoul, 06133, Rep. of KOREA",
"jibun_address": "서울특별시 강남구 역삼동 648-23 (여삼빌딩)"
}
]
}
```
### 4. Normalize for humans
응답은 raw HTML이므로 그대로 붙이지 말고 아래처럼 정리한다.
- 우편번호
- 표준 주소
- 건물명이 있으면 함께 표기
- 도로명 국문 주소
- 공식 영문 주소
- 필요하면 지번 주소
- 후보가 여러 개면 상위 3~5개만 보여주고 어느 항목이 가장 근접한지 짚기
### 4. Retry with tighter and fuller keywords when needed
### 5. Retry with tighter and fuller keywords when needed
검색 결과가 없거나 timeout/reset이 반복되면 아래 순서로 재시도한다.
- 짧은 도로명 + 건물번호: `세종대로 209`
- 시/군/구 포함 전체 주소: `서울 종로구 세종대로 209`
- 동/리 + 지번 또는 대체 표기: `세종로 209`
- 짧은 도로명 + 건물번호: `테헤란로 123`
- 시/군/구 포함 전체 주소: `서울 강남구 테헤란로 123`
- 동/리 + 지번 또는 대체 표기: `역삼동 648-23`
### 5. Prefer temp files in wrapped shells
### 6. Prefer temp files in wrapped shells
CLI 래퍼나 에이전트 쉘에서는 here-doc + Python one-liner가 깨질 수 있으므로, 실전에서는 `mktemp` 같은 임시 파일에 HTML을 저장한 뒤 그 파일을 파싱하는 경로를 우선한다. 응답 일부만 보려고 `| head` 를 붙이면 다운스트림이 먼저 닫히면서 `curl: (23)` 이 보일 수 있으니, 이 경우도 전체 응답을 임시 파일에 저장한 뒤 확인한다.
## Done when
- 적어도 한 개의 우편번호 후보가 정리되어 있다
- 다중 후보일 때 사용자가 고를 수 있게 주소 차이가 보인다
- 적어도 한 개의 우편번호 후보와 공식 영문 주소가 정리되어 있다
- 다중 후보일 때 사용자가 고를 수 있게 국문/영문 주소 차이가 보인다
- 검색 결과가 없으면 재검색 키워드 방향을 제안했다
## Failure modes
- 우체국 검색 페이지 마크업이 바뀌면 `sch_zipcode` 추출 규칙이 깨질 수 있다
- 우체국 검색 페이지 마크업이 바뀌면 `viewDetail(...)` 추출 규칙이 깨질 수 있다
- 주소 키워드가 너무 넓으면 결과가 과하게 많아질 수 있다
- 재시도 없이 한 번만 호출하면 timeout/reset 같은 일시 오류가 날 수 있다
- `curl` 없이 기본 `urllib` 전송으로 바로 붙으면 연결 reset이 날 수 있다
- `curl` 없이 다른 클라이언트로 바로 붙으면 협상/전송 오류가 날 수 있다
## Notes
- 조회형 스킬이다
- 공식 표기 그대로 유지하는 조회형 스킬이다
- 상대 날짜/실시간 개념은 없으므로 주소 문자열 정제에 집중한다

View file

@ -0,0 +1,148 @@
from __future__ import annotations
import argparse
import html
import json
import subprocess
from dataclasses import asdict, dataclass
import re
from typing import Callable, Sequence
SEARCH_URL = "https://www.epost.kr/search.RetrieveIntegrationNewZipCdList.comm"
DEFAULT_LIMIT = 5
VIEW_DETAIL_PATTERN = re.compile(
r"viewDetail\(\s*'(?P<zip>(?:\\'|[^'])*)'\s*,\s*'(?P<road>(?:\\'|[^'])*)'\s*,\s*'(?P<english>(?:\\'|[^'])*)'\s*,\s*'(?P<jibun>(?:\\'|[^'])*)'\s*,\s*'(?P<row>(?:\\'|[^'])*)'\s*\)",
re.S,
)
@dataclass(frozen=True)
class AddressSearchResult:
zip_code: str
road_address: str
english_address: str
jibun_address: str | None = None
@dataclass(frozen=True)
class AddressSearchResponse:
query: str
results: list[AddressSearchResult]
def to_json(self) -> str:
return json.dumps(
{
"query": self.query,
"results": [asdict(item) for item in self.results],
},
ensure_ascii=False,
indent=2,
)
def clean_text(value: str | None) -> str | None:
if value is None:
return None
cleaned = html.unescape(value).replace("\\'", "'")
cleaned = " ".join(cleaned.split()).strip()
return cleaned or None
def parse_search_results(page: str) -> list[AddressSearchResult]:
items: list[AddressSearchResult] = []
for match in VIEW_DETAIL_PATTERN.finditer(page):
zip_code = clean_text(match.group("zip"))
road_address = clean_text(match.group("road"))
english_address = clean_text(match.group("english"))
jibun_address = clean_text(match.group("jibun"))
if not zip_code or not road_address or not english_address:
continue
items.append(
AddressSearchResult(
zip_code=zip_code,
road_address=road_address,
english_address=english_address,
jibun_address=jibun_address,
)
)
return items
def build_search_command(query: str) -> list[str]:
return [
"curl",
"--http1.1",
"--tls-max",
"1.2",
"--silent",
"--show-error",
"--location",
"--retry",
"3",
"--retry-all-errors",
"--retry-delay",
"1",
"--max-time",
"20",
"--get",
"--data-urlencode",
f"keyword={query}",
SEARCH_URL,
]
Runner = Callable[..., subprocess.CompletedProcess[str]]
def fetch_search_page(query: str, *, runner: Runner = subprocess.run) -> str:
result = runner(
build_search_command(query),
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
return result.stdout
Fetcher = Callable[[str], str]
def lookup_korean_address(
query: str,
*,
limit: int = DEFAULT_LIMIT,
fetcher: Fetcher = fetch_search_page,
) -> AddressSearchResponse:
normalized_query = " ".join(query.split()).strip()
if not normalized_query:
raise ValueError("query must not be blank")
if limit <= 0:
raise ValueError("limit must be a positive integer")
page = fetcher(normalized_query)
return AddressSearchResponse(
query=normalized_query,
results=parse_search_results(page)[:limit],
)
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Look up Korean postcodes and official English addresses from ePost.",
)
parser.add_argument("query", help="Korean road-name or jibun address query")
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT, help="maximum number of rows to keep")
return parser
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
response = lookup_korean_address(args.query, limit=args.limit)
print(response.to_json())
return 0
if __name__ == "__main__":
raise SystemExit(main())