Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed
- 会话列表默认启用 5 秒 WS 发现,补齐最近消息并按活动时间倒序;
缺失时间和未知未读数返回 null,列表完整性与最近消息上下文状态分别说明。
- 历史消息保留 CID、消息 ID 和创建时间;支持 limit 限定最近条数及 timeout,
按服务端消息 ID 去重,缺少 ID 的记录不按正文或时间推断为重复。

### Fixed
- 多图发布仅最终提交计一次商品预算,媒体上传独立计数;已上传收据、已确认类目和地址可复用,失败保留准备信息与提交状态。
- 本机限流和熔断按账号持久化,跨进程事务加锁、原子保存,损坏状态拒绝写入;风控保存失败保留原始错误。
- 搜索从真实页面响应读取语义字段,保留未知标签,区分昵称、地域与原价;无命中时排除推荐卡片,分页响应绑定查询与页码,部分失败在 CLI/MCP 明确报告。
- 商品详情从 itemDO/sellerDO 提取,保留价格和状态 0;类目分数对应选中分类,发布地址使用 selectedPoi。
- 单聊结合 userInfo 与 ownerInfo 及当前账号确认对端,避免买卖家角色对换时
选到自己;WS 会话的角色信息与消息参加者共同用于身份确认。
- MCP 输出 schema 与统一的 ok/data 或错误对象一致,避免历史工具返回 list
的声明与适配器实际对象冲突。
- 消息读取在超时、连接失败或分页游标不推进时明确报告失败;列表保留已有
结果并标注未完成的摘要,取消时清理连接和心跳。

## [0.4.0] - 2026-09-07

### Added
Expand Down
48 changes: 30 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
- 🔐 **17 个命令覆盖核心链路**:发布、下架、查询、图片上传、AI 类目识别、默认地址、IM 收发 + 会话列表、skills 安装
- 📡 **真·实时 IM**:WebSocket 长连 + 自动重连 + **三类事件分类输出**
- `event=message`(收到消息)· `event=read`(已读回执)· `event=new_msg`(轻量通知)
- 🛡 **内置风控护栏**:令牌桶限流(1 写/分钟)+ RGV587 自动熔断
- 🛡 **内置风控护栏**:账号与业务桶限流(经营 1 次/分钟、媒体 9 次/分钟)+ RGV587 自动熔断
- 🧠 **AI-first I/O**:`--format json/yaml/table/md/csv`,给 LLM 喂 JSON、给人看表格
- ⚡ **一次定义,三种入口**:CLI / MCP / Skill 共享同一 registry
- ✅ **真实端到端验证**:每个命令都跑过真实账号
Expand Down Expand Up @@ -180,7 +180,7 @@ $ goofish list-commands --format table
| `media upload` | 上传图片到闲鱼 CDN | ✅ |
| `category recommend` | AI 识别商品类目 | ❌ |
| `location default` | 获取默认发布地址 | ❌ |
| `message list-chats` | 拉取会话列表(左栏;`--watch-secs N` 叠加 WS 历史推送补漏) | ❌ |
| `message list-chats` | 会话列表(默认 HTTP + 短时 WS,补最近消息并按活动时间倒序) | ❌ |
| `search items` | 搜索闲鱼商品(浏览器路径 Playwright + 系统 Chrome) | ❌ |
| `item view` | 浏览器视角看商品详情(字段完整,抗风控;`item get` 的姊妹版) | ❌ |
| `message history` | 拉取会话历史消息 | ❌ |
Expand All @@ -189,6 +189,22 @@ $ goofish list-commands --format table

</details>

会话读取使用真实消息时间,而非会话创建时间:

```bash
goofish message list-chats --format json
goofish message history <cid> --limit 20 --timeout 30 --format json
```

`list-chats` 默认 `--watch-secs 5`,在一个已就绪的连接中补齐真人会话的最近消息页;
`--watch-secs 0` 关闭 WS 会话发现,结果可能遗漏 HTTP 未返回的会话。
`--timeout` 限制摘要读取阶段。历史输出保留 `cid`、`message_id` 和毫秒时间 `created_at`,
`--limit 0` 保留完整翻页行为,其他正数只读最近 N 条。

`metadata_status` 标明摘要是当前、为空、不完整或读取失败;无法确定的时间为 `null`。
`has_more` 只代表 HTTP 分页,`enumeration_complete=false` 表明短时同步不能证明全部历史会话已覆盖。
接口契约与异常边界见 [会话同步](docs/conversation-sync.md)。

