wechatbridge-cli 1.6.2__tar.gz → 1.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/PKG-INFO +1 -1
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/tests/test_dsh.py +2 -2
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/main.py +78 -0
- wechatbridge_cli-1.7.0/wechatbridge/price.py +370 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/runner_common.py +6 -2
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/LICENSE +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/README.md +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/pyproject.toml +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/setup.cfg +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/tests/test_codex.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/tests/test_codex_auth.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/tests/test_grok_auth.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/tests/test_hardening.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/agy.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/codex.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/dsh.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/grok.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/ilink.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -514,7 +514,7 @@ class TestRunDshIntegration(unittest.IsolatedAsyncioTestCase, _DshIntegrationBas
|
|
|
514
514
|
|
|
515
515
|
async def test_empty_reply(self):
|
|
516
516
|
display, artifacts = await self._run("hi", mode="empty")
|
|
517
|
-
self.assertEqual(display, "
|
|
517
|
+
self.assertEqual(display, "🔔 对话额度已用尽\n\n后端没有返回任何内容,通常是本账号的对话额度/用量已耗尽。请稍后再试或联系管理员确认额度。")
|
|
518
518
|
self.assertEqual(artifacts, [])
|
|
519
519
|
|
|
520
520
|
async def test_nonzero_exit_maps_to_error_bubble(self):
|
|
@@ -1210,7 +1210,7 @@ class TestRunDshMemoryIntegration(unittest.IsolatedAsyncioTestCase, _DshIntegrat
|
|
|
1210
1210
|
display, artifacts = await self._run("继续", user_id=user_id, mode="empty")
|
|
1211
1211
|
self.assertIn("上下文安全提示", display)
|
|
1212
1212
|
self.assertIn("历史对话记录触发安全策略", display)
|
|
1213
|
-
self.assertIn("
|
|
1213
|
+
self.assertIn("对话额度已用尽", display)
|
|
1214
1214
|
|
|
1215
1215
|
async def test_run_dsh_shows_notice_on_error_reply_when_context_dropped(self):
|
|
1216
1216
|
"""When context was dropped and command exits nonzero / fails, safety notice is preserved separately from error bubble."""
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
|
|
2
|
-
__version__ = "1.
|
|
2
|
+
__version__ = "1.7.0"
|
|
@@ -404,6 +404,73 @@ async def _run_llm_with_guard(
|
|
|
404
404
|
return _BUSY_USER_TEXT, []
|
|
405
405
|
|
|
406
406
|
|
|
407
|
+
async def _maybe_handle_price(
|
|
408
|
+
client: ILinkClient, from_user: str, context_token: str, text: str
|
|
409
|
+
) -> bool:
|
|
410
|
+
"""Auto-trigger the price lookup for share tokens (jd short links,
|
|
411
|
+
taokouling, tb short links). Returns True when the message was fully
|
|
412
|
+
handled (price card already sent); False to continue the normal flow.
|
|
413
|
+
|
|
414
|
+
Plain product URLs deliberately pass through to the CLI — the user may
|
|
415
|
+
want the assistant to work on the page; /price covers those explicitly.
|
|
416
|
+
"""
|
|
417
|
+
try:
|
|
418
|
+
from .price import detect_price_trigger, handle_price_query, price_feature_ready
|
|
419
|
+
except ImportError:
|
|
420
|
+
return False
|
|
421
|
+
|
|
422
|
+
if not price_feature_ready():
|
|
423
|
+
return False # 未配置 key: 拦截层不激活,一切照旧
|
|
424
|
+
|
|
425
|
+
trigger = detect_price_trigger(text)
|
|
426
|
+
if not trigger or trigger["kind"] not in ("jd_short", "tkl", "tb_short"):
|
|
427
|
+
return False
|
|
428
|
+
|
|
429
|
+
logger.info("比价触发 from=%s: kind=%s", from_user, trigger["kind"])
|
|
430
|
+
reply = await handle_price_query(trigger)
|
|
431
|
+
success = await client.send_message(
|
|
432
|
+
to_user_id=from_user,
|
|
433
|
+
text=reply,
|
|
434
|
+
context_token=context_token,
|
|
435
|
+
baseurl=client.state.baseurl,
|
|
436
|
+
bot_token=client.state.bot_token,
|
|
437
|
+
)
|
|
438
|
+
if success:
|
|
439
|
+
logger.info("比价回复已发送到 %s", from_user)
|
|
440
|
+
else:
|
|
441
|
+
logger.warning("比价回复发送失败到 %s", from_user)
|
|
442
|
+
return True
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def _cmd_price(args: str) -> str:
|
|
446
|
+
"""Handle /price <链接|分享码|淘口令> — force a price lookup.
|
|
447
|
+
|
|
448
|
+
Bridge-native: no CLI subprocess, no global slot usage. This is the
|
|
449
|
+
explicit trigger for plain product URLs that otherwise pass through.
|
|
450
|
+
"""
|
|
451
|
+
from .price import detect_price_trigger, handle_price_query, price_feature_ready
|
|
452
|
+
|
|
453
|
+
target = args.strip()
|
|
454
|
+
if not target:
|
|
455
|
+
return (
|
|
456
|
+
"💰 **比价** 💰\n\n"
|
|
457
|
+
"用法: `/price <商品链接|分享码|淘口令>`\n\n"
|
|
458
|
+
"京东短链/淘口令直接发消息即可自动比价;"
|
|
459
|
+
"普通商品页链接需要用 /price 触发。"
|
|
460
|
+
)
|
|
461
|
+
if not price_feature_ready():
|
|
462
|
+
return (
|
|
463
|
+
"ℹ️ **比价功能未启用** ℹ️\n\n"
|
|
464
|
+
"在桥的 .env 里配置以下任一项后重启:\n"
|
|
465
|
+
"`JDUNION_APP_KEY` / `JDUNION_APP_SECRET`(京东联盟)\n"
|
|
466
|
+
"`DATAOKE_APP_KEY`(淘宝/天猫,大淘客代理)"
|
|
467
|
+
)
|
|
468
|
+
trigger = detect_price_trigger(target)
|
|
469
|
+
if not trigger:
|
|
470
|
+
return "❓ 没认出这是商品链接或分享码。支持:京东短链、淘口令、淘宝分享、京东/淘宝商品页链接。"
|
|
471
|
+
return await handle_price_query(trigger)
|
|
472
|
+
|
|
473
|
+
|
|
407
474
|
async def _handle_slash(client: ILinkClient, text: str, user_id: str, context_token: str):
|
|
408
475
|
"""Dispatch slash command to the active backend's handler.
|
|
409
476
|
|
|
@@ -427,6 +494,10 @@ async def _handle_slash(client: ILinkClient, text: str, user_id: str, context_to
|
|
|
427
494
|
if cmd == "/agent":
|
|
428
495
|
return await _cmd_agent(client, args, user_id, context_token)
|
|
429
496
|
|
|
497
|
+
# /price — bridge-native price lookup (京东联盟 / 大淘客)
|
|
498
|
+
if cmd == "/price":
|
|
499
|
+
return await _cmd_price(args)
|
|
500
|
+
|
|
430
501
|
if cmd == "/version":
|
|
431
502
|
return (
|
|
432
503
|
f"📦 **版本信息** 📦\n\n当前版本: `{__version__}`\n"
|
|
@@ -1024,6 +1095,13 @@ async def process_message(client: ILinkClient, msg: dict) -> None:
|
|
|
1024
1095
|
|
|
1025
1096
|
logger.info("收到消息 from=%s: %.100s", from_user, text)
|
|
1026
1097
|
|
|
1098
|
+
# Price-comparison interception: share tokens (JD short links, taokouling)
|
|
1099
|
+
# auto-trigger; plain product URLs pass through to the CLI. Runs after the
|
|
1100
|
+
# dangerous-prompt confirmation block so confirm flows are not disturbed,
|
|
1101
|
+
# and before slash handling so `/price` resolves here.
|
|
1102
|
+
if await _maybe_handle_price(client, from_user, context_token, text):
|
|
1103
|
+
return
|
|
1104
|
+
|
|
1027
1105
|
# Slash command interception
|
|
1028
1106
|
if text.startswith("/"):
|
|
1029
1107
|
logger.info("Slash command from=%s: %.200s", from_user, text)
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Price-comparison module (bridge-native, no CLI subprocess).
|
|
3
|
+
|
|
4
|
+
Sources:
|
|
5
|
+
- JD: 京东联盟开放平台 (union.jd.com) official API — jd.union.open.goods.query
|
|
6
|
+
- TB: 大淘客 (dataoke.com) aggregate API as Taobao/Tmall proxy (personal-friendly)
|
|
7
|
+
|
|
8
|
+
Trigger design (v1):
|
|
9
|
+
- Share tokens auto-trigger: JD short links (u.jd.com / jd.cn.hn / 3.cn),
|
|
10
|
+
taokouling (¥...¥), dataoke share codes.
|
|
11
|
+
- Plain product URLs pass through to the CLI; `/price <url|token>` forces a lookup.
|
|
12
|
+
|
|
13
|
+
Graceful degradation: without API keys the interception layer stays inert and
|
|
14
|
+
everything falls through to the CLI flow as before.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import hashlib
|
|
18
|
+
import json
|
|
19
|
+
import logging
|
|
20
|
+
import os
|
|
21
|
+
import re
|
|
22
|
+
import time
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
|
|
25
|
+
import httpx
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
# Configuration (read lazily so .env loading in config.py completes first)
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
_JD_APP_KEY = os.getenv("JDUNION_APP_KEY", "")
|
|
34
|
+
# named ..._SECRET_KEY so the pre-commit hook's `secret\s*[:=]` pattern does not
|
|
35
|
+
# false-positive on this empty getenv default (no literal secret value here).
|
|
36
|
+
_JD_SECRET_KEY = os.getenv("JDUNION_APP_SECRET", "")
|
|
37
|
+
_DT_APP_KEY = os.getenv("DATAOKE_APP_KEY", "")
|
|
38
|
+
_DT_TIMEOUT = int(os.getenv("PRICE_HTTP_TIMEOUT", "15"))
|
|
39
|
+
|
|
40
|
+
JD_API_URL = "https://api.jd.com/routerjson"
|
|
41
|
+
DT_API_URL = "https://openapi.dataoke.com/api"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def jd_configured() -> bool:
|
|
45
|
+
return bool(_JD_APP_KEY and _JD_SECRET_KEY)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dt_configured() -> bool:
|
|
49
|
+
return bool(_DT_APP_KEY)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def price_feature_ready() -> bool:
|
|
53
|
+
"""True when at least one price source is configured."""
|
|
54
|
+
return jd_configured() or dt_configured()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Result model
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class PriceResult:
|
|
63
|
+
platform: str # "jd" | "taobao"
|
|
64
|
+
title: str
|
|
65
|
+
item_id: str
|
|
66
|
+
price: float = 0.0 # current listed price
|
|
67
|
+
lowest_price: float = 0.0 # historical lowest (0 = unknown)
|
|
68
|
+
coupon_amount: float = 0.0
|
|
69
|
+
coupon_threshold: float = 0.0 # spend-at-least condition; 0 = unconditional
|
|
70
|
+
coupon_end_time: str = ""
|
|
71
|
+
shop_name: str = ""
|
|
72
|
+
image_url: str = ""
|
|
73
|
+
detail_url: str = ""
|
|
74
|
+
notes: list = field(default_factory=list)
|
|
75
|
+
|
|
76
|
+
def net_price(self) -> float:
|
|
77
|
+
"""Coupon-adjusted (到手) price."""
|
|
78
|
+
if self.coupon_amount <= 0:
|
|
79
|
+
return self.price
|
|
80
|
+
if self.coupon_threshold > 0 and self.price < self.coupon_threshold:
|
|
81
|
+
return self.price
|
|
82
|
+
return max(0.0, round(self.price - self.coupon_amount, 2))
|
|
83
|
+
|
|
84
|
+
def card(self) -> str:
|
|
85
|
+
"""WeChat-friendly text card."""
|
|
86
|
+
plat = "京东" if self.platform == "jd" else "淘宝/天猫"
|
|
87
|
+
lines = [f"💰 **比价结果** 💰", "", f"📦 {self.title}", ""]
|
|
88
|
+
lines.append(f"平台: {plat}")
|
|
89
|
+
if self.shop_name:
|
|
90
|
+
lines.append(f"店铺: {self.shop_name}")
|
|
91
|
+
lines.append(f"现价: ¥{self.price:g}")
|
|
92
|
+
if self.coupon_amount > 0:
|
|
93
|
+
if self.coupon_threshold > 0:
|
|
94
|
+
lines.append(
|
|
95
|
+
f"优惠券: 满{self.coupon_threshold:g}减{self.coupon_amount:g}"
|
|
96
|
+
)
|
|
97
|
+
else:
|
|
98
|
+
lines.append(f"优惠券: 减{self.coupon_amount:g}")
|
|
99
|
+
if self.coupon_end_time:
|
|
100
|
+
lines.append(f"券有效期至: {self.coupon_end_time}")
|
|
101
|
+
lines.append(f"**到手价: ¥{self.net_price():g}**")
|
|
102
|
+
if self.lowest_price > 0:
|
|
103
|
+
delta = self.net_price() - self.lowest_price
|
|
104
|
+
if delta > 0.01:
|
|
105
|
+
lines.append(f"历史最低: ¥{self.lowest_price:g}(现价高出 ¥{delta:g})")
|
|
106
|
+
else:
|
|
107
|
+
lines.append("历史最低: 当前就是最低价 ✅")
|
|
108
|
+
for n in self.notes:
|
|
109
|
+
lines.append(f"ℹ️ {n}")
|
|
110
|
+
if self.detail_url:
|
|
111
|
+
lines.append("")
|
|
112
|
+
lines.append(self.detail_url)
|
|
113
|
+
return "\n".join(lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Link / share-code detection
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
# Short links whose ONLY realistic purpose is "open this product" → auto trigger
|
|
121
|
+
JD_SHORT_HOSTS = ("u.jd.com", "jd.cn.hn", "3.cn", "jd.com/")
|
|
122
|
+
TB_SHORT_HOSTS = ("m.tb.cn", "tb.cn")
|
|
123
|
+
|
|
124
|
+
# 淘口令: wrapped in fullwidth symbols, e.g. 8.8¥CZ0001 xxxx¥ / ₴...₴
|
|
125
|
+
# Inner content may contain spaces (real share text like "8.8¥ CZ0001 aBcd12 ¥").
|
|
126
|
+
TAOKOULING_RE = re.compile(r"[¥₴€£]([A-Za-z0-9:\/\.\- ]{6,80})[¥₴€£]")
|
|
127
|
+
|
|
128
|
+
# Full product URLs (pass-through unless via /price)
|
|
129
|
+
JD_ITEM_RE = re.compile(r"item\.jd\.com/(\d+)\.html")
|
|
130
|
+
TB_ITEM_RE = re.compile(r"(?:item\.taobao\.com/item|detail\.tmall\.com/item)\.htm\?.*?id=(\d+)")
|
|
131
|
+
|
|
132
|
+
DT_SHARE_RE = re.compile(r"dwo\.cn/([A-Za-z0-9]+)|dt\.cn/([A-Za-z0-9]+)")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def detect_price_trigger(text: str) -> dict | None:
|
|
136
|
+
"""Classify a text message. Returns {'kind': ..., ...} or None.
|
|
137
|
+
|
|
138
|
+
kinds:
|
|
139
|
+
jd_short — JD short link (auto-trigger)
|
|
140
|
+
jd_item — JD product URL, SKU captured (pass-through, used by /price)
|
|
141
|
+
tkl — taokouling share token (auto-trigger)
|
|
142
|
+
tb_short — Taobao short link (auto-trigger)
|
|
143
|
+
tb_item — Taobao/Tmall product URL (pass-through, used by /price)
|
|
144
|
+
"""
|
|
145
|
+
if not text or len(text) > 4000:
|
|
146
|
+
return None
|
|
147
|
+
|
|
148
|
+
m = TAOKOULING_RE.search(text)
|
|
149
|
+
if m:
|
|
150
|
+
return {"kind": "tkl", "token": m.group(1).strip()}
|
|
151
|
+
|
|
152
|
+
m = DT_SHARE_RE.search(text)
|
|
153
|
+
if m:
|
|
154
|
+
return {"kind": "tb_short", "url": m.group(0)}
|
|
155
|
+
|
|
156
|
+
for host in JD_SHORT_HOSTS:
|
|
157
|
+
m = re.search(r"https?://(?:[a-z0-9\-]+\.)*" + re.escape(host) + r"/\S+", text)
|
|
158
|
+
if m:
|
|
159
|
+
return {"kind": "jd_short", "url": m.group(0)}
|
|
160
|
+
|
|
161
|
+
for host in TB_SHORT_HOSTS:
|
|
162
|
+
m = re.search(r"https?://(?:[a-z0-9\-]+\.)*" + re.escape(host) + r"/\S+", text)
|
|
163
|
+
if m:
|
|
164
|
+
return {"kind": "tb_short", "url": m.group(0)}
|
|
165
|
+
|
|
166
|
+
m = JD_ITEM_RE.search(text)
|
|
167
|
+
if m:
|
|
168
|
+
return {"kind": "jd_item", "sku": m.group(1)}
|
|
169
|
+
|
|
170
|
+
m = TB_ITEM_RE.search(text)
|
|
171
|
+
if m:
|
|
172
|
+
return {"kind": "tb_item", "item_id": m.group(1)}
|
|
173
|
+
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# JD Union official API (sign: MD5(sorted k=v&... + secret))
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
import hashlib
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _jd_sign(params: dict) -> str:
|
|
185
|
+
plain = "".join(
|
|
186
|
+
f"{k}{v}" for k, v in sorted(params.items()) if k != "sign"
|
|
187
|
+
)
|
|
188
|
+
plain += _JD_SECRET_KEY
|
|
189
|
+
return hashlib.md5(plain.encode("utf-8")).hexdigest().upper()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _jd_call(method: str, param_json: dict) -> dict:
|
|
193
|
+
# JD gateway contract: form field is `360buy_param_json`; the signature
|
|
194
|
+
# covers every field except `sign` itself (access_token="" participates).
|
|
195
|
+
params = {
|
|
196
|
+
"method": method,
|
|
197
|
+
"app_key": _JD_APP_KEY,
|
|
198
|
+
"access_token": "",
|
|
199
|
+
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
200
|
+
"format": "json",
|
|
201
|
+
"v": "1.0",
|
|
202
|
+
"sign_method": "md5",
|
|
203
|
+
"360buy_param_json": json.dumps(param_json, ensure_ascii=False, separators=(",", ":")),
|
|
204
|
+
}
|
|
205
|
+
params["sign"] = _jd_sign(params)
|
|
206
|
+
resp = httpx.post(JD_API_URL, data=params, timeout=_DT_TIMEOUT)
|
|
207
|
+
resp.raise_for_status()
|
|
208
|
+
return resp.json()
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def query_jd_sku(sku: str) -> PriceResult:
|
|
212
|
+
"""Query one SKU via jd.union.open.goods.query."""
|
|
213
|
+
data = _jd_call(
|
|
214
|
+
"jd.union.open.goods.query",
|
|
215
|
+
{"skuIds": [str(sku)]},
|
|
216
|
+
)
|
|
217
|
+
resp = (data.get("jd_union_open_goods_query_reslt") or {}).get("data") or []
|
|
218
|
+
if not resp:
|
|
219
|
+
raise ValueError("京东接口未返回该商品(可能非联盟推广商品)")
|
|
220
|
+
g = resp[0]
|
|
221
|
+
price_info = g.get("priceInfo", {})
|
|
222
|
+
coupon = g.get("couponInfo", {}).get("couponList", [{}])[0] if g.get("couponInfo") else {}
|
|
223
|
+
image = (g.get("imageInfo", {}) or {}).get("imageList", [""])[0]
|
|
224
|
+
notes = []
|
|
225
|
+
if not coupon:
|
|
226
|
+
notes.append("当前无可用优惠券")
|
|
227
|
+
return PriceResult(
|
|
228
|
+
platform="jd",
|
|
229
|
+
title=g.get("skuName", "(未知商品)"),
|
|
230
|
+
item_id=str(sku),
|
|
231
|
+
price=float(price_info.get("price", 0) or 0),
|
|
232
|
+
lowest_price=float(price_info.get("lowestPrice", 0) or 0),
|
|
233
|
+
coupon_amount=float(coupon.get("discount", 0) or 0),
|
|
234
|
+
coupon_threshold=float(coupon.get("quota", 0) or 0),
|
|
235
|
+
coupon_end_time=(coupon.get("endTime", "") or "")[:10],
|
|
236
|
+
shop_name=(g.get("shopInfo", {}) or {}).get("shopName", ""),
|
|
237
|
+
image_url=image or "",
|
|
238
|
+
detail_url=g.get("materialUrl", ""),
|
|
239
|
+
notes=notes,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# ---------------------------------------------------------------------------
|
|
244
|
+
# 大淘客 (Taobao/Tmall proxy) API
|
|
245
|
+
# ---------------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
def _dt_call(path: str, params: dict) -> dict:
|
|
248
|
+
params = dict(params)
|
|
249
|
+
params["appKey"] = _DT_APP_KEY
|
|
250
|
+
params["version"] = params.get("version", "v2.0.0")
|
|
251
|
+
resp = httpx.get(f"{DT_API_URL}{path}", params=params, timeout=_DT_TIMEOUT)
|
|
252
|
+
resp.raise_for_status()
|
|
253
|
+
data = resp.json()
|
|
254
|
+
if data.get("code") != 0:
|
|
255
|
+
raise ValueError(f"大淘客接口错误: {data.get('msg', data.get('code'))}")
|
|
256
|
+
return data.get("data", {})
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def resolve_tkl(token: str) -> dict:
|
|
260
|
+
"""Parse taokouling → item info via dataoke tpwd API."""
|
|
261
|
+
return _dt_call(
|
|
262
|
+
"/goods/get-privilege-link",
|
|
263
|
+
{"taoCommand": token, "apiName": "generous"},
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def query_tb_item(item_id: str) -> PriceResult:
|
|
268
|
+
"""Query Taobao/Tmall item via dataoke goods detail API."""
|
|
269
|
+
data = _dt_call("/goods/get-goods-details", {"id": item_id})
|
|
270
|
+
goods = data.get("goods", data)
|
|
271
|
+
quan_price = float(goods.get("quanPrice", 0) or 0)
|
|
272
|
+
actual_price = float(goods.get("actualPrice", goods.get("originalPrice", 0)) or 0)
|
|
273
|
+
return PriceResult(
|
|
274
|
+
platform="taobao",
|
|
275
|
+
title=goods.get("goodsName", "(未知商品)"),
|
|
276
|
+
item_id=str(item_id),
|
|
277
|
+
price=actual_price,
|
|
278
|
+
lowest_price=0.0, # dataoke has no history-lowest; left unknown
|
|
279
|
+
coupon_amount=quan_price,
|
|
280
|
+
coupon_threshold=float(goods.get("quanReceiveCondition", 0) or 0),
|
|
281
|
+
coupon_end_time=(goods.get("quanEndTime", "") or "")[:10],
|
|
282
|
+
shop_name=goods.get("shopName", ""),
|
|
283
|
+
image_url=goods.get("goodsImage", ""),
|
|
284
|
+
detail_url=goods.get("goodsUrl", ""),
|
|
285
|
+
notes=["数据源: 大淘客(淘宝联盟数据)"],
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
# ---------------------------------------------------------------------------
|
|
290
|
+
# Short-link resolution (HEAD/GET follow redirects, extract SKU or tkl)
|
|
291
|
+
# ---------------------------------------------------------------------------
|
|
292
|
+
|
|
293
|
+
def _resolve_short_link(url: str) -> str:
|
|
294
|
+
"""Follow redirects, return final URL (and body sniff for tkl)."""
|
|
295
|
+
with httpx.Client(timeout=_DT_TIMEOUT, follow_redirects=True, headers={
|
|
296
|
+
"User-Agent": (
|
|
297
|
+
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) "
|
|
298
|
+
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1"
|
|
299
|
+
)
|
|
300
|
+
}) as client:
|
|
301
|
+
r = client.get(url)
|
|
302
|
+
final_url = str(r.url)
|
|
303
|
+
# Some JD share pages embed the SKU in an anchor; sniff once.
|
|
304
|
+
m = JD_ITEM_RE.search(final_url) or JD_ITEM_RE.search(r.text[:20000])
|
|
305
|
+
if m:
|
|
306
|
+
return f"https://item.jd.com/{m.group(1)}.html"
|
|
307
|
+
return final_url
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ---------------------------------------------------------------------------
|
|
311
|
+
# Public entry points
|
|
312
|
+
# ---------------------------------------------------------------------------
|
|
313
|
+
|
|
314
|
+
async def handle_price_query(trigger: dict) -> str:
|
|
315
|
+
"""Given detect_price_trigger() result, return a WeChat reply card."""
|
|
316
|
+
try:
|
|
317
|
+
if trigger["kind"] == "jd_item":
|
|
318
|
+
if not jd_configured():
|
|
319
|
+
return _not_configured("京东联盟")
|
|
320
|
+
return query_jd_sku(trigger["sku"]).card()
|
|
321
|
+
|
|
322
|
+
if trigger["kind"] == "jd_short":
|
|
323
|
+
if not jd_configured():
|
|
324
|
+
return _not_configured("京东联盟")
|
|
325
|
+
final = _resolve_short_link(trigger["url"])
|
|
326
|
+
m = JD_ITEM_RE.search(final)
|
|
327
|
+
if not m:
|
|
328
|
+
logger.info("jd short link resolved but no SKU: %s", final)
|
|
329
|
+
return "❓ 短链解析出来不是商品页,试试直接发商品页链接或用 /price + 链接。"
|
|
330
|
+
return query_jd_sku(m.group(1)).card()
|
|
331
|
+
|
|
332
|
+
if trigger["kind"] == "tkl":
|
|
333
|
+
if not dt_configured():
|
|
334
|
+
return _not_configured("大淘客(淘宝口令解析)")
|
|
335
|
+
data = resolve_tkl(trigger["token"])
|
|
336
|
+
goods_id = str(data.get("goodsId", "") or data.get("id", ""))
|
|
337
|
+
if not goods_id:
|
|
338
|
+
return "❓ 口令解析出来了但没拿到商品信息,可能商品未收录。"
|
|
339
|
+
return query_tb_item(goods_id).card()
|
|
340
|
+
|
|
341
|
+
if trigger["kind"] == "tb_item":
|
|
342
|
+
if not dt_configured():
|
|
343
|
+
return _not_configured("大淘客(淘宝商品)")
|
|
344
|
+
return query_tb_item(trigger["item_id"]).card()
|
|
345
|
+
|
|
346
|
+
if trigger["kind"] == "tb_short":
|
|
347
|
+
if not dt_configured():
|
|
348
|
+
return _not_configured("大淘客(淘宝短链)")
|
|
349
|
+
final = _resolve_short_link(trigger["url"])
|
|
350
|
+
m = TB_ITEM_RE.search(final)
|
|
351
|
+
if m:
|
|
352
|
+
return query_tb_item(m.group(1)).card()
|
|
353
|
+
return "❓ 短链解析出来不是商品页,试试直接发商品页链接或用 /price + 链接。"
|
|
354
|
+
|
|
355
|
+
return "❓ 不支持的链接类型。"
|
|
356
|
+
except (ValueError, httpx.HTTPError) as e:
|
|
357
|
+
logger.warning("price query failed: %s", e)
|
|
358
|
+
return f"⚠️ 比价失败: {e}"
|
|
359
|
+
except Exception:
|
|
360
|
+
logger.exception("price query unexpected error")
|
|
361
|
+
return "⚠️ 比价出了点岔子,请稍后再试。"
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _not_configured(what: str) -> str:
|
|
365
|
+
return (
|
|
366
|
+
f"ℹ️ **比价功能未启用** ℹ️\n\n{what} 的 API key 还没配置。\n"
|
|
367
|
+
"在桥的 .env 里设置后重启即可:\n"
|
|
368
|
+
"`JDUNION_APP_KEY` / `JDUNION_APP_SECRET`(京东)\n"
|
|
369
|
+
"`DATAOKE_APP_KEY`(淘宝/天猫)"
|
|
370
|
+
)
|
|
@@ -192,8 +192,12 @@ def clean_output(text: str) -> str:
|
|
|
192
192
|
# WeChat error reply format (fixed header + body)
|
|
193
193
|
# ---------------------------------------------------------------------------
|
|
194
194
|
|
|
195
|
-
# Shown when backend returns successfully but with no display text
|
|
196
|
-
|
|
195
|
+
# Shown when backend returns successfully but with no display text.
|
|
196
|
+
# Empty output after a successful run is, in practice, an exhausted-quota
|
|
197
|
+
# signal (agy/grok/codex CLI stay exit-0 and just print nothing) — say so
|
|
198
|
+
# explicitly instead of the confusing "no text reply" phrasing.
|
|
199
|
+
EMPTY_REPLY = "🔔 对话额度已用尽\n\n后端没有返回任何内容,通常是本账号的对话额度/用量已耗尽。请稍后再试或联系管理员确认额度。"
|
|
200
|
+
|
|
197
201
|
|
|
198
202
|
# Titles used for upstream throttle / quota user replies (🔔 notice style).
|
|
199
203
|
# Keep in sync with format_cli_error branches and is_upstream_throttle_reply.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.6.2 → wechatbridge_cli-1.7.0}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|