goofish-cli 0.2.2__tar.gz → 0.2.3__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.2 → goofish_cli-0.2.3}/CHANGELOG.md +20 -1
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/PKG-INFO +1 -1
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/pyproject.toml +1 -1
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/mtop.py +23 -8
- goofish_cli-0.2.3/src/goofish_cli/core/refresh.py +146 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_mtop.py +19 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_refresh.py +18 -1
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/uv.lock +1 -1
- goofish_cli-0.2.2/src/goofish_cli/core/refresh.py +0 -80
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/.env.example +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/.github/pull_request_template.md +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/.github/workflows/ci.yml +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/.github/workflows/publish.yml +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/.gitignore +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/CONTRIBUTING.md +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/LICENSE +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/NOTICE +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/README.md +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/docs/architecture.md +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/docs/compliance.md +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/docs/mcp-setup.md +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/examples/README.md +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/examples/publish_from_folder.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/examples/watch_to_autoreply.sh +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/cli.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/auth/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/auth/login.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/auth/reset_guard.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/auth/status.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/category/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/category/recommend.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/item/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/item/delete.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/item/get.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/item/publish.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/item/view.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/location/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/location/default.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/media/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/media/upload.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/message/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/message/history.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/message/list_chats.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/message/send.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/message/watch.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/search/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/commands/search/search.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/browser.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/browser_cookie.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/errors.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/guard.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/limiter.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/output.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/registry.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/session.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/sign.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/strategy.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/token.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/core/ws.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/mcp_server.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/src/goofish_cli/static/goofish_js_version_2.js +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/__init__.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_auth_login.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_browser_cookie.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_guard.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_item_view.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_limiter.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_list_chats.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_publish_payload.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_registry.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_search.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_session_bootstrap.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_sign.py +0 -0
- {goofish_cli-0.2.2 → goofish_cli-0.2.3}/tests/test_ws_codec.py +0 -0
|
@@ -6,6 +6,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.3] - 2026-04-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **自动点击 passport 弹窗"快速进入"免密登录**(`core/refresh.py::_try_quick_enter`):
|
|
13
|
+
v0.2.2 的 Playwright goto 首页在系统 Chrome 里抓出的 cookie 指纹不匹配时会弹
|
|
14
|
+
`#alibaba-login-box` 登录框,服务端拒发完整 session cookie 导致后续 mtop 报
|
|
15
|
+
`FAIL_SYS_SESSION_EXPIRED`。v0.2.3 起自动识别弹窗并点"快速进入"走浏览器免密
|
|
16
|
+
记忆登录,无需用户交互完成 session 恢复。刷新后追加访问 `/bought` 触发强鉴权页
|
|
17
|
+
下发完整 `cookie2 / sgcookie / _tb_token_`。
|
|
18
|
+
- `goofish_page()` 已经支持的 `cookies=` 注入点被 refresh 复用,调用方 session
|
|
19
|
+
和浏览器上下文的登录态保持一致(承接 v0.2.2 Copilot review 的改造)。
|
|
20
|
+
|
|
21
|
+
### Changed
|
|
22
|
+
- mtop auto-refresh 错误码匹配从"仅 token 层"扩展到**也处理 session 层失效**
|
|
23
|
+
(`FAIL_SYS_SESSION_EXPIRED`)。`_is_token_expired_error` 重命名为
|
|
24
|
+
`_is_recoverable_auth_error`,语义更清晰。`FAIL_SYS_ILLEGAL_ACCESS` 仍不在
|
|
25
|
+
可恢复列表(风控层问题,刷 cookie 救不了)。
|
|
26
|
+
|
|
9
27
|
## [0.2.2] - 2026-04-22
|
|
10
28
|
|
|
11
29
|
### Added
|
|
@@ -86,7 +104,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
86
104
|
- 本版本需要用户手动从浏览器导入 cookie(含 `unb` / `_m_h5_tk` / `x5sec`)
|
|
87
105
|
- 遇到 `RGV587_ERROR` 风控时,需在浏览器完成滑块验证并**重新导出**带 `x5sec` 的 cookie
|
|
88
106
|
|
|
89
|
-
[Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.
|
|
107
|
+
[Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.3...HEAD
|
|
108
|
+
[0.2.3]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.2...v0.2.3
|
|
90
109
|
[0.2.2]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.1...v0.2.2
|
|
91
110
|
[0.2.1]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.0...v0.2.1
|
|
92
111
|
[0.2.0]: https://github.com/fancyboi999/goofish-cli/compare/v0.1.0...v0.2.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: goofish-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.3
|
|
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
|
|
@@ -74,8 +74,10 @@ def call(
|
|
|
74
74
|
) -> dict[str, Any]:
|
|
75
75
|
"""调用 mtop 接口。返回原始 JSON。失败抛 GoofishError 子类。
|
|
76
76
|
|
|
77
|
-
`_auto_refresh=True`:遇到
|
|
78
|
-
|
|
77
|
+
`_auto_refresh=True`:遇到 token 层(`FAIL_SYS_TOKEN_EXOIRED`)或 session 层
|
|
78
|
+
(`FAIL_SYS_SESSION_EXPIRED`)失效时,自动用 Playwright 访问闲鱼首页 + 点
|
|
79
|
+
passport 弹窗的"快速进入"免密登录刷新 cookie 后重试一次。递归调用时置 False
|
|
80
|
+
避免死循环。
|
|
79
81
|
"""
|
|
80
82
|
url = f"{MTOP_HOST}/h5/{api}/{version}/"
|
|
81
83
|
t_ms = str(int(time.time() * 1000))
|
|
@@ -114,14 +116,16 @@ def call(
|
|
|
114
116
|
try:
|
|
115
117
|
_classify_error(raw, api)
|
|
116
118
|
except AuthRequiredError as e:
|
|
117
|
-
#
|
|
118
|
-
#
|
|
119
|
-
|
|
119
|
+
# token 层(_m_h5_tk 过期)和 session 层(cookie2/sgcookie 失效)都可以通过
|
|
120
|
+
# Playwright goto 闲鱼首页 → 点 passport 弹窗的"快速进入"免密记忆登录恢复。
|
|
121
|
+
# v0.2.3 起统一由 refresh_cookies_via_browser 处理;`ILLEGAL_ACCESS` 是风控
|
|
122
|
+
# 层面问题,刷 cookie 也救不了,不在可恢复列表内。
|
|
123
|
+
if not (_auto_refresh and _is_recoverable_auth_error(e)):
|
|
120
124
|
raise
|
|
121
125
|
from goofish_cli.core.refresh import is_enabled, refresh_cookies_via_browser
|
|
122
126
|
if not is_enabled():
|
|
123
127
|
raise
|
|
124
|
-
logger.info(f"[{api}]
|
|
128
|
+
logger.info(f"[{api}] 检测到登录态失效,尝试 Playwright 免密登录刷新 cookie…")
|
|
125
129
|
if not refresh_cookies_via_browser(session):
|
|
126
130
|
raise
|
|
127
131
|
# 刷新成功:重试一次,禁用递归自动刷新
|
|
@@ -134,9 +138,20 @@ def call(
|
|
|
134
138
|
return raw
|
|
135
139
|
|
|
136
140
|
|
|
137
|
-
def
|
|
141
|
+
def _is_recoverable_auth_error(e: AuthRequiredError) -> bool:
|
|
142
|
+
"""可通过 Playwright 自动刷新恢复的登录态失效错误码。
|
|
143
|
+
|
|
144
|
+
- TOKEN_EXOIRED / TOKEN_EMPTY / 令牌过期 → h5_tk 层,goto 首页即续
|
|
145
|
+
- SESSION_EXPIRED → session 层,点'快速进入'免密记忆登录恢复
|
|
146
|
+
- ILLEGAL_ACCESS → 风控层,不在此列(救不了)
|
|
147
|
+
"""
|
|
138
148
|
msg = str(e)
|
|
139
|
-
return any(kw in msg for kw in (
|
|
149
|
+
return any(kw in msg for kw in (
|
|
150
|
+
"FAIL_SYS_TOKEN_EXOIRED",
|
|
151
|
+
"FAIL_SYS_TOKEN_EMPTY",
|
|
152
|
+
"FAIL_SYS_SESSION_EXPIRED",
|
|
153
|
+
"令牌过期",
|
|
154
|
+
))
|
|
140
155
|
|
|
141
156
|
|
|
142
157
|
def _classify_error(raw: dict[str, Any], api: str) -> None:
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""遇 token/session 层失效时用 Playwright 自动刷新 cookie。
|
|
2
|
+
|
|
3
|
+
`_m_h5_tk` 只有 10 分钟有效期,服务端在浏览器**活跃访问**闲鱼页面时通过
|
|
4
|
+
`Set-Cookie` 续期;浏览器静默几分钟就会过期。mcp/cli 用 `browser_cookie3`
|
|
5
|
+
从磁盘抓快照,命中"抓的时候还没过期、用的时候已过"的窗口很常见。
|
|
6
|
+
|
|
7
|
+
进一步:系统 Chrome 里被 `browser_cookie3` 抓走的 cookie 注入到 Playwright
|
|
8
|
+
tmp profile 后,服务端因为指纹不对会把这份 cookie 视为"半失效 session",
|
|
9
|
+
首页访问会弹 passport 登录框 —— 弹窗里有 `快速进入`(免密记忆登录)按钮,
|
|
10
|
+
我们自动点一下就能让服务端重新下发完整 session cookie,整个过程用户无感。
|
|
11
|
+
|
|
12
|
+
流程(v0.2.3):
|
|
13
|
+
1. goto 首页 → 可能弹 `#alibaba-login-box` 登录 iframe
|
|
14
|
+
2. iframe 里定位 `快速进入` 按钮并点击(免密记忆登录)
|
|
15
|
+
3. 等 modal 消失 → goto `/bought` 触发强鉴权页下发完整 session
|
|
16
|
+
4. 抓 Playwright context.cookies() 作为新快照
|
|
17
|
+
|
|
18
|
+
开关:`GOOFISH_AUTO_REFRESH_TOKEN=0` 可关闭(CI 或想自定义刷新策略时)。
|
|
19
|
+
|
|
20
|
+
注意:闲鱼后端返回的错误码是 `FAIL_SYS_TOKEN_EXOIRED`(是 EXOIRED 不是 EXPIRED,
|
|
21
|
+
拼写没写错),和 `_AUTH_KEYWORDS` / `_is_recoverable_auth_error` 匹配一致。
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import os
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from loguru import logger
|
|
30
|
+
|
|
31
|
+
from goofish_cli.core.session import Session, resolve_cookie_path, write_cookies_json
|
|
32
|
+
|
|
33
|
+
HOME_URL = "https://www.goofish.com"
|
|
34
|
+
# 强鉴权页,goto 后服务端必须下发完整 session cookie(cookie2/sgcookie/_tb_token_)
|
|
35
|
+
AUTH_PROBE_URL = "https://www.goofish.com/bought"
|
|
36
|
+
|
|
37
|
+
# 刷新成功的必需字段:`_m_h5_tk`(h5 签名)和 `unb`(用户 id)是任何请求都要的;
|
|
38
|
+
# `cookie2` 是真正的 session token —— 光有 _m_h5_tk/unb 没有 cookie2 仍会被
|
|
39
|
+
# 服务端判定 SESSION_EXPIRED。所以"刷新成功"必须见到 cookie2 被下发。
|
|
40
|
+
_REQUIRED_FRESH_COOKIES = ("_m_h5_tk", "unb", "cookie2")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _try_quick_enter(page: Any) -> bool:
|
|
44
|
+
"""闲鱼首页若弹 passport 登录框,点'快速进入'走免密记忆登录。
|
|
45
|
+
|
|
46
|
+
无弹窗 → 当作已登录返回 True。
|
|
47
|
+
有弹窗但按钮不存在或点击失败 → 返回 False(浏览器里可能连免密记忆都清空了,
|
|
48
|
+
这种情况需要扫码,不在本函数职责内,由调用方决定降级路径)。
|
|
49
|
+
"""
|
|
50
|
+
iframe_el = await page.query_selector("#alibaba-login-box")
|
|
51
|
+
if not iframe_el:
|
|
52
|
+
return True
|
|
53
|
+
frame = await iframe_el.content_frame()
|
|
54
|
+
if not frame:
|
|
55
|
+
logger.debug("[refresh] alibaba-login-box iframe content_frame 未就绪")
|
|
56
|
+
return False
|
|
57
|
+
try:
|
|
58
|
+
# 显式 timeout:Playwright 默认 30s 太长,会让整个 refresh 流程卡住
|
|
59
|
+
await frame.wait_for_load_state("domcontentloaded", timeout=5000)
|
|
60
|
+
# 给 iframe 里的 Vue/React 组件一点时间 mount
|
|
61
|
+
await page.wait_for_timeout(800)
|
|
62
|
+
await frame.get_by_text("快速进入", exact=True).first.click(timeout=5000)
|
|
63
|
+
logger.info("[refresh] 点击 passport '快速进入' 免密登录")
|
|
64
|
+
await page.wait_for_selector("#alibaba-login-box", state="hidden", timeout=10000)
|
|
65
|
+
return True
|
|
66
|
+
except Exception as e: # noqa: BLE001
|
|
67
|
+
logger.warning(f"[refresh] '快速进入'不可用(浏览器免密记忆可能已失效):{e}")
|
|
68
|
+
return False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
async def _refresh_async(cookies: dict[str, str]) -> dict[str, str]:
|
|
72
|
+
# 延迟 import:测试环境没装 playwright 也能 import refresh 模块
|
|
73
|
+
from goofish_cli.core.browser import goofish_page
|
|
74
|
+
|
|
75
|
+
# 显式传 cookies——让 Playwright context 注入**调用方当前 session** 的登录态,
|
|
76
|
+
# 而不是让 goofish_page 再走一次 Session.load() 从磁盘拉(内存里可能已被改)。
|
|
77
|
+
async with goofish_page(cookies=cookies) as page:
|
|
78
|
+
await page.goto(HOME_URL, wait_until="domcontentloaded")
|
|
79
|
+
# 给弹窗 + mtop h5 sign 一些时间渲染
|
|
80
|
+
await page.wait_for_timeout(1500)
|
|
81
|
+
|
|
82
|
+
# 有弹窗但"快速进入"不可用 → 浏览器免密记忆彻底失效,后续 goto /bought
|
|
83
|
+
# 也只会被跳登录页。直接返回空 dict 让上层报原始 AuthRequiredError,
|
|
84
|
+
# 避免返回"只更新了 _m_h5_tk 但 session 仍失效"的假成功 cookies。
|
|
85
|
+
if not await _try_quick_enter(page):
|
|
86
|
+
return {}
|
|
87
|
+
|
|
88
|
+
# 访问强鉴权页,触发服务端下发完整 session cookies(cookie2 / sgcookie /
|
|
89
|
+
# _tb_token_ 会被 Set-Cookie 刷新)。
|
|
90
|
+
try:
|
|
91
|
+
await page.goto(AUTH_PROBE_URL, wait_until="domcontentloaded", timeout=15000)
|
|
92
|
+
await page.wait_for_timeout(1500)
|
|
93
|
+
except Exception as e: # noqa: BLE001
|
|
94
|
+
logger.debug(f"[refresh] goto {AUTH_PROBE_URL} 异常(忽略):{e}")
|
|
95
|
+
|
|
96
|
+
pw_cookies = await page.context.cookies()
|
|
97
|
+
|
|
98
|
+
return {c["name"]: c["value"] for c in pw_cookies if c.get("name") and c.get("value")}
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def is_enabled() -> bool:
|
|
102
|
+
return os.environ.get("GOOFISH_AUTO_REFRESH_TOKEN") != "0"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def refresh_cookies_via_browser(session: Session, *, persist: bool = True) -> bool:
|
|
106
|
+
"""用 Playwright 刷一次闲鱼 cookie 合并回 session。
|
|
107
|
+
|
|
108
|
+
流程见模块 docstring。成功返回 True,否则 False(调用方按 False 走原始
|
|
109
|
+
AuthRequiredError 让上层报给用户)。
|
|
110
|
+
"""
|
|
111
|
+
current = {name: value for name, value in session.http.cookies.items() if value}
|
|
112
|
+
try:
|
|
113
|
+
fresh = asyncio.run(_refresh_async(current))
|
|
114
|
+
except Exception as e: # noqa: BLE001 — Playwright 起不来、Chrome 未装、超时等都走这里
|
|
115
|
+
logger.warning(f"用 Playwright 刷 cookie 失败:{e}")
|
|
116
|
+
return False
|
|
117
|
+
|
|
118
|
+
missing = [k for k in _REQUIRED_FRESH_COOKIES if k not in fresh]
|
|
119
|
+
if missing:
|
|
120
|
+
logger.warning(
|
|
121
|
+
f"刷 cookie 后仍缺关键字段 {missing}(可能'快速进入'未成功或 session 未下发),"
|
|
122
|
+
f"跳过合并(拿到 {len(fresh)} 个 cookie)"
|
|
123
|
+
)
|
|
124
|
+
return False
|
|
125
|
+
|
|
126
|
+
# 关键:直接 `update(fresh)` 会造成跨 domain 同名 cookie 并存(如 .goofish.com 下
|
|
127
|
+
# 一份旧 _m_h5_tk + Playwright 又写入一份新的),后续 `cookies.get(name)` 会抛
|
|
128
|
+
# "There are multiple cookies with name"。所以先删掉所有将被更新的 name 的旧条目。
|
|
129
|
+
names_to_refresh = set(fresh.keys())
|
|
130
|
+
for cookie in list(session.http.cookies):
|
|
131
|
+
if cookie.name in names_to_refresh:
|
|
132
|
+
session.http.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name)
|
|
133
|
+
session.http.cookies.update(fresh)
|
|
134
|
+
|
|
135
|
+
if persist:
|
|
136
|
+
# 尊重 GOOFISH_COOKIES_PATH —— 用户配置了自定义路径时不能写默认路径后再下次
|
|
137
|
+
# Session.load 又去读自定义路径,造成"刷新了但下次启动又回到旧的"。
|
|
138
|
+
path = resolve_cookie_path()
|
|
139
|
+
# 此时 session.http.cookies 已没有同名冲突,安全转 dict
|
|
140
|
+
merged = {**dict(session.http.cookies), **fresh}
|
|
141
|
+
try:
|
|
142
|
+
write_cookies_json(path, merged)
|
|
143
|
+
logger.info(f"cookie 已刷新并写回 {path}")
|
|
144
|
+
except OSError as e:
|
|
145
|
+
logger.debug(f"写回 cookies.json 失败(内存里仍生效):{e}")
|
|
146
|
+
return True
|
|
@@ -38,3 +38,22 @@ def test_classify_not_found():
|
|
|
38
38
|
|
|
39
39
|
with pytest.raises(NotFoundError):
|
|
40
40
|
_classify_error({"ret": ["FAIL_BIZ_ITEM_NOT_FOUND::商品不存在"]}, "mtop.test")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize(
|
|
44
|
+
"msg,expected",
|
|
45
|
+
[
|
|
46
|
+
("[api] 登录态失效:FAIL_SYS_TOKEN_EXOIRED::令牌过期", True),
|
|
47
|
+
("[api] 登录态失效:FAIL_SYS_TOKEN_EMPTY::令牌为空", True),
|
|
48
|
+
("[api] 登录态失效:FAIL_SYS_SESSION_EXPIRED::Session过期", True),
|
|
49
|
+
("[api] 登录态失效:令牌过期", True),
|
|
50
|
+
# 风控/权限类刷 cookie 救不了,不触发自动刷新
|
|
51
|
+
("[api] 登录态失效:FAIL_SYS_ILLEGAL_ACCESS::非法访问", False),
|
|
52
|
+
("[api] 未找到:FAIL_BIZ_ITEM_NOT_FOUND", False),
|
|
53
|
+
],
|
|
54
|
+
)
|
|
55
|
+
def test_is_recoverable_auth_error(msg, expected):
|
|
56
|
+
from goofish_cli.core.errors import AuthRequiredError
|
|
57
|
+
from goofish_cli.core.mtop import _is_recoverable_auth_error
|
|
58
|
+
|
|
59
|
+
assert _is_recoverable_auth_error(AuthRequiredError(msg)) is expected
|
|
@@ -74,7 +74,7 @@ def test_refresh_respects_custom_cookie_path(tmp_path, monkeypatch):
|
|
|
74
74
|
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(custom))
|
|
75
75
|
|
|
76
76
|
session = _make_session({"_m_h5_tk": "old", "unb": "u1"})
|
|
77
|
-
fresh = {"_m_h5_tk": "new", "unb": "u1"}
|
|
77
|
+
fresh = {"_m_h5_tk": "new", "unb": "u1", "cookie2": "c"}
|
|
78
78
|
with patch.object(refresh, "asyncio") as mock_async:
|
|
79
79
|
mock_async.run.side_effect = _fake_run(fresh)
|
|
80
80
|
ok = refresh.refresh_cookies_via_browser(session)
|
|
@@ -85,6 +85,23 @@ def test_refresh_respects_custom_cookie_path(tmp_path, monkeypatch):
|
|
|
85
85
|
assert not (tmp_path / "cookies.json").exists()
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
def test_refresh_fails_when_cookie2_missing(tmp_path, monkeypatch):
|
|
89
|
+
"""cookie2 是真正的 session token,缺它必须判失败(v0.2.3 Copilot review)。"""
|
|
90
|
+
session = _make_session({"_m_h5_tk": "old", "unb": "u1", "cookie2": "old_c"})
|
|
91
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(tmp_path / "cookies.json"))
|
|
92
|
+
|
|
93
|
+
# fresh 有 _m_h5_tk / unb 但无 cookie2 —— 过去会误判成功,现在必须 False
|
|
94
|
+
fresh = {"_m_h5_tk": "new", "unb": "u1"}
|
|
95
|
+
with patch.object(refresh, "asyncio") as mock_async:
|
|
96
|
+
mock_async.run.side_effect = _fake_run(fresh)
|
|
97
|
+
ok = refresh.refresh_cookies_via_browser(session)
|
|
98
|
+
|
|
99
|
+
assert ok is False
|
|
100
|
+
# 旧 cookie 不能被覆盖
|
|
101
|
+
assert session.http.cookies.get("_m_h5_tk") == "old"
|
|
102
|
+
assert not (tmp_path / "cookies.json").exists()
|
|
103
|
+
|
|
104
|
+
|
|
88
105
|
def test_refresh_swallows_playwright_exception():
|
|
89
106
|
session = _make_session({"_m_h5_tk": "old", "unb": "u1"})
|
|
90
107
|
with patch.object(refresh, "asyncio") as mock_async:
|
|
@@ -1,80 +0,0 @@
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|