You've already forked pruchase_jhw
first commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
# 根包初始化
|
||||
@@ -0,0 +1 @@
|
||||
# 浏览器模块包初始化
|
||||
@@ -0,0 +1,237 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
from playwright.async_api import async_playwright, Playwright, BrowserContext
|
||||
from loguru import logger
|
||||
from src.config.settings import get_settings
|
||||
from src.config.accounts import AccountConfig
|
||||
|
||||
# 注入到所有页面的 JS 脚本,用于绕过 Cloudflare、Akamai 等自动化检测
|
||||
STEALTH_JS = """
|
||||
// 1. 隐藏 navigator.webdriver
|
||||
Object.defineProperty(navigator, 'webdriver', {
|
||||
get: () => undefined
|
||||
});
|
||||
|
||||
// 2. 覆盖语言列表,优先日语(与目标地区一致)
|
||||
Object.defineProperty(navigator, 'languages', {
|
||||
get: () => ['ja-JP', 'ja', 'en-US', 'en']
|
||||
});
|
||||
|
||||
// 3. 伪造 WebGL 渲染器信息,伪装为标准硬件
|
||||
const getParameter = WebGLRenderingContext.prototype.getParameter;
|
||||
WebGLRenderingContext.prototype.getParameter = function(parameter) {
|
||||
// UNMASKED_VENDOR_WEBGL (37445)
|
||||
if (parameter === 37445) {
|
||||
return 'Intel Inc.';
|
||||
}
|
||||
// UNMASKED_RENDERER_WEBGL (37446)
|
||||
if (parameter === 37446) {
|
||||
return 'Intel(R) UHD Graphics';
|
||||
}
|
||||
return getParameter.apply(this, arguments);
|
||||
};
|
||||
|
||||
// 4. 伪造设备规格
|
||||
Object.defineProperty(navigator, 'hardwareConcurrency', { get: () => 8 });
|
||||
Object.defineProperty(navigator, 'deviceMemory', { get: () => 8 });
|
||||
|
||||
// 5. 模拟完整插件列表(无头浏览器常见特征为空列表)
|
||||
const mockPlugins = () => {
|
||||
const makePlugin = (name, filename, description) => {
|
||||
const p = Object.create(Plugin.prototype);
|
||||
Object.defineProperties(p, {
|
||||
name: { get: () => name },
|
||||
filename: { get: () => filename },
|
||||
description: { get: () => description },
|
||||
length: { get: () => 0 }
|
||||
});
|
||||
return p;
|
||||
};
|
||||
const plugins = [
|
||||
makePlugin("PDF Viewer", "internal-pdf-viewer", "Portable Document Format"),
|
||||
makePlugin("Chrome PDF Viewer", "mhjfbgoacfdegokjbbpboihdaibdccjg", "Chrome PDF Viewer"),
|
||||
makePlugin("Chromium PDF Viewer", "internal-pdf-viewer", "Chromium PDF Viewer")
|
||||
];
|
||||
const pList = Object.create(PluginArray.prototype);
|
||||
Object.defineProperties(pList, {
|
||||
length: { get: () => plugins.length },
|
||||
item: { value: (index) => plugins[index] },
|
||||
namedItem: { value: (name) => plugins.find(p => p.name === name) }
|
||||
});
|
||||
for (let i = 0; i < plugins.length; i++) {
|
||||
Object.defineProperty(pList, i, { get: () => plugins[i] });
|
||||
}
|
||||
return pList;
|
||||
};
|
||||
Object.defineProperty(navigator, 'plugins', {
|
||||
get: () => mockPlugins()
|
||||
});
|
||||
|
||||
// 6. 模拟 window.chrome(无头浏览器通常没有,Cloudflare 会检测)
|
||||
window.chrome = {
|
||||
app: {
|
||||
isInstalled: false,
|
||||
InstallState: {
|
||||
DISABLED: 'disabled',
|
||||
INSTALLED: 'installed',
|
||||
NOT_INSTALLED: 'not_installed'
|
||||
},
|
||||
RunningState: {
|
||||
CANNOT_RUN: 'cannot_run',
|
||||
READY_TO_RUN: 'ready_to_run',
|
||||
RUNNING: 'running'
|
||||
}
|
||||
},
|
||||
runtime: {
|
||||
OnInstalledReason: {
|
||||
CHROME_UPDATE: 'chrome_update',
|
||||
INSTALL: 'install',
|
||||
SHARED_MODULE_UPDATE: 'shared_module_update',
|
||||
UPDATE: 'update'
|
||||
},
|
||||
OnRestartRequiredReason: {
|
||||
APP_UPDATE: 'app_update',
|
||||
OS_UPDATE: 'os_update',
|
||||
PERIODIC: 'periodic'
|
||||
},
|
||||
PlatformArch: {
|
||||
ARM: 'arm',
|
||||
ARM64: 'arm64',
|
||||
MIPS: 'mips',
|
||||
MIPS64: 'mips64',
|
||||
X86_32: 'x86-32',
|
||||
X86_64: 'x86-64'
|
||||
},
|
||||
PlatformNaclArch: {
|
||||
ARM: 'arm',
|
||||
MIPS: 'mips',
|
||||
MIPS64: 'mips64',
|
||||
X86_32: 'x86-32',
|
||||
X86_64: 'x86-64'
|
||||
},
|
||||
PlatformOs: {
|
||||
ANDROID: 'android',
|
||||
CROS: 'cros',
|
||||
LINUX: 'linux',
|
||||
MAC: 'mac',
|
||||
OPENBSD: 'openbsd',
|
||||
WIN: 'win'
|
||||
},
|
||||
RequestUpdateCheckStatus: {
|
||||
NO_UPDATE: 'no_update',
|
||||
THROTTLED: 'throttled',
|
||||
UPDATE_AVAILABLE: 'update_available'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 7. 模拟 Permissions 查询行为一致性
|
||||
const originalQuery = window.navigator.permissions.query;
|
||||
window.navigator.permissions.query = (parameters) => (
|
||||
parameters.name === 'notifications' ?
|
||||
Promise.resolve({ state: Notification.permission }) :
|
||||
originalQuery(parameters)
|
||||
);
|
||||
"""
|
||||
|
||||
class BrowserManager:
|
||||
"""
|
||||
浏览器管理器:负责初始化 Playwright 上下文。
|
||||
按账号隔离数据环境(user_data_dir),并注入反爬脚本以规避 Cloudflare、Akamai 等检测。
|
||||
"""
|
||||
def __init__(self) -> None:
|
||||
self.settings = get_settings()
|
||||
|
||||
async def get_context(self, account: AccountConfig) -> Tuple[Playwright, BrowserContext]:
|
||||
"""
|
||||
为指定账号启动持久化浏览器上下文。
|
||||
返回 (Playwright, BrowserContext) 元组。
|
||||
调用方必须在 try-finally 中确保关闭 context 与 playwright 实例。
|
||||
|
||||
Args:
|
||||
account: 包含 account_id、user_data_dir 及可选 proxy 的账号配置。
|
||||
|
||||
Returns:
|
||||
Tuple[Playwright, BrowserContext]: 已启动的驱动与隔离浏览器上下文实例。
|
||||
"""
|
||||
# 1. 解析并确保本地持久化目录存在
|
||||
data_dir = Path(account.user_data_dir)
|
||||
try:
|
||||
data_dir.mkdir(parents=True, exist_ok=True)
|
||||
# logger.debug(f"持久化用户数据目录: {data_dir.absolute()}")
|
||||
except Exception as e:
|
||||
logger.error(f"创建账号 '{account.account_id}' 的持久化目录失败: {e}")
|
||||
raise e
|
||||
|
||||
# 2. 配置隐身 Chromium 启动参数
|
||||
chrome_args = [
|
||||
"--disable-blink-features=AutomationControlled", # 浏览器层面移除 navigator.webdriver
|
||||
"--disable-infobars", # 隐藏「Chrome 正受到自动测试软件控制」提示条
|
||||
"--no-sandbox", # Docker/服务器环境兼容
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage", # 内存受限环境避免资源崩溃
|
||||
"--start-maximized", # 启动时全屏(最大化)
|
||||
"--disable-gpu", # 服务器无头模式稳定渲染
|
||||
"--use-mock-keychain", # 避免本地密钥环弹窗(macOS/Linux)
|
||||
"--password-store=basic",
|
||||
]
|
||||
|
||||
executable_path_raw = self.settings.browser_executable_path
|
||||
executable_path = None
|
||||
if executable_path_raw:
|
||||
candidate_path = Path(executable_path_raw).expanduser()
|
||||
if candidate_path.is_file():
|
||||
executable_path = str(candidate_path)
|
||||
logger.info(f"使用自定义浏览器可执行文件: {executable_path}")
|
||||
else:
|
||||
logger.warning(
|
||||
f"配置的浏览器可执行文件无效,将使用 Playwright 默认浏览器: {executable_path_raw}"
|
||||
)
|
||||
headless_mode = self.settings.browser_headless
|
||||
|
||||
# 使用较新的 Windows Chrome User-Agent
|
||||
user_agent = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
|
||||
)
|
||||
|
||||
logger.info(f"正在为账号 {account.account_id} 启动 Playwright | 无头模式: {headless_mode}")
|
||||
|
||||
# 3. 按账号配置可选代理(服务器 IP 段绕过 Cloudflare 封锁时常用)
|
||||
proxy_config = None
|
||||
if account.proxy:
|
||||
proxy_config = {"server": account.proxy}
|
||||
logger.info(f"账号 '{account.account_id}' 已启用代理: {account.proxy}")
|
||||
|
||||
playwright_driver = await async_playwright().start()
|
||||
|
||||
try:
|
||||
# 4. 使用隔离目录创建持久化上下文
|
||||
launch_options = {
|
||||
"user_data_dir": str(data_dir.absolute()),
|
||||
"headless": headless_mode,
|
||||
"args": chrome_args,
|
||||
"user_agent": user_agent,
|
||||
"no_viewport": True, # 配合 --start-maximized 使得网页视口也铺满全屏
|
||||
"locale": "ja-JP", # 区域语言一致
|
||||
"timezone_id": "Asia/Tokyo", # 日本本地时区
|
||||
"accept_downloads": True, # 允许自动下载(如需要)
|
||||
"proxy": proxy_config # 住宅代理等
|
||||
}
|
||||
if executable_path:
|
||||
launch_options["executable_path"] = executable_path
|
||||
|
||||
context = await playwright_driver.chromium.launch_persistent_context(**launch_options)
|
||||
|
||||
# 5. 注入高级反指纹隐身脚本
|
||||
await context.add_init_script(STEALTH_JS)
|
||||
logger.success(f"账号 '{account.account_id}' 的 Playwright 上下文启动成功。")
|
||||
|
||||
return playwright_driver, context
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"账号 '{account.account_id}' 启动持久化上下文失败: {e}")
|
||||
# 启动失败时清理驱动进程
|
||||
await playwright_driver.stop()
|
||||
raise e
|
||||
@@ -0,0 +1 @@
|
||||
# 配置模块包初始化
|
||||
@@ -0,0 +1,74 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from pydantic import BaseModel, Field
|
||||
import yaml
|
||||
from loguru import logger
|
||||
from src.config.settings import get_settings
|
||||
|
||||
class AccountConfig(BaseModel):
|
||||
"""
|
||||
单个浏览器电商账号的配置模型(Pydantic Schema)。
|
||||
"""
|
||||
account_id: str
|
||||
username: str
|
||||
password: str | None = None
|
||||
user_data_dir: str
|
||||
proxy: str | None = None # 可选代理地址,例如 "http://127.0.0.1:8080"
|
||||
|
||||
class SiteAccounts(BaseModel):
|
||||
"""
|
||||
按电商站点分组的账号列表。
|
||||
"""
|
||||
surugaya: list[AccountConfig] = Field(default_factory=list)
|
||||
mercari: list[AccountConfig] = Field(default_factory=list)
|
||||
yahoo: list[AccountConfig] = Field(default_factory=list)
|
||||
|
||||
class MultiAccountConfig(BaseModel):
|
||||
"""
|
||||
YAML 根结构:将 'sites' 映射到各站点的账号配置。
|
||||
"""
|
||||
sites: SiteAccounts
|
||||
|
||||
@lru_cache
|
||||
def load_accounts_config() -> MultiAccountConfig:
|
||||
"""
|
||||
使用 PyYAML 加载并解析 accounts.yaml,经 Pydantic 校验后返回配置对象。
|
||||
"""
|
||||
settings = get_settings()
|
||||
config_path = Path(settings.accounts_config_path)
|
||||
|
||||
if not config_path.exists():
|
||||
logger.warning(f"未找到账号配置文件: {config_path.absolute()},将使用空配置。")
|
||||
# 返回空配置
|
||||
return MultiAccountConfig(sites=SiteAccounts())
|
||||
|
||||
try:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
raw_data = yaml.safe_load(f) or {}
|
||||
|
||||
config = MultiAccountConfig.model_validate(raw_data)
|
||||
logger.info(f"已成功加载账号配置: {config_path.absolute()},"
|
||||
f"骏河屋账号 {len(config.sites.surugaya)} 个。")
|
||||
return config
|
||||
except Exception as e:
|
||||
logger.error(f"加载或解析账号配置失败: {config_path.absolute()},错误: {e}")
|
||||
raise e
|
||||
|
||||
def get_account_config(site: str, account_id: str) -> AccountConfig | None:
|
||||
"""
|
||||
根据站点名与 account_id 查找并返回对应账号配置。
|
||||
"""
|
||||
config = load_accounts_config()
|
||||
site_accounts = getattr(config.sites, site.lower(), None)
|
||||
|
||||
if site_accounts is None:
|
||||
logger.warning(f"站点 '{site}' 未在账号配置中定义。")
|
||||
return None
|
||||
|
||||
for acc in site_accounts:
|
||||
if acc.account_id == account_id:
|
||||
return acc
|
||||
|
||||
logger.warning(f"站点 '{site}' 下未找到账号 ID '{account_id}'。")
|
||||
return None
|
||||
@@ -0,0 +1,109 @@
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from typing import Annotated, Any
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
|
||||
|
||||
# 根据 APP_ENV 确定环境配置文件(默认为 dev)
|
||||
APP_ENV = os.getenv("APP_ENV", "dev").lower()
|
||||
ENV_FILE = f".env.{APP_ENV}"
|
||||
|
||||
class Settings(BaseSettings):
|
||||
"""
|
||||
全局应用配置模型。
|
||||
使用 pydantic-settings 校验类型并自动解析复杂数据类型。
|
||||
"""
|
||||
# 运行环境
|
||||
app_env: str = Field(default="dev", alias="APP_ENV")
|
||||
|
||||
# 日志配置
|
||||
log_level: str = Field(default="INFO", alias="LOG_LEVEL")
|
||||
log_dir: str = Field(default="./logs", alias="LOG_DIR")
|
||||
|
||||
# 节点配置
|
||||
node_id: str = Field(default="node_1", alias="NODE_ID")
|
||||
|
||||
# Redis 配置
|
||||
redis_host: str = Field(default="127.0.0.1", alias="REDIS_HOST")
|
||||
redis_port: int = Field(default=6379, alias="REDIS_PORT")
|
||||
redis_password: str | None = Field(default=None, alias="REDIS_PASSWORD")
|
||||
redis_db: int = Field(default=0, alias="REDIS_DB")
|
||||
redis_stream_key: str = Field(default="jp_surugaya_purchase_task", alias="REDIS_STREAM_KEY")
|
||||
redis_consumer_group: str = Field(default="surugaya_purchase_task_group", alias="REDIS_CONSUMER_GROUP")
|
||||
redis_consumer_name: str = Field(default="surugaya_purchase_task_worker", alias="REDIS_CONSUMER_NAME")
|
||||
redis_delay_zset_key: str = Field(default="jp_surugaya_trade_delay", alias="REDIS_DELAY_ZSET_KEY")
|
||||
redis_delay_poll_interval: int = Field(default=3600, alias="REDIS_DELAY_POLL_INTERVAL")
|
||||
redis_delay_batch_size: int = Field(default=10, alias="REDIS_DELAY_BATCH_SIZE")
|
||||
|
||||
|
||||
# 浏览器控制
|
||||
browser_headless: bool = Field(default=True, alias="BROWSER_HEADLESS")
|
||||
browser_executable_path: str | None = Field(default=None, alias="BROWSER_EXECUTABLE_PATH")
|
||||
browser_keep_open: bool = Field(default=False, alias="BROWSER_KEEP_OPEN")
|
||||
|
||||
# 配置与资源路径
|
||||
accounts_config_path: str = Field(default="./accounts.yaml", alias="ACCOUNTS_CONFIG_PATH")
|
||||
screenshot_dir: str = Field(default="./archives", alias="SCREENSHOT_DIR")
|
||||
|
||||
# 任务上报配置
|
||||
callback_url: str | None = Field(default=None, alias="CALLBACK_URL")
|
||||
appid: str | None = Field(default=None, alias="APPID")
|
||||
app_secret: str | None = Field(default=None, alias="APP_SECRET")
|
||||
|
||||
# PayPay 配置
|
||||
paypay_phone: str | None = Field(default=None, alias="PAYPAY_PHONE")
|
||||
paypay_password: str | None = Field(default=None, alias="PAYPAY_PASSWORD")
|
||||
|
||||
# 通知配置
|
||||
notify_enabled: bool = Field(default=False, alias="NOTIFY_ENABLED")
|
||||
notify_channels: Annotated[list[str], NoDecode] = Field(
|
||||
default_factory=lambda: ["email"],
|
||||
alias="NOTIFY_CHANNELS",
|
||||
)
|
||||
notify_on_task_failed: bool = Field(default=True, alias="NOTIFY_ON_TASK_FAILED")
|
||||
|
||||
# 邮件通知配置
|
||||
mail_enabled: bool = Field(default=True, alias="MAIL_ENABLED")
|
||||
mail_smtp_host: str | None = Field(default=None, alias="MAIL_SMTP_HOST")
|
||||
mail_smtp_port: int = Field(default=465, alias="MAIL_SMTP_PORT")
|
||||
mail_smtp_use_ssl: bool = Field(default=True, alias="MAIL_SMTP_USE_SSL")
|
||||
mail_smtp_starttls: bool = Field(default=False, alias="MAIL_SMTP_STARTTLS")
|
||||
mail_username: str | None = Field(default=None, alias="MAIL_USERNAME")
|
||||
mail_password: str | None = Field(default=None, alias="MAIL_PASSWORD")
|
||||
mail_from_address: str | None = Field(default=None, alias="MAIL_FROM_ADDRESS")
|
||||
mail_from_name: str = Field(default="JP Purchase Bot", alias="MAIL_FROM_NAME")
|
||||
mail_to_addresses: Annotated[list[str], NoDecode] = Field(
|
||||
default_factory=list,
|
||||
alias="MAIL_TO_ADDRESSES",
|
||||
)
|
||||
mail_subject_prefix: str = Field(default="[JP-Purchase]", alias="MAIL_SUBJECT_PREFIX")
|
||||
mail_timeout_seconds: int = Field(default=10, alias="MAIL_TIMEOUT_SECONDS")
|
||||
|
||||
@field_validator("notify_channels", "mail_to_addresses", mode="before")
|
||||
@classmethod
|
||||
def _parse_csv_list(cls, value: Any) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
|
||||
if isinstance(value, str):
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
if isinstance(value, (list, tuple, set)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
|
||||
return [str(value).strip()] if str(value).strip() else []
|
||||
|
||||
# Pydantic Settings 配置项
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=ENV_FILE,
|
||||
env_file_encoding="utf-8",
|
||||
extra="ignore" # 忽略其他系统环境变量
|
||||
)
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
"""
|
||||
获取缓存的全局配置单例。
|
||||
"""
|
||||
return Settings()
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
import asyncio
|
||||
import signal
|
||||
import sys
|
||||
from loguru import logger
|
||||
|
||||
from src.config.settings import get_settings
|
||||
from src.config.accounts import load_accounts_config
|
||||
from src.notifications.service import NotificationService
|
||||
from src.utils.logger import setup_logger
|
||||
from src.task_queue.dispatcher import TaskDispatcher
|
||||
from src.task_queue.delay_zset_listener import RedisZSetDelayListener
|
||||
from src.task_queue.listener import RedisStreamListener
|
||||
from src.task_queue.redis_manager import get_redis_manager
|
||||
|
||||
async def main() -> None:
|
||||
"""
|
||||
应用程序主入口函数。
|
||||
- 初始化定制的 Loguru 结构化日志系统。
|
||||
- 校验 Pydantic 配置及多站点账号 YAML 配置文件是否正确。
|
||||
- 针对不同操作系统平台注册优雅退出信号处理器。
|
||||
- 启动 Redis Stream 消息队列持续消费监听循环。
|
||||
"""
|
||||
# 1. 初始化按天分割日志系统
|
||||
setup_logger()
|
||||
logger.info("正在初始化日本采购自动化守护进程...")
|
||||
|
||||
# 2. 启动前快速校验:验证全局环境变量设置与账号配置文件
|
||||
try:
|
||||
settings = get_settings()
|
||||
logger.info(f"应用配置加载成功: APP_ENV={settings.app_env.upper()}")
|
||||
NotificationService(settings).validate_configuration()
|
||||
|
||||
# 解析并校验 accounts.yaml
|
||||
load_accounts_config()
|
||||
await get_redis_manager().initialize()
|
||||
except Exception as e:
|
||||
logger.critical(f"由于配置错误,守护进程启动失败: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# 3. 实例化核心任务分发器与 Stream 监听器
|
||||
dispatcher = TaskDispatcher()
|
||||
stream_listener = RedisStreamListener(dispatcher)
|
||||
delay_listener = RedisZSetDelayListener(dispatcher)
|
||||
|
||||
# 4. 优雅停机信号处理 (跨平台支持)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
def shutdown_handler(sig_name: str) -> None:
|
||||
logger.warning(f"接收到系统退出信号 '{sig_name}'。正在启动优雅停机流程...")
|
||||
# 触发监听器停止的协程任务
|
||||
asyncio.create_task(stream_listener.stop())
|
||||
asyncio.create_task(delay_listener.stop())
|
||||
|
||||
# Windows 默认的 Proactor 事件循环不支持 add_signal_handler。
|
||||
if sys.platform != "win32":
|
||||
logger.debug("正在注册 Unix 系统信号监听器 (SIGINT / SIGTERM)。")
|
||||
for sig in (signal.SIGINT, signal.SIGTERM):
|
||||
try:
|
||||
loop.add_signal_handler(sig, lambda s=sig: shutdown_handler(s.name))
|
||||
except NotImplementedError:
|
||||
pass
|
||||
else:
|
||||
logger.debug("运行于 Windows 平台。跳过系统信号注册,将直接捕获 KeyboardInterrupt。")
|
||||
|
||||
# 5. 启动消息监听消费主循环
|
||||
try:
|
||||
stream_task = asyncio.create_task(stream_listener.listen())
|
||||
delay_task = asyncio.create_task(delay_listener.listen())
|
||||
await asyncio.gather(stream_task, delay_task)
|
||||
except asyncio.CancelledError:
|
||||
logger.warning("监听器轮询执行被事件循环取消。")
|
||||
except Exception as e:
|
||||
logger.exception(f"主守护进程轮询中发生致命崩溃: {e}")
|
||||
finally:
|
||||
# 保证安全回收与连接断开
|
||||
await asyncio.gather(
|
||||
stream_listener.stop(),
|
||||
delay_listener.stop(),
|
||||
get_redis_manager().close(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
logger.success("日本采购自动化守护进程已安全优雅退出。")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
logger.warning("捕获到键盘手动中断。立即退出程序...")
|
||||
sys.exit(0)
|
||||
@@ -0,0 +1,4 @@
|
||||
from src.notifications.models import NotificationEvent
|
||||
from src.notifications.service import NotificationService
|
||||
|
||||
__all__ = ["NotificationEvent", "NotificationService"]
|
||||
@@ -0,0 +1,14 @@
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from src.notifications.models import NotificationEvent
|
||||
|
||||
|
||||
class NotificationChannel(ABC):
|
||||
channel_name: str
|
||||
|
||||
@abstractmethod
|
||||
async def send(self, event: NotificationEvent) -> None:
|
||||
"""
|
||||
发送通知事件。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,3 @@
|
||||
from src.notifications.channels.email import EmailNotificationChannel
|
||||
|
||||
__all__ = ["EmailNotificationChannel"]
|
||||
@@ -0,0 +1,54 @@
|
||||
import asyncio
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
from email.utils import formataddr
|
||||
|
||||
from src.config.settings import Settings
|
||||
from src.notifications.base import NotificationChannel
|
||||
from src.notifications.models import NotificationEvent
|
||||
|
||||
|
||||
class EmailNotificationChannel(NotificationChannel):
|
||||
channel_name = "email"
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self.settings = settings
|
||||
|
||||
async def send(self, event: NotificationEvent) -> None:
|
||||
await asyncio.to_thread(self._send_sync, event)
|
||||
|
||||
def _send_sync(self, event: NotificationEvent) -> None:
|
||||
message = EmailMessage()
|
||||
from_display = formataddr(
|
||||
(
|
||||
self.settings.mail_from_name,
|
||||
self.settings.mail_from_address or "",
|
||||
)
|
||||
)
|
||||
message["Subject"] = event.title
|
||||
message["From"] = from_display
|
||||
message["To"] = ", ".join(self.settings.mail_to_addresses)
|
||||
message.set_content(event.content_text)
|
||||
|
||||
host = self.settings.mail_smtp_host or ""
|
||||
port = self.settings.mail_smtp_port
|
||||
timeout = self.settings.mail_timeout_seconds
|
||||
|
||||
if self.settings.mail_smtp_use_ssl:
|
||||
with smtplib.SMTP_SSL(host, port, timeout=timeout) as server:
|
||||
self._login_and_send(server, message)
|
||||
return
|
||||
|
||||
with smtplib.SMTP(host, port, timeout=timeout) as server:
|
||||
if self.settings.mail_smtp_starttls:
|
||||
server.starttls()
|
||||
self._login_and_send(server, message)
|
||||
|
||||
def _login_and_send(self, server: smtplib.SMTP, message: EmailMessage) -> None:
|
||||
username = self.settings.mail_username or ""
|
||||
password = self.settings.mail_password or ""
|
||||
|
||||
if username:
|
||||
server.login(username, password)
|
||||
|
||||
server.send_message(message)
|
||||
@@ -0,0 +1,11 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class NotificationEvent(BaseModel):
|
||||
event_type: str
|
||||
title: str
|
||||
content_text: str
|
||||
severity: Literal["info", "warning", "error"] = "info"
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,131 @@
|
||||
from datetime import datetime
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from src.config.settings import Settings, get_settings
|
||||
from src.notifications.base import NotificationChannel
|
||||
from src.notifications.channels import EmailNotificationChannel
|
||||
from src.notifications.models import NotificationEvent
|
||||
|
||||
|
||||
class NotificationService:
|
||||
def __init__(self, settings: Settings | None = None) -> None:
|
||||
self.settings = settings or get_settings()
|
||||
|
||||
def validate_configuration(self) -> None:
|
||||
if not self.settings.notify_enabled:
|
||||
return
|
||||
|
||||
channels = self._get_enabled_channel_names()
|
||||
if not channels:
|
||||
logger.warning("通知模块已启用,但未配置任何有效通知渠道。")
|
||||
return
|
||||
|
||||
logger.info(f"通知模块已启用,当前渠道: {', '.join(channels)}")
|
||||
|
||||
if "email" in channels and self._get_email_configuration_issues():
|
||||
logger.warning(
|
||||
"邮件通知配置不完整,失败通知将被跳过: "
|
||||
+ "; ".join(self._get_email_configuration_issues())
|
||||
)
|
||||
|
||||
async def notify_task_failed(self, task_context: dict[str, Any]) -> None:
|
||||
"""
|
||||
通知任务执行失败。
|
||||
:param task_context: 任务上下文,包含任务ID、失败原因等信息。
|
||||
"""
|
||||
if not self.settings.notify_on_task_failed:
|
||||
return
|
||||
|
||||
title = f"{self.settings.mail_subject_prefix} {task_context.get("title", "")}"
|
||||
event = NotificationEvent(
|
||||
event_type="task_failed",
|
||||
title=title,
|
||||
content_text=self._render_task_failed_content(task_context),
|
||||
severity="error",
|
||||
metadata=task_context,
|
||||
)
|
||||
await self.notify(event)
|
||||
|
||||
async def notify(self, event: NotificationEvent) -> None:
|
||||
if not self.settings.notify_enabled:
|
||||
logger.debug("通知模块未启用,跳过发送通知。")
|
||||
return
|
||||
|
||||
channels = self._build_channels()
|
||||
if not channels:
|
||||
logger.warning("通知模块未找到可用渠道,跳过发送通知。")
|
||||
return
|
||||
|
||||
for channel in channels:
|
||||
try:
|
||||
await channel.send(event)
|
||||
logger.success(
|
||||
f"通知发送成功,渠道={channel.channel_name},事件={event.event_type}"
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
f"通知发送失败,渠道={channel.channel_name},事件={event.event_type},错误: {exc}"
|
||||
)
|
||||
|
||||
def _build_channels(self) -> list[NotificationChannel]:
|
||||
channels: list[NotificationChannel] = []
|
||||
|
||||
for channel_name in self._get_enabled_channel_names():
|
||||
if channel_name == "email":
|
||||
if not self.settings.mail_enabled:
|
||||
logger.debug("邮件渠道已在配置中关闭。")
|
||||
continue
|
||||
|
||||
issues = self._get_email_configuration_issues()
|
||||
if issues:
|
||||
logger.warning(
|
||||
"邮件通知配置不完整,跳过邮件渠道: " + "; ".join(issues)
|
||||
)
|
||||
continue
|
||||
|
||||
channels.append(EmailNotificationChannel(self.settings))
|
||||
|
||||
return channels
|
||||
|
||||
def _get_enabled_channel_names(self) -> list[str]:
|
||||
return [channel.lower() for channel in self.settings.notify_channels if channel.strip()]
|
||||
|
||||
def _get_email_configuration_issues(self) -> list[str]:
|
||||
issues: list[str] = []
|
||||
|
||||
if not self.settings.mail_smtp_host:
|
||||
issues.append("缺少 MAIL_SMTP_HOST")
|
||||
if not self.settings.mail_from_address:
|
||||
issues.append("缺少 MAIL_FROM_ADDRESS")
|
||||
if not self.settings.mail_to_addresses:
|
||||
issues.append("缺少 MAIL_TO_ADDRESSES")
|
||||
if not self.settings.mail_username:
|
||||
issues.append("缺少 MAIL_USERNAME")
|
||||
if self.settings.mail_username and self.settings.mail_password is None:
|
||||
issues.append("缺少 MAIL_PASSWORD")
|
||||
|
||||
return issues
|
||||
|
||||
def _render_task_failed_content(self, task_context: dict[str, Any]) -> str:
|
||||
message = task_context.get("message", "")
|
||||
message_txt = ""
|
||||
# 格式化为json字符串
|
||||
if message:
|
||||
message_txt = json.dumps(message, ensure_ascii=False, indent=4)
|
||||
|
||||
lines = [
|
||||
"采购任务失败通知",
|
||||
"",
|
||||
f"任务ID: {task_context.get('task_id', 'N/A')}",
|
||||
f"Topic: {task_context.get('topic', 'unknown')}",
|
||||
f"采购账号: {task_context.get('account_id', '')}",
|
||||
f"失败原因: {task_context.get('error_message', '') or '未知错误'}",
|
||||
f"截图路径: {task_context.get('screenshot_path', '无')}",
|
||||
f"录屏路径: {task_context.get('video_path', '无')}",
|
||||
f"发生时间: {task_context.get('occurred_at', datetime.now().strftime('%Y-%m-%d %H:%M:%S'))}",
|
||||
f"原始消息: {message_txt}",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1 @@
|
||||
"""页面解析器包。"""
|
||||
@@ -0,0 +1,18 @@
|
||||
from .models import (
|
||||
SurugayaOrderDetail,
|
||||
SurugayaOrderItem,
|
||||
SurugayaOrderStatus,
|
||||
SurugayaOrderSummary,
|
||||
SurugayaShippingInfo,
|
||||
)
|
||||
from .order_detail import SurugayaOrderDetailParser, parse_surugaya_order_detail
|
||||
|
||||
__all__ = [
|
||||
"SurugayaOrderDetail",
|
||||
"SurugayaOrderItem",
|
||||
"SurugayaOrderStatus",
|
||||
"SurugayaOrderSummary",
|
||||
"SurugayaShippingInfo",
|
||||
"SurugayaOrderDetailParser",
|
||||
"parse_surugaya_order_detail",
|
||||
]
|
||||
@@ -0,0 +1,69 @@
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SurugayaOrderStatus(BaseModel):
|
||||
"""交易状态表的结构化结果。"""
|
||||
|
||||
trade_status: str | None = None
|
||||
status_message: str | None = None
|
||||
# 保留日文原始键值,便于排障和后续补字段。
|
||||
raw: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SurugayaOrderSummary(BaseModel):
|
||||
"""订单摘要表的结构化结果。"""
|
||||
|
||||
trade_number: str | None = None
|
||||
order_date: str | None = None
|
||||
items_subtotal: int | None = None
|
||||
item_count: int | None = None
|
||||
payment_method: str | None = None
|
||||
cash_on_delivery_fee: int | None = None
|
||||
shipping_date: str | None = None
|
||||
inquiry_number: str | None = None
|
||||
shipping_and_handling_fee: int | None = None
|
||||
digital_gift_certificate_amount: int | None = None
|
||||
grand_total: int | None = None
|
||||
# 保留订单摘要表的原始日文字段。
|
||||
raw: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SurugayaShippingInfo(BaseModel):
|
||||
"""收件信息表的结构化结果。"""
|
||||
|
||||
recipient_name: str | None = None
|
||||
recipient_address: str | None = None
|
||||
recipient_phone_number: str | None = None
|
||||
recipient_email: str | None = None
|
||||
# 保留配送信息表的原始日文字段。
|
||||
raw: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SurugayaOrderItem(BaseModel):
|
||||
"""商品明细表中的单行商品记录。"""
|
||||
|
||||
product_code: str | None = None
|
||||
condition: str | None = None
|
||||
branch_number: str | None = None
|
||||
product_title: str | None = None
|
||||
unit_price: int | None = None
|
||||
quantity: int | None = None
|
||||
discount_amount: int | None = None
|
||||
line_total: int | None = None
|
||||
remarks: str | None = None
|
||||
# 每一行商品同时保留原始列名和值。
|
||||
raw: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SurugayaOrderDetail(BaseModel):
|
||||
"""骏河屋交易详情页的完整结构化结果。"""
|
||||
|
||||
site: Literal["surugaya"] = "surugaya"
|
||||
status: SurugayaOrderStatus
|
||||
summary: SurugayaOrderSummary
|
||||
shipping: SurugayaShippingInfo
|
||||
items: list[SurugayaOrderItem] = Field(default_factory=list)
|
||||
# 以业务分段聚合保留原始表格结果。
|
||||
raw_tables: dict[str, Any] = Field(default_factory=dict)
|
||||
@@ -0,0 +1,315 @@
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from playwright.async_api import Locator, Page
|
||||
|
||||
from .models import (
|
||||
SurugayaOrderDetail,
|
||||
SurugayaOrderItem,
|
||||
SurugayaShippingInfo,
|
||||
SurugayaOrderStatus,
|
||||
SurugayaOrderSummary,
|
||||
)
|
||||
|
||||
|
||||
# 订单摘要表中的日文字段到英文字段映射。
|
||||
SUMMARY_FIELD_MAP = {
|
||||
"取引番号": "trade_number",
|
||||
"注文日": "order_date",
|
||||
"商品合計": "items_subtotal",
|
||||
"商品点数": "item_count",
|
||||
"支払方法": "payment_method",
|
||||
"代引き手数料": "cash_on_delivery_fee",
|
||||
"発送日": "shipping_date",
|
||||
"お問い合わせ番号": "inquiry_number",
|
||||
"送料・通信販売手数料": "shipping_and_handling_fee",
|
||||
"電子商品券": "digital_gift_certificate_amount",
|
||||
"総合計": "grand_total",
|
||||
}
|
||||
|
||||
# 收件信息表的日文字段到英文字段映射。
|
||||
SHIPPING_FIELD_MAP = {
|
||||
"氏名": "recipient_name",
|
||||
"住所": "recipient_address",
|
||||
"電話番号": "recipient_phone_number",
|
||||
"メールアドレス": "recipient_email",
|
||||
}
|
||||
|
||||
# 商品明细表的列名到英文字段映射。
|
||||
ITEM_FIELD_MAP = {
|
||||
"品番": "product_id",
|
||||
"状態": "status",
|
||||
"枝番": "branch_number",
|
||||
"商品タイトル": "product_title",
|
||||
"単価": "unit_price",
|
||||
"数量": "number",
|
||||
"値引額": "discount_amount",
|
||||
"金額": "product_total_amount",
|
||||
"備考": "remarks",
|
||||
}
|
||||
|
||||
# 需要按金额规则清洗的字段集合。
|
||||
MONEY_FIELDS = {
|
||||
"items_subtotal",
|
||||
"cash_on_delivery_fee",
|
||||
"shipping_and_handling_fee",
|
||||
"digital_gift_certificate_amount",
|
||||
"grand_total",
|
||||
"unit_price",
|
||||
"discount_amount",
|
||||
"line_total",
|
||||
}
|
||||
|
||||
# 需要按整数规则清洗的字段集合。
|
||||
INT_FIELDS = {
|
||||
"item_count",
|
||||
"quantity",
|
||||
}
|
||||
|
||||
class SurugayaOrderDetailParser:
|
||||
"""骏河屋交易详情页解析器。"""
|
||||
|
||||
@classmethod
|
||||
async def parse_page(cls, page: Page) -> SurugayaOrderDetail:
|
||||
"""对外主入口:传入已加载详情页的 `Page`,返回结构化订单对象。"""
|
||||
# 适配 `SurugayaOrderDetailParser.parse_page(page)` 这种类调用方式。
|
||||
parser = cls()
|
||||
if await page.locator("#pageCont").count() < 1:
|
||||
raise ValueError("Surugaya order detail page not found")
|
||||
return await parser._parse_page_content(page)
|
||||
|
||||
async def _parse_page_content(self, page: Page) -> SurugayaOrderDetail:
|
||||
"""解析正常交易详情页中的 `#pageCont` 核心区域。"""
|
||||
|
||||
page_cont = page.locator("#pageCont").first
|
||||
tables = page_cont.locator("table")
|
||||
table_count = await tables.count()
|
||||
if table_count < 4:
|
||||
raise ValueError("Expected 4 trade tables under #pageCont")
|
||||
|
||||
status_table = tables.nth(0)
|
||||
summary_table = tables.nth(1)
|
||||
shipping_table = tables.nth(2)
|
||||
items_table = tables.nth(3)
|
||||
|
||||
status = await self._parse_status_table(status_table)
|
||||
summary = await self._parse_summary_table(summary_table)
|
||||
shipping = await self._parse_shipping_table(shipping_table)
|
||||
items = await self._parse_items_table(items_table)
|
||||
|
||||
return SurugayaOrderDetail(
|
||||
status=status,
|
||||
summary=summary,
|
||||
shipping=shipping,
|
||||
items=items,
|
||||
raw_tables={
|
||||
"status": status.raw,
|
||||
"summary": summary.raw,
|
||||
"shipping": shipping.raw,
|
||||
"items": [item.raw for item in items],
|
||||
},
|
||||
)
|
||||
|
||||
async def _parse_status_table(self, table: Locator) -> SurugayaOrderStatus:
|
||||
"""解析第 1 张交易状态表。"""
|
||||
rows = table.locator("tr")
|
||||
row_count = await rows.count()
|
||||
if row_count < 1:
|
||||
return SurugayaOrderStatus()
|
||||
|
||||
first_row = await self._extract_row_cells(rows.nth(0))
|
||||
raw: dict[str, str] = {}
|
||||
trade_status = None
|
||||
status_message = None
|
||||
|
||||
if len(first_row) >= 2 and first_row[0]["tag"] == "th" and first_row[1]["tag"] == "td":
|
||||
label = first_row[0]["text"]
|
||||
value = first_row[1]["text"]
|
||||
raw[label] = value
|
||||
lines = self._split_lines(value)
|
||||
trade_status = lines[0] if lines else None
|
||||
status_message = " ".join(lines[1:]) or None
|
||||
|
||||
return SurugayaOrderStatus(
|
||||
trade_status=trade_status,
|
||||
status_message=status_message,
|
||||
raw=raw,
|
||||
)
|
||||
|
||||
async def _parse_summary_table(self, table: Locator) -> SurugayaOrderSummary:
|
||||
"""解析第 2 张订单摘要表。"""
|
||||
raw_pairs = await self._parse_key_value_rows(table)
|
||||
normalized = self._normalize_mapped_values(raw_pairs, SUMMARY_FIELD_MAP)
|
||||
return SurugayaOrderSummary(**normalized, raw=raw_pairs)
|
||||
|
||||
async def _parse_shipping_table(self, table: Locator) -> SurugayaShippingInfo:
|
||||
"""解析第 3 张收件信息表。"""
|
||||
raw_pairs = await self._parse_key_value_rows(table)
|
||||
normalized = self._normalize_mapped_values(raw_pairs, SHIPPING_FIELD_MAP)
|
||||
return SurugayaShippingInfo(**normalized, raw=raw_pairs)
|
||||
|
||||
async def _parse_items_table(self, table: Locator) -> list[SurugayaOrderItem]:
|
||||
"""解析第 4 张商品明细表。"""
|
||||
rows = table.locator("tr")
|
||||
row_count = await rows.count()
|
||||
if row_count < 2:
|
||||
return []
|
||||
|
||||
header_cells = await self._extract_row_cells(rows.nth(0))
|
||||
headers = [cell["text"] for cell in header_cells if cell["tag"] == "th" and cell["text"]]
|
||||
if not headers:
|
||||
return []
|
||||
|
||||
items: list[SurugayaOrderItem] = []
|
||||
expected_columns = len(headers)
|
||||
|
||||
for index in range(1, row_count):
|
||||
cells = await self._extract_row_cells(rows.nth(index))
|
||||
if not cells:
|
||||
continue
|
||||
|
||||
td_cells = [cell for cell in cells if cell["tag"] == "td"]
|
||||
if len(td_cells) != expected_columns:
|
||||
continue
|
||||
|
||||
raw_item = {
|
||||
header: td_cells[position]["text"]
|
||||
for position, header in enumerate(headers)
|
||||
}
|
||||
|
||||
if not any(raw_item.values()):
|
||||
continue
|
||||
|
||||
normalized = self._normalize_mapped_values(raw_item, ITEM_FIELD_MAP)
|
||||
items.append(SurugayaOrderItem(**normalized, raw=raw_item))
|
||||
|
||||
return items
|
||||
|
||||
async def _parse_key_value_rows(self, table: Locator) -> dict[str, str]:
|
||||
"""通用解析:按 `th -> td` 配对方式抽取键值。"""
|
||||
rows = table.locator("tr")
|
||||
row_count = await rows.count()
|
||||
result: dict[str, str] = {}
|
||||
|
||||
for index in range(row_count):
|
||||
cells = await self._extract_row_cells(rows.nth(index))
|
||||
if not cells:
|
||||
continue
|
||||
|
||||
pointer = 0
|
||||
while pointer < len(cells):
|
||||
current = cells[pointer]
|
||||
if current["tag"] != "th":
|
||||
pointer += 1
|
||||
continue
|
||||
|
||||
label = current["text"]
|
||||
pointer += 1
|
||||
if not label:
|
||||
continue
|
||||
|
||||
while pointer < len(cells) and cells[pointer]["tag"] != "td":
|
||||
pointer += 1
|
||||
|
||||
if pointer >= len(cells):
|
||||
break
|
||||
|
||||
result[label] = cells[pointer]["text"]
|
||||
pointer += 1
|
||||
|
||||
return result
|
||||
|
||||
async def _extract_row_cells(self, row: Locator) -> list[dict[str, str]]:
|
||||
"""提取一行中的所有单元格,并保留标签名与纯文本。"""
|
||||
return await row.locator(":scope > th, :scope > td").evaluate_all(
|
||||
"""cells => cells.map((cell) => ({
|
||||
tag: cell.tagName.toLowerCase(),
|
||||
text: (cell.innerText || "").replace(/\\u00a0/g, " ").trim(),
|
||||
}))"""
|
||||
)
|
||||
|
||||
def _normalize_mapped_values(
|
||||
self,
|
||||
raw_data: dict[str, str],
|
||||
field_map: dict[str, str],
|
||||
) -> dict[str, Any]:
|
||||
"""将日文键值映射并清洗为结构化字段。"""
|
||||
normalized: dict[str, Any] = {}
|
||||
|
||||
for label, value in raw_data.items():
|
||||
english_key = field_map.get(label)
|
||||
if not english_key:
|
||||
continue
|
||||
|
||||
cleaned_value = self._clean_text(value)
|
||||
if english_key in MONEY_FIELDS:
|
||||
normalized[english_key] = self._normalize_money(cleaned_value)
|
||||
elif english_key in INT_FIELDS:
|
||||
normalized[english_key] = self._normalize_int(cleaned_value)
|
||||
elif english_key.endswith("_date"):
|
||||
normalized[english_key] = self._normalize_date(cleaned_value)
|
||||
else:
|
||||
normalized[english_key] = cleaned_value
|
||||
|
||||
return normalized
|
||||
|
||||
def _split_lines(self, text: str | None) -> list[str]:
|
||||
"""按行拆分文本,并清理每一行空白。"""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
lines = [self._clean_text(part) for part in text.splitlines()]
|
||||
return [line for line in lines if line]
|
||||
|
||||
def _clean_text(self, text: str | None) -> str | None:
|
||||
"""清洗文本中的多余空白并统一空值。"""
|
||||
if text is None:
|
||||
return None
|
||||
|
||||
cleaned = re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
|
||||
return cleaned or None
|
||||
|
||||
def _normalize_money(self, text: str | None) -> int | None:
|
||||
"""将 `¥1,234` / `1,234円` 这类金额文本转成整数。"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
digits = re.findall(r"-?\d[\d,]*", text)
|
||||
if not digits:
|
||||
return None
|
||||
|
||||
value = digits[0].replace(",", "")
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def _normalize_int(self, text: str | None) -> int | None:
|
||||
"""提取文本中的首个整数值。"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
digits = re.findall(r"\d+", text)
|
||||
if not digits:
|
||||
return None
|
||||
|
||||
return int(digits[0])
|
||||
|
||||
def _normalize_date(self, text: str | None) -> str | None:
|
||||
"""将 `YYYY.MM.DD` / `YYYY/MM/DD` 统一为 `YYYY-MM-DD`。"""
|
||||
if not text:
|
||||
return None
|
||||
|
||||
match = re.search(r"(\d{4})[./-](\d{2})[./-](\d{2})", text)
|
||||
if not match:
|
||||
return text
|
||||
|
||||
year, month, day = match.groups()
|
||||
return f"{year}-{month}-{day}"
|
||||
|
||||
|
||||
async def parse_surugaya_order_detail(page: Page) -> dict[str, Any]:
|
||||
"""便捷函数:直接返回可 JSON 序列化的订单字典。"""
|
||||
parser = SurugayaOrderDetailParser()
|
||||
result = await parser.parse_page(page)
|
||||
return result.model_dump(mode="json")
|
||||
@@ -0,0 +1 @@
|
||||
# 消息队列模块包初始化
|
||||
@@ -0,0 +1,137 @@
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Dict
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from loguru import logger
|
||||
|
||||
from src.config.settings import get_settings
|
||||
from src.task_queue.dispatcher import TaskDispatcher
|
||||
|
||||
_CLAIM_DUE_MEMBERS_LUA = """
|
||||
local key = KEYS[1]
|
||||
local now = tonumber(ARGV[1])
|
||||
local count = tonumber(ARGV[2])
|
||||
local vals = redis.call('ZRANGEBYSCORE', key, '-inf', now, 'LIMIT', 0, count)
|
||||
if #vals > 0 then
|
||||
redis.call('ZREM', key, unpack(vals))
|
||||
end
|
||||
return vals
|
||||
"""
|
||||
|
||||
|
||||
class RedisZSetDelayListener:
|
||||
def __init__(self, dispatcher: TaskDispatcher) -> None:
|
||||
self.settings = get_settings()
|
||||
self.dispatcher = dispatcher
|
||||
self.redis: aioredis.Redis | None = None
|
||||
self.running = False
|
||||
|
||||
self.node_id = self.settings.node_id
|
||||
self.redis_delay_zset_key = self.settings.redis_delay_zset_key
|
||||
self.zset_key = f"{self.redis_delay_zset_key}:{self.node_id}"
|
||||
self.batch_size = self.settings.redis_delay_batch_size
|
||||
|
||||
async def initialize(self) -> None:
|
||||
logger.debug(f"正在连接 Redis(延时队列): {self.settings.redis_host}:{self.settings.redis_port}...")
|
||||
self.redis = aioredis.Redis(
|
||||
host=self.settings.redis_host,
|
||||
port=self.settings.redis_port,
|
||||
password=self.settings.redis_password,
|
||||
db=self.settings.redis_db,
|
||||
)
|
||||
await self.redis.ping()
|
||||
logger.debug("Redis(延时队列) 连接成功。")
|
||||
|
||||
async def listen(self) -> None:
|
||||
if not self.redis:
|
||||
await self.initialize()
|
||||
|
||||
self.running = True
|
||||
logger.info(f"Redis ZSET 延时队列监听循环已启动,key='{self.zset_key}'。")
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
due_members = await self._claim_due_members()
|
||||
if not due_members:
|
||||
await asyncio.sleep(3)
|
||||
continue
|
||||
|
||||
for member in due_members:
|
||||
member_str = self._decode_member(member)
|
||||
message_data = self._parse_member_json(member_str)
|
||||
logger.debug(f"解析到到期任务: {message_data}")
|
||||
await self.dispatcher.dispatch(message_data)
|
||||
|
||||
except aioredis.ConnectionError as ce:
|
||||
logger.error(f"Redis(延时队列) 连接断开: {ce},5 秒后尝试重连...")
|
||||
await self._handle_reconnect()
|
||||
except Exception as e:
|
||||
logger.exception(f"延时队列监听循环发生未预期错误: {e},5 秒后重试...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _claim_due_members(self) -> list[Any]:
|
||||
if not self.redis:
|
||||
return []
|
||||
|
||||
now_seconds = int(time.time())
|
||||
try:
|
||||
members = await self.redis.eval(
|
||||
_CLAIM_DUE_MEMBERS_LUA,
|
||||
1,
|
||||
self.zset_key,
|
||||
now_seconds,
|
||||
self.batch_size,
|
||||
)
|
||||
return members or []
|
||||
except Exception as e:
|
||||
logger.error(f"延时队列认领到期任务失败: {e}")
|
||||
return []
|
||||
|
||||
def _decode_member(self, member: Any) -> str:
|
||||
if isinstance(member, bytes):
|
||||
return member.decode("utf-8")
|
||||
return str(member)
|
||||
|
||||
def _parse_member_json(self, member_str: str) -> Dict[str, Any]:
|
||||
try:
|
||||
data = json.loads(member_str)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("member JSON 不是对象结构")
|
||||
now = int(time.time())
|
||||
task_data = {
|
||||
"task_id": f"id_{now}",
|
||||
"topic": "surugaya_trade_monitor",
|
||||
"purchase_account": "trdian023@tokugen.jp",
|
||||
"site_id": "7",
|
||||
"record_video": "",
|
||||
"payload": {
|
||||
"trade_code": data.get("trade_code"),
|
||||
},
|
||||
}
|
||||
|
||||
return task_data
|
||||
except Exception as e:
|
||||
raise ValueError(f"延时队列 member 解析失败: {e} | member={member_str}")
|
||||
|
||||
async def _handle_reconnect(self) -> None:
|
||||
self.redis = None
|
||||
while self.running and not self.redis:
|
||||
try:
|
||||
await asyncio.sleep(5)
|
||||
await self.initialize()
|
||||
except Exception as e:
|
||||
logger.error(f"重连失败: {e},继续重试...")
|
||||
|
||||
async def stop(self) -> None:
|
||||
logger.info("正在停止 Redis ZSET 延时队列监听器...")
|
||||
self.running = False
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.close()
|
||||
logger.info("Redis(延时队列) 连接已关闭。")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭 Redis(延时队列) 连接时出错: {e}")
|
||||
finally:
|
||||
self.redis = None
|
||||
@@ -0,0 +1,119 @@
|
||||
import asyncio
|
||||
from collections import defaultdict
|
||||
from typing import Callable, Coroutine, Dict, Any, Type
|
||||
from loguru import logger
|
||||
|
||||
from src.tasks.surugaya import SurugayaPurchaseTask
|
||||
from src.tasks.surugaya_trade_monitor import SurugayaTradeMonitorTask
|
||||
|
||||
# topic 到采购任务类的映射注册表
|
||||
TOPIC_TO_TASK_MAP: Dict[str, Type[Any]] = {
|
||||
"surugaya": SurugayaPurchaseTask, # 骏河屋采购任务
|
||||
"surugaya_trade_monitor": SurugayaTradeMonitorTask, # 骏河屋交易订单获取任务
|
||||
}
|
||||
|
||||
|
||||
class TaskDispatcher:
|
||||
"""
|
||||
任务分发器:处理来自消息队列的采购任务。
|
||||
- 在后台协程中执行任务,与 Redis 监听器解耦。
|
||||
- 按账号维度使用 asyncio.Lock 串行化浏览器操作,避免冲突。
|
||||
- 支持按 topic 模块化路由到不同工作流。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# 按 account_id 维护锁字典,新账号会自动创建锁
|
||||
# 单线程事件循环下完全异步安全
|
||||
self._account_locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
|
||||
logger.info("任务分发器已初始化(支持按账号串行锁)。")
|
||||
|
||||
async def dispatch(
|
||||
self,
|
||||
message_data: Dict[str, Any],
|
||||
on_complete_callback: Callable[[str, bool, Any], Coroutine[Any, Any, None]] = None
|
||||
) -> None:
|
||||
"""
|
||||
启动非阻塞后台协程处理任务,使监听器无需等待浏览器自动化完成即可继续轮询 Redis。
|
||||
|
||||
Args:
|
||||
message_data: 已解析的任务消息字典。
|
||||
on_complete_callback: 任务完成(成功或失败)时执行的异步回调。
|
||||
"""
|
||||
task_id = message_data.get("task_id", "N/A")
|
||||
# logger.debug(f"正在为任务 '{task_id}' 启动后台协程...")
|
||||
|
||||
# 投递到事件循环,fire-and-forget
|
||||
asyncio.create_task(self._execute_task_safe(message_data, on_complete_callback))
|
||||
|
||||
async def _execute_task_safe(
|
||||
self,
|
||||
message_data: Dict[str, Any],
|
||||
on_complete_callback: Callable[[str, bool, Any], Coroutine[Any, Any, None]] = None
|
||||
) -> None:
|
||||
"""
|
||||
在账号锁约束下执行具体采购流程,并统一记录异常。
|
||||
"""
|
||||
task_id = message_data.get("task_id", "N/A")
|
||||
topic = str(message_data.get("topic"))
|
||||
account_id = str(message_data.get("purchase_account"))
|
||||
callback_id = message_data.get("_stream_msg_id") or message_data.get("_callback_id") or task_id
|
||||
|
||||
logger.info(f"[任务 {task_id}] 开始分发,topic='{topic}',账号='{account_id}'。")
|
||||
|
||||
# 1. 校验必填字段
|
||||
if not topic or not account_id:
|
||||
logger.error(f"[任务 {task_id}] 缺少必填字段 topic 或 purchase_account,任务已中止。")
|
||||
if on_complete_callback:
|
||||
await on_complete_callback(str(callback_id), False, {"error": "缺少关键路由字段。", "result": None})
|
||||
return
|
||||
|
||||
# 2. 根据 topic 匹配已注册的工作流
|
||||
task_class = TOPIC_TO_TASK_MAP.get(topic.lower())
|
||||
if not task_class:
|
||||
logger.error(f"[任务 {task_id}] 未找到 topic '{topic}' 对应的采购任务类,任务已中止。")
|
||||
if on_complete_callback:
|
||||
await on_complete_callback(
|
||||
str(callback_id),
|
||||
False,
|
||||
{"error": f"未知的 topic: {topic}", "result": None},
|
||||
)
|
||||
return
|
||||
|
||||
# 3. 获取该账号的串行锁
|
||||
lock = self._account_locks[account_id]
|
||||
logger.info(f"[任务 {task_id}] 账号 '{account_id}' 忙碌中,已进入串行锁队列等待...")
|
||||
|
||||
# 异步锁保证同一 account_id 同一时刻只有一个协程在执行
|
||||
async with lock:
|
||||
logger.success(f"[任务 {task_id}] 已获取账号 '{account_id}' 的锁,正在启动浏览器工作流...")
|
||||
|
||||
success = False
|
||||
error_msg = None
|
||||
result_data: Any = None
|
||||
task_instance: Any = None
|
||||
|
||||
try:
|
||||
# 实例化任务并执行
|
||||
task_instance = task_class(message_data)
|
||||
await task_instance.run()
|
||||
success = True
|
||||
result_data = getattr(task_instance, "task_result_data", None)
|
||||
logger.success(f"[任务 {task_id}] 工作流执行成功。")
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
if task_instance:
|
||||
result_data = getattr(task_instance, "task_result_data", None)
|
||||
logger.error(f"[任务 {task_id}] 工作流因未处理异常而终止: {e}")
|
||||
finally:
|
||||
# 4. 触发完成回调(例如 Redis Stream ACK)
|
||||
if on_complete_callback:
|
||||
try:
|
||||
await on_complete_callback(
|
||||
str(callback_id),
|
||||
success,
|
||||
{"error": error_msg, "result": result_data},
|
||||
)
|
||||
except Exception as cb_err:
|
||||
logger.error(f"[任务 {task_id}] 完成回调执行失败: {cb_err}")
|
||||
|
||||
logger.debug(f"[任务 {task_id}] 已释放账号 '{account_id}' 的锁。")
|
||||
@@ -0,0 +1,205 @@
|
||||
import asyncio
|
||||
import json
|
||||
from typing import Dict, Any
|
||||
import redis.asyncio as aioredis
|
||||
from loguru import logger
|
||||
from src.config.settings import get_settings
|
||||
from src.task_queue.dispatcher import TaskDispatcher
|
||||
|
||||
|
||||
class RedisStreamListener:
|
||||
"""
|
||||
Redis Stream 持续消息监听器。
|
||||
- 使用 Redis 消费者组在多消费者间分担负载。
|
||||
- 启动时读取 Pending 任务,恢复崩溃前未完成的任务。
|
||||
- 非阻塞地将任务交给 TaskDispatcher,执行完毕后再 ACK。
|
||||
- 连接异常时安全重连。
|
||||
"""
|
||||
|
||||
def __init__(self, dispatcher: TaskDispatcher) -> None:
|
||||
self.settings = get_settings()
|
||||
self.dispatcher = dispatcher
|
||||
self.redis: aioredis.Redis | None = None
|
||||
self.running = False
|
||||
|
||||
# 从全局配置读取 Stream 相关参数
|
||||
self.stream_key = self.settings.redis_stream_key
|
||||
self.group_name = self.settings.redis_consumer_group
|
||||
self.consumer_name = self.settings.redis_consumer_name
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""
|
||||
连接 Redis,若消费者组不存在则创建。
|
||||
"""
|
||||
logger.debug(f"正在连接 Redis: {self.settings.redis_host}:{self.settings.redis_port}...")
|
||||
self.redis = aioredis.Redis(
|
||||
host=self.settings.redis_host,
|
||||
port=self.settings.redis_port,
|
||||
password=self.settings.redis_password,
|
||||
db=self.settings.redis_db
|
||||
)
|
||||
|
||||
# 测试连接
|
||||
await self.redis.ping()
|
||||
logger.debug("Redis 连接成功。")
|
||||
|
||||
# 确保 Stream 消费者组存在
|
||||
try:
|
||||
# mkstream=True:若 Stream 不存在则自动创建
|
||||
await self.redis.xgroup_create(
|
||||
name=self.stream_key,
|
||||
groupname=self.group_name,
|
||||
id="0",
|
||||
mkstream=True
|
||||
)
|
||||
logger.debug(f"已创建消费者组 '{self.group_name}',Stream: '{self.stream_key}'。")
|
||||
except Exception as e:
|
||||
# BUSYGROUP 表示消费者组已存在,属于正常情况
|
||||
if "BUSYGROUP" in str(e):
|
||||
logger.debug(f"消费者组 '{self.group_name}' 已存在。")
|
||||
else:
|
||||
logger.error(f"创建消费者组失败: {e}")
|
||||
raise e
|
||||
|
||||
async def listen(self) -> None:
|
||||
"""
|
||||
启动监听循环:先处理 Pending 任务,再持续读取新消息。
|
||||
"""
|
||||
if not self.redis:
|
||||
await self.initialize()
|
||||
|
||||
self.running = True
|
||||
logger.info("Redis Stream 监听循环已启动。")
|
||||
|
||||
# 1. 先恢复分配给本消费者但未 ACK 的 Pending 消息(崩溃重启清理)
|
||||
await self._consume_pending_messages()
|
||||
|
||||
# 2. 主循环:持续消费新任务
|
||||
while self.running:
|
||||
try:
|
||||
# 读取新消息(">" 表示从未投递给其他消费者的消息)
|
||||
streams = await self.redis.xreadgroup(
|
||||
groupname=self.group_name,
|
||||
consumername=self.consumer_name,
|
||||
streams={self.stream_key: ">"},
|
||||
count=1,
|
||||
block=3000 # 阻塞 3 秒,保持事件循环响应
|
||||
)
|
||||
|
||||
if not streams:
|
||||
# 超时无新消息,继续下一轮
|
||||
continue
|
||||
|
||||
for stream, messages in streams:
|
||||
for msg_id, raw_data in messages:
|
||||
parsed_msg = self._parse_message(msg_id, raw_data)
|
||||
# 交给分发器处理,并注册 ACK 回调
|
||||
await self.dispatcher.dispatch(parsed_msg, on_complete_callback=self.ack_message)
|
||||
|
||||
except aioredis.ConnectionError as ce:
|
||||
logger.error(f"Redis 连接断开: {ce},5 秒后尝试重连...")
|
||||
await self._handle_reconnect()
|
||||
except Exception as e:
|
||||
logger.exception(f"监听循环发生未预期错误: {e},5 秒后重试...")
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _consume_pending_messages(self) -> None:
|
||||
"""
|
||||
检查并处理本消费者在 PEL(Pending Entries List)中的未确认任务。
|
||||
保证崩溃后可恢复中断的作业。
|
||||
"""
|
||||
logger.info("正在检查 PEL 中的待恢复任务...")
|
||||
try:
|
||||
# 读取已投递给本消费者但未 ACK 的消息(id="0")
|
||||
streams = await self.redis.xreadgroup(
|
||||
groupname=self.group_name,
|
||||
consumername=self.consumer_name,
|
||||
streams={self.stream_key: "0"},
|
||||
count=10,
|
||||
block=500
|
||||
)
|
||||
|
||||
if not streams:
|
||||
logger.info("没有需要恢复的待处理任务。")
|
||||
return
|
||||
|
||||
for stream, messages in streams:
|
||||
if messages:
|
||||
logger.warning(f"发现 {len(messages)} 条待恢复任务,正在重新处理...")
|
||||
for msg_id, raw_data in messages:
|
||||
parsed_msg = self._parse_message(msg_id, raw_data)
|
||||
await self.dispatcher.dispatch(parsed_msg, on_complete_callback=self.ack_message)
|
||||
except Exception as e:
|
||||
logger.error(f"恢复待处理任务失败: {e}")
|
||||
|
||||
def _parse_message(self, msg_id: bytes, raw_data: Dict[bytes, bytes]) -> Dict[str, Any]:
|
||||
"""
|
||||
将 Redis 二进制数据解码为字符串,并组装为任务字典。
|
||||
"""
|
||||
decoded_data: Dict[str, Any] = {}
|
||||
for k, v in raw_data.items():
|
||||
key_str = k.decode("utf-8")
|
||||
val_str = v.decode("utf-8")
|
||||
decoded_data[key_str] = val_str
|
||||
|
||||
# 若 payload 为 JSON 字符串,则反序列化
|
||||
if "payload" in decoded_data:
|
||||
try:
|
||||
payload_value = decoded_data["payload"]
|
||||
if isinstance(payload_value, str):
|
||||
decoded_data = json.loads(payload_value)
|
||||
except Exception:
|
||||
# 非 JSON 格式时保持原字符串
|
||||
pass
|
||||
|
||||
# 注入 Stream 消息 ID,供后续 ACK 使用
|
||||
decoded_data["_stream_msg_id"] = msg_id.decode("utf-8")
|
||||
logger.debug(f"解析消息 '{msg_id.decode('utf-8')}',内容: {decoded_data}")
|
||||
return decoded_data
|
||||
|
||||
async def ack_message(self, msg_id: str, success: bool, error_msg: Any = None) -> None:
|
||||
"""
|
||||
任务结束后的回调:执行 Redis Stream XACK,从 PEL 中移除已处理消息。
|
||||
"""
|
||||
if not self.redis:
|
||||
logger.error(f"Redis 未连接,无法 ACK 消息 '{msg_id}'。")
|
||||
return
|
||||
|
||||
try:
|
||||
await self.redis.xack(self.stream_key, self.group_name, msg_id)
|
||||
if success:
|
||||
logger.debug(f"消息 '{msg_id}' 已 ACK,任务执行成功。")
|
||||
else:
|
||||
detail = error_msg
|
||||
if isinstance(error_msg, dict) and "error" in error_msg:
|
||||
detail = error_msg.get("error")
|
||||
logger.warning(f"消息 '{msg_id}' 已 ACK,任务执行失败: {detail}")
|
||||
except Exception as e:
|
||||
logger.error(f"ACK 消息 '{msg_id}' 失败: {e}")
|
||||
|
||||
async def _handle_reconnect(self) -> None:
|
||||
"""
|
||||
周期性尝试重连 Redis,直至成功。
|
||||
"""
|
||||
self.redis = None
|
||||
while not self.redis:
|
||||
try:
|
||||
await asyncio.sleep(5)
|
||||
await self.initialize()
|
||||
except Exception as e:
|
||||
logger.error(f"重连失败: {e},继续重试...")
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""
|
||||
停止监听循环并安全关闭 Redis 连接。
|
||||
"""
|
||||
logger.info("正在停止 Redis Stream 监听器...")
|
||||
self.running = False
|
||||
if self.redis:
|
||||
try:
|
||||
await self.redis.close()
|
||||
logger.info("Redis 连接已关闭。")
|
||||
except Exception as e:
|
||||
logger.error(f"关闭 Redis 连接时出错: {e}")
|
||||
finally:
|
||||
self.redis = None
|
||||
@@ -0,0 +1,59 @@
|
||||
from functools import lru_cache
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from loguru import logger
|
||||
|
||||
from src.config.settings import get_settings
|
||||
|
||||
|
||||
class RedisManager:
|
||||
"""
|
||||
管理任务侧共享 Redis 客户端的生命周期。
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.settings = get_settings()
|
||||
self._redis: aioredis.Redis | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
if self._redis is not None:
|
||||
return
|
||||
|
||||
logger.debug(
|
||||
f"正在初始化任务侧 RedisManager: {self.settings.redis_host}:{self.settings.redis_port}..."
|
||||
)
|
||||
redis_client = aioredis.Redis(
|
||||
host=self.settings.redis_host,
|
||||
port=self.settings.redis_port,
|
||||
password=self.settings.redis_password,
|
||||
db=self.settings.redis_db,
|
||||
health_check_interval=30, # 定期心跳保活,防止长连接被防火墙静默掐断
|
||||
max_connections=50, # 限制最大连接数,防止突发并发耗尽 Redis 资源
|
||||
)
|
||||
await redis_client.ping()
|
||||
self._redis = redis_client
|
||||
logger.info("RedisManager 初始化成功。")
|
||||
|
||||
def get_client(self) -> aioredis.Redis:
|
||||
if self._redis is None:
|
||||
raise RuntimeError("RedisManager 尚未初始化,请先在应用启动阶段调用 initialize()。")
|
||||
return self._redis
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._redis is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._redis.close()
|
||||
logger.info("任务侧 RedisManager 已关闭。")
|
||||
finally:
|
||||
self._redis = None
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_redis_manager() -> RedisManager:
|
||||
return RedisManager()
|
||||
|
||||
|
||||
def get_redis_client() -> aioredis.Redis:
|
||||
return get_redis_manager().get_client()
|
||||
@@ -0,0 +1 @@
|
||||
# 任务模块包初始化
|
||||
@@ -0,0 +1,325 @@
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import datetime
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any
|
||||
|
||||
import redis.asyncio as aioredis
|
||||
from playwright.async_api import Playwright, BrowserContext, Page
|
||||
from loguru import logger
|
||||
|
||||
from src.config.settings import get_settings
|
||||
from src.config.accounts import get_account_config, AccountConfig
|
||||
from src.browser.manager import BrowserManager
|
||||
from src.notifications.service import NotificationService
|
||||
from src.utils.video_recorder import TaskVideoRecorder
|
||||
|
||||
|
||||
class BasePurchaseTask(ABC):
|
||||
"""
|
||||
采购任务基类:定义工作流骨架与生命周期。
|
||||
- 从配置文件解析账号信息。
|
||||
- 启动并管理隔离的 Playwright 上下文。
|
||||
- 支持自定义截图,失败时自动紧急截图。
|
||||
- 任何情况下都强制回收浏览器资源,防止泄漏。
|
||||
"""
|
||||
|
||||
def __init__(self, message_data: Dict[str, Any]) -> None:
|
||||
self.message = message_data
|
||||
self.settings = get_settings()
|
||||
self.browser_manager = BrowserManager()
|
||||
self.notification_service = NotificationService(self.settings)
|
||||
|
||||
# 从消息中提取的任务参数
|
||||
self.task_id: str = message_data.get("task_id", "N/A")
|
||||
self.topic: str = message_data.get("topic", "unknown")
|
||||
self.account_id: str = message_data.get("purchase_account", "")
|
||||
self.site_id: str = message_data.get("site_id", "unknown")
|
||||
|
||||
# 需在结束时清理的 Playwright 对象
|
||||
self.playwright_driver: Playwright | None = None
|
||||
self.context: BrowserContext | None = None
|
||||
self.page: Page | None = None
|
||||
self.video_recorder: TaskVideoRecorder | None = None
|
||||
self.video_output_path: Path | None = None
|
||||
self.last_screenshot_path: Path | None = None
|
||||
self.record_video_enabled = self._resolve_record_video_enabled()
|
||||
|
||||
# 记录任务执行状态与错误信息
|
||||
self.is_success: bool = False
|
||||
self.error_message: str = ""
|
||||
self.task_result_data: Any = None
|
||||
# 上报的事件,有值才上报
|
||||
self.report_event: str = ""
|
||||
# 是否上报任务结果
|
||||
self.is_report: bool = False
|
||||
|
||||
async def run(self) -> None:
|
||||
"""
|
||||
执行采购任务完整生命周期:初始化、业务逻辑、异常处理、资源清理。
|
||||
"""
|
||||
logger.info(f"[任务 {self.task_id}] 正在解析账号配置,账号 ID: '{self.account_id}'...")
|
||||
# 1. 查找账号配置(先骏河屋,后续可扩展其他站点)
|
||||
account_config = get_account_config("surugaya", self.account_id)
|
||||
|
||||
if not account_config:
|
||||
err_msg = f"无法解析账号 '{self.account_id}' 的配置。"
|
||||
logger.error(f"[任务 {self.task_id}] {err_msg}")
|
||||
raise ValueError(err_msg)
|
||||
|
||||
logger.debug(
|
||||
f"[任务 {self.task_id}] 使用账号: {account_config.username} | 数据目录: {account_config.user_data_dir}")
|
||||
|
||||
try:
|
||||
# 开启任务录屏(如果配置)
|
||||
if self.record_video_enabled:
|
||||
self.video_recorder = TaskVideoRecorder(
|
||||
archives_root=self.settings.screenshot_dir,
|
||||
topic=self.topic,
|
||||
task_id=self.task_id,
|
||||
)
|
||||
self.video_output_path = await self.video_recorder.start()
|
||||
logger.warning(
|
||||
f"[任务 {self.task_id}] 已开启任务录屏,输出文件: "
|
||||
f"{self.video_output_path.name} | 路径: {self.video_output_path.absolute()}"
|
||||
)
|
||||
|
||||
# 通过 BrowserManager 启动浏览器
|
||||
self.playwright_driver, self.context = await self.browser_manager.get_context(account_config)
|
||||
|
||||
# 打开新标签页
|
||||
self.page = await self.context.new_page()
|
||||
|
||||
# 交由子类实现具体站点逻辑
|
||||
self.task_result_data = await self.execute()
|
||||
self.is_success = True
|
||||
|
||||
except Exception as e:
|
||||
self.error_message = str(e)
|
||||
# 未预期失败时拍摄紧急截图留存现场
|
||||
logger.error(f"[任务 {self.task_id}] 执行出错: {e},延迟关闭并截图")
|
||||
if self.page:
|
||||
try:
|
||||
await asyncio.sleep(0.5)
|
||||
await self.take_screenshot(scene="任务执行出错")
|
||||
except Exception as ss_err:
|
||||
logger.error(f"[任务 {self.task_id}] 截图失败: {ss_err}")
|
||||
|
||||
# 在挂起排障前,必须先结束录屏,否则录像进程会无休止录制占用硬盘
|
||||
await self._stop_video_record_safely()
|
||||
|
||||
# 通知平台人员任务失败
|
||||
await self._notify_task_failed()
|
||||
|
||||
# raise
|
||||
|
||||
finally:
|
||||
# 正常结束或未被 except 拦截的其他情况(如主动 Cancel),确保停止录屏
|
||||
await self._stop_video_record_safely()
|
||||
|
||||
# 无论成败都回收 Playwright 后台进程
|
||||
logger.debug(f"[任务 {self.task_id}] 正在清理浏览器上下文并停止驱动...")
|
||||
try:
|
||||
if self.context:
|
||||
await self.context.close()
|
||||
if self.playwright_driver:
|
||||
await self.playwright_driver.stop()
|
||||
logger.debug(f"[任务 {self.task_id}] 浏览器资源已安全释放。")
|
||||
except Exception as close_err:
|
||||
logger.error(f"[任务 {self.task_id}] 释放浏览器资源时出错: {close_err}")
|
||||
|
||||
# 上报任务结果
|
||||
if self.is_report:
|
||||
await self.report_task_result()
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self) -> Any:
|
||||
"""
|
||||
抽象方法:各站点具体的采购业务流程。
|
||||
由子类实现(例如 SurugayaPurchaseTask),可返回执行结果数据以供上报。
|
||||
"""
|
||||
pass
|
||||
|
||||
async def report_task_result(self) -> None:
|
||||
"""
|
||||
上报任务执行结果。
|
||||
无论成功还是失败都会调用,其失败不影响消息的 ACK。
|
||||
"""
|
||||
url = self.settings.callback_url
|
||||
if not url:
|
||||
return
|
||||
|
||||
payload = {
|
||||
"event": self.report_event,
|
||||
"task_id": self.task_id,
|
||||
"is_success": 1 if self.is_success else 0,
|
||||
"error_message": self.error_message,
|
||||
"result_data": self.task_result_data,
|
||||
"original_message": self.message,
|
||||
}
|
||||
|
||||
logger.info(f"[任务 {self.task_id}] 正在上报任务结果 (is_success={payload['is_success']}) 至 {url} ...")
|
||||
logger.debug(f"[任务 {self.task_id}] 上报内容: {json.dumps(payload, ensure_ascii=False)}")
|
||||
try:
|
||||
await self._send_http_report_async(url, payload)
|
||||
logger.success(f"[任务 {self.task_id}] 任务结果上报成功。")
|
||||
except Exception as e:
|
||||
logger.error(f"[任务 {self.task_id}] 任务结果上报失败: {e}")
|
||||
|
||||
async def _send_http_report_async(self, url: str, data: dict) -> None:
|
||||
"""
|
||||
使用 httpx 发送异步 HTTP 请求,并附带签名认证(AppId + 签名摘要)。
|
||||
"""
|
||||
import json
|
||||
import httpx
|
||||
from src.utils.http_utils import generate_signed_headers
|
||||
|
||||
# 1. 准备基础数据
|
||||
app_id = self.settings.appid or ""
|
||||
app_secret = self.settings.app_secret or ""
|
||||
|
||||
# 2. 组装 Headers
|
||||
headers = generate_signed_headers(app_id, app_secret, data)
|
||||
headers["Content-Type"] = "application/json; charset=utf-8"
|
||||
|
||||
# 3. 序列化 Body,需与生成签名时的序列化方式一致
|
||||
payload_bytes = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
||||
|
||||
# 4. 异步发送请求
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(url, content=payload_bytes, headers=headers)
|
||||
response.raise_for_status()
|
||||
|
||||
# 5. 解析响应内容(根据实际业务接口文档可作调整)
|
||||
response_data = response.json()
|
||||
logger.info(f"[任务 {self.task_id}] 任务结果上报成功,响应内容: {response_data}")
|
||||
|
||||
async def _stop_video_record_safely(self) -> None:
|
||||
"""
|
||||
安全停止录屏的幂等方法,确保任务出错挂起或自然结束时都能正确停止 FFmpeg。
|
||||
"""
|
||||
if self.video_recorder and self.video_recorder.is_recording:
|
||||
try:
|
||||
saved_video_path = await self.video_recorder.stop()
|
||||
if saved_video_path:
|
||||
self.video_output_path = saved_video_path
|
||||
logger.warning(
|
||||
f"[任务 {self.task_id}] 任务录屏已保存: "
|
||||
f"{saved_video_path.name} | 路径: {saved_video_path.absolute()}"
|
||||
)
|
||||
else:
|
||||
logger.warning(f"[任务 {self.task_id}] 录屏已停止,但未找到输出文件。")
|
||||
except Exception as video_err:
|
||||
logger.error(f"[任务 {self.task_id}] 停止录屏时出错: {video_err}")
|
||||
|
||||
async def take_screenshot(self, scene: str, **screenshot_options: Any) -> Path | None:
|
||||
"""
|
||||
截取当前页面并保存至:
|
||||
archives/{YYYYMMDD}/{topic}/{task_id}_{scene}_{timestamp}.png
|
||||
|
||||
额外的 screenshot 参数会透传给 Playwright 的 page.screenshot(),
|
||||
例如:full_page=True
|
||||
"""
|
||||
if not self.page:
|
||||
logger.warning(f"[任务 {self.task_id}] 无法截图 '{scene}':当前没有可用的 Page 对象。")
|
||||
return None
|
||||
|
||||
# 1. 按日期组织目录
|
||||
now = datetime.now()
|
||||
date_str = now.strftime("%Y%m%d")
|
||||
timestamp_ms = int(now.timestamp() * 1000)
|
||||
|
||||
# 2. 清理路径中的非法字符(仅保留字母数字、连字符、下划线)
|
||||
topic_clean = "".join(c for c in self.topic if c.isalnum() or c in ("_", "-"))
|
||||
scene_clean = "".join(c for c in scene if c.isalnum() or c in ("_", "-"))
|
||||
task_id_clean = "".join(c for c in self.task_id if c.isalnum() or c in ("_", "-"))
|
||||
|
||||
# 3. 创建目标目录
|
||||
screenshot_dir = Path(self.settings.screenshot_dir) / date_str / topic_clean
|
||||
|
||||
try:
|
||||
screenshot_dir.mkdir(parents=True, exist_ok=True)
|
||||
filename = f"{task_id_clean}_{scene_clean}_{timestamp_ms}.png"
|
||||
filepath = screenshot_dir / filename
|
||||
|
||||
logger.debug(f"[任务 {self.task_id}] 保存截图至: {filepath.absolute()}")
|
||||
|
||||
# 加入默认超时时间,避免默认的 30s 导致任务长时间挂起
|
||||
if "timeout" not in screenshot_options:
|
||||
screenshot_options["timeout"] = 10000
|
||||
|
||||
try:
|
||||
await self.page.screenshot(
|
||||
path=str(filepath.absolute()),
|
||||
**screenshot_options,
|
||||
)
|
||||
except Exception as e:
|
||||
# Playwright 截图时常会因为等待 Web 字体加载 (waiting for fonts to load) 而超时挂起
|
||||
if "Timeout" in str(e) or "fonts" in str(e):
|
||||
logger.warning(f"[任务 {self.task_id}] 截图遇到超时或字体加载阻塞,尝试强制停止页面网络后重试...")
|
||||
# 强制停止页面中所有还在 pending 的网络请求(如加载极慢的外部字体)
|
||||
await self.page.evaluate("window.stop()")
|
||||
await self.page.screenshot(
|
||||
path=str(filepath.absolute()),
|
||||
timeout=5000, # 重试给 5 秒
|
||||
)
|
||||
else:
|
||||
raise e
|
||||
|
||||
logger.success(f"[任务 {self.task_id}] 截图已保存: {filename}")
|
||||
self.last_screenshot_path = filepath
|
||||
return filepath
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"[任务 {self.task_id}] 保存场景 '{scene}' 的截图失败: {e}")
|
||||
return None
|
||||
|
||||
def _resolve_record_video_enabled(self) -> bool:
|
||||
top_level_value = self.message.get("record_video")
|
||||
if top_level_value is not None:
|
||||
return str(top_level_value).strip() == "1"
|
||||
|
||||
payload = self.message.get("payload")
|
||||
if isinstance(payload, dict) and "record_video" in payload:
|
||||
return str(payload.get("record_video")).strip() == "1"
|
||||
|
||||
return False
|
||||
|
||||
def _build_failure_notification_context(self) -> Dict[str, Any]:
|
||||
return {
|
||||
"title": "任务执行失败",
|
||||
"task_id": self.task_id,
|
||||
"topic": self.topic,
|
||||
"account_id": self.account_id,
|
||||
"site_id": self.site_id,
|
||||
"error_message": self.error_message,
|
||||
"screenshot_path": str(self.last_screenshot_path.absolute()) if self.last_screenshot_path else "无",
|
||||
"video_path": str(self.video_output_path.absolute()) if self.video_output_path else "无",
|
||||
"occurred_at": datetime.now().isoformat(timespec="seconds"),
|
||||
"message": self.message,
|
||||
}
|
||||
|
||||
async def _notify_task_failed(self) -> None:
|
||||
try:
|
||||
await self.notification_service.notify_task_failed(
|
||||
self._build_failure_notification_context()
|
||||
)
|
||||
except Exception as notify_err:
|
||||
logger.error(f"[任务 {self.task_id}] 发送失败通知时出错: {notify_err}")
|
||||
|
||||
def get_delay_zset_key(self) -> str:
|
||||
return f"{self.settings.redis_delay_zset_key}:{self.settings.node_id}"
|
||||
|
||||
def get_hash_key(self) -> str:
|
||||
"""
|
||||
生成Redis Hash表的key,包含节点ID和交易单号。
|
||||
"""
|
||||
return "jp_surugaya_trade"
|
||||
|
||||
def get_delay_interval_seconds(self) -> int:
|
||||
"""
|
||||
获取延时队列间隔,单位秒。
|
||||
"""
|
||||
intervals = self.settings.redis_delay_poll_interval or 3600
|
||||
return intervals
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,106 @@
|
||||
import json
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from parsers.surugaya import SurugayaOrderDetailParser
|
||||
from src.tasks.base import BasePurchaseTask
|
||||
from task_queue.redis_manager import get_redis_client
|
||||
|
||||
|
||||
class SurugayaTradeMonitorTask(BasePurchaseTask):
|
||||
"""
|
||||
骏河屋交易订单获取任务。
|
||||
|
||||
入参约定:
|
||||
- topic: surugaya_trade_monitor
|
||||
- payload.trade_list: 交易单号列表
|
||||
"""
|
||||
|
||||
async def execute(self) -> dict[str, Any]:
|
||||
self.report_event = "surugaya_trade_monitor"
|
||||
self.is_report = False
|
||||
redis_client = None
|
||||
trade_code = ""
|
||||
try:
|
||||
logger.debug(f"[任务 {self.task_id}] 骏河屋交易订单获取工作流已启动。")
|
||||
if not self.page:
|
||||
raise ValueError("页面对象未初始化,无法执行交易订单获取任务。")
|
||||
|
||||
payload = self.message.get("payload", {})
|
||||
trade_code = payload.get("trade_code", "")
|
||||
if not trade_code:
|
||||
err_msg = f"无法解析交易单号 '{trade_code}'。"
|
||||
logger.error(err_msg)
|
||||
raise ValueError(err_msg)
|
||||
|
||||
page = self.page
|
||||
|
||||
await page.goto(f"https://www.suruga-ya.jp/pcmypage/action_sell_search/detail?trade_code={trade_code}",
|
||||
wait_until="domcontentloaded", timeout=30000)
|
||||
|
||||
order_detail_model = await SurugayaOrderDetailParser.parse_page(page)
|
||||
order_detail = order_detail_model.model_dump(mode="json") if order_detail_model else None
|
||||
|
||||
logger.info(f"[任务 {self.task_id}] 骏河屋交易订单获取工作流执行成功,订单详情:{order_detail}")
|
||||
|
||||
# 写入hash表
|
||||
if not order_detail:
|
||||
raise Exception(f"交易单号 '{trade_code}' 未解析到订单详情。")
|
||||
|
||||
new_trade_status = order_detail_model.status.trade_status or ""
|
||||
trade_number = order_detail_model.summary.trade_number or None
|
||||
|
||||
redis_client = get_redis_client()
|
||||
|
||||
# 更新hash表
|
||||
hash_key = self.get_hash_key()
|
||||
delay_zset_key = self.get_delay_zset_key()
|
||||
requeue_member = json.dumps({"trade_code": trade_code}, ensure_ascii=False)
|
||||
|
||||
# 获取上次保存的订单状态
|
||||
old_trade_status = None
|
||||
existing_order_detail = await redis_client.hget(hash_key, trade_code)
|
||||
if existing_order_detail:
|
||||
old_trade_status = json.loads(existing_order_detail)["status"]["trade_status"]
|
||||
|
||||
if old_trade_status and old_trade_status != new_trade_status:
|
||||
logger.info(f"[任务 {self.task_id}] 订单状态发生改变,需要上报。")
|
||||
self.is_report = True
|
||||
|
||||
await redis_client.hset(
|
||||
hash_key,
|
||||
trade_code,
|
||||
json.dumps(order_detail, ensure_ascii=False),
|
||||
)
|
||||
logger.info(
|
||||
f"[任务 {self.task_id}] 已将订单详情写入 Redis Hash '{hash_key}',field='{trade_code}'。"
|
||||
)
|
||||
|
||||
enqueue = True # 是否需要再次入队
|
||||
if trade_number is not None:
|
||||
logger.warning(
|
||||
f"[任务 {self.task_id}] 订单物流单号已获取到 '{trade_number}',放弃再次入队。"
|
||||
)
|
||||
enqueue = False
|
||||
elif new_trade_status == "発送済み" or new_trade_status == "取引終了":
|
||||
logger.warning(f"[任务 {self.task_id}] 订单状态为 '{new_trade_status}',放弃再次入队。")
|
||||
enqueue = False
|
||||
|
||||
# 任务再次入队
|
||||
if enqueue:
|
||||
next_score = int(time.time()) + self.get_delay_interval_seconds()
|
||||
await redis_client.zadd(delay_zset_key, {requeue_member: next_score})
|
||||
logger.info(
|
||||
f"[任务 {self.task_id}] 延时任务已重新入队,key='{delay_zset_key}',score={next_score}。"
|
||||
)
|
||||
|
||||
return {
|
||||
"trade_code": trade_code,
|
||||
"order_detail": order_detail,
|
||||
}
|
||||
except Exception as e:
|
||||
self.is_report = False
|
||||
logger.error(f"[任务 {self.task_id}] 骏河屋交易订单获取工作流执行失败,错误信息:{e}")
|
||||
raise e
|
||||
@@ -0,0 +1 @@
|
||||
# 工具模块包初始化
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
def is_valid_str(s):
|
||||
"""判断字符串是否有效,即非空且非空格。"""
|
||||
return isinstance(s, str) and len(s.strip()) > 0
|
||||
@@ -0,0 +1,38 @@
|
||||
import json
|
||||
import time
|
||||
import hmac
|
||||
import secrets
|
||||
import hashlib
|
||||
from typing import Dict, Any
|
||||
|
||||
def generate_signed_headers(app_id: str, app_secret: str, data: Dict[str, Any], method: str = "POST") -> Dict[str, str]:
|
||||
timestamp = str(int(time.time() * 1000))
|
||||
nonce = secrets.token_hex(16)
|
||||
|
||||
payload_str = json.dumps(data, ensure_ascii=False)
|
||||
payload_bytes = payload_str.encode("utf-8")
|
||||
actual_body_hash = hashlib.sha256(payload_bytes).hexdigest()
|
||||
|
||||
string_to_sign = "\n".join([
|
||||
method.upper(),
|
||||
app_id,
|
||||
timestamp,
|
||||
nonce,
|
||||
actual_body_hash
|
||||
])
|
||||
|
||||
signature = hmac.new(
|
||||
app_secret.encode("utf-8"),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha256
|
||||
).hexdigest()
|
||||
|
||||
headers = {
|
||||
"X-Ca-Appid": app_id,
|
||||
"X-Ca-Timestamp": timestamp,
|
||||
"X-Ca-Nonce": nonce,
|
||||
"X-Ca-Signature": signature,
|
||||
"Content-Type": "application/json; charset=utf-8"
|
||||
}
|
||||
|
||||
return headers
|
||||
@@ -0,0 +1,71 @@
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from loguru import logger
|
||||
from src.config.settings import get_settings
|
||||
|
||||
# 统一日志格式:日期时间 | 级别 | 消息(不含模块/函数/行号)
|
||||
_LOG_FORMAT = "{time:YYYY-MM-DD HH:mm:ss} | {level: <5} | {message}"
|
||||
|
||||
# 控制台在相同格式上叠加颜色(内容与文件一致)
|
||||
_CONSOLE_FORMAT = (
|
||||
"<green>{time:YYYY-MM-DD HH:mm:ss}</green> | "
|
||||
"<level>{level: <5}</level> | "
|
||||
"<level>{message}</level>"
|
||||
)
|
||||
|
||||
def setup_logger() -> None:
|
||||
"""
|
||||
配置并初始化 Loguru 日志系统,具备以下能力:
|
||||
- 控制台与文件使用相同的时间与字段布局。
|
||||
- 非阻塞异步日志写入(内部任务队列)。
|
||||
- 每天 00:00 自动按日切割日志文件。
|
||||
- 保留最近 14 天历史,旧日志自动压缩为 zip。
|
||||
- 支持通过配置自定义日志目录。
|
||||
"""
|
||||
settings = get_settings()
|
||||
log_dir = Path(settings.log_dir)
|
||||
|
||||
# 确保日志目录存在
|
||||
try:
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
except Exception as e:
|
||||
print(f"严重错误: 无法创建日志目录 '{log_dir}': {e}", file=sys.stderr)
|
||||
raise e
|
||||
|
||||
# 移除默认 handler,避免重复输出
|
||||
logger.remove()
|
||||
|
||||
# 覆盖 INFO 级别的控制台颜色为白色
|
||||
logger.level("INFO", color="<green>")
|
||||
logger.level("DEBUG", color="<white>")
|
||||
|
||||
is_dev = settings.app_env.lower() == "dev"
|
||||
|
||||
# 1. 控制台输出
|
||||
logger.add(
|
||||
sys.stderr,
|
||||
format=_CONSOLE_FORMAT,
|
||||
level=settings.log_level.upper(),
|
||||
enqueue=True,
|
||||
backtrace=is_dev,
|
||||
diagnose=is_dev,
|
||||
)
|
||||
|
||||
# 2. 文件输出(仅在非 dev 环境,或特定配置下启用)
|
||||
# 按日切割:logs/purchase_YYYY-MM-DD.log
|
||||
if not is_dev:
|
||||
log_file_pattern = log_dir / "app_{time:YYYY-MM-DD}.log"
|
||||
|
||||
logger.add(
|
||||
str(log_file_pattern),
|
||||
format=_LOG_FORMAT,
|
||||
level=settings.log_level.upper(),
|
||||
rotation="00:00", # 每天午夜 00:00 切割
|
||||
retention="14 days", # 自动清理 14 天前的日志
|
||||
compression="zip", # 历史日志压缩为 zip
|
||||
enqueue=True, # 异步线程/协程安全写入
|
||||
backtrace=True,
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
logger.info(f"日志系统已初始化。级别: {settings.log_level.upper()},目录: {log_dir.absolute()}")
|
||||
@@ -0,0 +1,116 @@
|
||||
from playwright.async_api import Page
|
||||
import asyncio
|
||||
import random
|
||||
import re
|
||||
|
||||
async def human_scroll(page: Page, mode: str, value: int = 0):
|
||||
"""
|
||||
模拟人类行为的网页滚动方法
|
||||
:param page: Playwright 的 Page 对象
|
||||
:param mode: 滚动模式 -> 'top'(滚动到顶部), 'bottom'(滚动到底部), 'random'(随机上下滚动), 'distance'(滚动指定距离)
|
||||
:param value: 当 mode='distance' 时,代表要滚动的像素距离(正数向下,负数向上)
|
||||
"""
|
||||
|
||||
async def smooth_scroll_step(pixel_distance: int):
|
||||
"""核心拟真算法:将一个大距离拆分成带有加速度和随机抖动的微小步长"""
|
||||
if pixel_distance == 0:
|
||||
return
|
||||
|
||||
direction = 1 if pixel_distance > 0 else -1
|
||||
remaining = abs(pixel_distance)
|
||||
|
||||
while remaining > 0:
|
||||
# 模拟人类滚动时越接近目标速度越慢的特征(减速缓冲)
|
||||
if remaining > 100:
|
||||
step = random.randint(30, 70)
|
||||
elif remaining > 30:
|
||||
step = random.randint(10, 25)
|
||||
else:
|
||||
step = remaining
|
||||
|
||||
remaining -= step
|
||||
scroll_y = step * direction
|
||||
|
||||
# 执行单步微调滚动
|
||||
await page.evaluate(f"window.scrollBy(0, {scroll_y});")
|
||||
|
||||
# 随机微小停顿,模拟手指滑动的摩擦阻力(20ms - 50ms)
|
||||
await asyncio.sleep(random.uniform(0.02, 0.05))
|
||||
|
||||
# 0.5% 的极低概率触发人类的“视线停留”
|
||||
if random.random() < 0.005:
|
||||
await asyncio.sleep(random.uniform(0.3, 0.8))
|
||||
|
||||
# ==================== 模式 0:滚动到顶部 ====================
|
||||
if mode == 'top':
|
||||
# print("[HumanScroll] 正在模拟向上滚动至页面顶部...")
|
||||
last_scroll = -1
|
||||
while True:
|
||||
# 获取当前滚动条位置
|
||||
current_scroll = await page.evaluate("window.scrollY")
|
||||
|
||||
# 防死循环:如果位置不再变化,说明已经到顶或页面无法滚动
|
||||
if current_scroll <= 0 or current_scroll == last_scroll:
|
||||
break
|
||||
|
||||
last_scroll = current_scroll
|
||||
chunk = random.randint(300, 700)
|
||||
await smooth_scroll_step(-chunk)
|
||||
await asyncio.sleep(random.uniform(0.5, 1.2)) # 每滑一下停顿看一眼
|
||||
|
||||
# ==================== 模式 1:滚动到底部 ====================
|
||||
elif mode == 'bottom':
|
||||
# print("[HumanScroll] 正在模拟向下滚动至页面底部...")
|
||||
last_scroll = -1
|
||||
while True:
|
||||
# 合并 evaluate 请求,减少与浏览器的 IPC 通信开销
|
||||
scroll_info = await page.evaluate("() => [window.scrollY + window.innerHeight, document.body.scrollHeight]")
|
||||
current_scroll, total_height = scroll_info[0], scroll_info[1]
|
||||
|
||||
# 防死循环:如果位置不再变化,说明已经到底或页面无法滚动
|
||||
if current_scroll >= total_height - 10 or current_scroll == last_scroll:
|
||||
break
|
||||
|
||||
last_scroll = current_scroll
|
||||
chunk = random.randint(300, 700)
|
||||
await smooth_scroll_step(chunk)
|
||||
await asyncio.sleep(random.uniform(0.5, 1.2)) # 每滑一下停顿看一眼
|
||||
|
||||
# ==================== 模式 2:随机上下滚动几下 ====================
|
||||
elif mode == 'random':
|
||||
# print("[HumanScroll] 正在执行人类迷惑行为:随机上下滚动...")
|
||||
# 随机决定滚动 3 到 6 下
|
||||
scroll_times = random.randint(3, 6)
|
||||
|
||||
for _ in range(scroll_times):
|
||||
# 70% 概率向下,30% 概率向上
|
||||
if random.random() > 0.3:
|
||||
distance = random.randint(200, 500) # 向下滚
|
||||
else:
|
||||
distance = random.randint(-400, -150) # 向上回滚
|
||||
|
||||
await smooth_scroll_step(distance)
|
||||
await asyncio.sleep(random.uniform(0.6, 1.5)) # 停顿
|
||||
|
||||
# ==================== 模式 3:滚动指定距离 ====================
|
||||
elif mode == 'distance':
|
||||
# print(f"[HumanScroll] 正在精准模拟滚动指定距离: {value} 像素")
|
||||
await smooth_scroll_step(value)
|
||||
|
||||
else:
|
||||
raise ValueError("未知的滚动模式!请选择 'top', 'bottom', 'random' 或 'distance'")
|
||||
|
||||
|
||||
def format_japanese_price(price_str: str | None) -> int:
|
||||
"""
|
||||
将日本电商网站的价格字符串格式化为标准整数
|
||||
支持的格式示例: "600円 (税込)", "1,250円", "¥ 3,000", "300"
|
||||
"""
|
||||
if not price_str:
|
||||
return 0
|
||||
|
||||
digits = re.findall(r'\d', price_str)
|
||||
if not digits:
|
||||
return 0
|
||||
|
||||
return int("".join(digits))
|
||||
@@ -0,0 +1,136 @@
|
||||
import asyncio
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import pyautogui
|
||||
|
||||
|
||||
def _sanitize_path_component(value: str) -> str:
|
||||
sanitized = "".join(c for c in value if c.isalnum() or c in ("_", "-"))
|
||||
return sanitized or "unknown"
|
||||
|
||||
|
||||
class TaskVideoRecorder:
|
||||
"""
|
||||
使用 FFmpeg 进行 Windows 整桌面录制的轻量封装。
|
||||
生命周期由任务基类控制:start() 开始录制,stop() 停止并返回产物路径。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
archives_root: str,
|
||||
topic: str,
|
||||
task_id: str,
|
||||
ffmpeg_bin: str = "ffmpeg",
|
||||
fps: int = 12,
|
||||
) -> None:
|
||||
self.archives_root = Path(archives_root)
|
||||
self.topic = _sanitize_path_component(topic)
|
||||
self.task_id = _sanitize_path_component(task_id)
|
||||
self.ffmpeg_bin = ffmpeg_bin
|
||||
self.fps = fps
|
||||
self.process: asyncio.subprocess.Process | None = None
|
||||
self.output_path: Path | None = None
|
||||
|
||||
@property
|
||||
def is_recording(self) -> bool:
|
||||
return self.process is not None and self.process.returncode is None
|
||||
|
||||
async def start(self) -> Path:
|
||||
if self.is_recording and self.output_path is not None:
|
||||
return self.output_path
|
||||
|
||||
ffmpeg_path = shutil.which(self.ffmpeg_bin)
|
||||
if not ffmpeg_path:
|
||||
raise RuntimeError("未找到 FFmpeg 可执行文件,请先确保 ffmpeg 已安装并可通过 PATH 调用。")
|
||||
|
||||
now = datetime.now()
|
||||
date_str = now.strftime("%Y%m%d")
|
||||
timestamp_ms = int(now.timestamp() * 1000)
|
||||
|
||||
output_dir = self.archives_root / date_str / self.topic
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self.output_path = output_dir / f"video_{self.task_id}_{timestamp_ms}.mp4"
|
||||
|
||||
# 动态获取主屏幕的分辨率,避免录制到第二显示器
|
||||
screen_width, screen_height = pyautogui.size()
|
||||
|
||||
command = [
|
||||
ffmpeg_path,
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"gdigrab",
|
||||
"-draw_mouse",
|
||||
"1",
|
||||
"-framerate",
|
||||
str(self.fps),
|
||||
"-video_size",
|
||||
f"{screen_width}x{screen_height}",
|
||||
"-offset_x",
|
||||
"0",
|
||||
"-offset_y",
|
||||
"0",
|
||||
"-i",
|
||||
"desktop",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"ultrafast",
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(self.output_path),
|
||||
]
|
||||
|
||||
self.process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
|
||||
# 短暂等待,尽早暴露 FFmpeg 启动失败(编码器/权限/设备占用等)。
|
||||
await asyncio.sleep(0.5)
|
||||
if self.process.returncode is not None:
|
||||
_, stderr_data = await self.process.communicate()
|
||||
stderr_text = stderr_data.decode("utf-8", errors="ignore").strip()
|
||||
self.process = None
|
||||
failed_path = self.output_path
|
||||
self.output_path = None
|
||||
raise RuntimeError(
|
||||
"FFmpeg 录屏启动失败"
|
||||
+ (f": {stderr_text}" if stderr_text else "")
|
||||
+ (f" | 目标文件: {failed_path}" if failed_path else "")
|
||||
)
|
||||
|
||||
return self.output_path
|
||||
|
||||
async def stop(self) -> Path | None:
|
||||
if not self.process:
|
||||
return self.output_path
|
||||
|
||||
process = self.process
|
||||
self.process = None
|
||||
|
||||
try:
|
||||
if process.returncode is None and process.stdin:
|
||||
process.stdin.write(b"q\n")
|
||||
await process.stdin.drain()
|
||||
|
||||
await asyncio.wait_for(process.wait(), timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5)
|
||||
except asyncio.TimeoutError:
|
||||
process.kill()
|
||||
await process.wait()
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
finally:
|
||||
if process.stdin and not process.stdin.is_closing():
|
||||
process.stdin.close()
|
||||
|
||||
return self.output_path if self.output_path and self.output_path.exists() else None
|
||||
Reference in New Issue
Block a user