Support nearby ER status checks

Add an E-Gen based emergency-room skill that resolves a user location, queries the public nearby emergency-room list, and reports operation flags while documenting that exact remaining bed counts are not exposed by this surface.

Constraint: Issue #255 requested NEMC emergency bed status using public monitoring/E-Gen surfaces.
Rejected: Scraping private monitoring dashboards or claiming exact bed utilization | public endpoints expose operation flags, not per-hospital remaining bed counts.
Confidence: high
Scope-risk: narrow
Directive: Preserve the public-data limitation text unless a verified official bed-count endpoint is added.
Tested: npm run lint --workspace emergency-room-beds; npm test --workspace emergency-room-beds; node --test scripts/skill-docs.test.js; npm run typecheck; npm pack --workspace emergency-room-beds --dry-run; ./scripts/validate-skills.sh; live E-Gen coordinate smoke.
Not-tested: npm run ci end-to-end due local Python 3.14 pip/pyexpat import error before tests.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-17 18:37:07 +09:00
commit 4e2d1faf19
15 changed files with 1058 additions and 1 deletions

View file

@ -0,0 +1,5 @@
---
"emergency-room-beds": minor
---
Add an E-Gen based nearby emergency-room status skill and package.

View file

@ -63,6 +63,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 근처 가장 싼 주유소 찾기 | `cheap-gas-nearby` | 현재 위치 기준 근처 최저가 주유소 조회 | 불필요 | [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md) |
| 근처 공중화장실 찾기 | `public-restroom-nearby` | 현재 위치 기준 근처 공중화장실/개방화장실 조회 | 불필요 | [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md) |
| 근처 공영주차장 찾기 | `parking-lot-search` | 현재 위치 기준 근처 공영주차장 위치·요금·운영시간 조회 | 불필요 | [근처 공영주차장 찾기 가이드](docs/features/parking-lot-search.md) |
| 근처 응급실 병상 상태 확인 | `emergency-room-beds` | 현재 위치 기준 가까운 응급실 운영·입원실/병상 운영 플래그와 갱신시각 조회 (정확한 잔여 병상 수/가동률은 공개 E-Gen nearby 목록에 없음) | 불필요 | [근처 응급실 병상 상태 확인 가이드](docs/features/emergency-room-beds.md) |
| 한국 마라톤 일정 조회 | `korean-marathon-schedule` | 고러닝 공개 페이지와 대한철인3종협회 일정에서 마라톤·철인3종 대회 일정, 장소, 신청 마감일, 종목 조회 | 불필요 | [한국 마라톤 일정 조회 가이드](docs/features/korean-marathon-schedule.md) |
| KBO 경기 결과 조회 | `kbo-results` | 날짜별 KBO 경기 일정, 결과, 팀별 필터링 | 불필요 | [KBO 결과 가이드](docs/features/kbo-results.md) |
| KBL 경기 결과 조회 | `kbl-results` | 날짜별 KBL 경기 일정, 결과, 팀별 필터링, 현재 순위 확인 | 불필요 | [KBL 경기 결과 가이드](docs/features/kbl-results.md) |
@ -172,6 +173,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
- [근처 가장 싼 주유소 찾기 가이드](docs/features/cheap-gas-nearby.md)
- [근처 공중화장실 찾기 가이드](docs/features/public-restroom-nearby.md)
- [근처 공영주차장 찾기 가이드](docs/features/parking-lot-search.md)
- [근처 응급실 병상 상태 확인 가이드](docs/features/emergency-room-beds.md)
- [한국 마라톤 일정 조회 가이드](docs/features/korean-marathon-schedule.md)
- [KBO 경기 결과 조회](docs/features/kbo-results.md)
- [KBL 경기 결과 가이드](docs/features/kbl-results.md)

View file

