Add an official subway lost-property guidance path

Issue #98 was approved as a conservative implementation: structure the official LOST112 and 서울교통공사 workflows instead of pretending a stable public API exists. The new helper builds the documented search payload, the docs explain the v1 hybrid scope, and regression coverage locks the helper plus repo docs into the shipped flow.

Constraint: Public subway lost-property APIs remain unconfirmed, so v1 must stay guidance-first
Rejected: Full automated scraping of live result tables | unstable without confirmed API/session guarantees
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep this skill on official HTTPS entry points until a stable public API is verified end-to-end
Tested: python3 -m unittest scripts.test_subway_lost_property; python3 scripts/subway_lost_property.py --station 강남역 --item 지갑 --days 14 --verify-live; npm run ci
Not-tested: Browser-only user journey through LOST112 search submission beyond payload generation
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-10 12:05:50 +09:00
commit 8d61339634
10 changed files with 580 additions and 2 deletions

View file

@ -24,6 +24,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| KTX 예매 | KTX/Korail 열차 조회, 예약, 예약 확인, 취소 | 필요 | [KTX 예매 가이드](docs/features/ktx-booking.md) |
| 카카오톡 Mac CLI | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 지하철 분실물 조회 | 지하철 역/물품명 기준 공식 LOST112 분실물 검색 조건과 유실물센터 진입점 안내 | 불필요 | [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md) |
| 한국 날씨 조회 | 기상청 단기예보 기반 한국 날씨 조회 | 불필요 | [한국 날씨 조회 가이드](docs/features/korea-weather.md) |
| 사용자 위치 미세먼지 조회 | 현재 위치 또는 지역 기준 PM10/PM2.5 미세먼지 조회 | 불필요 | [사용자 위치 미세먼지 조회 가이드](docs/features/fine-dust-location.md) |
| 한강 수위 정보 조회 | 한강 관측소 기준 현재 수위·유량·기준수위 확인 | 불필요 | [한강 수위 정보 가이드](docs/features/han-river-water-level.md) |
@ -90,6 +91,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [KTX 예매](docs/features/ktx-booking.md)
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
- [지하철 분실물 조회 가이드](docs/features/subway-lost-property.md)
- [한국 날씨 조회 가이드](docs/features/korea-weather.md)
- [사용자 위치 미세먼지 조회](docs/features/fine-dust-location.md)
- [한강 수위 정보 가이드](docs/features/han-river-water-level.md)

View file

@ -0,0 +1,73 @@
# 지하철 분실물 조회 가이드
## 이 기능으로 할 수 있는 일
- 역명/물품명/기간 기준으로 LOST112 공식 검색 조건 정리
- 서울교통공사 유실물센터 공식 진입점 안내
- `SITE=V` 기준 지하철 등 외부기관 습득물 검색 payload 생성
- 공식 페이지 reachability를 보수적으로 점검
## 먼저 필요한 것
- [공통 설정 가이드](../setup.md) 완료
- `python3`, `curl` 사용 가능 환경
- 인터넷 연결
## v1 범위
현재 공개 API는 명확하지 않으므로, 이 기능은 **안내형/하이브리드** 범위로 제공된다.
- 공식 LOST112 검색폼에 넣을 값을 구조화해 준다.
- 서울교통공사 유실물센터를 같이 열 수 있게 한다.
- 자동 결과 수집은 보장하지 않는다.
## 공식 경로
- LOST112 습득물 목록: `https://www.lost112.go.kr/find/findList.do`
- 서울교통공사 유실물센터: `https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541`
LOST112에서 실제로 중요한 검색 조건은 아래와 같다.
- `SITE=V`: 경찰 이외 기관(지하철, 공항 등)
- `DEP_PLACE`: 보관장소/역명 키워드
- `PRDT_NM`: 물품명
- `START_YMD`, `END_YMD`: 검색 기간
## 기본 흐름
1. 사용자에게 역명, 물품명, 대략의 날짜를 먼저 받는다.
2. helper로 LOST112 payload와 `curl` 예시를 생성한다.
3. 역명 그대로 검색한 뒤, 결과가 없으면 `역` 없는 키워드나 호선명으로 넓힌다.
4. 서울교통공사 유실물센터 페이지를 함께 열어 후속 절차를 확인한다.
## 예시
```bash
python3 scripts/subway_lost_property.py \
--station 강남역 \
--item 지갑 \
--days 14
```
live reachability 확인까지 하려면:
```bash
python3 scripts/subway_lost_property.py \
--station 강남역 \
--item 지갑 \
--days 14 \
--verify-live
```
## 출력 예시에서 확인할 점
- `payload.SITE``V` 로 고정되어 있는지
- `payload.DEP_PLACE` 에 역명 키워드가 들어갔는지
- `curl_example``https://www.lost112.go.kr/find/findList.do` 를 사용하는지
- `official_sources` 에 LOST112 와 서울교통공사 URL이 모두 들어 있는지
## 주의할 점
- 공식 사이트 응답이 느릴 수 있다.
- 역명 표기가 실제 보관장소 표기와 다를 수 있다.
- 공개 API가 확인되기 전까지는 완전 자동 조회형으로 취급하지 않는다.

