mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
ff255bf272
commit
c7ab1edc7e
3 changed files with 162 additions and 23 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue