@nomadamas/k-skill 0.4.3 → 0.6.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/package.json +1 -1
- package/skills/animal-pharmacy-search/instruction.md +150 -0
- package/skills/animal-pharmacy-search/scripts/animal_pharmacy_mcp.py +276 -0
- package/skills/animal-pharmacy-search/skill.json +8 -0
- package/skills/consumer-price-safety-search/instruction.md +30 -0
- package/skills/consumer-price-safety-search/skill.json +6 -0
- package/skills/government-support-survey/instruction.md +52 -0
- package/skills/government-support-survey/references/NOTICE.md +30 -0
- package/skills/government-support-survey/scripts/run_survey.py +71 -0
- package/skills/government-support-survey/skill.json +10 -0
- package/skills/kamis-food-price/instruction.md +66 -0
- package/skills/kamis-food-price/scripts/run_kamis.py +110 -0
- package/skills/kamis-food-price/skill.json +9 -0
- package/skills/korean-cinema-search/instruction.md +7 -1
- package/skills/mofa-travel-safety/instruction.md +62 -0
- package/skills/mofa-travel-safety/scripts/run_mofa_travel_safety.py +57 -0
- package/skills/mofa-travel-safety/skill.json +9 -0
- package/skills/real-estate-search/instruction.md +22 -2
- package/skills/real-estate-search/scripts/nationwide_daily_report.py +545 -0
- package/skills/seoul-weather-risk/instruction.md +4 -1
- package/skills/seoul-weather-risk/scripts/seoul_weather_risk.py +99 -3
- package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
- package/templates/browser.md +1 -1
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Deterministic nationwide RTMS CSV report for the real-estate-search skill."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import csv
|
|
8
|
+
import http.cookiejar
|
|
9
|
+
import io
|
|
10
|
+
import json
|
|
11
|
+
import statistics
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
import urllib.parse
|
|
15
|
+
import urllib.request
|
|
16
|
+
from calendar import monthrange
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import date, datetime, timedelta
|
|
19
|
+
from typing import Dict, Iterable, List, Optional, Sequence, Tuple
|
|
20
|
+
from urllib.error import HTTPError, URLError
|
|
21
|
+
from zoneinfo import ZoneInfo
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
BASE_URL = "https://rt.molit.go.kr"
|
|
25
|
+
LANDING_URL = f"{BASE_URL}/pt/xls/xls.do"
|
|
26
|
+
SIDO_URL = f"{BASE_URL}/data/sido.do"
|
|
27
|
+
SGG_URL = f"{BASE_URL}/data/sgg.do"
|
|
28
|
+
COUNT_URL = f"{BASE_URL}/pt/xls/ptXlsDownDataCheck.do"
|
|
29
|
+
CSV_URL = f"{BASE_URL}/pt/xls/ptXlsCSVDown.do"
|
|
30
|
+
USER_AGENT = "k-skill-real-estate-search/1"
|
|
31
|
+
JSON_LIMIT = 2 * 1024 * 1024
|
|
32
|
+
CSV_LIMIT = 64 * 1024 * 1024
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ReportError(RuntimeError):
|
|
36
|
+
def __init__(self, code: str, message: str) -> None:
|
|
37
|
+
super().__init__(f"{code}: {message}")
|
|
38
|
+
self.code = code
|
|
39
|
+
self.message = message
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Region:
|
|
44
|
+
province: str
|
|
45
|
+
name: str
|
|
46
|
+
lawd_cd: str
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def full_name(self) -> str:
|
|
50
|
+
return self.province if self.province == self.name else f"{self.province} {self.name}"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class Combo:
|
|
55
|
+
asset_code: str
|
|
56
|
+
deal_code: str
|
|
57
|
+
asset: str
|
|
58
|
+
deal: str
|
|
59
|
+
short: str
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def key(self) -> str:
|
|
63
|
+
return f"{self.asset_code}{self.deal_code}"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
COMBINATIONS: Tuple[Combo, ...] = (
|
|
67
|
+
Combo("A", "1", "아파트", "매매", "아매"),
|
|
68
|
+
Combo("A", "2", "아파트", "전월세", "아임"),
|
|
69
|
+
Combo("D", "1", "오피스텔", "매매", "오매"),
|
|
70
|
+
Combo("D", "2", "오피스텔", "전월세", "오임"),
|
|
71
|
+
Combo("B", "1", "연립다세대", "매매", "연매"),
|
|
72
|
+
Combo("B", "2", "연립다세대", "전월세", "연임"),
|
|
73
|
+
Combo("C", "1", "단독·다가구", "매매", "단매"),
|
|
74
|
+
Combo("C", "2", "단독·다가구", "전월세", "단임"),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class ParsedData:
|
|
80
|
+
region_counts: Dict[str, int] = field(default_factory=dict)
|
|
81
|
+
prices: List[int] = field(default_factory=list)
|
|
82
|
+
deposits: List[int] = field(default_factory=list)
|
|
83
|
+
monthly_rents: List[int] = field(default_factory=list)
|
|
84
|
+
cancelled: int = 0
|
|
85
|
+
latest_month: Optional[str] = None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class ComboResult:
|
|
90
|
+
combo: Combo
|
|
91
|
+
status: str
|
|
92
|
+
from_date: date
|
|
93
|
+
to_date: date
|
|
94
|
+
announced: int
|
|
95
|
+
error: Optional[str] = None
|
|
96
|
+
region_counts: Dict[str, int] = field(default_factory=dict)
|
|
97
|
+
prices: List[int] = field(default_factory=list)
|
|
98
|
+
deposits: List[int] = field(default_factory=list)
|
|
99
|
+
monthly_rents: List[int] = field(default_factory=list)
|
|
100
|
+
cancelled: int = 0
|
|
101
|
+
latest_month: Optional[str] = None
|
|
102
|
+
seconds: float = 0.0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def month_windows(as_of: date) -> List[Tuple[date, date]]:
|
|
106
|
+
current_start = as_of.replace(day=1)
|
|
107
|
+
previous_end = current_start - timedelta(days=1)
|
|
108
|
+
previous_start = previous_end.replace(day=1)
|
|
109
|
+
return [(current_start, as_of), (previous_start, previous_end)]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _read_limited(response: object, limit: int) -> bytes:
|
|
113
|
+
headers = getattr(response, "headers", None)
|
|
114
|
+
content_length = headers.get("Content-Length") if headers else None
|
|
115
|
+
if content_length:
|
|
116
|
+
try:
|
|
117
|
+
if int(content_length) > limit:
|
|
118
|
+
raise ReportError("response_too_large", f"응답이 {limit:,}바이트 제한을 초과했습니다.")
|
|
119
|
+
except ValueError as exc:
|
|
120
|
+
raise ReportError("malformed_response", "Content-Length가 숫자가 아닙니다.") from exc
|
|
121
|
+
body = response.read(limit + 1) # type: ignore[attr-defined]
|
|
122
|
+
if len(body) > limit:
|
|
123
|
+
raise ReportError("response_too_large", f"응답이 {limit:,}바이트 제한을 초과했습니다.")
|
|
124
|
+
return body
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _request_bytes(
|
|
128
|
+
opener: urllib.request.OpenerDirector,
|
|
129
|
+
url: str,
|
|
130
|
+
payload: Optional[Dict[str, str]] = None,
|
|
131
|
+
timeout: int = 30,
|
|
132
|
+
limit: int = JSON_LIMIT,
|
|
133
|
+
) -> bytes:
|
|
134
|
+
data = urllib.parse.urlencode(payload).encode("ascii") if payload is not None else None
|
|
135
|
+
request = urllib.request.Request(
|
|
136
|
+
url,
|
|
137
|
+
data=data,
|
|
138
|
+
headers={"User-Agent": USER_AGENT, "Referer": LANDING_URL},
|
|
139
|
+
method="POST" if data is not None else "GET",
|
|
140
|
+
)
|
|
141
|
+
try:
|
|
142
|
+
with opener.open(request, timeout=timeout) as response:
|
|
143
|
+
return _read_limited(response, limit)
|
|
144
|
+
except HTTPError as exc:
|
|
145
|
+
status = exc.code
|
|
146
|
+
exc.close()
|
|
147
|
+
raise ReportError("http_error", f"공식 RTMS가 HTTP {status}를 반환했습니다.") from exc
|
|
148
|
+
except (URLError, TimeoutError) as exc:
|
|
149
|
+
raise ReportError("network_error", "공식 RTMS에 연결하지 못했습니다.") from exc
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _json_object(raw: bytes) -> Dict[str, object]:
|
|
153
|
+
try:
|
|
154
|
+
value = json.loads(raw.decode("utf-8"))
|
|
155
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
156
|
+
raise ReportError("malformed_response", "공식 RTMS JSON 응답을 해석할 수 없습니다.") from exc
|
|
157
|
+
if not isinstance(value, dict):
|
|
158
|
+
raise ReportError("malformed_response", "공식 RTMS JSON 응답이 객체가 아닙니다.")
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _json_list(raw: bytes) -> List[object]:
|
|
163
|
+
try:
|
|
164
|
+
value = json.loads(raw.decode("utf-8"))
|
|
165
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
166
|
+
raise ReportError("malformed_response", "공식 RTMS JSON 응답을 해석할 수 없습니다.") from exc
|
|
167
|
+
if not isinstance(value, list):
|
|
168
|
+
raise ReportError("malformed_response", "공식 RTMS JSON 응답이 배열이 아닙니다.")
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _code_name(item: object, name_key: str, kind: str) -> Tuple[str, str]:
|
|
173
|
+
if not isinstance(item, dict):
|
|
174
|
+
raise ReportError("region_contract", f"{kind} 항목이 객체가 아닙니다.")
|
|
175
|
+
code = str(item.get("signguCode", "")).strip()
|
|
176
|
+
name = str(item.get(name_key, "")).strip()
|
|
177
|
+
if len(code) != 5 or not code.isdigit() or not name:
|
|
178
|
+
raise ReportError("region_contract", f"{kind} 코드 또는 이름이 올바르지 않습니다.")
|
|
179
|
+
return code, name
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def fetch_regions(opener: urllib.request.OpenerDirector) -> List[Region]:
|
|
183
|
+
_request_bytes(opener, LANDING_URL, timeout=30, limit=JSON_LIMIT)
|
|
184
|
+
sido_list = _json_list(_request_bytes(opener, SIDO_URL, {}, limit=JSON_LIMIT))
|
|
185
|
+
if not 1 <= len(sido_list) <= 30:
|
|
186
|
+
raise ReportError("region_contract", "시·도 목록 크기가 허용 범위를 벗어났습니다.")
|
|
187
|
+
|
|
188
|
+
regions: List[Region] = []
|
|
189
|
+
for raw_province in sido_list:
|
|
190
|
+
province_code, province = _code_name(raw_province, "ctprvnNm", "시·도")
|
|
191
|
+
sgg_list = _json_list(
|
|
192
|
+
_request_bytes(opener, SGG_URL, {"signguCode": province_code[:2]}, limit=JSON_LIMIT)
|
|
193
|
+
)
|
|
194
|
+
if not 1 <= len(sgg_list) <= 100:
|
|
195
|
+
raise ReportError("region_contract", f"{province} 시·군·구 목록 크기가 허용 범위를 벗어났습니다.")
|
|
196
|
+
for raw_region in sgg_list:
|
|
197
|
+
lawd_cd, name = _code_name(raw_region, "signguNm", "시·군·구")
|
|
198
|
+
regions.append(Region(province, name, lawd_cd))
|
|
199
|
+
|
|
200
|
+
if not 1 <= len(regions) <= 500:
|
|
201
|
+
raise ReportError("region_contract", "전국 시·군·구 목록 크기가 허용 범위를 벗어났습니다.")
|
|
202
|
+
if len({region.lawd_cd for region in regions}) != len(regions):
|
|
203
|
+
raise ReportError("region_contract", "중복 법정동 코드가 있습니다.")
|
|
204
|
+
if len({region.full_name for region in regions}) != len(regions):
|
|
205
|
+
raise ReportError("region_contract", "중복 시·군·구 이름이 있습니다.")
|
|
206
|
+
return regions
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _query_form(combo: Combo, from_date: date, to_date: date) -> Dict[str, str]:
|
|
210
|
+
return {
|
|
211
|
+
"srhThingNo": combo.asset_code,
|
|
212
|
+
"srhDelngSecd": combo.deal_code,
|
|
213
|
+
"srhAddrGbn": "1",
|
|
214
|
+
"srhLfstsSecd": "1",
|
|
215
|
+
"sidoNm": "전체",
|
|
216
|
+
"sggNm": "전체",
|
|
217
|
+
"emdNm": "전체",
|
|
218
|
+
"loadNm": "전체",
|
|
219
|
+
"areaNm": "전체",
|
|
220
|
+
"hsmpNm": "전체",
|
|
221
|
+
"mobileAt": "",
|
|
222
|
+
"srhFromDt": from_date.isoformat(),
|
|
223
|
+
"srhToDt": to_date.isoformat(),
|
|
224
|
+
"srhNewRonSecd": "",
|
|
225
|
+
"srhSidoCd": "",
|
|
226
|
+
"srhSggCd": "",
|
|
227
|
+
"srhEmdCd": "",
|
|
228
|
+
"srhLoadCd": "",
|
|
229
|
+
"srhHsmpCd": "",
|
|
230
|
+
"srhRoadNm": "",
|
|
231
|
+
"srhArea": "",
|
|
232
|
+
"srhLrArea": "",
|
|
233
|
+
"srhFromAmount": "",
|
|
234
|
+
"srhToAmount": "",
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def fetch_count(opener: urllib.request.OpenerDirector, form: Dict[str, str]) -> int:
|
|
239
|
+
payload = _json_object(_request_bytes(opener, COUNT_URL, form, timeout=60, limit=JSON_LIMIT))
|
|
240
|
+
value = payload.get("cnt")
|
|
241
|
+
try:
|
|
242
|
+
count = int(str(value))
|
|
243
|
+
except (TypeError, ValueError) as exc:
|
|
244
|
+
raise ReportError("count_contract", "RTMS 건수가 정수가 아닙니다.") from exc
|
|
245
|
+
if not 0 <= count <= 1_000_000:
|
|
246
|
+
raise ReportError("count_contract", "RTMS 건수가 허용 범위를 벗어났습니다.")
|
|
247
|
+
return count
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def fetch_csv(opener: urllib.request.OpenerDirector, form: Dict[str, str]) -> bytes:
|
|
251
|
+
return _request_bytes(opener, CSV_URL, form, timeout=180, limit=CSV_LIMIT)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _amount(value: str, field_name: str) -> int:
|
|
255
|
+
normalized = value.replace(",", "").replace(" ", "").strip()
|
|
256
|
+
if not normalized or not normalized.isdigit():
|
|
257
|
+
raise ReportError("csv_contract", f"{field_name} 값이 정수가 아닙니다.")
|
|
258
|
+
return int(normalized)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _region_code(location: str, lookup: Dict[str, str], max_words: int) -> str:
|
|
262
|
+
words = location.split()
|
|
263
|
+
for size in range(min(max_words, len(words)), 0, -1):
|
|
264
|
+
code = lookup.get(" ".join(words[:size]))
|
|
265
|
+
if code:
|
|
266
|
+
return code
|
|
267
|
+
raise ReportError("unmapped_region", "CSV 시군구를 공식 지역 목록에 매핑하지 못했습니다.")
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def parse_csv(raw: bytes, announced: int, combo: Combo, regions: Sequence[Region]) -> ParsedData:
|
|
271
|
+
try:
|
|
272
|
+
text = raw.decode("cp949")
|
|
273
|
+
except UnicodeDecodeError as exc:
|
|
274
|
+
raise ReportError("csv_contract", "RTMS CSV가 CP949 형식이 아닙니다.") from exc
|
|
275
|
+
|
|
276
|
+
reader = csv.reader(io.StringIO(text, newline=""))
|
|
277
|
+
header: Optional[List[str]] = None
|
|
278
|
+
data = ParsedData()
|
|
279
|
+
lookup = {region.full_name: region.lawd_cd for region in regions}
|
|
280
|
+
max_words = max(len(name.split()) for name in lookup)
|
|
281
|
+
observed = 0
|
|
282
|
+
|
|
283
|
+
for raw_row in reader:
|
|
284
|
+
if header is None:
|
|
285
|
+
if raw_row and raw_row[0].lstrip("\ufeff").strip() == "NO":
|
|
286
|
+
header = [cell.strip() for cell in raw_row]
|
|
287
|
+
header[0] = header[0].lstrip("\ufeff")
|
|
288
|
+
continue
|
|
289
|
+
if not raw_row or not any(cell.strip() for cell in raw_row):
|
|
290
|
+
continue
|
|
291
|
+
if len(raw_row) != len(header):
|
|
292
|
+
raise ReportError("csv_contract", "RTMS CSV 행의 열 수가 헤더와 다릅니다.")
|
|
293
|
+
row = dict(zip(header, (cell.strip() for cell in raw_row)))
|
|
294
|
+
observed += 1
|
|
295
|
+
if not row.get("NO", "").isdigit():
|
|
296
|
+
raise ReportError("csv_contract", "RTMS CSV NO 값이 정수가 아닙니다.")
|
|
297
|
+
for field_name in ("시군구", "계약년월", "계약일"):
|
|
298
|
+
if field_name not in row or not row[field_name]:
|
|
299
|
+
raise ReportError("csv_contract", f"RTMS CSV의 {field_name} 값이 없습니다.")
|
|
300
|
+
month = row["계약년월"]
|
|
301
|
+
if len(month) != 6 or not month.isdigit():
|
|
302
|
+
raise ReportError("csv_contract", "계약년월 형식이 YYYYMM이 아닙니다.")
|
|
303
|
+
code = _region_code(row["시군구"], lookup, max_words)
|
|
304
|
+
data.region_counts[code] = data.region_counts.get(code, 0) + 1
|
|
305
|
+
data.latest_month = max(data.latest_month or month, month)
|
|
306
|
+
|
|
307
|
+
if combo.deal_code == "1":
|
|
308
|
+
if "거래금액(만원)" not in row or "해제사유발생일" not in row:
|
|
309
|
+
raise ReportError("csv_contract", "매매 CSV 가격 또는 해제 필드가 없습니다.")
|
|
310
|
+
if row["해제사유발생일"] not in ("", "-"):
|
|
311
|
+
data.cancelled += 1
|
|
312
|
+
else:
|
|
313
|
+
data.prices.append(_amount(row["거래금액(만원)"], "거래금액"))
|
|
314
|
+
else:
|
|
315
|
+
for field_name in ("보증금(만원)", "월세금(만원)"):
|
|
316
|
+
if field_name not in row:
|
|
317
|
+
raise ReportError("csv_contract", f"전월세 CSV의 {field_name} 필드가 없습니다.")
|
|
318
|
+
data.deposits.append(_amount(row["보증금(만원)"], "보증금"))
|
|
319
|
+
data.monthly_rents.append(_amount(row["월세금(만원)"], "월세금"))
|
|
320
|
+
|
|
321
|
+
if header is None:
|
|
322
|
+
raise ReportError("csv_contract", "RTMS CSV 헤더를 찾지 못했습니다.")
|
|
323
|
+
if observed != announced:
|
|
324
|
+
raise ReportError("count_mismatch", f"사전 건수 {announced:,}건과 CSV {observed:,}건이 다릅니다.")
|
|
325
|
+
return data
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def fetch_combo(
|
|
329
|
+
opener: urllib.request.OpenerDirector,
|
|
330
|
+
combo: Combo,
|
|
331
|
+
as_of: date,
|
|
332
|
+
regions: Sequence[Region],
|
|
333
|
+
) -> ComboResult:
|
|
334
|
+
started = time.monotonic()
|
|
335
|
+
last_from, last_to = month_windows(as_of)[-1]
|
|
336
|
+
for from_date, to_date in month_windows(as_of):
|
|
337
|
+
last_from, last_to = from_date, to_date
|
|
338
|
+
try:
|
|
339
|
+
form = _query_form(combo, from_date, to_date)
|
|
340
|
+
announced = fetch_count(opener, form)
|
|
341
|
+
if announced == 0:
|
|
342
|
+
continue
|
|
343
|
+
parsed = parse_csv(fetch_csv(opener, form), announced, combo, regions)
|
|
344
|
+
return ComboResult(
|
|
345
|
+
combo,
|
|
346
|
+
"success",
|
|
347
|
+
from_date,
|
|
348
|
+
to_date,
|
|
349
|
+
announced,
|
|
350
|
+
region_counts=parsed.region_counts,
|
|
351
|
+
prices=parsed.prices,
|
|
352
|
+
deposits=parsed.deposits,
|
|
353
|
+
monthly_rents=parsed.monthly_rents,
|
|
354
|
+
cancelled=parsed.cancelled,
|
|
355
|
+
latest_month=parsed.latest_month,
|
|
356
|
+
seconds=time.monotonic() - started,
|
|
357
|
+
)
|
|
358
|
+
except ReportError as exc:
|
|
359
|
+
return ComboResult(
|
|
360
|
+
combo,
|
|
361
|
+
"failure",
|
|
362
|
+
from_date,
|
|
363
|
+
to_date,
|
|
364
|
+
0,
|
|
365
|
+
error=f"{exc.code}: {exc.message}",
|
|
366
|
+
seconds=time.monotonic() - started,
|
|
367
|
+
)
|
|
368
|
+
return ComboResult(
|
|
369
|
+
combo,
|
|
370
|
+
"empty",
|
|
371
|
+
last_from,
|
|
372
|
+
last_to,
|
|
373
|
+
0,
|
|
374
|
+
seconds=time.monotonic() - started,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def summarize_cells(regions: Sequence[Region], results: Sequence[ComboResult]) -> Dict[str, int]:
|
|
379
|
+
by_key = {result.combo.key: result for result in results}
|
|
380
|
+
summary = {"total": len(regions) * len(COMBINATIONS), "success": 0, "empty": 0, "failure": 0, "unexecuted": 0}
|
|
381
|
+
for combo in COMBINATIONS:
|
|
382
|
+
result = by_key.get(combo.key)
|
|
383
|
+
if result is None:
|
|
384
|
+
summary["unexecuted"] += len(regions)
|
|
385
|
+
elif result.status == "failure":
|
|
386
|
+
summary["failure"] += len(regions)
|
|
387
|
+
elif result.status == "empty":
|
|
388
|
+
summary["empty"] += len(regions)
|
|
389
|
+
else:
|
|
390
|
+
populated = sum(result.region_counts.get(region.lawd_cd, 0) > 0 for region in regions)
|
|
391
|
+
summary["success"] += populated
|
|
392
|
+
summary["empty"] += len(regions) - populated
|
|
393
|
+
return summary
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _number(value: float) -> str:
|
|
397
|
+
return f"{int(value):,}" if float(value).is_integer() else f"{value:,.1f}"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _money(value: float) -> str:
|
|
401
|
+
raw = f"{_number(value)}만원"
|
|
402
|
+
if value < 10_000:
|
|
403
|
+
return raw
|
|
404
|
+
eok = int(value // 10_000)
|
|
405
|
+
remainder = value - eok * 10_000
|
|
406
|
+
human = f"{eok}억" + (f" {_number(remainder)}만원" if remainder else "원")
|
|
407
|
+
return f"{raw}({human})"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _range(values: Sequence[int]) -> str:
|
|
411
|
+
if not values:
|
|
412
|
+
return "유효 가격 없음"
|
|
413
|
+
return f"중위 {_money(statistics.median(values))} · 최저 {_money(min(values))} · 최고 {_money(max(values))}"
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _month(value: Optional[str]) -> str:
|
|
417
|
+
return f"{value[:4]}-{value[4:]}" if value else "응답 거래월 없음"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def render_report(as_of: date, regions: Sequence[Region], results: Sequence[ComboResult], seconds: float) -> str:
|
|
421
|
+
summary = summarize_cells(regions, results)
|
|
422
|
+
by_key = {result.combo.key: result for result in results}
|
|
423
|
+
raw_rows = sum(result.announced for result in results if result.status == "success")
|
|
424
|
+
cancelled = sum(result.cancelled for result in results if result.status == "success")
|
|
425
|
+
lines = [
|
|
426
|
+
"*전국 실거래가·전월세 일일 보고*",
|
|
427
|
+
"",
|
|
428
|
+
"*핵심 결과*",
|
|
429
|
+
f"• 조회 기준(KST): {as_of.isoformat()}",
|
|
430
|
+
f"• 대상 시·군·구: {len(regions):,}개 (공식 지역목록 응답)",
|
|
431
|
+
f"• 대상: 4개 자산 × 매매·전월세 = 8개 전국 일괄 조회 / {summary['total']:,}개 지역 조합",
|
|
432
|
+
f"• 성공 조합: {summary['success']:,} · 빈 결과: {summary['empty']:,} · 실패: {summary['failure']:,} · 미실행: {summary['unexecuted']:,}",
|
|
433
|
+
f"• 원문 행: {raw_rows:,}건 · 해제 표시: {cancelled:,}건 · 실행: {seconds:.1f}초",
|
|
434
|
+
"",
|
|
435
|
+
"*상세 결과*",
|
|
436
|
+
]
|
|
437
|
+
|
|
438
|
+
for combo in COMBINATIONS:
|
|
439
|
+
result = by_key.get(combo.key)
|
|
440
|
+
label = f"{combo.asset}/{combo.deal}"
|
|
441
|
+
if result is None:
|
|
442
|
+
lines.append(f"• {label}: 미실행")
|
|
443
|
+
elif result.status == "failure":
|
|
444
|
+
lines.append(f"• {label}: 실패 · {result.error}")
|
|
445
|
+
elif result.status == "empty":
|
|
446
|
+
lines.append(f"• {label}: 빈 결과 · {result.from_date.isoformat()}~{result.to_date.isoformat()}")
|
|
447
|
+
else:
|
|
448
|
+
populated = sum(result.region_counts.get(region.lawd_cd, 0) > 0 for region in regions)
|
|
449
|
+
basis = f"{result.from_date.isoformat()}~{result.to_date.isoformat()} · 최신 거래월 {_month(result.latest_month)}"
|
|
450
|
+
if combo.deal_code == "1":
|
|
451
|
+
prices = _range(result.prices)
|
|
452
|
+
extra = f" · 해제 {result.cancelled:,}건" if result.cancelled else ""
|
|
453
|
+
else:
|
|
454
|
+
prices = f"보증금 {_range(result.deposits)} / 월세 {_range(result.monthly_rents)}"
|
|
455
|
+
extra = ""
|
|
456
|
+
lines.append(
|
|
457
|
+
f"• {label}: {result.announced:,}건 · 거래 있음 {populated:,}/{len(regions):,}개 지역 · {basis} · {prices}{extra}"
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
lines.extend(["", "*지역별 건수*", "• 범례(순서): " + "/".join(combo.short for combo in COMBINATIONS) + " · 단위 건"])
|
|
461
|
+
current_province: Optional[str] = None
|
|
462
|
+
for region in regions:
|
|
463
|
+
if region.province != current_province:
|
|
464
|
+
current_province = region.province
|
|
465
|
+
lines.append(f"• *{current_province}*")
|
|
466
|
+
values: List[str] = []
|
|
467
|
+
for combo in COMBINATIONS:
|
|
468
|
+
result = by_key.get(combo.key)
|
|
469
|
+
if result is None:
|
|
470
|
+
values.append("미실행")
|
|
471
|
+
elif result.status == "failure":
|
|
472
|
+
values.append("실패")
|
|
473
|
+
else:
|
|
474
|
+
values.append(str(result.region_counts.get(region.lawd_cd, 0)))
|
|
475
|
+
lines.append(f" ◦ {region.lawd_cd} {region.name}: {'/'.join(values)}")
|
|
476
|
+
|
|
477
|
+
failures = [result for result in results if result.status == "failure"]
|
|
478
|
+
if failures:
|
|
479
|
+
lines.extend(["", "*실패 범위*"])
|
|
480
|
+
for result in failures:
|
|
481
|
+
lines.append(f"• {result.combo.asset}/{result.combo.deal}: 전국 {len(regions):,}개 지역 · {result.error}")
|
|
482
|
+
|
|
483
|
+
lines.extend(
|
|
484
|
+
[
|
|
485
|
+
"",
|
|
486
|
+
"*Source 근거*",
|
|
487
|
+
f"• <{LANDING_URL}|국토교통부 실거래가 자료제공>",
|
|
488
|
+
"• 공식 화면이 반환한 시·도/시·군·구 목록과 전국 CSV 응답만 집계했습니다.",
|
|
489
|
+
"",
|
|
490
|
+
"*GPT 해석*",
|
|
491
|
+
"• 없음 — 원문 응답을 결정적으로 집계했습니다.",
|
|
492
|
+
"",
|
|
493
|
+
"*한계*",
|
|
494
|
+
"• 계약일 기준 신고 자료이며 당일에도 추가·정정·해제될 수 있습니다. 공식 통계와는 집계 기준이 다릅니다.",
|
|
495
|
+
"• 지역 건수는 원문 행 기준이고, 매매 가격 통계에서는 해제 표시 행을 제외했습니다.",
|
|
496
|
+
"• 직전 성공 결과를 저장하지 않으므로 전일 비교는 `비교 기준 없음`입니다.",
|
|
497
|
+
]
|
|
498
|
+
)
|
|
499
|
+
return "\n".join(lines)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _fatal_report(as_of: date, error: ReportError) -> str:
|
|
503
|
+
return "\n".join(
|
|
504
|
+
[
|
|
505
|
+
"*전국 실거래가·전월세 일일 보고*",
|
|
506
|
+
"",
|
|
507
|
+
"*핵심 결과*",
|
|
508
|
+
f"• 조회 기준(KST): {as_of.isoformat()}",
|
|
509
|
+
"• 상태: 실패",
|
|
510
|
+
"• 대상 시·군·구/성공/빈 결과/실패/미실행: 공식 지역목록을 확보하지 못해 산정하지 않음",
|
|
511
|
+
f"• 오류: {error.code} · {error.message}",
|
|
512
|
+
"",
|
|
513
|
+
"*Source 근거*",
|
|
514
|
+
f"• <{LANDING_URL}|국토교통부 실거래가 자료제공>",
|
|
515
|
+
"",
|
|
516
|
+
"*GPT 해석*",
|
|
517
|
+
"• 없음 — 실패를 추정값으로 대체하지 않았습니다.",
|
|
518
|
+
]
|
|
519
|
+
)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def run(argv: Optional[Sequence[str]] = None) -> int:
|
|
523
|
+
parser = argparse.ArgumentParser(description="공식 RTMS 전국 실거래가·전월세 일일 보고")
|
|
524
|
+
parser.add_argument("--as-of", type=date.fromisoformat, help="조회 기준일 YYYY-MM-DD (기본: KST 오늘)")
|
|
525
|
+
args = parser.parse_args(argv)
|
|
526
|
+
today = datetime.now(ZoneInfo("Asia/Seoul")).date()
|
|
527
|
+
as_of = args.as_of or today
|
|
528
|
+
if as_of > today:
|
|
529
|
+
parser.error("--as-of는 KST 오늘 이후일 수 없습니다.")
|
|
530
|
+
|
|
531
|
+
started = time.monotonic()
|
|
532
|
+
opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()))
|
|
533
|
+
try:
|
|
534
|
+
regions = fetch_regions(opener)
|
|
535
|
+
except ReportError as exc:
|
|
536
|
+
print(_fatal_report(as_of, exc))
|
|
537
|
+
return 1
|
|
538
|
+
|
|
539
|
+
results = [fetch_combo(opener, combo, as_of, regions) for combo in COMBINATIONS]
|
|
540
|
+
print(render_report(as_of, regions, results, time.monotonic() - started))
|
|
541
|
+
return int(any(result.status == "failure" for result in results))
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
if __name__ == "__main__":
|
|
545
|
+
sys.exit(run())
|
|
@@ -51,6 +51,8 @@ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.p
|
|
|
51
51
|
|
|
52
52
|
`--filter`가 필요하거나 게시 계약을 점검해야 할 때는 `--fast`를 빼고 full-contract query를 사용한다. fast query가 `product_not_ready` 또는 계약 오류를 반환하면 fixture나 추정값으로 대체하지 말고 아래 진단 흐름을 수행한다.
|
|
53
53
|
|
|
54
|
+
`--from`/`--to`에 날짜만 넣으면 그날 `00:00:00`–`23:59:59`로 확장한다. ASK 서울 serving window가 자정부터 열려 있지 않아 `422 query_window_unavailable`이 오면 helper는 요청 구간과 `available_from_at`/`available_to_at`의 교집합으로 한 번만 재시도한다. 교집합이 없으면 그 에러의 available window를 보여주고 중단한다. 없는 시간대를 추정 데이터로 채우지 않는다.
|
|
55
|
+
|
|
54
56
|
### Contract diagnostics (only when needed)
|
|
55
57
|
|
|
56
58
|
1. 환경 설정만 확인한다. 이 명령은 네트워크를 호출하지 않는다.
|
|
@@ -99,6 +101,7 @@ npx -y @nomadamas/k-skill@0 exec seoul-weather-risk scripts/seoul_weather_risk.p
|
|
|
99
101
|
- `location_mapping_invalid`: bundled 행정동 reference의 버전·스키마·행 수 계약 오류
|
|
100
102
|
- `proxy_disabled`, `invalid_proxy_base_url`: proxy 환경 설정 오류
|
|
101
103
|
- `unauthorized`/`api_key_missing`(401), `forbidden`/`api_key_forbidden`(403), `unknown_product`(404)
|
|
102
|
-
- `cursor_expired`(409), `rate_limited`(429), `product_not_ready`(503)
|
|
104
|
+
- `cursor_expired`(409), `query_window_unavailable`(422), `rate_limited`(429), `product_not_ready`(503)
|
|
105
|
+
- `query_window_unavailable`: 요청한 `--from`/`--to`가 현재 제공 가능한 예보 window와 겹치지 않음. `details.available_from_at`/`available_to_at`를 확인한다. 겹치는 구간이면 helper가 이미 한 번 재시도한 뒤의 결과다.
|
|
103
106
|
- `upstream_not_configured`(503): proxy 운영 환경에 ASK Seoul 전용 서비스 키 또는 origin이 설정되지 않음
|
|
104
107
|
- `response_contract_invalid`, `malformed_response`: 단일 제품 계약 또는 API 응답 계약 drift
|