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.
Files changed (80) hide show
  1. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/CHANGELOG.md +25 -1
  2. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/PKG-INFO +1 -1
  3. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/pyproject.toml +1 -1
  4. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/login.py +26 -1
  5. goofish_cli-0.2.4/src/goofish_cli/core/qr_login.py +139 -0
  6. goofish_cli-0.2.4/tests/test_qr_login.py +129 -0
  7. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/uv.lock +1 -1
  8. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.env.example +0 -0
  9. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  10. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  11. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/pull_request_template.md +0 -0
  12. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/workflows/ci.yml +0 -0
  13. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.github/workflows/publish.yml +0 -0
  14. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/.gitignore +0 -0
  15. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/CONTRIBUTING.md +0 -0
  16. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/LICENSE +0 -0
  17. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/NOTICE +0 -0
  18. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/README.md +0 -0
  19. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/docs/architecture.md +0 -0
  20. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/docs/compliance.md +0 -0
  21. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/docs/mcp-setup.md +0 -0
  22. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/examples/README.md +0 -0
  23. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/examples/publish_from_folder.py +0 -0
  24. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/examples/watch_to_autoreply.sh +0 -0
  25. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/__init__.py +0 -0
  26. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/cli.py +0 -0
  27. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/__init__.py +0 -0
  28. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/__init__.py +0 -0
  29. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/reset_guard.py +0 -0
  30. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/status.py +0 -0
  31. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/category/__init__.py +0 -0
  32. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/category/recommend.py +0 -0
  33. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/__init__.py +0 -0
  34. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/delete.py +0 -0
  35. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/get.py +0 -0
  36. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/publish.py +0 -0
  37. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/view.py +0 -0
  38. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/location/__init__.py +0 -0
  39. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/location/default.py +0 -0
  40. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/media/__init__.py +0 -0
  41. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/media/upload.py +0 -0
  42. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/__init__.py +0 -0
  43. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/history.py +0 -0
  44. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/list_chats.py +0 -0
  45. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/send.py +0 -0
  46. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/watch.py +0 -0
  47. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/search/__init__.py +0 -0
  48. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/commands/search/search.py +0 -0
  49. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/__init__.py +0 -0
  50. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/browser.py +0 -0
  51. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/browser_cookie.py +0 -0
  52. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/errors.py +0 -0
  53. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/guard.py +0 -0
  54. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/limiter.py +0 -0
  55. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/mtop.py +0 -0
  56. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/output.py +0 -0
  57. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/refresh.py +0 -0
  58. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/registry.py +0 -0
  59. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/session.py +0 -0
  60. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/sign.py +0 -0
  61. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/strategy.py +0 -0
  62. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/token.py +0 -0
  63. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/core/ws.py +0 -0
  64. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/mcp_server.py +0 -0
  65. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/src/goofish_cli/static/goofish_js_version_2.js +0 -0
  66. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/__init__.py +0 -0
  67. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_auth_login.py +0 -0
  68. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_browser_cookie.py +0 -0
  69. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_guard.py +0 -0
  70. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_item_view.py +0 -0
  71. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_limiter.py +0 -0
  72. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_list_chats.py +0 -0
  73. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_mtop.py +0 -0
  74. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_publish_payload.py +0 -0
  75. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_refresh.py +0 -0
  76. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_registry.py +0 -0
  77. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_search.py +0 -0
  78. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_session_bootstrap.py +0 -0
  79. {goofish_cli-0.2.3 → goofish_cli-0.2.4}/tests/test_sign.py +0 -0
  80. {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.3...HEAD
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
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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "goofish-cli"
7
- version = "0.2.3"
7
+ version = "0.2.4"
8
8
  description = "闲鱼 CLI — 支持 MCP,未来支持 Claude Skills。为 AI Agent 提供闲鱼自动化基础能力。"
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -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 source is None:
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
@@ -307,7 +307,7 @@ wheels = [
307
307
 
308
308
  [[package]]
309
309
  name = "goofish-cli"
310
- version = "0.2.3"
310
+ version = "0.2.4"
311
311
  source = { editable = "." }
312
312
  dependencies = [
313
313
  { name = "browser-cookie3" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes