goofish-cli 0.2.2__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 (81) hide show
  1. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/CHANGELOG.md +44 -1
  2. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/PKG-INFO +1 -1
  3. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/pyproject.toml +1 -1
  4. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/login.py +26 -1
  5. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/mtop.py +23 -8
  6. goofish_cli-0.2.4/src/goofish_cli/core/qr_login.py +139 -0
  7. goofish_cli-0.2.4/src/goofish_cli/core/refresh.py +146 -0
  8. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_mtop.py +19 -0
  9. goofish_cli-0.2.4/tests/test_qr_login.py +129 -0
  10. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_refresh.py +18 -1
  11. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/uv.lock +1 -1
  12. goofish_cli-0.2.2/src/goofish_cli/core/refresh.py +0 -80
  13. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/.env.example +0 -0
  14. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  15. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  16. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/.github/pull_request_template.md +0 -0
  17. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/.github/workflows/ci.yml +0 -0
  18. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/.github/workflows/publish.yml +0 -0
  19. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/.gitignore +0 -0
  20. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/CONTRIBUTING.md +0 -0
  21. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/LICENSE +0 -0
  22. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/NOTICE +0 -0
  23. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/README.md +0 -0
  24. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/docs/architecture.md +0 -0
  25. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/docs/compliance.md +0 -0
  26. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/docs/mcp-setup.md +0 -0
  27. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/examples/README.md +0 -0
  28. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/examples/publish_from_folder.py +0 -0
  29. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/examples/watch_to_autoreply.sh +0 -0
  30. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/__init__.py +0 -0
  31. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/cli.py +0 -0
  32. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/__init__.py +0 -0
  33. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/__init__.py +0 -0
  34. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/reset_guard.py +0 -0
  35. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/auth/status.py +0 -0
  36. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/category/__init__.py +0 -0
  37. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/category/recommend.py +0 -0
  38. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/__init__.py +0 -0
  39. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/delete.py +0 -0
  40. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/get.py +0 -0
  41. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/publish.py +0 -0
  42. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/item/view.py +0 -0
  43. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/location/__init__.py +0 -0
  44. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/location/default.py +0 -0
  45. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/media/__init__.py +0 -0
  46. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/media/upload.py +0 -0
  47. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/__init__.py +0 -0
  48. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/history.py +0 -0
  49. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/list_chats.py +0 -0
  50. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/send.py +0 -0
  51. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/message/watch.py +0 -0
  52. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/search/__init__.py +0 -0
  53. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/commands/search/search.py +0 -0
  54. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/__init__.py +0 -0
  55. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/browser.py +0 -0
  56. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/browser_cookie.py +0 -0
  57. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/errors.py +0 -0
  58. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/guard.py +0 -0
  59. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/limiter.py +0 -0
  60. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/output.py +0 -0
  61. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/registry.py +0 -0
  62. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/session.py +0 -0
  63. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/sign.py +0 -0
  64. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/strategy.py +0 -0
  65. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/token.py +0 -0
  66. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/core/ws.py +0 -0
  67. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/mcp_server.py +0 -0
  68. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/src/goofish_cli/static/goofish_js_version_2.js +0 -0
  69. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/__init__.py +0 -0
  70. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_auth_login.py +0 -0
  71. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_browser_cookie.py +0 -0
  72. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_guard.py +0 -0
  73. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_item_view.py +0 -0
  74. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_limiter.py +0 -0
  75. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_list_chats.py +0 -0
  76. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_publish_payload.py +0 -0
  77. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_registry.py +0 -0
  78. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_search.py +0 -0
  79. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_session_bootstrap.py +0 -0
  80. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_sign.py +0 -0
  81. {goofish_cli-0.2.2 → goofish_cli-0.2.4}/tests/test_ws_codec.py +0 -0
@@ -6,6 +6,47 @@ 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
+
32
+ ## [0.2.3] - 2026-04-22
33
+
34
+ ### Added
35
+ - **自动点击 passport 弹窗"快速进入"免密登录**(`core/refresh.py::_try_quick_enter`):
36
+ v0.2.2 的 Playwright goto 首页在系统 Chrome 里抓出的 cookie 指纹不匹配时会弹
37
+ `#alibaba-login-box` 登录框,服务端拒发完整 session cookie 导致后续 mtop 报
38
+ `FAIL_SYS_SESSION_EXPIRED`。v0.2.3 起自动识别弹窗并点"快速进入"走浏览器免密
39
+ 记忆登录,无需用户交互完成 session 恢复。刷新后追加访问 `/bought` 触发强鉴权页
40
+ 下发完整 `cookie2 / sgcookie / _tb_token_`。
41
+ - `goofish_page()` 已经支持的 `cookies=` 注入点被 refresh 复用,调用方 session
42
+ 和浏览器上下文的登录态保持一致(承接 v0.2.2 Copilot review 的改造)。
43
+
44
+ ### Changed
45
+ - mtop auto-refresh 错误码匹配从"仅 token 层"扩展到**也处理 session 层失效**
46
+ (`FAIL_SYS_SESSION_EXPIRED`)。`_is_token_expired_error` 重命名为
47
+ `_is_recoverable_auth_error`,语义更清晰。`FAIL_SYS_ILLEGAL_ACCESS` 仍不在
48
+ 可恢复列表(风控层问题,刷 cookie 救不了)。
49
+
9
50
  ## [0.2.2] - 2026-04-22
10
51
 
11
52
  ### Added
@@ -86,7 +127,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
86
127
  - 本版本需要用户手动从浏览器导入 cookie(含 `unb` / `_m_h5_tk` / `x5sec`)
87
128
  - 遇到 `RGV587_ERROR` 风控时,需在浏览器完成滑块验证并**重新导出**带 `x5sec` 的 cookie
88
129
 
89
- [Unreleased]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.2...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
132
+ [0.2.3]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.2...v0.2.3
90
133
  [0.2.2]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.1...v0.2.2
91
134
  [0.2.1]: https://github.com/fancyboi999/goofish-cli/compare/v0.2.0...v0.2.1
92
135
  [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.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "goofish-cli"
7
- version = "0.2.2"
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 被吞,用户会困惑。
@@ -74,8 +74,10 @@ def call(
74
74
  ) -> dict[str, Any]:
75
75
  """调用 mtop 接口。返回原始 JSON。失败抛 GoofishError 子类。
76
76
 
77
- `_auto_refresh=True`:遇到 `FAIL_SYS_TOKEN_EXOIRED` 时自动用 Playwright goto
78
- 闲鱼首页刷一次 cookie 再重试一次。递归调用时用 False 避免死循环。
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
- # 只对 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)):
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}] 检测到 _m_h5_tk 过期,尝试 Playwright goto 刷新 cookie…")
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 _is_token_expired_error(e: AuthRequiredError) -> bool:
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 ("FAIL_SYS_TOKEN_EXOIRED", "FAIL_SYS_TOKEN_EMPTY", "令牌过期"))
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,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,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
@@ -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
@@ -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:
@@ -307,7 +307,7 @@ wheels = [
307
307
 
308
308
  [[package]]
309
309
  name = "goofish-cli"
310
- version = "0.2.2"
310
+ version = "0.2.4"
311
311
  source = { editable = "." }
312
312
  dependencies = [
313
313
  { name = "browser-cookie3" },
@@ -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