Let agents find open nearby bars from Korean map data

Kakao Map mobile search results and place panels provide enough public data to turn station/neighborhood queries into nearby bar summaries with live open status, menu hints, seating hints, and phone numbers. This adds a reusable workspace package plus docs/skill wiring, while keeping the flow keyless and grounded in TDD + live smoke verification.

Constraint: Must avoid paid/authenticated Kakao APIs and new dependencies
Constraint: Nearby bar results must use current live panel/open-hour data
Rejected: Kakao Local REST API | requires app key/setup and breaks the no-auth posture
Rejected: Naver Map scraping | public responses were more rate-limited in testing
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: If Kakao changes panel3/search HTML contracts, verify headers and anchor-selection heuristics before expanding fallback logic
Tested: node --test packages/kakao-bar-nearby/test/index.test.js
Tested: node --test scripts/skill-docs.test.js
Tested: lsp diagnostics on affected files (0 errors)
Tested: live smoke searchNearbyBarsByLocationQuery('사당') on 2026-03-29
Tested: npm run ci
Not-tested: Precise distance calculation when Kakao station panels omit coordinates
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-03-29 15:34:31 +09:00
commit 25b1d3b328
19 changed files with 1232 additions and 1 deletions

View file

@ -27,6 +27,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
| 로또 당첨 확인 | 최신 회차, 특정 회차, 번호 대조 | 불필요 | [로또 결과 가이드](docs/features/lotto-results.md) |
| HWP 문서 처리 | `.hwp` → JSON/Markdown/HTML 변환, 이미지 추출, 배치 처리, Windows 직접 제어 선택 | 불필요 | [HWP 문서 처리 가이드](docs/features/hwp.md) |
| 근처 블루리본 맛집 | 현재 위치를 먼저 확인한 뒤 블루리본 서베이 공식 표면으로 근처 블루리본 맛집 검색 | 불필요 | [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md) |
| 근처 술집 조회 | 현재 위치(서울역/강남/사당 등)를 먼저 확인한 뒤 카카오맵 기준으로 영업 상태·메뉴·좌석·전화번호가 포함된 술집 조회 | 불필요 | [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md) |
| 우편번호 검색 | 주소 키워드로 공식 우체국 우편번호 조회 | 불필요 | [우편번호 검색 가이드](docs/features/zipcode-search.md) |
| 다이소 상품 조회 | 다이소몰 공식 매장/상품/재고 표면으로 특정 매장의 상품 재고 확인 | 불필요 | [다이소 상품 조회 가이드](docs/features/daiso-product-search.md) |
| 택배 배송조회 | CJ대한통운·우체국 공식 표면으로 배송 상태를 조회하고, carrier adapter 규칙으로 추가 택배사 확장을 준비 | 불필요 | [택배 배송조회 가이드](docs/features/delivery-tracking.md) |
@ -63,6 +64,7 @@ Claude code, codex, opencode 등 각종 코딩 에이전트 지원합니다.
- [로또 당첨 확인](docs/features/lotto-results.md)
- [HWP 문서 처리](docs/features/hwp.md)
- [근처 블루리본 맛집 가이드](docs/features/blue-ribbon-nearby.md)
- [근처 술집 조회 가이드](docs/features/kakao-bar-nearby.md)
- [우편번호 검색](docs/features/zipcode-search.md)
- [다이소 상품 조회](docs/features/daiso-product-search.md)
- [택배 배송조회](docs/features/delivery-tracking.md)

View file

