purewhiter--mobilegym
927 行
33 KiB
Python
927 行
33 KiB
Python
"""
|
|
eBay app state accessor.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import re
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Any, Literal
|
|
|
|
from bench_env.task.base import BaseApp
|
|
from bench_env.task.common_tasks import match_value, normalize_text
|
|
|
|
BuyingFormat = Literal["buyItNow", "auction", "offer"]
|
|
SortId = Literal["bestMatch", "priceLow", "priceHigh", "endingSoon", "newlyListed", "distance"]
|
|
|
|
EBAY_THEME_PARAM = {
|
|
"type": "enum",
|
|
"values": {
|
|
"浅色": "light",
|
|
"深色": "dark",
|
|
"节电模式": "battery",
|
|
},
|
|
"default": "dark",
|
|
"description": "eBay 主题",
|
|
}
|
|
|
|
EBAY_SEARCH_QUERY_PARAM = {
|
|
"type": "enum",
|
|
"values": [
|
|
"电风扇",
|
|
"耳机",
|
|
"运动鞋",
|
|
"吸尘器",
|
|
"电脑",
|
|
"电视",
|
|
"戒指",
|
|
"腕表",
|
|
"行李箱",
|
|
"发动机零件",
|
|
],
|
|
"default": "电风扇",
|
|
"description": "eBay 搜索关键词",
|
|
}
|
|
|
|
EBAY_SORT_PARAM = {
|
|
"type": "enum",
|
|
"values": {
|
|
"最低价 + 运费优先": "priceLow",
|
|
"最高价 + 运费优先": "priceHigh",
|
|
"新刊登优先": "newlyListed",
|
|
"距离:最近优先": "distance",
|
|
},
|
|
"default": "priceLow",
|
|
"description": "eBay 搜索排序方式",
|
|
}
|
|
|
|
EBAY_CATEGORY_VALUES = {
|
|
"电子产品": "electronics",
|
|
"服装、鞋子和配饰": "fashion",
|
|
"家庭和花园": "home-garden",
|
|
"珠宝和手表": "jewelry",
|
|
"eBay 汽车": "motors",
|
|
"机票及旅游": "travel",
|
|
}
|
|
|
|
EBAY_QUERY_CATEGORY_PAIRS = [
|
|
{"query": "电脑", "category": "electronics"},
|
|
{"query": "运动鞋", "category": "fashion"},
|
|
{"query": "吸尘器", "category": "home-garden"},
|
|
{"query": "戒指", "category": "jewelry"},
|
|
{"query": "发动机零件", "category": "motors"},
|
|
{"query": "行李箱", "category": "travel"},
|
|
]
|
|
|
|
EBAY_SEARCH_CHANGES = ["ebay.search", "ebay.recentSearches"]
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Product:
|
|
id: str
|
|
title: str
|
|
categoryId: str
|
|
categoryLabel: str
|
|
typeId: str
|
|
typeLabel: str
|
|
brand: str
|
|
condition: str
|
|
price: float
|
|
originalPrice: float | None
|
|
shipping: float
|
|
freeShipping: bool
|
|
buyingFormat: BuyingFormat
|
|
dateListed: int
|
|
endingSoon: int
|
|
distanceKm: int
|
|
location: str
|
|
sales: str | None
|
|
isSponsored: bool | None
|
|
image: str
|
|
|
|
@property
|
|
def total_cost(self) -> float:
|
|
return float(self.price) + float(self.shipping)
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[3]
|
|
PRODUCTS_PATH = ROOT / "apps" / "Ebay" / "data" / "products.json"
|
|
|
|
|
|
def load_products() -> list[Product]:
|
|
raw = json.loads(PRODUCTS_PATH.read_text(encoding="utf-8"))
|
|
products: list[Product] = []
|
|
for item in raw:
|
|
products.append(
|
|
Product(
|
|
id=str(item["id"]),
|
|
title=str(item["title"]),
|
|
categoryId=str(item["categoryId"]),
|
|
categoryLabel=str(item.get("categoryLabel") or ""),
|
|
typeId=str(item["typeId"]),
|
|
typeLabel=str(item.get("typeLabel") or ""),
|
|
brand=str(item["brand"]),
|
|
condition=str(item["condition"]),
|
|
price=float(item["price"]),
|
|
originalPrice=float(item["originalPrice"]) if item.get("originalPrice") is not None else None,
|
|
shipping=float(item["shipping"]),
|
|
freeShipping=bool(item["freeShipping"]),
|
|
buyingFormat=str(item["buyingFormat"]), # type: ignore[assignment]
|
|
dateListed=int(item["dateListed"]),
|
|
endingSoon=int(item["endingSoon"]),
|
|
distanceKm=int(item["distanceKm"]),
|
|
location=str(item["location"]),
|
|
sales=str(item["sales"]) if item.get("sales") else None,
|
|
isSponsored=bool(item["isSponsored"]) if item.get("isSponsored") is not None else None,
|
|
image=str(item["image"]),
|
|
)
|
|
)
|
|
return products
|
|
|
|
|
|
PRODUCTS: list[Product] = load_products()
|
|
|
|
# Mirrors apps/Ebay/pages/SearchPage.tsx COUNTRY_TO_CONTINENT
|
|
COUNTRY_TO_CONTINENT: dict[str, str] = {
|
|
"中国": "亚洲",
|
|
"日本": "亚洲",
|
|
"韩国": "亚洲",
|
|
"印度": "亚洲",
|
|
"美国": "北美洲",
|
|
"加拿大": "北美洲",
|
|
"墨西哥": "北美洲",
|
|
"英国": "欧洲",
|
|
"德国": "欧洲",
|
|
"法国": "欧洲",
|
|
"意大利": "欧洲",
|
|
"西班牙": "欧洲",
|
|
"澳大利亚": "大洋洲",
|
|
"新西兰": "大洋洲",
|
|
"巴西": "南美洲",
|
|
}
|
|
|
|
|
|
def _matches_location(product_location: str, selected_location: str) -> bool:
|
|
"""Match location the same way the frontend does (exact or continent)."""
|
|
if selected_location == product_location:
|
|
return True
|
|
return COUNTRY_TO_CONTINENT.get(product_location) == selected_location
|
|
|
|
|
|
def _normalize_search_text(text: str) -> str:
|
|
return re.sub(r"\s+", "", text.lower())
|
|
|
|
|
|
def filter_products(
|
|
*,
|
|
query: str | None = None,
|
|
category_id: str | None = None,
|
|
brand: str | None = None,
|
|
buying_format: BuyingFormat | None = None,
|
|
condition: str | None = None,
|
|
location: str | None = None,
|
|
free_shipping_only: bool = False,
|
|
min_total: float | None = None,
|
|
max_total: float | None = None,
|
|
) -> list[Product]:
|
|
query = _normalize_search_text((query or "").strip())
|
|
result: list[Product] = []
|
|
for product in PRODUCTS:
|
|
if category_id and product.categoryId != category_id:
|
|
continue
|
|
if brand and product.brand != brand:
|
|
continue
|
|
if buying_format and product.buyingFormat != buying_format:
|
|
continue
|
|
if condition and product.condition != condition:
|
|
continue
|
|
if location and not _matches_location(product.location, location):
|
|
continue
|
|
if free_shipping_only and not product.freeShipping:
|
|
continue
|
|
if min_total is not None and product.total_cost < min_total:
|
|
continue
|
|
if max_total is not None and product.total_cost > max_total:
|
|
continue
|
|
if query:
|
|
haystack = _normalize_search_text(
|
|
f"{product.title} {product.brand} {product.typeLabel} {product.categoryLabel}"
|
|
)
|
|
if query not in haystack:
|
|
continue
|
|
result.append(product)
|
|
return result
|
|
|
|
|
|
def sort_products(products: list[Product], sort_id: SortId) -> list[Product]:
|
|
if sort_id == "priceLow":
|
|
return sorted(products, key=lambda product: product.total_cost)
|
|
if sort_id == "priceHigh":
|
|
return sorted(products, key=lambda product: product.total_cost, reverse=True)
|
|
if sort_id == "newlyListed":
|
|
return sorted(products, key=lambda product: product.dateListed, reverse=True)
|
|
if sort_id == "endingSoon":
|
|
return sorted(products, key=lambda product: product.endingSoon)
|
|
if sort_id == "distance":
|
|
return sorted(products, key=lambda product: product.distanceKm)
|
|
return list(products)
|
|
|
|
|
|
def expect_top(
|
|
*,
|
|
query: str,
|
|
category_id: str | None = None,
|
|
sort_id: SortId,
|
|
brand: str | None = None,
|
|
buying_format: BuyingFormat | None = None,
|
|
condition: str | None = None,
|
|
location: str | None = None,
|
|
free_shipping_only: bool = False,
|
|
min_total: float | None = None,
|
|
max_total: float | None = None,
|
|
n: int = 1,
|
|
) -> list[Product]:
|
|
filtered = filter_products(
|
|
query=query,
|
|
category_id=category_id,
|
|
brand=brand,
|
|
buying_format=buying_format,
|
|
condition=condition,
|
|
location=location,
|
|
free_shipping_only=free_shipping_only,
|
|
min_total=min_total,
|
|
max_total=max_total,
|
|
)
|
|
sorted_list = sort_products(filtered, sort_id)
|
|
if len(sorted_list) < n:
|
|
raise ValueError(
|
|
f"Task design error: expected at least {n} results but got {len(sorted_list)} "
|
|
f"(query={query}, category={category_id or 'ANY'}, sort={sort_id})"
|
|
)
|
|
return sorted_list[:n]
|
|
|
|
|
|
def _snapshot_query_matches_intent(snap_query: str, canonical_query: str) -> bool:
|
|
"""Snapshot `query` is the full search box text; tasks use a canonical keyword (e.g. 耳机)."""
|
|
s = (snap_query or "").strip().lower()
|
|
c = (canonical_query or "").strip().lower()
|
|
if not c:
|
|
return True
|
|
if not s:
|
|
return False
|
|
if s == c:
|
|
return True
|
|
return c in s
|
|
|
|
|
|
def _snapshot_brand_matches(snapshot: dict[str, Any], expected_brand: str | None) -> bool:
|
|
"""Brand may be only in filters, only in combined query (e.g. Sony 耳机), or both."""
|
|
if expected_brand is None:
|
|
return True
|
|
eb = str(expected_brand).strip()
|
|
snap_brand_raw = str(snapshot.get("brand") or "").strip()
|
|
snap_query = str(snapshot.get("query") or "")
|
|
parts = [p.strip() for p in snap_brand_raw.split(",") if p.strip()]
|
|
if parts:
|
|
return eb in parts
|
|
return eb.lower() in snap_query.lower()
|
|
|
|
|
|
def _price_field_matches(actual: Any, expected: str | None) -> bool:
|
|
if expected is None:
|
|
return True
|
|
a = str(actual or "").strip()
|
|
b = str(expected).strip()
|
|
if a == b:
|
|
return True
|
|
try:
|
|
return float(a) == float(b)
|
|
except ValueError:
|
|
return False
|
|
|
|
|
|
def snapshot_matches_search_criteria(
|
|
snapshot: dict[str, Any] | None,
|
|
*,
|
|
query: str,
|
|
sort_option: str | None = None,
|
|
category_id: str | None = None,
|
|
brand: str | None = None,
|
|
buying_format: str | None = None,
|
|
condition: str | None = None,
|
|
location: str | None = None,
|
|
free_shipping_only: bool | None = None,
|
|
price_min: str | None = None,
|
|
price_max: str | None = None,
|
|
) -> bool:
|
|
"""Whether *snapshot* (a history entry or ``search.current``) matches the task filters."""
|
|
if not isinstance(snapshot, dict):
|
|
return False
|
|
if not _snapshot_query_matches_intent(str(snapshot.get("query") or ""), query):
|
|
return False
|
|
if sort_option is not None and str(snapshot.get("sortOption") or "") != sort_option:
|
|
return False
|
|
if category_id is not None and str(snapshot.get("categoryId") or "") != category_id:
|
|
return False
|
|
if brand is not None and not _snapshot_brand_matches(snapshot, brand):
|
|
return False
|
|
if buying_format is not None and str(snapshot.get("buyingFormat") or "") != buying_format:
|
|
return False
|
|
if condition is not None:
|
|
actual_conditions = sorted(str(item) for item in (snapshot.get("conditions") or []))
|
|
if actual_conditions != [condition]:
|
|
return False
|
|
if location is not None and str(snapshot.get("location") or "") != location:
|
|
return False
|
|
if free_shipping_only is not None and bool(snapshot.get("freeShippingOnly")) != free_shipping_only:
|
|
return False
|
|
if not _price_field_matches(snapshot.get("priceMin"), price_min):
|
|
return False
|
|
if not _price_field_matches(snapshot.get("priceMax"), price_max):
|
|
return False
|
|
return True
|
|
|
|
|
|
def expect_count(
|
|
*,
|
|
query: str,
|
|
category_id: str | None = None,
|
|
brand: str | None = None,
|
|
buying_format: BuyingFormat | None = None,
|
|
condition: str | None = None,
|
|
location: str | None = None,
|
|
free_shipping_only: bool = False,
|
|
min_total: float | None = None,
|
|
max_total: float | None = None,
|
|
) -> int:
|
|
return len(
|
|
filter_products(
|
|
query=query,
|
|
category_id=category_id,
|
|
brand=brand,
|
|
buying_format=buying_format,
|
|
condition=condition,
|
|
location=location,
|
|
free_shipping_only=free_shipping_only,
|
|
min_total=min_total,
|
|
max_total=max_total,
|
|
)
|
|
)
|
|
|
|
|
|
# =============================================================================
|
|
# Shared answer parsing helpers (for eBay tasks)
|
|
# =============================================================================
|
|
|
|
# Match the "count unit" in natural-language answers, e.g.:
|
|
# - "有 11 个结果"
|
|
# - "有 5 双"
|
|
_EBAY_COUNT_UNIT_RE = re.compile(r"(\d{1,5})\s*(?:个|条|双|件)(?:结果|条(?:记录)?)?")
|
|
|
|
|
|
def extract_two_counts_from_natural_answer(text: Any) -> tuple[int, int] | None:
|
|
"""
|
|
Extract the first two integer counts from an agent free-form answer.
|
|
|
|
Tight by design: only matches numbers followed by count units (个/条/双/件),
|
|
so it won't confuse price range bounds like 620/690 as counts.
|
|
"""
|
|
if text is None:
|
|
return None
|
|
s = normalize_text(str(text))
|
|
matches = [int(m.group(1)) for m in _EBAY_COUNT_UNIT_RE.finditer(s)]
|
|
if len(matches) >= 2:
|
|
return matches[0], matches[1]
|
|
return None
|
|
|
|
|
|
def infer_winner_label(first_count: int, second_count: int, first_label: str, second_label: str, tie_label: str = "相同") -> str:
|
|
if first_count > second_count:
|
|
return first_label
|
|
if second_count > first_count:
|
|
return second_label
|
|
return tie_label
|
|
|
|
|
|
|
|
# =============================================================================
|
|
# Answer matching helpers (moved from tasks.py per §1.1)
|
|
# =============================================================================
|
|
|
|
|
|
def _case_field_suffix(*parts: Any) -> str:
|
|
raw = "_".join(str(p) for p in parts if p is not None and str(p).strip())
|
|
return re.sub(r"[^\w\u4e00-\u9fff]+", "_", raw).strip("_") or "case"
|
|
|
|
|
|
_EBAY_STANDALONE_NUM_RE = re.compile(
|
|
r"(?<!\d)-?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?!\d)"
|
|
)
|
|
|
|
|
|
def _ebay_parse_floats_in_order(text: str) -> list[float]:
|
|
s = normalize_text(text)
|
|
out: list[float] = []
|
|
for m in _EBAY_STANDALONE_NUM_RE.finditer(s):
|
|
try:
|
|
out.append(float(m.group().replace(",", "").replace(",", "")))
|
|
except ValueError:
|
|
continue
|
|
return out
|
|
|
|
|
|
def _ebay_match_price(expected: float, actual_fragment: Any) -> bool:
|
|
"""Match an expected price (yuan) against an actual value or text fragment."""
|
|
if actual_fragment is None:
|
|
return False
|
|
if isinstance(actual_fragment, bool):
|
|
return False
|
|
if isinstance(actual_fragment, (int, float)):
|
|
return math.isclose(float(actual_fragment), expected, rel_tol=1e-6, abs_tol=0.02)
|
|
for n in _ebay_parse_floats_in_order(str(actual_fragment)):
|
|
if math.isclose(n, expected, rel_tol=1e-6, abs_tol=0.02):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _ebay_winner_label_matches_in_text(
|
|
label_expected: str,
|
|
full: str | None,
|
|
winner_marker_words: list[str] | None,
|
|
) -> bool:
|
|
"""Check winner label near the marker word."""
|
|
if full is None:
|
|
return False
|
|
full_norm = normalize_text(full)
|
|
low = label_expected.lower()
|
|
if label_expected == "相同" or low in ("same", "tied", "equal"):
|
|
return any(w in full_norm for w in ("相同", "一样", "same", "tied", "equal"))
|
|
if not winner_marker_words:
|
|
return match_value(label_expected, full_norm)
|
|
for marker in winner_marker_words:
|
|
marker_norm = normalize_text(marker)
|
|
if not marker_norm:
|
|
continue
|
|
if re.search(
|
|
rf"{re.escape(marker_norm)}[^。;,,]{{0,20}}{re.escape(label_expected)}",
|
|
full_norm,
|
|
):
|
|
return True
|
|
if re.search(
|
|
rf"{re.escape(label_expected)}[^。;,,]{{0,20}}{re.escape(marker_norm)}",
|
|
full_norm,
|
|
):
|
|
return True
|
|
return False
|
|
|
|
|
|
|
|
def build_compare_two_totals_checks(
|
|
*,
|
|
label_expected: str,
|
|
label_key: str,
|
|
first_total: float,
|
|
first_key: str,
|
|
second_total: float,
|
|
second_key: str,
|
|
winner_marker_words: list[str] | None = None,
|
|
answer: Any,
|
|
) -> list[dict[str, Any]]:
|
|
"""Two price slots + one label; plain string answers list prices in order."""
|
|
if isinstance(answer, dict):
|
|
return [
|
|
{"field": f"answer.{label_key}", "expected": label_expected,
|
|
"actual": answer.get(label_key),
|
|
"passed": match_value(label_expected, answer.get(label_key))},
|
|
{"field": f"answer.{first_key}", "expected": first_total,
|
|
"actual": answer.get(first_key),
|
|
"passed": _ebay_match_price(first_total, answer.get(first_key))},
|
|
{"field": f"answer.{second_key}", "expected": second_total,
|
|
"actual": answer.get(second_key),
|
|
"passed": _ebay_match_price(second_total, answer.get(second_key))},
|
|
]
|
|
full = None if answer is None else str(answer)
|
|
nums = _ebay_parse_floats_in_order(full) if full else []
|
|
first_actual = str(nums[0]) if len(nums) > 0 else None
|
|
second_actual = str(nums[1]) if len(nums) > 1 else None
|
|
return [
|
|
{"field": f"answer.{label_key}", "expected": label_expected,
|
|
"actual": full,
|
|
"passed": _ebay_winner_label_matches_in_text(label_expected, full, winner_marker_words)},
|
|
{"field": f"answer.{first_key}", "expected": first_total,
|
|
"actual": first_actual,
|
|
"passed": _ebay_match_price(first_total, first_actual)},
|
|
{"field": f"answer.{second_key}", "expected": second_total,
|
|
"actual": second_actual,
|
|
"passed": _ebay_match_price(second_total, second_actual)},
|
|
]
|
|
|
|
|
|
def build_compare_counts_checks(
|
|
*,
|
|
more_expected: str,
|
|
label1: str,
|
|
count1: int,
|
|
label2: str,
|
|
count2: int,
|
|
answer: Any,
|
|
) -> list[dict[str, Any]]:
|
|
"""Count-based comparison between two filtered groups."""
|
|
if isinstance(answer, dict):
|
|
return [
|
|
{"field": "answer.more", "expected": more_expected,
|
|
"actual": answer.get("more"),
|
|
"passed": match_value(more_expected, answer.get("more"))},
|
|
{"field": f"answer.{label1}Count", "expected": count1,
|
|
"actual": answer.get(f"{label1}Count"),
|
|
"passed": match_value(count1, answer.get(f"{label1}Count"))},
|
|
{"field": f"answer.{label2}Count", "expected": count2,
|
|
"actual": answer.get(f"{label2}Count"),
|
|
"passed": match_value(count2, answer.get(f"{label2}Count"))},
|
|
]
|
|
full = None if answer is None else str(answer)
|
|
# String-based: extract winner label and two counts
|
|
more_passed = False
|
|
if full:
|
|
pair = extract_two_counts_from_natural_answer(full)
|
|
if pair is not None:
|
|
inferred = infer_winner_label(pair[0], pair[1], first_label=label1, second_label=label2)
|
|
more_passed = inferred == more_expected
|
|
elif more_expected in full:
|
|
more_passed = True
|
|
return [
|
|
{"field": "answer.more", "expected": more_expected,
|
|
"actual": full, "passed": more_passed},
|
|
{"field": f"answer.{label1}Count", "expected": count1,
|
|
"actual": full, "passed": match_value(count1, full)},
|
|
{"field": f"answer.{label2}Count", "expected": count2,
|
|
"actual": full, "passed": match_value(count2, full)},
|
|
]
|
|
|
|
|
|
class Ebay(BaseApp):
|
|
"""
|
|
eBay state accessor.
|
|
|
|
Usage:
|
|
ebay = Ebay(input.apps["ebay"])
|
|
ebay.recent_searches
|
|
ebay.current_search
|
|
"""
|
|
|
|
@property
|
|
def recent_searches(self) -> list[dict[str, Any]]:
|
|
return self.get_list("recentSearches")
|
|
|
|
@property
|
|
def current_search(self) -> dict[str, Any]:
|
|
return self.get("search", {}).get("current", {})
|
|
|
|
@property
|
|
def search_history(self) -> list[dict[str, Any]]:
|
|
history = self.get("search", {}).get("history", [])
|
|
return history if isinstance(history, list) else []
|
|
|
|
@property
|
|
def last_compare(self) -> dict[str, Any] | None:
|
|
last_compare = self.get("search", {}).get("lastCompare")
|
|
return last_compare if isinstance(last_compare, dict) else None
|
|
|
|
@staticmethod
|
|
def sample_query_category_pair(env_state: dict[str, Any], rng: Any) -> dict[str, str]:
|
|
picked = rng.choice(EBAY_QUERY_CATEGORY_PAIRS)
|
|
return {"query": str(picked["query"]), "category": str(picked["category"])}
|
|
|
|
@staticmethod
|
|
def sample_two_items(env_state: dict[str, Any], rng: Any) -> dict[str, str]:
|
|
"""从搜索关键词候选列表中随机采样两个不同的商品关键词。"""
|
|
pool = list(EBAY_SEARCH_QUERY_PARAM["values"])
|
|
picked = rng.sample(pool, 2)
|
|
return {"item1": picked[0], "item2": picked[1]}
|
|
|
|
def find_latest_snapshot(
|
|
self,
|
|
*,
|
|
query: str,
|
|
sort_option: str | None = None,
|
|
category_id: str | None = None,
|
|
brand: str | None = None,
|
|
buying_format: str | None = None,
|
|
condition: str | None = None,
|
|
location: str | None = None,
|
|
free_shipping_only: bool | None = None,
|
|
price_min: str | None = None,
|
|
price_max: str | None = None,
|
|
) -> dict[str, Any] | None:
|
|
kw: dict[str, Any] = {
|
|
"query": query,
|
|
"sort_option": sort_option,
|
|
"category_id": category_id,
|
|
"brand": brand,
|
|
"buying_format": buying_format,
|
|
"condition": condition,
|
|
"location": location,
|
|
"free_shipping_only": free_shipping_only,
|
|
"price_min": price_min,
|
|
"price_max": price_max,
|
|
}
|
|
for snapshot in reversed(self.search_history):
|
|
if snapshot_matches_search_criteria(snapshot, **kw):
|
|
return snapshot
|
|
# Filter-only updates sync into ``search.current`` on every change; history is only
|
|
# appended on search/sort/apply. Accept the live current state when it matches.
|
|
cur = self.current_search
|
|
if snapshot_matches_search_criteria(cur, **kw):
|
|
return cur
|
|
return None
|
|
|
|
def cheapest_product(
|
|
self,
|
|
*,
|
|
query: str,
|
|
condition: str | None = None,
|
|
location: str | None = None,
|
|
brand: str | None = None,
|
|
) -> Product:
|
|
"""返回满足筛选条件的最低总价商品。"""
|
|
return expect_top(
|
|
query=query,
|
|
condition=condition,
|
|
location=location,
|
|
brand=brand,
|
|
sort_id="priceLow",
|
|
n=1,
|
|
)[0]
|
|
|
|
def check_search_snapshot(
|
|
self,
|
|
query: str,
|
|
*,
|
|
condition: str | None = None,
|
|
sort_option: str | None = None,
|
|
first_total_cents: int | None = None,
|
|
field: str | None = None,
|
|
) -> dict[str, Any]:
|
|
if field is None:
|
|
field = f"ebay.search.{query}"
|
|
snapshot = self.find_latest_snapshot(
|
|
query=query,
|
|
condition=condition,
|
|
sort_option=sort_option,
|
|
)
|
|
actual = None
|
|
passed = snapshot is not None
|
|
if snapshot is not None:
|
|
actual = {
|
|
"query": snapshot.get("query"),
|
|
"conditions": snapshot.get("conditions"),
|
|
"sortOption": snapshot.get("sortOption"),
|
|
"firstTotalCents": snapshot.get("firstTotalCents"),
|
|
}
|
|
if first_total_cents is not None:
|
|
try:
|
|
passed = passed and int(snapshot.get("firstTotalCents")) == int(first_total_cents)
|
|
except (TypeError, ValueError):
|
|
passed = False
|
|
expected: dict[str, Any] = {"query": query}
|
|
if condition is not None:
|
|
expected["condition"] = condition
|
|
if sort_option is not None:
|
|
expected["sortOption"] = sort_option
|
|
if first_total_cents is not None:
|
|
expected["firstTotalCents"] = int(first_total_cents)
|
|
return {
|
|
"field": field,
|
|
"expected": expected,
|
|
"actual": actual,
|
|
"passed": passed,
|
|
}
|
|
|
|
def check_current_search(
|
|
self,
|
|
query: str,
|
|
*,
|
|
sort_option: str | None = None,
|
|
first_total_cents: int | None = None,
|
|
field: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""仅校验 search.current(当前搜索页),不扫描历史记录。
|
|
|
|
用于需要验证"当前仍停留在目标搜索结果页"的任务判定。
|
|
"""
|
|
from bench_env.task.utils import norm as _norm
|
|
if field is None:
|
|
field = f"ebay.current.{query}"
|
|
cur = self.current_search
|
|
q = str(cur.get("query") or "")
|
|
sort_id = cur.get("sortOption")
|
|
cents = cur.get("firstTotalCents")
|
|
query_ok = bool(q) and _norm(query) in _norm(q)
|
|
sort_ok = sort_option is None or sort_id == sort_option
|
|
price_ok = True
|
|
if first_total_cents is not None:
|
|
try:
|
|
price_ok = int(cents) == int(first_total_cents)
|
|
except (TypeError, ValueError):
|
|
price_ok = False
|
|
passed = query_ok and sort_ok and price_ok
|
|
expected: dict[str, Any] = {"query": query}
|
|
if sort_option is not None:
|
|
expected["sortOption"] = sort_option
|
|
if first_total_cents is not None:
|
|
expected["firstTotalCents"] = int(first_total_cents)
|
|
return {
|
|
"field": field,
|
|
"expected": expected,
|
|
"actual": {"query": q, "sortOption": sort_id, "firstTotalCents": cents},
|
|
"passed": passed,
|
|
}
|
|
|
|
def compare_cheapest_products(
|
|
self,
|
|
*,
|
|
query1: str,
|
|
query2: str,
|
|
condition: str | None = None,
|
|
location: str | None = None,
|
|
) -> tuple[str, Product, Product, float]:
|
|
"""比较两次搜索的最低总价商品,返回更便宜的一方与差价。"""
|
|
first = self.cheapest_product(query=query1, condition=condition, location=location)
|
|
second = self.cheapest_product(query=query2, condition=condition, location=location)
|
|
first_total = round(first.total_cost, 2)
|
|
second_total = round(second.total_cost, 2)
|
|
if first_total < second_total:
|
|
return query1, first, second, round(second_total - first_total, 2)
|
|
if second_total < first_total:
|
|
return query2, first, second, round(first_total - second_total, 2)
|
|
return "相同", first, second, 0.0
|
|
|
|
# -- check methods --
|
|
|
|
def check_has_snapshot(
|
|
self,
|
|
*,
|
|
query: str,
|
|
brand: str | None = None,
|
|
condition: str | None = None,
|
|
location: str | None = None,
|
|
sort_option: str | None = None,
|
|
price_min: str | None = None,
|
|
price_max: str | None = None,
|
|
field: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Check that a matching search snapshot exists."""
|
|
if field is None:
|
|
field = f"snapshot.{_case_field_suffix(brand, query, location)}"
|
|
snapshot = self.find_latest_snapshot(
|
|
query=query, brand=brand, condition=condition,
|
|
location=location, sort_option=sort_option,
|
|
price_min=price_min, price_max=price_max,
|
|
)
|
|
parts = [p for p in [brand, query, f"@ {location}" if location else None] if p]
|
|
if price_min or price_max:
|
|
parts.append(f"[{price_min or ''}, {price_max or ''}]")
|
|
return {
|
|
"field": field,
|
|
"expected": f"matching snapshot for {' '.join(parts)}",
|
|
"actual": snapshot,
|
|
"passed": snapshot is not None,
|
|
}
|
|
|
|
# -- answer methods --
|
|
|
|
def cheapest_product_answer(
|
|
self, *, query: str, brand: str | None = None,
|
|
condition: str | None = None, location: str | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Answer method: {title, price} of cheapest matching product."""
|
|
p = self.cheapest_product(
|
|
query=query, condition=condition, location=location, brand=brand,
|
|
)
|
|
return {"title": p.title, "price": round(p.total_cost, 2)}
|
|
|
|
@staticmethod
|
|
def compare_top_totals(
|
|
q1: str, q2: str, *,
|
|
condition: str | None = None,
|
|
location: str | None = None,
|
|
sort_id: SortId = "priceLow",
|
|
) -> tuple[float, float]:
|
|
"""Return (first_total, second_total) in yuan for two queries."""
|
|
first = expect_top(
|
|
query=q1, condition=condition, location=location,
|
|
sort_id=sort_id, n=1,
|
|
)[0]
|
|
second = expect_top(
|
|
query=q2, condition=condition, location=location,
|
|
sort_id=sort_id, n=1,
|
|
)[0]
|
|
return (
|
|
round(first.total_cost, 2),
|
|
round(second.total_cost, 2),
|
|
)
|
|
|
|
# -- sampler staticmethods (moved from tasks.py per §1.1) --
|
|
|
|
@staticmethod
|
|
def sample_brand_location_case(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
|
|
"""Shared sampler for brand+location filtered searches (count / cheapest tasks)."""
|
|
candidates = [
|
|
{"query": "耳机", "brand": "Sony", "location": "欧洲", "condition": "全新"},
|
|
{"query": "运动鞋", "brand": "Nike", "location": "欧洲", "condition": "全新"},
|
|
{"query": "吸尘器", "brand": "Dyson", "location": "亚洲", "condition": "全新"},
|
|
{"query": "发动机零件", "brand": "Bosch", "location": "欧洲", "condition": "全新"},
|
|
]
|
|
valid = [
|
|
c for c in candidates
|
|
if expect_count(query=c["query"], brand=c["brand"],
|
|
condition=c["condition"], location=c["location"]) > 0
|
|
]
|
|
return rng.choice(valid or candidates)
|
|
|
|
@staticmethod
|
|
def sample_compare_pair(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
|
|
"""Sampler for L4 two-product price comparison tasks."""
|
|
pool = list(EBAY_SEARCH_QUERY_PARAM["values"])
|
|
pair = rng.sample(pool, 2)
|
|
modes = [
|
|
{"sort_id": "priceLow", "sort_label": "最低价", "extreme": "最便宜", "comparison": "更便宜"},
|
|
{"sort_id": "priceHigh", "sort_label": "最高价", "extreme": "最贵", "comparison": "更贵"},
|
|
]
|
|
mode = rng.choice(modes)
|
|
return {"item1": pair[0], "item2": pair[1], **mode}
|
|
|
|
@staticmethod
|
|
def sample_range_case(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
|
|
base_candidates = [
|
|
{"query": "运动鞋", "brand": "Nike", "location": "欧洲", "condition": "全新"},
|
|
{"query": "耳机", "brand": "Sony", "location": "欧洲", "condition": "全新"},
|
|
{"query": "吸尘器", "brand": "Dyson", "location": "亚洲", "condition": "全新"},
|
|
]
|
|
valid_cases: list[dict[str, Any]] = []
|
|
for c in base_candidates:
|
|
products = filter_products(
|
|
query=c["query"], brand=c["brand"],
|
|
condition=c["condition"], location=c["location"],
|
|
)
|
|
if not products:
|
|
continue
|
|
totals = sorted(int(round(p.total_cost)) for p in products)
|
|
pick = totals[min(len(totals) - 1, max(0, len(totals) // 3))]
|
|
for span in (20, 30, 40, 60):
|
|
lo = max(0, pick - span)
|
|
hi = pick + span
|
|
cnt = expect_count(
|
|
query=c["query"], brand=c["brand"],
|
|
condition=c["condition"], location=c["location"],
|
|
min_total=lo, max_total=hi,
|
|
)
|
|
if cnt > 0:
|
|
valid_cases.append({**c, "price_min": str(lo), "price_max": str(hi)})
|
|
break
|
|
if valid_cases:
|
|
return rng.choice(valid_cases)
|
|
return {
|
|
"query": "运动鞋", "brand": "Nike", "location": "欧洲",
|
|
"condition": "全新", "price_min": "510", "price_max": "540",
|
|
}
|
|
|
|
@staticmethod
|
|
def sample_compare_counts_groups(env_state: dict[str, Any], rng: Any) -> dict[str, Any]:
|
|
"""Sampler for L4 two-group count comparison tasks."""
|
|
candidates = [
|
|
{"query": "耳机", "brand": "Sony", "location": "欧洲", "condition": "全新"},
|
|
{"query": "运动鞋", "brand": "Nike", "location": "欧洲", "condition": "全新"},
|
|
{"query": "吸尘器", "brand": "Dyson", "location": "亚洲", "condition": "全新"},
|
|
{"query": "发动机零件", "brand": "Bosch", "location": "欧洲", "condition": "全新"},
|
|
]
|
|
def _with_range(c: dict[str, Any]) -> dict[str, Any] | None:
|
|
products = filter_products(
|
|
query=c["query"], brand=c["brand"],
|
|
condition=c["condition"], location=c["location"],
|
|
)
|
|
if not products:
|
|
return None
|
|
totals = sorted(int(round(p.total_cost)) for p in products)
|
|
pick = totals[min(len(totals) - 1, max(0, len(totals) // 3))]
|
|
for span in (20, 30, 40, 60):
|
|
lo, hi = max(0, pick - span), pick + span
|
|
if expect_count(query=c["query"], brand=c["brand"],
|
|
condition=c["condition"], location=c["location"],
|
|
min_total=lo, max_total=hi) > 0:
|
|
return {**c, "price_min": str(lo), "price_max": str(hi)}
|
|
return None
|
|
|
|
valid = [r for c in candidates if (r := _with_range(c)) is not None]
|
|
if len(valid) >= 2:
|
|
g1, g2 = rng.sample(valid, 2)
|
|
else:
|
|
g1 = {"query": "耳机", "brand": "Sony", "location": "欧洲",
|
|
"condition": "全新", "price_min": "620", "price_max": "690"}
|
|
g2 = {"query": "运动鞋", "brand": "Nike", "location": "欧洲",
|
|
"condition": "全新", "price_min": "510", "price_max": "540"}
|
|
return {f"{k}1": v for k, v in g1.items()} | {f"{k}2": v for k, v in g2.items()}
|