Improve restroom coverage with Kakao source merging

The public-restroom lookup now keeps the official CSV as the authoritative first layer while enriching sparse areas with Kakao Local keyword and gas-station searches when a REST API key is configured. All returned POIs are normalized onto local haversine distance calculations so Kakao's unreliable distance field cannot affect ordering, and map links encode names before coordinates.\n\nThe CSV path can optionally use Kakao coord2address to correct display names and addresses for known source-data coordinate mismatches without changing the default no-key behavior.\n\nConstraint: Kakao REST API requires caller-provided REST API key via option or KAKAO_REST_API_KEY\nConstraint: Existing no-key CSV-only behavior must continue to work\nRejected: Replace CSV with Kakao-only search | loses official open-time metadata and source priority\nRejected: Trust Kakao distance field | issue evidence shows user-origin mismatch\nConfidence: high\nScope-risk: moderate\nDirective: Keep CSV sourceLayer priority ahead of Kakao dedupe unless official data is explicitly deprecated\nTested: npm test --workspace public-restroom-nearby\nTested: npm run lint --workspace public-restroom-nearby\nTested: npm run ci\nNot-tested: Live Kakao REST API call with a production key
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-28 23:23:20 +09:00
commit ff255bf272
7 changed files with 508 additions and 14 deletions

View file

@ -0,0 +1,5 @@
---
"public-restroom-nearby": minor
---
Add Kakao REST API keyword and gas-station layers to nearby restroom search, recompute all distances locally, deduplicate merged sources, and optionally correct CSV display data via Kakao coord2address.

View file

