Angiin

perf: 手机验证码登录流程速度优化 + 手机号确认强制化

- send_phone_code: 合并重叠的 UI 等待逻辑,固定 sleep 改为事件驱动轮询倒计时
- submit_phone_code: type_text delay_ms=0 替代 JS setValue(保证 isTrusted=true)
- cmd_phone_login: 去掉 close_page 与 verify-code 保持一致
- SKILL.md: 强制每次登录必须向用户确认手机号,禁止自动填入
@@ -339,7 +339,7 @@ def cmd_phone_login(args: argparse.Namespace) -> None: @@ -339,7 +339,7 @@ def cmd_phone_login(args: argparse.Namespace) -> None:
339 exit_code=0 if success else 2, 339 exit_code=0 if success else 2,
340 ) 340 )
341 finally: 341 finally:
342 - browser.close_page(page) 342 + # 不关闭 tab——与 verify-code 一致,保留页面供重试
343 browser.close() 343 browser.close()
344 344
345 345
@@ -36,6 +36,20 @@ from .urls import EXPLORE_URL @@ -36,6 +36,20 @@ from .urls import EXPLORE_URL
36 logger = logging.getLogger(__name__) 36 logger = logging.getLogger(__name__)
37 37
38 38
  39 +def _wait_for_countdown(page: Page, timeout: float = 5.0) -> None:
  40 + """等待"获取验证码"按钮出现倒计时数字,确认验证码已发送。
  41 +
  42 + 轮询按钮文字直到包含数字(如 "60s"),超时则抛出 RateLimitError。
  43 + """
  44 + deadline = time.monotonic() + timeout
  45 + while time.monotonic() < deadline:
  46 + btn_text = page.get_element_text(GET_CODE_BUTTON) or ""
  47 + if any(ch.isdigit() for ch in btn_text):
  48 + return
  49 + time.sleep(0.3)
  50 + raise RateLimitError()
  51 +
  52 +
39 def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None: 53 def _wait_for_auth_ui(page: Page, timeout: float = 8.0) -> None:
40 """等待认证 UI 出现,替代固定延迟。 54 """等待认证 UI 出现,替代固定延迟。
41 55
@@ -171,20 +185,26 @@ def send_phone_code(page: Page, phone: str) -> bool: @@ -171,20 +185,26 @@ def send_phone_code(page: Page, phone: str) -> bool:
171 """ 185 """
172 page.navigate(EXPLORE_URL) 186 page.navigate(EXPLORE_URL)
173 page.wait_for_load() 187 page.wait_for_load()
174 - sleep_random(1500, 2500) 188 +
  189 + # 直接等待登录容器出现(合并了 _wait_for_auth_ui 的逻辑,避免重复等待)
  190 + try:
  191 + page.wait_for_element(LOGIN_CONTAINER, timeout=10.0)
  192 + except Exception as exc:
  193 + # 可能已登录(没有登录容器),检查登录状态
  194 + if page.has_element(LOGIN_STATUS):
  195 + return False
  196 + raise RuntimeError("找不到登录表单") from exc
175 197
176 if page.has_element(LOGIN_STATUS): 198 if page.has_element(LOGIN_STATUS):
177 return False 199 return False
178 200
179 - # 等待登录弹窗出现  
180 - page.wait_for_element(LOGIN_CONTAINER, timeout=15.0)  
181 - sleep_random(500, 800) 201 + sleep_random(200, 400)
182 202
183 # 点击手机号输入框并逐字输入 203 # 点击手机号输入框并逐字输入
184 page.click_element(PHONE_INPUT) 204 page.click_element(PHONE_INPUT)
185 sleep_random(200, 400) 205 sleep_random(200, 400)
186 page.type_text(phone, delay_ms=80) 206 page.type_text(phone, delay_ms=80)
187 - sleep_random(500, 800) 207 + sleep_random(200, 400)
188 208
189 # 先勾选用户协议,再点获取验证码 209 # 先勾选用户协议,再点获取验证码
190 if not page.has_element(AGREE_CHECKBOX_CHECKED): 210 if not page.has_element(AGREE_CHECKBOX_CHECKED):
@@ -193,12 +213,9 @@ def send_phone_code(page: Page, phone: str) -> bool: @@ -193,12 +213,9 @@ def send_phone_code(page: Page, phone: str) -> bool:
193 213
194 # 点击"获取验证码" 214 # 点击"获取验证码"
195 page.click_element(GET_CODE_BUTTON) 215 page.click_element(GET_CODE_BUTTON)
196 - sleep_random(2000, 2500)  
197 216
198 - # 检测按钮是否变为倒计时(成功发送后按钮文字会包含数字秒数)  
199 - btn_text = page.get_element_text(GET_CODE_BUTTON) or ""  
200 - if not any(ch.isdigit() for ch in btn_text):  
201 - raise RateLimitError() 217 + # 事件驱动:轮询按钮文字直到出现倒计时数字,替代固定 2-2.5s 等待
  218 + _wait_for_countdown(page)
