diff --git a/pyproject.toml b/pyproject.toml index 360713e..a8bdbe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/browser/manager.py b/src/browser/manager.py index 0d2942c..000ae09 100644 --- a/src/browser/manager.py +++ b/src/browser/manager.py @@ -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 # 住宅代理等 } diff --git a/src/tasks/surugaya.py b/src/tasks/surugaya.py index cb1f489..7248bcb 100644 --- a/src/tasks/surugaya.py +++ b/src/tasks/surugaya.py @@ -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: """ 获取所有显示的支付方式 diff --git a/src/tasks/surugaya_trade_monitor.py b/src/tasks/surugaya_trade_monitor.py index 37fcf94..3936fb7 100644 --- a/src/tasks/surugaya_trade_monitor.py +++ b/src/tasks/surugaya_trade_monitor.py @@ -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) diff --git a/src/utils/page_utils.py b/src/utils/page_utils.py deleted file mode 100644 index 9dfa6c5..0000000 --- a/src/utils/page_utils.py +++ /dev/null @@ -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)) diff --git a/uv.lock b/uv.lock index 681192e..6172a23 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]