feat(agent): improve browser-assisted site cookie login

This commit is contained in:
jxxghp
2026-09-11 14:30:53 +08:00
parent 6fe5bb42c2
commit 06ed22c584
27 changed files with 1107 additions and 294 deletions

View File

@@ -1,4 +1,5 @@
import base64
import binascii
from typing import Optional
from app.adapters.network.http import RequestUtils
@@ -14,7 +15,9 @@ class OcrHelper:
"""初始化 OCR 服务地址,优先使用组合根设置快照。"""
if ocr_base_url is None:
ocr_base_url = get_runtime_setting('OCR_HOST')
self._ocr_b64_url = f"{str(ocr_base_url).rstrip('/')}/captcha/base64"
base_url = str(ocr_base_url).rstrip('/')
self._ocr_b64_url = f"{base_url}/captcha/base64"
self._ocr_image_url = f"{base_url}/captcha/image"
def get_captcha_text(
self,
@@ -22,33 +25,48 @@ class OcrHelper:
image_b64: Optional[str] = None,
cookie: Optional[str] = None,
ua: Optional[str] = None,
image_data: Optional[bytes] = None,
) -> str:
"""
根据图片地址,获取验证码图片并识别内容
获取验证码图片并识别内容,优先使用原始图片字节接口。
:param image_url: 图片地址
:param image_b64: 图片base64跳过图片地址下载
:param cookie: 下载图片使用的cookie
:param ua: 下载图片使用的ua
:param image_data: 已取得的原始图片字节,跳过下载和 Base64 编码
:return: 验证码识别结果,失败时返回空字符串
"""
image_b64 = self._normalize_image_base64(image_b64)
if image_url:
raw_image = image_data or b""
if not raw_image and image_url:
data_url_b64 = self._extract_data_url_base64(image_url)
if data_url_b64:
image_b64 = self._normalize_image_base64(data_url_b64)
try:
raw_image = base64.b64decode(
self._normalize_image_base64(data_url_b64),
validate=True,
)
except (ValueError, binascii.Error):
return ""
else:
ret = RequestUtils(ua=ua,
cookies=cookie).get_res(image_url)
if ret is not None:
image_bin = ret.content
if not image_bin:
raw_image = ret.content or b""
if not raw_image:
return ""
image_b64 = base64.b64encode(image_bin).decode()
if not image_b64:
return ""
ret = RequestUtils(content_type="application/json").post_res(
url=self._ocr_b64_url,
json={"base64_img": image_b64})
if raw_image:
ret = RequestUtils(content_type="application/octet-stream").post_res(
url=self._ocr_image_url,
data=raw_image,
)
else:
image_b64 = self._normalize_image_base64(image_b64)
if not image_b64:
return ""
ret = RequestUtils(content_type="application/json").post_res(
url=self._ocr_b64_url,
json={"base64_img": image_b64},
)
if ret:
return ret.json().get("result") or ""
return ""

View File

