feat: 为出站请求统一接入 HTTP 代理

This commit is contained in:
2026-08-14 16:08:39 +08:00
parent 3dc2aeb3a2
commit fabca9510d
23 changed files with 213 additions and 14 deletions
+3
View File
@@ -60,6 +60,9 @@ RAKUTEN_PROXY_SERVER=
RAKUTEN_PROXY_USERNAME= RAKUTEN_PROXY_USERNAME=
# 代理密码(如代理需要认证则填写) # 代理密码(如代理需要认证则填写)
RAKUTEN_PROXY_PASSWORD= RAKUTEN_PROXY_PASSWORD=
# 不经代理的主机名(逗号分隔,支持 *.internal 这类通配符)。默认覆盖本机与 Docker
# 服务间通信;内部网关或抓取服务使用其它域名时,将其显式加入此列表。
RAKUTEN_PROXY_BYPASS=localhost,127.0.0.1,::1,rakuten-api,rakuten-trading,rakuten-gateway
# ---- OpenTelemetry traces(可选;默认关闭)---- # ---- OpenTelemetry traces(可选;默认关闭)----
# 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint, # 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint,
+4 -2
View File
@@ -644,6 +644,8 @@ trading 加购时的字段选择策略:多规格挑第一个非售罄的 varia
- 鉴权:`RAKUTEN_BEARER_TOKEN`(三个服务共用) - 鉴权:`RAKUTEN_BEARER_TOKEN`(三个服务共用)
- 抓取:`RAKUTEN_MAX_SITE_CONCURRENCY`(默认 `8`,两站各自独立计数)、`RAKUTEN_HTTP_MAX_ATTEMPTS`(默认 `3`)、`RAKUTEN_SESSION_TTL_SECONDS`(默认 `1800`,仅乐天) - 抓取:`RAKUTEN_MAX_SITE_CONCURRENCY`(默认 `8`,两站各自独立计数)、`RAKUTEN_HTTP_MAX_ATTEMPTS`(默认 `3`)、`RAKUTEN_SESSION_TTL_SECONDS`(默认 `1800`,仅乐天)
- 浏览器兜底(仅乐天):`RAKUTEN_BROWSER_FALLBACK_ENABLED``RAKUTEN_BROWSER_HEADLESS``RAKUTEN_BROWSER_CHANNEL` - 浏览器兜底(仅乐天):`RAKUTEN_BROWSER_FALLBACK_ENABLED``RAKUTEN_BROWSER_HEADLESS``RAKUTEN_BROWSER_CHANNEL`
- 代理(需日本 IP 时):`RAKUTEN_PROXY_SERVER``RAKUTEN_PROXY_USERNAME``RAKUTEN_PROXY_PASSWORD` - 代理(需日本 IP 时):`RAKUTEN_PROXY_SERVER``RAKUTEN_PROXY_USERNAME``RAKUTEN_PROXY_PASSWORD``RAKUTEN_PROXY_BYPASS`
> 从中国大陆直连实测可用、无需代理;`RAKUTEN_PROXY_SERVER` 留空即可。 > 从中国大陆直连实测可用、无需代理;`RAKUTEN_PROXY_SERVER` 留空即可。设置后,所有面向
> 外部站点的 HTTPX 与 Playwright 流量都会经代理;本机和 Docker 服务间地址由
> `RAKUTEN_PROXY_BYPASS` 直连。该设置不影响 OTel 上报。
+2 -1
View File
@@ -16,6 +16,7 @@ from dataclasses import dataclass, field
from typing import Any from typing import Any
from app.shared.config import Settings from app.shared.config import Settings
from app.shared.proxy import playwright_launch_proxy
from app.scraping.core import site from app.scraping.core import site
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -100,7 +101,7 @@ class BrowserFallback:
self._browser = await self._playwright.chromium.launch( self._browser = await self._playwright.chromium.launch(
headless=self._settings.browser_headless_effective, headless=self._settings.browser_headless_effective,
channel=self._settings.browser_channel or None, 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, timeout=self._settings.browser_launch_timeout_seconds * 1000,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
+2 -1
View File
@@ -22,6 +22,7 @@ from opentelemetry import trace
from app.scraping.core import rakuma_site as site from app.scraping.core import rakuma_site as site
from app.shared.config import Settings from app.shared.config import Settings
from app.shared.proxy import httpx_client_options
from app.shared.errors import ( from app.shared.errors import (
ItemNotFoundError, ItemNotFoundError,
ResourceBusyError, ResourceBusyError,
@@ -51,7 +52,7 @@ class RakumaSession:
headers=site.default_headers(), headers=site.default_headers(),
timeout=self._settings.request_timeout_seconds, timeout=self._settings.request_timeout_seconds,
follow_redirects=True, follow_redirects=True,
proxy=self._settings.httpx_proxy, **httpx_client_options(self._settings),
http2=True, http2=True,
) )
logger.info( logger.info(
+2 -1
View File
@@ -23,6 +23,7 @@ from opentelemetry import trace
from app.scraping.core import site from app.scraping.core import site
from app.shared.config import Settings from app.shared.config import Settings
from app.shared.proxy import httpx_client_options
from app.shared.errors import ( from app.shared.errors import (
ItemNotFoundError, ItemNotFoundError,
ResourceBusyError, ResourceBusyError,
@@ -91,7 +92,7 @@ class SiteSession:
headers=site.default_headers(mobile=mobile), headers=site.default_headers(mobile=mobile),
timeout=self._settings.request_timeout_seconds, timeout=self._settings.request_timeout_seconds,
follow_redirects=True, follow_redirects=True,
proxy=self._settings.httpx_proxy, **httpx_client_options(self._settings),
http2=True, http2=True,
), ),
) )
+27 -1
View File
@@ -8,9 +8,11 @@
「通用 / 仅抓取 / 仅交易 / 仅网关 / 仅交易 worker」分区标注,各进程只读自己那部分。 「通用 / 仅抓取 / 仅交易 / 仅网关 / 仅交易 worker」分区标注,各进程只读自己那部分。
""" """
import socket import socket
from fnmatch import fnmatchcase
from functools import lru_cache from functools import lru_cache
from pathlib import Path from pathlib import Path
from typing import Literal from typing import Literal
from urllib.parse import quote, urlsplit
from pydantic import Field from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -88,6 +90,8 @@ class Settings(BaseSettings):
proxy_server: str | None = None proxy_server: str | None = None
proxy_username: str | None = None proxy_username: str | None = None
proxy_password: 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(通用,可选;默认关闭)---- # ---- OpenTelemetry traces(通用,可选;默认关闭)----
# 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint,用于排查"抓到 # 启用后把抓取-解析链路以 span 导出到 OTLP/HTTP endpoint,用于排查"抓到
@@ -192,6 +196,8 @@ class Settings(BaseSettings):
proxy["username"] = self.proxy_username proxy["username"] = self.proxy_username
if self.proxy_password: if self.proxy_password:
proxy["password"] = self.proxy_password proxy["password"] = self.proxy_password
if self.proxy_bypass.strip():
proxy["bypass"] = self.proxy_bypass
return proxy return proxy
@property @property
@@ -205,9 +211,29 @@ class Settings(BaseSettings):
scheme, _, rest = self.proxy_server.partition("://") scheme, _, rest = self.proxy_server.partition("://")
if not rest: if not rest:
return self.proxy_server 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}" 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 @property
def auth_state_path(self) -> Path: def auth_state_path(self) -> Path:
"""登录态目录的绝对路径,不存在时创建""" """登录态目录的绝对路径,不存在时创建"""
+23
View File
@@ -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
+1
View File
@@ -61,6 +61,7 @@ def build_container() -> TradingContainer:
client = GatewayClient( client = GatewayClient(
settings.order_gateway_url, settings.order_gateway_url,
settings.bearer_token, settings.bearer_token,
settings=settings,
timeout=max(60.0, settings.lease_max_wait_seconds + 10), timeout=max(60.0, settings.lease_max_wait_seconds + 10),
) )
local_db = LocalDB(settings.trading_db_path_resolved) local_db = LocalDB(settings.trading_db_path_resolved)
+2 -1
View File
@@ -37,6 +37,7 @@ from typing import Any
import httpx import httpx
from app.shared.config import Settings from app.shared.config import Settings
from app.shared.proxy import httpx_client_options
from app.shared.errors import NotLoggedInError, UpstreamRequestError from app.shared.errors import NotLoggedInError, UpstreamRequestError
from app.trading.core import auth_site from app.trading.core import auth_site
@@ -110,7 +111,7 @@ class AuthSession:
headers=profile.headers(), headers=profile.headers(),
timeout=self._settings.request_timeout_seconds, timeout=self._settings.request_timeout_seconds,
follow_redirects=True, follow_redirects=True,
proxy=self._settings.httpx_proxy, **httpx_client_options(self._settings),
http2=True, http2=True,
) )
self._sites[name] = _SiteAuth(name=name, client=client) self._sites[name] = _SiteAuth(name=name, client=client)
+2 -1
View File
@@ -25,6 +25,7 @@ from typing import Any
import yaml import yaml
from app.shared.config import BASE_DIR, Settings from app.shared.config import BASE_DIR, Settings
from app.shared.proxy import playwright_launch_proxy
from app.trading.core import auth_site from app.trading.core import auth_site
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -355,7 +356,7 @@ async def login_one(
user_data_dir=str(account.user_data_dir), user_data_dir=str(account.user_data_dir),
headless=False, # 自动登录仍需有头:撞验证码要人工接管 headless=False, # 自动登录仍需有头:撞验证码要人工接管
channel=settings.browser_channel or None, channel=settings.browser_channel or None,
proxy=settings.playwright_proxy, proxy=playwright_launch_proxy(settings),
user_agent=profile.user_agent, user_agent=profile.user_agent,
locale="ja-JP", locale="ja-JP",
timezone_id="Asia/Tokyo", timezone_id="Asia/Tokyo",
+6 -1
View File
@@ -16,7 +16,9 @@ from typing import Any
import httpx import httpx
from app.shared.config import Settings
from app.shared.errors import AppError from app.shared.errors import AppError
from app.shared.proxy import httpx_client_options
from app.shared.task_state import OrderState, TaskStatus from app.shared.task_state import OrderState, TaskStatus
from app.trading.worker.models import LeaseTask from app.trading.worker.models import LeaseTask
@@ -29,13 +31,16 @@ class GatewayClient:
一份 AsyncClient 实例贯穿 worker 整个生命周期连接池由 httpx 管理 一份 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/..." 拼出双斜杠 # 末尾去斜杠,避免 base + "/api/..." 拼出双斜杠
self._base_url = base_url.rstrip("/") self._base_url = base_url.rstrip("/")
self._client = httpx.AsyncClient( self._client = httpx.AsyncClient(
base_url=self._base_url, base_url=self._base_url,
headers={"Authorization": f"Bearer {bearer_token}"}, headers={"Authorization": f"Bearer {bearer_token}"},
timeout=timeout, timeout=timeout,
**httpx_client_options(settings, target_url=self._base_url),
) )
async def aclose(self) -> None: async def aclose(self) -> None:
+2
View File
@@ -97,6 +97,7 @@ from app.shared.purchase_contract import (
basket_domain_of, basket_domain_of,
inventory_flag_for, inventory_flag_for,
) )
from app.shared.proxy import playwright_launch_proxy
from app.shared.task_state import OrderState from app.shared.task_state import OrderState
from app.trading.core import auth_site from app.trading.core import auth_site
from app.trading.worker.models import LeaseTask from app.trading.worker.models import LeaseTask
@@ -556,6 +557,7 @@ class SiteInteractor:
# 状态的操作(加购/结算/支付)都必须走非无头浏览器 # 状态的操作(加购/结算/支付)都必须走非无头浏览器
headless=False, headless=False,
channel=self._settings.browser_channel or None, channel=self._settings.browser_channel or None,
proxy=playwright_launch_proxy(self._settings),
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
self._context = await self._browser.new_context( self._context = await self._browser.new_context(
+1
View File
@@ -48,6 +48,7 @@ async def _start_visible(site: SiteInteractor, settings) -> None:
site._browser = await site._playwright.chromium.launch( site._browser = await site._playwright.chromium.launch(
headless=False, headless=False,
channel=settings.browser_channel or None, channel=settings.browser_channel or None,
proxy=settings.playwright_proxy,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
site._context = await site._browser.new_context( site._context = await site._browser.new_context(
+5 -3
View File
@@ -24,7 +24,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import httpx import httpx
from app.shared.config import get_settings from app.shared.config import Settings, get_settings
from app.shared.proxy import httpx_client_options
from app.trading.core import auth_site from app.trading.core import auth_site
PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "checkout" PROBE_DIR = Path(__file__).resolve().parent.parent / ".probe" / "checkout"
@@ -42,12 +43,13 @@ def load_cookies(state_path: Path) -> list[dict]:
return state.get("cookies", []) return state.get("cookies", [])
def build_client(cookies: list[dict]) -> httpx.AsyncClient: def build_client(cookies: list[dict], settings: Settings) -> httpx.AsyncClient:
client = httpx.AsyncClient( client = httpx.AsyncClient(
headers=auth_site.PROFILES["rakuten"].headers(), headers=auth_site.PROFILES["rakuten"].headers(),
timeout=30.0, timeout=30.0,
follow_redirects=True, follow_redirects=True,
http2=True, http2=True,
**httpx_client_options(settings),
) )
for cookie in cookies: for cookie in cookies:
name = cookie.get("name") name = cookie.get("name")
@@ -210,7 +212,7 @@ async def main() -> int:
cookies = load_cookies(state_path) cookies = load_cookies(state_path)
print(f"加载 {len(cookies)} 条 cookie 自 {state_path}") print(f"加载 {len(cookies)} 条 cookie 自 {state_path}")
async with build_client(cookies) as client: async with build_client(cookies, settings) as client:
await probe_readonly(client) await probe_readonly(client)
if args.mutate: if args.mutate:
if not args.item_url: if not args.item_url:
+1
View File
@@ -56,6 +56,7 @@ async def run(item_url: str, *, headful: bool) -> int:
browser = await pw.chromium.launch( browser = await pw.chromium.launch(
headless=not headful, headless=not headful,
channel=settings.browser_channel or None, channel=settings.browser_channel or None,
proxy=settings.playwright_proxy,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
context = await browser.new_context( context = await browser.new_context(
+1
View File
@@ -48,6 +48,7 @@ async def _start_visible(site: SiteInteractor, settings) -> None:
site._browser = await site._playwright.chromium.launch( site._browser = await site._playwright.chromium.launch(
headless=False, headless=False,
channel=settings.browser_channel or None, channel=settings.browser_channel or None,
proxy=settings.playwright_proxy,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
site._context = await site._browser.new_context( site._context = await site._browser.new_context(
+1
View File
@@ -48,6 +48,7 @@ async def run(*, headful: bool) -> int:
browser = await pw.chromium.launch( browser = await pw.chromium.launch(
headless=not headful, headless=not headful,
channel=settings.browser_channel or None, channel=settings.browser_channel or None,
proxy=settings.playwright_proxy,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
context = await browser.new_context( context = await browser.new_context(
+1
View File
@@ -53,6 +53,7 @@ async def _start_visible(site: SiteInteractor, settings) -> None:
site._browser = await site._playwright.chromium.launch( site._browser = await site._playwright.chromium.launch(
headless=False, headless=False,
channel=settings.browser_channel or None, channel=settings.browser_channel or None,
proxy=settings.playwright_proxy,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
site._context = await site._browser.new_context( site._context = await site._browser.new_context(
+5 -1
View File
@@ -164,7 +164,11 @@ async def main() -> int:
log: list[str] = [] log: list[str] = []
async with async_playwright() as pw: async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True, args=["--no-first-run"]) browser = await pw.chromium.launch(
headless=True,
proxy=settings.playwright_proxy,
args=["--no-first-run"],
)
context = await browser.new_context( context = await browser.new_context(
storage_state=state_path, storage_state=state_path,
user_agent=auth_site.RAKUTEN_USER_AGENT, user_agent=auth_site.RAKUTEN_USER_AGENT,
+5 -1
View File
@@ -179,7 +179,11 @@ async def main() -> int:
log: list[str] = [] log: list[str] = []
async with async_playwright() as pw: async with async_playwright() as pw:
browser = await pw.chromium.launch(headless=True, args=["--no-first-run"]) browser = await pw.chromium.launch(
headless=True,
proxy=settings.playwright_proxy,
args=["--no-first-run"],
)
context = await browser.new_context( context = await browser.new_context(
storage_state=state_path, storage_state=state_path,
user_agent=auth_site.RAKUTEN_USER_AGENT, user_agent=auth_site.RAKUTEN_USER_AGENT,
+1
View File
@@ -121,6 +121,7 @@ async def main() -> int:
browser = await pw.chromium.launch( browser = await pw.chromium.launch(
headless=True, headless=True,
channel=settings.browser_channel or None, channel=settings.browser_channel or None,
proxy=settings.playwright_proxy,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
context = await browser.new_context( context = await browser.new_context(
+1
View File
@@ -723,6 +723,7 @@ async def _start_visible(site: SiteInteractor, settings) -> None:
site._browser = await site._playwright.chromium.launch( # noqa: SLF001 site._browser = await site._playwright.chromium.launch( # noqa: SLF001
headless=False, headless=False,
channel=settings.browser_channel or None, channel=settings.browser_channel or None,
proxy=settings.playwright_proxy,
args=["--no-first-run", "--disable-blink-features=AutomationControlled"], args=["--no-first-run", "--disable-blink-features=AutomationControlled"],
) )
site._context = await site._browser.new_context( # noqa: SLF001 site._context = await site._browser.new_context( # noqa: SLF001
+115
View File
@@ -0,0 +1,115 @@
"""统一出站代理策略的单元测试。"""
from __future__ import annotations
import asyncio
import ast
from pathlib import Path
from app.shared.config import Settings
from app.shared.proxy import httpx_client_options, playwright_launch_proxy
from app.trading.worker import client as worker_client
from app.trading.worker.client import GatewayClient
PROJECT_ROOT = Path(__file__).resolve().parent.parent
def _settings(**overrides) -> Settings:
return Settings(_env_file=None, **overrides)
def test_disabled_proxy_does_not_inherit_host_environment() -> None:
settings = _settings()
assert httpx_client_options(settings) == {"trust_env": False}
assert playwright_launch_proxy(settings) is None
def test_authenticated_proxy_encodes_httpx_credentials() -> None:
settings = _settings(
proxy_server="http://proxy.example:8080",
proxy_username="user@example.com",
proxy_password="pa:ss /#",
proxy_bypass="localhost,.internal.example",
)
assert httpx_client_options(settings) == {
"trust_env": False,
"proxy": "http://user%40example.com:pa%3Ass%20%2F%23@proxy.example:8080",
}
assert playwright_launch_proxy(settings) == {
"server": "http://proxy.example:8080",
"username": "user@example.com",
"password": "pa:ss /#",
"bypass": "localhost,.internal.example",
}
def test_internal_hosts_bypass_httpx_proxy() -> None:
settings = _settings(
proxy_server="http://proxy.example:8080",
proxy_bypass="localhost,.internal.example,*.svc",
)
assert settings.proxy_bypasses("http://localhost:31109")
assert settings.proxy_bypasses("https://api.internal.example/path")
assert settings.proxy_bypasses("https://orders.svc")
assert not settings.proxy_bypasses("https://www.rakuten.co.jp/")
assert httpx_client_options(settings, target_url="http://localhost:31109") == {
"trust_env": False
}
assert httpx_client_options(settings, target_url="https://www.rakuten.co.jp/") == {
"trust_env": False,
"proxy": "http://proxy.example:8080",
}
def test_gateway_client_uses_its_base_url_for_proxy_policy(monkeypatch) -> None:
settings = _settings(proxy_server="http://proxy.example:8080")
captured: dict[str, object] = {}
def fake_httpx_options(actual_settings: Settings, *, target_url: str | None = None) -> dict[str, object]:
captured["settings"] = actual_settings
captured["target_url"] = target_url
return {"trust_env": False}
monkeypatch.setattr(worker_client, "httpx_client_options", fake_httpx_options)
client = GatewayClient("https://gateway.example", "token", settings=settings)
try:
assert captured == {"settings": settings, "target_url": "https://gateway.example"}
finally:
asyncio.run(client.aclose())
def test_every_project_http_client_and_browser_launch_has_proxy_policy() -> None:
"""新增出站入口时,强制它显式接入统一代理策略。"""
missing_httpx_policy: list[Path] = []
missing_browser_proxy: list[Path] = []
for root_name in ("app", "scripts"):
for path in (PROJECT_ROOT / root_name).rglob("*.py"):
tree = ast.parse(path.read_text(encoding="utf-8"))
for node in ast.walk(tree):
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute):
continue
if (
node.func.attr == "AsyncClient"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "httpx"
):
uses_policy = any(
keyword.arg is None
and isinstance(keyword.value, ast.Call)
and isinstance(keyword.value.func, ast.Name)
and keyword.value.func.id == "httpx_client_options"
for keyword in node.keywords
)
if not uses_policy:
missing_httpx_policy.append(path)
if node.func.attr in {"launch", "launch_persistent_context"} and not any(
keyword.arg == "proxy" for keyword in node.keywords
):
missing_browser_proxy.append(path)
assert not missing_httpx_policy
assert not missing_browser_proxy