Keep Kakao station searches anchored to public places

Station-like Kakao queries were still falling through malformed subway panels to the first usable business result, which skewed every downstream distance calculation. This change keeps area queries on usable landmark anchors, restricts station-like fallthroughs to anchor-like prefix matches, and adds regressions for both the area-query and station-query paths.

Constraint: Kakao subway panels can return only subway_station_id without summary coordinates
Constraint: Must preserve live nearby-bar lookups for area queries like 사당 while avoiding unrelated business anchors
Rejected: Accept any usable fallback with coordinates | still measures from unrelated businesses
Rejected: Throw for all station-like fallback misses | would regress live area-query usability when landmark anchors are already good
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: If Kakao introduces new public-place categories, extend the anchor-like allowlist before relaxing the station-query prefix guard
Tested: node --test packages/kakao-bar-nearby/test/index.test.js
Tested: node --test scripts/skill-docs.test.js
Tested: npm run ci
Tested: live searchNearbyBarsByLocationQuery('사당', { limit: 3, panelLimit: 5 }) on 2026-03-29
Tested: live searchNearbyBarsByLocationQuery('사당역', { limit: 3, panelLimit: 5 }) on 2026-03-29
Not-tested: Other non-역 Kakao location aliases that may need new anchor-category allowlist entries
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-29 16:43:27 +09:00
commit d40e362cef
3 changed files with 297 additions and 7 deletions

View file

