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) |
|
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
|
||||||
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
|
| ~~근처 블루리본 맛집~~ ⚠️ 지원 중단 | ~~현재 위치 기준 근처 블루리본 선정 맛집 조회~~ | ~~불필요~~ | ~~[근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)~~ |
|
||||||
| 근처 술집 조회 | 현재 위치 기준 영업 상태·메뉴·좌석·전화번호가 포함된 근처 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-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/daiso-product-search.md) |
|
||||||
| 마켓컬리 상품 조회 | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
|
| 마켓컬리 상품 조회 | 마켓컬리 상품 검색, 현재 가격, 할인 여부, 품절 여부 조회 | 불필요 | [마켓컬리 상품 조회 가이드](docs/features/market-kurly-search.md) |
|
||||||
| 올리브영 검색 | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
|
| 올리브영 검색 | 올리브영 매장·상품·재고 조회 | 불필요 | [올리브영 검색 가이드](docs/features/olive-young-search.md) |
|
||||||
|
|
|
||||||
|
|
@ -1,40 +1,68 @@
|
||||||
# 우편번호 검색 가이드
|
# 우편번호 + 영문주소 검색 가이드
|
||||||
|
|
||||||
## 이 기능으로 할 수 있는 일
|
## 이 기능으로 할 수 있는 일
|
||||||
|
|
||||||
- 주소 키워드로 공식 우체국 우편번호 조회
|
- 주소 키워드로 공식 우체국 우편번호 조회
|
||||||
- 같은 도로명/건물명 후보가 여러 개일 때 상위 결과 비교
|
- 같은 후보의 국문 도로명/지번 주소와 공식 영문 주소를 함께 비교
|
||||||
- 검색 결과가 없을 때 바로 재검색 키워드 조정
|
- 검색 결과가 없을 때 바로 재검색 키워드 조정
|
||||||
|
|
||||||
## 먼저 필요한 것
|
## 먼저 필요한 것
|
||||||
|
|
||||||
- 인터넷 연결
|
- 인터넷 연결
|
||||||
- `curl`
|
- `curl`
|
||||||
- 선택 사항: `python3`
|
- `python3`
|
||||||
|
|
||||||
## 입력값
|
## 입력값
|
||||||
|
|
||||||
- 주소 키워드
|
- 주소 키워드
|
||||||
- 예: `세종대로 209`
|
- 예: `서울특별시 강남구 테헤란로 123`
|
||||||
- 예: `판교역로 235`
|
- 예: `역삼동 648-23`
|
||||||
|
|
||||||
## 기본 흐름
|
## 기본 흐름
|
||||||
|
|
||||||
1. 비공식 지도/블로그 검색으로 우회하지 말고 우체국 공식 검색 페이지를 먼저 조회합니다.
|
1. 비공식 변환기나 블로그 표기로 우회하지 말고 우체국 공식 통합 검색 페이지를 먼저 조회합니다.
|
||||||
2. 주소 키워드를 `keyword` 파라미터로 넘겨 HTML 결과를 받습니다.
|
2. 주소 키워드를 `keyword` 파라미터로 넘겨 HTML 결과를 받습니다.
|
||||||
3. 결과에서 우편번호(`sch_zipcode`)와 표준 주소(`sch_address1`), 건물명(`sch_bdNm`)을 추출합니다.
|
3. 결과에서 `viewDetail(zip, roadAddress, englishAddress, jibunAddress, rowIndex)` 패턴을 추출합니다.
|
||||||
4. 후보가 여러 개면 상위 3~5개만 간단히 비교해 줍니다.
|
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
|
```bash
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
import html
|
import html
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
query = "세종대로 209"
|
query = "서울특별시 강남구 테헤란로 123"
|
||||||
cmd = [
|
cmd = [
|
||||||
"curl",
|
"curl",
|
||||||
"--http1.1",
|
"--http1.1",
|
||||||
|
|
@ -53,29 +81,27 @@ cmd = [
|
||||||
"--get",
|
"--get",
|
||||||
"--data-urlencode",
|
"--data-urlencode",
|
||||||
f"keyword={query}",
|
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,
|
cmd,
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
).stdout
|
||||||
page = result.stdout
|
|
||||||
|
|
||||||
matches = re.findall(
|
matches = re.findall(
|
||||||
r'name="sch_zipcode"\s+value="([^"]+)".*?name="sch_address1"\s+value="([^"]+)".*?name="sch_bdNm"\s+value="([^"]*)"',
|
r"viewDetail\('([^']*)','([^']*)','([^']*)','([^']*)',\s*'[^']*'\)",
|
||||||
page,
|
page,
|
||||||
re.S,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not matches:
|
for zip_code, road_address, english_address, jibun_address in matches[:5]:
|
||||||
raise SystemExit("검색 결과가 없습니다.")
|
print(zip_code)
|
||||||
|
print(html.unescape(road_address))
|
||||||
for zip_code, address, building in matches[:5]:
|
print(html.unescape(english_address))
|
||||||
suffix = f" ({building})" if building else ""
|
print(html.unescape(jibun_address))
|
||||||
print(f"{zip_code}\t{html.unescape(address)}{suffix}")
|
print("---")
|
||||||
PY
|
PY
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -83,15 +109,14 @@ PY
|
||||||
|
|
||||||
- 쉘 래퍼나 에이전트 환경에서는 here-doc + Python one-liner보다 `mktemp` 같은 임시 파일에 HTML을 저장한 뒤 파싱하는 쪽이 더 안전합니다.
|
- 쉘 래퍼나 에이전트 환경에서는 here-doc + Python one-liner보다 `mktemp` 같은 임시 파일에 HTML을 저장한 뒤 파싱하는 쪽이 더 안전합니다.
|
||||||
- 응답 일부만 빨리 보려고 `curl ... | head` 를 붙이면 다운스트림이 먼저 닫히면서 `curl: (23)` 이 보일 수 있습니다. 이때는 전체 응답을 임시 파일에 저장한 뒤 확인합니다.
|
- 응답 일부만 빨리 보려고 `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은 응답 파싱/정리에만 사용합니다.
|
- 문서 기본 예시는 `curl --http1.1 --tls-max 1.2` 전송을 사용하고, Python은 응답 파싱/정리에만 사용합니다.
|
||||||
- 바깥쪽 Python `timeout`은 두지 않고 `curl` 자체 제한(`--max-time` + `--retry`)으로 전체 전송 시간을 제어합니다.
|
- 바깥쪽 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
|
- 한강홍수통제소 Open API 정책: https://www.hrfco.go.kr/web/openapi/policy.do
|
||||||
- 한강홍수통제소 API base: https://api.hrfco.go.kr
|
- 한강홍수통제소 API base: https://api.hrfco.go.kr
|
||||||
- 우체국 도로명주소 검색: https://parcel.epost.go.kr/parcel/comm/zipcode/comm_newzipcd_list.jsp
|
- 우체국 도로명주소 검색: 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대한통운 배송조회: https://www.cjlogistics.com/ko/tool/parcel/tracking
|
||||||
- CJ대한통운 배송상세 JSON: https://www.cjlogistics.com/ko/tool/parcel/tracking-detail
|
- CJ대한통운 배송상세 JSON: https://www.cjlogistics.com/ko/tool/parcel/tracking-detail
|
||||||
- 우체국 배송조회: https://service.epost.go.kr/trace.RetrieveRegiPrclDeliv.postal?sid1=
|
- 우체국 배송조회: https://service.epost.go.kr/trace.RetrieveRegiPrclDeliv.postal?sid1=
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import urllib.parse
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
from dataclasses import asdict, dataclass
|
from dataclasses import asdict, dataclass
|
||||||
|
from html.parser import HTMLParser
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
SERVICE_KEY_ENV_VAR = "KIPRIS_PLUS_API_KEY"
|
SERVICE_KEY_ENV_VAR = "KIPRIS_PLUS_API_KEY"
|
||||||
|
|
@ -75,6 +76,40 @@ class PatentDetail:
|
||||||
big_drawing: str | None
|
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:
|
def clean_text(value: str | None) -> str | None:
|
||||||
if value is None:
|
if value is None:
|
||||||
return 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
|
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:
|
if element is None:
|
||||||
return None
|
return []
|
||||||
child = element.find(tag_name)
|
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)
|
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)
|
return int(value)
|
||||||
|
|
||||||
|
|
||||||
def parse_xml_response(xml_text: str) -> ET.Element:
|
def parse_xml_response(xml_text: str) -> ET.Element | XmlNode:
|
||||||
try:
|
try:
|
||||||
root = ET.fromstring(xml_text)
|
root = ET.fromstring(xml_text)
|
||||||
except ET.ParseError as exc:
|
except (ET.ParseError, ImportError):
|
||||||
raise RuntimeError(f"Failed to parse KIPRIS Plus XML response: {exc}") from exc
|
root = parse_xml_with_fallback(xml_text)
|
||||||
|
|
||||||
result_code = get_child_text(root.find("header"), "resultCode")
|
header = find_child(root, "header")
|
||||||
result_msg = get_child_text(root.find("header"), "resultMsg")
|
result_code = get_child_text(header, "resultCode")
|
||||||
|
result_msg = get_child_text(header, "resultMsg")
|
||||||
if result_code and result_code != "00":
|
if result_code and result_code != "00":
|
||||||
raise RuntimeError(result_msg or f"KIPRIS Plus API error code {result_code}")
|
raise RuntimeError(result_msg or f"KIPRIS Plus API error code {result_code}")
|
||||||
return root
|
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")
|
application_number = get_child_text(item, "applicationNumber")
|
||||||
if not application_number:
|
if not application_number:
|
||||||
raise RuntimeError("KIPRIS Plus response item is missing applicationNumber")
|
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:
|
def parse_patent_search_response(xml_text: str, *, query: str) -> PatentSearchResponse:
|
||||||
root = parse_xml_response(xml_text)
|
root = parse_xml_response(xml_text)
|
||||||
body = root.find("body")
|
body = find_child(root, "body")
|
||||||
items_parent = body.find("items") if body is not None else None
|
items_parent = find_child(body, "items")
|
||||||
item_elements = items_parent.findall("item") if items_parent is not None else []
|
item_elements = find_children(items_parent, "item")
|
||||||
items = [parse_patent_item(item) for item in item_elements]
|
items = [parse_patent_item(item) for item in item_elements]
|
||||||
return PatentSearchResponse(
|
return PatentSearchResponse(
|
||||||
query=query,
|
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:
|
def parse_patent_detail_response(xml_text: str) -> PatentDetail:
|
||||||
root = parse_xml_response(xml_text)
|
root = parse_xml_response(xml_text)
|
||||||
body = root.find("body")
|
body = find_child(root, "body")
|
||||||
item = body.find("item") if body is not None else None
|
item = find_child(body, "item")
|
||||||
if item is None and body is not None:
|
if item is None and body is not None:
|
||||||
items_parent = body.find("items")
|
items_parent = find_child(body, "items")
|
||||||
item = items_parent.find("item") if items_parent is not None else None
|
item = find_child(items_parent, "item")
|
||||||
if item is None:
|
if item is None:
|
||||||
raise RuntimeError("KIPRIS Plus detail response did not include an item payload")
|
raise RuntimeError("KIPRIS Plus detail response did not include an item payload")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,9 @@
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "npm run build --workspaces --if-present",
|
"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",
|
"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",
|
"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",
|
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||||
"version-packages": "changeset version",
|
"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/);
|
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");
|
const skillPath = path.join(repoRoot, "zipcode-search", "SKILL.md");
|
||||||
|
|
||||||
assert.ok(fs.existsSync(skillPath), "expected zipcode-search/SKILL.md to exist");
|
assert.ok(fs.existsSync(skillPath), "expected zipcode-search/SKILL.md to exist");
|
||||||
|
|
||||||
const skill = read(path.join("zipcode-search", "SKILL.md"));
|
const skill = read(path.join("zipcode-search", "SKILL.md"));
|
||||||
const featureDoc = read(path.join("docs", "features", "zipcode-search.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);
|
assert.match(skill, /^name: zipcode-search$/m);
|
||||||
|
|
||||||
for (const doc of [skill, featureDoc]) {
|
for (const doc of [skill, featureDoc]) {
|
||||||
assert.match(doc, /parcel\.epost\.go\.kr\/parcel\/comm\/zipcode\/comm_newzipcd_list\.jsp/);
|
assert.match(doc, /https:\/\/www\.epost\.kr\/search\.RetrieveIntegrationNewZipCdList\.comm/);
|
||||||
assert.match(doc, /sch_zipcode/);
|
assert.match(doc, /viewDetail/);
|
||||||
assert.match(doc, /sch_address1/);
|
assert.match(doc, /English\/집배코드/);
|
||||||
assert.match(doc, /sch_bdNm/);
|
assert.match(doc, /Rep\. of KOREA/);
|
||||||
assert.match(doc, /curl --http1\.1 --tls-max 1\.2/);
|
assert.match(doc, /curl --http1\.1 --tls-max 1\.2/);
|
||||||
assert.match(doc, /--max-time/);
|
assert.match(doc, /--max-time/);
|
||||||
assert.match(doc, /"--retry",\s+"3"/);
|
assert.match(doc, /"--retry",\s+"3"/);
|
||||||
assert.match(doc, /--retry-all-errors/);
|
assert.match(doc, /--retry-all-errors/);
|
||||||
assert.match(doc, /"--retry-delay",\s+"1"/);
|
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, /mktemp|임시 파일/);
|
||||||
assert.match(doc, /curl: \(23\)/);
|
|
||||||
assert.match(doc, /짧은 도로명 \+ 건물번호/);
|
|
||||||
assert.match(doc, /시\/군\/구 포함 전체 주소/);
|
|
||||||
assert.doesNotMatch(doc, /urllib\.request/);
|
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.match(skill, /검색 결과가 없으면/i);
|
||||||
assert.doesNotMatch(skill, /timeout\s*=/);
|
assert.doesNotMatch(skill, /timeout\s*=/);
|
||||||
assert.doesNotMatch(featureDoc, /timeout\s*=/);
|
assert.doesNotMatch(featureDoc, /timeout\s*=/);
|
||||||
assert.match(skill, /`curl` 자체 제한/);
|
|
||||||
assert.match(featureDoc, /프로토콜\/클라이언트 제약/i);
|
assert.match(featureDoc, /프로토콜\/클라이언트 제약/i);
|
||||||
assert.match(featureDoc, /`curl` 자체 제한/);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("repository docs advertise the delivery-tracking skill across the documented surfaces", () => {
|
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
|
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
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
category: utility
|
category: utility
|
||||||
locale: ko-KR
|
locale: ko-KR
|
||||||
phase: v1
|
phase: v2
|
||||||
---
|
---
|
||||||
|
|
||||||
# Zipcode Search
|
# Zipcode Search
|
||||||
|
|
||||||
## What this skill does
|
## What this skill does
|
||||||
|
|
||||||
우체국 공식 도로명주소 검색 페이지를 조회해서 주소 키워드에 맞는 우편번호를 빠르게 찾는다.
|
우체국 공식 통합 우편번호 검색 페이지를 조회해서 주소 키워드에 맞는 우편번호와 공식 영문 주소를 함께 찾는다.
|
||||||
|
|
||||||
## When to use
|
## When to use
|
||||||
|
|
||||||
- "이 주소 우편번호 뭐야"
|
- "이 주소 우편번호랑 영문 주소 같이 알려줘"
|
||||||
- "세종대로 209 우편번호 찾아줘"
|
- "서울특별시 강남구 테헤란로 123 영문 주소로 바꿔줘"
|
||||||
- "판교역로 235 주소 코드만 빨리 알려줘"
|
- "해외 결제용으로 한국 주소 영문 표기 필요해"
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- 인터넷 연결
|
- 인터넷 연결
|
||||||
- `curl`
|
- `curl`
|
||||||
- 선택 사항: `python3`
|
- `python3`
|
||||||
|
|
||||||
## Inputs
|
## Inputs
|
||||||
|
|
||||||
|
|
@ -35,19 +35,19 @@ metadata:
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
### 1. Query the official ePost page first
|
### 1. Query the official integrated ePost page first
|
||||||
|
|
||||||
비공식 지도 검색이나 블로그 주소 데이터로 우회하지 말고 아래 우체국 공식 검색 페이지를 먼저 조회한다.
|
비공식 영문주소 변환기나 블로그 표기를 쓰지 말고 아래 우체국 공식 통합 검색 페이지를 먼저 조회한다.
|
||||||
|
|
||||||
```text
|
```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
|
```bash
|
||||||
python3 - <<'PY'
|
python3 - <<'PY'
|
||||||
|
|
@ -55,7 +55,7 @@ import html
|
||||||
import re
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
query = "세종대로 209"
|
query = "서울특별시 강남구 테헤란로 123"
|
||||||
cmd = [
|
cmd = [
|
||||||
"curl",
|
"curl",
|
||||||
"--http1.1",
|
"--http1.1",
|
||||||
|
|
@ -74,71 +74,95 @@ cmd = [
|
||||||
"--get",
|
"--get",
|
||||||
"--data-urlencode",
|
"--data-urlencode",
|
||||||
f"keyword={query}",
|
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,
|
cmd,
|
||||||
check=True,
|
check=True,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
).stdout
|
||||||
page = result.stdout
|
|
||||||
|
|
||||||
matches = re.findall(
|
matches = re.findall(
|
||||||
r'name="sch_zipcode"\s+value="([^"]+)".*?name="sch_address1"\s+value="([^"]+)".*?name="sch_bdNm"\s+value="([^"]*)"',
|
r"viewDetail\('([^']*)','([^']*)','([^']*)','([^']*)',\s*'[^']*'\)",
|
||||||
page,
|
page,
|
||||||
re.S,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if not matches:
|
if not matches:
|
||||||
raise SystemExit("검색 결과가 없습니다.")
|
raise SystemExit("검색 결과가 없습니다.")
|
||||||
|
|
||||||
for zip_code, address, building in matches[:5]:
|
for zip_code, road_address, english_address, jibun_address in matches[:5]:
|
||||||
suffix = f" ({building})" if building else ""
|
print(zip_code)
|
||||||
print(f"{zip_code}\t{html.unescape(address)}{suffix}")
|
print(html.unescape(road_address))
|
||||||
|
print(html.unescape(english_address))
|
||||||
|
print(html.unescape(jibun_address))
|
||||||
|
print("---")
|
||||||
PY
|
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이므로 그대로 붙이지 말고 아래처럼 정리한다.
|
응답은 raw HTML이므로 그대로 붙이지 말고 아래처럼 정리한다.
|
||||||
|
|
||||||
- 우편번호
|
- 우편번호
|
||||||
- 표준 주소
|
- 도로명 국문 주소
|
||||||
- 건물명이 있으면 함께 표기
|
- 공식 영문 주소
|
||||||
|
- 필요하면 지번 주소
|
||||||
- 후보가 여러 개면 상위 3~5개만 보여주고 어느 항목이 가장 근접한지 짚기
|
- 후보가 여러 개면 상위 3~5개만 보여주고 어느 항목이 가장 근접한지 짚기
|
||||||
|
|
||||||
### 4. Retry with tighter and fuller keywords when needed
|
### 5. Retry with tighter and fuller keywords when needed
|
||||||
|
|
||||||
검색 결과가 없거나 timeout/reset이 반복되면 아래 순서로 재시도한다.
|
검색 결과가 없거나 timeout/reset이 반복되면 아래 순서로 재시도한다.
|
||||||
|
|
||||||
- 짧은 도로명 + 건물번호: `세종대로 209`
|
- 짧은 도로명 + 건물번호: `테헤란로 123`
|
||||||
- 시/군/구 포함 전체 주소: `서울 종로구 세종대로 209`
|
- 시/군/구 포함 전체 주소: `서울 강남구 테헤란로 123`
|
||||||
- 동/리 + 지번 또는 대체 표기: `세종로 209`
|
- 동/리 + 지번 또는 대체 표기: `역삼동 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)` 이 보일 수 있으니, 이 경우도 전체 응답을 임시 파일에 저장한 뒤 확인한다.
|
CLI 래퍼나 에이전트 쉘에서는 here-doc + Python one-liner가 깨질 수 있으므로, 실전에서는 `mktemp` 같은 임시 파일에 HTML을 저장한 뒤 그 파일을 파싱하는 경로를 우선한다. 응답 일부만 보려고 `| head` 를 붙이면 다운스트림이 먼저 닫히면서 `curl: (23)` 이 보일 수 있으니, 이 경우도 전체 응답을 임시 파일에 저장한 뒤 확인한다.
|
||||||
|
|
||||||
## Done when
|
## Done when
|
||||||
|
|
||||||
- 적어도 한 개의 우편번호 후보가 정리되어 있다
|
- 적어도 한 개의 우편번호 후보와 공식 영문 주소가 정리되어 있다
|
||||||
- 다중 후보일 때 사용자가 고를 수 있게 주소 차이가 보인다
|
- 다중 후보일 때 사용자가 고를 수 있게 국문/영문 주소 차이가 보인다
|
||||||
- 검색 결과가 없으면 재검색 키워드 방향을 제안했다
|
- 검색 결과가 없으면 재검색 키워드 방향을 제안했다
|
||||||
|
|
||||||
## Failure modes
|
## Failure modes
|
||||||
|
|
||||||
- 우체국 검색 페이지 마크업이 바뀌면 `sch_zipcode` 추출 규칙이 깨질 수 있다
|
- 우체국 검색 페이지 마크업이 바뀌면 `viewDetail(...)` 추출 규칙이 깨질 수 있다
|
||||||
- 주소 키워드가 너무 넓으면 결과가 과하게 많아질 수 있다
|
- 주소 키워드가 너무 넓으면 결과가 과하게 많아질 수 있다
|
||||||
- 재시도 없이 한 번만 호출하면 timeout/reset 같은 일시 오류가 날 수 있다
|
- 재시도 없이 한 번만 호출하면 timeout/reset 같은 일시 오류가 날 수 있다
|
||||||
- `curl` 없이 기본 `urllib` 전송으로 바로 붙으면 연결 reset이 날 수 있다
|
- `curl` 없이 다른 클라이언트로 바로 붙으면 협상/전송 오류가 날 수 있다
|
||||||
|
|
||||||
## Notes
|
## 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