@ -0,0 +1,107 @@
# 근처 술집 조회 가이드
## 이 기능으로 할 수 있는 일
- 서울역/강남/사당/논현 같은 위치 질의를 카카오맵 기준 술집 검색으로 변환
- **현재 영업 상태** 기준으로 영업 중인 술집을 먼저 정리
- 대표 메뉴, 좌석 옵션(단체석/바테이블 등), 전화번호를 함께 제공
- 역/랜드마크 anchor 와 술집 결과의 거리를 대략적으로 계산
## 가장 먼저 할 일
이 기능은 **반드시 현재 위치를 먼저 물어본 뒤** 실행합니다.
권장 질문 예시:
```text
현재 위치를 알려주세요. 서울역/강남/사당 같은 역명이나 동네명으로 보내주시면 카카오맵 기준 근처 술집을 찾아볼게요.
```
## 입력값
- 역명: `서울역`, `사당`, `강남`, `신논현`, `논현`
- 동네/랜드마크: `해방촌`, `코엑스`, `성수동`
위치가 넓거나 애매하면 가까운 역명으로 한 번 더 좁히는 편이 정확합니다.
## 공식 Kakao Map 표면
- 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
- 장소 상세 페이지: `https://place.map.kakao.com/<confirmId>`
기본 흐름은 `위치 query``위치 anchor 후보 선택``위치 + 술집 검색``panel3 정규화` 입니다.
## 정규화되는 핵심 필드
- 술집명 / 카테고리
- 현재 영업 상태 (`영업 중`, `영업 전`, `휴무일`)
- 대표 메뉴
- 좌석 옵션 / 인원 수용 힌트 (`단체석`, `바테이블` 등)
- 전화번호
- 거리(가능하면)
## Node.js 예시
```js
const { searchNearbyBarsByLocationQuery } = require("kakao-bar-nearby");
async function main() {
const result = await searchNearbyBarsByLocationQuery("서울역", {
limit: 5
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 검증된 live smoke 예시
아래 값은 **2026-03-29**`사당`, `limit=3`, `panelLimit=8` 로 실제 호출해 확인한 결과 일부입니다.
```json
{
"anchor": {
"name": "사당역 2호선"
},
"meta": {
"openNowCount": 3
},
"items": [
{
"name": "방배을지로골뱅이술집포차 사당역점",
"openStatus": { "label": "영업 중", "detail": "24:00 까지" },
"menuSamples": ["을지로골뱅이(골뱅이무침)", "백골뱅이탕 (중)", "먹태"]
},
{
"name": "우미노식탁",
"openStatus": { "label": "영업 중", "detail": "24:00 까지" },
"seatingKeywords": ["단체석", "케이크 반입 가능", "바테이블"]
},
{
"name": "커먼테이블",
"openStatus": { "label": "영업 중", "detail": "01:00 까지" },
"phone": "010-7730-1056"
}
]
}
```
## 운영 팁
- 영업 중인 결과가 있으면 먼저 보여주고, 없으면 곧 오픈하는 곳을 같이 보여준다.
- 메뉴가 비어 있으면 카카오맵 카테고리와 소개 문구로 `대략적인 메뉴` 를 설명한다.
- `단체석`, `룸`, `바테이블` 같은 좌석 옵션으로 인원 수용을 근사치로 설명한다.
- 바로 예약/주문까지 가지 말고 조회 결과만 제공한다.
## 주의할 점
- panel3 JSON 은 브라우저와 유사한 헤더가 없으면 406 이 날 수 있습니다.
- 카카오맵의 장소 패널 구조가 바뀌면 메뉴/영업 정보 필드도 달라질 수 있습니다.
- exact seating capacity 숫자는 제공되지 않을 수 있으므로 `단체 방문 가능` 같은 근사 힌트로 안내합니다.

View file

@ -51,6 +51,7 @@ npx --yes skills add <owner/repo> \
--skill fine-dust-location \
--skill daiso-product-search \
--skill blue-ribbon-nearby \
--skill kakao-bar-nearby \
--skill zipcode-search \
--skill delivery-tracking
```

View file

@ -12,6 +12,7 @@
- 사용자 위치 미세먼지 조회 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 근처 술집 조회 스킬 출시
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
- 다이소 상품 조회 스킬 출시

View file

@ -24,6 +24,8 @@
- 블루리본 메인: https://www.bluer.co.kr/
- 블루리본 지역 검색: https://www.bluer.co.kr/search/zone
- 블루리본 주변 맛집 JSON: https://www.bluer.co.kr/restaurants/map
- 카카오맵 모바일 검색: https://m.map.kakao.com/actions/searchView
- 카카오맵 장소 패널 JSON: https://place-api.map.kakao.com/places/panel3/<confirmId>
- 서울특별시 지하철 실시간 도착정보: https://www.data.go.kr/data/15058052/openapi.do
- 에어코리아 대기오염정보: https://www.data.go.kr/data/15073861/openapi.do
- 에어코리아 측정소정보: https://www.data.go.kr/data/15073877/openapi.do

86
kakao-bar-nearby/SKILL.md Normal file
View file

