Files

406 lines
15 KiB
Python
Raw Permalink Normal View History

2026-10-05 17:30:47 +08:00
"""
模块名称: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"]