744 lines
30 KiB
Python
744 lines
30 KiB
Python
"""Playwright RPA:SmartCampus HR 平台登录流程。
|
||
|
||
v1.1.31:从 platform_collector.py 拆分出来的纯登录流程子模块。
|
||
包含 12 个函数:
|
||
- _try_auto_sign_in / _wait_for_login_inputs_filled / _is_login_page_url_for_user
|
||
- _wait_for_manual_login / _wait_for_url_stable / _fill_root_login
|
||
- _fill_spa_login_from_env / _is_dev_mode_panel_open / _resolve_target_industry
|
||
- _click_dev_mode_sign_in_for_industry / _click_campus_link / _fill_spa_login
|
||
|
||
设计要点(保持 v1.1.30 不变):
|
||
- 所有 facade 公开符号(HUMAN_POLL_INTERVAL / LoginTimeoutError / _goto / _dump_page /
|
||
_is_logged_in / _is_spa_routed)通过 `from service import platform_collector as _pc` lazy import
|
||
→ 保证测试 `pc.HUMAN_POLL_INTERVAL = 0.001` monkey-patch 生效
|
||
- `HUMAN_WAIT_TIMEOUT` / `SPA_LOGIN_*` / `SIDEBAR_*` / `URL_STABLE_*` 等不会动态 patch 的常量
|
||
从 `util.constants` 顶部 import
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import time
|
||
|
||
from util.constants import (
|
||
HUMAN_PROMPT_INTERVAL,
|
||
HUMAN_WAIT_TIMEOUT,
|
||
SIDEBAR_LINK_TIMEOUT_MS,
|
||
SPA_LOGIN_INPUT_TIMEOUT_MS,
|
||
SPA_LOGIN_SETTLE_MS,
|
||
SPA_LOGIN_URL_TIMEOUT_MS,
|
||
URL_AFTER_LOGIN_TIMEOUT_MS,
|
||
URL_STABLE_POLL_S,
|
||
)
|
||
from util.logging_setup import get_skill_logger
|
||
|
||
logger = get_skill_logger()
|
||
|
||
|
||
def _try_auto_sign_in(page, wait_input_sec: int = 2) -> bool:
|
||
"""v1.1.21:检测登录页 demo/123456 是否已预填,有则自动点 Sign in。
|
||
|
||
v1.1.24:扩展到支持 SPA 二级登录页(/industries/campus/smartcampus/login),
|
||
按钮文本可能是中文「登录」而非英文「Sign in」。
|
||
|
||
适用场景:
|
||
- 根域登录页(https://sandbox.jc2009.com/):按钮「Sign in」
|
||
- SPA 二级登录页(/industries/campus/smartcampus/login):按钮「登录」
|
||
默认有 demo/123456(或 18677308191 等真实账号)预填在 input。
|
||
|
||
行为:
|
||
1. 等 input 出现,最长 wait_input_sec 秒(默认 2s)
|
||
2. 检查 input 是否都有值
|
||
3. 有值 → 点 Sign in / 登录 → return True
|
||
4. 无值 → return False(交给 _wait_for_login_inputs_filled 接管)
|
||
|
||
不会自动 fill(如果没值就放弃,让用户自己输)。
|
||
"""
|
||
try:
|
||
page.wait_for_selector(
|
||
"#jc-user, #jc-pass, input[type='tel'], input[type='password']",
|
||
timeout=wait_input_sec * 1000,
|
||
)
|
||
except Exception:
|
||
logger.info(
|
||
"platform_collector: %ds 内未出现登录 input,跳过自动登录",
|
||
wait_input_sec,
|
||
)
|
||
return False
|
||
|
||
# 多种 input selector 优先级
|
||
user_input = None
|
||
for sel in ("#jc-user", "input[type='tel']", "input[autocomplete='username']"):
|
||
loc = page.locator(sel).first
|
||
if loc.count() > 0:
|
||
user_input = loc
|
||
break
|
||
if user_input is None:
|
||
return False
|
||
|
||
pass_input = None
|
||
for sel in ("#jc-pass", "input[type='password']", "input[autocomplete='current-password']"):
|
||
loc = page.locator(sel).first
|
||
if loc.count() > 0:
|
||
pass_input = loc
|
||
break
|
||
if pass_input is None:
|
||
return False
|
||
|
||
user_val = (user_input.input_value() or "").strip()
|
||
pass_val = (pass_input.input_value() or "").strip()
|
||
|
||
if not user_val or not pass_val:
|
||
logger.info(
|
||
"platform_collector: 登录页 input 为空(user=%r, pass=%r),"
|
||
"等用户输入",
|
||
bool(user_val), bool(pass_val),
|
||
)
|
||
return False
|
||
|
||
# 多种按钮文本优先级:英文 Sign in > 中文登录 > submit
|
||
btn = None
|
||
btn_text_used = ""
|
||
for txt in ("Sign in", "登录", "Login", "signin"):
|
||
loc = page.locator(f"button:has-text('{txt}')").first
|
||
if loc.count() > 0:
|
||
btn = loc
|
||
btn_text_used = txt
|
||
break
|
||
if btn is None:
|
||
btn = page.locator("button[type='submit']").first
|
||
btn_text_used = "submit"
|
||
if btn is None or btn.count() == 0:
|
||
logger.warning("platform_collector: 未找到登录按钮")
|
||
return False
|
||
|
||
logger.info(
|
||
"platform_collector: 登录页已预填账号(user=%r),自动点 %s 按钮",
|
||
user_val, btn_text_used,
|
||
)
|
||
btn.click()
|
||
return True
|
||
|
||
|
||
def _wait_for_login_inputs_filled(page, wait_sec: int = HUMAN_WAIT_TIMEOUT) -> bool:
|
||
"""v1.1.21:等用户输入完账号密码,然后自动点登录按钮。
|
||
|
||
v1.1.26:按用户最新要求改回**自动点登录**行为——
|
||
「到了 /industries/campus/smartcampus/login 整个页面,有账号和密码可用直接点登录跳转」,
|
||
不再等用户自己点。
|
||
|
||
优先级(任一 selector 命中即视为有 input):
|
||
- user: `#jc-user` / `input[type='tel']` / `input[autocomplete='username']`
|
||
- pass: `#jc-pass` / `input[type='password']` / `input[autocomplete='current-password']`
|
||
|
||
按钮文本优先级:`Sign in` / `登录` / `Login` / `signin` / `submit`。
|
||
"""
|
||
# Lazy import: 测试通过 pc.HUMAN_POLL_INTERVAL = X 加速,需要 facade attr lookup
|
||
from service import platform_collector as _pc
|
||
|
||
deadline = time.monotonic() + wait_sec
|
||
last_prompt_at = time.monotonic()
|
||
last_state_log = time.monotonic()
|
||
|
||
while time.monotonic() < deadline:
|
||
cur_url = page.url
|
||
|
||
# 已不在登录页(用户可能已点登录完成跳转)
|
||
if not _is_login_page_url_for_user(cur_url):
|
||
logger.info(
|
||
"platform_collector: URL 已离开登录页(%s),登录完成", cur_url,
|
||
)
|
||
return True
|
||
|
||
# 探测 input(多 selector 优先级)
|
||
user_val, pass_val = "", ""
|
||
user_loc, pass_loc = None, None
|
||
for sel in ("#jc-user", "input[type='tel']", "input[autocomplete='username']"):
|
||
loc = page.locator(sel).first
|
||
if loc.count() > 0:
|
||
user_loc = loc
|
||
user_val = (loc.input_value() or "").strip()
|
||
break
|
||
for sel in ("#jc-pass", "input[type='password']", "input[autocomplete='current-password']"):
|
||
loc = page.locator(sel).first
|
||
if loc.count() > 0:
|
||
pass_loc = loc
|
||
pass_val = (loc.input_value() or "").strip()
|
||
break
|
||
|
||
now = time.monotonic()
|
||
if now - last_state_log >= HUMAN_PROMPT_INTERVAL:
|
||
last_state_log = now
|
||
logger.info(
|
||
"platform_collector: 等待用户输入 user=%r pass=%r (URL=%s)",
|
||
bool(user_val), bool(pass_val), cur_url,
|
||
)
|
||
|
||
# input 都有值 → 自动点登录按钮(v1.1.26 关键:用户最新要求自动点)
|
||
if user_val and pass_val:
|
||
btn = None
|
||
btn_text_used = ""
|
||
for txt in ("Sign in", "登录", "Login", "signin"):
|
||
loc = page.locator(f"button:has-text('{txt}')").first
|
||
if loc.count() > 0:
|
||
btn = loc
|
||
btn_text_used = txt
|
||
break
|
||
if btn is None:
|
||
btn = page.locator("button[type='submit']").first
|
||
btn_text_used = "submit"
|
||
if btn is not None and btn.count() > 0:
|
||
logger.info(
|
||
"platform_collector: ✅ 账号密码已填好(user=%r),自动点 %s 按钮",
|
||
user_val, btn_text_used,
|
||
)
|
||
btn.click()
|
||
return True
|
||
logger.warning("platform_collector: input 有值但找不到登录按钮")
|
||
|
||
# 提醒用户
|
||
if now - last_prompt_at >= HUMAN_PROMPT_INTERVAL:
|
||
last_prompt_at = now
|
||
remaining = int(deadline - now)
|
||
if user_val and pass_val:
|
||
# 上面 click 应该已经 return,不会到这里
|
||
print(
|
||
f"[登录] ✅ 账号密码已填好(user={user_val[:3]}***),"
|
||
f"请点「登录」按钮,剩余 {remaining}s..."
|
||
)
|
||
else:
|
||
print(
|
||
f"[登录] 等待用户输入账号密码... 剩余 {remaining}s"
|
||
)
|
||
|
||
time.sleep(_pc.HUMAN_POLL_INTERVAL)
|
||
|
||
logger.warning(
|
||
"platform_collector: %ds 内用户未跳转(最后 URL=%s)", wait_sec, page.url,
|
||
)
|
||
return False
|
||
|
||
|
||
def _is_login_page_url_for_user(url: str) -> bool:
|
||
"""判断 URL 是否还在登录页(用于 _wait_for_login_inputs_filled 退出条件)。"""
|
||
u = url.lower()
|
||
if "/login" in u or "/signin" in u or "/auth/" in u:
|
||
return True
|
||
# 根域本身也是登录页(demo 数据未提交前的初始态)
|
||
return u.rstrip("/").endswith("sandbox.jc2009.com")
|
||
|
||
|
||
def _wait_for_manual_login(page, wait_sec: int = HUMAN_WAIT_TIMEOUT) -> bool:
|
||
"""手工登录 fallback(v1.1.19 重写,v1.1.20 加强,v1.1.21 修订):
|
||
等用户在浏览器完成登录。
|
||
|
||
判定「登录完成」的多个信号(满足任一即视为完成):
|
||
1. URL 含 `/app/` 且稳定 URL_STABLE_REQUIRED_S 秒(v1.1.19 旧逻辑)
|
||
2. URL 已离开 `/login` / `/signin` / `/auth/` 等登录路径(v1.1.20 新增)
|
||
—— 即便 sandbox 未跳到 /app/,只要离开登录页就认为登录成功
|
||
3. 页面出现 sidebar 元素 `a[href$='/hr/contracts']`(v1.1.20 新增)
|
||
—— 说明已加载到 SPA 主页
|
||
|
||
- **不做任何 goto / reload / click**,避免打断用户输入
|
||
- 每 HUMAN_PROMPT_INTERVAL 秒打印一次剩余时间(默认 15s)
|
||
- 每 HUMAN_POLL_INTERVAL 秒检查一次(默认 5s)
|
||
- 超时抛 LoginTimeoutError。
|
||
"""
|
||
# Lazy import: 读 facade constants 与异常类,便于测试 patch
|
||
from service import platform_collector as _pc
|
||
|
||
print(
|
||
f"[登录] 请在浏览器中完成登录"
|
||
f"(sandbox demo/123456 或真实账号),最多等待 {wait_sec} 秒..."
|
||
)
|
||
deadline = time.monotonic() + wait_sec
|
||
last_prompt_at = time.monotonic()
|
||
last_url = page.url
|
||
last_url_change_at = time.monotonic()
|
||
|
||
def _is_login_page_url(url: str) -> bool:
|
||
u = url.lower()
|
||
return (
|
||
"/login" in u or "/signin" in u or "/auth/" in u
|
||
or u.rstrip("/").endswith("sandbox.jc2009.com")
|
||
)
|
||
|
||
def _sidebar_present() -> bool:
|
||
try:
|
||
return page.locator("a[href$='/hr/contracts']").count() > 0
|
||
except Exception:
|
||
return False
|
||
|
||
while True:
|
||
remaining = deadline - time.monotonic()
|
||
if remaining <= 0:
|
||
break
|
||
|
||
cur_url = page.url
|
||
if cur_url != last_url:
|
||
logger.info(
|
||
"platform_collector: URL 变化 %s → %s", last_url, cur_url,
|
||
)
|
||
last_url = cur_url
|
||
last_url_change_at = time.monotonic()
|
||
|
||
# 信号 1:URL 跳到 /app/... 并稳定
|
||
if _pc._is_logged_in(page):
|
||
stable_for = time.monotonic() - last_url_change_at
|
||
if stable_for >= _pc.URL_STABLE_REQUIRED_S:
|
||
print(
|
||
f"[登录] ✅ 检测到登录完成(URL 含 /app/,稳定 {stable_for:.1f}s)"
|
||
)
|
||
return True
|
||
logger.info(
|
||
"platform_collector: URL 含 /app/ 但还在过渡,等稳定 "
|
||
"(current=%s, stable_for=%.1fs)", cur_url, stable_for,
|
||
)
|
||
# 信号 2:URL 已离开登录页(即使不在 /app/,比如卡在中间过渡页也算)
|
||
elif not _is_login_page_url(cur_url):
|
||
stable_for = time.monotonic() - last_url_change_at
|
||
if stable_for >= _pc.URL_STABLE_REQUIRED_S:
|
||
print(
|
||
f"[登录] ✅ 检测到登录完成(URL 已离开登录页 → {cur_url},"
|
||
f"稳定 {stable_for:.1f}s)"
|
||
)
|
||
return True
|
||
logger.info(
|
||
"platform_collector: URL 已离开登录页但还没稳定,等 "
|
||
"(current=%s, stable_for=%.1fs)", cur_url, stable_for,
|
||
)
|
||
# 信号 3:sidebar 已出现
|
||
elif _sidebar_present():
|
||
print(
|
||
f"[登录] ✅ 检测到登录完成(sidebar 已出现,URL={cur_url})"
|
||
)
|
||
return True
|
||
else:
|
||
# 还在登录页:每 HUMAN_PROMPT_INTERVAL 秒提醒用户一次
|
||
now = time.monotonic()
|
||
if now - last_prompt_at >= _pc.HUMAN_PROMPT_INTERVAL:
|
||
last_prompt_at = now
|
||
print(
|
||
f"[登录] 等待用户输入... 剩余 {int(remaining)}s "
|
||
f"(当前 URL={cur_url})"
|
||
)
|
||
|
||
time.sleep(_pc.HUMAN_POLL_INTERVAL)
|
||
|
||
raise _pc.LoginTimeoutError(
|
||
f"自动登录失败,{wait_sec}s 内用户在浏览器中也未完成登录"
|
||
f"(最后 URL={page.url})"
|
||
)
|
||
|
||
|
||
def _wait_for_url_stable(
|
||
page, target_substr: str | tuple[str, ...], max_wait_sec: int = 30,
|
||
) -> bool:
|
||
"""v1.1.19:等 URL 含 target_substr,且连续 URL_STABLE_REQUIRED_S 秒不变。
|
||
|
||
v1.1.22:target_substr 支持 tuple(任一匹配即视为稳定)。
|
||
|
||
用于「已登录态直接跳到目标页面」后等待 SPA 路由稳定。
|
||
**不做任何 goto / reload**,只 listen URL 变化。
|
||
|
||
Returns:
|
||
True = URL 已稳定到任一目标;False = 超时。
|
||
"""
|
||
# Lazy import: 读 URL_STABLE_REQUIRED_S 便于测试 patch
|
||
from service import platform_collector as _pc
|
||
|
||
if isinstance(target_substr, str):
|
||
targets = (target_substr,)
|
||
else:
|
||
targets = target_substr
|
||
|
||
deadline = time.monotonic() + max_wait_sec
|
||
last_url = page.url
|
||
last_url_change_at = time.monotonic()
|
||
|
||
while time.monotonic() < deadline:
|
||
cur_url = page.url
|
||
if cur_url != last_url:
|
||
logger.info(
|
||
"platform_collector._wait_for_url_stable: URL 变化 %s → %s",
|
||
last_url, cur_url,
|
||
)
|
||
last_url = cur_url
|
||
last_url_change_at = time.monotonic()
|
||
|
||
cur_lower = cur_url.lower()
|
||
if any(t.lower() in cur_lower for t in targets):
|
||
stable_for = time.monotonic() - last_url_change_at
|
||
if stable_for >= _pc.URL_STABLE_REQUIRED_S:
|
||
return True
|
||
|
||
time.sleep(URL_STABLE_POLL_S)
|
||
return False
|
||
|
||
|
||
def _fill_root_login(page) -> bool:
|
||
"""v1.1.31:根域登录页(如还在):从 .env 读 SMARTCAMPUS_LOGIN_USER /
|
||
SMARTCAMPUS_LOGIN_PASSWORD(不硬编码 demo/123456)→ fill → click Sign in。
|
||
|
||
无 .env 凭证时 return False,调用方走「用户手工登录」分支。
|
||
"""
|
||
# Lazy import: 读 _get_env(保留在 facade,测试通过 pc._ENV_CACHE 隔离)
|
||
from service import platform_collector as _pc
|
||
|
||
user_input = page.locator('#jc-user').first
|
||
if user_input.count() == 0:
|
||
return False
|
||
|
||
root_user = _pc._get_env("SMARTCAMPUS_LOGIN_USER", "").strip()
|
||
root_pass = _pc._get_env("SMARTCAMPUS_LOGIN_PASSWORD", "").strip()
|
||
if not root_user or not root_pass:
|
||
logger.info(
|
||
"platform_collector._fill_root_login: .env 未配置 "
|
||
"SMARTCAMPUS_LOGIN_USER / SMARTCAMPUS_LOGIN_PASSWORD,"
|
||
"**不自动 fill**,等用户手工输入"
|
||
)
|
||
return False
|
||
|
||
user_input.fill(root_user)
|
||
page.locator('#jc-pass').first.fill(root_pass)
|
||
page.locator('button[type="submit"]:has-text("Sign in")').first.click()
|
||
logger.info("platform_collector: 根域自动登录(user 来自 .env)...")
|
||
try:
|
||
page.wait_for_url(
|
||
lambda u: "/home" in u or "/select" in u or "/industries/" in u,
|
||
timeout=URL_AFTER_LOGIN_TIMEOUT_MS,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("等根域登录跳转超时: %s", e)
|
||
logger.info("platform_collector: 根域登录后 URL=%s", page.url)
|
||
return True
|
||
|
||
|
||
def _fill_spa_login_from_env(page) -> bool:
|
||
"""v1.1.31/32:SPA 二级登录页(/industries/campus/smartcampus/login)
|
||
按用户要求「密码不能直接放到代码」—— 从 .env 读 SMARTCAMPUS_LOGIN_USER /
|
||
SMARTCAMPUS_LOGIN_PASSWORD(无值时不自动 fill,等用户手工输入)。
|
||
|
||
v1.1.32:sandbox SPA 登录页默认会把「教师(通用)」demo 凭据预填到主表单 input。
|
||
但用户实际要登录的可能是「人事处」「财务处」等不同行业 tile;
|
||
按用户截图场景,需先在右侧 DEV MODE 面板里点对应 tile 的 Sign in,
|
||
让 SPA 把对应行业的 demo 凭据搬到主表单,再走原 fill+click 流程。
|
||
行业优先级:.env SMARTCAMPUS_LOGIN_INDUSTRY(默认 hr)→ 探测页面已预填的 user 值。
|
||
|
||
selector 优先级:
|
||
- user: `#jc-user` → `input[type='tel']` → `input[autocomplete='username']`
|
||
- pass: `#jc-pass` → `input[type='password']` → `input[autocomplete='current-password']`
|
||
按钮文本优先级:Sign in → 登录 → Login → signin → submit[type]。
|
||
|
||
返回是否成功点登录按钮(**无 .env 凭证时返回 False,让用户手工输入**)。
|
||
"""
|
||
# Lazy import: 读 _get_env(facade 持有 _ENV_CACHE)
|
||
from service import platform_collector as _pc
|
||
|
||
# 1. 从 .env 读凭证(不硬编码)
|
||
spa_user = _pc._get_env("SMARTCAMPUS_LOGIN_USER", "").strip()
|
||
spa_pass = _pc._get_env("SMARTCAMPUS_LOGIN_PASSWORD", "").strip()
|
||
if not spa_user or not spa_pass:
|
||
logger.info(
|
||
"platform_collector._fill_spa_login_from_env: .env 未配置 "
|
||
"SMARTCAMPUS_LOGIN_USER / SMARTCAMPUS_LOGIN_PASSWORD,"
|
||
"**不自动 fill**,等用户在浏览器手工输入"
|
||
)
|
||
return False
|
||
|
||
# 1.5 v1.1.32:检测 DEV MODE 面板是否展开 → 是否需要先点对应行业的 Sign in
|
||
# sandbox 行为:教师(通用)demo 总是预填到主表单,登录其他行业(hr/finance/research)
|
||
# 需要在右侧 DEV MODE 面板里点对应 tile 的 Sign in。
|
||
if _is_dev_mode_panel_open(page):
|
||
target_industry = _resolve_target_industry(page)
|
||
logger.info(
|
||
"platform_collector: DEV MODE 面板展开,目标行业=%s", target_industry,
|
||
)
|
||
_click_dev_mode_sign_in_for_industry(page, target_industry)
|
||
|
||
# 2. 找 user input
|
||
user_loc = None
|
||
for sel in ("#jc-user", "input[type='tel']", "input[autocomplete='username']"):
|
||
loc = page.locator(sel).first
|
||
if loc.count() > 0:
|
||
user_loc = loc
|
||
break
|
||
if user_loc is None:
|
||
logger.warning(
|
||
"platform_collector._fill_spa_login_from_env: 找不到 user input "
|
||
"(#jc-user / tel / autocomplete=username 全 miss)"
|
||
)
|
||
return False
|
||
|
||
# 3. 找 pass input
|
||
pass_loc = None
|
||
for sel in ("#jc-pass", "input[type='password']", "input[autocomplete='current-password']"):
|
||
loc = page.locator(sel).first
|
||
if loc.count() > 0:
|
||
pass_loc = loc
|
||
break
|
||
if pass_loc is None:
|
||
logger.warning(
|
||
"platform_collector._fill_spa_login_from_env: 找不到 pass input"
|
||
)
|
||
return False
|
||
|
||
user_loc.fill(spa_user)
|
||
pass_loc.fill(spa_pass)
|
||
logger.info(
|
||
"platform_collector: SPA 二级登录页 fill 完成(user 来自 .env)"
|
||
)
|
||
|
||
# 4. 找登录按钮
|
||
btn = None
|
||
btn_text_used = ""
|
||
for txt in ("Sign in", "登录", "Login", "signin"):
|
||
loc = page.locator(f"button:has-text('{txt}')").first
|
||
if loc.count() > 0:
|
||
btn = loc
|
||
btn_text_used = txt
|
||
break
|
||
if btn is None:
|
||
btn = page.locator("button[type='submit']").first
|
||
btn_text_used = "submit"
|
||
if btn is None or btn.count() == 0:
|
||
logger.warning(
|
||
"platform_collector._fill_spa_login_from_env: 找不到登录按钮"
|
||
)
|
||
return False
|
||
|
||
logger.info(
|
||
"platform_collector: SPA 二级登录页点 %s 按钮(user 来自 .env)",
|
||
btn_text_used,
|
||
)
|
||
btn.click()
|
||
return True
|
||
|
||
|
||
def _is_dev_mode_panel_open(page) -> bool:
|
||
"""v1.1.32:检测 sandbox SPA 登录页右侧 DEV MODE 面板是否展开。
|
||
|
||
面板里有「DEV MODE」文字 + 多个 tile(教师通用 / 人事处 / 财务处 / 科研处),
|
||
每个 tile 有自己的 Fill + Sign in 按钮。
|
||
|
||
返回 True 表示面板可见且可点对应行业的 Sign in。
|
||
|
||
实现策略(避免依赖 inner_text——fake page mock 不一定支持):
|
||
- 用 selector 探测至少 2 个 Sign in 按钮(教师/人事处/财务处/科研处 tile)
|
||
- 至少 1 个含「DEV MODE」文本的元素
|
||
"""
|
||
try:
|
||
# 1. 至少 2 个 Sign in 按钮(教师通用 + 至少 1 个行业 tile)
|
||
sign_in_btns = page.locator("button:has-text('Sign in')")
|
||
if sign_in_btns.count() < 2:
|
||
return False
|
||
# 2. DEV MODE 标志:找含 'DEV MODE' 文本的元素
|
||
dev_marker = page.locator(
|
||
"*:has-text('DEV MODE'), *:text-is('DEV MODE')"
|
||
).first
|
||
return dev_marker.count() > 0
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
def _resolve_target_industry(page) -> str:
|
||
"""v1.1.32:解析要登录的目标行业。
|
||
|
||
优先级:
|
||
1. .env SMARTCAMPUS_LOGIN_INDUSTRY(可选,默认 'hr')
|
||
2. 探测页面:若主表单已预填 user 是 'hr' / 'finance' / 'research' / 'demo'
|
||
→ 直接用该值(说明用户已点过对应 tile 的 Fill)
|
||
"""
|
||
# Lazy import: 读 _get_env 拿 SMARTCAMPUS_LOGIN_INDUSTRY
|
||
from service import platform_collector as _pc
|
||
|
||
explicit = _pc._get_env("SMARTCAMPUS_LOGIN_INDUSTRY", "").strip().lower()
|
||
if explicit:
|
||
return explicit
|
||
# fallback:探测主表单 input 已预填的 user 值
|
||
try:
|
||
for sel in ("#jc-user", "input[type='tel']", "input[autocomplete='username']"):
|
||
loc = page.locator(sel).first
|
||
if loc.count() > 0:
|
||
cur = (loc.input_value() or "").strip().lower()
|
||
if cur in ("hr", "finance", "research", "demo"):
|
||
return cur
|
||
except Exception:
|
||
pass
|
||
return "hr"
|
||
|
||
|
||
def _click_dev_mode_sign_in_for_industry(page, industry: str) -> bool:
|
||
"""v1.1.32:在 DEV MODE 面板里点对应行业 tile 的 Sign in 按钮。
|
||
|
||
sandbox 行为:每个 tile 是一段包含「行业名 + Email + Password + Fill + Sign in」的 div,
|
||
用户点 Sign in 会让对应行业的 demo 凭据填到主表单。
|
||
|
||
selector 策略(简化版,避免依赖 inner_text——fake page mock 不一定支持):
|
||
- 行业显示名 → 用 :has-text + Sign in 按钮组合定位
|
||
- 兜底:用 nth 索引(教师通用=1 / 人事处=2 / 财务处=3 / 科研处=4)
|
||
|
||
返回是否成功点击(找不到时返回 False,由调用方决定 fallback)。
|
||
"""
|
||
if not industry:
|
||
return False
|
||
|
||
# 行业显示名 → 可能的中文标签
|
||
industry_aliases = {
|
||
"hr": ("人事处",),
|
||
"finance": ("财务处",),
|
||
"research": ("科研处",),
|
||
"demo": ("教师", "通用", "Teacher"),
|
||
}
|
||
aliases = industry_aliases.get(industry, (industry,))
|
||
|
||
# 1. 模式 A:找包含「行业中文标签」的容器 + 容器内 Sign in 按钮
|
||
for alias in aliases:
|
||
try:
|
||
# Playwright 支持 :has() / :has-text() / :text-matches()
|
||
tile_btn = page.locator(
|
||
f"div:has-text('{alias}'):has(button:has-text('Sign in')) "
|
||
f"button:has-text('Sign in')"
|
||
).first
|
||
if tile_btn.count() > 0:
|
||
logger.info(
|
||
"platform_collector: DEV MODE 点 %s tile 的 Sign in 按钮",
|
||
alias,
|
||
)
|
||
tile_btn.click()
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(
|
||
"platform_collector: DEV MODE %s Sign in 点击失败: %s", alias, e,
|
||
)
|
||
|
||
# 2. 兜底:直接找所有 Sign in 按钮,按 industry 顺序选
|
||
# hr(人事处) 排第 2 个,finance(财务处) 排第 3 个,research(科研处) 排第 4 个
|
||
industry_order = {"demo": 1, "hr": 2, "finance": 3, "research": 4}
|
||
if industry in industry_order:
|
||
idx = industry_order[industry]
|
||
try:
|
||
all_sign_in = page.locator("button:has-text('Sign in')")
|
||
cnt = all_sign_in.count()
|
||
if cnt >= idx:
|
||
logger.info(
|
||
"platform_collector: DEV MODE 用兜底策略点第 %d 个 Sign in(%s)",
|
||
idx, industry,
|
||
)
|
||
all_sign_in.nth(idx - 1).click()
|
||
return True
|
||
except Exception as e:
|
||
logger.warning(
|
||
"platform_collector: DEV MODE 兜底点击失败: %s", e,
|
||
)
|
||
|
||
logger.warning(
|
||
"platform_collector: DEV MODE 找不到 industry=%s 的 Sign in 按钮", industry,
|
||
)
|
||
return False
|
||
|
||
|
||
def _click_campus_link(page) -> bool:
|
||
"""在 /select 页面点智慧校园 tile,返回是否点中。"""
|
||
link = page.locator("a[href*='/industries/campus']").first
|
||
if link.count() == 0:
|
||
logger.warning("platform_collector: 未找到 智慧校园 link")
|
||
return False
|
||
link.click()
|
||
logger.info("platform_collector: click 智慧校园 链接")
|
||
try:
|
||
page.wait_for_url(lambda u: "/industries/campus" in u, timeout=SIDEBAR_LINK_TIMEOUT_MS // 2)
|
||
except Exception as e:
|
||
logger.warning("等 sandbox 路径超时: %s;当前 URL=%s", e, page.url)
|
||
return True
|
||
|
||
|
||
def _fill_spa_login(page, settle_ms: int = SPA_LOGIN_SETTLE_MS) -> bool:
|
||
"""sandbox SPA 自动登录(如还在登录页):fill demo/123456 → click 登录 → 等跳转。
|
||
|
||
返回是否执行了登录(True = 已点登录按钮且 URL 已跳离 login;调用方可继续后续流程)。
|
||
失败时**不抛异常**,由调用方决定是否 fallback 到 _wait_for_manual_login。
|
||
|
||
v1.1.10 关键修复:**先检测 _is_logged_in,已登录态直接 return True 不再 fill**。
|
||
之前没这个检查,profile 复用时会被强制 demo fill 破坏 session。
|
||
|
||
v1.1.11 关键修复:**sandbox SPA 登录 input 实际是 `type="tel"` + 无 autocomplete**,
|
||
之前 selector `input[autocomplete="username"]` 永远找不到,自动 fill 100% 失败。
|
||
改用 sandbox 实际 selector `#jc-user` / `#jc-pass`(来自 root 登录页)+ `button[type="submit"]`。
|
||
|
||
修复要点(v1.1.5/v1.1.6 登录时间太短导致采集不到数据):
|
||
- 默认 settle_ms = SPA_LOGIN_SETTLE_MS(4000ms),给 SPA 异步渲染 + token 写入 cookie 时间
|
||
- 等 username input 的 timeout = SPA_LOGIN_INPUT_TIMEOUT_MS(20s)
|
||
- click 按钮后 wait_for_url timeout = SPA_LOGIN_URL_TIMEOUT_MS(30s)
|
||
- 多处过渡路径(/login, /signin, /auth/)一并判断
|
||
"""
|
||
# Lazy import: 读 _is_logged_in(facade 持有,便于测试 mock)
|
||
from service import platform_collector as _pc
|
||
|
||
# v1.1.10:先检查是否已登录(profile cookie 复用场景),已登录直接 return True
|
||
if _pc._is_logged_in(page):
|
||
logger.info(
|
||
"platform_collector: 已登录态(profile cookie 复用),跳过自动 fill"
|
||
)
|
||
return True
|
||
|
||
url_lower = page.url.lower()
|
||
if "/login" not in url_lower and "/signin" not in url_lower and "/auth/" not in url_lower:
|
||
return False
|
||
# v1.1.11:sandbox SPA 登录页 input 是 #jc-user / #jc-pass(与 root 登录页同 ID)
|
||
# 等输入框出现(带超时,避免 sandbox mock 慢启动卡死)
|
||
try:
|
||
page.wait_for_selector(
|
||
'#jc-user, input[type="tel"], input[autocomplete="username"]',
|
||
timeout=SPA_LOGIN_INPUT_TIMEOUT_MS,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("SPA login input 未出现: %s;当前 URL=%s", e, page.url)
|
||
return False
|
||
# v1.1.11:按优先级选 selector —— sandbox 实际用 #jc-user
|
||
user_sel = None
|
||
for sel in ('#jc-user', 'input[type="tel"]', 'input[autocomplete="username"]'):
|
||
loc = page.locator(sel).first
|
||
if loc.count() > 0:
|
||
user_sel = sel
|
||
break
|
||
if not user_sel:
|
||
logger.warning("SPA login input selector 全 miss(#jc-user / type=tel / autocomplete=username)")
|
||
return False
|
||
page.locator(user_sel).first.fill("demo")
|
||
# 密码 selector 同样按优先级
|
||
pass_sel = None
|
||
for sel in ('#jc-pass', 'input[type="password"]', 'input[autocomplete="current-password"]'):
|
||
loc = page.locator(sel).first
|
||
if loc.count() > 0:
|
||
pass_sel = sel
|
||
break
|
||
if not pass_sel:
|
||
logger.warning("SPA password input selector 全 miss")
|
||
return False
|
||
page.locator(pass_sel).first.fill("123456")
|
||
page.locator(
|
||
'button:has-text("登录"), button[type="submit"]:has-text("Sign in"), '
|
||
'button[type="submit"]'
|
||
).first.click()
|
||
logger.info("platform_collector: sandbox SPA 自动登录 (user_sel=%s pass_sel=%s)", user_sel, pass_sel)
|
||
page.wait_for_timeout(settle_ms)
|
||
try:
|
||
page.wait_for_url(
|
||
lambda u: "/login" not in u.lower()
|
||
and "/signin" not in u.lower()
|
||
and "/auth/" not in u.lower(),
|
||
timeout=SPA_LOGIN_URL_TIMEOUT_MS,
|
||
)
|
||
except Exception as e:
|
||
logger.warning("SPA 登录超时: %s;当前 URL=%s", e, page.url)
|
||
return False
|
||
# v1.1.10:fill 后再检测 _is_logged_in,避免 sandbox demo 凭据失效时
|
||
# 被误判为「已登录」(URL 跳 /app/home 但 cookie 没写入)
|
||
if not _pc._is_logged_in(page):
|
||
logger.warning(
|
||
"platform_collector: SPA URL 跳离 login 但 _is_logged_in=False,登录可能失败"
|
||
)
|
||
return False
|
||
logger.info("platform_collector: SPA 登录后 URL=%s", page.url)
|
||
return True
|