@ -0,0 +1,86 @@
---
name: kakao-bar-nearby
description: Use when the user asks for nearby bars or 근처 술집. Always ask the user's current location first, then use Kakao Map search + place detail panels to find open-now bars with menu, seating, and phone hints.
license: MIT
metadata:
category: food
locale: ko-KR
phase: v1
---
# Kakao Bar Nearby
## What this skill does
유저가 알려준 현재 위치를 기준으로 **카카오맵 기준 근처 술집**을 찾아준다.
- 위치는 자동으로 추정하지 않는다.
- **반드시 먼저 현재 위치를 질문**한다.
- `서울역`, `강남`, `사당`, `신논현`, `논현` 같은 역명/동네/랜드마크 질의를 그대로 받을 수 있다.
- 결과에는 현재 영업 상태, 대표 메뉴, 좌석 옵션(단체석/바테이블 등), 전화번호를 포함한다.
## When to use
- "서울역 근처 술집 찾아줘"
- "강남에서 지금 영업중인 와인바 뭐 있어?"
- "논현 근처 4명 갈만한 술집 알려줘"
- "사당에서 전화번호 있는 이자카야 몇 군데만 보여줘"
## Mandatory first question
위치 정보 없이 바로 검색하지 말고 반드시 먼저 물어본다.
- 권장 질문: `현재 위치를 알려주세요. 서울역/강남/사당 같은 역명이나 동네명으로 보내주시면 카카오맵 기준 근처 술집을 찾아볼게요.`
- 위치가 애매하면: `가까운 역명이나 동 이름으로 한 번만 더 알려주세요.`
## Official Kakao Map surfaces
- 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
- 장소 상세 페이지: `https://place.map.kakao.com/<confirmId>`
## Workflow
1. 유저에게 반드시 현재 위치를 묻는다.
2. 받은 위치 문자열을 카카오맵 검색으로 anchor 후보(역/랜드마크)로 해석한다.
3. 같은 위치 문자열에 `술집` 키워드를 붙여 nearby 술집 검색 결과를 가져온다.
4. 상위 후보의 panel3 JSON 을 조회해 현재 영업 상태, 메뉴, 좌석 옵션, 전화번호를 정규화한다.
5. **영업 중인 술집을 먼저** 보여주고, 필요하면 곧 열 곳도 함께 보여준다.
## Responding
보통 3~5개만 짧게 정리한다.
- 술집명
- 카테고리
- 영업 상태 (`영업 중`, `영업 전`, `휴무일` 등)
- 대표 메뉴 2~3개
- 좌석/인원 수용 힌트 (`단체석`, `바테이블` 등)
- 전화번호
- 거리(가능하면)
## Node.js example
```js
const { searchNearbyBarsByLocationQuery } = require("kakao-bar-nearby");
async function main() {
const result = await searchNearbyBarsByLocationQuery("서울역", {
limit: 5
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Done when
- 유저의 현재 위치를 먼저 확인했다.
- 카카오맵 기준 술집 결과를 최소 1개 이상 찾았거나, 찾지 못한 이유와 다음 질문을 제시했다.
- 영업 상태/메뉴/좌석 옵션/전화번호가 포함된 요약을 보여줬다.

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js && 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 blue-ribbon-nearby --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace blue-ribbon-nearby --dry-run && npm pack --workspace kakao-bar-nearby --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,73 @@
# kakao-bar-nearby
카카오맵 검색 + 장소 패널 JSON 을 사용해 근처 술집을 찾는 Node.js 패키지입니다.
## 설치
배포 후:
```bash
npm install kakao-bar-nearby
```
이 저장소에서 개발할 때:
```bash
npm install
```
## 사용 원칙
- 유저 위치는 자동으로 추적하지 않습니다.
- 먼저 **현재 위치를 먼저 물어본다** 는 규칙을 지키세요.
- `서울역 술집`, `강남 술집`, `사당 술집` 같은 질의를 카카오맵 모바일 검색으로 조회합니다.
- 영업 중인 결과를 먼저 정렬하고, 대표 메뉴·좌석 힌트·전화번호를 함께 반환합니다.
## 공식 Kakao Map 표면
- 모바일 검색: `https://m.map.kakao.com/actions/searchView`
- 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
- 장소 상세 페이지: `https://place.map.kakao.com/<confirmId>`
## 사용 예시
```js
const { searchNearbyBarsByLocationQuery } = require("kakao-bar-nearby");
async function main() {
const result = await searchNearbyBarsByLocationQuery("서울역", {
limit: 5
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Live smoke snapshot
2026-03-29 에 `사당`, `limit=3`, `panelLimit=8` 로 실제 호출했을 때 상위 결과 예시는 아래와 같았습니다.
```json
{
"anchor": { "name": "사당역 2호선" },
"meta": { "openNowCount": 3 },
"items": [
{ "name": "방배을지로골뱅이술집포차 사당역점", "open": "영업 중", "detail": "24:00 까지" },
{ "name": "우미노식탁", "open": "영업 중", "detail": "24:00 까지" },
{ "name": "커먼테이블", "open": "영업 중", "detail": "01:00 까지" }
]
}
```
## 공개 API
- `parseSearchResultsHtml(html)`
- `selectAnchorCandidate(locationQuery, items)`
- `normalizePlacePanel(panel, searchItem, anchorPoint)`
- `searchNearbyBarsByLocationQuery(locationQuery, options?)`

View file

@ -0,0 +1,33 @@
{
"name": "kakao-bar-nearby",
"version": "0.1.0",
"description": "Kakao Map based nearby bar lookup for Korean location queries with open-now status, menu, seating, and phone hints",
"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",
"kakao-map",
"bar",
"pub",
"alcohol"
],
"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,188 @@
const {
isBarPanel,
normalizeAnchorPanel,
normalizePlacePanel,
parseSearchResultsHtml,
selectAnchorCandidate
} = 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_PANEL_LIMIT = 8;
const STATIONISH_CATEGORY_PATTERN = /(기차역|전철역|지하철역|환승역|수도권\d+호선|역)$/u;
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/",
"sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site"
};
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: {
...(responseType === "json" ? DEFAULT_PANEL_HEADERS : DEFAULT_BROWSER_HEADERS),
...(options.headers || {})
},
signal: options.signal
});
if (!response.ok) {
throw new Error(`Kakao bar lookup request failed with ${response.status} for ${url}`);
}
return responseType === "json" ? response.json() : 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, "json");
}
function sortBars(items) {
return [...items].sort((left, right) => {
if (left.isOpenNow !== right.isOpenNow) {
return Number(right.isOpenNow) - Number(left.isOpenNow);
}
const leftDistance = left.distanceMeters ?? Number.POSITIVE_INFINITY;
const rightDistance = right.distanceMeters ?? Number.POSITIVE_INFINITY;
if (leftDistance !== rightDistance) {
return leftDistance - rightDistance;
}
return left.name.localeCompare(right.name, "ko");
});
}
function rankAnchorQueue(query, anchorCandidates) {
const preferredAnchor = selectAnchorCandidate(query, anchorCandidates);
return [preferredAnchor, ...anchorCandidates.filter((candidate) => candidate.id !== preferredAnchor.id)];
}
async function resolveAnchor(query, options = {}) {
const anchorSearchHtml = await fetchSearchResults(query, options);
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
const anchorQueue = rankAnchorQueue(query, anchorCandidates);
for (const candidate of anchorQueue) {
try {
const anchorPanel = await fetchPlacePanel(candidate.id, options);
return {
anchor: normalizeAnchorPanel(anchorPanel, candidate),
anchorCandidates
};
} catch (error) {
if (!/404/.test(String(error.message || error))) {
throw error;
}
}
}
throw new Error(`No Kakao Map place panel was available for ${query}.`);
}
function shouldRetryWithStationQuery(query, anchor) {
return (
!/역$/u.test(query) &&
(!Number.isFinite(anchor.latitude) || !Number.isFinite(anchor.longitude) || !STATIONISH_CATEGORY_PATTERN.test(anchor.category))
);
}
async function searchNearbyBarsByLocationQuery(locationQuery, options = {}) {
const query = String(locationQuery || "").trim();
if (!query) {
throw new Error("locationQuery is required.");
}
let { anchor, anchorCandidates } = await resolveAnchor(query, options);
if (shouldRetryWithStationQuery(query, anchor)) {
try {
const stationResolution = await resolveAnchor(`${query}`, options);
if (
Number.isFinite(stationResolution.anchor.latitude) &&
Number.isFinite(stationResolution.anchor.longitude)
) {
anchor = stationResolution.anchor;
anchorCandidates = stationResolution.anchorCandidates;
} else if (STATIONISH_CATEGORY_PATTERN.test(stationResolution.anchor.category)) {
anchor = stationResolution.anchor;
anchorCandidates = stationResolution.anchorCandidates;
}
} catch (_error) {
// Keep the original anchor when the station fallback is unavailable.
}
}
const searchHtml = await fetchSearchResults(`${query} 술집`, options);
const searchItems = parseSearchResultsHtml(searchHtml);
const panelLimit = Math.max(1, Number(options.panelLimit || DEFAULT_PANEL_LIMIT));
const panels = await Promise.all(
searchItems.slice(0, panelLimit).map(async (searchItem) => ({
searchItem,
panel: await fetchPlacePanel(searchItem.id, options)
})),
);
const normalizedItems = sortBars(
panels
.filter(({ panel, searchItem }) => isBarPanel(panel, searchItem))
.map(({ panel, searchItem }) =>
normalizePlacePanel(panel, searchItem, {
latitude: anchor.latitude,
longitude: anchor.longitude
}),
),
);
return {
anchor,
anchorCandidates,
items: normalizedItems.slice(0, options.limit ?? 5),
meta: {
evaluatedAt: new Date().toISOString(),
totalSearchResults: searchItems.length,
openNowCount: normalizedItems.filter((item) => item.isOpenNow).length,
fetchedPanels: panels.length
}
};
}
module.exports = {
DEFAULT_PANEL_LIMIT,
PLACE_PANEL_URL_BASE,
SEARCH_VIEW_URL,
fetchPlacePanel,
fetchSearchResults,
normalizePlacePanel,
parseSearchResultsHtml,
searchNearbyBarsByLocationQuery,
selectAnchorCandidate
};

View file

@ -0,0 +1,299 @@
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 ANCHOR_STATION_PATTERN = /(역|기차역|전철역|지하철역|환승역)$/u;
const ANCHOR_CATEGORY_PATTERN = /(기차역|전철역|지하철역|역사|광장|공원|거리|테마거리|관광명소|랜드마크)/u;
const BAR_CATEGORY_PATTERN = /(술집|주점|와인바|바\(BAR\)|\bBAR\b|맥주,호프|호프|이자카야|칵테일|포차|요리주점|일본식주점)/iu;
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) || "",
phone: extractAttribute(fragment, "data-phone") || extractInnerText(fragment, "num_phone"),
openStatusLabel: extractInnerText(fragment, "tag_openoff"),
openStatusText: extractInnerText(fragment, "txt_openoff")
});
}
return items;
}
function scoreAnchorCandidate(query, item) {
const normalizedQuery = normalizeText(query);
const normalizedName = normalizeText(item.name);
const normalizedAddress = normalizeText(item.address);
const normalizedCategory = normalizeText(item.category);
let score = 0;
if (!normalizedQuery) {
return score;
}
if (normalizedName === normalizedQuery) {
score += 1_000;
}
if (normalizedName === `${normalizedQuery}` || normalizedName === normalizedQuery.replace(/역$/u, "")) {
score += 950;
}
if (normalizedName.startsWith(normalizedQuery)) {
score += 800;
}
if (normalizedName.includes(normalizedQuery)) {
score += 600;
}
if (normalizedAddress.includes(normalizedQuery)) {
score += 120;
}
if (ANCHOR_STATION_PATTERN.test(item.name) || ANCHOR_CATEGORY_PATTERN.test(item.category)) {
score += 250;
}
if (BAR_CATEGORY_PATTERN.test(item.category) || BAR_CATEGORY_PATTERN.test(item.name)) {
score -= 200;
}
if (normalizedCategory.includes("기차역") || normalizedCategory.includes("전철역")) {
score += 80;
}
if (!/^\d+$/.test(String(item.id || ""))) {
score -= 500;
}
return score;
}
function selectAnchorCandidate(query, items) {
const ranked = [...(items || [])].sort((left, right) => {
const scoreDelta = scoreAnchorCandidate(query, right) - scoreAnchorCandidate(query, left);
if (scoreDelta !== 0) {
return scoreDelta;
}
return left.name.localeCompare(right.name, "ko");
});
if (ranked.length === 0) {
throw new Error("No Kakao Map place candidate matched that location query.");
}
return ranked[0];
}
function calculateDistanceMeters(originLatitude, originLongitude, latitude, longitude) {
if (![originLatitude, originLongitude, latitude, longitude].every(Number.isFinite)) {
return null;
}
const toRadians = (value) => (value * Math.PI) / 180;
const earthRadiusMeters = 6_371_000;
const latitudeDelta = toRadians(latitude - originLatitude);
const longitudeDelta = toRadians(longitude - originLongitude);
const haversine =
Math.sin(latitudeDelta / 2) ** 2 +
Math.cos(toRadians(originLatitude)) *
Math.cos(toRadians(latitude)) *
Math.sin(longitudeDelta / 2) ** 2;
return Math.round(2 * earthRadiusMeters * Math.asin(Math.sqrt(haversine)));
}
function collectMenuSamples(panel) {
return [...new Set(
(panel.menu?.menus?.items || [])
.map((item) => String(item.name || "").trim())
.filter(Boolean),
)].slice(0, 5);
}
function collectSeatingKeywords(panel) {
const keywords = new Set();
const aiMate = panel.ai_mate || {};
for (const value of aiMate.summary?.contents || []) {
if (value) {
keywords.add(String(value).trim());
}
}
for (const sheet of aiMate.bottom_sheet?.sheets || []) {
for (const item of sheet.list || []) {
if (item.title === "좌석 옵션") {
for (const keyword of item.keywords || []) {
if (keyword) {
keywords.add(String(keyword).trim());
}
}
}
}
}
return [...keywords];
}
function deriveCapacityHint(seatingKeywords) {
if (seatingKeywords.some((keyword) => /단체석||대관/u.test(keyword))) {
return "단체 방문 가능";
}
if (seatingKeywords.some((keyword) => /바테이블|혼술/u.test(keyword))) {
return "소규모/혼술 위주";
}
return seatingKeywords[0] || null;
}
function normalizeOpenStatus(panel, searchItem = {}) {
const headline = panel.open_hours?.headline || {};
const label = headline.display_text || searchItem.openStatusLabel || null;
const detail = headline.display_text_info || searchItem.openStatusText || null;
const code = headline.code || null;
const today = panel.open_hours?.week_from_today?.week_periods
?.flatMap((period) => period.days || [])
?.find((day) => day.is_highlight);
const todayHours = today?.on_days?.start_end_time_desc || null;
const isOpenNow = /영업\s*중/u.test(label || "") || code === "OPEN" || code === "OPEN_NOW";
return {
code,
label,
detail,
todayHours,
isOpenNow
};
}
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 || "",
phone: summary.phone_numbers?.[0]?.tel || searchItem.phone || null,
latitude: Number(summary.point?.lat),
longitude: Number(summary.point?.lon),
sourceUrl: summary.confirm_id ? `https://place.map.kakao.com/${summary.confirm_id}` : null
};
}
function isBarCategoryValue(value) {
return BAR_CATEGORY_PATTERN.test(String(value || ""));
}
function isBarPanel(panel, searchItem = {}) {
const summary = panel.summary || {};
return [
summary.category?.name3,
summary.category?.name2,
searchItem.category,
summary.name,
searchItem.name
].some(isBarCategoryValue);
}
function normalizePlacePanel(panel, searchItem = {}, anchorPoint = {}) {
const summary = panel.summary || {};
const latitude = Number(summary.point?.lat);
const longitude = Number(summary.point?.lon);
const openStatus = normalizeOpenStatus(panel, searchItem);
const seatingKeywords = collectSeatingKeywords(panel);
const menuSamples = collectMenuSamples(panel);
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 || "",
phone: summary.phone_numbers?.[0]?.tel || searchItem.phone || null,
latitude,
longitude,
distanceMeters: calculateDistanceMeters(anchorPoint.latitude, anchorPoint.longitude, latitude, longitude),
isOpenNow: openStatus.isOpenNow,
openStatus: {
code: openStatus.code,
label: openStatus.label,
detail: openStatus.detail,
todayHours: openStatus.todayHours
},
menuSamples,
seatingKeywords,
capacityHint: deriveCapacityHint(seatingKeywords),
tags: panel.place_add_info?.tags || [],
summary:
panel.ai_mate?.bottom_sheet?.summary ||
panel.ai_mate?.summary?.title ||
null,
sourceUrl: summary.confirm_id ? `https://place.map.kakao.com/${summary.confirm_id}` : null
};
}
module.exports = {
SEARCH_ITEM_PATTERN,
calculateDistanceMeters,
isBarPanel,
normalizeAnchorPanel,
normalizePlacePanel,
parseSearchResultsHtml,
selectAnchorCandidate
};

