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
goofish_cli/__init__.py
ADDED
|
@@ -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
|