@nomadamas/k-skill 0.5.0 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nomadamas/k-skill",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "k-skill unified CLI: assembles runtime-aware skill instructions and ships bundled helper files",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -28,9 +28,26 @@
28
28
 
29
29
  ## Prerequisites
30
30
 
31
- 없음. 사용자는 별도 API key를 준비할 필요가 없다. upstream key는 proxy 서버에서만 주입한다.
31
+ 없음. 사용자는 별도 API key를 준비할 필요가 없다. 개별 조회의 upstream key는 proxy 서버에서만 주입하며, 전국 일일 보고는 아래 공식 RTMS CSV를 직접 사용한다.
32
32
 
33
- ## Default path
33
+ ## 전국 일일 보고 — 공식 RTMS CSV 직접 조회
34
+
35
+ proxy나 ServiceKey 없이 국토교통부 실거래가 자료제공 화면의 실제 CSV 응답을 집계한다.
36
+
37
+ ```bash
38
+ npx -y @nomadamas/k-skill@0 exec real-estate-search scripts/nationwide_daily_report.py -- --as-of YYYY-MM-DD
39
+ ```
40
+
41
+ `--as-of`를 생략하면 KST 오늘을 사용한다. 공식 시·도/시·군·구 목록을 조회한 뒤 아파트·오피스텔·연립다세대·단독다가구의 매매·전월세 8개 조합을 전국 일괄 CSV로 실행한다. 현재 월이 0건이면 직전 월을 한 번 확인한다.
42
+
43
+ - `success`: CSV를 내려받아 파싱한 조합. 지역별 0건은 `empty`로 집계한다.
44
+ - `empty`: 현재 월과 직전 월의 공식 건수가 모두 0인 조합.
45
+ - `failure`: 네트워크, HTTP, 응답 크기, JSON/CSV 계약 또는 건수 불일치 오류.
46
+ - `unexecuted`: 실행 결과가 없는 조합. 실패나 빈 결과를 이 상태로 바꾸지 않는다.
47
+
48
+ 한 조합이라도 실패하면 보고서는 성공/빈 결과/실패 범위를 모두 출력하고 종료코드 1을 반환한다. 원문 근거는 실제로 접속한 `https://rt.molit.go.kr/pt/xls/xls.do`로 고정한다.
49
+
50
+ ## 개별 조회 기본 경로
34
51
 
35
52
  추가 client API 레이어는 불필요하다. 그냥 프록시 서버에 HTTP 요청만 넣으면 된다.
36
53
 
@@ -156,17 +173,20 @@ curl -fsS --get 'https://k-skill-proxy.nomadamas.org/v1/real-estate/officetel/re
156
173
  - 프록시 서버에 `DATA_GO_KR_API_KEY` 가 없으면 503 응답
157
174
  - upstream MOLIT API 오류면 502 + `molit_api_XXX` 에러 코드
158
175
  - 해당 지역/기간에 데이터가 없으면 빈 `items` 배열 반환
176
+ - 전국 일일 보고의 공식 RTMS 응답이 실패하거나 계약과 다르면 해당 조합을 `failure`로 유지하고 누락 수치나 더미 데이터를 만들지 않는다.
159
177
 
160
178
  ## Done when
161
179
 
162
180
  - 요청 자산 타입에 맞는 endpoint를 선택했다.
163
181
  - 필요한 경우 `region-code` 로 지역코드를 먼저 확인했다.
164
182
  - 실거래가/전월세 결과를 조회하고 요약했다.
183
+ - 전국 일일 보고 요청이면 공식 RTMS CSV helper를 실행하고 success/empty/failure/unexecuted를 구분했다.
165
184
  - 원본 데이터 출처(국토교통부 실거래가 신고)를 함께 남겼다.
166
185
 
167
186
  ## Notes
168
187
 
169
188
  - 원본 참고: `https://github.com/tae0y/real-estate-mcp/tree/main`
170
189
  - 공식 데이터 출처: 공공데이터포털 (`https://www.data.go.kr`)
190
+ - 공식 RTMS CSV 자료제공: `https://rt.molit.go.kr/pt/xls/xls.do`
171
191
  - 가격 단위: `price_10k`, `deposit_10k` = 만원 단위 (예: 245000 = 24억 5천만원)
172
192
  - 취소된 거래는 서버에서 자동 필터링된다.
@@ -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())