mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
commit
cd1c2d1503
20 changed files with 1411 additions and 8 deletions
5
.changeset/bright-penguins-tickle.md
Normal file
5
.changeset/bright-penguins-tickle.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"public-restroom-nearby": minor
|
||||
---
|
||||
|
||||
Add the first official public-restroom nearby lookup package and skill/docs set.
|
||||
|
|
@ -39,6 +39,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
| 조선왕조실록 검색 | 조선왕조실록 키워드 검색과 왕별/연도별 필터, 기사 발췌 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
|
||||
| 한국 특허 정보 검색 | 한국 특허/실용신안 키워드 검색 및 출원번호 상세 조회 | 필요 | [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md) |
|
||||
| 근처 가장 싼 주유소 찾기 | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
|
||||
| 근처 공중화장실 찾기 | 현재 위치 기준 근처 공중화장실/개방화장실 조회 | 불필요 | [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md) |
|
||||
| KBO 경기 결과 조회 | 날짜별 KBO 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
|
||||
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
|
||||
| LCK 경기 분석 | LCK 경기 결과, 현재 순위, live turning point, 밴픽, 패치 메타, 팀 파워 레이팅 | 불필요 | [LCK 경기 분석 가이드](docs/features/lck-analytics.md) |
|
||||
|
|
@ -110,6 +111,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
|
||||
- [한국 특허 정보 검색 가이드](docs/features/korean-patent-search.md)
|
||||
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
|
||||
- [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md)
|
||||
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
|
||||
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
|
||||
- [LCK 경기 분석 가이드](docs/features/lck-analytics.md)
|
||||
|
|
|
|||
145
docs/features/public-restroom-nearby.md
Normal file
145
docs/features/public-restroom-nearby.md
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
# 근처 공중화장실 찾기 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- 현재 위치 기준 근처 공중화장실 / 개방화장실 검색
|
||||
- 동네/역명/랜드마크를 Kakao Map anchor 로 변환한 뒤 nearby 계산
|
||||
- 공식 `공중화장실정보` 표준데이터 기반 거리순 요약
|
||||
- 개방시간, 주소, 지도 링크까지 함께 정리
|
||||
|
||||
## 가장 먼저 할 일
|
||||
|
||||
이 기능은 **반드시 현재 위치를 먼저 물어본 뒤** 실행합니다.
|
||||
|
||||
권장 질문 예시:
|
||||
|
||||
```text
|
||||
현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 공중화장실을 찾아볼게요.
|
||||
```
|
||||
|
||||
## 입력값
|
||||
|
||||
- 동네/상권: `광화문`, `성수동`, `해운대`
|
||||
- 역명/랜드마크: `서울역`, `강남역`, `코엑스`
|
||||
- 좌표: `37.57103, 126.97679`
|
||||
|
||||
위치 문자열은 Kakao Map anchor 검색으로 **WGS84 좌표**를 잡고, anchor 주소에서 추론한 시도 코드가 있으면 해당 지역 CSV만 내려받습니다.
|
||||
|
||||
## 공식 표면
|
||||
|
||||
- 공공데이터포털 공중화장실 표준데이터 안내: `https://www.data.go.kr/data/15012892/standard.do`
|
||||
- 파일 소개 페이지: `https://file.localdata.go.kr/file/public_restroom_info/info`
|
||||
- 전국 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info`
|
||||
- 지역별 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>`
|
||||
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView`
|
||||
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
|
||||
|
||||
공식 CSV에는 화장실명, 주소, 위·경도, 남녀/장애인 화장실 수, 개방시간, 기저귀교환대, 비상벨 등이 담겨 있습니다.
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. 유저에게 현재 위치를 먼저 묻습니다.
|
||||
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보합니다.
|
||||
3. anchor 주소에서 서울/경기/부산 같은 시도 정보를 추론합니다.
|
||||
4. 공식 `공중화장실정보` CSV를 내려받아 위·경도 기준 거리순으로 정렬합니다.
|
||||
5. 가장 가까운 3~5개만 짧게 응답합니다.
|
||||
|
||||
## Node.js 예시
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3
|
||||
});
|
||||
|
||||
for (const item of result.items) {
|
||||
console.log(`${item.name}: ${Math.round(item.distanceMeters)}m, ${item.address}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
반경 제한이 필요하면 `maxDistanceMeters` 옵션으로 100m 같은 거리 캡을 줄 수 있습니다.
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3,
|
||||
maxDistanceMeters: 100
|
||||
});
|
||||
|
||||
console.log(`100m 이내 결과 수: ${result.meta.total}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
## Offline smoke example
|
||||
|
||||
fixture 기반 검증:
|
||||
|
||||
```bash
|
||||
node --test packages/public-restroom-nearby/test/index.test.js
|
||||
```
|
||||
|
||||
## 검증된 live smoke 예시
|
||||
|
||||
아래 값은 **2026-04-16** 에 `광화문`, `limit=3` 로 실제 호출해 확인한 결과 일부입니다.
|
||||
|
||||
```json
|
||||
{
|
||||
"anchor": {
|
||||
"name": "광화문",
|
||||
"address": "서울 종로구 사직로 161 (세종로)"
|
||||
},
|
||||
"meta": {
|
||||
"region": {
|
||||
"name": "서울특별시"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"name": "세종로공영주차장",
|
||||
"type": "개방화장실",
|
||||
"openTimeDetail": "00~24"
|
||||
},
|
||||
{
|
||||
"name": "종로구청화장실",
|
||||
"type": "개방화장실",
|
||||
"openTimeDetail": "평일9시간(09:00~18:00)"
|
||||
},
|
||||
{
|
||||
"name": "세종문화회관 화장실",
|
||||
"type": "개방화장실",
|
||||
"openTimeDetail": "08~22"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
같은 날짜에 `광화문`, `limit=3`, `maxDistanceMeters=100` 으로 확인했을 때는 `meta.total = 0` 이었습니다.
|
||||
|
||||
## 운영 팁
|
||||
|
||||
- 좌표를 직접 받으면 anchor 검색을 생략해 더 빠르게 nearby 계산을 할 수 있습니다.
|
||||
- 화장실이 너무 많이 잡히는 지역이면 `maxDistanceMeters` 로 100m, 300m 같은 거리 캡을 먼저 걸어두세요.
|
||||
- CSV는 공개 표준데이터이므로 **실시간 잠금/점검 상태는 보장하지 않습니다**. 개방시간 위주로만 안내하세요.
|
||||
- 넓은 질의(예: `강남`)는 기준점이 흔들릴 수 있으니 필요하면 역명/동 이름으로 한 번 더 좁히세요.
|
||||
- 지도 링크가 필요하면 `item.mapUrl` 을 함께 전달하면 됩니다.
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- 데이터는 공식 공개 CSV지만 실시간 availability API는 아닙니다.
|
||||
- CSV 인코딩은 CP949 계열일 수 있어 직접 구현할 때 디코딩 처리가 필요합니다.
|
||||
- Kakao Map anchor 검색은 기준점만 잡는 용도이고, 최종 화장실 데이터는 공식 표준데이터를 기준으로 합니다.
|
||||
|
|
@ -62,6 +62,7 @@ npx --yes skills add <owner/repo> \
|
|||
--skill korean-patent-search \
|
||||
--skill korea-weather \
|
||||
--skill cheap-gas-nearby \
|
||||
--skill public-restroom-nearby \
|
||||
--skill fine-dust-location \
|
||||
--skill han-river-water-level \
|
||||
--skill subway-lost-property \
|
||||
|
|
@ -256,7 +257,7 @@ npm run ci
|
|||
### Node 패키지
|
||||
|
||||
```bash
|
||||
npm install -g @ohah/hwpjs kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp market-kurly-search daiso bunjang-cli
|
||||
npm install -g @ohah/hwpjs kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby public-restroom-nearby korean-law-mcp market-kurly-search daiso bunjang-cli
|
||||
export NODE_PATH="$(npm root -g)"
|
||||
```
|
||||
|
||||
|
|
@ -328,6 +329,7 @@ node scripts/korean_character_count.js --text $'첫 줄\n둘째 줄🙂' --profi
|
|||
- `korean-stock-search`
|
||||
- `household-waste-info`
|
||||
- `cheap-gas-nearby`
|
||||
- `public-restroom-nearby`
|
||||
- `k-schoollunch-menu` (hosted proxy에 `KEDU_INFO_KEY`가 배포된 경우 사용자 시크릿 불필요)
|
||||
|
||||
관련 문서:
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@
|
|||
- 조선왕조실록 검색 스킬 출시
|
||||
- 한국 특허 정보 검색 스킬 출시
|
||||
- 근처 가장 싼 주유소 찾기 스킬 출시
|
||||
- 근처 공중화장실 찾기 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 근처 술집 조회 스킬 출시
|
||||
|
|
|
|||
|
|
@ -97,6 +97,7 @@ bash scripts/check-setup.sh
|
|||
- [하이패스 영수증 발급 가이드](features/hipass-receipt.md)
|
||||
- [한국 주식 정보 조회 가이드](features/korean-stock-search.md)
|
||||
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
|
||||
- [근처 공중화장실 찾기 가이드](features/public-restroom-nearby.md)
|
||||
- [생활쓰레기 배출정보 조회 가이드](features/household-waste-info.md)
|
||||
- [학교 급식 식단 조회 가이드](features/k-schoollunch-menu.md)
|
||||
- [의약품 안전 체크 가이드](features/mfds-drug-safety.md)
|
||||
|
|
|
|||
|
|
@ -103,6 +103,10 @@
|
|||
- Opinet 반경 내 주유소 API: https://www.opinet.co.kr/api/aroundAll.do
|
||||
- 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/15012892/standard.do
|
||||
- 공중화장실정보 파일 소개: https://file.localdata.go.kr/file/public_restroom_info/info
|
||||
- 공중화장실정보 전국 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info
|
||||
- 공중화장실정보 지역별 CSV: https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>
|
||||
- 서울특별시 지하철 실시간 도착정보: 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
|
||||
|
|
|
|||
21
package-lock.json
generated
21
package-lock.json
generated
|
|
@ -1287,6 +1287,10 @@
|
|||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/public-restroom-nearby": {
|
||||
"resolved": "packages/public-restroom-nearby",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/quansync": {
|
||||
"version": "0.2.11",
|
||||
"dev": true,
|
||||
|
|
@ -1687,7 +1691,7 @@
|
|||
}
|
||||
},
|
||||
"packages/blue-ribbon-nearby": {
|
||||
"version": "0.2.2",
|
||||
"version": "0.2.3",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -1697,7 +1701,7 @@
|
|||
}
|
||||
},
|
||||
"packages/cheap-gas-nearby": {
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
@ -1711,7 +1715,7 @@
|
|||
}
|
||||
},
|
||||
"packages/hipass-receipt": {
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"playwright-core": "^1.52.0"
|
||||
|
|
@ -1755,13 +1759,20 @@
|
|||
}
|
||||
},
|
||||
"packages/lck-analytics": {
|
||||
"version": "0.3.0",
|
||||
"version": "0.4.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/market-kurly-search": {
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"packages/public-restroom-nearby": {
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
|
|
@ -1776,7 +1787,7 @@
|
|||
}
|
||||
},
|
||||
"packages/used-car-price-search": {
|
||||
"version": "0.4.0",
|
||||
"version": "0.5.0",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"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 scripts/geeknews_search.py scripts/test_geeknews_search.py scripts/test_naver_blog_search.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.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 scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_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 public-restroom-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",
|
||||
"release:npm": "changeset publish"
|
||||
|
|
|
|||
7
packages/public-restroom-nearby/CHANGELOG.md
Normal file
7
packages/public-restroom-nearby/CHANGELOG.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# public-restroom-nearby
|
||||
|
||||
## 0.1.0
|
||||
|
||||
### Minor Changes
|
||||
|
||||
- Initial release: official public-restroom nearby lookup package for Korean location queries.
|
||||
138
packages/public-restroom-nearby/README.md
Normal file
138
packages/public-restroom-nearby/README.md
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
# public-restroom-nearby
|
||||
|
||||
공식 `공중화장실정보` 표준데이터와 Kakao Map anchor 검색을 사용해 근처 공중화장실/개방화장실을 찾는 Node.js 패키지입니다.
|
||||
|
||||
## 설치
|
||||
|
||||
배포 후:
|
||||
|
||||
```bash
|
||||
npm install public-restroom-nearby
|
||||
```
|
||||
|
||||
이 저장소에서 개발할 때:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## 사용 원칙
|
||||
|
||||
- 유저 위치는 자동으로 추적하지 않습니다.
|
||||
- 먼저 현재 위치를 묻고, 받은 동네/역명/랜드마크/위도·경도를 사용하세요.
|
||||
- 화장실 데이터는 공식 `공중화장실정보` CSV를 직접 사용합니다.
|
||||
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 구하고, 가능하면 해당 시도 CSV로 좁혀서 조회합니다.
|
||||
|
||||
## 공식 표면
|
||||
|
||||
- 공공데이터포털 표준데이터 안내: `https://www.data.go.kr/data/15012892/standard.do`
|
||||
- 파일 소개 페이지: `https://file.localdata.go.kr/file/public_restroom_info/info`
|
||||
- 전국 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info`
|
||||
- 지역별 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>`
|
||||
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView`
|
||||
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
|
||||
|
||||
## 사용 예시
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3
|
||||
});
|
||||
|
||||
for (const item of result.items) {
|
||||
console.log(`${item.name}: ${Math.round(item.distanceMeters)}m, ${item.address}`);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
좌표를 직접 받은 경우:
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByCoordinates } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByCoordinates({
|
||||
latitude: 37.57103,
|
||||
longitude: 126.97679,
|
||||
limit: 3
|
||||
});
|
||||
|
||||
console.log(result.items);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
거리 제한이 필요하면 `maxDistanceMeters` 를 함께 넘겨서 반경 바깥 결과를 잘라낼 수 있습니다.
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3,
|
||||
maxDistanceMeters: 100
|
||||
});
|
||||
|
||||
console.log(`100m 이내 결과 수: ${result.meta.total}`);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
## Live smoke snapshot
|
||||
|
||||
2026-04-16 에 `광화문`, `limit=3` 로 실제 호출했을 때 상위 결과 예시:
|
||||
|
||||
```json
|
||||
{
|
||||
"anchor": {
|
||||
"name": "광화문",
|
||||
"address": "서울 종로구 사직로 161 (세종로)"
|
||||
},
|
||||
"meta": {
|
||||
"region": {
|
||||
"name": "서울특별시"
|
||||
}
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"name": "세종로공영주차장",
|
||||
"type": "개방화장실"
|
||||
},
|
||||
{
|
||||
"name": "종로구청화장실",
|
||||
"type": "개방화장실"
|
||||
},
|
||||
{
|
||||
"name": "세종문화회관 화장실",
|
||||
"type": "개방화장실"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
같은 날짜에 `광화문`, `limit=3`, `maxDistanceMeters=100` 로 확인했을 때는 `meta.total = 0` 으로 100m 이내 결과만 남도록 동작했습니다.
|
||||
|
||||
## 공개 API
|
||||
|
||||
- `parseCoordinateQuery(locationQuery)`
|
||||
- `inferRegion(address)`
|
||||
- `buildDatasetDownloadUrl(options?)`
|
||||
- `normalizePublicRestroomRows(csvText, origin, options?)`
|
||||
- `searchNearbyPublicRestroomsByCoordinates(options)`
|
||||
- `searchNearbyPublicRestroomsByLocationQuery(locationQuery, options?)`
|
||||
32
packages/public-restroom-nearby/package.json
Normal file
32
packages/public-restroom-nearby/package.json
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "public-restroom-nearby",
|
||||
"version": "0.1.0",
|
||||
"description": "Official public restroom standard-data client for nearby restroom lookup from a user-provided Korean location",
|
||||
"license": "MIT",
|
||||
"main": "src/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://github.com/NomaDamas/k-skill.git"
|
||||
},
|
||||
"keywords": [
|
||||
"k-skill",
|
||||
"korea",
|
||||
"restroom",
|
||||
"toilet",
|
||||
"public-restroom"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
|
||||
"test": "node --test"
|
||||
}
|
||||
}
|
||||
220
packages/public-restroom-nearby/src/index.js
Normal file
220
packages/public-restroom-nearby/src/index.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
const {
|
||||
buildDatasetDownloadUrl,
|
||||
decodeDatasetBuffer,
|
||||
extractDistrict,
|
||||
inferRegion,
|
||||
normalizeAnchorPanel,
|
||||
normalizePublicRestroomRows,
|
||||
parseCoordinateQuery,
|
||||
parseSearchResultsHtml,
|
||||
rankAnchorCandidates
|
||||
} = require("./parse");
|
||||
|
||||
const SEARCH_VIEW_URL = "https://m.map.kakao.com/actions/searchView";
|
||||
const PLACE_PANEL_URL_BASE = "https://place-api.map.kakao.com/places/panel3";
|
||||
const DEFAULT_BROWSER_HEADERS = {
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
|
||||
"user-agent":
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36"
|
||||
};
|
||||
const DEFAULT_PANEL_HEADERS = {
|
||||
...DEFAULT_BROWSER_HEADERS,
|
||||
accept: "application/json, text/plain, */*",
|
||||
appVersion: "6.6.0",
|
||||
origin: "https://place.map.kakao.com",
|
||||
pf: "PC",
|
||||
referer: "https://place.map.kakao.com/"
|
||||
};
|
||||
|
||||
async function request(url, options = {}, responseType = "text") {
|
||||
const fetchImpl = options.fetchImpl || global.fetch;
|
||||
|
||||
if (typeof fetchImpl !== "function") {
|
||||
throw new Error("A fetch implementation is required.");
|
||||
}
|
||||
|
||||
const response = await fetchImpl(url, {
|
||||
headers: {
|
||||
...(options.headerSet || DEFAULT_BROWSER_HEADERS),
|
||||
...(options.headers || {})
|
||||
},
|
||||
signal: options.signal
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error(`Request failed with ${response.status} for ${url}`);
|
||||
error.status = response.status;
|
||||
error.url = url;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (responseType === "json") {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
if (responseType === "buffer") {
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function fetchSearchResults(query, options = {}) {
|
||||
const url = new URL(SEARCH_VIEW_URL);
|
||||
url.searchParams.set("q", String(query || "").trim());
|
||||
|
||||
return request(url.toString(), options, "text");
|
||||
}
|
||||
|
||||
async function fetchPlacePanel(confirmId, options = {}) {
|
||||
return request(`${PLACE_PANEL_URL_BASE}/${confirmId}`, { ...options, headerSet: DEFAULT_PANEL_HEADERS }, "json");
|
||||
}
|
||||
|
||||
function isRecoverablePlacePanelError(error) {
|
||||
const status = Number(error?.status);
|
||||
|
||||
return Number.isInteger(status) && status >= 400 && status < 600;
|
||||
}
|
||||
|
||||
async function resolveAnchor(locationQuery, options = {}) {
|
||||
const anchorSearchHtml = await fetchSearchResults(locationQuery, options);
|
||||
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
|
||||
const rankedCandidates = rankAnchorCandidates(locationQuery, anchorCandidates);
|
||||
|
||||
for (const candidate of rankedCandidates) {
|
||||
let anchorPanel;
|
||||
|
||||
try {
|
||||
anchorPanel = await fetchPlacePanel(candidate.id, options);
|
||||
} catch (error) {
|
||||
if (isRecoverablePlacePanelError(error)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const anchor = normalizeAnchorPanel(anchorPanel, candidate);
|
||||
|
||||
if (Number.isFinite(anchor.latitude) && Number.isFinite(anchor.longitude)) {
|
||||
return {
|
||||
anchor,
|
||||
candidates: rankedCandidates
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`No usable Kakao Map place panel was available for ${locationQuery}.`);
|
||||
}
|
||||
|
||||
async function fetchDatasetCsv(options = {}) {
|
||||
const datasetUrl = buildDatasetDownloadUrl(options);
|
||||
const buffer = await request(
|
||||
datasetUrl,
|
||||
{
|
||||
...options,
|
||||
headers: {
|
||||
referer: "https://file.localdata.go.kr/file/public_restroom_info/info",
|
||||
...(options.headers || {})
|
||||
}
|
||||
},
|
||||
"buffer",
|
||||
);
|
||||
|
||||
return {
|
||||
datasetUrl,
|
||||
csvText: decodeDatasetBuffer(buffer)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLimit(limit) {
|
||||
if (limit === undefined || limit === null) {
|
||||
return 5;
|
||||
}
|
||||
|
||||
const parsed = Number(limit);
|
||||
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
throw new Error("limit must be a positive number.");
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function searchNearbyPublicRestroomsByCoordinates(options = {}) {
|
||||
const latitude = Number(options.latitude);
|
||||
const longitude = Number(options.longitude);
|
||||
const limit = normalizeLimit(options.limit);
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
throw new Error("latitude and longitude must be finite numbers.");
|
||||
}
|
||||
|
||||
const dataset = await fetchDatasetCsv(options);
|
||||
const allItems = normalizePublicRestroomRows(dataset.csvText, { latitude, longitude }, {
|
||||
maxDistanceMeters: options.maxDistanceMeters,
|
||||
preferredDistrict: options.preferredDistrict
|
||||
});
|
||||
|
||||
return {
|
||||
anchor: {
|
||||
name: options.anchorName || "입력 좌표",
|
||||
address: options.anchorAddress || null,
|
||||
latitude,
|
||||
longitude
|
||||
},
|
||||
items: allItems.slice(0, limit),
|
||||
meta: {
|
||||
total: allItems.length,
|
||||
limit,
|
||||
datasetUrl: dataset.datasetUrl,
|
||||
region: options.region || null
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function searchNearbyPublicRestroomsByLocationQuery(locationQuery, options = {}) {
|
||||
const coordinateQuery = parseCoordinateQuery(locationQuery);
|
||||
|
||||
if (coordinateQuery) {
|
||||
return searchNearbyPublicRestroomsByCoordinates({
|
||||
...options,
|
||||
...coordinateQuery,
|
||||
anchorName: String(locationQuery || "").trim()
|
||||
});
|
||||
}
|
||||
|
||||
const { anchor, candidates } = await resolveAnchor(locationQuery, options);
|
||||
const region = inferRegion(anchor.address);
|
||||
|
||||
const result = await searchNearbyPublicRestroomsByCoordinates({
|
||||
...options,
|
||||
latitude: anchor.latitude,
|
||||
longitude: anchor.longitude,
|
||||
orgCode: options.orgCode || region?.orgCode,
|
||||
region,
|
||||
preferredDistrict: options.preferredDistrict || extractDistrict(anchor.address),
|
||||
anchorName: anchor.name,
|
||||
anchorAddress: anchor.address
|
||||
});
|
||||
|
||||
return {
|
||||
...result,
|
||||
anchor,
|
||||
candidates,
|
||||
meta: {
|
||||
...result.meta,
|
||||
region
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDatasetDownloadUrl,
|
||||
inferRegion,
|
||||
normalizePublicRestroomRows,
|
||||
parseCoordinateQuery,
|
||||
searchNearbyPublicRestroomsByCoordinates,
|
||||
searchNearbyPublicRestroomsByLocationQuery
|
||||
};
|
||||
408
packages/public-restroom-nearby/src/parse.js
Normal file
408
packages/public-restroom-nearby/src/parse.js
Normal file
|
|
@ -0,0 +1,408 @@
|
|||
const { TextDecoder } = require("node:util");
|
||||
|
||||
const SEARCH_ITEM_PATTERN = /<li\s+class="search_item\s+base"([\s\S]*?)<\/li>/giu;
|
||||
const TAG_PATTERN = /<[^>]+>/g;
|
||||
const NON_WORD_PATTERN = /[^\p{L}\p{N}]+/gu;
|
||||
|
||||
const REGION_ENTRIES = [
|
||||
["서울특별시", "6110000_ALL", ["서울특별시", "서울"]],
|
||||
["부산광역시", "6260000_ALL", ["부산광역시", "부산"]],
|
||||
["대구광역시", "6270000_ALL", ["대구광역시", "대구"]],
|
||||
["인천광역시", "6280000_ALL", ["인천광역시", "인천"]],
|
||||
["광주광역시", "6290000_ALL", ["광주광역시", "광주"]],
|
||||
["대전광역시", "6300000_ALL", ["대전광역시", "대전"]],
|
||||
["울산광역시", "6310000_ALL", ["울산광역시", "울산"]],
|
||||
["세종특별자치시", "5690000_ALL", ["세종특별자치시", "세종"]],
|
||||
["경기도", "6410000_ALL", ["경기도", "경기"]],
|
||||
["강원특별자치도", "6530000_ALL", ["강원특별자치도", "강원도", "강원"]],
|
||||
["충청북도", "6430000_ALL", ["충청북도", "충북"]],
|
||||
["충청남도", "6440000_ALL", ["충청남도", "충남"]],
|
||||
["전북특별자치도", "6540000_ALL", ["전북특별자치도", "전라북도", "전북"]],
|
||||
["전라남도", "6460000_ALL", ["전라남도", "전남"]],
|
||||
["경상북도", "6470000_ALL", ["경상북도", "경북"]],
|
||||
["경상남도", "6480000_ALL", ["경상남도", "경남"]],
|
||||
["제주특별자치도", "6500000_ALL", ["제주특별자치도", "제주도", "제주"]],
|
||||
];
|
||||
|
||||
function decodeHtml(value) {
|
||||
return String(value || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return decodeHtml(String(value || "").replace(TAG_PATTERN, " "))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(NON_WORD_PATTERN, "");
|
||||
}
|
||||
|
||||
function extractAttribute(fragment, name) {
|
||||
const match = fragment.match(new RegExp(`${name}="([^"]*)"`, "iu"));
|
||||
return match ? decodeHtml(match[1]).trim() : "";
|
||||
}
|
||||
|
||||
function extractInnerText(fragment, className) {
|
||||
const match = fragment.match(
|
||||
new RegExp(`<[^>]+class="[^"]*${className}[^"]*"[^>]*>([\\s\\S]*?)<\\/[^>]+>`, "iu"),
|
||||
);
|
||||
|
||||
return match ? stripTags(match[1]) : "";
|
||||
}
|
||||
|
||||
function parseSearchResultsHtml(html) {
|
||||
const items = [];
|
||||
let match;
|
||||
|
||||
while ((match = SEARCH_ITEM_PATTERN.exec(String(html || ""))) !== null) {
|
||||
const fragment = match[1];
|
||||
const id = extractAttribute(fragment, "data-id");
|
||||
const name = extractAttribute(fragment, "data-title") || extractInnerText(fragment, "tit_g");
|
||||
|
||||
if (!id || !name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const addressMatches = [...fragment.matchAll(/<span class="txt_g">([\s\S]*?)<\/span>/giu)]
|
||||
.map((entry) => stripTags(entry[1]))
|
||||
.filter(Boolean);
|
||||
|
||||
items.push({
|
||||
id,
|
||||
name,
|
||||
category: extractInnerText(fragment, "txt_ginfo"),
|
||||
address: addressMatches.at(-1) || ""
|
||||
});
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
function scoreAnchorCandidate(query, item) {
|
||||
const normalizedQuery = normalizeText(query);
|
||||
const normalizedName = normalizeText(item.name);
|
||||
const normalizedAddress = normalizeText(item.address);
|
||||
let score = 0;
|
||||
|
||||
if (!normalizedQuery) {
|
||||
return score;
|
||||
}
|
||||
|
||||
if (normalizedName === normalizedQuery) {
|
||||
score += 1000;
|
||||
}
|
||||
|
||||
if (normalizedName.startsWith(normalizedQuery)) {
|
||||
score += 800;
|
||||
}
|
||||
|
||||
if (normalizedName.includes(normalizedQuery)) {
|
||||
score += 600;
|
||||
}
|
||||
|
||||
if (normalizedAddress.includes(normalizedQuery)) {
|
||||
score += 100;
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function rankAnchorCandidates(query, items) {
|
||||
return [...(items || [])].sort((left, right) => {
|
||||
const scoreDelta = scoreAnchorCandidate(query, right) - scoreAnchorCandidate(query, left);
|
||||
|
||||
if (scoreDelta !== 0) {
|
||||
return scoreDelta;
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, "ko");
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAnchorPanel(panel, searchItem = {}) {
|
||||
const summary = panel.summary || {};
|
||||
|
||||
return {
|
||||
id: String(summary.confirm_id || searchItem.id || ""),
|
||||
name: summary.name || searchItem.name || "",
|
||||
category: summary.category?.name3 || summary.category?.name2 || searchItem.category || "",
|
||||
address: summary.address?.disp || searchItem.address || "",
|
||||
latitude: toNumber(summary.point?.lat),
|
||||
longitude: toNumber(summary.point?.lon),
|
||||
sourceUrl: summary.confirm_id ? `https://place.map.kakao.com/${summary.confirm_id}` : null
|
||||
};
|
||||
}
|
||||
|
||||
function parseCoordinateQuery(locationQuery) {
|
||||
const match = String(locationQuery || "")
|
||||
.trim()
|
||||
.match(/^(-?\d+(?:\.\d+)?)\s*[,/ ]\s*(-?\d+(?:\.\d+)?)$/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
latitude: Number(match[1]),
|
||||
longitude: Number(match[2])
|
||||
};
|
||||
}
|
||||
|
||||
function inferRegion(value) {
|
||||
const normalized = normalizeText(value);
|
||||
|
||||
for (const [name, orgCode, aliases] of REGION_ENTRIES) {
|
||||
for (const alias of aliases) {
|
||||
if (normalized.startsWith(normalizeText(alias))) {
|
||||
return { name, orgCode };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildDatasetDownloadUrl(options = {}) {
|
||||
const url = new URL("https://file.localdata.go.kr/file/download/public_restroom_info/info");
|
||||
|
||||
if (options.orgCode) {
|
||||
url.searchParams.set("orgCode", options.orgCode);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function decodeDatasetBuffer(buffer) {
|
||||
const asUtf8 = Buffer.from(buffer).toString("utf8");
|
||||
|
||||
if (asUtf8.includes("개방자치단체코드") && asUtf8.includes("화장실명")) {
|
||||
return asUtf8;
|
||||
}
|
||||
|
||||
return new TextDecoder("euc-kr").decode(buffer);
|
||||
}
|
||||
|
||||
function parseCsv(csvText) {
|
||||
const rows = [];
|
||||
let row = [];
|
||||
let value = "";
|
||||
let inQuotes = false;
|
||||
|
||||
const text = String(csvText || "");
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
const nextCharacter = text[index + 1];
|
||||
|
||||
if (inQuotes) {
|
||||
if (character === '"' && nextCharacter === '"') {
|
||||
value += '"';
|
||||
index += 1;
|
||||
} else if (character === '"') {
|
||||
inQuotes = false;
|
||||
} else {
|
||||
value += character;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inQuotes = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ",") {
|
||||
row.push(value);
|
||||
value = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === "\n") {
|
||||
row.push(value.replace(/\r$/u, ""));
|
||||
rows.push(row);
|
||||
row = [];
|
||||
value = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
value += character;
|
||||
}
|
||||
|
||||
if (value || row.length > 0) {
|
||||
row.push(value.replace(/\r$/u, ""));
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
const [headerRow, ...dataRows] = rows.filter((entry) => entry.some((cell) => cell !== ""));
|
||||
|
||||
if (!headerRow || headerRow.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return dataRows.map((cells) => {
|
||||
const record = {};
|
||||
|
||||
for (let index = 0; index < headerRow.length; index += 1) {
|
||||
record[headerRow[index]] = cells[index] ?? "";
|
||||
}
|
||||
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
if (value === null || value === undefined || value === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parsed = Number(String(value).replace(/,/g, ""));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function toBooleanYesNo(value) {
|
||||
return String(value || "").trim().toUpperCase() === "Y";
|
||||
}
|
||||
|
||||
function haversineDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB) {
|
||||
const earthRadiusMeters = 6371008.8;
|
||||
const toRadians = (value) => (value * Math.PI) / 180;
|
||||
const deltaLatitude = toRadians(latitudeB - latitudeA);
|
||||
const deltaLongitude = toRadians(longitudeB - longitudeA);
|
||||
const originLatitude = toRadians(latitudeA);
|
||||
const targetLatitude = toRadians(latitudeB);
|
||||
|
||||
const value =
|
||||
Math.sin(deltaLatitude / 2) ** 2 +
|
||||
Math.cos(originLatitude) * Math.cos(targetLatitude) * Math.sin(deltaLongitude / 2) ** 2;
|
||||
|
||||
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
|
||||
}
|
||||
|
||||
function buildMapUrl(name, latitude, longitude) {
|
||||
return `https://map.kakao.com/link/map/${encodeURIComponent(name)},${latitude},${longitude}`;
|
||||
}
|
||||
|
||||
function extractDistrict(address) {
|
||||
const match = String(address || "")
|
||||
.trim()
|
||||
.match(/^(?:\S+)\s+(\S+(?:구|군|시))/u);
|
||||
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function normalizePublicRestroomRows(csvText, origin, options = {}) {
|
||||
const latitude = Number(origin?.latitude);
|
||||
const longitude = Number(origin?.longitude);
|
||||
const limit = options.limit ?? null;
|
||||
const maxDistanceMeters = Number.isFinite(Number(options.maxDistanceMeters))
|
||||
? Number(options.maxDistanceMeters)
|
||||
: null;
|
||||
const preferredDistrict = String(options.preferredDistrict || "").trim() || null;
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
||||
throw new Error("normalizePublicRestroomRows requires finite origin coordinates.");
|
||||
}
|
||||
|
||||
const items = parseCsv(csvText)
|
||||
.map((row) => {
|
||||
const itemLatitude = toNumber(row["WGS84위도"]);
|
||||
const itemLongitude = toNumber(row["WGS84경도"]);
|
||||
|
||||
if (!Number.isFinite(itemLatitude) || !Number.isFinite(itemLongitude)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const distanceMeters = haversineDistanceMeters(latitude, longitude, itemLatitude, itemLongitude);
|
||||
const roadAddress = String(row["소재지도로명주소"] || "").trim();
|
||||
const lotAddress = String(row["소재지지번주소"] || "").trim();
|
||||
const address = roadAddress || lotAddress;
|
||||
|
||||
return {
|
||||
id: String(row["관리번호"] || "").trim(),
|
||||
name: String(row["화장실명"] || "").trim(),
|
||||
type: String(row["구분명"] || "").trim(),
|
||||
address,
|
||||
roadAddress: roadAddress || null,
|
||||
lotAddress: lotAddress || null,
|
||||
latitude: itemLatitude,
|
||||
longitude: itemLongitude,
|
||||
distanceMeters,
|
||||
phone: String(row["전화번호"] || "").trim() || null,
|
||||
managementAgency: String(row["관리기관명"] || "").trim() || null,
|
||||
openTimeCategory: String(row["개방시간"] || "").trim() || null,
|
||||
openTimeDetail: String(row["개방시간상세"] || "").trim() || null,
|
||||
hasEmergencyBell: toBooleanYesNo(row["비상벨설치여부"]),
|
||||
hasBabyChangingTable: toBooleanYesNo(row["기저귀교환대유무"]),
|
||||
hasAccessibleFacility:
|
||||
(toNumber(row["남성용-장애인용대변기수"]) || 0) +
|
||||
(toNumber(row["남성용-장애인용소변기수"]) || 0) +
|
||||
(toNumber(row["여성용-장애인용대변기수"]) || 0) >
|
||||
0,
|
||||
mapUrl: buildMapUrl(String(row["화장실명"] || "").trim(), itemLatitude, itemLongitude)
|
||||
};
|
||||
})
|
||||
.filter(Boolean)
|
||||
.filter((item) => (maxDistanceMeters === null ? true : item.distanceMeters <= maxDistanceMeters))
|
||||
.sort((left, right) => {
|
||||
if (preferredDistrict) {
|
||||
const leftMatchesDistrict = extractDistrict(left.address) === preferredDistrict;
|
||||
const rightMatchesDistrict = extractDistrict(right.address) === preferredDistrict;
|
||||
|
||||
if (leftMatchesDistrict !== rightMatchesDistrict) {
|
||||
return leftMatchesDistrict ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (left.distanceMeters !== right.distanceMeters) {
|
||||
return left.distanceMeters - right.distanceMeters;
|
||||
}
|
||||
|
||||
if (left.type !== right.type) {
|
||||
return left.type.localeCompare(right.type, "ko");
|
||||
}
|
||||
|
||||
return left.name.localeCompare(right.name, "ko");
|
||||
});
|
||||
|
||||
const dedupedItems = [];
|
||||
const seen = new Set();
|
||||
|
||||
for (const item of items) {
|
||||
const key = [item.name, item.address, item.latitude, item.longitude, item.type].join("::");
|
||||
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
dedupedItems.push(item);
|
||||
}
|
||||
|
||||
if (limit === null) {
|
||||
return dedupedItems;
|
||||
}
|
||||
|
||||
return dedupedItems.slice(0, limit);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDatasetDownloadUrl,
|
||||
decodeDatasetBuffer,
|
||||
extractDistrict,
|
||||
inferRegion,
|
||||
normalizeAnchorPanel,
|
||||
normalizePublicRestroomRows,
|
||||
parseCoordinateQuery,
|
||||
parseSearchResultsHtml,
|
||||
rankAnchorCandidates
|
||||
};
|
||||
13
packages/public-restroom-nearby/test/fixtures/anchor-panel.json
vendored
Normal file
13
packages/public-restroom-nearby/test/fixtures/anchor-panel.json
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"summary": {
|
||||
"confirm_id": "1001",
|
||||
"name": "광화문",
|
||||
"address": {
|
||||
"disp": "서울특별시 종로구 세종대로 172"
|
||||
},
|
||||
"point": {
|
||||
"lat": 37.57103,
|
||||
"lon": 126.97679
|
||||
}
|
||||
}
|
||||
}
|
||||
7
packages/public-restroom-nearby/test/fixtures/anchor-search.html
vendored
Normal file
7
packages/public-restroom-nearby/test/fixtures/anchor-search.html
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<ul>
|
||||
<li class="search_item base" data-id="1001" data-title="광화문">
|
||||
<strong class="tit_g">광화문</strong>
|
||||
<span class="txt_ginfo">역사유적지</span>
|
||||
<span class="txt_g">서울특별시 종로구 세종로 1-68</span>
|
||||
</li>
|
||||
</ul>
|
||||
4
packages/public-restroom-nearby/test/fixtures/public-restrooms-seoul.csv
vendored
Normal file
4
packages/public-restroom-nearby/test/fixtures/public-restrooms-seoul.csv
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
개방자치단체코드,관리번호,구분명,근거법령명,화장실명,소재지도로명주소,소재지지번주소,남성용-대변기수,남성용-소변기수,남성용-장애인용대변기수,남성용-장애인용소변기수,남성용-어린이용대변기수,남성용-어린이용소변기수,여성용-대변기수,여성용-장애인용대변기수,여성용-어린이용대변기수,관리기관명,전화번호,개방시간,개방시간상세,설치연월,WGS84위도,WGS84경도,화장실소유구분명,오물처리방식,안전관리시설설치대상여부,비상벨설치여부,비상벨설치장소,화장실입구CCTV설치유무,기저귀교환대유무,기저귀교환대장소,리모델링연월,데이터기준일자,데이터갱신구분,데이터갱신시점,최종수정시점
|
||||
3000000,202530000000100842,개방화장실,법제3조제16호-영제3조제1항제1호,종로문화체육센터,서울특별시 종로구 인왕산로1길 21,서울특별시 종로구 사직동 284-1,2,3,1,1,1,1,7,1,1,종로구시설관리공단 건강사업부,027329393,정시,09:00~18:00,,37.57428,126.96468,공공기관-지방공공기관(지방공기업/지방출자출연기관),수세식,Y,Y,장애인화장실,N,Y,여자화장실,,2024-12-31,I,2026-03-24 03:27:16,2025-11-10 09:45:40
|
||||
3000000,202530000000100863,공중화장실,법제3조제16호-영제3조제1항제1호,통인시장 고객만족센터,서울특별시 종로구 자하문로 15길 18,서울특별시 종로구 통인동 10-3,2,3,1,0,0,0,4,1,0,통인시장 상인회,027220911,정시,11:00~16:00,201002,37.58077,126.96995,공공기관-지방자치단체,수세식,Y,Y,장애인화장실+남자화장실+여자화장실,N,Y,여자화장실,,2024-12-31,I,2026-03-24 03:28:39,2025-11-10 09:45:40
|
||||
3000000,202530000000100830,개방화장실,법제3조제16호-영제3조제1항제1호,보건소,서울특별시 종로구 자하문로19길 36,서울특별시 종로구 옥인동 45-30,7,7,0,0,0,0,10,0,0,종로보건소,0221483520,정시,평일9시간(09:00~18:00),,37.58177,126.96926,공공기관-지방자치단체,수세식,Y,N,,N,N,,,2024-12-31,I,2026-03-24 03:27:16,2025-11-10 09:45:40
|
||||
|
283
packages/public-restroom-nearby/test/index.test.js
Normal file
283
packages/public-restroom-nearby/test/index.test.js
Normal file
|
|
@ -0,0 +1,283 @@
|
|||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
buildDatasetDownloadUrl,
|
||||
inferRegion,
|
||||
normalizePublicRestroomRows,
|
||||
parseCoordinateQuery,
|
||||
searchNearbyPublicRestroomsByCoordinates,
|
||||
searchNearbyPublicRestroomsByLocationQuery
|
||||
} = require("../src/index");
|
||||
|
||||
const fixturesDir = path.join(__dirname, "fixtures");
|
||||
const anchorSearchHtml = fs.readFileSync(path.join(fixturesDir, "anchor-search.html"), "utf8");
|
||||
const anchorPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "anchor-panel.json"), "utf8"));
|
||||
const csvFixture = fs.readFileSync(path.join(fixturesDir, "public-restrooms-seoul.csv"), "utf8");
|
||||
|
||||
test("parseCoordinateQuery recognizes latitude/longitude pairs", () => {
|
||||
assert.deepEqual(parseCoordinateQuery("37.573713, 126.978338"), {
|
||||
latitude: 37.573713,
|
||||
longitude: 126.978338
|
||||
});
|
||||
assert.equal(parseCoordinateQuery("광화문"), null);
|
||||
});
|
||||
|
||||
test("inferRegion maps Korean region names to the official localdata orgCode", () => {
|
||||
assert.deepEqual(inferRegion("서울특별시 종로구 세종대로"), {
|
||||
name: "서울특별시",
|
||||
orgCode: "6110000_ALL"
|
||||
});
|
||||
assert.deepEqual(inferRegion("경기도 성남시 분당구"), {
|
||||
name: "경기도",
|
||||
orgCode: "6410000_ALL"
|
||||
});
|
||||
assert.equal(inferRegion("미상 주소"), null);
|
||||
});
|
||||
|
||||
test("buildDatasetDownloadUrl defaults to the nationwide CSV and supports regional narrowing", () => {
|
||||
assert.equal(
|
||||
buildDatasetDownloadUrl(),
|
||||
"https://file.localdata.go.kr/file/download/public_restroom_info/info"
|
||||
);
|
||||
assert.equal(
|
||||
buildDatasetDownloadUrl({ orgCode: "6110000_ALL" }),
|
||||
"https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL"
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizePublicRestroomRows keeps useful restroom metadata and sorts by distance", () => {
|
||||
const items = normalizePublicRestroomRows(csvFixture, {
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944
|
||||
});
|
||||
|
||||
assert.equal(items.length, 3);
|
||||
assert.deepEqual(
|
||||
items.map((item) => [item.id, item.name, item.type, item.address]),
|
||||
[
|
||||
["202530000000100863", "통인시장 고객만족센터", "공중화장실", "서울특별시 종로구 자하문로 15길 18"],
|
||||
["202530000000100830", "보건소", "개방화장실", "서울특별시 종로구 자하문로19길 36"],
|
||||
["202530000000100842", "종로문화체육센터", "개방화장실", "서울특별시 종로구 인왕산로1길 21"]
|
||||
]
|
||||
);
|
||||
assert.ok(items[0].distanceMeters < items[1].distanceMeters);
|
||||
const cultureCenter = items.find((item) => item.id === "202530000000100842");
|
||||
assert.equal(cultureCenter.openTimeDetail, "09:00~18:00");
|
||||
assert.equal(
|
||||
cultureCenter.mapUrl,
|
||||
"https://map.kakao.com/link/map/%EC%A2%85%EB%A1%9C%EB%AC%B8%ED%99%94%EC%B2%B4%EC%9C%A1%EC%84%BC%ED%84%B0,37.57428,126.96468"
|
||||
);
|
||||
assert.equal(cultureCenter.hasBabyChangingTable, true);
|
||||
assert.equal(cultureCenter.hasEmergencyBell, true);
|
||||
});
|
||||
|
||||
test("normalizePublicRestroomRows collapses identical restroom rows from the official CSV", () => {
|
||||
const duplicatedCsv = `${csvFixture.trim()}\n${csvFixture.trim().split("\n")[1]}\n`;
|
||||
const items = normalizePublicRestroomRows(duplicatedCsv, {
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944
|
||||
});
|
||||
|
||||
assert.equal(items.length, 3);
|
||||
assert.equal(
|
||||
items.filter((item) => item.id === "202530000000100842").length,
|
||||
1
|
||||
);
|
||||
});
|
||||
|
||||
test("normalizePublicRestroomRows can prefer the anchor district over suspicious cross-district coordinates", () => {
|
||||
const weightedCsv = `${csvFixture.trim()}\n3000000,999999999999999999,개방화장실,법제3조제16호-영제3조제1항제1호,멀리있는구청,서울특별시 서대문구 통일로 1,서울특별시 서대문구 냉천동 1,1,1,0,0,0,0,1,0,0,테스트기관,0212345678,정시,09:00~18:00,,37.57372,126.97834,공공기관-지방자치단체,수세식,Y,N,,N,N,,,2024-12-31,I,2026-03-24 03:27:16,2025-11-10 09:45:40\n`;
|
||||
const items = normalizePublicRestroomRows(weightedCsv, {
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944
|
||||
}, {
|
||||
preferredDistrict: "종로구"
|
||||
});
|
||||
|
||||
assert.notEqual(items[0].name, "멀리있는구청");
|
||||
assert.equal(items[0].address.includes("종로구"), true);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByCoordinates queries the official CSV and returns nearest normalized items", async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
calls.push(String(url));
|
||||
return makeResponse(Buffer.from(csvFixture, "utf8"));
|
||||
};
|
||||
|
||||
const result = await searchNearbyPublicRestroomsByCoordinates({
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944,
|
||||
limit: 2,
|
||||
fetchImpl
|
||||
});
|
||||
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.equal(result.items[0].name, "통인시장 고객만족센터");
|
||||
assert.equal(result.meta.datasetUrl, "https://file.localdata.go.kr/file/download/public_restroom_info/info");
|
||||
assert.deepEqual(calls, ["https://file.localdata.go.kr/file/download/public_restroom_info/info"]);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByCoordinates forwards maxDistanceMeters to the CSV normalization path", async () => {
|
||||
const result = await searchNearbyPublicRestroomsByCoordinates({
|
||||
latitude: 37.57371315593711,
|
||||
longitude: 126.97833785777944,
|
||||
limit: 5,
|
||||
maxDistanceMeters: 100,
|
||||
fetchImpl: async () => makeResponse(Buffer.from(csvFixture, "utf8"))
|
||||
});
|
||||
|
||||
assert.equal(result.items.length, 0);
|
||||
assert.equal(result.meta.total, 0);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByLocationQuery resolves a Kakao anchor, narrows to the regional CSV, and returns nearest restrooms", async () => {
|
||||
const calls = [];
|
||||
const fetchImpl = async (url) => {
|
||||
const resolved = String(url);
|
||||
calls.push(resolved);
|
||||
|
||||
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
|
||||
return makeResponse(anchorSearchHtml, "text/html");
|
||||
}
|
||||
|
||||
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
|
||||
return makeResponse(anchorPanel, "application/json");
|
||||
}
|
||||
|
||||
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL") {
|
||||
return makeResponse(Buffer.from(csvFixture, "utf8"));
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${resolved}`);
|
||||
};
|
||||
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 2,
|
||||
fetchImpl
|
||||
});
|
||||
|
||||
assert.equal(result.anchor.name, "광화문");
|
||||
assert.equal(result.anchor.address, "서울특별시 종로구 세종대로 172");
|
||||
assert.equal(result.meta.region.name, "서울특별시");
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.equal(result.items[0].name, "종로문화체육센터");
|
||||
assert.deepEqual(calls, [
|
||||
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
|
||||
"https://place-api.map.kakao.com/places/panel3/1001",
|
||||
"https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL"
|
||||
]);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByLocationQuery falls through to later Kakao candidates when a panel request fails", async () => {
|
||||
const calls = [];
|
||||
const multiCandidateSearchHtml = `
|
||||
<ul>
|
||||
<li class="search_item base" data-id="1001" data-title="광화문">
|
||||
<strong class="tit_g">광화문</strong>
|
||||
<span class="txt_ginfo">역사유적지</span>
|
||||
<span class="txt_g">서울특별시 종로구 세종로 1-68</span>
|
||||
</li>
|
||||
<li class="search_item base" data-id="1002" data-title="광화문광장">
|
||||
<strong class="tit_g">광화문광장</strong>
|
||||
<span class="txt_ginfo">광장</span>
|
||||
<span class="txt_g">서울특별시 종로구 세종대로 172</span>
|
||||
</li>
|
||||
</ul>
|
||||
`;
|
||||
const fallbackPanel = {
|
||||
summary: {
|
||||
...anchorPanel.summary,
|
||||
confirm_id: "1002",
|
||||
name: "광화문광장"
|
||||
}
|
||||
};
|
||||
const fetchImpl = async (url) => {
|
||||
const resolved = String(url);
|
||||
calls.push(resolved);
|
||||
|
||||
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
|
||||
return makeResponse(multiCandidateSearchHtml, "text/html");
|
||||
}
|
||||
|
||||
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
|
||||
return { ok: false, status: 500 };
|
||||
}
|
||||
|
||||
if (resolved === "https://place-api.map.kakao.com/places/panel3/1002") {
|
||||
return makeResponse(fallbackPanel, "application/json");
|
||||
}
|
||||
|
||||
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL") {
|
||||
return makeResponse(Buffer.from(csvFixture, "utf8"));
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${resolved}`);
|
||||
};
|
||||
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 2,
|
||||
fetchImpl
|
||||
});
|
||||
|
||||
assert.equal(result.anchor.id, "1002");
|
||||
assert.equal(result.anchor.name, "광화문광장");
|
||||
assert.equal(result.items.length, 2);
|
||||
assert.deepEqual(calls, [
|
||||
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
|
||||
"https://place-api.map.kakao.com/places/panel3/1001",
|
||||
"https://place-api.map.kakao.com/places/panel3/1002",
|
||||
"https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=6110000_ALL"
|
||||
]);
|
||||
});
|
||||
|
||||
test("searchNearbyPublicRestroomsByLocationQuery still surfaces non-HTTP Kakao panel errors", async () => {
|
||||
const fetchImpl = async (url) => {
|
||||
const resolved = String(url);
|
||||
|
||||
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
|
||||
return makeResponse(anchorSearchHtml, "text/html");
|
||||
}
|
||||
|
||||
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
|
||||
throw new Error("socket hang up");
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${resolved}`);
|
||||
};
|
||||
|
||||
await assert.rejects(
|
||||
searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
fetchImpl
|
||||
}),
|
||||
/socket hang up/
|
||||
);
|
||||
});
|
||||
|
||||
function makeResponse(body, contentType = "text/csv;charset=UTF-8") {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: {
|
||||
get(name) {
|
||||
if (String(name).toLowerCase() === "content-type") {
|
||||
return contentType;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
},
|
||||
async text() {
|
||||
return Buffer.isBuffer(body) ? body.toString("utf8") : String(body);
|
||||
},
|
||||
async json() {
|
||||
return typeof body === "string" ? JSON.parse(body) : body;
|
||||
},
|
||||
async arrayBuffer() {
|
||||
return Buffer.isBuffer(body) ? body : Buffer.from(String(body), "utf8");
|
||||
}
|
||||
};
|
||||
}
|
||||
95
public-restroom-nearby/SKILL.md
Normal file
95
public-restroom-nearby/SKILL.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
---
|
||||
name: public-restroom-nearby
|
||||
description: Use when the user asks for nearby public/open restrooms or 근처 화장실. Always ask the user's current location first, then use the official nationwide public-restroom standard dataset plus Kakao anchor resolution.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: convenience
|
||||
locale: ko-KR
|
||||
phase: v1
|
||||
---
|
||||
|
||||
# Public Restroom Nearby
|
||||
|
||||
## What this skill does
|
||||
|
||||
유저가 알려준 현재 위치를 기준으로 **근처 공중화장실 / 개방화장실** 을 찾는다.
|
||||
|
||||
- 위치는 자동으로 추정하지 않는다.
|
||||
- **반드시 먼저 현재 위치를 질문**한다.
|
||||
- 화장실 데이터는 공식 `공중화장실정보` 표준데이터를 사용한다.
|
||||
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 잡고, 가능한 경우 해당 시도 데이터만 좁혀서 조회한다.
|
||||
- 좌표를 직접 받으면 바로 nearby 계산으로 들어간다.
|
||||
|
||||
## When to use
|
||||
|
||||
- "근처 화장실 찾아줘"
|
||||
- "서울역 근처 공중화장실 있어?"
|
||||
- "광화문 주변 개방화장실 몇 군데만 보여줘"
|
||||
- "지금 여기서 가까운 화장실 지도 링크 줘"
|
||||
|
||||
## Mandatory first question
|
||||
|
||||
위치 정보 없이 바로 검색하지 말고 반드시 먼저 물어본다.
|
||||
|
||||
- 권장 질문: `현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 공중화장실을 찾아볼게요.`
|
||||
- 위치가 애매하면: `가까운 역명이나 동 이름으로 한 번만 더 알려주세요.`
|
||||
|
||||
## Official surfaces
|
||||
|
||||
- 공공데이터포털 공중화장실 표준데이터 안내: `https://www.data.go.kr/data/15012892/standard.do`
|
||||
- 전국 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info`
|
||||
- 지역별 CSV: `https://file.localdata.go.kr/file/download/public_restroom_info/info?orgCode=<시도코드>`
|
||||
- 파일 소개 페이지: `https://file.localdata.go.kr/file/public_restroom_info/info`
|
||||
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
|
||||
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
|
||||
|
||||
## Workflow
|
||||
|
||||
1. 유저에게 반드시 현재 위치를 묻는다.
|
||||
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보한다.
|
||||
3. anchor 주소에서 시도(서울/경기/부산 등)를 추론할 수 있으면 해당 지역 CSV로 좁힌다.
|
||||
4. 공식 `공중화장실정보` CSV를 내려받아 위·경도 기준 거리순으로 정렬한다.
|
||||
5. 보통 3~5개만 짧게 정리하고, 필요하면 지도 링크(`map.kakao.com/link/map/...`)를 같이 준다.
|
||||
|
||||
## Responding
|
||||
|
||||
결과는 보통 아래 필드를 포함해 짧게 정리한다.
|
||||
|
||||
- 화장실명
|
||||
- 구분명(공중화장실 / 개방화장실)
|
||||
- 거리
|
||||
- 주소
|
||||
- 개방시간/상세
|
||||
- 지도 링크
|
||||
|
||||
## Node.js example
|
||||
|
||||
```js
|
||||
const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-nearby");
|
||||
|
||||
async function main() {
|
||||
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
|
||||
limit: 3
|
||||
});
|
||||
|
||||
console.log(result.anchor);
|
||||
console.log(result.items);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
```
|
||||
|
||||
## Done when
|
||||
|
||||
- 유저의 현재 위치를 먼저 확인했다.
|
||||
- 공식 데이터 기반으로 최소 1개 이상 nearby restroom 을 찾았거나, 못 찾은 이유와 다음 질문을 제시했다.
|
||||
- 가장 가까운 결과를 3~5개 이내로 정리했다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- Kakao Map anchor 가 애매하면 위치 기준점이 흔들릴 수 있다.
|
||||
- 공개 표준데이터는 실시간 점유/잠금 상태를 주지 않으므로 개방시간 중심으로만 안내해야 한다.
|
||||
- CSV 인코딩/컬럼 구조가 바뀌면 정규화 로직을 다시 확인해야 한다.
|
||||
|
|
@ -221,10 +221,34 @@ test("repository docs advertise the used-car-price-search skill", () => {
|
|||
assert.match(install, /--skill used-car-price-search/);
|
||||
assert.match(
|
||||
install,
|
||||
/npm install -g @ohah\/hwpjs kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp/,
|
||||
/npm install -g @ohah\/hwpjs kbo-game kleague-results lck-analytics toss-securities hipass-receipt k-lotto coupang-product-search used-car-price-search cheap-gas-nearby(?: public-restroom-nearby)? korean-law-mcp/,
|
||||
);
|
||||
});
|
||||
|
||||
test("repository docs advertise the public-restroom-nearby skill", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "public-restroom-nearby.md");
|
||||
const skillPath = path.join(repoRoot, "public-restroom-nearby", "SKILL.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/public-restroom-nearby.md to exist");
|
||||
assert.ok(fs.existsSync(skillPath), "expected public-restroom-nearby/SKILL.md to exist");
|
||||
assert.match(readme, /\| 근처 공중화장실 찾기 \|/);
|
||||
assert.match(readme, /\[근처 공중화장실 찾기 가이드\]\(docs\/features\/public-restroom-nearby\.md\)/);
|
||||
assert.match(install, /--skill public-restroom-nearby/);
|
||||
assert.match(install, /npm install -g .*public-restroom-nearby/);
|
||||
});
|
||||
|
||||
test("public-restroom-nearby docs describe the maxDistanceMeters distance cap", () => {
|
||||
const featureDoc = read(path.join("docs", "features", "public-restroom-nearby.md"));
|
||||
const packageReadme = read(path.join("packages", "public-restroom-nearby", "README.md"));
|
||||
|
||||
assert.match(featureDoc, /maxDistanceMeters/);
|
||||
assert.match(featureDoc, /100m/);
|
||||
assert.match(packageReadme, /maxDistanceMeters/);
|
||||
assert.match(packageReadme, /100m/);
|
||||
});
|
||||
|
||||
test("repository docs advertise the lck-analytics skill and package", () => {
|
||||
const readme = read("README.md");
|
||||
const install = read(path.join("docs", "install.md"));
|
||||
|
|
@ -1113,6 +1137,7 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
|
|||
assert.match(packageJson.scripts["pack:dry-run"], /workspace market-kurly-search/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace public-restroom-nearby/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace kleague-results/);
|
||||
assert.match(packageJson.scripts["pack:dry-run"], /workspace lck-analytics/);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue