Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
76 changes: 51 additions & 25 deletions docs/features/zipcode-search.md
Original file line number Diff line number Diff line change
@@ -1,40 +1,69 @@
# 우편번호 검색 가이드
# 우편번호 + 영문주소 검색 가이드

## 이 기능으로 할 수 있는 일

- 주소 키워드로 공식 우체국 우편번호 조회
- 같은 도로명/건물명 후보가 여러 개일 때 상위 결과 비교
- 같은 후보의 국문 도로명/지번 주소와 공식 영문 주소를 함께 비교
- 검색 결과가 없을 때 바로 재검색 키워드 조정

## 먼저 필요한 것

- 인터넷 연결
- `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"
./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",
Expand All @@ -53,45 +82,42 @@ 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
```

## 실전 운영 팁

- 쉘 래퍼나 에이전트 환경에서는 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(...)` 추출을 붙입니다.

## 주의할 점

Expand Down
1 change: 1 addition & 0 deletions docs/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
103 changes: 87 additions & 16 deletions korean-patent-search/scripts/patent_search.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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"
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)


Expand All @@ -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")
Expand All @@ -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,
Expand All @@ -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")

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
23 changes: 12 additions & 11 deletions scripts/skill-docs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -528,40 +528,41 @@ 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, /\.\/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", () => {
Expand Down
Loading
Loading