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.
- goofish_cli-0.2.0/CHANGELOG.md +61 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/PKG-INFO +11 -4
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/README.md +8 -3
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/pyproject.toml +3 -1
- goofish_cli-0.2.0/src/goofish_cli/commands/auth/login.py +116 -0
- goofish_cli-0.2.0/src/goofish_cli/commands/item/view.py +182 -0
- goofish_cli-0.2.0/src/goofish_cli/commands/message/list_chats.py +110 -0
- goofish_cli-0.2.0/src/goofish_cli/commands/search/search.py +169 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/__init__.py +4 -0
- goofish_cli-0.2.0/src/goofish_cli/core/browser.py +147 -0
- goofish_cli-0.2.0/src/goofish_cli/core/browser_cookie.py +262 -0
- goofish_cli-0.2.0/src/goofish_cli/core/errors.py +51 -0
- goofish_cli-0.2.0/src/goofish_cli/core/session.py +151 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/ws.py +107 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/mcp_server.py +13 -2
- goofish_cli-0.2.0/tests/__init__.py +0 -0
- goofish_cli-0.2.0/tests/test_auth_login.py +74 -0
- goofish_cli-0.2.0/tests/test_browser_cookie.py +164 -0
- goofish_cli-0.2.0/tests/test_item_view.py +24 -0
- goofish_cli-0.2.0/tests/test_list_chats.py +106 -0
- goofish_cli-0.2.0/tests/test_search.py +36 -0
- goofish_cli-0.2.0/tests/test_session_bootstrap.py +137 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/uv.lock +219 -1
- goofish_cli-0.1.0/CHANGELOG.md +0 -32
- goofish_cli-0.1.0/src/goofish_cli/commands/auth/login.py +0 -66
- goofish_cli-0.1.0/src/goofish_cli/core/errors.py +0 -31
- goofish_cli-0.1.0/src/goofish_cli/core/session.py +0 -88
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.env.example +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/pull_request_template.md +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/workflows/ci.yml +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.github/workflows/publish.yml +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/.gitignore +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/CONTRIBUTING.md +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/LICENSE +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/NOTICE +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/docs/architecture.md +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/docs/compliance.md +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/docs/mcp-setup.md +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/examples/README.md +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/examples/publish_from_folder.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/examples/watch_to_autoreply.sh +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/__init__.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/cli.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/__init__.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/auth/__init__.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/auth/reset_guard.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/auth/status.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/category/__init__.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/category/recommend.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/item/__init__.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/item/delete.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/item/get.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/item/publish.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/location/__init__.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/location/default.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/media/__init__.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/media/upload.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/message/__init__.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/message/history.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/message/send.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/commands/message/watch.py +0 -0
- {goofish_cli-0.1.0/tests → goofish_cli-0.2.0/src/goofish_cli/commands/search}/__init__.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/guard.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/limiter.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/mtop.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/output.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/registry.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/sign.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/strategy.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/core/token.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/src/goofish_cli/static/goofish_js_version_2.js +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_guard.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_limiter.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_mtop.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_publish_payload.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_registry.py +0 -0
- {goofish_cli-0.1.0 → goofish_cli-0.2.0}/tests/test_sign.py +0 -0
- {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.
|
|
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
|
-
- 🔐 **
|
|
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
|
-
| 单元测试 |
|
|
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 #
|
|
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
|
-
- 🔐 **
|
|
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
|
-
| 单元测试 |
|
|
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 #
|
|
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.
|
|
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
|
+
}
|