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
+2 -2
View File
@@ -9,8 +9,8 @@ authors = [
{ name = "Antigravity Development Team" }
]
dependencies = [
# playwright 由 surugaya-common 的 [browser] extra 提供(>=1.54.0,<2.0.0),不再直接声明。
"surugaya-common[browser]>=0.2.0",
# playwright / playwright-stealth 由 surugaya-common 的 [browser] extra 提供,不再直接声明。
"surugaya-common[browser]>=0.3.0",
"pydantic>=2.7.0",
"pydantic-settings>=2.2.0",
"loguru>=0.7.2",
+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))
Generated
+17 -4
View File
@@ -409,7 +409,7 @@ requires-dist = [
{ name = "pyscreeze", specifier = ">=1.0.1" },
{ name = "pyyaml", specifier = ">=6.0.1" },
{ name = "redis", specifier = "==8.0.0" },
{ name = "surugaya-common", extras = ["browser"], specifier = ">=0.2.0", index = "https://git.jerryyan.net/api/packages/jp/pypi/simple" },
{ name = "surugaya-common", extras = ["browser"], specifier = ">=0.3.0", index = "https://git.jerryyan.net/api/packages/jp/pypi/simple" },
]
[[package]]
@@ -796,6 +796,18 @@ wheels = [
{ url = "https://mirrors.huaweicloud.com/repository/pypi/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe" },
]
[[package]]
name = "playwright-stealth"
version = "2.0.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "playwright" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/db/6ade5d539c7d151b9defc78fafa8b65aa52352617d0e7699b47008bd801f/playwright_stealth-2.0.3.tar.gz", hash = "sha256:1d8e488fbdd8f190f1269ea8cf5d57d14df3a9f1af1001c41ee3588b2aac3133", size = 25751, upload-time = "2026-04-04T02:50:33.88Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b5/10/607c409712c02a26c4cb794820514cb7fdaaeac15fb05bed917fb8a354b3/playwright_stealth-2.0.3-py3-none-any.whl", hash = "sha256:1887ade423ab7ff8ae16d363a30a38de0b5817e1e4a29d47b74bf3a0e3dbfcb4", size = 34385, upload-time = "2026-04-04T02:50:35.246Z" },
]
[[package]]
name = "pyautogui"
version = "0.9.54"
@@ -1100,20 +1112,21 @@ wheels = [
[[package]]
name = "surugaya-common"
version = "0.2.0"
version = "0.3.0"
source = { registry = "https://git.jerryyan.net/api/packages/jp/pypi/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "redis" },
]
sdist = { url = "https://git.jerryyan.net/api/packages/jp/pypi/files/surugaya-common/0.2.0/surugaya_common-0.2.0.tar.gz", hash = "sha256:168a5810672f5ead82957a9491611b64bf439f489b4eeaa287b302206712e2ab" }
sdist = { url = "https://git.jerryyan.net/api/packages/jp/pypi/files/surugaya-common/0.3.0/surugaya_common-0.3.0.tar.gz", hash = "sha256:578757409e8e062fd9bac32244775f4db76ba1dd8d22cb5fe4eaeab8f068c85f" }
wheels = [
{ url = "https://git.jerryyan.net/api/packages/jp/pypi/files/surugaya-common/0.2.0/surugaya_common-0.2.0-py3-none-any.whl", hash = "sha256:140587df6b60abf1f1fc3b592d0668a096ddd42f5216ee16d4b83a95783b58cd" },
{ url = "https://git.jerryyan.net/api/packages/jp/pypi/files/surugaya-common/0.3.0/surugaya_common-0.3.0-py3-none-any.whl", hash = "sha256:281ae05a4996e4100d40abd6e5c6ffe0953cec5142745d287e1b21082c2bad1f" },
]
[package.optional-dependencies]
browser = [
{ name = "playwright" },
{ name = "playwright-stealth" },
]
[[package]]