Fin-DataPilot Deploy Bot
ci: da17dfe
ab26c67
Raw
History Blame Contribute Delete
10.4 kB
#!/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()