Expose official nearby fuel prices for location-based gas station lookups

Add the first cheap-gas-nearby skill/package pair so nearby gas-price
queries can resolve a user-supplied location, translate it into Opinet's
KATEC search contract, and return the cheapest nearby stations with
address and facility detail. The docs and setup surfaces now advertise
the new skill and its Opinet API key requirement.

Constraint: Nearby fuel prices must come from the official KNOC Opinet API when available
Constraint: No new external dependencies were allowed for coordinate conversion or location resolution
Rejected: Map-only gas price scraping | official Opinet Open API exists and is the preferred source
Rejected: Require lat/lng input only | poorer UX than supporting landmark/station queries through anchor resolution
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep `OPINET_API_KEY` as the only supported official-price credential unless the repo adopts an Opinet proxy later
Tested: npm run ci; node --test packages/cheap-gas-nearby/test/index.test.js; offline fixture smoke via searchCheapGasStationsByLocationQuery('서울역', ...)
Not-tested: Live Opinet API call with a real `OPINET_API_KEY` (no non-placeholder key was configured locally)
Related: #54
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-05 19:46:09 +09:00
commit 397d0eea84
24 changed files with 1418 additions and 9 deletions

View file

@ -0,0 +1,5 @@
---
"cheap-gas-nearby": minor
---
Publish the first official Opinet-powered nearby cheapest gas station lookup package and skill docs.

View file

@ -26,6 +26,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 한국 법령 검색 | `korean-law-mcp` 우선 + 장애 시 `법망` fallback으로 법령/조문/판례/유권해석 조회 | 로컬 CLI/MCP면 `LAW_OC` 필요, remote endpoint/법망 fallback은 불필요 | [한국 법령 검색 가이드](docs/features/korean-law-search.md) |
| 한국 부동산 실거래가 조회 | upstream `real-estate-mcp`로 아파트/오피스텔/빌라/단독주택 실거래가·전월세·지역코드 조회, hosted endpoint가 없으면 self-host + Cloudflare Tunnel + launchd 운영 | 로컬/stdio/self-host면 `DATA_GO_KR_API_KEY` 필요 | [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md) |
| 조선왕조실록 검색 | 공식 조선왕조실록 사이트에서 키워드 검색 후 왕별/연도별 필터와 기사 excerpt 조회 | 불필요 | [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md) |
| 근처 가장 싼 주유소 찾기 | 현재 위치를 먼저 확인한 뒤 Kakao Map anchor + Opinet 공식 API로 근처 최저가 주유소 조회 | `OPINET_API_KEY` 필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| KBO 경기 결과 조회 | 날짜별 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| K리그 경기 결과 조회 | 날짜별 K리그1/K리그2 경기 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [K리그 결과 가이드](docs/features/kleague-results.md) |
| 토스증권 조회 | `tossctl` 기반 계좌 요약, 포트폴리오, 시세, 주문내역, 관심종목 조회 | 필요 | [토스증권 조회 가이드](docs/features/toss-securities.md) |
@ -71,6 +72,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [한국 법령 검색 가이드](docs/features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](docs/features/real-estate-search.md)
- [조선왕조실록 검색 가이드](docs/features/joseon-sillok-search.md)
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [K리그 경기 결과 조회](docs/features/kleague-results.md)
- [토스증권 조회 가이드](docs/features/toss-securities.md)

121
cheap-gas-nearby/SKILL.md Normal file
View file

