presale-mcp 0.1.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/LICENSE +21 -0
- package/README.md +103 -0
- package/dist/index.js +3535 -0
- package/package.json +36 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3535 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
5
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
6
|
+
import { z as z3 } from "zod";
|
|
7
|
+
|
|
8
|
+
// src/utils.ts
|
|
9
|
+
function normalizeWhitespace(value) {
|
|
10
|
+
return value.trim().replace(/\s+/g, " ");
|
|
11
|
+
}
|
|
12
|
+
function nowIso() {
|
|
13
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
14
|
+
}
|
|
15
|
+
function distanceLabel(distanceM) {
|
|
16
|
+
if (distanceM >= 1e3) {
|
|
17
|
+
const km = distanceM / 1e3;
|
|
18
|
+
return `\uC57D ${Number.isInteger(km) ? km.toFixed(0) : km.toFixed(1)}km`;
|
|
19
|
+
}
|
|
20
|
+
return `\uC57D ${Math.round(distanceM)}m`;
|
|
21
|
+
}
|
|
22
|
+
function haversineMeters(a, b) {
|
|
23
|
+
const radius = 6371e3;
|
|
24
|
+
const toRad = (degree) => degree * Math.PI / 180;
|
|
25
|
+
const dLat = toRad(b.lat - a.lat);
|
|
26
|
+
const dLng = toRad(b.lng - a.lng);
|
|
27
|
+
const lat1 = toRad(a.lat);
|
|
28
|
+
const lat2 = toRad(b.lat);
|
|
29
|
+
const sinLat = Math.sin(dLat / 2);
|
|
30
|
+
const sinLng = Math.sin(dLng / 2);
|
|
31
|
+
const h = sinLat * sinLat + Math.cos(lat1) * Math.cos(lat2) * sinLng * sinLng;
|
|
32
|
+
return Math.round(2 * radius * Math.asin(Math.sqrt(h)));
|
|
33
|
+
}
|
|
34
|
+
function assertKoreaCoordinate(lat, lng) {
|
|
35
|
+
if (lat < 33 || lat > 39 || lng < 124 || lng > 132) {
|
|
36
|
+
return "Coordinate is outside the expected South Korea bounding box.";
|
|
37
|
+
}
|
|
38
|
+
return void 0;
|
|
39
|
+
}
|
|
40
|
+
async function readJsonResponse(response) {
|
|
41
|
+
const text3 = await response.text();
|
|
42
|
+
try {
|
|
43
|
+
return text3.length === 0 ? null : JSON.parse(text3);
|
|
44
|
+
} catch {
|
|
45
|
+
throw new Error(`Invalid JSON response (${response.status})`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// src/cache.ts
|
|
50
|
+
var NOOP_CACHE = {
|
|
51
|
+
async getOrSet(_key, _ttlMs, fetchFn) {
|
|
52
|
+
return fetchFn();
|
|
53
|
+
},
|
|
54
|
+
async getOrSetWithStatus(_key, _ttlMs, fetchFn) {
|
|
55
|
+
return { value: await fetchFn(), hit: false };
|
|
56
|
+
},
|
|
57
|
+
async getOrSetWithStatusIf(_key, _ttlMs, fetchFn) {
|
|
58
|
+
return { value: await fetchFn(), hit: false };
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
function createCache(_db, _now = Date.now) {
|
|
62
|
+
return NOOP_CACHE;
|
|
63
|
+
}
|
|
64
|
+
var TTL = {
|
|
65
|
+
geocode: 30 * 24 * 60 * 60 * 1e3,
|
|
66
|
+
region: 30 * 24 * 60 * 60 * 1e3,
|
|
67
|
+
announcements: 6 * 60 * 60 * 1e3,
|
|
68
|
+
announcementDetail: 7 * 24 * 60 * 60 * 1e3,
|
|
69
|
+
trades: 24 * 60 * 60 * 1e3,
|
|
70
|
+
complexInfo: 30 * 24 * 60 * 60 * 1e3,
|
|
71
|
+
nearbyComplex: 6 * 60 * 60 * 1e3
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// src/tools/geocode.ts
|
|
75
|
+
async function getGeocode(input, env, fetcher = fetch, options = {}) {
|
|
76
|
+
const address = normalizeWhitespace(input.address);
|
|
77
|
+
if (!options.cache) return geocodeUncached(input, env, fetcher, options);
|
|
78
|
+
const key = `geo:${options.skipRegionCode ? "n" : "r"}:${address}`;
|
|
79
|
+
const cached = await options.cache.getOrSetWithStatus(key, TTL.geocode, async () => {
|
|
80
|
+
const result = await geocodeUncached(input, env, fetcher, options);
|
|
81
|
+
return "lat" in result ? result : null;
|
|
82
|
+
});
|
|
83
|
+
if (cached.value && "lat" in cached.value) return { ...cached.value, metadata: { ...cached.value.metadata, cache_hit: cached.hit } };
|
|
84
|
+
return geocodeUncached(input, env, fetcher, options);
|
|
85
|
+
}
|
|
86
|
+
async function geocodeUncached(input, env, fetcher, options) {
|
|
87
|
+
const address = normalizeWhitespace(input.address);
|
|
88
|
+
const metadata = {
|
|
89
|
+
cache_hit: false,
|
|
90
|
+
providers_tried: ["naver"],
|
|
91
|
+
collected_at: nowIso()
|
|
92
|
+
};
|
|
93
|
+
if (!address) {
|
|
94
|
+
return errorResult(address, "INVALID_ADDRESS", "\uC8FC\uC18C \uBB38\uC790\uC5F4\uC774 \uBE44\uC5B4 \uC788\uC2B5\uB2C8\uB2E4.", metadata);
|
|
95
|
+
}
|
|
96
|
+
if (!env.NAVER_MAPS_CLIENT_ID || !env.NAVER_MAPS_CLIENT_SECRET) {
|
|
97
|
+
return errorResult(address, "NAVER_AUTH_MISSING", "NAVER_MAPS_CLIENT_ID \uB610\uB294 NAVER_MAPS_CLIENT_SECRET \uD658\uACBD\uBCC0\uC218\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.", metadata);
|
|
98
|
+
}
|
|
99
|
+
let queryResult = await queryNaver(address, env, fetcher);
|
|
100
|
+
if (queryResult.kind === "http_error") {
|
|
101
|
+
return errorResult(address, "NAVER_API_ERROR", `\uB124\uC774\uBC84 Geocoding API \uC624\uB958: HTTP ${queryResult.status}`, metadata);
|
|
102
|
+
}
|
|
103
|
+
if (queryResult.kind === "not_found") {
|
|
104
|
+
const cleaned = cleanCompoundAddress(address);
|
|
105
|
+
if (cleaned && cleaned !== address) {
|
|
106
|
+
const retry = await queryNaver(cleaned, env, fetcher);
|
|
107
|
+
if (retry.kind === "hit") queryResult = retry;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (queryResult.kind !== "hit") {
|
|
111
|
+
return errorResult(address, "ADDRESS_NOT_FOUND", "\uC785\uB825\uD558\uC2E0 \uC8FC\uC18C\uB97C \uC88C\uD45C\uB85C \uBCC0\uD658\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.", metadata);
|
|
112
|
+
}
|
|
113
|
+
const first = queryResult.address;
|
|
114
|
+
const lat = Number(first.y);
|
|
115
|
+
const lng = Number(first.x);
|
|
116
|
+
const regionCode = options.skipRegionCode ? void 0 : await lookupRegionCode(address, env, fetcher);
|
|
117
|
+
const normalizedAddress = first.jibunAddress || first.roadAddress || address;
|
|
118
|
+
const roadAddress = first.roadAddress || void 0;
|
|
119
|
+
const result = {
|
|
120
|
+
address,
|
|
121
|
+
lat,
|
|
122
|
+
lng,
|
|
123
|
+
normalized_address: normalizedAddress,
|
|
124
|
+
address_type: first.jibunAddress ? "jibun" : first.roadAddress ? "road" : "approximate",
|
|
125
|
+
matched_provider: "naver",
|
|
126
|
+
...roadAddress ? { road_address: roadAddress } : {},
|
|
127
|
+
region: extractRegion(first.addressElements ?? []),
|
|
128
|
+
...regionCode ? { region_code: regionCode } : {},
|
|
129
|
+
confidence: first.jibunAddress || first.roadAddress ? "exact" : "partial",
|
|
130
|
+
metadata
|
|
131
|
+
};
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
function errorResult(address, error, message, metadata) {
|
|
135
|
+
return {
|
|
136
|
+
address,
|
|
137
|
+
error,
|
|
138
|
+
message,
|
|
139
|
+
matched_provider: "naver",
|
|
140
|
+
suggestions: ["\uB3C4\uB85C\uBA85 \uC8FC\uC18C\uB85C \uB2E4\uC2DC \uC2DC\uB3C4\uD574\uBCF4\uC138\uC694.", "\uB354 \uAD6C\uCCB4\uC801\uC778 \uC9C0\uBC88\uC744 \uC785\uB825\uD574\uBCF4\uC138\uC694.", "\uC2DC\xB7\uB3C4 \uB2E8\uC704\uAE4C\uC9C0 \uD3EC\uD568\uB41C \uC804\uCCB4 \uC8FC\uC18C\uB97C \uC0AC\uC6A9\uD574\uBCF4\uC138\uC694."],
|
|
141
|
+
metadata
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
async function queryNaver(query, env, fetcher) {
|
|
145
|
+
const url = new URL("https://maps.apigw.ntruss.com/map-geocode/v2/geocode");
|
|
146
|
+
url.searchParams.set("query", query);
|
|
147
|
+
const response = await fetcher(url.toString(), {
|
|
148
|
+
headers: {
|
|
149
|
+
"x-ncp-apigw-api-key-id": env.NAVER_MAPS_CLIENT_ID ?? "",
|
|
150
|
+
"x-ncp-apigw-api-key": env.NAVER_MAPS_CLIENT_SECRET ?? "",
|
|
151
|
+
Accept: "application/json"
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
if (!response.ok) return { kind: "http_error", status: response.status };
|
|
155
|
+
const payload = await readJsonResponse(response);
|
|
156
|
+
const first = payload.addresses?.[0];
|
|
157
|
+
if (!first || payload.meta?.totalCount === 0) return { kind: "not_found" };
|
|
158
|
+
return { kind: "hit", address: first };
|
|
159
|
+
}
|
|
160
|
+
function cleanCompoundAddress(address) {
|
|
161
|
+
let cleaned = address.split(",")[0] ?? address;
|
|
162
|
+
cleaned = cleaned.replace(/\([^)]*\)/g, " ");
|
|
163
|
+
cleaned = cleaned.replace(/\s*(일원|일대|일부|번지)\b.*$/u, " ");
|
|
164
|
+
cleaned = cleaned.replace(/번지\s*$/u, " ");
|
|
165
|
+
return normalizeWhitespace(cleaned);
|
|
166
|
+
}
|
|
167
|
+
function extractRegion(elements) {
|
|
168
|
+
const byType = (type) => elements.find((element) => element.types?.includes(type))?.longName;
|
|
169
|
+
const sido = byType("SIDO");
|
|
170
|
+
const sigungu = byType("SIGUGUN");
|
|
171
|
+
const eupmyeondong = byType("DONGMYUN");
|
|
172
|
+
const jibun = byType("LAND_NUMBER");
|
|
173
|
+
const region = {};
|
|
174
|
+
if (sido) region.sido = sido;
|
|
175
|
+
if (sigungu) region.sigungu = sigungu;
|
|
176
|
+
if (eupmyeondong) region.eupmyeondong = eupmyeondong;
|
|
177
|
+
if (jibun) region.jibun = jibun;
|
|
178
|
+
return region;
|
|
179
|
+
}
|
|
180
|
+
async function lookupRegionCode(address, env, fetcher) {
|
|
181
|
+
if (!env.KAKAO_REST_API_KEY) return void 0;
|
|
182
|
+
const url = new URL("https://dapi.kakao.com/v2/local/search/address.json");
|
|
183
|
+
url.searchParams.set("query", address);
|
|
184
|
+
url.searchParams.set("analyze_type", "similar");
|
|
185
|
+
url.searchParams.set("size", "1");
|
|
186
|
+
const response = await fetcher(url.toString(), {
|
|
187
|
+
headers: { Authorization: `KakaoAK ${env.KAKAO_REST_API_KEY}` }
|
|
188
|
+
});
|
|
189
|
+
if (!response.ok) return void 0;
|
|
190
|
+
const payload = await readJsonResponse(response);
|
|
191
|
+
return payload.documents?.[0]?.address?.b_code || void 0;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// src/config/categories.yaml
|
|
195
|
+
var categories_default = 'amenity:\n - id: school_elementary\n label: \uCD08\uB4F1\uD559\uAD50\n aliases: ["\uCD08\uB4F1\uD559\uAD50", "\uCD08\uB4F1", "\uCD08\uB4F1\uAD50"]\n kakao_code: SC4\n filter:\n category_name_contains: "\uCD08\uB4F1\uD559\uAD50"\n - id: school_middle\n label: \uC911\uD559\uAD50\n aliases: ["\uC911\uD559\uAD50", "\uC911\uB4F1"]\n kakao_code: SC4\n filter:\n category_name_contains: "\uC911\uD559\uAD50"\n - id: school_high\n label: \uACE0\uB4F1\uD559\uAD50\n aliases: ["\uACE0\uB4F1\uD559\uAD50", "\uACE0\uAD50", "\uACE0\uB4F1"]\n kakao_code: SC4\n filter:\n category_name_contains: "\uACE0\uB4F1\uD559\uAD50"\n - id: school\n label: \uD559\uAD50\n aliases: ["\uD559\uAD50", "\uC2A4\uCFE8"]\n kakao_code: SC4\n - id: subway\n label: \uC9C0\uD558\uCCA0\uC5ED\n aliases: ["\uC9C0\uD558\uCCA0", "\uC9C0\uD558\uCCA0\uC5ED", "\uC5ED", "\uC804\uCCA0"]\n kakao_code: SW8\n - id: mart_large\n label: \uB300\uD615\uB9C8\uD2B8\n aliases: ["\uB9C8\uD2B8", "\uB300\uD615\uB9C8\uD2B8", "\uC774\uB9C8\uD2B8", "\uD648\uD50C\uB7EC\uC2A4", "\uB86F\uB370\uB9C8\uD2B8"]\n kakao_code: MT1\n - id: hospital\n label: \uBCD1\uC6D0\n aliases: ["\uBCD1\uC6D0", "\uC758\uB8CC"]\n kakao_code: HP8\n - id: pharmacy\n label: \uC57D\uAD6D\n aliases: ["\uC57D\uAD6D"]\n kakao_code: PM9\n - id: park\n label: \uACF5\uC6D0\n aliases: ["\uACF5\uC6D0"]\n kakao_code: AT4\n filter:\n category_name_contains: "\uACF5\uC6D0"\n\nkeyword_search_presets:\n apartment:\n query: "\uC544\uD30C\uD2B8"\n category_filter:\n include_contains: ["\uC544\uD30C\uD2B8"]\n exclude_contains: ["\uC911\uAC1C", "\uBD84\uC591\uC0AC\uBB34\uC18C", "\uBAA8\uB378\uD558\uC6B0\uC2A4", "\uC8FC\uCC28\uC7A5", "\uC544\uD30C\uD2B8\uC0C1\uAC00", "\uC544\uD30C\uD2B8 \uB3D9"]\n deduplicate_by: "name"\n officetel:\n query: "\uC624\uD53C\uC2A4\uD154"\n category_filter:\n include_contains: ["\uC624\uD53C\uC2A4\uD154"]\n exclude_contains: ["\uC911\uAC1C", "\uBD84\uC591\uC0AC\uBB34\uC18C"]\n deduplicate_by: "name"\n library:\n query: "\uB3C4\uC11C\uAD00"\n category_filter:\n include_contains: ["\uB3C4\uC11C\uAD00"]\n exclude_contains: ["\uC11C\uC810", "\uBD81\uCE74\uD398"]\n';
|
|
196
|
+
|
|
197
|
+
// src/config.ts
|
|
198
|
+
import { parse } from "yaml";
|
|
199
|
+
var parsed = parse(categories_default);
|
|
200
|
+
var categories = parsed.amenity;
|
|
201
|
+
var keywordSearchPresets = parsed.keyword_search_presets;
|
|
202
|
+
function resolveCategory(input) {
|
|
203
|
+
const normalized = input.trim().toLowerCase();
|
|
204
|
+
return categories.find((category) => {
|
|
205
|
+
if (category.id.toLowerCase() === normalized || category.label.toLowerCase() === normalized) return true;
|
|
206
|
+
return category.aliases?.some((alias) => alias.toLowerCase() === normalized) ?? false;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/tools/nearby-category.ts
|
|
211
|
+
async function searchByNearbyCategory(input, env, fetcher = fetch, options = {}) {
|
|
212
|
+
if (!options.cache) return searchByNearbyCategoryUncached(input, env, fetcher);
|
|
213
|
+
const key = `kakao:cat:${JSON.stringify({
|
|
214
|
+
lat: input.center_lat.toFixed(4),
|
|
215
|
+
lng: input.center_lng.toFixed(4),
|
|
216
|
+
radius: input.radius_m ?? 2e3,
|
|
217
|
+
categories: input.categories ?? null,
|
|
218
|
+
filter: input.subcategory_filter ?? null
|
|
219
|
+
})}`;
|
|
220
|
+
const cached = await options.cache.getOrSetWithStatus(key, TTL.announcements, async () => {
|
|
221
|
+
const result = await searchByNearbyCategoryUncached(input, env, fetcher);
|
|
222
|
+
return result.summary.total > 0 ? result : null;
|
|
223
|
+
});
|
|
224
|
+
if (cached.value) return { ...cached.value, metadata: { ...cached.value.metadata, cache_hit: cached.hit } };
|
|
225
|
+
return searchByNearbyCategoryUncached(input, env, fetcher);
|
|
226
|
+
}
|
|
227
|
+
async function searchByNearbyCategoryUncached(input, env, fetcher = fetch) {
|
|
228
|
+
const radiusM = Math.min(Math.max(input.radius_m ?? 2e3, 0), 2e4);
|
|
229
|
+
const metadata = {
|
|
230
|
+
cache_hit: false,
|
|
231
|
+
collected_at: nowIso(),
|
|
232
|
+
data_source: "kakao_category",
|
|
233
|
+
warnings: [],
|
|
234
|
+
truncated: false
|
|
235
|
+
};
|
|
236
|
+
const result = {
|
|
237
|
+
center: { lat: input.center_lat, lng: input.center_lng },
|
|
238
|
+
radius_m: radiusM,
|
|
239
|
+
results: {},
|
|
240
|
+
summary: { total: 0, by_category: {} },
|
|
241
|
+
metadata
|
|
242
|
+
};
|
|
243
|
+
const coordinateWarning = assertKoreaCoordinate(input.center_lat, input.center_lng);
|
|
244
|
+
if (coordinateWarning) {
|
|
245
|
+
metadata.warnings?.push(coordinateWarning);
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
if (!env.KAKAO_REST_API_KEY) {
|
|
249
|
+
metadata.warnings?.push("KAKAO_REST_API_KEY is missing.");
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
const resolvedCategories = (input.categories ?? ["school", "subway", "mart_large"]).map((categoryInput) => ({ original: categoryInput, category: resolveCategory(categoryInput) })).filter(({ original, category }) => {
|
|
253
|
+
if (!category) metadata.warnings?.push(`Unknown category: ${original}`);
|
|
254
|
+
return Boolean(category);
|
|
255
|
+
}).map(({ category }) => category);
|
|
256
|
+
for (const category of resolvedCategories) {
|
|
257
|
+
const documents = await fetchAllPages(category.kakao_code, input.center_lat, input.center_lng, radiusM, env, fetcher, metadata);
|
|
258
|
+
const filterContains = input.subcategory_filter?.category_name_contains ?? category.filter?.category_name_contains;
|
|
259
|
+
const places = documents.filter((doc) => filterContains ? doc.category_name.includes(filterContains) : true).map((doc) => normalizePlace(doc, input.center_lat, input.center_lng)).filter((place, index, array) => array.findIndex((other) => other.kakao_place_id === place.kakao_place_id) === index).sort((a, b) => a.distance_m - b.distance_m);
|
|
260
|
+
result.results[category.id] = places;
|
|
261
|
+
result.summary.by_category[category.id] = places.length;
|
|
262
|
+
result.summary.total += places.length;
|
|
263
|
+
}
|
|
264
|
+
if (metadata.warnings?.length === 0) delete metadata.warnings;
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
async function fetchAllPages(kakaoCode, lat, lng, radiusM, env, fetcher, metadata) {
|
|
268
|
+
const documents = [];
|
|
269
|
+
for (let page = 1; page <= 3; page += 1) {
|
|
270
|
+
const url = new URL("https://dapi.kakao.com/v2/local/search/category.json");
|
|
271
|
+
url.searchParams.set("category_group_code", kakaoCode);
|
|
272
|
+
url.searchParams.set("x", String(lng));
|
|
273
|
+
url.searchParams.set("y", String(lat));
|
|
274
|
+
url.searchParams.set("radius", String(radiusM));
|
|
275
|
+
url.searchParams.set("sort", "distance");
|
|
276
|
+
url.searchParams.set("page", String(page));
|
|
277
|
+
url.searchParams.set("size", "15");
|
|
278
|
+
const response = await fetcher(url.toString(), {
|
|
279
|
+
headers: { Authorization: `KakaoAK ${env.KAKAO_REST_API_KEY}` }
|
|
280
|
+
});
|
|
281
|
+
if (!response.ok) throw new Error(`Kakao category API failed: HTTP ${response.status}`);
|
|
282
|
+
const payload = await readJsonResponse(response);
|
|
283
|
+
documents.push(...payload.documents ?? []);
|
|
284
|
+
if (payload.meta?.is_end !== false) break;
|
|
285
|
+
if (page === 3) {
|
|
286
|
+
metadata.truncated = true;
|
|
287
|
+
metadata.warnings ??= [];
|
|
288
|
+
metadata.warnings.push("Kakao Local API returned the maximum 45 results for at least one query; additional farther results may have been truncated.");
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
if (documents.length >= 45) {
|
|
292
|
+
metadata.truncated = true;
|
|
293
|
+
metadata.warnings ??= [];
|
|
294
|
+
const warning = "Kakao Local API returned the maximum 45 results for at least one query; additional farther results may have been truncated.";
|
|
295
|
+
if (!metadata.warnings.includes(warning)) metadata.warnings.push(warning);
|
|
296
|
+
}
|
|
297
|
+
return documents;
|
|
298
|
+
}
|
|
299
|
+
function normalizePlace(doc, centerLat, centerLng) {
|
|
300
|
+
const lat = Number(doc.y);
|
|
301
|
+
const lng = Number(doc.x);
|
|
302
|
+
const distanceM = doc.distance && doc.distance.length > 0 ? Number(doc.distance) : haversineMeters({ lat: centerLat, lng: centerLng }, { lat, lng });
|
|
303
|
+
return {
|
|
304
|
+
name: doc.place_name,
|
|
305
|
+
lat,
|
|
306
|
+
lng,
|
|
307
|
+
distance_m: Math.round(distanceM),
|
|
308
|
+
distance_label: distanceLabel(distanceM),
|
|
309
|
+
address: doc.road_address_name || doc.address_name,
|
|
310
|
+
...doc.road_address_name ? { road_address: doc.road_address_name } : {},
|
|
311
|
+
kakao_place_id: doc.id,
|
|
312
|
+
kakao_category_name: doc.category_name
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// src/tools/nearby-keyword.ts
|
|
317
|
+
var KAKAO_GRID_CONCURRENCY = 6;
|
|
318
|
+
async function searchByNearbyKeyword(input, env, fetcher = fetch, options = {}) {
|
|
319
|
+
if (!options.cache) return searchByNearbyKeywordUncached(input, env, fetcher);
|
|
320
|
+
const key = `kakao:kw:${JSON.stringify({
|
|
321
|
+
lat: input.center_lat.toFixed(4),
|
|
322
|
+
lng: input.center_lng.toFixed(4),
|
|
323
|
+
radius: input.radius_m ?? 2e3,
|
|
324
|
+
query: input.query?.trim() ?? null,
|
|
325
|
+
preset: input.preset ?? null,
|
|
326
|
+
filter: input.category_filter ?? null,
|
|
327
|
+
dedupe: input.deduplicate_by ?? null,
|
|
328
|
+
grid: input.grid ?? false
|
|
329
|
+
})}`;
|
|
330
|
+
const cached = await options.cache.getOrSetWithStatus(key, TTL.announcements, async () => {
|
|
331
|
+
const result = await collectNearbyKeywordResults(input, env, fetcher);
|
|
332
|
+
return result.summary.total_found > 0 ? result : null;
|
|
333
|
+
});
|
|
334
|
+
if (cached.value) return applyPagination(cached.value, input, cached.hit);
|
|
335
|
+
return searchByNearbyKeywordUncached(input, env, fetcher);
|
|
336
|
+
}
|
|
337
|
+
async function searchByNearbyKeywordUncached(input, env, fetcher = fetch) {
|
|
338
|
+
const result = await collectNearbyKeywordResults(input, env, fetcher);
|
|
339
|
+
return applyPagination(result, input, false);
|
|
340
|
+
}
|
|
341
|
+
async function collectNearbyKeywordResults(input, env, fetcher = fetch) {
|
|
342
|
+
const radiusM = Math.min(Math.max(input.radius_m ?? 2e3, 0), 2e4);
|
|
343
|
+
const metadata = {
|
|
344
|
+
cache_hit: false,
|
|
345
|
+
collected_at: nowIso(),
|
|
346
|
+
data_source: "kakao_keyword",
|
|
347
|
+
warnings: [],
|
|
348
|
+
truncated: false
|
|
349
|
+
};
|
|
350
|
+
const preset = input.preset ? keywordSearchPresets[input.preset] : void 0;
|
|
351
|
+
if (input.preset && !preset) metadata.warnings?.push(`Unknown preset: ${input.preset}`);
|
|
352
|
+
const query = input.query?.trim() || preset?.query || "";
|
|
353
|
+
const filterApplied = input.category_filter ?? preset?.category_filter ?? null;
|
|
354
|
+
const deduplicateBy = input.deduplicate_by ?? preset?.deduplicate_by ?? null;
|
|
355
|
+
if (input.query?.trim() && isShortNoisyKeyword(query)) {
|
|
356
|
+
metadata.warnings?.push(`Short keyword "${query}" can return noisy or incomplete Kakao results. Prefer a more specific place type, brand+district, or preset when possible.`);
|
|
357
|
+
}
|
|
358
|
+
const result = {
|
|
359
|
+
center: { lat: input.center_lat, lng: input.center_lng },
|
|
360
|
+
radius_m: radiusM,
|
|
361
|
+
query,
|
|
362
|
+
filter_applied: filterApplied,
|
|
363
|
+
deduplicate_by: deduplicateBy,
|
|
364
|
+
results: [],
|
|
365
|
+
summary: { total_found: 0, after_filter: 0, after_dedupe: 0 },
|
|
366
|
+
metadata
|
|
367
|
+
};
|
|
368
|
+
const coordinateWarning = assertKoreaCoordinate(input.center_lat, input.center_lng);
|
|
369
|
+
if (coordinateWarning) {
|
|
370
|
+
metadata.warnings?.push(coordinateWarning);
|
|
371
|
+
return result;
|
|
372
|
+
}
|
|
373
|
+
if (!query) {
|
|
374
|
+
metadata.warnings?.push("Either query or preset is required.");
|
|
375
|
+
return result;
|
|
376
|
+
}
|
|
377
|
+
if (!env.KAKAO_REST_API_KEY) {
|
|
378
|
+
metadata.warnings?.push("KAKAO_REST_API_KEY is missing.");
|
|
379
|
+
return result;
|
|
380
|
+
}
|
|
381
|
+
const documents = input.grid ? await fetchGridCells(query, input.center_lat, input.center_lng, radiusM, env, fetcher, metadata) : await fetchAllPages2(query, input.center_lat, input.center_lng, radiusM, env, fetcher, metadata);
|
|
382
|
+
result.summary.total_found = documents.length;
|
|
383
|
+
const filtered = documents.filter((doc) => matchesFilter(doc.category_name, filterApplied));
|
|
384
|
+
result.summary.after_filter = filtered.length;
|
|
385
|
+
const normalized = filtered.map((doc) => normalizePlace2(doc, input.center_lat, input.center_lng)).filter((place) => place.distance_m <= radiusM).sort((a, b) => a.distance_m - b.distance_m);
|
|
386
|
+
const deduped = deduplicate(normalized, deduplicateBy);
|
|
387
|
+
result.results = deduped;
|
|
388
|
+
result.summary.after_dedupe = deduped.length;
|
|
389
|
+
if (metadata.warnings?.length === 0) delete metadata.warnings;
|
|
390
|
+
return result;
|
|
391
|
+
}
|
|
392
|
+
function applyPagination(result, input, cacheHit) {
|
|
393
|
+
const limit = normalizeLimit(input.limit);
|
|
394
|
+
const offset = normalizeOffset(input.offset);
|
|
395
|
+
const total = result.results.length;
|
|
396
|
+
const returned = limit === void 0 ? result.results.slice(offset) : result.results.slice(offset, offset + limit);
|
|
397
|
+
const consumedCount = offset + returned.length;
|
|
398
|
+
const omittedCount = Math.max(0, total - consumedCount);
|
|
399
|
+
const hasMore = consumedCount < total;
|
|
400
|
+
const metadata = {
|
|
401
|
+
...result.metadata,
|
|
402
|
+
cache_hit: cacheHit,
|
|
403
|
+
returned_count: returned.length,
|
|
404
|
+
omitted_count: omittedCount,
|
|
405
|
+
offset,
|
|
406
|
+
has_more: hasMore,
|
|
407
|
+
next_offset: hasMore ? consumedCount : null,
|
|
408
|
+
truncated: Boolean(result.metadata.truncated) || hasMore,
|
|
409
|
+
...limit !== void 0 ? { limit, result_limit: limit } : {}
|
|
410
|
+
};
|
|
411
|
+
if (omittedCount > 0) {
|
|
412
|
+
metadata.warnings ??= [];
|
|
413
|
+
metadata.warnings.push(`search_by_nearby_keyword returned ${returned.length} of ${total} distance-sorted results; use a higher limit or narrower filter for more candidates.`);
|
|
414
|
+
}
|
|
415
|
+
if (metadata.warnings?.length === 0) delete metadata.warnings;
|
|
416
|
+
return {
|
|
417
|
+
...result,
|
|
418
|
+
results: returned,
|
|
419
|
+
metadata
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
function normalizeLimit(limit) {
|
|
423
|
+
if (limit === void 0) return void 0;
|
|
424
|
+
if (!Number.isFinite(limit)) return void 0;
|
|
425
|
+
return Math.min(200, Math.max(1, Math.floor(limit)));
|
|
426
|
+
}
|
|
427
|
+
function normalizeOffset(offset) {
|
|
428
|
+
if (offset === void 0) return 0;
|
|
429
|
+
if (!Number.isFinite(offset)) return 0;
|
|
430
|
+
return Math.max(0, Math.floor(offset));
|
|
431
|
+
}
|
|
432
|
+
function isShortNoisyKeyword(query) {
|
|
433
|
+
const compact = query.replace(/\s+/g, "");
|
|
434
|
+
if (!compact) return false;
|
|
435
|
+
if (compact.length <= 2) return true;
|
|
436
|
+
return compact.length <= 3 && /^[A-Za-z0-9]+$/.test(compact);
|
|
437
|
+
}
|
|
438
|
+
async function fetchAllPages2(query, lat, lng, radiusM, env, fetcher, metadata) {
|
|
439
|
+
const documents = [];
|
|
440
|
+
for (let page = 1; page <= 3; page += 1) {
|
|
441
|
+
const url = new URL("https://dapi.kakao.com/v2/local/search/keyword.json");
|
|
442
|
+
url.searchParams.set("query", query);
|
|
443
|
+
url.searchParams.set("x", String(lng));
|
|
444
|
+
url.searchParams.set("y", String(lat));
|
|
445
|
+
url.searchParams.set("radius", String(radiusM));
|
|
446
|
+
url.searchParams.set("sort", "distance");
|
|
447
|
+
url.searchParams.set("page", String(page));
|
|
448
|
+
url.searchParams.set("size", "15");
|
|
449
|
+
const response = await fetcher(url.toString(), {
|
|
450
|
+
headers: { Authorization: `KakaoAK ${env.KAKAO_REST_API_KEY}` }
|
|
451
|
+
});
|
|
452
|
+
if (!response.ok) throw new Error(`Kakao keyword API failed: HTTP ${response.status}`);
|
|
453
|
+
const payload = await readJsonResponse(response);
|
|
454
|
+
documents.push(...payload.documents ?? []);
|
|
455
|
+
if (payload.meta?.is_end !== false) break;
|
|
456
|
+
if (page === 3) {
|
|
457
|
+
metadata.truncated = true;
|
|
458
|
+
metadata.warnings ??= [];
|
|
459
|
+
metadata.warnings.push("Kakao Local API returned the maximum 45 results for at least one query; additional farther results may have been truncated.");
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (documents.length >= 45) {
|
|
463
|
+
metadata.truncated = true;
|
|
464
|
+
metadata.warnings ??= [];
|
|
465
|
+
const warning = "Kakao Local API returned the maximum 45 results for at least one query; additional farther results may have been truncated.";
|
|
466
|
+
if (!metadata.warnings.includes(warning)) metadata.warnings.push(warning);
|
|
467
|
+
}
|
|
468
|
+
return documents;
|
|
469
|
+
}
|
|
470
|
+
async function fetchGridCells(query, lat, lng, radiusM, env, fetcher, metadata) {
|
|
471
|
+
const radiusKm = radiusM / 1e3;
|
|
472
|
+
const cellKm = 1.25;
|
|
473
|
+
const dLat = 1 / 111;
|
|
474
|
+
const dLng = 1 / (111 * Math.cos(lat * Math.PI / 180));
|
|
475
|
+
const steps = Math.max(1, Math.ceil(radiusKm / cellKm));
|
|
476
|
+
const rects = [];
|
|
477
|
+
const seen = /* @__PURE__ */ new Map();
|
|
478
|
+
for (let i = -steps; i < steps; i += 1) {
|
|
479
|
+
for (let j = -steps; j < steps; j += 1) {
|
|
480
|
+
const south = lat + i * cellKm * dLat;
|
|
481
|
+
const north = lat + (i + 1) * cellKm * dLat;
|
|
482
|
+
const west = lng + j * cellKm * dLng;
|
|
483
|
+
const east = lng + (j + 1) * cellKm * dLng;
|
|
484
|
+
rects.push(`${west},${south},${east},${north}`);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
const cellResults = await mapWithConcurrency(rects, KAKAO_GRID_CONCURRENCY, async (rect) => fetchGridCell(query, rect, env, fetcher));
|
|
488
|
+
let cellsTruncated = 0;
|
|
489
|
+
for (const result of cellResults) {
|
|
490
|
+
for (const doc of result.documents) {
|
|
491
|
+
if (!seen.has(doc.id)) seen.set(doc.id, doc);
|
|
492
|
+
}
|
|
493
|
+
if (result.truncated) cellsTruncated += 1;
|
|
494
|
+
}
|
|
495
|
+
if (cellsTruncated > 0) {
|
|
496
|
+
metadata.truncated = true;
|
|
497
|
+
metadata.warnings ??= [];
|
|
498
|
+
metadata.warnings.push(`Grid mode: ${cellsTruncated} cell(s) still hit the 45-result cap; narrow cell size or radius for exhaustive coverage in very dense areas.`);
|
|
499
|
+
}
|
|
500
|
+
return [...seen.values()];
|
|
501
|
+
}
|
|
502
|
+
async function fetchGridCell(query, rect, env, fetcher) {
|
|
503
|
+
const documents = [];
|
|
504
|
+
let truncated = false;
|
|
505
|
+
for (let page = 1; page <= 3; page += 1) {
|
|
506
|
+
const url = new URL("https://dapi.kakao.com/v2/local/search/keyword.json");
|
|
507
|
+
url.searchParams.set("query", query);
|
|
508
|
+
url.searchParams.set("rect", rect);
|
|
509
|
+
url.searchParams.set("page", String(page));
|
|
510
|
+
url.searchParams.set("size", "15");
|
|
511
|
+
const response = await fetcher(url.toString(), { headers: { Authorization: `KakaoAK ${env.KAKAO_REST_API_KEY}` } });
|
|
512
|
+
if (!response.ok) throw new Error(`Kakao keyword API failed: HTTP ${response.status}`);
|
|
513
|
+
const payload = await readJsonResponse(response);
|
|
514
|
+
documents.push(...payload.documents ?? []);
|
|
515
|
+
if (payload.meta?.is_end !== false) break;
|
|
516
|
+
if (page === 3) truncated = true;
|
|
517
|
+
}
|
|
518
|
+
return { documents, truncated };
|
|
519
|
+
}
|
|
520
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
521
|
+
const results = new Array(items.length);
|
|
522
|
+
let next = 0;
|
|
523
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
524
|
+
while (next < items.length) {
|
|
525
|
+
const current = next;
|
|
526
|
+
next += 1;
|
|
527
|
+
results[current] = await mapper(items[current]);
|
|
528
|
+
}
|
|
529
|
+
});
|
|
530
|
+
await Promise.all(workers);
|
|
531
|
+
return results;
|
|
532
|
+
}
|
|
533
|
+
function matchesFilter(categoryName, filter) {
|
|
534
|
+
if (!filter) return true;
|
|
535
|
+
const includes = filter.include_contains ?? [];
|
|
536
|
+
const excludes = filter.exclude_contains ?? [];
|
|
537
|
+
return includes.every((needle) => categoryName.includes(needle)) && !excludes.some((needle) => categoryName.includes(needle));
|
|
538
|
+
}
|
|
539
|
+
function deduplicate(places, deduplicateBy) {
|
|
540
|
+
if (!deduplicateBy) return places;
|
|
541
|
+
const seen = /* @__PURE__ */ new Set();
|
|
542
|
+
const deduped = [];
|
|
543
|
+
for (const place of places) {
|
|
544
|
+
const key = deduplicateBy === "name" ? place.name : place.address;
|
|
545
|
+
if (seen.has(key)) continue;
|
|
546
|
+
seen.add(key);
|
|
547
|
+
deduped.push(place);
|
|
548
|
+
}
|
|
549
|
+
return deduped;
|
|
550
|
+
}
|
|
551
|
+
function normalizePlace2(doc, centerLat, centerLng) {
|
|
552
|
+
const lat = Number(doc.y);
|
|
553
|
+
const lng = Number(doc.x);
|
|
554
|
+
const distanceM = doc.distance && doc.distance.length > 0 ? Number(doc.distance) : haversineMeters({ lat: centerLat, lng: centerLng }, { lat, lng });
|
|
555
|
+
return {
|
|
556
|
+
name: doc.place_name,
|
|
557
|
+
lat,
|
|
558
|
+
lng,
|
|
559
|
+
distance_m: Math.round(distanceM),
|
|
560
|
+
distance_label: distanceLabel(distanceM),
|
|
561
|
+
address: doc.road_address_name || doc.address_name,
|
|
562
|
+
...doc.road_address_name ? { road_address: doc.road_address_name } : {},
|
|
563
|
+
kakao_place_id: doc.id,
|
|
564
|
+
kakao_category_name: doc.category_name
|
|
565
|
+
};
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// src/providers/applyhome.ts
|
|
569
|
+
var APT_ENDPOINT = "https://api.odcloud.kr/api/ApplyhomeInfoDetailSvc/v1/getAPTLttotPblancDetail";
|
|
570
|
+
var RETRY_DELAYS_MS = [25, 50];
|
|
571
|
+
var SUBSCRPT_AREA_CODE_NAMES = {
|
|
572
|
+
"100": "\uC11C\uC6B8",
|
|
573
|
+
"200": "\uBD80\uC0B0",
|
|
574
|
+
"300": "\uB300\uC804",
|
|
575
|
+
"312": "\uCDA9\uB0A8",
|
|
576
|
+
"400": "\uC778\uCC9C",
|
|
577
|
+
"500": "\uAD11\uC8FC",
|
|
578
|
+
"600": "\uB300\uAD6C",
|
|
579
|
+
"700": "\uC6B8\uC0B0",
|
|
580
|
+
"800": "\uC138\uC885",
|
|
581
|
+
"900": "\uACBD\uAE30"
|
|
582
|
+
};
|
|
583
|
+
async function fetchApplyHomeAptAnnouncements(query, env, fetcher = fetch) {
|
|
584
|
+
const serviceKey = env.DATA_GO_KR_SERVICE_KEY;
|
|
585
|
+
if (!serviceKey) {
|
|
586
|
+
return { rows: [], sources: [], warnings: ["DATA_GO_KR_SERVICE_KEY is missing."] };
|
|
587
|
+
}
|
|
588
|
+
const perPage = query.perPage ?? 100;
|
|
589
|
+
let page = query.page ?? 1;
|
|
590
|
+
const rows = [];
|
|
591
|
+
const sources = [];
|
|
592
|
+
const warnings = [];
|
|
593
|
+
while (true) {
|
|
594
|
+
const url = buildApplyHomeAptUrl({ ...query, page, perPage }, serviceKey);
|
|
595
|
+
sources.push(redactServiceKeyInUrl(url.toString(), serviceKey));
|
|
596
|
+
let response = await fetcher(url.toString(), { headers: { Accept: "application/json" } });
|
|
597
|
+
if (!response.ok && response.status === 400 && query.regionCode && !query.regionName && SUBSCRPT_AREA_CODE_NAMES[query.regionCode]) {
|
|
598
|
+
const regionName = SUBSCRPT_AREA_CODE_NAMES[query.regionCode];
|
|
599
|
+
warnings.push(`ApplyHome APT region code condition returned HTTP 400; retried with region name ${regionName}.`);
|
|
600
|
+
const fallbackUrl = buildApplyHomeAptUrl({ ...query, regionCode: void 0, regionName, page, perPage }, serviceKey);
|
|
601
|
+
sources.push(redactServiceKeyInUrl(fallbackUrl.toString(), serviceKey));
|
|
602
|
+
response = await fetchWithRetry(fallbackUrl.toString(), fetcher);
|
|
603
|
+
} else if (!response.ok) {
|
|
604
|
+
response = await retryAfterInitialResponse(url.toString(), fetcher, response);
|
|
605
|
+
}
|
|
606
|
+
if (!response.ok) {
|
|
607
|
+
warnings.push(`ApplyHome APT API failed: HTTP ${response.status}`);
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
const payload = await readJsonResponse(response);
|
|
611
|
+
const pageRows = Array.isArray(payload.data) ? payload.data : [];
|
|
612
|
+
rows.push(...pageRows);
|
|
613
|
+
const totalCount = Number(payload.totalCount ?? pageRows.length);
|
|
614
|
+
const responsePerPage = Number(payload.perPage ?? perPage);
|
|
615
|
+
const responsePage = Number(payload.page ?? page);
|
|
616
|
+
if (responsePage * responsePerPage >= totalCount || pageRows.length === 0) break;
|
|
617
|
+
page += 1;
|
|
618
|
+
}
|
|
619
|
+
return { rows, sources, warnings };
|
|
620
|
+
}
|
|
621
|
+
async function fetchWithRetry(url, fetcher) {
|
|
622
|
+
const response = await fetcher(url, { headers: { Accept: "application/json" } });
|
|
623
|
+
return retryAfterInitialResponse(url, fetcher, response);
|
|
624
|
+
}
|
|
625
|
+
async function retryAfterInitialResponse(url, fetcher, initialResponse) {
|
|
626
|
+
let response = initialResponse;
|
|
627
|
+
for (const delayMs of RETRY_DELAYS_MS) {
|
|
628
|
+
if (response.ok) return response;
|
|
629
|
+
await sleep(delayMs);
|
|
630
|
+
response = await fetcher(url, { headers: { Accept: "application/json" } });
|
|
631
|
+
}
|
|
632
|
+
return response;
|
|
633
|
+
}
|
|
634
|
+
function sleep(ms) {
|
|
635
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
636
|
+
}
|
|
637
|
+
function buildApplyHomeAptUrl(query, serviceKey) {
|
|
638
|
+
const url = new URL(APT_ENDPOINT);
|
|
639
|
+
url.searchParams.set("page", String(query.page));
|
|
640
|
+
url.searchParams.set("perPage", String(query.perPage));
|
|
641
|
+
url.searchParams.set("cond[RCRIT_PBLANC_DE::GTE]", query.dateFrom);
|
|
642
|
+
if (query.dateTo) url.searchParams.set("cond[RCRIT_PBLANC_DE::LTE]", query.dateTo);
|
|
643
|
+
if (query.regionCode) url.searchParams.set("cond[SUBSCRPT_AREA_CODE::EQ]", query.regionCode);
|
|
644
|
+
if (query.regionName) url.searchParams.set("cond[SUBSCRPT_AREA_CODE_NM::EQ]", query.regionName);
|
|
645
|
+
if (query.applyAddress) url.searchParams.set("cond[HSSPLY_ADRES::LIKE]", query.applyAddress);
|
|
646
|
+
if (query.houseName) url.searchParams.set("cond[HOUSE_NM::LIKE]", query.houseName);
|
|
647
|
+
url.searchParams.set("serviceKey", serviceKey);
|
|
648
|
+
return url;
|
|
649
|
+
}
|
|
650
|
+
function redactServiceKeyInUrl(sourceUrl, serviceKey) {
|
|
651
|
+
const variants = /* @__PURE__ */ new Set();
|
|
652
|
+
if (serviceKey) {
|
|
653
|
+
variants.add(serviceKey);
|
|
654
|
+
variants.add(encodeURIComponent(serviceKey));
|
|
655
|
+
try {
|
|
656
|
+
variants.add(decodeURIComponent(serviceKey));
|
|
657
|
+
} catch {
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
let redacted = sourceUrl.replace(/([?&]serviceKey=)[^&]*/g, "$1{SERVICE_KEY}");
|
|
661
|
+
for (const variant of variants) {
|
|
662
|
+
if (!variant) continue;
|
|
663
|
+
redacted = redacted.split(variant).join("{SERVICE_KEY}");
|
|
664
|
+
}
|
|
665
|
+
return redacted;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
// src/providers/kakao-region.ts
|
|
669
|
+
async function getRegionNameForCoordinate(point, env, fetcher = fetch) {
|
|
670
|
+
if (!env.KAKAO_REST_API_KEY) {
|
|
671
|
+
return { warning: "KAKAO_REST_API_KEY is missing; coordinate/radius region sampling is unavailable." };
|
|
672
|
+
}
|
|
673
|
+
const url = new URL("https://dapi.kakao.com/v2/local/geo/coord2regioncode.json");
|
|
674
|
+
url.searchParams.set("x", String(point.lng));
|
|
675
|
+
url.searchParams.set("y", String(point.lat));
|
|
676
|
+
const response = await fetcher(url.toString(), {
|
|
677
|
+
headers: { Authorization: `KakaoAK ${env.KAKAO_REST_API_KEY}` }
|
|
678
|
+
});
|
|
679
|
+
if (!response.ok) {
|
|
680
|
+
return { warning: `Kakao coord2regioncode failed: HTTP ${response.status}` };
|
|
681
|
+
}
|
|
682
|
+
const payload = await readJsonResponse(response);
|
|
683
|
+
const document = payload.documents?.find((item) => item.region_type === "B") ?? payload.documents?.[0];
|
|
684
|
+
if (!document) return { warning: "Kakao coord2regioncode returned no region document." };
|
|
685
|
+
const parts = [document.region_1depth_name, document.region_2depth_name, document.region_3depth_name, document.region_4depth_name].map((part) => part?.trim()).filter((part) => Boolean(part));
|
|
686
|
+
return parts.length ? { regionName: parts.join(" ") } : { warning: "Kakao coord2regioncode region document had no region names." };
|
|
687
|
+
}
|
|
688
|
+
function expandRegionNameQueries(regionName) {
|
|
689
|
+
const parts = regionName.split(/\s+/).filter(Boolean);
|
|
690
|
+
const queries = /* @__PURE__ */ new Set();
|
|
691
|
+
if (parts.length >= 3) queries.add(parts.slice(0, 3).join(" "));
|
|
692
|
+
if (parts.length >= 2) queries.add(parts.slice(0, 2).join(" "));
|
|
693
|
+
queries.add(regionName);
|
|
694
|
+
return [...queries];
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// src/resolve/name.ts
|
|
698
|
+
var SIM_MIN = 0.7;
|
|
699
|
+
var GAP = 0.1;
|
|
700
|
+
var MAX_CANDIDATES = 5;
|
|
701
|
+
var BRAND_MAP = [
|
|
702
|
+
[/skview/g, "sk\uBDF0"],
|
|
703
|
+
[/푸르지오|prugio/g, "prugio"],
|
|
704
|
+
[/아이파크|ipark/g, "ipark"],
|
|
705
|
+
[/힐스테이트|hillstate/g, "hillstate"],
|
|
706
|
+
[/래미안/g, "\uB798\uBBF8\uC548"],
|
|
707
|
+
[/캐슬|castle/g, "castle"],
|
|
708
|
+
[/자이|xi/g, "xi"],
|
|
709
|
+
[/위브|weve/g, "weve"],
|
|
710
|
+
[/sk뷰/g, "sk\uBDF0"],
|
|
711
|
+
[/뷰|view/g, "view"],
|
|
712
|
+
[/이편한세상|e편한세상/g, "\uC774\uD3B8\uD55C\uC138\uC0C1"],
|
|
713
|
+
[/the/g, "\uB354"]
|
|
714
|
+
];
|
|
715
|
+
var SIDE_TOKENS = /(아파트|apt|견본주택|모델하우스|분양사무소)/g;
|
|
716
|
+
var ROMAN = [
|
|
717
|
+
[/Ⅹ|ⅹ/g, "10"],
|
|
718
|
+
[/Ⅸ|ⅸ/g, "9"],
|
|
719
|
+
[/Ⅷ|ⅷ/g, "8"],
|
|
720
|
+
[/Ⅶ|ⅶ/g, "7"],
|
|
721
|
+
[/Ⅵ|ⅵ/g, "6"],
|
|
722
|
+
[/Ⅴ|ⅴ/g, "5"],
|
|
723
|
+
[/Ⅳ|ⅳ/g, "4"],
|
|
724
|
+
[/Ⅲ|ⅲ/g, "3"],
|
|
725
|
+
[/Ⅱ|ⅱ/g, "2"],
|
|
726
|
+
[/Ⅰ|ⅰ/g, "1"]
|
|
727
|
+
];
|
|
728
|
+
var ALPHA_KO = {
|
|
729
|
+
a: "\uC5D0\uC774",
|
|
730
|
+
b: "\uBE44",
|
|
731
|
+
c: "\uC528",
|
|
732
|
+
d: "\uB514",
|
|
733
|
+
e: "\uC774",
|
|
734
|
+
f: "\uC5D0\uD504",
|
|
735
|
+
g: "\uC9C0",
|
|
736
|
+
h: "\uC5D0\uC774\uCE58",
|
|
737
|
+
i: "\uC544\uC774",
|
|
738
|
+
j: "\uC81C\uC774",
|
|
739
|
+
k: "\uCF00\uC774",
|
|
740
|
+
l: "\uC5D8",
|
|
741
|
+
m: "\uC5E0",
|
|
742
|
+
n: "\uC5D4",
|
|
743
|
+
o: "\uC624",
|
|
744
|
+
p: "\uD53C",
|
|
745
|
+
q: "\uD050",
|
|
746
|
+
r: "\uC54C",
|
|
747
|
+
s: "\uC5D0\uC2A4",
|
|
748
|
+
t: "\uD2F0",
|
|
749
|
+
u: "\uC720",
|
|
750
|
+
v: "\uBE0C\uC774",
|
|
751
|
+
w: "\uB354\uBE14\uC720",
|
|
752
|
+
x: "\uC5D1\uC2A4",
|
|
753
|
+
y: "\uC640\uC774",
|
|
754
|
+
z: "\uC9C0"
|
|
755
|
+
};
|
|
756
|
+
function normalizeComplexName(name) {
|
|
757
|
+
let n = name.toLowerCase();
|
|
758
|
+
for (const [re, rep] of ROMAN) n = n.replace(re, rep);
|
|
759
|
+
n = n.replace(/\([^)]*\)/g, "");
|
|
760
|
+
n = n.replace(/[\s\-·.,()[\]{}]/g, "");
|
|
761
|
+
n = n.replace(/[a-z](?=\d+bl)/g, "");
|
|
762
|
+
for (const [re, rep] of BRAND_MAP) n = n.replace(re, rep);
|
|
763
|
+
n = n.replace(/(?<![a-z])[a-z](?![a-z])/g, (ch) => ALPHA_KO[ch] ?? ch);
|
|
764
|
+
n = n.replace(SIDE_TOKENS, "");
|
|
765
|
+
return n;
|
|
766
|
+
}
|
|
767
|
+
var CHO = ["\u3131", "\u3132", "\u3134", "\u3137", "\u3138", "\u3139", "\u3141", "\u3142", "\u3143", "\u3145", "\u3146", "\u3147", "\u3148", "\u3149", "\u314A", "\u314B", "\u314C", "\u314D", "\u314E"];
|
|
768
|
+
var JUNG = ["\u314F", "\u3150", "\u3151", "\u3152", "\u3153", "\u3154", "\u3155", "\u3156", "\u3157", "\u3158", "\u3159", "\u315A", "\u315B", "\u315C", "\u315D", "\u315E", "\u315F", "\u3160", "\u3161", "\u3162", "\u3163"];
|
|
769
|
+
var JONG = ["", "\u3131", "\u3132", "\u3133", "\u3134", "\u3135", "\u3136", "\u3137", "\u3139", "\u313A", "\u313B", "\u313C", "\u313D", "\u313E", "\u313F", "\u3140", "\u3141", "\u3142", "\u3144", "\u3145", "\u3146", "\u3147", "\u3148", "\u314A", "\u314B", "\u314C", "\u314D", "\u314E"];
|
|
770
|
+
function decomposeJamo(text3) {
|
|
771
|
+
let out = "";
|
|
772
|
+
for (const ch of text3) {
|
|
773
|
+
const code = ch.charCodeAt(0);
|
|
774
|
+
if (code >= 44032 && code <= 55203) {
|
|
775
|
+
const offset = code - 44032;
|
|
776
|
+
const cho = Math.floor(offset / 588);
|
|
777
|
+
const jung = Math.floor(offset % 588 / 28);
|
|
778
|
+
const jong = offset % 28;
|
|
779
|
+
out += CHO[cho] + JUNG[jung] + JONG[jong];
|
|
780
|
+
} else {
|
|
781
|
+
out += ch;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
return out;
|
|
785
|
+
}
|
|
786
|
+
function complexNameSimilarity(a, b, opts = {}) {
|
|
787
|
+
const normalizedA = opts.aNormalized ? a : normalizeComplexName(a);
|
|
788
|
+
const normalizedB = opts.bNormalized ? b : normalizeComplexName(b);
|
|
789
|
+
const na = decomposeJamo(normalizedA);
|
|
790
|
+
const nb = decomposeJamo(normalizedB);
|
|
791
|
+
if (!na && !nb) return 1;
|
|
792
|
+
if (!na || !nb) return 0;
|
|
793
|
+
if (na === nb) return 1;
|
|
794
|
+
const dist = levenshtein(na, nb);
|
|
795
|
+
const editSim = 1 - dist / Math.max(na.length, nb.length);
|
|
796
|
+
const [shorter, longer] = na.length <= nb.length ? [na, nb] : [nb, na];
|
|
797
|
+
const containSim = longer.includes(shorter) ? 0.9 + 0.1 * (shorter.length / longer.length) : 0;
|
|
798
|
+
const diceSim = reorderedNameDiceSimilarity(normalizedA, normalizedB);
|
|
799
|
+
const sim = Math.max(editSim, containSim, diceSim);
|
|
800
|
+
return capNumericMismatchSimilarity(normalizedA, normalizedB, sim);
|
|
801
|
+
}
|
|
802
|
+
function reorderedNameDiceSimilarity(a, b) {
|
|
803
|
+
const minLen = Math.min(a.length, b.length);
|
|
804
|
+
const maxLen = Math.max(a.length, b.length);
|
|
805
|
+
if (minLen < 6 || minLen / maxLen < 0.6) return 0;
|
|
806
|
+
return diceSimilarity(charNgrams(a, 2), charNgrams(b, 2));
|
|
807
|
+
}
|
|
808
|
+
function charNgrams(value, n) {
|
|
809
|
+
if (value.length <= n) return [value];
|
|
810
|
+
const grams = [];
|
|
811
|
+
for (let i = 0; i <= value.length - n; i += 1) {
|
|
812
|
+
grams.push(value.slice(i, i + n));
|
|
813
|
+
}
|
|
814
|
+
return grams;
|
|
815
|
+
}
|
|
816
|
+
function diceSimilarity(a, b) {
|
|
817
|
+
if (a.length === 0 && b.length === 0) return 1;
|
|
818
|
+
if (a.length === 0 || b.length === 0) return 0;
|
|
819
|
+
const counts = /* @__PURE__ */ new Map();
|
|
820
|
+
for (const gram of a) counts.set(gram, (counts.get(gram) ?? 0) + 1);
|
|
821
|
+
let intersection = 0;
|
|
822
|
+
for (const gram of b) {
|
|
823
|
+
const count = counts.get(gram) ?? 0;
|
|
824
|
+
if (count <= 0) continue;
|
|
825
|
+
intersection += 1;
|
|
826
|
+
counts.set(gram, count - 1);
|
|
827
|
+
}
|
|
828
|
+
return 2 * intersection / (a.length + b.length);
|
|
829
|
+
}
|
|
830
|
+
function capNumericMismatchSimilarity(a, b, sim) {
|
|
831
|
+
const aNumbers = numericTokens(a);
|
|
832
|
+
const bNumbers = numericTokens(b);
|
|
833
|
+
if (aNumbers.length === 0 || bNumbers.length === 0) return sim;
|
|
834
|
+
if (aNumbers.join(",") === bNumbers.join(",")) return sim;
|
|
835
|
+
return Math.min(sim, SIM_MIN - 0.01);
|
|
836
|
+
}
|
|
837
|
+
function numericTokens(value) {
|
|
838
|
+
return [...value.matchAll(/\d+/g)].map((match) => match[0]);
|
|
839
|
+
}
|
|
840
|
+
function levenshtein(a, b) {
|
|
841
|
+
const m = a.length;
|
|
842
|
+
const n = b.length;
|
|
843
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
844
|
+
for (let i = 0; i <= m; i += 1) dp[i][0] = i;
|
|
845
|
+
for (let j = 0; j <= n; j += 1) dp[0][j] = j;
|
|
846
|
+
for (let i = 1; i <= m; i += 1) {
|
|
847
|
+
for (let j = 1; j <= n; j += 1) {
|
|
848
|
+
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
|
|
849
|
+
dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + cost);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return dp[m][n];
|
|
853
|
+
}
|
|
854
|
+
function scoreAndDecide(query, items) {
|
|
855
|
+
const nq = normalizeComplexName(query);
|
|
856
|
+
const scored = items.filter((it) => it.code && it.name).map((it) => ({
|
|
857
|
+
code: it.code,
|
|
858
|
+
name: it.name,
|
|
859
|
+
...it.address_road ? { address_road: it.address_road } : {},
|
|
860
|
+
sim: complexNameSimilarity(it.name, nq, { bNormalized: true })
|
|
861
|
+
})).sort((a, b) => b.sim - a.sim);
|
|
862
|
+
const top = scored[0];
|
|
863
|
+
if (!top || top.sim < SIM_MIN) {
|
|
864
|
+
return { status: "not_found", candidates: scored.slice(0, MAX_CANDIDATES) };
|
|
865
|
+
}
|
|
866
|
+
if (top.sim >= 0.9999) {
|
|
867
|
+
return { status: "confirmed", code: top.code, name: top.name, match: "exact", sim: top.sim, candidates: scored.slice(0, MAX_CANDIDATES) };
|
|
868
|
+
}
|
|
869
|
+
const second = scored[1];
|
|
870
|
+
const gap = second ? top.sim - second.sim : top.sim;
|
|
871
|
+
if (gap < GAP) {
|
|
872
|
+
return { status: "ambiguous", candidates: scored.slice(0, MAX_CANDIDATES) };
|
|
873
|
+
}
|
|
874
|
+
return { status: "confirmed", code: top.code, name: top.name, match: "approximate", sim: top.sim, candidates: scored.slice(0, MAX_CANDIDATES) };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// src/tools/presale-announcements.ts
|
|
878
|
+
var MAX_GEOCODE_CALLS = 200;
|
|
879
|
+
var MAX_PROVIDER_QUERIES = 30;
|
|
880
|
+
var DEFAULT_PROVIDER_CONCURRENCY = 6;
|
|
881
|
+
var DEFAULT_PROVIDER_TIMEOUT_MS = 8e3;
|
|
882
|
+
var MAX_METADATA_SOURCES = 5;
|
|
883
|
+
var DEFAULT_GEOCODE_CONCURRENCY = 6;
|
|
884
|
+
var DEFAULT_GEOCODE_TIMEOUT_MS = 5e3;
|
|
885
|
+
var DEFAULT_GEOCODE_DEADLINE_MS = 75e3;
|
|
886
|
+
function normalizeApplyHomeAptRow(row, today = /* @__PURE__ */ new Date()) {
|
|
887
|
+
const announcement = {
|
|
888
|
+
announcement_id: text(row.PBLANC_NO),
|
|
889
|
+
house_manage_no: text(row.HOUSE_MANAGE_NO),
|
|
890
|
+
type: "apt",
|
|
891
|
+
type_label: "APT \uC77C\uBC18\uBD84\uC591",
|
|
892
|
+
name: text(row.HOUSE_NM),
|
|
893
|
+
address: text(row.HSSPLY_ADRES),
|
|
894
|
+
region: {
|
|
895
|
+
...optionalText(row.SUBSCRPT_AREA_CODE) ? { sido_code: optionalText(row.SUBSCRPT_AREA_CODE) } : {},
|
|
896
|
+
...optionalText(row.SUBSCRPT_AREA_CODE_NM) ? { sido_name: optionalText(row.SUBSCRPT_AREA_CODE_NM) } : {},
|
|
897
|
+
...parseSigungu(text(row.HSSPLY_ADRES)) ? { sigungu: parseSigungu(text(row.HSSPLY_ADRES)) } : {}
|
|
898
|
+
},
|
|
899
|
+
dates: buildDates(row, today),
|
|
900
|
+
regulations: {
|
|
901
|
+
...optionalBool(row.SPECLT_RDN_EARTH_AT) !== void 0 ? { speculation_overheat: optionalBool(row.SPECLT_RDN_EARTH_AT) } : {},
|
|
902
|
+
...optionalText(row.MDAT_TRGET_AREA_SECD) ? { adjustment_target: optionalText(row.MDAT_TRGET_AREA_SECD) } : {},
|
|
903
|
+
...optionalBool(row.PARCPRC_ULS_AT) !== void 0 ? { price_ceiling: optionalBool(row.PARCPRC_ULS_AT) } : {},
|
|
904
|
+
...optionalBool(row.IMPRMN_BSNS_AT) !== void 0 ? { improvement_project: optionalBool(row.IMPRMN_BSNS_AT) } : {},
|
|
905
|
+
...optionalBool(row.PUBLIC_HOUSE_EARTH_AT) !== void 0 ? { public_house_district: optionalBool(row.PUBLIC_HOUSE_EARTH_AT) } : {},
|
|
906
|
+
...optionalBool(row.LRSCL_BLDLND_AT) !== void 0 ? { large_scale_district: optionalBool(row.LRSCL_BLDLND_AT) } : {},
|
|
907
|
+
...optionalBool(row.NPLN_PRVOPR_PUBLIC_HOUSE_AT) !== void 0 ? { metropolitan_private: optionalBool(row.NPLN_PRVOPR_PUBLIC_HOUSE_AT) } : {},
|
|
908
|
+
...optionalBool(row.PUBLIC_HOUSE_SPCLM_APPLC_APT) !== void 0 ? { public_house_special: optionalBool(row.PUBLIC_HOUSE_SPCLM_APPLC_APT) } : {}
|
|
909
|
+
},
|
|
910
|
+
status: calculatePresaleStatus(row, today)
|
|
911
|
+
};
|
|
912
|
+
addOptional(announcement, "address_zip", row.HSSPLY_ZIP);
|
|
913
|
+
addOptionalNumber(announcement, "total_units", row.TOT_SUPLY_HSHLDCO);
|
|
914
|
+
addOptional(announcement, "developer", row.BSNS_MBY_NM);
|
|
915
|
+
addOptional(announcement, "constructor", row.CNSTRCT_ENTRPS_NM);
|
|
916
|
+
addOptional(announcement, "contact_tel", row.MDHS_TELNO);
|
|
917
|
+
addOptional(announcement, "homepage_url", row.HMPG_ADRES);
|
|
918
|
+
addOptional(announcement, "newspaper", row.NSPRC_NM);
|
|
919
|
+
addOptional(announcement, "house_type", row.HOUSE_SECD_NM);
|
|
920
|
+
addOptional(announcement, "detail_type", row.HOUSE_DTL_SECD_NM);
|
|
921
|
+
addOptional(announcement, "rent_type", row.RENT_SECD_NM);
|
|
922
|
+
addOptional(announcement, "official_url", row.PBLANC_URL);
|
|
923
|
+
return announcement;
|
|
924
|
+
}
|
|
925
|
+
function calculatePresaleStatus(row, today = /* @__PURE__ */ new Date()) {
|
|
926
|
+
const day = dateOnly(today);
|
|
927
|
+
const subscriptionStart = minDate([row.SPSPLY_RCEPT_BGNDE, row.GNRL_RNK1_CRSPAREA_RCPTDE, row.GNRL_RNK1_ETC_GG_RCPTDE, row.GNRL_RNK1_ETC_AREA_RCPTDE, row.GNRL_RNK2_CRSPAREA_RCPTDE, row.GNRL_RNK2_ETC_GG_RCPTDE, row.GNRL_RNK2_ETC_AREA_RCPTDE]);
|
|
928
|
+
const subscriptionEnd = maxDate([row.SPSPLY_RCEPT_ENDDE, row.GNRL_RNK1_CRSPAREA_ENDDE, row.GNRL_RNK1_ETC_GG_ENDDE, row.GNRL_RNK1_ETC_AREA_ENDDE, row.GNRL_RNK2_CRSPAREA_ENDDE, row.GNRL_RNK2_ETC_GG_ENDDE, row.GNRL_RNK2_ETC_AREA_ENDDE]);
|
|
929
|
+
const contractStart = parseDate(row.CNTRCT_CNCLS_BGNDE);
|
|
930
|
+
const contractEnd = parseDate(row.CNTRCT_CNCLS_ENDDE);
|
|
931
|
+
if (!subscriptionStart && !subscriptionEnd && !contractStart && !contractEnd) return "\uC0C1\uD0DC\uBBF8\uC0C1";
|
|
932
|
+
if (subscriptionStart && day < subscriptionStart) return "\uBD84\uC591\uC608\uC815";
|
|
933
|
+
if (subscriptionStart && subscriptionEnd && day >= subscriptionStart && day <= subscriptionEnd) return "\uBD84\uC591\uC911";
|
|
934
|
+
if (contractStart && contractEnd && day >= contractStart && day <= contractEnd) return "\uACC4\uC57D\uC9C4\uD589";
|
|
935
|
+
if (contractEnd && day > contractEnd) return "\uBD84\uC591\uC644\uB8CC";
|
|
936
|
+
if (subscriptionEnd && day > subscriptionEnd) return "\uCCAD\uC57D\uC644\uB8CC";
|
|
937
|
+
return "\uC0C1\uD0DC\uBBF8\uC0C1";
|
|
938
|
+
}
|
|
939
|
+
async function searchPresaleAnnouncements(input, env, deps = {}) {
|
|
940
|
+
const today = deps.today ?? /* @__PURE__ */ new Date();
|
|
941
|
+
const monthsBack = clampInt(input.months_back ?? 24, 1, 180);
|
|
942
|
+
const radiusKm = clampInt(input.radius_km ?? 5, 1, 20);
|
|
943
|
+
const dateRange = resolveDateRange(input, today, monthsBack);
|
|
944
|
+
const warnings = [];
|
|
945
|
+
const sources = [];
|
|
946
|
+
const bestEffortRadius = input.center_lat !== void 0 && input.center_lng !== void 0;
|
|
947
|
+
let providerQueryDiscoveredCount = 0;
|
|
948
|
+
let providerQueryCount = 0;
|
|
949
|
+
let providerQueryOmittedCount = 0;
|
|
950
|
+
let providerCacheHits = 0;
|
|
951
|
+
let providerQueryFailedCount = 0;
|
|
952
|
+
let providerQueryTimeoutCount = 0;
|
|
953
|
+
let geocodeStats = emptyGeocodeStats(deps);
|
|
954
|
+
const resultLimit = normalizeResultLimit(input.limit);
|
|
955
|
+
const baseResult = () => {
|
|
956
|
+
const sourceCount = sources.length;
|
|
957
|
+
const metadataSources = sources.slice(0, MAX_METADATA_SOURCES);
|
|
958
|
+
if (sourceCount > metadataSources.length) {
|
|
959
|
+
pushUnique(warnings, `ApplyHome sources trimmed to ${metadataSources.length} representative URL(s); source_count=${sourceCount}.`);
|
|
960
|
+
}
|
|
961
|
+
return {
|
|
962
|
+
input: buildInput(input, radiusKm, monthsBack, dateRange),
|
|
963
|
+
announcements: [],
|
|
964
|
+
unlocated_announcements: [],
|
|
965
|
+
summary: { total_found: 0, geocoded: 0, within_radius: bestEffortRadius ? 0 : null, unlocated: 0, by_status: {} },
|
|
966
|
+
metadata: {
|
|
967
|
+
cache_hit: false,
|
|
968
|
+
collected_at: nowIso(),
|
|
969
|
+
data_source: "applyhome_apt",
|
|
970
|
+
date_from: dateRange.dateFrom,
|
|
971
|
+
...dateRange.dateTo ? { date_to: dateRange.dateTo } : {},
|
|
972
|
+
best_effort_radius: bestEffortRadius,
|
|
973
|
+
sources: metadataSources,
|
|
974
|
+
source_count: sourceCount,
|
|
975
|
+
provider_query_count: providerQueryCount,
|
|
976
|
+
provider_query_discovered_count: providerQueryDiscoveredCount,
|
|
977
|
+
provider_query_omitted_count: providerQueryOmittedCount,
|
|
978
|
+
provider_query_truncated: providerQueryOmittedCount > 0,
|
|
979
|
+
provider_query_failed_count: providerQueryFailedCount,
|
|
980
|
+
provider_query_timeout_count: providerQueryTimeoutCount,
|
|
981
|
+
partial: providerQueryFailedCount > 0 || geocodeStats.timeoutCount > 0 || geocodeStats.skippedCount > 0,
|
|
982
|
+
geocode_attempted_count: geocodeStats.attemptedCount,
|
|
983
|
+
geocode_timeout_count: geocodeStats.timeoutCount,
|
|
984
|
+
geocode_skipped_count: geocodeStats.skippedCount,
|
|
985
|
+
geocode_deadline_ms: geocodeStats.deadlineMs,
|
|
986
|
+
geocode_concurrency: geocodeStats.concurrency,
|
|
987
|
+
geocode_timeout_ms: geocodeStats.timeoutMs,
|
|
988
|
+
returned_count: 0,
|
|
989
|
+
omitted_count: 0,
|
|
990
|
+
result_limit: resultLimit,
|
|
991
|
+
truncated: false,
|
|
992
|
+
...warnings.length ? { warnings } : {}
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
};
|
|
996
|
+
if (!input.house_name && !input.apply_address && !input.region_codes?.length && !bestEffortRadius) {
|
|
997
|
+
warnings.push("At least one search criterion is required: house_name, region_codes, apply_address, or center coordinates.");
|
|
998
|
+
return baseResult();
|
|
999
|
+
}
|
|
1000
|
+
if (bestEffortRadius) {
|
|
1001
|
+
warnings.push("Coordinate/radius ApplyHome search is best-effort because ApplyHome does not support radius search; boundary legal-dong omissions are possible.");
|
|
1002
|
+
}
|
|
1003
|
+
const discoveredProviderQueries = await buildProviderQueries(input, env, deps, dateRange, radiusKm, warnings);
|
|
1004
|
+
providerQueryDiscoveredCount = discoveredProviderQueries.length;
|
|
1005
|
+
const providerQueries = discoveredProviderQueries.slice(0, MAX_PROVIDER_QUERIES);
|
|
1006
|
+
providerQueryCount = providerQueries.length;
|
|
1007
|
+
providerQueryOmittedCount = Math.max(0, discoveredProviderQueries.length - providerQueries.length);
|
|
1008
|
+
if (providerQueryOmittedCount > 0) {
|
|
1009
|
+
warnings.push(`\uBC95\uC815\uB3D9 ${discoveredProviderQueries.length}\uAC1C \uC911 ${providerQueries.length}\uAC1C\uB9CC \uC870\uD68C\uD588\uC2B5\uB2C8\uB2E4. \uBC18\uACBD \uACBD\uACC4\uC758 \uBD84\uC591 \uACF5\uACE0\uAC00 \uB204\uB77D\uB420 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`);
|
|
1010
|
+
}
|
|
1011
|
+
const rawRows = [];
|
|
1012
|
+
const providerResults = await mapWithConcurrency2(providerQueries, normalizeConcurrency(deps.providerConcurrency), (query) => fetchApplyHomeAptAnnouncementsCached(query, env, deps));
|
|
1013
|
+
for (const { result: providerResult, cacheHit, timedOut, failed } of providerResults) {
|
|
1014
|
+
if (cacheHit) providerCacheHits += 1;
|
|
1015
|
+
if (timedOut) providerQueryTimeoutCount += 1;
|
|
1016
|
+
if (failed) providerQueryFailedCount += 1;
|
|
1017
|
+
rawRows.push(...providerResult.rows);
|
|
1018
|
+
sources.push(...providerResult.sources);
|
|
1019
|
+
warnings.push(...providerResult.warnings);
|
|
1020
|
+
}
|
|
1021
|
+
const uniqueRows = dedupeRows(rawRows);
|
|
1022
|
+
let announcements = uniqueRows.map((row) => normalizeApplyHomeAptRow(row, today));
|
|
1023
|
+
announcements = applyPostFilters(announcements, input);
|
|
1024
|
+
const result = baseResult();
|
|
1025
|
+
if (bestEffortRadius && input.center_lat !== void 0 && input.center_lng !== void 0) {
|
|
1026
|
+
const center = { lat: input.center_lat, lng: input.center_lng };
|
|
1027
|
+
const filtered = await geocodeAndFilterByRadius(announcements, center, radiusKm, env, deps, warnings);
|
|
1028
|
+
geocodeStats = filtered.stats;
|
|
1029
|
+
result.metadata.geocode_attempted_count = geocodeStats.attemptedCount;
|
|
1030
|
+
result.metadata.geocode_timeout_count = geocodeStats.timeoutCount;
|
|
1031
|
+
result.metadata.geocode_skipped_count = geocodeStats.skippedCount;
|
|
1032
|
+
result.metadata.geocode_deadline_ms = geocodeStats.deadlineMs;
|
|
1033
|
+
result.metadata.geocode_concurrency = geocodeStats.concurrency;
|
|
1034
|
+
result.metadata.geocode_timeout_ms = geocodeStats.timeoutMs;
|
|
1035
|
+
result.metadata.partial = result.metadata.partial || geocodeStats.timeoutCount > 0 || geocodeStats.skippedCount > 0;
|
|
1036
|
+
if (warnings.length) result.metadata.warnings = warnings;
|
|
1037
|
+
result.announcements = filtered.announcements;
|
|
1038
|
+
result.unlocated_announcements = filtered.unlocated;
|
|
1039
|
+
result.summary.geocoded = filtered.geocoded;
|
|
1040
|
+
result.summary.within_radius = filtered.announcements.length;
|
|
1041
|
+
result.summary.unlocated = filtered.unlocated.length;
|
|
1042
|
+
} else {
|
|
1043
|
+
result.announcements = announcements;
|
|
1044
|
+
}
|
|
1045
|
+
const fullAnnouncements = result.announcements;
|
|
1046
|
+
result.summary.total_found = fullAnnouncements.length;
|
|
1047
|
+
result.summary.by_status = summarizeStatus(fullAnnouncements);
|
|
1048
|
+
result.announcements = fullAnnouncements.slice(0, resultLimit);
|
|
1049
|
+
const omittedCount = Math.max(0, fullAnnouncements.length - result.announcements.length);
|
|
1050
|
+
if (omittedCount > 0) {
|
|
1051
|
+
result.metadata.truncated = true;
|
|
1052
|
+
result.metadata.warnings = [...result.metadata.warnings ?? [], `search_presale_announcements returned ${result.announcements.length} of ${fullAnnouncements.length} announcements; increase limit or narrow filters for more candidates.`];
|
|
1053
|
+
}
|
|
1054
|
+
result.metadata.returned_count = result.announcements.length;
|
|
1055
|
+
result.metadata.omitted_count = omittedCount;
|
|
1056
|
+
result.metadata.cache_hit = providerQueryCount > 0 && providerCacheHits === providerQueryCount;
|
|
1057
|
+
attachHouseNameNotFoundHint(result, input, dateRange.dateFrom);
|
|
1058
|
+
return result;
|
|
1059
|
+
}
|
|
1060
|
+
async function fetchApplyHomeAptAnnouncementsCached(query, env, deps) {
|
|
1061
|
+
if (!deps.cache) {
|
|
1062
|
+
const fetched = await fetchApplyHomeProviderQuery(query, env, deps);
|
|
1063
|
+
return { ...fetched, cacheHit: false };
|
|
1064
|
+
}
|
|
1065
|
+
const key = `applyhome:apt:${stableQueryKey(query)}`;
|
|
1066
|
+
const cached = deps.cache.getOrSetWithStatusIf ? await deps.cache.getOrSetWithStatusIf(
|
|
1067
|
+
key,
|
|
1068
|
+
TTL.announcements,
|
|
1069
|
+
() => fetchApplyHomeProviderQuery(query, env, deps),
|
|
1070
|
+
(value) => !value.failed && !value.timedOut
|
|
1071
|
+
) : await deps.cache.getOrSetWithStatus(
|
|
1072
|
+
key,
|
|
1073
|
+
TTL.announcements,
|
|
1074
|
+
() => fetchApplyHomeProviderQuery(query, env, deps)
|
|
1075
|
+
);
|
|
1076
|
+
return { ...cached.value, cacheHit: cached.hit };
|
|
1077
|
+
}
|
|
1078
|
+
async function fetchApplyHomeProviderQuery(query, env, deps) {
|
|
1079
|
+
const timeoutMs = normalizeTimeoutMs(deps.providerTimeoutMs);
|
|
1080
|
+
try {
|
|
1081
|
+
const result = await withTimeout(fetchApplyHomeAptAnnouncements(query, env, deps.fetcher), timeoutMs);
|
|
1082
|
+
const failed = result.warnings.some((warning) => warning.startsWith("ApplyHome APT API failed:"));
|
|
1083
|
+
return { result, failed, timedOut: false };
|
|
1084
|
+
} catch (error) {
|
|
1085
|
+
const timedOut = error instanceof Error && error.message === "PROVIDER_TIMEOUT";
|
|
1086
|
+
const warning = timedOut ? `ApplyHome provider query timed out after ${timeoutMs}ms: ${describeApplyHomeQuery(query)}.` : `ApplyHome provider query failed: ${error instanceof Error ? error.message : String(error)}.`;
|
|
1087
|
+
return { result: { rows: [], sources: [], warnings: [warning] }, failed: true, timedOut };
|
|
1088
|
+
}
|
|
1089
|
+
}
|
|
1090
|
+
function stableQueryKey(query) {
|
|
1091
|
+
return JSON.stringify({
|
|
1092
|
+
dateFrom: query.dateFrom,
|
|
1093
|
+
dateTo: query.dateTo ?? "",
|
|
1094
|
+
regionCode: query.regionCode ?? "",
|
|
1095
|
+
regionName: query.regionName ?? "",
|
|
1096
|
+
applyAddress: query.applyAddress ?? "",
|
|
1097
|
+
houseName: query.houseName ?? "",
|
|
1098
|
+
page: query.page ?? 1,
|
|
1099
|
+
perPage: query.perPage ?? 100
|
|
1100
|
+
});
|
|
1101
|
+
}
|
|
1102
|
+
async function mapWithConcurrency2(items, concurrency, mapper) {
|
|
1103
|
+
const results = new Array(items.length);
|
|
1104
|
+
let next = 0;
|
|
1105
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
1106
|
+
while (next < items.length) {
|
|
1107
|
+
const current = next;
|
|
1108
|
+
next += 1;
|
|
1109
|
+
results[current] = await mapper(items[current]);
|
|
1110
|
+
}
|
|
1111
|
+
});
|
|
1112
|
+
await Promise.all(workers);
|
|
1113
|
+
return results;
|
|
1114
|
+
}
|
|
1115
|
+
function normalizeConcurrency(value) {
|
|
1116
|
+
if (value === void 0 || !Number.isFinite(value)) return DEFAULT_PROVIDER_CONCURRENCY;
|
|
1117
|
+
return Math.min(10, Math.max(1, Math.floor(value)));
|
|
1118
|
+
}
|
|
1119
|
+
function normalizeTimeoutMs(value) {
|
|
1120
|
+
if (value === void 0 || !Number.isFinite(value)) return DEFAULT_PROVIDER_TIMEOUT_MS;
|
|
1121
|
+
return Math.min(3e4, Math.max(1, Math.floor(value)));
|
|
1122
|
+
}
|
|
1123
|
+
function normalizeGeocodeConcurrency(value) {
|
|
1124
|
+
if (value === void 0 || !Number.isFinite(value)) return DEFAULT_GEOCODE_CONCURRENCY;
|
|
1125
|
+
return Math.min(12, Math.max(1, Math.floor(value)));
|
|
1126
|
+
}
|
|
1127
|
+
function normalizeGeocodeTimeoutMs(value) {
|
|
1128
|
+
if (value === void 0 || !Number.isFinite(value)) return DEFAULT_GEOCODE_TIMEOUT_MS;
|
|
1129
|
+
return Math.min(3e4, Math.max(1, Math.floor(value)));
|
|
1130
|
+
}
|
|
1131
|
+
function normalizeGeocodeDeadlineMs(value) {
|
|
1132
|
+
if (value === void 0 || !Number.isFinite(value)) return DEFAULT_GEOCODE_DEADLINE_MS;
|
|
1133
|
+
return Math.min(18e4, Math.max(1, Math.floor(value)));
|
|
1134
|
+
}
|
|
1135
|
+
function normalizeResultLimit(value) {
|
|
1136
|
+
if (value === void 0 || !Number.isFinite(value)) return 30;
|
|
1137
|
+
return Math.min(100, Math.max(1, Math.floor(value)));
|
|
1138
|
+
}
|
|
1139
|
+
async function withTimeout(promise, timeoutMs) {
|
|
1140
|
+
let timeoutId;
|
|
1141
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
1142
|
+
timeoutId = setTimeout(() => reject(new Error("PROVIDER_TIMEOUT")), timeoutMs);
|
|
1143
|
+
});
|
|
1144
|
+
try {
|
|
1145
|
+
return await Promise.race([promise, timeout]);
|
|
1146
|
+
} finally {
|
|
1147
|
+
if (timeoutId !== void 0) clearTimeout(timeoutId);
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
function describeApplyHomeQuery(query) {
|
|
1151
|
+
return query.houseName ?? query.applyAddress ?? query.regionName ?? query.regionCode ?? "all";
|
|
1152
|
+
}
|
|
1153
|
+
function pushUnique(values, value) {
|
|
1154
|
+
if (!values.includes(value)) values.push(value);
|
|
1155
|
+
}
|
|
1156
|
+
function attachHouseNameNotFoundHint(result, input, dateFrom) {
|
|
1157
|
+
if (!input.house_name || result.summary.total_found > 0) return;
|
|
1158
|
+
const warning = "house_name \uAC80\uC0C9 \uACB0\uACFC\uAC00 0\uAC74\uC785\uB2C8\uB2E4. \uB2E8\uC9C0\uBA85 \uD45C\uAE30\uAC00 \uCCAD\uC57D\uD648\uACFC \uB2E4\uB97C \uC218 \uC788\uC73C\uB2C8 get_address \u2192 get_region_code \uD6C4 \uC9C0\uC5ED/\uBC18\uACBD \uAC80\uC0C9\uC73C\uB85C \uC7AC\uC870\uD68C\uD558\uC138\uC694.";
|
|
1159
|
+
const warnings = result.metadata.warnings ?? [];
|
|
1160
|
+
if (!warnings.includes(warning)) result.metadata.warnings = [...warnings, warning];
|
|
1161
|
+
result.next = {
|
|
1162
|
+
reason: "HOUSE_NAME_NOT_FOUND",
|
|
1163
|
+
message: "\uB2E8\uC9C0\uBA85\uB9CC\uC73C\uB85C \uCCAD\uC57D\uD648 HOUSE_NM LIKE \uAC80\uC0C9\uC774 \uC2E4\uD328\uD588\uC2B5\uB2C8\uB2E4. \uC7A5\uC18C\uBA85\uC73C\uB85C \uC8FC\uC18C/\uC88C\uD45C\uB97C \uBA3C\uC800 \uD655\uC815\uD55C \uB4A4, \uBC95\uC815\uB3D9/\uBC18\uACBD \uAE30\uBC18\uC73C\uB85C \uC7AC\uC870\uD68C\uD558\uACE0 \uACB0\uACFC\uB97C \uB2E8\uC9C0\uBA85\uC73C\uB85C \uD544\uD130\uB9C1\uD558\uC138\uC694.",
|
|
1164
|
+
suggested_tool_chain: ["get_address", "get_region_code", "search_presale_announcements"],
|
|
1165
|
+
get_address: { query: input.house_name },
|
|
1166
|
+
get_region_code: { query: "<address from get_address>" },
|
|
1167
|
+
search_presale_announcements: {
|
|
1168
|
+
recommended_args: {
|
|
1169
|
+
center_lat: "<lat from get_address>",
|
|
1170
|
+
center_lng: "<lng from get_address>",
|
|
1171
|
+
radius_km: input.radius_km ?? 5,
|
|
1172
|
+
date_from: dateFrom,
|
|
1173
|
+
move_in_from: "2018-01"
|
|
1174
|
+
},
|
|
1175
|
+
note: "PDF 13\uD398\uC774\uC9C0 \uAC19\uC740 \uC2DC\uC7A5\uAD8C \uAC80\uC99D\uC740 \uB2E8\uC9C0\uBA85 \uB2E8\uAC74 \uC870\uD68C\uBCF4\uB2E4 \uC0AC\uC5C5\uC9C0 \uC911\uC2EC \uC88C\uD45C+\uBC18\uACBD 5km+move_in_from=2018-01 \uBC29\uC2DD\uC73C\uB85C \uD6C4\uBCF4\uAD70\uC744 \uB9CC\uB4E4\uACE0, \uACF5\uACE0 \uBAA9\uB85D\uC758 \uC138\uB300\uC218\xB7\uC77C\uC815\xB7\uC704\uCE58 \uAE30\uC900\uC73C\uB85C \uD6C4\uC18D \uAC80\uC99D\uC744 \uC774\uC5B4\uAC00\uB294 \uD3B8\uC774 \uC548\uC804\uD569\uB2C8\uB2E4."
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
}
|
|
1179
|
+
async function buildProviderQueries(input, env, deps, dateRange, radiusKm, warnings) {
|
|
1180
|
+
const base = { dateFrom: dateRange.dateFrom, ...dateRange.dateTo ? { dateTo: dateRange.dateTo } : {} };
|
|
1181
|
+
if (input.house_name) return [{ ...base, houseName: input.house_name }];
|
|
1182
|
+
if (input.region_codes?.length) return input.region_codes.map((regionCode) => ({ ...base, regionCode }));
|
|
1183
|
+
if (input.apply_address) return [{ ...base, applyAddress: input.apply_address }];
|
|
1184
|
+
if (input.center_lat !== void 0 && input.center_lng !== void 0) {
|
|
1185
|
+
const points = samplePoints({ lat: input.center_lat, lng: input.center_lng }, radiusKm);
|
|
1186
|
+
const resolver = deps.regionResolver ?? ((point) => getRegionNameForCoordinate(point, env, deps.fetcher));
|
|
1187
|
+
const querySet = /* @__PURE__ */ new Set();
|
|
1188
|
+
for (const point of points) {
|
|
1189
|
+
const resolved = await resolver(point);
|
|
1190
|
+
if (resolved.warning) warnings.push(resolved.warning);
|
|
1191
|
+
if (resolved.regionName) {
|
|
1192
|
+
for (const query of expandRegionNameQueries(resolved.regionName)) {
|
|
1193
|
+
querySet.add(query);
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
return [...querySet].map((applyAddress) => ({ ...base, applyAddress }));
|
|
1198
|
+
}
|
|
1199
|
+
return [{ ...base }];
|
|
1200
|
+
}
|
|
1201
|
+
function emptyGeocodeStats(deps) {
|
|
1202
|
+
return {
|
|
1203
|
+
attemptedCount: 0,
|
|
1204
|
+
timeoutCount: 0,
|
|
1205
|
+
skippedCount: 0,
|
|
1206
|
+
deadlineMs: normalizeGeocodeDeadlineMs(deps.geocodeDeadlineMs),
|
|
1207
|
+
concurrency: normalizeGeocodeConcurrency(deps.geocodeConcurrency),
|
|
1208
|
+
timeoutMs: normalizeGeocodeTimeoutMs(deps.geocodeTimeoutMs)
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
async function geocodeAndFilterByRadius(announcements, center, radiusKm, env, deps, warnings) {
|
|
1212
|
+
const geocoder = deps.geocoder ?? (async (address) => {
|
|
1213
|
+
const result = await getGeocode({ address }, env, deps.fetcher, { skipRegionCode: true, ...deps.cache ? { cache: deps.cache } : {} });
|
|
1214
|
+
return "lat" in result ? result : {};
|
|
1215
|
+
});
|
|
1216
|
+
const within = [];
|
|
1217
|
+
const unlocated = [];
|
|
1218
|
+
let geocoded = 0;
|
|
1219
|
+
const stats = emptyGeocodeStats(deps);
|
|
1220
|
+
const startedAt = Date.now();
|
|
1221
|
+
const deadlineAt = startedAt + stats.deadlineMs;
|
|
1222
|
+
let next = 0;
|
|
1223
|
+
const workers = Array.from({ length: Math.min(stats.concurrency, announcements.length) }, async () => {
|
|
1224
|
+
while (true) {
|
|
1225
|
+
const current = next;
|
|
1226
|
+
next += 1;
|
|
1227
|
+
const announcement = announcements[current];
|
|
1228
|
+
if (!announcement) return;
|
|
1229
|
+
if (stats.attemptedCount >= MAX_GEOCODE_CALLS || Date.now() >= deadlineAt) {
|
|
1230
|
+
stats.skippedCount += 1;
|
|
1231
|
+
unlocated.push(announcement);
|
|
1232
|
+
continue;
|
|
1233
|
+
}
|
|
1234
|
+
stats.attemptedCount += 1;
|
|
1235
|
+
let resolved = null;
|
|
1236
|
+
try {
|
|
1237
|
+
const remainingMs = Math.max(1, deadlineAt - Date.now());
|
|
1238
|
+
resolved = await withTimeout(resolvePresaleLocation(announcement, geocoder, env, deps.fetcher ?? fetch), Math.min(stats.timeoutMs, remainingMs));
|
|
1239
|
+
} catch (error) {
|
|
1240
|
+
if (error instanceof Error && error.message === "PROVIDER_TIMEOUT") {
|
|
1241
|
+
stats.timeoutCount += 1;
|
|
1242
|
+
warnings.push(`Presale geocoding timed out after ${stats.timeoutMs}ms: ${announcement.name}.`);
|
|
1243
|
+
} else {
|
|
1244
|
+
warnings.push(`Presale geocoding failed: ${announcement.name} (${error instanceof Error ? error.message : String(error)}).`);
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
if (!resolved) {
|
|
1248
|
+
unlocated.push(announcement);
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
geocoded += 1;
|
|
1252
|
+
const distanceM = haversineMeters(center, { lat: resolved.lat, lng: resolved.lng });
|
|
1253
|
+
if (distanceM <= radiusKm * 1e3) {
|
|
1254
|
+
within.push({
|
|
1255
|
+
...announcement,
|
|
1256
|
+
lat: resolved.lat,
|
|
1257
|
+
lng: resolved.lng,
|
|
1258
|
+
distance_m: distanceM,
|
|
1259
|
+
distance_label: distanceLabel(distanceM),
|
|
1260
|
+
location_confidence: resolved.confidence,
|
|
1261
|
+
location_source: resolved.source
|
|
1262
|
+
});
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
});
|
|
1266
|
+
await Promise.all(workers);
|
|
1267
|
+
if (announcements.length > MAX_GEOCODE_CALLS) {
|
|
1268
|
+
warnings.push(`Coordinate-mode geocoding is capped at ${MAX_GEOCODE_CALLS} announcements; narrow the radius or use apply_address/region_codes for exhaustive results.`);
|
|
1269
|
+
}
|
|
1270
|
+
if (stats.skippedCount > 0) {
|
|
1271
|
+
warnings.push(`Presale geocoding deadline ${stats.deadlineMs}ms reached; ${stats.skippedCount} announcements were moved to unlocated_announcements without geocoding.`);
|
|
1272
|
+
}
|
|
1273
|
+
if (unlocated.length) {
|
|
1274
|
+
warnings.push(
|
|
1275
|
+
`${unlocated.length} announcements could not be geocoded/located and were moved to unlocated_announcements: ${summarizeAnnouncementNames(unlocated)}.`
|
|
1276
|
+
);
|
|
1277
|
+
}
|
|
1278
|
+
within.sort((a, b) => (a.distance_m ?? Number.MAX_SAFE_INTEGER) - (b.distance_m ?? Number.MAX_SAFE_INTEGER));
|
|
1279
|
+
return { announcements: within, unlocated, geocoded, stats };
|
|
1280
|
+
}
|
|
1281
|
+
function summarizeAnnouncementNames(announcements, limit = 5) {
|
|
1282
|
+
const names = announcements.slice(0, limit).map((announcement) => announcement.name);
|
|
1283
|
+
const remaining = announcements.length - names.length;
|
|
1284
|
+
return remaining > 0 ? `${names.join(", ")} \uC678 ${remaining}\uAC74` : names.join(", ");
|
|
1285
|
+
}
|
|
1286
|
+
async function resolvePresaleLocation(announcement, geocoder, env, fetcher) {
|
|
1287
|
+
const geo = await geocoder(announcement.address);
|
|
1288
|
+
if ("lat" in geo && "lng" in geo && isTrustworthyGeocode(announcement.address, geo)) {
|
|
1289
|
+
return { lat: geo.lat, lng: geo.lng, confidence: "exact", source: "naver_geocode" };
|
|
1290
|
+
}
|
|
1291
|
+
const kakao = await resolveLocationByKakaoKeyword(announcement, env, fetcher);
|
|
1292
|
+
if (kakao) return { ...kakao, confidence: "kakao_keyword", source: "kakao_keyword" };
|
|
1293
|
+
if (typeof geo.lat !== "number" || typeof geo.lng !== "number") {
|
|
1294
|
+
const dongAddress = extractDongLevelAddress(announcement.address);
|
|
1295
|
+
if (dongAddress && dongAddress !== announcement.address) {
|
|
1296
|
+
const dongGeo = await geocoder(dongAddress);
|
|
1297
|
+
if ("lat" in dongGeo && "lng" in dongGeo && isValidCoordinate(dongGeo.lat, dongGeo.lng)) {
|
|
1298
|
+
return { lat: dongGeo.lat, lng: dongGeo.lng, confidence: "dong_fallback", source: "dong_fallback" };
|
|
1299
|
+
}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
return null;
|
|
1303
|
+
}
|
|
1304
|
+
function isTrustworthyGeocode(address, geo) {
|
|
1305
|
+
if (typeof geo.lat !== "number" || typeof geo.lng !== "number") return false;
|
|
1306
|
+
if (!isValidCoordinate(geo.lat, geo.lng)) return false;
|
|
1307
|
+
if (geo.confidence && geo.confidence !== "exact") return false;
|
|
1308
|
+
if (isBlockStyleAddress(address)) return isPreciseAddressHit(geo.normalized_address ?? "");
|
|
1309
|
+
return true;
|
|
1310
|
+
}
|
|
1311
|
+
function isValidCoordinate(lat, lng) {
|
|
1312
|
+
return Number.isFinite(lat) && Number.isFinite(lng) && !(lat === 0 && lng === 0);
|
|
1313
|
+
}
|
|
1314
|
+
function isBlockStyleAddress(value) {
|
|
1315
|
+
return /(?:^|[\s-])(?:[A-Z]?\d+\s*)?BL\b|블록|지구/u.test(value.toUpperCase());
|
|
1316
|
+
}
|
|
1317
|
+
function isPreciseAddressHit(value) {
|
|
1318
|
+
return /(?:동|읍|면|가|리)\s+산?\d+(?:-\d+)?\b/u.test(value) || /(?:로|길)\s*\d+(?:-\d+)?\b/u.test(value);
|
|
1319
|
+
}
|
|
1320
|
+
async function resolveLocationByKakaoKeyword(announcement, env, fetcher) {
|
|
1321
|
+
if (!env.KAKAO_REST_API_KEY) return null;
|
|
1322
|
+
const queryVariants = buildKakaoQueryVariants(announcement.name);
|
|
1323
|
+
const candidates = [];
|
|
1324
|
+
for (const query of queryVariants) {
|
|
1325
|
+
const docs = await searchKakaoKeyword(query, env, fetcher);
|
|
1326
|
+
candidates.push(...docs);
|
|
1327
|
+
}
|
|
1328
|
+
const best = pickBestKakaoCandidate(announcement, candidates);
|
|
1329
|
+
if (!best?.x || !best.y) return null;
|
|
1330
|
+
const lat = Number(best.y);
|
|
1331
|
+
const lng = Number(best.x);
|
|
1332
|
+
return isValidCoordinate(lat, lng) ? { lat, lng } : null;
|
|
1333
|
+
}
|
|
1334
|
+
async function searchKakaoKeyword(query, env, fetcher) {
|
|
1335
|
+
const url = new URL("https://dapi.kakao.com/v2/local/search/keyword.json");
|
|
1336
|
+
url.searchParams.set("query", query);
|
|
1337
|
+
url.searchParams.set("page", "1");
|
|
1338
|
+
url.searchParams.set("size", "10");
|
|
1339
|
+
const response = await fetcher(url.toString(), { headers: { Authorization: `KakaoAK ${env.KAKAO_REST_API_KEY}` } });
|
|
1340
|
+
if (!response.ok) return [];
|
|
1341
|
+
const payload = await readJsonResponse(response);
|
|
1342
|
+
return payload.documents ?? [];
|
|
1343
|
+
}
|
|
1344
|
+
function buildKakaoQueryVariants(name) {
|
|
1345
|
+
const stripped = name.replace(/\([^)]*\)/g, " ").replace(/견본주택|모델하우스|분양사무소/gu, " ");
|
|
1346
|
+
const withoutBlockPrefix = stripped.replace(/\b[A-Z](?=\d+\s*BL\b)/giu, "");
|
|
1347
|
+
const withoutBlock = withoutBlockPrefix.replace(/\b\d+\s*BL\b/giu, " ");
|
|
1348
|
+
return uniqueNonEmpty([
|
|
1349
|
+
stripped,
|
|
1350
|
+
withoutBlockPrefix,
|
|
1351
|
+
withoutBlock,
|
|
1352
|
+
stripped.replace(/2/g, "\u2161").replace(/3/g, "\u2162"),
|
|
1353
|
+
stripped.replace(/[ⅠⅡⅢⅣⅤⅥⅦⅧⅨⅩ]/gu, "")
|
|
1354
|
+
]);
|
|
1355
|
+
}
|
|
1356
|
+
function pickBestKakaoCandidate(announcement, candidates) {
|
|
1357
|
+
const normalizedName = normalizeComplexName(announcement.name);
|
|
1358
|
+
const deduped = dedupeKakaoCandidates(candidates);
|
|
1359
|
+
const filtered = deduped.filter((candidate) => candidate.place_name && candidate.x && candidate.y).filter((candidate) => !/견본주택|모델하우스|분양사무소|중개/u.test(`${candidate.place_name ?? ""} ${candidate.category_name ?? ""}`)).filter((candidate) => /아파트|오피스텔|주거시설/u.test(`${candidate.place_name ?? ""} ${candidate.category_name ?? ""}`)).map((candidate) => ({
|
|
1360
|
+
candidate,
|
|
1361
|
+
sim: complexNameSimilarity(candidate.place_name ?? "", normalizedName, { bNormalized: true }),
|
|
1362
|
+
regionHit: candidate.address_name && sameSigungu(announcement.address, candidate.address_name) ? 1 : 0
|
|
1363
|
+
})).filter((scored) => scored.sim >= 0.7).sort((a, b) => b.regionHit - a.regionHit || b.sim - a.sim || (a.candidate.place_name ?? "").localeCompare(b.candidate.place_name ?? ""));
|
|
1364
|
+
const first = filtered[0];
|
|
1365
|
+
const second = filtered[1];
|
|
1366
|
+
if (!first) return null;
|
|
1367
|
+
if (second && first.regionHit === second.regionHit && first.sim - second.sim < 0.1) return null;
|
|
1368
|
+
return first.candidate;
|
|
1369
|
+
}
|
|
1370
|
+
function dedupeKakaoCandidates(candidates) {
|
|
1371
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1372
|
+
const deduped = [];
|
|
1373
|
+
for (const candidate of candidates) {
|
|
1374
|
+
const key = candidate.id || `${candidate.place_name ?? ""}:${candidate.address_name ?? ""}:${candidate.x ?? ""}:${candidate.y ?? ""}`;
|
|
1375
|
+
if (seen.has(key)) continue;
|
|
1376
|
+
seen.add(key);
|
|
1377
|
+
deduped.push(candidate);
|
|
1378
|
+
}
|
|
1379
|
+
return deduped;
|
|
1380
|
+
}
|
|
1381
|
+
function sameSigungu(left, right) {
|
|
1382
|
+
const l = sigunguKey(left);
|
|
1383
|
+
const r = sigunguKey(right);
|
|
1384
|
+
return Boolean(l && r && l === r);
|
|
1385
|
+
}
|
|
1386
|
+
function sigunguKey(address) {
|
|
1387
|
+
const parts = address.split(/\s+/).filter(Boolean);
|
|
1388
|
+
if (parts.length < 2) return void 0;
|
|
1389
|
+
const sido = parts[0];
|
|
1390
|
+
if (!sido) return void 0;
|
|
1391
|
+
if (parts[1]?.endsWith("\uC2DC") && parts[2]?.endsWith("\uAD6C")) return `${sido} ${parts[1]} ${parts[2]}`;
|
|
1392
|
+
return parts[1] ? `${sido} ${parts[1]}` : void 0;
|
|
1393
|
+
}
|
|
1394
|
+
function uniqueNonEmpty(values) {
|
|
1395
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1396
|
+
const result = [];
|
|
1397
|
+
for (const value of values) {
|
|
1398
|
+
const normalized = value.replace(/\s+/g, " ").trim();
|
|
1399
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
1400
|
+
seen.add(normalized);
|
|
1401
|
+
result.push(normalized);
|
|
1402
|
+
}
|
|
1403
|
+
return result;
|
|
1404
|
+
}
|
|
1405
|
+
function extractDongLevelAddress(address) {
|
|
1406
|
+
const tokens = address.split(/\s+/).filter(Boolean);
|
|
1407
|
+
const collected = [];
|
|
1408
|
+
for (const token of tokens) {
|
|
1409
|
+
const cleaned = token.replace(/[(),]/g, "");
|
|
1410
|
+
collected.push(cleaned);
|
|
1411
|
+
if (/[동읍면]$/.test(cleaned) && collected.length >= 3) {
|
|
1412
|
+
return collected.join(" ");
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
return void 0;
|
|
1416
|
+
}
|
|
1417
|
+
function applyPostFilters(announcements, input) {
|
|
1418
|
+
let filtered = announcements;
|
|
1419
|
+
if (input.status_filter?.length) {
|
|
1420
|
+
filtered = filtered.filter((announcement) => input.status_filter?.includes(announcement.status));
|
|
1421
|
+
}
|
|
1422
|
+
if (input.move_in_from || input.move_in_to) {
|
|
1423
|
+
const from = normalizeMonth(input.move_in_from);
|
|
1424
|
+
const to = normalizeMonth(input.move_in_to);
|
|
1425
|
+
filtered = filtered.filter((announcement) => {
|
|
1426
|
+
const moveIn = normalizeMonth(announcement.dates.expected_move_in);
|
|
1427
|
+
if (!moveIn) return false;
|
|
1428
|
+
if (from && moveIn < from) return false;
|
|
1429
|
+
if (to && moveIn > to) return false;
|
|
1430
|
+
return true;
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
return filtered;
|
|
1434
|
+
}
|
|
1435
|
+
function samplePoints(center, radiusKm) {
|
|
1436
|
+
const points = [center];
|
|
1437
|
+
const outerBearings = radiusKm <= 2 ? [0, 90, 180, 270] : [0, 45, 90, 135, 180, 225, 270, 315];
|
|
1438
|
+
for (const bearing of outerBearings) {
|
|
1439
|
+
points.push(destinationPoint(center, radiusKm, bearing));
|
|
1440
|
+
}
|
|
1441
|
+
if (radiusKm > 3) {
|
|
1442
|
+
for (const bearing of [0, 90, 180, 270]) {
|
|
1443
|
+
points.push(destinationPoint(center, radiusKm / 2, bearing));
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
return points;
|
|
1447
|
+
}
|
|
1448
|
+
function destinationPoint(center, distanceKm, bearingDeg) {
|
|
1449
|
+
const radiusKmEarth = 6371;
|
|
1450
|
+
const distanceRatio = distanceKm / radiusKmEarth;
|
|
1451
|
+
const bearing = bearingDeg * Math.PI / 180;
|
|
1452
|
+
const lat1 = center.lat * Math.PI / 180;
|
|
1453
|
+
const lng1 = center.lng * Math.PI / 180;
|
|
1454
|
+
const lat2 = Math.asin(Math.sin(lat1) * Math.cos(distanceRatio) + Math.cos(lat1) * Math.sin(distanceRatio) * Math.cos(bearing));
|
|
1455
|
+
const lng2 = lng1 + Math.atan2(Math.sin(bearing) * Math.sin(distanceRatio) * Math.cos(lat1), Math.cos(distanceRatio) - Math.sin(lat1) * Math.sin(lat2));
|
|
1456
|
+
return { lat: lat2 * 180 / Math.PI, lng: lng2 * 180 / Math.PI };
|
|
1457
|
+
}
|
|
1458
|
+
function buildInput(input, radiusKm, monthsBack, dateRange) {
|
|
1459
|
+
return {
|
|
1460
|
+
...input.center_lat !== void 0 && input.center_lng !== void 0 ? { center: { lat: input.center_lat, lng: input.center_lng } } : {},
|
|
1461
|
+
radius_km: radiusKm,
|
|
1462
|
+
months_back: monthsBack,
|
|
1463
|
+
...input.year ? { year: input.year } : {},
|
|
1464
|
+
date_from: dateRange.dateFrom,
|
|
1465
|
+
...dateRange.dateTo ? { date_to: dateRange.dateTo } : {},
|
|
1466
|
+
...input.move_in_from ? { move_in_from: input.move_in_from } : {},
|
|
1467
|
+
...input.move_in_to ? { move_in_to: input.move_in_to } : {},
|
|
1468
|
+
...input.region_codes ? { region_codes: input.region_codes } : {},
|
|
1469
|
+
...input.apply_address ? { apply_address: input.apply_address } : {},
|
|
1470
|
+
...input.house_name ? { house_name: input.house_name } : {},
|
|
1471
|
+
limit: normalizeResultLimit(input.limit),
|
|
1472
|
+
type: "apt"
|
|
1473
|
+
};
|
|
1474
|
+
}
|
|
1475
|
+
function resolveDateRange(input, today, monthsBack) {
|
|
1476
|
+
if (input.year) return { dateFrom: `${input.year}-01-01`, dateTo: `${input.year}-12-31` };
|
|
1477
|
+
const explicitFrom = parseDate(input.date_from);
|
|
1478
|
+
const explicitTo = parseDate(input.date_to);
|
|
1479
|
+
return { dateFrom: explicitFrom ?? formatDate(addMonths(today, -monthsBack)), ...explicitTo ? { dateTo: explicitTo } : {} };
|
|
1480
|
+
}
|
|
1481
|
+
function buildDates(row, today) {
|
|
1482
|
+
const dates = {
|
|
1483
|
+
...optionalText(row.RCRIT_PBLANC_DE) ? { announcement: optionalText(row.RCRIT_PBLANC_DE) } : {},
|
|
1484
|
+
...optionalText(row.SPSPLY_RCEPT_BGNDE) ? { special_start: optionalText(row.SPSPLY_RCEPT_BGNDE) } : {},
|
|
1485
|
+
...optionalText(row.SPSPLY_RCEPT_ENDDE) ? { special_end: optionalText(row.SPSPLY_RCEPT_ENDDE) } : {},
|
|
1486
|
+
...optionalText(row.PRZWNER_PRESNATN_DE) ? { winner_announcement: optionalText(row.PRZWNER_PRESNATN_DE) } : {},
|
|
1487
|
+
...optionalText(row.CNTRCT_CNCLS_BGNDE) ? { contract_start: optionalText(row.CNTRCT_CNCLS_BGNDE) } : {},
|
|
1488
|
+
...optionalText(row.CNTRCT_CNCLS_ENDDE) ? { contract_end: optionalText(row.CNTRCT_CNCLS_ENDDE) } : {},
|
|
1489
|
+
...optionalText(row.MVN_PREARNGE_YM) ? { expected_move_in: optionalText(row.MVN_PREARNGE_YM) } : {},
|
|
1490
|
+
subscription: {
|
|
1491
|
+
general: {
|
|
1492
|
+
...optionalText(row.GNRL_RNK1_CRSPAREA_RCPTDE) ? { rank1_local_start: optionalText(row.GNRL_RNK1_CRSPAREA_RCPTDE) } : {},
|
|
1493
|
+
...optionalText(row.GNRL_RNK1_CRSPAREA_ENDDE) ? { rank1_local_end: optionalText(row.GNRL_RNK1_CRSPAREA_ENDDE) } : {},
|
|
1494
|
+
...optionalText(row.GNRL_RNK1_ETC_GG_RCPTDE) ? { rank1_gg_start: optionalText(row.GNRL_RNK1_ETC_GG_RCPTDE) } : {},
|
|
1495
|
+
...optionalText(row.GNRL_RNK1_ETC_GG_ENDDE) ? { rank1_gg_end: optionalText(row.GNRL_RNK1_ETC_GG_ENDDE) } : {},
|
|
1496
|
+
...optionalText(row.GNRL_RNK1_ETC_AREA_RCPTDE) ? { rank1_etc_start: optionalText(row.GNRL_RNK1_ETC_AREA_RCPTDE) } : {},
|
|
1497
|
+
...optionalText(row.GNRL_RNK1_ETC_AREA_ENDDE) ? { rank1_etc_end: optionalText(row.GNRL_RNK1_ETC_AREA_ENDDE) } : {},
|
|
1498
|
+
...optionalText(row.GNRL_RNK2_CRSPAREA_RCPTDE) ? { rank2_local_start: optionalText(row.GNRL_RNK2_CRSPAREA_RCPTDE) } : {},
|
|
1499
|
+
...optionalText(row.GNRL_RNK2_CRSPAREA_ENDDE) ? { rank2_local_end: optionalText(row.GNRL_RNK2_CRSPAREA_ENDDE) } : {},
|
|
1500
|
+
...optionalText(row.GNRL_RNK2_ETC_GG_RCPTDE) ? { rank2_gg_start: optionalText(row.GNRL_RNK2_ETC_GG_RCPTDE) } : {},
|
|
1501
|
+
...optionalText(row.GNRL_RNK2_ETC_GG_ENDDE) ? { rank2_gg_end: optionalText(row.GNRL_RNK2_ETC_GG_ENDDE) } : {},
|
|
1502
|
+
...optionalText(row.GNRL_RNK2_ETC_AREA_RCPTDE) ? { rank2_etc_start: optionalText(row.GNRL_RNK2_ETC_AREA_RCPTDE) } : {},
|
|
1503
|
+
...optionalText(row.GNRL_RNK2_ETC_AREA_ENDDE) ? { rank2_etc_end: optionalText(row.GNRL_RNK2_ETC_AREA_ENDDE) } : {}
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
};
|
|
1507
|
+
dates.days_until_move_in = monthDiffFromToday(row.MVN_PREARNGE_YM, today);
|
|
1508
|
+
dates.days_until_close = dayDiff(row.GNRL_RNK2_CRSPAREA_ENDDE ?? row.SPSPLY_RCEPT_ENDDE, today);
|
|
1509
|
+
return dates;
|
|
1510
|
+
}
|
|
1511
|
+
function dedupeRows(rows) {
|
|
1512
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1513
|
+
return rows.filter((row) => {
|
|
1514
|
+
const key = `${text(row.PBLANC_NO)}:${text(row.HOUSE_MANAGE_NO)}`;
|
|
1515
|
+
if (seen.has(key)) return false;
|
|
1516
|
+
seen.add(key);
|
|
1517
|
+
return true;
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
function summarizeStatus(announcements) {
|
|
1521
|
+
return announcements.reduce((acc, announcement) => {
|
|
1522
|
+
acc[announcement.status] = (acc[announcement.status] ?? 0) + 1;
|
|
1523
|
+
return acc;
|
|
1524
|
+
}, {});
|
|
1525
|
+
}
|
|
1526
|
+
function parseSigungu(address) {
|
|
1527
|
+
const parts = address.split(/\s+/).filter(Boolean);
|
|
1528
|
+
if (parts.length < 2) return void 0;
|
|
1529
|
+
if (parts[1]?.endsWith("\uC2DC") && parts[2]?.endsWith("\uAD6C")) return `${parts[1]} ${parts[2]}`;
|
|
1530
|
+
return parts[1];
|
|
1531
|
+
}
|
|
1532
|
+
function addOptional(target, key, value) {
|
|
1533
|
+
const normalized = optionalText(value);
|
|
1534
|
+
if (normalized) target[String(key)] = normalized;
|
|
1535
|
+
}
|
|
1536
|
+
function addOptionalNumber(target, key, value) {
|
|
1537
|
+
const normalized = optionalNumber(value);
|
|
1538
|
+
if (normalized !== void 0) target[String(key)] = normalized;
|
|
1539
|
+
}
|
|
1540
|
+
function text(value) {
|
|
1541
|
+
return optionalText(value) ?? "";
|
|
1542
|
+
}
|
|
1543
|
+
function optionalText(value) {
|
|
1544
|
+
if (value === void 0 || value === null) return void 0;
|
|
1545
|
+
const textValue = String(value).trim();
|
|
1546
|
+
return textValue.length ? textValue : void 0;
|
|
1547
|
+
}
|
|
1548
|
+
function optionalNumber(value) {
|
|
1549
|
+
const maybeText = optionalText(value);
|
|
1550
|
+
if (!maybeText) return void 0;
|
|
1551
|
+
const number = Number(maybeText.replace(/,/g, ""));
|
|
1552
|
+
return Number.isFinite(number) ? number : void 0;
|
|
1553
|
+
}
|
|
1554
|
+
function optionalBool(value) {
|
|
1555
|
+
const maybeText = optionalText(value);
|
|
1556
|
+
if (!maybeText) return void 0;
|
|
1557
|
+
if (maybeText === "Y") return true;
|
|
1558
|
+
if (maybeText === "N") return false;
|
|
1559
|
+
return void 0;
|
|
1560
|
+
}
|
|
1561
|
+
function minDate(values) {
|
|
1562
|
+
const dates = values.map(parseDate).filter((value) => Boolean(value)).sort();
|
|
1563
|
+
return dates[0];
|
|
1564
|
+
}
|
|
1565
|
+
function maxDate(values) {
|
|
1566
|
+
const dates = values.map(parseDate).filter((value) => Boolean(value)).sort();
|
|
1567
|
+
return dates.at(-1);
|
|
1568
|
+
}
|
|
1569
|
+
function parseDate(value) {
|
|
1570
|
+
const raw = optionalText(value);
|
|
1571
|
+
if (!raw) return void 0;
|
|
1572
|
+
const match = raw.match(/^(\d{4})-?(\d{2})-?(\d{2})$/);
|
|
1573
|
+
return match ? `${match[1]}-${match[2]}-${match[3]}` : void 0;
|
|
1574
|
+
}
|
|
1575
|
+
function normalizeMonth(value) {
|
|
1576
|
+
const raw = optionalText(value);
|
|
1577
|
+
if (!raw) return void 0;
|
|
1578
|
+
const match = raw.match(/^(\d{4})-?(\d{2})$/);
|
|
1579
|
+
return match ? `${match[1]}${match[2]}` : void 0;
|
|
1580
|
+
}
|
|
1581
|
+
function dateOnly(date) {
|
|
1582
|
+
return date.toISOString().slice(0, 10);
|
|
1583
|
+
}
|
|
1584
|
+
function formatDate(date) {
|
|
1585
|
+
return date.toISOString().slice(0, 10);
|
|
1586
|
+
}
|
|
1587
|
+
function addMonths(date, months) {
|
|
1588
|
+
const copy = new Date(date.getTime());
|
|
1589
|
+
copy.setUTCMonth(copy.getUTCMonth() + months);
|
|
1590
|
+
return copy;
|
|
1591
|
+
}
|
|
1592
|
+
function dayDiff(value, today) {
|
|
1593
|
+
const parsed2 = parseDate(value);
|
|
1594
|
+
if (!parsed2) return null;
|
|
1595
|
+
const target = (/* @__PURE__ */ new Date(`${parsed2}T00:00:00Z`)).getTime();
|
|
1596
|
+
const current = (/* @__PURE__ */ new Date(`${dateOnly(today)}T00:00:00Z`)).getTime();
|
|
1597
|
+
return Math.ceil((target - current) / 864e5);
|
|
1598
|
+
}
|
|
1599
|
+
function monthDiffFromToday(value, today) {
|
|
1600
|
+
const raw = optionalText(value);
|
|
1601
|
+
if (!raw) return null;
|
|
1602
|
+
const match = raw.match(/^(\d{4})(\d{2})$/);
|
|
1603
|
+
if (!match) return null;
|
|
1604
|
+
return dayDiff(`${match[1]}-${match[2]}-01`, today);
|
|
1605
|
+
}
|
|
1606
|
+
function clampInt(value, min, max) {
|
|
1607
|
+
return Math.min(Math.max(Math.trunc(value), min), max);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
// src/providers/applyhome-detail.ts
|
|
1611
|
+
var BASE_URL = "https://api.odcloud.kr/api/ApplyhomeInfoDetailSvc/v1/";
|
|
1612
|
+
var RETRY_DELAYS_MS2 = [25, 50];
|
|
1613
|
+
var DETAIL_ENDPOINTS = {
|
|
1614
|
+
apt: "getAPTLttotPblancMdl",
|
|
1615
|
+
urbty: "getUrbtyOfctlLttotPblancMdl",
|
|
1616
|
+
remndr: "getRemndrLttotPblancMdl",
|
|
1617
|
+
opt: "getOPTLttotPblancMdl",
|
|
1618
|
+
pbl_pvt_rent: "getPblPvtRentLttotPblancMdl"
|
|
1619
|
+
};
|
|
1620
|
+
async function fetchAnnouncementDetail(query, env, fetcher = fetch) {
|
|
1621
|
+
const serviceKey = env.DATA_GO_KR_SERVICE_KEY;
|
|
1622
|
+
if (!serviceKey) {
|
|
1623
|
+
return { rows: [], sources: [], warnings: ["DATA_GO_KR_SERVICE_KEY is missing."] };
|
|
1624
|
+
}
|
|
1625
|
+
const perPage = query.perPage ?? 100;
|
|
1626
|
+
let page = query.page ?? 1;
|
|
1627
|
+
const rows = [];
|
|
1628
|
+
const sources = [];
|
|
1629
|
+
const warnings = [];
|
|
1630
|
+
while (true) {
|
|
1631
|
+
const url = buildDetailUrl({ ...query, page, perPage }, serviceKey);
|
|
1632
|
+
sources.push(redactServiceKeyInUrl(url.toString(), serviceKey));
|
|
1633
|
+
const response = await fetchWithRetry2(url.toString(), fetcher);
|
|
1634
|
+
if (!response.ok) {
|
|
1635
|
+
warnings.push(`ApplyHome detail API failed: HTTP ${response.status}`);
|
|
1636
|
+
break;
|
|
1637
|
+
}
|
|
1638
|
+
const payload = await readJsonResponse(response);
|
|
1639
|
+
const pageRows = Array.isArray(payload.data) ? payload.data : [];
|
|
1640
|
+
rows.push(...pageRows);
|
|
1641
|
+
const totalCount = Number(payload.totalCount ?? pageRows.length);
|
|
1642
|
+
const responsePerPage = Number(payload.perPage ?? perPage);
|
|
1643
|
+
const responsePage = Number(payload.page ?? page);
|
|
1644
|
+
if (responsePage * responsePerPage >= totalCount || pageRows.length === 0) break;
|
|
1645
|
+
page += 1;
|
|
1646
|
+
}
|
|
1647
|
+
return { rows, sources, warnings };
|
|
1648
|
+
}
|
|
1649
|
+
async function fetchWithRetry2(url, fetcher) {
|
|
1650
|
+
let response = await fetcher(url, { headers: { Accept: "application/json" } });
|
|
1651
|
+
for (const delayMs of RETRY_DELAYS_MS2) {
|
|
1652
|
+
if (response.ok) return response;
|
|
1653
|
+
await sleep2(delayMs);
|
|
1654
|
+
response = await fetcher(url, { headers: { Accept: "application/json" } });
|
|
1655
|
+
}
|
|
1656
|
+
return response;
|
|
1657
|
+
}
|
|
1658
|
+
function sleep2(ms) {
|
|
1659
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1660
|
+
}
|
|
1661
|
+
function buildDetailUrl(query, serviceKey) {
|
|
1662
|
+
const url = new URL(BASE_URL + DETAIL_ENDPOINTS[query.type]);
|
|
1663
|
+
url.searchParams.set("page", String(query.page));
|
|
1664
|
+
url.searchParams.set("perPage", String(query.perPage));
|
|
1665
|
+
url.searchParams.set("cond[PBLANC_NO::EQ]", query.announcementId);
|
|
1666
|
+
url.searchParams.set("cond[HOUSE_MANAGE_NO::EQ]", query.houseManageNo);
|
|
1667
|
+
url.searchParams.set("serviceKey", serviceKey);
|
|
1668
|
+
return url;
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
// src/tools/announcement-detail.ts
|
|
1672
|
+
var PYEONG_PER_SQM = 3.3058;
|
|
1673
|
+
function normalizeUnitTypeRow(row, type) {
|
|
1674
|
+
const houseTy = optionalText2(row.HOUSE_TY);
|
|
1675
|
+
const tp = optionalText2(row.TP);
|
|
1676
|
+
const typeName = houseTy ?? tp ?? "";
|
|
1677
|
+
const exclusiveArea = optionalNumber2(row.EXCLUSE_AR) ?? parseAreaFromTypeName(typeName) ?? null;
|
|
1678
|
+
const supplyArea = optionalNumber2(row.SUPLY_AR) ?? null;
|
|
1679
|
+
const supplyUnits = resolveSupplyUnits(row, type);
|
|
1680
|
+
const maxPrice = optionalNumber2(row.LTTOT_TOP_AMOUNT) ?? optionalNumber2(row.SUPLY_AMOUNT) ?? null;
|
|
1681
|
+
return {
|
|
1682
|
+
model_no: optionalText2(row.MODEL_NO) ?? null,
|
|
1683
|
+
type_name: typeName,
|
|
1684
|
+
exclusive_area_sqm: exclusiveArea,
|
|
1685
|
+
supply_area_sqm: supplyArea,
|
|
1686
|
+
pyeong: supplyArea !== null && supplyArea > 0 ? roundToTwo(supplyArea / PYEONG_PER_SQM) : null,
|
|
1687
|
+
supply_units: supplyUnits,
|
|
1688
|
+
max_price_10k: maxPrice
|
|
1689
|
+
};
|
|
1690
|
+
}
|
|
1691
|
+
async function getAnnouncementDetail(input, env, deps = {}) {
|
|
1692
|
+
const type = input.type ?? "apt";
|
|
1693
|
+
if (!deps.cache) return getAnnouncementDetailUncached(input, env, deps, false);
|
|
1694
|
+
const key = `cheongyak:detail:${type}:${input.house_manage_no}:${input.announcement_id}`;
|
|
1695
|
+
const cached = await deps.cache.getOrSetWithStatus(key, TTL.announcementDetail, async () => {
|
|
1696
|
+
const result = await getAnnouncementDetailUncached(input, env, deps, false);
|
|
1697
|
+
return "error" in result ? null : result;
|
|
1698
|
+
});
|
|
1699
|
+
if (cached.value) return { ...cached.value, metadata: { ...cached.value.metadata, cache_hit: cached.hit } };
|
|
1700
|
+
return getAnnouncementDetailUncached(input, env, deps, false);
|
|
1701
|
+
}
|
|
1702
|
+
async function getAnnouncementDetailUncached(input, env, deps, cacheHit) {
|
|
1703
|
+
const type = input.type ?? "apt";
|
|
1704
|
+
const sources = [];
|
|
1705
|
+
if (!env.DATA_GO_KR_SERVICE_KEY) {
|
|
1706
|
+
return errorResult2(input, type, "AUTH_MISSING", "[AUTH_MISSING] DATA_GO_KR_SERVICE_KEY \uD658\uACBD\uBCC0\uC218\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.", sources, cacheHit);
|
|
1707
|
+
}
|
|
1708
|
+
const provider = await fetchAnnouncementDetail(
|
|
1709
|
+
{ houseManageNo: input.house_manage_no, announcementId: input.announcement_id, type },
|
|
1710
|
+
env,
|
|
1711
|
+
deps.fetcher
|
|
1712
|
+
);
|
|
1713
|
+
sources.push(...provider.sources);
|
|
1714
|
+
if (provider.rows.length === 0) {
|
|
1715
|
+
return errorResult2(input, type, "NOT_FOUND", "[NOT_FOUND] \uC774 \uACF5\uACE0\uC758 \uC8FC\uD0DD\uD615\uBCC4 \uC0C1\uC138\uAC00 \uC870\uD68C\uB418\uC9C0 \uC54A\uC74C. \uBA74\uC801 \uCD94\uC815\xB7\uC0DD\uC131 \uAE08\uC9C0.", sources, cacheHit, provider.warnings);
|
|
1716
|
+
}
|
|
1717
|
+
const unitTypes = provider.rows.map((row) => normalizeUnitTypeRow(row, type)).filter((unit) => unit.type_name.length > 0);
|
|
1718
|
+
if (unitTypes.length === 0) {
|
|
1719
|
+
return errorResult2(input, type, "NOT_FOUND", "[NOT_FOUND] \uC774 \uACF5\uACE0\uC758 \uC8FC\uD0DD\uD615\uBCC4 \uC0C1\uC138\uAC00 \uC870\uD68C\uB418\uC9C0 \uC54A\uC74C. \uBA74\uC801 \uCD94\uC815\xB7\uC0DD\uC131 \uAE08\uC9C0.", sources, cacheHit, provider.warnings);
|
|
1720
|
+
}
|
|
1721
|
+
return {
|
|
1722
|
+
house_manage_no: input.house_manage_no,
|
|
1723
|
+
announcement_id: input.announcement_id,
|
|
1724
|
+
type,
|
|
1725
|
+
unit_types: unitTypes,
|
|
1726
|
+
summary: {
|
|
1727
|
+
total_unit_types: unitTypes.length,
|
|
1728
|
+
total_units: unitTypes.reduce((acc, unit) => acc + (unit.supply_units ?? 0), 0)
|
|
1729
|
+
},
|
|
1730
|
+
metadata: {
|
|
1731
|
+
cache_hit: cacheHit,
|
|
1732
|
+
collected_at: nowIso(),
|
|
1733
|
+
data_source: "cheongyak_mdl",
|
|
1734
|
+
sources,
|
|
1735
|
+
...provider.warnings.length ? { warnings: provider.warnings } : {}
|
|
1736
|
+
}
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
function errorResult2(input, type, error, message, sources, cacheHit, warnings = []) {
|
|
1740
|
+
return {
|
|
1741
|
+
house_manage_no: input.house_manage_no,
|
|
1742
|
+
announcement_id: input.announcement_id,
|
|
1743
|
+
type,
|
|
1744
|
+
error,
|
|
1745
|
+
message,
|
|
1746
|
+
metadata: {
|
|
1747
|
+
cache_hit: cacheHit,
|
|
1748
|
+
collected_at: nowIso(),
|
|
1749
|
+
data_source: "cheongyak_mdl",
|
|
1750
|
+
sources,
|
|
1751
|
+
...warnings.length ? { warnings } : {}
|
|
1752
|
+
}
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
function resolveSupplyUnits(row, type) {
|
|
1756
|
+
if (type === "apt" || type === "remndr") {
|
|
1757
|
+
const general = optionalNumber2(row.SUPLY_HSHLDCO) ?? null;
|
|
1758
|
+
const special = optionalNumber2(row.SPSPLY_HSHLDCO) ?? null;
|
|
1759
|
+
return general === null && special === null ? null : (general ?? 0) + (special ?? 0);
|
|
1760
|
+
}
|
|
1761
|
+
if (type === "pbl_pvt_rent") {
|
|
1762
|
+
return optionalNumber2(row.SUPLY_HSHLDCO) ?? null;
|
|
1763
|
+
}
|
|
1764
|
+
return optionalNumber2(row.SUPLY_HSHLDCO) ?? null;
|
|
1765
|
+
}
|
|
1766
|
+
function parseAreaFromTypeName(value) {
|
|
1767
|
+
if (!value) return void 0;
|
|
1768
|
+
const match = value.match(/(\d+(?:\.\d+)?)/);
|
|
1769
|
+
if (!match?.[1]) return void 0;
|
|
1770
|
+
const number = Number(match[1]);
|
|
1771
|
+
return Number.isFinite(number) ? number : void 0;
|
|
1772
|
+
}
|
|
1773
|
+
function optionalText2(value) {
|
|
1774
|
+
if (value === void 0 || value === null) return void 0;
|
|
1775
|
+
const text3 = String(value).trim();
|
|
1776
|
+
return text3.length ? text3 : void 0;
|
|
1777
|
+
}
|
|
1778
|
+
function optionalNumber2(value) {
|
|
1779
|
+
const text3 = optionalText2(value);
|
|
1780
|
+
if (!text3) return void 0;
|
|
1781
|
+
const number = Number(text3.replace(/,/g, ""));
|
|
1782
|
+
return Number.isFinite(number) ? number : void 0;
|
|
1783
|
+
}
|
|
1784
|
+
function roundToTwo(value) {
|
|
1785
|
+
return Math.round(value * 100) / 100;
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
// src/providers/kapt.ts
|
|
1789
|
+
var LIST_BASE = "https://apis.data.go.kr/1613000/AptListService3";
|
|
1790
|
+
var INFO_BASE = "https://apis.data.go.kr/1613000/AptBasisInfoServiceV4";
|
|
1791
|
+
async function fetchLegaldongAptList(bjdCode, env, fetcher = fetch) {
|
|
1792
|
+
return fetchAptList(`${LIST_BASE}/getLegaldongAptList3`, "bjdCode", bjdCode, env, fetcher);
|
|
1793
|
+
}
|
|
1794
|
+
async function fetchSigunguAptList(sigunguCode, env, fetcher = fetch) {
|
|
1795
|
+
return fetchAptList(`${LIST_BASE}/getSigunguAptList3`, "sigunguCode", sigunguCode, env, fetcher);
|
|
1796
|
+
}
|
|
1797
|
+
async function fetchAptList(endpoint, codeParam, codeValue, env, fetcher) {
|
|
1798
|
+
const serviceKey = env.DATA_GO_KR_SERVICE_KEY;
|
|
1799
|
+
if (!serviceKey) return { data: [], sources: [], error: "DATA_GO_KR_SERVICE_KEY is missing." };
|
|
1800
|
+
const items = [];
|
|
1801
|
+
const sources = [];
|
|
1802
|
+
let pageNo = 1;
|
|
1803
|
+
const numOfRows = 100;
|
|
1804
|
+
let resultCode;
|
|
1805
|
+
let resultMsg;
|
|
1806
|
+
while (true) {
|
|
1807
|
+
const url = new URL(endpoint);
|
|
1808
|
+
url.searchParams.set(codeParam, codeValue);
|
|
1809
|
+
url.searchParams.set("pageNo", String(pageNo));
|
|
1810
|
+
url.searchParams.set("numOfRows", String(numOfRows));
|
|
1811
|
+
url.searchParams.set("serviceKey", serviceKey);
|
|
1812
|
+
sources.push(redactServiceKeyInUrl(url.toString(), serviceKey));
|
|
1813
|
+
const response = await fetcher(url.toString(), { headers: { Accept: "application/json" } });
|
|
1814
|
+
if (!response.ok) return { data: items, sources, error: `Apt list API failed: HTTP ${response.status}` };
|
|
1815
|
+
const payload = await readJsonResponse(response);
|
|
1816
|
+
resultCode = payload.response?.header?.resultCode;
|
|
1817
|
+
resultMsg = payload.response?.header?.resultMsg;
|
|
1818
|
+
if (resultCode && resultCode !== "00") {
|
|
1819
|
+
return { data: items, sources, resultCode, resultMsg, error: `API resultCode ${resultCode}` };
|
|
1820
|
+
}
|
|
1821
|
+
const pageItems = normalizeListItems(payload.response?.body?.items);
|
|
1822
|
+
items.push(...pageItems);
|
|
1823
|
+
const totalCount = Number(payload.response?.body?.totalCount ?? items.length);
|
|
1824
|
+
if (pageItems.length === 0 || pageNo * numOfRows >= totalCount) break;
|
|
1825
|
+
pageNo += 1;
|
|
1826
|
+
}
|
|
1827
|
+
return { data: items, sources, resultCode, resultMsg };
|
|
1828
|
+
}
|
|
1829
|
+
function normalizeListItems(items) {
|
|
1830
|
+
if (!items) return [];
|
|
1831
|
+
if (Array.isArray(items)) return items;
|
|
1832
|
+
const inner = items.item;
|
|
1833
|
+
if (!inner) return [];
|
|
1834
|
+
return Array.isArray(inner) ? inner : [inner];
|
|
1835
|
+
}
|
|
1836
|
+
async function fetchAptBasisInfo(kaptCode, env, fetcher = fetch) {
|
|
1837
|
+
return fetchAptItem(`${INFO_BASE}/getAphusBassInfoV4`, kaptCode, env, fetcher);
|
|
1838
|
+
}
|
|
1839
|
+
async function fetchAptDetailInfo(kaptCode, env, fetcher = fetch) {
|
|
1840
|
+
return fetchAptItem(`${INFO_BASE}/getAphusDtlInfoV4`, kaptCode, env, fetcher);
|
|
1841
|
+
}
|
|
1842
|
+
async function fetchAptItem(endpoint, kaptCode, env, fetcher) {
|
|
1843
|
+
const serviceKey = env.DATA_GO_KR_SERVICE_KEY;
|
|
1844
|
+
if (!serviceKey) return { data: null, sources: [], error: "DATA_GO_KR_SERVICE_KEY is missing." };
|
|
1845
|
+
const url = new URL(endpoint);
|
|
1846
|
+
url.searchParams.set("kaptCode", kaptCode);
|
|
1847
|
+
url.searchParams.set("serviceKey", serviceKey);
|
|
1848
|
+
const sources = [redactServiceKeyInUrl(url.toString(), serviceKey)];
|
|
1849
|
+
const response = await fetcher(url.toString(), { headers: { Accept: "application/json" } });
|
|
1850
|
+
if (!response.ok) return { data: null, sources, error: `Apt info API failed: HTTP ${response.status}` };
|
|
1851
|
+
const payload = await readJsonResponse(response);
|
|
1852
|
+
const resultCode = payload.response?.header?.resultCode;
|
|
1853
|
+
const resultMsg = payload.response?.header?.resultMsg;
|
|
1854
|
+
if (resultCode && resultCode !== "00") {
|
|
1855
|
+
return { data: null, sources, resultCode, resultMsg, error: `API resultCode ${resultCode}` };
|
|
1856
|
+
}
|
|
1857
|
+
return { data: payload.response?.body?.item ?? null, sources, resultCode, resultMsg };
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1860
|
+
// src/tools/address.ts
|
|
1861
|
+
async function getAddress(input, env, fetcher = fetch, options = {}) {
|
|
1862
|
+
const query = normalizeWhitespace(input.query);
|
|
1863
|
+
if (!options.cache) return getAddressUncached({ ...input, query }, env, fetcher);
|
|
1864
|
+
const key = `kakao:address:${JSON.stringify({ query, size: input.size ?? 5, lat: input.center_lat ?? null, lng: input.center_lng ?? null })}`;
|
|
1865
|
+
const cached = await options.cache.getOrSetWithStatus(key, TTL.region, async () => {
|
|
1866
|
+
const result = await getAddressUncached({ ...input, query }, env, fetcher);
|
|
1867
|
+
return result.results.length > 0 ? result : null;
|
|
1868
|
+
});
|
|
1869
|
+
if (cached.value) return { ...cached.value, metadata: { ...cached.value.metadata, cache_hit: cached.hit } };
|
|
1870
|
+
return getAddressUncached({ ...input, query }, env, fetcher);
|
|
1871
|
+
}
|
|
1872
|
+
async function getAddressUncached(input, env, fetcher) {
|
|
1873
|
+
const query = normalizeWhitespace(input.query);
|
|
1874
|
+
const metadata = { cache_hit: false, collected_at: nowIso(), data_source: "kakao_keyword", warnings: [] };
|
|
1875
|
+
const result = { query, results: [], summary: { total_found: 0 }, metadata };
|
|
1876
|
+
if (!query) {
|
|
1877
|
+
metadata.warnings?.push("query is required.");
|
|
1878
|
+
return result;
|
|
1879
|
+
}
|
|
1880
|
+
if (!env.KAKAO_REST_API_KEY) {
|
|
1881
|
+
metadata.warnings?.push("KAKAO_REST_API_KEY is missing.");
|
|
1882
|
+
return result;
|
|
1883
|
+
}
|
|
1884
|
+
const url = new URL("https://dapi.kakao.com/v2/local/search/keyword.json");
|
|
1885
|
+
url.searchParams.set("query", query);
|
|
1886
|
+
url.searchParams.set("size", String(Math.min(Math.max(input.size ?? 5, 1), 15)));
|
|
1887
|
+
if (input.center_lat !== void 0 && input.center_lng !== void 0) {
|
|
1888
|
+
url.searchParams.set("x", String(input.center_lng));
|
|
1889
|
+
url.searchParams.set("y", String(input.center_lat));
|
|
1890
|
+
url.searchParams.set("sort", "distance");
|
|
1891
|
+
}
|
|
1892
|
+
const response = await fetcher(url.toString(), { headers: { Authorization: `KakaoAK ${env.KAKAO_REST_API_KEY}` } });
|
|
1893
|
+
if (!response.ok) throw new Error(`Kakao keyword API failed: HTTP ${response.status}`);
|
|
1894
|
+
const payload = await readJsonResponse(response);
|
|
1895
|
+
result.results = (payload.documents ?? []).map((doc) => normalizeCandidate(doc, input.center_lat, input.center_lng));
|
|
1896
|
+
result.summary.total_found = result.results.length;
|
|
1897
|
+
addAddressWarnings(result, metadata);
|
|
1898
|
+
if (metadata.warnings?.length === 0) delete metadata.warnings;
|
|
1899
|
+
return result;
|
|
1900
|
+
}
|
|
1901
|
+
function addAddressWarnings(result, metadata) {
|
|
1902
|
+
if (result.results.length === 0) {
|
|
1903
|
+
metadata.warnings?.push("No Kakao keyword candidates found. Retry with alternate brand/roman numeral/number spacing variants or a nearby station/legal-dong query.");
|
|
1904
|
+
return;
|
|
1905
|
+
}
|
|
1906
|
+
if (result.results.some((candidate) => isModelHouseCandidate(candidate))) {
|
|
1907
|
+
metadata.warnings?.push("Some get_address candidates look like model houses/sales offices, not the actual complex site. Verify the candidate category/address before using it as the project location.");
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
function isModelHouseCandidate(candidate) {
|
|
1911
|
+
return /견본주택|모델하우스|분양사무소|홍보관|sales office/i.test(`${candidate.name} ${candidate.kakao_category_name}`);
|
|
1912
|
+
}
|
|
1913
|
+
function normalizeCandidate(doc, centerLat, centerLng) {
|
|
1914
|
+
const lat = Number(doc.y);
|
|
1915
|
+
const lng = Number(doc.x);
|
|
1916
|
+
const distanceM = doc.distance ? Number(doc.distance) : centerLat !== void 0 && centerLng !== void 0 ? haversineMeters({ lat: centerLat, lng: centerLng }, { lat, lng }) : void 0;
|
|
1917
|
+
return {
|
|
1918
|
+
name: doc.place_name,
|
|
1919
|
+
address: doc.address_name,
|
|
1920
|
+
...doc.road_address_name ? { road_address: doc.road_address_name } : {},
|
|
1921
|
+
lat,
|
|
1922
|
+
lng,
|
|
1923
|
+
...distanceM !== void 0 ? { distance_m: Math.round(distanceM), distance_label: distanceLabel(distanceM) } : {},
|
|
1924
|
+
kakao_place_id: doc.id,
|
|
1925
|
+
kakao_category_name: doc.category_name
|
|
1926
|
+
};
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1929
|
+
// src/tools/region-code.ts
|
|
1930
|
+
var APPLYHOME_BY_SIDO = {
|
|
1931
|
+
"11": "100",
|
|
1932
|
+
// 서울
|
|
1933
|
+
"26": "200",
|
|
1934
|
+
// 부산
|
|
1935
|
+
"27": "200",
|
|
1936
|
+
// 대구
|
|
1937
|
+
"28": "200",
|
|
1938
|
+
// 인천
|
|
1939
|
+
"29": "200",
|
|
1940
|
+
// 광주
|
|
1941
|
+
"30": "300",
|
|
1942
|
+
// 대전
|
|
1943
|
+
"31": "200",
|
|
1944
|
+
// 울산
|
|
1945
|
+
"36": "300",
|
|
1946
|
+
// 세종
|
|
1947
|
+
"41": "410",
|
|
1948
|
+
// 경기
|
|
1949
|
+
"42": "420",
|
|
1950
|
+
// 강원
|
|
1951
|
+
"43": "360",
|
|
1952
|
+
// 충북
|
|
1953
|
+
"44": "312",
|
|
1954
|
+
// 충남
|
|
1955
|
+
"45": "500",
|
|
1956
|
+
// 전북
|
|
1957
|
+
"46": "500",
|
|
1958
|
+
// 전남
|
|
1959
|
+
"47": "600",
|
|
1960
|
+
// 경북
|
|
1961
|
+
"48": "600",
|
|
1962
|
+
// 경남
|
|
1963
|
+
"50": "500",
|
|
1964
|
+
// 제주
|
|
1965
|
+
"51": "420",
|
|
1966
|
+
// 강원특별자치도
|
|
1967
|
+
"52": "500"
|
|
1968
|
+
// 전북특별자치도
|
|
1969
|
+
};
|
|
1970
|
+
async function getRegionCode(input, env, fetcher = fetch, options = {}) {
|
|
1971
|
+
const query = normalizeWhitespace(input.query);
|
|
1972
|
+
if (!options.cache) return getRegionCodeUncached({ query }, env, fetcher);
|
|
1973
|
+
const key = `region:code:${query}`;
|
|
1974
|
+
const cached = await options.cache.getOrSetWithStatus(key, TTL.region, async () => {
|
|
1975
|
+
const result = await getRegionCodeUncached({ query }, env, fetcher);
|
|
1976
|
+
return "full_code" in result ? result : null;
|
|
1977
|
+
});
|
|
1978
|
+
if (cached.value && "full_code" in cached.value) return { ...cached.value, metadata: { ...cached.value.metadata, cache_hit: cached.hit } };
|
|
1979
|
+
return getRegionCodeUncached({ query }, env, fetcher);
|
|
1980
|
+
}
|
|
1981
|
+
async function getRegionCodeUncached(input, env, fetcher) {
|
|
1982
|
+
const query = normalizeWhitespace(input.query);
|
|
1983
|
+
const metadata = { cache_hit: false, collected_at: nowIso(), data_source: "kakao_address" };
|
|
1984
|
+
if (!query) return { query, error: "INVALID_QUERY", message: "query is required.", metadata };
|
|
1985
|
+
if (!env.KAKAO_REST_API_KEY) return { query, error: "KAKAO_AUTH_MISSING", message: "KAKAO_REST_API_KEY is missing.", metadata };
|
|
1986
|
+
const direct = await searchAddress(query, env, fetcher);
|
|
1987
|
+
if (direct) return buildSuccess(query, direct, metadata, "address");
|
|
1988
|
+
const placeAddress = await searchKeywordAddress(query, env, fetcher);
|
|
1989
|
+
if (placeAddress) {
|
|
1990
|
+
const viaPlace = await searchAddress(placeAddress, env, fetcher);
|
|
1991
|
+
if (viaPlace) return buildSuccess(query, viaPlace, metadata, "keyword_fallback");
|
|
1992
|
+
}
|
|
1993
|
+
return { query, error: "NOT_FOUND", message: "[NOT_FOUND] \uBC95\uC815\uB3D9\uCF54\uB4DC\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.", metadata };
|
|
1994
|
+
}
|
|
1995
|
+
async function searchAddress(query, env, fetcher) {
|
|
1996
|
+
const url = new URL("https://dapi.kakao.com/v2/local/search/address.json");
|
|
1997
|
+
url.searchParams.set("query", query);
|
|
1998
|
+
url.searchParams.set("analyze_type", "similar");
|
|
1999
|
+
url.searchParams.set("size", "1");
|
|
2000
|
+
const response = await fetcher(url.toString(), { headers: { Authorization: `KakaoAK ${env.KAKAO_REST_API_KEY}` } });
|
|
2001
|
+
if (!response.ok) throw new Error(`Kakao address API failed: HTTP ${response.status}`);
|
|
2002
|
+
const payload = await readJsonResponse(response);
|
|
2003
|
+
const doc = payload.documents?.[0];
|
|
2004
|
+
return doc?.address?.b_code ? doc : null;
|
|
2005
|
+
}
|
|
2006
|
+
async function searchKeywordAddress(query, env, fetcher) {
|
|
2007
|
+
const url = new URL("https://dapi.kakao.com/v2/local/search/keyword.json");
|
|
2008
|
+
url.searchParams.set("query", query);
|
|
2009
|
+
url.searchParams.set("size", "1");
|
|
2010
|
+
const response = await fetcher(url.toString(), { headers: { Authorization: `KakaoAK ${env.KAKAO_REST_API_KEY}` } });
|
|
2011
|
+
if (!response.ok) return null;
|
|
2012
|
+
const payload = await readJsonResponse(response);
|
|
2013
|
+
return payload.documents?.[0]?.address_name ?? null;
|
|
2014
|
+
}
|
|
2015
|
+
function buildSuccess(query, doc, metadata, via) {
|
|
2016
|
+
const fullCode = doc.address.b_code;
|
|
2017
|
+
const sidoCode = fullCode.slice(0, 2);
|
|
2018
|
+
const regionName = [doc.address?.region_1depth_name, doc.address?.region_2depth_name, doc.address?.region_3depth_name].map((part) => part?.trim()).filter((part) => Boolean(part)).join(" ");
|
|
2019
|
+
return {
|
|
2020
|
+
query,
|
|
2021
|
+
full_code: fullCode,
|
|
2022
|
+
sigungu_code: fullCode.slice(0, 5),
|
|
2023
|
+
sido_code: sidoCode,
|
|
2024
|
+
...APPLYHOME_BY_SIDO[sidoCode] ? { applyhome_code: APPLYHOME_BY_SIDO[sidoCode] } : {},
|
|
2025
|
+
region_name: regionName || doc.address?.address_name || doc.address_name || query,
|
|
2026
|
+
...doc.address_name ? { address_name: doc.address_name } : {},
|
|
2027
|
+
...doc.y ? { lat: Number(doc.y) } : {},
|
|
2028
|
+
...doc.x ? { lng: Number(doc.x) } : {},
|
|
2029
|
+
resolved_via: via,
|
|
2030
|
+
metadata
|
|
2031
|
+
};
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
// src/resolve/locate.ts
|
|
2035
|
+
async function resolveComplexLocation(query, env, deps = {}) {
|
|
2036
|
+
const warnings = [];
|
|
2037
|
+
const trimmed = query.trim();
|
|
2038
|
+
if (!trimmed) return { status: "not_found", query, message: "[NOT_FOUND] \uBE48 \uAC80\uC0C9\uC5B4\uC785\uB2C8\uB2E4.", warnings };
|
|
2039
|
+
if (!env.KAKAO_REST_API_KEY) return { status: "auth_missing", query, message: "[AUTH_MISSING] KAKAO_REST_API_KEY \uD658\uACBD\uBCC0\uC218\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.", warnings };
|
|
2040
|
+
const addressFn = deps.addressFn ?? ((q) => getAddress({ query: q, size: 5 }, env, deps.fetcher ?? fetch, deps.cache ? { cache: deps.cache } : {}));
|
|
2041
|
+
const regionFn = deps.regionFn ?? ((q) => getRegionCode({ query: q }, env, deps.fetcher ?? fetch, deps.cache ? { cache: deps.cache } : {}));
|
|
2042
|
+
const addressResult = await addressFn(trimmed);
|
|
2043
|
+
const hits = addressResult.results ?? [];
|
|
2044
|
+
if (hits.length === 0) {
|
|
2045
|
+
return { status: "not_found", query, message: "[NOT_FOUND] \uC8FC\uC18C \uD6C4\uBCF4\uB97C \uCC3E\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. \uB2E8\uC9C0\uBA85/\uC8FC\uC18C\uB97C \uD655\uC778\uD558\uC138\uC694.", warnings };
|
|
2046
|
+
}
|
|
2047
|
+
const top = hits[0];
|
|
2048
|
+
const isResidential = /주거시설|아파트|오피스텔/.test(top.kakao_category_name);
|
|
2049
|
+
if (!isResidential) {
|
|
2050
|
+
warnings.push(`\uCD5C\uC0C1\uC704 \uD6C4\uBCF4 \uCE74\uD14C\uACE0\uB9AC\uAC00 \uC8FC\uAC70\uC2DC\uC124\uC774 \uC544\uB2D9\uB2C8\uB2E4(${top.kakao_category_name}). \uB2E8\uC9C0\uBA85\uC774 \uBAA8\uD638\uD560 \uC218 \uC788\uC2B5\uB2C8\uB2E4.`);
|
|
2051
|
+
}
|
|
2052
|
+
const { umd, jibun } = parseUmdJibun(top.address);
|
|
2053
|
+
const dongLevel = umd ? top.address.replace(/\s*\S+$/, "") : top.address;
|
|
2054
|
+
let region = await regionFn(dongLevel);
|
|
2055
|
+
if (!("full_code" in region) && dongLevel !== top.address) {
|
|
2056
|
+
region = await regionFn(top.address);
|
|
2057
|
+
}
|
|
2058
|
+
if (!("full_code" in region)) {
|
|
2059
|
+
return { status: "region_failed", query, message: `[REGION_FAILED] \uC8FC\uC18C(${top.address})\uC5D0\uC11C \uBC95\uC815\uB3D9\uCF54\uB4DC\uB97C \uC5BB\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4.`, warnings };
|
|
2060
|
+
}
|
|
2061
|
+
const candidates = hits.slice(0, 5).map((h) => ({
|
|
2062
|
+
name: h.name,
|
|
2063
|
+
address: h.address,
|
|
2064
|
+
...h.road_address ? { road_address: h.road_address } : {},
|
|
2065
|
+
lat: h.lat,
|
|
2066
|
+
lng: h.lng,
|
|
2067
|
+
category: h.kakao_category_name
|
|
2068
|
+
}));
|
|
2069
|
+
return {
|
|
2070
|
+
status: "resolved",
|
|
2071
|
+
query,
|
|
2072
|
+
kakao_place_name: top.name,
|
|
2073
|
+
address: top.address,
|
|
2074
|
+
...top.road_address ? { road_address: top.road_address } : {},
|
|
2075
|
+
...umd ? { umd } : {},
|
|
2076
|
+
...jibun ? { jibun } : {},
|
|
2077
|
+
full_code: region.full_code,
|
|
2078
|
+
sigungu_code: region.sigungu_code,
|
|
2079
|
+
sido_code: region.sido_code,
|
|
2080
|
+
...region.applyhome_code ? { applyhome_code: region.applyhome_code } : {},
|
|
2081
|
+
region_name: region.region_name,
|
|
2082
|
+
lat: top.lat,
|
|
2083
|
+
lng: top.lng,
|
|
2084
|
+
is_residential: isResidential,
|
|
2085
|
+
candidates,
|
|
2086
|
+
warnings
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
function parseUmdJibun(address) {
|
|
2090
|
+
const match = address.match(/(\S+(?:동|읍|면|가|리))\s+(산?\d+(?:-\d+)?)/);
|
|
2091
|
+
if (!match) return {};
|
|
2092
|
+
return { umd: match[1], jibun: match[2] };
|
|
2093
|
+
}
|
|
2094
|
+
|
|
2095
|
+
// src/tools/complex-info.ts
|
|
2096
|
+
async function getComplexInfo(input, env, deps = {}) {
|
|
2097
|
+
if (!deps.cache) return getComplexInfoUncached(input, env, deps);
|
|
2098
|
+
const keyId = input.kapt_code?.trim() || (input.complex_query ? `q:${normalizeComplexName(input.complex_query)}` : `${input.bjd_code}:${normalizeComplexName(input.complex_name ?? "")}`);
|
|
2099
|
+
const key = `kapt:info:${keyId}:${input.detail ? "d" : "b"}`;
|
|
2100
|
+
const cached = await deps.cache.getOrSetWithStatus(key, TTL.complexInfo, async () => {
|
|
2101
|
+
const result = await getComplexInfoUncached(input, env, deps);
|
|
2102
|
+
return "error" in result ? null : result;
|
|
2103
|
+
});
|
|
2104
|
+
if (cached.value && !("error" in cached.value)) return { ...cached.value, metadata: { ...cached.value.metadata, cache_hit: cached.hit } };
|
|
2105
|
+
return getComplexInfoUncached(input, env, deps);
|
|
2106
|
+
}
|
|
2107
|
+
async function getComplexInfoUncached(input, env, deps = {}) {
|
|
2108
|
+
const sources = [];
|
|
2109
|
+
const fetcher = deps.fetcher;
|
|
2110
|
+
if (!env.DATA_GO_KR_SERVICE_KEY) {
|
|
2111
|
+
return errorResult3("config_error", "API \uC778\uC99D\uD0A4 \uBBF8\uC124\uC815", sources);
|
|
2112
|
+
}
|
|
2113
|
+
if (!input.kapt_code && !input.complex_query && !(input.bjd_code && input.complex_name)) {
|
|
2114
|
+
return errorResult3("invalid_input", "kapt_code, complex_query, \uB610\uB294 (bjd_code+complex_name) \uC911 \uD558\uB098\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4", sources);
|
|
2115
|
+
}
|
|
2116
|
+
let kaptCode = input.kapt_code?.trim();
|
|
2117
|
+
let resolvedFrom = "kapt_code";
|
|
2118
|
+
let matchKind;
|
|
2119
|
+
let resolvedName;
|
|
2120
|
+
let resolvedLocation;
|
|
2121
|
+
let bjdCode = input.bjd_code;
|
|
2122
|
+
let complexName = input.complex_name;
|
|
2123
|
+
if (!kaptCode && input.complex_query) {
|
|
2124
|
+
const located = await resolveComplexLocation(input.complex_query, env, {
|
|
2125
|
+
...fetcher ? { fetcher } : {},
|
|
2126
|
+
...deps.cache ? { cache: deps.cache } : {}
|
|
2127
|
+
});
|
|
2128
|
+
if (located.status !== "resolved") {
|
|
2129
|
+
return errorResult3(located.status === "auth_missing" ? "config_error" : "NOT_FOUND", located.message, sources);
|
|
2130
|
+
}
|
|
2131
|
+
bjdCode = located.full_code;
|
|
2132
|
+
complexName = input.complex_name ?? located.kakao_place_name;
|
|
2133
|
+
resolvedLocation = {
|
|
2134
|
+
address: located.address,
|
|
2135
|
+
umd: located.umd,
|
|
2136
|
+
jibun: located.jibun,
|
|
2137
|
+
full_code: located.full_code,
|
|
2138
|
+
sigungu_code: located.sigungu_code,
|
|
2139
|
+
kakao_place_name: located.kakao_place_name,
|
|
2140
|
+
warnings: located.warnings
|
|
2141
|
+
};
|
|
2142
|
+
}
|
|
2143
|
+
if (!kaptCode && bjdCode && complexName) {
|
|
2144
|
+
const resolution = await resolveKaptCode(bjdCode, complexName, env, fetcher, sources);
|
|
2145
|
+
if ("error" in resolution) return resolution;
|
|
2146
|
+
kaptCode = resolution.kaptCode;
|
|
2147
|
+
resolvedName = resolution.name;
|
|
2148
|
+
matchKind = resolution.match;
|
|
2149
|
+
resolvedFrom = input.complex_query ? "complex_query" : "bjd_code+name";
|
|
2150
|
+
}
|
|
2151
|
+
if (!kaptCode) {
|
|
2152
|
+
return errorResult3("NOT_FOUND", "[NOT_FOUND] \uB2E8\uC9C0\uB97C \uD2B9\uC815\uD558\uC9C0 \uBABB\uD588\uC2B5\uB2C8\uB2E4. \uC815\uBCF4 \uC0DD\uC131 \uAE08\uC9C0.", sources);
|
|
2153
|
+
}
|
|
2154
|
+
const basisResult = await fetchAptBasisInfo(kaptCode, env, fetcher);
|
|
2155
|
+
sources.push(...basisResult.sources);
|
|
2156
|
+
if (basisResult.error && !basisResult.data) {
|
|
2157
|
+
if (basisResult.resultCode && basisResult.resultCode !== "00") {
|
|
2158
|
+
return errorResult3("api_error", `K-apt \uAE30\uBCF8\uC815\uBCF4 \uC870\uD68C \uC2E4\uD328 (resultCode ${basisResult.resultCode})`, sources);
|
|
2159
|
+
}
|
|
2160
|
+
return errorResult3("network_error", `K-apt \uAE30\uBCF8\uC815\uBCF4 \uC870\uD68C \uC2E4\uD328: ${basisResult.error}`, sources);
|
|
2161
|
+
}
|
|
2162
|
+
if (!basisResult.data) {
|
|
2163
|
+
return errorResult3("NOT_FOUND", "[NOT_FOUND] \uD574\uB2F9 \uB2E8\uC9C0\uC758 \uAE30\uBCF8\uC815\uBCF4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4. \uC815\uBCF4 \uC0DD\uC131 \uAE08\uC9C0.", sources);
|
|
2164
|
+
}
|
|
2165
|
+
const basic = mapBasic(basisResult.data);
|
|
2166
|
+
let detail = null;
|
|
2167
|
+
const derived = {};
|
|
2168
|
+
if (input.detail) {
|
|
2169
|
+
const detailResult = await fetchAptDetailInfo(kaptCode, env, fetcher);
|
|
2170
|
+
sources.push(...detailResult.sources);
|
|
2171
|
+
if (detailResult.data) {
|
|
2172
|
+
detail = mapDetail(detailResult.data);
|
|
2173
|
+
const totalParking = (detail.parking_ground ?? 0) + (detail.parking_underground ?? 0);
|
|
2174
|
+
if (basic.total_units && basic.total_units > 0 && totalParking > 0) {
|
|
2175
|
+
derived.parking_per_unit = Math.round(totalParking / basic.total_units * 100) / 100;
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
return {
|
|
2180
|
+
kapt_code: kaptCode,
|
|
2181
|
+
name: resolvedName ?? toText(basisResult.data.kaptName) ?? kaptCode,
|
|
2182
|
+
resolved_from: resolvedFrom,
|
|
2183
|
+
...matchKind ? { match: matchKind } : {},
|
|
2184
|
+
...resolvedLocation ? { resolved_location: resolvedLocation } : {},
|
|
2185
|
+
basic,
|
|
2186
|
+
detail,
|
|
2187
|
+
derived,
|
|
2188
|
+
metadata: {
|
|
2189
|
+
cache_hit: false,
|
|
2190
|
+
collected_at: nowIso(),
|
|
2191
|
+
data_source: "kapt_basis_info_v4",
|
|
2192
|
+
sources,
|
|
2193
|
+
note: "\uC900\uACF5\uB144\uB3C4=\uC0AC\uC6A9\uC2B9\uC778\uC77C \uAE30\uC900. \uB2E8\uC9C0\uBA85 \uB9E4\uCE6D\uC740 best-effort"
|
|
2194
|
+
}
|
|
2195
|
+
};
|
|
2196
|
+
}
|
|
2197
|
+
async function resolveKaptCode(bjdCode, complexName, env, fetcher, sources) {
|
|
2198
|
+
const legaldong = await fetchLegaldongAptList(bjdCode, env, fetcher);
|
|
2199
|
+
sources.push(...legaldong.sources);
|
|
2200
|
+
let decision = scoreAndDecide(complexName, toNameCandidates(legaldong.data));
|
|
2201
|
+
if (decision.status === "not_found") {
|
|
2202
|
+
const sigunguCode = bjdCode.slice(0, 5);
|
|
2203
|
+
const sigungu = await fetchSigunguAptList(sigunguCode, env, fetcher);
|
|
2204
|
+
sources.push(...sigungu.sources);
|
|
2205
|
+
const widened = scoreAndDecide(complexName, toNameCandidates(sigungu.data));
|
|
2206
|
+
if (widened.status !== "not_found") decision = widened;
|
|
2207
|
+
}
|
|
2208
|
+
if (decision.status === "not_found") {
|
|
2209
|
+
return errorResult3("NOT_FOUND", "[NOT_FOUND] \uD574\uB2F9 \uBC95\uC815\uB3D9\uC5D0\uC11C \uB2E8\uC9C0 \uBBF8\uBC1C\uACAC. \uC815\uBCF4 \uC0DD\uC131 \uAE08\uC9C0", sources);
|
|
2210
|
+
}
|
|
2211
|
+
if (decision.status === "ambiguous") {
|
|
2212
|
+
const err = errorResult3("AMBIGUOUS", "[NEEDS_DISAMBIGUATION] \uB2E8\uC9C0 \uD2B9\uC815 \uBD88\uAC00 \u2014 \uBA54\uD0C0\uC815\uBCF4 \uC0DD\uC131 \uAE08\uC9C0", sources);
|
|
2213
|
+
err.candidates = decision.candidates.map((c) => ({
|
|
2214
|
+
kapt_code: c.code,
|
|
2215
|
+
name: c.name,
|
|
2216
|
+
...c.address_road ? { address_road: c.address_road } : {},
|
|
2217
|
+
sim: Math.round(c.sim * 1e3) / 1e3
|
|
2218
|
+
}));
|
|
2219
|
+
return err;
|
|
2220
|
+
}
|
|
2221
|
+
return { kaptCode: decision.code, name: decision.name, match: decision.match };
|
|
2222
|
+
}
|
|
2223
|
+
function toNameCandidates(items) {
|
|
2224
|
+
const out = [];
|
|
2225
|
+
for (const item of items) {
|
|
2226
|
+
const code = toText(item.kaptCode);
|
|
2227
|
+
const name = toText(item.kaptName);
|
|
2228
|
+
if (!code || !name) continue;
|
|
2229
|
+
out.push({ code, name, ...toText(item.doroJuso) ? { address_road: toText(item.doroJuso) } : {} });
|
|
2230
|
+
}
|
|
2231
|
+
return out;
|
|
2232
|
+
}
|
|
2233
|
+
function mapBasic(item) {
|
|
2234
|
+
const useDate = toText(item.kaptUsedate);
|
|
2235
|
+
const buildYear = useDate && useDate.length >= 4 ? Number(useDate.slice(0, 4)) : null;
|
|
2236
|
+
return {
|
|
2237
|
+
build_year: buildYear !== null && Number.isFinite(buildYear) ? buildYear : null,
|
|
2238
|
+
use_approval_date: useDate ?? null,
|
|
2239
|
+
total_units: toNumber(item.kaptdaCnt),
|
|
2240
|
+
dong_count: toNumber(item.kaptDongCnt),
|
|
2241
|
+
constructor: toText(item.kaptBcompany) ?? null,
|
|
2242
|
+
developer: toText(item.kaptAcompany) ?? null,
|
|
2243
|
+
sale_type: toText(item.codeSaleNm) ?? null,
|
|
2244
|
+
heat_type: toText(item.codeHeatNm) ?? null,
|
|
2245
|
+
mgmt_type: toText(item.codeMgrNm) ?? null,
|
|
2246
|
+
hall_type: toText(item.codeHallNm) ?? null,
|
|
2247
|
+
complex_category: toText(item.codeAptNm) ?? null,
|
|
2248
|
+
top_floor: toNumber(item.kaptTopFloor),
|
|
2249
|
+
base_floor: toNumber(item.kaptBaseFloor),
|
|
2250
|
+
total_floor_area: toNumber(item.kaptTarea),
|
|
2251
|
+
private_area_sum: toNumber(item.privArea),
|
|
2252
|
+
ho_count: toNumber(item.hoCnt),
|
|
2253
|
+
unit_mix: {
|
|
2254
|
+
under_60: toNumber(item.kaptMparea60),
|
|
2255
|
+
_60_85: toNumber(item.kaptMparea85),
|
|
2256
|
+
_85_135: toNumber(item.kaptMparea135),
|
|
2257
|
+
over_135: toNumber(item.kaptMparea136)
|
|
2258
|
+
},
|
|
2259
|
+
address_jibun: toText(item.kaptAddr) ?? null,
|
|
2260
|
+
address_road: toText(item.doroJuso) ?? null,
|
|
2261
|
+
bjd_code: toText(item.bjdCode) ?? null,
|
|
2262
|
+
office_tel: toText(item.kaptTel) ?? null,
|
|
2263
|
+
homepage: toText(item.kaptUrl) ?? null
|
|
2264
|
+
};
|
|
2265
|
+
}
|
|
2266
|
+
function mapDetail(item) {
|
|
2267
|
+
return {
|
|
2268
|
+
parking_ground: toNumber(item.kaptdPcnt),
|
|
2269
|
+
parking_underground: toNumber(item.kaptdPcntu),
|
|
2270
|
+
elevator_count: toNumber(item.kaptdEcnt),
|
|
2271
|
+
cctv_count: toNumber(item.kaptdCccnt),
|
|
2272
|
+
subway_line: toText(item.subwayLine) ?? null,
|
|
2273
|
+
subway_station: toText(item.subwayStation) ?? null,
|
|
2274
|
+
subway_walk_min: toText(item.kaptdWtimesub) ?? null,
|
|
2275
|
+
bus_walk_min: toText(item.kaptdWtimebus) ?? null,
|
|
2276
|
+
welfare_facility: toText(item.welfareFacility) ?? null,
|
|
2277
|
+
convenient_facility: toText(item.convenientFacility) ?? null,
|
|
2278
|
+
education_facility: toText(item.educationFacility) ?? null,
|
|
2279
|
+
ev_charger_ground: toNumber(item.groundElChargerCnt),
|
|
2280
|
+
ev_charger_underground: toNumber(item.undergroundElChargerCnt),
|
|
2281
|
+
structure: toText(item.codeStr) ?? null,
|
|
2282
|
+
water_supply: toText(item.codeWsupply) ?? null,
|
|
2283
|
+
home_network: toText(item.codeNet) ?? null,
|
|
2284
|
+
use_yn: toText(item.useYn) ?? null
|
|
2285
|
+
};
|
|
2286
|
+
}
|
|
2287
|
+
function errorResult3(error, message, sources) {
|
|
2288
|
+
return {
|
|
2289
|
+
error,
|
|
2290
|
+
message,
|
|
2291
|
+
metadata: { cache_hit: false, collected_at: nowIso(), data_source: "kapt_basis_info_v4", sources }
|
|
2292
|
+
};
|
|
2293
|
+
}
|
|
2294
|
+
function toText(value) {
|
|
2295
|
+
if (value === void 0 || value === null) return void 0;
|
|
2296
|
+
const text3 = String(value).trim();
|
|
2297
|
+
return text3.length ? text3 : void 0;
|
|
2298
|
+
}
|
|
2299
|
+
function toNumber(value) {
|
|
2300
|
+
const text3 = toText(value);
|
|
2301
|
+
if (!text3) return null;
|
|
2302
|
+
const num = Number(text3.replace(/,/g, ""));
|
|
2303
|
+
return Number.isFinite(num) ? num : null;
|
|
2304
|
+
}
|
|
2305
|
+
|
|
2306
|
+
// src/tools/complex-trades.ts
|
|
2307
|
+
var PYEONG_PER_SQM2 = 3.3058;
|
|
2308
|
+
var DEFAULT_SUPPLY_RATIO = 0.752;
|
|
2309
|
+
var DEFAULT_MOLIT_NUM_OF_ROWS = 1e3;
|
|
2310
|
+
var MOLIT_PAGE_CONCURRENCY = 4;
|
|
2311
|
+
var ENDPOINTS = {
|
|
2312
|
+
sale: {
|
|
2313
|
+
url: "https://apis.data.go.kr/1613000/RTMSDataSvcAptTrade/getRTMSDataSvcAptTrade",
|
|
2314
|
+
dataSource: "molit_apt_trade"
|
|
2315
|
+
},
|
|
2316
|
+
resale: {
|
|
2317
|
+
url: "https://apis.data.go.kr/1613000/RTMSDataSvcSilvTrade/getRTMSDataSvcSilvTrade",
|
|
2318
|
+
dataSource: "molit_apt_resale"
|
|
2319
|
+
},
|
|
2320
|
+
rent: {
|
|
2321
|
+
url: "https://apis.data.go.kr/1613000/RTMSDataSvcAptRent/getRTMSDataSvcAptRent",
|
|
2322
|
+
dataSource: "molit_apt_rent"
|
|
2323
|
+
}
|
|
2324
|
+
};
|
|
2325
|
+
var ALL_TRADE_TYPES = ["sale", "rent", "resale"];
|
|
2326
|
+
var PRIMARY_TYPE_ORDER = ["sale", "resale", "rent"];
|
|
2327
|
+
async function getComplexTrades(input, env, deps = {}) {
|
|
2328
|
+
const tradeType = input.trade_type ?? "sale";
|
|
2329
|
+
let resolved = input;
|
|
2330
|
+
let resolveWarnings = [];
|
|
2331
|
+
if (!input.region_code && input.complex_query) {
|
|
2332
|
+
const located = await resolveComplexLocation(input.complex_query, env, {
|
|
2333
|
+
...deps.fetcher ? { fetcher: deps.fetcher } : {},
|
|
2334
|
+
...deps.cache ? { cache: deps.cache } : {}
|
|
2335
|
+
});
|
|
2336
|
+
if (located.status !== "resolved") {
|
|
2337
|
+
return errorResult4(input, tradeType, located.status === "auth_missing" ? "AUTH_MISSING" : "NOT_FOUND", located.message, [], located.warnings, false);
|
|
2338
|
+
}
|
|
2339
|
+
resolveWarnings = located.warnings;
|
|
2340
|
+
resolved = {
|
|
2341
|
+
...input,
|
|
2342
|
+
region_code: located.sigungu_code,
|
|
2343
|
+
// Auto-build a 법정동+지번 filter when the resolver parsed them; keep any
|
|
2344
|
+
// explicit complex_filter the caller provided.
|
|
2345
|
+
...input.complex_filter ? {} : located.umd && located.jibun ? { complex_filter: { umd: located.umd, jibun: located.jibun, jibun_match: "bonbun", apt_name: located.kakao_place_name } } : {}
|
|
2346
|
+
};
|
|
2347
|
+
}
|
|
2348
|
+
if (!resolved.region_code) {
|
|
2349
|
+
return errorResult4(input, tradeType, "INVALID_INPUT", "region_code \uB610\uB294 complex_query \uC911 \uD558\uB098\uAC00 \uD544\uC694\uD569\uB2C8\uB2E4.", [], [], false);
|
|
2350
|
+
}
|
|
2351
|
+
if (tradeType === "all") {
|
|
2352
|
+
return withResolveWarnings(await getComplexTradesAll(resolved, env, deps), resolveWarnings);
|
|
2353
|
+
}
|
|
2354
|
+
if (!deps.cache) return withResolveWarnings(await getComplexTradesUncached(resolved, env, deps, false), resolveWarnings);
|
|
2355
|
+
const key = `molit:trades:${tradeType}:${resolved.region_code}:${resolved.year_month}:${resolved.num_of_rows ?? DEFAULT_MOLIT_NUM_OF_ROWS}`;
|
|
2356
|
+
const cached = await deps.cache.getOrSetWithStatus(key, TTL.trades, async () => {
|
|
2357
|
+
const provider = await fetchMolitTrades(resolved, env, deps.fetcher, tradeType);
|
|
2358
|
+
return provider.api_error ? null : provider;
|
|
2359
|
+
});
|
|
2360
|
+
if (cached.value) return withResolveWarnings(buildResult(resolved, tradeType, cached.value, cached.hit), resolveWarnings);
|
|
2361
|
+
return withResolveWarnings(await getComplexTradesUncached(resolved, env, deps, false), resolveWarnings);
|
|
2362
|
+
}
|
|
2363
|
+
function withResolveWarnings(result, warnings) {
|
|
2364
|
+
if (warnings.length === 0) return result;
|
|
2365
|
+
const merged = [...result.metadata.warnings ?? [], ...warnings];
|
|
2366
|
+
return { ...result, metadata: { ...result.metadata, warnings: merged } };
|
|
2367
|
+
}
|
|
2368
|
+
async function getComplexTradesUncached(input, env, deps, cacheHit) {
|
|
2369
|
+
const tradeType = normalizeSingleTradeType(input.trade_type);
|
|
2370
|
+
const provider = await fetchMolitTrades(input, env, deps.fetcher, tradeType);
|
|
2371
|
+
if (provider.api_error) {
|
|
2372
|
+
return errorResult4(input, tradeType, provider.api_error.code === "03" ? "NOT_FOUND" : "API_ERROR", provider.api_error.message, provider.sources, provider.warnings, cacheHit);
|
|
2373
|
+
}
|
|
2374
|
+
return buildResult(input, tradeType, provider, cacheHit);
|
|
2375
|
+
}
|
|
2376
|
+
async function getComplexTradesAll(input, env, deps) {
|
|
2377
|
+
const settled = await Promise.allSettled(
|
|
2378
|
+
ALL_TRADE_TYPES.map(async (tradeType) => {
|
|
2379
|
+
const singleInput = { ...input, trade_type: tradeType };
|
|
2380
|
+
const result = deps.cache ? await getComplexTrades(singleInput, env, deps) : await getComplexTradesUncached(singleInput, env, deps, false);
|
|
2381
|
+
return [tradeType, result];
|
|
2382
|
+
})
|
|
2383
|
+
);
|
|
2384
|
+
const results = {};
|
|
2385
|
+
const warnings = [];
|
|
2386
|
+
for (let index = 0; index < settled.length; index += 1) {
|
|
2387
|
+
const tradeType = ALL_TRADE_TYPES[index];
|
|
2388
|
+
const item = settled[index];
|
|
2389
|
+
if (item.status === "fulfilled") {
|
|
2390
|
+
const [, result] = item.value;
|
|
2391
|
+
results[tradeType] = result;
|
|
2392
|
+
if (result.metadata.warnings) warnings.push(...result.metadata.warnings);
|
|
2393
|
+
} else {
|
|
2394
|
+
const message = item.reason instanceof Error ? item.reason.message : String(item.reason);
|
|
2395
|
+
warnings.push(`MOLIT ${tradeType} fetch rejected: ${message}`);
|
|
2396
|
+
results[tradeType] = errorResult4(input, tradeType, "API_ERROR", `[API_ERROR] ${message}`, [], [], false);
|
|
2397
|
+
}
|
|
2398
|
+
}
|
|
2399
|
+
const primaryType = PRIMARY_TYPE_ORDER.find((tradeType) => {
|
|
2400
|
+
const result = results[tradeType];
|
|
2401
|
+
return result && !("error" in result) && result.summary.sample_count > 0;
|
|
2402
|
+
}) ?? null;
|
|
2403
|
+
const sources = ALL_TRADE_TYPES.flatMap((tradeType) => results[tradeType]?.metadata.sources ?? []);
|
|
2404
|
+
const returnedCount = ALL_TRADE_TYPES.reduce((sum, tradeType) => sum + metadataCount(results[tradeType], "returned_count"), 0);
|
|
2405
|
+
const omittedCount = ALL_TRADE_TYPES.reduce((sum, tradeType) => sum + metadataCount(results[tradeType], "omitted_count"), 0);
|
|
2406
|
+
const truncated = ALL_TRADE_TYPES.some((tradeType) => Boolean(results[tradeType]?.metadata.truncated));
|
|
2407
|
+
return {
|
|
2408
|
+
trade_type: "all",
|
|
2409
|
+
region_code: input.region_code ?? "",
|
|
2410
|
+
year_month: input.year_month,
|
|
2411
|
+
filter_applied: normalizeFilter(input.complex_filter),
|
|
2412
|
+
results,
|
|
2413
|
+
primary_type: primaryType,
|
|
2414
|
+
metadata: {
|
|
2415
|
+
cache_hit: ALL_TRADE_TYPES.every((tradeType) => Boolean(results[tradeType]?.metadata.cache_hit)),
|
|
2416
|
+
collected_at: nowIso(),
|
|
2417
|
+
data_sources: ALL_TRADE_TYPES.map((tradeType) => ENDPOINTS[tradeType].dataSource),
|
|
2418
|
+
sources,
|
|
2419
|
+
...warnings.length ? { warnings } : {},
|
|
2420
|
+
returned_count: returnedCount,
|
|
2421
|
+
omitted_count: omittedCount,
|
|
2422
|
+
truncated,
|
|
2423
|
+
source_count: sources.length
|
|
2424
|
+
}
|
|
2425
|
+
};
|
|
2426
|
+
}
|
|
2427
|
+
async function fetchMolitTrades(input, env, fetcher = fetch, tradeType) {
|
|
2428
|
+
if (!env.DATA_GO_KR_SERVICE_KEY) {
|
|
2429
|
+
return { rows: [], total_count: 0, sources: [], warnings: ["DATA_GO_KR_SERVICE_KEY is missing."], api_error: { code: "AUTH_MISSING", message: "[AUTH_MISSING] DATA_GO_KR_SERVICE_KEY \uD658\uACBD\uBCC0\uC218\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." } };
|
|
2430
|
+
}
|
|
2431
|
+
const serviceKey = env.DATA_GO_KR_SERVICE_KEY;
|
|
2432
|
+
const numOfRows = input.num_of_rows ?? DEFAULT_MOLIT_NUM_OF_ROWS;
|
|
2433
|
+
const fetchPage = async (pageNo) => {
|
|
2434
|
+
const url = new URL(ENDPOINTS[tradeType].url);
|
|
2435
|
+
url.searchParams.set("serviceKey", serviceKey);
|
|
2436
|
+
url.searchParams.set("LAWD_CD", input.region_code ?? "");
|
|
2437
|
+
url.searchParams.set("DEAL_YMD", input.year_month);
|
|
2438
|
+
url.searchParams.set("pageNo", String(pageNo));
|
|
2439
|
+
url.searchParams.set("numOfRows", String(numOfRows));
|
|
2440
|
+
const source = redactServiceKeyInUrl(url.toString(), serviceKey);
|
|
2441
|
+
const response = await fetcher(url.toString(), {
|
|
2442
|
+
headers: {
|
|
2443
|
+
Accept: "application/xml,text/xml,*/*",
|
|
2444
|
+
// MOLIT real-transaction API returns "400 Request Blocked" for requests
|
|
2445
|
+
// with no/curl User-Agent; a browser-like UA is required.
|
|
2446
|
+
"User-Agent": "Mozilla/5.0 (compatible; presale-mcp/1.0)"
|
|
2447
|
+
}
|
|
2448
|
+
});
|
|
2449
|
+
if (!response.ok) {
|
|
2450
|
+
return { rows: [], total_count: 0, sources: [source], warnings: [], page_no: pageNo, num_of_rows: numOfRows, api_error: { code: String(response.status), message: `MOLIT real transaction API failed: HTTP ${response.status}` } };
|
|
2451
|
+
}
|
|
2452
|
+
const xml = await response.text();
|
|
2453
|
+
const parsed2 = parseMolitXml(xml);
|
|
2454
|
+
if (parsed2.resultCode && parsed2.resultCode !== "000") {
|
|
2455
|
+
return { rows: [], total_count: parsed2.totalCount ?? 0, sources: [source], warnings: [], page_no: pageNo, num_of_rows: parsed2.numOfRows ?? numOfRows, api_error: { code: parsed2.resultCode, message: `[${parsed2.resultCode}] ${parsed2.resultMsg || "MOLIT API error"}` } };
|
|
2456
|
+
}
|
|
2457
|
+
return {
|
|
2458
|
+
rows: parsed2.items,
|
|
2459
|
+
total_count: parsed2.totalCount ?? parsed2.items.length,
|
|
2460
|
+
sources: [source],
|
|
2461
|
+
warnings: [],
|
|
2462
|
+
page_no: parsed2.pageNo ?? pageNo,
|
|
2463
|
+
num_of_rows: parsed2.numOfRows ?? numOfRows
|
|
2464
|
+
};
|
|
2465
|
+
};
|
|
2466
|
+
const first = await fetchPage(1);
|
|
2467
|
+
if (first.api_error) return first;
|
|
2468
|
+
const totalCount = first.total_count;
|
|
2469
|
+
const totalPages = Math.max(1, Math.ceil(totalCount / Math.max(1, first.num_of_rows)));
|
|
2470
|
+
const restPages = Array.from({ length: Math.max(0, totalPages - 1) }, (_, index) => index + 2);
|
|
2471
|
+
const rest = await mapWithConcurrency3(restPages, MOLIT_PAGE_CONCURRENCY, fetchPage);
|
|
2472
|
+
const apiError = rest.find((page) => page.api_error);
|
|
2473
|
+
if (apiError) return apiError;
|
|
2474
|
+
const pages = [first, ...rest];
|
|
2475
|
+
return {
|
|
2476
|
+
rows: pages.flatMap((page) => page.rows),
|
|
2477
|
+
total_count: totalCount,
|
|
2478
|
+
sources: pages.flatMap((page) => page.sources),
|
|
2479
|
+
warnings: pages.flatMap((page) => page.warnings)
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
function buildResult(input, tradeType, provider, cacheHit) {
|
|
2483
|
+
const normalizedRows = provider.rows.map((row) => normalizeTradeRow(row, tradeType)).filter((item) => item !== null);
|
|
2484
|
+
const ownershipExcludedCount = tradeType === "resale" && normalizeResaleOwnership(input.resale_ownership) === "presale_only" ? normalizedRows.filter((item) => isOccupancyRightResale(item)).length : 0;
|
|
2485
|
+
const normalizedAll = tradeType === "resale" && normalizeResaleOwnership(input.resale_ownership) === "presale_only" ? normalizedRows.filter((item) => !isOccupancyRightResale(item)) : normalizedRows;
|
|
2486
|
+
const warnings = [
|
|
2487
|
+
...provider.warnings,
|
|
2488
|
+
...ownershipExcludedCount > 0 ? [`MOLIT resale returned ${ownershipExcludedCount} occupancy-right rows excluded by resale_ownership=presale_only; use resale_ownership='all' to include them.`] : []
|
|
2489
|
+
];
|
|
2490
|
+
if (provider.total_count === 0 || normalizedAll.length === 0) {
|
|
2491
|
+
return errorResult4(input, tradeType, "NOT_FOUND", "[NOT_FOUND] \uD574\uB2F9 \uC9C0\uC5ED\xB7\uAE30\uAC04 \uC2E4\uAC70\uB798 \uC5C6\uC74C. \uC2DC\uC138 \uCD94\uC815\xB7\uC0DD\uC131 \uAE08\uC9C0", provider.sources, warnings, cacheHit);
|
|
2492
|
+
}
|
|
2493
|
+
const filterApplied = normalizeFilter(input.complex_filter);
|
|
2494
|
+
const matchResults = filterApplied ? normalizedAll.map((item) => ({ item, match: matchComplexFilter(item, filterApplied) })) : normalizedAll.map((item) => ({ item, match: { matched: true, viaNameFallback: false } }));
|
|
2495
|
+
const filteredItems = matchResults.filter(({ match }) => match.matched).map(({ item }) => item);
|
|
2496
|
+
const usedNameFallback = matchResults.some(({ match }) => match.matched && match.viaNameFallback);
|
|
2497
|
+
const limit = normalizeLimit2(input.limit);
|
|
2498
|
+
const items = filteredItems.slice(0, limit);
|
|
2499
|
+
const summaryItems = tradeType === "resale" ? filteredItems.filter((item) => !isOccupancyRightResale(item)) : filteredItems;
|
|
2500
|
+
let filterNote = "\uBC95\uC815\uB3D9+\uC9C0\uBC88 best-effort \uB9E4\uCE6D. \uB2E4\uC9C0\uBC88 \uB300\uB2E8\uC9C0\xB7\uD45C\uAE30\uCC28\uB85C \uB204\uB77D \uAC00\uB2A5";
|
|
2501
|
+
if (usedNameFallback) {
|
|
2502
|
+
filterNote += " [name fallback] MOLIT \uBE14\uB85D\uD615 \uC9C0\uBC88 \uD589\uC744 \uB2E8\uC9C0\uBA85 \uC5C4\uACA9 \uB9E4\uCE6D\uC73C\uB85C \uD3EC\uD568";
|
|
2503
|
+
}
|
|
2504
|
+
if (filterApplied && filteredItems.length === 0 && normalizedAll.length > 0) {
|
|
2505
|
+
filterNote += " [PARTIAL] \uC9C0\uC815 \uB2E8\uC9C0 \uBBF8\uBC1C\uACAC \u2014 \uC2DC\uC138 \uC0DD\uC131 \uAE08\uC9C0";
|
|
2506
|
+
}
|
|
2507
|
+
const omittedCount = Math.max(0, filteredItems.length - items.length);
|
|
2508
|
+
return {
|
|
2509
|
+
trade_type: tradeType,
|
|
2510
|
+
region_code: input.region_code ?? "",
|
|
2511
|
+
year_month: input.year_month,
|
|
2512
|
+
filter_applied: filterApplied,
|
|
2513
|
+
total_count: provider.total_count,
|
|
2514
|
+
filtered_count: filteredItems.length,
|
|
2515
|
+
items,
|
|
2516
|
+
summary: summarize(summaryItems, tradeType),
|
|
2517
|
+
...tradeType === "resale" ? { summary_by_ownership: summarizeResaleByOwnership(filteredItems) } : {},
|
|
2518
|
+
metadata: {
|
|
2519
|
+
cache_hit: cacheHit,
|
|
2520
|
+
collected_at: nowIso(),
|
|
2521
|
+
data_source: ENDPOINTS[tradeType].dataSource,
|
|
2522
|
+
sources: provider.sources,
|
|
2523
|
+
...warnings.length ? { warnings } : {},
|
|
2524
|
+
area_note: "\uBA74\uC801\xB7\uD3C9\uB2F9\uAC00\uB294 \uC804\uC6A9\uBA74\uC801 \uAE30\uC900",
|
|
2525
|
+
filter_note: filterNote,
|
|
2526
|
+
returned_count: items.length,
|
|
2527
|
+
omitted_count: omittedCount,
|
|
2528
|
+
truncated: omittedCount > 0,
|
|
2529
|
+
source_count: provider.sources.length
|
|
2530
|
+
}
|
|
2531
|
+
};
|
|
2532
|
+
}
|
|
2533
|
+
function normalizeTradeRow(row, tradeType) {
|
|
2534
|
+
if (row.cdealType === "O") return null;
|
|
2535
|
+
const aptName = text2(row.aptNm);
|
|
2536
|
+
const dong = text2(row.umdNm);
|
|
2537
|
+
const jibun = text2(row.jibun);
|
|
2538
|
+
const area = numberValue(row.excluUseAr);
|
|
2539
|
+
const tradeDate = buildTradeDate(row.dealYear, row.dealMonth, row.dealDay);
|
|
2540
|
+
if (!aptName || !dong || !jibun || area === void 0 || !tradeDate) return null;
|
|
2541
|
+
const base = {
|
|
2542
|
+
apt_name: aptName,
|
|
2543
|
+
dong,
|
|
2544
|
+
jibun,
|
|
2545
|
+
area_sqm: area,
|
|
2546
|
+
floor: numberValue(row.floor) ?? null,
|
|
2547
|
+
trade_date: tradeDate
|
|
2548
|
+
};
|
|
2549
|
+
if (tradeType === "rent") {
|
|
2550
|
+
const deposit = numberValue(row.deposit);
|
|
2551
|
+
const monthlyRent = numberValue(row.monthlyRent);
|
|
2552
|
+
if (deposit === void 0 || monthlyRent === void 0) return null;
|
|
2553
|
+
return {
|
|
2554
|
+
...base,
|
|
2555
|
+
...text2(row.aptSeq) ? { apt_seq: text2(row.aptSeq) } : {},
|
|
2556
|
+
deposit_10k: deposit,
|
|
2557
|
+
monthly_rent_10k: monthlyRent,
|
|
2558
|
+
lease_type: monthlyRent === 0 ? "\uC804\uC138" : "\uC6D4\uC138",
|
|
2559
|
+
build_year: numberValue(row.buildYear) ?? null,
|
|
2560
|
+
...text2(row.contractType) ? { contract_type: text2(row.contractType) } : {}
|
|
2561
|
+
};
|
|
2562
|
+
}
|
|
2563
|
+
const price = numberValue(row.dealAmount);
|
|
2564
|
+
if (price === void 0) return null;
|
|
2565
|
+
const pyeongPrice = area > 0 ? Math.round(price / (area / PYEONG_PER_SQM2)) : null;
|
|
2566
|
+
const supplyEst = area > 0 ? Math.round(price / (area / DEFAULT_SUPPLY_RATIO / PYEONG_PER_SQM2)) : null;
|
|
2567
|
+
const priceFields = {
|
|
2568
|
+
price_10k: price,
|
|
2569
|
+
pyeong_price: pyeongPrice,
|
|
2570
|
+
pyeong_price_supply_est: supplyEst,
|
|
2571
|
+
supply_ratio_assumed: DEFAULT_SUPPLY_RATIO,
|
|
2572
|
+
area_basis: "\uC804\uC6A9\uBA74\uC801"
|
|
2573
|
+
};
|
|
2574
|
+
if (tradeType === "resale") {
|
|
2575
|
+
return {
|
|
2576
|
+
...base,
|
|
2577
|
+
...priceFields,
|
|
2578
|
+
...text2(row.ownershipGbn) ? { ownership_gbn: text2(row.ownershipGbn) } : {},
|
|
2579
|
+
...text2(row.dealingGbn) ? { deal_type: text2(row.dealingGbn) } : {},
|
|
2580
|
+
...text2(row.slerGbn) ? { seller: text2(row.slerGbn) } : {},
|
|
2581
|
+
...text2(row.buyerGbn) ? { buyer: text2(row.buyerGbn) } : {}
|
|
2582
|
+
};
|
|
2583
|
+
}
|
|
2584
|
+
return {
|
|
2585
|
+
...base,
|
|
2586
|
+
...priceFields,
|
|
2587
|
+
build_year: numberValue(row.buildYear) ?? null,
|
|
2588
|
+
...text2(row.dealingGbn) ? { deal_type: text2(row.dealingGbn) } : {},
|
|
2589
|
+
...text2(row.slerGbn) ? { seller: text2(row.slerGbn) } : {},
|
|
2590
|
+
...text2(row.buyerGbn) ? { buyer: text2(row.buyerGbn) } : {},
|
|
2591
|
+
...text2(row.landLeaseholdGbn) ? { land_leasehold: text2(row.landLeaseholdGbn) } : {}
|
|
2592
|
+
};
|
|
2593
|
+
}
|
|
2594
|
+
function summarize(items, tradeType) {
|
|
2595
|
+
if (tradeType === "rent") {
|
|
2596
|
+
const rentItems = items;
|
|
2597
|
+
const deposits = rentItems.map((item) => item.deposit_10k);
|
|
2598
|
+
const monthly = rentItems.map((item) => item.monthly_rent_10k);
|
|
2599
|
+
return {
|
|
2600
|
+
median_deposit_10k: median(deposits),
|
|
2601
|
+
min_deposit_10k: deposits.length ? Math.min(...deposits) : null,
|
|
2602
|
+
max_deposit_10k: deposits.length ? Math.max(...deposits) : null,
|
|
2603
|
+
monthly_rent_avg_10k: monthly.length ? Math.round(monthly.reduce((a, b) => a + b, 0) / monthly.length) : null,
|
|
2604
|
+
jeonse_ratio_pct: null,
|
|
2605
|
+
sample_count: items.length
|
|
2606
|
+
};
|
|
2607
|
+
}
|
|
2608
|
+
return summarizePriceTrades(items);
|
|
2609
|
+
}
|
|
2610
|
+
function summarizePriceTrades(items) {
|
|
2611
|
+
const prices = items.map((item) => item.price_10k);
|
|
2612
|
+
const pyeongPrices = items.map((item) => item.pyeong_price).filter((value) => value !== null);
|
|
2613
|
+
return {
|
|
2614
|
+
median_price_10k: median(prices),
|
|
2615
|
+
min_price_10k: prices.length ? Math.min(...prices) : null,
|
|
2616
|
+
max_price_10k: prices.length ? Math.max(...prices) : null,
|
|
2617
|
+
median_pyeong_price: median(pyeongPrices),
|
|
2618
|
+
sample_count: items.length
|
|
2619
|
+
};
|
|
2620
|
+
}
|
|
2621
|
+
function summarizeResaleByOwnership(items) {
|
|
2622
|
+
const resaleItems = items.filter((item) => isResaleTradeItem(item));
|
|
2623
|
+
return {
|
|
2624
|
+
presale: summarizePriceTrades(resaleItems.filter((item) => item.ownership_gbn === "\uBD84")),
|
|
2625
|
+
occupancy_right: summarizePriceTrades(resaleItems.filter((item) => item.ownership_gbn === "\uC785")),
|
|
2626
|
+
other: summarizePriceTrades(resaleItems.filter((item) => item.ownership_gbn !== "\uBD84" && item.ownership_gbn !== "\uC785"))
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
function isResaleTradeItem(item) {
|
|
2630
|
+
return "price_10k" in item && !("build_year" in item);
|
|
2631
|
+
}
|
|
2632
|
+
function isOccupancyRightResale(item) {
|
|
2633
|
+
return isResaleTradeItem(item) && item.ownership_gbn === "\uC785";
|
|
2634
|
+
}
|
|
2635
|
+
function normalizeFilter(filter) {
|
|
2636
|
+
if (!filter) return null;
|
|
2637
|
+
const jibun = Array.isArray(filter.jibun) ? filter.jibun : [filter.jibun];
|
|
2638
|
+
return {
|
|
2639
|
+
umd: filter.umd,
|
|
2640
|
+
jibun,
|
|
2641
|
+
jibun_match: filter.jibun_match ?? "exact",
|
|
2642
|
+
...filter.apt_name ? { apt_name: filter.apt_name } : {}
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
function matchComplexFilter(item, filter) {
|
|
2646
|
+
if (!sameLegalDong(item.dong, filter.umd)) return { matched: false, viaNameFallback: false };
|
|
2647
|
+
const itemJibun = normalizeJibun(item.jibun);
|
|
2648
|
+
const jibunMatches = filter.jibun.some((candidate) => {
|
|
2649
|
+
const normalized = normalizeJibun(candidate);
|
|
2650
|
+
if (filter.jibun_match === "bonbun") return bonbun(itemJibun) === bonbun(normalized);
|
|
2651
|
+
return itemJibun === normalized;
|
|
2652
|
+
});
|
|
2653
|
+
if (jibunMatches) {
|
|
2654
|
+
if (filter.apt_name && !isCompatibleComplexName(item.apt_name, filter.apt_name)) return { matched: false, viaNameFallback: false };
|
|
2655
|
+
return { matched: true, viaNameFallback: false };
|
|
2656
|
+
}
|
|
2657
|
+
if (filter.apt_name && isBlockStyleJibun(itemJibun) && isStrictComplexNameMatch(item.apt_name, filter.apt_name)) {
|
|
2658
|
+
return { matched: true, viaNameFallback: true };
|
|
2659
|
+
}
|
|
2660
|
+
return { matched: false, viaNameFallback: false };
|
|
2661
|
+
}
|
|
2662
|
+
function sameLegalDong(itemDong, filterDong) {
|
|
2663
|
+
const item = normalizeLegalDong(itemDong);
|
|
2664
|
+
const filter = normalizeLegalDong(filterDong);
|
|
2665
|
+
if (!item || !filter) return false;
|
|
2666
|
+
if (item === filter) return true;
|
|
2667
|
+
return legalDongLeaf(item) === legalDongLeaf(filter);
|
|
2668
|
+
}
|
|
2669
|
+
function normalizeLegalDong(value) {
|
|
2670
|
+
return value.trim().replace(/\s+/g, " ");
|
|
2671
|
+
}
|
|
2672
|
+
function legalDongLeaf(value) {
|
|
2673
|
+
const tokens = value.split(" ").filter(Boolean);
|
|
2674
|
+
return tokens[tokens.length - 1] ?? value;
|
|
2675
|
+
}
|
|
2676
|
+
function isCompatibleComplexName(itemName, filterName) {
|
|
2677
|
+
const item = normalizeComplexName(itemName);
|
|
2678
|
+
const filter = normalizeComplexName(filterName);
|
|
2679
|
+
return item.includes(filter) || filter.includes(item);
|
|
2680
|
+
}
|
|
2681
|
+
function isStrictComplexNameMatch(itemName, filterName) {
|
|
2682
|
+
const item = normalizeComplexName(itemName);
|
|
2683
|
+
const filter = normalizeComplexName(filterName);
|
|
2684
|
+
if (item.length < 6 || filter.length < 6) return false;
|
|
2685
|
+
if (item === filter) return true;
|
|
2686
|
+
return complexNameSimilarity(item, filter, { aNormalized: true, bNormalized: true }) >= 0.96;
|
|
2687
|
+
}
|
|
2688
|
+
function isBlockStyleJibun(jibun) {
|
|
2689
|
+
return /[a-zA-Z가-힣]/.test(jibun);
|
|
2690
|
+
}
|
|
2691
|
+
function normalizeLimit2(limit) {
|
|
2692
|
+
if (limit === void 0) return 30;
|
|
2693
|
+
if (!Number.isFinite(limit)) return 30;
|
|
2694
|
+
return Math.min(100, Math.max(1, Math.floor(limit)));
|
|
2695
|
+
}
|
|
2696
|
+
function normalizeSingleTradeType(tradeType) {
|
|
2697
|
+
return tradeType === "rent" || tradeType === "resale" ? tradeType : "sale";
|
|
2698
|
+
}
|
|
2699
|
+
function normalizeResaleOwnership(value) {
|
|
2700
|
+
return value === "all" ? "all" : "presale_only";
|
|
2701
|
+
}
|
|
2702
|
+
function metadataCount(result, key) {
|
|
2703
|
+
if (!result || "error" in result) return 0;
|
|
2704
|
+
return result.metadata[key];
|
|
2705
|
+
}
|
|
2706
|
+
async function mapWithConcurrency3(items, concurrency, mapper) {
|
|
2707
|
+
const results = new Array(items.length);
|
|
2708
|
+
let next = 0;
|
|
2709
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
2710
|
+
while (next < items.length) {
|
|
2711
|
+
const current = next;
|
|
2712
|
+
next += 1;
|
|
2713
|
+
results[current] = await mapper(items[current]);
|
|
2714
|
+
}
|
|
2715
|
+
});
|
|
2716
|
+
await Promise.all(workers);
|
|
2717
|
+
return results;
|
|
2718
|
+
}
|
|
2719
|
+
function parseMolitXml(xml) {
|
|
2720
|
+
return {
|
|
2721
|
+
resultCode: firstTag(xml, "resultCode"),
|
|
2722
|
+
resultMsg: firstTag(xml, "resultMsg"),
|
|
2723
|
+
items: [...xml.matchAll(/<item>([\s\S]*?)<\/item>/g)].map((match) => parseItem(match[1] ?? "")),
|
|
2724
|
+
totalCount: numberValue(firstTag(xml, "totalCount")),
|
|
2725
|
+
pageNo: numberValue(firstTag(xml, "pageNo")),
|
|
2726
|
+
numOfRows: numberValue(firstTag(xml, "numOfRows"))
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2729
|
+
function parseItem(xml) {
|
|
2730
|
+
const row = {};
|
|
2731
|
+
for (const match of xml.matchAll(/<([A-Za-z0-9_]+)>([\s\S]*?)<\/\1>/g)) {
|
|
2732
|
+
const key = match[1];
|
|
2733
|
+
if (!key) continue;
|
|
2734
|
+
row[key] = decodeXml(match[2] ?? "").trim();
|
|
2735
|
+
}
|
|
2736
|
+
return row;
|
|
2737
|
+
}
|
|
2738
|
+
function firstTag(xml, tag) {
|
|
2739
|
+
const escaped = tag.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
2740
|
+
const match = xml.match(new RegExp(`<${escaped}>([\\s\\S]*?)<\\/${escaped}>`));
|
|
2741
|
+
return match?.[1] ? decodeXml(match[1]).trim() : void 0;
|
|
2742
|
+
}
|
|
2743
|
+
function errorResult4(input, tradeType, error, message, sources, warnings, cacheHit) {
|
|
2744
|
+
const dataSource = tradeType === "all" ? "molit_real_transactions" : ENDPOINTS[tradeType].dataSource;
|
|
2745
|
+
return {
|
|
2746
|
+
trade_type: tradeType,
|
|
2747
|
+
region_code: input.region_code ?? "",
|
|
2748
|
+
year_month: input.year_month,
|
|
2749
|
+
error,
|
|
2750
|
+
message,
|
|
2751
|
+
metadata: {
|
|
2752
|
+
cache_hit: cacheHit,
|
|
2753
|
+
collected_at: nowIso(),
|
|
2754
|
+
data_source: dataSource,
|
|
2755
|
+
sources,
|
|
2756
|
+
...warnings.length ? { warnings } : {}
|
|
2757
|
+
}
|
|
2758
|
+
};
|
|
2759
|
+
}
|
|
2760
|
+
function median(values) {
|
|
2761
|
+
if (values.length === 0) return null;
|
|
2762
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
2763
|
+
const mid = Math.floor(sorted.length / 2);
|
|
2764
|
+
if (sorted.length % 2 === 1) return sorted[mid] ?? null;
|
|
2765
|
+
const left = sorted[mid - 1];
|
|
2766
|
+
const right = sorted[mid];
|
|
2767
|
+
return left === void 0 || right === void 0 ? null : Math.round((left + right) / 2);
|
|
2768
|
+
}
|
|
2769
|
+
function buildTradeDate(year, month, day) {
|
|
2770
|
+
if (!year || !month || !day) return null;
|
|
2771
|
+
return `${year.padStart(4, "0")}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
|
|
2772
|
+
}
|
|
2773
|
+
function text2(value) {
|
|
2774
|
+
return (value ?? "").trim();
|
|
2775
|
+
}
|
|
2776
|
+
function numberValue(value) {
|
|
2777
|
+
const normalized = text2(value).replace(/,/g, "");
|
|
2778
|
+
if (!normalized) return void 0;
|
|
2779
|
+
const n = Number(normalized);
|
|
2780
|
+
return Number.isFinite(n) ? n : void 0;
|
|
2781
|
+
}
|
|
2782
|
+
function normalizeJibun(value) {
|
|
2783
|
+
return value.replace(/\s+/g, "").replace(/^산/u, "");
|
|
2784
|
+
}
|
|
2785
|
+
function bonbun(value) {
|
|
2786
|
+
return value.split("-")[0] ?? value;
|
|
2787
|
+
}
|
|
2788
|
+
function decodeXml(value) {
|
|
2789
|
+
return value.replace(/</g, "<").replace(/>/g, ">").replace(/&/g, "&").replace(/"/g, '"').replace(/'/g, "'");
|
|
2790
|
+
}
|
|
2791
|
+
|
|
2792
|
+
// src/providers/naver-staticmap.ts
|
|
2793
|
+
var ENDPOINT = "https://maps.apigw.ntruss.com/map-static/v2/raster";
|
|
2794
|
+
var STATIC_MAP_MAX_MARKERS = 20;
|
|
2795
|
+
var clamp = (n, lo, hi) => Math.max(lo, Math.min(hi, Math.round(n)));
|
|
2796
|
+
function buildStaticMapUrl(req, env) {
|
|
2797
|
+
const url = new URL(ENDPOINT);
|
|
2798
|
+
url.searchParams.set("w", String(clamp(req.width, 1, 1024)));
|
|
2799
|
+
url.searchParams.set("h", String(clamp(req.height, 1, 1024)));
|
|
2800
|
+
url.searchParams.set("maptype", req.maptype ?? "basic");
|
|
2801
|
+
if (req.scale) url.searchParams.set("scale", String(req.scale));
|
|
2802
|
+
if (req.format) url.searchParams.set("format", req.format);
|
|
2803
|
+
const markers = (req.markers ?? []).slice(0, STATIC_MAP_MAX_MARKERS);
|
|
2804
|
+
if (markers.length > 0) {
|
|
2805
|
+
for (const m of markers) {
|
|
2806
|
+
const parts = [`type:d`, `size:${m.size ?? "mid"}`];
|
|
2807
|
+
if (m.color) parts.push(`color:${m.color}`);
|
|
2808
|
+
if (m.label && m.size !== "tiny") parts.push(`label:${m.label}`);
|
|
2809
|
+
parts.push(`pos:${fmt(m.lng)} ${fmt(m.lat)}`);
|
|
2810
|
+
url.searchParams.append("markers", parts.join("|"));
|
|
2811
|
+
}
|
|
2812
|
+
} else if (req.center) {
|
|
2813
|
+
url.searchParams.set("center", `${fmt(req.center.lng)},${fmt(req.center.lat)}`);
|
|
2814
|
+
url.searchParams.set("level", String(clamp(req.level ?? 16, 0, 20)));
|
|
2815
|
+
}
|
|
2816
|
+
return url.toString();
|
|
2817
|
+
}
|
|
2818
|
+
async function fetchStaticMap(req, env, fetcher = fetch) {
|
|
2819
|
+
if (!env.NAVER_MAPS_CLIENT_ID || !env.NAVER_MAPS_CLIENT_SECRET) {
|
|
2820
|
+
return { error: "NAVER_AUTH_MISSING" };
|
|
2821
|
+
}
|
|
2822
|
+
const url = buildStaticMapUrl(req, env);
|
|
2823
|
+
const response = await fetcher(url, {
|
|
2824
|
+
headers: {
|
|
2825
|
+
"x-ncp-apigw-api-key-id": env.NAVER_MAPS_CLIENT_ID,
|
|
2826
|
+
"x-ncp-apigw-api-key": env.NAVER_MAPS_CLIENT_SECRET
|
|
2827
|
+
}
|
|
2828
|
+
});
|
|
2829
|
+
if (!response.ok) {
|
|
2830
|
+
return { error: `STATIC_MAP_API_ERROR`, status: response.status };
|
|
2831
|
+
}
|
|
2832
|
+
const mimeType = response.headers.get("content-type") ?? "image/jpeg";
|
|
2833
|
+
const buf = new Uint8Array(await response.arrayBuffer());
|
|
2834
|
+
return { base64: toBase64(buf), mimeType, bytes: buf.byteLength };
|
|
2835
|
+
}
|
|
2836
|
+
function fmt(n) {
|
|
2837
|
+
return Number(n.toFixed(7)).toString();
|
|
2838
|
+
}
|
|
2839
|
+
function toBase64(bytes) {
|
|
2840
|
+
let binary = "";
|
|
2841
|
+
const chunk = 32768;
|
|
2842
|
+
for (let i = 0; i < bytes.length; i += chunk) {
|
|
2843
|
+
binary += String.fromCharCode(...bytes.subarray(i, i + chunk));
|
|
2844
|
+
}
|
|
2845
|
+
return btoa(binary);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
// src/tools/static-map.ts
|
|
2849
|
+
function levelForRadius(radiusM) {
|
|
2850
|
+
if (radiusM <= 250) return 17;
|
|
2851
|
+
if (radiusM <= 500) return 16;
|
|
2852
|
+
if (radiusM <= 1e3) return 15;
|
|
2853
|
+
if (radiusM <= 2e3) return 14;
|
|
2854
|
+
if (radiusM <= 4e3) return 13;
|
|
2855
|
+
if (radiusM <= 8e3) return 12;
|
|
2856
|
+
return 11;
|
|
2857
|
+
}
|
|
2858
|
+
async function getStaticMap(input, env, deps = {}) {
|
|
2859
|
+
const metadata = { cache_hit: false, collected_at: nowIso(), data_source: "naver_static_map" };
|
|
2860
|
+
const warnings = [];
|
|
2861
|
+
const markers = input.markers ?? [];
|
|
2862
|
+
if (markers.length === 0 && !input.center) {
|
|
2863
|
+
return { error: "INVALID_INPUT", message: "markers \uB610\uB294 center \uC911 \uD558\uB098\uB294 \uD544\uC694\uD569\uB2C8\uB2E4.", metadata };
|
|
2864
|
+
}
|
|
2865
|
+
if (!env.NAVER_MAPS_CLIENT_ID || !env.NAVER_MAPS_CLIENT_SECRET) {
|
|
2866
|
+
return { error: "NAVER_AUTH_MISSING", message: "NAVER_MAPS_CLIENT_ID/SECRET \uD658\uACBD\uBCC0\uC218\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4.", metadata };
|
|
2867
|
+
}
|
|
2868
|
+
const maptype = input.maptype ?? "basic";
|
|
2869
|
+
const width = input.width ?? 700;
|
|
2870
|
+
const height = input.height ?? 500;
|
|
2871
|
+
let trimmed = markers;
|
|
2872
|
+
if (markers.length > STATIC_MAP_MAX_MARKERS) {
|
|
2873
|
+
trimmed = markers.slice(0, STATIC_MAP_MAX_MARKERS);
|
|
2874
|
+
warnings.push(`\uC815\uC801\uC9C0\uB3C4\uB294 \uB9C8\uCEE4 \uCD5C\uB300 ${STATIC_MAP_MAX_MARKERS}\uAC1C\uAE4C\uC9C0\uB9CC \uC9C0\uC6D0\uD569\uB2C8\uB2E4. ${markers.length}\uAC1C \uC911 \uC55E ${STATIC_MAP_MAX_MARKERS}\uAC1C\uB9CC \uD45C\uC2DC\uD569\uB2C8\uB2E4.`);
|
|
2875
|
+
}
|
|
2876
|
+
if (input.radius_m) {
|
|
2877
|
+
warnings.push("\uC815\uC801\uC9C0\uB3C4\uC5D0\uB294 \uBC18\uACBD \uC6D0\uC744 \uADF8\uB9B4 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4. \uBC18\uACBD\uC740 \uC90C \uB808\uBCA8 \uACB0\uC815\uC5D0\uB9CC \uC0AC\uC6A9\uB429\uB2C8\uB2E4.");
|
|
2878
|
+
}
|
|
2879
|
+
const useCenter = trimmed.length === 0 && input.center;
|
|
2880
|
+
const level = input.level ?? (input.radius_m ? levelForRadius(input.radius_m) : 16);
|
|
2881
|
+
const result = await fetchStaticMap(
|
|
2882
|
+
{
|
|
2883
|
+
width,
|
|
2884
|
+
height,
|
|
2885
|
+
maptype,
|
|
2886
|
+
...useCenter ? { center: input.center, level } : {},
|
|
2887
|
+
...trimmed.length > 0 ? { markers: trimmed.map((m) => ({ lat: m.lat, lng: m.lng, ...m.label ? { label: m.label } : {}, ...m.color ? { color: m.color } : {} })) } : {}
|
|
2888
|
+
},
|
|
2889
|
+
env,
|
|
2890
|
+
deps.fetcher
|
|
2891
|
+
);
|
|
2892
|
+
if ("error" in result) {
|
|
2893
|
+
return {
|
|
2894
|
+
error: result.error === "NAVER_AUTH_MISSING" ? "NAVER_AUTH_MISSING" : "STATIC_MAP_API_ERROR",
|
|
2895
|
+
message: result.error === "NAVER_AUTH_MISSING" ? "\uC778\uC99D\uD0A4\uAC00 \uC5C6\uC2B5\uB2C8\uB2E4." : `\uC815\uC801\uC9C0\uB3C4 API \uC624\uB958 (HTTP ${result.status ?? "?"})`,
|
|
2896
|
+
metadata
|
|
2897
|
+
};
|
|
2898
|
+
}
|
|
2899
|
+
return {
|
|
2900
|
+
image: { base64: result.base64, mimeType: result.mimeType },
|
|
2901
|
+
marker_count: trimmed.length,
|
|
2902
|
+
maptype,
|
|
2903
|
+
warnings,
|
|
2904
|
+
metadata
|
|
2905
|
+
};
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
// src/tool-metadata.ts
|
|
2909
|
+
var SERVICE_DISPLAY_NAME = "Presale Remote MCP(\uBD84\uC591\uAC00 \uBD84\uC11D MCP)";
|
|
2910
|
+
var readOnlyAnnotations = (title) => ({
|
|
2911
|
+
title,
|
|
2912
|
+
readOnlyHint: true,
|
|
2913
|
+
destructiveHint: false,
|
|
2914
|
+
openWorldHint: true,
|
|
2915
|
+
idempotentHint: true
|
|
2916
|
+
});
|
|
2917
|
+
var TOOL_METADATA = {
|
|
2918
|
+
get_geocode: {
|
|
2919
|
+
title: "Geocode Address",
|
|
2920
|
+
description: `${SERVICE_DISPLAY_NAME} geocodes a Korean road-name or lot-number address into latitude/longitude with Naver Geocoding, and adds Kakao legal-dong region_code when available.`,
|
|
2921
|
+
annotations: readOnlyAnnotations("Geocode Address")
|
|
2922
|
+
},
|
|
2923
|
+
search_by_nearby_category: {
|
|
2924
|
+
title: "Search Nearby Category",
|
|
2925
|
+
description: `${SERVICE_DISPLAY_NAME} searches categorized nearby POIs around coordinates with Kakao Local category search, such as schools, subway stations, and large marts. Use keyword search for apartments or officetels.`,
|
|
2926
|
+
annotations: readOnlyAnnotations("Search Nearby Category")
|
|
2927
|
+
},
|
|
2928
|
+
search_by_nearby_keyword: {
|
|
2929
|
+
title: "Search Nearby Keyword",
|
|
2930
|
+
description: `${SERVICE_DISPLAY_NAME} searches nearby places by Kakao Local keyword around coordinates. Supports apartment/officetel/library presets, category filters, dedupe, distance limit, offset pagination, and grid mode for dense or wide-radius searches that need better recall. If has_more is true, continue with next_offset.`,
|
|
2931
|
+
annotations: readOnlyAnnotations("Search Nearby Keyword")
|
|
2932
|
+
},
|
|
2933
|
+
search_presale_announcements: {
|
|
2934
|
+
title: "Search Presale Announcements",
|
|
2935
|
+
description: `${SERVICE_DISPLAY_NAME} searches ApplyHome APT presale announcements by region, address, house_name, date, move-in month, or best-effort coordinate radius. If house_name returns no results, retry via address/region/coordinate search using get_address and get_region_code hints.`,
|
|
2936
|
+
annotations: readOnlyAnnotations("Search Presale Announcements")
|
|
2937
|
+
},
|
|
2938
|
+
get_announcement_detail: {
|
|
2939
|
+
title: "Get Announcement Detail",
|
|
2940
|
+
description: `${SERVICE_DISPLAY_NAME} retrieves ApplyHome APT announcement unit-type details by house_manage_no and announcement_id, including exclusive area, supply area, pyeong, and supplied units. It does not calculate presale price or premium.`,
|
|
2941
|
+
annotations: readOnlyAnnotations("Get Announcement Detail")
|
|
2942
|
+
},
|
|
2943
|
+
get_complex_info: {
|
|
2944
|
+
title: "Get Complex Info",
|
|
2945
|
+
description: `${SERVICE_DISPLAY_NAME} retrieves K-apt apartment complex metadata regardless of unit count. Provide kapt_code, bjd_code plus complex_name, or complex_query for automatic location and K-apt code resolution. Ambiguous or unregistered complexes return candidates or NOT_FOUND without guessing.`,
|
|
2946
|
+
annotations: readOnlyAnnotations("Get Complex Info")
|
|
2947
|
+
},
|
|
2948
|
+
get_complex_trades: {
|
|
2949
|
+
title: "Get Complex Trades",
|
|
2950
|
+
description: `${SERVICE_DISPLAY_NAME} retrieves MOLIT apartment sale, resale, rent, or all three trade types by region_code and year_month, with optional complex_query or legal-dong/jibun filters. For resale, resale_ownership can include occupancy-right rows separately from presale rights. It returns NOT_FOUND or PARTIAL when trades cannot be confirmed and does not estimate prices.`,
|
|
2951
|
+
annotations: readOnlyAnnotations("Get Complex Trades")
|
|
2952
|
+
},
|
|
2953
|
+
get_address: {
|
|
2954
|
+
title: "Get Address Candidates",
|
|
2955
|
+
description: `${SERVICE_DISPLAY_NAME} converts a place or apartment name into Kakao Local address candidates with coordinates. Use this helper when only a complex name is known before geocoding, region lookup, or complex-specific calls.`,
|
|
2956
|
+
annotations: readOnlyAnnotations("Get Address Candidates")
|
|
2957
|
+
},
|
|
2958
|
+
get_region_code: {
|
|
2959
|
+
title: "Get Region Code",
|
|
2960
|
+
description: `${SERVICE_DISPLAY_NAME} resolves an address or region name into legal-dong full_code, sigungu_code, and ApplyHome sido code using Kakao address search. Use full_code for get_complex_info and sigungu_code for get_complex_trades.`,
|
|
2961
|
+
annotations: readOnlyAnnotations("Get Region Code")
|
|
2962
|
+
},
|
|
2963
|
+
get_static_map: {
|
|
2964
|
+
title: "Get Static Map",
|
|
2965
|
+
description: `${SERVICE_DISPLAY_NAME} creates a Naver Static Map image for markers, center, radius-based zoom, and map type. It returns an image directly and does not store map payloads.`,
|
|
2966
|
+
annotations: readOnlyAnnotations("Get Static Map")
|
|
2967
|
+
}
|
|
2968
|
+
};
|
|
2969
|
+
|
|
2970
|
+
// src/tool-input-schemas.ts
|
|
2971
|
+
import { z } from "zod";
|
|
2972
|
+
var emptyToUndefined = (value) => value === "" || value === null ? void 0 : value;
|
|
2973
|
+
var requiredNumber = (description) => z.preprocess(emptyToUndefined, z.coerce.number()).describe(description);
|
|
2974
|
+
var searchNearbyKeywordInputShape = {
|
|
2975
|
+
center_lat: requiredNumber("\uAC80\uC0C9 \uC911\uC2EC \uC704\uB3C4"),
|
|
2976
|
+
center_lng: requiredNumber("\uAC80\uC0C9 \uC911\uC2EC \uACBD\uB3C4"),
|
|
2977
|
+
radius_m: z.preprocess(emptyToUndefined, z.coerce.number().int().min(0).max(2e4).default(2e3)).describe("\uAC80\uC0C9 \uBC18\uACBD \uBBF8\uD130. \uCD5C\uB300 20000"),
|
|
2978
|
+
query: z.string().optional().describe("\uC9C1\uC811 \uAC80\uC0C9 \uD0A4\uC6CC\uB4DC. preset \uBBF8\uC0AC\uC6A9 \uC2DC \uD544\uC218"),
|
|
2979
|
+
preset: z.enum(["apartment", "officetel", "library"]).optional().describe("\uBBF8\uB9AC \uC815\uC758\uB41C \uAC80\uC0C9 preset"),
|
|
2980
|
+
category_filter: z.object({
|
|
2981
|
+
include_contains: z.array(z.string()).optional(),
|
|
2982
|
+
exclude_contains: z.array(z.string()).optional()
|
|
2983
|
+
}).optional().describe("\uCE74\uCE74\uC624 category_name \uAE30\uBC18 \uD3EC\uD568/\uC81C\uC678 \uD544\uD130"),
|
|
2984
|
+
deduplicate_by: z.enum(["name", "address"]).optional().describe("\uC911\uBCF5 \uC81C\uAC70 \uAE30\uC900"),
|
|
2985
|
+
grid: z.boolean().optional().describe("true\uBA74 \uBC18\uACBD\uC744 rect \uACA9\uC790(\uC57D 1.25km \uC140)\uB85C \uBD84\uD560\uD574 \uAC01 \uC140\uC744 \uAC80\uC0C9\xB7\uD569\uC9D1\uD569\uD569\uB2C8\uB2E4. \uCE74\uCE74\uC624 \uD0A4\uC6CC\uB4DC \uAC80\uC0C9\uC774 \uAC70\uB9AC\uC21C \uCD5C\uB300 ~45\uAC74\uB9CC \uC8FC\uB294 \uD55C\uACC4\uB97C \uC6B0\uD68C\uD574 \uBC00\uC9D1 \uC9C0\uC5ED(\uC608: 5km \uB0B4 \uC544\uD30C\uD2B8 \uB2E4\uC218)\uC744 \uB204\uB77D \uC5C6\uC774 \uC218\uC9D1\uD569\uB2C8\uB2E4. \uD638\uCD9C \uC218\uAC00 \uB298\uC5B4\uB098\uB2C8 \uB113\uC740 \uBC18\uACBD \uC804\uC218 \uC218\uC9D1\uC5D0\uB9CC \uC0AC\uC6A9\uD558\uC138\uC694."),
|
|
2986
|
+
limit: z.preprocess(emptyToUndefined, z.coerce.number().int().min(1).max(200).default(50)).describe("\uAC70\uB9AC\uC21C \uC815\uB82C\xB7\uC911\uBCF5\uC81C\uAC70 \uD6C4 \uBC18\uD658\uD560 \uCD5C\uB300 \uC7A5\uC18C \uC218. \uAE30\uBCF8 50, \uCD5C\uB300 200"),
|
|
2987
|
+
offset: z.preprocess(emptyToUndefined, z.coerce.number().int().min(0).default(0)).describe("\uD398\uC774\uC9C0\uB124\uC774\uC158 \uC2DC\uC791 \uC704\uCE58. has_more=true\uC774\uBA74 next_offset\uC73C\uB85C \uC774\uC5B4 \uC870\uD68C\uD569\uB2C8\uB2E4. \uAE30\uBCF8 0")
|
|
2988
|
+
};
|
|
2989
|
+
|
|
2990
|
+
// src/tool-output-schemas.ts
|
|
2991
|
+
import { z as z2 } from "zod";
|
|
2992
|
+
var nullableNumber = z2.number().nullable().optional();
|
|
2993
|
+
var optionalNumber3 = z2.number().optional();
|
|
2994
|
+
var toolMetadataSchema = z2.object({
|
|
2995
|
+
cache_hit: z2.boolean(),
|
|
2996
|
+
collected_at: z2.string(),
|
|
2997
|
+
data_source: z2.string().optional(),
|
|
2998
|
+
data_sources: z2.array(z2.string()).optional(),
|
|
2999
|
+
providers_tried: z2.array(z2.string()).optional(),
|
|
3000
|
+
sources: z2.array(z2.string()).optional(),
|
|
3001
|
+
warnings: z2.array(z2.string()).optional(),
|
|
3002
|
+
truncated: z2.boolean().optional(),
|
|
3003
|
+
fallback_suggestion: z2.string().optional(),
|
|
3004
|
+
area_note: z2.string().optional(),
|
|
3005
|
+
filter_note: z2.string().optional(),
|
|
3006
|
+
returned_count: z2.number().optional(),
|
|
3007
|
+
omitted_count: z2.number().optional(),
|
|
3008
|
+
result_limit: z2.number().optional(),
|
|
3009
|
+
limit: z2.number().optional(),
|
|
3010
|
+
offset: z2.number().optional(),
|
|
3011
|
+
has_more: z2.boolean().optional(),
|
|
3012
|
+
next_offset: z2.number().nullable().optional(),
|
|
3013
|
+
source_count: z2.number().optional()
|
|
3014
|
+
});
|
|
3015
|
+
var placeResultSchema = z2.object({
|
|
3016
|
+
name: z2.string(),
|
|
3017
|
+
lat: z2.number(),
|
|
3018
|
+
lng: z2.number(),
|
|
3019
|
+
distance_m: z2.number(),
|
|
3020
|
+
distance_label: z2.string(),
|
|
3021
|
+
address: z2.string(),
|
|
3022
|
+
road_address: z2.string().optional(),
|
|
3023
|
+
kakao_place_id: z2.string(),
|
|
3024
|
+
kakao_category_name: z2.string()
|
|
3025
|
+
});
|
|
3026
|
+
var addressCandidateSchema = placeResultSchema.extend({
|
|
3027
|
+
distance_m: z2.number().optional(),
|
|
3028
|
+
distance_label: z2.string().optional()
|
|
3029
|
+
});
|
|
3030
|
+
var keywordFilterSchema = z2.object({
|
|
3031
|
+
include_contains: z2.array(z2.string()).optional(),
|
|
3032
|
+
exclude_contains: z2.array(z2.string()).optional()
|
|
3033
|
+
});
|
|
3034
|
+
var tradeFilterSchema = z2.object({
|
|
3035
|
+
umd: z2.string(),
|
|
3036
|
+
jibun: z2.array(z2.string()),
|
|
3037
|
+
jibun_match: z2.enum(["exact", "bonbun"]),
|
|
3038
|
+
apt_name: z2.string().optional()
|
|
3039
|
+
});
|
|
3040
|
+
var tradeItemSchema = z2.object({
|
|
3041
|
+
apt_name: z2.string(),
|
|
3042
|
+
dong: z2.string(),
|
|
3043
|
+
jibun: z2.string(),
|
|
3044
|
+
area_sqm: z2.number(),
|
|
3045
|
+
floor: z2.number().nullable(),
|
|
3046
|
+
trade_date: z2.string(),
|
|
3047
|
+
price_10k: z2.number().optional(),
|
|
3048
|
+
pyeong_price: nullableNumber,
|
|
3049
|
+
pyeong_price_supply_est: nullableNumber,
|
|
3050
|
+
supply_ratio_assumed: z2.number().optional(),
|
|
3051
|
+
area_basis: z2.literal("\uC804\uC6A9\uBA74\uC801").optional(),
|
|
3052
|
+
build_year: z2.number().nullable().optional(),
|
|
3053
|
+
deal_type: z2.string().optional(),
|
|
3054
|
+
seller: z2.string().optional(),
|
|
3055
|
+
buyer: z2.string().optional(),
|
|
3056
|
+
land_leasehold: z2.string().optional(),
|
|
3057
|
+
ownership_gbn: z2.string().optional(),
|
|
3058
|
+
apt_seq: z2.string().optional(),
|
|
3059
|
+
deposit_10k: z2.number().optional(),
|
|
3060
|
+
monthly_rent_10k: z2.number().optional(),
|
|
3061
|
+
lease_type: z2.enum(["\uC804\uC138", "\uC6D4\uC138"]).optional(),
|
|
3062
|
+
contract_type: z2.string().optional()
|
|
3063
|
+
});
|
|
3064
|
+
var tradeSummarySchema = z2.object({
|
|
3065
|
+
median_price_10k: z2.number().nullable().optional(),
|
|
3066
|
+
min_price_10k: z2.number().nullable().optional(),
|
|
3067
|
+
max_price_10k: z2.number().nullable().optional(),
|
|
3068
|
+
median_pyeong_price: z2.number().nullable().optional(),
|
|
3069
|
+
median_deposit_10k: z2.number().nullable().optional(),
|
|
3070
|
+
min_deposit_10k: z2.number().nullable().optional(),
|
|
3071
|
+
max_deposit_10k: z2.number().nullable().optional(),
|
|
3072
|
+
monthly_rent_avg_10k: z2.number().nullable().optional(),
|
|
3073
|
+
jeonse_ratio_pct: z2.null().optional(),
|
|
3074
|
+
sample_count: z2.number()
|
|
3075
|
+
});
|
|
3076
|
+
var resaleOwnershipSummarySchema = z2.object({
|
|
3077
|
+
presale: tradeSummarySchema,
|
|
3078
|
+
occupancy_right: tradeSummarySchema,
|
|
3079
|
+
other: tradeSummarySchema
|
|
3080
|
+
});
|
|
3081
|
+
var complexTradesNestedErrorSchema = z2.object({
|
|
3082
|
+
trade_type: z2.enum(["sale", "resale", "rent", "all"]),
|
|
3083
|
+
region_code: z2.string(),
|
|
3084
|
+
year_month: z2.string(),
|
|
3085
|
+
error: z2.enum(["AUTH_MISSING", "NOT_FOUND", "API_ERROR", "PARSE_ERROR", "INVALID_INPUT"]),
|
|
3086
|
+
message: z2.string(),
|
|
3087
|
+
metadata: toolMetadataSchema.extend({
|
|
3088
|
+
data_source: z2.string(),
|
|
3089
|
+
sources: z2.array(z2.string())
|
|
3090
|
+
})
|
|
3091
|
+
});
|
|
3092
|
+
var complexTradesSingleSuccessSchema = z2.object({
|
|
3093
|
+
trade_type: z2.enum(["sale", "resale", "rent"]),
|
|
3094
|
+
region_code: z2.string(),
|
|
3095
|
+
year_month: z2.string(),
|
|
3096
|
+
filter_applied: tradeFilterSchema.nullable(),
|
|
3097
|
+
total_count: z2.number(),
|
|
3098
|
+
filtered_count: z2.number(),
|
|
3099
|
+
items: z2.array(tradeItemSchema),
|
|
3100
|
+
summary: tradeSummarySchema,
|
|
3101
|
+
summary_by_ownership: resaleOwnershipSummarySchema.optional(),
|
|
3102
|
+
metadata: toolMetadataSchema.extend({
|
|
3103
|
+
data_source: z2.string(),
|
|
3104
|
+
sources: z2.array(z2.string()),
|
|
3105
|
+
area_note: z2.string(),
|
|
3106
|
+
filter_note: z2.string(),
|
|
3107
|
+
returned_count: z2.number(),
|
|
3108
|
+
omitted_count: z2.number(),
|
|
3109
|
+
truncated: z2.boolean(),
|
|
3110
|
+
source_count: z2.number()
|
|
3111
|
+
})
|
|
3112
|
+
});
|
|
3113
|
+
var complexBasicSchema = z2.object({
|
|
3114
|
+
build_year: z2.number().nullable(),
|
|
3115
|
+
use_approval_date: z2.string().nullable(),
|
|
3116
|
+
total_units: z2.number().nullable(),
|
|
3117
|
+
dong_count: z2.number().nullable(),
|
|
3118
|
+
constructor: z2.string().nullable(),
|
|
3119
|
+
developer: z2.string().nullable(),
|
|
3120
|
+
sale_type: z2.string().nullable(),
|
|
3121
|
+
heat_type: z2.string().nullable(),
|
|
3122
|
+
mgmt_type: z2.string().nullable(),
|
|
3123
|
+
hall_type: z2.string().nullable(),
|
|
3124
|
+
complex_category: z2.string().nullable(),
|
|
3125
|
+
top_floor: z2.number().nullable(),
|
|
3126
|
+
base_floor: z2.number().nullable(),
|
|
3127
|
+
total_floor_area: z2.number().nullable(),
|
|
3128
|
+
private_area_sum: z2.number().nullable(),
|
|
3129
|
+
ho_count: z2.number().nullable(),
|
|
3130
|
+
unit_mix: z2.object({
|
|
3131
|
+
under_60: z2.number().nullable(),
|
|
3132
|
+
_60_85: z2.number().nullable(),
|
|
3133
|
+
_85_135: z2.number().nullable(),
|
|
3134
|
+
over_135: z2.number().nullable()
|
|
3135
|
+
}),
|
|
3136
|
+
address_jibun: z2.string().nullable(),
|
|
3137
|
+
address_road: z2.string().nullable(),
|
|
3138
|
+
bjd_code: z2.string().nullable(),
|
|
3139
|
+
office_tel: z2.string().nullable(),
|
|
3140
|
+
homepage: z2.string().nullable()
|
|
3141
|
+
});
|
|
3142
|
+
var complexDetailSchema = z2.object({
|
|
3143
|
+
parking_ground: z2.number().nullable(),
|
|
3144
|
+
parking_underground: z2.number().nullable(),
|
|
3145
|
+
elevator_count: z2.number().nullable(),
|
|
3146
|
+
cctv_count: z2.number().nullable(),
|
|
3147
|
+
subway_line: z2.string().nullable(),
|
|
3148
|
+
subway_station: z2.string().nullable(),
|
|
3149
|
+
subway_walk_min: z2.string().nullable(),
|
|
3150
|
+
bus_walk_min: z2.string().nullable(),
|
|
3151
|
+
welfare_facility: z2.string().nullable(),
|
|
3152
|
+
convenient_facility: z2.string().nullable(),
|
|
3153
|
+
education_facility: z2.string().nullable(),
|
|
3154
|
+
ev_charger_ground: z2.number().nullable(),
|
|
3155
|
+
ev_charger_underground: z2.number().nullable(),
|
|
3156
|
+
structure: z2.string().nullable(),
|
|
3157
|
+
water_supply: z2.string().nullable(),
|
|
3158
|
+
home_network: z2.string().nullable(),
|
|
3159
|
+
use_yn: z2.string().nullable()
|
|
3160
|
+
});
|
|
3161
|
+
var getGeocodeOutputShape = {
|
|
3162
|
+
address: z2.string(),
|
|
3163
|
+
lat: z2.number(),
|
|
3164
|
+
lng: z2.number(),
|
|
3165
|
+
normalized_address: z2.string(),
|
|
3166
|
+
address_type: z2.enum(["road", "jibun", "approximate"]),
|
|
3167
|
+
matched_provider: z2.literal("naver"),
|
|
3168
|
+
road_address: z2.string().optional(),
|
|
3169
|
+
region: z2.object({
|
|
3170
|
+
sido: z2.string().optional(),
|
|
3171
|
+
sigungu: z2.string().optional(),
|
|
3172
|
+
eupmyeondong: z2.string().optional(),
|
|
3173
|
+
jibun: z2.string().optional()
|
|
3174
|
+
}),
|
|
3175
|
+
region_code: z2.string().optional(),
|
|
3176
|
+
confidence: z2.enum(["exact", "partial"]),
|
|
3177
|
+
metadata: toolMetadataSchema
|
|
3178
|
+
};
|
|
3179
|
+
var getRegionCodeOutputShape = {
|
|
3180
|
+
query: z2.string(),
|
|
3181
|
+
full_code: z2.string(),
|
|
3182
|
+
sigungu_code: z2.string(),
|
|
3183
|
+
sido_code: z2.string(),
|
|
3184
|
+
applyhome_code: z2.string().optional(),
|
|
3185
|
+
region_name: z2.string(),
|
|
3186
|
+
address_name: z2.string().optional(),
|
|
3187
|
+
lat: optionalNumber3,
|
|
3188
|
+
lng: optionalNumber3,
|
|
3189
|
+
resolved_via: z2.enum(["address", "keyword_fallback"]).optional(),
|
|
3190
|
+
metadata: toolMetadataSchema
|
|
3191
|
+
};
|
|
3192
|
+
var searchNearbyCategoryOutputShape = {
|
|
3193
|
+
center: z2.object({
|
|
3194
|
+
lat: z2.number(),
|
|
3195
|
+
lng: z2.number()
|
|
3196
|
+
}),
|
|
3197
|
+
radius_m: z2.number(),
|
|
3198
|
+
results: z2.record(z2.string(), z2.array(placeResultSchema)),
|
|
3199
|
+
summary: z2.object({
|
|
3200
|
+
total: z2.number(),
|
|
3201
|
+
by_category: z2.record(z2.string(), z2.number())
|
|
3202
|
+
}),
|
|
3203
|
+
metadata: toolMetadataSchema
|
|
3204
|
+
};
|
|
3205
|
+
var searchNearbyKeywordOutputShape = {
|
|
3206
|
+
center: z2.object({
|
|
3207
|
+
lat: z2.number(),
|
|
3208
|
+
lng: z2.number()
|
|
3209
|
+
}),
|
|
3210
|
+
radius_m: z2.number(),
|
|
3211
|
+
query: z2.string(),
|
|
3212
|
+
filter_applied: keywordFilterSchema.nullable(),
|
|
3213
|
+
deduplicate_by: z2.enum(["name", "address"]).nullable(),
|
|
3214
|
+
results: z2.array(placeResultSchema),
|
|
3215
|
+
summary: z2.object({
|
|
3216
|
+
total_found: z2.number(),
|
|
3217
|
+
after_filter: z2.number(),
|
|
3218
|
+
after_dedupe: z2.number()
|
|
3219
|
+
}),
|
|
3220
|
+
metadata: toolMetadataSchema
|
|
3221
|
+
};
|
|
3222
|
+
var getAddressOutputShape = {
|
|
3223
|
+
query: z2.string(),
|
|
3224
|
+
results: z2.array(addressCandidateSchema),
|
|
3225
|
+
summary: z2.object({
|
|
3226
|
+
total_found: z2.number()
|
|
3227
|
+
}),
|
|
3228
|
+
metadata: toolMetadataSchema
|
|
3229
|
+
};
|
|
3230
|
+
var getComplexInfoOutputShape = {
|
|
3231
|
+
kapt_code: z2.string(),
|
|
3232
|
+
name: z2.string(),
|
|
3233
|
+
resolved_from: z2.enum(["kapt_code", "bjd_code+name", "complex_query"]),
|
|
3234
|
+
match: z2.enum(["exact", "approximate"]).optional(),
|
|
3235
|
+
resolved_location: z2.object({
|
|
3236
|
+
address: z2.string(),
|
|
3237
|
+
umd: z2.string().optional(),
|
|
3238
|
+
jibun: z2.string().optional(),
|
|
3239
|
+
full_code: z2.string(),
|
|
3240
|
+
sigungu_code: z2.string(),
|
|
3241
|
+
kakao_place_name: z2.string(),
|
|
3242
|
+
warnings: z2.array(z2.string())
|
|
3243
|
+
}).optional(),
|
|
3244
|
+
basic: complexBasicSchema,
|
|
3245
|
+
detail: complexDetailSchema.nullable(),
|
|
3246
|
+
derived: z2.object({
|
|
3247
|
+
parking_per_unit: z2.number().optional()
|
|
3248
|
+
}),
|
|
3249
|
+
metadata: toolMetadataSchema.extend({
|
|
3250
|
+
data_source: z2.literal("kapt_basis_info_v4"),
|
|
3251
|
+
sources: z2.array(z2.string()),
|
|
3252
|
+
note: z2.string()
|
|
3253
|
+
})
|
|
3254
|
+
};
|
|
3255
|
+
var getComplexTradesOutputSchema = z2.object({
|
|
3256
|
+
trade_type: z2.enum(["sale", "resale", "rent", "all"]),
|
|
3257
|
+
region_code: z2.string(),
|
|
3258
|
+
year_month: z2.string(),
|
|
3259
|
+
filter_applied: tradeFilterSchema.nullable(),
|
|
3260
|
+
total_count: z2.number().optional(),
|
|
3261
|
+
filtered_count: z2.number().optional(),
|
|
3262
|
+
items: z2.array(tradeItemSchema).optional(),
|
|
3263
|
+
summary: tradeSummarySchema.optional(),
|
|
3264
|
+
summary_by_ownership: resaleOwnershipSummarySchema.optional(),
|
|
3265
|
+
results: z2.object({
|
|
3266
|
+
sale: z2.union([complexTradesSingleSuccessSchema, complexTradesNestedErrorSchema]),
|
|
3267
|
+
rent: z2.union([complexTradesSingleSuccessSchema, complexTradesNestedErrorSchema]),
|
|
3268
|
+
resale: z2.union([complexTradesSingleSuccessSchema, complexTradesNestedErrorSchema])
|
|
3269
|
+
}).optional(),
|
|
3270
|
+
primary_type: z2.enum(["sale", "resale", "rent"]).nullable().optional(),
|
|
3271
|
+
metadata: toolMetadataSchema.extend({
|
|
3272
|
+
sources: z2.array(z2.string()),
|
|
3273
|
+
returned_count: z2.number(),
|
|
3274
|
+
omitted_count: z2.number(),
|
|
3275
|
+
truncated: z2.boolean(),
|
|
3276
|
+
source_count: z2.number()
|
|
3277
|
+
})
|
|
3278
|
+
});
|
|
3279
|
+
var getStaticMapOutputShape = {
|
|
3280
|
+
marker_count: z2.number(),
|
|
3281
|
+
maptype: z2.enum(["basic", "satellite", "satellite_base", "terrain", "traffic"]),
|
|
3282
|
+
warnings: z2.array(z2.string())
|
|
3283
|
+
};
|
|
3284
|
+
var TOOL_OUTPUT_SCHEMAS = {
|
|
3285
|
+
get_geocode: getGeocodeOutputShape,
|
|
3286
|
+
search_by_nearby_category: searchNearbyCategoryOutputShape,
|
|
3287
|
+
search_by_nearby_keyword: searchNearbyKeywordOutputShape,
|
|
3288
|
+
get_address: getAddressOutputShape,
|
|
3289
|
+
get_region_code: getRegionCodeOutputShape,
|
|
3290
|
+
get_complex_info: getComplexInfoOutputShape,
|
|
3291
|
+
get_complex_trades: getComplexTradesOutputSchema,
|
|
3292
|
+
get_static_map: getStaticMapOutputShape
|
|
3293
|
+
};
|
|
3294
|
+
|
|
3295
|
+
// src/tool-result.ts
|
|
3296
|
+
function isStructuredContent(value) {
|
|
3297
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
3298
|
+
}
|
|
3299
|
+
function jsonToolResult(value, isError = false) {
|
|
3300
|
+
const text3 = JSON.stringify(value) ?? "null";
|
|
3301
|
+
return {
|
|
3302
|
+
content: [{ type: "text", text: text3 }],
|
|
3303
|
+
...!isError && isStructuredContent(value) ? { structuredContent: value } : {},
|
|
3304
|
+
...isError ? { isError: true } : {}
|
|
3305
|
+
};
|
|
3306
|
+
}
|
|
3307
|
+
function imageToolResult(base64, mimeType, summary) {
|
|
3308
|
+
const summaryText = JSON.stringify(summary) ?? "null";
|
|
3309
|
+
return {
|
|
3310
|
+
content: [
|
|
3311
|
+
{ type: "image", data: base64, mimeType },
|
|
3312
|
+
{ type: "text", text: summaryText }
|
|
3313
|
+
],
|
|
3314
|
+
...isStructuredContent(summary) ? { structuredContent: summary } : {}
|
|
3315
|
+
};
|
|
3316
|
+
}
|
|
3317
|
+
|
|
3318
|
+
// src/index.ts
|
|
3319
|
+
function readEnv() {
|
|
3320
|
+
const env = {};
|
|
3321
|
+
if (process.env.KAKAO_REST_API_KEY) env.KAKAO_REST_API_KEY = process.env.KAKAO_REST_API_KEY;
|
|
3322
|
+
if (process.env.NAVER_MAPS_CLIENT_ID) env.NAVER_MAPS_CLIENT_ID = process.env.NAVER_MAPS_CLIENT_ID;
|
|
3323
|
+
if (process.env.NAVER_MAPS_CLIENT_SECRET) env.NAVER_MAPS_CLIENT_SECRET = process.env.NAVER_MAPS_CLIENT_SECRET;
|
|
3324
|
+
if (process.env.DATA_GO_KR_SERVICE_KEY) env.DATA_GO_KR_SERVICE_KEY = process.env.DATA_GO_KR_SERVICE_KEY;
|
|
3325
|
+
if (!env.KAKAO_REST_API_KEY) console.error("[warn] KAKAO_REST_API_KEY not set \u2014 Kakao tools will fail.");
|
|
3326
|
+
if (!env.NAVER_MAPS_CLIENT_ID || !env.NAVER_MAPS_CLIENT_SECRET) {
|
|
3327
|
+
console.error("[warn] NAVER_MAPS_CLIENT_ID/SECRET not set \u2014 get_geocode (required) and get_static_map will fail.");
|
|
3328
|
+
}
|
|
3329
|
+
if (!env.DATA_GO_KR_SERVICE_KEY) {
|
|
3330
|
+
console.error("[warn] DATA_GO_KR_SERVICE_KEY not set \u2014 presale/complex-info/complex-trades tools will fail.");
|
|
3331
|
+
}
|
|
3332
|
+
return env;
|
|
3333
|
+
}
|
|
3334
|
+
function createServer(env) {
|
|
3335
|
+
const cache = createCache();
|
|
3336
|
+
const server = new McpServer({
|
|
3337
|
+
name: "presale-mcp",
|
|
3338
|
+
version: "0.1.0"
|
|
3339
|
+
});
|
|
3340
|
+
const registerTool = (name, config, handler) => {
|
|
3341
|
+
server.registerTool(name, config, async (input, extra) => handler(input, extra));
|
|
3342
|
+
};
|
|
3343
|
+
registerTool(
|
|
3344
|
+
"get_geocode",
|
|
3345
|
+
{
|
|
3346
|
+
...TOOL_METADATA.get_geocode,
|
|
3347
|
+
inputSchema: {
|
|
3348
|
+
address: z3.string().min(1).describe("\uB3C4\uB85C\uBA85 \uB610\uB294 \uC9C0\uBC88 \uC8FC\uC18C. \uC608: \uCDA9\uB0A8 \uCC9C\uC548\uC2DC \uC11C\uBD81\uAD6C \uC640\uCD0C\uB3D9 59-6")
|
|
3349
|
+
},
|
|
3350
|
+
outputSchema: TOOL_OUTPUT_SCHEMAS.get_geocode
|
|
3351
|
+
},
|
|
3352
|
+
async (input) => {
|
|
3353
|
+
const result = await getGeocode(input, env, fetch, { cache });
|
|
3354
|
+
return jsonToolResult(result, "error" in result);
|
|
3355
|
+
}
|
|
3356
|
+
);
|
|
3357
|
+
registerTool(
|
|
3358
|
+
"search_by_nearby_category",
|
|
3359
|
+
{
|
|
3360
|
+
...TOOL_METADATA.search_by_nearby_category,
|
|
3361
|
+
inputSchema: {
|
|
3362
|
+
center_lat: z3.number().describe("\uAC80\uC0C9 \uC911\uC2EC \uC704\uB3C4"),
|
|
3363
|
+
center_lng: z3.number().describe("\uAC80\uC0C9 \uC911\uC2EC \uACBD\uB3C4"),
|
|
3364
|
+
radius_m: z3.number().int().min(0).max(2e4).default(2e3).describe("\uAC80\uC0C9 \uBC18\uACBD \uBBF8\uD130. \uCD5C\uB300 20000"),
|
|
3365
|
+
categories: z3.array(z3.string()).optional().describe("\uCE74\uD14C\uACE0\uB9AC ID \uB610\uB294 \uBCC4\uCE6D \uBC30\uC5F4. \uC608: school_elementary, school, subway, mart_large"),
|
|
3366
|
+
subcategory_filter: z3.object({ category_name_contains: z3.string().optional() }).optional().describe("\uCE74\uCE74\uC624 category_name \uCD94\uAC00 \uD3EC\uD568 \uD544\uD130")
|
|
3367
|
+
},
|
|
3368
|
+
outputSchema: TOOL_OUTPUT_SCHEMAS.search_by_nearby_category
|
|
3369
|
+
},
|
|
3370
|
+
async (input) => jsonToolResult(await searchByNearbyCategory(input, env, fetch, { cache }))
|
|
3371
|
+
);
|
|
3372
|
+
registerTool(
|
|
3373
|
+
"search_by_nearby_keyword",
|
|
3374
|
+
{
|
|
3375
|
+
...TOOL_METADATA.search_by_nearby_keyword,
|
|
3376
|
+
inputSchema: searchNearbyKeywordInputShape,
|
|
3377
|
+
outputSchema: TOOL_OUTPUT_SCHEMAS.search_by_nearby_keyword
|
|
3378
|
+
},
|
|
3379
|
+
async (input) => jsonToolResult(await searchByNearbyKeyword(input, env, fetch, { cache }))
|
|
3380
|
+
);
|
|
3381
|
+
registerTool(
|
|
3382
|
+
"search_presale_announcements",
|
|
3383
|
+
{
|
|
3384
|
+
...TOOL_METADATA.search_presale_announcements,
|
|
3385
|
+
inputSchema: {
|
|
3386
|
+
center_lat: z3.number().optional().describe("\uAC80\uC0C9 \uC911\uC2EC \uC704\uB3C4. ApplyHome\uC740 \uBC18\uACBD \uAC80\uC0C9\uC744 \uC9C1\uC811 \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uC73C\uBBC0\uB85C \uBC95\uC815\uB3D9 \uC0D8\uD50C\uB9C1 \uD6C4 \uAC70\uB9AC \uC7AC\uD544\uD130\uB9C1\uD558\uB294 best-effort \uBC29\uC2DD\uC785\uB2C8\uB2E4."),
|
|
3387
|
+
center_lng: z3.number().optional().describe("\uAC80\uC0C9 \uC911\uC2EC \uACBD\uB3C4. ApplyHome\uC740 \uBC18\uACBD \uAC80\uC0C9\uC744 \uC9C1\uC811 \uC9C0\uC6D0\uD558\uC9C0 \uC54A\uC73C\uBBC0\uB85C \uBC95\uC815\uB3D9 \uC0D8\uD50C\uB9C1 \uD6C4 \uAC70\uB9AC \uC7AC\uD544\uD130\uB9C1\uD558\uB294 best-effort \uBC29\uC2DD\uC785\uB2C8\uB2E4."),
|
|
3388
|
+
radius_km: z3.number().int().min(1).max(20).default(5).describe("\uAC80\uC0C9 \uBC18\uACBD km. \uAE30\uBCF8 5km"),
|
|
3389
|
+
months_back: z3.number().int().min(1).max(180).default(24).describe("\uCD5C\uADFC N\uAC1C\uC6D4 \uACF5\uACE0. \uAE30\uBCF8 24\uAC1C\uC6D4, \uCD5C\uB300 180\uAC1C\uC6D4. 2018\uB144 \uC774\uD6C4 \uC785\uC8FC \uAC80\uC99D\uC740 date_from \uB610\uB294 \uCDA9\uBD84\uD788 \uD070 months_back\uC744 \uC0AC\uC6A9\uD558\uC138\uC694."),
|
|
3390
|
+
year: z3.number().int().min(2021).max(2100).optional().describe("\uD2B9\uC815 \uACF5\uACE0 \uC5F0\uB3C4. \uC608: 2025. date_from/date_to\uBCF4\uB2E4 \uC6B0\uC120\uD569\uB2C8\uB2E4."),
|
|
3391
|
+
date_from: z3.string().optional().describe("\uACF5\uACE0\uC77C \uC2DC\uC791\uC77C YYYY-MM-DD"),
|
|
3392
|
+
date_to: z3.string().optional().describe("\uACF5\uACE0\uC77C \uC885\uB8CC\uC77C YYYY-MM-DD"),
|
|
3393
|
+
move_in_from: z3.string().optional().describe("\uC785\uC8FC\uC608\uC815\uC6D4 \uC2DC\uC791 YYYY-MM \uB610\uB294 YYYYMM. ApplyHome \uACB0\uACFC \uD6C4\uCC98\uB9AC \uD544\uD130\uC785\uB2C8\uB2E4."),
|
|
3394
|
+
move_in_to: z3.string().optional().describe("\uC785\uC8FC\uC608\uC815\uC6D4 \uC885\uB8CC YYYY-MM \uB610\uB294 YYYYMM. ApplyHome \uACB0\uACFC \uD6C4\uCC98\uB9AC \uD544\uD130\uC785\uB2C8\uB2E4."),
|
|
3395
|
+
region_codes: z3.array(z3.string()).optional().describe("ApplyHome SUBSCRPT_AREA_CODE \uC2DC\uB3C4 \uCF54\uB4DC \uBC30\uC5F4. \uC608: 300, 312"),
|
|
3396
|
+
status_filter: z3.array(z3.string()).optional().describe("\uC0C1\uD0DC \uD544\uD130. \uC608: \uBD84\uC591\uC608\uC815, \uBD84\uC591\uC911, \uCCAD\uC57D\uC644\uB8CC, \uACC4\uC57D\uC9C4\uD589, \uBD84\uC591\uC644\uB8CC"),
|
|
3397
|
+
house_name: z3.string().optional().describe("\uC8FC\uD0DD\uBA85 LIKE \uAC80\uC0C9\uC5B4. \uD45C\uAE30 \uCC28\uC774\uB85C 0\uAC74\uC774\uBA74 \uC751\uB2F5\uC758 next \uD78C\uD2B8\uC5D0 \uB530\uB77C get_address/get_region_code \uD6C4 \uC9C0\uC5ED/\uBC18\uACBD \uAC80\uC0C9\uC73C\uB85C \uC7AC\uC870\uD68C\uD558\uC138\uC694."),
|
|
3398
|
+
apply_address: z3.string().optional().describe("\uACF5\uAE09\uC704\uCE58 HSSPLY_ADRES LIKE \uAC80\uC0C9\uC5B4. \uC608: \uB0A8\uC591\uC8FC\uC2DC, \uACE0\uC591\uC2DC \uC77C\uC0B0\uB3D9\uAD6C"),
|
|
3399
|
+
limit: z3.number().int().min(1).max(100).default(30).describe("\uC751\uB2F5\uC5D0 \uBC18\uD658\uD560 \uBD84\uC591\uACF5\uACE0 \uC218. \uC694\uC57D\uC740 \uC804\uCCB4 \uD544\uD130 \uACB0\uACFC \uAE30\uC900\uC73C\uB85C \uACC4\uC0B0\uD558\uACE0 announcements\uB9CC \uC81C\uD55C\uD569\uB2C8\uB2E4. \uAE30\uBCF8 30, \uCD5C\uB300 100")
|
|
3400
|
+
}
|
|
3401
|
+
},
|
|
3402
|
+
async (input) => jsonToolResult(await searchPresaleAnnouncements(input, env, { cache }))
|
|
3403
|
+
);
|
|
3404
|
+
registerTool(
|
|
3405
|
+
"get_announcement_detail",
|
|
3406
|
+
{
|
|
3407
|
+
...TOOL_METADATA.get_announcement_detail,
|
|
3408
|
+
inputSchema: {
|
|
3409
|
+
house_manage_no: z3.string().min(1).describe("\uC8FC\uD0DD\uAD00\uB9AC\uBC88\uD638 (HOUSE_MANAGE_NO)"),
|
|
3410
|
+
announcement_id: z3.string().min(1).describe("\uACF5\uACE0\uBC88\uD638 (PBLANC_NO)"),
|
|
3411
|
+
type: z3.enum(["apt", "urbty", "remndr", "opt", "pbl_pvt_rent"]).default("apt").describe("\uACF5\uAE09\uC720\uD615. \uAE30\uBCF8\uAC12\uC740 apt(\uACF5\uB3D9\uC8FC\uD0DD)\uC774\uBA70, \uB300\uBD80\uBD84\uC758 APT \uBD84\uC591\uACF5\uACE0\uB294 apt\uB97C \uC0AC\uC6A9\uD569\uB2C8\uB2E4.")
|
|
3412
|
+
}
|
|
3413
|
+
},
|
|
3414
|
+
async (input) => {
|
|
3415
|
+
const result = await getAnnouncementDetail(input, env, { cache });
|
|
3416
|
+
return jsonToolResult(result, "error" in result);
|
|
3417
|
+
}
|
|
3418
|
+
);
|
|
3419
|
+
registerTool(
|
|
3420
|
+
"get_complex_info",
|
|
3421
|
+
{
|
|
3422
|
+
...TOOL_METADATA.get_complex_info,
|
|
3423
|
+
inputSchema: {
|
|
3424
|
+
kapt_code: z3.string().optional().describe("\uB2E8\uC9C0\uCF54\uB4DC. \uC54C\uBA74 \uC774\uAC83\uB9CC\uC73C\uB85C \uBC14\uB85C \uC870\uD68C(\uD574\uC18C \uB2E8\uACC4 \uC0DD\uB7B5)."),
|
|
3425
|
+
bjd_code: z3.string().optional().describe("\uBC95\uC815\uB3D9\uCF54\uB4DC 10\uC790\uB9AC. kapt_code\uAC00 \uC5C6\uC744 \uB54C complex_name\uACFC \uD568\uAED8 \uB2E8\uC9C0\uCF54\uB4DC \uD574\uC18C\uC5D0 \uC0AC\uC6A9. get_geocode\uC758 region_code\uC5D0\uC11C \uC5BB\uC744 \uC218 \uC788\uC74C."),
|
|
3426
|
+
complex_name: z3.string().optional().describe("\uB2E8\uC9C0\uBA85. kapt_code\uAC00 \uC5C6\uC744 \uB54C bjd_code\uC640 \uD568\uAED8 \uB9E4\uCE6D."),
|
|
3427
|
+
complex_query: z3.string().optional().describe("\uB2E8\uC9C0\uBA85 \uB610\uB294 \uC8FC\uC18C \uC790\uC720\uD615 \uC785\uB825. \uC774\uAC83\uB9CC \uC8FC\uBA74 \uB0B4\uBD80\uC5D0\uC11C \uCE74\uCE74\uC624 \uAC80\uC0C9\u2192\uC8FC\uC18C\u2192\uBC95\uC815\uB3D9\uCF54\uB4DC\uB97C \uC790\uB3D9 \uD574\uC18C\uD55C \uB4A4 K-apt \uB2E8\uC9C0\uB97C \uB9E4\uCE6D\uD569\uB2C8\uB2E4(\uAC00\uC7A5 \uAC04\uD3B8\uD55C \uC785\uAD6C). kapt_code/bjd_code\uAC00 \uC788\uC73C\uBA74 \uADF8\uCABD\uC774 \uC6B0\uC120."),
|
|
3428
|
+
detail: z3.boolean().default(false).describe("true\uBA74 \uC0C1\uC138\uC815\uBCF4(\uC8FC\uCC28\xB7\uC2B9\uAC15\uAE30\xB7CCTV\xB7\uAD50\uD1B5\xB7\uC2DC\uC124 \uB4F1)\uAE4C\uC9C0 \uC870\uD68C\xB7\uBCD1\uD569\uD558\uACE0 parking_per_unit\uC744 \uD30C\uC0DD.")
|
|
3429
|
+
},
|
|
3430
|
+
outputSchema: TOOL_OUTPUT_SCHEMAS.get_complex_info
|
|
3431
|
+
},
|
|
3432
|
+
async (input) => {
|
|
3433
|
+
const result = await getComplexInfo(input, env, { cache });
|
|
3434
|
+
return jsonToolResult(result, "error" in result);
|
|
3435
|
+
}
|
|
3436
|
+
);
|
|
3437
|
+
registerTool(
|
|
3438
|
+
"get_complex_trades",
|
|
3439
|
+
{
|
|
3440
|
+
...TOOL_METADATA.get_complex_trades,
|
|
3441
|
+
inputSchema: {
|
|
3442
|
+
region_code: z3.string().regex(/^\d{5}$/).optional().describe("\uC2DC\uAD70\uAD6C\uCF54\uB4DC 5\uC790\uB9AC(LAWD_CD). get_region_code\uC758 sigungu_code \uC0AC\uC6A9. \uC608: 11110. complex_query\uB97C \uC8FC\uBA74 \uC0DD\uB7B5 \uAC00\uB2A5."),
|
|
3443
|
+
year_month: z3.string().regex(/^\d{6}$/).describe("\uACC4\uC57D\uB144\uC6D4 YYYYMM. \uC608: 202407"),
|
|
3444
|
+
trade_type: z3.enum(["sale", "resale", "rent", "all"]).default("sale").describe("\uAC70\uB798\uC720\uD615: sale=\uB9E4\uB9E4, resale=\uBD84\uC591\uAD8C\uC804\uB9E4, rent=\uC804\uC6D4\uC138, all=\uC138 \uC720\uD615 \uBCD1\uB82C \uC870\uD68C"),
|
|
3445
|
+
resale_ownership: z3.enum(["presale_only", "all"]).default("presale_only").describe("\uBD84\uC591\uAD8C\uC804\uB9E4(resale) \uACB0\uACFC\uC758 \uC18C\uC720\uAD8C \uAD6C\uBD84. presale_only=\uBD84\uC591\uAD8C/\uACF5\uB780\uB9CC(\uAE30\uBCF8), all=\uC785\uC8FC\uAD8C \uD3EC\uD568. \uC785\uC8FC\uAD8C\uC740 \uC7AC\uAC74\uCD95 \uC870\uD569\uC6D0 \uBB3C\uB7C9\uC73C\uB85C \uC77C\uBC18\uBD84\uC591\uAC00\uC640 \uC131\uACA9\uC774 \uB2E4\uB985\uB2C8\uB2E4."),
|
|
3446
|
+
complex_query: z3.string().optional().describe("\uB2E8\uC9C0\uBA85 \uB610\uB294 \uC8FC\uC18C \uC790\uC720\uD615 \uC785\uB825. \uC8FC\uBA74 \uCE74\uCE74\uC624 \uAC80\uC0C9\u2192\uC8FC\uC18C\u2192\uC2DC\uAD70\uAD6C\uCF54\uB4DC+\uBC95\uC815\uB3D9+\uC9C0\uBC88\uC744 \uC790\uB3D9 \uD574\uC18C\uD574 \uD574\uB2F9 \uB2E8\uC9C0 \uAC70\uB798\uB9CC \uD544\uD130\uB9C1\uD569\uB2C8\uB2E4(region_code/complex_filter \uC0DD\uB7B5 \uAC00\uB2A5)."),
|
|
3447
|
+
complex_filter: z3.object({
|
|
3448
|
+
umd: z3.string().min(1).describe("\uBC95\uC815\uB3D9\uBA85. \uC608: \uC22D\uC778\uB3D9"),
|
|
3449
|
+
jibun: z3.union([z3.string(), z3.array(z3.string())]).describe("\uC9C0\uBC88 1\uAC1C \uB610\uB294 \uC5EC\uB7EC \uAC1C. \uC608: 202-3"),
|
|
3450
|
+
jibun_match: z3.enum(["exact", "bonbun"]).default("exact").describe("exact=\uC804\uCCB4 \uC9C0\uBC88 \uC77C\uCE58, bonbun=\uBCF8\uBC88\uB9CC \uC77C\uCE58"),
|
|
3451
|
+
apt_name: z3.string().optional().describe("\uC120\uD0DD \uBCF4\uC870 \uD544\uD130\uC6A9 \uB2E8\uC9C0\uBA85")
|
|
3452
|
+
}).optional().describe("\uD2B9\uC815 \uB2E8\uC9C0 \uD544\uD130. \uC0DD\uB7B5\uD558\uBA74 \uC2DC\uAD70\uAD6C \uC804\uCCB4 \uAC70\uB798 \uBC18\uD658"),
|
|
3453
|
+
num_of_rows: z3.number().int().min(1).max(1e3).default(1e3).describe("\uD398\uC774\uC9C0\uB2F9 \uC870\uD68C \uAC74\uC218. \uAE30\uBCF8 1000"),
|
|
3454
|
+
limit: z3.number().int().min(1).max(100).default(30).describe("\uC751\uB2F5\uC5D0 \uBC18\uD658\uD560 \uAC70\uB798 \uD589 \uC218. \uC694\uC57D\uC740 \uC804\uCCB4 \uD544\uD130 \uACB0\uACFC \uAE30\uC900\uC73C\uB85C \uACC4\uC0B0\uD558\uACE0 items\uB9CC \uC81C\uD55C\uD569\uB2C8\uB2E4. \uAE30\uBCF8 30, \uCD5C\uB300 100")
|
|
3455
|
+
},
|
|
3456
|
+
outputSchema: TOOL_OUTPUT_SCHEMAS.get_complex_trades
|
|
3457
|
+
},
|
|
3458
|
+
async (input) => {
|
|
3459
|
+
const result = await getComplexTrades(input, env, { cache });
|
|
3460
|
+
return jsonToolResult(result, "error" in result);
|
|
3461
|
+
}
|
|
3462
|
+
);
|
|
3463
|
+
registerTool(
|
|
3464
|
+
"get_address",
|
|
3465
|
+
{
|
|
3466
|
+
...TOOL_METADATA.get_address,
|
|
3467
|
+
inputSchema: {
|
|
3468
|
+
query: z3.string().min(1).describe("\uC7A5\uC18C\uBA85 \uB610\uB294 \uB2E8\uC9C0\uBA85. \uC608: \uB798\uBBF8\uC548\uC6D0\uBCA0\uC77C\uB9AC"),
|
|
3469
|
+
size: z3.number().int().min(1).max(15).default(5).describe("\uBC18\uD658 \uD6C4\uBCF4 \uC218. \uAE30\uBCF8 5, \uCD5C\uB300 15"),
|
|
3470
|
+
center_lat: z3.number().optional().describe("\uC815\uB82C \uAE30\uC900 \uC911\uC2EC \uC704\uB3C4. center_lng\uC640 \uD568\uAED8 \uC8FC\uBA74 \uAC70\uB9AC\uC21C \uC815\uB82C\uD569\uB2C8\uB2E4."),
|
|
3471
|
+
center_lng: z3.number().optional().describe("\uC815\uB82C \uAE30\uC900 \uC911\uC2EC \uACBD\uB3C4. center_lat\uC640 \uD568\uAED8 \uC8FC\uBA74 \uAC70\uB9AC\uC21C \uC815\uB82C\uD569\uB2C8\uB2E4.")
|
|
3472
|
+
},
|
|
3473
|
+
outputSchema: TOOL_OUTPUT_SCHEMAS.get_address
|
|
3474
|
+
},
|
|
3475
|
+
async (input) => jsonToolResult(await getAddress(input, env, fetch, { cache }))
|
|
3476
|
+
);
|
|
3477
|
+
registerTool(
|
|
3478
|
+
"get_region_code",
|
|
3479
|
+
{
|
|
3480
|
+
...TOOL_METADATA.get_region_code,
|
|
3481
|
+
inputSchema: {
|
|
3482
|
+
query: z3.string().min(1).describe("\uC8FC\uC18C \uB610\uB294 \uC9C0\uC5ED\uBA85. \uC608: \uCDA9\uB0A8 \uCC9C\uC548\uC2DC \uC11C\uBD81\uAD6C \uC640\uCD0C\uB3D9 59-6")
|
|
3483
|
+
},
|
|
3484
|
+
outputSchema: TOOL_OUTPUT_SCHEMAS.get_region_code
|
|
3485
|
+
},
|
|
3486
|
+
async (input) => {
|
|
3487
|
+
const result = await getRegionCode(input, env, fetch, { cache });
|
|
3488
|
+
return jsonToolResult(result, "error" in result);
|
|
3489
|
+
}
|
|
3490
|
+
);
|
|
3491
|
+
registerTool(
|
|
3492
|
+
"get_static_map",
|
|
3493
|
+
{
|
|
3494
|
+
...TOOL_METADATA.get_static_map,
|
|
3495
|
+
inputSchema: {
|
|
3496
|
+
markers: z3.array(
|
|
3497
|
+
z3.object({
|
|
3498
|
+
lat: z3.number().describe("\uC704\uB3C4"),
|
|
3499
|
+
lng: z3.number().describe("\uACBD\uB3C4"),
|
|
3500
|
+
label: z3.string().optional().describe("\uB9C8\uCEE4 \uB77C\uBCA8 A-Z \uB610\uB294 0-9"),
|
|
3501
|
+
color: z3.string().optional().describe("\uB9C8\uCEE4 \uC0C9. Red/Blue/Orange/Yellow/Green/Purple/Gray \uB610\uB294 0xRRGGBB"),
|
|
3502
|
+
info: z3.string().optional().describe("\uCC38\uACE0\uC6A9 \uC124\uBA85(\uC815\uC801\uC9C0\uB3C4\uC5D0\uB294 \uB80C\uB354\uB9C1\uB418\uC9C0 \uC54A\uC74C)")
|
|
3503
|
+
})
|
|
3504
|
+
).optional().describe("\uC9C0\uB3C4\uC5D0 \uD45C\uC2DC\uD560 \uB9C8\uCEE4 \uBC30\uC5F4(\uCD5C\uB300 20). \uC8FC\uBA74 \uB124\uC774\uBC84\uAC00 \uC790\uB3D9\uC73C\uB85C \uC601\uC5ED\uC744 \uB9DE\uCDA5\uB2C8\uB2E4."),
|
|
3505
|
+
center: z3.object({ lat: z3.number(), lng: z3.number() }).optional().describe("\uC911\uC2EC \uC88C\uD45C. markers\uAC00 \uC5C6\uC744 \uB54C \uC0AC\uC6A9."),
|
|
3506
|
+
radius_m: z3.number().int().min(0).optional().describe("\uBC18\uACBD(m). \uC90C \uB808\uBCA8 \uACB0\uC815\uC5D0\uB9CC \uC0AC\uC6A9(\uC6D0\uC740 \uADF8\uB824\uC9C0\uC9C0 \uC54A\uC74C)."),
|
|
3507
|
+
maptype: z3.enum(["basic", "satellite", "satellite_base", "terrain", "traffic"]).optional().describe("\uC9C0\uB3C4\uC720\uD615. \uAE30\uBCF8 basic."),
|
|
3508
|
+
width: z3.number().int().min(1).max(1024).optional().describe("\uC774\uBBF8\uC9C0 \uB108\uBE44 px(\uCD5C\uB300 1024, \uAE30\uBCF8 700)."),
|
|
3509
|
+
height: z3.number().int().min(1).max(1024).optional().describe("\uC774\uBBF8\uC9C0 \uB192\uC774 px(\uCD5C\uB300 1024, \uAE30\uBCF8 500)."),
|
|
3510
|
+
level: z3.number().int().min(0).max(20).optional().describe("\uC90C \uB808\uBCA8 0-20. \uC9C0\uC815 \uC2DC radius_m\uBCF4\uB2E4 \uC6B0\uC120.")
|
|
3511
|
+
},
|
|
3512
|
+
outputSchema: TOOL_OUTPUT_SCHEMAS.get_static_map
|
|
3513
|
+
},
|
|
3514
|
+
async (input) => {
|
|
3515
|
+
const result = await getStaticMap(input, env);
|
|
3516
|
+
if ("error" in result) return jsonToolResult(result, true);
|
|
3517
|
+
return imageToolResult(result.image.base64, result.image.mimeType, {
|
|
3518
|
+
marker_count: result.marker_count,
|
|
3519
|
+
maptype: result.maptype,
|
|
3520
|
+
warnings: result.warnings
|
|
3521
|
+
});
|
|
3522
|
+
}
|
|
3523
|
+
);
|
|
3524
|
+
return server;
|
|
3525
|
+
}
|
|
3526
|
+
async function main() {
|
|
3527
|
+
const env = readEnv();
|
|
3528
|
+
const server = createServer(env);
|
|
3529
|
+
const transport = new StdioServerTransport();
|
|
3530
|
+
await server.connect(transport);
|
|
3531
|
+
}
|
|
3532
|
+
main().catch((e) => {
|
|
3533
|
+
console.error(e);
|
|
3534
|
+
process.exit(1);
|
|
3535
|
+
});
|