mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
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:
parent
5591502f9e
commit
4e2d1faf19
15 changed files with 1058 additions and 1 deletions
5
.changeset/issue-255-emergency-room-beds.md
Normal file
5
.changeset/issue-255-emergency-room-beds.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
"emergency-room-beds": minor
|
||||
---
|
||||
|
||||
Add an E-Gen based nearby emergency-room status skill and package.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
65
docs/features/emergency-room-beds.md
Normal file
65
docs/features/emergency-room-beds.md
Normal 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>`
|
||||
|
|
@ -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 \
|
||||
|
|
|
|||
92
emergency-room-beds/SKILL.md
Normal file
92
emergency-room-beds/SKILL.md
Normal 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
11
package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
60
packages/emergency-room-beds/README.md
Normal file
60
packages/emergency-room-beds/README.md
Normal 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`
|
||||
32
packages/emergency-room-beds/package.json
Normal file
32
packages/emergency-room-beds/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
259
packages/emergency-room-beds/src/index.js
Normal file
259
packages/emergency-room-beds/src/index.js
Normal 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
|
||||
};
|
||||
266
packages/emergency-room-beds/src/parse.js
Normal file
266
packages/emergency-room-beds/src/parse.js
Normal 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(/&/g, "&")
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function stripTags(value) {
|
||||
return decodeHtml(String(value || "").replace(TAG_PATTERN, " "))
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || "")
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(NON_WORD_PATTERN, "");
|
||||
}
|
||||
|
||||
function extractAttribute(fragment, name) {
|
||||
const match = fragment.match(new RegExp(`${name}="([^"]*)"`, "iu"));
|
||||
return match ? decodeHtml(match[1]).trim() : "";
|
||||
}
|
||||
|
||||
function extractInnerText(fragment, className) {
|
||||
const match = fragment.match(
|
||||
new RegExp(`<[^>]+class="[^"]*${className}[^"]*"[^>]*>([\\s\\S]*?)<\\/[^>]+>`, "iu"),
|
||||
);
|
||||
|
||||
return match ? stripTags(match[1]) : "";
|
||||
}
|
||||
|
||||
function parseSearchResultsHtml(html) {
|
||||
const items = [];
|
||||
let match;
|
||||
|
||||
while ((match = SEARCH_ITEM_PATTERN.exec(String(html || ""))) !== null) {
|
||||
const fragment = match[1];
|
||||
const id = extractAttribute(fragment, "data-id");
|
||||
const name = extractAttribute(fragment, "data-title") || extractInnerText(fragment, "tit_g");
|
||||
|
||||
if (!id || !name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const addressMatches = [...fragment.matchAll(/<span class="txt_g">([\s\S]*?)<\/span>/giu)]
|
||||
.map((entry) => stripTags(entry[1]))
|
||||
.filter(Boolean);
|
||||
|
||||
items.push({
|
||||
id,
|
||||
name,
|
||||
category: extractInnerText(fragment, "txt_ginfo"),
|
||||
address: addressMatches.at(-1) || "",
|
||||
phone: extractAttribute(fragment, "data-phone") || extractInnerText(fragment, "num_phone") || 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
|
||||
};
|
||||
10
packages/emergency-room-beds/test/fixtures/anchor-panel.json
vendored
Normal file
10
packages/emergency-room-beds/test/fixtures/anchor-panel.json
vendored
Normal 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 }
|
||||
}
|
||||
}
|
||||
7
packages/emergency-room-beds/test/fixtures/anchor-search.html
vendored
Normal file
7
packages/emergency-room-beds/test/fixtures/anchor-search.html
vendored
Normal 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>
|
||||
75
packages/emergency-room-beds/test/fixtures/emergency-room-list.json
vendored
Normal file
75
packages/emergency-room-beds/test/fixtures/emergency-room-list.json
vendored
Normal 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 }
|
||||
}
|
||||
172
packages/emergency-room-beds/test/index.test.js
Normal file
172
packages/emergency-room-beds/test/index.test.js
Normal 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;
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue