goofish-cli 0.2.0__tar.gz → 0.2.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.
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/CHANGELOG.md +33 -1
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/PKG-INFO +5 -5
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/README.md +4 -4
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/docs/mcp-setup.md +2 -2
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/pyproject.toml +9 -1
- goofish_cli-0.2.2/src/goofish_cli/__init__.py +9 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/browser.py +7 -1
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/mtop.py +33 -2
- goofish_cli-0.2.2/src/goofish_cli/core/refresh.py +80 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/session.py +11 -3
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/mcp_server.py +11 -3
- goofish_cli-0.2.2/tests/test_refresh.py +105 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/uv.lock +1 -1
- goofish_cli-0.2.0/src/goofish_cli/__init__.py +0 -1
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.env.example +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/pull_request_template.md +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/workflows/ci.yml +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/workflows/publish.yml +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.gitignore +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/CONTRIBUTING.md +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/LICENSE +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/NOTICE +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/docs/architecture.md +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/docs/compliance.md +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/examples/README.md +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/examples/publish_from_folder.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/examples/watch_to_autoreply.sh +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/cli.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/login.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/reset_guard.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/status.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/category/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/category/recommend.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/delete.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/get.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/publish.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/view.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/location/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/location/default.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/media/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/media/upload.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/history.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/list_chats.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/send.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/watch.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/search/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/search/search.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/browser_cookie.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/errors.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/guard.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/limiter.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/output.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/registry.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/sign.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/strategy.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/token.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/ws.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/static/goofish_js_version_2.js +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/__init__.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_auth_login.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_browser_cookie.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_guard.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_item_view.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_limiter.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_list_chats.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_mtop.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_publish_payload.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_registry.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_search.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_session_bootstrap.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_sign.py +0 -0
- {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_ws_codec.py +0 -0
|
@@ -6,6 +6,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.2] - 2026-04-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **`_m_h5_tk` 自动刷新机制**(`core/refresh.py`):mtop 调用遇
|
|
13
|
+
`FAIL_SYS_TOKEN_EXOIRED` / `FAIL_SYS_TOKEN_EMPTY` / `令牌过期` 时,自动用
|
|
14
|
+
Playwright `goto` 一次 `https://www.goofish.com` 触发服务端 `Set-Cookie` 续 token,
|
|
15
|
+
合并回 session 后重试一次原请求。开关:`GOOFISH_AUTO_REFRESH_TOKEN=0` 关闭(CI 或
|
|
16
|
+
想自定义刷新策略时)。
|
|
17
|
+
- **只处理 token 层失效**:`FAIL_SYS_SESSION_EXPIRED` / `FAIL_SYS_ILLEGAL_ACCESS` 不触发
|
|
18
|
+
自动刷新(意味着 `unb` / `cookie2` 也失效了,刷 `_m_h5_tk` 救不了,仍按原 AuthRequiredError 抛)。
|
|
19
|
+
- **避免跨 domain 同名 cookie 并存**:Playwright 写回的新 cookie 和 `browser_cookie3`
|
|
20
|
+
导入的旧 cookie 有不同 domain,`requests.CookieJar` 允许两者并存,但后续
|
|
21
|
+
`cookies.get(name)` 会抛 `CookieConflictError`。刷新时先 `clear(domain, path, name)`
|
|
22
|
+
掉所有同名旧条目再 `update(fresh)`。
|
|
23
|
+
- 刷新成功后自动写回 `~/.goofish-cli/cookies.json`,下次启动不用再刷。
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- mtop `call()` 新增 `_auto_refresh` 参数(默认 True),递归重试时置 False 避免死循环。
|
|
27
|
+
|
|
28
|
+
## [0.2.1] - 2026-04-22
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- MCP script entry 新增 `goofish-cli`(与 package 名一致),`uvx goofish-cli` 即可直接拉起
|
|
32
|
+
MCP server。Claude Code 等 AI Agent 的 MCP 配置应使用这个入口。`goofish-mcp` 作为
|
|
33
|
+
次级入口保留,但只在本包已装到某 env 时可用(`pip install goofish-cli` /
|
|
34
|
+
`uv tool install goofish-cli` / `uvx --from goofish-cli goofish-mcp`)——单写
|
|
35
|
+
`uvx goofish-mcp` 仍会因 PyPI 无同名包解析失败。
|
|
36
|
+
- `goofish_cli.__version__` 改为从 `importlib.metadata` 动态读取 package 版本,避免和
|
|
37
|
+
`pyproject.toml` 的 `version` 字段 drift(之前硬编码在 `__init__.py` 里,0.2.0 忘了同步)。
|
|
38
|
+
|
|
9
39
|
## [0.2.0] - 2026-04-22
|
|
10
40
|
|
|
11
41
|
### Added
|
|
@@ -56,6 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
56
86
|
- 本版本需要用户手动从浏览器导入 cookie(含 `unb` / `_m_h5_tk` / `x5sec`)
|
|
57
87
|
- 遇到 `RGV587_ERROR` 风控时,需在浏览器完成滑块验证并**重新导出**带 `x5sec` 的 cookie
|
|
58
88
|
|
|
59
|
-
[Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.
|
|
89
|
+
[Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.2...HEAD
|
|
90
|
+
[0.2.2]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.1...v0.2.2
|
|
91
|
+
[0.2.1]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.0...v0.2.1
|
|
60
92
|
[0.2.0]: https://github.com/fancyboi999/goofish-cli/compare/v0.1.0...v0.2.0
|
|
61
93
|
[0.1.0]: https://github.com/fancyboi999/goofish-cli/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: goofish-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: 闲鱼 CLI — 支持 MCP,未来支持 Claude Skills。为 AI Agent 提供闲鱼自动化基础能力。
|
|
5
5
|
Project-URL: Homepage, https://github.com/fancyboi999/goofish-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/fancyboi999/goofish-cli
|
|
@@ -58,7 +58,7 @@ Description-Content-Type: text/markdown
|
|
|
58
58
|
**同一份定义**同时输出给三种消费者:
|
|
59
59
|
|
|
60
60
|
- 👨💻 **人类**:`goofish item get 12345 --format table`
|
|
61
|
-
- 🤖 **AI Agent(Claude Code / Cursor / Codex)**:`uvx goofish-
|
|
61
|
+
- 🤖 **AI Agent(Claude Code / Cursor / Codex)**:`uvx goofish-cli` → 自动注册成 MCP tool
|
|
62
62
|
- 🧩 **Claude Skills**(规划中):`skills/` 目录直接放进 Agent
|
|
63
63
|
|
|
64
64
|
> 架构思想来自 [opencli](https://github.com/jackwener/opencli) 的 single-registry 设计。
|
|
@@ -215,7 +215,7 @@ $ goofish item publish \
|
|
|
215
215
|
"mcpServers": {
|
|
216
216
|
"goofish": {
|
|
217
217
|
"command": "uvx",
|
|
218
|
-
"args": ["goofish-
|
|
218
|
+
"args": ["goofish-cli"]
|
|
219
219
|
}
|
|
220
220
|
}
|
|
221
221
|
}
|
|
@@ -231,13 +231,13 @@ Claude 会自动把全部命令看成 tool:`goofish_item_get` / `goofish_item_
|
|
|
231
231
|
|---|---|
|
|
232
232
|
| 11 个核心 mtop 接口 | 发布/下架/查询/图片/类目/地址/IM 全覆盖 |
|
|
233
233
|
| CLI + `--format` 多格式输出 | `json` / `yaml` / `table` / `md` / `csv`,人机两用 |
|
|
234
|
-
| MCP Server | `uvx goofish-
|
|
234
|
+
| MCP Server | `uvx goofish-cli` 一行接入 Claude Code / Cursor |
|
|
235
235
|
| WebSocket 批量 push 全量解码 | 一帧多条消息全部还原,不丢单 |
|
|
236
236
|
| WebSocket 自动重连 | 断线自退避重连,长跑无感知 |
|
|
237
237
|
| 已读回执 / typing / 新消息通知分类 | `/s/sync` 元事件结构化为三类 JSONL |
|
|
238
238
|
| 全局限流 + 风控熔断 | 令牌桶 1 写/分钟 + RGV587 自动熔断 |
|
|
239
239
|
| 单元测试 | 33 个,ruff 零告警 |
|
|
240
|
-
| 包分发 | `pip install goofish-cli` / `uvx goofish-
|
|
240
|
+
| 包分发 | `pip install goofish-cli` / `uvx goofish-cli` |
|
|
241
241
|
|
|
242
242
|
---
|
|
243
243
|
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
**同一份定义**同时输出给三种消费者:
|
|
20
20
|
|
|
21
21
|
- 👨💻 **人类**:`goofish item get 12345 --format table`
|
|
22
|
-
- 🤖 **AI Agent(Claude Code / Cursor / Codex)**:`uvx goofish-
|
|
22
|
+
- 🤖 **AI Agent(Claude Code / Cursor / Codex)**:`uvx goofish-cli` → 自动注册成 MCP tool
|
|
23
23
|
- 🧩 **Claude Skills**(规划中):`skills/` 目录直接放进 Agent
|
|
24
24
|
|
|
25
25
|
> 架构思想来自 [opencli](https://github.com/jackwener/opencli) 的 single-registry 设计。
|
|
@@ -176,7 +176,7 @@ $ goofish item publish \
|
|
|
176
176
|
"mcpServers": {
|
|
177
177
|
"goofish": {
|
|
178
178
|
"command": "uvx",
|
|
179
|
-
"args": ["goofish-
|
|
179
|
+
"args": ["goofish-cli"]
|
|
180
180
|
}
|
|
181
181
|
}
|
|
182
182
|
}
|
|
@@ -192,13 +192,13 @@ Claude 会自动把全部命令看成 tool:`goofish_item_get` / `goofish_item_
|
|
|
192
192
|
|---|---|
|
|
193
193
|
| 11 个核心 mtop 接口 | 发布/下架/查询/图片/类目/地址/IM 全覆盖 |
|
|
194
194
|
| CLI + `--format` 多格式输出 | `json` / `yaml` / `table` / `md` / `csv`,人机两用 |
|
|
195
|
-
| MCP Server | `uvx goofish-
|
|
195
|
+
| MCP Server | `uvx goofish-cli` 一行接入 Claude Code / Cursor |
|
|
196
196
|
| WebSocket 批量 push 全量解码 | 一帧多条消息全部还原,不丢单 |
|
|
197
197
|
| WebSocket 自动重连 | 断线自退避重连,长跑无感知 |
|
|
198
198
|
| 已读回执 / typing / 新消息通知分类 | `/s/sync` 元事件结构化为三类 JSONL |
|
|
199
199
|
| 全局限流 + 风控熔断 | 令牌桶 1 写/分钟 + RGV587 自动熔断 |
|
|
200
200
|
| 单元测试 | 33 个,ruff 零告警 |
|
|
201
|
-
| 包分发 | `pip install goofish-cli` / `uvx goofish-
|
|
201
|
+
| 包分发 | `pip install goofish-cli` / `uvx goofish-cli` |
|
|
202
202
|
|
|
203
203
|
---
|
|
204
204
|
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"mcpServers": {
|
|
10
10
|
"goofish": {
|
|
11
11
|
"command": "uvx",
|
|
12
|
-
"args": ["goofish-
|
|
12
|
+
"args": ["goofish-cli"]
|
|
13
13
|
}
|
|
14
14
|
}
|
|
15
15
|
}
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
{
|
|
22
22
|
"mcpServers": {
|
|
23
23
|
"goofish": {
|
|
24
|
-
"command": "/Users/you/Desktop/goofish-cli/.venv/bin/goofish-
|
|
24
|
+
"command": "/Users/you/Desktop/goofish-cli/.venv/bin/goofish-cli"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
}
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "goofish-cli"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.2"
|
|
8
8
|
description = "闲鱼 CLI — 支持 MCP,未来支持 Claude Skills。为 AI Agent 提供闲鱼自动化基础能力。"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "Apache-2.0" }
|
|
@@ -44,6 +44,14 @@ dev = [
|
|
|
44
44
|
|
|
45
45
|
[project.scripts]
|
|
46
46
|
goofish = "goofish_cli.cli:app"
|
|
47
|
+
# MCP server 入口。名字与 package 名一致,`uvx goofish-cli` 可直接拉起(uvx 默认按
|
|
48
|
+
# command 名查包,和 scripts 名对齐就不用 --from 绕弯)。这是 Claude Code 等 AI Agent
|
|
49
|
+
# 配 MCP 时的推荐写法。
|
|
50
|
+
goofish-cli = "goofish_cli.mcp_server:main"
|
|
51
|
+
# 保留 `goofish-mcp` 别名只在 **本包已装** 时可用(`pip install goofish-cli` /
|
|
52
|
+
# `uv tool install goofish-cli` / `uvx --from goofish-cli goofish-mcp`)。单独的
|
|
53
|
+
# `uvx goofish-mcp` 仍会因 PyPI 无同名包解析失败 —— 这不是别名能救的,Claude Code
|
|
54
|
+
# MCP 配置必须用上面的 `uvx goofish-cli`。
|
|
47
55
|
goofish-mcp = "goofish_cli.mcp_server:main"
|
|
48
56
|
|
|
49
57
|
[project.urls]
|
|
@@ -85,9 +85,14 @@ async def goofish_page(
|
|
|
85
85
|
*,
|
|
86
86
|
headless: bool | None = None,
|
|
87
87
|
viewport: tuple[int, int] = (1440, 900),
|
|
88
|
+
cookies: dict[str, str] | None = None,
|
|
88
89
|
) -> AsyncIterator[Any]:
|
|
89
90
|
"""启动系统 Chrome(独立 tmp profile)+ 灌 cookie,yield 出一个 `Page`。
|
|
90
91
|
|
|
92
|
+
`cookies` 可选:显式传入 `{name: value}` 时直接用;不传则走 `Session.load()`
|
|
93
|
+
三级兜底。自动刷 `_m_h5_tk` 的调用方需要用内存里当前 session 的 cookies 而非
|
|
94
|
+
磁盘快照(可能已被改)—— 传 `cookies=session.http.cookies` 展平后的 dict。
|
|
95
|
+
|
|
91
96
|
用法:
|
|
92
97
|
async with goofish_page() as page:
|
|
93
98
|
await page.goto("https://www.goofish.com/search?q=foo")
|
|
@@ -102,7 +107,8 @@ async def goofish_page(
|
|
|
102
107
|
PROFILES_PARENT.mkdir(parents=True, exist_ok=True)
|
|
103
108
|
# 每次调用独立 profile 目录,避开 Chrome SingletonLock 并发冲突
|
|
104
109
|
profile_dir = Path(tempfile.mkdtemp(prefix="chrome-", dir=str(PROFILES_PARENT)))
|
|
105
|
-
cookies
|
|
110
|
+
if cookies is None:
|
|
111
|
+
cookies = _load_cookies_from_session()
|
|
106
112
|
pw_cookies = _cookies_to_playwright(cookies)
|
|
107
113
|
|
|
108
114
|
try:
|
|
@@ -11,6 +11,8 @@ import json
|
|
|
11
11
|
import time
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
14
16
|
from goofish_cli.core.errors import (
|
|
15
17
|
AuthRequiredError,
|
|
16
18
|
GoofishError,
|
|
@@ -68,8 +70,13 @@ def call(
|
|
|
68
70
|
spm_cnt: str = "a21ybx.home.0.0",
|
|
69
71
|
extra_params: dict[str, str] | None = None,
|
|
70
72
|
headers: dict[str, str] | None = None,
|
|
73
|
+
_auto_refresh: bool = True,
|
|
71
74
|
) -> dict[str, Any]:
|
|
72
|
-
"""调用 mtop 接口。返回原始 JSON。失败抛 GoofishError 子类。
|
|
75
|
+
"""调用 mtop 接口。返回原始 JSON。失败抛 GoofishError 子类。
|
|
76
|
+
|
|
77
|
+
`_auto_refresh=True`:遇到 `FAIL_SYS_TOKEN_EXOIRED` 时自动用 Playwright goto
|
|
78
|
+
闲鱼首页刷一次 cookie 再重试一次。递归调用时用 False 避免死循环。
|
|
79
|
+
"""
|
|
73
80
|
url = f"{MTOP_HOST}/h5/{api}/{version}/"
|
|
74
81
|
t_ms = str(int(time.time() * 1000))
|
|
75
82
|
data_val = data if isinstance(data, str) else json.dumps(data, separators=(",", ":"))
|
|
@@ -104,10 +111,34 @@ def call(
|
|
|
104
111
|
timeout=30,
|
|
105
112
|
)
|
|
106
113
|
raw = resp.json()
|
|
107
|
-
|
|
114
|
+
try:
|
|
115
|
+
_classify_error(raw, api)
|
|
116
|
+
except AuthRequiredError as e:
|
|
117
|
+
# 只对 h5_tk 层的过期做自动刷新 —— session 级失效(FAIL_SYS_SESSION_EXPIRED
|
|
118
|
+
# / ILLEGAL_ACCESS)通常意味着 unb/cookie2 也得换,不是刷 _m_h5_tk 能救的。
|
|
119
|
+
if not (_auto_refresh and _is_token_expired_error(e)):
|
|
120
|
+
raise
|
|
121
|
+
from goofish_cli.core.refresh import is_enabled, refresh_cookies_via_browser
|
|
122
|
+
if not is_enabled():
|
|
123
|
+
raise
|
|
124
|
+
logger.info(f"[{api}] 检测到 _m_h5_tk 过期,尝试 Playwright goto 刷新 cookie…")
|
|
125
|
+
if not refresh_cookies_via_browser(session):
|
|
126
|
+
raise
|
|
127
|
+
# 刷新成功:重试一次,禁用递归自动刷新
|
|
128
|
+
return call(
|
|
129
|
+
session, api, data,
|
|
130
|
+
version=version, spm_cnt=spm_cnt,
|
|
131
|
+
extra_params=extra_params, headers=headers,
|
|
132
|
+
_auto_refresh=False,
|
|
133
|
+
)
|
|
108
134
|
return raw
|
|
109
135
|
|
|
110
136
|
|
|
137
|
+
def _is_token_expired_error(e: AuthRequiredError) -> bool:
|
|
138
|
+
msg = str(e)
|
|
139
|
+
return any(kw in msg for kw in ("FAIL_SYS_TOKEN_EXOIRED", "FAIL_SYS_TOKEN_EMPTY", "令牌过期"))
|
|
140
|
+
|
|
141
|
+
|
|
111
142
|
def _classify_error(raw: dict[str, Any], api: str) -> None:
|
|
112
143
|
"""根据 ret 字段分类抛异常。成功则不抛。"""
|
|
113
144
|
ret = raw.get("ret") or []
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""遇 FAIL_SYS_TOKEN_EXOIRED 时用 Playwright goto 闲鱼首页刷新 cookie。
|
|
2
|
+
|
|
3
|
+
`_m_h5_tk` 只有 10 分钟有效期,服务端在浏览器**活跃访问**闲鱼页面时通过
|
|
4
|
+
`Set-Cookie` 续期;浏览器静默几分钟就会过期。mcp/cli 用 `browser_cookie3`
|
|
5
|
+
从磁盘抓快照,命中"抓的时候还没过期、用的时候已过"的窗口很常见。
|
|
6
|
+
|
|
7
|
+
与其让用户手动去浏览器刷新,不如我们自己用 Playwright `goto` 一次首页——
|
|
8
|
+
反正 search/item view 的浏览器基础设施已经搭好了,直接复用。
|
|
9
|
+
|
|
10
|
+
开关:`GOOFISH_AUTO_REFRESH_TOKEN=0` 可关闭(CI 或想自定义刷新策略时)。
|
|
11
|
+
|
|
12
|
+
注意:闲鱼后端返回的错误码是 `FAIL_SYS_TOKEN_EXOIRED`(是 EXOIRED 不是 EXPIRED,
|
|
13
|
+
拼写没写错),和 `_AUTH_KEYWORDS` / `_is_token_expired_error` 匹配一致。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
from goofish_cli.core.session import Session, resolve_cookie_path, write_cookies_json
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _refresh_async(url: str, cookies: dict[str, str]) -> dict[str, str]:
|
|
26
|
+
# 延迟 import:测试环境没装 playwright 也能 import refresh 模块
|
|
27
|
+
from goofish_cli.core.browser import goofish_page
|
|
28
|
+
|
|
29
|
+
# 显式传 cookies——让 Playwright context 注入**调用方当前 session** 的登录态,
|
|
30
|
+
# 而不是让 goofish_page 再走一次 Session.load() 从磁盘拉(内存里可能已被改)。
|
|
31
|
+
async with goofish_page(cookies=cookies) as page:
|
|
32
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
33
|
+
# 给 mtop h5 sign 一些时间跑完并让服务端 Set-Cookie 新 _m_h5_tk
|
|
34
|
+
await page.wait_for_timeout(1500)
|
|
35
|
+
pw_cookies = await page.context.cookies()
|
|
36
|
+
|
|
37
|
+
return {c["name"]: c["value"] for c in pw_cookies if c.get("name") and c.get("value")}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_enabled() -> bool:
|
|
41
|
+
return os.environ.get("GOOFISH_AUTO_REFRESH_TOKEN") != "0"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def refresh_cookies_via_browser(session: Session, *, persist: bool = True) -> bool:
|
|
45
|
+
"""用 Playwright goto 一次闲鱼首页拿新 cookie 并合并回 session。
|
|
46
|
+
|
|
47
|
+
成功返回 True,否则 False(调用方按 False 走原始 AuthRequiredError)。
|
|
48
|
+
"""
|
|
49
|
+
current = {name: value for name, value in session.http.cookies.items() if value}
|
|
50
|
+
try:
|
|
51
|
+
fresh = asyncio.run(_refresh_async("https://www.goofish.com", current))
|
|
52
|
+
except Exception as e: # noqa: BLE001 — Playwright 起不来、Chrome 未装、超时等都走这里
|
|
53
|
+
logger.warning(f"用 Playwright 刷 cookie 失败:{e}")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
if "_m_h5_tk" not in fresh or "unb" not in fresh:
|
|
57
|
+
logger.warning(f"刷 cookie 后仍缺关键字段,跳过合并(拿到 {len(fresh)} 个 cookie)")
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# 关键:直接 `update(fresh)` 会造成跨 domain 同名 cookie 并存(如 .goofish.com 下
|
|
61
|
+
# 一份旧 _m_h5_tk + Playwright 又写入一份新的),后续 `cookies.get(name)` 会抛
|
|
62
|
+
# "There are multiple cookies with name"。所以先删掉所有将被更新的 name 的旧条目。
|
|
63
|
+
names_to_refresh = set(fresh.keys())
|
|
64
|
+
for cookie in list(session.http.cookies):
|
|
65
|
+
if cookie.name in names_to_refresh:
|
|
66
|
+
session.http.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name)
|
|
67
|
+
session.http.cookies.update(fresh)
|
|
68
|
+
|
|
69
|
+
if persist:
|
|
70
|
+
# 尊重 GOOFISH_COOKIES_PATH —— 用户配置了自定义路径时不能写默认路径后再下次
|
|
71
|
+
# Session.load 又去读自定义路径,造成"刷新了但下次启动又回到旧的"。
|
|
72
|
+
path = resolve_cookie_path()
|
|
73
|
+
# 此时 session.http.cookies 已没有同名冲突,安全转 dict
|
|
74
|
+
merged = {**dict(session.http.cookies), **fresh}
|
|
75
|
+
try:
|
|
76
|
+
write_cookies_json(path, merged)
|
|
77
|
+
logger.info(f"cookie 已刷新并写回 {path}")
|
|
78
|
+
except OSError as e:
|
|
79
|
+
logger.debug(f"写回 cookies.json 失败(内存里仍生效):{e}")
|
|
80
|
+
return True
|
|
@@ -22,6 +22,16 @@ from goofish_cli.core.errors import AuthRequiredError
|
|
|
22
22
|
from goofish_cli.core.sign import generate_device_id
|
|
23
23
|
|
|
24
24
|
DEFAULT_COOKIE_PATH = Path.home() / ".goofish-cli" / "cookies.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_cookie_path(cookie_path: Path | str | None = None) -> Path:
|
|
28
|
+
"""解析实际 cookie 文件路径。优先级:显式入参 > GOOFISH_COOKIES_PATH > 默认。
|
|
29
|
+
|
|
30
|
+
`Session.load()` 和自动刷新写回逻辑必须走同一套解析,避免"读一个路径、写另一个"。
|
|
31
|
+
"""
|
|
32
|
+
return Path(os.path.expanduser(
|
|
33
|
+
cookie_path or os.environ.get("GOOFISH_COOKIES_PATH") or DEFAULT_COOKIE_PATH
|
|
34
|
+
))
|
|
25
35
|
DEVICE_CACHE_PATH = Path.home() / ".goofish-cli" / "device.json"
|
|
26
36
|
USER_AGENT = (
|
|
27
37
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
@@ -39,9 +49,7 @@ class Session:
|
|
|
39
49
|
|
|
40
50
|
@classmethod
|
|
41
51
|
def load(cls, cookie_path: Path | str | None = None) -> Session:
|
|
42
|
-
path =
|
|
43
|
-
cookie_path or os.environ.get("GOOFISH_COOKIES_PATH") or DEFAULT_COOKIE_PATH
|
|
44
|
-
))
|
|
52
|
+
path = resolve_cookie_path(cookie_path)
|
|
45
53
|
|
|
46
54
|
cookies = _load_or_bootstrap_cookies(path)
|
|
47
55
|
|
|
@@ -1,12 +1,20 @@
|
|
|
1
1
|
"""FastMCP 入口。扫描同一 registry → 每个 Command 注册为 @mcp.tool()。
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
Claude Code 配置:
|
|
3
|
+
启动(推荐):`uvx goofish-cli` —— uvx 按 command 名找包,`goofish-cli` 匹配 PyPI
|
|
4
|
+
包名,一行搞定。Claude Code 配置:
|
|
5
|
+
|
|
5
6
|
{
|
|
6
7
|
"mcpServers": {
|
|
7
|
-
"goofish": { "command": "uvx", "args": ["goofish-
|
|
8
|
+
"goofish": { "command": "uvx", "args": ["goofish-cli"] }
|
|
8
9
|
}
|
|
9
10
|
}
|
|
11
|
+
|
|
12
|
+
其他入口(仅在 `goofish-cli` 包已装到某 env 时可用):
|
|
13
|
+
- `python -m goofish_cli.mcp_server`
|
|
14
|
+
- `goofish-mcp`(pip / uv tool install 后可用;或 `uvx --from goofish-cli goofish-mcp`)
|
|
15
|
+
|
|
16
|
+
注意:`uvx goofish-mcp` 单写会因 PyPI 无同名包而解析失败——不是别名能救的,这是 uvx
|
|
17
|
+
按 command 名查包的默认行为决定的。
|
|
10
18
|
"""
|
|
11
19
|
from __future__ import annotations
|
|
12
20
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""测 refresh 模块。Playwright 走真浏览器不进单测,mock 掉 asyncio.run。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from goofish_cli.core import refresh
|
|
10
|
+
from goofish_cli.core.session import Session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _make_session(cookies: dict[str, str]) -> Session:
|
|
14
|
+
http = requests.Session()
|
|
15
|
+
http.cookies.update(cookies)
|
|
16
|
+
return Session(http=http, unb=cookies.get("unb", ""), tracknick="", device_id="dev")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _fake_run(result):
|
|
20
|
+
"""模拟 asyncio.run:close 掉传入的 coroutine(避免 unawaited warning)后返回结果。"""
|
|
21
|
+
def _inner(coro):
|
|
22
|
+
coro.close()
|
|
23
|
+
return result
|
|
24
|
+
return _inner
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fake_run_raises(exc):
|
|
28
|
+
def _inner(coro):
|
|
29
|
+
coro.close()
|
|
30
|
+
raise exc
|
|
31
|
+
return _inner
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_refresh_merges_new_cookies_and_dedupes_same_name(tmp_path, monkeypatch):
|
|
35
|
+
"""关键回归:刷新后不能出现跨 domain 同名 cookie(`.cookies.get(...)` 会抛)。"""
|
|
36
|
+
# 预埋一份旧 _m_h5_tk(默认 domain)
|
|
37
|
+
session = _make_session({"_m_h5_tk": "old_1", "unb": "u1", "cookie2": "c2"})
|
|
38
|
+
|
|
39
|
+
# 通过 GOOFISH_COOKIES_PATH 让 resolve_cookie_path 指到测试临时路径
|
|
40
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(tmp_path / "cookies.json"))
|
|
41
|
+
|
|
42
|
+
fresh = {"_m_h5_tk": "new_2", "unb": "u1", "cookie2": "c3", "x5sec": "x"}
|
|
43
|
+
with patch.object(refresh, "asyncio") as mock_async:
|
|
44
|
+
mock_async.run.side_effect = _fake_run(fresh)
|
|
45
|
+
ok = refresh.refresh_cookies_via_browser(session)
|
|
46
|
+
|
|
47
|
+
assert ok is True
|
|
48
|
+
# 去重后能正常 get —— 不会抛 "multiple cookies with name"
|
|
49
|
+
assert session.http.cookies.get("_m_h5_tk") == "new_2"
|
|
50
|
+
assert session.http.cookies.get("cookie2") == "c3"
|
|
51
|
+
assert session.http.cookies.get("x5sec") == "x"
|
|
52
|
+
# 磁盘也写了
|
|
53
|
+
assert (tmp_path / "cookies.json").exists()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_refresh_fails_when_required_keys_missing(tmp_path, monkeypatch):
|
|
57
|
+
session = _make_session({"_m_h5_tk": "old", "unb": "u1"})
|
|
58
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(tmp_path / "cookies.json"))
|
|
59
|
+
|
|
60
|
+
with patch.object(refresh, "asyncio") as mock_async:
|
|
61
|
+
mock_async.run.side_effect = _fake_run({"foo": "bar"}) # 没 _m_h5_tk / unb
|
|
62
|
+
ok = refresh.refresh_cookies_via_browser(session)
|
|
63
|
+
|
|
64
|
+
assert ok is False
|
|
65
|
+
# 不应覆盖旧 session
|
|
66
|
+
assert session.http.cookies.get("_m_h5_tk") == "old"
|
|
67
|
+
# 也不应写盘
|
|
68
|
+
assert not (tmp_path / "cookies.json").exists()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_refresh_respects_custom_cookie_path(tmp_path, monkeypatch):
|
|
72
|
+
"""GOOFISH_COOKIES_PATH 自定义路径必须被尊重(Copilot review feedback)。"""
|
|
73
|
+
custom = tmp_path / "nested" / "my.json"
|
|
74
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(custom))
|
|
75
|
+
|
|
76
|
+
session = _make_session({"_m_h5_tk": "old", "unb": "u1"})
|
|
77
|
+
fresh = {"_m_h5_tk": "new", "unb": "u1"}
|
|
78
|
+
with patch.object(refresh, "asyncio") as mock_async:
|
|
79
|
+
mock_async.run.side_effect = _fake_run(fresh)
|
|
80
|
+
ok = refresh.refresh_cookies_via_browser(session)
|
|
81
|
+
|
|
82
|
+
assert ok is True
|
|
83
|
+
assert custom.exists()
|
|
84
|
+
# 默认路径不应被写
|
|
85
|
+
assert not (tmp_path / "cookies.json").exists()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_refresh_swallows_playwright_exception():
|
|
89
|
+
session = _make_session({"_m_h5_tk": "old", "unb": "u1"})
|
|
90
|
+
with patch.object(refresh, "asyncio") as mock_async:
|
|
91
|
+
mock_async.run.side_effect = _fake_run_raises(RuntimeError("chrome not installed"))
|
|
92
|
+
ok = refresh.refresh_cookies_via_browser(session, persist=False)
|
|
93
|
+
assert ok is False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize(
|
|
97
|
+
"env,expected",
|
|
98
|
+
[(None, True), ("0", False), ("1", True), ("", True)],
|
|
99
|
+
)
|
|
100
|
+
def test_is_enabled_honors_env(monkeypatch, env, expected):
|
|
101
|
+
if env is None:
|
|
102
|
+
monkeypatch.delenv("GOOFISH_AUTO_REFRESH_TOKEN", raising=False)
|
|
103
|
+
else:
|
|
104
|
+
monkeypatch.setenv("GOOFISH_AUTO_REFRESH_TOKEN", env)
|
|
105
|
+
assert refresh.is_enabled() is expected
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.1.0"
|
|
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
|
|
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
|
|
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
|
|
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
|