View file

@ -64,6 +64,7 @@ npx --yes skills add <owner/repo> \
--skill cheap-gas-nearby \
--skill fine-dust-location \
--skill han-river-water-level \
--skill subway-lost-property \
--skill daiso-product-search \
--skill market-kurly-search \
--skill olive-young-search \
@ -94,6 +95,7 @@ npx --yes skills add <owner/repo> \
--skill korean-patent-search \
--skill hipass-receipt \
--skill seoul-subway-arrival \
--skill subway-lost-property \
--skill korea-weather \
--skill fine-dust-location
```

View file

@ -102,6 +102,8 @@
- Opinet 주유소 상세정보 API: https://www.opinet.co.kr/api/detailById.do
- Opinet 지역코드 API: https://www.opinet.co.kr/api/areaCode.do
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 경찰청 LOST112 습득물 목록: https://www.lost112.go.kr/find/findList.do
- 서울교통공사 유실물센터: https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541
- 기상청 단기예보 조회서비스: https://www.data.go.kr/data/15084084/openapi.do
- 에어코리아 대기오염정보: https://www.data.go.kr/data/15073861/openapi.do
- 에어코리아 측정소정보: https://www.data.go.kr/data/15073877/openapi.do

View file

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

View file

@ -513,6 +513,32 @@ test("ktx-booking helper python regression tests pass", () => {
);
});
test("repository docs advertise the subway-lost-property skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "subway-lost-property.md");
const skillPath = path.join(repoRoot, "subway-lost-property", "SKILL.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/subway-lost-property.md to exist");
assert.ok(fs.existsSync(skillPath), "expected subway-lost-property/SKILL.md to exist");
assert.match(readme, /\| 지하철 분실물 조회 \|/);
assert.match(readme, /\[지하철 분실물 조회 가이드\]\(docs\/features\/subway-lost-property\.md\)/);
assert.match(install, /--skill subway-lost-property/);
});
test("subway-lost-property docs lock the official LOST112 guidance flow", () => {
const skill = read(path.join("subway-lost-property", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "subway-lost-property.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /LOST112/);
assert.match(doc, /seoulmetro\.co\.kr\/kr\/page\.do\?menuIdx=541/);
assert.match(doc, /python3 scripts\/subway_lost_property\.py/);
assert.match(doc, /SITE=V/);
assert.match(doc, /안내형|하이브리드/);
}
});
test("repository docs advertise the zipcode-search skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));

18
scripts/subway_lost_property.py Executable file
View file

@ -0,0 +1,18 @@
#!/usr/bin/env python3
from __future__ import annotations
from pathlib import Path
_BUNDLED_HELPER = (
Path(__file__).resolve().parent.parent
/ "subway-lost-property"
/ "scripts"
/ "subway_lost_property.py"
)
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
raise FileNotFoundError(f"Bundled subway lost-property helper not found: {_BUNDLED_HELPER}")
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())

View file

@ -0,0 +1,117 @@
import contextlib
import io
import json
import os
from datetime import date
from pathlib import Path
import unittest
from unittest import mock
from scripts.subway_lost_property import (
LOST112_LIST_URL,
SEOUL_METRO_LOST_CENTER_URL,
SearchQuery,
build_curl_command,
build_search_payload,
build_search_plan,
expand_station_keywords,
main,
probe_source,
)
class SubwayLostPropertyQueryTest(unittest.TestCase):
def test_build_search_payload_defaults_to_external_agency_search(self):
payload = build_search_payload(
SearchQuery(
station="강남역",
item="지갑",
start_date=date(2026, 4, 1),
end_date=date(2026, 4, 10),
)
)
self.assertEqual(payload["START_YMD"], "20260401")
self.assertEqual(payload["END_YMD"], "20260410")
self.assertEqual(payload["PRDT_NM"], "지갑")
self.assertEqual(payload["DEP_PLACE"], "강남역")
self.assertEqual(payload["SITE"], "V")
self.assertEqual(payload["pageIndex"], "1")
def test_expand_station_keywords_keeps_station_and_strips_suffix(self):
self.assertEqual(expand_station_keywords(" 강남역 "), ["강남역", "강남"])
def test_build_search_plan_serializes_official_sources_and_guidance(self):
plan = build_search_plan(
station="강남역",
item="지갑",
days=14,
today=date(2026, 4, 10),
)
self.assertEqual(plan.query.station, "강남역")
self.assertEqual(plan.query.item, "지갑")
self.assertEqual(plan.query.start_date.isoformat(), "2026-03-27")
self.assertEqual(plan.query.end_date.isoformat(), "2026-04-10")
self.assertEqual(plan.official_sources[0]["url"], LOST112_LIST_URL)
self.assertEqual(plan.official_sources[1]["url"], SEOUL_METRO_LOST_CENTER_URL)
self.assertIn("강남역", plan.suggested_keywords)
self.assertIn("강남", plan.suggested_keywords)
self.assertIn("SITE=V", build_curl_command(plan.payload))
def test_blank_station_is_rejected(self):
with self.assertRaisesRegex(ValueError, "station"):
build_search_plan(station=" ")
class SubwayLostPropertyProbeTest(unittest.TestCase):
def test_probe_source_marks_successful_fetch_as_reachable(self):
runner = mock.Mock(return_value=mock.Mock(returncode=0, stdout="<html></html>", stderr=""))
status = probe_source("LOST112", LOST112_LIST_URL, runner=runner)
self.assertEqual(status["status"], "reachable")
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("--max-time") + 1], "15")
self.assertEqual(command[-1], LOST112_LIST_URL)
def test_probe_source_marks_timeouts_cleanly(self):
runner = mock.Mock(side_effect=__import__("subprocess").CalledProcessError(28, ["curl"], stderr="Operation timed out"))
status = probe_source("서울교통공사", SEOUL_METRO_LOST_CENTER_URL, runner=runner)
self.assertEqual(status["status"], "timeout")
self.assertIn("timed out", status["detail"].lower())
class SubwayLostPropertyCliShapeTest(unittest.TestCase):
def test_cli_prints_json_plan(self):
stdout = io.StringIO()
with contextlib.redirect_stdout(stdout):
main(["--station", "강남역", "--item", "지갑", "--days", "14"])
payload = json.loads(stdout.getvalue())
self.assertEqual(payload["query"]["station"], "강남역")
self.assertEqual(payload["payload"]["SITE"], "V")
self.assertIn("curl", payload["curl_example"])
self.assertEqual(payload["official_sources"][0]["url"], LOST112_LIST_URL)
def test_helper_scripts_are_executable_python_entrypoints(self):
repo_root = Path(__file__).resolve().parent.parent
for helper in (
repo_root / "scripts" / "subway_lost_property.py",
repo_root / "subway-lost-property" / "scripts" / "subway_lost_property.py",
):
with self.subTest(helper=helper):
self.assertTrue(os.access(helper, os.X_OK), f"{helper} should be executable")
self.assertTrue(
helper.read_text(encoding="utf-8").startswith("#!/usr/bin/env python3\n"),
f"{helper} should start with a Python shebang",
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,103 @@
---
name: subway-lost-property
description: Use when the user asks how to look up 지하철 분실물 or 유실물 by 역명/물품명. v1 is an 안내형/하이브리드 skill that structures the official LOST112 + 서울교통공사 flow conservatively.
license: MIT
metadata:
category: transit
locale: ko-KR
phase: v1
---
# Subway Lost Property
## What this skill does
지하철에서 잃어버린 물건을 찾기 위해 공식 경로를 구조화한다.
- LOST112 `습득물 목록 조회`에 넣을 검색 조건을 정리한다.
- 서울교통공사 유실물센터 진입점을 함께 안내한다.
- 공개 API가 명확하지 않은 상태라 v1은 **안내형/하이브리드** 범위로 유지한다.
## When to use
- "강남역에서 지갑 잃어버렸는데 어디서 찾아?"
- "2호선 지하철 분실물 조회 방법 알려줘"
- "서울 지하철 유실물 공식 사이트로 바로 찾게 도와줘"
## Inputs
- 필수: 역명 또는 보관장소 키워드
- 선택: 물품명, 호선, 분실/습득 추정 기간
## Official surfaces
- LOST112 습득물 목록: `https://www.lost112.go.kr/find/findList.do`
- 서울교통공사 유실물센터: `https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541`
LOST112 검색 폼에서 확인되는 핵심 필드는 아래와 같다.
- `SITE=V`: 경찰 이외 기관(지하철, 공항 등)
- `DEP_PLACE`: 보관장소
- `PRDT_NM`: 습득물명
- `START_YMD`, `END_YMD`: 검색 기간
## Workflow
### 1) Ask for the minimum clues first
바로 추정하지 말고 아래 정보를 먼저 받는다.
- 어느 역/어느 구간인지
- 물건 종류가 무엇인지
- 대략 언제 잃어버렸는지
- 서울교통공사(1~8호선) 범위인지, 다른 운영사인지
### 2) Generate the official LOST112 search payload
repo helper를 그대로 써도 된다.
```bash
python3 scripts/subway_lost_property.py \
--station 강남역 \
--item 지갑 \
--days 14
```
helper는 기본적으로 `SITE=V` 를 사용하고, 역명/물품명/기간을 LOST112 form payload와 `curl` 예시로 정리해 준다.
### 3) Optionally verify live reachability
```bash
python3 scripts/subway_lost_property.py \
--station 강남역 \
--item 지갑 \
--days 14 \
--verify-live
```
`--verify-live` 는 공식 페이지 접근 가능 여부만 보수적으로 확인한다. 사이트가 느리면 timeout을 그대로 보고하고 manual open으로 전환한다.
### 4) Guide the user conservatively
- 먼저 LOST112에서 역명 그대로 검색
- 결과가 없으면 `강남`처럼 `역` 없는 키워드로 재검색
- 필요하면 호선명도 추가 검색
- 서울교통공사 유실물센터 페이지를 함께 열어 후속 안내 확인
## Done when
- 사용자가 공식 조회 경로를 바로 열 수 있다.
- LOST112 검색 조건(`SITE=V`, 역명, 물품명, 기간)을 받았다.
- 자동 조회 보장 범위와 manual fallback을 분명히 설명했다.
## Failure modes
- 공식 사이트 응답이 느리거나 timeout 발생
- 역명이 실제 보관장소 표기와 달라 검색 결과가 비는 경우
- 공개 API 부재로 자동 결과 수집이 안정적이지 않은 경우
## Notes
- v1은 공식 웹 흐름을 안전하게 안내하는 범위다.
- 완전 자동 조회형으로 확장하려면 캡차/세션/동적 요청 안정성 재검증이 먼저 필요하다.
- helper는 공식 HTTPS 진입점만 사용한다.

