goofish-cli 0.1.0__tar.gz → 0.2.0__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 (80) hide show
  1. goofish_cli-0.2.0/CHANGELOG.md +61 -0
  2. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/PKG-INFO +11 -4
  3. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/README.md +8 -3
  4. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/pyproject.toml +3 -1
  5. goofish_cli-0.2.0/src/goofish_cli/commands/auth/login.py +116 -0
  6. goofish_cli-0.2.0/src/goofish_cli/commands/item/view.py +182 -0
  7. goofish_cli-0.2.0/src/goofish_cli/commands/message/list_chats.py +110 -0
  8. goofish_cli-0.2.0/src/goofish_cli/commands/search/search.py +169 -0
  9. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/__init__.py +4 -0
  10. goofish_cli-0.2.0/src/goofish_cli/core/browser.py +147 -0
  11. goofish_cli-0.2.0/src/goofish_cli/core/browser_cookie.py +262 -0
  12. goofish_cli-0.2.0/src/goofish_cli/core/errors.py +51 -0
  13. goofish_cli-0.2.0/src/goofish_cli/core/session.py +151 -0
  14. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/ws.py +107 -0
  15. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/mcp_server.py +13 -2
  16. goofish_cli-0.2.0/tests/__init__.py +0 -0
  17. goofish_cli-0.2.0/tests/test_auth_login.py +74 -0
  18. goofish_cli-0.2.0/tests/test_browser_cookie.py +164 -0
  19. goofish_cli-0.2.0/tests/test_item_view.py +24 -0
  20. goofish_cli-0.2.0/tests/test_list_chats.py +106 -0
  21. goofish_cli-0.2.0/tests/test_search.py +36 -0
  22. goofish_cli-0.2.0/tests/test_session_bootstrap.py +137 -0
  23. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/uv.lock +219 -1
  24. goofish_cli-0.1.0/CHANGELOG.md +0 -32
  25. goofish_cli-0.1.0/src/goofish_cli/commands/auth/login.py +0 -66
  26. goofish_cli-0.1.0/src/goofish_cli/core/errors.py +0 -31
  27. goofish_cli-0.1.0/src/goofish_cli/core/session.py +0 -88
  28. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.env.example +0 -0
  29. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  30. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  31. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/pull_request_template.md +0 -0
  32. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/workflows/ci.yml +0 -0
  33. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/workflows/publish.yml +0 -0
  34. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.gitignore +0 -0
  35. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/CONTRIBUTING.md +0 -0
  36. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/LICENSE +0 -0
  37. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/NOTICE +0 -0
  38. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/docs/architecture.md +0 -0
  39. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/docs/compliance.md +0 -0
  40. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/docs/mcp-setup.md +0 -0
  41. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/examples/README.md +0 -0
  42. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/examples/publish_from_folder.py +0 -0
  43. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/examples/watch_to_autoreply.sh +0 -0
  44. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/__init__.py +0 -0
  45. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/cli.py +0 -0
  46. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/__init__.py +0 -0
  47. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/auth/__init__.py +0 -0
  48. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/auth/reset_guard.py +0 -0
  49. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/auth/status.py +0 -0
  50. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/category/__init__.py +0 -0
  51. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/category/recommend.py +0 -0
  52. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/item/__init__.py +0 -0
  53. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/item/delete.py +0 -0
  54. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/item/get.py +0 -0
  55. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/item/publish.py +0 -0
  56. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/location/__init__.py +0 -0
  57. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/location/default.py +0 -0
  58. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/media/__init__.py +0 -0
  59. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/media/upload.py +0 -0
  60. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/message/__init__.py +0 -0
  61. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/message/history.py +0 -0
  62. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/message/send.py +0 -0
  63. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/message/watch.py +0 -0
  64. {goofish_cli-0.1.0/tests → goofish_cli-0.2.0/src/goofish_cli/commands/search}/__init__.py +0 -0
  65. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/guard.py +0 -0
  66. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/limiter.py +0 -0
  67. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/mtop.py +0 -0
  68. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/output.py +0 -0
  69. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/registry.py +0 -0
  70. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/sign.py +0 -0
  71. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/strategy.py +0 -0
  72. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/token.py +0 -0
  73. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/static/goofish_js_version_2.js +0 -0
  74. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_guard.py +0 -0
  75. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_limiter.py +0 -0
  76. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_mtop.py +0 -0
  77. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_publish_payload.py +0 -0
  78. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_registry.py +0 -0
  79. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_sign.py +0 -0
  80. {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_ws_codec.py +0 -0
@@ -0,0 +1,61 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
5
+ and this project adheres to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.2.0] - 2026-04-22
10
+
11
+ ### Added
12
+ - **浏览器自动化子系统**(`core/browser.py`):基于 Playwright + 系统 Chrome(`channel="chrome"`)
13
+ 每次调用独立 tmp profile(`~/.goofish-cli/profiles/chrome-<tmp>/`),退出清理——
14
+ 避开 Chrome `SingletonLock` 并发冲突。自动从 `Session.load()` 注入登录态。
15
+ 默认 **headful**(headless 会被闲鱼识别为"非法访问"),CI 可 `GOOFISH_HEADLESS=1` 切回。
16
+ - `goofish search items <query>` [`--limit N`]:浏览器路径搜索商品。对标 OpenCLI
17
+ `xianyu/search.js`,打开搜索页 → 自动滚动触发懒加载 → 从 DOM 提卡片。返回
18
+ `rank / item_id / title / price / condition / brand / location / badge / url`。
19
+ - `goofish item view <item_id>`:浏览器视角看商品详情。在商品页上下文里调
20
+ `window.lib.mtop.request('mtop.taobao.idle.pc.detail')`,从 `itemDO/sellerDO/itemLabelExtList`
21
+ 抽 20+ 字段(description / want_count / browse_count / 成色 / 品牌 / seller_score /
22
+ reply_ratio_24h / image_urls 等)。和现有 `item get`(CLI 直签,字段浅)并存。
23
+ - `goofish message list-chats`:拉取会话列表(对应网页左栏),返回 `session_id`、对方昵称、
24
+ 未读数、最后一条消息摘要、`sessionType`(1=真人 / 3=系统 / 6=互动 / 23=官方通知)。
25
+ AI Agent 可基于 `sessionType=1 and unread>0` 一键过滤"待回复真人会话"。
26
+ - 接口:`mtop.taobao.idlemessage.pc.session.sync` v3.0,入参 `fetchNum`。
27
+ - `list-chats --watch-secs N`:在 baseline 之上连 WS 收 N 秒 `ackDiff(pts=0)` 的历史推送,
28
+ 补 h5 `session.sync` 漏掉的 cid(网页左栏 50 条也靠 ACCS 累积,单次 HTTP 拿不到)。每条
29
+ record 带 `source` 字段(`baseline` / `watch`)。watch 记录只有 cid / session_type /
30
+ item_id / ts,要正文要自己调 `message history <cid>`。
31
+
32
+ ### Changed
33
+ - MCP handler 用 `asyncio.to_thread` 包装同步命令调用,避免 `asyncio.run()` 在已有事件
34
+ 循环里抛 `RuntimeError` —— `search items` / `item view` / `list-chats --watch-secs` 现在
35
+ 可以通过 MCP 正常调用。
36
+
37
+ ## [0.1.0] - 2026-04-21
38
+
39
+ ### Added
40
+ - 初始发布。12 个命令覆盖闲鱼核心运营链路:
41
+ - `auth`:login / status / reset-guard
42
+ - `item`:get / publish / delete
43
+ - `media upload`、`category recommend`、`location default`
44
+ - `message`:watch / history / send(WebSocket IM)
45
+ - `message watch` 三类事件分类输出(JSONL):
46
+ - `event=message`:收到消息正文
47
+ - `event=read`:对方已读回执(带 msg_ids)
48
+ - `event=new_msg`:轻量新消息通知(无正文,需 history 拉取)
49
+ - WebSocket 自动重连 + 指数退避
50
+ - 全局速率限制(令牌桶 1 写/分钟)+ RGV587 熔断器
51
+ - MCP server:`goofish-mcp` 同一命令集自动暴露给 Claude Code / Cursor / Codex
52
+ - 统一输出契约 `--format json/yaml/table/md/csv`
53
+ - 29 个单测
54
+
55
+ ### Notes
56
+ - 本版本需要用户手动从浏览器导入 cookie(含 `unb` / `_m_h5_tk` / `x5sec`)
57
+ - 遇到 `RGV587_ERROR` 风控时,需在浏览器完成滑块验证并**重新导出**带 `x5sec` 的 cookie
58
+
59
+ [Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.0...HEAD
60
+ [0.2.0]: https://github.com/fancyboi999/goofish-cli/compare/v0.1.0...v0.2.0
61
+ [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.1.0
3
+ Version: 0.2.0
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
@@ -18,8 +18,10 @@ Classifier: Programming Language :: Python :: 3.11
18
18
  Classifier: Programming Language :: Python :: 3.12
19
19
  Classifier: Topic :: Internet :: WWW/HTTP
20
20
  Requires-Python: >=3.11
21
+ Requires-Dist: browser-cookie3>=0.20
21
22
  Requires-Dist: loguru>=0.7
22
23
  Requires-Dist: mcp>=1.2
24
+ Requires-Dist: playwright>=1.58.0
23
25
  Requires-Dist: pydantic>=2.6
24
26
  Requires-Dist: pyexecjs>=1.5
25
27
  Requires-Dist: pyyaml>=6.0
@@ -65,7 +67,7 @@ Description-Content-Type: text/markdown
65
67
 
66
68
  ## ✨ 核心特性
67
69
 
68
- - 🔐 **12 个命令覆盖核心链路**:发布、下架、查询、图片上传、AI 类目识别、默认地址、IM 收发
70
+ - 🔐 **13 个命令覆盖核心链路**:发布、下架、查询、图片上传、AI 类目识别、默认地址、IM 收发 + 会话列表
69
71
  - 📡 **真·实时 IM**:WebSocket 长连 + 自动重连 + **三类事件分类输出**
70
72
  - `event=message`(收到消息)· `event=read`(已读回执)· `event=new_msg`(轻量通知)
71
73
  - 🛡 **内置风控护栏**:令牌桶限流(1 写/分钟)+ RGV587 自动熔断
@@ -116,6 +118,9 @@ $ goofish list-commands --format table
116
118
  | `media upload` | 上传图片到闲鱼 CDN | ✅ |
117
119
  | `category recommend` | AI 识别商品类目 | ❌ |
118
120
  | `location default` | 获取默认发布地址 | ❌ |
121
+ | `message list-chats` | 拉取会话列表(左栏;`--watch-secs N` 叠加 WS 历史推送补漏) | ❌ |
122
+ | `search items` | 搜索闲鱼商品(浏览器路径 Playwright + 系统 Chrome) | ❌ |
123
+ | `item view` | 浏览器视角看商品详情(字段完整,抗风控;`item get` 的姊妹版) | ❌ |
119
124
  | `message history` | 拉取会话历史消息 | ❌ |
120
125
  | `message send` | 发送文本/图片 | ✅ |
121
126
  | `message watch` | 常驻 IM 长连(JSONL 输出) | ❌ |
@@ -231,7 +236,7 @@ Claude 会自动把全部命令看成 tool:`goofish_item_get` / `goofish_item_
231
236
  | WebSocket 自动重连 | 断线自退避重连,长跑无感知 |
232
237
  | 已读回执 / typing / 新消息通知分类 | `/s/sync` 元事件结构化为三类 JSONL |
233
238
  | 全局限流 + 风控熔断 | 令牌桶 1 写/分钟 + RGV587 自动熔断 |
234
- | 单元测试 | 29 个,ruff 零告警 |
239
+ | 单元测试 | 33 个,ruff 零告警 |
235
240
  | 包分发 | `pip install goofish-cli` / `uvx goofish-mcp` |
236
241
 
237
242
  ---
@@ -239,6 +244,8 @@ Claude 会自动把全部命令看成 tool:`goofish_item_get` / `goofish_item_
239
244
  ## 🗺 Roadmap
240
245
 
241
246
  - [x] v0.1:12 个命令 + MCP + IM 三类事件
247
+ - [x] v0.2:`goofish message list-chats`(会话列表 + sessionType 分类:1 真人 / 3 系统 / 6 互动 / 23 通知;`--watch-secs` 支持合并 WS `ackDiff(pts=0)` 历史推送补齐 h5 接口漏掉的会话)
248
+ - [x] v0.2:浏览器自动化链路(吸纳 [OpenCLI](https://github.com/jackwener/opencli) 精华)—— Playwright + 系统 Chrome 驱动 `goofish search items` / `goofish item view`,抗风控 & 完整字段
242
249
  - [ ] v0.2:`goofish message create-chat`(主动与陌生用户建会话)
243
250
  - [ ] v0.2:Claude Skills 包装(`skills/` 目录)
244
251
  - [ ] v0.3:`goofish order`(订单状态查询 / 发货)
@@ -254,7 +261,7 @@ cd goofish-cli
254
261
  uv venv --python 3.11
255
262
  uv pip install -e ".[dev]"
256
263
 
257
- uv run pytest # 29 测全绿
264
+ uv run pytest # 33 测全绿
258
265
  uv run ruff check src tests # 零告警
259
266
  ```
260
267
 
@@ -28,7 +28,7 @@
28
28
 
29
29
  ## ✨ 核心特性
30
30
 
31
- - 🔐 **12 个命令覆盖核心链路**:发布、下架、查询、图片上传、AI 类目识别、默认地址、IM 收发
31
+ - 🔐 **13 个命令覆盖核心链路**:发布、下架、查询、图片上传、AI 类目识别、默认地址、IM 收发 + 会话列表
32
32
  - 📡 **真·实时 IM**:WebSocket 长连 + 自动重连 + **三类事件分类输出**
33
33
  - `event=message`(收到消息)· `event=read`(已读回执)· `event=new_msg`(轻量通知)
34
34
  - 🛡 **内置风控护栏**:令牌桶限流(1 写/分钟)+ RGV587 自动熔断
@@ -79,6 +79,9 @@ $ goofish list-commands --format table
79
79
  | `media upload` | 上传图片到闲鱼 CDN | ✅ |
80
80
  | `category recommend` | AI 识别商品类目 | ❌ |
81
81
  | `location default` | 获取默认发布地址 | ❌ |
82
+ | `message list-chats` | 拉取会话列表(左栏;`--watch-secs N` 叠加 WS 历史推送补漏) | ❌ |
83
+ | `search items` | 搜索闲鱼商品(浏览器路径 Playwright + 系统 Chrome) | ❌ |
84
+ | `item view` | 浏览器视角看商品详情(字段完整,抗风控;`item get` 的姊妹版) | ❌ |
82
85
  | `message history` | 拉取会话历史消息 | ❌ |
83
86
  | `message send` | 发送文本/图片 | ✅ |
84
87
  | `message watch` | 常驻 IM 长连(JSONL 输出) | ❌ |
@@ -194,7 +197,7 @@ Claude 会自动把全部命令看成 tool:`goofish_item_get` / `goofish_item_
194
197
  | WebSocket 自动重连 | 断线自退避重连,长跑无感知 |
195
198
  | 已读回执 / typing / 新消息通知分类 | `/s/sync` 元事件结构化为三类 JSONL |
196
199
  | 全局限流 + 风控熔断 | 令牌桶 1 写/分钟 + RGV587 自动熔断 |
197
- | 单元测试 | 29 个,ruff 零告警 |
200
+ | 单元测试 | 33 个,ruff 零告警 |
198
201
  | 包分发 | `pip install goofish-cli` / `uvx goofish-mcp` |
199
202
 
200
203
  ---
@@ -202,6 +205,8 @@ Claude 会自动把全部命令看成 tool:`goofish_item_get` / `goofish_item_
202
205
  ## 🗺 Roadmap
203
206
 
204
207
  - [x] v0.1:12 个命令 + MCP + IM 三类事件
208
+ - [x] v0.2:`goofish message list-chats`(会话列表 + sessionType 分类:1 真人 / 3 系统 / 6 互动 / 23 通知;`--watch-secs` 支持合并 WS `ackDiff(pts=0)` 历史推送补齐 h5 接口漏掉的会话)
209
+ - [x] v0.2:浏览器自动化链路(吸纳 [OpenCLI](https://github.com/jackwener/opencli) 精华)—— Playwright + 系统 Chrome 驱动 `goofish search items` / `goofish item view`,抗风控 & 完整字段
205
210
  - [ ] v0.2:`goofish message create-chat`(主动与陌生用户建会话)
206
211
  - [ ] v0.2:Claude Skills 包装(`skills/` 目录)
207
212
  - [ ] v0.3:`goofish order`(订单状态查询 / 发货)
@@ -217,7 +222,7 @@ cd goofish-cli
217
222
  uv venv --python 3.11
218
223
  uv pip install -e ".[dev]"
219
224
 
220
- uv run pytest # 29 测全绿
225
+ uv run pytest # 33 测全绿
221
226
  uv run ruff check src tests # 零告警
222
227
  ```
223
228
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "goofish-cli"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "闲鱼 CLI — 支持 MCP,未来支持 Claude Skills。为 AI Agent 提供闲鱼自动化基础能力。"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -30,6 +30,8 @@ dependencies = [
30
30
  "tenacity>=8.2",
31
31
  "mcp>=1.2",
32
32
  "websockets>=13",
33
+ "browser-cookie3>=0.20",
34
+ "playwright>=1.58.0",
33
35
  ]
34
36
 
35
37
  [project.optional-dependencies]
@@ -0,0 +1,116 @@
1
+ """auth login — 导入闲鱼登录态到 ~/.goofish-cli/cookies.json。
2
+
3
+ 默认行为(零参数):从本机所有已装浏览器中自动探测 → 最低认知负荷。
4
+ 支持的浏览器:Chrome / Edge / Brave / Chromium / Opera / OperaGX / Vivaldi
5
+ / Arc / Firefox / LibreWolf / Safari(由 browser_cookie3 提供)。
6
+
7
+ 降级路径:
8
+
9
+ - `auth login` 自动 auto-detect(推荐)
10
+ - `auth login --browser edge` 指定单个浏览器
11
+ - `auth login <path>` 从 JSON 文件导入
12
+ - `auth login <cookie_str> --raw` 粘贴 "k=v; k=v" 字符串
13
+
14
+ 触发条件:
15
+ 1. 所有浏览器都没拿到 → 报错里列出手动兜底方案
16
+ 2. 环境变量 GOOFISH_NO_CHROME_BOOTSTRAP=1 只影响其它命令启动时的
17
+ Session.load 自动 bootstrap,不影响本命令。
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from pathlib import Path
23
+
24
+ from goofish_cli.core import Strategy, command
25
+ from goofish_cli.core.errors import AuthRequiredError
26
+ from goofish_cli.core.session import DEFAULT_COOKIE_PATH, write_cookies_json
27
+
28
+
29
+ @command(
30
+ namespace="auth",
31
+ name="login",
32
+ description="导入登录态(默认从本机浏览器 auto-detect;支持 Chrome/Edge/Brave/Safari/Firefox 等)",
33
+ strategy=Strategy.PUBLIC,
34
+ columns=["source", "path", "unb", "tracknick", "cookies_count"],
35
+ )
36
+ def login(
37
+ source: str | None = None,
38
+ *,
39
+ raw: bool = False,
40
+ browser: str = "auto",
41
+ ) -> dict[str, object]:
42
+ target = DEFAULT_COOKIE_PATH
43
+
44
+ if source is None:
45
+ if raw:
46
+ # --raw 要求紧跟 cookie 字符串;如果没传 source,说明用户漏了参数——
47
+ # 不能静默走浏览器 auto-detect,否则 --raw 被吞,用户会困惑。
48
+ raise ValueError(
49
+ "--raw 需要配合 cookie 字符串使用,如:"
50
+ "goofish auth login 'unb=...; _m_h5_tk=...' --raw"
51
+ )
52
+ cookies, source_label = _pull_from_browser(browser)
53
+ elif raw:
54
+ cookies = _parse_raw(source)
55
+ source_label = "raw"
56
+ else:
57
+ p = Path(source).expanduser()
58
+ cookies = _parse_json(p.read_text())
59
+ source_label = f"file:{p}"
60
+
61
+ if "unb" not in cookies or "_m_h5_tk" not in cookies:
62
+ raise AuthRequiredError(
63
+ "cookie 缺失关键字段 unb / _m_h5_tk。"
64
+ "请先在浏览器里登录 https://www.goofish.com 再试。"
65
+ )
66
+
67
+ write_cookies_json(target, cookies)
68
+
69
+ return {
70
+ "source": source_label,
71
+ "path": str(target),
72
+ "unb": cookies.get("unb", ""),
73
+ "tracknick": cookies.get("tracknick", ""),
74
+ "cookies_count": len(cookies),
75
+ }
76
+
77
+
78
+ def _pull_from_browser(browser: str) -> tuple[dict[str, str], str]:
79
+ from goofish_cli.core.browser_cookie import (
80
+ BrowserCookieError,
81
+ available_browsers,
82
+ extract_goofish_cookies,
83
+ )
84
+ try:
85
+ used, cookies = extract_goofish_cookies(browser=browser)
86
+ return cookies, f"browser:{used}"
87
+ except BrowserCookieError as e:
88
+ supported = ", ".join(available_browsers())
89
+ raise AuthRequiredError(
90
+ f"浏览器登录态导入失败:{e}\n"
91
+ f"兜底方案:\n"
92
+ f" 1. 确认已在任一浏览器里登录 https://www.goofish.com 后重试\n"
93
+ f" 2. 指定具体浏览器:`goofish auth login --browser edge`(支持:{supported})\n"
94
+ f" 3. 手动导出 JSON:`goofish auth login ~/Downloads/cookies.json`\n"
95
+ f" 4. 粘 cookie 字符串:`goofish auth login 'unb=...; _m_h5_tk=...' --raw`"
96
+ ) from e
97
+
98
+
99
+ def _parse_raw(raw: str) -> dict[str, str]:
100
+ out: dict[str, str] = {}
101
+ for part in raw.split(";"):
102
+ part = part.strip()
103
+ if not part or "=" not in part:
104
+ continue
105
+ k, _, v = part.partition("=")
106
+ out[k.strip()] = v.strip()
107
+ return out
108
+
109
+
110
+ def _parse_json(text: str) -> dict[str, str]:
111
+ data = json.loads(text)
112
+ if isinstance(data, list):
113
+ return {c["name"]: c["value"] for c in data if "name" in c and "value" in c}
114
+ if isinstance(data, dict):
115
+ return {str(k): str(v) for k, v in data.items()}
116
+ raise AuthRequiredError("cookie JSON 格式不识别(需 list 或 dict)")
@@ -0,0 +1,182 @@
1
+ """item view — 浏览器视角的商品详情。对标 OpenCLI `xianyu/item.js`。
2
+
3
+ `item get` 已经在 CLI 里直签调 `mtop.taobao.idle.pc.detail` v1.0,字段浅抽(5 项)。
4
+ `item view` 在真实 Chrome 的商品页上下文里调同一 API(走 `window.lib.mtop.request`),
5
+ 好处:
6
+
7
+ 1. **字段完整**:直接按 OpenCLI 的提取器从 `data.itemDO / data.sellerDO / itemLabelExtList`
8
+ 抽 20+ 字段(description / want_count / browse_count / 成色 / 品牌 / image_urls /
9
+ seller_score / reply_ratio_24h 等)。
10
+ 2. **抗风控兜底**:CLI 直签偶尔遇到 x5sec / h5_token 失效,浏览器路径作为备份链路。
11
+
12
+ 保留 `item get`(快且不需要 Chrome),两条路并存。用户根据场景选。
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import asyncio
17
+ import json
18
+ import re
19
+ from typing import Any
20
+
21
+ from goofish_cli.core import Strategy, command
22
+ from goofish_cli.core.browser import goofish_page
23
+ from goofish_cli.core.errors import AuthRequiredError, GoofishError
24
+
25
+
26
+ def _normalize_item_id(value: Any) -> str:
27
+ s = str(value or "").strip()
28
+ if not re.fullmatch(r"\d+", s):
29
+ raise GoofishError(f"item_id 必须是数字,收到:{value!r}")
30
+ return s
31
+
32
+
33
+ def _build_item_url(item_id: str) -> str:
34
+ return f"https://www.goofish.com/item?id={item_id}"
35
+
36
+
37
+ # 页面上下文里运行的抽取脚本。直接复用 OpenCLI 的提取逻辑(DOM 文案检查 + mtop 调用)。
38
+ _FETCH_JS = r"""
39
+ (itemId) => (async () => {
40
+ const clean = (v) => String(v ?? '').replace(/\s+/g, ' ').trim();
41
+ const extractRetCode = (ret) => {
42
+ const first = Array.isArray(ret) ? ret[0] : '';
43
+ return clean(first).split('::')[0] || '';
44
+ };
45
+ const waitFor = async (predicate, timeoutMs = 5000) => {
46
+ const start = Date.now();
47
+ while (Date.now() - start < timeoutMs) {
48
+ if (predicate()) return true;
49
+ await new Promise((r) => setTimeout(r, 150));
50
+ }
51
+ return false;
52
+ };
53
+
54
+ const bodyText = document.body?.innerText || '';
55
+ // 注意:「登录后」在页脚也会出现,不能作为 auth 判定;真正的未登录靠 mtop 返回的
56
+ // FAIL_SYS_SESSION_EXPIRED 来判,这里只拦显式验证码/风控页。
57
+ if (/验证码|安全验证|异常访问/.test(bodyText)) return { error: 'blocked' };
58
+
59
+ await waitFor(() => window.lib?.mtop?.request);
60
+ if (!window.lib || !window.lib.mtop || typeof window.lib.mtop.request !== 'function') {
61
+ return { error: 'mtop-not-ready' };
62
+ }
63
+
64
+ let response;
65
+ try {
66
+ response = await window.lib.mtop.request({
67
+ api: 'mtop.taobao.idle.pc.detail',
68
+ data: { itemId: String(itemId) },
69
+ type: 'POST',
70
+ v: '1.0',
71
+ dataType: 'json',
72
+ needLogin: false,
73
+ needLoginPC: false,
74
+ sessionOption: 'AutoLoginOnly',
75
+ ecode: 0,
76
+ });
77
+ } catch (error) {
78
+ const ret = error?.ret || [];
79
+ return {
80
+ error: 'mtop-request-failed',
81
+ error_code: extractRetCode(ret),
82
+ error_message: clean(Array.isArray(ret) ? ret.join(' | ') : error?.message || String(error)),
83
+ };
84
+ }
85
+
86
+ const retCode = extractRetCode(response?.ret || []);
87
+ if (retCode && retCode !== 'SUCCESS') {
88
+ return {
89
+ error: 'mtop-response-error',
90
+ error_code: retCode,
91
+ error_message: clean((response?.ret || []).join(' | ')),
92
+ };
93
+ }
94
+
95
+ const data = response?.data || {};
96
+ const item = data.itemDO || {};
97
+ const seller = data.sellerDO || {};
98
+ const labels = Array.isArray(item.itemLabelExtList) ? item.itemLabelExtList : [];
99
+ const findLabel = (name) => labels.find((l) => clean(l.propertyText) === name)?.text || '';
100
+ const images = Array.isArray(item.imageInfos)
101
+ ? item.imageInfos.map((e) => e?.url).filter(Boolean)
102
+ : [];
103
+
104
+ return {
105
+ item_id: clean(item.itemId || itemId),
106
+ title: clean(item.title || ''),
107
+ description: clean(item.desc || ''),
108
+ price: clean('¥' + (item.soldPrice || item.defaultPrice || '')).replace(/^¥\s*$/, ''),
109
+ original_price: clean(item.originalPrice || ''),
110
+ want_count: String(item.wantCnt ?? ''),
111
+ collect_count: String(item.collectCnt ?? ''),
112
+ browse_count: String(item.browseCnt ?? ''),
113
+ status: clean(item.itemStatusStr || ''),
114
+ condition: clean(findLabel('成色')),
115
+ brand: clean(findLabel('品牌')),
116
+ category: clean(findLabel('分类')),
117
+ location: clean(seller.publishCity || seller.city || ''),
118
+ seller_name: clean(seller.nick || seller.uniqueName || ''),
119
+ seller_id: String(seller.sellerId || ''),
120
+ seller_score: clean(seller.xianyuSummary || ''),
121
+ reply_ratio_24h: clean(seller.replyRatio24h || ''),
122
+ reply_interval: clean(seller.replyInterval || ''),
123
+ seller_url: seller.sellerId ? 'https://www.goofish.com/personal?userId=' + seller.sellerId : '',
124
+ image_count: String(images.length),
125
+ image_urls: images,
126
+ };
127
+ })()
128
+ """
129
+
130
+
131
+ async def _run(item_id: str) -> dict[str, Any]:
132
+ url = _build_item_url(item_id)
133
+ async with goofish_page() as page:
134
+ await page.goto(url, wait_until="domcontentloaded")
135
+ await page.wait_for_timeout(2000)
136
+ result = await page.evaluate(_FETCH_JS, item_id)
137
+
138
+ if not isinstance(result, dict):
139
+ raise GoofishError("商品详情页返回结构非预期")
140
+
141
+ err = result.get("error")
142
+ if err == "blocked":
143
+ raise GoofishError("商品详情页被验证码/安全验证拦截,触发风控")
144
+ if err == "mtop-not-ready":
145
+ raise GoofishError("页面 window.lib.mtop 未就绪(等待超时),页面加载异常")
146
+
147
+ # auth 判定统一走 mtop 返回的 error_code/error_message 里的 SESSION_EXPIRED,
148
+ # 不再依赖 body 文案正则("登录后" 在页脚也会出现,误报多)。
149
+ err_code = str(result.get("error_code") or "")
150
+ err_msg = str(result.get("error_message") or "")
151
+ if re.search(r"FAIL_SYS_SESSION_EXPIRED|SESSION_EXPIRED", err_code + " " + err_msg):
152
+ raise AuthRequiredError("mtop 返回 session 过期:" + (err_msg or err_code))
153
+ if err:
154
+ raise GoofishError(f"mtop 调用失败 [{err_code or err}]: {err_msg or '无详情'}")
155
+ if not result.get("title"):
156
+ raise GoofishError(f"未拿到商品 {item_id} 的标题,接口返回为空")
157
+
158
+ return result
159
+
160
+
161
+ @command(
162
+ namespace="item",
163
+ name="view",
164
+ description="浏览器视角查看商品详情(字段比 item get 更全,抗风控)",
165
+ strategy=Strategy.COOKIE,
166
+ columns=[
167
+ "item_id", "title", "price", "condition", "brand", "location",
168
+ "seller_name", "want_count", "browse_count",
169
+ ],
170
+ )
171
+ def view(item_id: str) -> dict[str, Any]:
172
+ normalized = _normalize_item_id(item_id)
173
+ # MCP / table 渲染依赖 dict;image_urls 是列表(JSON 里保留,table 里压成长度)
174
+ result = asyncio.run(_run(normalized))
175
+ result["_image_urls_json"] = json.dumps(result.get("image_urls", []), ensure_ascii=False)
176
+ return result
177
+
178
+
179
+ __test__ = {
180
+ "_normalize_item_id": _normalize_item_id,
181
+ "_build_item_url": _build_item_url,
182
+ }
@@ -0,0 +1,110 @@
1
+ """message list-chats — 拉取会话列表(左栏)。
2
+
3
+ h5 接口 `mtop.taobao.idlemessage.pc.session.sync` v3.0 是阉割版,只返回活跃
4
+ Top N 会话;网页左栏看到的完整列表其实是靠 ACCS 长连累积的,单次 HTTP 拿不到。
5
+ 所以提供 `--watch-secs N` 可选开关:短时连 WS + `ackDiff(pts=0)` 拉历史推送,
6
+ 从中抽取会话激活事件 + new_msg 通知里的 cid,补齐 baseline 漏掉的会话。
7
+
8
+ 数据来源两路合并:
9
+
10
+ 1. `mtop.taobao.idlemessage.pc.session.sync` v3.0 —— baseline,字段齐全
11
+ (peer_nick / peer_user_id / unread / last_msg / ts / session_type / item_id)。
12
+ 2. `--watch-secs N`(可选)—— watch,只有 cid 骨架
13
+ (session_id / session_type / item_id / ts),`peer_nick` / `peer_user_id` /
14
+ `last_msg` / `unread` 都填空值。要正文请自己调 `message history <cid>`。
15
+
16
+ 输出 record 带 `source` 字段区分 `baseline` 和 `watch`,shape 一致,方便调用方统一处理。
17
+ """
18
+
19
+ import asyncio
20
+ from typing import Any
21
+
22
+ from goofish_cli.core import Session, Strategy, command
23
+ from goofish_cli.core.mtop import call
24
+
25
+
26
+ def _pick(d: dict[str, Any], *path: str, default: Any = "") -> Any:
27
+ cur: Any = d
28
+ for key in path:
29
+ if not isinstance(cur, dict):
30
+ return default
31
+ cur = cur.get(key)
32
+ if cur is None:
33
+ return default
34
+ return cur
35
+
36
+
37
+ def _parse_session(item: dict[str, Any]) -> dict[str, Any]:
38
+ session = item.get("session") or {}
39
+ user_info = session.get("userInfo") or {}
40
+ summary = _pick(item, "message", "summary", default={}) or {}
41
+ return {
42
+ "session_id": str(session.get("sessionId", "")),
43
+ "peer_nick": user_info.get("nick", "") or user_info.get("fishNick", ""),
44
+ "peer_user_id": str(user_info.get("userId", "")),
45
+ "unread": summary.get("unread", 0),
46
+ "last_msg": summary.get("summary", ""),
47
+ "ts": summary.get("ts", 0),
48
+ "session_type": session.get("sessionType", 0),
49
+ "item_id": "",
50
+ "source": "baseline",
51
+ }
52
+
53
+
54
+ def _watch_record(w: dict[str, Any]) -> dict[str, Any]:
55
+ """把 WS 收集到的裸 cid 包成跟 baseline 同形状的 record。"""
56
+ ts_raw = w.get("last_msg_ts") or 0
57
+ try:
58
+ ts = int(ts_raw)
59
+ except (TypeError, ValueError):
60
+ ts = 0
61
+ return {
62
+ "session_id": str(w["cid"]),
63
+ "peer_nick": "",
64
+ "peer_user_id": str(w.get("peer_user_id", "")),
65
+ "unread": 0,
66
+ "last_msg": "",
67
+ "ts": ts,
68
+ "session_type": int(w.get("session_type") or 0),
69
+ "item_id": str(w.get("item_id", "")),
70
+ "source": "watch",
71
+ }
72
+
73
+
74
+ @command(
75
+ namespace="message",
76
+ name="list-chats",
77
+ description="拉取会话列表(左栏):session.sync 基线 + 可选 WS 增量补 cid",
78
+ strategy=Strategy.COOKIE,
79
+ columns=[
80
+ "session_id", "peer_nick", "peer_user_id",
81
+ "unread", "last_msg", "ts", "source",
82
+ ],
83
+ )
84
+ def list_chats(fetch_num: int = 50, watch_secs: float = 0.0) -> dict[str, Any]:
85
+ session = Session.load()
86
+ raw = call(
87
+ session,
88
+ api="mtop.taobao.idlemessage.pc.session.sync",
89
+ data={"fetchNum": int(fetch_num)},
90
+ version="3.0",
91
+ spm_cnt="a21ybx.im.0.0",
92
+ )
93
+ data = raw.get("data") or {}
94
+ baseline = [_parse_session(s) for s in data.get("sessions") or []]
95
+ known = {b["session_id"] for b in baseline}
96
+
97
+ extras: list[dict[str, Any]] = []
98
+ if watch_secs > 0:
99
+ from goofish_cli.core.ws import collect_session_cids
100
+
101
+ pushed = asyncio.run(collect_session_cids(session, duration=float(watch_secs)))
102
+ extras = [_watch_record(w) for w in pushed if str(w["cid"]) not in known]
103
+
104
+ return {
105
+ "sessions": baseline + extras,
106
+ "has_more": bool(data.get("hasMore")),
107
+ "total": len(baseline) + len(extras),
108
+ "from_baseline": len(baseline),
109
+ "from_watch": len(extras),
110
+ }