Files
DDSRem e0642eafa7 chore(types): reduce v3 host mypy debt (#7057)
* chore(types): clear AniList endpoint mypy debt

* chore(types): clear Douban endpoint mypy debt

* chore(types): clear recommendation endpoint mypy debt

* chore(types): clear subscription notification mypy debt

* chore(types): clear SDK string compatibility mypy debt

* chore(types): ratchet v3 host mypy across remaining files

* chore(types): reduce v3 host mypy debt and retire ledger

* chore(types): reconcile host snapshot with upstream HTTP pool

* test(types): cover host paths exposed by mypy cleanup
2026-10-18 09:37:49 +08:00

788 lines
33 KiB
Python
Raw Permalink 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.
"""
通过注入浏览器、HTTP 和识别端口完成站点登录与 Cookie 获取。
"""
import time
from typing import Any, Callable, Optional, Protocol, Tuple
from urllib.parse import urljoin, urlparse
from lxml import etree
from app.application.security.twofactor import TwoFactorAuth
from app.domain.site import SiteUtils
from app.runtime.log import logger
CookieResult = Tuple[Optional[str], Optional[str], str]
class CookiePageContentPort(Protocol):
"""
声明登录页源码读取和加载等待所需的页面能力。
"""
def wait_for_load_state(self, state: str, *, timeout: int) -> None:
"""
等待页面进入指定加载状态。
:param state: 目标加载状态
:param timeout: 最长等待时间,单位为毫秒
"""
...
def content(self) -> str:
"""
读取当前页面源码,导航期间的异常由实现方抛出。
:return: 页面 HTML 源码
"""
...
class CookieBrowserPort(Protocol):
"""
声明站点登录用例所需的受控浏览器执行能力。
"""
def action(self, *, url: str, callback: Callable[[Any], CookieResult],
proxies: Optional[dict[str, Any]], timeout: Optional[int]) -> CookieResult:
"""
在受控页面会话内执行登录回调并返回兼容三元组。
页面及浏览器资源的获取与释放由实现方负责,回调不得把页面留给会话外使用。
:param url: 站点登录页面地址
:param callback: 在已创建页面会话内执行的登录回调,返回 Cookie 结果三元组
:param proxies: 浏览器代理配置;None 表示不指定代理
:param timeout: 浏览器操作超时,单位为秒;None 使用实现默认值
:return: 回调产生的 Cookie、User-Agent、错误说明三元组;浏览器未完成回调时沿用底层 None 回退
"""
class CaptchaHttpPort(Protocol):
"""
声明验证码图片下载能力。
"""
def fetch(self, *, url: str, cookie: str, ua: str) -> Optional[bytes]:
"""
下载验证码图片字节。
:param url: 验证码图片的绝对地址
:param cookie: 当前站点会话的 Cookie 请求头文本
:param ua: 当前浏览器会话使用的 User-Agent
:return: 下载的原始图片字节;未取得有效响应时返回 None
"""
class CaptchaOcrPort(Protocol):
"""
声明验证码图片识别能力。
"""
def recognize(self, image_data: bytes) -> str:
"""
识别原始验证码图片字节,避免在应用层重复 Base64 编码。
:param image_data: 未做 Base64 编码的验证码图片字节
:return: 识别出的验证码文本;未取得识别结果时返回空字符串
"""
_cookie_browser_port: Optional[CookieBrowserPort] = None
_captcha_http_port: Optional[CaptchaHttpPort] = None
_captcha_ocr_port: Optional[CaptchaOcrPort] = None
def configure_cookie_ports(*, browser: CookieBrowserPort, http: CaptchaHttpPort,
ocr: CaptchaOcrPort) -> None:
"""
由组合根装配站点登录与验证码端口。
"""
global _cookie_browser_port, _captcha_http_port, _captcha_ocr_port
_cookie_browser_port = browser
_captcha_http_port = http
_captcha_ocr_port = ocr
def reset_cookie_ports() -> None:
"""
清除站点登录端口,避免跨生命周期保留 Adapter。
"""
global _cookie_browser_port, _captcha_http_port, _captcha_ocr_port
_cookie_browser_port = None
_captcha_http_port = None
_captcha_ocr_port = None
def _require_cookie_ports() -> Tuple[CookieBrowserPort, CaptchaHttpPort, CaptchaOcrPort]:
"""
返回已装配端口,缺失时明确拒绝隐式构造 Adapter。
"""
if _cookie_browser_port is None or _captcha_http_port is None or _captcha_ocr_port is None:
raise RuntimeError("站点登录端口尚未由启动组合根装配")
return _cookie_browser_port, _captcha_http_port, _captcha_ocr_port
class CookieLoginFormMixin:
"""
提供跨站点登录表单、验证码和页面错误识别的共享能力。
"""
# 站点登录界面元素XPATH
_SITE_LOGIN_XPATH = {
"username": [
'//input[@name="username"]',
'//input[@name="user"]',
'//input[@name="user_email"]',
'//input[@name="txt_user"]',
'//input[@name="txt_email"]',
'//input[@name="email"]',
'//input[@id="form_item_username"]',
'//input[@id="username"]',
'//input[@id="user"]',
'//input[@id="email"]',
'//input[contains(@placeholder,"用户名")]',
'//input[contains(@placeholder,"邮箱")]',
(
'//input[not(translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz")="hidden") and '
'(contains(translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz"),"user") or '
'contains(translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz"),"email") or '
'contains(translate(@id,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz"),"user") or '
'contains(translate(@id,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz"),"email"))]'
),
],
"password": [
'//input[@name="password"]',
'//input[@id="form_item_password"]',
'//input[@id="password"]',
'//input[@type="password"]',
'//form[.//input[@type="password"]][1]//input[@type="password"][1]',
],
"captcha": [
'//input[@name="imagestring"]',
'//input[@name="captcha"]',
'//input[@name="captcha_code"]',
'//input[@name="verifycode"]',
'//input[@name="verification_code"]',
'//input[@name="security_code"]',
'//input[@name="imagecode"]',
'//input[@id="form_item_captcha"]',
'//input[@placeholder="驗證碼"]',
'//input[contains(@placeholder,"验证码")]',
(
'//input[not(translate(@type,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz")="hidden") and '
'(contains(translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz"),"captcha") or '
'contains(translate(@name,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz"),"verify") or '
'contains(translate(@id,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz"),"captcha") or '
'contains(translate(@id,"ABCDEFGHIJKLMNOPQRSTUVWXYZ",'
'"abcdefghijklmnopqrstuvwxyz"),"verify"))]'
),
],
"captcha_img": [
'//img[@alt="captcha"]/@src',
'//img[@alt="CAPTCHA"]/@src',
'//img[@alt="SECURITY CODE"]/@src',
'//img[@id="LAY-user-get-vercode"]/@src',
'//img[contains(@src,"/api/getCaptcha")]/@src',
(
'//img[contains(translate(concat(@alt," ",@title," ",@class," ",@src),'
'"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"captcha")]/@src'
),
(
'//img[contains(translate(concat(@alt," ",@title," ",@class," ",@src),'
'"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"verify")]/@src'
),
],
"submit": [
'//input[@type="submit"]',
'//button[@type="submit"]',
'//button[@lay-filter="login"]',
'//button[@lay-filter="formLogin"]',
'//input[@type="button"][@value="登录"]',
'//input[@id="submit-btn"]',
'//button[contains(translate(normalize-space(.),"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"login")]',
'//button[contains(normalize-space(.),"登录") or contains(normalize-space(.),"登入") or contains(normalize-space(.),"提交")]',
'//input[contains(translate(@value,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"login")]',
],
"error": [
"//table[@class='main']//td[@class='text']/text()",
'//*[@role="alert"]//text()',
'//*[contains(translate(@class,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"error")]//text()',
'//*[contains(translate(@class,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"alert")]//text()',
'//*[contains(translate(@class,"ABCDEFGHIJKLMNOPQRSTUVWXYZ","abcdefghijklmnopqrstuvwxyz"),"message")]//text()',
],
"remember": [
'//input[@type="checkbox"][contains(@name,"remember") or contains(@id,"remember")]',
'//*[@role="checkbox"][contains(.,"保持登录") or contains(.,"记住我") or contains(.,"自动登录")]',
],
"twostep": [
'//input[@name="two_step_code"]',
'//input[@name="2fa_secret"]',
'//input[@name="otp"]',
]
}
@classmethod
def _first_xpath(cls, html: etree._Element, key: str) -> Optional[str]:
"""
返回页面上第一个命中的字段 XPath。
"""
for xpath in cls._SITE_LOGIN_XPATH.get(key, []):
if html.xpath(xpath):
return xpath
return None
@classmethod
def _find_login_fields(
cls,
html: etree._Element,
) -> tuple[Optional[str], Optional[str]]:
"""
按常见命名、表单结构和输入类型推断用户名与密码字段。
"""
username_xpath = cls._first_xpath(html, "username")
password_xpath = cls._first_xpath(html, "password")
form_xpath = "//form[.//input[translate(@type,\"ABCDEFGHIJKLMNOPQRSTUVWXYZ\",\"abcdefghijklmnopqrstuvwxyz\")=\"password\"]][1]"
form_nodes = html.xpath(form_xpath)
if form_nodes:
login_form = form_nodes[0]
def belongs_to_login_form(xpath: Optional[str]) -> bool:
"""
判断全局候选是否属于含密码字段的登录表单。
"""
if not xpath:
return False
nodes = html.xpath(xpath)
return bool(nodes) and nodes[0] in login_form.iter()
if not belongs_to_login_form(username_xpath):
username_xpath = None
if not belongs_to_login_form(password_xpath):
password_xpath = None
if not password_xpath:
password_xpath = f'{form_xpath}//input[@type="password"][1]'
if not username_xpath:
scoped_candidates: tuple[str, ...] = (
f'{form_xpath}//input[@name="username"][1]',
f'{form_xpath}//input[@name="user"][1]',
f'{form_xpath}//input[@name="user_email"][1]',
f'{form_xpath}//input[@name="txt_user"][1]',
f'{form_xpath}//input[@name="txt_email"][1]',
f'{form_xpath}//input[@name="email"][1]',
f'{form_xpath}//input[@id="username"][1]',
f'{form_xpath}//input[@id="user"][1]',
f'{form_xpath}//input[@id="email"][1]',
f'{form_xpath}//input[@type="email"][1]',
f'{form_xpath}//input[@type="text"][1]',
f'{form_xpath}//input[not(@type) and not(@name="password")][1]',
)
username_xpath = next(
(candidate for candidate in scoped_candidates if html.xpath(candidate)),
None,
)
if password_xpath and username_xpath:
return username_xpath, password_xpath
if not password_xpath and html.xpath(f"{form_xpath}//input[@type='password']"):
password_xpath = f"{form_xpath}//input[@type='password'][1]"
if not username_xpath:
scoped_candidates = (
f"{form_xpath}//input[@type='email'][1]",
f"{form_xpath}//input[@type='text'][1]",
f"{form_xpath}//input[not(@type) and not(@name='password')][1]",
)
username_xpath = next(
(candidate for candidate in scoped_candidates if html.xpath(candidate)),
None,
)
if not password_xpath and html.xpath("//input[@type='password'][1]"):
password_xpath = "//input[@type='password'][1]"
if not username_xpath:
fallback_candidates = (
"//input[@type='email'][1]",
"//input[@type='text'][1]",
)
username_xpath = next(
(candidate for candidate in fallback_candidates if html.xpath(candidate)),
None,
)
return username_xpath, password_xpath
@classmethod
def _find_captcha_source(
cls,
html: etree._Element,
) -> tuple[Optional[str], Optional[str]]:
"""
查找验证码输入字段及图片地址,兼容常见命名和站点自定义 class。
"""
captcha_xpath = cls._first_xpath(html, "captcha")
if not captcha_xpath:
return None, None
for image_xpath in cls._SITE_LOGIN_XPATH.get("captcha_img", []):
values = html.xpath(image_xpath)
if values and isinstance(values[0], str):
return captcha_xpath, values[0]
return captcha_xpath, None
@classmethod
def _page_issue(cls, html_text: str, page_url: str) -> Optional[str]:
"""
把站点不可用和人机挑战归类为 Agent 可采取行动的提示。
"""
text = " ".join((html_text or "").split())
lowered = text.lower()
url_lowered = (page_url or "").lower()
if any(
marker in lowered or marker in url_lowered
for marker in (
"cf-chl-",
"cloudflare",
"cf-turnstile",
"turnstile",
"challenge-platform",
"captcha verification token is missing",
)
):
return "站点需要完成 Cloudflare/人机验证,无法自动登录,请手动登录后提供 Cookie"
if any(
marker in lowered or marker in url_lowered
for marker in (
"404 not found",
"502 bad gateway",
"503 service unavailable",
"site not found",
"没有找到站点",
"站点不存在",
"域名未绑定",
"无法连接到站点",
)
):
return "站点不可用或域名未绑定源站,请先确认站点地址和网络状态"
return None
@classmethod
def _error_message(cls, html_text: str) -> str:
"""
提取登录页可见错误,并去除重复或过长的 HTML 文本。
"""
html = etree.HTML(html_text or "")
if html is None:
return ""
messages: list[str] = []
for xpath in cls._SITE_LOGIN_XPATH.get("error", []):
for value in html.xpath(xpath):
text = " ".join(str(value).split())
if text and text not in messages:
messages.append(text)
return ";".join(messages)[:500]
@staticmethod
def _page_user_agent(page: Any) -> str:
"""
读取当前页面 User-Agent,浏览器实现不支持时返回空字符串。
"""
try:
return str(page.evaluate("() => window.navigator.userAgent") or "")
except Exception:
return ""
@staticmethod
def _click_with_fallback(page: Any, selector: str, timeout: int = 5000) -> None:
"""
依次尝试普通、强制、元素和 XPath JavaScript 点击。
"""
first_error: Optional[Exception] = None
try:
page.click(selector)
return
except Exception as error:
first_error = error
try:
page.click(selector, timeout=timeout, force=True)
return
except Exception:
pass
try:
element = page.query_selector(selector)
if element is not None and hasattr(element, "click"):
element.click(timeout=timeout, force=True)
return
except Exception:
pass
try:
clicked = page.evaluate(
"""
(xpath) => {
const node = document.evaluate(
xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
).singleNodeValue;
if (!node) return false;
node.click();
return true;
}
""",
selector,
)
if clicked is True or clicked is None:
return
except Exception:
pass
raise first_error or RuntimeError("无法点击页面元素")
@classmethod
def _refresh_captcha(cls, page: Any, html: etree._Element) -> bool:
"""
点击验证码图片或调用页面 reload,尽量获取下一张验证码。
"""
for image_xpath in cls._SITE_LOGIN_XPATH.get("captcha_img", []):
if not html.xpath(image_xpath):
continue
selector = image_xpath[:-5] if image_xpath.endswith("/@src") else image_xpath
try:
cls._click_with_fallback(page, selector, timeout=3000)
return True
except Exception:
continue
reload_page = getattr(page, "reload", None)
if callable(reload_page):
try:
reload_page(wait_until="domcontentloaded", timeout=10000)
return True
except Exception:
pass
return False
class CookieHelper(CookieLoginFormMixin):
"""
处理站点登录表单、验证码和 Cookie 获取流程。
"""
_MAX_CAPTCHA_ATTEMPTS = 3
@staticmethod
def get_page_content(page: CookiePageContentPort, retries: int = 3,
interval: float = 1.0) -> Optional[str]:
"""
获取页面源码,页面跳转中(如登录前后的重定向)会导致 page.content() 抛出
"Unable to retrieve content because the page is navigating" 异常,等待加载完成后重试。
:param page: 浏览器页面
:param retries: 最大重试次数
:param interval: 重试间隔(秒)
:return: 页面源码
"""
for i in range(retries):
# 等待加载失败不代表源码不可读取,最后一次等待失败时仍尝试直接获取源码
try:
page.wait_for_load_state("domcontentloaded", timeout=10 * 1000)
except Exception as e:
if i < retries - 1:
logger.warning(f"等待页面加载完成失败:{str(e)},{interval}秒后重试 ({i + 1}/{retries - 1})")
time.sleep(interval)
continue
logger.warning(f"等待页面加载完成失败:{str(e)},尝试直接获取源码")
try:
return page.content()
except Exception as e:
if i >= retries - 1:
logger.error(f"获取页面源码失败:{str(e)}")
return None
logger.warning(f"获取页面源码失败:{str(e)},{interval}秒后重试 ({i + 1}/{retries - 1})")
time.sleep(interval)
return None
@staticmethod
def parse_cookies(cookies: list[object]) -> str:
"""
将浏览器 Cookie 列表转成请求头字符串,并忽略不完整条目。
:param cookies: 浏览器返回的 Cookie 条目;非字典或缺少名称、值的条目会跳过
:return: 用分号分隔的 Cookie 请求头;没有有效条目时返回空字符串
"""
if not cookies:
return ""
values: list[str] = []
for cookie in cookies:
if not isinstance(cookie, dict):
continue
name = cookie.get("name")
value = cookie.get("value")
if name is not None and value is not None:
values.append(f"{name}={value}")
return "; ".join(values) + ("; " if values else "")
@staticmethod
def _find_login_page_url(html: etree._Element, current_url: str) -> Optional[str]:
"""
从首页查找同源登录入口,避免把账号密码提交到跨域页面。
"""
login_hrefs = html.xpath(
"//a["
"contains(translate(@href, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'login')"
" or contains(translate(@href, 'ABCDEFGHIJKLMNOPQRSTUVWXYZ', 'abcdefghijklmnopqrstuvwxyz'), 'signin')"
"]/@href"
)
current = urlparse(current_url)
for href in login_hrefs:
login_url = urljoin(current_url, href)
target = urlparse(login_url)
if target.scheme in ("http", "https") and \
target.scheme == current.scheme and target.netloc == current.netloc:
return str(login_url)
return None
def get_site_cookie_ua(self,
url: str,
username: str,
password: str,
two_step_code: Optional[str] = None,
proxies: Optional[dict[str, object]] = None,
timeout: Optional[int] = None) -> Tuple[Optional[str], Optional[str], str]:
"""
获取站点 Cookie、User-Agent 和兼容错误消息。
:param url: 站点登录地址
:param username: 登录用户名
:param password: 登录密码
:param two_step_code: 可选的二步验证码或密钥
:param proxies: 浏览器代理配置;None 表示不指定代理
:param timeout: 浏览器超时时间,单位为秒;None 使用实现默认值
:return: Cookie、User-Agent 和错误说明;失败时保留原有空值与错误文本
"""
return self._get_site_cookie_ua_impl(
url=url,
username=username,
password=password,
two_step_code=two_step_code,
proxies=proxies,
timeout=timeout,
)
def _get_site_cookie_ua_impl(self,
url: str,
username: str,
password: str,
two_step_code: Optional[str] = None,
proxies: Optional[dict[str, object]] = None,
timeout: Optional[int] = None) -> Tuple[Optional[str], Optional[str], str]:
"""
获取站点cookie和ua。
:param url: 站点地址
:param username: 用户名
:param password: 密码
:param two_step_code: 二步验证码或密钥
:param proxies: 代理
:param timeout: 超时时间
:return: Cookie、User-Agent 与错误说明组成的三元组
"""
def __page_handler(page: Any) -> CookieResult:
"""
在受控浏览器页面内完成登录并返回当前会话凭据。
"""
html_text = self.get_page_content(page)
if not html_text:
return None, None, "获取源码失败"
html = etree.HTML(html_text)
if html is None:
return None, None, "解析网页源码失败"
issue = self._page_issue(html_text, getattr(page, "url", "") or url)
if issue:
return None, None, issue
username_xpath, password_xpath = self._find_login_fields(html)
if not username_xpath or not password_xpath:
login_url = self._find_login_page_url(html, getattr(page, "url", "") or url)
if login_url:
try:
page.goto(
login_url,
wait_until="domcontentloaded",
timeout=(timeout or 60) * 1000,
)
except Exception as error:
return None, None, f"打开登录页面失败:{str(error)}"
html_text = self.get_page_content(page)
html = etree.HTML(html_text) if html_text else None
if html is None:
return None, None, "解析网页源码失败"
issue = self._page_issue(html_text or "", getattr(page, "url", "") or url)
if issue:
return None, None, issue
username_xpath, password_xpath = self._find_login_fields(html)
if not username_xpath or not password_xpath:
try:
page.wait_for_selector("xpath=//input[@type='password']", timeout=5000)
except Exception:
pass
latest_text = self.get_page_content(page)
html = etree.HTML(latest_text) if latest_text else None
if html is not None:
html_text = latest_text or html_text
username_xpath, password_xpath = self._find_login_fields(html)
if not username_xpath:
return None, None, "未找到用户名输入框,登录表单字段无法识别"
if not password_xpath:
return None, None, "未找到密码输入框,登录表单字段无法识别"
otp_code = TwoFactorAuth(two_step_code).get_code()
twostep_xpath = self._first_xpath(html, "twostep") if otp_code else None
captcha_xpath, captcha_img_url = self._find_captcha_source(html)
if captcha_xpath and not captcha_img_url:
return None, None, "检测到验证码输入框,但未找到验证码图片"
submit_xpath = self._first_xpath(html, "submit")
if not submit_xpath:
return None, None, "未找到登录按钮,请在登录页提供可提交的按钮"
try:
page.wait_for_selector(submit_xpath)
page.fill(username_xpath, username)
page.fill(password_xpath, password)
for xpath in self._SITE_LOGIN_XPATH.get("remember", []):
remember_element = page.query_selector(xpath)
if not remember_element:
continue
try:
checked = remember_element.get_attribute("aria-checked")
if checked is None:
checked = "true" if remember_element.is_checked() else "false"
if checked != "true":
remember_element.click(timeout=3000)
break
except Exception as error:
logger.warning(f"勾选记住登录选项失败:{str(error)},尝试下一候选")
if twostep_xpath:
page.fill(twostep_xpath, otp_code)
if captcha_xpath and captcha_img_url:
captcha_element = page.query_selector(captcha_xpath)
if captcha_element is None or captcha_element.is_visible():
captcha = ""
for attempt in range(self._MAX_CAPTCHA_ATTEMPTS):
current_text = self.get_page_content(page)
current_html = etree.HTML(current_text) if current_text else html
if current_html is None:
current_html = html
if current_html is None:
continue
current_xpath, current_image = self._find_captcha_source(current_html)
captcha_xpath = current_xpath or captcha_xpath
captcha_img_url = current_image or captcha_img_url
code_url = self.__get_captcha_url(
getattr(page, "url", "") or url,
captcha_img_url,
)
cookie = self.parse_cookies(page.context.cookies())
ua = self._page_user_agent(page)
captcha = self.__get_captcha_text(
cookie=cookie,
ua=ua,
code_url=code_url,
)
if captcha:
logger.info("验证码已完成识别,第 %s/%s 次尝试", attempt + 1, self._MAX_CAPTCHA_ATTEMPTS)
break
if attempt < self._MAX_CAPTCHA_ATTEMPTS - 1:
self._refresh_captcha(page, current_html)
time.sleep(0.5)
if not captcha:
return None, None, f"验证码识别失败,已尝试 {self._MAX_CAPTCHA_ATTEMPTS} 次,请手动刷新验证码后重试"
page.fill(captcha_xpath, captcha)
self._click_with_fallback(page, submit_xpath)
page.wait_for_load_state("networkidle", timeout=30 * 1000)
except Exception as error:
logger.error(f"仿真登录失败:{str(error)}")
return None, None, f"仿真登录失败:{str(error)}"
current_url = (getattr(page, "url", "") or "").lower()
if "verify" in current_url:
if not otp_code:
return None, None, "站点要求二次验证码,请提供二步验证码或密钥"
html_text = self.get_page_content(page)
html = etree.HTML(html_text) if html_text else None
verify_xpath = self._first_xpath(html, "twostep") if html is not None else None
if verify_xpath:
try:
page.fill(verify_xpath, TwoFactorAuth(two_step_code).get_code())
self._click_with_fallback(page, submit_xpath)
page.wait_for_load_state("networkidle", timeout=30 * 1000)
except Exception as error:
logger.error(f"二次验证码输入失败:{str(error)}")
return None, None, f"二次验证码输入失败:{str(error)}"
first_failure_html: Optional[str] = None
for index in range(3):
if index:
time.sleep(2)
latest_text = self.get_page_content(page)
if not latest_text:
continue
if SiteUtils.is_logged_in(latest_text):
return self.parse_cookies(page.context.cookies()), self._page_user_agent(page), ""
if first_failure_html is None:
first_failure_html = latest_text
failure_issue = self._page_issue(
latest_text,
getattr(page, "url", "") or url,
)
if failure_issue or self._error_message(latest_text):
first_failure_html = latest_text
break
if not first_failure_html:
return None, None, "获取登录结果源码失败"
failure_issue = self._page_issue(
first_failure_html,
getattr(page, "url", "") or url,
)
if failure_issue:
return None, None, failure_issue
error_message = self._error_message(first_failure_html)
return None, None, f"登录失败:{error_message}" if error_message else "登录失败:页面未提供具体原因"
if not url or not username or not password:
return None, None, "参数错误"
browser_port, _, _ = _require_cookie_ports()
return browser_port.action(url=url,
callback=__page_handler,
proxies=proxies,
timeout=timeout)
@staticmethod
def __get_captcha_text(cookie: str, ua: str, code_url: str) -> str:
"""
识别验证码图片的内容。
"""
if not code_url:
return ""
_, http_port, ocr_port = _require_cookie_ports()
content = http_port.fetch(url=code_url, cookie=cookie, ua=ua)
if not content:
return ""
return ocr_port.recognize(content)
@staticmethod
def __get_captcha_url(siteurl: str, imageurl: str) -> str:
"""
按页面地址解析验证码图片地址,兼容绝对、根相对和路径相对 URL。
"""
if not siteurl or not imageurl:
return ""
return urljoin(siteurl, imageurl)