@nomadamas/k-skill 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/k-skill.js +11 -0
- package/package.json +1 -1
- package/skills/bccard-eatpl-search/instruction.md +144 -0
- package/skills/bccard-eatpl-search/references/DISCLAIMER.md +11 -0
- package/skills/bccard-eatpl-search/references/TRADEMARK-LEGAL-STATEMENT.md +7 -0
- package/skills/bccard-eatpl-search/scripts/kakao-place-rating.mjs +254 -0
- package/skills/bccard-eatpl-search/skill.json +6 -0
- package/skills/korean-privacy-terms/instruction.md +1 -0
- package/skills/korean-privacy-terms/scripts/install.sh +17 -0
- package/skills/religious-facility-search/instruction.md +195 -0
- package/skills/religious-facility-search/scripts/religious_facility_search.py +277 -0
- package/skills/religious-facility-search/skill.json +9 -0
- package/skills/sharenuri-facility-search/instruction.md +127 -0
- package/skills/sharenuri-facility-search/references/DISCLAIMER.md +23 -0
- package/skills/sharenuri-facility-search/references/TRADEMARK-LEGAL-STATEMENT.md +9 -0
- package/skills/sharenuri-facility-search/scripts/sharenuri_search.py +344 -0
- package/skills/sharenuri-facility-search/skill.json +8 -0
- package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
package/bin/k-skill.js
CHANGED
|
@@ -10,6 +10,7 @@ const {
|
|
|
10
10
|
} = require("../src/assemble");
|
|
11
11
|
const { runBundledScript } = require("../src/execute");
|
|
12
12
|
const { detectRuntime } = require("../src/detect");
|
|
13
|
+
const { version } = require("../package.json");
|
|
13
14
|
|
|
14
15
|
function usage() {
|
|
15
16
|
return [
|
|
@@ -23,6 +24,11 @@ function usage() {
|
|
|
23
24
|
" path <skill> <file> Print the absolute path of a bundled asset",
|
|
24
25
|
" files <skill> Print local paths of the skill's bundled helper files",
|
|
25
26
|
" list List bundled skills",
|
|
27
|
+
" version Print the installed CLI version",
|
|
28
|
+
"",
|
|
29
|
+
"Options:",
|
|
30
|
+
" -h, --help Show this help",
|
|
31
|
+
" -v, -V, --version Print the installed CLI version",
|
|
26
32
|
"",
|
|
27
33
|
"Runtime detection: DOLSHOI_ACTION_BROKER_URL enables Dolshoi mode;",
|
|
28
34
|
"CLOAKBROWSER_PEEK_TOKEN marks CloakBrowser availability.",
|
|
@@ -37,6 +43,11 @@ function main() {
|
|
|
37
43
|
return 0;
|
|
38
44
|
}
|
|
39
45
|
|
|
46
|
+
if (command === "version" || command === "--version" || command === "-V" || command === "-v") {
|
|
47
|
+
console.log(version);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
|
|
40
51
|
if (command === "list") {
|
|
41
52
|
for (const name of listSkills()) console.log(name);
|
|
42
53
|
return 0;
|
package/package.json
CHANGED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# BC카드 eat.pl 맛집 검색
|
|
2
|
+
|
|
3
|
+
## What this skill does
|
|
4
|
+
|
|
5
|
+
한국에서 맛집, 음식점, 식당, 밥집, 카페, 고깃집, 회식 장소를 찾을 때 쓴다. 사용자가 말한 장소를 먼저 `kakao-map`으로 정확한 도로명주소로 해석한 뒤, 그 주소를 중심으로 BC카드 eat.pl 잇플의 가맹점 검색을 조회한다. 결과는 반환 매출 랭킹 상위 순서를 보존하며, 사용자의 장르·동네·외국인 방문·회식·영업시간·혼잡 시간 조건으로 좁혀 표로 제시한다.
|
|
6
|
+
|
|
7
|
+
추천은 별점 순위가 아니라 최근 1개월 BC카드 결제 데이터 기반 매출 순이다. 매출액(원)은 없고, 대신 `allSaleRnk`/`ccgSaleRnk` 백분위로 **얼마나 상위인지**를 반드시 숫자로 보여 준다. `srtTime`/`endTime`은 **영업 예상 시간**으로 정리한다. 모든 행은 가맹점명 바로 오른쪽에 eat.pl 상세 링크를 붙이고, 전국·지역 매출 상위 바로 오른쪽에 카카오맵 평점을 함께 보여 준다.
|
|
8
|
+
|
|
9
|
+
## 사용자 답변 규칙 (필수)
|
|
10
|
+
|
|
11
|
+
사용자에게 보이는 답변에는 구현·연동 세부사항을 **절대 쓰지 않는다.** 결과는 맛집 추천처럼만 보여 준다.
|
|
12
|
+
|
|
13
|
+
답변에 넣지 말 것:
|
|
14
|
+
|
|
15
|
+
- endpoint, URL 경로, HTTP 메서드, curl, 프록시, 서버, 환경변수, 기관코드, 토큰, 상태코드
|
|
16
|
+
- 내부 필드명 (`location`, `merTpbuzNm`, `allSaleRnk`, `ccgSaleRnk`, `merUrl`, `frnrVsitYn`, `empyVsitYn`, `srtTime` 등)
|
|
17
|
+
- `k-skill-proxy`, 릴레이, Lightsail, gpu01, API, JSON, 캐시, 좌표 체계
|
|
18
|
+
|
|
19
|
+
표의 전국·지역 매출 상위 %, 카카오맵 평점, 영업 예상 시간, `[eat.pl 상세](url)`, 출처 한 줄은 출력 계약대로 유지한다. 실패해도 오류 코드나 엔드포인트를 말하지 말고, 그 조건으로는 매장을 찾지 못했다고만 안내한다.
|
|
20
|
+
|
|
21
|
+
## When to use
|
|
22
|
+
|
|
23
|
+
먹을 곳, 식당, 카페를 달라는 요청이면 이 스킬을 쓴다. 예:
|
|
24
|
+
|
|
25
|
+
- "맛집 추천해줘", "근처 음식점", "밥 먹을 곳", "분위기 좋은 식당"
|
|
26
|
+
- "강남역 근처 고기집 중 매출 높은 곳"
|
|
27
|
+
- "을지로입구역 주변 외국인이 많이 가는 카페"
|
|
28
|
+
- "성수동에서 회식하기 좋은 맛집"
|
|
29
|
+
- "이 동네 카페", "디저트 맛집", "회식 장소"
|
|
30
|
+
|
|
31
|
+
술집·바만 찾는 요청은 `kakao-bar-nearby`를 우선한다.
|
|
32
|
+
|
|
33
|
+
## Workflow
|
|
34
|
+
|
|
35
|
+
아래 절차는 에이전트 내부 작업이다. 사용자 답변에 그대로 옮기지 않는다.
|
|
36
|
+
|
|
37
|
+
1. 장소명이 모호하면 먼저 행정구역 또는 도로명주소를 확인한다. `kakao-map` 스킬을 우선 사용한다. 이 경로는 k-skill-proxy가 중계하는 **카카오 공식 Local REST API** (`GET /v1/kakao-map/search/keyword`, `coord2address` 등)이다. 장소 해석에 카카오맵 웹 페이지를 스크래핑하지 않는다. 카카오맵 평점만 아래 5번 단계의 번들 헬퍼가 공개 검색 페이지에 표시된 값을 읽는다. 사용자가 지정한 경우 다른 지도 스킬로 교차 확인할 수 있다.
|
|
38
|
+
2. 검색 중심은 장소의 정확한 주소에서 최소한 시·군·구와 도로명/동 단위로 정한다. eat.pl API는 텍스트 주소 부분일치 검색이며 반경(meter) 검색 API가 아니므로, API 결과를 실제 근처로 단정하지 말고 지도 스킬로 위치를 교차 확인한다.
|
|
39
|
+
3. 다음 프록시 endpoint를 GET으로 호출한다.
|
|
40
|
+
|
|
41
|
+
```bash
|
|
42
|
+
curl -fsS --get "${KSKILL_PROXY_BASE_URL:-https://k-skill-proxy.nomadamas.org}/v1/bccard-eatpl/search" \
|
|
43
|
+
--data-urlencode 'location=서울 중구 을지로30길 20' \
|
|
44
|
+
--data-urlencode 'merTpbuzNm=카페'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
지원 입력:
|
|
48
|
+
|
|
49
|
+
- `location` 또는 `address`: 정확한 주소/지역 문자열
|
|
50
|
+
- `merTpbuzNm`, `genre`, `business_type`: eat.pl 대분류 또는 소분류
|
|
51
|
+
- `merNm`, `merchant_name`: 가맹점명 일부
|
|
52
|
+
|
|
53
|
+
4. 최대 100건을 받은 뒤 지도 검색 결과와 주소·상호를 대조하고, 요청 조건에 맞는 매장만 남긴다. 존재하지 않는 리뷰·메뉴·주차·영업 여부를 추측하지 않는다.
|
|
54
|
+
5. 표에 넣을 각 매장의 카카오맵 평점을 번들 헬퍼로 조회한다. 가맹점명과 주소는 eat.pl 응답값을 그대로 넘긴다.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
npx -y @nomadamas/k-skill@0 exec bccard-eatpl-search scripts/kakao-place-rating.mjs -- \
|
|
58
|
+
--name '{merNm}' --address '{addr}'
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
- 출력 JSON의 `status`가 `ok`일 때만 `rating`과 `reviewCount`를 사용한다.
|
|
62
|
+
- `no_match`, `ambiguous`, `no_rating`, `error`는 모두 카카오맵 평점을 `미확인`으로 표시한다. 평점을 추측하거나 다른 지점·비슷한 상호의 평점을 대신 쓰지 않는다.
|
|
63
|
+
- 헬퍼 호출이 실패해도 표 생성은 계속한다. 평점 때문에 eat.pl 조회를 다시 시도하지 않는다.
|
|
64
|
+
|
|
65
|
+
6. 아래 **출력 계약**을 만족하는 표만 사용자에게 보여 준다. 계약을 빠뜨린 표는 미완성이다.
|
|
66
|
+
7. 반드시 `eat.pl 잇플 · BC카드 결제 데이터 기반`을 출처로 표기한다.
|
|
67
|
+
|
|
68
|
+
## 출력 계약 (필수)
|
|
69
|
+
|
|
70
|
+
표 컬럼: `가맹점명`, `eat.pl 상세 링크`, `주소`, `업종`, `전국 매출 상위`, `지역 매출 상위`, `카카오맵 평점`, `동네 맛집`, `외국인 방문`, `회식 적합`, `영업 예상 시간`, `혼잡 요일/시간`, `주요 고객층`.
|
|
71
|
+
|
|
72
|
+
### 매출이 얼마나 높은지
|
|
73
|
+
|
|
74
|
+
- 매장마다 `전국 매출 상위 {allSaleRnk}%`, `지역 매출 상위 {ccgSaleRnk}%`를 빠짐없이 숫자로 쓴다. "매출이 높다"만 쓰지 않는다. 필드 이름 자체는 사용자에게 쓰지 않는다.
|
|
75
|
+
- 백분위 값이 **낮을수록** 최근 1개월 BC카드 결제 매출 순위가 높다. `상위 0.09%`는 `상위 3%`보다 훨씬 위다.
|
|
76
|
+
- 1% 미만은 소수점을 보존한다. 예: `0.04` → `전국 매출 상위 0.04%`.
|
|
77
|
+
- 실제 매출액(원)은 제공되지 않는다. 백분위가 높낮이 정보다.
|
|
78
|
+
|
|
79
|
+
### 영업 예상 시간
|
|
80
|
+
|
|
81
|
+
- `srtTime`/`endTime`(HHMM)을 `영업 예상 시간 HH:MM–HH:MM`으로 변환한다. 예: `0702`+`2246` → `영업 예상 시간 07:02–22:46`.
|
|
82
|
+
- 종료 시각이 시작보다 이르면 자정 넘김으로 `영업 예상 시간 18:14–익일 05:56`처럼 적는다.
|
|
83
|
+
- 표 밖 한 줄로 "최근 1개월 결제 승인 시각 기반 추정이므로 공식 영업시간이 아님"을 고지한다.
|
|
84
|
+
|
|
85
|
+
### 상세 링크 강제
|
|
86
|
+
|
|
87
|
+
- **모든 행**에 응답 `merUrl`을 `가맹점명` 바로 오른쪽 컬럼의 마크다운 링크로 첨부한다: `[eat.pl 상세]({merUrl})`.
|
|
88
|
+
- 링크는 항상 가맹점명 오른쪽에 둔다. 표 맨 뒤 컬럼으로 미루지 않는다.
|
|
89
|
+
- `merUrl`이 없거나 `https://web.paybooc.ai/`로 시작하지 않으면 그 행을 출력하지 않는다.
|
|
90
|
+
- 링크가 빠진 목록·표는 완성 결과가 아니다. URL 경로를 조합하거나 추측하지 않는다.
|
|
91
|
+
|
|
92
|
+
### 카카오맵 평점
|
|
93
|
+
|
|
94
|
+
- `카카오맵 평점` 컬럼은 `지역 매출 상위` 바로 오른쪽에 둔다.
|
|
95
|
+
- 헬퍼가 `status: "ok"`를 반환한 행만 `{rating} (리뷰 {reviewCount})`로 쓴다. 예: `2.6 (리뷰 334)`.
|
|
96
|
+
- 그 외 상태(`no_match`, `ambiguous`, `no_rating`, `error`)와 헬퍼 실패는 `미확인`으로 쓴다. 평점이 없다고 0점이나 임의 값을 넣지 않는다.
|
|
97
|
+
- 이 평점은 카카오맵 이용자 평균 평점이며 BC카드 결제 데이터가 아니다. 사용자가 물으면 그렇게 설명한다.
|
|
98
|
+
|
|
99
|
+
## Credentials and proxy operations
|
|
100
|
+
|
|
101
|
+
사용자에게 credentials를 묻거나 출력하지 않는다. 운영 환경에 다음 값을 secret으로 주입한다.
|
|
102
|
+
|
|
103
|
+
- `BCCARD_EATPL_INST_NM`: 제휴 시 발급된 기관코드
|
|
104
|
+
- `BCCARD_EATPL_API_BASE_URL`: 필수값, 기본값 없음 (미설정 시 503 `upstream_not_configured`). 운영 URL은 `https://api.paybooc.ai/api/mer`
|
|
105
|
+
- `BCCARD_EATPL_RELAY_URL`: 선택값, 고정 outbound IP relay URL. 설정하면 proxy는 이 URL만 호출하고, 토큰 없이는 호출하지 않는다
|
|
106
|
+
- `BCCARD_EATPL_RELAY_TOKEN`: 선택값, relay `Authorization: Bearer` 시크릿. gpu01 `k-skill-proxy` `.env`와 Lightsail relay env에만 저장한다. 사용자·클라이언트는 Lightsail을 직접 호출하지 않는다
|
|
107
|
+
- `BCCARD_EATPL_API_TIMEOUT_MS`: 선택값, 기본 20000
|
|
108
|
+
|
|
109
|
+
`trnsTrceNo`는 사용자가 입력하지 않는다. proxy가 기관코드 + 한국시간 기준 `YYYYMMDD` + 10자리 순번으로 매 요청 생성한다.
|
|
110
|
+
|
|
111
|
+
## Important interpretation
|
|
112
|
+
|
|
113
|
+
- 결과 자체가 매출 랭킹 상위 순이며, 별도 매출액은 제공되지 않는다. 높낮이는 전국·지역 백분위로만 말한다.
|
|
114
|
+
- `allSaleRnk`/`ccgSaleRnk`는 퍼센트 백분위이며 낮을수록 상위다. 사용자 답변에는 필드명 대신 `전국 매출 상위 N%`로만 쓴다.
|
|
115
|
+
- `srtTime`/`endTime`은 최근 1개월 결제 승인 시각 기반 **영업 예상 시간**이지 영업시간 공시가 아니다.
|
|
116
|
+
- 실시간 영업 여부는 eat.pl 상세 페이지를 확인해야 하며, 조회 시점 관측과 다를 수 있다.
|
|
117
|
+
- 전화번호는 유효하지 않을 수 있다.
|
|
118
|
+
- 좌표는 TM128이므로 WGS84 지도 좌표로 직접 해석하지 않는다. 이 사실을 사용자에게 설명하지 않는다.
|
|
119
|
+
|
|
120
|
+
## Failure modes
|
|
121
|
+
|
|
122
|
+
내부 처리 기준이다. 사용자에게 코드명을 말하지 않는다.
|
|
123
|
+
|
|
124
|
+
- `400 bad_request`: 주소·장르·가맹점명 중 하나도 없거나 입력이 잘못됨
|
|
125
|
+
- `503 upstream_not_configured`: `BCCARD_EATPL_INST_NM` 미설정
|
|
126
|
+
- `403/401 upstream_error`: IP 등록, 기관코드, 제휴 권한 또는 upstream 인증 문제
|
|
127
|
+
- `502 upstream_error`: eat.pl 연결·서버 오류
|
|
128
|
+
- `502 upstream_semantic_error`: `rspCode`가 성공이 아니거나 응답 구조가 명세와 다름
|
|
129
|
+
- 개발 서버 연결 타임아웃: 개발 접근 정보 또는 네트워크/IP allowlist를 BC카드 측에 확인
|
|
130
|
+
- 빈 결과: 주소 텍스트가 너무 좁거나 업종 표기가 맞지 않을 수 있으므로 임의 재시도하지 말고 주소 범위를 넓히거나 명세의 업종값을 확인
|
|
131
|
+
- 카카오맵 평점 조회 실패·모호·무응답: 해당 매장만 `미확인`으로 표시하고 표 생성을 계속한다. 평점 조회 실패로 전체 검색을 실패 처리하지 않는다
|
|
132
|
+
|
|
133
|
+
## Legal and partnership notice
|
|
134
|
+
|
|
135
|
+
이 스킬은 BC카드와의 공식 제휴 연동을 위해 제공되는 k-skill 구현물이라는 전제에서 사용한다. 다만 이 저장소와 k-skill 자체가 BC카드의 공식 제품·상표·지원 채널이라는 의미는 아니며, 실제 운영 전에는 BC카드의 제휴 승인·기관코드 발급·IP 등록·노출 문구 검수를 완료해야 한다. 출처는 항상 `eat.pl 잇플 · BC카드 결제 데이터 기반`으로 표시한다.
|
|
136
|
+
|
|
137
|
+
## Done when
|
|
138
|
+
|
|
139
|
+
- 장소를 `kakao-map`(k-skill-proxy 카카오 공식 Local API)으로 주소로 해석했다.
|
|
140
|
+
- eat.pl 결과를 최대 100건까지 받아 지도 정보와 대조했다.
|
|
141
|
+
- 사용자의 조건으로 필터링한 표에 전국·지역 매출 상위 %, 영업 예상 시간이 **모든 행**에 있고, eat.pl 상세 링크는 가맹점명 오른쪽, 카카오맵 평점(또는 `미확인`)은 지역 매출 상위 오른쪽에 **모든 행**에 있다.
|
|
142
|
+
- 매출 순 추천과 최근 1개월 결제 데이터 기준임을 설명했다.
|
|
143
|
+
- 출처·제휴 전제·데이터 한계를 함께 고지했다.
|
|
144
|
+
- 사용자 답변에 endpoint, 프록시, 환경변수, 내부 필드명이 없다.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# DISCLAIMER — `bccard-eatpl-search`
|
|
2
|
+
|
|
3
|
+
이 스킬은 BC카드와 공식 제휴 연동을 전제로 설계된 구현물이다. 그러나 이 저장소, k-skill, 또는 이 스킬 자체가 BC카드·eat.pl 잇플의 공식 기능·공식 제품·공식 지원 채널·상표권자라는 뜻은 아니다. 서비스의 공식 제휴·협업 범위는 별도 계약으로 확인하며, 운영 노출 전에는 BC카드의 제휴 승인, 기관코드 발급, 호출 IP 등록, 개발·운영 endpoint 승인 및 표시 문구 검수를 완료해야 한다. 명칭은 기능 설명·조회 대상·링크 식별에만 사용한다.
|
|
4
|
+
|
|
5
|
+
결과는 최근 1개월 BC카드 결제 데이터에서 집계된 가맹점 단위 정보다. 실제 매출액, 고객 개인 정보, 리뷰 평점, 현재 영업을 보증하지 않는다. `merUrl` 상세 페이지의 실시간 결제 기반 표시는 API의 분석 기반 영업 관측과 다를 수 있다.
|
|
6
|
+
|
|
7
|
+
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)과 대법원 2022. 5. 12. 선고 [2021도1533 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=221765)의 취지를 포함해, [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조), [정보통신망법 제48조](https://www.law.go.kr/법령/정보통신망이용촉진및정보보호등에관한법률/제48조), [저작권법 제93조](https://www.law.go.kr/법령/저작권법/제93조), [형법 제314조 제2항](https://www.law.go.kr/법령/형법/제314조) 등 관련 법령과 계약 조건을 별도로 준수해야 한다.
|
|
8
|
+
|
|
9
|
+
이 스킬은 공개적으로 허용된 공식 API와 지도 서비스만 사용한다. CAPTCHA, 인증, IP 제한, 접근통제, quota 또는 서비스 운영 정책을 우회하지 않는다. 영업 방해나 서비스 장애를 일으키는 조직적·대량 크롤링, 재배포·데이터베이스 구축이 아니라 개인의 장소 선택을 위한 제한된 조회에 사용한다.
|
|
10
|
+
|
|
11
|
+
이 문서는 법률 자문이나 BC카드의 최종 승인을 대신하지 않는다.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# 상표 사용 법적 고지 — `bccard-eatpl-search`
|
|
2
|
+
|
|
3
|
+
이 스킬에서 `BC카드`, `eat.pl`, `잇플`, `paybooc.ai` 명칭은 연동 대상 서비스와 응답 상세 링크를 식별하기 위해 필요한 범위에서 사용한다. k-skill의 출처를 해당 권리자와 혼동시키려는 사용이 아니다. 실제 공식 제휴·후원·승인 범위는 별도의 계약과 검수로 확인해야 한다.
|
|
4
|
+
|
|
5
|
+
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)은 타인의 표장을 출처표시가 아니라 상품 기능 또는 적용 기종을 밝히기 위해 사용하고 상표 사용으로 인식될 수 없는 경우 침해가 아니라고 판시했다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)에 따른 출처 혼동 판단은 표시 태양과 사용 경위에 따라 별도로 검토해야 한다.
|
|
6
|
+
|
|
7
|
+
따라서 명칭은 기능 설명과 링크 식별에 필요한 평문으로만 사용하고, 별도 근거 없이 로고·인증·파트너 표현을 사용하지 않는다. 이 문서는 법률 자문이나 BC카드의 최종 승인을 대신하지 않는다.
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Resolves a merchant's Kakao Map aggregate rating for the bccard-eatpl-search table.
|
|
3
|
+
//
|
|
4
|
+
// Step 1 uses the official Kakao Local keyword search through k-skill-proxy
|
|
5
|
+
// (the REST API key lives server-side) to resolve the merchant to a stable
|
|
6
|
+
// Kakao place id. Step 2 reads the public Kakao Map mobile search page and
|
|
7
|
+
// takes the displayed aggregate rating of that exact place id. Any doubt —
|
|
8
|
+
// no candidate, several candidates, missing rating, unreachable upstream —
|
|
9
|
+
// yields a null rating with an explicit status instead of a guess.
|
|
10
|
+
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
|
|
14
|
+
export const DEFAULT_PROXY_BASE_URL = "https://k-skill-proxy.nomadamas.org";
|
|
15
|
+
export const KAKAO_MOBILE_SEARCH_URL = "https://m.map.kakao.com/actions/searchView";
|
|
16
|
+
export const DEFAULT_TIMEOUT_MS = 10000;
|
|
17
|
+
|
|
18
|
+
const MOBILE_USER_AGENT =
|
|
19
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 " +
|
|
20
|
+
"(KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1";
|
|
21
|
+
|
|
22
|
+
const ADDRESS_SIGNIFICANT_SUFFIX = /[구군동읍면리로길]$/;
|
|
23
|
+
|
|
24
|
+
function baseResult(query) {
|
|
25
|
+
return {
|
|
26
|
+
query,
|
|
27
|
+
status: "error",
|
|
28
|
+
kakaoPlaceId: null,
|
|
29
|
+
kakaoPlaceName: null,
|
|
30
|
+
kakaoPlaceUrl: null,
|
|
31
|
+
rating: null,
|
|
32
|
+
reviewCount: null
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function tokenSet(text) {
|
|
37
|
+
if (!text) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
return String(text)
|
|
41
|
+
.split(/\s+/)
|
|
42
|
+
.map((token) => token.replace(/[^0-9A-Za-z가-힣]/g, "").toLowerCase())
|
|
43
|
+
.filter((token) => token.length > 0 && /[a-z가-힣]/.test(token));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function significantAddressTokens(address) {
|
|
47
|
+
return tokenSet(address).filter((token) => ADDRESS_SIGNIFICANT_SUFFIX.test(token));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sharesSignificantAddressToken(left, right) {
|
|
51
|
+
const rightTokens = new Set(significantAddressTokens(right));
|
|
52
|
+
return significantAddressTokens(left).some((token) => rightTokens.has(token));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function candidateAddresses(document) {
|
|
56
|
+
return [document.road_address_name, document.address_name].filter(Boolean);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isAddressCompatible(document, address) {
|
|
60
|
+
return candidateAddresses(document).some((candidateAddress) =>
|
|
61
|
+
sharesSignificantAddressToken(candidateAddress, address)
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function matchesName(document, needleTokens) {
|
|
66
|
+
const nameTokens = new Set(tokenSet(document.place_name));
|
|
67
|
+
return needleTokens.every((needle) => nameTokens.has(needle));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function pickKakaoCandidate(documents, { name, address }) {
|
|
71
|
+
const needleTokens = tokenSet(name);
|
|
72
|
+
if (needleTokens.length === 0 || !Array.isArray(documents) || documents.length === 0) {
|
|
73
|
+
return { status: "no_match" };
|
|
74
|
+
}
|
|
75
|
+
const nameMatches = documents.filter((document) => matchesName(document, needleTokens));
|
|
76
|
+
if (nameMatches.length === 0) {
|
|
77
|
+
return { status: "no_match" };
|
|
78
|
+
}
|
|
79
|
+
const hasAddressConstraint = significantAddressTokens(address).length > 0;
|
|
80
|
+
if (!hasAddressConstraint) {
|
|
81
|
+
return nameMatches.length === 1
|
|
82
|
+
? { status: "ok", document: nameMatches[0] }
|
|
83
|
+
: { status: "ambiguous", candidates: nameMatches.length };
|
|
84
|
+
}
|
|
85
|
+
const compatible = nameMatches.filter((document) => isAddressCompatible(document, address));
|
|
86
|
+
if (compatible.length === 1) {
|
|
87
|
+
return { status: "ok", document: compatible[0] };
|
|
88
|
+
}
|
|
89
|
+
return compatible.length === 0 ? { status: "no_match" } : { status: "ambiguous", candidates: compatible.length };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function decodeHtmlEntities(value) {
|
|
93
|
+
return value
|
|
94
|
+
.replace(/"/g, '"')
|
|
95
|
+
.replace(/'/g, "'")
|
|
96
|
+
.replace(/</g, "<")
|
|
97
|
+
.replace(/>/g, ">")
|
|
98
|
+
.replace(/&/g, "&");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function parseSearchViewItems(html) {
|
|
102
|
+
if (typeof html !== "string" || html.length === 0) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
const chunks = html.split('<li class="search_item').slice(1);
|
|
106
|
+
const items = [];
|
|
107
|
+
for (const chunk of chunks) {
|
|
108
|
+
const idMatch = chunk.match(/data-id="(\d+)"/);
|
|
109
|
+
const titleMatch = chunk.match(/data-title="([^"]*)"/);
|
|
110
|
+
if (!idMatch || !titleMatch) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const ratingIndex = chunk.search(/num_rate">[0-9.]+</);
|
|
114
|
+
let rating = null;
|
|
115
|
+
let reviewCount = null;
|
|
116
|
+
if (ratingIndex !== -1) {
|
|
117
|
+
rating = Number.parseFloat(chunk.match(/num_rate">([0-9.]+)</)[1]);
|
|
118
|
+
const countMatch = chunk.slice(ratingIndex).match(/txt_num">\((\d+)\)/);
|
|
119
|
+
reviewCount = countMatch ? Number.parseInt(countMatch[1], 10) : null;
|
|
120
|
+
}
|
|
121
|
+
items.push({
|
|
122
|
+
id: idMatch[1],
|
|
123
|
+
title: decodeHtmlEntities(titleMatch[1]),
|
|
124
|
+
rating,
|
|
125
|
+
reviewCount
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
return items;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function fetchText(url, { fetchImpl, timeoutMs, headers }) {
|
|
132
|
+
const controller = new AbortController();
|
|
133
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
134
|
+
try {
|
|
135
|
+
const response = await fetchImpl(url, { headers, signal: controller.signal, redirect: "follow" });
|
|
136
|
+
if (!response.ok) {
|
|
137
|
+
throw new Error(`HTTP ${response.status}`);
|
|
138
|
+
}
|
|
139
|
+
return await response.text();
|
|
140
|
+
} finally {
|
|
141
|
+
clearTimeout(timer);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export async function lookupKakaoPlaceRating({
|
|
146
|
+
name,
|
|
147
|
+
address = "",
|
|
148
|
+
proxyBaseUrl = DEFAULT_PROXY_BASE_URL,
|
|
149
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
150
|
+
fetchImpl = fetch
|
|
151
|
+
}) {
|
|
152
|
+
const result = baseResult({ name, address });
|
|
153
|
+
if (!name || !String(name).trim()) {
|
|
154
|
+
result.status = "no_match";
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let documents;
|
|
159
|
+
try {
|
|
160
|
+
const searchUrl =
|
|
161
|
+
`${proxyBaseUrl}/v1/kakao-map/search/keyword?` +
|
|
162
|
+
new URLSearchParams({ query: String(name).trim(), size: "15" });
|
|
163
|
+
const payload = JSON.parse(await fetchText(searchUrl, { fetchImpl, timeoutMs, headers: {} }));
|
|
164
|
+
documents = Array.isArray(payload.documents) ? payload.documents : null;
|
|
165
|
+
if (documents === null) {
|
|
166
|
+
throw new Error("missing documents array");
|
|
167
|
+
}
|
|
168
|
+
} catch (error) {
|
|
169
|
+
result.status = "error";
|
|
170
|
+
result.error = `kakao local search failed: ${error.message}`;
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const picked = pickKakaoCandidate(documents, { name, address });
|
|
175
|
+
if (picked.status !== "ok") {
|
|
176
|
+
result.status = picked.status;
|
|
177
|
+
return result;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const place = picked.document;
|
|
181
|
+
result.kakaoPlaceId = place.id;
|
|
182
|
+
result.kakaoPlaceName = place.place_name;
|
|
183
|
+
result.kakaoPlaceUrl = place.place_url || `http://place.map.kakao.com/${place.id}`;
|
|
184
|
+
|
|
185
|
+
let html;
|
|
186
|
+
try {
|
|
187
|
+
const searchViewUrl =
|
|
188
|
+
`${KAKAO_MOBILE_SEARCH_URL}?` + new URLSearchParams({ q: String(name).trim() });
|
|
189
|
+
html = await fetchText(searchViewUrl, {
|
|
190
|
+
fetchImpl,
|
|
191
|
+
timeoutMs,
|
|
192
|
+
headers: { "user-agent": MOBILE_USER_AGENT, "accept-language": "ko-KR,ko;q=0.9" }
|
|
193
|
+
});
|
|
194
|
+
} catch (error) {
|
|
195
|
+
result.status = "error";
|
|
196
|
+
result.error = `kakao map rating page failed: ${error.message}`;
|
|
197
|
+
return result;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const item = parseSearchViewItems(html).find((candidate) => candidate.id === place.id);
|
|
201
|
+
if (!item || item.rating === null) {
|
|
202
|
+
result.status = "no_rating";
|
|
203
|
+
return result;
|
|
204
|
+
}
|
|
205
|
+
result.status = "ok";
|
|
206
|
+
result.rating = item.rating;
|
|
207
|
+
result.reviewCount = item.reviewCount;
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function printUsage() {
|
|
212
|
+
console.error(
|
|
213
|
+
"usage: kakao-place-rating.mjs --name <가맹점명> [--address <주소>] " +
|
|
214
|
+
"[--proxy-base-url <url>] [--timeout-ms <ms>]"
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
219
|
+
let args;
|
|
220
|
+
try {
|
|
221
|
+
args = parseArgs({
|
|
222
|
+
args: argv,
|
|
223
|
+
options: {
|
|
224
|
+
name: { type: "string" },
|
|
225
|
+
address: { type: "string", default: "" },
|
|
226
|
+
"proxy-base-url": { type: "string", default: process.env.KSKILL_PROXY_BASE_URL || DEFAULT_PROXY_BASE_URL },
|
|
227
|
+
"timeout-ms": { type: "string", default: String(DEFAULT_TIMEOUT_MS) },
|
|
228
|
+
help: { type: "boolean", default: false }
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
printUsage();
|
|
233
|
+
return 2;
|
|
234
|
+
}
|
|
235
|
+
if (args.values.help || !args.values.name) {
|
|
236
|
+
printUsage();
|
|
237
|
+
return args.values.help ? 0 : 2;
|
|
238
|
+
}
|
|
239
|
+
const timeoutMs = Number.parseInt(args.values["timeout-ms"], 10);
|
|
240
|
+
const result = await lookupKakaoPlaceRating({
|
|
241
|
+
name: args.values.name,
|
|
242
|
+
address: args.values.address,
|
|
243
|
+
proxyBaseUrl: args.values["proxy-base-url"],
|
|
244
|
+
timeoutMs: Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_TIMEOUT_MS
|
|
245
|
+
});
|
|
246
|
+
process.stdout.write(`${JSON.stringify(result)}\n`);
|
|
247
|
+
return 0;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
251
|
+
main().then((code) => {
|
|
252
|
+
process.exitCode = code;
|
|
253
|
+
});
|
|
254
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "bccard-eatpl-search",
|
|
3
|
+
"description": "대한민국 맛집, 음식점, 식당, 밥집, 카페, 커피, 디저트, 고깃집, 회식 장소를 지역·역·동네 기준으로 찾아 준다. Use when the user asks for 맛집, 음식점, 좋은 식당, 근처 카페, 밥 먹을 곳, 회식하기 좋은 곳, 분위기 좋은 맛집, or Korean restaurant and cafe recommendations. 별점이 아니라 최근 결제 데이터 기반 매출 상위로 정리한다.",
|
|
4
|
+
"profiles": ["proxy", "lookup"],
|
|
5
|
+
"frontmatter": "name: bccard-eatpl-search\ndescription: 대한민국 맛집, 음식점, 식당, 밥집, 카페, 커피, 디저트, 고깃집, 회식 장소를 지역·역·동네 기준으로 찾아 준다. Use when the user asks for 맛집, 음식점, 좋은 식당, 근처 카페, 밥 먹을 곳, 회식하기 좋은 곳, 분위기 좋은 맛집, or Korean restaurant and cafe recommendations. 별점이 아니라 최근 결제 데이터 기반 매출 상위로 정리한다.\nlicense: MIT\nmetadata:\n category: food\n locale: ko-KR\n phase: v1"
|
|
6
|
+
}
|
|
@@ -115,6 +115,7 @@ git -C ~/.agents/skills/korean-privacy-terms/upstream rev-parse HEAD
|
|
|
115
115
|
- pin SHA 가 업스트림에서 삭제/force-push 된 경우: 스크립트가 SHA mismatch 로 비정상 종료한다. `scripts/upstream.pin` 을 최신 태그 SHA 로 bump 하고 PR 을 만든다.
|
|
116
116
|
- Next.js Pages Router 단독 프로젝트: 업스트림이 실행을 중단한다. 사용자에게 App Router 전환이 선행 조건임을 안내한다.
|
|
117
117
|
- 법률 개정 드리프트: 업스트림이 CHANGELOG 로 반영 기준을 관리한다. pin 만 올리지 말고 업스트림 CHANGELOG 를 함께 확인한다.
|
|
118
|
+
- 한글 이용약관 템플릿 0바이트 (upstream, #660): pin 시점 기준 `jurisdictions/kr-pipa/terms-of-service.ko.mdx.tmpl` 과 `templates/terms-of-service.mdx.tmpl` 이 빈 파일이라 한국어(`outputLocale: ko`) 경로에서 이용약관을 생성할 수 없다. 개인정보처리방침(ko/en)과 영문 약관은 정상 생성된다. 업스트림에 템플릿이 채워지면 `scripts/upstream.pin` bump 로 해소한다. `install.sh` 가 설치 시 0바이트 템플릿을 경고로 표면화하므로 인터뷰 시작 전에 확인한다.
|
|
118
119
|
|
|
119
120
|
## Notes
|
|
120
121
|
|
|
@@ -66,6 +66,23 @@ if [[ "${HEAD_SHA}" != "${UPSTREAM_SHA}" ]]; then
|
|
|
66
66
|
exit 1
|
|
67
67
|
fi
|
|
68
68
|
|
|
69
|
+
# 0바이트 템플릿 검증 (#660). 업스트림 파일이 빈 채 커밋돼 있으면 해당 경로의
|
|
70
|
+
# 문서 생성이 조용히 실패하므로 설치 시점에 경고로 표면화한다. 다른 경로의
|
|
71
|
+
# 문서는 정상 생성되므로 경고만 하고 설치는 계속한다.
|
|
72
|
+
EMPTY_TEMPLATES=()
|
|
73
|
+
while IFS= read -r -d '' EMPTY_FILE; do
|
|
74
|
+
EMPTY_TEMPLATES+=("${EMPTY_FILE#"${CLONE_DIR}/"}")
|
|
75
|
+
done < <(find "${CLONE_DIR}" -type f -name '*.tmpl' -size 0 -print0)
|
|
76
|
+
|
|
77
|
+
if (( ${#EMPTY_TEMPLATES[@]} > 0 )); then
|
|
78
|
+
echo "[korean-privacy-terms] warning: pinned upstream 에 0바이트 템플릿 ${#EMPTY_TEMPLATES[@]}건이 있다." >&2
|
|
79
|
+
for EMPTY_TEMPLATE in "${EMPTY_TEMPLATES[@]}"; do
|
|
80
|
+
echo " - ${EMPTY_TEMPLATE}" >&2
|
|
81
|
+
done
|
|
82
|
+
echo " 해당 템플릿 경로의 문서는 생성되지 않는다. 나머지 경로는 정상 생성된다." >&2
|
|
83
|
+
echo " 상세와 해소 상태는 스킬 instruction.md 의 Failure modes 를 확인해달라." >&2
|
|
84
|
+
fi
|
|
85
|
+
|
|
69
86
|
# Dual-install targets. Both paths respect AGENTS.md indirection rules and do not
|
|
70
87
|
# introduce repo-local skill directories.
|
|
71
88
|
HOME_DIRS=(
|