workspace— step4

This commit is contained in:
2026-06-09 18:22:55 +08:00
parent 5ceabe9c9a
commit 66aebcf565
8 changed files with 535 additions and 10 deletions
+21 -7
View File
@@ -6,6 +6,7 @@ from typing import Any
from urllib import error, request from urllib import error, request
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from pydantic import ValidationError
from app.api.dependencies import get_container, require_bearer_token from app.api.dependencies import get_container, require_bearer_token
from app.core.container import ServiceContainer from app.core.container import ServiceContainer
@@ -29,6 +30,7 @@ from app.utils.parse_util import (
get_shipping_fee, get_shipping_fee,
) )
from surugaya_common import redis_keys from surugaya_common import redis_keys
from surugaya_common.models import SurugayaOrderDetail
router = APIRouter(prefix="/api", tags=["scrape"]) router = APIRouter(prefix="/api", tags=["scrape"])
@@ -279,18 +281,18 @@ async def order_add_trade_monitor(
@router.get( @router.get(
"/trade", "/trade",
response_model=ApiResponse[dict[str, Any]], response_model=ApiResponse[SurugayaOrderDetail],
dependencies=[Depends(require_bearer_token)], dependencies=[Depends(require_bearer_token)],
) )
async def trade( async def trade(
trade_code: str, trade_code: str,
container: ServiceContainer = Depends(get_container), container: ServiceContainer = Depends(get_container),
) -> ApiResponse[dict[str, Any]]: ) -> ApiResponse[SurugayaOrderDetail]:
""" """
获取订单详情 获取订单详情
""" """
if not trade_code or trade_code.strip() == "": if not trade_code or trade_code.strip() == "":
return ApiResponse[dict[str, Any]]( return ApiResponse[SurugayaOrderDetail](
success=False, success=False,
msg="trade_code 不能为空", msg="trade_code 不能为空",
data=None, data=None,
@@ -298,7 +300,7 @@ async def trade(
) )
if container.session_store._redis is None: if container.session_store._redis is None:
return ApiResponse[dict[str, Any]]( return ApiResponse[SurugayaOrderDetail](
success=False, success=False,
msg="Redis 未配置", msg="Redis 未配置",
data=None, data=None,
@@ -323,17 +325,29 @@ async def trade(
approximate=True, approximate=True,
) )
return ApiResponse[dict[str, Any]]( return ApiResponse[SurugayaOrderDetail](
success=False, success=False,
msg="未找到该交易", msg="未找到该交易",
data=None, data=None,
code=404, code=404,
) )
return ApiResponse[dict[str, Any]]( # Redis 中存储的是 worker 写入的 SurugayaOrderDetail 序列化结果,
# 用共享契约校验并类型化;历史脏数据校验失败时降级为结构化错误而非 500。
try:
order_detail = SurugayaOrderDetail.model_validate_json(trade_data)
except ValidationError as exc:
return ApiResponse[SurugayaOrderDetail](
success=False,
msg=f"订单详情数据结构异常: {exc.error_count()} 处校验失败",
data=None,
code=500,
)
return ApiResponse[SurugayaOrderDetail](
success=True, success=True,
msg="success", msg="success",
data=json.loads(trade_data), data=order_detail,
code=0, code=0,
) )
+8 -3
View File
@@ -12,9 +12,14 @@ dependencies = [
"redis==8.0.0", "redis==8.0.0",
] ]
# playwright 仅作为可选 extra:只有解析器/浏览器工具需要它,核心安装保持 light。
# 范围取 jp_surugaya(>=1.54,<2) 与 worker(>=1.43) 的交集,确保 workspace 解析到单一 playwright。
[project.optional-dependencies]
browser = ["playwright>=1.54.0,<2.0.0"]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/surugaya_common"] packages = ["src/surugaya_common"]
# Keep surugaya_common light: stdlib + pydantic + redis only. redis is pinned to # 保持 surugaya_common core 轻量:仅 stdlib + pydantic + redis(==8.0.0,与 API 和 worker 对齐)。
# ==8.0.0 to stay in lockstep with both the API service and the worker. Do not add # playwright 只允许出现在上面的 [browser] extra 中,core 安装绝不引入。
# fastapi/playwright/opencv/numpy/pyautogui/pillow here. # 不要在此加入 fastapi/opencv/numpy/pyautogui/pillow
@@ -0,0 +1,108 @@
"""浏览器拟真交互工具(需 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 语义不同,合并属行为变更,单独处理。
"""
from playwright.async_api import Page
import asyncio
import random
async def human_scroll(page: Page, mode: str, value: int = 0):
"""
模拟人类行为的网页滚动方法
:param page: Playwright 的 Page 对象
:param mode: 滚动模式 -> 'top'(滚动到顶部), 'bottom'(滚动到底部), 'random'(随机上下滚动), 'distance'(滚动指定距离)
:param value: 当 mode='distance' 时,代表要滚动的像素距离(正数向下,负数向上)
"""
async def smooth_scroll_step(pixel_distance: int):
"""核心拟真算法:将一个大距离拆分成带有加速度和随机抖动的微小步长"""
if pixel_distance == 0:
return
direction = 1 if pixel_distance > 0 else -1
remaining = abs(pixel_distance)
while remaining > 0:
# 模拟人类滚动时越接近目标速度越慢的特征(减速缓冲)
if remaining > 100:
step = random.randint(30, 70)
elif remaining > 30:
step = random.randint(10, 25)
else:
step = remaining
remaining -= step
scroll_y = step * direction
# 执行单步微调滚动
await page.evaluate(f"window.scrollBy(0, {scroll_y});")
# 随机微小停顿,模拟手指滑动的摩擦阻力(20ms - 50ms)
await asyncio.sleep(random.uniform(0.02, 0.05))
# 0.5% 的极低概率触发人类的“视线停留”
if random.random() < 0.005:
await asyncio.sleep(random.uniform(0.3, 0.8))
# ==================== 模式 0:滚动到顶部 ====================
if mode == 'top':
# print("[HumanScroll] 正在模拟向上滚动至页面顶部...")
last_scroll = -1
while True:
# 获取当前滚动条位置
current_scroll = await page.evaluate("window.scrollY")
# 防死循环:如果位置不再变化,说明已经到顶或页面无法滚动
if current_scroll <= 0 or current_scroll == last_scroll:
break
last_scroll = current_scroll
chunk = random.randint(300, 700)
await smooth_scroll_step(-chunk)
await asyncio.sleep(random.uniform(0.5, 1.2)) # 每滑一下停顿看一眼
# ==================== 模式 1:滚动到底部 ====================
elif mode == 'bottom':
# print("[HumanScroll] 正在模拟向下滚动至页面底部...")
last_scroll = -1
while True:
# 合并 evaluate 请求,减少与浏览器的 IPC 通信开销
scroll_info = await page.evaluate("() => [window.scrollY + window.innerHeight, document.body.scrollHeight]")
current_scroll, total_height = scroll_info[0], scroll_info[1]
# 防死循环:如果位置不再变化,说明已经到底或页面无法滚动
if current_scroll >= total_height - 10 or current_scroll == last_scroll:
break
last_scroll = current_scroll
chunk = random.randint(300, 700)
await smooth_scroll_step(chunk)
await asyncio.sleep(random.uniform(0.5, 1.2)) # 每滑一下停顿看一眼
# ==================== 模式 2:随机上下滚动几下 ====================
elif mode == 'random':
# print("[HumanScroll] 正在执行人类迷惑行为:随机上下滚动...")
# 随机决定滚动 3 到 6 下
scroll_times = random.randint(3, 6)
for _ in range(scroll_times):
# 70% 概率向下,30% 概率向上
if random.random() > 0.3:
distance = random.randint(200, 500) # 向下滚
else:
distance = random.randint(-400, -150) # 向上回滚
await smooth_scroll_step(distance)
await asyncio.sleep(random.uniform(0.6, 1.5)) # 停顿
# ==================== 模式 3:滚动指定距离 ====================
elif mode == 'distance':
# print(f"[HumanScroll] 正在精准模拟滚动指定距离: {value} 像素")
await smooth_scroll_step(value)
else:
raise ValueError("未知的滚动模式!请选择 'top', 'bottom', 'random''distance'")
@@ -0,0 +1 @@
"""surugaya_common 解析器子包(部分依赖 playwright,需安装 surugaya_common[browser])。"""
@@ -0,0 +1,11 @@
"""骏河屋页面解析器(需 playwright,安装 surugaya_common[browser])。"""
from surugaya_common.parsers.surugaya.order_detail import (
SurugayaOrderDetailParser,
parse_surugaya_order_detail,
)
__all__ = [
"SurugayaOrderDetailParser",
"parse_surugaya_order_detail",
]
@@ -0,0 +1,329 @@
"""骏河屋交易详情页解析器(需 playwright,安装 surugaya_common[browser])。
从 jp_pruchase_zdh_5/src/parsers/surugaya/order_detail.py 迁移而来,模型导入改为
共享包 surugaya_common.models。
相对 worker 原版的唯一行为差异:修正了 ITEM_FIELD_MAP 的字段映射 bug——原版将
品番/状態/数量/金額 误映射为 product_id/status/number/product_total_amount,与
SurugayaOrderItem 模型字段 product_code/condition/quantity/line_total 不符,被
pydantic 默认 extra=ignore 静默丢弃,导致这 4 列恒为 None。此处已对齐模型字段。
worker 本地副本仍为旧版,应在 repoint 到本包时一并淘汰。
"""
import re
from typing import Any
from playwright.async_api import Locator, Page
from surugaya_common.models import (
SurugayaOrderDetail,
SurugayaOrderItem,
SurugayaShippingInfo,
SurugayaOrderStatus,
SurugayaOrderSummary,
)
# 订单摘要表中的日文字段到英文字段映射。
SUMMARY_FIELD_MAP = {
"取引番号": "trade_number",
"注文日": "order_date",
"商品合計": "items_subtotal",
"商品点数": "item_count",
"支払方法": "payment_method",
"代引き手数料": "cash_on_delivery_fee",
"発送日": "shipping_date",
"お問い合わせ番号": "inquiry_number",
"送料・通信販売手数料": "shipping_and_handling_fee",
"電子商品券": "digital_gift_certificate_amount",
"総合計": "grand_total",
}
# 收件信息表的日文字段到英文字段映射。
SHIPPING_FIELD_MAP = {
"氏名": "recipient_name",
"住所": "recipient_address",
"電話番号": "recipient_phone_number",
"メールアドレス": "recipient_email",
}
# 商品明细表的列名到英文字段映射。
# 字段名已对齐 SurugayaOrderItem 模型(修正了 worker 原版的映射 bug:
# product_code/condition/quantity/line_total 此前因键名不符被 pydantic 静默丢弃)。
ITEM_FIELD_MAP = {
"品番": "product_code",
"状態": "condition",
"枝番": "branch_number",
"商品タイトル": "product_title",
"単価": "unit_price",
"数量": "quantity",
"値引額": "discount_amount",
"金額": "line_total",
"備考": "remarks",
}
# 需要按金额规则清洗的字段集合。
MONEY_FIELDS = {
"items_subtotal",
"cash_on_delivery_fee",
"shipping_and_handling_fee",
"digital_gift_certificate_amount",
"grand_total",
"unit_price",
"discount_amount",
"line_total",
}
# 需要按整数规则清洗的字段集合。
INT_FIELDS = {
"item_count",
"quantity",
}
class SurugayaOrderDetailParser:
"""骏河屋交易详情页解析器。"""
@classmethod
async def parse_page(cls, page: Page) -> SurugayaOrderDetail:
"""对外主入口:传入已加载详情页的 `Page`,返回结构化订单对象。"""
# 适配 `SurugayaOrderDetailParser.parse_page(page)` 这种类调用方式。
parser = cls()
if await page.locator("#pageCont").count() < 1:
raise ValueError("Surugaya order detail page not found")
return await parser._parse_page_content(page)
async def _parse_page_content(self, page: Page) -> SurugayaOrderDetail:
"""解析正常交易详情页中的 `#pageCont` 核心区域。"""
page_cont = page.locator("#pageCont").first
tables = page_cont.locator("table")
table_count = await tables.count()
if table_count < 4:
raise ValueError("Expected 4 trade tables under #pageCont")
status_table = tables.nth(0)
summary_table = tables.nth(1)
shipping_table = tables.nth(2)
items_table = tables.nth(3)
status = await self._parse_status_table(status_table)
summary = await self._parse_summary_table(summary_table)
shipping = await self._parse_shipping_table(shipping_table)
items = await self._parse_items_table(items_table)
return SurugayaOrderDetail(
status=status,
summary=summary,
shipping=shipping,
items=items,
raw_tables={
"status": status.raw,
"summary": summary.raw,
"shipping": shipping.raw,
"items": [item.raw for item in items],
},
)
async def _parse_status_table(self, table: Locator) -> SurugayaOrderStatus:
"""解析第 1 张交易状态表。"""
rows = table.locator("tr")
row_count = await rows.count()
if row_count < 1:
return SurugayaOrderStatus()
first_row = await self._extract_row_cells(rows.nth(0))
raw: dict[str, str] = {}
trade_status = None
status_message = None
if len(first_row) >= 2 and first_row[0]["tag"] == "th" and first_row[1]["tag"] == "td":
label = first_row[0]["text"]
value = first_row[1]["text"]
raw[label] = value
lines = self._split_lines(value)
trade_status = lines[0] if lines else None
status_message = " ".join(lines[1:]) or None
return SurugayaOrderStatus(
trade_status=trade_status,
status_message=status_message,
raw=raw,
)
async def _parse_summary_table(self, table: Locator) -> SurugayaOrderSummary:
"""解析第 2 张订单摘要表。"""
raw_pairs = await self._parse_key_value_rows(table)
normalized = self._normalize_mapped_values(raw_pairs, SUMMARY_FIELD_MAP)
return SurugayaOrderSummary(**normalized, raw=raw_pairs)
async def _parse_shipping_table(self, table: Locator) -> SurugayaShippingInfo:
"""解析第 3 张收件信息表。"""
raw_pairs = await self._parse_key_value_rows(table)
normalized = self._normalize_mapped_values(raw_pairs, SHIPPING_FIELD_MAP)
return SurugayaShippingInfo(**normalized, raw=raw_pairs)
async def _parse_items_table(self, table: Locator) -> list[SurugayaOrderItem]:
"""解析第 4 张商品明细表。"""
rows = table.locator("tr")
row_count = await rows.count()
if row_count < 2:
return []
header_cells = await self._extract_row_cells(rows.nth(0))
headers = [cell["text"] for cell in header_cells if cell["tag"] == "th" and cell["text"]]
if not headers:
return []
items: list[SurugayaOrderItem] = []
expected_columns = len(headers)
for index in range(1, row_count):
cells = await self._extract_row_cells(rows.nth(index))
if not cells:
continue
td_cells = [cell for cell in cells if cell["tag"] == "td"]
if len(td_cells) != expected_columns:
continue
raw_item = {
header: td_cells[position]["text"]
for position, header in enumerate(headers)
}
if not any(raw_item.values()):
continue
normalized = self._normalize_mapped_values(raw_item, ITEM_FIELD_MAP)
items.append(SurugayaOrderItem(**normalized, raw=raw_item))
return items
async def _parse_key_value_rows(self, table: Locator) -> dict[str, str]:
"""通用解析:按 `th -> td` 配对方式抽取键值。"""
rows = table.locator("tr")
row_count = await rows.count()
result: dict[str, str] = {}
for index in range(row_count):
cells = await self._extract_row_cells(rows.nth(index))
if not cells:
continue
pointer = 0
while pointer < len(cells):
current = cells[pointer]
if current["tag"] != "th":
pointer += 1
continue
label = current["text"]
pointer += 1
if not label:
continue
while pointer < len(cells) and cells[pointer]["tag"] != "td":
pointer += 1
if pointer >= len(cells):
break
result[label] = cells[pointer]["text"]
pointer += 1
return result
async def _extract_row_cells(self, row: Locator) -> list[dict[str, str]]:
"""提取一行中的所有单元格,并保留标签名与纯文本。"""
return await row.locator(":scope > th, :scope > td").evaluate_all(
"""cells => cells.map((cell) => ({
tag: cell.tagName.toLowerCase(),
text: (cell.innerText || "").replace(/\\u00a0/g, " ").trim(),
}))"""
)
def _normalize_mapped_values(
self,
raw_data: dict[str, str],
field_map: dict[str, str],
) -> dict[str, Any]:
"""将日文键值映射并清洗为结构化字段。"""
normalized: dict[str, Any] = {}
for label, value in raw_data.items():
english_key = field_map.get(label)
if not english_key:
continue
cleaned_value = self._clean_text(value)
if english_key in MONEY_FIELDS:
normalized[english_key] = self._normalize_money(cleaned_value)
elif english_key in INT_FIELDS:
normalized[english_key] = self._normalize_int(cleaned_value)
elif english_key.endswith("_date"):
normalized[english_key] = self._normalize_date(cleaned_value)
else:
normalized[english_key] = cleaned_value
return normalized
def _split_lines(self, text: str | None) -> list[str]:
"""按行拆分文本,并清理每一行空白。"""
if not text:
return []
lines = [self._clean_text(part) for part in text.splitlines()]
return [line for line in lines if line]
def _clean_text(self, text: str | None) -> str | None:
"""清洗文本中的多余空白并统一空值。"""
if text is None:
return None
cleaned = re.sub(r"\s+", " ", text.replace("\xa0", " ")).strip()
return cleaned or None
def _normalize_money(self, text: str | None) -> int | None:
"""将 `¥1,234` / `1,234円` 这类金额文本转成整数。"""
if not text:
return None
digits = re.findall(r"-?\d[\d,]*", text)
if not digits:
return None
value = digits[0].replace(",", "")
try:
return int(value)
except ValueError:
return None
def _normalize_int(self, text: str | None) -> int | None:
"""提取文本中的首个整数值。"""
if not text:
return None
digits = re.findall(r"\d+", text)
if not digits:
return None
return int(digits[0])
def _normalize_date(self, text: str | None) -> str | None:
"""将 `YYYY.MM.DD` / `YYYY/MM/DD` 统一为 `YYYY-MM-DD`。"""
if not text:
return None
match = re.search(r"(\d{4})[./-](\d{2})[./-](\d{2})", text)
if not match:
return text
year, month, day = match.groups()
return f"{year}-{month}-{day}"
async def parse_surugaya_order_detail(page: Page) -> dict[str, Any]:
"""便捷函数:直接返回可 JSON 序列化的订单字典。"""
parser = SurugayaOrderDetailParser()
result = await parser.parse_page(page)
return result.model_dump(mode="json")
+50
View File
@@ -0,0 +1,50 @@
"""SurugayaOrderDetailParser 商品明细字段映射的回归测试。
锁定 ITEM_FIELD_MAP 字段名 bug 的修复:品番/状態/数量/金額 必须正确填充
product_code/condition/quantity/line_total,而非被 pydantic 静默丢弃。
"""
from surugaya_common.models import SurugayaOrderItem
from surugaya_common.parsers.surugaya.order_detail import (
ITEM_FIELD_MAP,
SurugayaOrderDetailParser,
)
def test_item_field_map_targets_are_real_model_fields():
"""ITEM_FIELD_MAP 的每个目标字段都必须是 SurugayaOrderItem 的真实字段。"""
model_fields = set(SurugayaOrderItem.model_fields)
for jp_column, en_field in ITEM_FIELD_MAP.items():
assert en_field in model_fields, f"{jp_column} 映射到非法字段 {en_field}"
def test_normalize_item_row_populates_all_corrected_fields():
"""修复后:品番/状態/数量/金額 应正确填充,金额/整数字段完成归一化。"""
parser = SurugayaOrderDetailParser()
raw_item = {
"品番": "ABC-123",
"状態": "中古",
"枝番": "1",
"商品タイトル": "测试商品",
"単価": "1,250円",
"数量": "2",
"値引額": "100円",
"金額": "2,400円",
"備考": "x",
}
normalized = parser._normalize_mapped_values(raw_item, ITEM_FIELD_MAP)
item = SurugayaOrderItem(**normalized, raw=raw_item)
# 此前被静默丢弃的 4 个字段现在应被正确填充
assert item.product_code == "ABC-123"
assert item.condition == "中古"
assert item.quantity == 2 # INT_FIELDS 归一
assert item.line_total == 2400 # MONEY_FIELDS 归一,逗号去除
# 原本就正确的字段保持不变
assert item.branch_number == "1"
assert item.product_title == "测试商品"
assert item.unit_price == 1250
assert item.discount_amount == 100
assert item.remarks == "x"
Generated
+7
View File
@@ -524,11 +524,18 @@ dependencies = [
{ name = "redis" }, { name = "redis" },
] ]
[package.optional-dependencies]
browser = [
{ name = "playwright" },
]
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.54.0,<2.0.0" },
{ name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" },
{ name = "redis", specifier = "==8.0.0" }, { name = "redis", specifier = "==8.0.0" },
] ]
provides-extras = ["browser"]
[[package]] [[package]]
name = "surugaya-scraper-service" name = "surugaya-scraper-service"