emergency-room-beds 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- package/package.json +32 -0
- package/src/index.js +275 -0
- package/src/parse.js +295 -0
package/README.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# emergency-room-beds
|
|
2
|
+
|
|
3
|
+
Nearby Korean emergency-room lookup backed by E-Gen's public emergency-room search surface.
|
|
4
|
+
|
|
5
|
+
## What it can and cannot report
|
|
6
|
+
|
|
7
|
+
- It resolves a user-provided location to coordinates, then calls E-Gen's public nearby emergency-room list endpoint.
|
|
8
|
+
- It reports distance, hospital category, address, phone, update time, and operation flags such as emergency-room operation and inpatient-bed operation.
|
|
9
|
+
- Operation flags are tri-state: `true` for upstream `Y`, `false` for upstream `N`, and `null` when E-Gen omits or changes a flag value.
|
|
10
|
+
- 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.
|
|
11
|
+
- For emergencies, call 119 or the hospital directly. Public E-Gen/Kakao data can lag, fail, or be incomplete and is not medical advice.
|
|
12
|
+
|
|
13
|
+
## Public surfaces
|
|
14
|
+
|
|
15
|
+
- NEMC monitoring entry point: `https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do`
|
|
16
|
+
- E-Gen emergency-room search page: `https://www.e-gen.or.kr/egen/search_emergency_room.do`
|
|
17
|
+
- E-Gen nearby emergency-room list endpoint: `https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do`
|
|
18
|
+
- Kakao Map mobile search: `https://m.map.kakao.com/actions/searchView?q=<query>`
|
|
19
|
+
- Kakao Map place panel JSON: `https://place-api.map.kakao.com/places/panel3/<confirmId>`
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
const { searchNearbyEmergencyRoomsByLocationQuery } = require("emergency-room-beds");
|
|
25
|
+
|
|
26
|
+
async function main() {
|
|
27
|
+
const result = await searchNearbyEmergencyRoomsByLocationQuery("광화문", {
|
|
28
|
+
limit: 3,
|
|
29
|
+
radius: 5
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
console.log(result.anchor);
|
|
33
|
+
console.log(result.items);
|
|
34
|
+
console.log(result.meta.bedCountLimitation);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
main().catch((error) => {
|
|
38
|
+
console.error(error);
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
});
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## Public API
|
|
44
|
+
|
|
45
|
+
- `parseCoordinateQuery(locationQuery)`
|
|
46
|
+
- `buildEmergencyRoomListRequest(options)`
|
|
47
|
+
- `normalizeEmergencyRoomRows(payload, origin, options)`
|
|
48
|
+
- `searchNearbyEmergencyRoomsByCoordinates(options)`
|
|
49
|
+
- `searchNearbyEmergencyRoomsByLocationQuery(locationQuery, options)`
|
|
50
|
+
|
|
51
|
+
## Result fields
|
|
52
|
+
|
|
53
|
+
Each item includes:
|
|
54
|
+
|
|
55
|
+
- `name`, `emergencyGrade`, `hospitalType`
|
|
56
|
+
- `address`, `phone`, `latitude`, `longitude`, `distanceKm`
|
|
57
|
+
- `bedStatus.emergencyRoomOperating`
|
|
58
|
+
- `bedStatus.inpatientBedsOperating`
|
|
59
|
+
- `bedStatus.traumaCenter`
|
|
60
|
+
- `bedStatus.pediatricSpecialty`
|
|
61
|
+
- `bedStatus.currentGeneralCareAvailable`
|
|
62
|
+
- `updatedAt`, `sourceUrl`, `mapUrl`
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "emergency-room-beds",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Public E-Gen nearby emergency room status lookup for Korean location queries",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md"
|
|
10
|
+
],
|
|
11
|
+
"engines": {
|
|
12
|
+
"node": ">=18"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/NomaDamas/k-skill.git"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"k-skill",
|
|
23
|
+
"korea",
|
|
24
|
+
"emergency-room",
|
|
25
|
+
"e-gen",
|
|
26
|
+
"hospital"
|
|
27
|
+
],
|
|
28
|
+
"scripts": {
|
|
29
|
+
"lint": "node --check src/index.js && node --check src/parse.js && node --check test/index.test.js",
|
|
30
|
+
"test": "node --test"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
const {
|
|
2
|
+
isValidLatitude,
|
|
3
|
+
isValidLongitude,
|
|
4
|
+
normalizeAnchorPanel,
|
|
5
|
+
normalizeEmergencyRoomRows,
|
|
6
|
+
parseCoordinateQuery,
|
|
7
|
+
parseSearchResultsHtml,
|
|
8
|
+
rankAnchorCandidates
|
|
9
|
+
} = require("./parse");
|
|
10
|
+
|
|
11
|
+
const SEARCH_VIEW_URL = "https://m.map.kakao.com/actions/searchView";
|
|
12
|
+
const PLACE_PANEL_URL_BASE = "https://place-api.map.kakao.com/places/panel3";
|
|
13
|
+
const EGEN_EMERGENCY_ROOM_LIST_URL = "https://www.e-gen.or.kr/egen/retrieve_emergency_room_list.do";
|
|
14
|
+
const EGEN_REFERER_URL = "https://www.e-gen.or.kr/egen/search_emergency_room.do";
|
|
15
|
+
const BED_COUNT_LIMITATION = "E-Gen nearby ER list exposes operation flags, not exact real-time remaining bed counts.";
|
|
16
|
+
const DEFAULT_BROWSER_HEADERS = {
|
|
17
|
+
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
|
18
|
+
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
|
|
19
|
+
"user-agent":
|
|
20
|
+
"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"
|
|
21
|
+
};
|
|
22
|
+
const DEFAULT_PANEL_HEADERS = {
|
|
23
|
+
...DEFAULT_BROWSER_HEADERS,
|
|
24
|
+
accept: "application/json, text/plain, */*",
|
|
25
|
+
appVersion: "6.6.0",
|
|
26
|
+
origin: "https://place.map.kakao.com",
|
|
27
|
+
pf: "PC",
|
|
28
|
+
referer: "https://place.map.kakao.com/"
|
|
29
|
+
};
|
|
30
|
+
const DEFAULT_JSON_HEADERS = {
|
|
31
|
+
accept: "application/json, text/javascript, */*; q=0.01",
|
|
32
|
+
"accept-language": "ko,en-US;q=0.9,en;q=0.8",
|
|
33
|
+
"content-type": "application/x-www-form-urlencoded; charset=UTF-8",
|
|
34
|
+
origin: "https://www.e-gen.or.kr",
|
|
35
|
+
referer: EGEN_REFERER_URL,
|
|
36
|
+
"user-agent": DEFAULT_BROWSER_HEADERS["user-agent"],
|
|
37
|
+
"x-requested-with": "XMLHttpRequest"
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
async function request(url, options = {}, responseType = "json") {
|
|
41
|
+
const fetchImpl = options.fetchImpl || global.fetch;
|
|
42
|
+
|
|
43
|
+
if (typeof fetchImpl !== "function") {
|
|
44
|
+
throw new Error("A fetch implementation is required.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const response = await fetchImpl(url, {
|
|
48
|
+
method: options.method,
|
|
49
|
+
body: options.body,
|
|
50
|
+
headers: {
|
|
51
|
+
...(options.headerSet || (responseType === "json" ? DEFAULT_JSON_HEADERS : DEFAULT_BROWSER_HEADERS)),
|
|
52
|
+
...(options.headers || {})
|
|
53
|
+
},
|
|
54
|
+
signal: options.signal
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
if (!response.ok) {
|
|
58
|
+
const error = new Error(`Request failed with ${response.status} for ${url}`);
|
|
59
|
+
error.status = response.status;
|
|
60
|
+
error.url = url;
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return responseType === "json" ? response.json() : response.text();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function normalizeBoundedInteger(value, defaultValue, label, min, max) {
|
|
68
|
+
if (value === undefined || value === null || value === "") {
|
|
69
|
+
return defaultValue;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const parsed = Number.parseInt(value, 10);
|
|
73
|
+
if (!Number.isFinite(parsed) || parsed < min || parsed > max) {
|
|
74
|
+
throw new Error(`${label} must be between ${min} and ${max}.`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return parsed;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function normalizeCoordinate(value, label, isValid) {
|
|
81
|
+
const parsed = Number(value);
|
|
82
|
+
|
|
83
|
+
if (!isValid(parsed)) {
|
|
84
|
+
throw new Error(`${label} must be between ${label === "latitude" ? "-90 and 90" : "-180 and 180"}.`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return parsed;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function normalizeCoordinates(options = {}) {
|
|
91
|
+
const latitude = Number(options.latitude ?? options.lat);
|
|
92
|
+
const longitude = Number(options.longitude ?? options.lon);
|
|
93
|
+
|
|
94
|
+
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) {
|
|
95
|
+
throw new Error("latitude and longitude must be finite numbers.");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
latitude: normalizeCoordinate(latitude, "latitude", isValidLatitude),
|
|
100
|
+
longitude: normalizeCoordinate(longitude, "longitude", isValidLongitude)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeOrder(order) {
|
|
105
|
+
const value = String(order || "distance").trim();
|
|
106
|
+
|
|
107
|
+
if (!["distance", "accuracy"].includes(value)) {
|
|
108
|
+
throw new Error("order must be one of: distance, accuracy.");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return value;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizeEmergencyGradeCodes(value) {
|
|
115
|
+
if (Array.isArray(value)) {
|
|
116
|
+
return value.map((entry) => String(entry).trim()).filter(Boolean).join(",");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return String(value || "").trim();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function buildEmergencyRoomListRequest(options = {}) {
|
|
123
|
+
const { latitude, longitude } = normalizeCoordinates(options);
|
|
124
|
+
|
|
125
|
+
const radius = normalizeBoundedInteger(options.radius ?? options.maxDistanceKm, 3, "radius", 1, 50);
|
|
126
|
+
const currentPageNum = normalizeBoundedInteger(options.currentPageNum ?? options.pageNo, 1, "currentPageNum", 1, 1000);
|
|
127
|
+
const body = new URLSearchParams();
|
|
128
|
+
body.set("lat", String(latitude));
|
|
129
|
+
body.set("lon", String(longitude));
|
|
130
|
+
body.set("emoggrdcStr", normalizeEmergencyGradeCodes(options.emergencyGradeCodes ?? options.emoggrdcStr));
|
|
131
|
+
body.set("silson24", options.silson24 ? "Y" : "N");
|
|
132
|
+
body.set("emogdesc", String(options.hospitalName || options.emogdesc || "").trim());
|
|
133
|
+
body.set("radius", String(radius));
|
|
134
|
+
body.set("order", normalizeOrder(options.order));
|
|
135
|
+
body.set("currentPageNum", String(currentPageNum));
|
|
136
|
+
|
|
137
|
+
return {
|
|
138
|
+
url: options.apiBaseUrl || EGEN_EMERGENCY_ROOM_LIST_URL,
|
|
139
|
+
method: "POST",
|
|
140
|
+
body
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function fetchEmergencyRoomList(options = {}) {
|
|
145
|
+
const requestOptions = buildEmergencyRoomListRequest(options);
|
|
146
|
+
|
|
147
|
+
return request(
|
|
148
|
+
requestOptions.url,
|
|
149
|
+
{
|
|
150
|
+
...options,
|
|
151
|
+
method: requestOptions.method,
|
|
152
|
+
body: requestOptions.body,
|
|
153
|
+
headerSet: DEFAULT_JSON_HEADERS
|
|
154
|
+
},
|
|
155
|
+
"json",
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
async function fetchSearchResults(query, options = {}) {
|
|
160
|
+
const url = new URL(SEARCH_VIEW_URL);
|
|
161
|
+
url.searchParams.set("q", String(query || "").trim());
|
|
162
|
+
|
|
163
|
+
return request(url.toString(), options, "text");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function fetchPlacePanel(confirmId, options = {}) {
|
|
167
|
+
return request(`${PLACE_PANEL_URL_BASE}/${confirmId}`, { ...options, headerSet: DEFAULT_PANEL_HEADERS }, "json");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isRecoverablePlacePanelError(error) {
|
|
171
|
+
const status = Number(error?.status);
|
|
172
|
+
return status === 404 || status === 410;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function resolveAnchor(locationQuery, options = {}) {
|
|
176
|
+
const anchorSearchHtml = await fetchSearchResults(locationQuery, options);
|
|
177
|
+
const anchorCandidates = parseSearchResultsHtml(anchorSearchHtml);
|
|
178
|
+
const rankedCandidates = rankAnchorCandidates(locationQuery, anchorCandidates);
|
|
179
|
+
|
|
180
|
+
for (const candidate of rankedCandidates) {
|
|
181
|
+
let anchorPanel;
|
|
182
|
+
try {
|
|
183
|
+
anchorPanel = await fetchPlacePanel(candidate.id, options);
|
|
184
|
+
} catch (error) {
|
|
185
|
+
if (isRecoverablePlacePanelError(error)) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
throw error;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const anchor = normalizeAnchorPanel(anchorPanel, candidate);
|
|
192
|
+
if (Number.isFinite(anchor.latitude) && Number.isFinite(anchor.longitude)) {
|
|
193
|
+
return { anchor, anchorCandidates: rankedCandidates };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new Error(`No usable Kakao Map place panel was available for ${locationQuery}.`);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function buildMeta(payload, options, total) {
|
|
201
|
+
const limit = normalizeBoundedInteger(options.limit, 5, "limit", 1, 50);
|
|
202
|
+
const radius = normalizeBoundedInteger(options.radius ?? options.maxDistanceKm, 3, "radius", 1, 50);
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
total,
|
|
206
|
+
upstreamTotal: payload?.paging?.totalCount ?? null,
|
|
207
|
+
limit,
|
|
208
|
+
radius,
|
|
209
|
+
source: "e-gen",
|
|
210
|
+
sourceUrl: EGEN_REFERER_URL,
|
|
211
|
+
dashboardUrl: "https://dw.nemc.or.kr/nemcMonitoring/mainmgr/Main.do",
|
|
212
|
+
bedCountLimitation: BED_COUNT_LIMITATION
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function searchNearbyEmergencyRoomsByCoordinates(options = {}) {
|
|
217
|
+
const { latitude, longitude } = normalizeCoordinates(options);
|
|
218
|
+
|
|
219
|
+
const limit = normalizeBoundedInteger(options.limit, 5, "limit", 1, 50);
|
|
220
|
+
const payload = await fetchEmergencyRoomList({ ...options, latitude, longitude });
|
|
221
|
+
const allItems = normalizeEmergencyRoomRows(payload, { latitude, longitude }, options);
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
anchor: {
|
|
225
|
+
name: options.anchorName || "입력 좌표",
|
|
226
|
+
address: options.anchorAddress || null,
|
|
227
|
+
latitude,
|
|
228
|
+
longitude
|
|
229
|
+
},
|
|
230
|
+
items: allItems.slice(0, limit),
|
|
231
|
+
meta: buildMeta(payload, { ...options, limit }, allItems.length)
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function searchNearbyEmergencyRoomsByLocationQuery(locationQuery, options = {}) {
|
|
236
|
+
const coordinateQuery = parseCoordinateQuery(locationQuery);
|
|
237
|
+
|
|
238
|
+
if (coordinateQuery) {
|
|
239
|
+
return searchNearbyEmergencyRoomsByCoordinates({
|
|
240
|
+
...options,
|
|
241
|
+
...coordinateQuery,
|
|
242
|
+
anchorName: "입력 좌표"
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const { anchor, anchorCandidates } = await resolveAnchor(locationQuery, options);
|
|
247
|
+
const result = await searchNearbyEmergencyRoomsByCoordinates({
|
|
248
|
+
...options,
|
|
249
|
+
latitude: anchor.latitude,
|
|
250
|
+
longitude: anchor.longitude,
|
|
251
|
+
anchorName: anchor.name,
|
|
252
|
+
anchorAddress: anchor.address
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
...result,
|
|
257
|
+
anchor,
|
|
258
|
+
meta: {
|
|
259
|
+
...result.meta,
|
|
260
|
+
anchorCandidates: anchorCandidates.length
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
module.exports = {
|
|
266
|
+
BED_COUNT_LIMITATION,
|
|
267
|
+
DEFAULT_JSON_HEADERS,
|
|
268
|
+
EGEN_EMERGENCY_ROOM_LIST_URL,
|
|
269
|
+
buildEmergencyRoomListRequest,
|
|
270
|
+
fetchEmergencyRoomList,
|
|
271
|
+
normalizeEmergencyRoomRows,
|
|
272
|
+
parseCoordinateQuery,
|
|
273
|
+
searchNearbyEmergencyRoomsByCoordinates,
|
|
274
|
+
searchNearbyEmergencyRoomsByLocationQuery
|
|
275
|
+
};
|
package/src/parse.js
ADDED
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
const SEARCH_ITEM_PATTERN = /<li\s+class="search_item\s+base"([\s\S]*?)<\/li>/giu;
|
|
2
|
+
const TAG_PATTERN = /<[^>]+>/g;
|
|
3
|
+
const NON_WORD_PATTERN = /[^\p{L}\p{N}]+/gu;
|
|
4
|
+
|
|
5
|
+
function decodeHtml(value) {
|
|
6
|
+
return String(value || "")
|
|
7
|
+
.replace(/&/g, "&")
|
|
8
|
+
.replace(/'/g, "'")
|
|
9
|
+
.replace(/"/g, '"')
|
|
10
|
+
.replace(/</g, "<")
|
|
11
|
+
.replace(/>/g, ">");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function stripTags(value) {
|
|
15
|
+
return decodeHtml(String(value || "").replace(TAG_PATTERN, " "))
|
|
16
|
+
.replace(/\s+/g, " ")
|
|
17
|
+
.trim();
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeText(value) {
|
|
21
|
+
return String(value || "")
|
|
22
|
+
.normalize("NFKC")
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(NON_WORD_PATTERN, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function extractAttribute(fragment, name) {
|
|
28
|
+
const match = fragment.match(new RegExp(`${name}="([^"]*)"`, "iu"));
|
|
29
|
+
return match ? decodeHtml(match[1]).trim() : "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function extractInnerText(fragment, className) {
|
|
33
|
+
const match = fragment.match(
|
|
34
|
+
new RegExp(`<[^>]+class="[^"]*${className}[^"]*"[^>]*>([\\s\\S]*?)<\\/[^>]+>`, "iu"),
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
return match ? stripTags(match[1]) : "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseSearchResultsHtml(html) {
|
|
41
|
+
const items = [];
|
|
42
|
+
let match;
|
|
43
|
+
|
|
44
|
+
while ((match = SEARCH_ITEM_PATTERN.exec(String(html || ""))) !== null) {
|
|
45
|
+
const fragment = match[1];
|
|
46
|
+
const id = extractAttribute(fragment, "data-id");
|
|
47
|
+
const name = extractAttribute(fragment, "data-title") || extractInnerText(fragment, "tit_g");
|
|
48
|
+
|
|
49
|
+
if (!id || !name) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const addressMatches = [...fragment.matchAll(/<span class="txt_g">([\s\S]*?)<\/span>/giu)]
|
|
54
|
+
.map((entry) => stripTags(entry[1]))
|
|
55
|
+
.filter(Boolean);
|
|
56
|
+
|
|
57
|
+
items.push({
|
|
58
|
+
id,
|
|
59
|
+
name,
|
|
60
|
+
category: extractInnerText(fragment, "txt_ginfo"),
|
|
61
|
+
address: addressMatches.at(-1) || "",
|
|
62
|
+
phone: extractAttribute(fragment, "data-phone") || extractInnerText(fragment, "num_phone") || null
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return items;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scoreAnchorCandidate(query, item) {
|
|
70
|
+
const normalizedQuery = normalizeText(query);
|
|
71
|
+
const normalizedName = normalizeText(item.name);
|
|
72
|
+
const normalizedAddress = normalizeText(item.address);
|
|
73
|
+
let score = 0;
|
|
74
|
+
|
|
75
|
+
if (!normalizedQuery) {
|
|
76
|
+
return score;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (normalizedName === normalizedQuery) {
|
|
80
|
+
score += 1000;
|
|
81
|
+
}
|
|
82
|
+
if (normalizedName.startsWith(normalizedQuery)) {
|
|
83
|
+
score += 800;
|
|
84
|
+
}
|
|
85
|
+
if (normalizedName.includes(normalizedQuery)) {
|
|
86
|
+
score += 600;
|
|
87
|
+
}
|
|
88
|
+
if (normalizedAddress.includes(normalizedQuery)) {
|
|
89
|
+
score += 120;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return score;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function rankAnchorCandidates(query, items) {
|
|
96
|
+
return [...(items || [])].sort((left, right) => {
|
|
97
|
+
const scoreDelta = scoreAnchorCandidate(query, right) - scoreAnchorCandidate(query, left);
|
|
98
|
+
|
|
99
|
+
if (scoreDelta !== 0) {
|
|
100
|
+
return scoreDelta;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return left.name.localeCompare(right.name, "ko");
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function normalizeAnchorPanel(panel, searchItem = {}) {
|
|
108
|
+
const summary = panel.summary || {};
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id: String(summary.confirm_id || searchItem.id || ""),
|
|
112
|
+
name: summary.name || searchItem.name || "",
|
|
113
|
+
category: summary.category?.name3 || summary.category?.name2 || searchItem.category || "",
|
|
114
|
+
address: summary.address?.disp || searchItem.address || "",
|
|
115
|
+
phone: summary.phone_numbers?.[0]?.tel || searchItem.phone || null,
|
|
116
|
+
latitude: toNumber(summary.point?.lat),
|
|
117
|
+
longitude: toNumber(summary.point?.lon),
|
|
118
|
+
sourceUrl: summary.confirm_id ? `https://place.map.kakao.com/${summary.confirm_id}` : null
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseCoordinateQuery(locationQuery) {
|
|
123
|
+
const match = String(locationQuery || "")
|
|
124
|
+
.trim()
|
|
125
|
+
.match(/^(-?\d+(?:\.\d+)?)\s*[,/ ]\s*(-?\d+(?:\.\d+)?)$/);
|
|
126
|
+
|
|
127
|
+
if (!match) {
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const latitude = Number(match[1]);
|
|
132
|
+
const longitude = Number(match[2]);
|
|
133
|
+
|
|
134
|
+
if (!isValidCoordinatePair(latitude, longitude)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return { latitude, longitude };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function toNumber(value) {
|
|
142
|
+
if (value === null || value === undefined || value === "") {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const parsed = Number(String(value).replace(/,/g, ""));
|
|
147
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isValidLatitude(value) {
|
|
151
|
+
return Number.isFinite(value) && value >= -90 && value <= 90;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function isValidLongitude(value) {
|
|
155
|
+
return Number.isFinite(value) && value >= -180 && value <= 180;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isValidCoordinatePair(latitude, longitude) {
|
|
159
|
+
return isValidLatitude(latitude) && isValidLongitude(longitude);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function toBooleanYesNo(value) {
|
|
163
|
+
const normalized = String(value ?? "").trim().toUpperCase();
|
|
164
|
+
|
|
165
|
+
if (normalized === "Y") {
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (normalized === "N") {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildMapUrl(name, latitude, longitude) {
|
|
177
|
+
return `https://map.kakao.com/link/map/${encodeURIComponent(name)},${latitude},${longitude}`;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function parseEgenTimestamp(value) {
|
|
181
|
+
const text = String(value || "").trim();
|
|
182
|
+
const match = text.match(/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/);
|
|
183
|
+
|
|
184
|
+
if (!match) {
|
|
185
|
+
return null;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}+09:00`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function haversineDistanceMeters(latitudeA, longitudeA, latitudeB, longitudeB) {
|
|
192
|
+
const earthRadiusMeters = 6371008.8;
|
|
193
|
+
const toRadians = (value) => (value * Math.PI) / 180;
|
|
194
|
+
const deltaLatitude = toRadians(latitudeB - latitudeA);
|
|
195
|
+
const deltaLongitude = toRadians(longitudeB - longitudeA);
|
|
196
|
+
const originLatitude = toRadians(latitudeA);
|
|
197
|
+
const targetLatitude = toRadians(latitudeB);
|
|
198
|
+
|
|
199
|
+
const value =
|
|
200
|
+
Math.sin(deltaLatitude / 2) ** 2 +
|
|
201
|
+
Math.cos(originLatitude) * Math.cos(targetLatitude) * Math.sin(deltaLongitude / 2) ** 2;
|
|
202
|
+
|
|
203
|
+
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(value), Math.sqrt(1 - value));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function getEmergencyRoomRows(payload) {
|
|
207
|
+
if (Array.isArray(payload)) {
|
|
208
|
+
return payload;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (Array.isArray(payload?.list)) {
|
|
212
|
+
return payload.list;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
throw new Error("Unexpected E-Gen emergency room payload shape.");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function normalizeEmergencyRoomRows(payload, origin, options = {}) {
|
|
219
|
+
const latitude = Number(origin?.latitude);
|
|
220
|
+
const longitude = Number(origin?.longitude);
|
|
221
|
+
const radius = Number.isFinite(Number(options.radius ?? options.maxDistanceKm)) ? Number(options.radius ?? options.maxDistanceKm) : null;
|
|
222
|
+
|
|
223
|
+
if (!isValidCoordinatePair(latitude, longitude)) {
|
|
224
|
+
throw new Error("normalizeEmergencyRoomRows requires valid origin coordinates.");
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return getEmergencyRoomRows(payload)
|
|
228
|
+
.map((row) => {
|
|
229
|
+
const itemLatitude = toNumber(row.LAT ?? row.lat);
|
|
230
|
+
const itemLongitude = toNumber(row.LON ?? row.lon);
|
|
231
|
+
|
|
232
|
+
if (!isValidCoordinatePair(itemLatitude, itemLongitude)) {
|
|
233
|
+
return null;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const distanceKm = toNumber(row.DISTANCE2 ?? row.DISTANCE) ?? haversineDistanceMeters(latitude, longitude, itemLatitude, itemLongitude) / 1000;
|
|
237
|
+
const name = String(row.TITLE || row.name || "").trim();
|
|
238
|
+
|
|
239
|
+
if (!name) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
id: String(row.EMOGCODE || row.id || ""),
|
|
245
|
+
name,
|
|
246
|
+
emergencyGrade: row.CATEGORY1 || null,
|
|
247
|
+
hospitalType: row.CATEGORY2 || null,
|
|
248
|
+
address: row.ADDRROAD || row.ADDRLAGE || null,
|
|
249
|
+
phone: row.TEL || null,
|
|
250
|
+
latitude: itemLatitude,
|
|
251
|
+
longitude: itemLongitude,
|
|
252
|
+
distanceKm: Math.round(distanceKm * 1000) / 1000,
|
|
253
|
+
bedStatus: {
|
|
254
|
+
emergencyRoomOperating: toBooleanYesNo(row.EMOGERYN),
|
|
255
|
+
inpatientBedsOperating: toBooleanYesNo(row.EMOGPRYN),
|
|
256
|
+
traumaCenter: toBooleanYesNo(row.EMOGTRYN),
|
|
257
|
+
pediatricSpecialty: toBooleanYesNo(row.CHILD_SPCLTY_AT),
|
|
258
|
+
currentGeneralCareAvailable: toBooleanYesNo(row.OPERATIONYN),
|
|
259
|
+
pediatricNightCare: toBooleanYesNo(row.NIGHTCAREYN),
|
|
260
|
+
holidayOpen: toBooleanYesNo(row.HOLIDAYYN),
|
|
261
|
+
silson24Linked: toBooleanYesNo(row.SILSON24_CHK)
|
|
262
|
+
},
|
|
263
|
+
schedules: {
|
|
264
|
+
monday: row.MONDAY || null,
|
|
265
|
+
tuesday: row.TUESDAY || null,
|
|
266
|
+
wednesday: row.WEDNESDAY || null,
|
|
267
|
+
thursday: row.THURSDAY || null,
|
|
268
|
+
friday: row.FRIDAY || null,
|
|
269
|
+
saturday: row.SATURDAY || null,
|
|
270
|
+
sunday: row.SUNDAY || null,
|
|
271
|
+
holiday: row.HOLIDAY || null,
|
|
272
|
+
note: row.OPN_BIGO || null
|
|
273
|
+
},
|
|
274
|
+
updatedAt: parseEgenTimestamp(row.EMOGUPDT),
|
|
275
|
+
sourceUrl: "https://www.e-gen.or.kr/egen/search_emergency_room.do",
|
|
276
|
+
mapUrl: buildMapUrl(name, itemLatitude, itemLongitude)
|
|
277
|
+
};
|
|
278
|
+
})
|
|
279
|
+
.filter(Boolean)
|
|
280
|
+
.filter((item) => radius === null || item.distanceKm <= radius)
|
|
281
|
+
.sort((left, right) => left.distanceKm - right.distanceKm || left.name.localeCompare(right.name, "ko"));
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
module.exports = {
|
|
285
|
+
buildMapUrl,
|
|
286
|
+
isValidCoordinatePair,
|
|
287
|
+
isValidLatitude,
|
|
288
|
+
isValidLongitude,
|
|
289
|
+
normalizeAnchorPanel,
|
|
290
|
+
normalizeEmergencyRoomRows,
|
|
291
|
+
parseCoordinateQuery,
|
|
292
|
+
parseEgenTimestamp,
|
|
293
|
+
parseSearchResultsHtml,
|
|
294
|
+
rankAnchorCandidates
|
|
295
|
+
};
|