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.
Files changed (62) hide show
  1. kiwoom/__init__.py +47 -0
  2. kiwoom/_data/kiwoom_api_spec.json +66372 -0
  3. kiwoom/core/__init__.py +6 -0
  4. kiwoom/core/auth.py +349 -0
  5. kiwoom/core/client.py +244 -0
  6. kiwoom/core/errors.py +177 -0
  7. kiwoom/core/platform_paths.py +68 -0
  8. kiwoom/core/profiles.py +143 -0
  9. kiwoom/core/runtime.py +153 -0
  10. kiwoom/core/secrets.py +217 -0
  11. kiwoom/core/settings.py +64 -0
  12. kiwoom/core/token_store.py +186 -0
  13. kiwoom/core/types.py +28 -0
  14. kiwoom/core/ws_client.py +262 -0
  15. kiwoom/realtime/__init__.py +30 -0
  16. kiwoom/realtime/decoders.py +171 -0
  17. kiwoom/realtime/events.py +98 -0
  18. kiwoom/realtime/packets.py +65 -0
  19. kiwoom/realtime/schemas.py +76 -0
  20. kiwoom/realtime/stream.py +213 -0
  21. kiwoom/specs.py +272 -0
  22. kiwoom_cli/README.md +671 -0
  23. kiwoom_cli/__init__.py +9 -0
  24. kiwoom_cli/__main__.py +5 -0
  25. kiwoom_cli/argument_maps.py +561 -0
  26. kiwoom_cli/arguments.py +147 -0
  27. kiwoom_cli/auth_context.py +64 -0
  28. kiwoom_cli/banner.py +125 -0
  29. kiwoom_cli/commands/__init__.py +1 -0
  30. kiwoom_cli/commands/auth.py +406 -0
  31. kiwoom_cli/commands/groups.py +281 -0
  32. kiwoom_cli/commands/mapped.py +74 -0
  33. kiwoom_cli/commands/orders.py +158 -0
  34. kiwoom_cli/commands/spec.py +98 -0
  35. kiwoom_cli/commands/stocks.py +100 -0
  36. kiwoom_cli/commands/streams.py +470 -0
  37. kiwoom_cli/doctor.py +324 -0
  38. kiwoom_cli/errors.py +24 -0
  39. kiwoom_cli/executor/__init__.py +33 -0
  40. kiwoom_cli/executor/condition.py +374 -0
  41. kiwoom_cli/executor/rest.py +132 -0
  42. kiwoom_cli/executor/waits.py +34 -0
  43. kiwoom_cli/executor/websocket.py +131 -0
  44. kiwoom_cli/main.py +203 -0
  45. kiwoom_cli/maps/README.md +99 -0
  46. kiwoom_cli/maps/api_commands.csv +209 -0
  47. kiwoom_cli/maps/arguments.csv +731 -0
  48. kiwoom_cli/maps/order_confirmation_commands.csv +13 -0
  49. kiwoom_cli/maps/order_confirmation_fields.csv +71 -0
  50. kiwoom_cli/maps/order_price_policies.csv +47 -0
  51. kiwoom_cli/maps/order_value_labels.csv +28 -0
  52. kiwoom_cli/maps/positional_arguments.csv +21 -0
  53. kiwoom_cli/order_confirmation.py +167 -0
  54. kiwoom_cli/output.py +136 -0
  55. kiwoom_cli/registry.py +95 -0
  56. kiwoom_cli/safety.py +30 -0
  57. kiwoom_cli/setup.py +533 -0
  58. kwcli-0.1.0.dist-info/METADATA +215 -0
  59. kwcli-0.1.0.dist-info/RECORD +62 -0
  60. kwcli-0.1.0.dist-info/WHEEL +4 -0
  61. kwcli-0.1.0.dist-info/entry_points.txt +2 -0
  62. 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())