trading 新增 4 条购物车接口(POST /api/cart/{add,status,clear,remove}),
全部 Bearer 鉴权、走 SiteInteractor(Playwright + storage_state)。同步把
SiteInteractor 从 gateway URL 解耦——lifespan 总是构造与启停,container
字段 worker_site → site,加 asyncio.Lock 让 HTTP 与 worker 共用同一把锁
(同账号串行硬约束)。clear/remove 用 UI 点击 button[aria-label="削除"],
探针回报这是稳定 selector;真账号实测前先用此路径。
抽 ichiba 加购字段解析到 app/shared/purchase_contract.py(常量 +
inventory_flag_for + basket_domain_of + base_form_fields),原本 scraping
与 trading 重复实现同一段 __INITIAL_STATE__.purchase 解析。进一步发现
README 写的「purchase 块是两服务契约」实际未落地——trading 必须 Playwright
开页(httpx 被 TLS 指纹拦死),本地抽比再调 /api/item_detail 更快更新鲜。
删除 scraping 端 PurchaseInfo/PurchaseOption/PurchaseOptionValue 模型、
各站 _purchase_info 函数、tests/test_purchase.py。ItemDetailData 保留
purchase_condition / is_sold_out / purchase_unit / sku 等商品状态字段。
README「加购与下单」段重写。
328 测试全绿(含架构测试守住三方互不 import)。
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
698 lines
26 KiB
Python
698 lines
26 KiB
Python
"""抓取侧 API 数据模型:请求体和响应体定义
|
|
|
|
字段命名贴合乐天站点自身的语义(item_code / shop_code / genre_id / sku 等),
|
|
不做跨站点的字段名归一,避免解析层与对外契约之间反复翻译。
|
|
|
|
响应信封 `ApiResponse` 在 app/shared/api.py,与交易侧共用;登录态、订单等需要
|
|
账号的模型在 app/trading/models.py,不在这里。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from enum import StrEnum
|
|
from typing import Any
|
|
|
|
from pydantic import BaseModel, Field, HttpUrl, model_validator
|
|
|
|
|
|
class SortOption(StrEnum):
|
|
"""搜索排序方式,对应搜索页 `s=` 参数"""
|
|
|
|
STANDARD = "standard" # 站点默认相关度排序
|
|
PRICE_ASC = "price_asc"
|
|
PRICE_DESC = "price_desc"
|
|
NEWEST = "newest"
|
|
REVIEW_COUNT = "review_count"
|
|
REVIEW_SCORE = "review_score"
|
|
PRICE_WITH_SHIPPING_ASC = "price_with_shipping_asc"
|
|
PRICE_WITH_SHIPPING_DESC = "price_with_shipping_desc"
|
|
|
|
|
|
class ItemCondition(StrEnum):
|
|
"""商品成色筛选"""
|
|
|
|
NEW = "new"
|
|
USED = "used"
|
|
RENTAL = "rental"
|
|
|
|
|
|
class SearchRequest(BaseModel):
|
|
"""搜索请求参数
|
|
|
|
三种用法(优先级从高到低):
|
|
1. 传 search_url:直接透传一条乐天搜索页 URL,服务端原样抓取,
|
|
此时除 page 与 exclude_ads 外的筛选字段全部忽略;
|
|
page 若显式指定(>1),会覆盖 URL 中的页码。
|
|
2. 传 keyword(可叠加任意筛选字段)
|
|
3. 只传 genre_id:抓取该分类下的商品
|
|
"""
|
|
|
|
keyword: str = ""
|
|
page: int = Field(default=1, ge=1, le=150) # 站点侧最多约 150 页(subset 6750 / 45)
|
|
sort: SortOption = SortOption.STANDARD
|
|
genre_id: str | None = None # 乐天分类 ID,如 565950
|
|
min_price: int | None = Field(default=None, ge=0)
|
|
max_price: int | None = Field(default=None, ge=0)
|
|
shop_id: int | None = None # 限定店铺(对应 `sid` 参数,取搜索结果的 shop.shop_id)
|
|
exclude_keyword: str | None = None # 排除词(`nitem`)
|
|
title_only: bool = False # 仅在商品标题中匹配(`sf=1`)
|
|
or_query: bool = False # 关键词之间用 OR 而非 AND(`st=O`)
|
|
min_review_score: int | None = Field(default=None, ge=1, le=5) # 最低评分
|
|
condition: ItemCondition | None = None # 新品 / 中古 / 租赁
|
|
include_sold_out: bool = False # 包含售罄商品
|
|
free_shipping: bool = False # 仅免运费
|
|
has_review: bool = False # 仅有评论
|
|
next_day_delivery: bool = False # 仅次日达
|
|
super_deal: bool = False # 仅 SuperDEAL
|
|
tags: list[str] = Field(default_factory=list) # 站点标签 ID(`tg`)
|
|
search_url: HttpUrl | None = None
|
|
exclude_ads: bool = True # 剔除搜索结果中混入的 CPC 广告位
|
|
|
|
@model_validator(mode="after")
|
|
def check_search_target(self) -> SearchRequest:
|
|
if (
|
|
not self.search_url
|
|
and not self.keyword.strip()
|
|
and not self.genre_id
|
|
and self.shop_id is None
|
|
):
|
|
raise ValueError("keyword、genre_id、shop_id、search_url 至少需要提供一个")
|
|
if self.min_price is not None and self.max_price is not None and self.min_price > self.max_price:
|
|
raise ValueError("min_price 不能大于 max_price")
|
|
return self
|
|
|
|
|
|
class ShopDetailRequest(BaseModel):
|
|
"""乐天商家详情请求参数:传店铺代码(店铺 URL 的路径段),或直接传店铺页 URL"""
|
|
|
|
shop_code: str | None = None # 店铺代码,如 edion
|
|
shop_url: HttpUrl | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def check_shop_target(self) -> ShopDetailRequest:
|
|
if not self.shop_url and not (self.shop_code or "").strip():
|
|
raise ValueError("需要提供 shop_code 或 shop_url")
|
|
return self
|
|
|
|
|
|
class ShopItemsRequest(BaseModel):
|
|
"""乐天商家商品列表请求参数
|
|
|
|
站点没有单独的「店铺内商品」接口,本服务转成一次限定店铺的搜索
|
|
(搜索页的 `sid` 参数),因此支持与 /api/search 相同的排序与筛选。
|
|
|
|
shop_id 与 shop_code 至少提供一个;只给 shop_code 时会先取一次店铺详情
|
|
换出 shop_id,多花一次请求,能直接给 shop_id 时优先给。
|
|
"""
|
|
|
|
shop_id: int | None = None # 取自搜索结果或商家详情的 shop.shop_id
|
|
shop_code: str | None = None # 店铺代码,如 edion
|
|
keyword: str = "" # 在店铺内按关键词过滤
|
|
page: int = Field(default=1, ge=1, le=150)
|
|
sort: SortOption = SortOption.STANDARD
|
|
genre_id: str | None = None
|
|
min_price: int | None = Field(default=None, ge=0)
|
|
max_price: int | None = Field(default=None, ge=0)
|
|
condition: ItemCondition | None = None
|
|
include_sold_out: bool = False
|
|
free_shipping: bool = False
|
|
exclude_ads: bool = True
|
|
|
|
@model_validator(mode="after")
|
|
def check_shop_target(self) -> ShopItemsRequest:
|
|
if self.shop_id is None and not (self.shop_code or "").strip():
|
|
raise ValueError("需要提供 shop_id 或 shop_code")
|
|
if self.min_price is not None and self.max_price is not None and self.min_price > self.max_price:
|
|
raise ValueError("min_price 不能大于 max_price")
|
|
return self
|
|
|
|
def to_search_request(self, shop_id: int) -> SearchRequest:
|
|
"""转成一次限定店铺的搜索请求
|
|
|
|
站点没有独立的「店铺内商品」页可供分页抓取,店铺商品实际就是
|
|
`sid=` 限定后的搜索结果,因此这里复用同一条抓取链路。
|
|
"""
|
|
return SearchRequest(
|
|
keyword=self.keyword,
|
|
page=self.page,
|
|
sort=self.sort,
|
|
genre_id=self.genre_id,
|
|
min_price=self.min_price,
|
|
max_price=self.max_price,
|
|
shop_id=shop_id,
|
|
condition=self.condition,
|
|
include_sold_out=self.include_sold_out,
|
|
free_shipping=self.free_shipping,
|
|
exclude_ads=self.exclude_ads,
|
|
)
|
|
|
|
|
|
class ShopDetailData(BaseModel):
|
|
"""乐天商家详情数据"""
|
|
|
|
shop_id: int | None = None
|
|
shop_code: str = ""
|
|
shop_name: str = ""
|
|
shop_url: str = ""
|
|
introduction: str = "" # 店铺简介
|
|
signboard_url: str = "" # 店铺招牌图
|
|
logo_url: str = ""
|
|
review_score: float = 0.0
|
|
review_count: int = 0
|
|
# 站点在评价数过少时不展示评分;此时 review_score 不可信
|
|
review_displayed: bool = False
|
|
is_39_shop: bool = False # 39ショップ(满 3980 日元免运费)
|
|
age_verification_required: bool = False
|
|
status: int | None = None # 站点店铺状态码,1 = 营业中
|
|
holidays: list[str] = Field(default_factory=list) # 店铺休息日
|
|
|
|
|
|
class ItemDetailRequest(BaseModel):
|
|
"""商品详情请求参数:传 shop_code + item_code,或直接传商品页 URL"""
|
|
|
|
shop_code: str | None = None # 店铺代码,如 edion(商品 URL 的第一段)
|
|
item_code: str | None = None # 店铺内商品编号,如 4902370549263(商品 URL 的第二段)
|
|
item_url: HttpUrl | None = None
|
|
include_sku_variants: bool = True # SKU 组合可能多达数百条,不需要时可关闭
|
|
|
|
@model_validator(mode="after")
|
|
def check_item_target(self) -> ItemDetailRequest:
|
|
if not self.item_url and not (self.shop_code and self.item_code):
|
|
raise ValueError("需要提供 item_url,或同时提供 shop_code 与 item_code")
|
|
return self
|
|
|
|
|
|
class GenreRequest(BaseModel):
|
|
"""分类查询参数
|
|
|
|
不传 genre_id 时返回 39 个顶层分类;传入时返回该分类的信息、祖先路径与直接子分类。
|
|
"""
|
|
|
|
genre_id: str | None = None
|
|
|
|
|
|
class GenreNode(BaseModel):
|
|
"""分类树上的一个节点"""
|
|
|
|
genre_id: str = ""
|
|
name: str = ""
|
|
# 该分类下的商品数。顶层列表不返回该值:站点给出的是「当前查询在该分类下的
|
|
# 命中数」,与分类自身的商品总量不是一回事,避免误用。
|
|
item_count: int | None = None
|
|
shortcut: str = "" # 站点分类短代码,如 game / flower
|
|
is_leaf: bool = False # 叶子分类,没有下级
|
|
url: str = "" # 分类页地址
|
|
|
|
|
|
class GenreData(BaseModel):
|
|
"""分类查询结果"""
|
|
|
|
genre_id: str = "" # 空串表示顶层
|
|
name: str = ""
|
|
full_name: str = "" # 站点给出的完整分类名,仅分类页有
|
|
description: str = "" # 站点分类描述,仅分类页有
|
|
is_leaf: bool = False
|
|
url: str = ""
|
|
ancestors: list[GenreNode] = Field(default_factory=list) # 从顶层到父级,不含自身
|
|
children: list[GenreNode] = Field(default_factory=list) # 直接子分类
|
|
|
|
|
|
class ShopSummary(BaseModel):
|
|
"""店铺信息"""
|
|
|
|
shop_id: int | None = None
|
|
shop_code: str = "" # 店铺 URL 代码,如 edion;与 item_code 一起可定位商品
|
|
shop_name: str = ""
|
|
shop_url: str = ""
|
|
review_score: float = 0.0
|
|
review_count: int = 0
|
|
|
|
|
|
class ReviewSummary(BaseModel):
|
|
"""评价信息"""
|
|
|
|
score: float = 0.0
|
|
count: int = 0
|
|
url: str = ""
|
|
|
|
|
|
class SearchItem(BaseModel):
|
|
"""搜索结果中的单个商品"""
|
|
|
|
item_id: str = "" # 乐天内部商品 ID(搜索结果的 code 字段)
|
|
item_code: str = "" # 商品 URL 第二段,调详情接口用
|
|
item_name: str = ""
|
|
item_url: str = "" # 真实商品页地址;广告位已还原为 originalItemUrl
|
|
catch_copy: str = "" # 商品副标题
|
|
price: int = 0
|
|
price_range: str = "" # 多 SKU 时的价格区间,如 "1000~2000"
|
|
has_price_range: bool = False
|
|
image_url: str = ""
|
|
image_urls: list[str] = Field(default_factory=list)
|
|
shop: ShopSummary = Field(default_factory=ShopSummary)
|
|
review: ReviewSummary = Field(default_factory=ReviewSummary)
|
|
genre_id: str = ""
|
|
genre_path: str = "" # 形如 /0/101205/565950/566404
|
|
genre_names: list[str] = Field(default_factory=list)
|
|
shipping_fee: int | None = None # 站点未给出时为 null
|
|
delivery_message: str = ""
|
|
point_count: int = 0
|
|
is_sold_out: bool = False
|
|
is_ad: bool = False # CPC 广告位
|
|
has_multi_sku: bool = False
|
|
variant_id: str = ""
|
|
item_options: dict[str, Any] = Field(default_factory=dict) # 站点 itemOptions 原样透出
|
|
|
|
|
|
class SearchResultData(BaseModel):
|
|
"""搜索结果数据"""
|
|
|
|
keyword: str = ""
|
|
page: int = 1
|
|
page_size: int = 0
|
|
total_count: int = 0 # 站点声明的命中总数
|
|
reachable_count: int = 0 # 实际可翻页取到的上限(站点 subset,随查询条件变化)
|
|
has_more: bool = False
|
|
# 请求页码超出 reachable_count 对应的页数。站点此时不会返回空列表,而是
|
|
# 静默回绕到第 1 页;这里识别出来并把 items 置空,避免上游把重复数据当新数据。
|
|
out_of_range: bool = False
|
|
ad_count: int = 0 # 本页被识别出的广告位数量(exclude_ads=true 时已从 items 剔除)
|
|
request_url: str = "" # 实际抓取的乐天页面地址,便于排查
|
|
items: list[SearchItem] = Field(default_factory=list)
|
|
|
|
|
|
class SkuAttribute(BaseModel):
|
|
"""SKU 属性项"""
|
|
|
|
title: str = ""
|
|
value: str = ""
|
|
|
|
|
|
class SkuAxisValue(BaseModel):
|
|
"""SKU 选择轴上的一个取值"""
|
|
|
|
value: str = ""
|
|
label: str = ""
|
|
is_sold_out: bool = False
|
|
|
|
|
|
class SkuAxis(BaseModel):
|
|
"""SKU 选择轴,如「颜色」「尺码」"""
|
|
|
|
key: str = ""
|
|
label: str = ""
|
|
values: list[SkuAxisValue] = Field(default_factory=list)
|
|
|
|
|
|
class SkuVariant(BaseModel):
|
|
"""一个具体的 SKU 组合"""
|
|
|
|
variant_id: str = ""
|
|
selector_values: list[str] = Field(default_factory=list) # 与 axis 顺序对应的取值
|
|
price: int = 0
|
|
quantity: int = 0
|
|
is_sold_out: bool = False
|
|
delivery_message: str = ""
|
|
attributes: list[SkuAttribute] = Field(default_factory=list)
|
|
|
|
|
|
class SkuInfo(BaseModel):
|
|
"""商品 SKU 信息"""
|
|
|
|
inventory_type: str = "" # single / multiple
|
|
quantity: int = 0
|
|
show_inventory: bool = False
|
|
delivery_message: str = ""
|
|
attributes: list[SkuAttribute] = Field(default_factory=list)
|
|
axis: list[SkuAxis] = Field(default_factory=list)
|
|
variants: list[SkuVariant] = Field(default_factory=list) # include_sku_variants=false 时为空
|
|
variant_count: int = 0 # 不受 include_sku_variants 影响,始终为真实组合数
|
|
|
|
|
|
class ShippingInfo(BaseModel):
|
|
"""配送与运费信息"""
|
|
|
|
shipping_fee: int | None = None
|
|
is_shipping_free: bool = False
|
|
is_asuraku: bool = False # あす楽(次日达)
|
|
is_next_day_delivery: bool = False
|
|
free_shipping_threshold: int | None = None
|
|
prefecture_id: int | None = None # 站点默认收货地(13 = 东京都)
|
|
delivery_message: str = ""
|
|
|
|
|
|
class Breadcrumb(BaseModel):
|
|
"""分类面包屑"""
|
|
|
|
name: str = ""
|
|
url: str = ""
|
|
|
|
|
|
class ItemDetailData(BaseModel):
|
|
"""商品详情数据
|
|
|
|
部分乐天官方店的商品页会跳转到独立子站,各子站页面结构不同、可提供的字段也
|
|
不同。source 标明这条数据由哪个站点解析而来,字段覆盖差异见 README。
|
|
|
|
加购(构造 cart 请求)不在本服务范围内——加购需要已登录的乐天账号会话,
|
|
归 trading 服务(app.trading)。本响应只描述「商品状态」:能不能买
|
|
(purchase_condition / is_sold_out)、规格(sku.variants)、起订单位等。
|
|
"""
|
|
|
|
source: str = "ichiba" # ichiba / books / brandavenue / biccamera
|
|
source_url: str = "" # 实际解析的页面地址;跳转时与 item_url 不同
|
|
item_id: str = ""
|
|
item_code: str = ""
|
|
item_name: str = ""
|
|
catch_copy: str = ""
|
|
description: str = "" # 店铺自填的商品说明,含 HTML
|
|
item_url: str = ""
|
|
price: int = 0 # 最低售价(含税,多 SKU 时为最低价)
|
|
pre_tax_price: int = 0
|
|
tax_flag: bool = False
|
|
tax_rate: float = 0.0
|
|
purchase_condition: str = "" # 站点原值,enabled 表示可购买
|
|
is_sold_out: bool = False
|
|
purchase_unit: int = 0 # 起订单位
|
|
images: list[str] = Field(default_factory=list)
|
|
shop: ShopSummary = Field(default_factory=ShopSummary)
|
|
review: ReviewSummary = Field(default_factory=ReviewSummary)
|
|
genre_id: str = ""
|
|
breadcrumbs: list[Breadcrumb] = Field(default_factory=list)
|
|
shipping: ShippingInfo = Field(default_factory=ShippingInfo)
|
|
sku: SkuInfo = Field(default_factory=SkuInfo)
|
|
|
|
|
|
class HealthData(BaseModel):
|
|
"""抓取服务健康检查响应数据
|
|
|
|
这里不含登录态——登录态属于交易服务,查它请打交易服务的 /health。
|
|
"""
|
|
|
|
status: str
|
|
browser_fallback_enabled: bool
|
|
browser_fallback_ready: bool
|
|
browser_fallback_error: str | None = None
|
|
sessions: dict[str, Any] = Field(default_factory=dict)
|
|
|
|
|
|
# ==========================================================================
|
|
# ラクマ(fril.jp)
|
|
#
|
|
# 乐天市场是 B2C 商城(店铺 × 商品 × SKU),ラクマ 是 C2C 二手集市:
|
|
# 每件商品都是独一无二的一件,没有 SKU、没有库存数量、没有店铺代码,
|
|
# 卖家用一串 hash 标识。字段因此单独建模,不与市场侧强行合并。
|
|
# ==========================================================================
|
|
|
|
|
|
class RakumaSortOption(StrEnum):
|
|
"""ラクマ 搜索排序方式,对应搜索页 `sort=` + `order=` 两个参数"""
|
|
|
|
STANDARD = "standard" # おすすめ順(站点默认)
|
|
NEWEST = "newest" # 新着順
|
|
PRICE_ASC = "price_asc"
|
|
PRICE_DESC = "price_desc"
|
|
LIKE_COUNT = "like_count" # いいね数順
|
|
|
|
|
|
class RakumaCondition(StrEnum):
|
|
"""ラクマ 商品状态(出品者自己申告的成色,6 档)"""
|
|
|
|
NEW = "new" # 新品、未使用
|
|
ALMOST_NEW = "almost_new" # 未使用に近い
|
|
NO_DAMAGE = "no_damage" # 目立った傷や汚れなし
|
|
SLIGHT_DAMAGE = "slight_damage" # やや傷や汚れあり
|
|
DAMAGED = "damaged" # 傷や汚れあり
|
|
POOR = "poor" # 全体的に状態が悪い
|
|
|
|
|
|
class RakumaTransaction(StrEnum):
|
|
"""ラクマ 售卖状态筛选"""
|
|
|
|
ON_SALE = "on_sale" # 販売中のみ
|
|
SOLD_OUT = "sold_out" # 売切れのみ
|
|
|
|
|
|
class RakumaAuthenticity(StrEnum):
|
|
"""ラクマ 正品鉴定服务类型"""
|
|
|
|
BEFORE_DELIVERY = "before_delivery" # お届け前鑑定
|
|
AFTER_DELIVERY = "after_delivery" # 後から鑑定
|
|
|
|
|
|
class RakumaSearchRequest(BaseModel):
|
|
"""ラクマ 搜索请求参数
|
|
|
|
两种用法(优先级从高到低):
|
|
1. 传 search_url:透传一条 fril.jp 搜索页 URL,此时除 page 外的筛选字段全部忽略
|
|
2. 传 keyword / category_id / brand_id(可叠加任意筛选字段)
|
|
|
|
keyword、category_id、brand_id、search_url 四者至少提供一个。
|
|
"""
|
|
|
|
keyword: str = ""
|
|
page: int = Field(default=1, ge=1, le=100) # 站点侧 page>100 直接 404
|
|
sort: RakumaSortOption = RakumaSortOption.STANDARD
|
|
category_id: str | None = None # ラクマ 分类 ID,如 788
|
|
brand_id: str | None = None # ラクマ 品牌 ID,如 5296
|
|
min_price: int | None = Field(default=None, ge=0)
|
|
max_price: int | None = Field(default=None, ge=0)
|
|
exclude_keyword: str | None = None # 排除词(站点 `excluded_query`,需与 keyword 同时使用)
|
|
conditions: list[RakumaCondition] = Field(default_factory=list) # 可多选
|
|
transaction: RakumaTransaction | None = None # 不传表示不限
|
|
free_shipping: bool = False # 仅「送料込み」(卖家承担运费)
|
|
anonymous_shipping: bool = False # 仅匿名配送
|
|
except_for_no_brand: bool = False # 排除无品牌商品;与 brand_id 互斥
|
|
authenticity_types: list[RakumaAuthenticity] = Field(default_factory=list)
|
|
search_url: HttpUrl | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def check_search_target(self) -> RakumaSearchRequest:
|
|
if not self.search_url and not self.keyword.strip() and not self.category_id and not self.brand_id:
|
|
raise ValueError("keyword、category_id、brand_id、search_url 至少需要提供一个")
|
|
if self.min_price is not None and self.max_price is not None and self.min_price > self.max_price:
|
|
raise ValueError("min_price 不能大于 max_price")
|
|
# 站点前端在无关键词时会拒绝下发 excluded_query,服务端也不认,这里提前拦下
|
|
if self.exclude_keyword and not self.keyword.strip():
|
|
raise ValueError("exclude_keyword 必须与 keyword 同时使用")
|
|
return self
|
|
|
|
|
|
class RakumaItemDetailRequest(BaseModel):
|
|
"""ラクマ 商品详情请求参数:传 item_id(商品 URL 的最后一段),或直接传商品页 URL"""
|
|
|
|
item_id: str | None = None # 商品页 hash,如 4aca1d6db3e422f3a251a8a8b61e1eff
|
|
item_url: HttpUrl | None = None
|
|
|
|
@model_validator(mode="after")
|
|
def check_item_target(self) -> RakumaItemDetailRequest:
|
|
if not self.item_url and not (self.item_id or "").strip():
|
|
raise ValueError("需要提供 item_id 或 item_url")
|
|
return self
|
|
|
|
|
|
class RakumaShopDetailRequest(BaseModel):
|
|
"""ラクマ 卖家详情请求参数:传 shop_id(店铺 URL 的最后一段),或直接传店铺页 URL"""
|
|
|
|
shop_id: str | None = None # 店铺页 hash,如 422750cb7921557bc8dba2416915d968
|
|
shop_url: HttpUrl | None = None
|
|
# 评价明细在单独的 /review 页上,需要多打一次请求,默认不取
|
|
include_reviews: bool = False
|
|
|
|
@model_validator(mode="after")
|
|
def check_shop_target(self) -> RakumaShopDetailRequest:
|
|
if not self.shop_url and not (self.shop_id or "").strip():
|
|
raise ValueError("需要提供 shop_id 或 shop_url")
|
|
return self
|
|
|
|
|
|
class RakumaShopItemsRequest(BaseModel):
|
|
"""ラクマ 卖家商品列表请求参数
|
|
|
|
店铺页按上架顺序分页展示该卖家的全部商品(含已售出),站点不提供
|
|
排序与筛选参数,因此这里只有页码。
|
|
"""
|
|
|
|
shop_id: str | None = None
|
|
shop_url: HttpUrl | None = None
|
|
page: int = Field(default=1, ge=1)
|
|
|
|
@model_validator(mode="after")
|
|
def check_shop_target(self) -> RakumaShopItemsRequest:
|
|
if not self.shop_url and not (self.shop_id or "").strip():
|
|
raise ValueError("需要提供 shop_id 或 shop_url")
|
|
return self
|
|
|
|
|
|
class RakumaCategoryRequest(BaseModel):
|
|
"""ラクマ 分类查询参数
|
|
|
|
不传 category_id 时返回 14 个顶层分类;传入时返回该分类的名称、祖先路径与
|
|
直接子分类。站点一次请求就返回整棵树,因此 include_descendants 只是换一种
|
|
组织方式,不会多打请求。
|
|
"""
|
|
|
|
category_id: str | None = None
|
|
# 返回该分类下的完整子树(不止直接子级)。分类树共三层,
|
|
# 顶层分类的子树可达上百条。
|
|
include_descendants: bool = False
|
|
|
|
|
|
class RakumaCategoryNode(BaseModel):
|
|
"""ラクマ 分类树上的一个节点
|
|
|
|
站点只给 id / parentId / name / hasChild 四个字段,没有商品数——
|
|
商品数要逐个分类去抓 `/category/{id}` 页面,成本过高,本接口不提供。
|
|
"""
|
|
|
|
category_id: str = ""
|
|
name: str = ""
|
|
parent_id: str = "" # "0" 表示顶层分类
|
|
is_leaf: bool = False # 叶子分类,没有下级
|
|
url: str = "" # 分类页地址
|
|
# include_descendants=true 时填充下级分类,否则始终为空
|
|
children: list[RakumaCategoryNode] = Field(default_factory=list)
|
|
|
|
|
|
class RakumaCategoryData(BaseModel):
|
|
"""ラクマ 分类查询结果"""
|
|
|
|
category_id: str = "" # 空串表示顶层
|
|
name: str = ""
|
|
full_name: str = "" # 从顶层拼到自身的路径名,如「エンタメ/ホビー / ゲームソフト/ゲーム機本体 / 家庭用ゲームソフト」
|
|
is_leaf: bool = False
|
|
url: str = ""
|
|
total_count: int = 0 # 站点分类树的节点总数,用于确认取到的是全量树
|
|
ancestors: list[RakumaCategoryNode] = Field(default_factory=list) # 从顶层到父级,不含自身
|
|
children: list[RakumaCategoryNode] = Field(default_factory=list) # 直接子分类;include_descendants=true 时带子树
|
|
|
|
|
|
class RakumaSeller(BaseModel):
|
|
"""ラクマ 卖家(出品者)摘要"""
|
|
|
|
shop_id: str = "" # 店铺页 hash,可直接用于 /api/rakuma/shop_detail
|
|
user_id: str = "" # 站点内部数值用户 ID
|
|
shop_name: str = "" # 店铺名,卖家可自定义
|
|
user_name: str = "" # 用户昵称
|
|
shop_url: str = ""
|
|
icon_url: str = ""
|
|
seller_type: str = "" # 站点原值,如 一般 / 事業者
|
|
review_score: float = 0.0
|
|
review_count: int = 0
|
|
is_verified: bool = False # 本人確認済
|
|
|
|
|
|
class RakumaSearchItem(BaseModel):
|
|
"""ラクマ 搜索结果中的单个商品"""
|
|
|
|
item_id: str = "" # 商品页 hash,调详情接口用
|
|
item_number: str = "" # 站点内部数值商品 ID
|
|
item_name: str = ""
|
|
item_url: str = ""
|
|
price: int = 0
|
|
image_url: str = ""
|
|
is_sold_out: bool = False
|
|
brand_id: str = ""
|
|
brand_name: str = ""
|
|
category_id: str = ""
|
|
category_names: list[str] = Field(default_factory=list)
|
|
seller_user_id: str = "" # 卖家数值 ID;店铺 hash 需从详情页取
|
|
seller_type: str = ""
|
|
|
|
|
|
class RakumaSearchResultData(BaseModel):
|
|
"""ラクマ 搜索结果数据"""
|
|
|
|
keyword: str = ""
|
|
page: int = 1
|
|
page_size: int = 0
|
|
# 站点声明的命中总数。页面上展示为「約1,190,000件」的四舍五入值,
|
|
# 这里取的是埋点属性里的精确值。
|
|
total_count: int = 0
|
|
has_more: bool = False
|
|
request_url: str = ""
|
|
items: list[RakumaSearchItem] = Field(default_factory=list)
|
|
|
|
|
|
class RakumaItemDetailData(BaseModel):
|
|
"""ラクマ 商品详情数据
|
|
|
|
C2C 集市的商品是单件的:没有 SKU 组合,没有库存数量,
|
|
「規格」在站点上只体现为一个可选的尺码字段。
|
|
"""
|
|
|
|
item_id: str = ""
|
|
item_number: str = ""
|
|
item_name: str = ""
|
|
description: str = ""
|
|
item_url: str = ""
|
|
price: int = 0
|
|
is_sold_out: bool = False
|
|
images: list[str] = Field(default_factory=list)
|
|
condition: str = "" # 商品の状態,站点原文如「目立った傷や汚れなし」
|
|
size: str = "" # サイズ,无尺码时为空
|
|
brand_id: str = ""
|
|
brand_name: str = ""
|
|
category_id: str = "" # 最具体的一级分类 ID
|
|
breadcrumbs: list[Breadcrumb] = Field(default_factory=list)
|
|
shipping_payer: str = "" # 配送料の負担,如「送料込」
|
|
shipping_method: str = "" # 配送方法
|
|
shipping_date_estimate: str = "" # 発送日の目安
|
|
shipping_from: str = "" # 発送元の地域
|
|
is_anonymous_shipping: bool = False # 匿名配送
|
|
like_count: int = 0 # いいね数
|
|
comment_count: int = 0
|
|
posted_at: str = "" # 站点展示的相对时间,如「約1時間前」
|
|
seller: RakumaSeller = Field(default_factory=RakumaSeller)
|
|
|
|
|
|
class RakumaReview(BaseModel):
|
|
"""ラクマ 卖家的一条交易评价"""
|
|
|
|
rating: str = "" # good / normal / bad
|
|
title: str = "" # 站点原文,如「よい出品者です」
|
|
comment: str = ""
|
|
reviewer_name: str = ""
|
|
reviewed_at: str = "" # 站点展示的日期,如 2026/05/04
|
|
|
|
|
|
class RakumaRatingBreakdown(BaseModel):
|
|
"""评价数量分档"""
|
|
|
|
good: int = 0
|
|
normal: int = 0
|
|
bad: int = 0
|
|
|
|
|
|
class RakumaShopDetailData(BaseModel):
|
|
"""ラクマ 卖家详情数据"""
|
|
|
|
shop_id: str = ""
|
|
user_id: str = ""
|
|
shop_name: str = ""
|
|
user_name: str = ""
|
|
shop_url: str = ""
|
|
icon_url: str = ""
|
|
cover_url: str = ""
|
|
introduction: str = "" # プロフィール文
|
|
review_score: float = 0.0
|
|
review_count: int = 0
|
|
is_verified: bool = False # 本人確認済
|
|
verification_label: str = "" # 站点原文,如「本人確認済」/「本人確認未完了」
|
|
item_count: int = 0 # 该卖家在售 + 已售商品总数
|
|
# 以下三项需 include_reviews=true 才会填充
|
|
rating_breakdown: RakumaRatingBreakdown = Field(default_factory=RakumaRatingBreakdown)
|
|
seller_rating_breakdown: RakumaRatingBreakdown = Field(default_factory=RakumaRatingBreakdown)
|
|
reviews: list[RakumaReview] = Field(default_factory=list)
|
|
|
|
|
|
class RakumaShopItemsData(BaseModel):
|
|
"""ラクマ 卖家商品列表数据"""
|
|
|
|
shop_id: str = ""
|
|
shop_name: str = ""
|
|
page: int = 1
|
|
total_count: int = 0 # 该卖家的商品总数
|
|
has_more: bool = False
|
|
request_url: str = ""
|
|
items: list[RakumaSearchItem] = Field(default_factory=list)
|