@ -1,4 +1,5 @@
const {
isAnchorLikePlace,
isBarPanel,
normalizeAnchorPanel,
normalizePlacePanel,
@ -10,6 +11,7 @@ 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_PANEL_LIMIT = 8;
const STATIONISH_CATEGORY_PATTERN = /(기차역|전철역|지하철역|환승역|수도권\d+호선|역)$/u;
const NON_WORD_PATTERN = /[^\p{L}\p{N}]+/gu;
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",
@ -89,10 +91,45 @@ function isUsableAnchor(anchor) {
return Boolean(
anchor?.sourceUrl &&
Number.isFinite(anchor?.latitude) &&
Number.isFinite(anchor?.longitude),
Number.isFinite(anchor?.longitude)
);
}
function normalizeQueryText(value) {
return String(value || "")
.normalize("NFKC")
.toLowerCase()
.replace(NON_WORD_PATTERN, "");
}
function isStationLikeQuery(query) {
return /역$/u.test(query);
}
function matchesAnchorQueryPrefix(query, anchor = {}) {
const normalizedQuery = normalizeQueryText(query);
if (!normalizedQuery) {
return false;
}
return [anchor.name, anchor.category]
.map((value) => normalizeQueryText(value))
.some((value) => value && (value.startsWith(normalizedQuery) || normalizedQuery.startsWith(value)));
}
function shouldAcceptAnchor(query, anchor) {
if (!isUsableAnchor(anchor)) {
return false;
}
if (!isStationLikeQuery(query)) {
return true;
}
return isAnchorLikePlace(anchor) && matchesAnchorQueryPrefix(query, anchor);
}
async function resolveAnchor(query, options = {}) {
const anchorSearchHtml = await fetchSearchResults(query, options);
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
@ -103,7 +140,7 @@ async function resolveAnchor(query, options = {}) {
const anchorPanel = await fetchPlacePanel(candidate.id, options);
const anchor = normalizeAnchorPanel(anchorPanel, candidate);
if (isUsableAnchor(anchor)) {
if (shouldAcceptAnchor(query, anchor)) {
return {
anchor,
anchorCandidates
@ -121,8 +158,8 @@ async function resolveAnchor(query, options = {}) {
function shouldRetryWithStationQuery(query, anchor) {
return (
!/역$/u.test(query) &&
(!Number.isFinite(anchor.latitude) || !Number.isFinite(anchor.longitude) || !STATIONISH_CATEGORY_PATTERN.test(anchor.category))
!isStationLikeQuery(query) &&
(!isUsableAnchor(anchor) || (!STATIONISH_CATEGORY_PATTERN.test(anchor.category) && !isAnchorLikePlace(anchor)))
);
}

View file

@ -2,7 +2,8 @@ const SEARCH_ITEM_PATTERN = /<li\s+class="search_item\s+base"([\s\S]*?)<\/li>/gi
const TAG_PATTERN = /<[^>]+>/g;
const NON_WORD_PATTERN = /[^\p{L}\p{N}]+/gu;
const ANCHOR_STATION_PATTERN = /(역|기차역|전철역|지하철역|환승역)$/u;
const ANCHOR_CATEGORY_PATTERN = /(기차역|전철역|지하철역|역사|광장|공원|거리|테마거리|관광명소|랜드마크)/u;
const ANCHOR_CATEGORY_PATTERN =
/(기차역|전철역|지하철역|역사|광장|공원|거리|테마거리|관광명소|랜드마크|먹자골목|교차로|주차장|정류장|환승센터)/u;
const BAR_CATEGORY_PATTERN = /(술집|주점|와인바|바\(BAR\)|\bBAR\b|맥주,호프|호프|이자카야|칵테일|포차|요리주점|일본식주점)/iu;
function decodeHtml(value) {
@ -237,6 +238,13 @@ function normalizeAnchorPanel(panel, searchItem = {}) {
};
}
function isAnchorLikePlace(place = {}) {
return (
!BAR_CATEGORY_PATTERN.test(`${place.name || ""} ${place.category || ""}`) &&
(ANCHOR_STATION_PATTERN.test(place.name || "") || ANCHOR_CATEGORY_PATTERN.test(place.category || ""))
);
}
function isBarCategoryValue(value) {
return BAR_CATEGORY_PATTERN.test(String(value || ""));
}
@ -291,6 +299,7 @@ function normalizePlacePanel(panel, searchItem = {}, anchorPoint = {}) {
module.exports = {
SEARCH_ITEM_PATTERN,
calculateDistanceMeters,
isAnchorLikePlace,
isBarPanel,
normalizeAnchorPanel,
normalizePlacePanel,

View file

@ -183,9 +183,253 @@ test("searchNearbyBarsByLocationQuery skips unusable station-like anchor panels
assert.ok(Number.isFinite(result.items[0].distanceMeters));
});
function makeResponse(body, contentType) {
test("searchNearbyBarsByLocationQuery keeps a usable landmark anchor for area queries instead of overwriting it with a station-business fallback", async () => {
const query = "사당";
const areaAnchorSearchHtml = buildSearchResultsHtml([
{
id: "11220631014",
name: "사당역",
category: "서울 서초구 방배2동",
address: "버스 정류장 번호 :"
},
{
id: "792176818",
name: "사당1동먹자골목상점가",
category: "먹자골목",
address: "서울 동작구 사당동 1101"
}
]);
const stationFallbackSearchHtml = buildSearchResultsHtml([
{
id: "21160811",
name: "사당역 2호선",
category: "수도권2호선",
address: "서울 동작구 사당동"
},
{
id: "211389635",
name: "삼육가 사당역1호점",
category: "육류,고기",
address: "서울 서초구 방배동"
},
{
id: "23371032",
name: "사당역 공영주차장",
category: "공영주차장",
address: "서울 서초구 방배동"
}
]);
const nearbyBarSearchHtml = buildSearchResultsHtml([
{
id: "2001",
name: "데이브루펍",
category: "맥주,호프",
address: "서울 중구 칠패로 31",
phone: "02-1111-2222",
openStatusLabel: "영업중",
openStatusText: "영업시간 12:00 ~ 23:30"
}
]);
const themeStreetAnchorPanel = {
summary: {
confirm_id: "792176818",
name: "사당1동먹자골목상점가",
category: {
name2: "관광,명소",
name3: "테마거리"
},
point: {
lat: 37.47835510628598,
lon: 126.98071247669172
},
address: {
disp: "서울 동작구 사당동 1101"
}
}
};
const invalidBusStopResponse = makeResponse({ error: "not found" }, "application/json", 404);
const invalidStationPanel = {
subway_station_id: "21160811"
};
const businessAnchorPanel = {
summary: {
confirm_id: "211389635",
name: "삼육가 사당역1호점",
category: {
name2: "한식",
name3: "육류,고기"
},
point: {
lat: 37.47742921471774,
lon: 126.98296478218445
},
address: {
disp: "서울 서초구 방배동"
}
}
};
const publicAnchorPanel = {
summary: {
confirm_id: "23371032",
name: "사당역 공영주차장",
category: {
name2: "교통시설",
name3: "주차장"
},
point: {
lat: 37.475555162992165,
lon: 126.98330436588915
},
address: {
disp: "서울 서초구 방배동"
}
}
};
const calls = [];
const responses = new Map([
[buildSearchUrl(query), makeResponse(areaAnchorSearchHtml, "text/html")],
[buildSearchUrl(`${query}`), makeResponse(stationFallbackSearchHtml, "text/html")],
[buildSearchUrl(`${query} 술집`), makeResponse(nearbyBarSearchHtml, "text/html")],
["https://place-api.map.kakao.com/places/panel3/11220631014", invalidBusStopResponse],
["https://place-api.map.kakao.com/places/panel3/792176818", makeResponse(themeStreetAnchorPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/21160811", makeResponse(invalidStationPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/211389635", makeResponse(businessAnchorPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/23371032", makeResponse(publicAnchorPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/2001", makeResponse(openBarPanel, "application/json")]
]);
const result = await searchNearbyBarsByLocationQuery(query, {
limit: 1,
panelLimit: 1,
fetchImpl: async (url) => {
const resolved = String(url);
calls.push(resolved);
const response = responses.get(resolved);
if (!response) {
throw new Error(`unexpected url: ${resolved}`);
}
return response;
}
});
assert.equal(result.anchor.id, "792176818");
assert.equal(result.anchor.name, "사당1동먹자골목상점가");
assert.ok(Number.isFinite(result.items[0].distanceMeters));
assert.ok(!calls.includes(buildSearchUrl("사당역")));
});
test("searchNearbyBarsByLocationQuery keeps station-like queries on public anchors instead of unrelated businesses", async () => {
const query = "사당역";
const stationSearchHtml = buildSearchResultsHtml([
{
id: "21160811",
name: "사당역 2호선",
category: "수도권2호선",
address: "서울 동작구 사당동"
},
{
id: "21160829",
name: "사당역 4호선",
category: "수도권4호선",
address: "서울 동작구 사당동"
},
{
id: "211389635",
name: "삼육가 사당역1호점",
category: "육류,고기",
address: "서울 서초구 방배동"
},
{
id: "23371032",
name: "사당역 공영주차장",
category: "공영주차장",
address: "서울 서초구 방배동"
}
]);
const nearbyBarSearchHtml = buildSearchResultsHtml([
{
id: "2001",
name: "데이브루펍",
category: "맥주,호프",
address: "서울 중구 칠패로 31",
phone: "02-1111-2222",
openStatusLabel: "영업중",
openStatusText: "영업시간 12:00 ~ 23:30"
}
]);
const invalidStationPanel = {
subway_station_id: "21160811"
};
const invalidSecondStationPanel = {
subway_station_id: "21160829"
};
const businessAnchorPanel = {
summary: {
confirm_id: "211389635",
name: "삼육가 사당역1호점",
category: {
name2: "한식",
name3: "육류,고기"
},
point: {
lat: 37.47742921471774,
lon: 126.98296478218445
},
address: {
disp: "서울 서초구 방배동"
}
}
};
const publicAnchorPanel = {
summary: {
confirm_id: "23371032",
name: "사당역 공영주차장",
category: {
name2: "교통시설",
name3: "주차장"
},
point: {
lat: 37.475555162992165,
lon: 126.98330436588915
},
address: {
disp: "서울 서초구 방배동"
}
}
};
const responses = new Map([
[buildSearchUrl(query), makeResponse(stationSearchHtml, "text/html")],
[buildSearchUrl(`${query} 술집`), makeResponse(nearbyBarSearchHtml, "text/html")],
["https://place-api.map.kakao.com/places/panel3/21160811", makeResponse(invalidStationPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/21160829", makeResponse(invalidSecondStationPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/211389635", makeResponse(businessAnchorPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/23371032", makeResponse(publicAnchorPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/2001", makeResponse(openBarPanel, "application/json")]
]);
const result = await searchNearbyBarsByLocationQuery(query, {
limit: 1,
panelLimit: 1,
fetchImpl: async (url) => {
const response = responses.get(String(url));
if (!response) {
throw new Error(`unexpected url: ${url}`);
}
return response;
}
});
assert.equal(result.anchor.id, "23371032");
assert.equal(result.anchor.name, "사당역 공영주차장");
assert.equal(result.anchor.category, "주차장");
assert.ok(Number.isFinite(result.items[0].distanceMeters));
});
function makeResponse(body, contentType, status = 200) {
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status: 200,
status,
headers: {
"content-type": contentType
}