<details>
<summary><b><code>goofish auth status</code></b> — 登录态健康检查</summary>

Expand Down Expand Up @@ -245,25 +261,21 @@ $ goofish message send <masked-cid> <masked-user-id> \
<summary><b><code>goofish item publish</code></b> — 发布商品(含风控护栏)</summary>

```bash
$ goofish item publish \
--title "男士毛呢大衣 驼色长款" \
--desc "全新未拆封 原价 2999 现 999" \
--images ./a.png,./b.png \
--price 999
goofish item publish "男士毛呢大衣 驼色长款" \
"全新未拆封 原价 2999 现 999" ./a.png ./b.png 999 --format json
```

流程:
1. `media upload` 每张图 → CDN URL + 尺寸
2. `category recommend` 拿 AI 识别的 catId
3. `location default` 拿默认地址
4. `mtop.idle.pc.idleitem.publish` 落库
流程:上传图片 → 推荐类目 → 默认地址 → 提交。已有上传收据可通过
`--images-json '[{"url":"https://example.alicdn.com/image.png","width":1024,"height":1024}]'`
复用,省略本地图片参数;已确认类目和地址分别通过 `--category-json`、`--location-json`
传入对应工具返回的完整 DTO。

返回:
```json
{"ok": true, "itemId": "1046118265141", "status": "published"}
```
返回 `item_id`、`status="accepted"` 和 `requires_readback=true`;使用
`goofish item get <item_id>` 和自己的商品列表确认保存。失败也保留已上传图片和准备信息;
`submission_unknown` 必须先核对商品列表,不自动重复发布。

**触发令牌桶限流**(1 写/分钟)。高频调用会被本地拒绝,避免被闲鱼风控。
发布与下架共用账号的 `item.write` 预算,默认 1 次/分钟;媒体上传独立计数,默认
9 次/分钟。一个多图商品只消耗一次商品预算。
</details>

