kwcli 0.1.0__py3-none-any.whl
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.
- kiwoom/__init__.py +47 -0
- kiwoom/_data/kiwoom_api_spec.json +66372 -0
- kiwoom/core/__init__.py +6 -0
- kiwoom/core/auth.py +349 -0
- kiwoom/core/client.py +244 -0
- kiwoom/core/errors.py +177 -0
- kiwoom/core/platform_paths.py +68 -0
- kiwoom/core/profiles.py +143 -0
- kiwoom/core/runtime.py +153 -0
- kiwoom/core/secrets.py +217 -0
- kiwoom/core/settings.py +64 -0
- kiwoom/core/token_store.py +186 -0
- kiwoom/core/types.py +28 -0
- kiwoom/core/ws_client.py +262 -0
- kiwoom/realtime/__init__.py +30 -0
- kiwoom/realtime/decoders.py +171 -0
- kiwoom/realtime/events.py +98 -0
- kiwoom/realtime/packets.py +65 -0
- kiwoom/realtime/schemas.py +76 -0
- kiwoom/realtime/stream.py +213 -0
- kiwoom/specs.py +272 -0
- kiwoom_cli/README.md +671 -0
- kiwoom_cli/__init__.py +9 -0
- kiwoom_cli/__main__.py +5 -0
- kiwoom_cli/argument_maps.py +561 -0
- kiwoom_cli/arguments.py +147 -0
- kiwoom_cli/auth_context.py +64 -0
- kiwoom_cli/banner.py +125 -0
- kiwoom_cli/commands/__init__.py +1 -0
- kiwoom_cli/commands/auth.py +406 -0
- kiwoom_cli/commands/groups.py +281 -0
- kiwoom_cli/commands/mapped.py +74 -0
- kiwoom_cli/commands/orders.py +158 -0
- kiwoom_cli/commands/spec.py +98 -0
- kiwoom_cli/commands/stocks.py +100 -0
- kiwoom_cli/commands/streams.py +470 -0
- kiwoom_cli/doctor.py +324 -0
- kiwoom_cli/errors.py +24 -0
- kiwoom_cli/executor/__init__.py +33 -0
- kiwoom_cli/executor/condition.py +374 -0
- kiwoom_cli/executor/rest.py +132 -0
- kiwoom_cli/executor/waits.py +34 -0
- kiwoom_cli/executor/websocket.py +131 -0
- kiwoom_cli/main.py +203 -0
- kiwoom_cli/maps/README.md +99 -0
- kiwoom_cli/maps/api_commands.csv +209 -0
- kiwoom_cli/maps/arguments.csv +731 -0
- kiwoom_cli/maps/order_confirmation_commands.csv +13 -0
- kiwoom_cli/maps/order_confirmation_fields.csv +71 -0
- kiwoom_cli/maps/order_price_policies.csv +47 -0
- kiwoom_cli/maps/order_value_labels.csv +28 -0
- kiwoom_cli/maps/positional_arguments.csv +21 -0
- kiwoom_cli/order_confirmation.py +167 -0
- kiwoom_cli/output.py +136 -0
- kiwoom_cli/registry.py +95 -0
- kiwoom_cli/safety.py +30 -0
- kiwoom_cli/setup.py +533 -0
- kwcli-0.1.0.dist-info/METADATA +215 -0
- kwcli-0.1.0.dist-info/RECORD +62 -0
- kwcli-0.1.0.dist-info/WHEEL +4 -0
- kwcli-0.1.0.dist-info/entry_points.txt +2 -0
- kwcli-0.1.0.dist-info/licenses/LICENSE.md +36 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Schema registry for Kiwoom realtime WebSocket values.
|
|
2
|
+
|
|
3
|
+
The packaged Kiwoom API spec is the source of truth. This module exposes the
|
|
4
|
+
spec rows under ``REAL``/``data[*].values`` as a registry that decoders can read
|
|
5
|
+
without embedding FID-specific branching in decode logic.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from functools import lru_cache
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
RealtimeField = dict[str, str]
|
|
15
|
+
RealtimeSchema = dict[str, Any]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@lru_cache(maxsize=1)
|
|
19
|
+
def realtime_schemas() -> dict[str, RealtimeSchema]:
|
|
20
|
+
"""Return built-in realtime schemas keyed by realtime type/API ID."""
|
|
21
|
+
from kiwoom.specs import load_api_specs
|
|
22
|
+
|
|
23
|
+
schemas: dict[str, RealtimeSchema] = {}
|
|
24
|
+
for api_id, payload in load_api_specs().items():
|
|
25
|
+
fields = _extract_value_fields(payload)
|
|
26
|
+
if not fields:
|
|
27
|
+
continue
|
|
28
|
+
meta = payload.get("meta", {})
|
|
29
|
+
api_name = str(meta.get("API 명", "")).strip()
|
|
30
|
+
schemas[api_id] = {
|
|
31
|
+
"event": api_id,
|
|
32
|
+
"name": api_name,
|
|
33
|
+
"fields": fields,
|
|
34
|
+
}
|
|
35
|
+
return schemas
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_realtime_schema(real_type: str) -> RealtimeSchema | None:
|
|
39
|
+
"""Return a realtime schema for *real_type*, or ``None`` if unknown."""
|
|
40
|
+
return realtime_schemas().get(str(real_type).strip())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _extract_value_fields(payload: dict[str, Any]) -> dict[str, RealtimeField]:
|
|
44
|
+
fields: dict[str, RealtimeField] = {}
|
|
45
|
+
seen_names: set[str] = set()
|
|
46
|
+
in_values = False
|
|
47
|
+
for row in payload.get("response", {}).get("body", []):
|
|
48
|
+
element = str(row.get("element", "")).strip()
|
|
49
|
+
depth = row.get("depth")
|
|
50
|
+
if element == "values" and depth == 1:
|
|
51
|
+
in_values = True
|
|
52
|
+
continue
|
|
53
|
+
if not in_values:
|
|
54
|
+
continue
|
|
55
|
+
if depth == 2 and element:
|
|
56
|
+
korean_name = str(row.get("한글명", "")).strip()
|
|
57
|
+
if korean_name:
|
|
58
|
+
field_name = (
|
|
59
|
+
korean_name
|
|
60
|
+
if korean_name not in seen_names
|
|
61
|
+
else f"{korean_name}_{element}"
|
|
62
|
+
)
|
|
63
|
+
seen_names.add(field_name)
|
|
64
|
+
fields[element] = {
|
|
65
|
+
# The current official spec provides Korean display names,
|
|
66
|
+
# not English field identifiers. Keep the explicit spec name
|
|
67
|
+
# as the stable v1 named key instead of guessing. When the
|
|
68
|
+
# spec repeats a display name in one schema, suffix the FID
|
|
69
|
+
# so named decoding cannot overwrite a previous value.
|
|
70
|
+
"name": field_name,
|
|
71
|
+
"ko": korean_name,
|
|
72
|
+
"type": str(row.get("type", "")).strip() or "String",
|
|
73
|
+
}
|
|
74
|
+
elif isinstance(depth, int) and depth <= 1:
|
|
75
|
+
in_values = False
|
|
76
|
+
return fields
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""In-process pub/sub and collection helpers for Kiwoom realtime streams.
|
|
2
|
+
|
|
3
|
+
These utilities are API-agnostic. Callers provide the realtime client, the
|
|
4
|
+
control packet(s) to send, and an optional ``{fid: label}`` column map. Message
|
|
5
|
+
decoding and topic routing reuse :mod:`kiwoom.realtime.events`, so examples only
|
|
6
|
+
need to declare their column map and the consumers/topics they care about.
|
|
7
|
+
|
|
8
|
+
Two delivery models are provided:
|
|
9
|
+
|
|
10
|
+
* :func:`collect_realtime` - await a bounded number of events and return them as
|
|
11
|
+
DataFrames or JSON (one-shot collection).
|
|
12
|
+
* :func:`run_pubsub` - fan a single live stream out to multiple topic consumers
|
|
13
|
+
via an in-process :class:`AsyncPubSub`.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import asyncio
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from collections.abc import Awaitable, Callable, Mapping
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import pandas as pd
|
|
24
|
+
|
|
25
|
+
from kiwoom.realtime.events import (
|
|
26
|
+
is_terminal_event,
|
|
27
|
+
normalize_message_events,
|
|
28
|
+
resolve_topic,
|
|
29
|
+
topic_fanout,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
Consumer = Callable[["asyncio.Queue[Any]"], Awaitable[None]]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class AsyncPubSub:
|
|
36
|
+
"""Minimal in-process pub/sub built on ``asyncio.Queue``.
|
|
37
|
+
|
|
38
|
+
No external broker (Redis/Kafka) is involved: one received message can be
|
|
39
|
+
fanned out to several consumers, each reading from its own queue.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
self._subscribers: dict[str, list[asyncio.Queue[Any]]] = defaultdict(list)
|
|
44
|
+
|
|
45
|
+
def subscribe(self, topic: str) -> "asyncio.Queue[Any]":
|
|
46
|
+
"""Register interest in *topic* and return the queue to read from."""
|
|
47
|
+
queue: asyncio.Queue[Any] = asyncio.Queue()
|
|
48
|
+
self._subscribers[topic].append(queue)
|
|
49
|
+
return queue
|
|
50
|
+
|
|
51
|
+
async def publish(self, topic: str, message: Any) -> None:
|
|
52
|
+
"""Deliver *message* to every queue subscribed to *topic*."""
|
|
53
|
+
for queue in self._subscribers.get(topic, []):
|
|
54
|
+
await queue.put(message)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def event_to_dataframe(event: dict[str, Any]) -> pd.DataFrame:
|
|
58
|
+
"""Convert one decoded event into a single-row DataFrame."""
|
|
59
|
+
return pd.DataFrame([event])
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def _publish_stream(
|
|
63
|
+
client: Any,
|
|
64
|
+
*,
|
|
65
|
+
api_url: str,
|
|
66
|
+
bodies: dict[str, Any] | list[dict[str, Any]],
|
|
67
|
+
columns: dict[str, str],
|
|
68
|
+
pubsub: AsyncPubSub,
|
|
69
|
+
max_messages: int | None,
|
|
70
|
+
close_client: bool,
|
|
71
|
+
) -> None:
|
|
72
|
+
body_list = [bodies] if isinstance(bodies, dict) else list(bodies)
|
|
73
|
+
published_realtime = 0
|
|
74
|
+
try:
|
|
75
|
+
if not client.is_connected:
|
|
76
|
+
await client.connect(api_url=api_url)
|
|
77
|
+
for body in body_list:
|
|
78
|
+
await client.send(body)
|
|
79
|
+
|
|
80
|
+
async for message in client.iter_messages():
|
|
81
|
+
for event in normalize_message_events(message, columns):
|
|
82
|
+
for topic in topic_fanout(resolve_topic(event)):
|
|
83
|
+
await pubsub.publish(topic, event)
|
|
84
|
+
|
|
85
|
+
if str(event.get("trnm", "")).upper() == "REAL":
|
|
86
|
+
published_realtime += 1
|
|
87
|
+
if max_messages is not None and published_realtime >= max_messages:
|
|
88
|
+
return
|
|
89
|
+
|
|
90
|
+
if is_terminal_event(event):
|
|
91
|
+
return
|
|
92
|
+
finally:
|
|
93
|
+
if close_client:
|
|
94
|
+
await client.close()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def run_pubsub(
|
|
98
|
+
client: Any,
|
|
99
|
+
*,
|
|
100
|
+
api_url: str,
|
|
101
|
+
bodies: dict[str, Any] | list[dict[str, Any]],
|
|
102
|
+
consumers: Mapping[str, Consumer],
|
|
103
|
+
columns: Mapping[str, str] | None = None,
|
|
104
|
+
pubsub: AsyncPubSub | None = None,
|
|
105
|
+
max_messages: int | None = None,
|
|
106
|
+
close_client: bool = True,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Fan a live Kiwoom realtime stream out to topic *consumers*.
|
|
109
|
+
|
|
110
|
+
Each ``{topic: consumer}`` pair gets its own queue; the publisher decodes
|
|
111
|
+
every received message, routes it to the matching topics (plus the shared
|
|
112
|
+
``kiwoom.realtime`` / ``kiwoom.all`` fan-out), and the consumers run as
|
|
113
|
+
background tasks until the stream ends.
|
|
114
|
+
|
|
115
|
+
Args:
|
|
116
|
+
client: A connected-or-connectable realtime WebSocket client.
|
|
117
|
+
api_url: Realtime WebSocket API path.
|
|
118
|
+
bodies: One ``REG`` packet, or a list to combine several on one
|
|
119
|
+
connection.
|
|
120
|
+
consumers: ``{topic: async consumer(queue)}`` mapping.
|
|
121
|
+
columns: Optional ``{fid: label}`` map applied to realtime values.
|
|
122
|
+
pubsub: Optional shared bus (inject to combine with other streams).
|
|
123
|
+
max_messages: Stop after this many ``REAL`` events (None = unlimited).
|
|
124
|
+
close_client: Close *client* when the stream ends.
|
|
125
|
+
"""
|
|
126
|
+
bus = pubsub or AsyncPubSub()
|
|
127
|
+
tasks = [
|
|
128
|
+
asyncio.create_task(consumer(bus.subscribe(topic)))
|
|
129
|
+
for topic, consumer in consumers.items()
|
|
130
|
+
]
|
|
131
|
+
try:
|
|
132
|
+
await _publish_stream(
|
|
133
|
+
client,
|
|
134
|
+
api_url=api_url,
|
|
135
|
+
bodies=bodies,
|
|
136
|
+
columns=dict(columns or {}),
|
|
137
|
+
pubsub=bus,
|
|
138
|
+
max_messages=max_messages,
|
|
139
|
+
close_client=close_client,
|
|
140
|
+
)
|
|
141
|
+
finally:
|
|
142
|
+
for task in tasks:
|
|
143
|
+
task.cancel()
|
|
144
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _format_collected(
|
|
148
|
+
rows: list[dict[str, Any]],
|
|
149
|
+
system_rows: list[dict[str, Any]],
|
|
150
|
+
output: str,
|
|
151
|
+
) -> pd.DataFrame | dict[str, Any] | list[dict[str, Any]]:
|
|
152
|
+
if output == "json":
|
|
153
|
+
if rows and system_rows:
|
|
154
|
+
return {"system": system_rows, "data": rows}
|
|
155
|
+
return rows or system_rows
|
|
156
|
+
result: dict[str, pd.DataFrame] = {"data": pd.DataFrame(rows)}
|
|
157
|
+
if system_rows:
|
|
158
|
+
result["system"] = pd.DataFrame(system_rows)
|
|
159
|
+
return result
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def collect_realtime(
|
|
163
|
+
client: Any,
|
|
164
|
+
*,
|
|
165
|
+
api_url: str,
|
|
166
|
+
body: dict[str, Any],
|
|
167
|
+
columns: Mapping[str, str] | None = None,
|
|
168
|
+
output: str = "dataframe",
|
|
169
|
+
max_messages: int = 10,
|
|
170
|
+
) -> pd.DataFrame | dict[str, Any] | list[dict[str, Any]]:
|
|
171
|
+
"""Collect up to *max_messages* realtime events, then return them.
|
|
172
|
+
|
|
173
|
+
REAL events are decoded through *columns* and collected as data rows; other
|
|
174
|
+
frames (REG/SYSTEM/...) are printed and collected separately. Collection
|
|
175
|
+
stops at *max_messages* data rows or on a terminal frame.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
client: A connectable realtime WebSocket client.
|
|
179
|
+
api_url: Realtime WebSocket API path.
|
|
180
|
+
body: The ``REG`` packet to send.
|
|
181
|
+
columns: Optional ``{fid: label}`` map applied to realtime values.
|
|
182
|
+
output: ``"dataframe"`` or ``"json"``.
|
|
183
|
+
max_messages: Maximum number of REAL events to collect.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
``{"data": ..., "system": ...}`` (DataFrames) or the JSON equivalent.
|
|
187
|
+
"""
|
|
188
|
+
if max_messages < 1:
|
|
189
|
+
raise ValueError("max_messages must be greater than 0")
|
|
190
|
+
column_map = dict(columns or {})
|
|
191
|
+
rows: list[dict[str, Any]] = []
|
|
192
|
+
system_rows: list[dict[str, Any]] = []
|
|
193
|
+
try:
|
|
194
|
+
await client.subscribe(api_url=api_url, body=body)
|
|
195
|
+
|
|
196
|
+
async for message in client.iter_messages():
|
|
197
|
+
for event in normalize_message_events(message, column_map):
|
|
198
|
+
if str(event.get("trnm", "")).upper() == "REAL":
|
|
199
|
+
rows.append(
|
|
200
|
+
{key: value for key, value in event.items() if key != "trnm"}
|
|
201
|
+
)
|
|
202
|
+
if len(rows) >= max_messages:
|
|
203
|
+
return _format_collected(rows, system_rows, output)
|
|
204
|
+
continue
|
|
205
|
+
|
|
206
|
+
system_rows.append(event)
|
|
207
|
+
print(f"[{event.get('trnm') or 'MESSAGE'}]", event, flush=True)
|
|
208
|
+
if is_terminal_event(event):
|
|
209
|
+
return _format_collected(rows, system_rows, output)
|
|
210
|
+
finally:
|
|
211
|
+
await client.close()
|
|
212
|
+
|
|
213
|
+
return _format_collected(rows, system_rows, output)
|
kiwoom/specs.py
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from importlib.resources import files
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
DEFAULT_SPEC_PATH = files("kiwoom").joinpath("_data", "kiwoom_api_spec.json")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_api_specs(spec_path: Path | None = None) -> dict[str, dict[str, Any]]:
|
|
11
|
+
resolved_path = spec_path or DEFAULT_SPEC_PATH
|
|
12
|
+
payload = json.loads(resolved_path.read_text(encoding="utf-8"))
|
|
13
|
+
return {
|
|
14
|
+
str(api_payload.get("meta", {}).get("API ID", "")).strip(): api_payload
|
|
15
|
+
for api_payload in payload.get("apis", {}).values()
|
|
16
|
+
if str(api_payload.get("meta", {}).get("API ID", "")).strip()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_search_entries(spec_path: Path | None = None) -> list[dict[str, object]]:
|
|
21
|
+
entries: list[dict[str, object]] = []
|
|
22
|
+
|
|
23
|
+
for api_payload in load_api_specs(spec_path).values():
|
|
24
|
+
meta = api_payload.get("meta", {})
|
|
25
|
+
request = api_payload.get("request", {})
|
|
26
|
+
request_terms: list[str] = []
|
|
27
|
+
|
|
28
|
+
for item in request.get("header", []):
|
|
29
|
+
request_terms.extend(_collect_item_terms(item))
|
|
30
|
+
|
|
31
|
+
for item in request.get("body", []):
|
|
32
|
+
request_terms.extend(_collect_item_terms(item))
|
|
33
|
+
|
|
34
|
+
response = api_payload.get("response", {})
|
|
35
|
+
response_terms: list[str] = []
|
|
36
|
+
|
|
37
|
+
for item in response.get("body", []):
|
|
38
|
+
if str(item.get("element", "")).strip() in _COMMON_RESPONSE_FIELDS:
|
|
39
|
+
continue
|
|
40
|
+
response_terms.extend(_collect_item_terms(item))
|
|
41
|
+
|
|
42
|
+
entries.append(
|
|
43
|
+
{
|
|
44
|
+
"api_id": str(meta.get("API ID", "")).strip(),
|
|
45
|
+
"api_name": str(meta.get("API 명", "")).strip(),
|
|
46
|
+
"menu_path": str(meta.get("메뉴 위치", "")).strip(),
|
|
47
|
+
"url": str(meta.get("URL", "")).strip(),
|
|
48
|
+
"method": str(meta.get("Method", "")).strip(),
|
|
49
|
+
"request_terms": list(dict.fromkeys(term for term in request_terms if term)),
|
|
50
|
+
"response_terms": list(dict.fromkeys(term for term in response_terms if term)),
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return entries
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def get_api_spec(api_id: str, *, spec_path: Path | None = None) -> dict[str, Any]:
|
|
58
|
+
normalized_api_id = api_id.strip()
|
|
59
|
+
specs = load_api_specs(spec_path)
|
|
60
|
+
try:
|
|
61
|
+
return specs[normalized_api_id]
|
|
62
|
+
except KeyError as exc:
|
|
63
|
+
raise ValueError(f"알 수 없는 Kiwoom API ID입니다: {normalized_api_id}") from exc
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def list_api_groups(*, spec_path: Path | None = None) -> list[dict[str, object]]:
|
|
67
|
+
counts: dict[tuple[str, str], int] = {}
|
|
68
|
+
for api_payload in load_api_specs(spec_path).values():
|
|
69
|
+
major, subcategory = _split_menu_path(str(api_payload.get("meta", {}).get("메뉴 위치", "")))
|
|
70
|
+
key = (major, subcategory)
|
|
71
|
+
counts[key] = counts.get(key, 0) + 1
|
|
72
|
+
return [
|
|
73
|
+
{"major_category": major, "subcategory": subcategory, "count": count}
|
|
74
|
+
for (major, subcategory), count in sorted(counts.items())
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def list_api_summaries(
|
|
79
|
+
*,
|
|
80
|
+
group: str | None = None,
|
|
81
|
+
spec_path: Path | None = None,
|
|
82
|
+
limit: int | None = None,
|
|
83
|
+
) -> list[dict[str, object]]:
|
|
84
|
+
normalized_group = group.strip().casefold() if group else None
|
|
85
|
+
summaries: list[dict[str, object]] = []
|
|
86
|
+
for api_payload in load_api_specs(spec_path).values():
|
|
87
|
+
meta = api_payload.get("meta", {})
|
|
88
|
+
menu_path = str(meta.get("메뉴 위치", "")).strip()
|
|
89
|
+
if normalized_group and normalized_group not in menu_path.casefold():
|
|
90
|
+
continue
|
|
91
|
+
summaries.append(
|
|
92
|
+
{
|
|
93
|
+
"api_id": str(meta.get("API ID", "")).strip(),
|
|
94
|
+
"api_name": str(meta.get("API 명", "")).strip(),
|
|
95
|
+
"menu_path": menu_path,
|
|
96
|
+
"method": str(meta.get("Method", "")).strip(),
|
|
97
|
+
"url": str(meta.get("URL", "")).strip(),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
summaries.sort(key=lambda item: str(item["api_id"]))
|
|
101
|
+
if limit is not None:
|
|
102
|
+
return summaries[:limit]
|
|
103
|
+
return summaries
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def search_api_specs(query: str, *, spec_path: Path | None = None, limit: int = 10) -> list[dict[str, object]]:
|
|
107
|
+
return search_entries(load_search_entries(spec_path), query=query, limit=limit)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def search_entries(entries: list[dict[str, object]], *, query: str, limit: int = 10) -> list[dict[str, object]]:
|
|
111
|
+
if limit <= 0:
|
|
112
|
+
raise ValueError("limit 값은 0보다 커야 합니다.")
|
|
113
|
+
|
|
114
|
+
normalized_query = query.strip().casefold()
|
|
115
|
+
if not normalized_query:
|
|
116
|
+
return []
|
|
117
|
+
|
|
118
|
+
query_tokens = [token for token in normalized_query.split() if token]
|
|
119
|
+
scored: list[tuple[int, dict[str, object]]] = []
|
|
120
|
+
|
|
121
|
+
for entry in entries:
|
|
122
|
+
score = _score_entry(entry, normalized_query, query_tokens)
|
|
123
|
+
if score <= 0:
|
|
124
|
+
continue
|
|
125
|
+
scored.append((score, entry))
|
|
126
|
+
|
|
127
|
+
scored.sort(key=lambda item: (-item[0], str(item[1]["api_id"])))
|
|
128
|
+
return [entry for _, entry in scored[:limit]]
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def format_search_results(results: list[dict[str, object]]) -> str:
|
|
132
|
+
if not results:
|
|
133
|
+
return "검색 결과가 없습니다."
|
|
134
|
+
|
|
135
|
+
lines: list[str] = []
|
|
136
|
+
for entry in results:
|
|
137
|
+
request_terms = ", ".join(str(term) for term in entry["request_terms"][:6])
|
|
138
|
+
response_terms = ", ".join(str(term) for term in entry.get("response_terms", [])[:6])
|
|
139
|
+
lines.extend(
|
|
140
|
+
[
|
|
141
|
+
f'{entry["api_id"]} | {entry["api_name"]}',
|
|
142
|
+
f' 메뉴: {entry["menu_path"]}',
|
|
143
|
+
f' 호출: {entry["method"]} {entry["url"]}',
|
|
144
|
+
f" 요청 키워드: {request_terms or '-'}",
|
|
145
|
+
f" 응답 키워드: {response_terms or '-'}",
|
|
146
|
+
]
|
|
147
|
+
)
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def format_api_groups(groups: list[dict[str, object]]) -> str:
|
|
152
|
+
if not groups:
|
|
153
|
+
return "API 그룹이 없습니다."
|
|
154
|
+
return "\n".join(
|
|
155
|
+
f'{group["major_category"]} > {group["subcategory"]}: {group["count"]}'
|
|
156
|
+
for group in groups
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def format_api_summaries(summaries: list[dict[str, object]]) -> str:
|
|
161
|
+
if not summaries:
|
|
162
|
+
return "API 목록이 없습니다."
|
|
163
|
+
return "\n".join(
|
|
164
|
+
f'{entry["api_id"]} | {entry["api_name"]} | {entry["method"]} {entry["url"]} | {entry["menu_path"]}'
|
|
165
|
+
for entry in summaries
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def format_api_spec(api_payload: dict[str, Any]) -> str:
|
|
170
|
+
meta = api_payload.get("meta", {})
|
|
171
|
+
request = api_payload.get("request", {})
|
|
172
|
+
response = api_payload.get("response", {})
|
|
173
|
+
lines = [
|
|
174
|
+
f'{str(meta.get("API ID", "")).strip()} | {str(meta.get("API 명", "")).strip()}',
|
|
175
|
+
f' 메뉴: {str(meta.get("메뉴 위치", "")).strip()}',
|
|
176
|
+
f' 호출: {str(meta.get("Method", "")).strip()} {str(meta.get("URL", "")).strip()}',
|
|
177
|
+
f" 요청 헤더 필수: {_format_fields(_required_items(request.get('header')))}",
|
|
178
|
+
f" 요청 바디 필수: {_format_fields(_required_items(request.get('body')))}",
|
|
179
|
+
f" 요청 바디 선택: {_format_fields(_optional_items(request.get('body')))}",
|
|
180
|
+
f" 응답 바디: {_format_fields(response.get('body', []))}",
|
|
181
|
+
]
|
|
182
|
+
return "\n".join(lines)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
_COMMON_HEADER_FIELDS = frozenset({"api-id", "authorization", "cont-yn", "next-key"})
|
|
186
|
+
_COMMON_RESPONSE_FIELDS = frozenset({"return_code", "return_msg", "trnm", "data", "type", "name", "item", "values"})
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _split_menu_path(menu_path: str) -> tuple[str, str]:
|
|
190
|
+
parts = [part.strip() for part in menu_path.split(">") if part.strip()]
|
|
191
|
+
if not parts:
|
|
192
|
+
return ("미분류", "미분류")
|
|
193
|
+
if len(parts) == 1:
|
|
194
|
+
return (parts[0], "미분류")
|
|
195
|
+
return (parts[0], parts[1])
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _required_items(items: list[dict] | None) -> list[dict]:
|
|
199
|
+
return [item for item in items or [] if item.get("required") == "Y"]
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _optional_items(items: list[dict] | None) -> list[dict]:
|
|
203
|
+
return [item for item in items or [] if item.get("required") != "Y"]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _format_fields(items: list[dict]) -> str:
|
|
207
|
+
fields = []
|
|
208
|
+
for item in items:
|
|
209
|
+
element = str(item.get("element", "")).strip()
|
|
210
|
+
if not element:
|
|
211
|
+
continue
|
|
212
|
+
korean_name = str(item.get("한글명", "")).strip()
|
|
213
|
+
field_type = str(item.get("type", "")).strip()
|
|
214
|
+
label = element
|
|
215
|
+
if korean_name:
|
|
216
|
+
label = f"{label}({korean_name})"
|
|
217
|
+
if field_type:
|
|
218
|
+
label = f"{label}:{field_type}"
|
|
219
|
+
fields.append(label)
|
|
220
|
+
return ", ".join(fields) if fields else "-"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _collect_item_terms(item: dict) -> list[str]:
|
|
224
|
+
element = str(item.get("element", "")).strip()
|
|
225
|
+
if element in _COMMON_HEADER_FIELDS:
|
|
226
|
+
return []
|
|
227
|
+
terms = [element, str(item.get("한글명", "")).strip()]
|
|
228
|
+
description = _normalize_description(str(item.get("description", "")).strip())
|
|
229
|
+
if description and len(description) <= 30:
|
|
230
|
+
terms.append(description)
|
|
231
|
+
return terms
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _score_entry(entry: dict[str, object], normalized_query: str, query_tokens: list[str]) -> int:
|
|
235
|
+
api_id = str(entry["api_id"])
|
|
236
|
+
api_name = str(entry["api_name"])
|
|
237
|
+
menu_path = str(entry["menu_path"])
|
|
238
|
+
url = str(entry["url"])
|
|
239
|
+
request_terms = [str(term) for term in entry["request_terms"]]
|
|
240
|
+
response_terms = [str(term) for term in entry.get("response_terms", [])]
|
|
241
|
+
|
|
242
|
+
score = 0
|
|
243
|
+
if api_id.casefold() == normalized_query:
|
|
244
|
+
score += 1000
|
|
245
|
+
if normalized_query in api_id.casefold():
|
|
246
|
+
score += 250
|
|
247
|
+
if normalized_query in api_name.casefold():
|
|
248
|
+
score += 180
|
|
249
|
+
if normalized_query in menu_path.casefold():
|
|
250
|
+
score += 120
|
|
251
|
+
if normalized_query in url.casefold():
|
|
252
|
+
score += 80
|
|
253
|
+
|
|
254
|
+
for term in request_terms:
|
|
255
|
+
if normalized_query in term.casefold():
|
|
256
|
+
score += 40
|
|
257
|
+
|
|
258
|
+
for term in response_terms:
|
|
259
|
+
if normalized_query in term.casefold():
|
|
260
|
+
score += 30
|
|
261
|
+
|
|
262
|
+
searchable_text = " ".join([api_id, api_name, menu_path, url, *request_terms, *response_terms]).casefold()
|
|
263
|
+
if query_tokens and all(token in searchable_text for token in query_tokens):
|
|
264
|
+
score += 60
|
|
265
|
+
|
|
266
|
+
return score
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _normalize_description(description: str) -> str:
|
|
270
|
+
if not description:
|
|
271
|
+
return ""
|
|
272
|
+
return " ".join(part.strip() for part in description.splitlines() if part.strip())
|