common扩展
This commit is contained in:
@@ -4,8 +4,8 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "surugaya-common"
|
||||
version = "0.2.0"
|
||||
description = "Shared contracts (Redis keys, future models) for jp_surugaya services"
|
||||
version = "0.3.0"
|
||||
description = "Shared contracts and browser utilities (Redis keys, models, parsers, fingerprint profile) for jp_surugaya services"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"pydantic>=2.0.0,<3.0.0",
|
||||
@@ -15,7 +15,10 @@ dependencies = [
|
||||
# playwright 仅作为可选 extra:只有解析器/浏览器工具需要它,核心安装保持 light。
|
||||
# 范围取 jp_surugaya(>=1.54,<2) 与 worker(>=1.43) 的交集,确保 workspace 解析到单一 playwright。
|
||||
[project.optional-dependencies]
|
||||
browser = ["playwright>=1.54.0,<2.0.0"]
|
||||
browser = [
|
||||
"playwright>=1.54.0,<2.0.0",
|
||||
"playwright-stealth>=1.0.0,<3.0.0",
|
||||
]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/surugaya_common"]
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
"""Shared contracts for jp_surugaya services (Redis keys, future models)."""
|
||||
"""Shared contracts and utilities for jp_surugaya services.
|
||||
|
||||
from surugaya_common import redis_keys
|
||||
核心安装(无 extra)可用:redis_keys、urls、browser_profile、parse_utils、
|
||||
app_utils、http_utils、models。
|
||||
需安装 surugaya_common[browser]:browser_utils、stealth、parsers。
|
||||
"""
|
||||
|
||||
__all__ = ["redis_keys"]
|
||||
from surugaya_common import browser_profile, parse_utils, redis_keys, urls
|
||||
|
||||
__all__ = ["browser_profile", "parse_utils", "redis_keys", "urls"]
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""浏览器指纹与启动参数共享配置(纯常量,core 安装即可用,不依赖 playwright)。
|
||||
|
||||
API(jp_surugaya)与 worker(jp_pruchase_zdh_5)访问同一目标站点,
|
||||
指纹参数必须保持一致:UA、语言、时区任何一处自相矛盾都可能触发风控
|
||||
(参见 cf_clearance 复用因指纹不一致被判 403 的教训)。
|
||||
"""
|
||||
|
||||
# 与日语环境一致的指纹参数
|
||||
LOCALE = "ja-JP"
|
||||
TIMEZONE_ID = "Asia/Tokyo"
|
||||
NAVIGATOR_LANGUAGES = ("ja-JP", "ja", "en-US", "en")
|
||||
ACCEPT_LANGUAGE = "ja-JP,ja;q=0.9,en-US;q=0.8,en;q=0.7"
|
||||
|
||||
# 两项目统一使用的 Windows Chrome User-Agent
|
||||
DEFAULT_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"
|
||||
)
|
||||
|
||||
# 页面级反检测请求头(page.set_extra_http_headers 用)
|
||||
DEFAULT_PAGE_HEADERS = {
|
||||
"Accept-Language": ACCEPT_LANGUAGE,
|
||||
"Cache-Control": "no-cache",
|
||||
"Pragma": "no-cache",
|
||||
}
|
||||
|
||||
# 两项目共用的 Chromium 反检测启动参数
|
||||
STEALTH_LAUNCH_ARGS = (
|
||||
"--disable-blink-features=AutomationControlled", # 浏览器层面移除 navigator.webdriver
|
||||
"--disable-infobars", # 隐藏「Chrome 正受到自动测试软件控制」提示条
|
||||
"--no-sandbox", # Docker/服务器环境兼容
|
||||
"--disable-setuid-sandbox",
|
||||
"--disable-dev-shm-usage", # 内存受限环境避免资源崩溃
|
||||
"--disable-gpu", # 服务器无头模式稳定渲染
|
||||
"--use-mock-keychain", # 避免本地密钥环弹窗(macOS/Linux)
|
||||
"--password-store=basic",
|
||||
)
|
||||
|
||||
|
||||
def stealth_launch_args() -> list[str]:
|
||||
"""返回共享启动参数的可变副本,调用方可在其上追加进程自有参数。"""
|
||||
return list(STEALTH_LAUNCH_ARGS)
|
||||
@@ -1,14 +1,81 @@
|
||||
"""浏览器拟真交互工具(需 playwright,安装 surugaya_common[browser])。
|
||||
|
||||
从 jp_pruchase_zdh_5/src/utils/page_utils.py 忠实迁移 human_scroll,逻辑保持一致。
|
||||
注:page_utils.py 中的纯函数 format_japanese_price 未迁移——它与 API 端
|
||||
app/utils/parse_util.py 的 format_price 语义不同,合并属行为变更,单独处理。
|
||||
- human_scroll:从 jp_pruchase_zdh_5/src/utils/page_utils.py 忠实迁移,逻辑保持一致。
|
||||
- clear_cart:worker 与 API cargo_service 各自的 clear_cart 近乎逐字重复,
|
||||
统一为本实现(以 worker 版为准,保留拟真随机等待)。
|
||||
- format_japanese_price 已迁至 surugaya_common.parse_utils(纯 stdlib,不依赖 playwright)。
|
||||
"""
|
||||
|
||||
from playwright.async_api import Page
|
||||
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
||||
import asyncio
|
||||
import logging
|
||||
import random
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 购物车页(/cargo/detail)的关键选择器
|
||||
CART_ITEM_SELECTOR = "table.item_box tbody tr.item"
|
||||
CART_REMOVE_LINK_SELECTOR = "table.item_box a.remove_product"
|
||||
|
||||
|
||||
async def count_cart_items(page: Page) -> int:
|
||||
"""统计购物车页当前的商品条目数(需已位于 /cargo/detail 页面)。"""
|
||||
return await page.locator(CART_ITEM_SELECTOR).count()
|
||||
|
||||
|
||||
async def clear_cart(page: Page) -> None:
|
||||
"""清空购物车(需已位于 /cargo/detail 页面)。
|
||||
|
||||
- 商品数量超过 3 个时优先点击「全削除」按钮一次性清空
|
||||
- 否则逐条点击删除链接,等待条目数减少后继续,含拟真随机等待
|
||||
"""
|
||||
logger.debug("正在清空购物车...")
|
||||
cart_goods_num = await count_cart_items(page)
|
||||
if cart_goods_num < 1:
|
||||
return
|
||||
|
||||
# 购物车商品数量超过3个后会出现"全削除"按钮
|
||||
if cart_goods_num > 3:
|
||||
try:
|
||||
delete_button = page.locator("#total_box .delbtn-all").get_by_role("button", name="全削除")
|
||||
# 显式等待按钮在页面上可见(容错处理:给它最多 3000 毫秒的加载时间)
|
||||
await delete_button.wait_for(state="visible", timeout=3000)
|
||||
if await delete_button.is_visible():
|
||||
logger.debug("检测到'全削除'按钮存在,正在执行点击...")
|
||||
await delete_button.click()
|
||||
return
|
||||
except Exception:
|
||||
logger.debug("未检测到'全削除'按钮。")
|
||||
|
||||
# 遍历删除
|
||||
await page.wait_for_timeout(random.randint(1000, 3000))
|
||||
while True:
|
||||
cart_goods_num = await page.locator(CART_ITEM_SELECTOR).count()
|
||||
if cart_goods_num == 0:
|
||||
break
|
||||
|
||||
remove_link = page.locator(CART_REMOVE_LINK_SELECTOR).first
|
||||
try:
|
||||
await remove_link.click()
|
||||
# 删除按钮不会触发弹窗,直接等待购物车条目数减少即可。
|
||||
await page.wait_for_function(
|
||||
"""({selector, expectedCount}) => {
|
||||
return document.querySelectorAll(selector).length < expectedCount;
|
||||
}""",
|
||||
arg={"selector": CART_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(CART_ITEM_SELECTOR).count()
|
||||
if latest_count < cart_goods_num:
|
||||
logger.debug("购物车商品已删除,当前购物车数量:%s", latest_count)
|
||||
continue
|
||||
|
||||
logger.warning("删除商品后数量未变化,等待后重试")
|
||||
await page.wait_for_timeout(1000)
|
||||
|
||||
|
||||
async def human_scroll(page: Page, mode: str, value: int = 0):
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
"""纯文本解析工具(仅 stdlib)。
|
||||
|
||||
从 jp_pruchase_zdh_5/src/utils/page_utils.py 忠实迁移 format_japanese_price。
|
||||
"""
|
||||
|
||||
import re
|
||||
|
||||
|
||||
def format_japanese_price(price_str: str | None) -> int:
|
||||
"""
|
||||
将日本电商网站的价格字符串格式化为标准整数
|
||||
支持的格式示例: "600円 (税込)", "1,250円", "¥ 3,000", "300"
|
||||
|
||||
注意:实现为提取字符串中所有数字字符后拼接,与 jp_surugaya API 端
|
||||
parse_util.format_price(货币符号定位 + 价格范围取低价)语义不同,勿混用。
|
||||
"""
|
||||
if not price_str:
|
||||
return 0
|
||||
|
||||
digits = re.findall(r'\d', price_str)
|
||||
if not digits:
|
||||
return 0
|
||||
|
||||
return int("".join(digits))
|
||||
@@ -0,0 +1,24 @@
|
||||
"""playwright-stealth 共享工厂(需安装 surugaya_common[browser])。
|
||||
|
||||
统一两项目的 stealth 指纹配置:navigator.languages 覆盖必须与日语
|
||||
locale / Accept-Language 对齐,否则带 cf_clearance 的复用请求会因
|
||||
指纹自相矛盾被 Cloudflare 判 403。
|
||||
"""
|
||||
|
||||
from playwright_stealth import Stealth
|
||||
|
||||
from surugaya_common.browser_profile import NAVIGATOR_LANGUAGES
|
||||
|
||||
|
||||
def create_stealth() -> Stealth:
|
||||
"""创建与日语环境对齐的 Stealth 实例。
|
||||
|
||||
用法约定:
|
||||
- browser.new_context 流程:用 stealth.use_async(async_playwright()) 钩子
|
||||
包装 playwright,new_context/new_page 自动应用 stealth(含 HTTP
|
||||
sec-ch-ua 客户端提示)。jp_surugaya BrowserPool 即此用法。
|
||||
- launch_persistent_context 流程:use_async 钩子不覆盖它(playwright-stealth
|
||||
已知缺口),必须显式 await stealth.apply_stealth_async(context),
|
||||
此路径只注入 init script,无 sec-ch-ua 等 HTTP 头伪装。
|
||||
"""
|
||||
return Stealth(navigator_languages_override=NAVIGATOR_LANGUAGES)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""suruga-ya.jp 站点 URL 契约:两项目共享的页面地址与构造器。"""
|
||||
|
||||
BASE_URL = "https://www.suruga-ya.jp"
|
||||
|
||||
# 购物车详情页
|
||||
CARGO_DETAIL_URL = f"{BASE_URL}/cargo/detail"
|
||||
|
||||
# 会员中心(登录入口)
|
||||
MYPAGE_URL = f"{BASE_URL}/pcmypage"
|
||||
|
||||
|
||||
def product_detail_url(product_id: str, tenpo_cd: str | None = None) -> str:
|
||||
"""商品详情页地址;tenpo_cd 用于加盟店商品。"""
|
||||
url = f"{BASE_URL}/product/detail/{product_id}"
|
||||
if tenpo_cd:
|
||||
url += f"?tenpo_cd={tenpo_cd}"
|
||||
return url
|
||||
|
||||
|
||||
def trade_detail_url(trade_code: str) -> str:
|
||||
"""交易(订单)详情页地址。"""
|
||||
return f"{BASE_URL}/pcmypage/action_sell_search/detail?trade_code={trade_code}"
|
||||
Reference in New Issue
Block a user