#!/usr/bin/env python3 """ 金融结构化数据查询 - 通用金融数据查询工具(financial-query) 通过同花顺问财 OpenAPI `query2data` 端点,查询 A 股 / 港股 / 美股 / 基金 / 期货 / ETF / 板块 / 概念 / 指数 等全市场金融结构化数据(行情、估值、财务、 事件、资金流向、宏观经济等)。 严格遵循 Iwencai (问财) OpenAPI 网关规范: - 每次请求携带 8 个 X-Claw-* Header(X-Claw-Skill-Id 沿用平台注册名 `hithink-financial-query`) - X-Claw-Trace-Id 为每次新生成的 64 字符十六进制唯一 ID - Authorization Bearer 仅从环境变量 IWENCAI_API_KEY 读取 - 优先使用 POST - 使用 Python3 标准库,跨平台兼容 注意:默认返回 10 条数据,可通过 --page 和 --limit 参数翻页获取更多数据 """ import argparse import json import os import secrets import sys import urllib.error import urllib.request from typing import Optional, Union SKILL_NAME = "hithink-financial-query" SKILL_VERSION = "2.0.0" DEFAULT_API_URL = "https://openapi.iwencai.com/v1/query2data" DEFAULT_PAGE = "1" DEFAULT_LIMIT = "10" DEFAULT_TIMEOUT = 30 class AStockAPIError(Exception): """API 错误异常类""" def __init__(self, message: str, status_code: int = None, response: Union[str, dict, None] = None): super().__init__(message) self.message = message self.status_code = status_code self.response = response def generate_trace_id() -> str: """生成 64 字符十六进制全局唯一追踪 ID。""" return secrets.token_hex(32) def get_api_key(cli_api_key: Optional[str]) -> str: """获取 API 密钥:优先 CLI 参数,其次环境变量。""" key = cli_api_key or os.environ.get("IWENCAI_API_KEY", "") if not key: raise AStockAPIError( "API 密钥未设置。请通过 --api-key 参数或环境变量 IWENCAI_API_KEY 指定。\n" "首次使用获取指引:打开 https://www.iwencai.com/skillhub → 登录 → 点击 Skill → " "安装方式-Agent用户-复制您的 IWENCAI_API_KEY。" ) return key def build_headers(api_key: str, trace_id: str, call_type: str = "normal") -> dict: """构造符合问财网关规范的请求头。""" return { "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", "X-Claw-Call-Type": call_type, "X-Claw-Skill-Id": SKILL_NAME, "X-Claw-Skill-Version": SKILL_VERSION, "X-Claw-Plugin-Id": "none", "X-Claw-Plugin-Version": "none", "X-Claw-Trace-Id": trace_id, } def query_astock( query: str, page: str, limit: str, api_key: Optional[str], call_type: str = "normal", timeout: int = DEFAULT_TIMEOUT, ) -> dict: """ 调用数据查询接口。 """ api_key = get_api_key(api_key) api_url = DEFAULT_API_URL trace_id = generate_trace_id() payload = { "query": query, "page": page, "limit": limit, "is_cache": "1", "expand_index": "true", } headers = build_headers(api_key, trace_id, call_type) claw_headers = {k: v for k, v in headers.items() if k.startswith("X-Claw-")} request = urllib.request.Request( api_url, data=json.dumps(payload).encode("utf-8"), headers=headers, method="POST", ) try: with urllib.request.urlopen(request, timeout=timeout) as response: response_body = response.read().decode("utf-8") if not response_body.strip(): return {"text_response": "", "trace_id": trace_id, "claw_headers": claw_headers} try: parsed_response = json.loads(response_body) if isinstance(parsed_response, dict): parsed_response["trace_id"] = trace_id parsed_response["claw_headers"] = claw_headers return parsed_response elif isinstance(parsed_response, list): return {"data": parsed_response, "trace_id": trace_id, "claw_headers": claw_headers} else: return {"text_response": str(parsed_response), "trace_id": trace_id, "claw_headers": claw_headers} except json.JSONDecodeError: return {"text_response": response_body, "trace_id": trace_id, "claw_headers": claw_headers} except urllib.error.HTTPError as e: error_body = e.read().decode("utf-8") if e.fp else "" if error_body.strip(): try: error_json = json.loads(error_body) raise AStockAPIError( f"HTTP 错误 {e.code}: {e.reason}", status_code=e.code, response=error_json, ) except json.JSONDecodeError: raise AStockAPIError( f"HTTP 错误 {e.code}: {e.reason}", status_code=e.code, response=error_body, ) else: raise AStockAPIError( f"HTTP 错误 {e.code}: {e.reason}", status_code=e.code, response="", ) except urllib.error.URLError as e: raise AStockAPIError(f"网络错误: {e.reason}") def parse_args(): """解析命令行参数""" parser = argparse.ArgumentParser( description="金融结构化数据查询 - 通用金融数据查询工具(不包含选股/筛选)", formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--query", "-q", type=str, required=True, help="查询字符串(必填)" ) parser.add_argument( "--page", type=str, default=DEFAULT_PAGE, help=f"分页参数,值必须为正整数(默认: {DEFAULT_PAGE})" ) parser.add_argument( "--limit", type=str, default=DEFAULT_LIMIT, help=f"每页条数,值必须为正整数(默认: {DEFAULT_LIMIT})" ) parser.add_argument( "--api-key", type=str, default=None, help="API 密钥(默认从环境变量 IWENCAI_API_KEY 读取)" ) parser.add_argument( "--call-type", type=str, choices=["normal", "retry"], default="normal", help="调用类型: normal(正常请求)或 retry(重试请求)(默认: normal)" ) parser.add_argument( "--timeout", type=int, default=DEFAULT_TIMEOUT, help=f"请求超时时间,单位秒(默认: {DEFAULT_TIMEOUT})" ) args = parser.parse_args() try: page_val = int(args.page) if page_val < 1: parser.error(f"--page 必须为正整数,当前值: {args.page}") except ValueError: parser.error(f"--page 必须为正整数,当前值: {args.page}") try: limit_val = int(args.limit) if limit_val < 1: parser.error(f"--limit 必须为正整数,当前值: {args.limit}") except ValueError: parser.error(f"--limit 必须为正整数,当前值: {args.limit}") return args def main(): """主函数""" args = parse_args() try: result = query_astock( query=args.query, page=args.page, limit=args.limit, api_key=args.api_key, call_type=args.call_type, timeout=args.timeout, ) if isinstance(result, dict) and "text_response" not in result: if "datas" not in result: print(json.dumps(result, ensure_ascii=False, indent=2)) sys.exit(1) datas = result["datas"] code_count = int(result.get("code_count", 0)) chunks_info = result.get("chunks_info", {}) trace_id = result.get("trace_id", "") current_page = int(args.page) current_limit = int(args.limit) has_more = current_page * current_limit < code_count output = { "success": True, "query": args.query, "code_count": code_count, "returned_count": len(datas), "page": args.page, "limit": args.limit, "has_more": has_more, "chunks_info": chunks_info, "trace_id": trace_id, "datas": datas, } if has_more: output["pagination_tip"] = ( f"共查到 {code_count} 条记录,当前返回第 {args.page} 页的 {len(datas)} 条。" f"如需更多数据,请使用 --page 参数翻页。" ) if not datas: output["empty_data_tip"] = ( "未查询到符合条件的数据。建议放宽或简化查询条件后重试" "(使用 --call-type retry 标记重试请求)。" "如仍无数据,可引导用户访问同花顺问财: https://www.iwencai.com/unifiedwap/chat" ) print(json.dumps(output, ensure_ascii=False, indent=2)) else: print(json.dumps(result, ensure_ascii=False, indent=2)) except AStockAPIError as e: if isinstance(e.response, dict): gateway_output = dict(e.response) if e.status_code is not None: gateway_output.setdefault("status_code", e.status_code) print(json.dumps(gateway_output, ensure_ascii=False, indent=2)) elif isinstance(e.response, str) and e.response.strip(): print(e.response) else: error_output = {"error": e.message} if e.status_code is not None: error_output["status_code"] = e.status_code print(json.dumps(error_output, ensure_ascii=False, indent=2)) sys.exit(1) except KeyboardInterrupt: print("\n操作已取消。", file=sys.stderr) sys.exit(130) except Exception as e: error_output = { "error": f"发生错误: {str(e)}" } print(json.dumps(error_output, ensure_ascii=False, indent=2)) sys.exit(1) if __name__ == "__main__": main()