View file

@ -0,0 +1,21 @@
{
"summary": {
"confirm_id": "1001",
"name": "서울역",
"category": {
"name1": "교통,수송",
"name2": "기차역",
"name3": "기차역"
},
"point": {
"lon": 126.97068,
"lat": 37.55472
},
"address": {
"disp": "서울 용산구 동자동"
},
"phone_numbers": [
{ "tel": "1544-7788" }
]
}
}

View file

@ -0,0 +1,22 @@
<ul id="placeList" class="list_result ">
<li class="search_item base" data-id="1001" data-cid="1001" data-title="서울역" data-phone="1544-7788">
<a href="javascript:;" class="link_result">
<span class="info_result">
<span class="txt_tit">
<strong class="tit_g">서울역</strong><span class="txt_ginfo ">기차역</span>
</span>
<span class="txt_g">서울 용산구 동자동</span>
</span>
</a>
</li>
<li class="search_item base" data-id="1002" data-cid="1002" data-title="서울로7017" data-phone="">
<a href="javascript:;" class="link_result">
<span class="info_result">
<span class="txt_tit">
<strong class="tit_g">서울로7017</strong><span class="txt_ginfo ">테마거리</span>
</span>
<span class="txt_g">서울 중구 만리재로</span>
</span>
</a>
</li>
</ul>

