AutoRpa/rpa_selection/auth_login.py

368 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 商城登录:读环境变量账号,写出 token/cookie 缓存。
#
# 账号只认 YAOEX_ / YBM_ 环境变量,不读 secrets.json。
# 缓存文件权限收成 0o600。
from __future__ import annotations
import json
import os
import sys
import time
from pathlib import Path
from typing import Any
from http_util import cookies_for_http, parse_cookie_value
from mall_config import (
YAOEX_HOME_URL,
YAOEX_LOGIN_URL,
YBM_HOME_URL,
YBM_LOGIN_URL,
)
from search_yaoex import YaoexAuth
from util import normalize_text
PROJECT_ROOT = Path(__file__).resolve().parent
REPO_ROOT = PROJECT_ROOT.parent
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from rpa_oracle.env import load_env
AUTH_DIR = PROJECT_ROOT / "data" / ".auth"
DEFAULT_YAOEX_AUTH_PATH = AUTH_DIR / "yaoex.json"
DEFAULT_YBM_COOKIE_PATH = AUTH_DIR / "ybm.json"
# 把单个 .env 补进环境。已有非空变量不覆盖。加购班过渡仍走这一层。
def load_dotenv(path: Path) -> None:
load_env(path)
# 读商城账号。只认 YAOEX_ / YBM_ 环境变量,不读 secrets.json。
#
# 先补选品 .env 里尚未占用的键。
def load_secrets() -> dict[str, dict[str, str]]:
load_env(PROJECT_ROOT / ".env")
env_map = {
"yaoex": ("YAOEX_USERNAME", "YAOEX_PASSWORD"),
"ybm": ("YBM_USERNAME", "YBM_PASSWORD"),
}
return {
mall: {
"username": os.environ.get(user_key) or "",
"password": os.environ.get(pass_key) or "",
}
for mall, (user_key, pass_key) in env_map.items()
}
# 从缓存 JSON 读出 1 号药城 token / user_id / buyer_code。
def load_yaoex_auth_file(path: Path) -> YaoexAuth:
data = json.loads(path.read_text(encoding="utf-8"))
return YaoexAuth(
token=normalize_text(data.get("token")),
user_id=normalize_text(data.get("user_id")),
buyer_code=normalize_text(data.get("buyer_code")),
)
# 从缓存 JSON 读出药帮忙 cookie。文件必须是对象。只留下能进 HTTP 头的项。
def load_cookie_file(path: Path) -> dict[str, str]:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError(f"cookie 文件不是对象:{path}")
loaded = {str(key): parse_cookie_value(str(value)) for key, value in data.items()}
return cookies_for_http(loaded)
# 把缓存文件权限收成 0o600。Windows 上 chmod 可能无效,忽略 OSError。
def _restrict_file_mode(path: Path) -> None:
try:
os.chmod(path, 0o600)
except OSError:
pass
# 写出 1 号药城登录缓存,权限 0o600。
def save_yaoex_auth(auth: YaoexAuth, path: Path = DEFAULT_YAOEX_AUTH_PATH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(
{"token": auth.token, "user_id": auth.user_id, "buyer_code": auth.buyer_code},
ensure_ascii=False,
indent=2,
),
encoding="utf-8",
)
_restrict_file_mode(path)
# 写出药帮忙 cookie 缓存,权限 0o600。中文展示字段不写进文件。
def save_cookie_file(cookies: dict[str, str], path: Path = DEFAULT_YBM_COOKIE_PATH) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(
json.dumps(cookies_for_http(cookies), ensure_ascii=False, indent=2),
encoding="utf-8",
)
_restrict_file_mode(path)
# 账号或密码为空时退出,提示去环境变量配置。
def _require_account(mall_label: str, username: str, password: str) -> None:
if not username or not password:
raise SystemExit(
f"缺少{mall_label}账号。请在 .env 或环境变量中配置用户名和密码。"
)
# 路径是否落在仓库根之内。
def _is_inside_repo(path: Path, repo_root: Path) -> bool:
try:
path.resolve().relative_to(repo_root.resolve())
except ValueError:
return False
return True
# 选品 Chrome 用户目录固定在仓库外,避免每次新开空浏览器触发极验滑块。
def resolve_selection_chrome_profile(
requested: str | None = None,
*,
repo_root: Path | None = None,
) -> Path:
root = (repo_root or REPO_ROOT).resolve()
outside = (root.parent / "rpa-chrome-profile" / "selection").resolve()
raw = os.getenv("RPA_SELECTION_CHROME_PROFILE") if requested is None else requested
if not raw:
base = os.getenv("RPA_CHROME_PROFILE")
raw = str(Path(base).expanduser() / "selection") if base else ""
if not raw:
return outside
candidate = Path(raw).expanduser().resolve()
if _is_inside_repo(candidate, root):
print(f"选品 Chrome 用户目录指向仓库内 {candidate},已改用 {outside}")
return outside
return candidate
# 同一轮脚本共用一套已登录的 Chrome 用户目录。keep=True 时搜完仍留着窗口。
class BrowserRuntime:
# 记下是否保持窗口,以及保持多少秒。
def __init__(self, keep: bool = False, hold_seconds: int = 180) -> None:
self.keep = keep
self.hold_seconds = hold_seconds
self._playwright: Any = None
self._context: Any = None
self._virgin_page: Any = None
# 打开仓库外的持久 Chrome 用户目录。已启动则直接返回。
def start(self, *, viewport: dict[str, int] | None = None) -> None:
if self._playwright is not None:
return
from playwright.sync_api import sync_playwright
self._playwright = sync_playwright().start()
profile = resolve_selection_chrome_profile()
profile.mkdir(parents=True, exist_ok=True)
print(f"选品 Chrome 用户目录:{profile}")
try:
self._context = self._playwright.chromium.launch_persistent_context(
str(profile),
headless=False,
channel="chrome",
viewport=viewport or {"width": 1440, "height": 900},
args=[
"--start-maximized",
"--disable-blink-features=AutomationControlled",
"--no-first-run",
"--no-default-browser-check",
],
ignore_default_args=["--enable-automation"],
)
except Exception as exc:
raise RuntimeError(
f"无法打开选品 Chrome 用户目录 {profile}(是否已有实例占用):{exc}"
) from exc
pages = list(self._context.pages)
self._virgin_page = pages[0] if pages else None
# 取出一个页面。优先复用启动时自带的空白页。
def new_page(self, *, viewport: dict[str, int] | None = None) -> tuple[Any, Any]:
self.start(viewport=viewport)
assert self._context is not None
if self._virgin_page is not None:
page = self._virgin_page
self._virgin_page = None
else:
page = self._context.new_page()
page.bring_to_front()
return self._context, page
# keep=True 时先留窗口,再关 Playwright。
def close(self) -> None:
if self.keep and self._context is not None:
if self.hold_seconds <= 0:
print("浏览器保持打开,不会自动关。要关时在终端停掉进程。")
while True:
time.sleep(3600)
else:
print(f"浏览器保持打开 {self.hold_seconds} 秒,请看屏幕上的 Chrome。")
time.sleep(self.hold_seconds)
if self._context is not None:
try:
self._context.close()
except Exception:
pass
self._context = None
self._virgin_page = None
if self._playwright is not None:
try:
self._playwright.stop()
except Exception:
pass
self._playwright = None
# 若停在 1 号药城登录页则填账号;仍停在登录页则失败。
def _ensure_yaoex_logged_in(page: Any, username: str, password: str) -> None:
page.goto(YAOEX_LOGIN_URL, wait_until="domcontentloaded")
page.wait_for_timeout(2000)
if "/login" not in page.url:
return
account_input = page.locator("#username").first
password_input = page.locator("#password").first
captcha_button = page.locator(
"#captcha-box .geetest_btn_click, #captcha-box .geetest_btn, #captcha-box .geetest_radar_btn"
).first
login_button = page.locator("#login-btn").first
account_input.wait_for(state="visible", timeout=30000)
account_input.fill(username)
password_input.fill(password)
print("1号药城:已填账号。请看 Chrome——若出现滑块拼图,拖过去。窗口会等 3 分钟。")
if captcha_button.count() > 0:
captcha_button.click(force=True)
try:
page.wait_for_function(
"() => !window.location.href.includes('/login')",
timeout=20000,
)
except Exception:
try:
login_button.click(force=True)
except Exception:
pass
try:
page.wait_for_function(
"() => !window.location.href.includes('/login')",
timeout=180000,
)
except Exception:
pass
if "/login" in page.url:
login_error = ""
error_locator = page.locator(".error-tips span, .layui-layer-content, .geetest_tip")
if error_locator.count() > 0:
login_error = normalize_text(error_locator.first.inner_text())
shot = PROJECT_ROOT / "output" / "结果" / "yaoex_login_fail.png"
shot.parent.mkdir(parents=True, exist_ok=True)
page.screenshot(path=str(shot), full_page=True)
detail = f",页面提示:{login_error}" if login_error else ""
raise RuntimeError(
f"1号药城登录后仍停留在登录页,登录可能失败{detail}。截图:{shot}"
)
# 若停在药帮忙登录页则填账号;仍停在登录页则失败。
def _ensure_ybm_logged_in(page: Any, username: str, password: str) -> None:
page.goto(YBM_LOGIN_URL, wait_until="domcontentloaded")
page.wait_for_timeout(2000)
if "/new/login" not in page.url:
return
account_input = page.locator("form.login-form input[type='text']").first
password_input = page.locator("form.login-form input[type='password']").first
agree_input = page.locator(".login-section-agree input[type='checkbox']").first
agree_visible_box = page.locator(".login-section-agree .el-checkbox__inner").first
login_button = page.locator("button.login-button").first
account_input.wait_for(state="visible", timeout=30000)
account_input.fill(username)
password_input.fill(password)
if not agree_input.is_checked():
agree_visible_box.click()
login_button.click()
page.wait_for_timeout(5000)
if "/new/login" in page.url:
login_error = ""
error_locator = page.locator(".el-message__content")
if error_locator.count() > 0:
login_error = normalize_text(error_locator.first.inner_text())
detail = f",页面提示:{login_error}" if login_error else ""
raise RuntimeError(f"药帮忙登录后仍停留在登录页,登录可能失败{detail}")
# 打开 1 号药城,登录后从 cookie 抽出 token / user_id / buyer_code。
def _playwright_login_yaoex(username: str, password: str, runtime: BrowserRuntime) -> YaoexAuth:
context, page = runtime.new_page()
_ensure_yaoex_logged_in(page, username, password)
page.goto(YAOEX_HOME_URL, wait_until="domcontentloaded")
page.wait_for_timeout(3000)
print("1号药城窗口应已打开首页。")
cookies = context.cookies()
cookie_map = {item["name"]: item["value"] for item in cookies}
token = parse_cookie_value(cookie_map.get("ycgltoken")) or parse_cookie_value(
cookie_map.get("yctoken")
)
user_id = parse_cookie_value(cookie_map.get("ycuserId"))
buyer_code = parse_cookie_value(cookie_map.get("ycenterpriseId"))
if not token or not user_id or not buyer_code:
raise RuntimeError("未能从1号药城 cookie 提取完整认证字段(token/user_id/buyer_code)。")
return YaoexAuth(token=token, user_id=user_id, buyer_code=buyer_code)
# 打开药帮忙,登录后带回能进 HTTP 头的 cookie。
def _playwright_login_ybm(username: str, password: str, runtime: BrowserRuntime) -> dict[str, str]:
context, page = runtime.new_page()
_ensure_ybm_logged_in(page, username, password)
page.goto(YBM_HOME_URL, wait_until="domcontentloaded")
page.wait_for_timeout(3000)
print("药帮忙窗口应已打开首页。")
cookies = context.cookies()
loaded = {item["name"]: parse_cookie_value(item["value"]) for item in cookies}
return cookies_for_http(loaded)
# 登录 1 号药城。缺账号则退出;传入 runtime 时不关浏览器。
def login_yaoex(runtime: BrowserRuntime | None = None) -> YaoexAuth:
secrets = load_secrets()
username = secrets["yaoex"].get("username", "")
password = secrets["yaoex"].get("password", "")
_require_account("1号药城", username, password)
own = runtime is None
session = runtime or BrowserRuntime(keep=False)
try:
return _playwright_login_yaoex(username, password, session)
finally:
if own:
session.close()
# 登录药帮忙。缺账号则退出;传入 runtime 时不关浏览器。
def login_ybm(runtime: BrowserRuntime | None = None) -> dict[str, str]:
secrets = load_secrets()
username = secrets["ybm"].get("username", "")
password = secrets["ybm"].get("password", "")
_require_account("药帮忙", username, password)
own = runtime is None
session = runtime or BrowserRuntime(keep=False)
try:
return _playwright_login_ybm(username, password, session)
finally:
if own:
session.close()