first commit

This commit is contained in:
zmq
2026-05-26 14:16:16 +08:00
committed by Jerry Yan
commit 535d7f4ac5
40 changed files with 4571 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# 根包初始化
+1
View File
@@ -0,0 +1 @@
# 浏览器模块包初始化
+237
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# 配置模块包初始化
+74
View File
@@ -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
+109
View File
@@ -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
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
from src.notifications.models import NotificationEvent
from src.notifications.service import NotificationService
__all__ = ["NotificationEvent", "NotificationService"]
+14
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
from src.notifications.channels.email import EmailNotificationChannel
__all__ = ["EmailNotificationChannel"]
+54
View File
@@ -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)
+11
View File
@@ -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)
+131
View File
@@ -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)
+1
View File
@@ -0,0 +1 @@
"""页面解析器包。"""
+18
View File
@@ -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",
]
+69
View File
@@ -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)
+315
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
# 消息队列模块包初始化
+137
View File
@@ -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
+119
View File
@@ -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}' 的锁。")
+205
View File
@@ -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
+59
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
# 任务模块包初始化
+325
View File
@@ -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
+106
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
# 工具模块包初始化
+4
View File
@@ -0,0 +1,4 @@
def is_valid_str(s):
"""判断字符串是否有效,即非空且非空格。"""
return isinstance(s, str) and len(s.strip()) > 0
+38
View File
@@ -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
+71
View File
@@ -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()}")
+116
View File
@@ -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))
+136
View File
@@ -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