View file

@ -0,0 +1,47 @@
<ul id="placeList" class="list_result ">
<li class="search_item base" data-id="2001" data-cid="2001" data-title="데이브루펍" data-phone="02-1111-2222">
<a href="javascript:;" class="link_result">
<span class="info_result">
<span class="txt_tit">
<strong class="tit_g">데이브루펍</strong><span class="txt_ginfo ">맥주,호프</span>
</span>
<span class="txt_g">서울 중구 칠패로 31</span>
<span class="info_openoff">
<span class="tag_openoff">영업중</span>
<span class="txt_openoff">영업시간 12:00 ~ 23:30</span>
</span>
<a href="tel:02-1111-2222" class="num_phone"><span class="screen_out">TEL</span>02-1111-2222</a>
</span>
</a>
</li>
<li class="search_item base" data-id="2002" data-cid="2002" data-title="밤산책와인바" data-phone="02-3333-4444">
<a href="javascript:;" class="link_result">
<span class="info_result">
<span class="txt_tit">
<strong class="tit_g">밤산책와인바</strong><span class="txt_ginfo ">와인바</span>
</span>
<span class="txt_g">서울 중구 중림로 10</span>
<span class="info_openoff">
<span class="tag_openoff">영업전</span>
<span class="txt_openoff">영업시간 18:00 ~ 01:00</span>
</span>
<a href="tel:02-3333-4444" class="num_phone"><span class="screen_out">TEL</span>02-3333-4444</a>
</span>
</a>
</li>
<li class="search_item base" data-id="2003" data-cid="2003" data-title="서울역국밥" data-phone="02-5555-6666">
<a href="javascript:;" class="link_result">
<span class="info_result">
<span class="txt_tit">
<strong class="tit_g">서울역국밥</strong><span class="txt_ginfo ">국밥</span>
</span>
<span class="txt_g">서울 중구 세종대로 12</span>
<span class="info_openoff">
<span class="tag_openoff">영업중</span>
<span class="txt_openoff">영업시간 10:00 ~ 22:00</span>
</span>
<a href="tel:02-5555-6666" class="num_phone"><span class="screen_out">TEL</span>02-5555-6666</a>
</span>
</a>
</li>
</ul>

