feat: 为出站请求统一接入 HTTP 代理
This commit is contained in:
@@ -16,6 +16,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.shared.config import Settings
|
||||
from app.shared.proxy import playwright_launch_proxy
|
||||
from app.scraping.core import site
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -100,7 +101,7 @@ class BrowserFallback:
|
||||
self._browser = await self._playwright.chromium.launch(
|
||||
headless=self._settings.browser_headless_effective,
|
||||
channel=self._settings.browser_channel or None,
|
||||
proxy=self._settings.playwright_proxy,
|
||||
proxy=playwright_launch_proxy(self._settings),
|
||||
timeout=self._settings.browser_launch_timeout_seconds * 1000,
|
||||
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
|
||||
)
|
||||
|
||||
@@ -22,6 +22,7 @@ from opentelemetry import trace
|
||||
|
||||
from app.scraping.core import rakuma_site as site
|
||||
from app.shared.config import Settings
|
||||
from app.shared.proxy import httpx_client_options
|
||||
from app.shared.errors import (
|
||||
ItemNotFoundError,
|
||||
ResourceBusyError,
|
||||
@@ -51,7 +52,7 @@ class RakumaSession:
|
||||
headers=site.default_headers(),
|
||||
timeout=self._settings.request_timeout_seconds,
|
||||
follow_redirects=True,
|
||||
proxy=self._settings.httpx_proxy,
|
||||
**httpx_client_options(self._settings),
|
||||
http2=True,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
@@ -23,6 +23,7 @@ from opentelemetry import trace
|
||||
|
||||
from app.scraping.core import site
|
||||
from app.shared.config import Settings
|
||||
from app.shared.proxy import httpx_client_options
|
||||
from app.shared.errors import (
|
||||
ItemNotFoundError,
|
||||
ResourceBusyError,
|
||||
@@ -91,7 +92,7 @@ class SiteSession:
|
||||
headers=site.default_headers(mobile=mobile),
|
||||
timeout=self._settings.request_timeout_seconds,
|
||||
follow_redirects=True,
|
||||
proxy=self._settings.httpx_proxy,
|
||||
**httpx_client_options(self._settings),
|
||||
http2=True,
|
||||
),
|
||||
)
|
||||
|
||||
+27
-1
@@ -8,9 +8,11 @@
|
||||
「通用 / 仅抓取 / 仅交易 / 仅网关 / 仅交易 worker」分区标注,各进程只读自己那部分。
|
||||
"""
|
||||
import socket
|
||||
from fnmatch import fnmatchcase
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
from urllib.parse import quote, urlsplit
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
@@ -88,6 +90,8 @@ class Settings(BaseSettings):
|
||||
proxy_server: str | None = None
|
||||
proxy_username: str | None = None
|
||||
proxy_password: str | None = None
|
||||
# 逗号分隔的直连主机名或通配符。服务间请求不应绕到公网代理。
|
||||
proxy_bypass: str = "localhost,127.0.0.1,::1,rakuten-api,rakuten-trading,rakuten-gateway"
|
||||
|
||||
# ---- OpenTelemetry traces(通用,可选;默认关闭)----
|
||||
# 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint,用于排查"抓到
|
||||
@@ -192,6 +196,8 @@ class Settings(BaseSettings):
|
||||
proxy["username"] = self.proxy_username
|
||||
if self.proxy_password:
|
||||
proxy["password"] = self.proxy_password
|
||||
if self.proxy_bypass.strip():
|
||||
proxy["bypass"] = self.proxy_bypass
|
||||
return proxy
|
||||
|
||||
@property
|
||||
@@ -205,9 +211,29 @@ class Settings(BaseSettings):
|
||||
scheme, _, rest = self.proxy_server.partition("://")
|
||||
if not rest:
|
||||
return self.proxy_server
|
||||
credentials = f"{self.proxy_username}:{self.proxy_password or ''}"
|
||||
credentials = f"{quote(self.proxy_username, safe='')}:{quote(self.proxy_password or '', safe='')}"
|
||||
return f"{scheme}://{credentials}@{rest}"
|
||||
|
||||
def proxy_bypasses(self, url: str) -> bool:
|
||||
"""目标 URL 的主机是否应绕过外部代理。"""
|
||||
host = urlsplit(url).hostname
|
||||
if not host:
|
||||
return False
|
||||
|
||||
normalized_host = host.rstrip(".").lower()
|
||||
for raw_pattern in self.proxy_bypass.split(","):
|
||||
pattern = raw_pattern.strip().rstrip(".").lower()
|
||||
if not pattern:
|
||||
continue
|
||||
if pattern.startswith("."):
|
||||
suffix = pattern[1:]
|
||||
if normalized_host == suffix or normalized_host.endswith(f".{suffix}"):
|
||||
return True
|
||||
continue
|
||||
if fnmatchcase(normalized_host, pattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
@property
|
||||
def auth_state_path(self) -> Path:
|
||||
"""登录态目录的绝对路径,不存在时创建"""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""项目出站代理策略;OTel exporter 不使用这里的配置。"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.shared.config import Settings
|
||||
|
||||
|
||||
def httpx_client_options(
|
||||
settings: "Settings", *, target_url: str | None = None
|
||||
) -> dict[str, object]:
|
||||
"""返回 HTTPX 客户端统一的代理与环境变量策略。"""
|
||||
proxy = None if target_url and settings.proxy_bypasses(target_url) else settings.httpx_proxy
|
||||
options: dict[str, object] = {"trust_env": False}
|
||||
if proxy is not None:
|
||||
options["proxy"] = proxy
|
||||
return options
|
||||
|
||||
|
||||
def playwright_launch_proxy(settings: "Settings") -> dict[str, str] | None:
|
||||
"""返回 Playwright 启动参数使用的统一代理设置。"""
|
||||
return settings.playwright_proxy
|
||||
@@ -61,6 +61,7 @@ def build_container() -> TradingContainer:
|
||||
client = GatewayClient(
|
||||
settings.order_gateway_url,
|
||||
settings.bearer_token,
|
||||
settings=settings,
|
||||
timeout=max(60.0, settings.lease_max_wait_seconds + 10),
|
||||
)
|
||||
local_db = LocalDB(settings.trading_db_path_resolved)
|
||||
|
||||
@@ -37,6 +37,7 @@ from typing import Any
|
||||
import httpx
|
||||
|
||||
from app.shared.config import Settings
|
||||
from app.shared.proxy import httpx_client_options
|
||||
from app.shared.errors import NotLoggedInError, UpstreamRequestError
|
||||
from app.trading.core import auth_site
|
||||
|
||||
@@ -110,7 +111,7 @@ class AuthSession:
|
||||
headers=profile.headers(),
|
||||
timeout=self._settings.request_timeout_seconds,
|
||||
follow_redirects=True,
|
||||
proxy=self._settings.httpx_proxy,
|
||||
**httpx_client_options(self._settings),
|
||||
http2=True,
|
||||
)
|
||||
self._sites[name] = _SiteAuth(name=name, client=client)
|
||||
|
||||
@@ -25,6 +25,7 @@ from typing import Any
|
||||
import yaml
|
||||
|
||||
from app.shared.config import BASE_DIR, Settings
|
||||
from app.shared.proxy import playwright_launch_proxy
|
||||
from app.trading.core import auth_site
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -355,7 +356,7 @@ async def login_one(
|
||||
user_data_dir=str(account.user_data_dir),
|
||||
headless=False, # 自动登录仍需有头:撞验证码要人工接管
|
||||
channel=settings.browser_channel or None,
|
||||
proxy=settings.playwright_proxy,
|
||||
proxy=playwright_launch_proxy(settings),
|
||||
user_agent=profile.user_agent,
|
||||
locale="ja-JP",
|
||||
timezone_id="Asia/Tokyo",
|
||||
|
||||
@@ -16,7 +16,9 @@ from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.shared.config import Settings
|
||||
from app.shared.errors import AppError
|
||||
from app.shared.proxy import httpx_client_options
|
||||
from app.shared.task_state import OrderState, TaskStatus
|
||||
from app.trading.worker.models import LeaseTask
|
||||
|
||||
@@ -29,13 +31,16 @@ class GatewayClient:
|
||||
一份 AsyncClient 实例贯穿 worker 整个生命周期,连接池由 httpx 管理。
|
||||
"""
|
||||
|
||||
def __init__(self, base_url: str, bearer_token: str, *, timeout: float = 60.0):
|
||||
def __init__(
|
||||
self, base_url: str, bearer_token: str, *, settings: Settings, timeout: float = 60.0
|
||||
):
|
||||
# 末尾去斜杠,避免 base + "/api/..." 拼出双斜杠
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._client = httpx.AsyncClient(
|
||||
base_url=self._base_url,
|
||||
headers={"Authorization": f"Bearer {bearer_token}"},
|
||||
timeout=timeout,
|
||||
**httpx_client_options(settings, target_url=self._base_url),
|
||||
)
|
||||
|
||||
async def aclose(self) -> None:
|
||||
|
||||
@@ -97,6 +97,7 @@ from app.shared.purchase_contract import (
|
||||
basket_domain_of,
|
||||
inventory_flag_for,
|
||||
)
|
||||
from app.shared.proxy import playwright_launch_proxy
|
||||
from app.shared.task_state import OrderState
|
||||
from app.trading.core import auth_site
|
||||
from app.trading.worker.models import LeaseTask
|
||||
@@ -556,6 +557,7 @@ class SiteInteractor:
|
||||
# 状态的操作(加购/结算/支付)都必须走非无头浏览器
|
||||
headless=False,
|
||||
channel=self._settings.browser_channel or None,
|
||||
proxy=playwright_launch_proxy(self._settings),
|
||||
args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
|
||||
)
|
||||
self._context = await self._browser.new_context(
|
||||
|
||||
Reference in New Issue
Block a user