View file

@ -0,0 +1,235 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import json
import shlex
import subprocess
from dataclasses import asdict, dataclass
from datetime import date, timedelta
from typing import Callable
LOST112_LIST_URL = "https://www.lost112.go.kr/find/findList.do"
SEOUL_METRO_LOST_CENTER_URL = "https://www.seoulmetro.co.kr/kr/page.do?menuIdx=541"
CURL_USER_AGENT = "Mozilla/5.0"
Runner = Callable[..., subprocess.CompletedProcess[str]]
@dataclass(frozen=True)
class SearchQuery:
station: str
item: str | None = None
line: str | None = None
start_date: date | None = None
end_date: date | None = None
@dataclass(frozen=True)
class SearchPlan:
query: SearchQuery
payload: dict[str, str]
suggested_keywords: list[str]
official_sources: list[dict[str, str]]
guidance: list[str]
cautions: list[str]
curl_example: str
def to_dict(self) -> dict[str, object]:
return {
"query": {
**asdict(self.query),
"start_date": self.query.start_date.isoformat() if self.query.start_date else None,
"end_date": self.query.end_date.isoformat() if self.query.end_date else None,
},
"payload": self.payload,
"suggested_keywords": self.suggested_keywords,
"official_sources": self.official_sources,
"guidance": self.guidance,
"cautions": self.cautions,
"curl_example": self.curl_example,
}
def normalize_station(station: str) -> str:
normalized = " ".join(station.split())
if not normalized:
raise ValueError("station is required")
return normalized
def expand_station_keywords(station: str) -> list[str]:
normalized = normalize_station(station)
keywords = [normalized]
if normalized.endswith("") and len(normalized) > 1:
keywords.append(normalized[:-1])
return list(dict.fromkeys(keyword for keyword in keywords if keyword))
def build_search_payload(query: SearchQuery) -> dict[str, str]:
station = normalize_station(query.station)
if not query.start_date or not query.end_date:
raise ValueError("start_date and end_date are required")
payload = {
"pageIndex": "1",
"START_YMD": query.start_date.strftime("%Y%m%d"),
"END_YMD": query.end_date.strftime("%Y%m%d"),
"PRDT_NM": (query.item or "").strip(),
"DEP_PLACE": station,
"SITE": "V",
"PLACE_SE_CD": "",
"FD_LCT_CD": "",
"FD_SIGUNGU": "",
"IN_NM": "",
"ATC_ID": "",
"F_ATC_ID": "",
"PRDT_CL_CD01": "",
"PRDT_CL_CD02": "",
"PRDT_CL_NM": "",
"MENU_NO": "",
}
return payload
def _base_curl_command(url: str, max_time: int) -> list[str]:
return [
"curl",
"-fsS",
"-L",
"--http1.1",
"--tls-max",
"1.2",
"--retry",
"1",
"--max-time",
str(max_time),
"-A",
CURL_USER_AGENT,
url,
]
def build_curl_command(payload: dict[str, str]) -> str:
command = _base_curl_command(LOST112_LIST_URL, 20)
for key, value in payload.items():
if value:
command.extend(["--data-urlencode", f"{key}={value}"])
return " ".join(shlex.quote(part) for part in command)
def probe_source(name: str, url: str, runner: Runner = subprocess.run) -> dict[str, str]:
command = _base_curl_command(url, 15)
try:
completed = runner(command, capture_output=True, text=True, check=True)
return {
"name": name,
"url": url,
"status": "reachable",
"detail": f"fetched {len(completed.stdout)} bytes",
}
except subprocess.CalledProcessError as error:
detail = (error.stderr or error.stdout or str(error)).strip() or "unknown error"
status = "timeout" if error.returncode == 28 or "timed out" in detail.lower() else "error"
return {"name": name, "url": url, "status": status, "detail": detail}
def build_search_plan(
station: str,
item: str | None = None,
line: str | None = None,
days: int = 30,
today: date | None = None,
verify_live: bool = False,
probe: Callable[[str, str], dict[str, str]] | None = None,
) -> SearchPlan:
normalized_station = normalize_station(station)
if days <= 0:
raise ValueError("days must be positive")
today = today or date.today()
end_date = today
start_date = today - timedelta(days=days)
query = SearchQuery(
station=normalized_station,
item=(item or "").strip() or None,
line=(line or "").strip() or None,
start_date=start_date,
end_date=end_date,
)
payload = build_search_payload(query)
suggested_keywords = expand_station_keywords(normalized_station)
if query.line:
suggested_keywords.append(query.line)
suggested_keywords = list(dict.fromkeys(suggested_keywords))
default_sources = [
{
"name": "LOST112 습득물 목록",
"url": LOST112_LIST_URL,
"purpose": "경찰 이외 기관(지하철·공항 등) 습득물 검색",
"status": "not_checked",
},
{
"name": "서울교통공사 유실물센터",
"url": SEOUL_METRO_LOST_CENTER_URL,
"purpose": "서울 지하철 공식 유실물 진입점/추가 안내",
"status": "not_checked",
},
]
if verify_live:
probe = probe or probe_source
default_sources = [
{**probe(source["name"], source["url"]), "purpose": source["purpose"]}
for source in default_sources
]
item_hint = query.item or "분실물"
guidance = [
f"먼저 LOST112에서 보관장소를 '{normalized_station}' 로, 물품명은 '{item_hint}' 로 검색합니다.",
"검색 폼에서는 SITE=V(경찰이외의기관) 기준으로 지하철/공항 등 기관 습득물을 우선 좁힙니다.",
"결과가 없으면 역명에서 ''을 뺀 키워드나 호선명을 추가로 검색합니다.",
"서울교통공사 안내 페이지를 함께 열어 운영사 유실물센터/후속 절차를 확인합니다.",
]
cautions = [
"v1은 공식 웹 조회 경로를 구조화하는 안내형/하이브리드 스킬이다.",
"공개 API가 확인되지 않아 자동 결과 수집은 보장하지 않는다.",
"공식 사이트 응답 속도가 느리면 manual open으로 전환한다.",
]
return SearchPlan(
query=query,
payload=payload,
suggested_keywords=suggested_keywords,
official_sources=default_sources,
guidance=guidance,
cautions=cautions,
curl_example=build_curl_command(payload),
)
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Generate official subway lost-property search guidance.")
parser.add_argument("--station", required=True, help="역명 또는 보관장소 키워드")
parser.add_argument("--item", help="예: 지갑, 이어폰")
parser.add_argument("--line", help="예: 2호선")
parser.add_argument("--days", type=int, default=30, help="검색 기간(일), 기본값 30")
parser.add_argument("--verify-live", action="store_true", help="공식 페이지 reachability를 curl로 확인")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> None:
args = parse_args(argv)
plan = build_search_plan(
station=args.station,
item=args.item,
line=args.line,
days=args.days,
verify_live=args.verify_live,
)
print(json.dumps(plan.to_dict(), ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()