View file

@ -0,0 +1,72 @@
{
"summary": {
"confirm_id": "2002",
"name": "밤산책와인바",
"category": {
"name1": "음식점",
"name2": "술집",
"name3": "와인바"
},
"point": {
"lon": 126.9689,
"lat": 37.5575
},
"address": {
"disp": "서울 중구 중림로 10"
},
"phone_numbers": [
{ "tel": "02-3333-4444" }
]
},
"open_hours": {
"headline": {
"code": "BEFORE_OPEN",
"display_text": "영업 전",
"display_text_info": "18:00 오픈"
},
"week_from_today": {
"week_periods": [
{
"days": [
{
"day_of_the_week_desc": "일(3/29)",
"on_days": {
"start_end_time_desc": "18:00 ~ 01:00"
},
"is_highlight": true
}
]
}
]
}
},
"menu": {
"menus": {
"items": [
{ "name": "하우스와인", "price": 13000 },
{ "name": "치즈 플레이트", "price": 21000 }
],
"menu_type": "FOOD"
}
},
"ai_mate": {
"bottom_sheet": {
"summary": "조용한 분위기의 와인바입니다.",
"sheets": [
{
"title": "제공 서비스",
"ui_type": "LIST",
"list": [
{
"title": "좌석 옵션",
"keywords": ["바테이블"]
}
]
}
]
}
},
"place_add_info": {
"tags": ["데이트하기좋은"]
}
}

