mx_xuangu.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260
  1. #!/usr/bin/env python3
  2. # mx_xuangu - 妙想智能选股 skill
  3. # 基于东方财富妙想API提供智能选股能力
  4. import os
  5. import sys
  6. import json
  7. import csv
  8. import re
  9. import argparse
  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. """将查询文本转为安全文件名片段"""
  15. s = re.sub(r'[<>:"/\\|?*]', "_", s)
  16. s = s.strip().replace(" ", "_")[:max_len]
  17. return s or "query"
  18. def build_column_map(columns: List[Dict[str, Any]]) -> Dict[str, str]:
  19. """
  20. 从返回的 columns 构建 原始列名 -> 中文列名 的映射
  21. """
  22. name_map: Dict[str, str] = {}
  23. for col in columns or []:
  24. if not isinstance(col, dict):
  25. continue
  26. en_key = col.get("field", "") or col.get("name", "") or col.get("key", "")
  27. cn_name = col.get("displayName", "") or col.get("title", "") or col.get("label", "")
  28. date_msg = col.get('dateMsg', '')
  29. if date_msg:
  30. cn_name = cn_name + ' ' + date_msg
  31. if en_key is not None and cn_name is not None:
  32. name_map[str(en_key)] = str(cn_name)
  33. return name_map
  34. def columns_order(columns: List[Dict[str, Any]]) -> List[str]:
  35. """按 columns 顺序返回原始列名列表,用于 CSV 表头顺序"""
  36. order: List[str] = []
  37. for col in columns or []:
  38. if not isinstance(col, dict):
  39. continue
  40. en_key = col.get("field") or col.get("name") or col.get("key")
  41. if en_key is not None:
  42. order.append(str(en_key))
  43. return order
  44. def parse_partial_results_table(partial_results: str) -> List[Dict[str, str]]:
  45. """
  46. 将 partialResults 的 Markdown 表格字符串解析为行字典列表
  47. """
  48. if not partial_results or not isinstance(partial_results, str):
  49. return []
  50. lines = [ln.strip() for ln in partial_results.strip().splitlines() if ln.strip()]
  51. if not lines:
  52. return []
  53. def split_cells(line: str) -> List[str]:
  54. return [c.strip() for c in line.split("|") if c.strip() != ""]
  55. header_cells = split_cells(lines[0])
  56. if not header_cells:
  57. return []
  58. # 跳过分隔行(如 |---|---|)
  59. data_start = 1
  60. if data_start < len(lines) and re.match(r"^[\s\|\-]+$", lines[data_start]):
  61. data_start = 2
  62. rows: List[Dict[str, str]] = []
  63. for i in range(data_start, len(lines)):
  64. cells = split_cells(lines[i])
  65. if len(cells) != len(header_cells):
  66. # 列数不一致时按长度对齐,缺的补空
  67. if len(cells) < len(header_cells):
  68. cells.extend([""] * (len(header_cells) - len(cells)))
  69. else:
  70. cells = cells[: len(header_cells)]
  71. rows.append(dict(zip(header_cells, cells)))
  72. return rows
  73. def datalist_to_rows(
  74. datalist: List[Dict[str, Any]],
  75. column_map: Dict[str, str],
  76. column_order: List[str],
  77. ) -> List[Dict[str, str]]:
  78. """
  79. 将 datalist 中每行的原始键按 column_map 替换为中文键,保证顺序
  80. """
  81. if not datalist:
  82. return []
  83. # 表头顺序:优先按 columns 顺序,未在 columns 中的键按首次出现顺序排在后面
  84. first = datalist[0]
  85. extra_keys = [k for k in first if k not in column_order]
  86. header_order = column_order + extra_keys
  87. rows: List[Dict[str, str]] = []
  88. for row in datalist:
  89. if not isinstance(row, dict):
  90. continue
  91. cn_row: Dict[str, str] = {}
  92. for en_key in header_order:
  93. if en_key not in row:
  94. continue
  95. cn_name = column_map.get(en_key, en_key)
  96. val = row[en_key]
  97. if val is None:
  98. cn_row[cn_name] = ""
  99. elif isinstance(val, (dict, list)):
  100. cn_row[cn_name] = json.dumps(val, ensure_ascii=False)
  101. else:
  102. cn_row[cn_name] = str(val)
  103. rows.append(cn_row)
  104. return rows
  105. class MXSelectStock:
  106. """妙想智能选股客户端"""
  107. BASE_URL = "https://mkapi2.dfcfs.com/finskillshub/api/claw/stock-screen"
  108. def __init__(self, api_key: Optional[str] = None):
  109. """
  110. 初始化客户端
  111. :param api_key: MX API Key,如果不提供则从环境变量 MX_APIKEY 读取
  112. """
  113. self.api_key = api_key or os.getenv("MX_APIKEY")
  114. if not self.api_key:
  115. raise ValueError(
  116. "MX_APIKEY 环境变量未设置,请先设置环境变量:\n"
  117. "export MX_APIKEY=your_api_key_here\n"
  118. "或者在初始化时传入 api_key 参数"
  119. )
  120. def search(self, query: str) -> Dict[str, Any]:
  121. """
  122. 智能选股
  123. :param query: 自然语言查询,如 "今天A股价格大于10元"
  124. :return: API 响应结果
  125. """
  126. headers = {
  127. "Content-Type": "application/json",
  128. "apikey": self.api_key
  129. }
  130. data = {
  131. "keyword": query
  132. }
  133. response = requests.post(self.BASE_URL, headers=headers, json=data, timeout=30)
  134. response.raise_for_status()
  135. return response.json()
  136. @staticmethod
  137. def extract_data(result: Dict[str, Any]) -> Tuple[List[Dict[str, str]], str, Optional[str]]:
  138. """
  139. 提取数据 :
  140. - 优先使用 allResults.result.dataList 全量数据
  141. - 若无则回退到解析 partialResults Markdown 表格
  142. :return: (rows, data_source, error)
  143. """
  144. status = result.get("status")
  145. if status != 0:
  146. return [], "", f"顶层错误: 状态码 {status} - {result.get('message', '')}"
  147. data = result.get("data", {})
  148. inner_data = data.get("data", {})
  149. # 优先使用全量数据 dataList
  150. data_list = inner_data.get("allResults", {}).get("result", {}).get("dataList", [])
  151. columns = inner_data.get("allResults", {}).get("result", {}).get("columns", [])
  152. if isinstance(data_list, list) and data_list:
  153. column_map = build_column_map(columns)
  154. order = columns_order(columns)
  155. rows = datalist_to_rows(data_list, column_map, order)
  156. return rows, "dataList", None
  157. # 回退到 partialResults 解析
  158. partial_results = inner_data.get("partialResults", "")
  159. if partial_results:
  160. rows = parse_partial_results_table(partial_results)
  161. return rows, "partialResults", None
  162. return [], "", "返回中无有效 dataList 且 partialResults 无法解析或为空"
  163. def main():
  164. """命令行入口 """
  165. parser = argparse.ArgumentParser(description='通过自然语言查询进行智能选股(A股/港股/美股/板块/基金/ETF)')
  166. parser.add_argument('query', nargs='?', help='自然语言查询,如「股价大于10元的A股」')
  167. parser.add_argument('--query', dest='query_opt', help='自然语言查询(显式参数)')
  168. parser.add_argument('--output-dir', dest='output_dir', help='输出目录,默认 /root/.openclaw/workspace/mx_data/output/')
  169. args = parser.parse_args()
  170. # Resolve query
  171. query = args.query_opt or args.query
  172. if not query:
  173. parser.print_help()
  174. sys.exit(1)
  175. # Default output directory is fixed to /root/.openclaw/workspace/mx_data/output/
  176. default_output = Path("/root/.openclaw/workspace/mx_data/output")
  177. output_dir = Path(args.output_dir) if args.output_dir else default_output
  178. output_dir.mkdir(parents=True, exist_ok=True)
  179. try:
  180. mx = MXSelectStock()
  181. result = mx.search(query)
  182. rows, data_source, err = mx.extract_data(result)
  183. if err:
  184. print(f"错误: {err}")
  185. print(f"原始结果预览: {json.dumps(result, ensure_ascii=False)[:500]}")
  186. sys.exit(2)
  187. if not rows:
  188. print("未找到符合条件的数据")
  189. sys.exit(0)
  190. # 输出 CSV
  191. fieldnames = list(rows[0].keys())
  192. safe_name = safe_filename(query)
  193. csv_path = output_dir / f"mx_xuangu_{safe_name}.csv"
  194. desc_path = output_dir / f"mx_xuangu_{safe_name}_description.txt"
  195. with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
  196. writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
  197. writer.writeheader()
  198. for row in rows:
  199. writer.writerow(row)
  200. # 写入描述文件
  201. description_lines = [
  202. "智能选股 结果说明",
  203. "=" * 40,
  204. f"查询内容: {query}",
  205. f"数据行数: {len(rows)}(来源: {data_source})",
  206. f"列名(中文): {', '.join(fieldnames)}",
  207. "",
  208. "说明: 数据来源于东方财富妙想智能选股;"
  209. + ("列名已按 columns 映射为中文。" if data_source == "dataList" else "表格来自 partialResults 解析。"),
  210. ]
  211. desc_path.write_text("\n".join(description_lines), encoding="utf-8")
  212. # 终端输出信息
  213. print(f"✅ CSV: {csv_path}")
  214. print(f"📄 描述: {desc_path}")
  215. print(f"📊 行数: {len(rows)}")
  216. # 保存原始 JSON
  217. json_path = output_dir / f"mx_xuangu_{safe_name}_raw.json"
  218. with open(json_path, "w", encoding="utf-8") as f:
  219. json.dump(result, f, ensure_ascii=False, indent=2)
  220. print(f"📄 原始JSON: {json_path}")
  221. except Exception as e:
  222. print(f"错误: {str(e)}", file=sys.stderr)
  223. sys.exit(1)
  224. if __name__ == "__main__":
  225. main()