@ -0,0 +1,65 @@
# 근처 응급실 병상 상태 확인
`emergency-room-beds` 스킬은 사용자가 알려준 위치 기준으로 가까운 응급실을 찾고, E-Gen 공개 응급실 찾기 표면에서 제공하는 응급실/입원실 운영 상태 플래그를 정리한다.
## 핵심 원칙
- 위치를 자동 추적하지 않는다. 위치가 없으면 먼저 현재 위치를 질문한다.
- 데이터 출처는 NEMC/E-Gen 공개 페이지와 E-Gen nearby 응급실 목록 endpoint다.
- E-Gen nearby 목록은 응급실 운영 여부와 입원실/병상 운영 플래그를 제공하지만, 병원별 정확한 실시간 잔여 병상 수나 병상 가동률 수치를 제공하지 않는다.
- 긴급 상황에서는 결과와 별개로 119 또는 병원 대표전화 확인을 안내한다.
## 사용 예
```text
현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 응급실 상태를 찾아볼게요.
```
위치를 받으면 `emergency-room-beds` 패키지의 `searchNearbyEmergencyRoomsByLocationQuery()`를 사용한다.
## Node.js 예시
```js
const { searchNearbyEmergencyRoomsByLocationQuery } = require("emergency-room-beds");
async function main() {
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", {
limit: 3,
radius: 5
});
console.log(result.anchor);
console.log(result.items.map((item) => ({
name: item.name,
distanceKm: item.distanceKm,
emergencyRoomOperating: item.bedStatus.emergencyRoomOperating,
inpatientBedsOperating: item.bedStatus.inpatientBedsOperating,
updatedAt: item.updatedAt,
phone: item.phone,
mapUrl: item.mapUrl
})));
console.log(result.meta.bedCountLimitation);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## 응답 필드
- 병원명, 거리, 응급의료기관 등급, 병원 유형
- 응급실 운영 여부 (`emergencyRoomOperating`)
- 입원실/병상 운영 플래그 (`inpatientBedsOperating`)
- 권역외상센터/소아전문/소아야간진료 여부
- 주소, 대표전화, 갱신시각, 지도 링크
- 공개 데이터 한계 문구: 정확한 실시간 잔여 병상 수/가동률 미제공
## 참고 표면
- NEMC 모니터링: <https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do>
- E-Gen 응급실 찾기: <https://www.e-gen.or.kr/egen/search_emergency_room.do>
- E-Gen nearby endpoint: `https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`

View file

@ -75,6 +75,7 @@ npx --yes skills add <owner/repo> \
--skill korea-weather \
--skill cheap-gas-nearby \
--skill public-restroom-nearby \
--skill emergency-room-beds \
--skill fine-dust-location \
--skill han-river-water-level \
--skill subway-lost-property \

View file

@ -0,0 +1,92 @@
---
name: emergency-room-beds
description: Use when the user asks for nearby Korean emergency rooms, 응급실, ER, or emergency bed/병상 status near a location. Ask for the user's current location first unless a location was already provided.
license: MIT
metadata:
category: health
locale: ko-KR
phase: v1
---
# Emergency Room Beds
## What this skill does
사용자가 알려준 현재 위치를 기준으로 **근처 응급실**과 공개 E-Gen 응급실 상태 플래그를 찾는다.
- 위치는 자동 추정하지 않는다.
- 위치가 없으면 먼저 현재 위치를 묻는다.
- 위치 문자열은 Kakao Map anchor 검색으로 좌표를 잡는다.
- 응급실 목록은 E-Gen 공개 응급실 찾기 표면을 사용한다.
- 응급실 운영 여부, 입원실/병상 운영 플래그, 권역외상센터/소아전문 여부, 데이터 갱신시각을 보여준다.
- **정확한 실시간 잔여 병상 수나 병상 가동률을 확정해서 말하지 않는다.** 공개 E-Gen nearby 목록은 병상 수치가 아니라 운영 플래그를 제공한다.
## When to use
- "근처 응급실 찾아줘"
- "응급실 병상 상태 확인해줘"
- "광화문 주변 응급실 어디가 가까워?"
- "현재 위치 근처 응급실 운영 여부 알려줘"
## Mandatory first question
위치 정보가 없으면 먼저 물어본다.
`현재 위치를 알려주세요. 동네/역명/랜드마크/위도·경도 중 편한 형식으로 보내주시면 근처 응급실 상태를 찾아볼게요.`
## Official/public surfaces
- NEMC 모니터링: `https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do`
- E-Gen 응급실 찾기: `https://www.e-gen.or.kr/egen/search_emergency_room.do`
- E-Gen nearby list endpoint: `https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do`
- Kakao Map 모바일 검색: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map 장소 패널 JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
## Workflow
1. 사용자의 현재 위치를 확보한다.
2. `emergency-room-beds` 패키지의 `searchNearbyEmergencyRoomsByLocationQuery()`를 사용한다.
3. 보통 3~5개 이내로 거리순 결과를 정리한다.
4. 반드시 "공개 E-Gen nearby 목록 기준이며 정확한 잔여 병상 수/가동률은 제공되지 않는다"고 밝힌다.
5. 긴급 상황이면 119 또는 병원 전화 확인을 권한다.
## Responding
결과는 짧고 실용적으로 정리한다.
- 병원명 / 거리
- 응급의료기관 등급 / 병원 유형
- 응급실 운영 여부
- 입원실/병상 운영 플래그
- 권역외상센터/소아전문 여부
- 주소 / 대표전화
- 갱신시각
- 지도 링크
## Node.js example
```js
const { searchNearbyEmergencyRoomsByLocationQuery } = require("emergency-room-beds");
async function main() {
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", {
limit: 3,
radius: 5
});
console.log(result.anchor);
console.log(result.items);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Done when
- 위치 기준 anchor를 확인했다.
- 가까운 응급실을 찾았거나, 못 찾은 이유와 다음 검색 범위를 제시했다.
- 공개 데이터의 한계(정확한 잔여 병상 수/가동률 미제공)를 명확히 밝혔다.
- 긴급 상황에서는 119/전화 확인 안내를 포함했다.

11
package-lock.json generated
View file

@ -650,6 +650,10 @@
"resolved": "packages/donation-place-search",
"link": true
},
"node_modules/emergency-room-beds": {
"resolved": "packages/emergency-room-beds",
"link": true
},
"node_modules/enquirer": {
"version": "2.4.1",
"dev": true,
@ -1791,6 +1795,13 @@
"node": ">=18"
}
},
"packages/emergency-room-beds": {
"version": "0.1.0",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"packages/gangnamunni-clinic-search": {
"version": "0.1.0",
"license": "MIT",

View file

@ -13,7 +13,7 @@
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py 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 scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh",
"typecheck": "tsc --noEmit",
"test": "python3 -m pip install --user --quiet beautifulsoup4 && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_build_manus_bundle.js && PYTHONPATH=.:scripts python3 -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ticket_availability scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts python3 -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts python3 -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && 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 market-kurly-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 public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run",
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-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 public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --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,60 @@
# emergency-room-beds
Nearby Korean emergency-room lookup backed by E-Gen's public emergency-room search surface.
## What it can and cannot report
- It resolves a user-provided location to coordinates, then calls E-Gen's public nearby emergency-room list endpoint.
- It reports distance, hospital category, address, phone, update time, and operation flags such as emergency-room operation and inpatient-bed operation.
- It does **not** claim exact real-time remaining bed counts. The public E-Gen nearby list exposes operation flags, not per-hospital remaining bed numbers.
## Public surfaces
- NEMC monitoring entry point: `https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do`
- E-Gen emergency-room search page: `https://www.e-gen.or.kr/egen/search_emergency_room.do`
- E-Gen nearby emergency-room list endpoint: `https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do`
- Kakao Map mobile search: `https://m.map.kakao.com/actions/searchView?q=<query>`
- Kakao Map place panel JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
## Usage
```js
const { searchNearbyEmergencyRoomsByLocationQuery } = require("emergency-room-beds");
async function main() {
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", {
limit: 3,
radius: 5
});
console.log(result.anchor);
console.log(result.items);
console.log(result.meta.bedCountLimitation);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});
```
## Public API
- `parseCoordinateQuery(locationQuery)`
- `buildEmergencyRoomListRequest(options)`
- `normalizeEmergencyRoomRows(payload, origin, options)`
- `searchNearbyEmergencyRoomsByCoordinates(options)`
- `searchNearbyEmergencyRoomsByLocationQuery(locationQuery, options)`
## Result fields
Each item includes:
- `name`, `emergencyGrade`, `hospitalType`
- `address`, `phone`, `latitude`, `longitude`, `distanceKm`
- `bedStatus.emergencyRoomOperating`
- `bedStatus.inpatientBedsOperating`
- `bedStatus.traumaCenter`
- `bedStatus.pediatricSpecialty`
- `bedStatus.currentGeneralCareAvailable`
- `updatedAt`, `sourceUrl`, `mapUrl`