202 219
203 logger.info("验证码已发送至 %s", phone[:3] + "****" + phone[-4:]) 220 logger.info("验证码已发送至 %s", phone[:3] + "****" + phone[-4:])
204 return True 221 return True
@@ -214,9 +231,9 @@ def submit_phone_code(page: Page, code: str) -> bool: @@ -214,9 +231,9 @@ def submit_phone_code(page: Page, code: str) -> bool:
214 Returns: 231 Returns:
215 True 登录成功,False 失败(超时或验证码错误)。 232 True 登录成功,False 失败(超时或验证码错误)。
216 """ 233 """
217 - # 点击验证码输入框,先清空已有内容(防止重试时追加导致验证码错误),再逐字输入 234 + # 点击验证码输入框,先清空再用 CDP 键盘事件逐字输入(isTrusted=true,React 能识别)
218 page.click_element(CODE_INPUT) 235 page.click_element(CODE_INPUT)
219 - sleep_random(300, 500) 236 + sleep_random(100, 200)
220 page.evaluate( 237 page.evaluate(
221 f"""(() => {{ 238 f"""(() => {{
222 const el = document.querySelector({json.dumps(CODE_INPUT)}); 239 const el = document.querySelector({json.dumps(CODE_INPUT)});
@@ -229,12 +246,12 @@ def submit_phone_code(page: Page, code: str) -> bool: @@ -229,12 +246,12 @@ def submit_phone_code(page: Page, code: str) -> bool:
229 }} 246 }}
230 }})()""" 247 }})()"""
231 ) 248 )
232 - page.type_text(code, delay_ms=100)  
233 - sleep_random(500, 800) 249 + page.type_text(code, delay_ms=0)
  250 + sleep_random(100, 200)
234 251
235 # 点击登录按钮 252 # 点击登录按钮
236 page.click_element(PHONE_LOGIN_SUBMIT) 253 page.click_element(PHONE_LOGIN_SUBMIT)
237 - sleep_random(1000, 2000) 254 + sleep_random(500, 1000)
238 255
239 # 检查是否有错误提示 256 # 检查是否有错误提示
240 err = page.get_element_text(LOGIN_ERR_MSG) 257 err = page.get_element_text(LOGIN_ERR_MSG)
@@ -125,14 +125,18 @@ python scripts/cli.py wait-login @@ -125,14 +125,18 @@ python scripts/cli.py wait-login
125 125
126 #### 方式 B:手机验证码登录(无界面服务器,分两步) 126 #### 方式 B:手机验证码登录(无界面服务器,分两步)
127 127
128 -**执行前必须先向用户索取手机号,不得自行假设或跳过此步。** 128 +**⚠️ 强制要求:必须先向用户确认手机号,即使上下文中已有手机号也不得跳过。**
  129 +- 用户可能要登录不同账号,手机号可能已变更。
  130 +- **禁止从历史对话、记忆或上下文中自动填入手机号。**
  131 +- **每次登录都必须明确向用户询问并得到确认后才能执行 `send-code`。**
129 132
130 -**第一步** — 向用户询问手机号,然后发送验证码: 133 +**第一步** — 向用户确认手机号,然后发送验证码:
131 134
132 -> 请先问用户:"请提供您的手机号(不含国家码,如 13800138000)",获得回复后再执行以下命令。 135 +> **必须先问用户**:"请提供您要登录的手机号(不含国家码,如 13800138000)"。
  136 +> 收到用户明确回复手机号后,才能执行以下命令。**不得跳过此步。**
133 137
134 ```bash 138 ```bash
135 -python scripts/cli.py send-code --phone <用户提供的手机号> 139 +python scripts/cli.py send-code --phone <用户确认的手机号>
136 ``` 140 ```
137 - 自动填写手机号、勾选用户协议、点击"获取验证码"。 141 - 自动填写手机号、勾选用户协议、点击"获取验证码"。
138 - Chrome 页面保持打开,等待下一步。 142 - Chrome 页面保持打开,等待下一步。