Files
ProxyCat/modules/proxypool_service.py
2026-10-05 17:30:47 +08:00

407 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
模块名称:modules.proxypool_service
功能描述:代理池在 ProxyCat 进程内的宿主:用专用线程承载池的 asyncio 事件循环,对外只暴露同步门面方法,
供 Flask 同步路由与 CLI 调用,另提供进程内事件循环使用的异步取用入口(取一个 / 取一批出口代理)。
职责边界:负责:事件循环线程的创建与销毁、Application 初始化与关闭、跨线程调用的桥接与超时/异常转换、配置推送;
不负责:HTTP 路由与请求解析(见 proxypool_api)、配置文件读写(见 ProxyCat 配置接口)、池业务逻辑(见 core.services)。
关键依赖:core.application(app / api.dependencies)、core.config.Config、core.logger、
modules.pool_config_ini、modules.logging_setup、modules.loop_noise_filter、modules.modules。
已知限制:
1. from modules.proxypool import POOL_DIR 仅靠导入副作用注入 sys.path,必须早于 core.* 导入且不可删除。
2. 在池事件循环线程内调用 call() 会死锁;call() 有线程守卫,池线程内调用直接抛 RuntimeError。
3. 调用超时必须大于池内部最长超时(aiohttp 30 秒);DEFAULT_CALL_TIMEOUT 取 35 秒,超时后调用方放弃而池仍在跑。
4. stop() 默认 35 秒预算等于池内各段最坏耗时之和(插件停止 10 秒 + 配置落库 5 秒 + 写队列排空 15 秒 + 余量),改动任一段都要重算。
5. 上一次 stop() 超时未让线程退出时 start() 拒绝启动,避免两个 Application 并存并同写 proxies.db。
6. stop 超时后 _stopping 常置:is_running 为假而线程可能仍在写库,退出与恢复路径须按 has_live_thread 判断。
7. 语言提供者须在构造期注册(池未启动时面板仍按当前语言渲染池设置文案),不可因初始化阶段会重复注册而删除。
"""
import asyncio
import concurrent.futures
import logging
import os
import random
import threading
from pathlib import Path
from typing import Any, Callable, Coroutine, Optional
from modules.proxypool import POOL_DIR # noqa: F401
from core.application.app import Application
from core.application.api.dependencies import (
set_container, set_dedup_cache, set_language_provider,
)
from core.config import Config
from core.logger import attach_pool_log_handlers
from modules import pool_config_ini
from modules.logging_setup import LogCategory, classify_record
from modules.loop_noise_filter import install_loop_noise_filter
from modules.modules import get_message
logger = logging.getLogger(__name__)
DEFAULT_CALL_TIMEOUT = 35.0
CONFIG_WATCH_INTERVAL = 3.0
class _PoolLogFilter(logging.Filter):
def filter(self, record: logging.LogRecord) -> bool:
return classify_record(record) == LogCategory.POOL
class PoolUnavailableError(RuntimeError):
pass
class PoolTimeoutError(RuntimeError):
pass
class ProxyPoolService:
def __init__(self, config: Config, config_ini_path: str | Path,
language_provider: Optional[Callable[[], str]] = None):
self._config = config
self._config_ini_path = Path(config_ini_path)
self._language_provider = language_provider
set_language_provider(language_provider)
self._thread: Optional[threading.Thread] = None
self._loop: Optional[asyncio.AbstractEventLoop] = None
self._application: Optional[Application] = None
self._ready = threading.Event()
self._stopping = threading.Event()
self._startup_ok = False
self._lifecycle_lock = threading.RLock()
self._last_error: Optional[str] = None
self._last_mtime: float = 0.0
self._watch_task: Optional[asyncio.Task] = None
@property
def is_running(self) -> bool:
thread = self._thread
return bool(
thread is not None
and thread.is_alive()
and self._startup_ok
and self._ready.is_set()
and not self._stopping.is_set()
)
@property
def has_live_thread(self) -> bool:
thread = self._thread
return thread is not None and thread.is_alive()
@property
def last_error(self) -> Optional[str]:
return self._last_error
@property
def language(self) -> str:
if self._language_provider is None:
return 'cn'
return self._language_provider() or 'cn'
def start(self, timeout: float = 30.0) -> bool:
with self._lifecycle_lock:
if self.is_running:
return True
previous = self._thread
if previous is not None and previous.is_alive():
self._last_error = get_message('pool_previous_stop_incomplete', self.language)
logger.error(self._last_error)
return False
self._ready.clear()
self._stopping.clear()
self._startup_ok = False
self._last_error = None
self._remember_config_mtime()
self._thread = threading.Thread(
target=self._run_loop, name="proxypool-loop", daemon=True
)
self._thread.start()
if not self._ready.wait(timeout):
self._last_error = get_message('pool_start_timeout', self.language, f"{timeout:.0f}")
logger.error(self._last_error)
return False
return self.is_running
def stop(self, timeout: float = 35.0) -> bool:
with self._lifecycle_lock:
thread = self._thread
if thread is None or not thread.is_alive():
self._thread = None
return True
logger.info(get_message('pool_stopping', self.language))
self._stopping.set()
loop = self._loop
if loop is not None:
try:
loop.call_soon_threadsafe(loop.stop)
except RuntimeError:
pass
thread.join(timeout)
if thread.is_alive():
self._last_error = get_message('pool_stop_timeout', self.language, f"{timeout:.0f}")
logger.error(self._last_error)
return False
with self._lifecycle_lock:
self._thread = None
self._loop = None
self._stopping.clear()
logger.info(get_message('pool_stop_success', self.language))
return True
def restart(self, timeout: float = 30.0) -> bool:
with self._lifecycle_lock:
self.stop(timeout=timeout)
return self.start(timeout=timeout)
def _run_loop(self) -> None:
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
self._loop = loop
install_loop_noise_filter(loop)
initialized = False
try:
loop.run_until_complete(self._initialize_application())
initialized = True
self._startup_ok = True
logger.info(get_message('pool_start_success', self.language))
except Exception as e:
self._startup_ok = False
self._last_error = f"{type(e).__name__}: {e}"
logger.error(get_message('pool_start_failed', self.language, e), exc_info=True)
finally:
self._ready.set()
if initialized:
try:
loop.run_forever()
except Exception as e:
self._last_error = f"{type(e).__name__}: {e}"
logger.error(get_message('pool_loop_crashed', self.language, e), exc_info=True)
self._shutdown_application(loop)
async def _initialize_application(self) -> None:
application = Application(self._config)
try:
await application.initialize()
except BaseException:
try:
await application.shutdown()
except Exception as e:
logger.warning("初始化失败后的收尾也失败了(不影响报错): %s", e)
raise
self._application = application
set_container(application.get_container())
set_dedup_cache(application.dedup_cache)
set_language_provider(self._language_provider)
attach_pool_log_handlers(
self._config.logging,
logging.getLogger().level or logging.INFO,
record_filter=_PoolLogFilter(),
)
self._watch_task = asyncio.create_task(self._watch_config_file())
def _shutdown_application(self, loop: asyncio.AbstractEventLoop) -> None:
application = self._application
self._application = None
set_container(None)
set_dedup_cache(None)
if application is None:
self._close_loop(loop)
return
try:
loop.run_until_complete(application.shutdown())
except Exception as e:
logger.error("关闭代理池时出错: %s", e, exc_info=True)
self._close_loop(loop)
@staticmethod
def _close_loop(loop: asyncio.AbstractEventLoop) -> None:
try:
pending = [t for t in asyncio.all_tasks(loop) if not t.done()]
for task in pending:
task.cancel()
if pending:
loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
except Exception as e:
logger.debug("清理残留任务时出错(忽略): %s", e)
finally:
try:
loop.close()
except Exception as e:
logger.debug("关闭事件循环时出错(忽略): %s", e)
def call(self, coro: Coroutine, timeout: float = DEFAULT_CALL_TIMEOUT) -> Any:
if threading.current_thread() is self._thread:
coro.close()
raise RuntimeError("禁止在代理池事件循环线程内同步等待池调用(会死锁)")
if not self.is_running or self._loop is None:
coro.close()
raise PoolUnavailableError(
self._last_error or get_message('pool_not_running', self.language))
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
try:
return future.result(timeout)
except concurrent.futures.TimeoutError:
future.cancel()
raise PoolTimeoutError(
get_message('pool_call_timeout', self.language, f"{timeout:.0f}")) from None
except concurrent.futures.CancelledError:
raise PoolUnavailableError(
get_message('pool_stopped', self.language)) from None
def submit(self, coro: Coroutine) -> concurrent.futures.Future:
if not self.is_running or self._loop is None:
coro.close()
raise PoolUnavailableError(
self._last_error or get_message('pool_not_running', self.language))
return asyncio.run_coroutine_threadsafe(coro, self._loop)
async def _run_route(self, coro, timeout: float):
future = self.submit(coro)
return await asyncio.wait_for(asyncio.wrap_future(future), timeout=timeout)
@staticmethod
def _text_content(result) -> str:
from core.application.api.http_types import RawResponse
return result.content if isinstance(result, RawResponse) else str(result)
async def fetch_random_proxy_url(self, timeout: float = 10.0) -> Optional[str]:
from core.application.api.routes import proxies as proxy_routes
if not self.is_running:
return None
result = await self._run_route(
proxy_routes.get_random_proxy(status='valid', format='text'), timeout
)
return self._text_content(result).strip() or None
async def fetch_proxy_entries(self, limit: int, timeout: float = 10.0) -> list:
from core.application.api.routes import proxies as proxy_routes
limit = int(limit)
if not self.is_running or limit <= 0:
return []
counted = await self._run_route(
proxy_routes.get_proxies_count(status='valid'), timeout
)
total = int((counted or {}).get('count', 0))
if total <= 0:
return []
pages = max(1, (total + limit - 1) // limit)
page = random.randint(1, pages)
result = await self._run_route(
proxy_routes.get_proxies(
status='valid', page=page, page_size=limit, format='json'
),
timeout,
)
return [entry for entry in (result or [])
if isinstance(entry, dict)][:limit]
async def fetch_proxy_urls(self, limit: int, timeout: float = 10.0) -> list:
entries = await self.fetch_proxy_entries(limit, timeout)
urls = [(entry.get('proxy_url') or '').strip() for entry in entries]
return [url for url in urls if url]
async def report_proxy_use(self, ip: str, port: int, ok: bool,
test_url: str = "", timeout: float = 5.0) -> None:
from core.application.api.routes import validation as validation_routes
if not self.is_running:
return
try:
future = self.submit(
validation_routes.report_proxy_use(ip, port, ok, test_url)
)
await asyncio.wait_for(asyncio.wrap_future(future), timeout=timeout)
except Exception as e:
logger.debug("使用期反馈上报失败(已忽略): %s: %s", type(e).__name__, e)
def apply_config(self, config: Config, timeout: float = 15.0) -> None:
if not self.is_running or self._application is None:
raise PoolUnavailableError(
self._last_error or get_message('pool_not_running', self.language))
self.call(self._application.apply_config(config), timeout=timeout)
attach_pool_log_handlers(
config.logging,
logging.getLogger().level or logging.INFO,
record_filter=_PoolLogFilter(),
)
def reload_config_from_file(self, timeout: float = 15.0) -> None:
config = pool_config_ini.load_pool_config(self._config_ini_path)
self._config = config
self.apply_config(config, timeout=timeout)
def note_config_saved(self) -> None:
self._remember_config_mtime()
def _remember_config_mtime(self) -> None:
try:
self._last_mtime = os.path.getmtime(self._config_ini_path)
except OSError:
self._last_mtime = 0.0
async def _watch_config_file(self) -> None:
while True:
try:
await asyncio.sleep(CONFIG_WATCH_INTERVAL)
mtime = os.path.getmtime(self._config_ini_path)
if mtime <= self._last_mtime:
continue
config = pool_config_ini.reload_pool_config(self._config_ini_path)
if config is None:
logger.warning(get_message('pool_config_unreadable', self.language))
continue
logger.info(get_message('config_file_changed', self.language))
self._config = config
if self._application is not None:
await self._application.apply_config(config)
self._last_mtime = mtime
except asyncio.CancelledError:
break
except Exception as e:
logger.warning("配置文件兜底轮询出错(不影响运行): %s", e)
__all__ = ["ProxyPoolService", "PoolUnavailableError", "PoolTimeoutError"]