Bound optional Kakao restroom enrichment

Kakao CSV display correction now defaults to the requested visible limit instead of the whole normalized CSV set, while explicit csvCorrectionLimit can still widen the window. Optional Kakao keyword/category enrichment failures are isolated as metadata so CSV results remain usable when Kakao rate-limits or fails.

Constraint: PR #180 review identified quota and reliability blockers in the Kakao enrichment path

Rejected: Keep Promise.all fail-fast semantics | optional enrichment must not mask official CSV results

Rejected: Correct every CSV row by default | broad CSV responses can exceed latency and quota expectations

Confidence: high

Scope-risk: narrow

Directive: Keep official CSV as the primary source; Kakao enrichment failures should stay non-fatal unless a future explicit strict mode is added

Tested: KAKAO_REST_API_KEY=env-test npm test --workspace public-restroom-nearby

Tested: KAKAO_REST_API_KEY=env-test KAKAO_REST_APIKEY=env-alt npm test --workspace public-restroom-nearby

Tested: npm run lint --workspace public-restroom-nearby

Tested: npm run ci

Tested: mocked smoke run confirmed 1 returned CSV item, 1 coord2address call, and 3 Kakao layer warnings

Not-tested: live Kakao API behavior with a real API key
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-29 00:17:47 +09:00
commit c7ab1edc7e
3 changed files with 162 additions and 23 deletions

View file

@ -81,10 +81,12 @@ main().catch((error) => {
```
Kakao 키가 설정된 경우 기본 Kakao 검색 반경은 1,000m이며 `radiusMeters` 로 조정할 수 있습니다. `maxDistanceMeters` 는 병합 후 전체 결과 필터링에 사용됩니다. CSV 좌표의 실제 건물명/주소를 Kakao `coord2address` 로 보정하려면 `correctCsvWithKakao: true` 를 넘기세요.
Kakao 키가 설정된 경우 기본 Kakao 검색 반경은 1,000m이며 `radiusMeters` 로 조정할 수 있습니다. `maxDistanceMeters` 는 병합 후 전체 결과 필터링에 사용됩니다. CSV 좌표의 실제 건물명/주소를 Kakao `coord2address` 로 보정하려면 `correctCsvWithKakao: true` 를 넘기세요. 기본 보정 범위는 최종 요청 `limit` 개수로 제한되며, 더 넓게 보정해야 할 때만 `csvCorrectionLimit` 을 명시하세요.
병합 중복 제거는 좌표 50m 이내를 같은 시설로 보고 CSV(Layer 1)를 우선 보존합니다. 지도 링크는 장소명을 URL 인코딩해 공백/괄호/한국어 이름이 좌표 파싱을 깨지 않도록 생성합니다.
Kakao 키워드/주유소 보강은 선택 계층입니다. Kakao Local API가 401/429/5xx 등으로 실패해도 공식 CSV 결과는 반환되며, 실패한 계층은 `result.meta.kakaoErrors``source`, `status`, `message`, `url` 로 기록됩니다.
거리 제한이 필요하면 `maxDistanceMeters` 를 함께 넘겨서 반경 바깥 결과를 잘라낼 수 있습니다.
```js

View file

@ -257,7 +257,10 @@ async function fetchKakaoLayerItems(origin, options = {}) {
const kakaoRestApiKey = resolveKakaoRestApiKey(options);
if (!kakaoRestApiKey || options.includeKakaoSources === false) {
return [];
return {
items: [],
errors: []
};
}
const radius = normalizeRadius(options.radiusMeters ?? 1000);
@ -265,29 +268,66 @@ async function fetchKakaoLayerItems(origin, options = {}) {
const requests = [];
for (const keyword of KAKAO_RESTROOM_KEYWORDS) {
requests.push(fetchKakaoJson("/search/keyword.json", {
query: keyword,
requests.push({
source: SOURCE_KAKAO_KEYWORD,
sourceLayer: 2,
type: keyword,
promise: fetchKakaoJson("/search/keyword.json", {
query: keyword,
x: origin.longitude,
y: origin.latitude,
radius,
sort: "distance",
size
}, options)
});
}
requests.push({
source: SOURCE_KAKAO_GAS_STATION,
sourceLayer: 3,
type: "주유소 화장실",
promise: 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_KEYWORD, sourceLayer: 2, type: keyword })));
}, options)
});
const settled = await Promise.allSettled(requests.map((layer) => layer.promise));
const items = [];
const errors = [];
for (const [index, result] of settled.entries()) {
const layer = requests[index];
if (result.status === "rejected") {
errors.push(normalizeKakaoLayerError(result.reason, layer));
continue;
}
items.push(...(result.value?.documents || [])
.map((document) => normalizeKakaoPoi(document, origin, layer))
.filter(Boolean));
}
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: "주유소 화장실" })));
return {
items,
errors
};
}
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));
function normalizeKakaoLayerError(error, layer) {
return {
source: layer.source,
type: layer.type,
status: Number.isInteger(Number(error?.status)) ? Number(error.status) : null,
message: String(error?.message || "Kakao layer request failed."),
url: error?.url ? String(error.url) : null
};
}
async function fetchKakaoCoord2Address(latitude, longitude, options = {}) {
@ -330,7 +370,7 @@ async function correctCsvItemsWithKakao(items, options = {}) {
return items;
}
const limit = Math.max(0, Math.min(Number(options.csvCorrectionLimit ?? items.length) || 0, items.length));
const limit = Math.max(0, Math.min(Number(options.csvCorrectionLimit ?? options.limit) || 0, items.length));
const corrected = [...items];
for (let index = 0; index < limit; index += 1) {
@ -405,8 +445,12 @@ async function searchNearbyPublicRestroomsByCoordinates(options = {}) {
const csvItems = await correctCsvItemsWithKakao(normalizePublicRestroomRows(dataset.csvText, origin, {
maxDistanceMeters: options.maxDistanceMeters,
preferredDistrict: options.preferredDistrict
}), options);
const kakaoItems = await fetchKakaoLayerItems(origin, options);
}), {
...options,
limit
});
const kakaoResult = await fetchKakaoLayerItems(origin, options);
const kakaoItems = kakaoResult.items;
const allItems = mergeAndDeduplicateSources([...csvItems, ...kakaoItems], options);
return {
@ -423,7 +467,8 @@ async function searchNearbyPublicRestroomsByCoordinates(options = {}) {
datasetUrl: dataset.datasetUrl,
region: options.region || null,
sources: summarizeSources(allItems),
kakaoEnabled: Boolean(resolveKakaoRestApiKey(options)) && options.includeKakaoSources !== false
kakaoEnabled: Boolean(resolveKakaoRestApiKey(options)) && options.includeKakaoSources !== false,
kakaoErrors: kakaoResult.errors
}
};
}