View file

@ -0,0 +1,28 @@
{
"summary": {
"confirm_id": "2003",
"name": "서울역국밥",
"category": {
"name1": "음식점",
"name2": "한식",
"name3": "국밥"
},
"point": {
"lon": 126.971,
"lat": 37.5539
},
"address": {
"disp": "서울 중구 세종대로 12"
},
"phone_numbers": [
{ "tel": "02-5555-6666" }
]
},
"open_hours": {
"headline": {
"code": "OPEN",
"display_text": "영업 중",
"display_text_info": "22:00 영업종료"
}
}
}

View file

@ -0,0 +1,80 @@
{
"summary": {
"confirm_id": "2001",
"name": "데이브루펍",
"category": {
"name1": "음식점",
"name2": "술집",
"name3": "맥주,호프"
},
"point": {
"lon": 126.9722,
"lat": 37.5553
},
"address": {
"disp": "서울 중구 칠패로 31"
},
"phone_numbers": [
{ "tel": "02-1111-2222" }
]
},
"open_hours": {
"headline": {
"code": "OPEN",
"display_text": "영업 중",
"display_text_info": "23:30 라스트오더"
},
"week_from_today": {
"week_periods": [
{
"days": [
{
"day_of_the_week_desc": "일(3/29)",
"on_days": {
"start_end_time_desc": "12:00 ~ 23:30"
},
"is_highlight": true
}
]
}
]
}
},
"menu": {
"menus": {
"items": [
{ "name": "수제맥주 샘플러", "price": 18000 },
{ "name": "감바스", "price": 22000 },
{ "name": "페퍼로니 피자", "price": 24000 }
],
"menu_type": "FOOD"
}
},
"ai_mate": {
"summary": {
"title": "낮술도 가능한 캐주얼 펍",
"contents": ["단체석"]
},
"bottom_sheet": {
"summary": "수제맥주와 피자를 함께 즐기기 좋고, 낮에도 운영하는 캐주얼 펍입니다.",
"sheets": [
{
"title": "제공 서비스",
"ui_type": "LIST",
"list": [
{
"title": "좌석 옵션",
"keywords": ["단체석", "바테이블"]
}
]
}
]
},
"blog_summaries": [
{ "title": "방문 목적", "keywords": ["2차", "친구·지인"] }
]
},
"place_add_info": {
"tags": ["술한잔하기좋은"]
}
}

View file

@ -0,0 +1,117 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
normalizePlacePanel,
parseSearchResultsHtml,
searchNearbyBarsByLocationQuery,
selectAnchorCandidate
} = require("../src/index");
const fixturesDir = path.join(__dirname, "fixtures");
const anchorSearchHtml = fs.readFileSync(path.join(fixturesDir, "anchor-search.html"), "utf8");
const barSearchHtml = fs.readFileSync(path.join(fixturesDir, "bar-search.html"), "utf8");
const anchorPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "anchor-panel.json"), "utf8"));
const openBarPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "open-bar-panel.json"), "utf8"));
const closedBarPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "closed-bar-panel.json"), "utf8"));
const nonBarPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "non-bar-panel.json"), "utf8"));
test("parseSearchResultsHtml extracts Kakao mobile search cards with open-status and phone fields", () => {
const items = parseSearchResultsHtml(barSearchHtml);
assert.equal(items.length, 3);
assert.deepEqual(items[0], {
id: "2001",
name: "데이브루펍",
category: "맥주,호프",
address: "서울 중구 칠패로 31",
phone: "02-1111-2222",
openStatusLabel: "영업중",
openStatusText: "영업시간 12:00 ~ 23:30"
});
});
test("selectAnchorCandidate prefers the obvious station/landmark match for the location query", () => {
const anchor = selectAnchorCandidate("서울역", parseSearchResultsHtml(anchorSearchHtml));
assert.equal(anchor.id, "1001");
assert.equal(anchor.name, "서울역");
assert.equal(anchor.category, "기차역");
});
test("normalizePlacePanel keeps menu, seating, phone, distance, and open-now hints", () => {
const item = normalizePlacePanel(openBarPanel, {
id: "2001",
phone: "02-1111-2222",
openStatusLabel: "영업중",
openStatusText: "영업시간 12:00 ~ 23:30"
}, {
latitude: 37.55472,
longitude: 126.97068
});
assert.equal(item.id, "2001");
assert.equal(item.name, "데이브루펍");
assert.equal(item.phone, "02-1111-2222");
assert.equal(item.isOpenNow, true);
assert.equal(item.openStatus.label, "영업 중");
assert.equal(item.openStatus.detail, "23:30 라스트오더");
assert.deepEqual(item.menuSamples, ["수제맥주 샘플러", "감바스", "페퍼로니 피자"]);
assert.deepEqual(item.seatingKeywords, ["단체석", "바테이블"]);
assert.equal(item.capacityHint, "단체 방문 가능");
assert.ok(item.distanceMeters > 0);
});
test("searchNearbyBarsByLocationQuery returns open bars first and drops non-bar categories", async () => {
const responses = new Map([
["https://m.map.kakao.com/actions/searchView?q=%EC%84%9C%EC%9A%B8%EC%97%AD", makeResponse(anchorSearchHtml, "text/html")],
["https://m.map.kakao.com/actions/searchView?q=%EC%84%9C%EC%9A%B8%EC%97%AD+%EC%88%A0%EC%A7%91", makeResponse(barSearchHtml, "text/html")],
["https://place-api.map.kakao.com/places/panel3/1001", makeResponse(anchorPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/2001", makeResponse(openBarPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/2002", makeResponse(closedBarPanel, "application/json")],
["https://place-api.map.kakao.com/places/panel3/2003", makeResponse(nonBarPanel, "application/json")]
]);
const calls = [];
const 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;
};
const result = await searchNearbyBarsByLocationQuery("서울역", {
limit: 5,
panelLimit: 3,
fetchImpl
});
assert.equal(result.anchor.name, "서울역");
assert.equal(result.anchor.category, "기차역");
assert.equal(result.anchorCandidates[0].id, "1001");
assert.equal(result.meta.totalSearchResults, 3);
assert.equal(result.meta.openNowCount, 1);
assert.equal(result.items.length, 2);
assert.deepEqual(
result.items.map((item) => [item.name, item.isOpenNow]),
[["데이브루펍", true], ["밤산책와인바", false]]
);
assert.ok(!result.items.some((item) => item.name === "서울역국밥"));
assert.ok(calls.some((url) => url.endsWith("/places/panel3/2001")));
});
function makeResponse(body, contentType) {
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status: 200,
headers: {
"content-type": contentType
}
});
}

