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.
Files changed (41) hide show
  1. goofish_cli/__init__.py +1 -0
  2. goofish_cli/cli.py +119 -0
  3. goofish_cli/commands/__init__.py +0 -0
  4. goofish_cli/commands/auth/__init__.py +0 -0
  5. goofish_cli/commands/auth/login.py +66 -0
  6. goofish_cli/commands/auth/reset_guard.py +16 -0
  7. goofish_cli/commands/auth/status.py +40 -0
  8. goofish_cli/commands/category/__init__.py +0 -0
  9. goofish_cli/commands/category/recommend.py +64 -0
  10. goofish_cli/commands/item/__init__.py +0 -0
  11. goofish_cli/commands/item/delete.py +32 -0
  12. goofish_cli/commands/item/get.py +34 -0
  13. goofish_cli/commands/item/publish.py +175 -0
  14. goofish_cli/commands/location/__init__.py +0 -0
  15. goofish_cli/commands/location/default.py +40 -0
  16. goofish_cli/commands/media/__init__.py +0 -0
  17. goofish_cli/commands/media/upload.py +53 -0
  18. goofish_cli/commands/message/__init__.py +0 -0
  19. goofish_cli/commands/message/history.py +23 -0
  20. goofish_cli/commands/message/send.py +102 -0
  21. goofish_cli/commands/message/watch.py +58 -0
  22. goofish_cli/core/__init__.py +26 -0
  23. goofish_cli/core/errors.py +31 -0
  24. goofish_cli/core/guard.py +63 -0
  25. goofish_cli/core/limiter.py +60 -0
  26. goofish_cli/core/mtop.py +131 -0
  27. goofish_cli/core/output.py +82 -0
  28. goofish_cli/core/registry.py +78 -0
  29. goofish_cli/core/session.py +88 -0
  30. goofish_cli/core/sign.py +37 -0
  31. goofish_cli/core/strategy.py +10 -0
  32. goofish_cli/core/token.py +88 -0
  33. goofish_cli/core/ws.py +493 -0
  34. goofish_cli/mcp_server.py +56 -0
  35. goofish_cli/static/goofish_js_version_2.js +568 -0
  36. goofish_cli-0.1.0.dist-info/METADATA +279 -0
  37. goofish_cli-0.1.0.dist-info/RECORD +41 -0
  38. goofish_cli-0.1.0.dist-info/WHEEL +4 -0
  39. goofish_cli-0.1.0.dist-info/entry_points.txt +3 -0
  40. goofish_cli-0.1.0.dist-info/licenses/LICENSE +190 -0
  41. goofish_cli-0.1.0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,88 @@
