| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260 |
- #!/usr/bin/env python3
- # mx_xuangu - 妙想智能选股 skill
- # 基于东方财富妙想API提供智能选股能力
- import os
- import sys
- import json
- import csv
- import re
- import argparse
- import requests
- from pathlib import Path
- from typing import Dict, List, Optional, Any, Tuple
- def safe_filename(s: str, max_len: int = 80) -> str:
- """将查询文本转为安全文件名片段"""
- s = re.sub(r'[<>:"/\\|?*]', "_", s)
- s = s.strip().replace(" ", "_")[:max_len]
- return s or "query"
- def build_column_map(columns: List[Dict[str, Any]]) -> Dict[str, str]:
- """
- 从返回的 columns 构建 原始列名 -> 中文列名 的映射
- """
- name_map: Dict[str, str] = {}
- for col in columns or []:
- if not isinstance(col, dict):
- continue
- en_key = col.get("field", "") or col.get("name", "") or col.get("key", "")
- cn_name = col.get("displayName", "") or col.get("title", "") or col.get("label", "")
- date_msg = col.get('dateMsg', '')
- if date_msg:
- cn_name = cn_name + ' ' + date_msg
- if en_key is not None and cn_name is not None:
- name_map[str(en_key)] = str(cn_name)
- return name_map
- def columns_order(columns: List[Dict[str, Any]]) -> List[str]:
- """按 columns 顺序返回原始列名列表,用于 CSV 表头顺序"""
- order: List[str] = []
- for col in columns or []:
- if not isinstance(col, dict):
- continue
- en_key = col.get("field") or col.get("name") or col.get("key")
- if en_key is not None:
- order.append(str(en_key))
- return order
- def parse_partial_results_table(partial_results: str) -> List[Dict[str, str]]:
- """
- 将 partialResults 的 Markdown 表格字符串解析为行字典列表
- """
- if not partial_results or not isinstance(partial_results, str):
- return []
- lines = [ln.strip() for ln in partial_results.strip().splitlines() if ln.strip()]
- if not lines:
- return []
- def split_cells(line: str) -> List[str]:
- return [c.strip() for c in line.split("|") if c.strip() != ""]
- header_cells = split_cells(lines[0])
- if not header_cells:
- return []
- # 跳过分隔行(如 |---|---|)
- data_start = 1
- if data_start < len(lines) and re.match(r"^[\s\|\-]+$", lines[data_start]):
- data_start = 2
- rows: List[Dict[str, str]] = []
- for i in range(data_start, len(lines)):
- cells = split_cells(lines[i])
- if len(cells) != len(header_cells):
- # 列数不一致时按长度对齐,缺的补空
- if len(cells) < len(header_cells):
- cells.extend([""] * (len(header_cells) - len(cells)))
- else:
- cells = cells[: len(header_cells)]
- rows.append(dict(zip(header_cells, cells)))
- return rows
- def datalist_to_rows(
- datalist: List[Dict[str, Any]],
- column_map: Dict[str, str],
- column_order: List[str],
- ) -> List[Dict[str, str]]:
- """
- 将 datalist 中每行的原始键按 column_map 替换为中文键,保证顺序
- """
- if not datalist:
- return []
- # 表头顺序:优先按 columns 顺序,未在 columns 中的键按首次出现顺序排在后面
- first = datalist[0]
- extra_keys = [k for k in first if k not in column_order]
- header_order = column_order + extra_keys
- rows: List[Dict[str, str]] = []
- for row in datalist:
- if not isinstance(row, dict):
- continue
- cn_row: Dict[str, str] = {}
- for en_key in header_order:
- if en_key not in row:
- continue
- cn_name = column_map.get(en_key, en_key)
- val = row[en_key]
- if val is None:
- cn_row[cn_name] = ""
- elif isinstance(val, (dict, list)):
- cn_row[cn_name] = json.dumps(val, ensure_ascii=False)
- else:
- cn_row[cn_name] = str(val)
- rows.append(cn_row)
- return rows
- class MXSelectStock:
- """妙想智能选股客户端"""
-
- BASE_URL = "https://mkapi2.dfcfs.com/finskillshub/api/claw/stock-screen"
-
- def __init__(self, api_key: Optional[str] = None):
- """
- 初始化客户端
- :param api_key: MX API Key,如果不提供则从环境变量 MX_APIKEY 读取
- """
- self.api_key = api_key or os.getenv("MX_APIKEY")
- if not self.api_key:
- raise ValueError(
- "MX_APIKEY 环境变量未设置,请先设置环境变量:\n"
- "export MX_APIKEY=your_api_key_here\n"
- "或者在初始化时传入 api_key 参数"
- )
-
- def search(self, query: str) -> Dict[str, Any]:
- """
- 智能选股
- :param query: 自然语言查询,如 "今天A股价格大于10元"
- :return: API 响应结果
- """
- headers = {
- "Content-Type": "application/json",
- "apikey": self.api_key
- }
- data = {
- "keyword": query
- }
-
- response = requests.post(self.BASE_URL, headers=headers, json=data, timeout=30)
- response.raise_for_status()
- return response.json()
-
- @staticmethod
- def extract_data(result: Dict[str, Any]) -> Tuple[List[Dict[str, str]], str, Optional[str]]:
- """
- 提取数据 :
- - 优先使用 allResults.result.dataList 全量数据
- - 若无则回退到解析 partialResults Markdown 表格
- :return: (rows, data_source, error)
- """
- status = result.get("status")
- if status != 0:
- return [], "", f"顶层错误: 状态码 {status} - {result.get('message', '')}"
-
- data = result.get("data", {})
- inner_data = data.get("data", {})
-
- # 优先使用全量数据 dataList
- data_list = inner_data.get("allResults", {}).get("result", {}).get("dataList", [])
- columns = inner_data.get("allResults", {}).get("result", {}).get("columns", [])
-
- if isinstance(data_list, list) and data_list:
- column_map = build_column_map(columns)
- order = columns_order(columns)
- rows = datalist_to_rows(data_list, column_map, order)
- return rows, "dataList", None
-
- # 回退到 partialResults 解析
- partial_results = inner_data.get("partialResults", "")
- if partial_results:
- rows = parse_partial_results_table(partial_results)
- return rows, "partialResults", None
-
- return [], "", "返回中无有效 dataList 且 partialResults 无法解析或为空"
- def main():
- """命令行入口 """
- parser = argparse.ArgumentParser(description='通过自然语言查询进行智能选股(A股/港股/美股/板块/基金/ETF)')
- parser.add_argument('query', nargs='?', help='自然语言查询,如「股价大于10元的A股」')
- parser.add_argument('--query', dest='query_opt', help='自然语言查询(显式参数)')
- parser.add_argument('--output-dir', dest='output_dir', help='输出目录,默认 /root/.openclaw/workspace/mx_data/output/')
- args = parser.parse_args()
-
- # Resolve query
- query = args.query_opt or args.query
- if not query:
- parser.print_help()
- sys.exit(1)
-
- # Default output directory is fixed to /root/.openclaw/workspace/mx_data/output/
- default_output = Path("/root/.openclaw/workspace/mx_data/output")
- output_dir = Path(args.output_dir) if args.output_dir else default_output
- output_dir.mkdir(parents=True, exist_ok=True)
-
- try:
- mx = MXSelectStock()
- result = mx.search(query)
- rows, data_source, err = mx.extract_data(result)
-
- if err:
- print(f"错误: {err}")
- print(f"原始结果预览: {json.dumps(result, ensure_ascii=False)[:500]}")
- sys.exit(2)
-
- if not rows:
- print("未找到符合条件的数据")
- sys.exit(0)
-
- # 输出 CSV
- fieldnames = list(rows[0].keys())
- safe_name = safe_filename(query)
- csv_path = output_dir / f"mx_xuangu_{safe_name}.csv"
- desc_path = output_dir / f"mx_xuangu_{safe_name}_description.txt"
-
- with open(csv_path, "w", newline="", encoding="utf-8-sig") as f:
- writer = csv.DictWriter(f, fieldnames=fieldnames, extrasaction="ignore")
- writer.writeheader()
- for row in rows:
- writer.writerow(row)
-
- # 写入描述文件
- description_lines = [
- "智能选股 结果说明",
- "=" * 40,
- f"查询内容: {query}",
- f"数据行数: {len(rows)}(来源: {data_source})",
- f"列名(中文): {', '.join(fieldnames)}",
- "",
- "说明: 数据来源于东方财富妙想智能选股;"
- + ("列名已按 columns 映射为中文。" if data_source == "dataList" else "表格来自 partialResults 解析。"),
- ]
- desc_path.write_text("\n".join(description_lines), encoding="utf-8")
-
- # 终端输出信息
- print(f"✅ CSV: {csv_path}")
- print(f"📄 描述: {desc_path}")
- print(f"📊 行数: {len(rows)}")
-
- # 保存原始 JSON
- json_path = output_dir / f"mx_xuangu_{safe_name}_raw.json"
- with open(json_path, "w", encoding="utf-8") as f:
- json.dump(result, f, ensure_ascii=False, indent=2)
- print(f"📄 原始JSON: {json_path}")
-
- except Exception as e:
- print(f"错误: {str(e)}", file=sys.stderr)
- sys.exit(1)
- if __name__ == "__main__":
- main()
|