View file

@ -1,4 +1,4 @@
const test = require("node:test");
const { afterEach, beforeEach, test } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
@ -16,6 +16,18 @@ 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");
const originalKakaoRestApiKey = process.env.KAKAO_REST_API_KEY;
const originalKakaoRestApiKeyAlt = process.env.KAKAO_REST_APIKEY;
beforeEach(() => {
delete process.env.KAKAO_REST_API_KEY;
delete process.env.KAKAO_REST_APIKEY;
});
afterEach(() => {
restoreEnv("KAKAO_REST_API_KEY", originalKakaoRestApiKey);
restoreEnv("KAKAO_REST_APIKEY", originalKakaoRestApiKeyAlt);
});
test("parseCoordinateQuery recognizes latitude/longitude pairs", () => {
assert.deepEqual(parseCoordinateQuery("37.573713, 126.978338"), {
@ -393,6 +405,77 @@ test("searchNearbyPublicRestroomsByCoordinates can correct CSV display names and
assert.equal(result.items[0].source, "csv");
});
test("searchNearbyPublicRestroomsByCoordinates only corrects the default visible CSV window", async () => {
const coord2addressCalls = [];
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?")) {
coord2addressCalls.push(resolved);
return makeResponse({ documents: [] }, "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,
includeKakaoSources: false,
fetchImpl
});
assert.equal(result.items.length, 1);
assert.equal(coord2addressCalls.length, 1);
});
test("searchNearbyPublicRestroomsByCoordinates returns CSV results with warnings when optional Kakao layers fail", 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") || resolved.includes("category.json")) {
return { ok: false, status: 429 };
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyPublicRestroomsByCoordinates({
latitude: 37.57371315593711,
longitude: 126.97833785777944,
limit: 1,
kakaoRestApiKey: "test-key",
fetchImpl
});
assert.equal(result.items.length, 1);
assert.equal(result.items[0].source, "csv");
assert.equal(result.meta.sources.csv, 3);
assert.equal(result.meta.sources.kakaoKeyword, 0);
assert.equal(result.meta.sources.kakaoGasStation, 0);
assert.equal(result.meta.kakaoErrors.length, 3);
assert.deepEqual(
result.meta.kakaoErrors.map((error) => error.source),
["kakao_keyword", "kakao_keyword", "kakao_category_gas_station"]
);
assert.ok(result.meta.kakaoErrors.every((error) => error.status === 429));
});
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,
@ -475,3 +558,12 @@ function makeResponse(body, contentType = "text/csv;charset=UTF-8") {
}
};
}
function restoreEnv(name, value) {
if (value === undefined) {
delete process.env[name];
return;
}
process.env[name] = value;
}