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.
Files changed (79) hide show
  1. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/CHANGELOG.md +33 -1
  2. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/PKG-INFO +5 -5
  3. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/README.md +4 -4
  4. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/docs/mcp-setup.md +2 -2
  5. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/pyproject.toml +9 -1
  6. goofish_cli-0.2.2/src/goofish_cli/__init__.py +9 -0
  7. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/browser.py +7 -1
  8. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/mtop.py +33 -2
  9. goofish_cli-0.2.2/src/goofish_cli/core/refresh.py +80 -0
  10. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/session.py +11 -3
  11. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/mcp_server.py +11 -3
  12. goofish_cli-0.2.2/tests/test_refresh.py +105 -0
  13. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/uv.lock +1 -1
  14. goofish_cli-0.2.0/src/goofish_cli/__init__.py +0 -1
  15. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.env.example +0 -0
  16. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  17. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  18. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/pull_request_template.md +0 -0
  19. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/workflows/ci.yml +0 -0
  20. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.github/workflows/publish.yml +0 -0
  21. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/.gitignore +0 -0
  22. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/CONTRIBUTING.md +0 -0
  23. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/LICENSE +0 -0
  24. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/NOTICE +0 -0
  25. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/docs/architecture.md +0 -0
  26. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/docs/compliance.md +0 -0
  27. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/examples/README.md +0 -0
  28. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/examples/publish_from_folder.py +0 -0
  29. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/examples/watch_to_autoreply.sh +0 -0
  30. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/cli.py +0 -0
  31. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/__init__.py +0 -0
  32. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/__init__.py +0 -0
  33. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/login.py +0 -0
  34. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/reset_guard.py +0 -0
  35. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/status.py +0 -0
  36. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/category/__init__.py +0 -0
  37. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/category/recommend.py +0 -0
  38. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/__init__.py +0 -0
  39. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/delete.py +0 -0
  40. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/get.py +0 -0
  41. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/publish.py +0 -0
  42. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/view.py +0 -0
  43. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/location/__init__.py +0 -0
  44. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/location/default.py +0 -0
  45. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/media/__init__.py +0 -0
  46. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/media/upload.py +0 -0
  47. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/__init__.py +0 -0
  48. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/history.py +0 -0
  49. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/list_chats.py +0 -0
  50. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/send.py +0 -0
  51. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/watch.py +0 -0
  52. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/search/__init__.py +0 -0
  53. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/commands/search/search.py +0 -0
  54. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/__init__.py +0 -0
  55. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/browser_cookie.py +0 -0
  56. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/errors.py +0 -0
  57. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/guard.py +0 -0
  58. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/limiter.py +0 -0
  59. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/output.py +0 -0
  60. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/registry.py +0 -0
  61. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/sign.py +0 -0
  62. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/strategy.py +0 -0
  63. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/token.py +0 -0
  64. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/core/ws.py +0 -0
  65. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/src/goofish_cli/static/goofish_js_version_2.js +0 -0
  66. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/__init__.py +0 -0
  67. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_auth_login.py +0 -0
  68. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_browser_cookie.py +0 -0
  69. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_guard.py +0 -0
  70. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_item_view.py +0 -0
  71. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_limiter.py +0 -0
  72. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_list_chats.py +0 -0
  73. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_mtop.py +0 -0
  74. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_publish_payload.py +0 -0
  75. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_registry.py +0 -0
  76. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_search.py +0 -0
  77. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_session_bootstrap.py +0 -0
  78. {goofish_cli-0.2.0 → goofish_cli-0.2.2}/tests/test_sign.py +0 -0
  79. {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.0...HEAD
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.0
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-mcp` → 自动注册成 MCP tool
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-mcp"]
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-mcp` 一行接入 Claude Code / Cursor |
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-mcp` |
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-mcp` → 自动注册成 MCP tool
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-mcp"]
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-mcp` 一行接入 Claude Code / Cursor |
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-mcp` |
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-mcp"]
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-mcp"
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.0"
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]
@@ -0,0 +1,9 @@
1
+ from importlib.metadata import PackageNotFoundError
2
+ from importlib.metadata import version as _pkg_version
3
+
4
+ try:
5
+ __version__ = _pkg_version("goofish-cli")
6
+ except PackageNotFoundError:
7
+ __version__ = "0.0.0+dev"
8
+
9
+ __all__ = ["__version__"]
@@ -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 = _load_cookies_from_session()
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
- _classify_error(raw, api)
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 = Path(os.path.expanduser(
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
- 启动:`uvx goofish-mcp` 或 `python -m goofish_cli.mcp_server`
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-mcp"] }
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
@@ -307,7 +307,7 @@ wheels = [
307
307
 
308
308
  [[package]]
309
309
  name = "goofish-cli"
310
- version = "0.2.0"
310
+ version = "0.2.2"
311
311
  source = { editable = "." }
312
312
  dependencies = [
313
313
  { name = "browser-cookie3" },
@@ -1 +0,0 @@
1
- __version__ = "0.1.0"
File without changes
File without changes
File without changes
File without changes
File without changes