goofish-cli 0.1.0__py3-none-any.whl
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.
- goofish_cli/__init__.py +1 -0
- goofish_cli/cli.py +119 -0
- goofish_cli/commands/__init__.py +0 -0
- goofish_cli/commands/auth/__init__.py +0 -0
- goofish_cli/commands/auth/login.py +66 -0
- goofish_cli/commands/auth/reset_guard.py +16 -0
- goofish_cli/commands/auth/status.py +40 -0
- goofish_cli/commands/category/__init__.py +0 -0
- goofish_cli/commands/category/recommend.py +64 -0
- goofish_cli/commands/item/__init__.py +0 -0
- goofish_cli/commands/item/delete.py +32 -0
- goofish_cli/commands/item/get.py +34 -0
- goofish_cli/commands/item/publish.py +175 -0
- goofish_cli/commands/location/__init__.py +0 -0
- goofish_cli/commands/location/default.py +40 -0
- goofish_cli/commands/media/__init__.py +0 -0
- goofish_cli/commands/media/upload.py +53 -0
- goofish_cli/commands/message/__init__.py +0 -0
- goofish_cli/commands/message/history.py +23 -0
- goofish_cli/commands/message/send.py +102 -0
- goofish_cli/commands/message/watch.py +58 -0
- goofish_cli/core/__init__.py +26 -0
- goofish_cli/core/errors.py +31 -0
- goofish_cli/core/guard.py +63 -0
- goofish_cli/core/limiter.py +60 -0
- goofish_cli/core/mtop.py +131 -0
- goofish_cli/core/output.py +82 -0
- goofish_cli/core/registry.py +78 -0
- goofish_cli/core/session.py +88 -0
- goofish_cli/core/sign.py +37 -0
- goofish_cli/core/strategy.py +10 -0
- goofish_cli/core/token.py +88 -0
- goofish_cli/core/ws.py +493 -0
- goofish_cli/mcp_server.py +56 -0
- goofish_cli/static/goofish_js_version_2.js +568 -0
- goofish_cli-0.1.0.dist-info/METADATA +279 -0
- goofish_cli-0.1.0.dist-info/RECORD +41 -0
- goofish_cli-0.1.0.dist-info/WHEEL +4 -0
- goofish_cli-0.1.0.dist-info/entry_points.txt +3 -0
- goofish_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
- goofish_cli-0.1.0.dist-info/licenses/NOTICE +11 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""message history — 拉取指定会话的历史消息。
|
|
2
|
+
|
|
3
|
+
走 WebSocket LWP `/r/MessageManager/listUserMessages`(官方没有对应的 HTTP 版)。
|
|
4
|
+
一次性命令:连 → reg → 翻页直到 hasMore=0 → 断开。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from goofish_cli.core import Session, Strategy, command
|
|
11
|
+
from goofish_cli.core.ws import list_user_messages
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@command(
|
|
15
|
+
namespace="message",
|
|
16
|
+
name="history",
|
|
17
|
+
description="拉取指定 cid 会话的历史消息(翻页到底)",
|
|
18
|
+
strategy=Strategy.COOKIE,
|
|
19
|
+
columns=["send_user_id", "send_user_name", "message"],
|
|
20
|
+
)
|
|
21
|
+
def history(cid: str, limit_per_page: int = 20) -> list[dict[str, Any]]:
|
|
22
|
+
session = Session.load()
|
|
23
|
+
return asyncio.run(list_user_messages(session, cid, limit_per_page=limit_per_page))
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""message send — 向指定会话发送一条消息(文本/图片)。
|
|
2
|
+
|
|
3
|
+
写操作,走限流 + 熔断。未知 cid 时可传 --item-id 自动创建单聊。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import asyncio
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
from goofish_cli.core import Session, Strategy, command
|
|
10
|
+
from goofish_cli.core.guard import watch
|
|
11
|
+
from goofish_cli.core.limiter import acquire
|
|
12
|
+
from goofish_cli.core.token import get_access_token
|
|
13
|
+
from goofish_cli.core.ws import (
|
|
14
|
+
connect,
|
|
15
|
+
create_chat,
|
|
16
|
+
heartbeat_loop,
|
|
17
|
+
register,
|
|
18
|
+
send_image,
|
|
19
|
+
send_text,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@command(
|
|
24
|
+
namespace="message",
|
|
25
|
+
name="send",
|
|
26
|
+
description="向会话发送消息(text/image)。text 必填,image 走 url+wh",
|
|
27
|
+
strategy=Strategy.COOKIE,
|
|
28
|
+
columns=["cid", "toid", "kind", "ok", "mid"],
|
|
29
|
+
write=True,
|
|
30
|
+
)
|
|
31
|
+
def send(
|
|
32
|
+
cid: str,
|
|
33
|
+
toid: str,
|
|
34
|
+
text: str = "",
|
|
35
|
+
*,
|
|
36
|
+
kind: Literal["text", "image"] = "text",
|
|
37
|
+
image_url: str = "",
|
|
38
|
+
image_width: int = 0,
|
|
39
|
+
image_height: int = 0,
|
|
40
|
+
item_id: str = "",
|
|
41
|
+
) -> dict[str, Any]:
|
|
42
|
+
session = Session.load()
|
|
43
|
+
with acquire("message.write"), watch():
|
|
44
|
+
return asyncio.run(_send(
|
|
45
|
+
session,
|
|
46
|
+
cid=cid,
|
|
47
|
+
toid=toid,
|
|
48
|
+
text=text,
|
|
49
|
+
kind=kind,
|
|
50
|
+
image_url=image_url,
|
|
51
|
+
image_width=image_width,
|
|
52
|
+
image_height=image_height,
|
|
53
|
+
item_id=item_id,
|
|
54
|
+
))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def _send(
|
|
58
|
+
session: Session,
|
|
59
|
+
*,
|
|
60
|
+
cid: str,
|
|
61
|
+
toid: str,
|
|
62
|
+
text: str,
|
|
63
|
+
kind: str,
|
|
64
|
+
image_url: str,
|
|
65
|
+
image_width: int,
|
|
66
|
+
image_height: int,
|
|
67
|
+
item_id: str,
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
token = get_access_token(session)
|
|
70
|
+
async with connect(session) as ws:
|
|
71
|
+
await register(ws, session, token)
|
|
72
|
+
hb = asyncio.create_task(heartbeat_loop(ws))
|
|
73
|
+
try:
|
|
74
|
+
if item_id:
|
|
75
|
+
await create_chat(ws, myid=session.unb, toid=toid, item_id=item_id)
|
|
76
|
+
await asyncio.sleep(0.5)
|
|
77
|
+
|
|
78
|
+
if kind == "text":
|
|
79
|
+
if not text:
|
|
80
|
+
raise ValueError("kind=text 需要 --text")
|
|
81
|
+
mid = await send_text(
|
|
82
|
+
ws, myid=session.unb, cid=cid, toid=toid, text=text
|
|
83
|
+
)
|
|
84
|
+
elif kind == "image":
|
|
85
|
+
if not (image_url and image_width and image_height):
|
|
86
|
+
raise ValueError("kind=image 需要 --image-url/--image-width/--image-height")
|
|
87
|
+
mid = await send_image(
|
|
88
|
+
ws,
|
|
89
|
+
myid=session.unb,
|
|
90
|
+
cid=cid,
|
|
91
|
+
toid=toid,
|
|
92
|
+
url=image_url,
|
|
93
|
+
width=image_width,
|
|
94
|
+
height=image_height,
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
raise ValueError(f"不支持的 kind: {kind}")
|
|
98
|
+
# 等一轮 ack 回包,避免 WS 提前关
|
|
99
|
+
await asyncio.sleep(1.0)
|
|
100
|
+
finally:
|
|
101
|
+
hb.cancel()
|
|
102
|
+
return {"cid": cid, "toid": toid, "kind": kind, "ok": True, "mid": mid}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""message watch — 常驻接收消息,每条解密后以 JSONL 打到 stdout。
|
|
2
|
+
|
|
3
|
+
不做自动回复;Agent 自己拿 JSONL 决策再调 message send。
|
|
4
|
+
Ctrl-C 退出。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from loguru import logger
|
|
13
|
+
|
|
14
|
+
from goofish_cli.core import Session, Strategy, command
|
|
15
|
+
from goofish_cli.core.ws import (
|
|
16
|
+
extract_incoming_text,
|
|
17
|
+
extract_meta_event,
|
|
18
|
+
extract_push_messages,
|
|
19
|
+
run_forever,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@command(
|
|
24
|
+
namespace="message",
|
|
25
|
+
name="watch",
|
|
26
|
+
description="常驻 IM 长连接,下行事件以 JSONL 输出到 stdout(Ctrl-C 退出)",
|
|
27
|
+
strategy=Strategy.COOKIE,
|
|
28
|
+
columns=[],
|
|
29
|
+
)
|
|
30
|
+
def watch() -> dict[str, Any]:
|
|
31
|
+
session = Session.load()
|
|
32
|
+
logger.info(f"[watch] unb={session.unb} tracknick={session.tracknick}")
|
|
33
|
+
|
|
34
|
+
async def _handler(msg: dict[str, Any], _ws) -> None:
|
|
35
|
+
# /s/para 是对方"正在输入"状态通知,跳过避免噪音
|
|
36
|
+
if msg.get("lwp") == "/s/para":
|
|
37
|
+
return
|
|
38
|
+
for decoded in extract_push_messages(msg):
|
|
39
|
+
# 优先识别元事件(read / new_msg 通知)
|
|
40
|
+
meta = extract_meta_event(decoded)
|
|
41
|
+
if meta is not None:
|
|
42
|
+
sys.stdout.write(json.dumps(meta, ensure_ascii=False) + "\n")
|
|
43
|
+
sys.stdout.flush()
|
|
44
|
+
continue
|
|
45
|
+
# 再尝试嵌套消息正文
|
|
46
|
+
item = extract_incoming_text(decoded)
|
|
47
|
+
if item is None:
|
|
48
|
+
continue
|
|
49
|
+
# 真消息:send_message 非空 或 contentType=1
|
|
50
|
+
if item.get("send_message") or item.get("content_type") == 1:
|
|
51
|
+
sys.stdout.write(json.dumps(item, ensure_ascii=False) + "\n")
|
|
52
|
+
sys.stdout.flush()
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
asyncio.run(run_forever(session, handler=_handler))
|
|
56
|
+
except KeyboardInterrupt:
|
|
57
|
+
logger.info("[watch] 用户中断")
|
|
58
|
+
return {"ok": True}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from goofish_cli.core.errors import (
|
|
2
|
+
AuthRequiredError,
|
|
3
|
+
GoofishError,
|
|
4
|
+
NotFoundError,
|
|
5
|
+
RateLimitedError,
|
|
6
|
+
RiskControlError,
|
|
7
|
+
SignError,
|
|
8
|
+
)
|
|
9
|
+
from goofish_cli.core.registry import Command, command, iter_commands, registry
|
|
10
|
+
from goofish_cli.core.session import Session
|
|
11
|
+
from goofish_cli.core.strategy import Strategy
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AuthRequiredError",
|
|
15
|
+
"Command",
|
|
16
|
+
"GoofishError",
|
|
17
|
+
"NotFoundError",
|
|
18
|
+
"RateLimitedError",
|
|
19
|
+
"RiskControlError",
|
|
20
|
+
"Session",
|
|
21
|
+
"SignError",
|
|
22
|
+
"Strategy",
|
|
23
|
+
"command",
|
|
24
|
+
"iter_commands",
|
|
25
|
+
"registry",
|
|
26
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""统一异常体系。driver 层根据响应体 ret 或状态自动抛对应异常。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GoofishError(Exception):
|
|
6
|
+
exit_code = 1
|
|
7
|
+
|
|
8
|
+
def __init__(self, message: str, *, raw: dict | None = None):
|
|
9
|
+
super().__init__(message)
|
|
10
|
+
self.raw = raw
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AuthRequiredError(GoofishError):
|
|
14
|
+
exit_code = 77
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SignError(GoofishError):
|
|
18
|
+
exit_code = 78
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RateLimitedError(GoofishError):
|
|
22
|
+
exit_code = 75
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class RiskControlError(GoofishError):
|
|
26
|
+
"""触发风控:RGV587 / punish / FAIL_SYS_USER_VALIDATE 等。"""
|
|
27
|
+
exit_code = 76
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotFoundError(GoofishError):
|
|
31
|
+
exit_code = 79
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""风控熔断。检测到 RiskControlError 后写入熔断时间戳,后续请求直接拒绝。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import time
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from goofish_cli.core.errors import RiskControlError
|
|
11
|
+
|
|
12
|
+
STATE_PATH = Path.home() / ".goofish-cli" / "circuit.json"
|
|
13
|
+
DEFAULT_BREAK_MINUTES = 10
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _break_seconds() -> int:
|
|
17
|
+
try:
|
|
18
|
+
return max(60, int(os.environ.get("GOOFISH_CIRCUIT_BREAK_MINUTES", DEFAULT_BREAK_MINUTES)) * 60)
|
|
19
|
+
except ValueError:
|
|
20
|
+
return DEFAULT_BREAK_MINUTES * 60
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _load() -> float:
|
|
24
|
+
if not STATE_PATH.exists():
|
|
25
|
+
return 0.0
|
|
26
|
+
try:
|
|
27
|
+
return float(json.loads(STATE_PATH.read_text()).get("until", 0))
|
|
28
|
+
except (json.JSONDecodeError, OSError, ValueError):
|
|
29
|
+
return 0.0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _save(until: float) -> None:
|
|
33
|
+
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
STATE_PATH.write_text(json.dumps({"until": until}))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def check() -> None:
|
|
38
|
+
until = _load()
|
|
39
|
+
if until and time.time() < until:
|
|
40
|
+
remain = int(until - time.time())
|
|
41
|
+
raise RiskControlError(
|
|
42
|
+
f"风控熔断中,剩余 {remain}s。触发后自动冷却,可通过 `goofish auth reset-guard` 手动解除。"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def trip(reason: str = "") -> None:
|
|
47
|
+
_save(time.time() + _break_seconds())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def reset() -> None:
|
|
51
|
+
if STATE_PATH.exists():
|
|
52
|
+
STATE_PATH.unlink()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@contextmanager
|
|
56
|
+
def watch():
|
|
57
|
+
"""包住写操作:命中 RGV587 自动熔断。"""
|
|
58
|
+
check()
|
|
59
|
+
try:
|
|
60
|
+
yield
|
|
61
|
+
except RiskControlError:
|
|
62
|
+
trip()
|
|
63
|
+
raise
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""令牌桶限流。单账号 + 单命名空间,默认 1 写/分钟(可配)。
|
|
2
|
+
|
|
3
|
+
写入 ~/.goofish-cli/limiter.json 做进程间共享(单机多进程场景)。
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import time
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from goofish_cli.core.errors import RateLimitedError
|
|
14
|
+
|
|
15
|
+
STATE_PATH = Path.home() / ".goofish-cli" / "limiter.json"
|
|
16
|
+
DEFAULT_WRITE_RPM = 1
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _rpm() -> int:
|
|
20
|
+
try:
|
|
21
|
+
return max(1, int(os.environ.get("GOOFISH_WRITE_RPM", DEFAULT_WRITE_RPM)))
|
|
22
|
+
except ValueError:
|
|
23
|
+
return DEFAULT_WRITE_RPM
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _load() -> dict[str, list[float]]:
|
|
27
|
+
if not STATE_PATH.exists():
|
|
28
|
+
return {}
|
|
29
|
+
try:
|
|
30
|
+
return json.loads(STATE_PATH.read_text())
|
|
31
|
+
except (json.JSONDecodeError, OSError):
|
|
32
|
+
return {}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _save(state: dict[str, list[float]]) -> None:
|
|
36
|
+
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
|
37
|
+
STATE_PATH.write_text(json.dumps(state))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def check(bucket: str) -> None:
|
|
41
|
+
"""消耗一个令牌。超限抛 RateLimitedError。"""
|
|
42
|
+
now = time.time()
|
|
43
|
+
window = 60.0
|
|
44
|
+
rpm = _rpm()
|
|
45
|
+
state = _load()
|
|
46
|
+
hits = [t for t in state.get(bucket, []) if now - t < window]
|
|
47
|
+
if len(hits) >= rpm:
|
|
48
|
+
wait = window - (now - hits[0])
|
|
49
|
+
raise RateLimitedError(
|
|
50
|
+
f"限流:bucket={bucket} 每 {window:.0f}s 上限 {rpm},再等 {wait:.1f}s"
|
|
51
|
+
)
|
|
52
|
+
hits.append(now)
|
|
53
|
+
state[bucket] = hits
|
|
54
|
+
_save(state)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@contextmanager
|
|
58
|
+
def acquire(bucket: str):
|
|
59
|
+
check(bucket)
|
|
60
|
+
yield
|
goofish_cli/core/mtop.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""统一 mtop 调用模板。抽出 headers/params/sign 流程。
|
|
2
|
+
|
|
3
|
+
要点:
|
|
4
|
+
- `t` 取真实毫秒:`int(time.time() * 1000)`,而不是 `int(time.time()) * 1000`(后者末三位恒为 0)
|
|
5
|
+
- 自动识别风控关键字抛 RiskControlError
|
|
6
|
+
- 自动识别令牌过期抛 AuthRequiredError
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from goofish_cli.core.errors import (
|
|
15
|
+
AuthRequiredError,
|
|
16
|
+
GoofishError,
|
|
17
|
+
NotFoundError,
|
|
18
|
+
RiskControlError,
|
|
19
|
+
SignError,
|
|
20
|
+
)
|
|
21
|
+
from goofish_cli.core.session import USER_AGENT, Session
|
|
22
|
+
from goofish_cli.core.sign import generate_sign
|
|
23
|
+
|
|
24
|
+
APP_KEY = "34839810"
|
|
25
|
+
MTOP_HOST = "https://h5api.m.goofish.com"
|
|
26
|
+
|
|
27
|
+
_RISK_KEYWORDS = (
|
|
28
|
+
"RGV587_ERROR",
|
|
29
|
+
"FAIL_SYS_USER_VALIDATE",
|
|
30
|
+
"哎哟喂",
|
|
31
|
+
"/punish",
|
|
32
|
+
)
|
|
33
|
+
_AUTH_KEYWORDS = (
|
|
34
|
+
"FAIL_SYS_SESSION_EXPIRED",
|
|
35
|
+
"FAIL_SYS_TOKEN_EXOIRED",
|
|
36
|
+
"FAIL_SYS_TOKEN_EMPTY",
|
|
37
|
+
"令牌过期",
|
|
38
|
+
"FAIL_SYS_ILLEGAL_ACCESS",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def default_headers() -> dict[str, str]:
|
|
43
|
+
return {
|
|
44
|
+
"accept": "application/json",
|
|
45
|
+
"accept-language": "en,zh-CN;q=0.9,zh;q=0.8,zh-TW;q=0.7,ja;q=0.6",
|
|
46
|
+
"cache-control": "no-cache",
|
|
47
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
48
|
+
"origin": "https://www.goofish.com",
|
|
49
|
+
"pragma": "no-cache",
|
|
50
|
+
"priority": "u=1, i",
|
|
51
|
+
"referer": "https://www.goofish.com/",
|
|
52
|
+
"sec-ch-ua": '"Chromium";v="146", "Not-A.Brand";v="24", "Google Chrome";v="146"',
|
|
53
|
+
"sec-ch-ua-mobile": "?0",
|
|
54
|
+
"sec-ch-ua-platform": '"macOS"',
|
|
55
|
+
"sec-fetch-dest": "empty",
|
|
56
|
+
"sec-fetch-mode": "cors",
|
|
57
|
+
"sec-fetch-site": "same-site",
|
|
58
|
+
"user-agent": USER_AGENT,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def call(
|
|
63
|
+
session: Session,
|
|
64
|
+
api: str,
|
|
65
|
+
data: dict[str, Any] | list[Any] | str,
|
|
66
|
+
*,
|
|
67
|
+
version: str = "1.0",
|
|
68
|
+
spm_cnt: str = "a21ybx.home.0.0",
|
|
69
|
+
extra_params: dict[str, str] | None = None,
|
|
70
|
+
headers: dict[str, str] | None = None,
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""调用 mtop 接口。返回原始 JSON。失败抛 GoofishError 子类。"""
|
|
73
|
+
url = f"{MTOP_HOST}/h5/{api}/{version}/"
|
|
74
|
+
t_ms = str(int(time.time() * 1000))
|
|
75
|
+
data_val = data if isinstance(data, str) else json.dumps(data, separators=(",", ":"))
|
|
76
|
+
|
|
77
|
+
token = session.h5_token
|
|
78
|
+
if not token:
|
|
79
|
+
raise AuthRequiredError("_m_h5_tk 缺失,请重新登录并导出 cookie")
|
|
80
|
+
sign = generate_sign(t_ms, token, data_val)
|
|
81
|
+
|
|
82
|
+
params = {
|
|
83
|
+
"jsv": "2.7.2",
|
|
84
|
+
"appKey": APP_KEY,
|
|
85
|
+
"t": t_ms,
|
|
86
|
+
"sign": sign,
|
|
87
|
+
"v": version,
|
|
88
|
+
"type": "originaljson",
|
|
89
|
+
"accountSite": "xianyu",
|
|
90
|
+
"dataType": "json",
|
|
91
|
+
"timeout": "20000",
|
|
92
|
+
"api": api,
|
|
93
|
+
"sessionOption": "AutoLoginOnly",
|
|
94
|
+
"spm_cnt": spm_cnt,
|
|
95
|
+
}
|
|
96
|
+
if extra_params:
|
|
97
|
+
params.update(extra_params)
|
|
98
|
+
|
|
99
|
+
resp = session.http.post(
|
|
100
|
+
url,
|
|
101
|
+
params=params,
|
|
102
|
+
headers=headers or default_headers(),
|
|
103
|
+
data={"data": data_val},
|
|
104
|
+
timeout=30,
|
|
105
|
+
)
|
|
106
|
+
raw = resp.json()
|
|
107
|
+
_classify_error(raw, api)
|
|
108
|
+
return raw
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _classify_error(raw: dict[str, Any], api: str) -> None:
|
|
112
|
+
"""根据 ret 字段分类抛异常。成功则不抛。"""
|
|
113
|
+
ret = raw.get("ret") or []
|
|
114
|
+
ret_str = " | ".join(ret) if isinstance(ret, list) else str(ret)
|
|
115
|
+
if not ret_str or "SUCCESS" in ret_str:
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
for kw in _RISK_KEYWORDS:
|
|
119
|
+
if kw in ret_str:
|
|
120
|
+
raise RiskControlError(
|
|
121
|
+
f"[{api}] 触发风控:{ret_str}",
|
|
122
|
+
raw=raw,
|
|
123
|
+
)
|
|
124
|
+
for kw in _AUTH_KEYWORDS:
|
|
125
|
+
if kw in ret_str:
|
|
126
|
+
raise AuthRequiredError(f"[{api}] 登录态失效:{ret_str}", raw=raw)
|
|
127
|
+
if "ILLEGAL_REQUEST" in ret_str or "sign" in ret_str.lower():
|
|
128
|
+
raise SignError(f"[{api}] 签名错误:{ret_str}", raw=raw)
|
|
129
|
+
if "NOT_FOUND" in ret_str or "不存在" in ret_str:
|
|
130
|
+
raise NotFoundError(f"[{api}] 未找到:{ret_str}", raw=raw)
|
|
131
|
+
raise GoofishError(f"[{api}] 调用失败:{ret_str}", raw=raw)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""统一输出渲染器。支持 json/yaml/table/md/csv。非 TTY 场景 table → json 降级。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import csv
|
|
5
|
+
import io
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from enum import StrEnum
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
from rich.table import Table
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Format(StrEnum):
|
|
17
|
+
JSON = "json"
|
|
18
|
+
YAML = "yaml"
|
|
19
|
+
TABLE = "table"
|
|
20
|
+
MD = "md"
|
|
21
|
+
CSV = "csv"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _as_rows(data: Any) -> tuple[list[str], list[dict[str, Any]]]:
|
|
25
|
+
if isinstance(data, dict):
|
|
26
|
+
return list(data.keys()), [data]
|
|
27
|
+
if isinstance(data, list) and data and isinstance(data[0], dict):
|
|
28
|
+
cols: list[str] = []
|
|
29
|
+
for item in data:
|
|
30
|
+
for k in item:
|
|
31
|
+
if k not in cols:
|
|
32
|
+
cols.append(k)
|
|
33
|
+
return cols, data
|
|
34
|
+
return [], []
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def render(data: Any, fmt: Format = Format.JSON, columns: list[str] | None = None) -> None:
|
|
38
|
+
# 非 TTY 且用户没显式指定 table → 走 json(便于管道处理)
|
|
39
|
+
if fmt is Format.TABLE and not sys.stdout.isatty():
|
|
40
|
+
fmt = Format.JSON
|
|
41
|
+
|
|
42
|
+
if fmt is Format.JSON:
|
|
43
|
+
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
if fmt is Format.YAML:
|
|
47
|
+
print(yaml.safe_dump(data, allow_unicode=True, sort_keys=False))
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
cols, rows = _as_rows(data)
|
|
51
|
+
if columns:
|
|
52
|
+
cols = columns
|
|
53
|
+
if not rows:
|
|
54
|
+
# 标量或空:降级为 JSON
|
|
55
|
+
print(json.dumps(data, ensure_ascii=False, indent=2, default=str))
|
|
56
|
+
return
|
|
57
|
+
|
|
58
|
+
if fmt is Format.TABLE:
|
|
59
|
+
console = Console()
|
|
60
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
61
|
+
for c in cols:
|
|
62
|
+
table.add_column(c)
|
|
63
|
+
for row in rows:
|
|
64
|
+
table.add_row(*[str(row.get(c, "")) for c in cols])
|
|
65
|
+
console.print(table)
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
if fmt is Format.MD:
|
|
69
|
+
print("| " + " | ".join(cols) + " |")
|
|
70
|
+
print("| " + " | ".join("---" for _ in cols) + " |")
|
|
71
|
+
for row in rows:
|
|
72
|
+
print("| " + " | ".join(str(row.get(c, "")) for c in cols) + " |")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
if fmt is Format.CSV:
|
|
76
|
+
buf = io.StringIO()
|
|
77
|
+
writer = csv.DictWriter(buf, fieldnames=cols, extrasaction="ignore")
|
|
78
|
+
writer.writeheader()
|
|
79
|
+
for row in rows:
|
|
80
|
+
writer.writerow({c: row.get(c, "") for c in cols})
|
|
81
|
+
sys.stdout.write(buf.getvalue())
|
|
82
|
+
return
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""命令注册中心。参照 opencli cli({...}) —— 单一 registry → CLI / MCP / Skill 共享。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import importlib
|
|
5
|
+
import pkgutil
|
|
6
|
+
from collections.abc import Callable, Iterator
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from goofish_cli.core.strategy import Strategy
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class Command:
|
|
15
|
+
namespace: str
|
|
16
|
+
name: str
|
|
17
|
+
description: str
|
|
18
|
+
func: Callable[..., Any]
|
|
19
|
+
strategy: Strategy = Strategy.COOKIE
|
|
20
|
+
columns: list[str] = field(default_factory=list)
|
|
21
|
+
write: bool = False
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def full_name(self) -> str:
|
|
25
|
+
return f"{self.namespace}.{self.name}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
_REGISTRY: dict[str, Command] = {}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def command(
|
|
32
|
+
*,
|
|
33
|
+
namespace: str,
|
|
34
|
+
name: str,
|
|
35
|
+
description: str,
|
|
36
|
+
strategy: Strategy = Strategy.COOKIE,
|
|
37
|
+
columns: list[str] | None = None,
|
|
38
|
+
write: bool = False,
|
|
39
|
+
) -> Callable[[Callable[..., Any]], Callable[..., Any]]:
|
|
40
|
+
"""装饰器:将函数注册为命令。"""
|
|
41
|
+
|
|
42
|
+
def decorator(func: Callable[..., Any]) -> Callable[..., Any]:
|
|
43
|
+
cmd = Command(
|
|
44
|
+
namespace=namespace,
|
|
45
|
+
name=name,
|
|
46
|
+
description=description,
|
|
47
|
+
func=func,
|
|
48
|
+
strategy=strategy,
|
|
49
|
+
columns=columns or [],
|
|
50
|
+
write=write,
|
|
51
|
+
)
|
|
52
|
+
if cmd.full_name in _REGISTRY:
|
|
53
|
+
raise RuntimeError(f"重复注册命令:{cmd.full_name}")
|
|
54
|
+
_REGISTRY[cmd.full_name] = cmd
|
|
55
|
+
return func
|
|
56
|
+
|
|
57
|
+
return decorator
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def registry() -> dict[str, Command]:
|
|
61
|
+
return dict(_REGISTRY)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def iter_commands() -> Iterator[Command]:
|
|
65
|
+
return iter(_REGISTRY.values())
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def discover() -> None:
|
|
69
|
+
"""自动扫描 goofish_cli.commands.* 所有模块,触发装饰器注册。"""
|
|
70
|
+
from goofish_cli import commands # noqa: WPS433
|
|
71
|
+
|
|
72
|
+
def _walk(pkg: Any) -> None:
|
|
73
|
+
for info in pkgutil.iter_modules(pkg.__path__, pkg.__name__ + "."):
|
|
74
|
+
module = importlib.import_module(info.name)
|
|
75
|
+
if info.ispkg:
|
|
76
|
+
_walk(module)
|
|
77
|
+
|
|
78
|
+
_walk(commands)
|