@ -0,0 +1,121 @@
---
name: cheap-gas-nearby
description: Use when the user asks for nearby cheapest gas stations or 근처 가장 싼 주유소. Always ask the user's current location first, then use Kakao Map anchor resolution plus official Opinet fuel-price APIs.
license: MIT
metadata:
category: transport
locale: ko-KR
phase: v1
---
# Cheap Gas Nearby
## What this skill does
유저가 알려준 현재 위치를 기준으로 **근처에서 가장 싼 주유소**를 찾아준다.
- 위치는 자동으로 추정하지 않는다.
- **반드시 먼저 현재 위치를 질문**한다.
- 가격 데이터는 한국석유공사 **Opinet 공식 API**를 우선 사용한다.
- 동네/역명/랜드마크 입력은 Kakao Map anchor 검색으로 좌표를 잡은 뒤 Opinet nearby 검색으로 연결한다.
- 기본 제품은 **휘발유(B027)** 이고, 유저가 경유라고 명시하면 **경유(D047)** 로 바꾼다.
## When to use
- "근처 가장 싼 주유소 찾아줘"
- "서울역 근처 휘발유 제일 싼 데 어디야?"
- "강남에서 경유 싼 주유소 몇 군데만 보여줘"
- "지금 여기 근처 셀프주유소 중 싼 순으로 알려줘"
## Mandatory first question
위치 정보 없이 바로 검색하지 말고 반드시 먼저 물어본다.
- 권장 질문: `현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처에서 가장 싼 주유소를 찾아볼게요.`
- 제품이 불명확하면: `휘발유 기준으로 볼까요, 경유 기준으로 볼까요? 따로 말씀 없으면 휘발유로 찾을게요.`
- 위치가 애매하면: `가까운 역명이나 동 이름으로 한 번만 더 알려주세요.`
## Required secret
- `OPINET_API_KEY`
이 스킬은 **공식 Opinet Open API 인증키**가 있어야 정확한 nearby 가격 조회를 할 수 있다.
`OPINET_API_KEY` 가 비어 있으면 우회하지 말고 필요한 값 이름을 그대로 알려준다.
## Official Opinet surfaces
- 오픈 API 안내: `https://www.opinet.co.kr/user/custapi/openApiInfo.do`
- 반경 내 주유소: `https://www.opinet.co.kr/api/aroundAll.do`
- 주유소 상세정보(ID): `https://www.opinet.co.kr/api/detailById.do`
- 지역코드: `https://www.opinet.co.kr/api/areaCode.do`
반경 검색 핵심 파라미터:
- `x`, `y`: 기준 위치 **KATEC** 좌표
- `radius`: 반경(m, 최대 5000)
- `prodcd`: `B027`(휘발유), `D047`(경유), `B034`(고급휘발유), `C004`(등유), `K015`(LPG)
- `sort=1`: 가격순
## Location resolution surface
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
위치 문자열은 Kakao Map으로 **anchor 좌표(WGS84)** 를 구한 뒤, 내부적으로 **WGS84 → KATEC** 변환을 적용해 Opinet `aroundAll.do` 에 넘긴다.
## Workflow
1. 유저에게 반드시 현재 위치를 묻는다.
2. 위치 문자열을 받으면 Kakao Map anchor 검색으로 좌표를 찾는다.
- 위도/경도를 직접 받으면 anchor 검색을 생략한다.
3. 좌표를 KATEC으로 변환한다.
4. Opinet `aroundAll.do``sort=1` 가격순으로 조회한다.
5. 상위 후보에 대해 `detailById.do` 를 호출해 도로명주소, 전화번호, 셀프 여부, 세차장, 경정비, 품질인증 여부를 보강한다.
6. 보통 3~5개만 짧게 정리한다.
## Responding
결과는 보통 아래 필드를 포함해 짧게 정리한다.
- 주유소명
- 가격(휘발유/경유 중 요청한 제품)
- 거리
- 주소
- 셀프 여부
- 세차장/경정비/품질인증 여부(있으면)
## Node.js example
```js
const { searchCheapGasStationsByLocationQuery } = require("cheap-gas-nearby");
async function main() {
const result = await searchCheapGasStationsByLocationQuery("서울역", {
apiKey: process.env.OPINET_API_KEY,
productCode: "B027",
radius: 1000,
limit: 3
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Done when
- 유저의 현재 위치를 먼저 확인했다.
- `OPINET_API_KEY` 준비 여부를 확인했다.
- 공식 Opinet nearby 결과를 최소 1개 이상 찾았거나, 못 찾은 이유와 다음 질문을 제시했다.
- 가격순 상위 결과를 3~5개 이내로 정리했다.
## Failure modes
- `OPINET_API_KEY` 가 없으면 공식 nearby 조회를 시작할 수 없다.
- Kakao Map anchor가 애매하면 좌표가 잘못 잡힐 수 있어 추가 위치 확인이 필요하다.
- Opinet Open API 응답이 일시적으로 비거나 갱신 중일 수 있다.

View file

@ -0,0 +1,106 @@
# 근처 가장 싼 주유소 찾기 가이드
## 이 기능으로 할 수 있는 일
- 현재 위치 기준 근처 최저가 주유소 검색
- 휘발유/경유 기준 nearby 가격 비교
- Opinet 공식 API(`aroundAll.do`, `detailById.do`) 기반 요약
- 셀프 여부, 세차장, 경정비, 품질인증 여부까지 함께 정리
## 먼저 필요한 것
- 인터넷 연결
- `node` 18+
- `OPINET_API_KEY`
- `cheap-gas-nearby` package 또는 이 저장소 전체 설치
## 가장 먼저 할 일
이 기능은 **반드시 현재 위치를 먼저 물어본 뒤** 실행합니다.
권장 질문 예시:
```text
현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처에서 가장 싼 주유소를 찾아볼게요.
```
제품을 안 알려주면 보통 **휘발유(B027)** 기준으로 시작하고, 경유가 필요하면 `D047` 로 바꿉니다.
## 입력값
- 동네/상권: `강남`, `성수동`, `판교`
- 역명/랜드마크: `서울역`, `강남역`, `코엑스`
- 좌표: `37.55472, 126.97068`
- 제품코드: `B027`(휘발유), `D047`(경유)
위치 문자열은 Kakao Map anchor 검색으로 **WGS84 좌표**를 잡고, 내부적으로 **KATEC** 으로 변환해 Opinet nearby 검색에 사용합니다.
## 공식 표면
- Opinet 오픈 API 안내: `https://www.opinet.co.kr/user/custapi/openApiInfo.do`
- 반경 내 주유소: `https://www.opinet.co.kr/api/aroundAll.do`
- 주유소 상세정보(ID): `https://www.opinet.co.kr/api/detailById.do`
- 지역코드: `https://www.opinet.co.kr/api/areaCode.do`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
Opinet nearby 검색의 핵심 파라미터:
- `x`, `y`: 기준 위치 **KATEC** 좌표
- `radius`: 반경(m, 최대 5000)
- `prodcd`: `B027`, `D047`, `B034`, `C004`, `K015`
- `sort=1`: 가격순
## 기본 흐름
1. 유저에게 현재 위치를 먼저 묻습니다.
2. 위치 문자열을 받으면 Kakao Map으로 anchor 후보를 고르고 좌표를 확보합니다.
3. 좌표를 **WGS84 → KATEC** 으로 변환합니다.
4. Opinet `aroundAll.do``sort=1` 가격순으로 조회합니다.
5. 상위 후보는 `detailById.do` 로 재조회해 주소/전화번호/편의시설을 보강합니다.
6. 가격순 상위 3~5개만 짧게 응답합니다.
## Node.js 예시
```js
const { searchCheapGasStationsByLocationQuery } = require("cheap-gas-nearby");
async function main() {
const result = await searchCheapGasStationsByLocationQuery("서울역", {
apiKey: process.env.OPINET_API_KEY,
productCode: "B027",
radius: 1000,
limit: 3
});
for (const item of result.items) {
console.log(`${item.name}: ${item.price}원/L, ${item.distanceMeters}m, ${item.roadAddress}`);
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Offline smoke example
실제 키가 없는 환경에서도 패키지 동작은 fixture 기반으로 검증할 수 있습니다.
```bash
node --test packages/cheap-gas-nearby/test/index.test.js
```
## 운영 팁
- `OPINET_API_KEY` 가 비어 있으면 다른 가격 서비스로 자동 우회하지 말고 필요한 값을 정확히 안내합니다.
- 서울역/강남처럼 넓은 질의는 anchor 위치가 흔들릴 수 있으니 필요하면 더 구체적인 역 출구/동 이름을 한 번 더 받습니다.
- 동일 가격이면 거리순으로 다시 정렬해 보여주는 편이 좋습니다.
- 결과가 너무 많으면 반경을 `1000m` 또는 `2000m` 정도로 유지하는 편이 읽기 쉽습니다.
## 주의할 점
- Opinet Open API는 인증키가 필요합니다.
- Kakao Map anchor 검색은 위치 기준점만 잡기 위한 보조 단계이고, 최종 가격/순위 데이터는 Opinet을 기준으로 합니다.
- Opinet 응답의 좌표는 KATEC 이므로 WGS84와 혼동하면 안 됩니다.

View file

@ -53,6 +53,7 @@ npx --yes skills add <owner/repo> \
--skill korean-law-search \
--skill real-estate-search \
--skill joseon-sillok-search \
--skill cheap-gas-nearby \
--skill fine-dust-location \
--skill daiso-product-search \
--skill blue-ribbon-nearby \
@ -73,6 +74,7 @@ npx --yes skills add <owner/repo> \
--skill ktx-booking \
--skill korean-law-search \
--skill real-estate-search \
--skill cheap-gas-nearby \
--skill joseon-sillok-search \
--skill seoul-subway-arrival \
--skill fine-dust-location
@ -144,7 +146,7 @@ npm run ci
### Node 패키지
```bash
npm install -g @ohah/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp
npm install -g @ohah/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp
export NODE_PATH="$(npm root -g)"
```
@ -196,6 +198,7 @@ python3 scripts/korean_spell_check.py --text "아버지가방에들어가신다.
- `fine-dust-location`
- `korean-law-search`
- `real-estate-search`
- `cheap-gas-nearby`
관련 문서:

View file

@ -15,6 +15,7 @@
- 한국 법령 검색 스킬 출시
- 한국 부동산 실거래가 조회 스킬 출시
- 조선왕조실록 검색 스킬 출시
- 근처 가장 싼 주유소 찾기 스킬 출시
- 우편번호 검색
- 근처 블루리본 맛집 스킬 출시
- 근처 술집 조회 스킬 출시

View file

@ -26,6 +26,7 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
DATA_GO_KR_API_KEY=replace-me
OPINET_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
```
@ -63,9 +64,10 @@ KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
- `KSKILL_KTX_PASSWORD`
- `LAW_OC`
- `DATA_GO_KR_API_KEY`
- `OPINET_API_KEY`
- `AIR_KOREA_OPEN_API_KEY`
- `KSKILL_PROXY_BASE_URL`
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로에서 쓰는 표준 변수명이다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY` 도 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다.
`LAW_OC``korean-law-mcp` 가 법제처 Open API 를 호출할 때 쓰는 표준 변수명이다. 이 값은 로컬 CLI/로컬 MCP server 경로에서만 사용자 쪽에 필요하고, upstream remote MCP endpoint 예시는 사용자 `LAW_OC` 없이 `url`만 등록한다. `DATA_GO_KR_API_KEY` 는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로에서 쓰는 표준 변수명이다. `OPINET_API_KEY` 는 한국석유공사 Opinet Open API 인증키로, 근처 가장 싼 주유소 찾기 skill/package 의 공식 nearby 가격 조회에 사용한다. public 공유용 Cloudflare Tunnel/Auth0/operator secret은 사용자 기본 secrets 파일에 넣지 않는다. 프록시 운영자 문맥에서는 upstream 환경변수 `SEOUL_OPEN_API_KEY` 도 사용할 수 있다. 다만 일반 사용자/client 쪽 기본 secrets 파일에는 넣지 않는다. `KSKILL_PROXY_BASE_URL` 도 서울 지하철 route가 실제 배포된 proxy URL 로만 넣는다.
이 레포의 credential-bearing skill은 전부 이 정책을 전제로 작성한다. 자세한 공통 설치 절차는 [공통 설정 가이드](setup.md)를 본다.

View file

@ -1,6 +1,6 @@
# 공통 설정 가이드
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 부동산 실거래가 조회의 로컬/self-host 경로용 `DATA_GO_KR_API_KEY`, self-host 프록시 운영용 서울 지하철/미세먼지 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다.
`k-skill` 전체 스킬을 설치한 뒤, 인증 정보가 필요한 기능(SRT 예매, KTX 예매, 한국 법령 검색의 로컬 CLI/MCP 경로용 `LAW_OC`, 한국 부동산 실거래가 조회의 로컬/self-host 경로용 `DATA_GO_KR_API_KEY`, 근처 가장 싼 주유소 찾기용 `OPINET_API_KEY`, self-host 프록시 운영용 서울 지하철/미세먼지 upstream key, 또는 배포 확인이 끝난 proxy URL 공유)이 있으면 이 절차를 진행하면 된다.
## Credential resolution order
@ -26,6 +26,7 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
DATA_GO_KR_API_KEY=replace-me
OPINET_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
EOF
@ -42,6 +43,8 @@ remote MCP endpoint는 사용자 `LAW_OC` 없이 `url`만으로 연결한다.
한국 부동산 실거래가 조회의 로컬/self-host 경로는 upstream `real-estate-mcp` 가 읽는 `DATA_GO_KR_API_KEY` 를 채운다. 2026-04-05 기준 upstream 문서에는 고정 public endpoint가 없어 self-host를 기본으로 보고, Cloudflare Tunnel/operator secret은 운영자별 값이라 기본 client secrets 파일에는 넣지 않는다.
근처 가장 싼 주유소 찾기는 한국석유공사 Opinet Open API 인증키인 `OPINET_API_KEY` 를 채운다. 이 값이 없으면 공식 nearby 가격 조회를 시작할 수 없으므로 비공식 가격 서비스로 자동 우회하지 않는다.
## 확인
```bash
@ -64,6 +67,7 @@ bash scripts/check-setup.sh
| 한국 법령 검색 (로컬 CLI/MCP) | `LAW_OC` |
| 한국 법령 검색 (remote MCP endpoint) | 사용자 시크릿 불필요 (`url`만 등록, 장애 시 `법망` fallback 가능) |
| 한국 부동산 실거래가 조회 (로컬/stdio/self-host) | `DATA_GO_KR_API_KEY` |
| 근처 가장 싼 주유소 찾기 | `OPINET_API_KEY` |
| 한국 부동산 실거래가 조회 (공유 URL) | 사용자 시크릿 불필요, 대신 운영자가 self-host + Cloudflare Tunnel + launchd/systemd 를 준비 |
| 서울 지하철 도착정보 조회 | self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL` |
| 사용자 위치 미세먼지 조회 | `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY` |
@ -76,6 +80,7 @@ bash scripts/check-setup.sh
- [사용자 위치 미세먼지 조회 가이드](features/fine-dust-location.md)
- [한국 법령 검색 가이드](features/korean-law-search.md)
- [한국 부동산 실거래가 조회 가이드](features/real-estate-search.md)
- [근처 가장 싼 주유소 찾기 가이드](features/cheap-gas-nearby.md)
- [보안/시크릿 정책](security-and-secrets.md)
설치 기본 흐름은 "전체 스킬 설치 → 개별 기능 사용" 이다.

View file

@ -45,6 +45,10 @@
- 조선왕조실록 메인: https://sillok.history.go.kr
- 조선왕조실록 검색 결과: https://sillok.history.go.kr/search/searchResultList.do
- 조선왕조실록 기사 상세: https://sillok.history.go.kr/id/kda_12512030_002
- Opinet 오픈 API 안내: https://www.opinet.co.kr/user/custapi/openApiInfo.do
- Opinet 반경 내 주유소 API: https://www.opinet.co.kr/api/aroundAll.do
- Opinet 주유소 상세정보 API: https://www.opinet.co.kr/api/detailById.do
- Opinet 지역코드 API: https://www.opinet.co.kr/api/areaCode.do
- 서울특별시 지하철 실시간 도착정보: 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

View file

@ -4,5 +4,6 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
DATA_GO_KR_API_KEY=replace-me
OPINET_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com

View file

@ -69,6 +69,7 @@ KSKILL_KTX_ID=replace-me
KSKILL_KTX_PASSWORD=replace-me
LAW_OC=replace-me
DATA_GO_KR_API_KEY=replace-me
OPINET_API_KEY=replace-me
AIR_KOREA_OPEN_API_KEY=replace-me
KSKILL_PROXY_BASE_URL=https://your-proxy.example.com
EOF
@ -83,6 +84,8 @@ chmod 0600 ~/.config/k-skill/secrets.env
한국 부동산 실거래가 조회는 upstream `real-estate-mcp` 의 로컬/stdio/self-host 경로를 쓸 때 `DATA_GO_KR_API_KEY` 를 채운다. 2026-04-05 기준 고정 public endpoint는 확인하지 못했으므로 shared URL이 필요하면 self-host + Cloudflare Tunnel + launchd(systemd) 운영을 먼저 설명한다.
근처 가장 싼 주유소 찾기는 한국석유공사 Opinet Open API 인증키인 `OPINET_API_KEY` 를 채운다. 이 값이 없으면 공식 nearby 가격 조회를 시작할 수 없으므로 비공식 가격 서비스로 자동 우회하지 않는다.
### Missing secret response template
인증 스킬에서 값이 빠졌을 때는 credential resolution order에 따라 확보한다.
@ -94,6 +97,7 @@ chmod 0600 ~/.config/k-skill/secrets.env
- 로컬 한국 법령 검색: `LAW_OC` + `korean-law-mcp`
- 한국 법령 검색 remote endpoint: 사용자 `LAW_OC` 없이 `url`만 등록, 장애 시 `법망` fallback
- 로컬/stdio 한국 부동산 실거래가 조회: `DATA_GO_KR_API_KEY` + `real-estate-mcp`
- 근처 가장 싼 주유소 찾기: `OPINET_API_KEY` + `cheap-gas-nearby`
- 공유형 한국 부동산 실거래가 조회: 운영자가 self-host + Cloudflare Tunnel + launchd/systemd 준비
- 서울 지하철: self-host 또는 배포 확인이 끝난 `KSKILL_PROXY_BASE_URL`
- 사용자 위치 미세먼지 조회: `KSKILL_PROXY_BASE_URL` 또는 `AIR_KOREA_OPEN_API_KEY`

16
package-lock.json generated
View file

@ -574,6 +574,10 @@
"dev": true,
"license": "MIT"
},
"node_modules/cheap-gas-nearby": {
"resolved": "packages/cheap-gas-nearby",
"link": true
},
"node_modules/cookie": {
"version": "1.1.1",
"license": "MIT",
@ -585,10 +589,6 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/coupang-product-search": {
"resolved": "packages/coupang-product-search",
"link": true
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"dev": true,
@ -1622,8 +1622,16 @@
"node": ">=18"
}
},
"packages/cheap-gas-nearby": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/coupang-product-search": {
"version": "0.1.0",
"extraneous": true,
"license": "MIT",
"engines": {
"node": ">=18"

View file

@ -12,7 +12,7 @@
"lint": "node --check scripts/skill-docs.test.js && python3 -m py_compile scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "node --test scripts/skill-docs.test.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check && 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 && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace used-car-price-search --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 && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace used-car-price-search --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,68 @@
# cheap-gas-nearby
한국석유공사 오피넷(Opinet) 공식 API를 사용해 근처의 가장 싼 주유소를 찾는 Node.js 패키지입니다.
## 설치
배포 후:
```bash
npm install cheap-gas-nearby
```
이 저장소에서 개발할 때:
```bash
npm install
```
## 사용 원칙
- 유저 위치는 자동으로 추적하지 않습니다.
- 먼저 현재 위치를 묻고, 받은 동네/역명/랜드마크/위도·경도를 사용하세요.
- 가격 데이터는 공식 Opinet Open API를 우선 사용합니다.
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 잡은 뒤, Opinet `aroundAll.do` 검색으로 연결합니다.
- 공식 API key (`OPINET_API_KEY`) 또는 `apiKey` 옵션이 필요합니다.
## 공식 표면
- 오픈 API 안내: `https://www.opinet.co.kr/user/custapi/openApiInfo.do`
- 반경 내 주유소: `https://www.opinet.co.kr/api/aroundAll.do`
- 주유소 상세정보(ID): `https://www.opinet.co.kr/api/detailById.do`
- 지역코드: `https://www.opinet.co.kr/api/areaCode.do`
- Kakao Map anchor 검색: `https://m.map.kakao.com/actions/searchView`
## 사용 예시
```js
const { searchCheapGasStationsByLocationQuery } = require("cheap-gas-nearby");
async function main() {
const result = await searchCheapGasStationsByLocationQuery("서울역", {
apiKey: process.env.OPINET_API_KEY,
radius: 1000,
productCode: "B027",
limit: 3
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 공개 API
- `parseSearchResultsHtml(html)`
- `selectAnchorCandidate(query, items)`
- `normalizeAnchorPanel(panel, searchItem)`
- `wgs84ToKatec(latitude, longitude)`
- `buildAroundSearchParams(options)`
- `parseAroundResponse(payload)`
- `normalizeDetailItem(payload)`
- `searchCheapGasStationsByCoordinates(options)`
- `searchCheapGasStationsByLocationQuery(locationQuery, options)`

View file

@ -0,0 +1,32 @@
{
"name": "cheap-gas-nearby",
"version": "0.1.0",
"description": "Official Opinet based nearby cheapest gas station lookup for Korean location queries",
"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",
"opinet",
"gas-station",
"fuel-price"
],
"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,267 @@
const {
buildAroundSearchParams,
normalizeAnchorPanel,
normalizeDetailItem,
parseAroundResponse,
parseSearchResultsHtml,
selectAnchorCandidate,
sortStationsByPriceAndDistance,
wgs84ToKatec
} = 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 OPINET_BASE_URL = "https://www.opinet.co.kr/api";
const AROUND_ALL_URL = `${OPINET_BASE_URL}/aroundAll.do`;
const DETAIL_BY_ID_URL = `${OPINET_BASE_URL}/detailById.do`;
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"
};
const DEFAULT_JSON_HEADERS = {
accept: "application/json, text/plain;q=0.9,*/*;q=0.8",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
"user-agent": DEFAULT_BROWSER_HEADERS["user-agent"]
};
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 headerSet =
responseType === "json"
? options.headerSet || DEFAULT_JSON_HEADERS
: options.headerSet || DEFAULT_BROWSER_HEADERS;
const response = await fetchImpl(url, {
headers: {
...headerSet,
...(options.headers || {})
},
signal: options.signal
});
if (!response.ok) {
throw new Error(`Request failed with ${response.status} for ${url}`);
}
return responseType === "json" ? response.json() : response.text();
}
function resolveApiKey(options = {}) {
const apiKey = options.apiKey || options.certKey || process.env.OPINET_API_KEY;
if (!apiKey) {
throw new Error("OPINET_API_KEY or options.apiKey is required for official Opinet lookups.");
}
return apiKey;
}
function parseCoordinateQuery(locationQuery) {
const match = String(locationQuery || "")
.trim()
.match(/^(-?\d+(?:\.\d+)?)\s*[,/ ]\s*(-?\d+(?:\.\d+)?)$/);
if (!match) {
return null;
}
return {
latitude: Number(match[1]),
longitude: Number(match[2])
};
}
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, headerSet: DEFAULT_PANEL_HEADERS }, "json");
}
async function fetchOpinetJson(url, options = {}) {
return request(url, { ...options, headerSet: DEFAULT_JSON_HEADERS }, "json");
}
async function resolveAnchor(locationQuery, options = {}) {
const anchorSearchHtml = await fetchSearchResults(locationQuery, options);
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
const selectedAnchor = selectAnchorCandidate(locationQuery, anchorCandidates);
const anchorPanel = await fetchPlacePanel(selectedAnchor.id, options);
const anchor = normalizeAnchorPanel(anchorPanel, selectedAnchor);
if (!Number.isFinite(anchor.latitude) || !Number.isFinite(anchor.longitude)) {
throw new Error(`Kakao Map anchor panel did not include coordinates for ${locationQuery}.`);
}
return {
anchor,
anchorCandidates
};
}
async function fetchAroundStations({ x, y, radius, productCode, apiKey, sort = 1 }, options = {}) {
const url = new URL(AROUND_ALL_URL);
const params = buildAroundSearchParams({ x, y, radius, productCode, sort });
for (const [key, value] of Object.entries({ ...params, certkey: apiKey })) {
url.searchParams.set(key, value);
}
return parseAroundResponse(await fetchOpinetJson(url.toString(), options));
}
async function fetchDetailById(id, apiKey, options = {}) {
const url = new URL(DETAIL_BY_ID_URL);
url.searchParams.set("out", "json");
url.searchParams.set("id", id);
url.searchParams.set("certkey", apiKey);
return normalizeDetailItem(await fetchOpinetJson(url.toString(), options));
}
function mergeStationDetail(aroundItem, detailItem) {
if (!detailItem) {
return aroundItem;
}
return {
...aroundItem,
...detailItem,
price: aroundItem.price,
distanceMeters: aroundItem.distanceMeters,
name: detailItem.name || aroundItem.name,
brandCode: detailItem.brandCode || aroundItem.brandCode,
brandName: detailItem.brandName || aroundItem.brandName
};
}
async function searchCheapGasStationsByCoordinates(options = {}) {
const latitude = Number(options.latitude);
const longitude = Number(options.longitude);
const radius = Number(options.radius ?? 1000);
const productCode = options.productCode || "B027";
const limit = Math.max(1, Number(options.limit ?? 5));
const detailLimit = Math.max(0, Number(options.detailLimit ?? limit));
const apiKey = resolveApiKey(options);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("latitude and longitude must be finite numbers.");
}
const katec = wgs84ToKatec(latitude, longitude);
const aroundItems = await fetchAroundStations(
{
x: katec.x,
y: katec.y,
radius,
productCode,
apiKey,
sort: options.sort ?? 1
},
options,
);
const rankedItems = sortStationsByPriceAndDistance(aroundItems);
const detailTargets = rankedItems.slice(0, detailLimit);
const detailEntries = await Promise.all(
detailTargets.map(async (item) => {
try {
return [item.id, await fetchDetailById(item.id, apiKey, options)];
} catch (error) {
return [item.id, { error: String(error.message || error) }];
}
}),
);
const detailMap = new Map(detailEntries);
return {
anchor: {
latitude,
longitude,
katecX: katec.x,
katecY: katec.y
},
items: rankedItems.slice(0, limit).map((item) => mergeStationDetail(item, detailMap.get(item.id))),
meta: {
productCode,
radius,
total: rankedItems.length
}
};
}
async function searchCheapGasStationsByLocationQuery(locationQuery, options = {}) {
const query = String(locationQuery || "").trim();
if (!query) {
throw new Error("locationQuery is required.");
}
const coordinateQuery = parseCoordinateQuery(query);
if (coordinateQuery) {
return searchCheapGasStationsByCoordinates({
...options,
...coordinateQuery
});
}
const { anchor, anchorCandidates } = await resolveAnchor(query, options);
const result = await searchCheapGasStationsByCoordinates({
...options,
latitude: anchor.latitude,
longitude: anchor.longitude
});
return {
...result,
anchor,
anchorCandidates,
meta: {
...result.meta,
resolvedQuery: query
}
};
}
module.exports = {
AROUND_ALL_URL,
DETAIL_BY_ID_URL,
PLACE_PANEL_URL_BASE,
SEARCH_VIEW_URL,
buildAroundSearchParams,
fetchDetailById,
fetchPlacePanel,
fetchSearchResults,
normalizeAnchorPanel,
normalizeDetailItem,
parseAroundResponse,
parseSearchResultsHtml,
searchCheapGasStationsByCoordinates,
searchCheapGasStationsByLocationQuery,
selectAnchorCandidate,
wgs84ToKatec
};

View file

@ -0,0 +1,442 @@
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 GAS_STATION_PATTERN = /(주유소|충전소|셀프주유소|가스충전소)/u;
const WGS84_A = 6378137.0;
const WGS84_F = 1 / 298.257223563;
const BESSEL_A = 6377397.155;
const BESSEL_F = 1 / 299.1528128;
const KATEC_LAT0 = degreesToRadians(38.0);
const KATEC_LON0 = degreesToRadians(128.0);
const KATEC_FALSE_EASTING = 400000.0;
const KATEC_FALSE_NORTHING = 600000.0;
const KATEC_SCALE = 0.9999;
const WGS84_TO_BESSEL = [146.43, -507.89, -681.46];
const BRAND_NAMES = {
ETC: "자가상표",
E1G: "E1",
GSC: "GS칼텍스",
HDO: "현대오일뱅크",
NHO: "농협알뜰",
RTE: "자영알뜰",
RTX: "고속도로알뜰",
SKE: "SK에너지",
SKG: "SK가스",
SOL: "S-OIL"
};
const PRODUCT_CODE_TO_KEY = {
B027: "gasoline",
B034: "premiumGasoline",
C004: "kerosene",
D047: "diesel",
K015: "lpg"
};
function degreesToRadians(value) {
return (value * Math.PI) / 180;
}
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 toNumber(value) {
if (value === null || value === undefined || value === "") {
return null;
}
const parsed = Number(String(value).replace(/,/g, ""));
return Number.isFinite(parsed) ? parsed : null;
}
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") || null
});
}
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 += 1000;
}
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 (GAS_STATION_PATTERN.test(`${item.name} ${item.category}`)) {
score -= 200;
}
if (!/^\d+$/.test(String(item.id || ""))) {
score -= 500;
}
if (normalizedCategory.includes("기차역") || normalizedCategory.includes("전철역")) {
score += 80;
}
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 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 meridionalArc(phi, semiMajorAxis, eccentricitySquared) {
const e2 = eccentricitySquared;
return semiMajorAxis * (
(1 - e2 / 4 - (3 * e2 ** 2) / 64 - (5 * e2 ** 3) / 256) * phi -
((3 * e2) / 8 + (3 * e2 ** 2) / 32 + (45 * e2 ** 3) / 1024) * Math.sin(2 * phi) +
((15 * e2 ** 2) / 256 + (45 * e2 ** 3) / 1024) * Math.sin(4 * phi) -
((35 * e2 ** 3) / 3072) * Math.sin(6 * phi)
);
}
function wgs84ToBessel(lat, lon) {
const [dx, dy, dz] = WGS84_TO_BESSEL;
const sourceEccentricitySquared = 2 * WGS84_F - WGS84_F ** 2;
const targetEccentricitySquared = 2 * BESSEL_F - BESSEL_F ** 2;
const latitudeRadians = degreesToRadians(lat);
const longitudeRadians = degreesToRadians(lon);
const sinLatitude = Math.sin(latitudeRadians);
const cosLatitude = Math.cos(latitudeRadians);
const primeVerticalRadius = WGS84_A / Math.sqrt(1 - sourceEccentricitySquared * sinLatitude ** 2);
const x = primeVerticalRadius * cosLatitude * Math.cos(longitudeRadians) + dx;
const y = primeVerticalRadius * cosLatitude * Math.sin(longitudeRadians) + dy;
const z = primeVerticalRadius * (1 - sourceEccentricitySquared) * sinLatitude + dz;
const besselLongitude = Math.atan2(y, x);
const horizontal = Math.sqrt(x ** 2 + y ** 2);
let besselLatitude = Math.atan2(z, horizontal * (1 - targetEccentricitySquared));
for (let index = 0; index < 8; index += 1) {
const sinBesselLatitude = Math.sin(besselLatitude);
const besselRadius = BESSEL_A / Math.sqrt(1 - targetEccentricitySquared * sinBesselLatitude ** 2);
const nextLatitude = Math.atan2(z + targetEccentricitySquared * besselRadius * sinBesselLatitude, horizontal);
if (Math.abs(nextLatitude - besselLatitude) < 1e-14) {
besselLatitude = nextLatitude;
break;
}
besselLatitude = nextLatitude;
}
return {
latitudeRadians: besselLatitude,
longitudeRadians: besselLongitude
};
}
function wgs84ToKatec(latitude, longitude) {
const { latitudeRadians, longitudeRadians } = wgs84ToBessel(latitude, longitude);
const besselEccentricitySquared = 2 * BESSEL_F - BESSEL_F ** 2;
const secondEccentricitySquared = besselEccentricitySquared / (1 - besselEccentricitySquared);
const sinLatitude = Math.sin(latitudeRadians);
const cosLatitude = Math.cos(latitudeRadians);
const tanLatitude = Math.tan(latitudeRadians);
const primeVerticalRadius = BESSEL_A / Math.sqrt(1 - besselEccentricitySquared * sinLatitude ** 2);
const tanSquared = tanLatitude ** 2;
const curvature = secondEccentricitySquared * cosLatitude ** 2;
const A = (longitudeRadians - KATEC_LON0) * cosLatitude;
const meridional = meridionalArc(latitudeRadians, BESSEL_A, besselEccentricitySquared);
const meridionalOrigin = meridionalArc(KATEC_LAT0, BESSEL_A, besselEccentricitySquared);
const x =
KATEC_FALSE_EASTING +
KATEC_SCALE *
primeVerticalRadius *
(A + ((1 - tanSquared + curvature) * A ** 3) / 6 +
((5 - 18 * tanSquared + tanSquared ** 2 + 72 * curvature - 58 * secondEccentricitySquared) * A ** 5) /
120);
const y =
KATEC_FALSE_NORTHING +
KATEC_SCALE *
(meridional - meridionalOrigin +
primeVerticalRadius *
tanLatitude *
(A ** 2 / 2 +
((5 - tanSquared + 9 * curvature + 4 * curvature ** 2) * A ** 4) / 24 +
((61 - 58 * tanSquared + tanSquared ** 2 + 600 * curvature - 330 * secondEccentricitySquared) * A ** 6) /
720));
return {
x,
y
};
}
function formatCoordinate(value) {
const numericValue = Number(value);
if (!Number.isFinite(numericValue)) {
throw new Error("Coordinate values must be finite numbers.");
}
return numericValue.toFixed(4);
}
function buildAroundSearchParams({ x, y, radius, productCode, sort = 1 } = {}) {
if (!Number.isFinite(Number(x)) || !Number.isFinite(Number(y))) {
throw new Error("x and y are required KATEC coordinates.");
}
const normalizedRadius = Number(radius ?? 1000);
if (!Number.isFinite(normalizedRadius) || normalizedRadius <= 0 || normalizedRadius > 5000) {
throw new Error("radius must be a positive number up to 5000 meters.");
}
return {
out: "json",
x: formatCoordinate(x),
y: formatCoordinate(y),
radius: String(Math.round(normalizedRadius)),
prodcd: productCode || "B027",
sort: String(sort)
};
}
function extractOilEntries(payload) {
const oil = payload?.RESULT?.OIL;
if (Array.isArray(oil)) {
return oil;
}
if (oil && typeof oil === "object") {
return [oil];
}
return [];
}
function normalizeAroundItem(item) {
return {
id: String(item.UNI_ID || item.uni_id || ""),
brandCode: String(item.POLL_DIV_CO || item.POLL_DIV_CD || item.poll_div_cd || ""),
brandName:
BRAND_NAMES[String(item.POLL_DIV_CO || item.POLL_DIV_CD || item.poll_div_cd || "")] ||
String(item.POLL_DIV_CO || item.POLL_DIV_CD || item.poll_div_cd || ""),
name: item.OS_NM || item.os_nm || "",
price: toNumber(item.PRICE ?? item.price),
distanceMeters: toNumber(item.DISTANCE ?? item.distance),
katecX: toNumber(item.GIS_X_COOR ?? item.gis_x_coor),
katecY: toNumber(item.GIS_Y_COOR ?? item.gis_y_coor)
};
}
function parseAroundResponse(payload) {
return extractOilEntries(payload)
.map((item) => normalizeAroundItem(item))
.filter((item) => item.id && Number.isFinite(item.price));
}
function normalizeProductPrices(priceEntries) {
const priceList = Array.isArray(priceEntries) ? priceEntries : priceEntries ? [priceEntries] : [];
const prices = {};
const raw = {};
for (const entry of priceList) {
const productCode = String(entry.PRODCD || entry.prodcd || "");
const key = PRODUCT_CODE_TO_KEY[productCode] || productCode;
const price = toNumber(entry.PRICE ?? entry.price);
raw[productCode] = {
price,
tradeDate: String(entry.TRADE_DT || entry.trade_dt || "") || null,
tradeTime: String(entry.TRADE_TM || entry.trade_tm || "") || null
};
if (key) {
prices[key] = price;
}
}
return {
prices,
raw
};
}
function normalizeDetailItem(payload) {
const [item] = extractOilEntries(payload);
if (!item) {
throw new Error("Opinet detail payload did not include an OIL record.");
}
const priceSummary = normalizeProductPrices(item.OIL_PRICE || item.oil_price);
const brandCode = String(item.POLL_DIV_CO || item.POLL_DIV_CD || item.poll_div_cd || "");
return {
id: String(item.UNI_ID || item.uni_id || ""),
brandCode,
brandName: BRAND_NAMES[brandCode] || brandCode,
name: item.OS_NM || item.os_nm || "",
lotAddress: item.VAN_ADR || item.van_adr || null,
roadAddress: item.NEW_ADR || item.new_adr || null,
phone: item.TEL || item.tel || null,
sigunCode: item.SIGUNCD || item.siguncd || null,
lpgYn: item.LPG_YN || item.lpg_yn || null,
isSelf: String(item.SELF_YN || item.self_yn || "N") === "Y",
hasMaintenance: String(item.MAINT_YN || item.maint_yn || "N") === "Y",
hasCarWash: String(item.CAR_WASH_YN || item.car_wash_yn || "N") === "Y",
hasConvenienceStore: String(item.CVS_YN || item.cvs_yn || "N") === "Y",
kpetroCertified: String(item.KPETRO_YN || item.kpetro_yn || "N") === "Y",
katecX: toNumber(item.GIS_X_COOR ?? item.gis_x_coor),
katecY: toNumber(item.GIS_Y_COOR ?? item.gis_y_coor),
prices: priceSummary.prices,
rawPrices: priceSummary.raw
};
}
function sortStationsByPriceAndDistance(items) {
return [...items].sort((left, right) => {
if ((left.price ?? Number.POSITIVE_INFINITY) !== (right.price ?? Number.POSITIVE_INFINITY)) {
return (left.price ?? Number.POSITIVE_INFINITY) - (right.price ?? Number.POSITIVE_INFINITY);
}
if ((left.distanceMeters ?? Number.POSITIVE_INFINITY) !== (right.distanceMeters ?? Number.POSITIVE_INFINITY)) {
return (left.distanceMeters ?? Number.POSITIVE_INFINITY) - (right.distanceMeters ?? Number.POSITIVE_INFINITY);
}
return String(left.name || "").localeCompare(String(right.name || ""), "ko");
});
}
module.exports = {
BRAND_NAMES,
PRODUCT_CODE_TO_KEY,
buildAroundSearchParams,
normalizeAnchorPanel,
normalizeAroundItem,
normalizeDetailItem,
parseAroundResponse,
parseSearchResultsHtml,
selectAnchorCandidate,
sortStationsByPriceAndDistance,
wgs84ToKatec
};

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,33 @@
{
"RESULT": {
"OIL": [
{
"UNI_ID": "A1000001",
"POLL_DIV_CO": "SKE",
"OS_NM": "서울역셀프주유소",
"PRICE": "1635",
"DISTANCE": "112.4",
"GIS_X_COOR": "309240.0000",
"GIS_Y_COOR": "550790.0000"
},
{
"UNI_ID": "A1000002",
"POLL_DIV_CO": "RTE",
"OS_NM": "만리알뜰주유소",
"PRICE": "1649",
"DISTANCE": "315.0",
"GIS_X_COOR": "309010.2000",
"GIS_Y_COOR": "550940.1000"
},
{
"UNI_ID": "A1000003",
"POLL_DIV_CO": "SOL",
"OS_NM": "서울로주유소",
"PRICE": "1649",
"DISTANCE": "220.0",
"GIS_X_COOR": "309500.3000",
"GIS_Y_COOR": "550820.5000"
}
]
}
}

View file

@ -0,0 +1,25 @@
{
"RESULT": {
"OIL": {
"UNI_ID": "A1000001",
"POLL_DIV_CO": "SKE",
"OS_NM": "서울역셀프주유소",
"VAN_ADR": "서울 용산구 동자동 43-205",
"NEW_ADR": "서울 용산구 한강대로 405",
"TEL": "02-1111-2222",
"SIGUNCD": "0101",
"LPG_YN": "N",
"MAINT_YN": "Y",
"CAR_WASH_YN": "Y",
"CVS_YN": "N",
"KPETRO_YN": "Y",
"SELF_YN": "Y",
"GIS_X_COOR": "309240.0000",
"GIS_Y_COOR": "550790.0000",
"OIL_PRICE": [
{ "PRODCD": "B027", "PRICE": "1635", "TRADE_DT": "20260405", "TRADE_TM": "091501" },
{ "PRODCD": "D047", "PRICE": "1529", "TRADE_DT": "20260405", "TRADE_TM": "091455" }
]
}
}
}

View file

@ -0,0 +1,25 @@
{
"RESULT": {
"OIL": {
"UNI_ID": "A1000003",
"POLL_DIV_CO": "SOL",
"OS_NM": "서울로주유소",
"VAN_ADR": "서울 중구 봉래동2가 122",
"NEW_ADR": "서울 중구 통일로 10",
"TEL": "02-3333-4444",
"SIGUNCD": "0102",
"LPG_YN": "N",
"MAINT_YN": "N",
"CAR_WASH_YN": "Y",
"CVS_YN": "Y",
"KPETRO_YN": "N",
"SELF_YN": "N",
"GIS_X_COOR": "309500.3000",
"GIS_Y_COOR": "550820.5000",
"OIL_PRICE": [
{ "PRODCD": "B027", "PRICE": "1649", "TRADE_DT": "20260405", "TRADE_TM": "091420" },
{ "PRODCD": "D047", "PRICE": "1539", "TRADE_DT": "20260405", "TRADE_TM": "091410" }
]
}
}
}

View file

@ -0,0 +1,166 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
buildAroundSearchParams,
normalizeAnchorPanel,
normalizeDetailItem,
parseAroundResponse,
parseSearchResultsHtml,
searchCheapGasStationsByLocationQuery,
selectAnchorCandidate,
wgs84ToKatec
} = require("../src/index");
const fixturesDir = path.join(__dirname, "fixtures");
const anchorSearchHtml = fs.readFileSync(path.join(fixturesDir, "anchor-search.html"), "utf8");
const anchorPanel = JSON.parse(fs.readFileSync(path.join(fixturesDir, "anchor-panel.json"), "utf8"));
const aroundResponse = JSON.parse(fs.readFileSync(path.join(fixturesDir, "around-response.json"), "utf8"));
const detailA1000001 = JSON.parse(fs.readFileSync(path.join(fixturesDir, "detail-a1000001.json"), "utf8"));
const detailA1000003 = JSON.parse(fs.readFileSync(path.join(fixturesDir, "detail-a1000003.json"), "utf8"));
test("parseSearchResultsHtml extracts Kakao search cards for anchor resolution", () => {
const items = parseSearchResultsHtml(anchorSearchHtml);
assert.equal(items.length, 2);
assert.deepEqual(items[0], {
id: "1001",
name: "서울역",
category: "기차역",
address: "서울 용산구 동자동",
phone: "1544-7788"
});
});
test("selectAnchorCandidate prefers the obvious station/landmark match", () => {
const anchor = selectAnchorCandidate("서울역", parseSearchResultsHtml(anchorSearchHtml));
assert.equal(anchor.id, "1001");
assert.equal(anchor.name, "서울역");
});
test("normalizeAnchorPanel keeps source URL and WGS84 coordinates", () => {
const item = normalizeAnchorPanel(anchorPanel, { id: "1001", name: "서울역", category: "기차역" });
assert.equal(item.id, "1001");
assert.equal(item.latitude, 37.55472);
assert.equal(item.longitude, 126.97068);
assert.equal(item.sourceUrl, "https://place.map.kakao.com/1001");
});
test("wgs84ToKatec converts WGS84 coordinates into the KATEC values Opinet expects", () => {
const { x, y } = wgs84ToKatec(37.55472, 126.97068);
assert.ok(Math.abs(x - 309252.2237) < 1);
assert.ok(Math.abs(y - 550779.9944) < 1);
});
test("buildAroundSearchParams encodes the official Opinet nearby search contract", () => {
const params = buildAroundSearchParams({
x: 309252.2237,
y: 550779.9944,
radius: 1000,
productCode: "B027",
sort: 1
});
assert.equal(params.out, "json");
assert.equal(params.x, "309252.2237");
assert.equal(params.y, "550779.9944");
assert.equal(params.radius, "1000");
assert.equal(params.prodcd, "B027");
assert.equal(params.sort, "1");
});
test("parseAroundResponse normalizes nearby Opinet stations and keeps cheapest ordering", () => {
const items = parseAroundResponse(aroundResponse);
assert.equal(items.length, 3);
assert.deepEqual(
items.map((item) => [item.id, item.price, item.distanceMeters]),
[
["A1000001", 1635, 112.4],
["A1000002", 1649, 315],
["A1000003", 1649, 220]
]
);
});
test("normalizeDetailItem enriches a station with address, services, and product prices", () => {
const item = normalizeDetailItem(detailA1000001);
assert.equal(item.id, "A1000001");
assert.equal(item.name, "서울역셀프주유소");
assert.equal(item.brandCode, "SKE");
assert.equal(item.roadAddress, "서울 용산구 한강대로 405");
assert.equal(item.phone, "02-1111-2222");
assert.equal(item.isSelf, true);
assert.equal(item.hasCarWash, true);
assert.equal(item.hasMaintenance, true);
assert.equal(item.kpetroCertified, true);
assert.equal(item.prices.gasoline, 1635);
assert.equal(item.prices.diesel, 1529);
});
test("searchCheapGasStationsByLocationQuery resolves the anchor, queries Opinet, enriches details, and sorts by price then distance", async () => {
const calls = [];
const fetchImpl = async (url) => {
const resolved = String(url);
calls.push(resolved);
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EC%84%9C%EC%9A%B8%EC%97%AD")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse(anchorPanel, "application/json");
}
if (resolved.startsWith("https://www.opinet.co.kr/api/aroundAll.do?")) {
return makeResponse(aroundResponse, "application/json");
}
if (resolved.includes("detailById.do") && resolved.includes("id=A1000001")) {
return makeResponse(detailA1000001, "application/json");
}
if (resolved.includes("detailById.do") && resolved.includes("id=A1000003")) {
return makeResponse(detailA1000003, "application/json");
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchCheapGasStationsByLocationQuery("서울역", {
apiKey: "test-opinet-key",
radius: 1000,
limit: 2,
detailLimit: 2,
fetchImpl
});
assert.equal(result.anchor.name, "서울역");
assert.equal(result.anchor.sourceUrl, "https://place.map.kakao.com/1001");
assert.deepEqual(
result.items.map((item) => [item.id, item.price, item.distanceMeters, item.roadAddress]),
[
["A1000001", 1635, 112.4, "서울 용산구 한강대로 405"],
["A1000003", 1649, 220, "서울 중구 통일로 10"]
]
);
assert.equal(result.meta.productCode, "B027");
assert.equal(result.meta.radius, 1000);
assert.ok(calls.some((url) => url.includes("aroundAll.do")));
assert.ok(calls.some((url) => url.includes("detailById.do") && url.includes("A1000001")));
});
function makeResponse(body, contentType) {
return new Response(typeof body === "string" ? body : JSON.stringify(body), {
status: 200,
headers: {
"content-type": contentType
}
});
}

View file

@ -180,7 +180,7 @@ test("repository docs advertise the used-car-price-search skill", () => {
assert.match(install, /--skill used-car-price-search/);
assert.match(
install,
/npm install -g @ohah\/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search korean-law-mcp/,
/npm install -g @ohah\/hwpjs kbo-game kleague-results toss-securities k-lotto coupang-product-search used-car-price-search cheap-gas-nearby korean-law-mcp/,
);
});
@ -1300,3 +1300,49 @@ test("repository docs advertise the shipped korean-spell-check helper assets", (
assert.match(readme, /\[한국어 맞춤법 검사 가이드\]\(docs\/features\/korean-spell-check\.md\)/);
assert.match(install, /python3 scripts\/korean_spell_check\.py/);
});
test("repository docs advertise the cheap-gas-nearby skill and Opinet key requirements", () => {
const readme = read("README.md");
const install = read(path.join("docs", "install.md"));
const setup = read(path.join("docs", "setup.md"));
const security = read(path.join("docs", "security-and-secrets.md"));
const sources = read(path.join("docs", "sources.md"));
const roadmap = read(path.join("docs", "roadmap.md"));
const setupSkill = read(path.join("k-skill-setup", "SKILL.md"));
const examplesSecrets = read(path.join("examples", "secrets.env.example"));
const featureDocPath = path.join(repoRoot, "docs", "features", "cheap-gas-nearby.md");
const skillPath = path.join(repoRoot, "cheap-gas-nearby", "SKILL.md");
assert.equal(fs.existsSync(featureDocPath), true);
assert.equal(fs.existsSync(skillPath), true);
assert.match(readme, /\| 근처 가장 싼 주유소 찾기 \|/);
assert.match(readme, /\[근처 가장 싼 주유소 찾기 가이드\]\(docs\/features\/cheap-gas-nearby\.md\)/);
assert.match(install, /--skill cheap-gas-nearby/);
for (const doc of [setup, security, setupSkill]) {
assert.match(doc, /OPINET_API_KEY/);
assert.match(doc, /오피넷|Opinet/);
}
assert.match(examplesSecrets, /^OPINET_API_KEY=replace-me$/m);
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/user\/custapi\/openApiInfo\.do/);
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/api\/aroundAll\.do/);
assert.match(sources, /https:\/\/www\.opinet\.co\.kr\/api\/detailById\.do/);
assert.match(roadmap, /근처 가장 싼 주유소 찾기 스킬 출시/);
});
test("cheap-gas-nearby skill docs require location-first prompts and official Opinet surfaces", () => {
const skill = read(path.join("cheap-gas-nearby", "SKILL.md"));
const featureDoc = read(path.join("docs", "features", "cheap-gas-nearby.md"));
for (const doc of [skill, featureDoc]) {
assert.match(doc, /현재 위치를 알려주세요/);
assert.match(doc, /OPINET_API_KEY/);
assert.match(doc, /aroundAll\.do/);
assert.match(doc, /detailById\.do/);
assert.match(doc, /areaCode\.do/);
assert.match(doc, /휘발유|경유/);
assert.match(doc, /KATEC/);
assert.match(doc, /카카오맵|Kakao Map/);
}
});