wechatbridge-cli 1.7.1__tar.gz → 1.7.2__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.7.1 → wechatbridge_cli-1.7.2}/PKG-INFO +1 -1
- wechatbridge_cli-1.7.2/tests/test_price.py +186 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/__init__.py +1 -1
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/main.py +1 -1
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/price.py +41 -9
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/PKG-INFO +1 -1
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/SOURCES.txt +1 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/LICENSE +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/README.md +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/pyproject.toml +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/setup.cfg +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_codex.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_codex_auth.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_dsh.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_grok_auth.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/tests/test_hardening.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/__main__.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/agy.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/codex.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/config.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/dsh.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/grok.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/ilink.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/runner_common.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge/update_check.py +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/dependency_links.txt +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/entry_points.txt +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/requires.txt +0 -0
- {wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/top_level.txt +0 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Price module contract tests (stdlib unittest only).
|
|
2
|
+
|
|
3
|
+
These pin the *external* contracts that cannot be exercised without real
|
|
4
|
+
API keys: the JD gateway signature rule, the Dataoke signature rule, the
|
|
5
|
+
correct Dataoke endpoints/params for taokouling parsing and goods detail,
|
|
6
|
+
and response parsing against sample payloads shaped like the real APIs.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import os
|
|
13
|
+
import unittest
|
|
14
|
+
from unittest import mock
|
|
15
|
+
|
|
16
|
+
os.environ.setdefault("JDUNION_APP_KEY", "test_jd_key")
|
|
17
|
+
os.environ.setdefault("JDUNION_APP_SECRET", "test_jd_secret")
|
|
18
|
+
os.environ.setdefault("DATAOKE_APP_KEY", "test_dt_key")
|
|
19
|
+
os.environ.setdefault("DATAOKE_APP_SECRET", "test_dt_secret")
|
|
20
|
+
|
|
21
|
+
from wechatbridge import price as price_mod
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestJdSign(unittest.TestCase):
|
|
25
|
+
def test_secret_wraps_both_ends(self):
|
|
26
|
+
params = {
|
|
27
|
+
"method": "jd.union.open.goods.query",
|
|
28
|
+
"app_key": "test_jd_key",
|
|
29
|
+
"access_token": "",
|
|
30
|
+
"timestamp": "2026-11-01 19:00:00",
|
|
31
|
+
"format": "json",
|
|
32
|
+
"v": "1.0",
|
|
33
|
+
"sign_method": "md5",
|
|
34
|
+
"360buy_param_json": '{"skuIds":["123"]}',
|
|
35
|
+
}
|
|
36
|
+
plain = "".join(f"{k}{v}" for k, v in sorted(params.items()))
|
|
37
|
+
expected = hashlib.md5(
|
|
38
|
+
("test_jd_secret" + plain + "test_jd_secret").encode("utf-8")
|
|
39
|
+
).hexdigest().upper()
|
|
40
|
+
self.assertEqual(price_mod._jd_sign(dict(params)), expected)
|
|
41
|
+
|
|
42
|
+
def test_jd_query_parses_sample_payload(self):
|
|
43
|
+
sample = {
|
|
44
|
+
"jd_union_open_goods_query_reslt": {
|
|
45
|
+
"code": "0",
|
|
46
|
+
"data": [
|
|
47
|
+
{
|
|
48
|
+
"skuName": "Sample Widget 2000",
|
|
49
|
+
"priceInfo": {"price": 129.0, "lowestPrice": 99.0},
|
|
50
|
+
"couponInfo": {
|
|
51
|
+
"couponList": [
|
|
52
|
+
{"discount": 20.0, "quota": 100.0, "endTime": "2026-11-11 23:59:59"}
|
|
53
|
+
]
|
|
54
|
+
},
|
|
55
|
+
"imageInfo": {"imageList": ["https://img.example/x.jpg"]},
|
|
56
|
+
"shopInfo": {"shopName": "Sample Shop"},
|
|
57
|
+
"materialUrl": "https://item.jd.com/123.html",
|
|
58
|
+
}
|
|
59
|
+
],
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
with mock.patch.object(price_mod, "_jd_call", return_value=sample):
|
|
63
|
+
res = price_mod.query_jd_sku("123")
|
|
64
|
+
self.assertEqual(res.title, "Sample Widget 2000")
|
|
65
|
+
self.assertEqual(res.price, 129.0)
|
|
66
|
+
self.assertEqual(res.lowest_price, 99.0)
|
|
67
|
+
self.assertEqual(res.net_price(), 109.0)
|
|
68
|
+
card = res.card()
|
|
69
|
+
self.assertIn("到手价: ¥109", card)
|
|
70
|
+
self.assertIn("历史最低: ¥99", card)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class TestDataokeSignAndCalls(unittest.TestCase):
|
|
74
|
+
def test_dt_sign_matches_sdk_algorithm(self):
|
|
75
|
+
params = {"goodsId": "789", "appKey": "test_dt_key", "version": "v2.0.0"}
|
|
76
|
+
plain = "&".join(f"{k}={v}" for k, v in sorted(params.items()))
|
|
77
|
+
expected = hashlib.md5(
|
|
78
|
+
(plain + "&key=test_dt_secret").encode("utf-8")
|
|
79
|
+
).hexdigest().upper()
|
|
80
|
+
self.assertEqual(price_mod._dt_sign(dict(params)), expected)
|
|
81
|
+
|
|
82
|
+
def test_dt_call_attaches_appkey_version_and_sign(self):
|
|
83
|
+
captured = {}
|
|
84
|
+
|
|
85
|
+
class FakeResp:
|
|
86
|
+
def raise_for_status(self):
|
|
87
|
+
pass
|
|
88
|
+
|
|
89
|
+
def json(self):
|
|
90
|
+
return {"code": 0, "data": {"ok": True}}
|
|
91
|
+
|
|
92
|
+
def fake_get(url, params=None, timeout=None):
|
|
93
|
+
captured["url"] = url
|
|
94
|
+
captured["params"] = dict(params or {})
|
|
95
|
+
return FakeResp()
|
|
96
|
+
|
|
97
|
+
with mock.patch.object(price_mod.httpx, "get", side_effect=fake_get):
|
|
98
|
+
out = price_mod._dt_call("/goods/get-goods-details", {"goodsId": "789"})
|
|
99
|
+
self.assertEqual(out, {"ok": True})
|
|
100
|
+
self.assertEqual(captured["params"]["appKey"], "test_dt_key")
|
|
101
|
+
self.assertEqual(captured["params"]["version"], "v2.0.0")
|
|
102
|
+
unsigned = {k: v for k, v in captured["params"].items() if k != "sign"}
|
|
103
|
+
self.assertEqual(captured["params"]["sign"], price_mod._dt_sign(unsigned))
|
|
104
|
+
|
|
105
|
+
def test_dt_call_raises_on_nonzero_code(self):
|
|
106
|
+
class FakeResp:
|
|
107
|
+
def raise_for_status(self):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def json(self):
|
|
111
|
+
return {"code": 1, "msg": "sign error"}
|
|
112
|
+
|
|
113
|
+
with mock.patch.object(price_mod.httpx, "get", return_value=FakeResp()):
|
|
114
|
+
with self.assertRaises(ValueError):
|
|
115
|
+
price_mod._dt_call("/goods/get-goods-details", {"goodsId": "1"})
|
|
116
|
+
|
|
117
|
+
def test_resolve_tkl_uses_parse_content_endpoint(self):
|
|
118
|
+
captured = {}
|
|
119
|
+
|
|
120
|
+
def fake_call(path, params):
|
|
121
|
+
captured["path"] = path
|
|
122
|
+
captured["params"] = dict(params)
|
|
123
|
+
return {"goodsId": "789456"}
|
|
124
|
+
|
|
125
|
+
with mock.patch.object(price_mod, "_dt_call", side_effect=fake_call):
|
|
126
|
+
out = price_mod.resolve_tkl("CZ0001 aBcd12")
|
|
127
|
+
self.assertEqual(captured["path"], "/tb-service/parse-content")
|
|
128
|
+
self.assertEqual(captured["params"]["content"], "CZ0001 aBcd12")
|
|
129
|
+
self.assertEqual(out["goodsId"], "789456")
|
|
130
|
+
|
|
131
|
+
def test_query_tb_item_sends_goods_id_and_parses(self):
|
|
132
|
+
captured = {}
|
|
133
|
+
sample_data = {
|
|
134
|
+
"goods": {
|
|
135
|
+
"goodsName": "TB Sample",
|
|
136
|
+
"actualPrice": 59.9,
|
|
137
|
+
"originalPrice": 79.9,
|
|
138
|
+
"quanPrice": 10.0,
|
|
139
|
+
"quanReceiveCondition": 50.0,
|
|
140
|
+
"quanEndTime": "2026-11-11 00:00:00",
|
|
141
|
+
"shopName": "TB Shop",
|
|
142
|
+
"goodsImage": "https://img.example/y.jpg",
|
|
143
|
+
"goodsUrl": "https://detail.tmall.com/item.htm?id=789",
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
def fake_call(path, params):
|
|
148
|
+
captured["path"] = path
|
|
149
|
+
captured["params"] = dict(params)
|
|
150
|
+
return sample_data
|
|
151
|
+
|
|
152
|
+
with mock.patch.object(price_mod, "_dt_call", side_effect=fake_call):
|
|
153
|
+
res = price_mod.query_tb_item("789")
|
|
154
|
+
self.assertEqual(captured["params"], {"goodsId": "789"})
|
|
155
|
+
self.assertEqual(res.title, "TB Sample")
|
|
156
|
+
self.assertEqual(res.price, 59.9)
|
|
157
|
+
self.assertEqual(res.net_price(), 49.9)
|
|
158
|
+
|
|
159
|
+
def test_dt_configured_requires_secret(self):
|
|
160
|
+
with mock.patch.object(price_mod, "_DT_APP_SECRET", ""):
|
|
161
|
+
self.assertFalse(price_mod.dt_configured())
|
|
162
|
+
with mock.patch.object(price_mod, "_DT_APP_SECRET", "x"):
|
|
163
|
+
self.assertTrue(price_mod.dt_configured())
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
class TestDetectTrigger(unittest.TestCase):
|
|
167
|
+
def test_samples(self):
|
|
168
|
+
cases = [
|
|
169
|
+
("看看 https://u.jd.com/AbCdEf 不错", "jd_short"),
|
|
170
|
+
("8.8¥CZ0001 aBcd12¥ 打开淘宝", "tkl"),
|
|
171
|
+
("https://item.jd.com/100012043978.html", "jd_item"),
|
|
172
|
+
("https://detail.tmall.com/item.htm?id=789456123", "tb_item"),
|
|
173
|
+
("https://m.tb.cn/h.abc123", "tb_short"),
|
|
174
|
+
("今天天气不错", None),
|
|
175
|
+
]
|
|
176
|
+
for text, kind in cases:
|
|
177
|
+
got = price_mod.detect_price_trigger(text)
|
|
178
|
+
if kind is None:
|
|
179
|
+
self.assertIsNone(got, msg=text)
|
|
180
|
+
else:
|
|
181
|
+
self.assertIsNotNone(got, msg=text)
|
|
182
|
+
self.assertEqual(got["kind"], kind, msg=text)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
if __name__ == "__main__":
|
|
186
|
+
unittest.main()
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""WeChatBridge — bridge WeChat messages to agy, Grok Build, or Codex CLIs."""
|
|
2
|
-
__version__ = "1.7.
|
|
2
|
+
__version__ = "1.7.2"
|
|
@@ -467,7 +467,7 @@ async def _cmd_price(args: str) -> str:
|
|
|
467
467
|
"ℹ️ **比价功能未启用** ℹ️\n\n"
|
|
468
468
|
"在桥的 .env 里配置以下任一项后重启:\n"
|
|
469
469
|
"`JDUNION_APP_KEY` / `JDUNION_APP_SECRET`(京东联盟)\n"
|
|
470
|
-
"`DATAOKE_APP_KEY`(淘宝/天猫,大淘客代理)"
|
|
470
|
+
"`DATAOKE_APP_KEY` / `DATAOKE_APP_SECRET`(淘宝/天猫,大淘客代理)"
|
|
471
471
|
)
|
|
472
472
|
trigger = detect_price_trigger(target)
|
|
473
473
|
if not trigger:
|
|
@@ -37,6 +37,7 @@ _JD_APP_KEY = os.getenv("JDUNION_APP_KEY", "")
|
|
|
37
37
|
# false-positive on this empty getenv default (no literal secret value here).
|
|
38
38
|
_JD_SECRET_KEY = os.getenv("JDUNION_APP_SECRET", "")
|
|
39
39
|
_DT_APP_KEY = os.getenv("DATAOKE_APP_KEY", "")
|
|
40
|
+
_DT_APP_SECRET = os.getenv("DATAOKE_APP_SECRET", "")
|
|
40
41
|
_DT_TIMEOUT = int(os.getenv("PRICE_HTTP_TIMEOUT", "15"))
|
|
41
42
|
|
|
42
43
|
JD_API_URL = "https://api.jd.com/routerjson"
|
|
@@ -48,7 +49,9 @@ def jd_configured() -> bool:
|
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def dt_configured() -> bool:
|
|
51
|
-
|
|
52
|
+
# Dataoke rejects unsigned requests: both the appKey and the appSecret
|
|
53
|
+
# (used only locally to compute `sign`, never sent) are required.
|
|
54
|
+
return bool(_DT_APP_KEY and _DT_APP_SECRET)
|
|
52
55
|
|
|
53
56
|
|
|
54
57
|
def price_feature_ready() -> bool:
|
|
@@ -182,11 +185,15 @@ def detect_price_trigger(text: str) -> dict | None:
|
|
|
182
185
|
|
|
183
186
|
|
|
184
187
|
def _jd_sign(params: dict) -> str:
|
|
188
|
+
# JD gateway rule: MD5(secret + sorted key+value concat + secret), upper.
|
|
189
|
+
# The secret wraps BOTH ends — appending it only at the end produces a
|
|
190
|
+
# signature the gateway always rejects.
|
|
185
191
|
plain = "".join(
|
|
186
192
|
f"{k}{v}" for k, v in sorted(params.items()) if k != "sign"
|
|
187
193
|
)
|
|
188
|
-
|
|
189
|
-
|
|
194
|
+
return hashlib.md5(
|
|
195
|
+
(_JD_SECRET_KEY + plain + _JD_SECRET_KEY).encode("utf-8")
|
|
196
|
+
).hexdigest().upper()
|
|
190
197
|
|
|
191
198
|
|
|
192
199
|
def _jd_call(method: str, param_json: dict) -> dict:
|
|
@@ -244,10 +251,23 @@ def query_jd_sku(sku: str) -> PriceResult:
|
|
|
244
251
|
# 大淘客 (Taobao/Tmall proxy) API
|
|
245
252
|
# ---------------------------------------------------------------------------
|
|
246
253
|
|
|
254
|
+
def _dt_sign(params: dict) -> str:
|
|
255
|
+
# Dataoke rule (per the official SDK): sort every request parameter
|
|
256
|
+
# (appKey and version included, sign excluded) by key, join as
|
|
257
|
+
# "k1=v1&k2=v2&...", append "&key=<appSecret>", MD5, uppercase.
|
|
258
|
+
plain = "&".join(
|
|
259
|
+
f"{k}={v}" for k, v in sorted(params.items()) if k != "sign"
|
|
260
|
+
)
|
|
261
|
+
return hashlib.md5(
|
|
262
|
+
(plain + "&key=" + _DT_APP_SECRET).encode("utf-8")
|
|
263
|
+
).hexdigest().upper()
|
|
264
|
+
|
|
265
|
+
|
|
247
266
|
def _dt_call(path: str, params: dict) -> dict:
|
|
248
267
|
params = dict(params)
|
|
249
268
|
params["appKey"] = _DT_APP_KEY
|
|
250
269
|
params["version"] = params.get("version", "v2.0.0")
|
|
270
|
+
params["sign"] = _dt_sign(params)
|
|
251
271
|
resp = httpx.get(f"{DT_API_URL}{path}", params=params, timeout=_DT_TIMEOUT)
|
|
252
272
|
resp.raise_for_status()
|
|
253
273
|
data = resp.json()
|
|
@@ -257,16 +277,24 @@ def _dt_call(path: str, params: dict) -> dict:
|
|
|
257
277
|
|
|
258
278
|
|
|
259
279
|
def resolve_tkl(token: str) -> dict:
|
|
260
|
-
"""Parse taokouling → item info via dataoke
|
|
280
|
+
"""Parse taokouling → item info via dataoke's universal parse API.
|
|
281
|
+
|
|
282
|
+
The endpoint for turning a share token / share text into a goods id is
|
|
283
|
+
/tb-service/parse-content (param: content). The privilege-link endpoint
|
|
284
|
+
only converts an already-known goodsId into a promotion link and cannot
|
|
285
|
+
parse tokens.
|
|
286
|
+
"""
|
|
261
287
|
return _dt_call(
|
|
262
|
-
"/
|
|
263
|
-
{"
|
|
288
|
+
"/tb-service/parse-content",
|
|
289
|
+
{"content": token, "version": "v1.0.0"},
|
|
264
290
|
)
|
|
265
291
|
|
|
266
292
|
|
|
267
293
|
def query_tb_item(item_id: str) -> PriceResult:
|
|
268
294
|
"""Query Taobao/Tmall item via dataoke goods detail API."""
|
|
269
|
-
|
|
295
|
+
# get-goods-details takes the Taobao item id as `goodsId`, not `id`
|
|
296
|
+
# (`id` is dataoke's own internal goods id and matches nothing here).
|
|
297
|
+
data = _dt_call("/goods/get-goods-details", {"goodsId": str(item_id)})
|
|
270
298
|
goods = data.get("goods", data)
|
|
271
299
|
quan_price = float(goods.get("quanPrice", 0) or 0)
|
|
272
300
|
actual_price = float(goods.get("actualPrice", goods.get("originalPrice", 0)) or 0)
|
|
@@ -400,7 +428,11 @@ async def handle_price_query(trigger: dict) -> str:
|
|
|
400
428
|
if not dt_configured():
|
|
401
429
|
return _not_configured("大淘客(淘宝口令解析)")
|
|
402
430
|
data = await asyncio.to_thread(resolve_tkl, trigger["token"])
|
|
403
|
-
goods_id = str(
|
|
431
|
+
goods_id = str(
|
|
432
|
+
data.get("goodsId", "")
|
|
433
|
+
or data.get("itemId", "")
|
|
434
|
+
or data.get("id", "")
|
|
435
|
+
)
|
|
404
436
|
if not goods_id:
|
|
405
437
|
return "❓ 口令解析出来了但没拿到商品信息,可能商品未收录。"
|
|
406
438
|
res = await asyncio.to_thread(query_tb_item, goods_id)
|
|
@@ -440,5 +472,5 @@ def _not_configured(what: str) -> str:
|
|
|
440
472
|
f"ℹ️ **比价功能未启用** ℹ️\n\n{what} 的 API key 还没配置。\n"
|
|
441
473
|
"在桥的 .env 里设置后重启即可:\n"
|
|
442
474
|
"`JDUNION_APP_KEY` / `JDUNION_APP_SECRET`(京东)\n"
|
|
443
|
-
"`DATAOKE_APP_KEY`(淘宝/天猫)"
|
|
475
|
+
"`DATAOKE_APP_KEY` / `DATAOKE_APP_SECRET`(淘宝/天猫)"
|
|
444
476
|
)
|
|
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
|
|
File without changes
|
|
File without changes
|
{wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{wechatbridge_cli-1.7.1 → wechatbridge_cli-1.7.2}/wechatbridge_cli.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|