goofish-cli 0.2.3__tar.gz → 0.2.4__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.3 → goofish_cli-0.2.4}/CHANGELOG.md +25 -1
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/PKG-INFO +1 -1
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/pyproject.toml +1 -1
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/login.py +26 -1
- goofish_cli-0.2.4/src/goofish_cli/core/qr_login.py +139 -0
- goofish_cli-0.2.4/tests/test_qr_login.py +129 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/uv.lock +1 -1
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.env.example +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/pull_request_template.md +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/workflows/ci.yml +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/workflows/publish.yml +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.gitignore +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/CONTRIBUTING.md +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/LICENSE +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/NOTICE +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/README.md +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/docs/architecture.md +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/docs/compliance.md +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/docs/mcp-setup.md +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/examples/README.md +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/examples/publish_from_folder.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/examples/watch_to_autoreply.sh +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/cli.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/reset_guard.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/status.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/category/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/category/recommend.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/delete.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/get.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/publish.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/view.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/location/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/location/default.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/media/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/media/upload.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/history.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/list_chats.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/send.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/watch.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/search/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/search/search.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/browser.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/browser_cookie.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/errors.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/guard.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/limiter.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/mtop.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/output.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/refresh.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/registry.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/session.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/sign.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/strategy.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/token.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/ws.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/mcp_server.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/static/goofish_js_version_2.js +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/__init__.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_auth_login.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_browser_cookie.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_guard.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_item_view.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_limiter.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_list_chats.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_mtop.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_publish_payload.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_refresh.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_registry.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_search.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_session_bootstrap.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_sign.py +0 -0
- {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_ws_codec.py +0 -0
|
@@ -6,6 +6,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.2.4] - 2026-04-22
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- **`auth login --qr` 扫码登录兜底**(`core/qr_login.py`):v0.2.3 的"快速进入"
|
|
13
|
+
免密登录依赖浏览器免密记忆,换机 / 长期不登 / 清 cookie 后记忆失效就没法用。
|
|
14
|
+
v0.2.4 补上扫码路径——Playwright 开干净 tmp profile goto 首页触发 passport
|
|
15
|
+
iframe(`styleType=vertical` 布局里 QR canvas 首屏就在 DOM 里,无需切 tab),
|
|
16
|
+
用户用手机闲鱼 App 扫码 + 手机确认后,轮询 `context.cookies()` 直到见到完整
|
|
17
|
+
session cookie(`_m_h5_tk / unb / cookie2`)即判成功,抓快照写回
|
|
18
|
+
`~/.goofish-cli/cookies.json`。
|
|
19
|
+
- `--qr-timeout N`(默认 120s)/ `GOOFISH_QR_TIMEOUT=N` 可调;超时抛
|
|
20
|
+
`AuthRequiredError`,提示延长超时重试。
|
|
21
|
+
- 传 `goofish_page(cookies={})` 是关键——不传空 dict 会走 `Session.load` 把
|
|
22
|
+
已有 cookie 灌进去,passport 就优先走"快速进入"跳过 QR,用户看不到扫码界面。
|
|
23
|
+
- 和 v0.2.3 refresh 自动路径分工:refresh=无感续命(SESSION_EXPIRED 时自动
|
|
24
|
+
点"快速进入"),qr_login=显式扫码(需用户拿手机)。不混用,不塞进 refresh
|
|
25
|
+
自动 retry(扫码时间不可控,会违反"无感"语义)。
|
|
26
|
+
|
|
27
|
+
### Changed
|
|
28
|
+
- `auth login` 新增 `--qr` / `--qr-timeout` flag;`--qr` 与 `<source>` /
|
|
29
|
+
`--raw` / `--browser` 互斥,组合使用会直接报错(QR 走独立 Playwright 浏览器,
|
|
30
|
+
同时传其它来源会让用户困惑)。
|
|
31
|
+
|
|
9
32
|
## [0.2.3] - 2026-04-22
|
|
10
33
|
|
|
11
34
|
### Added
|
|
@@ -104,7 +127,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
|
104
127
|
- 本版本需要用户手动从浏览器导入 cookie(含 `unb` / `_m_h5_tk` / `x5sec`)
|
|
105
128
|
- 遇到 `RGV587_ERROR` 风控时,需在浏览器完成滑块验证并**重新导出**带 `x5sec` 的 cookie
|
|
106
129
|
|
|
107
|
-
[Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.
|
|
130
|
+
[Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.4...HEAD
|
|
131
|
+
[0.2.4]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.3...v0.2.4
|
|
108
132
|
[0.2.3]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.2...v0.2.3
|
|
109
133
|
[0.2.2]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.1...v0.2.2
|
|
110
134
|
[0.2.1]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.0...v0.2.1
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: goofish-cli
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.4
|
|
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
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
- `auth login --browser edge` 指定单个浏览器
|
|
11
11
|
- `auth login <path>` 从 JSON 文件导入
|
|
12
12
|
- `auth login <cookie_str> --raw` 粘贴 "k=v; k=v" 字符串
|
|
13
|
+
- `auth login --qr` 扫码登录(兜底:浏览器免密记忆失效 / 换机 / 首次用)
|
|
13
14
|
|
|
14
15
|
触发条件:
|
|
15
16
|
1. 所有浏览器都没拿到 → 报错里列出手动兜底方案
|
|
@@ -38,10 +39,34 @@ def login(
|
|
|
38
39
|
*,
|
|
39
40
|
raw: bool = False,
|
|
40
41
|
browser: str = "auto",
|
|
42
|
+
qr: bool = False,
|
|
43
|
+
qr_timeout: int | None = None,
|
|
41
44
|
) -> dict[str, object]:
|
|
42
45
|
target = DEFAULT_COOKIE_PATH
|
|
43
46
|
|
|
44
|
-
if
|
|
47
|
+
if qr:
|
|
48
|
+
# QR 和其它来源互斥:QR 会起独立 Playwright 浏览器,source / --raw / 指定
|
|
49
|
+
# --browser 没意义还会让用户困惑("我传的 cookie 哪去了?")
|
|
50
|
+
if source is not None or raw or browser != "auto":
|
|
51
|
+
raise ValueError(
|
|
52
|
+
"--qr 不能与 <source> / --raw / --browser 同时使用"
|
|
53
|
+
"(QR 走独立的 Playwright 浏览器)"
|
|
54
|
+
)
|
|
55
|
+
# qr_timeout=None 让 core.qr_login 走统一的 env → 默认值 兜底逻辑;
|
|
56
|
+
# 这里若写 int 默认(例如 120)会把 env 覆盖路径挡掉(CLI 总是显式传值)。
|
|
57
|
+
from goofish_cli.core.qr_login import login_via_qr
|
|
58
|
+
cookies = login_via_qr(timeout=qr_timeout, persist=False)
|
|
59
|
+
if not cookies:
|
|
60
|
+
# 空 dict 可能来自两种失败:超时未扫码,或 Playwright 起不来(Chrome
|
|
61
|
+
# 未装、端口占用等)。文案同时覆盖,让用户知道去翻日志。
|
|
62
|
+
raise AuthRequiredError(
|
|
63
|
+
"QR 扫码登录未完成——可能是超时内未扫码 / 手机未确认,"
|
|
64
|
+
"也可能是 Playwright 浏览器启动失败(Chrome 未装、端口占用等,"
|
|
65
|
+
"详见前面的 warning 日志)。可重试并延长超时:"
|
|
66
|
+
"goofish auth login --qr --qr-timeout 180"
|
|
67
|
+
)
|
|
68
|
+
source_label = "qr"
|
|
69
|
+
elif source is None:
|
|
45
70
|
if raw:
|
|
46
71
|
# --raw 要求紧跟 cookie 字符串;如果没传 source,说明用户漏了参数——
|
|
47
72
|
# 不能静默走浏览器 auto-detect,否则 --raw 被吞,用户会困惑。
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""扫码登录——浏览器免密记忆失效时的 auth fallback。
|
|
2
|
+
|
|
3
|
+
与 v0.2.3 `core/refresh.py` 的分工:
|
|
4
|
+
- refresh:遇 mtop SESSION_EXPIRED 时**自动**点"快速进入"免密登录,无需用户交互。
|
|
5
|
+
- qr_login:用户免密记忆彻底清空(换机 / 长期不登 / 清 cookie)时,走**扫码**
|
|
6
|
+
拿一份全新 session。必须显式触发(`auth login --qr`),因为需要用户拿手机。
|
|
7
|
+
|
|
8
|
+
不把 QR 塞进 refresh 自动路径的原因:refresh 的合约是"无感续命",扫码要用户
|
|
9
|
+
参与,时间不可控,违反合约。所以两条路并存,根据"浏览器是否还认识你"自动/手动选。
|
|
10
|
+
|
|
11
|
+
passport 页面(`passport.goofish.com/mini_login.htm?...&styleType=vertical`)的
|
|
12
|
+
vertical 布局里扫码区和密码区并排,canvas 首屏就在 DOM 里——不用切 tab。
|
|
13
|
+
|
|
14
|
+
流程:
|
|
15
|
+
1. `goofish_page(cookies={})` 开**干净 tmp profile**(否则 passport 会优先走"快速
|
|
16
|
+
进入"跳过 QR)
|
|
17
|
+
2. goto 首页触发 `#alibaba-login-box` iframe
|
|
18
|
+
3. 等 `.qrcode-login canvas` 渲染 → QR 就绪
|
|
19
|
+
4. 轮询 `context.cookies()`:看见 `_m_h5_tk / unb / cookie2` 全到位说明扫码 +
|
|
20
|
+
手机端确认都过了,抓快照返回
|
|
21
|
+
5. 超时(默认 120s,`GOOFISH_QR_TIMEOUT` 可调)→ 空 dict,上层报 AuthRequiredError
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import os
|
|
27
|
+
import time
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from loguru import logger
|
|
31
|
+
|
|
32
|
+
from goofish_cli.core.session import resolve_cookie_path, write_cookies_json
|
|
33
|
+
|
|
34
|
+
HOME_URL = "https://www.goofish.com"
|
|
35
|
+
|
|
36
|
+
# 扫码 + 手机确认后,passport 会下发完整 session cookie。这三个齐了才算真的登上:
|
|
37
|
+
# _m_h5_tk 是 h5 签名、unb 是用户 id、cookie2 是 session token。
|
|
38
|
+
_REQUIRED_LOGIN_COOKIES = ("_m_h5_tk", "unb", "cookie2")
|
|
39
|
+
|
|
40
|
+
_DEFAULT_QR_TIMEOUT = 120
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
async def _wait_for_qr(page: Any, timeout_ms: int = 15000) -> bool:
|
|
44
|
+
"""等 passport iframe 里 QR canvas 渲染出来(说明可扫)。"""
|
|
45
|
+
try:
|
|
46
|
+
iframe_el = await page.wait_for_selector("#alibaba-login-box", timeout=timeout_ms)
|
|
47
|
+
except Exception as e: # noqa: BLE001
|
|
48
|
+
logger.warning(f"[qr] 未检测到 passport iframe:{e}(首页或许已登录?)")
|
|
49
|
+
return False
|
|
50
|
+
frame = await iframe_el.content_frame()
|
|
51
|
+
if not frame:
|
|
52
|
+
logger.warning("[qr] passport iframe content_frame 未就绪")
|
|
53
|
+
return False
|
|
54
|
+
try:
|
|
55
|
+
await frame.wait_for_load_state("domcontentloaded", timeout=5000)
|
|
56
|
+
await frame.wait_for_selector(".qrcode-login canvas", timeout=timeout_ms)
|
|
57
|
+
return True
|
|
58
|
+
except Exception as e: # noqa: BLE001
|
|
59
|
+
logger.warning(f"[qr] QR canvas 未渲染:{e}")
|
|
60
|
+
return False
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _has_all_login_cookies(cookies_list: list[dict[str, Any]]) -> bool:
|
|
64
|
+
names = {c.get("name") for c in cookies_list if c.get("value")}
|
|
65
|
+
return all(k in names for k in _REQUIRED_LOGIN_COOKIES)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def _login_via_qr_async(timeout: int) -> dict[str, str]:
|
|
69
|
+
# 延迟 import——单测环境没装 playwright 也能 import 本模块
|
|
70
|
+
from goofish_cli.core.browser import goofish_page
|
|
71
|
+
|
|
72
|
+
# cookies={} 是关键:不传空 dict 会走 Session.load 把现有登录态灌进去,
|
|
73
|
+
# passport 就优先走"快速进入"跳过 QR,用户看不到扫码界面。
|
|
74
|
+
async with goofish_page(cookies={}) as page:
|
|
75
|
+
await page.goto(HOME_URL, wait_until="domcontentloaded")
|
|
76
|
+
await page.wait_for_timeout(1500)
|
|
77
|
+
|
|
78
|
+
if not await _wait_for_qr(page):
|
|
79
|
+
return {}
|
|
80
|
+
|
|
81
|
+
logger.info(f"[qr] 请用手机闲鱼 App 扫码登录({timeout}s 超时)")
|
|
82
|
+
|
|
83
|
+
# 轮询 cookies:扫码 → 手机确认 → passport 下发完整 session 一般 < 5s
|
|
84
|
+
deadline = time.monotonic() + timeout
|
|
85
|
+
while time.monotonic() < deadline:
|
|
86
|
+
cookies_list = await page.context.cookies()
|
|
87
|
+
if _has_all_login_cookies(cookies_list):
|
|
88
|
+
logger.info("[qr] 扫码成功,session cookies 已下发")
|
|
89
|
+
return {
|
|
90
|
+
c["name"]: c["value"]
|
|
91
|
+
for c in cookies_list
|
|
92
|
+
if c.get("name") and c.get("value")
|
|
93
|
+
}
|
|
94
|
+
await asyncio.sleep(1.0)
|
|
95
|
+
|
|
96
|
+
logger.warning(f"[qr] {timeout}s 超时未登录成功")
|
|
97
|
+
return {}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _resolve_timeout(timeout: int | None) -> int:
|
|
101
|
+
"""timeout=None 时读 env;env 未设或非整数时回退默认值(不让 CLI 崩)。"""
|
|
102
|
+
if timeout is not None:
|
|
103
|
+
return timeout
|
|
104
|
+
raw = os.environ.get("GOOFISH_QR_TIMEOUT")
|
|
105
|
+
if not raw:
|
|
106
|
+
return _DEFAULT_QR_TIMEOUT
|
|
107
|
+
try:
|
|
108
|
+
return int(raw)
|
|
109
|
+
except ValueError:
|
|
110
|
+
logger.warning(
|
|
111
|
+
f"GOOFISH_QR_TIMEOUT={raw!r} 不是整数,回退默认 {_DEFAULT_QR_TIMEOUT}s"
|
|
112
|
+
)
|
|
113
|
+
return _DEFAULT_QR_TIMEOUT
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def login_via_qr(*, timeout: int | None = None, persist: bool = True) -> dict[str, str]:
|
|
117
|
+
"""阻塞:起 Playwright 让用户扫码,成功返回 cookies 并可选写回磁盘。
|
|
118
|
+
|
|
119
|
+
空 dict 表示超时或 Playwright 异常(Chrome 未装等)。
|
|
120
|
+
"""
|
|
121
|
+
timeout = _resolve_timeout(timeout)
|
|
122
|
+
try:
|
|
123
|
+
cookies = asyncio.run(_login_via_qr_async(timeout))
|
|
124
|
+
except Exception as e: # noqa: BLE001 — Playwright 起不来、Chrome 未装等都走这里
|
|
125
|
+
logger.warning(f"QR 扫码登录失败:{e}")
|
|
126
|
+
return {}
|
|
127
|
+
|
|
128
|
+
if not cookies:
|
|
129
|
+
return {}
|
|
130
|
+
|
|
131
|
+
if persist:
|
|
132
|
+
path = resolve_cookie_path()
|
|
133
|
+
try:
|
|
134
|
+
write_cookies_json(path, cookies)
|
|
135
|
+
logger.info(f"cookie 已写回 {path}")
|
|
136
|
+
except OSError as e:
|
|
137
|
+
logger.warning(f"写回 cookies.json 失败(内存里仍生效):{e}")
|
|
138
|
+
|
|
139
|
+
return cookies
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""测 qr_login 模块。Playwright 走真浏览器不进单测,mock 掉 asyncio.run。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from goofish_cli.core import qr_login
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _fake_run(result):
|
|
12
|
+
"""模拟 asyncio.run:close 掉传入的 coroutine(避免 unawaited warning)后返回结果。"""
|
|
13
|
+
def _inner(coro):
|
|
14
|
+
coro.close()
|
|
15
|
+
return result
|
|
16
|
+
return _inner
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _fake_run_raises(exc):
|
|
20
|
+
def _inner(coro):
|
|
21
|
+
coro.close()
|
|
22
|
+
raise exc
|
|
23
|
+
return _inner
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_login_via_qr_success_writes_disk(tmp_path, monkeypatch):
|
|
27
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(tmp_path / "cookies.json"))
|
|
28
|
+
fake = {"_m_h5_tk": "t", "unb": "u", "cookie2": "c", "sgcookie": "s"}
|
|
29
|
+
with patch.object(qr_login, "asyncio") as mock_async:
|
|
30
|
+
mock_async.run.side_effect = _fake_run(fake)
|
|
31
|
+
out = qr_login.login_via_qr(timeout=10, persist=True)
|
|
32
|
+
|
|
33
|
+
assert out == fake
|
|
34
|
+
assert (tmp_path / "cookies.json").exists()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_login_via_qr_success_no_persist(tmp_path, monkeypatch):
|
|
38
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(tmp_path / "cookies.json"))
|
|
39
|
+
fake = {"_m_h5_tk": "t", "unb": "u", "cookie2": "c"}
|
|
40
|
+
with patch.object(qr_login, "asyncio") as mock_async:
|
|
41
|
+
mock_async.run.side_effect = _fake_run(fake)
|
|
42
|
+
out = qr_login.login_via_qr(timeout=10, persist=False)
|
|
43
|
+
|
|
44
|
+
assert out == fake
|
|
45
|
+
assert not (tmp_path / "cookies.json").exists()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def test_login_via_qr_timeout_returns_empty(tmp_path, monkeypatch):
|
|
49
|
+
monkeypatch.setenv("GOOFISH_COOKIES_PATH", str(tmp_path / "cookies.json"))
|
|
50
|
+
with patch.object(qr_login, "asyncio") as mock_async:
|
|
51
|
+
mock_async.run.side_effect = _fake_run({}) # 模拟超时没拿到 cookies
|
|
52
|
+
out = qr_login.login_via_qr(timeout=5, persist=True)
|
|
53
|
+
|
|
54
|
+
assert out == {}
|
|
55
|
+
# 空结果不应写盘
|
|
56
|
+
assert not (tmp_path / "cookies.json").exists()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_login_via_qr_swallows_playwright_exception():
|
|
60
|
+
with patch.object(qr_login, "asyncio") as mock_async:
|
|
61
|
+
mock_async.run.side_effect = _fake_run_raises(RuntimeError("chrome not installed"))
|
|
62
|
+
out = qr_login.login_via_qr(timeout=5, persist=False)
|
|
63
|
+
assert out == {}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_login_via_qr_respects_env_timeout(monkeypatch):
|
|
67
|
+
"""timeout=None 时读 GOOFISH_QR_TIMEOUT,传给 _login_via_qr_async。"""
|
|
68
|
+
monkeypatch.setenv("GOOFISH_QR_TIMEOUT", "30")
|
|
69
|
+
captured: list[int] = []
|
|
70
|
+
|
|
71
|
+
async def _record(t):
|
|
72
|
+
captured.append(t)
|
|
73
|
+
return {}
|
|
74
|
+
|
|
75
|
+
monkeypatch.setattr(qr_login, "_login_via_qr_async", _record)
|
|
76
|
+
out = qr_login.login_via_qr(persist=False)
|
|
77
|
+
|
|
78
|
+
assert captured == [30]
|
|
79
|
+
assert out == {}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_login_via_qr_env_invalid_falls_back(monkeypatch):
|
|
83
|
+
"""Copilot review:env 非整数不能让命令崩,要 warn + 回退默认。"""
|
|
84
|
+
monkeypatch.setenv("GOOFISH_QR_TIMEOUT", "not-a-number")
|
|
85
|
+
captured: list[int] = []
|
|
86
|
+
|
|
87
|
+
async def _record(t):
|
|
88
|
+
captured.append(t)
|
|
89
|
+
return {}
|
|
90
|
+
|
|
91
|
+
monkeypatch.setattr(qr_login, "_login_via_qr_async", _record)
|
|
92
|
+
# 不应抛 ValueError
|
|
93
|
+
out = qr_login.login_via_qr(persist=False)
|
|
94
|
+
|
|
95
|
+
assert captured == [qr_login._DEFAULT_QR_TIMEOUT]
|
|
96
|
+
assert out == {}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_login_via_qr_explicit_timeout_wins_over_env(monkeypatch):
|
|
100
|
+
"""显式传 timeout 时不读 env。"""
|
|
101
|
+
monkeypatch.setenv("GOOFISH_QR_TIMEOUT", "999")
|
|
102
|
+
captured: list[int] = []
|
|
103
|
+
|
|
104
|
+
async def _record(t):
|
|
105
|
+
captured.append(t)
|
|
106
|
+
return {}
|
|
107
|
+
|
|
108
|
+
monkeypatch.setattr(qr_login, "_login_via_qr_async", _record)
|
|
109
|
+
qr_login.login_via_qr(timeout=42, persist=False)
|
|
110
|
+
|
|
111
|
+
assert captured == [42]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@pytest.mark.parametrize(
|
|
115
|
+
"cookies,expected",
|
|
116
|
+
[
|
|
117
|
+
([{"name": "_m_h5_tk", "value": "a"},
|
|
118
|
+
{"name": "unb", "value": "b"},
|
|
119
|
+
{"name": "cookie2", "value": "c"}], True),
|
|
120
|
+
([{"name": "_m_h5_tk", "value": "a"},
|
|
121
|
+
{"name": "unb", "value": "b"}], False), # 缺 cookie2
|
|
122
|
+
([{"name": "_m_h5_tk", "value": "a"},
|
|
123
|
+
{"name": "unb", "value": ""}, # unb 为空等于没有
|
|
124
|
+
{"name": "cookie2", "value": "c"}], False),
|
|
125
|
+
([], False),
|
|
126
|
+
],
|
|
127
|
+
)
|
|
128
|
+
def test_has_all_login_cookies(cookies, expected):
|
|
129
|
+
assert qr_login._has_all_login_cookies(cookies) 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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|