---
Expand Down Expand Up @@ -297,7 +309,7 @@ Claude 会自动把全部命令看成 tool:`goofish_item_get` / `goofish_item_
| WebSocket 批量 push 全量解码 | 一帧多条消息全部还原,不丢单 |
| WebSocket 自动重连 | 断线自退避重连,长跑无感知 |
| 已读回执 / typing / 新消息通知分类 | `/s/sync` 元事件结构化为三类 JSONL |
| 全局限流 + 风控熔断 | 令牌桶 1 写/分钟 + RGV587 自动熔断 |
| 全局限流 + 风控熔断 | 账号业务预算 + RGV587 自动熔断 |
| 单元测试 | 33 个,ruff 零告警 |
| 包分发 | `pip install goofish-cli` / `uvx goofish-cli` |

Expand Down
21 changes: 13 additions & 8 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
│ core/sign.py pyexecjs → goofish_js_version_2.js │
│ core/session.py cookie 加载 + requests.Session │
│ core/mtop.py 统一 mtop 调用 + 错误分类 │
│ core/limiter.py 令牌桶限流(1 写/分钟,可配) │
│ core/limiter.py 账号业务桶限流(经营/媒体分开) │
│ core/guard.py 风控熔断(RGV587 → trip) │
│ core/output.py 统一渲染 json/yaml/table/md/csv │
│ core/errors.py 异常体系 + exit_code │
Expand Down Expand Up @@ -69,21 +69,26 @@ def get(item_id: str) -> dict:
- **namespace + name** → CLI 路径 `goofish item get`,MCP tool 名 `item_get`
- **columns** → 输出契约(table/csv/md 场景的列顺序)
- **strategy** → 认证要求(PUBLIC / COOKIE / WS)
- **write=True** → 自动触发限流 + 风控熔断
- **write=True** → 写操作元数据;实际写端点必须进入 `write_operation(session, bucket)`

## 风控护栏

1. **令牌桶**(`core/limiter.py`):默认 1 写/分钟,持久化在 `~/.goofish-cli/limiter.json`
2. **熔断**(`core/guard.py`):`watch()` 上下文内命中 `RiskControlError` → 写 `circuit.json`,默认熔断 10 分钟
3. **响应体识别**(`core/mtop.py`):自动扫 `RGV587_ERROR / punish / FAIL_SYS_USER_VALIDATE` 等关键字
1. **滑动窗口预算**(`core/limiter.py`):按账号和业务桶计数。发布与下架共用 `item.write`,消息用 `message.write`,默认各 1 次/分钟;上传用独立 `media.write`,默认 9 次/分钟。经营预算通过 `GOOFISH_WRITE_RPM` 配置,媒体通过 `GOOFISH_MEDIA_WRITE_RPM` 配置。这是本机预算,不代表平台额度。
2. **熔断**(`core/guard.py`):所有写端点先检查账号熔断;命中 `RiskControlError` 后持久化,默认 10 分钟。旧版共享熔断和预算须自然过期。保存失败保留原始风控错误并提示停止写入。
3. **持久化**(`core/local_state.py`):稳定锁文件覆盖读、检查和原子替换事务;跨 CLI 进程和 MCP 线程共享。损坏状态拒绝写入,不静默清零。可通过 `GOOFISH_LIMITER_PATH`、`GOOFISH_GUARD_PATH` 选择隔离状态路径。
4. **响应识别**:MTop 与上传端点根据真实响应确认成功或拒绝。失败尝试不退预算;结果未知时先回读,不自动重发。

这些护栏在 **底层强制**,Agent 层无法绕过。
发布可接受本地图片,也可通过 `images_json` 复用 `media_upload` 的完整收据;`category_json` 和 `location_json` 接收相应工具返回的已确认 DTO。准备或提交失败保留收据与准备信息,分别报告 `not_submitted`、`submission_rejected` 或 `submission_unknown`。返回商品 ID 代表 `accepted`,详情与商品列表回读负责确认实际保存。

搜索观察网页实际发出的搜索响应,按 `keyword/pageNumber` 与请求发起代次关联;分页前清除上一页状态,忽略迟到响应。只有平台控制字段确认的查询结果进入 `items`;无命中时网页的推荐卡片单独识别。未标明语义的标签保留在 `labels`,品牌、成色等未知属性返回 null,卖家昵称与地域分开。翻页失败保留部分结果,CLI 非零退出,MCP 返回 `ok=false` 和数据及错误。

商品详情使用 `itemDO/sellerDO`,不使用埋点推测价格和状态;价格 0 和状态码 0 保留,`defaultPrice` 布尔值只表达议价类型。分类模型分数只从选中分类或分类卡的复合身份获取,不把其他属性分数当置信度,也不把缺失分数当 0。

## 实现要点

- **`t` 毫秒位**:`int(time.time() * 1000)` 取真实毫秒(避免 `int(time.time()) * 1000` 把末三位抹成 000 的精度陷阱)
- **默认地址**:`commonAddresses[0]` 兜底,参数化接口支持显式指定 `addressId`
- **默认地址**:优先 `selectedPoi`,缺失时使用第一个常用地址;发布使用返回的 `selected` DTO,显式地址通过 `location_json` 提供。
- **风控识别**:扫描响应体关键字(`RGV587_ERROR` / `punish` / `FAIL_SYS_USER_VALIDATE`),命中即熔断
- **限流**:令牌桶持久化在 `~/.goofish-cli/limiter.json`
- **限流**:滑动窗口预算持久化在 `~/.goofish-cli/limiter.json`
- **形态**:CLI + MCP(+ Skill 规划),同一份 registry 输出三种形态
- **命令组织**:每命令一个文件,`@command(...)` 装饰器自注册,参照 opencli
38 changes: 38 additions & 0 deletions docs/conversation-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# 会话同步契约

CLI 与 MCP 消费同一注册函数。MCP 适配器返回 `{ok, data}` 或 `{ok, error_type, message}`,输出 schema 声明该包裹对象,业务函数内部返回 list 时也遵守同一契约。`message list-chats` 以 HTTP 会话摘要作为基线,默认收集 5 秒 WS 会话发现,再用一个连接逐个读取真人和未分类会话的最近消息页。已分类系统频道保留其服务端摘要,不调用真人历史接口。

## 身份与活动

- `session_id` 和历史中的 `cid` 去除 `@goofish` 后缀,作为合并与去重标识。
- 最近活动取历史消息的 `createAt`,保留为毫秒时间 `created_at` / `ts`。会话创建时间不能替代最近消息时间;未知时间为 `null`,排在已知时间之后。
- HTTP 单聊同时检查 userInfo 与 ownerInfo,排除当前账号,只有一个候选时选为对端;不能假定 userInfo 永远是对方。
- WS 会话角色须包含当前账号,且只有一个非当前账号成员,才能据此确定对端。同时核对 HTTP 的对端字段;角色与 HTTP 候选冲突时不赋值。缺少两路身份时再使用消息发送者/接收者,仍有多个候选时不赋值。
- 商品的 `itemSellerId`、`squadName` 和消息正文不能当作可靠对端昵称。消息中的发送者标签只用于对应的发送者,不能把自己的昵称赋给对端。
- 同一 CID 合并 HTTP 与 WS 信息,最近消息 ID 和预览来自新读取的消息页,按最近活动倒序输出。

## 有界读取与结果状态

`message history <cid> --limit N` 只读取最近 N 条,按时间正序返回;`limit=0` 完整翻页。输出保留消息 ID、CID 和时间,跨页以消息 ID 去重。游标不推进、响应结构无效或服务端拒绝均报错,不作为空历史成功返回。

摘要读取复用一个已鉴权和就绪的 WS 连接,每次请求按 mid 匹配响应,只有一个接收者。超时取消会关闭连接并等待心跳任务退出。历史整体 WS 阶段和列表摘要阶段各受 `--timeout` 约束;HTTP 请求另有基础客户端超时,WS 发现受 `watch_secs + 15` 秒约束。

列表保留成功的摘要,逐会话暴露读取失败:

| 字段 | 含义 |
|---|---|
| `metadata_status` | `current` 最近消息已读取;`partial` 身份、时间或正文未齐;`empty` 当前历史为空;`stale` 摘要读取失败;`http` / `system` 系统摘要 |
| `metadata_missing` | 未能确定的字段,不以默认零值伪装 |
| `metadata_errors` | 摘要或发现阶段的失败及对应会话 |
| `metadata_complete` | 本次真人和未分类会话的最近消息与对端上下文是否完整;不包含未读计数或历史枚举完整性 |
| `unknown_activity_count` | 没有可确认活动时间的条目数 |
| `has_more_scope` | `http`,has_more 仅对应 HTTP 分页 |
| `enumeration_complete` | false;HTTP 和短时 WS 发现不保证覆盖全部历史会话 |

`--watch-secs 0` 显式关闭 WS 发现,不保证 HTTP 未返回的会话能被列出。WS 新发现会话的 unread 仍可能为 null;即使最近消息与对端上下文完整,也不能据此推导未读数。模型调用方应依据字段状态决定是否继续,而非仅依据退出码判断上下文完整。

## 维护与验证

消息页读取与解析集中在 `core/message_history.py`;连接、握手、ACK 和会话发现由 `core/ws.py` 提供。列表层只承担会话合并、身份选择、摘要构建与排序,不实现另一套协议接收循环。

修改后按 CONTRIBUTING.md 运行现有检查,并使用同一真实会话验证 CLI 默认列表、最近/完整历史和 MCP stdio 调用。对照原始 createAt、消息 ID、正文及当前账号身份;同一会话不得重复,系统会话不能误当真人,缺失或超时须明确暴露。可在隔离副本禁用 WS、摘要补齐或排序,验证各步骤对真实结果的独立贡献。
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ dependencies = [
"browser-cookie3>=0.20",
"playwright>=1.58.0",
"cryptography>=41.0",
"filelock>=3.16,<4",
]

[project.optional-dependencies]
Expand Down
3 changes: 1 addition & 2 deletions skills/goofish-overview/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ metadata:
Agent 在 goofish 任务里请遵守:

1. **任何写操作前先读 `auth_status`**,避免 token 已过期却继续写 → 白忙一场。
2. **`item_publish / media_upload / message_send` 都有速率限制**(令牌桶
1 写/分钟),不要短时间连发。RGV587 触发后需用户手动 `goofish auth reset-guard`。
2. **`item_publish / media_upload / message_send` 都有速率限制**(账号业务预算),不要短时间连发。RGV587 触发后需用户手动 `goofish auth reset-guard`。
3. **所有对外发送(发商品、发消息)务必让用户先确认文案**,不要自动提交。
4. **多步任务中途报状态**:上一步完成了什么、下一步准备做什么。闲鱼任务
通常是 3-6 步,用户期望有进度感。
23 changes: 13 additions & 10 deletions skills/goofish-overview/references/mcp-tools-index.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,17 @@
| `item_get` | HTTP 视角拉详情(只读) | `item_id` | 无 |
| `item_list` | 查看当前账号的在售商品 | `limit?` | 无 |
| `item_view` | 浏览器视角拉详情(字段更全、抗风控) | `item_id` | 触发 Playwright 启动 |
| `item_publish` | 发布商品(自动类目+默认地址) | `title, desc, price, image_urls, cat_id?, addr?` | **写操作**,令牌桶 1 写/分钟 |
| `item_publish` | 发布商品(自动类目+默认地址) | `title, desc, price, images?, images_json?, category_json?, location_json?` | **写操作**,item.write 默认 1 次/分钟 |
| `item_delete` | 下架/删除商品 | `item_id` | **写操作** + 风控护栏 |

**发布前强依赖**:`category_recommend` 拿 catId、`media_upload` 拿 image_urls、`location_default` 兜底 addr。
**发布前强依赖**:`category_recommend` 拿 catId、`media_upload` 拿图片收据、`location_default` 兜底 location_json。

## Message(4 个)

| 工具 | 用途 | 典型入参 | 备注 |
|---|---|---|---|
| `message_list_chats` | 会话列表(左栏) | `limit?, watch_secs?` | session.sync v3.0 是阉割版,默认会启 WebSocket 增量补齐 cid |
| `message_history` | 某 cid 的历史消息(翻页到底) | `cid` | 拉上下文做意图分类用 |
| `message_list_chats` | 按最近活动排序的会话摘要 | `fetch_num?, watch_secs?, timeout?` | 默认 5 秒 WS 发现并读取最近消息;检查 metadata_status,不能把 has_more 当全部会话完整性 |
| `message_history` | 某 cid 的历史消息 | `cid, limit_per_page?, limit?, timeout?` | limit=0 翻页到底;正数只读最近 N 条,保留 message_id/created_at |
| `message_watch` | 常驻 IM 长连接,事件以 JSONL 输出 | `secs?` | 阻塞式;Skill 里慎用,短连接更合适 |
| `message_send` | 发消息(text/image) | `cid, text` 或 `cid, image_url, w, h` | **写操作** + 外联词风控 |

Expand All @@ -41,7 +41,7 @@
| `category_recommend` | AI 识别类目(输入标题+图片返回 catId/catName) | 发布前必跑,类目错放会降权 |
| `media_upload` | 上传图片到闲鱼 CDN | 返回 `{url, width, height}`,给 `item_publish` 直接用 |
| `search_items` | 搜闲鱼商品(浏览器路径,抗风控) | 诊断限流时用"卖家视角 vs 买家视角"对比 |
| `location_default` | 账号默认发布地址 | `item_publish` 不传 addr 时的兜底 |
| `location_default` | 账号默认发布地址 | `item_publish` 不传 location_json 时的兜底 |

## Skills(1 个,辅助类)

Expand All @@ -66,12 +66,15 @@ auth_status → message_list_chats → message_history (按 cid) → [LLM 意图
auth_status → search_items (用自家核心词,买家视角) → item_view (拉自家详情) → item_get (历史元数据对比)
```

## 写操作全局节流
## 写操作预算与错误恢复

`item_publish / item_delete / media_upload / message_send` 共用令牌桶:
- 容量 1、每分钟补 1
- 短时连发会返回 `RATE_LIMITED`
- RGV587(服务端风控)触发后需用户从浏览器重新导 cookie(带 x5sec/mtop_partitioned_detect),`auth_reset_guard` 只解本地熔断
- 同账号发布和下架使用 `item.write`,消息使用 `message.write`,默认各 1 次/分钟。
- 上传使用独立 `media.write`,默认 9 次/分钟;上传收据通过 `images_json` 复用。
- CLI 进程与 MCP 线程共享加锁、原子保存的状态;损坏文件拒绝写入。
- 本机预算不代表平台额度;平台风控触发账号熔断,`auth_reset_guard` 只改变本机状态。
- 发布失败保留图片、类目、地址及提交状态;`submission_unknown` 先回读,不能自动重发。
- 搜索 `complete=false` 是未完成,CLI 非零退出,MCP `ok=false` 仍带部分 `data`。
- 搜索的品牌、成色未知时是 null;用 labels 查看原始标签,不能按顺序猜属性。空查询命中不含网页推荐。

## 错误类型映射

Expand Down
Loading
Loading