mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
8b36634e28
commit
25b1d3b328
19 changed files with 1232 additions and 1 deletions
|
|
@ -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)
|
||||
|
|
|
|||
107
docs/features/kakao-bar-nearby.md
Normal file
107
docs/features/kakao-bar-nearby.md
Normal 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 숫자는 제공되지 않을 수 있으므로 `단체 방문 가능` 같은 근사 힌트로 안내합니다.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
- 사용자 위치 미세먼지 조회 스킬 출시
|
||||
- 우편번호 검색
|
||||
- 근처 블루리본 맛집 스킬 출시
|
||||
- 근처 술집 조회 스킬 출시
|
||||
- 택배 배송조회 스킬 출시 (CJ대한통운 / 우체국)
|
||||
- 다이소 상품 조회 스킬 출시
|
||||
|
||||
|
|
|
|||
|
|
@ -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
86
kakao-bar-nearby/SKILL.md
Normal 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개 이상 찾았거나, 찾지 못한 이유와 다음 질문을 제시했다.
|
||||
- 영업 상태/메뉴/좌석 옵션/전화번호가 포함된 요약을 보여줬다.
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
73
packages/kakao-bar-nearby/README.md
Normal file
73
packages/kakao-bar-nearby/README.md
Normal 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?)`
|
||||
33
packages/kakao-bar-nearby/package.json
Normal file
33
packages/kakao-bar-nearby/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
188
packages/kakao-bar-nearby/src/index.js
Normal file
188
packages/kakao-bar-nearby/src/index.js
Normal 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
|
||||
};
|
||||
299
packages/kakao-bar-nearby/src/parse.js
Normal file
299
packages/kakao-bar-nearby/src/parse.js
Normal 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(/&/g, "&")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/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
|
||||
};
|
||||
21
packages/kakao-bar-nearby/test/fixtures/anchor-panel.json
vendored
Normal file
21
packages/kakao-bar-nearby/test/fixtures/anchor-panel.json
vendored
Normal 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" }
|
||||
]
|
||||
}
|
||||
}
|
||||
22
packages/kakao-bar-nearby/test/fixtures/anchor-search.html
vendored
Normal file
22
packages/kakao-bar-nearby/test/fixtures/anchor-search.html
vendored
Normal 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>
|
||||
47
packages/kakao-bar-nearby/test/fixtures/bar-search.html
vendored
Normal file
47
packages/kakao-bar-nearby/test/fixtures/bar-search.html
vendored
Normal 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>
|
||||
72
packages/kakao-bar-nearby/test/fixtures/closed-bar-panel.json
vendored
Normal file
72
packages/kakao-bar-nearby/test/fixtures/closed-bar-panel.json
vendored
Normal 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": ["데이트하기좋은"]
|
||||
}
|
||||
}
|
||||
28
packages/kakao-bar-nearby/test/fixtures/non-bar-panel.json
vendored
Normal file
28
packages/kakao-bar-nearby/test/fixtures/non-bar-panel.json
vendored
Normal 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 영업종료"
|
||||
}
|
||||
}
|
||||
}
|
||||
80
packages/kakao-bar-nearby/test/fixtures/open-bar-panel.json
vendored
Normal file
80
packages/kakao-bar-nearby/test/fixtures/open-bar-panel.json
vendored
Normal 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": ["술한잔하기좋은"]
|
||||
}
|
||||
}
|
||||
117
packages/kakao-bar-nearby/test/index.test.js
Normal file
117
packages/kakao-bar-nearby/test/index.test.js
Normal 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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue