fix: 选品复用仓库外 Chrome 用户目录,减少极验滑块

登录改为 persistent context,默认目录在仓库外 rpa-chrome-profile/selection。
This commit is contained in:
Yuuuu 2026-10-03 19:51:13 +08:00
parent 6fb2f5de95
commit f58c7a73ab
2 changed files with 192 additions and 17 deletions

View File

@ -17,6 +17,7 @@ from search_yaoex import YaoexAuth
from util import normalize_text from util import normalize_text
PROJECT_ROOT = Path(__file__).resolve().parent PROJECT_ROOT = Path(__file__).resolve().parent
REPO_ROOT = PROJECT_ROOT.parent
AUTH_DIR = PROJECT_ROOT / "data" / ".auth" AUTH_DIR = PROJECT_ROOT / "data" / ".auth"
DEFAULT_YAOEX_AUTH_PATH = AUTH_DIR / "yaoex.json" DEFAULT_YAOEX_AUTH_PATH = AUTH_DIR / "yaoex.json"
DEFAULT_YBM_COOKIE_PATH = AUTH_DIR / "ybm.json" DEFAULT_YBM_COOKIE_PATH = AUTH_DIR / "ybm.json"
@ -99,37 +100,88 @@ def _require_account(mall_label: str, username: str, password: str) -> None:
) )
def _is_inside_repo(path: Path, repo_root: Path) -> bool:
try:
path.resolve().relative_to(repo_root.resolve())
except ValueError:
return False
return True
def resolve_selection_chrome_profile(
requested: str | None = None,
*,
repo_root: Path | None = None,
) -> Path:
"""选品 Chrome 用户目录固定在仓库外,避免每次新开空浏览器触发极验滑块。"""
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
class BrowserRuntime: class BrowserRuntime:
"""同一轮脚本共用一套 Playwright。keep=True 时搜完仍留着窗口。""" """同一轮脚本共用一套已登录的 Chrome 用户目录。keep=True 时搜完仍留着窗口。"""
def __init__(self, keep: bool = False, hold_seconds: int = 180) -> None: def __init__(self, keep: bool = False, hold_seconds: int = 180) -> None:
self.keep = keep self.keep = keep
self.hold_seconds = hold_seconds self.hold_seconds = hold_seconds
self._playwright: Any = None self._playwright: Any = None
self._browsers: list[Any] = [] self._context: Any = None
self._virgin_page: Any = None
def start(self) -> None: def start(self, *, viewport: dict[str, int] | None = None) -> None:
if self._playwright is not None: if self._playwright is not None:
return return
from playwright.sync_api import sync_playwright from playwright.sync_api import sync_playwright
self._playwright = sync_playwright().start() 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]: def new_page(self, *, viewport: dict[str, int] | None = None) -> tuple[Any, Any]:
self.start() self.start(viewport=viewport)
browser = self._playwright.chromium.launch( assert self._context is not None
headless=False, if self._virgin_page is not None:
channel="chrome", page = self._virgin_page
args=["--new-window", "--start-maximized"], self._virgin_page = None
) else:
self._browsers.append(browser) page = self._context.new_page()
context = browser.new_context(viewport=viewport or {"width": 1440, "height": 900})
page = context.new_page()
page.bring_to_front() page.bring_to_front()
return context, page return self._context, page
def close(self) -> None: def close(self) -> None:
if self.keep and self._browsers: if self.keep and self._context is not None:
if self.hold_seconds <= 0: if self.hold_seconds <= 0:
print("浏览器保持打开,不会自动关。要关时在终端停掉进程。") print("浏览器保持打开,不会自动关。要关时在终端停掉进程。")
while True: while True:
@ -137,12 +189,13 @@ class BrowserRuntime:
else: else:
print(f"浏览器保持打开 {self.hold_seconds} 秒,请看屏幕上的 Chrome。") print(f"浏览器保持打开 {self.hold_seconds} 秒,请看屏幕上的 Chrome。")
time.sleep(self.hold_seconds) time.sleep(self.hold_seconds)
for browser in self._browsers: if self._context is not None:
try: try:
browser.close() self._context.close()
except Exception: except Exception:
pass pass
self._browsers.clear() self._context = None
self._virgin_page = None
if self._playwright is not None: if self._playwright is not None:
try: try:
self._playwright.stop() self._playwright.stop()

View File

@ -0,0 +1,122 @@
from pathlib import Path
import playwright.sync_api as playwright_sync
import auth_login
from auth_login import BrowserRuntime, resolve_selection_chrome_profile
REPO_ROOT = Path(__file__).resolve().parents[2]
def _is_relative_to(path: Path, root: Path) -> bool:
try:
path.resolve().relative_to(root.resolve())
except ValueError:
return False
return True
def test_default_chrome_profile_is_outside_repo_selection_subdir(monkeypatch):
monkeypatch.delenv("RPA_SELECTION_CHROME_PROFILE", raising=False)
monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False)
profile = resolve_selection_chrome_profile(repo_root=REPO_ROOT)
assert profile == (REPO_ROOT.parent / "rpa-chrome-profile" / "selection").resolve()
assert not _is_relative_to(profile, REPO_ROOT)
def test_env_path_inside_repo_is_rejected(monkeypatch):
inside = REPO_ROOT / "rpa_selection" / "data" / "chrome-profile"
monkeypatch.delenv("RPA_CHROME_PROFILE", raising=False)
profile = resolve_selection_chrome_profile(requested=str(inside), repo_root=REPO_ROOT)
assert profile == (REPO_ROOT.parent / "rpa-chrome-profile" / "selection").resolve()
assert not _is_relative_to(profile, REPO_ROOT)
def test_env_path_outside_repo_is_kept(tmp_path):
outside = tmp_path / "custom-chrome"
outside.mkdir()
profile = resolve_selection_chrome_profile(requested=str(outside), repo_root=REPO_ROOT)
assert profile == outside.resolve()
def test_rpa_chrome_profile_uses_selection_subdir(monkeypatch, tmp_path):
base = tmp_path / "work-profile"
monkeypatch.delenv("RPA_SELECTION_CHROME_PROFILE", raising=False)
monkeypatch.setenv("RPA_CHROME_PROFILE", str(base))
profile = resolve_selection_chrome_profile(repo_root=REPO_ROOT)
assert profile == (base / "selection").resolve()
class _FakePage:
url = "about:blank"
def bring_to_front(self) -> None:
return None
class _FakeContext:
def __init__(self) -> None:
self.pages = [_FakePage()]
self.closed = False
self.new_page_calls = 0
def new_page(self) -> _FakePage:
self.new_page_calls += 1
page = _FakePage()
self.pages.append(page)
return page
def close(self) -> None:
self.closed = True
class _FakeChromium:
def __init__(self) -> None:
self.persistent_calls: list[tuple[str, dict]] = []
self.launch_calls: list[dict] = []
self.context = _FakeContext()
def launch_persistent_context(self, user_data_dir: str, **kwargs):
self.persistent_calls.append((user_data_dir, kwargs))
return self.context
def launch(self, **kwargs):
self.launch_calls.append(kwargs)
raise AssertionError("选品登录必须复用用户目录,禁止 launch 空 Chrome")
class _FakePlaywright:
def __init__(self, chromium: _FakeChromium) -> None:
self.chromium = chromium
def start(self) -> "_FakePlaywright":
return self
def stop(self) -> None:
return None
def test_browser_runtime_reuses_persistent_profile(monkeypatch, tmp_path):
profile = tmp_path / "selection-profile"
monkeypatch.setenv("RPA_SELECTION_CHROME_PROFILE", str(profile))
chromium = _FakeChromium()
fake = _FakePlaywright(chromium)
monkeypatch.setattr(playwright_sync, "sync_playwright", lambda: fake)
runtime = BrowserRuntime(keep=False)
ctx1, page1 = runtime.new_page()
ctx2, page2 = runtime.new_page()
assert chromium.launch_calls == []
assert len(chromium.persistent_calls) == 1
user_data_dir, kwargs = chromium.persistent_calls[0]
assert user_data_dir == str(profile.resolve())
assert kwargs.get("channel") == "chrome"
assert "--disable-blink-features=AutomationControlled" in kwargs.get("args", [])
assert ctx1 is ctx2 is chromium.context
assert page1 is not page2
assert chromium.context.new_page_calls == 1
runtime.close()
assert chromium.context.closed
assert auth_login.resolve_selection_chrome_profile() == profile.resolve()