Merge remote-tracking branch 'origin/master'

This commit is contained in:
2026-06-11 23:05:34 +08:00
6 changed files with 39 additions and 116 deletions
+8 -18
View File
@@ -4,6 +4,7 @@ from pathlib import Path
from typing import Dict
from playwright.async_api import async_playwright, Playwright, BrowserContext
from loguru import logger
from surugaya_common.browser_profile import DEFAULT_USER_AGENT, LOCALE, TIMEZONE_ID, stealth_launch_args
from src.config.settings import get_settings
from src.config.accounts import AccountConfig, load_accounts_config
@@ -239,17 +240,9 @@ class BrowserManager:
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",
# 2. 配置隐身 Chromium 启动参数(共享反检测参数 + 本进程追加项)
chrome_args = stealth_launch_args() + [
"--start-maximized", # 启动时全屏(最大化)
]
executable_path_raw = self.settings.browser_executable_path
@@ -265,11 +258,8 @@ class BrowserManager:
)
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"
)
# 使用与 API 服务一致的 Windows Chrome User-Agent(共享指纹配置)
user_agent = DEFAULT_USER_AGENT
logger.info(f"正在为账号 {account.account_id} 启动 Playwright | 无头模式: {headless_mode}")
@@ -289,8 +279,8 @@ class BrowserManager:
"args": chrome_args,
"user_agent": user_agent,
"no_viewport": True, # 配合 --start-maximized 使得网页视口也铺满全屏
"locale": "ja-JP", # 区域语言一致
"timezone_id": "Asia/Tokyo", # 日本本地时区
"locale": LOCALE, # 区域语言一致(ja-JP)
"timezone_id": TIMEZONE_ID, # 日本本地时区(Asia/Tokyo)
"accept_downloads": True, # 允许自动下载(如需要)
"proxy": proxy_config # 住宅代理等
}
+10 -75
View File
@@ -17,12 +17,13 @@ from playwright.async_api import TimeoutError as PlaywrightTimeoutError
from notifications import NotificationEvent
from src.tasks.base import BasePurchaseTask
from src.config.accounts import get_account_config
from src.utils.page_utils import format_japanese_price
from src.task_queue.redis_manager import get_redis_client
from surugaya_common.models import SurugayaOrderDetail
from surugaya_common.parsers.surugaya.order_detail import SurugayaOrderDetailParser
from surugaya_common.browser_utils import human_scroll
from surugaya_common.browser_utils import clear_cart, human_scroll
from surugaya_common.parse_utils import format_japanese_price
from surugaya_common.app_utils import is_valid_str
from surugaya_common.urls import CARGO_DETAIL_URL, MYPAGE_URL, product_detail_url, trade_detail_url
class TemplateMatchResult(TypedDict):
@@ -57,7 +58,7 @@ class SurugayaPurchaseTask(BasePurchaseTask):
logger.info(f"[任务 {self.task_id}] 购买任务开始,商品:{item_list}")
# 打开购物车
await page.goto("https://www.suruga-ya.jp/cargo/detail", wait_until="domcontentloaded", timeout=30000)
await page.goto(CARGO_DETAIL_URL, wait_until="domcontentloaded", timeout=30000)
# 检查登录状态
logger.debug(f"[任务 {self.task_id}] 正在校验账号 '{self.account_id}' 的登录状态...")
@@ -70,7 +71,7 @@ class SurugayaPurchaseTask(BasePurchaseTask):
raise RuntimeError(f"账号 '{self.account_id}' 登录失败,无法继续执行采购流程。")
# 重新进入购物车
await page.goto("https://www.suruga-ya.jp/cargo/detail", wait_until="domcontentloaded", timeout=30000)
await page.goto(CARGO_DETAIL_URL, wait_until="domcontentloaded", timeout=30000)
# 判断购物车是否有商品
cart_goods_num = await page.locator("table.item_box tbody tr.item").count()
@@ -79,7 +80,7 @@ class SurugayaPurchaseTask(BasePurchaseTask):
# 清空购物车
await asyncio.sleep(random.uniform(1, 3)) # 加入随机延迟,模拟人工操作间隔
if cart_goods_num > 0:
await self.clear_cart(page)
await clear_cart(page)
# 将待采购的商品加入购物车
for index, item in enumerate(item_list):
@@ -96,9 +97,7 @@ class SurugayaPurchaseTask(BasePurchaseTask):
logger.warning(f"[任务 {self.task_id}] 索引 {index} 的商品数量为 0,跳过。")
continue
detail_url = f"https://www.suruga-ya.jp/product/detail/{product_id}"
if tenpo_cd:
detail_url += f"?tenpo_cd={tenpo_cd}"
detail_url = product_detail_url(product_id, tenpo_cd)
logger.debug(
f"[任务 {self.task_id}] 进入商品 {product_id} 详情页,购买数量: {number}, URL: {detail_url}")
@@ -195,7 +194,7 @@ class SurugayaPurchaseTask(BasePurchaseTask):
# 再次进入购物车,确认商品是否加入成功
await asyncio.sleep(random.uniform(1, 3))
await page.goto("https://www.suruga-ya.jp/cargo/detail", wait_until="domcontentloaded", timeout=30000)
await page.goto(CARGO_DETAIL_URL, wait_until="domcontentloaded", timeout=30000)
cart_goods_num = await page.locator("table.item_box tbody tr.item").count()
logger.debug(f"[任务 {self.task_id}] 再次进入购物车页面,购物车商品数量:{cart_goods_num}")
if cart_goods_num < 1:
@@ -304,7 +303,7 @@ class SurugayaPurchaseTask(BasePurchaseTask):
解析交易详情页内容,提取交易详情。
"""
await page.goto(
f"https://www.suruga-ya.jp/pcmypage/action_sell_search/detail?trade_code={trade_code}",
trade_detail_url(trade_code),
wait_until="domcontentloaded",
timeout=30000,
)
@@ -345,7 +344,7 @@ class SurugayaPurchaseTask(BasePurchaseTask):
password = account_config.password
logger.info(f"[任务 {self.task_id}] 正在初始化骏河屋登录流程...")
await page.goto("https://www.suruga-ya.jp/pcmypage", wait_until="domcontentloaded", timeout=30000)
await page.goto(MYPAGE_URL, wait_until="domcontentloaded", timeout=30000)
# 1. 确保表单和输入框在画面上完全可见
# 骏河屋的账号框 id 是 'edit-mail'
@@ -390,70 +389,6 @@ class SurugayaPurchaseTask(BasePurchaseTask):
await self.take_screenshot(scene="surugaya_login_failed")
raise e
async def clear_cart(self, page: Page) -> None:
"""
清空购物车
"""
logger.debug(f"[任务 {self.task_id}] 正在清空购物车...")
cart_goods_num = await page.locator("table.item_box tbody tr.item").count()
if cart_goods_num < 1:
return
is_delete = 0
# 购物车商品数量超过3个后会出现"全削除"按钮
if cart_goods_num > 3:
try:
delete_button = page.locator("#total_box .delbtn-all").get_by_role("button", name="全削除")
# 3. 显式等待按钮在页面上可见(容错处理:比如给它最多 3000 毫秒的加载时间)
# 如果按钮是一开始就在静态 HTML 里的,这一步也可以省略,直接用下面的 is_visible()
await delete_button.wait_for(state="visible", timeout=3000)
if await delete_button.is_visible():
logger.debug("检测到‘全削除’按钮存在,正在执行点击...")
await delete_button.click()
is_delete = 1
except Exception:
logger.debug("未检测到‘全削除’按钮。")
if is_delete == 1:
return
# 遍历删除
await page.wait_for_timeout(random.randint(1000, 3000))
item_selector = "table.item_box tbody tr.item"
remove_selector = "table.item_box a.remove_product"
while True:
cart_goods_num = await page.locator(item_selector).count()
if cart_goods_num == 0:
break
remove_link = page.locator(remove_selector).first
href = await remove_link.get_attribute("href")
# logger.debug(f"[任务 {self.task_id}] 删除购物车商品:{href or '(no href)'},当前购物车数量:{cart_goods_num}")
try:
await remove_link.click()
# 删除按钮不会触发弹窗,直接等待购物车条目数减少即可。
await page.wait_for_function(
"""({selector, expectedCount}) => {
return document.querySelectorAll(selector).length < expectedCount;
}""",
arg={"selector": item_selector, "expectedCount": cart_goods_num},
timeout=15_000,
)
await page.wait_for_timeout(random.randint(800, 1500))
except PlaywrightTimeoutError:
latest_count = await page.locator(item_selector).count()
if latest_count < cart_goods_num:
logger.debug(f"[任务 {self.task_id}] 购物车商品已删除,当前购物车数量:{latest_count}")
continue
logger.warning(f"[任务 {self.task_id}] 删除商品后数量未变化,等待后重试")
await page.wait_for_timeout(1000)
cart_goods_num = await page.locator(item_selector).count()
# logger.debug(f"[任务 {self.task_id}] 购物车已清空。")
async def get_show_payment_methods(self, page: Page) -> list:
"""
获取所有显示的支付方式
+2 -1
View File
@@ -5,6 +5,7 @@ from typing import Any
from loguru import logger
from surugaya_common.parsers.surugaya import SurugayaOrderDetailParser
from surugaya_common.urls import trade_detail_url
from src.tasks.base import BasePurchaseTask
from src.task_queue.redis_manager import get_redis_client
@@ -37,7 +38,7 @@ class SurugayaTradeMonitorTask(BasePurchaseTask):
page = self.page
await page.goto(f"https://www.suruga-ya.jp/pcmypage/action_sell_search/detail?trade_code={trade_code}",
await page.goto(trade_detail_url(trade_code),
wait_until="domcontentloaded", timeout=30000)
order_detail_model = await SurugayaOrderDetailParser.parse_page(page)
-16
View File
@@ -1,16 +0,0 @@
import re
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))