mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
The feature branch already routed zipcode lookup through the integrated ePost English-address surface, but the helper scripts themselves still lacked a shebang and executable mode. This follow-up locks the packaging/CLI contract with a regression test and marks both entrypoints executable so the documented helper can be invoked directly as a script. Constraint: Issue #96 requires an executable zipcode_search.py helper in both the repo script wrapper and bundled skill helper Rejected: Document python3-only invocation and leave file modes unchanged | would not satisfy the executable-helper requirement or catch future regressions Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep the wrapper and bundled helper executable together because tests assert the pair as the public entrypoints Tested: python3 -m unittest scripts.test_zipcode_search; node --test scripts/skill-docs.test.js; python3 scripts/zipcode_search.py "서울특별시 강남구 테헤란로 123"; ./scripts/zipcode_search.py "서울특별시 강남구 테헤란로 123"; npm run build; PYTHONPATH=.:scripts python3 -m unittest scripts.test_patent_search; npm run ci Not-tested: Direct execution of the bundled zipcode-search/scripts/zipcode_search.py helper against the live endpoint
150 lines
3.9 KiB
Python
Executable file
150 lines
3.9 KiB
Python
Executable file
#!/usr/bin/env python3
|
|
|
|
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())
|