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 @@
1
+ __version__ = "0.1.0"
goofish_cli/cli.py ADDED
@@ -0,0 +1,119 @@
1
+ """Typer 入口。扫描 registry → 按 namespace 聚合为子命令树 → 统一注入 --format。"""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from collections import defaultdict
6
+
7
+ import typer
8
+ from loguru import logger
9
+
10
+ from goofish_cli.core import GoofishError, iter_commands
11
+ from goofish_cli.core.output import Format, render
12
+ from goofish_cli.core.registry import Command, discover
13
+
14
+ app = typer.Typer(
15
+ name="goofish",
16
+ help="闲鱼 CLI — 支持 MCP,未来支持 Skills。为 AI Agent 提供闲鱼自动化基础能力。",
17
+ no_args_is_help=True,
18
+ add_completion=False,
19
+ )
20
+
21
+
22
+ def _format_option() -> typer.Option:
23
+ return typer.Option(
24
+ Format.TABLE.value,
25
+ "--format",
26
+ "-f",
27
+ help="输出格式:json/yaml/table/md/csv",
28
+ )
29
+
30
+
31
+ def _wrap(cmd: Command):
32
+ """把 registry 里的函数包装成 Typer 回调,注入 --format 并统一渲染/异常处理。"""
33
+
34
+ # 直接复用原函数的签名,加一个 --format
35
+ import functools
36
+ import inspect
37
+
38
+ sig = inspect.signature(cmd.func)
39
+
40
+ @functools.wraps(cmd.func)
41
+ def wrapper(**kwargs):
42
+ fmt = Format(kwargs.pop("format"))
43
+ try:
44
+ result = cmd.func(**kwargs)
45
+ except GoofishError as e:
46
+ typer.secho(f"[{type(e).__name__}] {e}", fg=typer.colors.RED, err=True)
47
+ raise typer.Exit(code=e.exit_code) from e
48
+ except Exception as e: # noqa: BLE001
49
+ typer.secho(f"[Error] {e}", fg=typer.colors.RED, err=True)
50
+ logger.exception(e)
51
+ raise typer.Exit(code=1) from e
52
+ render(result, fmt=fmt, columns=cmd.columns or None)
53
+
54
+ # 重建签名以让 Typer 正确推导
55
+ new_params = list(sig.parameters.values()) + [
56
+ inspect.Parameter(
57
+ "format",
58
+ kind=inspect.Parameter.KEYWORD_ONLY,
59
+ default=Format.TABLE.value,
60
+ annotation=str,
61
+ )
62
+ ]
63
+ wrapper.__signature__ = sig.replace(parameters=new_params) # type: ignore[attr-defined]
64
+ return wrapper
65
+
66
+
67
+ def build_app() -> typer.Typer:
68
+ discover()
69
+ # namespace → Typer sub
70
+ subs: dict[str, typer.Typer] = defaultdict(lambda: typer.Typer(no_args_is_help=True))
71
+
72
+ by_ns: dict[str, list[Command]] = defaultdict(list)
73
+ for cmd in iter_commands():
74
+ by_ns[cmd.namespace].append(cmd)
75
+
76
+ for ns, cmds in by_ns.items():
77
+ sub = typer.Typer(
78
+ name=ns,
79
+ help=f"{ns} 子命令({len(cmds)} 个)",
80
+ no_args_is_help=True,
81
+ add_completion=False,
82
+ )
83
+ for cmd in cmds:
84
+ sub.command(name=cmd.name, help=cmd.description)(_wrap(cmd))
85
+ subs[ns] = sub
86
+ app.add_typer(sub, name=ns)
87
+
88
+ return app
89
+
90
+
91
+ @app.command(name="version")
92
+ def version_cmd():
93
+ """打印版本号。"""
94
+ from goofish_cli import __version__
95
+ print(f"goofish-cli {__version__}")
96
+
97
+
98
+ @app.command(name="list-commands")
99
+ def list_commands(format: str = Format.TABLE.value): # noqa: A002
100
+ """列出所有已注册命令(调试用)。"""
101
+ discover()
102
+ rows = [
103
+ {
104
+ "command": f"goofish {c.namespace} {c.name}",
105
+ "description": c.description,
106
+ "strategy": c.strategy.value,
107
+ "write": c.write,
108
+ }
109
+ for c in iter_commands()
110
+ ]
111
+ render(rows, fmt=Format(format), columns=["command", "description", "strategy", "write"])
112
+
113
+
114
+ # 模块导入时即构建(Typer scripts 需要 top-level app)
115
+ build_app()
116
+
117
+
118
+ if __name__ == "__main__":
119
+ sys.exit(app())
File without changes
File without changes
@@ -0,0 +1,66 @@
1
+ """auth login — 导入 cookie 到 ~/.goofish-cli/cookies.json。
2
+
3
+ 支持两种格式:
4
+ - Chrome 扩展导出的 JSON 数组 [{"name":"...","value":"..."}]
5
+ - cookie 字符串 "k=v; k=v"(--raw)
6
+ """
7
+
8
+ import json
9
+ from pathlib import Path
10
+
11
+ from goofish_cli.core import Strategy, command
12
+ from goofish_cli.core.session import DEFAULT_COOKIE_PATH
13
+
14
+
15
+ @command(
16
+ namespace="auth",
17
+ name="login",
18
+ description="从 JSON 文件或 cookie 字符串导入登录态",
19
+ strategy=Strategy.PUBLIC,
20
+ columns=["path", "unb", "tracknick", "cookies_count"],
21
+ )
22
+ def login(source: str, *, raw: bool = False) -> dict[str, object]:
23
+ if raw:
24
+ cookies = _parse_raw(source)
25
+ else:
26
+ p = Path(source).expanduser()
27
+ cookies = _parse_json(p.read_text())
28
+
29
+ if "unb" not in cookies or "_m_h5_tk" not in cookies:
30
+ raise ValueError("cookie 缺失关键字段 unb / _m_h5_tk,请重新从浏览器导出")
31
+
32
+ target = DEFAULT_COOKIE_PATH
33
+ target.parent.mkdir(parents=True, exist_ok=True)
34
+ target.write_text(json.dumps(
35
+ [{"name": k, "value": v} for k, v in cookies.items()],
36
+ ensure_ascii=False,
37
+ indent=2,
38
+ ))
39
+ target.chmod(0o600)
40
+
41
+ return {
42
+ "path": str(target),
43
+ "unb": cookies.get("unb", ""),
44
+ "tracknick": cookies.get("tracknick", ""),
45
+ "cookies_count": len(cookies),
46
+ }
47
+
48
+
49
+ def _parse_raw(raw: str) -> dict[str, str]:
50
+ out: dict[str, str] = {}
51
+ for part in raw.split(";"):
52
+ part = part.strip()
53
+ if not part or "=" not in part:
54
+ continue
55
+ k, _, v = part.partition("=")
56
+ out[k.strip()] = v.strip()
57
+ return out
58
+
59
+
60
+ def _parse_json(text: str) -> dict[str, str]:
61
+ data = json.loads(text)
62
+ if isinstance(data, list):
63
+ return {c["name"]: c["value"] for c in data if "name" in c and "value" in c}
64
+ if isinstance(data, dict):
65
+ return {str(k): str(v) for k, v in data.items()}
66
+ raise ValueError("cookie JSON 格式不识别(需 list 或 dict)")
@@ -0,0 +1,16 @@
1
+ """auth reset-guard — 手动解除风控熔断状态。"""
2
+
3
+ from goofish_cli.core import Strategy, command
4
+ from goofish_cli.core.guard import reset
5
+
6
+
7
+ @command(
8
+ namespace="auth",
9
+ name="reset-guard",
10
+ description="手动解除风控熔断状态",
11
+ strategy=Strategy.PUBLIC,
12
+ columns=["ok"],
13
+ )
14
+ def reset_guard() -> dict[str, bool]:
15
+ reset()
16
+ return {"ok": True}
@@ -0,0 +1,40 @@
1
+ """auth status — 检查登录态是否有效。调用 mtop.taobao.idlemessage.pc.loginuser.get"""
2
+
3
+ from typing import Any
4
+
5
+ from goofish_cli.core import Session, Strategy, command
6
+ from goofish_cli.core.mtop import call
7
+
8
+
9
+ @command(
10
+ namespace="auth",
11
+ name="status",
12
+ description="检查登录态是否有效,返回 unb / tracknick / 昵称",
13
+ strategy=Strategy.COOKIE,
14
+ columns=["unb", "tracknick", "nick", "valid"],
15
+ )
16
+ def status() -> dict[str, Any]:
17
+ session = Session.load()
18
+ try:
19
+ raw = call(
20
+ session,
21
+ api="mtop.taobao.idlemessage.pc.loginuser.get",
22
+ data={},
23
+ version="1.0",
24
+ spm_cnt="a21ybx.im.0.0",
25
+ )
26
+ user = raw.get("data", {}) or {}
27
+ return {
28
+ "unb": session.unb,
29
+ "tracknick": session.tracknick,
30
+ "nick": user.get("nick", ""),
31
+ "valid": True,
32
+ }
33
+ except Exception as e: # noqa: BLE001
34
+ return {
35
+ "unb": session.unb,
36
+ "tracknick": session.tracknick,
37
+ "nick": "",
38
+ "valid": False,
39
+ "error": str(e),
40
+ }
File without changes
@@ -0,0 +1,64 @@
1
+ """category recommend — AI 识别商品类目(发布前置)。
2
+
3
+ 接口:mtop.taobao.idle.kgraph.property.recommend v2.0
4
+ """
5
+
6
+ import json
7
+ from typing import Any
8
+
9
+ from goofish_cli.core import Session, Strategy, command
10
+ from goofish_cli.core.mtop import call
11
+
12
+
13
+ @command(
14
+ namespace="category",
15
+ name="recommend",
16
+ description="AI 识别商品类目,输入标题+图片返回 catId/catName",
17
+ strategy=Strategy.COOKIE,
18
+ columns=["cat_id", "cat_name", "channel_cat_id", "tb_cat_id", "confidence"],
19
+ )
20
+ def recommend(
21
+ title: str,
22
+ images_json: str = "[]",
23
+ ) -> dict[str, Any]:
24
+ """images_json 是 JSON 字符串:[{"url":"...","width":1024,"height":1024}, ...]"""
25
+ session = Session.load()
26
+ images = json.loads(images_json) if images_json else []
27
+ image_infos: list[dict[str, Any]] = []
28
+ for img in images:
29
+ image_infos.append({
30
+ "extraInfo": {"isH": "false", "isT": "false", "raw": "false"},
31
+ "isQrCode": False,
32
+ "url": img["url"],
33
+ "heightSize": img["height"],
34
+ "widthSize": img["width"],
35
+ "major": True,
36
+ "type": 0,
37
+ "status": "done",
38
+ })
39
+
40
+ raw = call(
41
+ session,
42
+ api="mtop.taobao.idle.kgraph.property.recommend",
43
+ data={
44
+ "title": title,
45
+ "lockCpv": False,
46
+ "multiSKU": False,
47
+ "publishScene": "mainPublish",
48
+ "scene": "newPublishChoice",
49
+ "description": title,
50
+ "imageInfos": image_infos,
51
+ "uniqueCode": "1775905618164677",
52
+ },
53
+ version="2.0",
54
+ spm_cnt="a21ybx.publish.0.0",
55
+ )
56
+ predict = (raw.get("data", {}) or {}).get("categoryPredictResult", {}) or {}
57
+ return {
58
+ "cat_id": str(predict.get("catId", "")),
59
+ "cat_name": predict.get("catName", ""),
60
+ "channel_cat_id": str(predict.get("channelCatId", "")),
61
+ "tb_cat_id": str(predict.get("tbCatId", "")),
62
+ "confidence": predict.get("confidence", 0),
63
+ "raw": raw,
64
+ }
File without changes
@@ -0,0 +1,32 @@
1
+ """item delete — 下架商品。接口 com.taobao.idle.item.delete v1.1(写操作)"""
2
+
3
+ from goofish_cli.core import Session, Strategy, command
4
+ from goofish_cli.core.guard import watch
5
+ from goofish_cli.core.limiter import acquire
6
+ from goofish_cli.core.mtop import call
7
+
8
+
9
+ @command(
10
+ namespace="item",
11
+ name="delete",
12
+ description="下架/删除商品(写操作,受限流和风控护栏保护)",
13
+ strategy=Strategy.COOKIE,
14
+ columns=["item_id", "ok", "message"],
15
+ write=True,
16
+ )
17
+ def delete(item_id: str) -> dict[str, object]:
18
+ session = Session.load()
19
+ with acquire("item.write"), watch():
20
+ raw = call(
21
+ session,
22
+ api="com.taobao.idle.item.delete",
23
+ data={"itemId": str(item_id)},
24
+ version="1.1",
25
+ spm_cnt="a21ybx.item.0.0",
26
+ )
27
+ ret = raw.get("ret") or []
28
+ return {
29
+ "item_id": str(item_id),
30
+ "ok": any("SUCCESS" in r for r in ret),
31
+ "message": " | ".join(ret),
32
+ }
@@ -0,0 +1,34 @@
1
+ """item get — 查询商品详情。接口 mtop.taobao.idle.pc.detail v1.0(只读)"""
2
+
3
+ from typing import Any
4
+
5
+ from goofish_cli.core import Session, Strategy, command
6
+ from goofish_cli.core.mtop import call
7
+
8
+
9
+ @command(
10
+ namespace="item",
11
+ name="get",
12
+ description="查询闲鱼商品详情(只读)",
13
+ strategy=Strategy.COOKIE,
14
+ columns=["item_id", "title", "price", "seller_nick", "status"],
15
+ )
16
+ def get(item_id: str) -> dict[str, Any]:
17
+ session = Session.load()
18
+ raw = call(
19
+ session,
20
+ api="mtop.taobao.idle.pc.detail",
21
+ data={"itemId": str(item_id)},
22
+ version="1.0",
23
+ spm_cnt="a21ybx.item.0.0",
24
+ )
25
+ data = raw.get("data", {}) or {}
26
+ track = data.get("trackParams", {}) or {}
27
+ return {
28
+ "item_id": track.get("id", item_id),
29
+ "title": track.get("title", ""),
30
+ "price": track.get("soldPrice") or track.get("price", ""),
31
+ "seller_nick": track.get("seller_nick", ""),
32
+ "status": track.get("itemStatus", ""),
33
+ "raw": raw,
34
+ }
@@ -0,0 +1,175 @@
1
+ """item publish — 发布新商品。
2
+
3
+ 流程:upload_images → category.recommend → location.default → publish
4
+ 接口:mtop.idle.pc.idleitem.publish v1.0(写操作)
5
+ """
6
+
7
+ import json
8
+ from typing import Any, Literal
9
+
10
+ from goofish_cli.commands.category.recommend import recommend
11
+ from goofish_cli.commands.location.default import default as get_default_location
12
+ from goofish_cli.commands.media.upload import upload
13
+ from goofish_cli.core import Session, Strategy, command
14
+ from goofish_cli.core.guard import watch
15
+ from goofish_cli.core.limiter import acquire
16
+ from goofish_cli.core.mtop import call
17
+
18
+
19
+ @command(
20
+ namespace="item",
21
+ name="publish",
22
+ description="发布商品(自动识别类目 + 默认地址),价格单位元",
23
+ strategy=Strategy.COOKIE,
24
+ columns=["item_id", "title", "price", "cat_name", "ok"],
25
+ write=True,
26
+ )
27
+ def publish(
28
+ title: str,
29
+ desc: str,
30
+ images: list[str],
31
+ price: float,
32
+ original_price: float | None = None,
33
+ delivery: Literal["包邮", "按距离计费", "一口价", "无需邮寄"] = "无需邮寄",
34
+ post_price: float = 0,
35
+ can_self_pickup: bool = True,
36
+ ) -> dict[str, Any]:
37
+ session = Session.load()
38
+
39
+ # 1. 上传图片(每张独立限流)
40
+ image_infos: list[dict[str, Any]] = []
41
+ for img_path in images:
42
+ with acquire("item.write"):
43
+ r = upload(img_path)
44
+ image_infos.append({"url": r["url"], "width": r["width"], "height": r["height"]})
45
+
46
+ # 2. AI 类目
47
+ cat = recommend(title, json.dumps(image_infos))
48
+
49
+ # 3. 默认地址
50
+ loc = get_default_location()
51
+
52
+ # 4. 发布(最后一步,走熔断)
53
+ data = _build_publish_data(
54
+ title=title,
55
+ desc=desc,
56
+ image_infos=image_infos,
57
+ price=price,
58
+ original_price=original_price,
59
+ delivery=delivery,
60
+ post_price=post_price,
61
+ can_self_pickup=can_self_pickup,
62
+ cat_info=cat,
63
+ location=loc,
64
+ )
65
+
66
+ with acquire("item.write"), watch():
67
+ raw = call(
68
+ session,
69
+ api="mtop.idle.pc.idleitem.publish",
70
+ data=data,
71
+ version="1.0",
72
+ spm_cnt="a21ybx.publish.0.0",
73
+ )
74
+
75
+ data_out = raw.get("data", {}) or {}
76
+ return {
77
+ "item_id": data_out.get("itemId", ""),
78
+ "title": title,
79
+ "price": price,
80
+ "cat_name": cat["cat_name"],
81
+ "ok": any("SUCCESS" in r for r in raw.get("ret", [])),
82
+ }
83
+
84
+
85
+ def _build_publish_data(
86
+ *,
87
+ title: str,
88
+ desc: str,
89
+ image_infos: list[dict[str, Any]],
90
+ price: float,
91
+ original_price: float | None,
92
+ delivery: str,
93
+ post_price: float,
94
+ can_self_pickup: bool,
95
+ cat_info: dict[str, Any],
96
+ location: dict[str, Any],
97
+ ) -> dict[str, Any]:
98
+ image_do_list = [
99
+ {
100
+ "extraInfo": {"isH": "false", "isT": "false", "raw": "false"},
101
+ "isQrCode": False,
102
+ "url": img["url"],
103
+ "heightSize": img["height"],
104
+ "widthSize": img["width"],
105
+ "major": True,
106
+ "type": 0,
107
+ "status": "done",
108
+ }
109
+ for img in image_infos
110
+ ]
111
+
112
+ post_fee: dict[str, Any] = {
113
+ "canFreeShipping": False,
114
+ "supportFreight": False,
115
+ "onlyTakeSelf": False,
116
+ }
117
+ if delivery == "包邮":
118
+ post_fee["canFreeShipping"] = True
119
+ post_fee["supportFreight"] = True
120
+ elif delivery == "按距离计费":
121
+ post_fee["supportFreight"] = True
122
+ post_fee["templateId"] = "-100"
123
+ elif delivery == "一口价":
124
+ post_fee["supportFreight"] = True
125
+ post_fee["postPriceInCent"] = str(int(post_price * 100))
126
+ post_fee["templateId"] = "0"
127
+ elif delivery == "无需邮寄":
128
+ post_fee["templateId"] = "0"
129
+
130
+ price_dto: dict[str, str] = {}
131
+ default_price = price <= 0
132
+ if not default_price:
133
+ price_dto["priceInCent"] = str(int(price * 100))
134
+ if original_price and original_price > 0:
135
+ price_dto["origPriceInCent"] = str(int(original_price * 100))
136
+
137
+ item_addr: dict[str, Any] = {}
138
+ if location.get("division_id"):
139
+ all_addrs = location.get("all", []) or []
140
+ first = all_addrs[0] if all_addrs else {}
141
+ item_addr = {
142
+ "area": first.get("area", ""),
143
+ "city": first.get("city", ""),
144
+ "divisionId": first.get("divisionId", ""),
145
+ "gps": f"{first.get('longitude', '')},{first.get('latitude', '')}",
146
+ "poiId": first.get("poiId", ""),
147
+ "poiName": first.get("poi", ""),
148
+ "prov": first.get("prov", ""),
149
+ }
150
+
151
+ return {
152
+ "freebies": False,
153
+ "itemTypeStr": "b",
154
+ "quantity": "1",
155
+ "simpleItem": "true",
156
+ "imageInfoDOList": image_do_list,
157
+ "itemTextDTO": {"desc": desc, "title": title, "titleDescSeparate": True},
158
+ "itemLabelExtList": [],
159
+ "itemPriceDTO": price_dto,
160
+ "userRightsProtocols": [{"enable": False, "serviceCode": "SKILL_PLAY_NO_MIND"}],
161
+ "itemPostFeeDTO": post_fee,
162
+ "itemAddrDTO": item_addr,
163
+ "defaultPrice": default_price,
164
+ "itemCatDTO": {
165
+ "catId": cat_info["cat_id"],
166
+ "catName": cat_info["cat_name"],
167
+ "channelCatId": cat_info["channel_cat_id"],
168
+ "tbCatId": cat_info["tb_cat_id"],
169
+ },
170
+ "onlyTakeSelf": can_self_pickup,
171
+ "uniqueCode": "1775897582791680",
172
+ "sourceId": "pcMainPublish",
173
+ "bizcode": "pcMainPublish",
174
+ "publishScene": "pcMainPublish",
175
+ }
File without changes
@@ -0,0 +1,40 @@
1
+ """location default — 获取默认发布地址。
2
+
3
+ 接口:mtop.taobao.idle.local.poi.get v1.0
4
+ """
5
+
6
+ from typing import Any
7
+
8
+ from goofish_cli.core import Session, Strategy, command
9
+ from goofish_cli.core.mtop import call
10
+
11
+
12
+ @command(
13
+ namespace="location",
14
+ name="default",
15
+ description="获取账号默认发布地址",
16
+ strategy=Strategy.COOKIE,
17
+ columns=["prov", "city", "area", "poi", "division_id"],
18
+ )
19
+ def default(longitude: float = 121.4737, latitude: float = 31.2304) -> dict[str, Any]:
20
+ session = Session.load()
21
+ raw = call(
22
+ session,
23
+ api="mtop.taobao.idle.local.poi.get",
24
+ data={"longitude": longitude, "latitude": latitude},
25
+ version="1.0",
26
+ spm_cnt="a21ybx.publish.0.0",
27
+ )
28
+ data = raw.get("data", {}) or {}
29
+ addrs = data.get("commonAddresses", []) or []
30
+ selected = data.get("selectedPoi") or (addrs[0] if addrs else None)
31
+ if not selected:
32
+ return {"prov": "", "city": "", "area": "", "poi": "", "division_id": "", "all": []}
33
+ return {
34
+ "prov": selected.get("prov", ""),
35
+ "city": selected.get("city", ""),
36
+ "area": selected.get("area", ""),
37
+ "poi": selected.get("poi", ""),
38
+ "division_id": str(selected.get("divisionId", "")),
39
+ "all": addrs or [selected],
40
+ }
File without changes
@@ -0,0 +1,53 @@
1
+ """media upload — 上传图片到闲鱼 CDN。"""
2
+
3
+ import os
4
+
5
+ from goofish_cli.core import Session, Strategy, command
6
+ from goofish_cli.core.session import USER_AGENT
7
+
8
+ UPLOAD_URL = "https://stream-upload.goofish.com/api/upload.api"
9
+
10
+
11
+ @command(
12
+ namespace="media",
13
+ name="upload",
14
+ description="上传图片到闲鱼 CDN,返回图片 URL + 尺寸",
15
+ strategy=Strategy.COOKIE,
16
+ columns=["url", "width", "height", "size"],
17
+ write=True,
18
+ )
19
+ def upload(path: str) -> dict[str, object]:
20
+ session = Session.load()
21
+ abs_path = os.path.expanduser(path)
22
+ if not os.path.exists(abs_path):
23
+ raise FileNotFoundError(f"图片不存在:{abs_path}")
24
+
25
+ headers = {
26
+ "accept": "*/*",
27
+ "origin": "https://www.goofish.com",
28
+ "referer": "https://www.goofish.com/",
29
+ "user-agent": USER_AGENT,
30
+ }
31
+ params = {"floderId": "0", "appkey": "xy_chat", "_input_charset": "utf-8"}
32
+
33
+ with open(abs_path, "rb") as f:
34
+ resp = session.http.post(
35
+ UPLOAD_URL,
36
+ headers=headers,
37
+ params=params,
38
+ files={"file": (os.path.basename(abs_path), f, "image/png")},
39
+ timeout=60,
40
+ )
41
+ raw = resp.json()
42
+ obj = raw.get("object") or {}
43
+ pix = str(obj.get("pix", "0x0"))
44
+ try:
45
+ width, height = map(int, pix.split("x"))
46
+ except ValueError:
47
+ width = height = 0
48
+ return {
49
+ "url": obj.get("url", ""),
50
+ "width": width,
51
+ "height": height,
52
+ "size": obj.get("size", 0),
53
+ }
File without changes