@ -5,6 +5,7 @@
- 현재 위치 기준 근처 공중화장실 / 개방화장실 검색
- 동네/역명/랜드마크를 Kakao Map anchor 로 변환한 뒤 nearby 계산
- 공식 `공중화장실정보` 표준데이터 기반 거리순 요약
- Kakao REST API 키가 있으면 `공중화장실`/`개방화장실` 키워드와 `OL7` 주유소 레이어를 추가 병합
- 개방시간, 주소, 지도 링크까지 함께 정리
## 가장 먼저 할 일
@ -33,6 +34,9 @@
- 지역별 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>`
- Kakao Local 키워드 검색: `https://dapi.kakao.com/v2/local/search/keyword.json` (`공중화장실`, `개방화장실`)
- Kakao Local 카테고리 검색: `https://dapi.kakao.com/v2/local/search/category.json` (`category_group_code=OL7`)
- Kakao 좌표→주소 보정: `https://dapi.kakao.com/v2/local/geo/coord2address.json`
공식 CSV에는 화장실명, 주소, 위·경도, 남녀/장애인 화장실 수, 개방시간, 기저귀교환대, 비상벨 등이 담겨 있습니다.
@ -41,8 +45,9 @@
1. 유저에게 현재 위치를 먼저 묻습니다.
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보합니다.
3. anchor 주소에서 서울/경기/부산 같은 시도 정보를 추론합니다.
4. 공식 `공중화장실정보` CSV를 내려받아 위·경도 기준 거리순으로 정렬합니다.
5. 가장 가까운 3~5개만 짧게 응답합니다.
4. 공식 `공중화장실정보` CSV를 내려받고, Kakao REST API 키가 있으면 `공중화장실`, `개방화장실`, `OL7` 주유소 결과를 추가 조회합니다.
5. Kakao 응답의 `distance` 값은 사용하지 않고 모든 결과를 WGS84 위·경도 haversine 거리로 다시 계산합니다.
6. CSV를 우선 보존하면서 50m 이내 중복 시설을 제거한 뒤, 가장 가까운 3~5개만 짧게 응답합니다.
## Node.js 예시
@ -65,6 +70,8 @@ main().catch((error) => {
});
```
Kakao REST API 키가 있으면 기본 반경 1,000m에서 Kakao 레이어를 병합합니다. `radiusMeters` 로 Kakao 조회 반경을 조정할 수 있고, `maxDistanceMeters` 는 CSV와 Kakao 병합 후 전체 결과에 적용됩니다. Kakao 키가 없으면 기존처럼 CSV 단일 소스로 동작합니다.
반경 제한이 필요하면 `maxDistanceMeters` 옵션으로 100m 같은 거리 캡을 줄 수 있습니다.
```js
@ -73,7 +80,8 @@ const { searchNearbyPublicRestroomsByLocationQuery } = require("public-restroom-
async function main() {
const result = await searchNearbyPublicRestroomsByLocationQuery("광화문", {
limit: 3,
maxDistanceMeters: 100
maxDistanceMeters: 100,
kakaoRestApiKey: process.env.KAKAO_REST_API_KEY
});
console.log(`100m 이내 결과 수: ${result.meta.total}`);
@ -85,6 +93,15 @@ main().catch((error) => {
});
```
CSV 좌표의 표시명/주소가 실제 건물과 어긋나는 경우에는 Kakao `coord2address` 보정을 명시적으로 켤 수 있습니다. 기본값은 꺼짐입니다.
```js
const result = await searchNearbyPublicRestroomsByLocationQuery("양재천", {
kakaoRestApiKey: process.env.KAKAO_REST_API_KEY,
correctCsvWithKakao: true
});
```
## Offline smoke example
fixture 기반 검증:
@ -134,12 +151,13 @@ node --test packages/public-restroom-nearby/test/index.test.js
- 좌표를 직접 받으면 anchor 검색을 생략해 더 빠르게 nearby 계산을 할 수 있습니다.
- 화장실이 너무 많이 잡히는 지역이면 `maxDistanceMeters` 로 100m, 300m 같은 거리 캡을 먼저 걸어두세요.
- CSV는 공개 표준데이터이므로 **실시간 잠금/점검 상태는 보장하지 않습니다**. 개방시간 위주로만 안내하세요.
- CSV와 Kakao 검색 모두 **실시간 잠금/점검 상태는 보장하지 않습니다**. CSV 개방시간과 현장 안내를 보수적으로 안내하세요.
- 넓은 질의(예: `강남`)는 기준점이 흔들릴 수 있으니 필요하면 역명/동 이름으로 한 번 더 좁히세요.
- 지도 링크가 필요하면 `item.mapUrl` 을 함께 전달하면 됩니다.
## 주의할 점
- 데이터는 공식 공개 CSV지만 실시간 availability API는 아닙니다.
- 데이터는 공식 공개 CSV와 Kakao POI 검색을 병합하지만 실시간 availability API는 아닙니다.
- CSV 인코딩은 CP949 계열일 수 있어 직접 구현할 때 디코딩 처리가 필요합니다.
- Kakao Map anchor 검색은 기준점만 잡는 용도이고, 최종 화장실 데이터는 공식 표준데이터를 기준으로 합니다.
- Kakao Map anchor 검색은 기준점만 잡는 용도입니다. Kakao REST 결과를 병합하더라도 CSV가 1순위 데이터이며, 50m 이내 중복은 CSV 항목을 보존합니다.
- Kakao `distance` 필드는 사용하지 않습니다. 정렬과 필터링은 모두 로컬 haversine 계산값 기준입니다.

View file

@ -1,6 +1,6 @@
# public-restroom-nearby
공식 `공중화장실정보` 표준데이터와 Kakao Map anchor 검색을 사용해 근처 공중화장실/개방화장실을 찾는 Node.js 패키지입니다.
공식 `공중화장실정보` 표준데이터를 기본으로 사용하고, Kakao REST API 키가 있으면 Kakao 키워드/주유소 검색까지 병합해 근처 공중화장실/개방화장실을 찾는 Node.js 패키지입니다.
## 설치
@ -21,6 +21,8 @@ npm install
- 유저 위치는 자동으로 추적하지 않습니다.
- 먼저 현재 위치를 묻고, 받은 동네/역명/랜드마크/위도·경도를 사용하세요.
- 화장실 데이터는 공식 `공중화장실정보` CSV를 직접 사용합니다.
- `kakaoRestApiKey` 옵션 또는 `KAKAO_REST_API_KEY` 환경변수가 있으면 3계층(`CSV` + Kakao `공중화장실`/`개방화장실` 키워드 + Kakao `OL7` 주유소 카테고리)을 병합합니다.
- Kakao 응답의 `distance` 필드는 신뢰하지 않고 모든 결과를 WGS84 좌표 기준 haversine 거리로 다시 계산해 정렬합니다.
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 구하고, 가능하면 해당 시도 CSV로 좁혀서 조회합니다.
## 공식 표면
@ -31,6 +33,9 @@ npm install
- 지역별 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>`
- Kakao Local 키워드 검색: `https://dapi.kakao.com/v2/local/search/keyword.json` (`공중화장실`, `개방화장실`)
- Kakao Local 카테고리 검색: `https://dapi.kakao.com/v2/local/search/category.json` (`category_group_code=OL7`)
- Kakao 좌표→주소 보정: `https://dapi.kakao.com/v2/local/geo/coord2address.json`
## 사용 예시
@ -62,7 +67,8 @@ async function main() {
const result = await searchNearbyPublicRestroomsByCoordinates({
latitude: 37.57103,
longitude: 126.97679,
limit: 3
limit: 3,
kakaoRestApiKey: process.env.KAKAO_REST_API_KEY
});
console.log(result.items);
@ -74,6 +80,11 @@ main().catch((error) => {
});
```
Kakao 키가 설정된 경우 기본 Kakao 검색 반경은 1,000m이며 `radiusMeters` 로 조정할 수 있습니다. `maxDistanceMeters` 는 병합 후 전체 결과 필터링에 사용됩니다. CSV 좌표의 실제 건물명/주소를 Kakao `coord2address` 로 보정하려면 `correctCsvWithKakao: true` 를 넘기세요.
병합 중복 제거는 좌표 50m 이내를 같은 시설로 보고 CSV(Layer 1)를 우선 보존합니다. 지도 링크는 장소명을 URL 인코딩해 공백/괄호/한국어 이름이 좌표 파싱을 깨지 않도록 생성합니다.
거리 제한이 필요하면 `maxDistanceMeters` 를 함께 넘겨서 반경 바깥 결과를 잘라낼 수 있습니다.
```js

View file

@ -1,8 +1,10 @@
const {
buildDatasetDownloadUrl,
buildMapUrl,
decodeDatasetBuffer,
extractDistrict,
inferRegion,
haversineDistanceMeters,
normalizeAnchorPanel,
normalizePublicRestroomRows,
parseCoordinateQuery,
@ -12,6 +14,12 @@ const {
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 KAKAO_LOCAL_API_BASE = "https://dapi.kakao.com/v2/local";
const KAKAO_RESTROOM_KEYWORDS = ["공중화장실", "개방화장실"];
const KAKAO_GAS_STATION_CATEGORY = "OL7";
const SOURCE_CSV = "csv";
const SOURCE_KAKAO_KEYWORD = "kakao_keyword";
const SOURCE_KAKAO_GAS_STATION = "kakao_category_gas_station";
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",
@ -142,6 +150,247 @@ function normalizeLimit(limit) {
return parsed;
}
function resolveKakaoRestApiKey(options = {}) {
return (
options.kakaoRestApiKey ||
options.kakaoApiKey ||
process.env.KAKAO_REST_API_KEY ||
process.env.KAKAO_REST_APIKEY ||
null
);
}
function buildKakaoUrl(pathname, params = {}) {
const url = new URL(`${KAKAO_LOCAL_API_BASE}${pathname}`);
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
return url.toString();
}
async function fetchKakaoJson(pathname, params, options = {}) {
const kakaoRestApiKey = resolveKakaoRestApiKey(options);
if (!kakaoRestApiKey) {
return null;
}
return request(
buildKakaoUrl(pathname, params),
{
...options,
headers: {
Authorization: `KakaoAK ${kakaoRestApiKey}`,
...(options.headers || {})
}
},
"json",
);
}
function normalizeRadius(radius) {
const parsed = Number(radius ?? 1000);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error("radius must be a positive number.");
}
return Math.min(Math.round(parsed), 20000);
}
function normalizeKakaoSize(size) {
const parsed = Number(size ?? 15);
if (!Number.isFinite(parsed) || parsed <= 0) {
throw new Error("kakao size must be a positive number.");
}
return Math.min(Math.round(parsed), 15);
}
function normalizeKakaoPoi(document, origin, { source, sourceLayer, type }) {
const latitude = Number(document?.y);
const longitude = Number(document?.x);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
return null;
}
const name = String(document.place_name || "").trim();
if (!name) {
return null;
}
const address = String(document.road_address_name || document.address_name || "").trim();
return {
id: `kakao:${document.id || source}:${latitude},${longitude}`,
name,
type,
address,
roadAddress: String(document.road_address_name || "").trim() || null,
lotAddress: String(document.address_name || "").trim() || null,
latitude,
longitude,
distanceMeters: haversineDistanceMeters(origin.latitude, origin.longitude, latitude, longitude),
source,
sourceLayer,
category: String(document.category_name || "").trim() || null,
phone: String(document.phone || "").trim() || null,
managementAgency: null,
openTimeCategory: null,
openTimeDetail: source === SOURCE_KAKAO_GAS_STATION ? "주유소는 공중화장실법 시행령상 개방 의무 시설입니다." : null,
hasEmergencyBell: false,
hasBabyChangingTable: false,
hasAccessibleFacility: false,
mapUrl: buildMapUrl(name, latitude, longitude)
};
}
async function fetchKakaoLayerItems(origin, options = {}) {
const kakaoRestApiKey = resolveKakaoRestApiKey(options);
if (!kakaoRestApiKey || options.includeKakaoSources === false) {
return [];
}
const radius = normalizeRadius(options.radiusMeters ?? 1000);
const size = normalizeKakaoSize(options.kakaoSize);
const requests = [];
for (const keyword of KAKAO_RESTROOM_KEYWORDS) {
requests.push(fetchKakaoJson("/search/keyword.json", {
query: keyword,
x: origin.longitude,
y: origin.latitude,
radius,
sort: "distance",
size
}, options).then((payload) => ({ payload, source: SOURCE_KAKAO_KEYWORD, sourceLayer: 2, type: keyword })));
}
requests.push(fetchKakaoJson("/search/category.json", {
category_group_code: KAKAO_GAS_STATION_CATEGORY,
x: origin.longitude,
y: origin.latitude,
radius,
sort: "distance",
size
}, options).then((payload) => ({ payload, source: SOURCE_KAKAO_GAS_STATION, sourceLayer: 3, type: "주유소 화장실" })));
const settled = await Promise.all(requests);
return settled.flatMap(({ payload, source, sourceLayer, type }) => (payload?.documents || [])
.map((document) => normalizeKakaoPoi(document, origin, { source, sourceLayer, type }))
.filter(Boolean));
}
async function fetchKakaoCoord2Address(latitude, longitude, options = {}) {
return fetchKakaoJson("/geo/coord2address.json", {
x: longitude,
y: latitude,
input_coord: "WGS84"
}, options);
}
function applyKakaoAddressCorrection(item, payload) {
const firstDocument = payload?.documents?.[0];
const roadAddress = firstDocument?.road_address;
const lotAddress = firstDocument?.address;
const buildingName = String(roadAddress?.building_name || "").trim();
const correctedRoadAddress = String(roadAddress?.address_name || "").trim();
const correctedLotAddress = String(lotAddress?.address_name || "").trim();
const correctedAddress = correctedRoadAddress || correctedLotAddress;
if (!buildingName && !correctedAddress) {
return item;
}
const correctedName = buildingName || item.name;
return {
...item,
originalName: item.originalName || item.name,
originalAddress: item.originalAddress || item.address,
name: correctedName,
address: correctedAddress || item.address,
roadAddress: correctedRoadAddress || item.roadAddress,
lotAddress: correctedLotAddress || item.lotAddress,
mapUrl: buildMapUrl(correctedName, item.latitude, item.longitude)
};
}
async function correctCsvItemsWithKakao(items, options = {}) {
if (!resolveKakaoRestApiKey(options) || options.correctCsvWithKakao !== true) {
return items;
}
const limit = Math.max(0, Math.min(Number(options.csvCorrectionLimit ?? items.length) || 0, items.length));
const corrected = [...items];
for (let index = 0; index < limit; index += 1) {
const item = corrected[index];
const payload = await fetchKakaoCoord2Address(item.latitude, item.longitude, options);
corrected[index] = applyKakaoAddressCorrection(item, payload);
}
return corrected;
}
function areSameFacility(left, right, thresholdMeters = 50) {
return haversineDistanceMeters(left.latitude, left.longitude, right.latitude, right.longitude) <= thresholdMeters;
}
function mergeAndDeduplicateSources(sourceItems, options = {}) {
const thresholdMeters = Number(options.deduplicateDistanceMeters ?? 50);
const maxDistanceMeters = Number.isFinite(Number(options.maxDistanceMeters))
? Number(options.maxDistanceMeters)
: null;
const sortedForPriority = sourceItems
.filter((item) => (maxDistanceMeters === null ? true : item.distanceMeters <= maxDistanceMeters))
.sort((left, right) => {
if (left.sourceLayer !== right.sourceLayer) {
return left.sourceLayer - right.sourceLayer;
}
return left.distanceMeters - right.distanceMeters;
});
const kept = [];
for (const item of sortedForPriority) {
if (kept.some((candidate) => areSameFacility(candidate, item, thresholdMeters))) {
continue;
}
kept.push(item);
}
return kept.sort((left, right) => {
if (left.distanceMeters !== right.distanceMeters) {
return left.distanceMeters - right.distanceMeters;
}
if (left.sourceLayer !== right.sourceLayer) {
return left.sourceLayer - right.sourceLayer;
}
return left.name.localeCompare(right.name, "ko");
});
}
function summarizeSources(items) {
return {
csv: items.filter((item) => item.source === SOURCE_CSV).length,
kakaoKeyword: items.filter((item) => item.source === SOURCE_KAKAO_KEYWORD).length,
kakaoGasStation: items.filter((item) => item.source === SOURCE_KAKAO_GAS_STATION).length
};
}
async function searchNearbyPublicRestroomsByCoordinates(options = {}) {
const latitude = Number(options.latitude);
const longitude = Number(options.longitude);
@ -151,11 +400,14 @@ async function searchNearbyPublicRestroomsByCoordinates(options = {}) {
throw new Error("latitude and longitude must be finite numbers.");
}
const origin = { latitude, longitude };
const dataset = await fetchDatasetCsv(options);
const allItems = normalizePublicRestroomRows(dataset.csvText, { latitude, longitude }, {
const csvItems = await correctCsvItemsWithKakao(normalizePublicRestroomRows(dataset.csvText, origin, {
maxDistanceMeters: options.maxDistanceMeters,
preferredDistrict: options.preferredDistrict
});
}), options);
const kakaoItems = await fetchKakaoLayerItems(origin, options);
const allItems = mergeAndDeduplicateSources([...csvItems, ...kakaoItems], options);
return {
anchor: {
@ -169,7 +421,9 @@ async function searchNearbyPublicRestroomsByCoordinates(options = {}) {
total: allItems.length,
limit,
datasetUrl: dataset.datasetUrl,
region: options.region || null
region: options.region || null,
sources: summarizeSources(allItems),
kakaoEnabled: Boolean(resolveKakaoRestApiKey(options)) && options.includeKakaoSources !== false
}
};
}

View file

@ -292,6 +292,10 @@ function buildMapUrl(name, latitude, longitude) {
return `https://map.kakao.com/link/map/${encodeURIComponent(name)},${latitude},${longitude}`;
}
function haversineDistanceMetersPublic(latitudeA, longitudeA, latitudeB, longitudeB) {
return haversineDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB);
}
function extractDistrict(address) {
const match = String(address || "")
.trim()
@ -337,6 +341,8 @@ function normalizePublicRestroomRows(csvText, origin, options = {}) {
latitude: itemLatitude,
longitude: itemLongitude,
distanceMeters,
source: "csv",
sourceLayer: 1,
phone: String(row["전화번호"] || "").trim() || null,
managementAgency: String(row["관리기관명"] || "").trim() || null,
openTimeCategory: String(row["개방시간"] || "").trim() || null,
@ -400,6 +406,8 @@ module.exports = {
decodeDatasetBuffer,
extractDistrict,
inferRegion,
haversineDistanceMeters: haversineDistanceMetersPublic,
buildMapUrl,
normalizeAnchorPanel,
normalizePublicRestroomRows,
parseCoordinateQuery,

View file

@ -257,6 +257,200 @@ test("searchNearbyPublicRestroomsByLocationQuery still surfaces non-HTTP Kakao p
);
});
test("searchNearbyPublicRestroomsByCoordinates merges CSV, Kakao restroom keywords, and Kakao gas stations", async () => {
const calls = [];
const fetchImpl = async (url, requestOptions = {}) => {
const resolved = String(url);
calls.push({ url: resolved, authorization: requestOptions.headers?.Authorization });
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info") {
return makeResponse(Buffer.from(csvFixture, "utf8"));
}
if (resolved.startsWith("https://dapi.kakao.com/v2/local/search/keyword.json?")) {
const parsed = new URL(resolved);
const query = parsed.searchParams.get("query");
assert.equal(parsed.searchParams.get("x"), "126.97833785777944");
assert.equal(parsed.searchParams.get("y"), "37.57371315593711");
assert.equal(parsed.searchParams.get("radius"), "1000");
assert.equal(parsed.searchParams.get("sort"), "distance");
assert.equal(parsed.searchParams.get("size"), "15");
if (query === "공중화장실") {
return makeResponse({ documents: [kakaoDocument({ id: "pub-far", name: "멀리 보이는 공중화장실", lat: 37.579, lon: 126.987, distance: "1" })] }, "application/json");
}
if (query === "개방화장실") {
return makeResponse({ documents: [kakaoDocument({ id: "open-near", name: "가까운 개방화장실", lat: 37.5739, lon: 126.9784, distance: "9999" })] }, "application/json");
}
}
if (resolved.startsWith("https://dapi.kakao.com/v2/local/search/category.json?")) {
const parsed = new URL(resolved);
assert.equal(parsed.searchParams.get("category_group_code"), "OL7");
return makeResponse({ documents: [kakaoDocument({ id: "gas-1", name: "광화문주유소", lat: 37.574, lon: 126.979, category: "주유소" })] }, "application/json");
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyPublicRestroomsByCoordinates({
latitude: 37.57371315593711,
longitude: 126.97833785777944,
limit: 5,
maxDistanceMeters: 2000,
kakaoRestApiKey: "test-key",
fetchImpl
});
assert.equal(result.meta.sources.csv, 3);
assert.equal(result.meta.sources.kakaoKeyword, 2);
assert.equal(result.meta.sources.kakaoGasStation, 1);
assert.deepEqual(
result.items.map((item) => item.name).slice(0, 2),
["가까운 개방화장실", "광화문주유소"]
);
assert.notEqual(result.items[0].name, "멀리 보이는 공중화장실");
assert.equal(result.items[0].source, "kakao_keyword");
assert.equal(result.items[0].sourceLayer, 2);
assert.ok(result.items[0].distanceMeters < result.items[2].distanceMeters, "local haversine distance, not Kakao distance, controls sorting");
assert.ok(calls.filter((call) => call.url.includes("dapi.kakao.com")).every((call) => call.authorization === "KakaoAK test-key"));
});
test("searchNearbyPublicRestroomsByCoordinates deduplicates merged sources within 50 meters with CSV priority", async () => {
const fetchImpl = async (url) => {
const resolved = String(url);
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info") {
return makeResponse(Buffer.from(csvFixture, "utf8"));
}
if (resolved.includes("keyword.json")) {
return makeResponse({ documents: [kakaoDocument({ id: "dup", name: "통인시장 고객만족센터", lat: 37.58077, lon: 126.96995 })] }, "application/json");
}
if (resolved.includes("category.json")) {
return makeResponse({ documents: [] }, "application/json");
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyPublicRestroomsByCoordinates({
latitude: 37.57371315593711,
longitude: 126.97833785777944,
limit: 10,
kakaoRestApiKey: "test-key",
fetchImpl
});
const duplicates = result.items.filter((item) => item.name === "통인시장 고객만족센터");
assert.equal(duplicates.length, 1);
assert.equal(duplicates[0].source, "csv");
assert.equal(duplicates[0].sourceLayer, 1);
});
test("searchNearbyPublicRestroomsByCoordinates can correct CSV display names and addresses with Kakao coord2address", async () => {
const fetchImpl = async (url) => {
const resolved = String(url);
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info") {
return makeResponse(Buffer.from(csvFixture, "utf8"));
}
if (resolved.startsWith("https://dapi.kakao.com/v2/local/geo/coord2address.json?")) {
return makeResponse({
documents: [{
road_address: {
building_name: "도곡근린공원 실내배드민턴장",
address_name: "서울특별시 강남구 도곡로 99"
},
address: { address_name: "서울특별시 강남구 도곡동 1" }
}]
}, "application/json");
}
if (resolved.includes("keyword.json") || resolved.includes("category.json")) {
return makeResponse({ documents: [] }, "application/json");
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyPublicRestroomsByCoordinates({
latitude: 37.57371315593711,
longitude: 126.97833785777944,
limit: 1,
kakaoRestApiKey: "test-key",
correctCsvWithKakao: true,
fetchImpl
});
assert.equal(result.items[0].name, "도곡근린공원 실내배드민턴장");
assert.equal(result.items[0].address, "서울특별시 강남구 도곡로 99");
assert.equal(result.items[0].originalName, "통인시장 고객만족센터");
assert.equal(result.items[0].source, "csv");
});
test("Kakao map links encode special place names before coordinates", () => {
const items = normalizePublicRestroomRows(`관리번호,구분명,화장실명,소재지도로명주소,WGS84위도,WGS84경도\n1,개방화장실,양재천 영동2교(남단) 개방화장실,서울특별시 강남구,37.477,127.035\n`, {
latitude: 37.477,
longitude: 127.035
});
assert.equal(
items[0].mapUrl,
"https://map.kakao.com/link/map/%EC%96%91%EC%9E%AC%EC%B2%9C%20%EC%98%81%EB%8F%992%EA%B5%90(%EB%82%A8%EB%8B%A8)%20%EA%B0%9C%EB%B0%A9%ED%99%94%EC%9E%A5%EC%8B%A4,37.477,127.035"
);
});
test("searchNearbyPublicRestroomsByCoordinates applies maxDistanceMeters after Kakao source merge", async () => {
const fetchImpl = async (url) => {
const resolved = String(url);
if (resolved === "https://file.localdata.go.kr/file/download/public_restroom_info/info") {
return makeResponse(Buffer.from(csvFixture, "utf8"));
}
if (resolved.includes("keyword.json")) {
return makeResponse({ documents: [kakaoDocument({ id: "far-kakao", name: "먼 카카오 화장실", lat: 37.59, lon: 126.99 })] }, "application/json");
}
if (resolved.includes("category.json")) {
return makeResponse({ documents: [] }, "application/json");
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyPublicRestroomsByCoordinates({
latitude: 37.57371315593711,
longitude: 126.97833785777944,
limit: 5,
maxDistanceMeters: 100,
kakaoRestApiKey: "test-key",
fetchImpl
});
assert.equal(result.items.length, 0);
assert.equal(result.meta.total, 0);
});
function kakaoDocument({ id, name, lat, lon, distance = "0", category = "공중화장실" }) {
return {
id,
place_name: name,
category_name: category,
road_address_name: `${name} 도로명주소`,
address_name: `${name} 지번주소`,
y: String(lat),
x: String(lon),
distance
};
}
function makeResponse(body, contentType = "text/csv;charset=UTF-8") {
return {
ok: true,

View file

@ -16,7 +16,8 @@ metadata:
- 위치는 자동으로 추정하지 않는다.
- **반드시 먼저 현재 위치를 질문**한다.
- 화장실 데이터는 공식 `공중화장실정보` 표준데이터를 사용한다.
- 화장실 데이터는 공식 `공중화장실정보` 표준데이터를 기본으로 사용한다.
- `KAKAO_REST_API_KEY` 또는 `kakaoRestApiKey` 옵션이 있으면 Kakao Local REST API로 `공중화장실`, `개방화장실`, `OL7` 주유소를 추가 조회해 병합한다.
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 잡고, 가능한 경우 해당 시도 데이터만 좁혀서 조회한다.
- 좌표를 직접 받으면 바로 nearby 계산으로 들어간다.
@ -48,8 +49,10 @@ metadata:
1. 유저에게 반드시 현재 위치를 묻는다.
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보한다.
3. anchor 주소에서 시도(서울/경기/부산 등)를 추론할 수 있으면 해당 지역 CSV로 좁힌다.
4. 공식 `공중화장실정보` CSV를 내려받아 위·경도 기준 거리순으로 정렬한다.
5. 보통 3~5개만 짧게 정리하고, 필요하면 지도 링크(`map.kakao.com/link/map/...`)를 같이 준다.
4. 공식 `공중화장실정보` CSV를 내려받고, Kakao REST API 키가 있으면 `keyword.json?query=공중화장실`, `keyword.json?query=개방화장실`, `category.json?category_group_code=OL7` 결과를 추가한다.
5. Kakao `distance` 필드는 버리고 모든 결과를 위·경도 haversine 거리로 직접 재계산한다.
6. 좌표 50m 이내 중복은 동일 시설로 보고 CSV 결과를 우선 보존한다.
7. 보통 3~5개만 짧게 정리하고, 필요하면 URL 인코딩된 지도 링크(`map.kakao.com/link/map/...`)를 같이 준다.
## Responding
@ -90,6 +93,7 @@ main().catch((error) => {
## Failure modes
- Kakao REST API 키가 없으면 CSV 단일 소스로 동작하므로 누락 POI가 있을 수 있다.
- Kakao Map anchor 가 애매하면 위치 기준점이 흔들릴 수 있다.
- 공개 표준데이터는 실시간 점유/잠금 상태를 주지 않으므로 개방시간 중심으로만 안내해야 한다.
- CSV 인코딩/컬럼 구조가 바뀌면 정규화 로직을 다시 확인해야 한다.