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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down Expand Up @@ -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)
Expand Down
76 changes: 76 additions & 0 deletions docs/features/subway-lost-property.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# 지하철 분실물 조회 가이드

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

- 역명/물품명/기간 기준으로 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와 referer가 포함된 runnable `curl` 예시를 생성한다. 예시 `curl` 은 느린 공식 응답을 감안해 `--max-time 60` 을 포함하고, 응답 HTML을 `lost112-search-result.html` 로 저장한다.
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` 에 `--referer https://www.lost112.go.kr/` 가 포함되어 있는지
- `curl_example` 에 `--max-time 60` 이 포함되어 있는지
- `curl_example` 에 `--output lost112-search-result.html` 가 포함되어 있는지
- `curl_example` 이 `https://www.lost112.go.kr/find/findList.do` 를 사용하는지
- `official_sources` 에 LOST112 와 서울교통공사 URL이 모두 들어 있는지

## 주의할 점

- 공식 사이트 응답이 느릴 수 있다.
- 역명 표기가 실제 보관장소 표기와 다를 수 있다.
- 공개 API가 확인되기 전까지는 완전 자동 조회형으로 취급하지 않는다.
2 changes: 2 additions & 0 deletions docs/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down Expand Up @@ -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
```
Expand Down
2 changes: 2 additions & 0 deletions docs/sources.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 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",
Expand Down
26 changes: 26 additions & 0 deletions scripts/skill-docs.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
18 changes: 18 additions & 0 deletions scripts/subway_lost_property.py
Original file line number Diff line number Diff line change
@@ -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())
127 changes: 127 additions & 0 deletions scripts/test_subway_lost_property.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
import contextlib
import io
import json
import os
import shlex
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)
command = shlex.split(build_curl_command(plan.payload))
self.assertNotIn("-L", command)
self.assertIn("--max-time", command)
self.assertEqual(command[command.index("--max-time") + 1], "60")
self.assertIn("--referer", command)
self.assertEqual(command[command.index("--referer") + 1], "https://www.lost112.go.kr/")
self.assertIn("--output", command)
self.assertEqual(command[command.index("--output") + 1], "lost112-search-result.html")
self.assertIn("SITE=V", " ".join(command))
self.assertEqual(command[-1], LOST112_LIST_URL)

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()
Loading
Loading