mx_data.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407
  1. #!/usr/bin/env python3
  2. # mx_data - 妙想金融数据查询 skill
  3. # 基于东方财富妙想API提供金融数据查询能力
  4. # 输出 Excel(多sheet)+ 描述 txt
  5. import os
  6. import sys
  7. import json
  8. import re
  9. import pandas as pd
  10. import requests
  11. from pathlib import Path
  12. from typing import Dict, List, Optional, Any, Tuple
  13. def safe_filename(s: str, max_len: int = 80) -> str:
  14. """Convert query string to safe filename """
  15. s = re.sub(r'[<>:"/\\|?*\[\]]', "_", s)
  16. s = s.strip().replace(" ", "_")[:max_len]
  17. return s or "query"
  18. def flatten_value(v: Any) -> str:
  19. """Flatten any value to string """
  20. if v is None:
  21. return ""
  22. if isinstance(v, (dict, list)):
  23. return json.dumps(v, ensure_ascii=False)
  24. return str(v)
  25. def ordered_keys(table: Dict[str, Any], indicator_order: List[Any]) -> List[Any]:
  26. """Order keys """
  27. data_keys = [k for k in table.keys() if k != "headName"]
  28. key_map = {str(k): k for k in data_keys}
  29. preferred: List[Any] = []
  30. seen: set[str] = set()
  31. for key in indicator_order:
  32. key_str = str(key)
  33. if key_str in key_map and key_str not in seen:
  34. preferred.append(key_map[key_str])
  35. seen.add(key_str)
  36. for key in data_keys:
  37. key_str = str(key)
  38. if key_str not in seen:
  39. preferred.append(key)
  40. seen.add(key_str)
  41. return preferred
  42. def normalize_values(raw_values: List[Any], expected_len: int) -> List[str]:
  43. """Normalize values length """
  44. values = [flatten_value(v) for v in raw_values]
  45. if len(values) < expected_len:
  46. values.extend([""] * (expected_len - len(values)))
  47. return values[:expected_len]
  48. def return_code_map(block: Dict[str, Any]) -> Dict[str, str]:
  49. """Get return code map"""
  50. for key in ("returnCodeMap", "returnCodeNameMap", "codeMap"):
  51. data = block.get(key)
  52. if isinstance(data, dict):
  53. return {str(k): flatten_value(v) for k, v in data.items()}
  54. return {}
  55. def format_indicator_label(key: str, name_map: Dict[str, Any], code_map: Dict[str, str]) -> str:
  56. """Format indicator label"""
  57. mapped = name_map.get(key)
  58. if mapped is None and key.isdigit():
  59. mapped = name_map.get(int(key))
  60. if mapped not in (None, ""):
  61. return flatten_value(mapped)
  62. mapped_code = code_map.get(key)
  63. if mapped_code not in (None, ""):
  64. return flatten_value(mapped_code)
  65. if key.isdigit():
  66. return ""
  67. return key
  68. def table_to_rows(block: Dict[str, Any]) -> Tuple[List[Dict[str, str]], List[str]]:
  69. """
  70. Convert table to rows - adapted for MX API format:
  71. MX API format: headName is date array, each indicator is an array matching headName length
  72. This is DATE-ON-ROW format, each row is a date
  73. """
  74. table = block.get("table") or {}
  75. name_map = block.get("nameMap") or {}
  76. if isinstance(name_map, list):
  77. name_map = {str(i): v for i, v in enumerate(name_map)}
  78. elif not isinstance(name_map, dict):
  79. name_map = {}
  80. if not isinstance(table, dict):
  81. # Fallback for generic tables
  82. if isinstance(table, list):
  83. if not table:
  84. return [], []
  85. if isinstance(table[0], dict):
  86. rows = table
  87. else:
  88. rows = [
  89. dict(zip([f"column_{i}" for i in range(len(table[0]))], row))
  90. for row in table
  91. ]
  92. else:
  93. return [], []
  94. return [{name_map.get(k, k): flatten_value(v) for k, v in row.items()} for row in rows], list(rows[0].keys())
  95. headers = table.get("headName") or []
  96. if not isinstance(headers, list):
  97. headers = []
  98. order = ordered_keys(table, block.get("indicatorOrder") or [])
  99. entity_name = flatten_value(block.get("entityName") or "") or "指标"
  100. code_map = return_code_map(block)
  101. rows: List[Dict[str, str]] = []
  102. data_key_count = len([key for key in table.keys() if key != "headName"])
  103. # MX API format: headName is the date column (one row = one date)
  104. # headName = [date1, date2, date3, ...]
  105. # indicator = [val1, val2, val3, ...]
  106. # So each row is a date with all indicator values
  107. if len(headers) > 0: # has dates (one date per row)
  108. fieldnames = ["date"] # first column is date from headName
  109. for key in order:
  110. if key != "headName":
  111. label = format_indicator_label(str(key), name_map, code_map)
  112. if label: # only add non-empty labels
  113. fieldnames.append(label)
  114. # Build one row per date
  115. for row_idx, date in enumerate(headers):
  116. row = {"date": flatten_value(date)}
  117. for key in order:
  118. if key == "headName":
  119. continue
  120. label = format_indicator_label(str(key), name_map, code_map)
  121. if not label:
  122. continue
  123. raw_values = table.get(key, [])
  124. value = raw_values[row_idx] if row_idx < len(raw_values) else ""
  125. row[label] = flatten_value(value)
  126. rows.append(row)
  127. return rows, fieldnames
  128. if len(headers) == 1 and data_key_count >= 1:
  129. # Single date (current quote)
  130. fieldnames = [entity_name, flatten_value(headers[0])]
  131. for key in order:
  132. raw_values = table.get(key, [])
  133. value = raw_values[0] if isinstance(raw_values, list) and raw_values else raw_values
  134. label = format_indicator_label(str(key), name_map, code_map)
  135. rows.append({fieldnames[0]: label, fieldnames[1]: flatten_value(value)})
  136. return rows, fieldnames
  137. # headName 为空但各指标为等长数组:按索引展开成行(十大股东等无日期维度场景)
  138. if len(headers) == 0 and data_key_count >= 1:
  139. col_labels: List[str] = []
  140. col_arrays: List[List[Any]] = []
  141. for key in order:
  142. if key == "headName":
  143. continue
  144. raw_values = table.get(key, [])
  145. if not isinstance(raw_values, list):
  146. raw_values = [raw_values] if raw_values not in (None, "") else []
  147. lab = format_indicator_label(str(key), name_map, code_map)
  148. if not lab:
  149. lab = str(key)
  150. col_labels.append(lab)
  151. col_arrays.append(raw_values)
  152. if col_labels:
  153. n_rows = max((len(a) for a in col_arrays), default=0)
  154. if n_rows > 0:
  155. wide_rows: List[Dict[str, str]] = []
  156. for i in range(n_rows):
  157. row_d: Dict[str, str] = {}
  158. for lab, arr in zip(col_labels, col_arrays):
  159. row_d[lab] = flatten_value(arr[i]) if i < len(arr) else ""
  160. wide_rows.append(row_d)
  161. return wide_rows, col_labels
  162. # Fallback
  163. return [], []
  164. class MXData:
  165. """妙想金融数据查询客户端"""
  166. BASE_URL = "https://mkapi2.dfcfs.com/finskillshub/api/claw/query"
  167. def __init__(self, api_key: Optional[str] = None):
  168. """
  169. 初始化客户端
  170. :param api_key: MX API Key,如果不提供则从环境变量 MX_APIKEY 读取
  171. """
  172. self.api_key = api_key or os.getenv("MX_APIKEY")
  173. if not self.api_key:
  174. raise ValueError(
  175. "MX_APIKEY 环境变量未设置,请先设置环境变量:\n"
  176. "export MX_APIKEY=your_api_key_here\n"
  177. "或者在初始化时传入 api_key 参数"
  178. )
  179. def query(self, tool_query: str) -> Dict[str, Any]:
  180. """
  181. 查询金融数据
  182. :param tool_query: 自然语言查询问句,如 "东方财富最新价"
  183. :return: API 响应结果
  184. """
  185. headers = {
  186. "Content-Type": "application/json",
  187. "apikey": self.api_key
  188. }
  189. data = {
  190. "toolQuery": tool_query
  191. }
  192. response = requests.post(self.BASE_URL, headers=headers, json=data, timeout=30)
  193. response.raise_for_status()
  194. return response.json()
  195. @staticmethod
  196. def parse_result(result: Dict[str, Any]) -> Tuple[List[Dict[str, Any]], List[str], int, Optional[str]]:
  197. """
  198. Parse API result into tables for Excel output
  199. :return: (tables, condition_parts, total_rows, error)
  200. tables: [{"sheet_name": str, "rows": list[dict], "fieldnames": list[str]}]
  201. """
  202. status = result.get("status")
  203. message = result.get("message", "")
  204. if status != 0:
  205. return [], [], 0, f"顶层错误: 状态码 {status} - {message}"
  206. data = result.get("data", {})
  207. inner_data = data.get("data", {})
  208. search_result = inner_data.get("searchDataResultDTO", {})
  209. dto_list = search_result.get("dataTableDTOList", [])
  210. if not dto_list:
  211. return [], [], 0, "接口返回中无 dataTableDTOList"
  212. condition_parts: List[str] = []
  213. tables: List[Dict[str, Any]] = []
  214. total_rows = 0
  215. for i, dto in enumerate(dto_list):
  216. if not isinstance(dto, dict):
  217. continue
  218. sheet_name = safe_filename(
  219. dto.get("title") or dto.get("inputTitle") or dto.get("entityName") or f"表{i + 1}"
  220. )
  221. condition = dto.get("condition")
  222. if condition is not None and condition != "":
  223. entity = dto.get("entityName") or sheet_name
  224. condition_parts.append(f"[{entity}]\n{condition}")
  225. rows, fieldnames = table_to_rows(dto)
  226. if not rows:
  227. raw_tbl = dto.get("rawTable")
  228. if isinstance(raw_tbl, dict) and raw_tbl:
  229. alt = dict(dto)
  230. alt["table"] = raw_tbl
  231. rows, fieldnames = table_to_rows(alt)
  232. if not rows:
  233. continue
  234. tables.append({"sheet_name": sheet_name, "rows": rows, "fieldnames": fieldnames})
  235. total_rows += len(rows)
  236. if not tables:
  237. return [], condition_parts, 0, "dataTableDTOList 中无有效 table 数据"
  238. return tables, condition_parts, total_rows, None
  239. @staticmethod
  240. def format_terminal(result: Dict[str, Any], tables: List[Dict[str, Any]], total_rows: int) -> str:
  241. """Format result for terminal display"""
  242. output = []
  243. status = result.get("status")
  244. message = result.get("message", "")
  245. if status != 0:
  246. output.append(f"**错误**: 状态码 {status} - {message}")
  247. return "\n".join(output)
  248. data = result.get("data", {})
  249. inner_data = data.get("data", {})
  250. search_result = inner_data.get("searchDataResultDTO", {})
  251. entity_tags = search_result.get("entityTagDTOList", [])
  252. if entity_tags:
  253. output.append("**查询证券**:")
  254. entities = []
  255. for tag in entity_tags:
  256. name = tag.get("fullName", "")
  257. code = tag.get("secuCode", "")
  258. type_name = tag.get("entityTypeName", "")
  259. entities.append(f"- {name} ({code}) - {type_name}")
  260. output.append("\n".join(entities))
  261. output.append("")
  262. output.append(f"**查询结果**: {len(tables)} 个表,共 {total_rows} 行数据\n")
  263. # Only preview first 20 rows of first table on terminal
  264. if tables:
  265. first = tables[0]
  266. output.append(f"**{first['sheet_name']}** (前20行预览):\n")
  267. rows = first["rows"][:20]
  268. if rows:
  269. fieldnames = first["fieldnames"]
  270. output.append("| " + " | ".join(fieldnames) + " |")
  271. output.append("| " + " | ".join(["---"] * len(fieldnames)) + " |")
  272. for row in rows:
  273. cells = [str(row.get(f, "")) for f in fieldnames]
  274. output.append("| " + " | ".join(cells) + " |")
  275. if len(first["rows"]) > 20:
  276. output.append(f"| ... | " * (len(fieldnames) - 1) + "... |")
  277. output.append("")
  278. question_id = search_result.get("questionId", "")
  279. if question_id:
  280. output.append(f"*查询ID: {question_id}*")
  281. return "\n".join(output)
  282. @staticmethod
  283. def write_output_files(
  284. query_text: str,
  285. output_dir: Path,
  286. tables: List[Dict[str, Any]],
  287. total_rows: int,
  288. condition_parts: List[str],
  289. ) -> Tuple[Path, Path]:
  290. """Write output Excel (multiple sheets) and description"""
  291. safe_name = safe_filename(query_text)
  292. file_path = output_dir / f"mx_data_{safe_name}.xlsx"
  293. desc_path = output_dir / f"mx_data_{safe_name}_description.txt"
  294. # Write all tables to a single Excel file with multiple sheets
  295. with pd.ExcelWriter(file_path, engine="openpyxl") as writer:
  296. for table in tables:
  297. df = pd.DataFrame(table["rows"], columns=table["fieldnames"])
  298. df.to_excel(writer, sheet_name=table["sheet_name"], index=False)
  299. # Write description file
  300. description_lines = [
  301. "金融数据查询结果说明",
  302. "=" * 40,
  303. f"查询内容: {query_text}",
  304. f"数据文件路径: {file_path}",
  305. f"描述文件路径: {desc_path}",
  306. f"数据行数: {total_rows}",
  307. f"表数量: {len(tables)}",
  308. f"Sheet 列表: {', '.join([t['sheet_name'] for t in tables])}",
  309. ]
  310. if condition_parts:
  311. description_lines.append("")
  312. description_lines.append("筛选条件:")
  313. description_lines.extend(condition_parts)
  314. desc_path.write_text("\n".join(description_lines), encoding="utf-8")
  315. return file_path, desc_path
  316. def main():
  317. """命令行入口 - 保持与 MX_FinData 一致的使用方式,输出 Excel 多 sheet"""
  318. if len(sys.argv) < 2:
  319. print(f"用法: {sys.argv[0]} \"查询问句\" [输出目录]")
  320. print(f"默认输出目录: /root/.openclaw/workspace/mx_data/output/")
  321. print("示例: python mx_data.py \"同花顺最近3年每天的最新价\"")
  322. sys.exit(1)
  323. # Build query
  324. if len(sys.argv) >= 3:
  325. query = " ".join(sys.argv[1:-1])
  326. output_dir = Path(sys.argv[-1])
  327. else:
  328. query = " ".join(sys.argv[1:])
  329. # Default output to fixed directory
  330. output_dir = Path("/root/.openclaw/workspace/mx_data/output")
  331. # Ensure output directory exists
  332. output_dir.mkdir(parents=True, exist_ok=True)
  333. try:
  334. mx = MXData()
  335. result = mx.query(query)
  336. tables, condition_parts, total_rows, err = mx.parse_result(result)
  337. if err:
  338. print(f"错误: {err}")
  339. sys.exit(1)
  340. # Terminal preview
  341. print(mx.format_terminal(result, tables, total_rows))
  342. # Write Excel (multiple sheets) + description txt
  343. file_path, desc_path = mx.write_output_files(query, output_dir, tables, total_rows, condition_parts)
  344. print(f"\n✅ Excel 文件: {file_path}")
  345. print(f"📄 描述文件: {desc_path}")
  346. print(f"📊 总行数: {total_rows}, 表数: {len(tables)}")
  347. # Save original JSON
  348. json_filename = output_dir / f"mx_data_{safe_filename(query)}_raw.json"
  349. with open(json_filename, "w", encoding="utf-8") as f:
  350. json.dump(result, f, ensure_ascii=False, indent=2)
  351. print(f"📄 原始JSON: {json_filename}")
  352. except Exception as e:
  353. print(f"错误: {str(e)}", file=sys.stderr)
  354. sys.exit(1)
  355. if __name__ == "__main__":
  356. main()