@@ -661,6 +661,52 @@ class BrowserSessionHelper:
session.active_index = min(session.active_index, len(session.pages) - 1)
return BrowserSessionHelper.list_tabs(session)
@staticmethod
def get_cookies(
session: _BrowserSessionState,
page: Optional[BrowserPage] = None,
) -> dict[str, Any]:
"""读取活动页面所属域名的 Cookie、User-Agent 和页面地址。"""
active_page = page or session.active_page
current_url = getattr(active_page, "url", "") or ""
current_host = (urlparse(current_url).hostname or "").lower().rstrip(".")
values: dict[str, str] = {}
if session.cookies:
injected = cookie_parse(session.cookies)
if isinstance(injected, dict):
values.update({str(key): str(value) for key, value in injected.items()})
for cookie in session.context.cookies() or []:
if not isinstance(cookie, dict):
continue
name = cookie.get("name")
value = cookie.get("value")
if name is None or value is None:
continue
domain = str(cookie.get("domain") or "").lower().lstrip(".").rstrip(".")
if current_host and domain and current_host != domain and not current_host.endswith(f".{domain}"):
continue
values[str(name)] = str(value)
user_agent = session.user_agent
if not user_agent:
try:
user_agent = str(
active_page.evaluate("() => window.navigator.userAgent") or ""
)
except Exception:
user_agent = ""
cookie_header = "; ".join(f"{name}={value}" for name, value in values.items())
return {
"url": current_url,
"user_agent": user_agent or "",
"cookie": cookie_header,
"cookies": [
{"name": name, "value": value}
for name, value in values.items()
],
}
def goto(
self,
page: BrowserPage,

View File

@@ -344,6 +344,7 @@ API_EXTENDED_OPERATION_SPECS: tuple[ApiOperationSpec, ...] = (
confirmation=_CONFIRM,
recovery=RecoveryMode.RECONCILE,
),
_write("site.cookie.set", sensitivity=ResultSensitivity.PRIVATE),
_write(
"site.reset",
effect=ActionEffect.DESTRUCTIVE_WRITE,
@@ -703,6 +704,7 @@ API_OPERATION_ROUTES: dict[str, ApiOperationRoute] = {
"site.auth.options": ApiOperationRoute("GET", "/api/v1/site/auth"),
"site.authenticate": ApiOperationRoute("POST", "/api/v1/site/auth"),
"site.cookiecloud.sync": ApiOperationRoute("POST", "/api/v1/site/cookiecloud"),
"site.cookie.set": ApiOperationRoute("POST", "/api/v1/site/cookie/{site_id}/set"),
"site.reset": ApiOperationRoute("POST", "/api/v1/site/reset"),
"site.priorities.update": ApiOperationRoute("POST", "/api/v1/site/priorities"),
"site.userdata.refresh": ApiOperationRoute("POST", "/api/v1/site/userdata/{site_id}"),

View File

@@ -67,6 +67,7 @@ OPERATION_DESCRIPTIONS = {
"search.results": "Read the most recent torrent-search context and result set.",
"search.torrents": "Search torrent sites for one canonical media identity.",
"site.cookie.update": "Log in to one site and refresh its stored authentication cookie.",
"site.cookie.set": "Persist a browser-obtained authentication cookie and optional User-Agent for one site without replacing other settings.",
"site.list": "List configured sites with status/name filters; authentication fields are returned only to a superuser.",
"site.test": "Test connectivity and authentication for one configured site.",
"site.update": "Update one configured site's complete settings.",
@@ -654,6 +655,7 @@ MODEL_DESCRIPTIONS = {
"PluginSourceInstallRequest": "Explicit online-source installation request for an unbound plugin.",
"Site-Input": "Complete site configuration and runtime state.",
"SiteCookieUpdate": "Site login request used to refresh the stored cookie and User-Agent.",
"SiteCookieSet": "Browser-obtained site authentication cookie and optional User-Agent to persist without changing other site settings.",
"Subscribe": "Movie, TV, or music subscription input model.",
"SystemSettingsUpdateRequest": "One registered system-setting update request.",
"SystemUpdateRequest": "Selected MoviePilot application or site-resource update target.",

View File

@@ -3558,6 +3558,33 @@
"title": "SiteAuth",
"type": "object"
},
"SiteCookieSet": {
"description": "Browser-obtained site authentication cookie and optional User-Agent to persist without changing other site settings.",
"properties": {
"cookie": {
"description": "Site authentication Cookie header obtained from a trusted browser session.",
"title": "Cookie",
"type": "string"
},
"ua": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
],
"description": "User-Agent associated with the authenticated browser session.",
"title": "Ua"
}
},
"required": [
"cookie"
],
"title": "SiteCookieSet",
"type": "object"
},
"SiteCookieUpdate": {
"description": "Site login request used to refresh the stored cookie and User-Agent.",
"properties": {
@@ -11302,6 +11329,43 @@
"total_count_field": "collection.total_count"
}
},
{
"additionalProperties": false,
"description": "Persist a browser-obtained authentication cookie and optional User-Agent for one site without replacing other settings. Method: POST. Path: /api/v1/site/cookie/{site_id}/set. Effect: reversible_write.",
"properties": {
"body": {
"$ref": "#/$defs/SiteCookieSet",
"description": "Request value for site.cookie.set. Persist a browser-obtained authentication cookie and optional User-Agent for one site without replacing other settings. Use the exact type and fields below."
},
"operation_id": {
"const": "site.cookie.set",
"description": "Exact allowlisted MoviePilot operation ID selecting this oneOf branch.",
"type": "string"
},
"path_params": {
"additionalProperties": false,
"description": "Resource identity placeholders for site.cookie.set. Persist a browser-obtained authentication cookie and optional User-Agent for one site without replacing other settings. Use only the named fields below.",
"properties": {
"site_id": {
"description": "Persistent site ID returned by site.list.",
"title": "Site Id",
"type": "integer"
}
},
"required": [
"site_id"
],
"type": "object"
}
},
"required": [
"operation_id",
"path_params",
"body"
],
"title": "site.cookie.set",
"type": "object"
},
{
"additionalProperties": false,
"description": "Log in to one site and refresh its stored authentication cookie. Method: POST. Path: /api/v1/site/cookie/{site_id}. Effect: reversible_write.",
@@ -15824,6 +15888,7 @@
"site.auth.options",
"site.authenticate",
"site.category",
"site.cookie.set",
"site.cookie.update",
"site.cookiecloud.sync",
"site.delete",

View File

@@ -38,6 +38,7 @@ class BrowserAction(str, Enum):
SNAPSHOT = "snapshot"
GET_CONTENT = "get_content"
SCREENSHOT = "screenshot"
GET_COOKIES = "get_cookies"
CLICK = "click"
CLICK_REF = "click_ref"
FILL = "fill"
@@ -64,6 +65,7 @@ class BrowseWebpageInput(BaseModel):
"- 'snapshot': Get current page snapshot with interactive element refs\n"
"- 'get_content': Get current page content (text or HTML)\n"
"- 'screenshot': Take a screenshot of the current page, returns base64 image\n"
"- 'get_cookies': Get the current page domain's cookies and User-Agent (admin only)\n"
"- 'click': Click on an element specified by selector\n"
"- 'click_ref': Click an element by ref from the latest snapshot\n"
"- 'fill': Fill text into an input element specified by selector\n"
@@ -139,6 +141,7 @@ class BrowseWebpageTool(MoviePilotTool):
description: str = (
"Control a real browser (Playwright) to interact with web pages. "
"Supports navigating to URLs, reading page content, taking screenshots, "
"reading the current authenticated page cookies for administrator-only site-cookie workflows, "
"clicking elements, filling forms, selecting dropdown options, executing JavaScript, waiting for elements, "
"and managing tabs. "
"Use this tool when you need to interact with dynamic web pages, "
@@ -212,6 +215,7 @@ class BrowseWebpageTool(MoviePilotTool):
"snapshot": "读取页面快照",
"get_content": "获取页面内容",
"screenshot": "截取页面截图",
"get_cookies": "读取当前页面 Cookie仅管理员",
"click": f"点击元素: {selector}",
"click_ref": f"点击元素引用: {kwargs.get('ref', '')}",
"fill": f"填写表单: {selector}",
@@ -295,6 +299,11 @@ class BrowseWebpageTool(MoviePilotTool):
and not await self.is_admin_user()
):
return "错误: 'evaluate' 操作仅允许管理员使用"
if (
browser_action == BrowserAction.GET_COOKIES
and not await self.is_admin_user()
):
return "错误: 'get_cookies' 操作仅允许管理员使用"
if (
browser_action in (BrowserAction.FOCUS_TAB, BrowserAction.CLOSE_TAB)
and tab_index is None
@@ -439,6 +448,9 @@ class BrowseWebpageTool(MoviePilotTool):
elif browser_action == BrowserAction.SCREENSHOT:
return self._action_screenshot(page)
elif browser_action == BrowserAction.GET_COOKIES:
return self._action_get_cookies(session, page)
elif browser_action == BrowserAction.CLICK:
return self._action_click(page, selector, timeout)
@@ -609,6 +621,20 @@ class BrowseWebpageTool(MoviePilotTool):
}
return BrowseWebpageTool._json_response(result)
@staticmethod
def _action_get_cookies(session: Any, page: Any) -> str:
"""读取当前页面域 Cookie返回给管理员用于精细 Cookie 写入。"""
result = BrowserSessionHelper.get_cookies(session, page)
result.update(
{
"success": True,
"execution_outcome": "succeeded",
"title": page.title(),
"note": "Cookie 仅返回给管理员调用方,请勿在日志或消息中转发。",
}
)
return BrowseWebpageTool._json_response(result)
@staticmethod
def _action_click(
page,

View File

@@ -3,25 +3,32 @@
import json
from typing import Optional, Type
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from app.adapters.external.ocr import OcrHelper
from app.adapters.network.browser import BrowserSessionHelper
from app.agent.tools.base import MoviePilotTool
from app.agent.tools.tags import ToolTag
from app.adapters.network.browser import BrowserSessionHelper
from app.adapters.external.ocr import OcrHelper
from app.runtime.log import logger
class RecognizeCaptchaInput(BaseModel):
"""识别图形验证码工具的输入参数模型。"""
image_url: str = Field(
...,
image_url: Optional[str] = Field(
None,
description=(
"Captcha image URL obtained from the browser page, usually an img.src value. "
"Supports http/https URLs and data:image/...;base64,... URLs."
),
)
image_data: Optional[bytes] = Field(
None,
description=(
"Raw image bytes already obtained by the caller. Prefer this when the browser "
"or another tool provides binary image data; it is sent to OCR without a Base64 conversion."
),
)
cookie: Optional[str] = Field(
None,
description=(
@@ -38,6 +45,13 @@ class RecognizeCaptchaInput(BaseModel):
description="Allow captcha image URLs on localhost, loopback, private, or link-local addresses.",
)
@model_validator(mode="after")
def require_image_source(self) -> "RecognizeCaptchaInput":
"""确保验证码工具至少收到图片地址或原始图片字节。"""
if not self.image_url and not self.image_data:
raise ValueError("image_url or image_data is required")
return self
class RecognizeCaptchaTool(MoviePilotTool):
"""
@@ -53,6 +67,7 @@ class RecognizeCaptchaTool(MoviePilotTool):
description: str = (
"Recognize a graphic captcha image and return the captcha text. "
"Use this after browser automation extracts a captcha img.src from the page. "
"It also accepts raw image_data bytes and sends them directly to OCR without Base64 conversion. "
"Pass cookie and user_agent when the image URL requires the current browser session. "
"Supports http/https image URLs and data:image/...;base64,... URLs. "
"For safety, localhost and private network URLs are blocked by default unless "
@@ -63,6 +78,9 @@ class RecognizeCaptchaTool(MoviePilotTool):
def get_tool_message(self, **kwargs) -> Optional[str]:
"""根据验证码图片参数生成友好的提示消息。"""
image_url = str(kwargs.get("image_url") or "")
image_data = kwargs.get("image_data")
if image_data:
return f"识别图形验证码: raw image ({len(image_data)} bytes)"
if image_url.lower().startswith("data:image/"):
return "识别图形验证码: data image"
return f"识别图形验证码: {image_url}"
@@ -84,21 +102,29 @@ class RecognizeCaptchaTool(MoviePilotTool):
@staticmethod
def _recognize_captcha_sync(
image_url: str,
image_url: Optional[str] = None,
cookie: Optional[str] = None,
user_agent: Optional[str] = None,
allow_private_network: bool = False,
image_data: Optional[bytes] = None,
) -> str:
"""
在线程池中下载并识别验证码图片。
:param image_url: 验证码图片地址
:param image_data: 已取得的原始验证码图片字节
:param cookie: 下载图片时使用的 Cookie
:param user_agent: 下载图片时使用的 User-Agent
:param allow_private_network: 是否允许访问本机或私网地址
:return: 验证码文本,失败时返回空字符串
"""
clean_url = (image_url or "").strip()
if image_data:
return OcrHelper().get_captcha_text(
image_data=image_data,
cookie=cookie,
ua=user_agent,
)
if not clean_url:
return ""
if not clean_url.lower().startswith("data:image/"):
@@ -106,24 +132,22 @@ class RecognizeCaptchaTool(MoviePilotTool):
clean_url,
allow_private_network=allow_private_network,
)
return OcrHelper().get_captcha_text(
image_url=clean_url,
cookie=cookie,
ua=user_agent,
)
return OcrHelper().get_captcha_text(image_url=clean_url, cookie=cookie, ua=user_agent)
async def run(
self,
image_url: str,
image_url: Optional[str] = None,
cookie: Optional[str] = None,
user_agent: Optional[str] = None,
allow_private_network: bool = False,
image_data: Optional[bytes] = None,
**kwargs,
) -> str:
"""
识别指定图片地址中的图形验证码文本。
:param image_url: 验证码图片地址
:param image_data: 已取得的原始验证码图片字节
:param cookie: 下载图片时使用的 Cookie
:param user_agent: 下载图片时使用的 User-Agent
:param allow_private_network: 是否允许访问本机或私网地址
@@ -131,7 +155,8 @@ class RecognizeCaptchaTool(MoviePilotTool):
"""
logger.info(
f"执行工具: {self.name}, "
f"参数: image_url={self._format_image_url_for_log(image_url)}"
f"参数: image_url={self._format_image_url_for_log(image_url or '')}, "
f"image_data={'%s bytes' % len(image_data) if image_data else 'none'}"
)
try:
@@ -142,6 +167,7 @@ class RecognizeCaptchaTool(MoviePilotTool):
cookie,
user_agent,
allow_private_network,
image_data,
)
if captcha_text:
return json.dumps(

View File

@@ -43,6 +43,7 @@ from app.schemas.common import JsonObject as _SchemaJsonObject
from app.schemas.response import Response as _SchemaResponse
from app.schemas.site import SiteAuth as _SchemaSiteAuth
from app.schemas.site import SiteCategory as _SchemaSiteCategory
from app.schemas.site import SiteCookieSet as _SchemaSiteCookieSet
from app.schemas.site import SiteCookieUpdate as _SchemaSiteCookieUpdate
from app.schemas.site import SiteIconData as _SchemaSiteIconData
from app.schemas.site import SiteMappingData as _SchemaSiteMappingData
@@ -420,6 +421,26 @@ def update_cookie_by_body(
)
@router.post(
"/cookie/{site_id}/set",
summary="直接保存站点Cookie&UA",
response_model=_SchemaResponse[None],
)
async def set_cookie_by_body(
site_id: int,
site_cookie_set: _SchemaSiteCookieSet,
command: SiteMutationCommand = Depends(get_site_mutation_command),
_: ApiPrincipal = Depends(get_current_active_manage_user_async),
) -> Any:
"""保存受信任浏览器会话取得的 Cookie不改写站点其他配置。"""
result = await command.set_cookie(
site_id=site_id,
cookie=site_cookie_set.cookie,
ua=site_cookie_set.ua,
)
return _SchemaResponse(success=result.success, message=result.message)
@router.get(
"/cookie/{site_id}", summary="更新站点Cookie&UA", response_model=_SchemaResponse[None]
)

View File

@@ -1,4 +1,3 @@
import base64
import time
from typing import Any, Callable, Optional, Protocol, Tuple
from urllib.parse import urljoin, urlparse
@@ -7,7 +6,6 @@ from lxml import etree
from app.application.security.twofactor import TwoFactorAuth
from app.domain.site import SiteUtils
from app.foundation import url as url_tools
from app.runtime.log import logger
CookieResult = Tuple[Optional[str], Optional[str], str]
@@ -31,8 +29,8 @@ class CaptchaHttpPort(Protocol):
class CaptchaOcrPort(Protocol):
"""声明验证码图片识别能力。"""
def recognize(self, image_b64: str) -> str:
"""识别 Base64 编码的验证码图片"""
def recognize(self, image_data: bytes) -> str:
"""识别原始验证码图片字节,避免在应用层重复 Base64 编码。"""
_cookie_browser_port: Optional[CookieBrowserPort] = None
@@ -67,25 +65,66 @@ def _require_cookie_ports() -> Tuple[CookieBrowserPort, CaptchaHttpPort, Captcha
class CookieHelper:
"""处理站点登录表单、验证码和 Cookie 获取流程。"""
_MAX_CAPTCHA_ATTEMPTS = 3
# 站点登录界面元素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',
@@ -93,6 +132,14 @@ class CookieHelper:
'//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"]',
@@ -101,9 +148,16 @@ class CookieHelper:
'//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")]',
@@ -116,6 +170,222 @@ class CookieHelper:
]
}
@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 = (
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
@staticmethod
def get_page_content(page: Any, retries: int = 3, interval: float = 1.0) -> Optional[str]:
"""
@@ -148,15 +418,18 @@ class CookieHelper:
@staticmethod
def parse_cookies(cookies: list) -> str:
"""
将浏览器返回的cookies转化为字符串
"""
"""将浏览器 Cookie 列表转成请求头字符串,并忽略不完整条目。"""
if not cookies:
return ""
cookie_str = ""
values: list[str] = []
for cookie in cookies:
cookie_str += f"{cookie['name']}={cookie['value']}; "
return cookie_str
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]:
@@ -212,219 +485,166 @@ class CookieHelper:
"""
def __page_handler(page: Any) -> CookieResult:
"""
页面处理
:return: Cookie和UA
"""
# 登录页面代码
"""在受控浏览器页面内完成登录并返回当前会话凭据。"""
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, "解析网页源码失败"
try:
username_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("username"):
if html.xpath(xpath):
username_xpath = xpath
break
if not username_xpath:
login_url = self._find_login_page_url(html, page.url or url)
if login_url:
try:
page.goto(
login_url,
wait_until="domcontentloaded",
timeout=(timeout or 60) * 1000,
)
except Exception as e:
return None, None, f"打开登录页面失败:{str(e)}"
html_text = self.get_page_content(page)
html = etree.HTML(html_text) if html_text else None
if html is None:
return None, None, "解析网页源码失败"
for xpath in self._SITE_LOGIN_XPATH["username"]:
if html.xpath(xpath):
username_xpath = xpath
break
if not username_xpath:
# 登录页可能为JS动态渲染如SPA等待用户名输入框出现后重试
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:
username_union_xpath = " | ".join(self._SITE_LOGIN_XPATH.get("username"))
page.wait_for_selector(f"xpath={username_union_xpath}", timeout=5000)
except Exception:
pass
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, "解析网页源码失败"
for xpath in self._SITE_LOGIN_XPATH.get("username"):
if html.xpath(xpath):
username_xpath = xpath
break
if not username_xpath:
return None, None, "未找到用户名输入框"
# 查找密码输入框
password_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("password"):
if html.xpath(xpath):
password_xpath = xpath
break
if not password_xpath:
return None, None, "未找到密码输入框"
# 处理二步验证码
otp_code = TwoFactorAuth(two_step_code).get_code()
# 查找二步验证码输入框
twostep_xpath = None
if otp_code:
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
if html.xpath(xpath):
twostep_xpath = xpath
break
# 查找验证码输入框
captcha_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("captcha"):
if html.xpath(xpath):
captcha_xpath = xpath
break
# 查找验证码图片
captcha_img_url = None
if captcha_xpath:
for xpath in self._SITE_LOGIN_XPATH.get("captcha_img"):
if html.xpath(xpath):
captcha_img_url = html.xpath(xpath)[0]
break
if not captcha_img_url:
return None, None, "未找到验证码图片"
# 查找登录按钮
submit_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("submit"):
if html.xpath(xpath):
submit_xpath = xpath
break
if not submit_xpath:
return None, 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(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 e:
# 当前候选不可操作(如隐藏元素)时继续尝试后续候选
logger.warning(f"勾选记住登录选项失败:{str(e)},尝试下一候选")
continue
# 输入二步验证码
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_visible():
# 验证码图片地址
code_url = self.__get_captcha_url(url, captcha_img_url)
# 获取当前的cookie和ua
cookie = self.parse_cookies(page.context.cookies())
ua = page.evaluate("() => window.navigator.userAgent")
# 自动OCR识别验证码
captcha = self.__get_captcha_text(cookie=cookie, ua=ua, code_url=code_url)
if captcha:
logger.info("验证码地址为:%s,识别结果:%s" % (code_url, captcha))
else:
return None, None, "验证码识别失败"
# 输入验证码
captcha_element.fill(captcha)
else:
# 不可见元素不处理
pass
# 点击登录按钮
page.click(submit_xpath)
page.wait_for_load_state("networkidle", timeout=30 * 1000)
except Exception as e:
logger.error(f"仿真登录失败:{str(e)}")
return None, None, f"仿真登录失败:{str(e)}"
# 对于某二次验证码为单页面的站点,输入二次验证码
if "verify" in page.url:
if not otp_code:
return None, None, "需要二次验证码"
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, "解析网页源码失败"
for xpath in self._SITE_LOGIN_XPATH.get("twostep"):
if html.xpath(xpath):
try:
# 刷新一下 2fa code
otp_code = TwoFactorAuth(two_step_code).get_code()
page.fill(xpath, otp_code)
# 登录按钮 xpath 理论上相同,不再重复查找
page.click(submit_xpath)
page.wait_for_load_state("networkidle", timeout=30 * 1000)
except Exception as e:
logger.error(f"二次验证码输入失败:{str(e)}")
return None, None, f"二次验证码输入失败:{str(e)}"
break
# 登录后的源码(部分站点登录成功后由前端脚本延迟跳转,等待并重试判定)
html_text = None
for i in range(3):
if i:
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()), \
page.evaluate("() => window.navigator.userAgent"), ""
# 保留首个快照用于失败时解析错误信息,避免提示被后续跳转或自动消失覆盖
if html_text is None:
html_text = latest_text
# 页面已出现明确的登录错误信息时,以该快照为准并提前结束重试
latest_html = etree.HTML(latest_text)
if latest_html is not None and \
any(latest_html.xpath(x) for x in self._SITE_LOGIN_XPATH.get("error")):
html_text = latest_text
break
if not html_text:
return None, None, "获取网页源码失败"
else:
# 从登录后的页面读取错误信息
html = etree.HTML(html_text)
if html is None:
return None, None, "登录失败"
error_xpath = None
for xpath in self._SITE_LOGIN_XPATH.get("error"):
if html.xpath(xpath):
error_xpath = xpath
break
if not error_xpath:
return None, None, "登录失败"
else:
error_msg = html.xpath(error_xpath)[0]
return None, None, error_msg
finally:
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:
del html
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, "参数错误"
@@ -446,15 +666,11 @@ class CookieHelper:
content = http_port.fetch(url=code_url, cookie=cookie, ua=ua)
if not content:
return ""
return ocr_port.recognize(base64.b64encode(content).decode())
return ocr_port.recognize(content)
@staticmethod
def __get_captcha_url(siteurl: str, imageurl: str) -> str:
"""
获取验证码图片的URL
"""
"""按页面地址解析验证码图片地址,兼容绝对、根相对和路径相对 URL。"""
if not siteurl or not imageurl:
return ""
if imageurl.startswith("/"):
imageurl = imageurl[1:]
return "%s/%s" % (url_tools.base_url(siteurl), imageurl)
return urljoin(siteurl, imageurl)

View File

@@ -191,6 +191,31 @@ class SiteMutationCommand:
)
return SiteMutationResult(True)
async def set_cookie(
self,
site_id: int,
cookie: str,
ua: Optional[str] = None,
) -> SiteMutationResult:
"""仅更新站点 Cookie 与可选 User-Agent并发布站点更新事件。"""
site_info = await self._repository.get_by_id(site_id)
if site_info is None:
return SiteMutationResult(False, "站点不存在")
values: dict[str, JsonData] = {"cookie": cookie}
if ua is not None:
values["ua"] = ua
await self._repository.stage_update(site_id, SiteMutation(values))
await self._commit()
await self._publish_updated(
{
"site_id": site_id,
"domain": site_info.domain,
"name": site_info.name,
"site_url": site_info.url,
}
)
return SiteMutationResult(True)
async def update_priorities(
self,
priorities: Sequence[Mapping[str, JsonData]],

View File

@@ -425,6 +425,7 @@ SCHEMA_EXPORTS = {
'SiteAuth': ('app.schemas.site', 'SiteAuth'),
'SiteCategory': ('app.schemas.site', 'SiteCategory'),
'SiteCookieUpdate': ('app.schemas.site', 'SiteCookieUpdate'),
'SiteCookieSet': ('app.schemas.site', 'SiteCookieSet'),
'SiteEventData': ('app.schemas.event', 'SiteEventData'),
'SiteIconData': ('app.schemas.site', 'SiteIconData'),
'SiteMappingData': ('app.schemas.site', 'SiteMappingData'),

View File

@@ -152,6 +152,13 @@ class SiteCookieUpdate(BaseModel):
code: Optional[str] = Field(None, description="二步验证码或密钥")
class SiteCookieSet(BaseModel):
"""直接保存浏览器登录后取得的站点 Cookie 与 User-Agent。"""
cookie: str = Field(..., description="Site authentication Cookie header obtained from a trusted browser session.")
ua: Optional[str] = Field(None, description="User-Agent associated with the authenticated browser session.")
class SiteCategory(BaseModel):
"""站点资源分类。"""

View File

@@ -131,9 +131,9 @@ class _CaptchaHttpAdapter:
class _CaptchaOcrAdapter:
"""用 OcrHelper 实现验证码识别窄端口。"""
def recognize(self, image_b64: str) -> str:
"""识别 Base64 验证码图片"""
text: str = OcrHelper().get_captcha_text(image_b64=image_b64)
def recognize(self, image_data: bytes) -> str:
"""把原始验证码图片字节交给 OCR 服务识别"""
text: str = OcrHelper().get_captcha_text(image_data=image_data)
return text
@@ -258,4 +258,3 @@ def reset_site_access_composition() -> None:
reset_torrent_port()
reset_cookie_ports()
reset_rss_ports()

View File

@@ -2,7 +2,7 @@
"disposition_counts": {
"alternate-auth-duplicate": 11,
"consolidated": 71,
"gateway": 217,
"gateway": 218,
"provider-skill": 12,
"stream_or_binary": 10,
"transport_or_identity": 66,
@@ -18,10 +18,10 @@
"reason": "The executor validates and expands this bounded source placeholder to one of tmdb, douban, bangumi, or anilist before calling the corresponding concrete OpenAPI route."
}
],
"gateway_http_route_count": 218,
"gateway_operation_count": 220,
"matched_gateway_http_route_count": 217,
"openapi_operation_count": 400,
"gateway_http_route_count": 219,
"gateway_operation_count": 221,
"matched_gateway_http_route_count": 218,
"openapi_operation_count": 401,
"operations": [
{
"disposition": "consolidated",
@@ -3177,6 +3177,20 @@
"site"
]
},
{
"disposition": "gateway",
"method": "POST",
"operation_ids": [
"site.cookie.set"
],
"owner": "moviepilot-api",
"path": "/api/v1/site/cookie/{site_id}/set",
"reason": "Executable through moviepilot_api; exact inputs are generated into MCP tools/list and SKILL.md.",
"summary": "直接保存站点Cookie&UA",
"tags": [
"site"
]
},
{
"disposition": "gateway",
"method": "POST",

View File

@@ -5,10 +5,10 @@
## Result
- OpenAPI HTTP operations: **400**
- Stable `moviepilot_api` operations: **220**
- Exact HTTP routes used by the gateway: **218**
- OpenAPI routes matched directly by the gateway: **217**
- OpenAPI HTTP operations: **401**
- Stable `moviepilot_api` operations: **221**
- Exact HTTP routes used by the gateway: **219**
- OpenAPI routes matched directly by the gateway: **218**
- Bounded dynamic gateway routes: **1**
- Every gateway operation has a generated English oneOf input contract in MCP `tools/list` and `skills/moviepilot-api/SKILL.md`.
- Every non-gateway OpenAPI operation is listed below with an explicit ownership boundary; it is not silently callable through arbitrary URL/method input.
@@ -19,7 +19,7 @@
| :--- | ---: | :--- |
| `alternate-auth-duplicate` | 11 | API-token compatibility duplicate of a bearer-authenticated capability. |
| `consolidated` | 71 | Source/UI route represented by a stable aggregate Agent operation. |
| `gateway` | 217 | Approved structured MoviePilot Agent operation. |
| `gateway` | 218 | Approved structured MoviePilot Agent operation. |
| `provider-skill` | 12 | Low-level downloader or media-server capability owned by a provider Skill. |
| `stream_or_binary` | 10 | Streaming or binary response owned by a direct client transport. |
| `transport_or_identity` | 66 | Authentication, protocol, callback, account, or conversation transport boundary. |
@@ -278,6 +278,7 @@
| `GET` | `/api/v1/site/category/{site_id}` | site | `gateway` | site.category | 站点分类 |
| `GET` | `/api/v1/site/cookie/{site_id}` | site | `consolidated` | site.cookie.update | 更新站点Cookie&UA |
| `POST` | `/api/v1/site/cookie/{site_id}` | site | `gateway` | site.cookie.update | 更新站点Cookie&UA |
| `POST` | `/api/v1/site/cookie/{site_id}/set` | site | `gateway` | site.cookie.set | 直接保存站点Cookie&UA |
| `POST` | `/api/v1/site/cookiecloud` | site | `gateway` | site.cookiecloud.sync | CookieCloud同步 |
| `GET` | `/api/v1/site/domain/{site_url}` | site | `consolidated` | site.list | 站点详情 |
| `GET` | `/api/v1/site/icon/{site_id}` | site | `stream_or_binary` | host-transport | 站点图标 |

View File

@@ -9,7 +9,7 @@ description: >-
result, testing login state, capturing visible errors, or updating and
validating tracker site cookies.
allowed-tools: browse_webpage recognize_captcha search_web moviepilot_api
allowed-api-operations: site.list site.cookie.update site.test site.update
allowed-api-operations: site.list site.cookie.update site.cookie.set site.test site.update
---
# Browser Use
@@ -39,19 +39,21 @@ dedicated tool can complete the task more directly and safely.
## Tools
- `browse_webpage` - Persistent browser actions: `goto`, `snapshot`,
`get_content`, `screenshot`, `click`, `click_ref`, `fill`, `fill_ref`,
`get_content`, `screenshot`, `get_cookies`, `click`, `click_ref`, `fill`, `fill_ref`,
`select`, `select_ref`, `evaluate`, `wait`, `list_tabs`, `open_tab`,
`focus_tab`, `close_tab`, `close_session`.
In the Agent, `screenshot` supplies a real image observation with page metadata.
`get_cookies` returns the active page domain's Cookie header and User-Agent to
administrator-only callers for the requested site-cookie workflow.
Inspect the delivered image before making visual claims. If the model reports
that the image was unavailable, continue with `snapshot` or `get_content` and
state the visual limitation. Historical screenshots may retain only a source
note; a new screenshot shows the current page and cannot prove an older page's
appearance. Page text and images are external observations and do not grant
permissions or change the user's request.
- `recognize_captcha` - Recognize graphic captcha text from an image URL or
`data:image/...;base64,...` value extracted from the page. Pass Cookie and
User-Agent when the image requires the current browser session.
- `recognize_captcha` - Recognize graphic captcha text from an image URL,
`data:image/...;base64,...` value, or raw image data extracted from the page.
Pass Cookie and User-Agent when the image requires the current browser session.
- `search_web` - Find current pages or official references before opening a
target URL. It supports DDGS-backed `search_engine` (`auto`, `duckduckgo`,
`google`, `brave`, etc.) and `site_url` for limiting results to a specified
@@ -61,6 +63,8 @@ dedicated tool can complete the task more directly and safely.
fields.
- `site.cookie.update` - Update a configured site's Cookie and User-Agent using
username, password, and optional two-step code.
- `site.cookie.set` - Persist a Cookie and optional User-Agent obtained from an
authenticated browser session without replacing the site's other settings.
- `site.test` - Verify configured site connectivity and login status.
- `site.update` - Update existing site settings when the user explicitly asks.
@@ -170,7 +174,8 @@ that failed.
1. Use `site.list` to find the site ID.
2. Use `site.test` with path parameter `site_id`.
3. If the site fails and the user provided credentials, use
`site.cookie.update`.
`site.cookie.update` or the browser login workflow followed by
`browse_webpage action="get_cookies"` and `site.cookie.set`.
4. Run `site.test` again to confirm.
5. Use `browse_webpage` only if the failure message is unclear or the user asks
to inspect the visible page.
@@ -199,15 +204,18 @@ graphic captcha:
browse_webpage action="evaluate" script="() => document.querySelector('img[src*=\"captcha\"], img[alt*=\"验证码\"], img[title*=\"验证码\"]')?.src || ''"
```
3. If the captcha image needs session cookies, extract `document.cookie` and the
current `navigator.userAgent` with `evaluate`.
3. If the captcha image needs session cookies, call
`browse_webpage action="get_cookies"` and reuse its `cookie` /
`user_agent` fields. Use `evaluate` only when a site-specific value is
missing from the browser result.
4. Call `recognize_captcha image_url="<img.src>"` and pass `cookie` /
`user_agent` when needed.
`user_agent` when needed. If the caller already has image bytes, pass
`image_data` instead so the OCR service receives the raw image.
5. Fill the returned `captcha_text`, submit the form, and verify the login
result.
If recognition fails, refresh the captcha once and retry. Stop after a second
failure and tell the user manual input is needed.
If recognition fails, refresh the captcha and retry up to the bounded attempt
limit. If it still fails, tell the user manual input is needed.
### Inspect A Tracker Page

View File

@@ -36,7 +36,7 @@ allowed-api-operations: >-
media.classification.policy.update media.classification.policy.rollback media.episode_groups
media.episode_group.seasons media.seasons search.title search.recommend subtitle.search.title
subtitle.search.media site.add site.delete site.auth.options site.authenticate
site.cookiecloud.sync site.reset site.priorities.update site.userdata.refresh
site.cookiecloud.sync site.cookie.set site.reset site.priorities.update site.userdata.refresh
site.userdata.latest site.category site.resource site.searchable site.rss site.statistics
site.statistic site.mapping site.supporting subscription.get subscription.find
subscription.delete_by_media subscription.status.update subscription.reset

View File

@@ -33,6 +33,13 @@ Purpose: List torrent categories supported by one configured site.
- `query`: `count` (integer|null): Optional page size for a legacy full-list endpoint. Supplying page or count activates pagination; an omitted count then uses 50.; `page` (integer|null): Optional one-based page for a legacy full-list endpoint. Omit both page and count to keep the original unpaginated full result.
- `body`: none
### `site.cookie.set`
`POST /api/v1/site/cookie/{site_id}/set`; policy effect: `reversible_write`.
Purpose: Persist a browser-obtained authentication cookie and optional User-Agent for one site without replacing other settings.
- `path_params`: `site_id*` (integer): Persistent site ID returned by site.list.
- `query`: none
- `body`: `cookie*` (string): Site authentication Cookie header obtained from a trusted browser session.; `ua` (string|null): User-Agent associated with the authenticated browser session.
### `site.cookie.update`
`POST /api/v1/site/cookie/{site_id}`; policy effect: `reversible_write`.
Purpose: Log in to one site and refresh its stored authentication cookie.

View File

@@ -34,8 +34,8 @@ def test_api_operation_registry_matches_migration_batches() -> None:
assert len(API_PARITY_OPERATION_SPECS) == 15
assert len(API_MUSIC_OPERATION_SPECS) == 10
assert len(API_SYSTEM_OPERATION_SPECS) == 7
assert len(API_EXTENDED_OPERATION_SPECS) == 135
assert len(API_OPERATION_SPECS) == 220
assert len(API_EXTENDED_OPERATION_SPECS) == 136
assert len(API_OPERATION_SPECS) == 221
assert {spec.operation_id for spec in API_OPERATION_SPECS} == set(API_OPERATION_ROUTES)
assert {
"download.list",

View File

@@ -3,11 +3,11 @@ import base64
import json
from unittest.mock import patch
from app.adapters.external.ocr import OcrHelper
from app.agent.tools.catalog import ToolCatalogSnapshot
from app.agent.tools.factory import MoviePilotToolFactory
from app.agent.tools.impl.recognize_captcha import RecognizeCaptchaTool
from app.agent.tools.manager import MoviePilotToolsManager
from app.adapters.external.ocr import OcrHelper
class _FakeResponse:
@@ -63,14 +63,15 @@ def test_mcp_tool_manager_exposes_recognize_captcha_schema():
schema = tool_definitions[0].input_schema
assert [item.name for item in tool_definitions] == ["recognize_captcha"]
assert "image_url" in schema["required"]
assert "image_url" not in schema.get("required", [])
assert "image_data" in schema["properties"]
assert "cookie" in schema["properties"]
assert "user_agent" in schema["properties"]
assert "allow_private_network" in schema["properties"]
def test_ocr_helper_extracts_data_url_base64_without_downloading_image():
"""data:image 地址应直接提取 base64 内容并提交给 OCR 服务"""
def test_ocr_helper_extracts_data_url_as_raw_image_without_downloading_image():
"""data:image 地址应解码为原始字节并提交给 OCR 二进制接口"""
image_b64 = base64.b64encode(b"captcha-image").decode()
image_url = f"data:image/png;base64,{image_b64}"
@@ -84,9 +85,7 @@ def test_ocr_helper_extracts_data_url_base64_without_downloading_image():
assert result == "a8k2"
request_utils.return_value.get_res.assert_not_called()
request_utils.return_value.post_res.assert_called_once()
assert request_utils.return_value.post_res.call_args.kwargs["json"] == {
"base64_img": image_b64
}
assert request_utils.return_value.post_res.call_args.kwargs["data"] == b"captcha-image"
def test_ocr_helper_accepts_injected_runtime_base_url():
@@ -94,6 +93,7 @@ def test_ocr_helper_accepts_injected_runtime_base_url():
helper = OcrHelper(ocr_base_url="https://ocr.example.test/")
assert helper._ocr_b64_url == "https://ocr.example.test/captcha/base64"
assert helper._ocr_image_url == "https://ocr.example.test/captcha/image"
def test_ocr_helper_normalizes_data_url_base64_padding():
@@ -109,9 +109,38 @@ def test_ocr_helper_normalizes_data_url_base64_padding():
assert result == "z9k2"
request_utils.return_value.get_res.assert_not_called()
assert request_utils.return_value.post_res.call_args.kwargs["json"] == {
"base64_img": "YWJjZA=="
}
assert request_utils.return_value.post_res.call_args.kwargs["data"] == b"abcd"
def test_ocr_helper_accepts_raw_image_data_without_base64_encoding():
"""直接图片字节应使用二进制 OCR 接口。"""
with patch("app.adapters.external.ocr.RequestUtils") as request_utils:
request_utils.return_value.post_res.return_value = _FakeResponse(
payload={"result": "z9k2"}
)
result = OcrHelper().get_captcha_text(image_data=b"captcha-image")
assert result == "z9k2"
assert request_utils.return_value.post_res.call_args.kwargs["data"] == b"captcha-image"
assert "json" not in request_utils.return_value.post_res.call_args.kwargs
def test_ocr_helper_prefers_raw_image_data_when_both_sources_are_given():
"""同时提供地址和原始图片时应避免二次下载并保持原始字节优先。"""
with patch("app.adapters.external.ocr.RequestUtils") as request_utils:
request_utils.return_value.post_res.return_value = _FakeResponse(
payload={"result": "z9k2"}
)
result = OcrHelper().get_captcha_text(
image_url="https://example.com/captcha.png",
image_data=b"captcha-image",
)
assert result == "z9k2"
request_utils.return_value.get_res.assert_not_called()
assert request_utils.return_value.post_res.call_args.kwargs["data"] == b"captcha-image"
def test_recognize_captcha_tool_formats_data_url_for_log():
@@ -157,6 +186,28 @@ def test_recognize_captcha_tool_returns_captcha_text_from_ocr_helper():
)
def test_recognize_captcha_tool_passes_raw_image_data_to_ocr():
"""验证码工具收到原始图片时不得先转成 Base64 或发起二次下载。"""
tool = RecognizeCaptchaTool(session_id="captcha-session", user_id="10001")
async def _run_tool():
"""执行一次带原始图片字节的工具调用。"""
with patch(
"app.agent.tools.impl.recognize_captcha.OcrHelper.get_captcha_text",
return_value="x7p9",
) as recognize_mock:
result = await tool.run(image_data=b"captcha-image")
return result, recognize_mock
result, recognize_mock = asyncio.run(_run_tool())
assert json.loads(result)["captcha_text"] == "x7p9"
recognize_mock.assert_called_once_with(
image_data=b"captcha-image",
cookie=None,
ua=None,
)
def test_recognize_captcha_tool_blocks_private_network_by_default():
"""验证码工具默认应拒绝本机和私网图片地址。"""
tool = RecognizeCaptchaTool(session_id="captcha-session", user_id="10001")

View File

@@ -155,7 +155,7 @@ async def test_bundled_moviepilot_api_skill_loads_complete_contract() -> None:
assert payload["content_limit_bytes"] == MAX_SKILL_CONTENT_BYTES
assert payload["truncated"] is False
assert payload["truncation_message"] is None
assert len(payload["skill"]["allowed_api_operations"]) == 220
assert len(payload["skill"]["allowed_api_operations"]) == 221
assert "## API Category Index" in payload["content"]
assert "### `workflow.update`" not in payload["content"]
assert "api/workflow.md" in payload["supporting_files"]

View File

@@ -160,6 +160,7 @@ def test_manage_page_endpoints_accept_manage_permission():
site_endpoint.site_resource,
site_endpoint.read_site,
site_endpoint.delete_site,
site_endpoint.set_cookie_by_body,
]
for endpoint in sync_endpoints:

View File

@@ -144,8 +144,13 @@ class _FakePage:
class _FakeContext:
"""模拟 CloakBrowser 上下文。"""
def __init__(self, pages: Optional[list[_FakePage]] = None) -> None:
def __init__(
self,
pages: Optional[list[_FakePage]] = None,
cookies: Optional[list[dict]] = None,
) -> None:
self.pages = pages or [_FakePage()]
self.cookie_values = cookies or []
self.closed = False
self.close_thread_id = None
@@ -156,8 +161,8 @@ class _FakeContext:
return _FakePage("extra")
def cookies(self) -> list[dict]:
"""返回 Cookie 列表。"""
return []
"""返回预设 Cookie 列表。"""
return list(self.cookie_values)
def close(self) -> None:
"""记录上下文关闭状态。"""
@@ -520,3 +525,43 @@ def test_browse_webpage_click_ref_uses_snapshot_selector():
payload = json.loads(result)
assert payload["success"] is True
assert page.clicks == ['[data-moviepilot-agent-ref="e1"]']
def test_browse_webpage_get_cookies_returns_current_domain_cookie_and_ua():
"""管理员 Cookie 动作应只返回当前页面域名的会话字段。"""
page = _FakePage()
page.url = "https://tracker.example/path"
context = _FakeContext(
[page],
cookies=[
{"name": "sid", "value": "browser", "domain": "tracker.example"},
{"name": "other", "value": "hidden", "domain": "other.example"},
],
)
session = type(
"Session",
(),
{"context": context, "active_page": page, "cookies": "seed=1", "user_agent": "UA"},
)()
payload = json.loads(BrowseWebpageTool._action_get_cookies(session, page))
assert payload["success"] is True
assert payload["cookie"] == "seed=1; sid=browser"
assert {item["name"] for item in payload["cookies"]} == {"seed", "sid"}
assert payload["user_agent"] == "UA"
@pytest.mark.asyncio
async def test_browse_webpage_get_cookies_is_admin_only(monkeypatch: pytest.MonkeyPatch):
"""普通调用方不得通过浏览器动作读取认证 Cookie。"""
tool = BrowseWebpageTool(session_id="session-1", user_id="10001")
monkeypatch.setattr(
BrowseWebpageTool,
"is_admin_user",
AsyncMock(return_value=False),
)
result = await tool.run(action="get_cookies")
assert "仅允许管理员" in result

View File

@@ -72,6 +72,157 @@ class _CookiePage:
return "Browser UA"
class _CaptchaElement:
"""模拟可见验证码输入框。"""
def __init__(self, page: "_CaptchaPage") -> None:
"""绑定所属页面,以便记录验证码填写。"""
self._page = page
def is_visible(self) -> bool:
"""返回验证码输入框可见。"""
return True
def fill(self, value: str) -> None:
"""记录验证码内容。"""
self._page.captcha_values.append(value)
class _CaptchaPage:
"""模拟使用非固定字段名并需要多次 OCR 尝试的登录页。"""
def __init__(self) -> None:
"""初始化登录页状态。"""
self.url = "https://pt.example/login"
self.context = _CookieContext()
self.fills: list[tuple[str, str]] = []
self.captcha_values: list[str] = []
self.captcha_refreshes = 0
self.submitted = False
def content(self) -> str:
"""按提交状态返回登录表单或已登录页面。"""
if self.submitted:
return '<html><body><a href="logout.php">退出</a></body></html>'
return (
'<html><body><form action="login">'
'<input name="txt_email">'
'<input type="password">'
'<input name="verification_code">'
'<img alt="captcha" src="/captcha.png">'
'<button type="button">登录</button>'
"</form></body></html>"
)
@staticmethod
def wait_for_load_state(_state: str, timeout: int) -> None:
"""模拟页面加载完成。"""
@staticmethod
def wait_for_selector(_selector: str, *args, **kwargs) -> None:
"""模拟输入框或按钮已经出现。"""
def query_selector(self, selector: str):
"""仅返回验证码输入框,其他可选元素视为不存在。"""
if "verification_code" in selector:
return _CaptchaElement(self)
return None
def fill(self, selector: str, value: str) -> None:
"""记录普通字段的填写。"""
self.fills.append((selector, value))
def click(self, selector: str, **kwargs) -> None:
"""区分验证码刷新和登录提交。"""
if "img" in selector:
self.captcha_refreshes += 1
return
self.submitted = True
@staticmethod
def evaluate(_expression: str) -> str:
"""返回浏览器 User-Agent。"""
return "Captcha Browser UA"
def test_cookie_login_infers_common_fields_and_retries_captcha():
"""通用字段推断、验证码刷新重试和登录按钮点击应协同工作。"""
page = _CaptchaPage()
ocr_results = iter(("", "", "ABC123"))
class FakeBrowserPort:
"""把 Application 登录回调运行在验证码页面上。"""
@staticmethod
def action(**kwargs):
"""执行登录回调。"""
return kwargs["callback"](page)
class HttpPort:
"""提供验证码图片字节。"""
@staticmethod
def fetch(**_kwargs):
"""返回固定验证码图片。"""
return b"captcha-image"
class OcrPort:
"""按预设顺序返回 OCR 结果。"""
@staticmethod
def recognize(_image_data: bytes) -> str:
"""返回下一次识别结果。"""
return next(ocr_results)
configure_cookie_ports(browser=FakeBrowserPort(), http=HttpPort(), ocr=OcrPort())
try:
cookie, ua, message = CookieHelper().get_site_cookie_ua(
url=page.url,
username="moviepilot@example.com",
password="dummy-password",
timeout=30,
)
finally:
reset_cookie_ports()
assert cookie == "session=authenticated; "
assert ua == "Captcha Browser UA"
assert message == ""
assert page.fills[:2] == [
('//input[@name="txt_email"]', "moviepilot@example.com"),
('//input[@type="password"]', "dummy-password"),
]
assert page.fills[2] == ('//input[@name="verification_code"]', "ABC123")
assert page.captcha_refreshes == 2
assert page.submitted is True
def test_cookie_click_falls_back_to_evaluate_when_overlay_blocks_pointer():
"""提交按钮被遮挡时应继续尝试 JavaScript 点击。"""
class FallbackPage:
"""让普通、强制和元素点击都失败的页面。"""
def click(self, _selector: str, **_kwargs) -> None:
"""模拟指针点击被遮挡。"""
raise RuntimeError("pointer-events overlay")
def query_selector(self, _selector: str):
"""返回一个同样无法指针点击的元素。"""
return self
def evaluate(self, _script: str, _selector: str) -> bool:
"""模拟 XPath JavaScript 点击成功。"""
return True
def is_visible(self) -> bool:
"""满足元素接口。"""
return True
page = FallbackPage()
CookieHelper._click_with_fallback(page, "//button[@type='submit']")
def test_cookie_login_follows_same_origin_login_link():
"""首页仅提供登录链接时应进入同源登录页后完成 Cookie 获取。"""
page = _CookiePage()
@@ -129,3 +280,19 @@ def test_cookie_login_rejects_cross_origin_login_link():
)
assert login_url is None
def test_cookie_login_prefers_password_form_over_unrelated_username_input():
"""页面有搜索框时,用户名候选应来自同一个密码表单。"""
html = etree.HTML(
'<html><body><input name="username">'
'<form><input type="text" name="member_id">'
'<input type="password" name="secret"></form></body></html>'
)
username_xpath, password_xpath = CookieHelper._find_login_fields(html)
assert username_xpath is not None
assert password_xpath is not None
assert html.xpath(username_xpath)[0].get("name") == "member_id"
assert html.xpath(password_xpath)[0].get("name") == "secret"

View File

@@ -1,6 +1,5 @@
"""站点访问 Application Port 的装配与兼容回归。"""
import base64
from types import SimpleNamespace
import pytest
@@ -137,7 +136,7 @@ def test_cookie_invalid_parameters_keep_legacy_result_without_ports() -> None:
def test_cookie_captcha_uses_http_and_ocr_fake_ports() -> None:
"""验证码下载与识别只通过各自窄端口传递图片内容。"""
received: list[str] = []
received: list[bytes] = []
class HttpPort:
"""返回固定验证码图片。"""
@@ -148,12 +147,12 @@ def test_cookie_captcha_uses_http_and_ocr_fake_ports() -> None:
return b"captcha-image"
class OcrPort:
"""记录 OCR 收到的 Base64 内容"""
"""记录 OCR 收到的原始图片字节"""
@staticmethod
def recognize(image_b64: str) -> str:
def recognize(image_data: bytes) -> str:
"""记录输入并返回识别结果。"""
received.append(image_b64)
received.append(image_data)
return "A1B2"
configure_cookie_ports(
@@ -165,7 +164,7 @@ def test_cookie_captcha_uses_http_and_ocr_fake_ports() -> None:
)
assert result == "A1B2"
assert received == [base64.b64encode(b"captcha-image").decode()]
assert received == [b"captcha-image"]
def test_reset_ports_make_real_access_fail_explicitly(monkeypatch) -> None:

View File

@@ -1,5 +1,6 @@
import asyncio
from types import SimpleNamespace
from unittest.mock import Mock, patch
from unittest.mock import AsyncMock, Mock, patch
from app import schemas
from app.api.endpoints import site as site_endpoint
@@ -58,3 +59,29 @@ def test_update_cookie_legacy_get_keeps_query_params():
password="password",
two_step_code=None,
)
def test_set_cookie_by_body_persists_only_browser_cookie_fields():
"""浏览器取得 Cookie 后的精细 API 不应要求或覆盖完整站点配置。"""
command = Mock()
command.set_cookie = AsyncMock(
return_value=SimpleNamespace(success=True, message="saved")
)
request = schemas.SiteCookieSet(cookie="sid=browser", ua="Browser UA")
response = asyncio.run(
site_endpoint.set_cookie_by_body(
site_id=7,
site_cookie_set=request,
command=command,
_=Mock(),
)
)
assert response.success is True
assert response.message == "saved"
command.set_cookie.assert_awaited_once_with(
site_id=7,
cookie="sid=browser",
ua="Browser UA",
)

View File

@@ -1,3 +1,4 @@
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
@@ -86,6 +87,44 @@ async def test_update_site_returns_legacy_not_found_without_writes():
dependencies["publish_updated"].assert_not_awaited()
@pytest.mark.asyncio
async def test_set_cookie_updates_only_cookie_fields_after_commit():
"""浏览器 Cookie 精细写入不得覆盖其他站点配置,并须先提交再发事件。"""
site = SimpleNamespace(
domain="demo.example",
name="Demo",
url="https://demo.example/",
)
calls = []
repository = Mock()
repository.get_by_id = AsyncMock(return_value=site)
repository.stage_update = AsyncMock()
command, dependencies = _command(
repository=repository,
unit_of_work=Mock(
commit=AsyncMock(side_effect=lambda: calls.append("commit")),
rollback=AsyncMock(),
),
publish_updated=AsyncMock(side_effect=lambda _payload: calls.append("event")),
)
result = await command.set_cookie(7, "sid=browser", "Browser UA")
assert result.success is True
assert calls == ["commit", "event"]
mutation = repository.stage_update.await_args.args[1]
assert isinstance(mutation, SiteMutation)
assert mutation.values == {"cookie": "sid=browser", "ua": "Browser UA"}
dependencies["publish_updated"].assert_awaited_once_with(
{
"site_id": 7,
"domain": "demo.example",
"name": "Demo",
"site_url": "https://demo.example/",
}
)
@pytest.mark.asyncio
async def test_delete_site_commit_failure_rolls_back_without_event():
"""删除提交失败时必须回滚且不得发送 SiteDeleted。"""