377 lines
12 KiB
Python
377 lines
12 KiB
Python
"""
|
||
微信自动登录(u2)— 检测未登录 → 账号密码登录
|
||
|
||
适配:微信 8.0.56+ / Redmi Note 13 (1080x2400)
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
from typing import Any, Dict, List
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
WX_PKG = "com.tencent.mm"
|
||
|
||
LOGIN_PAGE_MARKERS = [
|
||
"登录", "注册", "用手机号登录", "用微信号", "QQ号", "邮箱",
|
||
"Sign in", "Log in", "Log In",
|
||
]
|
||
MAIN_MARKERS = ["通讯录", "发现", "我", "微信"]
|
||
|
||
# 红米 Note 13 1080x2400 近似坐标(降级点击)
|
||
COORD_LOGIN_BTN = (540, 2050)
|
||
COORD_WECHAT_ID_LINK = (540, 1680)
|
||
COORD_FORM_LOGIN = (540, 2180)
|
||
|
||
|
||
class WeChatAutoLogin:
|
||
"""微信登录状态检测 + 密码登录"""
|
||
|
||
def __init__(self, skill):
|
||
self.s = skill
|
||
|
||
def _xml(self) -> str:
|
||
try:
|
||
return self.s.d.dump_hierarchy() or ""
|
||
except Exception as exc:
|
||
logger.warning(f"[auto_login] dump_hierarchy 失败: {exc}")
|
||
return ""
|
||
|
||
def _activity(self) -> str:
|
||
try:
|
||
cur = self.s.d.app_current()
|
||
return str(cur.get("activity") or "")
|
||
except Exception:
|
||
return ""
|
||
|
||
def _ui_sample(self, xml: str = "", limit: int = 24) -> List[str]:
|
||
src = xml or self._xml()
|
||
texts = re.findall(r'text="([^"]{1,48})"', src)
|
||
descs = re.findall(r'content-desc="([^"]{1,48})"', src)
|
||
seen: set[str] = set()
|
||
out: List[str] = []
|
||
for t in texts + descs:
|
||
t = t.strip()
|
||
if not t or t in seen:
|
||
continue
|
||
seen.add(t)
|
||
out.append(t)
|
||
if len(out) >= limit:
|
||
break
|
||
return out
|
||
|
||
def _tap(self, x: int, y: int) -> None:
|
||
try:
|
||
self.s.d.click(x, y)
|
||
except Exception:
|
||
self.s.click(x, y)
|
||
|
||
def _cold_start_wechat(self) -> None:
|
||
try:
|
||
self.s.d.app_stop(WX_PKG)
|
||
self.s.sleep(0.8)
|
||
except Exception:
|
||
pass
|
||
self.s.launch()
|
||
self.s.sleep(2.5)
|
||
|
||
def _dismiss_dialogs(self) -> None:
|
||
for label in (
|
||
"允许", "同意", "同意并继续", "同意并登录", "我知道了",
|
||
"确定", "继续", "Allow", "OK",
|
||
):
|
||
if self.s.click_text(label, timeout=1) or self.s.click_contains(label, timeout=1):
|
||
self.s.sleep(0.8)
|
||
|
||
def check_login_state(self) -> Dict[str, Any]:
|
||
"""检测是否已登录微信主界面"""
|
||
try:
|
||
if not self.s.is_running():
|
||
self.s.launch()
|
||
else:
|
||
self.s.d.app_start(WX_PKG)
|
||
self.s.sleep(2.0)
|
||
self._dismiss_dialogs()
|
||
|
||
xml = self._xml()
|
||
activity = self._activity()
|
||
|
||
main_hits = sum(1 for m in ("通讯录", "发现", "我") if m in xml)
|
||
if main_hits >= 2 or (
|
||
"LauncherUI" in activity and "通讯录" in xml
|
||
):
|
||
return {
|
||
"logged_in": True,
|
||
"state": "logged_in",
|
||
"activity": activity,
|
||
}
|
||
|
||
if any(m in xml for m in LOGIN_PAGE_MARKERS) or any(
|
||
k in activity for k in ("Login", "Welcome", "MobileLogin", "LoginUI")
|
||
):
|
||
return {
|
||
"logged_in": False,
|
||
"state": "login_page",
|
||
"activity": activity,
|
||
"ui_sample": self._ui_sample(xml),
|
||
}
|
||
|
||
if self.s.click_text("通讯录", timeout=2):
|
||
return {
|
||
"logged_in": True,
|
||
"state": "logged_in",
|
||
"activity": activity,
|
||
}
|
||
|
||
return {
|
||
"logged_in": False,
|
||
"state": "unknown",
|
||
"activity": activity,
|
||
"ui_sample": self._ui_sample(xml),
|
||
}
|
||
except Exception as exc:
|
||
logger.exception(f"[auto_login] check_login_state: {exc}")
|
||
return {"logged_in": False, "state": "error", "error": str(exc)}
|
||
|
||
def _goto_password_login(self) -> None:
|
||
"""从欢迎页/登录页进入账号密码输入界面"""
|
||
self._cold_start_wechat()
|
||
self._dismiss_dialogs()
|
||
|
||
xml = self._xml()
|
||
if xml.count("EditText") >= 2:
|
||
return
|
||
|
||
clicked = False
|
||
for label in ("登录", "Log in", "Log In", "Sign in"):
|
||
if self.s.click_text(label, timeout=2) or self.s.click_contains(label, timeout=2):
|
||
clicked = True
|
||
self.s.sleep(1.2)
|
||
break
|
||
if not clicked:
|
||
self._tap(*COORD_LOGIN_BTN)
|
||
self.s.sleep(1.2)
|
||
|
||
self._dismiss_dialogs()
|
||
|
||
for label in (
|
||
"用微信号/QQ号/邮箱登录",
|
||
"用微信号/QQ号/邮箱",
|
||
"用微信号/QQ/邮箱登录",
|
||
"用微信号",
|
||
"QQ号/邮箱",
|
||
"密码登录",
|
||
"用密码登录",
|
||
"更多登录方式",
|
||
"其他登录方式",
|
||
):
|
||
if self.s.click_contains(label, timeout=2):
|
||
logger.info(f"[auto_login] 进入登录: {label}")
|
||
self.s.sleep(1.2)
|
||
break
|
||
else:
|
||
if "手机号" in self._xml() and "EditText" in self._xml():
|
||
self._tap(*COORD_WECHAT_ID_LINK)
|
||
self.s.sleep(1.2)
|
||
|
||
for label in (
|
||
"用微信号/QQ号/邮箱登录",
|
||
"用微信号/QQ号/邮箱",
|
||
"密码登录",
|
||
):
|
||
if self.s.click_contains(label, timeout=1):
|
||
self.s.sleep(1.0)
|
||
break
|
||
|
||
def _edit_count(self) -> int:
|
||
try:
|
||
edits = self.s.d(className="android.widget.EditText")
|
||
return edits.count if hasattr(edits, "count") else len(list(edits))
|
||
except Exception:
|
||
return 0
|
||
|
||
def _fill_edittexts(self, account: str, password: str) -> bool:
|
||
"""填写账号/密码(优先 EditText 顺序,降级 hint/坐标)"""
|
||
d = self.s.d
|
||
count = self._edit_count()
|
||
|
||
if count >= 2:
|
||
try:
|
||
edits = d(className="android.widget.EditText")
|
||
edits[0].click()
|
||
self.s.sleep(0.3)
|
||
edits[0].clear_text()
|
||
edits[0].set_text(account)
|
||
self.s.sleep(0.4)
|
||
edits[1].click()
|
||
self.s.sleep(0.3)
|
||
edits[1].clear_text()
|
||
edits[1].set_text(password)
|
||
return True
|
||
except Exception as exc:
|
||
logger.warning(f"[auto_login] EditText[0,1] 失败: {exc}")
|
||
|
||
if count == 1:
|
||
try:
|
||
edits = d(className="android.widget.EditText")
|
||
edits[0].click()
|
||
self.s.sleep(0.3)
|
||
edits[0].clear_text()
|
||
edits[0].set_text(account)
|
||
self.s.sleep(0.5)
|
||
if self.s.click_text("下一步", timeout=2) or self.s.click_contains("下一步", timeout=2):
|
||
self.s.sleep(1.5)
|
||
pwd = d(className="android.widget.EditText")
|
||
if pwd.count >= 1:
|
||
pwd[0].click()
|
||
pwd[0].clear_text()
|
||
pwd[0].set_text(password)
|
||
return True
|
||
except Exception as exc:
|
||
logger.warning(f"[auto_login] 分步登录失败: {exc}")
|
||
|
||
filled_account = False
|
||
for hint in ("微信号", "QQ号", "邮箱", "账号", "手机号", "WeChat ID"):
|
||
if self.s.click_contains(hint, timeout=1):
|
||
self.s.input_text(account, clear=True)
|
||
filled_account = True
|
||
self.s.sleep(0.5)
|
||
break
|
||
if not filled_account and count >= 1:
|
||
try:
|
||
d(className="android.widget.EditText")[0].set_text(account)
|
||
filled_account = True
|
||
except Exception:
|
||
pass
|
||
|
||
for hint in ("密码", "请输入密码", "Password"):
|
||
if self.s.click_contains(hint, timeout=1):
|
||
self.s.input_text(password, clear=True)
|
||
return True
|
||
|
||
if count >= 2:
|
||
try:
|
||
edits = d(className="android.widget.EditText")
|
||
edits[1].set_text(password)
|
||
return bool(account)
|
||
except Exception:
|
||
pass
|
||
|
||
return False
|
||
|
||
def _submit_login(self) -> bool:
|
||
"""提交登录(避免误点欢迎页「登录」)"""
|
||
if self._edit_count() >= 1:
|
||
for label in ("同意并登录", "登录", "Log In", "Sign in"):
|
||
elems = self.s.d(textContains=label)
|
||
try:
|
||
n = elems.count if hasattr(elems, "count") else len(list(elems))
|
||
except Exception:
|
||
n = 0
|
||
if n == 1 and self.s.click_contains(label, timeout=2):
|
||
return True
|
||
if n > 1:
|
||
try:
|
||
elems[n - 1].click()
|
||
return True
|
||
except Exception:
|
||
pass
|
||
self._tap(*COORD_FORM_LOGIN)
|
||
return True
|
||
return (
|
||
self.s.click_text("登录", timeout=2)
|
||
or self.s.click_contains("登录", timeout=2)
|
||
)
|
||
|
||
def login_by_password(self, account: str, password: str) -> Dict[str, Any]:
|
||
"""
|
||
账号密码登录微信。
|
||
account: 微信号 / 手机号 / 邮箱(如 Lytiao1)
|
||
password: 登录密码
|
||
"""
|
||
account = (account or "").strip()
|
||
password = password or ""
|
||
steps: List[str] = []
|
||
|
||
if not account or not password:
|
||
return {
|
||
"success": False,
|
||
"error": "缺少 account 或 password",
|
||
"steps": steps,
|
||
}
|
||
|
||
state = self.check_login_state()
|
||
steps.append(f"check:{state.get('state')}")
|
||
if state.get("logged_in"):
|
||
return {
|
||
"success": True,
|
||
"already_logged_in": True,
|
||
"state": state,
|
||
"account": account,
|
||
"steps": steps,
|
||
}
|
||
|
||
try:
|
||
self._goto_password_login()
|
||
steps.append("goto_password_login")
|
||
steps.append(f"edits:{self._edit_count()}")
|
||
|
||
if not self._fill_edittexts(account, password):
|
||
steps.append("fill_edittexts_partial")
|
||
else:
|
||
steps.append("fill_edittexts_ok")
|
||
|
||
submitted = self._submit_login()
|
||
steps.append(f"submit:{submitted}")
|
||
self.s.sleep(4.0)
|
||
self._dismiss_dialogs()
|
||
self.s.sleep(2.0)
|
||
|
||
final = self.check_login_state()
|
||
steps.append(f"final:{final.get('state')}")
|
||
|
||
if final.get("logged_in"):
|
||
return {
|
||
"success": True,
|
||
"already_logged_in": False,
|
||
"state": final,
|
||
"account": account,
|
||
"steps": steps,
|
||
}
|
||
|
||
xml = self._xml()
|
||
need_verify = any(
|
||
k in xml
|
||
for k in ("验证", "安全验证", "滑块", "验证码", "人脸识别", "新设备")
|
||
)
|
||
return {
|
||
"success": False,
|
||
"error": "登录未完成,可能需要安全验证" if need_verify else "未能进入微信主界面",
|
||
"need_verify": need_verify,
|
||
"state": final,
|
||
"account": account,
|
||
"steps": steps,
|
||
"activity": final.get("activity", ""),
|
||
"ui_sample": self._ui_sample(xml),
|
||
}
|
||
except Exception as exc:
|
||
logger.exception(f"[auto_login] login_by_password: {exc}")
|
||
return {
|
||
"success": False,
|
||
"error": str(exc),
|
||
"account": account,
|
||
"steps": steps,
|
||
"ui_sample": self._ui_sample(),
|
||
}
|
||
|
||
def ensure_logged_in(self, account: str, password: str) -> Dict[str, Any]:
|
||
"""未登录则自动密码登录"""
|
||
state = self.check_login_state()
|
||
if state.get("logged_in"):
|
||
return {"success": True, "action": "skip", "state": state}
|
||
result = self.login_by_password(account, password)
|
||
result["action"] = "login"
|
||
return result
|