mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
224fd3aad5
commit
5c95e9e742
10 changed files with 460 additions and 93 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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(...)` 추출을 붙입니다.
|
||||
|
||||
## 주의할 점
|
||||
|
||||
|
|
|
|||
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
87
scripts/test_zipcode_search.py
Normal file
87
scripts/test_zipcode_search.py
Normal 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
11
scripts/zipcode_search.py
Normal 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())
|
||||
|
|
@ -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
|
||||
|
||||
- 조회형 스킬이다
|
||||
- 공식 표기 그대로 유지하는 조회형 스킬이다
|
||||
- 상대 날짜/실시간 개념은 없으므로 주소 문자열 정제에 집중한다
|
||||
|
|
|
|||
148
zipcode-search/scripts/zipcode_search.py
Normal file
148
zipcode-search/scripts/zipcode_search.py
Normal 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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue