goofish-cli 0.2.1__tar.gz → 0.2.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/CHANGELOG.md +21 -1
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/PKG-INFO +1 -1
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/pyproject.toml +1 -1
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/browser.py +7 -1
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/mtop.py +33 -2
- goofish_cli-0.2.2/src/goofish_cli/core/refresh.py +80 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/session.py +11 -3
- goofish_cli-0.2.2/tests/test_refresh.py +105 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/uv.lock +1 -1
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/.env.example +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/.github/pull_request_template.md +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/.github/workflows/ci.yml +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/.github/workflows/publish.yml +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/.gitignore +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/CONTRIBUTING.md +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/LICENSE +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/NOTICE +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/README.md +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/docs/architecture.md +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/docs/compliance.md +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/docs/mcp-setup.md +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/examples/README.md +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/examples/publish_from_folder.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/examples/watch_to_autoreply.sh +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/cli.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/login.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/reset_guard.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/auth/status.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/category/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/category/recommend.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/delete.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/get.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/publish.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/item/view.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/location/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/location/default.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/media/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/media/upload.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/history.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/list_chats.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/send.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/message/watch.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/search/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/commands/search/search.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/browser_cookie.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/errors.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/guard.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/limiter.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/output.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/registry.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/sign.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/strategy.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/token.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/core/ws.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/mcp_server.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/src/goofish_cli/static/goofish_js_version_2.js +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/__init__.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_auth_login.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_browser_cookie.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_guard.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_item_view.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_limiter.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_list_chats.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_mtop.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_publish_payload.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_registry.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_search.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_session_bootstrap.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_sign.py +0 -0
- {goofish_cli-0.2.1 → goofish_cli-0.2.2}/tests/test_ws_codec.py +0 -0
|
@@ -6,6 +6,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.2] - 2026-04-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **`_m_h5_tk` 自动刷新机制**(`core/refresh.py`):mtop 调用遇
|
|
13
|
+
`FAIL_SYS_TOKEN_EXOIRED` / `FAIL_SYS_TOKEN_EMPTY` / `令牌过期` 时,自动用
|
|
14
|
+
Playwright `goto` 一次 `https://www.goofish.com` 触发服务端 `Set-Cookie` 续 token,
|
|
15
|
+
合并回 session 后重试一次原请求。开关:`GOOFISH_AUTO_REFRESH_TOKEN=0` 关闭(CI 或
|
|
16
|
+
想自定义刷新策略时)。
|
|
17
|
+
- **只处理 token 层失效**:`FAIL_SYS_SESSION_EXPIRED` / `FAIL_SYS_ILLEGAL_ACCESS` 不触发
|
|
18
|
+
自动刷新(意味着 `unb` / `cookie2` 也失效了,刷 `_m_h5_tk` 救不了,仍按原 AuthRequiredError 抛)。
|
|
19
|
+
- **避免跨 domain 同名 cookie 并存**:Playwright 写回的新 cookie 和 `browser_cookie3`
|
|
20
|
+
导入的旧 cookie 有不同 domain,`requests.CookieJar` 允许两者并存,但后续
|
|
21
|
+
`cookies.get(name)` 会抛 `CookieConflictError`。刷新时先 `clear(domain, path, name)`
|
|
22
|
+
掉所有同名旧条目再 `update(fresh)`。
|
|
23
|
+
- 刷新成功后自动写回 `~/.goofish-cli/cookies.json`,下次启动不用再刷。
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
- mtop `call()` 新增 `_auto_refresh` 参数(默认 True),递归重试时置 False 避免死循环。
|
|
27
|
+
|
|
9
28
|
## [0.2.1] - 2026-04-22
|
|
10
29
|
|
|
11
30
|
### Changed
|
|
@@ -67,7 +86,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
67
86
|
- 本版本需要用户手动从浏览器导入 cookie(含 `unb` / `_m_h5_tk` / `x5sec`)
|
|
68
87
|
- 遇到 `RGV587_ERROR` 风控时,需在浏览器完成滑块验证并**重新导出**带 `x5sec` 的 cookie
|
|
69
88
|
|
|
70
|
-
[Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.
|
|
89
|
+
[Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.2...HEAD
|
|
90
|
+
[0.2.2]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.1...v0.2.2
|
|
71
91
|
[0.2.1]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.0...v0.2.1
|
|
72
92
|
[0.2.0]: https://github.com/fancyboi999/goofish-cli/compare/v0.1.0...v0.2.0
|
|
73
93
|
[0.1.0]: https://github.com/fancyboi999/goofish-cli/releases/tag/v0.1.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: goofish-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: 闲鱼 CLI — 支持 MCP,未来支持 Claude Skills。为 AI Agent 提供闲鱼自动化基础能力。
|
|
5
5
|
Project-URL: Homepage, https://github.com/fancyboi999/goofish-cli
|
|
6
6
|
Project-URL: Repository, https://github.com/fancyboi999/goofish-cli
|
|
@@ -85,9 +85,14 @@ async def goofish_page(
|
|
|
85
85
|
*,
|
|
86
86
|
headless: bool | None = None,
|
|
87
87
|
viewport: tuple[int, int] = (1440, 900),
|
|
88
|
+
cookies: dict[str, str] | None = None,
|
|
88
89
|
) -> AsyncIterator[Any]:
|
|
89
90
|
"""启动系统 Chrome(独立 tmp profile)+ 灌 cookie,yield 出一个 `Page`。
|
|
90
91
|
|
|
92
|
+
`cookies` 可选:显式传入 `{name: value}` 时直接用;不传则走 `Session.load()`
|
|
93
|
+
三级兜底。自动刷 `_m_h5_tk` 的调用方需要用内存里当前 session 的 cookies 而非
|
|
94
|
+
磁盘快照(可能已被改)—— 传 `cookies=session.http.cookies` 展平后的 dict。
|
|
95
|
+
|
|
91
96
|
用法:
|
|
92
97
|
async with goofish_page() as page:
|
|
93
98
|
await page.goto("https://www.goofish.com/search?q=foo")
|
|
@@ -102,7 +107,8 @@ async def goofish_page(
|
|
|
102
107
|
PROFILES_PARENT.mkdir(parents=True, exist_ok=True)
|
|
103
108
|
# 每次调用独立 profile 目录,避开 Chrome SingletonLock 并发冲突
|
|
104
109
|
profile_dir = Path(tempfile.mkdtemp(prefix="chrome-", dir=str(PROFILES_PARENT)))
|
|
105
|
-
cookies
|
|
110
|
+
if cookies is None:
|
|
111
|
+
cookies = _load_cookies_from_session()
|
|
106
112
|
pw_cookies = _cookies_to_playwright(cookies)
|
|
107
113
|
|
|
108
114
|
try:
|
|
@@ -11,6 +11,8 @@ import json
|
|
|
11
11
|
import time
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
|
+
from loguru import logger
|
|
15
|
+
|
|
14
16
|
from goofish_cli.core.errors import (
|
|
15
17
|
AuthRequiredError,
|
|
16
18
|
GoofishError,
|
|
@@ -68,8 +70,13 @@ def call(
|
|
|
68
70
|
spm_cnt: str = "a21ybx.home.0.0",
|
|
69
71
|
extra_params: dict[str, str] | None = None,
|
|
70
72
|
headers: dict[str, str] | None = None,
|
|
73
|
+
_auto_refresh: bool = True,
|
|
71
74
|
) -> dict[str, Any]:
|
|
72
|
-
"""调用 mtop 接口。返回原始 JSON。失败抛 GoofishError 子类。
|
|
75
|
+
"""调用 mtop 接口。返回原始 JSON。失败抛 GoofishError 子类。
|
|
76
|
+
|
|
77
|
+
`_auto_refresh=True`:遇到 `FAIL_SYS_TOKEN_EXOIRED` 时自动用 Playwright goto
|
|
78
|
+
闲鱼首页刷一次 cookie 再重试一次。递归调用时用 False 避免死循环。
|
|
79
|
+
"""
|
|
73
80
|
url = f"{MTOP_HOST}/h5/{api}/{version}/"
|
|
74
81
|
t_ms = str(int(time.time() * 1000))
|
|
75
82
|
data_val = data if isinstance(data, str) else json.dumps(data, separators=(",", ":"))
|
|
@@ -104,10 +111,34 @@ def call(
|
|
|
104
111
|
timeout=30,
|
|
105
112
|
)
|
|
106
113
|
raw = resp.json()
|
|
107
|
-
|
|
114
|
+
try:
|
|
115
|
+
_classify_error(raw, api)
|
|
116
|
+
except AuthRequiredError as e:
|
|
117
|
+
# 只对 h5_tk 层的过期做自动刷新 —— session 级失效(FAIL_SYS_SESSION_EXPIRED
|
|
118
|
+
# / ILLEGAL_ACCESS)通常意味着 unb/cookie2 也得换,不是刷 _m_h5_tk 能救的。
|
|
119
|
+
if not (_auto_refresh and _is_token_expired_error(e)):
|
|
120
|
+
raise
|
|
121
|
+
from goofish_cli.core.refresh import is_enabled, refresh_cookies_via_browser
|
|
122
|
+
if not is_enabled():
|
|
123
|
+
raise
|
|
124
|
+
logger.info(f"[{api}] 检测到 _m_h5_tk 过期,尝试 Playwright goto 刷新 cookie…")
|
|
125
|
+
if not refresh_cookies_via_browser(session):
|
|
126
|
+
raise
|
|
127
|
+
# 刷新成功:重试一次,禁用递归自动刷新
|
|
128
|
+
return call(
|
|
129
|
+
session, api, data,
|
|
130
|
+
version=version, spm_cnt=spm_cnt,
|
|
131
|
+
extra_params=extra_params, headers=headers,
|
|
132
|
+
_auto_refresh=False,
|
|
133
|
+
)
|
|
108
134
|
return raw
|
|
109
135
|
|
|
110
136
|
|
|
137
|
+
def _is_token_expired_error(e: AuthRequiredError) -> bool:
|
|
138
|
+
msg = str(e)
|
|
139
|
+
return any(kw in msg for kw in ("FAIL_SYS_TOKEN_EXOIRED", "FAIL_SYS_TOKEN_EMPTY", "令牌过期"))
|
|
140
|
+
|
|
141
|
+
|
|
111
142
|
def _classify_error(raw: dict[str, Any], api: str) -> None:
|
|
112
143
|
"""根据 ret 字段分类抛异常。成功则不抛。"""
|
|
113
144
|
ret = raw.get("ret") or []
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"""遇 FAIL_SYS_TOKEN_EXOIRED 时用 Playwright goto 闲鱼首页刷新 cookie。
|
|
2
|
+
|
|
3
|
+
`_m_h5_tk` 只有 10 分钟有效期,服务端在浏览器**活跃访问**闲鱼页面时通过
|
|
4
|
+
`Set-Cookie` 续期;浏览器静默几分钟就会过期。mcp/cli 用 `browser_cookie3`
|
|
5
|
+
从磁盘抓快照,命中"抓的时候还没过期、用的时候已过"的窗口很常见。
|
|
6
|
+
|
|
7
|
+
与其让用户手动去浏览器刷新,不如我们自己用 Playwright `goto` 一次首页——
|
|
8
|
+
反正 search/item view 的浏览器基础设施已经搭好了,直接复用。
|
|
9
|
+
|
|
10
|
+
开关:`GOOFISH_AUTO_REFRESH_TOKEN=0` 可关闭(CI 或想自定义刷新策略时)。
|
|
11
|
+
|
|
12
|
+
注意:闲鱼后端返回的错误码是 `FAIL_SYS_TOKEN_EXOIRED`(是 EXOIRED 不是 EXPIRED,
|
|
13
|
+
拼写没写错),和 `_AUTH_KEYWORDS` / `_is_token_expired_error` 匹配一致。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import asyncio
|
|
18
|
+
import os
|
|
19
|
+
|
|
20
|
+
from loguru import logger
|
|
21
|
+
|
|
22
|
+
from goofish_cli.core.session import Session, resolve_cookie_path, write_cookies_json
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
async def _refresh_async(url: str, cookies: dict[str, str]) -> dict[str, str]:
|
|
26
|
+
# 延迟 import:测试环境没装 playwright 也能 import refresh 模块
|
|
27
|
+
from goofish_cli.core.browser import goofish_page
|
|
28
|
+
|
|
29
|
+
# 显式传 cookies——让 Playwright context 注入**调用方当前 session** 的登录态,
|
|
30
|
+
# 而不是让 goofish_page 再走一次 Session.load() 从磁盘拉(内存里可能已被改)。
|
|
31
|
+
async with goofish_page(cookies=cookies) as page:
|
|
32
|
+
await page.goto(url, wait_until="domcontentloaded")
|
|
33
|
+
# 给 mtop h5 sign 一些时间跑完并让服务端 Set-Cookie 新 _m_h5_tk
|
|
34
|
+
await page.wait_for_timeout(1500)
|
|
35
|
+
pw_cookies = await page.context.cookies()
|
|
36
|
+
|
|
37
|
+
return {c["name"]: c["value"] for c in pw_cookies if c.get("name") and c.get("value")}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_enabled() -> bool:
|
|
41
|
+
return os.environ.get("GOOFISH_AUTO_REFRESH_TOKEN") != "0"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def refresh_cookies_via_browser(session: Session, *, persist: bool = True) -> bool:
|
|
45
|
+
"""用 Playwright goto 一次闲鱼首页拿新 cookie 并合并回 session。
|
|
46
|
+
|
|
47
|
+
成功返回 True,否则 False(调用方按 False 走原始 AuthRequiredError)。
|
|
48
|
+
"""
|
|
49
|
+
current = {name: value for name, value in session.http.cookies.items() if value}
|
|
50
|
+
try:
|
|
51
|
+
fresh = asyncio.run(_refresh_async("https://www.goofish.com", current))
|
|
52
|
+
except Exception as e: # noqa: BLE001 — Playwright 起不来、Chrome 未装、超时等都走这里
|
|
53
|
+
logger.warning(f"用 Playwright 刷 cookie 失败:{e}")
|
|
54
|
+
return False
|
|
55
|
+
|
|
56
|
+
if "_m_h5_tk" not in fresh or "unb" not in fresh:
|
|
57
|
+
logger.warning(f"刷 cookie 后仍缺关键字段,跳过合并(拿到 {len(fresh)} 个 cookie)")
|
|
58
|
+
return False
|
|
59
|
+
|
|
60
|
+
# 关键:直接 `update(fresh)` 会造成跨 domain 同名 cookie 并存(如 .goofish.com 下
|
|
61
|
+
# 一份旧 _m_h5_tk + Playwright 又写入一份新的),后续 `cookies.get(name)` 会抛
|
|
62
|
+
# "There are multiple cookies with name"。所以先删掉所有将被更新的 name 的旧条目。
|
|
63
|
+
names_to_refresh = set(fresh.keys())
|
|
64
|
+
for cookie in list(session.http.cookies):
|
|
65
|
+
if cookie.name in names_to_refresh:
|
|
66
|
+
session.http.cookies.clear(domain=cookie.domain, path=cookie.path, name=cookie.name)
|
|
67
|
+
session.http.cookies.update(fresh)
|
|
68
|
+
|
|
69
|
+
if persist:
|
|
70
|
+
# 尊重 GOOFISH_COOKIES_PATH —— 用户配置了自定义路径时不能写默认路径后再下次
|
|
71
|
+
# Session.load 又去读自定义路径,造成"刷新了但下次启动又回到旧的"。
|
|
72
|
+
path = resolve_cookie_path()
|
|
73
|
+
# 此时 session.http.cookies 已没有同名冲突,安全转 dict
|
|
74
|
+
merged = {**dict(session.http.cookies), **fresh}
|
|
75
|
+
try:
|
|
76
|
+
write_cookies_json(path, merged)
|
|
77
|
+
logger.info(f"cookie 已刷新并写回 {path}")
|
|
78
|
+
except OSError as e:
|
|
79
|
+
logger.debug(f"写回 cookies.json 失败(内存里仍生效):{e}")
|
|
80
|
+
return True
|
|
@@ -22,6 +22,16 @@ from goofish_cli.core.errors import AuthRequiredError
|
|
|
22
22
|
from goofish_cli.core.sign import generate_device_id
|
|
23
23
|
|
|
24
24
|
DEFAULT_COOKIE_PATH = Path.home() / ".goofish-cli" / "cookies.json"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_cookie_path(cookie_path: Path | str | None = None) -> Path:
|
|
28
|
+
"""解析实际 cookie 文件路径。优先级:显式入参 > GOOFISH_COOKIES_PATH > 默认。
|
|
29
|
+
|
|
30
|
+
`Session.load()` 和自动刷新写回逻辑必须走同一套解析,避免"读一个路径、写另一个"。
|
|
31
|
+
"""
|
|
32
|
+
return Path(os.path.expanduser(
|
|
33
|
+
cookie_path or os.environ.get("GOOFISH_COOKIES_PATH") or DEFAULT_COOKIE_PATH
|
|
34
|
+
))
|
|
25
35
|
DEVICE_CACHE_PATH = Path.home() / ".goofish-cli" / "device.json"
|
|
26
36
|
USER_AGENT = (
|
|
27
37
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
|
|
@@ -39,9 +49,7 @@ class Session:
|
|
|
39
49
|
|
|
40
50
|
@classmethod
|
|
41
51
|
def load(cls, cookie_path: Path | str | None = None) -> Session:
|
|
42
|
-
path =
|
|
43
|
-
cookie_path or os.environ.get("GOOFISH_COOKIES_PATH") or DEFAULT_COOKIE_PATH
|
|
44
|
-
))
|
|
52
|
+
path = resolve_cookie_path(cookie_path)
|
|
45
53
|
|
|
46
54
|
cookies = _load_or_bootstrap_cookies(path)
|
|
47
55
|
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""测 refresh 模块。Playwright 走真浏览器不进单测,mock 掉 asyncio.run。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from goofish_cli.core import refresh
|
|
10
|
+
from goofish_cli.core.session import Session
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _make_session(cookies: dict[str, str]) -> Session:
|
|
14
|
+
http = requests.Session()
|
|
15
|
+
http.cookies.update(cookies)
|
|
16
|
+
return Session(http=http, unb=cookies.get("unb", ""), tracknick="", device_id="dev")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _fake_run(result):
|
|
20
|
+
"""模拟 asyncio.run:close 掉传入的 coroutine(避免 unawaited warning)后返回结果。"""
|
|
21
|
+
def _inner(coro):
|
|
22
|
+
coro.close()
|
|
23
|
+
return result
|
|
24
|
+
return _inner
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _fake_run_raises(exc):
|
|
28
|
+
def _inner(coro):
|
|
29
|
+
coro.close()
|
|
30
|
+
raise exc
|
|
31
|
+
return _inner
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_refresh_merges_new_cookies_and_dedupes_same_name(tmp_path, monkeypatch):
|
|
35
|
+
"""关键回归:刷新后不能出现跨 domain 同名 cookie(`.cookies.get(...)` 会抛)。"""
|
|
36
|
+
# 预埋一份旧 _m_h5_tk(默认 domain)
|
|
37
|
+
session = _make_session({"_m_h5_tk": "old_1", "unb": "u1", "cookie2": "c2"})
|
|
38
|
+
|
|
39
|
+
# 通过 GOOFISH_COOKIES_PATH 让 resolve_cookie_path 指到测试临时路径
|
|
40
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(tmp_path / "cookies.json"))
|
|
41
|
+
|
|
42
|
+
fresh = {"_m_h5_tk": "new_2", "unb": "u1", "cookie2": "c3", "x5sec": "x"}
|
|
43
|
+
with patch.object(refresh, "asyncio") as mock_async:
|
|
44
|
+
mock_async.run.side_effect = _fake_run(fresh)
|
|
45
|
+
ok = refresh.refresh_cookies_via_browser(session)
|
|
46
|
+
|
|
47
|
+
assert ok is True
|
|
48
|
+
# 去重后能正常 get —— 不会抛 "multiple cookies with name"
|
|
49
|
+
assert session.http.cookies.get("_m_h5_tk") == "new_2"
|
|
50
|
+
assert session.http.cookies.get("cookie2") == "c3"
|
|
51
|
+
assert session.http.cookies.get("x5sec") == "x"
|
|
52
|
+
# 磁盘也写了
|
|
53
|
+
assert (tmp_path / "cookies.json").exists()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_refresh_fails_when_required_keys_missing(tmp_path, monkeypatch):
|
|
57
|
+
session = _make_session({"_m_h5_tk": "old", "unb": "u1"})
|
|
58
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(tmp_path / "cookies.json"))
|
|
59
|
+
|
|
60
|
+
with patch.object(refresh, "asyncio") as mock_async:
|
|
61
|
+
mock_async.run.side_effect = _fake_run({"foo": "bar"}) # 没 _m_h5_tk / unb
|
|
62
|
+
ok = refresh.refresh_cookies_via_browser(session)
|
|
63
|
+
|
|
64
|
+
assert ok is False
|
|
65
|
+
# 不应覆盖旧 session
|
|
66
|
+
assert session.http.cookies.get("_m_h5_tk") == "old"
|
|
67
|
+
# 也不应写盘
|
|
68
|
+
assert not (tmp_path / "cookies.json").exists()
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_refresh_respects_custom_cookie_path(tmp_path, monkeypatch):
|
|
72
|
+
"""GOOFISH_COOKIES_PATH 自定义路径必须被尊重(Copilot review feedback)。"""
|
|
73
|
+
custom = tmp_path / "nested" / "my.json"
|
|
74
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(custom))
|
|
75
|
+
|
|
76
|
+
session = _make_session({"_m_h5_tk": "old", "unb": "u1"})
|
|
77
|
+
fresh = {"_m_h5_tk": "new", "unb": "u1"}
|
|
78
|
+
with patch.object(refresh, "asyncio") as mock_async:
|
|
79
|
+
mock_async.run.side_effect = _fake_run(fresh)
|
|
80
|
+
ok = refresh.refresh_cookies_via_browser(session)
|
|
81
|
+
|
|
82
|
+
assert ok is True
|
|
83
|
+
assert custom.exists()
|
|
84
|
+
# 默认路径不应被写
|
|
85
|
+
assert not (tmp_path / "cookies.json").exists()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_refresh_swallows_playwright_exception():
|
|
89
|
+
session = _make_session({"_m_h5_tk": "old", "unb": "u1"})
|
|
90
|
+
with patch.object(refresh, "asyncio") as mock_async:
|
|
91
|
+
mock_async.run.side_effect = _fake_run_raises(RuntimeError("chrome not installed"))
|
|
92
|
+
ok = refresh.refresh_cookies_via_browser(session, persist=False)
|
|
93
|
+
assert ok is False
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.parametrize(
|
|
97
|
+
"env,expected",
|
|
98
|
+
[(None, True), ("0", False), ("1", True), ("", True)],
|
|
99
|
+
)
|
|
100
|
+
def test_is_enabled_honors_env(monkeypatch, env, expected):
|
|
101
|
+
if env is None:
|
|
102
|
+
monkeypatch.delenv("GOOFISH_AUTO_REFRESH_TOKEN", raising=False)
|
|
103
|
+
else:
|
|
104
|
+
monkeypatch.setenv("GOOFISH_AUTO_REFRESH_TOKEN", env)
|
|
105
|
+
assert refresh.is_enabled() is expected
|
|
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
|