View file

@ -0,0 +1,32 @@
{
"name": "emergency-room-beds",
"version": "0.1.0",
"description": "Public E-Gen nearby emergency room status 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",
"emergency-room",
"e-gen",
"hospital"
],
"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,259 @@
const {
normalizeAnchorPanel,
normalizeEmergencyRoomRows,
parseCoordinateQuery,
parseSearchResultsHtml,
rankAnchorCandidates
} = 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 EGEN_EMERGENCY_ROOM_LIST_URL = "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do";
const EGEN_REFERER_URL = "https://www.e-gen.or.kr/egen/search_emergency_room.do";
const BED_COUNT_LIMITATION = "E-Gen nearby ER list exposes operation flags, not exact real-time remaining bed counts.";
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/"
};
const DEFAULT_JSON_HEADERS = {
accept: "application/json, text/javascript, */*; q=0.01",
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
origin: "https://www.e-gen.or.kr",
referer: EGEN_REFERER_URL,
"user-agent": DEFAULT_BROWSER_HEADERS["user-agent"],
"x-requested-with": "XMLHttpRequest"
};
async function request(url, options = {}, responseType = "json") {
const fetchImpl = options.fetchImpl || global.fetch;
if (typeof fetchImpl !== "function") {
throw new Error("A fetch implementation is required.");
}
const response = await fetchImpl(url, {
method: options.method,
body: options.body,
headers: {
...(options.headerSet || (responseType === "json" ? DEFAULT_JSON_HEADERS : DEFAULT_BROWSER_HEADERS)),
...(options.headers || {})
},
signal: options.signal
});
if (!response.ok) {
const error = new Error(`Request failed with ${response.status} for ${url}`);
error.status = response.status;
error.url = url;
throw error;
}
return responseType === "json" ? response.json() : response.text();
}
function normalizeBoundedInteger(value, defaultValue, label, min, max) {
if (value === undefined || value === null || value === "") {
return defaultValue;
}
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
throw new Error(`${label} must be between ${min} and ${max}.`);
}
return parsed;
}
function normalizeOrder(order) {
const value = String(order || "distance").trim();
if (!["distance", "accuracy"].includes(value)) {
throw new Error("order must be one of: distance, accuracy.");
}
return value;
}
function normalizeEmergencyGradeCodes(value) {
if (Array.isArray(value)) {
return value.map((entry) => String(entry).trim()).filter(Boolean).join(",");
}
return String(value || "").trim();
}
function buildEmergencyRoomListRequest(options = {}) {
const latitude = Number(options.latitude ?? options.lat);
const longitude = Number(options.longitude ?? options.lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("latitude and longitude must be finite numbers.");
}
const radius = normalizeBoundedInteger(options.radius ?? options.maxDistanceKm, 3, "radius", 1, 50);
const currentPageNum = normalizeBoundedInteger(options.currentPageNum ?? options.pageNo, 1, "currentPageNum", 1, 1000);
const body = new URLSearchParams();
body.set("lat", String(latitude));
body.set("lon", String(longitude));
body.set("emoggrdcStr", normalizeEmergencyGradeCodes(options.emergencyGradeCodes ?? options.emoggrdcStr));
body.set("silson24", options.silson24 ? "Y" : "N");
body.set("emogdesc", String(options.hospitalName || options.emogdesc || "").trim());
body.set("radius", String(radius));
body.set("order", normalizeOrder(options.order));
body.set("currentPageNum", String(currentPageNum));
return {
url: options.apiBaseUrl || EGEN_EMERGENCY_ROOM_LIST_URL,
method: "POST",
body
};
}
async function fetchEmergencyRoomList(options = {}) {
const requestOptions = buildEmergencyRoomListRequest(options);
return request(
requestOptions.url,
{
...options,
method: requestOptions.method,
body: requestOptions.body,
headerSet: DEFAULT_JSON_HEADERS
},
"json",
);
}
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");
}
function isRecoverablePlacePanelError(error) {
const status = Number(error?.status);
return Number.isInteger(status) && status >= 400 && status < 600;
}
async function resolveAnchor(locationQuery, options = {}) {
const anchorSearchHtml = await fetchSearchResults(locationQuery, options);
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
const rankedCandidates = rankAnchorCandidates(locationQuery, anchorCandidates);
for (const candidate of rankedCandidates) {
let anchorPanel;
try {
anchorPanel = await fetchPlacePanel(candidate.id, options);
} catch (error) {
if (isRecoverablePlacePanelError(error)) {
continue;
}
throw error;
}
const anchor = normalizeAnchorPanel(anchorPanel, candidate);
if (Number.isFinite(anchor.latitude) && Number.isFinite(anchor.longitude)) {
return { anchor, anchorCandidates: rankedCandidates };
}
}
throw new Error(`No usable Kakao Map place panel was available for ${locationQuery}.`);
}
function buildMeta(payload, options, total) {
const limit = normalizeBoundedInteger(options.limit, 5, "limit", 1, 50);
const radius = normalizeBoundedInteger(options.radius ?? options.maxDistanceKm, 3, "radius", 1, 50);
return {
total,
upstreamTotal: payload?.paging?.totalCount ?? null,
limit,
radius,
source: "e-gen",
sourceUrl: EGEN_REFERER_URL,
dashboardUrl: "https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do",
bedCountLimitation: BED_COUNT_LIMITATION
};
}
async function searchNearbyEmergencyRoomsByCoordinates(options = {}) {
const latitude = Number(options.latitude ?? options.lat);
const longitude = Number(options.longitude ?? options.lon);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("latitude and longitude must be finite numbers.");
}
const limit = normalizeBoundedInteger(options.limit, 5, "limit", 1, 50);
const payload = await fetchEmergencyRoomList({ ...options, latitude, longitude });
const allItems = normalizeEmergencyRoomRows(payload, { latitude, longitude }, options);
return {
anchor: {
name: options.anchorName || "입력 좌표",
address: options.anchorAddress || null,
latitude,
longitude
},
items: allItems.slice(0, limit),
meta: buildMeta(payload, { ...options, limit }, allItems.length)
};
}
async function searchNearbyEmergencyRoomsByLocationQuery(locationQuery, options = {}) {
const coordinateQuery = parseCoordinateQuery(locationQuery);
if (coordinateQuery) {
return searchNearbyEmergencyRoomsByCoordinates({
...options,
...coordinateQuery,
anchorName: "입력 좌표"
});
}
const { anchor, anchorCandidates } = await resolveAnchor(locationQuery, options);
const result = await searchNearbyEmergencyRoomsByCoordinates({
...options,
latitude: anchor.latitude,
longitude: anchor.longitude,
anchorName: anchor.name,
anchorAddress: anchor.address
});
return {
...result,
anchor,
meta: {
...result.meta,
anchorCandidates: anchorCandidates.length
}
};
}
module.exports = {
BED_COUNT_LIMITATION,
DEFAULT_JSON_HEADERS,
EGEN_EMERGENCY_ROOM_LIST_URL,
buildEmergencyRoomListRequest,
fetchEmergencyRoomList,
normalizeEmergencyRoomRows,
parseCoordinateQuery,
searchNearbyEmergencyRoomsByCoordinates,
searchNearbyEmergencyRoomsByLocationQuery
};

