384 lines
17 KiB
Python
384 lines
17 KiB
Python
import asyncio
|
|
import logging
|
|
import random
|
|
import re
|
|
|
|
from playwright.async_api import async_playwright, Page
|
|
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
|
from selectolax.parser import HTMLParser
|
|
|
|
from app.core.config import Settings
|
|
from app.models.scrape import CargoRequest
|
|
from app.utils.parse_util import extract_product_id, format_price
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CargoService:
|
|
"""购物车服务"""
|
|
|
|
def __init__(self, settings: Settings):
|
|
self._settings = settings
|
|
|
|
async def get_cargo_info(self, request: CargoRequest) -> dict:
|
|
"""获取购物车信息
|
|
|
|
每次访问都会启动一个全新的 Playwright 浏览器实例,
|
|
创建全新的上下文,避免任何状态残留。
|
|
先将指定的商品加入购物车,然后再进入购物车页面解析信息。
|
|
"""
|
|
async with async_playwright() as p:
|
|
headless = self._settings.browser_headless_effective
|
|
launch_args = [
|
|
"--disable-blink-features=AutomationControlled",
|
|
"--disable-infobars",
|
|
"--no-sandbox",
|
|
"--disable-setuid-sandbox",
|
|
"--disable-dev-shm-usage",
|
|
"--disable-gpu",
|
|
"--use-mock-keychain",
|
|
"--password-store=basic",
|
|
]
|
|
if not headless:
|
|
launch_args.extend(["--start-maximized", "--window-position=0,0"])
|
|
|
|
# 启动全新的浏览器进程
|
|
browser = await p.chromium.launch(
|
|
headless=headless,
|
|
channel=self._settings.browser_channel,
|
|
proxy=self._settings.proxy,
|
|
args=launch_args,
|
|
)
|
|
|
|
try:
|
|
# 创建全新上下文
|
|
context = await browser.new_context(
|
|
locale=self._settings.browser_locale,
|
|
timezone_id=self._settings.browser_timezone_id,
|
|
user_agent=self._settings.browser_user_agent,
|
|
viewport=None if not headless else {"width": 1440, "height": 900},
|
|
)
|
|
|
|
if self._settings.browser_stealth_enabled:
|
|
try:
|
|
from playwright_stealth import Stealth
|
|
await Stealth().apply_stealth_async(context)
|
|
except Exception as e:
|
|
logger.warning("应用 playwright-stealth 失败: %s", e)
|
|
|
|
page = await context.new_page()
|
|
await page.set_extra_http_headers(
|
|
{
|
|
"Accept-Language": "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7",
|
|
"Cache-Control": "no-cache",
|
|
"Pragma": "no-cache",
|
|
}
|
|
)
|
|
|
|
target_url = "https://www.suruga-ya.jp/cargo/detail"
|
|
# 先进入购物车页面
|
|
await page.goto(target_url, wait_until="domcontentloaded", timeout=15000)
|
|
|
|
# 判断购物车是否有商品
|
|
cart_goods_num = await page.locator("table.item_box tbody tr.item").count()
|
|
|
|
# 清空购物车
|
|
await asyncio.sleep(random.uniform(1, 3)) # 加入随机延迟,模拟人工操作间隔
|
|
if cart_goods_num > 0:
|
|
await self.clear_cart(page)
|
|
|
|
# 将待采购的商品加入购物车
|
|
item_list = request.payload.item_list
|
|
for index, item in enumerate(item_list):
|
|
product_id = item.id
|
|
if not product_id:
|
|
logger.warning("索引 %s 的商品未提供 id,跳过。", index)
|
|
continue
|
|
|
|
number = item.number or 1
|
|
if number <= 0:
|
|
logger.warning("索引 %s 的商品数量为 0,跳过。", index)
|
|
continue
|
|
|
|
logger.debug("进入商品 %s 详情页,准备购买数量: %s...", product_id, number)
|
|
detail_url = f"https://www.suruga-ya.jp/product/detail/{product_id}"
|
|
await page.goto(detail_url, wait_until="domcontentloaded", timeout=20000)
|
|
|
|
# 判断是否缺货
|
|
out_of_stock_dom = page.locator("div.out-of-stock-text")
|
|
is_out_of_stock = await out_of_stock_dom.is_visible()
|
|
if is_out_of_stock:
|
|
logger.warning("商品 %s 缺货,跳过", product_id)
|
|
continue
|
|
|
|
# 提取商品最大购买数量
|
|
try:
|
|
quantity_select = page.locator("#quantity_selection")
|
|
await quantity_select.wait_for(state="visible", timeout=5000)
|
|
last_option_locator = quantity_select.locator("option").last
|
|
buy_number_limit_str = await last_option_locator.get_attribute("value")
|
|
buy_number_limit = int(buy_number_limit_str) if buy_number_limit_str else 0
|
|
logger.debug("商品 %s 最大购买数量: %s", product_id, buy_number_limit)
|
|
if 0 < buy_number_limit < number:
|
|
number = buy_number_limit
|
|
except PlaywrightTimeoutError:
|
|
logger.debug("商品 %s 没有数量选择框,默认购买数量: %s", product_id, number)
|
|
|
|
try:
|
|
btn_buy = page.locator("button.btn_buy").first
|
|
await btn_buy.wait_for(state="visible", timeout=5000)
|
|
|
|
if await btn_buy.is_disabled():
|
|
logger.warning("商品 %s 已售罄或无法购买(按钮被禁用),跳过", product_id)
|
|
continue
|
|
|
|
# 根据 number 循环点击加入购物车
|
|
for i in range(number):
|
|
logger.debug("点击加入购物车按钮,第 %s 次", i + 1)
|
|
await btn_buy.click()
|
|
if i < number - 1:
|
|
# 多次点击之间加入随机延迟,防止被识别为机器人或触发频率限制
|
|
await asyncio.sleep(random.uniform(0.8, 1.5))
|
|
except PlaywrightTimeoutError:
|
|
logger.warning("商品 %s 找不到购买按钮(可能是已下架/缺货),跳过", product_id)
|
|
continue
|
|
|
|
logger.info("再次访问购物车页面: %s", target_url)
|
|
await page.goto(target_url, wait_until="domcontentloaded", timeout=15000)
|
|
|
|
# 解析购物车
|
|
try:
|
|
primary = page.locator("#container #main3 #primary")
|
|
if not await primary.is_visible(timeout=10000):
|
|
logger.warning("购物车商品元素未找到,跳过解析")
|
|
raise Exception("购物车商品获取失败")
|
|
|
|
result = await self.parse_cargo_info(page)
|
|
except Exception as e:
|
|
logger.warning("购物车价格元素未找到,跳过解析: %s", e)
|
|
raise Exception("购物车价格获取失败")
|
|
|
|
return result
|
|
finally:
|
|
await browser.close()
|
|
|
|
async def parse_cargo_info(self, page: Page):
|
|
"""
|
|
解析商品总额,运费和手续费金额等信息
|
|
"""
|
|
logger.debug("解析购物车信息")
|
|
total_goods_amount = 0
|
|
total_shipping_fee = 0
|
|
total_commission_fee = 0
|
|
items: list[dict] = []
|
|
|
|
total_price_dom = page.locator("#total_box div.total_price")
|
|
try:
|
|
await total_price_dom.first.wait_for(state="attached", timeout=20000)
|
|
|
|
total_price_str = await total_price_dom.locator("p.price_name .total_price").first.text_content()
|
|
if total_price_str:
|
|
total_goods_amount = format_price(total_price_str.strip()) or 0
|
|
logger.debug("商品总额: %s", total_goods_amount)
|
|
|
|
delivery_charge_html = await total_price_dom.locator("p.delivery_charge").first.inner_html()
|
|
if delivery_charge_html:
|
|
shipping_match = re.search(r"([\d,]+)(?=円の送料)", delivery_charge_html)
|
|
if shipping_match:
|
|
total_shipping_fee = int(shipping_match.group(1).replace(",", ""))
|
|
logger.debug("运费: %s", total_shipping_fee)
|
|
|
|
handling_match = re.search(r"通信販売手数料.*?:([\d,]+)(?=円)", delivery_charge_html)
|
|
if handling_match:
|
|
total_commission_fee = int(handling_match.group(1).replace(",", ""))
|
|
logger.debug("手续费: %s", total_commission_fee)
|
|
except PlaywrightTimeoutError:
|
|
pass
|
|
|
|
table = page.locator("#container #main3 #primary table.item_box")
|
|
await table.wait_for(state="visible", timeout=10000)
|
|
|
|
rows = table.locator("tr.item_row")
|
|
for i in range(await rows.count()):
|
|
row = rows.nth(i)
|
|
logger.debug("解析商品 %s", i)
|
|
|
|
link_node = row.locator("td.photo_box a").first
|
|
href = await link_node.get_attribute("href") or ""
|
|
|
|
img_node = row.locator("td.photo_box img").first
|
|
goods_image = await img_node.get_attribute("src") or ""
|
|
|
|
name_node = row.locator("td.item_detail .item_name a").first
|
|
goods_name = (await name_node.text_content() or "").strip()
|
|
|
|
price_node = row.locator("td.price").first
|
|
price_text = (await price_node.text_content() or "").strip()
|
|
goods_price = format_price(price_text) or 0
|
|
|
|
quantity = 1
|
|
qty_input = row.locator("input[name*='qty'], input[name*='quantity']").first
|
|
if await qty_input.count():
|
|
qty_val = (await qty_input.get_attribute("value") or "").strip()
|
|
if qty_val.isdigit():
|
|
quantity = int(qty_val)
|
|
else:
|
|
qty_select = row.locator("select[name*='qty'], select[name*='quantity'], select.quantity").first
|
|
if await qty_select.count():
|
|
selected = qty_select.locator("option[selected]").first
|
|
if await selected.count():
|
|
qty_val = (await selected.get_attribute("value") or "").strip()
|
|
if qty_val.isdigit():
|
|
quantity = int(qty_val)
|
|
|
|
items.append(
|
|
{
|
|
"goods_id": extract_product_id(href),
|
|
"goods_name": goods_name,
|
|
"goods_price": goods_price,
|
|
"goods_image": goods_image,
|
|
"quantity": quantity,
|
|
}
|
|
)
|
|
|
|
if total_goods_amount <= 0:
|
|
total_goods_amount = sum(item["goods_price"] * item["quantity"] for item in items)
|
|
|
|
return {
|
|
"total_goods_amount": total_goods_amount,
|
|
"total_shipping_fee": total_shipping_fee,
|
|
"total_commission_fee": total_commission_fee,
|
|
"items": items,
|
|
}
|
|
|
|
def _parse_cargo_html(self, html: str) -> dict:
|
|
tree = HTMLParser(html)
|
|
|
|
items = []
|
|
# 根据常见的骏河屋购物车结构进行解析
|
|
item_nodes = tree.css("table.cart_table tr.item_row, .cart-item")
|
|
if not item_nodes:
|
|
item_nodes = tree.css("div.item_box") # Fallback
|
|
|
|
for node in item_nodes:
|
|
# 解析商品ID
|
|
id_node = node.css_first("input[name='goods_id'], .goods_id")
|
|
goods_id = id_node.attributes.get("value", "") if id_node else ""
|
|
if not goods_id:
|
|
# 尝试从链接提取
|
|
link = node.css_first("a[href*='/product/detail/']")
|
|
if link:
|
|
href = link.attributes.get("href", "")
|
|
import re
|
|
match = re.search(r'/product/detail/([A-Za-z0-9]+)', href)
|
|
if match:
|
|
goods_id = match.group(1)
|
|
|
|
if not goods_id:
|
|
continue
|
|
|
|
# 解析商品名称
|
|
name_node = node.css_first(".item_name, .title, p.name")
|
|
goods_name = name_node.text().strip() if name_node else f"product-{goods_id}"
|
|
|
|
# 解析商品价格
|
|
price_node = node.css_first(".price, .item_price, td.price")
|
|
price_text = price_node.text().strip() if price_node else "0"
|
|
goods_price = format_price(price_text) or 0
|
|
|
|
# 解析商品图片
|
|
img_node = node.css_first("img")
|
|
goods_image = img_node.attributes.get("src", "") if img_node else ""
|
|
|
|
items.append({
|
|
"goods_id": goods_id,
|
|
"goods_name": goods_name,
|
|
"goods_price": goods_price,
|
|
"goods_image": goods_image,
|
|
"number": 1
|
|
})
|
|
|
|
# 提取金额信息
|
|
total_goods_amount = 0
|
|
total_shipping_fee = 0
|
|
total_commission_fee = 0
|
|
|
|
# 寻找包含金额的总计区域
|
|
summary_nodes = tree.css(".summary_box, .total_box, table.total_table tr")
|
|
for node in summary_nodes:
|
|
text = node.text().replace(" ", "").replace("\n", "")
|
|
if "商品合計" in text or "小計" in text:
|
|
price_node = node.css_first(".price, td:last-child")
|
|
if price_node:
|
|
total_goods_amount = format_price(price_node.text().strip()) or 0
|
|
elif "送料" in text:
|
|
price_node = node.css_first(".price, td:last-child")
|
|
if price_node:
|
|
total_shipping_fee = format_price(price_node.text().strip()) or 0
|
|
elif "通信販売手数料" in text or "代引手数料" in text or "手数料" in text:
|
|
price_node = node.css_first(".price, td:last-child")
|
|
if price_node:
|
|
total_commission_fee = format_price(price_node.text().strip()) or 0
|
|
|
|
return {
|
|
"items": items,
|
|
"total_goods_amount": total_goods_amount,
|
|
"total_shipping_fee": total_shipping_fee,
|
|
"total_commission_fee": total_commission_fee
|
|
}
|
|
|
|
async def clear_cart(self, page):
|
|
"""
|
|
清空购物车
|
|
"""
|
|
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
|
|
|
|
# 遍历删除
|
|
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
|
|
|
|
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:
|
|
continue
|
|
|
|
await page.wait_for_timeout(1000)
|