1
+ """cookie 加载 + requests.Session 管理 + token 提取。"""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import requests
10
+
11
+ from goofish_cli.core.errors import AuthRequiredError
12
+ from goofish_cli.core.sign import generate_device_id
13
+
14
+ DEFAULT_COOKIE_PATH = Path.home() / ".goofish-cli" / "cookies.json"
15
+ DEVICE_CACHE_PATH = Path.home() / ".goofish-cli" / "device.json"
16
+ USER_AGENT = (
17
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
18
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
19
+ "Chrome/146.0.0.0 Safari/537.36"
20
+ )
21
+
22
+
23
+ @dataclass
24
+ class Session:
25
+ http: requests.Session
26
+ unb: str
27
+ tracknick: str
28
+ device_id: str
29
+
30
+ @classmethod
31
+ def load(cls, cookie_path: Path | str | None = None) -> Session:
32
+ path = Path(os.path.expanduser(
33
+ cookie_path or os.environ.get("GOOFISH_COOKIES_PATH") or DEFAULT_COOKIE_PATH
34
+ ))
35
+ if not path.exists():
36
+ raise AuthRequiredError(
37
+ f"cookie 文件不存在:{path}。请先 `goofish auth login --from <path>` 导入。"
38
+ )
39
+ cookies = _load_cookies(path)
40
+ if "unb" not in cookies or "_m_h5_tk" not in cookies:
41
+ raise AuthRequiredError(
42
+ f"cookie 缺失 unb / _m_h5_tk,检查 {path} 是否完整(建议从 goofish.com 登录后再导一次)"
43
+ )
44
+ http = requests.Session()
45
+ http.cookies.update(cookies)
46
+ return cls(
47
+ http=http,
48
+ unb=cookies["unb"],
49
+ tracknick=cookies.get("tracknick", ""),
50
+ device_id=_load_or_mint_device_id(cookies["unb"]),
51
+ )
52
+
53
+ @property
54
+ def h5_token(self) -> str:
55
+ raw = self.http.cookies.get("_m_h5_tk", "")
56
+ return raw.split("_")[0] if raw else ""
57
+
58
+
59
+ def _load_or_mint_device_id(unb: str) -> str:
60
+ """device_id 必须在 unb 维度稳定。
61
+
62
+ IM WebSocket 的 accessToken 会绑定 (appKey, deviceId)。若每次 Session.load 调用
63
+ JS 重新随机生成 device_id,token 签发时用 A,/reg 时用 B,会返回 401
64
+ "device id or appkey is not equal"。
65
+ """
66
+ if DEVICE_CACHE_PATH.exists():
67
+ try:
68
+ raw = json.loads(DEVICE_CACHE_PATH.read_text())
69
+ if raw.get("unb") == unb and raw.get("device_id"):
70
+ return raw["device_id"]
71
+ except (json.JSONDecodeError, OSError):
72
+ pass
73
+ device_id = generate_device_id(unb)
74
+ DEVICE_CACHE_PATH.parent.mkdir(parents=True, exist_ok=True)
75
+ DEVICE_CACHE_PATH.write_text(json.dumps({"unb": unb, "device_id": device_id}))
76
+ DEVICE_CACHE_PATH.chmod(0o600)
77
+ return device_id
78
+
79
+
80
+ def _load_cookies(path: Path) -> dict[str, str]:
81
+ text = path.read_text()
82
+ raw = json.loads(text)
83
+ # 兼容两种格式:Chrome 扩展导出的 list[{name,value,...}] / 纯 dict
84
+ if isinstance(raw, list):
85
+ return {c["name"]: c["value"] for c in raw if "name" in c and "value" in c}
86
+ if isinstance(raw, dict):
87
+ return {str(k): str(v) for k, v in raw.items()}
88
+ raise AuthRequiredError(f"cookies.json 格式不识别:{path}")
@@ -0,0 +1,37 @@
1
+ """execjs 桥接 goofish_js_version_2.js。提供 sign/device_id/mid/uuid/decrypt。"""
2
+ from __future__ import annotations
3
+
4
+ import subprocess
5
+ from functools import lru_cache, partial
6
+ from importlib.resources import files
7
+
8
+ # 静默 Windows 编码问题(跨平台无害)
9
+ subprocess.Popen = partial(subprocess.Popen, encoding="utf-8")
10
+
11
+ import execjs # noqa: E402 必须在 subprocess 补丁之后
12
+
13
+
14
+ @lru_cache(maxsize=1)
15
+ def _ctx() -> execjs._abstract_runtime.AbstractRuntimeContext:
16
+ js_path = files("goofish_cli.static").joinpath("goofish_js_version_2.js")
17
+ return execjs.compile(js_path.read_text(encoding="utf-8"))
18
+
19
+
20
+ def generate_sign(t: str, token: str, data: str) -> str:
21
+ return _ctx().call("generate_sign", t, token, data)
22
+
23
+
24
+ def generate_device_id(user_id: str) -> str:
25
+ return _ctx().call("generate_device_id", user_id)
26
+
27
+
28
+ def generate_mid() -> str:
29
+ return _ctx().call("generate_mid")
30
+
31
+
32
+ def generate_uuid() -> str:
33
+ return _ctx().call("generate_uuid")
34
+
35
+
36
+ def decrypt(data: str) -> str:
37
+ return _ctx().call("decrypt", data)
@@ -0,0 +1,10 @@
1
+ """命令认证/调用策略枚举(参照 opencli Strategy)。"""
2
+ from __future__ import annotations
3
+
4
+ from enum import StrEnum
5
+
6
+
7
+ class Strategy(StrEnum):
8
+ PUBLIC = "public"
9
+ COOKIE = "cookie"
10
+ WEBSOCKET = "ws"
@@ -0,0 +1,88 @@
1
+ """IM accessToken 获取/刷新。对应 mtop.taobao.idlemessage.pc.login.token。
2
+
3
+ 用于 WebSocket 鉴权(/reg 阶段)。不同于 HTTP 的 _m_h5_tk,这个 token 是给 IM 长连用的。
4
+
5
+ accessToken 会被缓存到 ~/.goofish-cli/im_token.json(TTL 由 GOOFISH_TOKEN_TTL 控制,默认 30 分钟)。
6
+ 闲鱼风控对 `mtop.taobao.idlemessage.pc.login.token` 比较敏感,高频调用会 RGV587。
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from goofish_cli.core.errors import AuthRequiredError
16
+ from goofish_cli.core.mtop import call
17
+ from goofish_cli.core.session import Session
18
+
19
+ IM_APP_KEY = "444e9908a51d1cb236a27862abc769c9"
20
+ TOKEN_CACHE = Path.home() / ".goofish-cli" / "im_token.json"
21
+ DEFAULT_TTL = 30 * 60 # 30 分钟,远低于真实 token 过期(观察约 2h)
22
+
23
+
24
+ def _ttl() -> int:
25
+ try:
26
+ return max(60, int(os.environ.get("GOOFISH_TOKEN_TTL", DEFAULT_TTL)))
27
+ except ValueError:
28
+ return DEFAULT_TTL
29
+
30
+
31
+ def _load_cache(unb: str) -> str | None:
32
+ if not TOKEN_CACHE.exists():
33
+ return None
34
+ try:
35
+ raw = json.loads(TOKEN_CACHE.read_text())
36
+ except (json.JSONDecodeError, OSError):
37
+ return None
38
+ if raw.get("unb") != unb:
39
+ return None
40
+ if time.time() - float(raw.get("t", 0)) > _ttl():
41
+ return None
42
+ return raw.get("token") or None
43
+
44
+
45
+ def _save_cache(unb: str, token: str) -> None:
46
+ TOKEN_CACHE.parent.mkdir(parents=True, exist_ok=True)
47
+ TOKEN_CACHE.write_text(json.dumps({"unb": unb, "token": token, "t": time.time()}))
48
+ TOKEN_CACHE.chmod(0o600)
49
+
50
+
51
+ def get_access_token(session: Session, *, force_refresh: bool = False) -> str:
52
+ """取 WebSocket 用的 accessToken。
53
+
54
+ 优先级:环境变量 GOOFISH_IM_TOKEN > 文件缓存 > 调用 mtop 接口。
55
+ 支持注入原因:`mtop.taobao.idlemessage.pc.login.token` 风控非常敏感,
56
+ 完成滑块后 web 端自己持有的 token 可以直接拷出来塞给 CLI 用。
57
+ """
58
+ env_token = os.environ.get("GOOFISH_IM_TOKEN", "").strip()
59
+ if env_token:
60
+ return env_token
61
+ if not force_refresh:
62
+ cached = _load_cache(session.unb)
63
+ if cached:
64
+ return cached
65
+ data = {"appKey": IM_APP_KEY, "deviceId": session.device_id}
66
+ raw = call(
67
+ session,
68
+ api="mtop.taobao.idlemessage.pc.login.token",
69
+ data=data,
70
+ version="1.0",
71
+ spm_cnt="a21ybx.im.0.0",
72
+ )
73
+ token = (raw.get("data") or {}).get("accessToken", "")
74
+ if not token:
75
+ raise AuthRequiredError(f"accessToken 获取失败:{raw.get('ret')}")
76
+ _save_cache(session.unb, token)
77
+ return token
78
+
79
+
80
+ def refresh_login(session: Session) -> dict[str, Any]:
81
+ """刷新登录态(轻量 ping),长连常驻时 10 分钟调一次避免掉线。"""
82
+ return call(
83
+ session,
84
+ api="mtop.taobao.idlemessage.pc.loginuser.get",
85
+ data={},
86
+ version="1.0",
87
+ spm_cnt="a21ybx.im.0.0",
88
+ )