View file

@ -0,0 +1,266 @@
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;
function decodeHtml(value) {
return String(value || "")
.replace(/&amp;/g, "&")
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">");
}
function stripTags(value) {
return decodeHtml(String(value || "").replace(TAG_PATTERN, " "))
.replace(/\s+/g, " ")
.trim();
}
function normalizeText(value) {
return String(value || "")
.normalize("NFKC")
.toLowerCase()
.replace(NON_WORD_PATTERN, "");
}
function extractAttribute(fragment, name) {
const match = fragment.match(new RegExp(`${name}="([^"]*)"`, "iu"));
return match ? decodeHtml(match[1]).trim() : "";
}
function extractInnerText(fragment, className) {
const match = fragment.match(
new RegExp(`<[^>]+class="[^"]*${className}[^"]*"[^>]*>([\\s\\S]*?)<\\/[^>]+>`, "iu"),
);
return match ? stripTags(match[1]) : "";
}
function parseSearchResultsHtml(html) {
const items = [];
let match;
while ((match = SEARCH_ITEM_PATTERN.exec(String(html || ""))) !== null) {
const fragment = match[1];
const id = extractAttribute(fragment, "data-id");
const name = extractAttribute(fragment, "data-title") || extractInnerText(fragment, "tit_g");
if (!id || !name) {
continue;
}
const addressMatches = [...fragment.matchAll(/<span class="txt_g">([\s\S]*?)<\/span>/giu)]
.map((entry) => stripTags(entry[1]))
.filter(Boolean);
items.push({
id,
name,
category: extractInnerText(fragment, "txt_ginfo"),
address: addressMatches.at(-1) || "",
phone: extractAttribute(fragment, "data-phone") || extractInnerText(fragment, "num_phone") || null
});
}
return items;
}
function scoreAnchorCandidate(query, item) {
const normalizedQuery = normalizeText(query);
const normalizedName = normalizeText(item.name);
const normalizedAddress = normalizeText(item.address);
let score = 0;
if (!normalizedQuery) {
return score;
}
if (normalizedName === normalizedQuery) {
score += 1000;
}
if (normalizedName.startsWith(normalizedQuery)) {
score += 800;
}
if (normalizedName.includes(normalizedQuery)) {
score += 600;
}
if (normalizedAddress.includes(normalizedQuery)) {
score += 120;
}
return score;
}
function rankAnchorCandidates(query, items) {
return [...(items || [])].sort((left, right) => {
const scoreDelta = scoreAnchorCandidate(query, right) - scoreAnchorCandidate(query, left);
if (scoreDelta !== 0) {
return scoreDelta;
}
return left.name.localeCompare(right.name, "ko");
});
}
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: toNumber(summary.point?.lat),
longitude: toNumber(summary.point?.lon),
sourceUrl: summary.confirm_id ? `https://place.map.kakao.com/${summary.confirm_id}` : null
};
}
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])
};
}
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 toBooleanYesNo(value) {
return String(value || "").trim().toUpperCase() === "Y";
}
function buildMapUrl(name, latitude, longitude) {
return `https://map.kakao.com/link/map/${encodeURIComponent(name)},${latitude},${longitude}`;
}
function parseEgenTimestamp(value) {
const text = String(value || "").trim();
const match = text.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/);
if (!match) {
return null;
}
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}+09:00`;
}
function haversineDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB) {
const earthRadiusMeters = 6371008.8;
const toRadians = (value) => (value * Math.PI) / 180;
const deltaLatitude = toRadians(latitudeB - latitudeA);
const deltaLongitude = toRadians(longitudeB - longitudeA);
const originLatitude = toRadians(latitudeA);
const targetLatitude = toRadians(latitudeB);
const value =
Math.sin(deltaLatitude / 2) ** 2 +
Math.cos(originLatitude) * Math.cos(targetLatitude) * Math.sin(deltaLongitude / 2) ** 2;
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
}
function getEmergencyRoomRows(payload) {
if (Array.isArray(payload)) {
return payload;
}
if (Array.isArray(payload?.list)) {
return payload.list;
}
return [];
}
function normalizeEmergencyRoomRows(payload, origin, options = {}) {
const latitude = Number(origin?.latitude);
const longitude = Number(origin?.longitude);
const radius = Number.isFinite(Number(options.radius ?? options.maxDistanceKm)) ? Number(options.radius ?? options.maxDistanceKm) : null;
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
throw new Error("normalizeEmergencyRoomRows requires finite origin coordinates.");
}
return getEmergencyRoomRows(payload)
.map((row) => {
const itemLatitude = toNumber(row.LAT ?? row.lat);
const itemLongitude = toNumber(row.LON ?? row.lon);
if (!Number.isFinite(itemLatitude) || !Number.isFinite(itemLongitude)) {
return null;
}
const distanceKm = toNumber(row.DISTANCE2 ?? row.DISTANCE) ?? haversineDistanceMeters(latitude, longitude, itemLatitude, itemLongitude) / 1000;
const name = String(row.TITLE || row.name || "").trim();
if (!name) {
return null;
}
return {
id: String(row.EMOGCODE || row.id || ""),
name,
emergencyGrade: row.CATEGORY1 || null,
hospitalType: row.CATEGORY2 || null,
address: row.ADDRROAD || row.ADDRLAGE || null,
phone: row.TEL || null,
latitude: itemLatitude,
longitude: itemLongitude,
distanceKm: Math.round(distanceKm * 1000) / 1000,
bedStatus: {
emergencyRoomOperating: toBooleanYesNo(row.EMOGERYN),
inpatientBedsOperating: toBooleanYesNo(row.EMOGPRYN),
traumaCenter: toBooleanYesNo(row.EMOGTRYN),
pediatricSpecialty: toBooleanYesNo(row.CHILD_SPCLTY_AT),
currentGeneralCareAvailable: toBooleanYesNo(row.OPERATIONYN),
pediatricNightCare: toBooleanYesNo(row.NIGHTCAREYN),
holidayOpen: toBooleanYesNo(row.HOLIDAYYN),
silson24Linked: toBooleanYesNo(row.SILSON24_CHK)
},
schedules: {
monday: row.MONDAY || null,
tuesday: row.TUESDAY || null,
wednesday: row.WEDNESDAY || null,
thursday: row.THURSDAY || null,
friday: row.FRIDAY || null,
saturday: row.SATURDAY || null,
sunday: row.SUNDAY || null,
holiday: row.HOLIDAY || null,
note: row.OPN_BIGO || null
},
updatedAt: parseEgenTimestamp(row.EMOGUPDT),
sourceUrl: "https://www.e-gen.or.kr/egen/search_emergency_room.do",
mapUrl: buildMapUrl(name, itemLatitude, itemLongitude)
};
})
.filter(Boolean)
.filter((item) => radius === null || item.distanceKm <= radius)
.sort((left, right) => left.distanceKm - right.distanceKm || left.name.localeCompare(right.name, "ko"));
}
module.exports = {
buildMapUrl,
normalizeAnchorPanel,
normalizeEmergencyRoomRows,
parseCoordinateQuery,
parseEgenTimestamp,
parseSearchResultsHtml,
rankAnchorCandidates
};

View file

@ -0,0 +1,10 @@
{
"summary": {
"confirm_id": "1001",
"name": "광화문",
"category": { "name2": "관광명소", "name3": "역사유적지" },
"address": { "disp": "서울특별시 종로구 세종대로 172" },
"phone_numbers": [{ "tel": "02-120" }],
"point": { "lat": 37.57371315593711, "lon": 126.97833785777944 }
}
}

View file

@ -0,0 +1,7 @@
<ul>
<li class="search_item base" data-id="1001" data-title="광화문">
<strong class="tit_g">광화문</strong>
<span class="txt_ginfo">역사유적지</span>
<span class="txt_g">서울특별시 종로구 세종대로 172</span>
</li>
</ul>

View file

@ -0,0 +1,75 @@
{
"list": [
{
"CATEGORY1": "지역응급의료센터",
"CATEGORY2": "상급종합병원",
"TITLE": "강북삼성병원",
"EMOGCODE": "A1100006",
"ADDRROAD": "서울특별시 종로구 새문안로 29 (평동)",
"TEL": "02-2001-2001",
"LAT": "37.568497631233",
"LON": "126.967938054517",
"DISTANCE": 1,
"DISTANCE2": 1.004,
"EMOGERYN": "Y",
"EMOGPRYN": "Y",
"EMOGTRYN": null,
"CHILD_SPCLTY_AT": null,
"OPERATIONYN": "N",
"NIGHTCAREYN": "N",
"HOLIDAYYN": "N",
"SILSON24_CHK": "Y",
"EMOGUPDT": "20260311142633",
"MONDAY": "08:30~17:00",
"TUESDAY": "08:30~17:00",
"WEDNESDAY": "08:30~17:00",
"THURSDAY": "08:30~17:00",
"FRIDAY": "08:30~17:00",
"SATURDAY": "08:30~12:30"
},
{
"CATEGORY1": "권역응급의료센터",
"CATEGORY2": "상급종합병원",
"TITLE": "서울대학교병원",
"EMOGCODE": "A1100017",
"ADDRROAD": "서울특별시 종로구 대학로 101 (연건동)",
"TEL": "02-1588-5700",
"LAT": "37.579666089243",
"LON": "126.998963084121",
"DISTANCE": 2.4,
"DISTANCE2": 2.447,
"EMOGERYN": "Y",
"EMOGPRYN": "Y",
"EMOGTRYN": null,
"CHILD_SPCLTY_AT": "Y",
"OPERATIONYN": "Y",
"NIGHTCAREYN": "N",
"HOLIDAYYN": "N",
"SILSON24_CHK": "Y",
"EMOGUPDT": "20260504090610",
"MONDAY": "08:00~18:00",
"TUESDAY": "08:00~18:00",
"WEDNESDAY": "08:00~18:00",
"THURSDAY": "08:00~18:00",
"FRIDAY": "08:00~18:00",
"SATURDAY": "08:00~13:30"
},
{
"CATEGORY1": "지역응급의료기관",
"CATEGORY2": "종합병원",
"TITLE": "반경밖병원",
"EMOGCODE": "A9999999",
"ADDRROAD": "서울특별시 강남구 테헤란로 1",
"TEL": "02-0000-0000",
"LAT": "37.500000",
"LON": "127.100000",
"DISTANCE": 15,
"DISTANCE2": 15.1,
"EMOGERYN": "N",
"EMOGPRYN": "N",
"OPERATIONYN": "N",
"EMOGUPDT": "20250101010101"
}
],
"paging": { "totalCount": 3, "currentPageNum": 1 }
}

View file

@ -0,0 +1,172 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const {
buildEmergencyRoomListRequest,
normalizeEmergencyRoomRows,
parseCoordinateQuery,
searchNearbyEmergencyRoomsByCoordinates,
searchNearbyEmergencyRoomsByLocationQuery
} = 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 emergencyRoomList = JSON.parse(fs.readFileSync(path.join(fixturesDir, "emergency-room-list.json"), "utf8"));
const ORIGIN = {
latitude: 37.57371315593711,
longitude: 126.97833785777944
};
test("parseCoordinateQuery recognizes latitude/longitude pairs", () => {
assert.deepEqual(parseCoordinateQuery("37.573713, 126.978338"), {
latitude: 37.573713,
longitude: 126.978338
});
assert.equal(parseCoordinateQuery("광화문"), null);
});
test("buildEmergencyRoomListRequest targets E-Gen's public nearby ER endpoint", () => {
const request = buildEmergencyRoomListRequest({
...ORIGIN,
radius: 10,
order: "accuracy",
currentPageNum: 2,
emergencyGradeCodes: ["A", "C"],
hospitalName: "서울"
});
assert.equal(request.url, "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do");
assert.equal(request.method, "POST");
assert.equal(request.body.get("lat"), String(ORIGIN.latitude));
assert.equal(request.body.get("lon"), String(ORIGIN.longitude));
assert.equal(request.body.get("radius"), "10");
assert.equal(request.body.get("order"), "accuracy");
assert.equal(request.body.get("currentPageNum"), "2");
assert.equal(request.body.get("emoggrdcStr"), "A,C");
assert.equal(request.body.get("emogdesc"), "서울");
});
test("normalizeEmergencyRoomRows exposes nearby ER and inpatient bed operation flags", () => {
const items = normalizeEmergencyRoomRows(emergencyRoomList, ORIGIN, { radius: 5 });
assert.equal(items.length, 2);
assert.deepEqual(items.map((item) => [item.id, item.name, item.emergencyGrade, item.distanceKm]), [
["A1100006", "강북삼성병원", "지역응급의료센터", 1.004],
["A1100017", "서울대학교병원", "권역응급의료센터", 2.447]
]);
assert.deepEqual(items[0].bedStatus, {
emergencyRoomOperating: true,
inpatientBedsOperating: true,
traumaCenter: false,
pediatricSpecialty: false,
currentGeneralCareAvailable: false,
pediatricNightCare: false,
holidayOpen: false,
silson24Linked: true
});
assert.equal(items[1].bedStatus.pediatricSpecialty, true);
assert.equal(items[0].updatedAt, "2026-03-11T14:26:33+09:00");
assert.equal(items[0].mapUrl, "https://map.kakao.com/link/map/%EA%B0%95%EB%B6%81%EC%82%BC%EC%84%B1%EB%B3%91%EC%9B%90,37.568497631233,126.967938054517");
});
test("searchNearbyEmergencyRoomsByCoordinates posts to E-Gen and returns normalized items", async () => {
const calls = [];
const fetchImpl = async (url, options) => {
calls.push({ url: String(url), options });
return makeResponse(emergencyRoomList);
};
const result = await searchNearbyEmergencyRoomsByCoordinates({
...ORIGIN,
limit: 1,
radius: 5,
fetchImpl
});
assert.equal(result.items.length, 1);
assert.equal(result.items[0].name, "강북삼성병원");
assert.equal(result.meta.source, "e-gen");
assert.equal(result.meta.bedCountLimitation, "E-Gen nearby ER list exposes operation flags, not exact real-time remaining bed counts.");
assert.equal(calls[0].url, "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do");
assert.equal(calls[0].options.method, "POST");
assert.equal(calls[0].options.body.get("radius"), "5");
});
test("searchNearbyEmergencyRoomsByLocationQuery resolves a Kakao anchor before querying E-Gen", async () => {
const calls = [];
const fetchImpl = async (url, options = {}) => {
const resolved = String(url);
calls.push({ url: resolved, options });
if (resolved.startsWith("https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8")) {
return makeResponse(anchorSearchHtml, "text/html");
}
if (resolved === "https://place-api.map.kakao.com/places/panel3/1001") {
return makeResponse(anchorPanel, "application/json");
}
if (resolved === "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do") {
assert.equal(options.body.get("lat"), String(ORIGIN.latitude));
assert.equal(options.body.get("lon"), String(ORIGIN.longitude));
return makeResponse(emergencyRoomList);
}
throw new Error(`unexpected url: ${resolved}`);
};
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", {
limit: 2,
radius: 5,
fetchImpl
});
assert.equal(result.anchor.name, "광화문");
assert.equal(result.anchor.address, "서울특별시 종로구 세종대로 172");
assert.equal(result.items.length, 2);
assert.deepEqual(calls.map((call) => call.url), [
"https://m.map.kakao.com/actions/searchView?q=%EA%B4%91%ED%99%94%EB%AC%B8",
"https://place-api.map.kakao.com/places/panel3/1001",
"https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do"
]);
});
test("searchNearbyEmergencyRoomsByCoordinates validates bounded inputs", async () => {
await assert.rejects(
searchNearbyEmergencyRoomsByCoordinates({ latitude: "x", longitude: 126.9 }),
/latitude and longitude must be finite numbers/
);
await assert.rejects(
searchNearbyEmergencyRoomsByCoordinates({ ...ORIGIN, limit: 0 }),
/limit must be between 1 and 50/
);
await assert.rejects(
searchNearbyEmergencyRoomsByCoordinates({ ...ORIGIN, radius: 0 }),
/radius must be between 1 and 50/
);
});
function makeResponse(body, contentType = "application/json;charset=UTF-8") {
return {
ok: true,
status: 200,
headers: {
get(name) {
if (String(name).toLowerCase() === "content-type") {
return contentType;
}
return null;
}
},
async text() {
return typeof body === "string" ? body : JSON.stringify(body);
},
async json() {
return typeof body === "string" ? JSON.parse(body) : body;
}
};
}