View file

@ -552,6 +552,7 @@ test("root pack:dry-run script covers all publishable workspaces", () => {
assert.match(packageJson.scripts["pack:dry-run"], /workspace k-lotto/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace daiso-product-search/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace blue-ribbon-nearby/);
assert.match(packageJson.scripts["pack:dry-run"], /workspace kakao-bar-nearby/);
});
test("repository docs advertise the blue-ribbon-nearby skill across the documented surfaces", () => {
@ -605,6 +606,57 @@ test("blue-ribbon-nearby package README stays aligned with the location-first an
assert.match(packageReadme, /searchNearbyByLocationQuery/);
});
test("repository docs advertise the kakao-bar-nearby skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const sources = read(path.join("docs", "sources.md"));
const featureDocPath = path.join(repoRoot, "docs", "features", "kakao-bar-nearby.md");
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakao-bar-nearby.md to exist");
assert.match(readme, /\| 근처 술집 조회 \|/);
assert.match(readme, /\[근처 술집 조회 가이드\]\(docs\/features\/kakao-bar-nearby\.md\)/);
assert.match(install, /--skill kakao-bar-nearby/);
assert.match(roadmap, /근처 술집 조회 스킬 출시/);
assert.match(sources, /카카오맵 모바일 검색: https:\/\/m\.map\.kakao\.com\/actions\/searchView/);
assert.match(sources, /카카오맵 장소 패널 JSON: https:\/\/place-api\.map\.kakao\.com\/places\/panel3\//);
});
test("kakao-bar-nearby skill documents location-first Kakao Map search with open-now/menu/seating hints", () => {
const skillPath = path.join(repoRoot, "kakao-bar-nearby", "SKILL.md");
assert.ok(fs.existsSync(skillPath), "expected kakao-bar-nearby/SKILL.md to exist");
const skill = read(path.join("kakao-bar-nearby", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "kakao-bar-nearby.md"));
assert.match(skill, /^name: kakao-bar-nearby$/m);
for (const doc of [skill, featureDoc]) {
assert.match(doc, /현재 위치/);
assert.match(doc, /서울역|강남|사당|논현/);
assert.match(doc, /https:\/\/m\.map\.kakao\.com\/actions\/searchView/);
assert.match(doc, /https:\/\/place-api\.map\.kakao\.com\/places\/panel3\//);
assert.match(doc, /영업 중|영업전|영업 상태/);
assert.match(doc, /메뉴/);
assert.match(doc, /단체석|좌석 옵션|인원 수용/);
assert.match(doc, /전화번호/);
assert.match(doc, /kakao-bar-nearby|근처 술집 조회/u);
}
});
test("kakao-bar-nearby package README stays aligned with the Kakao Map live lookup flow", () => {
const packageReadme = read(path.join("packages", "kakao-bar-nearby", "README.md"));
assert.match(packageReadme, /현재 위치를 먼저 물어본다/u);
assert.match(packageReadme, /서울역 술집/);
assert.match(packageReadme, /https:\/\/m\.map\.kakao\.com\/actions\/searchView/);
assert.match(packageReadme, /https:\/\/place-api\.map\.kakao\.com\/places\/panel3\//);
assert.match(packageReadme, /searchNearbyBarsByLocationQuery/);
});
test("repository docs advertise the fine-dust-location skill across the documented surfaces", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));