Merge pull request #123 from NomaDamas/feature/#117

Feature/#117
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-16 13:35:13 +09:00 committed by GitHub
commit cd1c2d1503
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 1411 additions and 8 deletions

View file

@ -0,0 +1,5 @@
---
"public-restroom-nearby": minor
---
Add the first official public-restroom nearby lookup package and skill/docs set.

View file

@ -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)

View 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 검색은 기준점만 잡는 용도이고, 최종 화장실 데이터는 공식 표준데이터를 기준으로 합니다.

View file

@ -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`가 배포된 경우 사용자 시크릿 불필요)
관련 문서:

View file

@ -24,6 +24,7 @@
- 조선왕조실록 검색 스킬 출시
- 한국 특허 정보 검색 스킬 출시
- 근처 가장 싼 주유소 찾기 스킬 출시
- 근처 공중화장실 찾기 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 근처 술집 조회 스킬 출시

View file

@ -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)

View file

@ -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
View file

@ -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"

View file

@ -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"

View 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.

View 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?)`

View 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"
}
}

View 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
};

View 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(/&amp;/g, "&")
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/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
};

View file

@ -0,0 +1,13 @@
{
"summary": {
"confirm_id": "1001",
"name": "광화문",
"address": {
"disp": "서울특별시 종로구 세종대로 172"
},
"point": {
"lat": 37.57103,
"lon": 126.97679
}
}
}

View 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>

View 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
1 개방자치단체코드 관리번호 구분명 근거법령명 화장실명 소재지도로명주소 소재지지번주소 남성용-대변기수 남성용-소변기수 남성용-장애인용대변기수 남성용-장애인용소변기수 남성용-어린이용대변기수 남성용-어린이용소변기수 여성용-대변기수 여성용-장애인용대변기수 여성용-어린이용대변기수 관리기관명 전화번호 개방시간 개방시간상세 설치연월 WGS84위도 WGS84경도 화장실소유구분명 오물처리방식 안전관리시설설치대상여부 비상벨설치여부 비상벨설치장소 화장실입구CCTV설치유무 기저귀교환대유무 기저귀교환대장소 리모델링연월 데이터기준일자 데이터갱신구분 데이터갱신시점 최종수정시점
2 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
3 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
4 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

View 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");
}
};
}

View 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 인코딩/컬럼 구조가 바뀌면 정규화 로직을 다시 확인해야 한다.

View file

@ -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/);
});