diff --git a/gp/SKILL.md b/gp/SKILL.md new file mode 100644 index 0000000..6954968 --- /dev/null +++ b/gp/SKILL.md @@ -0,0 +1,78 @@ +--- +name: stock +description: "当用户发送「股票 代码」或「gp 代码」时触发。调用腾讯股票行情接口获取实时数据,再调用本地微信机器人发消息接口把股票信息发给当前用户。" +argument-hint: "参数为股票代码,例如 600519 或 sz000001" +--- + +# Stock Skill + +## 描述 +这是一个用于查询股票实时行情并直接发送给当前用户的技能。 + +当用户发送 `股票 600519` 或 `gp sz000001` 时,调用腾讯股票行情接口获取实时数据,再调用本地微信机器人接口把格式化后的股票信息发出去。 + +这个仓库里额外提供了一个可执行脚本 `scripts/stock.py`,方便宿主机器人直接调用。 + +## 触发条件 +- 用户发送 `股票 <股票代码>` 或 `gp <股票代码>` +- 股票代码可以是纯数字(如 `600519`),也可以带市场前缀(如 `sh600519`、`sz000001`) + +## 接口信息 +- 获取股票行情:`https://qt.gtimg.cn/q={stock_code}` +- 请求方式:`GET` +- 发消息接口:`http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/text` +- 请求方式:`POST` +- 本地脚本:`scripts/stock.py` + +### 腾讯股票行情返回示例 +``` +v_sh600519="1~贵州茅台~600519~1945.50~1942.00~1941.00~1946.80~1940.02~1945.71~4291131~8365444900~183~1945.60~826~1945.59~649~1945.58~699~1945.57~589~1945.56~1449~1945.71~1014~1945.72~722~1945.73~369~1945.74~471~1945.75~202~1945.76~2026-05-19~15:00:00~00~-1.18~-0.06~1945.50~1906.02~1945.50~1895216~4901415~1.22~836544.4900~18.55~403727.15~0.37~107.86~15.03~232.10~10.49~10.61~PB~0.23~0.53~-0.22~10.99~1945.50~1900.16~~ 1044479.27~1.22~1895216~4901415~0.06~10.47~10.49~10.61~0.01~-82.56~1945.50~1906.02~0.03~-0.06~1945.50~-0.39~1942.00~1036.50~1.88~181580~185384~1945.50~1900.16~0.00~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~0~" +``` + +### 字段说明 +| 索引 | 含义 | +|------|------| +| 1 | 股票名称 | +| 2 | 股票代码 | +| 3 | 当前价格 | +| 4 | 昨收价 | +| 5 | 今开价 | +| 6 | 成交量(手) | +| 7 | 成交额(万) | +| 8 | 最高价 | +| 9 | 最低价 | +| 10 | 涨跌额 | +| 11 | 涨跌幅(%) | + +## 环境变量 +- `ROBOT_WECHAT_CLIENT_PORT`:本地微信机器人服务端口。 +- `ROBOT_FROM_WX_ID`:当前消息来源用户的 wxid。 + +## 执行步骤 +1. 当用户发送 `股票 <代码>` 或 `gp <代码>` 时触发该技能。 +2. 在仓库根目录下执行本地脚本:`python3 scripts/stock.py <股票代码>`。 +3. 脚本内部自动处理股票代码格式(补全市场前缀 sh/sz)。 +4. 脚本发送 `GET` 请求到 `https://qt.gtimg.cn/q={stock_code}`。 +5. 脚本解析返回的行情数据,提取关键字段。 +6. 脚本从环境变量中读取 `ROBOT_WECHAT_CLIENT_PORT` 和 `ROBOT_FROM_WX_ID`。 +7. 脚本发送 `POST` 请求到 `http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/text`,请求体为: + ```json + { + "to_wxid": "{ROBOT_FROM_WX_ID}", + "content": "【{股票名称}({股票代码})】 + 当前价:{当前价格} + 涨跌幅:{涨跌幅}% + 涨跌额:{涨跌额} + 最高:{最高价} + 最低:{最低价} + 今开:{今开价} + 昨收:{昨收价} + 成交量:{成交量} + 成交额:{成交额}" + } + ``` +8. 如果任一步骤失败,回复兜底文案:`股票查询失败,请稍后再试。` + +## 回复要求 +- 成功时,直接发送格式化股票信息,不要额外追加解释文字。 +- 失败时,使用固定兜底文案回复。 diff --git a/gp/stock.cpython-312.pyc b/gp/stock.cpython-312.pyc new file mode 100644 index 0000000..ac55de6 Binary files /dev/null and b/gp/stock.cpython-312.pyc differ diff --git a/gp/stock.py b/gp/stock.py new file mode 100644 index 0000000..5d475e8 --- /dev/null +++ b/gp/stock.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +from __future__ import annotations +import io +import json +import os +import re +import sys +import traceback +import urllib.error +import urllib.request + +# 将 stdout 编码设置为 UTF-8,确保 emoji 等字符能正常输出 +if hasattr(sys.stdout, "reconfigure"): + sys.stdout.reconfigure(encoding="utf-8") +else: + sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8") +sys.stderr = sys.stdout + +QUOTE_API_URL = "https://qt.gtimg.cn/q={}" +FALLBACK_TEXT = "股票查询失败,请稍后再试。" + + +def normalize_stock_code(code: str) -> str: + """自动补全股票代码市场前缀""" + code = code.strip().upper() + # 如果已经带前缀,直接返回 + if code.startswith("SH") or code.startswith("SZ"): + return code + # 如果已经是带 sh/sz 前缀 + if re.match(r"^(sh|sz)\d{6}$", code, re.IGNORECASE): + return code.upper() + # 纯数字处理 + if re.match(r"^\d{6}$", code): + # 沪市:6开头 + if code.startswith("6"): + return f"SH{code}" + # 深市:0或3开头 + elif code.startswith(("0", "3")): + return f"SZ{code}" + # 北交所:8开头 + elif code.startswith(("8", "4")): + return f"BJ{code}" + # 无法识别 + return code + + +def parse_market_prefix(code: str) -> str: + """将 SH/SZ 转换为 qq 接口使用的 sh/sz 前缀""" + code = code.upper() + if code.startswith("SH"): + return f"sh{code[2:]}" + elif code.startswith("SZ"): + return f"sz{code[2:]}" + elif code.startswith("BJ"): + return f"bj{code[2:]}" + return code + + +def fetch_stock_quote(stock_code: str) -> dict | None: + """获取股票行情数据并解析为字典""" + try: + url = QUOTE_API_URL.format(stock_code) + with urllib.request.urlopen(url, timeout=10) as response: + raw = response.read().decode("gbk") + except (urllib.error.URLError, TimeoutError, OSError): + return None + + # 解析返回数据,格式如:v_sh600519="..."; + match = re.search(r'="([^"]+)"', raw) + if not match: + return None + + fields = match.group(1).split("~") + if len(fields) < 45: + return None + + return { + "name": fields[1], # 股票名称 + "code": fields[2], # 股票代码 + "price": fields[3], # 当前价格 + "yest_close": fields[4], # 昨收价 + "open": fields[5], # 今开价 + "volume": fields[6], # 成交量(手) + "amount": fields[37], # 成交额(万) + "high": fields[33], # 最高价 + "low": fields[34], # 最低价 + "change": fields[31], # 涨跌额 + "change_pct": fields[32], # 涨跌幅(%) + } + + +def format_stock_message(data: dict) -> str: + """格式化股票信息为可读文本""" + up_icon = "📈" if float(data.get("change", 0)) >= 0 else "📉" + lines = [ + f"【{data['name']}({data['code']})】", + f"{'=' * 20}", + f"{up_icon} 当前价:{data['price']}", + f"📊 涨跌幅:{data['change_pct']}%", + f"📊 涨跌额:{data['change']}", + f"⬆️ 最高:{data['high']}", + f"⬇️ 最低:{data['low']}", + f"🔓 今开:{data['open']}", + f"🔒 昨收:{data['yest_close']}", + f"📈 成交量:{data['volume']}", + f"💰 成交额:{data['amount']}万", + ] + return "\n".join(lines) + + +def send_text(text: str) -> bool: + """发送文本消息到微信机器人""" + robot_port = os.environ.get("ROBOT_WECHAT_CLIENT_PORT", "").strip() + to_wxid = os.environ.get("ROBOT_FROM_WX_ID", "").strip() + if not robot_port or not to_wxid: + return False + + api_url = ( + f"http://127.0.0.1:{robot_port}/api/v1/robot/message/send/text" + ) + body = json.dumps( + { + "to_wxid": to_wxid, + "content": text, + } + ).encode("utf-8") + + request = urllib.request.Request( + api_url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + + try: + with urllib.request.urlopen(request, timeout=10) as response: + if 200 <= response.status < 300: + return True + payload = json.load(response) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError): + return False + + code = payload.get("code") + return code == 200 or code == 0 + + +def main() -> int: + args = sys.argv[1:] + if not args: + sys.stdout.write("请提供股票代码,例如:python3 stock.py 600519\n") + return 0 + + raw_code = args[0] + normalized = normalize_stock_code(raw_code) + quoted_code = parse_market_prefix(normalized) + + # 在QQ接口中,多个股票用逗号分隔 + data = fetch_stock_quote(quoted_code) + if not data: + sys.stdout.write(FALLBACK_TEXT) + sys.stdout.write("\n") + return 0 + + message = format_stock_message(data) + if send_text(message): + return 0 + + # 发送失败,输出到stdout让宿主机器人捕获 + sys.stdout.write(message) + sys.stdout.write("\n") + return 0 + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except SystemExit: + raise + except Exception: + traceback.print_exc(file=sys.stdout) + raise SystemExit(1)