@nomadamas/k-skill 0.5.0 → 0.7.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/komsa-ferry-info/instruction.md +54 -0
- package/skills/komsa-ferry-info/scripts/komsa_ferry_info.py +76 -0
- package/skills/komsa-ferry-info/skill.json +6 -0
- package/skills/railway-timetable/instruction.md +40 -0
- package/skills/railway-timetable/references/AUTOMATION-LEGAL-STATEMENT.md +5 -0
- package/skills/railway-timetable/references/DISCLAIMER.md +7 -0
- package/skills/railway-timetable/references/TRADEMARK-LEGAL-STATEMENT.md +3 -0
- package/skills/{ktx-booking/scripts/ktx_booking.py → railway-timetable/scripts/ktx_backend.py} +0 -0
- package/skills/{ktx-booking → railway-timetable}/scripts/ktx_timetable.py +0 -2
- package/skills/railway-timetable/scripts/railway_timetable.py +95 -0
- package/skills/railway-timetable/scripts/railway_timetable.py.lock +27 -0
- package/skills/railway-timetable/skill.json +8 -0
- package/skills/real-estate-search/instruction.md +22 -2
- package/skills/real-estate-search/scripts/nationwide_daily_report.py +545 -0
- package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc +0 -0
- package/skills/ktx-booking/instruction.md +0 -61
- package/skills/ktx-booking/references/AUTOMATION-LEGAL-STATEMENT.md +0 -10
- package/skills/ktx-booking/references/DISCLAIMER.md +0 -14
- package/skills/ktx-booking/references/TRADEMARK-LEGAL-STATEMENT.md +0 -9
- package/skills/ktx-booking/skill.json +0 -8
- package/skills/srt-booking/instruction.md +0 -75
- package/skills/srt-booking/references/AUTOMATION-LEGAL-STATEMENT.md +0 -25
- package/skills/srt-booking/references/DISCLAIMER.md +0 -13
- package/skills/srt-booking/references/TRADEMARK-LEGAL-STATEMENT.md +0 -9
- package/skills/srt-booking/scripts/srt_booking.py +0 -171
- package/skills/srt-booking/scripts/srt_booking.py.lock +0 -207
- package/skills/srt-booking/skill.json +0 -8
- /package/skills/{ktx-booking/scripts/ktx_booking.py.lock → railway-timetable/scripts/ktx_backend.py.lock} +0 -0
|
@@ -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())
|
package/skills/store-longevity-radar/scripts/__pycache__/store_longevity_download.cpython-312.pyc
CHANGED
|
Binary file
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
# KTX Official Timetable Lookup
|
|
2
|
-
|
|
3
|
-
## What this skill does
|
|
4
|
-
|
|
5
|
-
한국철도공사가 로그인 없이 공개하는 최신 KTX 운행시간표 XLSX를 내려받아 출발역·도착역·출발시간 조건에 맞는 열차를 찾는다.
|
|
6
|
-
|
|
7
|
-
이 스킬은 **공식 계획 시간표 조회 전용**이다.
|
|
8
|
-
|
|
9
|
-
- 회원 로그인, credential, Korail 앱 내부 API를 사용하지 않는다.
|
|
10
|
-
- Dynapath 또는 anti-bot token을 생성·재현·우회하지 않는다.
|
|
11
|
-
- 실시간 잔여석, 실제 운휴·지연, 호차·좌석번호는 조회하지 않는다.
|
|
12
|
-
- 예약, 예약대기, 좌석 선점, 결제, 취소, 자동 재조회는 실행하지 않는다.
|
|
13
|
-
- 구매와 실시간 운행 확인은 공식 코레일 페이지에서 사용자가 직접 진행한다.
|
|
14
|
-
|
|
15
|
-
실행 환경에는 Python 3.11 이상과 `uv`가 필요하다. `uv`가 helper에 고정된 `openpyxl` 환경을 자동 설치한다.
|
|
16
|
-
|
|
17
|
-
## Commands
|
|
18
|
-
|
|
19
|
-
```bash
|
|
20
|
-
npx -y @nomadamas/k-skill@0 exec ktx-booking scripts/ktx_booking.py -- \
|
|
21
|
-
search --dep 서울 --arr 부산 --date 20260819 --time 0600 --time-limit 1200 --limit 5
|
|
22
|
-
```
|
|
23
|
-
|
|
24
|
-
```bash
|
|
25
|
-
npx -y @nomadamas/k-skill@0 exec ktx-booking scripts/ktx_booking.py -- source
|
|
26
|
-
```
|
|
27
|
-
|
|
28
|
-
## Inputs
|
|
29
|
-
|
|
30
|
-
- `--dep`: 출발역명. `서울`과 `서울역` 모두 허용한다.
|
|
31
|
-
- `--arr`: 도착역명.
|
|
32
|
-
- `--date`: `YYYYMMDD`.
|
|
33
|
-
- `--time`: 가장 이른 출발시각 `HHMM`, 기본 `0000`.
|
|
34
|
-
- `--time-limit`: 가장 늦은 출발시각 `HHMM`, 기본 `2359`.
|
|
35
|
-
- `--limit`: 최대 결과 수, 1~50.
|
|
36
|
-
|
|
37
|
-
## Output
|
|
38
|
-
|
|
39
|
-
- `count`
|
|
40
|
-
- `trains[]`: `train_no`, `train_type`, `dep`, `arr`, `dep_time`, `arr_time`
|
|
41
|
-
- `date`
|
|
42
|
-
- `schedule_note`
|
|
43
|
-
- `source`: 공식 게시물 제목·게시일·XLSX URL·게시판 URL
|
|
44
|
-
- `booking_url`
|
|
45
|
-
|
|
46
|
-
항상 이 결과는 **공개 운행계획 시간표**이며 실시간 잔여석·운휴·지연 정보가 아니라는 점을 명시한다.
|
|
47
|
-
|
|
48
|
-
## Failure modes
|
|
49
|
-
|
|
50
|
-
- 공식 시간표 게시판 또는 XLSX 다운로드 실패
|
|
51
|
-
- 요청일에 적용되는 KTX 시간표 없음
|
|
52
|
-
- XLSX 형식 변경으로 header를 찾지 못함
|
|
53
|
-
- 요청 구간·시간대 결과 없음: 오류가 아니라 `count: 0`
|
|
54
|
-
- 잘못된 날짜·시간·limit: 실행 전 오류
|
|
55
|
-
|
|
56
|
-
## Hard boundaries
|
|
57
|
-
|
|
58
|
-
- 로그인·credential 요청 금지
|
|
59
|
-
- `ScheduleView`, `korail2`, Dynapath 등 앱 내부 경로 사용 금지
|
|
60
|
-
- 예약·결제·취소·좌석 선점 금지
|
|
61
|
-
- CAPTCHA·인증·anti-bot 통제 우회 금지
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
# KTX 공식 시간표 조회 관련 고지
|
|
2
|
-
|
|
3
|
-
이 스킬은 한국철도공사가 공개한 시간표 게시판과 XLSX 첨부파일만 읽는다.
|
|
4
|
-
|
|
5
|
-
- 회원 로그인과 credential을 사용하지 않는다.
|
|
6
|
-
- Korail 앱 내부 API, `ScheduleView`, Dynapath token을 호출·생성·재현하지 않는다.
|
|
7
|
-
- CAPTCHA, anti-bot 또는 접근통제를 우회하지 않는다.
|
|
8
|
-
- 실시간 잔여석, 예약, 좌석 선점, 결제, 취소를 실행하지 않는다.
|
|
9
|
-
|
|
10
|
-
결과는 공개 운행계획 시간표이며 실제 운휴·지연·잔여석 정보가 아니다. 실제 이용 전에는 공식 코레일 예매 페이지에서 다시 확인해야 한다.
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
# DISCLAIMER — `ktx-booking`
|
|
2
|
-
|
|
3
|
-
이 스킬은 한국철도공사·코레일·KTX의 공식 기능 또는 공식 지원 도구가 아니며, 공식 제휴·후원·승인·인증 또는 협업한 사실이 전혀 없습니다. 상표와 서비스명은 열차 조회·호환 대상을 설명하기 위해서만 사용합니다.
|
|
4
|
-
|
|
5
|
-
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)(소니용 리모컨 사건)은 기능 설명용 표장과 출처표시를 구별했습니다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)에 따른 출처 혼동 판단은 별도입니다.
|
|
6
|
-
|
|
7
|
-
대법원 2022. 5. 12. 선고 [2021도1533 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=221765)은 공개정보 수집만으로 곧바로 [정보통신망법 제48조](https://www.law.go.kr/법령/정보통신망이용촉진및정보보호등에관한법률/제48조) 위반이 되는 것은 아닌 사정을 제시했지만, 철도 예매 자동화·접근통제 우회·반복 선점을 허용하지 않습니다. [저작권법 제93조](https://www.law.go.kr/법령/저작권법/제93조)와 [형법 제314조 제2항](https://www.law.go.kr/법령/형법/제314조)도 별도입니다.
|
|
8
|
-
|
|
9
|
-
- 공개 운행정보 자동 수집은 반드시 개인의 정보 조회용으로만 사용합니다.
|
|
10
|
-
- 매진 감시, 조직적·대량 크롤링, 반복 선점·예약·취소, 공격적 polling을 하지 않습니다.
|
|
11
|
-
- CAPTCHA·anti-bot·로그인·접근통제·rate limit·IP 차단을 우회하거나 차단 회피를 하지 않습니다.
|
|
12
|
-
- `MACRO ERROR`나 접근 거부가 나타나면 즉시 중단하며 정상 예매 영업을 방해하지 않습니다.
|
|
13
|
-
|
|
14
|
-
이 문서는 적법성을 보증하는 법률 자문이 아닙니다.
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# 상표 사용 법적 고지 — `ktx-booking`
|
|
2
|
-
|
|
3
|
-
이 스킬에서 `KTX`, `코레일`, `Korail` 명칭은 열차 조회·예약·취소의 **대상 철도 서비스와 운영기관**을 식별하기 위해 사용한다. k-skill의 출처나 운영주체를 코레일로 표시하려는 사용이 아니다.
|
|
4
|
-
|
|
5
|
-
대법원 2005. 6. 10. 선고 [2005도1637 판결](https://www.law.go.kr/LSW/precInfoP.do?precSeq=83920)은 타인의 표장을 출처표시가 아니라 상품 기능 또는 적용 기종을 밝히기 위해 사용하고 상표 사용으로 인식될 수 없는 경우 침해가 아니라고 판시했다. [상표법 제2조](https://www.law.go.kr/법령/상표법/제2조), [제89조](https://www.law.go.kr/법령/상표법/제89조), [제90조](https://www.law.go.kr/법령/상표법/제90조), [제108조](https://www.law.go.kr/법령/상표법/제108조)와 대법원 [2011다18802](https://www.law.go.kr/LSW/precInfoP.do?precSeq=167457), [2019후10418](https://law.go.kr/LSW/precInfoP.do?mode=0&precSeq=230725) 판결에 따라 실제 거래계에서 출처표시로 기능하는지는 표시 태양과 사용 경위 등을 종합해 판단해야 한다.
|
|
6
|
-
|
|
7
|
-
따라서 이 명칭은 필요한 범위의 평문으로만 사용하고, 별도 근거 없이 로고, 공식·제휴·후원·인증·파트너 표현을 사용하지 않는다. 각 상표의 권리는 해당 권리자에게 있다. 이 문서는 운송계약, API·서비스 약관, 개인정보 또는 예약·결제 흐름의 적법성을 판단하지 않는다.
|
|
8
|
-
|
|
9
|
-
전체 검토: [제3자 상표의 기능 설명·호환 대상 표시 검토](https://github.com/NomaDamas/k-skill/blob/dev/docs/legal/trademark-use-review.md)
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ktx-booking",
|
|
3
|
-
"description": "Read-only KTX operating-timetable lookup from official Korail public XLSX attachments. No login, internal mobile API, anti-bot bypass, live seat availability, reservation, or payment.",
|
|
4
|
-
"profiles": [
|
|
5
|
-
"lookup"
|
|
6
|
-
],
|
|
7
|
-
"frontmatter": "name: ktx-booking\ndescription: Read-only KTX operating-timetable lookup from official Korail public XLSX attachments. No login, internal mobile API, anti-bot bypass, live seat availability, reservation, or payment.\nlicense: MIT\nmetadata:\n category: travel\n locale: ko-KR\n phase: v1"
|
|
8
|
-
}
|