From ac5184ecb071d74c52fa519c4889c783470ecc6d Mon Sep 17 00:00:00 2001 From: lj091715 <1091062319@qq.com> Date: Tue, 19 May 2026 10:10:51 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=96=87=E4=BB=B6=E8=87=B3?= =?UTF-8?q?=20gpdm?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gpdm/SKILL.md | 122 ++++++++++++++++++++++++ gpdm/stock.cpython-312.pyc | Bin 0 -> 7317 bytes gpdm/stock.py | 187 +++++++++++++++++++++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 gpdm/SKILL.md create mode 100644 gpdm/stock.cpython-312.pyc create mode 100644 gpdm/stock.py diff --git a/gpdm/SKILL.md b/gpdm/SKILL.md new file mode 100644 index 0000000..0d0a46b --- /dev/null +++ b/gpdm/SKILL.md @@ -0,0 +1,122 @@ +--- +name: stock +description: "当用户发送「股票 代码」或「gp 代码」时触发。调用 mairuiapi 实时交易数据接口获取行情,再调用本地微信机器人发消息接口把股票信息发给当前用户。" +argument-hint: "参数为股票代码,例如 600519 或 000001" +--- + +# Stock Skill + +## 描述 +这是一个用于查询股票实时行情并直接发送给当前用户的技能。 + +当用户发送 `股票 600519` 或 `gp 000001` 时,调用 mairuiapi 实时交易数据接口获取行情数据,再调用本地微信机器人接口把格式化后的股票信息发出去。 + +这个仓库里额外提供了一个可执行脚本 `scripts/stock.py`,方便宿主机器人直接调用。 + +## 触发条件 +- 用户发送 `股票 <股票代码>` 或 `gp <股票代码>` +- 股票代码为纯数字(如 `600519`、`000001`) + +## 接口信息 +- 获取股票行情:`https://api.mairuiapi.com/hsrl/ssjy/{stock_code}/{licence}` +- 请求方式:`GET` +- 发消息接口:`http://127.0.0.1:{ROBOT_WECHAT_CLIENT_PORT}/api/v1/robot/message/send/text` +- 请求方式:`POST` +- 本地脚本:`scripts/stock.py` + +### API 返回示例(贵州茅台 600519) +```json +[{ + "t": "2026-05-19 15:00:00", + "p": 1323.0, + "pc": -0.75, + "ud": -9.95, + "v": 4.97, + "cje": 6594983723.0, + "zf": 1.73, + "hs": 0.4, + "pe": 15.2, + "lb": 0.89, + "fm": 0.18, + "h": 1342.68, + "l": 1319.61, + "o": 1336.0, + "yc": 1332.95, + "sz": 16567534944.45, + "lt": 16567534944.45, + "zs": 0.04, + "sjl": 6.12, + "zdf60": -13.24, + "zdfnc": -3.93 +}] +``` + +### 字段说明 +| 字段 | 数据类型 | 字段说明 | +|------|---------|---------| +| fm | number | 五分钟涨跌幅(%) | +| h | number | 最高价(元) | +| hs | number | 换手(%) | +| lb | number | 量比(%) | +| l | number | 最低价(元) | +| lt | number | 流通市值(元) | +| o | number | 开盘价(元) | +| pe | number | 市盈率(动态,总市值除以预估全年净利润,例如当前公布一季度净利润1000万,则预估全年净利润4000万) | +| pc | number | 涨跌幅(%) | +| p | number | 当前价格(元) | +| sz | number | 总市值(元) | +| cje | number | 成交额(元) | +| ud | number | 涨跌额(元) | +| v | number | 成交量(手) | +| yc | number | 昨日收盘价(元) | +| zf | number | 振幅(%) | +| zs | number | 涨速(%) | +| sjl | number | 市净率 | +| zdf60 | number | 60日涨跌幅(%) | +| zdfnc | number | 年初至今涨跌幅(%) | +| t | string | 更新时间 yyyy-MM-ddHH:mm:ss | + +## 环境变量 +- `ROBOT_WECHAT_CLIENT_PORT`:本地微信机器人服务端口。 +- `ROBOT_FROM_WX_ID`:当前消息来源用户的 wxid。 +- `STOCK_LICENCE`:mairuiapi 的 licence 密钥(可选,默认使用内置 licence)。 + +## 执行步骤 +1. 当用户发送 `股票 <代码>` 或 `gp <代码>` 时触发该技能。 +2. 在仓库根目录下执行本地脚本:`python3 scripts/stock.py <股票代码>`。 +3. 脚本从环境变量 `STOCK_LICENCE` 获取 licence,如果未设置则使用内置默认值。 +4. 脚本发送 `GET` 请求到 `https://api.mairuiapi.com/hsrl/ssjy/{stock_code}/{licence}`。 +5. 脚本解析返回的 JSON 数据,提取关键字段。 +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": "【600519】 + 📈 当前价:1323.00 + 📊 涨跌幅:-0.75% + 📊 涨跌额:-9.95 + ⬆️ 最高:1342.68 + ⬇️ 最低:1319.61 + 🔓 今开:1336.00 + 🔒 昨收:1332.95 + 📈 成交量:4.97手 + 💰 成交额:6594983723.00 + 🔄 换手率:0.40% + 📐 振幅:1.73% + 📊 市盈率:15.20 + 📊 市净率:6.12 + 📊 量比:0.89 + ⚡ 涨速:0.04% + ⏱ 五分钟涨跌:0.18% + 📅 60日涨跌:-13.24% + 📅 年初至今:-3.93% + ==================== + 更新时间:2026-05-19 15:00:00" + } + ``` +8. 如果任一步骤失败,回复兜底文案:`股票查询失败,请稍后再试。` + +## 回复要求 +- 成功时,直接发送格式化股票信息,不要额外追加解释文字。 +- 失败时,使用固定兜底文案回复。 diff --git a/gpdm/stock.cpython-312.pyc b/gpdm/stock.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ac55de67ebed5a14b072587457bf9abdd0c28611 GIT binary patch literal 7317 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 literal 0 HcmV?d00001 diff --git a/gpdm/stock.py b/gpdm/stock.py new file mode 100644 index 0000000..86d9b33 --- /dev/null +++ b/gpdm/stock.py @@ -0,0 +1,187 @@ +#!/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://api.mairuiapi.com/hsrl/ssjy/{}/{}" +DEFAULT_LICENCE = "3E81CB37-4BCE-4DAF-B0AC-AEA069A58973" +FALLBACK_TEXT = "股票查询失败,请稍后再试。" + + +def normalize_stock_code(code: str) -> str: + """验证并标准化股票代码(仅保留6位纯数字)""" + code = code.strip() + # 去掉可能的前缀(sh/SH/sz/SZ/bj/BJ) + code = re.sub(r"^(sh|sz|bj)", "", code, flags=re.IGNORECASE) + if re.match(r"^\d{6}$", code): + return code + # 如果传入了完整代码但包含其他字符,尝试提取数字 + digits = re.findall(r"\d", code) + if len(digits) >= 6: + return "".join(digits[:6]) + return "" + + +def get_licence() -> str: + """获取 licence,优先从环境变量读取""" + return os.environ.get("STOCK_LICENCE", "").strip() or DEFAULT_LICENCE + + +def fetch_stock_quote(stock_code: str) -> dict | None: + """获取股票行情数据并解析为字典""" + licence = get_licence() + url = QUOTE_API_URL.format(stock_code, licence) + try: + req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) + with urllib.request.urlopen(req, timeout=10) as response: + payload = json.load(response) + except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, OSError): + return None + + # 处理 JSON 数组返回格式 [{}, ...] + if isinstance(payload, list) and len(payload) > 0: + payload = payload[0] + + if not isinstance(payload, dict): + return None + + # 检查接口返回是否包含有效数据 + if "p" not in payload: + return None + + return payload + + +def format_stock_message(data: dict, stock_code: str = "") -> str: + """格式化股票信息为可读文本""" + p = data.get("p", 0) or 0 + pc = data.get("pc", 0) or 0 + ud = data.get("ud", 0) or 0 + + up_icon = "📈" if float(pc) >= 0 else "📉" + title = f"【{stock_code}】" if stock_code else "【查询结果】" + + lines = [ + title, + f"{'=' * 20}", + f"{up_icon} 当前价:{_fmt(p)}", + f"📊 涨跌幅:{_fmt(pc)}%", + f"📊 涨跌额:{_fmt(ud)}", + f"⬆️ 最高:{_fmt(data.get('h'))}", + f"⬇️ 最低:{_fmt(data.get('l'))}", + f"🔓 今开:{_fmt(data.get('o'))}", + f"🔒 昨收:{_fmt(data.get('yc'))}", + f"📈 成交量:{_fmt(data.get('v'))}手", + f"💰 成交额:{_fmt(data.get('cje'))}", + f"🔄 换手率:{_fmt(data.get('hs'))}%", + f"📐 振幅:{_fmt(data.get('zf'))}%", + f"📊 市盈率:{_fmt(data.get('pe'))}", + f"📊 市净率:{_fmt(data.get('sjl'))}", + f"📊 量比:{_fmt(data.get('lb'))}", + f"⚡ 涨速:{_fmt(data.get('zs'))}%", + f"⏱ 五分钟涨跌:{_fmt(data.get('fm'))}%", + f"📅 60日涨跌:{_fmt(data.get('zdf60'))}%", + f"📅 年初至今:{_fmt(data.get('zdfnc'))}%", + f"📊 总市值:{_fmt(data.get('sz'))}", + f"📊 流通市值:{_fmt(data.get('lt'))}", + f"{'=' * 20}", + f"更新时间:{data.get('t', '未知')}", + ] + return "\n".join(lines) + + +def _fmt(val) -> str: + """统一格式化数值,金融数据保留2位小数""" + if val is None: + return "0" + if isinstance(val, (int, float)): + return f"{val:.2f}" + return str(val) + + +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] + stock_code = normalize_stock_code(raw_code) + if not stock_code: + sys.stdout.write(FALLBACK_TEXT) + sys.stdout.write("\n") + return 0 + + data = fetch_stock_quote(stock_code) + if not data: + sys.stdout.write(FALLBACK_TEXT) + sys.stdout.write("\n") + return 0 + + message = format_stock_message(data, stock_code) + 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)